From 5b21d78d0a9422ca0245b6974f4badf194c4a7e7 Mon Sep 17 00:00:00 2001 From: Juan Carlos Blanco Delgado Date: Tue, 26 Aug 2025 20:27:31 +0100 Subject: [PATCH 1/8] fix(telemetry): improve error logging for custom trace_id generation #7909 --- .cargo/config.toml | 4 + ...ify_authorization_directive_composition.md | 5 - .../feat_add_dns_resolution_strategy.md | 28 - ...eat_feat-enhance-error-logging-trace-id.md | 46 + .changesets/feat_geal_subgraph_request_id.md | 5 - ...ble_generate_query_fragments_by_default.md | 26 - .changesets/feat_max_headers.md | 19 - .changesets/fix_duckki_fed_678.md | 5 + ...en_log_less_error_for_subgraph_batching.md | 7 - .../fix_geal_deduplication_processing_time.md | 5 - .../fix_geal_introspection_dedup_fix.md | 5 - .../fix_geal_response_validation_errors.md | 5 - .changesets/fix_renee_limit_errors.md | 6 - ...inesling_remove_demand_control_warnings.md | 5 - .../fix_tninesling_typename_resolution.md | 5 - .changesets/helm_host_configuration.md | 6 - .../maint_bnjjj_fix_supergraph_events_span.md | 5 - ...al_query_planner_cache_key_improvements.md | 8 - .gitmodules => .changesets/sedceNmNQ | 0 .circleci/config.yml | 663 +- .config/mise/config.ci-mac.toml | 5 + .config/mise/config.ci.toml | 4 + .config/mise/config.toml | 19 + .config/mise/config.windows.toml | 2 + .config/nextest.toml | 153 +- .git-blame-ignore-revs | 9 + .githooks/prepare-commit-msg | 8 - .github/CODEOWNERS | 15 +- .github/pull_request_template.md | 21 +- .github/workflows/update_apollo_protobuf.yaml | 8 +- .github/workflows/update_uplink_schema.yml | 8 +- .gitignore | 14 + .gitleaks.toml | 2 + .gitmessage | 7 - .ignore | 1 - .mergify.yml | 18 + CHANGELOG.md | 15167 ++-------------- CONTRIBUTING.md | 2 - Cargo.lock | 5822 +++--- Cargo.toml | 38 +- DEVELOPMENT.md | 42 +- README.md | 2 - RELEASE_CHECKLIST.md | 293 +- ROADMAP.md | 51 - about.toml | 9 +- apollo-federation/Cargo.toml | 58 +- apollo-federation/README.md | 2 +- apollo-federation/cli/Cargo.toml | 15 +- apollo-federation/cli/src/bench.rs | 2 +- apollo-federation/cli/src/main.rs | 407 +- apollo-federation/examples/api_schema.rs | 6 +- apollo-federation/src/api_schema.rs | 4 +- apollo-federation/src/compat.rs | 10 +- apollo-federation/src/composition/mod.rs | 121 + .../src/composition/satisfiability.rs | 205 + .../satisfiability/conditions_validation.rs | 316 + .../satisfiability/satisfiability_error.rs | 593 + .../satisfiability/validation_context.rs | 282 + .../satisfiability/validation_state.rs | 463 + .../satisfiability/validation_traversal.rs | 311 + .../src/connectors/expand/carryover.rs | 670 + .../src/connectors/expand/carryover/inputs.rs | 338 + .../expand/merge/basic_1.graphql | 8 + .../expand/merge/basic_2.graphql | 11 + .../merge/connector_Query_user_0.graphql | 0 .../merge/connector_Query_users_0.graphql | 0 .../expand/merge/connector_User_d_1.graphql | 0 .../expand/merge/graphql.graphql | 0 .../expand/merge/inaccessible.graphql | 0 .../expand/merge/inaccessible_2.graphql | 0 .../src/connectors/expand/mod.rs | 850 + ...__expand__carryover__tests__carryover.snap | 110 + .../src/connectors/expand/tests/mod.rs | 42 + .../expand/tests/schemas/expand/batch.graphql | 71 + .../expand/tests/schemas/expand/batch.yaml | 25 + .../tests/schemas/expand/carryover.graphql | 202 + .../tests/schemas/expand/carryover.yaml | 177 + .../tests/schemas/expand/directives.graphql | 135 + .../tests/schemas/expand/directives.yaml | 82 + .../schemas/expand/interface-object.graphql | 97 + .../schemas/expand/interface-object.yaml | 59 + .../expand/tests/schemas/expand/keys.graphql | 88 + .../expand/tests/schemas/expand/keys.yaml | 44 + .../schemas/expand/nested_inputs.graphql | 75 + .../tests/schemas/expand/nested_inputs.yaml | 29 + .../schemas/expand/normalize_names.graphql | 71 + .../tests/schemas/expand/normalize_names.yaml | 25 + .../tests/schemas/expand/realistic.graphql | 154 + .../tests/schemas/expand/realistic.yaml | 124 + .../schemas/expand/sibling_fields.graphql | 75 + .../tests/schemas/expand/sibling_fields.yaml | 35 + .../tests/schemas/expand/simple.graphql | 76 + .../expand/tests/schemas/expand/simple.yaml | 41 + .../tests/schemas/expand/steelthread.graphql | 79 + .../tests/schemas/expand/steelthread.yaml | 66 + .../schemas/expand/types_used_twice.graphql | 81 + .../schemas/expand/types_used_twice.yaml | 29 + .../tests/schemas/ignore/ignored.graphql | 57 + .../expand/tests/schemas/ignore/ignored.yaml | 16 + .../expand/tests/schemas/regenerate.sh | 26 + .../tests/snapshots/api@batch.graphql.snap | 16 + .../snapshots/api@carryover.graphql.snap | 48 + .../snapshots/api@directives.graphql.snap | 33 + .../api@interface-object.graphql.snap | 34 + .../tests/snapshots/api@keys.graphql.snap | 30 + .../snapshots/api@nested_inputs.graphql.snap | 19 + .../api@normalize_names.graphql.snap | 17 + .../snapshots/api@realistic.graphql.snap | 80 + .../snapshots/api@sibling_fields.graphql.snap | 19 + .../tests/snapshots/api@simple.graphql.snap | 19 + .../snapshots/api@steelthread.graphql.snap | 21 + .../api@types_used_twice.graphql.snap | 23 + .../snapshots/connectors@batch.graphql.snap | 337 + .../connectors@carryover.graphql.snap | 790 + .../connectors@directives.graphql.snap | 6 + .../connectors@interface-object.graphql.snap | 492 + .../snapshots/connectors@keys.graphql.snap | 1637 ++ .../connectors@nested_inputs.graphql.snap | 299 + .../connectors@normalize_names.graphql.snap | 343 + .../connectors@realistic.graphql.snap | 1644 ++ .../connectors@sibling_fields.graphql.snap | 288 + .../snapshots/connectors@simple.graphql.snap | 559 + .../connectors@steelthread.graphql.snap | 501 + .../connectors@types_used_twice.graphql.snap | 238 + .../snapshots/supergraph@batch.graphql.snap | 66 + .../supergraph@carryover.graphql.snap | 154 + .../supergraph@directives.graphql.snap | 111 + .../supergraph@interface-object.graphql.snap | 86 + .../snapshots/supergraph@keys.graphql.snap | 85 + .../supergraph@nested_inputs.graphql.snap | 65 + .../supergraph@normalize_names.graphql.snap | 64 + .../supergraph@realistic.graphql.snap | 133 + .../supergraph@sibling_fields.graphql.snap | 69 + .../snapshots/supergraph@simple.graphql.snap | 71 + .../supergraph@steelthread.graphql.snap | 71 + .../supergraph@types_used_twice.graphql.snap | 69 + .../src/connectors/expand/visitors/input.rs | 133 + .../src/connectors/expand/visitors/mod.rs | 424 + .../connectors/expand/visitors/selection.rs | 202 + apollo-federation/src/connectors/header.rs | 530 + apollo-federation/src/connectors/id.rs | 238 + .../src/connectors/json_selection/README.md | 1177 ++ .../src/connectors/json_selection/apply_to.rs | 4777 +++++ .../src/connectors/json_selection/fixtures.rs | 30 + .../json_selection/grammar/Alias.svg | 53 + .../json_selection/grammar/AtPath.svg} | 46 +- .../json_selection/grammar/Comment.svg | 49 + .../json_selection/grammar/ExprPath.svg | 68 + .../json_selection/grammar/Identifier.svg | 22 +- .../json_selection/grammar/JSONSelection.svg} | 37 +- .../json_selection/grammar/Key.svg | 40 +- .../json_selection/grammar/KeyPath.svg | 52 + .../json_selection/grammar/LitArray.svg | 77 + .../json_selection/grammar/LitExpr.svg | 80 + .../json_selection/grammar/LitNumber.svg | 71 + .../json_selection/grammar/LitObject.svg | 77 + .../json_selection/grammar/LitOp.svg | 54 + .../json_selection/grammar/LitOpChain.svg | 59 + .../json_selection/grammar/LitPath.svg | 66 + .../json_selection/grammar/LitPrimitive.svg | 76 + .../json_selection/grammar/LitProperty.svg} | 40 +- .../json_selection/grammar/LitString.svg | 92 + .../json_selection/grammar/MethodArgs.svg | 77 + .../json_selection/grammar/NamedSelection.svg | 74 + .../grammar/NonEmptyPathTail.svg | 69 + .../json_selection/grammar/PathSelection.svg | 52 + .../json_selection/grammar/PathStep.svg | 22 +- .../json_selection/grammar/PathTail.svg | 53 + .../json_selection/grammar/Spaces.svg | 22 +- .../grammar/SpacesOrComments.svg | 22 +- .../json_selection/grammar/SubSelection.svg | 61 + .../json_selection/grammar/VarPath.svg | 67 + .../json_selection/grammar/rr-2.5.svg | 57 + .../src/connectors/json_selection/helpers.rs | 343 + .../connectors/json_selection/immutable.rs | 56 + .../connectors/json_selection/known_var.rs | 44 + .../src/connectors/json_selection/lit_expr.rs | 1178 ++ .../src/connectors/json_selection/location.rs | 397 + .../src/connectors/json_selection/methods.rs | 279 + .../json_selection/methods/common.rs | 146 + .../json_selection/methods/future/has.rs | 189 + .../json_selection/methods/future/keys.rs | 145 + .../json_selection/methods/future/match_if.rs | 140 + .../json_selection/methods/future/mod.rs | 14 + ...orrect_call_methods_with_extra_spaces.snap | 140 + .../json_selection/methods/future/typeof.rs | 97 + .../json_selection/methods/future/values.rs | 128 + .../json_selection/methods/public/and.rs | 363 + .../methods/public/arithmetic.rs | 931 + .../json_selection/methods/public/contains.rs | 607 + .../json_selection/methods/public/echo.rs | 214 + .../json_selection/methods/public/entries.rs | 247 + .../json_selection/methods/public/eq.rs | 500 + .../json_selection/methods/public/filter.rs | 441 + .../json_selection/methods/public/find.rs | 444 + .../json_selection/methods/public/first.rs | 136 + .../json_selection/methods/public/get.rs | 1280 ++ .../json_selection/methods/public/gt.rs | 468 + .../json_selection/methods/public/gte.rs | 464 + .../json_selection/methods/public/in.rs | 633 + .../methods/public/join_not_null.rs | 365 + .../methods/public/json_stringify.rs | 119 + .../json_selection/methods/public/last.rs | 159 + .../json_selection/methods/public/lt.rs | 468 + .../json_selection/methods/public/lte.rs | 464 + .../json_selection/methods/public/map.rs | 193 + .../json_selection/methods/public/match.rs | 237 + .../json_selection/methods/public/mod.rs | 59 + .../json_selection/methods/public/ne.rs | 499 + .../json_selection/methods/public/not.rs | 237 + .../json_selection/methods/public/or.rs | 343 + .../methods/public/parse_int.rs | 917 + .../json_selection/methods/public/size.rs | 237 + .../json_selection/methods/public/slice.rs | 221 + ...orrect_call_methods_with_extra_spaces.snap | 140 + .../methods/public/to_string.rs | 403 + .../json_selection/methods/tests.rs | 23 + .../src/connectors/json_selection/mod.rs | 85 + .../src/connectors/json_selection/parser.rs | 4225 +++++ .../src/connectors/json_selection/pretty.rs | 537 + .../json_selection/selection_set.rs | 742 + .../json_selection/selection_trie.rs | 435 + .../snapshots/arrow_path_ranges.snap | 84 + .../snapshots/basic_spread_parsing-10.snap | 166 + .../snapshots/basic_spread_parsing-2.snap | 76 + .../snapshots/basic_spread_parsing-3.snap | 72 + .../snapshots/basic_spread_parsing-4.snap | 72 + .../snapshots/basic_spread_parsing-5.snap | 98 + .../snapshots/basic_spread_parsing-6.snap | 80 + .../snapshots/basic_spread_parsing-7.snap | 106 + .../snapshots/basic_spread_parsing-8.snap | 110 + .../snapshots/basic_spread_parsing-9.snap | 136 + .../snapshots/basic_spread_parsing.snap | 46 + .../basic_spread_parsing_one_field.snap | 46 + .../json_selection/snapshots/check_many.snap | 46 + .../snapshots/error_snapshots-2.snap | 12 + .../snapshots/error_snapshots.snap | 12 + .../snapshots/error_snapshots_v0_2-2.snap | 12 + .../snapshots/error_snapshots_v0_2.snap | 12 + .../snapshots/error_snapshots_v0_3-2.snap | 12 + .../snapshots/error_snapshots_v0_3.snap | 12 + .../snapshots/expr_path_selections-2.snap | 173 + .../snapshots/expr_path_selections.snap | 173 + ...naked_literal_path_for_connect_v0_2-2.snap | 107 + ...naked_literal_path_for_connect_v0_2-3.snap | 46 + ...naked_literal_path_for_connect_v0_2-4.snap | 46 + ...naked_literal_path_for_connect_v0_2-5.snap | 76 + ...naked_literal_path_for_connect_v0_2-6.snap | 100 + .../naked_literal_path_for_connect_v0_2.snap | 57 + .../snapshots/optional_field_selections.snap | 116 + .../snapshots/parse_with_range_snapshots.snap | 590 + .../snapshots/path_expr_with_spaces_v0_2.snap | 173 + .../snapshots/path_expr_with_spaces_v0_3.snap | 173 + .../snapshots/path_with_subselection-2.snap | 211 + .../snapshots/path_with_subselection-3.snap | 316 + .../snapshots/path_with_subselection-4.snap | 12 + .../snapshots/path_with_subselection-5.snap | 172 + .../snapshots/path_with_subselection-6.snap | 213 + .../snapshots/path_with_subselection-7.snap | 332 + .../snapshots/path_with_subselection.snap | 121 + .../json_selection/snapshots/selection.snap | 425 + .../snapshots/single_key_paths-2.snap | 140 + .../snapshots/single_key_paths-3.snap | 114 + .../snapshots/single_key_paths.snap | 140 + .../snapshots/single_key_paths_v0_2.snap | 140 + .../snapshots/single_key_paths_v0_3-2.snap | 114 + .../snapshots/single_key_paths_v0_3-3.snap | 138 + .../snapshots/single_key_paths_v0_3.snap | 140 + .../snapshots/spread_parsing_a_spread_b.snap | 72 + .../snapshots/spread_parsing_spread_a_b.snap | 72 + .../spread_parsing_spread_a_b_c.snap | 98 + .../spread_parsing_spread_a_spread_b.snap | 76 + .../spread_parsing_spread_a_sub_b_c.snap | 106 + ...pread_parsing_spread_a_sub_b_spread_c.snap | 110 + ...ead_parsing_spread_a_sub_b_spread_c_d.snap | 136 + ...ng_spread_a_sub_spread_b_c_d_spread_e.snap | 166 + .../spread_parsing_spread_spread_a_sub_b.snap | 80 + .../unambiguous_single_key_paths_v0_2.snap | 140 + .../unambiguous_single_key_paths_v0_3.snap | 140 + .../snapshots/valid_single_key_path_v0_3.snap | 114 + ...valid_single_key_path_with_alias_v0_3.snap | 138 + apollo-federation/src/connectors/mod.rs | 242 + apollo-federation/src/connectors/models.rs | 835 + .../src/connectors/models/headers.rs | 263 + .../connectors/models/http_json_transport.rs | 776 + .../src/connectors/models/keys.rs | 99 + .../src/connectors/models/problem_location.rs | 20 + .../src/connectors/models/source.rs | 240 + apollo-federation/src/connectors/runtime.rs | 8 + .../src/connectors/runtime/debug.rs | 347 + .../src/connectors/runtime/errors.rs | 115 + .../src/connectors/runtime/form_encoding.rs | 139 + .../connectors/runtime/http_json_transport.rs | 421 + .../src/connectors/runtime/inputs.rs | 247 + .../src/connectors/runtime/key.rs | 131 + .../src/connectors/runtime/mapping.rs | 70 + .../src/connectors/runtime/responses.rs | 411 + ...tors__models__tests__from_schema_v0_2.snap | 436 + ...parse__expressions_with_nested_braces.snap | 134 + ..._parse__mixed_constant_and_expression.snap | 66 + ...__string_template__test_parse__offset.snap | 63 + ...template__test_parse__simple_constant.snap | 14 + ...mplate__test_parse__simple_expression.snap | 54 + ...e__test_parse__absolute_url_with_path.snap | 48 + ...arse__absolute_url_with_path_variable.snap | 85 + ...__test_parse__absolute_url_with_query.snap | 50 + ...rse__absolute_url_with_query_variable.snap | 87 + ...plate__test_parse__basic_absolute_url.snap | 27 + ...e__expression_missing_closing_bracket.snap | 10 + ...st_parse__nested_braces_in_expression.snap | 104 + ...url_template__test_parse__path_list-2.snap | 32 + ...url_template__test_parse__path_list-3.snap | 69 + ...url_template__test_parse__path_list-4.snap | 94 + ...__url_template__test_parse__path_list.snap | 22 + ...test_parse__url_path_template_parse-2.snap | 262 + ...test_parse__url_path_template_parse-3.snap | 135 + ...test_parse__url_path_template_parse-4.snap | 118 + ...__test_parse__url_path_template_parse.snap | 92 + ...plate__test_parse__variable_param_key.snap | 138 + .../src/connectors/spec/connect.rs | 756 + .../src/connectors/spec/errors.rs | 64 + apollo-federation/src/connectors/spec/http.rs | 14 + apollo-federation/src/connectors/spec/mod.rs | 296 + .../src/connectors/spec/source.rs | 430 + .../spec/type_and_directive_specifications.rs | 871 + .../src/connectors/string_template.rs | 806 + .../tests/schemas/duplicated_id.graphql | 126 + .../tests/schemas/duplicated_id.yaml | 47 + .../tests/schemas/is-success-source.graphql | 126 + .../tests/schemas/is-success-source.yaml | 48 + .../tests/schemas/is-success.graphql | 126 + .../connectors/tests/schemas/is-success.yaml | 49 + .../connectors/tests/schemas/simple.graphql | 125 + .../src/connectors/tests/schemas/simple.yaml | 47 + .../tests/schemas/simple_v0_2.graphql | 82 + .../connectors/tests/schemas/simple_v0_2.yaml | 65 + .../tests/schemas/source-template.graphql | 125 + .../tests/schemas/source-template.yaml | 51 + .../src/connectors/validation/connect.rs | 417 + .../connectors/validation/connect/entity.rs | 351 + .../src/connectors/validation/connect/http.rs | 434 + .../validation/connect/selection.rs | 475 + .../validation/connect/selection/variables.rs | 265 + .../src/connectors/validation/coordinates.rs | 209 + .../src/connectors/validation/errors.rs | 405 + .../src/connectors/validation/expression.rs | 954 + .../src/connectors/validation/graphql.rs | 92 + .../connectors/validation/graphql/strings.rs | 285 + .../src/connectors/validation/http.rs | 4 + .../src/connectors/validation/http/headers.rs | 149 + .../src/connectors/validation/http/url.rs | 55 + .../validation/http/url_properties.rs | 207 + .../src/connectors/validation/mod.rs | 338 + .../src/connectors/validation/schema.rs | 515 + .../src/connectors/validation/schema/keys.rs | 242 + .../snapshots/validation_tests.snap | 6 + ...ion_tests@all_fields_selected.graphql.snap | 42 + ...sts@all_fields_selected_repro.graphql.snap | 35 + .../validation_tests@batch.graphql.snap | 106 + ...batch__batch_alias_happy_path.graphql.snap | 6 + ...__batch_incorrect_context_key.graphql.snap | 14 + ...@batch__batch_incorrect_field.graphql.snap | 21 + ...ests@batch__batch_missing_key.graphql.snap | 14 + ...tch__batch_missing_nested_key.graphql.snap | 14 + ...ests@batch__batch_nested_keys.graphql.snap | 21 + ...lidation_tests@body_selection.graphql.snap | 35 + ...tion_tests@circular_reference.graphql.snap | 15 + ...on_tests@circular_reference_2.graphql.snap | 15 + ...on_tests@circular_reference_3.graphql.snap | 14 + ...@connect_source_name_mismatch.graphql.snap | 14 + ...ests@connect_source_undefined.graphql.snap | 14 + ...ts@connect_spec_version_error.graphql.snap | 14 + ...lidation_tests@denest_scalars.graphql.snap | 15 + ...idation_tests@denest_scalars2.graphql.snap | 15 + ...sts@disallowed_abstract_types.graphql.snap | 21 + ...disallowed_federation_imports.graphql.snap | 21 + ...n_tests@duplicate_source_name.graphql.snap | 15 + ...lidation_tests@duplicated_ids.graphql.snap | 15 + ...idation_tests@empty_selection.graphql.snap | 21 + ...ation_tests@empty_source_name.graphql.snap | 14 + .../validation_tests@env-vars.graphql.snap | 35 + .../validation_tests@errors.graphql.snap | 58 + ...n_tests@fields_with_arguments.graphql.snap | 15 + ...sts@group_selection_on_scalar.graphql.snap | 15 + ...ders__disallowed_header_names.graphql.snap | 182 + ..._disallowed_header_names_v0.2.graphql.snap | 182 + ...valuate_to_invalid_types_v0_2.graphql.snap | 23 + ...valuate_to_invalid_types_v0_3.graphql.snap | 38 + ..._invalid_connect_http_headers.graphql.snap | 72 + ...namespace_in_header_variables.graphql.snap | 56 + ...ted_paths_in_header_variables.graphql.snap | 35 + ...__invalid_source_http_headers.graphql.snap | 65 + ...@invalid_chars_in_source_name.graphql.snap | 35 + ...idation_tests@invalid_id_name.graphql.snap | 14 + ...d_namespace_in_body_selection.graphql.snap | 14 + ...ested_paths_in_json_selection.graphql.snap | 35 + ...ests@invalid_selection_syntax.graphql.snap | 14 + ...is_success_connect_happy_path.graphql.snap | 6 + ...s__is_success_invalid_connect.graphql.snap | 14 + ...s__is_success_invalid_sources.graphql.snap | 14 + ..._is_success_source_happy_path.graphql.snap | 6 + ...rg_is_object_but_field_is_not.graphql.snap | 15 + ..._type_doesnt_match_field_type.graphql.snap | 15 + ...d__composite_key_doesnt_match.graphql.snap | 14 + ...y_arg_field_arg_name_mismatch.graphql.snap | 15 + ...g_name_mismatch_composite_key.graphql.snap | 15 + ...invalid__entity_false_on_type.graphql.snap | 14 + ...lid__entity_true_on_list_type.graphql.snap | 14 + ...entity_true_on_non_root_field.graphql.snap | 14 + ..._true_returning_non_null_type.graphql.snap | 14 + ..._entity_true_returning_scalar.graphql.snap | 14 + ...ties__invalid__mismatch_batch.graphql.snap | 14 + ..._mismatch_composite_key_batch.graphql.snap | 14 + ...ultiple_keys_not_all_resolved.graphql.snap | 14 + ...e_keys_not_all_resolved_batch.graphql.snap | 14 + ...alid__no_args_for_entity_true.graphql.snap | 14 + ...ties__invalid__unrelated_keys.graphql.snap | 14 + ...es__valid__basic_implicit_key.graphql.snap | 6 + ...lid__basic_implicit_key_batch.graphql.snap | 6 + ...or_matches_non_resolvable_key.graphql.snap | 6 + ...ches_non_resolvable_key_batch.graphql.snap | 6 + ..._matches_one_of_multiple_keys.graphql.snap | 6 + ...es_one_of_multiple_keys_batch.graphql.snap | 6 + ..._field_counts_as_key_resolver.graphql.snap | 6 + ..._counts_as_key_resolver_batch.graphql.snap | 6 + ...s__valid__entity_true_on_type.graphql.snap | 6 + ...id__mix_explicit_and_implicit.graphql.snap | 6 + ...x_explicit_and_implicit_batch.graphql.snap | 6 + ..._connectors_for_multiple_keys.graphql.snap | 6 + ...ctors_for_multiple_keys_batch.graphql.snap | 6 + ...ing_connect_on_mutation_field.graphql.snap | 14 + ...issing_connect_on_query_field.graphql.snap | 14 + ...issing_http_method_on_connect.graphql.snap | 14 + ...n_tests@missing_source_import.graphql.snap | 21 + ...idation_tests@multiple_errors.graphql.snap | 21 + ...tiple_http_methods_on_connect.graphql.snap | 15 + ...s@non_root_circular_reference.graphql.snap | 16 + ...alidation_tests@question_v0_2.graphql.snap | 28 + ...alidation_tests@question_v0_3.graphql.snap | 14 + ...sts@renamed_connect_directive.graphql.snap | 14 + ...idation_tests@request_headers.graphql.snap | 59 + ...ests@select_nonexistant_group.graphql.snap | 14 + ...tests@source_directive_rename.graphql.snap | 14 + ...subscriptions_with_connectors.graphql.snap | 14 + ...ts@transformed__upgrade_0.1.graphql-2.snap | 7 + ...ests@transformed__upgrade_0.1.graphql.snap | 6 + ...olute_connect_url_with_source.graphql.snap | 14 + ...plates__expressions-in-domain.graphql.snap | 35 + ...d-jsonselection-in-expression.graphql.snap | 14 + ...lates__invalid-path-parameter.graphql.snap | 14 + ...emplates__invalid_connect_url.graphql.snap | 14 + ...s__invalid_connect_url_scheme.graphql.snap | 14 + ...ace_in_url_template_variables.graphql.snap | 28 + ...ths_in_url_template_variables.graphql.snap | 35 + ...templates__invalid_source_url.graphql.snap | 14 + ...es__invalid_source_url_scheme.graphql.snap | 14 + ...__invalid_source_url_template.graphql.snap | 28 + ...@uri_templates__invalid_types.graphql.snap | 45 + ...ve_connect_url_without_source.graphql.snap | 21 + ...templates__this_on_root_types.graphql.snap | 21 + ...undefined_arg_in_url_template.graphql.snap | 21 + ...ndefined_this_in_url_template.graphql.snap | 21 + ...alid-expressions-after-domain.graphql.snap | 6 + ...id_connect_absolute_multiline.graphql.snap | 6 + ...s__valid_connect_absolute_url.graphql.snap | 6 + ...ates__valid_connect_multiline.graphql.snap | 6 + ...es__valid_source_url_template.graphql.snap | 6 + ..._properties__invalid_mappings.graphql.snap | 35 + ...on_tests@url_properties__path.graphql.snap | 58 + ...@url_properties__query_params.graphql.snap | 58 + ...dation_tests@valid_large_body.graphql.snap | 6 + ...nnect_on_resolvable_key_field.graphql.snap | 6 + ...@valid_selection_with_escapes.graphql.snap | 21 + .../src/connectors/validation/source.rs | 249 + .../test_data/all_fields_selected.graphql | 73 + .../all_fields_selected_repro.graphql | 19 + .../validation/test_data/batch.graphql | 91 + .../batch/batch_alias_happy_path.graphql | 29 + .../batch/batch_incorrect_context_key.graphql | 29 + .../batch/batch_incorrect_field.graphql | 29 + .../test_data/batch/batch_missing_key.graphql | 28 + .../batch/batch_missing_nested_key.graphql | 28 + .../test_data/batch/batch_nested_keys.graphql | 24 + .../test_data/body_selection.graphql | 56 + .../test_data/circular_reference.graphql | 15 + .../test_data/circular_reference_2.graphql | 26 + .../test_data/circular_reference_3.graphql | 16 + .../connect_source_name_mismatch.graphql | 11 + .../connect_source_undefined.graphql | 10 + .../connect_spec_version_error.graphql | 5 + .../test_data/denest_scalars.graphql | 31 + .../test_data/denest_scalars2.graphql | 26 + .../disallowed_abstract_types.graphql | 48 + .../disallowed_federation_imports.graphql | 22 + .../test_data/duplicate_source_name.graphql | 12 + .../test_data/duplicated_ids.graphql | 29 + .../test_data/empty_selection.graphql | 15 + .../test_data/empty_source_name.graphql | 11 + .../validation/test_data/env-vars.graphql | 44 + .../validation/test_data/errors.graphql | 120 + .../test_data/fields_with_arguments.graphql | 21 + .../group_selection_on_scalar.graphql | 13 + .../headers/disallowed_header_names.graphql | 78 + .../disallowed_header_names_v0.2.graphql | 78 + ...hat_evaluate_to_invalid_types_v0_2.graphql | 24 + ...hat_evaluate_to_invalid_types_v0_3.graphql | 24 + .../invalid_connect_http_headers.graphql | 31 + ...alid_namespace_in_header_variables.graphql | 32 + ...d_nested_paths_in_header_variables.graphql | 60 + .../invalid_source_http_headers.graphql | 33 + .../invalid_chars_in_source_name.graphql | 31 + .../test_data/invalid_id_name.graphql | 11 + ...nvalid_namespace_in_body_selection.graphql | 25 + ...lid_nested_paths_in_json_selection.graphql | 52 + .../invalid_selection_syntax.graphql | 9 + .../is_success_connect_happy_path.graphql | 42 + .../is_success_invalid_connect.graphql | 42 + .../is_success_invalid_sources.graphql | 44 + .../is_success_source_happy_path.graphql | 41 + .../arg_is_object_but_field_is_not.graphql | 21 + .../arg_type_doesnt_match_field_type.graphql | 17 + .../composite_key_doesnt_match.graphql | 47 + ...entity_arg_field_arg_name_mismatch.graphql | 17 + ...ld_arg_name_mismatch_composite_key.graphql | 44 + .../invalid/entity_false_on_type.graphql | 12 + .../invalid/entity_true_on_list_type.graphql | 16 + .../entity_true_on_non_root_field.graphql | 22 + ...ntity_true_returning_non_null_type.graphql | 16 + .../entity_true_returning_scalar.graphql | 11 + .../invalid/mismatch_batch.graphql | 12 + .../mismatch_composite_key_batch.graphql | 46 + .../multiple_keys_not_all_resolved.graphql | 50 + ...ltiple_keys_not_all_resolved_batch.graphql | 41 + .../invalid/no_args_for_entity_true.graphql | 17 + .../invalid/unrelated_keys.graphql | 28 + .../valid/basic_implicit_key.graphql | 16 + .../valid/basic_implicit_key_batch.graphql | 11 + ...nnector_matches_non_resolvable_key.graphql | 17 + ...r_matches_non_resolvable_key_batch.graphql | 13 + ...ector_matches_one_of_multiple_keys.graphql | 47 + ...matches_one_of_multiple_keys_batch.graphql | 48 + ...ntity_field_counts_as_key_resolver.graphql | 37 + ...field_counts_as_key_resolver_batch.graphql | 32 + .../valid/entity_true_on_type.graphql | 12 + .../valid/mix_explicit_and_implicit.graphql | 24 + .../mix_explicit_and_implicit_batch.graphql | 20 + ...ntity_connectors_for_multiple_keys.graphql | 31 + ...connectors_for_multiple_keys_batch.graphql | 26 + .../missing_connect_on_mutation_field.graphql | 10 + .../missing_connect_on_query_field.graphql | 10 + .../missing_http_method_on_connect.graphql | 10 + .../test_data/missing_source_import.graphql | 8 + .../test_data/multiple_errors.graphql | 11 + .../multiple_http_methods_on_connect.graphql | 15 + .../non_root_circular_reference.graphql | 42 + .../test_data/question_v0_2.graphql | 40 + .../test_data/question_v0_3.graphql | 41 + .../renamed_connect_directive.graphql | 10 + .../test_data/request_headers.graphql | 48 + .../select_nonexistant_group.graphql | 13 + .../test_data/source_directive_rename.graphql | 11 + .../subscriptions_with_connectors.graphql | 15 + .../test_data/transformed/upgrade_0.1.graphql | 17 + .../absolute_connect_url_with_source.graphql | 15 + .../expressions-in-domain.graphql | 28 + ...nvalid-jsonselection-in-expression.graphql | 15 + .../invalid-path-parameter.graphql | 15 + .../uri_templates/invalid_connect_url.graphql | 6 + .../invalid_connect_url_scheme.graphql | 7 + ...amespace_in_url_template_variables.graphql | 29 + ...ed_paths_in_url_template_variables.graphql | 40 + .../uri_templates/invalid_source_url.graphql | 11 + .../invalid_source_url_scheme.graphql | 11 + .../invalid_source_url_template.graphql | 15 + .../uri_templates/invalid_types.graphql | 42 + ...elative_connect_url_without_source.graphql | 7 + .../uri_templates/this_on_root_types.graphql | 23 + .../undefined_arg_in_url_template.graphql | 13 + .../undefined_this_in_url_template.graphql | 22 + .../valid-expressions-after-domain.graphql | 23 + .../valid_connect_absolute_multiline.graphql | 25 + .../valid_connect_absolute_url.graphql | 12 + .../valid_connect_multiline.graphql | 10 + .../valid_source_url_template.graphql | 18 + .../url_properties/invalid_mappings.graphql | 18 + .../test_data/url_properties/path.graphql | 48 + .../url_properties/query_params.graphql | 70 + .../test_data/valid_large_body.graphql | 116 + .../valid_selection_with_escapes.graphql | 30 + apollo-federation/src/connectors/variable.rs | 179 + apollo-federation/src/correctness/mod.rs | 109 + .../src/correctness/query_plan_analysis.rs | 976 + .../correctness/query_plan_analysis_test.rs | 385 + .../src/correctness/query_plan_soundness.rs | 660 + .../correctness/query_plan_soundness_test.rs | 189 + .../src/correctness/response_shape.rs | 1510 ++ .../src/correctness/response_shape_compare.rs | 582 + .../response_shape_compare_test.rs | 342 + .../src/correctness/response_shape_test.rs | 519 + .../src/correctness/subgraph_constraint.rs | 155 + apollo-federation/src/display_helpers.rs | 32 +- apollo-federation/src/error/mod.rs | 1545 +- apollo-federation/src/error/suggestion.rs | 47 + apollo-federation/src/lib.rs | 368 +- apollo-federation/src/link/argument.rs | 38 +- .../src/link/authenticated_spec_definition.rs | 123 + .../src/link/cache_tag_spec_definition.rs | 95 + .../src/link/context_spec_definition.rs | 187 + .../src/link/cost_spec_definition.rs | 387 +- apollo-federation/src/link/database.rs | 201 +- .../src/link/federation_spec_definition.rs | 728 +- .../src/link/graphql_definition.rs | 4 +- .../src/link/inaccessible_spec_definition.rs | 133 +- .../src/link/join_spec_definition.rs | 1224 +- .../src/link/link_spec_definition.rs | 415 +- apollo-federation/src/link/mod.rs | 198 +- .../src/link/policy_spec_definition.rs | 170 + .../link/requires_scopes_spec_definition.rs | 173 + apollo-federation/src/link/spec.rs | 64 +- apollo-federation/src/link/spec_definition.rs | 199 +- .../src/link/tag_spec_definition.rs | 118 + apollo-federation/src/merge.rs | 709 +- apollo-federation/src/merge/fields.rs | 35 + ...pollo_federation__merge__tests__basic.snap | 56 +- ...ederation__merge__tests__inaccessible.snap | 39 +- ...federation__merge__tests__input_types.snap | 69 + ...sts__interface_implementing_interface.snap | 76 + ...ation__merge__tests__interface_object.snap | 83 + ...ederation__merge__tests__steel_thread.snap | 35 +- .../merge/testdata/input_types/one.graphql | 107 + .../one.graphql | 114 + .../testdata/interface_object/one.graphql | 109 + .../testdata/interface_object/three.graphql | 98 + .../testdata/interface_object/two.graphql | 93 + apollo-federation/src/merge/tests.rs | 123 + .../src/merger/compose_directive_manager.rs | 26 + .../src/merger/error_reporter.rs | 228 + apollo-federation/src/merger/hints.rs | 451 + apollo-federation/src/merger/merge_enum.rs | 706 + apollo-federation/src/merger/merge_field.rs | 1646 ++ apollo-federation/src/merger/merge_links.rs | 287 + apollo-federation/src/merger/merge_type.rs | 194 + apollo-federation/src/merger/merge_union.rs | 324 + apollo-federation/src/merger/merger.rs | 2257 +++ apollo-federation/src/merger/mod.rs | 10 + apollo-federation/src/operation/contains.rs | 63 +- .../src/operation/directive_list.rs | 33 +- apollo-federation/src/operation/merging.rs | 132 +- apollo-federation/src/operation/mod.rs | 1637 +- apollo-federation/src/operation/optimize.rs | 4297 +---- apollo-federation/src/operation/rebase.rs | 1006 +- .../src/operation/selection_map.rs | 102 +- apollo-federation/src/operation/simplify.rs | 131 +- .../src/operation/tests/defer.rs | 194 - apollo-federation/src/operation/tests/mod.rs | 2042 ++- .../src/query_graph/build_query_graph.rs | 806 +- .../src/query_graph/condition_resolver.rs | 204 +- .../src/query_graph/graph_path.rs | 4341 ++--- .../src/query_graph/graph_path/operation.rs | 2626 +++ .../src/query_graph/graph_path/transition.rs | 1011 + apollo-federation/src/query_graph/mod.rs | 266 +- apollo-federation/src/query_graph/output.rs | 26 +- .../src/query_graph/path_tree.rs | 178 +- .../src/query_plan/conditions.rs | 285 +- apollo-federation/src/query_plan/display.rs | 118 +- .../src/query_plan/fetch_dependency_graph.rs | 1278 +- .../fetch_dependency_graph_processor.rs | 80 +- apollo-federation/src/query_plan/generate.rs | 22 +- apollo-federation/src/query_plan/mod.rs | 107 +- .../src/query_plan/query_planner.rs | 746 +- .../query_plan/query_planning_traversal.rs | 289 +- .../non_local_selections_estimation.rs | 987 + .../src/query_plan/requires_selection.rs | 34 + .../src/query_plan/serializable_document.rs | 108 + .../schema/argument_composition_strategies.rs | 407 +- apollo-federation/src/schema/blueprint.rs | 361 + apollo-federation/src/schema/definitions.rs | 6 +- .../src/schema/directive_location.rs | 40 + apollo-federation/src/schema/field_set.rs | 382 +- .../schema/fixtures/field-set-alias.graphqls | 59 + .../schema/fixtures/shareable_fields.graphqls | 112 + .../src/schema/fixtures/used_fields.graphqls | 147 + apollo-federation/src/schema/locations.rs | 51 + apollo-federation/src/schema/mod.rs | 1129 +- apollo-federation/src/schema/position.rs | 2690 ++- apollo-federation/src/schema/referencer.rs | 211 +- .../src/schema/schema_upgrader.rs | 1505 ++ .../src/schema/subgraph_metadata.rs | 500 +- .../type_and_directive_specification.rs | 625 +- .../src/schema/validators/cache_tag.rs | 674 + .../src/schema/validators/context.rs | 164 + .../src/schema/validators/cost.rs | 30 + .../src/schema/validators/external.rs | 71 + .../src/schema/validators/from_context.rs | 3095 ++++ .../src/schema/validators/interface_object.rs | 166 + .../src/schema/validators/key.rs | 189 + .../src/schema/validators/list_size.rs | 154 + .../src/schema/validators/merged.rs | 372 + .../src/schema/validators/mod.rs | 369 + .../src/schema/validators/provides.rs | 187 + .../src/schema/validators/requires.rs | 155 + .../src/schema/validators/root_fields.rs | 137 + .../src/schema/validators/shareable.rs | 106 + .../src/schema/validators/tag.rs | 52 + .../sources/connect/json_selection/README.md | 860 - .../connect/json_selection/apply_to.rs | 1312 -- .../json_selection/grammar/Comment.svg | 49 - .../json_selection/grammar/JSArray.svg | 69 - .../json_selection/grammar/JSLiteral.svg | 66 - .../json_selection/grammar/JSNumber.svg | 75 - .../json_selection/grammar/JSONSelection.svg | 52 - .../json_selection/grammar/JSObject.svg | 69 - .../json_selection/grammar/JSPrimitive.svg | 76 - .../json_selection/grammar/KeyPath.svg | 52 - .../json_selection/grammar/MethodArgs.svg | 69 - .../grammar/NamedFieldSelection.svg | 59 - .../grammar/NamedGroupSelection.svg | 52 - .../grammar/NamedPathSelection.svg | 52 - .../grammar/NamedQuotedSelection.svg | 59 - .../json_selection/grammar/NamedSelection.svg | 66 - .../json_selection/grammar/PathSelection.svg | 59 - .../json_selection/grammar/StarSelection.svg | 60 - .../json_selection/grammar/StringLiteral.svg | 92 - .../json_selection/grammar/SubSelection.svg | 61 - .../json_selection/grammar/UnsignedInt.svg | 59 - .../json_selection/grammar/VarPath.svg | 67 - .../sources/connect/json_selection/graphql.rs | 197 - .../sources/connect/json_selection/helpers.rs | 151 - .../src/sources/connect/json_selection/mod.rs | 13 - .../sources/connect/json_selection/parser.rs | 1603 -- .../sources/connect/json_selection/pretty.rs | 352 - apollo-federation/src/sources/connect/mod.rs | 12 - .../src/sources/connect/url_path_template.rs | 1808 -- apollo-federation/src/sources/mod.rs | 1 - apollo-federation/src/subgraph/database.rs | 95 - apollo-federation/src/subgraph/mod.rs | 551 +- apollo-federation/src/subgraph/spec.rs | 164 +- apollo-federation/src/subgraph/typestate.rs | 1471 ++ .../src/supergraph/join_directive.rs | 313 + apollo-federation/src/supergraph/mod.rs | 797 +- apollo-federation/src/supergraph/schema.rs | 51 +- apollo-federation/src/supergraph/subgraph.rs | 5 +- .../src/utils/fallible_iterator.rs | 262 +- apollo-federation/src/utils/human_readable.rs | 182 + apollo-federation/src/utils/logging.rs | 10 +- apollo-federation/src/utils/mod.rs | 15 + .../src/utils/multi_index_map.rs | 35 + apollo-federation/src/utils/serde_bridge.rs | 77 + apollo-federation/tests/api_schema.rs | 526 +- .../tests/composition/compose_directive.rs | 1006 + .../tests/composition/demand_control.rs | 440 + apollo-federation/tests/composition/mod.rs | 62 + .../tests/composition/validation_errors.rs | 558 + apollo-federation/tests/composition_tests.rs | 2 +- apollo-federation/tests/core_test.rs | 422 + .../dhat_profiling/connectors_validation.rs | 24 + .../tests/dhat_profiling/query_plan.rs | 97 + .../tests/dhat_profiling/supergraph.rs | 70 + apollo-federation/tests/extract_subgraphs.rs | 196 +- apollo-federation/tests/main.rs | 2 + .../query_plan/build_query_plan_support.rs | 53 +- .../query_plan/build_query_plan_tests.rs | 122 +- .../build_query_plan_tests/cancel.rs | 116 + .../build_query_plan_tests/context.rs | 1306 ++ .../build_query_plan_tests/defer.rs | 1584 +- .../disable_subgraphs.rs | 161 + .../build_query_plan_tests/entities.rs | 142 + .../fetch_operation_names.rs | 12 +- .../fragment_autogeneration.rs | 584 +- .../handles_operations_with_directives.rs | 12 +- .../interface_object.rs | 105 +- .../introspection_typename_handling.rs | 4 +- .../merged_abstract_types_handling.rs | 3 + .../build_query_plan_tests/named_fragments.rs | 563 - .../named_fragments_expansion.rs | 369 + .../named_fragments_preservation.rs | 1384 -- .../build_query_plan_tests/overrides.rs | 94 +- .../overrides/shareable.rs | 6 +- .../build_query_plan_tests/requires.rs | 98 +- .../build_query_plan_tests/subscriptions.rs | 105 +- apollo-federation/tests/query_plan/mod.rs | 2 - .../query_plan/operation_validations_tests.rs | 1013 -- ..._typename_on_interface_object_type.graphql | 83 + .../defer_on_renamed_root_type.graphql | 66 + ...nt_entity_fetches_to_same_subgraph.graphql | 97 + .../it_expands_nested_fragments.graphql | 75 + ...tion_from_operation_with_fragments.graphql | 102 + .../it_preserves_directives.graphql | 68 + ..._directives_on_collapsed_fragments.graphql | 75 + ...override_a_field_from_an_interface.graphql | 90 + .../supergraphs/plan_with_check.graphql | 67 + .../set_context_one_subgraph.graphql | 87 + ...g_back_and_forth_between_subgraphs.graphql | 110 + ...cesses_a_different_top_level_query.graphql | 87 + ...t_before_key_resolution_transition.graphql | 95 + ...est_efficiently_merge_fetch_groups.graphql | 120 + ...set_context_test_fetched_as_a_list.graphql | 88 + ...ext_test_impacts_on_query_planning.graphql | 106 + ...et_context_test_variable_is_a_list.graphql | 87 + ...already_in_a_different_fetch_group.graphql | 88 + ...ariable_is_from_different_subgraph.graphql | 88 + ...est_variable_is_from_same_subgraph.graphql | 88 + ...est_with_type_conditions_for_union.graphql | 102 + .../test_callback_is_called.graphql | 67 + .../test_cancel_as_soon_as_possible.graphql | 67 + ...late_enough_that_planning_finishes.graphql | 67 + .../test_cancel_near_the_middle.graphql | 67 + ...ng_all_subgraph_jumps_causes_error.graphql | 74 + ...graph_jump_causes_other_to_be_used.graphql | 74 + ...ss_expensive_subgraph_jump_is_used.graphql | 74 + ...th_interface_object_does_not_crash.graphql | 86 + ..._subscription_results_in_an_error.graphql} | 0 ...a_subscription_results_in_an_error.graphql | 79 + ...a_subscription_results_in_an_error.graphql | 79 + .../supergraphs/works_with_key_chains.graphql | 11 +- ...osition_tests__can_compose_supergraph.snap | 27 +- ...ompose_types_from_different_subgraphs.snap | 27 +- ..._tests__can_compose_with_descriptions.snap | 19 +- ...compose_removes_federation_directives.snap | 23 +- ...tract_subgraphs__can_extract_subgraph.snap | 20 +- ...mand_control_directive_name_conflicts.snap | 20 +- ...mand_control_directive_name_conflicts.snap | 20 +- ...s__extracts_demand_control_directives.snap | 20 +- ...cts_renamed_demand_control_directives.snap | 20 +- ...aphs__extracts_set_context_directives.snap | 178 + ...ubgraphs__extracts_string_enum_values.snap | 179 + .../fixtures/tag_validation_template.graphqls | 37 + apollo-federation/tests/subgraph/mod.rs | 1 + .../tests/subgraph/parse_expand_tests.rs | 45 +- .../subgraph/subgraph_validation_tests.rs | 2307 +++ apollo-router-benchmarks/Cargo.toml | 4 +- apollo-router-benchmarks/src/shared.rs | 3 +- apollo-router-scaffold/Cargo.toml | 26 - .../scaffold-test/.cargo/config | 3 - .../scaffold-test/.dockerignore | 1 - .../scaffold-test/.gitignore | 2 - .../scaffold-test/Cargo.toml | 25 - .../scaffold-test/Dockerfile | 54 - .../scaffold-test/README.md | 120 - .../scaffold-test/router.yaml | 5 - .../scaffold-test/src/main.rs | 7 - .../scaffold-test/src/plugins/auth.rs | 99 - .../scaffold-test/src/plugins/basic.rs | 123 - .../scaffold-test/src/plugins/mod.rs | 3 - .../scaffold-test/src/plugins/tracing.rs | 103 - .../scaffold-test/xtask/Cargo.toml | 12 - .../scaffold-test/xtask/src/main.rs | 38 - apollo-router-scaffold/src/lib.rs | 204 - apollo-router-scaffold/src/plugin.rs | 177 - .../templates/base/.cargo/config | 3 - .../templates/base/.dockerignore | 1 - .../templates/base/.gitignore | 2 - .../templates/base/.scaffold.toml | 32 - .../templates/base/Cargo.template.toml | 39 - .../templates/base/Dockerfile | 54 - .../templates/base/README.md | 120 - .../templates/base/router.yaml | 5 - .../templates/base/rust-toolchain.toml | 6 - .../templates/base/src/main.rs | 7 - .../templates/base/xtask/Cargo.template.toml | 20 - .../templates/base/xtask/src/main.rs | 38 - .../templates/plugin/.scaffold.toml | 25 - .../templates/plugin/src/plugins/mod.rs | 0 .../plugin/src/plugins/{{snake_name}}.rs | 199 - ...cd04a19fd394c234940976dd32bc507984fca.json | 16 + ...a71988b1ef61a8a4dd38e4ac71bcb968d489e.json | 20 + ...e261b56416439675a38b316017478797a56ab.json | 25 + ...cf4fdc174de48a1d7fd64c088a15d02c2c690.json | 22 + ...b27163e1b83fe86adf6e6a906a11547a1d05f.json | 22 + ...3813f86f0b8072bbd241ed1090b5c37b932c8.json | 25 + ...c848ed7767b3aa0e1b3eaeb612f4840f4765e.json | 46 + ...22ec531e1dc6f695c56268bb3546b1f14beab.json | 22 + ...53deffa2e7a2c8570ef7089701ebab9f02665.json | 46 + ...49b47ba93ab750c54cecec6ccf636f1145178.json | 16 + ...9cd980c07f640be2fea4bc9d76dfc451d437d.json | 22 + ...f57f456f3a15c9dfd1388e0b32a0978a08ae0.json | 14 + ...a0c610380ed3411e0e5d3cae03e94347410a3.json | 29 + ...cf21671f600ee645ed77e5a300b6bbb9590c3.json | 20 + apollo-router/Cargo.toml | 303 +- apollo-router/README.md | 2 +- apollo-router/benches/deeply_nested.rs | 140 +- apollo-router/benches/huge_requests.rs | 119 +- apollo-router/build/main.rs | 32 - apollo-router/build/studio.rs | 3 +- apollo-router/examples/planner.rs | 2 +- apollo-router/examples/router.yaml | 2 - apollo-router/feature_discussions.json | 4 +- .../20250516144204_creation.down.sql | 10 + .../migrations/20250516144204_creation.up.sql | 66 + apollo-router/rustfmt.toml | 1 + apollo-router/src/ageing_priority_queue.rs | 174 + .../src/apollo_studio_interop/mod.rs | 297 +- ...terop__tests__enums_with_nested_query.snap | 8 + ...sts__enums_with_nested_query_fragment.snap | 8 + ...sts__extended_references_nested_query.snap | 56 + ...ms_from_response_with_nested_query.graphql | 7 + ...esponse_with_nested_query_fragment.graphql | 11 + .../testdata/schema_interop.graphql | 13 + .../src/apollo_studio_interop/tests.rs | 737 +- .../axum_factory/axum_http_server_factory.rs | 395 +- .../compression/codec/brotli/encoder.rs | 21 +- .../compression/codec/flate/encoder.rs | 5 +- .../src/axum_factory/compression/codec/mod.rs | 2 +- .../src/axum_factory/compression/mod.rs | 41 +- .../src/axum_factory/connection_handle.rs | 89 + apollo-router/src/axum_factory/listeners.rs | 467 +- apollo-router/src/axum_factory/metrics.rs | 62 + apollo-router/src/axum_factory/mod.rs | 17 +- ...factory__tests__defer_is_not_buffered.snap | 81 +- apollo-router/src/axum_factory/tests.rs | 684 +- apollo-router/src/axum_factory/utils.rs | 18 +- apollo-router/src/batching.rs | 210 +- apollo-router/src/cache/metrics.rs | 533 + apollo-router/src/cache/mod.rs | 77 +- apollo-router/src/cache/redis.rs | 401 +- apollo-router/src/cache/size_estimation.rs | 2 +- apollo-router/src/cache/storage.rs | 113 +- apollo-router/src/compute_job/metrics.rs | 192 + apollo-router/src/compute_job/mod.rs | 415 + ...ompute_job__tests__observability@logs.snap | 25 + apollo-router/src/configuration/connector.rs | 19 + .../configuration/cooperative_cancellation.rs | 84 + apollo-router/src/configuration/cors.rs | 1145 +- apollo-router/src/configuration/expansion.rs | 139 +- apollo-router/src/configuration/metrics.rs | 285 +- ...31-experimental_reuse_query_fragments.yaml | 4 + .../0032-experimental_query_planner_mode.yaml | 4 + .../migrations/0033-experimental_retry.yaml | 6 + .../0034-experimental_parallelism.yaml | 4 + .../migrations/0035-preview_connectors.yaml | 5 + .../0036-preview_connectors_subgraphs.yaml | 6 + .../0037-preview_otlp_error_metrics.yaml | 5 + .../2000-jwt-issuer-becomes-issuers.yaml | 68 + .../2038-ignored-headers-subs-dedup.yaml | 5 + .../2039-cors-origins-to-policies.yaml | 18 + .../src/configuration/migrations/README.md | 2 +- apollo-router/src/configuration/mod.rs | 533 +- apollo-router/src/configuration/mode.rs | 24 + .../src/configuration/persisted_queries.rs | 15 +- apollo-router/src/configuration/schema.rs | 38 +- apollo-router/src/configuration/server.rs | 118 + ...figuration__expansion__test__dev_mode.snap | 3 +- ...iguration__metrics__test__env_metrics.snap | 6 +- ...rics__test__experimental_mode_metrics.snap | 10 - ...cs__test__experimental_mode_metrics_2.snap | 10 - ...cs__test__experimental_mode_metrics_3.snap | 10 - ..._test__metrics@connectors.router.yaml.snap | 15 + ...s__test__metrics@entities.router.yaml.snap | 1 + ...t__metrics@response_cache.router.yaml.snap | 16 + ...__test__metrics@telemetry.router.yaml.snap | 6 +- ...__metrics@traffic_shaping.router.yaml.snap | 3 +- ...nfiguration__tests__schema_generation.snap | 3882 ++-- ...iguration@apollo_extended_errors.yaml.snap | 9 + ...ration@connectors_preview.router.yaml.snap | 14 + ...figuration@jaeger_enabled.router.yaml.snap | 6 +- ...figuration@jwt_issuer_to_issuers.yaml.snap | 23 + ...rade_old_configuration@legacy_qp.yaml.snap | 7 + ...uration@minor__cors_both_origins.yaml.snap | 20 + ...n@minor__cors_conflicting_config.yaml.snap | 12 + ...ration@minor__cors_match_origins.yaml.snap | 19 + ...iguration@minor__cors_no_origins.yaml.snap | 11 + ...ration@minor__cors_origins_empty.yaml.snap | 10 + ...ation@minor__cors_origins_simple.yaml.snap | 11 + ...inor__cors_origins_with_settings.yaml.snap | 23 + ...onfiguration@minor__health_check.yaml.snap | 7 + ...ration@minor__subscription_dedup.yaml.snap | 11 + ...iguration@apollo_extended_errors.yaml.snap | 9 + apollo-router/src/configuration/subgraph.rs | 8 +- .../testdata/config_full.router.yaml | 3 +- .../testdata/metrics/connectors.router.yaml | 12 + .../testdata/metrics/entities.router.yaml | 9 +- ...query_planner_parallelism_auto.router.yaml | 3 - ...ery_planner_parallelism_static.router.yaml | 3 - .../metrics/response_cache.router.yaml | 25 + .../metrics/subscriptions.router.yaml | 4 +- .../testdata/metrics/telemetry.router.yaml | 46 +- .../metrics/traffic_shaping.router.yaml | 5 - .../migrations/apollo_extended_errors.yaml | 4 + .../migrations/connectors_preview.router.yaml | 10 + .../migrations/defer_support_ga.router.yaml | 2 - .../migrations/jaeger_enabled.router.yaml | 5 - .../jaeger_scheduled_delay.router.yaml | 6 - .../migrations/jwt_issuer_to_issuers.yaml | 16 + .../testdata/migrations/legacy_qp.yaml | 5 + .../migrations/minor/cors_both_origins.yaml | 13 + .../minor/cors_conflicting_config.yaml | 7 + .../migrations/minor/cors_match_origins.yaml | 12 + .../migrations/minor/cors_no_origins.yaml | 5 + .../migrations/minor/cors_origins_empty.yaml | 3 + .../migrations/minor/cors_origins_simple.yaml | 4 + .../minor/cors_origins_with_settings.yaml | 16 + .../migrations/minor/subscription_dedup.yaml | 5 + ...telemetry_router_to_supergraph.router.yaml | 8 - .../testdata/supergraph_config.router.yaml | 5 +- .../testdata/tracing_jaeger_agent.router.yaml | 9 - .../tracing_jaeger_collector.router.yaml | 9 - .../tracing_jaeger_collector_env.router.yaml | 9 - .../testdata/tracing_jaeger_full.router.yaml | 11 - apollo-router/src/configuration/tests.rs | 293 +- apollo-router/src/configuration/upgrade.rs | 428 +- apollo-router/src/configuration/yaml.rs | 4 +- apollo-router/src/context/deprecated.rs | 122 + apollo-router/src/context/extensions/mod.rs | 4 +- apollo-router/src/context/extensions/sync.rs | 72 +- apollo-router/src/context/mod.rs | 150 +- apollo-router/src/error.rs | 335 +- apollo-router/src/executable.rs | 292 +- apollo-router/src/files.rs | 105 +- apollo-router/src/graphql/mod.rs | 341 +- apollo-router/src/graphql/request.rs | 151 +- apollo-router/src/graphql/response.rs | 203 +- apollo-router/src/graphql/visitor.rs | 10 +- apollo-router/src/http_ext.rs | 77 +- apollo-router/src/http_server_factory.rs | 19 +- apollo-router/src/introspection.rs | 161 +- apollo-router/src/json_ext.rs | 365 +- apollo-router/src/layers/async_checkpoint.rs | 321 +- .../src/layers/map_first_graphql_response.rs | 6 +- apollo-router/src/layers/mod.rs | 88 +- apollo-router/src/layers/sync_checkpoint.rs | 28 +- apollo-router/src/lib.rs | 43 +- apollo-router/src/metrics/aggregation.rs | 258 +- apollo-router/src/metrics/filter.rs | 245 +- apollo-router/src/metrics/layer.rs | 480 - apollo-router/src/metrics/mod.rs | 1130 +- apollo-router/src/notification.rs | 212 +- apollo-router/src/orbiter/mod.rs | 31 +- apollo-router/src/otel_compat.rs | 38 + apollo-router/src/plugin/mod.rs | 200 +- apollo-router/src/plugin/serde.rs | 39 +- apollo-router/src/plugin/test/mock/canned.rs | 263 +- .../src/plugin/test/mock/connector.rs | 129 + apollo-router/src/plugin/test/mock/mod.rs | 2 + .../src/plugin/test/mock/subgraph.rs | 8 +- apollo-router/src/plugin/test/mod.rs | 3 + apollo-router/src/plugin/test/service.rs | 8 +- .../src/plugins/authentication/connector.rs | 52 + .../src/plugins/authentication/error.rs | 127 + .../src/plugins/authentication/jwks.rs | 580 +- .../src/plugins/authentication/mod.rs | 1077 +- ...entication__tests__parse_failure_logs.snap | 2 +- ...ation__tests__parse_failure_logs@logs.snap | 2 +- .../src/plugins/authentication/subgraph.rs | 112 +- .../src/plugins/authentication/tests.rs | 633 +- .../plugins/authorization/authenticated.rs | 89 +- .../src/plugins/authorization/mod.rs | 96 +- .../src/plugins/authorization/policy.rs | 96 +- .../src/plugins/authorization/scopes.rs | 94 +- ..._tests__unauthenticated_request_defer.snap | 14 +- .../src/plugins/authorization/tests.rs | 47 +- .../src/plugins/cache/cache_control.rs | 85 +- apollo-router/src/plugins/cache/entity.rs | 783 +- .../src/plugins/cache/invalidation.rs | 63 +- .../plugins/cache/invalidation_endpoint.rs | 166 +- apollo-router/src/plugins/cache/metrics.rs | 72 +- ...uter__plugins__cache__tests__insert-2.snap | 18 +- ...uter__plugins__cache__tests__insert-3.snap | 18 +- ...uter__plugins__cache__tests__insert-4.snap | 18 +- ...uter__plugins__cache__tests__insert-5.snap | 16 + ...uter__plugins__cache__tests__insert-6.snap | 17 + ...router__plugins__cache__tests__insert.snap | 17 +- ...tests__insert_with_nested_field_set-2.snap | 7 + ...tests__insert_with_nested_field_set-3.snap | 19 + ...tests__insert_with_nested_field_set-4.snap | 7 + ...tests__insert_with_nested_field_set-5.snap | 16 + ...tests__insert_with_nested_field_set-6.snap | 19 + ...__tests__insert_with_nested_field_set.snap | 16 + ..._cache__tests__insert_with_requires-2.snap | 7 + ..._cache__tests__insert_with_requires-3.snap | 15 + ..._cache__tests__insert_with_requires-4.snap | 7 + ..._cache__tests__insert_with_requires-5.snap | 16 + ..._cache__tests__insert_with_requires-6.snap | 15 + ...s__cache__tests__insert_with_requires.snap | 16 + ...ns__cache__tests__invalidate_entity-2.snap | 17 + ...ns__cache__tests__invalidate_entity-3.snap | 7 + ...ns__cache__tests__invalidate_entity-4.snap | 16 + ...ns__cache__tests__invalidate_entity-5.snap | 17 + ...gins__cache__tests__invalidate_entity.snap | 16 + ...ins__cache__tests__missing_entities-2.snap | 5 +- ...ins__cache__tests__no_cache_control-4.snap | 12 +- ...ter__plugins__cache__tests__no_data-2.snap | 21 +- ...ter__plugins__cache__tests__no_data-3.snap | 16 + ...ter__plugins__cache__tests__no_data-4.snap | 39 + ...outer__plugins__cache__tests__no_data.snap | 28 +- ...ter__plugins__cache__tests__private-3.snap | 17 +- ...ter__plugins__cache__tests__private-4.snap | 17 + ...ter__plugins__cache__tests__private-5.snap | 16 + ...ter__plugins__cache__tests__private-6.snap | 19 + ...outer__plugins__cache__tests__private.snap | 25 +- apollo-router/src/plugins/cache/tests.rs | 522 +- apollo-router/src/plugins/chaos/mod.rs | 127 + apollo-router/src/plugins/chaos/reload.rs | 514 + .../src/plugins/connectors/configuration.rs | 163 + .../plugins/connectors/handle_responses.rs | 1290 ++ .../src/plugins/connectors/incompatible.rs | 132 + .../plugins/connectors/incompatible/apq.rs | 67 + .../connectors/incompatible/authentication.rs | 114 + .../connectors/incompatible/batching.rs | 79 + .../connectors/incompatible/coprocessor.rs | 45 + .../connectors/incompatible/entity_cache.rs | 69 + .../connectors/incompatible/headers.rs | 59 + .../plugins/connectors/incompatible/rhai.rs | 41 + .../connectors/incompatible/telemetry.rs | 76 + .../plugins/connectors/incompatible/tls.rs | 54 + .../incompatible/traffic_shaping.rs | 72 + .../connectors/incompatible/url_override.rs | 53 + .../src/plugins/connectors/make_requests.rs | 2181 +++ .../connectors/make_requests/graphql_utils.rs | 145 + apollo-router/src/plugins/connectors/mod.rs | 22 + .../src/plugins/connectors/plugin.rs | 181 + .../src/plugins/connectors/query_plans.rs | 120 + .../src/plugins/connectors/request_limit.rs | 116 + .../src/plugins/connectors/testdata/README.md | 22 + .../testdata/batch-max-size.graphql | 109 + .../connectors/testdata/batch-max-size.yaml | 26 + .../connectors/testdata/batch-query.graphql | 71 + .../connectors/testdata/batch-query.yaml | 25 + .../plugins/connectors/testdata/batch.graphql | 71 + .../plugins/connectors/testdata/batch.yaml | 25 + .../connect-on-interface-object.graphql | 91 + .../testdata/connect-on-interface-object.yaml | 45 + .../testdata/connect-on-type.graphql | 71 + .../connectors/testdata/connect-on-type.yaml | 25 + .../testdata/connector-without-source.graphql | 68 + .../testdata/connector-without-source.yaml | 28 + .../connectors/testdata/content-type.graphql | 122 + .../connectors/testdata/content-type.yaml | 25 + .../connectors/testdata/env-var.graphql | 69 + .../connectors/testdata/errors.graphql | 149 + .../plugins/connectors/testdata/errors.yaml | 29 + .../connectors/testdata/form-encoding.graphql | 107 + .../connectors/testdata/form-encoding.yaml | 114 + .../testdata/interface-object.graphql | 97 + .../connectors/testdata/interface-object.yaml | 58 + .../connectors/testdata/mutation.graphql | 85 + .../plugins/connectors/testdata/mutation.yaml | 60 + .../connectors/testdata/nullability.graphql | 87 + .../connectors/testdata/nullability.yaml | 71 + .../testdata/progressive-override.graphql | 72 + .../testdata/progressive-override.yaml | 56 + .../connectors/testdata/quickstart.graphql | 82 + .../connectors/testdata/quickstart.yaml | 75 + .../quickstart_api_snapshots/query_1.json | 31 + .../quickstart_api_snapshots/query_2.json | 23 + .../quickstart_api_snapshots/query_3.json | 61 + .../quickstart_api_snapshots/query_4.json | 279 + .../plugins/connectors/testdata/regenerate.sh | 23 + .../connectors/testdata/selection.graphql | 83 + .../connectors/testdata/selection.yaml | 55 + .../connectors/testdata/steelthread.graphql | 90 + .../connectors/testdata/steelthread.yaml | 126 + .../testdata/url-properties.graphql | 62 + .../connectors/testdata/url-properties.yaml | 29 + .../testdata/variables-subgraph.graphql | 104 + .../connectors/testdata/variables.graphql | 97 + .../connectors/testdata/variables.yaml | 6 + .../connectors/tests/connect_on_type.rs | 658 + .../plugins/connectors/tests/content_type.rs | 507 + .../connectors/tests/error_handling.rs | 627 + .../src/plugins/connectors/tests/mock_api.rs | 150 + .../src/plugins/connectors/tests/mod.rs | 2350 +++ .../connectors/tests/progressive_override.rs | 57 + .../plugins/connectors/tests/query_plan.rs | 255 + .../plugins/connectors/tests/quickstart.rs | 278 + .../plugins/connectors/tests/req_asserts.rs | 198 + .../connectors/tests/url_properties.rs | 47 + .../src/plugins/connectors/tests/variables.rs | 47 + .../src/plugins/connectors/tracing.rs | 130 + .../src/plugins/coprocessor/execution.rs | 623 +- apollo-router/src/plugins/coprocessor/mod.rs | 645 +- .../src/plugins/coprocessor/supergraph.rs | 680 +- apollo-router/src/plugins/coprocessor/test.rs | 3702 +++- apollo-router/src/plugins/cors.rs | 1122 ++ .../plugins/csrf/fixtures/default.router.yaml | 2 + .../fixtures/required_headers.router.yaml | 3 + .../csrf/fixtures/unsafe_disabled.router.yaml | 2 + .../src/plugins/{csrf.rs => csrf/mod.rs} | 205 +- .../cost_calculator/directives.rs | 208 +- .../fixtures/arbitrary_json_schema.graphql | 94 + .../fixtures/custom_cost_schema.graphql | 12 +- .../federated_ships_typename_query.graphql | 7 + .../federated_ships_typename_response.json | 16 + .../fixtures/subscription_query.graphql | 6 + .../fixtures/subscription_schema.graphql | 92 + .../demand_control/cost_calculator/schema.rs | 314 +- ...__federated_query_with_typenames@logs.snap | 5 + .../cost_calculator/static_cost.rs | 344 +- .../src/plugins/demand_control/mod.rs | 185 +- .../plugins/demand_control/strategy/mod.rs | 8 +- .../strategy/static_estimated.rs | 2 +- .../plugins/demand_control/strategy/test.rs | 2 +- .../plugins/enhanced_client_awareness/mod.rs | 74 + .../enhanced_client_awareness/tests.rs | 101 + .../src/plugins/expose_query_plan.rs | 190 +- .../src/plugins/file_uploads/error.rs | 13 +- .../src/plugins/file_uploads/map_field.rs | 2 +- apollo-router/src/plugins/file_uploads/mod.rs | 55 +- .../file_uploads/multipart_form_data.rs | 13 +- .../plugins/file_uploads/multipart_request.rs | 24 +- .../file_uploads/rearrange_query_plan.rs | 12 +- apollo-router/src/plugins/fleet_detector.rs | 820 + apollo-router/src/plugins/forbid_mutations.rs | 19 +- apollo-router/src/plugins/headers.rs | 1026 -- .../propagate_passthrough.router.yaml | 10 + ...ropagate_passthrough_defaulted.router.yaml | 9 + apollo-router/src/plugins/headers/mod.rs | 1843 ++ apollo-router/src/plugins/healthcheck/mod.rs | 542 + .../allowed_fifty_per_second.router.yaml | 7 + .../allowed_ten_per_second.router.yaml | 7 + .../testdata/custom_listener.router.yaml | 2 + .../testdata/default_listener.router.yaml | 2 + .../testdata/disabled_listener.router.yaml | 3 + .../src/plugins/include_subgraph_errors.rs | 345 - .../plugins/include_subgraph_errors/config.rs | 227 + .../effective_config.rs | 191 + .../plugins/include_subgraph_errors/mod.rs | 168 + ...aph_errors__tests__allow_all_explicit.snap | 14 + ...llow_product_override_implicit_redact.snap | 14 + ...__allow_product_when_account_redacted.snap | 14 + ...s__allow_product_when_review_redacted.snap | 14 + ...__tests__filter_global_allow_keep_msg.snap | 13 + ...tests__filter_global_allow_redact_msg.snap | 13 + ...h_errors__tests__incremental_response.snap | 24 + ...aph_errors__tests__non_subgraph_error.snap | 13 + ...edact_account_override_explicit_allow.snap | 8 + ...ph_errors__tests__redact_all_explicit.snap | 10 + ...ph_errors__tests__redact_all_implicit.snap | 10 + ...edact_product_override_explicit_allow.snap | 10 + ...s__redact_product_when_review_allowed.snap | 10 + ...s__subgraph_allow_extend_global_allow.snap | 14 + ...__subgraph_allow_override_global_deny.snap | 13 + ...errors__tests__subgraph_allow_service.snap | 12 + ...raph_bool_false_override_global_allow.snap | 10 + ...bgraph_bool_true_override_global_deny.snap | 14 + ...sts__subgraph_deny_extend_global_deny.snap | 12 + ...__subgraph_deny_override_global_allow.snap | 12 + ..._errors__tests__subgraph_deny_service.snap | 13 + ..._tests__subgraph_exclude_global_allow.snap | 13 + ...ests__subgraph_obj_override_redaction.snap | 13 + ...ubgraph_errors__tests__valid_response.snap | 31 + .../plugins/include_subgraph_errors/tests.rs | 505 + .../src/plugins/license_enforcement/mod.rs | 260 + apollo-router/src/plugins/limits/layer.rs | 150 +- apollo-router/src/plugins/limits/limited.rs | 98 +- apollo-router/src/plugins/limits/mod.rs | 164 +- .../mock_subgraphs/execution/engine.rs | 331 + .../execution/input_coercion.rs | 342 + .../plugins/mock_subgraphs/execution/mod.rs | 20 + .../mock_subgraphs/execution/resolver.rs | 88 + .../execution/result_coercion.rs | 236 + .../mock_subgraphs/execution/validation.rs | 16 + .../src/plugins/mock_subgraphs/mod.rs | 377 + apollo-router/src/plugins/mod.rs | 17 +- apollo-router/src/plugins/override_url.rs | 8 +- .../src/plugins/progressive_override/mod.rs | 6 +- ...dden_field_yields_expected_query_plan.snap | 14 +- ...dden_field_yields_expected_query_plan.snap | 18 +- .../src/plugins/progressive_override/tests.rs | 82 +- .../plugins/progressive_override/visitor.rs | 6 +- .../src/plugins/record_replay/record.rs | 99 +- .../src/plugins/record_replay/recording.rs | 2 +- .../src/plugins/record_replay/replay.rs | 48 +- .../src/plugins/record_replay/replay_tests.rs | 2 +- .../plugins/response_cache/cache_control.rs | 466 + .../plugins/response_cache/invalidation.rs | 282 + .../response_cache/invalidation_endpoint.rs | 420 + .../src/plugins/response_cache/metrics.rs | 360 + .../src/plugins/response_cache/mod.rs | 15 + .../src/plugins/response_cache/plugin.rs | 2933 +++ .../src/plugins/response_cache/postgres.rs | 709 + ...ache__tests__failure_mode_reconnect-2.snap | 77 + ...ache__tests__failure_mode_reconnect-4.snap | 72 + ...gins__response_cache__tests__insert-3.snap | 72 + ...lugins__response_cache__tests__insert.snap | 77 + ...tests__insert_with_nested_field_set-3.snap | 77 + ...__tests__insert_with_nested_field_set.snap | 85 + ..._cache__tests__insert_with_requires-3.snap | 73 + ...se_cache__tests__insert_with_requires.snap | 80 + ...che__tests__invalidate_by_cache_tag-3.snap | 71 + ...che__tests__invalidate_by_cache_tag-5.snap | 76 + ...cache__tests__invalidate_by_cache_tag.snap | 76 + ...ache__tests__invalidate_by_subgraph-3.snap | 69 + ..._cache__tests__invalidate_by_subgraph.snap | 74 + ...se_cache__tests__invalidate_by_type-3.snap | 71 + ...se_cache__tests__invalidate_by_type-5.snap | 76 + ...onse_cache__tests__invalidate_by_type.snap | 76 + ...onse_cache__tests__missing_entities-2.snap | 37 + ...sponse_cache__tests__missing_entities.snap | 20 + ...ins__response_cache__tests__no_data-3.snap | 119 + ...ugins__response_cache__tests__no_data.snap | 123 + ...ts__polymorphic_private_and_public-11.snap | 97 + ...sts__polymorphic_private_and_public-3.snap | 102 + ...sts__polymorphic_private_and_public-5.snap | 100 + ...sts__polymorphic_private_and_public-7.snap | 97 + ...sts__polymorphic_private_and_public-9.snap | 100 + ...tests__polymorphic_private_and_public.snap | 106 + ...se_cache__tests__private_and_public-3.snap | 100 + ...se_cache__tests__private_and_public-5.snap | 104 + ...onse_cache__tests__private_and_public.snap | 105 + ...response_cache__tests__private_only-3.snap | 73 + ...response_cache__tests__private_only-5.snap | 77 + ...__response_cache__tests__private_only.snap | 78 + ...__tests__private_without_private_id-3.snap | 71 + ...he__tests__private_without_private_id.snap | 76 + .../src/plugins/response_cache/tests.rs | 4313 +++++ apollo-router/src/plugins/rhai/engine.rs | 97 +- apollo-router/src/plugins/rhai/execution.rs | 20 +- apollo-router/src/plugins/rhai/mod.rs | 257 +- apollo-router/src/plugins/rhai/router.rs | 20 +- ...tests__it_prints_messages_to_log@logs.snap | 8 + ...i_plugin_execution_service_error@logs.snap | 15 + ...__rhai_plugin_supergraph_service@logs.snap | 5 + apollo-router/src/plugins/rhai/subgraph.rs | 27 +- apollo-router/src/plugins/rhai/supergraph.rs | 20 +- apollo-router/src/plugins/rhai/tests.rs | 411 +- ...y_plan__tests__it_expose_query_plan-2.snap | 39 +- ...ery_plan__tests__it_expose_query_plan.snap | 39 +- apollo-router/src/plugins/subscription.rs | 564 +- apollo-router/src/plugins/telemetry/apollo.rs | 68 +- .../src/plugins/telemetry/apollo_exporter.rs | 31 +- .../plugins/telemetry/apollo_otlp_exporter.rs | 201 +- apollo-router/src/plugins/telemetry/config.rs | 114 +- .../config_new/apollo/instruments.rs | 312 + .../telemetry/config_new/apollo/mod.rs | 1 + .../telemetry/config_new/attributes.rs | 2073 +-- .../telemetry/config_new/cache/attributes.rs | 26 +- .../plugins/telemetry/config_new/cache/mod.rs | 15 +- .../telemetry/config_new/conditional.rs | 48 +- .../telemetry/config_new/conditions.rs | 109 +- .../config_new/connector/attributes.rs | 146 + .../telemetry/config_new/connector/events.rs | 323 + .../config_new/connector/instruments.rs | 347 + .../telemetry/config_new/connector/mod.rs | 10 + .../config_new/connector/selectors.rs | 979 + ..._tests__connector_events_request@logs.snap | 14 + ...tests__connector_events_response@logs.snap | 14 + .../telemetry/config_new/connector/spans.rs | 27 + .../plugins/telemetry/config_new/cost/mod.rs | 57 +- .../plugins/telemetry/config_new/events.rs | 1092 +- .../config_new/experimental_when_header.rs | 89 - .../telemetry/config_new/extendable.rs | 84 +- .../connector_fetch_duration/metrics.snap | 29 + .../connector_fetch_duration/router.yaml | 7 + .../apollo/connector_fetch_duration/test.yaml | 21 + .../subgraph_fetch_duration/metrics.snap | 29 + .../subgraph_fetch_duration/router.yaml | 7 + .../apollo/subgraph_fetch_duration/test.yaml | 18 + .../metrics.snap | 35 + .../router.yaml | 21 + .../custom_counter_with_conditions/test.yaml | 22 + .../connector/custom_histogram/metrics.snap | 33 + .../connector/custom_histogram/router.yaml | 19 + .../connector/custom_histogram/test.yaml | 26 + .../http_client_request_duration/metrics.snap | 55 + .../http_client_request_duration/router.yaml | 28 + .../http_client_request_duration/test.yaml | 21 + .../connector/mapping_problems/metrics.snap | 52 + .../connector/mapping_problems/router.yaml | 28 + .../connector/mapping_problems/test.yaml | 56 + .../subgraph_and_connector/metrics.snap | 31 + .../subgraph_and_connector/router.yaml | 12 + .../subgraph_and_connector/test.yaml | 38 + .../graphql/custom_histogram/metrics.snap | 4 +- .../metrics.snap | 2 + .../metrics.snap | 1 + .../graphql/field.length/metrics.snap | 1 + .../router/attribute.error.type/metrics.snap | 1 + .../attribute.on_graphql_error/metrics.snap | 5 + .../attribute.on_graphql_error/test.yaml | 14 +- .../custom_counter_on_error/router.yaml | 2 +- .../router/custom_counter_on_error/test.yaml | 2 +- .../metrics.snap | 1 + .../custom_histogram_duration/metrics.snap | 1 + .../metrics.snap | 1 + .../metrics.snap | 1 + .../router/custom_histogram_unit/metrics.snap | 1 + .../metrics.snap | 3 +- .../metrics.snap | 1 + .../metrics.snap | 1 + .../metrics.snap | 1 + .../metrics.snap | 1 + .../metrics.snap | 1 + .../http.server.request.duration/metrics.snap | 1 + .../metrics.snap | 1 + .../test.yaml | 2 +- .../metrics.snap | 1 + .../telemetry/config_new/fixtures/schema.json | 90 + .../fixtures/subgraph/caching/test.yaml | 2 +- .../metrics.snap | 8 +- .../metrics.snap | 1 + .../custom_histogram_duration/metrics.snap | 6 +- .../metrics.snap | 8 +- .../custom_histogram_unit/metrics.snap | 6 +- .../metrics.snap | 8 +- .../metrics.snap | 1 + .../metrics.snap | 1 + .../metrics.snap | 6 +- .../custom_histogram_duration/metrics.snap | 6 +- .../metrics.snap | 6 +- .../metrics.snap | 1 + .../metrics.snap | 1 + .../metrics.snap | 1 + .../metrics.snap | 1 + .../custom_histogram_unit/metrics.snap | 8 +- .../metrics.snap | 8 +- .../metrics.snap | 1 + .../metrics.snap | 1 + .../config_new/graphql/attributes.rs | 82 +- .../telemetry/config_new/graphql/mod.rs | 68 +- .../telemetry/config_new/graphql/selectors.rs | 6 +- .../config_new/http_common/attributes.rs | 577 + .../config_new/http_common/events.rs | 0 .../config_new/http_common/instruments.rs | 1 + .../telemetry/config_new/http_common/mod.rs | 5 + .../config_new/http_common/selectors.rs | 1 + .../telemetry/config_new/http_common/spans.rs | 1 + .../config_new/http_server/attributes.rs | 827 + .../config_new/http_server/events.rs | 1 + .../config_new/http_server/instruments.rs | 1 + .../telemetry/config_new/http_server/mod.rs | 5 + .../config_new/http_server/selectors.rs | 1 + .../telemetry/config_new/http_server/spans.rs | 1 + .../telemetry/config_new/instruments.rs | 1134 +- .../plugins/telemetry/config_new/logging.rs | 161 +- .../src/plugins/telemetry/config_new/mod.rs | 41 +- .../telemetry/config_new/router/attributes.rs | 222 + .../telemetry/config_new/router/events.rs | 259 + .../config_new/router/instruments.rs | 138 + .../telemetry/config_new/router/mod.rs | 5 + .../telemetry/config_new/router/selectors.rs | 913 + ...er__events__tests__router_events@logs.snap | 12 + ...sts__router_events_graphql_error@logs.snap | 8 + ...__router_events_graphql_response@logs.snap | 8 + .../telemetry/config_new/router/spans.rs | 459 + .../plugins/telemetry/config_new/selectors.rs | 3558 ---- ..._tests__connector_events_request@logs.snap | 14 + ...tests__connector_events_response@logs.snap | 14 + ...ew__events__tests__router_events@logs.snap | 41 +- ...sts__router_events_graphql_error@logs.snap | 18 - ...__router_events_graphql_response@logs.snap | 18 - ...__events__tests__subgraph_events@logs.snap | 16 +- ..._tests__subgraph_events_response@logs.snap | 16 +- ...events__tests__supergraph_events@logs.snap | 4 - ...pergraph_events_on_graphql_error@logs.snap | 2 - ...s__supergraph_events_on_response@logs.snap | 2 - ...aph_events_with_exists_condition@logs.snap | 4 - .../src/plugins/telemetry/config_new/spans.rs | 792 +- .../config_new/subgraph/attributes.rs | 316 + .../telemetry/config_new/subgraph/events.rs | 190 + .../config_new/subgraph/instruments.rs | 165 + .../telemetry/config_new/subgraph/mod.rs | 5 + .../config_new/subgraph/selectors.rs | 1637 ++ ...h__events__test__subgraph_events@logs.snap | 26 + ...__test__subgraph_events_response@logs.snap | 26 + .../telemetry/config_new/subgraph/spans.rs | 270 + .../config_new/supergraph/attributes.rs | 243 + .../telemetry/config_new/supergraph/events.rs | 312 + .../config_new/supergraph/instruments.rs | 39 + .../telemetry/config_new/supergraph/mod.rs | 5 + .../config_new/supergraph/selectors.rs | 1202 ++ ...events__tests__supergraph_events@logs.snap | 32 + ...pergraph_events_on_graphql_error@logs.snap | 18 + ...s__supergraph_events_on_response@logs.snap | 18 + ...aph_events_with_exists_condition@logs.snap | 18 + .../telemetry/config_new/supergraph/spans.rs | 226 + apollo-router/src/plugins/telemetry/consts.rs | 9 +- .../plugins/telemetry/dynamic_attribute.rs | 98 +- .../src/plugins/telemetry/endpoint.rs | 90 +- .../plugins/telemetry/error_counter/mod.rs | 270 + .../plugins/telemetry/error_counter/tests.rs | 1301 ++ .../src/plugins/telemetry/error_handler.rs | 216 + .../src/plugins/telemetry/fmt_layer.rs | 613 +- .../src/plugins/telemetry/formatters/json.rs | 132 +- .../src/plugins/telemetry/formatters/mod.rs | 180 +- .../src/plugins/telemetry/formatters/text.rs | 97 +- .../src/plugins/telemetry/logging/mod.rs | 107 +- ...y__logging__test__router_service@logs.snap | 17 - ..._logging__test__subgraph_service@logs.snap | 9 +- ...ogging__test__supergraph_service@logs.snap | 3 - ...etry__logging__test__when_header@logs.snap | 71 - .../experimental_when_header.router.yaml | 9 - .../metrics/apollo/histogram/cost.rs | 2 +- .../metrics/apollo/histogram/duration.rs | 6 +- .../metrics/apollo/histogram/list_length.rs | 10 +- .../plugins/telemetry/metrics/apollo/mod.rs | 364 +- ...cs__apollo__studio__test__aggregation.snap | 18 +- ...eature_disabled_with_partial_defaults.snap | 82 + ...feature_enabled_with_partial_defaults.snap | 84 + ..._apollo__test__apollo_metrics_exclude.snap | 6 +- ...rics_features_disabled_when_defaulted.snap | 82 + ..._metrics_features_explicitly_disabled.snap | 82 + ...o_metrics_features_explicitly_enabled.snap | 85 + ..._metrics_features_implicitly_disabled.snap | 82 + ...test__apollo_metrics_for_subscription.snap | 11 +- ...apollo_metrics_for_subscription_error.snap | 11 +- ...t__apollo_metrics_multiple_operations.snap | 11 +- ...o__test__apollo_metrics_parse_failure.snap | 11 +- ...test__apollo_metrics_single_operation.snap | 13 +- ...est__apollo_metrics_unknown_operation.snap | 11 +- ...st__apollo_metrics_validation_failure.snap | 11 +- .../telemetry/metrics/apollo/studio.rs | 68 +- .../src/plugins/telemetry/metrics/mod.rs | 436 +- .../src/plugins/telemetry/metrics/otlp.rs | 6 +- .../plugins/telemetry/metrics/prometheus.rs | 72 +- .../metrics/span_metrics_exporter.rs | 166 - apollo-router/src/plugins/telemetry/mod.rs | 1869 +- .../src/plugins/telemetry/otel/layer.rs | 578 +- .../src/plugins/telemetry/otel/mod.rs | 9 +- .../telemetry/otel/named_runtime_channel.rs | 184 + .../src/plugins/telemetry/otel/span_ext.rs | 4 +- .../src/plugins/telemetry/otel/tracer.rs | 57 +- apollo-router/src/plugins/telemetry/otlp.rs | 433 +- .../src/plugins/telemetry/proto/reports.proto | 15 +- apollo-router/src/plugins/telemetry/reload.rs | 137 +- .../src/plugins/telemetry/resource.rs | 215 +- ..._json_logging_deduplicates_attributes.snap | 6 + ..._with_custom_events_with_instrumented.snap | 5 +- ..._with_custom_events_with_instrumented.snap | 5 +- ...ry__tests__it_test_prometheus_metrics.snap | 28 +- ...est_prometheus_metrics_custom_buckets.snap | 10 +- ...s_custom_buckets_for_specific_metrics.snap | 14 +- ...t_prometheus_metrics_custom_view_drop.snap | 5 + ...prometheus_metrics_units_are_included.snap | 30 + .../src/plugins/telemetry/span_ext.rs | 27 + .../src/plugins/telemetry/span_factory.rs | 141 +- ....field_instrumentation_sampler.router.yaml | 11 + .../telemetry/testdata/config.router.yaml | 80 - .../testdata/custom_attributes.router.yaml | 88 +- .../testdata/custom_events.router.yaml | 37 +- ...l_config_all_features_defaults.router.yaml | 169 + ...ll_config_all_features_enabled.router.yaml | 186 + ...l_features_explicitly_disabled.router.yaml | 185 + ..._apq_disabled_partial_defaults.router.yaml | 175 + ...g_apq_enabled_partial_defaults.router.yaml | 176 + .../telemetry/testdata/prometheus.router.yaml | 4 + ...ustom_buckets_specific_metrics.router.yaml | 2 +- .../src/plugins/telemetry/tracing/apollo.rs | 13 +- .../telemetry/tracing/apollo_telemetry.rs | 480 +- .../tracing/datadog/agent_sampling.rs | 383 + .../tracing/{datadog.rs => datadog/mod.rs} | 140 +- .../tracing/datadog/span_processor.rs | 138 + .../datadog_exporter/exporter/intern.rs | 14 +- .../tracing/datadog_exporter/exporter/mod.rs | 100 +- .../datadog_exporter/exporter/model/mod.rs | 49 +- .../exporter/model/unified_tags.rs | 40 +- .../datadog_exporter/exporter/model/v03.rs | 23 +- .../datadog_exporter/exporter/model/v05.rs | 47 +- .../telemetry/tracing/datadog_exporter/mod.rs | 203 +- .../src/plugins/telemetry/tracing/jaeger.rs | 149 - .../src/plugins/telemetry/tracing/mod.rs | 69 +- .../src/plugins/telemetry/tracing/otlp.rs | 32 +- .../src/plugins/telemetry/tracing/reload.rs | 52 +- .../src/plugins/telemetry/tracing/zipkin.rs | 25 +- apollo-router/src/plugins/telemetry/utils.rs | 22 +- apollo-router/src/plugins/test.rs | 230 - apollo-router/src/plugins/test/mod.rs | 992 + apollo-router/src/plugins/test/router_ext.rs | 192 + .../src/plugins/test/subgraph_ext.rs | 174 + .../src/plugins/test/supergraph_ext.rs | 183 + .../plugins/traffic_shaping/deduplication.rs | 165 +- .../src/plugins/traffic_shaping/mod.rs | 976 +- .../src/plugins/traffic_shaping/rate/error.rs | 34 - .../plugins/traffic_shaping/rate/future.rs | 39 - .../src/plugins/traffic_shaping/rate/layer.rs | 53 - .../src/plugins/traffic_shaping/rate/mod.rs | 13 - .../src/plugins/traffic_shaping/rate/rate.rs | 33 - .../plugins/traffic_shaping/rate/service.rs | 83 - .../src/plugins/traffic_shaping/retry.rs | 78 - .../plugins/traffic_shaping/timeout/error.rs | 34 - .../plugins/traffic_shaping/timeout/future.rs | 57 - .../plugins/traffic_shaping/timeout/layer.rs | 26 - .../plugins/traffic_shaping/timeout/mod.rs | 60 - apollo-router/src/protocols/multipart.rs | 67 +- apollo-router/src/protocols/websocket.rs | 170 +- .../bridge_query_planner_pool.rs | 370 - .../query_planner/caching_query_planner.rs | 1363 +- apollo-router/src/query_planner/convert.rs | 105 +- .../src/query_planner/dual_query_planner.rs | 1323 -- apollo-router/src/query_planner/execution.rs | 214 +- apollo-router/src/query_planner/fetch.rs | 447 +- apollo-router/src/query_planner/labeler.rs | 6 +- apollo-router/src/query_planner/mod.rs | 10 +- apollo-router/src/query_planner/plan.rs | 149 +- ...ry_planner.rs => query_planner_service.rs} | 882 +- apollo-router/src/query_planner/rewrites.rs | 18 +- apollo-router/src/query_planner/selection.rs | 123 +- ...sts__empty_query_plan_usage_reporting.snap | 27 - ..._planner__tests__plan_usage_reporting.snap | 28 - ...sts__empty_query_plan_usage_reporting.snap | 5 + ...ce__tests__plan_invalid_query_errors.snap} | 0 ...ry_planner_service__tests__plan_root.snap} | 6 +- ..._service__tests__plan_usage_reporting.snap | 31 + ...ner_service__tests__subselections-10.snap} | 0 ...ner_service__tests__subselections-11.snap} | 0 ...ner_service__tests__subselections-12.snap} | 0 ...ner_service__tests__subselections-13.snap} | 0 ...ner_service__tests__subselections-14.snap} | 0 ...ner_service__tests__subselections-15.snap} | 0 ...ner_service__tests__subselections-16.snap} | 0 ...ner_service__tests__subselections-17.snap} | 0 ...ner_service__tests__subselections-18.snap} | 0 ...nner_service__tests__subselections-2.snap} | 0 ...nner_service__tests__subselections-3.snap} | 0 ...nner_service__tests__subselections-4.snap} | 0 ...nner_service__tests__subselections-5.snap} | 0 ...nner_service__tests__subselections-6.snap} | 0 ...nner_service__tests__subselections-7.snap} | 0 ...nner_service__tests__subselections-8.snap} | 0 ...nner_service__tests__subselections-9.snap} | 0 ...lanner_service__tests__subselections.snap} | 0 ..._planner__tests__query_plan_from_json.snap | 24 +- .../src/query_planner/subgraph_context.rs | 56 +- .../src/query_planner/subscription.rs | 224 +- apollo-router/src/query_planner/tests.rs | 392 +- apollo-router/src/registry/mod.rs | 314 + apollo-router/src/router/error.rs | 7 +- .../src/router/event/configuration.rs | 100 +- apollo-router/src/router/event/license.rs | 12 +- apollo-router/src/router/event/mod.rs | 20 +- apollo-router/src/router/event/reload.rs | 55 +- apollo-router/src/router/event/schema.rs | 346 +- apollo-router/src/router/event/shutdown.rs | 6 +- ...a__tests__schema_by_url_fallback@logs.snap | 7 +- apollo-router/src/router/mod.rs | 67 +- apollo-router/src/router_factory.rs | 1205 +- apollo-router/src/services/connect.rs | 71 + apollo-router/src/services/connector.rs | 1 + .../src/services/connector/request_service.rs | 362 + .../src/services/connector_service.rs | 281 + apollo-router/src/services/execution.rs | 2 +- .../src/services/execution/service.rs | 69 +- apollo-router/src/services/external.rs | 95 +- apollo-router/src/services/fetch.rs | 190 + apollo-router/src/services/fetch_service.rs | 511 + .../src/services/hickory_dns_connector.rs | 25 +- apollo-router/src/services/http.rs | 5 +- .../src/services/http/body_stream.rs | 34 - apollo-router/src/services/http/service.rs | 248 +- apollo-router/src/services/http/tests.rs | 527 +- .../layers/allow_only_http_post_mutations.rs | 112 +- apollo-router/src/services/layers/apq.rs | 248 +- .../services/layers/content_negotiation.rs | 79 +- .../freeform_graphql_behavior.rs | 316 + .../layers/persisted_queries/id_extractor.rs | 2 +- .../layers/persisted_queries/manifest.rs | 112 + .../persisted_queries/manifest_poller.rs | 992 +- .../services/layers/persisted_queries/mod.rs | 701 +- ...l_with_safelist_log_unknown_true@logs.snap | 27 + .../src/services/layers/query_analysis.rs | 266 +- .../src/services/layers/static_page.rs | 54 +- apollo-router/src/services/mod.rs | 36 +- .../batch_exceeds_maximum_size_response.json | 11 + apollo-router/src/services/query_planner.rs | 28 +- apollo-router/src/services/router.rs | 249 +- apollo-router/src/services/router/body.rs | 163 +- .../src/services/router/pipeline_handle.rs | 59 + apollo-router/src/services/router/service.rs | 577 +- ...sts__escaped_quotes_in_string_literal.snap | 4 +- ...es__router__tests__invalid_input_enum.snap | 21 + apollo-router/src/services/router/tests.rs | 302 +- ...will_create_an_http_request_span@logs.snap | 25 + apollo-router/src/services/subgraph.rs | 130 +- .../src/services/subgraph_service.rs | 807 +- apollo-router/src/services/supergraph.rs | 45 +- .../src/services/supergraph/service.rs | 527 +- ...eferred_fragment_bounds_nullability-2.snap | 45 + ..._from_primary_on_deferred_responses-2.snap | 5 +- ...tests__errors_on_deferred_responses-2.snap | 5 +- ...ts__errors_on_incremental_responses-2.snap | 10 +- ...aph__tests__errors_on_nullified_paths.snap | 3 +- ...h_invalid_paths_on_query_with_defer-2.snap | 30 + ...ith_invalid_paths_on_query_with_defer.snap | 27 + ...pergraph__tests__invalid_input_enum-2.snap | 15 - ...supergraph__tests__invalid_input_enum.snap | 12 +- ...__supergraph__tests__missing_entities.snap | 5 +- ...pergraph__tests__query_reconstruction.snap | 2 +- ...subscription_callback_schema_reload-3.snap | 10 +- .../src/services/supergraph/tests.rs | 348 +- apollo-router/src/services/transport.rs | 15 - apollo-router/src/spec/field_type.rs | 147 +- apollo-router/src/spec/fragments.rs | 2 +- apollo-router/src/spec/mod.rs | 20 +- apollo-router/src/spec/operation_limits.rs | 30 +- apollo-router/src/spec/query.rs | 370 +- apollo-router/src/spec/query/change.rs | 1087 -- apollo-router/src/spec/query/subselections.rs | 10 +- apollo-router/src/spec/query/tests.rs | 458 +- apollo-router/src/spec/query/transform.rs | 22 +- apollo-router/src/spec/query/traverse.rs | 2 +- apollo-router/src/spec/schema.rs | 247 +- apollo-router/src/spec/selection.rs | 78 +- apollo-router/src/state_machine.rs | 1240 +- apollo-router/src/test_harness.rs | 273 +- apollo-router/src/test_harness/http_client.rs | 37 +- .../src/test_harness/http_snapshot.rs | 697 + .../src/test_harness/http_snapshot_main.rs | 6 + .../test_harness/mocks/persisted_queries.rs | 36 +- apollo-router/src/testdata/jaeger.router.yaml | 15 +- .../orga_supergraph_cache_key.graphql | 117 + .../src/testdata/supergraph_cache_key.graphql | 149 + .../testdata/supergraph_nested_fields.graphql | 82 + ...supergraph_nested_fields_cache_key.graphql | 122 + .../testdata/supergraph_with_context.graphql | 2 +- apollo-router/src/tracer.rs | 40 +- .../src/uplink/feature_gate_enforcement.rs | 248 + .../src/uplink/license_enforcement.rs | 997 +- apollo-router/src/uplink/license_stream.rs | 112 +- apollo-router/src/uplink/mod.rs | 156 +- apollo-router/src/uplink/parsed_link_spec.rs | 106 + .../persisted_queries_manifest_stream.rs | 17 +- apollo-router/src/uplink/schema.rs | 20 + apollo-router/src/uplink/schema_stream.rs | 55 +- ...forcement__test__progressive_override.snap | 10 - ...ssive_override_with_renamed_join_spec.snap | 10 - ...tion_directives_via_schema_unlicensed.snap | 10 + ...hema_with_restricted_allowed_features.snap | 10 + ..._test__restricted_features_via_config.snap | 26 +- ...res_via_config_allowed_features_empty.snap | 40 + ...ricted_features_via_config_unlicensed.snap | 40 + ...ures_via_config_with_allowed_features.snap | 28 + ...st__restricted_unix_socket_via_schema.snap | 9 - ...cement_directive_arg_version_in_range.snap | 10 - ...icense_enforcement__test__set_context.snap | 12 - .../uplink/testdata/connectv0_3.router.yaml | 8 + .../feature_enforcement_connect_v0_2.graphql | 67 + .../feature_enforcement_connect_v0_3.graphql | 67 + .../src/uplink/testdata/license.jwks.json | 10 + .../uplink/testdata/restricted.router.yaml | 12 + .../schema_enforcement_connectors.graphql | 121 + ...orcement_spec_version_out_of_range.graphql | 4 +- apollo-router/src/uplink/uplink.graphql | 8 + apollo-router/templates/sandbox_index.html | 1 + apollo-router/tests/apollo_otel_traces.rs | 114 +- apollo-router/tests/apollo_reports.rs | 148 +- apollo-router/tests/common.rs | 962 +- apollo-router/tests/compute_backpressure.rs | 70 + .../fixtures/connectors/quickstart.graphql | 158 + .../fixtures/file_upload/add_header.rhai | 10 + .../fixtures/file_upload/default.router.yaml | 4 + .../fixtures/file_upload/large.router.yaml | 4 + .../fixtures/file_upload/rhai.router.yaml | 21 + .../fixtures/non_utf8_header_removal.rhai | 12 + ...persisted-queries-manifest-hot-reload.json | 12 + .../reports/all_features_disabled.router.yaml | 59 + .../reports/all_features_enabled.router.yaml | 63 + .../{ => reports}/apollo_reports.router.yaml | 2 +- .../apollo_reports_batch.router.yaml | 2 +- .../tests/fixtures/request_response_test.rhai | 6 + .../set_context/one_fetch_failure.json | 23 + .../fixtures/set_context/one_null_param.json | 20 + .../tests/fixtures/supergraph_connect.graphql | 74 + .../tests/fixtures/supergraph_connect.yaml | 38 + .../tests/fixtures/test_reload_1.rhai | 44 + .../tests/fixtures/test_reload_2.rhai | 44 + .../fixtures/type_conditions/artwork.json | 4 +- .../type_conditions/artwork_disabled.json | 2 +- .../artwork_query_fragments_enabled.json | 4 +- .../fixtures/type_conditions/search.json | 2 +- .../type_conditions/search_list_of_list.json | 2 +- .../search_list_of_list_of_list.json | 2 +- .../search_query_fragments_enabled.json | 2 +- .../tests/integration/allowed_features.rs | 657 + apollo-router/tests/integration/batching.rs | 96 +- apollo-router/tests/integration/connectors.rs | 1112 ++ .../tests/integration/coprocessor.rs | 365 +- apollo-router/tests/integration/docs.rs | 24 +- .../tests/integration/entity_cache.rs | 595 + .../tests/integration/file_upload.rs | 265 +- .../fixtures/authenticated_directive.graphql | 76 + .../fixtures/broken_plugin.router.yaml | 15 +- .../fixtures/connectors_sigv4.graphql | 116 + .../fixtures/connectors_sigv4.router.yaml | 12 + .../fixtures/coprocessor.router.yaml | 24 + .../coprocessor_conditional.router.yaml | 16 + .../coprocessor_demand_control.router.yaml | 6 +- .../integration/fixtures/happy.router.yaml | 15 +- .../fixtures/prometheus.router.yaml | 8 + .../fixtures/prometheus_updated.router.yaml | 15 + .../query_planner_max_evaluated_plans.graphql | 73 + ...nfig_update_query_planner_mode.router.yaml | 11 - ...g_update_reuse_query_fragments.router.yaml | 11 - .../redis_connection_closure.router.yaml | 16 + .../fixtures/rhai_logging.router.yaml | 3 + .../fixtures/rhai_reload.router.yaml | 4 + ...ll_connection_shutdown_timeout.router.yaml | 10 + ...ction_shutdown_timeout_updated.router.yaml | 10 + .../tests/integration/introspection.rs | 199 +- apollo-router/tests/integration/lifecycle.rs | 209 +- apollo-router/tests/integration/metrics.rs | 33 + .../tests/integration/mock_subgraphs.rs | 74 + apollo-router/tests/integration/mod.rs | 27 +- .../tests/integration/operation_limits.rs | 13 +- .../tests/integration/operation_name.rs | 48 +- apollo-router/tests/integration/postgres.rs | 642 + .../tests/integration/query_planner.rs | 360 - .../integration/query_planner/error_paths.rs | 391 + .../query_planner/max_evaluated_plans.rs | 141 + .../tests/integration/query_planner/mod.rs | 106 + ..._planner__error_paths__all_successful.snap | 43 + ...r_paths__multi_level_response_failure.snap | 114 + ..._error_paths__nested_response_failure.snap | 71 + ...or_paths__nested_response_failure_404.snap | 153 + ...hs__nested_response_failure_malformed.snap | 90 + ..._paths__second_level_response_failure.snap | 55 + ...ond_level_response_failure_empty_path.snap | 65 + ...cond_level_response_failure_malformed.snap | 69 + ...ror_paths__top_level_response_failure.snap | 43 + ..._top_level_response_failure_malformed.snap | 20 + apollo-router/tests/integration/redis.rs | 590 +- .../tests/integration/response_cache.rs | 497 + apollo-router/tests/integration/rhai.rs | 143 +- ...rror_selector__first_response_failure.snap | 13 + ...ror_selector__nested_response_failure.snap | 26 + ...n__lifecycle__cli_config_experimental.snap | 4 +- ...ation__postgres__entity_cache_basic-2.snap | 23 + ...ation__postgres__entity_cache_basic-3.snap | 11 + ...ation__postgres__entity_cache_basic-4.snap | 195 + ...ation__postgres__entity_cache_basic-5.snap | 17 + ...gration__postgres__entity_cache_basic.snap | 234 + ..._entity_cache_with_nested_field_set-2.snap | 18 + ..._entity_cache_with_nested_field_set-3.snap | 7 + ..._entity_cache_with_nested_field_set-4.snap | 19 + ..._entity_cache_with_nested_field_set-5.snap | 7 + ...s__entity_cache_with_nested_field_set.snap | 19 + ..._entity_cache_with_nested_field_set-2.snap | 18 + ..._entity_cache_with_nested_field_set-3.snap | 7 + ..._entity_cache_with_nested_field_set-4.snap | 19 + ..._entity_cache_with_nested_field_set-5.snap | 7 + ...s__entity_cache_with_nested_field_set.snap | 19 + ...tegration__redis__query_planner_cache.snap | 4 +- ...cache__response_cache_authorization-2.snap | 50 + ...cache__response_cache_authorization-3.snap | 54 + ...e_cache__response_cache_authorization.snap | 57 + ...esponse_cache__response_cache_basic-2.snap | 18 + ...esponse_cache__response_cache_basic-3.snap | 11 + ...esponse_cache__response_cache_basic-4.snap | 26 + ...esponse_cache__response_cache_basic-5.snap | 11 + ..._response_cache__response_cache_basic.snap | 29 + ...esponse_cache_with_nested_field_set-2.snap | 18 + ...esponse_cache_with_nested_field_set-3.snap | 7 + ...esponse_cache_with_nested_field_set-4.snap | 19 + ...esponse_cache_with_nested_field_set-5.snap | 7 + ..._response_cache_with_nested_field_set.snap | 19 + ...affic_shaping__connector_rate_limit-2.snap | 5 + ...traffic_shaping__connector_rate_limit.snap | 5 + ...n__traffic_shaping__connector_timeout.snap | 5 + ...tion__traffic_shaping__router_timeout.snap | 4 +- ...raffic_shaping__subgraph_rate_limit-2.snap | 2 +- ...on__traffic_shaping__subgraph_timeout.snap | 2 +- .../tests/integration/subgraph_response.rs | 291 +- ...scription.rs => subscription_load_test.rs} | 30 +- .../integration/subscriptions/callback.rs | 501 + .../fixtures/callback.router.yaml | 26 + .../fixtures/subscription.router.yaml | 28 + .../subscription_coprocessor.router.yaml | 60 + .../subscription_schema_reload.router.yaml | 35 + .../subscriptions/fixtures/supergraph.graphql | 120 + .../tests/integration/subscriptions/mod.rs | 668 + .../subscriptions/ws_passthrough.rs | 919 + apollo-router/tests/integration/supergraph.rs | 60 +- .../telemetry/apollo_otel_metrics.rs | 913 + .../tests/integration/telemetry/datadog.rs | 994 +- .../tests/integration/telemetry/events.rs | 350 + .../telemetry/fixtures/datadog.router.yaml | 4 +- ...atadog_agent_sampling_disabled.router.yaml | 23 + ...adog_agent_sampling_disabled_0.router.yaml | 22 + ...adog_agent_sampling_disabled_1.router.yaml | 22 + .../datadog_default_span_names.router.yaml | 1 + ...dog_header_propagator_override.router.yaml | 29 + .../datadog_no_parent_sampler.router.yaml | 25 + .../fixtures/datadog_no_sample.router.yaml | 1 + .../datadog_override_span_names.router.yaml | 1 + ...tadog_override_span_names_late.router.yaml | 1 + ...adog_parent_sampler_very_small.router.yaml | 26 + ...t_sampler_very_small_no_parent.router.yaml | 25 + ...ll_no_parent_no_agent_sampling.router.yaml | 26 + ...small_parent_no_agent_sampling.router.yaml | 22 + ...tadog_resource_mapping_default.router.yaml | 1 + ...adog_resource_mapping_override.router.yaml | 1 + .../telemetry/fixtures/graphql.router.yaml | 39 +- .../fixtures/jaeger-0.5-sample.router.yaml | 31 - .../fixtures/jaeger-advanced.router.yaml | 62 - .../fixtures/jaeger-no-sample.router.yaml | 34 - .../telemetry/fixtures/jaeger.router.yaml | 32 - .../jaeger_decimal_trace_id.router.yaml | 33 - .../telemetry/fixtures/json.router.yaml | 9 +- .../fixtures/json.sampler_off.router.yaml | 9 +- .../fixtures/json.span_attributes.router.yaml | 9 +- .../telemetry/fixtures/json.uuid.router.yaml | 9 +- .../fixtures/no-telemetry.router.yaml | 10 - .../telemetry/fixtures/otlp.router.yaml | 17 +- .../otlp_datadog_agent_no_sample.router.yaml | 42 + .../otlp_datadog_agent_sample.router.yaml | 42 + ...datadog_agent_sample_no_sample.router.yaml | 42 + .../otlp_datadog_propagation.router.yaml | 39 + ...p_datadog_propagation_no_agent.router.yaml | 38 + ..._propagation_no_parent_sampler.router.yaml | 40 + ...request_with_zipkin_propagator.router.yaml | 41 + .../otlp_invalid_endpoint.router.yaml | 16 + .../otlp_no_parent_sampler.router.yaml | 25 + .../otlp_override_client_name.router.yaml | 31 + ...ll_no_parent_no_agent_sampling.router.yaml | 24 + .../fixtures/override_client_name.rhai | 8 + .../telemetry/fixtures/prometheus.router.yaml | 20 +- .../fixtures/subgraph_auth.router.yaml | 8 - .../telemetry/fixtures/text.router.yaml | 9 +- .../fixtures/text.sampler_off.router.yaml | 9 +- .../telemetry/fixtures/text.uuid.router.yaml | 9 +- .../fixtures/trace_id_via_header.router.yaml | 4 - .../telemetry/fixtures/zipkin.router.yaml | 3 + .../tests/integration/telemetry/jaeger.rs | 659 - .../tests/integration/telemetry/logging.rs | 162 +- .../tests/integration/telemetry/metrics.rs | 171 +- .../tests/integration/telemetry/mod.rs | 67 +- .../tests/integration/telemetry/otlp.rs | 1171 +- .../integration/telemetry/propagation.rs | 16 +- .../tests/integration/telemetry/verifier.rs | 163 + .../tests/integration/telemetry/zipkin.rs | 217 +- .../tests/integration/traffic_shaping.rs | 258 +- apollo-router/tests/integration/typename.rs | 12 +- apollo-router/tests/integration_tests.rs | 334 +- apollo-router/tests/samples/README.md | 25 +- .../samples/basic/interface-object/plan.json | 10 +- .../tests/samples/core/defer/plan.json | 6 +- .../tests/samples/core/query1/plan.json | 4 +- .../enterprise/connectors-debugging/README.md | 3 + .../connectors-debugging/configuration.yaml | 14 + .../connectors-debugging/http_snapshots.json | 88 + .../enterprise/connectors-debugging/plan.json | 290 + .../connectors-debugging/supergraph.graphql | 156 + .../connectors-debugging/supergraph.yaml | 40 + .../enterprise/connectors-defer/README.md | 1 + .../connectors-defer/configuration.yaml | 13 + .../connectors-defer/http_snapshots.json | 38 + .../enterprise/connectors-defer/plan.json | 41 + .../connectors-defer/supergraph.graphql | 83 + .../connectors-defer/supergraph.yaml | 29 + .../connectors-demand-control/README.md | 1 + .../configuration.yaml | 21 + .../http_snapshots.json | 21 + .../connectors-demand-control/plan.json | 48 + .../supergraph.graphql | 74 + .../connectors-demand-control/supergraph.yaml | 21 + .../enterprise/connectors-pqs/README.md | 2 + .../connectors-pqs/configuration.yaml | 19 + .../connectors-pqs/http_snapshots.json | 51 + .../enterprise/connectors-pqs/manifest.json | 12 + .../enterprise/connectors-pqs/plan.json | 54 + .../connectors-pqs/supergraph.graphql | 83 + .../enterprise/connectors-pqs/supergraph.yaml | 63 + .../samples/enterprise/connectors/README.md | 1 + .../enterprise/connectors/configuration.yaml | 15 + .../enterprise/connectors/http_snapshots.json | 61 + .../samples/enterprise/connectors/plan.json | 40 + .../enterprise/connectors/supergraph.graphql | 81 + .../entity-cache/defer/configuration.yaml | 8 +- .../enterprise/entity-cache/defer/plan.json | 6 +- .../invalidation-entity-key/plan.json | 13 +- .../invalidation-subgraph-name/plan.json | 6 +- .../invalidation-subgraph-type/plan.json | 10 +- .../entity-cache/private/configuration.yaml | 8 +- .../enterprise/entity-cache/private/plan.json | 4 +- .../persisted-queries/basic/README.md | 3 + .../basic/configuration.yaml | 14 + .../basic/persisted-query-manifest.json | 15 + .../persisted-queries/basic/plan.yaml | 129 + .../persisted-queries/basic/rhai/main.rhai | 7 + .../basic/supergraph.graphql | 124 + .../basic/configuration.yaml | 2 - .../basic/configuration2.yaml | 2 - .../progressive-override/basic/plan.json | 12 +- .../progressive-override/basic/rhai/main.rhai | 6 +- .../warmup/configuration.yaml | 2 - .../warmup/configuration2.yaml | 2 - .../progressive-override/warmup/plan.json | 12 +- .../warmup/rhai/main.rhai | 6 +- .../enterprise/query-planning-redis/plan.json | 4 +- apollo-router/tests/samples_tests.rs | 252 +- apollo-router/tests/set_context.rs | 64 +- ...ollo_otel_traces__batch_send_header-2.snap | 1913 +- ...apollo_otel_traces__batch_send_header.snap | 1913 +- .../apollo_otel_traces__batch_trace_id-2.snap | 1913 +- .../apollo_otel_traces__batch_trace_id.snap | 1913 +- .../apollo_otel_traces__client_name-2.snap | 949 +- .../apollo_otel_traces__client_name.snap | 949 +- .../apollo_otel_traces__client_version-2.snap | 949 +- .../apollo_otel_traces__client_version.snap | 949 +- .../apollo_otel_traces__condition_else-2.snap | 1013 +- .../apollo_otel_traces__condition_else.snap | 1013 +- .../apollo_otel_traces__condition_if-2.snap | 1119 +- .../apollo_otel_traces__condition_if.snap | 1119 +- .../apollo_otel_traces__connector-2.snap | 419 + .../apollo_otel_traces__connector.snap | 419 + ...apollo_otel_traces__connector_error-2.snap | 2968 +++ .../apollo_otel_traces__connector_error.snap | 2968 +++ .../apollo_otel_traces__non_defer-2.snap | 949 +- .../apollo_otel_traces__non_defer.snap | 949 +- .../apollo_otel_traces__send_header-2.snap | 949 +- .../apollo_otel_traces__send_header.snap | 949 +- ...lo_otel_traces__send_variable_value-2.snap | 949 +- ...ollo_otel_traces__send_variable_value.snap | 949 +- .../apollo_otel_traces__trace_id-2.snap | 949 +- .../apollo_otel_traces__trace_id.snap | 949 +- .../apollo_reports__batch_send_header-2.snap | 9 +- .../apollo_reports__batch_send_header.snap | 9 +- .../apollo_reports__batch_trace_id-2.snap | 9 +- .../apollo_reports__batch_trace_id.snap | 9 +- .../apollo_reports__client_name-2.snap | 5 +- .../apollo_reports__client_name.snap | 5 +- .../apollo_reports__client_version-2.snap | 5 +- .../apollo_reports__client_version.snap | 5 +- .../apollo_reports__condition_else-2.snap | 5 +- .../apollo_reports__condition_else.snap | 5 +- .../apollo_reports__condition_if-2.snap | 5 +- .../apollo_reports__condition_if.snap | 5 +- .../apollo_reports__demand_control_stats.snap | 7 +- ...pollo_reports__demand_control_trace-2.snap | 5 +- .../apollo_reports__demand_control_trace.snap | 5 +- ...ports__demand_control_trace_batched-2.snap | 9 +- ...reports__demand_control_trace_batched.snap | 9 +- .../apollo_reports__features_disabled.snap | 151 + .../apollo_reports__features_enabled.snap | 153 + .../apollo_reports__new_field_stats.snap | 7 +- .../apollo_reports__non_defer-2.snap | 5 +- .../snapshots/apollo_reports__non_defer.snap | 5 +- .../apollo_reports__send_header-2.snap | 5 +- .../apollo_reports__send_header.snap | 5 +- ...apollo_reports__send_variable_value-2.snap | 5 +- .../apollo_reports__send_variable_value.snap | 5 +- .../snapshots/apollo_reports__stats.snap | 7 +- .../apollo_reports__stats_mocked.snap | 3 + .../snapshots/apollo_reports__trace_id-2.snap | 5 +- .../snapshots/apollo_reports__trace_id.snap | 5 +- .../snapshots/set_context__set_context.snap | 5 +- ...__set_context_dependent_fetch_failure.snap | 10 +- ...ntext_dependent_fetch_failure_rust_qp.snap | 115 + .../set_context__set_context_list.snap | 5 +- ...et_context__set_context_list_of_lists.snap | 5 +- ...xt__set_context_list_of_lists_rust_qp.snap | 112 + ...set_context__set_context_list_rust_qp.snap | 107 + ...set_context__set_context_no_typenames.snap | 5 +- ...ext__set_context_no_typenames_rust_qp.snap | 98 + .../set_context__set_context_rust_qp.snap | 100 + ...et_context__set_context_type_mismatch.snap | 5 +- ...xt__set_context_type_mismatch_rust_qp.snap | 98 + .../set_context__set_context_union.snap | 7 +- ...et_context__set_context_union_rust_qp.snap | 155 + ...__set_context_unrelated_fetch_failure.snap | 14 +- ...ntext_unrelated_fetch_failure_rust_qp.snap | 171 + .../set_context__set_context_with_null.snap | 5 +- ...ontext__set_context_with_null_rust_qp.snap | 98 + ...ons___test_type_conditions_disabled-2.snap | 154 - ...tions___test_type_conditions_disabled.snap | 10 +- ...ions___test_type_conditions_enabled-2.snap | 218 - ...itions___test_type_conditions_enabled.snap | 32 +- ...ns_enabled_generate_query_fragments-2.snap | 218 - ...ions_enabled_generate_query_fragments.snap | 32 +- ...ype_conditions_enabled_list_of_list-2.snap | 282 - ..._type_conditions_enabled_list_of_list.snap | 32 +- ...itions_enabled_list_of_list_of_list-2.snap | 288 - ...nditions_enabled_list_of_list_of_list.snap | 32 +- ...enabled_shouldnt_make_article_fetch-2.snap | 193 - ...s_enabled_shouldnt_make_article_fetch.snap | 30 +- .../tests/telemetry_resource_tests.rs | 229 + apollo-router/tests/tracing_common/mod.rs | 16 +- apollo-router/tests/type_conditions.rs | 58 +- deny.toml | 56 +- dev-docs/BACKPRESSURE_REVIEW_NOTES.md | 127 + dev-docs/HYPER_1.0_REVIEW_NOTES.md | 115 + dev-docs/HYPER_1.0_UPDATE.md | 129 + dev-docs/layer-inventory.md | 157 + dev-docs/logging.md | 4 +- dev-docs/metrics.md | 193 +- dev-docs/mock_subgraphs_plugin.md | 130 + dev-docs/yaml-design-guidance.md | 6 +- docker-compose.yml | 25 +- dockerfiles/Dockerfile.router | 22 +- dockerfiles/diy/build_docker_image.sh | 1 + dockerfiles/diy/dockerfiles/Dockerfile.repo | 5 +- dockerfiles/docker-compose-redis.yml | 24 +- dockerfiles/download_and_validate_router.sh | 116 + .../tracing/datadog-subgraph/Dockerfile | 10 - dockerfiles/tracing/datadog-subgraph/index.ts | 85 - .../datadog-subgraph/package-lock.json | 1863 -- .../tracing/datadog-subgraph/package.json | 23 - .../tracing/datadog-subgraph/tracer.ts | 3 - .../tracing/datadog-subgraph/tsconfig.json | 11 - .../tracing/docker-compose.datadog.yml | 40 - dockerfiles/tracing/docker-compose.jaeger.yml | 42 - dockerfiles/tracing/docker-compose.zipkin.yml | 41 - .../tracing/jaeger-subgraph/Dockerfile | 10 - dockerfiles/tracing/jaeger-subgraph/index.ts | 118 - .../tracing/jaeger-subgraph/package-lock.json | 2651 --- .../tracing/jaeger-subgraph/package.json | 24 - .../tracing/jaeger-subgraph/tsconfig.json | 11 - dockerfiles/tracing/router/Dockerfile | 17 - .../tracing/router/datadog.router.yaml | 16 - dockerfiles/tracing/router/jaeger.router.yaml | 17 - dockerfiles/tracing/router/zipkin.router.yaml | 16 - dockerfiles/tracing/supergraph.graphql | 51 - dockerfiles/tracing/supergraph.yml | 6 - .../tracing/zipkin-subgraph/Dockerfile | 10 - dockerfiles/tracing/zipkin-subgraph/index.ts | 117 - .../tracing/zipkin-subgraph/package-lock.json | 2747 --- .../tracing/zipkin-subgraph/package.json | 25 - .../tracing/zipkin-subgraph/recorder.ts | 8 - .../tracing/zipkin-subgraph/tsconfig.json | 11 - docs/shared/batch-processor-preamble.mdx | 12 +- docs/shared/config/apq.mdx | 30 + docs/shared/config/authentication.mdx | 44 + docs/shared/config/authorization.mdx | 13 + docs/shared/config/batching.mdx | 12 + docs/shared/config/connectors.mdx | 10 + docs/shared/config/coprocessor.mdx | 98 + docs/shared/config/cors.mdx | 36 + docs/shared/config/csrf.mdx | 9 + docs/shared/config/demand_control.mdx | 11 + docs/shared/config/experimental_chaos.mdx | 6 + ...experimental_type_conditioned_fetching.mdx | 5 + docs/shared/config/fleet_detector.mdx | 5 + docs/shared/config/forbid_mutations.mdx | 5 + docs/shared/config/headers.mdx | 11 + docs/shared/config/health_check.mdx | 13 + docs/shared/config/homepage.mdx | 7 + .../shared/config/include_subgraph_errors.mdx | 7 + docs/shared/config/license_enforcement.mdx | 5 + docs/shared/config/limits.mdx | 16 + docs/shared/config/override_subgraph_url.mdx | 5 + docs/shared/config/persisted_queries.mdx | 15 + docs/shared/config/plugins.mdx | 5 + docs/shared/config/preview_entity_cache.mdx | 54 + docs/shared/config/preview_file_uploads.mdx | 13 + docs/shared/config/progressive_override.mdx | 5 + docs/shared/config/rhai.mdx | 7 + docs/shared/config/sandbox.mdx | 6 + docs/shared/config/subscription.mdx | 22 + docs/shared/config/supergraph.mdx | 42 + docs/shared/config/telemetry.mdx | 698 + docs/shared/config/tls.mdx | 23 + docs/shared/config/traffic_shaping.mdx | 32 + .../router-request-lifecycle-overview.mdx | 29 + docs/shared/helm-show-router-output.mdx | 64 +- docs/shared/k8s-manual-config.mdx | 47 +- .../shared/router-config-properties-table.mdx | 1469 ++ docs/shared/router-lifecycle-services.mdx | 14 +- docs/shared/router-yaml-complete.mdx | 1096 ++ docs/source/_redirects | 10 - docs/source/_sidebar.yaml | 297 + docs/source/config.json | 3 - .../images/get-started/connector-dark.svg | 97 + docs/source/images/get-started/connector.svg | 97 + .../images/router-quickstart-sandbox.jpg | Bin 0 -> 552843 bytes .../images/router/datadog-apm-ops-example.png | Bin 0 -> 492109 bytes .../source/reference/router/configuration.mdx | 1350 -- docs/source/reference/router/errors.mdx | 107 - .../reference/router/self-hosted-install.mdx | 274 - .../telemetry/instrumentation/selectors.mdx | 111 - .../instrumentation/standard-instruments.mdx | 124 - .../telemetry/trace-exporters/datadog.mdx | 253 - .../telemetry/trace-exporters/jaeger.mdx | 139 - docs/source/routing/about-router.mdx | 150 - docs/source/routing/about-v2.mdx | 71 + docs/source/routing/changelog.mdx | 16 + docs/source/routing/cloud/configuration.mdx | 81 +- docs/source/routing/cloud/dedicated.mdx | 2 +- docs/source/routing/cloud/index.mdx | 4 +- .../routing/cloud/migrate-to-dedicated.mdx | 2 +- docs/source/routing/cloud/serverless.mdx | 2 +- docs/source/routing/cloud/subscriptions.mdx | 3 +- docs/source/routing/configuration/cli.mdx | 335 + docs/source/routing/configuration/envvars.mdx | 83 + .../source/routing/configuration/overview.mdx | 38 + docs/source/routing/configuration/yaml.mdx | 825 + docs/source/routing/configure-your-router.mdx | 103 - .../routing/customization/coprocessor.mdx | 437 +- .../customization/coprocessor/index.mdx | 476 + .../customization/coprocessor/reference.mdx | 844 + .../routing/customization/custom-binary.mdx | 111 +- .../routing/customization/native-plugins.mdx | 113 +- .../source/routing/customization/overview.mdx | 293 +- .../{rhai.mdx => rhai/index.mdx} | 19 +- .../customization/rhai/reference.mdx} | 65 +- docs/source/routing/errors.mdx | 213 + .../federation-version-support.mdx | 17 +- docs/source/routing/get-started.mdx | 317 + docs/source/routing/graphos-features.mdx | 37 + docs/source/routing/graphos-reporting.mdx | 72 +- docs/source/routing/header-propagation.mdx | 46 +- .../license.mdx} | 147 +- .../migration/from-gateway.mdx | 6 + .../observability/client-awareness.mdx | 26 - .../observability/client-id-enforcement.mdx | 77 +- .../debugging-client-requests.mdx | 79 + .../debugging-subgraph-requests.mdx | 16 +- .../observability/federated-trace-data.mdx | 82 + docs/source/routing/observability/index.mdx | 63 +- .../otel-traces-to-prometheus.mdx | 8 +- docs/source/routing/observability/otel.mdx | 287 + .../subgraph-error-inclusion.mdx | 150 +- .../routing/observability/telemetry.mdx | 158 - .../routing/observability/telemetry/index.mdx | 338 + .../telemetry/instrumentation/conditions.mdx | 76 + .../telemetry/instrumentation/events.mdx | 77 +- .../telemetry/instrumentation/instruments.mdx | 80 +- .../telemetry/instrumentation/selectors.mdx | 247 + .../telemetry/instrumentation/spans.mdx | 34 +- .../instrumentation/standard-attributes.mdx | 14 + .../instrumentation/standard-instruments.mdx | 192 + .../telemetry/log-exporters/overview.mdx | 27 +- .../telemetry/log-exporters/stdout.mdx | 2 + .../telemetry/metrics-exporters/datadog.mdx | 47 +- .../telemetry/metrics-exporters/dynatrace.mdx | 10 +- .../telemetry/metrics-exporters/new-relic.mdx | 9 +- .../telemetry/metrics-exporters/otlp.mdx | 3 + .../telemetry/metrics-exporters/overview.mdx | 85 +- .../metrics-exporters/prometheus.mdx | 60 +- .../telemetry/trace-exporters/datadog.mdx | 469 + .../telemetry/trace-exporters/dynatrace.mdx | 10 +- .../telemetry/trace-exporters/jaeger.mdx | 43 + .../telemetry/trace-exporters/new-relic.mdx | 13 +- .../telemetry/trace-exporters/otlp.mdx | 2 + .../telemetry/trace-exporters/overview.mdx | 75 +- .../telemetry/trace-exporters/zipkin.mdx | 2 + docs/source/routing/operations/defer.mdx | 17 +- .../source/routing/operations/file-upload.mdx | 5 +- docs/source/routing/operations/index.mdx | 11 - .../operations/subscriptions/api-gateway.mdx | 6 +- .../{index.mdx => configuration.mdx} | 88 +- .../subscriptions/multipart-protocol.mdx | 7 + .../operations/subscriptions/overview.mdx | 46 + .../performance/caching/distributed.mdx | 34 +- .../routing/performance/caching/entity.mdx | 49 +- .../routing/performance/caching/in-memory.mdx | 43 +- .../routing/performance/caching/index.mdx | 29 +- .../routing/performance/query-batching.mdx | 85 +- .../performance/query-planner-pools.mdx | 34 - .../routing/performance/traffic-shaping.mdx | 41 +- .../query-planning/native-query-planner.mdx | 67 +- .../query-planning-best-practices.mdx | 186 + docs/source/routing/request-lifecycle.mdx | 269 + .../routing/router-api-gateway-comparison.mdx | 112 +- .../security/authorization-overview.mdx | 112 + .../source/routing/security/authorization.mdx | 276 +- docs/source/routing/security/cors.mdx | 44 +- docs/source/routing/security/csrf.mdx | 2 +- .../routing/security/demand-control.mdx | 51 +- docs/source/routing/security/jwt.mdx | 96 +- .../routing/security/persisted-queries.mdx | 94 +- .../routing/security/request-limits.mdx | 13 +- .../security/router-authentication.mdx | 32 +- .../security/subgraph-authentication.mdx | 2 +- docs/source/routing/security/tls.mdx | 21 +- .../self-hosted/containerization/aws.mdx | 156 + .../self-hosted/containerization/azure.mdx | 157 + .../containerization/docker-router-only.mdx | 196 + .../self-hosted/containerization/docker.mdx | 155 +- .../self-hosted/containerization/gcp.mdx | 155 + .../self-hosted/containerization/index.mdx | 30 +- .../containerization/kubernetes.mdx | 301 - .../kubernetes/extensibility.mdx | 138 + .../containerization/kubernetes/metrics.mdx | 47 + .../kubernetes/other-considerations.mdx | 59 + .../kubernetes/quickstart.mdx | 135 + .../routing/self-hosted/health-checks.mdx | 35 +- docs/source/routing/self-hosted/index.mdx | 50 +- .../self-hosted/resource-estimator.mdx | 6 +- .../self-hosted/resource-management.mdx | 6 +- .../upgrade/from-router-v0.mdx} | 0 .../source/routing/upgrade/from-router-v1.mdx | 639 + docs/source/routing/uplink.mdx | 172 + examples/add-timestamp-header/rhai/Cargo.toml | 5 +- examples/async-auth/rust/Cargo.toml | 5 +- .../rust/src/allow_client_id_from_file.rs | 12 +- examples/async-auth/rust/src/main.rs | 2 + examples/cache-control/rhai/Cargo.toml | 7 +- .../cache-control/rhai/src/cache_control.rhai | 22 +- examples/cache-control/rhai/src/main.rs | 40 +- examples/context/rust/Cargo.toml | 5 +- examples/cookies-to-headers/rhai/Cargo.toml | 5 +- examples/cookies-to-headers/rhai/src/main.rs | 2 +- .../package-lock.json | 721 +- .../src/index.ts | 4 +- examples/coprocessor-subgraph/rust/Cargo.toml | 8 +- .../rust/src/echo_co_processor.rs | 15 +- .../coprocessor-surrogate-cache-key/README.md | 124 + .../nodejs/.gitignore | 2 + .../nodejs/README.md | 16 + .../nodejs/package.json | 15 + .../nodejs/router.yaml | 36 + .../nodejs/src/index.js | 110 + examples/data-response-mutate/rhai/Cargo.toml | 5 +- .../error-response-mutate/rhai/Cargo.toml | 5 +- .../rhai/Cargo.toml | 5 +- .../rust/Cargo.toml | 5 +- .../rust/src/main.rs | 2 + examples/hello-world/rust/Cargo.toml | 3 +- examples/jwt-claims/rhai/Cargo.toml | 5 +- examples/logging/rhai/Cargo.toml | 5 +- examples/op-name-to-header/rhai/Cargo.toml | 5 +- .../status-code-propagation/rust/Cargo.toml | 5 +- .../status-code-propagation/rust/router.yaml | 2 +- .../rust/src/propagate_status_code.rs | 4 +- examples/subgraph-request-log/rhai/Cargo.toml | 5 +- examples/supergraph-sdl/rust/Cargo.toml | 5 +- examples/surrogate-cache-key/rhai/Cargo.toml | 5 +- examples/telemetry/jaeger-agent.router.yaml | 9 - .../telemetry/jaeger-collector.router.yaml | 12 - examples/throw-error/rhai/Cargo.toml | 9 +- examples/throw-error/rhai/src/main.rs | 9 +- fuzz/Cargo.toml | 13 +- fuzz/README.md | 11 +- fuzz/docker-compose.yml | 38 - .../fuzz_targets/connector_selection_parse.rs | 112 + fuzz/fuzz_targets/federation.rs | 138 - fuzz/fuzz_targets/router.rs | 12 +- fuzz/fuzz_targets/router_errors.rs | 6 +- fuzz/router.yaml | 1 - fuzz/subgraph/Cargo.toml | 9 +- fuzz/subgraph/src/main.rs | 6 +- fuzz/subgraph/src/model.rs | 168 +- fuzz/supergraph-moretypes.graphql | 169 - fuzz/supergraph.graphql | 147 - helm/chart/router/Chart.yaml | 4 +- helm/chart/router/README.md | 11 +- helm/chart/router/templates/configmap.yaml | 2 +- helm/chart/router/templates/service.yaml | 2 +- .../router/templates/virtualservice.yaml | 12 +- helm/chart/router/values.yaml | 3 +- licenses.html | 7143 ++++++-- netlify.toml | 19 - renovate.json5 | 204 +- rust-toolchain.toml | 4 +- scripts/install.sh | 5 +- xtask/Cargo.lock | 2167 --- xtask/Cargo.toml | 20 +- xtask/README.md | 2 +- xtask/src/commands/changeset/mod.rs | 25 +- xtask/src/commands/compliance.rs | 7 +- xtask/src/commands/dev.rs | 9 +- xtask/src/commands/dist.rs | 5 +- xtask/src/commands/licenses.rs | 2 +- xtask/src/commands/lint.rs | 1 + xtask/src/commands/mod.rs | 2 + xtask/src/commands/package/macos.rs | 2 +- xtask/src/commands/release.rs | 96 +- xtask/src/commands/release/process.rs | 840 - xtask/src/commands/test.rs | 11 +- xtask/src/commands/unused.rs | 19 + xtask/src/lib.rs | 6 +- xtask/src/main.rs | 4 + 2350 files changed, 286541 insertions(+), 125899 deletions(-) delete mode 100644 .changesets/docs_clarify_authorization_directive_composition.md delete mode 100644 .changesets/feat_add_dns_resolution_strategy.md create mode 100644 .changesets/feat_feat-enhance-error-logging-trace-id.md delete mode 100644 .changesets/feat_geal_subgraph_request_id.md delete mode 100644 .changesets/feat_lrlna_enable_generate_query_fragments_by_default.md delete mode 100644 .changesets/feat_max_headers.md create mode 100644 .changesets/fix_duckki_fed_678.md delete mode 100644 .changesets/fix_garypen_log_less_error_for_subgraph_batching.md delete mode 100644 .changesets/fix_geal_deduplication_processing_time.md delete mode 100644 .changesets/fix_geal_introspection_dedup_fix.md delete mode 100644 .changesets/fix_geal_response_validation_errors.md delete mode 100644 .changesets/fix_renee_limit_errors.md delete mode 100644 .changesets/fix_tninesling_remove_demand_control_warnings.md delete mode 100644 .changesets/fix_tninesling_typename_resolution.md delete mode 100644 .changesets/helm_host_configuration.md delete mode 100644 .changesets/maint_bnjjj_fix_supergraph_events_span.md delete mode 100644 .changesets/maint_geal_query_planner_cache_key_improvements.md rename .gitmodules => .changesets/sedceNmNQ (100%) create mode 100644 .config/mise/config.ci-mac.toml create mode 100644 .config/mise/config.ci.toml create mode 100644 .config/mise/config.toml create mode 100644 .config/mise/config.windows.toml delete mode 100755 .githooks/prepare-commit-msg delete mode 100644 .gitmessage delete mode 100644 .ignore create mode 100644 .mergify.yml delete mode 100644 ROADMAP.md create mode 100644 apollo-federation/src/composition/mod.rs create mode 100644 apollo-federation/src/composition/satisfiability.rs create mode 100644 apollo-federation/src/composition/satisfiability/conditions_validation.rs create mode 100644 apollo-federation/src/composition/satisfiability/satisfiability_error.rs create mode 100644 apollo-federation/src/composition/satisfiability/validation_context.rs create mode 100644 apollo-federation/src/composition/satisfiability/validation_state.rs create mode 100644 apollo-federation/src/composition/satisfiability/validation_traversal.rs create mode 100644 apollo-federation/src/connectors/expand/carryover.rs create mode 100644 apollo-federation/src/connectors/expand/carryover/inputs.rs rename apollo-federation/src/{sources/connect => connectors}/expand/merge/basic_1.graphql (85%) rename apollo-federation/src/{sources/connect => connectors}/expand/merge/basic_2.graphql (66%) rename apollo-federation/src/{sources/connect => connectors}/expand/merge/connector_Query_user_0.graphql (100%) rename apollo-federation/src/{sources/connect => connectors}/expand/merge/connector_Query_users_0.graphql (100%) rename apollo-federation/src/{sources/connect => connectors}/expand/merge/connector_User_d_1.graphql (100%) rename apollo-federation/src/{sources/connect => connectors}/expand/merge/graphql.graphql (100%) rename apollo-federation/src/{sources/connect => connectors}/expand/merge/inaccessible.graphql (100%) rename apollo-federation/src/{sources/connect => connectors}/expand/merge/inaccessible_2.graphql (100%) create mode 100644 apollo-federation/src/connectors/expand/mod.rs create mode 100644 apollo-federation/src/connectors/expand/snapshots/apollo_federation__connectors__expand__carryover__tests__carryover.snap create mode 100644 apollo-federation/src/connectors/expand/tests/mod.rs create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/batch.graphql create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/batch.yaml create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/carryover.graphql create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/carryover.yaml create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/directives.graphql create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/directives.yaml create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/interface-object.graphql create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/interface-object.yaml create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/keys.graphql create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/keys.yaml create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/nested_inputs.graphql create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/nested_inputs.yaml create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/normalize_names.graphql create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/normalize_names.yaml create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/realistic.graphql create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/realistic.yaml create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/sibling_fields.graphql create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/sibling_fields.yaml create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/simple.graphql create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/simple.yaml create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/steelthread.graphql create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/steelthread.yaml create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/types_used_twice.graphql create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/expand/types_used_twice.yaml create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/ignore/ignored.graphql create mode 100644 apollo-federation/src/connectors/expand/tests/schemas/ignore/ignored.yaml create mode 100755 apollo-federation/src/connectors/expand/tests/schemas/regenerate.sh create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/api@batch.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/api@carryover.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/api@directives.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/api@interface-object.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/api@keys.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/api@nested_inputs.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/api@normalize_names.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/api@realistic.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/api@sibling_fields.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/api@simple.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/api@steelthread.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/api@types_used_twice.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/connectors@batch.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/connectors@carryover.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/connectors@directives.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/connectors@interface-object.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/connectors@keys.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/connectors@nested_inputs.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/connectors@normalize_names.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/connectors@realistic.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/connectors@sibling_fields.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/connectors@simple.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/connectors@steelthread.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/connectors@types_used_twice.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/supergraph@batch.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/supergraph@carryover.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/supergraph@directives.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/supergraph@interface-object.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/supergraph@keys.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/supergraph@nested_inputs.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/supergraph@normalize_names.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/supergraph@realistic.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/supergraph@sibling_fields.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/supergraph@simple.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/supergraph@steelthread.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/tests/snapshots/supergraph@types_used_twice.graphql.snap create mode 100644 apollo-federation/src/connectors/expand/visitors/input.rs create mode 100644 apollo-federation/src/connectors/expand/visitors/mod.rs create mode 100644 apollo-federation/src/connectors/expand/visitors/selection.rs create mode 100644 apollo-federation/src/connectors/header.rs create mode 100644 apollo-federation/src/connectors/id.rs create mode 100644 apollo-federation/src/connectors/json_selection/README.md create mode 100644 apollo-federation/src/connectors/json_selection/apply_to.rs create mode 100644 apollo-federation/src/connectors/json_selection/fixtures.rs create mode 100644 apollo-federation/src/connectors/json_selection/grammar/Alias.svg rename apollo-federation/src/{sources/connect/json_selection/grammar/Alias.svg => connectors/json_selection/grammar/AtPath.svg} (53%) create mode 100644 apollo-federation/src/connectors/json_selection/grammar/Comment.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/ExprPath.svg rename apollo-federation/src/{sources/connect => connectors}/json_selection/grammar/Identifier.svg (85%) rename apollo-federation/src/{sources/connect/json_selection/grammar/NakedSubSelection.svg => connectors/json_selection/grammar/JSONSelection.svg} (50%) rename apollo-federation/src/{sources/connect => connectors}/json_selection/grammar/Key.svg (51%) create mode 100644 apollo-federation/src/connectors/json_selection/grammar/KeyPath.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/LitArray.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/LitExpr.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/LitNumber.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/LitObject.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/LitOp.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/LitOpChain.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/LitPath.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/LitPrimitive.svg rename apollo-federation/src/{sources/connect/json_selection/grammar/JSProperty.svg => connectors/json_selection/grammar/LitProperty.svg} (59%) create mode 100644 apollo-federation/src/connectors/json_selection/grammar/LitString.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/MethodArgs.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/NamedSelection.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/NonEmptyPathTail.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/PathSelection.svg rename apollo-federation/src/{sources/connect => connectors}/json_selection/grammar/PathStep.svg (82%) create mode 100644 apollo-federation/src/connectors/json_selection/grammar/PathTail.svg rename apollo-federation/src/{sources/connect => connectors}/json_selection/grammar/Spaces.svg (80%) rename apollo-federation/src/{sources/connect => connectors}/json_selection/grammar/SpacesOrComments.svg (76%) create mode 100644 apollo-federation/src/connectors/json_selection/grammar/SubSelection.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/VarPath.svg create mode 100644 apollo-federation/src/connectors/json_selection/grammar/rr-2.5.svg create mode 100644 apollo-federation/src/connectors/json_selection/helpers.rs create mode 100644 apollo-federation/src/connectors/json_selection/immutable.rs create mode 100644 apollo-federation/src/connectors/json_selection/known_var.rs create mode 100644 apollo-federation/src/connectors/json_selection/lit_expr.rs create mode 100644 apollo-federation/src/connectors/json_selection/location.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/common.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/future/has.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/future/keys.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/future/match_if.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/future/mod.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/future/snapshots/get_should_correct_call_methods_with_extra_spaces.snap create mode 100644 apollo-federation/src/connectors/json_selection/methods/future/typeof.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/future/values.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/and.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/arithmetic.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/contains.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/echo.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/entries.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/eq.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/filter.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/find.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/first.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/get.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/gt.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/gte.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/in.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/join_not_null.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/json_stringify.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/last.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/lt.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/lte.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/map.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/match.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/mod.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/ne.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/not.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/or.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/parse_int.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/size.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/slice.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/snapshots/get_should_correct_call_methods_with_extra_spaces.snap create mode 100644 apollo-federation/src/connectors/json_selection/methods/public/to_string.rs create mode 100644 apollo-federation/src/connectors/json_selection/methods/tests.rs create mode 100644 apollo-federation/src/connectors/json_selection/mod.rs create mode 100644 apollo-federation/src/connectors/json_selection/parser.rs create mode 100644 apollo-federation/src/connectors/json_selection/pretty.rs create mode 100644 apollo-federation/src/connectors/json_selection/selection_set.rs create mode 100644 apollo-federation/src/connectors/json_selection/selection_trie.rs create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/arrow_path_ranges.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-10.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-3.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-4.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-5.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-6.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-7.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-8.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-9.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing_one_field.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/check_many.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/error_snapshots-2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/error_snapshots.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_2-2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_3-2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_3.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/expr_path_selections-2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/expr_path_selections.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-3.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-4.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-5.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-6.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/optional_field_selections.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/parse_with_range_snapshots.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/path_expr_with_spaces_v0_2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/path_expr_with_spaces_v0_3.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-3.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-4.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-5.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-6.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-7.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/selection.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/single_key_paths-2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/single_key_paths-3.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/single_key_paths.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_3-2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_3-3.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_3.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_a_spread_b.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_b.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_b_c.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_spread_b.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_b_c.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_b_spread_c.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_b_spread_c_d.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_spread_b_c_d_spread_e.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_spread_a_sub_b.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/unambiguous_single_key_paths_v0_2.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/unambiguous_single_key_paths_v0_3.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/valid_single_key_path_v0_3.snap create mode 100644 apollo-federation/src/connectors/json_selection/snapshots/valid_single_key_path_with_alias_v0_3.snap create mode 100644 apollo-federation/src/connectors/mod.rs create mode 100644 apollo-federation/src/connectors/models.rs create mode 100644 apollo-federation/src/connectors/models/headers.rs create mode 100644 apollo-federation/src/connectors/models/http_json_transport.rs create mode 100644 apollo-federation/src/connectors/models/keys.rs create mode 100644 apollo-federation/src/connectors/models/problem_location.rs create mode 100644 apollo-federation/src/connectors/models/source.rs create mode 100644 apollo-federation/src/connectors/runtime.rs create mode 100644 apollo-federation/src/connectors/runtime/debug.rs create mode 100644 apollo-federation/src/connectors/runtime/errors.rs create mode 100644 apollo-federation/src/connectors/runtime/form_encoding.rs create mode 100644 apollo-federation/src/connectors/runtime/http_json_transport.rs create mode 100644 apollo-federation/src/connectors/runtime/inputs.rs create mode 100644 apollo-federation/src/connectors/runtime/key.rs create mode 100644 apollo-federation/src/connectors/runtime/mapping.rs create mode 100644 apollo-federation/src/connectors/runtime/responses.rs create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__models__tests__from_schema_v0_2.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__expressions_with_nested_braces.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__mixed_constant_and_expression.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__offset.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__simple_constant.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__simple_expression.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_path.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_path_variable.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_query.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_query_variable.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__basic_absolute_url.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__expression_missing_closing_bracket.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__nested_braces_in_expression.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list-2.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list-3.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list-4.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse-2.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse-3.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse-4.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse.snap create mode 100644 apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__variable_param_key.snap create mode 100644 apollo-federation/src/connectors/spec/connect.rs create mode 100644 apollo-federation/src/connectors/spec/errors.rs create mode 100644 apollo-federation/src/connectors/spec/http.rs create mode 100644 apollo-federation/src/connectors/spec/mod.rs create mode 100644 apollo-federation/src/connectors/spec/source.rs create mode 100644 apollo-federation/src/connectors/spec/type_and_directive_specifications.rs create mode 100644 apollo-federation/src/connectors/string_template.rs create mode 100644 apollo-federation/src/connectors/tests/schemas/duplicated_id.graphql create mode 100644 apollo-federation/src/connectors/tests/schemas/duplicated_id.yaml create mode 100644 apollo-federation/src/connectors/tests/schemas/is-success-source.graphql create mode 100644 apollo-federation/src/connectors/tests/schemas/is-success-source.yaml create mode 100644 apollo-federation/src/connectors/tests/schemas/is-success.graphql create mode 100644 apollo-federation/src/connectors/tests/schemas/is-success.yaml create mode 100644 apollo-federation/src/connectors/tests/schemas/simple.graphql create mode 100644 apollo-federation/src/connectors/tests/schemas/simple.yaml create mode 100644 apollo-federation/src/connectors/tests/schemas/simple_v0_2.graphql create mode 100644 apollo-federation/src/connectors/tests/schemas/simple_v0_2.yaml create mode 100644 apollo-federation/src/connectors/tests/schemas/source-template.graphql create mode 100644 apollo-federation/src/connectors/tests/schemas/source-template.yaml create mode 100644 apollo-federation/src/connectors/validation/connect.rs create mode 100644 apollo-federation/src/connectors/validation/connect/entity.rs create mode 100644 apollo-federation/src/connectors/validation/connect/http.rs create mode 100644 apollo-federation/src/connectors/validation/connect/selection.rs create mode 100644 apollo-federation/src/connectors/validation/connect/selection/variables.rs create mode 100644 apollo-federation/src/connectors/validation/coordinates.rs create mode 100644 apollo-federation/src/connectors/validation/errors.rs create mode 100644 apollo-federation/src/connectors/validation/expression.rs create mode 100644 apollo-federation/src/connectors/validation/graphql.rs create mode 100644 apollo-federation/src/connectors/validation/graphql/strings.rs create mode 100644 apollo-federation/src/connectors/validation/http.rs create mode 100644 apollo-federation/src/connectors/validation/http/headers.rs create mode 100644 apollo-federation/src/connectors/validation/http/url.rs create mode 100644 apollo-federation/src/connectors/validation/http/url_properties.rs create mode 100644 apollo-federation/src/connectors/validation/mod.rs create mode 100644 apollo-federation/src/connectors/validation/schema.rs create mode 100644 apollo-federation/src/connectors/validation/schema/keys.rs create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@all_fields_selected.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@all_fields_selected_repro.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@batch.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_alias_happy_path.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_incorrect_context_key.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_incorrect_field.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_missing_key.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_missing_nested_key.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_nested_keys.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@body_selection.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@circular_reference.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@circular_reference_2.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@circular_reference_3.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@connect_source_name_mismatch.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@connect_source_undefined.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@connect_spec_version_error.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@denest_scalars.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@denest_scalars2.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@disallowed_abstract_types.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@disallowed_federation_imports.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@duplicate_source_name.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@duplicated_ids.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@empty_selection.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@empty_source_name.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@env-vars.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@errors.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@fields_with_arguments.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@group_selection_on_scalar.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__disallowed_header_names.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__disallowed_header_names_v0.2.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__expressions_that_evaluate_to_invalid_types_v0_2.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__expressions_that_evaluate_to_invalid_types_v0_3.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_connect_http_headers.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_namespace_in_header_variables.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_nested_paths_in_header_variables.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_source_http_headers.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_chars_in_source_name.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_id_name.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_namespace_in_body_selection.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_nested_paths_in_json_selection.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_selection_syntax.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_connect_happy_path.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_invalid_connect.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_invalid_sources.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_source_happy_path.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__arg_is_object_but_field_is_not.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__arg_type_doesnt_match_field_type.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__composite_key_doesnt_match.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_arg_field_arg_name_mismatch.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_arg_field_arg_name_mismatch_composite_key.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_false_on_type.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_on_list_type.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_on_non_root_field.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_returning_non_null_type.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_returning_scalar.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__mismatch_batch.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__mismatch_composite_key_batch.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__multiple_keys_not_all_resolved.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__multiple_keys_not_all_resolved_batch.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__no_args_for_entity_true.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__unrelated_keys.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__basic_implicit_key.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__basic_implicit_key_batch.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_non_resolvable_key.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_non_resolvable_key_batch.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_one_of_multiple_keys.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_one_of_multiple_keys_batch.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_field_counts_as_key_resolver.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_field_counts_as_key_resolver_batch.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_true_on_type.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__mix_explicit_and_implicit.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__mix_explicit_and_implicit_batch.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__multiple_entity_connectors_for_multiple_keys.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__multiple_entity_connectors_for_multiple_keys_batch.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_connect_on_mutation_field.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_connect_on_query_field.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_http_method_on_connect.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_source_import.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@multiple_errors.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@multiple_http_methods_on_connect.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@non_root_circular_reference.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@question_v0_2.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@question_v0_3.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@renamed_connect_directive.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@request_headers.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@select_nonexistant_group.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@source_directive_rename.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@subscriptions_with_connectors.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@transformed__upgrade_0.1.graphql-2.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@transformed__upgrade_0.1.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__absolute_connect_url_with_source.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__expressions-in-domain.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid-jsonselection-in-expression.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid-path-parameter.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_connect_url.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_connect_url_scheme.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_namespace_in_url_template_variables.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_nested_paths_in_url_template_variables.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_source_url.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_source_url_scheme.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_source_url_template.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_types.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__relative_connect_url_without_source.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__this_on_root_types.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__undefined_arg_in_url_template.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__undefined_this_in_url_template.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid-expressions-after-domain.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_connect_absolute_multiline.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_connect_absolute_url.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_connect_multiline.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_source_url_template.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@url_properties__invalid_mappings.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@url_properties__path.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@url_properties__query_params.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@valid_large_body.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@valid_no_connect_on_resolvable_key_field.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/snapshots/validation_tests@valid_selection_with_escapes.graphql.snap create mode 100644 apollo-federation/src/connectors/validation/source.rs create mode 100644 apollo-federation/src/connectors/validation/test_data/all_fields_selected.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/all_fields_selected_repro.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/batch.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/batch/batch_alias_happy_path.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/batch/batch_incorrect_context_key.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/batch/batch_incorrect_field.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/batch/batch_missing_key.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/batch/batch_missing_nested_key.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/batch/batch_nested_keys.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/body_selection.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/circular_reference.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/circular_reference_2.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/circular_reference_3.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/connect_source_name_mismatch.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/connect_source_undefined.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/connect_spec_version_error.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/denest_scalars.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/denest_scalars2.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/disallowed_abstract_types.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/disallowed_federation_imports.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/duplicate_source_name.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/duplicated_ids.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/empty_selection.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/empty_source_name.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/env-vars.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/errors.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/fields_with_arguments.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/group_selection_on_scalar.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/headers/disallowed_header_names.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/headers/disallowed_header_names_v0.2.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/headers/expressions_that_evaluate_to_invalid_types_v0_2.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/headers/expressions_that_evaluate_to_invalid_types_v0_3.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/headers/invalid_connect_http_headers.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/headers/invalid_namespace_in_header_variables.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/headers/invalid_nested_paths_in_header_variables.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/headers/invalid_source_http_headers.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/invalid_chars_in_source_name.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/invalid_id_name.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/invalid_namespace_in_body_selection.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/invalid_nested_paths_in_json_selection.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/invalid_selection_syntax.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/is_success/is_success_connect_happy_path.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/is_success/is_success_invalid_connect.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/is_success/is_success_invalid_sources.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/is_success/is_success_source_happy_path.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/arg_is_object_but_field_is_not.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/arg_type_doesnt_match_field_type.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/composite_key_doesnt_match.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_arg_field_arg_name_mismatch.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_arg_field_arg_name_mismatch_composite_key.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_false_on_type.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_on_list_type.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_on_non_root_field.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_returning_non_null_type.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_returning_scalar.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/mismatch_batch.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/mismatch_composite_key_batch.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/multiple_keys_not_all_resolved.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/multiple_keys_not_all_resolved_batch.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/no_args_for_entity_true.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/unrelated_keys.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/basic_implicit_key.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/basic_implicit_key_batch.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_non_resolvable_key.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_non_resolvable_key_batch.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_one_of_multiple_keys.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_one_of_multiple_keys_batch.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_field_counts_as_key_resolver.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_field_counts_as_key_resolver_batch.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_true_on_type.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/mix_explicit_and_implicit.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/mix_explicit_and_implicit_batch.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/multiple_entity_connectors_for_multiple_keys.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/multiple_entity_connectors_for_multiple_keys_batch.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/missing_connect_on_mutation_field.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/missing_connect_on_query_field.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/missing_http_method_on_connect.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/missing_source_import.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/multiple_errors.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/multiple_http_methods_on_connect.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/non_root_circular_reference.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/question_v0_2.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/question_v0_3.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/renamed_connect_directive.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/request_headers.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/select_nonexistant_group.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/source_directive_rename.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/subscriptions_with_connectors.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/transformed/upgrade_0.1.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/absolute_connect_url_with_source.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/expressions-in-domain.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/invalid-jsonselection-in-expression.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/invalid-path-parameter.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_connect_url.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_connect_url_scheme.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_namespace_in_url_template_variables.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_nested_paths_in_url_template_variables.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url_scheme.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url_template.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_types.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/relative_connect_url_without_source.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/this_on_root_types.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/undefined_arg_in_url_template.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/undefined_this_in_url_template.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/valid-expressions-after-domain.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_absolute_multiline.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_absolute_url.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_multiline.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/uri_templates/valid_source_url_template.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/url_properties/invalid_mappings.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/url_properties/path.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/url_properties/query_params.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/valid_large_body.graphql create mode 100644 apollo-federation/src/connectors/validation/test_data/valid_selection_with_escapes.graphql create mode 100644 apollo-federation/src/connectors/variable.rs create mode 100644 apollo-federation/src/correctness/mod.rs create mode 100644 apollo-federation/src/correctness/query_plan_analysis.rs create mode 100644 apollo-federation/src/correctness/query_plan_analysis_test.rs create mode 100644 apollo-federation/src/correctness/query_plan_soundness.rs create mode 100644 apollo-federation/src/correctness/query_plan_soundness_test.rs create mode 100644 apollo-federation/src/correctness/response_shape.rs create mode 100644 apollo-federation/src/correctness/response_shape_compare.rs create mode 100644 apollo-federation/src/correctness/response_shape_compare_test.rs create mode 100644 apollo-federation/src/correctness/response_shape_test.rs create mode 100644 apollo-federation/src/correctness/subgraph_constraint.rs create mode 100644 apollo-federation/src/error/suggestion.rs create mode 100644 apollo-federation/src/link/authenticated_spec_definition.rs create mode 100644 apollo-federation/src/link/cache_tag_spec_definition.rs create mode 100644 apollo-federation/src/link/context_spec_definition.rs create mode 100644 apollo-federation/src/link/policy_spec_definition.rs create mode 100644 apollo-federation/src/link/requires_scopes_spec_definition.rs create mode 100644 apollo-federation/src/link/tag_spec_definition.rs create mode 100644 apollo-federation/src/merge/fields.rs rename apollo-federation/src/{ => merge}/snapshots/apollo_federation__merge__tests__basic.snap (54%) rename apollo-federation/src/{ => merge}/snapshots/apollo_federation__merge__tests__inaccessible.snap (66%) create mode 100644 apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__input_types.snap create mode 100644 apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__interface_implementing_interface.snap create mode 100644 apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__interface_object.snap rename apollo-federation/src/{ => merge}/snapshots/apollo_federation__merge__tests__steel_thread.snap (68%) create mode 100644 apollo-federation/src/merge/testdata/input_types/one.graphql create mode 100644 apollo-federation/src/merge/testdata/interface_implementing_interface/one.graphql create mode 100644 apollo-federation/src/merge/testdata/interface_object/one.graphql create mode 100644 apollo-federation/src/merge/testdata/interface_object/three.graphql create mode 100644 apollo-federation/src/merge/testdata/interface_object/two.graphql create mode 100644 apollo-federation/src/merge/tests.rs create mode 100644 apollo-federation/src/merger/compose_directive_manager.rs create mode 100644 apollo-federation/src/merger/error_reporter.rs create mode 100644 apollo-federation/src/merger/hints.rs create mode 100644 apollo-federation/src/merger/merge_enum.rs create mode 100644 apollo-federation/src/merger/merge_field.rs create mode 100644 apollo-federation/src/merger/merge_links.rs create mode 100644 apollo-federation/src/merger/merge_type.rs create mode 100644 apollo-federation/src/merger/merge_union.rs create mode 100644 apollo-federation/src/merger/merger.rs create mode 100644 apollo-federation/src/merger/mod.rs delete mode 100644 apollo-federation/src/operation/tests/defer.rs create mode 100644 apollo-federation/src/query_graph/graph_path/operation.rs create mode 100644 apollo-federation/src/query_graph/graph_path/transition.rs create mode 100644 apollo-federation/src/query_plan/query_planning_traversal/non_local_selections_estimation.rs create mode 100644 apollo-federation/src/query_plan/requires_selection.rs create mode 100644 apollo-federation/src/query_plan/serializable_document.rs create mode 100644 apollo-federation/src/schema/blueprint.rs create mode 100644 apollo-federation/src/schema/directive_location.rs create mode 100644 apollo-federation/src/schema/fixtures/field-set-alias.graphqls create mode 100644 apollo-federation/src/schema/fixtures/shareable_fields.graphqls create mode 100644 apollo-federation/src/schema/fixtures/used_fields.graphqls create mode 100644 apollo-federation/src/schema/locations.rs create mode 100644 apollo-federation/src/schema/schema_upgrader.rs create mode 100644 apollo-federation/src/schema/validators/cache_tag.rs create mode 100644 apollo-federation/src/schema/validators/context.rs create mode 100644 apollo-federation/src/schema/validators/cost.rs create mode 100644 apollo-federation/src/schema/validators/external.rs create mode 100644 apollo-federation/src/schema/validators/from_context.rs create mode 100644 apollo-federation/src/schema/validators/interface_object.rs create mode 100644 apollo-federation/src/schema/validators/key.rs create mode 100644 apollo-federation/src/schema/validators/list_size.rs create mode 100644 apollo-federation/src/schema/validators/merged.rs create mode 100644 apollo-federation/src/schema/validators/mod.rs create mode 100644 apollo-federation/src/schema/validators/provides.rs create mode 100644 apollo-federation/src/schema/validators/requires.rs create mode 100644 apollo-federation/src/schema/validators/root_fields.rs create mode 100644 apollo-federation/src/schema/validators/shareable.rs create mode 100644 apollo-federation/src/schema/validators/tag.rs delete mode 100644 apollo-federation/src/sources/connect/json_selection/README.md delete mode 100644 apollo-federation/src/sources/connect/json_selection/apply_to.rs delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/Comment.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/JSArray.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/JSLiteral.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/JSNumber.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/JSObject.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/JSPrimitive.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/KeyPath.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/MethodArgs.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/NamedFieldSelection.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/NamedPathSelection.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/NamedSelection.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/StringLiteral.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/SubSelection.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/UnsignedInt.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/VarPath.svg delete mode 100644 apollo-federation/src/sources/connect/json_selection/graphql.rs delete mode 100644 apollo-federation/src/sources/connect/json_selection/helpers.rs delete mode 100644 apollo-federation/src/sources/connect/json_selection/mod.rs delete mode 100644 apollo-federation/src/sources/connect/json_selection/parser.rs delete mode 100644 apollo-federation/src/sources/connect/json_selection/pretty.rs delete mode 100644 apollo-federation/src/sources/connect/mod.rs delete mode 100644 apollo-federation/src/sources/connect/url_path_template.rs delete mode 100644 apollo-federation/src/sources/mod.rs delete mode 100644 apollo-federation/src/subgraph/database.rs create mode 100644 apollo-federation/src/subgraph/typestate.rs create mode 100644 apollo-federation/src/supergraph/join_directive.rs create mode 100644 apollo-federation/src/utils/human_readable.rs create mode 100644 apollo-federation/src/utils/multi_index_map.rs create mode 100644 apollo-federation/src/utils/serde_bridge.rs create mode 100644 apollo-federation/tests/composition/compose_directive.rs create mode 100644 apollo-federation/tests/composition/demand_control.rs create mode 100644 apollo-federation/tests/composition/mod.rs create mode 100644 apollo-federation/tests/composition/validation_errors.rs create mode 100644 apollo-federation/tests/core_test.rs create mode 100644 apollo-federation/tests/dhat_profiling/connectors_validation.rs create mode 100644 apollo-federation/tests/dhat_profiling/query_plan.rs create mode 100644 apollo-federation/tests/dhat_profiling/supergraph.rs create mode 100644 apollo-federation/tests/query_plan/build_query_plan_tests/cancel.rs create mode 100644 apollo-federation/tests/query_plan/build_query_plan_tests/context.rs create mode 100644 apollo-federation/tests/query_plan/build_query_plan_tests/disable_subgraphs.rs create mode 100644 apollo-federation/tests/query_plan/build_query_plan_tests/entities.rs delete mode 100644 apollo-federation/tests/query_plan/build_query_plan_tests/named_fragments.rs create mode 100644 apollo-federation/tests/query_plan/build_query_plan_tests/named_fragments_expansion.rs delete mode 100644 apollo-federation/tests/query_plan/build_query_plan_tests/named_fragments_preservation.rs delete mode 100644 apollo-federation/tests/query_plan/operation_validations_tests.rs create mode 100644 apollo-federation/tests/query_plan/supergraphs/allows_post_requires_input_with_typename_on_interface_object_type.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/defer_on_renamed_root_type.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/inefficient_entity_fetches_to_same_subgraph.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/it_expands_nested_fragments.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/it_handles_nested_fragment_generation_from_operation_with_fragments.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/it_preserves_directives.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/it_preserves_directives_on_collapsed_fragments.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/override_a_field_from_an_interface.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/plan_with_check.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/set_context_one_subgraph.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/set_context_required_field_is_several_levels_deep_going_back_and_forth_between_subgraphs.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/set_context_test_accesses_a_different_top_level_query.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/set_context_test_before_key_resolution_transition.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/set_context_test_efficiently_merge_fetch_groups.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/set_context_test_fetched_as_a_list.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/set_context_test_impacts_on_query_planning.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_a_list.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_already_in_a_different_fetch_group.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_from_different_subgraph.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_from_same_subgraph.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/set_context_test_with_type_conditions_for_union.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/test_callback_is_called.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/test_cancel_as_soon_as_possible.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/test_cancel_late_enough_that_planning_finishes.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/test_cancel_near_the_middle.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/test_if_disabling_all_subgraph_jumps_causes_error.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/test_if_disabling_less_expensive_subgraph_jump_causes_other_to_be_used.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/test_if_less_expensive_subgraph_jump_is_used.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/test_type_conditioned_fetching_with_interface_object_does_not_crash.graphql rename apollo-federation/tests/query_plan/supergraphs/{trying_to_use_defer_with_a_subcription_results_in_an_error.graphql => trying_to_use_defer_with_a_subscription_results_in_an_error.graphql} (100%) create mode 100644 apollo-federation/tests/query_plan/supergraphs/trying_to_use_include_with_a_subscription_results_in_an_error.graphql create mode 100644 apollo-federation/tests/query_plan/supergraphs/trying_to_use_skip_with_a_subscription_results_in_an_error.graphql create mode 100644 apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_set_context_directives.snap create mode 100644 apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_string_enum_values.snap create mode 100644 apollo-federation/tests/subgraph/fixtures/tag_validation_template.graphqls create mode 100644 apollo-federation/tests/subgraph/subgraph_validation_tests.rs delete mode 100644 apollo-router-scaffold/Cargo.toml delete mode 100644 apollo-router-scaffold/scaffold-test/.cargo/config delete mode 100644 apollo-router-scaffold/scaffold-test/.dockerignore delete mode 100644 apollo-router-scaffold/scaffold-test/.gitignore delete mode 100644 apollo-router-scaffold/scaffold-test/Cargo.toml delete mode 100644 apollo-router-scaffold/scaffold-test/Dockerfile delete mode 100644 apollo-router-scaffold/scaffold-test/README.md delete mode 100644 apollo-router-scaffold/scaffold-test/router.yaml delete mode 100644 apollo-router-scaffold/scaffold-test/src/main.rs delete mode 100644 apollo-router-scaffold/scaffold-test/src/plugins/auth.rs delete mode 100644 apollo-router-scaffold/scaffold-test/src/plugins/basic.rs delete mode 100644 apollo-router-scaffold/scaffold-test/src/plugins/mod.rs delete mode 100644 apollo-router-scaffold/scaffold-test/src/plugins/tracing.rs delete mode 100644 apollo-router-scaffold/scaffold-test/xtask/Cargo.toml delete mode 100644 apollo-router-scaffold/scaffold-test/xtask/src/main.rs delete mode 100644 apollo-router-scaffold/src/lib.rs delete mode 100644 apollo-router-scaffold/src/plugin.rs delete mode 100644 apollo-router-scaffold/templates/base/.cargo/config delete mode 100644 apollo-router-scaffold/templates/base/.dockerignore delete mode 100644 apollo-router-scaffold/templates/base/.gitignore delete mode 100644 apollo-router-scaffold/templates/base/.scaffold.toml delete mode 100644 apollo-router-scaffold/templates/base/Cargo.template.toml delete mode 100644 apollo-router-scaffold/templates/base/Dockerfile delete mode 100644 apollo-router-scaffold/templates/base/README.md delete mode 100644 apollo-router-scaffold/templates/base/router.yaml delete mode 100644 apollo-router-scaffold/templates/base/rust-toolchain.toml delete mode 100644 apollo-router-scaffold/templates/base/src/main.rs delete mode 100644 apollo-router-scaffold/templates/base/xtask/Cargo.template.toml delete mode 100644 apollo-router-scaffold/templates/base/xtask/src/main.rs delete mode 100644 apollo-router-scaffold/templates/plugin/.scaffold.toml delete mode 100644 apollo-router-scaffold/templates/plugin/src/plugins/mod.rs delete mode 100644 apollo-router-scaffold/templates/plugin/src/plugins/{{snake_name}}.rs create mode 100644 apollo-router/.sqlx/query-119ea89f7b98079bd3d2ec81596cd04a19fd394c234940976dd32bc507984fca.json create mode 100644 apollo-router/.sqlx/query-5070a632fa2c6fb2766a16d305ca71988b1ef61a8a4dd38e4ac71bcb968d489e.json create mode 100644 apollo-router/.sqlx/query-602e9f11a9cf461010a1523d8e8e261b56416439675a38b316017478797a56ab.json create mode 100644 apollo-router/.sqlx/query-6245ac300b03e217aa5ed15489acf4fdc174de48a1d7fd64c088a15d02c2c690.json create mode 100644 apollo-router/.sqlx/query-6cb3643cda13c340a17084366d2b27163e1b83fe86adf6e6a906a11547a1d05f.json create mode 100644 apollo-router/.sqlx/query-6e77a91b9716475f81f81e188b13813f86f0b8072bbd241ed1090b5c37b932c8.json create mode 100644 apollo-router/.sqlx/query-92256ab4f44ce1b6a034cbb1bfac848ed7767b3aa0e1b3eaeb612f4840f4765e.json create mode 100644 apollo-router/.sqlx/query-9cab35054c927272ec5fb820c6122ec531e1dc6f695c56268bb3546b1f14beab.json create mode 100644 apollo-router/.sqlx/query-a14112a24ef08c184e9062837d553deffa2e7a2c8570ef7089701ebab9f02665.json create mode 100644 apollo-router/.sqlx/query-a8ff75a83927cd264969cecc40449b47ba93ab750c54cecec6ccf636f1145178.json create mode 100644 apollo-router/.sqlx/query-b1b1778f3d3f1ca73b5037d6d6e9cd980c07f640be2fea4bc9d76dfc451d437d.json create mode 100644 apollo-router/.sqlx/query-b7f6dac1056d65e173a6f4f3d81f57f456f3a15c9dfd1388e0b32a0978a08ae0.json create mode 100644 apollo-router/.sqlx/query-c2b5393c5c63e03e12e9d480e82a0c610380ed3411e0e5d3cae03e94347410a3.json create mode 100644 apollo-router/.sqlx/query-cb60ba408aa989631c6257955d9cf21671f600ee645ed77e5a300b6bbb9590c3.json create mode 100644 apollo-router/migrations/20250516144204_creation.down.sql create mode 100644 apollo-router/migrations/20250516144204_creation.up.sql create mode 100644 apollo-router/rustfmt.toml create mode 100644 apollo-router/src/ageing_priority_queue.rs create mode 100644 apollo-router/src/apollo_studio_interop/snapshots/apollo_router__apollo_studio_interop__tests__enums_with_nested_query.snap create mode 100644 apollo-router/src/apollo_studio_interop/snapshots/apollo_router__apollo_studio_interop__tests__enums_with_nested_query_fragment.snap create mode 100644 apollo-router/src/apollo_studio_interop/snapshots/apollo_router__apollo_studio_interop__tests__extended_references_nested_query.snap create mode 100644 apollo-router/src/apollo_studio_interop/testdata/enums_from_response_with_nested_query.graphql create mode 100644 apollo-router/src/apollo_studio_interop/testdata/enums_from_response_with_nested_query_fragment.graphql create mode 100644 apollo-router/src/axum_factory/connection_handle.rs create mode 100644 apollo-router/src/axum_factory/metrics.rs create mode 100644 apollo-router/src/cache/metrics.rs create mode 100644 apollo-router/src/compute_job/metrics.rs create mode 100644 apollo-router/src/compute_job/mod.rs create mode 100644 apollo-router/src/compute_job/snapshots/apollo_router__compute_job__tests__observability@logs.snap create mode 100644 apollo-router/src/configuration/connector.rs create mode 100644 apollo-router/src/configuration/cooperative_cancellation.rs create mode 100644 apollo-router/src/configuration/migrations/0031-experimental_reuse_query_fragments.yaml create mode 100644 apollo-router/src/configuration/migrations/0032-experimental_query_planner_mode.yaml create mode 100644 apollo-router/src/configuration/migrations/0033-experimental_retry.yaml create mode 100644 apollo-router/src/configuration/migrations/0034-experimental_parallelism.yaml create mode 100644 apollo-router/src/configuration/migrations/0035-preview_connectors.yaml create mode 100644 apollo-router/src/configuration/migrations/0036-preview_connectors_subgraphs.yaml create mode 100644 apollo-router/src/configuration/migrations/0037-preview_otlp_error_metrics.yaml create mode 100644 apollo-router/src/configuration/migrations/2000-jwt-issuer-becomes-issuers.yaml create mode 100644 apollo-router/src/configuration/migrations/2038-ignored-headers-subs-dedup.yaml create mode 100644 apollo-router/src/configuration/migrations/2039-cors-origins-to-policies.yaml create mode 100644 apollo-router/src/configuration/mode.rs create mode 100644 apollo-router/src/configuration/server.rs delete mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__experimental_mode_metrics.snap delete mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__experimental_mode_metrics_2.snap delete mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__experimental_mode_metrics_3.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@connectors.router.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@response_cache.router.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@apollo_extended_errors.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@connectors_preview.router.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@jwt_issuer_to_issuers.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@legacy_qp.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_both_origins.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_conflicting_config.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_match_origins.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_no_origins.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_origins_empty.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_origins_simple.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_origins_with_settings.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__health_check.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__subscription_dedup.yaml.snap create mode 100644 apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_major_configuration@apollo_extended_errors.yaml.snap create mode 100644 apollo-router/src/configuration/testdata/metrics/connectors.router.yaml delete mode 100644 apollo-router/src/configuration/testdata/metrics/query_planner_parallelism_auto.router.yaml delete mode 100644 apollo-router/src/configuration/testdata/metrics/query_planner_parallelism_static.router.yaml create mode 100644 apollo-router/src/configuration/testdata/metrics/response_cache.router.yaml create mode 100644 apollo-router/src/configuration/testdata/migrations/apollo_extended_errors.yaml create mode 100644 apollo-router/src/configuration/testdata/migrations/connectors_preview.router.yaml delete mode 100644 apollo-router/src/configuration/testdata/migrations/defer_support_ga.router.yaml delete mode 100644 apollo-router/src/configuration/testdata/migrations/jaeger_enabled.router.yaml delete mode 100644 apollo-router/src/configuration/testdata/migrations/jaeger_scheduled_delay.router.yaml create mode 100644 apollo-router/src/configuration/testdata/migrations/jwt_issuer_to_issuers.yaml create mode 100644 apollo-router/src/configuration/testdata/migrations/legacy_qp.yaml create mode 100644 apollo-router/src/configuration/testdata/migrations/minor/cors_both_origins.yaml create mode 100644 apollo-router/src/configuration/testdata/migrations/minor/cors_conflicting_config.yaml create mode 100644 apollo-router/src/configuration/testdata/migrations/minor/cors_match_origins.yaml create mode 100644 apollo-router/src/configuration/testdata/migrations/minor/cors_no_origins.yaml create mode 100644 apollo-router/src/configuration/testdata/migrations/minor/cors_origins_empty.yaml create mode 100644 apollo-router/src/configuration/testdata/migrations/minor/cors_origins_simple.yaml create mode 100644 apollo-router/src/configuration/testdata/migrations/minor/cors_origins_with_settings.yaml create mode 100644 apollo-router/src/configuration/testdata/migrations/minor/subscription_dedup.yaml delete mode 100644 apollo-router/src/configuration/testdata/migrations/telemetry_router_to_supergraph.router.yaml delete mode 100644 apollo-router/src/configuration/testdata/tracing_jaeger_agent.router.yaml delete mode 100644 apollo-router/src/configuration/testdata/tracing_jaeger_collector.router.yaml delete mode 100644 apollo-router/src/configuration/testdata/tracing_jaeger_collector_env.router.yaml delete mode 100644 apollo-router/src/configuration/testdata/tracing_jaeger_full.router.yaml create mode 100644 apollo-router/src/context/deprecated.rs delete mode 100644 apollo-router/src/metrics/layer.rs create mode 100644 apollo-router/src/otel_compat.rs create mode 100644 apollo-router/src/plugin/test/mock/connector.rs create mode 100644 apollo-router/src/plugins/authentication/connector.rs create mode 100644 apollo-router/src/plugins/authentication/error.rs create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-5.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-6.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-2.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-3.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-4.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-5.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-6.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-2.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-3.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-4.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-5.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-6.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-2.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-3.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-4.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-5.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-3.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-4.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-4.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-5.snap create mode 100644 apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-6.snap create mode 100644 apollo-router/src/plugins/chaos/mod.rs create mode 100644 apollo-router/src/plugins/chaos/reload.rs create mode 100644 apollo-router/src/plugins/connectors/configuration.rs create mode 100644 apollo-router/src/plugins/connectors/handle_responses.rs create mode 100644 apollo-router/src/plugins/connectors/incompatible.rs create mode 100644 apollo-router/src/plugins/connectors/incompatible/apq.rs create mode 100644 apollo-router/src/plugins/connectors/incompatible/authentication.rs create mode 100644 apollo-router/src/plugins/connectors/incompatible/batching.rs create mode 100644 apollo-router/src/plugins/connectors/incompatible/coprocessor.rs create mode 100644 apollo-router/src/plugins/connectors/incompatible/entity_cache.rs create mode 100644 apollo-router/src/plugins/connectors/incompatible/headers.rs create mode 100644 apollo-router/src/plugins/connectors/incompatible/rhai.rs create mode 100644 apollo-router/src/plugins/connectors/incompatible/telemetry.rs create mode 100644 apollo-router/src/plugins/connectors/incompatible/tls.rs create mode 100644 apollo-router/src/plugins/connectors/incompatible/traffic_shaping.rs create mode 100644 apollo-router/src/plugins/connectors/incompatible/url_override.rs create mode 100644 apollo-router/src/plugins/connectors/make_requests.rs create mode 100644 apollo-router/src/plugins/connectors/make_requests/graphql_utils.rs create mode 100644 apollo-router/src/plugins/connectors/mod.rs create mode 100644 apollo-router/src/plugins/connectors/plugin.rs create mode 100644 apollo-router/src/plugins/connectors/query_plans.rs create mode 100644 apollo-router/src/plugins/connectors/request_limit.rs create mode 100644 apollo-router/src/plugins/connectors/testdata/README.md create mode 100644 apollo-router/src/plugins/connectors/testdata/batch-max-size.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/batch-max-size.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/batch-query.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/batch-query.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/batch.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/batch.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/connect-on-interface-object.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/connect-on-interface-object.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/connect-on-type.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/connect-on-type.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/connector-without-source.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/connector-without-source.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/content-type.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/content-type.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/env-var.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/errors.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/errors.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/form-encoding.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/form-encoding.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/interface-object.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/interface-object.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/mutation.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/mutation.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/nullability.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/nullability.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/progressive-override.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/progressive-override.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/quickstart.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/quickstart.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_1.json create mode 100644 apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_2.json create mode 100644 apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_3.json create mode 100644 apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_4.json create mode 100755 apollo-router/src/plugins/connectors/testdata/regenerate.sh create mode 100644 apollo-router/src/plugins/connectors/testdata/selection.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/selection.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/steelthread.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/steelthread.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/url-properties.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/url-properties.yaml create mode 100644 apollo-router/src/plugins/connectors/testdata/variables-subgraph.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/variables.graphql create mode 100644 apollo-router/src/plugins/connectors/testdata/variables.yaml create mode 100644 apollo-router/src/plugins/connectors/tests/connect_on_type.rs create mode 100644 apollo-router/src/plugins/connectors/tests/content_type.rs create mode 100644 apollo-router/src/plugins/connectors/tests/error_handling.rs create mode 100644 apollo-router/src/plugins/connectors/tests/mock_api.rs create mode 100644 apollo-router/src/plugins/connectors/tests/mod.rs create mode 100644 apollo-router/src/plugins/connectors/tests/progressive_override.rs create mode 100644 apollo-router/src/plugins/connectors/tests/query_plan.rs create mode 100644 apollo-router/src/plugins/connectors/tests/quickstart.rs create mode 100644 apollo-router/src/plugins/connectors/tests/req_asserts.rs create mode 100644 apollo-router/src/plugins/connectors/tests/url_properties.rs create mode 100644 apollo-router/src/plugins/connectors/tests/variables.rs create mode 100644 apollo-router/src/plugins/connectors/tracing.rs create mode 100644 apollo-router/src/plugins/cors.rs create mode 100644 apollo-router/src/plugins/csrf/fixtures/default.router.yaml create mode 100644 apollo-router/src/plugins/csrf/fixtures/required_headers.router.yaml create mode 100644 apollo-router/src/plugins/csrf/fixtures/unsafe_disabled.router.yaml rename apollo-router/src/plugins/{csrf.rs => csrf/mod.rs} (68%) create mode 100644 apollo-router/src/plugins/demand_control/cost_calculator/fixtures/arbitrary_json_schema.graphql create mode 100644 apollo-router/src/plugins/demand_control/cost_calculator/fixtures/federated_ships_typename_query.graphql create mode 100644 apollo-router/src/plugins/demand_control/cost_calculator/fixtures/federated_ships_typename_response.json create mode 100644 apollo-router/src/plugins/demand_control/cost_calculator/fixtures/subscription_query.graphql create mode 100644 apollo-router/src/plugins/demand_control/cost_calculator/fixtures/subscription_schema.graphql create mode 100644 apollo-router/src/plugins/demand_control/cost_calculator/snapshots/apollo_router__plugins__demand_control__cost_calculator__static_cost__tests__federated_query_with_typenames@logs.snap create mode 100644 apollo-router/src/plugins/enhanced_client_awareness/mod.rs create mode 100644 apollo-router/src/plugins/enhanced_client_awareness/tests.rs create mode 100644 apollo-router/src/plugins/fleet_detector.rs delete mode 100644 apollo-router/src/plugins/headers.rs create mode 100644 apollo-router/src/plugins/headers/fixtures/propagate_passthrough.router.yaml create mode 100644 apollo-router/src/plugins/headers/fixtures/propagate_passthrough_defaulted.router.yaml create mode 100644 apollo-router/src/plugins/headers/mod.rs create mode 100644 apollo-router/src/plugins/healthcheck/mod.rs create mode 100644 apollo-router/src/plugins/healthcheck/testdata/allowed_fifty_per_second.router.yaml create mode 100644 apollo-router/src/plugins/healthcheck/testdata/allowed_ten_per_second.router.yaml create mode 100644 apollo-router/src/plugins/healthcheck/testdata/custom_listener.router.yaml create mode 100644 apollo-router/src/plugins/healthcheck/testdata/default_listener.router.yaml create mode 100644 apollo-router/src/plugins/healthcheck/testdata/disabled_listener.router.yaml delete mode 100644 apollo-router/src/plugins/include_subgraph_errors.rs create mode 100644 apollo-router/src/plugins/include_subgraph_errors/config.rs create mode 100644 apollo-router/src/plugins/include_subgraph_errors/effective_config.rs create mode 100644 apollo-router/src/plugins/include_subgraph_errors/mod.rs create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_all_explicit.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_product_override_implicit_redact.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_product_when_account_redacted.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_product_when_review_redacted.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__filter_global_allow_keep_msg.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__filter_global_allow_redact_msg.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__incremental_response.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__non_subgraph_error.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_account_override_explicit_allow.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_all_explicit.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_all_implicit.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_product_override_explicit_allow.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_product_when_review_allowed.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_allow_extend_global_allow.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_allow_override_global_deny.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_allow_service.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_bool_false_override_global_allow.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_bool_true_override_global_deny.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_deny_extend_global_deny.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_deny_override_global_allow.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_deny_service.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_exclude_global_allow.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_obj_override_redaction.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__valid_response.snap create mode 100644 apollo-router/src/plugins/include_subgraph_errors/tests.rs create mode 100644 apollo-router/src/plugins/license_enforcement/mod.rs create mode 100644 apollo-router/src/plugins/mock_subgraphs/execution/engine.rs create mode 100644 apollo-router/src/plugins/mock_subgraphs/execution/input_coercion.rs create mode 100644 apollo-router/src/plugins/mock_subgraphs/execution/mod.rs create mode 100644 apollo-router/src/plugins/mock_subgraphs/execution/resolver.rs create mode 100644 apollo-router/src/plugins/mock_subgraphs/execution/result_coercion.rs create mode 100644 apollo-router/src/plugins/mock_subgraphs/execution/validation.rs create mode 100644 apollo-router/src/plugins/mock_subgraphs/mod.rs create mode 100644 apollo-router/src/plugins/response_cache/cache_control.rs create mode 100644 apollo-router/src/plugins/response_cache/invalidation.rs create mode 100644 apollo-router/src/plugins/response_cache/invalidation_endpoint.rs create mode 100644 apollo-router/src/plugins/response_cache/metrics.rs create mode 100644 apollo-router/src/plugins/response_cache/mod.rs create mode 100644 apollo-router/src/plugins/response_cache/plugin.rs create mode 100644 apollo-router/src/plugins/response_cache/postgres.rs create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__failure_mode_reconnect-2.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__failure_mode_reconnect-4.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert-3.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_nested_field_set-3.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_nested_field_set.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_requires-3.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_requires.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_cache_tag-3.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_cache_tag-5.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_cache_tag.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_subgraph-3.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_subgraph.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_type-3.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_type-5.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_type.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__missing_entities-2.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__missing_entities.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__no_data-3.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__no_data.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-11.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-3.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-5.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-7.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-9.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_and_public-3.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_and_public-5.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_and_public.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_only-3.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_only-5.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_only.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_without_private_id-3.snap create mode 100644 apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_without_private_id.snap create mode 100644 apollo-router/src/plugins/response_cache/tests.rs create mode 100644 apollo-router/src/plugins/rhai/snapshots/apollo_router__plugins__rhai__tests__it_prints_messages_to_log@logs.snap create mode 100644 apollo-router/src/plugins/rhai/snapshots/apollo_router__plugins__rhai__tests__rhai_plugin_execution_service_error@logs.snap create mode 100644 apollo-router/src/plugins/rhai/snapshots/apollo_router__plugins__rhai__tests__rhai_plugin_supergraph_service@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/apollo/instruments.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/apollo/mod.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/connector/attributes.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/connector/events.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/connector/instruments.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/connector/mod.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/connector/selectors.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/connector/snapshots/apollo_router__plugins__telemetry__config_new__connector__events__tests__connector_events_request@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/connector/snapshots/apollo_router__plugins__telemetry__config_new__connector__events__tests__connector_events_response@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/connector/spans.rs delete mode 100644 apollo-router/src/plugins/telemetry/config_new/experimental_when_header.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/connector_fetch_duration/metrics.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/connector_fetch_duration/router.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/connector_fetch_duration/test.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/subgraph_fetch_duration/metrics.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/subgraph_fetch_duration/router.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/subgraph_fetch_duration/test.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_counter_with_conditions/metrics.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_counter_with_conditions/router.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_counter_with_conditions/test.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_histogram/metrics.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_histogram/router.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_histogram/test.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/http_client_request_duration/metrics.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/http_client_request_duration/router.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/http_client_request_duration/test.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/mapping_problems/metrics.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/mapping_problems/router.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/mapping_problems/test.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/subgraph_and_connector/metrics.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/subgraph_and_connector/router.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/fixtures/connector/subgraph_and_connector/test.yaml create mode 100644 apollo-router/src/plugins/telemetry/config_new/http_common/attributes.rs rename apollo-router-scaffold/templates/base/src/plugins/mod.rs => apollo-router/src/plugins/telemetry/config_new/http_common/events.rs (100%) create mode 100644 apollo-router/src/plugins/telemetry/config_new/http_common/instruments.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/http_common/mod.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/http_common/selectors.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/http_common/spans.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/http_server/attributes.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/http_server/events.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/http_server/instruments.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/http_server/mod.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/http_server/selectors.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/http_server/spans.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/router/attributes.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/router/events.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/router/instruments.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/router/mod.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/router/selectors.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/router/snapshots/apollo_router__plugins__telemetry__config_new__router__events__tests__router_events@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/router/snapshots/apollo_router__plugins__telemetry__config_new__router__events__tests__router_events_graphql_error@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/router/snapshots/apollo_router__plugins__telemetry__config_new__router__events__tests__router_events_graphql_response@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/router/spans.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__connector_events_request@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__connector_events_response@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/subgraph/attributes.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/subgraph/events.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/subgraph/instruments.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/subgraph/mod.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/subgraph/selectors.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/subgraph/snapshots/apollo_router__plugins__telemetry__config_new__subgraph__events__test__subgraph_events@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/subgraph/snapshots/apollo_router__plugins__telemetry__config_new__subgraph__events__test__subgraph_events_response@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/subgraph/spans.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/supergraph/attributes.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/supergraph/events.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/supergraph/instruments.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/supergraph/mod.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/supergraph/selectors.rs create mode 100644 apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events_on_graphql_error@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events_on_response@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events_with_exists_condition@logs.snap create mode 100644 apollo-router/src/plugins/telemetry/config_new/supergraph/spans.rs create mode 100644 apollo-router/src/plugins/telemetry/error_counter/mod.rs create mode 100644 apollo-router/src/plugins/telemetry/error_counter/tests.rs create mode 100644 apollo-router/src/plugins/telemetry/error_handler.rs delete mode 100644 apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__when_header@logs.snap delete mode 100644 apollo-router/src/plugins/telemetry/logging/testdata/experimental_when_header.router.yaml create mode 100644 apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_distributed_apq_cache_feature_disabled_with_partial_defaults.snap create mode 100644 apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_distributed_apq_cache_feature_enabled_with_partial_defaults.snap create mode 100644 apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_disabled_when_defaulted.snap create mode 100644 apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_explicitly_disabled.snap create mode 100644 apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_explicitly_enabled.snap create mode 100644 apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_implicitly_disabled.snap delete mode 100644 apollo-router/src/plugins/telemetry/metrics/span_metrics_exporter.rs create mode 100644 apollo-router/src/plugins/telemetry/otel/named_runtime_channel.rs create mode 100644 apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__fmt_layer__tests__json_logging_deduplicates_attributes.snap create mode 100644 apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_custom_view_drop.snap create mode 100644 apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_units_are_included.snap create mode 100644 apollo-router/src/plugins/telemetry/span_ext.rs create mode 100644 apollo-router/src/plugins/telemetry/testdata/config.field_instrumentation_sampler.router.yaml create mode 100644 apollo-router/src/plugins/telemetry/testdata/full_config_all_features_defaults.router.yaml create mode 100644 apollo-router/src/plugins/telemetry/testdata/full_config_all_features_enabled.router.yaml create mode 100644 apollo-router/src/plugins/telemetry/testdata/full_config_all_features_explicitly_disabled.router.yaml create mode 100644 apollo-router/src/plugins/telemetry/testdata/full_config_apq_disabled_partial_defaults.router.yaml create mode 100644 apollo-router/src/plugins/telemetry/testdata/full_config_apq_enabled_partial_defaults.router.yaml create mode 100644 apollo-router/src/plugins/telemetry/tracing/datadog/agent_sampling.rs rename apollo-router/src/plugins/telemetry/tracing/{datadog.rs => datadog/mod.rs} (72%) create mode 100644 apollo-router/src/plugins/telemetry/tracing/datadog/span_processor.rs delete mode 100644 apollo-router/src/plugins/test.rs create mode 100644 apollo-router/src/plugins/test/mod.rs create mode 100644 apollo-router/src/plugins/test/router_ext.rs create mode 100644 apollo-router/src/plugins/test/subgraph_ext.rs create mode 100644 apollo-router/src/plugins/test/supergraph_ext.rs delete mode 100644 apollo-router/src/plugins/traffic_shaping/rate/error.rs delete mode 100644 apollo-router/src/plugins/traffic_shaping/rate/future.rs delete mode 100644 apollo-router/src/plugins/traffic_shaping/rate/layer.rs delete mode 100644 apollo-router/src/plugins/traffic_shaping/rate/mod.rs delete mode 100644 apollo-router/src/plugins/traffic_shaping/rate/rate.rs delete mode 100644 apollo-router/src/plugins/traffic_shaping/rate/service.rs delete mode 100644 apollo-router/src/plugins/traffic_shaping/retry.rs delete mode 100644 apollo-router/src/plugins/traffic_shaping/timeout/error.rs delete mode 100644 apollo-router/src/plugins/traffic_shaping/timeout/future.rs delete mode 100644 apollo-router/src/plugins/traffic_shaping/timeout/layer.rs delete mode 100644 apollo-router/src/plugins/traffic_shaping/timeout/mod.rs delete mode 100644 apollo-router/src/query_planner/bridge_query_planner_pool.rs delete mode 100644 apollo-router/src/query_planner/dual_query_planner.rs rename apollo-router/src/query_planner/{bridge_query_planner.rs => query_planner_service.rs} (59%) delete mode 100644 apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__empty_query_plan_usage_reporting.snap delete mode 100644 apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__plan_usage_reporting.snap create mode 100644 apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__empty_query_plan_usage_reporting.snap rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__plan_invalid_query_errors.snap => apollo_router__query_planner__query_planner_service__tests__plan_invalid_query_errors.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__plan_root.snap => apollo_router__query_planner__query_planner_service__tests__plan_root.snap} (71%) create mode 100644 apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__plan_usage_reporting.snap rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-10.snap => apollo_router__query_planner__query_planner_service__tests__subselections-10.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-11.snap => apollo_router__query_planner__query_planner_service__tests__subselections-11.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-12.snap => apollo_router__query_planner__query_planner_service__tests__subselections-12.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-13.snap => apollo_router__query_planner__query_planner_service__tests__subselections-13.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-14.snap => apollo_router__query_planner__query_planner_service__tests__subselections-14.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-15.snap => apollo_router__query_planner__query_planner_service__tests__subselections-15.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-16.snap => apollo_router__query_planner__query_planner_service__tests__subselections-16.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-17.snap => apollo_router__query_planner__query_planner_service__tests__subselections-17.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-18.snap => apollo_router__query_planner__query_planner_service__tests__subselections-18.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-2.snap => apollo_router__query_planner__query_planner_service__tests__subselections-2.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-3.snap => apollo_router__query_planner__query_planner_service__tests__subselections-3.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-4.snap => apollo_router__query_planner__query_planner_service__tests__subselections-4.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-5.snap => apollo_router__query_planner__query_planner_service__tests__subselections-5.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-6.snap => apollo_router__query_planner__query_planner_service__tests__subselections-6.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-7.snap => apollo_router__query_planner__query_planner_service__tests__subselections-7.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-8.snap => apollo_router__query_planner__query_planner_service__tests__subselections-8.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections-9.snap => apollo_router__query_planner__query_planner_service__tests__subselections-9.snap} (100%) rename apollo-router/src/query_planner/snapshots/{apollo_router__query_planner__bridge_query_planner__tests__subselections.snap => apollo_router__query_planner__query_planner_service__tests__subselections.snap} (100%) create mode 100644 apollo-router/src/registry/mod.rs create mode 100644 apollo-router/src/services/connect.rs create mode 100644 apollo-router/src/services/connector.rs create mode 100644 apollo-router/src/services/connector/request_service.rs create mode 100644 apollo-router/src/services/connector_service.rs create mode 100644 apollo-router/src/services/fetch.rs create mode 100644 apollo-router/src/services/fetch_service.rs delete mode 100644 apollo-router/src/services/http/body_stream.rs create mode 100644 apollo-router/src/services/layers/persisted_queries/freeform_graphql_behavior.rs create mode 100644 apollo-router/src/services/layers/persisted_queries/manifest.rs create mode 100644 apollo-router/src/services/layers/persisted_queries/snapshots/apollo_router__services__layers__persisted_queries__tests__pq_layer_freeform_graphql_with_safelist_log_unknown_true@logs.snap create mode 100644 apollo-router/src/services/query_batching/testdata/batch_exceeds_maximum_size_response.json create mode 100644 apollo-router/src/services/router/pipeline_handle.rs create mode 100644 apollo-router/src/services/router/snapshots/apollo_router__services__router__tests__invalid_input_enum.snap create mode 100644 apollo-router/src/services/snapshots/apollo_router__services__external__test__it_will_create_an_http_request_span@logs.snap create mode 100644 apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_with_invalid_paths_on_query_with_defer-2.snap create mode 100644 apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_with_invalid_paths_on_query_with_defer.snap delete mode 100644 apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__invalid_input_enum-2.snap delete mode 100644 apollo-router/src/services/transport.rs delete mode 100644 apollo-router/src/spec/query/change.rs create mode 100644 apollo-router/src/test_harness/http_snapshot.rs create mode 100644 apollo-router/src/test_harness/http_snapshot_main.rs create mode 100644 apollo-router/src/testdata/orga_supergraph_cache_key.graphql create mode 100644 apollo-router/src/testdata/supergraph_cache_key.graphql create mode 100644 apollo-router/src/testdata/supergraph_nested_fields.graphql create mode 100644 apollo-router/src/testdata/supergraph_nested_fields_cache_key.graphql create mode 100644 apollo-router/src/uplink/feature_gate_enforcement.rs create mode 100644 apollo-router/src/uplink/parsed_link_spec.rs create mode 100644 apollo-router/src/uplink/schema.rs delete mode 100644 apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__progressive_override.snap delete mode 100644 apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__progressive_override_with_renamed_join_spec.snap create mode 100644 apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_authorization_directives_via_schema_unlicensed.snap create mode 100644 apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_authorization_directives_via_schema_with_restricted_allowed_features.snap create mode 100644 apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config_allowed_features_empty.snap create mode 100644 apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config_unlicensed.snap create mode 100644 apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config_with_allowed_features.snap delete mode 100644 apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_unix_socket_via_schema.snap delete mode 100644 apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__schema_enforcement_directive_arg_version_in_range.snap delete mode 100644 apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__set_context.snap create mode 100644 apollo-router/src/uplink/testdata/connectv0_3.router.yaml create mode 100644 apollo-router/src/uplink/testdata/feature_enforcement_connect_v0_2.graphql create mode 100644 apollo-router/src/uplink/testdata/feature_enforcement_connect_v0_3.graphql create mode 100644 apollo-router/src/uplink/testdata/license.jwks.json create mode 100644 apollo-router/src/uplink/testdata/schema_enforcement_connectors.graphql create mode 100644 apollo-router/tests/compute_backpressure.rs create mode 100644 apollo-router/tests/fixtures/connectors/quickstart.graphql create mode 100644 apollo-router/tests/fixtures/file_upload/add_header.rhai create mode 100644 apollo-router/tests/fixtures/file_upload/rhai.router.yaml create mode 100644 apollo-router/tests/fixtures/non_utf8_header_removal.rhai create mode 100644 apollo-router/tests/fixtures/persisted-queries-manifest-hot-reload.json create mode 100644 apollo-router/tests/fixtures/reports/all_features_disabled.router.yaml create mode 100644 apollo-router/tests/fixtures/reports/all_features_enabled.router.yaml rename apollo-router/tests/fixtures/{ => reports}/apollo_reports.router.yaml (94%) rename apollo-router/tests/fixtures/{ => reports}/apollo_reports_batch.router.yaml (94%) create mode 100644 apollo-router/tests/fixtures/supergraph_connect.graphql create mode 100644 apollo-router/tests/fixtures/supergraph_connect.yaml create mode 100644 apollo-router/tests/fixtures/test_reload_1.rhai create mode 100644 apollo-router/tests/fixtures/test_reload_2.rhai create mode 100644 apollo-router/tests/integration/allowed_features.rs create mode 100644 apollo-router/tests/integration/connectors.rs create mode 100644 apollo-router/tests/integration/entity_cache.rs create mode 100644 apollo-router/tests/integration/fixtures/authenticated_directive.graphql create mode 100644 apollo-router/tests/integration/fixtures/connectors_sigv4.graphql create mode 100644 apollo-router/tests/integration/fixtures/connectors_sigv4.router.yaml create mode 100644 apollo-router/tests/integration/fixtures/coprocessor.router.yaml create mode 100644 apollo-router/tests/integration/fixtures/coprocessor_conditional.router.yaml create mode 100644 apollo-router/tests/integration/fixtures/prometheus.router.yaml create mode 100644 apollo-router/tests/integration/fixtures/prometheus_updated.router.yaml create mode 100644 apollo-router/tests/integration/fixtures/query_planner_max_evaluated_plans.graphql delete mode 100644 apollo-router/tests/integration/fixtures/query_planner_redis_config_update_query_planner_mode.router.yaml delete mode 100644 apollo-router/tests/integration/fixtures/query_planner_redis_config_update_reuse_query_fragments.router.yaml create mode 100644 apollo-router/tests/integration/fixtures/redis_connection_closure.router.yaml create mode 100644 apollo-router/tests/integration/fixtures/rhai_logging.router.yaml create mode 100644 apollo-router/tests/integration/fixtures/rhai_reload.router.yaml create mode 100644 apollo-router/tests/integration/fixtures/small_connection_shutdown_timeout.router.yaml create mode 100644 apollo-router/tests/integration/fixtures/small_connection_shutdown_timeout_updated.router.yaml create mode 100644 apollo-router/tests/integration/metrics.rs create mode 100644 apollo-router/tests/integration/mock_subgraphs.rs create mode 100644 apollo-router/tests/integration/postgres.rs delete mode 100644 apollo-router/tests/integration/query_planner.rs create mode 100644 apollo-router/tests/integration/query_planner/error_paths.rs create mode 100644 apollo-router/tests/integration/query_planner/max_evaluated_plans.rs create mode 100644 apollo-router/tests/integration/query_planner/mod.rs create mode 100644 apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__all_successful.snap create mode 100644 apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__multi_level_response_failure.snap create mode 100644 apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure.snap create mode 100644 apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_404.snap create mode 100644 apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_malformed.snap create mode 100644 apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure.snap create mode 100644 apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_empty_path.snap create mode 100644 apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_malformed.snap create mode 100644 apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__top_level_response_failure.snap create mode 100644 apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__top_level_response_failure_malformed.snap create mode 100644 apollo-router/tests/integration/response_cache.rs create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__coprocessor__on_graphql_error_selector__first_response_failure.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__coprocessor__on_graphql_error_selector__nested_response_failure.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-2.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-3.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-4.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-5.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-2.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-3.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-4.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-5.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-2.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-3.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-4.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-5.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_authorization-2.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_authorization-3.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_authorization.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-2.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-3.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-4.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-5.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-2.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-3.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-4.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-5.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__connector_rate_limit-2.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__connector_rate_limit.snap create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__connector_timeout.snap rename apollo-router/tests/integration/{subscription.rs => subscription_load_test.rs} (84%) create mode 100644 apollo-router/tests/integration/subscriptions/callback.rs create mode 100644 apollo-router/tests/integration/subscriptions/fixtures/callback.router.yaml create mode 100644 apollo-router/tests/integration/subscriptions/fixtures/subscription.router.yaml create mode 100644 apollo-router/tests/integration/subscriptions/fixtures/subscription_coprocessor.router.yaml create mode 100644 apollo-router/tests/integration/subscriptions/fixtures/subscription_schema_reload.router.yaml create mode 100644 apollo-router/tests/integration/subscriptions/fixtures/supergraph.graphql create mode 100644 apollo-router/tests/integration/subscriptions/mod.rs create mode 100644 apollo-router/tests/integration/subscriptions/ws_passthrough.rs create mode 100644 apollo-router/tests/integration/telemetry/apollo_otel_metrics.rs create mode 100644 apollo-router/tests/integration/telemetry/events.rs create mode 100644 apollo-router/tests/integration/telemetry/fixtures/datadog_agent_sampling_disabled.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/datadog_agent_sampling_disabled_0.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/datadog_agent_sampling_disabled_1.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/datadog_header_propagator_override.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/datadog_no_parent_sampler.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small_no_parent.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small_no_parent_no_agent_sampling.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small_parent_no_agent_sampling.router.yaml delete mode 100644 apollo-router/tests/integration/telemetry/fixtures/jaeger-0.5-sample.router.yaml delete mode 100644 apollo-router/tests/integration/telemetry/fixtures/jaeger-advanced.router.yaml delete mode 100644 apollo-router/tests/integration/telemetry/fixtures/jaeger-no-sample.router.yaml delete mode 100644 apollo-router/tests/integration/telemetry/fixtures/jaeger.router.yaml delete mode 100644 apollo-router/tests/integration/telemetry/fixtures/jaeger_decimal_trace_id.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_agent_no_sample.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_agent_sample.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_agent_sample_no_sample.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_propagation.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_propagation_no_agent.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_propagation_no_parent_sampler.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_request_with_zipkin_propagator.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/otlp_invalid_endpoint.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/otlp_no_parent_sampler.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/otlp_override_client_name.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/otlp_parent_sampler_very_small_no_parent_no_agent_sampling.router.yaml create mode 100644 apollo-router/tests/integration/telemetry/fixtures/override_client_name.rhai delete mode 100644 apollo-router/tests/integration/telemetry/jaeger.rs create mode 100644 apollo-router/tests/integration/telemetry/verifier.rs create mode 100644 apollo-router/tests/samples/enterprise/connectors-debugging/README.md create mode 100644 apollo-router/tests/samples/enterprise/connectors-debugging/configuration.yaml create mode 100644 apollo-router/tests/samples/enterprise/connectors-debugging/http_snapshots.json create mode 100644 apollo-router/tests/samples/enterprise/connectors-debugging/plan.json create mode 100644 apollo-router/tests/samples/enterprise/connectors-debugging/supergraph.graphql create mode 100644 apollo-router/tests/samples/enterprise/connectors-debugging/supergraph.yaml create mode 100644 apollo-router/tests/samples/enterprise/connectors-defer/README.md create mode 100644 apollo-router/tests/samples/enterprise/connectors-defer/configuration.yaml create mode 100644 apollo-router/tests/samples/enterprise/connectors-defer/http_snapshots.json create mode 100644 apollo-router/tests/samples/enterprise/connectors-defer/plan.json create mode 100644 apollo-router/tests/samples/enterprise/connectors-defer/supergraph.graphql create mode 100644 apollo-router/tests/samples/enterprise/connectors-defer/supergraph.yaml create mode 100644 apollo-router/tests/samples/enterprise/connectors-demand-control/README.md create mode 100644 apollo-router/tests/samples/enterprise/connectors-demand-control/configuration.yaml create mode 100644 apollo-router/tests/samples/enterprise/connectors-demand-control/http_snapshots.json create mode 100644 apollo-router/tests/samples/enterprise/connectors-demand-control/plan.json create mode 100644 apollo-router/tests/samples/enterprise/connectors-demand-control/supergraph.graphql create mode 100644 apollo-router/tests/samples/enterprise/connectors-demand-control/supergraph.yaml create mode 100644 apollo-router/tests/samples/enterprise/connectors-pqs/README.md create mode 100644 apollo-router/tests/samples/enterprise/connectors-pqs/configuration.yaml create mode 100644 apollo-router/tests/samples/enterprise/connectors-pqs/http_snapshots.json create mode 100644 apollo-router/tests/samples/enterprise/connectors-pqs/manifest.json create mode 100644 apollo-router/tests/samples/enterprise/connectors-pqs/plan.json create mode 100644 apollo-router/tests/samples/enterprise/connectors-pqs/supergraph.graphql create mode 100644 apollo-router/tests/samples/enterprise/connectors-pqs/supergraph.yaml create mode 100644 apollo-router/tests/samples/enterprise/connectors/README.md create mode 100644 apollo-router/tests/samples/enterprise/connectors/configuration.yaml create mode 100644 apollo-router/tests/samples/enterprise/connectors/http_snapshots.json create mode 100644 apollo-router/tests/samples/enterprise/connectors/plan.json create mode 100644 apollo-router/tests/samples/enterprise/connectors/supergraph.graphql create mode 100644 apollo-router/tests/samples/enterprise/persisted-queries/basic/README.md create mode 100644 apollo-router/tests/samples/enterprise/persisted-queries/basic/configuration.yaml create mode 100644 apollo-router/tests/samples/enterprise/persisted-queries/basic/persisted-query-manifest.json create mode 100644 apollo-router/tests/samples/enterprise/persisted-queries/basic/plan.yaml create mode 100644 apollo-router/tests/samples/enterprise/persisted-queries/basic/rhai/main.rhai create mode 100644 apollo-router/tests/samples/enterprise/persisted-queries/basic/supergraph.graphql create mode 100644 apollo-router/tests/snapshots/apollo_otel_traces__connector-2.snap create mode 100644 apollo-router/tests/snapshots/apollo_otel_traces__connector.snap create mode 100644 apollo-router/tests/snapshots/apollo_otel_traces__connector_error-2.snap create mode 100644 apollo-router/tests/snapshots/apollo_otel_traces__connector_error.snap create mode 100644 apollo-router/tests/snapshots/apollo_reports__features_disabled.snap create mode 100644 apollo-router/tests/snapshots/apollo_reports__features_enabled.snap create mode 100644 apollo-router/tests/snapshots/set_context__set_context_dependent_fetch_failure_rust_qp.snap create mode 100644 apollo-router/tests/snapshots/set_context__set_context_list_of_lists_rust_qp.snap create mode 100644 apollo-router/tests/snapshots/set_context__set_context_list_rust_qp.snap create mode 100644 apollo-router/tests/snapshots/set_context__set_context_no_typenames_rust_qp.snap create mode 100644 apollo-router/tests/snapshots/set_context__set_context_rust_qp.snap create mode 100644 apollo-router/tests/snapshots/set_context__set_context_type_mismatch_rust_qp.snap create mode 100644 apollo-router/tests/snapshots/set_context__set_context_union_rust_qp.snap create mode 100644 apollo-router/tests/snapshots/set_context__set_context_unrelated_fetch_failure_rust_qp.snap create mode 100644 apollo-router/tests/snapshots/set_context__set_context_with_null_rust_qp.snap delete mode 100644 apollo-router/tests/snapshots/type_conditions___test_type_conditions_disabled-2.snap delete mode 100644 apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled-2.snap delete mode 100644 apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_generate_query_fragments-2.snap delete mode 100644 apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list-2.snap delete mode 100644 apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list_of_list-2.snap delete mode 100644 apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_shouldnt_make_article_fetch-2.snap create mode 100644 apollo-router/tests/telemetry_resource_tests.rs create mode 100644 dev-docs/BACKPRESSURE_REVIEW_NOTES.md create mode 100644 dev-docs/HYPER_1.0_REVIEW_NOTES.md create mode 100644 dev-docs/HYPER_1.0_UPDATE.md create mode 100644 dev-docs/layer-inventory.md create mode 100644 dev-docs/mock_subgraphs_plugin.md create mode 100644 dockerfiles/download_and_validate_router.sh delete mode 100644 dockerfiles/tracing/datadog-subgraph/Dockerfile delete mode 100644 dockerfiles/tracing/datadog-subgraph/index.ts delete mode 100644 dockerfiles/tracing/datadog-subgraph/package-lock.json delete mode 100644 dockerfiles/tracing/datadog-subgraph/package.json delete mode 100644 dockerfiles/tracing/datadog-subgraph/tracer.ts delete mode 100644 dockerfiles/tracing/datadog-subgraph/tsconfig.json delete mode 100644 dockerfiles/tracing/docker-compose.datadog.yml delete mode 100644 dockerfiles/tracing/docker-compose.jaeger.yml delete mode 100644 dockerfiles/tracing/docker-compose.zipkin.yml delete mode 100644 dockerfiles/tracing/jaeger-subgraph/Dockerfile delete mode 100644 dockerfiles/tracing/jaeger-subgraph/index.ts delete mode 100644 dockerfiles/tracing/jaeger-subgraph/package-lock.json delete mode 100644 dockerfiles/tracing/jaeger-subgraph/package.json delete mode 100644 dockerfiles/tracing/jaeger-subgraph/tsconfig.json delete mode 100644 dockerfiles/tracing/router/Dockerfile delete mode 100644 dockerfiles/tracing/router/datadog.router.yaml delete mode 100644 dockerfiles/tracing/router/jaeger.router.yaml delete mode 100644 dockerfiles/tracing/router/zipkin.router.yaml delete mode 100644 dockerfiles/tracing/supergraph.graphql delete mode 100644 dockerfiles/tracing/supergraph.yml delete mode 100644 dockerfiles/tracing/zipkin-subgraph/Dockerfile delete mode 100644 dockerfiles/tracing/zipkin-subgraph/index.ts delete mode 100644 dockerfiles/tracing/zipkin-subgraph/package-lock.json delete mode 100644 dockerfiles/tracing/zipkin-subgraph/package.json delete mode 100644 dockerfiles/tracing/zipkin-subgraph/recorder.ts delete mode 100644 dockerfiles/tracing/zipkin-subgraph/tsconfig.json create mode 100644 docs/shared/config/apq.mdx create mode 100644 docs/shared/config/authentication.mdx create mode 100644 docs/shared/config/authorization.mdx create mode 100644 docs/shared/config/batching.mdx create mode 100644 docs/shared/config/connectors.mdx create mode 100644 docs/shared/config/coprocessor.mdx create mode 100644 docs/shared/config/cors.mdx create mode 100644 docs/shared/config/csrf.mdx create mode 100644 docs/shared/config/demand_control.mdx create mode 100644 docs/shared/config/experimental_chaos.mdx create mode 100644 docs/shared/config/experimental_type_conditioned_fetching.mdx create mode 100644 docs/shared/config/fleet_detector.mdx create mode 100644 docs/shared/config/forbid_mutations.mdx create mode 100644 docs/shared/config/headers.mdx create mode 100644 docs/shared/config/health_check.mdx create mode 100644 docs/shared/config/homepage.mdx create mode 100644 docs/shared/config/include_subgraph_errors.mdx create mode 100644 docs/shared/config/license_enforcement.mdx create mode 100644 docs/shared/config/limits.mdx create mode 100644 docs/shared/config/override_subgraph_url.mdx create mode 100644 docs/shared/config/persisted_queries.mdx create mode 100644 docs/shared/config/plugins.mdx create mode 100644 docs/shared/config/preview_entity_cache.mdx create mode 100644 docs/shared/config/preview_file_uploads.mdx create mode 100644 docs/shared/config/progressive_override.mdx create mode 100644 docs/shared/config/rhai.mdx create mode 100644 docs/shared/config/sandbox.mdx create mode 100644 docs/shared/config/subscription.mdx create mode 100644 docs/shared/config/supergraph.mdx create mode 100644 docs/shared/config/telemetry.mdx create mode 100644 docs/shared/config/tls.mdx create mode 100644 docs/shared/config/traffic_shaping.mdx create mode 100644 docs/shared/diagrams/router-request-lifecycle-overview.mdx create mode 100644 docs/shared/router-config-properties-table.mdx create mode 100644 docs/shared/router-yaml-complete.mdx delete mode 100644 docs/source/_redirects create mode 100644 docs/source/_sidebar.yaml create mode 100644 docs/source/images/get-started/connector-dark.svg create mode 100644 docs/source/images/get-started/connector.svg create mode 100644 docs/source/images/router-quickstart-sandbox.jpg create mode 100644 docs/source/images/router/datadog-apm-ops-example.png delete mode 100644 docs/source/reference/router/configuration.mdx delete mode 100644 docs/source/reference/router/errors.mdx delete mode 100644 docs/source/reference/router/self-hosted-install.mdx delete mode 100644 docs/source/reference/router/telemetry/instrumentation/selectors.mdx delete mode 100644 docs/source/reference/router/telemetry/instrumentation/standard-instruments.mdx delete mode 100644 docs/source/reference/router/telemetry/trace-exporters/datadog.mdx delete mode 100644 docs/source/reference/router/telemetry/trace-exporters/jaeger.mdx delete mode 100644 docs/source/routing/about-router.mdx create mode 100644 docs/source/routing/about-v2.mdx create mode 100644 docs/source/routing/changelog.mdx create mode 100644 docs/source/routing/configuration/cli.mdx create mode 100644 docs/source/routing/configuration/envvars.mdx create mode 100644 docs/source/routing/configuration/overview.mdx create mode 100644 docs/source/routing/configuration/yaml.mdx delete mode 100644 docs/source/routing/configure-your-router.mdx create mode 100644 docs/source/routing/customization/coprocessor/index.mdx create mode 100644 docs/source/routing/customization/coprocessor/reference.mdx rename docs/source/routing/customization/{rhai.mdx => rhai/index.mdx} (94%) rename docs/source/{reference/router/rhai.mdx => routing/customization/rhai/reference.mdx} (91%) create mode 100644 docs/source/routing/errors.mdx rename docs/source/{reference/router => routing}/federation-version-support.mdx (92%) create mode 100644 docs/source/routing/get-started.mdx create mode 100644 docs/source/routing/graphos-features.mdx rename docs/source/{reference/graphos-features.mdx => routing/license.mdx} (61%) rename docs/source/{reference => routing}/migration/from-gateway.mdx (97%) delete mode 100644 docs/source/routing/observability/client-awareness.mdx create mode 100644 docs/source/routing/observability/debugging-client-requests.mdx create mode 100644 docs/source/routing/observability/federated-trace-data.mdx create mode 100644 docs/source/routing/observability/otel.mdx delete mode 100644 docs/source/routing/observability/telemetry.mdx create mode 100644 docs/source/routing/observability/telemetry/index.mdx rename docs/source/{reference/router => routing/observability}/telemetry/instrumentation/conditions.mdx (66%) rename docs/source/{reference/router => routing/observability}/telemetry/instrumentation/events.mdx (81%) rename docs/source/{reference/router => routing/observability}/telemetry/instrumentation/instruments.mdx (81%) create mode 100644 docs/source/routing/observability/telemetry/instrumentation/selectors.mdx rename docs/source/{reference/router => routing/observability}/telemetry/instrumentation/spans.mdx (85%) rename docs/source/{reference/router => routing/observability}/telemetry/instrumentation/standard-attributes.mdx (88%) create mode 100644 docs/source/routing/observability/telemetry/instrumentation/standard-instruments.mdx rename docs/source/{reference/router => routing/observability}/telemetry/log-exporters/overview.mdx (87%) rename docs/source/{reference/router => routing/observability}/telemetry/log-exporters/stdout.mdx (99%) rename docs/source/{reference/router => routing/observability}/telemetry/metrics-exporters/datadog.mdx (51%) rename docs/source/{reference/router => routing/observability}/telemetry/metrics-exporters/dynatrace.mdx (83%) rename docs/source/{reference/router => routing/observability}/telemetry/metrics-exporters/new-relic.mdx (84%) rename docs/source/{reference/router => routing/observability}/telemetry/metrics-exporters/otlp.mdx (97%) rename docs/source/{reference/router => routing/observability}/telemetry/metrics-exporters/overview.mdx (64%) rename docs/source/{reference/router => routing/observability}/telemetry/metrics-exporters/prometheus.mdx (58%) create mode 100644 docs/source/routing/observability/telemetry/trace-exporters/datadog.mdx rename docs/source/{reference/router => routing/observability}/telemetry/trace-exporters/dynatrace.mdx (77%) create mode 100644 docs/source/routing/observability/telemetry/trace-exporters/jaeger.mdx rename docs/source/{reference/router => routing/observability}/telemetry/trace-exporters/new-relic.mdx (64%) rename docs/source/{reference/router => routing/observability}/telemetry/trace-exporters/otlp.mdx (99%) rename docs/source/{reference/router => routing/observability}/telemetry/trace-exporters/overview.mdx (78%) rename docs/source/{reference/router => routing/observability}/telemetry/trace-exporters/zipkin.mdx (98%) delete mode 100644 docs/source/routing/operations/index.mdx rename docs/source/routing/operations/subscriptions/{index.mdx => configuration.mdx} (88%) create mode 100644 docs/source/routing/operations/subscriptions/overview.mdx delete mode 100644 docs/source/routing/performance/query-planner-pools.mdx create mode 100644 docs/source/routing/query-planning/query-planning-best-practices.mdx create mode 100644 docs/source/routing/request-lifecycle.mdx create mode 100644 docs/source/routing/security/authorization-overview.mdx create mode 100644 docs/source/routing/self-hosted/containerization/aws.mdx create mode 100644 docs/source/routing/self-hosted/containerization/azure.mdx create mode 100644 docs/source/routing/self-hosted/containerization/docker-router-only.mdx create mode 100644 docs/source/routing/self-hosted/containerization/gcp.mdx delete mode 100644 docs/source/routing/self-hosted/containerization/kubernetes.mdx create mode 100644 docs/source/routing/self-hosted/containerization/kubernetes/extensibility.mdx create mode 100644 docs/source/routing/self-hosted/containerization/kubernetes/metrics.mdx create mode 100644 docs/source/routing/self-hosted/containerization/kubernetes/other-considerations.mdx create mode 100644 docs/source/routing/self-hosted/containerization/kubernetes/quickstart.mdx rename docs/source/{reference/migration/from-router-version-0.x.mdx => routing/upgrade/from-router-v0.mdx} (100%) create mode 100644 docs/source/routing/upgrade/from-router-v1.mdx create mode 100644 docs/source/routing/uplink.mdx create mode 100644 examples/coprocessor-surrogate-cache-key/README.md create mode 100644 examples/coprocessor-surrogate-cache-key/nodejs/.gitignore create mode 100644 examples/coprocessor-surrogate-cache-key/nodejs/README.md create mode 100644 examples/coprocessor-surrogate-cache-key/nodejs/package.json create mode 100644 examples/coprocessor-surrogate-cache-key/nodejs/router.yaml create mode 100644 examples/coprocessor-surrogate-cache-key/nodejs/src/index.js delete mode 100644 examples/telemetry/jaeger-agent.router.yaml delete mode 100644 examples/telemetry/jaeger-collector.router.yaml delete mode 100644 fuzz/docker-compose.yml create mode 100644 fuzz/fuzz_targets/connector_selection_parse.rs delete mode 100644 fuzz/fuzz_targets/federation.rs delete mode 100644 fuzz/supergraph-moretypes.graphql delete mode 100644 fuzz/supergraph.graphql delete mode 100644 netlify.toml delete mode 100644 xtask/Cargo.lock delete mode 100644 xtask/src/commands/release/process.rs create mode 100644 xtask/src/commands/unused.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 306074c6f0..4c9221f903 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,3 +5,7 @@ fed = "run -p apollo-federation-cli --" [profile.profiling] inherits = "release" debug = true + +[env] +# Unset this if you want to change sql queries for caching +SQLX_OFFLINE = "true" diff --git a/.changesets/docs_clarify_authorization_directive_composition.md b/.changesets/docs_clarify_authorization_directive_composition.md deleted file mode 100644 index 52d0610cc9..0000000000 --- a/.changesets/docs_clarify_authorization_directive_composition.md +++ /dev/null @@ -1,5 +0,0 @@ -### docs: correct authorization directive composition ([PR #6216](https://github.com/apollographql/router/pull/6216)) - -Make authorization directive composition clearer and correct code examples - -By [@Meschreiber](https://github.com/Meschreiber) in https://github.com/apollographql/router/pull/6216 diff --git a/.changesets/feat_add_dns_resolution_strategy.md b/.changesets/feat_add_dns_resolution_strategy.md deleted file mode 100644 index cfaa9aaf74..0000000000 --- a/.changesets/feat_add_dns_resolution_strategy.md +++ /dev/null @@ -1,28 +0,0 @@ -### Add ability to configure DNS resolution strategy ([PR #6109](https://github.com/apollographql/router/pull/6109)) - -The router now supports choosing a DNS resolution strategy for the coprocessor's and subgraph's URLs. -The new option is called `dns_resolution_strategy` and supports the following values: -* `ipv4_only` - Only query for `A` (IPv4) records. -* `ipv6_only` - Only query for `AAAA` (IPv6) records. -* `ipv4_and_ipv6` - Query for both `A` (IPv4) and `AAAA` (IPv6) records in parallel. -* `ipv6_then_ipv4` - Query for `AAAA` (IPv6) records first; if that fails, query for `A` (IPv4) records. -* `ipv4_then_ipv6`(default) - Query for `A` (IPv4) records first; if that fails, query for `AAAA` (IPv6) records. - -To change the DNS resolution strategy applied to the subgraph's URL: -```yaml title="router.yaml" -traffic_shaping: - all: - dns_resolution_strategy: ipv4_then_ipv6 - -``` - -You can also change the DNS resolution strategy applied to the coprocessor's URL: -```yaml title="router.yaml" -coprocessor: - url: http://coprocessor.example.com:8081 - client: - dns_resolution_strategy: ipv4_then_ipv6 - -``` - -By [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/6109 diff --git a/.changesets/feat_feat-enhance-error-logging-trace-id.md b/.changesets/feat_feat-enhance-error-logging-trace-id.md new file mode 100644 index 0000000000..8f4ce091e4 --- /dev/null +++ b/.changesets/feat_feat-enhance-error-logging-trace-id.md @@ -0,0 +1,46 @@ +### fix(telemetry): improve error logging for custom trace_id generation ([PR #7910](https://github.com/apollographql/router/pull/7910)) + +#7909 + +This pull request improves logging in the `CustomTraceIdPropagator` implementation by enhancing the error message with additional context about the `trace_id` and the error. + +Logging enhancement: + +* [`apollo-router/src/plugins/telemetry/mod.rs`](diffhunk://#diff-37adf9e170c9b384f17336e5b5e5bf9cd94fd1d618b8969996a5ad56b635ace6L1927-R1927): Updated the error logging statement to include the `trace_id` and the error details as structured fields, providing more context for debugging. + + + +--- + +**Checklist** + +Complete the checklist (and note appropriate exceptions) before the PR is marked ready-for-review. + +- [x] PR description explains the motivation for the change and relevant context for reviewing +- [x] PR description links appropriate GitHub/Jira tickets (creating when necessary) +- [x] Changeset is included for user-facing changes +- [x] Changes are compatible[^1] +- [x] Documentation[^2] completed +- [x] Performance impact assessed and acceptable +- [x] Metrics and logs are added[^3] and documented +- Tests added and passing[^4] + - [x] Unit tests + - [ ] Integration tests + - [ ] Manual tests, as necessary + +**Exceptions** + +*Note any exceptions here* + +**Notes** + +Performance impact is minimal since this change only affects error logging for invalid trace_ids, which occurs only when malformed trace_ids are provided in requests. The enhanced logging adds structured fields to existing error messages without introducing any runtime overhead for valid trace_ids. + +Documentation was completed by adding context about trace_id format requirements and error handling in `docs/source/routing/observability/telemetry/index.mdx`, explaining the W3C Trace Context specification compliance and how the router handles incompatible or malformed trace_ids. + +[^1]: It may be appropriate to bring upcoming changes to the attention of other (impacted) groups. Please endeavour to do this before seeking PR approval. The mechanism for doing this will vary considerably, so use your judgement as to how and when to do this. +[^2]: Configuration is an important part of many changes. Where applicable please try to document configuration examples. +[^3]: A lot of (if not most) features benefit from built-in observability and `debug`-level logs. Please read [this guidance](https://github.com/apollographql/router/blob/dev/dev-docs/metrics.md#adding-new-metrics) on metrics best-practices. +[^4]: Tick whichever testing boxes are applicable. If you are adding Manual Tests, please document the manual testing (extensively) in the Exceptions. + +By [@juancarlosjr97](https://github.com/juancarlosjr97) in https://github.com/apollographql/router/pull/7910 diff --git a/.changesets/feat_geal_subgraph_request_id.md b/.changesets/feat_geal_subgraph_request_id.md deleted file mode 100644 index b5e0934132..0000000000 --- a/.changesets/feat_geal_subgraph_request_id.md +++ /dev/null @@ -1,5 +0,0 @@ -### Add a subgraph request id ([PR #5858](https://github.com/apollographql/router/pull/5858)) - -This is a unique string identifying a subgraph request and response, allowing plugins and coprocessors to keep some state per subgraph request by matching on this id. It is available in coprocessors as `subgraphRequestId` and rhai scripts as `request.subgraph.id` and `response.subgraph.id`. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5858 \ No newline at end of file diff --git a/.changesets/feat_lrlna_enable_generate_query_fragments_by_default.md b/.changesets/feat_lrlna_enable_generate_query_fragments_by_default.md deleted file mode 100644 index 2ae65008fd..0000000000 --- a/.changesets/feat_lrlna_enable_generate_query_fragments_by_default.md +++ /dev/null @@ -1,26 +0,0 @@ -### Compress subgraph operations by generating fragments - -The router now compresses operations sent to subgraphs by default by generating fragment -definitions and using them in the operation. - -Initially, the router is using a very simple transformation that is implemented in both -the JavaScript and Native query planners. We will improve the algorithm after the JavaScript -planner is no longer supported. - -This replaces a previous experimental algorithm that was enabled by default. -`experimental_reuse_query_fragments` attempted to intelligently reuse the fragment definitions -from the original operation. Fragment generation is much faster, and in most cases produces -better outputs too. - -If you are relying on the shape of fragments in your subgraph operations or tests, you can opt -out of the new algorithm with the configuration below. Note we strongly recommend against -relying on the shape of planned operations as new router features and optimizations may affect -it, and we intend to remove `experimental_reuse_query_fragments` in a future release. - -```yaml -supergraph: - generate_query_fragments: false - experimental_reuse_query_fragments: true -``` - -By [@lrlna](https://github.com/lrlna) in https://github.com/apollographql/router/pull/6013 diff --git a/.changesets/feat_max_headers.md b/.changesets/feat_max_headers.md deleted file mode 100644 index 30939d802a..0000000000 --- a/.changesets/feat_max_headers.md +++ /dev/null @@ -1,19 +0,0 @@ -### Configuration Options for HTTP/1 Max Headers and Buffer Limits ([PR #6194](https://github.com/apollographql/router/pull/6194)) - -This update introduces configuration options that allow you to adjust the maximum number of HTTP/1 request headers and the maximum buffer size allocated for headers. - -By default, the Router accepts HTTP/1 requests with up to 100 headers and allocates ~400kib of buffer space to store them. If you need to handle requests with more headers or require a different buffer size, you can now configure these limits in the Router's configuration file: -```yaml -limits: - http1_request_max_headers: 200 - http1_request_max_buf_size: 200kib -``` - -Note for Rust Crate Users: If you are using the Router as a Rust crate, the `http1_request_max_buf_size` option requires the `hyper_header_limits` feature and also necessitates using Apollo's fork of the Hyper crate until the [changes are merged upstream](https://github.com/hyperium/hyper/pull/3523). -You can include this fork by adding the following patch to your Cargo.toml file: -```toml -[patch.crates-io] -"hyper" = { git = "https://github.com/apollographql/hyper.git", tag = "header-customizations-20241108" } -``` - -By [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/6194 diff --git a/.changesets/fix_duckki_fed_678.md b/.changesets/fix_duckki_fed_678.md new file mode 100644 index 0000000000..6723b7cfd5 --- /dev/null +++ b/.changesets/fix_duckki_fed_678.md @@ -0,0 +1,5 @@ +### (Federation) Removed `RebaseError::InterfaceObjectTypename` variant ([PR #8109](https://github.com/apollographql/router/pull/8109)) + +Fixed an uncommon query planning error, "Cannot add selection of field `X` to selection set of parent type `Y` that is potentially an interface object type at runtime". Although fetching `__typename` selections from interface object types are unnecessary, it is difficult to avoid them in all cases and the effect of having those selections in query plans is benign. Thus, the error variant and the check for the error have been removed. + +By [@duckki](https://github.com/duckki) in https://github.com/apollographql/router/pull/8109 diff --git a/.changesets/fix_garypen_log_less_error_for_subgraph_batching.md b/.changesets/fix_garypen_log_less_error_for_subgraph_batching.md deleted file mode 100644 index f81796d01a..0000000000 --- a/.changesets/fix_garypen_log_less_error_for_subgraph_batching.md +++ /dev/null @@ -1,7 +0,0 @@ -### If subgraph batching, do not log response data for notification failure ([PR #6150](https://github.com/apollographql/router/pull/6150)) - -A subgraph response may contain a lot of data and/or PII data. - -For a subgraph batching operation, we should not log out the entire subgraph response when failing to notify a waiting batch participant. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/6150 \ No newline at end of file diff --git a/.changesets/fix_geal_deduplication_processing_time.md b/.changesets/fix_geal_deduplication_processing_time.md deleted file mode 100644 index a3a75c467e..0000000000 --- a/.changesets/fix_geal_deduplication_processing_time.md +++ /dev/null @@ -1,5 +0,0 @@ -### do not count the wait time in deduplication as processing time ([PR #6207](https://github.com/apollographql/router/pull/6207)) - -waiting for a deduplicated request was incorrectly counted as time spent in the router overhead, while most of it was actually spent waiting for the subgraph response. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/6207 \ No newline at end of file diff --git a/.changesets/fix_geal_introspection_dedup_fix.md b/.changesets/fix_geal_introspection_dedup_fix.md deleted file mode 100644 index f93112b815..0000000000 --- a/.changesets/fix_geal_introspection_dedup_fix.md +++ /dev/null @@ -1,5 +0,0 @@ -### Fix introspection query deduplication ([Issue #6249](https://github.com/apollographql/router/issues/6249)) - -To reduce CPU usage, query planning and introspection queries are deduplicated. In some cases, deduplicated introspection queries were not receiving their result. This makes sure that answers are sent in all cases. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/6257 \ No newline at end of file diff --git a/.changesets/fix_geal_response_validation_errors.md b/.changesets/fix_geal_response_validation_errors.md deleted file mode 100644 index 95fc3d60eb..0000000000 --- a/.changesets/fix_geal_response_validation_errors.md +++ /dev/null @@ -1,5 +0,0 @@ -### add errors for response validation ([Issue #5372](https://github.com/apollographql/router/issues/5372)) - -When formatting responses, the router is validating the data returned by subgraphs and replacing it with null values as appropriate. That validation phase is now adding errors when encountering the wrong type in a field requested by the client. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5787 \ No newline at end of file diff --git a/.changesets/fix_renee_limit_errors.md b/.changesets/fix_renee_limit_errors.md deleted file mode 100644 index 60a3404ccc..0000000000 --- a/.changesets/fix_renee_limit_errors.md +++ /dev/null @@ -1,6 +0,0 @@ -### Limit the amount of GraphQL validation errors returned in the response ([PR #6187](https://github.com/apollographql/router/pull/6187)) - -When an invalid query is submitted, the router now returns at most 100 GraphQL parsing and validation errors in the response. -This prevents generating a very large response for nonsense documents. - -By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/6187 \ No newline at end of file diff --git a/.changesets/fix_tninesling_remove_demand_control_warnings.md b/.changesets/fix_tninesling_remove_demand_control_warnings.md deleted file mode 100644 index 195ad7d04c..0000000000 --- a/.changesets/fix_tninesling_remove_demand_control_warnings.md +++ /dev/null @@ -1,5 +0,0 @@ -### Remove noisy demand control logs ([PR #6192](https://github.com/apollographql/router/pull/6192)) - -Demand control no longer logs warnings when a subgraph response is missing a requested field. - -By [@tninesling](https://github.com/tninesling) in https://github.com/apollographql/router/pull/6192 diff --git a/.changesets/fix_tninesling_typename_resolution.md b/.changesets/fix_tninesling_typename_resolution.md deleted file mode 100644 index 934d4ed5d2..0000000000 --- a/.changesets/fix_tninesling_typename_resolution.md +++ /dev/null @@ -1,5 +0,0 @@ -### Do not override concrete type names with interface names when merging responses ([PR #6250](https://github.com/apollographql/router/pull/6250)) - -When using `@interfaceObject`, differing pieces of data can come back with either concrete types or interface types depending on the source. To make the response merging order-agnostic, check the schema to ensure concrete types are not overwritten with interfaces or less specific types. - -By [@tninesling](https://github.com/tninesling) in https://github.com/apollographql/router/pull/6250 diff --git a/.changesets/helm_host_configuration.md b/.changesets/helm_host_configuration.md deleted file mode 100644 index f64334803d..0000000000 --- a/.changesets/helm_host_configuration.md +++ /dev/null @@ -1,6 +0,0 @@ -### Allow for configuration of the host via the helm template for virtual service ([PR #5545](https://github.com/apollographql/router/pull/5795)) - -Using the virtual service template change allows the configuration of the host from a variable when doing helm deploy. -The default of any host causes issues for those that use different hosts for a single AKS cluster - -By [@nicksephora](https://github.com/nicksephora) in https://github.com/apollographql/router/pull/5545 diff --git a/.changesets/maint_bnjjj_fix_supergraph_events_span.md b/.changesets/maint_bnjjj_fix_supergraph_events_span.md deleted file mode 100644 index 00f5cf241d..0000000000 --- a/.changesets/maint_bnjjj_fix_supergraph_events_span.md +++ /dev/null @@ -1,5 +0,0 @@ -### Don't create a stub span for supergraph events if it already has a current span ([PR #6096](https://github.com/apollographql/router/pull/6096)) - -Don't create useless span when we already have a span available to use the span's extensions. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/6096 \ No newline at end of file diff --git a/.changesets/maint_geal_query_planner_cache_key_improvements.md b/.changesets/maint_geal_query_planner_cache_key_improvements.md deleted file mode 100644 index 720836429f..0000000000 --- a/.changesets/maint_geal_query_planner_cache_key_improvements.md +++ /dev/null @@ -1,8 +0,0 @@ -### Query planner cache key improvements ([Issue #5160](https://github.com/apollographql/router/issues/5160)) - -> [!IMPORTANT] -> If you have enabled [Distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching), this release changes the hashing algorithm used for the cache keys. On account of this, you should anticipate additional cache regeneration cost when updating between these versions while the new hashing algorithm comes into service. - -This brings several performance improvements to the query plan cache key generation. In particular, it changes the distributed cache's key format, adding prefixes to the different key segments, to help in debugging. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/6206 \ No newline at end of file diff --git a/.gitmodules b/.changesets/sedceNmNQ similarity index 100% rename from .gitmodules rename to .changesets/sedceNmNQ diff --git a/.circleci/config.yml b/.circleci/config.yml index 7b932c7fc3..4f0e749474 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,98 +1,110 @@ version: 2.1 -# Cache key bump: 1 +# Cache key bump: 3 # These "CircleCI Orbs" are reusable bits of configuration that can be shared # across projects. See https://circleci.com/orbs/ for more information. orbs: - gh: circleci/github-cli@2.3.0 - slack: circleci/slack@4.12.6 + slack: circleci/slack@5.2.0 secops: apollo/circleci-secops-orb@2.0.7 executors: amd_linux_build: &amd_linux_build_executor docker: - - image: cimg/base:stable + - image: ghcr.io/apollographql/ci-utility-docker-images/apollo-rust-builder:0.16.1 resource_class: xlarge environment: CARGO_BUILD_JOBS: 4 RUST_TEST_THREADS: 6 + MISE_ENV: ci amd_linux_helm: &amd_linux_helm_executor docker: - - image: cimg/base:stable + - image: ghcr.io/apollographql/ci-utility-docker-images/apollo-rust-builder:0.16.1 resource_class: small + environment: + MISE_ENV: ci amd_linux_test: &amd_linux_test_executor docker: - - image: cimg/base:stable - - image: cimg/redis:7.2.4 - - image: jaegertracing/all-in-one:1.54.0 - - image: openzipkin/zipkin:2.23.2 - - image: ghcr.io/datadog/dd-apm-test-agent/ddapm-test-agent:v1.17.0 - resource_class: xlarge + - image: ghcr.io/apollographql/ci-utility-docker-images/apollo-rust-builder:0.16.1 + - image: cimg/redis:7.4.5 + - image: cimg/postgres:17.5 + environment: + POSTGRES_USER: root + POSTGRES_DB: root + - image: openzipkin/zipkin:3.5.1 + - image: ghcr.io/datadog/dd-apm-test-agent/ddapm-test-agent:v1.31.1 + resource_class: 2xlarge environment: + MISE_ENV: ci CARGO_BUILD_JOBS: 4 arm_linux_build: &arm_linux_build_executor - machine: - image: ubuntu-2004:2024.01.1 - resource_class: arm.large + docker: + - image: ghcr.io/apollographql/ci-utility-docker-images/apollo-rust-builder:0.16.1 + resource_class: arm.xlarge environment: + MISE_ENV: ci CARGO_BUILD_JOBS: 8 arm_linux_test: &arm_linux_test_executor - machine: - image: ubuntu-2004:2024.01.1 + docker: + - image: ghcr.io/apollographql/ci-utility-docker-images/apollo-rust-builder:0.16.1 resource_class: arm.xlarge environment: + MISE_ENV: ci CARGO_BUILD_JOBS: 8 macos_build: &macos_build_executor macos: # See https://circleci.com/docs/xcode-policy along with the support matrix # at https://circleci.com/docs/using-macos#supported-xcode-versions. # We use the major.minor notation to bring in compatible patches. - xcode: "14.2.0" - resource_class: macos.m1.large.gen1 + xcode: "15.4.0" + resource_class: m2pro.large + environment: + MISE_ENV: ci,ci-mac macos_test: &macos_test_executor macos: # See https://circleci.com/docs/xcode-policy along with the support matrix # at https://circleci.com/docs/using-macos#supported-xcode-versions. # We use the major.minor notation to bring in compatible patches. - # - # TODO: remove workaround added in https://github.com/apollographql/router/pull/5462 - # once we update to Xcode >= 15.1.0 - # See: https://github.com/apollographql/router/pull/5462 - xcode: "14.2.0" - resource_class: macos.m1.large.gen1 + xcode: "15.4.0" + resource_class: m2pro.large + environment: + MISE_ENV: ci,ci-mac windows_build: &windows_build_executor machine: image: "windows-server-2019-vs2019:2024.02.21" - resource_class: windows.xlarge + resource_class: windows.2xlarge shell: bash.exe --login -eo pipefail + environment: + MISE_ENV: ci,windows windows_test: &windows_test_executor machine: image: "windows-server-2019-vs2019:2024.02.21" - resource_class: windows.xlarge + resource_class: windows.2xlarge shell: bash.exe --login -eo pipefail + environment: + MISE_ENV: ci,windows # We don't use {{ arch }} because on windows it is unstable https://discuss.circleci.com/t/value-of-arch-unstable-on-windows/40079 parameters: toolchain_version: type: string - default: '{{ checksum ".circleci/config.yml" }}-v2-{{ checksum "~/.arch" }}-{{ checksum "rust-toolchain.toml" }}-{{ checksum "~/.daily_version" }}' - xtask_version: - type: string - default: '{{ checksum ".circleci/config.yml" }}-{{ checksum "~/.arch" }}-{{ checksum "rust-toolchain.toml" }}-{{ checksum "~/.xtask_version" }}' + default: '{{ checksum ".circleci/config.yml" }}-v3-{{ checksum "~/.arch" }}-{{ checksum ".config/mise/config.toml" }}-{{ checksum "rust-toolchain.toml" }}-{{ checksum "~/.daily_version" }}' merge_version: type: string - default: '{{ checksum ".circleci/config.yml" }}-{{ checksum "~/.arch" }}-{{ checksum "rust-toolchain.toml" }}-{{ checksum "~/.xtask_version" }}-{{ checksum "~/.merge_version" }}' - protoc_version: - type: string - default: "21.8" + default: '{{ checksum ".circleci/config.yml" }}-v3-{{ checksum "~/.arch" }}-{{ checksum ".config/mise/config.toml" }}-{{ checksum "rust-toolchain.toml" }}-{{ checksum "~/.merge_version" }}' nightly: type: boolean default: false + coverage: + type: boolean + default: false # quick_nightly will skip testing and only build the release artifacts. quick_nightly: type: boolean default: false + test_updated_cargo_deps: + type: boolean + default: false # These are common environment variables that we want to set on on all jobs. # While these could conceivably be set on the CircleCI project settings' @@ -104,8 +116,8 @@ common_job_environment: &common_job_environment CARGO_NET_GIT_FETCH_WITH_CLI: "true" RUST_BACKTRACE: full CARGO_INCREMENTAL: 0 + MISE_VERSION: v2025.7.26 commands: - setup_environment: parameters: platform: @@ -117,22 +129,15 @@ commands: - restore_cache: keys: - "<< pipeline.parameters.toolchain_version >>" - - install_packages: - platform: << parameters.platform >> - - install_protoc: - platform: << parameters.platform >> - - install_rust: + - install_mise: platform: << parameters.platform >> - - install_extra_tools - fetch_dependencies - save_cache: key: "<< pipeline.parameters.toolchain_version >>" paths: - - ~/.deb - ~/.cargo - ~/.rustup - ~/.local - - install_xtask # Even though all executors use bash there are a number of differences that can be taken care of up front. # Windows shell commands are found on the path before the linux subsystem commands, so use aliases to override. @@ -148,8 +153,9 @@ commands: - when: condition: or: - - equal: [ *amd_linux_build_executor, << parameters.platform >> ] - - equal: [ *amd_linux_test_executor, << parameters.platform >> ] + - equal: [*amd_linux_build_executor, << parameters.platform >>] + - equal: [*amd_linux_test_executor, << parameters.platform >>] + - equal: [*amd_linux_helm_executor, << parameters.platform >>] steps: - run: name: Write arch @@ -158,8 +164,8 @@ commands: - when: condition: or: - - equal: [ *arm_linux_build_executor, << parameters.platform >> ] - - equal: [ *arm_linux_test_executor, << parameters.platform >> ] + - equal: [*arm_linux_build_executor, << parameters.platform >>] + - equal: [*arm_linux_test_executor, << parameters.platform >>] steps: - run: name: Write arch @@ -168,8 +174,8 @@ commands: - when: condition: or: - - equal: [ *macos_build_executor, << parameters.platform >> ] - - equal: [ *macos_test_executor, << parameters.platform >> ] + - equal: [*macos_build_executor, << parameters.platform >>] + - equal: [*macos_test_executor, << parameters.platform >>] steps: - run: name: Make link to md5 @@ -183,15 +189,16 @@ commands: - when: condition: or: - - equal: [ *windows_build_executor, << parameters.platform >> ] - - equal: [ *windows_test_executor, << parameters.platform >> ] + - equal: [*windows_build_executor, << parameters.platform >>] + - equal: [*windows_test_executor, << parameters.platform >>] steps: - run: - name: Create bash aliases + name: Extend Bash profile for Windows command: | echo 'alias find=/bin/find' >> "$BASH_ENV" echo 'alias sort=/bin/sort' >> "$BASH_ENV" echo 'export EXECUTABLE_SUFFIX=".exe"' >> "$BASH_ENV" + echo 'export PATH="$HOME/AppData/Local/mise/shims:$HOME/scoop/apps/mise/current/bin:$HOME/scoop/shims:$PATH"' >> "$BASH_ENV" - run: name: Write arch command: | @@ -205,8 +212,6 @@ commands: command: | # The Rust index takes time to download. Update this daily. date +%j > ~/.daily_version - # The checksum of the xtask/src directory, so that when we make changes to xtask we cause a full rebuild - find xtask/src -type f | while read name; do md5sum $name; done | sort -k 2 | md5sum > ~/.xtask_version # The closest common ancestor to the default branch, so that test jobs can take advantage previous compiles git remote set-head origin -a TARGET_BRANCH=$(git rev-parse --abbrev-ref origin/HEAD) @@ -215,51 +220,8 @@ commands: echo "Common ancestor is ${COMMON_ANCESTOR_REF}" echo "${CIRCLE_PROJECT_REPONAME}-${COMMON_ANCESTOR_REF}" > ~/.merge_version - # Linux specific step to install packages that are needed - install_packages: - parameters: - platform: - type: executor - steps: - - when: - condition: - or: - - equal: [ *amd_linux_build_executor, << parameters.platform >> ] - - equal: [ *amd_linux_test_executor, << parameters.platform >> ] - - equal: [ *arm_linux_build_executor, << parameters.platform >> ] - - equal: [ *arm_linux_test_executor, << parameters.platform >> ] - steps: - - run: - name: Update and install dependencies - command: | - if [[ ! -d "$HOME/.deb" ]]; then - mkdir $HOME/.deb - sudo apt-get --download-only -o Dir::Cache="$HOME/.deb" -o Dir::Cache::archives="$HOME/.deb" install libssl-dev libdw-dev cmake - fi - sudo dpkg -i $HOME/.deb/*.deb - - when: - condition: - or: - - equal: [ *windows_build_executor, << parameters.platform >> ] - - equal: [ *windows_test_executor, << parameters.platform >> ] - steps: - - run: - name: Install CMake - command: | - choco install cmake.install -y - echo 'export PATH="/c/Program Files/CMake/bin:$PATH"' >> "$BASH_ENV" - exit $LASTEXITCODE - - when: - condition: - or: - - equal: [ *macos_build_executor, << parameters.platform >> ] - - equal: [ *macos_test_executor, << parameters.platform >> ] - steps: - - run: - name: Install CMake - command: | - brew install cmake - install_protoc: + # Install mise for tool version management + install_mise: parameters: platform: type: executor @@ -267,148 +229,55 @@ commands: - when: condition: or: - - equal: [ *amd_linux_build_executor, << parameters.platform >> ] - - equal: [ *amd_linux_test_executor, << parameters.platform >> ] + - equal: [*amd_linux_helm_executor, << parameters.platform >>] + - equal: [*amd_linux_build_executor, << parameters.platform >>] + - equal: [*amd_linux_test_executor, << parameters.platform >>] + - equal: [*arm_linux_build_executor, << parameters.platform >>] + - equal: [*arm_linux_test_executor, << parameters.platform >>] + - equal: [*macos_build_executor, << parameters.platform >>] + - equal: [*macos_test_executor, << parameters.platform >>] steps: - run: - name: Install protoc + name: Install mise command: | - if [[ ! -f "$HOME/.local/bin/protoc" ]]; then - curl -L https://github.com/protocolbuffers/protobuf/releases/download/v<< pipeline.parameters.protoc_version >>/protoc-<< pipeline.parameters.protoc_version >>-linux-x86_64.zip --output protoc.zip - unzip protoc.zip -d $HOME/.local - fi + curl https://mise.jdx.dev/install.sh | sh + mise activate bash >> "$BASH_ENV" + # --raw disables the terminal timer/progress bars, so if mise gets stuck, + # it will not continue to produce output preventing CCI from timing it out + mise install --yes --raw - when: condition: or: - - equal: [ *arm_linux_build_executor, << parameters.platform >> ] - - equal: [ *arm_linux_test_executor, << parameters.platform >> ] + - equal: [*windows_build_executor, << parameters.platform >>] + - equal: [*windows_test_executor, << parameters.platform >>] steps: - run: - name: Install protoc + name: Install scoop + shell: powershell.exe -ExecutionPolicy Bypass command: | - if [[ ! -f "$HOME/.local/bin/protoc" ]]; then - curl -L https://github.com/protocolbuffers/protobuf/releases/download/v<< pipeline.parameters.protoc_version >>/protoc-<< pipeline.parameters.protoc_version >>-linux-aarch_64.zip --output protoc.zip - unzip protoc.zip -d $HOME/.local - fi - - when: - condition: - or: - - equal: [ *macos_build_executor, << parameters.platform >> ] - - equal: [ *macos_test_executor, << parameters.platform >> ] - steps: + iex "& {$(irm get.scoop.sh)} -RunAsAdmin" - run: - name: Install protoc + name: Install mise command: | - if [[ ! -f "$HOME/.local/bin/protoc" ]]; then - curl -L https://github.com/protocolbuffers/protobuf/releases/download/v<< pipeline.parameters.protoc_version >>/protoc-<< pipeline.parameters.protoc_version >>-osx-universal_binary.zip --output protoc.zip - unzip protoc.zip -d $HOME/.local - fi - - when: - condition: - or: - - equal: [ *windows_build_executor, << parameters.platform >> ] - - equal: [ *windows_test_executor, << parameters.platform >> ] - steps: - - run: - name: Install protoc - command: | - if [[ ! -f "$HOME/.local/bin/protoc$EXECUTABLE_SUFFIX" ]]; then - curl -L https://github.com/protocolbuffers/protobuf/releases/download/v<< pipeline.parameters.protoc_version >>/protoc-<< pipeline.parameters.protoc_version >>-win64.zip --output protoc.zip - unzip protoc.zip -d $HOME/.local - fi - - install_rust: - parameters: - platform: - type: executor - steps: - - run: - name: Install Rust - command: | - if [[ ! -d "$HOME/.cargo" ]]; then - curl https://sh.rustup.rs -sSf -o rustup.sh - chmod 755 ./rustup.sh - ./rustup.sh -y --profile minimal --component clippy --component rustfmt --default-toolchain none - $HOME/.cargo/bin/rustc -V - fi - echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> "$BASH_ENV" - - - when: - condition: - or: - - equal: [ *windows_build_executor, << parameters.platform >> ] - - equal: [ *windows_test_executor, << parameters.platform >> ] - steps: - - run: - name: Special case for Windows because of ssh-agent - command: | - printf "[net]\ngit-fetch-with-cli = true" >> ~/.cargo/Cargo.toml - - when: - condition: - or: - - equal: [ *macos_build_executor, << parameters.platform >> ] - steps: - - run: - name: Special case for OSX x86_64 builds - command: | - rustup target add x86_64-apple-darwin - - - when: - condition: - equal: [ *arm_linux_test_executor, << parameters.platform >> ] - steps: - - run: - name: Install nightly Rust to build the fuzzers - command: | - rustup install nightly - - install_extra_tools: - steps: - - run: - name: Install cargo deny, about, edit - command: | - if [[ ! -f "$HOME/.cargo/bin/cargo-deny$EXECUTABLE_SUFFIX" ]]; then - cargo install --locked --version 0.14.21 cargo-deny - cargo install --locked --version 0.6.1 cargo-about - cargo install --locked --version 0.12.2 cargo-edit - cargo install --locked --version 0.12.0 cargo-fuzz - fi - - if [[ ! -f "$HOME/.cargo/bin/cargo-nextest$EXECUTABLE_SUFFIX" ]]; then - cargo install --locked --version 0.9.70 cargo-nextest - fi - + scoop install mise@${MISE_VERSION#v} + # --raw disables the terminal timer/progress bars, so if mise gets stuck, + # it will not continue to produce output preventing CCI from timing it out + mise install --yes --raw fetch_dependencies: steps: - run: name: Fetch dependencies command: cargo fetch --locked - install_xtask: - steps: - - restore_cache: - keys: - - "<< pipeline.parameters.xtask_version >>" - - run: - name: Install xtask - command: | - if [[ ! -f "$HOME/.cargo/bin/xtask$EXECUTABLE_SUFFIX" ]]; then - cargo install --locked --path xtask - fi - - save_cache: - key: "<< pipeline.parameters.xtask_version >>" - paths: - - ~/.cargo/bin/xtask - - ~/.cargo/bin/xtask.exe xtask_lint: steps: - restore_cache: keys: - "<< pipeline.parameters.merge_version >>-lint" - - run: xtask lint + - run: cargo xtask lint - when: condition: - equal: [ "dev", "<< pipeline.git.branch >>" ] + equal: ["dev", "<< pipeline.git.branch >>"] steps: - save_cache: key: "<< pipeline.parameters.merge_version >>-lint" @@ -416,20 +285,13 @@ commands: - target xtask_release_preverify: steps: - - run: xtask release pre-verify + - run: cargo xtask release pre-verify xtask_check_helm: steps: - run: name: Validate helm manifests command: | - # Install Helm - curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - - # Install kubeconform - KUBECONFORM_INSTALL=$(mktemp -d) - curl -L https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz | tar xz -C "${KUBECONFORM_INSTALL}" - # Create list of kube versions CURRENT_KUBE_VERSIONS=$(curl -s -L https://raw.githubusercontent.com/kubernetes/website/main/data/releases/schedule.yaml \ | yq -o json '.' \ @@ -464,7 +326,7 @@ commands: helm template --kube-version "${kube_version}" router helm/chart/router --set autoscaling.enabled=true > "${TEMPLATE_DIR}/router-${kube_version}.yaml" # Execute kubeconform on our templated charts to ensure they are good - "${KUBECONFORM_INSTALL}/kubeconform" \ + kubeconform \ --kubernetes-version "${kube_version}" \ --strict \ --schema-location default \ @@ -480,10 +342,10 @@ commands: - "<< pipeline.parameters.merge_version >>-compliance" # cargo-deny fetches a rustsec advisory DB, which has to happen on github.com over https - run: git config --global --unset-all url.ssh://git@github.com.insteadof - - run: xtask check-compliance + - run: cargo xtask check-compliance - when: condition: - equal: [ "dev", "<< pipeline.git.branch >>" ] + equal: ["dev", "<< pipeline.git.branch >>"] steps: - save_cache: key: "<< pipeline.parameters.merge_version >>-compliance" @@ -503,11 +365,9 @@ commands: environment: # Use the settings from the "ci" profile in nextest configuration. NEXTEST_PROFILE: ci - # Temporary disable lib backtrace since it crashing on MacOS - # TODO: remove this workaround once we update to Xcode >= 15.1.0 - # See: https://github.com/apollographql/router/pull/5462 - RUST_LIB_BACKTRACE: 0 - command: xtask test --workspace --locked --features ci,hyper_header_limits + command: | + cargo xtask test --workspace --locked --features ci,snapshot + - run: name: Delete large files from cache command: | @@ -515,7 +375,7 @@ commands: rm target/debug/router* - when: condition: - equal: [ "dev", "<< pipeline.git.branch >>" ] + equal: ["dev", "<< pipeline.git.branch >>"] steps: - save_cache: key: "<< pipeline.parameters.merge_version >>-test-<< parameters.variant >>" @@ -524,9 +384,29 @@ commands: - store_test_results: # The results from nextest that power the CircleCI Insights. path: ./target/nextest/ci/junit.xml - fuzz_build: + # Temporarily disable cargo-fuzz https://github.com/apollographql/router/pull/7488 + # fuzz_build: + # steps: + # - run: cargo +nightly fuzz build + do_coverage: + parameters: + variant: + type: string + default: "default" steps: - - run: cargo +nightly fuzz build + - run: + name: Run coverage + environment: + # Use the settings from the "ci" profile in nextest configuration. + NEXTEST_PROFILE: ci + command: cargo llvm-cov nextest --ignore-run-fail --codecov --output-path codecov_report.json + - run: + name: Upload coverage report + command: | + # Upload the coverage report to Codecov. + codecov-cli --auto-load-params-from CircleCI upload-process --file ./codecov_report.json --token "${CODECOV_TOKEN}" + - store_artifacts: + path: ./codecov_report.json jobs: lint: @@ -552,9 +432,11 @@ jobs: steps: - when: condition: - equal: [ *amd_linux_helm_executor, << parameters.platform >> ] + equal: [*amd_linux_helm_executor, << parameters.platform >>] steps: - checkout + - setup_environment: + platform: << parameters.platform >> - xtask_check_helm check_compliance: @@ -579,19 +461,35 @@ jobs: fuzz: type: boolean default: false + coverage: + type: boolean + default: false executor: << parameters.platform >> steps: - checkout - setup_environment: platform: << parameters.platform >> - xtask_test - - when: - condition: - and: - - equal: [ true, << parameters.fuzz >> ] - - equal: [ *arm_linux_test_executor, << parameters.platform >> ] - steps: - - fuzz_build + # Temporarily disable cargo-fuzz https://github.com/apollographql/router/pull/7488 + # - when: + # condition: + # and: + # - equal: [ true, << parameters.fuzz >> ] + # - equal: [ *arm_linux_test_executor, << parameters.platform >> ] + # steps: + # - fuzz_build + coverage: + environment: + <<: *common_job_environment + parameters: + platform: + type: executor + executor: << parameters.platform >> + steps: + - checkout + - setup_environment: + platform: << parameters.platform >> + - do_coverage test_updated: environment: @@ -599,6 +497,10 @@ jobs: parameters: platform: type: executor + default: amd_linux_test + from_test_updated_cargo_deps_workflow: + type: boolean + default: false executor: << parameters.platform >> steps: - checkout @@ -611,6 +513,68 @@ jobs: cargo fetch - xtask_test: variant: "updated" + + - when: + condition: + equal: + [true, << parameters.from_test_updated_cargo_deps_workflow >>] + steps: + - slack/notify: + event: fail + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":x: The `test_updated_cargo_deps` workflow has **failed** for `${CIRCLE_JOB}` on `${CIRCLE_PROJECT_REPONAME}`'s `${CIRCLE_BRANCH}`!" + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "action_id": "success_tagged_deploy_view", + "text": { + "type": "plain_text", + "text": "View Job" + }, + "url": "${CIRCLE_BUILD_URL}" + } + ] + } + ] + } + - slack/notify: + event: pass + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: The `test_updated_cargo_deps` workflow has passed for `${CIRCLE_JOB}` on `${CIRCLE_PROJECT_REPONAME}`'s `${CIRCLE_BRANCH}`." + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "action_id": "success_tagged_deploy_view", + "text": { + "type": "plain_text", + "text": "View Job" + }, + "url": "${CIRCLE_BUILD_URL}" + } + ] + } + ] + } pre_verify_release: environment: <<: *common_job_environment @@ -645,20 +609,20 @@ jobs: - when: condition: or: - - equal: [ *macos_build_executor, << parameters.platform >> ] + - equal: [*macos_build_executor, << parameters.platform >>] steps: - when: condition: - equal: [ true, << parameters.nightly >> ] + equal: [true, << parameters.nightly >>] steps: - run: cargo xtask release prepare nightly - run: command: > - cargo xtask dist --target aarch64-apple-darwin --features hyper_header_limits + cargo xtask dist --target aarch64-apple-darwin - run: command: > - cargo xtask dist --target x86_64-apple-darwin --features hyper_header_limits + cargo xtask dist --target x86_64-apple-darwin - run: command: > mkdir -p artifacts @@ -684,41 +648,22 @@ jobs: --keychain-password ${MACOS_KEYCHAIN_PASSWORD} --notarization-password ${MACOS_NOTARIZATION_PASSWORD} --output artifacts/ - - when: - condition: - and: - - equal: [ *amd_linux_build_executor, << parameters.platform >> ] - - equal: [ true, << parameters.nightly >> ] - steps: - - run: - name: Helm install - command: | - # Install Helm - curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - - run: - name: helm-docs install - command: | - # install golang (to ${HOME}/go) - curl -OLs https://go.dev/dl/go1.21.3.linux-amd64.tar.gz - tar -C "${HOME}" -xf go1.21.3.linux-amd64.tar.gz - # install helm-docs - PATH="${HOME}/go/bin" GOPATH="${HOME}/.local" GO111MODULE=on go install github.com/norwoodj/helm-docs/cmd/helm-docs@latest - when: condition: or: - - equal: [ *amd_linux_build_executor, << parameters.platform >> ] - - equal: [ *arm_linux_build_executor, << parameters.platform >> ] - - equal: [ *windows_build_executor, << parameters.platform >> ] + - equal: [*amd_linux_build_executor, << parameters.platform >>] + - equal: [*arm_linux_build_executor, << parameters.platform >>] + - equal: [*windows_build_executor, << parameters.platform >>] steps: # This will set the version to include current date and commit hash - when: condition: - equal: [ true, << parameters.nightly >> ] + equal: [true, << parameters.nightly >>] steps: - run: cargo xtask release prepare nightly - run: command: > - cargo xtask dist --features hyper_header_limits + cargo xtask dist - run: command: > mkdir -p artifacts @@ -734,8 +679,8 @@ jobs: - when: condition: and: - - equal: [ *amd_linux_build_executor, << parameters.platform >> ] - - equal: [ true, << parameters.nightly >> ] + - equal: [*amd_linux_build_executor, << parameters.platform >>] + - equal: [true, << parameters.nightly >>] - matches: pattern: "^https:\\/\\/github\\.com\\/apollographql\\/router.*$" value: << pipeline.project.git_url >> @@ -750,20 +695,49 @@ jobs: command: | # Source of the new image will be ser to the repo URL. # This will have the effect of setting org.opencontainers.image.source and org.opencontainers.image.author to the originating pipeline - # Therefore the docker image will have the same permissions as the originating project. + # Therefore the docker image will have the same permissions as the originating project. # See: https://docs.github.com/en/packages/learn-github-packages/connecting-a-repository-to-a-package#connecting-a-repository-to-a-container-image-using-the-command-line - + BASE_VERSION=$(cargo metadata --format-version=1 --no-deps | jq --raw-output '.packages[0].version') - ARTIFACT_URL="https://output.circle-artifacts.com/output/job/${CIRCLE_WORKFLOW_JOB_ID}/artifacts/0/artifacts/router-v${BASE_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + ARTIFACT_FILENAME="router-v${BASE_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + ARTIFACT_URL="https://output.circle-artifacts.com/output/job/${CIRCLE_WORKFLOW_JOB_ID}/artifacts/0/artifacts/${ARTIFACT_FILENAME}" VERSION="v$(echo "${BASE_VERSION}" | tr "+" "-")" ROUTER_TAG=ghcr.io/apollographql/nightly/router - + + # Validate local artifact exists and calculate its checksum as a safety net + if [ ! -f "artifacts/${ARTIFACT_FILENAME}" ]; then + echo "Error: Local artifact not found: artifacts/${ARTIFACT_FILENAME}" + exit 1 + fi + LOCAL_ARTIFACT_SHA256SUM=$(sha256sum "artifacts/${ARTIFACT_FILENAME}" | cut -d' ' -f1) + echo "Local artifact checksum: ${LOCAL_ARTIFACT_SHA256SUM}" + + # Download the artifact and calculate its checksum + echo "Downloading artifact to calculate checksum..." + curl -sSL -H "Circle-Token: ${CIRCLE_TOKEN}" -o "router-artifact.tar.gz" "${ARTIFACT_URL}" + + # Calculate checksum of the tarball (not the binary inside) + ARTIFACT_URL_SHA256SUM=$(sha256sum "router-artifact.tar.gz" | cut -d' ' -f1) + rm -f router-artifact.tar.gz + + # Compare local vs remote artifact checksums + if [ "${LOCAL_ARTIFACT_SHA256SUM}" != "${ARTIFACT_URL_SHA256SUM}" ]; then + echo "Error: Local and remote artifact checksums don't match!" + echo "Local: ${LOCAL_ARTIFACT_SHA256SUM}" + echo "Remote: ${ARTIFACT_URL_SHA256SUM}" + exit 1 + fi + echo "Local and remote artifact checksums match: ${LOCAL_ARTIFACT_SHA256SUM}" + echo "REPO_URL: ${REPO_URL}" echo "BASE_VERSION: ${BASE_VERSION}" + echo "ARTIFACT_FILENAME: ${ARTIFACT_FILENAME}" echo "ARTIFACT_URL: ${ARTIFACT_URL}" + echo "ARTIFACT_URL_SHA256SUM: ${ARTIFACT_URL_SHA256SUM}" + echo "LOCAL_ARTIFACT_SHA256SUM: ${LOCAL_ARTIFACT_SHA256SUM}" echo "VERSION: ${VERSION}" echo "ROUTER_TAG: ${ROUTER_TAG}" - + # Create a multi-arch builder which works properly under qemu docker run --rm --privileged multiarch/qemu-user-static --reset -p yes docker context create buildx-build @@ -773,10 +747,10 @@ jobs: echo ${GITHUB_OCI_TOKEN} | docker login ghcr.io -u apollo-bot2 --password-stdin # TODO: Can't figure out how to build multi-arch image from ARTIFACT_URL right now. Figure out later... # Build and push debug image - docker buildx build --load --platform linux/amd64 --build-arg CIRCLE_TOKEN="${CIRCLE_TOKEN}" --build-arg REPO_URL="${REPO_URL}" --build-arg ARTIFACT_URL="${ARTIFACT_URL}" --build-arg DEBUG_IMAGE="true" --build-arg ROUTER_RELEASE=${VERSION} -f dockerfiles/Dockerfile.router -t ${ROUTER_TAG}:${VERSION}-debug . + docker buildx build --load --platform linux/amd64 --build-arg CIRCLE_TOKEN="${CIRCLE_TOKEN}" --build-arg REPO_URL="${REPO_URL}" --build-arg ARTIFACT_URL="${ARTIFACT_URL}" --build-arg ARTIFACT_URL_SHA256SUM="${ARTIFACT_URL_SHA256SUM}" --build-arg DEBUG_IMAGE="true" --build-arg ROUTER_RELEASE=${VERSION} --build-arg BASE_VERSION=${BASE_VERSION} -f dockerfiles/Dockerfile.router -t ${ROUTER_TAG}:${VERSION}-debug dockerfiles/. docker push ${ROUTER_TAG}:${VERSION}-debug # Build and push release image - docker buildx build --load --platform linux/amd64 --build-arg CIRCLE_TOKEN="${CIRCLE_TOKEN}" --build-arg REPO_URL="${REPO_URL}" --build-arg ARTIFACT_URL="${ARTIFACT_URL}" --build-arg ROUTER_RELEASE=${VERSION} -f dockerfiles/Dockerfile.router -t ${ROUTER_TAG}:${VERSION} . + docker buildx build --load --platform linux/amd64 --build-arg CIRCLE_TOKEN="${CIRCLE_TOKEN}" --build-arg REPO_URL="${REPO_URL}" --build-arg ARTIFACT_URL="${ARTIFACT_URL}" --build-arg ARTIFACT_URL_SHA256SUM="${ARTIFACT_URL_SHA256SUM}" --build-arg ROUTER_RELEASE=${VERSION} --build-arg BASE_VERSION=${BASE_VERSION} -f dockerfiles/Dockerfile.router -t ${ROUTER_TAG}:${VERSION} dockerfiles/. docker push ${ROUTER_TAG}:${VERSION} # save containers for analysis mkdir built-containers @@ -800,7 +774,7 @@ jobs: helm push ${CHART} oci://ghcr.io/apollographql/helm-charts-nightly - when: condition: - equal: [ true, << parameters.nightly >> ] + equal: [true, << parameters.nightly >>] steps: - slack/notify: event: fail @@ -861,7 +835,7 @@ jobs: publish_github_release: docker: - - image: cimg/base:stable + - image: ghcr.io/apollographql/ci-utility-docker-images/apollo-rust-builder:0.16.1 resource_class: small environment: <<: *common_job_environment @@ -870,16 +844,26 @@ jobs: - when: condition: not: - equal: [ "https://github.com/apollographql/router", << pipeline.project.git_url >> ] + equal: + [ + "https://github.com/apollographql/router", + << pipeline.project.git_url >>, + ] steps: - run: command: > echo "Not publishing any github release." - when: condition: - equal: [ "https://github.com/apollographql/router", << pipeline.project.git_url >> ] + equal: + [ + "https://github.com/apollographql/router", + << pipeline.project.git_url >>, + ] steps: - checkout + - setup_environment: + platform: amd_linux_build - setup_remote_docker: # CircleCI Image Policy # https://circleci.com/docs/remote-docker-images-support-policy/ @@ -887,7 +871,6 @@ jobs: docker_layer_caching: true - attach_workspace: at: artifacts - - gh/setup - run: command: > cd artifacts && sha256sum *.tar.gz > sha256sums.txt @@ -897,6 +880,19 @@ jobs: - run: command: > cd artifacts && sha1sum *.tar.gz > sha1sums.txt + + - run: + name: Publish to Crates.io + command: | + # Remove leading 'v' which is present on the Git tag; Cargo doesn't want that leading 'v' + VERSION_WITHOUT_LEADING_V="${VERSION#v}" + echo "Publishing apollo-federation@${VERSION_WITHOUT_LEADING_V} and apollo-router@${VERSION_WITHOUT_LEADING_V}" + # Just do a dry-run for now on apollo-federation, since that's a leading + # indicator that the publish is going to work. We don't do the attempt for + # router because it's dependent on apollo-federation, so it'll definitely fail. + cargo publish -p apollo-federation@${VERSION_WITHOUT_LEADING_V} + cargo publish -p apollo-router@${VERSION_WITHOUT_LEADING_V} + - run: name: Create GitHub Release command: > @@ -926,14 +922,12 @@ jobs: docker manifest inspect ${ROUTER_TAG}:${VERSION} > /dev/null && exit 1 docker manifest inspect ${ROUTER_TAG}:${VERSION}-debug > /dev/null && exit 1 # Build and push debug image - docker buildx build --platform linux/amd64,linux/arm64 --push --build-arg DEBUG_IMAGE="true" --build-arg ROUTER_RELEASE=${VERSION} -f dockerfiles/Dockerfile.router -t ${ROUTER_TAG}:${VERSION}-debug . + docker buildx build --platform linux/amd64,linux/arm64 --push --build-arg DEBUG_IMAGE="true" --build-arg ROUTER_RELEASE=${VERSION} -f dockerfiles/Dockerfile.router -t ${ROUTER_TAG}:${VERSION}-debug dockerfiles/. # Build and push release image - docker buildx build --platform linux/amd64,linux/arm64 --push --build-arg ROUTER_RELEASE=${VERSION} -f dockerfiles/Dockerfile.router -t ${ROUTER_TAG}:${VERSION} . + docker buildx build --platform linux/amd64,linux/arm64 --push --build-arg ROUTER_RELEASE=${VERSION} -f dockerfiles/Dockerfile.router -t ${ROUTER_TAG}:${VERSION} dockerfiles/. - run: name: Helm build command: | - # Install Helm - curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash # Package up the helm chart helm package helm/chart/router # Make sure we have the newest chart @@ -948,35 +942,33 @@ jobs: helm push ${CHART} oci://ghcr.io/apollographql/helm-charts workflows: + test_updated_cargo_deps: + when: << pipeline.parameters.test_updated_cargo_deps >> + jobs: + - test_updated: + platform: amd_linux_test + from_test_updated_cargo_deps_workflow: true ci_checks: when: not: or: - << pipeline.parameters.nightly >> - << pipeline.parameters.quick_nightly >> + - << pipeline.parameters.test_updated_cargo_deps >> jobs: - lint: matrix: parameters: - platform: [ amd_linux_build ] + platform: [amd_linux_build] - check_helm: matrix: parameters: - platform: [ amd_linux_helm ] + platform: [amd_linux_helm] - check_compliance: matrix: parameters: - platform: [ amd_linux_build ] + platform: [amd_linux_build] - - test_updated: - requires: - - lint - - check_helm - - check_compliance - matrix: - parameters: - platform: - [ amd_linux_test ] - test: # this should be changed back to true on dev after release fuzz: false @@ -987,8 +979,7 @@ workflows: matrix: parameters: platform: - [ macos_test, windows_test, amd_linux_test, arm_linux_test ] - + [macos_test, windows_test, amd_linux_test, arm_linux_test] quick-nightly: when: << pipeline.parameters.quick_nightly >> jobs: @@ -1000,45 +991,46 @@ workflows: matrix: parameters: platform: - [ macos_build, windows_build, amd_linux_build, arm_linux_build ] + [macos_build, windows_build, amd_linux_build, arm_linux_build] + coverage_only: + when: << pipeline.parameters.coverage >> + jobs: + - coverage: + matrix: + parameters: + platform: [macos_test] nightly: when: << pipeline.parameters.nightly >> jobs: - lint: matrix: parameters: - platform: [ amd_linux_build ] + platform: [amd_linux_build] - check_helm: matrix: parameters: - platform: [ amd_linux_helm ] + platform: [amd_linux_helm] - check_compliance: matrix: parameters: - platform: [ amd_linux_build ] - - - test_updated: + platform: [amd_linux_build] + - test: requires: - lint - - check_helm - - check_compliance matrix: parameters: platform: - [ amd_linux_test ] - - test: - requires: - - lint - - check_helm - - check_compliance + [macos_test, windows_test, amd_linux_test, arm_linux_test] + - coverage: matrix: parameters: - platform: - [ macos_test, windows_test, amd_linux_test, arm_linux_test ] + platform: [macos_test] - build_release: requires: - test - - test_updated + - coverage + - check_helm + - check_compliance nightly: true context: - router @@ -1046,7 +1038,7 @@ workflows: matrix: parameters: platform: - [ macos_build, windows_build, amd_linux_build, arm_linux_build ] + [macos_build, windows_build, amd_linux_build, arm_linux_build] - secops/wiz-docker: context: - platform-docker-ro @@ -1066,18 +1058,18 @@ workflows: # This allows us to tailor the policy applied during the scans to router. wiz-policies: Apollo-Router-Vulnerabilities-Policy - release: when: not: or: - << pipeline.parameters.nightly >> - << pipeline.parameters.quick_nightly >> + - << pipeline.parameters.test_updated_cargo_deps >> jobs: - pre_verify_release: matrix: parameters: - platform: [ amd_linux_build ] + platform: [amd_linux_build] filters: branches: ignore: /.*/ @@ -1086,7 +1078,7 @@ workflows: - lint: matrix: parameters: - platform: [ amd_linux_build ] + platform: [amd_linux_build] filters: branches: ignore: /.*/ @@ -1095,7 +1087,7 @@ workflows: - check_helm: matrix: parameters: - platform: [ amd_linux_helm ] + platform: [amd_linux_helm] filters: branches: ignore: /.*/ @@ -1104,17 +1096,7 @@ workflows: - check_compliance: matrix: parameters: - platform: [ amd_linux_build ] - filters: - branches: - ignore: /.*/ - tags: - only: /v.*/ - - test_updated: - matrix: - parameters: - platform: - [ amd_linux_test ] + platform: [amd_linux_build] filters: branches: ignore: /.*/ @@ -1124,7 +1106,7 @@ workflows: matrix: parameters: platform: - [ macos_test, windows_test, amd_linux_test, arm_linux_test ] + [macos_test, windows_test, amd_linux_test, arm_linux_test] filters: branches: ignore: /.*/ @@ -1134,7 +1116,7 @@ workflows: matrix: parameters: platform: - [ macos_build, windows_build, amd_linux_build, arm_linux_build ] + [macos_build, windows_build, amd_linux_build, arm_linux_build] filters: branches: ignore: /.*/ @@ -1148,7 +1130,6 @@ workflows: - check_compliance - pre_verify_release - test - - test_updated filters: branches: ignore: /.*/ @@ -1161,6 +1142,7 @@ workflows: or: - << pipeline.parameters.nightly >> - << pipeline.parameters.quick_nightly >> + - << pipeline.parameters.test_updated_cargo_deps >> jobs: - secops/gitleaks: context: @@ -1173,3 +1155,4 @@ workflows: - secops-oidc - github-orb git-base-revision: <<#pipeline.git.base_revision>><><> + disabled-signatures: "rules.providers.semgrep.security.javascript.lang.security.detect-insecure-websocket" diff --git a/.config/mise/config.ci-mac.toml b/.config/mise/config.ci-mac.toml new file mode 100644 index 0000000000..bc6d503461 --- /dev/null +++ b/.config/mise/config.ci-mac.toml @@ -0,0 +1,5 @@ +[tools] +# renovate-automation: rustc version +rust = { version = "1.89.0", targets = "x86_64-apple-darwin,aarch64-apple-darwin", profile = "default", components = "llvm-tools" } +"cargo:cargo-llvm-cov" = "0.6.16" +"ubi:codecov/codecov-cli" = "10.4.0" diff --git a/.config/mise/config.ci.toml b/.config/mise/config.ci.toml new file mode 100644 index 0000000000..6a0589aa9e --- /dev/null +++ b/.config/mise/config.ci.toml @@ -0,0 +1,4 @@ +[tools] +# Temporarily disable cargo-fuzz https://github.com/apollographql/router/pull/7488 +# "cargo:cargo-fuzz" = "0.12.0" +kubeconform = "0.6.7" diff --git a/.config/mise/config.toml b/.config/mise/config.toml new file mode 100644 index 0000000000..0cc0e86807 --- /dev/null +++ b/.config/mise/config.toml @@ -0,0 +1,19 @@ +[tools] +# renovate-automation: rustc version +rust = "1.89.0" +"aqua:cargo-bins/cargo-binstall" = "1.14.4" +"cargo:cargo-nextest" = "0.9.70" +"cargo:cargo-deny" = "0.18.2" +"cargo:cargo-edit" = "0.13.0" +"cargo:cargo-about" = "0.7.0" +"cargo:cargo-insta" = "1.38.0" +"cargo:htmlq" = "0.4.0" +"cargo:cargo-watch" = "8.5.3" +"cargo:cargo-machete" = "0.9.0" +"cargo:typos-cli" = "1.31.1" +protoc = "32.0" +gh = "2.72.0" +helm = "3.18.6" +helm-docs = "1.14.2" +yq = "4.47.1" +jq = "1.7.1" diff --git a/.config/mise/config.windows.toml b/.config/mise/config.windows.toml new file mode 100644 index 0000000000..750983275c --- /dev/null +++ b/.config/mise/config.windows.toml @@ -0,0 +1,2 @@ +[tools] +cmake = "3.31.1" diff --git a/.config/nextest.toml b/.config/nextest.toml index f2c4ef3618..d14f9f6934 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -12,43 +12,8 @@ # module, as they have a high failure rate, in general. retries = 2 filter = ''' - ( binary_id(=apollo-router) & test(=axum_factory::axum_http_server_factory::tests::request_cancel_log) ) -or ( binary_id(=apollo-router) & test(=axum_factory::axum_http_server_factory::tests::request_cancel_no_log) ) -or ( binary_id(=apollo-router) & test(=axum_factory::tests::cors_origin_default) ) -or ( binary_id(=apollo-router) & test(=axum_factory::tests::cors_origin_list) ) -or ( binary_id(=apollo-router) & test(=axum_factory::tests::cors_origin_regex) ) -or ( binary_id(=apollo-router) & test(=axum_factory::tests::it_answers_to_custom_endpoint) ) -or ( binary_id(=apollo-router) & test(=axum_factory::tests::it_compress_response_body) ) -or ( binary_id(=apollo-router) & test(=axum_factory::tests::response) ) -or ( binary_id(=apollo-router) & test(=axum_factory::tests::response_with_custom_endpoint) ) -or ( binary_id(=apollo-router) & test(=axum_factory::tests::response_with_custom_endpoint_wildcard) ) -or ( binary_id(=apollo-router) & test(=axum_factory::tests::response_with_custom_prefix_endpoint) ) -or ( binary_id(=apollo-router) & test(=axum_factory::tests::response_with_root_wildcard) ) -or ( binary_id(=apollo-router) & test(=layers::map_first_graphql_response::tests::test_map_first_graphql_response) ) -or ( binary_id(=apollo-router) & test(=notification::tests::it_test_ttl) ) -or ( binary_id(=apollo-router) & test(=plugins::authentication::subgraph::test::test_credentials_provider_refresh_on_stale) ) -or ( binary_id(=apollo-router) & test(=plugins::expose_query_plan::tests::it_expose_query_plan) ) -or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_does_not_redact_all_explicit_allow_account_explict_redact_for_product_query) ) -or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_does_not_redact_all_explicit_allow_review_explict_redact_for_product_query) ) -or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_does_not_redact_all_implicit_redact_product_explict_allow_for_product_query) ) -or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_does_redact_all_explicit_allow_account_explict_redact_for_account_query) ) -or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_does_redact_all_explicit_allow_product_explict_redact_for_product_query) ) -or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_redacts_all_subgraphs_implicit_redact) ) -or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_returns_valid_response) ) -or ( binary_id(=apollo-router) & test(=plugins::telemetry::config_new::instruments::tests::test_instruments) ) -or ( binary_id(=apollo-router) & test(=plugins::telemetry::metrics::apollo::test::apollo_metrics_enabled) ) -or ( binary_id(=apollo-router) & test(=plugins::telemetry::tests::it_test_prometheus_metrics) ) -or ( binary_id(=apollo-router) & test(=router::tests::basic_event_stream_test) ) -or ( binary_id(=apollo-router) & test(=router::tests::schema_update_test) ) -or ( binary_id(=apollo-router) & test(=services::subgraph_service::tests::test_subgraph_service_websocket_with_error) ) -or ( binary_id(=apollo-router) & test(=services::supergraph::tests::aliased_subgraph_data_rewrites_on_non_root_fetch) ) -or ( binary_id(=apollo-router) & test(=services::supergraph::tests::interface_object_typename_rewrites) ) -or ( binary_id(=apollo-router) & test(=services::supergraph::tests::only_query_interface_object_subgraph) ) -or ( binary_id(=apollo-router) & test(=uplink::license_stream::test::license_expander_claim_no_claim) ) -or ( binary_id(=apollo-router) & test(=uplink::license_stream::test::license_expander_claim_pause_claim) ) -or ( binary_id(=apollo-router) & test(=uplink::persisted_queries_manifest_stream::test::integration_test) ) -or ( binary_id(=apollo-router) & test(=uplink::schema_stream::test::integration_test) ) -or ( binary_id(=apollo-router-benchmarks) & test(=tests::test) ) + ( binary_id(=apollo-router-benchmarks) & test(=tests::test) ) +or ( binary_id(=apollo-router::apollo_otel_traces) & test(=connector_error) ) or ( binary_id(=apollo-router::apollo_otel_traces) & test(=non_defer) ) or ( binary_id(=apollo-router::apollo_otel_traces) & test(=test_batch_send_header) ) or ( binary_id(=apollo-router::apollo_otel_traces) & test(=test_batch_trace_id) ) @@ -68,8 +33,8 @@ or ( binary_id(=apollo-router::apollo_reports) & test(=test_client_version) ) or ( binary_id(=apollo-router::apollo_reports) & test(=test_condition_else) ) or ( binary_id(=apollo-router::apollo_reports) & test(=test_condition_if) ) or ( binary_id(=apollo-router::apollo_reports) & test(=test_demand_control_stats) ) -or ( binary_id(=apollo-router::apollo_reports) & test(=test_demand_control_trace) ) or ( binary_id(=apollo-router::apollo_reports) & test(=test_demand_control_trace_batched) ) +or ( binary_id(=apollo-router::apollo_reports) & test(=test_demand_control_trace) ) or ( binary_id(=apollo-router::apollo_reports) & test(=test_new_field_stats) ) or ( binary_id(=apollo-router::apollo_reports) & test(=test_send_header) ) or ( binary_id(=apollo-router::apollo_reports) & test(=test_send_variable_value) ) @@ -79,8 +44,11 @@ or ( binary_id(=apollo-router::integration_tests) & test(=api_schema_hides_field or ( binary_id(=apollo-router::integration_tests) & test(=automated_persisted_queries) ) or ( binary_id(=apollo-router::integration_tests) & test(=defer_default_variable) ) or ( binary_id(=apollo-router::integration_tests) & test(=defer_empty_primary_response) ) -or ( binary_id(=apollo-router::integration_tests) & test(=defer_path) ) or ( binary_id(=apollo-router::integration_tests) & test(=defer_path_in_array) ) +or ( binary_id(=apollo-router::integration_tests) & test(=defer_path_with_disabled_config) ) +or ( binary_id(=apollo-router::integration_tests) & test(=defer_path) ) +or ( binary_id(=apollo-router::integration_tests) & test(=defer_query_without_accept) ) +or ( binary_id(=apollo-router::integration_tests) & test(=empty_posts_should_not_work) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::batching::it_batches_with_errors_in_multi_graph) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::batching::it_batches_with_errors_in_single_graph) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::batching::it_handles_cancelled_by_coprocessor) ) @@ -92,6 +60,8 @@ or ( binary_id(=apollo-router::integration_tests) & test(=integration::batching: or ( binary_id(=apollo-router::integration_tests) & test(=integration::batching::it_handles_single_request_cancelled_by_rhai) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::batching::it_supports_multi_subgraph_batching) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::batching::it_supports_single_subgraph_batching) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::connectors::authentication::incompatible_warnings_with_overrides) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::coprocessor::test_coprocessor_response_handling) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::coprocessor::test_error_not_propagated_to_client) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_upload::it_fails_incompatible_query_order) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_upload::it_fails_invalid_file_order) ) @@ -102,49 +72,77 @@ or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_uplo or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_upload::it_fails_with_no_boundary_in_multipart) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_upload::it_supports_compression) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_upload::it_supports_nested_file) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_upload::it_uploads_file_to_subgraph) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_upload::it_uploads_to_multiple_subgraphs) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::introspection::integration) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::lifecycle::test_force_reload) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::lifecycle::test_graceful_shutdown) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::lifecycle::test_happy) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::lifecycle::test_plugin_ordering) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::lifecycle::test_reload_config_valid) ) -or ( binary_id(=apollo-router::integration_tests) & test(=integration::lifecycle::test_reload_config_with_broken_plugin) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::lifecycle::test_reload_config_with_broken_plugin_recovery) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::lifecycle::test_reload_config_with_broken_plugin) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::lifecycle::test_shutdown_with_idle_connection) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::operation_limits::test_request_bytes_limit_with_coprocessor) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::operation_limits::test_request_bytes_limit) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::query_planner::context_with_new_qp) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::query_planner::max_evaluated_plans::reports_evaluated_plans) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::query_planner::overloaded_compute_job_pool) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::query_planner::progressive_override_with_legacy_qp_reload_to_both_best_effort_keep_previous_config) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::query_planner::valid_schema_with_new_qp_change_to_broken_schema_keeps_old_config) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::redis::apq) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::redis::connection_failure_blocks_startup) ) -or ( binary_id(=apollo-router::integration_tests) & test(=integration::redis::entity_cache) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::redis::entity_cache_authorization) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::redis::entity_cache_basic) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::redis::entity_cache) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::redis::query_planner_redis_update_defer) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::redis::query_planner_redis_update_introspection) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::redis::query_planner_redis_update_query_fragments) ) -or ( binary_id(=apollo-router::integration_tests) & test(=integration::redis::query_planner_redis_update_reuse_query_fragments) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::redis::query_planner_redis_update_type_conditional_fetching) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::redis::test::connection_failure_blocks_startup) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::rhai::test_rhai_hot_reload_works) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::subgraph_response::test_invalid_error_locations_contains_negative_one_location) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::subgraph_response::test_valid_extensions_service_for_subgraph_error) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::subgraph_response::test_valid_extensions_service_is_preserved_for_subgraph_error) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::datadog::test_basic) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::datadog::test_priority_sampling_no_parent_propagated) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::datadog::test_resource_mapping_default) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::datadog::test_resource_mapping_override) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::datadog::test_resources) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::datadog::test_span_metrics) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::datadog::test_with_parent_span) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::events::test_custom_events) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::events::test_events_with_request_header_condition) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::jaeger::test_decimal_trace_id) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::jaeger::test_default_operation) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::jaeger::test_local_root) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::jaeger::test_reload) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::jaeger::test_remote_root) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::jaeger::test_selected_operation) ) -or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::logging::test_json) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::jaeger::test_span_attributes) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::logging::test_json_promote_span_attributes) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::logging::test_json_sampler_off) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::logging::test_json_uuid_format) ) -or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::logging::test_text) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::logging::test_json) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::logging::test_text_sampler_off) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::logging::test_text) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::metrics::test_bad_queries) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::metrics::test_graphql_metrics) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::metrics::test_metrics_bad_query) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::metrics::test_metrics_reloading) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::metrics::test_subgraph_auth_metrics) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::otlp::test_basic) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::otlp::test_plugin_overridden_client_name_is_included_in_telemetry) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::otlp::test_priority_sampling_parent_sampler_very_small_no_parent_no_agent_sampling) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::otlp::test_priority_sampling_propagated) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::otlp::test_trace_error) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::otlp::test_untraced_request_no_sample_datadog_agent) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::otlp::test_untraced_request_sample_datadog_agent_unsampled) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::propagation::test_trace_id_via_header) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::telemetry::zipkin::test_basic) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::traffic_shaping::test_router_rate_limit) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::traffic_shaping::test_router_timeout_operation_name_in_tracing) ) +or ( binary_id(=apollo-router::integration_tests) & test(=integration::traffic_shaping::test_router_timeout) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::traffic_shaping::test_subgraph_rate_limit) ) or ( binary_id(=apollo-router::integration_tests) & test(=integration::traffic_shaping::test_subgraph_timeout) ) or ( binary_id(=apollo-router::integration_tests) & test(=normal_query_with_defer_accept_header) ) @@ -152,27 +150,84 @@ or ( binary_id(=apollo-router::integration_tests) & test(=persisted_queries) ) or ( binary_id(=apollo-router::integration_tests) & test(=queries_should_work_over_get) ) or ( binary_id(=apollo-router::integration_tests) & test(=queries_should_work_over_post) ) or ( binary_id(=apollo-router::integration_tests) & test(=queries_should_work_with_compression) ) +or ( binary_id(=apollo-router::integration_tests) & test(=query_just_at_recursion_limit) ) +or ( binary_id(=apollo-router::integration_tests) & test(=query_just_at_token_limit) ) or ( binary_id(=apollo-router::integration_tests) & test(=query_just_under_recursion_limit) ) or ( binary_id(=apollo-router::integration_tests) & test(=query_just_under_token_limit) ) +or ( binary_id(=apollo-router::samples) & test(=/basic/interface-object) ) or ( binary_id(=apollo-router::samples) & test(=/basic/query1) ) or ( binary_id(=apollo-router::samples) & test(=/basic/query2) ) -or ( binary_id(=apollo-router::samples) & test(=/enterprise/entity-cache/invalidation) ) -or ( binary_id(=apollo-router::samples) & test(=/enterprise/entity-cache/invalidation-subgraph) ) +or ( binary_id(=apollo-router::samples) & test(=/core/defer) ) +or ( binary_id(=apollo-router::samples) & test(=/enterprise/connectors-debugging) ) +or ( binary_id(=apollo-router::samples) & test(=/enterprise/entity-cache/defer) ) or ( binary_id(=apollo-router::samples) & test(=/enterprise/entity-cache/invalidation-subgraph-name) ) or ( binary_id(=apollo-router::samples) & test(=/enterprise/entity-cache/invalidation-subgraph-type) ) +or ( binary_id(=apollo-router::samples) & test(=/enterprise/entity-cache/invalidation-subgraph) ) +or ( binary_id(=apollo-router::samples) & test(=/enterprise/entity-cache/invalidation) ) +or ( binary_id(=apollo-router::samples) & test(=/enterprise/entity-cache/private) ) +or ( binary_id(=apollo-router::samples) & test(=/enterprise/persisted-queries/basic) ) or ( binary_id(=apollo-router::samples) & test(=/enterprise/query-planning-redis) ) -or ( binary_id(=apollo-router::set_context) & test(=test_set_context) ) +or ( binary_id(=apollo-router::set_context) & test(=test_set_context_dependent_fetch_failure_rust_qp) ) or ( binary_id(=apollo-router::set_context) & test(=test_set_context_dependent_fetch_failure) ) -or ( binary_id(=apollo-router::set_context) & test(=test_set_context_list) ) or ( binary_id(=apollo-router::set_context) & test(=test_set_context_list_of_lists) ) +or ( binary_id(=apollo-router::set_context) & test(=test_set_context_list_rust_qp) ) +or ( binary_id(=apollo-router::set_context) & test(=test_set_context_list) ) +or ( binary_id(=apollo-router::set_context) & test(=test_set_context_no_typenames_rust_qp) ) or ( binary_id(=apollo-router::set_context) & test(=test_set_context_no_typenames) ) or ( binary_id(=apollo-router::set_context) & test(=test_set_context_type_mismatch) ) or ( binary_id(=apollo-router::set_context) & test(=test_set_context_union) ) or ( binary_id(=apollo-router::set_context) & test(=test_set_context_unrelated_fetch_failure) ) or ( binary_id(=apollo-router::set_context) & test(=test_set_context_with_null) ) +or ( binary_id(=apollo-router::set_context) & test(=test_set_context) ) or ( binary_id(=apollo-router::type_conditions) & test(=test_type_conditions_disabled) ) -or ( binary_id(=apollo-router::type_conditions) & test(=test_type_conditions_enabled) ) or ( binary_id(=apollo-router::type_conditions) & test(=test_type_conditions_enabled_generate_query_fragments) ) +or ( binary_id(=apollo-router::type_conditions) & test(=test_type_conditions_enabled_list_of_list) ) +or ( binary_id(=apollo-router::type_conditions) & test(=test_type_conditions_enabled) ) +or ( binary_id(=apollo-router) & test(=axum_factory::axum_http_server_factory::tests::request_cancel_log) ) +or ( binary_id(=apollo-router) & test(=axum_factory::axum_http_server_factory::tests::request_cancel_no_log) ) +or ( binary_id(=apollo-router) & test(=axum_factory::tests::cors_origin_default) ) +or ( binary_id(=apollo-router) & test(=axum_factory::tests::cors_origin_list) ) +or ( binary_id(=apollo-router) & test(=axum_factory::tests::cors_origin_regex) ) +or ( binary_id(=apollo-router) & test(=axum_factory::tests::it_answers_to_custom_endpoint) ) +or ( binary_id(=apollo-router) & test(=axum_factory::tests::it_compress_response_body) ) +or ( binary_id(=apollo-router) & test(=axum_factory::tests::response_with_custom_endpoint_wildcard) ) +or ( binary_id(=apollo-router) & test(=axum_factory::tests::response_with_custom_endpoint) ) +or ( binary_id(=apollo-router) & test(=axum_factory::tests::response_with_custom_prefix_endpoint) ) +or ( binary_id(=apollo-router) & test(=axum_factory::tests::response_with_root_wildcard) ) +or ( binary_id(=apollo-router) & test(=axum_factory::tests::response) ) +or ( binary_id(=apollo-router) & test(=layers::map_first_graphql_response::tests::test_map_first_graphql_response) ) +or ( binary_id(=apollo-router) & test(=notification::tests::it_test_ttl) ) +or ( binary_id(=apollo-router) & test(=plugins::authentication::subgraph::test::test_credentials_provider_refresh_on_stale) ) +or ( binary_id(=apollo-router) & test(=plugins::connectors::tests::quickstart::query_4) ) +or ( binary_id(=apollo-router) & test(=plugins::connectors::tests::test_interface_object) ) +or ( binary_id(=apollo-router) & test(=plugins::expose_query_plan::tests::it_expose_query_plan) ) +or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_does_not_redact_all_explicit_allow_account_explict_redact_for_product_query) ) +or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_does_not_redact_all_explicit_allow_review_explict_redact_for_product_query) ) +or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_does_not_redact_all_implicit_redact_product_explict_allow_for_product_query) ) +or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_does_redact_all_explicit_allow_account_explict_redact_for_account_query) ) +or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_does_redact_all_explicit_allow_product_explict_redact_for_product_query) ) +or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_redacts_all_subgraphs_implicit_redact) ) +or ( binary_id(=apollo-router) & test(=plugins::include_subgraph_errors::test::it_returns_valid_response) ) +or ( binary_id(=apollo-router) & test(=plugins::telemetry::config_new::instruments::tests::test_instruments) ) +or ( binary_id(=apollo-router) & test(=plugins::telemetry::metrics::apollo::test::apollo_metrics_enabled) ) +or ( binary_id(=apollo-router) & test(=plugins::telemetry::metrics::apollo::test::apollo_metrics_for_subscription_error) ) +or ( binary_id(=apollo-router) & test(=plugins::telemetry::metrics::apollo::test::apollo_metrics_multiple_operations) ) +or ( binary_id(=apollo-router) & test(=plugins::telemetry::metrics::apollo::test::apollo_metrics_parse_failure) ) +or ( binary_id(=apollo-router) & test(=plugins::telemetry::metrics::apollo::test::apollo_metrics_unknown_operation) ) +or ( binary_id(=apollo-router) & test(=plugins::telemetry::metrics::apollo::test::apollo_metrics_validation_failure) ) +or ( binary_id(=apollo-router) & test(=plugins::telemetry::tests::it_test_prometheus_metrics) ) +or ( binary_id(=apollo-router) & test(=protocols::websocket::tests::test_ws_connection_new_proto_with_heartbeat) ) +or ( binary_id(=apollo-router) & test(=router::tests::basic_event_stream_test) ) +or ( binary_id(=apollo-router) & test(=router::tests::schema_update_test) ) +or ( binary_id(=apollo-router) & test(=services::layers::persisted_queries::tests::pq_layer_freeform_graphql_with_safelist_log_unknown_true) ) +or ( binary_id(=apollo-router) & test(=services::subgraph_service::tests::test_subgraph_service_websocket_with_error) ) +or ( binary_id(=apollo-router) & test(=services::supergraph::tests::aliased_subgraph_data_rewrites_on_non_root_fetch) ) +or ( binary_id(=apollo-router) & test(=services::supergraph::tests::interface_object_typename_rewrites) ) +or ( binary_id(=apollo-router) & test(=services::supergraph::tests::only_query_interface_object_subgraph) ) +or ( binary_id(=apollo-router) & test(=uplink::license_stream::test::license_expander_claim_no_claim) ) +or ( binary_id(=apollo-router) & test(=uplink::license_stream::test::license_expander_claim_pause_claim) ) +or ( binary_id(=apollo-router) & test(=uplink::persisted_queries_manifest_stream::test::integration_test) ) +or ( binary_id(=apollo-router) & test(=uplink::schema_stream::test::integration_test) ) ''' [profile.ci] diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 353754e463..96a79b205d 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -2,3 +2,12 @@ # Reformat with imports_granularity = Item and group_imports = StdExternalCrate af3209126ffee9b825b694ee6913745873e8115b + +# Move apollo-federation to 2024 edition (including style edition) +3f62ebe8ab0adfb819a24af8bc9f2861f1ad9318 + +# Reformat apollo-router with style edition 2024 +2e75b7fa2ef516ccfffdf75b980f85c0465ee726 + +# Move apollo-federation-cli to 2024 edition (including style edition) +fbe79aa7ef03ad172c06481edc886281f0cd4c11 diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg deleted file mode 100755 index 125af77a90..0000000000 --- a/.githooks/prepare-commit-msg +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh - -firstLine=$(head -n1 $1) - -if [ -z "$firstLine" ] ;then - commitTemplate=$(cat `git rev-parse --git-dir`/../.gitmessage) - echo -e "$commitTemplate\n $(cat $1)" > $1 -fi diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6d9f87ef81..06e688cfbb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,9 +1,10 @@ /docs/ @apollographql/docs /.changesets/ @apollographql/docs -/apollo-federation/ @dariuszkuc @sachindshinde @goto-bus-stop @SimonSapin @lrlna @TylerBloom @duckki -/apollo-federation/src/sources/connect/json_selection @benjamn -/apollo-router/ @apollographql/polaris @apollographql/atlas -/apollo-router-benchmarks/ @apollographql/polaris @apollographql/atlas -/apollo-router-scaffold/ @apollographql/polaris @apollographql/atlas -/examples/ @apollographql/polaris @apollographql/atlas -/.github/CODEOWNERS @apollographql/polaris @apollographql/atlas +/apollo-federation/ @apollographql/fed-core @apollographql/rust-platform +/apollo-federation/src/connectors @apollographql/graph-dev +/apollo-router/ @apollographql/router-core +/apollo-router/src/plugins/connectors @apollographql/graph-dev +/apollo-router/src/plugins/fleet_detector.rs @apollographql/cloud-fleet +/apollo-router-benchmarks/ @apollographql/router-core @apollographql/fed-core +/examples/ @apollographql/router-core @apollographql/fed-core +/.github/CODEOWNERS @apollographql/router-core @apollographql/fed-core diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3de4040763..721316d62d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,21 +1,23 @@ -*Description here* - -Fixes #**issue_number** - + + --- **Checklist** Complete the checklist (and note appropriate exceptions) before the PR is marked ready-for-review. +- [ ] PR description explains the motivation for the change and relevant context for reviewing +- [ ] PR description links appropriate GitHub/Jira tickets (creating when necessary) +- [ ] Changeset is included for user-facing changes - [ ] Changes are compatible[^1] - [ ] Documentation[^2] completed - [ ] Performance impact assessed and acceptable -- Tests added and passing[^3] - - [ ] Unit Tests - - [ ] Integration Tests - - [ ] Manual Tests +- [ ] Metrics and logs are added[^3] and documented +- Tests added and passing[^4] + - [ ] Unit tests + - [ ] Integration tests + - [ ] Manual tests, as necessary **Exceptions** @@ -25,4 +27,5 @@ Complete the checklist (and note appropriate exceptions) before the PR is marked [^1]: It may be appropriate to bring upcoming changes to the attention of other (impacted) groups. Please endeavour to do this before seeking PR approval. The mechanism for doing this will vary considerably, so use your judgement as to how and when to do this. [^2]: Configuration is an important part of many changes. Where applicable please try to document configuration examples. -[^3]: Tick whichever testing boxes are applicable. If you are adding Manual Tests, please document the manual testing (extensively) in the Exceptions. +[^3]: A lot of (if not most) features benefit from built-in observability and `debug`-level logs. Please read [this guidance](https://github.com/apollographql/router/blob/dev/dev-docs/metrics.md#adding-new-metrics) on metrics best-practices. +[^4]: Tick whichever testing boxes are applicable. If you are adding Manual Tests, please document the manual testing (extensively) in the Exceptions. diff --git a/.github/workflows/update_apollo_protobuf.yaml b/.github/workflows/update_apollo_protobuf.yaml index cdb6aa84b4..12293ef0de 100644 --- a/.github/workflows/update_apollo_protobuf.yaml +++ b/.github/workflows/update_apollo_protobuf.yaml @@ -9,13 +9,13 @@ jobs: Update-Protobuf-Schema: runs-on: ubuntu-latest steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - name: Make changes to pull request run: | curl -f https://usage-reporting.api.apollographql.com/proto/reports.proto > ./apollo-router/src/plugins/telemetry/proto/reports.proto - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@v7 with: commit-message: Update Apollo Protobuf schema committer: GitHub @@ -29,3 +29,7 @@ jobs: title: 'chore: Update Apollo Protobuf' body: | This updates the copy of `reports.proto` which this repository relies on with the latest copy fetched via our public endpoint. + + > [!IMPORTANT] + > This PR will be continuously force-pushed with the new `reports.proto` copy. If the update requires code changes, apply them + > in a separate PR. This PR will be automatically closed if it is no longer necessary. diff --git a/.github/workflows/update_uplink_schema.yml b/.github/workflows/update_uplink_schema.yml index dd89b1ecdb..bd08a62f24 100644 --- a/.github/workflows/update_uplink_schema.yml +++ b/.github/workflows/update_uplink_schema.yml @@ -9,7 +9,7 @@ jobs: Update-Uplink-Schema: runs-on: ubuntu-latest steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - name: Install Rover run: | curl -sSL https://rover.apollo.dev/nix/v0.14.1 | sh @@ -19,7 +19,7 @@ jobs: rover graph introspect https://uplink.api.apollographql.com/ | perl -pe 'chomp if eof' > ./apollo-router/src/uplink/uplink.graphql - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@v7 with: commit-message: Update Uplink schema committer: GitHub @@ -31,3 +31,7 @@ jobs: title: 'chore: Update Uplink schema' body: | This updates the copy of `uplink.graphql` which this repository relies on with the latest copy fetched via `rover graph introspect`. + + > [!IMPORTANT] + > This PR will be continuously force-pushed with the new `uplink.graphql` copy. If the update requires code changes, apply them + > in a separate PR. This PR will be automatically closed if it is no longer necessary. diff --git a/.gitignore b/.gitignore index f2ff697ba0..0624c9b1de 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,17 @@ # Previous git submodules dockerfiles/federation-demo dockerfiles/federation2-demo + +# macOS +.DS_Store + +# dhat +dhat-heap.json + +# env file +.env + +# generated fuzz/ files +fuzz/crash-* +fuzz/slow-unit-* +fuzz/timeout-* \ No newline at end of file diff --git a/.gitleaks.toml b/.gitleaks.toml index 98ec4f9428..d12f011844 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -6,6 +6,8 @@ "f12bcddc663aa4c4d90218a1bb718fe74e0e7be3", "e1e6f93341aea383da2ec6b36a9bfcf7e63a111e", "e5590027506337381887dadef9baadd063e05830", + "b4fdf65c5eaca08057886e5b30553201302b9764", + "c8ae92e47de4e1bb3ae56e9beed27fbc2a1e136a", # https://github.com/apollographql/router/blob/d826844c8cf433f78938059f02feecc108468e49/licenses.html#L8558 # https://github.com/apollographql/router-private/blob/d826844c8cf433f78938059f02feecc108468e49/licenses.html#L8558 diff --git a/.gitmessage b/.gitmessage deleted file mode 100644 index ef3dccc6df..0000000000 --- a/.gitmessage +++ /dev/null @@ -1,7 +0,0 @@ -# Title: Summary, imperative, start upper case, don't end with a period -# No more than 50 chars. #### 50 chars is here: # - -# Replace 'xxx' with the github issue ID. -resolves #xxx - -# Body: Explain *what* and *why* (not *how*). diff --git a/.ignore b/.ignore deleted file mode 100644 index 0be3898307..0000000000 --- a/.ignore +++ /dev/null @@ -1 +0,0 @@ -router_errors.txt diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 0000000000..bdf9dcf341 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,18 @@ +pull_request_rules: + - name: backport 1.x + description: Add a label to backport to 1.x + conditions: + - label = backport-1.x + actions: + backport: + branches: + - 1.x + assignees: + - "{{ author }}" + bot_account: "{{ author }}" +defaults: + actions: + backport: + assignees: + - "{{ author }}" + bot_account: "{{ author }}" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4866bf62..5e15194561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14750 +1,1637 @@ # Changelog -All notable changes to Router will be documented in this file. - This project adheres to [Semantic Versioning v2.0.0](https://semver.org/spec/v2.0.0.html). -# [1.57.1] - 2024-10-31 - -## 🐛 Fixes - -### Progressive override: fix query planner cache warmup ([PR #6108](https://github.com/apollographql/router/pull/6108)) - -This fixes an issue in progressive override where the override labels were not transmitted to the query planner during cache warmup. Queries were correctly using the overridden fields at first, but after an update, reverted to non overridden fields, and could not recover. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/6108 - - - -# [1.57.0] - 2024-10-22 - -> [!IMPORTANT] -> If you have enabled [Distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching), updates to the query planner in this release will result in query plan caches being re-generated rather than re-used. On account of this, you should anticipate additional cache regeneration cost when updating between these versions while the new query plans come into service. +# [2.6.0] - 2025-08-25 ## 🚀 Features -### Remove legacy schema introspection ([PR #6139](https://github.com/apollographql/router/pull/6139)) - -Schema introspection in the router now runs natively without JavaScript. We have high confidence that the new native implementation returns responses that match the previous Javascript implementation, based on differential testing: fuzzing arbitrary queries against a large schema, and testing a corpus of customer schemas against a comprehensive query. - -Changes to the router's YAML configuration: - -* The `experimental_introspection_mode` key has been removed, with the `new` mode as the only behavior in this release. -* The `supergraph.query_planning.legacy_introspection_caching` key is removed, with the behavior in this release now similar to what was `false`: introspection responses are not part of the query plan cache but instead in a separate, small in-memory—only cache. - -When using the above deprecated configuration options, the router's automatic configuration migration will ensure that existing configuration continue to work until the next major version of the router. To simplify major upgrades, we recommend reviewing incremental updates to your YAML configuration by comparing the output of `./router config upgrade --config path/to/config.yaml` with your existing configuration. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/6139 +### [Subgraph Insights] Experimental Apollo Subgraph Fetch Histogram ([PR #8013](https://github.com/apollographql/router/pull/8013), [PR #8045](https://github.com/apollographql/router/pull/8045)) -### Support new `request_context` selector for telemetry ([PR #6160](https://github.com/apollographql/router/pull/6160)) +This change adds a new, experimental histogram to capture subgraph fetch duration for GraphOS. This will +eventually be used to power subgraph-level insights in Apollo Studio. -The router supports a new `request_context` selector for telemetry that enables access to the supergraph schema ID. - -You can configure the context to access the supergraph schema ID at the router service level: +This can be toggled on using a new boolean config flag: ```yaml telemetry: - instrumentation: - events: - router: - my.request_event: - message: "my request event message" - level: info - on: request - attributes: - schema.id: - request_context: "apollo::supergraph_schema_id" # The key containing the supergraph schema id -``` - -You can use the selector in any service at any stage. While this example applies to `events` attributes, the selector can also be used on spans and instruments. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/6160 - -### Support reading and setting `port` on request URIs using Rhai ([Issue #5437](https://github.com/apollographql/router/issues/5437)) - -Custom Rhai scripts in the router now support the `request.uri.port` and `request.subgraph.uri.port` functions for reading and setting URI ports. These functions enable you to update the full URI for subgraph fetches. For example: - -```rust -fn subgraph_service(service, subgraph){ - service.map_request(|request|{ - log_info(``); - if request.subgraph.uri.port == {} { - log_info("Port is not explicitly set"); - } - request.subgraph.uri.host = "api.apollographql.com"; - request.subgraph.uri.path = "/api/graphql"; - request.subgraph.uri.port = 1234; - log_info(``); - }); -} -``` - -By [@lleadbet](https://github.com/lleadbet) in https://github.com/apollographql/router/pull/5439 - -## 🐛 Fixes - -### Fix various edge cases for `__typename` field ([PR #6009](https://github.com/apollographql/router/pull/6009)) - -The router now correctly handles the `__typename` field used on operation root types, even when the subgraph's root type has a name that differs from the supergraph's root type. - -For example, given a query like this: - -```graphql -{ - ...RootFragment -} - -fragment RootFragment on Query { - __typename - me { - name - } -} + apollo: + experimental_subgraph_metrics: true ``` -Even if the subgraph's root type returns a `__typename` that differs from `Query`, the router will still use `Query` as the value of the `__typename` field. - -This change also includes fixes for other edge cases related to the handling of `__typename` fields. For a detailed technical description of the edge cases that were fixed, please see [this description](https://github.com/apollographql/router/pull/6009#issue-2529717207). - -By [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/6009 - -### Support `uri` and `method` properties on router "request" objects in Rhai ([PR #6147](https://github.com/apollographql/router/pull/6147)) +The new instrument is only sent to GraphOS and is not available in 3rd-party OTel export targets. It is not currently +customizable. Users requiring a customizable alternative can use the existing `http.client.request.duration` +instrument, which measures the same value. -The router now supports accessing `request.uri` and `request.method` properties from custom Rhai scripts. Previously, when trying to access `request.uri` and `request.method` on a router request in Rhai, the router would return error messages stating the properties were undefined. +By [@rregitsky](https://github.com/rregitsky) in https://github.com/apollographql/router/pull/8013 and https://github.com/apollographql/router/pull/8045 -An example Rhai script using these properties: +### Redis cache metrics ([PR #7920](https://github.com/apollographql/router/pull/7920)) -```rhai -fn router_service(service) { - let router_request_callback = Fn("router_request_callback"); - service.map_request(router_request_callback); -} - -fn router_request_callback (request) { - log_info(`Router Request... Host: , Path: `); -} -``` +The router now provides Redis cache monitoring with new metrics that help track performance, errors, and resource usage. -By [@andrewmcgivery](https://github.com/andrewmcgivery) in https://github.com/apollographql/router/pull/6114 +Connection and performance metrics: + - `apollo.router.cache.redis.connections`: Number of active Redis connections + - `apollo.router.cache.redis.command_queue_length`: Commands waiting to be sent to Redis, indicates if Redis is keeping up with demand + - `apollo.router.cache.redis.commands_executed`: Total number of Redis commands executed + - `apollo.router.cache.redis.redelivery_count`: Commands retried due to connection issues + - `apollo.router.cache.redis.errors`: Redis errors by type, to help diagnose authentication, network, and configuration problems -### Cost calculation for subgraph requests with named fragments ([PR #6162](https://github.com/apollographql/router/issues/6162)) +**Experimental** performance metrics: + - `experimental.apollo.router.cache.redis.network_latency_avg`: Average network latency to Redis + - `experimental.apollo.router.cache.redis.latency_avg`: Average Redis command execution time + - `experimental.apollo.router.cache.redis.request_size_avg`: Average request payload size + - `experimental.apollo.router.cache.redis.response_size_avg`: Average response payload size -In some cases where subgraph GraphQL operations contain named fragments and abstract types, demand control used the wrong type for cost calculation, and could reject valid operations. Now, the correct type is used. +> [!NOTE] +> The experimental metrics may change in future versions as we improve the underlying Redis client integration. -This fixes errors of the form: +You can configure how often metrics are collected using the `metrics_interval` setting: +```yaml +supergraph: + query_planning: + cache: + redis: + urls: ["redis://localhost:6379"] + ttl: "60s" + metrics_interval: "1s" # Collect metrics every second (default: 1s) ``` -Attempted to look up a field on type MyInterface, but the field does not exist -``` - -By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/6162 -### Federation v2.9.3 ([PR #6161](https://github.com/apollographql/router/pull/6161)) +By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/7920 -This release updates to Federation v2.9.3, with query planner fixes: +### Granular license enforcement ([PR #7917](https://github.com/apollographql/router/pull/7917)) -- Fixes a query planning bug where operation variables for a subgraph query wouldn't match what's used in that query. -- Fixes a query planning bug where directives applied to `__typename` may be omitted in the subgraph query. -- Fixes a query planning inefficiency where some redundant subgraph queries were not removed. -- Fixes a query planning inefficiency where some redundant inline fragments in `@key`/`@requires` selection sets were not optimized away. -- Fixes a query planning inefficiency where unnecessary subgraph jumps were being added when using `@context`/`@fromContext`. +The router license functionality now allows granular specification of features enabled to support current and future pricing plans. -By [@sachindshinde](https://github.com/sachindshinde) in https://github.com/apollographql/router/pull/6161 +By [@DMallare](https://github.com/DMallare) in https://github.com/apollographql/router/pull/7917 -# [1.56.0] - 2024-10-01 +### Additional Connector Custom Instrument Selectors ([PR #8045](https://github.com/apollographql/router/pull/8045)) -> [!IMPORTANT] -> If you have enabled [Distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching), this release changes the hashing algorithm used for the cache keys. On account of this, you should anticipate additional cache regeneration cost when updating between these versions while the new hashing algorithm comes into service. - -## 🚀 Features - -## Native query planner is now in public preview +This adds new [custom instrument selectors](https://www.apollographql.com/docs/graphos/routing/observability/telemetry/instrumentation/selectors#connector) for Connectors and enhances some existing selectors. The new selectors are: + - `supergraph_operation_name` + - The supergraph's operation name + - `supergraph_operation_kind` + - The supergraph's operation type (e.g. `query`, `mutation`, `subscription`) + - `request_context` + - Takes the value of the given key on the request context + - `connector_on_response_error` + - Returns true when the response does not meet the `is_successful` condition. Or, if that condition is not set, + returns true when the response has a non-200 status code -The native query planner is now in public preview. You can configure the `experimental_query_planner_mode` option in the router configuration YAML to change the mode of the native query planner. The following modes are available: +These selectors were modified to add additional functionality: + - `connector_request_mapping_problems` + - Adds a new `boolean` variant that will return `true` when a mapping problem exists on the request + - `connector_response_mapping_problems` + - Adds a new `boolean` variant that will return `true` when a mapping problem exists on the response -- `new`: Enable _only_ the new Rust-native query planner in the hot-path of query execution. -- `legacy`: Enable _only_ the legacy JavaScript query planner in the hot-path of query execution. -- `both_best_effort`: Enables _both_ the new and legacy query planners. They are configured in a comparison-based mode of operation with the legacy planner in the hot-path and the and the new planner in the cold-path. Comparisons are made between the two plans on a sampled basis and metrics are available to analyze the differences in aggregate. +By [@rregitsky](https://github.com/rregitsky) in https://github.com/apollographql/router/pull/8045 -### Support loading Apollo key from file ([PR #5917](https://github.com/apollographql/router/pull/5917)) +### Enable jemalloc on MacOS ([PR #8046](https://github.com/apollographql/router/pull/8046)) -You can now specific the location to a file containing the Apollo key that's used by Apollo Uplink and usage reporting. The router now supports both the `--apollo-key-path` CLI argument and the `APOLLO_KEY_PATH` environment variable for passing the file containing your Apollo key. - -Previously, the router supported only the `APOLLO_KEY` environment variable to provide the key. The new CLI argument and environment variable help users who prefer not to pass sensitive keys through environment variables. - -Note: This feature is unavailable for Windows. - -By [@lleadbet](https://github.com/lleadbet) in https://github.com/apollographql/router/pull/5917 +This PR enables the jemalloc allocator on MacOS by default, making it easier to do memory profiling. Previously, this was only done for Linux. +By [@Velfi](https://github.com/Velfi) in https://github.com/apollographql/router/pull/8046 ## 🐛 Fixes -### Prevent sending internal `apollo_private.*` attributes to Jaeger collector ([PR #6033](https://github.com/apollographql/router/pull/6033)) +### Entity caching: fix inconsistency in cache-control header handling ([PR #7987](https://github.com/apollographql/router/pull/7987)) -When using the router's Jaeger collector to send traces, you will no longer receive span attributes with the `apollo_private.` prefix. Those attributes were incorrectly sent, as that prefix is reserved for internal attributes. +When the [Subgraph Entity Caching] feature is in use, it determines the `Cache-Control` HTTP response header sent to supergraph clients based on those received from subgraph servers. +In this process, Apollo Router only emits the `max-age` [directive] and not `s-maxage`. +This PR fixes a bug where, for a query that involved a single subgraph fetch that was not already cached, the subgraph response’s `Cache-Control` header would be forwarded as-is. +Instead, it now goes through the same algorithm as other cases. -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/6033 +[Subgraph Entity Caching]: https://www.apollographql.com/docs/graphos/routing/performance/caching/entity +[directive]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control#response_directives -### Fix displaying custom event attributes on subscription events ([PR #6033](https://github.com/apollographql/router/pull/6033)) +By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/7987 -The router now properly displays custom event attributes that are set with selectors at the supergraph level. +### Query planning errors with progressive override on interface implementations ([PR #7929](https://github.com/apollographql/router/pull/7929)) -An example configuration: +The router now correctly generates query plans when using [progressive override](https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/entities/migrate-fields#incremental-migration-with-progressive-override) (`@override` with labels) on types that implement interfaces within the same subgraph. Previously, the Rust query planner would fail to generate plans for these scenarios with the error `"Was not able to find any options for {}: This shouldn't have happened."`, while the JavaScript planner handled them correctly. -```yaml title=router.yaml -telemetry: - instrumentation: - events: - supergraph: - supergraph.event: - message: supergraph event - on: event_response # on every supergraph event (like subscription event for example) - level: info - attributes: - test: - static: foo - response.data: - response_data: $ # Display all the response data payload - response.errors: - response_errors: $ # Display all the response errors payload -``` +This fix resolves planning failures when your schema uses: +- Interface implementations local to a subgraph +- Progressive override directives on both the implementing type and its fields +- Queries that traverse through the overridden interface implementations -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/6033 +The router will now successfully plan and execute queries that previously resulted in query planning errors. -### Update to Federation v2.9.2 ([PR #6069](https://github.com/apollographql/router/pull/6069)) +By [@TylerBloom](https://github.com/TylerBloom) in https://github.com/apollographql/router/pull/7929 -This release updates to Federation v2.9.2, with a small fix to the internal `__typename` optimization and a fix to prevent argument name collisions in the `@context`/`@fromContext` directives. +### Reliably distinguish GraphQL errors and transport errors in subscriptions ([PR #7901](https://github.com/apollographql/router/pull/7901)) -By [@dariuszkuc](https://github.com/dariuszkuc) in https://github.com/apollographql/router/pull/6069 +The [Multipart HTTP protocol for GraphQL Subscriptions](https://www.apollographql.com/docs/graphos/routing/operations/subscriptions/multipart-protocol) distinguishes between GraphQL-level errors and fatal transport-level errors. The router previously used a heuristic to determine if a given error was fatal or not, which could sometimes cause errors to be wrongly classified. For example, if a subgraph returned a GraphQL-level error for a subscription and then immediately ended the subscription, the router might propagate this as a fatal transport-level error. -## 📃 Configuration +This is now fixed. Fatal transport-level errors are tagged as such when they are constructed, so the router can reliably know how to serialize errors when sending them to the client. -### Add metrics for Rust vs. Deno configuration values ([PR #6056](https://github.com/apollographql/router/pull/6056)) +By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/7901 -To help track the migration from JavaScript (Deno) to native Rust implementations, the router now reports the values of the following configuration options to Apollo: +## 📚 Documentation -- `apollo.router.config.experimental_query_planner_mode` -- `apollo.router.config.experimental_introspection_mode` +### Update Documentation To Add DockerHub References -By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/6056 +Now that we have a DockerHub account we have published the Runtime Container to that account. +This fix simply adds a reference to that to the documentation +By [@jonathanrainer](https://github.com/jonathanrainer) in https://github.com/apollographql/router/pull/8054 -# [1.55.0] - 2024-09-24 -> [!IMPORTANT] -> If you have enabled [Distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching), this release changes the hashing algorithm used for the cache keys. On account of this, you should anticipate additional cache regeneration cost when updating between these versions while the new hashing algorithm comes into service. +# [2.5.0] - 2025-07-28 ## 🚀 Features -### Entity cache invalidation preview ([PR #5889](https://github.com/apollographql/router/pull/5889)) - -> ⚠️ This is a preview for an [Enterprise feature](https://www.apollographql.com/blog/platform/evaluating-apollo-router-understanding-free-and-open-vs-commercial-features/) of the Apollo Router. It requires an organization with a [GraphOS Enterprise plan](https://www.apollographql.com/pricing/). If your organization doesn't currently have an Enterprise plan, you can test out this functionality with a [free Enterprise trial](https://studio.apollographql.com/signup?type=enterprise-trial). -> -> As a preview feature, it's subject to our [Preview launch stage](https://www.apollographql.com/docs/resources/product-launch-stages/#preview) expectations and configuration and performance may change in future releases. - -As a follow up to the Entity cache preview that was published in the router 1.46.0 release, we're introducing a new feature that allows you to invalidate cached entries. - -This introduces two ways to invalidate cached entries: -- through an HTTP endpoint exposed by the router -- via GraphQL response `extensions` returned from subgraph requests - -The invalidation endpoint can be defined in the router's configuration, as follows: - -```yaml -preview_entity_cache: - enabled: true - - # global invalidation configuration - invalidation: - # address of the invalidation endpoint - # this should only be exposed to internal networks - listen: "127.0.0.1:3000" - path: "/invalidation" -``` - -Invalidation requests can target cached entries by: -- subgraph name -- subgraph name and type name -- subgraph name, type name and entity key - -You can learn more about invalidation in the [documentation](https://www.apollographql.com/docs/router/configuration/entity-caching#entity-cache-invalidation). - -By [@bnjjj](https://github.com/bnjjj),[@bryncooke](https://github.com/bryncooke), [@garypen](https://github.com/garypen), [@Geal](https://github.com/Geal), [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/5889 - -### Support aliasing standard attributes for telemetry ([Issue #5930](https://github.com/apollographql/router/issues/5930)) - -The router now supports creating aliases for standard attributes for telemetry. - -This fixes issues where standard attribute names collide with reserved attribute names. For example, the standard attribute name `entity.type` is a [reserved attribute](https://docs.newrelic.com/docs/new-relic-solutions/new-relic-one/core-concepts/what-entity-new-relic/#reserved-attributes) name for New Relic, so it won't work properly. Moreover `entity.type` is inconsistent with our other GraphQL attributes prefixed with `graphql.` +### Introduce per-origin CORS policies ([PR #7853](https://github.com/apollographql/router/pull/7853)) -The example configuration below renames `entity.type` to `graphql.type.name`: +Configuration can now specify different Cross-Origin Resource Sharing (CORS) rules for different origins using the `cors.policies` key. See the [CORS documentation](https://www.apollographql.com/docs/graphos/routing/security/cors) for details. ```yaml -telemetry: - instrumentation: - spans: - mode: spec_compliant # Docs state this significantly improves performance: https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/spans#spec_compliant - instruments: - cache: # Cache instruments configuration - apollo.router.operations.entity.cache: # A counter which counts the number of cache hit and miss for subgraph requests - attributes: - graphql.type.name: # renames entity.type - alias: entity_type # ENABLED and aliased to entity_type +cors: + policies: + # The default CORS options work for Studio. + - origins: ["https://studio.apollographql.com"] + # Specific config for trusted origins + - match_origins: ["^https://(dev|staging|www)?\\.my-app\\.(com|fr|tn)$"] + allow_credentials: true + allow_headers: ["content-type", "authorization", "x-web-version"] + # Catch-all for untrusted origins + - origins: ["*"] + allow_credentials: false + allow_headers: ["content-type"] ``` -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5957 +By [@Velfi](https://github.com/Velfi) in https://github.com/apollographql/router/pull/7853 -### Enable router customizations to access demand control info ([PR #5972](https://github.com/apollographql/router/pull/5972)) +### jemalloc metrics ([PR #7735](https://github.com/apollographql/router/pull/7735)) -Rhai scripts and coprocessors can now access demand control information via the context. For more information on Rhai constants to access demand control info, see [available Rhai API constants](https://apollographql.com/docs/router/customizations/rhai-api#available-constants). +This PR adds the following new metrics when running the router on Linux with its default `global-allocator` feature: -By [@tninesling](https://github.com/tninesling) in https://github.com/apollographql/router/pull/5972 - -### Support Redis connection pooling ([PR #5942](https://github.com/apollographql/router/pull/5942)) - -The router now supports Redis connection pooling for APQs, query planners and entity caches. This can improve performance when there is contention on Redis connections or latency in Redis calls. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5942 +- [apollo_router_jemalloc_active](https://jemalloc.net/jemalloc.3.html#stats.active): Total number of bytes in active pages allocated by the application. +- [apollo_router_jemalloc_allocated](https://jemalloc.net/jemalloc.3.html#stats.allocated): Total number of bytes allocated by the application. +- [apollo_router_jemalloc_mapped](https://jemalloc.net/jemalloc.3.html#stats.mapped): Total number of bytes in active extents mapped by the allocator. +- [apollo_router_jemalloc_metadata](https://jemalloc.net/jemalloc.3.html#stats.metadata): Total number of bytes dedicated to metadata, which comprise base allocations used for bootstrap-sensitive allocator metadata structures and internal allocations. +- [apollo_router_jemalloc_resident](https://jemalloc.net/jemalloc.3.html#stats.resident): Maximum number of bytes in physically resident data pages mapped by the allocator, comprising all pages dedicated to allocator metadata, pages backing active allocations, and unused dirty pages. +- [apollo_router_jemalloc_retained](https://jemalloc.net/jemalloc.3.html#stats.retained): Total number of bytes in virtual memory mappings that were retained rather than being returned to the operating system via e.g. `munmap(2)` or similar. +By [@Velfi](https://github.com/Velfi) in https://github.com/apollographql/router/pull/7735 ## 🐛 Fixes -### Remove unused fragments and input arguments when filtering operations ([PR #5952](https://github.com/apollographql/router/pull/5952)) - -This release fixes the authorization plugin's query filtering to remove unused fragments and input arguments if the related parts of the query are removed. Previously the plugin's query filtering generated validation errors when planning certain queries. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5952 - -### Hot-reloads will no longer interrupt certain gauges ([PR #5996](https://github.com/apollographql/router/pull/5996), [PR #5999](https://github.com/apollographql/router/pull/5999), [PR #5999](https://github.com/apollographql/router/pull/6012)) - -Previously when the router hot-reloaded a schema or a configuration file, the following gauges stopped working: - -* `apollo.router.cache.storage.estimated_size` -* `apollo_router_cache_size` -* `apollo.router.v8.heap.used` -* `apollo.router.v8.heap.total` -* `apollo.router.query_planning.queued` - -This issue has been fixed in this release, and the gauges now continue to function after a router hot-reloads. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5996 and https://github.com/apollographql/router/pull/5999 and https://github.com/apollographql/router/pull/6012 - -### Datadog sample propagation will respect previous sampling decisions ([PR #6005](https://github.com/apollographql/router/pull/6005)) - -[PR #5788](https://github.com/apollographql/router/pull/5788) introduced a regression where sampling was set on propagated headers regardless of the sampling decision in the router or upstream. +### Coprocessor: improve handling of invalid GraphQL responses with conditional validation ([PR #7731](https://github.com/apollographql/router/pull/7731)) -This PR reverts the code in question and adds a test to check that a non-sampled request doesn't result in sampling in the downstream subgraph service. +The router was creating invalid GraphQL responses internally, especially when subscriptions terminate. When a coprocessor is configured, it validates all responses for correctness, causing errors to be logged when the router generates invalid internal responses. This affects the reliability of subscription workflows with coprocessors. -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/6005 +Fix handling of invalid GraphQL responses returned from coprocessors, particularly when used with subscriptions. Added conditional response validation and improved testing to ensure correctness. Added the `response_validation` configuration option at the coprocessor level to enable the response validation (by default it's enabled). -### Include request variables when scoring for demand control ([PR #5995](https://github.com/apollographql/router/pull/5995)) +By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/7731 -Demand control scoring in the router now accounts for variables in queries. +### Fix deduplicated subscriptions hanging when one subscription closes ([PR #7879](https://github.com/apollographql/router/pull/7879)) -By [@tninesling](https://github.com/tninesling) in https://github.com/apollographql/router/pull/5995 +Fixes a regression introduced in v1.50.0. When multiple client subscriptions are deduped onto a single subgraph subscription in WebSocket passthrough mode, and the first client subscription closes, the Router would close the subgraph subscription. The other deduplicated subscriptions would then silently stop receiving events. -## 📃 Configuration - -### Enable new and old schema introspection implementations by default ([PR #6014](https://github.com/apollographql/router/pull/6014)) - -Starting with this release, if schema introspection is enabled, the router runs both the old Javascript implementation and a new Rust implementation of its introspection logic by default. - -The more performant Rust implementation will eventually replace the Javascript implementation. For now, both implementations are run by default so we can definitively assess the reliability and stability of the Rust implementation before removing the Javascript one. - -You can still toggle between implementations using the `experimental_introspection_mode` configuration key. Its valid values: - -- `new` runs only Rust-based validation -- `legacy` runs only Javascript-based validation -- `both` (default) runs both in comparison and logs errors if differences arise - -Having `both` as the default causes no client-facing impact. It will record and output the metrics of its comparison as a `apollo.router.operations.introspection.both` counter. (Note: if this counter in your metrics has `rust_error = true` or `is_matched = false`, please open an issue with Apollo.) - -Note: schema introspection itself is disabled by default, so its implementation(s) are run only if it's enabled in your configuration: +Now outgoing subscriptions to subgraphs are kept open as long as _any_ client subscription uses them. -```yaml -supergraph: - introspection: true -``` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/6014 +By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/7879 -## 🧪 Experimental +### Fix several hot reload issues with subscriptions ([PR #7746](https://github.com/apollographql/router/pull/7777)) -### Allow disabling persisted-queries-based query plan cache prewarm on schema reload +When a hot reload is triggered by a configuration change, the router attempted to apply updated configuration to open subscriptions. This could cause excessive logging. -The router supports the new `persisted_queries.experimental_prewarm_query_plan_cache.on_reload` configuration option. It toggles whether a query plan cache that's prewarmed upon loading a new schema includes operations from persisted query lists. Its default is `true`. Setting it `false` precludes operations from persisted query lists from being added to the prewarmed query plan cache. +When a hot reload was triggered by a schema change, the router closed subscriptions with a `SUBSCRIPTION_SCHEMA_RELOAD` error. This happened *before* the new schema was fully active and warmed up, so clients could reconnect to the _old_ schema, which should not happen. -Some background about the development of this option: +To fix these issues, a configuration and a schema change now have the same behavior. The router waits for the new configuration and schema to be active, and then closes all subscriptions with a `SUBSCRIPTION_SCHEMA_RELOAD`/`SUBSCRIPTION_CONFIG_RELOAD` error, so clients can reconnect. -- In router v1.31.0, we started including operations from persisted query lists when the router prewarms the query plan cache when loading a new schema. +By [@goto-bus-stop](https://github.com/goto-bus-stop) and [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/7777 -- Then in router v1.49.0, we let you also prewarm the query plan cache from the persisted query list during router startup by setting `persisted_queries.experimental_prewarm_query_plan_cache` to true. +### Fix error when removing non-UTF-8 headers with Rhai plugin ([PR #7801](https://github.com/apollographql/router/pull/7801)) -- In this release, we now allow you to disable the original feature so that the router can prewarm only recent operations from the query planning cache (and not operations from persisted query lists) when loading a new schema. +When trying to remove non-UTF-8 headers from a Rhai plugin, users were faced with an unhelpful error. Now, non-UTF-8 values will be lossy converted to UTF-8 when accessed from Rhai. This change affects `get`, `get_all`, and `remove` operations. -Note: the option added in v1.49.0 has been renamed from `persisted_queries.experimental_prewarm_query_plan_cache` to `persisted_queries.experimental_prewarm_query_plan_cache.on_startup`. Existing configuration files will keep working as before, but with a warning that can be resolved by updating your config file: +By [@Velfi](https://github.com/Velfi) in https://github.com/apollographql/router/pull/7801 -```diff - persisted_queries: - enabled: true -- experimental_prewarm_query_plan_cache: true -+ experimental_prewarm_query_plan_cache: -+ on_startup: true -``` +### Query planning errors with progressive override on interface implementations ([PR #7929](https://github.com/apollographql/router/pull/7929)) -By [@glasser](https://github.com/glasser) in https://github.com/apollographql/router/pull/5990 +The router now correctly generates query plans when using [progressive override](https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/entities/migrate-fields#incremental-migration-with-progressive-override) (`@override` with labels) on types that implement interfaces within the same subgraph. Previously, the Rust query planner would fail to generate plans for these scenarios with the error `"Was not able to find any options for {}: This shouldn't have happened."`, while the JavaScript planner handled them correctly. -# [1.54.0] - 2024-09-10 +This fix resolves planning failures when your schema uses: +- Interface implementations local to a subgraph +- Progressive override directives on both the implementing type and its fields +- Queries that traverse through the overridden interface implementations -## 🚀 Features +The router will now successfully plan and execute queries that previously resulted in query planning errors. -### Add configurability of span attributes in logs ([Issue #5540](https://github.com/apollographql/router/issues/5540)) +By [@TylerBloom](https://github.com/TylerBloom) in https://github.com/apollographql/router/pull/7929 -The router supports a new `telemetry.exporters.logging.stdout.format.json.span_attributes` option that enables you to choose a subset of all span attributes to display in your logs. +### Fix startup hang with an empty Persisted Queries list ([PR #7831](https://github.com/apollographql/router/pull/7831)) -When `span_attributes` is specified, the router searches for the first attribute in its input list of span attributes from the root span to the current span and attaches it to the outermost JSON object for the log event. If you set the same attribute name for different spans at different levels, the router chooses the attributes of child spans before the attributes of parent spans. +When the Persisted Queries feature is enabled, the router no longer hangs during startup when using a GraphOS account with no Persisted Queries manifest. +### Remove `@` from error paths ([Issue #4548](https://github.com/apollographql/router/issues/4548)) -For example, if you have spans that contains `span_attr_1` attribute and you only want to display this span attribute: +When a subgraph returns an unexpected response (ie not a body with at least one of `errors` or `data`), the errors surfaced by the router include an `@` in the path which indicates an error applied to all elements in the array. This is not a behavior defined in the GraphQL spec and is not easily parsed. -```yaml title="router.yaml" -telemetry: - exporters: - logging: - stdout: - enabled: true - format: - json: - display_span_list: false - span_attributes: - - span_attr_1 -``` +This fix expands the `@` symbol to reflect all paths that the error applies to. -Example output with a list of spans: +#### Example +Consider a federated graph with two subgraphs, `products` and `inventory`, and a `topProducts` query which fetches a list of products from `products` and then fetches an inventory status for each product. +A successful response might look like: ```json { - "timestamp": "2023-10-30T14:09:34.771388Z", - "level": "INFO", - "fields": { - "event_attr_1": "event_attr_1", - "event_attr_2": "event_attr_2" - }, - "target": "event_target", - "span_attr_1": "span_attr_1" + "data": { + "topProducts": [ + {"name": "Table", "inStock": true}, + {"name": "Chair", "inStock": false} + ] + } } ``` -To learn more, go to [`span_attributes`](https://www.apollographql.com/docs/router/configuration/telemetry/exporters/logging/stdout#span_attributes) docs. -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5867 - -### Add a histogram metric tracking evaluated query plans ([PR #5875](https://github.com/apollographql/router/pull/5875)) - -The router supports the new `apollo.router.query_planning.plan.evaluated_plans` histogram metric to track the number of evaluated query plans. - -You can use it to help set an optimal `supergraph.query_planning.experimental_plans_limit` option that limits the number of query plans evaluated for a query and reduces the time spent planning. - - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5875 - -## 🐛 Fixes - -### Fix Datadog sampling ([PR #5788](https://github.com/apollographql/router/pull/5788)) - -The router's Datadog exporter has been fixed so that traces are sampled as intended. - -Previously, the Datadog exporter's context may not have been set correctly, causing traces to be undersampled. - -By [@BrynCooke](https://github.com/BrynCooke) & [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5788 - -## 📃 Configuration - -### General availability of Apollo usage report generation ([#5807](https://github.com/apollographql/router/pull/5807)) - -The router's Apollo usage report generation feature that was previously [experimental](https://www.apollographql.com/docs/resources/product-launch-stages/#experimental-features) is now [generally available](https://www.apollographql.com/docs/resources/product-launch-stages/#general-availability). - -If you used its experimental configuration, you should migrate to the new configuration options: - -* `telemetry.apollo.experimental_apollo_metrics_reference_mode` is now `telemetry.apollo.metrics_reference_mode` -* `telemetry.apollo.experimental_apollo_signature_normalization_algorithm` is now `telemetry.apollo.signature_normalization_algorithm` -* `experimental_apollo_metrics_generation_mode` has been removed because the Rust implementation (the default since router v1.49.0) is generating reports identical to the previous router-bridge implementation - -The experimental configuration options are now deprecated. They are functional but will log warnings. - -By [@bonnici](https://github.com/bonnici) in https://github.com/apollographql/router/pull/5807 - -### Helm: Enable easier Kubernetes debugging with heaptrack ([Issue #5789](https://github.com/apollographql/router/issues/5789)) - -The router's Helm chart has been updated to help make debugging with heaptrack easier. - -Previously, when debugging multiple Pods with heaptrack, all Pods wrote to the same file, so they'd overwrite each others' results. This issue has been fixed by adding a `hostname` to each output data file from heaptrack. - -Also, the Helm chart now supports a `restartPolicy` that enables you to configure a Pod's [restart policy](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#container-restarts). The default value of `restartPolicy` is `Always` (the same as the Kubernetes default). - - -By [@cyberhck](https://github.com/cyberhck) in https://github.com/apollographql/router/pull/5850 - -## 📚 Documentation - -### Document OpenTelemetry information for operation limits ([PR #5884](https://github.com/apollographql/router/pull/5884)) - -The router's docs for operation limits now describe [using telemetry to set operation limits](https://www.apollographql.com/docs/router/configuration/operation-limits#using-telemetry-to-set-operation-limits) and [logging values](https://www.apollographql.com/docs/router/configuration/operation-limits#logging-values). - -By [@andrewmcgivery](https://github.com/andrewmcgivery) in https://github.com/apollographql/router/pull/5884 - - - -# [1.53.0] - 2024-08-28 - -> [!IMPORTANT] -> If you have enabled [Distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching), this release changes the hashing algorithm used for the cache keys. On account of this, you should anticipate additional cache regeneration cost when updating between these versions while the new hashing algorithm comes into service. - -## 🚀 Features - -### Support demand control directives ([PR #5777](https://github.com/apollographql/router/pull/5777)) - -> ⚠️ This is a [GraphOS Router feature](https://www.apollographql.com/graphos-router). - -The router supports two new demand control directives, `@cost` and `@listSize`, that you can use to provide more accurate estimates of GraphQL operation costs to the router's demand control plugin. - -Use the `@cost` directive to customize the weights of operation cost calculations, particularly for expensive resolvers. - -```graphql -type Product { - id: ID! - name: String - expensiveField: Int @cost(weight: 20) +Prior to this change, if the `inventory` subgraph returns a malformed response, the router response would look like: +```json +{ + "data": {"topProducts": [{"name": "Table", "inStock": null}, {"name": "Chair", "inStock": null}]}, + "errors": [ + { + "message": "service 'inventory' response was malformed: graphql response without data must contain at least one error", + "path": ["topProducts", "@"], + "extensions": {"service": "inventory", "reason": "graphql response without data must contain at least one error", "code": "SUBREQUEST_MALFORMED_RESPONSE"} + } + ] } ``` -Use the `@listSize` directive to provide a more accurate estimate for the size of a specific list field, particularly for those that differ greatly from the global list size estimate. - -```graphql -type Magazine { - # This is assumed to always return 5 items - headlines: [Article] @listSize(assumedSize: 5) - - # This is estimated to return as many items as are requested by the parameter named "first" - getPage(first: Int!, after: ID!): [Article] - @listSize(slicingArguments: ["first"]) +With this change, the response will look like: +```json +{ + "data": {"topProducts": [{"name": "Table", "inStock": null}, {"name": "Chair", "inStock": null}]}, + "errors": [ + { + "message": "service 'inventory' response was malformed: graphql response without data must contain at least one error", + "path": ["topProducts", 0], + "extensions": {"service": "inventory", "reason": "graphql response without data must contain at least one error", "code": "SUBREQUEST_MALFORMED_RESPONSE"} + }, + { + "message": "service 'inventory' response was malformed: graphql response without data must contain at least one error", + "path": ["topProducts", 1], + "extensions": {"service": "inventory", "reason": "graphql response without data must contain at least one error", "code": "SUBREQUEST_MALFORMED_RESPONSE"} + } + ] } ``` -To learn more, go to [Demand Control](https://www.apollographql.com/docs/router/executing-operations/demand-control/) docs. - -By [@tninesling](https://github.com/tninesling) in https://github.com/apollographql/router/pull/5777 - -### General Availability (GA) of Demand Control ([PR #5868](https://github.com/apollographql/router/pull/5868)) - -Demand control in the router is now a generally available (GA) feature. - -**GA compatibility update**: if you used demand control during its preview, to use it in GA you must update your configuration from `preview_demand_control` to `demand_control`. - -To learn more, go to [Demand Control](https://www.apollographql.com/docs/router/executing-operations/demand-control/) docs. - -By [@tninesling](https://github.com/tninesling) in https://github.com/apollographql/router/pull/5868 - -### Enable native query planner to run in the background ([PR #5790](https://github.com/apollographql/router/pull/5790), [PR #5811](https://github.com/apollographql/router/pull/5811), [PR #5771](https://github.com/apollographql/router/pull/5771), [PR #5860](https://github.com/apollographql/router/pull/5860)) - -The router now schedules background jobs to run the native (Rust) query planner to compare its results to the legacy implementation. This helps ascertain its correctness before making a decision to switch entirely to it from the legacy query planner. - -To learn more, go to [Experimental Query Planner Mode](https://www.apollographql.com/docs/router/configuration/experimental_query_planner_mode) docs. - -The router continues to use the legacy query planner to plan and execute operations, so there is no effect on the hot path. - -To disable running background comparisons with the native query planner, you can configure the router to enable only the `legacy` query planner: - -```yaml -experimental_query_planner_mode: legacy -``` - -By [SimonSapin](https://github.com/SimonSapin) in ([PR #5790](https://github.com/apollographql/router/pull/5790), [PR #5811](https://github.com/apollographql/router/pull/5811), [PR #5771](https://github.com/apollographql/router/pull/5771) [PR #5860](https://github.com/apollographql/router/pull/5860)) - -### Add warnings for invalid configuration of custom telemetry ([PR #5759](https://github.com/apollographql/router/issues/5759)) - -The router now logs warnings when running with telemetry that may have invalid custom configurations. - -For example, you may customize telemetry using invalid conditions or inaccessible statuses: - -```yaml -telemetry: - instrumentation: - events: - subgraph: - my.event: - message: "Auditing Router Event" - level: info - on: request - attributes: - subgraph.response.status: code - # Warning: should use selector for subgraph_name: true instead of comparing strings of subgraph_name and product - condition: - eq: - - subgraph_name - - product -``` - -Although the configuration is syntactically correct, its customization is invalid, and the router now outputs warnings for such invalid configurations. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5759 - -### Add V8 heap usage metrics ([PR #5781](https://github.com/apollographql/router/pull/5781)) - -The router supports new gauge metrics for tracking heap memory usage of the V8 Javascript engine: -- `apollo.router.v8.heap.used`: heap memory used by V8, in bytes -- `apollo.router.v8.heap.total`: total heap allocated by V8, in bytes - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5781 - -### Update Federation to v2.9.0 ([PR #5902](https://github.com/apollographql/router/pull/5902)) - -This updates the router to Federation v2.9.0. - -By [@tninesling](https://github.com/tninesling) in https://github.com/apollographql/router/pull/5902 - -### Helm: Support `maxSurge` and `maxUnavailable` for rolling updates ([Issue #5664](https://github.com/apollographql/router/issues/5664)) - -The router Helm chart now supports the configuration of `maxSurge` and `maxUnavailable` for the `RollingUpdate` deployment strategy. - -By [Jon Christiansen](https://github.com/theJC) in https://github.com/apollographql/router/pull/5665 - -### Support new telemetry trace ID format ([PR #5735](https://github.com/apollographql/router/pull/5735)) - -The router supports a new UUID format for telemetry trace IDs. - -The following formats are supported in router configuration for trace IDs: - -* `open_telemetry` -* `hexadecimal` (same as `opentelemetry`) -* `decimal` -* `datadog` -* `uuid` (may contain dashes) - -You can configure router logging to display the formatted trace ID with `display_trace_id`: - -```yaml - telemetry: - exporters: - logging: - stdout: - format: - json: - display_trace_id: (true|false|open_telemetry|hexadecimal|decimal|datadog|uuid) -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5735 - -### Add `format` for trace ID propagation. ([PR #5803](https://github.com/apollographql/router/pull/5803)) - -The router now supports specifying the format of trace IDs that are propagated to subgraphs via headers. - -You can configure the format with the `format` option: - -```yaml -telemetry: - exporters: - tracing: - propagation: - request: - header_name: "my_header" - # Must be in UUID form, with or without dashes - format: uuid +The above examples reflect the behavior with `include_subgraph_errors = true`; if `include_subgraph_errors` is false: +```json +{ + "data": {"topProducts": [{"name": "Table", "inStock": null}, {"name": "Chair", "inStock": null}]}, + "errors": [ + { + "message": "Subgraph errors redacted", + "path": ["topProducts", 0] + }, + { + "message": "Subgraph errors redacted", + "path": ["topProducts", 1] + } + ] +} ``` -Note that incoming requests must be some form of UUID, either with or without dashes. - -To learn about supported formats, go to [`request` configuration reference](https://apollographql.com/docs/router/configuration/telemetry/exporters/tracing/overview#request-configuration-reference) docs. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5803 - -### New `apollo.router.cache.storage.estimated_size` gauge ([PR #5770](https://github.com/apollographql/router/pull/5770)) - -The router supports the new metric `apollo.router.cache.storage.estimated_size` that helps users understand and monitor the amount of memory that query planner cache entries consume. - -The `apollo.router.cache.storage.estimated_size` metric gives an estimated size in bytes of a cache entry. It has the following attributes: -- `kind`: `query planner`. -- `storage`: `memory`. - -Before using the estimate to decide whether to update the cache, users should validate that the estimate correlates with their pod's memory usage. - -To learn how to troubleshoot with this metric, see the [Pods terminating due to memory pressure](https://www.apollographql.com/docs/router/containerization/kubernetes#pods-terminating-due-to-memory-pressure) guide in docs. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5770 - -## 🐛 Fixes - -### Fix GraphQL query directives validation bug ([PR #5753](https://github.com/apollographql/router/pull/5753)) - -The router now supports GraphQL queries where a variable is used in a directive on the same operation where the variable is declared. - -For example, the following query both declares and uses `$var`: - -```graphql -query GetSomething(: Int!) @someDirective(argument: $var) { - something -} -``` - -By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/5753 - -### Evaluate selectors in response stage when possible ([PR #5725](https://github.com/apollographql/router/pull/5725)) - -The router now supports having various supergraph selectors on response events. - -Because `events` are triggered at a specific event (`request`|`response`|`error`), you usually have only one condition for a related event. You can however have selectors that can be applied to several events, like `subgraph_name` to get the subgraph name). - -Example of an event to log the raw subgraph response only on a subgraph named `products`, this was not working before. - -```yaml -telemetry: - instrumentation: - events: - subgraph: - response: - level: info - condition: - eq: - - subgraph_name: true - - "products" -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5725 - -### Fix trace propagation via header ([PR #5802](https://github.com/apollographql/router/pull/5802)) - -The router now correctly propagates trace IDs when using the `propagation.request.header_name` configuration option. - -```yaml -telemetry: - exporters: - tracing: - propagation: - request: - header_name: "id_from_header" -``` - -Previously, trace IDs weren't transferred to the root span of the request, causing spans to be incorrectly attributed to new traces. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5802 - -### Add argument cost to type cost in demand control scoring algorithm ([PR #5740](https://github.com/apollographql/router/pull/5740)) - -The router's operation scoring algorithm for demand control now includes field arguments in the type cost. - -By [@tninesling](https://github.com/tninesling) in https://github.com/apollographql/router/pull/5740 - -### Support `gt`/`lt` conditions for parsing string selectors to numbers ([PR #5758](https://github.com/apollographql/router/pull/5758)) - -The router now supports greater than (`gt`) and less than (`lt`) conditions for header selectors. - -The following example applies an attribute on a span if the `content-length` header is greater than 100: - -```yaml -telemetry: - instrumentation: - spans: - mode: spec_compliant - router: - attributes: - trace_id: true - payload_is_to_big: # Set this attribute to true if the value of content-length header is > than 100 - static: true - condition: - gt: - - request_header: "content-length" - - 100 -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5758 - -### Set subgraph error path if not present ([PR #5773](https://github.com/apollographql/router/pull/5773)) - -The router now sets the error path in all cases during subgraph response conversion. Previously the router's subgraph service didn't set the error path for some network-level errors. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5773 - -### Fix cost result filtering for custom metrics ([PR #5838](https://github.com/apollographql/router/pull/5838)) - -The router can now filter for custom metrics that use demand control cost information in their conditions. This allows a telemetry config such as the following: - -```yaml -telemetry: - instrumentation: - instruments: - supergraph: - cost.rejected.operations: - type: histogram - value: - cost: estimated - description: "Estimated cost per rejected operation." - unit: delta - condition: - eq: - - cost: result - - "COST_ESTIMATED_TOO_EXPENSIVE" -``` - -This also fixes an issue where attribute comparisons would fail silently when comparing integers to float values. Users can now write integer values in conditions that compare against selectors that select floats: - -```yaml -telemetry: - instrumentation: - instruments: - supergraph: - cost.rejected.operations: - type: histogram - value: - cost: actual - description: "Estimated cost per rejected operation." - unit: delta - condition: - gt: - - cost: delta - - 1 -``` - -By [@tninesling](https://github.com/tninesling) in https://github.com/apollographql/router/pull/5838 - -### Fix missing `apollo_router_cache_size` metric ([PR #5770](https://github.com/apollographql/router/pull/5770)) - -Previously, if the in-memory cache wasn't mutated, the `apollo_router_cache_size` metric wouldn't be available. This has been fixed in this release. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5770 - -### Interrupted subgraph connections trigger error responses and subgraph service hook points ([PR #5859](https://github.com/apollographql/router/pull/5859)) - -The router now returns a proper subgraph response, with an error if necessary, when a subgraph connection is closed or returns an error. - -Previously, this issue prevented the subgraph response service from being triggered in coprocessors or Rhai scripts. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5859 - -### Fix `exists` condition for custom telemetry events ([Issue #5702](https://github.com/apollographql/router/issues/5702)) - -The router now properly handles the `exists` condition for events. The following configuration now works as intended: - -```yaml -telemetry: - instrumentation: - events: - supergraph: - my.event: - message: "Auditing Router Event" - level: info - on: request - attributes: - graphql.operation.name: true - condition: - exists: - operation_name: string -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5759 - -### Fix Datadog underreporting APM metrics ([PR #5780](https://github.com/apollographql/router/pull/5780)) - -The previous [PR #5703](https://github.com/apollographql/router/pull/5703) has been reverted in this release because it caused Datadog to underreport APM span metrics. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5780 - -### Fix inconsistent `type` attribute in `apollo.router.uplink.fetch.duration` metric ([PR #5816](https://github.com/apollographql/router/pull/5816)) - -The router now always reports a short name in the `type` attribute for the `apollo.router.fetch.duration` metric, instead of sometimes using a fully-qualified Rust path and sometimes using a short name. - -By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/5816 - -### Enable progressive override with Federation 2.7 and above ([PR #5754](https://github.com/apollographql/router/pull/5754)) - -The progressive override feature is now available when using Federation v2.7 and above. - -By [@o0ignition0o](https://github.com/o0ignition0o) in https://github.com/apollographql/router/pull/5754 - -### Support supergraph query selector for events ([PR #5764](https://github.com/apollographql/router/pull/5764)) - -The router now supports the `query: root_fields` selector for `event_response`. Previously the selector worked for `response` stage events but didn't work for `event_response`. - -The following configuration for a `query: root_fields` on an `event_response` now works: - -```yaml -telemetry: - instrumentation: - events: - supergraph: - OPERATION_LIMIT_INFO: - message: operation limit info - on: event_response - level: info - attributes: - graphql.operation.name: true - query.root_fields: - query: root_fields -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5764 - -### Fix session counting and the reporting of file handle shortage ([PR #5834](https://github.com/apollographql/router/pull/5834)) - -The router previously gave incorrect warnings about file handle shortages due to session counting incorrectly including connections to health-check connections or other non-GraphQL connections. This is now corrected so that only connections to the main GraphQL port are counted, and file handle shortages are now handled correctly as a global resource. - -Also, the router's port listening logic had its own custom rate-limiting of log notifications. This has been removed and replaced by the [standard router log rate limiting configuration](https://www.apollographql.com/docs/router/configuration/telemetry/exporters/logging/stdout/#rate_limit) - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/5834 - -## 📃 Configuration - -### Increase default Redis timeout ([PR #5795](https://github.com/apollographql/router/pull/5795)) - -The default Redis command timeout was increased from 2ms to 500ms to accommodate common production use cases. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5795 - -## 🛠 Maintenance - -### Improve performance by optimizing telemetry meter and instrument creation ([PR #5629](https://github.com/apollographql/router/pull/5629)) - -The router's performance has been improved by removing telemetry creation out of the critical path, from being created in every service to being created when starting the telemetry plugin. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5629 - -## 📚 Documentation - -### Add sections on using `@cost` and `@listSize` to demand control docs ([PR #5839](https://github.com/apollographql/router/pull/5839)) - -Updates the demand control documentation to include details on `@cost` and `@listSize` for more accurate cost estimation. - -By [@tninesling](https://github.com/tninesling) in https://github.com/apollographql/router/pull/5839 - -# [1.52.1] - 2024-08-27 - -> [!IMPORTANT] -> If you have enabled [Distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching), this release changes the hashing algorithm used for the cache keys. On account of this, you should anticipate additional cache regeneration cost when updating between these versions while the new hashing algorithm comes into service. - -## 🔒 Security - -### CVE-2024-43783: Payload limits may exceed configured maximum - -Correct a denial-of-service vulnerability which, under certain non-default configurations below, made it possible to exceed the configured request payload maximums set with the [`limits.http_max_request_bytes`](https://www.apollographql.com/docs/router/configuration/overview/#http_max_request_bytes) option. - -This affects the following non-default Router configurations: - -1. Those configured to send request bodies to [External Coprocessors](https://www.apollographql.com/docs/router/customizations/coprocessor) where the `coprocessor.router.request.body` configuration option is set to `true`; or -2. Those which declare custom native Rust plugins using the `plugins` configuration where those plugins access the request body in the `RouterService` layer. - -Rhai plugins are **not** impacted. See the associated Github Advisory, [GHSA-x6xq-whh3-gg32](https://github.com/apollographql/router/security/advisories/GHSA-x6xq-whh3-gg32), for more information. - -### CVE-2024-43414: Update query planner to resolve uncontrolled recursion - -Update the version of `@apollo/query-planner` used by Router to v2.8.5 which corrects an uncontrolled recursion weakness (classified as [CWE-674](https://cwe.mitre.org/data/definitions/674.html)) during query planning for complex queries on particularly complex graphs. - -This weakness impacts all versions of Router prior to this release. See the associated Github Advisory, [GHSA-fmj9-77q8-g6c4](https://github.com/apollographql/federation/security/advisories/GHSA-fmj9-77q8-g6c4), for more information. - -# [1.52.0] - 2024-07-30 - -## 🚀 Features - -### Provide helm support for when router's health_check's default path is not being used([Issue #5652](https://github.com/apollographql/router/issues/5652)) - -When helm chart is defining the liveness and readiness check probes, if the router has been configured to use a non-default health_check path, use that rather than the default ( /health ) - -By [Jon Christiansen](https://github.com/theJC) in https://github.com/apollographql/router/pull/5653 - -### Support new span and metrics formats for entity caching ([PR #5625](https://github.com/apollographql/router/pull/5625)) - -Metrics of the router's entity cache have been converted to the latest format with support for custom telemetry. - -The following example configuration shows the the `cache` instrument, the `cache` selector in the subgraph service, and the `cache` attribute of a subgraph span: - -```yaml -telemetry: - instrumentation: - instruments: - default_requirement_level: none - cache: - apollo.router.operations.entity.cache: - attributes: - entity.type: true - subgraph.name: - subgraph_name: true - supergraph.operation.name: - supergraph_operation_name: string - subgraph: - only_cache_hit_on_subgraph_products: - type: counter - value: - cache: hit - unit: hit - description: counter of subgraph request cache hit on subgraph products - condition: - all: - - eq: - - subgraph_name: true - - products - - gt: - - cache: hit - - 0 - attributes: - subgraph.name: true - supergraph.operation.name: - supergraph_operation_name: string - -``` - -To learn more, go to [Entity caching docs](https://www.apollographql.com/docs/router/configuration/entity-caching). - -By [@Geal](https://github.com/Geal) and [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5625 - -### Helm: Support renaming key for retrieving APOLLO_KEY secret ([Issue #5661](https://github.com/apollographql/router/issues/5661)) - -A user of the router Helm chart can now rename the key used to retrieve the value of the secret key referenced by `APOLLO_KEY`. - -Previously, the router Helm chart hardcoded the key name to `managedFederationApiKey`. This didn't support users whose infrastructure required custom key names when getting secrets, such as Kubernetes users who need to use specific key names to access a `secretStore` or `externalSecret`. This change provides a user the ability to control the name of the key to use in retrieving that value. - -By [Jon Christiansen](https://github.com/theJC) in https://github.com/apollographql/router/pull/5662 - -## 🐛 Fixes - -### Prevent Datadog timeout errors in logs ([Issue #2058](https://github.com/apollographql/router/issue/2058)) - -The router's Datadog exporter has been updated to reduce the frequency of logged errors related to connection pools. - -Previously, the connection pools used by the Datadog exporter frequently timed out, and each timeout logged an error like the following: - -``` -2024-07-19T15:28:22.970360Z ERROR OpenTelemetry trace error occurred: error sending request for url (http://127.0.0.1:8126/v0.5/traces): connection error: Connection reset by peer (os error 54) -``` - -Now, the pool timeout for the Datadog exporter has been changed so that timeout errors happen much less frequently. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5692 - -### Allow service version overrides ([PR #5689](https://github.com/apollographql/router/pull/5689)) - -The router now supports configuration of `service.version` via YAML file configuration. This enables users to produce custom versioned builds of the router. - - -The following example overrides the version to be `1.0`: -```yaml -telemetry: - exporters: - tracing: - common: - resource: - service.version: 1.0 -``` - - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5689 - -### Populate Datadog `span.kind` ([PR #5609](https://github.com/apollographql/router/pull/5609)) - -Because Datadog traces use `span.kind` to differentiate between different types of spans, the router now ensures that `span.kind` is correctly populated using the OpenTelemetry span kind, which has a 1-2-1 mapping to those set out in [dd-trace](https://github.com/DataDog/dd-trace-go/blob/main/ddtrace/ext/span_kind.go). - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5609 - -### Remove unnecessary internal metric events from traces and spans ([PR #5649](https://github.com/apollographql/router/pull/5649)) - -The router no longer includes some internal metric events in traces and spans that shouldn't have been included originally. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5649 - -### Support Datadog span metrics ([PR #5609](https://github.com/apollographql/router/pull/5609)) - -When using the APM view in Datadog, the router now displays span metrics for top-level spans or spans with the `_dd.measured` flag set. - -The router sets the `_dd.measured` flag by default for the following spans: - -* `request` -* `router` -* `supergraph` -* `subgraph` -* `subgraph_request` -* `http_request` -* `query_planning` -* `execution` -* `query_parsing` - -To enable or disable span metrics for any span, configure `span_metrics` for the Datadog exporter: - -```yaml -telemetry: - exporters: - tracing: - datadog: - enabled: true - span_metrics: - # Disable span metrics for supergraph - supergraph: false - # Enable span metrics for my_custom_span - my_custom_span: true -``` - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5609 and https://github.com/apollographql/router/pull/5703 - -### Use spawn_blocking for query parsing and validation ([PR #5235](https://github.com/apollographql/router/pull/5235)) - -To prevent its executor threads from blocking on large queries, the router now runs query parsing and validation in a Tokio blocking task. - -By [@xuorig](https://github.com/xuorig) in https://github.com/apollographql/router/pull/5235 - -## 🛠 Maintenance - -### chore: Update rhai to latest release (1.19.0) ([PR #5655](https://github.com/apollographql/router/pull/5655)) - -In Rhai 1.18.0, there were changes to how exceptions within functions were created. For details see: https://github.com/rhaiscript/rhai/blob/7e0ac9d3f4da9c892ed35a211f67553a0b451218/CHANGELOG.md?plain=1#L12 - -We've modified how we handle errors raised by Rhai to comply with this change, which means error message output is affected. The change means that errors in functions will no longer document which function the error occurred in, for example: - -```diff -- "rhai execution error: 'Runtime error: I have raised an error (line 223, position 5)\nin call to function 'process_subgraph_response_string''" -+ "rhai execution error: 'Runtime error: I have raised an error (line 223, position 5)'" -``` - -Making this change allows us to keep up with the latest version (1.19.0) of Rhai. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/5655 - -### Add version in the entity cache hash ([PR #5701](https://github.com/apollographql/router/pull/5701)) - -The hashing algorithm of the router's entity cache has been updated to include the entity cache version. - -[!IMPORTANT] -If you have previously enabled [entity caching](https://www.apollographql.com/docs/router/configuration/entity-caching), you should expect additional cache regeneration costs when updating to this version of the router while the new hashing algorithm comes into service. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5701 - -### Improve testing by avoiding cache effects and redacting tracing details ([PR #5638](https://github.com/apollographql/router/pull/5638)) - -We've had some problems with flaky tests and this PR addresses some of them. - -The router executes in parallel and concurrently. Many of our tests use snapshots to try and make assertions that functionality is continuing to work correctly. Unfortunately, concurrent/parallel execution and static snapshots don't co-operate very well. Results may appear in pseudo-random order (compared to snapshot expectations) and so tests become flaky and fail without obvious cause. - -The problem becomes particularly acute with features which are specifically designed for highly concurrent operation, such as batching. - -This set of changes addresses some of the router testing problems by: - -1. Making items in a batch test different enough that caching effects are avoided. -2. Redacting various details so that sequencing is not as much of an issue in the otel traces tests. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/5638 - -## 📚 Documentation - -### Update router naming conventions ([PR #5400](https://github.com/apollographql/router/pull/5400)) - -Renames our router product to distinguish between our non-commercial and commercial offerings. Instead of referring to the **Apollo Router**, we now refer to the following: -- **Apollo Router Core** is Apollo’s free-and-open (ELv2 licensed) implementation of a routing runtime for supergraphs. -- **GraphOS Router** is based on the Apollo Router Core and fully integrated with GraphOS. GraphOS Routers provide access to GraphOS’s commercial runtime features. - - -By [@shorgi](https://github.com/shorgi) in https://github.com/apollographql/router/pull/5400 - -## 🧪 Experimental - -### Enable Rust-based API schema implementation ([PR #5623](https://github.com/apollographql/router/pull/5623)) - -The router has transitioned to solely using a Rust-based API schema generation implementation. - -Previously, the router used a Javascript-based implementation. After testing for a few months, we've validated the improved performance and robustness of the new Rust-based implementation, so the router now only uses it. - -By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/5623 - - - -# [1.51.0] - 2024-07-16 - -## 🚀 Features - -### Support conditional coprocessor execution per stage of request lifecycle ([PR #5557](https://github.com/apollographql/router/pull/5557)) - -The router now supports conditional execution of the coprocessor for each stage of the request lifecycle (except for the `Execution` stage). - -To configure, define conditions for a specific stage by using selectors based on headers or context entries. For example, based on a supergraph response you can configure the coprocessor not to execute for any subscription: - - - -```yaml title=router.yaml -coprocessor: - url: http://127.0.0.1:3000 # mandatory URL which is the address of the coprocessor - timeout: 2s # optional timeout (2 seconds in this example). If not set, defaults to 1 second - supergraph: - response: - condition: - not: - eq: - - subscription - - operation_kind: string - body: true -``` - -To learn more, see the documentation about [coprocessor conditions](https://www.apollographql.com/docs/router/customizations/coprocessor/#conditions). - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5557 - -### Add option to deactivate introspection response caching ([PR #5583](https://github.com/apollographql/router/pull/5583)) - -The router now supports an option to deactivate introspection response caching. Because the router caches responses as introspection happens in the query planner, cached introspection responses may consume too much of the distributed cache or fill it up. Setting this option prevents introspection responses from filling up the router's distributed cache. - -To deactivate introspection caching, set `supergraph.query_planning.legacy_introspection_caching` to `false`: - - -```yaml -supergraph: - query_planning: - legacy_introspection_caching: false -``` - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5583 - -### Add 'subgraph_on_graphql_error' selector for subgraph ([PR #5622](https://github.com/apollographql/router/pull/5622)) - -The router now supports the `subgraph_on_graphql_error` selector for the subgraph service, which it already supported for the router and supergraph services. Subgraph service support enables easier detection of GraphQL errors in response bodies of subgraph requests. - -An example configuration with `subgraph_on_graphql_error` configured: - -```yaml -telemetry: - instrumentation: - instruments: - subgraph: - http.client.request.duration: - attributes: - subgraph.graphql.errors: # attribute containing a boolean set to true if response.errors is not empty - subgraph_on_graphql_error: true -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5622 - -## 🐛 Fixes - -### Add `response_context` in event selector for `event_*` instruments ([PR #5565](https://github.com/apollographql/router/pull/5565)) - -The router now supports creating custom instruments with a value set to `event_*` and using both a condition executed on an event and the `response_context` selector in attributes. Previous releases didn't support the `response_context` selector in attributes. - -An example configuration: - -```yaml -telemetry: - instrumentation: - instruments: - supergraph: - sf.graphql_router.errors: - value: event_unit - type: counter - unit: count - description: "graphql errors handled by the apollo router" - condition: - eq: - - true - - on_graphql_error: true - attributes: - "operation": - response_context: "operation_name" # This was not working before -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5565 - -### Provide valid trace IDs for unsampled traces in Rhai scripts ([PR #5606](https://github.com/apollographql/router/pull/5606)) - -The `traceid()` function in a Rhai script for the router now returns a valid trace ID for all traces. - -Previously, `traceid()` didn't return a trace ID if the trace wasn't selected for sampling. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5606 - -### Allow query batching and entity caching to work together ([PR #5598](https://github.com/apollographql/router/pull/5598)) - -The router now supports entity caching and subgraph batching to run simultaneously. Specifically, this change updates entity caching to ignore a subgraph request if the request is part of a batch. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/5598 - -### Gracefully handle subgraph response with `-1` values inside error locations ([PR #5633](https://github.com/apollographql/router/pull/5633)) - -This router now gracefully handles responses that contain invalid "`-1`" positional values for error locations in queries by ignoring those invalid locations. - -This change resolves the problem of GraphQL Java and GraphQL Kotlin using `{ "line": -1, "column": -1 }` values if they can't determine an error's location in a query, but the GraphQL specification [requires both `line` and `column` to be positive numbers](https://spec.graphql.org/draft/#sel-GAPHRPFCCaCGX5zM). - -As an example, a subgraph can respond with invalid error locations: -```json -{ - "data": { "topProducts": null }, - "errors": [{ - "message":"Some error on subgraph", - "locations": [ - { "line": -1, "column": -1 }, - ], - "path":["topProducts"] - }] -} -``` - -With this change, the router returns a response that ignores the invalid locations: - -```json -{ - "data": { "topProducts": null }, - "errors": [{ - "message":"Some error on subgraph", - "path":["topProducts"] - }] -} -``` - -By [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/5633 - -### Return request timeout and rate limited error responses as structured errors ([PR #5578](https://github.com/apollographql/router/pull/5578)) - -The router now returns request timeout errors (`408 Request Timeout`) and request rate limited errors (`429 Too Many Requests`) as structured GraphQL errors (for example, `{"errors": [...]}`). Previously, the router returned these as plaintext errors to clients. - -Both types of errors are properly tracked in telemetry, including the `apollo_router_graphql_error_total` metric. - -By [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/5578 - -### Fix span names and resource mapping for Datadog trace exporter ([Issue #5282](https://github.com/apollographql/router/issues/5282)) - -> [!NOTE] -> This is an **incremental** improvement, but we expect more improvements in Router v1.52.0 after https://github.com/apollographql/router/pull/5609/ lands. - -The router now uses _static span names_ by default. This change fixes the user experience of the Datadog trace exporter when sending traces with Datadog native configuration. - -The router has two ways of sending traces to Datadog: - -1. The [OpenTelemetry for Datadog](https://www.apollographql.com/docs/router/configuration/telemetry/exporters/tracing/datadog/#otlp-configuration) approach (which is the recommended method). This is identified by `otlp` in YAML configuration, and it is *not* impacted by this fix. -2. The ["Datadog native" configuration](https://www.apollographql.com/docs/router/configuration/telemetry/exporters/tracing/datadog/#datadog-native-configuration). This is identified by the use of a `datadog:` key in YAML configuration. - -This change fixes a bug in the latter approach that broke some Datadog experiences, such as the "Resources" section of the [Datadog APM Service Catalog](https://docs.datadoghq.com/service_catalog/) page. - -We now use static span names by default, with resource mappings providing additional context when requested, which enables the desired behavior which was not possible before. - -_If for some reason you wish to maintain the existing behavior, you must either update your spans and resource mappings, or keep your spans and instead configure the router to use _dynamic span names_ and disable resource mapping._ - -Enabling resource mapping and fixed span names is configured by the `enable_span_mapping` and `fixed_span_names` options: - -```yaml -telemetry: - exporters: - tracing: - datadog: - enabled: true - # Enables resource mapping, previously disabled by default, but now enabled. - enable_span_mapping: true - # Enables fixed span names, defaults to true. - fixed_span_names: true - - instrumentation: - spans: - mode: spec_compliant -``` - -With `enable_span_mapping` set to `true` (now default), the following resource mappings are applied: - -| OpenTelemetry Span Name | Datadog Span Operation Name | -|-------------------------|-----------------------------| -| `request` | `http.route` | -| `router` | `http.route` | -| `supergraph` | `graphql.operation.name` | -| `query_planning` | `graphql.operation.name` | -| `subgraph` | `subgraph.name` | -| `subgraph_request` | `graphql.operation.name` | -| `http_request` | `http.route` | - -You can override the default resource mappings by specifying the `resource_mapping` configuration: - -```yaml -telemetry: - exporters: - tracing: - datadog: - enabled: true - resource_mapping: - # Use `my.span.attribute` as the resource name for the `router` span - router: "my.span.attribute" -``` - -To learn more, see the [Datadog trace exporter](https://www.apollographql.com/docs/router/configuration/telemetry/exporters/tracing/datadog) documentation. - -By [@bnjjj](https://github.com/bnjjj) and [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/5386 - -## 📚 Documentation - -### Update documentation for `ignore_other_prefixes` ([PR #5592](https://github.com/apollographql/router/pull/5592)) - -Update [JWT authentication documentation](https://www.apollographql.com/docs/router/configuration/authn-jwt/) to clarify the behavior of the `ignore_other_prefixes` configuration option. - -By [@andrewmcgivery](https://github.com/andrewmcgivery) in https://github.com/apollographql/router/pull/5592 - - - -# [1.50.0] - 2024-07-02 - -## 🚀 Features - -### Support local persisted query manifests for use with offline licenses ([Issue #4587](https://github.com/apollographql/router/issues/4587)) - -Adds experimental support for passing [persisted query manifests](https://www.apollographql.com/docs/graphos/operations/persisted-queries/#31-generate-persisted-query-manifests) to use instead of the hosted Uplink version. - -For example: - -```router.yaml -persisted_queries: - enabled: true - log_unknown: true - experimental_local_manifests: - - ./persisted-query-manifest.json - safelist: - enabled: true - require_id: false -``` - -By [@lleadbet](https://github.com/lleadbet) in https://github.com/apollographql/router/pull/5310 - -### Support conditions on standard telemetry events ([Issue #5475](https://github.com/apollographql/router/issues/5475)) - -Enables setting [conditions](https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/conditions) on [standard events](https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/events/#standard-events). -For example: - -```yaml title="router.yaml" -telemetry: - instrumentation: - events: - router: - request: - level: info - condition: # Only log the router request if you sent `x-log-request` with the value `enabled` - eq: - - request_header: x-log-request - - "enabled" - response: off - error: error - # ... -``` - -Not supported for [batched requests](https://www.apollographql.com/docs/router/executing-operations/query-batching/). -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5476 - -### Make `status_code` available for `router_service` responses in Rhai scripts ([Issue #5357](https://github.com/apollographql/router/issues/5357)) - -Adds `response.status_code` on Rhai [`router_service`](https://www.apollographql.com/docs/router/customizations/rhai-api/#entry-point-hooks) responses. Previously, `status_code` was only available on `subgraph_service` responses. - -For example: - -```rust -fn router_service(service) { - let f = |response| { - if response.is_primary() { - print(response.status_code); - } - }; - - service.map_response(f); -} -``` - -By [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/5358 - -### Add new values for the supergraph `query` selector ([PR #5433](https://github.com/apollographql/router/pull/5433)) - -Adds support for four new values for the supergraph [`query` selector](https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/selectors#supergraph): - - `aliases`: the number of aliases in the query - - `depth`: the depth of the query - - `height`: the height of the query - - `root_fields`: the number of root fields in the query - -You can use this data to understand how your graph is used and to help determine where to set limits. - -For example: - -```router.yaml -telemetry: - instrumentation: - instruments: - supergraph: - 'query.depth': - description: 'The depth of the query' - value: - query: depth - unit: unit - type: histogram -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/5433 - -### Add the ability to drop metrics using otel views ([PR #5531](https://github.com/apollographql/router/pull/5531)) - -You can drop specific metrics if you don't want these metrics to be sent to your APM using [otel views](https://opentelemetry.io/docs/specs/otel/metrics/sdk/#view). - -```yaml title="router.yaml" -telemetry: - exporters: - metrics: - common: - service_name: apollo-router - views: - - name: apollo_router_http_request_duration_seconds # Instrument name you want to edit. You can use wildcard in names. If you want to target all instruments just use '*' - aggregation: drop - -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5531 - -### Add `operation_name` selector for router service in custom telemetry ([PR #5392](https://github.com/apollographql/router/pull/5392)) - -Adds an `operation_name` selector for the [router service](https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/selectors#router). -Previously, accessing `operation_name` was only possible through the `response_context` router service selector. - -For example: - -```yaml -telemetry: - instrumentation: - instruments: - router: - http.server.request.duration: - attributes: - graphql.operation.name: - operation_name: string -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5392 - -## 🐛 Fixes - -### Fix Cache-Control aggregation and age calculation in entity caching ([PR #5463](https://github.com/apollographql/router/pull/5463)) - -Enhances the reliability of caching behaviors in the entity cache feature by: - -- Ensuring the proper calculation of `max-age` and `s-max-age` fields in the `Cache-Control` header sent to clients. -- Setting appropriate default values if a subgraph does not provide a `Cache-Control` header. -- Guaranteeing that the `Cache-Control` header is aggregated consistently, even if the plugins is disabled entirely or on specific subgraphs. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5463 - -### Fix telemetry events when trace isn't sampled and preserve attribute types ([PR #5464](https://github.com/apollographql/router/pull/5464)) - -Improves accuracy and performance of event telemetry by: - -- Displaying custom event attributes even if the trace is not sampled -- Preserving original attribute type instead of converting it to string -- Ensuring `http.response.body.size` and `http.request.body.size` attributes are treated as numbers, not strings - -> :warning: Exercise caution if you have monitoring enabled on your logs, as attribute types may have changed. For example, attributes like `http.response.status_code` are now numbers (`200`) instead of strings (`"200"`). - - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5464 - -### Enable coprocessors for subscriptions ([PR #5542](https://github.com/apollographql/router/pull/5542)) - -Ensures that coprocessors correctly handle subscriptions by preventing skipped data from being overwritten. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5542 - -### Improve accuracy of `query_planning.plan.duration` ([PR #5](https://github.com/apollographql/router/pull/5530)) -Previously, the `apollo.router.query_planning.plan.duration` metric inaccurately included additional processing time beyond query planning. The additional time included pooling time, which is already accounted for in the metric. After this update, apollo.router.query_planning.plan.duration now accurately reflects only the query planning duration without additional processing time. - -For example, before the change, metrics reported: -```bash -2024-06-21T13:37:27.744592Z WARN apollo.router.query_planning.plan.duration 0.002475708 -2024-06-21T13:37:27.744651Z WARN apollo.router.query_planning.total.duration 0.002553958 - -2024-06-21T13:37:27.748831Z WARN apollo.router.query_planning.plan.duration 0.001635833 -2024-06-21T13:37:27.748860Z WARN apollo.router.query_planning.total.duration 0.001677167 -``` - -Post-change metrics now accurately reflect: -```bash -2024-06-21T13:37:27.743465Z WARN apollo.router.query_planning.plan.duration 0.00107725 -2024-06-21T13:37:27.744651Z WARN apollo.router.query_planning.total.duration 0.002553958 - -2024-06-21T13:37:27.748299Z WARN apollo.router.query_planning.plan.duration 0.000827 -2024-06-21T13:37:27.748860Z WARN apollo.router.query_planning.total.duration 0.001677167 -``` - -By [@xuorig](https://github.com/xuorig) and [@lrlna](https://github.com/lrlna) in https://github.com/apollographql/router/pull/5530 - -### Remove `deno_crypto` package due to security vulnerability ([Issue #5484](https://github.com/apollographql/router/issues/5484)) - -Removes [deno_crypto](https://crates.io/crates/deno_crypto) due to the vulnerability [reported in `curve25519-dalek`](https://rustsec.org/advisories/RUSTSEC-2024-0344 ). -Since the router exclusively used `deno_crypto` for generating UUIDs using the package's random number generator, this vulnerability had no impact on the router. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5483 - -### Add content-type header to failed auth checks ([Issue #5496](https://github.com/apollographql/router/issues/5496)) - -Adds `content-type` header when returning `AUTH_ERROR` from authentication service. - -By [@andrewmcgivery](https://github.com/andrewmcgivery) in https://github.com/apollographql/router/pull/5497 - -### Implement manual caching for AWS Security Token Service credentials ([PR #5508](https://github.com/apollographql/router/pull/5508)) - -In the AWS Security Token Service (STS), the `CredentialsProvider` chain includes caching, but this functionality was missing for `AssumeRoleProvider`. -This change introduces a custom `CredentialsProvider` that functions as a caching layer with these rules: - -- **Cache Expiry**: Credentials retrieved are stored in the cache based on their `credentials.expiry()` time if specified, or indefinitely (`ever`) if not. -- **Automatic Refresh**: Five minutes before cached credentials expire, an attempt is made to fetch updated credentials. -- **Retry Mechanism**: If credential retrieval fails, another attempt is scheduled after a one-minute interval. -- (Coming soon, not included in this change) **Manual Refresh**: The `CredentialsProvider` will expose a `refresh_credentials()` function. This can be manually invoked, for instance, upon receiving a `401` error during a subgraph call. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/5508 - -## 📃 Configuration - -### Align entity caching configuration structure for subgraph overrides ([PR #5474](https://github.com/apollographql/router/pull/5474)) - -Aligns the entity cache configuration structure to the same `all`/`subgraphs` override pattern found in other parts of the router configuration. For example, see the [header propagation](https://www.apollographql.com/docs/router/configuration/header-propagation) configuration. -An automated configuration migration is provided so existing usage is unaffected. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5474 - -### Restrict custom instrument `value`s to relevant stages ([PR #5472](https://github.com/apollographql/router/pull/5472)) - -Previously, custom instruments at each [request lifecycle stage](https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/instruments/#router-request-lifecycle-services) could specify unrelated values, like using `event_unit` for a router instrument. Now, only relevant values for each stage are allowed. - -Additionally, GraphQL instruments no longer need to specify `field_event`. There is no automatic migration for this change since GraphQL instruments are still experimental. - -```yaml -telemetry: - instrumentation: - instruments: - graphql: - # OLD definition of a custom instrument that measures the number of fields - my.unit.instrument: - value: field_unit # Changes to unit - - # NEW definition - my.unit.instrument: - value: unit - - # OLD - my.custom.instrument: - value: # Changes to not require `field_custom` - field_custom: - list_length: value - # NEW - my.custom.instrument: - value: - list_length: value -``` - -The following misconfiguration is now not possible: -```yaml -router_instrument: - value: - event_custom: - request_header: foo -``` - - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5472 - -## 🛠 Maintenance - -### Add cost information to protobuf traces ([PR #5430](https://github.com/apollographql/router/pull/5430)) - -Exports query cost information on Apollo protobuf traces if [`experimental_demand_control`](https://www.apollographql.com/docs/router/executing-operations/demand-control/) is enabled. Also displays exported information in GraphOS Studio. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5430 - -### Improve `xtask` release process ([PR #5275](https://github.com/apollographql/router/pull/5275)) - -Introduces a new `xtask` command to automate the release process by: -- Following the commands defined in our `RELEASE_CHECKLIST.md` file -- Storing the current state of the process in the `.release-state.json` file -- Prompting the user regularly for new info. - -These changes remove a lot of the manual environment variable setup and command copying previously required. - -Executed the new command by running `cargo xtask release start`, then calling `cargo xtask release continue` at each step. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5275 - -### Isolate usage of hyper v0.14 types for future compatibility ([PR #5175](https://github.com/apollographql/router/pull/5175)) - -Isolates usage of [hyper](https://hyper.rs/) types in response to the recent release of hyper v1.0. The new major version introduced improvements along with breaking changes. The goal is to reduce the impact of these breaking changes for the upcoming Router upgrade to the new hyper, and ensure that future upgrades are straightforward. - -This change only affects internal code and doesn't affect the router's public API or execution. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5175 - -### Introduce fuzz testing comparison between the router and monolithic subgraph ([PR #5302](https://github.com/apollographql/router/pull/5302)) - -Implements a fuzzer that can run on any router configuration to enhance router robustness and battle test new features. - -Adds a router fuzzing target, to compare the result of a query sent to to router vs a monolithic subgraph, with a supergraph schema that points all subgraphs to that same monolith. - -The monolithic subgraph consolidates code from typical subgraphs like accounts, products, reviews, and inventory (taken from the [starstuff repository](https://github.com/apollographql/starstuff/)). -This setup allows the subgraph to directly handle queries traditionally handled by individual subgraphs. -The invariant we check is that we should get the same result by sending the query to the subgraph directly or through a router that will artificially cut up the query into multiple subgraph requests, according to the supergraph schema. - -To execute it: -- Start a router using the schema `fuzz/subgraph/supergraph.graphql` -- Start the subgraph with `cargo run --release` in `fuzz/subgraph`. It will start a subgraph on port 4005. -- Start the fuzzer from the repo root with `cargo +nightly fuzz run router` - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5302 - -## 📚 Documentation - -### Add telemetry docs pages for Dynatrace ([PR #5533](https://github.com/apollographql/router/pull/5533)) - -Adds telemetry documentation for Dynatrace [metrics](https://www.apollographql.com/docs/router/configuration/telemetry/exporters/metrics/dynatrace/) and [trace exporters](https://www.apollographql.com/docs/router/configuration/telemetry/exporters/tracing/dynatrace/). - -By [@andrewmcgivery](https://github.com/andrewmcgivery) in https://github.com/apollographql/router/pull/5533 - -### Fix docs for 'exists' condition ([PR #5446](https://github.com/apollographql/router/pull/5446)) - -Fixes [documentation example](https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/conditions/#exists) for the `exists` condition. -The condition expects a single selector instead of an array. - -For example: - -```yaml -telemetry: - instrumentation: - instruments: - router: - my.instrument: - value: duration - type: counter - unit: s - description: "my description" - # ... - # This instrument will only be mutated if the condition evaluates to true - condition: - exists: - request_header: x-req-header -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5446 - -## 🧪 Experimental - -### Add experimental extended reference reporting configuration ([PR #5331](https://github.com/apollographql/router/pull/5331)) - -Adds an experimental configuration to turn on extended references in Apollo usage reports, including references to input object fields and enum values. - -This new configuration (`telemetry.apollo.experimental_apollo_metrics_reference_mode: extended`) only works when `experimental_apollo_metrics_generation_mode: new` is configured. -Apollo doesn't yet recommend these configurations in production while we continue to verify that the new functionality works as expected. - -By [@bonnici](https://github.com/bonnici) in https://github.com/apollographql/router/pull/5331 - -### Add experimental field metric reporting configuration ([PR #5443](https://github.com/apollographql/router/pull/5443)) - -Adds an experimental configuration to report field usage metrics to GraphOS Studio without [requiring subgraphs to support federated tracing (`ftv1`)](https://www.apollographql.com/docs/federation/metrics/#how-tracing-data-is-exposed-from-a-subgraph). - -The reported field usage data doesn't currently appear in GraphOS Studio. - -```yaml -telemetry: - apollo: - experimental_local_field_metrics: true -``` - -There is currently a small performance impact from enabling this feature. - -By [@tninesling](https://github.com/tninesling), [@geal](https://github.com/geal), [@bryn](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/5443 - -### Add experimental h2c communication capability for communicating with coprocessor ([Issue #5299](https://github.com/apollographql/router/issues/5299)) - -Allows HTTP/2 Cleartext (h2c) communication with coprocessors for scenarios where the networking architecture/mesh connections don't support or require TLS for outbound communications from the router. - -Introduces a new `coprocessor.client` configuration. The first and currently only option is `experimental_http2`. The available option settings are the same as the as [`experimental_http2` traffic shaping settings](https://www.apollographql.com/docs/router/configuration/traffic-shaping/#http2). - -- `disable` - disable HTTP/2, use HTTP/1.1 only -- `enable` - HTTP URLs use HTTP/1.1, HTTPS URLs use TLS with either HTTP/1.1 or HTTP/2 based on the TLS handshake -- `http2only` - HTTP URLs use h2c, HTTPS URLs use TLS with HTTP/2 -- not set - defaults to `enable` - -> [!NOTE] -> -> Configuring `experimental_http2: http2only` where the network doesn't support HTTP2 results in a failed coprocessor connection! - -By [@theJC](https://github.com/theJC) in https://github.com/apollographql/router/pull/5300 - - - -# [1.49.1] - 2024-06-19 - -> [!IMPORTANT] -> If you have enabled [Distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching), this release changes the hashing algorithm used for the cache keys. On account of this, you should anticipate additional cache regeneration cost when updating between these versions while the new hashing algorithm comes into service. - -## 🔒 Security - -### Replace dependency included in security advisory ([Issue #5484](https://github.com/apollographql/router/issues/5484)) - -This removes our use of a dependency that was cited in security advisories [RUSTSEC-2024-0344](https://rustsec.org/advisories/RUSTSEC-2024-0344) and [GHSA-x4gp-pqpj-f43q](https://github.com/advisories/GHSA-x4gp-pqpj-f43q). - -We have carefully analyzed our usages and determined that **Apollo Router is not impacted**. We only relied on different functions from the same dependency that were easily replaced. Despite lack of impact, we have opted to remove the dependency entirely out of an abundance of caution. This not only clears the warning on our side immediately, but also provides a clear path forward in the event that this shows up in any of our user's own scans. - -Users may upgrade at their own discretion, though as it was determined there is no impact, upgrading is not being explicitly recommended. - -See [the corresponding GitHub issue](https://github.com/apollographql/router/issues/5484). - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5483 - -## 🐛 Fixes - -### Update to Federation v2.8.1 ([PR #5483](https://github.com/apollographql/router/pull/5483)) - -The above security fix was in `router-bridge` which had already received a Federation version bump. This bump takes Federation to v2.8.1, which fixes a performance-related matter in *composition*. However, it does **not** impact query planning, which means this particular update is a no-op and this is simply a symbolic bump of the number itself, rather than any functional change. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5483 - -# [1.49.0] - 2024-06-18 - -> [!IMPORTANT] -> If you have enabled [Distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching), this release changes the hashing algorithm used for the cache keys. On account of this, you should anticipate additional cache regeneration cost when updating between these versions while the new hashing algorithm comes into service. - -## 🚀 Features - -### Override tracing span names using custom span selectors ([Issue #5261](https://github.com/apollographql/router/issues/5261)) - -Adds the ability to override span names by setting the `otel.name` attribute on any custom telemetry [selectors](https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/selectors/) . - -This example changes the span name to `router`: - -```yaml -telemetry: - instrumentation: - spans: - router: - otel.name: - static: router # Override the span name to router -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5365 - -### Add description and units to standard instruments ([PR #5407](https://github.com/apollographql/router/pull/5407)) - -This PR adds description and units to standard instruments available in the router. These descriptions and units have been copy pasted directly from the [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/) and are needed for better integrations with APMs. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5407 - -### Add `with_lock()` method to `Extensions` to facilitate avoidance of timing issues ([PR #5360](https://github.com/apollographql/router/pull/5360)) - -In the case that you necessitated writing custom Rust plugins, we've introduced [`with_lock()`](https://docs.rs/apollo-router/1.49.0/apollo_router/struct.ExtensionsMutex.html#method.with_lock) which explicitly restricts the lifetime of the `Extensions` lock. - -Without this method, it was too easy to run into issues interacting with the [`Extensions`](https://docs.rs/apollo-router/1.49.0-rc.1/apollo_router/struct.ExtensionsMutex.html) since we would inadvertently hold locks for too long. This was a source of bugs in the router and caused a lot of tests to be flaky. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/5360 - -### Add support for `unix_ms_now` in Rhai customizations ([Issue #5182](https://github.com/apollographql/router/issues/5182)) - -Rhai customizations can now use the `unix_ms_now()` function to obtain the current Unix timestamp in milliseconds since the Unix epoch. - -For example: - -```rhai -fn supergraph_service(service) { - let now = unix_ms_now(); -} -``` - -By [@shaikatzz](https://github.com/shaikatzz ) in https://github.com/apollographql/router/pull/5181 - -## 🐛 Fixes - -### Improve error message produced when subgraphs responses don't include an expected `content-type` header value ([Issue #5359](https://github.com/apollographql/router/issues/5359)) - -To enhance debuggability when a subgraph response lacks an expected `content-type` header value, the error message now includes additional details. - -Examples: -``` -HTTP fetch failed from 'test': subgraph response contains invalid 'content-type' header value \"application/json,application/json\"; expected content-type: application/json or content-type: application/graphql-response+json -``` - -``` -HTTP fetch failed from 'test': subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json -``` - -By [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/5223 - -### Performance improvements for demand control ([PR #5405](https://github.com/apollographql/router/pull/5405)) - -Removes unneeded logic in the hot path for our recently released public preview of [demand control](https://www.apollographql.com/docs/router/executing-operations/demand-control) feature to improve performance. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5405 - -### Skip hashing the entire schema on every query plan cache lookup ([PR #5374](https://github.com/apollographql/router/pull/5374)) - -This fixes performance issues when looking up query plans for large schemas. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5374 - -### Optimize GraphQL instruments ([PR #5375](https://github.com/apollographql/router/pull/5375)) - -When processing selectors for GraphQL instruments, heap allocations should be avoided for optimal performance. This change removes Vec allocations that were previously performed per field, yielding significant performance improvements. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5375 - -### Log metrics overflow as a warning rather than an error ([Issue #5173](https://github.com/apollographql/router/issues/5173)) - -If a metric has too high a cardinality, the following is displayed as a warning instead of an error: - -`OpenTelemetry metric error occurred: Metrics error: Warning: Maximum data points for metric stream exceeded/ Entry added to overflow` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5287 - -### Add support of `response_context` selectors for error conditions ([PR #5288](https://github.com/apollographql/router/pull/5288)) - -Provides the ability to configure custom instruments. For example: - -```yaml -http.server.request.timeout: - type: counter - value: unit - description: "request in timeout" - unit: request - attributes: - graphql.operation.name: - response_context: operation_name - condition: - eq: - - "request timed out" - - error: reason -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5288 - -### Inaccurate `apollo_router_opened_subscriptions` counter ([PR #5363](https://github.com/apollographql/router/pull/5363)) - -Fixes the `apollo_router_opened_subscriptions` counter which previously only incremented. The counter now also decrements. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5363 - -## 📃 Configuration - -## 🛠 Maintenance - -### Skip GraphOS tests when Apollo key not present ([PR #5362](https://github.com/apollographql/router/pull/5362)) - -Some tests require `APOLLO_KEY` and `APOLLO_GRAPH_REF` to execute successfully. -These are now skipped if these env variables are not present allowing external contributors to the router to successfully run the entire test suite. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5362 - -## 📚 Documentation - -### Standard instrument configuration documentation for subgraphs ([PR #5422](https://github.com/apollographql/router/pull/5422)) - -Added documentation about standard instruments available at the subgraph service level: - - * `http.client.request.body.size` - A histogram of request body sizes for requests handled by subgraphs. - * `http.client.request.duration` - A histogram of request durations for requests handled by subgraphs. - * `http.client.response.body.size` - A histogram of response body sizes for requests handled by subgraphs. - - -These instruments are configurable in `router.yaml`: - -```yaml title="router.yaml" -telemetry: - instrumentation: - instruments: - subgraph: - http.client.request.body.size: true # (default false) - http.client.request.duration: true # (default false) - http.client.response.body.size: true # (default false) -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5422 - -### Update docs frontmatter for consistency and discoverability ([PR #5164](https://github.com/apollographql/router/pull/5164)) - -Makes title case consistent for page titles and adds subtitles and meta-descriptions are updated for better discoverability. - -By [@Meschreiber](https://github.com/@Meschreiber) in https://github.com/apollographql/router/pull/5164 - -## 🧪 Experimental - -### Warm query plan cache using persisted queries on startup ([Issue #5334](https://github.com/apollographql/router/issues/5334)) - -Adds support for the router to use [persisted queries](https://www.apollographql.com/docs/graphos/operations/persisted-queries/) to warm the query plan cache upon startup using a new `experimental_prewarm_query_plan_cache` configuration option under `persisted_queries`. - -To enable: - -```yml -persisted_queries: - enabled: true - experimental_prewarm_query_plan_cache: true -``` - -By [@lleadbet](https://github.com/lleadbet) in https://github.com/apollographql/router/pull/5340 - -### Apollo reporting signature enhancements ([PR #5062](https://github.com/apollographql/router/pull/5061)) - -Adds a new experimental configuration option to turn on some enhancements for the Apollo reporting stats report key: -* Signatures will include the full normalized form of input objects -* Signatures will include aliases -* Some small normalization improvements - -This new configuration (telemetry.apollo.experimental_apollo_signature_normalization_algorithm) only works when in `experimental_apollo_metrics_generation_mode: new` mode and we don't yet recommend enabling it while we continue to verify that the new functionality works as expected. - -By [@bonnici](https://github.com/bonnici) in https://github.com/apollographql/router/pull/5062 - -### Add experimental support for sending traces to Studio via OTLP ([PR #4982](https://github.com/apollographql/router/pull/4982)) - -As the ecosystem around OpenTelemetry (OTel) has been expanding rapidly, we are evaluating a migration of Apollo's internal -tracing system to use an OTel-based protocol. - -In the short-term, benefits include: - -- A comprehensive way to visualize the router execution path in GraphOS Studio. -- Additional spans that were previously not included in Studio traces, such as query parsing, planning, execution, and more. -- Additional metadata such as subgraph fetch details, router idle / busy timing, and more. - -Long-term, we see this as a strategic enhancement to consolidate these two disparate tracing systems. -This will pave the way for future enhancements to more easily plug into the Studio trace visualizer. - -#### Configuration - -This change adds a new configuration option `experimental_otlp_tracing_sampler`. This can be used to send -a percentage of traces via OTLP instead of the native Apollo Usage Reporting protocol. Supported values: - -- `always_off` (default): send all traces via Apollo Usage Reporting protocol. -- `always_on`: send all traces via OTLP. -- `0.0 - 1.0`: the ratio of traces to send via OTLP (0.5 = 50 / 50). - -Note that this sampler is only applied _after_ the common tracing sampler, for example: - -#### Sample 1% of traces, send all traces via OTLP: - -```yaml -telemetry: - apollo: - # Send all traces via OTLP - experimental_otlp_tracing_sampler: always_on - - exporters: - tracing: - common: - # Sample traces at 1% of all traffic - sampler: 0.01 -``` - -by [@timbotnik](https://github.com/timbotnik) in https://github.com/apollographql/router/pull/4982 - -### Set Apollo metrics generation mode to `new` by default ([PR #5265](https://github.com/apollographql/router/pull/5265)) - -Changes the default value of `experimental_apollo_metrics_generation_mode` to -`new`. All metrics are showing that identical signatures are being generated in -this mode. - -By [@bonnici](https://github.com/bonnici) in https://github.com/apollographql/router/pull/5265 - -# [1.48.1] - 2024-06-10 - -## 🐛 Fixes - -### Improve error message produced when a subgraph response doesn't include an expected `content-type` header value ([Issue #5359](https://github.com/apollographql/router/issues/5359)) - -To improve a common debuggability challenge when a subgraph response doesn't contain an expected `content-type` header value, the error message produced will include additional details about the error. - -Some examples of the improved error message: - - * ``` - HTTP fetch failed from 'test': subgraph response contains invalid 'content-type' header value "application/json,application/json"; expected content-type: application/json or content-type: application/graphql-response+json - ``` - * ``` - HTTP fetch failed from 'test': subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json - ``` -By [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/5223 - -### Update `apollo-compiler` for two small improvements ([PR #5347](https://github.com/apollographql/router/pull/5347)) - -Updated our underlying `apollo-rs` dependency on our `apollo-compiler` crate to bring in two nice improvements: - -- _Fix validation performance bug_ - - Adds a cache in fragment spread validation, fixing a situation where validating a query with many fragment spreads against a schema with many interfaces could take multiple seconds to validate. - -- _Remove ariadne byte/char mapping_ - - Generating JSON or CLI reports for apollo-compiler diagnostics used a translation layer between byte offsets and character offsets, which cost some computation and memory proportional to the size of the source text. The latest version of `ariadne` allows us to remove this translation. - -By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/5347 - -## 📃 Configuration - -### Rename the telemetry selector which obtains the GraphOS operation id ([PR #5337](https://github.com/apollographql/router/pull/5337)) - -Renames a misnamed `trace_id` selector introduced in [v1.48.0](https://github.com/apollographql/router/releases/tag/v1.48.0) to the value which it actually represents which is an Apollo GraphOS operation ID, rather than a trace ID. Apologies for the confusion! Unfortunately, we aren't able to produce an Apollo GraphOS trace ID at this time. - -If you want to access this operation ID selector, here is an example of how to apply it to your tracing spans: - -```yaml -telemetry: - instrumentation: - spans: - router: - "studio.operation.id": - studio_operation_id: true -``` - -This can be useful for more easily locating the operation in [GraphOS' Insights](https://www.apollographql.com/docs/graphos/metrics/operations) feature and finding applicable traces in Studio. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5337 - -# [1.48.0] - 2024-05-29 - -## 🚀 Features - -### Demand control preview ([PR #5317](https://github.com/apollographql/router/pull/5317)) - -> ⚠️ This is a preview for an [Enterprise feature](https://www.apollographql.com/blog/platform/evaluating-apollo-router-understanding-free-and-open-vs-commercial-features/) of the Apollo Router. It requires an organization with a [GraphOS Enterprise plan](https://www.apollographql.com/pricing/). If your organization doesn't currently have an Enterprise plan, you can test out this functionality with a [free Enterprise trial](https://studio.apollographql.com/signup?type=enterprise-trial). -> -> As a preview feature, it's subject to our [Preview launch stage](https://www.apollographql.com/docs/resources/product-launch-stages/#preview) expectations and configuration and performance may change in future releases. - -Demand control allows you to control the cost of operations in the router, potentially rejecting requests that are too expensive that could bring down the Router or subgraphs. - -```yaml -# Demand control enabled, but in measure mode. -preview_demand_control: - enabled: true - # `measure` or `enforce` mode. Measure mode will analyze cost of operations but not reject them. - mode: measure - - strategy: - # Static estimated strategy has a fixed cost for elements and when set to enforce will reject - # requests that are estimated as too high before any execution takes place. - static_estimated: - # The assumed returned list size for operations. This should be set to the maximum number of items in graphql list - list_size: 10 - # The maximum cost of a single operation. - max: 1000 -``` - -Telemetry is emitted for demand control, including the estimated cost of operations and whether they were rejected or not. -Full details will be included in the documentation for demand control which will be finalized before the next release. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/5317 - -### Ability to include Apollo Studio trace ID on tracing spans ([Issue #3803](https://github.com/apollographql/router/issues/3803)), ([Issue #5172](https://github.com/apollographql/router/issues/5172)) - -Add support for a new trace ID selector kind, the `apollo` trace ID, which represents the trace ID on [Apollo GraphOS Studio](https://studio.apollographql.com/). - -An example configuration using `trace_id: apollo`: - -```yaml -telemetry: - instrumentation: - spans: - router: - "studio.trace.id": - trace_id: apollo -``` - - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5189 - -### Add ability for router to deal with query plans with contextual rewrites ([PR #5097](https://github.com/apollographql/router/pull/5097)) - -Adds the ability for the router to execute query plans with context rewrites. A context is generated by the `@fromContext` directive, and each context maps values in the collected data JSON onto a variable that's used as an argument to a field resolver. To learn more, see [Saving and referencing data with contexts](https://www.apollographql.com/docs/federation/federated-types/federated-directives#saving-and-referencing-data-with-contexts). - -⚠️ Because this feature requires a new version of federation, v2.8.0, distributed caches will need to be repopulated. - -By [@clenfest](https://github.com/clenfest) in https://github.com/apollographql/router/pull/5097 - -## 🐛 Fixes - -### Fix custom attributes for spans and histogram when used with `response_event` ([PR #5221](https://github.com/apollographql/router/pull/5221)) - -This release fixes multiple issues related to spans and selectors: - -- Custom attributes based on response_event in spans are properly added. -- Histograms using response_event selectors are properly updated. -- Static selectors that set a static value are now able to take a Value. -- Static selectors that set a static value are now set at every stage. -- The `on_graphql_error` selector is available on the supergraph stage. -- The status of a span can be overridden with the `otel.status_code` attribute. - -As an example of using these fixes, the configuration below uses spans with static selectors to mark spans as errors when GraphQL errors occur: - -```yaml -telemetry: - instrumentation: - spans: - router: - attributes: - otel.status_code: - static: error - condition: - eq: - - true - - on_graphql_error: true - supergraph: - attributes: - otel.status_code: - static: error - condition: - eq: - - true - - on_graphql_error: true -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5221 - -### Fix instrument incrementing on aborted request when condition is not fulfilled ([PR #5215](https://github.com/apollographql/router/pull/5215)) - -Previously when a telemetry instrument was dropped it would be incremented even if the associated condition was not fulfilled. For instance: - -```yaml -telemetry: - instrumentation: - instruments: - router: - http.server.active_requests: false - http.server.request.duration: false - "custom_counter": - description: "count of requests" - type: counter - unit: "unit" - value: unit - # This instrument should not be triggered as the condition is never true - condition: - eq: - - response_header: "never-received" - - static: "true" -``` - -In the case where a request was started, but the client aborted the request before the response was sent, the `response_header` would never be set to `"never-received"`, -and the instrument would not be triggered. However, the instrument would still be incremented. - -Conditions are now checked for aborted requests, and the instrument is only incremented if the condition is fulfilled. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5215 - -## 🛠 Maintenance - -### Send query planner and lifecycle metrics to Apollo ([PR #5267](https://github.com/apollographql/router/pull/5267), [PR #5270](https://github.com/apollographql/router/pull/5270)) - -To enable the performance measurement of the router's new query planner implementation, the router transmits to Apollo the following new metrics: -- `apollo.router.query_planning.*` provides metrics on the query planner that help improve the query planning implementation. -- `apollo.router.lifecycle.api_schema` provides feedback on the experimental Rust-based API schema generation. -- `apollo.router.lifecycle.license` provides metrics on license expiration that help improve the reliability of the license check mechanism. - -These metrics don't leak any sensitive data. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5267, [@goto-bus-stop](https://github.com/goto-bus-stop) - -## 📚 Documentation - -### Add Rhai API constants reference - -The Rhai API documentation now includes [a list of available constants](https://www.apollographql.com/docs/router/customizations/rhai-api/#available-constants) that are available in the Rhai runtime. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5189 -## 🧪 Experimental - -### GraphQL instruments ([PR #5215](https://github.com/apollographql/router/pull/5215), [PR #5257](https://github.com/apollographql/router/pull/5257)) - -This PR adds experimental GraphQL instruments to telemetry. - -The new instruments are configured in the following: -``` -telemetry: - instrumentation: - instruments: - graphql: - # The number of times a field was executed (counter) - field.execution: true - - # The length of list fields (histogram) - list.length: true - - # Custom counter of field execution where field name = name - "custom_counter": - description: "count of name field" - type: counter - unit: "unit" - value: field_unit - attributes: - graphql.type.name: true - graphql.field.type: true - graphql.field.name: true - condition: - eq: - - field_name: string - - "name" - - # Custom histogram of list lengths for topProducts - "custom_histogram": - description: "histogram of review length" - type: histogram - unit: "unit" - attributes: - graphql.type.name: true - graphql.field.type: true - graphql.field.name: true - value: - field_custom: - list_length: value - condition: - eq: - - field_name: string - - "topProducts" -``` - -Using the new instruments consumes significant performance resources from the router. Their performance will be improved in a future release. - -Large numbers of metrics may also be generated by using the instruments, so make sure to not incur excessively large APM costs. - -⚠ Use these instruments only in development. Don't use them in production. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5215 and https://github.com/apollographql/router/pull/5257 - - - -# [1.47.0] - 2024-05-21 - -## 🚀 Features - -### Support telemetry selectors with errors ([Issue #5027](https://github.com/apollographql/router/issues/5027)) - -The router now supports telemetry selectors that take into account the occurrence of errors. This capability enables you to create metrics, events, or span attributes that contain error messages. - -For example, you can create a counter for the number of timed-out requests for subgraphs: - - -```yaml -telemetry: - instrumentation: - instruments: - subgraph: - requests.timeout: - value: unit - type: counter - unit: request - description: "subgraph requests containing subgraph timeout" - attributes: - subgraph.name: true - condition: - eq: - - "request timed out" - - error: reason -``` - -The router also can now compute new attributes upon receiving a new event in a supergraph response. With this capability, you can fetch data directly from the supergraph response body: - -```yaml -telemetry: - instrumentation: - instruments: - acme.request.on_graphql_error: - value: event_unit - type: counter - unit: error - description: my description - condition: - eq: - - MY_ERROR_CODE - - response_errors: "$.[0].extensions.code" - attributes: - response_errors: - response_errors: "$.*" -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5022 - -### Add support for `status_code` subgraph response to Rhai ([Issue #5042](https://github.com/apollographql/router/issues/5042)) - -The router now supports `response.status_code` on the subgraph `Response` interface in Rhai. - -Examples using the response status code: - -- Converting a response status code to a string: - -```rhai -if response.status_code.to_string() == "200" { - print(`ok`); -} -``` - -- Converting a response status code to a number: - -```rhai -if parse_int(response.status_code.to_string()) == 200 { - print(`ok`); -} -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5045 - -### Add gt and lt operators for telemetry conditions ([PR #5048](https://github.com/apollographql/router/pull/5048)) - -The router supports greater than (`gt`) and less than (`lt`) operators for telemetry conditions. Similar to the `eq` operator, the configuration for both `gt` and `lt` takes two arguments as a list. The `gt` operator checks that the first argument is greater than the second, and the `lt` operator checks that the first argument is less than the second. Other conditions such as `gte`, `lte`, and `range` can be made from combinations of `gt`, `lt`, `eq`, and `all`. - -By [@tninesling](https://github.com/tninesling) in https://github.com/apollographql/router/pull/5048 - -### Expose busy timer APIs ([PR #4989](https://github.com/apollographql/router/pull/4989)) - -The router supports public APIs that native plugins can use to control when the router's busy timer is run. - -The router's busy timer measures the time spent working on a request outside of waiting for external calls, like coprocessors and subgraph calls. It includes the time spent waiting for other concurrent requests to be handled (the wait time in the executor) to show the actual router overhead when handling requests. - -The public methods are `Context::enter_active_request` and `Context::busy_time`. The result is reported in the `apollo_router_processing_time` metric - -For details on using the APIs, see the documentation for [`enter_active_request`](https://www.apollographql.com/docs/router/customizations/native#enter_active_request). - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4989 - -## 🐛 Fixes - -### Reduce JSON schema size and Router memory footprint ([PR #5061](https://github.com/apollographql/router/pull/5061)) - -As we add more features to the Router the size of the JSON schema for the router configuration file continutes to grow. In particular, adding [conditionals to telemetry](https://github.com/apollographql/router/pull/4987) in v1.46.0 significantly increased this size of the schema. This has a noticeable impact on initial memory footprint, although it does not impact service of requests. - -The JSON schema for the router configuration file has been optimized from approximately 100k lines down to just over 7k. - -This reduces the startup time of the Router and a smaller schema is more friendly for code editors. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5061 - -### Prevent query plan cache collision when planning options change ([Issue #5093](https://github.com/apollographql/router/issues/5093)) - -The router's hashing algorithm has been updated to prevent cache collisions when the router's configuration changes. - -> [!IMPORTANT] -> If you have enabled [Distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching), this release changes the hashing algorithm used for the cache keys. On account of this, you should anticipate additional cache regeneration cost when updating between these versions while the new hashing algorithm comes into service. - -The router supports multiple options that affect the generated query plans, including: -* `defer_support` -* `generate_query_fragments` -* `experimental_reuse_query_fragments` -* `experimental_type_conditioned_fetching` -* `experimental_query_planner_mode` - -If distributed query plan caching is enabled, changing any of these options results in different query plans being generated and cached. - -This could be problematic in the following scenarios: - -1. The router configuration changes and a query plan is loaded from cache which is incompatible with the new configuration. -2. Routers with different configurations share the same cache, which causes them to cache and load incompatible query plans. - -To prevent these from happening, the router now creates a hash for the entire query planner configuration and includes it in the cache key. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5100 - -### 5xx internal server error responses returned as GraphQL structured errors ([PR #5159](https://github.com/apollographql/router/pull/5159)) - -Previously, the router returned internal server errors (5xx class) as plaintext to clients. Now in this release, the router returns these 5xx errors as structured GraphQL (for example, `{"errors": [...]}`). - -Internal server errors are returned upon unexpected or unrecoverable disruptions to the GraphQL request lifecycle execution. When these occur, the underlying error messages are logged at an `ERROR` level to the router's logs. -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/5159 - -### Custom telemetry events not created when logging is disabled ([PR #5165](https://github.com/apollographql/router/pull/5165)) - -The router has been fixed to not create custom telemetry events when the log level is set to `off`. - -An example configuration with `level` set to `off` for a custom event: - -```yaml -telemetry: - instrumentation: - events: - router: - # Standard events - request: info - response: info - error: info - - # Custom events - my.disabled_request_event: - message: "my event message" - level: off # Disabled because we set the level to off - on: request - attributes: - http.request.body.size: true -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/5165 - -### Ensure that batch entry contexts are correctly preserved ([PR #5162](https://github.com/apollographql/router/pull/5162)) - -Previously, the router didn't use contexts correctly when processing batches. A representative context was chosen (the first item in a batch of items) and used to provide context functionality for all the generated responses. - -The router now correctly preserves request contexts and uses them during response creation. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/5162 - -### Validate enum values in input variables ([Issue #4633](https://github.com/apollographql/router/issues/4633)) - -The router now validates enum values provided in JSON variables. Invalid enum values result in `GRAPHQL_VALIDATION_FAILED` errors. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4753 - -### Strip dashes from `trace_id` in `CustomTraceIdPropagator` ([Issue #4892](https://github.com/apollographql/router/issues/4892)) - - -The router now strips dashes from trace IDs to ensure conformance with OpenTelemetry. - -In OpenTelemetry, trace IDs are 128-bit values represented as hex strings without dashes, and they're based on W3C's trace ID format. - -This has been applied within the router to `trace_id` in `CustomTraceIdPropagator`. - -Note, if raw trace IDs from headers are represented by uuid4 and contain dashes, the dashes should be stripped so that the raw trace ID value can be parsed into a valid `trace_id`. - - -By [@kindermax](https://github.com/kindermax) in https://github.com/apollographql/router/pull/5071 - - - -# [1.46.0] - 2024-05-07 - -## 🚀 Features - -### Entity cache preview: support queries with private scope ([PR #4855](https://github.com/apollographql/router/pull/4855)) - -**This feature is part of the work on [subgraph entity caching](https://www.apollographql.com/docs/router/configuration/entity-caching/), currently in preview.** - -The router now supports caching responses marked with `private` scope. This caching currently works only on subgraph responses without any schema-level information. - -For details about the caching behavior, see [PR #4855](https://github.com/apollographql/router/pull/4855) - - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4855 - -### Add configurable histogram buckets per metric ([Issue #4543](https://github.com/apollographql/router/issues/4543)) - -> [!NOTE] -> -> This feature was introduced in v1.40.0 but was prevented from working best until subsequent fixes in later versions. Therefore, this v1.46.0 release is the first release in which we recommend its use. - -The router supports overriding instrument settings for metrics with [OpenTelemetry views](https://opentelemetry.io/docs/concepts/signals/metrics/#views). You can use views to override default histogram buckets. - -Configure views with the `views` option. For example: - -```yaml -telemetry: - exporters: - metrics: - common: - service_name: apollo-router - views: - - name: apollo_router_http_request_duration_seconds # Instrument name you want to edit. You can use wildcard in names. If you want to target all instruments just use '*' - unit: "ms" # (Optional) override the unit - description: "my new description of this metric" # (Optional) override the description - aggregation: # (Optional) - histogram: - buckets: # Override default buckets configured for this histogram - - 1 - - 2 - - 3 - - 4 - - 5 - allowed_attribute_keys: # (Optional) Keep only listed attributes on the metric - - status -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4572 - - -### Add support of custom events defined by YAML for telemetry ([Issue #4320](https://github.com/apollographql/router/issues/4320)) - -Users can now [configure telemetry events via YAML](https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/events/) -to log that something has happened (e.g. a request had errors of a particular type) without reaching for Rhai or a custom plugin. - -Events may be triggered on conditions and can include information in the request/response pipeline as attributes. - -Here is an example of configuration: - -```yaml -telemetry: - instrumentation: - events: - router: - # Standard events - request: info - response: info - error: info - - # Custom events - my.event: - message: "my event message" - level: info - on: request - attributes: - http.response.body.size: false - # Only log when the x-log-request header is `log` - condition: - eq: - - "log" - - request_header: "x-log-request" - - supergraph: - # Custom event configuration for supergraph service ... - subgraph: - # Custom event configuration for subgraph service . -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4956 - -### Ability to ignore auth prefixes in the JWT plugin - -The router now supports a configuration to ignore header prefixes with the JWT plugin. Given that many application headers use the format of `Authorization: `, this option enables the router to process requests for specific schemes within the `Authorization` header while ignoring others. - -For example, you can configure the router to process requests with `Authorization: Bearer ` defined while ignoring others such as `Authorization: Basic `: - -```yaml title="router.yaml" -authentication: - router: - jwt: - header_name: authorization - header_value_prefix: "Bearer" - ignore_mismatched_prefix: true -``` - -If the header prefix is an empty string, this option is ignored. - -By [@lleadbet](https://github.com/lleadbet) in https://github.com/apollographql/router/pull/4718 - -### Support conditions on custom attributes for spans and a new selector for GraphQL errors ([Issue #4336](https://github.com/apollographql/router/issues/4336)) - -The router now supports conditionally adding attributes on a span and the new `on_graphql_error` selector that is set to true if the response body contains GraphQL errors. - -An example configuration using `condition` in `attributes` and `on_graphql_error`: - -```yaml -telemetry: - instrumentation: - spans: - router: - attributes: - otel.status_description: - static: "there was an error" - condition: - any: - - not: - eq: - - response_status: code - - 200 - - eq: - - on_graphql_error - - true -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4987 - -## 🐛 Fixes - -### Federation v2.7.5 ([PR #5064](https://github.com/apollographql/router/pull/5064)) - -This brings in a query planner fix released in v2.7.5 of Apollo Federation. Notably, from [its changelog](https://github.com/apollographql/federation/releases/tag/%40apollo%2Fquery-planner%402.7.5): - -- Fix issue with missing fragment definitions due to `generateQueryFragments`. ([#2993](https://github.com/apollographql/federation/pull/2993)) - - An incorrect implementation detail in `generateQueryFragments` caused certain queries to be missing fragment definitions, causing the operation to be invalid and fail early in the request life-cycle (before execution). Specifically, subsequent fragment "candidates" with the same type condition and the same length of selections as a previous fragment weren't correctly added to the list of fragments. An example of an affected query is: - - ```graphql - query { - t { - ... on A { - x - y - } - } - t2 { - ... on A { - y - z - } - } - } - ``` - - In this case, the second selection set would be converted to an inline fragment spread to subgraph fetches, but the fragment definition would be missing -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/5064 - -### Use supergraph schema to extract authorization info ([PR #5047](https://github.com/apollographql/router/pull/5047)) - -The router now uses the supergraph schema to extract authorization info, as authorization information may not be available on the query planner's subgraph schemas. This reverts the authorization changes made in [PR #4975](https://github.com/apollographql/router/pull/4975). - -By [@tninesling](https://github.com/tninesling) in https://github.com/apollographql/router/pull/5047 - -### Filter fetches added to batch during batch creation ([PR #5034](https://github.com/apollographql/router/pull/5034)) - -Previously, the router didn't filter query hashes when creating batches. This could result in failed queries because the additional hashes could incorrectly make a query appear to be committed when it wasn't actually registered in a batch. - -This release fixes this issue by filtering query hashes during batch creation. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/5034 - -### Use `subgraph.name` attribute instead of `apollo.subgraph.name` ([PR #5012](https://github.com/apollographql/router/pull/5012)) - -In the router v1.45.0, subgraph name mapping didn't work correctly in the Datadog exporter. - -The Datadog exporter does some explicit mapping of attributes and was using a value `apollo.subgraph.name` that the latest versions of the router don't use. The correct choice is `subgraph.name`. - -This release updates the mapping to reflect the change and fixes subgraph name mapping for Datadog. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/5012 - -## 📚 Documentation - -### Document traffic shaping default configuration ([PR #4953](https://github.com/apollographql/router/pull/4953)) - -The documentation for [configuring traffic shaping](https://www.apollographql.com/docs/router/configuration/traffic-shaping#configuration) has been updated to clarify that it's enabled by default with preset values. This setting has been the default since PR [#3330](https://github.com/apollographql/router/pull/3330), which landed in [v1.23.0](https://github.com/apollographql/router/releases/tag/v1.23.0). - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4953 - -## 🧪 Experimental - -### Experimental type conditioned fetching ([PR #4748](https://github.com/apollographql/router/pull/4748)) - -This release introduces an experimental configuration to enable type-conditioned fetching. - -Previously, when querying a field that was in a path of two or more unions, the query planner wasn't able to handle different selections and would aggressively collapse selections in fetches. This resulted in incorrect plans. - -Enabling the `experimental_type_conditioned_fetching` option can fix this issue by configuring the query planner to fetch with type conditions. - - -```yaml -experimental_type_conditioned_fetching: true # false by default -``` - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/4748 - - - -# [1.45.1] - 2024-04-26 - -## 🐛 Fixes - -### Correct v1.44.0 regression in query plan cache ([PR #5028](https://github.com/apollographql/router/pull/5028)) - -Correct a critical regression that was introduced in [v1.44.0](https://github.com/apollographql/router/pull/4883) which could lead to execution of an incorrect query plan. This issue only affects Routers that use [distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching), enabled via the `supergraph.query_planning.cache.redis.urls` configuration property. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/5028 - -### Use entire schema when hashing an introspection query ([Issue #5006](https://github.com/apollographql/router/issues/5006)) - -Correct a _different_ hashing bug which impacted introspection queries which was also introduced in [v1.44.0](https://github.com/apollographql/router/pull/4883). This other hashing bug failed to account for introspection queries, resulting in introspection results being misaligned to the current schema. This issue only affects Routers that use [distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching), enabled via the `supergraph.query_planning.cache.redis.urls` configuration property. - -This release fixes the hashing mechanism by adding the schema string to hashed data if an introspection field is encountered. As a result, the entire schema is taken into account and the correct introspection result is returned. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/5007 - -### Fix subgraph name mapping of Datadog exporter ([PR #5012](https://github.com/apollographql/router/pull/5012)) - -Previously in the router v1.45.0, subgraph name mapping didn't work correctly in the router's Datadog exporter. The exporter used the incorrect value `apollo.subgraph.name` for mapping attributes when it should have used the value `subgraph.name`. This issue has been fixed in this release. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/5012 - - -# [1.45.0] - 2024-04-22 - -> **Warning** -> -> **This version has a critical bug impacting users of [distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching). See the _Fixes_ in [v1.45.1](https://github.com/apollographql/router/releases/tag/v1.45.1) for details. We highly recommend using v1.45.1 or v1.43.2 over v1.45.0.** - -## 🚀 Features - -### Query validation process with Rust ([PR #4551](https://github.com/apollographql/router/pull/4551)) - -The router has been updated with a new Rust-based query validation process using `apollo-compiler` from the `apollo-rs` project. It replaces the Javascript implementation in the query planner. It improves query planner performance by moving the validation out of the query planner and into the router service, which frees up space in the query planner cache. - -Because validation now happens earlier in the router service and not in the query planner, error paths in the query planner are no longer encountered. Some messages in error responses returned from invalid queries should now be more clear. - -We've tested the new validation process by running it for months in production, concurrently with the JavaScript implementation, and have now completely transitioned to the Rust-based implementation. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4551 - -### Add support for SHA256 hashing in Rhai ([Issue #4939](https://github.com/apollographql/router/issues/4939)) - -The router supports a new `sha256` module to create SHA256 hashes in Rhai scripts. The module supports the `sha256::digest` function. - -An example script that uses the module: - -```rs -fn supergraph_service(service){ - service.map_request(|request|{ - log_info("hello world"); - let sha = sha256::digest("hello world"); - log_info(sha); - }); -} -``` - - -By [@lleadbet](https://github.com/lleadbet) in https://github.com/apollographql/router/pull/4940 - -### Subgraph support for query batching ([Issue #2002](https://github.com/apollographql/router/issues/2002)) - -As an extension to the ongoing work to support [client-side query batching in the router](https://github.com/apollographql/router/issues/126), the router now supports batching of subgraph requests. Each subgraph batch request retains the same external format as a client batch request. This optimization reduces the number of round-trip requests from the router to subgraphs. - -Also, batching in the router is now a generally available feature: the `experimental_batching` router configuration option has been deprecated and is replaced by the `batching` option. - -Previously, the router preserved the concept of a batch until a `RouterRequest` finished processing. From that point, the router converted each batch request item into a separate `SupergraphRequest`, and the router planned and executed those requests concurrently within the router, then reassembled them into a batch of `RouterResponse` to return to the client. Now with the implementation in this release, the concept of a batch is extended so that batches are issued to configured subgraphs (all or named). Each batch request item is planned and executed separately, but the queries issued to subgraphs are optimally assembled into batches which observe the query constraints of the various batch items. - -To configure subgraph batching, you can enable `batching.subgraph.all` for all subgraphs. You can also enable batching per subgraph with `batching.subgraph.subgraphs.*`. For example: - -```yaml -batching: - enabled: true - mode: batch_http_link - subgraph: - # Enable batching on all subgraphs - all: - enabled: true -``` - -```yaml -batching: - enabled: true - mode: batch_http_link - subgraph: - # Disable batching on all subgraphs - all: - enabled: false - # Configure (override) batching support per subgraph - subgraphs: - subgraph_1: - enabled: true - subgraph_2: - enabled: true -``` - -Note: `all` can be overridden by `subgraphs`. This applies in general for all router subgraph configuration options. - -To learn more, see [query batching in Apollo docs](https://www.apollographql.com/docs/router/executing-operations/query-batching/). - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4661 - -## 🐛 Fixes - -### Update `rustls` to v0.21.11, the latest v0.21.x patch ([PR #4993](https://github.com/apollographql/router/pull/4993)) - -While the Router **does** use `rustls`, [RUSTSEC-2024-0336] (also known as [CVE-2024-32650] and [GHSA-6g7w-8wpp-frhj]) **DOES NOT affect the Router** since it uses `tokio-rustls` which is specifically called out in the advisory as **unaffected**. - -Despite the lack of impact, we update `rustls` version v0.21.10 to [rustls v0.21.11] which includes a patch. - -[RUSTSEC-2024-0336]: https://rustsec.org/advisories/RUSTSEC-2024-0336.html -[CVE-2024-32650]: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-32650 -[GHSA-6g7w-8wpp-frhj]: https://github.com/advisories/GHSA-6g7w-8wpp-frhj -[rustls v0.21.11]: https://github.com/rustls/rustls/releases/tag/v%2F0.21.11 - -By [@tninesling](https://github.com/tninesling) in https://github.com/apollographql/router/pull/4993 - -### Performance improvements for Apollo usage report field generation ([PR 4951](https://github.com/apollographql/router/pull/4951)) - -The performance of generating Apollo usage report signatures, stats keys, and referenced fields has been improved. - -By [@bonnici](https://github.com/bonnici) in https://github.com/apollographql/router/pull/4951 - -### Apply alias rewrites to arrays ([PR #4958](https://github.com/apollographql/router/pull/4958)) - -The automatic aliasing rules introduced in [#2489](https://github.com/apollographql/router/pull/2489) to support `@interfaceObject` are now properly applied to lists. - -By [@o0ignition0o](https://github.com/o0ignition0o) in https://github.com/apollographql/router/pull/4958 - -### Fix compatibility of coprocessor metric creation ([PR #4930](https://github.com/apollographql/router/pull/4930)) - -Previously, the router's execution stage created coprocessor metrics differently than other stages. This produced metrics with slight incompatibilities. - -This release fixes the issue by creating coprocessor metrics in the same way as all other stages. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4930 - -## 📚 Documentation - -### Documentation updates for caching and metrics instruments ([PR #4872](https://github.com/apollographql/router/pull/4872)) - -Router documentation has been updated for a couple topics: -- [Performance improvements vs. stability concerns](https://www.apollographql.com/docs/router/configuration/in-memory-caching#performance-improvements-vs-stability) when using the router's operation cache -- [Overview of standard and custom metrics instruments](https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/instruments) - -By [@smyrick](https://github.com/smyrick) in https://github.com/apollographql/router/pull/4872 - -## 🧪 Experimental - -### Experimental: Introduce a pool of query planners ([PR #4897](https://github.com/apollographql/router/pull/4897)) - -The router supports a new experimental feature: a pool of query planners to parallelize query planning. - -You can configure query planner pools with the `supergraph.query_planning.experimental_parallelism` option: - -```yaml -supergraph: - query_planning: - experimental_parallelism: auto # number of available CPUs -``` - -Its value is the number of query planners that run in parallel, and its default value is `1`. You can set it to the special value `auto` to automatically set it equal to the number of available CPUs. - -You can discuss and comment about query planner pools in this [GitHub discussion](https://github.com/apollographql/router/discussions/4917). - -By [@xuorig](https://github.com/xuorig) and [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/4897 - -### Experimental: Rust implementation of Apollo usage report field generation ([PR 4796](https://github.com/apollographql/router/pull/4796)) - -The router supports a new experimental Rust implementation for generating the stats report keys and referenced fields that are sent in Apollo usage reports. This implementation is one part of the effort to replace the router-bridge with native Rust code. - -The feature is configured with the `experimental_apollo_metrics_generation_mode` setting. We recommend that you use its default value, so we can verify that it generates the same payloads as the previous implementation. - -By [@bonnici](https://github.com/bonnici) in https://github.com/apollographql/router/pull/4796 - -# [1.44.0] - 2024-04-12 - -> **Warning** -> -> **This version has a critical bug impacting users of [distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching). See the _Fixes_ in [v1.45.1](https://github.com/apollographql/router/releases/tag/v1.45.1) for details. We highly recommend using v1.45.1 or v1.43.2 over v1.44.0.** - - -## 🚀 Features - -### Add details to `router service call failed` errors ([Issue #4899](https://github.com/apollographql/router/issues/4899)) - -The router now includes more details in `router service call failed` error messages to improve their understandability and debuggability. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4900 - -### Support exporting metrics via OTLP HTTP ([Issue #4559](https://github.com/apollographql/router/issues/4559)) - -In addition to exporting metrics via OTLP/gRPC, the router now supports exporting metrics via OTLP/HTTP. - -You can enable exporting via OTLP/HTTP by setting the `protocol` key to `http` in your `router.yaml`: - -``` -telemetry: - exporters: - metrics: - otlp: - enabled: true - protocol: http -``` - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4842 - -### Add support of instruments in configuration for telemetry ([Issue #4319](https://github.com/apollographql/router/issues/4319)) - -Add support for custom and standard instruments through the configuration file. You'll be able to add your own custom metrics just using the configuration file. They may: -- be conditional -- get values from selectors, for instance headers, context or body -- have different types like `histogram` or `counter`. - -Example: - -```yaml title="router.yaml" -telemetry: - instrumentation: - instruments: - router: - http.server.active_requests: true - acme.request.duration: - value: duration - type: counter - unit: kb - description: "my description" - attributes: - http.response.status_code: true - "my_attribute": - response_header: "x-my-header" - - supergraph: - acme.graphql.requests: - value: unit - type: counter - unit: count - description: "supergraph requests" - - subgraph: - acme.graphql.subgraph.errors: - value: unit - type: counter - unit: count - description: "my description" -``` - -[Documentation](https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/instruments) - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4771 - -### Reuse cached query plans across schema updates ([Issue #4834](https://github.com/apollographql/router/issues/4834)) - -The router now supports an experimental feature to reuse schema aware query hashing—introduced with the [entity caching](https://www.apollographql.com/docs/router/configuration/entity-caching/) feature—to cache query plans. It reduces the amount of work when reloading the router. The hash of the cache stays the same for a query across schema updates if the schema updates don't change the query. If query planner [cache warm-up](https://www.apollographql.com/docs/router/configuration/in-memory-caching/#cache-warm-up) is configured, the router can reuse previous cache entries for which the hash does not change, consequently reducing both CPU usage and reload duration. - -You can enable reuse of cached query plans by setting the `supergraph.query_planning.experimental_reuse_query_plans` option: - -```yaml title="router.yaml" -supergraph: - query_planning: - warmed_up_queries: 100 - experimental_reuse_query_plans: true -``` - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4883 - -### Set a default TTL for query plans ([Issue #4473](https://github.com/apollographql/router/issues/4473)) - -The router has updated the default TTL for query plan caches. The new default TTL is 30 days. With the previous default being an infinite duration, the new finite default better supports the fact that the router updates caches with schema updates. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4588 - -## 🐛 Fixes - -### Replace null separator in cache key with `:` to match Redis convention ([PR #4886](https://github.com/apollographql/router/pull/4886)) - -To conform with Redis convention, the router now uses `:` instead of null as the separator in cache keys. This conformance helps to properly display cache keys in nested form in Redis clients. - -This PR (#4886) updates the separator for APQ cache keys. Another PR (#4583) updates the separator for query plan cache keys. - -By [@tapaderster](https://github.com/tapaderster) in https://github.com/apollographql/router/pull/4886 - -### Make 'router' user the owner of the docker image's /dist/data directory ([PR #4898](https://github.com/apollographql/router/pull/4898)) - -Since we made our images more secure, we run our router process as user 'router'. If we are running under 'heaptrack', e.g.: in a debug image, then we cannot write to /dist/data because it is owned by 'root'. - -This changes the ownership of /dist/data from 'root' to 'router' to allow writes to succeed. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4898 - -### Accept `extensions: null` in a GraphQL request ([Issue #3388](https://github.com/apollographql/router/issues/3388)) - -In GraphQL requests, `extensions` is an optional map. -Passing an explicit `null` was incorrectly considered a parse error. -Now it is equivalent to omiting that field entirely, or to passing an empty map. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/4911 - -### Require Cache-Control header for entity cache ([Issue #4880](https://github.com/apollographql/router/issues/4880)) - -Previously, the router's entity cache plugin didn't use a subgraph's `Cache-Control` header to decide whether to store a response. Instead, it cached all responses. - -Now, the router's entity cache plugin expects a `Cache-Control` header from a subgraph. If a subgraph does not provide it, the aggregated `Cache-Control` header sent to the client will contain `no-store`. - -Additionally, the router now verifies that a TTL is configured for all subgraphs, either globally or for each subgraph configuration. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4882 - -### Helm: include all standard labels in pod spec ([PR #4862](https://github.com/apollographql/router/pull/4862)) - -The templates for the router's Helm chart have been updated so that the `helm.sh/chart`, `app.kubernetes.io/version`, and `app.kubernetes.io/managed-by` labels are now included on pods, as they already were for all other resources created by the Helm chart. - -The specific change to the template is that the pod spec template now uses the `router.labels` template function instead of the `router.selectorLabels` template function. This allows you to remove a label from the selector without removing it from resource metadata by overriding the `router.selectorLabels` and `router.labels` functions and moving the label from the former to the latter. - -By [@glasser](https://github.com/glasser) in https://github.com/apollographql/router/pull/4862 - -### Persisted queries return 4xx errors ([PR #4887](https://github.com/apollographql/router/pull/4887)) - -Previously, sending an invalid persisted query request could return a 200 status code to the client when they should have returned errors. These requests now return errors as 4xx status codes: - -- Sending a PQ ID that is unknown returns 404 (Not Found). -- Sending freeform GraphQL when no freeform GraphQL is allowed returns - 400 (Bad Request). -- Sending both a PQ ID and freeform GraphQL in the same request (if the - APQ feature is not also enabled) returns 400 (Bad Request). -- Sending freeform GraphQL that is not in the safelist when the safelist - is enabled returns (403 Forbidden). -- A particular internal error that shouldn't happen returns 500 (Internal - Server Error). - - By [@glasser](https://github.com/glasser) in https://github.com/apollographql/router/pull/4887 - -## 📃 Configuration - -### Add `generate_query_fragments` configuration option ([PR #4885](https://github.com/apollographql/router/pull/4885)) - -Add a new `supergraph` configuration option `generate_query_fragments`. When set to `true`, the query planner will extract inline fragments into fragment definitions before sending queries to subgraphs. This can significantly reduce the size of the query sent to subgraphs, but may increase the time it takes to plan the query. Note that this option and `reuse_query_fragments` are mutually exclusive; if both are set to `true`, `generate_query_fragments` will take precedence. - -An example router configuration: - -```yaml title="router.yaml" -supergraph: - generate_query_fragments: true -``` - -By [@trevor-scheer](https://github.com/trevor-scheer) in https://github.com/apollographql/router/pull/4885 - -## 🛠 Maintenance - -### Fix integration test warning on macOS ([PR #4919](https://github.com/apollographql/router/pull/4919)) - -Previously, integration tests of the router on macOS could produce the warning messages: - -``` -warning: unused import: `common::Telemetry` - --> apollo-router/tests/integration/mod.rs:4:16 - | -4 | pub(crate) use common::Telemetry; - | ^^^^^^^^^^^^^^^^^ - | - = note: `#[warn(unused_imports)]` on by default - -warning: unused import: `common::ValueExt` - --> apollo-router/tests/integration/mod.rs:5:16 - | -5 | pub(crate) use common::ValueExt; - | ^^^^^^^^^^^^^^^^ -``` - -That issue is now resolved. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4919 - - - -# [1.43.2] - 2024-04-03 - -## 🐛 Fixes - -### Security fix: update h2 dependency - -References: -- https://rustsec.org/advisories/RUSTSEC-2024-0332 -- https://seanmonstar.com/blog/hyper-http2-continuation-flood/ -- https://www.kb.cert.org/vuls/id/421644 - -The router's performance could be degraded when receiving a flood of HTTP/2 CONTINUATION frames, when the Router is set up to terminate TLS for client connections. - -By [@geal](https://github.com/geal) - - - -# [1.43.1] - 2024-03-29 - -## 🚀 Features - -### Logs can display trace and span IDs ([PR #4823](https://github.com/apollographql/router/pull/4823)) - -To enable correlation between traces and logs, `trace_id` and `span_id` can now be displayed in log messages. - -For JSON logs, trace and span IDs are displayed by default: -```json -{"timestamp":"2024-03-19T15:37:41.516453239Z","level":"INFO","trace_id":"54ac7e5f0e8ab90ae67b822e95ffcbb8","span_id":"9b3f88c602de0ceb","message":"Supergraph GraphQL response", ...} -``` - -For text logs, trace and span IDs aren't displayed by default: -```log -2024-03-19T15:14:46.040435Z INFO trace_id: bbafc3f048b6137375dd78c10df18f50 span_id: 40ede28c5df1b5cc router{ -``` - -To configure, set the `display_span_id` and `display_trace_id` options in the logging exporter configuration. - -JSON (defaults to true): -```yaml -telemetry: - exporters: - logging: - stdout: - format: - json: - display_span_id: true - display_trace_id: true -``` - -Text (defaults to false): -```yaml -telemetry: - exporters: - logging: - stdout: - format: - text: - display_span_id: false - display_trace_id: false -``` - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4823 - -### Count errors with apollo.router.graphql_error metrics ([Issue #4749](https://github.com/apollographql/router/issues/4749)) - -The router supports a new metric, `apollo.router.graphql_error`, that is a counter of GraphQL errors. It has a `code` attribute to differentiate counts of different error codes. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4751 - -### Expose operation signature to plugins ([Issue #4558](https://github.com/apollographql/router/issues/4558)) - -The router now exposes [operation signatures](https://www.apollographql.com/docs/graphos/metrics/operation-signatures) to plugins with the context key `apollo_operation_signature`. The exposed operation signature is the string representation of the full signature. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4864 - -### Experimental logging of broken pipe errors ([PR #4870](https://github.com/apollographql/router/pull/4870)) - -The router can now emit a log message each time a client closes its connection early, which can help you debug issues with clients that close connections before the server can respond. - -This feature is disabled by default but can be enabled by setting the `experimental_log_broken_pipe` option to `true`: - -```yaml title="router.yaml" -supergraph: - experimental_log_on_broken_pipe: true -``` - -Note: users with internet-facing routers will likely not want to opt in to this log message, as they have no control over the clients. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4770 and [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4870 - -## 🐛 Fixes - -### Entity cache: fix support for Redis cluster ([PR #4790](https://github.com/apollographql/router/pull/4790)) - -In a Redis cluster, entities can be stored in different nodes, and a query to one node should only refer to the keys it manages. This is challenging for the Redis MGET operation, which requests multiple entities in the same request from the same node. - -This fix splits the MGET query into multiple MGET calls, where the calls are grouped by key hash to ensure each one gets to the corresponding node, and then merges the responses in the correct order. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4790 - -### Give spans their proper parent in the plugin stack ([Issue #4827](https://github.com/apollographql/router/issues/4827)) - -Previously, spans in plugin stacks appeared incorrectly as siblings rather than being nested. This was problematic when displaying traces or accounting for time spent in Datadog. - -This release fixes the issue, and plugin spans are now correctly nested within each other. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4877 - -### Fix(telemetry): keep consistency between tracing OTLP endpoint ([Issue #4798](https://github.com/apollographql/router/issues/4798)) - -Previously, when [exporting tracing data using OTLP](https://www.apollographql.com/docs/router/configuration/telemetry/exporters/tracing/otlp/#otlp-configuration) using only the base address of the OTLP endpoint, the router succeeded with gRPC but failed with HTTP due to [this bug](https://github.com/open-telemetry/opentelemetry-rust/issues/1618) in `opentelemetry-rust`. - -This release implements a workaround for the bug, where you must specify the correct HTTP path: - -```yaml -telemetry: - exporters: - tracing: - otlp: - enabled: true - endpoint: "http://localhost:4318" - protocol: http -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4801 - -### Execute the entire request pipeline if the client closed the connection ([Issue #4569](https://github.com/apollographql/router/issues/4569)), ([Issue #4576](https://github.com/apollographql/router/issues/4576)), ([Issue #4589](https://github.com/apollographql/router/issues/4589)), ([Issue #4590](https://github.com/apollographql/router/issues/4590)), ([Issue #4611](https://github.com/apollographql/router/issues/4611)) - -The router now ensures that the entire request handling pipeline is executed when the client closes the connection early to allow telemetry, Rhai scripts, or coprocessors to complete their tasks before canceling. - -Previously, when a client canceled a request, the entire execution was dropped, and parts of the router, including telemetry, couldn't run to completion. Now, the router executes up to the first response event (in the case of subscriptions or `@defer` usage), adds a `499` status code to the response, and skips the remaining subgraph requests. - -Note that this change will report more requests to Studio and the configured telemetry, and it will appear like a sudden increase in errors because the failing requests were not previously reported. - -You can keep the previous behavior of immediately dropping execution for canceled requests by setting the `early_cancel` option: - -```yaml -supergraph: - early_cancel: true -``` - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4770 - -### `null` extensions incorrectly disallowed on request ([Issue #4856](https://github.com/apollographql/router/issues/4856)) - -Previously the router incorrectly rejected requests with `null` extensions, which are allowed according to the [GraphQL over HTTP specification](https://graphql.github.io/graphql-over-http/draft/#sel-EALFPCCBCEtC37P). - -This issue has been fixed, and the router now allows requests with `null` extensions, like the following: - -```json -{ - "query": "{ topProducts { upc name reviews { id product { name } author { id name } } } }", - "variables": { - "date": "2022-01-01T00:00:00+00:00" - }, - "extensions": null -} -``` - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4865 - -### Fix external extensibility error log messages ([PR #4869](https://github.com/apollographql/router/pull/4869)) - -Previously, log messages for external extensibility errors from `execution` and `supergraph` responses were incorrectly logged as `router` responses. This issue has been fixed. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4869 - -### Remove invalid payload on graphql-ws Ping message ([Issue #4852](https://github.com/apollographql/router/issues/4852)) - -Previously, the router sent a string as a `Ping` payload, but that was incompatible with the [graphql-ws specification](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md#ping), which specifies that the payload is optional and should be an object or null. - -To ensure compatibility, the router now sends no payload for `Ping` messages. - -By [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/4852 - - - -# [1.43.0] - 2024-03-21 - -## 🚀 Features - -### Support configurable heartbeat for subscriptions using the WebSocket protocol ([Issue #4621](https://github.com/apollographql/router/issues/4621)) - -To support GraphQL Subscription WebSocket implementations such as [DGS](https://netflix.github.io/dgs/) that drop idle connections by design, the router adds the ability to configure a heartbeat to keep active connections alive. - -An example router configuration: - -```yaml -subscription: - mode: - passthrough: - all: - path: /graphql - heartbeat_interval: enable # Optional -``` - -By [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/4802 - -### Unix socket support for subgraphs ([Issue #3504](https://github.com/apollographql/router/issues/3504)) - -> ⚠️ This is an [Enterprise feature](https://www.apollographql.com/blog/platform/evaluating-apollo-router-understanding-free-and-open-vs-commercial-features/) of the Apollo Router. It requires an organization with a [GraphOS Enterprise plan](https://www.apollographql.com/pricing/). -> -> If your organization doesn't currently have an Enterprise plan, you can test out this functionality by signing up for a free Enterprise trial. - -The router now supports Unix sockets for subgraph connections by specifying URLs in the `unix:///path/to/router.sock` format in the schema, in addition to coming a valid URL option within [the existing `override_subgraph_url` configuration](https://www.apollographql.com/docs/router/configuration/overview/#subgraph-routing-urls). The router uses Unix stream-oriented sockets (not datagram-oriented). It supports compression but not TLS. - -Due to the lack of standardization of Unix socket URLs (and lack of support in the common URL types in Rust) a transformation is applied to to the socket path to parse it: the host is encoded in hexadecimal and stored in the `authority` part. This will have no consequence on the way the router functions, but [subgraph services](https://www.apollographql.com/docs/router/customizations/overview/#the-request-lifecycle) will receive URLs with the hex-encoded host. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4757 - -### Add an option to refresh expiration on Redis GET ([Issue #4473](https://github.com/apollographql/router/issues/4473)) - -This adds the option to refresh the time-to-live (TTL) on Redis entries when they are accessed. We want the query plan cache to act like an LRU cache (least-recently used), so if a TTL is set in its Redis configuration, it should reset every time it is accessed. - -While the option is also available for APQ, it's disabled for entity caching because that cache manages TTL directly. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4604 - -### Helm: Support configuring `ContainerResource` on Horizontal Pod Autoscaler (HPA) targets ([PR #4776](https://github.com/apollographql/router/pull/4776)) - -The router supports configuration of the [`ContainerResource` type metric](https://kubernetes.io/blog/2023/05/02/hpa-container-resource-metric/) on Horizontal Pod Autoscaler (HPA) targets in the Helm chart with Kubernetes v1.27 or later. - -By [@caugustus](https://github.com/caugustus) in https://github.com/apollographql/router/pull/4776 - -## 🐛 Fixes - -### Fix chunk formatting in multipart protocol ([Issue #4634](https://github.com/apollographql/router/issues/4634)) - -Previously, when sending a stream of chunks for HTTP multipart, the router finished each chunk by appending it with `\r\n`. -Now, the router doesn't append `\r\n` to the current chunk but instead prepends it to the next chunk. This enables the router to close a stream with the correct final boundary by appending `--\r\n` directly to the last chunk. - -This PR changes the way we're sending chunks in the stream. Instead of finishing the chunk with `\r\n` we don't send this at the end of our current chunk but instead at the beginning of the next one. For the end users nothing changes but it let us to close the stream with the right final boundary by appending `--\r\n` directly to the last chunk. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4681 - -### Zipkin service name not populated ([Issue #4807](https://github.com/apollographql/router/issues/4807)) - -The Zipkin trace exporter now respects service name configuration from YAML or environment variables. - -For instance to set the service name to `my-app`, you can use the following configuration in your `router.yaml` file: - -```yaml -telemetry: - exporters: - tracing: - common: - service_name: my-app - zipkin: - enabled: true - endpoint: default -``` - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4816 - -## 🛠 Maintenance - -### Preallocate response formatting output ([PR #4775](https://github.com/apollographql/router/pull/4775)) - -To improve runtime performance, an internal change to the router's `format_response` now preallocates the output object. - -By [@xuorig](https://github.com/xuorig) in https://github.com/apollographql/router/pull/4775 - -# [1.42.0] - 2024-03-12 - -## 🚀 Features - -### Add headers to the JWKS download request ([Issue #4651](https://github.com/apollographql/router/issues/4651)) - -The router supports the new `authentication.router.jwt.jwks.headers` option for setting static headers on HTTP requests to download a JWKS from an identity provider. - -For details, see the [JWKS configuration option](https://www.apollographql.com/docs/router/configuration/authn-jwt#jwks). - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4688 - -### Support loading JWT from other sources ([PR #4711](https://github.com/apollographql/router/pull/4711)) - -The router supports the new `authentication.router.jwt.sources` option. It enables cookies as an alternative source for tokens and allows multiple alternative sources. - -For details, see the [sources configuration option](https://www.apollographql.com/docs/router/configuration/authn-jwt#sources). - - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4711 - -## 🐛 Fixes - -### Attach `dd.trace_id` to JSON formatted log messages ([PR #4764](https://github.com/apollographql/router/pull/4764)) - -To enable correlation between DataDog tracing and logs, `dd.trace_id` must appear as a span attribute on the root of each JSON formatted log message. -Once you configure the `dd.trace_id` attribute in router.yaml, it will automatically be extracted from the root span and attached to the logs: - -```yaml title="router.yaml" -telemetry: - instrumentation: - spans: - mode: spec_compliant - router: - attributes: - dd.trace_id: true -``` - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4764 - -# [1.41.1] - 2024-03-08 - -> [!NOTE] -> -> v1.41.1 replaces a failed publish of v1.41.0. The version number had to be moved from v1.41.0 to v1.41.1, but the release is otherwise the same. Apologies for the confusion! - -## 🚀 Features - -### Entity caching: Add tracing spans around Redis interactions ([PR #4667](https://github.com/apollographql/router/pull/4667)) - -This adds `cache_lookup` and `cache_store` spans to traces which show Redis calls related to our recently announced [entity caching](https://www.apollographql.com/docs/router/configuration/entity-caching/) feature. This also changes the behavior slightly so that storing in Redis does not stop the execution of the rest of the query. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4667 - -### Use Gzip compression when downloading Persisted Query manifests ([PR #4622](https://github.com/apollographql/router/pull/4622)) - -Router will now request Gzip compression when downloading Persisted Query manifests for improved network efficiency. - -By [@glasser](https://github.com/glasser) in https://github.com/apollographql/router/pull/4622 - -### Redis: add a fail open option ([Issue #4334](https://github.com/apollographql/router/issues/4334)) - -This option configures the Router's behavior in case it cannot connect to Redis: -- By default, the router will start and all requests will be handled in a degraded state. -- Alternatively, this option can be configured to prevent the router from starting if it can't connect to Redis. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4534 - -## 🐛 Fixes - -### Default header now correctly set when `experimental_response_trace_id` is enabled ([Issue #4699](https://github.com/apollographql/router/issues/4699)) - -When configuring the `experimental_response_trace_id` without an explicit header it now correctly takes the default one `apollo-trace-id`. - -Example of configuration: - -```yaml -telemetry: - exporters: - tracing: - experimental_response_trace_id: - enabled: true -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4702 - -# [1.41.0] - 2024-03-08 - -> [!NOTE] -> -> The release of v1.41.0 failed unexpectedly late in the deployment process due to a preventable publishing failure. The release has been yanked from Crates.io on account of not being published successfully across all deployment targets. This release is fully replaced by v1.41.1. Apologies for the confusion! - -# [1.40.2] - 2024-03-06 - -## 🔒 Security - -### Apply `limits.http_max_request_bytes` on streaming request body decompression ([PR #4759](https://github.com/apollographql/router/pull/4759)) - -This release fixes a Denial-of-Service (DoS) type vulnerability which exists in affected versions of the Router according to our [published security advistory](https://github.com/apollographql/router/security/advisories/GHSA-cgqf-3cq5-wvcj). The fix changes the evaluation of the `limits.http_max_request_bytes` configuration to take place on a stream of bytes, allowing it to be applied to compressed HTTP payloads, prior to decompression. Previously, the limit was only being applied after the entirety of the compressed payload was decompressed, which could result in significant memory consumption which exceeded configured expectations while compressed payloads were expanded. - -## 🐛 Fixes - -### Re-activate the macOS Intel builder ([PR #4723](https://github.com/apollographql/router/pull/4723)) - -We have re-activated macOS Intel (x86) builds in CircleCI, despite their upcoming deprecation, while we take a different approach to solving this and maintaining Intel support for the time-being. This became necessary since cross-compiling the router from ARM to x86 resulted in issues with V8 snapshots and runtime issues on the macOS Intel binaries produced by those Apple Silicon build machines. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4723 - -# [1.40.1] - 2024-02-16 - -## 🐛 Fixes - -### Propagate tracing headers even when not sampling a trace ([Issue #4544](https://github.com/apollographql/router/issues/4544)) - -When the router was configured to sample only a portion of the trace, either through a ratio or using parent based sampling, and when trace propagation was configured, if a trace was not sampled, the router did not send the propagation headers to the subgraph. The subgraph was then unable to decide whether to record the trace or not. Now we make sure that trace headers will be sent even when a trace is not sampled. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4609 - -### Load TLS native certificate store once ([Issue #4491](https://github.com/apollographql/router/issues/4491)) - -When TLS was not configured for subgraphs, the OS-provided list of certificates was being parsed once _per subgraph_, which resulted in long loading times on macOS. With this change, the native root store is generated once and then reused across subgraphs, resolving the long loading times. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4616 - -### Apollo library dependency updates ([Issue #4525](https://github.com/apollographql/router/issues/4525), [Issue #413](https://github.com/apollographql/router/issues/4413)) - -Updates to our own dependencies of `apollo-rs` and `apollo-federation` bring in upstream fixes for operation validation including adjustments to field merging and enum input values for Rust-based validation. - -By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/4510 - -# [1.40.0] - 2024-02-14 - -## 🚀 Features - -### GraphOS entity caching ([Issue #4478](https://github.com/apollographql/router/issues/4478)) - -> ⚠️ This is a preview for an [Enterprise feature](https://www.apollographql.com/blog/platform/evaluating-apollo-router-understanding-free-and-open-vs-commercial-features/) of the Apollo Router. It requires an organization with a [GraphOS Enterprise plan](https://www.apollographql.com/pricing/). -> -> If your organization doesn't currently have an Enterprise plan, you can test out this functionality by signing up for a free Enterprise trial. - -The Apollo Router can now cache fine-grained subgraph responses at the entity level, which are reusable between requests. - -Caching federated GraphQL responses can be done at the HTTP level, but it's inefficient because a lot of data can be shared between different requests. The Apollo Router now contains an entity cache that works at the subgraph level: it caches subgraph responses, splits them by entities, and reuses entities across subgraph requests. -Along with reducing the cache size, the router's entity cache brings more flexibility in how and what to cache. It allows the router to store different parts of a response with different expiration dates, and link the cache with the authorization context to avoid serving stale, unauthorized data. - -As a preview feature, it's subject to our [Preview launch stage](https://www.apollographql.com/docs/resources/product-launch-stages/#preview) expectations. It doesn't support cache invalidation. We're making it available to test and gather feedback. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4195 - - -### Graduate distributed query plan caching from experimental ([Issue #4575](https://github.com/apollographql/router/issues/4575)) - -[Distributed query plan caching] (https://www.apollographql.com/docs/router/configuration/distributed-caching#distributed-query-plan-caching) has been validated in production deployments and is now a fully supported, non-experimental Enterprise feature of the Apollo Router. - -To migrate your router configuration, replace `supergraph.query_planning.experimental_cache` with `supergraph.query_planning.cache`. - -This release also adds improvements to the distributed cache: - 1. The `.` separator is replaced with `:` in the Redis cache key to align with conventions. - 2. The cache key length is reduced. - 3. A federation version is added to the cache key to prevent confusion when routers with different federation versions (and potentially different ways to generate a query plan) target the same cache. - 4. Cache insertion is moved to a parallel task. Once the query plan is created, this allows a request to be processed immediately instead of waiting for cache insertion to finish. This improvement has also been applied to the APQ cache. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4583 - -### Replace selector to extract body elements from subgraph responses via JSONPath ([Issue #4443](https://github.com/apollographql/router/issues/4443)) - -The `subgraph_response_body` [selector](https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/selectors/) has been deprecated and replaced with selectors for a response body's constituent elements: `subgraph_response_data` and `subgraph_response_errors`. - -When configuring `subgraph_response_data` and `subgraph_response_errors`, both use a JSONPath expression to fetch data or errors from a subgraph response. - -An example configuration: - -```yaml -telemetry: - instrumentation: - spans: - subgraph: - attributes: - "my_attribute": - subgraph_response_data: "$.productName" - subgraph_response_errors: "$.[0].message" -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4579 - -### Add a `.remove` method for headers in Rhai - -The router supports a new `.remove` method that enables users to remove headers in a Rhai script. - -For example: - -``` rust -fn supergraph_service(service) { - print("registering callbacks for operation timing"); - - const request_callback = Fn("process_request"); - service.map_request(request_callback); - - const response_callback = Fn("process_response"); - service.map_response(response_callback); -} - -fn process_request(request) { - request.context["request_start"] = Router.APOLLO_START.elapsed; -} - -fn process_response(response) { - response.headers.remove("x-custom-header") -} -``` - -By [@lrlna](https://github.com/lrlna) in https://github.com/apollographql/router/pull/4632 - -### Helm update to allow a list of gateways to `VirtualService` ([Issue #4464](https://github.com/apollographql/router/issues/4464)) - -Configuration of the router's Helm chart has been updated to allow multiple gateways. This enables configuration of multiple gateways in an Istio `VirtualService`. - -The previous configuration for a single `virtualservice.gatewayName` has been deprecated in favor of a configuration for an array of `virtualservice.gatewayNames`. - -By [@marcantoine-bibeau](https://github.com/marcantoine-bibeau) in https://github.com/apollographql/router/pull/4520 - -### Configure logging format automatically based on terminal ([Issue #4369](https://github.com/apollographql/router/issues/4369)) - -You can configure the logging output format when running with an interactive shell. - -If both `format` and `tty_format` are configured, then the format used depends on how the router is run: - -* If running with an interactive shell, then `tty_format` takes precedence. -* If running with a non-interactive shell, then `format` takes precedence. - -You can explicitly set the format in `router.yaml` with `telemetry.exporters.logging.stdout.tty_format`: - -```yaml title="router.yaml" -telemetry: - exporters: - logging: - stdout: - enabled: true - format: json - tty_format: text -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4567 - -### Add configurable histogram buckets per metric ([Issue #4543](https://github.com/apollographql/router/issues/4543)) - -> [!NOTE] -> -> While this feature was introduced in this v1.40.0 release, it was prevented from working best subsequent fixes in later versions. We recommend using _at least_ Router v1.46.0 to use this feature. - -The router supports overriding instrument settings for metrics with [OpenTelemetry views](https://opentelemetry.io/docs/concepts/signals/metrics/#views). You can use views to override default histogram buckets. - -Configure views with the `views` option. For example: - -```yaml -telemetry: - exporters: - metrics: - common: - service_name: apollo-router - views: - - name: apollo_router_http_request_duration_seconds # Instrument name you want to edit. You can use wildcard in names. If you want to target all instruments just use '*' - unit: "ms" # (Optional) override the unit - description: "my new description of this metric" # (Optional) override the description - aggregation: # (Optional) - histogram: - buckets: # Override default buckets configured for this histogram - - 1 - - 2 - - 3 - - 4 - - 5 - allowed_attribute_keys: # (Optional) Keep only listed attributes on the metric - - status -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4572 - -## 🐛 Fixes - -### Fix `active_session_count` when future is dropped ([Issue #4601](https://github.com/apollographql/router/issues/4601)) - -Fixes [an issue](https://github.com/apollographql/router/issues/4601) where `apollo_router_session_count_active` would increase indefinitely due -to the request future getting dropped before a counter could be decremented. - -By [@xuorig](https://github.com/xuorig) in https://github.com/apollographql/router/pull/4619 - - - -# [1.39.1] - 2024-02-08 - -## 🐛 Fixes - -### Re-instate macOS Intel-based (x86_64) binary distribution ([Issue #4483](https://github.com/apollographql/router/issues/4483)) - -We've re-instated the macOS Intel-based binary production and distribution that we had stopped in v1.38.0 on account of our CI provider shutting down their own Intel machines in upcoming months. Rather than using an Intel-based machine, we will rely on the Xcode-supported cross-compilation to produce _two_ separate binaries, both created and tested only with an ARM-based Mac. - -We will likely have to re-visit this deprecation in the future as libaries and hardware continues to move on but, as of today, there are still just enough users who are still reliant on Intel-based laptops that it warrants continuing our investment in the architecture. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4605 - - - -# [1.39.0] - 2024-02-05 - -## 🚀 Features - -### Introduce support for progressive `@override` ([PR #4521](https://github.com/apollographql/router/pull/4521)) - -> ⚠️ This is an [Enterprise feature](https://www.apollographql.com/blog/platform/evaluating-apollo-router-understanding-free-and-open-vs-commercial-features/) of the Apollo Router. It requires an organization with a [GraphOS Enterprise plan](https://www.apollographql.com/pricing/). -> -> If your organization doesn't currently have an Enterprise plan, you can test out this functionality by signing up for a free Enterprise trial. - -The change brings support for progressive `@override`, which allows dynamically overriding root fields and entity fields in the schema. This feature is enterprise only and requires a license key to be used. - -A new `label` argument is added to the `@override` directive in order to indicate the field is dynamically overridden. Labels can come in two forms: -1) String matching the form `percent(x)`: The router resolves these labels based on the `x` value. For example, `percent(50)` will route 50% of requests to the overridden field and 50% of requests to the original field. -2) Arbitrary string matching the regex `^[a-zA-Z][a-zA-Z0-9_-:./]*$`: These labels are expected to be resolved externally via coprocessor. A supergraph request hook can inspect and modify the context of a request in order to inform the router which labels to use during query planning. - -Please consult the docs for more information on how to use this feature and how to implement a coprocessor for label resolution. - -By [@TrevorScheer](https://github.com/TrevorScheer) in https://github.com/apollographql/router/pull/4521 - -### Specify trace ID formatting ([PR #4530](https://github.com/apollographql/router/pull/4530)) - -You can specify the format of the trace ID in the response headers of the supergraph service. - -An example configuration using this feature: -```yaml -telemetry: - apollo: - client_name_header: name_header - client_version_header: version_header - exporters: - tracing: - experimental_response_trace_id: - enabled: true - header_name: trace_id - format: decimal # Optional, defaults to hexadecimal -``` - -If the format is not specified, then the trace ID will continue to be in hexadecimal format. - -By [@nicholascioli](https://github.com/nicholascioli) in https://github.com/apollographql/router/pull/4530 - -### Add selector to get all baggage key values in span attributes ([Issue #4425](https://github.com/apollographql/router/issues/4425)) - -Previously, baggage items were configured as standard attributes in `router.yaml`, and adding a new baggage item required a configuration update and router rerelease. - -This release supports a new configuration that enables baggage items to be added automatically as span attributes. - -If you have several baggage items and would like to add all of them directly as span attributes (for example, `baggage: my_item=test, my_second_item=bar`), setting `baggage: true` will add automatically add two span attributes, `my_item=test` and `my_second_item=bar`. - -An example configuration: - -```yaml -telemetry: - instrumentation: - spans: - router: - attributes: - baggage: true -``` - - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4537 - -### Create a trace during router creation and plugin initialization ([Issue #4472](https://github.com/apollographql/router/issues/4472)) - -When the router starts or reloads, it will now generate a trace with spans for query planner creation, schema parsing, plugin initialisation and request pipeline creation. This will help debugging any issue during startup, especially during plugins creation. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4480 - -### Allow adding static attributes on specific spans in telemetry settings ([Issue #4561](https://github.com/apollographql/router/issues/4561)) - -It is now possible to add static attributes to spans, defined in the configuration file. - -Example of configuration: - -```yaml -telemetry: - instrumentation: - spans: - router: - attributes: - "my_attribute": "constant_value" - supergraph: - attributes: - "my_attribute": "constant_value" - subgraph: - attributes: - "my_attribute": "constant_value" -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4566 - -## 🐛 Fixes - -### Order HPA targets to resolve OutOfSync states ([Issue #4435](https://github.com/apollographql/router/issues/4435)) - -This update addresses an `OutOfSync` issue in ArgoCD applications when Horizontal Pod Autoscaler (HPA) is configured with both memory and CPU limits. -Previously, the live and desired manifests within Kubernetes were not consistently sorted, leading to persistent `OutOfSync` states in ArgoCD. -This change implements a sorting mechanism for HPA targets within the Helm chart, ensuring alignment with Kubernetes' expected order. -This fix proactively resolves the sync discrepancies while using HPA, circumventing the need to wait for Kubernetes' issue resolution (kubernetes/kubernetes#74099). - -By [@cyberhck](https://github.com/cyberhck) in https://github.com/apollographql/router/pull/4436 - -### Reactivate log events in traces ([PR #4486](https://github.com/apollographql/router/pull/4486)) - -This fixes a regression introduced in #2999, where events were not sent with traces anymore due to too aggressive sampling - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4486 - -### Fix inconsistency in environment variable parsing for telemetry ([Issue #3203](https://github.com/apollographql/router/issues/ISSUE_NUMBER)) - -Previously, the router would complain when using the rover recommendation of `APOLLO_TELEMETRY_DISABLED=1` environment -variable. Now any non-falsey value can be used, such as 1, yes, on, etc.. - -By [@nicholascioli](https://github.com/nicholascioli) in https://github.com/apollographql/router/pull/4549 - -### Store static pages in `Bytes` structure to avoid expensive allocation per request ([PR #4528](https://github.com/apollographql/router/pull/4528)) - -The `CheckpointService` created by the `StaticPageLayer` caused a non-insignificant amount of memory to be allocated on every request. The service stack gets cloned on every request, and so does the rendered template. - -The template is now stored in a `Bytes` struct instead which is cheap to clone. - -By [@xuorig](https://github.com/xuorig) in https://github.com/apollographql/router/pull/4528 - -### Fix header propagation issues ([Issue #4312](https://github.com/apollographql/router/issues/4312)), ([Issue #4398](https://github.com/apollographql/router/issues/4398)) - -This fixes two header propagation issues: -* if a client request header has already been added to a subgraph request due to another header propagation rule, then it is only added once -* `Accept`, `Accept-Encoding` and `Content-Encoding` were not in the list of reserved headers that cannot be propagated. They are now in that list because those headers are set explicitely by the Router in its subgraph requests - -There is a potential change in behavior: if a router deployment was accidentally relying on header propagation to compress subgraph requests, then it will not work anymore because `Content-Encoding` is not propagated anymore. Instead it should be set up from the `traffic_shaping` section of the Router configuration: - -```yaml -traffic_shaping: - all: - compression: gzip - subgraphs: # Rules applied to requests from the router to individual subgraphs - products: - compression: identity -``` - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4535 - -## 🧪 Experimental - -### Move cacheability metrics to the entity cache plugin ([Issue #4253](https://github.com/apollographql/router/issues/4253)) - -Cacheability metrics have been moved from the telemetry plugin to the entity cache plugin. - -New configuration has been added: -- Enabling or disabling the metrics -- Setting the metrics storage TTL (default is 60s) -- Disabling the metric's typename attribute by default. (Activating it can greatly increase the cardinality.) - -Cleanup and performance improvements have also been implemented. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4469 - - - -# [1.38.0] - 2024-01-19 - -## 🚀 Features - -### Promote HTTP request size limit from experimental to general availability ([PR #4442](https://github.com/apollographql/router/pull/4442)) - -In this release, the router YAML configuration option to set the maximum size of an HTTP request body is promoted [from experimental to general availability](https://www.apollographql.com/docs/resources/product-launch-stages/). The option was previously `experimental_http_max_request_bytes` and is now `http_max_request_bytes`. - -The previous `experimental_http_max_request_bytes` option works but produces a warning. - -To migrate, rename `experimental_http_max_request_bytes` to the generally available `http_max_request_bytes` option: - -```yaml -limits: - http_max_request_bytes: 2000000 # Default value: 2 MB -``` - -By default, the Apollo Router limits the size of the HTTP request body it reads from the network to 2 MB. Before increasing this limit, consider testing performance in an environment similar to your production, especially if some clients are untrusted. Many concurrent large requests can cause the router to run out of memory. - - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/4442 - -### New configuration options for Redis username and password ([Issue #4346](https://github.com/apollographql/router/issues/4346)) - -This release introduces new configuration options to set your Redis username and password. - -Example of configuration: - -```yaml title="router.yaml" -supergraph: - query_planning: - experimental_cache: - redis: #highlight-line - urls: ["redis://..."] #highlight-line - username: admin/123 # Optional, can be part of the urls directly, mainly useful if you have special character like '/' in your password that doesn't work in url. This field takes precedence over the username in the URL - password: admin # Optional, can be part of the urls directly, mainly useful if you have special character like '/' in your password that doesn't work in url. This field takes precedence over the password in the URL - timeout: 5ms # Optional, by default: 2ms - ttl: 24h # Optional, by default no expiration -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4453 - -### Support Redis key namespace ([Issue #4247](https://github.com/apollographql/router/issues/4247)) - -This release introduces support for Redis key namespace. - -The namespace, if provided, is prefixed to the key: `namespace:key`. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4458 - -## 🐛 Fixes - -### Fix the Datadog default tracing exporter URL ([Issue #4415](https://github.com/apollographql/router/issues/4416)) - -The default URL for the Datadog exporter was incorrectly set to `http://localhost:8126/v0.4/traces`. This caused issues for users running different agent versions. - -This is now fixed and matches the exporter URL of `http://127.0.0.1:8126`. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4444 - -### Set application default log level to `info` ([PR #4451](https://github.com/apollographql/router/pull/4451)) - -This release sets the default log level to `info` for an entire application, including custom external plugins, when the [`RUST_LOG` environment variable](https://www.apollographql.com/docs/router/configuration/telemetry/exporters/logging/overview/#log-level) isn't set. - -Previously, if you set the `--log` command-line option or `APOLLO_RUST_LOG` environment variable, their log level setting impacted more than the `apollo_router` crate and caused custom plugins with `info` logs or metrics to have to manually set `RUST_LOG=info`. - -> Note: setting `RUST_LOG` changes the application log level. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4451 - -### Fix response format for statically skipped root selection set ([Issue #4397](https://github.com/apollographql/router/issues/4397)) - -Previously, the Apollo Router didn't return responses with the same format for some operations with a root selection set that were skipped by `@skip` or `@include` directives. - -For example, if you hardcoded the parameter in a `@skip` directive: - -```graphql -{ - get @skip(if: true) { - id - name - } -} -``` - -Or if you used a variable: - -```graphql -{ - get(: Boolean = true) @skip(if: ) { - id - name - } -} -``` - - -The router returned responses with different formats. - -This release fixes the issue, and the router returns the same response for both examples: - -```json -{ "data": {}} -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4466 - -### Fix building entity representations with inline fragments ([PR #4441](https://github.com/apollographql/router/pull/4441)) - -Previously, when applying a selection set to an entity reference before it's used in a fetch node, the router would drop data from the reference when it selected using an inline fragment, for example `@requires(fields: "... on Foo { a } ... on Bar { b }")`). - -This release uses a more flexible abstract type / concrete type check when applying a selection set to an entity reference before it's used in a fetch node. - -By [@lennyburdette](https://github.com/lennyburdette) in https://github.com/apollographql/router/pull/4441 - -### Improve logging for JWKS download failures ([Issue #4448](https://github.com/apollographql/router/issues/4448)) - -To enable users to debug JWKS download and parse failures more easily, we've added more detailed logging to the router. The router now logs the following information when a JWKS download or parse fails: - -``` -2024-01-09T12:32:20.174144Z ERROR fetch jwks{url=http://bad.jwks.com/,} could not create JSON Value from url content, enable debug logs to see content e=expected value at line 1 column 1 -``` -Enabling debug logs via `APOLLO_LOG=debug` or `--logs DEBUG` will show the full JWKS content being parsed: -``` -2024-01-09T12:32:20.153055Z DEBUG fetch jwks{url=http://bad.jwks.com/,} parsing JWKS data="invalid jwks" -``` - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4449 - -### Rename batching metric for naming convention conformance ([PR #4424](https://github.com/apollographql/router/pull/4424)) - -In this release, the `apollo_router.operations.batching` metric has been renamed to `apollo.router.operations.batching` to conform to our naming convention of a `apollo.router.` prefix. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4424 - -### Improve JWKS parse error handling ([Issue #4463](https://github.com/apollographql/router/issues/4463)) - -When parsing a JSON Web Key Set (JWKS), the router now ignores any JWK that fails to parse rather than failing the entire JWKS parse. - -This can happen when the JWK is malformed, or when a JWK uses an unknown algorithm. When this happens a warning is output to the logs, for example: - -``` -2024-01-11T15:32:01.220034Z WARN fetch jwks{url=file:///tmp/jwks.json,} ignoring a key since it is not valid, enable debug logs to full content err=unknown variant `UnknownAlg`, expected one of `HS256`, `HS384`, `HS512`, `ES256`, `ES384`, `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `EdDSA` alg="UnknownAlg" index=2 -``` - -Log messages have the following attributes: -* `alg` - the JWK algorithm if known or `` -* `index` - the index of the JWK within the JWKS -* `url` - the URL of the JWKS that had the issue - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4465 - -### make `OperationKind` public ([Issue #4410](https://github.com/apollographql/router/issues/4410)) - -`OperationKind` was already used in a public field but the type itself was still private. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4489 - -### Update to Federation v2.6.3 ([PR #4468](https://github.com/apollographql/router/pull/4468)) - -This federation update contains query planning fixes for: -* Invalid typename used when calling into a subgraph that uses `@interfaceObject` -* A performance issue when generating planning paths for union members that use `@requires` - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4468 - -## 🛠 Maintenance - -### Pre-built binaries are only available for Apple Silicon ([Issue #3902](https://github.com/apollographql/router/issues/3902)) - -Prior to this release, macOS binaries were produced on Intel build machines in our CI pipeline. The binaries produced would also work on Apple Silicon (M1, etc.. chips) through the functionality provided by [Rosetta2](https://support.apple.com/en-gb/HT211861). - -Our [CI provider has announced the deprecation of the macOS Intel build machines](https://discuss.circleci.com/t/macos-intel-support-deprecation-in-january-2024/48718) and we are updating our build pipeline to use the new Apple Silicon based machines. - -This will have the following effects: - - Older, Intel based, macOS systems will no longer be able to execute our macOS router binaries. - - Newer, Apple Silicon based, macOS systems will get a performance boost due to no longer requiring Rosetta2 support. - -We have raised [an issue](https://github.com/apollographql/router/issues/4483) that describes options for Intel based macOS users. Please let us know in that issue if the alternatives we suggest (e.g., Docker, source build) don't work for you so we can discuss alternatives. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4484 - - - -# [1.37.0] - 2024-01-05 - -## 🚀 Features - -### General availability of subscription callback protocol ([Issue #3884](https://github.com/apollographql/router/issues/3884)) - -The subscription callback protocol feature is now generally available (GA). - -**The configuration of subscription callback protocol in GA is incompatible with its configuration from its preview.** Follow the next section to migrate from preview to GA. - -#### Migrate from preview to GA - -You must update your router configuration with the following steps: - -1. Change the name of the option from `subscription.mode.preview_callback` to `subscription.mode.callback`. - - Failure to use the updated option name when running the router will result in an error and the router won't start. - - - In the example of the GA configuration below, the option is renamed as `callback`. - - -2. Update the `public_url` field to include the full URL of your callback endpoint. - - Previously in preview, the public URL used by the router was the automatic concatenation of the `public_url` and `path` fields. In GA, the behavior has changed, and the router uses exactly the value set in `public_url`. This enables you to configure your own public URL, for example if you have a proxy in front of the router and want to configure redirection with the public URL. - - In the example of the GA configuration below, the path `/custom_callback` is no longer automatically appended to `public_url`, so instead it has to be set explicitly as `public_url: http://127.0.0.1:4000/custom_callback`. - -3. Configure the new `heartbeat_interval` field to set the period that a heartbeat must be sent to the callback endpoint for the subscription operation. - - The default heartbeat interval is 5 seconds. Heartbeats can be disabled by setting `heartbeat_interval: disabled`. - -```yaml -subscription: - enabled: true - mode: - callback: #highlight-line - public_url: http://127.0.0.1:4000/custom_callback - listen: 0.0.0.0:4000 - path: /custom_callback - heartbeat_interval: 5secs # can be "disabled", by default it's 5secs -``` - -#### Changes in callback protocol specifications - -The subscription specification has been updated with the following observable changes: - -* The router will always answer with the header `subscription-protocol: callback/1.0` on the callback endpoint. - -* Extensions data now includes the heartbeat interval (in milliseconds) that you can globally configure. We also switch from snake_case to camelCase notation. An example of a payload sent to the subgraph using callback mode: - -```json -{ - "query": "subscription { userWasCreated { name reviews { body } } }", - "extensions": { - "subscription": { - "callbackUrl": "http://localhost:4000/callback/c4a9d1b8-dc57-44ab-9e5a-6e6189b2b945", - "subscriptionId": "c4a9d1b8-dc57-44ab-9e5a-6e6189b2b945", - "verifier": "XXX", - "heartbeatIntervalMs": 5000 - } - } -} -``` - -* When the router is sending a subscription to a subgraph in callback mode, it now includes a specific `accept` header set to `application/json;callbackSpec=1.0` that let's you automatically detect if it's using callback mode or not. - - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4272 - -### Expose Context ID to Rhai scripts ([Issue #4370](https://github.com/apollographql/router/issues/4370)) - -We recently added an ID to `Context` which uniquely identifies the context for the duration of a request/response lifecycle. This is now accessible on `Request` or `Response` objects inside Rhai scripts. - -For example: - -```rhai -// Map Request for the Supergraph Service -fn supergraph_service(service) { - const request_callback = Fn("process_request"); - service.map_request(request_callback); -} - -// Generate a log for each Supergraph request with the request ID -fn process_request(request) { - print(`request id : ${request.id}`); -} -``` - -> Note: We expose this `Context` data directly from `Request` and `Response` objects, rather than on the `Context` object, to avoid the possibility of name collisions (e.g., with "id") in the context data itself. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4374 - -### Add support for downloading supergraph schema from a list of URLs ([Issue #4219](https://github.com/apollographql/router/issues/4219)) - -The `APOLLO_ROUTER_SUPERGRAPH_URLS` environment variable has been introduced to support downloading supergraph schema from a list of URLs. This is useful for users who require supergraph deployments to be synchronized via GitOps workflows. - -You configure `APOLLO_ROUTER_SUPERGRAPH_URLS` with a comma separated list of URLs that will be polled in order to try and retrieve the supergraph schema, and you configure the polling interval by setting `APOLLO_UPLINK_POLL_INTERVAL` (default: 10s). - - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4377 - -### Offline license support ([Issue #4219](https://github.com/apollographql/router/issues/4219)) - -> ⚠️ This is an [Enterprise feature](https://www.apollographql.com/blog/platform/evaluating-apollo-router-understanding-free-and-open-vs-commercial-features/) of the Apollo Router available on an as-needed basis. It requires the feature be enabled on an organization with a [GraphOS Enterprise plan](https://www.apollographql.com/pricing/). Send a request to your Apollo contact to enable it for your GraphOS Studio organization. - -Running your Apollo Router fleet while fully connected to GraphOS is the best choice for most Apollo users. To support various other scenarios, this release of the Router introduces GraphOS **offline** Enterprise licenses. An offline license enables routers to start and serve traffic without a persistent connection to GraphOS. - -For complete details on the feature, including how it varies from being fully-connected to GraphOS, see [Offline Enterprise license](https://www.apollographql.com/docs/router/enterprise-features/#offline-enterprise-license). - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/4372 - -## 🐛 Fixes - -### Fix reporting of subscription traces to Apollo ([Issue #4339](https://github.com/apollographql/router/issues/4339)) - -Fixes the reporting of subscription traces in Apollo usage reports to match what is reported in OpenTelemetry. - -By [@bonnici](https://github.com/bonnici) in https://github.com/apollographql/router/pull/4328 - -### Abstract spreads in queried objects no longer returning erroneous nulls ([Issue #4348](https://github.com/apollographql/router/issues/4348)) - -When you had an inline fragment on an union type in a fragment spread the response returned to the client was not fully accurate against the schema, or the GraphQL specification, which resulted in client errors and unexpected `null` values (either on fields or objects whose members existed on other concrete types of the union). This is now resolved and accounted for correctly, and additional tests have been added. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4401 - -### Fix TLS session ticket retries ([Issue #4305](https://github.com/apollographql/router/issues/4305)) - -In some cases, the router could retry TLS connections indefinitely with an invalid session ticket, making it impossible to contact a subgraph. We've provided a fix to the upstream `rustls` project with a fix, and brought in the updated dependency when it was published in v0.21.10. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4362 - -## 🛠 Maintenance - -### Fixed update telemetry config in Helm chart ([PR #4360](https://github.com/apollographql/router/pull/4360)) - -Previously, if using the new `telemetry` configuration (notably `telemetry.exporters`), the Helm chart would result in both old and new configuration at the same time. This was invalid and prevented the router from starting up. In this release, the Helm chart outputs the appropriate structure based on user-provided configuration. - -By [@lennyburdette](https://github.com/lennyburdette) in https://github.com/apollographql/router/pull/4360 - -### Update zerocopy dependency ([PR #4403](https://github.com/apollographql/router/pull/4403)) - -This changeset updates zerocopy to 0.7.31, which has a fix for https://rustsec.org/advisories/RUSTSEC-2023-0074. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/4403 - -## 📚 Documentation - -### Add links to related content in subgraph error inclusion docs ([PR #4206](https://github.com/apollographql/router/pull/4206)) - -The [Subgraph error inclusion](https://www.apollographql.com/docs/router/configuration/subgraph-error-inclusion) doc has been updated with links to related content about why you might not be seeing error message in logs or GraphOS and how you can configure their settings. - -By [@smyrick](https://github.com/smyrick) in https://github.com/apollographql/router/pull/4206 - -### Document `experimental_when_header` logging configuration option ([Issue #4342](https://github.com/apollographql/router/issues/4342)) - -In router v1.35.0, we introduced the¬ experimental logging configuration option `experimental_when_header` without documentation. Its documentation has been added [here](https://apollographql.com/docs/router/configuration/telemetry/exporters/logging/overview#requestresponse-logging). - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4359 - -## 🧪 Experimental - -### Expose query plan and paths limits ([PR #4367](https://github.com/apollographql/router/pull/4367)) - -Two new configuration options have been added to reduce the impact of complex queries on the planner: - -- `experimental_plans_limit` limits the number of generated plans. (Note: already generated plans remain valid, but they may not be optimal.) - -- `experimental_paths_limit` stops the planning process entirely if the number of possible paths for a selection in the schema gets too large. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4367 - - - -# [1.36.0] - 2024-01-02 - -## 🚀 Features - -### Run new (non-load-bearing) Rust validation out-of-band to help identify deltas ([Issue#4159](https://github.com/apollographql/router/issues/4159)) - -As part of the process to replace JavaScript validation with a more performant Rust validation in the router, we are enabling the router to run both validations as a default. This allows us to definitively assess reliability and stability of Rust validation before completely removing JavaScript validation. As before, it's possible to toggle between implementations using the `experimental_graphql_validation_mode` config key. Possible values are: `new` (runs only Rust-based validation), `legacy` (runs only JS-based validation), `both` (runs both in comparison, logging errors if a difference arises). - -The `both` mode is now the default, which will result in **no client-facing impact** but will output errors to the Router's logs if a discrepancy is recorded. If you discover discrepancies in your logs, please open an issue. - -By [@lrlna](https://github.com/lrlna) in https://github.com/apollographql/router/pull/4161 - -## 🐛 Fixes - -### Fix fragment usage with `@interfaceObject` ([Issue #3855](https://github.com/apollographql/router/issues/3855)) - -When requesting `__typename` under a fragment under an interface, from a subgraph adding fields to that interface with the `@interfaceObject` directive, the router was returning the interface name instead of the concrete type name. This is now fixed at the query planner level. - -By [@geal](https://github.com/geal) in https://github.com/apollographql/router/pull/4363 - -### TLS client configuration override for Redis ([Issue #3551](https://github.com/apollographql/router/issues/3551)) - -It is now possible to set up a client certificate or override the root certificate authority list for Redis connections, through the `tls` section under Redis configuration. Options follow the same format as [subgraph TLS configuration](https://www.apollographql.com/docs/router/configuration/overview/#tls): - -```yaml -apq: - router: - cache: - redis: - urls: [ "redis://localhost:6379" ] - tls: - certificate_authorities: "" - client_authentication: - certificate_chain: - key: -``` - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4304 - -### `span_mode: spec_compliant` not applied correctly ([Issue #4335](https://github.com/apollographql/router/issues/4335)) - -Previously, `telemetry.instrumentation.spans.span_mode.spec_compliant` was not being correctly applied. This resulted in extra request spans that should not have been present in spec compliant mode, where `router.supergraph.subgraph` was incorrectly output as `request.router.supergraph.subgraph`. This has been fixed in this release. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4341 - -## 🛠 Maintenance - -### chore: Update zerocopy dependency ([PR #4403](https://github.com/apollographql/router/pull/4403)) - -This changeset updates zerocopy to 0.7.31, which has a fix for https://rustsec.org/advisories/RUSTSEC-2023-0074. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/4403 - -# [1.35.0] - 2023-12-01 - -## 🚀 Features - -### Federation v2.6.1 - -This updates the Apollo Federation version to v2.6.1. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4296 - -### Support configurable heartbeat for subscription callback protocol ([Issue #4115](https://github.com/apollographql/router/issues/4115)) - -The heartbeat interval that the Apollo Router uses for the subscription callback protocol is now configurable. - -The heartbeat can even be disabled for certain platforms. - -An example configuration: - -```yaml -subscription: - enabled: true - mode: - preview_callback: - public_url: http://127.0.0.1:4000 - heartbeat_interval: 5s # Optional - listen: 127.0.0.1:4000 - path: /callback - subgraphs: - - accounts - ``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4246 - -### Enhanced telemetry ([Issue #3226](https://github.com/apollographql/router/issues/3226)) - -Telemetry functionality has been enhanced. The enhancements include: -* Allowing fine-grained configuration of attributes on router, supergraph and subgraph spans. -* Allowing coarse-grained control over attributes using OpenTelemetry requirement levels. -* Bringing attributes into alignment with OpenTelemetry semantic conventions, with many new attributes now being configurable. -* Allowing custom attributes to be easily declared in YAML. - -The enhanced telemetry enables new benefits. They include: -* Easily including trace IDs in your log statements for correlation. -* Extracting domain-specific data from the router's execution pipeline for example custom trace IDs. -* Diagnosing network related issues with standard [Open Telemetry HTTP attributes](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/). -* Improving performance by avoiding the use of large attributes on spans such as `graphql.document`. - -See the updated [telemetry documentation](configuration/telemetry/overview) for details on the new enhancements. - -By [@bnjjj](https://github.com/bnjjj), [@bryncooke](https://github.com/bryncooke) and [Edward Huang](https://github.com/shorgi) in https://github.com/apollographql/router/pull/4102 and https://github.com/apollographql/router/pull/4129 - -## 🐛 Fixes - -### Remove doubled slash (`//`) in logs for health check URL ([Issue #4270](https://github.com/apollographql/router/issues/4270)) - -Adding the ability to specify the path of the health endpoint introduced an error in the logging. An extra `/` was added before the specified path resulting in an unintended double-slash (`//`) in the rendered URL. It did not affect the actual health check endpoint. This is now fixed. - -By [@juancarlosjr97](https://github.com/juancarlosjr97) in https://github.com/apollographql/router/pull/4278 - -### Improved query deduplication with extracted authorization information from subgraph queries ([PR #4208](https://github.com/apollographql/router/pull/4208)) - -Query deduplication has been improved with authorization information extracted from subgraph queries. - -Previously, query deduplication was already taking authorization information into account in its key, but that was for the global authorization context (the intersection of what the query authorization requires and what the request token provides). -This was very coarse grained, leading to some subgraph queries with different authorization requirements or even no authorization requirements. - -In this release, the authorization information from subgraph queries is used for deduplication. This now means that deduplicated queries can be shared more widely across different authorization contexts. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4208 - -### Add missing schemas for Redis connections ([Issue #4173](https://github.com/apollographql/router/issues/4173)) - -Previously, support for additional schemas for the Redis client used in the Apollo Router were [added](https://github.com/apollographql/router/issues/3534). However, the router's Redis connection logic wasn't updated to process the new schema options. - -The Redis connection logic has been updated in this release. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4174 - -### Relax JWKS requirements ([PR #4234](https://github.com/apollographql/router/pull/4234)) - -Previously in the Apollo Router's logic for validating JWT with a corresponding JWK, a bug occured when the `use` and `key_ops` JWK parameters were absent, resulting in the key not being selected for verification. This bug has been fixed in this release. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4234 - -### Session count metrics no longer go negative ([Issue #3485](https://github.com/apollographql/router/issues/3485)) - -Previously, the `apollo_router_session_count_total` and `apollo_router_session_count_active` metrics were using counters that could become negative unexpectedly. - -This issue has been fixed in this release, with **the metric type changed from counter to gauge**. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3787 - -### Decrease default GraphQL parser recursion limit to 500 ([PR #4205](https://github.com/apollographql/router/pull/4205)) - -The Apollo Router's GraphQL parser uses recursion for nested selection sets, list values, or object values. The nesting level is limited to protect against stack overflow. - -Previously the default limit was 4096. That limit has been decreased to 500 in this release. - -You can change the limit (or backport the new default to older router versions) in YAML configuration: - -```yaml -limits: - parser_max_recursion: 700 -``` - -> Note: deeply nested selection sets often cause deeply nested response data. When handling a response from a subgraph, the JSON parser has its own recursion limit of 128 nesting levels. That limit is not configurable. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/4205 - -### Fix gRPC metadata configuration ([Issue #2831](https://github.com/apollographql/router/issues/2831)) - -Previously, telemetry exporters that used gRPC as a protocol would not correctly parse metadata configuration. Consequently, a user was forced to use a workaround of specifying a list of values instead of a map. For example: - -```yaml -telemetry: - exporters: - tracing: - otlp: - grpc: - metadata: - "key1": "value1" # Failed to parse - "key2": # Succeeded to parse - - "value2" -``` - -This issue has been fixed, and the following example with a map of values now parses correctly: - -```yaml -telemetry: - exporters: - tracing: - otlp: - grpc: - metadata: - "key1": "value1" -``` - -By [@bryncooke](https://github.com/AUTHOR) in https://github.com/apollographql/router/pull/4285 - -### Input objects values can be empty - -This updates to `apollo-parser@0.7.4` which fixes a critical bug introduced in `apollo-parser@0.7.3` where empty input objects failed to parse. The following is valid again: - -```graphql -{ field(argument: {}) } -``` - -By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/4309 - -### Rename `apollo.router.telemetry.studio.reports`' `type` attribute ([Issue #4300](https://github.com/apollographql/router/issues/4300)) - -To better comply with OpenTelemetry naming conventions, for `apollo.router.telemetry.studio.reports` the `type` attribute has been renamed to `report.type`. - -**Please update your dashboards if you are monitoring this metric.** - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4302 - -### Rhai scripts or coprocessors no longer prevent traces from appearing in Apollo Studio ([PR #4228](https://github.com/apollographql/router/pull/4228)) - -Previously, trace reports were not appearing in Apollo Studio's Operations view when the Router was configured with either coprocessors or Rhai script. That issue has been resolved in this release. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4228 - -## 🛠 Maintenance - -### Improve the secure deployability of our Helm Chart and Docker Image ([Issue #3856](https://github.com/apollographql/router/issues/3856)) - -This is a security improvement for the Apollo Router that is achieved by: - - Switching the router process owner from `root` to a user with less privileges - - Changing the default port from 80 to 4000 - - Updating the base image from bullseye (Debian 11) to bookworm (Debian 12) - -The primary motivations for these changes is that many Kubernetes environments impose security restrictions on containers. For example: - - Don't run as root - - Can't bind to ports < 1024 - -With these changes in place, the router is more secure by default and much simpler to deploy to secure environments. - -The base Debian image has also been updated at this time to keep track with bug fixes in the base image. - -Changing the default port in the Helm chart from 80 to 4000 is an innocuous change. This shouldn't impact most users. Changing the default user from `root` to `router` will have an impact. You will no longer be able to `exec` to the executing container (Kubernetes or Docker) and perform root privilege operations. The container is now "locked down", by default. Good for security, but less convenient for support or debugging. - -Although it's not recommended to revert to the previous behavior of the router executing as root and listening on port 80, it's possible to achieve that with the following configuration: - -``` -router: - configuration: - supergraph: - listen: 0.0.0.0:80 -securityContext: - runAsUser: 0 -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3971 - -### Improve Uplink error and warning messages ([Issue #3877](https://github.com/apollographql/router/issues/3877)) - -A few log messages for Apollo Uplink have been improved: - -- Added a warning if the router is started with only a single Uplink URL. -- Improved the error messages shown when a fetch from Uplink fails. - -By [@bonnici](https://github.com/bonnici) in https://github.com/apollographql/router/pull/4250 - -### Centralize telemetry resource cleanup ([Issue #4121](https://github.com/apollographql/router/issues/4121)) - -The OpenTelemetry shutdown procedures within the Apollo Router have been improved by centralizing the cleanup logic. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4148 - -# [1.34.1] - 2023-11-21 - -## 🐛 Fixes - -### Authorization: Filtered fragments remove corresponding fragment spreads ([Issue #4060](https://github.com/apollographql/router/issues/4060)) - -When fragments have been removed because they do not meet the authorization requirements to be queried, or in the case that their conditions cannot be fulfilled, any related fragment spreads which remain will be now be removed from the operation before execution. Additionally, fragment error paths are now applied at the point that the fragment use. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4155 - -### Authorization: Maintain a special case for `__typename` ([PR #3821](https://github.com/apollographql/router/pull/3821)) - -When evaluating authorization directives on fields returning interfaces, the special GraphQL `__typename` field will be maintained as an exception since it must work for _all_ implementors - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3821 - -### Enforce JWT expiration for subscriptions ([Issue #3947](https://github.com/apollographql/router/issues/3947)) - -If a JWT expires whilst a subscription is executing, the subscription should be terminated. This also applies to deferred responses. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4166 - -### Improved channel bounding via conversion of `futures` channels into `tokio` channels ([Issue #4103](https://github.com/apollographql/router/issues/4103), [Issue #4109](https://github.com/apollographql/router/issues/4109), [Issue #4110](https://github.com/apollographql/router/issues/4110), [Issue #4117](https://github.com/apollographql/router/issues/4117)) - -The use of `futures` channels have been converted to `tokio` channels which should ensure that channel bounds are observed correctly. We hope this brings some additional stability and predictability to the memory footprint. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4111, https://github.com/apollographql/router/pull/4118, https://github.com/apollographql/router/pull/4138 - -### Reduce recursion in GraphQL parsing via `apollo-parser` improvements ([Issue #4142](https://github.com/apollographql/router/issues/4142)) - -Improvements to `apollo-parser` are brought in which remove unnecessary recursion when parsing repeated syntax elements, such as enum values and union members, in type definitions. Some documents that used to hit the parser’s recursion limit will now successfully parse. - -By [@lrlna](https://github.com/lrlna) in https://github.com/apollographql/router/pull/4167 - -### Maintain query ordering within a batch ([Issue #4143](https://github.com/apollographql/router/issues/4143)) - -A bug in batch manipulation meant that the last element in a batch was treated as the first element. Ordering should be maintained and there is now an updated unit test to ensure this. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4144 - -### Port to `apollo-compiler` usage to `1.0-beta` ([PR #4038](https://github.com/apollographql/router/pull/4038)) - -Version 1.0 of `apollo-compiler` is a near-complete rewrite and introducing it in the Router unblocks a lot of upcoming work, including our _Rust-ification_ of the query planner. - -As an immediate benefit, some serialization-related bugs — including [Issue #3541](https://github.com/apollographql/router/issues/3541) — are fixed. Additionally, the representation of GraphQL documents within `apollo-compiler` is now mutable. This means that when modifying a query (such as to remove `@authenticated` fields from an unauthenticated request) the Router no longer needs to construct a new data structure (with `apollo-encoder`), serialize it, and reparse it. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/4038 - -### Propagate multi-value headers to subgraphs ([Issue #4153](https://github.com/apollographql/router/issues/4153)) - -Use `HeaderMap.append` instead of `insert` to avoid erasing previous values when using multiple headers with the same name. - -By [@nmoutschen](https://github.com/nmoutschen) in https://github.com/apollographql/router/pull/4154 - -## 📃 Configuration - -### Authentication: Allow customizing a `poll_interval` for the JWKS endpoint configuration ([Issue #4185](https://github.com/apollographql/router/issues/4185)) - -In order to compensate for variances in rate-limiting requirements for JWKS endpoints, a new `poll_interval` configuration option exists to adjust the polling interval for each JWKS URL. When not specified for a URL, the polling interval will remain as the default of 60 seconds. - -The configuration option accepts a human-readable duration (e.g., `60s` or `1minute 30s`). For example, the following configuration snippet sets the polling interval for a single JWKS URL to be every 30 seconds: - -```yml -authentication: - router: - jwt: - jwks: - - url: https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json - poll_interval: 30s -``` - -By [@lleadbet](https://github.com/lleadbet) in https://github.com/apollographql/router/pull/4212 - -### Allow customization of the health check endpoint path ([Issue #2938](https://github.com/apollographql/router/issues/2938)) - -Adds a configuration option for custom health check endpoints, `health_check.path`, with `/health` as the default value. - -By [@aaronArinder](https://github.com/aaronArinder) in https://github.com/apollographql/router/pull/4145 - -## 📚 Documentation - -### Coprocessors: Clarify capabilities of `RouterRequest` and `RouterResponse`'s `control` responses ([PR #4189](https://github.com/apollographql/router/pull/4189)) - -The coprocessor `RouterRequest` and `RouterResponse` stages already fully support `control: { break: 500 }`, but the response body *must* be a string. The documentation has been improved to provides examples in the [Terminating a client request](https://www.apollographql.com/docs/router/customizations/coprocessor#terminating-a-client-request) section. - -By [@lennyburdette](https://github.com/lennyburdette) in https://github.com/apollographql/router/pull/4189 - -## 🧪 Experimental - -### Support time-to-live (TTL) expiration for distributed cache entries ([Issue #4163](https://github.com/apollographql/router/issues/4163)) - -It is now possible to use configuration to set an expiration (time-to-live or TTL) for distributed caching (i.e., Redis) entries, both for APQ and query planning caches (using either `apq` or `query_planning`, respectively). By default, entries have no expiration. - -For example, to define the TTL for cached query plans stored in Redis to be 24 hours, the following configuration snippet could be used which specifies `ttl: 24h`. - -```yaml title="router.yaml" -supergraph: - query_planning: - experimental_cache: - redis: - urls: ["redis://..."] - timeout: 5ms # Optional, by default: 2ms - ttl: 24h # Optional, by default no expiration -``` - -Similarly, it is possible to set the cache for APQ entries. For details, see the [Distributed APQ caching](https://www.apollographql.com/docs/router/configuration/distributed-caching#distributed-apq-cachinghttps://www.apollographql.com/docs/router/configuration/distributed-caching#distributed-apq-caching) documentation. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4164 - -# [1.34.0] - 2023-11-15 - -## 🚀 Features - -### Authorization: dry run option ([Issue #3843](https://github.com/apollographql/router/issues/3843)) - -The `authorization.dry_run` option allows you to execute authorization directives without modifying a query while still returning the list of affected paths as top-level errors in a response. Use it to test authorization without breaking existing traffic. - -For details, see the documentation for [`authorization.dry_run`](https://www.apollographql.com/docs/router/configuration/authorization#dry_run). - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4079 - -### Rhai: support alternative base64 alphabets ([Issue #3783](https://github.com/apollographql/router/issues/3783)) - -When encoding or decoding strings, your Rhai customization scripts can now use alternative base64 alphabets in addition to the default `STANDARD`. - -The available base64 alphabets: - -* `STANDARD` -* `STANDARD_NO_PAD` -* `URL_SAFE` -* `URL_SAFE_NO_PAD` - -An example using an alphabet: - -``` -let original = "alice and bob"; -let encoded = base64::encode(original, base64::URL_SAFE); -// encoded will be "YWxpY2UgYW5kIGJvYgo=" -try { - let and_back = base64::decode(encoded, base64::URL_SAFE); - // and_back will be "alice and bob" -} -``` - -The default when the alphabet argument is not specified is `STANDARD`. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3885 - -### GraphOS authorization directives: `@policy` directive ([PR #3751](https://github.com/apollographql/router/pull/3751)) - -> ⚠️ This is an Enterprise feature of the Apollo Router. It requires an organization with a GraphOS Enterprise plan. -> -> If your organization doesn't currently have an Enterprise plan, you can test out this functionality by signing up for a free Enterprise trial. - -> The `@policy` directive requires using a [federation version `2.6`](https://www.apollographql.com/docs/federation/federation-versions). - -We introduce a new GraphOS authorization directive called `@policy` that is designed to offload authorization policy execution to a coprocessor or Rhai script. - -When executing an operation, the relevant policy will be determined based on `@policy` directives in the schema. The coprocessor or Rhai script then indicates which of those policies requirements are not met. Finally, the router filters out fields which are unauthorized in the same way it does when using `@authenticated` or `@requiresScopes` before executing the operation. - -For more information, see the [documentation](https://www.apollographql.com/docs/router/configuration/authorization#authorization-directives). - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3751 - -### Authorization directives are enabled by default ([Issue #3842](https://github.com/apollographql/router/issues/3842)) - -The authorization directives (`@requiresScopes`, `@authenticated`, `@policy`) are enabled by default and are usable without additional configuration under the following conditions: - -* The router starts with an API key from an Enterprise account. -* A schema contains authorization directives. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3713 - -### Add a flag to disable authorization error logs ([Issue #4077](https://github.com/apollographql/router/issues/4077) & [Issue #4116](https://github.com/apollographql/router/issues/4116)) - -Authorization errors need flexible reporting depending on the use case. They can now be configured with `authorization.preview_directives.errors` options: - -```yaml title="router.yaml" -authorization: - preview_directives: - errors: - log: true # default: true - response: "errors" # possible values: "errors" (default), "extensions", "disabled" -``` - -The `log` option allows platform operators to disable logged errors when they do not want to see the logs polluted by common authorization errors that occur frequently (such as those that are expected or ordinary like "not logged in"). - -The `response` option allows configuring how errors are returned to clients in various ways: - - `response: errors` places authorization errors in the GraphQL response `errors`. This is the default behavior. - - `response: extensions` places authorization errors in the response's `extensions` object which avoids raising exceptions with clients which are configured to reject operations which have `errors` . - - `response: disabled` will prevent the client from receiving any authorization errors. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/4076 & https://github.com/apollographql/router/pull/4122 - -### Add a new GraphOS Studio reporting metric ([Issue #3883](https://github.com/apollographql/router/issues/3883)) - -The new metric `apollo.router.telemetry.studio.reports` is a count of the number of reports the router submits to GraphOS Studio. - -Its `type` attribute sets the type of the report (`traces` or `metrics`). - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4039 - -## 🐛 Fixes - -### Bring OTel `service.name` in line with the OTel specification ([PR #4034](https://github.com/apollographql/router/pull/4034)) - -Handling of OpenTelemetry (OTel) `service.name` has been brought into line with the [OTel specification](https://opentelemetry.io/docs/concepts/sdk-configuration/general-sdk-configuration/#otel_service_name) across traces and metrics. - -Service name discovery is handled in the following order: -1. `OTEL_SERVICE_NAME` env -2. `OTEL_RESOURCE_ATTRIBUTES` env -3. `router.yaml` `service_name` -4. `router.yaml` `resources` (attributes) - -If none of the above are found then the service name will be set to `unknown_service:router` or `unknown_service` if the executable name cannot be determined. - -Users who have not explicitly configured their service name should do so with either the YAML config file or the `OTEL_SERVICE_NAME` environment variable. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4034 - -### Rename Helm template from `common.` to `apollographql.` ([Issue #4002](https://github.com/apollographql/router/issues/4002)) - -Previously there was a naming conflict between the router's Helm chart templates and Bitnami common templates, with the prefix `common`. - -To avoid the name conflict, the router's Helm chart templates are now renamed by changing `common.` to `apollographql.`. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4005 - -### Propagate headers for source stream events on subscriptions ([Issue #3731](https://github.com/apollographql/router/issues/3731)) - -The headers from subscription source stream events are now propagated to the subgraph request when the header propagation feature is configured. Previously, it was required to use Rhai script as a workaround to this limitation. That limitation has now been removed. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/4057 - -### Fix memory issues in the Apollo metrics exporter ([PR #4107](https://github.com/apollographql/router/pull/4107)) - -The Apollo metrics exporter has been improved to not overconsume memory under high load. - -Previously, the router appeared to leak memory when under load. The root cause was a bounded `futures` channel that did not enforce expected bounds on channel capacity and could overconsume memory. - -We have fixed the issue by: - - - Making the situation of channel overflow less likely to happen by speeding up metrics processing and altering the priority of metrics submission vs. metrics gathering. - - Switching to a `tokio` bounded channel that enforces the expected bound. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4107 - -### Support authorization directive renaming ([PR #3949](https://github.com/apollographql/router/pull/3949)) - -When importing directives into subgraph schemas using the `@link` directive, it is possible to rename the directive [using the `as:` parameter](https://specs.apollo.dev/link/v1.0/#@link.as). It is now possible to do this for authorization directives and they will be recognized appropriately even if they have been renamed. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3949 - -## 📃 Configuration - -### Bring telemetry tracing config and metrics config into alignment ([Issue #4043](https://github.com/apollographql/router/issues/4043)) - -Configuration between tracing and metrics was inconsistent and did not align with the terminology defined in the OpenTelemetry (OTel) specification. To correct this, the following changes have been made to the router's YAML configuration, `router.yaml`: - -`telemetry.tracing.trace_config` has been renamed to `common` - -```diff -telemetry - tracing: -- trace_config: -+ common: -``` - -`telemetry.tracing.common.attributes` has been renamed to `resource` -```diff -telemetry - tracing: - common: -- attributes: -+ resource: -``` - -`telemetry.metrics.common.resources` has been renamed to `resource` -```diff -telemetry - metrics: - common: -- resources: -+ resource: -``` -`telemetry.tracing.propagation.awsxray` has been renamed to `aws_xray` -```diff -telemetry - tracing: - propagation: -- awsxray: true -+ aws_xray: true -``` - -Although the router will upgrade any existing configuration on startup, you should update your configuration to use the new format as soon as possible. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4044, https://github.com/apollographql/router/pull/4050 and https://github.com/apollographql/router/pull/4051 - -## 🛠 Maintenance - -### Router should respond with `subscription-protocol` header for callback ([Issue #3929](https://github.com/apollographql/router/issues/3929)) - -The router will now include a `subscription-protocol: callback/1.0` header on the response to a initialization (check) message, per the [callback protocol documentation](https://www.apollographql.com/docs/router/executing-operations/subscription-callback-protocol). - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3939 - -### Use Trust DNS for hyper client resolver ([Issue #4030](https://github.com/apollographql/router/issues/4030)) - -The default hyper client DNS resolver was replaced with Trust DNS to reduce the memory footprint of the router. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4088 - -## 📚 Documentation - -### Clarify and fix docs about supported WebSocket subprotocols ([PR #4063](https://github.com/apollographql/router/pull/4063)) - -The documentation about setting up and configuring WebSocket protocols for router-to-subgraph communication has been improved, including clarifying how to set the subgraph path that exposes WebSocket capabilities. - -For details, see the [updated documentation](https://www.apollographql.com/docs/router/executing-operations/subscription-support/#websocket-setup) - -By [@shorgi](https://github.com/shorgi) in https://github.com/apollographql/router/pull/4063 - - - -# [1.33.2] - 2023-10-26 - -## 🐛 Fixes - -### Ensure `apollo_router_http_requests_total` metrics match ([Issue #4047](https://github.com/apollographql/router/issues/4047)) -Identically _named_ metrics were being emitted for `apollo_router_http_requests_total` (as intended) but with different _descriptions_ (not intended) resulting in occasional, but noisy, log warnings: -``` -OpenTelemetry metric error occurred: Metrics error: Instrument description conflict, using existing. -``` -The metrics' descriptions have been brought into alignment to resolve the log warnings and we will follow-up with additional work to think holistically about a more durable pattern that will prevent this from occurring in the future. -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4089 - - - -# [1.33.1] - 2023-10-20 - -## 🐛 Fixes - -### Ensure `apollo_router_http_requests_total` metrics match ([Issue #4047](https://github.com/apollographql/router/issues/4047)) - -Identically _named_ metrics were being emitted for `apollo_router_http_requests_total` (as intended) but with different _descriptions_ (not intended) resulting in occasional, but noisy, log warnings: - -``` -OpenTelemetry metric error occurred: Metrics error: Instrument description conflict, using existing. -``` - -The metrics' descriptions have been brought into alignment to resolve the log warnings and we will follow-up with additional work to think holistically about a more durable pattern that will prevent this from occurring in the future. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4065 - - - -# [1.33.0] - 2023-10-17 - -## 🚀 Features - -### Add `hasNext` to SupergraphRequest ([Issue #4016](https://github.com/apollographql/router/issues/4016)) - -Coprocessors multi-part response support has been enhanced to include `hasNext`, allowing you to determine when a request has completed. - -When `stage` is `SupergraphResponse`, `hasNext` if present and `true` indicates that there will be subsequent `SupergraphResponse` calls to the co-processor for each multi-part (`@defer`/subscriptions) response. - -See the [coprocessor documentation](https://www.apollographql.com/docs/router/customizations/coprocessor/) for more details. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4017 - -### Expose the ability to set topology spread constraints on the helm chart ([3891](https://github.com/apollographql/router/issues/3891)) - -Give developers the ability to set topology spread constraints that can be used to guarantee that federation pods are spread out evenly across AZs. - -By [bjoern](https://github.com/bjoernw) in https://github.com/apollographql/router/pull/3892 - -## 🐛 Fixes - -### Ignore JWKS keys which aren't supported by the router ([Issue #3853](https://github.com/apollographql/router/issues/3853)) - -If you have a JWKS which contains a key which has an algorithm (alg) which the router doesn't recognise, then the entire JWKS is disregarded even if there were other keys in the JWKS which the router could use. - -We have changed the JWKS processing logic so that we remove entries with an unrecognised algorithm from the list of available keys. We print a warning with the name of the algorithm for each removed entry. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3922 - -### Fix panic when streaming responses to co-processor ([Issue #4013](https://github.com/apollographql/router/issues/4013)) - -Streamed responses will no longer cause a panic in the co-processor plugin. This affected defer and stream queries. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/4014 - -### Only reject defer/subscriptions if actually part of a batch ([Issue #3956](https://github.com/apollographql/router/issues/3956)) - -Fix the checking logic so that deferred queries or subscriptions will only be rejected when experimental batching is enabled and the operations are part of a batch. - -Without this fix, all subscriptions or deferred queries would be rejected when experimental batching support was enabled. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3959 - -### Fix requires selection in arrays ([Issue #3972](https://github.com/apollographql/router/issues/3972)) - -When a field has a `@requires` annotation that selects an array, and some fields are missing in that array or some of the elements are null, the router would short circuit the selection and remove the entire array. This relaxes the condition to allow nulls in the selected array - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3975 - -### Fix router hang when opening the explorer, prometheus or health check page ([Issue #3941](https://github.com/apollographql/router/issues/3941)) - -The Router did not gracefully shutdown when an idle connections are made by a client, and would instead hang. In particular, web browsers make such connection in anticipation of future traffic. - -This is now fixed, and the Router will now gracefully shut down in a timely fashion. - - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3969 - -### Fix hang and high CPU usage when compressing small responses ([PR #3961](https://github.com/apollographql/router/pull/3961)) - -When returning small responses (less than 10 bytes) and compressing them using gzip, the router could go into an infinite loop - - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3961 - -## 📃 Configuration - -### Add `enabled` field for telemetry exporters ([PR #3952](https://github.com/apollographql/router/pull/3952)) - -Telemetry configuration now supports `enabled` on all exporters. This allows exporters to be disabled without removing them from the configuration and in addition allows for a more streamlined default configuration. - -```diff -telemetry: - tracing: - datadog: -+ enabled: true - jaeger: -+ enabled: true - otlp: -+ enabled: true - zipkin: -+ enabled: true -``` - -Existing configurations will be migrated to the new format automatically on startup. However, you should update your configuration to use the new format as soon as possible. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3952 - -## 🛠 Maintenance - -### Create a replacement self-signed server certificate: 10 years lifespan ([Issue #3998](https://github.com/apollographql/router/issues/3998)) - -This certificate is only used for testing, so 10 years lifespan is acceptable. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/4009 - -## 📚 Documentation - -### Updated documentation for deploying router ([PR #3943](https://github.com/apollographql/router/pull/3943)) - -Updated documentation for containerized router deployments, with guides and examples for [deploying on Kubernetes](https://www.apollographql.com/docs/router/containerization/kubernetes) and [running on Docker](https://www.apollographql.com/docs/router/containerization/docker). - -By [@shorgi](https://github.com/shorgi) in https://github.com/apollographql/router/pull/3943 - -### Document guidance for request and response buffering ([Issue #3838](https://github.com/apollographql/router/issues/3838)) - -Provides specific guidance on request and response buffering within the router. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3970 - - - -# [1.32.0] - 2023-10-04 - -## 🚀 Features - -### Move persisted queries to general availability ([PR #3914](https://github.com/apollographql/router/pull/3914)) - -[Persisted Queries](https://www.apollographql.com/docs/graphos/operations/persisted-queries/) (a GraphOS Enterprise feature) is now moving to General Availability, from Preview where it has been since Apollo Router 1.25. In addition to Safelisting, persisted queries can now also be used to [pre-warm the query plan cache](https://github.com/apollographql/router/releases/tag/v1.31.0) to speed up schema updates. - - -The feature is now configured with a `persisted_queries` top-level key in the YAML configuration instead of with `preview_persisted_queries`. Existing configuration files will keep working as before, but with a warning that can be resolved by renaming the configuration section from `preview_persisted_queries` to `persisted_queries`: - -```diff --preview_persisted_queries: -+persisted_queries: - enabled: true -``` - -By [@glasser](https://github.com/glasser) in https://github.com/apollographql/router/pull/3914 - -## 🐛 Fixes - -### Allow coprocessor to return error message ([PR #3806](https://github.com/apollographql/router/pull/3806)) - -Previously, a regression prevented an error message string from being returned in the body of a coprocessor request. That regression has been fixed, and a coprocessor can once again [return with an error message](https://www.apollographql.com/docs/router/customizations/coprocessor#terminating-a-client-request): - -```json -{ - "version": 1, - "stage": "SubgraphRequest", - "control": { - "break": 401 - }, - "body": "my error message" -} -``` - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3806 - - - -## 🛠 Maintenance - -### Update to OpenTelemetry 0.20.0 ([PR #3649](https://github.com/apollographql/router/pull/3649)) - -The router now uses OpenTelemetry 0.20.0. This includes a number of fixes and improvements from upstream. - -In particular metrics have some significant changes: -* Prometheus metrics are now aligned with the [OpenTelemetry spec](https://opentelemetry.io/docs/specs/otel/compatibility/prometheus_and_openmetrics/), and will not report `service_name` on each individual metric. Resource attributes are now moved to a single `target_info` metric. - - Users should check that their dashboards and alerts are properly configured when upgrading. - -* The default service name for metrics is now `unknown_service` as per the [OpenTelemetry spec](https://opentelemetry.io/docs/concepts/sdk-configuration/general-sdk-configuration/#otel_service_name). - - Users should ensure to configure service name via router.yaml, or via the `OTEL_SERVICE_NAME` environment variable. - -* The order of priority for setting service name has been brought into line with the rest of the router configuration. The order of priority is now: - 1. `OTEL_RESOURCE_ATTRIBUTES` environment variable - 2. `OTEL_SERVICE_NAME` environment variable - 3. `resource_attributes` in router.yaml - 4. `service_name` in router.yaml - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3649 - -### Fix type handling for telemetry metric counter ([Issue #3865](https://github.com/apollographql/router/issues/3865)) - -Previously, the assignment of some telemetry metric counters may not have succeeded because the assignment type wasn't accounted for. For example, the following panicked in debug mode because `1` wasn't `1u64`: - -```rust -tracing::info!( - monotonic_counter - .apollo - .router - .operations - .authentication - .jwt = 1, - authentication.jwt.failed = true -) -``` - -This issue has been fixed by adding more supported types for metric counters. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3868 - - -## 🧪 Experimental - -### Support for query batching ([Issue #126](https://github.com/apollographql/router/issues/126)) - -An experimental implementation of query batching has been added to support client request batching in the Apollo Router. - -If you’re using Apollo Client, you can leverage its built-in support for batching to reduce the number of individual requests sent to the Apollo Router. - -Once [configured](https://www.apollographql.com/docs/react/api/link/apollo-link-batch-http/), Apollo Client automatically combines multiple operations into a single HTTP request. The number of operations within a batch is client configurable, including the maximum number of operations in a batch and the maximum duration to wait for operations to accumulate before sending the batch request. - -The Apollo Router must be configured to receive batch requests, otherwise it rejects them. When processing a batch request, the router deserializes and processes each operation of a batch independently, and it responds to the client only after all operations of the batch have been completed. - -```yaml -experimental_batching: - enabled: true - mode: batch_http_link -``` - -All operations within a batch execute concurrently with respect to each other. - -Don't use subscriptions or `@defer` queries within a batch, as they are unsupported. - -For details, see the documentation for [query batching](https://www.apollographql.com/docs/router/executing-operations/query-batching). - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3837 - - - -# [1.31.0] - 2023-09-27 - -## 🚀 Features - -### TLS client authentication for subgraph requests ([Issue #3414](https://github.com/apollographql/router/issues/3414)) - -The router now supports TLS client authentication when connecting to subgraphs. It can be configured as follows: - -```yaml -tls: - subgraph: - all: - client_authentication: - certificate_chain: - key: - # if configuring for a specific subgraph: - subgraphs: - # subgraph name - products: - client_authentication: - certificate_chain: - key: -``` - -Details on TLS client authentication can be found in the [documentation](https://www.apollographql.com/docs/router/configuration/overview#tls-client-authentication-for-subgraph-requests) - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3794 - -### Added configuration to set redis request timeout ([Issue #3621](https://github.com/apollographql/router/issues/3621)) - -We added configuration to override default timeout for Redis requests. Default timeout was also changed from 1ms to **2ms**. - -Here is an example to change the timeout for [Distributed APQ](https://www.apollographql.com/docs/router/configuration/distributed-caching#distributed-apq-caching) (an Enterprise Feature): -```yaml -apq: - router: - cache: - redis: - urls: ["redis://..."] - timeout: 5ms -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3817 - -### JSON encoding and decoding in Rhai ([PR #3785](https://github.com/apollographql/router/pull/3785)) - -It is now possible to encode or decode JSON from Rhai scripts using `json::encode` and `json::decode`. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3785 - -### Supergraph coprocessor implementation ([PR #3647](https://github.com/apollographql/router/pull/3647)) - -Coprocessors now support supergraph service interception. - -On the request side, the coprocessor payload can contain: -- method -- headers -- body -- context -- sdl - -On the response side, the payload can contain: -- status_code -- headers -- body -- context -- sdl - -The supergraph request body contains: -- query -- operation name -- variables -- extensions - -The supergraph response body contains: -- label -- data -- errors -- extensions - -When using `@defer` or subscriptions a supergraph response may contain multiple GraphQL responses. The coprocessor will be called for each response. Please refer to our [coprocessor documentation](https://www.apollographql.com/docs/router/customizations/coprocessor) for more information. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3647 - -### Adds support for the OpenTelemetry AWS X-Ray tracing propagator ([PR #3580](https://github.com/apollographql/router/pull/3580)) - -This propagator helps propagate tracing information from upstream services (such as AWS load balancers) to downstream services. It also handles conversion between the X-Ray trace id format and OpenTelemetry span contexts. - -By [@scottmace](https://github.com/scottmace) in https://github.com/apollographql/router/pull/3580 - -### HTTP/2 Cleartext protocol (H2C) support for subgraph connections ([Issue #3535](https://github.com/apollographql/router/issues/3535)) - -The router can now connect to subgraphs over HTTP/2 Cleartext (H2C), which uses the HTTP/2 binary protocol directly over TCP **without TLS**, which is a mode of operation desired with some service mesh configurations (e.g., Istio, Envoy) where the value of added encryption is unnecessary. To activate it, set the `experimental_http2` option to `http2_only`. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3852 - -### Query plan cache warm-up improvements ([Issue #3704](https://github.com/apollographql/router/issues/3704), [Issue #3767](https://github.com/apollographql/router/issues/3767)) - -The `warm_up_queries` option enables quicker schema updates by precomputing query plans for your most used cached queries and your persisted queries. When a new schema is loaded, a precomputed query plan for it may already be in the in-memory cache. - -We made a series of improvements to this feature to make it easier to use: -* It is now active by default. -* It warms up the cache with the 30% most used queries from previous cache. -* The query cache percentage continues to be configurable, and it can be deactivated by setting it to 0. -* The warm-up will now plan queries in random order to make sure that the work can be shared by multiple router instances using distributed caching. -* Persisted queries are part of the warmed up queries. - -We also added histogram metrics for `apollo_router_query_planning_warmup_duration` and `apollo_router_schema_load_duration`. These metrics make it easier to track the time spent loading a new schema and planning queries in the warm-up phase. You can measure the query plan cache usage for both the in-memory-cache and distributed cache. This makes it easier to know how many entries are used as well as the cache hit rate. - -Here is what these metrics would look like in Prometheus: - -``` -# HELP apollo_router_query_planning_warmup_duration apollo_router_query_planning_warmup_duration -# TYPE apollo_router_query_planning_warmup_duration histogram -apollo_router_query_planning_warmup_duration_bucket{service_name="apollo-router",otel_scope_name="apollo/router",otel_scope_version="",le="0.05"} 1 -apollo_router_query_planning_warmup_duration_bucket{service_name="apollo-router",otel_scope_name="apollo/router",otel_scope_version="",le="0.1"} 1 -apollo_router_query_planning_warmup_duration_bucket{service_name="apollo-router",otel_scope_name="apollo/router",otel_scope_version="",le="0.25"} 1 -apollo_router_query_planning_warmup_duration_sum{service_name="apollo-router",otel_scope_name="apollo/router",otel_scope_version=""} 0.022390619 -apollo_router_query_planning_warmup_duration_count{service_name="apollo-router",otel_scope_name="apollo/router",otel_scope_version=""} 1 -# HELP apollo_router_schema_load_duration apollo_router_schema_load_duration -# TYPE apollo_router_schema_load_duration histogram -apollo_router_schema_load_duration_bucket{service_name="apollo-router",otel_scope_name="apollo/router",otel_scope_version="",le="0.05"} 8 -apollo_router_schema_load_duration_bucket{service_name="apollo-router",otel_scope_name="apollo/router",otel_scope_version="",le="0.1"} 8 -apollo_router_schema_load_duration_bucket{service_name="apollo-router",otel_scope_name="apollo/router",otel_scope_version="",le="0.25"} 8 -``` - -You can get more information about operating the query plan cache and its warm-up phase in the [documentation](https://www.apollographql.com/docs/router/configuration/in-memory-caching#cache-warm-up) - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3815 https://github.com/apollographql/router/pull/3801 https://github.com/apollographql/router/pull/3767 https://github.com/apollographql/router/pull/3769 https://github.com/apollographql/router/pull/3770 - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3807 - -## 🐛 Fixes - -### Fix error response on large number types in query transformations ([PR #3820](https://github.com/apollographql/router/pull/3820)) - -This bug caused the router to reject operations where a large hardcoded integer was used as input for a Float field: - -```graphql -# Schema -type Query { - field(argument: Float): Int! -} -# Operation -{ - field(argument: 123456789123) -} -``` - -This number is now correctly interpreted as a `Float`. This bug only affected hardcoded numbers, not numbers provided through variables. - -By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/3820 - -### Fix validation error with `ID` variable values overflowing 32-bit integers ([Issue #3873](https://github.com/apollographql/router/issues/3873)) - -Input values for variables of type `ID` were previously validated as "either like a GraphQL `Int` or like a GraphQL `String`". GraphQL `Int` is specified as a signed 32-bit integer, such that values that overflow fail validation. Applying this range restriction to `ID` values was incorrect. Instead, validation for `ID` now accepts any JSON integer or JSON string value, so that IDs larger than 32 bits can be used. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/3896 - -### Improve multi-cloud failover and error handling for Persisted Queries ([PR #3863](https://github.com/apollographql/router/pull/3863)) - -Improves the resilience of the Persisted Queries feature to Uplink outages. This makes errors while fetching persisted query manifests from Uplink more visible. - -By [@glasser](https://github.com/glasser) in https://github.com/apollographql/router/pull/3863 - -### Coprocessors: Discard content-length sent by coprocessors ([PR #3802](https://github.com/apollographql/router/pull/3802)) - -The `content-length` of an HTTP response can only be computed when a router response is being sent. -We now discard coprocessors `content-length` header to make sure the value is computed correctly. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3802 - -### Helm: If there are `extraLabels` add them to all resources ([PR #3622](https://github.com/apollographql/router/pull/3622)) - -This extends the functionality of `extraLabels` so that, if they are defined, they will be templated for all resources created by the chart. - -Previously, they were only templated onto the `Deployment` resource. - -By [@garypen](https://github.com/garypen) and [@bjoernw](https://github.com/bjoernw) in https://github.com/apollographql/router/pull/3622 - -## 📚 Documentation - -### Rhai documentation: remove incorrect statement about request.subgraph fields ([PR #3808](https://github.com/apollographql/router/pull/3808)) - -It is possible to modify `request.subgraph` fields from a Rhai script, which is now correctly reflected in [Rhai documentation](https://www.apollographql.com/docs/router/customizations/rhai-api/#request-interface). - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3808 - - - -# [1.30.1] - 2023-09-22 - -## 🐛 Fixes - -### Fix Studio reporting when it is not configured ([Issue #3871](https://github.com/apollographql/router/issues/3871)) - -We have fixed a bug that crept into v1.30.0 where reporting traces and metrics to Apollo Studio did not occur _unless_ the `apollo` section was defined in `telemetry` within configuration. This means that a relatively simple setup where _only_ the `APOLLO_KEY` and `APOLLO_GRAPH_REF` environment variables were set, reporting was not working. This is now corrected. Upgrading to v1.30.1 is straightforward, however, in the event that an upgrade from v1.30.0 to v1.30.1 is _not_ possible (for example, don't want to deploy on a Friday!), then the configuration can be set to an empty object and reporting will resume. An example of this is available on the referenced issue. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3881 - -# [1.30.0] - 2023-09-14 - -## 🚀 Features - -### Rhai Support at the `router_service` ([Issue #2278](https://github.com/apollographql/router/issues/2278)) - -It is now possible to interact with some aspects of requests and responses at the `router_service` level [using Rhai-based customizations](https://www.apollographql.com/docs/router/customizations/rhai/). The functionality is very similar to that provided for interacting with existing services, for example `supergraph_service`. For instance, you may "map" requests and responses as follows: - -```rust -fn router_service(service) { - const request_callback = Fn("process_request"); - service.map_request(request_callback); - const response_callback = Fn("process_response"); - service.map_response(response_callback); -} -``` - -The main difference from [existing services](https://www.apollographql.com/docs/router/customizations/rhai/#router-request-lifecycle) is that the `router_service` allows operating at an HTTP transport layer rather than the more structured GraphQL representations available at later service layers, like the [supergraph service](https://www.apollographql.com/docs/router/customizations/rhai/#supergraphservice). - -Initially, we are **not** allowing access to the `body` property itself. [This issue](https://github.com/apollographql/router/issues/3642) tracks changing that in the future. For now, it is possible to access the `context` and `headers`. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3234 - -## 🐛 Fixes - -### Small performance improvements to telemetry ([PR #3656](https://github.com/apollographql/router/pull/3656)) - -We applied some small performance improvements to the `SpanMetricsExporter` (which is used to report span timings), some of which apply in cases where telemetry is disabled and could be apparent to most users. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3656 - -### Handle interfaces in fragment spreads when `__typename` is omitted ([Issue #2587](https://github.com/apollographql/router/issues/2587)) - -We now check the parent type when using an inline-fragment, rather than relying on the expectation that `__typename` will be present. For cases where `__typename` was being omitted, this fixes responses where a portion of the selection set was silently dropped and not returned. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) and [@geal](https://github.com/geal) in https://github.com/apollographql/router/pull/3718 - -### Deduplication is, again, enabled by default as documented ([PR #3773](https://github.com/apollographql/router/pull/3773)) - -[Subscription deduplication](https://www.apollographql.com/docs/router/executing-operations/subscription-support#subscription-deduplication) is again enabled by default as it was intended to be. This important performance feature for subscriptions at scale was inadvertently disabled in v1.25.0 due to a bug. - -To explicitly disable deduplication, [set `enable_deduplication` to `false` in your configuration](https://www.apollographql.com/docs/router/executing-operations/subscription-support/#disabling-deduplication). - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3773 - -### Metrics are no longer coerced incorrectly ([Issue #3687](https://github.com/apollographql/router/issues/3687)) - -Metric attributes are no longer incorrectly coerced to strings. In addition, the logic around types which are accepted as metrics attributes has been simplified to avoid this in the future. Going forward, if the wrong type is specified, values will be ignored and a log message (at debug level) will be emitted. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3724 - -### Optimizations applied to header-handling operations ([Issue #3068](https://github.com/apollographql/router/issues/3068)) - -Latency and overhead of passing headers to subgraph queries has been reduced. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3721 - -### Avoid request overhead when telemetry is not enabled - -The overhead of OpenTelemetry has been removed when no tracing exporters are configured. - -This also improves performance when sampling criteria has _not_ been met by preventing unsampled sampled trace events from propagating to the rest of the OpenTelemetry stack. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2999 - -### Subgraph authentication: Apply signature after compression and APQ ([Issue #3608](https://github.com/apollographql/router/issues/3608)) - -The router will now _sign_ subgraph requests _just before_ they are sent to the subgraph (i.e., a bit later than previously), following up on the functionality of [subgraph authentication](https://www.apollographql.com/docs/router/configuration/authn-subgraph) which was first introduced in v1.27.0. - -This fixes interactions with: - - - Subgraph Automatic Persisted Queries (APQ) - - Subgraph HTTP compression - - Custom plugins that operate on the subgraph service (whether via Co-Processors, Rhai or a compiled Rust plugin) - -In most cases, the interactions between these features and the subgraph authentication feature were problematic and required disabling one or the other in order to generate a request that was correctly signed by the signature algorithm. This should all be resolved. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3735 - -### Handle multipart stream if the original stream was empty ([Issue #3293](https://github.com/apollographql/router/issues/3293)) - -Multi-part response streams (which are used for [subscriptions](https://www.apollographql.com/docs/router/executing-operations/subscription-support/) and operations which include [`@defer` directive](https://www.apollographql.com/docs/router/executing-operations/defer-support/)) are now terminated correctly when the response stream is empty. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3748 - -### Subscriptions: Include `x-accel-buffering` header on multipart responses ([Issue #3683](https://github.com/apollographql/router/issues/3683)) - -Setting the `x-accel-buffering` header to `no` for multipart responses allows certain proxies to configure themselves in a mode that is compatible with the buffering used by subscriptions. This improves Subscriptions' compatibility with existing infrastructure. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3749 - -## 🛠 Maintenance - -### Our Rust Toolchain has been updated to v1.72.0 ([PR #3707](https://github.com/apollographql/router/pull/3707)) - -Our Rust Toolchain has been updated to v1.72.0. For the majority of our users (those who do not compile their own Router from source), this change will not have any impact. Otherwise, Rust 1.72.0 can now be used. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3707 - - -### Replace `atty` crate with `std` ([PR #3729](https://github.com/apollographql/router/pull/3729)) - -To resolve a security advisory (for which our usage was _not_ affected), we've replaced `atty` with `std`. Instead, we now use equivalent functionality available in the Rust standard library, available since Rust v1.70.0. - -* https://github.com/apollographql/router/security/dependabot/68 -* https://doc.rust-lang.org/stable/std/io/trait.IsTerminal.html - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/3729 - -### Upgrade `webpki` and `rustls-webpki` ([PR #3728](https://github.com/apollographql/router/pull/3728)) - -These two dependency updates brings fixes for two separate security advisories: - -* https://rustsec.org/advisories/RUSTSEC-2023-0052 -* https://rustsec.org/advisories/RUSTSEC-2023-0053 - -Since Apollo Router does _not_ accept client certificates, it could only have been affected if a subgraph had provided a pathological TLS server certificate. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/3728 - -## 📚 Documentation - -### GraphOS authorization: Exemplify scope manipulation with Rhai at the router service level ([PR #3719](https://github.com/apollographql/router/pull/3719)) - -New [Authorization documentation](https://www.apollographql.com/docs/router/configuration/authorization/#requiresscopes) shows the how to use Rhai script to extract scopes and prepare them in the correct way, for use with `@requiresScope`. This becomes relevant since `@requiresScopes` expects scopes to come from the `scope` claim in the [OAuth2 access token format](https://datatracker.ietf.org/doc/html/rfc6749#section-3.3) while tokens may have scopes stored differently, e.g., as an array of strings, or even as different claims. If you have further questions on the right choice for you, please open a GitHub Discussion that provides an example of what you need to achieve. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3719 - - -# [1.29.1] - 2023-09-04 - -## 🚀 Features - -### GraphOS Enterprise: Authorization ([PR #3397](https://github.com/apollographql/router/pull/3397), [PR #3662](https://github.com/apollographql/router/pull/3662)) - -> ⚠️ This is an [Enterprise feature](https://www.apollographql.com/blog/platform/evaluating-apollo-router-understanding-free-and-open-vs-commercial-features/) of the Apollo Router. It requires an organization with a [GraphOS Enterprise plan](https://www.apollographql.com/pricing/). - -If your organization doesn't currently have an Enterprise plan, you can test out this functionality by signing up for a free [Enterprise trial](https://www.apollographql.com/docs/graphos/org/plans/#enterprise-trials). -We introduce two new directives, `@requiresScopes` and `@authenticated`, that define authorization policies for fields and types in the supergraph schema, composed with Federation version 2.5.3 or higher. - -They are defined as follows: - -```graphql -scalar federation__Scope -directive @requiresScopes(scopes: [[federation__Scope!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM -``` - -This directive allows granular access control through user-defined scopes. - -```graphql -directive @authenticated on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM -``` - -This directive allows access to the annotated field or type for authenticated requests only. -For more information on how to use these directives, please read Apollo Router [docs](https://www.apollographql.com/docs/router/configuration/authorization) - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3397 https://github.com/apollographql/router/pull/3662 - -## 🐛 Fixes - -### Subscriptions: Correct v1.28.x regression allowing panic via un-named subscription operation - -Correct a regression that was introduced in Router v1.28.0 which made a Router **panic** possible when the following _three_ conditions are _all_ met: - -1. When sending an un-named (i.e., "anonymous") `subscription` operation (e.g., `subscription { ... }`); **and**; -2. The Router has a `subscription` type defined in the Supergraph schema; **and** -3. Have subscriptions enabled (they are disabled by default) in the Router's YAML configuration, either by setting `enabled: true` _or_ by setting a `mode` within the `subscriptions` object (as seen in [the subscriptions documentation](https://www.apollographql.com/docs/router/executing-operations/subscription-support/#router-setup). - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3738 - - -### Update Deno to resolve Rust Docs generation failure ([Issue #3305](https://github.com/apollographql/router/issues/3305)) - -We've updated to the latest version of Deno (0.200) to fix errors when generating [docs.rs/apollo-router](https://docs.rs/crate/apollo-router/latest). - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3626 - -### GraphQL response processing is now captured under the execution span ([PR #3732](https://github.com/apollographql/router/pull/3732)) - -Ensure processing is captured under the "execution" span. Previously, events would be reported under the supergraph span or — even more arbitrarily — any plugin's span (e.g., Rhai). - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3732 - -## 🛠 Maintenance - -### Apollo Uplink connections re-use the existing HTTP client ([Issue #3333](https://github.com/apollographql/router/issues/3333)) - -A single HTTP client will now be shared between requests of the same type when making requests to [Apollo Uplink](https://www.apollographql.com/docs/federation/managed-federation/uplink/) to fetch supergraphs, licenses and configuration from Studio. Previously, such requests created a new HTTP client on each periodic fetch which occasionally resulted in CPU spikes, especially on macOS. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3703 - -### Remove unneeded schema parsing steps ([PR #3547](https://github.com/apollographql/router/pull/3547)) - -Access to a parsed schema is required in various parts of the Router. Previously were were parsing the schema multiple times, but this is now fixed. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3547 - -# [1.29.0] - 2023-09-04 - -> **Warning** -> -> **This version has a critical bug impacting anyone using subscriptions. See the _Fixes_ in [v1.29.1](https://github.com/apollographql/router/releases/tag/v1.29.1) for details. We highly recommend using v1.29.1 over v1.29.0 when using subscriptions.** - -## 🚀 Features - -### GraphOS Enterprise: Authorization ([PR #3397](https://github.com/apollographql/router/pull/3397), [PR #3662](https://github.com/apollographql/router/pull/3662)) - -> ⚠️ This is an [Enterprise feature](https://www.apollographql.com/blog/platform/evaluating-apollo-router-understanding-free-and-open-vs-commercial-features/) of the Apollo Router. It requires an organization with a [GraphOS Enterprise plan](https://www.apollographql.com/pricing/). - -If your organization doesn't currently have an Enterprise plan, you can test out this functionality by signing up for a free [Enterprise trial](https://www.apollographql.com/docs/graphos/org/plans/#enterprise-trials). -We introduce two new directives, `@requiresScopes` and `@authenticated`, that define authorization policies for fields and types in the supergraph schema, composed with Federation version 2.5.3 or higher. - -They are defined as follows: - -```graphql -scalar federation__Scope -directive @requiresScopes(scopes: [[federation__Scope!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM -``` - -This directive allows granular access control through user-defined scopes. - -```graphql -directive @authenticated on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM -``` - -This directive allows access to the annotated field or type for authenticated requests only. -For more information on how to use these directives, please read Apollo Router [docs](https://www.apollographql.com/docs/router/configuration/authorization) - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3397 https://github.com/apollographql/router/pull/3662 - -## 🐛 Fixes - -### Update Deno to resolve Rust Docs generation failure ([Issue #3305](https://github.com/apollographql/router/issues/3305)) - -We've updated to the latest version of Deno (0.200) to fix errors when generating [docs.rs/apollo-router](https://docs.rs/crate/apollo-router/latest). - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3626 - -### GraphQL response processing is now captured under the execution span ([PR #3732](https://github.com/apollographql/router/pull/3732)) - -Ensure processing is captured under the "execution" span. Previously, events would be reported under the supergraph span or — even more arbitrarily — any plugin's span (e.g., Rhai). - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3732 - -## 🛠 Maintenance - -### Apollo Uplink connections re-use the existing HTTP client ([Issue #3333](https://github.com/apollographql/router/issues/3333)) - -A single HTTP client will now be shared between requests of the same type when making requests to [Apollo Uplink](https://www.apollographql.com/docs/federation/managed-federation/uplink/) to fetch supergraphs, licenses and configuration from Studio. Previously, such requests created a new HTTP client on each periodic fetch which occasionally resulted in CPU spikes, especially on macOS. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3703 - -### Remove unneeded schema parsing steps ([PR #3547](https://github.com/apollographql/router/pull/3547)) - -Access to a parsed schema is required in various parts of the Router. Previously were were parsing the schema multiple times, but this is now fixed. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3547 - -# [1.28.1] - 2023-08-28 - -> **Warning** -> -> **This version has a critical bug impacting anyone using subscriptions. See the _Fixes_ in [v1.29.1](https://github.com/apollographql/router/releases/tag/v1.29.1) for details. We highly recommend using v1.29.1 over any v1.28.x version when using subscriptions.** - -## 🚀 Features - -### Expose the `stats_reports_key` hash to plugins. ([Issue #2728](https://github.com/apollographql/router/issues/2728)) - -This exposes a new key in the `Context`, `apollo_operation_id`, which identifies operation you can find in studio: - -``` -https://studio.apollographql.com/graph//variant//operations?query= -``` - -The `apollo_operation_id` context key is exposed during: - -- Execution service request -- Subgraph service request -- Subgraph service response -- Execution service response -- Supergraph service response -- Router service response - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3586 - -### Add new (unstable) metrics ([PR #3609](https://github.com/apollographql/router/pull/3609)) - -Many of our existing metrics are poorly and inconsistently named. In addition, they follow Prometheus style rather than Otel style. - -This changeset adds some new metrics that will give us a good foundation to build upon. -New metrics are namespaced `apollo.router.operations.*`. - -These metrics should be treated as unstable and may change in the future. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3609 - - -### Expose the number of subgraph fetches in `QueryPlan` ([#3658](https://github.com/apollographql/router/issues/3658)) - -Add a new `subgraph_fetches` method for the `QueryPlan` type that exposes the number of expected subgraph fetches for a given query plan. - -By [@nmoutschen](https://github.com/nmoutschen) in https://github.com/apollographql/router/pull/3659 - -## 🐛 Fixes - -### Flush metrics when Router reloads or shuts down ([Issue #3140](https://github.com/apollographql/router/issues/3140)) - -When the Router either reloads or shuts down it now flushes metrics. -Push metrics exporters, such as OTLP, would have previously missed some metrics — in particular those related to _reload_ events. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3143 - -### Helm: Declare `extraContainers` prior to the router container ([Issue #3632](https://github.com/apollographql/router/issues/3632)) - -Currently, in our Helm chart, `extraContainers` are declared _after_ the router container. Moving the `extraContainers` _ahead_ of the router container will make it simpler to co-ordinate container startup sequencing and take full advantage of Kubernetes' lifecycle hooks. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3633 - -### Fix memory leak caused by `Arc` circular reference in `Notify` ([Issue #3686](https://github.com/apollographql/router/issues/3686)) - -A [memory leak](https://github.com/apollographql/router/issues/3686) caused by a [change](https://github.com/apollographql/router/pull/3341) to subscription handling was fixed. - -By [@xuorig](https://github.com/xuorig) in https://github.com/apollographql/router/pull/3692 - -### Fix GraphQL block-comment parser regression ([Issue #3680](https://github.com/apollographql/router/issues/3680)) - -In 1.28.0, the GraphQL parser falsely errored out on backslashes in block comments, such as: - -```graphql -""" -A regex: '/\W/' -A path: PHP\Namespace\Class -""" -``` - -This now parses again. - -By [@goto-bus-stop](https://github.com/goto-bus-stop) in [PR #3675](https://github.com/apollographql/router/pull/3675) and [`apollo-rs#638`](https://github.com/apollographql/apollo-rs/pull/638). - -### Error no longer reported on Redis cache misses ([Issue #2876](https://github.com/apollographql/router/issues/2876)) - -The Router will no longer log an error in when fetching from Redis and the record doesn't exist. This affected APQ, QueryPlanning and experimental entity caching. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3661 - -## 🛠 Maintenance - -### Upgrade to Rust 1.71.1 ([PR #3536](https://github.com/apollographql/router/pull/3536)) - -This includes the fix for [CVE-2023-38497](https://blog.rust-lang.org/2023/08/03/cve-2023-38497.html). - -Although Apollo was not affected, users building custom binaries should consider their own build environments to determine if they were impacted. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/3536 - -### Add Apollo OpenTelemetry metrics ([PR #3354](https://github.com/apollographql/router/pull/3354), [PR #3651](https://github.com/apollographql/router/pull/3651)) - -We've added an OpenTelemetry metrics exporter which compliments and builds upon our existing Apollo Studio Protobuf format for metric transmission. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3354 and https://github.com/apollographql/router/pull/3651 - -## 📚 Documentation - -### Clarify that hot-reload does not affect Uplink-delivered config/schema ([PR #3596](https://github.com/apollographql/router/pull/3596)) - -This documentation adjustment (and small CLI help change) tries to clarify some confusion around the `--hot-reload` command line argument and the scope of its operation. - -Concretely, the supergraph and configuration that is delivered through a [GraphOS Launch](https://www.apollographql.com/docs/graphos/delivery/launches/) (and delivered through Uplink) is _always_ loaded immediately and will take effect as soon as possible. - -On the other hand, files that are provided locally - e.g., `--config ./file.yaml` and `--supergraph ./supergraph.graphql` - are only reloaded: - -- If `--hot-reload` is passed (or if another flag infers `--hot-reload`, as is the case with `--dev`) and a supergraph or configuration is changed; or -- When the router process is sent a SIGHUP. - -Otherwise, files provided locally to the router are only re-reloaded if the router process is completely restarted. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/3596 - -## 🧪 Experimental - -### Improvements to safelisting with Persisted Queries (preview) - -(The Persisted Queries feature was initially released in Router v1.25.0, as part of a private preview requiring enablement by Apollo support. The feature is now in public preview and is accessible to any enterprise GraphOS organization.) - -Several improvements to safelisting behavior based on preview feedback: - -* When the safelist is enabled (but `require_id` is not), matching now ignores the order of top-level definitions (operations and fragments) and ignored tokens (whitespace, comments, commas, etc), so that differences in these purely syntactic elements do not affect whether an operation is considered to be in the safelist. -* If introspection is enabled on the server, any operation whose top-level fields are introspection fields (`__type`, `__schema`, or `__typename`) is considered to be in the safelist. - This special case is not applied if `require_id` is enabled, so that Router never parses freeform GraphQL in this mode. -* When `log_unknown` is enabled and `apq` has not been disabled, the Router now logs any operation not in the safelist as unknown, even those sent via IDs if the operation was found in the APQ cache rather than the manifest. -* When `log_unknown` and `require_id` are both enabled, the Router now logs all operations that rejects (i.e., all operations sent as freeform GraphQL). - - Previously, the Router only logged the operations that would have been rejected by the safelist feature with `require_id` disabled (i.e., operations sent as freeform GraphQL that do not match an operation in the manifest). - -As a side effect of this change, Router now re-downloads the PQ manifest when reloading configuration dynamically rather than caching it across reloads. If this causes a notable performance regression for your use case, please file an issue. - -By [@glasser](https://github.com/glasser) in https://github.com/apollographql/router/pull/3566 - -# [1.28.0] - 2023-08-24 (Yanked) - -> **Warning** -> -> **See v1.28.1 for the version that replaces this release.** -> -> We yanked v1.28.0 shortly after it was released since we discovered an issue with block-comment parsing in GraphQL *schemas* that resulted in #3680. We have re-released a **fixed** v1.28.1 which takes the place of this release. The entire contents of this change log have been moved to v1.28.0. - -# [1.27.0] - 2023-08-18 - -## 🚀 Features - -### Add a metric tracking coprocessor latency ([Issue #2924](https://github.com/apollographql/router/issues/2924)) - -Introduces a new metric for the router: - -``` -apollo.router.operations.coprocessor.duration -``` - -It has one attribute: - -``` -coprocessor.stage: string (RouterRequest, RouterResponse, SubgraphRequest, SubgraphResponse) -``` - -It is a histogram metric tracking the time spent calling into the coprocessor. - -Note that the name of this metric may change in the future. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3513 - -### Configure AWS SigV4 authentication for subgraph requests ([PR #3365](https://github.com/apollographql/router/pull/3365)) - -Secure your router to subgraph communication on AWS using [Signature Version 4](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html) (Sigv4)! -This changeset provides you with a way to set up hard-coded credentials, as well as a default provider chain. -We recommend using the default provider chain configuration. - -Full use example: - -```yaml - authentication: - subgraph: - all: # configuration that will apply to all subgraphs - aws_sig_v4: - default_chain: - profile_name: "my-test-profile" # https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#ec2-instance-profile - region: "us-east-1" # https://docs.aws.amazon.com/general/latest/gr/rande.html - service_name: "lambda" # https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-services-that-work-with-iam.html - assume_role: # https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html - role_arn: "test-arn" - session_name: "test-session" - external_id: "test-id" - subgraphs: - products: - aws_sig_v4: - hardcoded: # Not recommended, prefer using default_chain as shown above - access_key_id: "my-access-key" - secret_access_key: "my-secret-access-key" - region: "us-east-1" - service_name: "vpc-lattice-svcs" # "s3", "lambda" etc. -``` - -The full documentation can be found in the [router documentation](https://www.apollographql.com/docs/router/configuration/authn-subgraph). - -By [@o0Ignition0o](https://github.com/o0Ignition0o) and [@BlenderDude](https://github.com/BlenderDude) in https://github.com/apollographql/router/pull/3365 - -### Helm: add init containers to deployment ([Issue #3248](https://github.com/apollographql/router/issues/3248)) - -This is a new option when deploying the router, so that containers may be specified which execute before the Router container (or any extra Containers) begin executing. You can read more about [init containers](https://kubernetes.io/docs/concepts/workloads/pods/init-containers/) in the Kubernetes documentation. - -By [@laszlorostas](https://github.com/laszlorostas) in https://github.com/apollographql/router/pull/3444 - -### Helm: expose the `lifecycle` object on the router container ([Issue #3563](https://github.com/apollographql/router/issues/3563)) - -You can now set the [Kubernetes `lifecycle` object](https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/) on the router container in the helm chart. - -By [@bjoernw](https://github.com/bjoernw) in https://github.com/apollographql/router/pull/3570 - -## 🐛 Fixes - -### Require the main (GraphQL) route to shutdown before other routes ([Issue #3521](https://github.com/apollographql/router/issues/3521)) - -Router shutdown sequence has been improved to ensure that the main (GraphQL) route is shutdown before other routes are shutdown. Prior to this change all routes shut down in parallel and this would mean that, for example, health checks stopped responding prematurely. - -This was particularly undesirable when the router is executing in Kubernetes, since continuing to report live/ready checks during shutdown is a requirement. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3557 - -### Spelling of `content_negociation` corrected to `content_negotiation` ([Issue #3204](https://github.com/apollographql/router/issues/3204)) - -We had a bit of a French twist on one of our internal module names. We won't promise it won't happen again, but `content_negociation` is spelled as `content_negotiation` now. 😄 - -Thank you for this contribution! - -By [@krishna15898](https://github.com/krishna15898) in https://github.com/apollographql/router/pull/3162 - -### Fix Redis reconnections ([Issue #3045](https://github.com/apollographql/router/issues/3045)) - -Redis reconnection policy was using an exponential backoff delay with a maximum number of attempts. Once that maximum is reached, reconnection was never tried again (there's no baseline retry). The router will now always retry with a maximum delay of 2 seconds, and a timeout of 1 millisecond, so that the router can continue serving requests in the meantime. - -This commit contains additional fixes: -- Release the lock on the in-memory cache while waiting for Redis, to let the in memory cache serve other requests. -- Add a custom serializer for the `SubSelectionKey` type. This type is used as key in a `HashMap` which is converted to a JSON object. Since object keys in JSON must be strings, a specific serializer was needed instead of the derived one. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3509 - -### Close the subscription when a new supergraph becomes active ([Issue #3320](https://github.com/apollographql/router/issues/3320)) - -Router schema/supergraph updates weren't resetting existing subscriptions which meant they could run with an out of date query plan. - -With this change, the router will signal clients that a `SUBSCRIPTION_SCHEMA_RELOAD` has occurred and close the running subscription. Clients will then subscribe again: - - -```json -{ - "errors": [ - { - "message": "subscription has been closed due to a schema reload", - "extensions": { - "code": "SUBSCRIPTION_SCHEMA_RELOAD" - } - } - ] -} -``` - - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3341 - -### Redis storage: return an error if a non serializable value is sent. ([#3594](https://github.com/apollographql/router/issues/3594)) - -An error will now be logged when a value is unable to be serialized before being sent to the Redis storage backend. The message suggests opening an issue since this would be a router bug that we'd need to fix! - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3597 - -### Handle ping/pong websocket messages before the Ack message is received ([PR #3562](https://github.com/apollographql/router/pull/3562)) - -Websocket servers will sometimes send Ping() messages before they Ack the connection initialization. This changeset allows the router to send Pong() messages, while still waiting until either `CONNECTION_ACK_TIMEOUT` elapsed, or the server successfully Acked the websocket connection start. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3562 - -### Subscription requests only count in telemetry if the feature is actually enabled ([PR #3500](https://github.com/apollographql/router/pull/3500)) - -Count subscription requests only if the feature is enabled. - -The router would previously count subscription requests regardless of whether the feature was enabled or not. This fix changes the behavior to only count subscription requests when the feature is enabled. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3500 - -## 🛠 Maintenance - -### Update `datadog-subgraph/`'s npm dependencies ([PR #3560](https://github.com/apollographql/router/pull/3560)) - -This changeset updates the `dd-trace` dependency and the Node.js version of the example Dockerfile. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3560 - -### Remove some `panic!` calls in persisted query logic ([PR #3527](https://github.com/apollographql/router/pull/3527)) - -Replace a few `panic!` calls with `expect()`s in the persisted query code to improve clarity. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3527 - -### Add a warning if we think `istio-proxy` injection is causing problems ([Issue #3533](https://github.com/apollographql/router/issues/3533)) - -We have encountered situations where the injection of `istio-proxy` in a router pod (executing in Kubernetes) causes networking errors during [Apollo Uplink](https://www.apollographql.com/docs/federation/managed-federation/uplink/) communication. - -The situation isn't due to Apollo Uplink, but rather the router is executing and attempting to retrieve Apollo Uplink data while the `istio-proxy` is simultaneously modifying its network configuration. - -This new warning message directs users to information which should help them to configure their Kubernetes cluster or pod to avoid this problem. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3545 - -### Log when custom plugins are detected with potentially-silenced log entries ([Issue #3526](https://github.com/apollographql/router/issues/3526)) - -Since [PR #3477](https://github.com/apollographql/router/pull/3477), users with custom plugins lost some log entries. This is because the default logging filter now restricts log entries to those that are in the `apollo` module, as originally intended. - -Users that have custom plugins need to configure the logging filter to include their modules, but may not have realised this. - -Now, if a custom plugin is detected, a message will be logged to the console indicating that the logging filter may need to be configured. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3540 - -### Parent based sampling tests ([PR #3136](https://github.com/apollographql/router/pull/3136)) - -This adds test for OpenTelemetry sampling defined either in the configuration or in headers carried by the request - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3136 - -## 📚 Documentation - -### Redis URL format ([Issue #3534](https://github.com/apollographql/router/issues/3534)) - -The Redis client used in the Router follows a convention on Redis server URLs to indicate TLS, cluster or sentinel usage - - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3556 - -### Request lifecycle ([PR #3391](https://github.com/apollographql/router/pull/3391)) - -This adds in-depth documentation of: -- the entire request lifecycle -- which services exist in the router -- the request and response types they use -- where plugins can attach themselves - -By [@Geal](https://github.com/Geal) [@Meschreiber](https://github.com/Meschreiber) in https://github.com/apollographql/router/pull/3391 - -### TLS termination and subgraph overrides ([Issue #3100](https://github.com/apollographql/router/issues/3100)) - -TLS termination was added in [PR #2614](https://github.com/apollographql/router/pull/2614) but never documented. Subgraph certificate override was added in [PR #2008](https://github.com/apollographql/router/pull/2008) but the documentation missed some details on self-signed certificates. These have both been corrected! - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3436 - -### `self` is immutable in the `Plugin` trait's methods ([Issue #3539](https://github.com/apollographql/router/issues/3539)) - -The documentation previously displayed `Plugin`'s methods as taking a mutable reference to `self`, while they actually take an _immutable_ reference to it. - -We've fixed the documentation. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3555 - - - -# [1.26.0] - 2023-07-28 - -## 🚀 Features - -### Add coprocessor metrics ([PR #3483](https://github.com/apollographql/router/pull/3483)) - -Introduces a new metric for the router: - -``` -apollo.router.operations.coprocessor -``` - -It has two attributes: - -``` -coprocessor.stage: string (RouterRequest, RouterResponse, SubgraphRequest, SubgraphResponse) -coprocessor.succeeded: bool -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3483 - -### Constrain APOLLO_ROUTER_LOG and --log global levels to the router ([Issue #3474](https://github.com/apollographql/router/issues/3474)) - -`APOLLO_ROUTER_LOG` and `--log` now implicitly set a filter constraining the logging to the `apollo_router` module, simplifying the debugging experience for users. - -For advanced users `RUST_LOG` can be used for standard log filter behavior. - -Thus: - -``` -RUST_LOG=apollo_router=warn ---log warn -APOLLO_ROUTER_LOG=warn -``` - -are equivalent with all three statements resulting in `warn` level logging for the router. - -For more details, read the logging configuration documentation. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3477 - -### Add support for PodDisruptionBudget to helm chart ([Issue #3345](https://github.com/apollographql/router/issues/3345)) - -A [PodDisuptionBudget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) may now be specified for your router to limit the number of concurrent disruptions. - -Example Configuration: - -```yaml -podDisruptionBudget: - minAvailable: 1 -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3469 - -## 🐛 Fixes - -### Don't hide `--dev` from `--help` ([Issue #2705](https://github.com/apollographql/router/issues/2705)) - -Display documentation about `--dev` when launching the router with `--help` argument. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3479 - -### Fix default rhai script dir for Windows ([Issue #3401](https://github.com/apollographql/router/issues/3401)) - -Using default `rhai.scripts` field won't end up in an error. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3411 - -### Fix the prometheus descriptions as well as the metrics ([Issue #3491](https://github.com/apollographql/router/issues/3491)) - -I didn't realise the descriptions on the prometheus stats were significant, so my previous prometheus fix constrained itself to renaming the actual metrics. - -This relaxes the regex pattern to include prom descriptions as well as metrics in the renaming. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3492 - -## 🛠 Maintenance - -### Add a pool idle timeout for subgraph HTTP connectors ([Issue #3435](https://github.com/apollographql/router/issues/3435)) - -Having a high idle pool timeout duration can sometimes trigger situations in which an HTTP request cannot complete (see [this comment](https://github.com/hyperium/hyper/issues/2136#issuecomment-589488526) for more information). - -This changeset sets a default timeout duration of 5 seconds, which we may make configurable eventually. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3470 - -### Don't reload the router if the schema/license hasn't changed ([Issue #3180](https://github.com/apollographql/router/issues/3180)) - -The router is performing frequent schema reloads due to notifications from uplink. In the majority of cases a schema reload is not required, because the schema hasn't actually changed. - -We won't reload the router if the schema/license hasn't changed. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3478 - - - -# [1.25.0] - 2023-07-19 - -## 🚀 Features - -### Persisted Queries w/opt-in safelisting (preview) ([PR #3347](https://github.com/apollographql/router/pull/3347)) - -Persisted Queries is an upcoming feature that helps you prevent unwanted traffic from reaching your graph. It's in private preview and isn't available unless your enterprise organization has been granted preview access by Apollo. - -Persisted Queries has two modes of operation: -* **Unregistered operation monitoring** - * Your router allows all GraphQL operations, while emitting structured traces containing unregistered operation bodies. -* **Operation safelisting** - * Your router rejects unregistered operations. - * Your router requires all operations to be sent as an ID. - -Unlike automatic persisted queries (APQ), an operation safelist lets you prevent malicious actors from constructing a free-format query that could overload your subgraph services. - -By [@EverlastingBugstopper](https://github.com/EverlastingBugstopper) in https://github.com/apollographql/router/pull/3347 - -## 🐛 Fixes - -### Fix issues around query fragment reuse - -[Federation 2.4.9](https://github.com/apollographql/federation/blob/main/gateway-js/CHANGELOG.md#249) contained a bug around query fragment reuse. The change was reverted in [2.4.10](https://github.com/apollographql/federation/blob/main/gateway-js/CHANGELOG.md#249) - -The version of federation used by the Router is now 2.4.10. - -By @BrynCooke in https://github.com/apollographql/router/pull/3453 - -### Fix prometheus statistics issues with _total_total names([Issue #3443](https://github.com/apollographql/router/issues/3443)) - -When producing prometheus statistics the otel crate (0.19.0) now automatically appends `_total` which is unhelpful. - -This fix removes `_total_total` from our statistics. However, counter metrics will still have `_total` appended to them if they did not so already. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3471 - -### Enforce default buckets for metrics ([PR #3432](https://github.com/apollographql/router/pull/3432)) - -When `telemetry.metrics.common` was not configured, no default metrics buckets were configured. -With this fix the default buckets are: `[0.001, 0.005, 0.015, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 1.0, 5.0, 10.0]` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3432 - -## 📃 Configuration - -### Add `subscription.enabled` field to enable subscription support ([Issue #3428](https://github.com/apollographql/router/issues/3428)) - -`enabled` is now required in `subscription` configuration. Example: - -```yaml -subscription: - enabled: true - mode: - passthrough: - all: - path: /ws -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3450 - -### Add option to disable reuse of query fragments ([Issue #3452](https://github.com/apollographql/router/issues/3452)) - -A new option has been added to the Router to allow disabling of the reuse of query fragments. This is useful for debugging purposes. -```yaml -supergraph: - experimental_reuse_query_fragments: false -``` - -The default value depends on the version of federation. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3453 - -## 🛠 Maintenance - -### Coprocessor: Set a default pool idle timeout duration. ([PR #3434](https://github.com/apollographql/router/pull/3434)) - -The default idle pool timeout duration in Hyper can sometimes trigger situations in which an HTTP request cannot complete (see [this comment](https://github.com/hyperium/hyper/issues/2136#issuecomment-589488526) for more information). - -This changeset sets a default timeout duration of 5 seconds. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3434 - -# [1.24.0] - 2023-07-13 - -***Note that this release contains a bug in query planning around query fragment reuse and should not be used. If upgrading, consider going straight to 1.25.0.*** - -## 🚀 Features - -### Add support for delta aggregation to otlp metrics ([PR #3412](https://github.com/apollographql/router/pull/3412)) - -Add a new configuration option (Temporality) to the otlp metrics configuration. - -This may be useful to fix problems with metrics when being processed by datadog which tends to expect Delta, rather than Cumulative, aggregations. - -See: - - https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/6129 - - https://github.com/DataDog/documentation/pull/15840 - -for more details. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3412 - -## 🐛 Fixes - -### Fix error handling for subgraphs ([Issue #3141](https://github.com/apollographql/router/issues/3141)) - -The GraphQL spec is rather light on what should happen when we process responses from subgraphs. The current behaviour within the Router was inconsistently short circuiting response processing and this producing confusing errors. -> #### Processing the response -> -> If the response uses a non-200 status code and the media type of the response payload is application/json then the client MUST NOT rely on the body to be a well-formed GraphQL response since the source of the response may not be the server but instead some intermediary such as API gateways, proxies, firewalls, etc. - -The logic has been simplified and made consistent using the following rules: -1. If the content type of the response is not `application/json` or `application/graphql-response+json` then we won't try to parse. -2. If an HTTP status is not 2xx it will always be attached as a graphql error. -3. If the response type is `application/json` and status is not 2xx and the body is not valid grapqhql the entire subgraph response will be attached as an error. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3328 - -## 🛠 Maintenance - -### chore: router-bridge 0.3.0+v2.4.8 -> =0.3.1+2.4.9 ([PR #3407](https://github.com/apollographql/router/pull/3407)) - -Updates `router-bridge` from ` = "0.3.0+v2.4.8"` to ` = "0.3.1+v2.4.9"`, note that with this PR, this dependency is now pinned to an exact version. This version update started failing tests because of a minor ordering change and it was not immediately clear why the test was failing. Pinning this dependency (that we own) allows us to only bring in the update at the proper time and will make test failures caused by the update to be more easily identified. - -By [@EverlastingBugstopper](https://github.com/EverlastingBugstopper) in https://github.com/apollographql/router/pull/3407 - -### remove the compiler from Query ([Issue #3373](https://github.com/apollographql/router/issues/3373)) - -The `Query` object caches information extracted from the query that is used to format responses. It was carrying an `ApolloCompiler` instance, but now we don't really need it anymore, since it is now cached at the query analysis layer. We also should not carry it in the supergraph request and execution request, because that makes the builders hard to manipulate for plugin authors. Since we are not exposing the compiler in the public API yet, we move it inside the context's private entries, where it will be easily accessible from internal code. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3367 - -### move AllowOnlyHttpPostMutationsLayer at the supergraph service level ([PR #3374](https://github.com/apollographql/router/pull/3374), [PR #3410](https://github.com/apollographql/router/pull/3410)) - -Now that we have access to a compiler in supergraph requests, we don't need to look into the query plan to know if a request contains mutations - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3374 & https://github.com/apollographql/router/pull/3410 - -### update opentelemetry to 0.19.0 ([Issue #2878](https://github.com/apollographql/router/issues/2878)) - - -We've updated the following opentelemetry related crates: - -``` -opentelemetry 0.18.0 -> 0.19.0 -opentelemetry-datadog 0.6.0 -> 0.7.0 -opentelemetry-http 0.7.0 -> 0.8.0 -opentelemetry-jaeger 0.17.0 -> 0.18.0 -opentelemetry-otlp 0.11.0 -> 0.12.0 -opentelemetry-semantic-conventions 0.10.0 -> 0.11.0 -opentelemetry-zipkin 0.16.0 -> 0.17.0 -opentelemetry-prometheus 0.11.0 -> 0.12.0 -tracing-opentelemetry 0.18.0 -> 0.19.0 -``` - -This allows us to close a number of opentelemetry related issues. - -Note: - -The prometheus specification mandates naming format and, unfortunately, the router had two metrics which weren't compliant. The otel upgrade enforces the specification, so the affected metrics are now renamed (see below). - -The two affected metrics in the router were: - -apollo_router_cache_hit_count -> apollo_router_cache_hit_count_total -apollo_router_cache_miss_count -> apollo_router_cache_miss_count_total - -If you are monitoring these metrics via prometheus, please update your dashboards with this name change. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3421 - -### Synthesize defer labels without RNG or collisions ([PR #3381](https://github.com/apollographql/router/pull/3381) and [PR #3423](https://github.com/apollographql/router/pull/3423)) - -The `@defer` directive accepts a `label` argument, but it is optional. To more accurately handle deferred responses, the Router internally rewrites queries to add labels on the `@defer` directive where they are missing. Responses eventually receive the reverse treatment to look as expected by client. - -This was done be generating random strings, handling collision with existing labels, and maintaining a `HashSet` of which labels had been synthesized. Instead, we now add a prefix to pre-existing labels and generate new labels without it. When processing a response, the absence of that prefix indicates a synthetic label. - -By [@SimonSapin](https://github.com/SimonSapin) and [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3381 and https://github.com/apollographql/router/pull/3423 - -### Move subscription event execution at the execution service level ([PR #3395](https://github.com/apollographql/router/pull/3395)) - -In order to prepare some future integration I moved the execution loop for subscription events at the execution_service level. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3395 - -## 📚 Documentation - -### Document claim augmentation via coprocessors ([Issue #3102](https://github.com/apollographql/router/issues/3102)) - -Claims augmentation is a common use case where user information from the JWT claims is used to look up more context like roles from databases, before sending it to subgraphs. This can be done with subgraphs, but it was not documented yet, and there was confusion on the order in which the plugins were called. This clears the confusion and provides an example configuration. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3386 - -# [1.23.0] - 2023-07-07 - -## 🚀 Features - -### Add `--listen` to CLI args ([PR #3296](https://github.com/apollographql/router/pull/3296)) - -Adds `--listen` to CLI args, which allows the user to specify the address to listen on. -It can also be set via environment variable `APOLLO_ROUTER_LISTEN_ADDRESS`. - -```bash -router --listen 0.0.0.0:4001 -``` - -By [@ptondereau](https://github.com/ptondereau) and [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3296 - -### Move operation limits and parser limits to General Availability ([PR #3356](https://github.com/apollographql/router/pull/3356)) - -[Operation Limits](https://www.apollographql.com/docs/router/configuration/operation-limits) (a GraphOS Enterprise feature) and [parser limits](https://www.apollographql.com/docs/router/configuration/overview/#parser-based-limits) are now moving to General Availability, from Preview where they have been since Apollo Router 1.17. - -For more information about launch stages, please see the documentation here: https://www.apollographql.com/docs/resources/product-launch-stages/ - -In addition to removing the `preview_` prefix, the configuration section has been renamed to just `limits` to encapsulate operation, parser and request limits. ([The request size limit](https://www.apollographql.com/docs/router/configuration/overview/#request-limits) is still [experimental](https://github.com/apollographql/router/discussions/3220).) Existing configuration files will keep working as before, but with a warning output to the logs. To fix that warning, rename the configuration section like so: - - -```diff --preview_operation_limits: -+limits: - max_depth: 100 - max_height: 200 - max_aliases: 30 - max_root_fields: 20 -``` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/3356 - -### Add support for readiness/liveness checks ([Issue #3233](https://github.com/apollographql/router/issues/3233)) - -Kubernetes lifecycle interop has been improved by implementing liveliness and readiness checks. - -Kubernetes considers a service is: - - - live - [if it isn't deadlocked](https://www.linkedin.com/posts/llarsson_betterdevopsonkubernetes-devops-devsecops-activity-7018587202121076736-LRxE) - - ready - if it is able to start accepting traffic - -(For more details: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/) - -The existing health check didn't surface this information. Instead, it returns a payload which indicates if the router is "healthy" or not and it's always returning "UP" (hard-coded). - -The router health check now exposes this information based in the following rules: -* Live - - Is not in state Errored - - Health check enabled and responding -* Ready - - Is running and accepting requests. - - Is `Live` - -To maintain backwards compatibility; query parameters named "ready" and "live" have been added to our existing health endpoint. Both POST and GET are supported. - -Sample queries: - -``` -curl -XPOST "http://localhost:8088/health?ready" OR curl "http://localhost:8088/health?ready" -curl -XPOST "http://localhost:8088/health?live" OR curl "http://localhost:8088/health?live" -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3276 - -### Include path to Rhai script in syntax error messages - -Syntax errors in the main Rhai script will now include the path to the script in the error message. - -By [@dbanty](https://github.com/dbanty) in https://github.com/apollographql/router/pull/3254 - -## Experimental support for GraphQL validation in Rust - -We are experimenting with a new GraphQL validation implementation written in Rust. The legacy implementation is part of the JavaScript query planner. This is part of a project to remove JavaScript from the Router to improve performance and memory behavior. - -To opt in to the new validation implementation, set: - -```yaml {4,8} title="router.yaml" -experimental_graphql_validation_mode: new -``` - -Or use `both` to run the implementations side by side and log a warning if there is a difference in results: - -```yaml {4,8} title="router.yaml" -experimental_graphql_validation_mode: both -``` - -This is an experimental option while we are still finding edge cases in the new implementation, and will be removed once we have confidence that parity has been achieved. - -By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/3134 - -### Add environment variable access to rhai ([Issue #1744](https://github.com/apollographql/router/issues/1744)) - -This introduces support for accessing environment variable within Rhai. The new `env` module contains one function and is imported by default: -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3240 - -### Add support for getting request method in Rhai ([Issue #2467](https://github.com/apollographql/router/issues/2467)) - -This adds support for getting the HTTP method of requests in Rhai. - -``` -fn process_request(request) { - if request.method == "OPTIONS" { - request.headers["x-custom-header"] = "value" - } -} -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3355 - -### Add additional build functionality to the diy build script ([Issue #3303](https://github.com/apollographql/router/issues/3303)) - -The diy build script is useful for ad-hoc image creation during testing or for building your own images based on a router repo. This set of enhancements makes it possible to - - - build docker images from arbitrary (nightly) builds (-a) - - build an amd64 docker image on an arm64 machine (or vice versa) (-m) - - change the name of the image from the default 'router' (-n) - -Note: the build machine image architecture is used if the -m flag is not supplied. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3304 - -## 🐛 Fixes - -### Bring root span name in line with otel semantic conventions. ([Issue #3229](https://github.com/apollographql/router/issues/3229)) - -Root span name has changed from `request` to ` ` - -[Open Telemetry graphql semantic conventions](https://opentelemetry.io/docs/specs/otel/trace/semantic_conventions/instrumentation/graphql/) specify that the root span name must match the operation kind and name. - -Many tracing providers don't have good support for filtering traces via attribute, so changing this significantly enhances the tracing experience. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3364 - -### An APQ query with a mismatched hash will error as HTTP 400 ([Issue #2948](https://github.com/apollographql/router/issues/2948)) - -We now have the same behavior in the Gateway and [the Router implementation](https://www.apollographql.com/docs/apollo-server/performance/apq/). Even if our previous behavior was still acceptable, any other behavior is a misconfiguration of a client and should be prevented early. - -Previously, if a client sent an operation with an APQ hash, we would merely log an error to the console, **not** register the operation (for the next request) but still execute the query. We now return a GraphQL error and don't execute the query. No clients should be impacted by this, though anyone who had hand-crafted a query **with** APQ information (for example, copied a previous APQ-registration query but only changed the operation without re-calculating the SHA-256) might now be forced to use the correct hash (or more practically, remove the hash). - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3128 - -### fix(subscription): take the callback url path from the configuration ([Issue #3361](https://github.com/apollographql/router/issues/3361)) - -Previously when you specified the `subscription.mode.callback.path` it was not used, we had an hardcoded value set to `/callback`. It's now using the specified path in the configuration - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3366 - -### Preserve all shutdown receivers across reloads ([Issue #3139](https://github.com/apollographql/router/issues/3139)) - -We keep a list of all active requests and process all of them during shutdown. This will avoid prematurely terminating connections down when: - -- some requests are in flight -- the router reloads (new schema, etc) -- the router gets a shutdown signal - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3311 - -### Enable serde_json float_roundtrip feature ([Issue #2951](https://github.com/apollographql/router/issues/2951)) - -The Router now preserves JSON floating point numbers exactly as they are received by enabling the `serde_json` `float_roudtrip` feature: - -> Use sufficient precision when parsing fixed precision floats from JSON to ensure that they maintain accuracy when round-tripped through JSON. This comes at an approximately 2x performance cost for parsing floats compared to the default best-effort precision. - - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3338 - -### Fix deferred response formatting when filtering queries ([PR #3298](https://github.com/apollographql/router/pull/3298), [Issue #3263](https://github.com/apollographql/router/issues/3263), [PR #3339](https://github.com/apollographql/router/pull/3339)) - -Filtering queries requires two levels of response formatting, and its implementation highlighted issues with deferred responses. Response formatting needs to recognize which deferred fragment generated it, and that the deferred response shapes can change depending on request variables, due to the `@defer` directive's `if` argument. - -For now, this is solved by generating the response shapes for primary and deferred responses, for each combination of the variables used in `@defer` applications, limited to 32 unique variables. There will be follow up work with another approach that removes this limitation. - -By [@Geal](https://github.com/Geal) and [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/3298, https://github.com/apollographql/router/issues/3263 and https://github.com/apollographql/router/pull/3339 - -### Otel Ensure that service name is correctly picked up from env and resources ([Issue #3215](https://github.com/apollographql/router/issues/3215)) - -`OTEL_SERVICE_NAME` env and `service.name` resource are now correctly used when creating tracing exporters. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3307 - -## 🛠 Maintenance - -### Use a Drop guard to track active requests ([PR #3343](https://github.com/apollographql/router/pull/3343)) - -Manually tracking active requests is error prone because we might return early without decrementing the active requests. To make sure this is done properly, `enter_active_request` now returns a guard struct, that will decrement the count on drop - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3343 - -### Merge tests to reduce linking time ([PR #3272](https://github.com/apollographql/router/pull/3272)) - -We build multiple test executables to perform short tests and each of them needs to link an entire router. By merging them in larger files, we can reduce the time spent in CI - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3272 - -### Always instanciate the traffic shaping plugin ([Issue #3327](https://github.com/apollographql/router/issues/3327)) - -The `traffic_shaping` plugin is now always part of the plugins list and is always active, with default configuration. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3330 - -### Refactor ExecutionService ([PR #3344](https://github.com/apollographql/router/pull/3344)) - -Split `ExecutionService` implementation into multiple methods for readability. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3344 - -### Refactor router service ([PR #3326](https://github.com/apollographql/router/pull/3326)) - -Refactor code around for easier readability and maintainability. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3326 - -### Fix missing origin repository in release checklist - -Fixes a missing --repo parameter at Step 28 of the release checklist, which would fail to edit the release notes if several upstreams are set on your machine. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/TBD - -### chore: updates `altool` to `notarytool` for MacOS codesigning ([Issue #3275](https://github.com/apollographql/router/issues/3275)) - -By [@EverlastingBugstopper](https://github.com/EverlastingBugstopper) in https://github.com/apollographql/router/pull/3334 - -## 📚 Documentation - -### Add security-related warnings to JWT auth docs ([PR #3299](https://github.com/apollographql/router/pull/3299)) - -There are a couple potential security pitfalls when leveraging the router for JWT authentication. These are now documented in [the relevant section of the docs](https://www.apollographql.com/docs/router/configuration/authn-jwt). If you are currently using JWT authentication in the router, be sure to [secure your subgraphs](https://www.apollographql.com/docs/federation/building-supergraphs/subgraphs-overview#securing-your-subgraphs) and [use care when propagating headers](https://www.apollographql.com/docs/router/configuration/authn-jwt#example-forwarding-claims-to-subgraphs). - -By [@dbanty](https://github.com/dbanty) in https://github.com/apollographql/router/pull/3299 - -### Update example for claim forwarding ([Issue #3224](https://github.com/apollographql/router/issues/3224)) - -The JWT claim example in our docs was insecure as it iterated over the list of claims and set them as headers. -A malicious user could have provided a valid JWT that was missing claims and then set those claims as headers. -This would only have affected users who had configured their routers to forward all headers from the client to subgraphs. - -The documentation has been updated to explicitly list the claims that are forwarded to the subgraph. -In addition, a new example has been added that uses extensions to forward claims. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/3319 - -### Document plugin ordering ([Issue #3207](https://github.com/apollographql/router/issues/3207)) - -Rust plugins are applied in the same order as they are configured in the Router’s YAML configuration file. -This is now documented behavior that users can rely on, with new tests to help maintain it. - -Additionally, some Router features happen to use the plugin mechanism internally. -Those now all have a fixed ordering, whereas previous Router versions would use a mixture -of fixed order for some internal plugins and configuration file order for the rest. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/3321 - -### Improve documentation for `Rhai` globals ([Issue #2671](https://github.com/apollographql/router/issues/2671)) - -The Router's `Rhai` interface can simulate closures: https://rhai.rs/book/language/fn-closure.html - -However, and this is an important restriction: - -" -The [anonymous function](https://rhai.rs/book/language/fn-anon.html) syntax, however, automatically captures [variables](https://rhai.rs/book/language/variables.html) that are not defined within the current scope, but are defined in the external scope – i.e. the scope where the [anonymous function](https://rhai.rs/book/language/fn-anon.html) is created. " - -Thus it's not possible for a `Rhai` closure to make reference to a global variable. - -This hasn't previously been an issue, but we've now added support for referencing global variables, one at the moment `Router`, for example: - -```sh -fn supergraph_service(service){ - let f = |request| { - let v = Router.APOLLO_SDL; - print(v); - }; - service.map_request(f); -} -``` -This won't work and you'll get an error something like: `service callback failed: Variable not found: Router (line 4, position 17)` - -There are two workarounds. Either: - -1. Create a local copy of the global that can be captured by the closure: -``` -fn supergraph_service(service){ - let v = Router.APOLLO_SDL; - let f = |request| { - print(v); - }; - service.map_request(f); -} -``` -Or: -2. Use a function pointer rather than closure syntax: -``` -fn supergraph_service(service) { - const request_callback = Fn("process_request"); - service.map_request(request_callback); -} - -fn process_request(request) { - print(``); -} -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3308 - -### Add a "Debugging" section to the Rhai plugin docs - -There are now a few tips & tricks in our docs for debugging Rhai scripts including how to get syntax highlighting, how to interpret error messages, and recommendations for tracking down runtime errors. - -By [@dbanty](https://github.com/dbanty) in https://github.com/apollographql/router/pull/3254 - -### Documentation for the query planner warm up phase ([Issue #3145](https://github.com/apollographql/router/issues/3145)) - -Query planner warm up was introduced in 1.7.0 but was not present in the documentation - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3151 - - - -# [1.22.0] - 2023-06-21 - -## 🚀 Features - -### Federated Subscriptions ([PR #3285](https://github.com/apollographql/router/pull/3285)) - -> ⚠️ **This is an [Enterprise feature](https://www.apollographql.com/blog/platform/evaluating-apollo-router-understanding-free-and-open-vs-commercial-features/) of the Apollo Router.** It requires an organization with a [GraphOS Enterprise plan](https://www.apollographql.com/pricing/). -> -> If your organization _doesn't_ currently have an Enterprise plan, you can test out this functionality by signing up for a free [Enterprise trial](https://www.apollographql.com/docs/graphos/org/plans/#enterprise-trials). - - -#### High-Level Overview - -##### What are Federated Subscriptions? - -This PR adds GraphQL subscription support to the Router for use with Federation. Clients can now use GraphQL subscriptions with the Router to receive realtime updates from a supergraph. With these changes, `subscription` operations are now a first-class supported feature of the Router and Federation, alongside queries and mutations. - -```mermaid -flowchart LR; - client(Client); - subgraph "Your infrastructure"; - router(["Apollo Router"]); - subgraphA[Products
subgraph]; - subgraphB[Reviews
subgraph]; - router---|Subscribes
over WebSocket|subgraphA; - router-.-|Can query for
entity fields|subgraphB; - end; - client---|Subscribes
over HTTP|router; - class client secondary; -``` - -##### Client to Router Communication - -- Apollo has designed and implemented a new open protocol for handling subscriptions called [multipart subscriptions](https://github.com/apollographql/router/blob/dev/dev-docs/multipart-subscriptions-protocol.md) -- With this new protocol clients can manage subscriptions with the Router over tried and true HTTP; WebSockets, SSE (server-sent events), etc. are not needed -- All Apollo clients ([Apollo Client web](https://www.apollographql.com/docs/react/data/subscriptions), [Apollo Kotlin](https://www.apollographql.com/docs/kotlin/essentials/subscriptions), [Apollo iOS](https://www.apollographql.com/docs/ios/fetching/subscriptions)) have been updated to support multipart subscriptions, and can be used out of the box with little to no extra configuration -- Subscription communication between clients and the Router must use the multipart subscription protocol, meaning only subscriptions over HTTP are supported at this time - -##### Router to Subgraph Communication - -- The Router communicates with subscription enabled subgraphs using WebSockets -- By default, the router sends subscription requests to subgraphs using the [graphql-transport-ws protocol](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md) which is implemented in the [graphql-ws](https://github.com/enisdenjo/graphql-ws) library. You can also configure it to use the [graphql-ws protocol](https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md) which is implemented in the [subscriptions-transport-ws library](https://github.com/apollographql/subscriptions-transport-ws). -- Subscription ready subgraphs can be introduced to Federation and the Router as is - no additional configuration is needed on the subgraph side - -##### Subscription Execution - -When the Router receives a GraphQL subscription request, the generated query plan will contain an initial subscription request to the subgraph that contributed the requested subscription root field. - -For example, as a result of a client sending this subscription request to the Router: - -```graphql -subscription { - reviewAdded { - id - body - product { - id - name - createdBy { - name - } - } - } -} -``` - -The router will send this request to the `reviews` subgraph: - -```graphql -subscription { - reviewAdded { - id - body - product { - id - } - } -} -``` - -When the `reviews` subgraph receives new data from its underlying source event stream, that data is sent back to the Router. Once received, the Router continues following the determined query plan to fetch any additional required data from other subgraphs: - -Example query sent to the `products` subgraph: - -```graphql -query ($representations: [_Any!]!) { - _entities(representations: $representations) { - ... on Product { - name - createdBy { - __typename - email - } - } - } -} -``` - -Example query sent to the `users` subgraph: - -```graphql -query ($representations: [_Any!]!) { - _entities(representations: $representations) { - ... on User { - name - } - } -} -``` - -When the Router finishes running the entire query plan, the data is merged back together and returned to the requesting client over HTTP (using the multipart subscriptions protocol). - -#### Configuration - -Here is a configuration example: - -```yaml title="router.yaml" -subscription: - mode: - passthrough: - all: # The router uses these subscription settings UNLESS overridden per-subgraph - path: /subscriptions # The path to use for subgraph subscription endpoints (Default: /ws) - subgraphs: # Overrides subscription settings for individual subgraphs - reviews: # Overrides settings for the 'reviews' subgraph - path: /ws # Overrides '/subscriptions' defined above - protocol: graphql_transport_ws # The WebSocket-based protocol to use for subscription communication (Default: graphql_ws) -``` - -#### Usage Reporting - -Subscription use is tracked in the Router as follows: - -- **Subscription registration:** The initial subscription operation sent by a client to the Router that's responsible for starting a new subscription -- **Subscription notification:** The resolution of the client subscription’s selection set in response to a subscription enabled subgraph source event - -Subscription registration and notification (with operation traces and statistics) are sent to Apollo Studio for observability. - -#### Advanced Features - -This PR includes the following configurable performance optimizations. - -#### Deduplication - -- If the Router detects that a client is using the same subscription as another client (ie. a subscription with the same HTTP headers and selection set), it will avoid starting a new subscription with the requested subgraph. The Router will reuse the same open subscription instead, and will send the same source events to the new client. -- This helps reduce the number of WebSockets that need to be opened between the Router and subscription enabled subgraphs, thereby drastically reducing Router to subgraph network traffic and overall latency -- For example, if 100 clients are subscribed to the same subscription there will be 100 open HTTP connections from the clients to the Router, but only 1 open WebSocket connection from the Router to the subgraph -- Subscription deduplication between the Router and subgraphs is enabled by default (but can be disabled via the Router config file) - -#### Callback Mode - -- Instead of sending subscription data between a Router and subgraph over an open WebSocket, the Router can be configured to send the subgraph a callback URL that will then be used to receive all source stream events -- Subscription enabled subgraphs send source stream events (subscription updates) back to the callback URL by making HTTP POST requests -- Refer to the [callback mode documentation](https://github.com/apollographql/router/blob/dev/dev-docs/callback_protocol.md) for more details, including an explanation of the callback URL request/response payload format -- This feature is still experimental and needs to be enabled explicitly in the Router config file - -By [@bnjjj](https://github.com/bnjjj) and [@o0Ignition0o](https://github.com/o0ignition0o) in https://github.com/apollographql/router/pull/3285 - - - -# [1.21.0] - 2023-06-20 - -## 🚀 Features - -### Restore HTTP payload size limit, make it configurable ([Issue #2000](https://github.com/apollographql/router/issues/2000)) - -Early versions of Apollo Router used to rely on a part of the Axum web framework -that imposed a 2 MB limit on the size of the HTTP request body. -Version 1.7 changed to read the body directly, unintentionally removing this limit. - -The limit is now restored to help protect against unbounded memory usage, but is now configurable: - -```yaml -preview_operation_limits: - experimental_http_max_request_bytes: 2000000 # Default value: 2 MB -``` - -This limit is checked while reading from the network, before JSON parsing. -Both the GraphQL document and associated variables count toward it. - -Before increasing this limit significantly consider testing performance -in an environment similar to your production, especially if some clients are untrusted. -Many concurrent large requests could cause the Router to run out of memory. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/3130 - -### Add support for empty auth prefixes ([Issue #2909](https://github.com/apollographql/router/issues/2909)) - -The `authentication.jwt` plugin now supports empty prefixes for the JWT header. Some companies use prefix-less headers; previously, the authentication plugin rejected requests even with an empty header explicitly set, such as: - -```yml -authentication: - jwt: - header_value_prefix: "" -``` - -By [@lleadbet](https://github.com/lleadbet) in https://github.com/apollographql/router/pull/3206 - -## 🐛 Fixes - -### GraphQL introspection errors are now 400 errors ([Issue #3090](https://github.com/apollographql/router/issues/3090)) - -If we get an introspection error during SupergraphService::plan_query(), then it is reported to the client as an HTTP 500 error. The Router now generates a valid GraphQL error for introspection errors whilst also modifying the HTTP status to be 400. - -Before: - -StatusCode:500 -```json -{"errors":[{"message":"value retrieval failed: introspection error: introspection error : Field \"__schema\" of type \"__Schema!\" must have a selection of subfields. Did you mean \"__schema { ... }\"?","extensions":{"code":"INTERNAL_SERVER_ERROR"}}]} -``` - -After: - -StatusCode:400 -```json -{"errors":[{"message":"introspection error : Field \"__schema\" of type \"__Schema!\" must have a selection of subfields. Did you mean \"__schema { ... }\"?","extensions":{"code":"INTROSPECTION_ERROR"}}]} -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3122 - -### Restore missing debug tools in "debug" Docker images ([Issue #3249](https://github.com/apollographql/router/issues/3249)) - -Debug Docker images were designed to make use of `heaptrack` for debugging memory issues. However, this functionality was inadvertently removed when we changed to multi-architecture Docker image builds. - -`heaptrack` functionality is now restored to our debug docker images. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3250 - -### Federation v2.4.8 ([Issue #3217](https://github.com/apollographql/router/issues/3217), [Issue #3227](https://github.com/apollographql/router/issues/3227)) - -This release bumps the Router's Federation support from v2.4.7 to v2.4.8, which brings in notable query planner fixes from [v2.4.8](https://github.com/apollographql/federation/releases/tag/@apollo/query-planner@2.4.8). Of note from those releases, this brings query planner fixes that (per that dependency's changelog): - -- Fix bug in the handling of dependencies of subgraph fetches. This bug was manifesting itself as an assertion error ([apollographql/federation#2622](https://github.com/apollographql/federation/pull/2622)) -thrown during query planning with a message of the form `Root groups X should have no remaining groups unhandled (...)`. - -- Fix issues in code to reuse named fragments. One of the fixed issue would manifest as an assertion error with a message ([apollographql/federation#2619](https://github.com/apollographql/federation/pull/2619)) -looking like `Cannot add fragment of condition X (...) to parent type Y (...)`. Another would manifest itself by -generating an invalid subgraph fetch where a field conflicts with another version of that field that is in a reused -named fragment. - -These manifested as Router issues https://github.com/apollographql/router/issues/3217 and https://github.com/apollographql/router/issues/3227. - -By [@renovate](https://github.com/renovate) and [o0ignition0o](https://github.com/o0ignition0o) in https://github.com/apollographql/router/pull/3202 - -### update Rhai to 1.15.0 to fix issue with hanging example test ([Issue #3213](https://github.com/apollographql/router/issues/3213)) - -One of our Rhai examples' tests have been regularly hanging in the CI builds. Investigation uncovered a race condition within Rhai itself. This update brings in the fixed version of Rhai and should eliminate the hanging problem and improve build stability. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3273 - -## 🛠 Maintenance - -### chore: split out router events into its own module ([PR #3235](https://github.com/apollographql/router/pull/3235)) - -Breaks down `./apollo-router/src/router.rs` into its own module `./apollo-router/src/router/mod.rs` with a sub-module `./apollo-router/src/router/event/mod.rs` that contains all the streams that we combine to start a router (entitlement, schema, reload, configuration, shutdown, more streams to be added). - -By [@EverlastingBugstopper](https://github.com/EverlastingBugstopper) in https://github.com/apollographql/router/pull/3235 - -### Simplify router service tests ([PR #3259](https://github.com/apollographql/router/pull/3259)) - -Parts of the router service creation were generic, to allow mocking, but the `TestHarness` API allows us to reuse the same code in all cases. Generic types have been removed to simplify the API. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3259 - -## 📚 Documentation - -### Improve example Rhai scripts for JWT Authentication ([PR #3184](https://github.com/apollographql/router/pull/3184)) - -Simplify the example Rhai scripts in the [JWT Authentication](https://www.apollographql.com/docs/router/configuration/authn-jwt) docs and includes a sample `main.rhai` file to make it clear how to use all scripts together. - -By [@dbanty](https://github.com/dbanty) in https://github.com/apollographql/router/pull/3184 - -## 🧪 Experimental - -### Expose the apollo compiler at the supergraph service level (internal) ([PR #3200](https://github.com/apollographql/router/pull/3200)) - -Add a query analysis phase inside the router service, before sending the query through the supergraph plugins. It makes a compiler available to supergraph plugins, to perform deeper analysis of the query. That compiler is then used in the query planner to create the `Query` object containing selections for response formatting. - -This is for internal use only for now, and the APIs are not considered stable. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) and [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3200 - -### Query planner plugins (internal) ([Issue #3150](https://github.com/apollographql/router/issues/3150)) - -Future functionality may need to modify a query between query plan caching and the query planner. This leads to the requirement to provide a query planner plugin capability. - -Query planner plugin functionality exposes an ApolloCompiler instance to perform preprocessing of a query before sending it to the query planner. - -This is for internal use only for now, and the APIs are not considered stable. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3177 and https://github.com/apollographql/router/pull/3252 - - -# [1.20.0] - 2023-05-31 - -## 🚀 Features - -### Configurable histogram buckets for metrics ([Issue #2333](https://github.com/apollographql/router/issues/2333)) - -It is now possible to change the default bucketing for histograms generated for metrics: - -```yaml title="router.yaml" -telemetry: - metrics: - common: - buckets: - - 0.05 - - 0.10 - - 0.25 - - 0.50 - - 1.00 - - 2.50 - - 5.00 - - 10.00 - - 20.00 -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3098 - -## 🐛 Fixes - -### Federation v2.4.7 ([Issue #3170](https://github.com/apollographql/router/issues/3170), [Issue #3133](https://github.com/apollographql/router/issues/3133)) - -This release bumps the Router's Federation support from v2.4.6 to v2.4.7, which brings in notable query planner fixes from [v2.4.7](https://github.com/apollographql/federation/releases/tag/%40apollo%2Fquery-planner%402.4.7). Of note from those releases, this brings query planner fixes that (per that dependency's changelog): - -- Re-work the code use to try to reuse query named fragments to improve performance (thus sometimes improving query ([#2604](https://github.com/apollographql/federation/pull/2604)) planning performance) -- Fix a raised assertion error (again, with a message of form like `Cannot add selection of field X to selection set of parent type Y`). -- Fix a rare issue where an `interface` or `union` field was not being queried for all the types it should be. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3185 - -### Set the global allocator in the library crate, not just the executable ([Issue #3126](https://github.com/apollographql/router/issues/3126)) - -In 1.19, Apollo Router [switched to use `jemalloc` as the global Rust allocator on Linux](https://github.com/apollographql/router/blob/dev/CHANGELOG.md#improve-memory-fragmentation-and-resource-consumption-by-switching-to-jemalloc-as-the-memory-allocator-on-linux-pr-2882) to reduce memory fragmentation. However, prior to this change this was only occurring in the executable binary provided by the `apollo-router` crate and [custom binaries](https://www.apollographql.com/docs/router/customizations/custom-binary) using the crate _as a library_ were not getting this benefit. - -The `apollo-router` library crate now sets the global allocator so that custom binaries also take advantage of this by default. If some other choice is desired, the `global-allocator` Cargo [feature flag](https://doc.rust-lang.org/cargo/reference/features.html) can be disabled in `Cargo.toml` with: - -```toml -[dependencies] -apollo-router = {version = "[…]", default-features = false} -``` - -Library crates that depend on `apollo-router` (if any) should also do this in order to leave the choice to the eventual executable. (Cargo default features are only disabled if *all* dependents specify `default-features = false`.) - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/3157 - -### Add `ca-certificates` to our Docker image ([Issue #3173](https://github.com/apollographql/router/issues/3173)) - -We removed `curl` from our Docker images to improve security, which meant that our implicit install of `ca-certificates` (as a dependency of `curl`) was no longer performed. - -This fix reinstates the `ca-certificates` package explicitly, which is required for the router to be able to process TLS requests. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3174 - -### Helm: Running of `helm test` no longer fails - -Running `helm test` was generating an error since `wget` was sending a request without a proper body and expecting an HTTP status response of 2xx. Without the proper body, it expectedly resulted in an HTTP status of 400. By switching to using `netcat` (or `nc`) we will now check that the port is up and use that to determine that the router is functional. - -By [@bbardawilwiser](https://github.com/bbardawilwiser) in https://github.com/apollographql/router/pull/3096 - -### Move `curl` dependency to separate layer in Docker image ([Issue #3144](https://github.com/apollographql/router/issues/3144)) - -We've moved `curl` out of the Docker image we publish. The `curl` command is only used in the image we produce today for the sake of downloading dependencies. It is never used after that, but we can move it to a separate layer to further remove it from the image. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/3146 - -## 🛠 Maintenance - -### Improve `cargo-about` license checking ([Issue #3176](https://github.com/apollographql/router/issues/3176)) - -From the description of this [cargo about PR](https://github.com/EmbarkStudios/cargo-about/pull/216), it is possible for `NOASSERTION` identifiers to be added when gathering license information, causing license checks to fail. This change uses the new `cargo-about` configuration `filter-noassertion` to eliminate the problem. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3178 - - - -# [1.19.1] - 2023-05-26 - -## 🐛 Fixes - -### Fix router coprocessor deferred response buffering and change JSON body type from Object to String ([Issue #3015](https://github.com/apollographql/router/issues/3015)) - -The current implementation of the `RouterResponse` processing for coprocessors forces buffering of response data before passing the data to a coprocessor. This is a bug, because deferred responses should be processed progressively with a stream of calls to the coprocessor as each chunk of data becomes available. - -Furthermore, the data type was assumed to be valid JSON for both `RouterRequest` and `RouterResponse` coprocessor processing. This is also a bug, because data at this stage of processing was never necessarily valid JSON. This is a particular issue when dealing with deferred (when using `@defer`) `RouterResponses`. - -This change fixes both of these bugs by modifying the router so that coprocessors are invoked with a `body` payload which is a JSON `String`, not a JSON `Object`. Furthermore, the router now processes each chunk of response data separately so that a coprocessor will receive multiple calls (once for each chunk) for a deferred response. - -For more details about how this works see the [coprocessor documentation](https://www.apollographql.com/docs/router/customizations/coprocessor/). - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3104 - -### Experimental: Query plan cache keys now include a hash of the query and operation name ([Issue #2998](https://github.com/apollographql/router/issues/2998)) - -> **Note** -> This feature is still _experimental_ and not recommended under normal use nor is it validated that caching query plans in a distributed fashion will result in improved performance. - -The experimental feature for caching query plans in a distributed store (e.g., Redis) will now create a SHA-256 hash of the query and operation name and include that hash in the cache key, rather than using the operation document as it was previously. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3101 - -### Federation v2.4.6 ([Issue #3133](https://github.com/apollographql/router/issues/3133)) - -This release bumps the Router's Federation support from v2.4.5 to v2.4.6, which brings in notable query planner fixes from [v2.4.6](https://github.com/apollographql/federation/releases/tag/%40apollo%2Fquery-planner%402.4.6). Of note from those releases, this brings query planner fixes that (per that dependency's changelog): - -- Fix assertion error in some overlapping fragment cases. In some cases, when fragments overlaps on some sub-selections ([apollographql/federation#2594](https://github.com/apollographql/federation/pull/2594)) and some interface field implementation relied on sub-typing, an assertion error could be raised with a message of the form `Cannot add selection of field X to selection set of parent type Y` and this fixes this problem. - -- Fix possible fragment-related assertion error during query planning. This prevents a rare case where an assertion with a ([apollographql/federation#2596](https://github.com/apollographql/federation/pull/2596)) message of the form `Cannot add fragment of condition X (runtimes: ...) to parent type Y (runtimes: ...)` could fail during query planning. - -In addition, the packaging includes dependency updates for `bytes`, `regex`, `once_cell`, `tokio`, and `uuid`. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3135 - -### Error redaction for subgraphs now respects _disabling_ it - -This follows-up on the new ability to selectively disable Studio-bound error redaction which was released in https://github.com/apollographql/router/pull/3011 by fixing a bug which was preventing users from _disabling_ that behavior on subgraphs. Redaction continues to be on by default and both the default behavior and the explicit `redact: true` option were behaving correctly. - -With this fix, the `tracing.apollo.errors.subgraph.all.redact` option set to `false` will now transmit the un-redacted error message to Studio. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3137 - -### Evaluate multiple keys matching a JWT criteria ([Issue #3017](https://github.com/apollographql/router/issues/3017)) - -In some cases, multiple keys could match what a JWT asks for (both the algorithm, `alg`, and optional key identifier, `kid`). Previously, we scored each possible match and only took the one with the highest score. But even then, we could have multiple keys with the same score (e.g., colliding `kid` between multiple JWKS in tests). - -The improved behavior will: - -- Return a list of those matching `key` instead of the one with the highest score. -- Try them one by one until the JWT is validated, or return an error. -- If some keys were found with the highest possible score (matching `alg`, with `kid` present and matching, too), then we only test those keys. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3031 - -## 🛠 Maintenance - -### chore(deps): `xtask/` dependency updates ([PR #3149](https://github.com/apollographql/router/pull/3149)) - -This is effectively running `cargo update` in the `xtask/` directory (our directory of tooling; not runtime components) to bring things more up to date. - -This changeset takes extra care to update `chrono`'s features to remove the `time` dependency which is impacted by [CVE-2020-26235](https://nvd.nist.gov/vuln/detail/CVE-2020-26235), resolving a moderate severity which was appearing in scans. Again, this is not a runtime dependency and there was no actual/known impact to any users. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/3149 - -### Improve testability of the `state_machine` in integration tests - -We have introduced a `TestRouterHttpServer` for writing more fine-grained integration tests in the Router core for the behaviors of the state machine. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3099 - -# [1.19.0] - 2023-05-19 - -> **Note** -> This release focused a notable amount of effort on improving both CPU usage and memory utilization/fragmentization. Our testing and pre-release feedback has been overwhelmingly positive. 🙌 - -## 🚀 Features - -### GraphOS Enterprise: `require_authentication` option to reject unauthenticated requests ([Issue #2866](https://github.com/apollographql/router/issues/2866)) - -While the authentication plugin validates queries with JWT, it does not reject unauthenticated requests, and leaves that to other layers. This allows co-processors to handle other authentication methods, and plugins at later layers to authorize the request or not. Typically, [this was done in rhai](https://www.apollographql.com/docs/router/configuration/authn-jwt#example-rejecting-unauthenticated-requests). - -This now adds an option to the Router's YAML configuration to reject unauthenticated requests. It can be used as follows: - -```yaml -authorization: - require_authentication: true -``` - -The plugin will check for the presence of the `apollo_authentication::JWT::claims` key in the request context as proof that the request is authenticated. - - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3002 - -## 🐛 Fixes - -### Prevent span attributes from being formatted to write logs - -We do not show span attributes in our logs, but the log formatter still spends time formatting them to a string, even when there will be no logs written for the trace. This adds the `NullFieldFormatter` that entirely avoids formatting the attributes to improve performance. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2890 - -### Federation v2.4.5 - -This release bumps the Router's Federation support from v2.4.2 to v2.4.5, which brings in notable query planner fixes from [v2.4.3](https://github.com/apollographql/federation/releases/tag/%40apollo%2Fquery-planner%402.4.2) and [v2.4.5](https://github.com/apollographql/federation/releases/tag/%40apollo%2Fquery-planner%402.4.5). **Federation v2.4.4 will not exist** due to a publishing failure. Of note from those releases, this brings query planner fixes that: - -- Improves the heuristics used to try to reuse the query named fragments in subgraph fetches. Said fragment will be reused ([apollographql/federation#2541](https://github.com/apollographql/federation/pull/2541)) more often, which can lead to smaller subgraph queries (and hence overall faster processing). -- Fix potential assertion error during query planning in some multi-field `@requires` case. This error could be triggered ([#2575](https://github.com/apollographql/federation/pull/2575)) when a field in a `@requires` depended on another field that was also part of that same requires (for instance, if a field has a `@requires(fields: "id otherField")` and that `id` is also a key necessary to reach the subgraph providing `otherField`). - - The assertion error thrown in that case contained the message `Root groups (...) should have no remaining groups unhandled (...)` - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/3107 - -### Add support for throwing GraphQL errors in Rhai responses ([Issue #3069](https://github.com/apollographql/router/issues/3069)) - -It's possible to throw a GraphQL error from Rhai when processing a request. This extends the capability to include errors when processing a response. - -Refer to the _Terminating client requests_ section of the [Rhai api documentation](https://www.apollographql.com/docs/router/configuration/rhai) to learn how to throw GraphQL payloads. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3089 - -### Use a parking-lot mutex in `Context` to avoid contention ([Issue #2751](https://github.com/apollographql/router/issues/2751)) - -Request context requires synchronized access to the busy timer, and previously we used a futures aware mutex for that, but those are susceptible to contention. This replaces that mutex with a parking-lot synchronous mutex that is much faster. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2885 - -### Config and schema reloads now use async IO ([Issue #2613](https://github.com/apollographql/router/issues/2613)) - -If you were using local schema or config then previously the Router was performing blocking IO in an async thread. This could have caused stalls to serving requests. -The Router now uses async IO for all config and schema reloads. - -Fixing the above surfaced an issue with the experimental `force_hot_reload` feature introduced for testing. This has also been fixed and renamed to `force_reload`. - -```diff -experimental_chaos: -- force_hot_reload: 1m -+ force_reload: 1m -``` - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/3016 - -### Improve subgraph coprocessor context processing ([Issue #3058](https://github.com/apollographql/router/issues/3058)) - -Each call to a subgraph co-processor could update the entire request context as a single operation. This is racy and could lead to difficult to predict context modifications depending on the order in which subgraph requests and responses are processed by the router. - -This fix modifies the router so that subgraph co-processor context updates are merged within the existing context. This is still racy, but means that subgraphs are only racing to perform updates at the context key level, rather than across the entire context. - -Future enhancements will provide a more comprehensive mechanism that will support some form of sequencing or change arbitration across subgraphs. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3054 - -## 🛠 Maintenance - -### Add private component to the `Context` structure ([Issue #2800](https://github.com/apollographql/router/issues/2800)) - -There's a cost in using the `Context` structure during a request's lifecycle, due to JSON serialization and deserialization incurred when doing inter-plugin communication (e.g., between Rhai/coprocessors and Rust). For internal router usage, we now use a more efficient structure that avoids serialization costs of our private contextual properties which do not need to be exposed to plugins. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2802 - -### Adds an integration test for all YAML configuration files in `./examples` ([Issue #2932](https://github.com/apollographql/router/issues/2932)) - -Adds an integration test that iterates over `./examples` looking for `.yaml` files that don't have a `Cargo.toml` or `.skipconfigvalidation` sibling file, and then running `setup_router_and_registry` on them, fast failing on any errors along the way. - -By [@EverlastingBugstopper](https://github.com/EverlastingBugstopper) in https://github.com/apollographql/router/pull/3097 - -### Improve memory fragmentation and resource consumption by switching to `jemalloc` as the memory allocator on Linux ([PR #2882](https://github.com/apollographql/router/pull/2882)) - -Detailed memory investigation revealed significant memory fragmentation when using the default allocator, `glibc`, on Linux. Performance testing and flame-graph analysis suggested that using `jemalloc` on Linux would yield notable performance improvements. In our tests, this figure shows performance to be about 35% faster than the default allocator, on account of spending less time managing memory fragmentation. - -Not everyone will see a 35% performance improvement. Depending on your usage patterns, you may see more or less than this. If you see a regression, please file an issue with details. - -We have no reason to believe that there are allocation problems on other platforms, so this change is confined to Linux. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2882 - -### Improve performance by avoiding temporary allocations creating response paths ([PR #2854](https://github.com/apollographql/router/pull/2854)) - -Response formatting generated many temporary allocations while creating response paths. By making a reference based type to hold these paths, we can prevent those allocations and improve performance. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2854 - - - -# [1.18.1] - 2023-05-11 - -## 🐛 Fixes - -### Fix multipart response compression by using a large enough buffer - -When writing a deferred response, if the output buffer was too small to write the entire compressed response, the compressor would write a small chunk that did not decompress to the entire primary response, and would then wait for the next response to send the rest. - -Unfortunately, we cannot really know the output size we need in advance, and if we asked the decoder, it would tell us that it flushed all the data, even if it could have sent more. To compensate for this, we raise the output buffer size, and grow the buffer a second time after flushing, if necessary. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3067 - -### Emit more log details to the state machine's `Running` phase ([Issue #3065](https://github.com/apollographql/router/issues/3065)) - -This change adds details about the triggers of potential state changes to the logs and also makes it easier to see when an un-entitled event causes a state change to be ignored. - -Prior to this change, it was difficult to know from the logs why a router state reload had been triggered and the logs didn't make it clear that it was possible that the state change was going to be ignored. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/3066 - - -### Respect GraphOS/Studio metric "backoff" guidance ([Issue #2888](https://github.com/apollographql/router/issues/2888)) - -For stability reasons, GraphOS metric ingress will return an HTTP `429` status code with `Retry-After` guidance if it's unable to immediately accept a metric submission from a router. A router instance should not try to submit further metrics until that amount of time (in seconds) has elapsed. This fix provides support for this interaction. - -While observing a backoff request from GraphOS, the router will continue to collect metrics and no metrics are lost unless the router terminates before the timeout expires. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2977 - -## 🛠 Maintenance - -### Refactor the way we're redacting errors for Apollo telemetry - -This follows-up on the federated subgraph trace error redaction mechanism changes which first appeared in [v1.16.0](https://github.com/apollographql/router/releases/tag/v1.16.0) via [PR #3011](https://github.com/apollographql/router/pull/3011) with some internal refactoring that improves the readability of the logic. There should be no functional changes to the feature's behavior. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3030 - - -# [1.18.0] - 2023-05-05 - -## 🚀 Features - -### Introduced new metric which tracks query planning time - -We've introduced a `apollo_router_query_planning_time` histogram which captures time spent in the query planning phase. This is documented along with our other metrics [in the documentation](https://www.apollographql.com/docs/router/configuration/metrics/#available-metrics). - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2974 - -## 🐛 Fixes - -### Small gzip'd responses no longer cause a panic - -A regression introduced in v1.17.0 — again related to compression — has been resolved. This occurred when small responses used invalid buffer management, causing a panic. - -By [@dbanty](https://github.com/dbanty) in https://github.com/apollographql/router/pull/3047 - -### HTTP status codes are now returned in `SubrequestHttpError` as intended - -When contextually available, the HTTP status code is included within `SubrequestHttpError`. This provides plugins the ability to access the status code directly. Previously, only string parsing of the `reason` could be used to determine the status code. - -This corrects a previous contribution which added the status code, but neglected to serialize it properly into the `extensions` in the response which are made available to plugins. Thank you to the same contributor for the correction! - -By [@scottdouglas1989](https://github.com/scottdouglas1989) in https://github.com/apollographql/router/pull/3005 - -## 📚 Documentation - -### Indicate that `apollo_router_cache_size` is a count of cache entries - -This follows-up [PR #2607](https://github.com/apollographql/router/pull/2607) which added `apollo_router_cache_size`. It adds `apollo_router_cache_size` to [the documentation](https://www.apollographql.com/docs/router/configuration/metrics/#available-metrics) and indicates that this is the number of cache entries (that is, a count). - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/3044 - -# [1.17.0] - 2023-05-04 - -## 🚀 Features - -### GraphOS Enterprise: Operation Limits - -You can define [operation limits](https://www.apollographql.com/docs/router/configuration/operation-limits) in your router's configuration to reject potentially malicious requests. An operation that exceeds _any_ specified limit is rejected. - -You define operation limits in your router's [YAML config file](https://www.apollographql.com/docs/router/configuration/overview#yaml-config-file), like so: - -```yaml -preview_operation_limits: - max_depth: 100 - max_height: 200 - max_aliases: 30 - max_root_fields: 20 -``` - -See details in [operation limits documentation](https://www.apollographql.com/docs/router/configuration/operation-limits) for information on setting up this GraphOS Enterprise feature. - -By [@SimonSapin](https://github.com/SimonSapin), [@lrlna](https://github.com/lrlna), and [@StephenBarlow](https://github.com/StephenBarlow) - -## 🐛 Fixes - -### Ensure the compression state is flushed ([Issue #3035](https://github.com/apollographql/router/issues/3035)) - -In some cases, the "finish" call to flush the compression state at the end of a request was not flushing the entire state. This fix calls "finish" multiple times until all data is used. - -This fixes a regression introduced in v1.16.0 by [#2986](https://github.com/apollographql/router/pull/2986) which resulted in larger responses being truncated after compression. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3037 - -## 🛠 Maintenance - -### Make `test_experimental_notice` assertion more targeted ([Pull #3036](https://github.com/apollographql/router/pull/3036)) - -Previously this test relied on a full snapshot of the log message. This was likely to result in failures, either due to environmental reasons or other unrelated changes. - -The test now relies on a more targeted assertion that is less likely to fail under various conditions. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/3036 - -# [1.16.0] - 2023-05-03 - -## 🚀 Features - -### Add ability to transmit un-redacted errors from federated traces to Apollo Studio - -When using subgraphs which are enabled with [Apollo Federated Tracing](https://www.apollographql.com/docs/router/configuration/apollo-telemetry/#enabling-field-level-instrumentation), the error messages within those traces will be **redacted by default**. - -New configuration (`tracing.apollo.errors.subgraph.all.redact`, which defaults to `true`) enables or disables the redaction mechanism. Similar configuration (`tracing.apollo.errors.subgraph.all.send`, which also defaults to `true`) enables or disables the entire transmission of the error to Studio. - -The error messages returned to the clients are **not** changed or redacted from their previous behavior. - -To enable sending subgraphs' federated trace error messages to Studio **without redaction**, you can set the following configuration: - -```yaml title="router.yaml" -telemetry: - apollo: - errors: - subgraph: - all: - send: true # (true = Send to Studio, false = Do not send; default: true) - redact: false # (true = Redact full error message, false = Do not redact; default: true) -``` - -It is also possible to configure this **per-subgraph** using a `subgraphs` map at the same level as `all` in the configuration, much like other sections of the configuration which have subgraph-specific capabilities: - -```yaml title="router.yaml" -telemetry: - apollo: - errors: - subgraph: - all: - send: true - redact: false # Disables redaction as a default. The `accounts` service enables it below. - subgraphs: - accounts: # Applies to the `accounts` subgraph, overriding the `all` global setting. - redact: true # Redacts messages from the `accounts` service. -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3011 - -### Introduce `response.is_primary` Rhai helper for working with deferred responses ([Issue #2935](https://github.com/apollographql/router/issues/2935)) ([Issue #2936](https://github.com/apollographql/router/issues/2936)) - -A new Rhai `response.is_primary()` helper has been introduced that returns `false` when the current chunk being processed is a _deferred response_ chunk. Put another way, it will be `false` if the chunk is a _follow-up_ response to the initial _primary_ response, during the fulfillment of a `@defer`'d fragment in a larger operation. The initial response will be `is_primary() == true`. This aims to provide the right primitives so users can write more defensible error checking. It is especially useful for response header manipulations, which is only possible on the primary response. The introduction of this relates to a bug fix noted in the _Fixes_ section below. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2945 - -### Time-based forced hot-reload for "chaos" testing - -For testing purposes, the Router can now artificially be forced to hot-reload (as if the configuration or schema had changed) at a configured time interval. This can help reproduce issues like reload-related memory leaks. We don't recommend using this in any production environment. (If you are compelled to use it in production, please let us know about your use case!) - -The new configuration section for this "chaos" testing is (and will likely remain) marked as "experimental": - -```yaml -experimental_chaos: - force_hot_reload: 1m -``` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/2988 - -### Provide helpful console output when using "preview" features, just like "experimental" features - -This expands on the existing mechanism that was originally introduced in https://github.com/apollographql/router/pull/2242, which supports the notion of an "experimental" feature, and makes it compatible with the notion of "preview" features. - -When preview or experimental features are used, an `INFO`-level log is emitted during startup to notify which features are used and shows URLs to their GitHub discussions, for feedback. Additionally, `router config experimental` and `router config preview` CLI sub-commands list all such features in the current Router version, regardless of which are used in a given configuration file. - -For more information about launch stages, please see the documentation here: https://www.apollographql.com/docs/resources/product-launch-stages/ - -By [@o0ignition0o](https://github.com/o0ignition0o), [@abernix](https://github.com/abernix), and [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/2960 - -### Report `operationCountByType` counts to Apollo Studio ([PR #2979](https://github.com/apollographql/router/pull/2979)) - -This adds the ability for Studio to track operation **counts** broken down by type of operations (e.g., `query` vs `mutation`). Previously, we only reported total operation count. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2979 - -## 🐛 Fixes - -### Update to Federation v2.4.2 - -This update to Federation v2.4.2 fixes a [potential bug](https://github.com/apollographql/federation/pull/2524) when an `@interfaceObject` type has a `@requires`. This might be encountered when an `@interfaceObject` type has a field with a `@requires` and the query requests that field only for some specific implementations of the corresponding interface. In this case, the generated query plan was sometimes invalid and could result in an invalid query to a subgraph. In the case that the subgraph was an Apollo Server implementation, this lead to the subgraph producing an `"The _entities resolver tried to load an entity for type X, but no object or interface type of that name was found in the schema"` error. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/2910 - -### Fix handling of deferred response errors from Rhai scripts ([Issue #2935](https://github.com/apollographql/router/issues/2935)) ([Issue #2936](https://github.com/apollographql/router/issues/2936)) - -If a Rhai script was to error while processing a deferred response (i.e., an operation which uses `@defer`) the Router was ignoring the error and returning `None` in the stream of results. This had two unfortunate aspects: - - - the error was not propagated to the client - - the stream was terminated (silently) - -With this fix we now capture the error and still propagate the response to the client. This fix _also_ adds support for the `is_primary()` method which may be invoked on both `supergraph_service()` and `execution_service()` responses. It may be used to avoid implementing exception handling for header interactions and to determine if a response `is_primary()` (i.e., first) or not. - -e.g.: - - -```perl - if response.is_primary() { - print(`all response headers: `); - } else { - print(`don't try to access headers`); - } -``` - -vs - - -```perl - try { - print(`all response headers: `); - } - catch(err) { - if err == "cannot access headers on a deferred response" { - print(`don't try to access headers`); - } - } -``` - -> **Note** -> This is a _minimal_ example for purposes of illustration which doesn't exhaustively check all error conditions. An exception handler should always handle all error conditions. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2945 - -### Fix incorrectly placed "message" in Rhai JSON-formatted logging ([Issue #2777](https://github.com/apollographql/router/issues/2777)) - -This fixes a bug where Rhai logging was incorrectly putting the message of the log into the `out` attribute, when serialized as JSON. Previously, the `message` field was showing `rhai_{{level}}` (i.e., `rhai_info`), despite there being a separate `level` field in the JSON structure. - -The impact of this fix can be seen in this example where we call `log_info()` in a Rhai script: - - -```perl - log_info("this is info"); -``` - -**Previously**, this would result in a log as follows, with the text of the message set within `out`, rather than `message`. - -```json -{"timestamp":"2023-04-19T07:46:15.483358Z","level":"INFO","message":"rhai_info","out":"this is info"} -``` - -**After the change**, the message is correctly within `message`. The level continues to be available at `level`. We've also additionally added a `target` property which shows the file which produced the error: - -```json -{"timestamp":"2023-04-19T07:46:15.483358Z","level":"INFO","message":"this is info","target":"src/rhai_logging.rhai"} -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2975 - - -### Deferred responses now utilize compression, when requested ([Issue #1572](https://github.com/apollographql/router/issues/1572)) - -We previously had to disable compression on deferred responses due to an upstream library bug. To fix this, we've replaced `tower-http`'s `CompressionLayer` with a custom stream transformation. This is necessary because `tower-http` uses `async-compression` under the hood, which buffers data until the end of the stream, analyzes it, then writes it, ensuring a better compression. However, this is wholly-incompatible with a core concept of the multipart protocol for `@defer`, which requires chunks to be sent _as soon as possible_. To support that, we need to compress chunks independently. - -This extracts parts of the `codec` module of `async-compression`, which so far is not public, and makes a streaming wrapper _above it_ that flushes the compressed data on every response within the stream. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2986 - -### Update the `h2` dependency to fix a _potential_ Denial-of-Service (DoS) vulnerability - -Proactively addresses the advisory in https://rustsec.org/advisories/RUSTSEC-2023-0034, though we have no evidence that suggests it has been exploited on any Router deployment. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2982 - -### Rate limit errors emitted from OpenTelemetry ([Issue #2953](https://github.com/apollographql/router/issues/2953)) - -When a batch span exporter is unable to send accept a span because the buffer is full it will emit an error. These errors can be very frequent and could potentially impact performance. To mitigate this, OpenTelemetry errors are now rate limited to one every ten seconds, per error type. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2954 - -### Improved messaging when a request is received without an operation ([Issue #2941](https://github.com/apollographql/router/issues/2941)) - -The message that is displayed when a request has been sent to the Router without an operation has been improved. This materializes as a developer experience improvement since users (especially those using GraphQL for the first time) might send a request to the Router using a tool that isn't GraphQL-aware, or might just have their API tool of choice misconfigured. - -Previously, the message stated "missing query string", but now more helpfully suggests sending either a POST or GET request and specifying the desired operation as the `query` parameter (i.e., either in the POST data or in the query string parameters for GET queries). - -By [@kushal-93](https://github.com/kushal-93) in https://github.com/apollographql/router/pull/2955 - -### Traffic shaping configuration fix for global `experimental_enable_http2` - -We've resolved a case where the `experimental_enable_http2` feature wouldn't properly apply when configured with a global configuration. - -Huge thanks to [@westhechiang](https://github.com/westhechiang), [@leggomuhgreggo](https://github.com/leggomuhgreggo), [@vecchp](https://github.com/vecchp) and [@davidvasandani](https://github.com/davidvasandani) for discovering the issue and finding a reproducible testcase! - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2976 - -### Limit the memory usage of the `apollo` OpenTelemetry exporter ([PR #3006](https://github.com/apollographql/router/pull/3006)) - -We've added a new LRU cache in place of a `Vec` for sub-span data to avoid keeping all events for a span in memory, since we don't need it for our computations. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/3006 - -# [1.15.1] - 2023-04-18 - -## 🐛 Fixes - -### Resolve Docker `unrecognized subcommand` error ([Issue #2966](https://github.com/apollographql/router/issues/2966)) - -We've repaired the Docker build of the v1.15.0 release which broke due to the introduction of syntax in the Dockerfile which can only be used by the the `docker buildx` tooling [which leverages Moby BuildKit](https://www.docker.com/blog/introduction-to-heredocs-in-dockerfiles/). - -Furthermore, the change didn't apply to the `diy` ("do-it-yourself") image, and we'd like to prevent the two Dockerfiles from deviating more than necessary. - -Overall, this reverts [apollographql/router#2925](https://github.com/apollographql/router/pull/2925). - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/2968 - -### Helm Chart `extraContainers` - -This is another iteration on the functionality for supporting side-cars within Helm charts, which is quite useful for [coprocessor](https://www.apollographql.com/docs/router/customizations/coprocessor/) configurations. - -By [@pcarrier](https://github.com/pcarrier) in https://github.com/apollographql/router/pull/2967 - -## 📃 Configuration - -### Treat Helm `extraLabels` as templates - -It is now possible to use data from Helm's `Values` or `Chart` objects to add additional labels to Kubernetes Deployments of Pods. - -As of this release, the following example: - -```yaml -extraLabels: - env: {{ .Chart.AppVersion }} -``` - -... will now result in: - -```yaml -labels: - env: "v1.2.3" -``` - -Previously, this would have resulted in merely emitting the untemplatized `{{ .Chart.AppVersion }}` value, resulting in an invalid label. - -By [@gscheibel](https://github.com/gscheibel) in https://github.com/apollographql/router/pull/2962 - -# [1.15.0] - 2023-04-17 - -## 🚀 Features - -### GraphOS Enterprise: Allow JWT algorithm restrictions ([Issue #2714](https://github.com/apollographql/router/issues/2714)) - -It is now possible to restrict the list of accepted algorthms to a well-known set for cases where an issuer's JSON Web Key Set (JWKS) contains keys which are usable with multiple algorithms. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2852 - -## 🐛 Fixes - -### Invalid requests now return proper GraphQL-shaped errors ([Issue #2934](https://github.com/apollographql/router/issues/2934)), ([Issue #2946](https://github.com/apollographql/router/issues/2946)) - -Unsupported `content-type` and `accept` headers sent on requests now return proper GraphQL errors nested as elements in a top-level `errors` array, rather than returning a single GraphQL error JSON object. - -This also introduces a new error code, `INVALID_CONTENT_TYPE_HEADER`, rather than using `INVALID_ACCEPT_HEADER` when an invalid `content-type` header was received. - -By [@EverlastingBugstopper](https://github.com/EverlastingBugstopper) in https://github.com/apollographql/router/pull/2947 - -## 🛠 Maintenance - -### Remove redundant `println!()` that broke json formatted logging ([PR #2923](https://github.com/apollographql/router/pull/2923)) - -The `println!()` statement being used in our trace transmission logic was redundant since it was already covered by a pre-existing `WARN` log line. Most disruptively though, it broke JSON logging. - -For example, this previously showed as: - -``` -Got error sending request for url (https://example.com/api/ingress/traces): connection error: unexpected end of file -{"timestamp":"2023-04-11T06:36:27.986412Z","level":"WARN","message":"attempt: 1, could not transfer: error sending request for url (https://example.com/api/ingress/traces): connection error: unexpected end of file"} -``` - -It will now merely log the second line. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2923 - -### Adds HTTP status code to subgraph HTTP error type - -When contextually available, the `SubrequestHttpError` now includes the HTTP status code. This provides plugins with the ability to access the status code directly. Previously, parsing the `reason` value as a string was the only way to determine the status code. - -By [@scottdouglas1989](https://github.com/scottdouglas1989) in https://github.com/apollographql/router/pull/2902 - -### Pin the `router-bridge` version - -When using the router as a library, `router-bridge` versions can be automatically updated, which can result in incompatibilities. We want to ensure that the Router and `router-bridge` always work with vetted versions, so we now pin it in our `Cargo.toml` and update it using our tooling. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2916 - -### Update to Federation v2.4.1 ([2937](https://github.com/apollographql/router/issues/2937)) - -The Router has been updated to use Federation v2.4.1, which includes [a fix involving `@interfaceObject`](https://github.com/apollographql/federation/blob/main/gateway-js/CHANGELOG.md#241). - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2957 - -# [1.14.0] - 2023-04-06 - -## 🚀 Features - -### GraphOS Enterprise: Coprocessor read access to request `uri`, `method` and HTTP response status codes ([Issue #2861](https://github.com/apollographql/router/issues/2861), [Issue #2861](https://github.com/apollographql/router/issues/2862)) - -We've added the ability for [coprocessors](https://www.apollographql.com/docs/router/customizations/coprocessor) to have read-only access to additional contextual information at [the `RouterService` and `SubgraphService`](https://www.apollographql.com/docs/router/customizations/coprocessor/#how-it-works) stages: - -The `RouterService` stage now has read-only access to these **client request** properties: - - `path` (e.g., `/graphql`) - - `method` (e.g., `POST`, `GET`) - -The `RouterService` stage now has read-only access to these **client response** properties: - - `status_code` (e.g. `403`, `200`) - -The `SubgraphService` stage now has read-only access to these **subgraph response** properties: - - `status_code` (e.g., `503`, `200`) - -By [@o0ignition0o](https://github.com/o0ignition0o) in https://github.com/apollographql/router/pull/2863 - -## 🐛 Fixes - -### Coprocessors: Empty body requests from `GET` requests are now deserialized without error - -Fixes a bug where a coprocessor operating at the `router_request` stage would fail to deserialize an empty body, which is typical for `GET` requests. - -By [@o0ignition0o](https://github.com/o0ignition0o) in https://github.com/apollographql/router/pull/2863 - -## 📃 Configuration - -### Helm: Router chart now supports `extraLabels` for Deployments/Pods - -Our Helm chart now supports a new value called `extraLabels`, which enables chart users to add custom labels to the Router Deployment and its Pods. - -By [@gscheibel](https://github.com/gscheibel/) in https://github.com/apollographql/router/pull/2903 - -### Helm: Router chart now supports `extraContainers` to run sidecars - -Our Helm chart now supports `extraContainers` in an effort to simplify the ability to run containers alongside Router containers (sidecars) which is a useful pattern for [coprocessors](https://www.apollographql.com/docs/router/customizations/coprocessor/). - -By [@pcarrier](https://github.com/pcarrier) in https://github.com/apollographql/router/pull/2881 - -### Migrate away from unimplemented `coprocessor.subgraph.all.response.uri` - -We have removed a completely unimplemented `coprocessor.subgraph.all.response.uri` key from our configuration. It had no effect, but we will automatically migrate configurations which did use it, resulting in no breaking changes by this removal. - -By [@o0ignition0o](https://github.com/o0ignition0o) in https://github.com/apollographql/router/pull/2863 - -## 📚 Documentation - -### Update coprocessor documentation to reflect newly added fields ([Issue #2886](https://github.com/apollographql/router/issues/2886)) - -The [External coprocessing documentation](https://www.apollographql.com/docs/router/customizations/coprocessor) is now up to date, with a full configuration example, and the newly added fields. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2863 - -### Example: Rhai-based `cache-control` response header management - -A new Rhai example demonstrates how to recreate some of the behavior of Apollo Gateway's subgraph `cache-control` response header behavior. This addresses some of the need identified in #326. - -By [@lennyburdette](https://github.com/lennyburdette) in https://github.com/apollographql/router/pull/2759 - -# [1.13.2] - 2023-04-03 - -## 🐛 Fixes - -### Replace the old query planner with the incoming query planner on reload - -We've fixed an important regression in v1.13.1 (introduced by [PR #2706](https://github.com/apollographql/router/pull/2706)) which resulted in Routers failing to update to newer supergraphs unless they were fully restarted; hot-reloads of the supergraph did not work properly. This affects all v1.13.1 versions, whether the supergraph was delivered from a local file or if delivered as part of Managed Federation through Apollo Uplink. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2895 - -# [1.13.1] - 2023-03-28 - -## 🚀 Features - -### Router homepage now supports redirecting to Apollo Studio Explorer ([PR #2282](https://github.com/apollographql/router/pull/2282)) - -In order to replicate the landing-page experience (called "homepage" on the Router) which was available in Apollo Gateway, we've introduced a `graph_ref` option to the `homepage` configuration. This allows users to be (optionally, as as sticky preference) _redirected_ from the Apollo Router homepage directly to the correct graph in Apollo Studio Explorer. - -Since users may have their own preference on the value, we do not automatically infer the graph reference (e.g., `graph@variant`), instead requiring that the user set it to the value of their choice. - -For example: - -```yaml -homepage: - graph_ref: my-org-graph@production -``` - -By [@flyboarder](https://github.com/flyboarder) in https://github.com/apollographql/router/pull/2282 - -### New metric for subgraph-requests, including "retry" and "break" events ([Issue #2518](https://github.com/apollographql/router/issues/2518)), ([Issue #2736](https://github.com/apollographql/router/issues/2736)) - -We now emit a `apollo_router_http_request_retry_total` metric from the Router. The metric also offers observability into _aborted_ requests via an `status = "aborted"` attribute on the metric. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2829 - -### New `receive_body` span represents time consuming a client's request body ([Issue #2518](https://github.com/apollographql/router/issues/2518)), ([Issue #2736](https://github.com/apollographql/router/issues/2736)) - -When running with **debug-level** instrumentation, the Router now emits a `receive_body` span which tracks time spent receiving the request body from the client. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2829 - -## 🐛 Fixes - -### Use single Deno runtime for query planning ([Issue #2690](https://github.com/apollographql/router/issues/2690)) - -We now keep the same JavaScript-based query-planning runtime alive for the entirety of the Router's lifetime, rather than disposing of it and creating a new one at several points in time, including when processing GraphQL requests, generating an "API schema" (the publicly queryable version of the supergraph, with private fields excluded), and when processing introspection queries. - -Not only is this a more preferred architecture that is more considerate of system resources, but it was also responsible for a memory leak which occurred during supergraph changes. - -We believe this will alleviate, but not entirely solve, the circumstances seen in the above-linked issue. - -By [@geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2706 - -# [1.13.0] - 2023-03-23 - -## 🚀 Features - -### Uplink metrics and improved logging ([Issue #2769](https://github.com/apollographql/router/issues/2769), [Issue #2815](https://github.com/apollographql/router/issues/2815), [Issue #2816](https://github.com/apollographql/router/issues/2816)) - -For monitoring, observability and debugging requirements around Uplink-related behaviors (those which occur as part of Managed Federation) the router now emits better log messages and emits new metrics around these facilities. The new metrics are: - -- `apollo_router_uplink_fetch_duration_seconds_bucket`: A _histogram_ of durations with the following attributes: - - - `url`: The URL that was polled - - `query`: `SupergraphSdl` or `Entitlement` - - `type`: `new`, `unchanged`, `http_error`, `uplink_error`, or `ignored` - - `code`: The error code, depending on `type` - - `error`: The error message - -- `apollo_router_uplink_fetch_count_total`: A _gauge_ that counts the overall success (`status="success"`) or failure (`status="failure"`) counts that occur when communicating to Uplink _without_ taking into account fallback. - -> :warning: The very first poll to Uplink is unable to capture metrics since its so early in the router's lifecycle that telemetry hasn't yet been setup. We consider this a suitable trade-off and don't want to allow perfect to be the enemy of good. - -Here's an example of what these new metrics look like from the Prometheus scraping endpoint: - -``` -# HELP apollo_router_uplink_fetch_count_total apollo_router_uplink_fetch_count_total -# TYPE apollo_router_uplink_fetch_count_total gauge -apollo_router_uplink_fetch_count_total{query="SupergraphSdl",service_name="apollo-router",status="success"} 1 -# HELP apollo_router_uplink_fetch_duration_seconds apollo_router_uplink_fetch_duration_seconds -# TYPE apollo_router_uplink_fetch_duration_seconds histogram -apollo_router_uplink_fetch_duration_seconds_bucket{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/",le="0.001"} 0 -apollo_router_uplink_fetch_duration_seconds_bucket{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/",le="0.005"} 0 -apollo_router_uplink_fetch_duration_seconds_bucket{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/",le="0.015"} 0 -apollo_router_uplink_fetch_duration_seconds_bucket{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/",le="0.05"} 0 -apollo_router_uplink_fetch_duration_seconds_bucket{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/",le="0.1"} 0 -apollo_router_uplink_fetch_duration_seconds_bucket{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/",le="0.2"} 0 -apollo_router_uplink_fetch_duration_seconds_bucket{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/",le="0.3"} 0 -apollo_router_uplink_fetch_duration_seconds_bucket{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/",le="0.4"} 0 -apollo_router_uplink_fetch_duration_seconds_bucket{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/",le="0.5"} 1 -apollo_router_uplink_fetch_duration_seconds_bucket{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/",le="1"} 1 -apollo_router_uplink_fetch_duration_seconds_bucket{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/",le="5"} 1 -apollo_router_uplink_fetch_duration_seconds_bucket{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/",le="10"} 1 -apollo_router_uplink_fetch_duration_seconds_bucket{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/",le="+Inf"} 1 -apollo_router_uplink_fetch_duration_seconds_sum{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/"} 0.465257131 -apollo_router_uplink_fetch_duration_seconds_count{kind="unchanged",query="SupergraphSdl",service_name="apollo-router",url="https://uplink.api.apollographql.com/"} 1 -``` - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/2779, https://github.com/apollographql/router/pull/2817, https://github.com/apollographql/router/pull/2819 https://github.com/apollographql/router/pull/2826 - -## 🐛 Fixes - -### Only process Uplink messages that are deemed to be newer ([Issue #2794](https://github.com/apollographql/router/issues/2794)) - -Uplink is backed by multiple cloud providers to ensure high availability. However, this means that there will be periods of time where Uplink endpoints do not agree on what the latest data is. They are eventually consistent. - -This has not been a problem for most users, as the default mode of operation for the router is to fallback to the secondary Uplink endpoint if the first fails. - -The other mode of operation, is round-robin, which is triggered only when setting the `APOLLO_UPLINK_ENDPOINTS` environment variable. In this mode there is a much higher chance that the router will go back and forth between schema versions due to disagreement between the Apollo Uplink servers or any user-provided proxies set into this variable. - -This change introduces two fixes: -1. The Router will only use fallback strategy. Uplink endpoints are not strongly consistent, and therefore it is better to always poll a primary source of information if available. -2. Uplink already handled freshness of schema but now also handles entitlement freshness. - -> Note: We advise against using `APOLLO_UPLINK_ENDPOINTS` to try to cache uplink responses for high availability purposes. Each request to Uplink currently sends state which limits the usefulness of such a cache. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/2803, https://github.com/apollographql/router/pull/2826, https://github.com/apollographql/router/pull/2846 - -### Distributed caching: Don't send Redis' `CLIENT SETNAME` ([PR #2825](https://github.com/apollographql/router/pull/2825)) - -We won't send [the `CLIENT SETNAME` command](https://redis.io/commands/client-setname/) to connected Redis servers. This resolves an incompatibility with some Redis-compatible servers since not all "Redis-compatible" offerings (like Google Memorystore) actually support _every_ Redis command. We weren't actually necessitating this feature, it was just a feature that could be enabled optionally on our Redis client. No Router functionality is impacted. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2825 - -### Support bare top-level `__typename` when aliased ([Issue #2792](https://github.com/apollographql/router/issues/2792)) - -PR #1762 implemented support for the query `{ __typename }` but it did not work properly if the top-level standalone `__typename` field was aliased. This now works properly. - -By [@glasser](https://github.com/glasser) in https://github.com/apollographql/router/pull/2791 - -### Maintain errors set on `_entities` ([Issue #2731](https://github.com/apollographql/router/issues/2731)) - -In their responses, some subgraph implementations do not return errors _per entity_ but instead on the entire path. We now transmit those, irregardless. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2756 - -## 📃 Configuration - -### Custom OpenTelemetry Datadog exporter mapping ([Issue #2228](https://github.com/apollographql/router/issues/2228)) - -This PR fixes the issue with the Datadog exporter not providing meaningful contextual data in the Datadog traces. -There is a [known issue](https://docs.rs/opentelemetry-datadog/latest/opentelemetry_datadog/#quirks) where OpenTelemetry is not fully compatible with Datadog. - -To fix this, the `opentelemetry-datadog` crate added [custom mapping functions](https://docs.rs/opentelemetry-datadog/0.6.0/opentelemetry_datadog/struct.DatadogPipelineBuilder.html#method.with_resource_mapping). - -Now, when `enable_span_mapping` is set to `true`, the Apollo Router will perform the following mapping: - -1. Use the OpenTelemetry span name to set the Datadog span operation name. -2. Use the OpenTelemetry span attributes to set the Datadog span resource name. - -For example: - -Let's say we send a query `MyQuery` to the Apollo Router, then the Router using the operation's query plan will send a query to `my-subgraph-name`, producing the following trace: - -``` - | apollo_router request | - | apollo_router router | - | apollo_router supergraph | - | apollo_router query_planning | apollo_router execution | - | apollo_router fetch | - | apollo_router subgraph | - | apollo_router subgraph_request | -``` - -As you can see, there is no clear information about the name of the query, the name of the subgraph, or the name of query sent to the subgraph. - -Instead, with this new `enable_span_mapping` setting set to `true`, the following trace will be created: - -``` - | request /graphql | - | router | - | supergraph MyQuery | - | query_planning MyQuery | execution | - | fetch fetch | - | subgraph my-subgraph-name | - | subgraph_request MyQuery__my-subgraph-name__0 | -``` - -All this logic is gated behind the configuration `enable_span_mapping` which, if set to `true`, will take the values from the span attributes. - -By [@samuelAndalon](https://github.com/samuelAndalon) in https://github.com/apollographql/router/pull/2790 - -## 🛠 Maintenance - -### Migrate `xtask` CLI parsing from `StructOpt` to `Clap` ([Issue #2807](https://github.com/apollographql/router/issues/2807)) - -As an internal improvement to our tooling, we've migrated our `xtask` toolset from `StructOpt` to `Clap`, since `StructOpt` is in maintenance mode. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/2808 - -### Subgraph configuration override ([Issue #2426](https://github.com/apollographql/router/issues/2426)) - -We've introduced a new generic wrapper type for _subgraph-level_ configuration, with the following behaviour: - -- If there's a config in `all`, it applies to all subgraphs. If it is not there, the default values apply -- If there's a config in `subgraphs` for a specific _named_ subgraph: - - the fields it specifies override the fields specified in `all` - - the fields it does _not_ specify uses the values provided by `all`, or default values, if applicable - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2453 - -### Add integration tests for Uplink URLs ([Issue #2827](https://github.com/apollographql/router/issues/2827)) - -We've added integration tests to ensure that all Uplink URLs can be contacted and data can be retrieved in an expected format. - -We've also changed our URLs to align exactly with Gateway, to simplify our own documentation. _Existing Router users do not need to take any action as we support both on our infrastructure._ - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/2830, https://github.com/apollographql/router/pull/2834 - -### Improve integration test harness ([Issue #2809](https://github.com/apollographql/router/issues/2809)) - -Our _internal_ integration test harness has been simplified. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/2810 - -### Use `kubeconform` to validate the Router's Helm manifest ([Issue #1914](https://github.com/apollographql/router/issues/1914)) - -We've had a couple cases where errors have been inadvertently introduced to our Helm charts. These have required fixes such as [this fix](https://github.com/apollographql/router/pull/2788). So far, we've been relying on manual testing and inspection, but we've reached the point where automation is desired. This change uses [`kubeconform`](https://github.com/yannh/kubeconform) to ensure that the YAML generated by our Helm manifest is indeed valid. Errors may still be possible, but this should at least prevent basic errors from occurring. This information will be surfaced in our CI checks. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2835 - -## 📚 Documentation - -### Re-point links going via redirect to their true sources - -Some of our documentation links were pointing to pages which have been renamed and received new page names during routine documentation updates. While the links were not broken (the former links redirected to the new URLs) we've updated them to avoid the extra hop - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2780 - -### Fix coprocessor docs about subgraph URI mutability - -The subgraph `uri` is (and always has been) _mutable_ when responding to the `SubgraphRequest` stage in a coprocessor. - -By [@lennyburdette](https://github.com/lennyburdette) in https://github.com/apollographql/router/pull/2801 - -# [1.12.1] - 2023-03-15 - -> :balloon: This is a fast-follow to v1.12.0 which included many new updates and new GraphOS Enterprise features. Be sure to check that (longer, more detailed!) changelog for the full details. Thanks! - -## 🐛 Fixes - -### Retain existing Apollo Uplink entitlements ([PR #2781](https://github.com/apollographql/router/pull/2781)) - -Our end-to-end integration testing revealed a newly-introduced bug in v1.12.0 which could affect requests to Apollo Uplink endpoints which are located in different data centers, when those results yield differing responses. This only impacted a very small number of cases, but retaining previous fetched values is undeniably more durable and will fix this so we're expediting a fix. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/2781 - - -# [1.12.0] - 2023-03-15 - -> :balloon: In this release, we are excited to make three new features **generally available** to GraphOS Enterprise customers running self-hosted routers: JWT Authentication, Distributed APQ Caching, and External Coprocessor support. Read more about these features below, and see [our documentation](https://www.apollographql.com/docs/router/enterprise-features/) for additional information. - -## 🚀 Features - -### GraphOS Enterprise: JWT Authentication - -> 🎈 JWT Authentication is now _generally available_ to GraphOS Enterprise customers running self-hosted routers. To fully account for the changes between the initial experimental release and the final generally available implementation, we recommend removing the experimental configuration and re-implementing it following the documentation below to ensure proper configuration and that all security requirements are met. - -Router v1.12 adds support for JWT validation, claim extraction, and custom security policies in Rhai scripting to reject bad traffic at the edge of the graph — for enhanced zero-trust and defense-in-depth. Extracting claims one time in the router and securely forwarding them to subgraphs can reduce the operational burden on backend API teams, reduce JWT processing, and speed up response times with improved header matching for increased [query deduplication](https://www.apollographql.com/docs/router/configuration/traffic-shaping/#query-deduplication). - -See the [JWT Authentication documentation](https://www.apollographql.com/docs/router/configuration/authn-jwt) for information on setting up this GraphOS Enterprise feature. - -### GraphOS Enterprise: Distributed APQ Caching - -> 🎈 Distributed APQ Caching is now _generally available_ to GraphOS Enterprise customers running self-hosted routers. To fully account for the changes between the initial experimental releases and the final generally available implementation, we recommend removing the experimental configuration and re-implementing it following the documentation below to ensure proper configuration. - -With Router v1.12, you can now use _distributed APQ caching_ to improve p99 latencies during peak times. A shared Redis instance can now be used by the entire router fleet to build the APQ cache faster and share existing APQ cache with new router instances that are spun up during scaling events – when they need it most. This ensures the fast path to query execution is consistently available to all users even during peak load. - -See the [distributed APQ caching documentation](https://www.apollographql.com/docs/router/configuration/distributed-caching) for information on setting up this GraphOS Enterprise feature. - -### GraphOS Enterprise: External Coprocessor support - -> 🎈 External Coprocessor support is now _generally available_ to GraphOS Enterprise customers running self-hosted routers. To fully account for the changes between the initial experimental releases and the final generally available implementation, we recommend removing the experimental configuration and re-implementing it following the documentation below to ensure proper configuration. - -Router now supports _external coprocessors_ written in your programming language of choice. Coprocessors run with full isolation and a clean separation of concerns, that decouples delivery and provides fault isolation. Low overhead can be achieved by running coprocessors alongside the router on the same host or in the same Kubernetes Pod as a sidecar. Coprocessors can be used to speed Gateway migrations, support bespoke use cases, or integrate the router with existing network services for custom auth (JWT mapping, claim enrichment), service discovery integration, and more! - -See the [external coprocessor documentation](https://www.apollographql.com/docs/router/configuration/external) for information on setting up this GraphOS Enterprise feature. - -### TLS termination ([Issue #2615](https://github.com/apollographql/router/issues/2615)) - -If there is no intermediary proxy or load-balancer present capable of doing it, the router ends up responsible for terminating TLS. This can be relevant in the case of needing to support HTTP/2, which requires TLS in most implementations. We've introduced TLS termination support for the router using the `rustls` implementation, limited to _one_ server certificate and using safe default ciphers. We do not support TLS versions prior to v1.2. - -If you require more advanced TLS termination than this implementation offers, we recommend using a proxy which supports this (as is the case with most cloud-based proxies today). - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2614 - -### Make `initialDelaySeconds` configurable for health check probes in Helm chart - -Currently `initialDelaySeconds` uses the default of `0`. This means that Kubernetes will give router _no additional time_ before it does the first probe. - -This can be configured as follows: - -```yaml -probes: - readiness: - initialDelaySeconds: 1 - liveness: - initialDelaySeconds: 5 -``` - -By [@Meemaw](https://github.com/meemaw) in https://github.com/apollographql/router/pull/2660 - -### GraphQL errors can be thrown within Rhai ([PR #2677](https://github.com/apollographql/router/pull/2677)) - -Up until now rhai script throws would yield an http status code and a message String which would end up as a GraphQL error. -This change allows users to throw with a valid GraphQL response body, which may include data, as well as errors and extensions. - -Refer to the `Terminating client requests` section of the [Rhai api documentation](https://www.apollographql.com/docs/router/configuration/rhai) to learn how to throw GraphQL payloads. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2677 - -## 🐛 Fixes - -### In-flight requests will terminate before shutdown is completed ([Issue #2539](https://github.com/apollographql/router/issues/2539)) - -In-flight client requests will now be completed when the router is asked to shutdown gracefully. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2610 - -### State machine will retain most recent valid config ([Issue #2752](https://github.com/apollographql/router/issues/2752)) - -The state machine will retain current state until new state has gone into service. Previously, if the router failed to reload either the configuration or the supergraph, it would discard the incoming state change even if that state change turned out to be invalid. It is important to avoid reloading inconsistent state because the a new supergraph may, for example, directly rely on changes in config to work correctly. - -Changing this behaviour means that the router must enter a "good" configuration state before it will reload, rather than reloading with potentially inconsistent state. - -For example, **previously**: - -1. Router starts with valid supergraph and config. -2. Router config is set to something invalid and restart doesn't happen. -3. Router receives a new schema, the router restarts with the new supergraph and the original valid config. - -**Now**, the latest information is used to restart the router: - -1. Router starts with valid schema and config. -2. Router config is set to something invalid and restart doesn't happen. -3. Router receives a new schema, but the router fails to restart because of config is still invalid. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/2753 - -### Ability to disable HTTP/2 for subgraphs ([Issue #2063](https://github.com/apollographql/router/issues/2063)) - -There are cases where the balancing HTTP/2 connections to subgraphs behaves erratically. While we consider this a bug, users may disable HTTP/2 support to subgraphs in the short-term while we work to find the root cause. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2621 - -### Tracing default service name restored ([Issue #2641](https://github.com/apollographql/router/issues/2641)) - -With this fix the default tracing service name is restored to `router`. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2642 - -### Header plugin now has a static plugin priority ([Issue #2559](https://github.com/apollographql/router/issues/2559)) - -Execution order of the `headers` plugin which handles header forwarding is now enforced. This ensures reliable behavior with other built-in plugins. - -It is now possible to use custom attributes derived from headers within the `telemetry` plugin in addition to using the `headers` plugin to propagate/insert headers for subgraphs. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2670 - -### Add `content-type` header when publishing Datadog metrics ([Issue #2697](https://github.com/apollographql/router/issues/2697)) - -Add the required `content-type` header for publishing Datadog metrics from Prometheus: - -``` -content-type: text/plain; version=0.0.4 -``` - -By [@ShaunPhillips](https://github.com/ShaunPhillips) in https://github.com/apollographql/router/pull/2698 - -### Sandbox Explorer endpoint URL is no longer editable ([PR #2729](https://github.com/apollographql/router/pull/2729)) - -The "Endpoint" in the Sandbox Explorer (Which is served by default when running in development mode) is no longer editable, to prevent inadvertent changes. Sandbox is not generally useful with other endpoints as CORS must be configured on the other host. - -A hosted version of Sandbox Explorer without this restriction [is still available](https://studio.apollographql.com/sandbox/explorer) if you necessitate a version which allows editing. - -By [@mayakoneval](https://github.com/mayakoneval) in https://github.com/apollographql/router/pull/2729 - -### Argument parsing is now optional in the `Executable` builder ([PR #2666](https://github.com/apollographql/router/pull/2666)) - -The `Executable` builder was parsing command-line arguments, which was causing issues when used as part of a larger application with its _own_ set of command-line flags, leading to those arguments not be recognized by the router. This change allows parsing the arguments _separately_, then passing the required ones to the `Executable` builder directly. The default behaviour is still parsing from inside the builder. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2666 - -### Unnecessary space has been removed from the log formatter ([PR #2755](https://github.com/apollographql/router/pull/2755)) - -Indentation was being introduced after the log-level annotations in router logs. We've removed the offending spaces! - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2755 - -### FTV1 trace sampling is now applied per _supergraph request_ rather than _subgraph request_ ([Issue #2655](https://github.com/apollographql/router/issues/2655)) - -Because tracing can be costly, it is only enabled for a configurable fraction of requests. Each request is selected for tracing or not with a corresponding probability. This used to be done as part of the _subgraph service_, meaning that when a single supergraph request handled by the Router involves making multiple subgraph requests, it would be possible (and likely) that tracing would only be enabled for some of those sub-requests. If this same supergraph request is repeated enough times the aggregated metrics should be fine, but for smaller sample size this risks giving an unexpectedly partial view of what’s happening. - -As of this change, each supergraph request received by the Router is either decided to be _sampled_ or _not sampled_ and all corresponding subgraph requests use that same decision. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/2656 - -### JWKS download failure no longer results in JWT plugin init failure ([Issue #2747](https://github.com/apollographql/router/issues/2747)) - -> This feature was previously experimental and is now _generally available_ as a GraphOS Enterprise feature. See the "Features" section above for more detail, and consult the [feature's documentation](https://www.apollographql.com/docs/router/configuration/authn-jwt) for more information. - -JWKS download can temporarily fail for the same reasons that any network request fails. Such an intermittent failure no longer fails plugin initialization, preventing router load or hot-reloads. We now continue try to download the failed asset during initialization making a reasonable effort to start router with all JWKS. In the event that one of the configured JWKS does not download, the router will still start with the remaining sets. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2754 - -### JWKS is now downloaded out of band ([Issue #2647](https://github.com/apollographql/router/issues/2647)) - -> This feature was previously experimental and is now _generally available_ as a GraphOS Enterprise feature. See the "Features" section above for more detail, and consult the [feature's documentation](https://www.apollographql.com/docs/router/configuration/authn-jwt) for more information. - -The JWKS download in the JWT authentication plugin now lives in a separate task which polls the JWKS URLs asynchronously, rather than downloading them on demand when a JWT is verified. This should reduce the latency for the initial requests received by the router and increase reliability by removing (internal) tower `Buffer` usage. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2648 - -### Add an issuer check after JWT signature verification ([Issue #2647](https://github.com/apollographql/router/issues/2647)) - -> This feature was previously experimental and is now _generally available_ as a GraphOS Enterprise feature. See the "Features" section above for more detail, and consult the [feature's documentation](https://www.apollographql.com/docs/router/configuration/authn-jwt) for more information. - -*This is a notable change if you're coming from the experimental implementation. Experimental features offer no breaking change policy while they are in experimental state.* - -A JWKS URL can now be associated with an issuer in the YAML configuration. After verifying the JWT signature, if the issuer **is** configured in YAML and there is an corresponding `iss` claim in the JWT, the router will check that they match, and reject the request if not. - -For those coming from experimental, the configuration changes incorporate a map of objects including `url` and an optional `issuer` property: - -```diff -< authentication: -< experimental: -< jwt: -< jwks_urls: -< - file:///path/to/jwks.json -< - http:///idp.dev/jwks.json ---- -> authentication: -> jwt: -> jwks: -> - url: file:///path/to/jwks.json -> issuer: "http://idp.local" # optional field -> - url: http:///idp.dev/jwks.json -> issuer: http://idp.dev # optional field -``` - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2672 - -## 📃 Configuration - -> :warning: Configuration changes are **non-breaking in the current minor version**, but we recommend making these changes as soon as possible since they will become breaking changes in a future major version. - -### `apq` has been moved to the top level of configuration ([Issue #2744](https://github.com/apollographql/router/issues/2744)) - -For improved usability, we will be moving items out of `supergraph` in the router configuration file. This is because various plugins use router pipeline stages as part of their YAML config, of which `supergraph` is one. - -You may not have this option in your configuration since APQ is on by default, but if you're using this option, the appropriate change will look like this: - -```diff -< supergraph: -< apq: -< enabled: true ---- -> apq: -> enabled: true -``` - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2745 - -## 🛠 Maintenance - -### Correct visibility of telemetry plugin ([Issue #2739](https://github.com/apollographql/router/issues/2739)) - -The telemetry plugin code _itself_ was previously marked `pub`. However, due to the recent refactor of the `telemetry` plugin and its associated tests this is no longer the case. This does not manifest as a breaking change since the plugin was exported under the `_private` module which itself was marked as internal. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/2740 - -### Jaeger integration tests have been improved in CircleCI ([Issue #2675](https://github.com/apollographql/router/issues/2675)) - -We now use a Jaeger Docker image rather than downloading the binaries directly, improving the overall reliability since the artifacts themselves were previously being pulled from GitHub artifacts and failed regularly. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2673 - -### Clean up `trace_providers` on a thread rather than in a Tokio `blocking_task` ([Issue #2668](https://github.com/apollographql/router/issues/2668)) - -OpenTelemetry shutdown occasionally hangs due to `Telemetry::Drop` using a `tokio::spawn_blocking` to flush the `trace_provider`. However, Tokio doesn't finish executing tasks before termination https://github.com/tokio-rs/tokio/issues/1156. - -This means that if the Tokio runtime itself is shutdown, there is a potential race where `trace_provider` may not be flushed. - -We can mitigate this by using a thread so that task flush will always be completed even if the Tokio runtime is shut down. Hangs were most likely to happen in tests due to the Tokio runtime being destroyed when the test method exits. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/2757 - -### Tweak the rate limit test times to prevent sporadic CI failures ([Issue #2667](https://github.com/apollographql/router/issues/2667)) - -A slight adjustment to the timing should make this less likely to cause flakes. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/2758 - -### Remove "dead" parsing code still using the `apollo-parser` AST ([Issue #2636](https://github.com/apollographql/router/issues/2636)) - -Now that `apollo-compiler` HIR has been used for long enough, the now-unused version of parsing code that was still based on `apollo-parser`'s AST has been removed. We had previously left this code intentionally to make it easy to roll-back to. - -This removal will unlock further refactoring in the upcoming https://github.com/apollographql/router/issues/2483. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/2637 - -### Use the `fred` Redis client ([Issue #2623](https://github.com/apollographql/router/issues/2623)) - -Use the `fred` Redis client instead of the `redis` and `redis-cluster-async` crates. Overall, this adds necessary support for TLS in Redis "cluster" mode, removes OpenSSL usage entirely (this was our only dependency which used OpenSSL, so this means that our router can install _without friction_ on the newest Ubuntu version again) and overall cleans up the code. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2689 - -### Update local development `docker-compose` configuration ([Issue #2680](https://github.com/apollographql/router/issues/2680)) - -The `federation-demo` was used for testing in early versions of the Router but is no longer used, and we removed most references to it some time ago. The `docker-compose.yml` (used primarily in the development of this repository) has been updated to reflect this, and now also includes Redis which is required for some tests. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/#2681 - -### Improve CI time by removing `test-binaries` from build ([Issue #2625](https://github.com/apollographql/router/issues/2625)) - -We now have an experimental plugin called `broken` that is included in the router. -This removes the need to use `test-binaries` and avoids a full recompile of the router during integration testing. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2650 - -### Ban `openssl-sys` using `cargo-deny` ([PR #2510](https://github.com/apollographql/router/pull/2638)) - -We avoid depending on OpenSSL in the router, instead opting to use `rustls` for various reasons. This change introduces a _tooling_ "ban" of the `openssl-sys` crate to avoid inadvertently introducing OpenSSL again in the future by signalling this early in our pull-requests. This will help us avoid mistakenly reintroducing it in the future. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2638 - -## 📚 Documentation - - -### `Context::get` has been corrected ([Issue #2580](https://github.com/apollographql/router/issues/2580)) - -If we have an error, it doesn't mean the context entry didn't exist, it generally means it's a deserialization error. We've updated the `Context::get` documentation to reflect this. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2669 - -### Remove "embedded" example ([Issue #2737](https://github.com/apollographql/router/issues/2737)) - -The "embedded" example in our documentation was a throwback to early days of the Router where "distribution as middleware" was considered more viable. As development has progressed, this approach has become obsolete, particularly as we have baked some of our functionality into the webserver layer. In addition, the entire example was still using the `TestHarness` which is designed for _testing_ rather than production traffic. Overall, we think the rest of our documentation properly represents modern days way of doing this work. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2738 - -# [1.11.0] - 2023-02-21 - -## 🚀 Features - -### Support for UUID and Unix timestamp functions in Rhai ([PR #2617](https://github.com/apollographql/router/pull/2617)) - -When building Rhai scripts, you'll often need to add headers that either uniquely identify a request, or append timestamp information for processing information later, such as crafting a trace header or otherwise. - -While the default `timestamp()` and similar functions (e.g. `apollo_start`) can be used, they aren't able to be translated into an epoch. - -This adds a `uuid_v4()` and `unix_now()` function to obtain a UUID and Unix timestamp, respectively. - -By [@lleadbet](https://github.com/lleadbet) in https://github.com/apollographql/router/pull/2617 - -### Show option to "Include Cookies" in Sandbox - -Adds default support when using the "Include Cookies" toggle in the Embedded Sandbox. - -By [@esilverm](https://github.com/esilverm) in https://github.com/apollographql/router/pull/2553 - -### Add a metric to track the cache size ([Issue #2522](https://github.com/apollographql/router/issues/2522)) - -We've introduced a new `apollo_router_cache_size` metric that reports the current size of in-memory caches. Like [other metrics](https://www.apollographql.com/docs/router/configuration/metrics), it is available via OpenTelemetry Metrics including Prometheus scraping. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2607 - -### Add a rhai global variable resolver and populate it ([Issue #2628](https://github.com/apollographql/router/issues/2628)) - -Rhai scripts cannot access Rust global constants by default, making cross plugin communication via `Context` difficult. - -This change introduces a new global [variable resolver](https://rhai.rs/book/engine/var.html) populates with a `Router` global constant. It currently has three members: - - - `APOLLO_START` -> should be used in place of `apollo_start` - - `APOLLO_SDL` -> should be used in place of `apollo_sdl` - - `APOLLO_AUTHENTICATION_JWT_CLAIMS` - -You access a member of this variable as follows: - -```rust -let my_var = Router.APOLLO_SDL; -``` - -We are removing the _experimental_ `APOLLO_AUTHENTICATION_JWT_CLAIMS` constant, but we will **retain the existing non-experimental constants** for purposes of backwards compatibility. - -We recommend that you shift to the new global constants since we will remove the old ones in a major breaking change release in the future. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2627 - -### Activate TLS for Redis cluster connections ([Issue #2332](https://github.com/apollographql/router/issues/2332)) - -This adds support for TLS connections in Redis Cluster mode, by applying it when the URLs use the `rediss` schema. - -By [@Geaal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2605 - -### Make `terminationGracePeriodSeconds` property configurable in the Helm chart - -The `terminationGracePeriodSeconds` property is now configurable on the `Deployment` object in the Helm chart. - -This can be useful when adjusting the default timeout values for the Router, and should always be a value slightly bigger than the Router timeout in order to ensure no requests are closed prematurely on shutdown. - -The Router timeout is configured via `traffic_shaping` - -```yaml -traffic_shaping: - router: - timeout: ... -``` - -By [@Meemaw](https://github.com/Meemaw) in https://github.com/apollographql/router/pull/2582 - -## 🐛 Fixes - -### Properly emit histograms metrics via OpenTelemetry ([Issue #2393](https://github.com/apollographql/router/issues/2493)) - -With the "inexpensive" metrics selector, histograms are only reported as gauges which caused them to be incorrectly interpreted when reaching Datadog - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2564 - -### Revisit Open Telemetry integration ([Issue #1812](https://github.com/apollographql/router/issues/1812), [Issue #2359](https://github.com/apollographql/router/issues/2359), [Issue #2338](https://github.com/apollographql/router/issues/2338), [Issue #2113](https://github.com/apollographql/router/issues/2113), [Issue #2113](https://github.com/apollographql/router/issues/2113)) - -There were several issues with the existing OpenTelemetry integration in the Router which we are happy to have resolved with this re-factoring: - -- Metrics would stop working after a schema or config update. -- Telemetry config could **not** be changed at runtime, instead requiring a full restart of the router. -- Logging format would vary depending on where the log statement existed in the code. -- On shutdown, the following message occurred frequently: - - ``` - OpenTelemetry trace error occurred: cannot send span to the batch span processor because the channel is closed - ``` - -- And worst of all, it had a tendency to leak memory. - -We have corrected these by re-visiting the way we integrate with OpenTelemetry and the supporting tracing packages. The new implementation brings our usage in line with new best-practices. - -In addition, the testing coverage for telemetry in general has been significantly improved. For more details of what changed and why take a look at https://github.com/apollographql/router/pull/2358. - -By [@bryncooke](https://github.com/bryncooke) and [@geal](https://github.com/geal) and [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2358 - -### Metrics attributes allow value types as defined by OpenTelemetry ([Issue #2510](https://github.com/apollographql/router/issues/2510)) - -Metrics attributes in OpenTelemetry allow the following types: - -* `string` -* `string[]` -* `float` -* `float[]` -* `int` -* `int[]` -* `bool` -* `bool[]` - -However, our configuration only allowed strings. This has been fixed, and therefore it is now possible to use booleans via environment variable expansion as metrics attributes. - -For example: -```yaml -telemetry: - metrics: - prometheus: - enabled: true - common: - attributes: - supergraph: - static: - - name: "my_boolean" - value: '' -``` - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2616 - -### Add missing `status` attribute on some metrics ([PR #2593](https://github.com/apollographql/router/pull/2593)) - -When labeling metrics, the Router did not consistently add the `status` attribute, resulting in an empty `status`. You'll now have `status="500"` for Router errors. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2593 - -## 🛠 Maintenance - -### Upgrade to Apollo Federation v2.3.2 - -This brings in a patch update to our Federation support, bringing it to v2.3.2. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/2586 - -### CORS: Give a more meaningful message for users who misconfigured `allow_any_origin` ([PR #2634](https://github.com/apollographql/router/pull/2634)) - -Allowing "any" origin in the router configuration can be done as follows: - -```yaml -cors: - allow_any_origin: true -``` - -However, some intuition and familiarity with the CORS specification might also lead someone to configure it as follows: - -```yaml -cors: - origins: - - "*" -``` - -Unfortunately, this won't work and the error message received when it was attempted was neither comprehensive nor actionable: - -``` -ERROR panicked at 'Wildcard origin (`*`) cannot be passed to `AllowOrigin::list`. Use `AllowOrigin::any()` instead' -``` - -This usability improvement adds helpful instructions to the error message, pointing you to the correct pattern for setting up this behavior in the router: - -``` -Invalid CORS configuration: use `allow_any_origin: true` to set `Access-Control-Allow-Origin: *` -``` - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2634 - -## 🧪 Experimental - -### Cleanup the error reporting in the experimental JWT authentication plugin ([PR #2609](https://github.com/apollographql/router/pull/2609)) - -Introduce a new `AuthenticationError` enum to document and consolidate various JWT processing errors that may occur. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2609 - -# [1.10.3] - 2023-02-10 - -## 🐛 Fixes - -### Per-type metrics based on FTV1 from subgraphs ([Issue #2551](https://github.com/apollographql/router/issues/2551)) - -[Since version 1.7.0](https://github.com/apollographql/router/blob/dev/CHANGELOG.md#traces-wont-cause-missing-field-stats-issue-2267), Apollo Router generates metrics directly instead of deriving them from traces being sent to Apollo Studio. However, these metrics were incomplete. This adds, based on data reported by subgraphs, the following: - -- Statistics about each field of each type of the GraphQL type system -- Statistics about errors at each path location of GraphQL responses - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/2541 - -## 🛠 Maintenance - -### Run `rustfmt` on `xtask/`, too ([Issue #2557](https://github.com/apollographql/router/issues/2557)) - -Our `xtask` runs `cargo fmt --all` which reformats of Rust code in all crates of the workspace. However, the code of xtask itself is a separate workspace. In order for it to be formatted with the same configuration, running a second `cargo` command is required. This adds that second command, and applies the corresponding formatting. - -Fixes https://github.com/apollographql/router/issues/2557 - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/2561 - -## 🧪 Experimental - -### Add support to JWT Authentication for JWK without specified `alg` - -Prior to this change, the router would only make use of a JWK for JWT verification if the key had an `alg` property. - -Now, the router searches through the set of configured JWKS (JSON Web Key Sets) to find the best matching JWK according to the following criteria: - - - a matching `kid` and `alg`; or - - a matching `kid` and _algorithm family_ (`kty`, per the [RFC 7517](https://www.rfc-editor.org/rfc/rfc7517); or - - a matching _algorithm family_ (`kty`) - -The algorithm family is used when the JWKS contain a JWK for which no `alg` is specified. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2540 - -# [1.10.2] - 2023-02-08 - -## 🐛 Fixes - -### Resolve incorrect nullification when using `@interfaceObject` with particular response objects ([PR #2530](https://github.com/apollographql/router/pull/2530)) - -> Note: This follows up on the v1.10.1 release which also attempted to fix this, but inadvertently excluded a required part of the fix due to an administrative oversight. - -The Federation 2.3.x `@interfaceObject` feature implies that an interface type in the supergraph may be locally handled as an object type by some specific subgraphs. Therefore, such subgraphs may return objects whose `__typename` is the interface type in their response. In some cases, those `__typename` were leading the Router to unexpectedly and incorrectly nullify the underlying objects. This was not caught in the initial integration of Federation 2.3. - -By [@pcmanus](https://github.com/pcmanus) in https://github.com/apollographql/router/pull/2530 - -## 🛠 Maintenance - -### Refactor Uplink implementation ([Issue #2547](https://github.com/apollographql/router/issues/2547)) - -The Apollo Uplink implementation within Apollo Router, which is used for fetching data _from_ Apollo GraphOS, has been decomposed into a reusable component so that it can be used more generically for fetching artifacts. This generally improved code quality and resulted in several new tests being added. - -Additionally, our round-robin fetching behaviour is now more durable. Previously, on failure, there would be a delay before trying the next round-robin URL. Now, all URLs will be tried in sequence until exhausted. If ultimately all URLs fail, then the usual delay is applied before trying again. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/2537 - -### Improve Changelog management through conventions and tooling ([PR #2545](https://github.com/apollographql/router/pull/2545), [PR #2534](https://github.com/apollographql/router/pull/2534)) - -New tooling and conventions adjust our "incoming changelog in the next release" mechanism to no longer rely on a single file, but instead leverage a "file per feature" pattern in conjunction with tooling to create that file. - -This stubbing takes place through the use of a new command: - - cargo xtask changeset create - -For more information on the process, read the [README in the `./.changesets` directory](https://github.com/apollographql/router/blob/HEAD/.changesets/README.md) or consult the referenced Pull Requests below. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/2545 and https://github.com/apollographql/router/pull/2534 - -# [1.10.1] - 2023-02-07 - -## 🐛 Fixes - -### Federation v2.3.1 ([Issue #2556](https://github.com/apollographql/router/issues/2556)) - -Update to [Federation v2.3.1](https://github.com/apollographql/federation/blob/main/query-planner-js/CHANGELOG.md#231) to fix subtle bug in `@interfaceObject`. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/2554 - -## 🛠 Maintenance - -### Redis integration tests ([Issue #2174](https://github.com/apollographql/router/issues/2174)) - -We now have integration tests for Redis usage with Automatic Persisted Queries and query planning. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2179 - -### CI: Enable compliance checks _except_ `licenses.html` update ([Issue #2514](https://github.com/apollographql/router/issues/2514)) - -In [#1573](https://github.com/apollographql/router/pull/1573), we removed the compliance checks for non-release CI pipelines, because `cargo-about` output would change ever so slightly on each run. - -While many of the checks provided by the compliance check are license related, some checks prevent us from inadvertently downgrading libraries and needing to open, e.g., [Issue #2512](https://github.com/apollographql/router/pull/2512). - -This set of changes includes the following: -- Introduce `cargo xtask licenses` to update licenses.html. -- Separate compliance (`cargo-deny`, which includes license checks) and licenses generation (`cargo-about`) in `xtask` -- Enable compliance as part of our CI checks for each open PR -- Update `cargo xtask all` so it runs tests, checks compliance and updates `licenses.html` -- Introduce `cargo xtask dev` so it checks compliance and runs tests - -Going forward, when developing on the Router source: - -- Use `cargo xtask all` to make sure everything is up to date before a release. -- Use `cargo xtask dev` before a PR. - -As a last note, updating `licenses.html` is now driven by `cargo xtask licenses`, which is part of the release checklist and automated through our release tooling in `xtask`. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2520 - -### Fix flaky tracing integration test ([Issue #2548](https://github.com/apollographql/router/issues/2548)) - -Disable federated-tracing (FTV1) in tests by lowering the sampling rate to zero so that consistent results are generated in test snapshots. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2549 - -### Update to Rust 1.67 - -We've updated the Minimum Supported Rust Version (MSRV) version to v1.67. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/2496 and https://github.com/apollographql/router/pull/2499 - -# [1.10.0] - 2023-02-01 - -## 🚀 Features - -### Update to Federation v2.3.0 ([Issue #2465](https://github.com/apollographql/router/issues/2465), [Issue #2485](https://github.com/apollographql/router/pull/2485) and [Issue #2489](https://github.com/apollographql/router/pull/2489)) - -This brings in Federation v2.3.0 execution support for: -- `@interfaceObject` (added to federation in [federation#2277](https://github.com/apollographql/federation/issues/2277)). -- the bug fix from [federation#2294](https://github.com/apollographql/federation/pull/2294). - -By [@abernix](https://github.com/abernix) and [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2462 -By [@pcmanus](https://github.com/pcmanus) in https://github.com/apollographql/router/pull/2485 and https://github.com/apollographql/router/pull/2489 - -### Always deduplicate variables on subgraph entity fetches ([Issue #2387](https://github.com/apollographql/router/issues/2387)) - -Variable deduplication allows the router to reduce the number of entities that are requested from subgraphs if some of them are redundant, and as such reduce the size of subgraph responses. It has been available for a while but was not active by default. This is now always on. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2445 - -### Add optional `Access-Control-Max-Age` header to CORS plugin ([Issue #2212](https://github.com/apollographql/router/issues/2212)) - -Adds new option called `max_age` to the existing `cors` object which will set the value returned in the [`Access-Control-Max-Age`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age) header. As was the case previously, when this value is not set **no** value is returned. - -It can be enabled using our standard time notation, as follows: - -``` -cors: - max_age: 1day -``` - -By [@osamra-rbi](https://github.com/osamra-rbi) in https://github.com/apollographql/router/pull/2331 - -### Improved support for wildcards in `supergraph.path` configuration ([Issue #2406](https://github.com/apollographql/router/issues/2406)) - -You can now use a wildcard in supergraph endpoint `path` like this: - -```yaml -supergraph: - listen: 0.0.0.0:4000 - path: /graph* -``` - -In this example, the Router would respond to requests on both `/graphql` and `/graphiql`. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2410 - - -## 🐛 Fixes - -### Forbid caching `PERSISTED_QUERY_NOT_FOUND` responses ([Issue #2502](https://github.com/apollographql/router/issues/2502)) - -The router now sends a `cache-control: private, no-cache, must-revalidate` response header to clients, in addition to the existing `PERSISTED_QUERY_NOT_FOUND` error code on the response which was being sent previously. This expanded behaviour occurs when a persisted query hash could not be found and is important since such responses should **not** be cached by intermediary proxies/CDNs since the client will need to be able to send the full query directly to the Router on a subsequent request. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2503 -### Listen on root URL when `/*` is set in `supergraph.path` configuration ([Issue #2471](https://github.com/apollographql/router/issues/2471)) - -This resolves a regression which occurred in Router 1.8 when using wildcard notation on a path-boundary, as such: - -```yaml -supergraph: - path: /* -``` - -This occurred due to an underlying [Axum upgrade](https://github.com/tokio-rs/axum/releases/tag/axum-v0.6.0) and resulted in failure to listen on `localhost` when a path was absent. We now special case `/*` to also listen to the URL without a path so you're able to call `http://localhost` (for example). - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2472 - -### Subgraph traffic shaping timeouts now return HTTP 504 status code ([Issue #2360](https://github.com/apollographql/router/issues/2360) [Issue #2400](https://github.com/apollographql/router/issues/240)) - -There was a regression where timeouts resulted in a HTTP response of `500 Internal Server Error`. This is now fixed with a test to guarantee it, the status code is now `504 Gateway Timeout` (instead of the previous `408 Request Timeout` which, was also incorrect in that it blamed the client). - -There is also a new metric emitted called `apollo_router_timeout` to track when timeouts are triggered. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2419 - -### Fix panic in schema parse error reporting ([Issue #2269](https://github.com/apollographql/router/issues/2269)) - -In order to support introspection, some definitions like `type __Field { … }` are implicitly added to schemas. This addition was done by string concatenation at the source level. In some cases, like unclosed braces, a parse error could be reported at a position beyond the size of the original source. This would cause a panic because only the unconcatenated string is sent to the error reporting library `miette`. - -Instead, the Router now parses introspection types separately and "concatenates" the definitions at the AST level. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/2448 - -### Always accept compressed subgraph responses ([Issue #2415](https://github.com/apollographql/router/issues/2415)) - -Previously, subgraph response decompression was only supported when subgraph request compression was _explicitly_ configured. This is now always active. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2450 - -### Fix handling of root query operations not named `Query` - -If you'd mapped your default `Query` type to something other than the default using `schema { query: OtherQuery }`, some parsing code in the Router would incorrectly return an error because it had previously assumed the default name of `Query`. The same case would have occurred if the root mutation type was not named `Mutation`. - -This is now corrected and the Router understands the mapping. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/2459 - -### Remove the `locations` field from subgraph errors ([Issue #2297](https://github.com/apollographql/router/issues/2297)) - -Subgraph errors can come with a `locations` field indicating which part of the query was causing issues, but it refers to the subgraph query generated by the query planner, and we have no way of translating it to locations in the client query. To avoid confusion, we've removed this field from the response until we can provide a more coherent way to map these errors back to the original operation. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2442 - -### Emit metrics showing number of client connections ([issue #2384](https://github.com/apollographql/router/issues/2384)) - -New metrics are available to track the client connections: - -- `apollo_router_session_count_total` indicates the number of currently connected clients -- `apollo_router_session_count_active` indicates the number of in flight GraphQL requests from connected clients. - -This also fixes the behaviour when we reach the maximum number of file descriptors: instead of going into a busy loop, the router will wait a bit before accepting a new connection. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2395 - -### `--dev` will no longer modify configuration that it does not directly touch ([Issue #2404](https://github.com/apollographql/router/issues/2404), [Issue #2481](https://github.com/apollographql/router/issues/2481)) - -Previously, the Router's `--dev` mode was operating against the configuration object model. This meant that it would sometimes replace pieces of configuration where it should have merely modified it. Now, `--dev` mode will _override_ the following properties in the YAML config, but it will leave any adjacent configuration as it was: - -```yaml -homepage: - enabled: false -include_subgraph_errors: - all: true -plugins: - experimental.expose_query_plan: true -sandbox: - enabled: true -supergraph: - introspection: true -telemetry: - tracing: - experimental_response_trace_id: - enabled: true -``` - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2489 - -## 🛠 Maintenance - -### Improve `#[serde(default)]` attribute on structs ([Issue #2424](https://github.com/apollographql/router/issues/2424)) - -If all the fields of your `struct` have their default value then use the `#[serde(default)]` on the `struct` instead of on each field. If you have specific default values for a field, you'll have to create your own `impl Default` for the `struct`. - -#### Correct approach - -```rust -#[serde(deny_unknown_fields, default)] -struct Export { - url: Url, - enabled: bool -} - -impl Default for Export { - fn default() -> Self { - Self { - url: default_url_fn(), - enabled: false - } - } -} -``` - -#### Discouraged approach - -```rust -#[serde(deny_unknown_fields)] -struct Export { - #[serde(default="default_url_fn") - url: Url, - #[serde(default)] - enabled: bool -} -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2424 - -## 📃 Configuration - -Configuration changes will be [automatically migrated on load](https://www.apollographql.com/docs/router/configuration/overview#upgrading-your-router-configuration). However, you should update your source configuration files as these will become breaking changes in a future major release. - -### `health-check` has been renamed to `health_check` ([Issue #2161](https://github.com/apollographql/router/issues/2161)) - -The `health_check` option in the configuration has been renamed to use `snake_case` rather than `kebab-case` for consistency with the other properties in the configuration: - -```diff --health-check: -+health_check: - enabled: true -``` - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2451 and https://github.com/apollographql/router/pull/2463 - -## 📚 Documentation - -### Disabling anonymous usage metrics ([Issue #2478](https://github.com/apollographql/router/issues/2478)) - -To disable the anonymous usage metrics, you set `APOLLO_TELEMETRY_DISABLED=true` in the environment. The documentation previously said to use `1` as the value instead of `true`. In the future, either will work, so this is primarily a bandaid for the immediate error. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2479 - -### `send_headers` and `send_variable_values` in `telemetry.apollo` ([Issue #2149](https://github.com/apollographql/router/issues/2149)) - -+ `send_headers` - - Provide this field to configure which request header names and values are included in trace data that's sent to Apollo Studio. Valid options are: `only` with an array, `except` with an array, `none`, `all`. - - The default value is `none``, which means no header names or values are sent to Studio. This is a security measure to prevent sensitive data from potentially reaching the Router. - -+ `send_variable_values` - - Provide this field to configure which variable values are included in trace data that's sent to Apollo Studio. Valid options are: `only` with an array, `except` with an array, `none`, `all`. - - The default value is `none`, which means no variable values are sent to Studio. This is a security measure to prevent sensitive data from potentially reaching the Router. - - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2435 - -### Propagating headers between subgraphs ([Issue #2128](https://github.com/apollographql/router/issues/2128)) - -Passing headers between subgraph services is possible via Rhai script and we've added an example to the [header propagation](https://www.apollographql.com/docs/router/configuration/header-propagation) documentation. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2446 - -### Propagating response headers to clients ([Issue #1284](https://github.com/apollographql/router/issues/1284)) - -Passing headers from subgraph services to clients is possible via Rhai script and we've added an example to the [header propagation](https://www.apollographql.com/docs/router/configuration/header-propagation) documentation. - -By [@lennyburdette](https://github.com/lennyburdette) in https://github.com/apollographql/router/pull/2474 - -### IPv6 listening instructions ([Issue #1835](https://github.com/apollographql/router/issues/1835)) - -Added instructions for how to represent IPv6 listening addresses to our [Overview](https://www.apollographql.com/docs/router/configuration/overview) documentation. - -```yaml -supergraph: - # The socket address and port to listen on. - # Note that this must be quoted to avoid interpretation as a yaml array. - listen: '[::1]:4000' -``` - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2440 - -## 🛠 Maintenance - -### Parse schemas and queries with `apollo-compiler` - -The Router now uses the higher-level representation (HIR) from `apollo-compiler` instead of using the AST from `apollo-parser` directly. This is a first step towards replacing a bunch of code that grew organically during the Router's early days, with a general-purpose library with intentional design. Internal data structures are unchanged for now. Parsing behavior has been tested to be identical on a large corpus of schemas and queries. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/2466 - -### Disregard value of `APOLLO_TELEMETRY_DISABLED` in Orbiter unit tests ([Issue #2487](https://github.com/apollographql/router/issues/2487)) - -The `orbiter::test::test_visit_args` tests were failing in the event that `APOLLO_TELEMETRY_DISABLED` was set, however this is now corrected. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2488 - -## 🥼 Experimental - -### JWT authentication ([Issue #912](https://github.com/apollographql/router/issues/912)) - -As a result of UX feedback, we are modifying the experimental JWT configuration. The `jwks_url` parameter is renamed to `jwks_urls` and now expects to receive an array of URLs, rather than a single URL. - -We've updated the [JWT Authentication documentation](apollographql.com/docs/router/configuration/authn-jwt) accordingly, however here's a short sample configuration example: - -```yaml -authentication: - experimental: - jwt: - jwks_urls: - - https://dev-abcd1234.us.auth0.com/.well-known/jwks.json -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2500 - - - -# [1.9.0] - 2023-01-20 - -## 🚀 Features - -### Add support for `base64::encode()` / `base64::decode()` in Rhai ([Issue #2025](https://github.com/apollographql/router/issues/2025)) - -Two new functions, `base64::encode()` and `base64::decode()`, have been added to the capabilities available within Rhai scripts to Base64-encode or Base64-decode strings, respectively. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2394 - -### Override the root TLS certificate list for subgraph requests ([Issue #1503](https://github.com/apollographql/router/issues/1503)) - -In some cases, users need to use self-signed certificates or use a custom certificate authority (CA) when communicating with subgraphs. - -It is now possible to consigure these certificate-related details using configuration for either specific subgraphs or all subgraphs, as follows: - -```yaml -tls: - subgraph: - all: - certificate_authorities: "${file./path/to/ca.crt}" - # Use a separate certificate for the `products` subgraph. - subgraphs: - products: - certificate_authorities: "${file./path/to/product_ca.crt}" -``` - -The file referenced in the `certificate_authorities` value is expected to be the combination of several PEM certificates, concatenated together into a single file (as is commonplace with Apache TLS configuration). - -These certificates are only configurable via the Router's configuration since using `SSL_CERT_FILE` would also override certificates for sending telemetry and communicating with Apollo Uplink. - -While we do not currently support terminating TLS at the Router (from clients), the `tls` is located at the root of the configuration file to allow all TLS-related configuration to be semantically grouped together in the future. - -Note: If you are attempting to use a self-signed certificate, it must be generated with the proper file extension and with `basicConstraints` disabled. For example, a `v3.ext` extension file: - -``` -subjectKeyIdentifier = hash -authorityKeyIdentifier = keyid:always,issuer:always -# this has to be disabled -# basicConstraints = CA:TRUE -keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment, keyAgreement, keyCertSign -subjectAltName = DNS:local.apollo.dev -issuerAltName = issuer:copy -``` - -Using this `v3.ext` file, the certificate can be generated with the appropriate certificate signing request (CSR) - in this example, `server.csr` - using the following `openssl` command: - -``` -openssl x509 -req -in server.csr -signkey server.key -out server.crt -extfile v3.ext -``` - -This will produce the file as `server.crt` which can be passed as `certificate_authorities`. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2008 - -### Measure the Router's processing time ([Issue #1949](https://github.com/apollographql/router/issues/1949) [Issue #2057](https://github.com/apollographql/router/issues/2057)) - -The Router now emits a metric called `apollo_router_processing_time` which measures the time spent executing the request **minus** the time spent waiting for an external requests (e.g., subgraph request/response or external plugin request/response). This measurement accounts both for the time spent actually executing the request as well as the time spent waiting for concurrent client requests to be executed. The unit of measurement for the metric is in seconds, as with other time-related metrics the router produces, though this is not meant to indicate in any way that the Router is going to add actual seconds of overhead. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2371 - -### Automated persisted queries support for subgraph requests ([PR #2284](https://github.com/apollographql/router/pull/2284)) - -Automatic persisted queries (APQ) (See useful context [in our Apollo Server docs](https://www.apollographql.com/docs/apollo-server/performance/apq/)) can now be used for subgraph requests. It is disabled by default, and can be configured for all subgraphs or per subgraph: - -```yaml title="router.yaml" -supergraph: - apq: - subgraph: - # override for all subgraphs - all: - enabled: false - # override per subgraph - subgraphs: - products: - enabled: true -``` - -By [@krishna15898](https://github.com/krishna15898) and [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2284 and https://github.com/apollographql/router/pull/2418 - -### Allow the disabling of automated persisted queries ([PR #2386](https://github.com/apollographql/router/pull/2386)) - -Automatic persisted queries (APQ) support is still enabled by default on the client side, but can now be disabled in the configuration: - -```yaml -supergraph: - apq: - enabled: false -``` - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2386 - -### Anonymous product usage analytics ([Issue #2124](https://github.com/apollographql/router/issues/2124), [Issue #2397](https://github.com/apollographql/router/issues/2397), [Issue #2412](https://github.com/apollographql/router/issues/2412)) - -Following up on https://github.com/apollographql/router/pull/1630, the Router transmits anonymous usage telemetry about configurable feature usage which helps guide Router product development. No information is transmitted in our usage collection that includes any request-specific information. Knowing what features and configuration our users are depending on allows us to evaluate opportunities to reduce complexity and remain diligent about the surface area of the Router over time. The privacy of your and your user's data is of critical importance to the core Router team and we handle it with great care in accordance with our [privacy policy](https://www.apollographql.com/docs/router/privacy/), which clearly states which data we collect and transmit and offers information on how to opt-out. - -Booleans and numeric values are included, however, any strings are represented as `` to avoid leaking confidential or sensitive information. - -For example: -```json5 -{ - "session_id": "fbe09da3-ebdb-4863-8086-feb97464b8d7", // Randomly generated at Router startup. - "version": "1.4.0", // The version of the router - "os": "linux", - "ci": null, // If CI is detected then this will name the CI vendor - "usage": { - "configuration.headers.all.request.propagate.named.": 3, - "configuration.headers.all.request.propagate.default.": 1, - "configuration.headers.all.request.len": 3, - "configuration.headers.subgraphs..request.propagate.named.": 2, - "configuration.headers.subgraphs..request.len": 2, - "configuration.headers.subgraphs.len": 1, - "configuration.homepage.enabled.true": 1, - "args.config-path.redacted": 1, - "args.hot-reload.true": 1, - //Many more keys. This is dynamic and will change over time. - //More... - //More... - //More... - } - } -``` - -Users can disable this mechanism by setting the environment variable `APOLLO_TELEMETRY_DISABLED=true` in their environment. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2173, https://github.com/apollographql/router/issues/2398, https://github.com/apollographql/router/pull/2413 - -## 🐛 Fixes - -### Don't send header names to Studio if `send_headers` is `none` ([Issue #2403](https://github.com/apollographql/router/issues/2403)) - -We no longer transmit header **names** to Apollo Studio when `send_headers` is set to `none` (the default). Previously, when `send_headers` was set to `none` (like in the following example) the header names were still transmitted with _empty_ header values. No actual values were ever being sent unless `send_headers` was sent to a more permissive option like `forward_headers_only` or `forward_headers_except`. - -```yaml -telemetry: - apollo: - send_headers: none -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2425 - - -### Response with `Content-type: application/json` when encountering incompatible `Content-type` or `Accept` request headers ([Issue #2334](https://github.com/apollographql/router/issues/2334)) - -When receiving requests with `content-type` and `accept` header mismatches (e.g., on multipart requests) the Router now utilizes a correct `content-type` header in its response. - -By [@Meemaw](https://github.com/Meemaw) in https://github.com/apollographql/router/pull/2370 - -### Fix `APOLLO_USAGE_REPORTING_INGRESS_URL` behavior when Router was run without a configuration file - -The environment variable `APOLLO_USAGE_REPORTING_INGRESS_URL` (not usually necessary under typical operation) was **not** being applied correctly when the Router was run without a configuration file. -In addition, defaulting of environment variables now directly injects the variable rather than injecting via expansion expression. This means that the use of `APOLLO_ROUTER_CONFIG_ENV_PREFIX` (even less common) doesn't affect injected configuration defaults. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2432 - -## 🛠 Maintenance - -### Remove unused factory traits ([PR #2372](https://github.com/apollographql/router/pull/2372)) - -We removed a factory trait that was only used in a single implementation, which removes the overall requirement that execution and subgraph building take place via that factory trait. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2372 - -### Optimize header propagation plugin's regular expression matching ([PR #2392](https://github.com/apollographql/router/pull/2392)) - -We've changed the header propagation plugins' behavior to reduce the chance of memory allocations occurring when applying regex-based header propagation rules. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2392 - -## 📚 Documentation - -### Creating custom metrics in plugins ([Issue #2294](https://github.com/apollographql/router/issues/2294)) - -To create your custom metrics in [Prometheus](https://prometheus.io/) you can use the [`tracing` macros](https://docs.rs/tracing/latest/tracing/index.html#macros) to generate an event. If you observe a specific naming pattern for your event, you'll be able to generate your own custom metrics directly in Prometheus. - -To publish a new metric, use tracing macros to generate an event that contains one of the following prefixes: - -`monotonic_counter.` _(non-negative numbers)_: Used when the metric will only ever increase. -`counter.`: For when the metric may increase or decrease over time. -`value.`: For discrete data points (i.e., when taking the sum of values does not make semantic sense) -`histogram.`: For building histograms (takes `f64`) - -This information is also available in [the Apollo Router documentation](https://www.apollographql.com/docs/router/customizations/native#add-custom-metrics). - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2417 - -## 🥼 Experimental - -### JWT authentication ([Issue #912](https://github.com/apollographql/router/issues/912)) - -Experimental JWT authentication is now configurable. Here's a typical sample configuration fragment: - -```yaml -authentication: - experimental: - jwt: - jwks_url: https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json -``` - -Until the documentation is published, you can [read more about configuring it](https://github.com/apollographql/router/blob/dev/docs/source/configuration/authn-jwt.mdx) in our GitHub repository source. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2348 - -### Redis cache for APQ and query planning ([PR #2431](https://github.com/apollographql/router/pull/2431)) - -Experimental caching was [already available for APQ and query planning](https://github.com/apollographql/router/blob/dev/CHANGELOG.md#experimental--apq-and-query-planner-redis-caching-fixes-pr-2176) but required a custom router build with the `experimental_cache` Cargo feature. That feature is now removed to make that cache easier to test. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2431 - -# [1.8.0] - 2023-01-11 - -## 📃 Configuration - -Configuration changes will be [automatically migrated on load](https://www.apollographql.com/docs/router/configuration/overview#upgrading-your-router-configuration). However, you should update your source configuration files as these will become breaking changes in a future major release. - -### Defer support graduates from preview ([Issue #2368](https://github.com/apollographql/router/issues/2368)) - -We're pleased to announce that [`@defer` support](https://www.apollographql.com/docs/router/executing-operations/defer-support/) has been promoted to general availability in accordance with our [product launch stages](https://www.apollographql.com/docs/resources/product-launch-stages/). - -Defer is enabled by default in the Router, however if you had previously explicitly *disabled* defer support via configuration then you will need to update your configuration accordingly: - -#### Before: - -```yaml -supergraph: - preview_defer_support: true -``` - -#### After: - -```yaml -supergraph: - defer_support: true -``` - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2378 - -### Remove `timeout` from OTLP exporter ([Issue #2337](https://github.com/apollographql/router/issues/2337)) - -A duplicative `timeout` property has been removed from the `telemetry.tracing.otlp` object since the `batch_processor` configuration already contained a `timeout` property. The Router will tolerate both options for now and this will be a breaking change in a future major release. Please update your configuration accordingly to reduce future work. - -Before: -```yaml -telemetry: - tracing: - otlp: - timeout: 5s -``` -After: -```yaml -telemetry: - tracing: - otlp: - batch_processor: - timeout: 5s -``` - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2338 - -## 🚀 Features - -### The Helm chart has graduated from prerelease to general availability ([PR #2380](https://github.com/apollographql/router/pull/2380)) - -As part of this release, we have promoted the Helm chart from its prerelease "release-candidate" stage to a "stable" version number. We have chosen to match the version of the Helm chart to the Router version, which is very agreeable with our automated Router releasing pipeline. This means the first stable version of the Helm chart will be `1.8.0` which will pair with Router 1.8.0 and subsequent versions will be in lock-step. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/2380 - -### Emit hit/miss metrics for APQ, Query Planning and Introspection caches ([Issue #1985](https://github.com/apollographql/router/issues/1985)) - -Added metrics for caching. -Each cache metric contains a `kind` attribute to indicate the kind of cache (`query planner`, `apq`, `introspection`) -and a `storage` attribute to indicate the backing storage e.g memory/disk. - -The following buckets are exposed: -`apollo_router_cache_hit_count` - cache hits. - -`apollo_router_cache_miss_count` - cache misses. - -`apollo_router_cache_hit_time` - cache hit duration. - -`apollo_router_cache_miss_time` - cache miss duration. - -Example -``` -# TYPE apollo_router_cache_hit_count counter -apollo_router_cache_hit_count{kind="query planner",new_test="my_version",service_name="apollo-router",storage="memory"} 2 -# TYPE apollo_router_cache_hit_time histogram -apollo_router_cache_hit_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.001"} 2 -apollo_router_cache_hit_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.005"} 2 -apollo_router_cache_hit_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.015"} 2 -apollo_router_cache_hit_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.05"} 2 -apollo_router_cache_hit_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.1"} 2 -apollo_router_cache_hit_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.2"} 2 -apollo_router_cache_hit_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.3"} 2 -apollo_router_cache_hit_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.4"} 2 -apollo_router_cache_hit_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.5"} 2 -apollo_router_cache_hit_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="1"} 2 -apollo_router_cache_hit_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="5"} 2 -apollo_router_cache_hit_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="10"} 2 -apollo_router_cache_hit_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="+Inf"} 2 -apollo_router_cache_hit_time_sum{kind="query planner",service_name="apollo-router",storage="memory"} 0.000236782 -apollo_router_cache_hit_time_count{kind="query planner",service_name="apollo-router",storage="memory"} 2 -# HELP apollo_router_cache_miss_count apollo_router_cache_miss_count -# TYPE apollo_router_cache_miss_count counter -apollo_router_cache_miss_count{kind="query planner",service_name="apollo-router",storage="memory"} 1 -# HELP apollo_router_cache_miss_time apollo_router_cache_miss_time -# TYPE apollo_router_cache_miss_time histogram -apollo_router_cache_miss_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.001"} 1 -apollo_router_cache_miss_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.005"} 1 -apollo_router_cache_miss_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.015"} 1 -apollo_router_cache_miss_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.05"} 1 -apollo_router_cache_miss_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.1"} 1 -apollo_router_cache_miss_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.2"} 1 -apollo_router_cache_miss_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.3"} 1 -apollo_router_cache_miss_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.4"} 1 -apollo_router_cache_miss_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="0.5"} 1 -apollo_router_cache_miss_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="1"} 1 -apollo_router_cache_miss_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="5"} 1 -apollo_router_cache_miss_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="10"} 1 -apollo_router_cache_miss_time_bucket{kind="query planner",service_name="apollo-router",storage="memory",le="+Inf"} 1 -apollo_router_cache_miss_time_sum{kind="query planner",service_name="apollo-router",storage="memory"} 0.000186783 -apollo_router_cache_miss_time_count{kind="query planner",service_name="apollo-router",storage="memory"} 1 -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2327 - -### Add support for single instance Redis ([Issue #2300](https://github.com/apollographql/router/issues/2300)) - -Experimental caching via Redis now works with single Redis instances when configured with a single URL. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2310 - -### Support TLS connections to single instance Redis ([Issue #2332](https://github.com/apollographql/router/issues/2332)) - -TLS connections are now supported when connecting to single Redis instances. It is useful for connecting to hosted Redis providers where TLS is mandatory. -TLS connections for clusters are not supported yet, see [Issue #2332](https://github.com/apollographql/router/issues/2332) for updates. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2336 - -## 🐛 Fixes - -### Correctly handle aliased `__typename` fields ([Issue #2330](https://github.com/apollographql/router/issues/2330)) - -If you aliased a `__typename` like in this example query: - -```graphql -{ - myproducts: products { - total - __typename - } - _0___typename: __typename -} -``` - -Before this fix, `_0___typename` was set to `null`. Thanks to this fix it now properly returns `Query`. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2357 - -### `subgraph_request` span is now set as the parent of traces coming from subgraphs ([Issue #2344](https://github.com/apollographql/router/issues/2344)) - -Before this fix, the context injected in headers to subgraphs was wrong and not attached to the correct parent span id, causing it to appear disconnected when rendering the trace tree. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2345 - -## 🛠 Maintenance - -### Simplify telemetry config code ([Issue #2337](https://github.com/apollographql/router/issues/2337)) - -This brings the telemetry plugin configuration closer to standards recommended in the [YAML design guidance](dev-docs/yaml-design-guidance.md). - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2338 - -### Upgrade the `clap` version in scaffold templates ([Issue #2165](https://github.com/apollographql/router/issues/2165)) - -Upgrade `clap` dependency version to a version supporting the generation of scaffolded plugins via xtask. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2343 - -### Upgrade axum to `0.6.1` ([PR #2303](https://github.com/apollographql/router/pull/2303)) - -For more details about the new `axum` release, please read [the project's change log](https://github.com/tokio-rs/axum/releases/tag/axum-v0.6.0) - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2303 - -### Set the HTTP response `content-type` as `application/json` when returning GraphQL errors ([Issue #2320](https://github.com/apollographql/router/issues/2320)) - -When throwing a `INVALID_GRAPHQL_REQUEST` error, it now specifies the expected `content-type` header rather than omitting the header as it was previously. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2321 - -### Move `APQ` and `EnsureQueryPresence` layers to the new `router_service` ([PR #2296](https://github.com/apollographql/router/pull/2296)) - -Moving APQ from the axum level to the `supergraph_service` reintroduced a `Buffer` to the service pipeline. -To avoid this, now the `APQ` and `EnsureQueryPresence` layers are part of the newly introduced `router_service`, removing that `Buffer`. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2296 - -### Refactor YAML validation error reports ([Issue #2180](https://github.com/apollographql/router/issues/2180)) - -YAML configuration file validation prints a report of the errors it encountered, but that report was missing some details and occasionally had its diagnostics cursor pointing at the wrong character/line. It now points at the correct place more reliably. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2347 - -# [1.7.0] - 2022-12-22 - -## 🚀 Features - -### Newly scaffolded projects now include a `Dockerfile` ([Issue #2295](https://github.com/apollographql/router/issues/2295)) - -Custom Router binary projects created using our [scaffolding tooling](https://www.apollographql.com/docs/router/customizations/custom-binary/) will now have a `Dockerfile` emitted to facilitate building custom Docker containers. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2307 - -### Apollo Uplink communication timeout is configurable ([PR #2271](https://github.com/apollographql/router/pull/2271)) - -The amount of time which can elapse before timing out when communicating with Apollo Uplink is now configurable via the `APOLLO_UPLINK_TIMEOUT` environment variable and the `--apollo-uplink-timeout` CLI flag, in a similar fashion to how the interval can be configured. It still defaults to 30 seconds. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2271 - -### Query plan cache is pre-warmed using existing operations when the supergraph changes ([Issue #2302](https://github.com/apollographql/router/issues/2302), [Issue #2308](https://github.com/apollographql/router/issues/2308)) - -A new `warmed_up_queries` configuration option has been introduced to pre-warm the query plan cache when the supergraph changes. - -Under normal operation, query plans are cached to avoid the recomputation cost. However, when the supergraph changes, previously-planned queries must be re-planned to account for implementation changes in the supergraph, even though the query itself may not have changed. Under load, this re-planning can cause performance variations due to the extra computation work. To reduce the impact, it is now possible to pre-warm the query plan cache for the incoming supergraph, prior to changing over to the new supergraph. Pre-warming slightly delays the roll-over to the incoming supergraph, but allows the most-requested operations to not be impacted by the additional computation work. - -To enable pre-warming, the following configuration can be introduced which sets `warmed_up_queries`: - -```yaml -supergraph: - query_planning: - # Pre-plan the 100 most used operations when the supergraph changes. (Default is "0", disabled.) - warmed_up_queries: 100 - experimental_cache: - in_memory: - # Sets the limit of entries in the query plan cache - limit: 512 -``` - -Query planning was also updated to finish executing and setting up the cache, even if the response couldn't be returned to the client which is important to avoid throwing away computationally-expensive work. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2309 - -## 🐛 Fixes - -### Propagate errors across inline fragments ([PR #2304](https://github.com/apollographql/router/pull/2304)) - -GraphQL errors are now correctly propagated across inline fragments. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2304 - -### Only rebuild `protos` if `reports.proto` source changes - -Apollo Studio accepts traces and metrics from Apollo Router via the Protobuf specification which lives in the `reports.proto` file in the repository. With this contribution, we only re-build from the `reports.proto` file when the file has actually changed, as opposed to doing it on every build which was occurring previously. This change saves build time for developers. - -By [@scottdouglas1989](https://github.com/scottdouglas1989) in https://github.com/apollographql/router/pull/2283 - -### Return an error on duplicate keys in configuration ([Issue #1428](https://github.com/apollographql/router/issues/1428)) - -Repeat usage of the same keys in Router YAML can be hard to notice but indicate a misconfiguration which can cause unexpected behavior since only one of the values can be in effect. With this improvement, the following YAML configuration will raise an error at Router startup to alert the user of the misconfiguration: - -```yaml -telemetry: - tracing: - propagation: - jaeger: true - tracing: - propagation: - jaeger: false -``` - -In this particular example, the error produced would be: - -``` -ERROR duplicated keys detected in your yaml configuration: 'telemetry.tracing' -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2270 - -### Return requested `__typename` in initial chunk of a deferred response ([Issue #1922](https://github.com/apollographql/router/issues/1922)) - -The special-case `__typename` field is no longer being treated incorrectly when requested at the root level on an operation which used `@defer`. For example, the following query: - -```graphql -{ - __typename - ...deferedFragment @defer -} - -fragment deferedFragment on Query { - slow -} -``` - -The Router now exhibits the correct behavior for this query with `__typename` being returned as soon as possible in the initial chunk, as follows: - -```json -{"data":{"__typename": "Query"},"hasNext":true} -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2274 - -### Log retriable Apollo Uplink failures at the `debug` level ([Issue #2004](https://github.com/apollographql/router/issues/2004)) - -The log levels for messages pertaining to Apollo Uplink schema fetch failures are now emitted at `debug` level to reduce noise since such failures do not indicate an actual error since they can be and are retried immediately. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2215 - -### Traces won't cause missing field-stats ([Issue #2267](https://github.com/apollographql/router/issues/2267)) - -Metrics are now correctly measured comprehensively and traces will obey the trace sampling configuration. Previously, if a request was sampled out of tracing it would not always contribute to metrics correctly. This was particularly problematic for users which had configured high sampling rates for their traces. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2277 and https://github.com/apollographql/router/pull/2286 - -### Replace default `notify` watcher mechanism with `PollWatcher` ([Issue #2245](https://github.com/apollographql/router/issues/2245)) - -We have replaced the default mechanism used by our underlying file-system notification library, [`notify`](https://crates.io/crates/notify), to use [`PollWatcher`](https://docs.rs/notify/4.0.17/notify/poll/struct.PollWatcher.html). This more aggressive change has been taken on account of continued reports of failed hot-reloading and follows up our previous replacement of [`hotwatch`](https://crates.io/crates/hotwatch). We don't have very demanding file watching requirements, so while `PollWatcher` offers less sophisticated functionality and _slightly_ slower reactivity, it is at least consistent on all platforms and should provide the best developer experience. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2276 - -### Preserve subgraph error's `path` property when redacting subgraph errors ([Issue #1818](https://github.com/apollographql/router/issues/1818)) - -The `path` property in errors is now preserved. Previously, error redaction was removing the error's `path` property, which made debugging difficult but also made it impossible to correctly match errors from deferred responses to the appropriate fields in the requested operation. Since the response shape for the primary and deferred responses are defined from the client-facing "API schema", rather than the supergraph, this change will not result in leaking internal supergraph implementation details to clients and the result will be consistent, even if the subgraph which provides a particular field changes over time. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2273 - -### Use correct URL decoding for `variables` in HTTP `GET` requests ([Issue #2248](https://github.com/apollographql/router/issues/2248)) - -The correct URL decoding will now be applied when making a `GET` request that passes in the `variables` query string parameter. Previously, _all_ '+' characters were being replaced with spaces which broke cases where the `+` symbol was not merely an encoding symbol (e.g., ISO8601 date time values with timezone information). - -By [@neominik](https://github.com/neominik) in https://github.com/apollographql/router/pull/2249 - -## 🛠 Maintenance - -### Return additional details to client for invalid GraphQL requests ([Issue #2301](https://github.com/apollographql/router/issues/2301)) - -Additional context will be returned to clients in the error indicating the source of the error when an invalid GraphQL request is made. For example, passing a string instead of an object for the `variables` property will now inform the client of the mistake, providing a better developer experience: - -```json -{ - "errors": [ - { - "message": "Invalid GraphQL request", - "extensions": { - "details": "failed to deserialize the request body into JSON: invalid type: string \"null\", expected a map at line 1 column 100", - "code": "INVALID_GRAPHQL_REQUEST" - } - } - ] -} -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2306 - -### OpenTelemetry spans to subgraphs now include the request URL ([Issue #2280](https://github.com/apollographql/router/issues/2280)) - -A new `http.url` attribute has been attached to `subgraph_request` OpenTelemetry trace spans which specifies the URL which the particular request was made to. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2292 - -### Errors returned to clients are now more consistently formed ([Issue #2101](https://github.com/apollographql/router/issues/2101)) - -We now return errors in a more consistent shape to those which were returned by Apollo Gateway and Apollo Server, and seen in the [documentation](https://www.apollographql.com/docs/apollo-server/data/errors/). In particular, when available, a stable `code` field will be included in the error's `extensions`. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2178 - -## 🧪 Experimental - -> **Note** -> -> These features are subject to change slightly (usually, in terms of naming or interfaces) before graduating to general availability. -> -> [Read more about how we treat experimental features](https://www.apollographql.com/docs/resources/product-launch-stages/#experimental-features). - -### Introduce a `router_service` layer ([Issue #1496](https://github.com/apollographql/router/issues/1496)) - -A `router_service` layer is now part of our service stack and allows plugin developers to process raw HTTP requests and responses from clients prior to those requests reaching the GraphQL processing within the `supergraph_service` layer. This will become a stable part of our API as we receive feedback from its early adopters. Please open a discussion with any feedback you might have! - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2170 - -### Request pipeline customization via HTTP ([Issue #1916](https://github.com/apollographql/router/issues/1916)) - -We now offer the ability to configure some aspects of the Router via the response to an HTTP `POST` request to an external endpoint. Initially, we are only offering this option to customize the newly introduced `router_service` (above, in these release notes), but our intention is to introduce customization of [existing service layers](https://www.apollographql.com/docs/router/customizations/overview/#how-customizations-work) as well (e.g., `supergraph_service, `subgraph_service`, etc.). Conceptually, this addition allows similar customizations that are possible with Rhai or Rust plugin by sending the operation's context as of a particular phase of the request pipeline "over the wire" as of a particular to an external HTTP service which has the ability to process its properties and return a (potentially) modified response to the Router. This will become a stable part of our API as we receive feedback from its early adopters. Please open a discussion with any feedback you might have! - -When this experimental option is enabled, contextual data will be transmitted as a JSON payload to an HTTP endpoint as a `POST` request. The response to such a request will be processed by the Router and any changes made by the external service will effect the remaining layers in the request pipeline. This allows external services to customize the Router behavior, but requires intentionally blocking Router's normal request pipeline. Therefore, any latency of a configured external service will have a direct impact on the performance of the Router and external services should be as performant as possible. - -To experiement with this behavior, consider adopting a configuration similar to the following which communicates with a service running on `http://127.0.0.1:8081` for the `router` service layer: - -```yaml -plugins: - experimental.external: - # A URL which will be called for each request for any configured stage. - url: http://127.0.0.1:8081 - - # A human-readable interval specifying the maximum allowed time. (Defaults to "1s", or one second) - timeout: 2s - - # A "stage" represents a phase of the request pipeline in which the external service will be invoked. - # They sit request pipeline as our Service Layers for Rust/Rhai, seen in our docs: - # https://www.apollographql.com/docs/router/customizations/overview/#how-customizations-work - stages: - - # Currently, the only supported value is "router". - router: - - # Define which properties of the request should be transmitted in the payload. - # Choosing the least amount of data will reduce the size of the payload. - # By default, all values are false and, when false, their presence in this map is optional. - request: - headers: true - context: true - body: true - sdl: true - - # Similar to "request", but which properties of the response should be sent. - # Again, all values are false by default and only must be specified if they are enabled. - response: - headers: true - context: true -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2229 - -# [1.6.0] - 2022-12-13 - -## ❗ BREAKING ❗ - -### Protoc now required to build ([Issue #1970](https://github.com/apollographql/router/issues/1970)) - -Protoc is now required to build Apollo Router. Upgrading to Open Telemetry 0.18 has enabled us to upgrade tonic which in turn no longer bundles protoc. -Users must install it themselves https://grpc.io/docs/protoc-installation/. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/1970 - -### Jaeger scheduled_delay moved to batch_processor->scheduled_delay ([Issue #2232](https://github.com/apollographql/router/issues/2232)) - -Jager config previously allowed configuration of scheduled_delay for batch span processor. To bring it in line with all other exporters this is now set using a batch_processor section. - -Before: -```yaml -telemetry: - tracing: - jaeger: - scheduled_delay: 100ms -``` - -After: -```yaml -telemetry: - tracing: - jaeger: - batch_processor: - scheduled_delay: 100ms -``` - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/1970 - -## 🚀 Features - -### Add support for experimental tooling ([Issue #2136](https://github.com/apollographql/router/issues/2136)) - -Display a message at startup listing used `experimental_` configurations with related GitHub discussions. -It also adds a new cli command `router config experimental` to display all available experimental configurations. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2242 - -### Re-deploy router pods if the SuperGraph configmap changes ([PR #2223](https://github.com/apollographql/router/pull/2223)) -When setting the supergraph with the `supergraphFile` variable a `sha256` checksum is calculated and set as an annotation for the router pods. This will spin up new pods when the supergraph is mounted via config map and the schema has changed. - -Note: It is preferable to not have `--hot-reload` enabled with this feature since re-configuring the router during a pod restart is duplicating the work and may cause confusion in log messaging. - -By [@toneill818](https://github.com/toneill818) in https://github.com/apollographql/router/pull/2223 - -### Tracing batch span processor is now configurable ([Issue #2232](https://github.com/apollographql/router/issues/2232)) - -Exporting traces often requires performance tuning based on the throughput of the router, sampling settings and ingestion capability of tracing ingress. - -All exporters now support configuring the batch span processor in the router yaml. -```yaml -telemetry: - apollo: - batch_processor: - scheduled_delay: 100ms - max_concurrent_exports: 1000 - max_export_batch_size: 10000 - max_export_timeout: 100s - max_queue_size: 10000 - tracing: - jaeger|zipkin|otlp|datadog: - batch_processor: - scheduled_delay: 100ms - max_concurrent_exports: 1000 - max_export_batch_size: 10000 - max_export_timeout: 100s - max_queue_size: 10000 -``` - -See the Open Telemetry docs for more information. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/1970 - -### Add hot-reload support for Rhai scripts ([Issue #1071](https://github.com/apollographql/router/issues/1071)) - -The router will "watch" your "rhai.scripts" directory for changes and prompt an interpreter re-load if changes are detected. Changes are defined as: - - * creating a new file with a ".rhai" suffix - * modifying or removing an existing file with a ".rhai" suffix - -The watch is recursive, so files in sub-directories of the "rhai.scripts" directory are also watched. - -The Router attempts to identify errors in scripts before applying the changes. If errors are detected, these will be logged and the changes will not be applied to the runtime. Not all classes of error can be reliably detected, so check the log output of your router to make sure that changes have been applied. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2198 - -### Add support for working with multi-value header keys to Rhai ([Issue #2211](https://github.com/apollographql/router/issues/2211), [Issue #2255](https://github.com/apollographql/router/issues/2255)) - -Adds support for setting a header map key with an array. This causes the HeaderMap key/values to be appended() to the map, rather than inserted(). - -Adds support for a new `values()` fn which retrieves multiple values for a HeaderMap key as an array. - -Example use from Rhai as: - -``` - response.headers["set-cookie"] = [ - "foo=bar; Domain=localhost; Path=/; Expires=Wed, 04 Jan 2023 17:25:27 GMT; HttpOnly; Secure; SameSite=None", - "foo2=bar2; Domain=localhost; Path=/; Expires=Wed, 04 Jan 2023 17:25:27 GMT; HttpOnly; Secure; SameSite=None", - ]; - response.headers.values("set-cookie"); // Returns the array of values -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2219, https://github.com/apollographql/router/pull/2258 - -## 🐛 Fixes - -### Filter nullified deferred responses ([Issue #2213](https://github.com/apollographql/router/issues/2168)) - -[`@defer` spec updates](https://github.com/graphql/graphql-spec/compare/01d7b98f04810c9a9db4c0e53d3c4d54dbf10b82...f58632f496577642221c69809c32dd46b5398bd7#diff-0f02d73330245629f776bb875e5ca2b30978a716732abca136afdd028d5cd33cR448-R470) mandates that a deferred response should not be sent if its path points to an element of the response that was nullified in a previous payload. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2184 - -### Return root `__typename` when parts of a query with deferred fragment ([Issue #1677](https://github.com/apollographql/router/issues/1677)) - -With this query: - -```graphql -{ - __typename - fast - ...deferedFragment @defer -} - -fragment deferedFragment on Query { - slow -} -``` - -You will receive the first response chunk: - -```json -{"data":{"__typename": "Query", "fast":0},"hasNext":true} -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2188 - - -### Wait for opentelemetry tracer provider to shutdown ([PR #2191](https://github.com/apollographql/router/pull/2191)) - -When we drop Telemetry we spawn a thread to perform the global opentelemetry trace provider shutdown. The documentation of this function indicates that "This will invoke the shutdown method on all span processors. span processors should export remaining spans before return". We should give that process some time to complete (5 seconds currently) before returning from the `drop`. This will provide more opportunity for spans to be exported. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2191 -### Dispatch errors from the primary response to deferred responses ([Issue #1818](https://github.com/apollographql/router/issues/1818), [Issue #2185](https://github.com/apollographql/router/issues/2185)) - -When errors are generated during the primary execution, some may also be assigned to deferred responses. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2192 - -### Reconstruct deferred queries with knowledge about fragments ([Issue #2105](https://github.com/apollographql/router/issues/2105)) - -When we are using `@defer`, response formatting must apply on a subset of the query (primary or deferred), that is reconstructed from information provided by the query planner: a path into the response and a subselection. Previously, that path did not include information on fragment application, which resulted in query reconstruction issues if `@defer` was used under a fragment application on an interface. - -By [@Geal](https://github.com/geal) in https://github.com/apollographql/router/pull/2109 - -## 🛠 Maintenance - -### Improve plugin registration predictability ([PR #2181](https://github.com/apollographql/router/pull/2181)) - -This replaces [ctor](https://crates.io/crates/ctor) with [linkme](https://crates.io/crates/linkme). `ctor` enables rust code to execute before `main`. This can be a source of undefined behaviour and we don't need our code to execute before `main`. `linkme` provides a registration mechanism that is perfect for this use case, so switching to use it makes the router more predictable, simpler to reason about and with a sound basis for future plugin enhancements. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2181 - -### it_rate_limit_subgraph_requests fixed ([Issue #2213](https://github.com/apollographql/router/issues/2213)) - -This test was failing frequently due to it being a timing test being run in a single threaded tokio runtime. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2218 - -### Update reports.proto protobuf definition ([PR #2247](https://github.com/apollographql/router/pull/2247)) - -Update the reports.proto file, and change the prompt to update the file with the correct new location. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2247 -### Upgrade OpenTelemetry to 0.18 ([Issue #1970](https://github.com/apollographql/router/issues/1970)) - -Update to OpenTelemetry 0.18. - -By [@bryncooke](https://github.com/bryncooke) and [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1970 and https://github.com/apollographql/router/pull/2236 - -### Remove spaceport ([Issue #2233](https://github.com/apollographql/router/issues/2233)) - -Removal significantly simplifies telemetry code and likely to increase performance and reliability. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/1970 - -### Update to Rust 1.65 ([Issue #2220](https://github.com/apollographql/router/issues/2220)) - -Rust MSRV incremented to 1.65. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2221 and https://github.com/apollographql/router/pull/2240 - -### Improve automated release ([Pull #2220](https://github.com/apollographql/router/pull/2256)) - -Improved the automated release to: -* Update the scaffold files -* Improve the names of prepare release steps in circle. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2256 - -### Use Elastic-2.0 license spdx ([PR #2055](https://github.com/apollographql/router/issues/2055)) - -Now that the Elastic-2.0 spdx is a valid identifier in the rust ecosystem, we can update the router references. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2054 - -## 📚 Documentation -### Create yaml config design guidance ([Issue #2158](https://github.com/apollographql/router/issues/2158)) - -Added some yaml design guidance to help us create consistent yaml config for new and existing features. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2159 - - -# [1.5.0] - 2022-12-06 -## ❗ BREAKING ❗ - -### Router debug Docker images now run under the control of heaptrack ([Issue #2135](https://github.com/apollographql/router/issues/2135)) - -From 1.5.0, our debug Docker image will invoke the router under the control of heaptrack. We are making this change to make it simple for users to investigate potential memory issues with the Router. - -Do not run debug images in performance sensitive contexts. The tracking of memory allocations will significantly impact performance. In general, the debug image should only be used in consultation with Apollo engineering and support. - -Look at our documentation for examples of how to use the image in either Docker or Kubernetes. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2142 - -### Fix naming inconsistency of telemetry.metrics.common.attributes.router ([Issue #2076](https://github.com/apollographql/router/issues/2076)) - -Mirroring the rest of the config `router` should be `supergraph` - -```yaml -telemetry: - metrics: - common: - attributes: - router: # old -``` -becomes -```yaml -telemetry: - metrics: - common: - attributes: - supergraph: # new -``` - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2116 - -### CLI structure changes ([Issue #2123](https://github.com/apollographql/router/issues/2123)) - -There is now a separate subcommand for config related operations: -* `config` - * `schema` - Output the configuration schema - * `upgrade` - Upgrade the configuration with optional diff support. - -`router --schema` has been deprecated and users should move to `router config schema`. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2116 - -## 🚀 Features - -### Add configuration for trace ID ([Issue #2080](https://github.com/apollographql/router/issues/2080)) - -Trace ids can be propagated directly from a request header: - -```yaml title="router.yaml" -telemetry: - tracing: - propagation: - # If you have your own way to generate a trace id and you want to pass it via a custom request header - request: - header_name: my-trace-id -``` -In addition, trace id can be exposed via a response header: -```yaml title="router.yaml" -telemetry: - tracing: - experimental_response_trace_id: - enabled: true # default: false - header_name: "my-trace-id" # default: "apollo-trace-id" -``` - -Using this configuration you will have a response header called `my-trace-id` containing the trace ID. It could help you to debug a specific query if you want to grep your log with this trace id to have more context. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2131 - -### Add configuration for logging and add more logs ([Issue #1998](https://github.com/apollographql/router/issues/1998)) - -By default, logs do not contain request body, response body or headers. -It is now possible to conditionally add this information for debugging and audit purposes. -Here is an example how you can configure it: - -```yaml title="router.yaml" -telemetry: - experimental_logging: - format: json # By default it's "pretty" if you are in an interactive shell session - display_filename: true # Display filename where the log is coming from. Default: true - display_line_number: false # Display line number in the file where the log is coming from. Default: true - # If one of these headers matches we will log supergraph and subgraphs requests/responses - when_header: - - name: apollo-router-log-request - value: my_client - headers: true # default: false - body: true # default: false - # log request for all requests/responses headers coming from Iphones - - name: user-agent - match: ^Mozilla/5.0 (iPhone* - headers: true -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2040 - -### Provide multi-arch (amd64/arm64) Docker images for the Router ([Issue #1932](https://github.com/apollographql/router/issues/1932)) - -From 1.5.0 our Docker images will be multi-arch. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2138 - -### Add a supergraph configmap option to the helm chart ([PR #2119](https://github.com/apollographql/router/pull/2119)) - -Adds the capability to create a configmap containing your supergraph schema. Here's an example of how you could make use of this from your values.yaml and with the `helm` install command. - -```yaml -extraEnvVars: - - name: APOLLO_ROUTER_SUPERGRAPH_PATH - value: /data/supergraph-schema.graphql - -extraVolumeMounts: - - name: supergraph-schema - mountPath: /data - readOnly: true - -extraVolumes: - - name: supergraph-schema - configMap: - name: "{{ .Release.Name }}-supergraph" - items: - - key: supergraph-schema.graphql - path: supergraph-schema.graphql -``` - -With that values.yaml content, and with your supergraph schema in a file name supergraph-schema.graphql, you can execute: - -``` -helm upgrade --install --create-namespace --namespace router-test --set-file supergraphFile=supergraph-schema.graphql router-test oci://ghcr.io/apollographql/helm-charts/router --version 1.0.0-rc.9 --values values.yaml -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2119 - -### Configuration upgrades ([Issue #2123](https://github.com/apollographql/router/issues/2123)) - -Occasionally we will make changes to the Router yaml configuration format. -When starting the Router, if the configuration can be upgraded, it will do so automatically and display a warning: - -``` -2022-11-22T14:01:46.884897Z WARN router configuration contains deprecated options: - - 1. telemetry.tracing.trace_config.attributes.router has been renamed to 'supergraph' for consistency - -These will become errors in the future. Run `router config upgrade ` to see a suggested upgraded configuration. -``` - -Note: If a configuration has errors after upgrading then the configuration will not be upgraded automatically. - -From the CLI users can run: -* `router config upgrade ` to output configuration that has been upgraded to match the latest config format. -* `router config upgrade --diff ` to output a diff e.g. -``` - telemetry: - apollo: - client_name_header: apollographql-client-name - metrics: - common: - attributes: -- router: -+ supergraph: - request: - header: - - named: "1" # foo -``` - -There are situations where comments and whitespace are not preserved. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2116, https://github.com/apollographql/router/pull/2162 - -### *Experimental* 🥼 subgraph request retry ([Issue #338](https://github.com/apollographql/router/issues/338), [Issue #1956](https://github.com/apollographql/router/issues/1956)) - -Implements subgraph request retries, using Finagle's retry buckets algorithm: -- it defines a minimal number of retries per second (`min_per_sec`, default is 10 retries per second), to -bootstrap the system or for low traffic deployments -- for each successful request, we add a "token" to the bucket, those tokens expire after `ttl` (default: 10 seconds) -- the number of available additional retries is a part of the number of tokens, defined by `retry_percent` (default is 0.2) - -Request retries are disabled by default on mutations. - -This is activated in the `traffic_shaping` plugin, either globally or per subgraph: - -```yaml -traffic_shaping: - all: - experimental_retry: - min_per_sec: 10 - ttl: 10s - retry_percent: 0.2 - retry_mutations: false - subgraphs: - accounts: - experimental_retry: - min_per_sec: 20 -``` - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2006 and https://github.com/apollographql/router/pull/2160 - -### *Experimental* 🥼 Caching configuration ([Issue #2075](https://github.com/apollographql/router/issues/2075)) - -Split Redis cache configuration for APQ and query planning: - -```yaml -supergraph: - apq: - experimental_cache: - in_memory: - limit: 512 - redis: - urls: ["redis://..."] - query_planning: - experimental_cache: - in_memory: - limit: 512 - redis: - urls: ["redis://..."] -``` - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2155 - -### `@defer` Apollo tracing support ([Issue #1600](https://github.com/apollographql/router/issues/1600)) - -Added Apollo tracing support for queries that use `@defer`. You can now view traces in Apollo Studio as normal. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2190 - -## 🐛 Fixes - -### Fix panic when dev mode enabled with empty config file ([Issue #2182](https://github.com/apollographql/router/issues/2182)) - -If you're running the Router with dev mode with an empty config file, it will no longer panic - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2195 - -### Fix missing apollo tracing variables ([Issue #2186](https://github.com/apollographql/router/issues/2186)) - -Send variable values had no effect. This is now fixed. -```yaml -telemetry: - apollo: - send_variable_values: all -``` - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2190 - - -### fix build_docker_image.sh script when using default repo ([PR #2163](https://github.com/apollographql/router/pull/2163)) - -Adding the `-r` flag recently broke the existing functionality to build from the default repo using `-b`. This fixes that. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2163 - -### Improve errors when subgraph returns non-GraphQL response with a non-2xx status code ([Issue #2117](https://github.com/apollographql/router/issues/2117)) - -The error response will now contain the status code and status name. Example: `HTTP fetch failed from 'my-service': 401 Unauthorized` - -By [@col](https://github.com/col) in https://github.com/apollographql/router/pull/2118 - -### handle mutations containing `@defer` ([Issue #2099](https://github.com/apollographql/router/issues/2099)) - -The Router generates partial query shapes corresponding to the primary and deferred responses, -to validate the data sent back to the client. Those query shapes were invalid for mutations. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2102 - -### *Experimental* 🥼 APQ and query planner Redis caching fixes ([PR #2176](https://github.com/apollographql/router/pull/2176)) - -* use a null byte as separator in Redis keys -* handle Redis connection errors -* mark APQ and query plan caching as license key functionality - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2176 - -## 🛠 Maintenance - -### Verify that deferred fragment acts as a boundary for nullability rules ([Issue #2169](https://github.com/apollographql/router/issues/2169)) - -Add a test to ensure that deferred fragments act as a boundary for nullability rules. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2183 - -### Refactor APQ ([PR #2129](https://github.com/apollographql/router/pull/2129)) - -Remove duplicated code. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2129 - -### Update apollo-rs ([PR #2177](https://github.com/apollographql/router/pull/2177)) - -Updates to new apollo-rs APIs, and fixes some potential panics on unexpected user input. - -By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/2177 - -### Semi-automate the release ([PR #2202](https://github.com/apollographql/router/pull/2202)) - -Developers can now run: -`cargo xtask release prepare minor` - -To raise a release PR. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2202 - - -### Fix webpki license check ([PR #2202](https://github.com/apollographql/router/pull/2202)) - -Fixed webpki license check. -Add missing Google Chromimum license. -By [@o0Ignition0o](https://github.com/o0Ignition0o) [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2202 - -## 📚 Documentation - -### Docs: Update cors match regex example ([Issue #2151](https://github.com/apollographql/router/issues/2151)) - -The docs CORS regex example now displays a working and safe way to allow `HTTPS` subdomains of `api.example.com`. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2152 - - -### update documentation to reflect new examples structure ([Issue #2095](https://github.com/apollographql/router/issues/2095)) - -Updated the examples directory structure. This fixes the documentation links to the examples. It also makes clear that rhai subgraph fields are read-only, since they are shared resources. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2133 - - -### Docs: Add a disclaimer for users who set up health-checks and prometheus endpoints in a containers environment ([Issue #2079](https://github.com/apollographql/router/issues/2079)) - -The health check and the prometheus endpoint listen to 127.0.0.1 by default. -While this is a safe default, it prevents other pods from performing healthchecks and scraping prometheus data. -This behavior and customization is now documented in the [health-checks](https://www.apollographql.com/docs/router/configuration/health-checks) and the [prometheus](https://www.apollographql.com/docs/router/configuration/metrics#using-prometheus) sections. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2194 - - -# [1.4.0] - 2022-11-15 - -## 🚀 Features - -### Add support for returning different HTTP status codes in Rhai ([Issue #2023](https://github.com/apollographql/router/issues/2023)) - -It is now possible to return different HTTP status codes when raising an exception in Rhai. You do this by providing an object map with two keys: `status` and `message`, rather than merely a string as was the case previously. - -```rust -throw #{ - status: 403, - message: "I have raised a 403" -}; -``` - -This example will short-circuit request/response processing and return with an HTTP status code of 403 to the client and also set the error message accordingly. - -It is still possible to return errors using the current pattern, which will continue to return HTTP status code 500 as previously: - -```rust -throw "I have raised an error"; -``` - -> It is not currently possible to return a 200 status code using this pattern. If you try, it will be implicitly converted into a 500 error. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2097 - -### Add support for `urlencode()` / `decode()` in Rhai ([Issue #2052](https://github.com/apollographql/router/issues/2052)) - -Two new functions, `urlencode()` and `urldecode()` may now be used to URL-encode or URL-decode strings, respectively. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2053 - -### **Experimental** 🥼 External cache storage in Redis ([PR #2024](https://github.com/apollographql/router/pull/2024)) - -We are experimenting with introducing external storage for caches in the Router, which will provide a foundation for caching things like automated persisted queries (APQ) amongst other future-looking ideas. Our initial implementation supports a multi-level cache hierarchy, first attempting an in-memory LRU-cache, proceeded by a Redis Cluster backend. - -As this is still experimental, it is only available as an opt-in through a Cargo feature-flag. - -By [@garypen](https://github.com/garypen) and [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2024 - -### Expose `query_plan` to `ExecutionRequest` in Rhai ([PR #2081](https://github.com/apollographql/router/pull/2081)) - -You can now read the query-plan from an execution request by accessing `request.query_plan`. Additionally, `request.context` also now supports the Rhai `in` keyword. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2081 - -## 🐛 Fixes - -### Move error messages about nullifying into `extensions` ([Issue #2071](https://github.com/apollographql/router/issues/2071)) - -The Router was previously creating and returning error messages in `errors` when nullability rules had been triggered (e.g., when a _non-nullable_ field was `null`, it nullifies the parent object). These are now emitted into a `valueCompletion` portion of the `extensions` response. - -Adding those messages in the list of `errors` was potentially redundant and resulted in failures by clients (such as the Apollo Client error policy, by default) which would otherwise have expected nullified fields as part of normal operation execution. Additionally, the subgraph could already add such an error message indicating why a field was null which would cause the error to be doubled. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2077 - -### Fix `Float` input-type coercion for default values with values larger than 32-bit ([Issue #2087](https://github.com/apollographql/router/issues/2087)) - -A regression has been fixed which caused the Router to reject integers larger than 32-bits used as the default values on `Float` fields in input types. - -In other words, the following will once again work as expected: - -```graphql -input MyInputType { - a_float_input: Float = 9876543210 -} -``` - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/2090 - -### Assume `Accept: application/json` when no `Accept` header is present [Issue #1990](https://github.com/apollographql/router/issues/1990)) - -The `Accept` header means `*/*` when it is absent, and despite efforts to fix this previously, we still were not always doing the correct thing. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/2078 - -### `@skip` and `@include` implementation for root-level fragment use ([Issue #2072](https://github.com/apollographql/router/issues/2072)) - -The `@skip` and `@include` directives are now implemented for both inline fragments and fragment spreads at the top-level of operations. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2096 - -## 🛠 Maintenance - -### Use `debian:bullseye-slim` as our base Docker image ([PR #2085](https://github.com/apollographql/router/pull/2085)) - -A while ago, when we added compression support to the router, we discovered that the Distroless base-images we were using didn't ship with a copy of `libz.so.1`. We addressed that problem by copying in a version of the library from the Distroless image (Java) which does ship it. While that worked, we found challenges in adding support for both `aarch64` and `amd64` Docker images that would make it less than ideal to continue using those Distroless images. - -Rather than persist with this complexity, we've concluded that it would be better to just use a base image which ships with `libz.so.1`, hence the change to `debian:bullseye-slim`. Those images are still quite minimal and the resulting images are similar in size. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2085 - -### Update `apollo-parser` to `v0.3.2` ([PR #2103](https://github.com/apollographql/router/pull/2103)) - -This updates our dependency on our `apollo-parser` package which brings a few improvements, including more defensive parsing of some operations. See its CHANGELOG in [the `apollo-rs` repository](https://github.com/apollographql/apollo-rs/blob/main/crates/apollo-parser/CHANGELOG.md#032---2022-11-15) for more details. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/2103 - -## 📚 Documentation - -### Fix example `helm show values` command ([PR #2088](https://github.com/apollographql/router/pull/2088)) - -The `helm show vaues` command needs to use the correct Helm chart reference `oci://ghcr.io/apollographql/helm-charts/router`. - -By [@col](https://github.com/col) in https://github.com/apollographql/router/pull/2088 - -# [1.3.0] - 2022-11-09 - -## 🚀 Features - -### Add support for DHAT-based heap profiling ([PR #1829](https://github.com/apollographql/router/pull/1829)) - -The [dhat-rs](https://github.com/nnethercote/dhat-rs) crate provides [DHAT](https://www.valgrind.org/docs/manual/dh-manual.html)-style heap profiling. We have added two compile-time features, `dhat-heap` and `dhat-ad-hoc`, which leverage this ability. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1829 - -### Add `trace_id` in logs to correlate entries from the same request ([Issue #1981](https://github.com/apollographql/router/issues/1981)) - -A `trace_id` is now added to each log line to help correlate log entries to specific requests. The value for this property will be automatically inherited from any enabled distributed tracing headers, such as those listed in our [Tracing propagation header](https://www.apollographql.com/docs/router/configuration/tracing/#propagation) documentation (e.g., Jaeger, Zipkin, Datadog, etc.). - -In the event that a `trace_id` was not inherited from a propagated header, the Router will originate a `trace_id` and propagate that ID to subgraphs if header propagation (see link above) is enabled. - -Here is an example of the `trace_id` appearing in plain-text log output: - -``` -2022-10-21T15:17:45.562553Z ERROR [trace_id=5e6a6bda8d0dca26e5aec14dafa6d96f] apollo_router::services::subgraph_service: fetch_error="hyper::Error(Connect, ConnectError(\"tcp connect error\", Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" }))" -2022-10-21T15:17:45.565768Z ERROR [trace_id=5e6a6bda8d0dca26e5aec14dafa6d96f] apollo_router::query_planner::execution: Fetch error: HTTP fetch failed from 'accounts': HTTP fetch failed from 'accounts': error trying to connect: tcp connect error: Connection refused (os error 111) -``` - -And an example of the `trace_id` appearing in JSON-formatted log output in a similar scenario: - -```json -{"timestamp":"2022-10-26T15:39:01.078260Z","level":"ERROR","fetch_error":"hyper::Error(Connect, ConnectError(\"tcp connect error\", Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" }))","target":"apollo_router::services::subgraph_service","filename":"apollo-router/src/services/subgraph_service.rs","line_number":182,"span":{"name":"subgraph"},"spans":[{"trace_id":"5e6a6bda8d0dca26e5aec14dafa6d96f","name":"request"},{"name":"supergraph"},{"name":"execution"},{"name":"parallel"},{"name":"fetch"},{"name":"subgraph"}]} -{"timestamp":"2022-10-26T15:39:01.080259Z","level":"ERROR","message":"Fetch error: HTTP fetch failed from 'accounts': HTTP fetch failed from 'accounts': error trying to connect: tcp connect error: Connection refused (os error 111)","target":"apollo_router::query_planner::execution","filename":"apollo-router/src/query_planner/execution.rs","line_number":188,"span":{"name":"parallel"},"spans":[{"trace_id":"5e6a6bda8d0dca26e5aec14dafa6d96f","name":"request"},{"name":"supergraph"},{"name":"execution"},{"name":"parallel"}]} -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1982 - -### Reload configuration when receiving the SIGHUP signal ([Issue #35](https://github.com/apollographql/router/issues/35)) - -The Router will now reload its configuration when receiving the SIGHUP signal. This signal is only supported on *nix platforms, -and only when a configuration file was passed to the Router initially at startup. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2015 - -## 🐛 Fixes - -### Fix the deduplication logic in deduplication caching ([Issue #1984](https://github.com/apollographql/router/issues/1984)) - -Under load, we found it was possible to break the router de-duplication logic and leave orphaned entries in the waiter map. This fixes the de-duplication logic to prevent this from occurring. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2014 - -### Follow back-off instructions from Studio Uplink ([Issue #1494](https://github.com/apollographql/router/issues/1494) [Issue #1539](https://github.com/apollographql/router/issues/1539)) - -When operating in a [Managed Federation configuration](https://www.apollographql.com/docs/federation/managed-federation/overview/) and fetching the supergraph from Apollo Uplink, the Router will now react differently depending on the response from Apollo Uplink, rather than retrying incessantly: - -- Not attempt to retry when met with unrecoverable conditions (e.g., a Graph that does not exist). -- Back-off on retries when the infrastructure asks for a longer retry interval. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2001 - -### Fix the rhai SDL `print` function ([Issue #2005](https://github.com/apollographql/router/issues/2005)) - -Fixes the `print` function exposed to rhai which was broken due to a recent change that was made in the way we pass SDL (schema definition language) to plugins. - -By [@fernando-apollo](https://github.com/fernando-apollo) in https://github.com/apollographql/router/pull/2007 - -### Export `router_factory::Endpoint` ([PR #2007](https://github.com/apollographql/router/pull/2007)) - -We now export the `router_factory::Endpoint` struct that was inadvertently unexposed. Without access to this struct, it was not possible to implement the `web_endpoints` trait in plugins. - -By [@scottdouglas1989](https://github.com/scottdouglas1989) in https://github.com/apollographql/router/pull/2007 - -### Validate default values for input object fields ([Issue #1979](https://github.com/apollographql/router/issues/1979)) - -When validating variables, the Router now uses graph-specified default values for object fields, if applicable. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2003 - -### Address regression when sending gRPC to `localhost` ([Issue #2036](https://github.com/apollographql/router/issues/2036)) - -We again support sending unencrypted gRPC tracing and metrics data to `localhost`. This follows-up on a regression which occurred in the previous release which addressed a limitation which prevented sending gRPC to TLS-secured endpoints. - -Applying a proper fix was complicated by an upstream issue ([opentelemetry-rust#908](https://github.com/open-telemetry/opentelemetry-rust/issues/908)) which incorrectly assumes `https` in the absence of a more-specific protocol/schema, contrary to the OpenTelmetry specification which indicates otherwise. - -The Router will now detect and work-around this upstream issue by explicitly setting the full, correct endpoint URLs when not specified in config. - -In addition: - -- Basic TLS-encyrption will be enabled when the endpoint scheme is explicitly `https`. -- A _warning_ will be emitted if the endpoint port is 443 but *no* TLS config is specified since _most_ traffic on port 443 is expected to be encrypted. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/#2048 - -## 🛠 Maintenance - -### Apply Tower best-practice to "inner" Service cloning ([PR #2030](https://github.com/apollographql/router/pull/2030)) - -We found our `Service` readiness checks could be improved by following the Tower project's [recommendations](https://docs.rs/tower/latest/tower/trait.Service.html#be-careful-when-cloning-inner-services) for cloning inner Services. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2030 - -### Split the configuration file implementation into modules ([Issue #1790](https://github.com/apollographql/router/issues/1790)) - -The internals of the implementation for the configuration have been modularized to facilitate on-going development. There should be no impact to end-users who are only using YAML to configure their Router. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1996 - -### Apply traffic-shaping directly to `supergraph` and `subgraph` ([PR #2034](https://github.com/apollographql/router/issues/2034)) - -The plugin infrastructure works on `BoxService` instances and makes no guarantee on plugin ordering. The traffic shaping plugin needs a clonable inner service, and should run right before calling the underlying service. We'e changed the traffic plugin application so it can work directly on the underlying service. The configuration remains the same since this is still implemented as a plugin. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/2034 - -## 📚 Documentation - -### Remove references to Git submodules from `DEVELOPMENT.md` ([Issue #2012](https://github.com/apollographql/router/issues/2012)) - -We've removed the instructions from our development documentation which guide users to familiarize themselves with and clone Git submodules when working on the Router source itself. This follows-up on the removal of the modules themselves in [PR #1856](https://github.com/apollographql/router/pull/1856). - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2045 - -# [1.2.1] - 2022-10-25 - -## 🐛 Fixes - -### Update to Federation v2.1.4 ([PR #1994](https://github.com/apollographql/router/pull/1994)) - -In addition to general Federation bug-fixes, this update should resolve a case ([seen in Issue #1962](https://github.com/apollographql/router/issues/1962)) where a `@defer` directives which had been previously present in a Supergraph were causing a startup failure in the Router when we were trying to generate an API schema in the Router with `@defer`. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/1994 - -### Assume `Accept: application/json` when no `Accept` header is present [Issue #1995](https://github.com/apollographql/router/pull/1995)) - -the `Accept` header means `*/*` when it is absent. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1995 - -### Fix OpenTelemetry OTLP gRPC ([Issue #1976](https://github.com/apollographql/router/issues/1976)) - -OpenTelemetry (OTLP) gRPC failures involving TLS errors have been resolved against external APMs: including Datadog, NewRelic and Honeycomb.io. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/#1977 - -### Prefix the Prometheus metrics with `apollo_router_` ([Issue #1915](https://github.com/apollographql/router/issues/1915)) - -Correctly prefix Prometheus metrics with `apollo_router`, per convention. - -```diff -- http_requests_error_total{message="cannot contact the subgraph",service_name="apollo-router",subgraph="my_subgraph_name_error",subgraph_error_extended_type="SubrequestHttpError"} 1 -+ apollo_router_http_requests_error_total{message="cannot contact the subgraph",service_name="apollo-router",subgraph="my_subgraph_name_error",subgraph_error_extended_type="SubrequestHttpError"} 1 -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1971 & https://github.com/apollographql/router/pull/1987 - -### Fix `--hot-reload` in Kubernetes and Docker ([Issue #1476](https://github.com/apollographql/router/issues/1476)) - -The `--hot-reload` flag now chooses a file event notification mechanism at runtime. The exact mechanism is determined by the [`notify`](https://crates.io/crates/notify) crate. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1964 - -### Fix a coercion rule that failed to validate 64-bit integers ([PR #1951](https://github.com/apollographql/router/pull/1951)) - -Queries that passed 64-bit integers for `Float` input variables were failing to validate despite being valid. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1951 - -### Prometheus: make sure `apollo_router_http_requests_error_total` and `apollo_router_http_requests_total` are incremented. ([PR #1953](https://github.com/apollographql/router/pull/1953)) - -This affected two different metrics differently: - -- The `apollo_router_http_requests_error_total` metric only incremented for requests that would be an `INTERNAL_SERVER_ERROR` in the Router (the service stack returning a `BoxError`). This meant that GraphQL validation errors were not increment this counter. - -- The `apollo_router_http_requests_total` metric would only increment for _successful_ requests despite the fact that the Prometheus documentation suggests this should be incremented _regardless_ of whether the request succeeded or not. - -This PR makes sure we always increment `apollo_router_http_requests_total` and we increment `apollo_router_http_requests_error_total` when the status code is 4xx or 5xx. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1953 - -### Set `no_delay` and `keepalive` on subgraph requests [Issue #1905](https://github.com/apollographql/router/issues/1905)) - -This re-introduces these parameters which were incorrectly removed in a previous pull request. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1910 - -## 🛠 Maintenance - -### Improve the stability of some flaky tests ([PR #1972](https://github.com/apollographql/router/pull/1972)) - -The trace and rate limiting tests have been sporadically failing in our CI environment. The root cause was a race-condition in the tests so the tests have been made more resilient to reduce the number of failures. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1972 and https://github.com/apollographql/router/pull/1974 - -### Update `docker-compose` and `Dockerfile`s now that the submodules have been removed ([PR #1950](https://github.com/apollographql/router/pull/1950)) - -We recently removed Git submodules from this repository but we didn't update various `docker-compose.yml` files. - -This PR adds new `Dockerfile`s and updates existing `docker-compose.yml` files so we can run integration tests (and the fuzzer) without needing to `git clone` and set up the Federation and `federation-demo` repositories. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1950 - -### Fix logic around `Accept` headers and multipart responses ([PR #1923](https://github.com/apollographql/router/pull/1923)) - -If the `Accept` header contained `multipart/mixed`, even with other alternatives like `application/json`, -a query with a single response was still sent as multipart, which made Apollo Studio Explorer fail on the initial introspection query. - -This changes the logic so that: - -- If the client has indicated an `accept` of `application/json` or `*/*` and there is a single response, it will be delivered as `content-type: application/json`. -- If there are multiple responses or the client only accepts `multipart/mixed`, we will send `content-type: multipart/mixed` response. This will occur even if there is only one response. -- Otherwise, we will return an HTTP status code of `406 Not Acceptable`. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1923 - -### `@defer`: duplicated errors across incremental items ([Issue #1834](https://github.com/apollographql/router/issues/1834), [Issue #1818](https://github.com/apollographql/router/issues/1818)) - -If a deferred response contains incremental responses, the errors should be dispatched in each increment according to the error's path. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1892 - -### Our Docker images are now linked to our GitHub repository per OCI-standards ([PR #1958](https://github.com/apollographql/router/pull/1958)) - -The `org.opencontainers.image.source` [annotation](https://github.com/opencontainers/image-spec/blob/main/annotations.md) has been added to our `Dockerfile`s and published Docker image in order to map the published image to our GitHub repository. - -By [@ndthanhdev](https://github.com/ndthanhdev) in https://github.com/apollographql/router/pull/1958 - -# [1.2.0] - 2022-10-11 - -## ❗ BREAKING ❗ - -> Note the breaking change is not for the Router itself, but for the [Router helm chart](https://github.com/apollographql/router/pkgs/container/helm-charts%2Frouter) which is still [1.0.0-rc.5](https://github.com/orgs/apollographql/packages/container/helm-charts%2Frouter/45240873?tag=1.0.0-rc.5) - -### Remove support for `rhai.input_file` from the helm chart ([Issue #1826](https://github.com/apollographql/router/issues/1826)) - -The existing `rhai.input_file` mechanism doesn't really work for most helm use cases. This PR removes this mechanism and and encourages the use of the `extraVolumes/extraVolumeMounts` mechanism with rhai. - -Example: Create a configmap which contains your rhai scripts. - -```yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: rhai-config - labels: - app.kubernetes.io/name: rhai-config - app.kubernetes.io/instance: rhai-config -data: - main.rhai: | - // Call map_request with our service and pass in a string with the name - // of the function to callback - fn subgraph_service(service, subgraph) { - print(`registering request callback for ${subgraph}`); - const request_callback = Fn("process_request"); - service.map_request(request_callback); - } - - // This will convert all cookie pairs into headers. - // If you only wish to convert certain cookies, you - // can add logic to modify the processing. - fn process_request(request) { - - // Find our cookies - if "cookie" in request.headers { - print("adding cookies as headers"); - let cookies = request.headers["cookie"].split(';'); - for cookie in cookies { - // Split our cookies into name and value - let k_v = cookie.split('=', 2); - if k_v.len() == 2 { - // trim off any whitespace - k_v[0].trim(); - k_v[1].trim(); - // update our headers - // Note: we must update subgraph.headers, since we are - // setting a header in our sub graph request - request.subgraph.headers[k_v[0]] = k_v[1]; - } - } - } else { - print("no cookies in request"); - } - } - my-module.rhai: | - fn process_request(request) { - print("processing a request"); - } -``` -Note how the data represents multiple rhai source files. The module code isn't used, it's just there to illustrate multiple files in a single configmap. - -With that configmap in place, the helm chart can be used with a values file that contains: - -```yaml -router: - configuration: - rhai: - scripts: /dist/rhai - main: main.rhai -extraVolumeMounts: - - name: rhai-volume - mountPath: /dist/rhai - readonly: true -extraVolumes: - - name: rhai-volume - configMap: - name: rhai-config -``` -The configuration tells the router to load the rhai script `main.rhai` from the directory `/dist/rhai` (and load any imported modules from /dist/rhai) - -This will mount the confimap created above in the `/dist/rhai` directory with two files: - - `main.rhai` - - `my-module.rhai` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1917 - -## 🚀 Features - -### Expose the TraceId functionality to rhai ([Issue #1935](https://github.com/apollographql/router/issues/1935)) - -A new function, traceid(), is exposed to rhai scripts which may be used to retrieve a unique trace id for a request. The trace id is an opentelemetry span id. - -``` -fn supergraph_service(service) { - try { - let id = traceid(); - print(`id: ${id}`); - } - catch(err) - { - // log any errors - log_error(`span id error: ${err}`); - } -} -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1937 - -## 🐛 Fixes - -### Fix studio reporting failures ([Issue #1903](https://github.com/apollographql/router/issues/1903)) - -The root cause of the issue was letting the server component of spaceport close silently during a re-configuration or schema reload. This fixes the issue by keeping the server component alive as long as the client remains connected. - -Additionally, recycled spaceport connections are now re-connected to spaceport to further ensure connection validity. - -Also make deadpool sizing constant across environments (#1893) - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1928 - -### Update `apollo-parser` to v0.2.12 ([PR #1921](https://github.com/apollographql/router/pull/1921)) - -Correctly lexes and creates an error token for unterminated GraphQL `StringValue`s with unicode and line terminator characters. - -By [@lrlna](https://github.com/lrlna) in https://github.com/apollographql/router/pull/1921 - -### `traffic_shaping.all.deduplicate_query` was not correctly set ([PR #1901](https://github.com/apollographql/router/pull/1901)) - -Due to a change in our traffic_shaping configuration the `deduplicate_query` field for all subgraph wasn't set correctly. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1901 - -## 🛠 Maintenance - -### Fix hpa yaml for appropriate kubernetes versions ([#1908](https://github.com/apollographql/router/pull/1908)) - -Correct schema for autoscaling/v2beta2 and autoscaling/v2 api versions of the -HorizontalPodAutoscaler within the helm chart - -By [@damienpontifex](https://github.com/damienpontifex) in https://github.com/apollographql/router/issues/1914 - -## 📚 Documentation - -# [1.1.0] - 2022-09-30 - -## 🚀 Features - -### Build, test and publish binaries for `aarch64-unknown-linux-gnu` architecture ([Issue #1192](https://github.com/apollographql/router/issues/1192)) - -We're now testing and building `aarch64-unknown-linux-gnu` binaries in our release pipeline and publishing those build artifacts as releases. These will be installable in the same way as our [existing installation instructions](https://www.apollographql.com/docs/router/quickstart/). - -By [@EverlastingBugstopper](https://github.com/EverlastingBugstopper) in https://github.com/apollographql/router/pull/1907 - -### Add ability to specify repository location in "DIY" Docker builds ([PR #1904](https://github.com/apollographql/router/issues/1904)) - -The new `-r` flag allows a developer to specify the location of a repository when building a diy docker image. Handy for developers with local repositories. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1904 - -### Support `serviceMonitor` in Helm chart - -`kube-prometheus-stack` ignores scrape annotations, so a `serviceMonitor` Custom Resource Definition (CRD) is required to scrape a given target to avoid `scrape_configs`. - -By [@hobbsh](https://github.com/hobbsh) in https://github.com/apollographql/router/pull/1853 - -### Add support for dynamic header injection ([Issue #1755](https://github.com/apollographql/router/issues/1755)) - -The following are now possible in our YAML configuration for `headers`: - -- Insert static header - - ```yaml - headers: - all: # Header rules for all subgraphs - request: - - insert: - name: "sent-from-our-apollo-router" - value: "indeed" - ``` - -- Insert header from context - - ```yaml - headers: - all: # Header rules for all subgraphs - request: - - insert: - name: "sent-from-our-apollo-router-context" - from_context: "my_key_in_context" - ``` - -- Insert header from request body - - ```yaml - headers: - all: # Header rules for all subgraphs - request: - - insert: - name: "sent-from-our-apollo-router-request-body" - path: ".operationName" # It's a JSON path query to fetch the operation name from request body - default: "UNKNOWN" # If no operationName has been specified - ``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1833 - -## 🐛 Fixes - -### Fix external secret support in our Helm chart ([Issue #1750](https://github.com/apollographql/router/issues/1750)) - -If an external secret is specified, e.g.: - -``` -helm install --set router.managedFederation.existingSecret="my-secret-name" -``` - -...then the router should be deployed and configured to use the _existing_ secret. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1878 - -### Do not erase errors when missing `_entities` ([Issue #1863](https://github.com/apollographql/router/issues/1863)) - -In a federated query, if the subgraph returned a response with `errors` and a `null` or absent `data` field, the Router was ignoring the subgraph error and instead returning an error complaining about the missing` _entities` field. - -The Router will now aggregate the subgraph error and the missing `_entities` error. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1870 - -### Fix Prometheus annotation and healthcheck default - -The Prometheus annotation is breaking on a `helm upgrade` so this fixes the template and also sets defaults. Additionally, defaults are now set for `health-check`'s `listen` to be `0.0.0.0:8088` within the Helm chart. - -By [@hobbsh](https://github.com/hobbsh) in https://github.com/apollographql/router/pull/1883 - -### Move response formatting to the execution service ([PR #1771](https://github.com/apollographql/router/pull/1771)) - -The response formatting process (in which response data is filtered according to deferred responses subselections and the API schema) was being executed in the `supergraph` service. This was a bit late since it resulted in the `execution` service returning a stream of invalid responses leading to the execution plugins operating on invalid data. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1771 - -### Hide footer from "homepage" landing page ([PR #1900](https://github.com/apollographql/router/pull/1900)) - -Hides some incorrect language about customization on the landing page. Currently to customize the landing page it requires additional support. - -By [@glasser](https://github.com/glasser) in https://github.com/apollographql/router/pull/1900 - -## 🛠 Maintenance - -### Update to Federation 2.1.3 ([Issue #1880](https://github.com/apollographql/router/issues/1880)) - -This brings in Federation 2.1.3 to bring in updates to `@apollo/federation` via the relevant bump in `router-bridge`. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/1806 - -### Update `reqwest` dependency to resolve DNS resolution failures ([Issue #1899](https://github.com/apollographql/router/issues/1899)) - -This should resolve intermittent failures to resolve DNS in Uplink which were occurring due to an upstream bug in the `reqwest` library. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/1806 - -### Remove span details from log records ([PR #1896](https://github.com/apollographql/router/pull/1896)) - -Prior to this change, span details were written to log files. This was unwieldy and contributed to log bloat. Spans and logs are still linked in trace aggregators, such as jaeger, and this change simply affects the content of the written to the console output. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1896 - -### Change span attribute names in OpenTelemetry to be more consistent ([PR #1876](https://github.com/apollographql/router/pull/1876)) - -The span attributes in our OpenTelemetry tracing spans are corrected to be consistently namespaced with attributes that are compliant with the OpenTelemetry specification. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1876 - -### Have CI use rust-toolchain.toml and not install another redudant toolchain ([Issue #1313](https://github.com/apollographql/router/issues/1313)) - -Avoids redundant work in CI and makes the YAML configuration less mis-leading. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1877 - -### Query plan execution refactoring ([PR #1843](https://github.com/apollographql/router/pull/1843)) - -This splits the query plan execution in multiple modules to make the code more manageable. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1843 - -### Remove `Buffer` from APQ ([PR #1641](https://github.com/apollographql/router/pull/1641)) - -This removes `tower::Buffer` usage from the Automated Persisted Queries (APQ) implementation to improve reliability. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1641 - -### Remove `Buffer` from query deduplication ([PR #1889](https://github.com/apollographql/router/pull/1889)) - -This removes `tower::Buffer` usage from the query deduplication implementation to improve reliability. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1889 - -### Set MSRV to 1.63.0 ([PR #1886](https://github.com/apollographql/router/issues/1886)) - -We compile and test with 1.63.0 on CI at the moment, so it is our de-facto Minimum Supported Rust Version (MSRV). - -Setting [`rust-version`](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field) in `Cargo.toml` provides a more helpful error message when using an older version rather than unexpected compilation errors. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/issues/1886 - -# [1.0.0] - 2022-09-20 - -> **Note** -> -> 🤸 **We've reached our initial v1.0.0 release**. This project adheres to [Semantic Versioning v2.0.0](https://semver.org/spec/v2.0.0.html) and our future version numbers will follow the practices outlined in that specification. If you're updating from [`1.0.0-rc.2`](https://github.com/apollographql/router/releases/tag/v1.0.0-rc.2) there is **one breaking change** to the API that is unlikely to affect you. -> -> The migration steps from each pre-1.0 version will vary depending on which release you're coming from. To update from previous versions, you can consult the [Release Notes](https://github.com/apollographql/router/blob/v1.0.0/CHANGELOG.md) for whichever version you are running and work your way to v1.0.0. -> -> Our [documentation](https://www.apollographql.com/docs/router/) has been updated to match our current v1.x state. In general, if you run the Router with your existing configuration, you should receive output indicating any values which are no longer valid and find their v1.0.0 equivalent in the updated documentation, or by searching the [`CHANGELOG.md`](https://github.com/apollographql/router/blob/v1.0.0/CHANGELOG.md) for the prior configuration option to find when it changed. -> -> Lastly, thank you for all of your positive and constructive feedback in our pre-1.0 stages. If you encounter any questions or feedback while updating to v1.0.0, please search for or open a [GitHub Discussion](https://github.com/apollographql/router/discussions/) or file a [GitHub Issue](https://github.com/apollographql/router/issues/new/choose) if you find something working differently than it's documented. -> -> We're excited about the path ahead! 👐 - -## ❗ BREAKING ❗ - -### Removed `Request::from_bytes()` from public API ([Issue #1855](https://github.com/apollographql/router/issues/1855)) - -We've removed `Request::from_bytes()` from the public API. We were no longer using it internally and we hardly expect anyone external to have been relying on it so it was worth the remaining breaking change prior to v1.0.0. - -We discovered this function during an exercise of documenting our entire public API. While we considered keeping it, it didn't necessarily meet our requirements for shipping it in the public API. It's internal usage was removed in [`d147f97d`](https://github.com/apollographql/router/commit/d147f97d as part of [PR #429](https://github.com/apollographql/router/pull/429). - -We're happy to consider re-introducing this in the future (it even has a matching `Response::from_bytes()` which it composes against nicely!), but we thought it was best to remove it for the time-being. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/1858 - -## 🚀 Features - -### Reintroduce health check ([Issue #1861](https://github.com/apollographql/router/issues/1861)) - -We have re-introduced a health check at the `/health` endpoint on a dedicated port that is not exposed on the default GraphQL execution port (`4000`) but instead on port `8088`. **We recommend updating from the previous health-point suggestion by consulting our [health check configuration](https://www.apollographql.com/docs/router/configuration/health-checks/) documentation.** This health check endpoint will act as an "overall" health check for the Router and we intend to add separate "liveliness" and "readiness" checks on their own dedicated endpoints (e.g., `/health/live` and `/health/ready`) in the future. At that time, this root `/health` check will aggregate all other health checks to provide an overall health status however, today, it is simply a "liveliness" check and we have not defined "readiness". We also intend to use port `8088` for other ("internal") functionality in the future, keeping the GraphQL execution endpoint dedicated to serving external client requests. - -As for some additional context as to why we've brought it back so quickly: We had previously removed the health check we had been offering in [PR #1766](https://github.com/apollographql/router/pull/1766) because we wanted to do some additional configurationd design and lean into a new "admin port" (`8088`). As a temporary solution, we offered the instruction to send a `GET` query to the Router with a GraphQL query. After some new learnings and feedback, we've had to re-visit that conversation earlier than we expected! - -Due to [default CSRF protections](https://www.apollographql.com/docs/router/configuration/csrf/) enabled in the Router, `GET` requests need to be accompanied by certain HTTP headers in order to disqualify them as being [CORS-preflightable](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request) requests. While sending the additional header was reasonable straightforward in Kubernetes, other environments (including Google Kubernetes Engine's managed load balancers) didn't offer the ability to send those necessary HTTP headers along with their `GET` queries. So, the `/health` endpoint is back. - -The health check endpoint is now exposed on `127.0.0.1:8088/health` by default, and its `listen` socket address can be changed in the YAML configuration: - -```yaml -health-check: - listen: 127.0.0.1:8088 # default - enabled: true # default -``` - -The previous health-check suggestion (i.e., `GET /?query={__typename}`) will still work, so long as your infrastructure supports sending custom HTTP headers with HTTP requests. Again though, we recommend updating to the new health check. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1859 - -## 🐛 Fixes - -### Remove `apollo_private` and OpenTelemetry entries from logs ([Issue #1862](https://github.com/apollographql/router/issues/1862)) - -This change removes some `apollo_private` and OpenTelemetry (e.g., `otel.kind`) fields from the logs. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1868 - -### Update and validate `Dockerfile` files ([Issue #1854](https://github.com/apollographql/router/issues/1854)) - -Several of the `Dockerfile`s in the repository were out-of-date with respect to recent configuration changes. We've updated the configuration files and extended our tests to catch this automatically in the future. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1857 - -## 🛠 Maintenance - -### Disable Deno snapshotting when building inside `docs.rs` - -This works around [V8 linking errors](https://docs.rs/crate/apollo-router/1.0.0-rc.2/builds/633287) and caters to specific build-environment constraints and requirements that exist on the Rust documentation site `docs.rs`. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1847 - -### Add the Studio Uplink schema to the repository, with a test checking that it is up to date. - -Previously we were downloading the Apollo Studio Uplink schema (which is used for fetching Managed Federation schema updates) at compile-time, which would [fail](https://docs.rs/crate/lets-see-if-this-builds-on-docs-rs/0.0.1/builds/633305) in build environments without Internet access, like `docs.rs`' build system. - -If an update is needed, the test failure will print a message with the command to run. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1847 - -# [1.0.0-rc.2] - 2022-09-20 - -## 🐛 Fixes - -### Update `apollo-parser` to v0.2.11 ([PR #1841](https://github.com/apollographql/router/pull/1841)) - -Fixes error creation for missing selection sets in named operation definitions by updating to `apollo-rs`'s [`apollo-parser` v0.2.11](https://crates.io/crates/apollo-parser/0.2.11). - -By [@lrlna](https://github.com/lrlna) in https://github.com/apollographql/router/pull/1841 - -### Fix router scaffold version ([Issue #1836](https://github.com/apollographql/router/issues/1836)) - -Add `v` prefix to the package version emitted in our [scaffold tooling](https://www.apollographql.com/docs/router/customizations/custom-binary/) when a published version of the crate is available. This results in packages depending (appropriately, we would claim!) on our published Cargo crates, rather than Git references to the repository. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1838 - -### Fixed `extraVolumeMounts` in Helm charts ([Issue #1824](https://github.com/apollographql/router/issues/1824)) - -Correct a case in our Helm charts where `extraVolumeMounts` was not be being filled into the deployment template correctly. - -By [@LockedThread](https://github.com/LockedThread) in https://github.com/apollographql/router/pull/1831 - -### Do not fill in a skeleton object when canceling a subgraph request ([Discussion #1377](https://github.com/apollographql/router/discussions/1377#discussioncomment-3655967)) - -Given a supergraph with multiple subgraphs `USER` and `ORGA`, like [this example supergraph](https://github.com/apollographql/router/blob/d0a02525c670e4317586100a31fdbdcd95c6ef07/apollo-router/src/services/supergraph_service.rs#L586-L623), if a query spans multiple subgraphs, like this: - -```graphql -query { - currentUser { # USER subgraph - activeOrganization { # ORGA subgraph - id - creatorUser { - name - } - } - } -} -``` - -...when the `USER` subgraph returns `{"currentUser": { "activeOrganization": null }}`, then the request to the `ORGA` subgraph -should be _cancelled_ and no data should be generated. This was not occurring since the query planner was incorrectly creating an object at the target path. This is now corrected. - -This fix also improves the internal usage of mocked subgraphs with `TestHarness`. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1819 - -### Default conditional `@defer` condition to `true` ([Issue #1820](https://github.com/apollographql/router/issues/1820)) - -According to recent updates in the `@defer` specification, defer conditions must default to `true`. This corrects a bug where that default value wasn't being initialized properly. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1832 - -### Support query plans with empty primary subselections ([Issue #1800](https://github.com/apollographql/router/issues/1800)) - -When a query with `@defer` would result in an empty primary response, the router was returning -an error in interpreting the query plan. It is now using the query plan properly, and detects -more precisely queries containing `@defer`. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1778 - -## 🛠 Maintenance - -### Add more compilation gates to hide noisy warnings ([PR #1830](https://github.com/apollographql/router/pull/1830)) - -Add more gates (for the `console` feature introduced in [PR #1632](https://github.com/apollographql/router/pull/1632)) to not emit compiler warnings when using the `--all-features` flag. (See original PR for more details on the flag usage.) - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1830 - -### Deny `panic`, `unwrap` and `expect` in the spec module ([PR #1844](https://github.com/apollographql/router/pull/1844)) - -We are generally working to eliminate `unwrap()` and `expect()` statements from critical paths in the codebase and have done so on the `spec` module. The `spec` module, in particular, is reached after parsing has occurred so any invariants expressed by these `expect`s would have already been enforced or validated. Still, we've decided to tighten things even further, by raising errors instead to provide end-users with even more stability. - -To further defend against re-introduction, the `spec` module now has linting annotations that prevent its content from using any code that explicitly panics. - -```rust -#![deny(clippy::unwrap_used)] -#![deny(clippy::expect_used)] -#![deny(clippy::panic)] -``` - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1844 - -### Remove potential panics from query plan execution ([PR #1842](https://github.com/apollographql/router/pull/1842)) - -Some remaining parts of the query plan execution code were using `expect()`, `unwrap()` and `panic()` to guard against assumptions -about data. These conditions have been replaced with errors which will returned in the response preventing the possibility of panics in these code paths. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1842 - -# [1.0.0-rc.1] - 2022-09-16 - -> **Note** -> We're almost to 1.0! We've got a couple relatively small breaking changes to the configuration for this release (none to the API) that should be relatively easy to adapt to and a number of bug fixes and usability improvements. - -## ❗ BREAKING ❗ - -### Change `headers` propagation configuration ([PR #1795](https://github.com/apollographql/router/pull/1795)) - -While it wasn't necessary today, we want to avoid a necessary breaking change in the future by proactively making room for up-and-coming work. We've therefore introduced another level into the `headers` configuration with a `request` object, to allow for a `response` (see [Issue #1284](https://github.com/apollographql/router/issues/1284)) to be an _additive_ feature after 1.0. - -A rough look at this should just be a matter of adding in `request` and indenting everything that was inside it: - -```patch -headers: - all: -+ request: - - remove: - named: "test" -``` - -The good news is that we'll have `response` in the future! For a full set of examples, please see the [header propagation documentation](https://www.apollographql.com/docs/router/configuration/header-propagation/). - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1795 - -### Bind the Sandbox on the same endpoint as the Supergraph, again ([Issue #1785](https://github.com/apollographql/router/issues/1785)) - -We have rolled back an addition that we released in this week's `v1.0.0-rc.0` which allowed Sandbox (an HTML page that makes requests to the `supergraph` endpoint) to be on a custom socket. In retrospect, we believe it was premature to make this change without considering the broader impact of this change which ultimately touches on CORS and some developer experiences bits. Practically speaking, we may not want to introduce this because it complicates the model in a number of ways. - -For the foreseeable future, Sandbox will continue to be on the same listener address as the `supergraph` listener. - -It's unlikely anyone has really leaned into this much already, but if you've already re-configured `sandbox` or `homepage` to be on a custom `listen`-er and/or `path` in `1.0.0-rc.0`, here is a diff of what you should remove: - -```diff -sandbox: -- listen: 127.0.0.1:4000 -- path: / - enabled: false -homepage: -- listen: 127.0.0.1:4000 -- path: / - enabled: true -``` - -Note this means you can either enable the `homepage`, or the `sandbox`, but not both. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1796 - -## 🚀 Features - -### Automatically check "Return Query Plans from Router" checkbox in Sandbox ([Issue #1803](https://github.com/apollographql/router/issues/1803)) - -When loading Sandbox, we now automatically configure it to toggle the "Request query plans from Router" checkbox to the enabled position which requests query plans from the Apollo Router when executing operations. These query plans are displayed in the Sandbox interface and can be seen by selecting "Query Plan Preview" from the drop-down above the panel on the right side of the interface. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/1804 - -## 🐛 Fixes - -### Fix `--dev` mode when no configuration file is specified ([Issue #1801](https://github.com/apollographql/router/issues/1801)) ([Issue #1802](https://github.com/apollographql/router/issues/1802)) - -We've reconciled an issue where the `--dev` mode flag was being ignored when running the router without a configuration file. (While many use cases do require a configuration file, the Router actually doesn't _need_ a confguration in many cases!) - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1808 - -### Respect `supergraph`'s `path` for Kubernetes deployment probes ([Issue #1787](https://github.com/apollographql/router/issues/1787)) - -If you've configured the `supergraph`'s `path` property using the Helm chart, the liveness -and readiness probes now utilize these correctly. This fixes a bug where they continued to use the _default_ path of `/` and resulted in a startup failure. - -By [@damienpontifex](https://github.com/damienpontifex) in https://github.com/apollographql/router/pull/1788 - -### Get variable default values from the query for query plan condition nodes ([PR #1640](https://github.com/apollographql/router/issues/1640)) - -The query plan condition nodes, generated by the `if` argument of the `@defer` directive, were -not using the default value of the variable passed in as an argument. - -This _also_ fixes _default value_ validations for non-`@defer`'d queries. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1640 - -### Correctly hot-reload when changing the `supergraph`'s `listen` socket ([Issue #1814](https://github.com/apollographql/router/issues/1814)) - -If you change the `supergraph`'s `listen` socket while in `--hot-reload` mode, the Router will now correctly pickup the change and bind to the new socket. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1815 - -## 🛠 Maintenance - -### Improve error message when querying non existent field ([Issue #1816](https://github.com/apollographql/router/issues/1816)) - -When querying a non-existent field you will get a better error message: - -```patch -{ - "errors": [ - { -- "message": "invalid type error, expected another type than 'Named type Computer'" -+ "message": "Cannot query field \"xxx\" on type \"Computer\"" - } - ] -} -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1817 - -### Update `apollo-router-scaffold` to use the published `apollo-router` crate [PR #1782](https://github.com/apollographql/router/pull/1782) - -Now that `apollo-router` is released on [crates.io](https://crates.io/crates/apollo-router), we have updated the project scaffold to rely on the published crate instead of Git tags. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1782 - -### Refactor `Configuration` validation ([Issue #1791](https://github.com/apollographql/router/issues/1791)) - -Instantiating `Configuration`s is now fallible, because it will run consistency checks on top of the already run structure checks. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1794 - -### Refactor response-formatting tests ([Issue #1798](https://github.com/apollographql/router/issues/1798)) - -Rewrite the response-formatting tests to use a builder pattern instead of macros and move the tests to a separate file. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1798 - -## 📚 Documentation - -### Add `rustdoc` documentation to various modules ([Issue #799](https://github.com/apollographql/router/issues/799)) - -Adds documentation for: - -- `apollo-router/src/layers/instrument.rs` -- `apollo-router/src/layers/map_first_graphql_response.rs` -- `apollo-router/src/layers/map_future_with_request_data.rs` -- `apollo-router/src/layers/sync_checkpoint.rs` -- `apollo-router/src/plugin/serde.rs` -- `apollo-router/src/tracer.rs` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1792 - -### Fixed `docs.rs` publishing error from our last release - -During our last release we discovered for the first time that our documentation wasn't able to compile on the [docs.rs](https://docs.rs) website, leaving our documentation in a [failed state](https://docs.rs/crate/apollo-router/1.0.0-rc.0/builds/629200). - -While we've reconciled _that particular problem_, we're now being affected by [this](https://docs.rs/crate/router-bridge/0.1.7/builds/629895) internal compiler errors (ICE) that [is affecting](https://github.com/rust-lang/rust/issues/101844) anyone using `1.65.0-nightly` builds circa today. Since docs.rs uses `nightly` for all builds, this means it'll be a few more days before we're published there. - -With thanks to [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/federation-rs/pull/185 - -# [1.0.0-rc.0] - 2022-09-14 - -## ❗ BREAKING ❗ - -> **Note** -> We are entering our release candidate ("RC") stage and expect this to be the last of our breaking changes. Overall, most of the breaking changes in this release revolve around three key factors which were motivators for most of the changes: -> -> 1. Having **safe and security defaults** which are suitable for production -> 2. Polishing our YAML configuration ergonomics and patterns -> 3. The introduction of a development mode activated with the `--dev` flag -> -> See the full changelog below for details on these (including the "Features" section for the `--dev` changes!) - -### Adjusted socket ("listener") addresses for more secure default behaviors - -- The Router will not listen on "all interfaces" in its default configuration (i.e., by binding to `0.0.0.0`). You may specify a specific socket by specifying the `interface:port` combination. If you desire behavior which binds to all interfaces, your configuration can specify a socket of `0.0.0.0:4000` (for port `4000` on all interfaces). -- By default, Prometheus (if enabled) no longer listens on the same socket as the GraphQL socket. You can change this behavior by binding it to the same socket as your GraphQL socket in your configuration. -- The health check endpoint is no longer available on the same socket as the GraphQL endpoint (In fact, the health check suggestion has changed in ways that are described elsewhere in this release's notes. Please review them separately!) - -### Safer out-of-the box defaults with `sandbox` and `introspection` disabled ([PR #1748](https://github.com/apollographql/router/pull/1748)) - -To reflect the fact that it is not recomended to have introspection on in production (and since Sandbox uses introspection to power its development features) the `sandbox` and `introspection` configuration are now **disabled unless you are running the Router with `--dev`**. - -If you would like to force them on even when outside of `--dev` mode, you can set them to `true` explicitly in your YAML configuration: - -```yaml -sandbox: - enabled: true -supergraph: - introspection: true -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1748 - -### Landing page ("home page") replaces Sandbox in default "production" mode ([PR #1768](https://github.com/apollographql/router/pull/1768)) - -As an extension of Sandbox and Introspection being disabled by default (see above), the Router now displays a simple landing page when running in its default mode. When you run the Apollo Router with the new `--dev` flag (see "Features" section below) you will still see the existing "Apollo Studio Sandbox" experience. - -We will offer additional options to customize the landing page in the future but for now you can disable the homepage entirely (leaving a _very_ generic page with a GraphQL message) by disabling the homepage entirely in your configuration: - -```yaml -homepage: - enabled: false -``` - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1768 - -### Listeners, paths and paths can be configured individually ([Issue #1500](https://github.com/apollographql/router/issues/1500)) - -It is now possible to individually configure the following features' socket/listener addresses (i.e., the IP address and port) in addition to the URL path: - -- GraphQL execution (default: `http://127.0.0.1:4000/`) -- Sandbox (default when using `--dev`: `http://127.0.0.1:4000/`) -- Prometheus (default when enabled: `http://127.0.0.1:9090/metrics`) - -Examples of how to configure these can be seen in the YAML configuration overhaul section of this changelog (just below) as well as in our documentation. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1718 - -### Overhaul/reorganization of YAML configuration ([#1500](https://github.com/apollographql/router/issues/1500)) - -To facilitate the changes in the previous bullet-points, we have moved configuration parameters which previously lived in the `server` section to new homes in the configuration, including `listen`, `graphql_path`, `landing_page`, and `introspection`. Additionally, `preview_defer_support` has moved, but is on by default and no longer necessary to be set explicitly unless you wish to disable it. - -As another section (below) notes, we have *removed* the health check and instead recommend users to configure their health checks (in, e.g, Kubernetes, Docker, etc.) to use a simple GraphQL query: `/?query={__typename}`. Read more about that in the other section, however this is reflected by its removal in the configuration. - -To exemplify the changes, this previous configuration will turn into the configuration that follows it: - -#### Before - -```yaml -server: - listen: 127.0.0.1:4000 - graphql_path: /graphql - health_check_path: /health # Health check has been deprecated. See below. - introspection: false - preview_defer_support: true - landing_page: true -telemetry: - metrics: - prometheus: - enabled: true -``` - -#### After - -```yaml -# This section is just for Sandbox configuration -sandbox: - listen: 127.0.0.1:4000 - path: / - enabled: false # Disabled by default, but on with `--dev`. - -# This section represents general supergraph GraphQL execution -supergraph: - listen: 127.0.0.1:4000 - path: / - introspection: false - # Can be removed unless it needs to be set to `false`. - preview_defer_support: true - -# The health check has been removed. See the section below in the CHANGELOG -# for more information on how to configure health checks going forward. - -# Prometheus scraper endpoint configuration -# The `listen` and `path` are not necessary if `127.0.0.1:9090/metrics` is okay -telemetry: - metrics: - prometheus: - listen: 127.0.0.1:9090 - path: /metrics - enabled: true -``` - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1718 - -### Environment variable expansion adjustments ([#1759](https://github.com/apollographql/router/issues/1759)) - -- Environment expansions **must** be prefixed with `env.`. -- File expansions **must** be prefixed with `file.`. -- The "default" designator token changes from `:` to `:-`. For example: - - `${env.USER_NAME:Nandor}` => `${env.USER_NAME:-Nandor}` - -- Failed expansions now result in an error - - Previously expansions that failed due to missing environment variables were silently skipped. Now they result in a configuration error. Add a default value using the above syntax if optional expansion is needed. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/1763 - -### Dedicated health check endpoint removed with new recommendation to use `/query={__typename}` query ([Issue #1765](https://github.com/apollographql/router/issues/1765)) - -We have *removed* the dedicated health check endpoint and now recommend users to configure their health checks (in, e.g, Kubernetes, Docker) to use a simple GraphQL query instead. - -Use the following query with a `content-type: application/json` header as a health check instead of `/.well-known/apollo/server-health`: - -``` -/?query={__typename} -``` - -The [Kubernetes documentation and related Helm charts](https://www.apollographql.com/docs/router/containerization/kubernetes) have been updated to reflect this change. - -Using this query has the added benefit of *actually testing GraphQL*. If this query returns with an HTTP 200 OK, it is just as reliable (and even more meaningful) than the previous `/.well-known/apollo/server-health` endpoint. It's important to include the `content-type: application/json` header to satisfy the Router's secure requirements that offer CSRF protections. - -In the future, we will likely reintroduce a dedicated health check "liveliness" endpoint along with a meaningful "readiness" health check at the same time. In the meantime, the query above is technically more durable than the health check we offered previously. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/TODO - -### Promote `include_subgraph_errors` out of "experimental" status ([Issue #1773](https://github.com/apollographql/router/issues/1773)) - -The `include_subraph_errors` plugin has been promoted out of "experimental" and will require a small configuration changes. For example: - -```diff --plugins: -- experimental.include_subgraph_errors: -- all: true # Propagate errors from all subraphs -- subgraphs: -- products: false # Do not propagate errors from the products subgraph -+include_subgraph_errors: -+ all: true # Propagate errors from all subraphs -+ subgraphs: -+ products: false # Do not propagate errors from the products subgraph - ``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1776 - -### `apollo-spaceport` and `uplink` are now part of `apollo-router` ([Issue #491](https://github.com/apollographql/router/issues/491)) - -Instead of being dependencies, they are now part of the `apollo-router` crate. They were not meant to be used independently. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1751 - -### Remove over-exposed functions from the public API ([PR #1746](https://github.com/apollographql/router/pull/1746)) - -The following functions are only required for router implementation, so removing from external API: - -``` -subgraph::new_from_response -supergraph::new_from_response -supergraph::new_from_graphql_response -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1746 - -### Span `client_name` and `client_version` attributes renamed ([#1514](https://github.com/apollographql/router/issues/1514)) - -OpenTelemetry attributes should be grouped by `.` rather than `_`, therefore the following attributes have changed: - -* `client_name` => `client.name` -* `client_version` => `client.version` - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/1514 - -### Otel configuration updated to use expansion ([#1772](https://github.com/apollographql/router/issues/1772)) - -File and env access in configuration now use the generic expansion mechanism introduced in [#1759](https://github.com/apollographql/router/issues/1759). - -```yaml - grpc: - key: - file: "foo.txt" - ca: - file: "bar.txt" - cert: - file: "baz.txt" -``` - -Becomes: -```yaml - grpc: - key: "${file.foo.txt}" - ca: "${file.bar.txt}" - cert: "${file.baz.txt}" -``` -or -```yaml - grpc: - key: "${env.FOO}" - ca: "${env.BAR}" - cert: "${env.BAZ}" -``` - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/1774 - -## 🚀 Features - -### Adds a development mode that can be enabled with the `--dev` flag ([#1474](https://github.com/apollographql/router/issues/1474)) - -By default, the Apollo Router is configured with production best-practices. When developing, it is often desired to have some of those features relaxed to make it easier to iterate. A `--dev` flag has been introduced to make the user experience easier while maintaining a default configuration which targets a productionized environment. - -The `--dev` mode will enable a few options _for development_ which are not normally on by default: - -- The Apollo Sandbox Explorer will be served instead of the Apollo Router landing page, allowing you to run queries against your development Router. -- Introspection will be enabled, allowing client tooling (and Sandbox!) to obtain the latest version of the schema. -- Hot-reloading of configuration will be enabled. (Also available with `--hot-reload` when running without `--dev`) -- It will be possible for Apollo Sandbox Explorer to request a query plan to be returned with any operations it executes. These query plans will allow you to observe how the operation will be executed against the underlying subgraphs. -- Errors received from subgraphs will not have their contents redacted to facilitate debugging. - -Additional considerations will be made in the future as we introduce new features that might necessitate a "development" workflow which is different than the default mode of operation. We will try to minimize these differences to avoid surprises in a production deployment while providing an execellent development experience. In the future, the (upcoming) `rover dev` experience will become our suggested pattern, but this should serve the purpose in the near term. - -By [@bnjjj](https://github.com/bnjjj) and [@EverlastingBugstopper](https://github.com/EverlastingBugstopper) and [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/1748 - -### Apollo Studio Federated Tracing ([#1514](https://github.com/apollographql/router/issues/1514)) - -Add support of [federated tracing](https://www.apollographql.com/docs/federation/metrics/) in Apollo Studio: - -```yaml -telemetry: - apollo: - # The percentage of requests will include HTTP request and response headers in traces sent to Apollo Studio. - # This is expensive and should be left at a low value. - # This cannot be higher than tracing->trace_config->sampler - field_level_instrumentation_sampler: 0.01 # (default) - - # Include HTTP request and response headers in traces sent to Apollo Studio - send_headers: # other possible values are all, only (with an array), except (with an array), none (by default) - except: # Send all headers except referer - - referer - - # Send variable values in Apollo in traces sent to Apollo Studio - send_variable_values: # other possible values are all, only (with an array), except (with an array), none (by default) - except: # Send all variable values except for variable named first - - first - tracing: - trace_config: - sampler: 0.5 # The percentage of requests that will generate traces (a rate or `always_on` or `always_off`) -``` - -By [@BrynCooke](https://github.com/BrynCooke) & [@bnjjj](https://github.com/bnjjj) & [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1514 - -### Provide access to the supergraph SDL from rhai scripts ([Issue #1735](https://github.com/apollographql/router/issues/1735)) - -There is a new global constant `apollo_sdl` which can be use to read the -supergraph SDL as a string. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1737 - -### Add support for `tokio-console` ([PR #1632](https://github.com/apollographql/router/issues/1632)) - -To aid in debugging the router, this adds support for [tokio-console](https://github.com/tokio-rs/console), enabled by a Cargo feature. - -To run the router with tokio-console, build it with `RUSTFLAGS="--cfg tokio_unstable" cargo run --features console`. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1632 - -### Restore the ability to specify custom schema and configuration sources ([#1733](https://github.com/apollographql/router/issues/1733)) - -You may now, once again, specify custom schema and config sources when constructing an executable. We had previously omitted this behavior in our API pruning with the expectation that it was still possible to specify via command line arguments and we almost immediately regretted it. We're happy to have it back! - -```rust -Executable::builder() - .shutdown(ShutdownSource::None) - .schema(SchemaSource::Stream(schemas)) - .config(ConfigurationSource::Stream(configs)) - .start() - .await -``` -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/1734 - -### Environment variable expansion prefixing ([#1759](https://github.com/apollographql/router/issues/1759)) - -The environment variable `APOLLO_ROUTER_CONFIG_ENV_PREFIX` can be used to prefix environment variable lookups during configuration expansion. This feature is undocumented and unsupported and may change at any time. **We do not recommend using this.** - -For example: - -`APOLLO_ROUTER_CONFIG_ENV_PREFIX=MY_PREFIX` - -Would cause: -`${env.FOO}` to be mapped to `${env.MY_PREFIX_FOO}` when expansion is performed. - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/1763 - -### Environment variable expansion mode configuration ([#1772](https://github.com/apollographql/router/issues/1772)) - -The environment variable `APOLLO_ROUTER_CONFIG_SUPPORTED_MODES` can be used to restrict which modes can be used for environment expansion. This feature is undocumented and unsupported and may change at any time. **We do not recommend using this.** - -For example: - -`APOLLO_ROUTER_CONFIG_SUPPORTED_MODES=env,file` env and file expansion -`APOLLO_ROUTER_CONFIG_SUPPORTED_MODES=env` - only env variable expansion allowed - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/1774 - - -## 🐛 Fixes - -### Support execution of the bare `__typename` field ([Issue #1761](https://github.com/apollographql/router/issues/1761)) - -For queries like `query { __typename }`, we now perform the expected behavior and return a GraphQL response even if the introspection has been disabled. (`introspection: false` should only apply to _schema introspeciton_ **not** _type-name introspection_.) - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1762 - -### Set `hasNext` for the last chunk of a deferred response ([#1687](https://github.com/apollographql/router/issues/1687) [#1745](https://github.com/apollographql/router/issues/1745)) - -There will no longer be an empty last response `{"hasNext": false}` and the `hasNext` field will be set on the last deferred response. There can still be one edge case where that empty message can occur, if some deferred queries were cancelled too quickly. Generally speaking, clients should expect this to happen to allow future behaviors and this is specified in the `@defer` draft specification. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1687 -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1745 - -## 🛠 Maintenance - -### Add errors vec in `QueryPlannerResponse` to handle errors in `query_planning_service` ([PR #1504](https://github.com/apollographql/router/pull/1504)) - -We changed `QueryPlannerResponse` to: - -- Add a `Vec` -- Make the query plan optional, so that it is not present when the query planner encountered a fatal error. Such an error would be in the `Vec` - -This should improve the messages returned during query planning. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1504 - -### Store the Apollo usage reporting Protobuf interface file in the repository - -Previously this file was downloaded when compiling the Router, but we had no good way to automatically check when to re-download it without causing the Router to be compiled all the time. - -Instead a copy now resides in the repository, with a test checking that it is up to date. This file can be updated by running this command then sending a PR: - -``` -curl -f https://usage-reporting.api.apollographql.com/proto/reports.proto \ - > apollo-router/src/spaceport/proto/reports.proto -``` - -By [@SimonSapin](https://github.com/SimonSapin) - -### Disable compression on `multipart/mixed` HTTP responses ([Issue #1572](https://github.com/apollographql/router/issues/1572)) - -The Router now reverts to using unpatched `async-compression`, and instead disables compression of multipart responses. We aim to re-enable compression soon, with a proper solution that is being designed in . - -As context to why we've made this change: features such as `@defer` require the Apollo Router to send a stream of multiple GraphQL responses in a single HTTP response with the body being a single byte stream. Due to current limitations with our upstream compression library, that entire byte stream is compressed as a whole, which causes the entire deferred response to be held back before being returned. This obviously isn't ideal for the `@defer` feature which tries to get reponses to client soon possible. - -This change replaces our previous work-around which involved a patched `async-compression`, which was not trivial to apply when using the Router as a dependency since [Cargo patching](https://doc.rust-lang.org/cargo/reference/overriding-dependencies.html) is done in a project’s root `Cargo.toml`. - -Again, we aim to re-visit this as soon as possible but found this to be the more approachable work-around. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1749 - -# [1.0.0-alpha.3] - 2022-09-07 - -## ❗ BREAKING ❗ - -### Unified supergraph and execution response types ([PR #1708](https://github.com/apollographql/router/pull/1708)) - -`apollo_router::services::supergraph::Response` and `apollo_router::services::execution::Response` were two structs with identical fields and almost-identical methods. The main difference was that builders were fallible for the former but not the latter. - -They are now the same type (with one location a `type` alias of the other), with fallible builders. Callers may need to add either a operator `?` (in plugins) or an `.unwrap()` call (in tests). - -```diff - let response = execution::Response::builder() - .error(error) - .status_code(StatusCode::BAD_REQUEST) - .context(req.context) -- .build(); -+ .build()?; -``` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1708 - -### Rename `originating_request` to `supergraph_request` on various plugin `Request` structures ([Issue #1713](https://github.com/apollographql/router/issues/1713)) - -We feel that `supergraph_request` makes it more clear that this is the request received from the client. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1715 - -### Prometheus is no longer defaulting to the GraphQL endpoint and listener address ([Issue #1645](https://github.com/apollographql/router/issues/1645)) - -The Router's Prometheus interface is now exposed at `127.0.0.1:9090/metrics`, rather than `http://0.0.0.0:4000/plugins/apollo.telemetry/prometheus`. This should be both more secure and also more generally compatible with the default settings that Prometheus expects (which also uses port `9090` and just `/metrics` as its defaults). - -To expose to a non-localhost interface, it is necessary to explicitly opt-into binding to a socket address of `0.0.0.0:9090` (i.e., all interfaces on port 9090) or a specific available interface (e.g., `192.168.4.1`) on the host. - -Have a look at the _Features_ section (below) to learn how to customize the listen address and the path. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1654 - -## 🚀 Features - -### New plugin helper: `map_first_graphql_response` ([Issue #1564](https://github.com/apollographql/router/issues/1564)) - -In supergraph and execution services, the service response contains not just one GraphQL response but a stream of them, in order to support features such as `@defer`. - -This new method of `ServiceExt` and `ServiceBuilderExt` in `apollo_router::layers` wraps a service and calls a `callback` when the first GraphQL response in the stream returned by the inner service becomes available. The callback can then access the HTTP parts (headers, status code, etc) or the first GraphQL response before returning them. - -See the doc-comments in `apollo-router/src/layers/mod.rs` for more. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1708 - -### Users can customize the Prometheus listener address and URL path ([Issue #1645](https://github.com/apollographql/router/issues/1645)) - -You can now customize the Prometheus listener socket address and URL path in your YAML configuration: - -```yaml -telemetry: - metrics: - prometheus: - listen: 127.0.0.1:9090 # default - path: /metrics # default - enabled: true -``` - -By [@o0Ignition0o](https://github.com/@o0Ignition0o) in https://github.com/apollographql/router/pull/1654 - -### Add an `apollo_router::graphql::ResponseStream` type alias ([PR #1697](https://github.com/apollographql/router/pull/1697)) - -It is equivalent to `BoxStream<'static, graphql::Response>` and makes -some type signatures slightly simpler. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1697 - -## 🐛 Fixes - -### Fix metrics duration for router request ([#1705](https://github.com/apollographql/router/issues/1705)) - -With the introduction of `BoxStream` for `@defer` we introduced a bug when computing HTTP request duration metrics where we failed to wait for the first response in the `BoxStream`. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1705 - -### Numerous fixes to preview `@defer` query planning ([Issue #1698](https://github.com/apollographql/router/issues/1698)) - -Updated to [Federation `2.1.2-alpha.0`](https://github.com/apollographql/federation/pull/2132) which brings in a number of fixes for the preview `@defer` support. These fixes include: - - - [Empty selection set produced with @defer'd query `federation#2123`](https://github.com/apollographql/federation/issues/2123) - - [Include directive with operation argument errors out in Fed 2.1 `federation#2124`](https://github.com/apollographql/federation/issues/2124) - - [query plan sequencing affected with __typename in fragment `federation#2128`](https://github.com/apollographql/federation/issues/2128) - - [Router Returns Error if __typename Omitted `router#1668`](https://github.com/apollographql/router/issues/1668) - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/1711 - -# [1.0.0-alpha.2] - 2022-09-06 - -## 🚀 Features - -### Add `service_name` and `service_namespace` in `telemetry.metrics.common` ([Issue #1490](https://github.com/apollographql/router/issues/1490)) - -Add `service_name` and `service_namespace` in `telemetry.metrics.common` to reflect the same configuration than tracing. - -```yaml -telemetry: - metrics: - common: - # (Optional, default to "apollo-router") Set the service name to easily find metrics related to the apollo-router in your metrics dashboards - service_name: "apollo-router" - # (Optional) - service_namespace: "apollo" -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1492 - -## 🐛 Fixes - -### Fix distributed tracing header propagation ([#1701](https://github.com/apollographql/router/issues/1701)) - -Span context is now correctly propagated if you're trying to propagate tracing context to the router. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1701 - -## 🛠 Maintenance - -### Replace `startup` crate with `ctor` crate ([#1704](https://github.com/apollographql/router/issues/1703)) - -At startup, the router registers plugins. The crate we used to use ([`startup`](https://crates.io/crates/startup/versions)) has been yanked from crates.io and archived on GitHub. We're unsure why the package was yanked, but we've decided to move to the [`ctor`](https://crates.io/crates/ctor) crate, which is more widely adopted and maintained. - -This should fix the sudden errors for those who were using the router as a library or attempting to scaffold a new plugin using `cargo scaffold`. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1704 - -### macOS: Update Xcode build version from 11.7 to 13.4 ([PR #1702](https://github.com/apollographql/router/pull/1702)) - -We now build our macOS binaries with Xcode 13.4 rather than 11.7. This may result in the Router not working on very old versions of macOS but we'd rather get this out of the way before CircleCI potentially deprecates 11.x images themselves and we're unable to test on them anymore. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/1702 - - -# [1.0.0-alpha.1] - 2022-09-02 - -> 👋 We're getting closer to our release candidate stages so there are far less breaking changes to the API in this version, rather changes to configuration. We'll have a bit more in the next release, but nothing as bad as the bumps from 0.15.x, through 0.16.0 and on to v1.0.0-alpha.0 - -## ❗ BREAKING ❗ - -### Preserve plugin response `Vary` headers ([PR #1660](https://github.com/apollographql/router/issues/1297)) - -It is now possible to set a `Vary` header in a client response from a plugin. - -> Note: This is a breaking change because the prior behaviour provided three default `Vary` headers and we've had to drop those to enable this change. If, after all plugin processing, there is no `Vary` header, the router will add one with a value of "`origin`", as is best-practice for cache control headers with CORS. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1660 - -### Fix the supported defer specification version to `20220824` ([PR #1652](https://github.com/apollographql/router/issues/1652)) - -Since the router will ship before the `@defer` specification is done, we add a parameter to the `Accept` and `Content-Type` headers to indicate which specification version is accepted. - -The specification is fixed to [graphql/graphql-spec@01d7b98](https://github.com/graphql/graphql-spec/commit/01d7b98f04810c9a9db4c0e53d3c4d54dbf10b82) - -The router will now return a response with the status code `406 Not Acceptable` if the `Accept` header does not match. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1652 - -### Change default enablement and promote `experimental_defer_support` to `preview_defer_support` ([PR #1673](https://github.com/apollographql/router/issues/1673)) - -Following up on a tremendous amount of work tracked in https://github.com/apollographql/router/issues/80 - which brought various stages of `@defer` support to the Router - this changes our designation of its status from "Experimental" to "Preview". It's worth noting that the `@defer` specification has *just* graduated to "Stage 2 (Draft)" mode in the GraphQL Working Group, so changes may still be expected and there are two stages ahead. To help things progress: - -- We've lifted the previous requirement that users opt into defer support by setting `experimental_defer_support: true` in the `server` section of their configuration. It is now on by default. -- The option is now called `preview_defer_support` and it can be set to `false` to _specifically_ opt out of it existing at all. This might be desired if you would prefer that it not even show up in introspection or be possible to use even if a client requests it. -- Using `@defer` support _requires_ clients set the appropriate HTTP `accept` header to use it. This puts the burden of understanding the risks of an early-preview on the clients who will need to consume the Router's responses. This is particularly important for clients who have long-lived support requirements (like native mobile apps). - - To see which headers are required, see https://github.com/apollographql/router/issues/1648. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/1685 - -## 🚀 Features - -### Return an error when nullifying a non-null field ([Issue #1304](https://github.com/apollographql/router/issues/1304)) - -Nullability rules may remove parts of the response without indicating why. Error messages now indicate which part of the response triggered nullability rules. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1537 - -### router now provides TraceId ([PR #1663](https://github.com/apollographql/router/issues/1536)) - -If you need a reliable way to link together the various stages of pipeline processing, you can now use - -```rust -apollo_router::tracer::TraceId::new() -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1663 - -## 🐛 Fixes - -### Docker images: Use absolute path for `ENTRYPOINT` ([PR #1684](https://github.com/apollographql/router/pull/1684)) - -This restores the absolute path in `ENTRYPOINT` in our `Dockerfile`s (and published images) to allow users to change their working directory without consequence (and without needing to change it back to `/dist` or override the `entrypoint`). - -By [@110y](https://github.com/110y) in https://github.com/apollographql/router/pull/1684 - -### Update our helm documentation to illustrate how to use our registry ([#1643](https://github.com/apollographql/router/issues/1643)) - -Updated documentation for helm charts to point to Apollo OCI registry. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1649 - -### Update router-bridge to `query-planner` v2.1.1 ([PR #1650](https://github.com/apollographql/router/pull/1650) [PR #1672](https://github.com/apollographql/router/pull/1672)) - -The 2.1.0 release of the query planner comes with fixes to fragment interpretation and reduced memory usage. -The 2.1.1 release of the query planner fixes an issue with the `@defer` directive's `if` argument being ignored. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1650 and https://github.com/apollographql/router/pull/1672 - -### Do not nullify the entire query if the root operation is not present ([PR #1674](https://github.com/apollographql/router/issues/1674)) - -If a root field was not returned by the subgraph (e.g., when there's an error) the entire data object should not be nullified. Instead, the root field that should be null (unless it is non nullable). - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1674 - -### Propagate graphql response regardless of the subgraph HTTP status code. ([#1664](https://github.com/apollographql/router/issues/1664)) - -Subgraph service calls no longer return an error when the received HTTP status code isn't 200. The GraphQL specification does not specify HTTP status code behavior since the GraphQL specification is transport agnostic. - -This commit removes our HTTP status code check in the `subgraph_service`. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1664 - -## 🛠 Maintenance - -### Remove cache layer ([PR #1647](https://github.com/apollographql/router/pull/1647)) - -`ServiceBuilderExt::cache` was removed in v0.16.0. The unused `CacheLayer` has now also been removed. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1647 - -### Refactor `SupergraphService` ([PR #1615](https://github.com/apollographql/router/issues/1615)) - -The `SupergraphService` code became too complex, so much that `rustfmt` could not modify it anymore. -This breaks up the code in more manageable functions. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1615 - -### Conditionally use `HorizontalPodAutoscaler` api version `autoscaling/v2` ([PR #1635](https://github.com/apollographql/router/pull/1635)) - -The helm chart `HorizontalPodAutoscaler` resource now will use API version `autoscaling/v2` on Kubernetes hosts greater than 1.23 when the version is available. Fallback to version `autoscaling/v2beta1` will still be utilised when this version is unavailable - -By [@damienpontifex](https://github.com/damienpontifex) in https://github.com/apollographql/router/pull/1635 - -# [1.0.0-alpha.0] - 2022-08-29 - -## ❗ BREAKING ❗ - -### Move `cors` configuration from `server` to root-level ([PR #1586](https://github.com/apollographql/router/pull/1586)) - -The `cors` configuration is now located at the root-level of the configuration file, rather than inside `server`. - -For example: - -```diff -- server: -- cors: -- origins: -- - https://yourdomain.com -+ cors: -+ origins: -+ - https://yourdomain.com -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1586 - -### Exit the router after logging panic details ([PR #1602](https://github.com/apollographql/router/pull/1602)) - -The Router will now terminate in the (unlikely) case where it panics. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1602 - -### Rename the `endpoint` parameter to `graphql_path` ([#1606](https://github.com/apollographql/router/issues/1606)) - -The `endpoint` parameter within the `server` portion of the YAML configuration has been renamed to `graphql_path` to more accurately reflect its behavior. - -If you used this option, the necessary change would look like: - -```diff -- server: -- endpoint: /graphql -+ server: -+ graphql_path: /graphql -``` - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/1609 - -### Remove `activate()` from the plugin API ([PR #1569](https://github.com/apollographql/router/pull/1569)) - -Recent changes to configuration reloading means that the only known consumer of this API, telemetry, is no longer using it. - -Let's remove it since it's simple to add back if later required. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1569 - -### Rename TestHarness methods ([PR #1579](https://github.com/apollographql/router/pull/1579)) - -Some methods of `apollo_router::TestHarness` were renamed: - -* `extra_supergraph_plugin` → `supergraph_hook` -* `extra_execution_plugin` → `execution_hook` -* `extra_subgraph_plugin` → `subgraph_hook` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1579 - -### `Request` and `Response` types from `apollo_router::http_ext` are private ([Issue #1589](https://github.com/apollographql/router/issues/1589)) - -These types were wrappers around the `Request` and `Response` types from the `http` crate. -Now the latter are used directly instead. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1589 - -### Changes to `IntoHeaderName` and `IntoHeaderValue` ([PR #1607](https://github.com/apollographql/router/pull/1607)) - -> Note: These types are typically not used directly, so we expect most user code to require no changes. - -* Move from `apollo_router::http_ext` to `apollo_router::services` -* Rename to `TryIntoHeaderName` and `TryIntoHeaderValue` -* Make contents opaque -* Replace generic `From` conversion with multiple specific conversions - that are implemented by `http::headers::Header{Name,Value}`. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1607 - -### `QueryPlan::usage_reporting` and `QueryPlannerContent` are private ([Issue #1556](https://github.com/apollographql/router/issues/1556)) - -These items have been removed from the public API of `apollo_router::services::execution`. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1568 - -### Insert the full target triplet in the package name, and prefix with `v` ([Issue #1385](https://github.com/apollographql/router/issues/1385)) - -The release tarballs now contain the full target triplet in their name along with a `v` prefix to be consistent with our other packaging techniques (e.g., Rover). - -For example: - -- `router-0.16.0-x86_64-linux.tar.gz` becomes `router-v0.16.0-x86_64-unknown-linux-gnu.tar.gz` -- `router-0.16.0-x86_64-macos.tar.gz` becomes` router-v0.16.0-x86_64-apple-darwin.tar.gz` -- `router-0.16.0-x86_64-windows.tar.gz` becomes` router-v0.16.0-x86_64-pc-windows-msvc.tar.gz` - -By [@abernix](https://github.com/abernix) and [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1433 (which re-lands work done in https://github.com/apollographql/router/pull/1393) - -### Many structs and enums are now `#[non_exhaustive]` ([Issue #1550](https://github.com/apollographql/router/issues/1550)) - -This means we may adjust `struct` fields or `enum` variants in additive ways in the future without breaking changes. To prepare for that eventuality: - -1. When using a struct pattern (such as for deconstructing a value into its fields), -use `..` to allow further fields: - - ```diff - -let PluginInit { config, supergraph_sdl } = init; - +let PluginInit { config, supergraph_sdl, .. } = init; - ``` - -2. Use field access instead: - - ```diff - -let PluginInit { config, supergraph_sdl } = init; - +let config = init.config; - +let supergraph_sdl = init.supergraph_sdl; - ``` - -3. When constructing a struct, use a builder or constructor method instead of struct literal syntax: - - ```diff - -let error = graphql::Error { - - message: "something went wrong".to_string(), - - ..Default::default() - -}; - +let error = graphql::Error::builder() - + .message("something went wrong") - + .build(); - ``` - -4. When matching on an enum, add a wildcard match arm: - - ```diff - match error { - ApolloRouterError::StartupError => "StartupError", - ApolloRouterError::HttpServerLifecycleError => "HttpServerLifecycleError", - ApolloRouterError::NoConfiguration => "NoConfiguration", - ApolloRouterError::NoSchema => "NoSchema", - ApolloRouterError::ServiceCreationError(_) => "ServiceCreationError", - ApolloRouterError::ServerCreationError(_) => "ServerCreationError", - + _ => "other error", - } - ``` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1614 - -### Some error enums or variants were removed ([Issue #81](https://github.com/apollographql/router/issues/81)) - -They were not used anymore in the public API (or at all). - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1621 - -## 🚀 Features - -### Instrument the rhai plugin with a tracing span ([PR #1598](https://github.com/apollographql/router/pull/1598)) - -If you have an active rhai script in your router, you will now see a "rhai plugin" span in tracing. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1598 - -## 🐛 Fixes - -### Only send one report for a response with deferred responses ([PR #1576](https://github.com/apollographql/router/issues/1576)) - -The router was sending one report per response (even deferred ones), while Studio was expecting one report for the entire -response. The router now sends one report which is inclusive of the latency of the entire operation. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1576 - -### Include formatted query plan when exposing the query plan ([#1557](https://github.com/apollographql/router/issues/1557)) - -Move the location of the `text` field when exposing the query plan and fill it with a formatted query plan. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1557 - -### Change state machine log messages to `trace` ([#1578](https://github.com/apollographql/router/issues/1578)) - -We no longer show internal state machine log events at the `info` level since they are unnecessary during normal operation. They are instead emitted at the `trace` level and can be enabled selectively using the `--log trace` flag. - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/1597 - -### Formatting problem fix of scalar fields selected several times ([PR #1583](https://github.com/apollographql/router/issues/1583)) - -Fixed a bug where querying scalar fields several times would put `null`s instead of expected values. - -By [@eole1712](https://github.com/eole1712) in https://github.com/apollographql/router/pull/1585 - -### Fix typo on HTTP errors from subgraph ([#1593](https://github.com/apollographql/router/pull/1593)) - -Remove the closed parenthesis at the end of error messages resulting from HTTP errors from subgraphs. - -By [@nmoutschen](https://github.com/nmoutschen) in https://github.com/apollographql/router/pull/1593 - -### Only send one report for a response with deferred responses ([PR #1596](https://github.com/apollographql/router/issues/1596)) - -Deferred responses come as `multipart/mixed` elements and are sent as individual HTTP response chunks. When a client receives one chunk, -that chunk should contain the next delimiter. This gives the client the ability to start processing the response instead of waiting for the -next chunk just for the delimiter. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1596 - -### Patch `async-compression` to compress responses in streaming ([PR #1604](https://github.com/apollographql/router/issues/1604)) - -The `async-compression` crate is a dependency used for HTTP response compression. Its implementation accumulates the entire compressed response in memory before sending it. However, this created problems for `@defer` responses since we want those responses to come as soon as -possible, rather than waiting until the _entire_ total response has been received and compressed. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1604 - -### Queries with `@defer` must have the `accept: multipart/mixed` header ([PR #1610](https://github.com/apollographql/router/issues/1610)) - -Since deferred responses can come back as multipart responses, we must check that the client supports that `content-type`. -This will allow older clients to show a meaningful error message instead of a parsing error if the `@defer` directive is -used but they don't support it. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1610 - -## 🛠 Maintenance - -### Depend on published `router-bridge` ([PR #1613](https://github.com/apollographql/router/issues/1613)) - -The `router-bridge` package is now published which means the `router` repository no longer depends on having Node.js installed to build. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1613 - -### Re-organize our release steps checklist ([PR #1605](https://github.com/apollographql/router/pull/1605)) - -We've got a lot of manual steps we need to do in order to release the Router binarys, but we can at least organize them meaningfuly for ourselves to follow! This is only a Router-team concern today! - -By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/1605) - -# [0.16.0] - 2022-08-22 - -We're getting closer and closer to our 1.0 release and with that we have a lot of polish that we're applying to our API to get it ready for it to be a durable surface area for our consumers to depend on. Due to various learnings we've had during the pre-1.0 phases of the Router, we are evolving our API to match what we now know. - -We do not intend on doing this much moving around of things again soon, but anyone who has been following the repository the last couple weeks knows there has been a lot of activity and discussion about where things should live. This means that this release has an _abnormally high number of breaking changes_, though we believe you'll find **_most_ of them to be relatively straightforward** to pivot away from. - -Please review the full change log to get all the details, but for the most part the changes in this release consist of: - - - a lot of renames of existing symbols - - the re-location of exported symbols to more appropriate modules - - the privatization of functions which we don't believe users needed directly (see below if any of these turn out to be a problem). - - During each step of the migration, we recommend **searching this changelog** for a symbol to find advice on how to migrate it. We've tried to make the instructions and path forward as clear as possible. - -- If you find yourself **needing help migrating** to the new patterns, please first take a close look at the examples provided in this change log and if you still need help, please [**open a discussion**](https://github.com/apollographql/router/discussions/). -- If you find yourself **unable to do something** you had previously been able to do, please [**open an issue**](https://github.com/apollographql/router/issues). Please make sure you include your use-case so we can understand better and document it for posterity! - -We appreciate your patience working through these and we're excited for the steps ahead! -## ❗ BREAKING ❗ - -### Remove `QueryPlannerService` ([PR #1552](https://github.com/apollographql/router/pull/1552)) - -This service was redundant, since anything done as part of the `QueryPlannerService` could be done either at the `SupergraphService` or at the `ExecutionService` level. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1552 - -### Rename `map_future_with_context` to `map_future_with_request_data` ([PR #1547](https://github.com/apollographql/router/pull/1547)) - -The function is not very well named since it's in fact used to extract any data from a request for use in a future. This rename makes it clear. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1547 - -### Rename traffic shaping deduplication options ([PR #1540](https://github.com/apollographql/router/pull/1540)) - -In the traffic shaping module: - - `variables_deduplication` configuration option is renamed to `deduplicate_variables`. - - `query_deduplication` configuration option is renamed to `deduplicate_query`. - -```diff -- traffic_shaping: -- variables_deduplication: true # Enable the variables deduplication optimization -- all: -- query_deduplication: true # Enable query deduplication for all subgraphs. -- subgraphs: -- products: -- query_deduplication: false # Disable query deduplication for products. -+ traffic_shaping: -+ deduplicate_variables: true # Enable the variables deduplication optimization -+ all: -+ deduplicate_query: true # Enable query deduplication for all subgraphs. -+ subgraphs: -+ products: -+ deduplicate_query: false # Disable query deduplication for products. -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1540 - -### Make `query_plan_options` private and wrap `QueryPlanContent` in an opaque type ([PR #1486](https://github.com/apollographql/router/pull/1486)) - -`QueryPlanOptions::query_plan_options` is no longer public. If you still necessitate usage of this, please open an issue with your use case. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1486 - -### Removed `delay_interval` in telemetry configuration. ([PR #1498](https://github.com/apollographql/router/pull/1498)) - -It was doing nothing. - -```yaml title="router.yaml" -telemetry: - metrics: - common: - # Removed, will now cause an error on Router startup: - delay_interval: - secs: 9 - nanos: 500000000 -``` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1498 - -### Remove telemetry configuration hot reloading ([PR #1463](https://github.com/apollographql/router/pull/1463)) - -Configuration hot reloading is not very useful for telemetry, and is the -source of regular bugs that are hard to fix. - -This removes the support for configuration reloading entirely. Now, the -router will reject a configuration reload with an error log if the -telemetry configuration changed. - -It is now possible to create a subscriber and pass it explicitely to the telemetry plugin -when creating it. It will then be modified to integrate the telemetry plugin's layer. - -By [@geal](https://github.com/geal) in https://github.com/apollographql/router/pull/1463 - -### Reorder query planner execution ([PR #1484](https://github.com/apollographql/router/pull/1484)) - -Query planning is deterministic and only depends on the query, operation name and query planning -options. As such, we can cache the result of the entire process. - -This changes the pipeline to apply query planner plugins between the cache and the bridge planner, -so those plugins will only be called once on the same query. If changes must be done per query, -they should happen in a supergraph service. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1464 - -### Remove `Buffer` from `Mock*Service` ([PR #1440](https://github.com/apollographql/router/pull/1440) - -This removes the usage of `tower_test::mock::Mock` in mocked services because it isolated the service in a task -so panics triggered by mockall were not transmitted up to the unit test that should catch it. -This rewrites the mocked services API to remove the `build()` method, and make them clonable if needed, -using an `expect_clone` call with mockall. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1440 - -### Some items were renamed or moved ([PR #1487](https://github.com/apollographql/router/pull/1487) [PR #1534](https://github.com/apollographql/router/pull/1534) [PR #1555](https://github.com/apollographql/router/pull/1555) [PR #1563](https://github.com/apollographql/router/pull/1563)) - -At the crate root: - -* `SchemaKind` → `SchemaSource` -* `SchemaKind::String(String)` → `SchemaSource::Static { schema_sdl: String }` -* `ConfigurationKind` → `ConfigurationSource` -* `ConfigurationKind::Instance` → `ConfigurationSource::Static` -* `ShutdownKind` → `ShutdownSource` -* `ApolloRouter` → `RouterHttpServer` - -In the `apollo_router::plugin::Plugin` trait: - -* `router_service` → `supergraph_service` - -In the `apollo_router::services` module, to new public sub-modules: - -* `SupergraphRequest` → `supergraph::Request` -* `SupergraphResponse` → `supergraph::Response` -* `ExecutionRequest` → `execution::Request` -* `ExecutionResponse` → `execution::Response` -* `SubgraphRequest` → `subgraph::Request` -* `SubgraphResponse` → `subgraph::Response` - -For convenience, these new sub-modules each contain type aliases -base on their respective `Request` and `Response` types. - -```rust -pub type BoxService = tower::util::BoxService; -pub type BoxCloneService = tower::util::BoxCloneService; -pub type ServiceResult = Result; -``` - -Migration example: - -```diff --use tower::util::BoxService; --use tower::BoxError; --use apollo_router::services::{RouterRequest, RouterResponse}; -+use apollo_router::services::router; - --async fn example(service: BoxService) -> RouterResponse { -+async fn example(service: router::BoxService) -> router::Response { -- let request = RouterRequest::builder()/*…*/.build(); -+ let request = router::Request::builder()/*…*/.build(); - service.oneshot(request).await - } -``` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1487, https://github.com/apollographql/router/pull/1534, https://github.com/apollographql/router/pull/1555, https://github.com/apollographql/router/pull/1563 - -### Some items were removed from the public API ([PR #1487](https://github.com/apollographql/router/pull/1487) [PR #1535](https://github.com/apollographql/router/pull/1535)) - -If you used some of them and don’t find a replacement, -please [file an issue](https://github.com/apollographql/router/issues/) -with details about the use case. - -``` -apollo_router::Configuration::boxed -apollo_router::Configuration::is_compatible -apollo_router::errors::CacheResolverError -apollo_router::errors::JsonExtError -apollo_router::errors::ParsesError::print -apollo_router::errors::PlanError -apollo_router::errors::PlannerError -apollo_router::errors::PlannerErrors -apollo_router::errors::QueryPlannerError -apollo_router::errors::ServiceBuildError -apollo_router::json_ext -apollo_router::layers::ServiceBuilderExt::cache -apollo_router::mock_service! -apollo_router::plugins -apollo_router::plugin::plugins -apollo_router::plugin::PluginFactory -apollo_router::plugin::DynPlugin -apollo_router::plugin::Handler -apollo_router::plugin::test::IntoSchema -apollo_router::plugin::test::MockSubgraphFactory -apollo_router::plugin::test::PluginTestHarness -apollo_router::query_planner::QueryPlan::execute -apollo_router::services -apollo_router::Schema -``` - -By [@SimonSapin](https://github.com/SimonSapin) - -### Router startup API changes ([PR #1487](https://github.com/apollographql/router/pull/1487)) - -The `RouterHttpServer::serve` method and its return type `RouterHandle` were removed, -their functionality merged into `RouterHttpServer` (formerly `ApolloRouter`). -The builder for `RouterHttpServer` now ends with a `start` method instead of `build`. -This method immediatly starts the server in a new Tokio task. - -```diff - RouterHttpServer::builder() - .configuration(configuration) - .schema(schema) -- .build() -- .serve() -+ .start() - .await -``` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1487 - -### `router_builder_fn` replaced by `shutdown` in the `Executable` builder ([PR #1487](https://github.com/apollographql/router/pull/1487)) - -The builder for `apollo_router::Executable` had a `router_builder_fn` method -allowing the specification of how a `RouterHttpServer` (previously `ApolloRouter`) was to be created -with a provided configuration and schema. -Since the only possible variation was specifying _when_ the server should shut down -(with a `ShutdownSource` parameter) the `router_builder_fn` was replaced with a new `shutdown` method. - -```diff - use apollo_router::Executable; --use apollo_router::RouterHttpServer; - use apollo_router::ShutdownSource; - - Executable::builder() -- .router_builder_fn(|configuration, schema| RouterHttpServer::builder() -- .configuration(configuration) -- .schema(schema) -- .shutdown(ShutdownSource::None) -- .start()) -+ .shutdown(ShutdownSource::None) - .start() - .await -``` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1487 - -### Removed constructors when there is a public builder ([PR #1487](https://github.com/apollographql/router/pull/1487)) - -Many types in the Router API can be constructed with the builder pattern. -We use the [`buildstructor`](https://crates.io/crates/buildstructor) crate -to auto-generate builder boilerplate based on the parameters of a constructor. -These constructors have been made private so that users must go through the builder instead, -which will allow us to add parameters in the future without a breaking API change. -If you were using one of these constructors, the migration generally looks like this: - -```diff --apollo_router::graphql::Error::new(m, vec![l], Some(p), Default::default()) -+apollo_router::graphql::Error::build() -+ .message(m) -+ .location(l) -+ .path(p) -+ .build() -``` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1487 - -### Removed deprecated type aliases ([PR #1487](https://github.com/apollographql/router/pull/1487)) - -A few versions ago, some types were moved from the crate root to a new `graphql` module. -To help the transition, type aliases were left at the old location with a deprecation warning. -These aliases are now removed and remaining imports must be changed to the new locations: - -```diff --use apollo_router::Error; --use apollo_router::Request; --use apollo_router::Response; -+use apollo_router::graphql::Error; -+use apollo_router::graphql::Request; -+use apollo_router::graphql::Response; -``` - -Alternatively, import the module with `use apollo_router::graphql` -then use qualified paths such as `graphql::Request`. -This can help disambiguate when multiple types share a name. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1487 - -### `RouterRequest::fake_builder` defaults to `Content-Type: application/json` ([PR #1487](https://github.com/apollographql/router/pull/1487)) - -`apollo_router::services::RouterRequest` has a builder for creating a “fake” request during tests. -When no `Content-Type` header is specified, this builder will now default to `application/json`. -This will help tests where a request goes through mandatory plugins, including CSRF protection, -which makes the request be accepted by CSRF protection. - -If a test requires a request specifically *without* a `Content-Type` header, -this default can be removed from a `RouterRequest` after building it: - -```rust -let mut router_request = RouterRequest::fake_builder().build(); -router_request.originating_request.headers_mut().remove("content-type"); -``` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1487 - -### Plugins return a `service` to create custom endpoints ([Issue #1481](https://github.com/apollographql/router/issues/1481)) - -Rust plugins can implement the `Plugin::custom_endpoint` trait method -to handle non-GraphQL HTTP requests. - -Previously, the return type of this method was `Option`, -where a `Handler` could be created with: - -```rust -impl Handler { - pub fn new(service: tower::util::BoxService< - apollo_router::http_ext::Request, - apollo_router::http_ext::Response, - tower::BoxError - >) -> Self {/* … */} -} -``` - -`Handler` has been removed from the public API and plugins now return a `BoxService` directly. -Additionally, the type for HTTP request and response bodies was changed -from `bytes::Bytes` to `hyper::Body` which is more flexible and is compatible with streams (which are necessary in future versions of the Router). - -The changes needed if using custom endpoints are: - -* Replace `Handler::new(service)` with `service` -* To read the full request body, - use [`hyper::body::to_bytes`](https://docs.rs/hyper/latest/hyper/body/fn.to_bytes.html) - or [`hyper::body::aggregate`](https://docs.rs/hyper/latest/hyper/body/fn.aggregate.html). -* A response `Body` can be created through conversion traits from various types. - For example: `"string".into()` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1533 - -## 🚀 Features - -### rhai logging functions now accept Dynamic parameters ([PR #1521](https://github.com/apollographql/router/pull/1521)) - -Prior to this change, rhai logging functions worked with string parameters. This change means that any valid rhai object -may now be passed as a logging parameter. - -By [@garypen](https://github.com/garypen) - -### Reduce initial memory footprint by lazily populating introspection query cache ([Issue #1517](https://github.com/apollographql/router/issues/1517)) - -In an early alpha release of the Router, we only executed certain "known" introspection queries because of prior technical constraints that prohibited us from doing something more flexible. Because the set of introspection queries was "known", it made sense to cache them. - -As of https://github.com/apollographql/router/pull/802, this special-casing is (thankfully) no longer necessary and we no longer need to _know_ (and constrain!) the introspection queries that the Router supports. - -We could have kept caching those "known" queries, however we were finding that the resulting cache size was quite large and making the Router's minimum memory footprint larger than need be since we were caching many introspection results which the Router instance would never encounter. - -This change removes the cache entirely and allows introspection queries served by the Router to merely be lazily calculated and cached on-demand, thereby reducing the initial memory footprint. Disabling introspection entirely will prevent any use of this cache since no introspection will be possible. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1517 - -### Expose query plan in extensions of GraphQL response (experimental) ([PR #1470](https://github.com/apollographql/router/pull/1470)) - -When enabled in configuration, it is now possible to expose the query plan in the GraphQL response `extensions`. This is only experimental at the moment, and we plan to integrate it into an upcoming version of Apollo Studio. Currently, no documentation is available. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1470 - -### Add support of global rate limit and timeout. [PR #1347](https://github.com/apollographql/router/pull/1347) - -Additions to the traffic shaping plugin: -- **Global rate limit** - If you want to rate limit requests to subgraphs or to the router itself. -- **Timeout**: - Set a timeout to subgraphs and router requests. - -```yaml -traffic_shaping: - router: # Rules applied to requests from clients to the router - global_rate_limit: # Accept a maximum of 10 requests per 5 secs. Excess requests must be rejected. - capacity: 10 - interval: 5s # Value in milliseconds must be greater than 0 and less than the max of a 64-bit integer (2^64-1). - timeout: 50s # If a request to the router takes more than 50secs then cancel the request (30 sec by default) - subgraphs: # Rules applied to requests from the router to individual subgraphs - products: - global_rate_limit: # Accept a maximum of 10 requests per 5 secs from the router. Excess requests must be rejected. - capacity: 10 - interval: 5s # Value in milliseconds must be greater than 0 and less than the max of a 64-bit integer (2^64-1). - timeout: 50s # If a request to the subgraph 'products' takes more than 50secs then cancel the request (30 sec by default) -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1347 - -### Explicit `shutdown` for `RouterHttpServer` handle ([PR #1487](https://github.com/apollographql/router/pull/1487)) - -If you explicitly create a `RouterHttpServer` handle, -dropping it while the server is running instructs the server shut down gracefuly. -However with the handle dropped, there is no way to wait for shutdown to end -or check that it went without error. -Instead, the new `shutdown` async method can be called explicitly -to obtain a `Result`: - -```diff - use RouterHttpServer; - let server = RouterHttpServer::builder().schema("schema").start(); - // … --drop(server); -+server.shutdown().await.unwrap(); -``` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1487 - -### Added `apollo_router::TestHarness` ([PR #1487](https://github.com/apollographql/router/pull/1487)) - -This is a builder for the part of an Apollo Router that handles GraphQL requests, -as a `tower::Service`. -This allows tests, benchmarks, etc -to manipulate request and response objects in memory without going over the network. -See the API documentation for an example. (It can be built with `cargo doc --open`.) - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1487 - -### Introduce `map_deferred_response` method for deferred responses ([PR #1501](https://github.com/apollographql/router/pull/1501)) - -The `map_deferred_response` method is now available for the router service and execution -service in Rhai. When using the `@defer` directive, we get the data in a serie of graphql -responses. The first one is available with the `map_response` method, where the HTTP headers -and the response body can be modified. The following responses are available through -`map_deferred_response`, which only has access to the response body. - -By [@geal](https://github.com/geal) in https://github.com/apollographql/router/pull/1501 - -## 🐛 Fixes - -### Return HTTP status code 400 when `variables` validation fails ([Issue #1403](https://github.com/apollographql/router/issues/1403)) - -Failure to validate out-of-band `variables` against both the `query` and the corresponding schema will now result in an HTTP status code of 400 being returned to the client. This instructs the client not to bother retrying without changing something about what it previously sent since subsequent retries would just fail validation again and again. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) - -### Include usage reporting data in the context even when the query plan has been cached ([#1559](https://github.com/apollographql/router/issues/1559)) - -Include usage reporting data in the context even when the query plan has been cached when calling `CachingQueryPlanner`. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1559 - -### Accept `SIGTERM` as shutdown signal ([PR #1497](https://github.com/apollographql/router/pull/1497)) - -This will make containers stop faster as they will not have to wait until a `SIGKILL` to stop the router (which generally comes several seconds later). - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1497 - -### Set the response `path` for deferred responses ([PR #1529](https://github.com/apollographql/router/pull/1529)) - -Some GraphQL clients rely on the response `path` to find out which -fragment created a deferred response, and generate code that checks the -type of the value at that path. -Previously the router was generating a value that starts at the root -for every deferred response. Now it checks the `path` returned by the query -plan execution and creates a response for each value that matches that -path. -In particular, for deferred fragments on an object inside an array, it -will create a separate response for each element of the array. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1529 - -### Activate defer support in introspection ([PR #1557](https://github.com/apollographql/router/pull/1557)) - -Introspection queries will now see the `@defer` directive if it was activated in the configuration file. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1557 - -### Support the incremental response field ([PR #1551](https://github.com/apollographql/router/pull/1551)) - -Recent changes in the `@defer` specification now mandate that the deferred responses are transmitted -as an array in the new `incremental` field of the JSON response. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1551 - -## 🛠 Maintenance - -These are generally internal improvements to the Router repository on GitHub. - -### Display `licenses.html` diff in CI if the check failed ([#1524](https://github.com/apollographql/router/issues/1524)) - -The CI check that ensures that the `license.html` file is up to date now displays what has changed when the file is out of sync. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) - -## 🚀 Features - -### Helm: Rhai script and Istio virtualservice support ([#1478](https://github.com/apollographql/router/issues/1478)) - -You can now pass a Rhai script file to the helm chart. -You can also provide an Istio `VirtualService` configuration, as well as custom `Egress` rules. -Head over to the helm chart [default values](https://github.com/apollographql/router/blob/main/helm/chart/router/values.yaml) to get started. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1478 - -## 📚 Documentation - -### Clarify path parameter usage ([PR #1473](https://github.com/apollographql/router/pull/1473)) - -Add an inline example of path parameter usage to the [section of the docs](https://www.apollographql.com/docs/router/configuration/overview/#endpoint-path) explaining that you cannot specify a wildcard in the middle of a path. - -By [@EverlastingBugstopper](https://github.com/EverlastingBugstopper) in https://github.com/apollographql/router/pull/1473 - -# [0.15.1] - 2022-08-10 - -## ⚠️ **SECURITY** ⚠️ - -### Landing page: Remove unsanitized example input - -The default landing page contained HTML to display a sample `curl` command which is made visible if the full landing page bundle could not be fetched from Apollo's CDN. The server's URL is directly interpolated into this command inside the browser from `window.location.href`. On some older browsers such as IE11, this value is not URI-encoded. On such browsers, opening a malicious URL pointing at an Apollo Router could cause execution of attacker-controlled JavaScript. In this release, the fallback page does not display a `curl` command. - -More details are available at the [security advisory](https://github.com/apollographql/router/security/advisories/GHSA-p5q6-hhww-f999). - -By [@o0Ignition0o](https://github.com/o0Ignition0o) - -# [0.15.0] - 2022-08-09 - -## ❗ BREAKING ❗ - -### CORS: Deprecate newly-added `allow_any_header` option and return to previous behavior ([PR #1480](https://github.com/apollographql/router/pull/1480)) - -We've re-considered and reverted changes we shipped in the last release with regards to how we handle the [`Access-Control-Request-Headers`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers) *request* header and its corresponding [`Access-Control-Allow-Headers`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers) response header. We've reverted to the previous releases' behavior, including the removal of the recently-added `allow_any_header` option. - -The previous default behavior was to **reflect** the client's `Access-Control-Request-Headers` request header values back in the `Access-Control-Allow-Headers` response header. This previous behavior is in fact a common default behavior in other CORS libraries as well, including the [`cors`](https://npm.im/cors) Node.js package and we think it's worth keeping as it was previously, rather than requiring users to specify `allow_any_header` for the _majority_ of use cases. We believe this to be a safe and secure default that is also more user-friendly. - -It is not typically necessary to change this default behavior, but if you wish to allow a more specific set of headers, you can disable the default header reflection and specify a list of headers using the `allow_headers` option, which will allow only those headers in negotiating a response: - -```yaml title="router.yaml" -server: - cors: - allow_any_origin: true - # Including this `allow_headers` isn't typically necessary (can be removed) but - # will *restrict* the permitted Access-Control-Allow-Headers response values. - allow_headers: - - Content-Type - - Authorization - - x-my-custom-header -``` - -By [@o0Ignition0o](https://github.com/o0ignition0o) in https://github.com/apollographql/router/pull/1480 - -### Reference-counting for the schema string given to plugins ([PR #1462](https://github.com/apollographql/router/pull/1462)) - -The type of the `supergraph_sdl` field of the `apollo_router::plugin::PluginInit` struct -was changed from `String` to `Arc`. -This reduces the number of copies of this string we keep in memory, as schemas can get large. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1462 - -## 🐛 Fixes - -### Update span attributes to be compliant with the opentelemetry for GraphQL specs ([PR #1449](https://github.com/apollographql/router/pull/1449)) - -Change attribute name `query` to `graphql.document` and `operation_name` to `graphql.operation.name` in spans. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1449 - -### Configuration handling enhancements ([PR #1454](https://github.com/apollographql/router/pull/1454)) - -Router config handling now: -* Allows completely empty configuration without error. -* Prevents unknown tags at the root of the configuration from being silently ignored. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/1454 - -## 📚 Documentation - - -### CORS: Fix trailing slashes, and display defaults ([PR #1471](https://github.com/apollographql/router/pull/1471)) - -The CORS documentation now displays a valid `origins` configuration (without trailing slash!), and the full configuration section displays its default settings. - - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1471 - - -### Add helm OCI example ([PR #1457](https://github.com/apollographql/router/pull/1457)) - -Update existing filesystem based example to illustrate how to do the same thing using our OCI stored helm chart. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1457 - - -# [0.14.0] - 2022-08-02 - -## ❗ BREAKING ❗ - -### Modify the plugin `new` method to pass an initialisation structure ([PR #1446](https://github.com/apollographql/router/pull/1446)) - -This change alters the `new` method for plugins to pass a `PluginInit` struct. - -We are making this change so that we can pass more information during plugin startup. The first change is that in addition to passing -the plugin configuration, we are now also passing the router supergraph sdl (Schema Definition Language) as a string. - -There is a new example (`supergraph_sdl`) which illustrates how to use this new capability. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1446 - -### Remove the generic stream type from `RouterResponse` and `ExecutionResponse` ([PR #1420](https://github.com/apollographql/router/pull/1420)) - -This generic type complicates the API with limited benefit because we use `BoxStream` everywhere in plugins: - -* `RouterResponse>` -> `RouterResponse` -* `ExecutionResponse>` -> `ExecutionResponse` - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1420 - -### Remove the HTTP request from `QueryPlannerRequest` ([PR #1439](https://github.com/apollographql/router/pull/1439)) - -The content of `QueryPlannerRequest` is used as argument to the query planner and as a cache key, -so it should not change depending on the variables or HTTP headers. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1439 - -### Change `PluggableRouterServiceBuilder` methods ([PR #1437](https://github.com/apollographql/router/pull/1437)) - -`with_naive_introspection` and `with_defer_support` where two parameter-less methods -of this builder that enabled boolean configuration flags. -They have been removed and replaced by `with_configuration` -which takes `Arc`. -A `Configuration` value can be created from various formats by deserializing with `serde`. -The removed methods correspond to `server.introspection` and `server.experimental_defer_support` -configuration keys respectively. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1437 - -### Changes to the `SchemaKind` enum ([PR #1437](https://github.com/apollographql/router/pull/1437)) - -The `Instance` variant is replaced with a variant named `String` that contains… -a `String` instead of `Box`, -so you no longer need to parse the schema before giving it to the router. -Similarly, the `Stream` variant now contains a stream of `String`s -instead of a stream of already-parsed `Schema`s. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1437 - -### `Schema` no longer implements `FromStr` ([PR #1437](https://github.com/apollographql/router/pull/1437)) - -This means that `str.parse::()` is no longer available. -If you still need a parsed `Schema` (see above), -use `apollo_router::Schema(str, &configuration)` instead. -To use the default `apollo_router::Configuration` -you can call `apollo_router::Schema(str, &Default::default())`. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1437 - -## 🚀 Features - -### Publish helm chart to OCI registry ([PR #1447](https://github.com/apollographql/router/pull/1447)) - -When we make a release, publish our helm chart to the same OCI registry that we use for our docker images. - -For more information about using OCI registries with helm, see [the helm documentation](https://helm.sh/blog/storing-charts-in-oci/). - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1447 - -### Configure Regex based CORS rules ([PR #1444](https://github.com/apollographql/router/pull/1444)) - -The router now supports regex based CORS rules, as explained in the [docs](https://www.apollographql.com/docs/router/configuration/cors) -It also supports the `allow_any_header` setting that will mirror client's requested headers. - -```yaml title="router.yaml" -server: - cors: - match_origins: - - "https://([a-z0-9]+[.])*api[.]example[.]com" # any host that uses https and ends with .api.example.com - allow_any_header: true # mirror client's headers -``` - -The default CORS headers configuration of the router allows `content-type`, `apollographql-client-version` and `apollographql-client-name`. - -By [@o0Ignition0o](https://github.com/o0ignition0o) in https://github.com/apollographql/router/pull/1444 - - -### Add support of error section in telemetry to add custom attributes ([PR #1443](https://github.com/apollographql/router/pull/1443)) - -The telemetry is now able to hook at the error stage if router or a subgraph is returning an error. Here is an example of configuration: - -```yaml -telemetry: - metrics: - prometheus: - enabled: true - common: - attributes: - subgraph: - all: - errors: # Only works if it's a valid GraphQL error - include_messages: true # Will include the error message in a message attribute - extensions: # Include extension data - - name: subgraph_error_extended_type # Name of the attribute - path: .type # JSON query path to fetch data from extensions -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1443 - -### Experimental support for the `@defer` directive ([PR #1182](https://github.com/apollographql/router/pull/1182)) - -The router can now understand the `@defer` directive, used to tag parts of a query so the response is split into -multiple parts that are sent one by one. - -:warning: *this is still experimental and not fit for production use yet* - -To activate it, add this option to the configuration file: - -```yaml -server: - experimental_defer_support: true -``` - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1182 - -### Rewrite the caching API ([PR #1281](https://github.com/apollographql/router/pull/1281)) - -This introduces a new asynchronous caching API that opens the way to multi level caching (in memory and -database). The API revolves around an `Entry` structure that allows query deduplication and lets the -client decide how to generate the value to cache, instead of a complicated delegate system inside the -cache. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1281 - -## 🐛 Fixes - -### Update serialization format for telemetry.tracing.otlp.grpc.metadata ([PR #1391](https://github.com/apollographql/router/pull/1391)) - -The metadata format now uses `IndexMap>`. - -By [@me-diru](https://github.com/me-diru) in https://github.com/apollographql/router/pull/1391 - -### Update the scaffold template so it targets router v0.14.0 ([PR #1431](https://github.com/apollographql/router/pull/1431)) - -The cargo scaffold template will target the latest version of the router. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1248 - -### Selection merging on non-object field aliases ([PR #1406](https://github.com/apollographql/router/issues/1406)) - -Fixed a bug where merging aliased fields would sometimes put `null`s instead of expected values. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1432 - -### A Rhai error instead of a Rust panic ([PR #1414 https://github.com/apollographql/router/pull/1414)) - -In Rhai plugins, accessors that mutate the originating request are not available when in the subgraph phase. Previously, trying to mutate anyway would cause a Rust panic. This has been changed to a Rhai error instead. - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1414 - -### Optimizations ([PR #1423](https://github.com/apollographql/router/pull/1423)) - -* Do not clone the client request during query plan execution -* Do not clone the usage reporting -* Avoid path allocations when iterating over JSON values - -The benchmarks show that this change brings a 23% gain in requests per second compared to the main branch. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1423 - -### do not perform nested fetches if the parent one returned null ([PR #1332](https://github.com/apollographql/router/pull/1332) - -In a query of the form: -```graphql -mutation { - mutationA { - mutationB - } -} -``` - -If `mutationA` returned null, we should not execute `mutationB`. - -By [@Ty3uK](https://github.com/Ty3uK) in https://github.com/apollographql/router/pull/1332 - -## 🛠 Maintenance - -## 📚 Documentation - -### Updates wording and formatting of README.md - -By [@EverlastingBugstopper](https://github.com/EverlastingBugstopper) in https://github.com/apollographql/router/pull/1445 -# [0.12.0] - 2022-08-18 - -## ❗ BREAKING ❗ - -### Move `experimental.rhai` out of `experimental` ([PR #1365](https://github.com/apollographql/router/pull/1365)) - -You will need to update your YAML configuration file to use the correct name for `rhai` plugin. - -```diff -- plugins: -- experimental.rhai: -- filename: /path/to/myfile.rhai -+ rhai: -+ scripts: /path/to/directory/containing/all/my/rhai/scripts (./scripts by default) -+ main: (main.rhai by default) -``` - -You can now modularise your rhai code. Rather than specifying a path to a filename containing your rhai code, the rhai plugin will now attempt to execute the script specified via `main`. If modules are imported, the rhai plugin will search for those modules in the `scripts` directory. for more details about how rhai makes use of modules, look at [the rhai documentation](https://rhai.rs/book/ref/modules/import.html). - -The simplest migration will be to set `scripts` to the directory containing your `myfile.rhai` and to rename your `myfile.rhai` to `main.rhai`. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1365 - -## 🐛 Fixes - -### The opentelemetry-otlp crate needs a http-client feature ([PR #1392](https://github.com/apollographql/router/pull/1392)) - -The opentelemetry-otlp crate only checks at runtime if a HTTP client was added through -cargo features. We now use reqwest for that. - -By [@geal](https://github.com/geal) in https://github.com/apollographql/router/pull/1392 - -### Expose the custom endpoints from RouterServiceFactory ([PR #1402](https://github.com/apollographql/router/pull/1402)) - -Plugin HTTP endpoints registration was broken during the Tower refactoring. We now make sure that the list -of endpoints is generated from the `RouterServiceFactory` instance. - -By [@geal](https://github.com/geal) in https://github.com/apollographql/router/pull/1402 - -## 🛠 Maintenance - -### Dependency updates ([PR #1389](https://github.com/apollographql/router/issues/1389), [PR #1394](https://github.com/apollographql/router/issues/1394), [PR #1395](https://github.com/apollographql/router/issues/1395)) - -Dependency updates were blocked for some time due to incompatibilities: - -- #1389: the router-bridge crate needed a new version of `deno_core` in its workspace that would not fix the version of `once_cell`. Now that it is done we can update `once_cell` in the router -- #1395: `clap` at version 3.2 changed the way values are extracted from matched arguments, which resulted in panics. This is now fixed and we can update `clap` in the router and related crates -- #1394: broader dependency updates now that everything is locked -- #1410: revert tracing update that caused two telemetry tests to fail (the router binary is not affected) - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1389 https://github.com/apollographql/router/pull/1394 https://github.com/apollographql/router/pull/1395 and [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1410 - -# [0.11.0] - 2022-07-12 - -## ❗ BREAKING ❗ - -### Relax plugin api mutability ([PR #1340](https://github.com/apollographql/router/pull/1340) ([PR #1289](https://github.com/apollographql/router/pull/1289)) - -the `Plugin::*_service()` methods were taking a `&mut self` as argument, but since -they work like a tower Layer, they can use `&self` instead. This change -then allows us to move from Buffer to service factories for the query -planner, execution and subgraph services. - -**Services are now created on the fly at session creation, so if any state must be shared -between executions, it should be stored in an `Arc>` in the plugin and cloned -into the new service in the `Plugin::*_service()` methods**. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1340 https://github.com/apollographql/router/pull/1289 - -## 🚀 Features - -### Add support to add custom resources on metrics. ([PR #1354](https://github.com/apollographql/router/pull/1354)) - -Resources are almost like attributes but more global. They are directly configured on the metrics exporter which means you'll always have these resources on each of your metrics. This functionality can be used to, for example, -apply a `service.name` to metrics to make them easier to find in larger infrastructure, as demonstrated here: - -```yaml -telemetry: - metrics: - common: - resources: - # Set the service name to easily find metrics related to the apollo-router in your metrics dashboards - service.name: "apollo-router" -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1354 - -## 🐛 Fixes - -### Fix fragment on interface without typename ([PR #1371](https://github.com/apollographql/router/pull/1371)) - -When the subgraph doesn't return the `__typename` and the type condition of a fragment is an interface, we should return the values if the entity implements the interface - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1371 - -### Fix detection of an introspection query ([PR #1370](https://github.com/apollographql/router/pull/1370)) - -A query that only contains `__typename` at the root will now special-cased as merely an introspection query and will bypass more complex query-planner execution (its value will just be `Query`). - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1370 - -### Accept nullable list as input ([PR #1363](https://github.com/apollographql/router/pull/1363)) - -Do not throw a validation error when you give `null` for an input variable of type `[Int!]`. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1363 - -## 🛠 Maintenance - -### Replace Buffers of tower services with service factories ([PR #1289](https://github.com/apollographql/router/pull/1289) [PR #1355](https://github.com/apollographql/router/pull/1355)) - -Tower services should be used by creating a new service instance for each new session -instead of going through a `Buffer`. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1289 https://github.com/apollographql/router/pull/1355 - -### Execute the query plan's first response directly ([PR #1357](https://github.com/apollographql/router/issues/1357)) - -The query plan was previously executed in a spawned task to prepare for the `@defer` implementation, but we can actually -generate the first response right inside the same future. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1357 - -### Remove deprecated `failure` crate from the dependency tree ([PR #1373](https://github.com/apollographql/router/pull/1373)) - -This should fix automated reports about [GHSA-jq66-xh47-j9f3](https://github.com/advisories/GHSA-jq66-xh47-j9f3). - -By [@yanns](https://github.com/yanns) in https://github.com/apollographql/router/pull/1373 - -### Render embedded Sandbox instead of landing page ([PR #1369](https://github.com/apollographql/router/pull/1369)) - -Open the router URL in a browser and start querying the router from the Apollo Sandbox. - -By [@mayakoneval](https://github.com/mayakoneval) in https://github.com/apollographql/router/pull/1369 - -## 📚 Documentation - -### Various documentation edits ([PR #1329](https://github.com/apollographql/router/issues/1329)) - -By [@StephenBarlow](https://github.com/StephenBarlow) in https://github.com/apollographql/router/pull/1329 - - -# [0.10.0] - 2022-07-05 - -## ❗ BREAKING ❗ - -### Change configuration for custom attributes for metrics in telemetry plugin ([PR #1300](https://github.com/apollographql/router/pull/1300) - -To create a distinction between subgraph metrics and router metrics, a distiction has been made in the configuration. Therefore, a new configuration section called `router` has been introduced and Router-specific properties are now listed there, as seen here: - -```diff -telemetry: - metrics: - common: - attributes: -- static: -- - name: "version" -- value: "v1.0.0" -- from_headers: -- - named: "content-type" -- rename: "payload_type" -- default: "application/json" -- - named: "x-custom-header-to-add" -+ router: -+ static: -+ - name: "version" -+ value: "v1.0.0" -+ request: -+ header: -+ - named: "content-type" -+ rename: "payload_type" -+ default: "application/json" -+ - named: "x-custom-header-to-add" -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1300 - -### Rename `http_compat` to `http_ext` ([PR #1291](https://github.com/apollographql/router/pull/1291)) - -The module provides extensions to the `http` crate which are specific to the way we use that crate in the router. This change also cleans up the provided extensions and fixes a few potential sources of error (by removing them) -such as the `Request::mock()` function. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1291 - -### Rework the entire public API structure ([PR #1216](https://github.com/apollographql/router/pull/1216), [PR #1242](https://github.com/apollographql/router/pull/1242), [PR #1267](https://github.com/apollographql/router/pull/1267), [PR #1277](https://github.com/apollographql/router/pull/1277), [PR #1303](https://github.com/apollographql/router/pull/1303)) - -* Many items have been removed from the public API and made private. - If you were relying on these previously-public methods and find that they are no longer available, please open an issue with your use case so we can consider how we want to re-introduce them. - -* Many re-exports have been removed. - Most notably from the crate root and all of the `prelude` modules. - These items now need to be imported from another location instead, - most often the module that defines them. - -* Some items have moved and need to be imported from a new location. - -For example, here are the changes made to `examples/add-timestamp-header/src/main.rs`: - -```diff --use apollo_router::{plugin::utils, Plugin, RouterRequest, RouterResponse}; -+use apollo_router::plugin::test; -+use apollo_router::plugin::Plugin; -+use apollo_router::services::{RouterRequest, RouterResponse}; -``` -```diff --let mut mock = utils::test::MockRouterService::new(); -+let mut mock = test::MockRouterService::new(); -``` -```diff --if let apollo_router::ResponseBody::GraphQL(response) = -+if let apollo_router::services::ResponseBody::GraphQL(response) = - service_response.next_response().await.unwrap() - { -``` - -If you're unsure where a given item needs to be imported from when porting code, -unfold the listing below and use your browser's search function (CTRL+F or ⌘+F). - -
- - Output of ./scripts/public_items.sh for 0.10.0 - -
-use apollo_router::ApolloRouter;
-use apollo_router::Configuration;
-use apollo_router::ConfigurationKind;
-use apollo_router::Context;
-use apollo_router::Error;
-use apollo_router::Executable;
-use apollo_router::Request;
-use apollo_router::Response;
-use apollo_router::Schema;
-use apollo_router::SchemaKind;
-use apollo_router::ShutdownKind;
-use apollo_router::error::CacheResolverError;
-use apollo_router::error::FetchError;
-use apollo_router::error::JsonExtError;
-use apollo_router::error::Location;
-use apollo_router::error::ParseErrors;
-use apollo_router::error::PlannerErrors;
-use apollo_router::error::QueryPlannerError;
-use apollo_router::error::SchemaError;
-use apollo_router::error::ServiceBuildError;
-use apollo_router::error::SpecError;
-use apollo_router::graphql::Error;
-use apollo_router::graphql::NewErrorBuilder;
-use apollo_router::graphql::Request;
-use apollo_router::graphql::Response;
-use apollo_router::json_ext::Object;
-use apollo_router::json_ext::Path;
-use apollo_router::json_ext::PathElement;
-use apollo_router::layers::ServiceBuilderExt;
-use apollo_router::layers::ServiceExt;
-use apollo_router::layers::async_checkpoint::AsyncCheckpointLayer;
-use apollo_router::layers::async_checkpoint::AsyncCheckpointService;
-use apollo_router::layers::cache::CachingLayer;
-use apollo_router::layers::cache::CachingService;
-use apollo_router::layers::instrument::InstrumentLayer;
-use apollo_router::layers::instrument::InstrumentService;
-use apollo_router::layers::map_future_with_context::MapFutureWithContextLayer;
-use apollo_router::layers::map_future_with_context::MapFutureWithContextService;
-use apollo_router::layers::sync_checkpoint::CheckpointLayer;
-use apollo_router::layers::sync_checkpoint::CheckpointService;
-use apollo_router::main;
-use apollo_router::mock_service;
-use apollo_router::plugin::DynPlugin;
-use apollo_router::plugin::Handler;
-use apollo_router::plugin::Plugin;
-use apollo_router::plugin::PluginFactory;
-use apollo_router::plugin::plugins;
-use apollo_router::plugin::register_plugin;
-use apollo_router::plugin::serde::deserialize_header_name;
-use apollo_router::plugin::serde::deserialize_header_value;
-use apollo_router::plugin::serde::deserialize_option_header_name;
-use apollo_router::plugin::serde::deserialize_option_header_value;
-use apollo_router::plugin::serde::deserialize_regex;
-use apollo_router::plugin::test::IntoSchema;
-use apollo_router::plugin::test::MockExecutionService;
-use apollo_router::plugin::test::MockQueryPlanningService;
-use apollo_router::plugin::test::MockRouterService;
-use apollo_router::plugin::test::MockSubgraph;
-use apollo_router::plugin::test::MockSubgraphService;
-use apollo_router::plugin::test::NewPluginTestHarnessBuilder;
-use apollo_router::plugin::test::PluginTestHarness;
-use apollo_router::plugins::csrf::CSRFConfig;
-use apollo_router::plugins::csrf::Csrf;
-use apollo_router::plugins::rhai::Conf;
-use apollo_router::plugins::rhai::Rhai;
-use apollo_router::plugins::telemetry::ROUTER_SPAN_NAME;
-use apollo_router::plugins::telemetry::Telemetry;
-use apollo_router::plugins::telemetry::apollo::Config;
-use apollo_router::plugins::telemetry::config::AttributeArray;
-use apollo_router::plugins::telemetry::config::AttributeValue;
-use apollo_router::plugins::telemetry::config::Conf;
-use apollo_router::plugins::telemetry::config::GenericWith;
-use apollo_router::plugins::telemetry::config::Metrics;
-use apollo_router::plugins::telemetry::config::MetricsCommon;
-use apollo_router::plugins::telemetry::config::Propagation;
-use apollo_router::plugins::telemetry::config::Sampler;
-use apollo_router::plugins::telemetry::config::SamplerOption;
-use apollo_router::plugins::telemetry::config::Trace;
-use apollo_router::plugins::telemetry::config::Tracing;
-use apollo_router::query_planner::OperationKind;
-use apollo_router::query_planner::QueryPlan;
-use apollo_router::query_planner::QueryPlanOptions;
-use apollo_router::register_plugin;
-use apollo_router::services::ErrorNewExecutionResponseBuilder;
-use apollo_router::services::ErrorNewQueryPlannerResponseBuilder;
-use apollo_router::services::ErrorNewRouterResponseBuilder;
-use apollo_router::services::ErrorNewSubgraphResponseBuilder;
-use apollo_router::services::ExecutionRequest;
-use apollo_router::services::ExecutionResponse;
-use apollo_router::services::ExecutionService;
-use apollo_router::services::FakeNewExecutionRequestBuilder;
-use apollo_router::services::FakeNewExecutionResponseBuilder;
-use apollo_router::services::FakeNewRouterRequestBuilder;
-use apollo_router::services::FakeNewRouterResponseBuilder;
-use apollo_router::services::FakeNewSubgraphRequestBuilder;
-use apollo_router::services::FakeNewSubgraphResponseBuilder;
-use apollo_router::services::NewExecutionRequestBuilder;
-use apollo_router::services::NewExecutionResponseBuilder;
-use apollo_router::services::NewExecutionServiceBuilder;
-use apollo_router::services::NewQueryPlannerRequestBuilder;
-use apollo_router::services::NewQueryPlannerResponseBuilder;
-use apollo_router::services::NewRouterRequestBuilder;
-use apollo_router::services::NewRouterResponseBuilder;
-use apollo_router::services::NewRouterServiceBuilder;
-use apollo_router::services::NewSubgraphRequestBuilder;
-use apollo_router::services::NewSubgraphResponseBuilder;
-use apollo_router::services::PluggableRouterServiceBuilder;
-use apollo_router::services::QueryPlannerContent;
-use apollo_router::services::QueryPlannerRequest;
-use apollo_router::services::QueryPlannerResponse;
-use apollo_router::services::ResponseBody;
-use apollo_router::services::RouterRequest;
-use apollo_router::services::RouterResponse;
-use apollo_router::services::RouterService;
-use apollo_router::services::SubgraphRequest;
-use apollo_router::services::SubgraphResponse;
-use apollo_router::services::SubgraphService;
-use apollo_router::services::http_ext::FakeNewRequestBuilder;
-use apollo_router::services::http_ext::IntoHeaderName;
-use apollo_router::services::http_ext::IntoHeaderValue;
-use apollo_router::services::http_ext::NewRequestBuilder;
-use apollo_router::services::http_ext::Request;
-use apollo_router::services::http_ext::Response;
-use apollo_router::subscriber::RouterSubscriber;
-use apollo_router::subscriber::is_global_subscriber_set;
-use apollo_router::subscriber::replace_layer;
-use apollo_router::subscriber::set_global_subscriber;
-
-
- -By [@SimonSapin](https://github.com/SimonSapin) - -### Entry point improvements ([PR #1227](https://github.com/apollographql/router/pull/1227)) ([PR #1234](https://github.com/apollographql/router/pull/1234)) ([PR #1239](https://github.com/apollographql/router/pull/1239)), [PR #1263](https://github.com/apollographql/router/pull/1263)) - -The interfaces around the entry point have been improved for naming consistency and to enable reuse when customization is required. -Most users will continue to use: -```rust -apollo_router::main() -``` - -However, if you want to specify extra customization to configuration/schema/shutdown then you may use `Executable::builder()` to override behavior. - -```rust -use apollo_router::Executable; -Executable::builder() - .router_builder_fn(|configuration, schema| ...) // Optional - .start().await? -``` - -Migration tips: -* Calls to `ApolloRouterBuilder::default()` should be migrated to `ApolloRouter::builder`. -* `FederatedServerHandle` has been renamed to `ApolloRouterHandle`. -* The ability to supply your own `RouterServiceFactory` has been removed. -* `StateListener`. This made the internal state machine unnecessarily complex. `listen_address()` remains on `ApolloRouterHandle`. -* `FederatedServerHandle::shutdown()` has been removed. Instead, dropping `ApolloRouterHandle` will cause the router to shutdown. -* `FederatedServerHandle::ready()` has been renamed to `FederatedServerHandle::listen_address()`, it will return the address when the router is ready to serve requests. -* `FederatedServerError` has been renamed to `ApolloRouterError`. -* `main_rt` should be migrated to `Executable::builder()` - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/1227 https://github.com/apollographql/router/pull/1234 https://github.com/apollographql/router/pull/1239 https://github.com/apollographql/router/pull/1263 - -### Non-GraphQL response body variants removed from `RouterResponse` ([PR #1307](https://github.com/apollographql/router/pull/1307), [PR #1328](https://github.com/apollographql/router/pull/1328)) - -The `ResponseBody` enum has been removed. -It had variants for GraphQL and non-GraphQL responses. - -It was used: - -* In `RouterResponse` which now uses `apollo_router::graphql::Response` instead -* In `Handler` for plugin custom endpoints which now uses `bytes::Bytes` instead - -Various type signatures will need changes such as: - -```diff -- RouterResponse> -+ RouterResponse> -``` - -Necessary code changes might look like: - -```diff -- return ResponseBody::GraphQL(response); -+ return response; -``` -```diff -- if let ResponseBody::GraphQL(graphql_response) = res { -- assert_eq!(&graphql_response.errors[0], expected_error); -- } else { -- panic!("expected a graphql response"); -- } -+ assert_eq!(&res.errors[0], expected_error); -``` - -By [@SimonSapin](https://github.com/SimonSapin) - -### Fixed control flow in helm chart for volume mounts & environment variables ([PR #1283](https://github.com/apollographql/router/issues/1283)) - -You will now be able to actually use the helm chart without being on a managed graph. - -By [@LockedThread](https://github.com/LockedThread) in https://github.com/apollographql/router/pull/1283 - -### Fail when unknown fields are encountered in configuration ([PR #1278](https://github.com/apollographql/router/pull/1278)) - -Now if you add an unknown configuration field at the root of your configuration file it will return an error, rather than silently continuing with un-recognized options. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1278 - -## 🚀 Features - -### Allow custom subgraph-specific attributes to be added to emitted metrics ([PR #1300](https://github.com/apollographql/router/pull/1300)) - -Previously, it was only possible to add custom attributes from headers which the router received from the external GraphQL client. Now, you are able to add custom attributes coming from both the headers and the body of either the Router's or the Subgraph's router request or response. You also have the ability to add an attributes from the context. For example: - -```yaml -telemetry: - metrics: - common: - attributes: - router: - static: - - name: "version" - value: "v1.0.0" - request: - header: - - named: "content-type" - rename: "payload_type" - default: "application/json" - - named: "x-custom-header-to-add" - response: - body: - # Take element from the Router's JSON response body router located at a specific path - - path: .errors[0].extensions.status - name: error_from_body - context: - # Take element from the context within plugin chains and add it in attributes - - named: my_key - subgraph: - all: - static: - # Always insert this static value on all metrics for ALL Subgraphs - - name: kind - value: subgraph_request - subgraphs: - # Apply these only for the SPECIFIC subgraph named `my_subgraph_name` - my_subgraph_name: - request: - header: - - named: "x-custom-header" - body: - # Take element from the request body of the router located at this path (here it's the query) - - path: .query - name: query - default: UNKNOWN -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1300 - -### Add support for modifying variables from a plugin ([PR #1257](https://github.com/apollographql/router/pull/1257)) - -Previously, it was not possible to modify variables in a `Request` from a plugin. This is now supported via both Rust and Rhai plugins. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1257 - -## 🐛 Fixes - -### Extend fix for compression support to include the DIY Dockerfiles ([PR #1352](https://github.com/apollographql/router/pull/1352)) - -Compression support is now shown in the DIY Dockerfiles, as a followup to [PR #1279](https://github.com/apollographql/router/pull/1279). - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1352 - -### Improve URL parsing in endpoint configuration ([PR #1341](https://github.com/apollographql/router/pull/1341)) - -Specifying an endpoint in this form '127.0.0.1:431' resulted in an error: 'relative URL without a base'. The fix enhances the URL parsing logic to check for these errors and re-parses with a default scheme 'http://' so that parsing succeeds. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1341 - -### Improve configuration validation and environment expansion ([PR #1331](https://github.com/apollographql/router/pull/1331)) - -Environment expansion now covers the entire configuration file, and supports non-string types. - -This means that it is now possible to use environment variables in the `server` section of the YAML configuration, including numeric and boolean fields. - -Environment variables will always be shown in their original form within error messages to prevent leakage of secrets. - -These changes allow more of the configuration file to be validated via JSON-schema, as previously we just skipped errors where fields contained environment variables. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/1331 - -### Fix input coercion for a list ([PR #1327](https://github.com/apollographql/router/pull/1327)) - -The router is now following coercion rules for lists in accordance with [the GraphQL specification](https://spec.graphql.org/June2018/#sec-Type-System.List). In particular, this fixes the case when an input type of `[Int]` with only `1` provided as a value will now be properly coerced to `[1]`. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1327 - -### Returns HTTP 400 Bad Request, rather than a 500, when hitting a query planning error ([PR #1321](https://github.com/apollographql/router/pull/1321)) - -A query planning error cannot be retried, so this error code more correctly matches the failure mode and indicates to the client that it should not be retried without changing the request. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1321 - -### Re-enable the subgraph error-redaction functionality ([PR #1317](https://github.com/apollographql/router/pull/1317)) - -In a re-factoring the `include_subgraph_errors` plugin was disabled. This meant that subgraph error handling was not working as intended. This change re-enables it and improves the functionality with additional logging. As part of the fix, the plugin initialization mechanism was improved to ensure that plugins start in the required sequence. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1317 - -### Restrict static introspection to only `__schema` and `__type` ([PR #1299](https://github.com/apollographql/router/pull/1299)) -Queries with selected field names starting with `__` are recognized as introspection queries. This includes `__schema`, `__type` and `__typename`. However, `__typename` is introspection at query time which is different from `__schema` and `__type` because two of the later can be answered with queries with empty input variables. This change will restrict introspection to only `__schema` and `__type`. - -By [@dingxiangfei2009](https://github.com/dingxiangfei2009) in https://github.com/apollographql/router/pull/1299 - -### Fix plugin scaffolding support ([PR #1293](https://github.com/apollographql/router/pull/1293)) - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1293 - -### Support introspection object types ([PR #1240](https://github.com/apollographql/router/pull/1240)) - -Introspection queries can use a set of object types defined in the specification. The query parsing code was not recognizing them, resulting in some introspection queries not working. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1240 - -### Update the scaffold template so it works with streams ([#1247](https://github.com/apollographql/router/issues/1247)) - -Release v0.9.4 changed the way we deal with `Response` objects, which can now be streams. The scaffold template now generates plugins that are compatible with this new Plugin API. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1248 - -### Fix fragment selection on interfaces ([PR #1295](https://github.com/apollographql/router/pull/1295)) - -Fragments type conditions were not being checked correctly on interfaces, resulting in invalid null fields added to the response or valid data being incorrectly `null`-ified. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1295 - -### Fix fragment selection on queries ([PR #1296](https://github.com/apollographql/router/pull/1296)) - -The schema object can specify objects for queries, mutations or subscriptions that are not named `Query`, `Mutation` or `Subscription`. Response formatting now supports it. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1296 - -### Fix fragment selection on unions ([PR #1346](https://github.com/apollographql/router/pull/1346)) - -Fragments type conditions were not checked correctly on unions, resulting in data being absent. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1346 - -### Reduce `poll_ready` calls in query deduplication ([PR #1350](https://github.com/apollographql/router/pull/1350)) - -The query deduplication service was making assumptions on the underlying service's behaviour, which could result in subgraph services panicking. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1350 - -## 🛠 Maintenance - -### chore: Run scaffold tests in CI and xtask only ([PR #1345](https://github.com/apollographql/router/pull/1345)) - -Run the scaffold tests in CI and through xtask, to keep a steady feedback loop while developping against `cargo test`. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1345 - -### Update rhai to latest release (1.8.0) ([PR #1337](https://github.com/apollographql/router/pull/1337) - -We had been depending on a pinned git version which had a fix we required. This now updates to the latest release which includes the fix upstream. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1337 - -### Remove typed-builder ([PR #1218](https://github.com/apollographql/router/pull/1218)) -Migrate all typed-builders code to `buildstructor`. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/1218 - -# [0.9.5] - 2022-06-17 -## ❗ BREAKING ❗ - -### Move `experimental.traffic_shaping` out of `experimental` [PR #1229](https://github.com/apollographql/router/pull/1229) -You will need to update your YAML configuration file to use the correct name for `traffic_shaping` plugin. - -```diff -- plugins: -- experimental.traffic_shaping: -- variables_deduplication: true # Enable the variables deduplication optimization -- all: -- query_deduplication: true # Enable query deduplication for all subgraphs. -- subgraphs: -- products: -- query_deduplication: false # Disable query deduplication for products. -+ traffic_shaping: -+ variables_deduplication: true # Enable the variables deduplication optimization -+ all: -+ query_deduplication: true # Enable query deduplication for all subgraphs. -+ subgraphs: -+ products: -+ query_deduplication: false # Disable query deduplication for products. -``` - -### Rhai plugin `request.sub_headers` renamed to `request.subgraph.headers` [PR #1261](https://github.com/apollographql/router/pull/1261) - -Rhai scripts previously supported the `request.sub_headers` attribute so that subgraph request headers could be -accessed. This is now replaced with an extended interface for subgraph requests: - -``` -request.subgraph.headers -request.subgraph.body.query -request.subgraph.body.operation_name -request.subgraph.body.variables -request.subgraph.body.extensions -request.subgraph.uri.host -request.subgraph.uri.path -``` - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1261 - -## 🚀 Features - -### Add support of compression [PR #1229](https://github.com/apollographql/router/pull/1229) -Add support of request and response compression for the router and all subgraphs. The router is now able to handle `Content-Encoding` and `Accept-Encoding` headers properly. Supported algorithms are `gzip`, `br`, `deflate`. -You can also enable compression on subgraphs requests and responses by updating the `traffic_shaping` configuration: - -```yaml -traffic_shaping: - all: - compression: br # Enable brotli compression for all subgraphs - subgraphs: - products: - compression: gzip # Enable gzip compression only for subgraph products -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1229 - -### Add support of multiple uplink URLs [PR #1210](https://github.com/apollographql/router/pull/1210) -Add support of multiple uplink URLs with a comma-separated list in `APOLLO_UPLINK_ENDPOINTS` and for `--apollo-uplink-endpoints` - -Example: -```bash -export APOLLO_UPLINK_ENDPOINTS="https://aws.uplink.api.apollographql.com/, https://uplink.api.apollographql.com/" -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1210 - -### Add support for adding extra environment variables and volumes to helm chart [PR #1245](https://github.com/apollographql/router/pull/1245) -You can mount your `supergraph.yaml` into the helm deployment via configmap. Using [Kustomize](https://kustomize.io/) to generate your configmap from your supergraph.yaml is suggested. - -Example configmap.yaml snippet: -```yaml -supergraph.yaml: - server: - listen: 0.0.0.0:80 -``` - -Example helm config: -```yaml -extraEnvVars: - - name: APOLLO_ROUTER_SUPERGRAPH_PATH - value: /etc/apollo/supergraph.yaml - # sets router log level to debug - - name: APOLLO_ROUTER_LOG - value: debug -extraEnvVarsCM: '' -extraEnvVarsSecret: '' - -extraVolumes: - - name: supergraph-volume - configMap: - name: some-configmap -extraVolumeMounts: - - name: supergraph-volume - mountPath: /etc/apollo -``` - -By [@LockedThread](https://github.com/LockedThread) in https://github.com/apollographql/router/pull/1245 - -## 🐛 Fixes - -### Support introspection object types ([PR #1240](https://github.com/apollographql/router/pull/1240)) -Introspection queries can use a set of object types defined in the specification. The query parsing code was not recognizing them, -resulting in some introspection queries not working. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1240 - -### Update the scaffold template so that it works with streams ([#1247](https://github.com/apollographql/router/issues/1247)) -Release v0.9.4 changed the way we deal with `Response` objects, which can now be streams. -The scaffold template has been updated so that it generates plugins that are compatible with the new Plugin API. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1248 - -### Create the `ExecutionResponse` after the primary response was generated ([PR #1260](https://github.com/apollographql/router/pull/1260)) -The `@defer` preliminary work had a surprising side effect: when using methods like `RouterResponse::map_response`, they were -executed before the subgraph responses were received, because they work on the stream of responses. -This PR goes back to the previous behaviour by awaiting the primary response before creating the `ExecutionResponse`. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1260 - -### Use the API schema to generate selections ([PR #1255](https://github.com/apollographql/router/pull/1255)) -When parsing the schema to generate selections for response formatting, we should use the API schema instead of the supergraph schema. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1255 - -## 📚 Documentation - -### Update README link to the configuration file ([PR #1208](https://github.com/apollographql/router/pull/1208)) -As the structure of the documentation has changed, the link should point to the `YAML config file` section of the overview. - -By [@gscheibel](https://github.com/gscheibel in https://github.com/apollographql/router/pull/1208 - - - -# [0.9.4] - 2022-06-14 - -## ❗ BREAKING ❗ - - -### Groundwork for `@defer` support ([PR #1175](https://github.com/apollographql/router/pull/1175)[PR #1206](https://github.com/apollographql/router/pull/1206)) -To prepare for the implementation of the `@defer` directive, the `ExecutionResponse` and `RouterResponse` types now carry a stream of responses instead of a unique response. For now that stream contains only one item, so there is no change in behaviour. However, the Plugin trait changed to accomodate this, so a couple of steps are required to migrate your plugin so that it is compatible with versions of the router >= v0.9.4: - -- Add a dependency to futures in your Cargo.toml: - -```diff -+futures = "0.3.21" -``` - -- Import `BoxStream`, and if your Plugin defines a `router_service` behavior, import `ResponseBody`: - -```diff -+ use futures::stream::BoxStream; -+ use apollo_router::ResponseBody; -``` - -- Update the `router_service` and the `execution_service` sections of your Plugin (if applicable): - -```diff - fn router_service( - &mut self, -- service: BoxService, -- ) -> BoxService { -+ service: BoxService>, BoxError>, -+ ) -> BoxService>, BoxError> { - -[...] - - fn execution_service( - &mut self, -- service: BoxService, -- ) -> BoxService { -+ service: BoxService>, BoxError>, -+ ) -> BoxService>, BoxError> { -``` - -We can now update our unit tests so they work on a stream of responses instead of a single one: - -```diff - // Send a request -- let result = test_harness.call_canned().await?; -- if let ResponseBody::GraphQL(graphql) = result.response.body() { -+ let mut result = test_harness.call_canned().await?; -+ -+ let first_response = result -+ .next_response() -+ .await -+ .expect("couldn't get primary response"); -+ -+ if let ResponseBody::GraphQL(graphql) = first_response { - assert!(graphql.data.is_some()); - } else { - panic!("expected graphql response") - } - -+ // You could keep calling result.next_response() until it yields None if you are expexting more parts. -+ assert!(result.next_response().await.is_none()); - Ok(()) - } -``` - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1206 -### The `apollo-router-core` crate has been merged into `apollo-router` ([PR #1189](https://github.com/apollographql/router/pull/1189)) - -To upgrade, remove any dependency on the `apollo-router-core` crate from your `Cargo.toml` files and change imports like so: - -```diff -- use apollo_router_core::prelude::*; -+ use apollo_router::prelude::*; -``` - -By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/1189 - - -### Fix input validation rules ([PR #1211](https://github.com/apollographql/router/pull/1211)) -The GraphQL specification provides two sets of coercion / validation rules, depending on whether we're dealing with inputs or outputs. -We have added validation rules for specified input validations which were not previously implemented. -This is a breaking change since slightly invalid input may have validated before but will now be guarded by newly-introduced validation rules. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1211 - -## 🚀 Features -### Add trace logs for parsing recursion consumption ([PR #1222](https://github.com/apollographql/router/pull/1222)) -The `apollo-parser` package now implements recursion limits which can be examined after the parsing phase. The router logs these -out at `trace` level. You can see them in your logs by searching for "`recursion_limit`". For example, when using JSON logging -and using `jq` to filter the output: - -``` -router -s ../graphql/supergraph.graphql -c ./router.yaml --log trace | jq -c '. | select(.fields.message == "recursion limit data")' -{"timestamp":"2022-06-10T15:01:02.213447Z","level":"TRACE","fields":{"message":"recursion limit data","recursion_limit":"recursion limit: 4096, high: 0"},"target":"apollo_router::spec::schema"} -{"timestamp":"2022-06-10T15:01:02.261092Z","level":"TRACE","fields":{"message":"recursion limit data","recursion_limit":"recursion limit: 4096, high: 0"},"target":"apollo_router::spec::schema"} -{"timestamp":"2022-06-10T15:01:07.642977Z","level":"TRACE","fields":{"message":"recursion limit data","recursion_limit":"recursion limit: 4096, high: 4"},"target":"apollo_router::spec::query"} -``` - -This example output shows that the maximum recursion limit is 4096 and that the query we processed caused us to recurse 4 times. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1222 - -### Helm chart now has the option to use an existing secrets for API key [PR #1196](https://github.com/apollographql/router/pull/1196) - -This change allows the use of an already existing secret for the graph API key. - -To use existing secrets, update your own `values.yaml` file or specify the value on your `helm install` command line. For example: - -``` -helm install --set router.managedFederation.existingSecret="my-secret-name" ` -``` - -By [@pellizzetti](https://github.com/pellizzetti) in https://github.com/apollographql/router/pull/1196 - -### Add iterators to `Context` ([PR #1202](https://github.com/apollographql/router/pull/1202)) -Context can now be iterated over, with two new methods: - - - `iter()` - - `iter_mut()` - -These implementations lean heavily on an underlying [`DashMap`](https://docs.rs/dashmap/5.3.4/dashmap/struct.DashMap.html#method.iter) implemetation, so refer to its documentation for more usage details. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1202 - -### Add an experimental optimization to deduplicate variables in query planner ([PR #872](https://github.com/apollographql/router/pull/872)) -Get rid of duplicated variables in requests and responses of the query planner. This optimization is disabled by default, if you want to enable it you just need override your configuration: - -```yaml title="router.yaml" -plugins: - experimental.traffic_shaping: - variables_deduplication: true # Enable the variables deduplication optimization -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/872 - -### Add more customizable metrics ([PR #1159](https://github.com/apollographql/router/pull/1159)) - -Added the ability to apply custom attributes/labels to metrics which are derived from header values using the Router's configuration file. For example: - -```yaml -telemetry: - metrics: - common: - attributes: - static: - - name: "version" - value: "v1.0.0" - from_headers: - - named: "content-type" - rename: "payload_type" - default: "application/json" - - named: "x-custom-header-to-add" -``` - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1159 - -### Allow to set a custom health check path ([PR #1164](https://github.com/apollographql/router/pull/1164)) -Added the possibility to set a custom health check path -```yaml -server: - # Default is /.well-known/apollo/server-health - health_check_path: /health -``` - -By [@jcaromiq](https://github.com/jcaromiq) in https://github.com/apollographql/router/pull/1164 - -## 🐛 Fixes - -### Pin `clap` dependency in `Cargo.toml` ([PR #1232](https://github.com/apollographql/router/pull/1232)) - -A minor release of `Clap` occured yesterday which introduced a breaking change. This change might lead `cargo scaffold` users to hit a panic a runtime when the router tries to parse environment variables and arguments. - -This patch pins the `clap` dependency to the version that was available before the release, until the root cause is found and fixed upstream. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1232 - -### Display better error message when on subgraph fetch errors ([PR #1201](https://github.com/apollographql/router/pull/1201)) - -Show a helpful error message when a subgraph does not return JSON or a bad status code - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1201 - -### Fix CORS configuration to eliminate runtime panic on misconfiguration ([PR #1197](https://github.com/apollographql/router/pull/1197)) - -Previously, it was possible to specify a CORS configuration which was syntactically valid, but which could not be enforced at runtime. For example, consider the following *invalid* configuration where the `allow_any_origin` and `allow_credentials` parameters are inherantly incompatible with each other (per the CORS specification): - -```yaml -server: - cors: - allow_any_origin: true - allow_credentials: true -``` - -Previously, this would result in a runtime panic. The router will now detect this kind of misconfiguration and report the error without panicking. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1197 - -## 🛠 Maintenance - -### Fix a flappy test to test custom health check path ([PR #1176](https://github.com/apollographql/router/pull/1176)) -Force the creation of `SocketAddr` to use a new unused port to avoid port collisions during testing. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1176 - -### Add static `@skip`/`@include` directive support ([PR #1185](https://github.com/apollographql/router/pull/1185)) - -- Rewrite the `InlineFragment` implementation -- Add support of static check for `@include` and `@skip` directives - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1185 - -### Update `buildstructor` to 0.3 ([PR #1207](https://github.com/apollographql/router/pull/1207)) - -Update `buildstructor` to v0.3. - -By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/1207 - -# [0.9.3] - 2022-06-01 - -## ❗ BREAKING ❗ - -## 🚀 Features -### Scaffold custom binary support ([PR #1104](https://github.com/apollographql/router/pull/1104)) - -Added CLI support for scaffolding a new Router binary project. This provides a starting point for people who want to use the Router as a library and create their own plugins - -By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/1104 - -### rhai `Context::upsert()` supported with example ([Issue #648](https://github.com/apollographql/router/issues/648)) - -Rhai plugins can now interact with `Context::upsert()`. We provide an [example in `./examples/rhai-surrogate-cache-key`](https://github.com/apollographql/router/tree/main/examples/rhai-surrogate-cache-key) to illustrate its use. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1136 - -### Measure APQ cache hits and registers ([Issue #1014](https://github.com/apollographql/router/issues/1014)) - -The APQ layer will now report cache hits and misses to Apollo Studio if telemetry is configured - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1117 - -### Add more information to the `subgraph_request` span ([PR #1119](https://github.com/apollographql/router/pull/1119)) - -Add a new span only for the subgraph request, with all HTTP and net information needed for the OpenTelemetry specs. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1119 - -## 🐛 Fixes - -### Compute default port in span information ([Issue #1160](https://github.com/apollographql/router/pull/1160)) - -Compute default port in span information for `net.peer.port` regarding the scheme of the request URI. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1160 - -### Response `Content-Type` is, again, `application/json` ([Issue #636](https://github.com/apollographql/router/issues/636)) - -The router was not setting a `content-type` on client responses. This fix ensures that a `content-type` of `application/json` is set when returning a GraphQL response. - -By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1154 - -### Prevent memory leaks when tasks are cancelled ([PR #767](https://github.com/apollographql/router/pull/767)) - -Cancelling a request could put the router in an unresponsive state where the deduplication layer or cache would make subgraph requests hang. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/767 - -## 🛠 Maintenance - -### Use subgraphs deployed on Fly.io in CI ([PR #1090](https://github.com/apollographql/router/pull/1090)) - -The CI needs some Node.js subgraphs for integration tests, which complicates its setup and increases the run time. By deploying, in advance, those subgraphs on Fly.io, we can simplify the CI run. - -By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/1090 - -### Unpin schemars version ([Issue #1074](https://github.com/apollographql/router/issues/1074)) - -[`schemars`](https://docs.rs/schemars/latest/schemars/) v0.8.9 caused compile errors due to it validating default types. This change has, however, been rolled back upstream and we can now depend on `schemars` v0.8.10. - -By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/1135 - -### Update Moka to fix occasional panics on AMD hardware ([Issue #1137](https://github.com/apollographql/router/issues/1137)) - -Moka has a dependency on Quanta which had an issue with AMD hardware. This is now fixed via https://github.com/moka-rs/moka/issues/119 - -By [@BrynCooke](https://github.com/BrynCooke) in [`6b20dc85`](https://github.com/apollographql/router/commit/6b20dc8520ca03384a4eabac932747fc3a9358d3) - -## 📚 Documentation - -### rhai `Context::upsert()` supported with example ([Issue #648](https://github.com/apollographql/router/issues/648)) - -Rhai documentation now illustrates how to use `Context::upsert()` in rhai code. - -By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/1136 - -# [0.9.2] - 2022-05-20 - -## ❗ BREAKING ❗ - -### Simplify Context::upsert() [PR #1073](https://github.com/apollographql/router/pull/1073) -Removes the `default` parameter and requires inserted values to implement `Default`. - -## 🚀 Features - -### DIY docker images script [PR #1106](https://github.com/apollographql/router/pull/1106) -The `build_docker_image.sh` script shows how to build docker images from our GH release tarballs or from a commit hash/tag against the router repo. - -## 🐛 Fixes - -### Return top `__typename` field when it's not an introspection query [PR #1102](https://github.com/apollographql/router/pull/1102) -When `__typename` is used at the top of the query in combination with other fields it was not returned in the output. - -### Fix the installation and releasing script for Windows [PR #1098](https://github.com/apollographql/router/pull/1098) -Do not put .exe for Windows in the name of the tarball when releasing new version - -### Aggregate usage reports in streaming and set the timeout to 5 seconds [PR #1066](https://github.com/apollographql/router/pull/1066) -The metrics plugin was allocating chunks of usage reports to aggregate them right after, this was replaced by a streaming loop. The interval for sending the reports to spaceport was reduced from 10s to 5s. +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7684 -### Fix the environment variable expansion for telemetry endpoints [PR #1092](https://github.com/apollographql/router/pull/1092) -Adds the ability to use environment variable expansion for the configuration of agent/collector endpoint for Jaeger, OTLP, Datadog. +### Remove use of APOLLO_TELEMETRY_DISABLED from the fleet detector plugin ([PR #7907](https://github.com/apollographql/router/pull/7907)) -### Fix the introspection query detection [PR #1100](https://github.com/apollographql/router/pull/1100) -Fix the introspection query detection, for example if you only have `__typename` in the query then it's an introspection query, if it's used with other fields (not prefixed by `__`) then it's not an introspection query. +The `APOLLO_TELEMETRY_DISABLED` environment variable only disables anonymous telemetry, it was never meant for disabling identifiable telemetry. This includes metrics from the fleet detection plugin. -## 🛠 Maintenance +By [@DMallare](https://github.com/DMallare) in https://github.com/apollographql/router/pull/7907 -### Add well known query to `PluginTestHarness` [PR #1114](https://github.com/apollographql/router/pull/1114) -Add `call_canned` on `PluginTestHarness`. It performs a well known query that will generate a valid response. +# [2.4.0] - 2025-06-30 -### Remove the batching and timeout from spaceport [PR #1080](https://github.com/apollographql/router/pull/1080) -Apollo Router is already handling report aggregation and sends the report every 5s. Now spaceport will put the incoming reports in a bounded queue and send them in order, with backpressure. +## 🚀 Features -## 📚 Documentation +### Support JWT audience (`aud`) validation ([PR #7578](https://github.com/apollographql/router/pull/7578)) -### Add CORS documentation ([PR #1044](https://github.com/apollographql/router/pull/1044)) -Updated the CORS documentation to reflect the recent [CORS and CSRF](https://github.com/apollographql/router/pull/1006) updates. +The router now supports JWT audience (`aud`) validation. This allows the router to ensure that the JWT is intended +for the specific audience it is being used with, enhancing security by preventing token misuse across different audiences. +The following sample configuration will validate the JWT's `aud` claim against the specified audiences and ensure a match with either `https://my.api` or `https://my.other.api`. If the `aud` claim does not match either of those configured audiences, the router will reject the request. -# [0.9.1] - 2022-05-17 +```yaml +authentication: + router: + jwt: + jwks: # This key is required. + - url: https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json + issuers: # optional list of issuers + - https://issuer.one + - https://issuer.two + audiences: # optional list of audiences + - https://my.api + - https://my.other.api + poll_interval: + headers: # optional list of static headers added to the HTTP request to the JWKS URL + - name: User-Agent + value: router + # These keys are optional. Default values are shown. + header_name: Authorization + header_value_prefix: Bearer + on_error: Error + # array of alternative token sources + sources: + - type: header + name: X-Authorization + value_prefix: Bearer + - type: cookie + name: authz +``` -## ❗ BREAKING ❗ +By [@Velfi](https://github.com/Velfi) in https://github.com/apollographql/router/pull/7578 -### Remove command line options `--apollo-graph-key` and `--apollo-graph-ref` [PR #1069](https://github.com/apollographql/router/pull/1069) -Using these command lime options exposes sensitive data in the process list. Setting via environment variables is now the only way that these can be set. -In addition these setting have also been removed from the telemetry configuration in yaml. +### Prioritize existing requests over query parsing and planning during "warm up" ([PR #7223](https://github.com/apollographql/router/pull/7223)) -## 🐛 Fixes -### Pin schemars version to 0.8.8 [PR #1075](https://github.com/apollographql/router/pull/1075) -The Schemars 0.8.9 causes compile errors due to it validating default types. Pin the version to 0.8.8. -See issue [#1074](https://github.com/apollographql/router/issues/1074) +The router warms up its query planning cache during a hot reload. This change decreases the priority +of warm up tasks in the compute job queue to reduce the impact of warmup on serving requests. -### Fix infinite recursion on during parsing [PR #1078](https://github.com/apollographql/router/pull/1078) -During parsing of queries the use of `"` in a parameter value caused infinite recursion. This preliminary fix will be revisited shortly. -## 📚 Documentation +This change adds new values to the `job.type` dimension of the following metrics: +- `apollo.router.compute_jobs.duration` - A histogram of time spent in the compute pipeline by the job, including the queue and query planning. + - `job.type`: (`query_planning`, `query_parsing`, `introspection`, **`query_planning_warmup`, `query_parsing_warmup`**) + - `job.outcome`: (`executed_ok`, `executed_error`, `channel_error`, `rejected_queue_full`, `abandoned`) +- `apollo.router.compute_jobs.queue.wait.duration` - A histogram of time spent in the compute queue by the job. + - `job.type`: (`query_planning`, `query_parsing`, `introspection`, **`query_planning_warmup`, `query_parsing_warmup`**) +- `apollo.router.compute_jobs.execution.duration` - A histogram of time spent to execute job (excludes time spent in the queue). + - `job.type`: (`query_planning`, `query_parsing`, `introspection`, **`query_planning_warmup`, `query_parsing_warmup`**) +- `apollo.router.compute_jobs.active_jobs` - A gauge of the number of compute jobs being processed in parallel. + - `job.type`: (`query_planning`, `query_parsing`, `introspection`, **`query_planning_warmup`, `query_parsing_warmup`**) -### Document available metrics in Prometheus [PR #1067](https://github.com/apollographql/router/pull/1067) -Add the list of metrics you can have using Prometheus +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7223 -# [v0.9.0] - 2022-05-13 +### Persisted queries: include operation name in `PERSISTED_QUERY_NOT_IN_LIST` error for debuggability ([PR #7768](https://github.com/apollographql/router/pull/7768)) -## 🎉 **The Apollo Router has graduated from _Preview_ to _General Availability (GA)_!** 🎉 +When persisted query safelisting is enabled and a request has an unknown PQ ID, the GraphQL error now has the extension field `operation_name` containing the GraphQL operation name (if provided explicitly in the request). Note that this only applies to the `PERSISTED_QUERY_NOT_IN_LIST` error returned when manifest-based PQs are enabled, APQs are disabled, and the request contains an operation ID that is not in the list. -We're so grateful for all the feedback we've received from our early Router adopters and we're excited to bring the Router to our General Availability (GA) release. +By [@glasser](https://github.com/glasser) in https://github.com/apollographql/router/pull/7768 -We hope you continue to report your experiences and bugs to our team as we continue to move things forward. If you're having any problems adopting the Router or finding the right migration path from Apollo Gateway which isn't already covered [in our migration guide](https://www.apollographql.com/docs/router/migrating-from-gateway), please open an issue or discussion on this repository! +## Introduce cooperative cancellation for query planning -## ❗ BREAKING ❗ +The cooperative cancellation feature allows the router to gracefully handle query planning timeouts and cancellations, improving resource utilization. -### Remove the agent endpoint configuration for Zipkin [PR #1025](https://github.com/apollographql/router/pull/1025) +The `mode` can be set to `measure` or `enforce`. We recommend starting with `measure`. In `measure` mode, the router will measure the time taken for query planning and emit metrics accordingly. In `enforce` mode, the router will cancel query planning operations that exceed the specified timeout. -Zipkin only supports `endpoint` URL configuration rather than `endpoint` within `collector`, this means Zipkin configuration changes from: +To observe this behavior, the router telemetry has been updated: -```yaml -telemetry: - tracing: - trace_config: - service_name: router - zipkin: - collector: - endpoint: default -``` +- Add an `outcome` attribute to the `apollo.router.query_planning.plan.duration` metric +- Add an `outcome` attribute to the `query_planning` span -to: +Below is a sample configuration to configure cooperative cancellation in measure mode: ```yaml -telemetry: - tracing: - trace_config: - service_name: router - zipkin: - endpoint: default +supergraph: + query_planning: + experimental_cooperative_cancellation: + enabled: true + mode: measure + timeout: 1s ``` -### CSRF Protection is enabled by default [PR #1006](https://github.com/apollographql/router/pull/1006) - -A [Cross-Site Request Forgery (CSRF) protection plugin](https://developer.mozilla.org/en-US/docs/Glossary/CSRF) is now enabled by default. +By [@Velfi](https://github.com/Velfi) in https://github.com/apollographql/router/pull/7604 -This means [simple requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests) will be rejected from now on, since they represent security risks without the correct CSRF protections in place. - -The plugin can be customized as explained in the [CORS and CSRF example](https://github.com/apollographql/router/tree/main/examples/cors-and-csrf/custom-headers.router.yaml). - -### CORS default behavior update [PR #1006](https://github.com/apollographql/router/pull/1006) - -The CORS `allow_headers` default behavior has changed from its previous configuration. +## 🐛 Fixes -The Router will now _reflect_ the values received in the [`Access-Control-Request-Headers`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers) header, rather than only allowing `Content-Type`, `apollographql-client-name` and `apollographql-client-version` as it did previously. +### Align `on_graphql_error` selector with `subgraph_on_graphql_error` ([PR #7676](https://github.com/apollographql/router/pull/7676)) -This change loosens the CORS-related headers restrictions, so it shouldn't have any impact on your setup. +The `on_graphql_error` selector will now return `true` or `false`, in alignment with the `subgraph_on_graphql_error` selector. Previously, the selector would return `true` or `None`. -## 🚀 Features +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7676 -### CSRF Protection [PR #1006](https://github.com/apollographql/router/pull/1006) -The router now embeds a CSRF protection plugin, which is enabled by default. Have a look at the [CORS and CSRF example](https://github.com/apollographql/router/tree/main/examples/cors-and-csrf/custom-headers.router.yaml) to learn how to customize it. [Documentation](https://www.apollographql.com/docs/router/configuration/cors/) will be updated soon! +### Return valid GraphQL response when performing a websocket handshake ([PR #7680](https://github.com/apollographql/router/pull/7680)) -### Helm chart now supports prometheus metrics [PR #1005](https://github.com/apollographql/router/pull/1005) -The router has supported exporting prometheus metrics for a while. This change updates our helm chart to enable router deployment prometheus metrics. +[PR #7141](https://github.com/apollographql/router/pull/7141) added checks on GraphQL responses returned from coprocessors to ensure compliance with GraphQL specifications. This surfaced an issue where subscription responses over websockets could omit the required `data` field during the handshake, resulting in invalid GraphQL response payloads. All websocket subscription responses will now return a valid GraphQL response when doing the websocket handshake. -Configure by updating your values.yaml or by specifying the value on your helm install command line. +By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/7680 -e.g.: helm install --set router.configuration.telemetry.metrics.prometheus.enabled=true +### Fix SigV4 configuration handling ([PR #7726](https://github.com/apollographql/router/pull/7726)) -> Note: Prometheus metrics are not enabled by default in the helm chart. +Fixed an issue introduced in Router 2.3.0 where some SigV4 configurations would fail to start, preventing communication with SigV4-enabled services. -### Extend capabilities of rhai processing engine [PR #1021](https://github.com/apollographql/router/pull/1021) +By [@dylan-apollo](https://github.com/dylan-apollo) in https://github.com/apollographql/router/pull/7726 -- Rhai plugins can now interact more fully with responses, including **body and header manipulation** where available. -- Closures are now supported for callback processing. -- Subgraph services are now identified by name. +### Improve error message for invalid variables ([Issue #2984](https://github.com/apollographql/router/issues/2984)) -There is more documentation about how to use the various rhai interfaces to the Router and we now have _six_ [examples of rhai scripts](https://github.com/apollographql/router/tree/main/examples) (look for examples prefixed with `rhai-`) doing various request and response manipulations! +When a variable in a GraphQL request is missing or contains an invalid value, the router now returns more useful error messages. Example: -## 🐛 Fixes +```diff +-invalid type for variable: 'x' ++invalid input value at x.coordinates[0].longitude: found JSON null for GraphQL Float! +``` -### Remove the requirement on `jq` in our install script [PR #1034](https://github.com/apollographql/router/pull/1034) +By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/7567 -We're now using `cut` command instead of `jq` which allows using our installer without installing `jq` first. (Don't get us wrong, we love `jq`, but not everyone has it installed!). +### Support exporting resources on all Prometheus metrics ([PR #7394](https://github.com/apollographql/router/pull/7394)) -### Configuration for Jaeger/Zipkin agent requires an URL instead of a socket address [PR #1018](https://github.com/apollographql/router/pull/1018) -The router now supports URLs for a Jaeger **or** Zipkin agent allowing configuration as follows in this `jaeger` example: +By default, the Prometheus metrics exporter will only export resources as `target_info` metrics, not inline on every metric. Now, you can add resources to every metric by setting `resource_selector` to `all` (default is `none`). ```yaml telemetry: - tracing: - trace_config: - service_name: router - jaeger: - agent: - endpoint: jaeger:14268 + exporters: + metrics: + common: + resource: + "test-resource": "test" + prometheus: + enabled: true + resource_selector: all # This will add resources on every metrics ``` -### Fix a panic in Zipkin telemetry configuration [PR #1019](https://github.com/apollographql/router/pull/1019) -Using the `reqwest` blocking client feature was causing panicking due to an incompatible usage of an asynchronous runtime. - -### Improvements to Apollo Studio reporting [PR #1020](https://github.com/apollographql/router/pull/1020), [PR #1037](https://github.com/apollographql/router/pull/1037) - -This architectural change, which moves the location that we do aggregations internally in the Router, allows us to move towards full reporting functionality. It shouldn't affect most users. -### Field usage reporting is now reported against the correct schema [PR #1043](https://github.com/apollographql/router/pull/1043) +Note: this change only affects Prometheus, not OTLP. -When using Managed Federation, we now report usage identified by the schema it was processed on, improving reporting in Apollo Studio. +By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/7394 -### Check that an object's `__typename` is part of the schema [PR #1033](https://github.com/apollographql/router/pull/1033) +### Forbid unknown `@link` directives for supergraph schemas where `purpose` is `EXECUTION` or `SECURITY` -In case a subgraph returns an object with a `__typename` field referring to a type that is not in the API schema, as is the case when using the `@inaccessible` directive on object types, the requested object tree is now replaced with a `null` value in order to conform with the API schema. This improves our behavior with the recently launched Contracts feature from Apollo Studio. +The legacy JavaScript query planner forbid any usage of unknown `@link` specs in supergraph schemas with either `EXECUTION` or `SECURITY` value set for the `for` argument (aka, the spec's "purpose"). This behavior had not been ported to the native query planner previously. This PR implements the expected behavior in the native query planner. -## 🛠 Maintenance - -### OpenTracing examples [PR #1015](https://github.com/apollographql/router/pull/1015) - -We now have complete examples of OpenTracing usage with Datadog, Jaeger and Zipkin, that can be started with docker-compose. - -## 📚 Documentation -### Add documentation for the endpoint configuration in server ([PR #1000](https://github.com/apollographql/router/pull/1000)) - -Documentation about setting a custom endpoint path for GraphQL queries has been added. - -Also, we reached issue / pull-request number ONE THOUSAND! (💯0) - -# [v0.9.0-rc.0] - 2022-05-10 +By [@duckki](https://github.com/duckki) in https://github.com/apollographql/router/pull/7587 -## 🎉 **The Apollo Router has graduated to its Release Candidate (RC) phase!** 🎉 +### Supergraph stage correctly receives `on_graphql_error` selector ([PR #7669](https://github.com/apollographql/router/pull/7669)) -We're so grateful for all the feedback we've received from our early Router adopters and we're excited to bring things even closer to our General Availability (GA) release. +The `on_graphql_error` selector will now correctly fire on the supergraph stage; previously it only worked on the router stage. -We hope you continue to report your experiences and bugs to our team as we continue to move things forward. If you're having any problems adopting the Router or finding the right migration path from Apollo Gateway which isn't already covered [in our migration guide](https://www.apollographql.com/docs/router/migrating-from-gateway), please open an issue or discussion on this repository! -## ❗ BREAKING ❗ +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7669 -### Renamed environment variables for consistency [PR #990](https://github.com/apollographql/router/pull/990) [PR #992](https://github.com/apollographql/router/pull/992) +### Invalid type condition in `@defer` fetch -We've adjusted the environment variables that the Router supports to be consistently prefixed with `APOLLO_` and to remove some inconsistencies in their previous naming. +The query planner was adding an inline spread (`...`) conditioned on the `Query` type in deferred subgraph fetch queries. Such a query would be invalid in the subgraph when the subgraph schema renamed the root `query` type to somethhing other than `Query`. The fix removes the root type condition from all subgraph queries, so that they stay valid even when root types are renamed. -You'll need to adjust to the new environment variable names, as follows: +By [@duckki](https://github.com/duckki) in https://github.com/apollographql/router/pull/7580 -- `RUST_LOG` -> `APOLLO_ROUTER_LOG` -- `CONFIGURATION_PATH` -> `APOLLO_ROUTER_CONFIG_PATH` -- `SUPERGRAPH_PATH` -> `APOLLO_ROUTER_SUPERGRAPH_PATH` -- `ROUTER_HOT_RELOAD` -> `APOLLO_ROUTER_HOT_RELOAD` -- `APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT` -> `APOLLO_UPLINK_ENDPOINTS` -- `APOLLO_SCHEMA_POLL_INTERVAL`-> `APOLLO_UPLINK_POLL_INTERVAL` +### Preserve `content-type` for file uploads when Rhai scripts are in use ([PR #7559](https://github.com/apollographql/router/pull/7559)) -In addition, the following command line flags have changed: -- `--apollo-schema-config-delivery-endpoint` -> `--apollo-uplink-url` -- `--apollo-schema-poll-interval` -> `--apollo-uplink-poll-interval` +If a Rhai script was invoked during file upload processing, then the "Content-Type" of the request was not preserved correctly. This would cause a file upload to fail. -### Configurable URL request path [PR #976](https://github.com/apollographql/router/pull/976) +The error message would be something like: -The default router endpoint is now `/` (previously, it was `/graphql`). It's now possible to customize that value by defining an `endpoint` in your Router configuration file's `server` section: - -```yaml -server: - # The socket address and port to listen on - # Defaults to 127.0.0.1:4000 - listen: 127.0.0.1:4000 - # Default is / - endpoint: /graphql +``` +"message": "invalid multipart request: Content-Type is not multipart/form-data", ``` -If you necessitated the previous behavior (using `/graphql`), you should use the above configuration. - -### Do even more with rhai scripts [PR #971](https://github.com/apollographql/router/pull/971) - -The rhai scripting support in the Router has been re-worked to bring its capabilities closer to that native Rust plugin. This includes full participation in the service plugin lifecycle and new capabilities like logging support! - -See our [`examples`](https://github.com/apollographql/router/tree/main/examples/) directory and [the documentation](https://www.apollographql.com/docs/router/customizations/rhai) for updated examples of how to use the new capabilities. +This issue has now been fixed. -## 🚀 Features +By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/7559 -### Did we already mention doing more with rhai? +### Improve OTLP metric HTTP endpoint behavior ([PR #7595](https://github.com/apollographql/router/pull/7595)) -It's listed as a breaking change above because it is, but it's worth highlighting that it's now possible to do even more using rhai scripting which previously necessitated writing native Rust plugins and compiling your own binary. +We made substantial updates to OpenTelemetry in router 2.0, but didn't catch that OpenTelemetry changed how it processed "endpoints" (destinations for metrics and traces) until now. -See our [`examples`](https://github.com/apollographql/router/tree/main/examples/) directory and [the documentation](https://www.apollographql.com/docs/router/customizations/rhai) for updated examples of how to use the new capabilities. +With the undetected change, the router wasn't setting the path correctly, resulting in failure to export metrics over HTTP when using the "default" endpoint. **Neither metrics via gRPC nor traces were impacted**. -### Panics now output to the console [PR #1001](https://github.com/apollographql/router/pull/1001) [PR #1004](https://github.com/apollographql/router/pull/1004) -Previously, panics would get swallowed but are now output to the console/logs. The use of the Rust-standard environment variables `RUST_BACKTRACE=1` (or `RUST_BACKTRACE=full`) will result in emitting the full backtrace. +We have fixed our interactions with the dependency and improved our testing to make sure this does not occur again. Additionally, the router now supports setting standard OpenTelemetry environment variables for endpoints. -### Apollo Studio Usage Reporting [PR #898](https://github.com/apollographql/router/pull/898) -If you have [enabled telemetry in the Router](https://www.apollographql.com/docs/router/configuration/apollo-telemetry#enabling-usage-reporting), you can now see field usage reporting for your queries by heading to the Fields page for your graph in Apollo Studio. +There is still a known problem when using environment variables to configure endpoints for the HTTP protocol when transmitting to an un-encrypted endpoint (i.e., TLS not configured). This affects the following environment variables: -Learn more about our field usage reporting in the Studio [documentation for field usage](https://www.apollographql.com/docs/studio/metrics/field-usage). +- `OTEL_EXPORTER_OTLP_ENDPOINT` +- `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` +- `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` -### `PluginTestHarness` [PR #898](https://github.com/apollographql/router/pull/898) -Added a simple plugin test harness that can provide canned responses to queries. This harness is early in development and the functionality and APIs will probably change. -```rust - let mut test_harness = PluginTestHarness::builder() - .plugin(plugin) - .schema(Canned) - .build() - .await?; +When these environment variables are set to insecure hosts, messages will appear in the logs indicating an error, but **the metrics and traces will still be sent correctly**: -let _ = test_harness - .call( - RouterRequest::fake_builder() - .header("name_header", "test_client") - .header("version_header", "1.0-test") - .query(query) - .and_operation_name(operation_name) - .and_context(context) - .build()?, - ) - .await; ``` -## 🐛 Fixes - -### Improve the diagnostics when encountering a configuration error [PR #963](https://github.com/apollographql/router/pull/963) -In the case of unrecognized properties in your Router's configuration, we will now point you directly to the unrecognized value. Previously, we pointed to the parent property even if it wasn't the source of the misconfiguration. - -### Only allow mutations on HTTP POST requests [PR #975](https://github.com/apollographql/router/pull/975) -Mutations are now only accepted when using the HTTP POST method. - -### Fix incorrectly omitting content of interface's fragment [PR #949](https://github.com/apollographql/router/pull/949) -The Router now distinguishes between fragments on concrete types and interfaces. -If an interface is encountered and `__typename` is being queried, we now check that the returned type implements the interface. - -### Set the service name if not specified in config or environment [PR #960](https://github.com/apollographql/router/pull/960) -The router now sets `router` as the default service name in OpenTelemetry traces, along with `process.executable_name`. This can be adjusted through the configuration file or environment variables. - -### Accept an endpoint URL without scheme for telemetry [PR #964](https://github.com/apollographql/router/pull/964) +2025-06-06T15:12:47.992144Z ERROR OpenTelemetry metric error occurred: Metrics exporter otlp failed with the grpc server returns error (Unknown error): , detailed error message: h2 protocol error: http2 error tonic::transport::Error(Transport, hyper::Error(Http2, Error { kind: GoAway(b"", FRAME_SIZE_ERROR, Library) })) +2025-06-06T15:12:47.992763Z ERROR OpenTelemetry trace error occurred: Exporter otlp encountered the following error(s): the grpc server returns error (Unknown error): , detailed error message: h2 protocol error: http2 error tonic::transport::Error(Transport, hyper::Error(Http2, Error { kind: GoAway(b"", FRAME_SIZE_ERROR, Library) })) +``` -Endpoint configuration for Datadog and OTLP take a URL as argument, but was incorrectly recognizing addresses of the format "host:port" (i.e., without a scheme, like `grpc://`) as the wrong protocol. This has been corrected! +This is tracked upstream at https://github.com/open-telemetry/opentelemetry-collector/issues/10952. -### Stricter application of `@inaccessible` [PR #985](https://github.com/apollographql/router/pull/985) +By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/7595 -The Router's query planner has been updated to v2.0.2 and stricter behavior for the `@inaccessible` directive. This also fully supports the new [Apollo Studio Contracts](https://www.apollographql.com/docs/studio/contracts/) feature which just went generally available (GA). +### Add `graphql.operation.name` attribute to `apollo.router.opened.subscriptions` counter ([PR #7606](https://github.com/apollographql/router/pull/7606)) -### Impose recursion limits on selection processing [PR #995](https://github.com/apollographql/router/pull/995) +The `apollo.router.opened.subscriptions` metric has an `graphql.operation.name` attribute applied to identify the named operation of open subscriptions. -We now limit operations to a depth of 512 to prevent cycles. +By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/7606 ## 🛠 Maintenance -### Use official SPDX license identifier for Elastic License v2 (ELv2) [Issue #418](https://github.com/apollographql/router/issues/418) +### Measure `preview_extended_error_metrics` in Apollo config telemetry ([PR #7597](https://github.com/apollographql/router/pull/7597)) -Rather than pointing to our `LICENSE` file, we now use the `Elastic-2.0` SPDX license identifier to indicate that a particular component is governed by the Elastic License 2.0 (ELv2). This should facilitate automated compatibility with licensing tools which assist with compliance. +By [@timbotnik](https://github.com/timbotnik) in https://github.com/apollographql/router/pull/7597 ## 📚 Documentation -### Router startup messaging now includes version and license notice [PR #986](https://github.com/apollographql/router/pull/986) +### Document Apollo Runtime Container deployment ([PR #7734](https://github.com/apollographql/router/pull/7734) and [PR #7668](https://github.com/apollographql/router/pull/7668)) + +The Apollo Runtime Container is now included in our documentation for deployment options. It also includes instructions for running Apollo Router with the Apollo MCP Server. -We now display the version of the Router at startup, along with clarity that the Router is licensed under [ELv2](https://go.apollo.dev/elv2). +By [@jonathanrainer](https://github.com/jonathanrainer) and [@lambertjosh](https://github.com/lambertjosh) in https://github.com/apollographql/router/pull/7734 and https://github.com/apollographql/router/pull/7668 -# [v0.1.0-preview.7] - 2022-05-04 -## ❗ BREAKING ❗ +### Fix incorrect reference to `apollo.router.schema.load.duration` ([PR #7582](https://github.com/apollographql/router/pull/7582)) -### Plugin utilities cleanup [PR #819](https://github.com/apollographql/router/pull/819), [PR #908](https://github.com/apollographql/router/pull/908) -Utilities around creating Request and Response structures have been migrated to builders. +The [in-memory cache documentation](https://www.apollographql.com/docs/graphos/routing/performance/caching/in-memory#cache-warm-up) was referencing an incorrect metric to track schema load times. Previously it was referred to as `apollo.router.schema.loading.time`, whereas the metric being emitted by the router since v2.0.0 is actually `apollo.router.schema.load.duration`. This is now fixed. -Migration: -* `plugin_utils::RouterRequest::builder()`->`RouterRequest::fake_builder()` -* `plugin_utils::RouterResponse::builder()`->`RouterResponse::fake_builder()` +By [@lrlna](https://github.com/lrlna) in https://github.com/apollographql/router/pull/7582 -In addition, the `plugin_utils` module has been removed. Mock service functionality has been migrated to `plugin::utils::test`. +# [2.3.0] - 2025-06-02 -### Layer cleanup [PR #950](https://github.com/apollographql/router/pull/950) -Reusable layers have all been moved to `apollo_router_core::layers`. In particular the `checkpoint_*` layers have been moved from the `plugins` module. -`async_checkpoint` has been renamed to `checkpoint_async` for consistency with Tower. -Layers that were internal to our execution pipeline have been moved and made private to the crate. +## 🚀 Features -### Plugin API changes [PR #855](https://github.com/apollographql/router/pull/855) -Previously the Plugin trait has three lifecycle hooks: new, startup, and shutdown. +**Connectors improvements**: Router 2.3.0 supports Connect spec v0.2, including batch requests, error customization, and direct access to HTTP headers. To use these features: upgrade your Router to 2.3, update your version of Federation to 2.11, and update the @link directives in your subgraphs to https://specs.apollo.dev/connect/v0.2. -Startup and shutdown are problematic because: -* Plugin construction happens in new and startup. This means creating in new and populating in startup. -* Startup and shutdown has to be explained to the user. -* Startup and shutdown ordering is delicate. +See the [Connectors changelog](https://www.apollographql.com/docs/graphos/connectors/reference/changelog) for more details. -The lifecycle now looks like this: -1. `new` -2. `activate` -3. `drop` +### Log whether safe-listing enforcement was skipped ([Issue #7509](https://github.com/apollographql/router/issues/7509)) -Users can migrate their plugins using the following: -* `Plugin#startup`->`Plugin#new` -* `Plugin#shutdown`->`Drop#drop` +When logging unknown operations encountered during safe-listing, include information about whether enforcement was skipped. This will help distinguish between truly problematic external operations (where `enforcement_skipped` is false) and internal operations that are intentionally allowed to bypass safelisting (where `enforcement_skipped` is true). -In addition, the `activate` lifecycle hook is now not marked as deprecated, and users are free to use it. +By [@DaleSeo](https://github.com/DaleSeo) in https://github.com/apollographql/router/pull/7509 -## 🚀 Features +### Add response body telemetry selector ([PR #7363](https://github.com/apollographql/router/pull/7363)) -### Add SpanKind and SpanStatusCode to follow the opentelemetry spec [PR #925](https://github.com/apollographql/router/pull/925) -Spans now contains [`otel.kind`](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/api.md#spankind) and [`otel.status_code`](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/api.md#set-status) attributes when needed to follow the opentelemtry spec . +The Router now supports a `response_body` selector which provides access to the response body in telemetry configurations. This enables more detailed monitoring and logging of response data in the Router. -### Configurable client identification headers [PR #850](https://github.com/apollographql/router/pull/850) -The router uses the HTTP headers `apollographql-client-name` and `apollographql-client-version` to identify clients in Studio telemetry. Those headers can now be overriden in the configuration: -```yaml title="router.yaml" +Example configuration: +```yaml telemetry: - apollo: - # Header identifying the client name. defaults to apollographql-client-name - client_name_header: - # Header identifying the client version. defaults to apollographql-client-version - client_version_header: + instrumentation: + spans: + router: + attributes: + "my_attribute": + response_body: true ``` -## 🐛 Fixes -### Fields in the root selection set of a query are now correctly skipped and included [PR #931](https://github.com/apollographql/router/pull/931) -The `@skip` and `@include` directives are now executed for the fields in the root selection set. +By [@Velfi](https://github.com/Velfi) in https://github.com/apollographql/router/pull/7363 -### Configuration errors on hot-reload are output [PR #850](https://github.com/apollographql/router/pull/850) -If a configuration file had errors on reload these were silently swallowed. These are now added to the logs. +### Support non-JSON and JSON-like content types for connectors ([PR #7380](https://github.com/apollographql/router/pull/7380)) -### Telemetry spans are no longer created for healthcheck requests [PR #938](https://github.com/apollographql/router/pull/938) -Telemetry spans where previously being created for the healthcheck requests which was creating noisy telemetry for users. +Connectors now inspect the `content-type` header of responses to determine how they should treat the response. This allows more flexibility as prior to this change, all responses were treated as JSON which would lead to errors on non-json responses. -### Dockerfile now allows overriding of `CONFIGURATION_PATH` [PR #948](https://github.com/apollographql/router/pull/948) -Previously `CONFIGURATION_PATH` could not be used to override the config location as it was being passed by command line arg. +The behavior is as follows: -## 🛠 Maintenance -### Upgrade `test-span` to display more children spans in our snapshots [PR #942](https://github.com/apollographql/router/pull/942) -Previously in test-span before the fix [introduced here](https://github.com/apollographql/test-span/pull/13) we were filtering too aggressively. So if we wanted to snapshot all `DEBUG` level if we encountered a `TRACE` span which had `DEBUG` children then these children were not snapshotted. It's now fixed and it's more consistent with what we could have/see in jaeger. +- If `content-type` ends with `/json` (like `application/json`) OR `+json` (like `application/vnd.foo+json`): content is parsed as JSON. +- If `content-type` is `text/plain`: content will be treated as a UTF-8 `string`. Content can be accessed in `selection` mapping via `$` variable. +- If `content-type` is any other value: content will be treated as a JSON `null`. +- If no `content-type` header is provided: content is assumed to be JSON and therefore parsed as JSON. -### Finalize migration from Warp to Axum [PR #920](https://github.com/apollographql/router/pull/920) -Adding more tests to be more confident to definitely delete the `warp-server` feature and get rid of `warp` +If deserialization fails, an error message of `Response deserialization failed` with a error code of `CONNECTOR_DESERIALIZE` will be returned: -### End to end integration tests for Jaeger [PR #850](https://github.com/apollographql/router/pull/850) -Jaeger tracing end to end test including client->router->subgraphs +```json +"errors": [ + { + "message": "Response deserialization failed", + "extensions": { + "code": "CONNECTOR_DESERIALIZE" + } + } +] +``` -### Router tracing span cleanup [PR #850](https://github.com/apollographql/router/pull/850) -Spans generated by the Router are now aligned with plugin services. +By [@andrewmcgivery](https://github.com/andrewmcgivery) in https://github.com/apollographql/router/pull/7380 -### Simplified CI for windows [PR #850](https://github.com/apollographql/router/pull/850) -All windows processes are spawned via xtask rather than a separate CircleCI stage. +### Include message and path for certain errors in Apollo telemetry ([PR #7378](https://github.com/apollographql/router/pull/7378)) -### Enable default feature in graphql_client [PR #905](https://github.com/apollographql/router/pull/905) -Removing the default feature can cause build issues in plugins. +For errors pertaining to connectors and demand control features, Apollo telemetry will now include the original error message and path as part of the traces sent to GraphOS. -### Do not remove __typename from the aggregated response [PR #919](https://github.com/apollographql/router/pull/919) -If the client was explicitely requesting the `__typename` field, it was removed from the aggregated subgraph data, and so was not usable by fragment to check the type. +By [@timbotnik](https://github.com/timbotnik) in https://github.com/apollographql/router/pull/7378 -### Follow the GraphQL spec about Response format [PR #926](https://github.com/apollographql/router/pull/926) -The response's `data` field can be null or absent depending on conventions that are now followed by the router. +### Support ignoring specific headers during subscriptions deduplication ([PR #7070](https://github.com/apollographql/router/pull/7070)) -## Add client awareness headers to CORS allowed headers [PR #917](https://github.com/apollographql/router/pull/917) +The Router now supports ignoring specific headers when deduplicating requests to subgraphs which provide subscription events. Previously, any differing headers which didn't actually affect the subscription response (e.g., `user-agent`) would prevent or limit the potential of deduplication. -The client awareness headers are now added by default to the list of CORS allowed headers, for easier integration of browser based applications. We also document how to override them and update the CORS configuration accordingly. +The introduction of the `ignored_headers` option allows you to specify headers to ignore during deduplication, enabling you to benefit from subscription deduplication even when requests include headers with unique or varying values that don't affect the subscription's event data. -## Remove unnecessary box in instrumentation layer [PR #940](https://github.com/apollographql/router/pull/940) +Configuration example: -Minor simplification of code to remove boxing during instrumentation. +```yaml +subscription: + enabled: true + deduplication: + enabled: true # optional, default: true + ignored_headers: # (optional) List of ignored headers when deduplicating subscriptions + - x-transaction-id + - custom-header-name +``` -## 📚 Documentation -### Enhanced rust docs ([PR #819](https://github.com/apollographql/router/pull/819)) -Many more rust docs have been added. +By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/7070 -### Federation version support page [PR #896](https://github.com/apollographql/router/pull/896) -Add Federation version support doc page detailing which versions of federation are compiled against versions of the router. +## 🐛 Fixes -### Improve readme for embedded Router [PR #936](https://github.com/apollographql/router/pull/936) -Add more details about pros and cons so that users know what they're letting themselves in for. +### Support disabling the health check endpoint ([PR #7519](https://github.com/apollographql/router/pull/7519)) -### Document layers [PR #950](https://github.com/apollographql/router/pull/950) -Document the notable existing layers and add rust docs for custom layers including basic use cases. +During the development of Router 2.0, the health check endpoint support was converted to be a plugin. Unfortunately, the support for disabling the health check endpoint was lost during the conversion. -# [v0.1.0-preview.6] - 2022-04-21 -## 🐛 Fixes +This is now fixed and a new unit test ensures that disabling the health check does not result in the creation of a health check endpoint. -### Restore the health check route [#883](https://github.com/apollographql/router/issues/883) -Axum rework caused the healthckeck route `/.well-known/apollo/server-health` to change. The route is now restored. +By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/7519 -### Correctly flag incoming POST requests [#865](https://github.com/apollographql/router/issues/865) -A regression happened during our recent switch to Axum that would propagate incoming POST requests as GET requests. Fixed and added regression tests. +### Propagate client name and version modifications through telemetry ([PR #7369](https://github.com/apollographql/router/pull/7369)) -# [v0.1.0-preview.5] - 2022-04-20 -## 🚀 Features -### Helm chart for the router [PR #861](https://github.com/apollographql/router/pull/861) +The Router accepts modifications to the client name and version (`apollo::telemetry::client_name` and `apollo::telemetry::client_version`), but those modifications were not propagated through the telemetry layers to update spans and traces. -[Helm](https://helm.sh) support provided by @damienpontifex. +After this change, the modifications from plugins **on the `router` service** are propagated through the telemetry layers. -### Line precise error reporting [PR #830](https://github.com/apollographql/router/pull/782) -The router will make a best effort to give line precise error reporting if the configuration was invalid. -```yaml -1. /telemetry/tracing/trace_config/sampler +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7369 -telemetry: - tracing: - trace_config: - service_name: router3 - sampler: "0.3" - ^----- "0.3" is not valid under any of the given schemas +### Prevent connectors error when using a variable in a nested input argument ([PR #7472](https://github.com/apollographql/router/pull/7472)) + +The connectors plugin will no longer error when using a variable in a nested input argument. The following example would error prior to this change: + +```graphql +query Query (: String){ + complexInputType(filters: { inSpace: true, search: }) +} ``` -### Install experience [PR #820](https://github.com/apollographql/router/pull/820) -Added an install script that will automatically download and unzip the router into the local directory. -For more info see the quickstart documentation. +By [@andrewmcgivery](https://github.com/andrewmcgivery) in https://github.com/apollographql/router/pull/7472 -## 🐛 Fixes +### Spans should only include path in `http.route` ([PR #7390](https://github.com/apollographql/router/pull/7390)) -### Fix concurrent query planning [#846](https://github.com/apollographql/router/issues/846) -The query planner has been reworked to make sure concurrent plan requests will be dispatched to the relevant requester. +Per the [OpenTelemetry spec](https://opentelemetry.io/docs/specs/semconv/attributes-registry/http/#http-route), the `http.route` should only include "the matched route, that is, the path template used in the format used by the respective server framework." -### Do not hang when tracing provider was not set as global [#849](https://github.com/apollographql/router/issues/847) -The telemetry plugin will now Drop cleanly when the Router service stack fails to build. +Prior to this change, the Router sends the full URI in `http.route`, which can be high cardinality (ie `/graphql?operation=one_of_many_values`). The Router will now only include the path (`/graphql`). -### Propagate error extensions originating from subgraphs [PR #839](https://github.com/apollographql/router/pull/839) -Extensions are now propagated following the configuration of the `include_subgraph_error` plugin. +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7390 -### Telemetry configuration [PR #830](https://github.com/apollographql/router/pull/830) -Jaeger and Zipkin telemetry config produced JSON schema that was invalid. +### Decrease log level for JWT authentication failure ([PR #7396](https://github.com/apollographql/router/pull/7396)) -### Return a better error when introspection is disabled [PR #751](https://github.com/apollographql/router/pull/751) -Instead of returning an error coming from the query planner, we are now returning a proper error explaining that the introspection has been disabled. +A recent change increased the log level of JWT authentication failures from `info` to `error`. This reverts that change. -### Add operation name to subquery fetches [PR #840](https://github.com/apollographql/router/pull/840) -If present in the query plan fetch node, the operation name will be added to sub-fetches. +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7396 -### Remove trailing slash from Datadog agent endpoint URL [PR #863](https://github.com/apollographql/router/pull/863) -Due to the way the endpoint URL is constructed in opentelemetry-datadog, we cannot set the agent endpoint to a URL with a trailing slash. +### Prefer headers propagated with Router YAML config over headers from Connector directives ([PR #7499](https://github.com/apollographql/router/pull/7499)) -## 🛠 Maintenance -### Configuration files validated [PR #830](https://github.com/apollographql/router/pull/830) -Router configuration files within the project are now largely validated via unit test. +When configuring the same header name in both `@connect(http: { headers: })` (or `@source(http: { headers: })`) in SDL and `propagate` in Router YAML configuration, the request had both headers, even if the value is the same. After this change, Router YAML configuration always wins. -### Switch web server framework from `warp` to `axum` [PR #751](https://github.com/apollographql/router/pull/751) -The router is now running by default with an [axum](https://github.com/tokio-rs/axum/) web server instead of `warp`. +By [@andrewmcgivery](https://github.com/andrewmcgivery) in https://github.com/apollographql/router/pull/7499 -### Improve the way we handle Request with axum [PR #845](https://github.com/apollographql/router/pull/845) [PR #877](https://github.com/apollographql/router/pull/877) -Take advantages of new extractors given by `axum`. +## 🛠 Maintenance +### Add timeouts and connection health checks to Redis connections ([Issue #6855](https://github.com/apollographql/router/issues/6855)) -# [v0.1.0-preview.4] - 2022-04-11 -## ❗ BREAKING ❗ -- **Telemetry simplification** [PR #782](https://github.com/apollographql/router/pull/782) +The Router's internal Redis configuration has been improved to increase client resiliency under various failure modes (TCP failures and timeouts, unresponsive sockets, Redis server failures, etc.). It also adds heartbeats (a PING every 10 seconds) to the Redis clients. - Telemetry configuration has been reworked to focus exporters rather than OpenTelemetry. Users can focus on what they are trying to integrate with rather than the fact that OpenTelemetry is used in the Apollo Router under the hood. +By [@aembke](https://github.com/aembke), [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7526 - ```yaml - telemetry: - apollo: - endpoint: - apollo_graph_ref: - apollo_key: - metrics: - prometheus: - enabled: true - tracing: - propagation: - # Propagation is automatically enabled for any exporters that are enabled, - # but you can enable extras. This is mostly to support otlp and opentracing. - zipkin: true - datadog: false - trace_context: false - jaeger: false - baggage: false - - otlp: - endpoint: default - protocol: grpc - http: - .. - grpc: - .. - zipkin: - agent: - endpoint: default - jaeger: - agent: - endpoint: default - datadog: - endpoint: default - ``` -## 🚀 Features -- **Datadog support** [PR #782](https://github.com/apollographql/router/pull/782) +## 📚 Documentation - Datadog support has been added via `telemetry` yaml configuration. +### Fix discrepancies in coprocessor metrics documentation ([PR #7359](https://github.com/apollographql/router/pull/7359)) -- **Yaml env variable expansion** [PR #782](https://github.com/apollographql/router/pull/782) +The documentation for standard metric instruments for [coprocessors](https://www.apollographql.com/docs/graphos/routing/observability/telemetry/instrumentation/standard-instruments#coprocessor) has been updated: - All values in the router configuration outside the `server` section may use environment variable expansion. - Unix style expansion is used. Either: +- Rename `apollo.router.operations.coprocessor.total` to `apollo.router.operations.coprocessor` +- Clarify that `coprocessor.succeeded` attribute applies to `apollo.router.operations.coprocessor` only. - * `${ENV_VAR_NAME}`- Expands to the environment variable `ENV_VAR_NAME`. - * `${ENV_VAR_NAME:some_default}` - Expands to `ENV_VAR_NAME` or `some_default` if the environment variable did not exist. +By [@shorgi](https://github.com/shorgi) in https://github.com/apollographql/router/pull/7359 - Only values may be expanded (not keys): - ```yaml {4,8} title="router.yaml" - example: - passord: "${MY_PASSWORD}" - ``` -## 🐛 Fixes +### Add example Rhai script for returning Demand Control metrics as response headers ([PR #7564](https://github.com/apollographql/router/pull/7564)) -- **Accept arrays in keys for subgraph joins** [PR #822](https://github.com/apollographql/router/pull/822) +A new section has been added to the [demand control documentation](https://www.apollographql.com/docs/graphos/routing/security/demand-control#accessing-programmatically) to demonstrate how to use Rhai scripts to expose cost estimation data in response headers. This allows clients to see the estimated cost, actual cost, and other demand control metrics directly in HTTP responses, which is useful for debugging and client-side optimization. - The router is now accepting arrays as part of the key joining between subgraphs. +By [@abernix](https://github.com/abernix) in https://github.com/apollographql/router/pull/7564 -- **Fix value shape on empty subgraph queries** [PR #827](https://github.com/apollographql/router/pull/827) - When selecting data for a federated query, if there is no data the router will not perform the subgraph query and will instead return a default value. This value had the wrong shape and was generating an object where the query would expect an array. +# [2.2.1] - 2025-05-13 -## 🛠 Maintenance +## 🐛 Fixes -- **Apollo federation 2.0.0 compatible query planning** [PR#828](https://github.com/apollographql/router/pull/828) +### Redis connection leak on schema changes ([PR #7319](https://github.com/apollographql/router/pull/7319)) - Now that Federation 2.0 is available, we have updated the query planner to use the latest release (@apollo/query-planner v2.0.0). +The router performs a 'hot reload' whenever it detects a schema update. During this reload, it effectively instantiates a new internal router, warms it up (optional), redirects all traffic to this new router, and drops the old internal router. +This change fixes a bug in that "drop" process where the Redis connections are never told to terminate, even though the Redis client pool is dropped. This leads to an ever-increasing number of inactive Redis connections as each new schema comes in and goes out of service, which eats up memory. -# [v0.1.0-preview.3] - 2022-04-08 -## 🚀 Features -- **Add version flag to router** ([PR #805](https://github.com/apollographql/router/pull/805)) +The solution adds a new up-down counter metric, `apollo.router.cache.redis.connections`, to track the number of open Redis connections. This metric includes a `kind` label to discriminate between different Redis connection pools, which mirrors the `kind` label on other cache metrics (ie `apollo.router.cache.hit.time`). - You can now provider a `--version or -V` flag to the router. It will output version information and terminate. +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7319 -- **New startup message** ([PR #780](https://github.com/apollographql/router/pull/780)) +### Propagate client name and version modifications through telemetry ([PR #7369](https://github.com/apollographql/router/pull/7369)) - The router startup message was updated with more links to documentation and version information. +The router accepts modifications to the client name and version (`apollo::telemetry::client_name` and `apollo::telemetry::client_version`), but those modifications are not currently propagated through the telemetry layers to update spans and traces. -- **Add better support of introspection queries** ([PR #802](https://github.com/apollographql/router/pull/802)) +This PR moves where the client name and version are bound to the span, so that the modifications from plugins **on the `router` service** are propagated. - Before this feature the Router didn't execute all the introspection queries, only a small number of the most common ones were executed. Now it detects if it's an introspection query, tries to fetch it from cache, if it's not in the cache we execute it and put the response in the cache. +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7369 -- **Add an option to disable the landing page** ([PR #801](https://github.com/apollographql/router/pull/801)) +### Progressive overrides are not disabled when connectors are used ([PR #7351](https://github.com/apollographql/router/pull/7351)) - By default the router will display a landing page, which could be useful in development. If this is not - desirable the router can be configured to not display this landing page: - ```yaml - server: - landing_page: false - ``` +Prior to this fix, introducing a connector disabled the progressive override plugin. -- **Add support of metrics in `apollo.telemetry` plugin** ([PR #738](https://github.com/apollographql/router/pull/738)) +By [@lennyburdette](https://github.com/lennyburdette) in https://github.com/apollographql/router/pull/7351 - The Router will now compute different metrics you can expose via Prometheus or OTLP exporter. +### Avoid unnecessary cloning in the deduplication plugin ([PR #7347](https://github.com/apollographql/router/pull/7347)) - Example of configuration to export an endpoint (configured with the path `/plugins/apollo.telemetry/metrics`) with metrics in `Prometheus` format: +The deduplication plugin always cloned responses, even if there were not multiple simultaneous requests that would benefit from the cloned response. - ```yaml - telemetry: - metrics: - exporter: - prometheus: - # By setting this endpoint you enable the prometheus exporter - # All our endpoints exposed by plugins are namespaced by the name of the plugin - # Then to access to this prometheus endpoint, the full url path will be `/plugins/apollo.telemetry/metrics` - endpoint: "/metrics" - ``` +We now check to see if deduplication will provide a benefit before we clone the subgraph response. -- **Add experimental support of `custom_endpoint` method in `Plugin` trait** ([PR #738](https://github.com/apollographql/router/pull/738)) +There was also an undiagnosed race condition which meant that a notification could be missed. This would have resulted in additional work being performed as the missed notification would have led to another subgraph request. - The `custom_endpoint` method lets you declare a new endpoint exposed for your plugin. For now it's only accessible for official `apollo.` plugins and for `experimental.`. The return type of this method is a Tower [`Service`](). +By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/7347 -- **configurable subgraph error redaction** ([PR #797](https://github.com/apollographql/router/issues/797)) - By default, subgraph errors are not propagated to the user. This experimental plugin allows messages to be propagated either for all subgraphs or on - an individual subgraph basis. Individual subgraph configuration overrides the default (all) configuration. The configuration mechanism is similar - to that used in the `headers` plugin: - ```yaml - plugins: - experimental.include_subgraph_errors: - all: true - ``` +### Spans should only include path in `http.route` ([PR #7390](https://github.com/apollographql/router/pull/7390)) -- **Add a trace level log for subgraph queries** ([PR #808](https://github.com/apollographql/router/issues/808)) +Per the [OpenTelemetry spec](https://opentelemetry.io/docs/specs/semconv/attributes-registry/http/#http-route), the `http.route` should only include "the matched route, that is, the path template used in the format used by the respective server framework." - To debug the query plan execution, we added log messages to print the query plan, and for each subgraph query, - the operation, variables and response. It can be activated as follows: +The router currently sends the full URI in `http.route`, which can be high cardinality (ie `/graphql?operation=one_of_many_values`). After this change, the router will only include the path (`/graphql`). - ``` - router -s supergraph.graphql --log info,apollo_router_core::query_planner::log=trace - ``` +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7390 -## 🐛 Fixes -- **Eliminate memory leaks when tasks are cancelled** [PR #758](https://github.com/apollographql/router/pull/758) +### Decrease log level for JWT authentication failure ([PR #7396](https://github.com/apollographql/router/pull/7396)) + +A recent change inadvertently increased the log level of JWT authentication failures from `info` to `error`. This reverts that change returning it to the previous behavior. - The deduplication layer could leak memory when queries were cancelled and never retried: leaks were previously cleaned up on the next similar query. Now the leaking data will be deleted right when the query is cancelled +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7396 -- **Trim the query to better detect an empty query** ([PR #738](https://github.com/apollographql/router/pull/738)) +### Avoid fractional decimals when generating `apollo.router.operations.batching.size` metrics for GraphQL request batch sizes ([PR #7306](https://github.com/apollographql/router/pull/7306)) - Before this fix, if you wrote a query with only whitespaces inside, it wasn't detected as an empty query. +Corrects the calculation of the `apollo.router.operations.batching.size` metric to reflect accurate batch sizes rather than occasionally returning fractional numbers. -- **Keep the original context in `RouterResponse` when returning an error** ([PR #738](https://github.com/apollographql/router/pull/738)) +By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/7306 - This fix keeps the original http request in `RouterResponse` when there is an error. +## 📃 Configuration -- **add a user-agent header to the studio usage ingress submission** ([PR #773](https://github.com/apollographql/router/pull/773)) +### Log warnings for deprecated coprocessor `context` configuration usage ([PR #7349](https://github.com/apollographql/router/pull/7349)) - Requests to Studio now identify the router and its version +`context: true` is an alias for `context: deprecated` but should not be used. The router now logs a runtime warning on startup if you do use it. -## 🛠 Maintenance -- **A faster Query planner** ([PR #768](https://github.com/apollographql/router/pull/768)) +Instead of: - We reworked the way query plans are generated before being cached, which lead to a great performance improvement. Moreover, the router is able to make sure the schema is valid at startup and on schema update, before you query it. +```yaml +coprocessor: + supergraph: + request: + context: true # ❌ +``` -- **Xtask improvements** ([PR #604](https://github.com/apollographql/router/pull/604)) +Explicitly use `deprecated` or `all`: - The command we run locally to make sure tests, lints and compliance-checks pass will now edit the license file and run cargo fmt so you can directly commit it before you open a Pull Request +```yaml +coprocessor: + supergraph: + request: + context: deprecated # ✅ +``` -- **Switch from reqwest to a Tower client for subgraph services** ([PR #769](https://github.com/apollographql/router/pull/769)) +See [the 2.x upgrade guide](https://www.apollographql.com/docs/graphos/routing/upgrade/from-router-v1#context-keys-for-coprocessors) for more detailed upgrade steps. - It results in better performance due to less URL parsing, and now header propagation falls under the apollo_router_core log filter, making it harder to disable accidentally +By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/7349 -- **Remove OpenSSL usage** ([PR #783](https://github.com/apollographql/router/pull/783) and [PR #810](https://github.com/apollographql/router/pull/810)) +## 🛠 Maintenance - OpenSSL is used for HTTPS clients when connecting to subgraphs or the Studio API. It is now replaced with rustls, which is faster to compile and link +### Linux: Compatibility with glibc 2.28 or newer ([PR #7355](https://github.com/apollographql/router/pull/7355)) -- **Download the Studio protobuf schema during build** ([PR #776](https://github.com/apollographql/router/pull/776) +The default build images provided in our CI environment have a relatively modern version of `glibc` (2.35). This means that on some distributions, notably those based around RedHat, it wasn't possible to use our binaries since the version of `glibc` was older than 2.35. - The schema was vendored before, now it is downloaded dynamically during the build process +We now maintain a build image which is based on a distribution with `glibc` 2.28. This is old enough that recent releases of either of the main Linux distribution families (Debian and RedHat) can make use of our binary releases. -- **Fix broken benchmarks** ([PR #797](https://github.com/apollographql/router/issues/797)) +By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/7355 - the `apollo-router-benchmarks` project was failing due to changes in the query planner. It is now fixed, and its subgraph mocking code is now available in `apollo-router-core` +### Reject `@skip`/`@include` on subscription root fields in validation ([PR #7338](https://github.com/apollographql/router/pull/7338)) -## 📚 Documentation +This implements a [GraphQL spec RFC](https://github.com/graphql/graphql-spec/pull/860), rejecting subscriptions in validation that can be invalid during execution. -- **Document the Plugin and DynPlugin trait** ([PR #800](https://github.com/apollographql/router/pull/800) +By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/7338 - Those traits are used to extend the router with Rust plugins +## 📚 Documentation -# [v0.1.0-preview.2] - 2022-04-01 -## ❗ BREAKING ❗ +### Query planning best practices ([PR #7263](https://github.com/apollographql/router/pull/7263)) -- **CORS default Configuration** ([#40](https://github.com/apollographql/router/issues/40)) +Added a new page under Routing docs about [Query Planning Best Practices](https://www.apollographql.com/docs/graphos/routing/query-planning/query-planning-best-practices). - The Router will allow only the https://studio.apollographql.com origin by default, instead of any origin. - This behavior can still be tweaked in the [YAML configuration](https://www.apollographql.com/docs/router/configuration/cors) +By [@smyrick](https://github.com/smyrick) in https://github.com/apollographql/router/pull/7263 -- **Hot reload flag** ([766](https://github.com/apollographql/router/issues/766)) - The `--watch` (or `-w`) flag that enables hot reload was renamed to `--hr` or `--hot-reload` +# [2.2.0] - 2025-04-28 ## 🚀 Features -- **Hot reload via en environment variable** ([766](https://github.com/apollographql/router/issues/766)) - You can now use the `ROUTER_HOT_RELOAD=true` environment variable to have the router watch for configuration and schema changes and automatically reload. +### Add support for connector header propagation via YAML config ([PR #7152](https://github.com/apollographql/router/pull/7152)) -- **Container images are now available** ([PR #764](https://github.com/apollographql/router/pull/764)) +Added support for connector header propagation via YAML config. All of the existing header propagation in the Router now works for connectors by using +`headers.connector.all` to apply rules to all connectors or `headers.connector.sources.*` to apply rules to specific sources. - We now build container images More details at: - https://github.com/apollographql/router/pkgs/container/router +Note that if one of these rules conflicts with a header set in your schema, either in `@connect` or `@source`, the value in your Router config will +take priority and be treated as an override. - You can use the images with docker, for example, as follows: - e.g.: docker pull ghcr.io/apollographql/router:v0.1.0-preview.1 +```yaml +headers: + connector: + all: # configuration for all connectors across all subgraphs + request: + - insert: + name: "x-inserted-header" + value: "hello world!" + - propagate: + named: "x-client-header" + sources: + connector-graph.random_person_api: + request: + - insert: + name: "x-inserted-header" + value: "hello world!" + - propagate: + named: "x-client-header" +``` - The images are based on [distroless](https://github.com/GoogleContainerTools/distroless) which is a very constrained image, intended to be secure and small. +By [@andrewmcgivery](https://github.com/andrewmcgivery) in https://github.com/apollographql/router/pull/7152 - We'll provide release and debug images for each release. The debug image has a busybox shell which can be accessed using (for instance) `--entrypoint=sh`. +### Enable configuration auto-migration for minor version bumps ([PR #7162](https://github.com/apollographql/router/pull/7162)) - For more details about these images, see the docs. +To facilitate configuration evolution within major versions of the router's lifecycles (e.g., within 2.x.x versions), YAML configuration migrations are applied automatically. To avoid configuration drift and facilitate maintenance, when upgrading to a new major version the migrations from the previous major (e.g., 1.x.x) will not be applied automatically. These will need to be applied with `router config upgrade` prior to the upgrade. To facilitate major version upgrades, we recommend regularly applying the configuration changes using `router config upgrade` and committing those to your version control system. -- **Skip and Include directives in post processing** ([PR #626](https://github.com/apollographql/router/pull/626)) +By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/7162 - The Router now understands the [@skip](https://spec.graphql.org/October2021/#sec--skip) and [@include](https://spec.graphql.org/October2021/#sec--include) directives in queries, to add or remove fields depending on variables. It works in post processing, by filtering fields after aggregating the subgraph responses. +### Allow expressions in more locations in Connectors URIs ([PR #7220](https://github.com/apollographql/router/pull/7220)) -- **Add an option to deactivate introspection** ([PR #749](https://github.com/apollographql/router/pull/749)) +Previously, we only allowed expressions in very specific locations in Connectors URIs: - While schema introspection is useful in development, we might not want to expose the entire schema in production, - so the router can be configured to forbid introspection queries as follows: - ```yaml - server: - introspection: false - ``` +1. A path segment, like `/users/{$args.id}` +2. A query parameter's _value_, like `/users?id={$args.id}` -## 🐛 Fixes -- **Move query dedup to an experimental `traffic_shaping` plugin** ([PR #753](https://github.com/apollographql/router/pull/753)) +Expressions can now be used anywhere in or after the path of the URI. +For example, you can do +`@connect(http: {GET: "/users?{$args.filterName}={$args.filterValue}"})`. +The result of any expression will _always_ be percent encoded. - The experimental `traffic_shaping` plugin will be a central location where we can add things such as rate limiting and retry. +> Note: Parts of this feature are only available when composing with Apollo Federation v2.11 or above (currently in preview). -- **Remove `hasNext` from our response objects** ([PR #733](https://github.com/apollographql/router/pull/733)) +By [@dylan-apollo](https://github.com/dylan-apollo) in https://github.com/apollographql/router/pull/7220 - `hasNext` is a field in the response that may be used in future to support features such as defer and stream. However, we are some way off supporting this and including it now may break clients. It has been removed. +### Enables reporting of persisted query usage by PQ ID to Apollo ([PR #7166](https://github.com/apollographql/router/pull/7166)) -- **Extend Apollo uplink configurability** ([PR #741](https://github.com/apollographql/router/pull/741)) +This change allows the router to report usage metrics by persisted query ID to Apollo, so that we can show usage stats for PQs. - Uplink url and poll interval can now be configured via command line arg and env variable: - ```bash - --apollo-schema-config-delivery-endpoint - The endpoint polled to fetch the latest supergraph schema [env: APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT=] +By [@bonnici](https://github.com/bonnici) in https://github.com/apollographql/router/pull/7166 - --apollo-schema-poll-interval - The time between polls to Apollo uplink. Minimum 10s [env: APOLLO_SCHEMA_POLL_INTERVAL=] [default: 10s] - ``` - In addition, other existing uplink env variables are now also configurable via arg. +### Instrument coprocessor request with `http_request` span ([Issue #6739](https://github.com/apollographql/router/issues/6739)) -- **Make deduplication and caching more robust against cancellation** [PR #752](https://github.com/apollographql/router/pull/752) [PR #758](https://github.com/apollographql/router/pull/758) +Coprocessor requests will now emit an `http_request` span. This span can help to gain +insight into latency that may be introduced over the network stack when communicating with coprocessor. - Cancelling a request could put the router in an unresponsive state where the deduplication layer or cache would make subgraph requests hang. +Coprocessor span attributes are: -- **Relax variables selection for subgraph queries** ([PR #755](https://github.com/apollographql/router/pull/755)) +- `otel.kind`: `CLIENT` +- `http.request.method`: `POST` +- `server.address`: `` +- `server.port`: `` +- `url.full`: `` +- `otel.name`: ` ` +- `otel.original_name`: `http_request` - Federated subgraph queries relying on partial or invalid data from previous subgraph queries could result in response failures or empty subgraph queries. The router is now more flexible when selecting data from previous queries, while still keeping a correct form for the final response +By [@theJC](https://github.com/theJC) in https://github.com/apollographql/router/pull/6776 -## 🛠 Maintenance +### Enables reporting for client libraries that send the library name and version information in operation requests. ([PR #7264](https://github.com/apollographql/router/pull/7264)) -## 📚 Documentation +Apollo client libraries can send the library name and version information in the `extensions` key of an operation request. If those values are found in a request the router will include them in the telemetry operation report sent to Apollo. -# [v0.1.0-preview.1] - 2022-03-23 - -## 🎉 **The Apollo Router has graduated to its Preview phase!** 🎉 -## ❗ BREAKING ❗ - -- **Improvements to telemetry attribute YAML ergonomics** ([PR #729](https://github.com/apollographql/router/pull/729)) - - Trace config YAML ergonomics have been improved. To add additional attributes to your trace information, you can now use the following format: - - ```yaml - trace_config: - attributes: - str: "a" - int: 1 - float: 1.0 - bool: true - str_arr: - - "a" - - "b" - int_arr: - - 1 - - 2 - float_arr: - - 1.0 - - 2.0 - bool_arr: - - true - - false - ``` -## 🐛 Fixes +By [@calvincestari](https://github.com/calvincestari) in https://github.com/apollographql/router/pull/7264 -- **Log and error message formatting** ([PR #721](https://github.com/apollographql/router/pull/721)) +### Add compute job pool spans ([PR #7236](https://github.com/apollographql/router/pull/7236)) - Logs and error messages now begin with lower case and do not have trailing punctuation, per Rust conventions. +The compute job pool in the router is used to execute CPU intensive work outside of the main I/O worker threads, including GraphQL parsing, query planning, and introspection. +This PR adds spans to jobs that are on this pool to allow users to see when latency is introduced due to +resource contention within the compute job pool. -- **OTLP default service.name and service.namespace** ([PR #722](https://github.com/apollographql/router/pull/722)) +* `compute_job`: + - `job.type`: (`query_parsing`|`query_planning`|`introspection`) +* `compute_job.execution` + - `job.age`: `P1`-`P8` + - `job.type`: (`query_parsing`|`query_planning`|`introspection`) - While the Jaeger YAML configuration would default to `router` for the `service.name` and to `apollo` for the `service.namespace`, it was not the case when using a configuration that utilized OTLP. This lead to an `UNKNOWN_SERVICE` name span in zipkin traces, and difficult to find Jaeger traces. +Jobs are executed highest priority (`P8`) first. Jobs that are low priority (`P1`) age over time, eventually executing +at highest priority. The age of a job is can be used to diagnose if a job was waiting in the queue due to other higher +priority jobs also in the queue. -# [v0.1.0-preview.0] - 2022-03-22 +By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/7236 -## 🎉 **The Apollo Router has graduated to its Preview phase!** 🎉 +### JWT authorization supports multiple issuers ([Issue #6172](https://github.com/apollographql/router/issues/6172)) -For more information on what's expected at this stage, please see our [release stages](https://www.apollographql.com/docs/resources/release-stages/#preview). +Allow JWT authorization options to support multiple issuers using the same JWKS. -## 🐛 Fixes +**Configuration change**: any `issuer` defined on currently existing `authentication.router.jwt.jwks` needs to be +migrated to an entry in the `issuers` list. This configuration will happen automatically until the next major version of the router. This change can be committed using `./router config upgrade` prior to the next major release. -- **Header propagation by `name` only fixed** ([PR #709](https://github.com/apollographql/router/pull/709)) +For example, the following configuration: - Previously `rename` and `default` values were required (even though they were correctly not flagged as required in the json schema). - The following will now work: - ```yaml - headers: - all: - - propagate: - named: test - ``` -- **Fix OTLP hang on reload** ([PR #711](https://github.com/apollographql/router/pull/711)) +```yaml +authentication: + router: + jwt: + jwks: + - url: https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json + issuer: https://issuer.one +``` - Fixes hang when OTLP exporter is configured and configuration hot reloads. +Will be changed to contain an array of `issuers` rather than a single `issuer`: -# [v0.1.0-alpha.10] 2022-03-21 +```yaml +authentication: + router: + jwt: + jwks: + - url: https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json + issuers: + - https://issuer.one + - https://issuer.two +``` -## ❗ BREAKING ❗ +By [@theJC](https://github.com/theJC) in https://github.com/apollographql/router/pull/7170 -- **Header propagation `remove`'s `name` is now `named`** ([PR #674](https://github.com/apollographql/router/pull/674)) +## 🐛 Fixes - This merely renames the `remove` options' `name` setting to be instead `named` to be a bit more intuitively named and consistent with its partner configuration, `propagate`. +### Fix JWT metrics discrepancy ([PR #7258](https://github.com/apollographql/router/pull/7258)) - _Previous configuration_ +This fixes the `apollo.router.operations.authentication.jwt` counter metric to behave [as documented](https://www.apollographql.com/docs/graphos/routing/security/jwt#observability): emitted for every request that uses JWT, with the `authentication.jwt.failed` attribute set to true or false for failed or successful authentication. - ```yaml - # Remove a named header - - remove: - name: "Remove" # Was: "name" - ``` - _New configuration_ +Previously, it was only used for failed authentication. - ```yaml - # Remove a named header - - remove: - named: "Remove" # Now: "named" - ``` +The attribute-less and accidentally-differently-named `apollo.router.operations.jwt` counter was and is only emitted for successful authentication, but is deprecated now. -- **Command-line flag vs Environment variable precedence changed** ([PR #693](https://github.com/apollographql/router/pull/693)) +By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/7258 - For logging related verbosity overrides, the `RUST_LOG` environment variable no longer takes precedence over the command line argument. The full order of precedence is now command-line argument overrides environment variable overrides the default setting. +### Fix potential telemetry deadlock ([PR #7142](https://github.com/apollographql/router/pull/7142)) -## 🚀 Features +The `tracing_subscriber` crate uses `RwLock`s to manage access to a `Span`'s `Extensions`. Deadlocks are possible when +multiple threads access this lock, including with reentrant locks: +``` +// Thread 1 | // Thread 2 +let _rg1 = lock.read(); | + | // will block + | let _wg = lock.write(); +// may deadlock | +let _rg2 = lock.read(); | +``` -- **Forbid mutations plugin** ([PR #641](https://github.com/apollographql/router/pull/641)) +This fix removes an opportunity for reentrant locking while extracting a Datadog identifier. - The forbid mutations plugin allows you to configure the router so that it disallows mutations. Assuming none of your `query` requests are mutating data or changing state (they shouldn't!) this plugin can be used to effectively make your graph read-only. This can come in handy when testing the router, for example, if you are mirroring/shadowing traffic when trying to validate a Gateway to Router migration! 😸 +There is also a potential for deadlocks when the root and active spans' `Extensions` are acquired at the same time, if +multiple threads are attempting to access those `Extensions` but in a different order. This fix removes a few cases +where multiple spans' `Extensions` are acquired at the same time. -- **⚠️ Add experimental Rhai plugin** ([PR #484](https://github.com/apollographql/router/pull/484)) +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7142 - Add an _experimental_ core plugin to be able to extend Apollo Router functionality using [Rhai script](https://rhai.rs/). This allows users to write their own `*_service` function similar to how as you would with a native Rust plugin but without needing to compile a custom router. Rhai scripts have access to the request context and headers directly and can make simple manipulations on them. +### Check if JWT claim is part of the context before getting the JWT expiration with subscriptions ([PR #7069](https://github.com/apollographql/router/pull/7069)) - See our [Rhai script documentation](https://www.apollographql.com/docs/router/customizations/rhai) for examples and details! +In v2.1.0 we introduced [logs](https://github.com/apollographql/router/pull/6930/files#diff-7597092ab9d509e0ffcb328691f1dded20f69d849f142628095f0455aa49880cR648) for the `jwt_expires_in` function which caused an unexpectedly chatty logging when using subscriptions. -## 🐛 Fixes +By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/7069 -- **Correctly set the URL path of the HTTP request in `RouterRequest`** ([Issue #699](https://github.com/apollographql/router/issues/699)) +### Parse nested input types and report them ([PR #6900](https://github.com/apollographql/router/pull/6900)) - Previously, we were not setting the right HTTP path on the `RouterRequest` so when writing a plugin with `router_service` you always had an empty path `/` on `RouterRequest`. +Fixes a bug where enums that were arguments to nested queries were not being reported. -## 📚 Documentation +By [@merylc](https://github.com/merylc) in https://github.com/apollographql/router/pull/6900 -- **We have incorporated a substantial amount of documentation** (via many, many PRs!) +### Add compute job pool metrics ([PR #7184](https://github.com/apollographql/router/pull/7184)) - See our improved documentation [on our website](https://www.apollographql.com/docs/router/). +The compute job pool is used within the router for compute intensive jobs that should not block the Tokio worker threads. +When this pool becomes saturated it is difficult for users to see why so that they can take action. +This change adds new metrics to help users understand how long jobs are waiting to be processed. -# [v0.1.0-alpha.9] 2022-03-16 -## ❗ BREAKING ❗ +New metrics: +- `apollo.router.compute_jobs.queue_is_full` - A counter of requests rejected because the queue was full. +- `apollo.router.compute_jobs.duration` - A histogram of time spent in the compute pipeline by the job, including the queue and query planning. + - `job.type`: (`query_planning`, `query_parsing`, `introspection`) + - `job.outcome`: (`executed_ok`, `executed_error`, `channel_error`, `rejected_queue_full`, `abandoned`) +- `apollo.router.compute_jobs.queue.wait.duration` - A histogram of time spent in the compute queue by the job. + - `job.type`: (`query_planning`, `query_parsing`, `introspection`) +- `apollo.router.compute_jobs.execution.duration` - A histogram of time spent to execute job (excludes time spent in the queue). + - `job.type`: (`query_planning`, `query_parsing`, `introspection`) +- `apollo.router.compute_jobs.active_jobs` - A gauge of the number of compute jobs being processed in parallel. + - `job.type`: (`query_planning`, `query_parsing`, `introspection`) -- **Header propagation configuration changes** ([PR #599](https://github.com/apollographql/router/pull/599)) +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7184 - Header manipulation configuration is now a core-plugin and configured at the _top-level_ of the Router's configuration file, rather than its previous location within service-level layers. Some keys have also been renamed. For example: +### Preserve trailing slashes in Connectors URIs ([PR #7220](https://github.com/apollographql/router/pull/7220)) - **Previous configuration** +Previously, a URI like `@connect(http: {GET: "/users/"})` could be normalized to `@connect(http: {GET: "/users"})`. This +change preserves the trailing slash, which is significant to some web servers. - ```yaml - subgraphs: - products: - layers: - - headers_propagate: - matching: - regex: .* - ``` - - **New configuration** - - ```yaml - headers: - subgraphs: - products: - - propagate: - matching: ".*" - ``` +By [@dylan-apollo](https://github.com/dylan-apollo) in https://github.com/apollographql/router/pull/7220 + +### Support @context/@fromContext when using Connectors ([PR #7132](https://github.com/apollographql/router/pull/7132)) -- **Move Apollo plugins to top-level configuration** ([PR #623](https://github.com/apollographql/router/pull/623)) +This fixes a bug that dropped the `@context` and `@fromContext` directives when introducing a connector. - Previously plugins were all under the `plugins:` section of the YAML config. However, these "core" plugins are now promoted to the top-level of the config. This reflects the fact that these plugins provide core functionality even though they are implemented as plugins under the hood and further reflects the fact that they receive special treatment in terms of initialization order (they are initialized first before members of `plugins`). +By [@lennyburdette](https://github.com/lennyburdette) in https://github.com/apollographql/router/pull/7132 -- **Remove configurable layers** ([PR #603](https://github.com/apollographql/router/pull/603)) +### telemetry: correctly apply conditions on events ([PR #7325](https://github.com/apollographql/router/pull/7325)) - Having `plugins` _and_ `layers` as configurable items in YAML was creating confusion as to when it was appropriate to use a `layer` vs a `plugin`. As the layer API is a subset of the plugin API, `plugins` has been kept, however the `layer` option has been dropped. +Fixed a issue where conditional telemetry events weren't being properly evaluated. +This affected both standard events (`response`, `error`) and custom telemetry events. -- **Plugin names have dropped the `com.apollographql` prefix** ([PR #602](https://github.com/apollographql/router/pull/600)) +For example in config like this: +```yaml +telemetry: + instrumentation: + events: + supergraph: + request: + level: info + condition: + eq: + - request_header: apollo-router-log-request + - testing + response: + level: info + condition: + eq: + - request_header: apollo-router-log-request + - testing +``` - Previously, core plugins were prefixed with `com.apollographql.`. This is no longer the case and, when coupled with the above moving of the core plugins to the top-level, the prefixing is no longer present. This means that, for example, `com.apollographql.telemetry` would now be just `telemetry`. +The Router would emit the `request` event when the header matched, but never emit the `response` event - even with the same matching header. -- **Use `ControlFlow` in checkpoints** ([PR #602](https://github.com/apollographql/router/pull/602)) +This fix ensures that all event conditions are properly evaluated, restoring expected telemetry behavior and making conditional logging work correctly throughout the entire request lifecycle. +By [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/7325 -- **Add Rhai plugin** ([PR #548](https://github.com/apollographql/router/pull/484)) +### Connection shutdown timeout 1.x ([PR #7058](https://github.com/apollographql/router/pull/7058)) - Both `checkpoint` and `async_checkpoint` now `use std::ops::ControlFlow` instead of the `Step` enum. `ControlFlow` has two variants, `Continue` and `Break`. +When a connection is closed we call `graceful_shutdown` on hyper and then await for the connection to close. -- **The `reporting` configuration changes to `telemetry`** ([PR #651](https://github.com/apollographql/router/pull/651)) +Hyper 0.x has various issues around shutdown that may result in us waiting for extended periods for the connection to eventually be closed. - All configuration that was previously under the `reporting` header is now under a `telemetry` key. -## :sparkles: Features +This PR introduces a configurable timeout from the termination signal to actual termination, defaulted to 60 seconds. The connection is forcibly terminated after the timeout is reached. -- **Header propagation now supports "all" subgraphs** ([PR #599](https://github.com/apollographql/router/pull/599)) +To configure, set the option in router yaml. It accepts human time durations: +``` +supergraph: + connection_shutdown_timeout: 60s +``` - It is now possible to configure header propagation rules for *all* subgraphs without needing to explicitly name each subgraph. You can accomplish this by using the `all` key, under the (now relocated; see above _breaking changes_) `headers` section. +Note that even after connections have been terminated the router will still hang onto pipelines if `early_cancel` has not been configured to true. The router is trying to complete the request. - ```yaml - headers: - all: - - propagate: - matching: "aaa.*" - - propagate: - named: "bbb" - default: "def" - rename: "ccc" - - insert: - name: "ddd" - value: "eee" - - remove: - matching: "fff.*" - - remove: - name: "ggg" - ``` - -- **Update to latest query planner from Federation 2** ([PR #653](https://github.com/apollographql/router/pull/653)) - - The Router now uses the `@apollo/query-planner@2.0.0-preview.5` query planner, bringing the most recent version of Federation 2. +Users can either set `early_cancel` to `true` +``` +supergraph: + early_cancel: true +``` -## 🐛 Fixes +AND/OR use traffic shaping timeouts: +``` +traffic_shaping: + router: + timeout: 60s +``` -- **`Content-Type` of HTTP responses is now set to `application/json`** ([Issue #639](https://github.com/apollographql/router/issues/639)) +By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/7058 - Previously, we were not setting a `content-type` on HTTP responses. While plugins can still set a different `content-type` if they'd like, we now ensure that a `content-type` of `application/json` is set when one was not already provided. +### Clarify tracing error messages in coprocessor's stages (PR #6791) -- **GraphQL Enums in query parameters** ([Issue #612](https://github.com/apollographql/router/issues/612)) +Trace messages in coprocessors used `external extensibility` namespace. They now use `coprocessor` in the message instead for clarity. - Enums in query parameters were handled correctly in the response formatting, but not in query validation. We now have a new test and a fix. +By [@briannafugate408](https://github.com/briannafugate408) -- **OTel trace propagation works again** ([PR #620](https://github.com/apollographql/router/pull/620)) +### Fix crash when an invalid query plan is generated ([PR #7214](https://github.com/apollographql/router/pull/7214)) - When we re-worked our OTel implementation to be a plugin, the ability to trace across processes (into subgraphs) was lost. This fix restores this capability. We are working to improve our end-to-end testing of this to prevent further regressions. +When an invalid query plan is generated, the router could panic and crash. +This could happen if there are gaps in the GraphQL validation implementation. +Now, even if there are unresolved gaps, the router will handle it gracefully and reject the request. -- **Reporting plugin schema generation** ([PR #607](https://github.com/apollographql/router/pull/607)) +By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/7214 - Previously our `reporting` plugin configuration was not able to participate in JSON Schema generation. This is now broadly correct and makes writing a syntactically-correct schema much easier. +### Fix Apollo request metadata generation for errors ([PR #7021](https://github.com/apollographql/router/pull/7021)) - To generate a schema, you can still run the same command as before: +* Fixes the Apollo operation ID and name generated for requests that fail due to parse, validation, or invalid operation name errors. +* Updates the error code generated for operations with an invalid operation name from GRAPHQL_VALIDATION_FAILED to GRAPHQL_UNKNOWN_OPERATION_NAME - ``` - router --schema > apollo_configuration_schema.json - ``` +By [@bonnici](https://github.com/bonnici) in https://github.com/apollographql/router/pull/7021 - Then, follow the instructions for associating it with your development environment. +### Enable Integer Error Code Reporting ([PR #7226](https://github.com/apollographql/router/pull/7226)) -- **Input object validation** ([PR #658](https://github.com/apollographql/router/pull/658)) +Fixes an issue where numeric error codes (e.g. 400, 500) were not properly parsed into a string and thus were not +reported to Apollo error telemetry. - Variable validation was incorrectly using output objects instead of input objects +By [@rregitsky](https://github.com/rregitsky) in https://github.com/apollographql/router/pull/7226 -# [v0.1.0-alpha.8] 2022-03-08 +### Increase compute job pool queue size ([PR #7205](https://github.com/apollographql/router/pull/7205)) -## :sparkles: Features +The compute job pool in the router is used to execute CPU intensive work outside of the main I/O worker threads, including GraphQL parsing, query planning, and introspection. When the pool is busy, jobs enter a queue. -- **Request lifecycle checkpoints** ([PR #558](https://github.com/apollographql/router/pull/548) and [PR #580](https://github.com/apollographql/router/pull/548)) +We previously set this queue size to 20 (per thread). However, this may be too small on resource constrained environments. - Checkpoints in the request pipeline now allow plugin authors (which includes us!) to check conditions during a request's lifecycle and circumvent further execution if desired. +This patch increases the queue size to 1,000 jobs per thread. For reference, in older router versions before the introduction of the compute job worker pool, the equivalent queue size was *1,000*. - Using `Step` return types within the checkpoint it's possible to influence what happens (including changing things like the HTTP status code, etc.). A caching layer, for example, could return `Step::Return(response)` if a cache "hit" occurred and `Step::Continue(request)` (to allow normal processing to continue) in the event of a cache "miss". +By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/7205 - These can be either synchronous or asynchronous. To see examples, see: +### Relax percent encoding for Connectors ([PR #7220](https://github.com/apollographql/router/pull/7220)) - - A [synchronous example](https://github.com/apollographql/router/tree/190afe181bf2c50be1761b522fcbdcc82b81d6ca/examples/forbid-anonymous-operations) - - An [asynchronous example](https://github.com/apollographql/router/tree/190afe181bf2c50be1761b522fcbdcc82b81d6ca/examples/async-allow-client-id) +Characters outside of `{ }` expressions will no longer be percent encoded unless they are completely invalid for a +URI. For example, in an expression like `@connect(http: {GET: "/products?filters[category]={$args.category}"})` the +square +braces `[ ]` will no longer be percent encoded. Any string from within a dynamic `{ }` will still be percent encoded. -- **Contracts support** ([PR #573](https://github.com/apollographql/router/pull/573)) +By [@dylan-apollo](https://github.com/dylan-apollo) in https://github.com/apollographql/router/pull/7220 - The Apollo Router now supports [Apollo Studio Contracts](https://www.apollographql.com/docs/studio/contracts/)! +### Preserve `data: null` when handling coprocessor GraphQL responses which included `errors` ([PR #7141](https://github.com/apollographql/router/pull/7141)) -- **Add OpenTracing support** ([PR #548](https://github.com/apollographql/router/pull/548)) +Previously, Router incorrectly swallowed `data: null` conditions on GraphQL responses returned from a coprocessor. - OpenTracing support has been added into the reporting plugin. You're now able to have span propagation (via headers) via two common formats supported by the `opentracing` crate: `zipkin_b3` and `jaeger`. +According to [GraphQL Spectification](https://spec.graphql.org/draft/#sel-FAPHLJCAACEBxlY): +> If an error was raised during the execution that prevented a valid response, the "data" entry in the response **should be null**. -## :bug: Fixes +That means if coprocessor returned a valid execution error, for example: -- **Configuration no longer requires `router_url`** ([PR #553](https://github.com/apollographql/router/pull/553)) +```json +{ + "data": null, + "errors": [{ "message": "Some execution error" }] +} +``` - When using Managed Federation or directly providing a Supergraph file, it is no longer necessary to provide a `routing_url` value. Instead, the values provided by the Supergraph or Studio will be used and the `routing_url` can be used only to override specific URLs for specific subgraphs. +It was incorrect (and inadvertent) to return the following response to the client: -- **Fix plugin ordering** ([PR #559](https://github.com/apollographql/router/issues/559)) +```json +{ + "errors": [{ "message": "Some execution error" }] +} +``` - Plugins need to execute in sequence of declaration *except* for certain "core" plugins (e.g., reporting) which must execute early in the plugin sequence to make sure they are in place as soon as possible in the Router lifecycle. This change now ensures that the reporting plugin executes first and that all other plugins are executed in the order of declaration in configuration. +This fix ensures compliance with the GraphQL specification in this regard by preserving the complete structure of the response returned from coprocessors. -- **Propagate Router operation lifecycle errors** ([PR #537](https://github.com/apollographql/router/issues/537)) +Contributed by [@IvanGoncharov](https://github.com/IvanGoncharov) in [#7141](https://github.com/apollographql/router/pull/7141) - Our recent extension rework was missing a key part: Error propagation and handling! This change makes sure errors that occurred during query planning and query execution will be displayed as GraphQL errors instead of an empty payload. +### Helm: Correct default telemetry `resource` property in `ConfigMap` ([Issue #6104](https://github.com/apollographql/router/issues/6104)) +The Helm chart was using an outdated value when emitting the `telemetry.exporters.metrics.common.resource.service.name` values. This has been updated to use the correct (singular) version of `resource` (rather than the incorrect `resources` which was used earlier in 1.x's life-cycle). -# [v0.1.0-alpha.7] 2022-02-25 +By [@vatsalpatel](https://github.com/vatsalpatel) in https://github.com/apollographql/router/pull/6105 -## :sparkles: Features +### Update Dockerfile exec script to use `#!/bin/bash` instead of `#!/usr/bin/env bash` ([Issue #3517](https://github.com/apollographql/router/issues/3517)) -- **Apollo Studio Explorer landing page** ([PR #526](https://github.com/apollographql/router/pull/526)) +For users of Google Cloud Platform (GCP) Cloud Run platform, using the router's default Docker image was not possible due to an error that would occur during startup: - We've replaced the _redirect_ to Apollo Studio with a statically rendered landing page. This supersedes the previous redirect approach was merely introduced as a short-cut. The experience now duplicates the user-experience which exists in Apollo Gateway today. +```sh +"/usr/bin/env: 'bash ': No such file or directory" +``` - It is also possible to _save_ the redirect preference and make the behavior sticky for future visits. As a bonus, this also resolves the failure to preserve the correct HTTP scheme (e.g., `https://`) in the event that the Apollo Router was operating behind a TLS-terminating proxy, since the redirect is now handled client-side. +To avoid this issue, we've changed the script to use `#!/bin/bash` instead of `#!/usr/bin/env bash`, as we use a fixed Linux distribution in Docker which has the Bash binary located in a fixed location. - Overall, this should be a more durable and more transparent experience for the user. +By [@lleadbet](https://github.com/lleadbet) in https://github.com/apollographql/router/pull/7198 -- **Display Apollo Router version on startup** ([PR #543](https://github.com/apollographql/router/pull/543)) - The Apollo Router displays its version on startup from now on, which will come in handy when debugging/observing how your application behaves. +### Remove "setting resource attributes is not allowed" warning ([PR #7272](https://github.com/apollographql/router/pull/7272)) -## :bug: Fixes +If Uplink was enabled, Router 2.1.x emitted this warning at startup even when there was no user configuration responsible for the condition: -- **Passing a `--supergraph` file supersedes Managed Federation** ([PR #535](https://github.com/apollographql/router/pull/535)) +``` +WARN setting resource attributes is not allowed for Apollo telemetry +``` - The `--supergraph` flag will no longer be silently ignored when the Supergraph is already being provided through [Managed Federation](https://www.apollographql.com/docs/federation/managed-federation/overview) (i.e., when the `APOLLO_KEY` and `APOLLO_GRAPH_REF` environment variables are set). This allows temporarily overriding the Supergraph schema that is fetched from Apollo Studio's Uplink endpoint, while still reporting metrics to Apollo Studio reporting ingress. +The warning is removed entirely. -- **Anonymous operation names are now empty in tracing** ([PR #525](https://github.com/apollographql/router/pull/525)) +By [@SimonSapin](https://github.com/SimonSapin) in https://github.com/apollographql/router/pull/7272 - When GraphQL operation names are not necessary to execute an operation (i.e., when there is only a single operation in a GraphQL document) and the GraphQL operation is _not_ named (i.e., it is anonymous), the `operation_name` attribute on the trace spans that are associated with the request will no longer contain a single hyphen character (`-`) but will instead be an empty string. This matches the way that these operations are represented during the GraphQL operation's life-cycle as well. +## 📃 Configuration -- **Resolved missing documentation in Apollo Explorer** ([PR #540](https://github.com/apollographql/router/pull/540)) +### Customization of "header read timeout" ([PR #7262](https://github.com/apollographql/router/pull/7262)) - We've resolved a scenario that prevented Apollo Explorer from displaying documentation by adding support for a new introspection query which also queries for deprecation (i.e., `includeDeprecated`) on `input` arguments. +This change exposes the server's header read timeout as the `server.http.header_read_timeout` configuration option. -# [v0.1.0-alpha.6] 2022-02-18 +By default, the `server.http.header_read_timeout` is set to previously hard-coded 10 seconds. A longer timeout can be configured using the `server.http.header_read_timeout` option. -## :sparkles: Features +```yaml title="router.yaml" +server: + http: + header_read_timeout: 30s +``` -- **Apollo Studio Managed Federation support** ([PR #498](https://github.com/apollographql/router/pull/498)) +By [@gwardwell ](https://github.com/gwardwell) in https://github.com/apollographql/router/pull/7262 - [Managed Federation]: https://www.apollographql.com/docs/federation/managed-federation/overview/ +### Fine-grained control over `include_subgraph_errors` ([Issue #6402](https://github.com/apollographql/router/pull/6402) - The Router can now automatically download and check for updates on its schema from Studio (via [Uplink])'s free, [Managed Federation] service. This is configured in the same way as Apollo Gateway via the `APOLLO_KEY` and `APOLLO_GRAPH_REF` environment variables, in the same way as was true in Apollo Gateway ([seen here](https://www.apollographql.com/docs/federation/managed-federation/setup/#4-connect-the-gateway-to-studio)). This will also enable operation usage reporting. +Update `include_subgraph_errors` with additional configuration options for both global and subgraph levels. This update provides finer control over error messages and extension keys for each subgraph. +For more details, please read [subgraph error inclusion](https://www.apollographql.com/docs/graphos/routing/observability/subgraph-error-inclusion). - > **Note:** It is not yet possible to configure the Router with [`APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT`]. If you need this behavior, please open a feature request with your use case. +```yaml +include_subgraph_errors: + all: + redact_message: true + allow_extensions_keys: + - code + subgraphs: + product: + redact_message: false # Propagate original error messages + allow_extensions_keys: # Extend global allow list - `code` and `reason` will be propagated + - reason + exclude_global_keys: # Exclude `code` from global allow list - only `reason` will be propagated. + - code + account: + deny_extensions_keys: # Overrides global allow list + - classification + review: false # Redact everything. - [`APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT`]: https://www.apollographql.com/docs/federation/managed-federation/uplink/#environment-variable - [Uplink]: https://www.apollographql.com/docs/federation/managed-federation/uplink/ - [operation usage reporting]: https://www.apollographql.com/docs/studio/metrics/usage-reporting/#pushing-metrics-from-apollo-server + # Undefined subgraphs inherits default global settings from `all` +``` -- **Subgraph header configuration** ([PR #453](https://github.com/apollographql/router/pull/453)) +**Note:** Using a `deny_extensions_keys` approach carries security risks because any sensitive information not explicitly included in the deny list will be exposed to clients. For better security, subgraphs should prefer to redact everything or `allow_extensions_keys` when possible. - The Router now supports passing both client-originated and router-originated headers to specific subgraphs using YAML configuration. Each subgraph which needs to receive headers can specify which headers (or header patterns) should be forwarded to which subgraph. +By [@Samjin](https://github.com/Samjin) and [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/7164 - More information can be found in our documentation on [subgraph header configuration]. +### Add new configurable delivery pathway for high cardinality GraphOS Studio metrics ([PR #7138](https://github.com/apollographql/router/pull/7138)) - At the moment, when using using YAML configuration alone, router-originated headers can only be static strings (e.g., `sent-from-apollo-router: true`). If you have use cases for deriving headers in the router dynamically, please open or find a feature request issue on the repository which explains the use case. +This change provides a secondary pathway for new "realtime" GraphOS Studio metrics whose delivery interval is configurable due to their higher cardinality. These metrics will respect `telemetry.apollo.batch_processor.scheduled_delay` as configured on the realtime path. All other Apollo metrics will maintain the previous hardcoded 60s send interval. - [subgraph header configuration]: https://www.apollographql.com/docs/router/configuration/#configuring-headers-received-by-subgraphs +By [@rregitsky](https://github.com/rregitsky) and [@timbotnik](https://github.com/timbotnik) in https://github.com/apollographql/router/pull/7138 -- **In-flight subgraph `query` de-duplication** ([PR #285](https://github.com/apollographql/router/pull/285)) +## 📚 Documentation - As a performance booster to both the Router and the subgraphs it communicates with, the Router will now _de-duplicate_ multiple _identical_ requests to subgraphs when there are multiple in-flight requests to the same subgraph with the same `query` (**never** `mutation`s), headers, and GraphQL `variables`. Instead, a single request will be made to the subgraph and the many client requests will be served via that single response. +### GraphQL error codes that can occur during router execution ([PR #7160](https://github.com/apollographql/router/issues/7160)) - There may be a substantial drop in number of requests observed by subgraphs with this release. +Added documentation for more GraphQL error codes that can occur during router execution, including better differentiation between HTTP status codes and GraphQL error extensions codes. -- **Operations can now be made via `GET` requests** ([PR #429](https://github.com/apollographql/router/pull/429)) +By [@timbotnik](https://github.com/timbotnik) in https://github.com/apollographql/router/pull/7160 - The Router now supports `GET` requests for `query` operations. Previously, the Apollo Router only supported making requests via `POST` requests. We've always intended on supporting `GET` support, but needed some additional support in place to make sure we could prevent allowing `mutation`s to happen over `GET` requests. +### Update API Gateway tech note ([PR #7261](https://github.com/apollographql/router/pull/7261)) -- **Automatic persisted queries (APQ) support** ([PR #433](https://github.com/apollographql/router/pull/433)) +Update the [Router vs Gateway Tech Note](https://www.apollographql.com/docs/graphos/routing/router-api-gateway-comparison) with more details now that we have connectors - The Router now handles [automatic persisted queries (APQ)] by default, as was previously the case in Apollo Gateway. APQ support pairs really well with `GET` requests (which also landed in this release) since they allow read operations (e.g., `GET` requests) to be more easily cached by intermediary proxies and CDNs, which typically forbid caching `POST` requests by specification (even if they often are just reads in GraphQL). Follow the link above to the documentation to test them out. +By [@smyrick](https://github.com/smyrick) in https://github.com/apollographql/router/pull/7261 - [automatic persisted queries (APQ)]: https://www.apollographql.com/docs/apollo-server/performance/apq/ +### Extended errors preview configuration ([PR 7038](https://github.com/apollographql/router/pull/7038)) -- **New internal Tower architecture and preparation for extensibility** ([PR #319](https://github.com/apollographql/router/pull/319)) +We've introduced documentation for [GraphOS extended error reporting](https://www.apollographql.com/docs/graphos/routing/configuration#extended-error-reporting). - We've introduced new foundational primitives to the Router's request pipeline which facilitate the creation of composable _onion layers_. For now, this is largely leveraged through a series of internal refactors and we'll need to document and expand on more of the details that facilitate developers building their own custom extensions. To leverage existing art — and hopefully maximize compatibility and facilitate familiarity — we've leveraged the [Tokio Tower `Service`] pattern. +By [@timbotnik](https://github.com/timbotnik) in https://github.com/apollographql/router/pull/7038 - This should facilitate a number of interesting extension opportunities and we're excited for what's in-store next. We intend on improving and iterating on the API's ergonomics for common Graph Router behaviors over time, and we'd encourage you to open issues on the repository with use-cases you might think need consideration. +### Add tip about `Apollo-Expose-Query-Plan: dry-run` to Cache warm-up ([PR #6973](https://github.com/apollographql/router/pull/6973)) - [Tokio Tower `Service`]: https://docs.rs/tower/latest/tower/trait.Service.html +The [Cache warm-up documentation](https://www.apollographql.com/docs/graphos/routing/performance/caching/in-memory#cache-warm-up) now flags the availability of the `Apollo-Expose-Query-Plan: dry-run` header. -- **Support for Jaeger HTTP collector in OpenTelemetry** ([PR #479](https://github.com/apollographql/router/pull/479)) +By [@smyrick](https://github.com/smyrick) in https://github.com/apollographql/router/pull/6973 - It is now possible to configure Jaeger HTTP collector endpoints within the `opentelemetry` configuration. Previously, Router only supported the UDP method. +# [2.1.3] - 2025-04-16 - The [documentation] has also been updated to demonstrate how this can be configured. +## 🐛 Fixes - [documentation]: https://www.apollographql.com/docs/router/configuration/#using-jaeger +### Entity-cache: handle multiple key directives ([PR #7228](https://github.com/apollographql/router/pull/7228)) -## :bug: Fixes +This PR fixes a bug in entity caching introduced by the fix in https://github.com/apollographql/router/pull/6888 for cases where several `@key` directives with different fields were declared on a type as documented [here](https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/reference/directives#managing-types). -- **Studio agent collector now binds to localhost** [PR #486](https://github.com/apollographql/router/pulls/486) +For example if you have this kind of entity in your schema: - The Studio agent collector will bind to `127.0.0.1`. It can be configured to bind to `0.0.0.0` if desired (e.g., if you're using the collector to collect centrally) by using the [`spaceport.listener` property] in the documentation. +```graphql +type Product @key(fields: "upc") @key(fields: "sku") { + upc: ID! + sku: ID! + name: String +} +``` - [`spaceport.listener` property]: https://www.apollographql.com/docs/router/configuration/#spaceport-configuration +By [@duckki](https://github.com/duckki) & [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/7228 -# [v0.1.0-alpha.5] 2022-02-15 +### Improve Error Message for Invalid JWT Header Values ([PR #7121](https://github.com/apollographql/router/pull/7121)) -## :sparkles: Features +Enhanced parsing error messages for JWT Authorization header values now provide developers with clear, actionable feedback while ensuring that no sensitive data is exposed. -- **Apollo Studio usage reporting agent and operation-level reporting** ([PR #309](https://github.com/apollographql/router/pulls/309), [PR #420](https://github.com/apollographql/router/pulls/420)) +Examples of the updated error messages: +```diff +- Header Value: '' is not correctly formatted. prefix should be 'Bearer' ++ Value of 'authorization' JWT header should be prefixed with 'Bearer' +``` - While there are several levels of Apollo Studio integration, the initial phase of our Apollo Studio reporting focuses on operation-level reporting. +```diff +- Header Value: 'Bearer' is not correctly formatted. Missing JWT ++ Value of 'authorization' JWT header has only 'Bearer' prefix but no JWT token +``` - At a high-level, this will allow Apollo Studio to have visibility into some basic schema details, like graph ID and variant, and per-operation details, including: +By [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/7121 - - Overall operation latency - - The number of times the operation is executed - - [Client awareness] reporting, which leverages the `apollographql-client-*` headers to give visibility into _which clients are making which operations_. +### Fix crash when an invalid query plan is generated ([PR #7214](https://github.com/apollographql/router/pull/7214)) - This should enable several Apollo Studio features including the _Clients_ and _Checks_ pages as well as the _Checks_ tab on the _Operations_ page. +When an invalid query plan is generated, the router could panic and crash. +This could happen if there are gaps in the GraphQL validation implementation. +Now, even if there are unresolved gaps, the router will handle it gracefully and reject the request. - > *Note:* As a current limitation, the _Fields_ page will not have detailed field-based metrics and on the _Operations_ page the _Errors_ tab, the _Traces_ tab and the _Error Percentage_ graph will not receive data. We recommend configuring the Router's [OpenTelemetry tracing] with your APM provider and using distributed tracing to increase visibility into individual resolver performance. +By [@goto-bus-stop](https://github.com/goto-bus-stop) in https://github.com/apollographql/router/pull/7214 - Overall, this marks a notable but still incremental progress toward more of the Studio integrations which are laid out in [#66](https://github.com/apollographql/router/issues/66). +# [2.1.2] - 2025-04-14 - [Client awareness]: https://www.apollographql.com/docs/studio/metrics/client-awareness/ - [Schema checks]: https://www.apollographql.com/docs/studio/schema-checks/ - [OpenTelemetry tracing]: https://www.apollographql.com/docs/router/configuration/#tracing +## 🐛 Fixes -- **Complete GraphQL validation** ([PR #471](https://github.com/apollographql/router/pull/471) via [federation-rs#37](https://github.com/apollographql/federation-rs/pull/37)) +### Support `@context`/`@fromContext` when using Connectors ([PR #7132](https://github.com/apollographql/router/pull/7132)) - We now apply all of the standard validations which are defined in the `graphql` (JavaScript) implementation's default set of "[specified rules]" during query planning. +This fixes a bug that dropped the `@context` and `@fromContext` directives when introducing a connector. - [specified rules]: https://github.com/graphql/graphql-js/blob/95dac43fd4bff037e06adaa7cfb44f497bca94a7/src/validation/specifiedRules.ts#L76-L103 +By [@lennyburdette](https://github.com/lennyburdette) in https://github.com/apollographql/router/pull/7132 -## :bug: Fixes +## 📃 Configuration -- **No more double `http://http://` in logs** ([PR #448](https://github.com/apollographql/router/pulls/448)) +### Add new configurable delivery pathway for high cardinality Apollo Studio metrics ([PR #7138](https://github.com/apollographql/router/pull/7138)) - The server logs will no longer advertise the listening host and port with a doubled-up `http://` prefix. You can once again click happily into Studio Explorer! +This change provides a secondary pathway for new "realtime" Studio metrics whose delivery interval is configurable due to their higher cardinality. These metrics will respect `telemetry.apollo.batch_processor.scheduled_delay` as configured on the realtime path. -- **Improved handling of Federation 1 supergraphs** ([PR #446](https://github.com/apollographql/router/pull/446) via [federation#1511](https://github.com/apollographql/federation/pull/1511)) +All other Apollo metrics will maintain the previous hardcoded 60s send interval. - Our partner team has improved the handling of Federation 1 supergraphs in the implementation of Federation 2 alpha (which the Router depends on and is meant to offer compatibility with Federation 1 in most cases). We've updated our query planner implementation to the version with the fixes. +By [@rregitsky](https://github.com/rregitsky) and [@timbotnik](https://github.com/timbotnik) in https://github.com/apollographql/router/pull/7138 - This also was the first time that we've leveraged the new [`federation-rs`] repository to handle our bridge, bringing a huge developmental advantage to teams working across the various concerns! - [`federation-rs`]: https://github.com/apollographql/federation-rs -- **Resolved incorrect subgraph ordering during merge** ([PR #460](https://github.com/apollographql/router/pull/460)) +# [2.1.1] - 2025-04-07 - A fix was applied to fix the behavior which was identified in [Issue #451] which was caused by a misconfigured filter which was being applied to field paths. +## 🔒 Security - [Issue #451]: https://github.com/apollographql/router/issues/451 -# [v0.1.0-alpha.4] 2022-02-03 +### Certain query patterns may cause resource exhaustion -## :sparkles: Features +Corrects a set of denial-of-service (DOS) vulnerabilities that made it possible for an attacker to render router inoperable with certain simple query patterns due to uncontrolled resource consumption. All prior-released versions and configurations are vulnerable except those where `persisted_queries.enabled`, `persisted_queries.safelist.enabled`, and `persisted_queries.safelist.require_id` are all `true`. -- **Unix socket support** via [#158](https://github.com/apollographql/router/issues/158) +See the associated GitHub Advisories [GHSA-3j43-9v8v-cp3f](https://github.com/apollographql/router/security/advisories/GHSA-3j43-9v8v-cp3f), [GHSA-84m6-5m72-45fp](https://github.com/apollographql/router/security/advisories/GHSA-84m6-5m72-45fp), [GHSA-75m2-jhh5-j5g2](https://github.com/apollographql/router/security/advisories/GHSA-75m2-jhh5-j5g2), and [GHSA-94hh-jmq8-2fgp](https://github.com/apollographql/router/security/advisories/GHSA-94hh-jmq8-2fgp), and the `apollo-compiler` GitHub Advisory [GHSA-7mpv-9xg6-5r79](https://github.com/apollographql/apollo-rs/security/advisories/GHSA-7mpv-9xg6-5r79) for more information. - _...and via upstream [`tokios-rs/tokio#4385`](https://github.com/tokio-rs/tokio/pull/4385)_ +By [@sachindshinde](https://github.com/sachindshinde) and [@goto-bus-stop](https://github.com/goto-bus-stop). - The Router can now listen on Unix domain sockets (i.e., IPC) in addition to the existing IP-based (port) listening. This should bring further compatibility with upstream intermediaries who also allow support this form of communication! +# [2.1.0] - 2025-03-25 - _(Thank you to [@cecton](https://github.com/cecton), both for the PR that landed this feature but also for contributing the upstream PR to `tokio`.)_ +## 🚀 Features -## :bug: Fixes +### Connectors: support for traffic shaping ([PR #6737](https://github.com/apollographql/router/pull/6737)) -- **Resolved hangs occurring on Router reload when `jaeger` was configured** via [#337](https://github.com/apollographql/router/pull/337) +Traffic shaping is now supported for connectors. To target a specific source, use the `subgraph_name.source_name` under the new `connector.sources` property of `traffic_shaping`. Settings under `connector.all` will apply to all connectors. `deduplicate_query` is not supported at this time. - Synchronous calls being made to [`opentelemetry::global::set_tracer_provider`] were causing the runtime to misbehave when the configuration (file) was adjusted (and thus, hot-reloaded) on account of the root context of that call being asynchronous. +Example config: - This change adjusts the call to be made from a new thread. Since this only affected _potential_ runtime configuration changes (again, hot-reloads on a configuration change), the thread spawn is a reasonable solution. +```yaml +traffic_shaping: + connector: + all: + timeout: 5s + sources: + connector-graph.random_person_api: + global_rate_limit: + capacity: 20 + interval: 1s + experimental_http2: http2only + timeout: 1s +``` - [`opentelemetry::global::set_tracer_provider`]: https://docs.rs/opentelemetry/0.10.0/opentelemetry/global/fn.set_tracer_provider.html +By [@andrewmcgivery](https://github.com/andrewmcgivery) in https://github.com/apollographql/router/pull/6737 -## :nail_care: Improvements +### Connectors: Support TLS configuration ([PR #6995](https://github.com/apollographql/router/pull/6995)) -> Most of the improvements this time are internal to the code-base but that doesn't mean we shouldn't talk about them. A great developer experience matters both internally and externally! :smile_cat: +Connectors now supports TLS configuration for using custom certificate authorities and utilizing client certificate authentication. -- **Store JSON strings in a `bytes::Bytes` instance** via [#284](https://github.com/apollographql/router/pull/284) +```yaml +tls: + connector: + sources: + connector-graph.random_person_api: + certificate_authorities: ${file.ca.crt} + client_authentication: + certificate_chain: ${file.client.crt} + key: ${file.client.key} +``` - The router does a a fair bit of deserialization, filtering, aggregation and re-serializing of JSON objects. Since we currently operate on a dynamic schema, we've been relying on [`serde_json::Value`] to represent this data internally. +By [@andrewmcgivery](https://github.com/andrewmcgivery) in https://github.com/apollographql/router/pull/6995 - After this change, that `Value` type is now replaced with an equivalent type from a new [`serde_json_bytes`], which acts as an envelope around an underlying `bytes::Bytes`. This allows us to refer to the buffer that contained the JSON data while avoiding the allocation and copying costs on each string for values that are largely unused by the Router directly. +### Update JWT handling ([PR #6930](https://github.com/apollographql/router/pull/6930)) - This should offer future benefits when implementing — e.g., query de-duplication and caching — since a single buffer will be usable by multiple responses at the same time. +This PR updates JWT-handling in the `AuthenticationPlugin`; - [`serde_json::Value`]: https://docs.rs/serde_json/0.9.8/serde_json/enum.Value.html - [`serde_json_bytes`]: https://crates.io/crates/serde_json_bytes - [`bytes::Bytes`]: https://docs.rs/bytes/0.4.12/bytes/struct.Bytes.html +- Users may now set a new config option `config.authentication.router.jwt.on_error`. + - When set to the default `Error`, JWT-related errors will be returned to users (the current behavior). + - When set to `Continue`, JWT errors will instead be ignored, and JWT claims will not be set in the request context. +- When JWTs are processed, whether processing succeeds or fails, the request context will contain a new variable `apollo::authentication::jwt_status` which notes the result of processing. -- **Development workflow improvement** via [#367](https://github.com/apollographql/router/pull/367) +By [@Velfi](https://github.com/Velfi) in https://github.com/apollographql/router/pull/6930 - Polished away some existing _Problems_ reported by `rust-analyzer` and added troubleshooting instructions to our documentation. +### Add `batching.maximum_size` configuration option to limit maximum client batch size ([PR #7005](https://github.com/apollographql/router/pull/7005)) -- **Removed unnecessary `Arc` from `PreparedQuery`'s `execute`** via [#328](https://github.com/apollographql/router/pull/328) +Add an optional `maximum_size` parameter to the batching configuration. - _...and followed up with [#367](https://github.com/apollographql/router/pull/367)_ +* When specified, the router will reject requests which contain more than `maximum_size` queries in the client batch. +* When unspecified, the router performs no size checking (the current behavior). -- **Bumped/upstream improvements to `test_span`** via [#359](https://github.com/apollographql/router/pull/359) +If the number of queries provided exceeds the maximum batch size, the entire batch fails with error code 422 (`Unprocessable Content`). For example: - _...and [`apollographql/test-span#11`](https://github.com/apollographql/test-span/pull/11) upstream_ +```json +{ + "errors": [ + { + "message": "Invalid GraphQL request", + "extensions": { + "details": "Batch limits exceeded: you provided a batch with 3 entries, but the configured maximum router batch size is 2", + "code": "BATCH_LIMIT_EXCEEDED" + } + } + ] +} +``` - Internally, this is just a version bump to the Router, but it required upstream changes to the `test-span` crate. The bump brings new filtering abilities and adjusts the verbosity of spans tracing levels, and removes non-determinism from tests. +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7005 -# [v0.1.0-alpha.3] 2022-01-11 +### Introduce PQ manifest `hot_reload` option for local manifests ([PR #6987](https://github.com/apollographql/router/pull/6987)) -## :rocket::waxing_crescent_moon: Public alpha release +This change introduces a [`persisted_queries.hot_reload` configuration option](https://www.apollographql.com/docs/graphos/routing/security/persisted-queries#hot_reload) to allow the router to hot reload local PQ manifest changes. -> An alpha or beta release is in volatile, active development. The release might not be feature-complete, and breaking API changes are possible between individual versions. +If you configure `local_manifests`, you can set `hot_reload` to `true` to automatically reload manifest files whenever they change. This lets you update local manifest files without restarting the router. -## :sparkles: Features +```yaml +persisted_queries: + enabled: true + local_manifests: + - ./path/to/persisted-query-manifest.json + hot_reload: true +``` -- Trace sampling [#228](https://github.com/apollographql/router/issues/228): Tracing each request can be expensive. The router now supports sampling, which allows us to only send a fraction of the received requests. +Note: This change explicitly does _not_ piggyback on the existing `--hot-reload` flag. -- Health check [#54](https://github.com/apollographql/router/issues/54) +By [@trevor-scheer](https://github.com/trevor-scheer) in https://github.com/apollographql/router/pull/6987 -## :bug: Fixes +### Add support to get/set URI scheme in Rhai ([Issue #6897](https://github.com/apollographql/router/issues/6897)) -- Schema parse errors [#136](https://github.com/apollographql/router/pull/136): The router wouldn't display what went wrong when parsing an invalid Schema. It now displays exactly where a the parsing error occured, and why. +This adds support to read and write the scheme from the `request.uri.scheme`/`request.subgraph.uri.scheme` functions in Rhai, +enabling the ability to switch between `http` and `https` for subgraph fetches. For example: -- Various tracing and telemetry fixes [#237](https://github.com/apollographql/router/pull/237): The router wouldn't display what went wrong when parsing an invalid Schema. It now displays exactly where a the parsing error occured, and why. +```rs +fn subgraph_service(service, subgraph){ + service.map_request(|request|{ + log_info(`${request.subgraph.uri.scheme}`); + if request.subgraph.uri.scheme == {} { + log_info("Scheme is not explicitly set"); + } + request.subgraph.uri.scheme = "https" + request.subgraph.uri.host = "api.apollographql.com"; + request.subgraph.uri.path = "/api/graphql"; + request.subgraph.uri.port = 1234; + log_info(`${request.subgraph.uri}`); + }); +} +``` +By [@starJammer](https://github.com/starJammer) in https://github.com/apollographql/router/pull/6906 -- Query variables validation [#62](https://github.com/apollographql/router/issues/62): Now that we have a schema parsing feature, we can validate the variables and their types against the schemas and queries. +### Add `router config validate` subcommand ([PR #7016](https://github.com/apollographql/router/pull/7016)) +Adds new `router config validate` subcommand to allow validation of a router config file without fully starting up the Router. -# [v0.1.0-alpha.2] 2021-12-03 +``` +./router config validate +``` -## :rocket::waxing_crescent_moon: Public alpha release +By [@andrewmcgivery](https://github.com/andrewmcgivery) in https://github.com/apollographql/router/pull/7016 -> An alpha or beta release is in volatile, active development. The release might not be feature-complete, and breaking API changes are possible between individual versions. +### Enable remote proxy downloads of the Router -## :sparkles: Features +This enables users without direct download access to specify a remote proxy mirror location for the GitHub download of +the Apollo Router releases. -- Add support for JSON Logging [#46](https://github.com/apollographql/router/issues/46) +By [@LongLiveCHIEF](https://github.com/LongLiveCHIEF) in https://github.com/apollographql/router/pull/6667 -## :bug: Fixes +### Add metric to measure cardinality overflow frequency ([PR #6998](https://github.com/apollographql/router/pull/6998)) -- Fix Open Telemetry report errors when using Zipkin [#180](https://github.com/apollographql/router/issues/180) +Adds a new counter metric, `apollo.router.telemetry.metrics.cardinality_overflow`, that is incremented when the [cardinality overflow log](https://github.com/open-telemetry/opentelemetry-rust/blob/d583695d30681ee1bd910156de27d91be3711822/opentelemetry-sdk/src/metrics/internal/mod.rs#L134) from [opentelemetry-rust](https://github.com/open-telemetry/opentelemetry-rust) occurs. This log means that a metric in a batch has reached a cardinality of > 2000 and that any excess attributes will be ignored. -# [v0.1.0-alpha.1] 2021-11-18 +By [@rregitsky](https://github.com/rregitsky) in https://github.com/apollographql/router/pull/6998 -## :rocket::waxing_crescent_moon: Initial public alpha release +### Add metrics for value completion errors ([PR #6905](https://github.com/apollographql/router/pull/6905)) -> An alpha or beta release is in volatile, active development. The release might not be feature-complete, and breaking API changes are possible between individual versions. +When the router encounters a value completion error, it is not included in the GraphQL errors array, making it harder to observe. To surface this issue in a more obvious way, router now counts value completion error metrics via the metric instruments `apollo.router.graphql.error` and `apollo.router.operations.error`, distinguishable via the `code` attribute with value `RESPONSE_VALIDATION_FAILED`. -See our [release stages] for more information. +By [@timbotnik](https://github.com/timbotnik) in https://github.com/apollographql/router/pull/6905 -## :sparkles: Features +### Add `apollo.router.pipelines` metrics ([PR #6967](https://github.com/apollographql/router/pull/6967)) -This release focuses on documentation and bug fixes, stay tuned for the next releases! +When the router reloads, either via schema change or config change, a new request pipeline is created. +Existing request pipelines are closed once their requests finish. However, this may not happen if there are ongoing long requests that do not finish, such as Subscriptions. -## :bug: Fixes +To enable debugging when request pipelines are being kept around, a new gauge metric has been added: -- Handle commas in the @join\_\_graph directive parameters [#101](https://github.com/apollographql/router/pull/101) +- `apollo.router.pipelines` - The number of request pipelines active in the router + - `schema.id` - The Apollo Studio schema hash associated with the pipeline. + - `launch.id` - The Apollo Studio launch id associated with the pipeline (optional). + - `config.hash` - The hash of the configuration -There are several accepted syntaxes to define @join\_\_graph parameters. While we did handle whitespace separated parameters such as `@join__graph(name: "accounts" url: "http://accounts/graphql")`for example, we discarded the url in`@join__graph(name: "accounts", url: "http://accounts/graphql")` (notice the comma). This pr fixes that. +By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/6967 -- Invert subgraph URL override logic [#135](https://github.com/apollographql/router/pull/135) +### Add `apollo.router.open_connections` metric ([PR #7023](https://github.com/apollographql/router/pull/7023)) -Subservices endpoint URLs can both be defined in `supergraph.graphql` and in the subgraphs section of the `configuration.yml` file. The configuration now correctly overrides the supergraph endpoint definition when applicable. +To help users to diagnose when connections are keeping pipelines hanging around, the following metric has been added: +- `apollo.router.open_connections` - The number of request pipelines active in the router + - `schema.id` - The Apollo Studio schema hash associated with the pipeline. + - `launch.id` - The Apollo Studio launch id associated with the pipeline (optional). + - `config.hash` - The hash of the configuration. + - `server.address` - The address that the router is listening on. + - `server.port` - The port that the router is listening on if not a unix socket. + - `http.connection.state` - Either `active` or `terminating`. -- Parse OTLP endpoint address [#156](https://github.com/apollographql/router/pull/156) +You can use this metric to monitor when connections are open via long running requests or keepalive messages. -The router OpenTelemetry configuration only supported full URLs (that contain a scheme) while OpenTelemtry collectors support full URLs and endpoints, defaulting to `https`. This pull request fixes that. +By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/7023 -## :books: Documentation +### Add span events to error spans for connectors and demand control plugin ([PR #6727](https://github.com/apollographql/router/pull/6727)) -A lot of configuration examples and links have been fixed ([#117](https://github.com/apollographql/router/pull/117), [#120](https://github.com/apollographql/router/pull/120), [#133](https://github.com/apollographql/router/pull/133)) +New span events have been added to trace spans which include errors. These span events include the GraphQL error code that relates to the error. So far, this only includes errors generated by connectors and the demand control plugin. -## :pray: Thank you! +By [@bonnici](https://github.com/bonnici) in https://github.com/apollographql/router/pull/6727 -Special thanks to @sjungling, @hsblhsn, @martin-dd, @Mithras and @vvakame for being pioneers by trying out the router, opening issues and documentation fixes! :rocket: +### Changes to experimental error metrics ([PR #6966](https://github.com/apollographql/router/pull/6966)) -# [v0.1.0-alpha.0] 2021-11-10 +In 2.0.0, an experimental metric `telemetry.apollo.errors.experimental_otlp_error_metrics` was introduced to track errors with additional attributes. A few related changes are included here: -## :rocket::waxing_crescent_moon: Initial public alpha release +- Sending these metrics now also respects the subgraph's `send` flag e.g. `telemetry.apollo.errors.subgraph.[all|(subgraph name)].send`. +- A new configuration option `telemetry.apollo.errors.subgraph.[all|(subgraph name)].redaction_policy` has been added. This flag only applies when `redact` is set to `true`. When set to `ErrorRedactionPolicy.Strict`, error redaction will behave as it has in the past. Setting this to `ErrorRedactionPolicy.Extended` will allow the `extensions.code` value from subgraph errors to pass through redaction and be sent to Studio. +- A warning about incompatibility of error telemetry with connectors will be suppressed when this feature is enabled, since it _does_ support connectors when using the new mode. -> An alpha or beta release is in volatile, active development. The release might not be feature-complete, and breaking API changes are possible between individual versions. +By [@timbotnik](https://github.com/timbotnik) in https://github.com/apollographql/router/pull/6966 -See our [release stages] for more information. -[release stages]: https://www.apollographql.com/docs/resources/release-stages/ +## 🐛 Fixes -## :sparkles: Features +### Export gauge instruments ([Issue #6859](https://github.com/apollographql/router/issues/6859)) -- **Federation 2 alpha** +Previously in router 2.x, when using the router's OTel `meter_provider()` to report metrics from Rust plugins, gauge instruments such as those created using `.u64_gauge()` weren't exported. The router now exports these instruments. - The Apollo Router supports the new alpha features of [Apollo Federation 2], including its improved shared ownership model and enhanced type merging. As new Federation 2 features are released, we will update the Router to bring in that new functionality. +By [@yanns](https://github.com/yanns) in https://github.com/apollographql/router/pull/6865 - [Apollo Federation 2]: https://www.apollographql.com/blog/announcement/backend/announcing-federation-2/ +### Use `batch_processor` config for Apollo metrics `PeriodicReader` ([PR #7024](https://github.com/apollographql/router/pull/7024)) -- **Supergraph support** +The Apollo OTLP `batch_processor` configurations `telemetry.apollo.batch_processor.scheduled_delay` and `telemetry.apollo.batch_processor.max_export_timeout` now also control the Apollo OTLP `PeriodicReader` export interval and timeout, respectively. This update brings parity between Apollo OTLP metrics and [non-Apollo OTLP exporter metrics](https://github.com/apollographql/router/blob/0f88850e0b164d12c14b1f05b0043076f21a3b28/apollo-router/src/plugins/telemetry/metrics/otlp.rs#L37-L40). - The Apollo Router supports supergraphs that are published to the Apollo Registry, or those that are composed locally. Both options are enabled by using [Rover] to produce (`rover supergraph compose`) or fetch (`rover supergraph fetch`) the supergraph to a file. This file is passed to the Apollo Router using the `--supergraph` flag. +By [@rregitsky](https://github.com/rregitsky) in https://github.com/apollographql/router/pull/7024 - See the Rover documentation on [supergraphs] for more information! +### Reduce Brotli encoding compression level ([Issue #6857](https://github.com/apollographql/router/issues/6857)) - [Rover]: https://www.apollographql.com/rover/ - [supergraphs]: https://www.apollographql.com/docs/rover/supergraphs/ +The Brotli encoding compression level has been changed from `11` to `4` to improve performance and mimic other compression algorithms' `fast` setting. This value is also a much more reasonable value for dynamic workloads. -- **Query planning and execution** +By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/7007 - The Apollo Router supports Federation 2 query planning using the same implementation we use in Apollo Gateway for maximum compatibility. In the future, we would like to migrate the query planner to Rust. Query plans are cached in the Apollo Router for improved performance. +### CPU count inference improvements for `cgroup` environments ([PR #6787](https://github.com/apollographql/router/pull/6787)) -- **Performance** +This fixes an issue where the `fleet_detector` plugin would not correctly infer the CPU limits for a system which used `cgroup` or `cgroup2`. - We've created benchmarks demonstrating the performance advantages of a Rust-based Apollo Router. Early results show a substantial performance improvement over our Node.js based Apollo Gateway, with the possibility of improving performance further for future releases. +By [@nmoutschen](https://github.com/nmoutschen) in https://github.com/apollographql/router/pull/6787 - Additionally, we are making benchmarking an integrated part of our CI/CD pipeline to allow us to monitor the changes over time. We hope to bring awareness of this into the public purview as we have new learnings. +### Separate entity keys and representation variables in entity cache key ([Issue #6673](https://github.com/apollographql/router/issues/6673)) - See our [blog post] for more. +This fix separates the entity keys and representation variable values in the cache key, to avoid issues with `@requires` for example. - [blog post]: https://www.apollographql.com/blog/announcement/backend/apollo-router-our-graphql-federation-runtime-in-rust/ +> [!IMPORTANT] +> +> If you have enabled [Distributed query plan caching](https://www.apollographql.com/docs/router/configuration/distributed-caching/#distributed-query-plan-caching), this release contains changes which necessarily alter the hashing algorithm used for the cache keys. On account of this, you should anticipate additional cache regeneration cost when updating between these versions while the new hashing algorithm comes into service. -- **Apollo Sandbox Explorer** +By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/6888 - [Apollo Sandbox Explorer] is a powerful web-based IDE for creating, running, and managing GraphQL operations. Visiting your Apollo Router endpoint will take you into the Apollo Sandbox Explorer, preconfigured to operate against your graph. +### Replace Rhai-specific hot-reload functionality with general hot-reload ([PR #6950](https://github.com/apollographql/router/pull/6950)) - [Apollo Sandbox Explorer]: https://www.apollographql.com/docs/studio/explorer/ +In Router 2.0 the rhai hot-reload capability was not working. This was because of architectural improvements to the router which meant that the entire service stack was no longer re-created for each request. -- **Introspection support** +The fix adds the rhai source files into the primary list of elements, configuration, schema, etc..., watched by the router and removes the old Rhai-specific file watching logic. - Introspection support makes it possible to immediately explore the graph that's running on your Apollo Router using the Apollo Sandbox Explorer. Introspection is currently enabled by default on the Apollo Router. In the future, we'll support toggling this behavior. +If --hot-reload is enabled, the router will reload on changes to Rhai source code just like it would for changes to configuration, for example. -- **OpenTelemetry tracing** +By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/6950 - For enabling observability with existing infrastructure and monitoring performance, we've added support using [OpenTelemetry] tracing. A number of configuration options can be seen in the [configuration][configuration 1] documentation under the `opentelemetry` property which allows enabling Jaeger or [OTLP]. +## 📃 Configuration - In the event that you'd like to send data to other tracing platforms, the [OpenTelemetry Collector] can be run an agent and can funnel tracing (and eventually, metrics) to a number of destinations which are implemented as [exporters]. +### Make experimental OTLP error metrics feature flag non-experimental ([PR #7033](https://github.com/apollographql/router/pull/7033)) - [configuration 1]: https://www.apollographql.com/docs/router/configuration/#configuration-file - [OpenTelemetry]: https://opentelemetry.io/ - [OTLP]: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/otlp.md - [OpenTelemetry Collector]: https://github.com/open-telemetry/opentelemetry-collector - [exporters]: https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter +Because the OTLP error metrics feature is being promoted to `preview` from `experimental`, this change updates its feature flag name from `experimental_otlp_error_metrics` to `preview_extended_error_metrics`. -- **CORS customizations** +By [@merylc](https://github.com/merylc) in https://github.com/apollographql/router/pull/7033 - For a seamless getting started story, the Apollo Router has CORS support enabled by default with `Access-Control-Allow-Origin` set to `*`, allowing access to it from any browser environment. - This configuration can be adjusted using the [CORS configuration] in the documentation. - [CORS configuration]: https://www.apollographql.com/docs/router/configuration/#handling-cors +> [!TIP] +> All notable changes to Router v2.x after its initial release will be documented in this file. To see previous history, see the [changelog prior to v2.0.0](https://github.com/apollographql/router/blob/1.x/CHANGELOG.md). -- **Subgraph routing URL overrides** +# [2.0.0] - 2025-02-17 - Routing URLs are encoded in the supergraph, so specifying them explicitly isn't always necessary. +This is a major release of the router containing significant new functionality and improvements to behaviour, resulting in more predictable resource utilisation and decreased latency. - In the event that you have dynamic subgraph URLs, or just want to quickly test something out locally, you can override subgraph URLs in the configuration. +Router 2.0.0 introduces general availability of Apollo Connectors, helping integrate REST services in router deployments. - Changes to the configuration will be hot-reloaded by the running Apollo Router. +This entry summarizes the overall changes in 2.0.0. To learn more details, go to the [What's New in router v2.x](https://www.apollographql.com/docs/graphos/routing/about-v2) page. -## 📚 Documentation +To upgrade to this version, follow the [upgrading from router 1.x to 2.x](https://www.apollographql.com/docs/graphos/reference/upgrade/from-router-v1) guide. - The beginnings of the [Apollo Router's documentation] is now available in the Apollo documentation. We look forward to continually improving it! +## ❗ BREAKING CHANGES ❗ -- **Quickstart tutorial** +In order to make structural improvements in the router and upgrade some of our key dependencies, some breaking changes were introduced in this major release. Most of the breaking changes are in the areas of configuration and observability. All details on what's been removed and changed can be found in the [upgrade guide](https://www.apollographql.com/docs/graphos/reference/upgrade/from-router-v1). - The [quickstart tutorial] offers a quick way to try out the Apollo Router using a pre-deployed set of subgraphs we have running in the cloud. No need to spin up local subgraphs! You can of course run the Apollo Router with your own subgraphs too by providing a supergraph. +## 🚀 Features -- **Configuration options** +Router 2.0.0 comes with many new features and improvements. While all the details can be found in the [What's New guide](https://www.apollographql.com/docs/graphos/routing/about-v2), the following features are the ones we are most excited about. - On our [configuration][configuration 2] page we have a set of descriptions for some common configuration options (e.g., supergraph and CORS) as well as a [full configuration] file example of the currently supported options. +**Simplified integration of REST services using Apollo Connectors.** Apollo Connectors are a declarative programming model for GraphQL, allowing you to plug your existing REST services directly into your graph. Once integrated, client developers gain all the benefits of GraphQL, and API owners gain all the benefits of GraphOS, including incorporation into a supergraph for a comprehensive, unified view of your organization's data and services. [This detailed guide](https://www.apollographql.com/docs/graphos/schema-design/connectors/router) outlines how to configure connectors with the router. Moving from Connectors Preview can be accomplished by following the steps in the [Connectors GA upgrade guide](https://www.apollographql.com/docs/graphos/schema-design/connectors/changelog). - [quickstart tutorial]: https://www.apollographql.com/docs/router/quickstart/ - [configuration 2]: https://www.apollographql.com/docs/router/configuration/ - [full configuration]: https://www.apollographql.com/docs/router/configuration/#configuration-file +**Predictable resource utilization and availability with back pressure.** Back pressure was not maintained in router 1.x, which meant _all_ requests were being accepted by the router. This resulted in issues for routers which are accepting high levels of traffic. Router 2.0.0 improves the handling of back pressure so that traffic shaping measures are more effective while also improving integration with telemetry. Improvements to back pressure then allows for significant improvements in traffic shaping, which improves router's ability to observe timeout and traffic shaping restrictions correctly. You can read about traffic shaping changes in [this section of the upgrade guide](https://www.apollographql.com/docs/graphos/reference/upgrade/from-router-v1#traffic-shaping). -# [v0.1.0-prealpha.5] 2021-11-09 +**Metrics now all follow OpenTelemetry naming conventions.** Some of router's earlier metrics were created before the introduction of OpenTelemetry, resulting in naming inconsistencies. Along with standardising metrics to OpenTelemetry, traces submitted to GraphOS also default to using OpenTelemetry in router 2.0.0. Quite a few existing metrics had to be changed in order to do this properly and correctly, and we encourage you to carefully read through the upgrade guide for all the metrics changes. -## :rocket: Features +**Improved validation of CORS configurations, preventing silent failures.** While CORS configuration did not change in router 2.0.0, we did improve CORS value validation. This results in things like invalid regex or unknown `allow_methods` returning errors early and preventing starting the router. -- **An updated `CHANGELOG.md`!** +**Documentation for context keys, improving usability for advanced customers.** Router 2.0.0 creates consistent naming semantics for request context keys, which are used to share data across internal router pipeline stages. If you are relying on context entries in rust plugins, rhai scripts, coprocessors, or telemetry selectors, please refer to [this section](https://www.apollographql.com/docs/graphos/reference/upgrade/from-router-v1#context-keys) to see what keys changed. - As we build out the base functionality for the router, we haven't spent much time updating the `CHANGELOG`. We should probably get better at that! +## 📃 Configuration - This release is the last one before reveal! 🎉 +Some changes to router configuration options were necessary in this release. Descriptions for both breaking changes to previous configuration and configuration for new features can be found in the [upgrade guide](https://www.apollographql.com/docs/graphos/reference/upgrade/from-router-v1)). -## :bug: Fixes +## 🛠 Maintenance -- **Potentially, many!** +Many external Rust dependencies (crates) have been updated to modern versions where possible. As the Rust ecosystem evolves, so does the router. Keeping these crates up to date helps keep the router secure and stable. - But the lack of clarity goes back to not having kept track of everything thus far! We can _fix_ our processes to keep track of these things! :smile_cat: +Major upgrades in this version include: -# [0.1.0] - TBA +- `axum` +- `http` +- `hyper` +- `opentelemetry` +- `redis` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 31ee69219e..c0e0f8af53 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,8 +51,6 @@ Refer to [the README file](README.md) or run `cargo run --help` for more informa - `crates/apollo-router/src/main.rs`: the entry point for the executable -Some of the functionalities rely on the current Javascript / TypeScript implementation, provided by [apollo federation](https://github.com/apollographql/federation), which is exposed through the [federation router-bridge](https://github.com/apollographql/federation/tree/main/router-bridge). - ## Documentation Documentation for using and contributing to the Apollo Router Core is built using Gatsby diff --git a/Cargo.lock b/Cargo.lock index b8d2d105bd..909c79c32b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "Inflector" @@ -12,54 +12,42 @@ dependencies = [ "regex", ] -[[package]] -name = "access-json" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed51fb0cfa6f88331d4424a7aca87146b315a3b5bd2bbad298ec855718ef9df" -dependencies = [ - "erased-serde", - "serde", - "serde_derive", - "serde_json", -] - [[package]] name = "add-timestamp-header" version = "0.1.0" dependencies = [ "anyhow", "apollo-router", - "http 0.2.12", + "http 1.3.1", "serde_json", "tokio", - "tower", + "tower 0.5.2", ] [[package]] name = "addr2line" -version = "0.22.0" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] [[package]] -name = "adler" -version = "1.0.2" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "const-random", - "getrandom 0.2.15", + "getrandom 0.3.3", "once_cell", "serde", "version_check", @@ -92,9 +80,24 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.18" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] [[package]] name = "anes" @@ -104,9 +107,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.14" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -119,87 +122,118 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.7" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "anstyle-parse" -version = "0.2.4" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.0" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.3" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", - "windows-sys 0.52.0", + "once_cell_polyfill", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.86" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "apollo-compiler" -version = "1.0.0-beta.24" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71153ad85c85f7aa63f0e0a5868912c220bb48e4c764556f5841d37fc17b0103" +checksum = "4369d2ac382b0752cc5ff8cdb020e7a3c74480e7d940fc99f139281f8701fb81" dependencies = [ "ahash", "apollo-parser", "ariadne", - "indexmap 2.2.6", + "futures", + "indexmap 2.11.0", "rowan", "serde", "serde_json_bytes", - "thiserror", + "thiserror 2.0.16", "triomphe", "typed-arena", - "uuid", +] + +[[package]] +name = "apollo-environment-detector" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c628346f10c7615f1dd9e3f486d55bcad9edb667f4444dcbcb9cb5943815583a" +dependencies = [ + "libc", + "serde", + "wmi", ] [[package]] name = "apollo-federation" -version = "1.57.1" +version = "2.6.0" dependencies = [ "apollo-compiler", + "apollo-federation", + "countmap", "derive_more", + "dhat", + "diff", "either", - "hashbrown 0.15.0", + "encoding_rs", + "form_urlencoded", + "hashbrown 0.15.5", "hex", - "indexmap 2.2.6", + "http 1.3.1", + "indexmap 2.11.0", "insta", - "itertools 0.13.0", - "lazy_static", - "multimap 0.10.0", - "nom", - "petgraph", + "itertools 0.14.0", + "levenshtein", + "line-col", + "mime", + "multi_try", + "multimap 0.10.1", + "nom 7.1.3", + "nom_locate", + "parking_lot", + "percent-encoding", + "petgraph 0.8.2", + "pretty_assertions", + "regex", "ron", + "rstest", "serde", + "serde_json", "serde_json_bytes", "sha1", - "strum 0.26.3", - "strum_macros 0.26.4", + "shape", + "similar", + "strum 0.27.2", + "strum_macros 0.27.2", "tempfile", - "thiserror", + "thiserror 2.0.16", "time", "tracing", "url", @@ -209,66 +243,70 @@ dependencies = [ name = "apollo-federation-cli" version = "0.1.0" dependencies = [ + "anyhow", "apollo-compiler", "apollo-federation", "clap", "insta", "serde", "serde_json", + "tracing-subscriber", ] [[package]] name = "apollo-parser" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b64257011a999f2e22275cf7a118f651e58dc9170e11b775d435de768fad0387" +checksum = "c8f05cbc7da3c2e3bb2f86e985aad5f72571d2e2cd26faf8caa7782131576f84" dependencies = [ "memchr", "rowan", - "thiserror", + "thiserror 1.0.69", ] [[package]] name = "apollo-router" -version = "1.57.1" +version = "2.6.0" dependencies = [ - "access-json", "ahash", "anyhow", "apollo-compiler", + "apollo-environment-detector", "apollo-federation", - "arc-swap", - "async-channel 1.9.0", "async-compression", "async-trait", "aws-config", "aws-credential-types", - "aws-sdk-sso", - "aws-sdk-ssooidc", - "aws-sdk-sts", "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http-client", "aws-smithy-runtime-api", "aws-types", - "axum", + "axum 0.8.4", + "axum-extra", + "axum-server", "base64 0.22.1", - "basic-toml", "bloomfilter", - "brotli 3.5.0", + "brotli", "buildstructor", "bytes", "bytesize", + "chrono", "ci_info", "clap", - "console", + "console 0.16.0", "cookie", "crossbeam-channel", + "ctor 0.5.0", "dashmap", "derivative", "derive_more", "dhat", "diff", "displaydoc", + "docker_credential", "ecdsa", + "encoding_rs", "flate2", "fred", "futures", @@ -278,26 +316,28 @@ dependencies = [ "hex", "hickory-resolver", "hmac", - "http 0.2.12", - "http-body 0.4.6", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", "http-serde", "humantime", "humantime-serde", "hyper", "hyper-rustls", + "hyper-util", "hyperlocal", - "indexmap 2.2.6", + "indexmap 2.11.0", "insta", - "itertools 0.13.0", + "itertools 0.14.0", "itoa", "jsonpath-rust", "jsonpath_lib", "jsonschema", "jsonwebtoken", - "lazy_static", "libc", "libtest-mimic", "linkme", + "log", "lru", "maplit", "mediatype", @@ -308,78 +348,83 @@ dependencies = [ "multimap 0.9.1", "notify", "nu-ansi-term 0.50.1", - "num-traits", + "num-traits 0.2.19", + "oci-client", "once_cell", - "opentelemetry 0.20.0", + "opentelemetry", "opentelemetry-aws", "opentelemetry-datadog", "opentelemetry-http", - "opentelemetry-jaeger", + "opentelemetry-jaeger-propagator", "opentelemetry-otlp", "opentelemetry-prometheus", - "opentelemetry-proto 0.5.0", + "opentelemetry-proto", "opentelemetry-semantic-conventions", "opentelemetry-stdout", "opentelemetry-zipkin", - "opentelemetry_api", - "opentelemetry_sdk 0.20.0", + "opentelemetry_sdk", "p256", "parking_lot", "paste", "pin-project-lite", + "pretty_assertions", "prometheus", - "prost 0.12.6", - "prost-types 0.12.6", + "prost", + "prost-types", "proteus", - "rand 0.8.5", - "rand_core 0.6.4", + "rand 0.9.2", "regex", "reqwest", "rhai", "rmp", - "router-bridge", - "rowan", "rstack", + "rstest", "rust-embed", "rustls", "rustls-native-certs", "rustls-pemfile", "ryu", "schemars", - "semver 1.0.23", + "scopeguard", + "semver", "serde", "serde_derive_default", "serde_json", "serde_json_bytes", + "serde_regex", "serde_urlencoded", "serde_yaml", - "serial_test", "sha1", "sha2", "shellexpand", "similar", + "sqlx", "static_assertions", - "strum_macros 0.26.4", + "strum 0.27.2", + "strum_macros 0.27.2", "sys-info", + "sysinfo", "tempfile", "test-log", - "thiserror", + "thiserror 2.0.16", + "tikv-jemalloc-ctl", "tikv-jemallocator", "time", "tokio", "tokio-rustls", "tokio-stream", - "tokio-tungstenite", + "tokio-tungstenite 0.27.0", "tokio-util", - "tonic 0.9.2", + "tonic", "tonic-build", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "tower-test", "tracing", "tracing-core", "tracing-futures", + "tracing-mock", "tracing-opentelemetry", "tracing-serde", "tracing-subscriber", @@ -398,7 +443,7 @@ dependencies = [ [[package]] name = "apollo-router-benchmarks" -version = "1.57.1" +version = "2.6.0" dependencies = [ "apollo-parser", "apollo-router", @@ -409,59 +454,29 @@ dependencies = [ "once_cell", "serde_json", "tokio", - "tower", -] - -[[package]] -name = "apollo-router-scaffold" -version = "1.57.1" -dependencies = [ - "anyhow", - "cargo-scaffold", - "clap", - "copy_dir", - "dircmp", - "regex", - "similar", - "str_inflector", - "tempfile", - "toml", -] - -[[package]] -name = "apollo-router-scaffold-test" -version = "0.1.0" -dependencies = [ - "anyhow", - "apollo-router", - "async-trait", - "schemars", - "serde", - "serde_json", - "tokio", - "tower", - "tracing", + "tower 0.5.2", ] [[package]] name = "apollo-smith" -version = "0.14.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89479524886fdbe62b124d3825879778680e0147304d1a6d32164418f8089a2" +checksum = "38c8cfeb15595a4f979c46712c30fb781d95335bab2be276554b923cadb120af" dependencies = [ "apollo-compiler", "apollo-parser", "arbitrary", - "indexmap 2.2.6", + "indexmap 2.11.0", "once_cell", - "thiserror", + "serde_json_bytes", + "thiserror 2.0.16", ] [[package]] name = "arbitrary" -version = "1.3.2" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" dependencies = [ "derive_arbitrary", ] @@ -474,21 +489,15 @@ checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" [[package]] name = "ariadne" -version = "0.4.1" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44055e597c674aef7cb903b2b9f6e4cba1277ed0d2d61dae7cd52d7ffa81f8e2" +checksum = "36f5e3dca4e09a6f340a61a0e9c7b61e030c69fc27bf29d73218f7e5e3b7638f" dependencies = [ "concolor", - "unicode-width", + "unicode-width 0.1.14", "yansi", ] -[[package]] -name = "ascii" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" - [[package]] name = "ascii_utils" version = "0.9.3" @@ -512,13 +521,13 @@ dependencies = [ "anyhow", "apollo-router", "async-trait", - "http 0.2.12", + "http 1.3.1", "schemars", "serde", "serde_json", "serde_json_bytes", "tokio", - "tower", + "tower 0.5.2", ] [[package]] @@ -534,9 +543,9 @@ dependencies = [ [[package]] name = "async-channel" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ "concurrent-queue", "event-listener-strategy", @@ -546,28 +555,31 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.11" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd066d0b4ef8ecb03a55319dc13aa6910616d0f44008a045bb1835af830abff5" +checksum = "ddb939d66e4ae03cee6091612804ba446b12878410cfa17f785f4dd67d4014e8" dependencies = [ - "brotli 6.0.0", + "brotli", "flate2", "futures-core", "memchr", "pin-project-lite", "tokio", + "zstd", + "zstd-safe", ] [[package]] name = "async-executor" -version = "1.13.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7ebdfa2ebdab6b1760375fa7d6f382b9f486eac35fc994625a00e89280bdbb7" +checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa" dependencies = [ "async-task", "concurrent-queue", - "fastrand 2.1.0", - "futures-lite 2.3.0", + "fastrand", + "futures-lite", + "pin-project-lite", "slab", ] @@ -577,57 +589,56 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-executor", - "async-io 2.3.3", - "async-lock 3.4.0", + "async-io", + "async-lock", "blocking", - "futures-lite 2.3.0", + "futures-lite", "once_cell", ] [[package]] name = "async-graphql" -version = "6.0.11" +version = "7.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298a5d587d6e6fdb271bf56af2dc325a80eb291fd0fc979146584b9a05494a8c" +checksum = "036618f842229ba0b89652ffe425f96c7c16a49f7e3cb23b56fca7f61fd74980" dependencies = [ "async-graphql-derive", "async-graphql-parser", "async-graphql-value", "async-stream", "async-trait", - "base64 0.13.1", + "base64 0.22.1", "bytes", "fast_chemail", "fnv", + "futures-timer", "futures-util", - "handlebars 4.5.0", - "http 0.2.12", - "indexmap 2.2.6", + "handlebars", + "http 1.3.1", + "indexmap 2.11.0", "mime", "multer", - "num-traits", - "once_cell", + "num-traits 0.2.19", "pin-project-lite", "regex", "serde", "serde_json", "serde_urlencoded", - "static_assertions", + "static_assertions_next", "tempfile", - "thiserror", + "thiserror 1.0.69", ] [[package]] name = "async-graphql-axum" -version = "6.0.11" +version = "7.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a1c20a2059bffbc95130715b23435a05168c518fba9709c81fa2a38eed990c" +checksum = "8725874ecfbf399e071150b8619c4071d7b2b7a2f117e173dddef53c6bdb6bb1" dependencies = [ "async-graphql", - "async-trait", - "axum", + "axum 0.8.4", "bytes", "futures-util", "serde_json", @@ -639,9 +650,9 @@ dependencies = [ [[package]] name = "async-graphql-derive" -version = "6.0.11" +version = "7.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f329c7eb9b646a72f70c9c4b516c70867d356ec46cb00dcac8ad343fd006b0" +checksum = "fd45deb3dbe5da5cdb8d6a670a7736d735ba65b455328440f236dfb113727a3d" dependencies = [ "Inflector", "async-graphql-parser", @@ -649,16 +660,16 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "strum 0.25.0", - "syn 2.0.76", - "thiserror", + "strum 0.26.3", + "syn 2.0.106", + "thiserror 1.0.69", ] [[package]] name = "async-graphql-parser" -version = "6.0.11" +version = "7.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6139181845757fd6a73fbb8839f3d036d7150b798db0e9bb3c6e83cdd65bd53b" +checksum = "60b7607e59424a35dadbc085b0d513aa54ec28160ee640cf79ec3b634eba66d3" dependencies = [ "async-graphql-value", "pest", @@ -668,126 +679,97 @@ dependencies = [ [[package]] name = "async-graphql-value" -version = "6.0.11" +version = "7.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323a5143f5bdd2030f45e3f2e0c821c9b1d36e79cf382129c64299c50a7f3750" +checksum = "34ecdaff7c9cffa3614a9f9999bf9ee4c3078fe3ce4d6a6e161736b56febf2de" dependencies = [ "bytes", - "indexmap 2.2.6", + "indexmap 2.11.0", "serde", "serde_json", ] [[package]] name = "async-io" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" -dependencies = [ - "async-lock 2.8.0", - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-lite 1.13.0", - "log", - "parking", - "polling 2.8.0", - "rustix 0.37.27", - "slab", - "socket2 0.4.10", - "waker-fn", -] - -[[package]] -name = "async-io" -version = "2.3.3" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d6baa8f0178795da0e71bc42c9e5d13261aac7ee549853162e66a241ba17964" +checksum = "19634d6336019ef220f09fd31168ce5c184b295cbf80345437cc36094ef223ca" dependencies = [ - "async-lock 3.4.0", + "async-lock", "cfg-if", "concurrent-queue", "futures-io", - "futures-lite 2.3.0", + "futures-lite", "parking", - "polling 3.7.2", - "rustix 0.38.34", + "polling", + "rustix", "slab", - "tracing", - "windows-sys 0.52.0", -] - -[[package]] -name = "async-lock" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" -dependencies = [ - "event-listener 2.5.3", + "windows-sys 0.60.2", ] [[package]] name = "async-lock" -version = "3.4.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" dependencies = [ - "event-listener 5.3.1", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] [[package]] name = "async-process" -version = "1.8.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88" +checksum = "65daa13722ad51e6ab1a1b9c01299142bc75135b337923cfa10e79bbbd669f00" dependencies = [ - "async-io 1.13.0", - "async-lock 2.8.0", + "async-channel 2.5.0", + "async-io", + "async-lock", "async-signal", + "async-task", "blocking", "cfg-if", - "event-listener 3.1.0", - "futures-lite 1.13.0", - "rustix 0.38.34", - "windows-sys 0.48.0", + "event-listener 5.4.1", + "futures-lite", + "rustix", ] [[package]] name = "async-signal" -version = "0.2.9" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfb3634b73397aa844481f814fad23bbf07fdb0eabec10f2eb95e58944b1ec32" +checksum = "f567af260ef69e1d52c2b560ce0ea230763e6fbb9214a85d768760a920e3e3c1" dependencies = [ - "async-io 2.3.3", - "async-lock 3.4.0", + "async-io", + "async-lock", "atomic-waker", "cfg-if", "futures-core", "futures-io", - "rustix 0.38.34", + "rustix", "signal-hook-registry", "slab", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] name = "async-std" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62565bb4402e926b29953c785397c6dc0391b7b446e45008b0049eb43cec6f5d" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" dependencies = [ "async-channel 1.9.0", "async-global-executor", - "async-io 1.13.0", - "async-lock 2.8.0", + "async-io", + "async-lock", "async-process", "crossbeam-utils", "futures-channel", "futures-core", "futures-io", - "futures-lite 1.13.0", + "futures-lite", "gloo-timers", "kv-log-macro", "log", @@ -801,9 +783,9 @@ dependencies = [ [[package]] name = "async-stream" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ "async-stream-impl", "futures-core", @@ -812,13 +794,13 @@ dependencies = [ [[package]] name = "async-stream-impl" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] @@ -829,48 +811,44 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.81" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] -name = "atomic-waker" -version = "1.1.2" +name = "atoi" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits 0.2.19", +] [[package]] -name = "auth-git2" -version = "0.5.4" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51bd0e4592409df8631ca807716dc1e5caafae5d01ce0157c966c71c7e49c3c" -dependencies = [ - "dirs", - "git2", - "terminal-prompt", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-config" -version = "1.5.4" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf6cfe2881cb1fcbba9ae946fb9a6480d3b7a714ca84c74925014a89ef3387a" +checksum = "c478f5b10ce55c9a33f87ca3404ca92768b144fc1bfdede7c0121214a8283a25" dependencies = [ "aws-credential-types", "aws-runtime", - "aws-sdk-sso", - "aws-sdk-ssooidc", "aws-sdk-sts", "aws-smithy-async", "aws-smithy-http", @@ -880,23 +858,19 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.1.0", - "hex", - "http 0.2.12", - "hyper", - "ring", + "fastrand", + "http 1.3.1", "time", "tokio", "tracing", "url", - "zeroize", ] [[package]] name = "aws-credential-types" -version = "1.2.0" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16838e6c9e12125face1c1eff1343c75e3ff540de98ff7ebd61874a89bcfeb9" +checksum = "1541072f81945fa1251f8795ef6c92c4282d74d59f88498ae7d4bf00f0ebdad9" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -906,77 +880,33 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.4.0" +version = "1.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f42c2d4218de4dcd890a109461e2f799a1a2ba3bcd2cde9af88360f5df9266c6" +checksum = "c034a1bc1d70e16e7f4e4caf7e9f7693e4c9c24cd91cf17c2a0b21abaebc7c8b" dependencies = [ "aws-credential-types", "aws-sigv4", "aws-smithy-async", "aws-smithy-http", + "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.1.0", + "fastrand", "http 0.2.12", "http-body 0.4.6", - "once_cell", "percent-encoding", "pin-project-lite", "tracing", "uuid", ] -[[package]] -name = "aws-sdk-sso" -version = "1.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11822090cf501c316c6f75711d77b96fba30658e3867a7762e5e2f5d32d31e81" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "http 0.2.12", - "once_cell", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sdk-ssooidc" -version = "1.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78a2a06ff89176123945d1bbe865603c4d7101bea216a550bb4d2e4e9ba74d74" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "http 0.2.12", - "once_cell", - "regex-lite", - "tracing", -] - [[package]] name = "aws-sdk-sts" -version = "1.39.0" +version = "1.83.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a20a91795850826a6f456f4a48eff1dfa59a0e69bdbf5b8c50518fd372106574" +checksum = "a5468593c47efc31fdbe6c902d1a5fde8d9c82f78a3f8ccfe907b1e9434748cb" dependencies = [ "aws-credential-types", "aws-runtime", @@ -989,17 +919,17 @@ dependencies = [ "aws-smithy-types", "aws-smithy-xml", "aws-types", + "fastrand", "http 0.2.12", - "once_cell", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.2.3" +version = "1.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df1b0fa6be58efe9d4ccc257df0a53b89cd8909e86591a13ca54817c87517be" +checksum = "084c34162187d39e3740cb635acd73c4e3a551a36146ad6fe8883c929c9f876c" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -1010,8 +940,7 @@ dependencies = [ "hex", "hmac", "http 0.2.12", - "http 1.1.0", - "once_cell", + "http 1.3.1", "percent-encoding", "sha2", "time", @@ -1020,9 +949,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.1" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62220bc6e97f946ddd51b5f1361f78996e704677afc518a4ff66b7a72ea1378c" +checksum = "1e190749ea56f8c42bf15dd76c65e14f8f765233e6df9b0506d9d934ebef867c" dependencies = [ "futures-util", "pin-project-lite", @@ -1031,9 +960,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.60.9" +version = "0.62.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9cd0ae3d97daa0a2bf377a4d8e8e1362cae590c4a1aad0d40058ebca18eb91e" +checksum = "7c4dacf2d38996cf729f55e7a762b30918229917eca115de45dfa8dfb97796c9" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -1041,23 +970,55 @@ dependencies = [ "bytes-utils", "futures-core", "http 0.2.12", + "http 1.3.1", "http-body 0.4.6", - "once_cell", "percent-encoding", "pin-project-lite", "pin-utils", "tracing", ] +[[package]] +name = "aws-smithy-http-client" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f108f1ca850f3feef3009bdcc977be201bca9a91058864d9de0684e64514bee0" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.3.1", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tower 0.5.2", + "tracing", +] + [[package]] name = "aws-smithy-json" -version = "0.60.7" +version = "0.61.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4683df9469ef09468dad3473d129960119a0d3593617542b7d52086c8486f2d6" +checksum = "a16e040799d29c17412943bdbf488fd75db04112d0c0d4b9290bacf5ae0014b9" dependencies = [ "aws-smithy-types", ] +[[package]] +name = "aws-smithy-observability" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9364d5989ac4dd918e5cc4c4bdcc61c9be17dcd2586ea7f69e348fc7c6cab393" +dependencies = [ + "aws-smithy-runtime-api", +] + [[package]] name = "aws-smithy-query" version = "0.60.7" @@ -1070,42 +1031,38 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.6.3" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0abbf454960d0db2ad12684a1640120e7557294b0ff8e2f11236290a1b293225" +checksum = "a3d57c8b53a72d15c8e190475743acf34e4996685e346a3448dd54ef696fc6e0" dependencies = [ "aws-smithy-async", "aws-smithy-http", + "aws-smithy-observability", "aws-smithy-runtime-api", "aws-smithy-types", "bytes", - "fastrand 2.1.0", - "h2", + "fastrand", "http 0.2.12", + "http 1.3.1", "http-body 0.4.6", "http-body 1.0.1", - "httparse", - "hyper", - "hyper-rustls", - "once_cell", "pin-project-lite", "pin-utils", - "rustls", "tokio", "tracing", ] [[package]] name = "aws-smithy-runtime-api" -version = "1.7.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e086682a53d3aa241192aa110fa8dfce98f2f5ac2ead0de84d41582c7e8fdb96" +checksum = "07f5e0fc8a6b3f2303f331b94504bbf754d85488f402d6f1dd7a6080f99afe56" dependencies = [ "aws-smithy-async", "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.1.0", + "http 1.3.1", "pin-project-lite", "tokio", "tracing", @@ -1114,16 +1071,15 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.2.4" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273dcdfd762fae3e1650b8024624e7cd50e484e37abdab73a7a706188ad34543" +checksum = "d498595448e43de7f4296b7b7a18a8a02c61ec9349128c80a368f7c3b4ab11a8" dependencies = [ "base64-simd", "bytes", "bytes-utils", - "futures-core", "http 0.2.12", - "http 1.1.0", + "http 1.3.1", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -1134,51 +1090,76 @@ dependencies = [ "ryu", "serde", "time", - "tokio", - "tokio-util", ] [[package]] name = "aws-smithy-xml" -version = "0.60.8" +version = "0.60.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d123fbc2a4adc3c301652ba8e149bf4bc1d1725affb9784eb20c953ace06bf55" +checksum = "3db87b96cb1b16c024980f133968d52882ca0daaee3a086c6decc500f6c99728" dependencies = [ "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.3" +version = "1.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5221b91b3e441e6675310829fd8984801b772cb1546ef6c0e54dec9f1ac13fef" +checksum = "b069d19bf01e46298eaedd7c6f283fe565a59263e53eebec945f3e6398f42390" dependencies = [ "aws-credential-types", "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "rustc_version 0.4.0", + "rustc_version", "tracing", ] [[package]] name = "axum" -version = "0.6.20" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", - "axum-core", - "base64 0.21.7", - "bitflags 1.3.2", + "axum-core 0.4.5", "bytes", "futures-util", - "headers", - "http 0.2.12", - "http-body 0.4.6", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +dependencies = [ + "axum-core 0.5.2", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", "hyper", + "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -1191,42 +1172,105 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", - "tower", + "tokio-tungstenite 0.26.2", + "tower 0.5.2", "tower-layer", "tower-service", + "tracing", ] [[package]] name = "axum-core" -version = "0.3.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ "async-trait", "bytes", "futures-util", - "http 0.2.12", - "http-body 0.4.6", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" +dependencies = [ + "axum 0.8.4", + "axum-core 0.5.2", + "bytes", + "futures-util", + "headers", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", "mime", + "pin-project-lite", "rustversion", + "serde", + "tower 0.5.2", "tower-layer", "tower-service", ] +[[package]] +name = "axum-server" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495c05f60d6df0093e8fb6e74aa5846a0ad06abaf96d76166283720bf740f8ab" +dependencies = [ + "bytes", + "fs-err", + "http 1.3.1", + "http-body 1.0.1", + "hyper", + "hyper-util", + "tokio", + "tower-service", +] + [[package]] name = "backtrace" -version = "0.3.73" +version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ "addr2line", - "cc", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", + "windows-targets 0.52.6", ] [[package]] @@ -1265,18 +1309,9 @@ dependencies = [ [[package]] name = "base64ct" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" - -[[package]] -name = "basic-toml" -version = "0.1.9" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "823388e228f614e9558c6804262db37960ec8821856535f5c3f59913140558f8" -dependencies = [ - "serde", -] +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bit-set" @@ -1284,7 +1319,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec 0.6.3", + "bit-vec", ] [[package]] @@ -1293,12 +1328,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" -[[package]] -name = "bit-vec" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" - [[package]] name = "bitflags" version = "1.3.2" @@ -1307,9 +1336,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" dependencies = [ "serde", ] @@ -1325,65 +1354,56 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.1" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-task", "futures-io", - "futures-lite 2.3.0", + "futures-lite", "piper", ] [[package]] name = "bloomfilter" -version = "1.0.14" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0bdbcf2078e0ba8a74e1fe0cf36f54054a04485759b61dfd60b174658e9607" +checksum = "1f6d7f06817e48ea4e17532fa61bc4e8b9a101437f0623f69d2ea54284f3a817" dependencies = [ - "bit-vec 0.7.0", - "getrandom 0.2.15", + "getrandom 0.2.16", "siphasher", ] [[package]] -name = "brotli" -version = "3.5.0" +name = "bnf" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" +checksum = "14c09ea5795b3dd735ff47c4b8adf64c46e3ce056fa3c4880b865a352e4c40a2" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor 2.5.1", + "getrandom 0.2.16", + "nom 7.1.3", + "rand 0.8.5", + "serde", + "serde_json", ] [[package]] name = "brotli" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor 4.0.1", -] - -[[package]] -name = "brotli-decompressor" -version = "2.5.1" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", + "brotli-decompressor", ] [[package]] name = "brotli-decompressor" -version = "4.0.1" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1391,9 +1411,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.9.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" dependencies = [ "memchr", "serde", @@ -1401,30 +1421,28 @@ dependencies = [ [[package]] name = "buildstructor" -version = "0.5.4" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3907aac66c65520545ae3cb3c195306e20d5ed5c90bfbb992e061cf12a104d0" +checksum = "caabaaee17b2a78d7aa349a33edc9090c6bb47e6dfb25b0da281df57628bba68" dependencies = [ - "lazy_static", "proc-macro2", "quote", "str_inflector", - "syn 2.0.76", - "thiserror", + "syn 2.0.106", "try_match", ] [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytecount" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "byteorder" @@ -1434,9 +1452,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.6.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" dependencies = [ "serde", ] @@ -1453,45 +1471,72 @@ dependencies = [ [[package]] name = "bytesize" -version = "1.3.0" +version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e368af43e418a04d52505cf3dbc23dda4e3407ae2fa99fd0e4f308ce546acc" +checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" dependencies = [ "serde", ] [[package]] name = "cache-control" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "apollo-router", - "http 0.2.12", + "http 1.3.1", "serde_json", "tokio", - "tower", + "tower 0.5.2", ] [[package]] -name = "cargo-scaffold" -version = "0.14.0" +name = "camino" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad9211604c79bf86afd55f798b3c105607f87bd08a9edbf71b22785b0d53f851" +checksum = "5d07aa9a93b00c76f71bc35d598bed923f6d4f3a9ca5c24b7737ae1a292841c0" dependencies = [ - "anyhow", - "auth-git2", - "clap", - "console", - "dialoguer", - "git2", - "globset", - "handlebars 5.1.2", - "indicatif", - "md5", "serde", - "shell-words", +] + +[[package]] +name = "cargo-platform" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8abf5d501fd757c2d2ee78d0cc40f606e92e3a63544420316565556ed28485e2" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-util-schemas" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dc1a6f7b5651af85774ae5a34b4e8be397d9cf4bc063b7e6dbd99a841837830" +dependencies = [ + "semver", + "serde", + "serde-untagged", + "serde-value", + "thiserror 2.0.16", "toml", - "walkdir", + "unicode-xid", + "url", +] + +[[package]] +name = "cargo_metadata" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c3f56c207c76c07652489840ff98687dcf213de178ac0974660d6fefeaf5ec6" +dependencies = [ + "camino", + "cargo-platform", + "cargo-util-schemas", + "semver", + "serde", + "serde_json", + "thiserror 2.0.16", ] [[package]] @@ -1502,19 +1547,41 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.1.5" +version = "1.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324c74f2155653c90b04f25b2a47a8a631360cb908f92a772695f430c7e31052" +checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f" dependencies = [ "jobserver", "libc", + "shlex", ] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits 0.2.19", + "serde", + "wasm-bindgen", + "windows-link", +] [[package]] name = "ci_info" @@ -1556,9 +1623,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.9" +version = "4.5.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64acc1846d54c1fe936a78dc189c34e28d3f5afc348403f28ecf53660b9b8462" +checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" dependencies = [ "clap_builder", "clap_derive", @@ -1566,9 +1633,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.9" +version = "4.5.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fb8393d67ba2e7bfaf28a23458e4e2b543cc73a99595511eb207fdb8aede942" +checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" dependencies = [ "anstream", "anstyle", @@ -1578,48 +1645,36 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.8" +version = "4.5.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085" +checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "clap_lex" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" - -[[package]] -name = "cmake" -version = "0.1.50" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31c789563b815f77f4250caee12365734369f942439b7defd71e18a48197130" -dependencies = [ - "cc", -] +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "colorchoice" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "combine" -version = "3.8.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ - "ascii", - "byteorder", - "either", + "bytes", "memchr", - "unreachable", ] [[package]] @@ -1653,15 +1708,28 @@ dependencies = [ [[package]] name = "console" -version = "0.15.8" +version = "0.15.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" dependencies = [ "encode_unicode", - "lazy_static", "libc", - "unicode-width", - "windows-sys 0.52.0", + "once_cell", + "unicode-width 0.2.1", + "windows-sys 0.59.0", +] + +[[package]] +name = "console" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.1", + "windows-sys 0.60.2", ] [[package]] @@ -1685,11 +1753,31 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "once_cell", "tiny-keccak", ] +[[package]] +name = "const_format" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126f97965c8ad46d6d9163268ff28432e8f6a1196a55578867832e3049df63dd" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "context-data" version = "0.1.0" @@ -1697,16 +1785,19 @@ dependencies = [ "anyhow", "apollo-router", "async-trait", - "http 0.2.12", - "tower", + "http 1.3.1", + "tower 0.5.2", "tracing", ] [[package]] name = "convert_case" -version = "0.4.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "cookie" @@ -1720,12 +1811,9 @@ dependencies = [ [[package]] name = "cookie-factory" -version = "0.3.3" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" -dependencies = [ - "futures", -] +checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" [[package]] name = "cookies-to-headers" @@ -1733,26 +1821,17 @@ version = "0.1.0" dependencies = [ "anyhow", "apollo-router", - "http 0.2.12", + "http 1.3.1", "serde_json", "tokio", - "tower", -] - -[[package]] -name = "copy_dir" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "543d1dd138ef086e2ff05e3a48cf9da045da2033d16f8538fd76b86cd49b2ca3" -dependencies = [ - "walkdir", + "tower 0.5.2", ] [[package]] name = "core-foundation" -version = "0.9.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -1760,9 +1839,18 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "countmap" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ef2a403c4af585607826502480ab6e453f320c230ef67255eee21f0cc72c0a6" +dependencies = [ + "num-traits 0.1.43", +] [[package]] name = "countme" @@ -1772,13 +1860,28 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpufeatures" -version = "0.2.12" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc16" version = "0.4.0" @@ -1787,18 +1890,18 @@ checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] [[package]] name = "criterion" -version = "0.5.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" dependencies = [ "anes", "cast", @@ -1806,16 +1909,13 @@ dependencies = [ "clap", "criterion-plot", "futures", - "is-terminal", - "itertools 0.10.5", - "num-traits", - "once_cell", + "itertools 0.13.0", + "num-traits 0.2.19", "oorandom", "plotters", "rayon", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "tokio", @@ -1824,28 +1924,34 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" dependencies = [ "cast", - "itertools 0.10.5", + "itertools 0.13.0", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" -version = "0.5.13" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1860,17 +1966,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" @@ -1904,11 +2019,27 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ctor" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + [[package]] name = "darling" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ "darling_core", "darling_macro", @@ -1916,36 +2047,37 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "dashmap" -version = "5.5.3" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" dependencies = [ "cfg-if", + "crossbeam-utils", "hashbrown 0.14.5", "lock_api", "once_cell", @@ -1955,20 +2087,19 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.6.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] name = "deadpool" -version = "0.9.5" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "421fe0f90f2ab22016f32a9881be5134fdd71c65298917084b0c7477cbc3856e" +checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490" dependencies = [ "async-trait", "deadpool-runtime", "num_cpus", - "retain_mut", "tokio", ] @@ -1979,185 +2110,99 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] -name = "debugid" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" -dependencies = [ - "serde", - "uuid", -] - -[[package]] -name = "deno-proc-macro-rules" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c65c2ffdafc1564565200967edc4851c7b55422d3913466688907efd05ea26f" -dependencies = [ - "deno-proc-macro-rules-macros", - "proc-macro2", - "syn 2.0.76", -] - -[[package]] -name = "deno-proc-macro-rules-macros" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3047b312b7451e3190865713a4dd6e1f821aed614ada219766ebc3024a690435" -dependencies = [ - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.76", -] - -[[package]] -name = "deno_console" -version = "0.115.0" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ab05b798826985966deb29fc6773ed29570de2f2147a30c4289c7cdf635214" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "deno_core", + "const-oid", + "pem-rfc7468", + "zeroize", ] [[package]] -name = "deno_core" -version = "0.200.0" +name = "deranged" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8ba264b90ceb6e95b39d82e674d8ecae86ca012f900338ea50d1a077d9d75fd" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" dependencies = [ - "anyhow", - "bytes", - "deno_ops", - "futures", - "indexmap 1.9.3", - "libc", - "log", - "once_cell", - "parking_lot", - "pin-project", + "powerfmt", "serde", - "serde_json", - "serde_v8", - "smallvec", - "sourcemap", - "tokio", - "url", - "v8", ] [[package]] -name = "deno_ops" -version = "0.78.0" +name = "derivative" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd1c83b1fd465ee0156f2917c9af9ca09fe2bf54052a2cae1a8dcbc7b89aefc" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" dependencies = [ - "deno-proc-macro-rules", - "lazy-regex", - "once_cell", - "pmutil", - "proc-macro-crate", "proc-macro2", "quote", - "regex", - "strum 0.25.0", - "strum_macros 0.25.3", "syn 1.0.109", - "syn 2.0.76", - "thiserror", -] - -[[package]] -name = "deno_url" -version = "0.115.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20490fff3b0f8c176a815e26371ff23313ea7f39cd51057701524c5b6fc36f6c" -dependencies = [ - "deno_core", - "serde", - "urlpattern", -] - -[[package]] -name = "deno_web" -version = "0.146.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dc8dda6e1337d4739ae9e94d75521689824d82a7deb154a2972b6eedac64507" -dependencies = [ - "async-trait", - "base64-simd", - "deno_core", - "encoding_rs", - "flate2", - "serde", - "tokio", - "uuid", - "windows-sys 0.48.0", ] [[package]] -name = "deno_webidl" -version = "0.115.0" +name = "derive_arbitrary" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73159d81053ead02e938b46d4bb7224c8e7cf25273ac16a250fb45bb09af7635" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ - "deno_core", + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] -name = "der" -version = "0.7.9" +name = "derive_builder" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", + "derive_builder_macro", ] [[package]] -name = "deranged" -version = "0.3.11" +name = "derive_builder_core" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "powerfmt", - "serde", + "darling", + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] -name = "derivative" -version = "2.2.0" +name = "derive_builder_macro" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "derive_builder_core", + "syn 2.0.106", ] [[package]] -name = "derive_arbitrary" -version = "1.3.2" +name = "derive_more" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.76", + "derive_more-impl", ] [[package]] -name = "derive_more" -version = "0.99.18" +name = "derive_more-impl" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "convert_case", "proc-macro2", "quote", - "rustc_version 0.4.0", - "syn 2.0.76", + "syn 2.0.106", + "unicode-xid", ] [[package]] @@ -2170,7 +2215,7 @@ dependencies = [ "lazy_static", "mintex", "parking_lot", - "rustc-hash", + "rustc-hash 1.1.0", "serde", "serde_json", "thousands", @@ -2182,10 +2227,10 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ - "console", + "console 0.15.11", "shell-words", "tempfile", - "thiserror", + "thiserror 1.0.69", "zeroize", ] @@ -2207,37 +2252,25 @@ dependencies = [ "subtle", ] -[[package]] -name = "dircmp" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3ca7fa3ba397980657070e679f412acddb7a372f1793ff68ef0bbe708680f0f" -dependencies = [ - "regex", - "sha2", - "thiserror", - "walkdir", -] - [[package]] name = "dirs" -version = "5.0.1" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.48.0", + "windows-sys 0.60.2", ] [[package]] @@ -2248,20 +2281,52 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", +] + +[[package]] +name = "docker_credential" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d89dfcba45b4afad7450a99b39e751590463e45c04728cf555d36bb66940de8" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +[[package]] +name = "dtor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "dw" @@ -2286,9 +2351,9 @@ dependencies = [ [[package]] name = "dyn-clone" -version = "1.0.17" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" @@ -2306,9 +2371,12 @@ dependencies = [ [[package]] name = "either" -version = "1.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] [[package]] name = "elliptic-curve" @@ -2332,51 +2400,57 @@ dependencies = [ [[package]] name = "encode_unicode" -version = "0.3.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" -version = "0.8.31" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] [[package]] name = "enum-as-inner" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ffccbb6966c05b32ef8fbac435df276c4ae4d3dc55a8cd0eb9745e6c12f546a" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "env_filter" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" dependencies = [ "log", "regex", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "env_logger" -version = "0.11.5" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" dependencies = [ "anstream", "anstyle", "env_filter", - "humantime", + "jiff", "log", ] @@ -2392,9 +2466,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" @@ -2405,14 +2479,24 @@ dependencies = [ "serde", ] +[[package]] +name = "erased-serde" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +dependencies = [ + "serde", + "typeid", +] + [[package]] name = "errno" -version = "0.3.9" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2422,27 +2506,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6" [[package]] -name = "event-listener" -version = "2.5.3" +name = "etcetera" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] [[package]] name = "event-listener" -version = "3.1.0" +version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93877bcde0eb80ca09131a08d23f0a5c18a620b01db137dba666d18cd9b30c2" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "event-listener" -version = "5.3.1" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -2451,11 +2535,11 @@ dependencies = [ [[package]] name = "event-listener-strategy" -version = "0.5.2" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.3.1", + "event-listener 5.4.1", "pin-project-lite", ] @@ -2465,10 +2549,10 @@ version = "0.1.0" dependencies = [ "async-graphql", "async-graphql-axum", - "axum", + "axum 0.8.4", "env_logger", "tokio", - "tower", + "tower 0.5.2", ] [[package]] @@ -2478,15 +2562,16 @@ dependencies = [ "anyhow", "apollo-router", "async-trait", + "bytes", "futures", - "http 0.2.12", - "hyper", + "http 1.3.1", + "http-body-util", "multimap 0.9.1", "schemars", "serde", "serde_json", "tokio", - "tower", + "tower 0.5.2", "tracing", ] @@ -2511,24 +2596,15 @@ dependencies = [ [[package]] name = "fastrand" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - -[[package]] -name = "fastrand" -version = "2.1.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "ff" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ "rand_core 0.6.4", "subtle", @@ -2536,40 +2612,50 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.23" +version = "0.2.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.4.1", - "windows-sys 0.52.0", + "libredox", + "windows-sys 0.60.2", ] [[package]] name = "fixedbitset" -version = "0.4.2" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.0.30" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", - "libz-ng-sys", "miniz_oxide", ] [[package]] name = "float-cmp" -version = "0.9.0" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "flume" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ - "num-traits", + "futures-core", + "futures-sink", + "spin 0.9.8", ] [[package]] @@ -2580,9 +2666,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "forbid-anonymous-operations" @@ -2591,10 +2677,10 @@ dependencies = [ "anyhow", "apollo-router", "async-trait", - "http 0.2.12", + "http 1.3.1", "serde_json", "tokio", - "tower", + "tower 0.5.2", "tracing", ] @@ -2604,10 +2690,10 @@ version = "0.1.0" dependencies = [ "anyhow", "apollo-router", - "http 0.2.12", + "http 1.3.1", "serde_json", "tokio", - "tower", + "tower 0.5.2", ] [[package]] @@ -2628,7 +2714,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] @@ -2639,9 +2725,9 @@ checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -2658,32 +2744,32 @@ dependencies = [ [[package]] name = "fragile" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" [[package]] name = "fred" -version = "7.1.2" +version = "10.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b99c2b48934cd02a81032dd7428b7ae831a27794275bc94eba367418db8a9e55" +checksum = "3a7b2fd0f08b23315c13b6156f971aeedb6f75fb16a29ac1872d2eabccc1490e" dependencies = [ "arc-swap", "async-trait", "bytes", "bytes-utils", "float-cmp", + "fred-macros", "futures", - "lazy_static", + "glob-match", "log", "parking_lot", "rand 0.8.5", "redis-protocol", "rustls", "rustls-native-certs", - "rustls-webpki", - "semver 1.0.23", - "socket2 0.5.7", + "semver", + "socket2 0.5.10", "tokio", "tokio-rustls", "tokio-stream", @@ -2693,29 +2779,40 @@ dependencies = [ ] [[package]] -name = "fsio" -version = "0.4.0" +name = "fred-macros" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad0ce30be0cc441b325c5d705c8b613a0ca0d92b6a8953d41bd236dc09a36d0" +checksum = "1458c6e22d36d61507034d5afecc64f105c1d39712b7ac6ec3b352c423f715cc" dependencies = [ - "dunce", + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] -name = "fslock" -version = "0.1.8" +name = "fs-err" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57eafdd0c16f57161105ae1b98a1238f97645f2f588438b2949c99a2af9616bf" +checksum = "88d7be93788013f265201256d58f04936a8079ad5dc898743aa20525f503b683" dependencies = [ - "libc", - "winapi", + "autocfg", + "tokio", +] + +[[package]] +name = "fsio" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4944f16eb6a05b4b2b79986b4786867bb275f52882adea798f17cc2588f25b2" +dependencies = [ + "dunce", ] [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -2728,9 +2825,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -2738,15 +2835,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -2755,33 +2852,29 @@ dependencies = [ ] [[package]] -name = "futures-io" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" - -[[package]] -name = "futures-lite" -version = "1.13.0" +name = "futures-intrusive" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ - "fastrand 1.9.0", "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", + "lock_api", + "parking_lot", ] +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-lite" -version = "2.3.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand 2.1.0", + "fastrand", "futures-core", "futures-io", "parking", @@ -2790,32 +2883,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-test" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce388237b32ac42eca0df1ba55ed3bbda4eaf005d7d4b5dbc0b20ab962928ac9" +checksum = "5961fb6311645f46e2cdc2964a8bfae6743fd72315eaec181a71ae3eb2467113" dependencies = [ "futures-core", "futures-executor", @@ -2825,7 +2918,6 @@ dependencies = [ "futures-task", "futures-util", "pin-project", - "pin-utils", ] [[package]] @@ -2836,9 +2928,9 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -2852,6 +2944,20 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows 0.61.3", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2876,73 +2982,90 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", "wasm-bindgen", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "ghost" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0e085ded9f1267c32176b40921b9754c474f7dd96f7e808d4a982e48aa1e854" +checksum = "d1323e4e10ffd5d48a21ea37f8d4e3b15dd841121d1301a86122fa0984bedf0a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "gimli" -version = "0.29.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] -name = "git2" -version = "0.18.3" +name = "glob" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" -dependencies = [ - "bitflags 2.6.0", - "libc", - "libgit2-sys", - "log", - "openssl-probe", - "openssl-sys", - "url", -] +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] -name = "glob" -version = "0.3.1" +name = "glob-match" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" [[package]] name = "globset" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" dependencies = [ "aho-corasick", "bstr", "log", - "regex-automata 0.4.7", - "regex-syntax 0.8.4", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", ] [[package]] name = "gloo-timers" -version = "0.2.6" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" dependencies = [ "futures-channel", "futures-core", @@ -2961,12 +3084,12 @@ dependencies = [ [[package]] name = "graphql-parser" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ebc8013b4426d5b81a4364c419a95ed0b404af2b82e2457de52d9348f0e474" +checksum = "7a818c0d883d7c0801df27be910917750932be279c7bc82dc541b8769425f409" dependencies = [ "combine", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -3021,17 +3144,17 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.26" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ + "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.2.6", + "http 1.3.1", + "indexmap 2.11.0", "slab", "tokio", "tokio-util", @@ -3040,28 +3163,14 @@ dependencies = [ [[package]] name = "half" -version = "2.4.1" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" dependencies = [ "cfg-if", "crunchy", ] -[[package]] -name = "handlebars" -version = "4.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa67bab9ff362228eb3d00bd024a4965d8231bbb7921167f0cfa66c6626b225" -dependencies = [ - "log", - "pest", - "pest_derive", - "serde", - "serde_json", - "thiserror", -] - [[package]] name = "handlebars" version = "5.1.2" @@ -3073,7 +3182,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -3087,22 +3196,27 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" -version = "0.15.0" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "hdrhistogram" version = "7.5.4" @@ -3110,19 +3224,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" dependencies = [ "byteorder", - "num-traits", + "num-traits 0.2.19", ] [[package]] name = "headers" -version = "0.3.9" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "bytes", "headers-core", - "http 0.2.12", + "http 1.3.1", "httpdate", "mime", "sha1", @@ -3130,11 +3244,11 @@ dependencies = [ [[package]] name = "headers-core" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http 0.2.12", + "http 1.3.1", ] [[package]] @@ -3160,21 +3274,15 @@ dependencies = [ "serde", "serde_json", "tokio", - "tower", + "tower 0.5.2", "tracing", ] [[package]] name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hermit-abi" -version = "0.4.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -3187,9 +3295,9 @@ dependencies = [ [[package]] name = "hickory-proto" -version = "0.24.1" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07698b8420e2f0d6447a436ba999ec85d8fbf2a398bbd737b82cac4a2e96e512" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ "async-trait", "cfg-if", @@ -3198,11 +3306,12 @@ dependencies = [ "futures-channel", "futures-io", "futures-util", - "idna 0.4.0", + "idna", "ipnet", "once_cell", - "rand 0.8.5", - "thiserror", + "rand 0.9.2", + "ring", + "thiserror 2.0.16", "tinyvec", "tokio", "tracing", @@ -3211,52 +3320,50 @@ dependencies = [ [[package]] name = "hickory-resolver" -version = "0.24.1" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28757f23aa75c98f254cf0405e6d8c25b831b32921b050a66692427679b1f243" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" dependencies = [ "cfg-if", "futures-util", "hickory-proto", "ipconfig", - "lru-cache", + "moka", "once_cell", "parking_lot", - "rand 0.8.5", + "rand 0.9.2", "resolv-conf", "smallvec", - "thiserror", + "thiserror 2.0.16", "tokio", "tracing", ] [[package]] -name = "hmac" -version = "0.12.1" +name = "hkdf" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "digest", + "hmac", ] [[package]] -name = "home" -version = "0.5.9" +name = "hmac" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "windows-sys 0.52.0", + "digest", ] [[package]] -name = "hostname" -version = "0.3.1" +name = "home" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ - "libc", - "match_cfg", - "winapi", + "windows-sys 0.59.0", ] [[package]] @@ -3272,15 +3379,24 @@ dependencies = [ [[package]] name = "http" -version = "1.1.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", "itoa", ] +[[package]] +name = "http-auth" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "150fa4a9462ef926824cf4519c84ed652ca8f4fbae34cb8af045b5cbcaf98822" +dependencies = [ + "memchr", +] + [[package]] name = "http-body" version = "0.4.6" @@ -3299,64 +3415,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.1.0", + "http 1.3.1", ] [[package]] name = "http-body-util" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", - "futures-util", - "http 1.1.0", + "futures-core", + "http 1.3.1", "http-body 1.0.1", "pin-project-lite", ] [[package]] name = "http-range-header" -version = "0.3.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" [[package]] name = "http-serde" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f560b665ad9f1572cfcaf034f7fb84338a7ce945216d64a90fd81f046a3caee" -dependencies = [ - "http 0.2.12", - "serde", -] - -[[package]] -name = "http-types" -version = "2.12.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" +checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" dependencies = [ - "anyhow", - "async-channel 1.9.0", - "base64 0.13.1", - "futures-lite 1.13.0", - "http 0.2.12", - "infer", - "pin-project-lite", - "rand 0.7.3", + "http 1.3.1", "serde", - "serde_json", - "serde_qs", - "serde_urlencoded", - "url", ] [[package]] name = "httparse" -version = "1.9.4" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -3366,9 +3461,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" [[package]] name = "humantime-serde" @@ -3382,148 +3477,257 @@ dependencies = [ [[package]] name = "hyper" -version = "0.14.31" -source = "git+https://github.com/apollographql/hyper.git?tag=header-customizations-20241108#c42aec785394b40645a283384838b856beace011" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", "futures-core", - "futures-util", "h2", - "http 0.2.12", - "http-body 0.4.6", + "http 1.3.1", + "http-body 1.0.1", "httparse", "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", - "socket2 0.5.7", "tokio", - "tower-service", - "tracing", "want", ] [[package]] name = "hyper-rustls" -version = "0.24.2" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "futures-util", - "http 0.2.12", + "http 1.3.1", "hyper", - "log", + "hyper-util", "rustls", "rustls-native-certs", + "rustls-pki-types", "tokio", "tokio-rustls", + "tower-service", + "webpki-roots 1.0.2", ] [[package]] name = "hyper-timeout" -version = "0.4.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ "hyper", + "hyper-util", "pin-project-lite", "tokio", - "tokio-io-timeout", + "tower-service", ] [[package]] -name = "hyperlocal" -version = "0.8.0" +name = "hyper-util" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fafdf7b2b2de7c9784f76e02c0935e65a8117ec3b768644379983ab333ac98c" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" dependencies = [ + "bytes", + "futures-channel", "futures-util", - "hex", + "http 1.3.1", + "http-body 1.0.1", "hyper", - "pin-project", + "pin-project-lite", + "socket2 0.5.10", "tokio", + "tower-service", + "tracing", ] [[package]] -name = "ident_case" -version = "1.0.1" +name = "hyperlocal" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] [[package]] -name = "idna" -version = "0.4.0" +name = "iana-time-zone" +version = "0.1.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.61.2", ] [[package]] -name = "idna" -version = "0.5.0" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "cc", ] [[package]] -name = "if_chain" -version = "1.0.2" +name = "icu_collections" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" - -[[package]] -name = "indexmap" -version = "1.9.3" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" dependencies = [ - "autocfg", - "hashbrown 0.12.3", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "indexmap" -version = "2.2.6" +name = "icu_normalizer" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" dependencies = [ - "equivalent", - "hashbrown 0.14.5", - "serde", + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "indicatif" -version = "0.17.8" +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" dependencies = [ - "console", - "instant", - "number_prefix", - "portable-atomic", - "unicode-width", + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", ] [[package]] -name = "infer" -version = "0.2.3" +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", + "serde", +] [[package]] name = "inotify" -version = "0.9.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.9.2", "inotify-sys", "libc", ] @@ -3539,17 +3743,18 @@ dependencies = [ [[package]] name = "insta" -version = "1.39.0" +version = "1.43.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "810ae6042d48e2c9e9215043563a58a80b877bc863228a74cf10c49d4620a6f5" +checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371" dependencies = [ - "console", - "lazy_static", - "linked-hash-map", + "console 0.15.11", + "globset", + "once_cell", "pest", "pest_derive", "serde", "similar", + "walkdir", ] [[package]] @@ -3561,31 +3766,25 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "integer-encoding" -version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" - [[package]] name = "inventory" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84344c6e0b90a9e2b6f3f9abe5cc74402684e348df7b32adca28747e0cef091a" dependencies = [ - "ctor", + "ctor 0.1.26", "ghost", ] [[package]] -name = "io-lifetimes" -version = "1.0.11" +name = "io-uring" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ - "hermit-abi 0.3.9", + "bitflags 2.9.2", + "cfg-if", "libc", - "windows-sys 0.48.0", ] [[package]] @@ -3594,7 +3793,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" dependencies = [ - "socket2 0.5.7", + "socket2 0.5.10", "widestring", "windows-sys 0.48.0", "winreg", @@ -3602,84 +3801,120 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.9.0" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] [[package]] name = "is-terminal" -version = "0.4.12" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ - "hermit-abi 0.3.9", + "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "is_terminal_polyfill" -version = "1.70.0" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "iso8601" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924e5d73ea28f59011fec52a0d12185d496a9b075d360657aed2a5707f701153" +checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" dependencies = [ - "nom", + "nom 8.0.0", ] [[package]] name = "itertools" -version = "0.10.5" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" dependencies = [ "either", ] [[package]] name = "itertools" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] [[package]] name = "jobserver" -version = "0.1.31" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e" +checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" dependencies = [ + "getrandom 0.3.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -3693,7 +3928,7 @@ dependencies = [ "pest_derive", "regex", "serde_json", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -3719,7 +3954,7 @@ dependencies = [ "bytecount", "fancy-regex", "fraction", - "getrandom 0.2.15", + "getrandom 0.2.16", "iso8601", "itoa", "memchr", @@ -3737,11 +3972,11 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "9.3.0" +version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "js-sys", "pem", "ring", @@ -3750,23 +3985,38 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "jwt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f" +dependencies = [ + "base64 0.13.1", + "crypto-common", + "digest", + "hmac", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "jwt-claims" version = "0.1.0" dependencies = [ "anyhow", "apollo-router", - "http 0.2.12", + "http 1.3.1", "serde_json", "tokio", - "tower", + "tower 0.5.2", ] [[package]] name = "kqueue" -version = "1.0.8" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" dependencies = [ "kqueue-sys", "libc", @@ -3792,122 +4042,80 @@ dependencies = [ ] [[package]] -name = "lazy-regex" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff63c423c68ea6814b7da9e88ce585f793c87ddd9e78f646970891769c8235d4" -dependencies = [ - "lazy-regex-proc_macros", - "once_cell", - "regex", -] - -[[package]] -name = "lazy-regex-proc_macros" -version = "2.4.1" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8edfc11b8f56ce85e207e62ea21557cfa09bb24a8f6b04ae181b086ff8611c22" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "proc-macro2", - "quote", - "regex", - "syn 1.0.109", + "spin 0.9.8", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "levenshtein" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libfuzzer-sys" -version = "0.4.7" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96cfd5557eb82f2b83fed4955246c988d331975a002961b07c81584d107e7f7" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" dependencies = [ "arbitrary", "cc", - "once_cell", ] [[package]] -name = "libgit2-sys" -version = "0.16.2+1.7.2" +name = "libm" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8" -dependencies = [ - "cc", - "libc", - "libssh2-sys", - "libz-sys", - "openssl-sys", - "pkg-config", -] +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.2", "libc", + "redox_syscall", ] [[package]] -name = "libssh2-sys" -version = "0.3.0" +name = "libsqlite3-sys" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", "pkg-config", "vcpkg", ] [[package]] name = "libtest-mimic" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc0bda45ed5b3a2904262c1bb91e526127aa70e7ef3758aba2ef93cf896b9b58" +checksum = "5297962ef19edda4ce33aaa484386e0a5b3d7f2f4e037cbeee00503ef6b29d33" dependencies = [ + "anstream", + "anstyle", "clap", "escape8259", - "termcolor", - "threadpool", -] - -[[package]] -name = "libz-ng-sys" -version = "1.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6409efc61b12687963e602df8ecf70e8ddacf95bc6576bcf16e3ac6328083c5" -dependencies = [ - "cmake", - "libc", ] [[package]] -name = "libz-sys" -version = "1.1.18" +name = "line-col" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c15da26e5af7e25c90b37a2d75cdbf940cf4a55316de9d84c679c9b8bfabf82e" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] +checksum = "9e69cdf6b85b5c8dce514f694089a2cf8b1a702f6cd28607bcb3cf296c9778db" [[package]] name = "linked-hash-map" @@ -3917,41 +4125,41 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linkme" -version = "0.3.27" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccb76662d78edc9f9bf56360d6919bdacc8b7761227727e5082f128eeb90bbf5" +checksum = "a1b1703c00b2a6a70738920544aa51652532cacddfec2e162d2e29eae01e665c" dependencies = [ "linkme-impl", ] [[package]] name = "linkme-impl" -version = "0.3.27" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dccda732e04fa3baf2e17cf835bfe2601c7c2edafd64417c627dabae3a8cda" +checksum = "04d55ca5d5a14363da83bf3c33874b8feaa34653e760d5216d7ef9829c88001a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "linux-raw-sys" -version = "0.3.8" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] -name = "linux-raw-sys" -version = "0.4.14" +name = "litemap" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ "autocfg", "scopeguard", @@ -3960,42 +4168,46 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" dependencies = [ "value-bag", ] [[package]] -name = "lru" -version = "0.12.3" +name = "loom" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" dependencies = [ - "hashbrown 0.14.5", + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", ] [[package]] -name = "lru-cache" -version = "0.1.2" +name = "lru" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +checksum = "86ea4e65087ff52f3862caff188d489f1fab49a0cb09e01b2e3f1a617b10aaed" dependencies = [ - "linked-hash-map", + "hashbrown 0.15.5", ] [[package]] -name = "maplit" -version = "1.0.2" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] -name = "match_cfg" -version = "0.1.0" +name = "maplit" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "matchers" @@ -4013,30 +4225,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] -name = "md5" -version = "0.7.0" +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] [[package]] name = "mediatype" -version = "0.19.18" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8878cd8d1b3c8c8ae4b2ba0a36652b7cf192f618a599a7fbdfa25cffd4ea72dd" +checksum = "f490ea2ae935dd8ac89c472d4df28c7f6b87cc20767e1b21fd5ed6a16e7f61e4" [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] -name = "memoffset" -version = "0.9.1" +name = "memorable-wordlist" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +checksum = "673c6a442e72f0bca457afb369a8130596eeeb51c80a38b1dd39b6c490ed36c1" dependencies = [ - "autocfg", + "rand 0.7.3", ] [[package]] @@ -4073,36 +4295,36 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.7.4" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ - "adler", + "adler2", ] [[package]] name = "mintex" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bec4598fddb13cc7b528819e697852653252b760f1228b7642679bf2ff2cd07" +checksum = "c505b3e17ed6b70a7ed2e67fbb2c560ee327353556120d6e72f5232b6880d536" [[package]] name = "mio" -version = "0.8.11" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", ] [[package]] name = "mockall" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c28b3fb6d753d28c20e826cd46ee611fda1cf3cde03a443a974043247c065a" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" dependencies = [ "cfg-if", "downcast", @@ -4114,39 +4336,59 @@ dependencies = [ [[package]] name = "mockall_derive" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "341014e7f530314e9a1fdbc7400b244efea7122662c96bfa248c31da5bfb2020" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", +] + +[[package]] +name = "moka" +version = "0.12.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "loom", + "parking_lot", + "portable-atomic", + "rustc_version", + "smallvec", + "tagptr", + "thiserror 1.0.69", + "uuid", ] [[package]] name = "multer" -version = "2.1.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01acbdc23469fd8fe07ab135923371d5f5a422fbf9c522158677c8eb15bc51c2" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 0.2.12", + "http 1.3.1", "httparse", - "log", "memchr", "mime", - "spin", - "version_check", + "serde", + "serde_json", + "spin 0.9.8", + "version_check", ] [[package]] -name = "multimap" -version = "0.8.3" +name = "multi_try" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" +checksum = "b42256e8ab5f19108cf42e2762786052ae4660635f6fe76134d2cab37068ee8a" [[package]] name = "multimap" @@ -4159,13 +4401,22 @@ dependencies = [ [[package]] name = "multimap" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" dependencies = [ "serde", ] +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +dependencies = [ + "spin 0.5.2", +] + [[package]] name = "nom" version = "7.1.3" @@ -4176,21 +4427,56 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +dependencies = [ + "bytecount", + "memchr", + "nom 7.1.3", +] + [[package]] name = "notify" -version = "6.1.1" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.6.0", - "filetime", + "bitflags 2.9.2", "inotify", "kqueue", "libc", "log", "mio", + "notify-types", "walkdir", - "windows-sys 0.48.0", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", ] [[package]] @@ -4223,7 +4509,7 @@ dependencies = [ "num-integer", "num-iter", "num-rational", - "num-traits", + "num-traits 0.2.19", ] [[package]] @@ -4233,8 +4519,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", - "num-traits", + "num-traits 0.2.19", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits 0.2.19", "rand 0.8.5", + "smallvec", + "zeroize", ] [[package]] @@ -4249,7 +4551,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ - "num-traits", + "num-traits 0.2.19", ] [[package]] @@ -4264,7 +4566,7 @@ version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "num-traits", + "num-traits 0.2.19", ] [[package]] @@ -4275,7 +4577,7 @@ checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ "autocfg", "num-integer", - "num-traits", + "num-traits 0.2.19", ] [[package]] @@ -4286,7 +4588,16 @@ checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ "num-bigint", "num-integer", - "num-traits", + "num-traits 0.2.19", +] + +[[package]] +name = "num-traits" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e5113e9fd4cc14ded8e499429f396a20f98c772a47cc8622a736e1ec843c31" +dependencies = [ + "num-traits 0.2.19", ] [[package]] @@ -4296,15 +4607,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] name = "num_cpus" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi 0.3.9", + "hermit-abi", "libc", ] @@ -4318,322 +4630,311 @@ dependencies = [ ] [[package]] -name = "number_prefix" -version = "0.4.0" +name = "objc2-core-foundation" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.9.2", +] [[package]] -name = "object" -version = "0.36.1" +name = "objc2-io-kit" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "081b846d1d56ddfc18fdf1a922e4f6e07a11768ea1b92dec44e42b72712ccfce" +checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" dependencies = [ - "memchr", + "libc", + "objc2-core-foundation", ] [[package]] -name = "once_cell" -version = "1.19.0" +name = "object" +version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] [[package]] -name = "oorandom" -version = "11.1.4" +name = "oci-client" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" - -[[package]] -name = "op-name-to-header" -version = "0.1.0" +checksum = "9b74df13319e08bc386d333d3dc289c774c88cc543cae31f5347db07b5ec2172" dependencies = [ - "anyhow", - "apollo-router", - "http 0.2.12", + "bytes", + "chrono", + "futures-util", + "http 1.3.1", + "http-auth", + "jwt", + "lazy_static", + "oci-spec", + "olpc-cjson", + "regex", + "reqwest", + "serde", "serde_json", + "sha2", + "thiserror 2.0.16", "tokio", - "tower", + "tracing", + "unicase", ] [[package]] -name = "openssl-probe" -version = "0.1.5" +name = "oci-spec" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +checksum = "2078e2f6be932a4de9aca90a375a45590809dfb5a08d93ab1ee217107aceeb67" +dependencies = [ + "const_format", + "derive_builder", + "getset", + "regex", + "serde", + "serde_json", + "strum 0.27.2", + "strum_macros 0.27.2", + "thiserror 2.0.16", +] [[package]] -name = "openssl-src" -version = "300.3.1+3.3.1" +name = "olpc-cjson" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7259953d42a81bf137fbbd73bd30a8e1914d6dce43c2b90ed575783a22608b91" +checksum = "696183c9b5fe81a7715d074fd632e8bd46f4ccc0231a3ed7fc580a80de5f7083" dependencies = [ - "cc", + "serde", + "serde_json", + "unicode-normalization", ] [[package]] -name = "openssl-sys" -version = "0.9.102" +name = "once_cell" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", + "critical-section", + "portable-atomic", ] [[package]] -name = "opentelemetry" -version = "0.20.0" +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "oorandom" +version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9591d937bc0e6d2feb6f71a559540ab300ea49955229c347a517a28d27784c54" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "op-name-to-header" +version = "0.1.0" dependencies = [ - "opentelemetry_api", - "opentelemetry_sdk 0.20.0", + "anyhow", + "apollo-router", + "http 1.3.1", + "serde_json", + "tokio", + "tower 0.5.2", ] +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "opentelemetry" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900d57987be3f2aeb70d385fff9b27fb74c5723cc9a52d904d4f9c807a0667bf" +checksum = "4c365a63eec4f55b7efeceb724f1336f26a9cf3427b70e59e2cd2a5b947fba96" dependencies = [ "futures-core", "futures-sink", "js-sys", "once_cell", "pin-project-lite", - "thiserror", - "urlencoding", + "thiserror 1.0.69", ] [[package]] name = "opentelemetry-aws" -version = "0.8.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31120a0109c172a42096766ef10e772f4a89422932be2c3b7f335858ff49380d" +checksum = "4f2e5bd1a2e1d14877086a2defe4ac968f42a6a15cfc5862a0f0ecd0f3530135" dependencies = [ "once_cell", - "opentelemetry_api", + "opentelemetry", + "opentelemetry_sdk", ] [[package]] name = "opentelemetry-datadog" -version = "0.8.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f4ecf595095d3b641dd2761a0c3d1f175d3d6c28f38e65418d8004ea3255dd" +checksum = "e55061f0b4acd624ce67434c4a6d6d1b5c341d62564bf80094bdaef884f1bf5b" dependencies = [ + "ahash", "futures-core", - "http 0.2.12", - "indexmap 1.9.3", - "itertools 0.10.5", + "http 1.3.1", + "indexmap 2.11.0", + "itertools 0.11.0", + "itoa", "once_cell", - "opentelemetry 0.20.0", + "opentelemetry", "opentelemetry-http", "opentelemetry-semantic-conventions", + "opentelemetry_sdk", "reqwest", "rmp", - "thiserror", + "ryu", + "thiserror 1.0.69", "url", ] [[package]] name = "opentelemetry-http" -version = "0.9.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7594ec0e11d8e33faf03530a4c49af7064ebba81c1480e01be67d90b356508b" +checksum = "ad31e9de44ee3538fb9d64fe3376c1362f406162434609e79aea2a41a0af78ab" dependencies = [ "async-trait", "bytes", - "http 0.2.12", - "opentelemetry_api", + "http 1.3.1", + "opentelemetry", "reqwest", ] [[package]] -name = "opentelemetry-jaeger" -version = "0.19.0" +name = "opentelemetry-jaeger-propagator" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876958ba9084f390f913fcf04ddf7bbbb822898867bb0a51cc28f2b9e5c1b515" +checksum = "fc0a68a13b92fc708d875ad659b08b35d08b8ef2403e01944b39ca21e5b08b17" dependencies = [ - "async-trait", - "futures-core", - "futures-util", - "headers", - "http 0.2.12", - "opentelemetry 0.20.0", - "opentelemetry-http", - "opentelemetry-semantic-conventions", - "reqwest", - "thrift", - "tokio", + "opentelemetry", ] [[package]] name = "opentelemetry-otlp" -version = "0.13.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e5e5a5c4135864099f3faafbe939eb4d7f9b80ebf68a8448da961b32a7c1275" +checksum = "6b925a602ffb916fb7421276b86756027b37ee708f9dce2dbdcc51739f07e727" dependencies = [ "async-trait", "futures-core", - "http 0.2.12", + "http 1.3.1", + "opentelemetry", "opentelemetry-http", - "opentelemetry-proto 0.3.0", - "opentelemetry-semantic-conventions", - "opentelemetry_api", - "opentelemetry_sdk 0.20.0", - "prost 0.11.9", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", "reqwest", - "thiserror", + "thiserror 1.0.69", "tokio", - "tonic 0.9.2", + "tonic", ] [[package]] name = "opentelemetry-prometheus" -version = "0.13.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d81bc254e2d572120363a2b16cdb0d715d301b5789be0cfc26ad87e4e10e53" +checksum = "cc4191ce34aa274621861a7a9d68dbcf618d5b6c66b10081631b61fd81fbc015" dependencies = [ "once_cell", - "opentelemetry_api", - "opentelemetry_sdk 0.20.0", + "opentelemetry", + "opentelemetry_sdk", "prometheus", "protobuf", ] [[package]] name = "opentelemetry-proto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e3f814aa9f8c905d0ee4bde026afd3b2577a97c10e1699912e3e44f0c4cbeb" -dependencies = [ - "opentelemetry_api", - "opentelemetry_sdk 0.20.0", - "prost 0.11.9", - "tonic 0.9.2", -] - -[[package]] -name = "opentelemetry-proto" -version = "0.5.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a8fddc9b68f5b80dae9d6f510b88e02396f006ad48cac349411fbecc80caae4" +checksum = "30ee9f20bff9c984511a02f082dc8ede839e4a9bf15cc2487c8d6fea5ad850d9" dependencies = [ "hex", - "opentelemetry 0.22.0", - "opentelemetry_sdk 0.22.1", - "prost 0.12.6", + "opentelemetry", + "opentelemetry_sdk", + "prost", "serde", - "tonic 0.11.0", + "tonic", ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.12.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73c9f9340ad135068800e7f1b24e9e09ed9e7143f5bf8518ded3d3ec69789269" -dependencies = [ - "opentelemetry 0.20.0", -] +checksum = "1cefe0543875379e47eb5f1e68ff83f45cc41366a92dfd0d073d513bf68e9a05" [[package]] name = "opentelemetry-stdout" -version = "0.1.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bd550321bc0f9d3f6dcbfe5c75262789de5b3e2776da2cbcfd2392aa05db0c6" +checksum = "d408d4345b8be6129a77c46c3bfc75f0d3476f3091909c7dd99c1f3d78582287" dependencies = [ + "async-trait", + "chrono", "futures-util", - "opentelemetry_api", - "opentelemetry_sdk 0.20.0", - "ordered-float 3.9.2", + "opentelemetry", + "opentelemetry_sdk", + "ordered-float 4.6.0", "serde", "serde_json", + "thiserror 1.0.69", ] [[package]] name = "opentelemetry-zipkin" -version = "0.18.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb966f01235207a6933c0aec98374fe9782df1c1d2b3d1db35c458451d138143" +checksum = "e68336254a44c5c20574989699582175910b933be85a593a13031ee58811d93d" dependencies = [ "async-trait", "futures-core", - "http 0.2.12", + "http 1.3.1", "once_cell", - "opentelemetry 0.20.0", + "opentelemetry", "opentelemetry-http", "opentelemetry-semantic-conventions", + "opentelemetry_sdk", "reqwest", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "typed-builder", ] -[[package]] -name = "opentelemetry_api" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a81f725323db1b1206ca3da8bb19874bbd3f57c3bcd59471bfb04525b265b9b" -dependencies = [ - "futures-channel", - "futures-util", - "indexmap 1.9.3", - "js-sys", - "once_cell", - "pin-project-lite", - "thiserror", - "urlencoding", -] - [[package]] name = "opentelemetry_sdk" -version = "0.20.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa8e705a0612d48139799fcbaba0d4a90f06277153e43dd2bdc16c6f0edd8026" +checksum = "692eac490ec80f24a17828d49b40b60f5aeaccdfe6a503f939713afd22bc28df" dependencies = [ "async-std", "async-trait", - "crossbeam-channel", "futures-channel", "futures-executor", "futures-util", + "glob", "once_cell", - "opentelemetry_api", - "ordered-float 3.9.2", + "opentelemetry", "percent-encoding", "rand 0.8.5", - "regex", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-stream", ] -[[package]] -name = "opentelemetry_sdk" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e90c7113be649e31e9a0f8b5ee24ed7a16923b322c3c5ab6367469c049d6b7e" -dependencies = [ - "async-trait", - "crossbeam-channel", - "futures-channel", - "futures-executor", - "futures-util", - "glob", - "once_cell", - "opentelemetry 0.22.0", - "ordered-float 4.2.1", - "percent-encoding", - "rand 0.8.5", - "thiserror", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -4646,32 +4947,23 @@ version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" dependencies = [ - "num-traits", -] - -[[package]] -name = "ordered-float" -version = "3.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1e1c390732d15f1d48471625cd92d154e66db2c56645e29a9cd26f4699f72dc" -dependencies = [ - "num-traits", + "num-traits 0.2.19", ] [[package]] name = "ordered-float" -version = "4.2.1" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ff2cf528c6c03d9ed653d6c4ce1dc0582dc4af309790ad92f07c1cd551b0be" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" dependencies = [ - "num-traits", + "num-traits 0.2.19", ] [[package]] name = "outref" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4030760ffd992bef45b0ae3f10ce1aba99e33464c90d14dd7c039884963ddc7a" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "overload" @@ -4693,15 +4985,15 @@ dependencies = [ [[package]] name = "parking" -version = "2.2.0" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", "parking_lot_core", @@ -4709,13 +5001,13 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.3", + "redox_syscall", "smallvec", "windows-targets 0.52.6", ] @@ -4728,9 +5020,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pem" -version = "3.0.4" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" dependencies = [ "base64 0.22.1", "serde", @@ -4747,26 +5039,26 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.7.11" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd53dff83f26735fdc1ca837098ccf133605d794cdae66acfc2bfac3ec809d95" +checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "thiserror", + "thiserror 2.0.16", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.7.11" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a548d2beca6773b1c244554d36fcf8548a8a58e74156968211567250e48e49a" +checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc" dependencies = [ "pest", "pest_generator", @@ -4774,65 +5066,75 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.11" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c93a82e8d145725dcbaf44e5ea887c8a869efdcc28706df2d08c69e17077183" +checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "pest_meta" -version = "2.7.11" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a941429fea7e08bedec25e4f6785b6ffaacc6b755da98df5ef3e7dcf4a124c4f" +checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5" dependencies = [ - "once_cell", "pest", "sha2", ] [[package]] name = "petgraph" -version = "0.6.5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.11.0", +] + +[[package]] +name = "petgraph" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +checksum = "54acf3a685220b533e437e264e4d932cfbdc4cc7ec0cd232ed73c08d03b8a7ca" dependencies = [ "fixedbitset", - "indexmap 2.2.6", + "hashbrown 0.15.5", + "indexmap 2.11.0", "serde", "serde_derive", ] [[package]] name = "pin-project" -version = "1.1.5" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.5" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "pin-project-lite" -version = "0.2.14" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -4842,15 +5144,26 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae1d5c74c9876f070d3e8fd503d748c7d974c3e48da8f41350fa5222ef9b4391" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" dependencies = [ "atomic-waker", - "fastrand 2.1.0", + "fastrand", "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -4863,17 +5176,17 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plotters" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15b6eccb8484002195a3e44fe65a4ce8e93a625797a063735536fd59cb01cf3" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" dependencies = [ - "num-traits", + "num-traits 0.2.19", "plotters-backend", "plotters-svg", "wasm-bindgen", @@ -4882,66 +5195,56 @@ dependencies = [ [[package]] name = "plotters-backend" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "414cec62c6634ae900ea1c56128dfe87cf63e7caece0852ec76aba307cebadb7" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" [[package]] name = "plotters-svg" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81b30686a7d9c3e010b84284bdd26a29f2138574f52f5eb6f794fc0ad924e705" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" dependencies = [ "plotters-backend", ] -[[package]] -name = "pmutil" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a40bc70c2c58040d2d8b167ba9a5ff59fc9dab7ad44771cfde3dcfde7a09c6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.76", -] - [[package]] name = "polling" -version = "2.8.0" +version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829" dependencies = [ - "autocfg", - "bitflags 1.3.2", "cfg-if", "concurrent-queue", - "libc", - "log", + "hermit-abi", "pin-project-lite", - "windows-sys 0.48.0", + "rustix", + "windows-sys 0.60.2", ] [[package]] -name = "polling" -version = "3.7.2" +name = "portable-atomic" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3ed00ed3fbf728b5816498ecd316d1716eecaced9c0c8d2c5a6740ca214985b" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi 0.4.0", - "pin-project-lite", - "rustix 0.38.34", - "tracing", - "windows-sys 0.52.0", + "portable-atomic", ] [[package]] -name = "portable-atomic" -version = "1.6.0" +name = "potential_utf" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] [[package]] name = "powerfmt" @@ -4951,15 +5254,18 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.17" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] [[package]] name = "predicates" -version = "3.1.2" +version = "3.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e9086cc7640c29a356d1a29fd134380bee9d8f79a17410aa76e7ad295f42c97" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" dependencies = [ "anstyle", "predicates-core", @@ -4967,28 +5273,38 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.6" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" [[package]] name = "predicates-tree" -version = "1.0.9" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" dependencies = [ "predicates-core", "termtree", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" -version = "0.1.25" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8646e95016a7a6c4adea95bafa8a16baab64b583356217f2c85db4a39d9a86" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 1.0.109", + "syn 2.0.106", ] [[package]] @@ -5002,19 +5318,40 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "1.3.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" dependencies = [ - "once_cell", - "toml_edit 0.19.15", + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -5031,7 +5368,7 @@ dependencies = [ "memchr", "parking_lot", "protobuf", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -5041,98 +5378,64 @@ dependencies = [ "anyhow", "apollo-router", "async-trait", - "http 0.2.12", + "http 1.3.1", "schemars", "serde", "serde_json", "tokio", - "tower", -] - -[[package]] -name = "prost" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" -dependencies = [ - "bytes", - "prost-derive 0.11.9", + "tower 0.5.2", ] [[package]] name = "prost" -version = "0.12.6" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive 0.12.6", + "prost-derive", ] [[package]] name = "prost-build" -version = "0.11.9" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "bytes", - "heck 0.4.1", - "itertools 0.10.5", - "lazy_static", + "heck 0.5.0", + "itertools 0.14.0", "log", - "multimap 0.8.3", - "petgraph", + "multimap 0.10.1", + "once_cell", + "petgraph 0.7.1", "prettyplease", - "prost 0.11.9", - "prost-types 0.11.9", + "prost", + "prost-types", "regex", - "syn 1.0.109", + "syn 2.0.106", "tempfile", - "which", -] - -[[package]] -name = "prost-derive" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" -dependencies = [ - "anyhow", - "itertools 0.10.5", - "proc-macro2", - "quote", - "syn 1.0.109", ] [[package]] name = "prost-derive" -version = "0.12.6" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.76", -] - -[[package]] -name = "prost-types" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" -dependencies = [ - "prost 0.11.9", + "syn 2.0.106", ] [[package]] name = "prost-types" -version = "0.12.6" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" dependencies = [ - "prost 0.12.6", + "prost", ] [[package]] @@ -5145,7 +5448,7 @@ dependencies = [ "regex", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "typetag", ] @@ -5156,20 +5459,75 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" [[package]] -name = "quick-error" -version = "1.2.3" +name = "quinn" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.16", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.16", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.59.0", +] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.7.3" @@ -5194,6 +5552,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -5214,6 +5582,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -5229,7 +5607,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", ] [[package]] @@ -5243,9 +5630,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -5253,9 +5640,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -5263,57 +5650,48 @@ dependencies = [ [[package]] name = "redis-protocol" -version = "4.1.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c31deddf734dc0a39d3112e73490e88b61a05e83e074d211f348404cee4d2c6" +checksum = "9cdba59219406899220fc4cdfd17a95191ba9c9afb719b5fa5a083d63109a9f1" dependencies = [ "bytes", "bytes-utils", "cookie-factory", "crc16", "log", - "nom", -] - -[[package]] -name = "redox_syscall" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" -dependencies = [ - "bitflags 1.3.2", + "nom 7.1.3", ] [[package]] name = "redox_syscall" -version = "0.5.3" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.2", ] [[package]] name = "redox_users" -version = "0.4.5" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 2.0.16", ] [[package]] name = "regex" -version = "1.10.5" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.7", - "regex-syntax 0.8.4", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", ] [[package]] @@ -5327,13 +5705,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.4", + "regex-syntax 0.8.5", ] [[package]] @@ -5350,27 +5728,34 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "relative-path" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "reqwest" -version = "0.11.27" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" dependencies = [ "async-compression", - "base64 0.21.7", + "base64 0.22.1", "bytes", - "encoding_rs", + "futures-channel", "futures-core", "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", "hyper", "hyper-rustls", + "hyper-util", "ipnet", "js-sys", "log", @@ -5379,42 +5764,34 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-native-certs", "rustls-pemfile", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", - "system-configuration", "tokio", "tokio-rustls", "tokio-util", + "tower 0.5.2", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", - "winreg", + "webpki-roots 0.26.11", + "windows-registry", ] [[package]] name = "resolv-conf" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00" -dependencies = [ - "hostname", - "quick-error", -] - -[[package]] -name = "retain_mut" -version = "0.1.9" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0" +checksum = "95325155c684b1c89f7765e30bc1c42e4a6da51ca513615660cb8a62ef9a88e3" [[package]] name = "rfc6979" @@ -5428,14 +5805,15 @@ dependencies = [ [[package]] name = "rhai" -version = "1.19.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61797318be89b1a268a018a92a7657096d83f3ecb31418b9e9c16dcbb043b702" +checksum = "ce4d759a4729a655ddfdbb3ff6e77fb9eadd902dae12319455557796e435d2a6" dependencies = [ "ahash", - "bitflags 2.6.0", + "bitflags 2.9.2", "instant", - "num-traits", + "no-std-compat", + "num-traits 0.2.19", "once_cell", "rhai_codegen", "serde", @@ -5450,10 +5828,10 @@ version = "0.1.0" dependencies = [ "anyhow", "apollo-router", - "http 0.2.12", + "http 1.3.1", "serde_json", "tokio", - "tower", + "tower 0.5.2", ] [[package]] @@ -5462,10 +5840,10 @@ version = "0.1.0" dependencies = [ "anyhow", "apollo-router", - "http 0.2.12", + "http 1.3.1", "serde_json", "tokio", - "tower", + "tower 0.5.2", ] [[package]] @@ -5474,10 +5852,10 @@ version = "0.1.0" dependencies = [ "anyhow", "apollo-router", - "http 0.2.12", + "http 1.3.1", "serde_json", "tokio", - "tower", + "tower 0.5.2", ] [[package]] @@ -5486,10 +5864,10 @@ version = "0.1.0" dependencies = [ "anyhow", "apollo-router", - "http 0.2.12", + "http 1.3.1", "serde_json", "tokio", - "tower", + "tower 0.5.2", ] [[package]] @@ -5498,10 +5876,10 @@ version = "0.1.0" dependencies = [ "anyhow", "apollo-router", - "http 0.2.12", + "http 1.3.1", "serde_json", "tokio", - "tower", + "tower 0.5.2", ] [[package]] @@ -5512,20 +5890,19 @@ checksum = "a5a11a05ee1ce44058fa3d5961d05194fdbe3ad6b40f904af764d81b86450e6b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "ring" -version = "0.17.8" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.16", "libc", - "spin", "untrusted", "windows-sys 0.52.0", ] @@ -5537,44 +5914,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" dependencies = [ "byteorder", - "num-traits", + "num-traits 0.2.19", "paste", ] [[package]] name = "ron" -version = "0.8.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +checksum = "beceb6f7bf81c73e73aeef6dd1356d9a1b2b4909e1f0fc3e59b034f9572d7b7f" dependencies = [ - "base64 0.21.7", - "bitflags 2.6.0", + "base64 0.22.1", + "bitflags 2.9.2", "serde", "serde_derive", -] - -[[package]] -name = "router-bridge" -version = "0.6.4+v2.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bcc6f2aa0c619a4fb74ce271873a500f5640c257ca2e7aa8ea6be6226262855" -dependencies = [ - "anyhow", - "async-channel 1.9.0", - "deno_console", - "deno_core", - "deno_url", - "deno_web", - "deno_webidl", - "rand 0.8.5", - "serde", - "serde_json", - "thiserror", - "tokio", - "tower", - "tower-service", - "tracing", - "which", + "unicode-ident", ] [[package]] @@ -5582,35 +5936,57 @@ name = "router-fuzz" version = "0.0.0" dependencies = [ "anyhow", + "apollo-federation", "apollo-parser", "apollo-router", "apollo-smith", "async-trait", + "bnf", "env_logger", - "http 0.2.12", + "http 1.3.1", "libfuzzer-sys", "log", + "rand 0.8.5", "reqwest", "schemars", "serde", "serde_json", "serde_json_bytes", - "tower", + "tower 0.5.2", ] [[package]] name = "rowan" -version = "0.15.15" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a58fa8a7ccff2aec4f39cc45bf5f985cec7125ab271cf681c279fd00192b49" +checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" dependencies = [ "countme", "hashbrown 0.14.5", - "memoffset", - "rustc-hash", + "rustc-hash 1.1.0", "text-size", ] +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits 0.2.19", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rstack" version = "0.3.3" @@ -5624,11 +6000,40 @@ dependencies = [ "log", ] +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.106", + "unicode-ident", +] + [[package]] name = "rust-embed" -version = "8.5.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0" +checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -5637,22 +6042,22 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.5.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478" +checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.76", + "syn 2.0.106", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.5.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d" +checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" dependencies = [ "globset", "sha2", @@ -5661,9 +6066,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -5672,104 +6077,101 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] -name = "rustc_version" -version = "0.2.3" +name = "rustc-hash" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" -dependencies = [ - "semver 0.9.0", -] +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustc_version" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" -dependencies = [ - "semver 1.0.23", -] - -[[package]] -name = "rustix" -version = "0.37.27" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "bitflags 1.3.2", - "errno", - "io-lifetimes", - "libc", - "linux-raw-sys 0.3.8", - "windows-sys 0.48.0", + "semver", ] [[package]] name = "rustix" -version = "0.38.34" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.2", "errno", "libc", - "linux-raw-sys 0.4.14", - "windows-sys 0.52.0", + "linux-raw-sys", + "windows-sys 0.60.2", ] [[package]] name = "rustls" -version = "0.21.12" +version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ "log", + "once_cell", "ring", + "rustls-pki-types", "rustls-webpki", - "sct", + "subtle", + "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.6.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" dependencies = [ "openssl-probe", - "rustls-pemfile", + "rustls-pki-types", "schannel", "security-framework", ] [[package]] name = "rustls-pemfile" -version = "1.0.4" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" dependencies = [ - "base64 0.21.7", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.101.7" +version = "0.103.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" dependencies = [ "ring", + "rustls-pki-types", "untrusted", ] [[package]] name = "rustversion" -version = "1.0.17" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "same-file" @@ -5780,29 +6182,20 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scc" -version = "2.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4465c22496331e20eb047ff46e7366455bc01c0c02015c4a376de0b2cd3a1af" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" -version = "0.1.23" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "schemars" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "schemars_derive", @@ -5813,37 +6206,27 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "sct" -version = "0.7.1" +name = "scoped-tls" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" [[package]] -name = "sdd" -version = "1.6.0" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eb0dde0ccd15e337a3cf738a9a38115c6d8e74795d074e73973dad3d229a897" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sec1" @@ -5861,11 +6244,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.2", "core-foundation", "core-foundation-sys", "libc", @@ -5874,9 +6257,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.11.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" dependencies = [ "core-foundation-sys", "libc", @@ -5884,52 +6267,52 @@ dependencies = [ [[package]] name = "semver" -version = "0.9.0" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" dependencies = [ - "semver-parser", + "serde", ] [[package]] -name = "semver" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" - -[[package]] -name = "semver-parser" -version = "0.7.0" +name = "serde" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] [[package]] -name = "serde" -version = "1.0.204" +name = "serde-untagged" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" +checksum = "34836a629bcbc6f1afdf0907a744870039b1e14c0561cb26094fa683b158eff3" dependencies = [ - "serde_derive", + "erased-serde 0.4.6", + "serde", + "typeid", ] [[package]] -name = "serde_bytes" -version = "0.11.15" +name = "serde-value" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "387cc504cb06bb40a96c8e04e951fe01854cf6bc921053c954e4a606d9675c6a" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" dependencies = [ + "ordered-float 2.10.1", "serde", ] [[package]] name = "serde_derive" -version = "1.0.204" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] @@ -5940,8 +6323,8 @@ checksum = "afb2522c2a87137bf6c2b3493127fed12877ef1b9476f074d6664edc98acd8a7" dependencies = [ "quote", "regex", - "syn 2.0.76", - "thiserror", + "syn 2.0.106", + "thiserror 1.0.69", ] [[package]] @@ -5952,30 +6335,31 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "serde_json" -version = "1.0.120" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ - "indexmap 2.2.6", + "indexmap 2.11.0", "itoa", + "memchr", "ryu", "serde", ] [[package]] name = "serde_json_bytes" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ecd92a088fb2500b2f146c9ddc5da9950bb7264d3f00932cd2a6fb369c26c46" +checksum = "a6a27c10711f94d1042b4c96d483556ec84371864e25d0e1cf3dc1024b0880b1" dependencies = [ "ahash", "bytes", - "indexmap 2.2.6", + "indexmap 2.11.0", "jsonpath-rust", "regex", "serde", @@ -5984,30 +6368,29 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" dependencies = [ "itoa", "serde", ] [[package]] -name = "serde_qs" -version = "0.8.5" +name = "serde_regex" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" dependencies = [ - "percent-encoding", + "regex", "serde", - "thiserror", ] [[package]] name = "serde_spanned" -version = "0.6.6" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] @@ -6024,22 +6407,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_v8" -version = "0.111.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "309b3060a9627882514f3a3ce3cc08ceb347a76aeeadc58f138c3f189cf88b71" -dependencies = [ - "bytes", - "derive_more", - "num-bigint", - "serde", - "serde_bytes", - "smallvec", - "thiserror", - "v8", -] - [[package]] name = "serde_yaml" version = "0.8.26" @@ -6052,31 +6419,6 @@ dependencies = [ "yaml-rust", ] -[[package]] -name = "serial_test" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4b487fe2acf240a021cf57c6b2b4903b1e78ca0ecd862a71b71d2a51fed77d" -dependencies = [ - "futures", - "log", - "once_cell", - "parking_lot", - "scc", - "serial_test_derive", -] - -[[package]] -name = "serial_test_derive" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82fe9db325bcef1fbcde82e078a5cc4efdf787e96b3b9cf45b50b529f2083d67" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.76", -] - [[package]] name = "sha1" version = "0.10.6" @@ -6090,15 +6432,27 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", "digest", ] +[[package]] +name = "shape" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "362c1523018b16b65737aa0ea76a731edbcd399e273c0130ba829b148f89dbd2" +dependencies = [ + "apollo-compiler", + "indexmap 2.11.0", + "serde_json", + "serde_json_bytes", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -6116,18 +6470,24 @@ checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" [[package]] name = "shellexpand" -version = "3.1.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b" +checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ "dirs", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -6144,19 +6504,19 @@ dependencies = [ [[package]] name = "similar" -version = "2.5.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42c91313f1d05da9b26f267f931cf178d4aba455b4c4622dd7355eb80c6640" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "simple_asn1" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", - "num-traits", - "thiserror", + "num-traits 0.2.19", + "thiserror 2.0.16", "time", ] @@ -6168,18 +6528,15 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] @@ -6198,45 +6555,38 @@ dependencies = [ [[package]] name = "socket2" -version = "0.4.10" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", - "winapi", + "windows-sys 0.52.0", ] [[package]] name = "socket2" -version = "0.5.7" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] -name = "sourcemap" -version = "6.4.1" +name = "spin" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4cbf65ca7dc576cf50e21f8d0712d96d4fcfd797389744b7b222a85cdf5bd90" -dependencies = [ - "data-encoding", - "debugid", - "if_chain", - "rustc_version 0.2.3", - "serde", - "serde_json", - "unicode-id", - "url", -] +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -6249,193 +6599,409 @@ dependencies = [ ] [[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "str_inflector" -version = "0.12.0" +name = "sqlx" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0b848d5a7695b33ad1be00f84a3c079fe85c9278a325ff9159e6c99cef4ef7" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ - "lazy_static", - "regex", + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", ] [[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.25.0" +name = "sqlx-core" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "strum_macros 0.25.3", + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener 5.4.1", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.11.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "rustls-native-certs", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.16", + "tokio", + "tokio-stream", + "tracing", + "url", ] [[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" - -[[package]] -name = "strum_macros" -version = "0.25.3" +name = "sqlx-macros" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ - "heck 0.4.1", "proc-macro2", "quote", - "rustversion", - "syn 2.0.76", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.106", ] [[package]] -name = "strum_macros" -version = "0.26.4" +name = "sqlx-macros-core" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ + "dotenvy", + "either", "heck 0.5.0", + "hex", + "once_cell", "proc-macro2", "quote", - "rustversion", - "syn 2.0.76", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.106", + "tokio", + "url", ] [[package]] -name = "subtle" -version = "2.6.1" +name = "sqlx-mysql" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "supergraph_sdl" -version = "0.1.0" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ - "anyhow", - "apollo-compiler", - "apollo-router", - "async-trait", - "tower", + "atoi", + "base64 0.22.1", + "bitflags 2.9.2", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.16", "tracing", + "whoami", ] [[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.76" +name = "sqlx-postgres" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578e081a14e0cefc3279b0472138c513f37b41a08d5a3cca9b6e4e8ceb6cd525" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "0.1.2" + "atoi", + "base64 0.22.1", + "bitflags 2.9.2", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.16", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.16", + "tracing", + "url", +] [[package]] -name = "sys-info" -version = "0.9.1" +name = "stable_deref_trait" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b3a0d0aba8bf96a0e1ddfdc352fc53b3df7f39318c71854910c3c4b024ae52c" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "static_assertions_next" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766" + +[[package]] +name = "str_inflector" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0b848d5a7695b33ad1be00f84a3c079fe85c9278a325ff9159e6c99cef4ef7" dependencies = [ - "cc", - "libc", + "lazy_static", + "regex", ] [[package]] -name = "system-configuration" -version = "0.5.1" +name = "stringprep" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "system-configuration-sys", + "unicode-bidi", + "unicode-normalization", + "unicode-properties", ] [[package]] -name = "system-configuration-sys" -version = "0.5.0" +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "core-foundation-sys", - "libc", + "strum_macros 0.26.4", ] [[package]] -name = "tempfile" -version = "3.10.1" +name = "strum" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "cfg-if", - "fastrand 2.1.0", - "rustix 0.38.34", - "windows-sys 0.52.0", + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.106", ] [[package]] -name = "termcolor" -version = "1.4.1" +name = "strum_macros" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "winapi-util", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] -name = "terminal-prompt" -version = "0.2.3" +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supergraph_sdl" +version = "0.1.0" +dependencies = [ + "anyhow", + "apollo-compiler", + "apollo-router", + "async-trait", + "tower 0.5.2", + "tracing", +] + +[[package]] +name = "syn" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "572818b3472910acbd5dff46a3413715c18e934b071ab2ba464a7b2c2af16376" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "sys-info" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b3a0d0aba8bf96a0e1ddfdc352fc53b3df7f39318c71854910c3c4b024ae52c" +dependencies = [ + "cc", "libc", - "winapi", +] + +[[package]] +name = "sysinfo" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07cec4dc2d2e357ca1e610cfb07de2fa7a10fc3e9fe89f72545f3d244ea87753" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.60.2", ] [[package]] name = "termtree" -version = "0.4.1" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "test-log" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dffced63c2b5c7be278154d76b479f9f9920ed34e7574201407f0b14e2bbb93" +checksum = "1e33b98a582ea0be1168eba097538ee8dd4bbe0f2b01b22ac92ea30054e5be7b" dependencies = [ "test-log-macros", "tracing-subscriber", @@ -6443,13 +7009,13 @@ dependencies = [ [[package]] name = "test-log-macros" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5999e24eaa32083191ba4e425deb75cdf25efefabe5aaccb7446dd0d4122a3f5" +checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] @@ -6460,69 +7026,66 @@ checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" [[package]] name = "thin-vec" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a38c90d48152c236a3ab59271da4f4ae63d678c5d7ad6b7714d7cb9760be5e4b" +checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" dependencies = [ "serde", ] [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", ] [[package]] -name = "thiserror-impl" -version = "1.0.63" +name = "thiserror" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.76", + "thiserror-impl 2.0.16", ] [[package]] -name = "thousands" -version = "0.2.0" +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] [[package]] -name = "thread_local" -version = "1.1.8" +name = "thiserror-impl" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ - "cfg-if", - "once_cell", + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] -name = "threadpool" -version = "1.8.1" +name = "thousands" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" -dependencies = [ - "num_cpus", -] +checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" [[package]] -name = "thrift" -version = "0.17.0" +name = "thread_local" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "byteorder", - "integer-encoding", - "log", - "ordered-float 2.10.1", - "threadpool", + "cfg-if", ] [[package]] @@ -6531,11 +7094,22 @@ version = "0.1.0" dependencies = [ "anyhow", "apollo-router", - "http 0.2.12", - "hyper", + "http 1.3.1", + "http-body-util", "serde_json", "tokio", - "tower", + "tower 0.5.2", +] + +[[package]] +name = "tikv-jemalloc-ctl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f21f216790c8df74ce3ab25b534e0718da5a1916719771d3fec23315c99e468b" +dependencies = [ + "libc", + "paste", + "tikv-jemalloc-sys", ] [[package]] @@ -6560,9 +7134,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" dependencies = [ "deranged", "itoa", @@ -6577,15 +7151,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" dependencies = [ "num-conv", "time-core", @@ -6600,6 +7174,16 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -6612,9 +7196,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -6627,49 +7211,40 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.38.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2caba9f80616f438e09748d5acda951967e1ea58508ef53d9c6402485a46df" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", - "num_cpus", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.7", + "slab", + "socket2 0.6.0", "tokio-macros", - "windows-sys 0.48.0", -] - -[[package]] -name = "tokio-io-timeout" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" -dependencies = [ - "pin-project-lite", - "tokio", + "windows-sys 0.59.0", ] [[package]] name = "tokio-macros" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "tokio-rustls" -version = "0.24.1" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" dependencies = [ "rustls", "tokio", @@ -6677,9 +7252,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.15" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" dependencies = [ "futures-core", "pin-project-lite", @@ -6702,24 +7277,37 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.20.1" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.26.2", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" dependencies = [ "futures-util", "log", "rustls", "rustls-native-certs", + "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.27.0", ] [[package]] name = "tokio-util" -version = "0.7.11" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", @@ -6732,98 +7320,74 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.15" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2caab0bf757388c6c0ae23b3293fdb463fee59434529014f85e3263b995c28" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit 0.22.16", + "toml_edit", ] [[package]] name = "toml_datetime" -version = "0.6.6" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.19.15" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.2.6", + "indexmap 2.11.0", + "serde", + "serde_spanned", "toml_datetime", - "winnow 0.5.40", + "toml_write", + "winnow", ] [[package]] -name = "toml_edit" -version = "0.22.16" +name = "toml_write" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "278f3d518e152219c994ce877758516bca5e118eaed6996192a774fb9fbf0788" -dependencies = [ - "indexmap 2.2.6", - "serde", - "serde_spanned", - "toml_datetime", - "winnow 0.6.13", -] +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tonic" -version = "0.9.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3082666a3a6433f7f511c7192923fa1fe07c69332d3c6a2e6bb040b569199d5a" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" dependencies = [ "async-stream", "async-trait", - "axum", - "base64 0.21.7", + "axum 0.7.9", + "base64 0.22.1", "bytes", "flate2", - "futures-core", - "futures-util", "h2", - "http 0.2.12", - "http-body 0.4.6", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", "hyper", "hyper-timeout", + "hyper-util", "percent-encoding", "pin-project", - "prost 0.11.9", + "prost", "rustls-native-certs", "rustls-pemfile", + "socket2 0.5.10", "tokio", "tokio-rustls", "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13" -dependencies = [ - "async-trait", - "base64 0.21.7", - "bytes", - "http 0.2.12", - "http-body 0.4.6", - "percent-encoding", - "pin-project", - "prost 0.12.6", - "tokio", - "tokio-stream", + "tower 0.4.13", "tower-layer", "tower-service", "tracing", @@ -6831,15 +7395,16 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.9.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6fdaae4c2c638bb70fe42803a26fbd6fc6ac8c72f5c59f67ecc2a2dcabf4b07" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" dependencies = [ "prettyplease", "proc-macro2", "prost-build", + "prost-types", "quote", - "syn 1.0.109", + "syn 2.0.106", ] [[package]] @@ -6850,7 +7415,6 @@ checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" dependencies = [ "futures-core", "futures-util", - "hdrhistogram", "indexmap 1.9.3", "pin-project", "pin-project-lite", @@ -6863,39 +7427,68 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "hdrhistogram", + "indexmap 2.11.0", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-http" -version = "0.4.4" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ "async-compression", - "bitflags 2.6.0", + "base64 0.22.1", + "bitflags 2.9.2", "bytes", "futures-core", "futures-util", - "http 0.2.12", - "http-body 0.4.6", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", "tokio", "tokio-util", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", + "uuid", ] [[package]] name = "tower-layer" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tower-test" @@ -6913,9 +7506,9 @@ dependencies = [ [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "log", "pin-project-lite", @@ -6925,20 +7518,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", "valuable", @@ -6958,9 +7551,9 @@ dependencies = [ [[package]] name = "tracing-log" -version = "0.1.4" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f751112709b4e791d8ce53e32c4ed2d353565a795ce84da2285393f41557bdf2" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ "log", "once_cell", @@ -6968,37 +7561,38 @@ dependencies = [ ] [[package]] -name = "tracing-log" -version = "0.2.0" +name = "tracing-mock" +version = "0.1.0-beta.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +checksum = "3ff59989fc8854bc58d3daeadbccf5d7fb0b722af043cf839c7785f1ff0daf0e" dependencies = [ - "log", - "once_cell", + "tracing", "tracing-core", ] [[package]] name = "tracing-opentelemetry" -version = "0.21.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75327c6b667828ddc28f5e3f169036cb793c3f588d83bf0f262a7f062ffed3c8" +checksum = "a9784ed4da7d921bc8df6963f8c80a0e4ce34ba6ba76668acadd3edbd985ff3b" dependencies = [ + "js-sys", "once_cell", - "opentelemetry 0.20.0", - "opentelemetry_sdk 0.20.0", + "opentelemetry", + "opentelemetry_sdk", "smallvec", "tracing", "tracing-core", - "tracing-log 0.1.4", + "tracing-log", "tracing-subscriber", + "web-time", ] [[package]] name = "tracing-serde" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" dependencies = [ "serde", "tracing-core", @@ -7006,9 +7600,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ "matchers", "nu-ansi-term 0.46.0", @@ -7021,7 +7615,7 @@ dependencies = [ "thread_local", "tracing", "tracing-core", - "tracing-log 0.2.0", + "tracing-log", "tracing-serde", ] @@ -7043,14 +7637,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "triomphe" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6631e42e10b40c0690bf92f404ebcfe6e1fdb480391d15f17cc8e96eeed5369" +checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" dependencies = [ "serde", "stable_deref_trait", @@ -7064,41 +7658,57 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "try_match" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61ae3c1941e8859e30d28e572683fbfa89ae5330748b45139aedf488389e2be4" +checksum = "b065c869a3f832418e279aa4c1d7088f9d5d323bde15a60a08e20c2cd4549082" dependencies = [ "try_match_inner", ] [[package]] name = "try_match_inner" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0a91713132798caecb23c977488945566875e7b61b902fb111979871cbff34e" +checksum = "b9c81686f7ab4065ccac3df7a910c4249f8c0f3fb70421d6ddec19b9311f63f9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", ] [[package]] name = "tungstenite" -version = "0.20.1" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ - "byteorder", "bytes", "data-encoding", - "http 0.2.12", + "http 1.3.1", "httparse", "log", - "rand 0.8.5", + "rand 0.9.2", + "sha1", + "thiserror 2.0.16", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.9.2", "rustls", + "rustls-pki-types", "sha1", - "thiserror", - "url", + "thiserror 2.0.16", "utf-8", ] @@ -7110,20 +7720,35 @@ checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" [[package]] name = "typed-builder" -version = "0.12.0" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77739c880e00693faef3d65ea3aad725f196da38b22fdc7ea6ded6e1ce4d3add" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6179333b981641242a768f30f371c9baccbfcc03749627000c500ab88bf4528b" +checksum = "1f718dfaf347dcb5b983bfc87608144b0bad87970aebcbea5ce44d2a30c08e63" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.106", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "typetag" @@ -7131,7 +7756,7 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4080564c5b2241b5bff53ab610082234e0c57b0417f4bd10596f183001505b8a" dependencies = [ - "erased-serde", + "erased-serde 0.3.31", "inventory", "once_cell", "serde", @@ -7151,9 +7776,9 @@ dependencies = [ [[package]] name = "ucd-trie" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uname" @@ -7165,96 +7790,61 @@ dependencies = [ ] [[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" +name = "unicase" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] -name = "unic-ucd-ident" -version = "0.9.0" +name = "unicode-bidi" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] -name = "unic-ucd-version" -version = "0.9.0" +name = "unicode-ident" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] -name = "unicase" -version = "2.7.0" +name = "unicode-normalization" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" dependencies = [ - "version_check", + "tinyvec", ] [[package]] -name = "unicode-bidi" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" - -[[package]] -name = "unicode-id" -version = "0.3.4" +name = "unicode-properties" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1b6def86329695390197b82c1e244a54a131ceb66c996f2088a3876e2ae083f" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" [[package]] -name = "unicode-ident" -version = "1.0.12" +name = "unicode-segmentation" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] -name = "unicode-normalization" -version = "0.1.23" +name = "unicode-width" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" -dependencies = [ - "tinyvec", -] +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.1.13" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" [[package]] -name = "unreachable" -version = "1.0.0" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56" -dependencies = [ - "void", -] +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "untrusted" @@ -7264,12 +7854,12 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.2" +version = "2.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "137a3c834eaf7139b73688502f3f1141a0337c5d8e4d9b536f9b8c796e26a7c4" dependencies = [ "form_urlencoded", - "idna 0.5.0", + "idna", "percent-encoding", "serde", ] @@ -7280,25 +7870,18 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "urlpattern" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9bd5ff03aea02fa45b13a7980151fe45009af1980ba69f651ec367121a31609" -dependencies = [ - "derive_more", - "regex", - "serde", - "unic-ucd-ident", - "url", -] - [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -7307,38 +7890,27 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.10.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.3.3", + "js-sys", "serde", "wasm-bindgen", ] -[[package]] -name = "v8" -version = "0.74.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eedac634b8dd39b889c5b62349cbc55913780226239166435c5cf66771792ea" -dependencies = [ - "bitflags 1.3.2", - "fslock", - "once_cell", - "which", -] - [[package]] name = "valuable" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.9.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a84c137d37ab0142f0f2ddfe332651fdbf252e7b7dbb4e67b6c1f1b2e925101" +checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" [[package]] name = "vcpkg" @@ -7348,15 +7920,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" - -[[package]] -name = "void" -version = "1.0.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vsimd" @@ -7364,12 +7930,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" -[[package]] -name = "waker-fn" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" - [[package]] name = "walkdir" version = "2.5.0" @@ -7397,153 +7957,360 @@ checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasite" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", + "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", + "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] -name = "wasm-bindgen-futures" -version = "0.4.42" +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.2", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "widestring" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f919aee0a93304be7f62e8e5027811bbba96bcb1de84d6618be56e43f8a32a1" +dependencies = [ + "windows-core 0.59.0", + "windows-targets 0.53.3", +] + +[[package]] +name = "windows" +version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link", + "windows-numerics", ] [[package]] -name = "wasm-bindgen-macro" -version = "0.2.92" +name = "windows-collections" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "quote", - "wasm-bindgen-macro-support", + "windows-core 0.61.2", ] [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.92" +name = "windows-core" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "810ce18ed2112484b0d4e15d022e5f598113e220c53e373fb31e67e21670c1ce" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.76", - "wasm-bindgen-backend", - "wasm-bindgen-shared", + "windows-implement 0.59.0", + "windows-interface", + "windows-result", + "windows-strings 0.3.1", + "windows-targets 0.53.3", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.92" +name = "windows-core" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.0", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings 0.4.2", +] [[package]] -name = "wasm-streams" -version = "0.4.0" +name = "windows-future" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "windows-core 0.61.2", + "windows-link", + "windows-threading", ] [[package]] -name = "web-sys" -version = "0.3.69" +name = "windows-implement" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" dependencies = [ - "js-sys", - "wasm-bindgen", + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] -name = "webpki-roots" -version = "0.25.4" +name = "windows-implement" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] [[package]] -name = "which" -version = "4.4.2" +name = "windows-interface" +version = "0.59.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.34", + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] -name = "widestring" -version = "1.1.0" +name = "windows-link" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] -name = "winapi" -version = "0.3.9" +name = "windows-numerics" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "windows-core 0.61.2", + "windows-link", ] [[package]] -name = "winapi-i686-pc-windows-gnu" +name = "windows-registry" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result", + "windows-strings 0.3.1", + "windows-targets 0.53.3", +] [[package]] -name = "winapi-util" -version = "0.1.8" +name = "windows-result" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-sys 0.52.0", + "windows-link", ] [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "windows-strings" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] [[package]] name = "windows-sys" @@ -7572,6 +8339,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -7611,13 +8396,39 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -7636,6 +8447,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -7654,6 +8471,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -7672,12 +8495,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -7696,6 +8531,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -7714,6 +8555,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -7732,6 +8579,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -7751,19 +8604,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "winnow" -version = "0.5.40" +name = "windows_x86_64_msvc" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.6.13" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59b5e5f6c299a3c7890b876a2a587f3115162487e704907d9b6cd29473052ba1" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" dependencies = [ "memchr", ] @@ -7778,40 +8628,136 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wiremock" -version = "0.5.22" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13a3a53eaf34f390dd30d7b1b078287dd05df2aa2e21a589ccb80f5c7253c2e9" +checksum = "a2b8b99d4cdbf36b239a9532e31fe4fb8acc38d1897c1761e161550a7dc78e6a" dependencies = [ "assert-json-diff", "async-trait", - "base64 0.21.7", + "base64 0.22.1", "deadpool", "futures", - "futures-timer", - "http-types", + "http 1.3.1", + "http-body-util", "hyper", + "hyper-util", "log", "once_cell", "regex", "serde", "serde_json", "tokio", + "url", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.2", +] + +[[package]] +name = "wmi" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7787dacdd8e71cbc104658aade4009300777f9b5fda6a75f19145fedb8a18e71" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.16", + "windows 0.59.0", + "windows-core 0.59.0", ] +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + [[package]] name = "wsl" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dab7ac864710bdea6594becbea5b5050333cf34fefb0dc319567eb347950d4" +[[package]] +name = "xattr" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3a19837351dc82ba89f8a125e22a3c475f05aba604acc023d62b2739ae2909" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xmlparser" version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" +[[package]] +name = "xshell" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e7290c623014758632efe00737145b6867b66292c42167f2ec381eb566a373d" +dependencies = [ + "xshell-macros", +] + +[[package]] +name = "xshell-macros" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ac00cd3f8ec9c1d33fb3e7958a82df6989c42d747bd326c822b1d625283547" + +[[package]] +name = "xtask" +version = "1.5.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "camino", + "cargo_metadata", + "chrono", + "clap", + "console 0.16.0", + "dialoguer", + "flate2", + "graphql_client", + "insta", + "itertools 0.14.0", + "libc", + "memorable-wordlist", + "nu-ansi-term 0.50.1", + "once_cell", + "regex", + "reqwest", + "serde", + "tar", + "tempfile", + "tinytemplate", + "tokio", + "walkdir", + "which", + "xshell", + "zip", +] + [[package]] name = "yaml-rust" version = "0.4.5" @@ -7827,24 +8773,69 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.106", + "synstructure", ] [[package]] @@ -7853,29 +8844,74 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zip" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835eb39822904d39cb19465de1159e05d371973f0c6df3a365ad50565ddc8b9" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.11.0", + "memchr", +] + [[package]] name = "zstd" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.2.0" +version = "7.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa556e971e7b568dc775c136fc9de8c779b1c2fc3a63defaafadffdbd3181afa" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.12+zstd.1.5.6" +version = "2.0.15+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a4e40c320c3cb459d9a9ff6de98cff88f4751ee9275d140e2be94a2b74e4c13" +checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index c492b05480..b52f9371ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,6 @@ default-members = ["apollo-router", "apollo-federation"] members = [ "apollo-router", "apollo-router-benchmarks", - "apollo-router-scaffold", - "apollo-router-scaffold/scaffold-test", "apollo-federation", "apollo-federation/cli", "examples/add-timestamp-header/rhai", @@ -29,7 +27,7 @@ members = [ "examples/throw-error/rhai", "fuzz", "fuzz/subgraph", - # Note that xtask is not in the workspace member because it relies on dependencies that are incompatible with the router. Notably hyperx but there are others. + "xtask", ] # this makes build scripts and proc macros faster to compile @@ -49,33 +47,31 @@ debug = 1 # Dependencies used in more than one place are specified here in order to keep versions in sync: # https://doc.rust-lang.org/cargo/reference/workspaces.html#the-dependencies-table [workspace.dependencies] -apollo-compiler = "=1.0.0-beta.24" -apollo-parser = "0.8.3" -apollo-smith = "0.14.0" +apollo-compiler = "1.28.0" +apollo-parser = "0.8.4" +apollo-smith = "0.15.0" async-trait = "0.1.77" +encoding_rs = "0.8" hex = { version = "0.4.3", features = ["serde"] } -http = "0.2.11" -insta = { version = "1.38.0", features = ["json", "redactions", "yaml"] } -once_cell = "1.19.0" -reqwest = { version = "0.11.0", default-features = false, features = [ - "rustls-tls", - "rustls-native-certs", - "gzip", +http = "1.1.0" +insta = { version = "1.38.0", features = [ "json", - "stream", + "redactions", + "yaml", + "glob", ] } +once_cell = "1.19.0" +reqwest = { version = "0.12.0", default-features = false } -schemars = { version = "0.8.16", features = ["url"] } -serde = { version = "1.0.197", features = ["derive", "rc"] } +schemars = { version = "0.8.22", features = ["url"] } +serde = { version = "1.0.198", features = ["derive", "rc"] } serde_json = { version = "1.0.114", features = [ "preserve_order", "float_roundtrip", ] } -serde_json_bytes = { version = "0.2.4", features = ["preserve_order"] } +serde_json_bytes = { version = "0.2.5", features = ["preserve_order"] } +similar = { version = "2.5.0", features = ["inline"] } sha1 = "0.10.6" tempfile = "3.10.1" tokio = { version = "1.36.0", features = ["full"] } -tower = { version = "0.4.13", features = ["full"] } - -[patch.crates-io] -"hyper" = { git = "https://github.com/apollographql/hyper.git", tag = "header-customizations-20241108" } +tower = { version = "0.5.1", features = ["full"] } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c46019ff93..1277243e6c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -18,16 +18,13 @@ The **Apollo Router Core** is a configurable, high-performance **graph router** ## Development -You will need a recent version of rust (`1.72` works well as of writing). -Installing rust [using rustup](https://www.rust-lang.org/tools/install) is -the recommended way to do it as it will install rustup, rustfmt and other -goodies that are not always included by default in other rust distribution channels: +You will need a recent version of rust, as specified in `rust-toolchain.toml`. +We recommend [using rustup](https://www.rust-lang.org/tools/install) +as it will automatically install the requiried toolchain version, +including rustfmt and clippy +that are not always included by default in other rust distribution channels. -``` -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -``` - -In addition, you will need to [install protoc](https://grpc.io/docs/protoc-installation/) and [cmake](https://cmake.org/). +In addition, you will need to [install protoc](https://grpc.io/docs/protoc-installation/). Set up your git hooks: @@ -51,6 +48,10 @@ docker-compose up -d have issues and you want to see the logs or if you want to run the service in foreground. +### Testing + +Tests on this repository are run using [nextest](https://nexte.st/). + ### Run against the docker-compose or Node.js setup Once the subgraphs are up and running, run the router with this command: @@ -98,19 +99,22 @@ You have to build the router with your choice of feature flags and you must use e.g.: heap and ad-hoc allocation tracing ```shell -# e.g. heap and ad-hoc allocation tracing: cargo build --profile release-dhat --features dhat-heap,dhat-ad-hoc +cargo build --profile release-dhat --features dhat-heap,dhat-ad-hoc ``` e.g.: heap allocation tracing ```shell -cargo build --profile release-dhat --features dhat-heap +cargo build --profile release-dhat --features dhat-heap ``` -This will create a router in `./target/release-dhat`. +This will create a router in `./target/release-dhat`, which can be run with: +```shell +cargo run --profile release-dhat --features dhat-heap -- -s ./apollo-router/testing_schema.graphql -c router.yaml +``` When you run your binary, on termination you will get `dhat-heap.json` and/or `dhat-ad-hoc.json` files which can -be examined using standard DHAT tooling. +be examined using standard DHAT tooling, e.g. [DHAT html viewer](https://nnethercote.github.io/dh_view/dh_view.html) For more details on interpreting these files and running tests, see the [dhat-rs](https://docs.rs/dhat/latest/dhat/#running) crate documentation. @@ -118,6 +122,18 @@ For more details on interpreting these files and running tests, see the [dhat-rs * If you have an issue with rust-analyzer reporting an unresolved import about `derivative::Derivative` [check this solution](https://github.com/rust-analyzer/rust-analyzer/issues/7459#issuecomment-876796459) found in a rust-analyzer issue. +### Code coverage + +Code coverage is run in CI nightly, but not done on every commit. To view coverage from nightly runs visit [our coverage on Codecov](https://codecov.io/gh/apollographql/router). + +To run code coverage locally, you can `cargo install cargo-llvm-cov`, and run: + +```shell +cargo llvm-cov nextest --summary-only +``` + +For full information on available options, including HTML reports and `lcov.info` file support, see [nextest documentation](https://nexte.st/book/coverage.html) and [cargo llvm-cov documentation](https://github.com/taiki-e/cargo-llvm-cov#get-coverage-of-cc-code-linked-to-rust-librarybinary). + ## Project maintainers Apollo Graph, Inc. diff --git a/README.md b/README.md index df19eb7593..0a560493c4 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,6 @@ Options: Schema location relative to the project directory [env: APOLLO_ROUTER_SUPERGRAPH_PATH=] --apollo-uplink-endpoints The endpoints (comma separated) polled to fetch the latest supergraph schema [env: APOLLO_UPLINK_ENDPOINTS=] - --apollo-uplink-poll-interval - The time between polls to Apollo uplink. Minimum 10s [env: APOLLO_UPLINK_POLL_INTERVAL=] [default: 10s] --anonymous-telemetry-disabled Disable sending anonymous usage information to Apollo [env: APOLLO_TELEMETRY_DISABLED=] --apollo-uplink-timeout diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 4ba8c4326f..e8730f4952 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -12,7 +12,6 @@ Release Checklist - Verifying the release (TODO) - [Troubleshooting a release](#troubleshooting-a-release) - Something went wrong? - [Nightly releases](#nightly-releases) -- [Using the new release automation](#using-the-new-release-automation) ## Building a Release @@ -49,13 +48,11 @@ The examples below will use [the GitHub CLI (`gh`)](https://cli.github.com/) to Make sure you have the following software installed and available in your `PATH`. - - `gh`: [The GitHub CLI](https://cli.github.com/) - - `cargo`: [Cargo & Rust Installation](https://doc.rust-lang.org/cargo/getting-started/installation.html) - - `helm`: see - - `helm-docs`: see - - `cargo-about`: install with `cargo install --locked cargo-about` - - `cargo-deny`: install with `cargo install --locked cargo-deny` - - `set-version` from `cargo-edit`: `cargo install --locked cargo-edit` +- `gh`: [The GitHub CLI](https://cli.github.com/) +- `cargo`: [Cargo & Rust Installation](https://doc.rust-lang.org/cargo/getting-started/installation.html) +- `helm`: see +- `helm-docs`: see +- `cargo-about`, `cargo-deny`, & `cargo-edit`: install the same versions as CI (`.circleci/config.yml#install_extra_tools`) #### Pick a version @@ -72,8 +69,8 @@ This project uses [Semantic Versioning 2.0.0](https://semver.org/). When releas Creating a release PR is the first step of starting a release, whether there will be pre-releases or not. About a release PR: -* A release PR is based on a release branch and a release branch gathers all the commits for a release. -* The release PR merges into `main` at the time that the release becomes official. +* A release PR is based on a mainline release line (e.g., `dev`, or `1.x`) and a release branch gathers all the commits for a release. +* The release PR merges into the mainline at the time that the release becomes official. * A release can be started from any branch or commit, but it is almost always started from `dev` as that is the main development trunk of the Router. * The release PR is in a draft mode until after the preparation PR has been merged into it. @@ -113,24 +110,30 @@ Start following the steps below to start a release PR. The process is **not ful git checkout -b "${APOLLO_ROUTER_RELEASE_VERSION}" ``` -7. Push this new branch to the appropriate remote. We will open a PR for it **later**, but this will be the **base** for the PR created in the next step). (And `--set-upstream` will of course track this locally. This is commonly abbreviated as `-u`.) +7. Add an empty commit to the branch. This isn't always necessary, but it allows the staging PR to be opened when there is no other difference to the base-branch (e.g., `dev`) which prevents the PR from getting opened, even in draft mode. + + ``` + git commit --allow-empty -m "Start v${APOLLO_ROUTER_RELEASE_VERSION} PR" + ``` + +8. Push this new branch to the appropriate remote. We will open a PR for it **later**, but this will be the **base** for the PR created in the next step). (And `--set-upstream` will of course track this locally. This is commonly abbreviated as `-u`.) ``` git push --set-upstream "${APOLLO_ROUTER_RELEASE_GIT_ORIGIN}" "${APOLLO_ROUTER_RELEASE_VERSION}" ``` -8. Now, open a draft PR with a small boilerplate header from the branch which was just pushed: +9. Now, open a draft PR with a small boilerplate header from the branch which was just pushed: ``` - cat < **Note** - > **This particular PR must be true-merged to \`main\`.** + > **This particular PR must be true-merged to \`dev\`.** - * This PR is only ready to review when it is marked as "Ready for Review". It represents the merge to the \`main\` branch of an upcoming release (version number in the title). + * This PR is only ready to review when it is marked as "Ready for Review". It represents the merge to the \`dev\` branch of an upcoming release (version number in the title). * It will act as a staging branch until we are ready to finalize the release. * We may cut any number of alpha and release candidate (RC) versions off this branch prior to formalizing it. * This PR is **primarily a merge commit**, so reviewing every individual commit shown below is **not necessary** since those have been reviewed in their own PR. However, things important to review on this PR **once it's marked "Ready for Review"**: - - Does this PR target the right branch? (usually, \`main\`) + - Does this PR target the right branch? (usually, \`dev\`) - Are the appropriate **version bumps** and **release note edits** in the end of the commit list (or within the last few commits). In other words, "Did the 'release prep' PR actually land on this branch?" - If those things look good, this PR is good to merge! EOM @@ -191,22 +194,12 @@ Start following the steps below to start a release PR. The process is **not ful 9. Git tag the current commit and & push the branch and the pre-release tag simultaneously: - This process will kick off the bulk of the release process on CircleCI, including building each architecture on its own infrastructure and notarizing the macOS binary. + This process will kick off the bulk of the release process on CircleCI, including building each architecture on its own infrastructure, notarizing the macOS binary and publishing to crates.io. ``` git tag -a "v${APOLLO_ROUTER_RELEASE_VERSION}${APOLLO_ROUTER_PRERELEASE_SUFFIX}" -m "${APOLLO_ROUTER_RELEASE_VERSION}${APOLLO_ROUTER_PRERELEASE_SUFFIX}" && \ git push "${APOLLO_ROUTER_RELEASE_GIT_ORIGIN}" "${APOLLO_ROUTER_RELEASE_VERSION}" "v${APOLLO_ROUTER_RELEASE_VERSION}${APOLLO_ROUTER_PRERELEASE_SUFFIX}" ``` - -10. Finally, publish the Crates from your local computer (this also needs to be moved to CI, but requires changing the release containers to be Rust-enabled and to restore the caches): - - > Note: This command may appear unnecessarily specific, but it will help avoid publishing a version to Crates.io that doesn't match what you're currently releasing. (e.g., in the event that you've changed branches in another window) - - ``` - cargo publish -p apollo-federation@"${APOLLO_ROUTER_RELEASE_VERSION}${APOLLO_ROUTER_PRERELEASE_SUFFIX}" && - cargo publish -p apollo-router@"${APOLLO_ROUTER_RELEASE_VERSION}${APOLLO_ROUTER_PRERELEASE_SUFFIX}" - ``` - ### Preparing the final release 1. Make sure you have all the [Software Requirements](#software-requirements) above fulfilled. @@ -248,7 +241,7 @@ Start following the steps below to start a release PR. The process is **not ful - Run our compliance checks and update the `licenses.html` file as appropriate. - Ensure we're not using any incompatible licenses in the release. -7. **MANUALLY CHECK AND UPDATE** the `federation-version-support.mdx` to make sure it shows the version of Federation which is included in the `router-bridge` that ships with this version of Router. This can be obtained by looking at the version of `router-bridge` in `apollo-router/Cargo.toml` and taking the number after the `+` (e.g., `router-bridge@0.2.0+v2.4.3` means Federation v2.4.3). +7. **MANUALLY CHECK AND UPDATE** the `federation-version-support.mdx` to make sure it shows the version of Federation which is supported by the Routter. 11. Now, review and stage he changes produced by the previous step. This is most safely done using the `--patch` (or `-p`) flag to `git add` (`-u` ignores untracked files). @@ -279,7 +272,7 @@ Start following the steps below to start a release PR. The process is **not ful git push --set-upstream "${APOLLO_ROUTER_RELEASE_GIT_ORIGIN}" "prep-${APOLLO_ROUTER_RELEASE_VERSION}" ``` -15. Programatically create a small temporary file called `this_release.md` with the changelog details of _precisely this release_ from the `CHANGELOG.md`: +15. Programmatically create a small temporary file called `this_release.md` with the changelog details of _precisely this release_ from the `CHANGELOG.md`: > Note: This file could totally be created by the `xtask` if we merely decide convention for it and whether we want it checked in or not. It will be used again later in process and, in theory, by CI. Definitely not suggesting this should live on as regex. @@ -366,41 +359,26 @@ Start following the steps below to start a release PR. The process is **not ful gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" pr ready "${APOLLO_ROUTER_RELEASE_VERSION}" ``` -7. Use the `gh` CLI to enable **auto-merge** (**_NOT_** auto-**_squash_**): +8. 🗣️ **Solicit approval from the Router team, wait for the PR to pass CI** - ``` - gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" pr merge --merge --body "" -t "release: v${APOLLO_ROUTER_RELEASE_VERSION}" --auto "${APOLLO_ROUTER_RELEASE_VERSION}" - ``` - -8. 🗣️ **Solicit approval from the Router team, wait for the PR to pass CI and auto-merge into `main`** - -9. After the PR has merged to `main`, pull `main` to your local terminal, and Git tag & push the release: +9. After the PR has been approved, add the Git tag & push the release: This process will kick off the bulk of the release process on CircleCI, including building each architecture on its own infrastructure and notarizing the macOS binary. ``` - git checkout main && \ + git checkout "${APOLLO_ROUTER_RELEASE_VERSION}" && \ git pull "${APOLLO_ROUTER_RELEASE_GIT_ORIGIN}" && \ git tag -a "v${APOLLO_ROUTER_RELEASE_VERSION}" -m "${APOLLO_ROUTER_RELEASE_VERSION}" && \ git push "${APOLLO_ROUTER_RELEASE_GIT_ORIGIN}" "v${APOLLO_ROUTER_RELEASE_VERSION}" ``` +11. Resolve any merge conflicts -10. Open a PR that reconciles `dev` (Make sure to merge this reconciliation PR back to dev, **do not squash or rebase**): +12. Mark the PR to **auto-merge NOT auto-squash** using the URL that is output from the previous command ``` - gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" pr create --title "Reconcile \`dev\` after merge to \`main\` for v${APOLLO_ROUTER_RELEASE_VERSION}" -B dev -H main --body "Follow-up to the v${APOLLO_ROUTER_RELEASE_VERSION} being officially released, bringing version bumps and changelog updates into the \`dev\` branch." - ``` - -11. Mark the PR to **auto-merge NOT auto-squash** using the URL that is output from the previous command - - ``` - APOLLO_RECONCILE_PR_URL=$(gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" pr list --state open --base dev --head main --json url --jq '.[-1] | .url') - test -n "${APOLLO_RECONCILE_PR_URL}" && \ - gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" pr merge --merge --auto "${APOLLO_RECONCILE_PR_URL}" + gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" pr merge --merge --body "" -t "release: v${APOLLO_ROUTER_RELEASE_VERSION}" --auto "${APOLLO_ROUTER_RELEASE_VERSION}" ``` -12. 🗣️ **Solicit approval from the Router team, wait for the PR to pass CI and auto-merge into `dev`** - 13. 👀 Follow along with the process by [going to CircleCI for the repository](https://app.circleci.com/pipelines/github/apollographql/router) and clicking on `release` for the Git tag that appears at the top of the list. 14. ⚠️ **Wait for `publish_github_release` on CircleCI to finish on this job before continuing.** ⚠️ @@ -440,16 +418,7 @@ Start following the steps below to start a release PR. The process is **not ful gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" release edit v"${APOLLO_ROUTER_RELEASE_VERSION}" -F ./this_release.md ``` -18. Finally, publish the Crates (`apollo-federation` followed by `apollo-router`) from your local computer from the `main` branch (this also needs to be moved to CI, but requires changing the release containers to be Rust-enabled and to restore the caches): - - > Note: This command may appear unnecessarily specific, but it will help avoid publishing a version to Crates.io that doesn't match what you're currently releasing. (e.g., in the event that you've changed branches in another window) - - ``` - cargo publish -p apollo-federation@"${APOLLO_ROUTER_RELEASE_VERSION}" && - cargo publish -p apollo-router@"${APOLLO_ROUTER_RELEASE_VERSION}" - ``` - -19. (Optional) To have a "social banner" for this release, run [this `htmlq` command](https://crates.io/crates/htmlq) (`cargo install htmlq`, or on MacOS `brew install htmlq`; its `jq` for HTML), open the link it produces, copy the image to your clipboard: +18. (Optional) To have a "social banner" for this release, run [this `htmlq` command](https://crates.io/crates/htmlq) (`cargo install htmlq`, or on MacOS `brew install htmlq`; its `jq` for HTML), open the link it produces, copy the image to your clipboard: ``` curl -s "https://github.com/apollographql/router/releases/tag/v${APOLLO_ROUTER_RELEASE_VERSION}" | htmlq 'meta[property="og:image"]' --attribute content @@ -515,207 +484,3 @@ Make sure you also delete the local tag: ```console git tag --delete vX.X.X ``` - -# Using the new release automation - -The release process precedently described can be executed through `cargo xtask` commands that store the required environment variables, along with the current state of the process, in a file called `.release-state.json`. -This can be executed by running `cargo xtask release start`, then calling `cargo xtask release continue` at each step. - -## Starting the process - -Run `cargo xtask release start` and it will prompt you for the version number you want, origin, github repository and the git ref to start from (branch, commit id or HEAD): - -``` -Starting release process -Version?: 1.123.456 -Git origin?: origin -Github repository?: apollographql/router -Git ref?: HEAD -Setting up the repository -Switched to branch 'dev' -[...] -Creating draft pull request for dev into main in apollographql/router - -https://github.com/apollographql/router/pull/5519 -Success! -``` - -CLI output has ANSI escapes to put emphasis on xtask messages VS underlying command output. - -If you had already started a release, it will ask you if you want to start a new one: - -``` -Starting release process -A release state file already exists, do you want to remove it and start a new one? [y/N] -``` - - -## Create a pre release PR - -After the draft, continue the process with `cargo xtask release continue`: - -``` -Select next release step -Next step?: -> create a prerelease - create the final release PR -``` - -Select `create a prerelease` in the choice: - -``` -Select next release step -Next step?: create a prerelease -Creating the pre release PR -prerelease suffix? 1.123.456-: rc.0 -Switched to branch '1.123.456' -Your branch is up to date with 'origin/1.123.456'. -From github.com:apollographql/router - * branch 1.123.456 -> FETCH_HEAD -Already up to date. -prerelease version: 1.123.456-rc.0 -updating Cargo.toml files - Upgraded apollo-federation from 1.49.1 to 1.123.456-rc.0 -[...] -please check the changes and add them with `git add -up .` -[...] -``` - - -Now follow the `git add -up .` process. Then finish the prerelease PR. - - -``` -Commit the changes and build the prerelease? yes -[1.123.456 a83fb721f] prep release: v1.123.456-rc.0 - 13 files changed, 21 insertions(+), 21 deletions(-) -[...] -To github.com:apollographql/router.git - 544f8f619..a83fb721f 1.123.456 -> 1.123.456 -[...] -To github.com:apollographql/router.git - * [new tag] v1.123.456-rc.0 -> v1.123.456-rc.0 -publish the crates: -cargo publish -p apollo-federation@1.123.456-rc.0 -cargo publish -p apollo-router@1.123.456-rc.0 -Success! -``` - - -Publishing crates has to be done manually for now. - -## Creating the final release PR - -``` -Select next release step -Next step?: - create a prerelease -> create the final release PR -``` - -Then: - -``` -Creating the final release PR -Already on '1.123.456' -Your branch is up to date with 'origin/1.123.456'. -From github.com:apollographql/router - * branch 1.123.456 -> FETCH_HEAD -Already up to date. -Switched to a new branch 'prep-1.123.456' -updating Cargo.toml files - Upgraded apollo-federation from 1.123.456-rc.0 to 1.123.456 -[...] -prep release branch created -**MANUALLY CHECK AND UPDATE** the `federation-version-support.mdx` to make sure it shows the version of Federation which is included in the `router-bridge` that ships with this version of Router. - This can be obtained by looking at the version of `router-bridge` in `apollo-router/Cargo.toml` and taking the number after the `+` (e.g., `router-bridge@0.2.0+v2.4.3` means Federation v2.4.3). -Make local edits to the newly rendered `CHANGELOG.md` entries to do some initial editoral. - - These things should have *ALWAYS* been resolved earlier in the review process of the PRs that introduced the changes, but they must be double checked: - - - There are no breaking changes. - - Entries are in categories (e.g., Fixes vs Features) that make sense. - - Titles stand alone and work without their descriptions. - - You don't need to read the title for the description to make sense. - - Grammar is good. (Or great! But don't let perfect be the enemy of good.) - - Formatting looks nice when rendered as markdown and follows common convention. -Success! -``` - -next step is another round of `git add -up .`: - -``` -please check the changes and add them with `git add -up .` - -[prep-1.123.456 103bfe1cd] prep release: v1.123.456 - 24 files changed, 190 insertions(+), 168 deletions(-) -[...] -Creating pull request for prep-1.123.456 into 1.123.456 in apollographql/router - -https://github.com/apollographql/router/pull/5520 -Success! -``` - -Get feedback from the team about the prep release PR. - -``` -Select next release step -Next step?: - create a prerelease -> finish the release process -``` - -Select "finish the release process", which will merge the prep release PR to he release branch: - -``` -Next step?: finish the release process -Merging the final release PR -Wait for the pre PR to merge into the release PR -Success! -``` - -Now we can create the PR from the release branch to main: - -``` -Switched to branch '1.123.456' -Your branch is up to date with 'origin/1.123.456'. -From github.com:apollographql/router - * branch 1.123.456 -> FETCH_HEAD -Already up to date. -✓ Pull request apollographql/router#5519 is marked as "ready for review" -release PR marked as ready -✓ Pull request apollographql/router#5519 will be automatically merged when all requirements are met -Wait for the release PR to merge into main -Success! -``` - -Continue the process once the release PR has been merged to main, and create now the reconciliation PR: - -``` -Tagging and releasing -Switched to branch 'main' -Your branch is behind 'origin/main' by 328 commits, and can be fast-forwarded. - (use "git pull" to update your local branch) -remote: Enumerating objects: 34, done. -[...] -Creating pull request for main into dev in apollographql/router - -https://github.com/apollographql/router/pull/5521 -dev reconciliation PR created -reconciliation PR URL: : https://github.com/apollographql/router/pull/5521 - -✓ Pull request apollographql/router#5521 will be automatically merged when all requirements are met -🗣️ **Solicit approval from the Router team, wait for the reconciliation PR to pass CI and auto-merge into `dev`** -⚠️ **Wait for `publish_github_release` on CircleCI to finish on this job before continuing.** ⚠️ -Success! -``` - -The last step will update the release notes and give you the command to publish the crates manually: - -``` -Updating release notes -manually publish the crates: -cargo publish -p apollo-federation@1.123.456 -cargo publish -p apollo-router@1.123.456 -Success! -``` diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 34f88021d8..0000000000 --- a/ROADMAP.md +++ /dev/null @@ -1,51 +0,0 @@ -## 🗺️🔭 Roadmap - -We'll be working to open issues and surface designs about these things and more, as the planning progresses. Follow this file for more details as they become available and we'll link the items below to issues as we create them. We look forward to your participation in those discussions! - -- **Apollo Studio integrations** - - We'll be building out stories for Apollo Studio, including: - - - Tracing - - Metrics reporting - - Schema reporting - - We'd like to make the Apollo Router as much as part of the Studio story as Apollo Gateway is today. - -- **Newer Federation 2 features** - - As new Apollo Federation 2 features are released, we'll integrate those updates into the Router. In most cases, this will be just as simple as updating the Apollo Router's dependencies. - -- **More customizations** - - We're excited about a number of opportunities for customizing behavior, including exploring options for: - - - Header manipulation - - Request context propagation - - Dynamic routing - - Authorization - - Auditing - - We hope to provide first-class experiences for many of the things which required a more-than-ideal amount of configuration. - -- **Specification compliance** - - We're still working on making the Apollo Router fully GraphQL specification compliant. This will be a continued effort and is also embodied in the Apollo Router's design principles. - - Until we finish this work, there may be responses returned to the clients which are not fully specification compliant, including artifacts of Federation query plan execution. (e.g., the inclusion of additional metadata). - -- **OpenTelemetry/Prometheus metrics** - - These will compliment the existing OpenTelemetry traces which we already support. It will help to paint a clearer picture of how the Apollo Router is performing and allow you to set alerts in your favorite alerting software. - -- **Structured logging** - - The logs that are produced from the Apollo Router should integrate well with existing log facilities. We'll be adding configuration to enable this (e.g., JSON-formatted logging). - -- **Continued performance tuning** - - The Apollo Router is already fast, but we'll be looking for more ways to make it faster. We'll be setting up CI/CD performance measurements to track regressions before they end up in user's deployments and to understand the cost of new features we introduce. - -- **Hardening** - - The Router will need new functionality to remain performant. This will include exploring options for rate-limiting, payload size checking, reacting to back-pressure, etc. diff --git a/about.toml b/about.toml index 094647afae..33b191d06b 100644 --- a/about.toml +++ b/about.toml @@ -9,8 +9,12 @@ accepted = [ "LicenseRef-ring", "MIT", "MPL-2.0", + "Unicode-3.0", + "OpenSSL", # required by aws-lc-sys "Unicode-DFS-2016", - "Zlib" + "Zlib", + "NCSA", # similar to MIT/BSD-3-Clause, used by libfuzzer + "CDLA-Permissive-2.0", # webpki-roots ] # See https://github.com/EmbarkStudios/cargo-about/pull/216 @@ -29,6 +33,9 @@ workarounds = [ [ring] accepted = ["OpenSSL"] +[aws-lc-sys] +accepted = ["OpenSSL"] + [webpki.clarify] license = "ISC" [[webpki.clarify.files]] diff --git a/apollo-federation/Cargo.toml b/apollo-federation/Cargo.toml index c93e2d79d0..3e505207ce 100644 --- a/apollo-federation/Cargo.toml +++ b/apollo-federation/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "apollo-federation" -version = "1.57.1" +version = "2.6.0" authors = ["The Apollo GraphQL Contributors"] -edition = "2021" +edition = "2024" description = "Apollo Federation" documentation = "https://docs.rs/apollo-federation" repository = "https://github.com/apollographql/router" @@ -15,35 +15,69 @@ autotests = false # Integration tests are m # This logging is gated behind a feature to avoid any unnecessary (even if # small) runtime costs where this data will not be desired. snapshot_tracing = ["ron"] +# `correctness` feature enables the `correctness` module. +correctness = [] [dependencies] apollo-compiler.workspace = true time = { version = "0.3.34", default-features = false, features = [ "local-offset", ] } -derive_more = "0.99.17" -hashbrown = "0.15.0" +countmap = "0.2.0" +derive_more = { version = "2.0.0", features = ["display", "from", "is_variant"] } +encoding_rs.workspace = true +http.workspace = true +hashbrown = "0.15.1" indexmap = { version = "2.2.6", features = ["serde"] } -itertools = "0.13.0" -lazy_static = "1.4.0" +itertools = "0.14.0" +line-col = "0.2.1" +levenshtein = "1" multimap = "0.10.0" +multi_try = "0.3.0" nom = "7.1.3" -petgraph = { version = "0.6.4", features = ["serde-1"] } +nom_locate = "4.2.0" +percent-encoding = "2.3.1" +petgraph = { version = "0.8.0", features = ["serde-1"] } +regex = "1.11.1" serde.workspace = true +serde_json.workspace = true serde_json_bytes.workspace = true -strum = "0.26.0" -strum_macros = "0.26.0" -thiserror = "1.0" +strum = "0.27.0" +strum_macros = "0.27.0" +thiserror = "2.0" url = "2" -tracing = "0.1.40" -ron = { version = "0.8.1", optional = true } either = "1.13.0" +tracing = "0.1.40" +ron = { version = "0.10.0", optional = true } +shape = "0.5.2" +form_urlencoded = "1.2.1" +parking_lot = "0.12.4" +mime = "0.3.17" [dev-dependencies] +diff = "0.1.13" hex.workspace = true insta.workspace = true sha1.workspace = true +similar.workspace = true tempfile.workspace = true +pretty_assertions = "1.4.0" +rstest = "0.26.0" +dhat = "0.3.3" +# workaround for https://github.com/rust-lang/cargo/issues/2911 +apollo-federation = { path = ".", features = ["correctness"] } [[test]] name = "main" + +[[test]] +name = "connectors_validation_profiling" +path = "tests/dhat_profiling/connectors_validation.rs" + +[[test]] +name = "supergraph_creation_profiling" +path = "tests/dhat_profiling/supergraph.rs" + +[[test]] +name = "query_plan_creation_profiling" +path = "tests/dhat_profiling/query_plan.rs" diff --git a/apollo-federation/README.md b/apollo-federation/README.md index 93781b480b..24f3e71314 100644 --- a/apollo-federation/README.md +++ b/apollo-federation/README.md @@ -3,10 +3,10 @@ [![Crates.io](https://img.shields.io/crates/v/apollo-federation.svg?style=flat-square)](https://crates.io/crates/apollo-federation) [![docs](https://img.shields.io/static/v1?label=docs&message=apollo-federation&color=blue&style=flat-square)](https://docs.rs/apollo-federation/) [![Join the community forum](https://img.shields.io/badge/join%20the%20community-forum-blueviolet)](https://community.apollographql.com) -[![Join our Discord server](https://img.shields.io/discord/1022972389463687228.svg?color=7389D8&labelColor=6A7EC2&logo=discord&logoColor=ffffff&style=flat-square)](https://discord.gg/graphos) Apollo Federation ----------------- + Apollo Federation is an architecture for declaratively composing APIs into a unified graph. Each team can own their slice of the graph independently, empowering them to deliver autonomously and incrementally. Federation 2 is an evolution of the original Apollo Federation with an improved shared ownership model, enhanced type merging, and cleaner syntax for a smoother developer experience. It’s backwards compatible, requiring no major changes to your subgraphs. diff --git a/apollo-federation/cli/Cargo.toml b/apollo-federation/cli/Cargo.toml index 6512e73ac8..c2ca900974 100644 --- a/apollo-federation/cli/Cargo.toml +++ b/apollo-federation/cli/Cargo.toml @@ -1,16 +1,19 @@ [package] name = "apollo-federation-cli" version = "0.1.0" -edition = "2021" +edition = "2024" +license-file = "../LICENSE" [dependencies] apollo-compiler.workspace = true -apollo-federation = { path = ".." } +apollo-federation = { path = "..", features = ["correctness"] } clap = { version = "4.5.1", features = ["derive"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde_json = { version = "1.0.114", features = [ + "preserve_order", +] } +anyhow = "1" [dev-dependencies] insta = { version = "1.38.0", features = ["json", "redactions"] } -serde = { version = "1.0.197", features = ["derive"] } -serde_json = { version = "1.0.114", features = [ - "preserve_order", -] } \ No newline at end of file +serde = { version = "1.0.197", features = ["derive"] } \ No newline at end of file diff --git a/apollo-federation/cli/src/bench.rs b/apollo-federation/cli/src/bench.rs index ed135d8ba4..6cf533b82d 100644 --- a/apollo-federation/cli/src/bench.rs +++ b/apollo-federation/cli/src/bench.rs @@ -3,10 +3,10 @@ use std::path::PathBuf; use std::time::Instant; use apollo_compiler::ExecutableDocument; +use apollo_federation::Supergraph; use apollo_federation::error::FederationError; use apollo_federation::query_plan::query_planner::QueryPlanner; use apollo_federation::query_plan::query_planner::QueryPlannerConfig; -use apollo_federation::Supergraph; pub(crate) fn run_bench( supergraph: Supergraph, diff --git a/apollo-federation/cli/src/main.rs b/apollo-federation/cli/src/main.rs index 28bb5f7921..545a11bd8e 100644 --- a/apollo-federation/cli/src/main.rs +++ b/apollo-federation/cli/src/main.rs @@ -1,21 +1,38 @@ use std::fs; use std::io; use std::num::NonZeroU32; +use std::ops::Range; use std::path::Path; use std::path::PathBuf; use std::process::ExitCode; +use anyhow::Error as AnyError; +use anyhow::anyhow; use apollo_compiler::ExecutableDocument; +use apollo_compiler::parser::LineColumn; +use apollo_federation::ApiSchemaOptions; +use apollo_federation::Supergraph; +use apollo_federation::bail; +use apollo_federation::composition; +use apollo_federation::composition::validate_satisfiability; +use apollo_federation::connectors::expand::ExpansionResult; +use apollo_federation::connectors::expand::expand_connectors; +use apollo_federation::error::CompositionError; use apollo_federation::error::FederationError; use apollo_federation::error::SingleFederationError; +use apollo_federation::error::SubgraphLocation; +use apollo_federation::internal_composition_api; use apollo_federation::query_graph; use apollo_federation::query_plan::query_planner::QueryPlanner; use apollo_federation::query_plan::query_planner::QueryPlannerConfig; -use apollo_federation::subgraph; -use bench::BenchOutput; +use apollo_federation::subgraph::SubgraphError; +use apollo_federation::subgraph::typestate; +use apollo_federation::supergraph as new_supergraph; use clap::Parser; +use tracing_subscriber::prelude::*; mod bench; +use bench::BenchOutput; use bench::run_bench; #[derive(Parser)] @@ -23,12 +40,12 @@ struct QueryPlannerArgs { /// Enable @defer support. #[arg(long, default_value_t = false)] enable_defer: bool, - /// Reuse fragments to compress subgraph queries. - #[arg(long, default_value_t = false)] - reuse_fragments: bool, /// Generate fragments to compress subgraph queries. #[arg(long, default_value_t = false)] generate_fragments: bool, + /// Enable type conditioned fetching. + #[arg(long, default_value_t = false)] + type_conditioned_fetching: bool, /// Run GraphQL validation check on generated subgraph queries. (default: true) #[arg(long, default_missing_value = "true", require_equals = true, num_args = 0..=1)] subgraph_validation: Option, @@ -38,10 +55,6 @@ struct QueryPlannerArgs { /// Set the `debug.paths_limit` option. #[arg(long)] paths_limit: Option, - /// If the supergraph only represents a single subgraph, pass through queries directly without - /// planning. - #[arg(long, default_value_t = false)] - single_subgraph_passthrough: bool, } /// CLI arguments. See @@ -73,6 +86,8 @@ enum Command { }, /// Outputs the formatted query plan for the given query and schema Plan { + #[arg(long)] + json: bool, query: PathBuf, /// Path(s) to one supergraph schema file, `-` for stdin or multiple subgraph schemas. schemas: Vec, @@ -89,6 +104,16 @@ enum Command { /// Path(s) to subgraph schemas. schemas: Vec, }, + /// Expand and validate a subgraph schema and print the result + Subgraph { + /// The path to the subgraph schema file, or `-` for stdin + subgraph_schema: PathBuf, + }, + /// Validate the satisfiability of a supergraph schema + Satisfiability { + /// The path to the supergraph schema file, or `-` for stdin + supergraph_schema: PathBuf, + }, /// Extract subgraph schemas from a supergraph schema to stdout (or in a directory if specified) Extract { /// The path to the supergraph schema file, or `-` for stdin @@ -104,20 +129,31 @@ enum Command { #[command(flatten)] planner: QueryPlannerArgs, }, + + /// Expand connector-enabled supergraphs + Expand { + /// The path to the supergraph schema file, or `-` for stdin + supergraph_schema: PathBuf, + + /// The output directory for the extracted subgraph schemas + destination_dir: Option, + + /// An optional prefix to match against expanded subgraph names + #[arg(long)] + filter_prefix: Option, + }, } impl QueryPlannerArgs { fn apply(&self, config: &mut QueryPlannerConfig) { config.incremental_delivery.enable_defer = self.enable_defer; - // --generate-fragments trumps --reuse-fragments - config.reuse_query_fragments = self.reuse_fragments && !self.generate_fragments; config.generate_query_fragments = self.generate_fragments; + config.type_conditioned_fetching = self.type_conditioned_fetching; config.subgraph_graphql_validation = self.subgraph_validation.unwrap_or(true); if let Some(max_evaluated_plans) = self.max_evaluated_plans { config.debug.max_evaluated_plans = max_evaluated_plans; } config.debug.paths_limit = self.paths_limit; - config.debug.bypass_planner_for_single_subgraph = self.single_subgraph_passthrough; } } @@ -129,7 +165,20 @@ impl From for QueryPlannerConfig { } } +/// Set up the tracing subscriber +fn init_tracing() { + let fmt_layer = tracing_subscriber::fmt::layer() + .without_time() + .with_target(false); + let filter_layer = tracing_subscriber::EnvFilter::from_default_env(); + tracing_subscriber::registry() + .with(fmt_layer) + .with(filter_layer) + .init(); +} + fn main() -> ExitCode { + init_tracing(); let args = Args::parse(); let result = match args.command { Command::Api { @@ -139,11 +188,14 @@ fn main() -> ExitCode { Command::QueryGraph { schemas } => cmd_query_graph(&schemas), Command::FederatedGraph { schemas } => cmd_federated_graph(&schemas), Command::Plan { + json, query, schemas, planner, - } => cmd_plan(&query, &schemas, planner), + } => cmd_plan(json, &query, &schemas, planner), Command::Validate { schemas } => cmd_validate(&schemas), + Command::Subgraph { subgraph_schema } => cmd_subgraph(&subgraph_schema), + Command::Satisfiability { supergraph_schema } => cmd_satisfiability(&supergraph_schema), Command::Compose { schemas } => cmd_compose(&schemas), Command::Extract { supergraph_schema, @@ -154,6 +206,15 @@ fn main() -> ExitCode { operations_dir, planner, } => cmd_bench(&supergraph_schema, &operations_dir, planner), + Command::Expand { + supergraph_schema, + destination_dir, + filter_prefix, + } => cmd_expand( + &supergraph_schema, + destination_dir.as_ref(), + filter_prefix.as_deref(), + ), }; match result { Err(error) => { @@ -173,7 +234,7 @@ fn read_input(input_path: &Path) -> String { } } -fn cmd_api_schema(file_paths: &[PathBuf], enable_defer: bool) -> Result<(), FederationError> { +fn cmd_api_schema(file_paths: &[PathBuf], enable_defer: bool) -> Result<(), AnyError> { let supergraph = load_supergraph(file_paths)?; let api_schema = supergraph.to_api_schema(apollo_federation::ApiSchemaOptions { include_defer: enable_defer, @@ -183,56 +244,114 @@ fn cmd_api_schema(file_paths: &[PathBuf], enable_defer: bool) -> Result<(), Fede Ok(()) } +fn compose_files_inner( + file_paths: &[PathBuf], +) -> Result, Vec> { + let mut subgraphs = Vec::new(); + let mut errors = Vec::new(); + for path in file_paths { + let doc_str = std::fs::read_to_string(path).unwrap(); + let url = format!("file://{}", path.to_str().unwrap()); + let basename = path.file_stem().unwrap().to_str().unwrap(); + let result = typestate::Subgraph::parse(basename, &url, &doc_str); + match result { + Ok(subgraph) => { + subgraphs.push(subgraph); + } + Err(err) => { + errors.push(err); + } + } + } + if !errors.is_empty() { + // Subgraph errors + let mut composition_errors = Vec::new(); + for error in errors { + composition_errors.extend(error.to_composition_errors()); + } + return Err(composition_errors); + } + + composition::compose(subgraphs) +} + /// Compose a supergraph from multiple subgraph files. -fn compose_files(file_paths: &[PathBuf]) -> Result { - let schemas: Vec<_> = file_paths - .iter() - .map(|pathname| { - let doc_str = std::fs::read_to_string(pathname).unwrap(); - let url = format!("file://{}", pathname.to_str().unwrap()); - let basename = pathname.file_stem().unwrap().to_str().unwrap(); - subgraph::Subgraph::parse_and_expand(basename, &url, &doc_str).unwrap() - }) - .collect(); - let supergraph = apollo_federation::Supergraph::compose(schemas.iter().collect()).unwrap(); - Ok(supergraph) +fn compose_files( + file_paths: &[PathBuf], +) -> Result, AnyError> { + match compose_files_inner(file_paths) { + Ok(supergraph) => Ok(supergraph), + Err(errors) => { + // Print composition errors + print_composition_errors(&errors); + let num_errors = errors.len(); + Err(anyhow!("Error: found {num_errors} composition error(s).")) + } + } +} + +fn print_composition_errors(errors: &[CompositionError]) { + for error in errors { + eprintln!( + "{code}: {message}", + code = error.code().definition().code(), + message = error + ); + print_subgraph_locations(error.locations()); + eprintln!(); // line break + } +} + +fn print_subgraph_locations(locations: &[SubgraphLocation]) { + if locations.is_empty() { + eprintln!("locations: "); + } else { + eprintln!("locations:"); + for loc in locations { + eprintln!( + " [{subgraph}] {start_line}:{start_column} - {end_line}:{end_column}", + subgraph = loc.subgraph, + start_line = loc.range.start.line, + start_column = loc.range.start.column, + end_line = loc.range.end.line, + end_column = loc.range.end.column, + ); + } + } } fn load_supergraph_file( file_path: &Path, ) -> Result { let doc_str = read_input(file_path); - apollo_federation::Supergraph::new(&doc_str) + apollo_federation::Supergraph::new_with_router_specs(&doc_str) } /// Load either single supergraph schema file or compose one from multiple subgraph files. /// If the single file is "-", read from stdin. -fn load_supergraph( - file_paths: &[PathBuf], -) -> Result { - if file_paths.is_empty() { - panic!("Error: missing command arguments"); +fn load_supergraph(file_paths: &[PathBuf]) -> Result { + let supergraph = if file_paths.is_empty() { + bail!("Error: missing command arguments"); } else if file_paths.len() == 1 { - load_supergraph_file(&file_paths[0]) + load_supergraph_file(&file_paths[0])? } else { - compose_files(file_paths) - } + let supergraph = compose_files(file_paths)?; + // Convert the new Supergraph struct into the old one. + let schema_doc = supergraph.schema().schema().to_string(); + apollo_federation::Supergraph::new_with_router_specs(&schema_doc)? + }; + Ok(supergraph) } -fn cmd_query_graph(file_paths: &[PathBuf]) -> Result<(), FederationError> { +fn cmd_query_graph(file_paths: &[PathBuf]) -> Result<(), AnyError> { let supergraph = load_supergraph(file_paths)?; - let name: &str = if file_paths.len() == 1 { - file_paths[0].file_stem().unwrap().to_str().unwrap() - } else { - "supergraph" - }; - let query_graph = - query_graph::build_query_graph::build_query_graph(name.into(), supergraph.schema)?; + let api_schema = supergraph.to_api_schema(Default::default())?; + let query_graph = query_graph::build_supergraph_api_query_graph(supergraph.schema, api_schema)?; println!("{}", query_graph::output::to_dot(&query_graph)); Ok(()) } -fn cmd_federated_graph(file_paths: &[PathBuf]) -> Result<(), FederationError> { +fn cmd_federated_graph(file_paths: &[PathBuf]) -> Result<(), AnyError> { let supergraph = load_supergraph(file_paths)?; let api_schema = supergraph.to_api_schema(Default::default())?; let query_graph = @@ -242,10 +361,11 @@ fn cmd_federated_graph(file_paths: &[PathBuf]) -> Result<(), FederationError> { } fn cmd_plan( + use_json: bool, query_path: &Path, schema_paths: &[PathBuf], planner: QueryPlannerArgs, -) -> Result<(), FederationError> { +) -> Result<(), AnyError> { let query = read_input(query_path); let supergraph = load_supergraph(schema_paths)?; @@ -253,27 +373,137 @@ fn cmd_plan( let planner = QueryPlanner::new(&supergraph, config)?; let query_doc = - ExecutableDocument::parse_and_validate(planner.api_schema().schema(), query, query_path)?; - print!( - "{}", - planner.build_query_plan(&query_doc, None, Default::default())? + ExecutableDocument::parse_and_validate(planner.api_schema().schema(), query, query_path) + .map_err(FederationError::from)?; + let query_plan = planner.build_query_plan(&query_doc, None, Default::default())?; + if use_json { + println!("{}", serde_json::to_string_pretty(&query_plan).unwrap()); + } else { + println!("{query_plan}"); + } + + // Check the query plan + let subgraphs_by_name = supergraph + .extract_subgraphs() + .unwrap() + .into_iter() + .map(|(name, subgraph)| (name, subgraph.schema)) + .collect(); + let result = apollo_federation::correctness::check_plan( + planner.api_schema(), + &supergraph.schema, + &subgraphs_by_name, + &query_doc, + &query_plan, ); - Ok(()) + match result { + Ok(_) => Ok(()), + Err(err) => Err(anyhow!("{err}")), + } } -fn cmd_validate(file_paths: &[PathBuf]) -> Result<(), FederationError> { +fn cmd_validate(file_paths: &[PathBuf]) -> Result<(), AnyError> { load_supergraph(file_paths)?; println!("[SUCCESS]"); Ok(()) } -fn cmd_compose(file_paths: &[PathBuf]) -> Result<(), FederationError> { +fn subgraph_parse_and_validate( + name: &str, + url: &str, + doc_str: &str, +) -> Result, SubgraphError> { + typestate::Subgraph::parse(name, url, doc_str)? + .expand_links()? + .assume_upgraded() + .validate() +} + +fn cmd_subgraph(file_path: &Path) -> Result<(), AnyError> { + let doc_str = read_input(file_path); + let name = file_path + .file_stem() + .and_then(|name| name.to_str().map(|x| x.to_string())) + .unwrap_or_else(|| "subgraph".to_string()); + let url = format!("http://{name}"); + let subgraph = match subgraph_parse_and_validate(&name, &url, &doc_str) { + Ok(subgraph) => subgraph, + Err(err) => { + let composition_errors: Vec<_> = err.to_composition_errors().collect(); + print_composition_errors(&composition_errors); + let num_errors = composition_errors.len(); + return Err(anyhow!( + "Error: found {num_errors} error(s) in subgraph schema" + )); + } + }; + + // Extra subgraph validation for @cacheTag directive + let result = internal_composition_api::validate_cache_tag_directives(&name, &url, &doc_str)?; + if !result.errors.is_empty() { + for err in &result.errors { + eprintln!( + "{code}: {message}", + code = err.code(), + message = err.message() + ); + print_locations(&err.locations); + eprintln!(); // line break + } + let num_errors = result.errors.len(); + return Err(anyhow!( + "Error: found {num_errors} error(s) in subgraph schema" + )); + } + + println!("{}", subgraph.schema_string()); + Ok(()) +} + +fn print_locations(locations: &[Range]) { + if locations.is_empty() { + eprintln!("locations: "); + } else { + eprintln!("locations:"); + for loc in locations { + eprintln!( + " {start_line}:{start_column} - {end_line}:{end_column}", + start_line = loc.start.line, + start_column = loc.start.column, + end_line = loc.end.line, + end_column = loc.end.column, + ); + } + } +} + +fn cmd_satisfiability(file_path: &Path) -> Result<(), AnyError> { + let doc_str = read_input(file_path); + let supergraph = new_supergraph::Supergraph::parse(&doc_str).unwrap(); + _ = validate_satisfiability(supergraph).expect("Supergraph should be satisfiable"); + Ok(()) +} + +fn cmd_compose(file_paths: &[PathBuf]) -> Result<(), AnyError> { let supergraph = compose_files(file_paths)?; - println!("{}", supergraph.schema.schema()); + println!("{}", supergraph.schema().schema()); + let hints = supergraph.hints(); + if !hints.is_empty() { + eprintln!("{num_hints} HINTS generated:", num_hints = hints.len()); + for hint in hints { + eprintln!(); // line break + eprintln!( + "{code}: {message}", + code = hint.code(), + message = hint.message() + ); + print_subgraph_locations(&hint.locations); + } + } Ok(()) } -fn cmd_extract(file_path: &Path, dest: Option<&PathBuf>) -> Result<(), FederationError> { +fn cmd_extract(file_path: &Path, dest: Option<&PathBuf>) -> Result<(), AnyError> { let supergraph = load_supergraph_file(file_path)?; let subgraphs = supergraph.extract_subgraphs()?; if let Some(dest) = dest { @@ -298,6 +528,73 @@ fn cmd_extract(file_path: &Path, dest: Option<&PathBuf>) -> Result<(), Federatio Ok(()) } +fn cmd_expand( + file_path: &Path, + dest: Option<&PathBuf>, + filter_prefix: Option<&str>, +) -> Result<(), AnyError> { + let original_supergraph = load_supergraph_file(file_path)?; + let ExpansionResult::Expanded { raw_sdl, .. } = expand_connectors( + &original_supergraph.schema.schema().serialize().to_string(), + &ApiSchemaOptions::default(), + )? + else { + bail!("supplied supergraph has no connectors to expand",); + }; + + // Validate the schema + // TODO: If expansion errors here due to bugs, it can be very hard to trace + // what specific portion of the expansion process failed. Work will need to be + // done to expansion to allow for returning an error type that carries the error + // and the expanded subgraph as seen until the error. + let expanded = Supergraph::new_with_router_specs(&raw_sdl)?; + + let subgraphs = expanded.extract_subgraphs()?; + if let Some(dest) = dest { + fs::create_dir_all(dest).map_err(|_| SingleFederationError::Internal { + message: "Error: directory creation failed".into(), + })?; + for (name, subgraph) in subgraphs { + // Skip any files not matching the prefix, if specified + if let Some(prefix) = filter_prefix + && !name.starts_with(prefix) + { + continue; + } + + let subgraph_path = dest.join(format!("{}.graphql", name)); + fs::write(subgraph_path, subgraph.schema.schema().to_string()).map_err(|_| { + SingleFederationError::Internal { + message: "Error: file output failed".into(), + } + })?; + } + } else { + // Print out the schemas as YAML so that it can be piped into rover + // TODO: It would be nice to use rover's supergraph type here instead of manually printing + println!("federation_version: 2"); + println!("subgraphs:"); + for (name, subgraph) in subgraphs { + // Skip any files not matching the prefix, if specified + if let Some(prefix) = filter_prefix + && !name.starts_with(prefix) + { + continue; + } + + let schema_str = subgraph.schema.schema().serialize().initial_indent_level(4); + println!(" {name}:"); + println!(" routing_url: none"); + println!(" schema:"); + println!(" sdl: |"); + println!("{schema_str}"); + println!(); // newline + } + } + + Ok(()) +} + fn _cmd_bench( file_path: &Path, operations_dir: &PathBuf, @@ -311,7 +608,7 @@ fn cmd_bench( file_path: &Path, operations_dir: &PathBuf, planner: QueryPlannerArgs, -) -> Result<(), FederationError> { +) -> Result<(), AnyError> { let results = _cmd_bench(file_path, operations_dir, planner.into())?; println!("| operation_name | time (ms) | evaluated_plans (max 10000) | error |"); println!("|----------------|----------------|-----------|-----------------------------|"); diff --git a/apollo-federation/examples/api_schema.rs b/apollo-federation/examples/api_schema.rs index 9cda97afc6..2d4dd27806 100644 --- a/apollo-federation/examples/api_schema.rs +++ b/apollo-federation/examples/api_schema.rs @@ -12,7 +12,11 @@ fn main() -> ExitCode { }; let schema = Schema::parse_and_validate(source, name).unwrap(); - let supergraph = Supergraph::from_schema(schema).unwrap(); + let supergraph = Supergraph::from_schema( + schema, + Some(&apollo_federation::default_supported_supergraph_specs()), + ) + .unwrap(); match supergraph.to_api_schema(Default::default()) { Ok(result) => println!("{}", result.schema()), diff --git a/apollo-federation/src/api_schema.rs b/apollo-federation/src/api_schema.rs index d515009084..79b422711f 100644 --- a/apollo-federation/src/api_schema.rs +++ b/apollo-federation/src/api_schema.rs @@ -1,16 +1,16 @@ //! Implements API schema generation. +use apollo_compiler::Node; use apollo_compiler::name; use apollo_compiler::schema::DirectiveDefinition; use apollo_compiler::schema::DirectiveLocation; use apollo_compiler::schema::InputValueDefinition; use apollo_compiler::ty; -use apollo_compiler::Node; use crate::error::FederationError; use crate::link::inaccessible_spec_definition::InaccessibleSpecDefinition; -use crate::schema::position; use crate::schema::FederationSchema; use crate::schema::ValidFederationSchema; +use crate::schema::position; /// Remove types and directives imported by `@link`. fn remove_core_feature_elements(schema: &mut FederationSchema) -> Result<(), FederationError> { diff --git a/apollo-federation/src/compat.rs b/apollo-federation/src/compat.rs index d3d8caa537..fcb1061730 100644 --- a/apollo-federation/src/compat.rs +++ b/apollo-federation/src/compat.rs @@ -7,6 +7,10 @@ //! This module contains functions that modify an apollo-rs schema to produce the same output as a //! graphql-js schema would. +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::Schema; use apollo_compiler::ast::Value; use apollo_compiler::collections::IndexMap; use apollo_compiler::executable; @@ -15,10 +19,6 @@ use apollo_compiler::schema::ExtendedType; use apollo_compiler::schema::InputValueDefinition; use apollo_compiler::schema::Type; use apollo_compiler::validation::Valid; -use apollo_compiler::ExecutableDocument; -use apollo_compiler::Name; -use apollo_compiler::Node; -use apollo_compiler::Schema; /// Return true if a directive application is "semantic", meaning it's observable in introspection. fn is_semantic_directive_application(directive: &Directive) -> bool { @@ -382,9 +382,9 @@ pub(crate) fn make_print_schema_compatible(schema: &mut Schema) { #[cfg(test)] mod tests { - use apollo_compiler::validation::Valid; use apollo_compiler::ExecutableDocument; use apollo_compiler::Schema; + use apollo_compiler::validation::Valid; use super::coerce_executable_values; diff --git a/apollo-federation/src/composition/mod.rs b/apollo-federation/src/composition/mod.rs new file mode 100644 index 0000000000..a38c9ffc50 --- /dev/null +++ b/apollo-federation/src/composition/mod.rs @@ -0,0 +1,121 @@ +mod satisfiability; + +use std::vec; + +use apollo_compiler::Schema; +use apollo_compiler::validation::Valid; +use itertools::Itertools; + +pub use crate::composition::satisfiability::validate_satisfiability; +use crate::error::CompositionError; +use crate::merger::merge::Merger; +pub use crate::schema::schema_upgrader::upgrade_subgraphs_if_necessary; +use crate::schema::validators::root_fields::validate_consistent_root_fields; +use crate::subgraph::typestate::Expanded; +use crate::subgraph::typestate::Initial; +use crate::subgraph::typestate::Subgraph; +use crate::subgraph::typestate::Upgraded; +use crate::subgraph::typestate::Validated; +pub use crate::supergraph::Merged; +pub use crate::supergraph::Satisfiable; +pub use crate::supergraph::Supergraph; + +pub fn compose( + subgraphs: Vec>, +) -> Result, Vec> { + tracing::debug!("Expanding subgraphs..."); + let expanded_subgraphs = expand_subgraphs(subgraphs)?; + tracing::debug!("Upgrading subgraphs..."); + let mut upgraded_subgraphs = upgrade_subgraphs_if_necessary(expanded_subgraphs)?; + tracing::debug!("Normalizing root types..."); + for subgraph in upgraded_subgraphs.iter_mut() { + subgraph + .normalize_root_types() + .map_err(|e| e.to_composition_errors().collect_vec())?; + } + tracing::debug!("Validating subgraphs..."); + let validated_subgraphs = validate_subgraphs(upgraded_subgraphs)?; + + tracing::debug!("Pre-merge validations..."); + pre_merge_validations(&validated_subgraphs)?; + tracing::debug!("Merging subgraphs..."); + let supergraph = merge_subgraphs(validated_subgraphs)?; + tracing::debug!("Post-merge validations..."); + post_merge_validations(&supergraph)?; + tracing::debug!("Validating satisfiability..."); + validate_satisfiability(supergraph) +} + +/// Apollo Federation allow subgraphs to specify partial schemas (i.e. "import" directives through +/// `@link`). This function will update subgraph schemas with all missing federation definitions. +pub fn expand_subgraphs( + subgraphs: Vec>, +) -> Result>, Vec> { + let mut errors: Vec = vec![]; + let expanded: Vec> = subgraphs + .into_iter() + .map(|s| s.expand_links()) + .filter_map(|r| r.map_err(|e| errors.extend(e.to_composition_errors())).ok()) + .collect(); + if errors.is_empty() { + Ok(expanded) + } else { + Err(errors) + } +} + +/// Validate subgraph schemas to ensure they satisfy Apollo Federation requirements (e.g. whether +/// `@key` specifies valid `FieldSet`s etc). +pub fn validate_subgraphs( + subgraphs: Vec>, +) -> Result>, Vec> { + let mut errors: Vec = vec![]; + let validated: Vec> = subgraphs + .into_iter() + .map(|s| s.validate()) + .filter_map(|r| r.map_err(|e| errors.extend(e.to_composition_errors())).ok()) + .collect(); + if errors.is_empty() { + Ok(validated) + } else { + Err(errors) + } +} + +/// Perform validations that require information about all available subgraphs. +pub fn pre_merge_validations( + subgraphs: &[Subgraph], +) -> Result<(), Vec> { + validate_consistent_root_fields(subgraphs)?; + // TODO: (FED-713) Implement any pre-merge validations that require knowledge of all subgraphs. + Ok(()) +} + +pub fn merge_subgraphs( + subgraphs: Vec>, +) -> Result, Vec> { + let merger = Merger::new(subgraphs, Default::default()).map_err(|e| { + vec![CompositionError::InternalError { + message: e.to_string(), + }] + })?; + let result = merger.merge(); + if result.errors.is_empty() { + let schema = result + .supergraph + .map(|s| s.into_inner().into_inner()) + .unwrap_or_else(Schema::new); + let supergraph = Supergraph::with_hints(Valid::assume_valid(schema), result.hints); + Ok(supergraph) + } else { + Err(result.errors) + } +} + +pub fn post_merge_validations( + _supergraph: &Supergraph, +) -> Result<(), Vec> { + // TODO: (FED-714) Implement any post-merge validations other than satisfiability, which is + // checked separately. + Ok(()) +} diff --git a/apollo-federation/src/composition/satisfiability.rs b/apollo-federation/src/composition/satisfiability.rs new file mode 100644 index 0000000000..513b4d843e --- /dev/null +++ b/apollo-federation/src/composition/satisfiability.rs @@ -0,0 +1,205 @@ +mod conditions_validation; +mod satisfiability_error; +mod validation_context; +mod validation_state; +mod validation_traversal; + +use std::sync::Arc; + +use crate::api_schema; +use crate::composition::satisfiability::validation_traversal::ValidationTraversal; +use crate::error::CompositionError; +use crate::error::FederationError; +use crate::merger::merge::CompositionOptions; +use crate::query_graph::QueryGraph; +use crate::query_graph::build_federated_query_graph; +use crate::query_graph::build_supergraph_api_query_graph; +use crate::schema::ValidFederationSchema; +use crate::supergraph::CompositionHint; +use crate::supergraph::Merged; +use crate::supergraph::Satisfiable; +use crate::supergraph::Supergraph; + +pub fn validate_satisfiability( + supergraph: Supergraph, +) -> Result, Vec> { + let mut errors = vec![]; + let mut hints = vec![]; + let supergraph_schema = match validate_satisfiability_inner(supergraph, &mut errors, &mut hints) + { + Ok(supergraph_schema) => supergraph_schema, + Err(error) => { + return Err(vec![CompositionError::InternalError { + message: error.to_string(), + }]); + } + }; + if !errors.is_empty() { + return Err(errors); + } + Ok(Supergraph::::new(supergraph_schema, hints)) +} + +fn validate_satisfiability_inner( + supergraph: Supergraph, + errors: &mut Vec, + hints: &mut Vec, +) -> Result { + // TODO: Avoid this clone by holding `FederationSchema` directly in `Merged` struct. + let supergraph_schema = ValidFederationSchema::new(supergraph.state.schema().clone())?; + let api_schema = api_schema::to_api_schema(supergraph_schema.clone(), Default::default())?; + + let api_schema_query_graph = + build_supergraph_api_query_graph(supergraph_schema.clone(), api_schema.clone())?; + let federated_query_graph = build_federated_query_graph( + supergraph_schema.clone(), + api_schema, + Some(true), + Some(false), + )?; + validate_graph_composition( + supergraph_schema.clone(), + Arc::new(api_schema_query_graph), + Arc::new(federated_query_graph), + // TODO: Pass composition options through once upstream function APIs have been updated. + &Default::default(), + errors, + hints, + )?; + Ok(supergraph_schema) +} + +/// Validates that all the queries expressible on the API schema resulting from the composition of +/// a set of subgraphs can be executed on those subgraphs. +fn validate_graph_composition( + // The supergraph schema generated by composition of the subgraph schemas. + supergraph_schema: ValidFederationSchema, + // The query graph of the API schema generated by the supergraph schema. + api_schema_query_graph: Arc, + // The federated query graph corresponding to the composed subgraphs. + federated_query_graph: Arc, + composition_options: &CompositionOptions, + errors: &mut Vec, + hints: &mut Vec, +) -> Result<(), FederationError> { + ValidationTraversal::new( + supergraph_schema, + api_schema_query_graph, + federated_query_graph, + composition_options, + )? + .validate(errors, hints) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_SUPERGRAPH: &str = r#" +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar context__ContextFieldValue + +interface I + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id") + @context(name: "A__contextI") +{ + id: ID! + value: Int! @join__field(graph: A) +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + A @join__graph(name: "A", url: "http://A") + B @join__graph(name: "B", url: "http://B") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type P + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id") +{ + id: ID! + data: String! @join__field(graph: A, contextArguments: [{context: "A__contextI", name: "onlyInA", type: "Int", selection: " { value }"}]) +} + +type Query + @join__type(graph: A) + @join__type(graph: B) +{ + start: I! @join__field(graph: B) +} + +type T implements I + @join__implements(graph: A, interface: "I") + @join__implements(graph: B, interface: "I") + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id") +{ + id: ID! + value: Int! @join__field(graph: A) + onlyInA: Int! @join__field(graph: A) + p: P! @join__field(graph: A) + sharedField: Int! + onlyInB: Int! @join__field(graph: B) +} + "#; + + #[test] + fn test_satisfiability_basic() { + let supergraph = Supergraph::parse(TEST_SUPERGRAPH).unwrap(); + _ = validate_satisfiability(supergraph).expect("Supergraph should be satisfiable"); + } +} diff --git a/apollo-federation/src/composition/satisfiability/conditions_validation.rs b/apollo-federation/src/composition/satisfiability/conditions_validation.rs new file mode 100644 index 0000000000..baf83adb3d --- /dev/null +++ b/apollo-federation/src/composition/satisfiability/conditions_validation.rs @@ -0,0 +1,316 @@ +use std::sync::Arc; + +use petgraph::graph::EdgeIndex; + +use crate::bail; +use crate::error::FederationError; +use crate::error::SingleFederationError; +use crate::operation::Selection; +use crate::operation::SelectionSet; +use crate::query_graph::QueryGraph; +use crate::query_graph::condition_resolver::CachingConditionResolver; +use crate::query_graph::condition_resolver::ConditionResolution; +use crate::query_graph::condition_resolver::ConditionResolverCache; +use crate::query_graph::graph_path::ExcludedConditions; +use crate::query_graph::graph_path::ExcludedDestinations; +use crate::query_graph::graph_path::operation::OpGraphPath; +use crate::query_graph::graph_path::operation::OpGraphPathContext; +use crate::query_graph::graph_path::operation::OpenBranch; +use crate::query_graph::graph_path::operation::OpenBranchAndSelections; +use crate::query_graph::graph_path::operation::SimultaneousPaths; +use crate::query_graph::graph_path::operation::SimultaneousPathsWithLazyIndirectPaths; + +/// A simple condition resolver that only validates that the condition can be satisfied, but +/// without trying compare/evaluate the potential various ways to validate said conditions. +/// Concretely, the `ConditionResolution` values returned by the create resolver will never contain +/// a `pathTree` (or an `unsatisfiedConditionReason` for that matter) and the cost will always +/// default to 1 if the conditions are satisfied. +// PORT_NOTE: This ports the `simpleValidationConditionResolver` function from JS. In JS +// version, the function creates a closure. In Rust, `ConditionValidationTraversal` +// implements `CachingConditionResolver` trait, similarly to how it was ported with +// `QueryPlanningTraversal`. Also, the JS version has a `withCaching` argument to +// control whether to use caching. Non-cached case is only used in tests. So, Rust +// version is simplified to always use caching. +// Note: Analogous to `resolve_condition_plan` method of the QueryPlanningTraversal struct. +pub(super) fn resolve_condition_plan( + query_graph: Arc, + edge: EdgeIndex, + context: &OpGraphPathContext, + excluded_destinations: &ExcludedDestinations, + excluded_conditions: &ExcludedConditions, + extra_conditions: Option<&SelectionSet>, +) -> Result { + let edge_weight = query_graph.edge_weight(edge)?; + let conditions = match (extra_conditions, &edge_weight.conditions) { + (Some(extra_conditions), None) => extra_conditions, + (None, Some(edge_conditions)) => edge_conditions, + (Some(_), Some(_)) => bail!("Both extra_conditions and edge conditions are set"), + (None, None) => bail!("Both extra_conditions and edge conditions are None"), + }; + let excluded_conditions = excluded_conditions.add_item(conditions); + let head = query_graph.edge_endpoints(edge)?.0; + let initial_path = OpGraphPath::new(query_graph.clone(), head)?; + let initial_option = SimultaneousPathsWithLazyIndirectPaths::new( + SimultaneousPaths(vec![Arc::new(initial_path)]), + context.clone(), + excluded_destinations.clone(), + excluded_conditions, + ); + let mut traversal = ConditionValidationTraversal::new( + query_graph.clone(), + initial_option, + conditions.iter().cloned(), + ); + traversal.find_resolution() +} + +struct ConditionValidationTraversal { + /// The federated query graph for the supergraph schema. + query_graph: Arc, + /// The cache for condition resolution. + condition_resolver_cache: ConditionResolverCache, + /// The stack of open branches left to plan, along with state indicating the next selection to + /// plan for them. + // PORT_NOTE: This implementation closely follows the way `QueryPlanningTraversal` was ported. + open_branches: Vec, +} + +impl ConditionValidationTraversal { + fn new( + query_graph: Arc, + initial_option: SimultaneousPathsWithLazyIndirectPaths, + selections: impl IntoIterator, + ) -> Self { + Self { + query_graph, + condition_resolver_cache: ConditionResolverCache::new(), + open_branches: vec![OpenBranchAndSelections { + selections: selections.into_iter().collect(), + open_branch: OpenBranch(vec![initial_option]), + }], + } + } + + // Analogous to `find_best_plan_inner` of QueryPlanningTraversal. + fn find_resolution(&mut self) -> Result { + while let Some(mut current_branch) = self.open_branches.pop() { + let Some(current_selection) = current_branch.selections.pop() else { + bail!("Sub-stack unexpectedly empty during validation traversal",); + }; + let (terminate_planning, new_branch) = + self.handle_open_branch(¤t_selection, &mut current_branch.open_branch.0)?; + if terminate_planning { + return Ok(ConditionResolution::unsatisfied_conditions()); + } + if !current_branch.selections.is_empty() { + self.open_branches.push(current_branch); + } + if let Some(new_branch) = new_branch { + self.open_branches.push(new_branch); + } + } + // If we exhaust the stack, it means we've been able to find "some" path for every possible + // selection in the condition, so the condition is validated. Note that we use a cost of 1 + // for all conditions as we don't care about efficiency. + Ok(ConditionResolution::Satisfied { + cost: 1.0f64, + path_tree: None, + context_map: None, + }) + } + + // Analogous to `handle_open_branch` of QueryPlanningTraversal. + fn handle_open_branch( + &mut self, + selection: &Selection, + options: &mut [SimultaneousPathsWithLazyIndirectPaths], + ) -> Result<(bool, Option), FederationError> { + let mut new_options = Vec::new(); + for paths in options.iter_mut() { + let options = paths.advance_with_operation_element( + self.query_graph.supergraph_schema()?.clone(), + &selection.element(), + self, + // In this particular case, we're traversing the selections of a FieldSet. By + // providing _no_ overrides here, it'll ensure that we don't incorrectly validate + // any cases where overridden fields are in a FieldSet, it's just disallowed + // completely. + &Default::default(), + &never_cancel, + &Default::default(), + )?; + let Some(options) = options else { + continue; + }; + new_options.extend(options); + } + if new_options.is_empty() { + // If we got no options, it means that particular selection of the conditions cannot be + // satisfied, so the overall condition cannot. + return Ok((true, None)); + } + + if let Some(selection_set) = selection.selection_set() { + // If the selection has a selection set, we need to continue traversing it. + let new_branch = OpenBranchAndSelections { + open_branch: OpenBranch(new_options), + selections: selection_set.iter().cloned().collect(), + }; + Ok((false, Some(new_branch))) + } else { + Ok((false, None)) + } + } +} + +// `advance_with_operation_element` method is cancelable, but composition doesn't need to be +// cancelable at the moment. So, this `never_cancel` function is passed to it for now. +pub(crate) fn never_cancel() -> Result<(), SingleFederationError> { + Ok(()) +} + +impl CachingConditionResolver for ConditionValidationTraversal { + fn query_graph(&self) -> &QueryGraph { + &self.query_graph + } + + fn resolver_cache(&mut self) -> &mut ConditionResolverCache { + &mut self.condition_resolver_cache + } + + fn resolve_without_cache( + &self, + edge: EdgeIndex, + context: &OpGraphPathContext, + excluded_destinations: &ExcludedDestinations, + excluded_conditions: &ExcludedConditions, + extra_conditions: Option<&SelectionSet>, + ) -> Result { + resolve_condition_plan( + self.query_graph.clone(), + edge, + context, + excluded_destinations, + excluded_conditions, + extra_conditions, + ) + } +} + +#[cfg(test)] +mod simple_condition_resolver_tests { + use super::*; + use crate::Supergraph; + use crate::query_graph::build_federated_query_graph; + + const TEST_SUPERGRAPH: &str = r#" +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + A @join__graph(name: "A", url: "http://A") + B @join__graph(name: "B", url: "http://B") + C @join__graph(name: "C", url: "http://C") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: A) + @join__type(graph: B) + @join__type(graph: C) +{ + start: T! @join__field(graph: A) +} + +type T + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id") + @join__type(graph: C, key: "id") +{ + id: ID! + onlyInA: Int! @join__field(graph: A) + onlyInB: Int! @join__field(graph: B) @join__field(graph: C, external: true) + onlyInC: Int! @join__field(graph: C, requires: "onlyInB") +} + "#; + + #[test] + fn test_simple_condition_resolver_basic() { + let supergraph = Supergraph::new_with_router_specs(TEST_SUPERGRAPH).unwrap(); + let query_graph = build_federated_query_graph( + supergraph.schema.clone(), + supergraph + .to_api_schema(Default::default()) + .unwrap() + .clone(), + Some(true), + Some(true), + ) + .unwrap(); + let query_graph = Arc::new(query_graph); + + for edge in query_graph.graph().edge_indices() { + let edge_weight = query_graph.edge_weight(edge).unwrap(); + if edge_weight.conditions.is_none() { + continue; // Skip edges without conditions. + } + let result = resolve_condition_plan( + query_graph.clone(), + edge, + &Default::default(), + &Default::default(), + &Default::default(), + None, + ) + .unwrap(); + // All edges are expected to be satisfiable. + assert!(matches!(result, ConditionResolution::Satisfied { .. })); + } + } +} diff --git a/apollo-federation/src/composition/satisfiability/satisfiability_error.rs b/apollo-federation/src/composition/satisfiability/satisfiability_error.rs new file mode 100644 index 0000000000..cec1abeece --- /dev/null +++ b/apollo-federation/src/composition/satisfiability/satisfiability_error.rs @@ -0,0 +1,593 @@ +use std::collections::BTreeSet; +use std::sync::Arc; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; +use apollo_compiler::executable; +use itertools::Itertools; +use petgraph::graph::EdgeIndex; + +use crate::bail; +use crate::composition::satisfiability::validation_state::ValidationState; +use crate::ensure; +use crate::error::CompositionError; +use crate::error::FederationError; +use crate::operation::FieldSelection; +use crate::operation::InlineFragment; +use crate::operation::InlineFragmentSelection; +use crate::operation::Operation; +use crate::operation::SelectionId; +use crate::operation::SelectionSet; +use crate::query_graph::QueryGraphEdgeTransition; +use crate::query_graph::QueryGraphNodeType; +use crate::query_graph::graph_path::Unadvanceables; +use crate::query_graph::graph_path::transition::TransitionGraphPath; +use crate::schema::ValidFederationSchema; +use crate::schema::position::CompositeTypeDefinitionPosition; +use crate::schema::position::FieldDefinitionPosition; +use crate::schema::position::TypeDefinitionPosition; +use crate::supergraph::CompositionHint; +use crate::utils::MultiIndexMap; +use crate::utils::human_readable::HumanReadableListOptions; +use crate::utils::human_readable::HumanReadableListPrefix; +use crate::utils::human_readable::human_readable_list; +use crate::utils::human_readable::human_readable_subgraph_names; +use crate::utils::human_readable::human_readable_types; + +/// Returns a satisfiability error in Ok case; Otherwise, returns another error in Err case. +pub(super) fn satisfiability_error( + unsatisfiable_path: &TransitionGraphPath, + _subgraphs_paths: &[&TransitionGraphPath], + subgraphs_paths_unadvanceables: &[Unadvanceables], + errors: &mut Vec, +) -> Result<(), FederationError> { + let witness = build_witness_operation(unsatisfiable_path)?; + let operation = witness.to_string(); + let message = format!( + "The following supergraph API query:\n\ + {operation}\n\ + cannot be satisfied by the subgraphs because:\n\ + {reasons}", + reasons = display_reasons(subgraphs_paths_unadvanceables), + ); + // PORT_NOTE: We're not using `_subgraphs_paths` parameter, since we didn't port + // the `ValidationError` class, yet. + errors.push(CompositionError::SatisfiabilityError { message }); + Ok(()) +} + +pub(super) fn shareable_field_non_intersecting_runtime_types_error( + invalid_state: &ValidationState, + field_definition_position: &FieldDefinitionPosition, + runtime_types_to_subgraphs: &IndexMap>, IndexSet>>, + errors: &mut Vec, +) -> Result<(), FederationError> { + let witness = build_witness_operation(invalid_state.supergraph_path())?; + let operation = witness.to_string(); + let type_strings = runtime_types_to_subgraphs + .iter() + .map(|(runtime_types, subgraphs)| { + format!( + " - in {}, {}", + human_readable_subgraph_names(subgraphs.iter()), + human_readable_list( + runtime_types + .iter() + .map(|runtime_type| format!("\"{runtime_type}\"")), + HumanReadableListOptions { + prefix: Some(HumanReadableListPrefix { + singular: "type", + plural: "types", + }), + empty_output: "no runtime type is defined", + ..Default::default() + } + ) + ) + }) + .join(";\n"); + let field = field_definition_position + .get(invalid_state.supergraph_path().graph().schema()?.schema())?; + let message = format!( + "For the following supergraph API query:\n\ + {}\n\ + Shared field \"{}\" return type \"{}\" has a non-intersecting set of possible runtime \ + types across subgraphs. Runtime types in subgraphs are:\n\ + {}.\n\ + This is not allowed as shared fields must resolve the same way in all subgraphs, and that \ + implies at least some common runtime types between the subgraphs.", + operation, + field_definition_position, + field.ty.inner_named_type(), + type_strings, + ); + errors.push(CompositionError::ShareableHasMismatchedRuntimeTypes { message }); + Ok(()) +} + +pub(super) fn shareable_field_mismatched_runtime_types_hint( + state: &ValidationState, + field_definition_position: &FieldDefinitionPosition, + common_runtime_types: &BTreeSet, + runtime_types_per_subgraphs: &IndexMap, Arc>>, + hints: &mut Vec, +) -> Result<(), FederationError> { + let witness = build_witness_operation(state.supergraph_path())?; + let operation = witness.to_string(); + let all_subgraphs = state.current_subgraph_names()?; + let subgraphs_with_type_not_in_intersection_string = all_subgraphs + .iter() + .map(|subgraph| { + let Some(runtime_types) = runtime_types_per_subgraphs.get(subgraph) else { + bail!("Unexpectedly no runtime types for path's tail's subgraph"); + }; + let types_to_not_implement = runtime_types + .iter() + .filter(|type_name| !common_runtime_types.contains(*type_name)) + .collect::>(); + if types_to_not_implement.is_empty() { + return Ok::<_, FederationError>(None); + }; + Ok(Some(format!( + " - subgraph \"{}\" should never resolve \"{}\" to an object of {}", + subgraph, + field_definition_position, + human_readable_types(types_to_not_implement.into_iter()), + ))) + }) + .process_results(|iter| iter.flatten().join(";\n"))?; + let field = + field_definition_position.get(state.supergraph_path().graph().schema()?.schema())?; + let message = format!( + "For the following supergraph API query:\n\ + {}\n\ + Shared field \"{}\" return type \"{}\" has different sets of possible runtime types across \ + subgraphs.\n\ + Since a shared field must be resolved the same way in all subgraphs, make sure that {} \ + only resolve \"{}\" to objects of {}. In particular:\n\ + {}\n\ + Otherwise the @shareable contract will be broken.", + operation, + field_definition_position, + field.ty.inner_named_type(), + human_readable_subgraph_names(all_subgraphs.iter()), + field_definition_position, + human_readable_types(common_runtime_types.iter()), + subgraphs_with_type_not_in_intersection_string, + ); + hints.push(CompositionHint { + message, + code: "INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN".to_owned(), + locations: Default::default(), // TODO + }); + Ok(()) +} + +fn build_witness_operation(witness: &TransitionGraphPath) -> Result { + let root = witness.head_node()?; + let Some(root_kind) = root.root_kind else { + bail!("build_witness_operation: root kind is not set"); + }; + let schema = witness.schema_by_source(&root.source)?; + let edges: Vec<_> = witness.iter().map(|item| item.0).collect(); + ensure!( + !edges.is_empty(), + "unsatisfiable_path should contain at least one edge/transition" + ); + let Some(selection_set) = build_witness_next_step(schema, witness, &edges)? else { + bail!("build_witness_operation: root selection set failed to build"); + }; + Ok(Operation { + schema: schema.clone(), + root_kind, + name: None, + selection_set, + variables: Default::default(), + directives: Default::default(), + }) +} + +// Recursively build a selection set bottom-up. +fn build_witness_next_step( + schema: &ValidFederationSchema, + witness: &TransitionGraphPath, + edges: &[EdgeIndex], +) -> Result, FederationError> { + match edges.split_first() { + // Base case + None => { + // We're at the end of our counter-example, meaning that we're at a point of traversing the + // supergraph where we know there is no valid equivalent subgraph traversals. That said, we + // may well not be on a terminal vertex (the type may not be a leaf), meaning that + // returning `None` may be invalid. In that case, we instead return an empty SelectionSet. + // This is, strictly speaking, equally invalid, but we use this as a convention to means + // "there is supposed to be a selection but we don't have it" and the code in + // `SelectionSet.toSelectionNode` (FED-577) handles this an prints an ellipsis (a '...'). + // + // Note that, as an alternative, we _could_ generate a random valid witness: while the + // current type is not terminal we would randomly pick a valid choice (if it's an abstract + // type, we'd "cast" to any implementation; if it's an object, we'd pick the first field + // and recurse on its type). However, while this would make sure our "witness" is always a + // fully valid query, this is probably less user friendly in practice because you'd have to + // follow the query manually to figure out at which point the query stop being satisfied by + // subgraphs. Putting the ellipsis instead make it immediately clear after which part of + // the query there is an issue. + let QueryGraphNodeType::SchemaType(type_pos) = &witness.tail_node()?.type_ else { + bail!("build_witness_next_step: tail type is not a schema type"); + }; + // Note that vertex types are named output types, so if it's not a leaf it is guaranteed to + // be selectable. + Ok( + match CompositeTypeDefinitionPosition::try_from(type_pos.clone()) { + Ok(composite_type_pos) => { + Some(SelectionSet::empty(schema.clone(), composite_type_pos)) + } + _ => None, + }, + ) + } + + // Recursive case + Some((edge_index, rest)) => { + let sub_selection = build_witness_next_step(schema, witness, rest)?; + let edge = witness.edge_weight(*edge_index)?; + let (parent_type, selection) = match &edge.transition { + QueryGraphEdgeTransition::Downcast { + source: _, + from_type_position, + to_type_position, + } => { + let inline_fragment = InlineFragment { + schema: schema.clone(), + parent_type_position: from_type_position.clone(), + type_condition_position: Some(to_type_position.clone()), + directives: Default::default(), + selection_id: SelectionId::new(), + }; + let Some(sub_selection) = sub_selection else { + bail!("build_witness_next_step: sub_selection is None"); + }; + ( + from_type_position.clone(), + InlineFragmentSelection::new(inline_fragment, sub_selection).into(), + ) + } + QueryGraphEdgeTransition::FieldCollection { + source: _, + field_definition_position, + is_part_of_provides: _, + } => { + let parent_type_pos = field_definition_position.parent(); + let Some(field) = FieldSelection::from_field( + &build_witness_field(schema, field_definition_position)?, + &parent_type_pos, + &Default::default(), + schema, + &|| Ok(()), // never cancels + )? + else { + bail!("build_witness_next_step: field is None"); + }; + let field = field.with_updated_selection_set(sub_selection); + (parent_type_pos.clone(), field.into()) + } + _ => { + // Witnesses are build from a path on the supergraph, so we shouldn't have any of those edges. + bail!("Invalid edge {edge} found in supergraph path"); + } + }; + Ok(Some(SelectionSet::from_selection(parent_type, selection))) + } + } +} + +fn build_witness_field( + schema: &ValidFederationSchema, + field_definition_position: &FieldDefinitionPosition, +) -> Result { + let field_def = field_definition_position.get(schema.schema())?; + let result = executable::Field::new(field_def.name.clone(), field_def.node.clone()); + let args = field_def + .arguments + .iter() + .filter_map(|arg_def| { + // PORT_NOTE: JS implementation didn't skip optional arguments. Rust version skips them + // for brevity. + if !arg_def.is_required() { + return None; + } + let arg_value = match generate_witness_value(schema, arg_def) { + Ok(value) => value, + Err(e) => { + return Some(Err(e)); + } + }; + Some(Ok(Node::new(ast::Argument { + name: arg_def.name.clone(), + value: arg_value, + }))) + }) + .collect::, _>>()?; + if args.is_empty() { + Ok(result) + } else { + Ok(result.with_arguments(args)) + } +} + +fn generate_witness_value( + schema: &ValidFederationSchema, + value_def: &ast::InputValueDefinition, +) -> Result, FederationError> { + // Note: We always generate a non-null value, even if the value's type is nullable. + let value = match value_def.ty.as_ref() { + executable::Type::Named(type_name) | executable::Type::NonNullNamed(type_name) => { + let type_pos = schema.get_type(type_name.clone())?; + match type_pos { + TypeDefinitionPosition::Scalar(scalar_type_pos) => { + match scalar_type_pos.type_name.as_str() { + "Int" => ast::Value::Int(0.into()), + #[allow(clippy::approx_constant)] + "Float" => ast::Value::Float((3.14).into()), + "Boolean" => ast::Value::Boolean(true), + "String" => ast::Value::String("A string value".to_string()), + // Users probably expect a particular format of ID at any particular place, + // but we have zero info on the context, so we just throw a string that + // hopefully make things clear. + "ID" => ast::Value::String("".to_string()), + // It's a custom scalar, but we don't know anything about that scalar so + // providing some random string. This will technically probably not be a + // valid value for that scalar, but hopefully that won't be enough to throw + // users off. + _ => ast::Value::String("".to_string()), + } + } + TypeDefinitionPosition::Enum(enum_type_pos) => { + let enum_type = enum_type_pos.get(schema.schema())?; + let Some((first_value, _)) = enum_type.values.first() else { + bail!("generate_witness_value: enum type has no values"); + }; + ast::Value::Enum(first_value.clone()) + } + TypeDefinitionPosition::InputObject(input_object_type_pos) => { + let object_type = input_object_type_pos.get(schema.schema())?; + let fields = object_type + .fields + .iter() + .filter_map(|(field_name, field_def)| { + // We don't bother with non-mandatory fields. + if !field_def.is_required() { + return None; + } + + let field_value = match generate_witness_value(schema, field_def) { + Ok(value) => value, + Err(e) => { + return Some(Err(e)); + } + }; + Some(Ok((field_name.clone(), field_value))) + }) + .collect::, _>>()?; + ast::Value::Object(fields) + } + _ => bail!("generate_witness_value: unexpected value type"), + } + } + executable::Type::List(_item_type) | executable::Type::NonNullList(_item_type) => { + ast::Value::List(vec![]) + } + }; + Ok(Node::new(value)) +} + +fn display_reasons(reasons: &[Unadvanceables]) -> String { + let mut by_subgraph = MultiIndexMap::new(); + for reason in reasons { + for unadvanceable in reason.iter() { + by_subgraph.insert(unadvanceable.source_subgraph(), unadvanceable) + } + } + by_subgraph + .iter() + .filter_map(|(subgraph, reasons)| { + let (first, rest) = reasons.split_first()?; + let details = if rest.is_empty() { + format!(r#" {}."#, first.details()) + } else { + // We put all the reasons into a set because it's possible multiple paths of the + // algorithm had the same "dead end". Typically, without this, there is cases where we + // end up with multiple "cannot find field x" messages (for the same "x"). + let all_details = reasons + .iter() + .map(|reason| reason.details()) + .collect::>(); + let mut formatted_details = vec!["".to_string()]; // to add a newline + formatted_details + .extend(all_details.iter().map(|details| format!(" - {details}."))); + formatted_details.join("\n") + }; + Some(format!(r#"- from subgraph "{subgraph}":{details}"#)) + }) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use apollo_compiler::parser::Parser; + use insta::assert_snapshot; + use petgraph::graph::NodeIndex; + use petgraph::visit::EdgeRef; + + use super::*; + use crate::query_graph::QueryGraph; + use crate::query_graph::build_query_graph::build_query_graph; + use crate::query_graph::condition_resolver::ConditionResolution; + use crate::schema::position::SchemaRootDefinitionKind; + + // A helper function that enumerates transition paths. + fn build_graph_paths( + query_graph: &Arc, + op_kind: SchemaRootDefinitionKind, + depth_limit: usize, + ) -> Result, FederationError> { + let nodes_by_kind = query_graph.root_kinds_to_nodes()?; + let root_node_idx = nodes_by_kind[&op_kind]; + let curr_graph_path = TransitionGraphPath::new(query_graph.clone(), root_node_idx)?; + build_graph_paths_recursive(query_graph, curr_graph_path, root_node_idx, depth_limit) + } + + fn build_graph_paths_recursive( + query_graph: &Arc, + curr_path: TransitionGraphPath, + curr_node_idx: NodeIndex, + depth_limit: usize, + ) -> Result, FederationError> { + if depth_limit == 0 { + return Ok(vec![]); + } + + let mut paths = vec![curr_path.clone()]; + for edge_ref in query_graph.out_edges(curr_node_idx) { + let edge = edge_ref.weight(); + match &edge.transition { + QueryGraphEdgeTransition::FieldCollection { .. } + | QueryGraphEdgeTransition::Downcast { .. } => { + let new_path = curr_path + .add( + edge.transition.clone(), + edge_ref.id(), + trivial_condition(), + None, + ) + .expect("adding edge to path"); + + // Recursively build paths from the new node + let new_paths = build_graph_paths_recursive( + query_graph, + new_path, + edge_ref.target(), + depth_limit - 1, + )?; + paths.extend(new_paths); + } + _ => {} + } + } + Ok(paths) + } + + fn trivial_condition() -> ConditionResolution { + ConditionResolution::Satisfied { + cost: 0.0, + path_tree: None, + context_map: None, + } + } + + fn parse_schema(schema_and_operation: &str) -> ValidFederationSchema { + let schema = Parser::new() + .parse_schema(schema_and_operation, "test.graphql") + .expect("parsing schema") + .validate() + .expect("validating schema"); + ValidFederationSchema::new(schema).expect("creating valid federation schema") + } + + #[test] + fn test_build_witness_operation() { + let schema_str = r#" + type Query + { + t: T + i: I + } + + interface I + { + id: ID! + } + + enum E { A B C } + + input MyInput { + intInput: Int! + enumInput: E! + optionalInput: String + } + + type T implements I + { + id: ID! + someField( + numArg: Int!, floatArg: Float!, strArg: String!, boolArg: Boolean!, + listArg: [Int!]!, enumArg: E!, myInputArg: MyInput!, optionalArg: String + ): String + } + "#; + + let schema = parse_schema(schema_str); + let query_graph = Arc::new( + build_query_graph("test".into(), schema.clone(), Default::default()) + .expect("building query graph"), + ); + let result: Vec<_> = build_graph_paths(&query_graph, SchemaRootDefinitionKind::Query, 3) + .expect("building graph paths") + .iter() + .filter(|path| path.iter().count() > 0) + .map(|path| { + let witness = build_witness_operation(path).expect("building witness operation"); + format!("{path}: {witness}") + }) + .collect(); + assert_snapshot!(result.join("\n\n"), @r###" + Query(test) --[t]--> T(test) (types: [T]): { + t { + ... + } + } + + Query(test) --[t]--> T(test) --[id]--> ID(test): { + t { + id + } + } + + Query(test) --[t]--> T(test) --[someField]--> String(test): { + t { + someField(boolArg: true, enumArg: A, floatArg: 3.14, listArg: [], myInputArg: {enumInput: A, intInput: 0}, numArg: 0, strArg: "A string value") + } + } + + Query(test) --[t]--> T(test) --[__typename]--> String(test): { + t { + __typename + } + } + + Query(test) --[i]--> I(test) (types: [T]): { + i { + ... + } + } + + Query(test) --[i]--> I(test) --[... on T]--> T(test) (types: [T]): { + i { + ... on T { + ... + } + } + } + + Query(test) --[__typename]--> String(test): { + __typename + } + "###); + } +} diff --git a/apollo-federation/src/composition/satisfiability/validation_context.rs b/apollo-federation/src/composition/satisfiability/validation_context.rs new file mode 100644 index 0000000000..f7c4d76cf2 --- /dev/null +++ b/apollo-federation/src/composition/satisfiability/validation_context.rs @@ -0,0 +1,282 @@ +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; +use itertools::Itertools; + +use crate::bail; +use crate::error::FederationError; +use crate::link::join_spec_definition::JoinSpecDefinition; +use crate::schema::ValidFederationSchema; +use crate::schema::position::CompositeTypeDefinitionPosition; +use crate::schema::position::FieldDefinitionPosition; +use crate::validate_supergraph_for_query_planning; + +pub(super) struct ValidationContext { + supergraph_schema: ValidFederationSchema, + join_spec: &'static JoinSpecDefinition, + join_type_directive: Node, + join_field_directive: Node, + types_to_contexts: IndexMap>, // mapping from type name to context names +} + +impl ValidationContext { + pub(super) fn new(supergraph_schema: ValidFederationSchema) -> Result { + let (_, join_spec, context_spec) = + validate_supergraph_for_query_planning(&supergraph_schema)?; + let join_type_directive = join_spec + .type_directive_definition(&supergraph_schema)? + .clone(); + let join_field_directive = join_spec + .field_directive_definition(&supergraph_schema)? + .clone(); + + let mut types_to_contexts = IndexMap::default(); + if let Some(context_spec) = context_spec { + let context_applications = + supergraph_schema.context_directive_applications_in_supergraph(context_spec)?; + for app in context_applications { + let app = app?; + let mut type_names = vec![app.target().type_name().clone()]; + match app.target() { + CompositeTypeDefinitionPosition::Interface(interface_type) => { + type_names.extend( + supergraph_schema + .all_implementation_types(interface_type)? + .iter() + .map(|type_pos| type_pos.type_name()) + .cloned(), + ); + } + CompositeTypeDefinitionPosition::Union(union_type) => { + let union_def = union_type.get(supergraph_schema.schema())?; + type_names.extend(union_def.members.iter().map(|m| m.name.clone())); + } + _ => {} + }; + for type_name in type_names { + types_to_contexts + .entry(type_name) + .or_insert_with(IndexSet::default) + .insert(app.arguments().name.to_string()); + } + } + } + + Ok(ValidationContext { + supergraph_schema, + join_spec, + join_type_directive, + join_field_directive, + types_to_contexts, + }) + } + + pub(super) fn is_shareable( + &self, + field: &FieldDefinitionPosition, + ) -> Result { + let Ok(type_in_supergraph) = self + .supergraph_schema + .get_type(field.parent().type_name().clone()) + else { + bail!("Type {} should exist in the supergraph", field.parent()); + }; + let Ok(type_in_supergraph) = CompositeTypeDefinitionPosition::try_from(type_in_supergraph) + else { + bail!("Type {} should be composite", field.parent().type_name()); + }; + if !type_in_supergraph.is_object_type() { + return Ok(false); + } + + let Ok(field_in_supergraph) = type_in_supergraph.field(field.field_name().clone()) else { + bail!( + "Field {} should exist in the supergraph", + field.field_name() + ); + }; + let join_field_apps = field_in_supergraph + .get_applied_directives(&self.supergraph_schema, &self.join_field_directive.name); + // A field is shareable if either: + // 1) there is not join__field, but multiple join__type + // 2) there is more than one join__field where the field is neither external nor overridden. + if join_field_apps.is_empty() { + let join_type_apps = type_in_supergraph + .get_applied_directives(&self.supergraph_schema, &self.join_type_directive.name); + Ok(join_type_apps.len() > 1) + } else { + let count = join_field_apps + .iter() + .map(|app| self.join_spec.field_directive_arguments(app)) + .process_results(|iter| { + iter.filter(|args| { + !(args.external.is_some_and(|x| x)) + && !(args.user_overridden.is_some_and(|x| x)) + }) + .count() + })?; + Ok(count > 1) + } + } + + pub(super) fn matching_contexts(&self, type_name: &Name) -> Option<&IndexSet> { + self.types_to_contexts.get(type_name) + } +} + +#[cfg(test)] +mod validation_context_tests { + use apollo_compiler::Name; + + use crate::composition::satisfiability::validation_context::ValidationContext; + use crate::composition::satisfiability::*; + use crate::schema::position::CompositeTypeDefinitionPosition; + + const TEST_SUPERGRAPH: &str = r#" +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar context__ContextFieldValue + +interface I + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id") + @context(name: "A__contextI") +{ + id: ID! + value: Int! @join__field(graph: A) +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + A @join__graph(name: "A", url: "http://A") + B @join__graph(name: "B", url: "http://B") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type P + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id") +{ + id: ID! + data: String! @join__field(graph: A, contextArguments: [{context: "A__contextI", name: "onlyInA", type: "Int", selection: " { value }"}]) +} + +type Query + @join__type(graph: A) + @join__type(graph: B) +{ + start: I! @join__field(graph: B) +} + +type T implements I + @join__implements(graph: A, interface: "I") + @join__implements(graph: B, interface: "I") + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id") +{ + id: ID! + value: Int! @join__field(graph: A) + onlyInA: Int! @join__field(graph: A) + p: P! @join__field(graph: A) + sharedField: Int! + onlyInB: Int! @join__field(graph: B) +} + "#; + + fn is_shareable_field(context: &ValidationContext, type_name: &str, field_name: &str) -> bool { + let supergraph_schema = &context.supergraph_schema; + let type_pos = supergraph_schema + .get_type(Name::new_unchecked(type_name)) + .unwrap(); + let type_pos = CompositeTypeDefinitionPosition::try_from(type_pos).unwrap(); + let field_pos = type_pos.field(Name::new_unchecked(field_name)).unwrap(); + context.is_shareable(&field_pos).unwrap() + } + + #[test] + fn test_is_shareable() { + let supergraph = Supergraph::parse(TEST_SUPERGRAPH).unwrap(); + let supergraph_schema = + ValidFederationSchema::new(supergraph.state.schema().clone()).unwrap(); + let context = ValidationContext::new(supergraph_schema).unwrap(); + + assert!(is_shareable_field(&context, "P", "id")); + assert!(!is_shareable_field(&context, "P", "data")); + assert!(is_shareable_field(&context, "T", "sharedField")); + assert!(!is_shareable_field(&context, "T", "onlyInB")); + } + + fn matching_contexts<'a>( + context: &'a ValidationContext, + type_name: &str, + ) -> Option> { + context + .matching_contexts(&Name::new_unchecked(type_name)) + .map(|set| set.iter().map(|s| s.as_str()).collect()) + } + + #[test] + fn test_matching_contexts() { + let supergraph = Supergraph::parse(TEST_SUPERGRAPH).unwrap(); + let supergraph_schema = + ValidFederationSchema::new(supergraph.state.schema().clone()).unwrap(); + let context = ValidationContext::new(supergraph_schema).unwrap(); + + assert_eq!(matching_contexts(&context, "I"), Some(vec!["A__contextI"]),); + assert_eq!(matching_contexts(&context, "T"), Some(vec!["A__contextI"]),); + assert_eq!(matching_contexts(&context, "P"), None,); + } +} diff --git a/apollo-federation/src/composition/satisfiability/validation_state.rs b/apollo-federation/src/composition/satisfiability/validation_state.rs new file mode 100644 index 0000000000..ad5578174b --- /dev/null +++ b/apollo-federation/src/composition/satisfiability/validation_state.rs @@ -0,0 +1,463 @@ +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::fmt::Display; +use std::sync::Arc; + +use apollo_compiler::Name; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; +use either::Either; +use itertools::Itertools; +use petgraph::graph::EdgeIndex; +use petgraph::visit::EdgeRef; + +use crate::bail; +use crate::composition::satisfiability::satisfiability_error::satisfiability_error; +use crate::composition::satisfiability::satisfiability_error::shareable_field_mismatched_runtime_types_hint; +use crate::composition::satisfiability::satisfiability_error::shareable_field_non_intersecting_runtime_types_error; +use crate::composition::satisfiability::validation_context::ValidationContext; +use crate::ensure; +use crate::error::CompositionError; +use crate::error::FederationError; +use crate::query_graph::OverrideConditions; +use crate::query_graph::QueryGraph; +use crate::query_graph::QueryGraphEdgeTransition; +use crate::query_graph::QueryGraphNodeType; +use crate::query_graph::condition_resolver::ConditionResolution; +use crate::query_graph::condition_resolver::ConditionResolver; +use crate::query_graph::graph_path::UnadvanceableClosures; +use crate::query_graph::graph_path::Unadvanceables; +use crate::query_graph::graph_path::transition::TransitionGraphPath; +use crate::query_graph::graph_path::transition::TransitionPathWithLazyIndirectPaths; +use crate::schema::position::AbstractTypeDefinitionPosition; +use crate::schema::position::SchemaRootDefinitionKind; +use crate::supergraph::CompositionHint; +use crate::utils::iter_into_single_item; + +pub(super) struct ValidationState { + /// Path in the supergraph (i.e. the API schema query graph) corresponding to the current state. + supergraph_path: TransitionGraphPath, + /// All the possible paths we could be in the subgraphs (excluding @provides paths). + subgraph_paths: Vec, + /// When we encounter a supergraph field with a progressive override (i.e. an @override with a + /// label condition), we consider both possibilities for the label value (T/F) as we traverse + /// the graph, and record that here. This allows us to exclude paths that can never be taken by + /// the query planner (i.e. a path where the condition is T in one case and F in another). + selected_override_conditions: Arc, +} + +pub(super) struct SubgraphPathInfo { + path: TransitionPathWithLazyIndirectPaths, + contexts: SubgraphPathContexts, +} + +/// A map from context names to information about their match in the subgraph path, if it exists. +/// This is a `BTreeMap` to support `Hash`, as this is used in keys in maps. +type SubgraphPathContexts = Arc>; + +#[derive(Clone, PartialEq, Eq, Hash)] +struct SubgraphPathContextInfo { + subgraph_name: Arc, + type_name: Name, +} + +#[derive(PartialEq, Eq, Hash)] +pub(super) struct SubgraphContextKey { + tail_subgraph_name: Arc, + contexts: SubgraphPathContexts, +} + +impl ValidationState { + pub(super) fn supergraph_path(&self) -> &TransitionGraphPath { + &self.supergraph_path + } + + pub(super) fn subgraph_paths(&self) -> &Vec { + &self.subgraph_paths + } + + pub(super) fn selected_override_conditions(&self) -> &Arc { + &self.selected_override_conditions + } + + // PORT_NOTE: Named `initial()` in the JS codebase, but conventionally in Rust this kind of + // constructor is named `new()`. + pub(super) fn new( + api_schema_query_graph: Arc, + federated_query_graph: Arc, + root_kind: SchemaRootDefinitionKind, + ) -> Result { + let Some(federated_root_node) = + federated_query_graph.root_kinds_to_nodes()?.get(&root_kind) + else { + bail!( + "The supergraph shouldn't have a {} root if no subgraphs have one", + root_kind + ); + }; + let federated_root_node_weight = federated_query_graph.node_weight(*federated_root_node)?; + ensure!( + federated_root_node_weight.type_ == QueryGraphNodeType::FederatedRootType(root_kind), + "Unexpected node type {} for federated query graph root (expected {})", + federated_root_node_weight.type_, + QueryGraphNodeType::FederatedRootType(root_kind), + ); + let initial_subgraph_path = + TransitionGraphPath::from_graph_root(federated_query_graph.clone(), root_kind)?; + Ok(Self { + supergraph_path: TransitionGraphPath::from_graph_root( + api_schema_query_graph, + root_kind, + )?, + subgraph_paths: federated_query_graph + .out_edges(*federated_root_node) + .into_iter() + .map(|edge_ref| { + let path = initial_subgraph_path.add( + QueryGraphEdgeTransition::SubgraphEnteringTransition, + edge_ref.id(), + ConditionResolution::no_conditions(), + None, + )?; + Ok::<_, FederationError>(SubgraphPathInfo { + path: TransitionPathWithLazyIndirectPaths::new(Arc::new(path)), + contexts: Default::default(), + }) + }) + .process_results(|iter| iter.collect())?, + selected_override_conditions: Default::default(), + }) + } + + /// Validates that the current state can always be advanced for the provided supergraph edge, + /// and returns the updated state if so. + /// + /// If the state cannot be properly advanced, then an error will be pushed onto the provided + /// `errors` array, nothing will be pushed onto the provided `hints` array, and no updated state + /// will be returned. Otherwise, nothing will be pushed onto the `errors` array, a hint may be + /// pushed onto the `hints` array, and an updated state will be returned except for the case + /// where the transition is guaranteed to yield no results (in which case no state is returned). + /// This exception occurs when the edge corresponds to a type condition that does not intersect + /// with the possible runtime types of the old path's tail, in which case further validation on + /// the new path is not necessary. + pub(super) fn validate_transition( + &mut self, + context: &ValidationContext, + supergraph_edge: EdgeIndex, + matching_contexts: &IndexSet, + condition_resolver: &mut impl ConditionResolver, + errors: &mut Vec, + hints: &mut Vec, + ) -> Result, FederationError> { + let edge_weight = self.supergraph_path.graph().edge_weight(supergraph_edge)?; + ensure!( + edge_weight.conditions.is_none(), + "Supergraph edges should not have conditions ({})", + edge_weight, + ); + let (_, transition_tail) = self + .supergraph_path + .graph() + .edge_endpoints(supergraph_edge)?; + let transition_tail_weight = self.supergraph_path.graph().node_weight(transition_tail)?; + let QueryGraphNodeType::SchemaType(target_type) = &transition_tail_weight.type_ else { + bail!("Unexpectedly encountered federation root node as tail node."); + }; + let new_override_conditions = + if let Some(override_condition) = &edge_weight.override_condition { + let mut conditions = self.selected_override_conditions.as_ref().clone(); + conditions.insert( + override_condition.label.clone(), + override_condition.condition, + ); + Arc::new(conditions) + } else { + self.selected_override_conditions.clone() + }; + + let mut new_subgraph_paths: Vec = Default::default(); + let mut dead_ends: Vec = Default::default(); + for SubgraphPathInfo { path, contexts } in self.subgraph_paths.iter_mut() { + let options = path.advance_with_transition( + &edge_weight.transition, + target_type, + self.supergraph_path.graph().schema()?, + condition_resolver, + &new_override_conditions, + )?; + let options = match options { + Either::Left(options) => options, + Either::Right(closures) => { + dead_ends.push(closures); + continue; + } + }; + if options.is_empty() { + // This means that the edge is a type condition and that if we follow the path in + // this subgraph, we're guaranteed that handling that type condition give us no + // matching results, and so we can skip this supergraph path entirely. + return Ok(None); + } + let new_contexts = if matching_contexts.is_empty() { + contexts.clone() + } else { + let tail_weight = path.path.graph().node_weight(path.path.tail())?; + let tail_subgraph = tail_weight.source.clone(); + let QueryGraphNodeType::SchemaType(tail_type) = &tail_weight.type_ else { + bail!("Unexpectedly encountered federation root node as tail node."); + }; + let mut contexts = contexts.as_ref().clone(); + for matching_context in matching_contexts { + contexts.insert( + matching_context.clone(), + SubgraphPathContextInfo { + subgraph_name: tail_subgraph.clone(), + type_name: tail_type.type_name().clone(), + }, + ); + } + Arc::new(contexts) + }; + new_subgraph_paths.extend(options.into_iter().map(|option| SubgraphPathInfo { + path: option, + contexts: new_contexts.clone(), + })) + } + let new_supergraph_path = self.supergraph_path.add( + edge_weight.transition.clone(), + supergraph_edge, + ConditionResolution::no_conditions(), + None, + )?; + if new_subgraph_paths.is_empty() { + satisfiability_error( + &new_supergraph_path, + &self + .subgraph_paths + .iter() + .map(|path| path.path.path.as_ref()) + .collect::>(), + &dead_ends + .into_iter() + .map(Unadvanceables::try_from) + .process_results(|iter| iter.collect::>())?, + errors, + )?; + return Ok(None); + } + + let updated_state = ValidationState { + supergraph_path: new_supergraph_path, + subgraph_paths: new_subgraph_paths, + selected_override_conditions: new_override_conditions, + }; + + // When handling a @shareable field, we also compare the set of runtime types for each of + // the subgraphs involved. If there is no common intersection between those sets, then we + // record an error: a @shareable field should resolve the same way in all the subgraphs in + // which it is resolved, and there is no way this can be true if each subgraph returns + // runtime objects that we know can never be the same. + // + // Additionally, if those sets of runtime types are not the same, we let it compose, but we + // log a warning. Indeed, having different runtime types is a red flag: it would be + // incorrect for a subgraph to resolve to an object of a type that the other subgraph cannot + // possibly return, so having some subgraph having types that the other doesn't know feels + // like something worth double-checking on the user side. Of course, as long as there is + // some runtime types intersection and the field resolvers only return objects of that + // intersection, then this could be a valid implementation. And this case can in particular + // happen temporarily as subgraphs evolve (potentially independently), but it is well worth + // warning in general. + + // Note that we ignore any path where the type is not an abstract type, because in practice + // this means an @interfaceObject and this should not be considered as an implementation + // type. Besides, @interfaceObject types always "stand-in" for every implementation type so + // they're never a problem for this check and can be ignored. + if updated_state.subgraph_paths.len() < 2 { + return Ok(Some(updated_state)); + } + let QueryGraphEdgeTransition::FieldCollection { + field_definition_position, + .. + } = &edge_weight.transition + else { + return Ok(Some(updated_state)); + }; + let new_supergraph_path_tail_weight = updated_state + .supergraph_path + .graph() + .node_weight(updated_state.supergraph_path.tail())?; + let QueryGraphNodeType::SchemaType(new_supergraph_path_tail_type) = + &new_supergraph_path_tail_weight.type_ + else { + bail!("Unexpectedly encountered federation root node as tail node."); + }; + if AbstractTypeDefinitionPosition::try_from(new_supergraph_path_tail_type.clone()).is_err() + { + return Ok(Some(updated_state)); + } + if !context.is_shareable(field_definition_position)? { + return Ok(Some(updated_state)); + } + let filtered_paths_count = updated_state + .subgraph_paths + .iter() + .map(|path| { + let path_tail_weight = path.path.path.graph().node_weight(path.path.path.tail())?; + let QueryGraphNodeType::SchemaType(path_tail_type) = &path_tail_weight.type_ else { + bail!("Unexpectedly encountered federation root node as tail node."); + }; + Ok::<_, FederationError>(path_tail_type) + }) + .process_results(|iter| { + iter.filter(|type_pos| { + AbstractTypeDefinitionPosition::try_from((*type_pos).clone()).is_ok() + }) + .count() + })?; + if filtered_paths_count < 2 { + return Ok(Some(updated_state)); + } + + // We start our intersection by using all the supergraph path types, both because it's a + // convenient "max" set to start our intersection, but also because that means we will + // ignore @inaccessible types in our checks (which is probably not very important because + // I believe the rules of @inaccessible kind of exclude having them here, but if that ever + // changes, it makes more sense this way). + let all_runtime_types = BTreeSet::from_iter( + updated_state + .supergraph_path() + .runtime_types_of_tail() + .iter() + .map(|type_pos| type_pos.type_name.clone()), + ); + let mut intersection = all_runtime_types.clone(); + + let mut runtime_types_to_subgraphs: IndexMap>, IndexSet>> = + Default::default(); + let mut runtime_types_per_subgraphs: IndexMap, Arc>> = + Default::default(); + let mut has_all_empty = true; + for new_subgraph_path in updated_state.subgraph_paths.iter() { + let new_subgraph_path_tail_weight = new_subgraph_path + .path + .path + .graph() + .node_weight(new_subgraph_path.path.path.tail())?; + let subgraph = &new_subgraph_path_tail_weight.source; + let type_names = Arc::new(BTreeSet::from_iter( + new_subgraph_path + .path + .path + .runtime_types_of_tail() + .iter() + .map(|type_pos| type_pos.type_name.clone()), + )); + + // If we see a type here that is not included in the list of all runtime types, it is + // safe to assume that it is an interface behaving like a runtime type (i.e. an + // @interfaceObject) and we should allow it to stand in for any runtime type. + if let Some(type_name) = iter_into_single_item(type_names.iter()) + && !all_runtime_types.contains(type_name) + { + continue; + } + runtime_types_per_subgraphs.insert(subgraph.clone(), type_names.clone()); + // PORT_NOTE: The JS code couldn't really use sets as map keys, so it instead used the + // formatted output text as the map key. We instead use a `BTreeSet`, and move + // the formatting logic into `shareable_field_non_intersecting_runtime_types_error()`. + runtime_types_to_subgraphs + .entry(type_names.clone()) + .or_default() + .insert(subgraph.clone()); + if !type_names.is_empty() { + has_all_empty = false; + } + intersection.retain(|type_name| type_names.contains(type_name)); + } + + // If `has_all_empty` is true, then it means that none of the subgraphs define any runtime + // types. If this occurs, typically all subgraphs define a given interface, but none have + // implementations. In that case, the intersection will be empty, but it's actually fine + // (which is why we special case this). In fact, assuming valid GraphQL subgraph servers + // (and it's not the place to sniff for non-compliant subgraph servers), the only value to + // which each subgraph can resolve is `null` and so that essentially guarantees that all + // subgraphs do resolve the same way. + if !has_all_empty { + if intersection.is_empty() { + shareable_field_non_intersecting_runtime_types_error( + &updated_state, + field_definition_position, + &runtime_types_to_subgraphs, + errors, + )?; + return Ok(None); + } + + // As we said earlier, we accept it if there's an intersection, but if the runtime types + // are not all the same, we still emit a warning to make it clear that the fields should + // not resolve any of the types not in the intersection. + if runtime_types_to_subgraphs.len() > 1 { + shareable_field_mismatched_runtime_types_hint( + &updated_state, + field_definition_position, + &intersection, + &runtime_types_per_subgraphs, + hints, + )?; + } + } + + Ok(Some(updated_state)) + } + + pub(super) fn current_subgraph_names(&self) -> Result>, FederationError> { + self.subgraph_paths + .iter() + .map(|path_info| { + Ok(path_info + .path + .path + .graph() + .node_weight(path_info.path.path.tail())? + .source + .clone()) + }) + .process_results(|iter| iter.collect()) + } + + pub(super) fn current_subgraph_context_keys( + &self, + ) -> Result, FederationError> { + self.subgraph_paths + .iter() + .map(|path_info| { + Ok(SubgraphContextKey { + tail_subgraph_name: path_info + .path + .path + .graph() + .node_weight(path_info.path.path.tail())? + .source + .clone(), + contexts: path_info.contexts.clone(), + }) + }) + .process_results(|iter| iter.collect()) + } +} + +impl Display for ValidationState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.supergraph_path.fmt(f)?; + write!(f, " <=> ")?; + let mut iter = self.subgraph_paths.iter(); + if let Some(first_path_info) = iter.next() { + first_path_info.path.fmt(f)?; + for path_info in iter { + write!(f, ", ")?; + path_info.path.fmt(f)?; + } + } + Ok(()) + } +} diff --git a/apollo-federation/src/composition/satisfiability/validation_traversal.rs b/apollo-federation/src/composition/satisfiability/validation_traversal.rs new file mode 100644 index 0000000000..38bb6a9977 --- /dev/null +++ b/apollo-federation/src/composition/satisfiability/validation_traversal.rs @@ -0,0 +1,311 @@ +use std::borrow::Cow; +use std::sync::Arc; + +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; +use petgraph::graph::EdgeIndex; +use petgraph::graph::NodeIndex; +use tracing::debug; +use tracing::debug_span; + +use crate::composition::satisfiability::validation_context::ValidationContext; +use crate::composition::satisfiability::validation_state::SubgraphContextKey; +use crate::composition::satisfiability::validation_state::ValidationState; +use crate::error::CompositionError; +use crate::error::FederationError; +use crate::merger::merge::CompositionOptions; +use crate::operation::SelectionSet; +use crate::query_graph::OverrideConditions; +use crate::query_graph::QueryGraph; +use crate::query_graph::QueryGraphEdgeTransition; +use crate::query_graph::condition_resolver::CachingConditionResolver; +use crate::query_graph::condition_resolver::ConditionResolution; +use crate::query_graph::condition_resolver::ConditionResolverCache; +use crate::query_graph::graph_path::ExcludedConditions; +use crate::query_graph::graph_path::ExcludedDestinations; +use crate::query_graph::graph_path::operation::OpGraphPathContext; +use crate::schema::ValidFederationSchema; +use crate::supergraph::CompositionHint; + +pub(super) struct ValidationTraversal { + top_level_condition_resolver: TopLevelConditionResolver, + /// The stack of non-terminal states left to traverse. + stack: Vec, + /// The previous visits for a node in the API schema query graph. + previous_visits: IndexMap>, + validation_errors: Vec, + validation_hints: Vec, + context: ValidationContext, + total_validation_subgraph_paths: usize, + max_validation_subgraph_paths: usize, +} + +struct TopLevelConditionResolver { + /// The federated query graph for the supergraph schema. + query_graph: Arc, + /// The cache for top-level condition resolution. + condition_resolver_cache: ConditionResolverCache, +} + +/// When we visit a node in the API schema query graph, we keep track of any information about the +/// simultaneous subgraph paths that may affect what options are available downstream. This is +/// currently: +/// 1. For each subgraph path, the subgraph of the path's tail along with the types and subgraphs of +/// any context matches encountered along that path. +/// 2. Any progressive override labels that we've assumed the value of while taking the API schema +/// query graph path. +/// +/// If we ever re-visit the node with at least more options than a prior visit while making at least +/// as many assumptions, then we know we can skip re-visiting. +struct NodeVisit { + subgraph_context_keys: IndexSet, + override_conditions: Arc, +} + +impl NodeVisit { + /// Determines if this visit is a non-strict superset of the `other` visit, meaning that this + /// visit has at least as many options as the `other` visit while making at least as many + /// assumptions. + // PORT_NOTE: Named `isSupersetOrEqual()` in the JS codebase, but supersets are by default + // non-strict and Rust typically names such methods as `is_superset()`. + fn is_superset(&self, other: &NodeVisit) -> bool { + self.subgraph_context_keys + .is_superset(&other.subgraph_context_keys) + && other + .override_conditions + .iter() + .all(|(label, is_enabled)| self.override_conditions.get(label) == Some(is_enabled)) + } +} + +impl ValidationTraversal { + const DEFAULT_MAX_VALIDATION_SUBGRAPH_PATHS: usize = 1_000_000; + + pub(super) fn new( + supergraph_schema: ValidFederationSchema, + api_schema_query_graph: Arc, + federated_query_graph: Arc, + composition_options: &CompositionOptions, + ) -> Result { + let mut validation_traversal = Self { + top_level_condition_resolver: TopLevelConditionResolver { + query_graph: federated_query_graph.clone(), + condition_resolver_cache: ConditionResolverCache::new(), + }, + stack: vec![], + previous_visits: Default::default(), + validation_errors: vec![], + validation_hints: vec![], + context: ValidationContext::new(supergraph_schema)?, + total_validation_subgraph_paths: 0, + max_validation_subgraph_paths: composition_options + .max_validation_subgraph_paths + .unwrap_or(Self::DEFAULT_MAX_VALIDATION_SUBGRAPH_PATHS), + }; + for kind in api_schema_query_graph.root_kinds_to_nodes()?.keys() { + validation_traversal.push_stack(ValidationState::new( + api_schema_query_graph.clone(), + federated_query_graph.clone(), + *kind, + )?); + } + Ok(validation_traversal) + } + + fn push_stack(&mut self, state: ValidationState) -> Option { + self.total_validation_subgraph_paths += state.subgraph_paths().len(); + self.stack.push(state); + if self.total_validation_subgraph_paths > self.max_validation_subgraph_paths { + Some(CompositionError::MaxValidationSubgraphPathsExceeded { + message: format!( + "Maximum number of validation subgraph paths exceeded: {}", + self.total_validation_subgraph_paths + ), + }) + } else { + None + } + } + + fn pop_stack(&mut self) -> Option { + if let Some(state) = self.stack.pop() { + self.total_validation_subgraph_paths -= state.subgraph_paths().len(); + Some(state) + } else { + None + } + } + + pub(super) fn validate( + &mut self, + errors: &mut Vec, + hints: &mut Vec, + ) -> Result<(), FederationError> { + while let Some(state) = self.pop_stack() { + if let Some(error) = self.handle_state(state)? { + // Certain errors during satisfiability can cause the algorithm to abort to avoid + // resource exhaustion; when this occurs, we only report that specific error. + errors.push(error); + hints.append(&mut self.validation_hints); + return Ok(()); + } + } + errors.append(&mut self.validation_errors); + hints.append(&mut self.validation_hints); + Ok(()) + } + + fn handle_state( + &mut self, + mut state: ValidationState, + ) -> Result, FederationError> { + debug!( + "Validation: {} open states. Validating {}", + self.stack.len() + 1, + state, + ); + let span = debug_span!(" |"); + let guard = span.enter(); + let current_node = state.supergraph_path().tail(); + let current_visit = NodeVisit { + subgraph_context_keys: state.current_subgraph_context_keys()?, + override_conditions: state.selected_override_conditions().clone(), + }; + let previous_visits_for_node = self.previous_visits.entry(current_node).or_default(); + for previous_visit in previous_visits_for_node.iter() { + if current_visit.is_superset(previous_visit) { + // This means that we've already seen the type and subgraph we're currently on in + // the supergraph, and for that previous visit, we've either finished validating we + // could reach anything from there, or are in the middle of it (in which case, we're + // in a loop). Since we have at least as many options while making at least as many + // assumptions as the previous visit, we can handle downstream operation elements + // the same way we did previously, and so we skip the node entirely. + drop(guard); + debug!("Have already validated this node."); + return Ok(None); + } + } + // We have to validate this node, but we can save the visit here to potentially avoid later + // visits. + previous_visits_for_node.push(current_visit); + + // Pre-collect the next edges for `state.supergraph_path()`, since as we iterate through + // these edges, we're going to be mutating the cache in `state.subgraph_paths()`. + // + // Note that if the `supergraph_path()` is terminal, this method is a no-op, which is + // expected/desired as it means we've successfully "validated" a path to its end. + let edges = state.supergraph_path().next_edges()?.collect::>(); + for edge in edges { + let edge_weight = state.supergraph_path().graph().edge_weight(edge)?; + let mut edge_head_type_name = None; + if let QueryGraphEdgeTransition::FieldCollection { + field_definition_position, + .. + } = &edge_weight.transition + { + if field_definition_position.is_introspection_typename_field() { + // There is no point in validating __typename edges, since we know we can always + // get those. + continue; + } else { + // If this edge is a field, then later we'll need the field's parent type. + edge_head_type_name = Some(field_definition_position.type_name()); + } + } + + // `selected_override_conditions()` indicates the labels (and their respective + // conditions) that we've selected/assumed so far in our traversal (i.e. "foo" -> true). + // There's no need to validate edges that share the same label with the opposite + // condition since they're unreachable during query planning. + if let Some(override_condition) = &edge_weight.override_condition + && state + .selected_override_conditions() + .contains_key(&override_condition.label) + && !override_condition.check(state.selected_override_conditions()) + { + debug!( + "Edge {} doesn't satisfy label condition: {}({}), no need to validate further", + edge_weight, + override_condition.label, + state + .selected_override_conditions() + .get(&override_condition.label) + .map_or("unset".to_owned(), |x| x.to_string()), + ); + continue; + } + + let matching_contexts = edge_head_type_name + .and_then(|name| self.context.matching_contexts(name)) + .map(Cow::Borrowed) + .unwrap_or_else(|| Cow::Owned(IndexSet::default())); + + debug!("Validating supergraph edge {}", edge_weight); + let span = debug_span!(" |"); + let guard = span.enter(); + let num_errors = self.validation_errors.len(); + let new_state = state.validate_transition( + &self.context, + edge, + matching_contexts.as_ref(), + &mut self.top_level_condition_resolver, + &mut self.validation_errors, + &mut self.validation_hints, + )?; + if num_errors != self.validation_errors.len() { + drop(guard); + debug!("Validation error!"); + continue; + } + + // The check for `is_terminal()` is not strictly necessary, since if we add a terminal + // state to the stack, then `handle_state()` will do nothing later. But it's worth + // checking it now and saving some memory/cycles. + if let Some(new_state) = new_state + && !new_state + .supergraph_path() + .graph() + .is_terminal(new_state.supergraph_path().tail()) + { + drop(guard); + debug!("Reached new state {}", new_state); + if let Some(error) = self.push_stack(new_state) { + return Ok(Some(error)); + } + continue; + } + drop(guard); + debug!("Reached terminal node/cycle") + } + + Ok(None) + } +} + +impl CachingConditionResolver for TopLevelConditionResolver { + fn query_graph(&self) -> &QueryGraph { + &self.query_graph + } + + fn resolver_cache(&mut self) -> &mut ConditionResolverCache { + &mut self.condition_resolver_cache + } + + fn resolve_without_cache( + &self, + edge: EdgeIndex, + context: &OpGraphPathContext, + excluded_destinations: &ExcludedDestinations, + excluded_conditions: &ExcludedConditions, + extra_conditions: Option<&SelectionSet>, + ) -> Result { + crate::composition::satisfiability::conditions_validation::resolve_condition_plan( + self.query_graph.clone(), + edge, + context, + excluded_destinations, + excluded_conditions, + extra_conditions, + ) + } +} diff --git a/apollo-federation/src/connectors/expand/carryover.rs b/apollo-federation/src/connectors/expand/carryover.rs new file mode 100644 index 0000000000..d15edd0652 --- /dev/null +++ b/apollo-federation/src/connectors/expand/carryover.rs @@ -0,0 +1,670 @@ +mod inputs; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Argument; +use apollo_compiler::ast::Directive; +use apollo_compiler::ast::Value; +use apollo_compiler::collections::HashSet; +use apollo_compiler::name; +use inputs::copy_input_types; +use multimap::MultiMap; + +use crate::connectors::ConnectSpec; +use crate::error::FederationError; +use crate::link::DEFAULT_LINK_NAME; +use crate::link::Link; +use crate::link::inaccessible_spec_definition::INACCESSIBLE_DIRECTIVE_NAME_IN_SPEC; +use crate::link::spec::APOLLO_SPEC_DOMAIN; +use crate::link::spec::Identity; +use crate::schema::FederationSchema; +use crate::schema::position::DirectiveArgumentDefinitionPosition; +use crate::schema::position::DirectiveDefinitionPosition; +use crate::schema::position::EnumTypeDefinitionPosition; +use crate::schema::position::EnumValueDefinitionPosition; +use crate::schema::position::InputObjectFieldDefinitionPosition; +use crate::schema::position::InputObjectTypeDefinitionPosition; +use crate::schema::position::InterfaceFieldArgumentDefinitionPosition; +use crate::schema::position::InterfaceFieldDefinitionPosition; +use crate::schema::position::InterfaceTypeDefinitionPosition; +use crate::schema::position::ObjectFieldArgumentDefinitionPosition; +use crate::schema::position::ObjectFieldDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceFieldDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceFieldDirectivePosition; +use crate::schema::position::ObjectTypeDefinitionPosition; +use crate::schema::position::ScalarTypeDefinitionPosition; +use crate::schema::position::SchemaDefinitionPosition; +use crate::schema::position::UnionTypeDefinitionPosition; +use crate::schema::referencer::DirectiveReferencers; + +const TAG_DIRECTIVE_NAME_IN_SPEC: Name = name!("tag"); +const AUTHENTICATED_DIRECTIVE_NAME_IN_SPEC: Name = name!("authenticated"); +const REQUIRES_SCOPES_DIRECTIVE_NAME_IN_SPEC: Name = name!("requiresScopes"); +const POLICY_DIRECTIVE_NAME_IN_SPEC: Name = name!("policy"); +const COST_DIRECTIVE_NAME_IN_SPEC: Name = name!("cost"); +const LIST_SIZE_DIRECTIVE_NAME_IN_SPEC: Name = name!("listSize"); +const CONTEXT_DIRECTIVE_NAME_IN_SPEC: Name = name!("context"); + +pub(super) fn carryover_directives( + from: &FederationSchema, + to: &mut FederationSchema, + specs: impl Iterator, + subgraph_name_replacements: &MultiMap<&str, String>, +) -> Result<(), FederationError> { + let Some(metadata) = from.metadata() else { + return Ok(()); + }; + + // @join__directive(graph: [], name: "link", args: { url: "https://specs.apollo.dev/connect/v0.1" }) + // this must exist for license key enforcement + for spec in specs { + SchemaDefinitionPosition.insert_directive(to, spec.join_directive_application().into())?; + } + + // @link for connect + if let Some(link) = metadata.for_identity(&ConnectSpec::identity()) { + SchemaDefinitionPosition.insert_directive(to, link.to_directive_application().into())?; + } + + // before copying over directive definitions, we need to ensure we copy over + // any input types (scalars, enums, input objects) they use + copy_input_types(from, to, subgraph_name_replacements)?; + + // @inaccessible + + if let Some(link) = metadata.for_identity(&Identity::inaccessible_identity()) { + let directive_name = link.directive_name_in_schema(&INACCESSIBLE_DIRECTIVE_NAME_IN_SPEC); + from.referencers() + .get_directive(&directive_name) + .and_then(|referencers| { + // because the merge code handles inaccessible, we have to check if the + // @link and directive definition are already present in the schema + if referencers.len() > 0 + && to + .metadata() + .and_then(|m| m.by_identity.get(&Identity::inaccessible_identity())) + .is_none() + { + SchemaDefinitionPosition + .insert_directive(to, link.to_directive_application().into())?; + copy_directive_definition(from, to, directive_name.clone())?; + } + referencers.copy_directives(from, to, &directive_name) + })?; + } + + // @tag + + if let Some(link) = metadata.for_identity(&Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: TAG_DIRECTIVE_NAME_IN_SPEC, + }) { + let directive_name = link.directive_name_in_schema(&TAG_DIRECTIVE_NAME_IN_SPEC); + from.referencers() + .get_directive(&directive_name) + .and_then(|referencers| { + if referencers.len() > 0 { + SchemaDefinitionPosition + .insert_directive(to, link.to_directive_application().into())?; + copy_directive_definition(from, to, directive_name.clone())?; + } + referencers.copy_directives(from, to, &directive_name) + })?; + } + + // @authenticated + + if let Some(link) = metadata.for_identity(&Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: AUTHENTICATED_DIRECTIVE_NAME_IN_SPEC, + }) { + let directive_name = link.directive_name_in_schema(&AUTHENTICATED_DIRECTIVE_NAME_IN_SPEC); + from.referencers() + .get_directive(&directive_name) + .and_then(|referencers| { + if referencers.len() > 0 { + SchemaDefinitionPosition + .insert_directive(to, link.to_directive_application().into())?; + copy_directive_definition(from, to, directive_name.clone())?; + } + referencers.copy_directives(from, to, &directive_name) + })?; + } + + // @requiresScopes + + if let Some(link) = metadata.for_identity(&Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: REQUIRES_SCOPES_DIRECTIVE_NAME_IN_SPEC, + }) { + let directive_name = link.directive_name_in_schema(&REQUIRES_SCOPES_DIRECTIVE_NAME_IN_SPEC); + from.referencers() + .get_directive(&directive_name) + .and_then(|referencers| { + if referencers.len() > 0 { + SchemaDefinitionPosition + .insert_directive(to, link.to_directive_application().into())?; + + copy_directive_definition(from, to, directive_name.clone())?; + } + referencers.copy_directives(from, to, &directive_name) + })?; + } + + // @policy + + if let Some(link) = metadata.for_identity(&Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: POLICY_DIRECTIVE_NAME_IN_SPEC, + }) { + let directive_name = link.directive_name_in_schema(&POLICY_DIRECTIVE_NAME_IN_SPEC); + from.referencers() + .get_directive(&directive_name) + .and_then(|referencers| { + if referencers.len() > 0 { + SchemaDefinitionPosition + .insert_directive(to, link.to_directive_application().into())?; + + copy_directive_definition(from, to, directive_name.clone())?; + } + referencers.copy_directives(from, to, &directive_name) + })?; + } + + // @cost + + if let Some(link) = metadata.for_identity(&Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: COST_DIRECTIVE_NAME_IN_SPEC, + }) { + let mut insert_link = false; + + let directive_name = link.directive_name_in_schema(&COST_DIRECTIVE_NAME_IN_SPEC); + from.referencers() + .get_directive(&directive_name) + .and_then(|referencers| { + if referencers.len() > 0 { + insert_link = true; + copy_directive_definition(from, to, directive_name.clone())?; + } + referencers.copy_directives(from, to, &directive_name) + })?; + + let directive_name = link.directive_name_in_schema(&LIST_SIZE_DIRECTIVE_NAME_IN_SPEC); + from.referencers() + .get_directive(&directive_name) + .and_then(|referencers| { + if referencers.len() > 0 { + insert_link = true; + copy_directive_definition(from, to, directive_name.clone())?; + } + referencers.copy_directives(from, to, &directive_name) + })?; + + if insert_link { + SchemaDefinitionPosition + .insert_directive(to, link.to_directive_application().into())?; + } + } + + // compose directive + + metadata + .directives_by_imported_name + .iter() + .filter(|(_name, (link, _import))| !is_known_link(link)) + .try_for_each(|(name, (link, import))| { + // This is a strange thing — someone is importing @defer, but it's not a type system directive so we don't need to carry it over + if name == "defer" { + return Ok(()); + } + let directive_name = link.directive_name_in_schema(&import.element); + from.referencers() + .get_directive(&directive_name) + .and_then(|referencers| { + if referencers.len() > 0 { + if !SchemaDefinitionPosition + .get(to.schema()) + .directives + .iter() + .any(|d| { + d.name == DEFAULT_LINK_NAME + && d.specified_argument_by_name("url") + .and_then(|url| url.as_str()) + .map(|url| link.url.to_string() == *url) + .unwrap_or_default() + }) + { + SchemaDefinitionPosition + .insert_directive(to, link.to_directive_application().into())?; + } + + copy_directive_definition(from, to, directive_name.clone())?; + } + referencers.copy_directives(from, to, &directive_name) + })?; + Ok::<_, FederationError>(()) + })?; + + // @context + + if let Some(link) = metadata.for_identity(&Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: CONTEXT_DIRECTIVE_NAME_IN_SPEC, + }) { + let mut insert_link = false; + + let directive_name = link.directive_name_in_schema(&CONTEXT_DIRECTIVE_NAME_IN_SPEC); + from.referencers() + .get_directive(&directive_name) + .and_then(|referencers| { + if referencers.len() > 0 { + insert_link = true; + copy_directive_definition(from, to, directive_name.clone())?; + } + referencers.copy_directives(from, to, &directive_name) + })?; + + if insert_link { + SchemaDefinitionPosition + .insert_directive(to, link.to_directive_application().into())?; + } + } + + // @join__field(contextArguments: ...) + // This is a special case where we need to copy a specific argument from + // join__field directives in the original supergraph over to matching (by + // graph: arguments) join__field directives in the new schema. This is to + // avoid recreating the logic for constructing the contextArguments + // argument. This works because @fromContext is not allowed in connector + // subgraphs, so we can always directly carry over argument values. + if let Ok(referencers) = from.referencers().get_directive("join__field") { + let fields = referencers + .object_fields + .iter() + .map(|pos| ObjectOrInterfaceFieldDefinitionPosition::Object(pos.clone())) + .chain( + referencers + .interface_fields + .iter() + .map(|pos| ObjectOrInterfaceFieldDefinitionPosition::Interface(pos.clone())), + ) + .filter_map(|pos| { + let field_def = pos.get(from.schema()).ok()?; + let applications = field_def + .directives + .iter() + .filter(|d| d.name == name!("join__field")) + .collect::>(); + Some((pos, applications)) + }) + .flat_map(|(pos, applications)| { + applications + .into_iter() + .map(move |application| (pos.clone(), application)) + }) + .filter_map(|(pos, application)| { + let argument = application + .arguments + .iter() + .find(|arg| arg.name == name!("contextArguments"))? + .clone(); + let graph = application + .arguments + .iter() + .find(|arg| arg.name == name!("graph")) + .and_then(|arg| arg.value.as_enum())? + .to_string(); + Some((pos, graph, argument)) + }); + + for (pos, graph, argument) in fields { + let field = pos.get(to.schema())?; + let directive_index = field + .directives + .iter() + .position(|d| { + d.name == name!("join__field") + && d.arguments.iter().any(|a| { + a.name == name!("graph") + && a.value.as_enum().map(|e| e.to_string()).unwrap_or_default() + == graph + }) + }) + .ok_or_else(|| { + FederationError::internal("Cannot find matching directive in new supergraph") + })?; + + let argument_names = argument + .value + .as_list() + .map(|list| list.iter().flat_map(|v| v.as_object()).flatten()) + .map(|pairs| { + pairs + .filter(|(name, _)| name == &name!("name")) + .flat_map(|(_, value)| value.as_str()) + .flat_map(|s| Name::new(s).ok()) + .collect::>() + }) + .ok_or_else(|| { + FederationError::internal("Cannot find `name` argument in `contextArguments`") + })?; + + ObjectOrInterfaceFieldDirectivePosition { + field: pos.clone(), + directive_name: name!("join__field"), + directive_index, + } + .add_argument(to, argument)?; + + for argument_name in argument_names { + // Remove the argument now that it's handled by `@join__field(contextArguments:)` + match &pos { + ObjectOrInterfaceFieldDefinitionPosition::Object(pos) => { + ObjectFieldArgumentDefinitionPosition { + type_name: pos.type_name.clone(), + field_name: pos.field_name.clone(), + argument_name, + } + .remove(to)?; + } + ObjectOrInterfaceFieldDefinitionPosition::Interface(pos) => { + InterfaceFieldArgumentDefinitionPosition { + type_name: pos.type_name.clone(), + field_name: pos.field_name.clone(), + argument_name, + } + .remove(to)?; + } + } + } + } + }; + + Ok(()) +} + +fn is_known_link(link: &Link) -> bool { + link.url.identity.domain == APOLLO_SPEC_DOMAIN + && [ + name!(link), + name!(join), + name!(tag), + name!(inaccessible), + name!(authenticated), + name!(requiresScopes), + name!(policy), + name!(context), + ] + .contains(&link.url.identity.name) +} + +fn copy_directive_definition( + from: &FederationSchema, + to: &mut FederationSchema, + directive_name: Name, +) -> Result<(), FederationError> { + let def_pos = DirectiveDefinitionPosition { directive_name }; + + // If it exists, remove it so we can add the directive as defined in the + // supergraph. In rare cases where a directive can be applied to both + // executable and type system locations, extract_subgraphs_from_supergraph + // will include the definition with only the executable locations, making + // other applications invalid. + if def_pos.get(to.schema()).is_ok() { + def_pos.remove(to)?; + } + + def_pos + .get(from.schema()) + .map_err(From::from) + .and_then(|def| { + def_pos.pre_insert(to)?; + def_pos.insert(to, def.clone()) + }) +} + +impl Link { + fn to_directive_application(&self) -> Directive { + let mut arguments: Vec> = vec![ + Argument { + name: name!(url), + value: self.url.to_string().into(), + } + .into(), + ]; + + // purpose: link__Purpose + if let Some(purpose) = &self.purpose { + arguments.push( + Argument { + name: name!(for), + value: Value::Enum(purpose.into()).into(), + } + .into(), + ); + } + + // as: String + if let Some(alias) = &self.spec_alias { + arguments.push( + Argument { + name: name!(as), + value: Value::String(alias.to_string()).into(), + } + .into(), + ); + } + + // import: [link__Import!] + if !self.imports.is_empty() { + arguments.push( + Argument { + name: name!(import), + value: Value::List( + self.imports + .iter() + .map(|i| { + let name = if i.is_directive { + format!("@{}", i.element) + } else { + i.element.to_string() + }; + + if let Some(alias) = &i.alias { + let alias = if i.is_directive { + format!("@{alias}") + } else { + alias.to_string() + }; + + Value::Object(vec![ + (name!(name), Value::String(name).into()), + (name!(as), Value::String(alias).into()), + ]) + } else { + Value::String(name) + } + .into() + }) + .collect::>(), + ) + .into(), + } + .into(), + ); + } + + Directive { + name: name!(link), + arguments, + } + } +} + +trait CopyDirective { + fn copy_directive( + &self, + from: &FederationSchema, + to: &mut FederationSchema, + directive_name: &Name, + ) -> Result<(), FederationError>; +} + +impl CopyDirective for SchemaDefinitionPosition { + fn copy_directive( + &self, + from: &FederationSchema, + to: &mut FederationSchema, + directive_name: &Name, + ) -> Result<(), FederationError> { + self.get(from.schema()) + .directives + .iter() + .filter(|d| &d.name == directive_name) + .try_for_each(|directive| self.insert_directive(to, directive.clone())) + } +} + +macro_rules! impl_copy_directive { + ($( $Ty: ty )+) => { + $( + impl CopyDirective for $Ty { + fn copy_directive( + &self, + from: &FederationSchema, + to: &mut FederationSchema, + directive_name: &Name, + ) -> Result<(), FederationError> { + self.get(from.schema()) + .map(|def| { + def.directives + .iter() + .filter(|d| &d.name == directive_name) + .try_for_each(|directive| self.insert_directive(to, directive.clone())) + }) + .unwrap_or(Ok(())) + } + } + )+ + }; +} + +impl_copy_directive! { + ScalarTypeDefinitionPosition + ObjectTypeDefinitionPosition + ObjectFieldDefinitionPosition + ObjectFieldArgumentDefinitionPosition + InterfaceTypeDefinitionPosition + InterfaceFieldDefinitionPosition + InterfaceFieldArgumentDefinitionPosition + UnionTypeDefinitionPosition + EnumTypeDefinitionPosition + EnumValueDefinitionPosition + InputObjectTypeDefinitionPosition + InputObjectFieldDefinitionPosition + DirectiveArgumentDefinitionPosition +} + +impl DirectiveReferencers { + pub(crate) fn len(&self) -> usize { + self.schema.as_ref().map(|_| 1).unwrap_or_default() + + self.scalar_types.len() + + self.object_types.len() + + self.object_fields.len() + + self.object_field_arguments.len() + + self.interface_types.len() + + self.interface_fields.len() + + self.interface_field_arguments.len() + + self.union_types.len() + + self.enum_types.len() + + self.enum_values.len() + + self.input_object_types.len() + + self.input_object_fields.len() + + self.directive_arguments.len() + } + + fn copy_directives( + &self, + from: &FederationSchema, + to: &mut FederationSchema, + directive_name: &Name, + ) -> Result<(), FederationError> { + if let Some(position) = &self.schema { + position.copy_directive(from, to, directive_name)? + } + self.scalar_types + .iter() + .try_for_each(|position| position.copy_directive(from, to, directive_name))?; + self.object_types + .iter() + .try_for_each(|position| position.copy_directive(from, to, directive_name))?; + self.object_fields + .iter() + .try_for_each(|position| position.copy_directive(from, to, directive_name))?; + self.object_field_arguments + .iter() + .try_for_each(|position| position.copy_directive(from, to, directive_name))?; + self.interface_types + .iter() + .try_for_each(|position| position.copy_directive(from, to, directive_name))?; + self.interface_fields + .iter() + .try_for_each(|position| position.copy_directive(from, to, directive_name))?; + self.interface_field_arguments + .iter() + .try_for_each(|position| position.copy_directive(from, to, directive_name))?; + self.union_types + .iter() + .try_for_each(|position| position.copy_directive(from, to, directive_name))?; + self.enum_types + .iter() + .try_for_each(|position| position.copy_directive(from, to, directive_name))?; + self.enum_values + .iter() + .try_for_each(|position| position.copy_directive(from, to, directive_name))?; + self.input_object_types + .iter() + .try_for_each(|position| position.copy_directive(from, to, directive_name))?; + self.input_object_fields + .iter() + .try_for_each(|position| position.copy_directive(from, to, directive_name))?; + self.directive_arguments + .iter() + .try_for_each(|position| position.copy_directive(from, to, directive_name))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use apollo_compiler::Schema; + use insta::assert_snapshot; + + use super::carryover_directives; + use crate::connectors::ConnectSpec; + use crate::merge::merge_federation_subgraphs; + use crate::schema::FederationSchema; + use crate::supergraph::extract_subgraphs_from_supergraph; + + #[test] + fn test_carryover() { + let sdl = include_str!("./tests/schemas/expand/directives.graphql"); + let schema = Schema::parse(sdl, "directives.graphql").expect("parse failed"); + let supergraph_schema = FederationSchema::new(schema).expect("federation schema failed"); + let subgraphs = extract_subgraphs_from_supergraph(&supergraph_schema, None) + .expect("extract subgraphs failed"); + let merged = merge_federation_subgraphs(subgraphs).expect("merge failed"); + let schema = merged.schema.into_inner(); + let mut schema = FederationSchema::new(schema).expect("federation schema failed"); + + carryover_directives( + &supergraph_schema, + &mut schema, + [ConnectSpec::V0_1].into_iter(), + &Default::default(), + ) + .expect("carryover failed"); + assert_snapshot!(schema.schema().serialize().to_string()); + } +} diff --git a/apollo-federation/src/connectors/expand/carryover/inputs.rs b/apollo-federation/src/connectors/expand/carryover/inputs.rs new file mode 100644 index 0000000000..e1676c7a92 --- /dev/null +++ b/apollo-federation/src/connectors/expand/carryover/inputs.rs @@ -0,0 +1,338 @@ +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast; +use apollo_compiler::ast::Value; +use apollo_compiler::collections::HashMap; +use apollo_compiler::name; +use apollo_compiler::schema::DirectiveList; +use apollo_compiler::schema::EnumType; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::InputObjectType; +use apollo_compiler::schema::ScalarType; +use itertools::Itertools; +use multimap::MultiMap; + +use crate::error::FederationError; +use crate::schema::FederationSchema; +use crate::schema::position::EnumTypeDefinitionPosition; +use crate::schema::position::InputObjectTypeDefinitionPosition; +use crate::schema::position::ScalarTypeDefinitionPosition; + +/// merge.rs doesn't have any logic for `@composeDirective` directives, so we +/// need to carry those directives AND their associated input types over into +/// the new supergraph. +/// +/// However, we can't just copy the definitions as-is, because their join__* +/// directives may reference subgraphs that no longer exist (were replaced by +/// "expanded" subgraphs/connectors). Each time we encounter a join__* directive +/// with a `graph:` argument referring to a missing subgraph, we'll need to +/// replace it with **one or more** new directives, one for each "expanded" +/// subgraph. +pub(super) fn copy_input_types( + from: &FederationSchema, + to: &mut FederationSchema, + subgraph_name_replacements: &MultiMap<&str, String>, +) -> Result<(), FederationError> { + let from_join_graph_enum = from + .schema() + .get_enum(&name!(join__Graph)) + .ok_or_else(|| FederationError::internal("Cannot find join__graph enum"))?; + let to_join_graph_enum = to + .schema() + .get_enum(&name!(join__Graph)) + .ok_or_else(|| FederationError::internal("Cannot find join__graph enum"))?; + let subgraph_enum_replacements = subgraph_replacements( + from_join_graph_enum, + to_join_graph_enum, + subgraph_name_replacements, + ) + .map_err(|e| FederationError::internal(format!("Failed to get subgraph replacements: {e}")))?; + + for (name, ty) in &from.schema().types { + if to.schema().types.contains_key(name) { + continue; + } + match ty { + ExtendedType::Scalar(node) => { + let references = from.referencers().scalar_types.get(name); + if references.is_none_or(|refs| refs.len() == 0) { + continue; + } + + let pos = ScalarTypeDefinitionPosition { + type_name: node.name.clone(), + }; + let node = + strip_invalid_join_directives_from_scalar(node, &subgraph_enum_replacements); + pos.pre_insert(to).ok(); + pos.insert(to, node).ok(); + } + ExtendedType::Enum(node) => { + let references = from.referencers().enum_types.get(name); + if references.is_none_or(|refs| refs.len() == 0) { + continue; + } + + let pos = EnumTypeDefinitionPosition { + type_name: node.name.clone(), + }; + let node = + strip_invalid_join_directives_from_enum(node, &subgraph_enum_replacements); + pos.pre_insert(to).ok(); + pos.insert(to, node).ok(); + } + ExtendedType::InputObject(node) => { + let references = from.referencers().input_object_types.get(name); + if references.is_none_or(|refs| refs.len() == 0) { + continue; + } + + let pos = InputObjectTypeDefinitionPosition { + type_name: node.name.clone(), + }; + let node = strip_invalid_join_directives_from_input_type( + node, + &subgraph_enum_replacements, + ); + pos.pre_insert(to).ok(); + pos.insert(to, node).ok(); + } + _ => {} + } + } + + Ok(()) +} + +/// Given an original join__Graph enum: +/// ```graphql +/// enum join__Graph { +/// REGULAR_SUBGRAPH @join__graph(name: "regular-subgraph") +/// CONNECTORS_SUBGRAPH @join__graph(name: "connectors-subgraph") +/// } +/// ``` +/// +/// and a new join__Graph enum: +/// ```graphql +/// enum join__Graph { +/// REGULAR_SUBGRAPH @join__graph(name: "regular-subgraph") +/// CONNECTORS_SUBGRAPH_QUERY_USER_0 @join__graph(name: "connectors-subgraph_Query_user_0") +/// CONNECTORS_SUBGRAPH_QUERY_USERS_0 @join__graph(name: "connectors-subgraph_Query_users_0") +/// } +/// ``` +/// +/// and a map of original subgraph names to new subgraph names: +/// ```ignore +/// { +/// "connectors-subgraph" => vec!["connectors-subgraph_Query_user_0", "connectors-subgraph_Query_users_0"] +/// } +/// ``` +/// +/// Return a map of enum value replacements: +/// ```ignore +/// { +/// "CONNECTORS_SUBGRAPH" => vec!["CONNECTORS_SUBGRAPH_QUERY_USER_0", "CONNECTORS_SUBGRAPH_QUERY_USERS_0"], +/// } +/// ``` +fn subgraph_replacements( + from_join_graph_enum: &EnumType, + to_join_graph_enum: &EnumType, + replaced_subgraph_names: &MultiMap<&str, String>, +) -> Result, String> { + let mut replacements = MultiMap::new(); + + fn subgraph_names_to_enum_values(enum_type: &EnumType) -> Result, &str> { + enum_type + .values + .iter() + .map(|(name, value)| { + value + .directives + .iter() + .find(|d| d.name == name!(join__graph)) + .and_then(|d| { + d.arguments + .iter() + .find(|a| a.name == name!(name)) + .and_then(|a| a.value.as_str()) + }) + .ok_or("no name argument on join__graph") + .map(|new_subgraph_name| (new_subgraph_name, name)) + }) + .try_collect() + } + + let new_subgraph_names_to_enum_values = subgraph_names_to_enum_values(to_join_graph_enum)?; + + let original_subgraph_names_to_enum_values = + subgraph_names_to_enum_values(from_join_graph_enum)?; + + for (original_subgraph_name, new_subgraph_names) in replaced_subgraph_names.iter_all() { + if let Some(original_enum_value) = original_subgraph_names_to_enum_values + .get(original_subgraph_name) + .cloned() + { + for new_subgraph_name in new_subgraph_names { + if let Some(new_enum_value) = new_subgraph_names_to_enum_values + .get(new_subgraph_name.as_str()) + .cloned() + { + replacements.insert(original_enum_value.clone(), new_enum_value.clone()); + } + } + } + } + + Ok(replacements) +} + +/// Given a list of directives and a directive name like `@join__type` or `@join__enumValue`, +/// replace the `graph:` argument with a new directive for each subgraph name in the +/// `replaced_subgraph_names` map. +fn replace_join_enum( + directives: &DirectiveList, + directive_name: &Name, + replaced_subgraph_names: &MultiMap, +) -> DirectiveList { + let mut new_directives = DirectiveList::new(); + for d in directives.iter() { + if &d.name == directive_name { + let Some(graph_arg) = d + .arguments + .iter() + .find(|a| a.name == name!(graph)) + .and_then(|a| a.value.as_enum()) + else { + continue; + }; + + let Some(replacements) = replaced_subgraph_names.get_vec(graph_arg) else { + new_directives.push(d.clone()); + continue; + }; + + for replacement in replacements { + let mut new_directive = d.clone(); + let new_directive = new_directive.make_mut(); + if let Some(a) = new_directive + .arguments + .iter_mut() + .find(|a| a.name == name!(graph)) + { + let a = a.make_mut(); + a.value = Value::Enum(replacement.clone()).into(); + }; + new_directives.push(new_directive.clone()); + } + } else { + new_directives.push(d.clone()); + } + } + new_directives +} + +/// Unfortunately, there are two different DirectiveList types, so this +/// function is duplicated. +fn replace_join_enum_ast( + directives: &ast::DirectiveList, + directive_name: &Name, + replaced_subgraph_names: &MultiMap, +) -> ast::DirectiveList { + let mut new_directives = ast::DirectiveList::new(); + for d in directives.iter() { + if &d.name == directive_name { + let Some(graph_arg) = d + .arguments + .iter() + .find(|a| a.name == name!(graph)) + .and_then(|a| a.value.as_enum()) + else { + continue; + }; + + let Some(replacements) = replaced_subgraph_names.get_vec(graph_arg) else { + new_directives.push(d.clone()); + continue; + }; + + for replacement in replacements { + let mut new_directive = d.clone(); + let new_directive = new_directive.make_mut(); + if let Some(a) = new_directive + .arguments + .iter_mut() + .find(|a| a.name == name!(graph)) + { + let a = a.make_mut(); + a.value = Value::Enum(replacement.clone()).into(); + }; + new_directives.push(new_directive.clone()); + } + } else { + new_directives.push(d.clone()); + } + } + new_directives +} + +fn strip_invalid_join_directives_from_input_type( + node: &InputObjectType, + replaced_subgraph_names: &MultiMap, +) -> Node { + let mut node = node.clone(); + + node.directives = replace_join_enum( + &node.directives, + &name!(join__type), + replaced_subgraph_names, + ); + + for (_, field) in node.fields.iter_mut() { + let field = field.make_mut(); + field.directives = replace_join_enum_ast( + &field.directives, + &name!(join__field), + replaced_subgraph_names, + ); + } + + node.into() +} + +fn strip_invalid_join_directives_from_enum( + node: &EnumType, + replaced_subgraph_names: &MultiMap, +) -> Node { + let mut node = node.clone(); + + node.directives = replace_join_enum( + &node.directives, + &name!(join__type), + replaced_subgraph_names, + ); + + for (_, value) in node.values.iter_mut() { + let value = value.make_mut(); + value.directives = replace_join_enum_ast( + &value.directives, + &name!(join__enumValue), + replaced_subgraph_names, + ); + } + node.into() +} + +fn strip_invalid_join_directives_from_scalar( + node: &ScalarType, + replaced_subgraph_names: &MultiMap, +) -> Node { + let mut node = node.clone(); + + node.directives = replace_join_enum( + &node.directives, + &name!(join__type), + replaced_subgraph_names, + ); + + node.into() +} diff --git a/apollo-federation/src/sources/connect/expand/merge/basic_1.graphql b/apollo-federation/src/connectors/expand/merge/basic_1.graphql similarity index 85% rename from apollo-federation/src/sources/connect/expand/merge/basic_1.graphql rename to apollo-federation/src/connectors/expand/merge/basic_1.graphql index e9a3099331..c3357ffb75 100644 --- a/apollo-federation/src/sources/connect/expand/merge/basic_1.graphql +++ b/apollo-federation/src/connectors/expand/merge/basic_1.graphql @@ -39,3 +39,11 @@ type Y { input YInput { z: ID } + +type Mutation { + m: M +} + +type M { + n: String +} diff --git a/apollo-federation/src/sources/connect/expand/merge/basic_2.graphql b/apollo-federation/src/connectors/expand/merge/basic_2.graphql similarity index 66% rename from apollo-federation/src/sources/connect/expand/merge/basic_2.graphql rename to apollo-federation/src/connectors/expand/merge/basic_2.graphql index e9a3099331..4dc7cdf929 100644 --- a/apollo-federation/src/sources/connect/expand/merge/basic_2.graphql +++ b/apollo-federation/src/connectors/expand/merge/basic_2.graphql @@ -6,15 +6,18 @@ type Query { interface I { id: ID! + f(x: ID, y: YInput): T } type A implements I { id: ID! + f(x: ID, y: YInput): T a: S } type B implements I { id: ID! + f(x: ID, y: YInput): T b: E } @@ -39,3 +42,11 @@ type Y { input YInput { z: ID } + +type Mutation { + m2(x: ID, y: YInput): M +} + +type M { + n: String +} diff --git a/apollo-federation/src/sources/connect/expand/merge/connector_Query_user_0.graphql b/apollo-federation/src/connectors/expand/merge/connector_Query_user_0.graphql similarity index 100% rename from apollo-federation/src/sources/connect/expand/merge/connector_Query_user_0.graphql rename to apollo-federation/src/connectors/expand/merge/connector_Query_user_0.graphql diff --git a/apollo-federation/src/sources/connect/expand/merge/connector_Query_users_0.graphql b/apollo-federation/src/connectors/expand/merge/connector_Query_users_0.graphql similarity index 100% rename from apollo-federation/src/sources/connect/expand/merge/connector_Query_users_0.graphql rename to apollo-federation/src/connectors/expand/merge/connector_Query_users_0.graphql diff --git a/apollo-federation/src/sources/connect/expand/merge/connector_User_d_1.graphql b/apollo-federation/src/connectors/expand/merge/connector_User_d_1.graphql similarity index 100% rename from apollo-federation/src/sources/connect/expand/merge/connector_User_d_1.graphql rename to apollo-federation/src/connectors/expand/merge/connector_User_d_1.graphql diff --git a/apollo-federation/src/sources/connect/expand/merge/graphql.graphql b/apollo-federation/src/connectors/expand/merge/graphql.graphql similarity index 100% rename from apollo-federation/src/sources/connect/expand/merge/graphql.graphql rename to apollo-federation/src/connectors/expand/merge/graphql.graphql diff --git a/apollo-federation/src/sources/connect/expand/merge/inaccessible.graphql b/apollo-federation/src/connectors/expand/merge/inaccessible.graphql similarity index 100% rename from apollo-federation/src/sources/connect/expand/merge/inaccessible.graphql rename to apollo-federation/src/connectors/expand/merge/inaccessible.graphql diff --git a/apollo-federation/src/sources/connect/expand/merge/inaccessible_2.graphql b/apollo-federation/src/connectors/expand/merge/inaccessible_2.graphql similarity index 100% rename from apollo-federation/src/sources/connect/expand/merge/inaccessible_2.graphql rename to apollo-federation/src/connectors/expand/merge/inaccessible_2.graphql diff --git a/apollo-federation/src/connectors/expand/mod.rs b/apollo-federation/src/connectors/expand/mod.rs new file mode 100644 index 0000000000..aad68f50b7 --- /dev/null +++ b/apollo-federation/src/connectors/expand/mod.rs @@ -0,0 +1,850 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use apollo_compiler::Schema; +use apollo_compiler::validation::Valid; +use carryover::carryover_directives; +use indexmap::IndexMap; +use itertools::Itertools; +use multimap::MultiMap; + +use crate::ApiSchemaOptions; +use crate::Supergraph; +use crate::ValidFederationSubgraph; +use crate::connectors::ConnectSpec; +use crate::connectors::Connector; +use crate::error::FederationError; +use crate::merge::merge_subgraphs; +use crate::schema::FederationSchema; +use crate::subgraph::Subgraph; +use crate::subgraph::ValidSubgraph; + +mod carryover; +pub(crate) mod visitors; +use visitors::filter_directives; + +use crate::connectors::spec::ConnectLink; + +pub struct Connectors { + pub by_service_name: Arc, Connector>>, + pub labels_by_service_name: Arc, String>>, + pub source_config_keys: Arc>, +} + +/// The result of a supergraph expansion of connect-aware subgraphs +pub enum ExpansionResult { + /// The supergraph had some subgraphs that were expanded + Expanded { + raw_sdl: String, + api_schema: Box>, + connectors: Connectors, + }, + + /// The supergraph contained no connect directives and was unchanged. + Unchanged, +} + +/// Expand a schema with connector directives into unique subgraphs per directive +/// +/// Until we have a source-aware query planner, work with connectors will need to interface +/// with standard query planning concepts while still enforcing connector-specific rules. To do so, +/// each connector is separated into its own unique subgraph with relevant GraphQL directives to enforce +/// field dependencies and response structures. This allows for satisfiability and validation to piggy-back +/// off of existing functionality in a reproducible way. +pub fn expand_connectors( + supergraph_str: &str, + api_schema_options: &ApiSchemaOptions, +) -> Result { + // TODO: Don't rely on finding the URL manually to short out + let connect_url = ConnectSpec::identity(); + let connect_url = format!("{}/{}/v", connect_url.domain, connect_url.name); + if !supergraph_str.contains(&connect_url) { + return Ok(ExpansionResult::Unchanged); + } + + let supergraph = Supergraph::new_with_router_specs(supergraph_str)?; + let api_schema = supergraph.to_api_schema(api_schema_options.clone())?; + + let (connect_subgraphs, graphql_subgraphs): (Vec<_>, Vec<_>) = supergraph + .extract_subgraphs()? + .into_iter() + .partition_map(|(_, sub)| match ConnectLink::new(sub.schema.schema()) { + Some(Ok(link)) if contains_connectors(&link, &sub) => either::Either::Left((link, sub)), + _ => either::Either::Right(ValidSubgraph::from(sub)), + }); + + // Expand just the connector subgraphs + let mut expanded_subgraphs = Vec::new(); + let mut spec_versions = HashSet::new(); + + for (link, sub) in connect_subgraphs { + expanded_subgraphs.extend(split_subgraph(&link, sub)?); + spec_versions.insert(link.spec); + } + + // Merge the subgraphs into one supergraph + let all_subgraphs = graphql_subgraphs + .iter() + .chain(expanded_subgraphs.iter().map(|(_, sub)| sub)) + .collect(); + let new_supergraph = merge_subgraphs(all_subgraphs).map_err(|e| { + FederationError::internal(format!("could not merge expanded subgraphs: {e:?}")) + })?; + + let subgraph_name_replacements = expanded_subgraphs + .iter() + .map(|(connector, _)| { + ( + connector.id.subgraph_name.as_str(), + connector.id.synthetic_name(), + ) + }) + .collect::>(); + + let mut new_supergraph = FederationSchema::new(new_supergraph.schema.into_inner())?; + carryover_directives( + &supergraph.schema, + &mut new_supergraph, + spec_versions.into_iter(), + &subgraph_name_replacements, + ) + .map_err(|e| FederationError::internal(format!("could not carry over directives: {e:?}")))?; + + let connectors_by_service_name: IndexMap, Connector> = expanded_subgraphs + .into_iter() + .map(|(connector, sub)| (sub.name.into(), connector)) + .collect(); + + let labels_by_service_name = connectors_by_service_name + .iter() + .map(|(service_name, connector)| (service_name.clone(), connector.label.0.clone())) + .collect(); + + let source_config_keys = connectors_by_service_name + .iter() + .map(|(_, connector)| connector.source_config_key()) + .collect(); + + Ok(ExpansionResult::Expanded { + raw_sdl: new_supergraph.schema().serialize().to_string(), + api_schema: Box::new(api_schema.schema().clone()), + connectors: Connectors { + by_service_name: Arc::new(connectors_by_service_name), + labels_by_service_name: Arc::new(labels_by_service_name), + source_config_keys: Arc::new(source_config_keys), + }, + }) +} + +fn contains_connectors(link: &ConnectLink, subgraph: &ValidFederationSubgraph) -> bool { + subgraph + .schema + .get_directive_definitions() + .any(|directive| { + directive.directive_name == link.connect_directive_name + || directive.directive_name == link.source_directive_name + }) +} + +/// Split up a subgraph so that each connector directive becomes its own subgraph. +/// +/// Subgraphs passed to this function should contain connector directives. +fn split_subgraph( + link: &ConnectLink, + subgraph: ValidFederationSubgraph, +) -> Result, FederationError> { + let connector_map = Connector::from_schema(subgraph.schema.schema(), &subgraph.name)?; + + let expander = helpers::Expander::new(link, &subgraph); + connector_map + .into_iter() + .map(|connector| { + // Build a subgraph using only the necessary fields from the directive + let schema = expander.expand(&connector)?; + let subgraph = Subgraph::new( + connector.id.synthetic_name().as_str(), + &subgraph.url, + &schema.schema().serialize().to_string(), + )?; + + // We only validate during debug builds since we should realistically only generate valid schemas + // for these subgraphs. + #[cfg(debug_assertions)] + let schema = subgraph.schema.validate()?; + #[cfg(not(debug_assertions))] + let schema = Valid::assume_valid(subgraph.schema); + + Ok(( + connector, + ValidSubgraph { + name: subgraph.name, + url: subgraph.url, + schema, + }, + )) + }) + .try_collect() +} + +mod helpers { + use apollo_compiler::Name; + use apollo_compiler::Node; + use apollo_compiler::ast; + use apollo_compiler::ast::Argument; + use apollo_compiler::ast::Directive; + use apollo_compiler::ast::FieldDefinition; + use apollo_compiler::ast::InputValueDefinition; + use apollo_compiler::ast::Value; + use apollo_compiler::name; + use apollo_compiler::schema::Component; + use apollo_compiler::schema::ComponentName; + use apollo_compiler::schema::ComponentOrigin; + use apollo_compiler::schema::DirectiveList; + use apollo_compiler::schema::EnumType; + use apollo_compiler::schema::ObjectType; + use apollo_compiler::schema::ScalarType; + use apollo_compiler::ty; + use indexmap::IndexMap; + use indexmap::IndexSet; + + use super::filter_directives; + use super::visitors::GroupVisitor; + use super::visitors::SchemaVisitor; + use super::visitors::try_insert; + use super::visitors::try_pre_insert; + use crate::ValidFederationSubgraph; + use crate::connectors::Connector; + use crate::connectors::EntityResolver; + use crate::connectors::JSONSelection; + use crate::connectors::id::ConnectedElement; + use crate::connectors::spec::ConnectLink; + use crate::error::FederationError; + use crate::internal_error; + use crate::link::spec::Identity; + use crate::schema::FederationSchema; + use crate::schema::ValidFederationSchema; + use crate::schema::position::ObjectFieldDefinitionPosition; + use crate::schema::position::ObjectTypeDefinitionPosition; + use crate::schema::position::SchemaRootDefinitionKind; + use crate::schema::position::SchemaRootDefinitionPosition; + use crate::schema::position::TypeDefinitionPosition; + use crate::subgraph::spec::EXTERNAL_DIRECTIVE_NAME; + use crate::subgraph::spec::INTF_OBJECT_DIRECTIVE_NAME; + use crate::subgraph::spec::KEY_DIRECTIVE_NAME; + use crate::subgraph::spec::REQUIRES_DIRECTIVE_NAME; + use crate::supergraph::new_empty_fed_2_subgraph_schema; + + /// A helper struct for expanding a subgraph into one per connect directive. + pub(super) struct Expander<'a> { + /// The name of the @key directive, as known in the subgraph + key_name: Name, + + /// The name of the @interfaceObject directive, as known in the subgraph + interface_object_name: Name, + + /// The original schema that contains connect directives + original_schema: &'a ValidFederationSchema, + + /// A list of directives to exclude when copying over types from the + /// original schema. + directive_deny_list: IndexSet, + } + + impl<'a> Expander<'a> { + pub(super) fn new(link: &ConnectLink, subgraph: &'a ValidFederationSubgraph) -> Self { + // When we go to expand all output types, we'll need to make sure that we don't carry over + // any connect-related directives. The following directives are also special because they + // influence planning and satisfiability: + // + // - @key: derived based on the fields selected + // - @external: the current approach will only add external fields to the list of keys + // if used in the transport. If not used at all, the field marked with this directive + // won't even be included in the expanded subgraph, but if it _is_ used then leaving + // this directive will result in planning failures. + // - @requires: the current approach will add required fields to the list of keys for + // implicit entities, so it can't stay. + let key_name = subgraph + .schema + .metadata() + .and_then(|m| m.for_identity(&Identity::federation_identity())) + .map_or(KEY_DIRECTIVE_NAME, |f| { + f.directive_name_in_schema(&KEY_DIRECTIVE_NAME) + }); + let interface_object_name = subgraph + .schema + .metadata() + .and_then(|m| m.for_identity(&Identity::federation_identity())) + .map_or(INTF_OBJECT_DIRECTIVE_NAME, |f| { + f.directive_name_in_schema(&INTF_OBJECT_DIRECTIVE_NAME) + }); + let extra_excluded = [EXTERNAL_DIRECTIVE_NAME, REQUIRES_DIRECTIVE_NAME] + .into_iter() + .map(|d| { + subgraph + .schema + .metadata() + .and_then(|m| m.for_identity(&Identity::federation_identity())) + .map(|f| f.directive_name_in_schema(&d)) + .unwrap_or(d) + }); + let directive_deny_list = IndexSet::from_iter(extra_excluded.chain([ + key_name.clone(), + link.connect_directive_name.clone(), + link.source_directive_name.clone(), + ])); + + Self { + key_name, + interface_object_name, + original_schema: &subgraph.schema, + directive_deny_list, + } + } + + /// Build an expanded subgraph for the supplied connector + pub(super) fn expand( + &self, + connector: &Connector, + ) -> Result { + let mut schema = new_empty_fed_2_subgraph_schema()?; + let query_alias = self + .original_schema + .schema() + .schema_definition + .query + .as_ref() + .map(|m| m.name.clone()) + .unwrap_or(name!("Query")); + let mutation_alias = self + .original_schema + .schema() + .schema_definition + .mutation + .as_ref() + .map(|m| m.name.clone()); + + let element = connector + .id + .directive + .element(self.original_schema.schema()) + .map_err(|_| { + FederationError::internal("Elements for connector position not found") + })?; + + match element { + ConnectedElement::Field { + field_def, + parent_type, + .. + } => { + let field_type = self + .original_schema + .get_type(field_def.ty.inner_named_type().clone())?; + + // We'll need to make sure that we always process the inputs first, since they need to be present + // before any dependent types + self.process_inputs(&mut schema, &field_def.arguments)?; + + // Actually process the type annotated with the connector, making sure to walk nested types + match field_type { + TypeDefinitionPosition::Object(object) => { + SchemaVisitor::new( + self.original_schema, + &mut schema, + &self.directive_deny_list, + ) + .walk(( + object, + connector + .selection + .next_subselection() + .cloned() + .ok_or_else(|| { + FederationError::internal( + "empty selections are not allowed", + ) + })?, + ))?; + } + + TypeDefinitionPosition::Scalar(_) | TypeDefinitionPosition::Enum(_) => { + self.insert_custom_leaf(&mut schema, &field_type)?; + } + + TypeDefinitionPosition::Interface(interface) => { + return Err(FederationError::internal(format!( + "connect directives not yet supported on interfaces: found on {}", + interface.type_name + ))); + } + TypeDefinitionPosition::Union(union) => { + return Err(FederationError::internal(format!( + "connect directives not yet supported on union: found on {}", + union.type_name + ))); + } + TypeDefinitionPosition::InputObject(input) => { + return Err(FederationError::internal(format!( + "connect directives not yet supported on inputs: found on {}", + input.type_name + ))); + } + }; + + // Add the root type for this connector, optionally inserting a dummy query root + // if the connector is not defined within a field on a Query (since a subgraph is invalid + // without at least a root-level Query) + + let parent_pos = ObjectTypeDefinitionPosition { + type_name: parent_type.name.clone(), + }; + + self.insert_object_and_field(&mut schema, &parent_pos, field_def)?; + self.ensure_query_root_type( + &mut schema, + &query_alias, + Some(&parent_type.name), + )?; + if let Some(mutation_alias) = mutation_alias { + self.ensure_mutation_root_type( + &mut schema, + &mutation_alias, + &parent_type.name, + )?; + } + + // Process any outputs needed by the connector + self.process_outputs( + &mut schema, + connector, + parent_type.name.clone(), + field_def.ty.inner_named_type().clone(), + )?; + } + ConnectedElement::Type { type_def } => { + SchemaVisitor::new( + self.original_schema, + &mut schema, + &self.directive_deny_list, + ) + .walk(( + ObjectTypeDefinitionPosition { + type_name: type_def.name.clone(), + }, + connector + .selection + .next_subselection() + .cloned() + .ok_or_else(|| { + FederationError::internal("empty selections are not allowed") + })?, + ))?; + + // we need a Query root field to be valid + self.ensure_query_root_type(&mut schema, &query_alias, None)?; + + // Process any outputs needed by the connector + self.process_outputs( + &mut schema, + connector, + type_def.name.clone(), + type_def.name.clone(), + )?; + } + } + + Ok(schema) + } + + /// Process all input types + /// + /// Inputs can include leaf types as well as custom inputs. + fn process_inputs( + &self, + to_schema: &mut FederationSchema, + arguments: &[Node], + ) -> Result<(), FederationError> { + // All inputs to a connector's field need to be carried over in order to always generate + // valid subgraphs + for arg in arguments { + let arg_type_name = arg.ty.inner_named_type(); + let arg_type = self.original_schema.get_type(arg_type_name.clone())?; + let arg_extended_type = arg_type.get(self.original_schema.schema())?; + + // If the input type isn't built in, then we need to carry it over, making sure to only walk + // if we have a complex input since leaf types can just be copied over. + if !arg_extended_type.is_built_in() { + match arg_type { + TypeDefinitionPosition::InputObject(input) => { + SchemaVisitor::new( + self.original_schema, + to_schema, + &self.directive_deny_list, + ) + .walk(input)?; + } + other => self.insert_custom_leaf(to_schema, &other)?, + }; + } + } + + Ok(()) + } + + // Process outputs needed by a connector + // + // By the time this method is called, all dependent types should exist for a connector, + // including its direct inputs. Since each connector could select only a subset of its output + // type, this method carries over each output type as seen by the selection defined on the connector. + fn process_outputs( + &self, + to_schema: &mut FederationSchema, + connector: &Connector, + parent_type_name: Name, + output_type_name: Name, + ) -> Result<(), FederationError> { + let resolvable_key = connector + .resolvable_key(self.original_schema.schema()) + .map_err(|_| FederationError::internal("error creating resolvable key"))?; + + let Some(resolvable_key) = resolvable_key else { + return self.copy_interface_object_keys(output_type_name, to_schema); + }; + + let parent_type = self.original_schema.get_type(parent_type_name)?; + let output_type = to_schema.get_type(output_type_name)?; + let key_for_type = match &connector.entity_resolver { + Some(EntityResolver::Explicit) => output_type, + _ => parent_type, + }; + + let parsed = JSONSelection::parse_with_spec( + &resolvable_key.serialize().no_indent().to_string(), + connector.spec, + ) + .map_err(|e| FederationError::internal(format!("error parsing key: {e}")))?; + + let visitor = + SchemaVisitor::new(self.original_schema, to_schema, &self.directive_deny_list); + + let output_type = match &key_for_type { + TypeDefinitionPosition::Object(object) => object, + + other => { + return Err(FederationError::internal(format!( + "connector output types currently only support object types: found {}", + other.type_name() + ))); + } + }; + + // This adds child types for all key fields + visitor.walk(( + output_type.clone(), + parsed + .next_subselection() + .cloned() + .ok_or_else(|| FederationError::internal("empty selections are not allowed"))?, + ))?; + + // This actually adds the key fields if necessary, which is only + // when depending on sibling fields. + if let Some(sub) = parsed.next_subselection() { + for named in sub.selections_iter() { + for field_name in named.names() { + let field_def = self + .original_schema + .schema() + .type_field(key_for_type.type_name(), field_name) + .map_err(|_| { + FederationError::internal(format!( + "field {} not found on type {}", + field_name, + key_for_type.type_name() + )) + })?; + + // TODO: future support for interfaces + let pos = ObjectFieldDefinitionPosition { + type_name: key_for_type.type_name().clone(), + field_name: Name::new(field_name)?, + }; + + if pos.get(to_schema.schema()).is_err() { + pos.insert( + to_schema, + Component::new(FieldDefinition { + description: field_def.description.clone(), + name: field_def.name.clone(), + arguments: field_def.arguments.clone(), + ty: field_def.ty.clone(), + directives: filter_directives( + &self.directive_deny_list, + &field_def.directives, + ), + }), + )?; + } + } + } + }; + + // If we have marked keys as being necessary for this output type, add them as an `@key` + // directive now. + let key_directive = Directive { + name: self.key_name.clone(), + arguments: vec![Node::new(Argument { + name: name!("fields"), + value: Node::new(Value::String( + resolvable_key.serialize().no_indent().to_string(), + )), + })], + }; + + match &key_for_type { + TypeDefinitionPosition::Object(o) => { + o.insert_directive(to_schema, Component::new(key_directive)) + } + TypeDefinitionPosition::Interface(i) => { + i.insert_directive(to_schema, Component::new(key_directive)) + } + _ => { + return Err(FederationError::internal( + "keys cannot be added to scalars, unions, enums, or input objects", + )); + } + }?; + + Ok(()) + } + + /// If the type has @interfaceObject and it doesn't have a key at this point + /// we'll need to add a key — this is a requirement for using @interfaceObject. + /// For now we'll just copy over keys from the original supergraph as resolvable: false + /// but we need to think through the implications of that. + fn copy_interface_object_keys( + &self, + type_name: Name, + to_schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + let Some(original_output_type) = self.original_schema.schema().get_object(&type_name) + else { + return Ok(()); + }; + + let is_interface_object = original_output_type + .directives + .iter() + .any(|d| d.name == self.interface_object_name); + + if is_interface_object { + let pos = ObjectTypeDefinitionPosition { + type_name: original_output_type.name.clone(), + }; + + for key in original_output_type + .directives + .iter() + .filter(|d| d.name == self.key_name) + { + let key_fields = key + .argument_by_name("fields", self.original_schema.schema()) + .map_err(|_| internal_error!("@key(fields:) argument missing"))?; + let key = Directive { + name: key.name.clone(), + arguments: vec![ + Node::new(Argument { + name: name!("fields"), + value: key_fields.clone(), + }), + Node::new(Argument { + name: name!("resolvable"), + value: Node::new(Value::Boolean(false)), + }), + ], + }; + pos.insert_directive(to_schema, Component::new(key))?; + } + } + + Ok(()) + } + + /// Inserts a custom leaf type into the schema + /// + /// This errors if called with a non-leaf type. + fn insert_custom_leaf( + &self, + to_schema: &mut FederationSchema, + r#type: &TypeDefinitionPosition, + ) -> Result<(), FederationError> { + match r#type { + TypeDefinitionPosition::Scalar(scalar) => { + let def = scalar.get(self.original_schema.schema())?; + let def = ScalarType { + description: def.description.clone(), + name: def.name.clone(), + directives: filter_directives(&self.directive_deny_list, &def.directives), + }; + + try_pre_insert!(to_schema, scalar)?; + try_insert!(to_schema, scalar, Node::new(def)) + } + TypeDefinitionPosition::Enum(r#enum) => { + let def = r#enum.get(self.original_schema.schema())?; + let def = EnumType { + description: def.description.clone(), + name: def.name.clone(), + directives: filter_directives(&self.directive_deny_list, &def.directives), + values: def.values.clone(), + }; + + try_pre_insert!(to_schema, r#enum)?; + try_insert!(to_schema, r#enum, Node::new(def)) + } + + other => Err(FederationError::internal(format!( + "expected a leaf, found: {}", + other.type_name(), + ))), + } + } + + /// Insert the parent type and field definition for a connector + fn insert_object_and_field( + &self, + to_schema: &mut FederationSchema, + field_parent: &ObjectTypeDefinitionPosition, + field: impl AsRef, + ) -> Result<(), FederationError> { + let original = field.as_ref(); + + let parent_type = field_parent.get(self.original_schema.schema())?; + + try_pre_insert!(to_schema, field_parent)?; + let field_def = FieldDefinition { + description: original.description.clone(), + name: original.name.clone(), + arguments: original.arguments.clone(), + ty: original.ty.clone(), + directives: filter_directives(&self.directive_deny_list, &original.directives), + }; + try_insert!( + to_schema, + field_parent, + Node::new(ObjectType { + description: parent_type.description.clone(), + name: parent_type.name.clone(), + implements_interfaces: parent_type.implements_interfaces.clone(), + directives: filter_directives( + &self.directive_deny_list, + &parent_type.directives, + ), + fields: Default::default() + }) + )?; + + let pos = ObjectFieldDefinitionPosition { + type_name: parent_type.name.clone(), + field_name: field_def.name.clone(), + }; + + pos.insert(to_schema, field_def.into())?; + + Ok(()) + } + + /// Insert a query root type for a connect field + /// + /// If the connector is not defined on a Query root field, we'll need to + /// construct a dummy field to make a valid schema. + /// + /// ```graphql + /// type Query { + /// _: ID @shareable @inaccessible + /// } + /// ``` + /// + /// Note: This would probably be better off expanding the query to have + /// an _entities vs. adding an inaccessible field. + fn ensure_query_root_type( + &self, + to_schema: &mut FederationSchema, + query_alias: &Name, + parent_type_name: Option<&Name>, + ) -> Result<(), FederationError> { + if parent_type_name.is_none_or(|name| name != query_alias) { + let query = ObjectTypeDefinitionPosition { + type_name: query_alias.clone(), + }; + + let dummy_field_def = FieldDefinition { + description: None, + name: name!("_"), + arguments: Vec::new(), + ty: ty!(ID), + directives: ast::DirectiveList(vec![Node::new(Directive { + name: name!("federation__inaccessible"), + arguments: Vec::new(), + })]), + }; + + query.pre_insert(to_schema)?; + query.insert( + to_schema, + Node::new(ObjectType { + description: None, + name: query_alias.clone(), + implements_interfaces: IndexSet::with_hasher(Default::default()), + directives: DirectiveList::new(), + fields: IndexMap::from_iter([( + dummy_field_def.name.clone(), + Component::new(dummy_field_def), + )]), + }), + )?; + } + + SchemaRootDefinitionPosition { + root_kind: SchemaRootDefinitionKind::Query, + } + .insert( + to_schema, + ComponentName { + origin: ComponentOrigin::Definition, + name: query_alias.clone(), + }, + )?; + + Ok(()) + } + + /// Adds the mutation root type to the schema definition if necessary + fn ensure_mutation_root_type( + &self, + to_schema: &mut FederationSchema, + mutation_alias: &Name, + parent_type_name: &Name, + ) -> Result<(), FederationError> { + if mutation_alias == parent_type_name + && to_schema.get_type(mutation_alias.clone()).is_ok() + { + let mutation_root = SchemaRootDefinitionPosition { + root_kind: SchemaRootDefinitionKind::Mutation, + }; + mutation_root.insert( + to_schema, + ComponentName { + origin: ComponentOrigin::Definition, + name: mutation_alias.clone(), + }, + )?; + } + + Ok(()) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/apollo-federation/src/connectors/expand/snapshots/apollo_federation__connectors__expand__carryover__tests__carryover.snap b/apollo-federation/src/connectors/expand/snapshots/apollo_federation__connectors__expand__carryover__tests__carryover.snap new file mode 100644 index 0000000000..c9fbadc7b0 --- /dev/null +++ b/apollo-federation/src/connectors/expand/snapshots/apollo_federation__connectors__expand__carryover__tests__carryover.snap @@ -0,0 +1,110 @@ +--- +source: apollo-federation/src/connectors/expand/carryover.rs +expression: schema.schema().serialize().to_string() +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @join__directive(graphs: [], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1"}) @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) @link(url: "https://specs.apollo.dev/tag/v0.3") @link(url: "https://specs.apollo.dev/authenticated/v0.1", for: SECURITY) @link(url: "https://specs.apollo.dev/requiresScopes/v0.1", for: SECURITY) @link(url: "https://specs.apollo.dev/policy/v0.1", for: SECURITY) @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) @link(url: "http://specs.example.org/custom/v0.1", import: ["@custom1", "@custom2", {name: "@originalName", as: "@custom3"}]) @link(url: "http://bugfix/weird/v1.0", import: ["@weird"]) @link(url: "https://specs.apollo.dev/context/v0.1", for: SECURITY) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @requiresScopes(scopes: [[requiresScopes__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @policy(policies: [[policy__Policy!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION + +directive @custom1 on OBJECT | FIELD_DEFINITION + +directive @custom2 on OBJECT | FIELD_DEFINITION + +directive @custom3 on OBJECT | FIELD_DEFINITION + +directive @weird on FIELD | FIELD_DEFINITION + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + ONE @join__graph(name: "one", url: "none") + TWO @join__graph(name: "two", url: "none") +} + +type Query @join__type(graph: ONE) @join__type(graph: TWO) { + tagged: String @join__field(graph: ONE, type: "String") @tag(name: "tag") + hidden: String @join__field(graph: ONE, type: "String") @inaccessible + custom: T @join__field(graph: ONE, type: "T") @custom1 + authenticated: String @join__field(graph: ONE, type: "String") @authenticated + requiresScopes: String @join__field(graph: ONE, type: "String") @requiresScopes(scopes: ["scope"]) + policy: String @join__field(graph: ONE, type: "String") @policy(policies: [["admin"]]) + overridden: String @join__field(graph: ONE, override: "two", overrideLabel: "label", type: "String") @join__field(graph: TWO, type: "String") + weird: [String] @join__field(graph: ONE, type: "[String]") @listSize(assumedSize: 99) @weird + customAgain: String @join__field(graph: TWO, type: "String") @custom1 + z: Z @join__field(graph: TWO, type: "Z") +} + +type T @join__type(graph: ONE) @custom2 { + field: String @join__field(graph: ONE, type: "String") @cost(weight: 5) @custom3 +} + +type X @join__type(graph: TWO, key: "id") { + id: ID! @join__field(graph: TWO, type: "ID!") + w: String @join__field(graph: TWO, type: "String", contextArguments: [{context: "two__ctx", name: "z", type: "String", selection: " { y }"}]) +} + +type Z @join__type(graph: TWO, key: "id") @context(name: "two__ctx") { + id: ID! @join__field(graph: TWO, type: "ID!") + y: String @join__field(graph: TWO, type: "String") + x: X @join__field(graph: TWO, type: "X") +} + +scalar context__ContextFieldValue + +scalar policy__Policy + +scalar requiresScopes__Scope diff --git a/apollo-federation/src/connectors/expand/tests/mod.rs b/apollo-federation/src/connectors/expand/tests/mod.rs new file mode 100644 index 0000000000..862872ead5 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/mod.rs @@ -0,0 +1,42 @@ +use std::fs::read_to_string; + +use insta::assert_debug_snapshot; +use insta::assert_snapshot; +use insta::glob; + +use crate::ApiSchemaOptions; +use crate::connectors::expand::ExpansionResult; +use crate::connectors::expand::expand_connectors; + +#[test] +fn it_expand_supergraph() { + insta::with_settings!({prepend_module_to_snapshot => false}, { + glob!("schemas/expand", "*.graphql", |path| { + let to_expand = read_to_string(path).unwrap(); + let ExpansionResult::Expanded { + raw_sdl, + api_schema, + connectors, + } = expand_connectors(&to_expand, &ApiSchemaOptions { include_defer: true, ..Default::default() }).unwrap() + else { + panic!("expected expansion to actually expand subgraphs for {path:?}"); + }; + + assert_snapshot!("api", api_schema); + assert_debug_snapshot!("connectors", connectors.by_service_name); + assert_snapshot!("supergraph", raw_sdl); + }); + }); +} + +#[test] +fn it_ignores_supergraph() { + insta::with_settings!({prepend_module_to_snapshot => false}, { + glob!("schemas/ignore", "*.graphql", |path| { + let to_ignore = read_to_string(path).unwrap(); + let ExpansionResult::Unchanged = expand_connectors(&to_ignore, &ApiSchemaOptions::default()).unwrap() else { + panic!("expected expansion to ignore non-connector supergraph for {path:?}"); + }; + }); + }); +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/batch.graphql b/apollo-federation/src/connectors/expand/tests/schemas/expand/batch.graphql new file mode 100644 index 0000000000..a1d77846d0 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/batch.graphql @@ -0,0 +1,71 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive(graphs: [ONE], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]}) + @join__directive(graphs: [ONE], name: "source", args: {name: "json", http: {baseURL: "http://localhost:4001/api"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + ONE @join__graph(name: "one", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: ONE) +{ + users: [User!]! @join__directive(graphs: [ONE], name: "connect", args: {source: "json", http: {GET: "/users"}, selection: "id"}) +} + +type User + @join__type(graph: ONE) + @join__directive(graphs: [ONE], name: "connect", args: {source: "json", http: {POST: "/users-batch", body: "ids: $batch.id"}, selection: "id name username"}) +{ + id: ID! + name: String + username: String +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/batch.yaml b/apollo-federation/src/connectors/expand/tests/schemas/expand/batch.yaml new file mode 100644 index 0000000000..3023bfd44f --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/batch.yaml @@ -0,0 +1,25 @@ +subgraphs: + one: + routing_url: http://none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11") + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "json", http: { baseURL: "http://localhost:4001/api" }) + + type Query { + users: [User!]! + @connect(source: "json", http: { GET: "/users" }, selection: "id") + } + + type User + @connect(source: "json" + http: { POST: "/users-batch", body: "ids: $$batch.id" } + selection: "id name username" + ) + { + id: ID! + name: String + username: String + } diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/carryover.graphql b/apollo-federation/src/connectors/expand/tests/schemas/expand/carryover.graphql new file mode 100644 index 0000000000..57a6a5c186 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/carryover.graphql @@ -0,0 +1,202 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/tag/v0.3") + @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) + @link(url: "https://specs.apollo.dev/authenticated/v0.1", for: SECURITY) + @link(url: "https://specs.apollo.dev/requiresScopes/v0.1", for: SECURITY) + @link(url: "https://specs.apollo.dev/policy/v0.1", for: SECURITY) + @link(url: "https://specs.apollo.dev/context/v0.1", for: SECURITY) + @link(url: "http://specs.example.org/custom/v0.1", import: ["@custom"]) + @link(url: "http://specs.example.org/custom2/v0.1", import: ["@custom2"]) + @link(url: "http://specs.example.org/custom3/v0.1", import: ["@custom3"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive(graphs: [ONE], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [ONE], name: "source", args: {name: "json", http: {baseURL: "http://example/"}}) +{ + query: Query +} + +directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @custom(s: custom__Scalar, e: custom__Enum, i: custom__Input) on OBJECT | FIELD_DEFINITION + +directive @custom2(s: custom__Scalar2, e: custom__Enum2, i: custom__Input2) on OBJECT | FIELD_DEFINITION + +directive @custom3(s: custom__Scalar3, e: custom__Enum3, i: custom__Input3) on OBJECT | FIELD_DEFINITION + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @policy(policies: [[policy__Policy!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @requiresScopes(scopes: [[requiresScopes__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +scalar context__ContextFieldValue + +enum custom__Enum + @join__type(graph: ONE) + @join__type(graph: TWO) +{ + ONE @join__enumValue(graph: ONE) @join__enumValue(graph: TWO) + TWO @join__enumValue(graph: ONE) @join__enumValue(graph: TWO) +} + +enum custom__Enum2 + @join__type(graph: ONE) +{ + ONE @join__enumValue(graph: ONE) + TWO @join__enumValue(graph: ONE) +} + +enum custom__Enum3 + @join__type(graph: TWO) +{ + ONE @join__enumValue(graph: TWO) + TWO @join__enumValue(graph: TWO) +} + +input custom__Input + @join__type(graph: ONE) + @join__type(graph: TWO) +{ + one: String + two: String +} + +input custom__Input2 + @join__type(graph: ONE) +{ + one: String + two: String +} + +input custom__Input3 + @join__type(graph: TWO) +{ + one: String + two: String +} + +scalar custom__Scalar + @join__type(graph: ONE) + @join__type(graph: TWO) + +scalar custom__Scalar2 + @join__type(graph: ONE) + +scalar custom__Scalar3 + @join__type(graph: TWO) + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + ONE @join__graph(name: "one", url: "none") + TWO @join__graph(name: "two", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +scalar policy__Policy + +type Query + @join__type(graph: ONE) + @join__type(graph: TWO) +{ + ts: [T] @join__field(graph: ONE) @join__directive(graphs: [ONE], name: "connect", args: {source: "json", http: {GET: "/t"}, selection: "id\ntagged\nhidden\ncustom\nauthenticated\nrequiresScopes\npolicy\noverridden"}) + t(id: ID): T @join__field(graph: ONE) @join__directive(graphs: [ONE], name: "connect", args: {source: "json", http: {GET: "/t/{$args.id}"}, selection: "id\ntagged\nhidden\ncustom\nauthenticated\nrequiresScopes\npolicy\noverridden", entity: true}) + z: Z @join__field(graph: TWO) +} + +type R + @join__type(graph: ONE) +{ + id: ID! +} + +scalar requiresScopes__Scope + +type T + @join__type(graph: ONE, key: "id") + @join__type(graph: TWO, key: "id") +{ + id: ID! + tagged: TEnum @join__field(graph: ONE) @tag(name: "tag") + hidden: String @inaccessible @join__field(graph: ONE) + custom: String @join__field(graph: ONE) @custom @custom2 + authenticated: String @join__field(graph: ONE) @authenticated + requiresScopes: String @join__field(graph: ONE) @requiresScopes(scopes: ["scope"]) + policy: String @join__field(graph: ONE) @policy(policies: [["admin"]]) + overridden: String @join__field(graph: ONE, override: "two", overrideLabel: "label") @join__field(graph: TWO, overrideLabel: "label") + r: R @join__field(graph: ONE) @join__directive(graphs: [ONE], name: "connect", args: {source: "json", http: {GET: "/t/{$this.id}/r"}, selection: "id"}) +} + +type X + @join__type(graph: TWO, key: "id") +{ + id: ID! + w: String @join__field(graph: TWO, contextArguments: [{context: "two__ctx", name: "z", type: "String", selection: " { y }"}]) +} + +type Z + @join__type(graph: TWO, key: "id") + @context(name: "two__ctx") +{ + id: ID! + y: String @custom(s: "x", e: ONE, i: {one: "one"}) + x: X @custom3(s: "x", e: ONE, i: {one: "one"}) +} + +enum TEnum @join__type(graph: ONE) { + ONE + TWO +} + +input UnusedInput @join__type(graph: ONE) { + one: String + two: TEnum +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/carryover.yaml b/apollo-federation/src/connectors/expand/tests/schemas/expand/carryover.yaml new file mode 100644 index 0000000000..0a50d26604 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/carryover.yaml @@ -0,0 +1,177 @@ +subgraphs: + one: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: [ + "@key", + "@inaccessible", "@tag", "@override", + "@authenticated", "@requiresScopes", "@policy", + "@composeDirective" + ] + ) + @link(url: "http://specs.example.org/custom/v0.1", import: ["@custom"]) + @link(url: "http://specs.example.org/custom2/v0.1", import: ["@custom2"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @composeDirective(name: "@custom") + @composeDirective(name: "@custom2") + @source(name: "json" http: { baseURL: "http://example/" }) + + type Query { + ts: [T] @connect( + source: "json" + http: { GET: "/t" } + selection: """ + id + tagged + hidden + custom + authenticated + requiresScopes + policy + overridden + """ + ) + t(id: ID): T @connect( + source: "json" + http: { GET: "/t/{$$args.id}" } + selection: """ + id + tagged + hidden + custom + authenticated + requiresScopes + policy + overridden + """ + entity: true + ) + } + + type T @key(fields: "id") { + id: ID! + tagged: TEnum @tag(name: "tag") + hidden: String @inaccessible + custom: String @custom @custom2 + authenticated: String @authenticated + requiresScopes: String @requiresScopes(scopes: ["scope"]) + policy: String @policy(policies: [["admin"]]) + overridden: String @override(from: "two", label: "label") + r: R @connect( + source: "json" + http: { GET: "/t/{$$this.id}/r" } + selection: "id" + ) + } + + enum TEnum { + ONE + TWO + } + + # if we carry this definition over, it won't have a valid reference + # to the enum it its expanded subgraph, so it'll fail in JS merging + input UnusedInput { + one: String + two: TEnum + } + + type R { + id: ID! + } + + # bug fix: this won't compose until it's fixed and released in federation + # the graphql file is currently hand-edited to add these definitions + # + # @custom appears in both subgraphs, so will be merged appropriately, and it will attributed only to the non-connector subgraph + # @custom2 appears in the connector subgraph, so we have to add it and rewrite the join__* directives + # @custom3 appears in the non-connector subgraph, so it's composed appropriately + # + # this won't compose until after 2.11.0-preview.3 + + directive @custom(s: custom__Scalar, e: custom__Enum, i: custom__Input) on OBJECT | FIELD_DEFINITION + + scalar custom__Scalar + + enum custom__Enum { + ONE + TWO + } + + input custom__Input { + one: String + two: String + } + + directive @custom2(s: custom__Scalar2, e: custom__Enum2, i: custom__Input2) on OBJECT | FIELD_DEFINITION + + scalar custom__Scalar2 + + enum custom__Enum2 { + ONE + TWO + } + + input custom__Input2 { + one: String + two: String + } + two: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@key", "@context", "@fromContext", "@composeDirective"]) + @link(url: "http://specs.example.org/custom/v0.1", import: ["@custom"]) + @link(url: "http://specs.example.org/custom3/v0.1", import: ["@custom3"]) + @composeDirective(name: "@custom") + @composeDirective(name: "@custom3") + + type T @key(fields: "id") { + id: ID! + overridden: String + } + + type Query { + z: Z + } + + type Z @key(fields: "id") @context(name: "ctx") { + id: ID! + y: String @custom(s: "x", e: ONE, i: { one: "one" }) + x: X @custom3(s: "x", e: ONE, i: { one: "one" }) + } + + type X @key(fields: "id") { + id: ID! + w(z: String @fromContext(field: "$$ctx { y }")): String + } + + directive @custom(s: custom__Scalar, e: custom__Enum, i: custom__Input) on OBJECT | FIELD_DEFINITION + scalar custom__Scalar + + enum custom__Enum { + ONE + TWO + } + + input custom__Input { + one: String + two: String + } + + directive @custom3(s: custom__Scalar3, e: custom__Enum3, i: custom__Input3) on OBJECT | FIELD_DEFINITION + + scalar custom__Scalar3 + + enum custom__Enum3 { + ONE + TWO + } + + input custom__Input3 { + one: String + two: String + } diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/directives.graphql b/apollo-federation/src/connectors/expand/tests/schemas/expand/directives.graphql new file mode 100644 index 0000000000..6f100f5798 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/directives.graphql @@ -0,0 +1,135 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @link(url: "https://specs.apollo.dev/tag/v0.3") + @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) + @link(url: "https://specs.apollo.dev/authenticated/v0.1", for: SECURITY) + @link(url: "https://specs.apollo.dev/requiresScopes/v0.1", for: SECURITY) + @link(url: "https://specs.apollo.dev/policy/v0.1", for: SECURITY) + @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) + @link(url: "https://specs.apollo.dev/context/v0.1", for: SECURITY) + @link(url: "http://specs.example.org/custom/v0.1", import: ["@custom1", "@custom2", {name: "@originalName", as: "@custom3"}]) + @link(url: "http://bugfix/weird/v1.0", import: ["@weird"]) +{ + query: Query +} + +directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @custom1 on OBJECT | FIELD_DEFINITION + +directive @custom2 on OBJECT | FIELD_DEFINITION + +directive @custom3 on OBJECT | FIELD_DEFINITION + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION + +directive @policy(policies: [[policy__Policy!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @requiresScopes(scopes: [[requiresScopes__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @weird on FIELD | FIELD_DEFINITION + +scalar context__ContextFieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + ONE @join__graph(name: "one", url: "none") + TWO @join__graph(name: "two", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +scalar policy__Policy + +type Query + @join__type(graph: ONE) + @join__type(graph: TWO) +{ + tagged: String @join__field(graph: ONE) @tag(name: "tag") + hidden: String @inaccessible @join__field(graph: ONE) + custom: T @join__field(graph: ONE) @custom1 + authenticated: String @join__field(graph: ONE) @authenticated + requiresScopes: String @join__field(graph: ONE) @requiresScopes(scopes: ["scope"]) + policy: String @join__field(graph: ONE) @policy(policies: [["admin"]]) + overridden: String @join__field(graph: ONE, override: "two", overrideLabel: "label") @join__field(graph: TWO, overrideLabel: "label") + weird: [String] @join__field(graph: ONE) @weird @listSize(assumedSize: 99) + customAgain: String @join__field(graph: TWO) @custom1 + z: Z @join__field(graph: TWO) +} + +scalar requiresScopes__Scope + +type T + @join__type(graph: ONE) + @custom2 +{ + field: String @custom3 @cost(weight: 5) +} + +type X + @join__type(graph: TWO, key: "id") +{ + id: ID! + w: String @join__field(graph: TWO, contextArguments: [{context: "two__ctx", name: "z", type: "String", selection: " { y }"}]) +} + +type Z + @join__type(graph: TWO, key: "id") + @context(name: "two__ctx") +{ + id: ID! + y: String + x: X +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/directives.yaml b/apollo-federation/src/connectors/expand/tests/schemas/expand/directives.yaml new file mode 100644 index 0000000000..cdff790bba --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/directives.yaml @@ -0,0 +1,82 @@ +subgraphs: + one: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: [ + "@override", "@inaccessible", "@tag", + "@authenticated", "@requiresScopes", "@policy", + "@cost", "@listSize", + "@composeDirective" + ] + ) + @link(url: "http://specs.example.org/custom/v0.1", import: ["@custom1", "@custom2", { name: "@originalName", as: "@custom3" }]) + @composeDirective(name: "@custom1") + @composeDirective(name: "@custom2") + @composeDirective(name: "@custom3") + directive @custom1 on OBJECT | FIELD_DEFINITION + directive @custom2 on OBJECT | FIELD_DEFINITION + directive @custom3 on OBJECT | FIELD_DEFINITION + type Query { + tagged: String @tag(name: "tag") + hidden: String @inaccessible + custom: T @custom1 + authenticated: String @authenticated + requiresScopes: String @requiresScopes(scopes: ["scope"]) + policy: String @policy(policies: [["admin"]]) + overridden: String @override(from: "two", label: "label") + } + + type T @custom2 { + field: String @custom3 @cost(weight: 5) + } + + # bug fix: if a customer tries to define @defer this way, it should be ignored + extend schema + @link(url: "http://bugfix/namespace/v1.0", import: ["@defer"]) + + directive @defer(label: String) on FIELD + + # bug fix: don't redefine scalars if the user defines them for some reason + scalar federation__RequireScopes + scalar federation__Policy + + # bug fix: here's a weird directive that's both executable and type system + extend schema + @link(url: "http://bugfix/weird/v1.0", import: ["@weird"]) + @composeDirective(name: "@weird") + + directive @weird on FIELD | FIELD_DEFINITION + + extend type Query { + weird: [String] @weird @listSize(assumedSize: 99) + } + two: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@key", "@composeDirective", "@context", "@fromContext"]) + @link(url: "http://specs.example.org/custom/v0.1", import: ["@custom1", "@custom2", { name: "@originalName", as: "@custom3" }]) + @composeDirective(name: "@custom1") + + directive @custom1 on OBJECT | FIELD_DEFINITION + directive @custom2 on OBJECT | FIELD_DEFINITION + directive @custom3 on OBJECT | FIELD_DEFINITION + type Query { + overridden: String + customAgain: String @custom1 + z: Z + } + + type Z @key(fields: "id") @context(name: "ctx") { + id: ID! + y: String + x: X + } + + type X @key(fields: "id") { + id: ID! + w(z: String @fromContext(field: "$$ctx { y }")): String + } \ No newline at end of file diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/interface-object.graphql b/apollo-federation/src/connectors/expand/tests/schemas/expand/interface-object.graphql new file mode 100644 index 0000000000..a7364124c6 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/interface-object.graphql @@ -0,0 +1,97 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "http://localhost:4001"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +interface Itf + @join__type(graph: CONNECTORS, key: "id", isInterfaceObject: true) + @join__type(graph: GRAPHQL, key: "id") +{ + id: ID! + c: Int! @join__field(graph: CONNECTORS) + d: Int! @join__field(graph: CONNECTORS) + e: String @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/itfs/{$this.id}/e"}, selection: "$"}) +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") + GRAPHQL @join__graph(name: "graphql", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) + @join__type(graph: GRAPHQL) +{ + itfs: [Itf] @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/itfs"}, selection: "id c"}) + itf(id: ID!): Itf @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/itfs/{$args.id}"}, selection: "id c d", entity: true}) +} + +type T1 implements Itf + @join__implements(graph: GRAPHQL, interface: "Itf") + @join__type(graph: GRAPHQL, key: "id") +{ + id: ID! + a: String + c: Int! @join__field + d: Int! @join__field + e: String @join__field +} + +type T2 implements Itf + @join__implements(graph: GRAPHQL, interface: "Itf") + @join__type(graph: GRAPHQL, key: "id") +{ + id: ID! + b: String + c: Int! @join__field + d: Int! @join__field + e: String @join__field +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/interface-object.yaml b/apollo-federation/src/connectors/expand/tests/schemas/expand/interface-object.yaml new file mode 100644 index 0000000000..8e91569443 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/interface-object.yaml @@ -0,0 +1,59 @@ +# requires federation_version: =2.10.0-preview.3 # NOTE: unreleased at time of writing +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key", "@interfaceObject"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source(name: "json", http: { baseURL: "http://localhost:4001" }) + + type Query { + itfs: [Itf] + @connect( + source: "json" + http: { GET: "/itfs" } + selection: "id c" + ) + + itf(id: ID!): Itf + @connect( + source: "json" + http: { GET: "/itfs/{$$args.id}" } + selection: "id c d" + entity: true + ) + } + + type Itf @key(fields: "id") @interfaceObject { + id: ID! + c: Int! + d: Int! + e: String + @connect( + source: "json" + http: { GET: "/itfs/{$$this.id}/e" } + selection: "$" + ) + } + graphql: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + + interface Itf @key(fields: "id") { + id: ID! + } + + type T1 implements Itf @key(fields: "id") { + id: ID! + a: String + } + + type T2 implements Itf @key(fields: "id") { + id: ID! + b: String + } diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/keys.graphql b/apollo-federation/src/connectors/expand/tests/schemas/expand/keys.graphql new file mode 100644 index 0000000000..c4928c19cb --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/keys.graphql @@ -0,0 +1,88 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [ONE], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect"]}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + ONE @join__graph(name: "one", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: ONE) +{ + t(id: ID!): T @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/ts/{$args.id}"}, selection: "id id2 unselected", entity: true}) + t2(id: ID!, id2: ID!): T @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/ts/{$args.id}?id2={$args.id2}"}, selection: "id id2 unselected", entity: true}) + + """ Uses the `unselected` field as a key, but doesn't select it """ + unselected(unselected: ID!): T @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/ts/{$args.unselected}"}, selection: "id id2 accessibleByUnselected", entity: true}) +} + +type R + @join__type(graph: ONE) +{ + id: ID! + id2: ID! +} + +type T + @join__type(graph: ONE, key: "id") + @join__type(graph: ONE, key: "id id2") + @join__type(graph: ONE, key: "unselected") +{ + id: ID! + id2: ID! + unselected: ID! + accessibleByUnselected: ID! + r1: R @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/rs/{$this.id}"}, selection: "id id2"}) + r2: R @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/rs/{$this.id}?id2={$this.id2}"}, selection: "id id2"}) + r3: R @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/rs/{$this.id}"}, selection: "id id2: $this.id2"}) + r4: R @join__directive(graphs: [ONE], name: "connect", args: {http: {POST: "http://localhost/rs", body: "id: $this.id"}, selection: "id id2"}) + r5: R @join__directive(graphs: [ONE], name: "connect", args: {http: {POST: "http://localhost/rs", body: "id: $this.id"}, selection: "id id2: $this.id2"}) +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/keys.yaml b/apollo-federation/src/connectors/expand/tests/schemas/expand/keys.yaml new file mode 100644 index 0000000000..aa80b407e4 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/keys.yaml @@ -0,0 +1,44 @@ +subgraphs: + one: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect"]) + type Query { + t(id: ID!): T + @connect( # expect `key: "id"` + http: { GET: "http://localhost/ts/{$$args.id}" } + selection: "id id2 unselected" + entity: true + ) + t2(id: ID!, id2: ID!): T + @connect( # expect `key: "id id2"` + http: { GET: "http://localhost/ts/{$$args.id}?id2={$$args.id2}" } + selection: "id id2 unselected" + entity: true + ) + """ Uses the `unselected` field as a key, but doesn't select it """ + unselected(unselected: ID!): T + @connect( + http: { GET: "http://localhost/ts/{$$args.unselected}" } + selection: "id id2 accessibleByUnselected" + entity: true + ) + } + type T @key(fields: "id") @key(fields: "id id2") @key(fields: "unselected") { + id: ID! + id2: ID! + unselected: ID! + accessibleByUnselected: ID! + r1: R @connect(http: { GET: "http://localhost/rs/{$$this.id}" }, selection: "id id2") # expect `key: "id"`` + r2: R @connect(http: { GET: "http://localhost/rs/{$$this.id}?id2={$$this.id2}" }, selection: "id id2") # expect `key: "id id2"` + r3: R @connect(http: { GET: "http://localhost/rs/{$$this.id}" }, selection: "id id2: $$this.id2") # expect `key: "id id2"` + r4: R @connect(http: { POST: "http://localhost/rs" body: "id: $$this.id" }, selection: "id id2") # expect `key: "id"` + r5: R @connect(http: { POST: "http://localhost/rs" body: "id: $$this.id" }, selection: "id id2: $$this.id2") # expect `key: "id id2"` + } + type R { + id: ID! + id2: ID! + } diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/nested_inputs.graphql b/apollo-federation/src/connectors/expand/tests/schemas/expand/nested_inputs.graphql new file mode 100644 index 0000000000..192ab5c497 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/nested_inputs.graphql @@ -0,0 +1,75 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "example", http: {baseURL: "http://example"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input BazInput + @join__type(graph: CONNECTORS) +{ + buzz: String + quux: QuuxInput +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + foo(bar: String, baz: BazInput, doubleBaz: BazInput): String @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "example", http: {GET: "/{$args.bar}/{$args.doubleBaz.buzz}/{$args.baz.quux.quaz}"}, selection: "$"}) +} + +input QuuxInput + @join__type(graph: CONNECTORS) +{ + quaz: String +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/nested_inputs.yaml b/apollo-federation/src/connectors/expand/tests/schemas/expand/nested_inputs.yaml new file mode 100644 index 0000000000..5d0ca4fa09 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/nested_inputs.yaml @@ -0,0 +1,29 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.7" + import: ["@key"] + ) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source(name: "example", http: { baseURL: "http://example" }) + + type Query { + foo(bar: String, baz: BazInput, doubleBaz: BazInput): String @connect( + source: "example", + http: { GET: "/{$$args.bar}/{$$args.doubleBaz.buzz}/{$$args.baz.quux.quaz}" } + selection: "$" + ) + } + + input BazInput { + buzz: String + quux: QuuxInput + } + + input QuuxInput { + quaz: String + } diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/normalize_names.graphql b/apollo-federation/src/connectors/expand/tests/schemas/expand/normalize_names.graphql new file mode 100644 index 0000000000..346fd6e0c5 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/normalize_names.graphql @@ -0,0 +1,71 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS_SUBGRAPH], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS_SUBGRAPH], name: "source", args: {name: "example", http: {baseURL: "http://example"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS_SUBGRAPH @join__graph(name: "connectors-subgraph", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS_SUBGRAPH) +{ + users: [User] @join__directive(graphs: [CONNECTORS_SUBGRAPH], name: "connect", args: {source: "example", http: {GET: "/"}, selection: "id a"}) + user(id: ID!): User @join__directive(graphs: [CONNECTORS_SUBGRAPH], name: "connect", args: {source: "example", http: {GET: "/{$args.id}"}, selection: "id a b", entity: true}) +} + +type User + @join__type(graph: CONNECTORS_SUBGRAPH, key: "id") +{ + id: ID! + a: String + b: String +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/normalize_names.yaml b/apollo-federation/src/connectors/expand/tests/schemas/expand/normalize_names.yaml new file mode 100644 index 0000000000..c0f92868e0 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/normalize_names.yaml @@ -0,0 +1,25 @@ +subgraphs: + connectors-subgraph: + routing_url: none + schema: + sdl: | + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.7" + import: ["@key", "@external", "@requires"] + ) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source(name: "example", http: { baseURL: "http://example" }) + + type Query { + users: [User] @connect(source: "example", http: { GET: "/" }, selection: "id a") + + user(id: ID!): User + @connect(source: "example", http: { GET: "/{$$args.id}" }, selection: "id a b", entity: true) + } + + type User @key(fields: "id") { + id: ID! + a: String + b: String + } \ No newline at end of file diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/realistic.graphql b/apollo-federation/src/connectors/expand/tests/schemas/expand/realistic.graphql new file mode 100644 index 0000000000..888fbb3ebc --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/realistic.graphql @@ -0,0 +1,154 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "example", http: {baseURL: "http://example"}}) +{ + query: Query + mutation: Mutation +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type Address + @join__type(graph: CONNECTORS) +{ + street: String + suite: String + city: String + zipcode: String + geo: AddressGeo +} + +type AddressGeo + @join__type(graph: CONNECTORS) +{ + lat: Float + lng: Float +} + +input AddressGeoInput + @join__type(graph: CONNECTORS) +{ + lat: Float + lng: Float +} + +input AddressInput + @join__type(graph: CONNECTORS) +{ + street: String + suite: String + city: String + zipcode: String + geo: AddressGeoInput +} + +type CompanyInfo + @join__type(graph: CONNECTORS) +{ + name: String + catchPhrase: String + bs: String + email: EmailAddress +} + +input CompanyInput + @join__type(graph: CONNECTORS) +{ + name: String! + catchPhrase: String +} + +input CreateUserInput + @join__type(graph: CONNECTORS) +{ + name: String! + username: String! + email: EmailAddress! + status: Status! + address: AddressInput +} + +scalar EmailAddress + @join__type(graph: CONNECTORS) + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Mutation + @join__type(graph: CONNECTORS) +{ + createUser(input: CreateUserInput!): User @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "example", http: {POST: "/create/user", body: "$args.input { name username email status address { street suite city zipcode geo { lat lng } } }"}, selection: "id"}) +} + +type Query + @join__type(graph: CONNECTORS) +{ + filterUsersByEmailDomain(email: EmailAddress!): [User] @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "example", http: {GET: "/filter/users", body: "emailDomain: $args.email"}, selection: "id\nname"}) + usersByCompany(company: CompanyInput!): [User] @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "example", http: {GET: "/by-company/{$args.company.name}"}, selection: "id\nname\ncompany {\n name\n catchPhrase\n bs\n}"}) + user(id: ID!): User @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "example", http: {GET: "/{$args.id}"}, selection: "id\nname\nusername\nemail\naddress {\n street\n suite\n city\n zipcode\n geo {\n lat\n lng\n }\n}\nphone\nwebsite\ncompany {\n name\n catchPhrase\n bs\n email\n}", entity: true}) +} + +enum Status + @join__type(graph: CONNECTORS) +{ + ACTIVE @join__enumValue(graph: CONNECTORS) + INACTIVE @join__enumValue(graph: CONNECTORS) +} + +type User + @join__type(graph: CONNECTORS, key: "id") +{ + id: ID! + name: String + username: String + email: EmailAddress + address: Address + phone: String + website: String + company: CompanyInfo +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/realistic.yaml b/apollo-federation/src/connectors/expand/tests/schemas/expand/realistic.yaml new file mode 100644 index 0000000000..62d16264a9 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/realistic.yaml @@ -0,0 +1,124 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.10" + import: ["@key"] + ) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source(name: "example", http: { baseURL: "http://example" }) + + type Query { + filterUsersByEmailDomain(email: EmailAddress!): [User] + @connect(source: "example", http: { GET: "/filter/users", body: "emailDomain: $$args.email" }, selection: """ + id + name + """) + + usersByCompany(company: CompanyInput!): [User] + @connect(source: "example", http: { GET: "/by-company/{$$args.company.name}" }, selection: """ + id + name + company { + name + catchPhrase + bs + }""") + + user(id: ID!): User + @connect(source: "example", http: { GET: "/{$$args.id}" }, selection: """ + id + name + username + email + address { + street + suite + city + zipcode + geo { + lat + lng + } + } + phone + website + company { + name + catchPhrase + bs + email + }""", entity: true) + } + + type User @key(fields: "id") { + id: ID! + name: String + username: String + email: EmailAddress + address: Address + phone: String + website: String + company: CompanyInfo + } + + type Address { + street: String + suite: String + city: String + zipcode: String + geo: AddressGeo + } + + type AddressGeo { + lat: Float + lng: Float + } + + type CompanyInfo { + name: String + catchPhrase: String + bs: String + email: EmailAddress + } + + input CompanyInput { + name: String! + catchPhrase: String + } + + scalar EmailAddress + + enum Status { + ACTIVE + INACTIVE + } + + type Mutation { + createUser(input: CreateUserInput!): User + @connect(source: "example", http: { POST: "/create/user", body: "$$args.input { name username email status address { street suite city zipcode geo { lat lng } } }" }, selection: "id") + } + + input CreateUserInput { + name: String! + username: String! + email: EmailAddress! + status: Status! + address: AddressInput + } + + input AddressInput { + street: String + suite: String + city: String + zipcode: String + geo: AddressGeoInput + } + + input AddressGeoInput { + lat: Float + lng: Float + } diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/sibling_fields.graphql b/apollo-federation/src/connectors/expand/tests/schemas/expand/sibling_fields.graphql new file mode 100644 index 0000000000..9acf89e7a2 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/sibling_fields.graphql @@ -0,0 +1,75 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "v1", http: {baseURL: "https://rt-airlock-services-listing.herokuapp.com"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +type K + @join__type(graph: CONNECTORS) +{ + id: ID! +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + f: T @join__directive(graphs: [CONNECTORS], name: "connect", args: {http: {GET: "https://my.api/t"}, selection: "k { id }"}) +} + +type T + @join__type(graph: CONNECTORS) +{ + k: K + b: String @join__directive(graphs: [CONNECTORS], name: "connect", args: {http: {GET: "https://my.api/t/{$this.k.id}"}, selection: "b"}) +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/sibling_fields.yaml b/apollo-federation/src/connectors/expand/tests/schemas/expand/sibling_fields.yaml new file mode 100644 index 0000000000..6657921a32 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/sibling_fields.yaml @@ -0,0 +1,35 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source( + name: "v1" + http: { baseURL: "https://rt-airlock-services-listing.herokuapp.com" } + ) + + type T { + k: K + b: String + @connect(http: { GET: "https://my.api/t/{$$this.k.id}" }, selection: "b") + } + + type K { + id: ID! + } + + type Query { + f: T + @connect( + http: { GET: "https://my.api/t" } + selection: """ + k { id } + """ + ) + } diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/simple.graphql b/apollo-federation/src/connectors/expand/tests/schemas/expand/simple.graphql new file mode 100644 index 0000000000..aa26ff6f2f --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/simple.graphql @@ -0,0 +1,76 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "example", http: {baseURL: "http://example"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") + GRAPHQL @join__graph(name: "graphql", url: "https://graphql") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) + @join__type(graph: GRAPHQL) +{ + users: [User] @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "example", http: {GET: "/"}, selection: "id a"}) + user(id: ID!): User @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "example", http: {GET: "/{$args.id}"}, selection: "id a b", entity: true}) +} + +type User + @join__type(graph: CONNECTORS, key: "id") + @join__type(graph: GRAPHQL, key: "id") +{ + id: ID! + a: String @join__field(graph: CONNECTORS) + b: String @join__field(graph: CONNECTORS) + c: String @join__field(graph: CONNECTORS, external: true) @join__field(graph: GRAPHQL) + d: String @join__field(graph: CONNECTORS, requires: "c") @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "example", http: {GET: "/{$this.c}/d", body: "with_b: $this.b"}, selection: "$"}) +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/simple.yaml b/apollo-federation/src/connectors/expand/tests/schemas/expand/simple.yaml new file mode 100644 index 0000000000..239f5885aa --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/simple.yaml @@ -0,0 +1,41 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.7" + import: ["@key", "@external", "@requires"] + ) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source(name: "example", http: { baseURL: "http://example" }) + + type Query { + users: [User] @connect(source: "example", id: "alpha_num_123", http: { GET: "/" }, selection: "id a") + + user(id: ID!): User + @connect(source: "example", http: { GET: "/{$$args.id}" }, selection: "id a b", entity: true) + } + + type User @key(fields: "id") { + id: ID! + a: String + b: String + c: String @external + d: String + @requires(fields: "c") + @connect(source: "example", http: { GET: "/{$$this.c}/d", body: "with_b: $$this.b" }, selection: "$") + } + + graphql: + routing_url: https://graphql + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + + type User @key(fields: "id") { + id: ID! + c: String + } diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/steelthread.graphql b/apollo-federation/src/connectors/expand/tests/schemas/expand/steelthread.graphql new file mode 100644 index 0000000000..462a345fbd --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/steelthread.graphql @@ -0,0 +1,79 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "https://jsonplaceholder.typicode.com/"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") + GRAPHQL @join__graph(name: "graphql", url: "https://localhost:4001") +} + +scalar JSON + @join__type(graph: CONNECTORS) + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) + @join__type(graph: GRAPHQL) +{ + users: [User] @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users"}, selection: "id name"}) + user(id: ID!): User @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users/{$args.id}"}, selection: "id\nname\nusername", entity: true}) +} + +type User + @join__type(graph: CONNECTORS, key: "id") + @join__type(graph: GRAPHQL, key: "id") +{ + id: ID! + name: String @join__field(graph: CONNECTORS) + username: String @join__field(graph: CONNECTORS) + c: String @join__field(graph: CONNECTORS, external: true) @join__field(graph: GRAPHQL) + d: String @join__field(graph: CONNECTORS, requires: "c") @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users/{$this.c}"}, selection: "$.phone"}) +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/steelthread.yaml b/apollo-federation/src/connectors/expand/tests/schemas/expand/steelthread.yaml new file mode 100644 index 0000000000..25c07a8b40 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/steelthread.yaml @@ -0,0 +1,66 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.10" + import: ["@key", "@external", "@requires", "@shareable"] + ) + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source( + name: "json" + http: { baseURL: "https://jsonplaceholder.typicode.com/" } + ) + + type Query { + users: [User] + @connect(source: "json", http: { GET: "/users" }, selection: "id name") + + user(id: ID!): User + @connect( + source: "json" + http: { GET: "/users/{$$args.id}" } + selection: """ + id + name + username + """ + entity: true + ) + } + + type User @key(fields: "id") { + id: ID! + name: String + username: String + c: String @external + d: String + @requires(fields: "c") + @connect( + source: "json" + http: { GET: "/users/{$$this.c}" } + selection: "$.phone" + ) + } + + scalar JSON + + graphql: + routing_url: https://localhost:4001 + schema: + sdl: | + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.7" + import: ["@key"] + ) + + type User @key(fields: "id") { + id: ID! + c: String + } diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/types_used_twice.graphql b/apollo-federation/src/connectors/expand/tests/schemas/expand/types_used_twice.graphql new file mode 100644 index 0000000000..1658ad5162 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/types_used_twice.graphql @@ -0,0 +1,81 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "example", http: {baseURL: "http://example"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type A + @join__type(graph: CONNECTORS) +{ + id: ID +} + +type B + @join__type(graph: CONNECTORS) +{ + a: A +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + ts: [T] @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "example", http: {GET: "/"}, selection: "a { id } b { a { id } }"}) +} + +type T + @join__type(graph: CONNECTORS) +{ + a: A + b: B +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/types_used_twice.yaml b/apollo-federation/src/connectors/expand/tests/schemas/expand/types_used_twice.yaml new file mode 100644 index 0000000000..379b183d97 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/types_used_twice.yaml @@ -0,0 +1,29 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.7" + import: ["@key", "@external", "@requires"] + ) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source(name: "example", http: { baseURL: "http://example" }) + + type Query { + ts: [T] @connect(source: "example", http: { GET: "/" }, selection: "a { id } b { a { id } }") + } + + type T { + a: A + b: B + } + + type A { + id: ID + } + + type B { + a: A + } diff --git a/apollo-federation/src/connectors/expand/tests/schemas/ignore/ignored.graphql b/apollo-federation/src/connectors/expand/tests/schemas/ignore/ignored.graphql new file mode 100644 index 0000000000..9424c35ed9 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/ignore/ignored.graphql @@ -0,0 +1,57 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar join__DirectiveArguments + +scalar join__FieldSet + +enum join__Graph { + GRAPHQL @join__graph(name: "graphql", url: "https://graphql") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: GRAPHQL) +{ + users: [User] +} + +type User + @join__type(graph: GRAPHQL, key: "id") +{ + id: ID! + c: String +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/ignore/ignored.yaml b/apollo-federation/src/connectors/expand/tests/schemas/ignore/ignored.yaml new file mode 100644 index 0000000000..b51befad9d --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/ignore/ignored.yaml @@ -0,0 +1,16 @@ +subgraphs: + graphql: + routing_url: https://graphql + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + + type User @key(fields: "id") { + id: ID! + c: String + } + + type Query { + users: [User] + } diff --git a/apollo-federation/src/connectors/expand/tests/schemas/regenerate.sh b/apollo-federation/src/connectors/expand/tests/schemas/regenerate.sh new file mode 100755 index 0000000000..8a569a3331 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/regenerate.sh @@ -0,0 +1,26 @@ +# Composes a single supergraph config file passed as an argument or all `.yaml` files in any subdirectories. +# For each supergraph config, outputs a `.graphql` file in the same directory. +# Optionally, you can set `FEDERATION_VERSION` to override the supergraph binary used +set -euo pipefail + +if [ -z "${FEDERATION_VERSION:-}" ]; then + FEDERATION_VERSION="2.10.0-preview.2" +fi + +regenerate_graphql() { + local supergraph_config=$1 + local test_name + test_name=$(basename "$supergraph_config" .yaml) + local dir_name + dir_name=$(dirname "$supergraph_config") + echo "Regenerating $dir_name/$test_name.graphql" + rover supergraph compose --federation-version "=$FEDERATION_VERSION" --config "$supergraph_config" > "$dir_name/$test_name.graphql" +} + +if [ -z "${1:-}" ]; then + for supergraph_config in */*.yaml; do + regenerate_graphql "$supergraph_config" + done +else + regenerate_graphql "$1" +fi \ No newline at end of file diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/api@batch.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/api@batch.graphql.snap new file mode 100644 index 0000000000..204b87e97e --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/api@batch.graphql.snap @@ -0,0 +1,16 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: api_schema +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/batch.graphql +--- +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +type Query { + users: [User!]! +} + +type User { + id: ID! + name: String + username: String +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/api@carryover.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/api@carryover.graphql.snap new file mode 100644 index 0000000000..0a2c9b8355 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/api@carryover.graphql.snap @@ -0,0 +1,48 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: api_schema +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/carryover.graphql +--- +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +type Query { + ts: [T] + t(id: ID): T + z: Z +} + +type R { + id: ID! +} + +type T { + id: ID! + tagged: TEnum + custom: String + authenticated: String + requiresScopes: String + policy: String + overridden: String + r: R +} + +type X { + id: ID! + w: String +} + +type Z { + id: ID! + y: String + x: X +} + +enum TEnum { + ONE + TWO +} + +input UnusedInput { + one: String + two: TEnum +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/api@directives.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/api@directives.graphql.snap new file mode 100644 index 0000000000..f0ec6d9bdc --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/api@directives.graphql.snap @@ -0,0 +1,33 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: api_schema +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/directives.graphql +--- +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +type Query { + tagged: String + custom: T + authenticated: String + requiresScopes: String + policy: String + overridden: String + weird: [String] + customAgain: String + z: Z +} + +type T { + field: String +} + +type X { + id: ID! + w: String +} + +type Z { + id: ID! + y: String + x: X +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/api@interface-object.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/api@interface-object.graphql.snap new file mode 100644 index 0000000000..5a6f55efec --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/api@interface-object.graphql.snap @@ -0,0 +1,34 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: api_schema +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/interface-object.graphql +--- +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +interface Itf { + id: ID! + c: Int! + d: Int! + e: String +} + +type Query { + itfs: [Itf] + itf(id: ID!): Itf +} + +type T1 implements Itf { + id: ID! + a: String + c: Int! + d: Int! + e: String +} + +type T2 implements Itf { + id: ID! + b: String + c: Int! + d: Int! + e: String +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/api@keys.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/api@keys.graphql.snap new file mode 100644 index 0000000000..34f7843796 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/api@keys.graphql.snap @@ -0,0 +1,30 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: api_schema +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/keys.graphql +--- +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +type Query { + t(id: ID!): T + t2(id: ID!, id2: ID!): T + " Uses the `unselected` field as a key, but doesn't select it " + unselected(unselected: ID!): T +} + +type R { + id: ID! + id2: ID! +} + +type T { + id: ID! + id2: ID! + unselected: ID! + accessibleByUnselected: ID! + r1: R + r2: R + r3: R + r4: R + r5: R +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/api@nested_inputs.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/api@nested_inputs.graphql.snap new file mode 100644 index 0000000000..8c09756ea3 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/api@nested_inputs.graphql.snap @@ -0,0 +1,19 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: api_schema +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/nested_inputs.graphql +--- +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +input BazInput { + buzz: String + quux: QuuxInput +} + +type Query { + foo(bar: String, baz: BazInput, doubleBaz: BazInput): String +} + +input QuuxInput { + quaz: String +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/api@normalize_names.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/api@normalize_names.graphql.snap new file mode 100644 index 0000000000..91f53fef60 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/api@normalize_names.graphql.snap @@ -0,0 +1,17 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: api_schema +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/normalize_names.graphql +--- +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +type Query { + users: [User] + user(id: ID!): User +} + +type User { + id: ID! + a: String + b: String +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/api@realistic.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/api@realistic.graphql.snap new file mode 100644 index 0000000000..50b1090604 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/api@realistic.graphql.snap @@ -0,0 +1,80 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: api_schema +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/realistic.graphql +--- +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +type Address { + street: String + suite: String + city: String + zipcode: String + geo: AddressGeo +} + +type AddressGeo { + lat: Float + lng: Float +} + +input AddressGeoInput { + lat: Float + lng: Float +} + +input AddressInput { + street: String + suite: String + city: String + zipcode: String + geo: AddressGeoInput +} + +type CompanyInfo { + name: String + catchPhrase: String + bs: String + email: EmailAddress +} + +input CompanyInput { + name: String! + catchPhrase: String +} + +input CreateUserInput { + name: String! + username: String! + email: EmailAddress! + status: Status! + address: AddressInput +} + +scalar EmailAddress + +type Mutation { + createUser(input: CreateUserInput!): User +} + +type Query { + filterUsersByEmailDomain(email: EmailAddress!): [User] + usersByCompany(company: CompanyInput!): [User] + user(id: ID!): User +} + +enum Status { + ACTIVE + INACTIVE +} + +type User { + id: ID! + name: String + username: String + email: EmailAddress + address: Address + phone: String + website: String + company: CompanyInfo +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/api@sibling_fields.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/api@sibling_fields.graphql.snap new file mode 100644 index 0000000000..3cd1679a95 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/api@sibling_fields.graphql.snap @@ -0,0 +1,19 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: api_schema +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/sibling_fields.graphql +--- +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +type K { + id: ID! +} + +type Query { + f: T +} + +type T { + k: K + b: String +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/api@simple.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/api@simple.graphql.snap new file mode 100644 index 0000000000..d4bbb7efe8 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/api@simple.graphql.snap @@ -0,0 +1,19 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: api_schema +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/simple.graphql +--- +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +type Query { + users: [User] + user(id: ID!): User +} + +type User { + id: ID! + a: String + b: String + c: String + d: String +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/api@steelthread.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/api@steelthread.graphql.snap new file mode 100644 index 0000000000..c90b1a25e4 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/api@steelthread.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: api_schema +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/steelthread.graphql +--- +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +scalar JSON + +type Query { + users: [User] + user(id: ID!): User +} + +type User { + id: ID! + name: String + username: String + c: String + d: String +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/api@types_used_twice.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/api@types_used_twice.graphql.snap new file mode 100644 index 0000000000..2e422704b7 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/api@types_used_twice.graphql.snap @@ -0,0 +1,23 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: api_schema +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/types_used_twice.graphql +--- +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +type A { + id: ID +} + +type B { + a: A +} + +type Query { + ts: [T] +} + +type T { + a: A + b: B +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/connectors@batch.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@batch.graphql.snap new file mode 100644 index 0000000000..7c6ecb21cf --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@batch.graphql.snap @@ -0,0 +1,337 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: connectors.by_service_name +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/batch.graphql +--- +{ + "one_Query_users_0": Connector { + id: ConnectId { + subgraph_name: "one", + source_name: Some( + "json", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.users), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://localhost:4001/api", + location: 0..25, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/users", + location: 0..6, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + ], + range: Some( + 0..2, + ), + }, + ), + spec: V0_2, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_2, + request_headers: {}, + response_headers: {}, + request_variable_keys: {}, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "one.json http: GET /users", + ), + }, + "one_User_0": Connector { + id: ConnectId { + subgraph_name: "one", + source_name: Some( + "json", + ), + named: None, + directive: Type( + ObjectTypeDefinitionDirectivePosition { + type_name: "User", + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://localhost:4001/api", + location: 0..25, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/users-batch", + location: 0..12, + }, + ), + ], + }, + method: Post, + headers: [], + body: Some( + JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "ids", + ), + range: Some( + 0..3, + ), + }, + range: Some( + 0..4, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $batch, + range: Some( + 5..11, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 12..14, + ), + }, + WithRange { + node: Empty, + range: Some( + 14..14, + ), + }, + ), + range: Some( + 11..14, + ), + }, + ), + range: Some( + 5..14, + ), + }, + }, + }, + ], + range: Some( + 0..14, + ), + }, + ), + spec: V0_2, + }, + ), + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 3..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 3..7, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "username", + ), + range: Some( + 8..16, + ), + }, + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 8..16, + ), + }, + }, + }, + ], + range: Some( + 0..16, + ), + }, + ), + spec: V0_2, + }, + config: None, + max_requests: None, + entity_resolver: Some( + TypeBatch, + ), + spec: V0_2, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $batch: { + "id", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "[BATCH] one.json http: POST /users-batch", + ), + }, +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/connectors@carryover.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@carryover.graphql.snap new file mode 100644 index 0000000000..21b49483ec --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@carryover.graphql.snap @@ -0,0 +1,790 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: connectors.by_service_name +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/carryover.graphql +--- +{ + "one_Query_ts_0": Connector { + id: ConnectId { + subgraph_name: "one", + source_name: Some( + "json", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.ts), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example/", + location: 0..15, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/t", + location: 0..2, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "tagged", + ), + range: Some( + 3..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 3..9, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "hidden", + ), + range: Some( + 10..16, + ), + }, + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 10..16, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "custom", + ), + range: Some( + 17..23, + ), + }, + WithRange { + node: Empty, + range: Some( + 23..23, + ), + }, + ), + range: Some( + 17..23, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "authenticated", + ), + range: Some( + 24..37, + ), + }, + WithRange { + node: Empty, + range: Some( + 37..37, + ), + }, + ), + range: Some( + 24..37, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "requiresScopes", + ), + range: Some( + 38..52, + ), + }, + WithRange { + node: Empty, + range: Some( + 52..52, + ), + }, + ), + range: Some( + 38..52, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "policy", + ), + range: Some( + 53..59, + ), + }, + WithRange { + node: Empty, + range: Some( + 59..59, + ), + }, + ), + range: Some( + 53..59, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "overridden", + ), + range: Some( + 60..70, + ), + }, + WithRange { + node: Empty, + range: Some( + 70..70, + ), + }, + ), + range: Some( + 60..70, + ), + }, + }, + }, + ], + range: Some( + 0..70, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: {}, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "one.json http: GET /t", + ), + }, + "one_Query_t_0": Connector { + id: ConnectId { + subgraph_name: "one", + source_name: Some( + "json", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.t), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example/", + location: 0..15, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/t/", + location: 0..3, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + spec: V0_1, + }, + location: 4..12, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "tagged", + ), + range: Some( + 3..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 3..9, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "hidden", + ), + range: Some( + 10..16, + ), + }, + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 10..16, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "custom", + ), + range: Some( + 17..23, + ), + }, + WithRange { + node: Empty, + range: Some( + 23..23, + ), + }, + ), + range: Some( + 17..23, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "authenticated", + ), + range: Some( + 24..37, + ), + }, + WithRange { + node: Empty, + range: Some( + 37..37, + ), + }, + ), + range: Some( + 24..37, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "requiresScopes", + ), + range: Some( + 38..52, + ), + }, + WithRange { + node: Empty, + range: Some( + 52..52, + ), + }, + ), + range: Some( + 38..52, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "policy", + ), + range: Some( + 53..59, + ), + }, + WithRange { + node: Empty, + range: Some( + 59..59, + ), + }, + ), + range: Some( + 53..59, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "overridden", + ), + range: Some( + 60..70, + ), + }, + WithRange { + node: Empty, + range: Some( + 70..70, + ), + }, + ), + range: Some( + 60..70, + ), + }, + }, + }, + ], + range: Some( + 0..70, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Explicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $args: { + "id", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "one.json http: GET /t/{$args.id}", + ), + }, + "one_T_r_0": Connector { + id: ConnectId { + subgraph_name: "one", + source_name: Some( + "json", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(T.r), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example/", + location: 0..15, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/t/", + location: 0..3, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + spec: V0_1, + }, + location: 4..12, + }, + ), + Constant( + Constant { + value: "/r", + location: 13..15, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + ], + range: Some( + 0..2, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Implicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $this: { + "id", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "one.json http: GET /t/{$this.id}/r", + ), + }, +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/connectors@directives.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@directives.graphql.snap new file mode 100644 index 0000000000..6764fd4286 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@directives.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: connectors.by_service_name +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/directives.graphql +--- +{} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/connectors@interface-object.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@interface-object.graphql.snap new file mode 100644 index 0000000000..e4d0c38d3a --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@interface-object.graphql.snap @@ -0,0 +1,492 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: connectors.by_service_name +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/interface-object.graphql +--- +{ + "connectors_Itf_e_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "json", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Itf.e), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://localhost:4001", + location: 0..21, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/itfs/", + location: 0..6, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + spec: V0_1, + }, + location: 7..15, + }, + ), + Constant( + Constant { + value: "/e", + location: 16..18, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 0..1, + ), + }, + WithRange { + node: Empty, + range: Some( + 1..1, + ), + }, + ), + range: Some( + 0..1, + ), + }, + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Implicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $this: { + "id", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.json http: GET /itfs/{$this.id}/e", + ), + }, + "connectors_Query_itfs_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "json", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.itfs), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://localhost:4001", + location: 0..21, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/itfs", + location: 0..5, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + ], + range: Some( + 0..4, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: {}, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.json http: GET /itfs", + ), + }, + "connectors_Query_itf_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "json", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.itf), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://localhost:4001", + location: 0..21, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/itfs/", + location: 0..6, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + spec: V0_1, + }, + location: 7..15, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "d", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + ], + range: Some( + 0..6, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Explicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $args: { + "id", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.json http: GET /itfs/{$args.id}", + ), + }, +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/connectors@keys.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@keys.graphql.snap new file mode 100644 index 0000000000..fd36e3e1dc --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@keys.graphql.snap @@ -0,0 +1,1637 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: connectors.by_service_name +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/keys.graphql +--- +{ + "one_Query_t_0": Connector { + id: ConnectId { + subgraph_name: "one", + source_name: None, + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.t), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: None, + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "http://localhost/ts/", + location: 0..20, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + spec: V0_1, + }, + location: 21..29, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id2", + ), + range: Some( + 3..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 3..6, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "unselected", + ), + range: Some( + 7..17, + ), + }, + WithRange { + node: Empty, + range: Some( + 17..17, + ), + }, + ), + range: Some( + 7..17, + ), + }, + }, + }, + ], + range: Some( + 0..17, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Explicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $args: { + "id", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "one. http: GET http://localhost/ts/{$args.id}", + ), + }, + "one_Query_t2_0": Connector { + id: ConnectId { + subgraph_name: "one", + source_name: None, + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.t2), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: None, + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "http://localhost/ts/", + location: 0..20, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + spec: V0_1, + }, + location: 21..29, + }, + ), + Constant( + Constant { + value: "?id2=", + location: 30..35, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id2", + ), + range: Some( + 6..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 5..9, + ), + }, + ), + range: Some( + 0..9, + ), + }, + }, + ), + spec: V0_1, + }, + location: 36..45, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id2", + ), + range: Some( + 3..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 3..6, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "unselected", + ), + range: Some( + 7..17, + ), + }, + WithRange { + node: Empty, + range: Some( + 17..17, + ), + }, + ), + range: Some( + 7..17, + ), + }, + }, + }, + ], + range: Some( + 0..17, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Explicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $args: { + "id", + "id2", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "one. http: GET http://localhost/ts/{$args.id}?id2={$args.id2}", + ), + }, + "one_Query_unselected_0": Connector { + id: ConnectId { + subgraph_name: "one", + source_name: None, + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.unselected), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: None, + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "http://localhost/ts/", + location: 0..20, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "unselected", + ), + range: Some( + 6..16, + ), + }, + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 5..16, + ), + }, + ), + range: Some( + 0..16, + ), + }, + }, + ), + spec: V0_1, + }, + location: 21..37, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id2", + ), + range: Some( + 3..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 3..6, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "accessibleByUnselected", + ), + range: Some( + 7..29, + ), + }, + WithRange { + node: Empty, + range: Some( + 29..29, + ), + }, + ), + range: Some( + 7..29, + ), + }, + }, + }, + ], + range: Some( + 0..29, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Explicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $args: { + "unselected", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "one. http: GET http://localhost/ts/{$args.unselected}", + ), + }, + "one_T_r1_0": Connector { + id: ConnectId { + subgraph_name: "one", + source_name: None, + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(T.r1), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: None, + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "http://localhost/rs/", + location: 0..20, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + spec: V0_1, + }, + location: 21..29, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id2", + ), + range: Some( + 3..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 3..6, + ), + }, + }, + }, + ], + range: Some( + 0..6, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Implicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $this: { + "id", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "one. http: GET http://localhost/rs/{$this.id}", + ), + }, + "one_T_r2_0": Connector { + id: ConnectId { + subgraph_name: "one", + source_name: None, + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(T.r2), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: None, + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "http://localhost/rs/", + location: 0..20, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + spec: V0_1, + }, + location: 21..29, + }, + ), + Constant( + Constant { + value: "?id2=", + location: 30..35, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id2", + ), + range: Some( + 6..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 5..9, + ), + }, + ), + range: Some( + 0..9, + ), + }, + }, + ), + spec: V0_1, + }, + location: 36..45, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id2", + ), + range: Some( + 3..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 3..6, + ), + }, + }, + }, + ], + range: Some( + 0..6, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Implicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $this: { + "id", + "id2", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "one. http: GET http://localhost/rs/{$this.id}?id2={$this.id2}", + ), + }, + "one_T_r3_0": Connector { + id: ConnectId { + subgraph_name: "one", + source_name: None, + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(T.r3), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: None, + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "http://localhost/rs/", + location: 0..20, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + spec: V0_1, + }, + location: 21..29, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "id2", + ), + range: Some( + 3..6, + ), + }, + range: Some( + 3..7, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 8..13, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id2", + ), + range: Some( + 14..17, + ), + }, + WithRange { + node: Empty, + range: Some( + 17..17, + ), + }, + ), + range: Some( + 13..17, + ), + }, + ), + range: Some( + 8..17, + ), + }, + }, + }, + ], + range: Some( + 0..17, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Implicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $this: { + "id", + }, + }, + response_variable_keys: { + $this: { + "id2", + }, + }, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "one. http: GET http://localhost/rs/{$this.id}", + ), + }, + "one_T_r4_0": Connector { + id: ConnectId { + subgraph_name: "one", + source_name: None, + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(T.r4), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: None, + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "http://localhost/rs", + location: 0..19, + }, + ), + ], + }, + method: Post, + headers: [], + body: Some( + JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + range: Some( + 0..3, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 4..9, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 10..12, + ), + }, + WithRange { + node: Empty, + range: Some( + 12..12, + ), + }, + ), + range: Some( + 9..12, + ), + }, + ), + range: Some( + 4..12, + ), + }, + }, + }, + ], + range: Some( + 0..12, + ), + }, + ), + spec: V0_1, + }, + ), + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id2", + ), + range: Some( + 3..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 3..6, + ), + }, + }, + }, + ], + range: Some( + 0..6, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Implicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $this: { + "id", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "one. http: POST http://localhost/rs", + ), + }, + "one_T_r5_0": Connector { + id: ConnectId { + subgraph_name: "one", + source_name: None, + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(T.r5), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: None, + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "http://localhost/rs", + location: 0..19, + }, + ), + ], + }, + method: Post, + headers: [], + body: Some( + JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + range: Some( + 0..3, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 4..9, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 10..12, + ), + }, + WithRange { + node: Empty, + range: Some( + 12..12, + ), + }, + ), + range: Some( + 9..12, + ), + }, + ), + range: Some( + 4..12, + ), + }, + }, + }, + ], + range: Some( + 0..12, + ), + }, + ), + spec: V0_1, + }, + ), + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "id2", + ), + range: Some( + 3..6, + ), + }, + range: Some( + 3..7, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 8..13, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id2", + ), + range: Some( + 14..17, + ), + }, + WithRange { + node: Empty, + range: Some( + 17..17, + ), + }, + ), + range: Some( + 13..17, + ), + }, + ), + range: Some( + 8..17, + ), + }, + }, + }, + ], + range: Some( + 0..17, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Implicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $this: { + "id", + }, + }, + response_variable_keys: { + $this: { + "id2", + }, + }, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "one. http: POST http://localhost/rs", + ), + }, +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/connectors@nested_inputs.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@nested_inputs.graphql.snap new file mode 100644 index 0000000000..7112483c2b --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@nested_inputs.graphql.snap @@ -0,0 +1,299 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: connectors.by_service_name +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/nested_inputs.graphql +--- +{ + "connectors_Query_foo_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "example", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.foo), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example", + location: 0..14, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/", + location: 0..1, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "bar", + ), + range: Some( + 6..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 5..9, + ), + }, + ), + range: Some( + 0..9, + ), + }, + }, + ), + spec: V0_1, + }, + location: 2..11, + }, + ), + Constant( + Constant { + value: "/", + location: 12..13, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "doubleBaz", + ), + range: Some( + 6..15, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "buzz", + ), + range: Some( + 16..20, + ), + }, + WithRange { + node: Empty, + range: Some( + 20..20, + ), + }, + ), + range: Some( + 15..20, + ), + }, + ), + range: Some( + 5..20, + ), + }, + ), + range: Some( + 0..20, + ), + }, + }, + ), + spec: V0_1, + }, + location: 14..34, + }, + ), + Constant( + Constant { + value: "/", + location: 35..36, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "baz", + ), + range: Some( + 6..9, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "quux", + ), + range: Some( + 10..14, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "quaz", + ), + range: Some( + 15..19, + ), + }, + WithRange { + node: Empty, + range: Some( + 19..19, + ), + }, + ), + range: Some( + 14..19, + ), + }, + ), + range: Some( + 9..19, + ), + }, + ), + range: Some( + 5..19, + ), + }, + ), + range: Some( + 0..19, + ), + }, + }, + ), + spec: V0_1, + }, + location: 37..56, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 0..1, + ), + }, + WithRange { + node: Empty, + range: Some( + 1..1, + ), + }, + ), + range: Some( + 0..1, + ), + }, + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $args: { + "bar", + "doubleBaz", + "baz", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.example http: GET /{$args.bar}/{$args.doubleBaz.buzz}/{$args.baz.quux.quaz}", + ), + }, +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/connectors@normalize_names.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@normalize_names.graphql.snap new file mode 100644 index 0000000000..0f53e3b74c --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@normalize_names.graphql.snap @@ -0,0 +1,343 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: connectors.by_service_name +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/normalize_names.graphql +--- +{ + "connectors-subgraph_Query_users_0": Connector { + id: ConnectId { + subgraph_name: "connectors-subgraph", + source_name: Some( + "example", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.users), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example", + location: 0..14, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/", + location: 0..1, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + ], + range: Some( + 0..4, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: {}, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors-subgraph.example http: GET /", + ), + }, + "connectors-subgraph_Query_user_0": Connector { + id: ConnectId { + subgraph_name: "connectors-subgraph", + source_name: Some( + "example", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.user), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example", + location: 0..14, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/", + location: 0..1, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + spec: V0_1, + }, + location: 2..10, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + ], + range: Some( + 0..6, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Explicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $args: { + "id", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors-subgraph.example http: GET /{$args.id}", + ), + }, +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/connectors@realistic.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@realistic.graphql.snap new file mode 100644 index 0000000000..d68f41956b --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@realistic.graphql.snap @@ -0,0 +1,1644 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: connectors.by_service_name +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/realistic.graphql +--- +{ + "connectors_Mutation_createUser_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "example", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Mutation.createUser), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example", + location: 0..14, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/create/user", + location: 0..12, + }, + ), + ], + }, + method: Post, + headers: [], + body: Some( + JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "input", + ), + range: Some( + 6..11, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 14..18, + ), + }, + WithRange { + node: Empty, + range: Some( + 18..18, + ), + }, + ), + range: Some( + 14..18, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "username", + ), + range: Some( + 19..27, + ), + }, + WithRange { + node: Empty, + range: Some( + 27..27, + ), + }, + ), + range: Some( + 19..27, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "email", + ), + range: Some( + 28..33, + ), + }, + WithRange { + node: Empty, + range: Some( + 33..33, + ), + }, + ), + range: Some( + 28..33, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "status", + ), + range: Some( + 34..40, + ), + }, + WithRange { + node: Empty, + range: Some( + 40..40, + ), + }, + ), + range: Some( + 34..40, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "address", + ), + range: Some( + 41..48, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "street", + ), + range: Some( + 51..57, + ), + }, + WithRange { + node: Empty, + range: Some( + 57..57, + ), + }, + ), + range: Some( + 51..57, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "suite", + ), + range: Some( + 58..63, + ), + }, + WithRange { + node: Empty, + range: Some( + 63..63, + ), + }, + ), + range: Some( + 58..63, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "city", + ), + range: Some( + 64..68, + ), + }, + WithRange { + node: Empty, + range: Some( + 68..68, + ), + }, + ), + range: Some( + 64..68, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "zipcode", + ), + range: Some( + 69..76, + ), + }, + WithRange { + node: Empty, + range: Some( + 76..76, + ), + }, + ), + range: Some( + 69..76, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "geo", + ), + range: Some( + 77..80, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "lat", + ), + range: Some( + 83..86, + ), + }, + WithRange { + node: Empty, + range: Some( + 86..86, + ), + }, + ), + range: Some( + 83..86, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "lng", + ), + range: Some( + 87..90, + ), + }, + WithRange { + node: Empty, + range: Some( + 90..90, + ), + }, + ), + range: Some( + 87..90, + ), + }, + }, + }, + ], + range: Some( + 81..92, + ), + }, + ), + range: Some( + 81..92, + ), + }, + ), + range: Some( + 77..92, + ), + }, + }, + }, + ], + range: Some( + 49..94, + ), + }, + ), + range: Some( + 49..94, + ), + }, + ), + range: Some( + 41..94, + ), + }, + }, + }, + ], + range: Some( + 12..96, + ), + }, + ), + range: Some( + 12..96, + ), + }, + ), + range: Some( + 5..96, + ), + }, + ), + range: Some( + 0..96, + ), + }, + }, + ), + spec: V0_1, + }, + ), + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + ], + range: Some( + 0..2, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $args: { + "input", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.example http: POST /create/user", + ), + }, + "connectors_Query_filterUsersByEmailDomain_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "example", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.filterUsersByEmailDomain), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example", + location: 0..14, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/filter/users", + location: 0..13, + }, + ), + ], + }, + method: Get, + headers: [], + body: Some( + JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "emailDomain", + ), + range: Some( + 0..11, + ), + }, + range: Some( + 0..12, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 13..18, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "email", + ), + range: Some( + 19..24, + ), + }, + WithRange { + node: Empty, + range: Some( + 24..24, + ), + }, + ), + range: Some( + 18..24, + ), + }, + ), + range: Some( + 13..24, + ), + }, + }, + }, + ], + range: Some( + 0..24, + ), + }, + ), + spec: V0_1, + }, + ), + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 3..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 3..7, + ), + }, + }, + }, + ], + range: Some( + 0..7, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $args: { + "email", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.example http: GET /filter/users", + ), + }, + "connectors_Query_usersByCompany_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "example", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.usersByCompany), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example", + location: 0..14, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/by-company/", + location: 0..12, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "company", + ), + range: Some( + 6..13, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 14..18, + ), + }, + WithRange { + node: Empty, + range: Some( + 18..18, + ), + }, + ), + range: Some( + 13..18, + ), + }, + ), + range: Some( + 5..18, + ), + }, + ), + range: Some( + 0..18, + ), + }, + }, + ), + spec: V0_1, + }, + location: 13..31, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 3..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 3..7, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "company", + ), + range: Some( + 8..15, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 20..24, + ), + }, + WithRange { + node: Empty, + range: Some( + 24..24, + ), + }, + ), + range: Some( + 20..24, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "catchPhrase", + ), + range: Some( + 27..38, + ), + }, + WithRange { + node: Empty, + range: Some( + 38..38, + ), + }, + ), + range: Some( + 27..38, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "bs", + ), + range: Some( + 41..43, + ), + }, + WithRange { + node: Empty, + range: Some( + 43..43, + ), + }, + ), + range: Some( + 41..43, + ), + }, + }, + }, + ], + range: Some( + 16..45, + ), + }, + ), + range: Some( + 16..45, + ), + }, + ), + range: Some( + 8..45, + ), + }, + }, + }, + ], + range: Some( + 0..45, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $args: { + "company", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.example http: GET /by-company/{$args.company.name}", + ), + }, + "connectors_Query_user_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "example", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.user), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example", + location: 0..14, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/", + location: 0..1, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + spec: V0_1, + }, + location: 2..10, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 3..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 3..7, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "username", + ), + range: Some( + 8..16, + ), + }, + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 8..16, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "email", + ), + range: Some( + 17..22, + ), + }, + WithRange { + node: Empty, + range: Some( + 22..22, + ), + }, + ), + range: Some( + 17..22, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "address", + ), + range: Some( + 23..30, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "street", + ), + range: Some( + 35..41, + ), + }, + WithRange { + node: Empty, + range: Some( + 41..41, + ), + }, + ), + range: Some( + 35..41, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "suite", + ), + range: Some( + 44..49, + ), + }, + WithRange { + node: Empty, + range: Some( + 49..49, + ), + }, + ), + range: Some( + 44..49, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "city", + ), + range: Some( + 52..56, + ), + }, + WithRange { + node: Empty, + range: Some( + 56..56, + ), + }, + ), + range: Some( + 52..56, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "zipcode", + ), + range: Some( + 59..66, + ), + }, + WithRange { + node: Empty, + range: Some( + 66..66, + ), + }, + ), + range: Some( + 59..66, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "geo", + ), + range: Some( + 69..72, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "lat", + ), + range: Some( + 79..82, + ), + }, + WithRange { + node: Empty, + range: Some( + 82..82, + ), + }, + ), + range: Some( + 79..82, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "lng", + ), + range: Some( + 87..90, + ), + }, + WithRange { + node: Empty, + range: Some( + 90..90, + ), + }, + ), + range: Some( + 87..90, + ), + }, + }, + }, + ], + range: Some( + 73..94, + ), + }, + ), + range: Some( + 73..94, + ), + }, + ), + range: Some( + 69..94, + ), + }, + }, + }, + ], + range: Some( + 31..96, + ), + }, + ), + range: Some( + 31..96, + ), + }, + ), + range: Some( + 23..96, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "phone", + ), + range: Some( + 97..102, + ), + }, + WithRange { + node: Empty, + range: Some( + 102..102, + ), + }, + ), + range: Some( + 97..102, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "website", + ), + range: Some( + 103..110, + ), + }, + WithRange { + node: Empty, + range: Some( + 110..110, + ), + }, + ), + range: Some( + 103..110, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "company", + ), + range: Some( + 111..118, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 123..127, + ), + }, + WithRange { + node: Empty, + range: Some( + 127..127, + ), + }, + ), + range: Some( + 123..127, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "catchPhrase", + ), + range: Some( + 130..141, + ), + }, + WithRange { + node: Empty, + range: Some( + 141..141, + ), + }, + ), + range: Some( + 130..141, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "bs", + ), + range: Some( + 144..146, + ), + }, + WithRange { + node: Empty, + range: Some( + 146..146, + ), + }, + ), + range: Some( + 144..146, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "email", + ), + range: Some( + 149..154, + ), + }, + WithRange { + node: Empty, + range: Some( + 154..154, + ), + }, + ), + range: Some( + 149..154, + ), + }, + }, + }, + ], + range: Some( + 119..156, + ), + }, + ), + range: Some( + 119..156, + ), + }, + ), + range: Some( + 111..156, + ), + }, + }, + }, + ], + range: Some( + 0..156, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Explicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $args: { + "id", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.example http: GET /{$args.id}", + ), + }, +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/connectors@sibling_fields.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@sibling_fields.graphql.snap new file mode 100644 index 0000000000..56fff81aea --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@sibling_fields.graphql.snap @@ -0,0 +1,288 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: connectors.by_service_name +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/sibling_fields.graphql +--- +{ + "connectors_Query_f_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: None, + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.f), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: None, + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "https://my.api/t", + location: 0..16, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "k", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 4..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 4..6, + ), + }, + }, + }, + ], + range: Some( + 2..8, + ), + }, + ), + range: Some( + 2..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + }, + ], + range: Some( + 0..8, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: {}, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors. http: GET https://my.api/t", + ), + }, + "connectors_T_b_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: None, + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(T.b), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: None, + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "https://my.api/t/", + location: 0..17, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "k", + ), + range: Some( + 6..7, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 8..10, + ), + }, + WithRange { + node: Empty, + range: Some( + 10..10, + ), + }, + ), + range: Some( + 7..10, + ), + }, + ), + range: Some( + 5..10, + ), + }, + ), + range: Some( + 0..10, + ), + }, + }, + ), + spec: V0_1, + }, + location: 18..28, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Empty, + range: Some( + 1..1, + ), + }, + ), + range: Some( + 0..1, + ), + }, + }, + }, + ], + range: Some( + 0..1, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Implicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $this: { + "k", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors. http: GET https://my.api/t/{$this.k.id}", + ), + }, +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/connectors@simple.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@simple.graphql.snap new file mode 100644 index 0000000000..e8efebfe6c --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@simple.graphql.snap @@ -0,0 +1,559 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: connectors.by_service_name +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/simple.graphql +--- +{ + "connectors_Query_users_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "example", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.users), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example", + location: 0..14, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/", + location: 0..1, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + ], + range: Some( + 0..4, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: {}, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.example http: GET /", + ), + }, + "connectors_Query_user_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "example", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.user), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example", + location: 0..14, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/", + location: 0..1, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + spec: V0_1, + }, + location: 2..10, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + ], + range: Some( + 0..6, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Explicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $args: { + "id", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.example http: GET /{$args.id}", + ), + }, + "connectors_User_d_1": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "example", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(User.d), + directive_name: "connect", + directive_index: 1, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example", + location: 0..14, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/", + location: 0..1, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 6..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 5..7, + ), + }, + ), + range: Some( + 0..7, + ), + }, + }, + ), + spec: V0_1, + }, + location: 2..9, + }, + ), + Constant( + Constant { + value: "/d", + location: 10..12, + }, + ), + ], + }, + method: Get, + headers: [], + body: Some( + JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "with_b", + ), + range: Some( + 0..6, + ), + }, + range: Some( + 0..7, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 8..13, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 14..15, + ), + }, + WithRange { + node: Empty, + range: Some( + 15..15, + ), + }, + ), + range: Some( + 13..15, + ), + }, + ), + range: Some( + 8..15, + ), + }, + }, + }, + ], + range: Some( + 0..15, + ), + }, + ), + spec: V0_1, + }, + ), + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 0..1, + ), + }, + WithRange { + node: Empty, + range: Some( + 1..1, + ), + }, + ), + range: Some( + 0..1, + ), + }, + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Implicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $this: { + "c", + "b", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.example http: GET /{$this.c}/d", + ), + }, +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/connectors@steelthread.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@steelthread.graphql.snap new file mode 100644 index 0000000000..0f6116c54e --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@steelthread.graphql.snap @@ -0,0 +1,501 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: connectors.by_service_name +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/steelthread.graphql +--- +{ + "connectors_Query_users_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "json", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.users), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "https://jsonplaceholder.typicode.com/", + location: 0..37, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/users", + location: 0..6, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 3..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 3..7, + ), + }, + }, + }, + ], + range: Some( + 0..7, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: {}, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.json http: GET /users", + ), + }, + "connectors_Query_user_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "json", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.user), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "https://jsonplaceholder.typicode.com/", + location: 0..37, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/users/", + location: 0..7, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + spec: V0_1, + }, + location: 8..16, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 3..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 3..7, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "username", + ), + range: Some( + 8..16, + ), + }, + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 8..16, + ), + }, + }, + }, + ], + range: Some( + 0..16, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Explicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $args: { + "id", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.json http: GET /users/{$args.id}", + ), + }, + "connectors_User_d_1": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "json", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(User.d), + directive_name: "connect", + directive_index: 1, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "https://jsonplaceholder.typicode.com/", + location: 0..37, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/users/", + location: 0..7, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 6..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 5..7, + ), + }, + ), + range: Some( + 0..7, + ), + }, + }, + ), + spec: V0_1, + }, + location: 8..15, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 0..1, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "phone", + ), + range: Some( + 2..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 1..7, + ), + }, + ), + range: Some( + 0..7, + ), + }, + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: Some( + Implicit, + ), + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $this: { + "c", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.json http: GET /users/{$this.c}", + ), + }, +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/connectors@types_used_twice.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@types_used_twice.graphql.snap new file mode 100644 index 0000000000..317fad0289 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@types_used_twice.graphql.snap @@ -0,0 +1,238 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: connectors.by_service_name +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/types_used_twice.graphql +--- +{ + "connectors_Query_ts_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "example", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.ts), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example", + location: 0..14, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/", + location: 0..1, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 4..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 4..6, + ), + }, + }, + }, + ], + range: Some( + 2..8, + ), + }, + ), + range: Some( + 2..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 9..10, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 13..14, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 17..19, + ), + }, + WithRange { + node: Empty, + range: Some( + 19..19, + ), + }, + ), + range: Some( + 17..19, + ), + }, + }, + }, + ], + range: Some( + 15..21, + ), + }, + ), + range: Some( + 15..21, + ), + }, + ), + range: Some( + 13..21, + ), + }, + }, + }, + ], + range: Some( + 11..23, + ), + }, + ), + range: Some( + 11..23, + ), + }, + ), + range: Some( + 9..23, + ), + }, + }, + }, + ], + range: Some( + 0..23, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: {}, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.example http: GET /", + ), + }, +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@batch.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@batch.graphql.snap new file mode 100644 index 0000000000..6d170d44ae --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@batch.graphql.snap @@ -0,0 +1,66 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: raw_sdl +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/batch.graphql +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) @join__directive(graphs: [], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.2"}) @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + ONE_QUERY_USERS_0 @join__graph(name: "one_Query_users_0", url: "http://none") + ONE_USER_0 @join__graph(name: "one_User_0", url: "http://none") +} + +type User @join__type(graph: ONE_QUERY_USERS_0) @join__type(graph: ONE_USER_0, key: "id") { + id: ID! @join__field(graph: ONE_QUERY_USERS_0, type: "ID!") @join__field(graph: ONE_USER_0, type: "ID!") + name: String @join__field(graph: ONE_USER_0, type: "String") + username: String @join__field(graph: ONE_USER_0, type: "String") +} + +type Query @join__type(graph: ONE_QUERY_USERS_0) @join__type(graph: ONE_USER_0) { + users: [User!]! @join__field(graph: ONE_QUERY_USERS_0, type: "[User!]!") + _: ID @inaccessible @join__field(graph: ONE_USER_0, type: "ID") +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@carryover.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@carryover.graphql.snap new file mode 100644 index 0000000000..c0f470956c --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@carryover.graphql.snap @@ -0,0 +1,154 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: raw_sdl +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/carryover.graphql +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) @join__directive(graphs: [], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1"}) @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) @link(url: "https://specs.apollo.dev/tag/v0.3") @link(url: "https://specs.apollo.dev/authenticated/v0.1", for: SECURITY) @link(url: "https://specs.apollo.dev/requiresScopes/v0.1", for: SECURITY) @link(url: "https://specs.apollo.dev/policy/v0.1", for: SECURITY) @link(url: "http://specs.example.org/custom/v0.1", import: ["@custom"]) @link(url: "http://specs.example.org/custom2/v0.1", import: ["@custom2"]) @link(url: "http://specs.example.org/custom3/v0.1", import: ["@custom3"]) @link(url: "https://specs.apollo.dev/context/v0.1", for: SECURITY) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @requiresScopes(scopes: [[requiresScopes__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @policy(policies: [[policy__Policy!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @custom(s: custom__Scalar, e: custom__Enum, i: custom__Input) on OBJECT | FIELD_DEFINITION + +directive @custom2(s: custom__Scalar2, e: custom__Enum2, i: custom__Input2) on OBJECT | FIELD_DEFINITION + +directive @custom3(s: custom__Scalar3, e: custom__Enum3, i: custom__Input3) on OBJECT | FIELD_DEFINITION + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + ONE_QUERY_T_0 @join__graph(name: "one_Query_t_0", url: "none") + ONE_QUERY_TS_0 @join__graph(name: "one_Query_ts_0", url: "none") + ONE_T_R_0 @join__graph(name: "one_T_r_0", url: "none") + TWO @join__graph(name: "two", url: "none") +} + +enum TEnum @join__type(graph: ONE_QUERY_T_0) @join__type(graph: ONE_QUERY_TS_0) { + ONE @join__enumValue(graph: ONE_QUERY_T_0) @join__enumValue(graph: ONE_QUERY_TS_0) + TWO @join__enumValue(graph: ONE_QUERY_T_0) @join__enumValue(graph: ONE_QUERY_TS_0) +} + +type T @join__type(graph: ONE_QUERY_T_0, key: "id") @join__type(graph: ONE_QUERY_TS_0) @join__type(graph: ONE_T_R_0, key: "id") @join__type(graph: TWO, key: "id") { + authenticated: String @join__field(graph: ONE_QUERY_T_0, type: "String") @join__field(graph: ONE_QUERY_TS_0, type: "String") @authenticated + custom: String @join__field(graph: ONE_QUERY_T_0, type: "String") @join__field(graph: ONE_QUERY_TS_0, type: "String") @custom @custom2 + hidden: String @join__field(graph: ONE_QUERY_T_0, type: "String") @join__field(graph: ONE_QUERY_TS_0, type: "String") @inaccessible + id: ID! @join__field(graph: ONE_QUERY_T_0, type: "ID!") @join__field(graph: ONE_QUERY_TS_0, type: "ID!") @join__field(graph: ONE_T_R_0, type: "ID!") @join__field(graph: TWO, type: "ID!") + overridden: String @join__field(graph: ONE_QUERY_T_0, override: "two", overrideLabel: "label", type: "String") @join__field(graph: ONE_QUERY_TS_0, override: "two", overrideLabel: "label", type: "String") @join__field(graph: TWO, type: "String") + policy: String @join__field(graph: ONE_QUERY_T_0, type: "String") @join__field(graph: ONE_QUERY_TS_0, type: "String") @policy(policies: [["admin"]]) + requiresScopes: String @join__field(graph: ONE_QUERY_T_0, type: "String") @join__field(graph: ONE_QUERY_TS_0, type: "String") @requiresScopes(scopes: ["scope"]) + tagged: TEnum @join__field(graph: ONE_QUERY_T_0, type: "TEnum") @join__field(graph: ONE_QUERY_TS_0, type: "TEnum") @tag(name: "tag") + r: R @join__field(graph: ONE_T_R_0, type: "R") +} + +type Query @join__type(graph: ONE_QUERY_T_0) @join__type(graph: ONE_QUERY_TS_0) @join__type(graph: ONE_T_R_0) @join__type(graph: TWO) { + t(id: ID): T @join__field(graph: ONE_QUERY_T_0, type: "T") + ts: [T] @join__field(graph: ONE_QUERY_TS_0, type: "[T]") + _: ID @inaccessible @join__field(graph: ONE_T_R_0, type: "ID") + z: Z @join__field(graph: TWO, type: "Z") +} + +type R @join__type(graph: ONE_T_R_0) { + id: ID! @join__field(graph: ONE_T_R_0, type: "ID!") +} + +enum custom__Enum @join__type(graph: TWO) { + ONE @join__enumValue(graph: TWO) + TWO @join__enumValue(graph: TWO) +} + +enum custom__Enum3 @join__type(graph: TWO) { + ONE @join__enumValue(graph: TWO) + TWO @join__enumValue(graph: TWO) +} + +input custom__Input @join__type(graph: TWO) { + one: String @join__field(graph: TWO, type: "String") + two: String @join__field(graph: TWO, type: "String") +} + +input custom__Input3 @join__type(graph: TWO) { + one: String @join__field(graph: TWO, type: "String") + two: String @join__field(graph: TWO, type: "String") +} + +scalar custom__Scalar @join__type(graph: TWO) + +scalar custom__Scalar3 @join__type(graph: TWO) + +type X @join__type(graph: TWO, key: "id") { + id: ID! @join__field(graph: TWO, type: "ID!") + w: String @join__field(graph: TWO, type: "String", contextArguments: [{context: "two__ctx", name: "z", type: "String", selection: " { y }"}]) +} + +type Z @join__type(graph: TWO, key: "id") @context(name: "two__ctx") { + id: ID! @join__field(graph: TWO, type: "ID!") + y: String @join__field(graph: TWO, type: "String") @custom(s: "x", e: ONE, i: {one: "one"}) + x: X @join__field(graph: TWO, type: "X") @custom3(s: "x", e: ONE, i: {one: "one"}) +} + +scalar context__ContextFieldValue + +enum custom__Enum2 @join__type(graph: ONE_QUERY_TS_0) @join__type(graph: ONE_QUERY_T_0) @join__type(graph: ONE_T_R_0) { + ONE @join__enumValue(graph: ONE_QUERY_TS_0) @join__enumValue(graph: ONE_QUERY_T_0) @join__enumValue(graph: ONE_T_R_0) + TWO @join__enumValue(graph: ONE_QUERY_TS_0) @join__enumValue(graph: ONE_QUERY_T_0) @join__enumValue(graph: ONE_T_R_0) +} + +input custom__Input2 @join__type(graph: ONE_QUERY_TS_0) @join__type(graph: ONE_QUERY_T_0) @join__type(graph: ONE_T_R_0) { + one: String + two: String +} + +scalar custom__Scalar2 @join__type(graph: ONE_QUERY_TS_0) @join__type(graph: ONE_QUERY_T_0) @join__type(graph: ONE_T_R_0) + +scalar policy__Policy + +scalar requiresScopes__Scope diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@directives.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@directives.graphql.snap new file mode 100644 index 0000000000..fc850ee535 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@directives.graphql.snap @@ -0,0 +1,111 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: raw_sdl +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/directives.graphql +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) @link(url: "https://specs.apollo.dev/tag/v0.3") @link(url: "https://specs.apollo.dev/authenticated/v0.1", for: SECURITY) @link(url: "https://specs.apollo.dev/requiresScopes/v0.1", for: SECURITY) @link(url: "https://specs.apollo.dev/policy/v0.1", for: SECURITY) @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) @link(url: "http://specs.example.org/custom/v0.1", import: ["@custom1", "@custom2", {name: "@originalName", as: "@custom3"}]) @link(url: "http://bugfix/weird/v1.0", import: ["@weird"]) @link(url: "https://specs.apollo.dev/context/v0.1", for: SECURITY) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @requiresScopes(scopes: [[requiresScopes__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @policy(policies: [[policy__Policy!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION + +directive @custom1 on OBJECT | FIELD_DEFINITION + +directive @custom2 on OBJECT | FIELD_DEFINITION + +directive @custom3 on OBJECT | FIELD_DEFINITION + +directive @weird on FIELD | FIELD_DEFINITION + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + ONE @join__graph(name: "one", url: "none") + TWO @join__graph(name: "two", url: "none") +} + +type Query @join__type(graph: ONE) @join__type(graph: TWO) { + tagged: String @join__field(graph: ONE, type: "String") @tag(name: "tag") + hidden: String @join__field(graph: ONE, type: "String") @inaccessible + custom: T @join__field(graph: ONE, type: "T") @custom1 + authenticated: String @join__field(graph: ONE, type: "String") @authenticated + requiresScopes: String @join__field(graph: ONE, type: "String") @requiresScopes(scopes: ["scope"]) + policy: String @join__field(graph: ONE, type: "String") @policy(policies: [["admin"]]) + overridden: String @join__field(graph: ONE, override: "two", overrideLabel: "label", type: "String") @join__field(graph: TWO, type: "String") + weird: [String] @join__field(graph: ONE, type: "[String]") @listSize(assumedSize: 99) @weird + customAgain: String @join__field(graph: TWO, type: "String") @custom1 + z: Z @join__field(graph: TWO, type: "Z") +} + +type T @join__type(graph: ONE) @custom2 { + field: String @join__field(graph: ONE, type: "String") @cost(weight: 5) @custom3 +} + +type X @join__type(graph: TWO, key: "id") { + id: ID! @join__field(graph: TWO, type: "ID!") + w: String @join__field(graph: TWO, type: "String", contextArguments: [{context: "two__ctx", name: "z", type: "String", selection: " { y }"}]) +} + +type Z @join__type(graph: TWO, key: "id") @context(name: "two__ctx") { + id: ID! @join__field(graph: TWO, type: "ID!") + y: String @join__field(graph: TWO, type: "String") + x: X @join__field(graph: TWO, type: "X") +} + +scalar context__ContextFieldValue + +scalar policy__Policy + +scalar requiresScopes__Scope diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@interface-object.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@interface-object.graphql.snap new file mode 100644 index 0000000000..d5bc0220ec --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@interface-object.graphql.snap @@ -0,0 +1,86 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: raw_sdl +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/interface-object.graphql +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) @join__directive(graphs: [], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1"}) @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + CONNECTORS_ITF_E_0 @join__graph(name: "connectors_Itf_e_0", url: "none") + CONNECTORS_QUERY_ITF_0 @join__graph(name: "connectors_Query_itf_0", url: "none") + CONNECTORS_QUERY_ITFS_0 @join__graph(name: "connectors_Query_itfs_0", url: "none") + GRAPHQL @join__graph(name: "graphql", url: "none") +} + +interface Itf @join__type(graph: CONNECTORS_ITF_E_0, isInterfaceObject: true, key: "id") @join__type(graph: CONNECTORS_QUERY_ITF_0, isInterfaceObject: true, key: "id") @join__type(graph: CONNECTORS_QUERY_ITFS_0, isInterfaceObject: true, key: "id", resolvable: false) @join__type(graph: GRAPHQL, key: "id") { + e: String @join__field(graph: CONNECTORS_ITF_E_0, type: "String") + id: ID! @join__field(graph: CONNECTORS_ITF_E_0, type: "ID!") @join__field(graph: CONNECTORS_QUERY_ITF_0, type: "ID!") @join__field(graph: CONNECTORS_QUERY_ITFS_0, type: "ID!") @join__field(graph: GRAPHQL, type: "ID!") + c: Int! @join__field(graph: CONNECTORS_QUERY_ITF_0, type: "Int!") @join__field(graph: CONNECTORS_QUERY_ITFS_0, type: "Int!") + d: Int! @join__field(graph: CONNECTORS_QUERY_ITF_0, type: "Int!") +} + +type Query @join__type(graph: CONNECTORS_ITF_E_0) @join__type(graph: CONNECTORS_QUERY_ITF_0) @join__type(graph: CONNECTORS_QUERY_ITFS_0) @join__type(graph: GRAPHQL) { + _: ID @inaccessible @join__field(graph: CONNECTORS_ITF_E_0, type: "ID") + itf(id: ID!): Itf @join__field(graph: CONNECTORS_QUERY_ITF_0, type: "Itf") + itfs: [Itf] @join__field(graph: CONNECTORS_QUERY_ITFS_0, type: "[Itf]") +} + +type T1 implements Itf @join__type(graph: GRAPHQL, key: "id") @join__implements(graph: GRAPHQL, interface: "Itf") { + id: ID! @join__field(graph: GRAPHQL, type: "ID!") + a: String @join__field(graph: GRAPHQL, type: "String") + e: String @join__field + c: Int! @join__field + d: Int! @join__field +} + +type T2 implements Itf @join__type(graph: GRAPHQL, key: "id") @join__implements(graph: GRAPHQL, interface: "Itf") { + id: ID! @join__field(graph: GRAPHQL, type: "ID!") + b: String @join__field(graph: GRAPHQL, type: "String") + e: String @join__field + c: Int! @join__field + d: Int! @join__field +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@keys.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@keys.graphql.snap new file mode 100644 index 0000000000..3f47e94fcd --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@keys.graphql.snap @@ -0,0 +1,85 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: raw_sdl +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/keys.graphql +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) @join__directive(graphs: [], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1"}) @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + ONE_QUERY_T2_0 @join__graph(name: "one_Query_t2_0", url: "none") + ONE_QUERY_T_0 @join__graph(name: "one_Query_t_0", url: "none") + ONE_QUERY_UNSELECTED_0 @join__graph(name: "one_Query_unselected_0", url: "none") + ONE_T_R1_0 @join__graph(name: "one_T_r1_0", url: "none") + ONE_T_R2_0 @join__graph(name: "one_T_r2_0", url: "none") + ONE_T_R3_0 @join__graph(name: "one_T_r3_0", url: "none") + ONE_T_R4_0 @join__graph(name: "one_T_r4_0", url: "none") + ONE_T_R5_0 @join__graph(name: "one_T_r5_0", url: "none") +} + +type T @join__type(graph: ONE_QUERY_T2_0, key: "id id2") @join__type(graph: ONE_QUERY_T_0, key: "id") @join__type(graph: ONE_QUERY_UNSELECTED_0, key: "unselected") @join__type(graph: ONE_T_R1_0, key: "id") @join__type(graph: ONE_T_R2_0, key: "id id2") @join__type(graph: ONE_T_R3_0, key: "id id2") @join__type(graph: ONE_T_R4_0, key: "id") @join__type(graph: ONE_T_R5_0, key: "id id2") { + id: ID! @join__field(graph: ONE_QUERY_T2_0, type: "ID!") @join__field(graph: ONE_QUERY_T_0, type: "ID!") @join__field(graph: ONE_QUERY_UNSELECTED_0, type: "ID!") @join__field(graph: ONE_T_R1_0, type: "ID!") @join__field(graph: ONE_T_R2_0, type: "ID!") @join__field(graph: ONE_T_R3_0, type: "ID!") @join__field(graph: ONE_T_R4_0, type: "ID!") @join__field(graph: ONE_T_R5_0, type: "ID!") + id2: ID! @join__field(graph: ONE_QUERY_T2_0, type: "ID!") @join__field(graph: ONE_QUERY_T_0, type: "ID!") @join__field(graph: ONE_QUERY_UNSELECTED_0, type: "ID!") @join__field(graph: ONE_T_R2_0, type: "ID!") @join__field(graph: ONE_T_R3_0, type: "ID!") @join__field(graph: ONE_T_R5_0, type: "ID!") + unselected: ID! @join__field(graph: ONE_QUERY_T2_0, type: "ID!") @join__field(graph: ONE_QUERY_T_0, type: "ID!") @join__field(graph: ONE_QUERY_UNSELECTED_0, type: "ID!") + accessibleByUnselected: ID! @join__field(graph: ONE_QUERY_UNSELECTED_0, type: "ID!") + r1: R @join__field(graph: ONE_T_R1_0, type: "R") + r2: R @join__field(graph: ONE_T_R2_0, type: "R") + r3: R @join__field(graph: ONE_T_R3_0, type: "R") + r4: R @join__field(graph: ONE_T_R4_0, type: "R") + r5: R @join__field(graph: ONE_T_R5_0, type: "R") +} + +type Query @join__type(graph: ONE_QUERY_T2_0) @join__type(graph: ONE_QUERY_T_0) @join__type(graph: ONE_QUERY_UNSELECTED_0) @join__type(graph: ONE_T_R1_0) @join__type(graph: ONE_T_R2_0) @join__type(graph: ONE_T_R3_0) @join__type(graph: ONE_T_R4_0) @join__type(graph: ONE_T_R5_0) { + t2(id: ID!, id2: ID!): T @join__field(graph: ONE_QUERY_T2_0, type: "T") + t(id: ID!): T @join__field(graph: ONE_QUERY_T_0, type: "T") + unselected(unselected: ID!): T @join__field(graph: ONE_QUERY_UNSELECTED_0, type: "T") + _: ID @inaccessible @join__field(graph: ONE_T_R1_0, type: "ID") @join__field(graph: ONE_T_R2_0, type: "ID") @join__field(graph: ONE_T_R3_0, type: "ID") @join__field(graph: ONE_T_R4_0, type: "ID") @join__field(graph: ONE_T_R5_0, type: "ID") +} + +type R @join__type(graph: ONE_T_R1_0) @join__type(graph: ONE_T_R2_0) @join__type(graph: ONE_T_R3_0) @join__type(graph: ONE_T_R4_0) @join__type(graph: ONE_T_R5_0) { + id: ID! @join__field(graph: ONE_T_R1_0, type: "ID!") @join__field(graph: ONE_T_R2_0, type: "ID!") @join__field(graph: ONE_T_R3_0, type: "ID!") @join__field(graph: ONE_T_R4_0, type: "ID!") @join__field(graph: ONE_T_R5_0, type: "ID!") + id2: ID! @join__field(graph: ONE_T_R1_0, type: "ID!") @join__field(graph: ONE_T_R2_0, type: "ID!") @join__field(graph: ONE_T_R3_0, type: "ID!") @join__field(graph: ONE_T_R4_0, type: "ID!") @join__field(graph: ONE_T_R5_0, type: "ID!") +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@nested_inputs.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@nested_inputs.graphql.snap new file mode 100644 index 0000000000..7771a87d67 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@nested_inputs.graphql.snap @@ -0,0 +1,65 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: raw_sdl +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/nested_inputs.graphql +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @join__directive(graphs: [], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1"}) @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + CONNECTORS_QUERY_FOO_0 @join__graph(name: "connectors_Query_foo_0", url: "none") +} + +input QuuxInput @join__type(graph: CONNECTORS_QUERY_FOO_0) { + quaz: String @join__field(graph: CONNECTORS_QUERY_FOO_0, type: "String") +} + +input BazInput @join__type(graph: CONNECTORS_QUERY_FOO_0) { + buzz: String @join__field(graph: CONNECTORS_QUERY_FOO_0, type: "String") + quux: QuuxInput @join__field(graph: CONNECTORS_QUERY_FOO_0, type: "QuuxInput") +} + +type Query @join__type(graph: CONNECTORS_QUERY_FOO_0) { + foo(bar: String, baz: BazInput, doubleBaz: BazInput): String @join__field(graph: CONNECTORS_QUERY_FOO_0, type: "String") +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@normalize_names.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@normalize_names.graphql.snap new file mode 100644 index 0000000000..06f37f5e2f --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@normalize_names.graphql.snap @@ -0,0 +1,64 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: raw_sdl +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/normalize_names.graphql +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @join__directive(graphs: [], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1"}) @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + CONNECTORS_SUBGRAPH_QUERY_USER_0 @join__graph(name: "connectors-subgraph_Query_user_0", url: "none") + CONNECTORS_SUBGRAPH_QUERY_USERS_0 @join__graph(name: "connectors-subgraph_Query_users_0", url: "none") +} + +type User @join__type(graph: CONNECTORS_SUBGRAPH_QUERY_USER_0, key: "id") @join__type(graph: CONNECTORS_SUBGRAPH_QUERY_USERS_0) { + a: String @join__field(graph: CONNECTORS_SUBGRAPH_QUERY_USER_0, type: "String") @join__field(graph: CONNECTORS_SUBGRAPH_QUERY_USERS_0, type: "String") + b: String @join__field(graph: CONNECTORS_SUBGRAPH_QUERY_USER_0, type: "String") + id: ID! @join__field(graph: CONNECTORS_SUBGRAPH_QUERY_USER_0, type: "ID!") @join__field(graph: CONNECTORS_SUBGRAPH_QUERY_USERS_0, type: "ID!") +} + +type Query @join__type(graph: CONNECTORS_SUBGRAPH_QUERY_USER_0) @join__type(graph: CONNECTORS_SUBGRAPH_QUERY_USERS_0) { + user(id: ID!): User @join__field(graph: CONNECTORS_SUBGRAPH_QUERY_USER_0, type: "User") + users: [User] @join__field(graph: CONNECTORS_SUBGRAPH_QUERY_USERS_0, type: "[User]") +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@realistic.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@realistic.graphql.snap new file mode 100644 index 0000000000..4db0976c58 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@realistic.graphql.snap @@ -0,0 +1,133 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: raw_sdl +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/realistic.graphql +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) @join__directive(graphs: [], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1"}) @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) { + query: Query + mutation: Mutation +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + CONNECTORS_MUTATION_CREATEUSER_0 @join__graph(name: "connectors_Mutation_createUser_0", url: "none") + CONNECTORS_QUERY_FILTERUSERSBYEMAILDOMAIN_0 @join__graph(name: "connectors_Query_filterUsersByEmailDomain_0", url: "none") + CONNECTORS_QUERY_USER_0 @join__graph(name: "connectors_Query_user_0", url: "none") + CONNECTORS_QUERY_USERSBYCOMPANY_0 @join__graph(name: "connectors_Query_usersByCompany_0", url: "none") +} + +scalar EmailAddress @join__type(graph: CONNECTORS_MUTATION_CREATEUSER_0) @join__type(graph: CONNECTORS_QUERY_FILTERUSERSBYEMAILDOMAIN_0) @join__type(graph: CONNECTORS_QUERY_USER_0) + +enum Status @join__type(graph: CONNECTORS_MUTATION_CREATEUSER_0) { + ACTIVE @join__enumValue(graph: CONNECTORS_MUTATION_CREATEUSER_0) + INACTIVE @join__enumValue(graph: CONNECTORS_MUTATION_CREATEUSER_0) +} + +input AddressGeoInput @join__type(graph: CONNECTORS_MUTATION_CREATEUSER_0) { + lat: Float @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "Float") + lng: Float @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "Float") +} + +input AddressInput @join__type(graph: CONNECTORS_MUTATION_CREATEUSER_0) { + street: String @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "String") + suite: String @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "String") + city: String @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "String") + zipcode: String @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "String") + geo: AddressGeoInput @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "AddressGeoInput") +} + +input CreateUserInput @join__type(graph: CONNECTORS_MUTATION_CREATEUSER_0) { + name: String! @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "String!") + username: String! @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "String!") + email: EmailAddress! @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "EmailAddress!") + status: Status! @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "Status!") + address: AddressInput @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "AddressInput") +} + +type User @join__type(graph: CONNECTORS_MUTATION_CREATEUSER_0) @join__type(graph: CONNECTORS_QUERY_FILTERUSERSBYEMAILDOMAIN_0) @join__type(graph: CONNECTORS_QUERY_USER_0, key: "id") @join__type(graph: CONNECTORS_QUERY_USERSBYCOMPANY_0) { + id: ID! @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "ID!") @join__field(graph: CONNECTORS_QUERY_FILTERUSERSBYEMAILDOMAIN_0, type: "ID!") @join__field(graph: CONNECTORS_QUERY_USER_0, type: "ID!") @join__field(graph: CONNECTORS_QUERY_USERSBYCOMPANY_0, type: "ID!") + name: String @join__field(graph: CONNECTORS_QUERY_FILTERUSERSBYEMAILDOMAIN_0, type: "String") @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") @join__field(graph: CONNECTORS_QUERY_USERSBYCOMPANY_0, type: "String") + address: Address @join__field(graph: CONNECTORS_QUERY_USER_0, type: "Address") + company: CompanyInfo @join__field(graph: CONNECTORS_QUERY_USER_0, type: "CompanyInfo") @join__field(graph: CONNECTORS_QUERY_USERSBYCOMPANY_0, type: "CompanyInfo") + email: EmailAddress @join__field(graph: CONNECTORS_QUERY_USER_0, type: "EmailAddress") + phone: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") + username: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") + website: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") +} + +type Mutation @join__type(graph: CONNECTORS_MUTATION_CREATEUSER_0) { + createUser(input: CreateUserInput!): User @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "User") +} + +type Query @join__type(graph: CONNECTORS_MUTATION_CREATEUSER_0) @join__type(graph: CONNECTORS_QUERY_FILTERUSERSBYEMAILDOMAIN_0) @join__type(graph: CONNECTORS_QUERY_USER_0) @join__type(graph: CONNECTORS_QUERY_USERSBYCOMPANY_0) { + _: ID @inaccessible @join__field(graph: CONNECTORS_MUTATION_CREATEUSER_0, type: "ID") + filterUsersByEmailDomain(email: EmailAddress!): [User] @join__field(graph: CONNECTORS_QUERY_FILTERUSERSBYEMAILDOMAIN_0, type: "[User]") + user(id: ID!): User @join__field(graph: CONNECTORS_QUERY_USER_0, type: "User") + usersByCompany(company: CompanyInput!): [User] @join__field(graph: CONNECTORS_QUERY_USERSBYCOMPANY_0, type: "[User]") +} + +type AddressGeo @join__type(graph: CONNECTORS_QUERY_USER_0) { + lat: Float @join__field(graph: CONNECTORS_QUERY_USER_0, type: "Float") + lng: Float @join__field(graph: CONNECTORS_QUERY_USER_0, type: "Float") +} + +type Address @join__type(graph: CONNECTORS_QUERY_USER_0) { + city: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") + geo: AddressGeo @join__field(graph: CONNECTORS_QUERY_USER_0, type: "AddressGeo") + street: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") + suite: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") + zipcode: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") +} + +type CompanyInfo @join__type(graph: CONNECTORS_QUERY_USER_0) @join__type(graph: CONNECTORS_QUERY_USERSBYCOMPANY_0) { + bs: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") @join__field(graph: CONNECTORS_QUERY_USERSBYCOMPANY_0, type: "String") + catchPhrase: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") @join__field(graph: CONNECTORS_QUERY_USERSBYCOMPANY_0, type: "String") + email: EmailAddress @join__field(graph: CONNECTORS_QUERY_USER_0, type: "EmailAddress") + name: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") @join__field(graph: CONNECTORS_QUERY_USERSBYCOMPANY_0, type: "String") +} + +input CompanyInput @join__type(graph: CONNECTORS_QUERY_USERSBYCOMPANY_0) { + name: String! @join__field(graph: CONNECTORS_QUERY_USERSBYCOMPANY_0, type: "String!") + catchPhrase: String @join__field(graph: CONNECTORS_QUERY_USERSBYCOMPANY_0, type: "String") +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@sibling_fields.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@sibling_fields.graphql.snap new file mode 100644 index 0000000000..377075325e --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@sibling_fields.graphql.snap @@ -0,0 +1,69 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: raw_sdl +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/sibling_fields.graphql +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) @join__directive(graphs: [], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1"}) @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + CONNECTORS_QUERY_F_0 @join__graph(name: "connectors_Query_f_0", url: "none") + CONNECTORS_T_B_0 @join__graph(name: "connectors_T_b_0", url: "none") +} + +type K @join__type(graph: CONNECTORS_QUERY_F_0) @join__type(graph: CONNECTORS_T_B_0) { + id: ID! @join__field(graph: CONNECTORS_QUERY_F_0, type: "ID!") @join__field(graph: CONNECTORS_T_B_0, type: "ID!") +} + +type T @join__type(graph: CONNECTORS_QUERY_F_0) @join__type(graph: CONNECTORS_T_B_0, key: "k { id }") { + k: K @join__field(graph: CONNECTORS_QUERY_F_0, type: "K") @join__field(graph: CONNECTORS_T_B_0, type: "K") + b: String @join__field(graph: CONNECTORS_T_B_0, type: "String") +} + +type Query @join__type(graph: CONNECTORS_QUERY_F_0) @join__type(graph: CONNECTORS_T_B_0) { + f: T @join__field(graph: CONNECTORS_QUERY_F_0, type: "T") + _: ID @inaccessible @join__field(graph: CONNECTORS_T_B_0, type: "ID") +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@simple.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@simple.graphql.snap new file mode 100644 index 0000000000..253862629c --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@simple.graphql.snap @@ -0,0 +1,71 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: raw_sdl +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/simple.graphql +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) @join__directive(graphs: [], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1"}) @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + CONNECTORS_QUERY_USER_0 @join__graph(name: "connectors_Query_user_0", url: "none") + CONNECTORS_QUERY_USERS_0 @join__graph(name: "connectors_Query_users_0", url: "none") + CONNECTORS_USER_D_1 @join__graph(name: "connectors_User_d_1", url: "none") + GRAPHQL @join__graph(name: "graphql", url: "https://graphql") +} + +type User @join__type(graph: CONNECTORS_QUERY_USER_0, key: "id") @join__type(graph: CONNECTORS_QUERY_USERS_0) @join__type(graph: CONNECTORS_USER_D_1, key: "c b") @join__type(graph: GRAPHQL, key: "id") { + a: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") @join__field(graph: CONNECTORS_QUERY_USERS_0, type: "String") + b: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") @join__field(graph: CONNECTORS_USER_D_1, type: "String") + id: ID! @join__field(graph: CONNECTORS_QUERY_USER_0, type: "ID!") @join__field(graph: CONNECTORS_QUERY_USERS_0, type: "ID!") @join__field(graph: GRAPHQL, type: "ID!") + d: String @join__field(graph: CONNECTORS_USER_D_1, type: "String") + c: String @join__field(graph: CONNECTORS_USER_D_1, type: "String") @join__field(graph: GRAPHQL, type: "String") +} + +type Query @join__type(graph: CONNECTORS_QUERY_USER_0) @join__type(graph: CONNECTORS_QUERY_USERS_0) @join__type(graph: CONNECTORS_USER_D_1) @join__type(graph: GRAPHQL) { + user(id: ID!): User @join__field(graph: CONNECTORS_QUERY_USER_0, type: "User") + users: [User] @join__field(graph: CONNECTORS_QUERY_USERS_0, type: "[User]") + _: ID @inaccessible @join__field(graph: CONNECTORS_USER_D_1, type: "ID") +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@steelthread.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@steelthread.graphql.snap new file mode 100644 index 0000000000..a8964870ed --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@steelthread.graphql.snap @@ -0,0 +1,71 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: raw_sdl +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/steelthread.graphql +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) @join__directive(graphs: [], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1"}) @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + CONNECTORS_QUERY_USER_0 @join__graph(name: "connectors_Query_user_0", url: "none") + CONNECTORS_QUERY_USERS_0 @join__graph(name: "connectors_Query_users_0", url: "none") + CONNECTORS_USER_D_1 @join__graph(name: "connectors_User_d_1", url: "none") + GRAPHQL @join__graph(name: "graphql", url: "https://localhost:4001") +} + +type User @join__type(graph: CONNECTORS_QUERY_USER_0, key: "id") @join__type(graph: CONNECTORS_QUERY_USERS_0) @join__type(graph: CONNECTORS_USER_D_1, key: "c") @join__type(graph: GRAPHQL, key: "id") { + id: ID! @join__field(graph: CONNECTORS_QUERY_USER_0, type: "ID!") @join__field(graph: CONNECTORS_QUERY_USERS_0, type: "ID!") @join__field(graph: GRAPHQL, type: "ID!") + name: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") @join__field(graph: CONNECTORS_QUERY_USERS_0, type: "String") + username: String @join__field(graph: CONNECTORS_QUERY_USER_0, type: "String") + d: String @join__field(graph: CONNECTORS_USER_D_1, type: "String") + c: String @join__field(graph: CONNECTORS_USER_D_1, type: "String") @join__field(graph: GRAPHQL, type: "String") +} + +type Query @join__type(graph: CONNECTORS_QUERY_USER_0) @join__type(graph: CONNECTORS_QUERY_USERS_0) @join__type(graph: CONNECTORS_USER_D_1) @join__type(graph: GRAPHQL) { + user(id: ID!): User @join__field(graph: CONNECTORS_QUERY_USER_0, type: "User") + users: [User] @join__field(graph: CONNECTORS_QUERY_USERS_0, type: "[User]") + _: ID @inaccessible @join__field(graph: CONNECTORS_USER_D_1, type: "ID") +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@types_used_twice.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@types_used_twice.graphql.snap new file mode 100644 index 0000000000..f47973bbf4 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@types_used_twice.graphql.snap @@ -0,0 +1,69 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: raw_sdl +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/types_used_twice.graphql +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @join__directive(graphs: [], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1"}) @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + CONNECTORS_QUERY_TS_0 @join__graph(name: "connectors_Query_ts_0", url: "none") +} + +type A @join__type(graph: CONNECTORS_QUERY_TS_0) { + id: ID @join__field(graph: CONNECTORS_QUERY_TS_0, type: "ID") +} + +type B @join__type(graph: CONNECTORS_QUERY_TS_0) { + a: A @join__field(graph: CONNECTORS_QUERY_TS_0, type: "A") +} + +type T @join__type(graph: CONNECTORS_QUERY_TS_0) { + a: A @join__field(graph: CONNECTORS_QUERY_TS_0, type: "A") + b: B @join__field(graph: CONNECTORS_QUERY_TS_0, type: "B") +} + +type Query @join__type(graph: CONNECTORS_QUERY_TS_0) { + ts: [T] @join__field(graph: CONNECTORS_QUERY_TS_0, type: "[T]") +} diff --git a/apollo-federation/src/connectors/expand/visitors/input.rs b/apollo-federation/src/connectors/expand/visitors/input.rs new file mode 100644 index 0000000000..abbf38e961 --- /dev/null +++ b/apollo-federation/src/connectors/expand/visitors/input.rs @@ -0,0 +1,133 @@ +use std::ops::Deref; + +use apollo_compiler::Node; +use apollo_compiler::ast::InputValueDefinition; +use apollo_compiler::schema::Component; +use apollo_compiler::schema::InputObjectType; +use indexmap::IndexMap; + +use super::FieldVisitor; +use super::GroupVisitor; +use super::SchemaVisitor; +use super::filter_directives; +use super::try_insert; +use super::try_pre_insert; +use crate::error::FederationError; +use crate::schema::position::InputObjectFieldDefinitionPosition; +use crate::schema::position::InputObjectTypeDefinitionPosition; +use crate::schema::position::TypeDefinitionPosition; + +impl FieldVisitor + for SchemaVisitor<'_, InputObjectTypeDefinitionPosition, InputObjectType> +{ + type Error = FederationError; + + fn visit<'a>(&mut self, field: InputObjectFieldDefinitionPosition) -> Result<(), Self::Error> { + let (_, r#type) = self.type_stack.last_mut().ok_or_else(|| { + FederationError::internal("tried to visit a field in a group not yet visited") + })?; + + // Extract the node info + let field_def = field.get(self.original_schema.schema())?; + + // Add the input to the currently processing object, making sure to not overwrite if it already + // exists (and verify that we didn't change the type) + let new_field = InputValueDefinition { + description: field_def.description.clone(), + name: field_def.name.clone(), + default_value: field_def.default_value.clone(), + ty: field_def.ty.clone(), + directives: filter_directives(self.directive_deny_list, &field_def.directives), + }; + + let input_type = self + .original_schema + .get_type(field_def.ty.inner_named_type().clone())?; + match input_type { + TypeDefinitionPosition::Scalar(pos) => { + try_pre_insert!(self.to_schema, pos)?; + try_insert!( + self.to_schema, + pos, + pos.get(self.original_schema.schema())?.clone() + )?; + } + TypeDefinitionPosition::Enum(pos) => { + try_pre_insert!(self.to_schema, pos)?; + try_insert!( + self.to_schema, + pos, + pos.get(self.original_schema.schema())?.clone() + )?; + } + _ => {} + } + + if let Some(old_field) = r#type.fields.get(&field.field_name) { + if *old_field.deref().deref() != new_field { + return Err(FederationError::internal(format!( + "tried to write field to existing type, but field type was different. expected {new_field:?} found {old_field:?}" + ))); + } + } else { + r#type + .fields + .insert(field.field_name, Component::new(new_field)); + } + + Ok(()) + } +} + +impl GroupVisitor + for SchemaVisitor<'_, InputObjectTypeDefinitionPosition, InputObjectType> +{ + fn try_get_group_for_field( + &self, + field: &InputObjectFieldDefinitionPosition, + ) -> Result, FederationError> { + // Return the next group, if found + let field_type = field.get(self.original_schema.schema())?; + let inner_type = self + .original_schema + .get_type(field_type.ty.inner_named_type().clone())?; + match inner_type { + TypeDefinitionPosition::InputObject(input) => Ok(Some(input)), + TypeDefinitionPosition::Scalar(_) | TypeDefinitionPosition::Enum(_) => Ok(None), + + other => Err(FederationError::internal(format!( + "input objects cannot include fields of type: {}", + other.type_name() + ))), + } + } + + fn enter_group<'a>( + &mut self, + group: &InputObjectTypeDefinitionPosition, + ) -> Result, FederationError> { + try_pre_insert!(self.to_schema, group)?; + + let group_def = group.get(self.original_schema.schema())?; + let output_type = InputObjectType { + description: group_def.description.clone(), + name: group_def.name.clone(), + directives: filter_directives(self.directive_deny_list, &group_def.directives), + fields: IndexMap::with_hasher(Default::default()), // Filled in by the rest of the visitor + }; + + self.type_stack.push((group.clone(), output_type)); + let def = group.get(self.original_schema.schema())?; + Ok(def.fields.keys().cloned().map(|f| group.field(f)).collect()) + } + + fn exit_group(&mut self) -> Result<(), FederationError> { + let (definition, r#type) = self + .type_stack + .pop() + .ok_or_else(|| FederationError::internal("tried to exit a group not yet visited"))?; + + // Now actually consolidate the object into our schema + try_insert!(self.to_schema, definition, Node::new(r#type)) + } +} diff --git a/apollo-federation/src/connectors/expand/visitors/mod.rs b/apollo-federation/src/connectors/expand/visitors/mod.rs new file mode 100644 index 0000000000..7c7e3200c3 --- /dev/null +++ b/apollo-federation/src/connectors/expand/visitors/mod.rs @@ -0,0 +1,424 @@ +//! Expansion Visitors +//! +//! This module contains various helper visitors for traversing nested structures, +//! adding needed types to a mutable schema. + +pub(crate) mod input; +mod selection; + +use std::collections::VecDeque; + +use apollo_compiler::Name; +use apollo_compiler::ast::Directive; +use indexmap::IndexSet; + +use crate::schema::FederationSchema; +use crate::schema::ValidFederationSchema; + +/// Filter out directives from a directive list +pub(crate) fn filter_directives<'a, D, I, O>(deny_list: &IndexSet, directives: D) -> O +where + D: IntoIterator, + I: 'a + AsRef + Clone, + O: FromIterator, +{ + directives + .into_iter() + .filter(|d| !deny_list.contains(&d.as_ref().name)) + .cloned() + .collect() +} + +/// Try to pre-insert into a schema, ignoring the operation if the type already exists +/// and matches the existing type +macro_rules! try_pre_insert { + ($schema:expr, $pos:expr) => {{ + if let Some(old_pos) = $schema.try_get_type($pos.type_name.clone()) { + // Verify that the types match + let pos = $crate::schema::position::TypeDefinitionPosition::from($pos.clone()); + if old_pos != pos { + Err($crate::FederationError::internal(format!( + "found different type when upserting: expected {:?} found {:?}", + pos, old_pos + ))) + } else { + Ok(()) + } + } else { + $pos.pre_insert($schema) + } + }}; +} + +/// Try to insert into a schema, ignoring the operation if the type already exists +/// and matches the existing type +macro_rules! try_insert { + ($schema:expr, $pos:expr, $def:expr) => {{ + if let Some(old_pos) = $schema.try_get_type($pos.type_name.clone()) { + // Verify that the types match + let pos = $crate::schema::position::TypeDefinitionPosition::from($pos.clone()); + if old_pos != pos { + Err($crate::FederationError::internal(format!( + "found different type when upserting: expected {:?} found {:?}", + pos, old_pos + ))) + } else { + Ok(()) + } + } else { + $pos.insert($schema, $def) + } + }}; +} +pub(crate) use try_insert; +pub(crate) use try_pre_insert; + +/// Visitor for arbitrary field types. +/// +/// Any type of interest that should be viewed when traversing the tree-like structure +/// defined by [GroupVisitor] should implement this trait. +pub(crate) trait FieldVisitor: Sized { + type Error; + + /// Visit a field + fn visit(&mut self, field: Field) -> Result<(), Self::Error>; +} + +/// Visitor for arbitrary tree-like structures where nodes can also have children +/// +/// This trait treats all nodes in the graph as Fields, checking if a Field is also +/// a group for handling children. Visiting order is depth-first. +pub(crate) trait GroupVisitor +where + Self: FieldVisitor, + Field: Clone, +{ + /// Try to get a group from a field, returning None if the field is not a group + fn try_get_group_for_field( + &self, + field: &Field, + ) -> Result, >::Error>; + + /// Enter a subselection group + /// Note: You can assume that the field corresponding to this + /// group will be visited first. + fn enter_group( + &mut self, + group: &Group, + ) -> Result, >::Error>; + + /// Exit a subselection group + /// Note: You can assume that the named selection corresponding to this + /// group will be visited and entered first. + fn exit_group(&mut self) -> Result<(), >::Error>; + + /// Walk through the `Group`, visiting each output key. If at any point, one of the + /// visitor methods returns an error, then the walk will be stopped and the error will be + /// returned. + fn walk(mut self, entry: Group) -> Result>::Error> { + // Start visiting each of the fields + let mut to_visit = + VecDeque::from_iter(self.enter_group(&entry)?.into_iter().map(|n| (0i32, n))); + let mut current_depth = 0; + while let Some((depth, next)) = to_visit.pop_front() { + for _ in depth..current_depth { + self.exit_group()?; + } + current_depth = depth; + + self.visit(next.clone())?; + + // If we have a named selection that has a subselection, then we want to + // make sure that we visit the children before all other siblings. + // + // Note: We reverse here since we always push to the front. + if let Some(group) = self.try_get_group_for_field(&next)? { + current_depth += 1; + + let fields = self.enter_group(&group)?; + fields + .into_iter() + .rev() + .for_each(|s| to_visit.push_front((current_depth, s))); + } + } + + // Make sure that we exit until we are no longer nested + for _ in 0..=current_depth { + self.exit_group()?; + } + + Ok(self) + } +} + +/// A visitor for schema building. +/// +/// This implementation of the JSONSelection visitor walks a JSONSelection, +/// copying over all output types (and respective fields / sub types) as it goes +/// from a reference schema. +pub(crate) struct SchemaVisitor<'a, Group, GroupType> { + /// List of directives to not copy over into the target schema. + directive_deny_list: &'a IndexSet, + + /// The original schema used for sourcing all types / fields / directives / etc. + original_schema: &'a ValidFederationSchema, + + /// The target schema for adding all types. + to_schema: &'a mut FederationSchema, + + /// A stack of parent types used for fetching subtypes + /// + /// Each entry corresponds to a nested subselect in the JSONSelection. + type_stack: Vec<(Group, GroupType)>, +} + +impl<'a, Group, GroupType> SchemaVisitor<'a, Group, GroupType> { + pub(crate) fn new( + original_schema: &'a ValidFederationSchema, + to_schema: &'a mut FederationSchema, + directive_deny_list: &'a IndexSet, + ) -> Self { + SchemaVisitor { + directive_deny_list, + original_schema, + to_schema, + type_stack: Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + use itertools::Itertools; + + use crate::connectors::JSONSelection; + use crate::connectors::SubSelection; + use crate::connectors::expand::visitors::FieldVisitor; + use crate::connectors::expand::visitors::GroupVisitor; + use crate::connectors::json_selection::NamedSelection; + use crate::error::FederationError; + + /// Visitor for tests. + /// + /// Each node visited is added, along with its depth. This is later printed + /// such that groups are indented based on depth. + struct TestVisitor<'a> { + depth_stack: Vec, + visited: &'a mut Vec<(usize, String)>, + } + + impl<'a> TestVisitor<'a> { + fn new(visited: &'a mut Vec<(usize, String)>) -> Self { + Self { + depth_stack: Vec::new(), + visited, + } + } + + fn last_depth(&self) -> Option { + self.depth_stack.last().copied() + } + } + + fn print_visited(visited: Vec<(usize, String)>) -> String { + let mut result = String::new(); + for (depth, visited) in visited { + result.push_str(&format!("{}{visited}\n", "| ".repeat(depth))); + } + + result + } + + impl FieldVisitor for TestVisitor<'_> { + type Error = FederationError; + + fn visit<'a>(&mut self, field: NamedSelection) -> Result<(), Self::Error> { + for name in field.names() { + self.visited + .push((self.last_depth().unwrap_or_default(), name.to_string())); + } + + Ok(()) + } + } + + impl GroupVisitor for TestVisitor<'_> { + fn try_get_group_for_field( + &self, + field: &NamedSelection, + ) -> Result, FederationError> { + Ok(field.next_subselection().cloned()) + } + + fn enter_group( + &mut self, + group: &SubSelection, + ) -> Result, FederationError> { + let next_depth = self.last_depth().map(|d| d + 1).unwrap_or(0); + self.depth_stack.push(next_depth); + Ok(group + .selections_iter() + .sorted_by_key(|s| s.names()) + .cloned() + .collect()) + } + + fn exit_group(&mut self) -> Result<(), FederationError> { + self.depth_stack.pop().unwrap(); + Ok(()) + } + } + + #[test] + fn it_iterates_over_empty_path() { + let mut visited = Vec::new(); + let visitor = TestVisitor::new(&mut visited); + let selection = JSONSelection::parse("").unwrap(); + + visitor + .walk(selection.next_subselection().cloned().unwrap()) + .unwrap(); + assert_snapshot!(print_visited(visited), @""); + } + + #[test] + fn it_iterates_over_simple_selection() { + let mut visited = Vec::new(); + let visitor = TestVisitor::new(&mut visited); + let selection = JSONSelection::parse("a b c d").unwrap(); + + visitor + .walk(selection.next_subselection().cloned().unwrap()) + .unwrap(); + assert_snapshot!(print_visited(visited), @r###" + a + b + c + d + "###); + } + + #[test] + fn it_iterates_over_aliased_selection() { + let mut visited = Vec::new(); + let visitor = TestVisitor::new(&mut visited); + let selection = JSONSelection::parse("a: one b: two c: three d: four").unwrap(); + + visitor + .walk(selection.next_subselection().cloned().unwrap()) + .unwrap(); + assert_snapshot!(print_visited(visited), @r###" + a + b + c + d + "###); + } + + #[test] + fn it_iterates_over_nested_selection() { + let mut visited = Vec::new(); + let visitor = TestVisitor::new(&mut visited); + let selection = JSONSelection::parse("a { b { c { d { e } } } } f").unwrap(); + + visitor + .walk(selection.next_subselection().cloned().unwrap()) + .unwrap(); + assert_snapshot!(print_visited(visited), @r###" + a + | b + | | c + | | | d + | | | | e + f + "###); + } + + #[test] + fn it_iterates_over_paths() { + let mut visited = Vec::new(); + let visitor = TestVisitor::new(&mut visited); + let selection = JSONSelection::parse( + "a + $.b { + c + $.d { + e + f: g.h { i } + } + } + j", + ) + .unwrap(); + + visitor + .walk(selection.next_subselection().cloned().unwrap()) + .unwrap(); + assert_snapshot!(print_visited(visited), @r###" + a + c + e + f + | i + j + "###); + } + + #[test] + fn it_iterates_over_complex_selection() { + let mut visited = Vec::new(); + let visitor = TestVisitor::new(&mut visited); + let selection = JSONSelection::parse( + "id + name + username + email + address { + street + suite + city + zipcode + geo { + lat + lng + } + } + phone + website + company { + name + catchPhrase + bs + }", + ) + .unwrap(); + + visitor + .walk(selection.next_subselection().cloned().unwrap()) + .unwrap(); + assert_snapshot!(print_visited(visited), @r###" + address + | city + | geo + | | lat + | | lng + | street + | suite + | zipcode + company + | bs + | catchPhrase + | name + email + id + name + phone + username + website + "###); + // let iter = selection.iter(); + // assert_debug_snapshot!(iter.collect_vec()); + } +} diff --git a/apollo-federation/src/connectors/expand/visitors/selection.rs b/apollo-federation/src/connectors/expand/visitors/selection.rs new file mode 100644 index 0000000000..1e4907601e --- /dev/null +++ b/apollo-federation/src/connectors/expand/visitors/selection.rs @@ -0,0 +1,202 @@ +use std::ops::Deref; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::FieldDefinition; +use apollo_compiler::schema::Component; +use apollo_compiler::schema::EnumType; +use apollo_compiler::schema::ObjectType; +use apollo_compiler::schema::ScalarType; +use indexmap::IndexMap; +use itertools::Itertools; + +use super::FieldVisitor; +use super::GroupVisitor; +use super::SchemaVisitor; +use super::filter_directives; +use super::try_insert; +use super::try_pre_insert; +use crate::connectors::SubSelection; +use crate::connectors::json_selection::NamedSelection; +use crate::error::FederationError; +use crate::schema::position::ObjectTypeDefinitionPosition; +use crate::schema::position::TypeDefinitionPosition; + +/// Type alias for JSONSelection group info +/// +/// A JSONSelection has subselections which do not have a way to lookup the parent subselection +/// nor the field name corresponding to that selection, so we need to keep the matching schema object +/// type when validating selections against concrete types. +pub(crate) type JSONSelectionGroup = (ObjectTypeDefinitionPosition, SubSelection); + +impl FieldVisitor for SchemaVisitor<'_, ObjectTypeDefinitionPosition, ObjectType> { + type Error = FederationError; + + fn visit<'a>(&mut self, field: NamedSelection) -> Result<(), Self::Error> { + let (definition, r#type) = self.type_stack.last_mut().ok_or_else(|| { + FederationError::internal("tried to visit a field in a group not yet entered") + })?; + + // Get the type of the field so we know how to visit it + for field_name in field.names() { + let field_name = Name::new(field_name)?; + let field = definition + .field(field_name.clone()) + .get(self.original_schema.schema())?; + let field_type = self + .original_schema + .get_type(field.ty.inner_named_type().clone())?; + let extended_field_type = field_type.get(self.original_schema.schema())?; + + // We only need to care about the type of the field if it isn't built-in + if !extended_field_type.is_built_in() { + match field_type { + TypeDefinitionPosition::Scalar(scalar) => { + let def = scalar.get(self.original_schema.schema())?; + let def = ScalarType { + description: def.description.clone(), + name: def.name.clone(), + directives: filter_directives( + self.directive_deny_list, + &def.directives, + ), + }; + + try_pre_insert!(self.to_schema, scalar)?; + try_insert!(self.to_schema, scalar, Node::new(def))?; + } + TypeDefinitionPosition::Enum(r#enum) => { + let def = r#enum.get(self.original_schema.schema())?; + let def = EnumType { + description: def.description.clone(), + name: def.name.clone(), + directives: filter_directives( + self.directive_deny_list, + &def.directives, + ), + values: def.values.clone(), + }; + + try_pre_insert!(self.to_schema, r#enum)?; + try_insert!(self.to_schema, r#enum, Node::new(def))?; + } + + // This will be handled by the rest of the visitor + TypeDefinitionPosition::Object(_) => {} + + // These will be handled later + TypeDefinitionPosition::Union(_) => { + return Err(FederationError::internal( + "unions are not yet handled for expansion", + )); + } + + // Anything else is not supported + TypeDefinitionPosition::InputObject(input) => { + return Err(FederationError::internal(format!( + "expected field to be a leaf or object type, found: input {}", + input.type_name, + ))); + } + TypeDefinitionPosition::Interface(interface) => { + return Err(FederationError::internal(format!( + "expected field to be a leaf or object type, found: interface {}", + interface.type_name, + ))); + } + }; + } + + // Add the field to the currently processing object, making sure to not overwrite if it already + // exists (and verify that we didn't change the type) + let new_field = FieldDefinition { + description: field.description.clone(), + name: field.name.clone(), + arguments: field.arguments.clone(), + ty: field.ty.clone(), + directives: filter_directives(self.directive_deny_list, &field.directives), + }; + if let Some(old_field) = r#type.fields.get(&field_name) { + if *old_field.deref().deref() != new_field { + return Err(FederationError::internal(format!( + "tried to write field to existing type, but field type was different. expected {new_field:?} found {old_field:?}" + ))); + } + } else { + r#type.fields.insert(field_name, Component::new(new_field)); + } + } + + Ok(()) + } +} + +impl GroupVisitor + for SchemaVisitor<'_, ObjectTypeDefinitionPosition, ObjectType> +{ + fn try_get_group_for_field( + &self, + field: &NamedSelection, + ) -> Result, FederationError> { + let (definition, _) = self.type_stack.last().ok_or_else(|| { + FederationError::internal("tried to get fields on a group not yet visited") + })?; + + match field.names().first() { + Some(field_name) => { + let field_name = Name::new(field_name)?; + let field_type_name = definition + .field(field_name) + .get(self.original_schema.schema())? + .ty + .inner_named_type(); + + let TypeDefinitionPosition::Object(field_type) = + self.original_schema.get_type(field_type_name.clone())? + else { + return Ok(None); + }; + + Ok(field.next_subselection().cloned().map(|s| (field_type, s))) + } + None => Ok(None), + } + } + + fn enter_group( + &mut self, + (group_type, group): &JSONSelectionGroup, + ) -> Result, FederationError> { + try_pre_insert!(self.to_schema, group_type)?; + let def = group_type.get(self.original_schema.schema())?; + + let sub_type = ObjectType { + description: def.description.clone(), + name: def.name.clone(), + implements_interfaces: def.implements_interfaces.clone(), + directives: filter_directives(self.directive_deny_list, &def.directives), + fields: IndexMap::with_hasher(Default::default()), // Will be filled in by the `visit` method for each field + }; + + self.type_stack.push((group_type.clone(), sub_type)); + Ok(group + .selections_iter() + .sorted_by_key(|s| s.names()) + .cloned() + .collect()) + } + + fn exit_group(&mut self) -> Result<(), FederationError> { + let (definition, r#type) = self + .type_stack + .pop() + .ok_or_else(|| FederationError::internal("tried to exit a group not yet entered"))?; + + try_insert!(self.to_schema, definition, Node::new(r#type)) + } +} + +#[cfg(test)] +mod tests { + // TODO: Write these tests +} diff --git a/apollo-federation/src/connectors/header.rs b/apollo-federation/src/connectors/header.rs new file mode 100644 index 0000000000..522bdce43b --- /dev/null +++ b/apollo-federation/src/connectors/header.rs @@ -0,0 +1,530 @@ +//! Headers defined in connectors `@source` and `@connect` directives. + +use std::ops::Deref; +#[cfg(test)] +use std::str::FromStr; + +use apollo_compiler::collections::IndexMap; +use serde_json_bytes::Value; + +use super::ApplyToError; +use crate::connectors::ConnectSpec; +use crate::connectors::string_template; +use crate::connectors::string_template::Part; +use crate::connectors::string_template::StringTemplate; + +#[derive(Clone, Debug)] +pub struct HeaderValue(StringTemplate); + +impl HeaderValue { + pub(crate) fn parse_with_spec( + s: &str, + spec: ConnectSpec, + ) -> Result { + let template = StringTemplate::parse_with_spec(s, spec)?; + // Validate that any constant parts are valid header values. + for part in &template.parts { + let Part::Constant(constant) = part else { + continue; + }; + http::HeaderValue::from_str(&constant.value).map_err(|_| string_template::Error { + message: format!("invalid value `{}`", constant.value), + location: constant.location.clone(), + })?; + } + Ok(Self(template)) + } + + /// Evaluate expressions in the header value. + /// + /// # Errors + /// + /// Returns an error any expression can't be evaluated, or evaluates to an unsupported type. + pub fn interpolate( + &self, + vars: &IndexMap, + ) -> Result<(http::HeaderValue, Vec), String> { + let (interpolated, apply_to_errors) = + self.0.interpolate(vars).map_err(|e| e.to_string())?; + let result = http::HeaderValue::from_str(&interpolated).map_err(|e| e.to_string())?; + Ok((result, apply_to_errors)) + } +} + +impl Deref for HeaderValue { + type Target = StringTemplate; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(test)] +impl FromStr for HeaderValue { + type Err = string_template::Error; + + /// Parses a [`HeaderValue`] from a &str, using [`ConnectSpec::latest()`] as + /// the parsing version. This trait implementation is only available in + /// tests, and should be avoided outside tests because it runs the risk of + /// ignoring the developer's chosen [`ConnectSpec`]. + fn from_str(s: &str) -> Result { + Self::parse_with_spec(s, ConnectSpec::latest()) + } +} + +#[cfg(test)] +mod test_header_value_parse { + use insta::assert_debug_snapshot; + + use super::*; + + #[test] + fn simple_constant() { + assert_debug_snapshot!( + HeaderValue::from_str("text"), + @r###" + Ok( + HeaderValue( + StringTemplate { + parts: [ + Constant( + Constant { + value: "text", + location: 0..4, + }, + ), + ], + }, + ), + ) + "### + ); + } + #[test] + fn simple_expression() { + assert_debug_snapshot!( + HeaderValue::from_str("{$config.one}"), + @r###" + Ok( + HeaderValue( + StringTemplate { + parts: [ + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $config, + range: Some( + 0..7, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "one", + ), + range: Some( + 8..11, + ), + }, + WithRange { + node: Empty, + range: Some( + 11..11, + ), + }, + ), + range: Some( + 7..11, + ), + }, + ), + range: Some( + 0..11, + ), + }, + }, + ), + spec: V0_2, + }, + location: 1..12, + }, + ), + ], + }, + ), + ) + "### + ); + } + #[test] + fn mixed_constant_and_expression() { + assert_debug_snapshot!( + HeaderValue::from_str("text{$config.one}text"), + @r###" + Ok( + HeaderValue( + StringTemplate { + parts: [ + Constant( + Constant { + value: "text", + location: 0..4, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $config, + range: Some( + 0..7, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "one", + ), + range: Some( + 8..11, + ), + }, + WithRange { + node: Empty, + range: Some( + 11..11, + ), + }, + ), + range: Some( + 7..11, + ), + }, + ), + range: Some( + 0..11, + ), + }, + }, + ), + spec: V0_2, + }, + location: 5..16, + }, + ), + Constant( + Constant { + value: "text", + location: 17..21, + }, + ), + ], + }, + ), + ) + "### + ); + } + + #[test] + fn invalid_header_values() { + assert_debug_snapshot!( + HeaderValue::from_str("\x7f"), + @r###" + Err( + Error { + message: "invalid value `\u{7f}`", + location: 0..1, + }, + ) + "### + ) + } + + #[test] + fn expressions_with_nested_braces() { + assert_debug_snapshot!( + HeaderValue::from_str("const{$config.one { two { three } }}another-const"), + @r###" + Ok( + HeaderValue( + StringTemplate { + parts: [ + Constant( + Constant { + value: "const", + location: 0..5, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $config, + range: Some( + 0..7, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "one", + ), + range: Some( + 8..11, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "two", + ), + range: Some( + 14..17, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "three", + ), + range: Some( + 20..25, + ), + }, + WithRange { + node: Empty, + range: Some( + 25..25, + ), + }, + ), + range: Some( + 20..25, + ), + }, + }, + }, + ], + range: Some( + 18..27, + ), + }, + ), + range: Some( + 18..27, + ), + }, + ), + range: Some( + 14..27, + ), + }, + }, + }, + ], + range: Some( + 12..29, + ), + }, + ), + range: Some( + 12..29, + ), + }, + ), + range: Some( + 7..29, + ), + }, + ), + range: Some( + 0..29, + ), + }, + }, + ), + spec: V0_2, + }, + location: 6..35, + }, + ), + Constant( + Constant { + value: "another-const", + location: 36..49, + }, + ), + ], + }, + ), + ) + "### + ); + } + + #[test] + fn missing_closing_braces() { + assert_debug_snapshot!( + HeaderValue::from_str("{$config.one"), + @r###" + Err( + Error { + message: "Invalid expression, missing closing }", + location: 0..12, + }, + ) + "### + ) + } +} + +#[cfg(test)] +mod test_interpolate { + use insta::assert_debug_snapshot; + use pretty_assertions::assert_eq; + use serde_json_bytes::json; + + use super::*; + #[test] + fn test_interpolate() { + let value = HeaderValue::from_str("before {$config.one} after").unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": "foo"})); + assert_eq!( + value.interpolate(&vars).unwrap().0, + http::HeaderValue::from_static("before foo after") + ); + } + + #[test] + fn test_interpolate_missing_value() { + let value = HeaderValue::from_str("{$config.one}").unwrap(); + let vars = IndexMap::default(); + assert_eq!( + value.interpolate(&vars).unwrap().0, + http::HeaderValue::from_static("") + ); + } + + #[test] + fn test_interpolate_value_array() { + let header_value = HeaderValue::from_str("{$config.one}").unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": ["one", "two"]})); + assert_eq!( + header_value.interpolate(&vars), + Err("Expression is not allowed to evaluate to arrays or objects.".to_string()) + ); + } + + #[test] + fn test_interpolate_value_bool() { + let header_value = HeaderValue::from_str("{$config.one}").unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": true})); + assert_eq!( + http::HeaderValue::from_static("true"), + header_value.interpolate(&vars).unwrap().0 + ); + } + + #[test] + fn test_interpolate_value_null() { + let header_value = HeaderValue::from_str("{$config.one}").unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": null})); + assert_eq!( + http::HeaderValue::from_static(""), + header_value.interpolate(&vars).unwrap().0 + ); + } + + #[test] + fn test_interpolate_value_number() { + let header_value = HeaderValue::from_str("{$config.one}").unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": 1})); + assert_eq!( + http::HeaderValue::from_static("1"), + header_value.interpolate(&vars).unwrap().0 + ); + } + + #[test] + fn test_interpolate_value_object() { + let header_value = HeaderValue::from_str("{$config.one}").unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": {}})); + assert_debug_snapshot!( + header_value.interpolate(&vars), + @r###" + Err( + "Expression is not allowed to evaluate to arrays or objects.", + ) + "### + ); + } + + #[test] + fn test_interpolate_value_string() { + let header_value = HeaderValue::from_str("{$config.one}").unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": "string"})); + assert_eq!( + http::HeaderValue::from_static("string"), + header_value.interpolate(&vars).unwrap().0 + ); + } +} + +#[cfg(test)] +mod test_get_expressions { + use super::*; + + #[test] + fn test_variable_references() { + let value = + HeaderValue::from_str("a {$this.a.b.c} b {$args.a.b.c} c {$config.a.b.c}").unwrap(); + let references: Vec<_> = value + .expressions() + .map(|e| e.expression.to_string()) + .collect(); + assert_eq!( + references, + vec!["$this.a.b.c", "$args.a.b.c", "$config.a.b.c"] + ); + } +} diff --git a/apollo-federation/src/connectors/id.rs b/apollo-federation/src/connectors/id.rs new file mode 100644 index 0000000000..169039618e --- /dev/null +++ b/apollo-federation/src/connectors/id.rs @@ -0,0 +1,238 @@ +use std::fmt; +use std::fmt::Display; +use std::fmt::Formatter; +use std::hash::Hash; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::Schema; +use apollo_compiler::ast::FieldDefinition; +use apollo_compiler::ast::NamedType; +use apollo_compiler::schema::Component; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::ObjectType; + +use crate::error::FederationError; +use crate::schema::position::ObjectOrInterfaceFieldDirectivePosition; + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct ObjectTypeDefinitionDirectivePosition { + pub(super) type_name: Name, + pub(super) directive_name: Name, + pub(super) directive_index: usize, +} + +/// Stores information about the position of the @connect directive, either +/// on a field or on a type. +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) enum ConnectorPosition { + Field(ObjectOrInterfaceFieldDirectivePosition), + Type(ObjectTypeDefinitionDirectivePosition), +} + +impl ConnectorPosition { + pub(crate) fn element<'s>( + &self, + schema: &'s Schema, + ) -> Result, FederationError> { + match self { + Self::Field(pos) => Ok(ConnectedElement::Field { + parent_type: schema + .types + .get(pos.field.parent().type_name()) + .and_then(|ty| { + if let ExtendedType::Object(obj) = ty { + Some(obj) + } else { + None + } + }) + .ok_or_else(|| { + FederationError::internal("Parent type for connector not found") + })?, + field_def: pos.field.get(schema).map_err(|_| { + FederationError::internal("Field definition for connector not found") + })?, + parent_category: if self.on_query_type(schema) { + ObjectCategory::Query + } else if self.on_mutation_type(schema) { + ObjectCategory::Mutation + } else { + ObjectCategory::Other + }, + }), + Self::Type(pos) => Ok(ConnectedElement::Type { + type_def: schema + .types + .get(&pos.type_name) + .and_then(|ty| { + if let ExtendedType::Object(obj) = ty { + Some(obj) + } else { + None + } + }) + .ok_or_else(|| FederationError::internal("Type for connector not found"))?, + }), + } + } + + // Only connectors on fields have a parent type (a root type or an entity type) + pub(crate) fn parent_type_name(&self) -> Option { + match self { + ConnectorPosition::Field(pos) => Some(pos.field.type_name().clone()), + ConnectorPosition::Type(_) => None, + } + } + + // The "base" type is the type returned by the connector. For connectors + // on fields, this is the field return type. For connectors on types, this + // is the type itself. + pub(crate) fn base_type_name(&self, schema: &Schema) -> Option { + match self { + ConnectorPosition::Field(_) => self + .field_definition(schema) + .map(|field| field.ty.inner_named_type().clone()), + ConnectorPosition::Type(pos) => Some(pos.type_name.clone()), + } + } + + pub(crate) fn field_definition<'s>( + &self, + schema: &'s Schema, + ) -> Option<&'s Component> { + match self { + ConnectorPosition::Field(pos) => pos.field.get(schema).ok(), + ConnectorPosition::Type(_) => None, + } + } + + pub(crate) fn coordinate(&self) -> String { + match self { + ConnectorPosition::Field(pos) => format!( + "{}.{}[{}]", + pos.field.type_name(), + pos.field.field_name(), + pos.directive_index, + ), + ConnectorPosition::Type(pos) => format!("{}[{}]", pos.type_name, pos.directive_index,), + } + } + + pub(crate) fn synthetic_name(&self) -> String { + match self { + ConnectorPosition::Field(pos) => format!( + "{}_{}_{}", + pos.field.type_name(), + pos.field.field_name(), + pos.directive_index, + ), + ConnectorPosition::Type(pos) => format!("{}_{}", pos.type_name, pos.directive_index), + } + } + + /// The "simple" name of a Connector position without directive index included. + /// This is useful for error messages where the index could be confusing to users. + pub(crate) fn simple_name(&self) -> String { + match self { + ConnectorPosition::Field(pos) => { + format!("{}.{}", pos.field.type_name(), pos.field.field_name(),) + } + ConnectorPosition::Type(pos) => format!("{}", pos.type_name), + } + } + + pub(super) fn on_root_type(&self, schema: &Schema) -> bool { + self.on_query_type(schema) || self.on_mutation_type(schema) + } + + fn on_query_type(&self, schema: &Schema) -> bool { + schema + .schema_definition + .query + .as_ref() + .is_some_and(|query| match self { + ConnectorPosition::Field(pos) => *pos.field.type_name() == query.name, + ConnectorPosition::Type(_) => false, + }) + } + + fn on_mutation_type(&self, schema: &Schema) -> bool { + schema + .schema_definition + .mutation + .as_ref() + .is_some_and(|mutation| match self { + ConnectorPosition::Field(pos) => *pos.field.type_name() == mutation.name, + ConnectorPosition::Type(_) => false, + }) + } +} + +/// Reifies the connector position into schema definitions +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConnectedElement<'schema> { + Field { + parent_type: &'schema Node, + field_def: &'schema Component, + parent_category: ObjectCategory, + }, + Type { + type_def: &'schema Node, + }, +} + +impl ConnectedElement<'_> { + pub(super) fn base_type_name(&self) -> NamedType { + match self { + ConnectedElement::Field { field_def, .. } => field_def.ty.inner_named_type().clone(), + ConnectedElement::Type { type_def } => type_def.name.clone(), + } + } + + pub(super) fn is_root_type(&self, schema: &Schema) -> bool { + self.is_query_type(schema) || self.is_mutation_type(schema) + } + + fn is_query_type(&self, schema: &Schema) -> bool { + schema + .schema_definition + .query + .as_ref() + .is_some_and(|query| match self { + ConnectedElement::Field { .. } => false, + ConnectedElement::Type { type_def } => type_def.name == query.name, + }) + } + + fn is_mutation_type(&self, schema: &Schema) -> bool { + schema + .schema_definition + .mutation + .as_ref() + .is_some_and(|mutation| match self { + ConnectedElement::Field { .. } => false, + ConnectedElement::Type { type_def } => type_def.name == mutation.name, + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ObjectCategory { + Query, + Mutation, + Other, +} + +impl Display for ConnectedElement<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::Field { + parent_type, + field_def, + .. + } => write!(f, "{}.{}", parent_type.name, field_def.name), + Self::Type { type_def } => write!(f, "{}", type_def.name), + } + } +} diff --git a/apollo-federation/src/connectors/json_selection/README.md b/apollo-federation/src/connectors/json_selection/README.md new file mode 100644 index 0000000000..6185155256 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/README.md @@ -0,0 +1,1177 @@ +# What is `JSONSelection` syntax? + +One of the most fundamental goals of the connectors project is that a GraphQL +subgraph schema, all by itself, should be able to encapsulate and selectively +re-expose any JSON-speaking data source as strongly-typed GraphQL, using a +declarative annotation syntax based on the `@source` and `@connect` directives, +with no need for additional resolver code, and without having to run a subgraph +server. + +Delivering on this goal entails somehow transforming arbitrary JSON into +GraphQL-shaped JSON without writing any procedural transformation code. Instead, +these transformations are expressed using a static, declarative string literal +syntax, which resembles GraphQL operation syntax but also supports a number of +other features necessary/convenient for processing arbitrary JSON. + +The _static_ part is important, since we need to be able to tell, by examining a +given `JSONSelection` string at composition time, exactly what shape its output +will have, even though we cannot anticipate every detail of every possible JSON +input that will be encountered at runtime. As a benefit of this static analysis, +we can then validate that the connector schema reliably generates the expected +GraphQL data types. + +In GraphQL terms, this syntax is represented by the `JSONSelection` scalar type, +whose grammar and semantics are detailed in this document. Typically, string +literals obeying this grammar will be passed as the `selection` argument to the +`@connect` directive, which is used to annotate fields of object types within a +subgraph schema. + +In terms of this Rust implementation, the string syntax is parsed into a +`JSONSelection` enum, which implements the `ApplyTo` trait for processing +incoming JSON and producing GraphQL-friendly JSON output. + +## Guiding principles + +As the `JSONSelection` syntax was being designed, and as we consider future +improvements, we should adhere to the following principles: + +1. Since `JSONSelection` syntax resembles GraphQL operation syntax and will + often be used in close proximity to GraphQL operations, whenever an element + of `JSONSelection` syntax looks the same as GraphQL, its behavior and + semantics should be the same as (or at least analogous to) GraphQL. It is + preferable, therefore, to invent new (non-GraphQL) `JSONSelection` syntax + when we want to introduce behaviors that are not part of GraphQL, or when + GraphQL syntax is insufficiently expressive to accomplish a particular + JSON-processing task. For example, `->` method syntax is better for inline + transformations that reusing/abusing GraphQL field argument syntax. + +2. It must be possible to statically determine the output shape (object + properties, array types, and nested value shapes) produced by a + `JSONSelection` string. JSON data encountered at runtime may be inherently + dynamic and unpredictable, but we must be able to validate the output shape + matches the GraphQL schema. Because we can assume all input data is some kind + of JSON, for types whose shape cannot be statically determined, the GraphQL + `JSON` scalar type can be used as an "any" type, though this should be + avoided because it limits the developer's ability to subselect fields of the + opaque `JSON` value in GraphQL operations. + +3. `JSONSelection` syntax may be _subsetted_ arbitrarily, either by generating a + reduced `JSONSelection` that serves the needs of a particular GraphQL + operation, or by skipping unneeded selections during `ApplyTo` execution. + When this subsetting happens, it would be highly undesirable for the behavior + of the remaining selections to change unexpectedly. Equivalently, but in the + other direction, `JSONSelection` syntax should always be _composable_, in the + sense that two `NamedSelection` items should continue to work as before when + used together in the same `SubSelection`. + +4. Backwards compatibility should be maintained as we release new versions of + the `JSONSelection` syntax along with new versions of the (forthcoming) + `@link(url: "https://specs.apollo.dev/connect/vX.Y")` specification. Wherever + possible, we should only add new functionality, not remove or change existing + functionality, unless we are releasing a new major version (and even then we + should be careful not to create unnecessary upgrade work for developers). + +## Formal grammar + +[Extended Backus-Naur Form](https://en.wikipedia.org/wiki/Extended_Backus%E2%80%93Naur_form) +(EBNF) provides a compact way to describe the complete `JSONSelection` grammar. + +This grammar is more for future reference than initial explanation, so don't +worry if it doesn't seem helpful yet, as every rule will be explained in detail +below. + +```ebnf +JSONSelection ::= NamedSelection* +SubSelection ::= "{" NamedSelection* "}" +NamedSelection ::= (Alias | "...")? PathSelection | Alias SubSelection +Alias ::= Key ":" +PathSelection ::= Path SubSelection? +VarPath ::= "$" (NO_SPACE Identifier)? PathTail +KeyPath ::= Key PathTail +AtPath ::= "@" PathTail +ExprPath ::= "$(" LitExpr ")" PathTail +PathTail ::= "?"? (PathStep "?"?)* +NonEmptyPathTail ::= "?"? (PathStep "?"?)+ | "?" +PathStep ::= "." Key | "->" Identifier MethodArgs? +Key ::= Identifier | LitString +Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]* +MethodArgs ::= "(" (LitExpr ("," LitExpr)* ","?)? ")" +LitExpr ::= LitOpChain | LitPath | LitPrimitive | LitObject | LitArray | PathSelection +LitOpChain ::= LitExpr (LitOp LitExpr)+ +LitOp ::= "??" | "?!" +LitPath ::= (LitPrimitive | LitObject | LitArray) NonEmptyPathTail +LitPrimitive ::= LitString | LitNumber | "true" | "false" | "null" +LitString ::= "'" ("\\'" | [^'])* "'" | '"' ('\\"' | [^"])* '"' +LitNumber ::= "-"? ([0-9]+ ("." [0-9]*)? | "." [0-9]+) +LitObject ::= "{" (LitProperty ("," LitProperty)* ","?)? "}" +LitProperty ::= Key ":" LitExpr +LitArray ::= "[" (LitExpr ("," LitExpr)* ","?)? "]" +NO_SPACE ::= !SpacesOrComments +SpacesOrComments ::= (Spaces | Comment)+ +Spaces ::= ("⎵" | "\t" | "\r" | "\n")+ +Comment ::= "#" [^\n]* +``` + +### How to read this grammar + +Every valid `JSONSelection` string can be parsed by starting with the +`JSONSelection` non-terminal and repeatedly applying one of the expansions on +the right side of the `::=` operator, with alternatives separated by the `|` +operator. Every `CamelCase` identifier on the left side of the `::=` operator +can be recursively expanded into one of its right-side alternatives. + +Methodically trying out all these alternatives is the fundamental job of the +parser. Parsing succeeds when only terminal tokens remain (quoted text or +regular expression character classes). + +Ambiguities can be resolved by applying the alternatives left to right, +accepting the first set of expansions that fully matches the input tokens. An +example where this kind of ordering matters is the `NamedSelection` rule, which +specifies parsing `NamedPathSelection` before `NamedFieldSelection` and +`NamedQuotedSelection`, so the entire path will be consumed, rather than +mistakenly consuming only the first key in the path as a field name. + +As in many regular expression syntaxes, the `*` and `+` operators denote +repetition (_zero or more_ and _one or more_, respectively), `?` denotes +optionality (_zero or one_), parentheses allow grouping, `"quoted"` or +`'quoted'` text represents raw characters that cannot be expanded further, and +`[...]` specifies character ranges. + +### Whitespace, comments, and `NO_SPACE` + +In many parsers, whitespace and comments are handled by the lexer, which +performs tokenization before the parser sees the input. This approach can +simplify the grammar, because the parser doesn't need to worry about whitespace +or comments, and can focus instead on parsing the structure of the input tokens. + +The grammar shown above adopts this convention. In other words, instead of +explicitly specifying everywhere whitespace and comments are allowed, we +verbally declare that **whitespace and comments are _allowed_ between any +tokens, except where explicitly forbidden by the `NO_SPACE` notation**. The +`NO_SPACE ::= !SpacesOrComments` rule is called _negative lookahead_ in many +parsing systems. Spaces are also implicitly _required_ if omitting them would +undesirably result in parsing adjacent tokens as one token, though the grammar +cannot enforce this requirement. + +While the current Rust parser implementation does not have a formal lexical +analysis phase, the `spaces_or_comments` function is used extensively to consume +whitespace and `#`-style comments wherever they might appear between tokens. The +negative lookahead of `NO_SPACE` is enforced by _avoiding_ `spaces_or_comments` +in a few key places: + +```ebnf +VarPath ::= "$" (NO_SPACE Identifier)? PathTail +Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]* +``` + +These rules mean the `$` of a `$variable` cannot be separated from the +identifier part (so `$ var` is invalid), and the first character of a +multi-character `Identifier` must not be separated from the remaining +characters. + +Make sure you use `spaces_or_comments` generously when modifying or adding to +the grammar implementation, or parsing may fail in cryptic ways when the input +contains seemingly harmless whitespace or comment characters. + +### GraphQL string literals vs. `JSONSelection` string literals + +Since the `JSONSelection` syntax is meant to be embedded within GraphQL string +literals, and GraphQL shares the same `'...'` and `"..."` string literal syntax +as `JSONSelection`, it can be visually confusing to embed a `JSONSelection` +string literal (denoted by the `LitString` non-terminal) within a GraphQL +string. + +Fortunately, GraphQL also supports multi-line string literals, delimited by +triple quotes (`"""` or `'''`), which allow using single- or double-quoted +`JSONSelection` string literals freely within the GraphQL string, along with +newlines and `#`-style comments. + +While it can be convenient to write short `JSONSelection` strings inline using +`"` or `'` quotes at the GraphQL level, multi-line string literals are strongly +recommended (with comments!) for any `JSONSelection` string that would overflow +the margin of a typical text editor. + +## Rule-by-rule grammar explanation + +This section discusses each non-terminal production in the `JSONSelection` +grammar, using a visual representation of the EBNF syntax called "railroad +diagrams" to illustrate the possible expansions of each rule. In case you need +to generate new diagrams or regenerate existing ones, you can use [this online +generator](https://rr.red-dove.com/ui), whose source code is available +[here](https://github.com/GuntherRademacher/rr). + +The railroad metaphor comes from the way you read the diagram: start at the ▶▶ +arrows on the far left side, and proceed along any path a train could take +(without reversing) until you reach the ▶◀ arrows on the far right side. +Whenever your "train" stops at a non-terminal node, recursively repeat the +process using the diagram for that non-terminal. When you reach a terminal +token, the input must match that token at the current position to proceed. If +you get stuck, restart from the last untaken branch. The input is considered +valid if you can reach the end of the original diagram, and invalid if you +exhaust all possible alternatives without reaching the end. + +I like to think every stop along the railroad has a gift shop and restrooms, so +feel free to take your time and enjoy the journey. + +### `JSONSelection ::= NamedSelection*` + +![JSONSelection](./grammar/JSONSelection.svg) + +The `JSONSelection` non-terminal is the top-level entry point for the grammar, +and consists of zero or more `NamedSelection` items. Each `NamedSelection` can +include an optional `Alias` or `...` followed by a `PathSelection`, or a +(mandatory) `Alias` followed by a `SubSelection`. + +### `SubSelection ::= "{" NamedSelection* "}"` + +![SubSelection](./grammar/SubSelection.svg) + +A `SubSelection` is a sequence of zero or more `NamedSelection` items surrounded +by `{` and `}`, and is used to select specific properties from the preceding +object, much like a nested selection set in a GraphQL operation. + +Note that `SubSelection` may appear recursively within itself, as part of one of +the various `NamedSelection` rules. This recursion allows for arbitrarily deep +nesting of selections, which is necessary to handle complex JSON structures. + +### `NamedSelection ::= (Alias | "...")? PathSelection | Alias SubSelection` + +![NamedSelection](./grammar/NamedSelection.svg) + +Every production of the `NamedSelection` non-terminal adds named properties to +the output object, though they obtain their properties/values from the input +object in different ways. + +Since `PathSelection` returns an anonymous value extracted from the given path, +if you want to use a `PathSelection` alongside other `NamedSelection` items, you +can either prefix it with an `Alias` or with a `...` spread operator, or ensure +the path has a trailing `SubSelection` guaranteeing an output object with fields +that can be merged into the larger selection set. + +For example, the `abc:` alias in this example causes the `{ a b c }` object +selected from `some.nested.path` to be nested under an `abc` output key: + +```graphql +id +author { name } +abc: some.nested.path { a b c } +``` + +This selection produces an output object with keys `id`, `author`, and `abc`, +where, `author` has an object value with a single `name` keye, and `abc` is an +object with keys `a`, `b`, and `c`. + +Note that `id` and `author` are single-`Key` `PathSelection`s with no `Alias` or +`...` preceding them. When they appear in a larger `SubSelection` (for example, +here at the top level), they assign their computed value to an output key +matching the single `Key` in the `PathSelection` (`id` or `author`). + +The `Alias`-free version is useful when you want to merge the output fields of a +path selection as siblings of other fields in a larger selection set: + +```graphql +id +author { name } +some.nested.path { a b c } +``` + +This produces an output object with keys `id`, `author`, `a`, `b`, and `c`, all +at the same level, rather than grouping them under the `abc` alias`. + +```graphql +id +created +model + +# The { role content } SubSelection is mandatory so the output keys +# can be statically determined: +choices->first.message { role content } +``` + +#### Single-`Key` path selections + +You may have noticed an ambiguity between these two lines: + +```graphql +author { name } +some.nested.path { a b c } +``` + +Since `some.nested.path` is a `Path` with a trailing `SubSelection` (concretely, +the `{ a b c }` part), we've said the `a`, `b`, and `c` fields of that +`SubSelection` get merged into the top-level output object. + +But isn't `author` also a (single-`Key`) `Path` with a trailing `SubSelection` +(the `{ name }` part)? Why do we preserve the `author` key, rather than merging +the `name` field into the top-level output object, as with `a`, `b`, and `c` +from `some.nested.path { a b c }`? + +**The answer is that `author` has a special status as a single-`Key` `Path` used +in a `SubSelection` context.** By contrast, `some.nested.path` is not a `Path` +that has an obvious (single) output `Key`, so it remains anonymous unless you +explicitly provide an alias: `abc: some.nested.path { a b c }`. Referring back +to the [Guiding principles](#guiding-principles), the `author { name }` syntax +resembles GraphQL operation syntax, so it must behave like GraphQL query syntax. +However, the `some.nested.path { a b c }` is not valid GraphQL, since there is +no GraphQL syntax for path selections, so we are released from the obligation to +do exactly what GraphQL does. + +You can always turn a single-`Key` path selection like `author { name }` into an +anonymous `VarPath` like `$.author { name }` by using the `$` variable as an +explicit disambiguator. This change always preserves the value because +single-`Key` field selections like `author` implicitly refer to the `$.author` +value, where `$` is the current object under selection. + +In addition to prefixing the key with `$.`, as soon as you append one or more +`PathStep`s to the single-`Key` path, as in `author.name` or +`author->get("name")->slice(0, 100)`, it becomes a `KeyPath` and thus behaves +like `some.nested.path`, no longer automatically providing `author` as its +output key. This makes sense because the path has undergone a transformation +that likely means the path's ultimate value is no longer accurately described by +the original `author` key. Note also that `$.author.name` is equivalent to +`author.name`, but the `$.` is typically unnecessary because `author.name` is +already unambiguously a more-than-single-`Key` path. + +Note that the special status of single-`Key` path selections is restricted to +the `SubSelection` parsing context. When you use the `$(...)` syntax or pass +`->` method arguments, parsing switches into the `LitExpr` literal expression +parsing context, where there is no special status for single-`Key` paths, and +everything is parsed as a JSON literal or a `PathSelection`: + +```graphql +# These two expressions are equivalent: +author->echo([@.name, author.name, author { name }]) +$.author->echo([@.name, $.author.name, $.author { name }]) + +# Given input { author: { name: "Ben" } } +# Expected output: ["Ben", "Ben", { name: "Ben" }] +``` + +While the `author` in `author { name }` no longer produces an `author` output +key (because we are passing an argument to `->echo`, which is always a `LitExpr` +element), the `name` in `author { name }` has returned to `SubSelection` parsing +context because of the `{...}` braces, so `name` still counts as a single-`Key` +GraphQL-style field selection. + +#### Named group selections + +Sometimes you will need to take a group of named properties and nest them under +a new name in the output object. The `NamedSelection ::= Alias SubSelection` +syntax uses the `Alias` to name the nested `SubSelection` object containing the +named properties to be grouped. The `Alias` is mandatory because the grouped +object would otherwise be anonymous. + +For example, if the input JSON has `firstName` and `lastName` fields, but you +want to represent them under a single `names` field in the output object, you +could use the following `NamedGroupSelection`: + +```graphql +names: { + first: firstName + last: lastName +} +# Also allowed: +firstName +lastName +``` + +A common use case for `NamedGroupSelection` is to create nested objects from +scalar ID fields: + +```graphql +postID +title +author: { + id: authorID + name: authorName +} +``` + +This convention is useful when the `Author` type is an entity with `@key(fields: +"id")`, and you want to select fields from `post` and `post.author` in the same +query, without directly handling the `post.authorID` field in GraphQL. + +### `Alias ::= Identifier ":"` + +![Alias](./grammar/Alias.svg) + +Analogous to a GraphQL alias, the `Alias` syntax allows for renaming properties +from the input JSON to match the desired output shape. + +In addition to renaming, `Alias` can provide names to otherwise anonymous +structures, such as those selected by `PathSelection` or `NamedGroupSelection`. + +### `Path ::= VarPath | KeyPath | AtPath | ExprPath` + +![Path](./grammar/Path.svg) + +A `Path` is a `VarPath`, `KeyPath`, `AtPath`, or `ExprPath`, which forms the +prefix of both `PathSelection` and `PathWithSubSelection`. + +In the Rust implementation, there is no separate `Path` struct, as we represent +both `PathSelection` and `PathWithSubSelection` using the `PathSelection` struct +and `PathList` enum. The `Path` non-terminal is just a grammatical convenience, +to avoid repetition between `PathSelection` and `PathWithSubSelection`. + +### `PathSelection ::= Path SubSelection?` + +![PathSelection](./grammar/PathSelection.svg) + +A `PathSelection` is a `Path` followed by an optional `SubSelection`. The +purpose of a `PathSelection` is to extract a single anonymous value from the +input JSON, without preserving the nested structure of the keys along the path. + +Since properties along the path may be either `Identifier` or `LitString` +values, you are not limited to selecting only properties that are valid GraphQL +field names, e.g. `myID: people."Ben Newman".id`. This is a slight departure +from JavaScript syntax, which would use `people["Ben Newman"].id` to achieve the +same result. Using `.` for all steps along the path is more consistent, and +aligns with the goal of keeping all property names statically analyzable, since +it does not suggest dynamic properties like `people[$name].id` are allowed. + +Often, the whole `JSONSelection` string serves as a `PathSelection`, in cases +where you want to extract a single nested value from the input JSON, without +selecting any other named properties: + +```graphql +type Query { + authorName(isbn: ID!): String + @connect(source: "BOOKS", http: { GET: "/books/{$args.isbn}" }, selection: "author.name") +} +``` + +If you need to select other named properties, you can still use a +`PathSelection` within a `NamedSelection*` sequence, as long as you give it an +`Alias`: + +```graphql +type Query { + book(isbn: ID!): Book + @connect( + source: "BOOKS" + http: { GET: "/books/{$args.isbn}" } + selection: """ + title + year: publication.year + authorName: author.name + """ + ) +} +``` + +### `VarPath ::= "$" (NO_SPACE Identifier)? PathTail` + +![VarPath](./grammar/VarPath.svg) + +A `VarPath` is a `PathSelection` that begins with a `$variable` reference, which +allows embedding arbitrary variables and their sub-properties within the output +object, rather than always selecting a property from the input object. The +`variable` part must be an `Identifier`, and must not be separated from the `$` +by whitespace. + +In the Rust implementation, input variables are passed as JSON to the +`apply_with_vars` method of the `ApplyTo` trait, providing additional context +besides the input JSON. Unlike GraphQL, the provided variables do not all have +to be consumed, since variables like `$this` may have many more possible keys +than you actually want to use. + +Variable references are especially useful when you want to refer to field +arguments (like `$args.some.arg` or `$args { x y }`) or sibling fields of the +current GraphQL object (like `$this.sibling` or `sibs: $this { brother sister +}`). + +Injecting a known argument value comes in handy when your REST endpoint does not +return the property you need: + +```graphql +type Query { + user(id: ID!): User + @connect( + source: "USERS" + http: { GET: "/users/{$args.id}" } + selection: """ + # For some reason /users/{$args.id} returns an object with name + # and email but no id, so we inject the id manually: + id: $args.id + name + email + """ + ) +} + +type User @key(fields: "id") { + id: ID! + name: String + email: String +} +``` + +In addition to variables like `$this` and `$args`, a special `$` variable is +always bound to the value received by the closest enclosing `SubSelection`, which allows you to transform input data that looks like this + +```json +{ + "id": 123, + "name": "Ben", + "friend_ids": [234, 345, 456] +} +``` + +into output data that looks like this + +```json +{ + "id": 123, + "name": "Ben", + "friends": [{ "id": 234 }, { "id": 345 }, { "id": 456 }] +} +``` + +using the following `JSONSelection` string: + +```graphql +id name friends: friend_ids { id: $ } +``` + +Because `friend_ids` is an array, the `{ id: $ }` selection maps over each +element of the array, with `$` taking on the value of each scalar ID in turn. +See [the FAQ](#what-about-arrays) for more discussion of this array-handling +behavior. + +The `$` variable is also essential for disambiguating a `KeyPath` consisting of +only one key from a `NamedFieldSelection` with no `Alias`. For example, +`$.result` extracts the `result` property as an anonymous value from the current +object, where as `result` would select an object that still has the `result` +property. + +### `KeyPath ::= Key PathTail` + +![KeyPath](./grammar/KeyPath.svg) + +A `KeyPath` is a `PathSelection` that begins with a `Key` (referring to a +property of the current object) and is followed by a sequence of at least one +`PathStep`, where each `PathStep` either selects a nested key or invokes a `->` +method against the preceding value. + +For example: + +```graphql +items: data.nested.items { id name } +firstItem: data.nested.items->first { id name } +firstItemName: data.nested.items->first.name +``` + +An important ambiguity arises when you want to extract a `PathSelection` +consisting of only a single key, such as `data` by itself. Since there is no `.` +to disambiguate the path from an ordinary `NamedFieldSelection`, the `KeyPath` +rule is inadequate. Instead, you should use a `VarPath` (which also counts as a +`PathSelection`), where the variable is the special `$` character, which +represents the current value being processed: + +```graphql +$.data { id name } +``` + +This will produce a single object with `id` and `name` fields, without the +enclosing `data` property. Equivalently, you could manually unroll this example +to the following `NamedSelection*` sequence: + +```graphql +id: data.id +name: data.name +``` + +In this case, the `$.` is no longer necessary because `data.id` and `data.name` +are unambiguously `KeyPath` selections. + +### `AtPath ::= "@" PathTail` + +![AtPath](./grammar/AtPath.svg) + +Similar to the special `$` variable, the `@` character always represents the +current value being processed, which is often equal to `$`, but may differ from +the `$` variable when `@` is used within the arguments of `->` methods. + +For example, when you want to compute the logical conjunction of several +properties of the current object, you can keep using `$` with different property +selections: + +```graphql +all: $.first->and($.second)->and($.third) +``` + +If the `$` variable were rebound to the input value received by the `->and` +method, this style of method chaining would not work, because the `$.second` +expression would attempt to select a `second` property from the value of +`$.first`. Instead, the `$` remains bound to the same value received by the +closest enclosing `{...}` selection set, or the root value when used at the top +level of a `JSONSelection`. + +The `@` character becomes useful when you need to refer to the input value +received by a `->` method, as when using the `->echo` method to wrap a given +input value: + +```graphql +wrapped: field->echo({ fieldValue: @ }) +children: parent->echo([@.child1, @.child2, @.child3]) +``` + +The `->map` method has the special ability to apply its argument to each element +of its input array, so `@` will take on the value of each of those elements, +rather than referring to the array itself: + +```graphql +doubled: numbers->map({ value: @->mul(2) }) +types: values->map(@->typeof) +``` + +This special behavior of `@` within `->map` is available to any method +implementation, since method arguments are not evaluated before calling the +method, but are passed in as expressions that the method may choose to evaluate +(or even repeatedly reevaluate) however it chooses. + +### `ExprPath ::= "$(" LitExpr ")" PathTail` + +![ExprPath](./grammar/ExprPath.svg) + +Another syntax for beginning a `PathSelection` is the `ExprPath` rule, which is +a `LitExpr` enclosed by `$(...)`, followed by zero or more `PathStep` items. + +This syntax is especially useful for embedding literal values, allowing + +```graphql +__typename: $("Product") +condition: $(true) + +# Probably incorrect because "Product" and true parse as field names: +# __typename: "Product" +# condition: true + +# Best alternative option without ExprPath: +# __typename: $->echo("Product") +# condition: $->echo(true) +``` + +In addition to embedding a single value, this syntax also makes it easier to use +a literal expression as the input value for a `.key` or `->method` application, +as in + +```graphql +alphabetSlice: $("abcdefghijklmnopqrstuvwxyz")->slice($args.start, $args.end) + +# Instead of using $->echo(...): +# alphabetSlice: $->echo("abcdefghijklmnopqrstuvwxyz")->slice($args.start, $args.end) +``` + +The `->echo` method is still useful when you want to do something with the input +value (which is bound to `@` within the echoed expression), rather than ignoring +the input value (using `@` nowhere in the expression). + +#### The difference between `array.field->map(...)` and `$(array.field)->map(...)` + +When you apply a field selection to an array (as in `array.field`), the field +selection is automatically mapped over each element of the array, producing a +new array of all the field values. + +If the field selection has a further `->method` applied to it (as in +`array.field->map(...)`), the method will be applied to each of the resulting +field values _individually_, rather than to the array as a whole, which is +probably not what you want given that you're using `->map` (unless each field +value is an array, and you want an array of all those arrays, after mapping). + +The `$(...)` wrapping syntax can be useful to control this behavior, because it +allows writing `$(array.field)->map(...)`, which provides the complete array of +field values as a single input to the `->map` method: + +```json +// Input JSON +{ + "array": [{ "field": 1 }, { "field": 2 }, { "field": 3 }] +} +``` + +```graphql +# Produces [2, 4, 6] by doubling each field value +doubled: $(array.field)->map(@->mul(2)) + +# Produces [[2], [4], [6]], since ->map applied to a non-array produces a +# single-element array wrapping the result of the mapping expression applied +# to that individual value +nested: array.field->map(@->mul(2)) +``` + +In this capacity, the `$(...)` syntax is useful for controlling +associativity/grouping/precedence, similar to parenthesized expressions in other +programming languages. + +### `PathTail ::= "?"? (PathStep "?"?)*` + +![PathTail](./grammar/PathTail.svg) + +The `PathTail` non-terminal defines the continuation of a path in a +`JSONSelection`. A `PathTail` allows any number of trailing `.key` and/or +`->method(...)`-based `PathStep`s through the input JSON structure, as well as +allowing (at most) one optional `?` between each `PathStep`. + +The optional `?` syntax indicates the preceding input value may be missing or +`null`, enabling safe navigation through potentially incomplete JSON structures. +Specifically, `a?` maps `a` to missing (`None`) if `a` is `null`, and silences +subsequent errors when `a` is either `None` or `null`. + +Importantly, the `?` token may not be repeated more than once in a row. This +makes logical sense because `a??` is equivalent to `a?`, meaning the `?` is +idempotent. So we never _need_ more than one `?` in a row, and preventing +multiple `?`s now will give us more freedom to add other infix operators that +may involve `?` characters, in the future. + +### `NonEmptyPathTail ::= "?"? (PathStep "?"?)+ | "?"` + +![NonEmptyPathTail](./grammar/NonEmptyPathTail.svg) + +A `NonEmptyPathTail` is a `PathTail` consisting of at least one `?`, or at least +one `PathStep`. This non-emptiness is important for the `LitExpr::LitPath` rule, +so individual `LitExpr` expressions are never interpreted as `LitExpr::Path` +paths: + +```ebnf +NonEmptyPathTail ::= "?"? (PathStep "?"?)+ | "?" +LitExpr ::= LitPath | LitPrimitive | LitObject | LitArray | PathSelection +LitPath ::= (LitPrimitive | LitObject | LitArray) NonEmptyPathTail +``` + +In other words, if a `LitPrimitive` has no `PathTail` after it, then it's just a +`LitPrimitive` and not a `LitPath`. If it has a `NonEmptyPathTail` after it, +then it's a `LitPath`. + +### `PathStep ::= "." Key | "->" Identifier MethodArgs?` + +![PathStep](./grammar/PathStep.svg) + +A `PathStep` is a single step along a `VarPath` or `KeyPath`, which can either +select a nested key using `.`, invoke a method using `->`, or coerce `null` to +`None` using the `?` token. + +Keys selected using `.` can be either `Identifier` or `LitString` names, but +method names invoked using `->` must be `Identifier` names, and must be +registered in the `JSONSelection` parser in order to be recognized. + +For the time being, only a fixed set of known methods are supported, though this +list may grow and/or become user-configurable in the future: + +```graphql +# The ->echo method returns its first input argument as-is, ignoring +# the input data. Useful for embedding literal values, as in +# $->echo("give me this string"), or wrapping the input value. +__typename: $->echo("Book") +wrapped: field->echo({ fieldValue: @ }) + +# Returns the type of the data as a string, e.g. "object", "array", +# "string", "number", "boolean", or "null". Note that `typeof null` is +# "object" in JavaScript but "null" for our purposes. +typeOfValue: value->typeof + +# When invoked against an array, ->map evaluates its first argument +# against each element of the array, binding the element values to `@`, +# and returns an array of the results. When invoked against a non-array, +# ->map evaluates its first argument against that value and returns the +# result without wrapping it in an array. +doubled: numbers->map(@->mul(2)) +types: values->map(@->typeof) + +# Returns true if the data is deeply equal to the first argument, false +# otherwise. Equality is solely value-based (all JSON), no references. +isObject: value->typeof->eq("object") + +# Takes any number of pairs [candidate, value], and returns value for +# the first candidate that equals the input data. If none of the +# pairs match, a runtime error is reported, but a single-element +# [] array as the final argument guarantees a default value. +__typename: kind->match( + ["dog", "Canine"], + ["cat", "Feline"], + ["Exotic"] +) + +# Like ->match, but expects the first element of each pair to evaluate +# to a boolean, returning the second element of the first pair whose +# first element is true. This makes providing a final catch-all case +# easy, since the last pair can be [true, ]. +__typename: kind->matchIf( + [@->eq("dog"), "Canine"], + [@->eq("cat"), "Feline"], + [true, "Exotic"] +) + +# Arithmetic methods, supporting both integers and floating point values, +# similar to JavaScript. +sum: $.a->add($.b)->add($.c) +difference: $.a->sub($.b)->sub($.c) +product: $.a->mul($.b, $.c) +quotient: $.a->div($.b) +remainder: $.a->mod($.b) + +# Array/string methods +first: list->first +last: list->last +index3: list->get(3) +secondToLast: list->get(-2) +slice: list->slice(0, 5) +substring: string->slice(2, 5) +arraySize: array->size +stringLength: string->size + +# Object methods +aValue: $->echo({ a: 123 })->get("a") +hasKey: object->has("key") +hasAB: object->has("a")->and(object->has("b")) +numberOfProperties: object->size +keys: object->keys +values: object->values +entries: object->entries +keysFromEntries: object->entries.key +valuesFromEntries: object->entries.value + +# Logical methods +negation: $.condition->not +bangBang: $.condition->not->not +disjunction: $.a->or($.b)->or($.c) +conjunction: $.a->and($.b, $.c) +aImpliesB: $.a->not->or($.b) +excludedMiddle: $.toBe->or($.toBe->not)->eq(true) +``` + +Any `PathStep` may optionally be a `?` character, which maps `null` values to +`None`, short-circuiting path evaluation. + +```graphql +a: $args.something?.nested?.name +b: isNull?.possiblyNull?.value +c: $.doesNotExist?->slice(0, 5) +``` + +If any of these `?`s map a `null` value to `None`, the whole path will evaluate +to `None`, and the corresponding key (`a`, `b`, or `c`) will not be defined in +the output object. The same behavior holds if properties like `$args.something` +are simply missing (`None`) rather than `null`. + +### `MethodArgs ::= "(" (LitExpr ("," LitExpr)* ","?)? ")"` + +![MethodArgs](./grammar/MethodArgs.svg) + +When a `PathStep` invokes an `->operator` method, the method invocation may +optionally take a sequence of comma-separated `LitExpr` arguments in +parentheses, as in `list->slice(0, 5)` or `kilometers: miles->mul(1.60934)`. + +Methods do not have to take arguments, as in `list->first` or `list->last`, +which is why `MethodArgs` is optional in `PathStep`. + +### `Key ::= Identifier | LitString` + +![Key](./grammar/Key.svg) + +A property name occurring along a dotted `PathSelection`, either an `Identifier` +or a `LitString`. + +### `Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]*` + +![Identifier](./grammar/Identifier.svg) + +Any valid GraphQL field name. If you need to select a property that is not +allowed by this rule, use a `NamedQuotedSelection` instead. + +In some languages, identifiers can include `$` characters, but `JSONSelection` +syntax aims to match GraphQL grammar, which does not allow `$` in field names. +Instead, the `$` is reserved for denoting variables in `VarPath` selections. + +### `LitExpr ::= LitOpChain | LitPath | LitPrimitive | LitObject | LitArray | PathSelection` + +![LitExpr](./grammar/LitExpr.svg) + +A `LitExpr` (short for _literal expression_) represents a JSON-like value that +can be passed inline as part of `MethodArgs`. + +The `LitExpr` mini-language diverges from JSON by allowing symbolic +`PathSelection` values (which may refer to variables or fields) in addition to +the usual JSON primitives. This allows `->` methods to be parameterized in +powerful ways, e.g. `page: list->slice(0, $limit)`. + +The `LitOpChain` variant supports operator chains like null-coalescing (`??`) and +none-coalescing (`?!`) operators, allowing expressions like `$($.field ?? "default")`. + +Also, as a minor syntactic convenience, `LitObject` literals can have +`Identifier` or `LitString` keys, whereas JSON objects can have only +double-quoted string literal keys. + +#### Null-coalescing operators + +The `LitExpr` syntax supports two null-coalescing operators through the +`LitOpChain` rule: `??` and `?!`. These operators provide fallback values when +expressions evaluate to `null` or are missing entirely (`None`). + +The `LitOpChain` rule defines operator chains where all operators in a chain must be the same type: +- `A ?? B` (null-coalescing): Returns `B` if `A` is `null` or `None`, otherwise returns `A` +- `A ?! B` (none-coalescing): Returns `B` if `A` is `None`, otherwise returns `A` (preserving `null` values) + +These operators are left-associative and can be chained, but **operators cannot +be mixed** within a single chain: + +```graphql +# Valid: Basic usage +fallback: $(missingField ?? "default") +preserveNull: $(nullField ?! "default") # keeps null as null + +# Valid: Chaining same operators (left-to-right evaluation) +multiLevel: $(first ?? second ?? third ?? "final fallback") +noneChain: $(first ?! second ?! third ?! "final fallback") + +# Valid: Combined with other expressions +computed: $(value ?? 0->add(10)) + +# INVALID: Mixed operators in same chain +mixed: $(first ?? second ?! third) # Parse error! +``` + +**Important**: Mixing operators like `??` and `?!` in the same expression chain +is not allowed. Each operator chain must use only one type of operator. This +restriction ensures predictable evaluation semantics and leaves room for future +operator precedence rules. + +The key difference between the operators is how they handle `null` values: +- `??` treats both `null` and `None` (missing) as "falsy" and uses the fallback +- `?!` only treats `None` (missing) as "falsy", preserving explicit `null` values + +### `LitOpChain ::= LitExpr (LitOp LitExpr)+` + +![LitOpChain](./grammar/LitOpChain.svg) + +A `LitOpChain` represents an operator chain where a binary operator is applied +to two or more operands. The parser only supports chaining multiple operands +with the same operator type, even though the grammar cannot easily enforce the +`LitOp` is the same for the whole sequence. + +For example, the expression `A ?? B ?? C` is parsed as a single `LitOpChain` +with operator `??` and operands `[A, B, C]`, evaluated left-to-right such that +`B` is returned if `A` is null/missing, `C` is returned if both `A` and `B` are +null/missing, otherwise the first non-null/non-missing value is returned. + +**Important restriction**: All operators in a chain must be the same type. Mixed +operators like `A ?? B ?! C` will fail to parse as a single `LitOpChain`, with +the parser stopping after the first operator change and leaving unparsed +remainder. + +### `LitOp ::= "??" | "?!"` + +![LitOp](./grammar/LitOp.svg) + +The `LitOp` rule defines the binary operators currently supported in `LitOpChain` expressions: + +- `??` (null-coalescing): Returns the right operand if the left operand is `null` or `None` (missing) +- `?!` (none-coalescing): Returns the right operand if the left operand is `None` (missing), but preserves `null` values + +This rule is designed to be extensible for future operators like `&&`, `||`, +`==`, `!=`, `<`, `<=`, `>`, `>=`, `+`, `-`, `*`, `/`, `%` while maintaining the +constraint that operators cannot be mixed within a single chain. + +### `LitPath ::= (LitPrimitive | LitObject | LitArray) PathStep+` + +![LitPath](./grammar/LitPath.svg) + +A `LitPath` is a special form of `PathSelection` (similar to `VarPath`, +`KeyPath`, `AtPath`, and `ExprPath`) that can be used _only_ within `LitExpr` +expressions, allowing the head of the path to be any `LitExpr` value, with a +non-empty tail of `PathStep` items afterward: + +```graphql +object: $({ + sd: "asdf"->slice(1, 3), + sum: 1234->add(5678), + celsius: 98.6->sub(32)->mul(5)->div(9), + nine: -1->add(10), + false: true->not, + true: false->not, + twenty: { a: 1, b: 2 }.b->mul(10), + last: [1, 2, 3]->last, + justA: "abc"->first, + justC: "abc"->last, +}) +``` + +Note that expressions like `true->not` and `"asdf"->slice(1, 3)` have a +different interpretation in the default selection syntax (outside of `LitExpr` +parsing), since `true` and `"asdf"` will be interpreted as field names there, +not as literal values. If you want to refer to a quoted field value within a +`LitExpr`, you can use the `$.` variable prefix to disambiguate it: + +```graphql +object: $({ + fieldEntries: $."quoted field"->entries, + stringPrefix: "quoted field"->slice(0, "quoted"->size), +}) +``` + +You can still nest the `$(...)` inside itself (or use it within `->` method +arguments), as in + +```graphql +justA: $($("abc")->first) +nineAgain: $($(-1)->add($(10))) +``` + +In these examples, only the outermost `$(...)` wrapper is required, though the +inner wrappers may be used to clarify the structure of the expression, similar +to parentheses in other languages. + +### `LitPrimitive ::= LitString | LitNumber | "true" | "false" | "null"` + +![LitPrimitive](./grammar/LitPrimitive.svg) + +Analogous to a JSON primitive value, with the only differences being that +`LitNumber` does not currently support the exponential syntax, and `LitString` +values can be single-quoted as well as double-quoted. + +### `LitString ::= "'" ("\\'" | [^'])* "'" | '"' ('\\"' | [^"])* '"'` + +![LitString](./grammar/LitString.svg) + +A string literal that can be single-quoted or double-quoted, and may contain any +characters except the quote character that delimits the string. The backslash +character `\` can be used to escape the quote character within the string. + +Note that the `\\'` and `\\"` tokens correspond to character sequences +consisting of two characters: a literal backslash `\` followed by a single quote +`'` or double quote `"` character, respectively. The double backslash is +important so the backslash can stand alone, without escaping the quote +character. + +You can avoid most of the headaches of escaping by choosing your outer quote +characters wisely. If your string contains many double quotes, use single quotes +to delimit the string, and vice versa, as in JavaScript. + +### `LitNumber ::= "-"? ([0-9]+ ("." [0-9]*)? | "." [0-9]+)` + +![LitNumber](./grammar/LitNumber.svg) + +A numeric literal that is possibly negative and may contain a fractional +component. The integer component is required unless a fractional component is +present, and the fractional component can have zero digits when the integer +component is present (as in `-123.`), but the fractional component must have at +least one digit when there is no integer component, since `.` is not a valid +numeric literal by itself. + +### `LitObject ::= "{" (LitProperty ("," LitProperty)* ","?)? "}"` + +![LitObject](./grammar/LitObject.svg) + +A sequence of `LitProperty` items within curly braces, as in JavaScript. + +Trailing commas are not currently allowed, but could be supported in the future. + +### `LitProperty ::= Key ":" LitExpr` + +![LitProperty](./grammar/LitProperty.svg) + +A key-value pair within a `LitObject`. Note that the `Key` may be either an +`Identifier` or a `LitString`, as in JavaScript. This is a little different +from JSON, which allows double-quoted strings only. + +### `LitArray ::= "[" (LitExpr ("," LitExpr)* ","?)? "]"` + +![LitArray](./grammar/LitArray.svg) + +A list of `LitExpr` items within square brackets, as in JavaScript. + +Trailing commas are not currently allowed, but could be supported in the future. + +### `NO_SPACE ::= !SpacesOrComments` + +The `NO_SPACE` non-terminal is used to enforce the absence of whitespace or +comments between certain tokens. See [Whitespace, comments, and +`NO_SPACE`](#whitespace-comments-and-no_space) for more information. There is no +diagram for this rule because the `!` negative lookahead operator is not +supported by the railroad diagram generator. + +### `SpacesOrComments ::= (Spaces | Comment)+` + +![SpacesOrComments](./grammar/SpacesOrComments.svg) + +A run of either whitespace or comments involving at least one character, which +are handled equivalently (ignored) by the parser. + +### `Spaces ::= ("⎵" | "\t" | "\r" | "\n")+` + +![Spaces](./grammar/Spaces.svg) + +A run of at least one whitespace character, including spaces, tabs, carriage +returns, and newlines. + +Note that we generally allow any amount of whitespace between tokens, so the +`Spaces` non-terminal is not explicitly used in most places where whitespace is +allowed, though it could be used to enforce the presence of some whitespace, if +desired. + +### `Comment ::= "#" [^\n]*` + +![Comment](./grammar/Comment.svg) + +A `#` character followed by any number of characters up to the next newline +character. Comments are allowed anywhere whitespace is allowed, and are handled +like whitespace (i.e. ignored) by the parser. + +## FAQ + +### What about arrays? + +As with standard GraphQL operation syntax, there is no explicit representation +of array-valued fields in this grammar, but (as with GraphQL) a `SubSelection` +following an array-valued field or `PathSelection` will be automatically applied +to every element of the array, thereby preserving/mapping/sub-selecting the +array structure. + +Conveniently, this handling of arrays also makes sense within dotted +`PathSelection` elements, which do not exist in GraphQL. Consider the following +selections, assuming the `author` property of the JSON object has an object +value with a child property called `articles` whose value is an array of +`Article` objects, which have `title`, `date`, `byline`, and `author` +properties: + +```graphql +@connect( + selection: "author.articles.title" #1 + selection: "author.articles { title }" #2 + selection: "author.articles { title date }" #3 + selection: "author.articles.byline.place" #4 + selection: "author.articles.byline { place date }" #5 + selection: "author.articles { name: author.name place: byline.place }" #6 + selection: "author.articles { titleDateAlias: { title date } }" #7 +) +``` + +These selections should produce the following result shapes: + +1. an array of `title` strings +2. an array of `{ title }` objects +3. an array of `{ title date }` objects +4. an array of `place` strings +5. an array of `{ place date }` objects +6. an array of `{ name place }` objects +7. an array of `{ titleDateAlias }` objects + +If the `author.articles` value happened not to be an array, this syntax would +resolve a single result in each case, instead of an array, but the +`JSONSelection` syntax would not have to change to accommodate this possibility. + +If the top-level JSON input itself is an array, then the whole `JSONSelection` +will be applied to each element of that array, and the result will be an array +of those results. + +Compared to dealing explicitly with hard-coded array indices, this automatic +array mapping behavior is much easier to reason about, once you get the hang of +it. If you're familiar with how arrays are handled during GraphQL execution, +it's essentially the same principle, extended to the additional syntaxes +introduced by `JSONSelection`. + +### Why a string-based syntax, rather than first-class syntax? + +### What about field argument syntax? + +### What future `JSONSelection` syntax is under consideration? diff --git a/apollo-federation/src/connectors/json_selection/apply_to.rs b/apollo-federation/src/connectors/json_selection/apply_to.rs new file mode 100644 index 0000000000..3b611c73e0 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/apply_to.rs @@ -0,0 +1,4777 @@ +/// ApplyTo is a trait for applying a JSONSelection to a JSON value, collecting +/// any/all errors encountered in the process. +use std::hash::Hash; + +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; +use serde_json_bytes::Map as JSONMap; +use serde_json_bytes::Value as JSON; +use serde_json_bytes::json; +use shape::Shape; +use shape::ShapeCase; +use shape::location::Location; +use shape::location::SourceId; + +use super::helpers::json_merge; +use super::helpers::json_type_name; +use super::immutable::InputPath; +use super::known_var::KnownVariable; +use super::lit_expr::LitExpr; +use super::lit_expr::LitOp; +use super::location::OffsetRange; +use super::location::Ranged; +use super::location::WithRange; +use super::methods::ArrowMethod; +use super::parser::*; +use crate::connectors::spec::ConnectSpec; + +pub(super) type VarsWithPathsMap<'a> = IndexMap)>; + +impl JSONSelection { + // Applying a selection to a JSON value produces a new JSON value, along + // with any/all errors encountered in the process. The value is represented + // as an Option to allow for undefined/missing values (which JSON does not + // explicitly support), which are distinct from null values (which it does + // support). + pub fn apply_to(&self, data: &JSON) -> (Option, Vec) { + self.apply_with_vars(data, &IndexMap::default()) + } + + pub fn apply_with_vars( + &self, + data: &JSON, + vars: &IndexMap, + ) -> (Option, Vec) { + // Using IndexSet over HashSet to preserve the order of the errors. + let mut errors = IndexSet::default(); + + let mut vars_with_paths: VarsWithPathsMap = IndexMap::default(); + for (var_name, var_data) in vars { + vars_with_paths.insert( + KnownVariable::from_str(var_name.as_str()), + (var_data, InputPath::empty().append(json!(var_name))), + ); + } + // The $ variable initially refers to the root data value, but is + // rebound by nested selection sets to refer to the root value the + // selection set was applied to. + vars_with_paths.insert(KnownVariable::Dollar, (data, InputPath::empty())); + + let spec = self.spec(); + let (value, apply_errors) = + self.apply_to_path(data, &vars_with_paths, &InputPath::empty(), spec); + + // Since errors is an IndexSet, this line effectively deduplicates the + // errors, in an attempt to make them less verbose. However, now that we + // include both path and range information in the errors, there's an + // argument to be made that errors can no longer be meaningfully + // deduplicated, so we might consider sticking with a Vec. + errors.extend(apply_errors); + + (value, errors.into_iter().collect()) + } + + pub fn shape(&self) -> Shape { + let context = + ShapeContext::new(SourceId::Other("JSONSelection".into())).with_spec(self.spec()); + + self.compute_output_shape( + // Relatively static/unchanging inputs to compute_output_shape, + // passed down by immutable shared reference. + &context, + // If we don't know anything about the shape of the input data, we + // can represent the data symbolically using the $root variable + // shape. Subproperties needed from this shape will show up as + // subpaths like $root.books.4.isbn in the output shape. + // + // While we do not currently have a $root variable available as a + // KnownVariable during apply_to_path execution, we might consider + // adding it, since it would align with the way we process other + // variable shapes. For now, $root exists only as a shape name that + // we are inventing right here. + Shape::name("$root", Vec::new()), + ) + } + + pub(crate) fn compute_output_shape(&self, context: &ShapeContext, input_shape: Shape) -> Shape { + debug_assert_eq!(context.spec(), self.spec()); + + let computable: &dyn ApplyToInternal = match &self.inner { + TopLevelSelection::Named(selection) => selection, + TopLevelSelection::Path(path_selection) => path_selection, + }; + + let dollar_shape = input_shape.clone(); + + if Some(&input_shape) == context.named_shapes().get("$root") { + // If the $root variable happens to be bound to the input shape, + // context does not need to be cloned or modified. + computable.compute_output_shape(context, input_shape, dollar_shape) + } else { + // Otherwise, we'll want to register the input_shape as $root in a + // cloned_context, so $root is reliably defined either way. + let cloned_context = context + .clone() + .with_named_shapes([("$root".to_string(), input_shape.clone())]); + computable.compute_output_shape(&cloned_context, input_shape, dollar_shape) + } + } +} + +impl Ranged for JSONSelection { + fn range(&self) -> OffsetRange { + match &self.inner { + TopLevelSelection::Named(selection) => selection.range(), + TopLevelSelection::Path(path_selection) => path_selection.range(), + } + } + + fn shape_location(&self, source_id: &SourceId) -> Option { + self.range().map(|range| source_id.location(range)) + } +} + +pub(super) trait ApplyToInternal { + // This is the trait method that should be implemented and called + // recursively by the various JSONSelection types. + fn apply_to_path( + &self, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, + ) -> (Option, Vec); + + // When array is encountered, the Self selection will be applied to each + // element of the array, producing a new array. + fn apply_to_array( + &self, + data_array: &[JSON], + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, + ) -> (Option, Vec) { + let mut output = Vec::with_capacity(data_array.len()); + let mut errors = Vec::new(); + + for (i, element) in data_array.iter().enumerate() { + let input_path_with_index = input_path.append(json!(i)); + let (applied, apply_errors) = + self.apply_to_path(element, vars, &input_path_with_index, spec); + errors.extend(apply_errors); + // When building an Object, we can simply omit missing properties + // and report an error, but when building an Array, we need to + // insert null values to preserve the original array indices/length. + output.push(applied.unwrap_or(JSON::Null)); + } + + (Some(JSON::Array(output)), errors) + } + + /// Computes the static output shape produced by a JSONSelection, by + /// traversing the selection AST, recursively calling `compute_output_shape` + /// on the current data/variable shapes at each level. + fn compute_output_shape( + &self, + context: &ShapeContext, + // Shape of the `@` variable, which typically changes with each + // recursive call to compute_output_shape. + input_shape: Shape, + // Shape of the `$` variable, which is bound to the closest enclosing + // subselection object, or the root data object if there is no enclosing + // subselection. + dollar_shape: Shape, + ) -> Shape; +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) struct ShapeContext { + /// [`ConnectSpec`] version derived from the [`JSONSelection`] that created + /// this [`ShapeContext`]. + #[allow(dead_code)] + spec: ConnectSpec, + + /// Shapes of other named variables, with the variable name `String` + /// including the initial `$` character. This map typically does not change + /// during the compute_output_shape recursion, and so can be passed down by + /// immutable reference. + named_shapes: IndexMap, + + /// A shared source name to use for all locations originating from this + /// `JSONSelection`. + source_id: SourceId, +} + +impl ShapeContext { + pub(crate) fn new(source_id: SourceId) -> Self { + Self { + spec: JSONSelection::default_connect_spec(), + named_shapes: IndexMap::default(), + source_id, + } + } + + #[allow(dead_code)] + pub(crate) fn spec(&self) -> ConnectSpec { + self.spec + } + + pub(crate) fn with_spec(mut self, spec: ConnectSpec) -> Self { + self.spec = spec; + self + } + + pub(crate) fn named_shapes(&self) -> &IndexMap { + &self.named_shapes + } + + pub(crate) fn with_named_shapes( + mut self, + named_shapes: impl IntoIterator, + ) -> Self { + for (name, shape) in named_shapes { + self.named_shapes.insert(name.clone(), shape.clone()); + } + self + } + + pub(crate) fn source_id(&self) -> &SourceId { + &self.source_id + } +} + +#[derive(Debug, Eq, PartialEq, Clone, Hash)] +pub struct ApplyToError { + message: String, + path: Vec, + range: OffsetRange, + spec: ConnectSpec, +} + +impl ApplyToError { + pub(crate) const fn new( + message: String, + path: Vec, + range: OffsetRange, + spec: ConnectSpec, + ) -> Self { + Self { + message, + path, + range, + spec, + } + } + + // This macro is useful for tests, but it absolutely should never be used with + // dynamic input at runtime, since it panics for any input that's not JSON. + #[cfg(test)] + pub(crate) fn from_json(json: &JSON) -> Self { + use crate::link::spec::Version; + + let error = json.as_object().unwrap(); + let message = error.get("message").unwrap().as_str().unwrap().to_string(); + let path = error.get("path").unwrap().as_array().unwrap().clone(); + let range = error.get("range").unwrap().as_array().unwrap(); + let spec = error + .get("spec") + .and_then(|s| s.as_str()) + .and_then(|s| match s.parse::() { + Ok(version) => ConnectSpec::try_from(&version).ok(), + Err(_) => None, + }) + .unwrap_or_else(ConnectSpec::latest); + + Self { + message, + path, + range: if range.len() == 2 { + let start = range[0].as_u64().unwrap() as usize; + let end = range[1].as_u64().unwrap() as usize; + Some(start..end) + } else { + None + }, + spec, + } + } + + pub fn message(&self) -> &str { + self.message.as_str() + } + + pub fn path(&self) -> &[JSON] { + self.path.as_slice() + } + + pub fn range(&self) -> OffsetRange { + self.range.clone() + } + + pub fn spec(&self) -> ConnectSpec { + self.spec + } +} + +// Rust doesn't allow implementing methods directly on tuples like +// (Option, Vec), so we define a trait to provide the +// methods we need, and implement the trait for the tuple in question. +pub(super) trait ApplyToResultMethods { + fn prepend_errors(self, errors: Vec) -> Self; + + fn and_then_collecting_errors( + self, + f: impl FnOnce(&JSON) -> (Option, Vec), + ) -> (Option, Vec); +} + +impl ApplyToResultMethods for (Option, Vec) { + // Intentionally taking ownership of self to avoid cloning, since we pretty + // much always use this method to replace the previous (value, errors) tuple + // before returning. + fn prepend_errors(self, mut errors: Vec) -> Self { + if errors.is_empty() { + self + } else { + let (value_opt, apply_errors) = self; + errors.extend(apply_errors); + (value_opt, errors) + } + } + + // A substitute for Option<_>::and_then that accumulates errors behind the + // scenes. I'm no Haskell programmer, but this feels monadic? ¯\_(ツ)_/¯ + fn and_then_collecting_errors( + self, + f: impl FnOnce(&JSON) -> (Option, Vec), + ) -> (Option, Vec) { + match self { + (Some(data), errors) => f(&data).prepend_errors(errors), + (None, errors) => (None, errors), + } + } +} + +impl ApplyToInternal for JSONSelection { + fn apply_to_path( + &self, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + _spec: ConnectSpec, + ) -> (Option, Vec) { + match &self.inner { + // Because we represent a JSONSelection::Named as a SubSelection, we + // can fully delegate apply_to_path to SubSelection::apply_to_path. + // Even if we represented Self::Named as a Vec, we + // could still delegate to SubSelection::apply_to_path, but we would + // need to create a temporary SubSelection to wrap the selections + // Vec. + TopLevelSelection::Named(named_selections) => { + named_selections.apply_to_path(data, vars, input_path, self.spec) + } + TopLevelSelection::Path(path_selection) => { + path_selection.apply_to_path(data, vars, input_path, self.spec) + } + } + } + + fn compute_output_shape( + &self, + context: &ShapeContext, + input_shape: Shape, + dollar_shape: Shape, + ) -> Shape { + debug_assert_eq!(context.spec(), self.spec()); + + match &self.inner { + TopLevelSelection::Named(selection) => { + selection.compute_output_shape(context, input_shape, dollar_shape) + } + TopLevelSelection::Path(path_selection) => { + path_selection.compute_output_shape(context, input_shape, dollar_shape) + } + } + } +} + +impl ApplyToInternal for NamedSelection { + fn apply_to_path( + &self, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, + ) -> (Option, Vec) { + let mut output: Option = None; + let mut errors = Vec::new(); + + let (value_opt, apply_errors) = self.path.apply_to_path(data, vars, input_path, spec); + errors.extend(apply_errors); + + match &self.prefix { + NamingPrefix::Alias(alias) => { + if let Some(value) = value_opt { + output = Some(json!({ alias.name.as_str(): value })); + } + } + + NamingPrefix::Spread(_spread_range) => { + match value_opt { + Some(JSON::Object(_) | JSON::Null) => { + // Objects and null are valid outputs for an + // inline/spread NamedSelection. + output = value_opt; + } + Some(value) => { + errors.push(ApplyToError::new( + format!("Expected object or null, not {}", json_type_name(&value)), + input_path.to_vec(), + self.path.range(), + spec, + )); + } + None => { + errors.push(ApplyToError::new( + "Inlined path produced no value".to_string(), + input_path.to_vec(), + self.path.range(), + spec, + )); + } + }; + } + + NamingPrefix::None => { + // Since there is no prefix (NamingPrefix::None), value_opt is + // usable as the output of NamedSelection::apply_to_path only if + // the NamedSelection has an implied single key, or by having a + // trailing SubSelection that guarantees object/null output. + if let Some(single_key) = self.path.get_single_key() { + if let Some(value) = value_opt { + output = Some(json!({ single_key.as_str(): value })); + } + } else { + output = value_opt; + } + } + } + + (output, errors) + } + + fn compute_output_shape( + &self, + context: &ShapeContext, + input_shape: Shape, + dollar_shape: Shape, + ) -> Shape { + let path_shape = self + .path + .compute_output_shape(context, input_shape, dollar_shape); + + if let Some(single_output_key) = self.get_single_key() { + let mut map = Shape::empty_map(); + map.insert(single_output_key.as_string(), path_shape); + Shape::record(map, self.shape_location(context.source_id())) + } else { + path_shape + } + } +} + +impl ApplyToInternal for PathSelection { + fn apply_to_path( + &self, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, + ) -> (Option, Vec) { + match (self.path.as_ref(), vars.get(&KnownVariable::Dollar)) { + // If this is a KeyPath, instead of using data as given, we need to + // evaluate the path starting from the current value of $. To evaluate + // the KeyPath against data, prefix it with @. This logic supports + // method chaining like obj->has('a')->and(obj->has('b')), where both + // obj references are interpreted as $.obj. + (PathList::Key(_, _), Some((dollar_data, dollar_path))) => { + self.path + .apply_to_path(dollar_data, vars, dollar_path, spec) + } + + // If $ is undefined for some reason, fall back to using data... + // TODO: Since $ should never be undefined, we might want to + // guarantee its existence at compile time, somehow. + // (PathList::Key(_, _), None) => todo!(), + _ => self.path.apply_to_path(data, vars, input_path, spec), + } + } + + fn compute_output_shape( + &self, + context: &ShapeContext, + input_shape: Shape, + dollar_shape: Shape, + ) -> Shape { + match self.path.as_ref() { + PathList::Key(_, _) => { + // If this is a KeyPath, we need to evaluate the path starting + // from the current $ shape, so we pass dollar_shape as the data + // *and* dollar_shape to self.path.compute_output_shape. + self.path + .compute_output_shape(context, dollar_shape.clone(), dollar_shape) + } + // If this is not a KeyPath, keep evaluating against input_shape. + // This logic parallels PathSelection::apply_to_path (above). + _ => self + .path + .compute_output_shape(context, input_shape, dollar_shape), + } + } +} + +impl ApplyToInternal for WithRange { + fn apply_to_path( + &self, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, + ) -> (Option, Vec) { + match self.as_ref() { + PathList::Var(ranged_var_name, tail) => { + let var_name = ranged_var_name.as_ref(); + if var_name == &KnownVariable::AtSign { + // We represent @ as a variable name in PathList::Var, but + // it is never stored in the vars map, because it is always + // shorthand for the current data value. + tail.apply_to_path(data, vars, input_path, spec) + } else if let Some((var_data, var_path)) = vars.get(var_name) { + // Variables are associated with a path, which is always + // just the variable name for named $variables other than $. + // For the special variable $, the path represents the + // sequence of keys from the root input data to the $ data. + tail.apply_to_path(var_data, vars, var_path, spec) + } else { + ( + None, + vec![ApplyToError::new( + format!("Variable {} not found", var_name.as_str()), + input_path.to_vec(), + ranged_var_name.range(), + spec, + )], + ) + } + } + PathList::Key(key, tail) => { + let input_path_with_key = input_path.append(key.to_json()); + + if let JSON::Array(array) = data { + // If we recursively call self.apply_to_array, it will end + // up invoking the tail of the key recursively, whereas we + // want to apply the tail once to the entire output array of + // shallow key lookups. To keep the recursion shallow, we + // need a version of self that has the same key but no tail. + let empty_tail = WithRange::new(PathList::Empty, tail.range()); + let self_with_empty_tail = + WithRange::new(PathList::Key(key.clone(), empty_tail), key.range()); + + self_with_empty_tail + .apply_to_array(array, vars, input_path, spec) + .and_then_collecting_errors(|shallow_mapped_array| { + // This tail.apply_to_path call happens only once, + // passing to the original/top-level tail the entire + // array produced by key-related recursion/mapping. + tail.apply_to_path( + shallow_mapped_array, + vars, + &input_path_with_key, + spec, + ) + }) + } else { + let not_found = || { + ( + None, + vec![ApplyToError::new( + format!( + "Property {} not found in {}", + key.dotted(), + json_type_name(data), + ), + input_path_with_key.to_vec(), + key.range(), + spec, + )], + ) + }; + + if !matches!(data, JSON::Object(_)) { + return not_found(); + } + + if let Some(child) = data.get(key.as_str()) { + tail.apply_to_path(child, vars, &input_path_with_key, spec) + } else if tail.is_question() { + (None, vec![]) + } else { + not_found() + } + } + } + PathList::Expr(expr, tail) => expr + .apply_to_path(data, vars, input_path, spec) + .and_then_collecting_errors(|value| { + tail.apply_to_path(value, vars, input_path, spec) + }), + PathList::Method(method_name, method_args, tail) => { + let method_path = + input_path.append(JSON::String(format!("->{}", method_name.as_ref()).into())); + + ArrowMethod::lookup(method_name).map_or_else( + || { + ( + None, + vec![ApplyToError::new( + format!("Method ->{} not found", method_name.as_ref()), + method_path.to_vec(), + method_name.range(), + spec, + )], + ) + }, + |method| { + let (result_opt, errors) = method.apply( + method_name, + method_args.as_ref(), + data, + vars, + &method_path, + spec, + ); + + if let Some(result) = result_opt { + tail.apply_to_path(&result, vars, &method_path, spec) + .prepend_errors(errors) + } else { + // If the method produced no output, assume the errors + // explain the None. Methods can legitimately produce + // None without errors (like ->first or ->last on an + // empty array), so we do not report any blanket error + // here when errors.is_empty(). + (None, errors) + } + }, + ) + } + PathList::Selection(selection) => selection.apply_to_path(data, vars, input_path, spec), + PathList::Question(tail) => { + // Universal null check for any operation after ? + if data.is_null() { + (None, vec![]) + } else { + tail.apply_to_path(data, vars, input_path, spec) + } + } + PathList::Empty => { + // If data is not an object here, we want to preserve its value + // without an error. + (Some(data.clone()), vec![]) + } + } + } + + fn compute_output_shape( + &self, + context: &ShapeContext, + input_shape: Shape, + dollar_shape: Shape, + ) -> Shape { + if input_shape.is_none() { + // If the previous path prefix evaluated to None, path evaluation + // must terminate because there is no JSON value to pass as the + // input_shape to the rest of the path, so the output shape of the + // whole path must be None. Any errors that might explain an + // unexpected None value should already have been reported as + // Shape::error_with_partial errors at a higher level. + return input_shape; + } + + match input_shape.case() { + ShapeCase::One(shapes) => { + return Shape::one( + shapes.iter().map(|shape| { + self.compute_output_shape(context, shape.clone(), dollar_shape.clone()) + }), + input_shape.locations.iter().cloned(), + ); + } + ShapeCase::All(shapes) => { + return Shape::all( + shapes.iter().map(|shape| { + self.compute_output_shape(context, shape.clone(), dollar_shape.clone()) + }), + input_shape.locations.iter().cloned(), + ); + } + ShapeCase::Error(error) => { + return match error.partial.as_ref() { + Some(partial) => Shape::error_with_partial( + error.message.clone(), + self.compute_output_shape(context, partial.clone(), dollar_shape), + input_shape.locations.iter().cloned(), + ), + None => input_shape.clone(), + }; + } + _ => {} + }; + + // Given the base cases above, we can assume below that input_shape is + // neither ::One, ::All, nor ::Error. + + let (current_shape, tail_opt) = match self.as_ref() { + PathList::Var(ranged_var_name, tail) => { + let var_name = ranged_var_name.as_ref(); + let var_shape = if var_name == &KnownVariable::AtSign { + input_shape + } else if var_name == &KnownVariable::Dollar { + dollar_shape.clone() + } else if let Some(shape) = context.named_shapes().get(var_name.as_str()) { + shape.clone() + } else { + Shape::name( + var_name.as_str(), + ranged_var_name.shape_location(context.source_id()), + ) + }; + (var_shape, Some(tail)) + } + + // For the first key in a path, PathSelection::compute_output_shape + // will have set our input_shape equal to its dollar_shape, thereby + // ensuring that some.nested.path is equivalent to + // $.some.nested.path. + PathList::Key(key, tail) => { + let child_shape = field(&input_shape, key, context.source_id()); + + // Here input_shape was not None, but input_shape.field(key) was + // None, so it's the responsibility of this PathList::Key node + // to report the missing property error. Elsewhere None may + // terminate path evaluation, but it does not necessarily + // trigger a Shape::error. Here, the shape system is telling us + // the key will never be found, so an error is warranted. + // + // In the future, we might allow tail to be a PathList::Question + // supporting optional ? chaining syntax, which would be a way + // of silencing this error when the key's absence is acceptable. + if child_shape.is_none() { + return Shape::error( + format!( + "Property {} not found in {}", + key.dotted(), + input_shape.pretty_print() + ), + key.shape_location(context.source_id()), + ); + } + + (child_shape, Some(tail)) + } + + PathList::Expr(expr, tail) => ( + expr.compute_output_shape(context, input_shape, dollar_shape.clone()), + Some(tail), + ), + + PathList::Method(method_name, method_args, tail) => { + if let Some(method) = ArrowMethod::lookup(method_name) { + // Before connect/v0.3, we did not consult method.shape at + // all, and instead returned Unknown. Since this behavior + // has consequences for URI validation, the older behavior + // is preserved/retrievable given ConnectSpec::V0_2/earlier. + if context.spec() < ConnectSpec::V0_3 { + ( + Shape::unknown(method_name.shape_location(context.source_id())), + None, + ) + } else { + ( + method.shape( + context, + method_name, + method_args.as_ref(), + input_shape, + dollar_shape.clone(), + ), + Some(tail), + ) + } + } else { + ( + Shape::error( + format!("Method ->{} not found", method_name.as_str()), + method_name.shape_location(context.source_id()), + ), + None, + ) + } + } + + PathList::Question(tail) => { + // Optional operation always produces nullable output + let result_shape = + tail.compute_output_shape(context, input_shape, dollar_shape.clone()); + // Make result nullable since optional chaining can produce null + ( + Shape::one( + [ + result_shape, + Shape::none().with_locations(self.shape_location(context.source_id())), + ], + self.shape_location(context.source_id()), + ), + None, + ) + } + + PathList::Selection(selection) => ( + selection.compute_output_shape(context, input_shape, dollar_shape.clone()), + None, + ), + + PathList::Empty => (input_shape, None), + }; + + if let Some(tail) = tail_opt { + tail.compute_output_shape(context, current_shape, dollar_shape) + } else { + current_shape + } + } +} + +impl ApplyToInternal for WithRange { + fn apply_to_path( + &self, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, + ) -> (Option, Vec) { + match self.as_ref() { + LitExpr::String(s) => (Some(JSON::String(s.clone().into())), vec![]), + LitExpr::Number(n) => (Some(JSON::Number(n.clone())), vec![]), + LitExpr::Bool(b) => (Some(JSON::Bool(*b)), vec![]), + LitExpr::Null => (Some(JSON::Null), vec![]), + LitExpr::Object(map) => { + let mut output = JSONMap::with_capacity(map.len()); + let mut errors = Vec::new(); + for (key, value) in map { + let (value_opt, apply_errors) = + value.apply_to_path(data, vars, input_path, spec); + errors.extend(apply_errors); + if let Some(value_json) = value_opt { + output.insert(key.as_str(), value_json); + } + } + (Some(JSON::Object(output)), errors) + } + LitExpr::Array(vec) => { + let mut output = Vec::with_capacity(vec.len()); + let mut errors = Vec::new(); + for value in vec { + let (value_opt, apply_errors) = + value.apply_to_path(data, vars, input_path, spec); + errors.extend(apply_errors); + output.push(value_opt.unwrap_or(JSON::Null)); + } + (Some(JSON::Array(output)), errors) + } + LitExpr::Path(path) => path.apply_to_path(data, vars, input_path, spec), + LitExpr::LitPath(literal, subpath) => literal + .apply_to_path(data, vars, input_path, spec) + .and_then_collecting_errors(|value| { + subpath.apply_to_path(value, vars, input_path, spec) + }), + LitExpr::OpChain(op, operands) => { + match op.as_ref() { + LitOp::NullishCoalescing => { + // Null coalescing: A ?? B ?? C + // Returns B if A is null OR None, otherwise A. If B is also null/None, returns C, etc. + let mut accumulated_errors = Vec::new(); + let mut last_value: Option = None; + + for operand in operands { + let (value, errors) = + operand.apply_to_path(data, vars, input_path, spec); + + match value { + // If we get a non-null, non-None value, return it + Some(JSON::Null) | None => { + // Accumulate errors but continue to next operand + accumulated_errors.extend(errors); + last_value = value; + continue; + } + Some(value) => { + // Found a non-null/non-None value, return it (ignoring accumulated errors) + return (Some(value), errors); + } + } + } + + // If the last value was Some(JSON::Null), we return + // that null, since there is no ?? after it. Otherwise, + // last_value will be None at this point, because we + // return Some(value) above as soon as we find a + // non-null/non-None value. + if last_value.is_none() { + // If we never found a non-null value, return None + // with all accumulated errors. + (None, accumulated_errors) + } else { + // If the last operand evaluated to null (or + // anything else except None), that counts as a + // successful evaluation, so we do not return any + // earlier accumulated_errors. + (last_value, Vec::new()) + } + } + + LitOp::NoneCoalescing => { + // None coalescing: A ?! B ?! C + // Returns B if A is None (preserves null), otherwise A. If B is also None, returns C, etc. + let mut accumulated_errors = Vec::new(); + + for operand in operands { + let (value, errors) = + operand.apply_to_path(data, vars, input_path, spec); + + match value { + // If we get None, continue to next operand + None => { + accumulated_errors.extend(errors); + continue; + } + // If we get any value (including null), return it + Some(value) => { + return (Some(value), errors); + } + } + } + + // All operands were None, return None with all accumulated errors + (None, accumulated_errors) + } + } + } + } + } + + fn compute_output_shape( + &self, + context: &ShapeContext, + input_shape: Shape, + dollar_shape: Shape, + ) -> Shape { + let locations = self.shape_location(context.source_id()); + + match self.as_ref() { + LitExpr::Null => Shape::null(locations), + LitExpr::Bool(value) => Shape::bool_value(*value, locations), + LitExpr::String(value) => Shape::string_value(value.as_str(), locations), + + LitExpr::Number(value) => { + if let Some(n) = value.as_i64() { + Shape::int_value(n, locations) + } else if value.is_f64() { + Shape::float(locations) + } else { + Shape::error("Number neither Int nor Float", locations) + } + } + + LitExpr::Object(map) => { + let mut fields = Shape::empty_map(); + for (key, value) in map { + fields.insert( + key.as_string(), + value.compute_output_shape( + context, + input_shape.clone(), + dollar_shape.clone(), + ), + ); + } + Shape::object(fields, Shape::none(), locations) + } + + LitExpr::Array(vec) => { + let mut shapes = Vec::with_capacity(vec.len()); + for value in vec { + shapes.push(value.compute_output_shape( + context, + input_shape.clone(), + dollar_shape.clone(), + )); + } + Shape::array(shapes, Shape::none(), locations) + } + + LitExpr::Path(path) => path.compute_output_shape(context, input_shape, dollar_shape), + + LitExpr::LitPath(literal, subpath) => { + let literal_shape = + literal.compute_output_shape(context, input_shape, dollar_shape.clone()); + subpath.compute_output_shape(context, literal_shape, dollar_shape) + } + + LitExpr::OpChain(op, operands) => { + match op.as_ref() { + LitOp::NullishCoalescing | LitOp::NoneCoalescing => { + let shapes: Vec = operands + .iter() + .map(|operand| { + operand.compute_output_shape( + context, + input_shape.clone(), + dollar_shape.clone(), + ) + }) + .collect(); + + // Create a union of all possible shapes + Shape::one(shapes, locations) + } + } + } + } + } +} + +impl ApplyToInternal for SubSelection { + fn apply_to_path( + &self, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, + ) -> (Option, Vec) { + if let JSON::Array(array) = data { + return self.apply_to_array(array, vars, input_path, spec); + } + + let vars: VarsWithPathsMap = { + let mut vars = vars.clone(); + vars.insert(KnownVariable::Dollar, (data, input_path.clone())); + vars + }; + + let mut output = JSON::Object(JSONMap::new()); + let mut errors = Vec::new(); + + for named_selection in self.selections.iter() { + let (named_output_opt, apply_errors) = + named_selection.apply_to_path(data, &vars, input_path, spec); + errors.extend(apply_errors); + + let (merged, merge_errors) = json_merge(Some(&output), named_output_opt.as_ref()); + + errors.extend(merge_errors.into_iter().map(|message| { + ApplyToError::new(message, input_path.to_vec(), self.range(), spec) + })); + + if let Some(merged) = merged { + output = merged; + } + } + + if !matches!(data, JSON::Object(_)) { + let output_is_empty = match &output { + JSON::Object(map) => map.is_empty(), + _ => false, + }; + if output_is_empty { + // If data was a primitive value (neither array nor object), and + // no output properties were generated, return data as is, along + // with any errors that occurred. + return (Some(data.clone()), errors); + } + } + + (Some(output), errors) + } + + fn compute_output_shape( + &self, + context: &ShapeContext, + input_shape: Shape, + _previous_dollar_shape: Shape, + ) -> Shape { + // Just as SubSelection::apply_to_path calls apply_to_array when data is + // an array, so compute_output_shape recursively computes the output + // shapes of each array element shape. + if let ShapeCase::Array { prefix, tail } = input_shape.case() { + let new_prefix = prefix + .iter() + .map(|shape| self.compute_output_shape(context, shape.clone(), shape.clone())) + .collect::>(); + + let new_tail = if tail.is_none() { + tail.clone() + } else { + self.compute_output_shape(context, tail.clone(), tail.clone()) + }; + + return Shape::array( + new_prefix, + new_tail, + self.shape_location(context.source_id()), + ); + } + + // If the input shape is a named shape, it might end up being an array, + // so we need to hedge the output shape using a wildcard that maps over + // array elements. + let input_shape = input_shape.any_item(Vec::new()); + + // The SubSelection rebinds the $ variable to the selected input object, + // so we can ignore _previous_dollar_shape. + let dollar_shape = input_shape.clone(); + + // Build up the merged object shape using Shape::all to merge the + // individual named_selection object shapes. + let mut all_shape = Shape::none(); + + for named_selection in self.selections.iter() { + // Simplifying as we go with Shape::all keeps all_shape relatively + // small in the common case when all named_selection items return an + // object shape, since those object shapes can all be merged + // together into one object. + all_shape = Shape::all( + [ + all_shape, + named_selection.compute_output_shape( + context, + input_shape.clone(), + dollar_shape.clone(), + ), + ], + self.shape_location(context.source_id()), + ); + + // If any named_selection item returns null instead of an object, + // that nullifies the whole object and allows shape computation to + // bail out early. + if all_shape.is_null() { + break; + } + } + + if all_shape.is_none() { + Shape::empty_object(self.shape_location(context.source_id())) + } else { + all_shape + } + } +} + +/// Helper to get the field from a shape or error if the object doesn't have that field. +fn field(shape: &Shape, key: &WithRange, source_id: &SourceId) -> Shape { + if let ShapeCase::One(inner) = shape.case() { + let mut new_fields = Vec::new(); + for inner_field in inner { + new_fields.push(field(inner_field, key, source_id)); + } + return Shape::one(new_fields, shape.locations.iter().cloned()); + } + if shape.is_none() || shape.is_null() { + return Shape::none(); + } + let field_shape = shape.field(key.as_str(), key.shape_location(source_id)); + if field_shape.is_none() { + return Shape::error( + format!("field `{field}` not found", field = key.as_str()), + key.shape_location(source_id), + ); + } + field_shape +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::assert_debug_snapshot; + use crate::connectors::json_selection::PrettyPrintable; + use crate::selection; + + #[rstest] + #[case::v0_2(ConnectSpec::V0_2)] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_apply_to_selection(#[case] spec: ConnectSpec) { + let data = json!({ + "hello": "world", + "nested": { + "hello": "world", + "world": "hello", + }, + "array": [ + { "hello": "world 0" }, + { "hello": "world 1" }, + { "hello": "world 2" }, + ], + }); + + #[track_caller] + fn check_ok(data: &JSON, selection: JSONSelection, expected_json: JSON) { + let (actual_json, errors) = selection.apply_to(data); + assert_eq!(actual_json, Some(expected_json)); + assert_eq!(errors, vec![]); + } + + check_ok(&data, selection!("hello", spec), json!({"hello": "world"})); + + check_ok( + &data, + selection!("nested", spec), + json!({ + "nested": { + "hello": "world", + "world": "hello", + }, + }), + ); + + check_ok(&data, selection!("nested.hello", spec), json!("world")); + check_ok(&data, selection!("$.nested.hello", spec), json!("world")); + + check_ok(&data, selection!("nested.world", spec), json!("hello")); + check_ok(&data, selection!("$.nested.world", spec), json!("hello")); + + check_ok( + &data, + selection!("nested hello", spec), + json!({ + "hello": "world", + "nested": { + "hello": "world", + "world": "hello", + }, + }), + ); + + check_ok( + &data, + selection!("array { hello }", spec), + json!({ + "array": [ + { "hello": "world 0" }, + { "hello": "world 1" }, + { "hello": "world 2" }, + ], + }), + ); + + check_ok( + &data, + selection!("greetings: array { hello }", spec), + json!({ + "greetings": [ + { "hello": "world 0" }, + { "hello": "world 1" }, + { "hello": "world 2" }, + ], + }), + ); + + check_ok( + &data, + selection!("$.array { hello }", spec), + json!([ + { "hello": "world 0" }, + { "hello": "world 1" }, + { "hello": "world 2" }, + ]), + ); + + check_ok( + &data, + selection!("worlds: array.hello", spec), + json!({ + "worlds": [ + "world 0", + "world 1", + "world 2", + ], + }), + ); + + check_ok( + &data, + selection!("worlds: $.array.hello", spec), + json!({ + "worlds": [ + "world 0", + "world 1", + "world 2", + ], + }), + ); + + check_ok( + &data, + selection!("array.hello", spec), + json!(["world 0", "world 1", "world 2",]), + ); + + check_ok( + &data, + selection!("$.array.hello", spec), + json!(["world 0", "world 1", "world 2",]), + ); + + check_ok( + &data, + selection!("nested grouped: { hello worlds: array.hello }", spec), + json!({ + "nested": { + "hello": "world", + "world": "hello", + }, + "grouped": { + "hello": "world", + "worlds": [ + "world 0", + "world 1", + "world 2", + ], + }, + }), + ); + + check_ok( + &data, + selection!("nested grouped: { hello worlds: $.array.hello }", spec), + json!({ + "nested": { + "hello": "world", + "world": "hello", + }, + "grouped": { + "hello": "world", + "worlds": [ + "world 0", + "world 1", + "world 2", + ], + }, + }), + ); + } + + #[rstest] + #[case::v0_2(ConnectSpec::V0_2)] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_apply_to_errors(#[case] spec: ConnectSpec) { + let data = json!({ + "hello": "world", + "nested": { + "hello": 123, + "world": true, + }, + "array": [ + { "hello": 1, "goodbye": "farewell" }, + { "hello": "two" }, + { "hello": 3.0, "smello": "yellow" }, + ], + }); + + assert_eq!( + selection!("hello", spec).apply_to(&data), + (Some(json!({"hello": "world"})), vec![],) + ); + + fn make_yellow_errors_expected( + yellow_range: std::ops::Range, + spec: ConnectSpec, + ) -> Vec { + vec![ApplyToError::new( + "Property .yellow not found in object".to_string(), + vec![json!("yellow")], + Some(yellow_range), + spec, + )] + } + assert_eq!( + selection!("yellow", spec).apply_to(&data), + (Some(json!({})), make_yellow_errors_expected(0..6, spec)), + ); + assert_eq!( + selection!("$.yellow", spec).apply_to(&data), + (None, make_yellow_errors_expected(2..8, spec)), + ); + + assert_eq!( + selection!("nested.hello", spec).apply_to(&data), + (Some(json!(123)), vec![],) + ); + + fn make_quoted_yellow_expected( + yellow_range: std::ops::Range, + spec: ConnectSpec, + ) -> (Option, Vec) { + ( + None, + vec![ApplyToError::new( + "Property .\"yellow\" not found in object".to_string(), + vec![json!("nested"), json!("yellow")], + Some(yellow_range), + spec, + )], + ) + } + assert_eq!( + selection!("nested.'yellow'", spec).apply_to(&data), + make_quoted_yellow_expected(7..15, spec), + ); + assert_eq!( + selection!("nested.\"yellow\"", spec).apply_to(&data), + make_quoted_yellow_expected(7..15, spec), + ); + assert_eq!( + selection!("$.nested.'yellow'", spec).apply_to(&data), + make_quoted_yellow_expected(9..17, spec), + ); + + fn make_nested_path_expected( + hola_range: (usize, usize), + yellow_range: (usize, usize), + spec: ConnectSpec, + ) -> (Option, Vec) { + ( + Some(json!({ + "world": true, + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Property .hola not found in object", + "path": ["nested", "hola"], + "range": hola_range, + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .yellow not found in object", + "path": ["nested", "yellow"], + "range": yellow_range, + "spec": spec.to_string(), + })), + ], + ) + } + assert_eq!( + selection!("$.nested { hola yellow world }", spec).apply_to(&data), + make_nested_path_expected((11, 15), (16, 22), spec), + ); + assert_eq!( + selection!(" $ . nested { hola yellow world } ", spec).apply_to(&data), + make_nested_path_expected((14, 18), (19, 25), spec), + ); + + fn make_partial_array_expected( + goodbye_range: (usize, usize), + spec: ConnectSpec, + ) -> (Option, Vec) { + ( + Some(json!({ + "partial": [ + { "hello": 1, "goodbye": "farewell" }, + { "hello": "two" }, + { "hello": 3.0 }, + ], + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Property .goodbye not found in object", + "path": ["array", 1, "goodbye"], + "range": goodbye_range, + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .goodbye not found in object", + "path": ["array", 2, "goodbye"], + "range": goodbye_range, + "spec": spec.to_string(), + })), + ], + ) + } + assert_eq!( + selection!("partial: $.array { hello goodbye }", spec).apply_to(&data), + make_partial_array_expected((25, 32), spec), + ); + assert_eq!( + selection!(" partial : $ . array { hello goodbye } ", spec).apply_to(&data), + make_partial_array_expected((29, 36), spec), + ); + + assert_eq!( + selection!("good: array.hello bad: array.smello", spec).apply_to(&data), + ( + Some(json!({ + "good": [ + 1, + "two", + 3.0, + ], + "bad": [ + null, + null, + "yellow", + ], + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Property .smello not found in object", + "path": ["array", 0, "smello"], + "range": [29, 35], + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .smello not found in object", + "path": ["array", 1, "smello"], + "range": [29, 35], + "spec": spec.to_string(), + })), + ], + ) + ); + + assert_eq!( + selection!("array { hello smello }", spec).apply_to(&data), + ( + Some(json!({ + "array": [ + { "hello": 1 }, + { "hello": "two" }, + { "hello": 3.0, "smello": "yellow" }, + ], + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Property .smello not found in object", + "path": ["array", 0, "smello"], + "range": [14, 20], + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .smello not found in object", + "path": ["array", 1, "smello"], + "range": [14, 20], + "spec": spec.to_string(), + })), + ], + ) + ); + + assert_eq!( + selection!("$.nested { grouped: { hello smelly world } }", spec).apply_to(&data), + ( + Some(json!({ + "grouped": { + "hello": 123, + "world": true, + }, + })), + vec![ApplyToError::from_json(&json!({ + "message": "Property .smelly not found in object", + "path": ["nested", "smelly"], + "range": [28, 34], + "spec": spec.to_string(), + }))], + ) + ); + + assert_eq!( + selection!("alias: $.nested { grouped: { hello smelly world } }", spec).apply_to(&data), + ( + Some(json!({ + "alias": { + "grouped": { + "hello": 123, + "world": true, + }, + }, + })), + vec![ApplyToError::from_json(&json!({ + "message": "Property .smelly not found in object", + "path": ["nested", "smelly"], + "range": [35, 41], + "spec": spec.to_string(), + }))], + ) + ); + } + + #[rstest] + #[case::v0_2(ConnectSpec::V0_2)] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_apply_to_nested_arrays(#[case] spec: ConnectSpec) { + let data = json!({ + "arrayOfArrays": [ + [ + { "x": 0, "y": 0 }, + ], + [ + { "x": 1, "y": 0 }, + { "x": 1, "y": 1 }, + { "x": 1, "y": 2 }, + ], + [ + { "x": 2, "y": 0 }, + { "x": 2, "y": 1 }, + ], + [], + [ + null, + { "x": 4, "y": 1 }, + { "x": 4, "why": 2 }, + null, + { "x": 4, "y": 4 }, + ] + ], + }); + + fn make_array_of_arrays_x_expected( + x_range: (usize, usize), + spec: ConnectSpec, + ) -> (Option, Vec) { + ( + Some(json!([[0], [1, 1, 1], [2, 2], [], [null, 4, 4, null, 4]])), + vec![ + ApplyToError::from_json(&json!({ + "message": "Property .x not found in null", + "path": ["arrayOfArrays", 4, 0, "x"], + "range": x_range, + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .x not found in null", + "path": ["arrayOfArrays", 4, 3, "x"], + "range": x_range, + "spec": spec.to_string(), + })), + ], + ) + } + assert_eq!( + selection!("arrayOfArrays.x", spec).apply_to(&data), + make_array_of_arrays_x_expected((14, 15), spec), + ); + assert_eq!( + selection!("$.arrayOfArrays.x", spec).apply_to(&data), + make_array_of_arrays_x_expected((16, 17), spec), + ); + + fn make_array_of_arrays_y_expected( + y_range: (usize, usize), + spec: ConnectSpec, + ) -> (Option, Vec) { + ( + Some(json!([ + [0], + [0, 1, 2], + [0, 1], + [], + [null, 1, null, null, 4], + ])), + vec![ + ApplyToError::from_json(&json!({ + "message": "Property .y not found in null", + "path": ["arrayOfArrays", 4, 0, "y"], + "range": y_range, + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .y not found in object", + "path": ["arrayOfArrays", 4, 2, "y"], + "range": y_range, + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .y not found in null", + "path": ["arrayOfArrays", 4, 3, "y"], + "range": y_range, + "spec": spec.to_string(), + })), + ], + ) + } + assert_eq!( + selection!("arrayOfArrays.y", spec).apply_to(&data), + make_array_of_arrays_y_expected((14, 15), spec), + ); + assert_eq!( + selection!("$.arrayOfArrays.y", spec).apply_to(&data), + make_array_of_arrays_y_expected((16, 17), spec), + ); + + assert_eq!( + selection!("alias: arrayOfArrays { x y }", spec).apply_to(&data), + ( + Some(json!({ + "alias": [ + [ + { "x": 0, "y": 0 }, + ], + [ + { "x": 1, "y": 0 }, + { "x": 1, "y": 1 }, + { "x": 1, "y": 2 }, + ], + [ + { "x": 2, "y": 0 }, + { "x": 2, "y": 1 }, + ], + [], + [ + null, + { "x": 4, "y": 1 }, + { "x": 4 }, + null, + { "x": 4, "y": 4 }, + ] + ], + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Property .x not found in null", + "path": ["arrayOfArrays", 4, 0, "x"], + "range": [23, 24], + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .y not found in null", + "path": ["arrayOfArrays", 4, 0, "y"], + "range": [25, 26], + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .y not found in object", + "path": ["arrayOfArrays", 4, 2, "y"], + "range": [25, 26], + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .x not found in null", + "path": ["arrayOfArrays", 4, 3, "x"], + "range": [23, 24], + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .y not found in null", + "path": ["arrayOfArrays", 4, 3, "y"], + "range": [25, 26], + "spec": spec.to_string(), + })), + ], + ), + ); + + fn make_array_of_arrays_x_y_expected( + x_range: (usize, usize), + y_range: (usize, usize), + spec: ConnectSpec, + ) -> (Option, Vec) { + ( + Some(json!({ + "ys": [ + [0], + [0, 1, 2], + [0, 1], + [], + [null, 1, null, null, 4], + ], + "xs": [ + [0], + [1, 1, 1], + [2, 2], + [], + [null, 4, 4, null, 4], + ], + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Property .y not found in null", + "path": ["arrayOfArrays", 4, 0, "y"], + "range": y_range, + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .y not found in object", + "path": ["arrayOfArrays", 4, 2, "y"], + "range": y_range, + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + // Reversing the order of "path" and "message" here to make + // sure that doesn't affect the deduplication logic. + "path": ["arrayOfArrays", 4, 3, "y"], + "message": "Property .y not found in null", + "range": y_range, + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .x not found in null", + "path": ["arrayOfArrays", 4, 0, "x"], + "range": x_range, + "spec": spec.to_string(), + })), + ApplyToError::from_json(&json!({ + "message": "Property .x not found in null", + "path": ["arrayOfArrays", 4, 3, "x"], + "range": x_range, + "spec": spec.to_string(), + })), + ], + ) + } + assert_eq!( + selection!("ys: arrayOfArrays.y xs: arrayOfArrays.x", spec).apply_to(&data), + make_array_of_arrays_x_y_expected((38, 39), (18, 19), spec), + ); + assert_eq!( + selection!("ys: $.arrayOfArrays.y xs: $.arrayOfArrays.x", spec).apply_to(&data), + make_array_of_arrays_x_y_expected((42, 43), (20, 21), spec), + ); + } + + #[rstest] + #[case::v0_2(ConnectSpec::V0_2)] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_apply_to_variable_expressions(#[case] spec: ConnectSpec) { + let id_object = selection!("id: $", spec).apply_to(&json!(123)); + assert_eq!(id_object, (Some(json!({"id": 123})), vec![])); + + let data = json!({ + "id": 123, + "name": "Ben", + "friend_ids": [234, 345, 456] + }); + + assert_eq!( + selection!("id name friends: friend_ids { id: $ }", spec).apply_to(&data), + ( + Some(json!({ + "id": 123, + "name": "Ben", + "friends": [ + { "id": 234 }, + { "id": 345 }, + { "id": 456 }, + ], + })), + vec![], + ), + ); + + let mut vars = IndexMap::default(); + vars.insert("$args".to_string(), json!({ "id": "id from args" })); + assert_eq!( + selection!("id: $args.id name", spec).apply_with_vars(&data, &vars), + ( + Some(json!({ + "id": "id from args", + "name": "Ben" + })), + vec![], + ), + ); + assert_eq!( + selection!("nested.path { id: $args.id name }", spec).apply_to(&json!({ + "nested": { + "path": data, + }, + })), + ( + Some(json!({ + "name": "Ben" + })), + vec![ApplyToError::from_json(&json!({ + "message": "Variable $args not found", + "path": ["nested", "path"], + "range": [18, 23], + "spec": spec.to_string(), + }))], + ), + ); + let mut vars_without_args_id = IndexMap::default(); + vars_without_args_id.insert("$args".to_string(), json!({ "unused": "ignored" })); + assert_eq!( + selection!("id: $args.id name", spec).apply_with_vars(&data, &vars_without_args_id), + ( + Some(json!({ + "name": "Ben" + })), + vec![ApplyToError::from_json(&json!({ + "message": "Property .id not found in object", + "path": ["$args", "id"], + "range": [10, 12], + "spec": spec.to_string(), + }))], + ), + ); + + // A single variable path should not be mapped over an input array. + assert_eq!( + selection!("$args.id", spec).apply_with_vars(&json!([1, 2, 3]), &vars), + (Some(json!("id from args")), vec![]), + ); + } + + #[test] + fn test_apply_to_variable_expressions_typename() { + let typename_object = + selection!("__typename: $->echo('Product') reviews { __typename: $->echo('Review') }") + .apply_to(&json!({"reviews": [{}]})); + assert_eq!( + typename_object, + ( + Some(json!({"__typename": "Product", "reviews": [{ "__typename": "Review" }] })), + vec![] + ) + ); + } + + #[test] + fn test_literal_expressions_in_parentheses() { + assert_eq!( + selection!("__typename: $('Product')").apply_to(&json!({})), + (Some(json!({"__typename": "Product"})), vec![]), + ); + + assert_eq!( + selection!(" __typename : 'Product' ").apply_to(&json!({})), + ( + Some(json!({})), + vec![ApplyToError::new( + "Property .\"Product\" not found in object".to_string(), + vec![json!("Product")], + Some(14..23), + ConnectSpec::latest(), + )], + ), + ); + + assert_eq!( + selection!( + r#" + one: $(1) + two: $(2) + negativeThree: $(- 3) + true: $(true ) + false: $( false) + null: $(null) + string: $("string") + array: $( [ 1 , 2 , 3 ] ) + object: $( { "key" : "value" } ) + path: $(nested.path) + "# + ) + .apply_to(&json!({ + "nested": { + "path": "nested path value" + } + })), + ( + Some(json!({ + "one": 1, + "two": 2, + "negativeThree": -3, + "true": true, + "false": false, + "null": null, + "string": "string", + "array": [1, 2, 3], + "object": { "key": "value" }, + "path": "nested path value", + })), + vec![], + ), + ); + + assert_eq!( + selection!( + r#" + one: $(1)->typeof + two: $(2)->typeof + negativeThree: $(-3)->typeof + true: $(true)->typeof + false: $(false)->typeof + null: $(null)->typeof + string: $("string")->typeof + array: $([1, 2, 3])->typeof + object: $({ "key": "value" })->typeof + path: $(nested.path)->typeof + "# + ) + .apply_to(&json!({ + "nested": { + "path": 12345 + } + })), + ( + Some(json!({ + "one": "number", + "two": "number", + "negativeThree": "number", + "true": "boolean", + "false": "boolean", + "null": "null", + "string": "string", + "array": "array", + "object": "object", + "path": "number", + })), + vec![], + ), + ); + + assert_eq!( + selection!( + r#" + items: $([ + 1, + -2.0, + true, + false, + null, + "string", + [1, 2, 3], + { "key": "value" }, + nested.path, + ])->map(@->typeof) + "# + ) + .apply_to(&json!({ + "nested": { + "path": { "deeply": "nested" } + } + })), + ( + Some(json!({ + "items": [ + "number", + "number", + "boolean", + "boolean", + "null", + "string", + "array", + "object", + "object", + ], + })), + vec![], + ), + ); + + assert_eq!( + selection!( + r#" + $({ + one: 1, + two: 2, + negativeThree: -3, + true: true, + false: false, + null: null, + string: "string", + array: [1, 2, 3], + object: { "key": "value" }, + path: $ . nested . path , + })->entries + "# + ) + .apply_to(&json!({ + "nested": { + "path": "nested path value" + } + })), + ( + Some(json!([ + { "key": "one", "value": 1 }, + { "key": "two", "value": 2 }, + { "key": "negativeThree", "value": -3 }, + { "key": "true", "value": true }, + { "key": "false", "value": false }, + { "key": "null", "value": null }, + { "key": "string", "value": "string" }, + { "key": "array", "value": [1, 2, 3] }, + { "key": "object", "value": { "key": "value" } }, + { "key": "path", "value": "nested path value" }, + ])), + vec![], + ), + ); + + assert_eq!( + selection!( + r#" + $({ + string: $("string")->slice(1, 4), + array: $([1, 2, 3])->map(@->add(10)), + object: $({ "key": "value" })->get("key"), + path: nested.path->slice($("nested ")->size), + needlessParens: $("oyez"), + withoutParens: "oyez", + }) + "# + ) + .apply_to(&json!({ + "nested": { + "path": "nested path value" + } + })), + ( + Some(json!({ + "string": "tri", + "array": [11, 12, 13], + "object": "value", + "path": "path value", + "needlessParens": "oyez", + "withoutParens": "oyez", + })), + vec![], + ), + ); + + assert_eq!( + selection!( + r#" + string: $("string")->slice(1, 4) + array: $([1, 2, 3])->map(@->add(10)) + object: $({ "key": "value" })->get("key") + path: nested.path->slice($("nested ")->size) + "# + ) + .apply_to(&json!({ + "nested": { + "path": "nested path value" + } + })), + ( + Some(json!({ + "string": "tri", + "array": [11, 12, 13], + "object": "value", + "path": "path value", + })), + vec![], + ), + ); + } + + #[rstest] + #[case::v0_2(ConnectSpec::V0_2)] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_inline_paths_with_subselections(#[case] spec: ConnectSpec) { + let data = json!({ + "id": 123, + "created": "2021-01-01T00:00:00Z", + "model": "gpt-4o", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "The capital of Australia is Canberra.", + }, + }, { + "index": 1, + "message": { + "role": "assistant", + "content": "The capital of Australia is Sydney.", + }, + }], + }); + + { + let expected = ( + Some(json!({ + "id": 123, + "created": "2021-01-01T00:00:00Z", + "model": "gpt-4o", + "role": "assistant", + "content": "The capital of Australia is Canberra.", + })), + vec![], + ); + + assert_eq!( + selection!( + r#" + id + created + model + role: choices->first.message.role + content: choices->first.message.content + "#, + spec + ) + .apply_to(&data), + expected, + ); + + assert_eq!( + selection!( + r#" + id + created + model + choices->first.message { + role + content + } + "#, + spec + ) + .apply_to(&data), + expected, + ); + + assert_eq!( + selection!( + r#" + id + choices->first.message { + role + content + } + created + model + "#, + spec + ) + .apply_to(&data), + expected, + ); + } + + { + let expected = ( + Some(json!({ + "id": 123, + "created": "2021-01-01T00:00:00Z", + "model": "gpt-4o", + "role": "assistant", + "message": "The capital of Australia is Sydney.", + })), + vec![], + ); + + assert_eq!( + selection!( + r#" + id + created + model + role: choices->last.message.role + message: choices->last.message.content + "#, + spec + ) + .apply_to(&data), + expected, + ); + + assert_eq!( + selection!( + r#" + id + created + model + choices->last.message { + role + message: content + } + "#, + spec + ) + .apply_to(&data), + expected, + ); + + assert_eq!( + selection!( + r#" + created + choices->last.message { + message: content + role + } + model + id + "#, + spec + ) + .apply_to(&data), + expected, + ); + } + + { + let expected = ( + Some(json!({ + "id": 123, + "created": "2021-01-01T00:00:00Z", + "model": "gpt-4o", + "role": "assistant", + "correct": "The capital of Australia is Canberra.", + "incorrect": "The capital of Australia is Sydney.", + })), + vec![], + ); + + assert_eq!( + selection!( + r#" + id + created + model + role: choices->first.message.role + correct: choices->first.message.content + incorrect: choices->last.message.content + "#, + spec + ) + .apply_to(&data), + expected, + ); + + assert_eq!( + selection!( + r#" + id + created + model + choices->first.message { + role + correct: content + } + choices->last.message { + incorrect: content + } + "#, + spec + ) + .apply_to(&data), + expected, + ); + + assert_eq!( + selection!( + r#" + id + created + model + choices->first.message { + role + correct: content + } + incorrect: choices->last.message.content + "#, + spec + ) + .apply_to(&data), + expected, + ); + + assert_eq!( + selection!( + r#" + id + created + model + choices->first.message { + correct: content + } + choices->last.message { + role + incorrect: content + } + "#, + spec + ) + .apply_to(&data), + expected, + ); + + assert_eq!( + selection!( + r#" + id + created + correct: choices->first.message.content + choices->last.message { + role + incorrect: content + } + model + "#, + spec + ) + .apply_to(&data), + expected, + ); + } + + { + let data = json!({ + "from": "data", + }); + + let vars = { + let mut vars = IndexMap::default(); + vars.insert( + "$this".to_string(), + json!({ + "id": 1234, + }), + ); + vars.insert( + "$args".to_string(), + json!({ + "input": { + "title": "The capital of Australia", + "body": "Canberra", + }, + "extra": "extra", + }), + ); + vars + }; + + let expected = ( + Some(json!({ + "id": 1234, + "title": "The capital of Australia", + "body": "Canberra", + "from": "data", + })), + vec![], + ); + + assert_eq!( + selection!( + r#" + id: $this.id + $args.input { + title + body + } + from + "#, + spec + ) + .apply_with_vars(&data, &vars), + expected, + ); + + assert_eq!( + selection!( + r#" + from + $args.input { title body } + id: $this.id + "#, + spec + ) + .apply_with_vars(&data, &vars), + expected, + ); + + assert_eq!( + selection!( + r#" + $args.input { body title } + from + id: $this.id + "#, + spec + ) + .apply_with_vars(&data, &vars), + expected, + ); + + assert_eq!( + selection!( + r#" + id: $this.id + $args { $.input { title body } } + from + "#, + spec + ) + .apply_with_vars(&data, &vars), + expected, + ); + + assert_eq!( + selection!( + r#" + id: $this.id + $args { $.input { title body } extra } + from: $.from + "#, + spec + ) + .apply_with_vars(&data, &vars), + ( + Some(json!({ + "id": 1234, + "title": "The capital of Australia", + "body": "Canberra", + "extra": "extra", + "from": "data", + })), + vec![], + ), + ); + + assert_eq!( + selection!( + r#" + # Equivalent to id: $this.id + $this { id } + + $args { + __typename: $("Args") + + # Requiring $. instead of just . prevents .input from + # parsing as a key applied to the $("Args") string. + $.input { title body } + + extra + } + + from: $.from + "#, + spec + ) + .apply_with_vars(&data, &vars), + ( + Some(json!({ + "id": 1234, + "title": "The capital of Australia", + "body": "Canberra", + "__typename": "Args", + "extra": "extra", + "from": "data", + })), + vec![], + ), + ); + } + } + + #[test] + fn test_inline_path_errors() { + { + let data = json!({ + "id": 123, + "created": "2021-01-01T00:00:00Z", + "model": "gpt-4o", + "choices": [{ + "message": "The capital of Australia is Canberra.", + }, { + "message": "The capital of Australia is Sydney.", + }], + }); + + let expected = ( + Some(json!({ + "id": 123, + "created": "2021-01-01T00:00:00Z", + "model": "gpt-4o", + })), + vec![ + ApplyToError::new( + "Property .role not found in string".to_string(), + vec![ + json!("choices"), + json!("->first"), + json!("message"), + json!("role"), + ], + Some(123..127), + ConnectSpec::latest(), + ), + ApplyToError::new( + "Property .content not found in string".to_string(), + vec![ + json!("choices"), + json!("->first"), + json!("message"), + json!("content"), + ], + Some(128..135), + ConnectSpec::latest(), + ), + ApplyToError::new( + "Expected object or null, not string".to_string(), + vec![], + // This is the range of the whole + // `choices->first.message { role content }` + // subselection. + Some(98..137), + ConnectSpec::latest(), + ), + ], + ); + + assert_eq!( + selection!( + r#" + id + created + model + choices->first.message { role content } + "# + ) + .apply_to(&data), + expected, + ); + } + + assert_eq!( + selection!("id nested.path.nonexistent { name }").apply_to(&json!({ + "id": 2345, + "nested": { + "path": "nested path value", + }, + })), + ( + Some(json!({ + "id": 2345, + })), + vec![ + ApplyToError::new( + "Property .nonexistent not found in string".to_string(), + vec![json!("nested"), json!("path"), json!("nonexistent")], + Some(15..26), + ConnectSpec::latest(), + ), + ApplyToError::new( + "Inlined path produced no value".to_string(), + vec![], + // This is the range of the whole + // `nested.path.nonexistent { name }` path selection. + Some(3..35), + ConnectSpec::latest(), + ), + ], + ), + ); + + let valid_inline_path_selection = JSONSelection::named(SubSelection { + selections: vec![NamedSelection { + prefix: NamingPrefix::None, + path: PathSelection { + path: PathList::Key( + Key::field("some").into_with_range(), + PathList::Key( + Key::field("object").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + }], + ..Default::default() + }); + + assert_eq!( + valid_inline_path_selection.apply_to(&json!({ + "some": { + "object": { + "key": "value", + }, + }, + })), + ( + Some(json!({ + "key": "value", + })), + vec![], + ), + ); + } + + #[test] + fn test_apply_to_non_identifier_properties() { + let data = json!({ + "not an identifier": [ + { "also.not.an.identifier": 0 }, + { "also.not.an.identifier": 1 }, + { "also.not.an.identifier": 2 }, + ], + "another": { + "pesky string literal!": { + "identifier": 123, + "{ evil braces }": true, + }, + }, + }); + + assert_eq!( + // The grammar enforces that we must always provide identifier aliases + // for non-identifier properties, so the data we get back will always be + // GraphQL-safe. + selection!("alias: 'not an identifier' { safe: 'also.not.an.identifier' }") + .apply_to(&data), + ( + Some(json!({ + "alias": [ + { "safe": 0 }, + { "safe": 1 }, + { "safe": 2 }, + ], + })), + vec![], + ), + ); + + assert_eq!( + selection!("'not an identifier'.'also.not.an.identifier'").apply_to(&data), + (Some(json!([0, 1, 2])), vec![],), + ); + + assert_eq!( + selection!("$.'not an identifier'.'also.not.an.identifier'").apply_to(&data), + (Some(json!([0, 1, 2])), vec![],), + ); + + assert_eq!( + selection!("$.\"not an identifier\" { safe: \"also.not.an.identifier\" }") + .apply_to(&data), + ( + Some(json!([ + { "safe": 0 }, + { "safe": 1 }, + { "safe": 2 }, + ])), + vec![], + ), + ); + + assert_eq!( + selection!( + "another { + pesky: 'pesky string literal!' { + identifier + evil: '{ evil braces }' + } + }" + ) + .apply_to(&data), + ( + Some(json!({ + "another": { + "pesky": { + "identifier": 123, + "evil": true, + }, + }, + })), + vec![], + ), + ); + + assert_eq!( + selection!("another.'pesky string literal!'.'{ evil braces }'").apply_to(&data), + (Some(json!(true)), vec![],), + ); + + assert_eq!( + selection!("another.'pesky string literal!'.\"identifier\"").apply_to(&data), + (Some(json!(123)), vec![],), + ); + + assert_eq!( + selection!("$.another.'pesky string literal!'.\"identifier\"").apply_to(&data), + (Some(json!(123)), vec![],), + ); + } + + #[rstest] + #[case::latest(ConnectSpec::V0_2)] + #[case::next(ConnectSpec::V0_3)] + fn test_left_associative_path_evaluation(#[case] spec: ConnectSpec) { + assert_eq!( + selection!("batch.id->first", spec).apply_to(&json!({ + "batch": [ + { "id": 1 }, + { "id": 2 }, + { "id": 3 }, + ], + })), + (Some(json!(1)), vec![]), + ); + + assert_eq!( + selection!("batch.id->last", spec).apply_to(&json!({ + "batch": [ + { "id": 1 }, + { "id": 2 }, + { "id": 3 }, + ], + })), + (Some(json!(3)), vec![]), + ); + + assert_eq!( + selection!("batch.id->size", spec).apply_to(&json!({ + "batch": [ + { "id": 1 }, + { "id": 2 }, + { "id": 3 }, + ], + })), + (Some(json!(3)), vec![]), + ); + + assert_eq!( + selection!("batch.id->slice(1)->first", spec).apply_to(&json!({ + "batch": [ + { "id": 1 }, + { "id": 2 }, + { "id": 3 }, + ], + })), + (Some(json!(2)), vec![]), + ); + + assert_eq!( + selection!("batch.id->map({ batchId: @ })", spec).apply_to(&json!({ + "batch": [ + { "id": 1 }, + { "id": 2 }, + { "id": 3 }, + ], + })), + ( + Some(json!([ + { "batchId": 1 }, + { "batchId": 2 }, + { "batchId": 3 }, + ])), + vec![], + ), + ); + + let mut vars = IndexMap::default(); + vars.insert( + "$batch".to_string(), + json!([ + { "id": 4 }, + { "id": 5 }, + { "id": 6 }, + ]), + ); + assert_eq!( + selection!("$batch.id->map({ batchId: @ })", spec).apply_with_vars( + &json!({ + "batch": "ignored", + }), + &vars + ), + ( + Some(json!([ + { "batchId": 4 }, + { "batchId": 5 }, + { "batchId": 6 }, + ])), + vec![], + ), + ); + + assert_eq!( + selection!("batch.id->map({ batchId: @ })->first", spec).apply_to(&json!({ + "batch": [ + { "id": 7 }, + { "id": 8 }, + { "id": 9 }, + ], + })), + (Some(json!({ "batchId": 7 })), vec![]), + ); + + assert_eq!( + selection!("batch.id->map({ batchId: @ })->last", spec).apply_to(&json!({ + "batch": [ + { "id": 7 }, + { "id": 8 }, + { "id": 9 }, + ], + })), + (Some(json!({ "batchId": 9 })), vec![]), + ); + + assert_eq!( + selection!("$batch.id->map({ batchId: @ })->first", spec).apply_with_vars( + &json!({ + "batch": "ignored", + }), + &vars + ), + (Some(json!({ "batchId": 4 })), vec![]), + ); + + assert_eq!( + selection!("$batch.id->map({ batchId: @ })->last", spec).apply_with_vars( + &json!({ + "batch": "ignored", + }), + &vars + ), + (Some(json!({ "batchId": 6 })), vec![]), + ); + + assert_eq!( + selection!("arrays.as.bs->echo({ echoed: @ })", spec).apply_to(&json!({ + "arrays": [ + { "as": { "bs": [10, 20, 30] } }, + { "as": { "bs": [40, 50, 60] } }, + { "as": { "bs": [70, 80, 90] } }, + ], + })), + ( + Some(json!({ + "echoed": [ + [10, 20, 30], + [40, 50, 60], + [70, 80, 90], + ], + })), + vec![], + ), + ); + + assert_eq!( + selection!("arrays.as.bs->echo({ echoed: @ })", spec).apply_to(&json!({ + "arrays": [ + { "as": { "bs": [10, 20, 30] } }, + { "as": [ + { "bs": [40, 50, 60] }, + { "bs": [70, 80, 90] }, + ] }, + { "as": { "bs": [100, 110, 120] } }, + ], + })), + ( + Some(json!({ + "echoed": [ + [10, 20, 30], + [ + [40, 50, 60], + [70, 80, 90], + ], + [100, 110, 120], + ], + })), + vec![], + ), + ); + + assert_eq!( + selection!("batch.id->jsonStringify", spec).apply_to(&json!({ + "batch": [ + { "id": 1 }, + { "id": 2 }, + { "id": 3 }, + ], + })), + (Some(json!("[1,2,3]")), vec![]), + ); + + assert_eq!( + selection!("batch.id->map([@])->echo([@])->jsonStringify", spec).apply_to(&json!({ + "batch": [ + { "id": 1 }, + { "id": 2 }, + { "id": 3 }, + ], + })), + (Some(json!("[[[1],[2],[3]]]")), vec![]), + ); + + assert_eq!( + selection!("batch.id->map([@])->echo([@])->jsonStringify->typeof", spec).apply_to( + &json!({ + "batch": [ + { "id": 1 }, + { "id": 2 }, + { "id": 3 }, + ], + }) + ), + (Some(json!("string")), vec![]), + ); + } + + #[test] + fn test_left_associative_output_shapes_v0_2() { + let spec = ConnectSpec::V0_2; + + assert_eq!( + selection!("$batch.id", spec).shape().pretty_print(), + "$batch.id" + ); + + assert_eq!( + selection!("$batch.id->first", spec).shape().pretty_print(), + "Unknown", + ); + + assert_eq!( + selection!("$batch.id->last", spec).shape().pretty_print(), + "Unknown", + ); + + let mut named_shapes = IndexMap::default(); + named_shapes.insert( + "$batch".to_string(), + Shape::list( + Shape::record( + { + let mut map = Shape::empty_map(); + map.insert("id".to_string(), Shape::int([])); + map + }, + [], + ), + [], + ), + ); + + let root_shape = Shape::name("$root", []); + let shape_context = ShapeContext::new(SourceId::Other("JSONSelection".into())) + .with_spec(spec) + .with_named_shapes(named_shapes); + + let computed_batch_id = + selection!("$batch.id", spec).compute_output_shape(&shape_context, root_shape.clone()); + assert_eq!(computed_batch_id.pretty_print(), "List"); + + let computed_first = selection!("$batch.id->first", spec) + .compute_output_shape(&shape_context, root_shape.clone()); + assert_eq!(computed_first.pretty_print(), "Unknown"); + + let computed_last = selection!("$batch.id->last", spec) + .compute_output_shape(&shape_context, root_shape.clone()); + assert_eq!(computed_last.pretty_print(), "Unknown"); + + assert_eq!( + selection!("$batch.id->jsonStringify", spec) + .shape() + .pretty_print(), + "Unknown", + ); + + assert_eq!( + selection!("$batch.id->map([@])->echo([@])->jsonStringify", spec) + .shape() + .pretty_print(), + "Unknown", + ); + + assert_eq!( + selection!("$batch.id->map(@)->echo(@)", spec) + .shape() + .pretty_print(), + "Unknown", + ); + + assert_eq!( + selection!("$batch.id->map(@)->echo([@])", spec) + .shape() + .pretty_print(), + "Unknown", + ); + + assert_eq!( + selection!("$batch.id->map([@])->echo(@)", spec) + .shape() + .pretty_print(), + "Unknown", + ); + + assert_eq!( + selection!("$batch.id->map([@])->echo([@])", spec) + .shape() + .pretty_print(), + "Unknown", + ); + + assert_eq!( + selection!("$batch.id->map([@])->echo([@])", spec) + .compute_output_shape(&shape_context, root_shape,) + .pretty_print(), + "Unknown", + ); + } + + #[test] + fn test_left_associative_output_shapes_v0_3() { + let spec = ConnectSpec::V0_3; + + assert_eq!( + selection!("$batch.id", spec).shape().pretty_print(), + "$batch.id" + ); + + assert_eq!( + selection!("$batch.id->first", spec).shape().pretty_print(), + "$batch.id.0", + ); + + assert_eq!( + selection!("$batch.id->last", spec).shape().pretty_print(), + "$batch.id.*", + ); + + let mut named_shapes = IndexMap::default(); + named_shapes.insert( + "$batch".to_string(), + Shape::list( + Shape::record( + { + let mut map = Shape::empty_map(); + map.insert("id".to_string(), Shape::int([])); + map + }, + [], + ), + [], + ), + ); + + let root_shape = Shape::name("$root", []); + let shape_context = ShapeContext::new(SourceId::Other("JSONSelection".into())) + .with_spec(spec) + .with_named_shapes(named_shapes.clone()); + + let computed_batch_id = + selection!("$batch.id", spec).compute_output_shape(&shape_context, root_shape.clone()); + assert_eq!(computed_batch_id.pretty_print(), "List"); + + let computed_first = selection!("$batch.id->first", spec) + .compute_output_shape(&shape_context, root_shape.clone()); + assert_eq!(computed_first.pretty_print(), "One"); + + let computed_last = selection!("$batch.id->last", spec) + .compute_output_shape(&shape_context, root_shape.clone()); + assert_eq!(computed_last.pretty_print(), "One"); + + assert_eq!( + selection!("$batch.id->jsonStringify", spec) + .shape() + .pretty_print(), + "String", + ); + + assert_eq!( + selection!("$batch.id->map([@])->echo([@])->jsonStringify", spec) + .shape() + .pretty_print(), + "String", + ); + + assert_eq!( + selection!("$batch.id->map(@)->echo(@)", spec) + .shape() + .pretty_print(), + "List<$batch.id.*>", + ); + + assert_eq!( + selection!("$batch.id->map(@)->echo([@])", spec) + .shape() + .pretty_print(), + "[List<$batch.id.*>]", + ); + + assert_eq!( + selection!("$batch.id->map([@])->echo(@)", spec) + .shape() + .pretty_print(), + "List<[$batch.id.*]>", + ); + + assert_eq!( + selection!("$batch.id->map([@])->echo([@])", spec) + .shape() + .pretty_print(), + "[List<[$batch.id.*]>]", + ); + + assert_eq!( + selection!("$batch.id->map([@])->echo([@])", spec) + .compute_output_shape(&shape_context, root_shape,) + .pretty_print(), + "[List<[Int]>]", + ); + } + + #[test] + fn test_lit_paths() { + let data = json!({ + "value": { + "key": 123, + }, + }); + + assert_eq!( + selection!("$(\"a\")->first").apply_to(&data), + (Some(json!("a")), vec![]), + ); + + assert_eq!( + selection!("$('asdf'->last)").apply_to(&data), + (Some(json!("f")), vec![]), + ); + + assert_eq!( + selection!("$(1234)->add(1111)").apply_to(&data), + (Some(json!(2345)), vec![]), + ); + + assert_eq!( + selection!("$(1234->add(1111))").apply_to(&data), + (Some(json!(2345)), vec![]), + ); + + assert_eq!( + selection!("$(value.key->mul(10))").apply_to(&data), + (Some(json!(1230)), vec![]), + ); + + assert_eq!( + selection!("$(value.key)->mul(10)").apply_to(&data), + (Some(json!(1230)), vec![]), + ); + + assert_eq!( + selection!("$(value.key->typeof)").apply_to(&data), + (Some(json!("number")), vec![]), + ); + + assert_eq!( + selection!("$(value.key)->typeof").apply_to(&data), + (Some(json!("number")), vec![]), + ); + + assert_eq!( + selection!("$([1, 2, 3])->last").apply_to(&data), + (Some(json!(3)), vec![]), + ); + + assert_eq!( + selection!("$([1, 2, 3]->first)").apply_to(&data), + (Some(json!(1)), vec![]), + ); + + assert_eq!( + selection!("$({ a: 'ay', b: 1 }).a").apply_to(&data), + (Some(json!("ay")), vec![]), + ); + + assert_eq!( + selection!("$({ a: 'ay', b: 2 }.a)").apply_to(&data), + (Some(json!("ay")), vec![]), + ); + + assert_eq!( + // Note that the -> has lower precedence than the -, so -1 is parsed + // as a completed expression before applying the ->add(10) method, + // giving 9 instead of -11. + selection!("$(-1->add(10))").apply_to(&data), + (Some(json!(9)), vec![]), + ); + } + + #[test] + fn test_compute_output_shape() { + assert_eq!(selection!("").shape().pretty_print(), "{}"); + + assert_eq!( + selection!("id name").shape().pretty_print(), + "{ id: $root.*.id, name: $root.*.name }", + ); + + // // On hold until variadic $(...) is merged (PR #6456). + // assert_eq!( + // selection!("$.data { thisOrThat: $(maybe.this, maybe.that) }") + // .shape() + // .pretty_print(), + // // Technically $.data could be an array, so this should be a union + // // of this shape and a list of this shape, except with + // // $root.data.0.maybe.{this,that} shape references. + // // + // // We could try to say that any { ... } shape represents either an + // // object or a list of objects, by policy, to avoid having to write + // // One<{...}, List<{...}>> everywhere a SubSelection appears. + // // + // // But then we don't know where the array indexes should go... + // "{ thisOrThat: One<$root.data.*.maybe.this, $root.data.*.maybe.that> }", + // ); + + assert_eq!( + selection!( + r#" + id + name + friends: friend_ids { id: @ } + alias: arrayOfArrays { x y } + ys: arrayOfArrays.y xs: arrayOfArrays.x + "# + ) + .shape() + .pretty_print(), + // This output shape is wrong if $root.friend_ids turns out to be an + // array, and it's tricky to see how to transform the shape to what + // it would have been if we knew that, where friends: List<{ id: + // $root.friend_ids.* }> (note the * meaning any array index), + // because who's to say it's not the id field that should become the + // List, rather than the friends field? + "{ alias: { x: $root.*.arrayOfArrays.*.x, y: $root.*.arrayOfArrays.*.y }, friends: { id: $root.*.friend_ids.* }, id: $root.*.id, name: $root.*.name, xs: $root.*.arrayOfArrays.x, ys: $root.*.arrayOfArrays.y }", + ); + + // TODO: re-test when method type checking is re-enabled + // assert_eq!( + // selection!(r#" + // id + // name + // friends: friend_ids->map({ id: @ }) + // alias: arrayOfArrays { x y } + // ys: arrayOfArrays.y xs: arrayOfArrays.x + // "#).shape().pretty_print(), + // "{ alias: { x: $root.*.arrayOfArrays.*.x, y: $root.*.arrayOfArrays.*.y }, friends: List<{ id: $root.*.friend_ids.* }>, id: $root.*.id, name: $root.*.name, xs: $root.*.arrayOfArrays.x, ys: $root.*.arrayOfArrays.y }", + // ); + // + // assert_eq!( + // selection!("$->echo({ thrice: [@, @, @] })") + // .shape() + // .pretty_print(), + // "{ thrice: [$root, $root, $root] }", + // ); + // + // assert_eq!( + // selection!("$->echo({ thrice: [@, @, @] })->entries") + // .shape() + // .pretty_print(), + // "[{ key: \"thrice\", value: [$root, $root, $root] }]", + // ); + // + // assert_eq!( + // selection!("$->echo({ thrice: [@, @, @] })->entries.key") + // .shape() + // .pretty_print(), + // "[\"thrice\"]", + // ); + // + // assert_eq!( + // selection!("$->echo({ thrice: [@, @, @] })->entries.value") + // .shape() + // .pretty_print(), + // "[[$root, $root, $root]]", + // ); + // + // assert_eq!( + // selection!("$->echo({ wrapped: @ })->entries { k: key v: value }") + // .shape() + // .pretty_print(), + // "[{ k: \"wrapped\", v: $root }]", + // ); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_key_access_with_existing_property(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "user": { + "profile": { + "name": "Alice" + } + } + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.user?.profile.name", spec) + .unwrap() + .apply_to(&data); + assert!(errors.is_empty()); + assert_eq!(result, Some(json!("Alice"))); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_key_access_with_null_value(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data_null = json!({ + "user": null + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.user?.profile.name", spec) + .unwrap() + .apply_to(&data_null); + assert!(errors.is_empty()); + assert_eq!(result, None); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_key_access_on_non_object(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data_non_obj = json!({ + "user": "not an object" + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.user?.profile.name", spec) + .unwrap() + .apply_to(&data_non_obj); + assert_eq!(errors.len(), 1); + assert!( + errors[0] + .message() + .contains("Property .profile not found in string") + ); + assert_eq!(result, None); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_key_access_with_missing_property(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "user": { + "other": "value" + } + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.user?.profile.name", spec) + .unwrap() + .apply_to(&data); + assert_eq!(errors.len(), 1); + assert!( + errors[0] + .message() + .contains("Property .profile not found in object") + ); + assert_eq!(result, None); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_chained_optional_key_access(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "user": { + "profile": { + "name": "Alice" + } + } + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.user?.profile?.name", spec) + .unwrap() + .apply_to(&data); + assert!(errors.is_empty()); + assert_eq!(result, Some(json!("Alice"))); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_chained_optional_access_with_null_in_middle(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data_partial_null = json!({ + "user": { + "profile": null + } + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.user?.profile?.name", spec) + .unwrap() + .apply_to(&data_partial_null); + assert!(errors.is_empty()); + assert_eq!(result, None); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_method_on_null(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "items": null + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.items?->first", spec) + .unwrap() + .apply_to(&data); + assert!(errors.is_empty()); + assert_eq!(result, None); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_method_with_valid_method(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "values": [1, 2, 3] + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.values?->first", spec) + .unwrap() + .apply_to(&data); + assert!(errors.is_empty()); + assert_eq!(result, Some(json!(1))); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_method_with_unknown_method(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "values": [1, 2, 3] + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.values?->length", spec) + .unwrap() + .apply_to(&data); + assert_eq!(errors.len(), 1); + assert!(errors[0].message().contains("Method ->length not found")); + assert_eq!(result, None); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_chaining_with_subselection_on_valid_data(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "user": { + "profile": { + "name": "Alice", + "age": 30, + "email": "alice@example.com" + } + } + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.user?.profile { name age }", spec) + .unwrap() + .apply_to(&data); + assert!(errors.is_empty()); + assert_eq!( + result, + Some(json!({ + "name": "Alice", + "age": 30 + })) + ); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_chaining_with_subselection_on_null_data(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data_null = json!({ + "user": null + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.user?.profile { name age }", spec) + .unwrap() + .apply_to(&data_null); + assert!(errors.is_empty()); + assert_eq!(result, None); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_mixed_regular_and_optional_chaining_working_case(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "response": { + "data": { + "user": { + "profile": { + "name": "Bob" + } + } + } + } + }); + + let (result, errors) = + JSONSelection::parse_with_spec("$.response.data?.user.profile.name", spec) + .unwrap() + .apply_to(&data); + assert!(errors.is_empty()); + assert_eq!(result, Some(json!("Bob"))); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_mixed_regular_and_optional_chaining_with_null(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data_null_data = json!({ + "response": { + "data": null + } + }); + + let (result, errors) = + JSONSelection::parse_with_spec("$.response.data?.user.profile.name", spec) + .unwrap() + .apply_to(&data_null_data); + assert!(errors.is_empty()); + assert_eq!(result, None); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_selection_set_with_valid_data(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "user": { + "id": 123, + "name": "Alice" + } + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.user ?{ id name }", spec) + .unwrap() + .apply_to(&data); + assert_eq!( + result, + Some(json!({ + "id": 123, + "name": "Alice" + })) + ); + assert_eq!(errors, vec![]); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_selection_set_with_null_data(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "user": null + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.user ?{ id name }", spec) + .unwrap() + .apply_to(&data); + assert_eq!(result, None); + assert_eq!(errors, vec![]); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_selection_set_with_missing_property(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "other": "value" + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.user ?{ id name }", spec) + .unwrap() + .apply_to(&data); + assert_eq!(result, None); + assert_eq!(errors.len(), 0); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_selection_set_with_non_object(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "user": "not an object" + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.user ?{ id name }", spec) + .unwrap() + .apply_to(&data); + // When data is not null but not an object, SubSelection still tries to access properties + // This results in errors, but returns the original value since no properties were found + assert_eq!(result, Some(json!("not an object"))); + assert_eq!(errors.len(), 2); + assert!( + errors[0] + .message() + .contains("Property .id not found in string") + ); + assert!( + errors[1] + .message() + .contains("Property .name not found in string") + ); + } + + #[test] + fn test_optional_field_selections() { + let spec = ConnectSpec::V0_3; + let author_selection = selection!("author? { age middleName? }", spec); + assert_debug_snapshot!(author_selection); + assert_eq!( + author_selection.pretty_print(), + "author? { age middleName? }", + ); + assert_eq!( + author_selection.shape().pretty_print(), + "{ author: One<{ age: $root.*.author.*.age, middleName: One<$root.*.author.*.middleName, None> }, None> }", + ); + } + + #[cfg(test)] + mod spread { + use serde_json_bytes::Value as JSON; + use serde_json_bytes::json; + use shape::Shape; + use shape::location::SourceId; + + use crate::connectors::ConnectSpec; + use crate::connectors::json_selection::ShapeContext; + + #[derive(Debug)] + pub(super) struct SetupItems { + pub data: JSON, + pub shape_context: ShapeContext, + pub root_shape: Shape, + } + + pub(super) fn setup(spec: ConnectSpec) -> SetupItems { + let a_b_data = json!({ + "a": { "phonetic": "ay" }, + "b": { "phonetic": "bee" }, + }); + + let a_b_data_shape = Shape::from_json_bytes(&a_b_data); + + let shape_context = ShapeContext::new(SourceId::Other("JSONSelection".into())) + .with_spec(spec) + .with_named_shapes([("$root".to_string(), a_b_data_shape)]); + + let root_shape = shape_context.named_shapes().get("$root").unwrap().clone(); + + SetupItems { + data: a_b_data, + shape_context, + root_shape, + } + } + } + + #[test] + fn test_spread_syntax_spread_a() { + let spec = ConnectSpec::V0_3; + let spread::SetupItems { + data: a_b_data, + shape_context, + root_shape, + } = spread::setup(spec); + + let spread_a = selection!("...a", spec); + assert_eq!( + spread_a.apply_to(&a_b_data), + (Some(json!({"phonetic": "ay"})), vec![]), + ); + assert_eq!(spread_a.shape().pretty_print(), "$root.*.a",); + assert_eq!( + spread_a + .compute_output_shape(&shape_context, root_shape) + .pretty_print(), + "{ phonetic: \"ay\" }", + ); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_nested_optional_selection_sets(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "user": { + "profile": { + "name": "Alice", + "email": "alice@example.com" + } + } + }); + + let (result, errors) = + JSONSelection::parse_with_spec("$.user.profile ?{ name email }", spec) + .unwrap() + .apply_to(&data); + assert_eq!( + result, + Some(json!({ + "name": "Alice", + "email": "alice@example.com" + })) + ); + assert_eq!(errors, vec![]); + + // Test with null nested data + let data_with_null_profile = json!({ + "user": { + "profile": null + } + }); + + let (result, errors) = + JSONSelection::parse_with_spec("$.user.profile ?{ name email }", spec) + .unwrap() + .apply_to(&data_with_null_profile); + assert_eq!(result, None); + assert_eq!(errors, vec![]); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_mixed_optional_selection_and_optional_chaining(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "user": { + "id": 123, + "profile": null + } + }); + + let (result, errors) = + JSONSelection::parse_with_spec("$.user ?{ id profileName: profile?.name }", spec) + .unwrap() + .apply_to(&data); + assert_eq!( + result, + Some(json!({ + "id": 123 + })) + ); + assert_eq!(errors, vec![]); + + // Test with missing user + let data_no_user = json!({ + "other": "value" + }); + + let (result, errors) = + JSONSelection::parse_with_spec("$.user ?{ id profileName: profile?.name }", spec) + .unwrap() + .apply_to(&data_no_user); + assert_eq!(result, None); + assert_eq!(errors.len(), 0); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_selection_set_parsing(#[case] spec: ConnectSpec) { + // Test that the parser correctly handles optional selection sets + let selection = JSONSelection::parse_with_spec("$.user? { id name }", spec).unwrap(); + assert_eq!(selection.pretty_print(), "$.user? { id name }"); + + // Test with nested optional selection sets + let selection = JSONSelection::parse_with_spec("$.user.profile? { name }", spec).unwrap(); + assert_eq!(selection.pretty_print(), "$.user.profile? { name }"); + + // Test mixed with regular selection sets + let selection = + JSONSelection::parse_with_spec("$.user? { id profile { name } }", spec).unwrap(); + assert_eq!(selection.pretty_print(), "$.user? { id profile { name } }"); + } + + #[rstest] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_optional_selection_set_with_arrays(#[case] spec: ConnectSpec) { + use serde_json_bytes::json; + + let data = json!({ + "users": [ + { + "id": 1, + "name": "Alice" + }, + null, + { + "id": 3, + "name": "Charlie" + } + ] + }); + + let (result, errors) = JSONSelection::parse_with_spec("$.users ?{ id name }", spec) + .unwrap() + .apply_to(&data); + assert_eq!( + result, + Some(json!([ + { + "id": 1, + "name": "Alice" + }, + null, + { + "id": 3, + "name": "Charlie" + } + ])) + ); + + assert_eq!(errors.len(), 2); + assert!( + errors[0] + .message() + .contains("Property .id not found in null") + ); + assert!( + errors[1] + .message() + .contains("Property .name not found in null") + ); + } + + #[test] + fn test_spread_syntax_a_spread_b() { + let spec = ConnectSpec::V0_3; + let spread::SetupItems { + data: a_b_data, + shape_context, + root_shape, + } = spread::setup(spec); + + let a_spread_b = selection!("a...b", spec); + assert_eq!( + a_spread_b.apply_to(&a_b_data), + ( + Some(json!({"a": { "phonetic": "ay" }, "phonetic": "bee" })), + vec![] + ), + ); + assert_eq!( + a_spread_b.shape().pretty_print(), + "All<$root.*.b, { a: $root.*.a }>", + ); + assert_eq!( + a_spread_b + .compute_output_shape(&shape_context, root_shape) + .pretty_print(), + "{ a: { phonetic: \"ay\" }, phonetic: \"bee\" }", + ); + } + + #[test] + fn test_spread_syntax_spread_a_b() { + let spec = ConnectSpec::V0_3; + let spread::SetupItems { + data: a_b_data, + shape_context, + root_shape, + } = spread::setup(spec); + + let spread_a_b = selection!("...a b", spec); + assert_eq!( + spread_a_b.apply_to(&a_b_data), + ( + Some(json!({"phonetic": "ay", "b": { "phonetic": "bee" }})), + vec![] + ), + ); + assert_eq!( + spread_a_b.shape().pretty_print(), + "All<$root.*.a, { b: $root.*.b }>", + ); + assert_eq!( + spread_a_b + .compute_output_shape(&shape_context, root_shape) + .pretty_print(), + "{ b: { phonetic: \"bee\" }, phonetic: \"ay\" }", + ); + } + + #[test] + fn test_spread_match_none() { + let spec = ConnectSpec::V0_3; + + let sel = selection!( + "before ...condition->match([true, { matched: true }]) after", + spec + ); + assert_eq!( + sel.shape().pretty_print(), + "One<{ after: $root.*.after, before: $root.*.before, matched: true }, { after: $root.*.after, before: $root.*.before }>", + ); + + assert_eq!( + sel.apply_to(&json!({ + "before": "before value", + "after": "after value", + "condition": true, + })), + ( + Some(json!({ + "before": "before value", + "after": "after value", + "matched": true, + })), + vec![], + ), + ); + + assert_eq!( + sel.apply_to(&json!({ + "before": "before value", + "after": "after value", + "condition": false, + })), + ( + Some(json!({ + "before": "before value", + "after": "after value", + })), + vec![ + ApplyToError::new( + "Method ->match did not match any [candidate, value] pair".to_string(), + vec![json!("condition"), json!("->match")], + Some(21..53), + spec, + ), + ApplyToError::new( + "Inlined path produced no value".to_string(), + vec![], + Some(10..53), + spec, + ) + ], + ), + ); + } + + #[cfg(test)] + mod spread_with_match { + use crate::connectors::ConnectSpec; + use crate::connectors::JSONSelection; + use crate::selection; + + pub(super) fn get_selection(spec: ConnectSpec) -> JSONSelection { + let sel = selection!( + r#" + upc + ... type->match( + ["book", { + __typename: "Book", + title: title, + author: { name: author.name }, + }], + ["movie", { + __typename: "Movie", + title: title, + director: director.name, + }], + ["magazine", { + __typename: "Magazine", + title: title, + editor: editor.name, + }], + ["dummy", {}], + [@, null], + ) + "#, + spec + ); + + assert_eq!( + sel.shape().pretty_print(), + // An upcoming Shape library update should improve the readability + // of this pretty printing considerably. + "One<{ __typename: \"Book\", author: { name: $root.*.author.name }, title: $root.*.title, upc: $root.*.upc }, { __typename: \"Movie\", director: $root.*.director.name, title: $root.*.title, upc: $root.*.upc }, { __typename: \"Magazine\", editor: $root.*.editor.name, title: $root.*.title, upc: $root.*.upc }, { upc: $root.*.upc }, null>" + ); + + sel + } + } + + #[test] + fn test_spread_with_match_book() { + let spec = ConnectSpec::V0_3; + let sel = spread_with_match::get_selection(spec); + + let book_data = json!({ + "upc": "1234567890", + "type": "book", + "title": "The Great Gatsby", + "author": { "name": "F. Scott Fitzgerald" }, + }); + assert_eq!( + sel.apply_to(&book_data), + ( + Some(json!({ + "__typename": "Book", + "upc": "1234567890", + "title": "The Great Gatsby", + "author": { "name": "F. Scott Fitzgerald" }, + })), + vec![], + ), + ); + } + + #[test] + fn test_spread_with_match_movie() { + let spec = ConnectSpec::V0_3; + let sel = spread_with_match::get_selection(spec); + + let movie_data = json!({ + "upc": "0987654321", + "type": "movie", + "title": "Inception", + "director": { "name": "Christopher Nolan" }, + }); + assert_eq!( + sel.apply_to(&movie_data), + ( + Some(json!({ + "__typename": "Movie", + "upc": "0987654321", + "title": "Inception", + "director": "Christopher Nolan", + })), + vec![], + ), + ); + } + + #[test] + fn test_spread_with_match_magazine() { + let spec = ConnectSpec::V0_3; + let sel = spread_with_match::get_selection(spec); + + let magazine_data = json!({ + "upc": "1122334455", + "type": "magazine", + "title": "National Geographic", + "editor": { "name": "Susan Goldberg" }, + }); + assert_eq!( + sel.apply_to(&magazine_data), + ( + Some(json!({ + "__typename": "Magazine", + "upc": "1122334455", + "title": "National Geographic", + "editor": "Susan Goldberg", + })), + vec![], + ), + ); + } + + #[test] + fn test_spread_with_match_dummy() { + let spec = ConnectSpec::V0_3; + let sel = spread_with_match::get_selection(spec); + + let dummy_data = json!({ + "upc": "5566778899", + "type": "dummy", + }); + assert_eq!( + sel.apply_to(&dummy_data), + ( + Some(json!({ + "upc": "5566778899", + })), + vec![], + ), + ); + } + + #[test] + fn test_spread_with_match_unknown() { + let spec = ConnectSpec::V0_3; + let sel = spread_with_match::get_selection(spec); + + let unknown_data = json!({ + "upc": "9988776655", + "type": "music", + "title": "The White Stripes", + "artist": { "name": "Jack White" }, + }); + assert_eq!(sel.apply_to(&unknown_data), (Some(json!(null)), vec![])); + } + + #[test] + fn test_spread_null() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("...$(null)", spec).apply_to(&json!({ "ignored": "data" })), + (Some(json!(null)), vec![]), + ); + assert_eq!( + selection!("ignored ...$(null)", spec).apply_to(&json!({ "ignored": "data" })), + (Some(json!(null)), vec![]), + ); + assert_eq!( + selection!("...$(null) ignored", spec).apply_to(&json!({ "ignored": "data" })), + (Some(json!(null)), vec![]), + ); + assert_eq!( + selection!("group: { a ...b }", spec).apply_to(&json!({ "a": "ay", "b": null })), + (Some(json!({ "group": null })), vec![]), + ); + } + + #[test] + fn test_spread_missing() { + let spec = ConnectSpec::V0_3; + + assert_eq!( + selection!("a ...missing z", spec).apply_to(&json!({ "a": "ay", "z": "zee" })), + ( + Some(json!({ + "a": "ay", + "z": "zee", + })), + vec![ + ApplyToError::new( + "Property .missing not found in object".to_string(), + vec![json!("missing")], + Some(5..12), + spec, + ), + ApplyToError::new( + "Inlined path produced no value".to_string(), + vec![], + Some(5..12), + spec, + ), + ], + ), + ); + + assert_eq!( + selection!("a ...$(missing) z", spec).apply_to(&json!({ "a": "ay", "z": "zee" })), + ( + Some(json!({ + "a": "ay", + "z": "zee", + })), + vec![ + ApplyToError::new( + "Property .missing not found in object".to_string(), + vec![json!("missing")], + Some(7..14), + spec, + ), + ApplyToError::new( + "Inlined path produced no value".to_string(), + vec![], + Some(5..15), + spec, + ), + ], + ), + ); + } + + #[test] + fn test_spread_invalid_numbers() { + let spec = ConnectSpec::V0_3; + + assert_eq!( + selection!("...invalid", spec).apply_to(&json!({ "invalid": 123 })), + ( + Some(json!({})), + vec![ApplyToError::new( + "Expected object or null, not number".to_string(), + vec![], + Some(3..10), + spec, + )], + ), + ); + + assert_eq!( + selection!(" ... $( invalid ) ", spec).apply_to(&json!({ "invalid": 234 })), + ( + Some(json!({})), + vec![ApplyToError::new( + "Expected object or null, not number".to_string(), + vec![], + Some(5..17), + spec, + )], + ), + ); + } + + #[test] + fn test_spread_invalid_bools() { + let spec = ConnectSpec::V0_3; + + assert_eq!( + selection!("...invalid", spec).apply_to(&json!({ "invalid": true })), + ( + Some(json!({})), + vec![ApplyToError::new( + "Expected object or null, not boolean".to_string(), + vec![], + Some(3..10), + spec, + )], + ), + ); + + assert_eq!( + selection!("...$(invalid)", spec).apply_to(&json!({ "invalid": false })), + ( + Some(json!({})), + vec![ApplyToError::new( + "Expected object or null, not boolean".to_string(), + vec![], + Some(3..13), + spec, + )], + ), + ); + } + + #[test] + fn test_spread_invalid_strings() { + let spec = ConnectSpec::V0_3; + + assert_eq!( + selection!("...invalid", spec).apply_to(&json!({ "invalid": "string" })), + ( + Some(json!({})), + vec![ApplyToError::new( + "Expected object or null, not string".to_string(), + vec![], + Some(3..10), + spec, + )], + ), + ); + + assert_eq!( + selection!("...$(invalid)", spec).apply_to(&json!({ "invalid": "string" })), + ( + Some(json!({})), + vec![ApplyToError::new( + "Expected object or null, not string".to_string(), + vec![], + Some(3..13), + spec, + )], + ), + ); + } + + #[test] + fn test_spread_invalid_arrays() { + let spec = ConnectSpec::V0_3; + + // The ... operator only works for objects for now, as it spreads their + // keys into some larger object. We may support array spreading in the + // future, but it will probably work somewhat differently (it may be + // available only within literal expressions, for example). + assert_eq!( + selection!("...invalid", spec).apply_to(&json!({ "invalid": [1, 2, 3] })), + ( + Some(json!({})), + vec![ApplyToError::new( + "Expected object or null, not array".to_string(), + vec![], + Some(3..10), + spec, + )], + ), + ); + + assert_eq!( + selection!("...$(invalid)", spec).apply_to(&json!({ "invalid": [] })), + ( + Some(json!({})), + vec![ApplyToError::new( + "Expected object or null, not array".to_string(), + vec![], + Some(3..13), + spec, + )], + ), + ); + } + + #[test] + fn test_spread_output_shapes() { + let spec = ConnectSpec::V0_3; + + assert_eq!(selection!("...a", spec).shape().pretty_print(), "$root.*.a"); + assert_eq!( + selection!("...$(a)", spec).shape().pretty_print(), + "$root.*.a", + ); + + assert_eq!( + selection!("a ...b", spec).shape().pretty_print(), + "All<$root.*.b, { a: $root.*.a }>", + ); + assert_eq!( + selection!("a ...$(b)", spec).shape().pretty_print(), + "All<$root.*.b, { a: $root.*.a }>", + ); + + assert_eq!( + selection!("a ...b c", spec).shape().pretty_print(), + "All<$root.*.b, { a: $root.*.a, c: $root.*.c }>", + ); + assert_eq!( + selection!("a ...$(b) c", spec).shape().pretty_print(), + "All<$root.*.b, { a: $root.*.a, c: $root.*.c }>", + ); + } + + #[test] + fn null_coalescing_should_return_left_when_left_not_null() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$('Foo' ?? 'Bar')", spec).apply_to(&json!({})), + (Some(json!("Foo")), vec![]), + ); + } + + #[test] + fn null_coalescing_should_return_right_when_left_is_null() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$(null ?? 'Bar')", spec).apply_to(&json!({})), + (Some(json!("Bar")), vec![]), + ); + } + + #[test] + fn none_coalescing_should_return_left_when_left_not_none() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$('Foo' ?! 'Bar')", spec).apply_to(&json!({})), + (Some(json!("Foo")), vec![]), + ); + } + + #[test] + fn none_coalescing_should_preserve_null_when_left_is_null() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$(null ?! 'Bar')", spec).apply_to(&json!({})), + (Some(json!(null)), vec![]), + ); + } + + #[test] + fn nullish_coalescing_should_return_final_null() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$(missing ?? null)", spec).apply_to(&json!({})), + (Some(json!(null)), vec![]), + ); + assert_eq!( + selection!("$(missing ?! null)", spec).apply_to(&json!({})), + (Some(json!(null)), vec![]), + ); + } + + #[test] + fn nullish_coalescing_should_return_final_none() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$(missing ?? also_missing)", spec).apply_to(&json!({})), + ( + None, + vec![ + ApplyToError::new( + "Property .missing not found in object".to_string(), + vec![json!("missing")], + Some(2..9), + spec, + ), + ApplyToError::new( + "Property .also_missing not found in object".to_string(), + vec![json!("also_missing")], + Some(13..25), + spec, + ), + ] + ), + ); + assert_eq!( + selection!("maybe: $(missing ?! also_missing)", spec).apply_to(&json!({})), + ( + Some(json!({})), + vec![ + ApplyToError::new( + "Property .missing not found in object".to_string(), + vec![json!("missing")], + Some(9..16), + spec, + ), + ApplyToError::new( + "Property .also_missing not found in object".to_string(), + vec![json!("also_missing")], + Some(20..32), + spec, + ), + ] + ), + ); + } + + #[test] + fn coalescing_operators_should_return_earlier_values_if_later_missing() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$(1234 ?? missing)", spec).apply_to(&json!({})), + (Some(json!(1234)), vec![]), + ); + assert_eq!( + selection!("$(item ?? missing)", spec).apply_to(&json!({ "item": 1234 })), + (Some(json!(1234)), vec![]), + ); + assert_eq!( + selection!("$(item ?? missing)", spec).apply_to(&json!({ "item": null })), + ( + None, + vec![ApplyToError::new( + "Property .missing not found in object".to_string(), + vec![json!("missing")], + Some(10..17), + spec, + )] + ), + ); + assert_eq!( + selection!("$(null ?! missing)", spec).apply_to(&json!({})), + (Some(json!(null)), vec![]), + ); + assert_eq!( + selection!("$(item ?! missing)", spec).apply_to(&json!({ "item": null })), + (Some(json!(null)), vec![]), + ); + } + + #[test] + fn null_coalescing_should_chain_left_to_right_when_multiple_nulls() { + // TODO: TEST HERE + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$(null ?? null ?? 'Bar')", spec).apply_to(&json!({})), + (Some(json!("Bar")), vec![]), + ); + } + + #[test] + fn null_coalescing_should_stop_at_first_non_null_when_chaining() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$('Foo' ?? null ?? 'Bar')", spec).apply_to(&json!({})), + (Some(json!("Foo")), vec![]), + ); + } + + #[test] + fn null_coalescing_should_fallback_when_field_is_null() { + let spec = ConnectSpec::V0_3; + let data = json!({"field1": null, "field2": "value2"}); + assert_eq!( + selection!("$($.field1 ?? $.field2)", spec).apply_to(&data), + (Some(json!("value2")), vec![]), + ); + } + + #[test] + fn null_coalescing_should_use_literal_fallback_when_all_fields_null() { + let spec = ConnectSpec::V0_3; + let data = json!({"field1": null, "field3": null}); + assert_eq!( + selection!("$($.field1 ?? $.field3 ?? 'fallback')", spec).apply_to(&data), + (Some(json!("fallback")), vec![]), + ); + } + + #[test] + fn none_coalescing_should_preserve_null_field() { + let spec = ConnectSpec::V0_3; + let data = json!({"nullField": null}); + assert_eq!( + selection!("$($.nullField ?! 'fallback')", spec).apply_to(&data), + (Some(json!(null)), vec![]), + ); + } + + #[test] + fn none_coalescing_should_replace_missing_field() { + let spec = ConnectSpec::V0_3; + let data = json!({"nullField": null}); + assert_eq!( + selection!("$($.missingField ?! 'fallback')", spec).apply_to(&data), + (Some(json!("fallback")), vec![]), + ); + } + + #[test] + fn null_coalescing_should_replace_null_field() { + let spec = ConnectSpec::V0_3; + let data = json!({"nullField": null}); + assert_eq!( + selection!("$($.nullField ?? 'fallback')", spec).apply_to(&data), + (Some(json!("fallback")), vec![]), + ); + } + + #[test] + fn null_coalescing_should_replace_missing_field() { + let spec = ConnectSpec::V0_3; + let data = json!({"nullField": null}); + assert_eq!( + selection!("$($.missingField ?? 'fallback')", spec).apply_to(&data), + (Some(json!("fallback")), vec![]), + ); + } + + #[test] + fn null_coalescing_should_preserve_number_type() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$(null ?? 42)", spec).apply_to(&json!({})), + (Some(json!(42)), vec![]), + ); + } + + #[test] + fn null_coalescing_should_preserve_boolean_type() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$(null ?? true)", spec).apply_to(&json!({})), + (Some(json!(true)), vec![]), + ); + } + + #[test] + fn null_coalescing_should_preserve_object_type() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$(null ?? {'key': 'value'})", spec).apply_to(&json!({})), + (Some(json!({"key": "value"})), vec![]), + ); + } + + #[test] + fn null_coalescing_should_preserve_array_type() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$(null ?? [1, 2, 3])", spec).apply_to(&json!({})), + (Some(json!([1, 2, 3])), vec![]), + ); + } + + #[test] + fn null_coalescing_should_fallback_when_null_used_as_method_arg() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$.a->add(b ?? c)", spec).apply_to(&json!({"a": 5, "b": null, "c": 5})), + (Some(json!(10)), vec![]), + ); + } + + #[test] + fn null_coalescing_should_fallback_when_none_used_as_method_arg() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$.a->add(missing ?? c)", spec) + .apply_to(&json!({"a": 5, "b": null, "c": 5})), + (Some(json!(10)), vec![]), + ); + } + + #[test] + fn null_coalescing_should_not_fallback_when_not_null_used_as_method_arg() { + let spec = ConnectSpec::V0_3; + assert_eq!( + selection!("$.a->add(b ?? c)", spec).apply_to(&json!({"a": 5, "b": 3, "c": 5})), + (Some(json!(8)), vec![]), + ); + } + + #[test] + fn null_coalescing_should_allow_multiple_method_args() { + let spec = ConnectSpec::V0_3; + let add_selection = selection!("a->add(b ?? c, missing ?! c)", spec); + assert_eq!( + add_selection.apply_to(&json!({ "a": 5, "b": 3, "c": 7 })), + (Some(json!(15)), vec![]), + ); + assert_eq!( + add_selection.apply_to(&json!({ "a": 5, "b": null, "c": 7 })), + (Some(json!(19)), vec![]), + ); + } + + #[test] + fn none_coalescing_should_allow_defaulting_match() { + let spec = ConnectSpec::V0_3; + + assert_eq!( + selection!("a ...b->match(['match', { b: 'world' }])", spec) + .apply_to(&json!({ "a": "hello", "b": "match" })), + (Some(json!({ "a": "hello", "b": "world" })), vec![]), + ); + + assert_eq!( + selection!("a ...$(b->match(['match', { b: 'world' }]) ?? {})", spec) + .apply_to(&json!({ "a": "hello", "b": "match" })), + (Some(json!({ "a": "hello", "b": "world" })), vec![]), + ); + + assert_eq!( + selection!("a ...$(b->match(['match', { b: 'world' }]) ?? {})", spec) + .apply_to(&json!({ "a": "hello", "b": "bogus" })), + (Some(json!({ "a": "hello" })), vec![]), + ); + + assert_eq!( + selection!("a ...$(b->match(['match', { b: 'world' }]) ?! null)", spec) + .apply_to(&json!({ "a": "hello", "b": "bogus" })), + (Some(json!(null)), vec![]), + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/fixtures.rs b/apollo-federation/src/connectors/json_selection/fixtures.rs new file mode 100644 index 0000000000..767698c759 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/fixtures.rs @@ -0,0 +1,30 @@ +use std::fmt::Display; +use std::str::FromStr; + +/// A namespace used in tests to avoid dependencies on specific external namespaces +#[derive(Debug, PartialEq)] +pub(super) enum Namespace { + Args, + This, +} + +impl FromStr for Namespace { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "$args" => Ok(Self::Args), + "$this" => Ok(Self::This), + _ => Err(format!("Unknown variable namespace: {s}")), + } + } +} + +impl Display for Namespace { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Args => write!(f, "$args"), + Self::This => write!(f, "$this"), + } + } +} diff --git a/apollo-federation/src/connectors/json_selection/grammar/Alias.svg b/apollo-federation/src/connectors/json_selection/grammar/Alias.svg new file mode 100644 index 0000000000..386cb90fdd --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/Alias.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + Key + + + + : + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Alias.svg b/apollo-federation/src/connectors/json_selection/grammar/AtPath.svg similarity index 53% rename from apollo-federation/src/sources/connect/json_selection/grammar/Alias.svg rename to apollo-federation/src/connectors/json_selection/grammar/AtPath.svg index 5c2a8db39b..10aca24ad5 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/Alias.svg +++ b/apollo-federation/src/connectors/json_selection/grammar/AtPath.svg @@ -3,51 +3,51 @@ - - - - Identifier - - - + - : + @ + + + + PathTail + + d="m17 17 h2 m0 0 h10 m32 0 h10 m0 0 h10 m70 0 h10 m3 0 h-3"/> diff --git a/apollo-federation/src/connectors/json_selection/grammar/Comment.svg b/apollo-federation/src/connectors/json_selection/grammar/Comment.svg new file mode 100644 index 0000000000..24687f4236 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/Comment.svg @@ -0,0 +1,49 @@ + + + + + + + + + + # + + + [^\n] + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/ExprPath.svg b/apollo-federation/src/connectors/json_selection/grammar/ExprPath.svg new file mode 100644 index 0000000000..cc862dc7b4 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/ExprPath.svg @@ -0,0 +1,68 @@ + + + + + + + + + + $( + + + + LitExpr + + + + ) + + + + PathTail + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Identifier.svg b/apollo-federation/src/connectors/json_selection/grammar/Identifier.svg similarity index 85% rename from apollo-federation/src/sources/connect/json_selection/grammar/Identifier.svg rename to apollo-federation/src/connectors/json_selection/grammar/Identifier.svg index 03a7bb0abf..2e096eb115 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/Identifier.svg +++ b/apollo-federation/src/connectors/json_selection/grammar/Identifier.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NakedSubSelection.svg b/apollo-federation/src/connectors/json_selection/grammar/JSONSelection.svg similarity index 50% rename from apollo-federation/src/sources/connect/json_selection/grammar/NakedSubSelection.svg rename to apollo-federation/src/connectors/json_selection/grammar/JSONSelection.svg index c7ec2b04a5..53f8148270 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NakedSubSelection.svg +++ b/apollo-federation/src/connectors/json_selection/grammar/JSONSelection.svg @@ -1,32 +1,32 @@ - + @@ -38,15 +38,8 @@ NamedSelection - - - - StarSelection - - - + d="m17 51 h2 m20 0 h10 m0 0 h132 m-162 0 l20 0 m-1 0 q-9 0 -9 -10 l0 -14 q0 -10 10 -10 m142 34 l20 0 m-20 0 q10 0 10 -10 l0 -14 q0 -10 -10 -10 m-142 0 h10 m122 0 h10 m23 34 h-3"/> + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Key.svg b/apollo-federation/src/connectors/json_selection/grammar/Key.svg similarity index 51% rename from apollo-federation/src/sources/connect/json_selection/grammar/Key.svg rename to apollo-federation/src/connectors/json_selection/grammar/Key.svg index a41054011a..bd9802b224 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/Key.svg +++ b/apollo-federation/src/connectors/json_selection/grammar/Key.svg @@ -1,32 +1,32 @@ - + @@ -39,14 +39,14 @@ Identifier - - - StringLiteral + xlink:href="#LitString" + xlink:title="LitString"> + + + LitString - - + d="m17 17 h2 m20 0 h10 m78 0 h10 m-118 0 h20 m98 0 h20 m-138 0 q10 0 10 10 m118 0 q0 -10 10 -10 m-128 10 v24 m118 0 v-24 m-118 24 q0 10 10 10 m98 0 q10 0 10 -10 m-108 10 h10 m72 0 h10 m0 0 h6 m23 -44 h-3"/> + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/KeyPath.svg b/apollo-federation/src/connectors/json_selection/grammar/KeyPath.svg new file mode 100644 index 0000000000..5665756aa3 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/KeyPath.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + Key + + + + + PathTail + + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/LitArray.svg b/apollo-federation/src/connectors/json_selection/grammar/LitArray.svg new file mode 100644 index 0000000000..391e2907ff --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/LitArray.svg @@ -0,0 +1,77 @@ + + + + + + + + + + [ + + + + LitExpr + + + + , + + + , + + + ] + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/LitExpr.svg b/apollo-federation/src/connectors/json_selection/grammar/LitExpr.svg new file mode 100644 index 0000000000..57abc28ff2 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/LitExpr.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + LitOpChain + + + + + LitPath + + + + + LitPrimitive + + + + + LitObject + + + + + LitArray + + + + + PathSelection + + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/LitNumber.svg b/apollo-federation/src/connectors/json_selection/grammar/LitNumber.svg new file mode 100644 index 0000000000..4eb2cc458c --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/LitNumber.svg @@ -0,0 +1,71 @@ + + + + + + + + + + - + + + [0-9] + + + . + + + [0-9] + + + . + + + [0-9] + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/LitObject.svg b/apollo-federation/src/connectors/json_selection/grammar/LitObject.svg new file mode 100644 index 0000000000..67f5cc44fb --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/LitObject.svg @@ -0,0 +1,77 @@ + + + + + + + + + + { + + + + LitProperty + + + + , + + + , + + + } + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/LitOp.svg b/apollo-federation/src/connectors/json_selection/grammar/LitOp.svg new file mode 100644 index 0000000000..c3f83122f2 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/LitOp.svg @@ -0,0 +1,54 @@ + + + + + + + + + + ?? + + + ?! + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/LitOpChain.svg b/apollo-federation/src/connectors/json_selection/grammar/LitOpChain.svg new file mode 100644 index 0000000000..70edeb58fd --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/LitOpChain.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + LitExpr + + + + + LitOp + + + + + LitExpr + + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/LitPath.svg b/apollo-federation/src/connectors/json_selection/grammar/LitPath.svg new file mode 100644 index 0000000000..082252f9b3 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/LitPath.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + LitPrimitive + + + + + LitObject + + + + + LitArray + + + + + NonEmptyPathTail + + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/LitPrimitive.svg b/apollo-federation/src/connectors/json_selection/grammar/LitPrimitive.svg new file mode 100644 index 0000000000..c7ac6846e1 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/LitPrimitive.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + LitString + + + + + LitNumber + + + + true + + + false + + + null + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSProperty.svg b/apollo-federation/src/connectors/json_selection/grammar/LitProperty.svg similarity index 59% rename from apollo-federation/src/sources/connect/json_selection/grammar/JSProperty.svg rename to apollo-federation/src/connectors/json_selection/grammar/LitProperty.svg index 320035e1e4..f46525478b 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSProperty.svg +++ b/apollo-federation/src/connectors/json_selection/grammar/LitProperty.svg @@ -1,32 +1,32 @@ - + @@ -47,14 +47,14 @@ rx="10"/> : - - - JSLiteral + xlink:href="#LitExpr" + xlink:title="LitExpr"> + + + LitExpr - - + d="m17 17 h2 m0 0 h10 m42 0 h10 m0 0 h10 m24 0 h10 m0 0 h10 m64 0 h10 m3 0 h-3"/> + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/LitString.svg b/apollo-federation/src/connectors/json_selection/grammar/LitString.svg new file mode 100644 index 0000000000..e58fb33e05 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/LitString.svg @@ -0,0 +1,92 @@ + + + + + + + + + + ' + + + \\' + + + [^'] + + + ' + + + " + + + \\" + + + [^"] + + + " + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/MethodArgs.svg b/apollo-federation/src/connectors/json_selection/grammar/MethodArgs.svg new file mode 100644 index 0000000000..de10e75c16 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/MethodArgs.svg @@ -0,0 +1,77 @@ + + + + + + + + + + ( + + + + LitExpr + + + + , + + + , + + + ) + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/NamedSelection.svg b/apollo-federation/src/connectors/json_selection/grammar/NamedSelection.svg new file mode 100644 index 0000000000..607f02912f --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/NamedSelection.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + Alias + + + + ... + + + + PathSelection + + + + + Alias + + + + + SubSelection + + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/NonEmptyPathTail.svg b/apollo-federation/src/connectors/json_selection/grammar/NonEmptyPathTail.svg new file mode 100644 index 0000000000..bec86b6d30 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/NonEmptyPathTail.svg @@ -0,0 +1,69 @@ + + + + + + + + + + ? + + + + PathStep + + + + ? + + + ? + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/PathSelection.svg b/apollo-federation/src/connectors/json_selection/grammar/PathSelection.svg new file mode 100644 index 0000000000..e396bafba0 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/PathSelection.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + Path + + + + + SubSelection + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/PathStep.svg b/apollo-federation/src/connectors/json_selection/grammar/PathStep.svg similarity index 82% rename from apollo-federation/src/sources/connect/json_selection/grammar/PathStep.svg rename to apollo-federation/src/connectors/json_selection/grammar/PathStep.svg index 299a06d8cc..313830841d 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/PathStep.svg +++ b/apollo-federation/src/connectors/json_selection/grammar/PathStep.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/connectors/json_selection/grammar/PathTail.svg b/apollo-federation/src/connectors/json_selection/grammar/PathTail.svg new file mode 100644 index 0000000000..2b8fed4ca8 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/PathTail.svg @@ -0,0 +1,53 @@ + + + + + + + + + + ? + + + + PathStep + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Spaces.svg b/apollo-federation/src/connectors/json_selection/grammar/Spaces.svg similarity index 80% rename from apollo-federation/src/sources/connect/json_selection/grammar/Spaces.svg rename to apollo-federation/src/connectors/json_selection/grammar/Spaces.svg index a08f826870..3dc54e66f5 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/Spaces.svg +++ b/apollo-federation/src/connectors/json_selection/grammar/Spaces.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/SpacesOrComments.svg b/apollo-federation/src/connectors/json_selection/grammar/SpacesOrComments.svg similarity index 76% rename from apollo-federation/src/sources/connect/json_selection/grammar/SpacesOrComments.svg rename to apollo-federation/src/connectors/json_selection/grammar/SpacesOrComments.svg index 2e9c815c1d..74299b7733 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/SpacesOrComments.svg +++ b/apollo-federation/src/connectors/json_selection/grammar/SpacesOrComments.svg @@ -3,30 +3,30 @@ diff --git a/apollo-federation/src/connectors/json_selection/grammar/SubSelection.svg b/apollo-federation/src/connectors/json_selection/grammar/SubSelection.svg new file mode 100644 index 0000000000..6c912b7439 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/SubSelection.svg @@ -0,0 +1,61 @@ + + + + + + + + + + { + + + + NamedSelection + + + + } + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/VarPath.svg b/apollo-federation/src/connectors/json_selection/grammar/VarPath.svg new file mode 100644 index 0000000000..50bb220059 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/VarPath.svg @@ -0,0 +1,67 @@ + + + + + + + + + + $ + + + + NO_SPACE + + + + + Identifier + + + + + PathTail + + + + + diff --git a/apollo-federation/src/connectors/json_selection/grammar/rr-2.5.svg b/apollo-federation/src/connectors/json_selection/grammar/rr-2.5.svg new file mode 100644 index 0000000000..9d1e83c742 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/grammar/rr-2.5.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + R + R + + diff --git a/apollo-federation/src/connectors/json_selection/helpers.rs b/apollo-federation/src/connectors/json_selection/helpers.rs new file mode 100644 index 0000000000..02e10cf968 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/helpers.rs @@ -0,0 +1,343 @@ +use apollo_compiler::collections::IndexSet; +use nom::Slice; +use nom::character::complete::multispace0; +use serde_json_bytes::Map as JSONMap; +use serde_json_bytes::Value as JSON; + +use super::ParseResult; +use super::is_identifier; +use super::location::Span; +use super::location::WithRange; + +// This macro is handy for tests, but it absolutely should never be used with +// dynamic input at runtime, since it panics if the selection string fails to +// parse for any reason. +#[cfg(test)] +#[macro_export] +macro_rules! selection { + ($input:expr) => { + match $crate::connectors::json_selection::JSONSelection::parse($input) { + Ok(parsed) => parsed, + Err(error) => { + panic!("invalid selection: {:?}, Reason: {:?}", $input, error); + } + } + }; + ($input:expr, $spec:expr) => { + match $crate::connectors::json_selection::JSONSelection::parse_with_spec($input, $spec) { + Ok(parsed) => parsed, + Err(error) => { + panic!("invalid selection: {:?}, Reason: {:?}", $input, error); + } + } + }; +} + +// Consumes any amount of whitespace and/or comments starting with # until the +// end of the line. +pub(crate) fn spaces_or_comments(input: Span<'_>) -> ParseResult<'_, WithRange<&str>> { + let mut suffix = input.clone(); + loop { + let mut made_progress = false; + let suffix_and_spaces = multispace0(suffix)?; + suffix = suffix_and_spaces.0; + if !suffix_and_spaces.1.fragment().is_empty() { + made_progress = true; + } + let suffix_len = suffix.fragment().len(); + if suffix.fragment().starts_with('#') { + if let Some(newline) = suffix.fragment().find('\n') { + suffix = suffix.slice(newline + 1..); + } else { + suffix = suffix.slice(suffix_len..); + } + made_progress = true; + } + if !made_progress { + let end_of_slice = input.fragment().len() - suffix_len; + let start = input.location_offset(); + let end = suffix.location_offset(); + return Ok(( + suffix, + WithRange::new( + input.slice(0..end_of_slice).fragment(), + // The location of the parsed spaces and comments + Some(start..end), + ), + )); + } + } +} + +#[allow(unused)] +pub(crate) fn span_is_all_spaces_or_comments(input: Span) -> bool { + match spaces_or_comments(input) { + Ok((remainder, _)) => remainder.fragment().is_empty(), + _ => false, + } +} + +pub(crate) const fn json_type_name(v: &JSON) -> &str { + match v { + JSON::Array(_) => "array", + JSON::Object(_) => "object", + JSON::String(_) => "string", + JSON::Number(_) => "number", + JSON::Bool(_) => "boolean", + JSON::Null => "null", + } +} + +/// Provides a standard method to convert JSON to string. +/// Errors on arrays or objects because "stringigying" is not semantically the same as converting to a string. +/// null is returned as None but commonly, it gets converted to a blank string ("") +pub(crate) fn json_to_string(json: &JSON) -> Result, &'static str> { + match json { + JSON::Null => Ok(None), + JSON::Bool(b) => Ok(Some(b.to_string())), + JSON::Number(n) => Ok(Some(n.to_string())), + JSON::String(s) => Ok(Some(s.as_str().to_string())), + JSON::Array(_) | JSON::Object(_) => Err("cannot convert arrays or objects to strings."), + } +} + +pub(crate) fn vec_push(mut vec: Vec, item: T) -> Vec { + vec.push(item); + vec +} + +pub(crate) fn json_merge(a: Option<&JSON>, b: Option<&JSON>) -> (Option, Vec) { + match (a, b) { + (Some(JSON::Object(a)), Some(JSON::Object(b))) => { + let mut merged = JSONMap::new(); + let mut errors = Vec::new(); + + for key in IndexSet::from_iter(a.keys().chain(b.keys())) { + let (child_opt, child_errors) = json_merge(a.get(key), b.get(key)); + if let Some(child) = child_opt { + merged.insert(key.clone(), child); + } + errors.extend(child_errors); + } + + (Some(JSON::Object(merged)), errors) + } + + (Some(JSON::Array(a)), Some(JSON::Array(b))) => { + let max_len = a.len().max(b.len()); + let mut merged = Vec::with_capacity(max_len); + let mut errors = Vec::new(); + + for i in 0..max_len { + let (child_opt, child_errors) = json_merge(a.get(i), b.get(i)); + if let Some(child) = child_opt { + merged.push(child); + } + errors.extend(child_errors); + } + + (Some(JSON::Array(merged)), errors) + } + + (Some(JSON::Null), _) => (Some(JSON::Null), Vec::new()), + (_, Some(JSON::Null)) => (Some(JSON::Null), Vec::new()), + + (Some(a), Some(b)) => { + if a == b { + (Some(a.clone()), Vec::new()) + } else { + let json_type_of_a = json_type_name(a); + let json_type_of_b = json_type_name(b); + ( + Some(b.clone()), + if json_type_of_a == json_type_of_b { + Vec::new() + } else { + vec![format!( + "Lossy merge replacing {} with {}", + json_type_of_a, json_type_of_b + )] + }, + ) + } + } + + (None, Some(b)) => (Some(b.clone()), Vec::new()), + (Some(a), None) => (Some(a.clone()), Vec::new()), + (None, None) => (None, Vec::new()), + } +} + +pub(crate) fn quote_if_necessary(input: &str) -> String { + if is_identifier(input) + || ( + // We also allow unquoted variable syntax, including $, @, and + // $identifier. + input == "@" + || input.starts_with('$') && (input.len() == 1 || is_identifier(&input[1..])) + ) + { + input.to_string() + } else { + serde_json_bytes::Value::String(input.into()).to_string() + } +} + +/// A helper to call `assert_snapshot!` without prepending the module, since prepending the +/// module makes all the paths in json_selection tests too long for Windows. +#[cfg(test)] +#[macro_export] +macro_rules! assert_snapshot { + ($($arg:tt)*) => { + insta::with_settings!({prepend_module_to_snapshot => false}, { + insta::assert_snapshot!($($arg)*); + }); + }; +} + +/// A helper to call `assert_debug_snapshot!` without prepending the module, since prepending the +/// module makes all the paths in json_selection tests too long for Windows. +#[cfg(test)] +#[macro_export] +macro_rules! assert_debug_snapshot { + ($($arg:tt)*) => { + insta::with_settings!({prepend_module_to_snapshot => false}, { + insta::assert_debug_snapshot!($($arg)*); + }); + }; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::connectors::json_selection::is_identifier; + use crate::connectors::json_selection::location::new_span; + + #[test] + fn test_spaces_or_comments() { + fn check(input: &str, (exp_remainder, exp_spaces): (&str, &str)) { + match spaces_or_comments(new_span(input)) { + Ok((remainder, parsed)) => { + assert_eq!(*remainder.fragment(), exp_remainder); + assert_eq!(*parsed.as_ref(), exp_spaces); + } + Err(e) => panic!("error: {e:?}"), + } + } + + check("", ("", "")); + check(" ", ("", " ")); + check(" ", ("", " ")); + + check("#", ("", "#")); + check("# ", ("", "# ")); + check(" # ", ("", " # ")); + check(" #", ("", " #")); + + check("#\n", ("", "#\n")); + check("# \n", ("", "# \n")); + check(" # \n", ("", " # \n")); + check(" #\n", ("", " #\n")); + check(" # \n ", ("", " # \n ")); + + check("hello", ("hello", "")); + check(" hello", ("hello", " ")); + check("hello ", ("hello ", "")); + check("hello#", ("hello#", "")); + check("hello #", ("hello #", "")); + check("hello # ", ("hello # ", "")); + check(" hello # ", ("hello # ", " ")); + check(" hello # world ", ("hello # world ", " ")); + + check("#comment", ("", "#comment")); + check(" #comment", ("", " #comment")); + check("#comment ", ("", "#comment ")); + check("#comment#", ("", "#comment#")); + check("#comment #", ("", "#comment #")); + check("#comment # ", ("", "#comment # ")); + check(" #comment # world ", ("", " #comment # world ")); + check(" # comment # world ", ("", " # comment # world ")); + + check( + " # comment\nnot a comment", + ("not a comment", " # comment\n"), + ); + check( + " # comment\nnot a comment\n", + ("not a comment\n", " # comment\n"), + ); + check( + "not a comment\n # comment\nasdf", + ("not a comment\n # comment\nasdf", ""), + ); + + #[rustfmt::skip] + check(" + # This is a comment + # And so is this + not a comment + ", ("not a comment + ", " + # This is a comment + # And so is this + ")); + + #[rustfmt::skip] + check(" + # This is a comment + not a comment + # Another comment + ", ("not a comment + # Another comment + ", " + # This is a comment + ")); + + #[rustfmt::skip] + check(" + not a comment + # This is a comment + # Another comment + ", ("not a comment + # This is a comment + # Another comment + ", " + ")); + } + + #[test] + fn test_is_identifier() { + assert!(is_identifier("hello")); + assert!(is_identifier("hello_world")); + assert!(is_identifier("hello_world_123")); + assert!(is_identifier("_hello_world")); + assert!(is_identifier("hello_world_")); + assert!(is_identifier("__hello_world")); + assert!(is_identifier("__hello_world__")); + assert!(!is_identifier("hello world")); + assert!(!is_identifier("hello-world")); + assert!(!is_identifier("123hello")); + assert!(!is_identifier("hello@world")); + assert!(!is_identifier("$hello")); + assert!(!is_identifier("hello$world")); + assert!(!is_identifier(" hello")); + assert!(!is_identifier("__hello_world ")); + assert!(!is_identifier(" hello_world_123 ")); + } + + #[test] + fn test_quote_if_necessary() { + assert_eq!(quote_if_necessary("hello"), "hello"); + assert_eq!(quote_if_necessary("hello world"), "\"hello world\""); + assert_eq!(quote_if_necessary("hello-world"), "\"hello-world\""); + assert_eq!(quote_if_necessary("123hello"), "\"123hello\""); + assert_eq!(quote_if_necessary("$"), "$"); + assert_eq!(quote_if_necessary("@"), "@"); + assert_eq!(quote_if_necessary("$hello"), "$hello"); + assert_eq!(quote_if_necessary("@asdf"), "\"@asdf\""); + assert_eq!(quote_if_necessary("as@df"), "\"as@df\""); + assert_eq!(quote_if_necessary("hello$world"), "\"hello$world\""); + assert_eq!(quote_if_necessary("hello world!"), "\"hello world!\""); + assert_eq!(quote_if_necessary("hello world!@#"), "\"hello world!@#\""); + } +} diff --git a/apollo-federation/src/connectors/json_selection/immutable.rs b/apollo-federation/src/connectors/json_selection/immutable.rs new file mode 100644 index 0000000000..62fe47007e --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/immutable.rs @@ -0,0 +1,56 @@ +use std::clone::Clone; +use std::rc::Rc; + +#[derive(Debug, Clone)] +pub(crate) struct InputPath { + path: Path, +} + +type Path = Option>>; + +#[derive(Debug, Clone)] +struct AppendPath { + prefix: Path, + last: T, +} + +impl InputPath { + pub(crate) const fn empty() -> Self { + Self { path: None } + } + + pub(crate) fn append(&self, last: T) -> Self { + Self { + path: Some(Rc::new(AppendPath { + prefix: self.path.clone(), + last, + })), + } + } + + pub(crate) fn to_vec(&self) -> Vec { + // This method needs to be iterative rather than recursive, to be + // consistent with the paranoia of the drop method. + let mut vec = Vec::new(); + let mut path = self.path.as_deref(); + while let Some(p) = path { + vec.push(p.last.clone()); + path = p.prefix.as_deref(); + } + vec.reverse(); + vec + } +} + +impl Drop for InputPath { + fn drop(&mut self) { + let mut path = self.path.take(); + while let Some(rc) = path { + if let Ok(mut p) = Rc::try_unwrap(rc) { + path = p.prefix.take(); + } else { + break; + } + } + } +} diff --git a/apollo-federation/src/connectors/json_selection/known_var.rs b/apollo-federation/src/connectors/json_selection/known_var.rs new file mode 100644 index 0000000000..76f228af6d --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/known_var.rs @@ -0,0 +1,44 @@ +#[cfg(test)] +use super::location::WithRange; + +#[derive(PartialEq, Eq, Clone, Hash)] +pub(crate) enum KnownVariable { + External(String), + Dollar, + AtSign, +} + +impl KnownVariable { + pub(crate) fn from_str(var_name: &str) -> Self { + match var_name { + "$" => Self::Dollar, + "@" => Self::AtSign, + s => Self::External(s.to_string()), + } + } + + pub(crate) fn as_str(&self) -> &str { + match self { + Self::External(namespace) => namespace.as_str(), + Self::Dollar => "$", + Self::AtSign => "@", + } + } + + #[cfg(test)] + pub(super) fn into_with_range(self) -> WithRange { + WithRange::new(self, None) + } +} + +impl std::fmt::Debug for KnownVariable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::fmt::Display for KnownVariable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} diff --git a/apollo-federation/src/connectors/json_selection/lit_expr.rs b/apollo-federation/src/connectors/json_selection/lit_expr.rs new file mode 100644 index 0000000000..e9a654435c --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/lit_expr.rs @@ -0,0 +1,1178 @@ +//! A LitExpr (short for LiteralExpression) is similar to a JSON value (or +//! serde_json::Value), with the addition of PathSelection as a possible leaf +//! value, so literal expressions passed to -> methods (via MethodArgs) can +//! incorporate dynamic $variable values in addition to the usual input data and +//! argument values. + +use apollo_compiler::collections::IndexMap; +use nom::branch::alt; +use nom::character::complete::char; +use nom::character::complete::one_of; +use nom::combinator::map; +use nom::combinator::opt; +use nom::combinator::recognize; +use nom::multi::many0; +use nom::multi::many1; +use nom::sequence::pair; +use nom::sequence::preceded; +use nom::sequence::tuple; + +use super::ExternalVarPaths; +use super::ParseResult; +use super::PathList; +use super::helpers::spaces_or_comments; +use super::location::Ranged; +use super::location::Span; +use super::location::WithRange; +use super::location::merge_ranges; +use super::location::ranged_span; +use super::nom_error_message; +use super::parser::Key; +use super::parser::PathSelection; +use super::parser::nom_fail_message; +use super::parser::parse_string_literal; +use crate::connectors::spec::ConnectSpec; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub(crate) enum LitExpr { + String(String), + Number(serde_json::Number), + Bool(bool), + Null, + Object(IndexMap, WithRange>), + Array(Vec>), + Path(PathSelection), + + // Whereas the LitExpr::Path variant wraps a PathSelection that obeys the + // parsing rules of the outer selection syntax (i.e. default JSONSelection + // syntax, not LitExpr syntax), this LitExpr::LitPath variant can be parsed + // only as part of a LitExpr, and allows the value at the root of the path + // to be any LitExpr literal expression, without needing a $(...) wrapper, + // allowing you to write "asdf"->slice(0, 2) when you're already in an + // expression parsing context, rather than $(asdf)->slice(0, 2). + // + // The WithRange argument is the root expression (never a + // LitExpr::Path), and the WithRange argument represents the rest + // of the path, which is never PathList::Empty, because that would mean the + // LitExpr could stand on its own, using one of the other variants. + LitPath(WithRange, WithRange), + + // Operator chains: A op B op C ... where all operators are the same type + // OpChain contains the operator type and a vector of operands + // For example: A ?? B ?? C becomes OpChain(NullishCoalescing, [A, B, C]) + OpChain(WithRange, Vec>), +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub(crate) enum LitOp { + NullishCoalescing, // ?? + NoneCoalescing, // ?! +} + +impl LitOp { + #[cfg(test)] + pub(super) fn into_with_range(self) -> WithRange { + WithRange::new(self, None) + } + + pub(super) fn as_str(&self) -> &str { + match self { + LitOp::NullishCoalescing => "??", + LitOp::NoneCoalescing => "?!", + } + } +} + +impl LitExpr { + // LitExpr ::= LitOpChain | LitPath | LitPrimitive | LitObject | LitArray | PathSelection + pub(crate) fn parse(input: Span) -> ParseResult> { + match input.extra.spec { + ConnectSpec::V0_1 | ConnectSpec::V0_2 => { + let (input, _) = spaces_or_comments(input)?; + Self::parse_primary(input) + } + ConnectSpec::V0_3 => Self::parse_with_operators(input), + } + } + + // Parse expressions with operator chains (no precedence since we forbid mixing operators) + fn parse_with_operators(input: Span) -> ParseResult> { + let (input, _) = spaces_or_comments(input)?; + + // Parse the left-hand side (primary expression) + let (mut input, left) = Self::parse_primary(input)?; + + // Track operators and operands for building OpChain + let mut current_op: Option> = None; + let mut operands = vec![left.clone()]; + + loop { + let (input_after_spaces, _) = spaces_or_comments(input.clone())?; + + // Try to parse a binary operator + if let Ok((suffix, op)) = Self::parse_binary_operator(input_after_spaces.clone()) { + // Check if we're starting a new operator chain or continuing an existing one + match current_op { + None => { + // Starting a new operator chain + current_op = Some(op); + } + Some(ref existing_op) if existing_op.as_ref() != op.as_ref() => { + // Operator mismatch - we cannot mix operators in a chain + // This breaks the chain, so we need to stop parsing here + let err = format!( + "Found mixed operators {} and {}. You can only chain operators of the same kind.", + existing_op.as_str(), + op.as_str(), + ); + return Err(nom_fail_message(input_after_spaces, err)); + } + Some(_) => { + // Same operator, continue the chain + } + } + + // Parse the right-hand side (with spaces) + let (suffix_with_spaces, _) = spaces_or_comments(suffix)?; + let (remainder, right) = Self::parse_primary(suffix_with_spaces)?; + + operands.push(right); + input = remainder; + } else { + break; + } + } + + // Build the final expression + let result = if let Some(op) = current_op { + let full_range = if operands.len() >= 2 { + merge_ranges( + operands.first().and_then(|o| o.range()), + operands.last().and_then(|o| o.range()), + ) + } else { + operands.first().and_then(|o| o.range()) + }; + WithRange::new(Self::OpChain(op, operands), full_range) + } else { + left + }; + + Ok((input, result)) + } + + fn parse_primary(input: Span) -> ParseResult> { + match alt((Self::parse_primitive, Self::parse_object, Self::parse_array))(input.clone()) { + Ok((suffix, initial_literal)) => { + // If we parsed an initial literal expression, it may be the + // entire result, but we also want to greedily parse one or more + // PathStep items that follow it, according to the rule + // + // LitPath ::= (LitPrimitive | LitObject | LitArray) PathStep+ + // + // This allows paths beginning with literal values without the + // initial $(...) expression wrapper, so you can write + // $(123->add(111)) instead of $($(123)->add(111)) when you're + // already in a LitExpr parsing context. + // + // We begin parsing the path at depth 1 rather than 0 because + // we've already parsed the initial literal at depth 0, so the + // subpath should obey the parsing rules for for depth > 0. + match PathList::parse_with_depth(suffix.clone(), 1) { + Ok((remainder, subpath)) => { + if matches!(subpath.as_ref(), PathList::Empty) { + return Ok((remainder, initial_literal)); + } + let full_range = merge_ranges(initial_literal.range(), subpath.range()); + Ok(( + remainder, + WithRange::new(Self::LitPath(initial_literal, subpath), full_range), + )) + } + // If we failed to parse a path, return initial_literal as-is. + Err(_) => Ok((suffix.clone(), initial_literal)), + } + } + + // If we failed to parse a primitive, object, or array, try parsing + // a PathSelection (which cannot be a LitPath). + Err(_) => PathSelection::parse(input.clone()).map(|(remainder, path)| { + let range = path.range(); + (remainder, WithRange::new(Self::Path(path), range)) + }), + } + } + + fn parse_binary_operator(input: Span) -> ParseResult> { + alt(( + map(ranged_span("??"), |qq| { + WithRange::new(LitOp::NullishCoalescing, qq.range()) + }), + map(ranged_span("?!"), |qq| { + WithRange::new(LitOp::NoneCoalescing, qq.range()) + }), + ))(input) + } + + // LitPrimitive ::= LitString | LitNumber | "true" | "false" | "null" + fn parse_primitive(input: Span) -> ParseResult> { + alt(( + map(parse_string_literal, |s| s.take_as(Self::String)), + Self::parse_number, + map(ranged_span("true"), |t| { + WithRange::new(Self::Bool(true), t.range()) + }), + map(ranged_span("false"), |f| { + WithRange::new(Self::Bool(false), f.range()) + }), + map(ranged_span("null"), |n| { + WithRange::new(Self::Null, n.range()) + }), + ))(input) + } + + // LitNumber ::= "-"? ([0-9]+ ("." [0-9]*)? | "." [0-9]+) + fn parse_number(input: Span) -> ParseResult> { + let (suffix, (_, neg, _, num)) = tuple(( + spaces_or_comments, + opt(ranged_span("-")), + spaces_or_comments, + alt(( + map( + pair( + recognize(many1(one_of("0123456789"))), + opt(tuple(( + spaces_or_comments, + ranged_span("."), + spaces_or_comments, + recognize(many0(one_of("0123456789"))), + ))), + ), + |(int, frac)| { + let int_range = Some( + int.location_offset()..int.location_offset() + int.fragment().len(), + ); + + let mut s = String::new(); + + // Remove leading zeros to avoid failing the stricter + // number.parse() below, but allow a single zero. + let mut int_chars_without_leading_zeros = + int.fragment().chars().skip_while(|c| *c == '0'); + if let Some(first_non_zero) = int_chars_without_leading_zeros.next() { + s.push(first_non_zero); + s.extend(int_chars_without_leading_zeros); + } else { + s.push('0'); + } + + let full_range = if let Some((_, dot, _, frac)) = frac { + let frac_range = merge_ranges( + dot.range(), + if frac.len() > 0 { + Some( + frac.location_offset() + ..frac.location_offset() + frac.fragment().len(), + ) + } else { + None + }, + ); + s.push('.'); + if frac.fragment().is_empty() { + s.push('0'); + } else { + s.push_str(frac.fragment()); + } + merge_ranges(int_range, frac_range) + } else { + int_range + }; + + WithRange::new(s, full_range) + }, + ), + map( + tuple(( + spaces_or_comments, + ranged_span("."), + spaces_or_comments, + recognize(many1(one_of("0123456789"))), + )), + |(_, dot, _, frac)| { + let frac_range = Some( + frac.location_offset()..frac.location_offset() + frac.fragment().len(), + ); + let full_range = merge_ranges(dot.range(), frac_range); + WithRange::new(format!("0.{}", frac.fragment()), full_range) + }, + ), + )), + ))(input.clone())?; + + let mut number = String::new(); + if neg.is_some() { + number.push('-'); + } + number.push_str(num.as_str()); + + number.parse().map(Self::Number).map_or_else( + |_| { + // CONSIDER USING THIS ERROR? now that we have access to them? + Err(nom_error_message( + input, + // We could include the faulty number in the error message, but + // it will also appear at the beginning of the input span. + "Failed to parse numeric literal", + )) + }, + |lit_number| { + Ok(( + suffix, + WithRange::new( + lit_number, + merge_ranges(neg.and_then(|n| n.range()), num.range()), + ), + )) + }, + ) + } + + // LitObject ::= "{" (LitProperty ("," LitProperty)* ","?)? "}" + fn parse_object(input: Span) -> ParseResult> { + let (input, _) = spaces_or_comments(input)?; + let (input, open_brace) = ranged_span("{")(input)?; + let (mut input, _) = spaces_or_comments(input)?; + + let mut output = IndexMap::default(); + + if let Ok((remainder, (key, value))) = Self::parse_property(input.clone()) { + output.insert(key, value); + input = remainder; + + while let Ok((remainder, _)) = tuple((spaces_or_comments, char(',')))(input.clone()) { + input = remainder; + if let Ok((remainder, (key, value))) = Self::parse_property(input.clone()) { + output.insert(key, value); + input = remainder; + } else { + break; + } + } + } + + let (input, _) = spaces_or_comments(input.clone())?; + let (input, close_brace) = ranged_span("}")(input)?; + + let range = merge_ranges(open_brace.range(), close_brace.range()); + Ok((input, WithRange::new(Self::Object(output), range))) + } + + // LitProperty ::= Key ":" LitExpr + fn parse_property(input: Span) -> ParseResult<(WithRange, WithRange)> { + tuple((Key::parse, spaces_or_comments, char(':'), Self::parse))(input) + .map(|(input, (key, _, _colon, value))| (input, (key, value))) + } + + // LitArray ::= "[" (LitExpr ("," LitExpr)* ","?)? "]" + fn parse_array(input: Span) -> ParseResult> { + tuple(( + spaces_or_comments, + ranged_span("["), + spaces_or_comments, + map( + opt(tuple(( + Self::parse, + many0(preceded( + tuple((spaces_or_comments, char(','))), + Self::parse, + )), + opt(tuple((spaces_or_comments, char(',')))), + ))), + |elements| { + let mut output = vec![]; + if let Some((first, rest, _trailing_comma)) = elements { + output.push(first); + output.extend(rest); + } + Self::Array(output) + }, + ), + spaces_or_comments, + ranged_span("]"), + ))(input) + .map(|(input, (_, open_bracket, _, output, _, close_bracket))| { + let range = merge_ranges(open_bracket.range(), close_bracket.range()); + (input, WithRange::new(output, range)) + }) + } + + #[cfg(test)] + pub(super) fn into_with_range(self) -> WithRange { + WithRange::new(self, None) + } + + #[allow(unused)] + pub(super) fn as_i64(&self) -> Option { + match self { + Self::Number(n) => n.as_i64(), + _ => None, + } + } +} + +impl ExternalVarPaths for LitExpr { + fn external_var_paths(&self) -> Vec<&PathSelection> { + let mut paths = vec![]; + match self { + Self::String(_) | Self::Number(_) | Self::Bool(_) | Self::Null => {} + Self::Object(map) => { + for value in map.values() { + paths.extend(value.external_var_paths()); + } + } + Self::Array(vec) => { + for value in vec { + paths.extend(value.external_var_paths()); + } + } + Self::Path(path) => { + paths.extend(path.external_var_paths()); + } + Self::LitPath(literal, subpath) => { + paths.extend(literal.external_var_paths()); + paths.extend(subpath.external_var_paths()); + } + Self::OpChain(_, operands) => { + for operand in operands { + paths.extend(operand.external_var_paths()); + } + } + } + paths + } +} + +#[cfg(test)] +mod tests { + use super::super::known_var::KnownVariable; + use super::super::location::strip_ranges::StripRanges; + use super::*; + use crate::connectors::json_selection::MethodArgs; + use crate::connectors::json_selection::PathList; + use crate::connectors::json_selection::PrettyPrintable; + use crate::connectors::json_selection::fixtures::Namespace; + use crate::connectors::json_selection::helpers::span_is_all_spaces_or_comments; + use crate::connectors::json_selection::location::new_span; + use crate::connectors::json_selection::location::new_span_with_spec; + use crate::connectors::spec::ConnectSpec; + + #[track_caller] + fn check_parse(input: &str, expected: LitExpr) { + match LitExpr::parse(new_span(input)) { + Ok((remainder, parsed)) => { + assert!(span_is_all_spaces_or_comments(remainder)); + assert_eq!(parsed.strip_ranges(), WithRange::new(expected, None)); + } + Err(e) => panic!("Failed to parse '{input}': {e:?}"), + }; + } + + #[test] + fn test_lit_expr_parse_primitives() { + check_parse("'hello'", LitExpr::String("hello".to_string())); + check_parse("\"hello\"", LitExpr::String("hello".to_string())); + check_parse(" 'hello' ", LitExpr::String("hello".to_string())); + check_parse(" \"hello\" ", LitExpr::String("hello".to_string())); + + check_parse("123", LitExpr::Number(serde_json::Number::from(123))); + check_parse("-123", LitExpr::Number(serde_json::Number::from(-123))); + check_parse(" - 123 ", LitExpr::Number(serde_json::Number::from(-123))); + check_parse( + "123.456", + LitExpr::Number(serde_json::Number::from_f64(123.456).unwrap()), + ); + check_parse( + ".456", + LitExpr::Number(serde_json::Number::from_f64(0.456).unwrap()), + ); + check_parse( + "-.456", + LitExpr::Number(serde_json::Number::from_f64(-0.456).unwrap()), + ); + check_parse( + "123.", + LitExpr::Number(serde_json::Number::from_f64(123.0).unwrap()), + ); + check_parse( + "-123.", + LitExpr::Number(serde_json::Number::from_f64(-123.0).unwrap()), + ); + check_parse("00", LitExpr::Number(serde_json::Number::from(0))); + check_parse( + "-00", + LitExpr::Number(serde_json::Number::from_f64(-0.0).unwrap()), + ); + check_parse("0", LitExpr::Number(serde_json::Number::from(0))); + check_parse( + "-0", + LitExpr::Number(serde_json::Number::from_f64(-0.0).unwrap()), + ); + check_parse(" 00 ", LitExpr::Number(serde_json::Number::from(0))); + check_parse(" 0 ", LitExpr::Number(serde_json::Number::from(0))); + check_parse( + " - 0 ", + LitExpr::Number(serde_json::Number::from_f64(-0.0).unwrap()), + ); + check_parse("001", LitExpr::Number(serde_json::Number::from(1))); + check_parse( + "00.1", + LitExpr::Number(serde_json::Number::from_f64(0.1).unwrap()), + ); + check_parse("0010", LitExpr::Number(serde_json::Number::from(10))); + check_parse( + "00.10", + LitExpr::Number(serde_json::Number::from_f64(0.1).unwrap()), + ); + check_parse("-001 ", LitExpr::Number(serde_json::Number::from(-1))); + check_parse( + "-00.1", + LitExpr::Number(serde_json::Number::from_f64(-0.1).unwrap()), + ); + check_parse(" - 0010 ", LitExpr::Number(serde_json::Number::from(-10))); + check_parse( + "- 00.10", + LitExpr::Number(serde_json::Number::from_f64(-0.1).unwrap()), + ); + check_parse( + "007.", + LitExpr::Number(serde_json::Number::from_f64(7.0).unwrap()), + ); + check_parse( + "-007.", + LitExpr::Number(serde_json::Number::from_f64(-7.0).unwrap()), + ); + + check_parse("true", LitExpr::Bool(true)); + check_parse(" true ", LitExpr::Bool(true)); + check_parse("false", LitExpr::Bool(false)); + check_parse(" false ", LitExpr::Bool(false)); + check_parse("null", LitExpr::Null); + check_parse(" null ", LitExpr::Null); + } + + #[test] + fn test_lit_expr_parse_objects() { + check_parse( + "{a: 1}", + LitExpr::Object({ + let mut map = IndexMap::default(); + map.insert( + Key::field("a").into_with_range(), + LitExpr::Number(serde_json::Number::from(1)).into_with_range(), + ); + map + }), + ); + + check_parse( + "{'a': 1}", + LitExpr::Object({ + let mut map = IndexMap::default(); + map.insert( + Key::quoted("a").into_with_range(), + LitExpr::Number(serde_json::Number::from(1)).into_with_range(), + ); + map + }), + ); + + { + fn make_expected(a_key: Key, b_key: Key) -> LitExpr { + let mut map = IndexMap::default(); + map.insert( + a_key.into_with_range(), + LitExpr::Number(serde_json::Number::from(1)).into_with_range(), + ); + map.insert( + b_key.into_with_range(), + LitExpr::Number(serde_json::Number::from(2)).into_with_range(), + ); + LitExpr::Object(map) + } + check_parse( + "{'a': 1, 'b': 2}", + make_expected(Key::quoted("a"), Key::quoted("b")), + ); + check_parse( + "{ a : 1, 'b': 2}", + make_expected(Key::field("a"), Key::quoted("b")), + ); + check_parse( + "{ a : 1, b: 2}", + make_expected(Key::field("a"), Key::field("b")), + ); + check_parse( + "{ \"a\" : 1, \"b\": 2 }", + make_expected(Key::quoted("a"), Key::quoted("b")), + ); + check_parse( + "{ \"a\" : 1, b: 2 }", + make_expected(Key::quoted("a"), Key::field("b")), + ); + check_parse( + "{ a : 1, \"b\": 2 }", + make_expected(Key::field("a"), Key::quoted("b")), + ); + } + } + + #[test] + fn test_lit_expr_parse_arrays() { + check_parse( + "[1, 2]", + LitExpr::Array(vec![ + WithRange::new(LitExpr::Number(serde_json::Number::from(1)), None), + WithRange::new(LitExpr::Number(serde_json::Number::from(2)), None), + ]), + ); + + check_parse( + "[1, true, 'three']", + LitExpr::Array(vec![ + WithRange::new(LitExpr::Number(serde_json::Number::from(1)), None), + WithRange::new(LitExpr::Bool(true), None), + WithRange::new(LitExpr::String("three".to_string()), None), + ]), + ); + } + + #[test] + fn test_lit_expr_parse_paths() { + { + let expected = LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("a").into_with_range(), + PathList::Key( + Key::field("b").into_with_range(), + PathList::Key( + Key::field("c").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }); + + check_parse("a.b.c", expected.clone()); + check_parse(" a . b . c ", expected); + } + + { + let expected = LitExpr::Path(PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("data").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }); + check_parse("$.data", expected.clone()); + check_parse(" $ . data ", expected); + } + + { + let expected = LitExpr::Array(vec![ + LitExpr::Path(PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("a").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }) + .into_with_range(), + LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("b").into_with_range(), + PathList::Key( + Key::field("c").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }) + .into_with_range(), + LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("d").into_with_range(), + PathList::Key( + Key::field("e").into_with_range(), + PathList::Key( + Key::field("f").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }) + .into_with_range(), + ]); + + check_parse("[$.a, b.c, d.e.f]", expected.clone()); + check_parse("[$.a, b.c, d.e.f,]", expected.clone()); + check_parse("[ $ . a , b . c , d . e . f ]", expected.clone()); + check_parse("[ $ . a , b . c , d . e . f , ]", expected.clone()); + check_parse( + r#"[ + $.a, + b.c, + d.e.f, + ]"#, + expected.clone(), + ); + check_parse( + r#"[ + $ . a , + b . c , + d . e . f , + ]"#, + expected, + ); + } + + { + let expected = LitExpr::Object({ + let mut map = IndexMap::default(); + map.insert( + Key::field("a").into_with_range(), + LitExpr::Path(PathSelection { + path: PathList::Var( + KnownVariable::External(Namespace::Args.to_string()).into_with_range(), + PathList::Key( + Key::field("a").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }) + .into_with_range(), + ); + map.insert( + Key::field("b").into_with_range(), + LitExpr::Path(PathSelection { + path: PathList::Var( + KnownVariable::External(Namespace::This.to_string()).into_with_range(), + PathList::Key( + Key::field("b").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }) + .into_with_range(), + ); + map + }); + + check_parse( + r#"{ + a: $args.a, + b: $this.b, + }"#, + expected.clone(), + ); + + check_parse( + r#"{ + b: $this.b, + a: $args.a, + }"#, + expected.clone(), + ); + + check_parse( + r#" { + a : $args . a , + b : $this . b + ,} "#, + expected, + ); + } + } + + #[test] + fn test_literal_methods() { + #[track_caller] + fn check_parse_and_print(input: &str, expected: LitExpr) { + let expected_inline = expected.pretty_print_with_indentation(true, 0); + match LitExpr::parse(new_span(input)) { + Ok((remainder, parsed)) => { + assert!(span_is_all_spaces_or_comments(remainder)); + assert_eq!(parsed.strip_ranges(), WithRange::new(expected, None)); + assert_eq!(parsed.pretty_print_with_indentation(true, 0), input); + assert_eq!(expected_inline, input); + } + Err(e) => panic!("Failed to parse '{input}': {e:?}"), + }; + } + + check_parse_and_print( + "$(\"a\")->first", + LitExpr::Path(PathSelection { + path: PathList::Expr( + LitExpr::String("a".to_string()).into_with_range(), + PathList::Method( + WithRange::new("first".to_string(), None), + None, + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }), + ); + + check_parse_and_print( + "$(\"a\"->first)", + LitExpr::Path(PathSelection { + path: PathList::Expr( + LitExpr::LitPath( + LitExpr::String("a".to_string()).into_with_range(), + PathList::Method( + WithRange::new("first".to_string(), None), + None, + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }), + ); + + check_parse_and_print( + "$(1234)->add(1111)", + LitExpr::Path(PathSelection { + path: PathList::Expr( + LitExpr::Number(serde_json::Number::from(1234)).into_with_range(), + PathList::Method( + WithRange::new("add".to_string(), None), + Some(MethodArgs { + args: vec![ + LitExpr::Number(serde_json::Number::from(1111)).into_with_range(), + ], + range: None, + }), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }), + ); + + check_parse_and_print( + "$(1234->add(1111))", + LitExpr::Path(PathSelection { + path: PathList::Expr( + LitExpr::LitPath( + LitExpr::Number(serde_json::Number::from(1234)).into_with_range(), + PathList::Method( + WithRange::new("add".to_string(), None), + Some(MethodArgs { + args: vec![ + LitExpr::Number(serde_json::Number::from(1111)) + .into_with_range(), + ], + range: None, + }), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }), + ); + + check_parse_and_print( + "$(value->mul(10))", + LitExpr::Path(PathSelection { + path: PathList::Expr( + LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("value").into_with_range(), + PathList::Method( + WithRange::new("mul".to_string(), None), + Some(MethodArgs { + args: vec![ + LitExpr::Number(serde_json::Number::from(10)) + .into_with_range(), + ], + range: None, + }), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }) + .into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }), + ); + + check_parse_and_print( + "$(value.key->typeof)", + LitExpr::Path(PathSelection { + path: PathList::Expr( + LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("value").into_with_range(), + PathList::Key( + Key::field("key").into_with_range(), + PathList::Method( + WithRange::new("typeof".to_string(), None), + None, + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }) + .into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }), + ); + + check_parse_and_print( + "$(value.key)->typeof", + LitExpr::Path(PathSelection { + path: PathList::Expr( + LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("value").into_with_range(), + PathList::Key( + Key::field("key").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }) + .into_with_range(), + PathList::Method( + WithRange::new("typeof".to_string(), None), + None, + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }), + ); + + check_parse_and_print( + "$([1, 2, 3])->last", + LitExpr::Path(PathSelection { + path: PathList::Expr( + LitExpr::Array(vec![ + LitExpr::Number(serde_json::Number::from(1)).into_with_range(), + LitExpr::Number(serde_json::Number::from(2)).into_with_range(), + LitExpr::Number(serde_json::Number::from(3)).into_with_range(), + ]) + .into_with_range(), + PathList::Method( + WithRange::new("last".to_string(), None), + None, + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }), + ); + + check_parse_and_print( + "$([1, 2, 3]->last)", + LitExpr::Path(PathSelection { + path: PathList::Expr( + LitExpr::LitPath( + LitExpr::Array(vec![ + LitExpr::Number(serde_json::Number::from(1)).into_with_range(), + LitExpr::Number(serde_json::Number::from(2)).into_with_range(), + LitExpr::Number(serde_json::Number::from(3)).into_with_range(), + ]) + .into_with_range(), + PathList::Method( + WithRange::new("last".to_string(), None), + None, + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }), + ); + + check_parse_and_print( + "$({ a: \"ay\", b: 1 }).a", + LitExpr::Path(PathSelection { + path: PathList::Expr( + LitExpr::Object({ + let mut map = IndexMap::default(); + map.insert( + Key::field("a").into_with_range(), + LitExpr::String("ay".to_string()).into_with_range(), + ); + map.insert( + Key::field("b").into_with_range(), + LitExpr::Number(serde_json::Number::from(1)).into_with_range(), + ); + map + }) + .into_with_range(), + PathList::Key( + Key::field("a").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }), + ); + + check_parse_and_print( + "$({ a: \"ay\", b: 2 }.a)", + LitExpr::Path(PathSelection { + path: PathList::Expr( + LitExpr::LitPath( + LitExpr::Object({ + let mut map = IndexMap::default(); + map.insert( + Key::field("a").into_with_range(), + LitExpr::String("ay".to_string()).into_with_range(), + ); + map.insert( + Key::field("b").into_with_range(), + LitExpr::Number(serde_json::Number::from(2)).into_with_range(), + ); + map + }) + .into_with_range(), + PathList::Key( + Key::field("a").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }), + ); + } + + #[test] + fn test_null_coalescing_operator_parsing() { + // Test basic parsing + check_parse_with_spec( + "null ?? 'Bar'", + ConnectSpec::V0_3, + LitExpr::OpChain( + LitOp::NullishCoalescing.into_with_range(), + vec![ + LitExpr::Null.into_with_range(), + LitExpr::String("Bar".to_string()).into_with_range(), + ], + ), + ); + + check_parse_with_spec( + "null ?! 'Bar'", + ConnectSpec::V0_3, + LitExpr::OpChain( + LitOp::NoneCoalescing.into_with_range(), + vec![ + LitExpr::Null.into_with_range(), + LitExpr::String("Bar".to_string()).into_with_range(), + ], + ), + ); + } + + #[test] + fn test_null_coalescing_chaining() { + // Test chaining: A ?? B ?? C should parse as OpChain(NullishCoalescing, [A, B, C]) + check_parse_with_spec( + "null ?? null ?? 'Bar'", + ConnectSpec::V0_3, + LitExpr::OpChain( + LitOp::NullishCoalescing.into_with_range(), + vec![ + LitExpr::Null.into_with_range(), + LitExpr::Null.into_with_range(), + LitExpr::String("Bar".to_string()).into_with_range(), + ], + ), + ); + } + + #[test] + fn test_operator_mixing_validation() { + // Test that mixing operators in a chain fails to parse + let result = LitExpr::parse(new_span_with_spec( + "null ?? 'foo' ?! 'bar'", + ConnectSpec::V0_3, + )); + + // Should fail with mixed operators error + let err = result.expect_err("Expected parse error for mixed operators ?? and ?!"); + + // Verify the error message contains information about mixed operators + let error_msg = format!("{err:?}"); + assert!( + error_msg.contains("Found mixed operators ?? and ?!"), + "Expected mixed operators error message, got: {error_msg}" + ); + } + + #[track_caller] + fn check_parse_with_spec(input: &str, spec: ConnectSpec, expected: LitExpr) { + match LitExpr::parse(new_span_with_spec(input, spec)) { + Ok((remainder, parsed)) => { + assert!(span_is_all_spaces_or_comments(remainder)); + assert_eq!(parsed.strip_ranges(), WithRange::new(expected, None)); + } + Err(e) => panic!("Failed to parse '{input}': {e:?}"), + } + } +} diff --git a/apollo-federation/src/connectors/json_selection/location.rs b/apollo-federation/src/connectors/json_selection/location.rs new file mode 100644 index 0000000000..2df65cd873 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/location.rs @@ -0,0 +1,397 @@ +use nom::bytes::complete::tag; +use nom::combinator::map; +use nom_locate::LocatedSpan; +use shape::location::Location; +use shape::location::SourceId; + +use super::ParseResult; +use crate::connectors::ConnectSpec; + +// Currently, all our error messages are &'static str, which allows the Span +// type to remain Copy, which is convenient to avoid having to clone Spans +// frequently in the parser code. +// +// If we wanted to introduce any error messages computed using format!, we'd +// have to switch to Option here (or some other type containing owned +// String data), which would make Span no longer Copy, requiring more cloning. +// Not the end of the world, but something to keep in mind for the future. +// +// The cloning would still be relatively cheap because we use None throughout +// parsing and then only set Some(message) when we need to report an error, so +// we would not be cloning long String messages very often (and the rest of the +// Span fields are cheap to clone). +pub(crate) type Span<'a> = LocatedSpan<&'a str, SpanExtra>; + +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub(crate) struct SpanExtra { + pub(super) spec: ConnectSpec, + pub(super) errors: Vec, +} + +#[cfg(test)] +pub(crate) fn new_span(input: &str) -> Span<'_> { + Span::new_extra( + input, + SpanExtra { + spec: super::JSONSelection::default_connect_spec(), + errors: Vec::new(), + }, + ) +} + +pub(crate) fn new_span_with_spec(input: &str, spec: ConnectSpec) -> Span<'_> { + Span::new_extra( + input, + SpanExtra { + spec, + errors: Vec::new(), + }, + ) +} + +pub(super) fn get_connect_spec(input: &Span) -> ConnectSpec { + input.extra.spec +} + +// Some parsed AST structures, like PathSelection and NamedSelection, can +// produce a range directly from their children, so they do not need to be +// wrapped as WithRange or WithRange. +// Additionally, AST nodes that are structs can store their own range as a +// field, so they can implement Ranged without the WithRange wrapper. +pub(crate) trait Ranged { + fn range(&self) -> OffsetRange; + + fn shape_location(&self, source_id: &SourceId) -> Option { + self.range().map(|range| source_id.location(range)) + } +} + +// The ranges produced by the JSONSelection parser are pairs of character +// offsets into the original string. The first element of the pair is the offset +// of the first character, and the second element is the offset of the character +// just past the end of the range. Offsets start at 0 for the first character in +// the file, following nom_locate's span.location_offset() convention. +pub(crate) type OffsetRange = Option>; + +// The most common implementation of the Ranged trait is the WithRange +// struct, used to wrap any AST node that (a) needs its own location information +// (because that information is not derivable from its children) and (b) cannot +// easily store that information by adding another struct field (most often +// because T is an enum or primitive/String type, not a struct). +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct WithRange { + node: Box, + range: OffsetRange, +} + +// We can recover some of the ergonomics of working with the inner type T by +// implementing Deref and DerefMut for WithRange. +impl std::ops::Deref for WithRange { + type Target = T; + + fn deref(&self) -> &Self::Target { + self.node.as_ref() + } +} +impl std::ops::DerefMut for WithRange { + fn deref_mut(&mut self) -> &mut Self::Target { + self.node.as_mut() + } +} + +impl AsRef for WithRange { + fn as_ref(&self) -> &T { + self.node.as_ref() + } +} + +impl AsMut for WithRange { + fn as_mut(&mut self) -> &mut T { + self.node.as_mut() + } +} + +impl PartialEq for WithRange +where + T: PartialEq, +{ + fn eq(&self, other: &T) -> bool { + self.node.as_ref() == other + } +} + +// Implement Hash if the inner type T implements Hash. +impl std::hash::Hash for WithRange { + fn hash(&self, state: &mut H) { + self.node.as_ref().hash(state) + } +} + +impl Ranged for WithRange { + fn range(&self) -> OffsetRange { + self.range.clone() + } +} + +impl WithRange { + pub(crate) fn new(node: T, range: OffsetRange) -> Self { + Self { + node: Box::new(node), + range, + } + } + + #[allow(unused)] + pub(crate) fn take(self) -> T { + *self.node + } + + pub(crate) fn take_as(self, f: impl FnOnce(T) -> U) -> WithRange { + WithRange::new(f(*self.node), self.range) + } +} + +pub(super) fn merge_ranges(left: OffsetRange, right: OffsetRange) -> OffsetRange { + match (left, right) { + // Tolerate out-of-order and overlapping ranges. + (Some(left_range), Some(right_range)) => { + Some(left_range.start.min(right_range.start)..left_range.end.max(right_range.end)) + } + (Some(left_range), None) => Some(left_range), + (None, Some(right_range)) => Some(right_range), + (None, None) => None, + } +} + +// Parser combinator that matches a &str and returns a WithRange<&str> with the +// matched string and the range of the match. +pub(super) fn ranged_span<'a, 'b: 'a>( + s: &'a str, +) -> impl FnMut(Span<'b>) -> ParseResult<'b, WithRange<&'b str>> { + map(tag(s), |t: Span<'b>| { + let start = t.location_offset(); + let range = Some(start..start + s.len()); + WithRange::new(*t.fragment(), range) + }) +} + +#[cfg(test)] +pub(crate) mod strip_ranges { + use apollo_compiler::collections::IndexMap; + + use super::super::known_var::KnownVariable; + use super::super::lit_expr::LitExpr; + use super::super::lit_expr::LitOp; + use super::super::parser::*; + use super::WithRange; + + /// Including location information in the AST introduces unnecessary + /// variation in many tests. StripLoc is a test-only trait allowing + /// participating AST nodes to remove their own and their descendants' + /// location information, thereby normalizing the AST for assert_eq! + /// comparisons. + pub(crate) trait StripRanges { + fn strip_ranges(&self) -> Self; + } + + impl StripRanges for WithRange { + fn strip_ranges(&self) -> Self { + WithRange::new(self.as_ref().clone(), None) + } + } + + impl StripRanges for WithRange { + fn strip_ranges(&self) -> Self { + WithRange::new(self.as_ref().clone(), None) + } + } + + impl StripRanges for JSONSelection { + fn strip_ranges(&self) -> Self { + match &self.inner { + TopLevelSelection::Named(subselect) => Self { + inner: TopLevelSelection::Named(subselect.strip_ranges()), + spec: self.spec, + }, + TopLevelSelection::Path(path) => Self { + inner: TopLevelSelection::Path(path.strip_ranges()), + spec: self.spec, + }, + } + } + } + + impl StripRanges for NamedSelection { + fn strip_ranges(&self) -> Self { + Self { + prefix: match &self.prefix { + NamingPrefix::None => NamingPrefix::None, + NamingPrefix::Alias(alias) => NamingPrefix::Alias(alias.strip_ranges()), + NamingPrefix::Spread(_) => NamingPrefix::Spread(None), + }, + path: self.path.strip_ranges(), + } + } + } + + impl StripRanges for PathSelection { + fn strip_ranges(&self) -> Self { + Self { + path: self.path.strip_ranges(), + } + } + } + + impl StripRanges for WithRange { + fn strip_ranges(&self) -> Self { + WithRange::new( + match self.as_ref() { + PathList::Var(var, rest) => { + PathList::Var(var.strip_ranges(), rest.strip_ranges()) + } + PathList::Key(key, rest) => { + PathList::Key(key.strip_ranges(), rest.strip_ranges()) + } + PathList::Expr(expr, rest) => { + PathList::Expr(expr.strip_ranges(), rest.strip_ranges()) + } + PathList::Method(method, opt_args, rest) => PathList::Method( + method.strip_ranges(), + opt_args.as_ref().map(|args| args.strip_ranges()), + rest.strip_ranges(), + ), + PathList::Question(tail) => PathList::Question(tail.strip_ranges()), + PathList::Selection(sub) => PathList::Selection(sub.strip_ranges()), + PathList::Empty => PathList::Empty, + }, + None, + ) + } + } + + impl StripRanges for SubSelection { + fn strip_ranges(&self) -> Self { + SubSelection { + selections: self.selections.iter().map(|s| s.strip_ranges()).collect(), + ..Default::default() + } + } + } + + impl StripRanges for Alias { + fn strip_ranges(&self) -> Self { + Alias { + name: self.name.strip_ranges(), + range: None, + } + } + } + + impl StripRanges for WithRange { + fn strip_ranges(&self) -> Self { + WithRange::new(self.as_ref().clone(), None) + } + } + + impl StripRanges for MethodArgs { + fn strip_ranges(&self) -> Self { + MethodArgs { + args: self.args.iter().map(|arg| arg.strip_ranges()).collect(), + range: None, + } + } + } + + impl StripRanges for WithRange { + fn strip_ranges(&self) -> Self { + WithRange::new( + match self.as_ref() { + LitExpr::String(s) => LitExpr::String(s.clone()), + LitExpr::Number(n) => LitExpr::Number(n.clone()), + LitExpr::Bool(b) => LitExpr::Bool(*b), + LitExpr::Null => LitExpr::Null, + LitExpr::Object(map) => { + let mut new_map = IndexMap::default(); + for (key, value) in map { + new_map.insert(key.strip_ranges(), value.strip_ranges()); + } + LitExpr::Object(new_map) + } + LitExpr::Array(vec) => { + let mut new_vec = vec![]; + for value in vec { + new_vec.push(value.strip_ranges()); + } + LitExpr::Array(new_vec) + } + LitExpr::Path(path) => LitExpr::Path(path.strip_ranges()), + LitExpr::LitPath(literal, subpath) => { + LitExpr::LitPath(literal.strip_ranges(), subpath.strip_ranges()) + } + LitExpr::OpChain(op, operands) => LitExpr::OpChain( + op.strip_ranges(), + operands + .iter() + .map(|operand| operand.strip_ranges()) + .collect(), + ), + }, + None, + ) + } + } + + impl StripRanges for WithRange { + fn strip_ranges(&self) -> Self { + WithRange::new(self.as_ref().clone(), None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::assert_debug_snapshot; + use crate::assert_snapshot; + use crate::connectors::JSONSelection; + + #[test] + fn test_merge_ranges() { + // Simple cases: + assert_eq!(merge_ranges(None, None), None); + assert_eq!(merge_ranges(Some(0..1), None), Some(0..1)); + assert_eq!(merge_ranges(None, Some(0..1)), Some(0..1)); + assert_eq!(merge_ranges(Some(0..1), Some(1..2)), Some(0..2)); + + // Out-of-order and overlapping ranges: + assert_eq!(merge_ranges(Some(1..2), Some(0..1)), Some(0..2)); + assert_eq!(merge_ranges(Some(0..1), Some(1..2)), Some(0..2)); + assert_eq!(merge_ranges(Some(0..2), Some(1..3)), Some(0..3)); + assert_eq!(merge_ranges(Some(1..3), Some(0..2)), Some(0..3)); + } + + #[test] + fn test_arrow_path_ranges() { + let parsed = JSONSelection::parse(" __typename: @ -> echo ( \"Frog\" , ) ").unwrap(); + assert_debug_snapshot!(parsed); + } + + #[test] + fn test_parse_with_range_snapshots() { + let parsed = JSONSelection::parse( + r#" + path: some.nested.path { isbn author { name }} + alias: "not an identifier" { + # Inject "Frog" as the __typename + __typename: @->echo( "Frog" , ) + wrapped: $->echo({ wrapped : @ , }) + group: { a b c } + arg: $args . arg + field + } + "#, + ) + .unwrap(); + assert_snapshot!(format!("{:#?}", parsed)); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods.rs b/apollo-federation/src/connectors/json_selection/methods.rs new file mode 100644 index 0000000000..39fa2ddd24 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods.rs @@ -0,0 +1,279 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use super::ApplyToError; +use super::MethodArgs; +use super::VarsWithPathsMap; +use super::immutable::InputPath; +use super::location::WithRange; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::spec::ConnectSpec; + +mod common; + +// Two kinds of methods: public ones and not-yet-public ones. The future ones +// have proposed implementations and tests, and some are even used within the +// tests of other methods, but are not yet exposed for use in connector schemas. +// Graduating to public status requires updated documentation, careful review, +// and team discussion to make sure the method is one we want to support +// long-term. Once we have a better story for checking method type signatures +// and versioning any behavioral changes, we should be able to expand/improve +// the list of public::* methods more quickly/confidently. +mod future; +mod public; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ArrowMethod { + // Public methods: + Echo, + Map, + Match, + First, + Last, + Slice, + Size, + Entries, + JsonStringify, + JoinNotNull, + Filter, + Find, + Gte, + Lte, + Eq, + Ne, + Or, + And, + Gt, + Lt, + Not, + In, + Contains, + Get, + ToString, + ParseInt, + Add, + Sub, + Mul, + Div, + Mod, + + // Future methods: + TypeOf, + MatchIf, + Has, + Keys, + Values, +} + +#[macro_export] +macro_rules! impl_arrow_method { + ($struct_name:ident, $impl_fn_name:ident, $shape_fn_name:ident) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) struct $struct_name; + impl $crate::connectors::json_selection::methods::ArrowMethodImpl for $struct_name { + fn apply( + &self, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: $crate::connectors::spec::ConnectSpec, + ) -> (Option, Vec) { + $impl_fn_name(method_name, method_args, data, vars, input_path, spec) + } + + fn shape( + &self, + context: &$crate::connectors::json_selection::apply_to::ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, + ) -> Shape { + $shape_fn_name(context, method_name, method_args, input_shape, dollar_shape) + } + } + }; +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +pub(super) trait ArrowMethodImpl { + fn apply( + &self, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, + ) -> (Option, Vec); + + fn shape( + &self, + context: &ShapeContext, + // Shape processing errors for methods can benefit from knowing the name + // of the method and its source range. Note that ArrowMethodImpl::shape + // is invoked for every invocation of a method, with appropriately + // different source ranges. + method_name: &WithRange, + // Most methods implementing ArrowMethodImpl::shape will need to know + // the shapes of their arguments, which can be computed from MethodArgs + // using the compute_output_shape method. + method_args: Option<&MethodArgs>, + // The input_shape is the shape of the @ variable, or the value from the + // left hand side of the -> token. + input_shape: Shape, + // The dollar_shape is the shape of the $ variable, or the input object + // associated with the closest enclosing subselection. + dollar_shape: Shape, + ) -> Shape; +} + +// This Deref implementation allows us to call .apply(...) directly on the +// ArrowMethod enum. +impl std::ops::Deref for ArrowMethod { + type Target = dyn ArrowMethodImpl; + + fn deref(&self) -> &Self::Target { + match self { + // Public methods: + Self::Echo => &public::EchoMethod, + Self::Map => &public::MapMethod, + Self::Match => &public::MatchMethod, + Self::First => &public::FirstMethod, + Self::Last => &public::LastMethod, + Self::Slice => &public::SliceMethod, + Self::Size => &public::SizeMethod, + Self::Entries => &public::EntriesMethod, + Self::JsonStringify => &public::JsonStringifyMethod, + Self::JoinNotNull => &public::JoinNotNullMethod, + Self::Filter => &public::FilterMethod, + Self::Find => &public::FindMethod, + Self::Gte => &public::GteMethod, + Self::Lte => &public::LteMethod, + Self::Eq => &public::EqMethod, + Self::Ne => &public::NeMethod, + Self::Or => &public::OrMethod, + Self::And => &public::AndMethod, + Self::Gt => &public::GtMethod, + Self::Lt => &public::LtMethod, + Self::Not => &public::NotMethod, + Self::In => &public::InMethod, + Self::Contains => &public::ContainsMethod, + Self::Get => &public::GetMethod, + Self::ToString => &public::ToStringMethod, + Self::ParseInt => &public::ParseIntMethod, + Self::Add => &public::AddMethod, + Self::Sub => &public::SubMethod, + Self::Mul => &public::MulMethod, + Self::Div => &public::DivMethod, + Self::Mod => &public::ModMethod, + + // Future methods: + Self::TypeOf => &future::TypeOfMethod, + Self::MatchIf => &future::MatchIfMethod, + Self::Has => &future::HasMethod, + Self::Keys => &future::KeysMethod, + Self::Values => &future::ValuesMethod, + } + } +} + +impl ArrowMethod { + // This method is currently used at runtime to look up methods by &str name, + // but it could be hoisted parsing time, and then we'd store an ArrowMethod + // instead of a String for the method name in the AST. + pub(super) fn lookup(name: &str) -> Option { + let method_opt = match name { + "echo" => Some(Self::Echo), + "map" => Some(Self::Map), + "eq" => Some(Self::Eq), + "match" => Some(Self::Match), + // As this case suggests, we can't necessarily provide a name() + // method for ArrowMethod (the opposite of lookup), because method + // implementations can be used under multiple names. + "matchIf" | "match_if" => Some(Self::MatchIf), + "typeof" => Some(Self::TypeOf), + "add" => Some(Self::Add), + "sub" => Some(Self::Sub), + "mul" => Some(Self::Mul), + "div" => Some(Self::Div), + "mod" => Some(Self::Mod), + "first" => Some(Self::First), + "last" => Some(Self::Last), + "slice" => Some(Self::Slice), + "size" => Some(Self::Size), + "has" => Some(Self::Has), + "get" => Some(Self::Get), + "keys" => Some(Self::Keys), + "values" => Some(Self::Values), + "entries" => Some(Self::Entries), + "not" => Some(Self::Not), + "or" => Some(Self::Or), + "and" => Some(Self::And), + "jsonStringify" => Some(Self::JsonStringify), + "joinNotNull" => Some(Self::JoinNotNull), + "filter" => Some(Self::Filter), + "find" => Some(Self::Find), + "gte" => Some(Self::Gte), + "lte" => Some(Self::Lte), + "ne" => Some(Self::Ne), + "gt" => Some(Self::Gt), + "lt" => Some(Self::Lt), + "in" => Some(Self::In), + "contains" => Some(Self::Contains), + "toString" => Some(Self::ToString), + "parseInt" => Some(Self::ParseInt), + _ => None, + }; + + match method_opt { + Some(method) if cfg!(test) || method.is_public() => Some(method), + _ => None, + } + } + + pub(super) const fn is_public(&self) -> bool { + // This set controls which ->methods are exposed for use in connector + // schemas. Non-public methods are still implemented and tested, but + // will not be returned from lookup_arrow_method outside of tests. + matches!( + self, + Self::Echo + | Self::Map + | Self::Match + | Self::First + | Self::Last + | Self::Slice + | Self::Size + | Self::Entries + | Self::JsonStringify + | Self::JoinNotNull + | Self::Filter + | Self::Find + | Self::Gte + | Self::Lte + | Self::Eq + | Self::Ne + | Self::Or + | Self::And + | Self::Gt + | Self::Lt + | Self::Not + | Self::In + | Self::Contains + | Self::Get + | Self::ToString + | Self::ParseInt + | Self::Add + | Self::Sub + | Self::Mul + | Self::Div + | Self::Mod + ) + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/common.rs b/apollo-federation/src/connectors/json_selection/methods/common.rs new file mode 100644 index 0000000000..a7b0397291 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/common.rs @@ -0,0 +1,146 @@ +use serde_json::Number; +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::ApplyToError; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; + +pub(crate) fn is_comparable_shape_combination(shape1: &Shape, shape2: &Shape) -> bool { + if Shape::float([]).accepts(shape1) { + Shape::float([]).accepts(shape2) || shape2.accepts(&Shape::unknown([])) + } else if Shape::string([]).accepts(shape1) { + Shape::string([]).accepts(shape2) || shape2.accepts(&Shape::unknown([])) + } else if shape1.accepts(&Shape::unknown([])) { + Shape::float([]).accepts(shape2) + || Shape::string([]).accepts(shape2) + || shape2.accepts(&Shape::unknown([])) + } else { + false + } +} + +pub(crate) fn number_value_as_float( + number: &Number, + method_name: &WithRange, + input_path: &InputPath, + spec: ConnectSpec, +) -> Result { + match number.as_f64() { + Some(val) => Ok(val), + None => { + // Note that we don't have tests for these `None` cases because I can't actually find a case where this ever actually fails + // It seems that the current implementation in serde_json always returns a value + Err(ApplyToError::new( + format!( + "Method ->{} fail to convert applied to value to float.", + method_name.as_ref(), + ), + input_path.to_vec(), + method_name.range(), + spec, + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[rstest::rstest] + #[case(Shape::float([]), Shape::float([]))] + #[case(Shape::float([]), Shape::unknown([]))] + #[case(Shape::float([]), Shape::name("test", []))] + #[case(Shape::float([]), Shape::int([]))] + #[case(Shape::string([]), Shape::string([]))] + #[case(Shape::string([]), Shape::unknown([]))] + #[case(Shape::string([]), Shape::name("test", []))] + #[case(Shape::unknown([]), Shape::float([]))] + #[case(Shape::unknown([]), Shape::int([]))] + #[case(Shape::unknown([]), Shape::string([]))] + #[case(Shape::unknown([]), Shape::unknown([]))] + #[case(Shape::unknown([]), Shape::name("test", []))] + #[case(Shape::name("test", []), Shape::float([]))] + #[case(Shape::name("test", []), Shape::string([]))] + #[case(Shape::name("test", []), Shape::unknown([]))] + #[case(Shape::name("test", []), Shape::int([]))] + #[case(Shape::name("test", []), Shape::name("test", []))] + #[case(Shape::int([]), Shape::float([]))] + #[case(Shape::int([]), Shape::int([]))] + #[case(Shape::int([]), Shape::name("test", []))] + #[case(Shape::int([]), Shape::unknown([]))] + #[case(Shape::one([Shape::string([])], []), Shape::one([Shape::string([])], []))] + fn test_is_comparable_shape_combination_positive_cases( + #[case] shape1: Shape, + #[case] shape2: Shape, + ) { + assert!(is_comparable_shape_combination(&shape1, &shape2)); + } + + #[rstest::rstest] + #[case(Shape::string([]), Shape::int([]))] + #[case(Shape::string([]), Shape::bool([]))] + #[case(Shape::string([]), Shape::null([]))] + #[case(Shape::string([]), Shape::float([]))] + #[case(Shape::string([]), Shape::list(Shape::string([]), []))] + #[case(Shape::string([]), Shape::dict(Shape::string([]), []))] + #[case(Shape::float([]), Shape::bool([]))] + #[case(Shape::float([]), Shape::null([]))] + #[case(Shape::float([]), Shape::string([]))] + #[case(Shape::float([]), Shape::list(Shape::string([]), []))] + #[case(Shape::float([]), Shape::dict(Shape::string([]), []))] + #[case(Shape::int([]), Shape::string([]))] + #[case(Shape::int([]), Shape::bool([]))] + #[case(Shape::int([]), Shape::null([]))] + #[case(Shape::int([]), Shape::list(Shape::string([]), []))] + #[case(Shape::int([]), Shape::dict(Shape::string([]), []))] + #[case(Shape::null([]), Shape::float([]))] + #[case(Shape::null([]), Shape::int([]))] + #[case(Shape::null([]), Shape::null([]))] + #[case(Shape::null([]), Shape::string([]))] + #[case(Shape::null([]), Shape::unknown([]))] + #[case(Shape::null([]), Shape::name("test", []))] + #[case(Shape::null([]), Shape::list(Shape::string([]), []))] + #[case(Shape::null([]), Shape::dict(Shape::string([]), []))] + #[case(Shape::name("test", []), Shape::bool([]))] + #[case(Shape::name("test", []), Shape::null([]))] + #[case(Shape::name("test", []), Shape::list(Shape::string([]), []))] + #[case(Shape::name("test", []), Shape::dict(Shape::string([]), []))] + #[case(Shape::unknown([]), Shape::bool([]))] + #[case(Shape::unknown([]), Shape::null([]))] + #[case(Shape::unknown([]), Shape::list(Shape::string([]), []))] + #[case(Shape::unknown([]), Shape::dict(Shape::string([]), []))] + #[case(Shape::list(Shape::string([]), []), Shape::string([]))] + #[case(Shape::list(Shape::string([]), []), Shape::float([]))] + #[case(Shape::list(Shape::string([]), []), Shape::int([]))] + #[case(Shape::list(Shape::string([]), []), Shape::bool([]))] + #[case(Shape::list(Shape::string([]), []), Shape::null([]))] + #[case(Shape::list(Shape::string([]), []), Shape::unknown([]))] + #[case(Shape::list(Shape::string([]), []), Shape::name("test", []))] + #[case(Shape::list(Shape::string([]), []), Shape::dict(Shape::string([]), []))] + #[case(Shape::dict(Shape::string([]), []), Shape::string([]))] + #[case(Shape::dict(Shape::string([]), []), Shape::float([]))] + #[case(Shape::dict(Shape::string([]), []), Shape::int([]))] + #[case(Shape::dict(Shape::string([]), []), Shape::bool([]))] + #[case(Shape::dict(Shape::string([]), []), Shape::null([]))] + #[case(Shape::dict(Shape::string([]), []), Shape::unknown([]))] + #[case(Shape::dict(Shape::string([]), []), Shape::name("test", []))] + #[case(Shape::dict(Shape::string([]), []), Shape::list(Shape::string([]), []))] + #[case(Shape::bool([]), Shape::float([]))] + #[case(Shape::bool([]), Shape::string([]))] + #[case(Shape::bool([]), Shape::int([]))] + #[case(Shape::bool([]), Shape::unknown([]))] + #[case(Shape::bool([]), Shape::name("test", []))] + #[case(Shape::bool([]), Shape::list(Shape::string([]), []))] + #[case(Shape::bool([]), Shape::dict(Shape::string([]), []))] + #[case(Shape::one([Shape::string([])], []), Shape::one([Shape::int([])], []))] + fn test_is_comparable_shape_combination_negative_cases( + #[case] shape1: Shape, + #[case] shape2: Shape, + ) { + assert!(!is_comparable_shape_combination(&shape1, &shape2)); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/future/has.rs b/apollo-federation/src/connectors/json_selection/methods/future/has.rs new file mode 100644 index 0000000000..bb7527bea1 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/future/has.rs @@ -0,0 +1,189 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(HasMethod, has_method, has_shape); +/// TODO: Split this into hasIndex and hasProperty on a separate PR +fn has_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let Some(arg) = method_args.and_then(|MethodArgs { args, .. }| args.first()) else { + return ( + None, + vec![ApplyToError::new( + format!("Method ->{} requires an argument", method_name.as_ref()), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + match arg.apply_to_path(data, vars, input_path, spec) { + (Some(JSON::Number(ref n)), arg_errors) => { + match (data, n.as_i64()) { + (JSON::Array(array), Some(index)) => { + let ilen = array.len() as i64; + // Negative indices count from the end of the array + let index = if index < 0 { ilen + index } else { index }; + (Some(JSON::Bool(index >= 0 && index < ilen)), arg_errors) + } + + (JSON::String(s), Some(index)) => { + let ilen = s.as_str().len() as i64; + // Negative indices count from the end of the array + let index = if index < 0 { ilen + index } else { index }; + (Some(JSON::Bool(index >= 0 && index < ilen)), arg_errors) + } + + _ => (Some(JSON::Bool(false)), arg_errors), + } + } + + (Some(JSON::String(ref s)), arg_errors) => match data { + JSON::Object(map) => (Some(JSON::Bool(map.contains_key(s.as_str()))), arg_errors), + _ => (Some(JSON::Bool(false)), arg_errors), + }, + + (_, arg_errors) => (Some(JSON::Bool(false)), arg_errors), + } +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn has_shape( + context: &ShapeContext, + method_name: &WithRange, + _method_args: Option<&MethodArgs>, + _input_shape: Shape, + _dollar_shape: Shape, +) -> Shape { + // TODO We could be more clever here (sometimes) based on the input_shape + // and argument shapes. + Shape::bool(method_name.shape_location(context.source_id())) +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn has_should_return_true_when_array_has_item_at_specified_index() { + assert_eq!( + selection!("$->has(1)").apply_to(&json!([1, 2, 3])), + (Some(json!(true)), vec![]), + ); + } + + #[test] + fn has_should_return_false_when_array_does_not_have_item_at_specified_index() { + assert_eq!( + selection!("$->has(5)").apply_to(&json!([1, 2, 3])), + (Some(json!(false)), vec![]), + ); + } + + #[test] + fn has_should_return_true_when_string_has_character_at_specified_index() { + assert_eq!( + selection!("$->has(2)").apply_to(&json!("oyez")), + (Some(json!(true)), vec![]), + ); + } + + #[test] + fn has_should_return_true_when_string_has_character_at_specified_negative_index() { + assert_eq!( + selection!("$->has(-2)").apply_to(&json!("oyez")), + (Some(json!(true)), vec![]), + ); + } + + #[test] + fn has_should_return_false_when_string_does_not_have_character_at_specified_negative_index() { + assert_eq!( + selection!("$->has(10)").apply_to(&json!("oyez")), + (Some(json!(false)), vec![]), + ); + } + + #[test] + fn has_should_return_true_when_object_has_specified_property() { + assert_eq!( + selection!("object->has('a')").apply_to(&json!({ + "object": { + "a": 123, + "b": 456, + }, + })), + (Some(json!(true)), vec![]), + ); + } + + #[test] + fn has_should_return_false_when_object_does_not_have_specified_property() { + assert_eq!( + selection!("object->has('c')").apply_to(&json!({ + "object": { + "a": 123, + "b": 456, + }, + })), + (Some(json!(false)), vec![]), + ); + } + + #[test] + fn has_should_return_false_when_trying_to_access_boolean_property_name() { + assert_eq!( + selection!("object->has(true)").apply_to(&json!({ + "object": { + "a": 123, + "b": 456, + }, + })), + (Some(json!(false)), vec![]), + ); + } + + #[test] + fn has_should_return_false_when_trying_to_access_null_property_name() { + assert_eq!( + selection!("object->has(null)").apply_to(&json!({ + "object": { + "a": 123, + "b": 456, + }, + })), + (Some(json!(false)), vec![]), + ); + } + + #[test] + fn has_should_return_boolean_type() { + assert_eq!( + selection!("object->has('xxx')->typeof").apply_to(&json!({ + "object": { + "a": 123, + "b": 456, + }, + })), + (Some(json!("boolean")), vec![]), + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/future/keys.rs b/apollo-federation/src/connectors/json_selection/methods/future/keys.rs new file mode 100644 index 0000000000..dff34c0ec4 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/future/keys.rs @@ -0,0 +1,145 @@ +use std::iter::empty; + +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::helpers::json_type_name; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(KeysMethod, keys_method, keys_shape); +/// Given an object, returns an array of its keys (aka properties). +/// Simple example: +/// +/// $->echo({"a": 1, "b": 2, "c": 3}) returns ["a", "b", "c"] +fn keys_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + _vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if method_args.is_some() { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + + match data { + JSON::Object(map) => { + let keys = map.keys().map(|key| JSON::String(key.clone())).collect(); + (Some(JSON::Array(keys)), vec![]) + } + _ => ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires an object input, not {}", + method_name.as_ref(), + json_type_name(data), + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ), + } +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn keys_shape( + context: &ShapeContext, + method_name: &WithRange, + _method_args: Option<&MethodArgs>, + input_shape: Shape, + _dollar_shape: Shape, +) -> Shape { + match input_shape.case() { + ShapeCase::Object { fields, rest, .. } => { + // Any statically known field names become string literal shapes in + // the resulting keys array. + let keys_vec = fields + .keys() + .map(|key| Shape::string_value(key.as_str(), empty())) + .collect::>(); + + Shape::array( + keys_vec, + // Since we're collecting key shapes, we want String for the + // rest shape when it's not None. + if rest.is_none() { + Shape::none() + } else { + Shape::string(empty()) + }, + empty(), + ) + } + _ => Shape::error( + "Method ->keys requires an object input", + method_name.shape_location(context.source_id()), + ), + } +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use super::*; + use crate::selection; + + #[test] + fn keys_should_return_array_of_keys_of_an_object() { + assert_eq!( + selection!("$->keys").apply_to(&json!({ + "a": 1, + "b": 2, + "c": 3, + })), + (Some(json!(["a", "b", "c"])), vec![]), + ); + } + + #[test] + fn keys_should_return_empty_array_on_empty_object() { + assert_eq!( + selection!("$->keys").apply_to(&json!({})), + (Some(json!([])), vec![]), + ); + } + + #[test] + fn keys_should_error_when_applied_to_non_object() { + assert_eq!( + selection!("notAnObject->keys").apply_to(&json!({ + "notAnObject": 123, + })), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->keys requires an object input, not number", + "path": ["notAnObject", "->keys"], + "range": [13, 17], + }))] + ), + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/future/match_if.rs b/apollo-federation/src/connectors/json_selection/methods/future/match_if.rs new file mode 100644 index 0000000000..a3e93c935a --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/future/match_if.rs @@ -0,0 +1,140 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::apply_to::ApplyToResultMethods; +use crate::connectors::json_selection::helpers::vec_push; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::lit_expr::LitExpr; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::json_selection::location::merge_ranges; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(MatchIfMethod, match_if_method, match_if_shape); +/// Like ->match, but expects the first element of each pair to evaluate to a +/// boolean, returning the second element of the first pair whose first element +/// is true. This makes providing a final catch-all case easy, since the last +/// pair can be [true, ]. +/// +/// Simplest example: +/// +/// $->echo(123)->matchIf([123, 'It matched!'], [true, 'It did not match!']) results in 'It matched!' +/// $->echo(123)->matchIf([456, 'It matched!'], [true, 'It did not match!']) results in 'It did not match!' +/// +fn match_if_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let mut errors = Vec::new(); + + if let Some(MethodArgs { args, .. }) = method_args { + for pair in args { + if let LitExpr::Array(pair) = pair.as_ref() { + let (pattern, value) = match pair.as_slice() { + [pattern, value] => (pattern, value), + _ => continue, + }; + let (condition_opt, condition_errors) = + pattern.apply_to_path(data, vars, input_path, spec); + errors.extend(condition_errors); + + if condition_opt == Some(JSON::Bool(true)) { + return value + .apply_to_path(data, vars, input_path, spec) + .prepend_errors(errors); + }; + } + } + } + ( + None, + vec_push( + errors, + ApplyToError::new( + format!( + "Method ->{} did not match any [condition, value] pair", + method_name.as_ref(), + ), + input_path.to_vec(), + merge_ranges( + method_name.range(), + method_args.and_then(|args| args.range()), + ), + spec, + ), + ), + ) +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn match_if_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + use super::super::public::match_shape; + // Since match_shape does not inspect the candidate expressions, we can + // reuse it for ->matchIf, where the only functional difference is that the + // candidate expressions are expected to be boolean. + match_shape(context, method_name, method_args, input_shape, dollar_shape) +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn match_if_should_return_first_element_evaluated_to_true() { + assert_eq!( + selection!( + r#" + num: value->matchIf( + [@->typeof->eq('number'), @], + [true, 'not a number'] + ) + "# + ) + .apply_to(&json!({ "value": 123 })), + ( + Some(json!({ + "num": 123, + })), + vec![], + ), + ); + } + + #[test] + fn match_if_should_return_default_true_element_when_no_other_matches() { + assert_eq!( + selection!( + r#" + num: value->matchIf( + [@->typeof->eq('number'), @], + [true, 'not a number'] + ) + "# + ) + .apply_to(&json!({ "value": true })), + ( + Some(json!({ + "num": "not a number", + })), + vec![], + ), + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/future/mod.rs b/apollo-federation/src/connectors/json_selection/methods/future/mod.rs new file mode 100644 index 0000000000..1a2a400392 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/future/mod.rs @@ -0,0 +1,14 @@ +// The future module contains methods that are not yet exposed for use in +// JSONSelection strings in connector schemas, but have proposed implementations +// and tests. After careful review, they may one day move to public. + +mod r#typeof; +pub(crate) use r#typeof::TypeOfMethod; +mod match_if; +pub(crate) use match_if::MatchIfMethod; +mod has; +pub(crate) use has::HasMethod; +mod keys; +pub(crate) use keys::KeysMethod; +mod values; +pub(crate) use values::ValuesMethod; diff --git a/apollo-federation/src/connectors/json_selection/methods/future/snapshots/get_should_correct_call_methods_with_extra_spaces.snap b/apollo-federation/src/connectors/json_selection/methods/future/snapshots/get_should_correct_call_methods_with_extra_spaces.snap new file mode 100644 index 0000000000..f46f2387d4 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/future/snapshots/get_should_correct_call_methods_with_extra_spaces.snap @@ -0,0 +1,140 @@ +--- +source: apollo-federation/src/connectors/json_selection/methods/future/get.rs +expression: selection_with_spaces +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 1..2, + ), + }, + WithRange { + node: Method( + WithRange { + node: "get", + range: Some( + 6..9, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 12..13, + ), + }, + WithRange { + node: Method( + WithRange { + node: "echo", + range: Some( + 17..21, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Number( + Number(-5), + ), + range: Some( + 24..27, + ), + }, + ], + range: Some( + 22..29, + ), + }, + ), + WithRange { + node: Method( + WithRange { + node: "mul", + range: Some( + 33..36, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Number( + Number(2), + ), + range: Some( + 39..40, + ), + }, + ], + range: Some( + 37..42, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 42..42, + ), + }, + ), + range: Some( + 30..42, + ), + }, + ), + range: Some( + 14..42, + ), + }, + ), + range: Some( + 12..42, + ), + }, + }, + ), + range: Some( + 12..42, + ), + }, + ], + range: Some( + 10..44, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 44..44, + ), + }, + ), + range: Some( + 3..44, + ), + }, + ), + range: Some( + 1..44, + ), + }, + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/methods/future/typeof.rs b/apollo-federation/src/connectors/json_selection/methods/future/typeof.rs new file mode 100644 index 0000000000..39587caa30 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/future/typeof.rs @@ -0,0 +1,97 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::helpers::json_type_name; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(TypeOfMethod, typeof_method, typeof_shape); +/// Given a JSON structure, returns a string representing the "type" +/// +/// Some examples: +/// $->echo(true) would result in "boolean" +/// $->echo([1, 2, 3]) would result in "array" +/// $->echo("hello") would result in "string" +/// $->echo(5) would result in "number" +fn typeof_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + _vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if method_args.is_some() { + ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ) + } else { + let typeof_string = JSON::String(json_type_name(data).to_string().into()); + (Some(typeof_string), Vec::new()) + } +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn typeof_shape( + context: &ShapeContext, + method_name: &WithRange, + _method_args: Option<&MethodArgs>, + _input_shape: Shape, + _dollar_shape: Shape, +) -> Shape { + // TODO Compute this union type once and clone it here. + let locations = method_name.shape_location(context.source_id()); + Shape::one( + [ + Shape::string_value("null", locations.clone()), + Shape::string_value("boolean", locations.clone()), + Shape::string_value("number", locations.clone()), + Shape::string_value("string", locations.clone()), + Shape::string_value("array", locations.clone()), + Shape::string_value("object", locations.clone()), + ], + locations, + ) +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use super::*; + use crate::selection; + + #[test] + fn typeof_should_return_appropriate_when_applied_to_data() { + fn check(selection: &str, data: &JSON, expected_type: &str) { + assert_eq!( + selection!(selection).apply_to(data), + (Some(json!(expected_type)), vec![]), + ); + } + + check("$->typeof", &json!(null), "null"); + check("$->typeof", &json!(true), "boolean"); + check("@->typeof", &json!(false), "boolean"); + check("$->typeof", &json!(123), "number"); + check("$->typeof", &json!(123.45), "number"); + check("$->typeof", &json!("hello"), "string"); + check("$->typeof", &json!([1, 2, 3]), "array"); + check("$->typeof", &json!({ "key": "value" }), "object"); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/future/values.rs b/apollo-federation/src/connectors/json_selection/methods/future/values.rs new file mode 100644 index 0000000000..4eb47e1d26 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/future/values.rs @@ -0,0 +1,128 @@ +use std::iter::empty; + +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::helpers::json_type_name; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(ValuesMethod, values_method, values_shape); +/// Given an object, returns an array of its values (aka property values). +/// Simple example: +/// +/// $->echo({"a": 1, "b": 2, "c": 3}) returns [1, 2, 3] +fn values_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + _vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if method_args.is_some() { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + + match data { + JSON::Object(map) => { + let values = map.values().cloned().collect(); + (Some(JSON::Array(values)), Vec::new()) + } + _ => ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires an object input, not {}", + method_name.as_ref(), + json_type_name(data), + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ), + } +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn values_shape( + context: &ShapeContext, + method_name: &WithRange, + _method_args: Option<&MethodArgs>, + input_shape: Shape, + _dollar_shape: Shape, +) -> Shape { + match input_shape.case() { + ShapeCase::Object { fields, rest, .. } => { + Shape::array(fields.values().cloned(), rest.clone(), empty()) + } + _ => Shape::error( + "Method ->values requires an object input", + method_name.shape_location(context.source_id()), + ), + } +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use super::*; + use crate::selection; + + #[test] + fn values_should_return_an_array_of_property_values_from_an_object() { + assert_eq!( + selection!("$->values").apply_to(&json!({ + "a": 1, + "b": "two", + "c": false, + })), + (Some(json!([1, "two", false])), vec![]), + ); + } + + #[test] + fn values_should_return_an_empty_array_given_empty_object() { + assert_eq!( + selection!("$->values").apply_to(&json!({})), + (Some(json!([])), vec![]), + ); + } + + #[test] + fn values_should_error_given_a_non_object() { + assert_eq!( + selection!("notAnObject->values").apply_to(&json!({ + "notAnObject": null, + })), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->values requires an object input, not null", + "path": ["notAnObject", "->values"], + "range": [13, 19], + }))] + ), + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/and.rs b/apollo-federation/src/connectors/json_selection/methods/public/and.rs new file mode 100644 index 0000000000..2a7b8d8113 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/and.rs @@ -0,0 +1,363 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(AndMethod, and_method, and_shape); +/// Given 2 or more values to compare, returns true if all of the values are true. +/// +/// Examples: +/// $(true)->and(false) results in false +/// $(false)->and(true) results in false +/// $(true)->and(true) results in true +/// $(false)->and(false) results in false +/// $(true)->and(false, true) results in false +fn and_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let Some(mut result) = data.as_bool() else { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} can only be applied to boolean values.", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + let Some(MethodArgs { args, .. }) = method_args else { + return ( + None, + vec![ApplyToError::new( + format!("Method ->{} requires arguments", method_name.as_ref()), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + let mut errors = Vec::new(); + for arg in args { + if !result { + break; + } + let (value_opt, arg_errors) = arg.apply_to_path(data, vars, input_path, spec); + errors.extend(arg_errors); + + match value_opt { + Some(JSON::Bool(value)) => result = result && value, + Some(_) => { + errors.extend(vec![ApplyToError::new( + format!( + "Method ->{} can only accept boolean arguments.", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )]); + } + None => {} + } + } + + (Some(JSON::Bool(result)), errors) +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn and_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + if method_args.and_then(|args| args.args.first()).is_none() { + return Shape::error( + format!( + "Method ->{} requires at least one argument", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + }; + + // We will accept anything bool-like OR unknown/named + if !(Shape::bool([]).accepts(&input_shape) || input_shape.accepts(&Shape::unknown([]))) { + return Shape::error( + format!( + "Method ->{} can only be applied to boolean values. Got {input_shape}.", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + + if let Some(MethodArgs { args, .. }) = method_args { + for (i, arg) in args.iter().enumerate() { + let arg_shape = + arg.compute_output_shape(context, input_shape.clone(), dollar_shape.clone()); + + // We will accept anything bool-like OR unknown/named + if !(Shape::bool([]).accepts(&arg_shape) || arg_shape.accepts(&Shape::unknown([]))) { + return Shape::error( + format!( + "Method ->{} can only accept boolean arguments. Got {arg_shape} at position {i}.", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + } + } + + Shape::bool(method_name.shape_location(context.source_id())) +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn and_should_return_true_when_both_values_are_truthy() { + assert_eq!( + selection!("$.both->and($.and)").apply_to(&json!({ + "both": true, + "and": true, + })), + (Some(json!(true)), vec![]), + ); + } + #[test] + fn and_should_return_false_when_either_value_is_falsy() { + assert_eq!( + selection!("data.x->and($.data.y)").apply_to(&json!({ + "data": { + "x": true, + "y": false, + }, + })), + (Some(json!(false)), vec![]), + ); + } + + #[test] + fn and_should_return_false_when_first_is_false_second_is_true() { + assert_eq!( + selection!("$.first->and($.second)").apply_to(&json!({ + "first": false, + "second": true, + })), + (Some(json!(false)), vec![]), + ); + } + + #[test] + fn and_should_return_false_when_both_values_are_false() { + assert_eq!( + selection!("$.first->and($.second)").apply_to(&json!({ + "first": false, + "second": false, + })), + (Some(json!(false)), vec![]), + ); + } + + #[test] + fn and_should_return_error_when_arguments_are_not_boolean() { + let result = selection!("$.a->and($.b, $.c)").apply_to(&json!({ + "a": true, + "b": null, + "c": 0, + })); + + assert_eq!(result.0, Some(json!(true))); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->and can only accept boolean arguments.") + ); + } + #[test] + fn and_should_return_error_when_applied_to_non_boolean() { + let result = selection!("$.b->and($.a, $.c)").apply_to(&json!({ + "a": false, + "b": null, + "c": 0, + })); + + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->and can only be applied to boolean values.") + ); + } + + #[test] + fn and_should_return_error_when_no_arguments_provided() { + let result = selection!("$.a->and").apply_to(&json!({ + "a": true, + })); + + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->and requires arguments") + ); + } +} + +#[cfg(test)] +mod shape_tests { + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::Key; + use crate::connectors::PathSelection; + use crate::connectors::json_selection::PathList; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..7, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + and_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("and".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::none(), + ) + } + + #[test] + fn and_shape_should_return_bool_on_valid_booleans() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Bool(false), None)], + Shape::bool([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn and_shape_should_error_on_non_boolean_input() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Bool(true), None)], + Shape::string([]) + ), + Shape::error( + "Method ->and can only be applied to boolean values. Got String.".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn and_shape_should_error_on_non_boolean_args() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("test".to_string()), None)], + Shape::bool([]) + ), + Shape::error( + "Method ->and can only accept boolean arguments. Got \"test\" at position 0." + .to_string(), + [get_location()] + ) + ); + } + + #[test] + fn and_shape_should_error_on_no_args() { + assert_eq!( + get_shape(vec![], Shape::bool([])), + Shape::error( + "Method ->and requires at least one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn and_shape_should_error_on_none_args() { + let location = get_location(); + assert_eq!( + and_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("and".to_string(), Some(location.span)), + None, + Shape::bool([]), + Shape::none(), + ), + Shape::error( + "Method ->and requires at least one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn and_shape_should_error_on_args_that_compute_as_none() { + let path = LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("a").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }); + let location = get_location(); + assert_eq!( + and_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("and".to_string(), Some(location.span)), + Some(&MethodArgs { + args: vec![path.into_with_range()], + range: None + }), + Shape::bool([]), + Shape::none(), + ), + Shape::error( + "Method ->and can only accept boolean arguments. Got None at position 0." + .to_string(), + [get_location()] + ) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/arithmetic.rs b/apollo-federation/src/connectors/json_selection/methods/public/arithmetic.rs new file mode 100644 index 0000000000..f2e21d2e4a --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/arithmetic.rs @@ -0,0 +1,931 @@ +use apollo_compiler::collections::IndexSet; +use serde_json::Number; +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::helpers::vec_push; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +/// This module exports a series of math functions (add, sub, mul, div, mod) which accept a number, and are applied +/// against a number, and returns the result of the operation. +/// +/// Examples: +/// $->echo(5)->add(5) results in 10 +/// $->echo(5)->sub(1) results in 4 +/// $->echo(5)->mul(5) results in 25 +/// $->echo(5)->div(5) results in 1.0 (division always returns a float) +/// $->echo(5)->mod(2) results in 1 +/// +/// You can also chain multiple of the same operation together by providing a comma separated list of numbers: +/// $->echo(1)->add(2,3,4,5) results in 15 +pub(super) fn arithmetic_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + op: impl Fn(&Number, &Number) -> Option, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if let Some(MethodArgs { args, .. }) = method_args { + if let JSON::Number(result) = data { + let mut result = result.clone(); + let mut errors = Vec::new(); + for arg in args { + let (value_opt, arg_errors) = arg.apply_to_path(data, vars, input_path, spec); + errors.extend(arg_errors); + if let Some(JSON::Number(n)) = value_opt { + if let Some(new_result) = op(&result, &n) { + result = new_result; + } else { + return ( + None, + vec_push( + errors, + ApplyToError::new( + format!( + "Method ->{} failed on argument {}", + method_name.as_ref(), + n + ), + input_path.to_vec(), + arg.range(), + spec, + ), + ), + ); + } + } else { + return ( + None, + vec_push( + errors, + ApplyToError::new( + format!( + "Method ->{} requires numeric arguments", + method_name.as_ref() + ), + input_path.to_vec(), + arg.range(), + spec, + ), + ), + ); + } + } + (Some(JSON::Number(result)), errors) + } else { + ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires numeric arguments", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ) + } + } else { + ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires at least one argument", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ) + } +} + +macro_rules! infix_math_op { + ($name:ident, $op:tt) => { + fn $name(a: &Number, b: &Number) -> Option { + if a.is_f64() || b.is_f64() { + Number::from_f64(a.as_f64().unwrap() $op b.as_f64().unwrap()) + } else if let (Some(a_i64), Some(b_i64)) = (a.as_i64(), b.as_i64()) { + infix_math_op!(@int_case a_i64, b_i64, $op) + } else { + None + } + } + }; + // This branching is because when we are dividing, two whole numbers could result in a non-whole result (E.g. 7/2 = 3.5) + // It is much more predictable and intuitive for this to return "3.5" instead of "3" (if we did int math) but we need + // the return type to always be the same for type checking to work. So, for division specifically, we will always return + // a float. + (@int_case $a:ident, $b:ident, /) => {{ + Number::from_f64($a as f64 / $b as f64) + }}; + (@int_case $a:ident, $b:ident, $op:tt) => {{ + Some(Number::from($a $op $b)) + }}; +} +infix_math_op!(add_op, +); +infix_math_op!(sub_op, -); +infix_math_op!(mul_op, *); +infix_math_op!(div_op, /); +infix_math_op!(rem_op, %); + +fn math_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let mut check_results = IndexSet::default(); + let input_result = check_numeric_shape(&input_shape); + + if let Some(result) = input_result { + check_results.insert(result); + } else { + return Shape::error( + format!( + "Method ->{} received non-numeric input", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + + if method_name.as_ref() == "div" { + // The ->div method stays safe by always returning Float, so + // check_result starts off false in that case. + check_results.insert(CheckNumericResult::FloatPossible); + } + + for (i, arg) in method_args + .iter() + .flat_map(|args| args.args.iter()) + .enumerate() + { + let arg_shape = + arg.compute_output_shape(context, input_shape.clone(), dollar_shape.clone()); + + if let Some(result) = check_numeric_shape(&arg_shape) { + check_results.insert(result); + } else { + return Shape::error( + format!( + "Method ->{} received non-numeric argument {}", + method_name.as_ref(), + i + ), + method_name.shape_location(context.source_id()), + ); + } + } + + if check_results + .iter() + .all(|result| matches!(result, CheckNumericResult::IntForSure)) + { + // If all the shapes are definitely integers, math_shape can return Int. + Shape::int(method_name.shape_location(context.source_id())) + } else { + // If any of the shapes are definitely floats, or could be floats, + // we return a Float shape. + Shape::float(method_name.shape_location(context.source_id())) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum CheckNumericResult { + /// The shape is definitely an integer (general Int, specific 123 value, or + /// union/intersection thereof). + IntForSure, + /// While we can't be sure the shape is an integer, it might still be a + /// number. Note that Float contains all JSON number values, including all + /// the integers. We report this result for Unknown and Name shapes as well, + /// since they could resolve to a numeric value. + FloatPossible, +} + +fn check_numeric_shape(shape: &Shape) -> Option { + // Using the `Shape::accepts` method automatically handles cases like shape + // being a union or intersection. + if Shape::int([]).accepts(shape) { + Some(CheckNumericResult::IntForSure) + } else if Shape::float([]).accepts(shape) + // The only shapes that accept Unknown are Unknown and ShapeCase::Name + // shapes, since their shape is logically unknown. It is otherwise + // tricky to express a shape that accepts any ::Name shape, without + // knowing the possible names in advance. + || shape.accepts(&Shape::unknown([])) + { + // If shape meets the requirements of Float, or is an Unknown/Name shape + // that might resolve to a numeric value, math_shape returns Float + // (which is the same as saying "any numeric JSON value"). + Some(CheckNumericResult::FloatPossible) + } else { + // If there's no chance the shape could be a number (because we know + // it's something else), math_shape will return an error. + None + } +} + +macro_rules! infix_math_method { + ($struct_name:ident, $fn_name:ident, $op:ident) => { + impl_arrow_method!($struct_name, $fn_name, math_shape); + fn $fn_name( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, + ) -> (Option, Vec) { + arithmetic_method(method_name, method_args, $op, data, vars, input_path, spec) + } + }; +} +infix_math_method!(AddMethod, add_method, add_op); +infix_math_method!(SubMethod, sub_method, sub_op); +infix_math_method!(MulMethod, mul_method, mul_op); +infix_math_method!(DivMethod, div_method, div_op); +infix_math_method!(ModMethod, mod_method, rem_op); + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn add_should_return_sum_of_integers() { + assert_eq!( + selection!("$->add(3)").apply_to(&json!(2)), + (Some(json!(5)), vec![]) + ); + } + + #[test] + fn add_should_return_sum_of_integer_and_float() { + assert_eq!( + selection!("$->add(1.5)").apply_to(&json!(2)), + (Some(json!(3.5)), vec![]) + ); + } + + #[test] + fn add_should_return_sum_of_float_and_integer() { + assert_eq!( + selection!("$->add(1)").apply_to(&json!(2.5)), + (Some(json!(3.5)), vec![]) + ); + } + + #[test] + fn add_should_return_sum_of_floats() { + assert_eq!( + selection!("$->add(1.5)").apply_to(&json!(2.5)), + (Some(json!(4.0)), vec![]) + ); + } + + #[test] + fn add_should_return_sum_of_multiple_arguments() { + assert_eq!( + selection!("$->add(1, 2, 3)").apply_to(&json!(4)), + (Some(json!(10)), vec![]) + ); + } + + #[test] + fn add_should_return_sum_with_negative_numbers() { + assert_eq!( + selection!("$->add(-5)").apply_to(&json!(10)), + (Some(json!(5)), vec![]) + ); + } + + #[test] + fn add_should_return_error_when_applied_to_non_numeric_input() { + let result = selection!("$->add(1)").apply_to(&json!("not a number")); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->add requires numeric arguments") + ); + } + + #[test] + fn add_should_return_error_when_given_non_numeric_argument() { + let result = selection!("$->add('not a number')").apply_to(&json!(5)); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->add requires numeric arguments") + ); + } + + #[test] + fn add_should_return_error_when_no_arguments_provided() { + let result = selection!("$->add").apply_to(&json!(5)); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->add requires at least one argument") + ); + } + + #[test] + fn sub_should_return_difference_of_integers() { + assert_eq!( + selection!("$->sub(3)").apply_to(&json!(8)), + (Some(json!(5)), vec![]) + ); + } + + #[test] + fn sub_should_return_difference_of_integer_and_float() { + assert_eq!( + selection!("$->sub(1.5)").apply_to(&json!(5)), + (Some(json!(3.5)), vec![]) + ); + } + + #[test] + fn sub_should_return_difference_of_float_and_integer() { + assert_eq!( + selection!("$->sub(2)").apply_to(&json!(5.5)), + (Some(json!(3.5)), vec![]) + ); + } + + #[test] + fn sub_should_return_difference_of_floats() { + assert_eq!( + selection!("$->sub(2.5)").apply_to(&json!(5.5)), + (Some(json!(3.0)), vec![]) + ); + } + + #[test] + fn sub_should_return_difference_with_multiple_arguments() { + assert_eq!( + selection!("$->sub(1, 2)").apply_to(&json!(10)), + (Some(json!(7)), vec![]) + ); + } + + #[test] + fn sub_should_return_negative_result() { + assert_eq!( + selection!("$->sub(10)").apply_to(&json!(5)), + (Some(json!(-5)), vec![]) + ); + } + + #[test] + fn sub_should_return_error_when_applied_to_non_numeric_input() { + let result = selection!("$->sub(1)").apply_to(&json!("not a number")); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->sub requires numeric arguments") + ); + } + + #[test] + fn sub_should_return_error_when_given_non_numeric_argument() { + let result = selection!("$->sub('not a number')").apply_to(&json!(5)); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->sub requires numeric arguments") + ); + } + + #[test] + fn sub_should_return_error_when_no_arguments_provided() { + let result = selection!("$->sub").apply_to(&json!(5)); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->sub requires at least one argument") + ); + } + + #[test] + fn mul_should_return_product_of_integers() { + assert_eq!( + selection!("$->mul(3)").apply_to(&json!(4)), + (Some(json!(12)), vec![]) + ); + } + + #[test] + fn mul_should_return_product_of_integer_and_float() { + assert_eq!( + selection!("$->mul(2.5)").apply_to(&json!(4)), + (Some(json!(10.0)), vec![]) + ); + } + + #[test] + fn mul_should_return_product_of_float_and_integer() { + assert_eq!( + selection!("$->mul(3)").apply_to(&json!(2.5)), + (Some(json!(7.5)), vec![]) + ); + } + + #[test] + fn mul_should_return_product_of_floats() { + assert_eq!( + selection!("$->mul(2.5)").apply_to(&json!(1.5)), + (Some(json!(3.75)), vec![]) + ); + } + + #[test] + fn mul_should_return_product_with_multiple_arguments() { + assert_eq!( + selection!("$->mul(2, 3)").apply_to(&json!(4)), + (Some(json!(24)), vec![]) + ); + } + + #[test] + fn mul_should_return_product_with_negative_numbers() { + assert_eq!( + selection!("$->mul(-2)").apply_to(&json!(5)), + (Some(json!(-10)), vec![]) + ); + } + + #[test] + fn mul_should_return_zero_when_multiplied_by_zero() { + assert_eq!( + selection!("$->mul(0)").apply_to(&json!(5)), + (Some(json!(0)), vec![]) + ); + } + + #[test] + fn mul_should_return_error_when_applied_to_non_numeric_input() { + let result = selection!("$->mul(2)").apply_to(&json!("not a number")); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->mul requires numeric arguments") + ); + } + + #[test] + fn mul_should_return_error_when_given_non_numeric_argument() { + let result = selection!("$->mul('not a number')").apply_to(&json!(5)); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->mul requires numeric arguments") + ); + } + + #[test] + fn mul_should_return_error_when_no_arguments_provided() { + let result = selection!("$->mul").apply_to(&json!(5)); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->mul requires at least one argument") + ); + } + + #[test] + fn div_should_return_quotient_of_integers_as_float() { + assert_eq!( + selection!("$->div(2)").apply_to(&json!(6)), + (Some(json!(3.0)), vec![]) + ); + } + + #[test] + fn div_should_return_quotient_with_decimal_result() { + assert_eq!( + selection!("$->div(2)").apply_to(&json!(5)), + (Some(json!(2.5)), vec![]) + ); + } + + #[test] + fn div_should_return_quotient_of_floats() { + assert_eq!( + selection!("$->div(2.5)").apply_to(&json!(7.5)), + (Some(json!(3.0)), vec![]) + ); + } + + #[test] + fn div_should_return_quotient_with_multiple_arguments() { + assert_eq!( + selection!("$->div(2, 3)").apply_to(&json!(12)), + (Some(json!(2.0)), vec![]) + ); + } + + #[test] + fn div_should_return_quotient_with_negative_numbers() { + assert_eq!( + selection!("$->div(-2)").apply_to(&json!(10)), + (Some(json!(-5.0)), vec![]) + ); + } + + #[test] + fn div_should_return_error_when_applied_to_non_numeric_input() { + let result = selection!("$->div(2)").apply_to(&json!("not a number")); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->div requires numeric arguments") + ); + } + + #[test] + fn div_should_return_error_when_given_non_numeric_argument() { + let result = selection!("$->div('not a number')").apply_to(&json!(5)); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->div requires numeric arguments") + ); + } + + #[test] + fn div_should_return_error_when_no_arguments_provided() { + let result = selection!("$->div").apply_to(&json!(5)); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->div requires at least one argument") + ); + } + + #[test] + fn mod_should_return_remainder_of_integers() { + assert_eq!( + selection!("$->mod(3)").apply_to(&json!(7)), + (Some(json!(1)), vec![]) + ); + } + + #[test] + fn mod_should_return_remainder_of_integer_and_float() { + assert_eq!( + selection!("$->mod(2.5)").apply_to(&json!(7)), + (Some(json!(2.0)), vec![]) + ); + } + + #[test] + fn mod_should_return_remainder_of_float_and_integer() { + assert_eq!( + selection!("$->mod(3)").apply_to(&json!(7.5)), + (Some(json!(1.5)), vec![]) + ); + } + + #[test] + fn mod_should_return_remainder_of_floats() { + assert_eq!( + selection!("$->mod(2.5)").apply_to(&json!(7.5)), + (Some(json!(0.0)), vec![]) + ); + } + + #[test] + fn mod_should_return_remainder_with_multiple_arguments() { + assert_eq!( + selection!("$->mod(3, 2)").apply_to(&json!(10)), + (Some(json!(1)), vec![]) + ); + } + + #[test] + fn mod_should_return_zero_when_no_remainder() { + assert_eq!( + selection!("$->mod(5)").apply_to(&json!(10)), + (Some(json!(0)), vec![]) + ); + } + + #[test] + fn mod_should_return_error_when_applied_to_non_numeric_input() { + let result = selection!("$->mod(2)").apply_to(&json!("not a number")); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->mod requires numeric arguments") + ); + } + + #[test] + fn mod_should_return_error_when_given_non_numeric_argument() { + let result = selection!("$->mod('not a number')").apply_to(&json!(5)); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->mod requires numeric arguments") + ); + } + + #[test] + fn mod_should_return_error_when_no_arguments_provided() { + let result = selection!("$->mod").apply_to(&json!(5)); + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->mod requires at least one argument") + ); + } +} + +#[cfg(test)] +mod shape_tests { + use serde_json::Number; + use shape::Shape; + use shape::location::Location; + use shape::location::SourceId; + + use crate::connectors::json_selection::MethodArgs; + use crate::connectors::json_selection::ShapeContext; + use crate::connectors::json_selection::lit_expr::LitExpr; + use crate::connectors::json_selection::location::WithRange; + use crate::connectors::json_selection::methods::public::arithmetic::math_shape; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..7, + } + } + + fn get_shape(method_name: &str, args: Vec>, input: Shape) -> Shape { + let location = get_location(); + math_shape( + &ShapeContext::new(location.source_id), + &WithRange::new(method_name.to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::none(), + ) + } + + #[test] + fn add_shape_should_return_int_for_integer_arguments() { + assert_eq!( + get_shape( + "add", + vec![WithRange::new(LitExpr::Number(Number::from(2)), None)], + Shape::int([]) + ), + Shape::int([get_location()]) + ); + } + + #[test] + fn add_shape_should_return_float_for_float_arguments() { + assert_eq!( + get_shape( + "add", + vec![WithRange::new( + LitExpr::Number(Number::from_f64(2.5).unwrap()), + None + )], + Shape::int([]) + ), + Shape::float([get_location()]) + ); + } + + #[test] + fn add_shape_should_return_float_for_float_input() { + assert_eq!( + get_shape( + "add", + vec![WithRange::new(LitExpr::Number(Number::from(2)), None)], + Shape::float([]) + ), + Shape::float([get_location()]) + ); + } + + #[test] + fn add_shape_should_return_error_for_non_numeric_input() { + assert_eq!( + get_shape( + "add", + vec![WithRange::new(LitExpr::Number(Number::from(1)), None)], + Shape::string([]), + ), + Shape::error( + "Method ->add received non-numeric input".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn add_shape_should_return_error_for_non_numeric_argument() { + assert_eq!( + get_shape( + "add", + vec![WithRange::new( + LitExpr::String("not a number".to_string()), + None, + )], + Shape::int([]), + ), + Shape::error( + "Method ->add received non-numeric argument 0".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn sub_shape_should_return_int_for_integer_arguments() { + assert_eq!( + get_shape( + "sub", + vec![WithRange::new(LitExpr::Number(Number::from(2)), None)], + Shape::int([]) + ), + Shape::int([get_location()]) + ); + } + + #[test] + fn sub_shape_should_return_float_for_float_arguments() { + assert_eq!( + get_shape( + "sub", + vec![WithRange::new( + LitExpr::Number(Number::from_f64(2.5).unwrap()), + None + )], + Shape::int([]) + ), + Shape::float([get_location()]) + ); + } + + #[test] + fn mul_shape_should_return_int_for_integer_arguments() { + assert_eq!( + get_shape( + "mul", + vec![WithRange::new(LitExpr::Number(Number::from(4)), None)], + Shape::int([]) + ), + Shape::int([get_location()]) + ); + } + + #[test] + fn mul_shape_should_return_float_for_float_arguments() { + assert_eq!( + get_shape( + "mul", + vec![WithRange::new( + LitExpr::Number(Number::from_f64(4.5).unwrap()), + None + )], + Shape::int([]) + ), + Shape::float([get_location()]) + ); + } + + #[test] + fn div_shape_should_always_return_float() { + assert_eq!( + get_shape( + "div", + vec![WithRange::new(LitExpr::Number(Number::from(2)), None)], + Shape::int([]) + ), + Shape::float([get_location()]) + ); + } + + #[test] + fn div_shape_should_return_float_for_float_arguments() { + assert_eq!( + get_shape( + "div", + vec![WithRange::new( + LitExpr::Number(Number::from_f64(2.5).unwrap()), + None + )], + Shape::int([]) + ), + Shape::float([get_location()]) + ); + } + + #[test] + fn mod_shape_should_return_int_for_integer_arguments() { + assert_eq!( + get_shape( + "mod", + vec![WithRange::new(LitExpr::Number(Number::from(3)), None)], + Shape::int([]) + ), + Shape::int([get_location()]) + ); + } + + #[test] + fn mod_shape_should_return_float_for_float_arguments() { + assert_eq!( + get_shape( + "mod", + vec![WithRange::new( + LitExpr::Number(Number::from_f64(3.5).unwrap()), + None + )], + Shape::int([]) + ), + Shape::float([get_location()]) + ); + } + + #[test] + fn math_shape_should_return_error_for_non_numeric_input() { + assert_eq!( + get_shape( + "mul", + vec![WithRange::new(LitExpr::Number(Number::from(1)), None)], + Shape::string([]), + ), + Shape::error( + "Method ->mul received non-numeric input".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn math_shape_should_return_error_for_non_numeric_argument() { + assert_eq!( + get_shape( + "div", + vec![WithRange::new(LitExpr::String("invalid".to_string()), None)], + Shape::int([]), + ), + Shape::error( + "Method ->div received non-numeric argument 0".to_string(), + [get_location()] + ) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/contains.rs b/apollo-federation/src/connectors/json_selection/methods/public/contains.rs new file mode 100644 index 0000000000..4780c025d4 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/contains.rs @@ -0,0 +1,607 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; + +use crate::connectors::ConnectSpec; +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::json_selection::methods::common::number_value_as_float; +use crate::impl_arrow_method; + +impl_arrow_method!(ContainsMethod, contains_method, contains_shape); +/// Returns true if the applied array contains the value in the argument. +/// Simple examples: +/// +/// $([123, 456, 789])->contains(123) results in true +/// $([456, 789])->contains(123) results in false +fn contains_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if let Some(MethodArgs { args, .. }) = method_args + && let [arg] = args.as_slice() + { + let (value_opt, arg_errors) = arg.apply_to_path(data, vars, input_path, spec); + let mut apply_to_errors = arg_errors; + + let matches = value_opt.and_then(|search_value| { + if let JSON::Array(array) = data { + for item in array { + let is_equal = match (item, &search_value) { + // Number comparisons: Always convert to float so 1 == 1.0 + (JSON::Number(left), JSON::Number(right)) => { + let left = + match number_value_as_float(left, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + let right = + match number_value_as_float(right, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + left == right + } + // Everything else + _ => item == &search_value, + }; + + if is_equal { + return Some(JSON::Bool(true)); + } + } + Some(JSON::Bool(false)) + } else { + apply_to_errors.push(ApplyToError::new( + format!( + "Method ->{} requires an array input, but got: {data}", + method_name.as_ref(), + ), + input_path.to_vec(), + method_name.range(), + spec, + )); + None + } + }); + + return (matches, apply_to_errors); + } + ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires exactly one argument", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ) +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn contains_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let arg_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if arg_count > 1 { + return Shape::error( + format!( + "Method ->{} requires only one argument, but {arg_count} were provided", + method_name.as_ref(), + ), + vec![], + ); + } + + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + method_name.shape_location(context.source_id()), + ); + }; + + let arg_shape = first_arg.compute_output_shape(context, input_shape.clone(), dollar_shape); + + // Ensure input is an array + if !Shape::tuple([], []).accepts(&input_shape) && !input_shape.accepts(&Shape::unknown([])) { + return Shape::error( + format!( + "Method ->{} requires an array input, but got: {input_shape}", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + + let ShapeCase::Array { prefix, tail } = input_shape.case() else { + return Shape::bool(method_name.shape_location(context.source_id())); + }; + + // Ensures that the argument is of the same type as the array elements... this includes covering cases like int/float and unknown/name + if let Some(item) = prefix + .iter() + .find(|item| !(arg_shape.accepts(item) || item.accepts(&arg_shape))) + { + return Shape::error_with_partial( + format!( + "Method ->{} can only compare values of the same type. Got {item} == {arg_shape}.", + method_name.as_ref() + ), + Shape::bool_value(false, method_name.shape_location(context.source_id())), + method_name.shape_location(context.source_id()), + ); + } + + // Also check the tail for type mismatch + if !(tail.is_none() || arg_shape.accepts(tail) || tail.accepts(&arg_shape)) { + return Shape::error_with_partial( + format!( + "Method ->{} can only compare values of the same type. Got {arg_shape} == {tail}.", + method_name.as_ref() + ), + Shape::bool_value(false, method_name.shape_location(context.source_id())), + method_name.shape_location(context.source_id()), + ); + } + + Shape::bool(method_name.shape_location(context.source_id())) +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn contains_should_return_true_when_array_contains_value() { + assert_eq!( + selection!( + r#" + result: value->contains(123) + "# + ) + .apply_to(&json!({ "value": [123, 456, 789] })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn contains_should_return_false_when_array_does_not_contain_value() { + assert_eq!( + selection!( + r#" + result: value->contains(123) + "# + ) + .apply_to(&json!({ "value": [456, 789] })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn contains_should_return_true_when_array_contains_numbers_of_different_types() { + assert_eq!( + selection!( + r#" + result: value->contains(1.0) + "# + ) + .apply_to(&json!({ "value": [1, 2.5, 3] })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn contains_should_return_true_when_array_contains_string() { + assert_eq!( + selection!( + r#" + result: value->contains("hello") + "# + ) + .apply_to(&json!({ "value": ["hello", "world", "test"] })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn contains_should_return_false_when_array_does_not_contain_string() { + assert_eq!( + selection!( + r#" + result: value->contains("hello") + "# + ) + .apply_to(&json!({ "value": ["world", "test"] })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn contains_should_return_true_when_array_contains_bool() { + assert_eq!( + selection!( + r#" + result: value->contains(true) + "# + ) + .apply_to(&json!({ "value": [true, false] })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn contains_should_return_false_when_array_does_not_contain_bool() { + assert_eq!( + selection!( + r#" + result: value->contains(true) + "# + ) + .apply_to(&json!({ "value": [false] })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn contains_should_return_true_when_array_contains_object() { + assert_eq!( + selection!( + r#" + result: value->contains({"name": "John", "age": 30}) + "# + ) + .apply_to( + &json!({ "value": [{"name": "John", "age": 30}, {"name": "Jane", "age": 25}] }) + ), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn contains_should_return_false_when_array_does_not_contain_object() { + assert_eq!( + selection!( + r#" + result: value->contains({"name": "John", "age": 30}) + "# + ) + .apply_to(&json!({ "value": [{"name": "Jane", "age": 25}] })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn contains_should_return_true_when_array_contains_array() { + assert_eq!( + selection!( + r#" + result: value->contains([1, 2, 3]) + "# + ) + .apply_to(&json!({ "value": [[1, 2, 3], [4, 5, 6]] })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn contains_should_return_false_when_array_does_not_contain_array() { + assert_eq!( + selection!( + r#" + result: value->contains([1, 2, 3]) + "# + ) + .apply_to(&json!({ "value": [[4, 5, 6], [7, 8, 9]] })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn contains_should_return_false_for_empty_array() { + assert_eq!( + selection!( + r#" + result: value->contains(123) + "# + ) + .apply_to(&json!({ "value": [] })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn contains_should_return_error_when_no_arguments_provided() { + let result = selection!( + r#" + result: value->contains() + "# + ) + .apply_to(&json!({ "value": [123] })); + + assert_eq!(result.0, Some(json!({}))); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->contains requires exactly one argument") + ); + } + + #[test] + fn contains_should_return_error_when_input_is_not_array() { + let result = selection!( + r#" + result: value->contains(123) + "# + ) + .apply_to(&json!({ "value": 123 })); + + assert_eq!(result.0, Some(json!({}))); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->contains requires an array input, but got: 123") + ); + } +} + +#[cfg(test)] +mod shape_tests { + use serde_json::Number; + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..8, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + contains_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("contains".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::none(), + ) + } + + #[test] + fn contains_shape_should_return_bool_on_valid_string_array() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::list(Shape::string([]), []) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn contains_shape_should_return_bool_on_valid_number_array() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Number(Number::from(42)), None)], + Shape::list(Shape::int([]), []) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn contains_shape_should_return_bool_on_valid_bool_array() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Bool(true), None)], + Shape::list(Shape::bool([]), []) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn contains_shape_should_error_on_non_array_input() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::string([]) + ), + Shape::error( + "Method ->contains requires an array input, but got: String".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn contains_shape_should_error_on_mixed_types() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::list(Shape::int([]), []) + ), + Shape::error_with_partial( + "Method ->contains can only compare values of the same type. Got \"a\" == Int." + .to_string(), + Shape::bool_value(false, [get_location()]), + [get_location()] + ) + ); + } + + #[test] + fn contains_shape_should_error_on_mixed_types_array() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::array([Shape::int([])], Shape::none(), []) + ), + Shape::error_with_partial( + "Method ->contains can only compare values of the same type. Got Int == \"a\"." + .to_string(), + Shape::bool_value(false, [get_location()]), + [get_location()] + ) + ); + } + + #[test] + fn contains_shape_should_error_on_no_args() { + assert_eq!( + get_shape(vec![], Shape::list(Shape::string([]), [])), + Shape::error( + "Method ->contains requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn contains_shape_should_error_on_too_many_args() { + assert_eq!( + get_shape( + vec![ + WithRange::new(LitExpr::Number(Number::from(42)), None), + WithRange::new(LitExpr::Number(Number::from(43)), None) + ], + Shape::list(Shape::int([]), []) + ), + Shape::error( + "Method ->contains requires only one argument, but 2 were provided".to_string(), + [] + ) + ); + } + + #[test] + fn contains_shape_should_error_on_none_args() { + let location = get_location(); + assert_eq!( + contains_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("contains".to_string(), Some(location.span)), + None, + Shape::list(Shape::string([]), []), + Shape::none(), + ), + Shape::error( + "Method ->contains requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn contains_shape_should_return_bool_on_unknown_input() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("test".to_string()), None)], + Shape::unknown([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn contains_shape_should_return_bool_on_named_input() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Number(Number::from(42)), None)], + Shape::name("a", []) + ), + Shape::bool([get_location()]) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/echo.rs b/apollo-federation/src/connectors/json_selection/methods/public/echo.rs new file mode 100644 index 0000000000..8d3eda6b0f --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/echo.rs @@ -0,0 +1,214 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(EchoMethod, echo_method, echo_shape); +/// Echo simply returns back whichever value is provided in it's arg. +/// The simplest possible case is $.echo("hello world") which would result in "hello world" +/// +/// However, it will also reflect back any type passed into it allowing you to act on those: +/// +/// $->echo([1,2,3])->first would result in "1" +/// +/// It's also worth noting that you can use $ to refer to to the selection and pass that into echo and you can also use @ to refer to the value that echo is being run on. +/// +/// For example, assuming my selection is { firstName: "John", children: ["Jack"] }... +/// +/// $->echo($.firstName) would result in "John" +/// $.children->echo(@->first) would result in "Jack" +fn echo_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if let Some(MethodArgs { args, .. }) = method_args + && let Some(arg) = args.first() + { + return arg.apply_to_path(data, vars, input_path, spec); + } + ( + None, + vec![ApplyToError::new( + format!("Method ->{} requires one argument", method_name.as_ref()), + input_path.to_vec(), + method_name.range(), + spec, + )], + ) +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn echo_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + if let Some(first_arg) = method_args.and_then(|args| args.args.first()) { + return first_arg.compute_output_shape(context, input_shape, dollar_shape); + } + Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + method_name.shape_location(context.source_id()), + ) +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn echo_should_output_value_when_applied_to_null() { + assert_eq!( + selection!("$->echo('oyez')").apply_to(&json!(null)), + (Some(json!("oyez")), vec![]), + ); + } + + #[test] + fn echo_should_output_value_when_applied_to_array() { + assert_eq!( + selection!("$->echo('oyez')").apply_to(&json!([1, 2, 3])), + (Some(json!("oyez")), vec![]), + ); + } + + #[test] + fn echo_should_allow_selection_from_array_value() { + assert_eq!( + selection!("$->echo([1, 2, 3]) { id: $ }").apply_to(&json!(null)), + (Some(json!([{ "id": 1 }, { "id": 2 }, { "id": 3 }])), vec![]), + ); + } + + #[test] + fn echo_should_allow_arrow_methods_off_returned_value() { + assert_eq!( + selection!("$->echo([1, 2, 3])->last { id: $ }").apply_to(&json!(null)), + (Some(json!({ "id": 3 })), vec![]), + ); + } + + #[test] + fn echo_should_allow_at_sign_to_input_value_from_selection_function() { + assert_eq!( + selection!("$.nested.value->echo(['before', @, 'after'])").apply_to(&json!({ + "nested": { + "value": 123, + }, + })), + (Some(json!(["before", 123, "after"])), vec![]), + ); + } + + #[test] + fn echo_should_allow_dollar_sign_to_input_applied_to_value() { + assert_eq!( + selection!("$.nested.value->echo(['before', $, 'after'])").apply_to(&json!({ + "nested": { + "value": 123, + }, + })), + ( + Some(json!(["before", { + "nested": { + "value": 123, + }, + }, "after"])), + vec![] + ), + ); + } + + #[test] + fn echo_should_allow_selection_functions_result_passed_as_value() { + assert_eq!( + selection!("results->echo(@->first)").apply_to(&json!({ + "results": [ + [1, 2, 3], + "ignored", + ], + })), + (Some(json!([1, 2, 3])), vec![]), + ); + } + + #[test] + fn echo_should_allow_arrow_functions_on_result_of_echo() { + assert_eq!( + selection!("results->echo(@->first)->last").apply_to(&json!({ + "results": [ + [1, 2, 3], + "ignored", + ], + })), + (Some(json!(3)), vec![]), + ); + } + + #[test] + fn echo_should_not_error_with_trailing_commas() { + let nested_value_data = json!({ + "nested": { + "value": 123, + }, + }); + + let expected = (Some(json!({ "wrapped": 123 })), vec![]); + + let check = |selection: &str| { + assert_eq!(selection!(selection).apply_to(&nested_value_data), expected,); + }; + + check("nested.value->echo({ wrapped: @ })"); + check("nested.value->echo({ wrapped: @,})"); + check("nested.value->echo({ wrapped: @,},)"); + check("nested.value->echo({ wrapped: @},)"); + check("nested.value->echo({ wrapped: @ , } , )"); + } + + #[test] + fn echo_should_flatted_object_list_using_at_sign() { + // Turn a list of { name, hobby } objects into a single { names: [...], + // hobbies: [...] } object. + assert_eq!( + selection!( + r#" + people->echo({ + names: @.name, + hobbies: @.hobby, + }) + "# + ) + .apply_to(&json!({ + "people": [ + { "name": "Alice", "hobby": "reading" }, + { "name": "Bob", "hobby": "fishing" }, + { "hobby": "painting", "name": "Charlie" }, + ], + })), + ( + Some(json!({ + "names": ["Alice", "Bob", "Charlie"], + "hobbies": ["reading", "fishing", "painting"], + })), + vec![], + ), + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/entries.rs b/apollo-federation/src/connectors/json_selection/methods/public/entries.rs new file mode 100644 index 0000000000..60c6ac5d7c --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/entries.rs @@ -0,0 +1,247 @@ +use serde_json_bytes::ByteString; +use serde_json_bytes::Map as JSONMap; +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; + +use crate::connectors::ConnectSpec; +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::helpers::json_type_name; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::impl_arrow_method; + +impl_arrow_method!(EntriesMethod, entries_method, entries_shape); +/// Returns the keys and values given an object. +/// +/// The simplest possible example: +/// +/// $->echo({"a": 1, "b": "two", "c": false, })->entries +/// would result in [{ "key": "a", "value": 1 }, { "key": "b", "value": "two" }, { "key": "c", "value": false },] +/// +/// You can also use .key to grab just the keys: +/// $->echo({"a": 1, "b": "two", "c": false, })->entries.key +/// would result in ["a", "b", "c"] +/// +/// or you can also use .value to grab just the values: +/// $->echo({"a": 1, "b": "two", "c": false, })->entries.key +/// would result in [1, "two", false] +fn entries_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + _vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if method_args.is_some() { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + + match data { + JSON::Object(map) => { + let entries = map + .iter() + .map(|(key, value)| { + let mut key_value_pair = JSONMap::new(); + key_value_pair.insert(ByteString::from("key"), JSON::String(key.clone())); + key_value_pair.insert(ByteString::from("value"), value.clone()); + JSON::Object(key_value_pair) + }) + .collect(); + (Some(JSON::Array(entries)), Vec::new()) + } + _ => ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires an object input, not {}", + method_name.as_ref(), + json_type_name(data), + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ), + } +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn entries_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + mut input_shape: Shape, + _dollar_shape: Shape, +) -> Shape { + if method_args.is_some() { + return Shape::error( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + + match input_shape.case() { + ShapeCase::Object { fields, rest, .. } => { + let entry_shapes = fields + .iter() + .map(|(key, value)| { + let mut key_value_pair = Shape::empty_map(); + key_value_pair.insert( + "key".to_string(), + Shape::string_value(key.as_str(), Vec::new()), + ); + key_value_pair.insert("value".to_string(), value.clone()); + Shape::object( + key_value_pair, + Shape::none(), + method_name.shape_location(context.source_id()), + ) + }) + .collect::>(); + + if rest.is_none() { + Shape::array( + entry_shapes, + rest.clone(), + method_name.shape_location(context.source_id()), + ) + } else { + let mut tail_key_value_pair = Shape::empty_map(); + tail_key_value_pair.insert("key".to_string(), Shape::string(Vec::new())); + tail_key_value_pair.insert("value".to_string(), rest.clone()); + Shape::array( + entry_shapes, + Shape::object( + tail_key_value_pair, + Shape::none(), + method_name.shape_location(context.source_id()), + ), + method_name.shape_location(context.source_id()), + ) + } + } + ShapeCase::Name(_, _) => { + let mut entries = Shape::empty_map(); + entries.insert("key".to_string(), Shape::string(Vec::new())); + entries.insert("value".to_string(), input_shape.any_field(Vec::new())); + Shape::list( + Shape::object( + entries, + Shape::none(), + method_name.shape_location(context.source_id()), + ), + method_name.shape_location(context.source_id()), + ) + } + _ => Shape::error( + format!("Method ->{} requires an object input", method_name.as_ref()), + { + input_shape + .locations + .extend(method_name.shape_location(context.source_id())); + input_shape.locations + }, + ), + } +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use crate::connectors::ApplyToError; + use crate::selection; + + #[test] + fn entries_should_return_keys_and_values_when_applied_to_object() { + assert_eq!( + selection!("$->entries").apply_to(&json!({ + "a": 1, + "b": "two", + "c": false, + })), + ( + Some(json!([ + { "key": "a", "value": 1 }, + { "key": "b", "value": "two" }, + { "key": "c", "value": false }, + ])), + vec![], + ), + ); + } + + #[test] + fn entries_should_return_only_keys_when_key_is_requested() { + assert_eq!( + // This is just like $->keys, given the automatic array mapping of + // .key, though you probably want to use ->keys directly because it + // avoids cloning all the values unnecessarily. + selection!("$->entries.key").apply_to(&json!({ + "one": 1, + "two": 2, + "three": 3, + })), + (Some(json!(["one", "two", "three"])), vec![]), + ); + } + + #[test] + fn entries_should_return_only_values_when_values_is_requested() { + assert_eq!( + // This is just like $->values, given the automatic array mapping of + // .value, though you probably want to use ->values directly because + // it avoids cloning all the keys unnecessarily. + selection!("$->entries.value").apply_to(&json!({ + "one": 1, + "two": 2, + "three": 3, + })), + (Some(json!([1, 2, 3])), vec![]), + ); + } + + #[test] + fn entries_should_return_empty_array_when_applied_to_empty_object() { + assert_eq!( + selection!("$->entries").apply_to(&json!({})), + (Some(json!([])), vec![]), + ); + } + + #[test] + fn entries_should_error_when_applied_to_non_object() { + assert_eq!( + selection!("notAnObject->entries").apply_to(&json!({ + "notAnObject": true, + })), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->entries requires an object input, not boolean", + "path": ["notAnObject", "->entries"], + "range": [13, 20], + }))] + ), + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/eq.rs b/apollo-federation/src/connectors/json_selection/methods/public/eq.rs new file mode 100644 index 0000000000..0178a4e1cb --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/eq.rs @@ -0,0 +1,500 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::json_selection::methods::common::number_value_as_float; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(EqMethod, eq_method, eq_shape); +/// Returns true if argument is equal to the applied to value or false if they are not equal. +/// Simple examples: +/// +/// $(123)->eq(123) results in true +/// $(123)->eq(456) results in false +fn eq_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if let Some(MethodArgs { args, .. }) = method_args + && let [arg] = args.as_slice() + { + let (value_opt, arg_errors) = arg.apply_to_path(data, vars, input_path, spec); + let mut apply_to_errors = arg_errors; + let matches = value_opt.and_then(|value| match (data, &value) { + // Number comparisons: Always convert to float so 1 == 1.0 + (JSON::Number(left), JSON::Number(right)) => { + let left = match number_value_as_float(left, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + let right = match number_value_as_float(right, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + + Some(JSON::Bool(left == right)) + } + // Everything else + _ => Some(JSON::Bool(&value == data)), + }); + + return (matches, apply_to_errors); + } + ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires exactly one argument", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ) +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn eq_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let arg_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if arg_count > 1 { + return Shape::error( + format!( + "Method ->{} requires only one argument, but {arg_count} were provided", + method_name.as_ref(), + ), + vec![], + ); + } + + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + method_name.shape_location(context.source_id()), + ); + }; + let arg_shape = first_arg.compute_output_shape(context, input_shape.clone(), dollar_shape); + + // Ensures that the arguments are of the same type... this includes covering cases like int/float and unknown/name + if !(input_shape.accepts(&arg_shape) || arg_shape.accepts(&input_shape)) { + return Shape::error_with_partial( + format!( + "Method ->{} can only compare values of the same type. Got {input_shape} == {arg_shape}.", + method_name.as_ref() + ), + Shape::bool_value(false, method_name.shape_location(context.source_id())), + method_name.shape_location(context.source_id()), + ); + } + + Shape::bool(method_name.shape_location(context.source_id())) +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn eq_should_return_true_when_applied_to_equals_argument() { + assert_eq!( + selection!( + r#" + result: value->eq(123) + "# + ) + .apply_to(&json!({ "value": 123 })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn eq_should_return_false_when_applied_to_does_not_equal_argument() { + assert_eq!( + selection!( + r#" + result: value->eq(1234) + "# + ) + .apply_to(&json!({ "value": 123 })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn eq_should_return_true_when_applied_to_numbers_of_different_types() { + assert_eq!( + selection!( + r#" + result: value->eq(1) + "# + ) + .apply_to(&json!({ "value": 1.0 })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn eq_should_return_true_when_applied_to_negative_numbers_of_different_types() { + assert_eq!( + selection!( + r#" + result: value->eq(-1) + "# + ) + .apply_to(&json!({ "value": -1.0 })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn eq_should_return_true_when_applied_to_equals_string_argument() { + assert_eq!( + selection!( + r#" + result: value->eq("hello") + "# + ) + .apply_to(&json!({ "value": "hello" })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn eq_should_return_false_when_applied_to_does_not_equal_string_argument() { + assert_eq!( + selection!( + r#" + result: value->eq("world") + "# + ) + .apply_to(&json!({ "value": "hello" })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn eq_should_return_true_when_applied_to_equals_bool_argument() { + assert_eq!( + selection!( + r#" + result: value->eq(true) + "# + ) + .apply_to(&json!({ "value": true })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn eq_should_return_false_when_applied_to_does_not_equal_bool_argument() { + assert_eq!( + selection!( + r#" + result: value->eq(false) + "# + ) + .apply_to(&json!({ "value": true })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn eq_should_return_true_when_applied_to_equals_object_argument() { + assert_eq!( + selection!( + r#" + result: value->eq({"name": "John", "age": 30}) + "# + ) + .apply_to(&json!({ "value": {"name": "John", "age": 30} })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn eq_should_return_false_when_applied_to_does_not_equal_object_argument() { + assert_eq!( + selection!( + r#" + result: value->eq({"name": "Jane", "age": 25}) + "# + ) + .apply_to(&json!({ "value": {"name": "John", "age": 30} })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn eq_should_return_true_when_applied_to_equals_array_argument() { + assert_eq!( + selection!( + r#" + result: value->eq([1, 2, 3]) + "# + ) + .apply_to(&json!({ "value": [1, 2, 3] })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn eq_should_return_false_when_applied_to_does_not_equal_array_argument() { + assert_eq!( + selection!( + r#" + result: value->eq([4, 5, 6]) + "# + ) + .apply_to(&json!({ "value": [1, 2, 3] })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn eq_should_return_error_when_no_arguments_provided() { + let result = selection!( + r#" + result: value->eq() + "# + ) + .apply_to(&json!({ "value": 123 })); + + assert_eq!(result.0, Some(json!({}))); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->eq requires exactly one argument") + ); + } +} + +#[cfg(test)] +mod shape_tests { + use serde_json::Number; + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..7, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + eq_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("eq".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::none(), + ) + } + + #[test] + fn eq_shape_should_return_bool_on_valid_strings() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::string([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn eq_shape_should_return_bool_on_valid_numbers() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Number(Number::from(42)), None)], + Shape::int([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn eq_shape_should_return_bool_on_valid_booleans() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Bool(true), None)], + Shape::bool([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn eq_shape_should_error_on_mixed_types() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::int([]) + ), + Shape::error_with_partial( + "Method ->eq can only compare values of the same type. Got Int == \"a\"." + .to_string(), + Shape::bool_value(false, [get_location()]), + [get_location()] + ) + ); + } + + #[test] + fn eq_shape_should_error_on_no_args() { + assert_eq!( + get_shape(vec![], Shape::string([])), + Shape::error( + "Method ->eq requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn eq_shape_should_error_on_too_many_args() { + assert_eq!( + get_shape( + vec![ + WithRange::new(LitExpr::Number(Number::from(42)), None), + WithRange::new(LitExpr::Number(Number::from(43)), None) + ], + Shape::int([]) + ), + Shape::error( + "Method ->eq requires only one argument, but 2 were provided".to_string(), + [] + ) + ); + } + + #[test] + fn eq_shape_should_error_on_none_args() { + let location = get_location(); + assert_eq!( + eq_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("eq".to_string(), Some(location.span)), + None, + Shape::string([]), + Shape::none(), + ), + Shape::error( + "Method ->eq requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn eq_shape_should_return_bool_on_unknown_input() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("test".to_string()), None)], + Shape::unknown([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn eq_shape_should_return_bool_on_named_input() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Number(Number::from(42)), None)], + Shape::name("a", []) + ), + Shape::bool([get_location()]) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/filter.rs b/apollo-federation/src/connectors/json_selection/methods/public/filter.rs new file mode 100644 index 0000000000..a976fe22d2 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/filter.rs @@ -0,0 +1,441 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(FilterMethod, filter_method, filter_shape); +/// "Filter" is an array method that returns a new array containing only items that match the criteria. +/// You can use it to filter an array of values based on a boolean condition. +/// +/// For example, given a selection of [1, 2, 3, 4, 5]: +/// +/// $->filter(@->eq(3)) result is [3] +/// $->filter(@->gt(3)) result is [4, 5] +/// +/// We are taking each value passed into filter via @ and running the condition function against that value. +/// Only values where the condition returns true will be included in the result array. +/// +/// Example with objects: +/// users->filter(@.active->eq(true)) returns only users where active is true +fn filter_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return ( + None, + vec![ApplyToError::new( + format!("Method ->{} requires one argument", method_name.as_ref()), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + if let JSON::Array(array) = data { + let mut output = Vec::new(); + let mut errors = Vec::new(); + let mut has_non_boolean_error = false; + + for (i, element) in array.iter().enumerate() { + let input_path = input_path.append(JSON::Number(i.into())); + let (applied_opt, arg_errors) = + first_arg.apply_to_path(element, vars, &input_path, spec); + errors.extend(arg_errors); + + match applied_opt { + Some(JSON::Bool(true)) => { + output.push(element.clone()); + } + Some(JSON::Bool(false)) => { + // Condition is false or errored, exclude the element + } + Some(_) | None => { + // Condition returned a non-boolean value, this is an error + has_non_boolean_error = true; + errors.push(ApplyToError::new( + format!( + "->{} condition must return a boolean value", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )); + } + } + } + + if has_non_boolean_error { + (None, errors) + } else { + (Some(JSON::Array(output)), errors) + } + } else { + // For non-array inputs, treat as single-element array + // Apply filter condition and return either [value] or [] + let (condition_result, mut condition_errors) = + first_arg.apply_to_path(data, vars, input_path, spec); + + match condition_result { + Some(JSON::Bool(true)) => (Some(JSON::Array(vec![data.clone()])), condition_errors), + Some(JSON::Bool(false)) => (Some(JSON::Array(vec![])), condition_errors), + Some(_) => { + // Condition returned a non-boolean value, this is an error + condition_errors.push(ApplyToError::new( + format!( + "->{} condition must return a boolean value", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )); + (None, condition_errors) + } + None => { + // Condition errored, errors are already in condition_errors + (None, condition_errors) + } + } + } +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn filter_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let arg_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if arg_count > 1 { + return Shape::error( + format!( + "Method ->{} requires only one argument, but {arg_count} were provided", + method_name.as_ref(), + ), + vec![], + ); + } + + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + method_name.shape_location(context.source_id()), + ); + }; + + // Compute the shape of the filter condition argument + let condition_shape = + first_arg.compute_output_shape(context, input_shape.clone(), dollar_shape); + + // Validate that the condition evaluates to a boolean or + // something that could become a boolean + if !(matches!(condition_shape.case(), ShapeCase::Bool(_)) || + // This allows Unknown and Name shapes, which can produce boolean + // values at runtime, without any runtime errors. + condition_shape.accepts(&Shape::unknown([]))) + { + return Shape::error( + format!( + "->{} condition must return a boolean value", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + + Shape::list(input_shape.any_item([]), input_shape.locations) +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn filter_should_return_matching_elements() { + assert_eq!( + selection!("$->echo([1,2,3,4,5])->filter(@->eq(3))").apply_to(&json!(null)), + (Some(json!([3])), vec![]), + ); + } + + #[test] + fn filter_should_return_multiple_matches() { + assert_eq!( + selection!("$->echo([1,2,3,2,1])->filter(@->eq(2))").apply_to(&json!(null)), + (Some(json!([2, 2])), vec![]), + ); + } + + #[test] + fn filter_should_return_empty_array_when_no_matches() { + assert_eq!( + selection!("$->echo([1,2,3])->filter(@->eq(5))").apply_to(&json!(null)), + (Some(json!([])), vec![]), + ); + } + + #[test] + fn filter_should_work_with_object_properties() { + assert_eq!( + selection!("users->filter(@.active->eq(true))").apply_to(&json!({ + "users": [ + { "name": "Alice", "active": true }, + { "name": "Bob", "active": false }, + { "name": "Charlie", "active": true }, + ], + })), + ( + Some(json!([ + { "name": "Alice", "active": true }, + { "name": "Charlie", "active": true }, + ])), + vec![] + ), + ); + } + + #[test] + fn filter_should_handle_non_array_input_true_condition() { + assert_eq!( + selection!("value->filter(@->eq(123))").apply_to(&json!({ + "value": 123, + })), + (Some(json!([123])), vec![]), + ); + } + + #[test] + fn filter_should_handle_non_array_input_false_condition() { + assert_eq!( + selection!("value->filter(@->eq(456))").apply_to(&json!({ + "value": 123, + })), + (Some(json!([])), vec![]), + ); + } + + #[test] + fn filter_should_handle_complex_conditions() { + // Filter numbers greater than 3 by checking if they don't equal 1, 2, or 3 + assert_eq!( + selection!("numbers->filter(@->eq(4))").apply_to(&json!({ + "numbers": [1, 2, 3, 4, 5, 6], + })), + (Some(json!([4])), vec![]), + ); + } + + #[test] + fn filter_should_error_with_non_boolean_results() { + // Elements where the condition doesn't return a boolean should cause an error + // Using a string condition that evaluates to a non-boolean + let result = selection!("values->filter(@->echo('not_boolean'))").apply_to(&json!({ + "values": [1, 2, 3], + })); + + // Should return None and have errors + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("->filter condition must return a boolean value") + ); + } + + #[test] + fn filter_should_chain_with_other_methods() { + // Filter for numbers equal to 3, 4, or 5, then map each to multiply by 10 + assert_eq!( + selection!("$->echo([1,2,3,4,5])->filter(@->eq(3))->map(@->add(10))") + .apply_to(&json!(null)), + (Some(json!([13])), vec![]), + ); + } + + #[test] + fn filter_should_work_with_string_values() { + assert_eq!( + selection!("words->filter(@->eq('hello'))").apply_to(&json!({ + "words": ["hello", "world", "hello", "test"], + })), + (Some(json!(["hello", "hello"])), vec![]), + ); + } + + #[test] + fn filter_should_handle_mixed_types() { + assert_eq!( + selection!("values->filter(@->typeof->eq('number'))").apply_to(&json!({ + "values": [1, "hello", 2.5, true, null, 42], + })), + (Some(json!([1, 2.5, 42])), vec![]), + ); + } +} + +#[cfg(test)] +mod shape_tests { + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::Key; + use crate::connectors::PathSelection; + use crate::connectors::json_selection::PathList; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..6, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + filter_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("filter".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::unknown([]), + ) + } + + #[test] + fn filter_shape_should_return_list_on_valid_boolean_condition() { + let input_shape = Shape::list(Shape::int([]), []); + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Bool(true), None)], + input_shape.clone() + ), + input_shape + ); + } + + #[test] + fn filter_shape_should_return_list_for_array_input() { + let item_shape = Shape::string([]); + let input_shape = Shape::list(item_shape, []); + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Bool(true), None)], + input_shape.clone() + ), + Shape::list(input_shape.any_item([]), input_shape.locations) + ); + } + + #[test] + fn filter_shape_should_return_list_for_single_item_input() { + let input_shape = Shape::string([]); + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Bool(true), None)], + input_shape.clone() + ), + Shape::list(input_shape.any_item([]), input_shape.locations) + ); + } + + #[test] + fn filter_shape_should_error_on_non_boolean_condition() { + assert_eq!( + get_shape( + vec![WithRange::new( + LitExpr::String("not_bool".to_string()), + None + )], + Shape::string([]) + ), + Shape::error( + "->filter condition must return a boolean value".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn filter_shape_should_error_on_no_args() { + assert_eq!( + get_shape(vec![], Shape::string([])), + Shape::error( + "Method ->filter requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn filter_shape_should_error_on_too_many_args() { + assert_eq!( + get_shape( + vec![ + WithRange::new(LitExpr::Bool(true), None), + WithRange::new(LitExpr::Bool(false), None) + ], + Shape::string([]) + ), + Shape::error( + "Method ->filter requires only one argument, but 2 were provided".to_string(), + [] + ) + ); + } + + #[test] + fn filter_shape_should_error_on_none_args() { + let location = get_location(); + assert_eq!( + filter_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("filter".to_string(), Some(location.span)), + None, + Shape::string([]), + Shape::none(), + ), + Shape::error( + "Method ->filter requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn filter_shape_should_handle_unknown_condition_shape() { + let path = LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("a").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }); + let input_shape = Shape::list(Shape::int([]), []); + // Unknown shapes should be accepted as they could produce boolean values at runtime + let result = get_shape(vec![path.into_with_range()], input_shape.clone()); + assert_eq!(result, Shape::list(input_shape.any_item([]), [])); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/find.rs b/apollo-federation/src/connectors/json_selection/methods/public/find.rs new file mode 100644 index 0000000000..db2e7ef195 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/find.rs @@ -0,0 +1,444 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; + +use crate::connectors::ConnectSpec; +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::impl_arrow_method; + +impl_arrow_method!(FindMethod, find_method, find_shape); +/// "Find" is an array method that returns the first item that matches the criteria. +/// You can use it to find the first value in an array based on a boolean condition. +/// If no matching item is found, it returns None. +/// +/// For example, given a selection of [1, 2, 3, 4, 5]: +/// +/// $->find(@->eq(3)) result is 3 +/// $->find(@->gt(3)) result is 4 +/// $->find(@->eq(10)) result is None (no match) +/// +/// We are taking each value passed into find via @ and running the condition function against that value. +/// The first value where the condition returns true will be returned. +/// +/// Example with objects: +/// users->find(@.active->eq(true)) returns the first user where active is true +fn find_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return ( + None, + vec![ApplyToError::new( + format!("Method ->{} requires one argument", method_name.as_ref()), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + if let JSON::Array(array) = data { + let mut errors = Vec::new(); + + for (i, element) in array.iter().enumerate() { + let input_path = input_path.append(JSON::Number(i.into())); + let (applied_opt, arg_errors) = + first_arg.apply_to_path(element, vars, &input_path, spec); + errors.extend(arg_errors); + + match applied_opt { + Some(JSON::Bool(true)) => { + // Found the first matching element, return it + return (Some(element.clone()), errors); + } + Some(JSON::Bool(false)) => { + // Condition is false or errored, continue searching + } + Some(_) | None => { + // Condition returned a non-boolean value, this is an error + errors.push(ApplyToError::new( + format!( + "->{} condition must return a boolean value", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )); + return (None, errors); + } + } + } + + // No matching element found + (None, errors) + } else { + // For non-array inputs, treat as single-element array + // Apply find condition and return either the value or None + let (condition_result, mut condition_errors) = + first_arg.apply_to_path(data, vars, input_path, spec); + + match condition_result { + Some(JSON::Bool(true)) => (Some(data.clone()), condition_errors), + Some(JSON::Bool(false)) => (None, condition_errors), + Some(_) => { + // Condition returned a non-boolean value, this is an error + condition_errors.push(ApplyToError::new( + format!( + "->{} condition must return a boolean value", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )); + (None, condition_errors) + } + None => { + // Condition errored, errors are already in condition_errors + (None, condition_errors) + } + } + } +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn find_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let arg_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if arg_count > 1 { + return Shape::error( + format!( + "Method ->{} requires only one argument, but {arg_count} were provided", + method_name.as_ref(), + ), + vec![], + ); + } + + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + method_name.shape_location(context.source_id()), + ); + }; + + // Compute the shape of the find condition argument + let condition_shape = + first_arg.compute_output_shape(context, input_shape.clone(), dollar_shape); + + // Validate that the condition evaluates to a boolean or + // something that could become a boolean + if !(matches!(condition_shape.case(), ShapeCase::Bool(_)) || + // This allows Unknown and Name shapes, which can produce boolean + // values at runtime, without any runtime errors. + condition_shape.accepts(&Shape::unknown([]))) + { + return Shape::error( + format!( + "->{} condition must return a boolean value", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + + // Find returns a single item (or None), so we return the item type of the input shape + Shape::one([Shape::none(), input_shape.any_item([])], []) +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn find_should_return_first_matching_element() { + assert_eq!( + selection!("$->echo([1,2,3,4,5])->find(@->eq(3))").apply_to(&json!(null)), + (Some(json!(3)), vec![]), + ); + } + + #[test] + fn find_should_return_first_match_when_multiple_exist() { + assert_eq!( + selection!("$->echo([1,2,3,2,1])->find(@->eq(2))").apply_to(&json!(null)), + (Some(json!(2)), vec![]), + ); + } + + #[test] + fn find_should_return_none_when_no_matches() { + assert_eq!( + selection!("$->echo([1,2,3])->find(@->eq(5))").apply_to(&json!(null)), + (None, vec![]), + ); + } + + #[test] + fn find_should_work_with_object_properties() { + assert_eq!( + selection!("users->find(@.active->eq(true))").apply_to(&json!({ + "users": [ + { "name": "Alice", "active": false }, + { "name": "Bob", "active": true }, + { "name": "Charlie", "active": true }, + ], + })), + (Some(json!({ "name": "Bob", "active": true })), vec![]), + ); + } + + #[test] + fn find_should_handle_non_array_input_true_condition() { + assert_eq!( + selection!("value->find(@->eq(123))").apply_to(&json!({ + "value": 123, + })), + (Some(json!(123)), vec![]), + ); + } + + #[test] + fn find_should_handle_non_array_input_false_condition() { + assert_eq!( + selection!("value->find(@->eq(456))").apply_to(&json!({ + "value": 123, + })), + (None, vec![]), + ); + } + + #[test] + fn find_should_handle_complex_conditions() { + // Find the first number greater than 3 + assert_eq!( + selection!("numbers->find(@->gt(3))").apply_to(&json!({ + "numbers": [1, 2, 3, 4, 5, 6], + })), + (Some(json!(4)), vec![]), + ); + } + + #[test] + fn find_should_error_with_non_boolean_results() { + // Elements where the condition doesn't return a boolean should cause an error + // Using a string condition that evaluates to a non-boolean + let result = selection!("values->find(@->echo('not_boolean'))").apply_to(&json!({ + "values": [1, 2, 3], + })); + + // Should return None and have errors + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("->find condition must return a boolean value") + ); + } + + #[test] + fn find_should_chain_with_other_methods() { + // Find the first number equal to 3, then add 10 to it + assert_eq!( + selection!("$->echo([1,2,3,4,5])->find(@->eq(3))->add(10)").apply_to(&json!(null)), + (Some(json!(13)), vec![]), + ); + } + + #[test] + fn find_should_work_with_string_values() { + assert_eq!( + selection!("words->find(@->eq('hello'))").apply_to(&json!({ + "words": ["world", "hello", "test", "hello"], + })), + (Some(json!("hello")), vec![]), + ); + } + + #[test] + fn find_should_handle_mixed_types() { + assert_eq!( + selection!("values->find(@->typeof->eq('string'))").apply_to(&json!({ + "values": [1, "hello", 2.5, true, null, 42], + })), + (Some(json!("hello")), vec![]), + ); + } + + #[test] + fn find_should_return_none_for_empty_array() { + assert_eq!( + selection!("$->echo([])->find(@->eq(1))").apply_to(&json!(null)), + (None, vec![]), + ); + } +} + +#[cfg(test)] +mod shape_tests { + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::Key; + use crate::connectors::PathSelection; + use crate::connectors::json_selection::PathList; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..4, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + find_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("find".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::unknown([]), + ) + } + + #[test] + fn find_shape_should_return_item_type_on_valid_boolean_condition() { + let input_shape = Shape::list(Shape::int([]), []); + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Bool(true), None)], + input_shape.clone() + ), + Shape::one([Shape::none(), input_shape.any_item([])], []) + ); + } + + #[test] + fn find_shape_should_return_item_type_for_array_input() { + let item_shape = Shape::string([]); + let input_shape = Shape::list(item_shape, []); + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Bool(true), None)], + input_shape.clone() + ), + Shape::one([Shape::none(), input_shape.any_item([])], []) + ); + } + + #[test] + fn find_shape_should_return_item_type_for_single_item_input() { + let input_shape = Shape::string([]); + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Bool(true), None)], + input_shape.clone() + ), + Shape::one([Shape::none(), input_shape.any_item([])], []) + ); + } + + #[test] + fn find_shape_should_error_on_non_boolean_condition() { + assert_eq!( + get_shape( + vec![WithRange::new( + LitExpr::String("not_bool".to_string()), + None + )], + Shape::string([]) + ), + Shape::error( + "->find condition must return a boolean value".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn find_shape_should_error_on_no_args() { + assert_eq!( + get_shape(vec![], Shape::string([])), + Shape::error( + "Method ->find requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn find_shape_should_error_on_too_many_args() { + assert_eq!( + get_shape( + vec![ + WithRange::new(LitExpr::Bool(true), None), + WithRange::new(LitExpr::Bool(false), None) + ], + Shape::string([]) + ), + Shape::error( + "Method ->find requires only one argument, but 2 were provided".to_string(), + [] + ) + ); + } + + #[test] + fn find_shape_should_error_on_none_args() { + let location = get_location(); + assert_eq!( + find_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("find".to_string(), Some(location.span)), + None, + Shape::string([]), + Shape::none(), + ), + Shape::error( + "Method ->find requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn find_shape_should_handle_unknown_condition_shape() { + let path = LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("a").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }); + let input_shape = Shape::list(Shape::int([]), []); + // Unknown shapes should be accepted as they could produce boolean values at runtime + let result = get_shape(vec![path.into_with_range()], input_shape.clone()); + assert_eq!( + result, + Shape::one([Shape::none(), input_shape.any_item([])], []) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/first.rs b/apollo-federation/src/connectors/json_selection/methods/public/first.rs new file mode 100644 index 0000000000..01b8151e79 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/first.rs @@ -0,0 +1,136 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(FirstMethod, first_method, first_shape); +/// The "first" method is a utility function that can be run against an array to grab the 0th item from it +/// or a string to get the first character. +/// The simplest possible example: +/// +/// $->echo([1,2,3])->first results in 1 +/// $->echo("hello")->first results in "h" +fn first_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + _vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if method_args.is_some() { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + + match data { + JSON::Array(array) => (array.first().cloned(), Vec::new()), + JSON::String(s) => s.as_str().chars().next().map_or_else( + || (None, Vec::new()), + |first| (Some(JSON::String(first.to_string().into())), Vec::new()), + ), + _ => ( + Some(data.clone()), + vec![ApplyToError::new( + format!( + "Method ->{} requires an array or string input", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ), + } +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn first_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + _dollar_shape: Shape, +) -> Shape { + let location = method_name.shape_location(context.source_id()); + if method_args.is_some() { + return Shape::error( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + location, + ); + } + + // Location is not solely based on the method, but also the type the method is being applied to + let locations = input_shape.locations.iter().cloned().chain(location); + + match input_shape.case() { + ShapeCase::String(Some(value)) => Shape::string_value(&value[0..1], locations), + ShapeCase::String(None) => Shape::string(locations), + ShapeCase::Array { prefix, tail } => match (prefix.first(), tail) { + (Some(first), _) => first.clone(), + (_, tail) if tail.is_none() => Shape::none(), + _ => Shape::one([tail.clone(), Shape::none()], locations), + }, + ShapeCase::Name(_, _) => input_shape.item(0, locations), + ShapeCase::Unknown => Shape::unknown(locations), + // When there is no obvious first element, ->first gives us the input + // value itself, which has input_shape. + _ => Shape::error_with_partial( + format!( + "Method ->{} requires an array or string input", + method_name.as_ref() + ), + input_shape.clone(), + locations, + ), + } +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn first_should_get_first_element_from_array() { + assert_eq!( + selection!("$->first").apply_to(&json!([1, 2, 3])), + (Some(json!(1)), vec![]), + ); + } + + #[test] + fn first_should_get_none_when_no_items_exist() { + assert_eq!(selection!("$->first").apply_to(&json!([])), (None, vec![]),); + } + + #[test] + fn first_should_get_first_char_from_string() { + assert_eq!( + selection!("$->first").apply_to(&json!("hello")), + (Some(json!("h")), vec![]), + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/get.rs b/apollo-federation/src/connectors/json_selection/methods/public/get.rs new file mode 100644 index 0000000000..dbbdcb1c7b --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/get.rs @@ -0,0 +1,1280 @@ +use serde_json_bytes::ByteString; +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; +use shape::location::SourceId; + +use crate::connectors::ConnectSpec; +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::helpers::vec_push; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::lit_expr::LitExpr; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::impl_arrow_method; + +impl_arrow_method!(GetMethod, get_method, get_shape); +/// For a string, gets the char at the specified index. +/// For an array, gets the item at the specified index. +/// For an object, gets the property with the specified name. +/// +/// Examples: +/// $->echo("hello")->get(0) returns "h" +/// $->echo([1,2,3])->get(0) returns 1 +/// $->echo({"a": "hello"})->get("a") returns "hello" +fn get_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let Some(index_literal) = method_args.and_then(|MethodArgs { args, .. }| args.first()) else { + return ( + None, + vec![ApplyToError::new( + format!("Method ->{} requires an argument", method_name.as_ref()), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + match data { + JSON::String(input_value) => handle_string_method( + method_name, + index_literal, + input_value, + vars, + input_path, + data, + spec, + ), + JSON::Array(input_value) => handle_array_method( + method_name, + index_literal, + input_value, + vars, + input_path, + data, + spec, + ), + JSON::Object(input_value) => handle_object_method( + method_name, + index_literal, + input_value, + vars, + input_path, + data, + spec, + ), + _ => ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} must be applied to a string, array, or object", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ), + } +} + +fn handle_string_method( + method_name: &WithRange, + index_literal: &WithRange, + input_value: &ByteString, + vars: &VarsWithPathsMap, + input_path: &InputPath, + data: &JSON, + spec: ConnectSpec, +) -> (Option, Vec) { + let (index, index_apply_to_errors) = index_literal.apply_to_path(data, vars, input_path, spec); + + match index { + Some(JSON::Number(index_value)) => { + let Some(index_value) = index_value.as_i64() else { + return ( + None, + vec_push( + index_apply_to_errors, + ApplyToError::new( + format!( + "Method ->{} failed to convert number index to integer", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + ), + ), + ); + }; + + // Create this error "just in time" to avoid unneeded memory allocation but also allows us to capture current index_value before it is manipulated for negative indexes + let out_of_bounds_error = |index_apply_to_errors| { + vec_push( + index_apply_to_errors, + ApplyToError::new( + format!( + "Method ->{} index {index_value} out of bounds in string of length {}", + method_name.as_ref(), + input_value.as_str().len() + ), + input_path.to_vec(), + method_name.range(), + spec, + ), + ) + }; + + if let Some(value) = get_string(index_value, input_value.as_str()) { + (Some(JSON::String(value.into())), index_apply_to_errors) + } else { + (None, out_of_bounds_error(index_apply_to_errors)) + } + } + Some(index_value) => ( + None, + vec_push( + index_apply_to_errors, + ApplyToError::new( + format!( + "Method ->{} on a string requires a integer index, got {index_value}", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + ), + ), + ), + None => (None, index_apply_to_errors), + } +} + +fn get_string(index: i64, value: &str) -> Option<&str> { + let index_value = if index < 0 { + value.len() as i64 + index + } else { + index + }; + + if index_value < 0 { + return None; + } + + let index_value = index_value as usize; + value.get(index_value..=index_value) +} + +fn handle_array_method( + method_name: &WithRange, + index_literal: &WithRange, + input_value: &[JSON], + vars: &VarsWithPathsMap, + input_path: &InputPath, + data: &JSON, + spec: ConnectSpec, +) -> (Option, Vec) { + let (index, index_apply_to_errors) = index_literal.apply_to_path(data, vars, input_path, spec); + + match index { + Some(JSON::Number(index_value)) => { + let Some(index_value) = index_value.as_i64() else { + return ( + None, + vec_push( + index_apply_to_errors, + ApplyToError::new( + format!( + "Method ->{} failed to convert number index to integer", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + ), + ), + ); + }; + + // Create this error "just in time" to avoid unneeded memory allocation but also allows us to capture current index_value before it is manipulated for negative indexes + let out_of_bounds_error = |index_apply_to_errors| { + vec_push( + index_apply_to_errors, + ApplyToError::new( + format!( + "Method ->{} index {index_value} out of bounds in array of length {}", + method_name.as_ref(), + input_value.len() + ), + input_path.to_vec(), + method_name.range(), + spec, + ), + ) + }; + + // Negative values should count from the back of the string so we add it to the length when it is negative + if let Some(value) = get_array(index_value, input_value) { + (Some(value.clone()), index_apply_to_errors) + } else { + (None, out_of_bounds_error(index_apply_to_errors)) + } + } + Some(index_value) => ( + None, + vec_push( + index_apply_to_errors, + ApplyToError::new( + format!( + "Method ->{} on an array requires a integer index, got {index_value}", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + ), + ), + ), + None => (None, index_apply_to_errors), + } +} + +fn get_array(index: i64, array: &[T]) -> Option<&T> { + let index = if index < 0 { + array.len() as i64 + index + } else { + index + }; + + if index < 0 { + return None; + } + + let index = index as usize; + array.get(index) +} + +fn handle_object_method( + method_name: &WithRange, + index_literal: &WithRange, + input_value: &serde_json_bytes::Map, + vars: &VarsWithPathsMap, + input_path: &InputPath, + data: &JSON, + spec: ConnectSpec, +) -> (Option, Vec) { + let (index, index_apply_to_errors) = index_literal.apply_to_path(data, vars, input_path, spec); + + match index { + Some(JSON::String(index_value)) => { + let index_value = index_value.as_str(); + + if let Some(value) = input_value.get(index_value) { + (Some(value.clone()), index_apply_to_errors) + } else { + ( + None, + vec_push( + index_apply_to_errors, + ApplyToError::new( + format!( + "Method ->{} property {index_value} not found in object", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + ), + ), + ) + } + } + Some(index_value) => ( + None, + vec_push( + index_apply_to_errors, + ApplyToError::new( + format!( + "Method ->{} on an object requires a string index, got {index_value}", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + ), + ), + ), + None => (None, index_apply_to_errors), + } +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn get_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let arg_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if arg_count > 1 { + return Shape::error( + format!( + "Method ->{} requires only one argument, but {arg_count} were provided", + method_name.as_ref(), + ), + vec![], + ); + } + + let Some(index_literal) = method_args.and_then(|args| args.args.first()) else { + return Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + method_name.shape_location(context.source_id()), + ); + }; + + let index_shape = + index_literal.compute_output_shape(context, input_shape.clone(), dollar_shape); + + if Shape::string([]).accepts(&input_shape) { + handle_string_shape(method_name, &input_shape, &index_shape, context.source_id()) + } else if Shape::tuple([], []).accepts(&input_shape) { + handle_array_shape(method_name, &input_shape, &index_shape, context.source_id()) + } else if Shape::empty_object([]).accepts(&input_shape) { + handle_object_shape(method_name, &input_shape, &index_shape, context.source_id()) + } else if input_shape.accepts(&Shape::unknown([])) { + handle_unknown_shape(method_name, &index_shape, context.source_id()) + } else { + Shape::error( + format!( + "Method ->{} must be applied to a string, array, or object", + method_name.as_ref() + ) + .as_str(), + method_name.shape_location(context.source_id()), + ) + } +} + +fn handle_string_shape( + method_name: &WithRange, + input_shape: &Shape, + index_shape: &Shape, + source_id: &SourceId, +) -> Shape { + // Handle Strings: Get a character at a integer index + let index_value = if Shape::int([]).accepts(index_shape) { + let ShapeCase::Int(Some(index_value)) = index_shape.case() else { + return Shape::string(method_name.shape_location(source_id)); + }; + index_value + } else if index_shape.accepts(&Shape::unknown([])) { + return Shape::string(method_name.shape_location(source_id)); + } else { + return Shape::error( + format!( + "Method ->{} must be provided an integer argument when applied to a string", + method_name.as_ref() + ) + .as_str(), + method_name.shape_location(source_id), + ); + }; + + let ShapeCase::String(Some(input_value)) = input_shape.case() else { + return Shape::string(method_name.shape_location(source_id)); + }; + + let out_of_bounds_error = || { + Shape::error( + format!( + "Method ->{} index {index_value} out of bounds in string of length {}", + method_name.as_ref(), + input_value.len() + ) + .as_str(), + method_name.shape_location(source_id), + ) + }; + + if let Some(value) = get_string(*index_value, input_value) { + Shape::string_value(value, method_name.shape_location(source_id)) + } else { + out_of_bounds_error() + } +} + +fn handle_array_shape( + method_name: &WithRange, + input_shape: &Shape, + index_shape: &Shape, + source_id: &SourceId, +) -> Shape { + // Handle Arrays: Get an array item at a integer index + let index_value = if Shape::int([]).accepts(index_shape) { + let ShapeCase::Int(Some(index_value)) = index_shape.case() else { + return input_shape.any_item(method_name.shape_location(source_id)); + }; + index_value + } else if index_shape.accepts(&Shape::unknown([])) { + return input_shape.any_item(method_name.shape_location(source_id)); + } else { + return Shape::error( + format!( + "Method ->{} must be provided an integer argument when applied to an array", + method_name.as_ref() + ) + .as_str(), + method_name.shape_location(source_id), + ); + }; + + let ShapeCase::Array { prefix, tail } = input_shape.case() else { + return input_shape.any_item(method_name.shape_location(source_id)); + }; + + let out_of_bounds_error = || { + Shape::error( + format!( + "Method ->{} index {index_value} out of bounds in array of length {}", + method_name.as_ref(), + prefix.len() + ) + .as_str(), + method_name.shape_location(source_id), + ) + }; + + if let Some(item) = get_array(*index_value, prefix) { + item.clone() + } else if !tail.is_none() { + // If we have a tail, we cannot know for sure if the item exists at the index or not + // This is because a tail implies that there are 0 to many items of that type + input_shape.any_item(method_name.shape_location(source_id)) + } else { + out_of_bounds_error() + } +} + +fn handle_object_shape( + method_name: &WithRange, + input_shape: &Shape, + index_shape: &Shape, + source_id: &SourceId, +) -> Shape { + // Handle Objects: Get an object property at a string index + let index_value = if Shape::string([]).accepts(index_shape) { + let ShapeCase::String(Some(index_value)) = index_shape.case() else { + return input_shape.any_field(method_name.shape_location(source_id)); + }; + index_value + } else if index_shape.accepts(&Shape::unknown([])) { + return input_shape.any_field(method_name.shape_location(source_id)); + } else { + return Shape::error( + format!( + "Method ->{} must be provided an string argument when applied to an object", + method_name.as_ref() + ) + .as_str(), + method_name.shape_location(source_id), + ); + }; + + let ShapeCase::Object { fields, rest } = input_shape.case() else { + return input_shape.any_field(method_name.shape_location(source_id)); + }; + + if let Some(item) = fields.get(index_value) { + item.clone() + } else if !rest.is_none() { + // If we have a rest, we cannot know for sure if the item exists at the index or not + // This is because a rest implies that there are 0 to many items of that type + input_shape.any_field(method_name.shape_location(source_id)) + } else { + Shape::error( + format!( + "Method ->{} property {index_value} not found in object", + method_name.as_ref() + ) + .as_str(), + method_name.shape_location(source_id), + ) + } +} + +fn handle_unknown_shape( + method_name: &WithRange, + index_shape: &Shape, + source_id: &SourceId, +) -> Shape { + if Shape::int([]).accepts(index_shape) + || index_shape.accepts(&Shape::unknown([])) + || Shape::string([]).accepts(index_shape) + { + Shape::unknown(method_name.shape_location(source_id)) + } else { + Shape::error( + format!( + "Method ->{} must be provided an integer or string argument", + method_name.as_ref() + ) + .as_str(), + method_name.shape_location(source_id), + ) + } +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use super::*; + use crate::assert_debug_snapshot; + use crate::selection; + + #[test] + fn get_should_return_item_from_array_at_specified_index() { + assert_eq!( + selection!("$->get(1)").apply_to(&json!([1, 2, 3])), + (Some(json!(2)), vec![]), + ); + } + + #[test] + fn get_should_return_item_from_array_at_specified_negative_index() { + assert_eq!( + selection!("$->get(-1)").apply_to(&json!([1, 2, 3])), + (Some(json!(3)), vec![]), + ); + } + + #[test] + fn get_should_return_item_from_arrays_when_used_in_map() { + assert_eq!( + selection!("numbers->map(@->get(-2))").apply_to(&json!({ + "numbers": [ + [1, 2, 3], + [5, 6], + ], + })), + (Some(json!([2, 5])), vec![]), + ); + } + + #[test] + fn get_should_return_error_when_specified_array_index_does_not_exist() { + assert_eq!( + selection!("$->get(3)").apply_to(&json!([1, 2, 3])), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get index 3 out of bounds in array of length 3", + "path": ["->get"], + "range": [3, 6], + }))] + ), + ); + } + + #[test] + fn get_should_return_error_when_specified_array_negative_index_does_not_exist() { + assert_eq!( + selection!("$->get(-4)").apply_to(&json!([1, 2, 3])), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get index -4 out of bounds in array of length 3", + "path": ["->get"], + "range": [3, 6], + }))] + ), + ); + } + + #[test] + fn get_should_return_error_when_no_argument_provided() { + assert_eq!( + selection!("$->get").apply_to(&json!([1, 2, 3])), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get requires an argument", + "path": ["->get"], + "range": [3, 6], + }))] + ), + ); + } + + #[test] + fn get_should_return_error_when_string_index_applied_to_array() { + assert_eq!( + selection!("$->get('bogus')").apply_to(&json!([1, 2, 3])), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get on an array requires a integer index, got \"bogus\"", + "path": ["->get"], + "range": [3, 6], + }))] + ), + ); + } + + #[test] + fn get_should_return_char_from_string_at_specified_index() { + assert_eq!( + selection!("$->get(2)").apply_to(&json!("oyez")), + (Some(json!("e")), vec![]), + ); + } + + #[test] + fn get_should_return_char_from_string_at_specified_negative_index() { + assert_eq!( + selection!("$->get(-1)").apply_to(&json!("oyez")), + (Some(json!("z")), vec![]), + ); + } + + #[test] + fn get_should_return_error_when_specified_string_index_does_not_exist() { + assert_eq!( + selection!("$->get(4)").apply_to(&json!("oyez")), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get index 4 out of bounds in string of length 4", + "path": ["->get"], + "range": [3, 6], + }))] + ), + ); + } + + #[test] + fn get_should_return_error_when_calculated_string_index_does_not_exist() { + let expected = ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get index -10 out of bounds in string of length 4", + "path": ["->get"], + "range": [3, 6], + }))], + ); + assert_eq!( + selection!("$->get($->echo(-5)->mul(2))").apply_to(&json!("oyez")), + expected, + ); + assert_eq!( + // The extra spaces here should not affect the error.range, as long + // as we don't accidentally capture trailing spaces in the range. + selection!("$->get($->echo(-5)->mul(2) )").apply_to(&json!("oyez")), + expected, + ); + } + + #[test] + fn get_should_correct_call_methods_with_extra_spaces() { + // All these extra spaces certainly do affect the error.range, but it's + // worth testing that we get all the ranges right, even with so much + // space that could be accidentally captured. + let selection_with_spaces = selection!(" $ -> get ( $ -> echo ( - 5 ) -> mul ( 2 ) ) "); + assert_eq!( + selection_with_spaces.apply_to(&json!("oyez")), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get index -10 out of bounds in string of length 4", + "path": ["->get"], + "range": [6, 9], + }))] + ) + ); + assert_debug_snapshot!(selection_with_spaces); + } + + #[test] + fn get_should_return_error_when_passing_bool_as_index() { + assert_eq!( + selection!("$->get(true)").apply_to(&json!("input")), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get on a string requires a integer index, got true", + "path": ["->get"], + "range": [3, 6], + }))] + ), + ); + } + + #[test] + fn get_should_return_item_from_object_at_specified_property() { + assert_eq!( + selection!("$->get('a')").apply_to(&json!({ + "a": 1, + "b": 2, + "c": 3, + })), + (Some(json!(1)), vec![]), + ); + } + + #[test] + fn get_should_throw_error_when_object_property_does_not_exist() { + assert_eq!( + selection!("$->get('d')").apply_to(&json!({ + "a": 1, + "b": 2, + "c": 3, + })), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->get property d not found in object", + "path": ["->get"], + "range": [3, 6], + }))] + ), + ); + } + + #[test] + fn get_should_be_able_to_chain_method_off_of_selected_object_property() { + assert_eq!( + selection!("$->get('a')->add(10)").apply_to(&json!({ + "a": 1, + "b": 2, + "c": 3, + })), + (Some(json!(11)), vec![]), + ); + } +} + +#[cfg(test)] +mod shape_tests { + use indexmap::IndexMap; + use serde_json::Number; + use shape::location::Location; + + use super::*; + use crate::connectors::Key; + use crate::connectors::PathSelection; + use crate::connectors::json_selection::PathList; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..7, + } + } + + fn get_test_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + get_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("get".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::unknown([]), + ) + } + + #[test] + fn get_shape_should_error_on_no_args() { + let location = get_location(); + assert_eq!( + get_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("get".to_string(), Some(location.span)), + None, + Shape::string([]), + Shape::none(), + ), + Shape::error( + "Method ->get requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_error_on_too_many_args() { + assert_eq!( + get_test_shape( + vec![ + WithRange::new(LitExpr::Number(Number::from(0)), None), + WithRange::new(LitExpr::Number(Number::from(1)), None) + ], + Shape::string([]) + ), + Shape::error( + "Method ->get requires only one argument, but 2 were provided".to_string(), + [] + ) + ); + } + + #[test] + fn get_shape_should_return_char_for_string_with_valid_int_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(1)), None)], + Shape::string_value("hello", []) + ), + Shape::string_value("e", [get_location()]) + ); + } + + #[test] + fn get_shape_should_return_char_for_string_with_negative_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(-1)), None)], + Shape::string_value("hello", []) + ), + Shape::string_value("o", [get_location()]) + ); + } + + #[test] + fn get_shape_should_return_string_for_string_without_known_value() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(0)), None)], + Shape::string([]) + ), + Shape::string([get_location()]) + ); + } + + #[test] + fn get_shape_should_error_for_string_with_out_of_bounds_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(10)), None)], + Shape::string_value("hello", []) + ), + Shape::error( + "Method ->get index 10 out of bounds in string of length 5".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_error_for_string_with_negative_out_of_bounds_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(-10)), None)], + Shape::string_value("hello", []) + ), + Shape::error( + "Method ->get index -10 out of bounds in string of length 5".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_error_for_empty_string_out_of_bounds() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(0)), None)], + Shape::string_value("", []) + ), + Shape::error( + "Method ->get index 0 out of bounds in string of length 0".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_error_for_string_with_string_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::String("invalid".to_string()), None)], + Shape::string([]) + ), + Shape::error( + "Method ->get must be provided an integer argument when applied to a string" + .to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_return_string_or_none_for_string_with_unknown_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new( + LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("a").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }), + None + )], + Shape::string([]) + ), + Shape::string([get_location()]) + ); + } + + #[test] + fn get_shape_should_return_element_for_array_with_valid_int_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(1)), None)], + Shape::array( + [Shape::int([]), Shape::string([]), Shape::bool([])], + Shape::none(), + [] + ) + ), + Shape::string([]) + ); + } + + #[test] + fn get_shape_should_return_shape_for_list_with_valid_int_index() { + let input_shape = Shape::list(Shape::string([]), []); + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(1)), None)], + Shape::list(Shape::string([]), []) + ), + input_shape.any_item([]) + ); + } + + #[test] + fn get_shape_should_return_element_for_array_with_negative_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(-1)), None)], + Shape::array( + [Shape::int([]), Shape::string([]), Shape::bool([])], + Shape::none(), + [] + ) + ), + Shape::bool([]) + ); + } + + #[test] + fn get_shape_should_error_for_array_with_out_of_bounds_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(5)), None)], + Shape::array([Shape::int([]), Shape::string([])], Shape::none(), []) + ), + Shape::error( + "Method ->get index 5 out of bounds in array of length 2".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_error_for_array_with_negative_out_of_bounds_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(-5)), None)], + Shape::array([Shape::int([]), Shape::string([])], Shape::none(), []) + ), + Shape::error( + "Method ->get index -5 out of bounds in array of length 2".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_error_for_array_with_out_of_bounds_index_on_empty_array() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(0)), None)], + Shape::array([], Shape::none(), []) + ), + Shape::error( + "Method ->get index 0 out of bounds in array of length 0".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_error_for_array_with_string_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::String("invalid".to_string()), None)], + Shape::array([Shape::int([])], Shape::none(), []) + ), + Shape::error( + "Method ->get must be provided an integer argument when applied to an array" + .to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_return_unknown_or_none_for_array_with_unknown_index() { + let input_shape = Shape::array([Shape::int([])], Shape::none(), []); + assert_eq!( + get_test_shape( + vec![WithRange::new( + LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("a").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }), + None + )], + input_shape.clone() + ), + input_shape.any_item([]) + ); + } + + #[test] + fn get_shape_should_return_property_shape_for_object_with_valid_string_key() { + let mut fields = IndexMap::default(); + fields.insert("key".to_string(), Shape::int([])); + fields.insert("other".to_string(), Shape::string([])); + + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::String("key".to_string()), None)], + Shape::object(fields, Shape::none(), []) + ), + Shape::int([]) + ); + } + + #[test] + fn get_shape_should_return_shape_for_dict_with_valid_string_key() { + let input_shape = Shape::dict(Shape::int([]), []); + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::String("key".to_string()), None)], + input_shape.clone() + ), + input_shape.any_field([]) + ); + } + + #[test] + fn get_shape_should_error_for_object_with_missing_key() { + let mut fields = IndexMap::default(); + fields.insert("existing".to_string(), Shape::int([])); + + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::String("missing".to_string()), None)], + Shape::object(fields, Shape::none(), []) + ), + Shape::error( + "Method ->get property missing not found in object".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_error_for_object_with_int_index() { + let fields = IndexMap::default(); + + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(42)), None)], + Shape::object(fields, Shape::none(), []) + ), + Shape::error( + "Method ->get must be provided an string argument when applied to an object" + .to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_error_for_object_with_bool_key() { + let fields = IndexMap::default(); + + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Bool(false), None)], + Shape::object(fields, Shape::none(), []) + ), + Shape::error( + "Method ->get must be provided an string argument when applied to an object" + .to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_error_for_object_with_null_key() { + let fields = IndexMap::default(); + + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Null, None)], + Shape::object(fields, Shape::none(), []) + ), + Shape::error( + "Method ->get must be provided an string argument when applied to an object" + .to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_return_unknown_for_object_with_unknown_key() { + let fields = IndexMap::default(); + let input_shape = Shape::object(fields, Shape::none(), []); + + assert_eq!( + get_test_shape( + vec![WithRange::new( + LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("a").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }), + None + )], + input_shape.clone() + ), + input_shape.any_field([]) + ); + } + + #[test] + fn get_shape_should_return_unknown_for_unknown_input_with_valid_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(0)), None)], + Shape::unknown([]) + ), + Shape::unknown([get_location()]) + ); + } + + #[test] + fn get_shape_should_return_string_or_unknown_for_unknown_input_with_string_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::String("key".to_string()), None)], + Shape::unknown([]) + ), + Shape::unknown([get_location()]) + ); + } + + #[test] + fn get_shape_should_error_for_unknown_input_with_bool_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Bool(true), None)], + Shape::unknown([]) + ), + Shape::error( + "Method ->get must be provided an integer or string argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_return_unknown_for_unknown_input_with_unknown_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new( + LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("a").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }), + None + )], + Shape::unknown([]) + ), + Shape::unknown([get_location()]) + ); + } + + #[test] + fn get_shape_should_error_for_bool_input_with_int_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(0)), None)], + Shape::bool([]) + ), + Shape::error( + "Method ->get must be applied to a string, array, or object".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_error_for_null_input_with_int_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(0)), None)], + Shape::null([]) + ), + Shape::error( + "Method ->get must be applied to a string, array, or object".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn get_shape_should_error_for_number_input_with_int_index() { + assert_eq!( + get_test_shape( + vec![WithRange::new(LitExpr::Number(Number::from(0)), None)], + Shape::int([]) + ), + Shape::error( + "Method ->get must be applied to a string, array, or object".to_string(), + [get_location()] + ) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/gt.rs b/apollo-federation/src/connectors/json_selection/methods/public/gt.rs new file mode 100644 index 0000000000..f0d127a11b --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/gt.rs @@ -0,0 +1,468 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::json_selection::methods::common::is_comparable_shape_combination; +use crate::connectors::json_selection::methods::common::number_value_as_float; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(GtMethod, gt_method, gt_shape); +/// Returns true if the applied to value is greater than the argument value. +/// Simple examples: +/// +/// $(3)->gt(3) results in false +/// $(4)->gt(3) results in true +/// $(2)->gt(3) results in false +/// $("a")->gt("b") results in false +/// $("c")->gt("b") results in true +fn gt_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires exactly one argument", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + let (value_opt, arg_errors) = first_arg.apply_to_path(data, vars, input_path, spec); + let mut apply_to_errors = arg_errors; + // We have to do this because Value doesn't implement PartialOrd + let matches = value_opt.and_then(|value| { + match (data, &value) { + // Number comparisons + (JSON::Number(left), JSON::Number(right)) => { + let left = match number_value_as_float(left, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + let right = match number_value_as_float(right, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + + Some(JSON::Bool(left > right)) + } + // String comparisons + (JSON::String(left), JSON::String(right)) => Some(JSON::Bool(left > right)), + // Mixed types or uncomparable types (including arrays and objects) return false + _ => { + apply_to_errors.push(ApplyToError::new( + format!( + "Method ->{} can only compare numbers and strings. Found: {data} > {value}", + method_name.as_ref(), + ), + input_path.to_vec(), + method_name.range(), + spec, + )); + + None + } + } + }); + + (matches, apply_to_errors) +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn gt_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let arg_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if arg_count > 1 { + return Shape::error( + format!( + "Method ->{} requires only one argument, but {arg_count} were provided", + method_name.as_ref(), + ), + vec![], + ); + } + + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + method_name.shape_location(context.source_id()), + ); + }; + + let arg_shape = first_arg.compute_output_shape(context, input_shape.clone(), dollar_shape); + + if is_comparable_shape_combination(&arg_shape, &input_shape) { + Shape::bool(method_name.shape_location(context.source_id())) + } else { + Shape::error_with_partial( + format!( + "Method ->{} can only compare two numbers or two strings. Found {input_shape} > {arg_shape}", + method_name.as_ref() + ), + Shape::bool(method_name.shape_location(context.source_id())), + method_name.shape_location(context.source_id()), + ) + } +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn gt_should_return_true_when_applied_to_number_is_greater_than_argument() { + assert_eq!( + selection!( + r#" + result: value->gt(3) + "# + ) + .apply_to(&json!({ "value": 4 })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn gt_should_return_false_when_applied_to_number_equals_argument() { + assert_eq!( + selection!( + r#" + result: value->gt(3) + "# + ) + .apply_to(&json!({ "value": 3 })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn gt_should_return_false_when_applied_to_number_is_less_than_argument() { + assert_eq!( + selection!( + r#" + result: value->gt(3) + "# + ) + .apply_to(&json!({ "value": 2 })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn gt_should_return_true_when_applied_to_string_is_greater_than_argument() { + assert_eq!( + selection!( + r#" + result: value->gt("b") + "# + ) + .apply_to(&json!({ "value": "c" })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn gt_should_return_false_when_applied_to_string_equals_argument() { + assert_eq!( + selection!( + r#" + result: value->gt("a") + "# + ) + .apply_to(&json!({ "value": "a" })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn gt_should_return_false_when_applied_to_string_is_less_than_argument() { + assert_eq!( + selection!( + r#" + result: value->gt("b") + "# + ) + .apply_to(&json!({ "value": "a" })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn gt_should_error_for_null_values() { + let result = selection!( + r#" + result: value->gt(null) + "# + ) + .apply_to(&json!({ "value": null })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->gt can only compare numbers and strings. Found: null > null") + ); + } + + #[test] + fn gt_should_error_for_boolean_values() { + let result = selection!( + r#" + result: value->gt(false) + "# + ) + .apply_to(&json!({ "value": true })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->gt can only compare numbers and strings. Found: true > false") + ); + } + + #[test] + fn gt_should_error_for_arrays() { + let result = selection!( + r#" + result: value->gt([1,2]) + "# + ) + .apply_to(&json!({ "value": [1,2,3] })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0].message().contains( + "Method ->gt can only compare numbers and strings. Found: [1,2,3] > [1,2]" + ) + ); + } + + #[test] + fn gt_should_error_for_objects() { + let result = selection!( + r#" + result: value->gt({"a": 1}) + "# + ) + .apply_to(&json!({ "value": {"a": 1, "b": 2} })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!(result.1[0].message().contains( + "Method ->gt can only compare numbers and strings. Found: {\"a\":1,\"b\":2} > {\"a\":1}" + )); + } + + #[test] + fn gt_should_error_for_mixed_types() { + let result = selection!( + r#" + result: value->gt("string") + "# + ) + .apply_to(&json!({ "value": 42 })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0].message().contains( + "Method ->gt can only compare numbers and strings. Found: 42 > \"string\"" + ) + ); + } + + #[test] + fn gt_should_return_error_when_no_arguments_provided() { + let result = selection!( + r#" + result: value->gt() + "# + ) + .apply_to(&json!({ "value": 42 })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->gt requires exactly one argument") + ); + } +} + +#[cfg(test)] +mod shape_tests { + use serde_json::Number; + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..7, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + gt_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("gt".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::none(), + ) + } + + #[test] + fn gt_shape_should_return_bool_on_valid_strings() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::string([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn gt_shape_should_return_bool_on_valid_numbers() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Number(Number::from(42)), None)], + Shape::int([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn gt_shape_should_error_on_mixed_types() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::int([]) + ), + Shape::error_with_partial( + "Method ->gt can only compare two numbers or two strings. Found Int > \"a\"" + .to_string(), + Shape::bool([get_location()]), + [get_location()] + ) + ); + } + + #[test] + fn gt_shape_should_error_on_no_args() { + assert_eq!( + get_shape(vec![], Shape::string([])), + Shape::error( + "Method ->gt requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn gt_shape_should_error_on_too_many_args() { + assert_eq!( + get_shape( + vec![ + WithRange::new(LitExpr::Number(Number::from(42)), None), + WithRange::new(LitExpr::Number(Number::from(42)), None) + ], + Shape::int([]) + ), + Shape::error( + "Method ->gt requires only one argument, but 2 were provided".to_string(), + [] + ) + ); + } + + #[test] + fn gt_shape_should_error_on_none_args() { + let location = get_location(); + assert_eq!( + gt_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("gt".to_string(), Some(location.span)), + None, + Shape::string([]), + Shape::none(), + ), + Shape::error( + "Method ->gt requires one argument".to_string(), + [get_location()] + ) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/gte.rs b/apollo-federation/src/connectors/json_selection/methods/public/gte.rs new file mode 100644 index 0000000000..70cf5b73c8 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/gte.rs @@ -0,0 +1,464 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::json_selection::methods::common::is_comparable_shape_combination; +use crate::connectors::json_selection::methods::common::number_value_as_float; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(GteMethod, gte_method, gte_shape); +/// Returns true if the applied to value is greater than or equal to the argument value. +/// Simple examples: +/// +/// $(3)->gte(3) results in true +/// $(4)->gte(3) results in true +/// $(2)->gte(3) results in false +/// $("a")->gte("b") results in false +/// $("c")->gte("b") results in true +fn gte_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires exactly one argument", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + let (value_opt, arg_errors) = first_arg.apply_to_path(data, vars, input_path, spec); + let mut apply_to_errors = arg_errors; + // We have to do this because Value doesn't implement PartialOrd + let matches = value_opt.and_then(|value| { + match (data, &value) { + // Number comparisons + (JSON::Number(left), JSON::Number(right)) => { + let left = match number_value_as_float(left, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + let right = match number_value_as_float(right, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + + Some(JSON::Bool(left >= right)) + } + // String comparisons + (JSON::String(left), JSON::String(right)) => Some(JSON::Bool(left >= right)), + // Mixed types or incomparable types (including arrays and objects) return false + _ => { + apply_to_errors.push(ApplyToError::new( + format!( + "Method ->{} can only compare numbers and strings. Found: {data} >= {value}", + method_name.as_ref(), + ), + input_path.to_vec(), + method_name.range(), + spec, + )); + + None + } + } + }); + + (matches, apply_to_errors) +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn gte_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let arg_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if arg_count > 1 { + return Shape::error( + format!( + "Method ->{} requires only one argument, but {arg_count} were provided", + method_name.as_ref(), + ), + vec![], + ); + } + + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + method_name.shape_location(context.source_id()), + ); + }; + + let arg_shape = first_arg.compute_output_shape(context, input_shape.clone(), dollar_shape); + + if is_comparable_shape_combination(&arg_shape, &input_shape) { + Shape::bool(method_name.shape_location(context.source_id())) + } else { + Shape::error_with_partial( + format!( + "Method ->{} can only compare two numbers or two strings. Found {input_shape} >= {arg_shape}", + method_name.as_ref() + ), + Shape::bool_value(false, method_name.shape_location(context.source_id())), + method_name.shape_location(context.source_id()), + ) + } +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn gte_should_return_true_when_applied_to_number_is_greater_than_argument() { + assert_eq!( + selection!( + r#" + result: value->gte(3) + "# + ) + .apply_to(&json!({ "value": 4 })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn gte_should_return_true_when_applied_to_number_equals_argument() { + assert_eq!( + selection!( + r#" + result: value->gte(3) + "# + ) + .apply_to(&json!({ "value": 3 })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn gte_should_return_false_when_applied_to_number_is_less_than_argument() { + assert_eq!( + selection!( + r#" + result: value->gte(3) + "# + ) + .apply_to(&json!({ "value": 2 })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn gte_should_return_true_when_applied_to_string_is_greater_than_argument() { + assert_eq!( + selection!( + r#" + result: value->gte("b") + "# + ) + .apply_to(&json!({ "value": "c" })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn gte_should_return_true_when_applied_to_string_equals_argument() { + assert_eq!( + selection!( + r#" + result: value->gte("a") + "# + ) + .apply_to(&json!({ "value": "a" })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn gte_should_return_false_when_applied_to_string_is_less_than_argument() { + assert_eq!( + selection!( + r#" + result: value->gte("b") + "# + ) + .apply_to(&json!({ "value": "a" })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn gte_should_error_for_null_values() { + let result = selection!( + r#" + result: value->gte(null) + "# + ) + .apply_to(&json!({ "value": null })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->gte can only compare numbers and strings. Found: null >= null") + ); + } + + #[test] + fn gte_should_error_for_boolean_values() { + let result = selection!( + r#" + result: value->gte(false) + "# + ) + .apply_to(&json!({ "value": true })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0].message().contains( + "Method ->gte can only compare numbers and strings. Found: true >= false" + ) + ); + } + + #[test] + fn gte_should_error_for_arrays() { + let result = selection!( + r#" + result: value->gte([1,2]) + "# + ) + .apply_to(&json!({ "value": [1,2,3] })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!(result.1[0].message().contains( + "Method ->gte can only compare numbers and strings. Found: [1,2,3] >= [1,2]" + )); + } + + #[test] + fn gte_should_error_for_objects() { + let result = selection!( + r#" + result: value->gte({"a": 1}) + "# + ) + .apply_to(&json!({ "value": {"a": 1, "b": 2} })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!(result.1[0].message().contains( + "Method ->gte can only compare numbers and strings. Found: {\"a\":1,\"b\":2} >= {\"a\":1}" + )); + } + + #[test] + fn gte_should_error_for_mixed_types() { + let result = selection!( + r#" + result: value->gte("string") + "# + ) + .apply_to(&json!({ "value": 42 })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!(result.1[0].message().contains( + "Method ->gte can only compare numbers and strings. Found: 42 >= \"string\"" + )); + } + + #[test] + fn gte_should_return_error_when_no_arguments_provided() { + let result = selection!( + r#" + result: value->gte() + "# + ) + .apply_to(&json!({ "value": 42 })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->gte requires exactly one argument") + ); + } +} + +#[cfg(test)] +mod shape_tests { + use serde_json::Number; + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..7, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + gte_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("gte".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::none(), + ) + } + + #[test] + fn gte_shape_should_return_bool_on_valid_strings() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::string([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn gte_shape_should_return_bool_on_valid_numbers() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Number(Number::from(42)), None)], + Shape::int([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn gte_shape_should_error_on_mixed_types() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::int([]) + ), + Shape::error_with_partial( + "Method ->gte can only compare two numbers or two strings. Found Int >= \"a\"" + .to_string(), + Shape::bool_value(false, [get_location()]), + [get_location()] + ) + ); + } + + #[test] + fn gte_shape_should_error_on_no_args() { + assert_eq!( + get_shape(vec![], Shape::string([])), + Shape::error( + "Method ->gte requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn gte_shape_should_error_on_too_many_args() { + assert_eq!( + get_shape( + vec![ + WithRange::new(LitExpr::Number(Number::from(42)), None), + WithRange::new(LitExpr::Number(Number::from(42)), None) + ], + Shape::int([]) + ), + Shape::error( + "Method ->gte requires only one argument, but 2 were provided".to_string(), + [] + ) + ); + } + + #[test] + fn gte_shape_should_error_on_none_args() { + let location = get_location(); + assert_eq!( + gte_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("gte".to_string(), Some(location.span)), + None, + Shape::string([]), + Shape::none(), + ), + Shape::error( + "Method ->gte requires one argument".to_string(), + [get_location()] + ) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/in.rs b/apollo-federation/src/connectors/json_selection/methods/public/in.rs new file mode 100644 index 0000000000..8a1159c29d --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/in.rs @@ -0,0 +1,633 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; + +use crate::connectors::ConnectSpec; +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::json_selection::methods::common::number_value_as_float; +use crate::impl_arrow_method; + +impl_arrow_method!(InMethod, in_method, in_shape); +/// Returns true if the applied value is equal to any of the values in the array argument. +/// Simple examples: +/// +/// $(123)->in([123, 456, 789]) results in true +/// $(123)->in([456, 789]) results in false +fn in_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if let Some(MethodArgs { args, .. }) = method_args + && let [arg] = args.as_slice() + { + let (value_opt, arg_errors) = arg.apply_to_path(data, vars, input_path, spec); + let mut apply_to_errors = arg_errors; + + let matches = value_opt.and_then(|value| { + if let JSON::Array(array) = &value { + for item in array { + let is_equal = match (data, item) { + // Number comparisons: Always convert to float so 1 == 1.0 + (JSON::Number(left), JSON::Number(right)) => { + let left = + match number_value_as_float(left, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + let right = + match number_value_as_float(right, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + left == right + } + // Everything else + _ => item == data, + }; + + if is_equal { + return Some(JSON::Bool(true)); + } + } + Some(JSON::Bool(false)) + } else { + apply_to_errors.push(ApplyToError::new( + format!( + "Method ->{} requires an array argument, but got: {value}", + method_name.as_ref(), + ), + input_path.to_vec(), + method_name.range(), + spec, + )); + None + } + }); + + return (matches, apply_to_errors); + } + ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires exactly one argument", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ) +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn in_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let arg_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if arg_count > 1 { + return Shape::error( + format!( + "Method ->{} requires only one argument, but {arg_count} were provided", + method_name.as_ref(), + ), + vec![], + ); + } + + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + method_name.shape_location(context.source_id()), + ); + }; + + let arg_shape = first_arg.compute_output_shape(context, input_shape.clone(), dollar_shape); + + if !Shape::tuple([], []).accepts(&arg_shape) && !arg_shape.accepts(&Shape::unknown([])) { + return Shape::error( + format!( + "Method ->{} requires an array argument, but got: {arg_shape}", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + + let ShapeCase::Array { prefix, tail } = arg_shape.case() else { + return Shape::bool(method_name.shape_location(context.source_id())); + }; + + // Ensures that the input is of the same type as all the array elements... this includes covering cases like int/float and unknown/name + if let Some(item) = prefix + .iter() + .find(|item| !(input_shape.accepts(item) || item.accepts(&input_shape))) + { + return Shape::error_with_partial( + format!( + "Method ->{} can only compare values of the same type. Got {input_shape} == {item}.", + method_name.as_ref() + ), + Shape::bool_value(false, method_name.shape_location(context.source_id())), + method_name.shape_location(context.source_id()), + ); + } + + // Also check the tail for type mismatch + if !(tail.is_none() || input_shape.accepts(tail) || tail.accepts(&input_shape)) { + return Shape::error_with_partial( + format!( + "Method ->{} can only compare values of the same type. Got {input_shape} == {tail}.", + method_name.as_ref() + ), + Shape::bool_value(false, method_name.shape_location(context.source_id())), + method_name.shape_location(context.source_id()), + ); + } + + Shape::bool(method_name.shape_location(context.source_id())) +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn in_should_return_true_when_applied_to_value_in_array() { + assert_eq!( + selection!( + r#" + result: value->in([123, 456, 789]) + "# + ) + .apply_to(&json!({ "value": 123 })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn in_should_return_false_when_applied_to_value_not_in_array() { + assert_eq!( + selection!( + r#" + result: value->in([456, 789]) + "# + ) + .apply_to(&json!({ "value": 123 })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn in_should_return_true_when_applied_to_numbers_of_different_types() { + assert_eq!( + selection!( + r#" + result: value->in([1, 2.5, 3]) + "# + ) + .apply_to(&json!({ "value": 1.0 })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn in_should_return_true_when_applied_to_string_in_array() { + assert_eq!( + selection!( + r#" + result: value->in(["hello", "world", "test"]) + "# + ) + .apply_to(&json!({ "value": "hello" })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn in_should_return_false_when_applied_to_string_not_in_array() { + assert_eq!( + selection!( + r#" + result: value->in(["world", "test"]) + "# + ) + .apply_to(&json!({ "value": "hello" })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn in_should_return_true_when_applied_to_bool_in_array() { + assert_eq!( + selection!( + r#" + result: value->in([true, false]) + "# + ) + .apply_to(&json!({ "value": true })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn in_should_return_false_when_applied_to_bool_not_in_array() { + assert_eq!( + selection!( + r#" + result: value->in([false]) + "# + ) + .apply_to(&json!({ "value": true })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn in_should_return_true_when_applied_to_object_in_array() { + assert_eq!( + selection!( + r#" + result: value->in([{"name": "John", "age": 30}, {"name": "Jane", "age": 25}]) + "# + ) + .apply_to(&json!({ "value": {"name": "John", "age": 30} })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn in_should_return_false_when_applied_to_object_not_in_array() { + assert_eq!( + selection!( + r#" + result: value->in([{"name": "Jane", "age": 25}]) + "# + ) + .apply_to(&json!({ "value": {"name": "John", "age": 30} })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn in_should_return_true_when_applied_to_array_in_array() { + assert_eq!( + selection!( + r#" + result: value->in([[1, 2, 3], [4, 5, 6]]) + "# + ) + .apply_to(&json!({ "value": [1, 2, 3] })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn in_should_return_false_when_applied_to_array_not_in_array() { + assert_eq!( + selection!( + r#" + result: value->in([[4, 5, 6], [7, 8, 9]]) + "# + ) + .apply_to(&json!({ "value": [1, 2, 3] })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn in_should_return_false_for_empty_array() { + assert_eq!( + selection!( + r#" + result: value->in([]) + "# + ) + .apply_to(&json!({ "value": 123 })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn in_should_return_error_when_no_arguments_provided() { + let result = selection!( + r#" + result: value->in() + "# + ) + .apply_to(&json!({ "value": 123 })); + + assert_eq!(result.0, Some(json!({}))); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->in requires exactly one argument") + ); + } + + #[test] + fn in_should_return_error_when_argument_is_not_array() { + let result = selection!( + r#" + result: value->in(123) + "# + ) + .apply_to(&json!({ "value": 123 })); + + assert_eq!(result.0, Some(json!({}))); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->in requires an array argument, but got: 123") + ); + } +} + +#[cfg(test)] +mod shape_tests { + use serde_json::Number; + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..7, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + in_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("in".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::none(), + ) + } + + #[test] + fn in_shape_should_return_bool_on_valid_string_array() { + assert_eq!( + get_shape( + vec![WithRange::new( + LitExpr::Array(vec![ + WithRange::new(LitExpr::String("a".to_string()), None), + WithRange::new(LitExpr::String("b".to_string()), None), + ]), + None + )], + Shape::string([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn in_shape_should_return_bool_on_valid_number_array() { + assert_eq!( + get_shape( + vec![WithRange::new( + LitExpr::Array(vec![ + WithRange::new(LitExpr::Number(Number::from(42)), None), + WithRange::new(LitExpr::Number(Number::from(43)), None), + ]), + None + )], + Shape::int([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn in_shape_should_return_bool_on_valid_bool_array() { + assert_eq!( + get_shape( + vec![WithRange::new( + LitExpr::Array(vec![ + WithRange::new(LitExpr::Bool(true), None), + WithRange::new(LitExpr::Bool(false), None), + ]), + None + )], + Shape::bool([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn in_shape_should_error_on_non_array_argument() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::string([]) + ), + Shape::error( + "Method ->in requires an array argument, but got: \"a\"".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn in_shape_should_error_on_mixed_types() { + assert_eq!( + get_shape( + vec![WithRange::new( + LitExpr::Array(vec![WithRange::new(LitExpr::String("a".to_string()), None),]), + None + )], + Shape::int([]) + ), + Shape::error_with_partial( + "Method ->in can only compare values of the same type. Got Int == \"a\"." + .to_string(), + Shape::bool_value(false, [get_location()]), + [get_location()] + ) + ); + } + + #[test] + fn in_shape_should_error_on_no_args() { + assert_eq!( + get_shape(vec![], Shape::string([])), + Shape::error( + "Method ->in requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn in_shape_should_error_on_too_many_args() { + assert_eq!( + get_shape( + vec![ + WithRange::new( + LitExpr::Array(vec![WithRange::new( + LitExpr::Number(Number::from(42)), + None + ),]), + None + ), + WithRange::new( + LitExpr::Array(vec![WithRange::new( + LitExpr::Number(Number::from(43)), + None + ),]), + None + ) + ], + Shape::int([]) + ), + Shape::error( + "Method ->in requires only one argument, but 2 were provided".to_string(), + [] + ) + ); + } + + #[test] + fn in_shape_should_error_on_none_args() { + let location = get_location(); + assert_eq!( + in_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("in".to_string(), Some(location.span)), + None, + Shape::string([]), + Shape::none(), + ), + Shape::error( + "Method ->in requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn in_shape_should_return_bool_on_unknown_input() { + assert_eq!( + get_shape( + vec![WithRange::new( + LitExpr::Array(vec![WithRange::new( + LitExpr::String("test".to_string()), + None + ),]), + None + )], + Shape::unknown([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn in_shape_should_return_bool_on_named_input() { + assert_eq!( + get_shape( + vec![WithRange::new( + LitExpr::Array(vec![WithRange::new( + LitExpr::Number(Number::from(42)), + None + ),]), + None + )], + Shape::name("a", []) + ), + Shape::bool([get_location()]) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/join_not_null.rs b/apollo-federation/src/connectors/json_selection/methods/public/join_not_null.rs new file mode 100644 index 0000000000..6b98ea20a1 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/join_not_null.rs @@ -0,0 +1,365 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::helpers::json_to_string; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!( + JoinNotNullMethod, + join_not_null_method, + join_not_null_method_shape +); +/// Takes an array of scalar values and joins them into a single string using a +/// separator, skipping null values. +/// +/// This method is specifically useful when dealing with lists of entity +/// references in Federation, which can contain null. It's rare that you'll want +/// to send a `null` to an upstream service when fetching a batch of entities, +/// so this is a useful and convenient method. +/// +/// $->echo(["hello", null, "world"])->joinNotNull(", ") would result in "hello, world" +fn join_not_null_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let mut warnings = vec![]; + + let Some((separator, arg_warnings)) = method_args + .and_then(|args| args.args.first()) + .map(|arg| arg.apply_to_path(data, vars, input_path, spec)) + else { + warnings.push(ApplyToError::new( + format!( + "Method ->{} requires a string argument", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )); + return (None, warnings); + }; + + warnings.extend(arg_warnings); + + let Some(separator) = separator.as_ref().and_then(|s| match s { + JSON::String(s) => Some(s), + _ => None, + }) else { + warnings.push(ApplyToError::new( + format!( + "Method ->{} requires a string argument, but received {}", + method_name.as_ref(), + separator + .as_ref() + .map_or("null".to_string(), |s| s.to_string()) + ), + input_path.to_vec(), + method_name.range(), + spec, + )); + return (None, warnings); + }; + + fn to_string(value: &JSON, method_name: &str) -> Result, String> { + json_to_string(value).map_err(|_| { + format!("Method ->{method_name} requires an array of scalar values as input",) + }) + } + + let joined = match data { + JSON::Array(values) => { + let mut joined = Vec::with_capacity(values.len()); + for value in values { + match to_string(value, method_name) { + Ok(Some(value)) => joined.push(value), + Ok(None) => {} + Err(err) => { + warnings.push(ApplyToError::new( + err, + input_path.to_vec(), + method_name.range(), + spec, + )); + return (None, warnings); + } + } + } + joined.join(separator.as_str()) + } + // Single values are emitted as strings with no separator + _ => match to_string(data, method_name) { + Ok(value) => value.unwrap_or_else(|| "".to_string()), + Err(err) => { + warnings.push(ApplyToError::new( + err, + input_path.to_vec(), + method_name.range(), + spec, + )); + return (None, warnings); + } + }, + }; + + (Some(JSON::String(joined.into())), warnings) +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn join_not_null_method_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let input_shape_contract = Shape::one( + [ + Shape::string([]), + Shape::int([]), + Shape::float([]), + Shape::bool([]), + Shape::null([]), + Shape::list( + Shape::one( + [ + Shape::string([]), + Shape::int([]), + Shape::float([]), + Shape::bool([]), + Shape::null([]), + ], + [], + ), + [], + ), + ], + [], + ); + + // allow unknown input + if !(input_shape.is_unknown() || matches!(input_shape.case(), ShapeCase::Name(_, _))) { + let mismatches = input_shape_contract.validate(&input_shape); + if !mismatches.is_empty() { + return Shape::error( + format!( + "Method ->{} requires an array of scalar values as input", + method_name.as_ref() + ), + [], + ); + } + } + + let Some(selection_shape) = method_args + .and_then(|args| args.args.first()) + .map(|s| s.compute_output_shape(context, input_shape, dollar_shape)) + else { + return Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + vec![], + ); + }; + + let method_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if method_count > 1 { + return Shape::error( + format!( + "Method ->{} requires only one argument, but {} were provided", + method_name.as_ref(), + method_count + ), + vec![], + ); + } + + // allow unknown separator + if !(selection_shape.is_unknown() || matches!(selection_shape.case(), ShapeCase::Name(_, _))) { + let mismatches = Shape::string([]).validate(&selection_shape); + if !mismatches.is_empty() { + return Shape::error( + format!( + "Method ->{} requires a string argument", + method_name.as_ref() + ), + vec![], + ); + } + } + + Shape::string(method_name.shape_location(context.source_id())) +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + use shape::location::SourceId; + + use super::*; + use crate::connectors::json_selection::lit_expr::LitExpr; + use crate::selection; + + #[rstest::rstest] + #[case(json!(["a","b","c"]), ", ", json!("a, b, c"))] + #[case(json!([1, 2, 3]), "|", json!("1|2|3"))] + #[case(json!([1.00000000000001, 2.9999999999999, 0.3]), "|", json!("1.00000000000001|2.9999999999999|0.3"))] + #[case(json!([true, false]), " and ", json!("true and false"))] + #[case(json!([null, "a", null, 1, null]), ", ", json!("a, 1"))] + #[case(json!([null, null]), ", ", json!(""))] + #[case(json!(1), ", ", json!("1"))] + #[case(json!("a"), ", ", json!("a"))] + #[case(json!(true), ", ", json!("true"))] + #[case(json!(null), ", ", json!(""))] + fn join_not_null_should_combine_arrays_with_a_separator( + #[case] input: JSON, + #[case] separator: String, + #[case] expected: JSON, + ) { + assert_eq!( + selection!(&format!("$->joinNotNull('{separator}')")).apply_to(&input), + (Some(expected), vec![]), + ); + } + + #[test] + fn join_not_null_evaluates_argument() { + assert_eq!( + selection!(&"$->joinNotNull(@->first)").apply_to(&json!(["1", "2", "3"])), + (Some(json!("11213")), vec![]), + ); + } + + #[rstest::rstest] + #[case(json!({"a": 1}), vec!["Method ->joinNotNull requires an array of scalar values as input"])] + #[case(json!([{"a": 1}, {"a": 2}]), vec!["Method ->joinNotNull requires an array of scalar values as input"])] + #[case(json!([[1, 2]]), vec!["Method ->joinNotNull requires an array of scalar values as input"])] + fn join_not_null_warnings(#[case] input: JSON, #[case] expected_warnings: Vec<&str>) { + use itertools::Itertools; + + let (result, warnings) = selection!("$->joinNotNull(',')").apply_to(&input); + assert_eq!(result, None); + assert_eq!( + warnings.iter().map(|w| w.message()).collect_vec(), + expected_warnings + ); + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + join_not_null_method_shape( + &ShapeContext::new(SourceId::new("test".to_string())), + &WithRange::new("joinNotNull".to_string(), Some(0..7)), + Some(&MethodArgs { args, range: None }), + input, + Shape::none(), + ) + } + + #[test] + fn test_join_not_null_shape_no_args() { + let output_shape = get_shape(vec![], Shape::list(Shape::string([]), [])); + assert_eq!( + output_shape, + Shape::error( + "Method ->joinNotNull requires one argument".to_string(), + vec![] + ) + ); + } + + #[test] + fn test_join_not_null_shape_non_string_args() { + let output_shape = get_shape( + vec![WithRange::new(LitExpr::Bool(true), None)], + Shape::list(Shape::string([]), []), + ); + assert_eq!( + output_shape, + Shape::error( + "Method ->joinNotNull requires a string argument".to_string(), + vec![] + ) + ); + } + + #[test] + fn test_join_not_null_shape_two_args() { + let output_shape = get_shape( + vec![ + WithRange::new(LitExpr::String(",".to_string()), None), + WithRange::new(LitExpr::String(",".to_string()), None), + ], + Shape::list(Shape::string([]), []), + ); + assert_eq!( + output_shape, + Shape::error( + "Method ->joinNotNull requires only one argument, but 2 were provided".to_string(), + vec![] + ) + ); + } + + #[test] + fn test_join_not_null_shape_scalar_input() { + let output_shape = get_shape( + vec![WithRange::new(LitExpr::String(",".to_string()), None)], + Shape::string([]), + ); + assert_eq!( + output_shape, + Shape::string([SourceId::new("test".to_string()).location(0..7)]) + ); + } + + #[test] + fn test_join_not_null_shape_list_of_list_input() { + let output_shape = get_shape( + vec![WithRange::new(LitExpr::String(",".to_string()), None)], + Shape::list(Shape::list(Shape::string([]), []), []), + ); + assert_eq!( + output_shape, + Shape::error( + "Method ->joinNotNull requires an array of scalar values as input".to_string(), + vec![] + ) + ); + } + + #[test] + fn test_join_not_null_shape_unknown_input() { + let output_shape = get_shape( + vec![WithRange::new(LitExpr::String(",".to_string()), None)], + Shape::unknown([]), + ); + assert_eq!( + output_shape, + Shape::string([SourceId::new("test".to_string()).location(0..7)]) + ); + } + + #[test] + fn test_join_not_null_shape_named_input() { + let output_shape = get_shape( + vec![WithRange::new(LitExpr::String(",".to_string()), None)], + Shape::name("$root.bar", []), + ); + assert_eq!( + output_shape, + Shape::string([SourceId::new("test".to_string()).location(0..7)]) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/json_stringify.rs b/apollo-federation/src/connectors/json_selection/methods/public/json_stringify.rs new file mode 100644 index 0000000000..4711eae9f3 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/json_stringify.rs @@ -0,0 +1,119 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!( + JsonStringifyMethod, + json_stringify_method, + json_stringify_shape +); +/// Returns a string representation of a structure +/// The simplest possible example: +/// +/// +/// $->echo({ "key": "value" })->jsonStringify would result in "{\"key\":\"value\"}" +fn json_stringify_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + _vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if method_args.is_some() { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + + match serde_json::to_string(data) { + Ok(val) => (Some(JSON::String(val.into())), Vec::new()), + Err(err) => ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} failed to serialize JSON: {}", + method_name.as_ref(), + err + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ), + } +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn json_stringify_shape( + context: &ShapeContext, + method_name: &WithRange, + _method_args: Option<&MethodArgs>, + _input_shape: Shape, + _dollar_shape: Shape, +) -> Shape { + Shape::string(method_name.shape_location(context.source_id())) +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use super::*; + use crate::connectors::ApplyToError; + use crate::selection; + + #[rstest::rstest] + #[case(json!(null), json!("null"), vec![])] + #[case(json!(true), json!("true"), vec![])] + #[case(json!(false), json!("false"), vec![])] + #[case(json!(42), json!("42"), vec![])] + #[case(json!(10.8), json!("10.8"), vec![])] + #[case(json!("hello world"), json!("\"hello world\""), vec![])] + #[case(json!([1, 2, 3]), json!("[1,2,3]"), vec![])] + #[case(json!({ "key": "value" }), json!("{\"key\":\"value\"}"), vec![])] + #[case(json!([1, "two", true, null]), json!("[1,\"two\",true,null]"), vec![])] + fn json_stringify_should_stringify_various_structures( + #[case] input: JSON, + #[case] expected: JSON, + #[case] errors: Vec, + ) { + assert_eq!( + selection!("$->jsonStringify").apply_to(&input), + (Some(expected), errors), + ); + } + + #[test] + fn json_stringify_should_error_when_provided_argument() { + assert_eq!( + selection!("$->jsonStringify(1)").apply_to(&json!(null)), + ( + None, + vec![ApplyToError::new( + "Method ->jsonStringify does not take any arguments".to_string(), + vec![json!("->jsonStringify")], + Some(3..16), + ConnectSpec::latest(), + )], + ), + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/last.rs b/apollo-federation/src/connectors/json_selection/methods/public/last.rs new file mode 100644 index 0000000000..2476a46d83 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/last.rs @@ -0,0 +1,159 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(LastMethod, last_method, last_shape); +/// The "last" method is a utility function that can be run against an array to grab the final item from it +/// or a string to get the last character. +/// The simplest possible example: +/// +/// $->echo([1,2,3])->last results in 3 +/// $->echo("hello")->last results in "o" +fn last_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + _vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if method_args.is_some() { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + + match data { + JSON::Array(array) => (array.last().cloned(), Vec::new()), + JSON::String(s) => s.as_str().chars().last().map_or_else( + || (None, Vec::new()), + |last| (Some(JSON::String(last.to_string().into())), Vec::new()), + ), + _ => ( + Some(data.clone()), + vec![ApplyToError::new( + format!( + "Method ->{} requires an array or string input", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ), + } +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn last_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + _dollar_shape: Shape, +) -> Shape { + if method_args.is_some() { + return Shape::error( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + + match input_shape.case() { + ShapeCase::String(Some(value)) => { + value.chars().last().map_or_else(Shape::none, |last_char| { + Shape::string_value( + last_char.to_string().as_str(), + method_name.shape_location(context.source_id()), + ) + }) + } + + ShapeCase::String(None) => Shape::one( + [ + Shape::string(method_name.shape_location(context.source_id())), + Shape::none(), + ], + method_name.shape_location(context.source_id()), + ), + + ShapeCase::Array { prefix, tail } => { + if tail.is_none() { + prefix.last().cloned().unwrap_or_else(Shape::none) + } else if let Some(last) = prefix.last() { + Shape::one( + [last.clone(), tail.clone(), Shape::none()], + method_name.shape_location(context.source_id()), + ) + } else { + Shape::one( + [tail.clone(), Shape::none()], + method_name.shape_location(context.source_id()), + ) + } + } + + ShapeCase::Name(_, _) => { + input_shape.any_item(method_name.shape_location(context.source_id())) + } + ShapeCase::Unknown => Shape::unknown(method_name.shape_location(context.source_id())), + + _ => Shape::error_with_partial( + format!( + "Method ->{} requires an array or string input", + method_name.as_ref() + ), + input_shape.clone(), + input_shape.locations, + ), + } +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn last_should_get_last_element_from_array() { + assert_eq!( + selection!("$->last").apply_to(&json!([1, 2, 3])), + (Some(json!(3)), Vec::new()), + ); + } + + #[test] + fn last_should_get_none_when_no_items_exist() { + assert_eq!(selection!("$->last").apply_to(&json!([])), (None, vec![]),); + } + + #[test] + fn last_should_get_last_char_from_string() { + assert_eq!( + selection!("$->last").apply_to(&json!("hello")), + (Some(json!("o")), vec![]), + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/lt.rs b/apollo-federation/src/connectors/json_selection/methods/public/lt.rs new file mode 100644 index 0000000000..699067600d --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/lt.rs @@ -0,0 +1,468 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::ConnectSpec; +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::json_selection::methods::common::is_comparable_shape_combination; +use crate::connectors::json_selection::methods::common::number_value_as_float; +use crate::impl_arrow_method; + +impl_arrow_method!(LtMethod, lt_method, lt_shape); +/// Returns true if the applied to value is less than the argument value. +/// Simple examples: +/// +/// $(3)->lt(3) results in false +/// $(2)->lt(3) results in true +/// $(4)->lt(3) results in false +/// $("a")->lt("b") results in true +/// $("c")->lt("b") results in false +fn lt_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires exactly one argument", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + let (value_opt, arg_errors) = first_arg.apply_to_path(data, vars, input_path, spec); + let mut apply_to_errors = arg_errors; + // We have to do this because Value doesn't implement PartialOrd + let matches = value_opt.and_then(|value| { + match (data, &value) { + // Number comparisons + (JSON::Number(left), JSON::Number(right)) => { + let left = match number_value_as_float(left, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + let right = match number_value_as_float(right, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + + Some(JSON::Bool(left < right)) + } + // String comparisons + (JSON::String(left), JSON::String(right)) => Some(JSON::Bool(left < right)), + // Mixed types or incomparable types (including arrays and objects) return false + _ => { + apply_to_errors.push(ApplyToError::new( + format!( + "Method ->{} can only compare numbers and strings. Found: {data} < {value}", + method_name.as_ref(), + ), + input_path.to_vec(), + method_name.range(), + spec, + )); + + None + } + } + }); + + (matches, apply_to_errors) +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn lt_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let arg_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if arg_count > 1 { + return Shape::error( + format!( + "Method ->{} requires only one argument, but {arg_count} were provided", + method_name.as_ref(), + ), + vec![], + ); + } + + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + method_name.shape_location(context.source_id()), + ); + }; + + let arg_shape = first_arg.compute_output_shape(context, input_shape.clone(), dollar_shape); + + if is_comparable_shape_combination(&arg_shape, &input_shape) { + Shape::bool(method_name.shape_location(context.source_id())) + } else { + Shape::error_with_partial( + format!( + "Method ->{} can only compare two numbers or two strings. Found {input_shape} < {arg_shape}", + method_name.as_ref() + ), + Shape::bool(method_name.shape_location(context.source_id())), + method_name.shape_location(context.source_id()), + ) + } +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn lt_should_return_true_when_applied_to_number_is_less_than_argument() { + assert_eq!( + selection!( + r#" + result: value->lt(3) + "# + ) + .apply_to(&json!({ "value": 2 })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn lt_should_return_false_when_applied_to_number_equals_argument() { + assert_eq!( + selection!( + r#" + result: value->lt(3) + "# + ) + .apply_to(&json!({ "value": 3 })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn lt_should_return_false_when_applied_to_number_is_greater_than_argument() { + assert_eq!( + selection!( + r#" + result: value->lt(3) + "# + ) + .apply_to(&json!({ "value": 4 })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn lt_should_return_true_when_applied_to_string_is_less_than_argument() { + assert_eq!( + selection!( + r#" + result: value->lt("b") + "# + ) + .apply_to(&json!({ "value": "a" })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn lt_should_return_false_when_applied_to_string_equals_argument() { + assert_eq!( + selection!( + r#" + result: value->lt("a") + "# + ) + .apply_to(&json!({ "value": "a" })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn lt_should_return_false_when_applied_to_string_is_greater_than_argument() { + assert_eq!( + selection!( + r#" + result: value->lt("b") + "# + ) + .apply_to(&json!({ "value": "c" })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn lt_should_error_for_null_values() { + let result = selection!( + r#" + result: value->lt(null) + "# + ) + .apply_to(&json!({ "value": null })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->lt can only compare numbers and strings. Found: null < null") + ); + } + + #[test] + fn lt_should_error_for_boolean_values() { + let result = selection!( + r#" + result: value->lt(false) + "# + ) + .apply_to(&json!({ "value": true })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->lt can only compare numbers and strings. Found: true < false") + ); + } + + #[test] + fn lt_should_error_for_arrays() { + let result = selection!( + r#" + result: value->lt([1,2]) + "# + ) + .apply_to(&json!({ "value": [1,2,3] })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0].message().contains( + "Method ->lt can only compare numbers and strings. Found: [1,2,3] < [1,2]" + ) + ); + } + + #[test] + fn lt_should_error_for_objects() { + let result = selection!( + r#" + result: value->lt({"a": 1}) + "# + ) + .apply_to(&json!({ "value": {"a": 1, "b": 2} })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!(result.1[0].message().contains( + "Method ->lt can only compare numbers and strings. Found: {\"a\":1,\"b\":2} < {\"a\":1}" + )); + } + + #[test] + fn lt_should_error_for_mixed_types() { + let result = selection!( + r#" + result: value->lt("string") + "# + ) + .apply_to(&json!({ "value": 42 })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0].message().contains( + "Method ->lt can only compare numbers and strings. Found: 42 < \"string\"" + ) + ); + } + + #[test] + fn lt_should_return_error_when_no_arguments_provided() { + let result = selection!( + r#" + result: value->lt() + "# + ) + .apply_to(&json!({ "value": 42 })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->lt requires exactly one argument") + ); + } +} + +#[cfg(test)] +mod shape_tests { + use serde_json::Number; + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..7, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + lt_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("lt".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::none(), + ) + } + + #[test] + fn lt_shape_should_return_bool_on_valid_strings() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::string([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn lt_shape_should_return_bool_on_valid_numbers() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Number(Number::from(42)), None)], + Shape::int([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn lt_shape_should_error_on_mixed_types() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::int([]) + ), + Shape::error_with_partial( + "Method ->lt can only compare two numbers or two strings. Found Int < \"a\"" + .to_string(), + Shape::bool([get_location()]), + [get_location()] + ) + ); + } + + #[test] + fn lt_shape_should_error_on_no_args() { + assert_eq!( + get_shape(vec![], Shape::string([])), + Shape::error( + "Method ->lt requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn lt_shape_should_error_on_too_many_args() { + assert_eq!( + get_shape( + vec![ + WithRange::new(LitExpr::Number(Number::from(42)), None), + WithRange::new(LitExpr::Number(Number::from(42)), None) + ], + Shape::int([]) + ), + Shape::error( + "Method ->lt requires only one argument, but 2 were provided".to_string(), + [] + ) + ); + } + + #[test] + fn lt_shape_should_error_on_none_args() { + let location = get_location(); + assert_eq!( + lt_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("lt".to_string(), Some(location.span)), + None, + Shape::string([]), + Shape::none(), + ), + Shape::error( + "Method ->lt requires one argument".to_string(), + [get_location()] + ) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/lte.rs b/apollo-federation/src/connectors/json_selection/methods/public/lte.rs new file mode 100644 index 0000000000..d7e83c985e --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/lte.rs @@ -0,0 +1,464 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::json_selection::methods::common::is_comparable_shape_combination; +use crate::connectors::json_selection::methods::common::number_value_as_float; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(LteMethod, lte_method, lte_shape); +/// Returns true if the applied to value is less than or equal to the argument value. +/// Simple examples: +/// +/// $(3)->lte(3) results in true +/// $(2)->lte(3) results in true +/// $(4)->lte(3) results in false +/// $("a")->lte("b") results in true +/// $("c")->lte("b") results in false +fn lte_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires exactly one argument", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + let (value_opt, arg_errors) = first_arg.apply_to_path(data, vars, input_path, spec); + let mut apply_to_errors = arg_errors; + // We have to do this because Value doesn't implement PartialOrd + let matches = value_opt.and_then(|value| { + match (data, &value) { + // Number comparisons + (JSON::Number(left), JSON::Number(right)) => { + let left = match number_value_as_float(left, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + let right = match number_value_as_float(right, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + + Some(JSON::Bool(left <= right)) + } + // String comparisons + (JSON::String(left), JSON::String(right)) => Some(JSON::Bool(left <= right)), + // Mixed types or incomparable types (including arrays and objects) return false + _ => { + apply_to_errors.push(ApplyToError::new( + format!( + "Method ->{} can only compare numbers and strings. Found: {data} <= {value}", + method_name.as_ref(), + ), + input_path.to_vec(), + method_name.range(), + spec, + )); + + None + } + } + }); + + (matches, apply_to_errors) +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn lte_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let arg_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if arg_count > 1 { + return Shape::error( + format!( + "Method ->{} requires only one argument, but {arg_count} were provided", + method_name.as_ref(), + ), + vec![], + ); + } + + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + method_name.shape_location(context.source_id()), + ); + }; + + let arg_shape = first_arg.compute_output_shape(context, input_shape.clone(), dollar_shape); + + if is_comparable_shape_combination(&arg_shape, &input_shape) { + Shape::bool(method_name.shape_location(context.source_id())) + } else { + Shape::error_with_partial( + format!( + "Method ->{} can only compare two numbers or two strings. Found {input_shape} <= {arg_shape}", + method_name.as_ref() + ), + Shape::bool_value(false, method_name.shape_location(context.source_id())), + method_name.shape_location(context.source_id()), + ) + } +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn lte_should_return_true_when_applied_to_number_is_less_than_argument() { + assert_eq!( + selection!( + r#" + result: value->lte(3) + "# + ) + .apply_to(&json!({ "value": 2 })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn lte_should_return_true_when_applied_to_number_equals_argument() { + assert_eq!( + selection!( + r#" + result: value->lte(3) + "# + ) + .apply_to(&json!({ "value": 3 })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn lte_should_return_false_when_applied_to_number_is_greater_than_argument() { + assert_eq!( + selection!( + r#" + result: value->lte(3) + "# + ) + .apply_to(&json!({ "value": 4 })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn lte_should_return_true_when_applied_to_string_is_less_than_argument() { + assert_eq!( + selection!( + r#" + result: value->lte("b") + "# + ) + .apply_to(&json!({ "value": "a" })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn lte_should_return_true_when_applied_to_string_equals_argument() { + assert_eq!( + selection!( + r#" + result: value->lte("a") + "# + ) + .apply_to(&json!({ "value": "a" })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn lte_should_return_false_when_applied_to_string_is_greater_than_argument() { + assert_eq!( + selection!( + r#" + result: value->lte("b") + "# + ) + .apply_to(&json!({ "value": "c" })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn lte_should_error_for_null_values() { + let result = selection!( + r#" + result: value->lte(null) + "# + ) + .apply_to(&json!({ "value": null })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->lte can only compare numbers and strings. Found: null <= null") + ); + } + + #[test] + fn lte_should_error_for_boolean_values() { + let result = selection!( + r#" + result: value->lte(false) + "# + ) + .apply_to(&json!({ "value": true })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0].message().contains( + "Method ->lte can only compare numbers and strings. Found: true <= false" + ) + ); + } + + #[test] + fn lte_should_error_for_arrays() { + let result = selection!( + r#" + result: value->lte([1,2]) + "# + ) + .apply_to(&json!({ "value": [1,2,3] })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!(result.1[0].message().contains( + "Method ->lte can only compare numbers and strings. Found: [1,2,3] <= [1,2]" + )); + } + + #[test] + fn lte_should_error_for_objects() { + let result = selection!( + r#" + result: value->lte({"a": 1}) + "# + ) + .apply_to(&json!({ "value": {"a": 1, "b": 2} })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!(result.1[0].message().contains( + "Method ->lte can only compare numbers and strings. Found: {\"a\":1,\"b\":2} <= {\"a\":1}" + )); + } + + #[test] + fn lte_should_error_for_mixed_types() { + let result = selection!( + r#" + result: value->lte("string") + "# + ) + .apply_to(&json!({ "value": 42 })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!(result.1[0].message().contains( + "Method ->lte can only compare numbers and strings. Found: 42 <= \"string\"" + )); + } + + #[test] + fn lte_should_return_error_when_no_arguments_provided() { + let result = selection!( + r#" + result: value->lte() + "# + ) + .apply_to(&json!({ "value": 42 })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->lte requires exactly one argument") + ); + } +} + +#[cfg(test)] +mod shape_tests { + use serde_json::Number; + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..7, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + lte_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("lte".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::none(), + ) + } + + #[test] + fn lte_shape_should_return_bool_on_valid_strings() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::string([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn lte_shape_should_return_bool_on_valid_numbers() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Number(Number::from(42)), None)], + Shape::int([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn lte_shape_should_error_on_mixed_types() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::int([]) + ), + Shape::error_with_partial( + "Method ->lte can only compare two numbers or two strings. Found Int <= \"a\"" + .to_string(), + Shape::bool_value(false, [get_location()]), + [get_location()] + ) + ); + } + + #[test] + fn lte_shape_should_error_on_no_args() { + assert_eq!( + get_shape(vec![], Shape::string([])), + Shape::error( + "Method ->lte requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn lte_shape_should_error_on_too_many_args() { + assert_eq!( + get_shape( + vec![ + WithRange::new(LitExpr::Number(Number::from(42)), None), + WithRange::new(LitExpr::Number(Number::from(42)), None) + ], + Shape::int([]) + ), + Shape::error( + "Method ->lte requires only one argument, but 2 were provided".to_string(), + [] + ) + ); + } + + #[test] + fn lte_shape_should_error_on_none_args() { + let location = get_location(); + assert_eq!( + lte_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("lte".to_string(), Some(location.span)), + None, + Shape::string([]), + Shape::none(), + ), + Shape::error( + "Method ->lte requires one argument".to_string(), + [get_location()] + ) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/map.rs b/apollo-federation/src/connectors/json_selection/methods/public/map.rs new file mode 100644 index 0000000000..4dfbe1b2fc --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/map.rs @@ -0,0 +1,193 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::apply_to::ApplyToResultMethods; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(MapMethod, map_method, map_shape); +/// "Map" is an array transform method very similar to the Array.map function you'd find in other languages. +/// You can use it to transform an array of values to a new array of values. +/// +/// For example, given a selection of [1, 2, 3]: +/// +/// $->map(@->add(10)) result is [11, 12, 13] +/// +/// We are taking each value passed into map via @ and running the "add" function against that value +/// +/// I could also "hard code" the values being passed in above using echo: +/// +/// $->echo([1,2,3])->map(@->add(10)) +fn map_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let Some(args) = method_args else { + return ( + None, + vec![ApplyToError::new( + format!("Method ->{} requires one argument", method_name.as_ref()), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + let Some(first_arg) = args.args.first() else { + return ( + None, + vec![ApplyToError::new( + format!("Method ->{} requires one argument", method_name.as_ref()), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + if let JSON::Array(array) = data { + let mut output = Vec::with_capacity(array.len()); + let mut errors = Vec::new(); + + for (i, element) in array.iter().enumerate() { + let input_path = input_path.append(JSON::Number(i.into())); + let (applied_opt, arg_errors) = + first_arg.apply_to_path(element, vars, &input_path, spec); + errors.extend(arg_errors); + output.insert(i, applied_opt.unwrap_or(JSON::Null)); + } + + (Some(JSON::Array(output)), errors) + } else { + // Return a singleton array wrapping the value of applying the + // ->map method the non-array input data. + first_arg + .apply_to_path(data, vars, input_path, spec) + .and_then_collecting_errors(|value| { + (Some(JSON::Array(vec![value.clone()])), Vec::new()) + }) + } +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn map_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + method_name.shape_location(context.source_id()), + ); + }; + match input_shape.case() { + ShapeCase::Array { prefix, tail } => { + let new_prefix = prefix + .iter() + .map(|shape| { + first_arg.compute_output_shape(context, shape.clone(), dollar_shape.clone()) + }) + .collect::>(); + let new_tail = first_arg.compute_output_shape(context, tail.clone(), dollar_shape); + Shape::array(new_prefix, new_tail, input_shape.locations) + } + _ => Shape::list( + first_arg.compute_output_shape(context, input_shape.any_item([]), dollar_shape), + input_shape.locations, + ), + } +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn map_should_transform_when_applied_to_array() { + assert_eq!( + selection!("messages->map(@.role)").apply_to(&json!({ + "messages": [ + { "role": "admin" }, + { "role": "user" }, + { "role": "guest" }, + ], + })), + (Some(json!(["admin", "user", "guest"])), vec![]), + ); + } + + #[test] + fn map_should_transform_when_applied_to_array_with_additional_transform() { + assert_eq!( + selection!("$->map(@->add(10))").apply_to(&json!([1, 2, 3])), + (Some(json!(vec![11, 12, 13])), vec![]), + ); + + assert_eq!( + selection!("values->map(@->typeof)").apply_to(&json!({ + "values": [1, 2.5, "hello", true, null, [], {}], + })), + ( + Some(json!([ + "number", "number", "string", "boolean", "null", "array", "object" + ])), + vec![], + ), + ); + + assert_eq!( + selection!("singleValue->map(@->mul(10))").apply_to(&json!({ + "singleValue": 123, + })), + (Some(json!([1230])), vec![]), + ); + } + + #[test] + fn map_should_transform_when_called_against_selected_array() { + assert_eq!( + selection!("$->echo([1,2,3])->map(@->add(10))").apply_to(&json!(null)), + (Some(json!(vec![11, 12, 13])), vec![]), + ); + } + + /* + #[test] + fn test_map_method() { + // TODO: re-test once method type checking is re-enabled + // { + // let single_value_data = json!({ + // "singleValue": 123, + // }); + // let json_selection = selection!("singleValue->map(@->jsonStringify)"); + // assert_eq!( + // json_selection.apply_to(&single_value_data), + // (Some(json!(["123"])), vec![]), + // ); + // let output_shape = json_selection.compute_output_shape( + // Shape::from_json_bytes(&single_value_data), + // &IndexMap::default(), + // &SourceId::new("test"), + // ); + // assert_eq!(output_shape.pretty_print(), "List"); + // } + }*/ +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/match.rs b/apollo-federation/src/connectors/json_selection/methods/public/match.rs new file mode 100644 index 0000000000..ccf705546c --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/match.rs @@ -0,0 +1,237 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::PathList; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::apply_to::ApplyToResultMethods; +use crate::connectors::json_selection::helpers::vec_push; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::known_var::KnownVariable; +use crate::connectors::json_selection::lit_expr::LitExpr; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::json_selection::location::merge_ranges; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(MatchMethod, match_method, match_shape); +/// The match method Takes any number of pairs [key, value], and returns value for the first +/// key that equals the data. If none of the pairs match, returns None. +/// Typically, the final pair will use @ as its key to ensure some default +/// value is returned. +/// +/// The most common use case would be mapping values to an enum. For example: +/// vehicleType: type->match( +/// ['1', 'CAR'], +/// ['2', 'VAN'], +/// [@, 'UNKNOWN'], +/// ) +fn match_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let mut errors = Vec::new(); + + if let Some(MethodArgs { args, .. }) = method_args { + for pair in args { + if let LitExpr::Array(pair) = pair.as_ref() { + let (pattern, value) = match pair.as_slice() { + [pattern, value] => (pattern, value), + _ => continue, + }; + let (candidate_opt, candidate_errors) = + pattern.apply_to_path(data, vars, input_path, spec); + errors.extend(candidate_errors); + + if let Some(candidate) = candidate_opt + && candidate == *data + { + return value + .apply_to_path(data, vars, input_path, spec) + .prepend_errors(errors); + }; + } + } + } + + ( + None, + vec_push( + errors, + ApplyToError::new( + format!( + "Method ->{} did not match any [candidate, value] pair", + method_name.as_ref(), + ), + input_path.to_vec(), + merge_ranges( + method_name.range(), + method_args.and_then(|args| args.range()), + ), + spec, + ), + ), + ) +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +pub(crate) fn match_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + if let Some(MethodArgs { args, .. }) = method_args { + let mut result_union = Vec::new(); + let mut has_infallible_case = false; + + for pair in args { + if let LitExpr::Array(pair) = pair.as_ref() { + let (pattern, value) = match pair.as_slice() { + [pattern, value] => (pattern, value), + _ => continue, + }; + if let LitExpr::Path(path) = pattern.as_ref() + && let PathList::Var(known_var, _tail) = path.path.as_ref() + && known_var.as_ref() == &KnownVariable::AtSign + { + has_infallible_case = true; + }; + + let value_shape = + value.compute_output_shape(context, input_shape.clone(), dollar_shape.clone()); + result_union.push(value_shape); + } + } + + if !has_infallible_case { + result_union.push(Shape::none()); + } + + if result_union.is_empty() { + Shape::error( + format!( + "Method ->{} requires at least one [candidate, value] pair", + method_name.as_ref(), + ), + merge_ranges( + method_name.range(), + method_args.and_then(|args| args.range()), + ) + .map(|range| context.source_id().location(range)), + ) + } else { + Shape::one( + result_union, + method_name.shape_location(context.source_id()), + ) + } + } else { + Shape::error( + format!( + "Method ->{} requires at least one [candidate, value] pair", + method_name.as_ref(), + ), + method_name.shape_location(context.source_id()), + ) + } +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn match_should_select_correct_value_from_options() { + assert_eq!( + selection!( + r#" + name + __typename: kind->match( + ['dog', 'Canine'], + ['cat', 'Feline'], + [@, 'Exotic'], + ) + "# + ) + .apply_to(&json!({ + "kind": "cat", + "name": "Whiskers", + })), + ( + Some(json!({ + "__typename": "Feline", + "name": "Whiskers", + })), + vec![], + ), + ); + } + + #[test] + fn match_should_select_default_value_using_at_sign() { + assert_eq!( + selection!( + r#" + name + __typename: kind->match( + ['dog', 'Canine'], + ['cat', 'Feline'], + [@, 'Exotic'], + ) + "# + ) + .apply_to(&json!({ + "kind": "axlotl", + "name": "Gulpy", + })), + ( + Some(json!({ + "__typename": "Exotic", + "name": "Gulpy", + })), + vec![], + ), + ); + } + + #[test] + fn match_should_result_in_error_when_no_match_found() { + let result = selection!( + r#" + name + __typename: kind->match( + ['dog', 'Canine'], + ['cat', 'Feline'], + ) + "# + ) + .apply_to(&json!({ + "kind": "axlotl", + "name": "Gulpy", + })); + + assert_eq!( + result.0, + Some(json!({ + "name": "Gulpy", + })), + ); + assert!( + result + .1 + .iter() + .any(|e| e.message() == "Method ->match did not match any [candidate, value] pair") + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/mod.rs b/apollo-federation/src/connectors/json_selection/methods/public/mod.rs new file mode 100644 index 0000000000..7f920db31b --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/mod.rs @@ -0,0 +1,59 @@ +mod echo; +pub(crate) use echo::EchoMethod; +mod entries; +pub(crate) use entries::EntriesMethod; +mod filter; +pub(crate) use filter::FilterMethod; +mod find; +pub(crate) use find::FindMethod; +mod first; +pub(crate) use first::FirstMethod; +mod gte; +pub(crate) use gte::GteMethod; +mod lte; +pub(crate) use lte::LteMethod; +mod json_stringify; +pub(crate) use json_stringify::JsonStringifyMethod; +mod last; +pub(crate) use last::LastMethod; +mod map; +pub(crate) use map::MapMethod; +mod r#match; +pub(crate) use r#match::MatchMethod; +pub(crate) use r#match::match_shape; +mod size; +pub(crate) use size::SizeMethod; +mod slice; +pub(crate) use slice::SliceMethod; +mod join_not_null; +pub(crate) use join_not_null::JoinNotNullMethod; +mod eq; +pub(crate) use eq::EqMethod; +mod ne; +pub(crate) use ne::NeMethod; +mod or; +pub(crate) use or::OrMethod; +mod gt; +pub(crate) use gt::GtMethod; +mod and; +pub(crate) use and::AndMethod; +mod lt; +pub(crate) use lt::LtMethod; +mod not; +pub(crate) use not::NotMethod; +mod r#in; +pub(crate) use r#in::InMethod; +mod get; +pub(crate) use get::GetMethod; +mod to_string; +pub(crate) use to_string::ToStringMethod; +mod parse_int; +pub(crate) use parse_int::ParseIntMethod; +mod contains; +pub(crate) use contains::ContainsMethod; +mod arithmetic; +pub(crate) use arithmetic::AddMethod; +pub(crate) use arithmetic::DivMethod; +pub(crate) use arithmetic::ModMethod; +pub(crate) use arithmetic::MulMethod; +pub(crate) use arithmetic::SubMethod; diff --git a/apollo-federation/src/connectors/json_selection/methods/public/ne.rs b/apollo-federation/src/connectors/json_selection/methods/public/ne.rs new file mode 100644 index 0000000000..06845248bc --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/ne.rs @@ -0,0 +1,499 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::json_selection::methods::common::number_value_as_float; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(NeMethod, ne_method, ne_shape); +/// Returns true if argument is not equal to the applied to value or false if they are equal. +/// Simple examples: +/// +/// $(123)->ne(123) results in false +/// $(123)->ne(456) results in true +fn ne_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if let Some(MethodArgs { args, .. }) = method_args + && let [arg] = args.as_slice() + { + let (value_opt, mut apply_to_errors) = arg.apply_to_path(data, vars, input_path, spec); + let matches = value_opt.and_then(|value| match (data, &value) { + // Number comparisons: Always convert to float so 1 == 1.0 + (JSON::Number(left), JSON::Number(right)) => { + let left = match number_value_as_float(left, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + let right = match number_value_as_float(right, method_name, input_path, spec) { + Ok(f) => f, + Err(err) => { + apply_to_errors.push(err); + return None; + } + }; + + Some(JSON::Bool(left != right)) + } + // Everything else + _ => Some(JSON::Bool(&value != data)), + }); + + return (matches, apply_to_errors); + } + ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires exactly one argument", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ) +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn ne_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let arg_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if arg_count > 1 { + return Shape::error( + format!( + "Method ->{} requires only one argument, but {arg_count} were provided", + method_name.as_ref(), + ), + vec![], + ); + } + + let Some(first_arg) = method_args.and_then(|args| args.args.first()) else { + return Shape::error( + format!("Method ->{} requires one argument", method_name.as_ref()), + method_name.shape_location(context.source_id()), + ); + }; + let arg_shape = first_arg.compute_output_shape(context, input_shape.clone(), dollar_shape); + + // Ensures that the arguments are of the same type... this includes covering cases like int/float and unknown/name + if !(input_shape.accepts(&arg_shape) || arg_shape.accepts(&input_shape)) { + return Shape::error_with_partial( + format!( + "Method ->{} can only compare values of the same type. Got {input_shape} != {arg_shape}.", + method_name.as_ref() + ), + Shape::bool_value(true, method_name.shape_location(context.source_id())), + method_name.shape_location(context.source_id()), + ); + } + + Shape::bool(method_name.shape_location(context.source_id())) +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn ne_should_return_false_when_applied_to_equals_argument() { + assert_eq!( + selection!( + r#" + result: value->ne(123) + "# + ) + .apply_to(&json!({ "value": 123 })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn ne_should_return_true_when_applied_to_does_not_equal_argument() { + assert_eq!( + selection!( + r#" + result: value->ne(1234) + "# + ) + .apply_to(&json!({ "value": 123 })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn ne_should_return_false_when_applied_to_numbers_of_different_types() { + assert_eq!( + selection!( + r#" + result: value->ne(1) + "# + ) + .apply_to(&json!({ "value": 1.0 })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn ne_should_return_false_when_applied_to_negative_numbers_of_different_types() { + assert_eq!( + selection!( + r#" + result: value->ne(-1) + "# + ) + .apply_to(&json!({ "value": -1.0 })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn ne_should_return_false_when_applied_to_equals_string_argument() { + assert_eq!( + selection!( + r#" + result: value->ne("hello") + "# + ) + .apply_to(&json!({ "value": "hello" })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn ne_should_return_true_when_applied_to_does_not_equal_string_argument() { + assert_eq!( + selection!( + r#" + result: value->ne("world") + "# + ) + .apply_to(&json!({ "value": "hello" })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn ne_should_return_false_when_applied_to_equals_bool_argument() { + assert_eq!( + selection!( + r#" + result: value->ne(true) + "# + ) + .apply_to(&json!({ "value": true })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn ne_should_return_true_when_applied_to_does_not_equal_bool_argument() { + assert_eq!( + selection!( + r#" + result: value->ne(false) + "# + ) + .apply_to(&json!({ "value": true })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn ne_should_return_false_when_applied_to_equals_object_argument() { + assert_eq!( + selection!( + r#" + result: value->ne({"name": "John", "age": 30}) + "# + ) + .apply_to(&json!({ "value": {"name": "John", "age": 30} })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn ne_should_return_true_when_applied_to_does_not_equal_object_argument() { + assert_eq!( + selection!( + r#" + result: value->ne({"name": "Jane", "age": 25}) + "# + ) + .apply_to(&json!({ "value": {"name": "John", "age": 30} })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn ne_should_return_false_when_applied_to_equals_array_argument() { + assert_eq!( + selection!( + r#" + result: value->ne([1, 2, 3]) + "# + ) + .apply_to(&json!({ "value": [1, 2, 3] })), + ( + Some(json!({ + "result": false, + })), + vec![], + ), + ); + } + + #[test] + fn ne_should_return_true_when_applied_to_does_not_equal_array_argument() { + assert_eq!( + selection!( + r#" + result: value->ne([4, 5, 6]) + "# + ) + .apply_to(&json!({ "value": [1, 2, 3] })), + ( + Some(json!({ + "result": true, + })), + vec![], + ), + ); + } + + #[test] + fn ne_should_return_error_when_no_arguments_provided() { + let result = selection!( + r#" + result: value->ne() + "# + ) + .apply_to(&json!({ "value": 123 })); + + assert_eq!(result.0, Some(json!({}))); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->ne requires exactly one argument") + ); + } +} + +#[cfg(test)] +mod shape_tests { + use serde_json::Number; + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..7, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + ne_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("ne".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::none(), + ) + } + + #[test] + fn ne_shape_should_return_bool_on_valid_strings() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::string([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn ne_shape_should_return_bool_on_valid_numbers() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Number(Number::from(42)), None)], + Shape::int([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn ne_shape_should_return_bool_on_valid_booleans() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Bool(true), None)], + Shape::bool([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn ne_shape_should_error_on_mixed_types() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("a".to_string()), None)], + Shape::int([]) + ), + Shape::error_with_partial( + "Method ->ne can only compare values of the same type. Got Int != \"a\"." + .to_string(), + Shape::bool_value(true, [get_location()]), + [get_location()] + ) + ); + } + + #[test] + fn ne_shape_should_error_on_no_args() { + assert_eq!( + get_shape(vec![], Shape::string([])), + Shape::error( + "Method ->ne requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn ne_shape_should_error_on_too_many_args() { + assert_eq!( + get_shape( + vec![ + WithRange::new(LitExpr::Number(Number::from(42)), None), + WithRange::new(LitExpr::Number(Number::from(43)), None) + ], + Shape::int([]) + ), + Shape::error( + "Method ->ne requires only one argument, but 2 were provided".to_string(), + [] + ) + ); + } + + #[test] + fn ne_shape_should_error_on_none_args() { + let location = get_location(); + assert_eq!( + ne_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("ne".to_string(), Some(location.span)), + None, + Shape::string([]), + Shape::none(), + ), + Shape::error( + "Method ->ne requires one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn ne_shape_should_return_bool_on_unknown_input() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("test".to_string()), None)], + Shape::unknown([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn ne_shape_should_return_bool_on_named_input() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Number(Number::from(42)), None)], + Shape::name("a", []) + ), + Shape::bool([get_location()]) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/not.rs b/apollo-federation/src/connectors/json_selection/methods/public/not.rs new file mode 100644 index 0000000000..f4e2943898 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/not.rs @@ -0,0 +1,237 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::ConnectSpec; +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::impl_arrow_method; + +impl_arrow_method!(NotMethod, not_method, not_shape); +/// Given a boolean value, returns the logical negation of that value. +/// +/// Examples: +/// $(true)->not results in false +/// $(false)->not results in true +fn not_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + _vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if method_args.is_some() { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + + let Some(value) = data.as_bool() else { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} can only be applied to boolean values.", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + (Some(JSON::Bool(!value)), vec![]) +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn not_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + _dollar_shape: Shape, +) -> Shape { + if method_args.is_some() { + return Shape::error( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + + // We will accept anything bool-like OR unknown/named + if !(Shape::bool([]).accepts(&input_shape) || input_shape.accepts(&Shape::unknown([]))) { + return Shape::error( + format!( + "Method ->{} can only be applied to boolean values. Got {input_shape}.", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + + Shape::bool(method_name.shape_location(context.source_id())) +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn not_should_negate_true() { + assert_eq!( + selection!("$.value->not").apply_to(&json!({ + "value": true, + })), + (Some(json!(false)), vec![]), + ); + } + + #[test] + fn not_should_negate_false() { + assert_eq!( + selection!("$.value->not").apply_to(&json!({ + "value": false, + })), + (Some(json!(true)), vec![]), + ); + } + + #[test] + fn not_should_return_error_when_applied_to_non_boolean() { + let result = selection!("$.value->not").apply_to(&json!({ + "value": "hello", + })); + + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->not can only be applied to boolean values.") + ); + } + + #[test] + fn not_should_return_error_when_arguments_provided() { + let result = selection!("$.value->not(true)").apply_to(&json!({ + "value": true, + })); + + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->not does not take any arguments") + ); + } + + #[test] + fn not_should_work_with_double_negation() { + assert_eq!( + selection!("$.value->not->not").apply_to(&json!({ + "value": true, + })), + (Some(json!(true)), vec![]), + ); + } +} + +#[cfg(test)] +mod shape_tests { + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..7, + } + } + + fn get_shape(args: Option<&MethodArgs>, input: Shape) -> Shape { + let location = get_location(); + not_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("not".to_string(), Some(location.span)), + args, + input, + Shape::none(), + ) + } + + #[test] + fn not_shape_should_return_bool_on_valid_boolean() { + assert_eq!( + get_shape(None, Shape::bool([])), + Shape::bool([get_location()]) + ); + } + + #[test] + fn not_shape_should_error_on_non_boolean_input() { + assert_eq!( + get_shape(None, Shape::string([])), + Shape::error( + "Method ->not can only be applied to boolean values. Got String.".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn not_shape_should_error_on_args_provided() { + assert_eq!( + get_shape( + Some(&MethodArgs { + args: vec![WithRange::new(LitExpr::Bool(true), None)], + range: None + }), + Shape::bool([]) + ), + Shape::error( + "Method ->not does not take any arguments".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn not_shape_should_accept_unknown_input() { + assert_eq!( + get_shape(None, Shape::unknown([])), + Shape::bool([get_location()]) + ); + } + + #[test] + fn not_shape_should_accept_name_input() { + assert_eq!( + get_shape(None, Shape::name("a", [])), + Shape::bool([get_location()]) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/or.rs b/apollo-federation/src/connectors/json_selection/methods/public/or.rs new file mode 100644 index 0000000000..9cd400662d --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/or.rs @@ -0,0 +1,343 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(OrMethod, or_method, or_shape); +/// Given 2 or more values to compare, returns true if any of the values are true. +/// +/// Examples: +/// $(true)->or(false) results in true +/// $(false)->or(true) results in true +/// $(true)->or(true) results in true +/// $(false)->or(false) results in false +/// $(false)->or(false, true) results in true +fn or_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let Some(mut result) = data.as_bool() else { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} can only be applied to boolean values.", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + let Some(MethodArgs { args, .. }) = method_args else { + return ( + None, + vec![ApplyToError::new( + format!("Method ->{} requires arguments", method_name.as_ref()), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + let mut errors = Vec::new(); + for arg in args { + if result { + break; + } + let (value_opt, arg_errors) = arg.apply_to_path(data, vars, input_path, spec); + errors.extend(arg_errors); + + match value_opt { + Some(JSON::Bool(value)) => result = value, + Some(_) => { + errors.extend(vec![ApplyToError::new( + format!( + "Method ->{} can only accept boolean arguments.", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )]); + } + None => {} + } + } + + (Some(JSON::Bool(result)), errors) +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn or_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + if method_args.and_then(|args| args.args.first()).is_none() { + return Shape::error( + format!( + "Method ->{} requires at least one argument", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + }; + + // We will accept anything bool-like OR unknown/named + if !(Shape::bool([]).accepts(&input_shape) || input_shape.accepts(&Shape::unknown([]))) { + return Shape::error( + format!( + "Method ->{} can only be applied to boolean values. Got {input_shape}.", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + + if let Some(MethodArgs { args, .. }) = method_args { + for (i, arg) in args.iter().enumerate() { + let arg_shape = + arg.compute_output_shape(context, input_shape.clone(), dollar_shape.clone()); + + // We will accept anything bool-like OR unknown/named + if !(Shape::bool([]).accepts(&arg_shape) || arg_shape.accepts(&Shape::unknown([]))) { + return Shape::error( + format!( + "Method ->{} can only accept boolean arguments. Got {arg_shape} at position {i}.", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + } + } + + Shape::bool(method_name.shape_location(context.source_id())) +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn or_should_return_true_when_either_value_is_truthy() { + assert_eq!( + selection!("$.both->or($.and)").apply_to(&json!({ + "both": true, + "and": false, + })), + (Some(json!(true)), vec![]), + ); + } + #[test] + fn or_should_return_false_when_neither_value_is_truthy() { + assert_eq!( + selection!("data.x->or($.data.y)").apply_to(&json!({ + "data": { + "x": false, + "y": false, + }, + })), + (Some(json!(false)), vec![]), + ); + } + + #[test] + fn or_should_return_error_when_arguments_are_not_boolean() { + let result = selection!("$.a->or($.b, $.c)").apply_to(&json!({ + "a": false, + "b": null, + "c": 0, + })); + + assert_eq!(result.0, Some(json!(false))); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->or can only accept boolean arguments.") + ); + } + #[test] + fn or_should_return_error_when_applied_to_non_boolean() { + let result = selection!("$.b->or($.a, $.c)").apply_to(&json!({ + "a": false, + "b": null, + "c": 0, + })); + + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->or can only be applied to boolean values.") + ); + } + + #[test] + fn or_should_return_error_when_no_arguments_provided() { + let result = selection!("$.a->or").apply_to(&json!({ + "a": true, + })); + + println!("result: {result:?}"); + + assert_eq!(result.0, None); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->or requires arguments") + ); + } +} + +#[cfg(test)] +mod shape_tests { + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::Key; + use crate::connectors::PathSelection; + use crate::connectors::json_selection::PathList; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..7, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + or_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("or".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::none(), + ) + } + + #[test] + fn or_shape_should_return_bool_on_valid_booleans() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Bool(false), None)], + Shape::bool([]) + ), + Shape::bool([get_location()]) + ); + } + + #[test] + fn or_shape_should_error_on_non_boolean_input() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Bool(true), None)], + Shape::string([]) + ), + Shape::error( + "Method ->or can only be applied to boolean values. Got String.".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn or_shape_should_error_on_non_boolean_args() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("test".to_string()), None)], + Shape::bool([]) + ), + Shape::error( + "Method ->or can only accept boolean arguments. Got \"test\" at position 0." + .to_string(), + [get_location()] + ) + ); + } + + #[test] + fn or_shape_should_error_on_no_args() { + assert_eq!( + get_shape(vec![], Shape::bool([])), + Shape::error( + "Method ->or requires at least one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn or_shape_should_error_on_none_args() { + let location = get_location(); + assert_eq!( + or_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("or".to_string(), Some(location.span)), + None, + Shape::bool([]), + Shape::none(), + ), + Shape::error( + "Method ->or requires at least one argument".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn or_shape_should_error_on_args_that_compute_as_none() { + let path = LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("a").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }); + let location = get_location(); + assert_eq!( + or_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("or".to_string(), Some(location.span)), + Some(&MethodArgs { + args: vec![path.into_with_range()], + range: None + }), + Shape::bool([]), + Shape::none(), + ), + Shape::error( + "Method ->or can only accept boolean arguments. Got None at position 0." + .to_string(), + [get_location()] + ) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/parse_int.rs b/apollo-federation/src/connectors/json_selection/methods/public/parse_int.rs new file mode 100644 index 0000000000..e012c4b29e --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/parse_int.rs @@ -0,0 +1,917 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::ConnectSpec; +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::impl_arrow_method; + +const DEFAULT_BASE: u32 = 10; +impl_arrow_method!(ParseIntMethod, parse_int_method, parse_int_shape); +/// Parses a string or number as an integer with an optional base. +/// Simple examples: +/// +/// $("42")->parseInt results in 42 +/// $("20")->parseInt(10) results in 20 +/// $("20")->parseInt(16) results in 32 +/// $("ff")->parseInt(16) results in 255 +/// $(42)->parseInt results in 42 +/// $(123.6)->parseInt results in 123 +/// $("invalid")->parseInt results in error +fn parse_int_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + // Handle both string and number inputs + let input_str = match data { + JSON::String(s) => s.as_str().to_string(), + JSON::Number(num) => { + // For numbers, convert to string representation for consistent parsing + if let Some(int_val) = num.as_i64() { + int_val.to_string() + } else if let Some(float_val) = num.as_f64() { + // Truncate float to integer, then convert to string + let truncated = float_val.trunc() as i64; + truncated.to_string() + } else { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} cannot parse number: {}", + method_name.as_ref(), + num + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + } + _ => { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} can only parse strings and numbers. Found: {}", + method_name.as_ref(), + data + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + }; + + if let Some(args) = method_args + && args.args.len() > 1 + { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} accepts at most one argument (base), but {} were provided", + method_name.as_ref(), + args.args.len() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + + // Parse base argument or use default (10) + let base = match method_args + .and_then(|args| args.args.first()) + .map(|first_arg| first_arg.apply_to_path(data, vars, input_path, spec)) + { + Some((Some(JSON::Number(base_num)), _)) => { + let Some(base_value) = base_num.as_u64() else { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} base argument must be an integer. Found: {}", + method_name.as_ref(), + base_num + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + // Validate radix range to prevent panic in from_str_radix + if !(2..=36).contains(&base_value) { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} failed to parse '{}' as integer with base {} (radix must be between 2 and 36)", + method_name.as_ref(), + input_str, + base_value + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + base_value as u32 + } + Some((Some(other), _)) => { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} base argument must be a number. Found: {}", + method_name.as_ref(), + other + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + Some((None, arg_errors)) => { + return (None, arg_errors); + } + None => DEFAULT_BASE, + }; + + // Parse the string with the specified base + match i64::from_str_radix(&input_str, base) { + Ok(parsed_value) => (Some(JSON::Number(parsed_value.into())), vec![]), + Err(_) => ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} failed to parse '{}' as integer with base {}", + method_name.as_ref(), + input_str, + base + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ), + } +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn parse_int_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + dollar_shape: Shape, +) -> Shape { + let arg_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if arg_count > 1 { + return Shape::error( + format!( + "Method ->{} accepts at most one argument (base), but {} were provided", + method_name.as_ref(), + arg_count + ), + method_name.shape_location(context.source_id()), + ); + } + + // Check if input is a string, number, or could be a string/number at runtime + if !(Shape::string([]).accepts(&input_shape) + || Shape::float([]).accepts(&input_shape) + || input_shape.accepts(&Shape::unknown([]))) + { + return Shape::error_with_partial( + format!( + "Method ->{} can only parse strings and numbers. Found: {}", + method_name.as_ref(), + input_shape + ), + Shape::none(), + method_name.shape_location(context.source_id()), + ); + } + + // If we have a base argument, validate its shape + if let Some(first_arg) = method_args.and_then(|args| args.args.first()) { + let arg_shape = first_arg.compute_output_shape(context, input_shape, dollar_shape); + + if !(Shape::int([]).accepts(&arg_shape) || arg_shape.accepts(&Shape::unknown([]))) { + return Shape::error_with_partial( + format!( + "Method ->{} base argument must be an integer. Found: {}", + method_name.as_ref(), + arg_shape + ), + Shape::int(method_name.shape_location(context.source_id())), + method_name.shape_location(context.source_id()), + ); + } + } + + Shape::int(method_name.shape_location(context.source_id())) +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn parse_int_should_parse_decimal_string() { + assert_eq!( + selection!( + r#" + result: value->parseInt + "# + ) + .apply_to(&json!({ "value": "42" })), + ( + Some(json!({ + "result": 42, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_parse_with_explicit_base_10() { + assert_eq!( + selection!( + r#" + result: value->parseInt(10) + "# + ) + .apply_to(&json!({ "value": "42" })), + ( + Some(json!({ + "result": 42, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_parse_hexadecimal_string() { + assert_eq!( + selection!( + r#" + result: value->parseInt(16) + "# + ) + .apply_to(&json!({ "value": "ff" })), + ( + Some(json!({ + "result": 255, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_parse_binary_string() { + assert_eq!( + selection!( + r#" + result: value->parseInt(2) + "# + ) + .apply_to(&json!({ "value": "1010" })), + ( + Some(json!({ + "result": 10, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_parse_octal_string() { + assert_eq!( + selection!( + r#" + result: value->parseInt(8) + "# + ) + .apply_to(&json!({ "value": "77" })), + ( + Some(json!({ + "result": 63, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_parse_negative_number() { + assert_eq!( + selection!( + r#" + result: value->parseInt() + "# + ) + .apply_to(&json!({ "value": "-42" })), + ( + Some(json!({ + "result": -42, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_parse_zero() { + assert_eq!( + selection!( + r#" + result: value->parseInt() + "# + ) + .apply_to(&json!({ "value": "0" })), + ( + Some(json!({ + "result": 0, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_error_for_invalid_string() { + let result = selection!( + r#" + result: value->parseInt + "# + ) + .apply_to(&json!({ "value": "invalid" })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->parseInt failed to parse 'invalid' as integer with base 10") + ); + } + + #[test] + fn parse_int_should_error_for_invalid_hex_string() { + let result = selection!( + r#" + result: value->parseInt(16) + "# + ) + .apply_to(&json!({ "value": "xyz" })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->parseInt failed to parse 'xyz' as integer with base 16") + ); + } + + #[test] + fn parse_int_should_error_for_empty_string() { + let result = selection!( + r#" + result: value->parseInt + "# + ) + .apply_to(&json!({ "value": "" })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->parseInt failed to parse '' as integer with base 10") + ); + } + + #[test] + fn parse_int_should_error_for_boolean_input() { + let result = selection!( + r#" + result: value->parseInt + "# + ) + .apply_to(&json!({ "value": true })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->parseInt can only parse strings and numbers. Found: true") + ); + } + + #[test] + fn parse_int_should_error_for_null_input() { + let result = selection!( + r#" + result: value->parseInt + "# + ) + .apply_to(&json!({ "value": null })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->parseInt can only parse strings and numbers. Found: null") + ); + } + + #[test] + fn parse_int_should_error_for_array_input() { + let result = selection!( + r#" + result: value->parseInt + "# + ) + .apply_to(&json!({ "value": [1, 2, 3] })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->parseInt can only parse strings and numbers. Found: [1,2,3]") + ); + } + + #[test] + fn parse_int_should_error_for_object_input() { + let result = selection!( + r#" + result: value->parseInt + "# + ) + .apply_to(&json!({ "value": {"a": 1} })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->parseInt can only parse strings and numbers. Found: {\"a\":1}") + ); + } + + #[test] + fn parse_int_should_error_for_invalid_base() { + let result = selection!( + r#" + result: value->parseInt(1) + "# + ) + .apply_to(&json!({ "value": "42" })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->parseInt failed to parse '42' as integer with base 1 (radix must be between 2 and 36)") + ); + } + + #[test] + fn parse_int_should_error_for_base_too_large() { + let result = selection!( + r#" + result: value->parseInt(37) + "# + ) + .apply_to(&json!({ "value": "42" })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->parseInt failed to parse '42' as integer with base 37 (radix must be between 2 and 36)") + ); + } + + #[test] + fn parse_int_should_error_for_non_numeric_base() { + let result = selection!( + r#" + result: value->parseInt("not_a_number") + "# + ) + .apply_to(&json!({ "value": "42" })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0].message().contains( + "Method ->parseInt base argument must be a number. Found: \"not_a_number\"" + ) + ); + } + + #[test] + fn parse_int_should_error_for_float_base() { + let result = selection!( + r#" + result: value->parseInt(10.5) + "# + ) + .apply_to(&json!({ "value": "42" })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->parseInt base argument must be an integer. Found: 10.5") + ); + } + + #[test] + fn parse_int_should_error_for_too_many_arguments() { + let result = selection!( + r#" + result: value->parseInt(10, 16) + "# + ) + .apply_to(&json!({ "value": "42" })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!(result.1[0].message().contains( + "Method ->parseInt accepts at most one argument (base), but 2 were provided" + )); + } + + #[test] + fn parse_int_should_handle_large_hex_numbers() { + assert_eq!( + selection!( + r#" + result: value->parseInt(16) + "# + ) + .apply_to(&json!({ "value": "7FFFFFFF" })), + ( + Some(json!({ + "result": 2147483647, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_parse_base_36() { + assert_eq!( + selection!( + r#" + result: value->parseInt(36) + "# + ) + .apply_to(&json!({ "value": "zz" })), + ( + Some(json!({ + "result": 1295, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_parse_integer_number() { + assert_eq!( + selection!( + r#" + result: value->parseInt + "# + ) + .apply_to(&json!({ "value": 42 })), + ( + Some(json!({ + "result": 42, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_parse_negative_integer_number() { + assert_eq!( + selection!( + r#" + result: value->parseInt + "# + ) + .apply_to(&json!({ "value": -123 })), + ( + Some(json!({ + "result": -123, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_parse_integer_number_with_base() { + assert_eq!( + selection!( + r#" + result: value->parseInt(16) + "# + ) + .apply_to(&json!({ "value": 10 })), + ( + Some(json!({ + "result": 16, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_truncate_positive_float() { + assert_eq!( + selection!( + r#" + result: value->parseInt + "# + ) + .apply_to(&json!({ "value": 123.6 })), + ( + Some(json!({ + "result": 123, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_truncate_negative_float() { + assert_eq!( + selection!( + r#" + result: value->parseInt + "# + ) + .apply_to(&json!({ "value": -123.9 })), + ( + Some(json!({ + "result": -123, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_handle_zero_float() { + assert_eq!( + selection!( + r#" + result: value->parseInt + "# + ) + .apply_to(&json!({ "value": 0.0 })), + ( + Some(json!({ + "result": 0, + })), + vec![], + ), + ); + } + + #[test] + fn parse_int_should_truncate_float_with_base() { + assert_eq!( + selection!( + r#" + result: value->parseInt(16) + "# + ) + .apply_to(&json!({ "value": 10.7 })), + ( + Some(json!({ + "result": 16, + })), + vec![], + ), + ); + } +} + +#[cfg(test)] +mod shape_tests { + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::Key; + use crate::connectors::PathSelection; + use crate::connectors::json_selection::PathList; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..8, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + parse_int_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("parseInt".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::unknown([]), + ) + } + + #[test] + fn parse_int_shape_should_return_int_for_string_input() { + assert_eq!( + get_shape(vec![], Shape::string([])), + Shape::int([get_location()]) + ); + } + + #[test] + fn parse_int_shape_should_return_int_for_string_input_with_base() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::Number(10.into()), None)], + Shape::string([]) + ), + Shape::int([get_location()]) + ); + } + + #[test] + fn parse_int_shape_should_return_int_for_int_input() { + assert_eq!( + get_shape(vec![], Shape::int([])), + Shape::int([get_location()]) + ); + } + + #[test] + fn parse_int_shape_should_return_int_for_float_input() { + assert_eq!( + get_shape(vec![], Shape::float([])), + Shape::int([get_location()]) + ); + } + + #[test] + fn parse_int_shape_should_error_for_boolean_input() { + assert_eq!( + get_shape(vec![], Shape::bool([])), + Shape::error_with_partial( + "Method ->parseInt can only parse strings and numbers. Found: Bool".to_string(), + Shape::none(), + [get_location()] + ) + ); + } + + #[test] + fn parse_int_shape_should_error_for_too_many_args() { + assert_eq!( + get_shape( + vec![ + WithRange::new(LitExpr::Number(10.into()), None), + WithRange::new(LitExpr::Number(16.into()), None) + ], + Shape::string([]) + ), + Shape::error( + "Method ->parseInt accepts at most one argument (base), but 2 were provided" + .to_string(), + [get_location()] + ) + ); + } + + #[test] + fn parse_int_shape_should_error_for_non_integer_base() { + assert_eq!( + get_shape( + vec![WithRange::new( + LitExpr::String("not_a_number".to_string()), + None + )], + Shape::string([]) + ), + Shape::error_with_partial( + "Method ->parseInt base argument must be an integer. Found: \"not_a_number\"" + .to_string(), + Shape::int([get_location()]), + [get_location()] + ) + ); + } + + #[test] + fn parse_int_shape_should_return_int_for_none_args() { + let location = get_location(); + assert_eq!( + parse_int_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("parseInt".to_string(), Some(location.span)), + None, + Shape::string([]), + Shape::none(), + ), + Shape::int([get_location()]) + ); + } + + #[test] + fn parse_int_shape_should_return_int_for_unknown_input() { + assert_eq!( + get_shape(vec![], Shape::unknown([])), + Shape::int([get_location()]) + ); + } + + #[test] + fn parse_int_shape_should_return_int_for_unknown_base_argument() { + let path = LitExpr::Path(PathSelection { + path: PathList::Key( + Key::field("unknown_field").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }); + + let result = get_shape(vec![path.into_with_range()], Shape::string([])); + assert_eq!(result, Shape::int([get_location()])); + } + + #[test] + fn parse_int_shape_should_error_for_object_input() { + assert_eq!( + get_shape(vec![], Shape::empty_object([])), + Shape::error_with_partial( + "Method ->parseInt can only parse strings and numbers. Found: {}".to_string(), + Shape::none(), + [get_location()] + ) + ); + } + + #[test] + fn parse_int_shape_should_error_for_array_input() { + assert_eq!( + get_shape(vec![], Shape::tuple([], [])), + Shape::error_with_partial( + "Method ->parseInt can only parse strings and numbers. Found: []".to_string(), + Shape::none(), + [get_location()] + ) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/size.rs b/apollo-federation/src/connectors/json_selection/methods/public/size.rs new file mode 100644 index 0000000000..508871d475 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/size.rs @@ -0,0 +1,237 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::helpers::json_type_name; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(SizeMethod, size_method, size_shape); +/// Returns the number of items in an array, length of a string, or the number of properties in an array. +/// The simplest possible example: +/// +/// $->echo([1,2,3,4,5])->size would result in 5 +/// $->echo("hello")->size would result in 5 +/// $->echo({"a": true, "b": true})->size would result in 2 +fn size_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + _vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if method_args.is_some() { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + + match data { + JSON::Array(array) => { + let size = array.len() as i64; + (Some(JSON::Number(size.into())), Vec::new()) + } + JSON::String(s) => { + let size = s.as_str().len() as i64; + (Some(JSON::Number(size.into())), Vec::new()) + } + // Though we can't ask for ->first or ->last or ->at(n) on an object, we + // can safely return how many properties the object has for ->size. + JSON::Object(map) => { + let size = map.len() as i64; + (Some(JSON::Number(size.into())), Vec::new()) + } + _ => ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires an array, string, or object input, not {}", + method_name.as_ref(), + json_type_name(data), + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ), + } +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn size_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + mut input_shape: Shape, + _dollar_shape: Shape, +) -> Shape { + if method_args.is_some() { + return Shape::error( + format!( + "Method ->{} does not take any arguments", + method_name.as_ref() + ), + method_name.shape_location(context.source_id()), + ); + } + + match input_shape.case() { + ShapeCase::String(Some(value)) => Shape::int_value( + value.len() as i64, + method_name.shape_location(context.source_id()), + ), + ShapeCase::String(None) => Shape::int(method_name.shape_location(context.source_id())), + ShapeCase::Name(_, _) => Shape::int(method_name.shape_location(context.source_id())), // TODO: catch errors after name resolution + ShapeCase::Array { prefix, tail } => { + if tail.is_none() { + Shape::int_value( + prefix.len() as i64, + method_name.shape_location(context.source_id()), + ) + } else { + Shape::int(method_name.shape_location(context.source_id())) + } + } + ShapeCase::Object { fields, rest, .. } => { + if rest.is_none() { + Shape::int_value( + fields.len() as i64, + method_name.shape_location(context.source_id()), + ) + } else { + Shape::int(method_name.shape_location(context.source_id())) + } + } + _ => Shape::error( + format!( + "Method ->{} requires an array, string, or object input", + method_name.as_ref() + ), + { + input_shape + .locations + .extend(method_name.shape_location(context.source_id())); + input_shape.locations + }, + ), + } +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use crate::connectors::ApplyToError; + use crate::selection; + + #[test] + fn size_should_return_0_when_empty_array() { + assert_eq!( + selection!("$->size").apply_to(&json!([])), + (Some(json!(0)), vec![]), + ); + } + + #[test] + fn size_should_return_number_of_items_in_array() { + assert_eq!( + selection!("$->size").apply_to(&json!([1, 2, 3])), + (Some(json!(3)), vec![]), + ); + } + + #[test] + fn size_should_error_when_applied_to_null() { + assert_eq!( + selection!("$->size").apply_to(&json!(null)), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->size requires an array, string, or object input, not null", + "path": ["->size"], + "range": [3, 7], + }))] + ), + ); + } + + #[test] + fn size_should_error_when_applied_to_bool() { + assert_eq!( + selection!("$->size").apply_to(&json!(true)), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->size requires an array, string, or object input, not boolean", + "path": ["->size"], + "range": [3, 7], + }))] + ), + ); + } + + #[test] + fn size_should_error_when_applied_to_number() { + assert_eq!( + selection!("count->size").apply_to(&json!({ + "count": 123, + })), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->size requires an array, string, or object input, not number", + "path": ["count", "->size"], + "range": [7, 11], + }))] + ), + ); + } + + #[test] + fn size_should_return_length_of_string() { + assert_eq!( + selection!("$->size").apply_to(&json!("hello")), + (Some(json!(5)), vec![]), + ); + } + + #[test] + fn size_should_return_0_on_empty_string() { + assert_eq!( + selection!("$->size").apply_to(&json!("")), + (Some(json!(0)), vec![]), + ); + } + + #[test] + fn size_should_return_number_of_properties_of_an_object() { + assert_eq!( + selection!("$->size").apply_to(&json!({ "a": 1, "b": 2, "c": 3 })), + (Some(json!(3)), vec![]), + ); + } + + #[test] + fn size_should_return_0_on_empty_object() { + assert_eq!( + selection!("$->size").apply_to(&json!({})), + (Some(json!(0)), vec![]), + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/slice.rs b/apollo-federation/src/connectors/json_selection/methods/public/slice.rs new file mode 100644 index 0000000000..ac5f27c5af --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/slice.rs @@ -0,0 +1,221 @@ +use std::iter::empty; + +use serde_json_bytes::Value as JSON; +use shape::Shape; +use shape::ShapeCase; + +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::ApplyToInternal; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::connectors::spec::ConnectSpec; +use crate::impl_arrow_method; + +impl_arrow_method!(SliceMethod, slice_method, slice_shape); +/// Extracts part of an array given a set of indices and returns a new array. +/// Can also be used on a string to get chars at the specified indices. +/// The simplest possible example: +/// +/// $->echo([0,1,2,3,4,5])->slice(1, 3) would result in [1,2] +/// $->echo("hello")->slice(1,3) would result in "el" +fn slice_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + let length = if let JSON::Array(array) = data { + array.len() as i64 + } else if let JSON::String(s) = data { + s.as_str().len() as i64 + } else { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} requires an array or string input", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + }; + + if let Some(MethodArgs { args, .. }) = method_args { + let mut errors = Vec::new(); + + let start = args + .first() + .and_then(|arg| { + let (value_opt, apply_errors) = arg.apply_to_path(data, vars, input_path, spec); + errors.extend(apply_errors); + value_opt + }) + .and_then(|n| n.as_i64()) + .unwrap_or(0) + .max(0) + .min(length) as usize; + + let end = args + .get(1) + .and_then(|arg| { + let (value_opt, apply_errors) = arg.apply_to_path(data, vars, input_path, spec); + errors.extend(apply_errors); + value_opt + }) + .and_then(|n| n.as_i64()) + .unwrap_or(length) + .max(0) + .min(length) as usize; + + let array = match data { + JSON::Array(array) => { + if end - start > 0 { + JSON::Array( + array + .iter() + .skip(start) + .take(end - start) + .cloned() + .collect(), + ) + } else { + JSON::Array(Vec::new()) + } + } + + JSON::String(s) => { + if end - start > 0 { + JSON::String(s.as_str()[start..end].to_string().into()) + } else { + JSON::String("".to_string().into()) + } + } + + _ => unreachable!(), + }; + + (Some(array), errors) + } else { + // TODO Should calling ->slice or ->slice() without arguments be an + // error? In JavaScript, array->slice() copies the array, but that's not + // so useful in an immutable value-typed language like JSONSelection. + (Some(data.clone()), Vec::new()) + } +} +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn slice_shape( + context: &ShapeContext, + method_name: &WithRange, + _method_args: Option<&MethodArgs>, + mut input_shape: Shape, + _dollar_shape: Shape, +) -> Shape { + // There are more clever shapes we could compute here (when start and end + // are statically known integers and input_shape is an array or string with + // statically known prefix elements, for example) but for now we play it + // safe (and honest) by returning a new variable-length array whose element + // shape is a union of the original element (prefix and tail) shapes. + match input_shape.case() { + ShapeCase::Array { prefix, tail } => { + let mut one_shapes = prefix.clone(); + if !tail.is_none() { + one_shapes.push(tail.clone()); + } + Shape::array([], Shape::one(one_shapes, empty()), input_shape.locations) + } + ShapeCase::String(_) => Shape::string(input_shape.locations), + ShapeCase::Name(_, _) => input_shape, // TODO: add a way to validate inputs after name resolution + _ => Shape::error( + format!( + "Method ->{} requires an array or string input", + method_name.as_ref() + ), + { + input_shape + .locations + .extend(method_name.shape_location(context.source_id())); + input_shape.locations + }, + ), + } +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn slice_should_grab_parts_of_array_by_specified_indices() { + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!([1, 2, 3, 4, 5])), + (Some(json!([2, 3])), vec![]), + ); + } + + #[test] + fn slice_should_stop_at_end_when_array_is_shorter_than_specified_end_index() { + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!([1, 2])), + (Some(json!([2])), vec![]), + ); + } + + #[test] + fn slice_should_return_empty_array_when_array_is_shorter_than_specified_indices() { + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!([1])), + (Some(json!([])), vec![]), + ); + } + + #[test] + fn slice_should_return_empty_array_when_provided_empty_array() { + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!([])), + (Some(json!([])), vec![]), + ); + } + + #[test] + fn slice_should_return_blank_when_string_is_empty() { + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!("")), + (Some(json!("")), vec![]), + ); + } + + #[test] + fn slice_should_return_part_of_string() { + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!("hello")), + (Some(json!("el")), vec![]), + ); + } + + #[test] + fn slice_should_return_part_of_string_when_slice_indices_are_larger_than_string() { + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!("he")), + (Some(json!("e")), vec![]), + ); + } + + #[test] + fn slice_should_return_empty_string_when_indices_are_completely_out_of_string_bounds() { + assert_eq!( + selection!("$->slice(1, 3)").apply_to(&json!("h")), + (Some(json!("")), vec![]), + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/snapshots/get_should_correct_call_methods_with_extra_spaces.snap b/apollo-federation/src/connectors/json_selection/methods/public/snapshots/get_should_correct_call_methods_with_extra_spaces.snap new file mode 100644 index 0000000000..0ebfc701c5 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/snapshots/get_should_correct_call_methods_with_extra_spaces.snap @@ -0,0 +1,140 @@ +--- +source: apollo-federation/src/connectors/json_selection/methods/public/get.rs +expression: selection_with_spaces +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 1..2, + ), + }, + WithRange { + node: Method( + WithRange { + node: "get", + range: Some( + 6..9, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 12..13, + ), + }, + WithRange { + node: Method( + WithRange { + node: "echo", + range: Some( + 17..21, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Number( + Number(-5), + ), + range: Some( + 24..27, + ), + }, + ], + range: Some( + 22..29, + ), + }, + ), + WithRange { + node: Method( + WithRange { + node: "mul", + range: Some( + 33..36, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Number( + Number(2), + ), + range: Some( + 39..40, + ), + }, + ], + range: Some( + 37..42, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 42..42, + ), + }, + ), + range: Some( + 30..42, + ), + }, + ), + range: Some( + 14..42, + ), + }, + ), + range: Some( + 12..42, + ), + }, + }, + ), + range: Some( + 12..42, + ), + }, + ], + range: Some( + 10..44, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 44..44, + ), + }, + ), + range: Some( + 3..44, + ), + }, + ), + range: Some( + 1..44, + ), + }, + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/methods/public/to_string.rs b/apollo-federation/src/connectors/json_selection/methods/public/to_string.rs new file mode 100644 index 0000000000..b62df09f06 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/public/to_string.rs @@ -0,0 +1,403 @@ +use serde_json_bytes::Value as JSON; +use shape::Shape; + +use crate::connectors::ConnectSpec; +use crate::connectors::json_selection::ApplyToError; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::ShapeContext; +use crate::connectors::json_selection::VarsWithPathsMap; +use crate::connectors::json_selection::helpers::json_to_string; +use crate::connectors::json_selection::immutable::InputPath; +use crate::connectors::json_selection::location::Ranged; +use crate::connectors::json_selection::location::WithRange; +use crate::impl_arrow_method; + +impl_arrow_method!(ToStringMethod, to_string_method, to_string_shape); +/// Returns a string representation of the applied value. +/// Simple examples: +/// +/// $(42)->toString() results in "42" +/// $("hello")->toString() results in "hello" +/// $(true)->toString() results in "true" +/// $(null)->toString() results in "" +/// $([1,2,3])->toString() results in error +/// $({a: 1})->toString() results in error +fn to_string_method( + method_name: &WithRange, + method_args: Option<&MethodArgs>, + data: &JSON, + _vars: &VarsWithPathsMap, + input_path: &InputPath, + spec: ConnectSpec, +) -> (Option, Vec) { + if let Some(args) = method_args + && !args.args.is_empty() + { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} does not accept any arguments, but {} were provided", + method_name.as_ref(), + args.args.len() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + + let string_value = match json_to_string(data) { + Ok(result) => result.unwrap_or_default(), + Err(error) => { + return ( + None, + vec![ApplyToError::new( + format!( + "Method ->{} {error} Use ->jsonStringify or ->joinNotNull instead", + method_name.as_ref() + ), + input_path.to_vec(), + method_name.range(), + spec, + )], + ); + } + }; + + (Some(JSON::String(string_value.into())), vec![]) +} + +#[allow(dead_code)] // method type-checking disabled until we add name resolution +fn to_string_shape( + context: &ShapeContext, + method_name: &WithRange, + method_args: Option<&MethodArgs>, + input_shape: Shape, + _dollar_shape: Shape, +) -> Shape { + let arg_count = method_args.map(|args| args.args.len()).unwrap_or_default(); + if arg_count > 0 { + return Shape::error( + format!( + "Method ->{} does not accept any arguments, but {arg_count} were provided", + method_name.as_ref(), + ), + method_name.shape_location(context.source_id()), + ); + } + + // Check if input is an object or array shape + if Shape::empty_object([]).accepts(&input_shape) || Shape::tuple([], []).accepts(&input_shape) { + return Shape::error_with_partial( + format!( + "Method ->{} cannot convert arrays or objects to strings. Use ->jsonStringify or ->joinNotNull instead", + method_name.as_ref() + ), + Shape::none(), + method_name.shape_location(context.source_id()), + ); + } + + Shape::string(method_name.shape_location(context.source_id())) +} + +#[cfg(test)] +mod method_tests { + use serde_json_bytes::json; + + use crate::selection; + + #[test] + fn to_string_should_convert_number_to_string() { + assert_eq!( + selection!( + r#" + result: value->toString() + "# + ) + .apply_to(&json!({ "value": 42 })), + ( + Some(json!({ + "result": "42", + })), + vec![], + ), + ); + } + + #[test] + fn to_string_should_convert_boolean_true_to_string() { + assert_eq!( + selection!( + r#" + result: value->toString() + "# + ) + .apply_to(&json!({ "value": true })), + ( + Some(json!({ + "result": "true", + })), + vec![], + ), + ); + } + + #[test] + fn to_string_should_convert_boolean_false_to_string() { + assert_eq!( + selection!( + r#" + result: value->toString() + "# + ) + .apply_to(&json!({ "value": false })), + ( + Some(json!({ + "result": "false", + })), + vec![], + ), + ); + } + + #[test] + fn to_string_should_convert_null_to_string() { + assert_eq!( + selection!( + r#" + result: value->toString() + "# + ) + .apply_to(&json!({ "value": null })), + ( + Some(json!({ + "result": "", + })), + vec![], + ), + ); + } + + #[test] + fn to_string_should_keep_string_as_string() { + assert_eq!( + selection!( + r#" + result: value->toString() + "# + ) + .apply_to(&json!({ "value": "hello" })), + ( + Some(json!({ + "result": "hello", + })), + vec![], + ), + ); + } + + #[test] + fn to_string_should_error_for_arrays() { + let result = selection!( + r#" + result: value->toString() + "# + ) + .apply_to(&json!({ "value": [1, 2, 3] })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->toString cannot convert arrays or objects to strings. Use ->jsonStringify or ->joinNotNull instead") + ); + } + + #[test] + fn to_string_should_error_for_objects() { + let result = selection!( + r#" + result: value->toString() + "# + ) + .apply_to(&json!({ "value": {"a": 1, "b": 2} })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->toString cannot convert arrays or objects to strings. Use ->jsonStringify or ->joinNotNull instead") + ); + } + + #[test] + fn to_string_should_convert_float_to_string() { + assert_eq!( + selection!( + r#" + result: value->toString() + "# + ) + .apply_to(&json!({ "value": 1.23 })), + ( + Some(json!({ + "result": "1.23", + })), + vec![], + ), + ); + } + + #[test] + fn to_string_should_error_when_arguments_provided() { + let result = selection!( + r#" + result: value->toString("arg") + "# + ) + .apply_to(&json!({ "value": 42 })); + + assert_eq!(result.0, Some(json!({})),); + assert!(!result.1.is_empty()); + assert!( + result.1[0] + .message() + .contains("Method ->toString does not accept any arguments, but 1 were provided") + ); + } +} + +#[cfg(test)] +mod shape_tests { + use shape::location::Location; + use shape::location::SourceId; + + use super::*; + use crate::connectors::json_selection::lit_expr::LitExpr; + + fn get_location() -> Location { + Location { + source_id: SourceId::new("test".to_string()), + span: 0..8, + } + } + + fn get_shape(args: Vec>, input: Shape) -> Shape { + let location = get_location(); + to_string_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("toString".to_string(), Some(location.span)), + Some(&MethodArgs { args, range: None }), + input, + Shape::unknown([]), + ) + } + + #[test] + fn to_string_shape_should_return_string_for_int_input() { + assert_eq!( + get_shape(vec![], Shape::int([])), + Shape::string([get_location()]) + ); + } + + #[test] + fn to_string_shape_should_return_string_for_string_input() { + assert_eq!( + get_shape(vec![], Shape::string([])), + Shape::string([get_location()]) + ); + } + + #[test] + fn to_string_shape_should_return_string_for_bool_input() { + assert_eq!( + get_shape(vec![], Shape::bool([])), + Shape::string([get_location()]) + ); + } + + #[test] + fn to_string_shape_should_return_string_for_null_input() { + assert_eq!( + get_shape(vec![], Shape::null([])), + Shape::string([get_location()]) + ); + } + + #[test] + fn to_string_shape_should_return_string_for_unknown_input() { + assert_eq!( + get_shape(vec![], Shape::unknown([])), + Shape::string([get_location()]) + ); + } + + #[test] + fn to_string_shape_should_return_string_for_name_input() { + assert_eq!( + get_shape(vec![], Shape::name("a", [])), + Shape::string([get_location()]) + ); + } + + #[test] + fn to_string_shape_should_error_on_args() { + assert_eq!( + get_shape( + vec![WithRange::new(LitExpr::String("arg".to_string()), None)], + Shape::int([]) + ), + Shape::error( + "Method ->toString does not accept any arguments, but 1 were provided".to_string(), + [get_location()] + ) + ); + } + + #[test] + fn to_string_shape_should_return_string_for_none_args() { + let location = get_location(); + assert_eq!( + to_string_shape( + &ShapeContext::new(location.source_id), + &WithRange::new("toString".to_string(), Some(location.span)), + None, + Shape::int([]), + Shape::none(), + ), + Shape::string([get_location()]) + ); + } + + #[test] + fn to_string_shape_should_error_for_object_input() { + assert_eq!( + get_shape(vec![], Shape::empty_object([])), + Shape::error_with_partial( + "Method ->toString cannot convert arrays or objects to strings. Use ->jsonStringify or ->joinNotNull instead" + .to_string(), + Shape::none(), + [get_location()] + ) + ); + } + + #[test] + fn to_string_shape_should_error_for_array_input() { + assert_eq!( + get_shape(vec![], Shape::tuple([], [])), + Shape::error_with_partial( + "Method ->toString cannot convert arrays or objects to strings. Use ->jsonStringify or ->joinNotNull instead" + .to_string(), + Shape::none(), + [get_location()] + ) + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/methods/tests.rs b/apollo-federation/src/connectors/json_selection/methods/tests.rs new file mode 100644 index 0000000000..735df4f775 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/methods/tests.rs @@ -0,0 +1,23 @@ +use serde_json_bytes::json; + +use super::*; +use crate::selection; + +#[test] +fn test_missing_method() { + assert_eq!( + selection!("nested.path->bogus").apply_to(&json!({ + "nested": { + "path": 123, + }, + })), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Method ->bogus not found", + "path": ["nested", "path", "->bogus"], + "range": [13, 18], + }))], + ), + ); +} diff --git a/apollo-federation/src/connectors/json_selection/mod.rs b/apollo-federation/src/connectors/json_selection/mod.rs new file mode 100644 index 0000000000..2c61ec45d5 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/mod.rs @@ -0,0 +1,85 @@ +mod apply_to; +pub(crate) mod helpers; +mod immutable; +mod known_var; +mod lit_expr; +pub(crate) mod location; +mod methods; +mod parser; +mod pretty; +mod selection_set; +mod selection_trie; + +pub use apply_to::*; +// Pretty code is currently only used in tests, so this cfg is to suppress the +// unused lint warning. If pretty code is needed in not test code, feel free to +// remove the `#[cfg(test)]`. +pub(crate) use location::Ranged; +pub use parser::*; +#[cfg(test)] +pub(crate) use pretty::*; +pub(crate) use selection_trie::SelectionTrie; +#[cfg(test)] +mod fixtures; + +#[cfg(test)] +mod test { + use rstest::rstest; + use serde_json_bytes::Value; + use serde_json_bytes::json; + + use super::*; + use crate::connectors::ConnectSpec; + + #[rstest] + #[case::select_field ("rootField", Some(json!({"rootField": "hello"})), "0.2" )] + #[case::select_field_value ("$.rootField", Some(json!("hello")), "0.2" )] + #[case::basic_subselection ("user { firstName lastName }", Some(json!({"user": { "firstName": "Alice", "lastName": "InChains" }})), "0.2" )] + #[case::array_subselection ("results { name }", Some(json!({"results": [{ "name": "Alice" }, { "name": "John" }]})), "0.2" )] + #[case::array_value_subselection ("$.results { name }", Some(json!([{ "name": "Alice" }, { "name": "John" }])), "0.2" )] + #[case::arrow_method ("results->first { name }", Some(json!({"name": "Alice"})), "0.2" )] + #[case::arbitrary_spaces ("results -> first { name }", Some(json!({"name": "Alice"})), "0.2" )] + #[case::select_field_optional ("rootField?", Some(json!({"rootField": "hello"})), "0.3" )] + #[case::select_null_optional ("nullField?", Some(json!({})), "0.3" )] + #[case::select_missing_optional ("missingField?", Some(json!({})), "0.3" )] + #[case::arrow_method_optional ("results?->first { name }", Some(json!({"name": "Alice"})), "0.3" )] + #[case::arrow_method_null_optional("nullField?->first { name }", None, "0.3")] + #[case::arrow_method_missing_optional("missingField?->first { name }", None, "0.3")] + #[case::optional_subselection ("user: user? { firstName lastName }", Some(json!({"user": { "firstName": "Alice", "lastName": "InChains" }})), "0.3" )] + #[case::optional_subselection_short ("user? { firstName lastName }", Some(json!({"user": { "firstName": "Alice", "lastName": "InChains" }})), "0.3" )] + fn kitchen_sink( + #[case] selection: &str, + #[case] expected: Option, + #[case] minimum_version: &str, + #[values(ConnectSpec::V0_2, ConnectSpec::V0_3)] version: ConnectSpec, + ) { + // We're effectively skipping the test but it will be reported as passed because Rust has no runtime "mark as skipped" capability + if version.as_str() < minimum_version { + return; + } + + let data = json!({ + "rootField": "hello", + "nullField": null, + "user": { + "firstName": "Alice", + "lastName": "InChains" + }, + "results": [ + { + "name": "Alice", + }, + { + "name": "John", + }, + ] + }); + + let (result, errors) = JSONSelection::parse_with_spec(selection, version) + .unwrap() + .apply_to(&data); + println!("errors: {errors:?}"); + assert!(errors.is_empty()); + assert_eq!(result, expected); + } +} diff --git a/apollo-federation/src/connectors/json_selection/parser.rs b/apollo-federation/src/connectors/json_selection/parser.rs new file mode 100644 index 0000000000..be2d2073ec --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/parser.rs @@ -0,0 +1,4225 @@ +use std::fmt::Display; +use std::hash::Hash; +use std::str::FromStr; + +use apollo_compiler::collections::IndexSet; +use itertools::Itertools; +use nom::IResult; +use nom::Slice; +use nom::branch::alt; +use nom::character::complete::char; +use nom::character::complete::one_of; +use nom::combinator::all_consuming; +use nom::combinator::map; +use nom::combinator::opt; +use nom::combinator::recognize; +use nom::error::ParseError; +use nom::multi::many0; +use nom::sequence::pair; +use nom::sequence::preceded; +use nom::sequence::terminated; +use nom::sequence::tuple; +use serde_json_bytes::Value as JSON; + +use super::helpers::spaces_or_comments; +use super::helpers::vec_push; +use super::known_var::KnownVariable; +use super::lit_expr::LitExpr; +use super::location::OffsetRange; +use super::location::Ranged; +use super::location::Span; +use super::location::SpanExtra; +use super::location::WithRange; +use super::location::merge_ranges; +use super::location::new_span_with_spec; +use super::location::ranged_span; +use crate::connectors::ConnectSpec; +use crate::connectors::Namespace; +use crate::connectors::json_selection::location::get_connect_spec; +use crate::connectors::variable::VariableNamespace; +use crate::connectors::variable::VariableReference; + +// ParseResult is the internal type returned by most ::parse methods, as it is +// convenient to use with nom's combinators. The top-level JSONSelection::parse +// method returns a slightly different IResult type that hides implementation +// details of the nom-specific types. +// +// TODO Consider switching the third IResult type parameter to VerboseError +// here, if error messages can be improved with additional context. +pub(super) type ParseResult<'a, T> = IResult, T>; + +// Generates a non-fatal error with the given suffix and message, allowing the +// parser to recover and continue. +pub(super) fn nom_error_message( + suffix: Span, + // This message type forbids computing error messages with format!, which + // might be worthwhile in the future. For now, it's convenient to avoid + // String messages so the Span type can remain Copy, so we don't have to + // clone spans frequently in the parsing code. In most cases, the suffix + // provides the dynamic context needed to interpret the static message. + message: impl Into, +) -> nom::Err> { + nom::Err::Error(nom::error::Error::from_error_kind( + suffix.map_extra(|extra| SpanExtra { + errors: vec_push(extra.errors, message.into()), + ..extra + }), + nom::error::ErrorKind::IsNot, + )) +} + +// Generates a fatal error with the given suffix Span and message, causing the +// parser to abort with the given error message, which is useful after +// recognizing syntax that completely constrains what follows (like the -> token +// before a method name), and what follows does not parse as required. +pub(super) fn nom_fail_message( + suffix: Span, + message: impl Into, +) -> nom::Err> { + nom::Err::Failure(nom::error::Error::from_error_kind( + suffix.map_extra(|extra| SpanExtra { + errors: vec_push(extra.errors, message.into()), + ..extra + }), + nom::error::ErrorKind::IsNot, + )) +} + +pub(crate) trait ExternalVarPaths { + fn external_var_paths(&self) -> Vec<&PathSelection>; +} + +// JSONSelection ::= PathSelection | NakedSubSelection +// NakedSubSelection ::= NamedSelection* StarSelection? + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct JSONSelection { + pub(super) inner: TopLevelSelection, + pub spec: ConnectSpec, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub(super) enum TopLevelSelection { + // Although we reuse the SubSelection type for the JSONSelection::Named + // case, we parse it as a sequence of NamedSelection items without the + // {...} curly braces that SubSelection::parse expects. + Named(SubSelection), + Path(PathSelection), +} + +// To keep JSONSelection::parse consumers from depending on details of the nom +// error types, JSONSelection::parse reports this custom error type. Other +// ::parse methods still internally report nom::error::Error for the most part. +#[derive(thiserror::Error, Debug, PartialEq, Eq, Clone)] +#[error("{message}: {fragment}")] +pub struct JSONSelectionParseError { + // The message will be a meaningful error message in many cases, but may + // fall back to a formatted nom::error::ErrorKind in some cases, e.g. when + // an alt(...) runs out of options and we can't determine which underlying + // error was "most" responsible. + pub message: String, + + // Since we are not exposing the nom_locate-specific Span type, we report + // span.fragment() and span.location_offset() here. + pub fragment: String, + + // While it might be nice to report a range rather than just an offset, not + // all parsing errors have an unambiguous end offset, so the best we can do + // is point to the suffix of the input that failed to parse (which + // corresponds to where the fragment starts). + pub offset: usize, + + // The ConnectSpec version used to parse and apply the selection. + pub spec: ConnectSpec, +} + +impl JSONSelection { + pub fn spec(&self) -> ConnectSpec { + self.spec + } + + pub fn named(sub: SubSelection) -> Self { + Self { + inner: TopLevelSelection::Named(sub), + spec: Self::default_connect_spec(), + } + } + + pub fn path(path: PathSelection) -> Self { + Self { + inner: TopLevelSelection::Path(path), + spec: Self::default_connect_spec(), + } + } + + pub(crate) fn if_named_else_path( + &self, + if_named: impl Fn(&SubSelection) -> T, + if_path: impl Fn(&PathSelection) -> T, + ) -> T { + match &self.inner { + TopLevelSelection::Named(subselect) => if_named(subselect), + TopLevelSelection::Path(path) => if_path(path), + } + } + + pub fn empty() -> Self { + Self { + inner: TopLevelSelection::Named(SubSelection::default()), + spec: Self::default_connect_spec(), + } + } + + pub fn is_empty(&self) -> bool { + match &self.inner { + TopLevelSelection::Named(subselect) => subselect.selections.is_empty(), + TopLevelSelection::Path(path) => *path.path == PathList::Empty, + } + } + + // JSONSelection::parse is possibly the "most public" method in the entire + // file, so it's important that the method signature can remain stable even + // if we drastically change implementation details. That's why we use &str + // as the input type and a custom JSONSelectionParseError type as the error + // type, rather than using Span or nom::error::Error directly. + pub fn parse(input: &str) -> Result { + JSONSelection::parse_with_spec(input, Self::default_connect_spec()) + } + + pub(super) fn default_connect_spec() -> ConnectSpec { + ConnectSpec::V0_2 + } + + pub fn parse_with_spec( + input: &str, + spec: ConnectSpec, + ) -> Result { + let span = new_span_with_spec(input, spec); + + match JSONSelection::parse_span(span) { + Ok((remainder, selection)) => { + let fragment = remainder.fragment(); + if fragment.is_empty() { + Ok(selection) + } else { + Err(JSONSelectionParseError { + message: "Unexpected trailing characters".to_string(), + fragment: fragment.to_string(), + offset: remainder.location_offset(), + spec: remainder.extra.spec, + }) + } + } + + Err(e) => match e { + nom::Err::Error(e) | nom::Err::Failure(e) => Err(JSONSelectionParseError { + message: if e.input.extra.errors.is_empty() { + format!("nom::error::ErrorKind::{:?}", e.code) + } else { + e.input + .extra + .errors + .iter() + .map(|s| s.to_string()) + .join("\n") + }, + fragment: e.input.fragment().to_string(), + offset: e.input.location_offset(), + spec: e.input.extra.spec, + }), + + nom::Err::Incomplete(_) => unreachable!("nom::Err::Incomplete not expected here"), + }, + } + } + + fn parse_span(input: Span) -> ParseResult { + match get_connect_spec(&input) { + ConnectSpec::V0_1 | ConnectSpec::V0_2 => Self::parse_span_v0_2(input), + ConnectSpec::V0_3 => Self::parse_span_v0_3(input), + } + } + + fn parse_span_v0_2(input: Span) -> ParseResult { + let spec = get_connect_spec(&input); + + match alt(( + all_consuming(terminated( + map(PathSelection::parse, |path| Self { + inner: TopLevelSelection::Path(path), + spec, + }), + // By convention, most ::parse methods do not consume trailing + // spaces_or_comments, so we need to consume them here in order + // to satisfy the all_consuming requirement. + spaces_or_comments, + )), + all_consuming(terminated( + map(SubSelection::parse_naked, |sub| Self { + inner: TopLevelSelection::Named(sub), + spec, + }), + // It's tempting to hoist the all_consuming(terminated(...)) + // checks outside the alt((...)) so we only need to handle + // trailing spaces_or_comments once, but that won't work because + // the Self::Path case should fail when a single PathSelection + // cannot be parsed, and that failure typically happens because + // the PathSelection::parse method does not consume the entire + // input, which is caught by the first all_consuming above. + spaces_or_comments, + )), + ))(input) + { + Ok((remainder, selection)) => { + if remainder.fragment().is_empty() { + Ok((remainder, selection)) + } else { + Err(nom_fail_message( + // Usually our nom errors report the original input that + // failed to parse, but that's not helpful here, since + // input corresponds to the entire string, whereas this + // error message is reporting junk at the end of the + // string that should not be there. + remainder, + "Unexpected trailing characters", + )) + } + } + Err(e) => Err(e), + } + } + + fn parse_span_v0_3(input: Span) -> ParseResult { + let spec = get_connect_spec(&input); + + match all_consuming(terminated( + map(SubSelection::parse_naked, |sub| { + if let (1, Some(only)) = (sub.selections.len(), sub.selections.first()) { + // SubSelection::parse_naked already enforces that there + // cannot be more than one NamedSelection if that + // NamedSelection is anonymous, and here's where we divert + // that case into TopLevelSelection::Path rather than + // TopLevelSelection::Named for easier processing later. + // + // The SubSelection may contain multiple inlined selections + // with NamingPrefix::Spread(None) (that is, an anonymous + // path with a trailing SubSelection), which are not + // considered anonymous in that context (because they may + // have zero or more output properties, which they spread + // into the larger result). However, if there is only one + // such ::Spread(None) selection in sub, then "spreading" + // its value into the larger SubSelection is equivalent to + // using its value as the entire output, so we can treat the + // whole thing as a TopLevelSelection::Path selection. + // + // Putting ... first causes NamingPrefix::Spread(Some(_)) to + // be used instead, so the whole selection remains a + // TopLevelSelection::Named, with the additional restriction + // that the argument of the ... must be an object or null + // (not an array). Eventually, we should deprecate spread + // selections without ..., and this complexity will go away. + if only.is_anonymous() || matches!(only.prefix, NamingPrefix::Spread(None)) { + return Self { + inner: TopLevelSelection::Path(only.path.clone()), + spec, + }; + } + } + Self { + inner: TopLevelSelection::Named(sub), + spec, + } + }), + // Most ::parse methods do not consume trailing spaces_or_comments, + // but here (at the top level) we need to make sure anything left at + // the end of the string is inconsequential, in order to satisfy the + // all_consuming combinator above. + spaces_or_comments, + ))(input) + { + Ok((remainder, selection)) => { + if remainder.fragment().is_empty() { + Ok((remainder, selection)) + } else { + Err(nom_fail_message( + // Usually our nom errors report the original input that + // failed to parse, but that's not helpful here, since + // input corresponds to the entire string, whereas this + // error message is reporting junk at the end of the + // string that should not be there. + remainder, + "Unexpected trailing characters", + )) + } + } + Err(e) => Err(e), + } + } + + pub(crate) fn next_subselection(&self) -> Option<&SubSelection> { + match &self.inner { + TopLevelSelection::Named(subselect) => Some(subselect), + TopLevelSelection::Path(path) => path.next_subselection(), + } + } + + #[allow(unused)] + pub(crate) fn next_mut_subselection(&mut self) -> Option<&mut SubSelection> { + match &mut self.inner { + TopLevelSelection::Named(subselect) => Some(subselect), + TopLevelSelection::Path(path) => path.next_mut_subselection(), + } + } + + pub fn variable_references(&self) -> impl Iterator> + '_ { + self.external_var_paths() + .into_iter() + .flat_map(|var_path| var_path.variable_reference()) + } +} + +impl ExternalVarPaths for JSONSelection { + fn external_var_paths(&self) -> Vec<&PathSelection> { + match &self.inner { + TopLevelSelection::Named(subselect) => subselect.external_var_paths(), + TopLevelSelection::Path(path) => path.external_var_paths(), + } + } +} + +// NamedSelection ::= (Alias | "...")? PathSelection | Alias SubSelection +// PathSelection ::= Path SubSelection? + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct NamedSelection { + pub(super) prefix: NamingPrefix, + pub(super) path: PathSelection, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub(super) enum NamingPrefix { + // When a NamedSelection has an Alias, it fully determines the output key, + // and any applied values from the path will be assigned to that key. + Alias(Alias), + // A path can be spread without an explicit ... token, provided it has a + // trailing SubSelection (guaranteeing it outputs a static set of object + // properties). In those cases, the OffsetRange will be None. When there is + // an actual ... token, the OffsetRange will be Some(token_range). + Spread(OffsetRange), + // When there is no Alias or ... spread token, and the path is not inlined + // implicitly due to a trailing SubSelection (which would be represented by + // ::Spread(None)), the NamingPrefix is ::None. The NamedSelection may still + // produce a single output key if self.path.get_single_key() returns + // Some(key), but otherwise it's an anonymous path, which produces only a + // JSON value. Singular anonymous paths are allowed at the top level, where + // any value they produce directly determines the output of the selection, + // but anonymous NamedSelections cannot be mixed together with other + // NamedSelections that produce names (in a SubSelection or anywhere else). + None, +} + +// Like PathSelection, NamedSelection is an AST structure that takes its range +// entirely from its children, so NamedSelection itself does not need to provide +// separate storage for its own range, and therefore does not need to be wrapped +// as WithRange, but merely needs to implement the Ranged trait. +impl Ranged for NamedSelection { + fn range(&self) -> OffsetRange { + let alias_or_spread_range = match &self.prefix { + NamingPrefix::None => None, + NamingPrefix::Alias(alias) => alias.range(), + NamingPrefix::Spread(range) => range.clone(), + }; + merge_ranges(alias_or_spread_range, self.path.range()) + } +} + +impl NamedSelection { + pub(super) fn has_single_output_key(&self) -> bool { + self.get_single_key().is_some() + } + + pub(super) fn get_single_key(&self) -> Option<&WithRange> { + match &self.prefix { + NamingPrefix::None => self.path.get_single_key(), + NamingPrefix::Spread(_) => None, + NamingPrefix::Alias(alias) => Some(&alias.name), + } + } + + pub(super) fn is_anonymous(&self) -> bool { + match &self.prefix { + NamingPrefix::None => self.path.is_anonymous(), + NamingPrefix::Alias(_) => false, + NamingPrefix::Spread(_) => false, + } + } + + pub(super) fn field( + alias: Option, + name: WithRange, + selection: Option, + ) -> Self { + let name_range = name.range(); + let tail = if let Some(selection) = selection.as_ref() { + WithRange::new(PathList::Selection(selection.clone()), selection.range()) + } else { + // The empty range is a collapsed range at the end of the + // preceding path, i.e. at the end of the field name. + let empty_range = name_range.as_ref().map(|range| range.end..range.end); + WithRange::new(PathList::Empty, empty_range) + }; + let tail_range = tail.range(); + let name_tail_range = merge_ranges(name_range, tail_range); + let prefix = if let Some(alias) = alias { + NamingPrefix::Alias(alias) + } else { + NamingPrefix::None + }; + Self { + prefix, + path: PathSelection { + path: WithRange::new(PathList::Key(name, tail), name_tail_range), + }, + } + } + + pub(crate) fn parse(input: Span) -> ParseResult { + match get_connect_spec(&input) { + ConnectSpec::V0_1 | ConnectSpec::V0_2 => Self::parse_v0_2(input), + ConnectSpec::V0_3 => Self::parse_v0_3(input), + } + } + + pub(crate) fn parse_v0_2(input: Span) -> ParseResult { + alt(( + // We must try parsing NamedPathSelection before NamedFieldSelection + // and NamedQuotedSelection because a NamedPathSelection without a + // leading `.`, such as `alias: some.nested.path` has a prefix that + // can be parsed as a NamedFieldSelection: `alias: some`. Parsing + // then fails when it finds the remaining `.nested.path` text. Some + // parsers would solve this by forbidding `.` in the "lookahead" for + // Named{Field,Quoted}Selection, but negative lookahead is tricky in + // nom, so instead we greedily parse NamedPathSelection first. + Self::parse_path, + Self::parse_field, + Self::parse_group, + ))(input) + } + + fn parse_field(input: Span) -> ParseResult { + tuple(( + opt(Alias::parse), + Key::parse, + spaces_or_comments, + opt(SubSelection::parse), + ))(input) + .map(|(remainder, (alias, name, _, selection))| { + (remainder, Self::field(alias, name, selection)) + }) + } + + // Parses either NamedPathSelection or PathWithSubSelection. + fn parse_path(input: Span) -> ParseResult { + if let Ok((remainder, alias)) = Alias::parse(input.clone()) { + match PathSelection::parse(remainder) { + Ok((remainder, path)) => Ok(( + remainder, + Self { + prefix: NamingPrefix::Alias(alias), + path, + }, + )), + Err(nom::Err::Failure(e)) => Err(nom::Err::Failure(e)), + Err(_) => Err(nom_error_message( + input.clone(), + "Path selection alias must be followed by a path", + )), + } + } else { + match PathSelection::parse(input.clone()) { + Ok((remainder, path)) => { + if path.is_anonymous() && path.has_subselection() { + // This covers the old PathWithSubSelection syntax, + // which is like ... in behavior (object properties + // spread into larger object) but without the explicit + // ... token. This syntax still works, provided the path + // is both anonymous and has a trailing SubSelection. + Ok(( + remainder, + Self { + prefix: NamingPrefix::Spread(None), + path, + }, + )) + } else { + Err(nom_fail_message( + input.clone(), + "Named path selection must either begin with alias or ..., or end with subselection", + )) + } + } + Err(nom::Err::Failure(e)) => Err(nom::Err::Failure(e)), + Err(_) => Err(nom_error_message( + input.clone(), + "Path selection must either begin with alias or ..., or end with subselection", + )), + } + } + } + + fn parse_group(input: Span) -> ParseResult { + tuple((Alias::parse, SubSelection::parse))(input).map(|(input, (alias, group))| { + let group_range = group.range(); + ( + input, + NamedSelection { + prefix: NamingPrefix::Alias(alias), + path: PathSelection { + path: WithRange::new(PathList::Selection(group), group_range), + }, + }, + ) + }) + } + + // NamedSelection ::= (Alias | "...")? PathSelection | Alias SubSelection + fn parse_v0_3(input: Span) -> ParseResult { + let (after_alias, alias) = opt(Alias::parse)(input.clone())?; + + if let Some(alias) = alias { + if let Ok((remainder, sub)) = SubSelection::parse(after_alias.clone()) { + let sub_range = sub.range(); + return Ok(( + remainder, + Self { + prefix: NamingPrefix::Alias(alias), + // This is what used to be called a NamedGroupSelection + // in the grammar, where an Alias SubSelection can be + // used to assign a nested name (the Alias) to a + // selection of fields from the current object. + // Logically, this corresponds to an Alias followed by a + // PathSelection with an empty/missing Path. While there + // is no way to write such a PathSelection normally, we + // can construct a PathList consisting of only a + // SubSelection here, for the sake of using the same + // machinery to process all NamedSelection nodes. + path: PathSelection { + path: WithRange::new(PathList::Selection(sub), sub_range), + }, + }, + )); + } + + PathSelection::parse(after_alias.clone()).map(|(remainder, path)| { + ( + remainder, + Self { + prefix: NamingPrefix::Alias(alias), + path, + }, + ) + }) + } else { + tuple(( + spaces_or_comments, + opt(ranged_span("...")), + PathSelection::parse, + ))(input.clone()) + .map(|(remainder, (_spaces, spread, path))| { + let prefix = if let Some(spread) = spread { + // An explicit ... spread token was used, so we record + // NamingPrefix::Spread(Some(_)). If the path produces + // something other than an object or null, we will catch + // that in apply_to_path and compute_output_shape (not a + // parsing concern). + NamingPrefix::Spread(spread.range()) + } else if path.is_anonymous() && path.has_subselection() { + // If there is no Alias or ... and the path is anonymous and + // it has a trailing SubSelection, then it should be spread + // into the larger SubSelection. This is an older syntax + // (PathWithSubSelection) that provided some of the benefits + // of ..., before ... was supported (in connect/v0.3). It's + // important the path is anonymous, since regular field + // selections like `user { id name }` meet all the criteria + // above but should not be spread because they do produce an + // output key. + NamingPrefix::Spread(None) + } else { + // Otherwise, the path has no prefix, so it either produces + // a single Key according to path.get_single_key(), or this + // is an anonymous NamedSelection, which are only allowed at + // the top level. However, since we don't know about other + // NamedSelections here, these rules have to be enforced at + // a higher level. + NamingPrefix::None + }; + (remainder, Self { prefix, path }) + }) + } + } + + pub(crate) fn names(&self) -> Vec<&str> { + if let Some(single_key) = self.get_single_key() { + vec![single_key.as_str()] + } else if let Some(sub) = self.path.next_subselection() { + // Flatten and deduplicate the names of the NamedSelection + // items in the SubSelection. + let mut name_set = IndexSet::default(); + for selection in sub.selections_iter() { + name_set.extend(selection.names()); + } + name_set.into_iter().collect() + } else { + Vec::new() + } + } + + /// Find the next subselection, if present + pub(crate) fn next_subselection(&self) -> Option<&SubSelection> { + self.path.next_subselection() + } + + #[allow(unused)] + pub(crate) fn next_mut_subselection(&mut self) -> Option<&mut SubSelection> { + self.path.next_mut_subselection() + } +} + +impl ExternalVarPaths for NamedSelection { + fn external_var_paths(&self) -> Vec<&PathSelection> { + self.path.external_var_paths() + } +} + +// Path ::= VarPath | KeyPath | AtPath | ExprPath +// PathSelection ::= Path SubSelection? +// VarPath ::= "$" (NO_SPACE Identifier)? PathTail +// KeyPath ::= Key PathTail +// AtPath ::= "@" PathTail +// ExprPath ::= "$(" LitExpr ")" PathTail +// PathTail ::= "?"? (PathStep "?"?)* +// PathStep ::= "." Key | "->" Identifier MethodArgs? + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct PathSelection { + pub(super) path: WithRange, +} + +// Like NamedSelection, PathSelection is an AST structure that takes its range +// entirely from self.path (a WithRange), so PathSelection itself does +// not need to be wrapped as WithRange, but merely needs to +// implement the Ranged trait. +impl Ranged for PathSelection { + fn range(&self) -> OffsetRange { + self.path.range() + } +} + +impl PathSelection { + pub(crate) fn parse(input: Span) -> ParseResult { + PathList::parse(input).map(|(input, path)| (input, Self { path })) + } + + pub(crate) fn variable_reference(&self) -> Option> { + match self.path.as_ref() { + PathList::Var(var, tail) => match var.as_ref() { + KnownVariable::External(namespace) => { + let selection = tail.compute_selection_trie(); + let full_range = merge_ranges(var.range(), tail.range()); + Some(VariableReference { + namespace: VariableNamespace { + namespace: N::from_str(namespace).ok()?, + location: var.range(), + }, + selection, + location: full_range, + }) + } + _ => None, + }, + _ => None, + } + } + + #[allow(unused)] + pub(super) fn is_single_key(&self) -> bool { + self.path.is_single_key() + } + + pub(super) fn get_single_key(&self) -> Option<&WithRange> { + self.path.get_single_key() + } + + pub(super) fn is_anonymous(&self) -> bool { + self.path.is_anonymous() + } + + #[allow(unused)] + pub(super) fn from_slice(keys: &[Key], selection: Option) -> Self { + Self { + path: WithRange::new(PathList::from_slice(keys, selection), None), + } + } + + #[allow(unused)] + pub(super) fn has_subselection(&self) -> bool { + self.path.has_subselection() + } + + pub(super) fn next_subselection(&self) -> Option<&SubSelection> { + self.path.next_subselection() + } + + #[allow(unused)] + pub(super) fn next_mut_subselection(&mut self) -> Option<&mut SubSelection> { + self.path.next_mut_subselection() + } +} + +impl ExternalVarPaths for PathSelection { + fn external_var_paths(&self) -> Vec<&PathSelection> { + let mut paths = Vec::new(); + match self.path.as_ref() { + PathList::Var(var_name, tail) => { + if matches!(var_name.as_ref(), KnownVariable::External(_)) { + paths.push(self); + } + paths.extend(tail.external_var_paths()); + } + other => { + paths.extend(other.external_var_paths()); + } + }; + paths + } +} + +impl From for PathSelection { + fn from(path: PathList) -> Self { + Self { + path: WithRange::new(path, None), + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub(crate) enum PathList { + // A VarPath must start with a variable (either $identifier, $, or @), + // followed by any number of PathStep items (the WithRange). + // Because we represent the @ quasi-variable using PathList::Var, this + // variant handles both VarPath and AtPath from the grammar. The + // PathList::Var variant may only appear at the beginning of a + // PathSelection's PathList, not in the middle. + Var(WithRange, WithRange), + + // A PathSelection that starts with a PathList::Key is a KeyPath, but a + // PathList::Key also counts as PathStep item, so it may also appear in the + // middle/tail of a PathList. + Key(WithRange, WithRange), + + // An ExprPath, which begins with a LitExpr enclosed by $(...). Must appear + // only at the beginning of a PathSelection, like PathList::Var. + Expr(WithRange, WithRange), + + // A PathList::Method is a PathStep item that may appear only in the + // middle/tail (not the beginning) of a PathSelection. + Method(WithRange, Option, WithRange), + + // Represents the ? syntax used for some.path?->method(...) optional + // chaining. If the preceding some.path value is missing (None) or null, + // some.path? evaluates to None, terminating path evaluation without an + // error. All other (non-null) values are passed along without change. + // + // The WithRange parameter represents the rest of the path + // following the `?` token. + Question(WithRange), + + // Optionally, a PathList may end with a SubSelection, which applies a set + // of named selections to the final value of the path. PathList::Selection + // by itself is not a valid PathList. + Selection(SubSelection), + + // Every PathList must be terminated by either PathList::Selection or + // PathList::Empty. PathList::Empty by itself is not a valid PathList. + Empty, +} + +impl PathList { + pub(super) fn parse(input: Span) -> ParseResult> { + match Self::parse_with_depth(input.clone(), 0) { + Ok((_, parsed)) if matches!(*parsed, Self::Empty) => Err(nom_error_message( + input.clone(), + // As a small technical note, you could consider + // NamedGroupSelection (an Alias followed by a SubSelection) as + // a kind of NamedPathSelection where the path is empty, but + // it's still useful to distinguish groups in the grammar so we + // can forbid empty paths in general. In fact, when parsing a + // NamedGroupSelection, this error message is likely to be the + // reason we abandon parsing NamedPathSelection and correctly + // fall back to NamedGroupSelection. + "Path selection cannot be empty", + )), + otherwise => otherwise, + } + } + + #[cfg(test)] + pub(super) fn into_with_range(self) -> WithRange { + WithRange::new(self, None) + } + + pub(super) fn parse_with_depth(input: Span, depth: usize) -> ParseResult> { + let spec = get_connect_spec(&input); + + // If the input is empty (i.e. this method will end up returning + // PathList::Empty), we want the OffsetRange to be an empty range at the + // end of the previously parsed PathList elements, not separated from + // them by trailing spaces or comments, so we need to capture the empty + // range before consuming leading spaces_or_comments. + let offset_if_empty = input.location_offset(); + let range_if_empty: OffsetRange = Some(offset_if_empty..offset_if_empty); + + // Consume leading spaces_or_comments for all cases below. + let (input, _spaces) = spaces_or_comments(input)?; + + // Variable references (including @ references), $(...) literals, and + // key references without a leading . are accepted only at depth 0, or + // at the beginning of the PathSelection. + if depth == 0 { + // The $(...) syntax allows embedding LitExpr values within + // JSONSelection syntax (when not already parsing a LitExpr). This + // case needs to come before the $ (and $var) case, because $( looks + // like the $ variable followed by a parse error in the variable + // case, unless we add some complicated lookahead logic there. + match tuple(( + spaces_or_comments, + ranged_span("$("), + LitExpr::parse, + spaces_or_comments, + ranged_span(")"), + ))(input.clone()) + { + Ok((suffix, (_, dollar_open_paren, expr, close_paren, _))) => { + let (remainder, rest) = Self::parse_with_depth(suffix, depth + 1)?; + let expr_range = merge_ranges(dollar_open_paren.range(), close_paren.range()); + let full_range = merge_ranges(expr_range, rest.range()); + return Ok(( + remainder, + WithRange::new(Self::Expr(expr, rest), full_range), + )); + } + Err(nom::Err::Failure(err)) => { + return Err(nom::Err::Failure(err)); + } + Err(_) => { + // We can otherwise continue for non-fatal errors + } + } + + if let Ok((suffix, (dollar, opt_var))) = + tuple((ranged_span("$"), opt(parse_identifier_no_space)))(input.clone()) + { + let dollar_range = dollar.range(); + let (remainder, rest) = Self::parse_with_depth(suffix, depth + 1)?; + let full_range = merge_ranges(dollar_range.clone(), rest.range()); + return if let Some(var) = opt_var { + let full_name = format!("{}{}", dollar.as_ref(), var.as_str()); + let known_var = KnownVariable::from_str(full_name.as_str()); + let var_range = merge_ranges(dollar_range, var.range()); + let ranged_known_var = WithRange::new(known_var, var_range); + Ok(( + remainder, + WithRange::new(Self::Var(ranged_known_var, rest), full_range), + )) + } else { + let ranged_dollar_var = WithRange::new(KnownVariable::Dollar, dollar_range); + Ok(( + remainder, + WithRange::new(Self::Var(ranged_dollar_var, rest), full_range), + )) + }; + } + + if let Ok((suffix, at)) = ranged_span("@")(input.clone()) { + let (remainder, rest) = Self::parse_with_depth(suffix, depth + 1)?; + let full_range = merge_ranges(at.range(), rest.range()); + return Ok(( + remainder, + WithRange::new( + Self::Var(WithRange::new(KnownVariable::AtSign, at.range()), rest), + full_range, + ), + )); + } + + if let Ok((suffix, key)) = Key::parse(input.clone()) { + let (remainder, rest) = Self::parse_with_depth(suffix, depth + 1)?; + + return match spec { + ConnectSpec::V0_1 | ConnectSpec::V0_2 => match rest.as_ref() { + // We use nom_error_message rather than nom_fail_message + // here because the key might actually be a field selection, + // which means we want to unwind parsing the path and fall + // back to parsing other kinds of NamedSelection. + Self::Empty | Self::Selection(_) => Err(nom_error_message( + input.clone(), + // Another place where format! might be useful to + // suggest .{key}, which would require storing error + // messages as owned Strings. + "Single-key path must be prefixed with $. to avoid ambiguity with field name", + )), + _ => { + let full_range = merge_ranges(key.range(), rest.range()); + Ok((remainder, WithRange::new(Self::Key(key, rest), full_range))) + } + }, + + // With the unification of NamedSelection enum variants into + // a single struct in connect/v0.3, the ambiguity between + // single-key paths and field selections is no longer a + // problem, since they are now represented the same way. + ConnectSpec::V0_3 => { + let full_range = merge_ranges(key.range(), rest.range()); + Ok((remainder, WithRange::new(Self::Key(key, rest), full_range))) + } + }; + } + } + + if depth == 0 { + // If the PathSelection does not start with a $var (or $ or @), a + // key., or $(expr), it is not a valid PathSelection. + if tuple((ranged_span("."), Key::parse))(input.clone()).is_ok() { + // Since we previously allowed starting key paths with .key but + // now forbid that syntax (because it can be ambiguous), suggest + // the unambiguous $.key syntax instead. + return Err(nom_fail_message( + input.clone(), + "Key paths cannot start with just .key (use $.key instead)", + )); + } + // This error technically covers the case above, but doesn't suggest + // a helpful solution. + return Err(nom_error_message( + input.clone(), + "Path selection must start with key, $variable, $, @, or $(expression)", + )); + } + + // At any depth, if the next token is ? but not the PathList::Question + // kind, we terminate path parsing so the hypothetical ?? or ?! tokens + // have a chance to be parsed as infix operators. This is not + // version-gated to connect/v0.3, because we want to begin forbidding + // these tokens as continuations of a Path as early as we can. + if input.fragment().starts_with("??") || input.fragment().starts_with("?!") { + return Ok((input, WithRange::new(Self::Empty, range_if_empty))); + } + + match spec { + ConnectSpec::V0_1 | ConnectSpec::V0_2 => { + // The ? token was not introduced until connect/v0.3. + } + ConnectSpec::V0_3 => { + if let Ok((suffix, question)) = ranged_span("?")(input.clone()) { + let (remainder, rest) = Self::parse_with_depth(suffix.clone(), depth + 1)?; + + return match rest.as_ref() { + // The ? cannot be repeated sequentially, so if rest starts with + // another PathList::Question, we terminate the current path, + // probably (but not necessarily) leading to a parse error for + // the upcoming ?. + PathList::Question(_) => { + let empty_range = question.range().map(|range| range.end..range.end); + let empty = WithRange::new(Self::Empty, empty_range); + Ok(( + suffix, + WithRange::new(Self::Question(empty), question.range()), + )) + } + _ => { + let full_range = merge_ranges(question.range(), rest.range()); + Ok((remainder, WithRange::new(Self::Question(rest), full_range))) + } + }; + } + } + }; + + // In previous versions of this code, a .key could appear at depth 0 (at + // the beginning of a path), which was useful to disambiguate a KeyPath + // consisting of a single key from a field selection. + // + // Now that key paths can appear alongside/after named selections within + // a SubSelection, the .key syntax is potentially unsafe because it may + // be parsed as a continuation of a previous field selection, since we + // ignore spaces/newlines/comments between keys in a path. + // + // In order to prevent this ambiguity, we now require that a single .key + // be written as a subproperty of the $ variable, e.g. $.key, which is + // equivalent to the old behavior, but parses unambiguously. In terms of + // this code, that means we allow a .key only at depths > 0. + if let Ok((remainder, (dot, key))) = tuple((ranged_span("."), Key::parse))(input.clone()) { + let (remainder, rest) = Self::parse_with_depth(remainder, depth + 1)?; + let dot_key_range = merge_ranges(dot.range(), key.range()); + let full_range = merge_ranges(dot_key_range, rest.range()); + return Ok((remainder, WithRange::new(Self::Key(key, rest), full_range))); + } + + // If we failed to parse "." Key above, but the input starts with a '.' + // character, it's an error unless it's the beginning of a ... token. + if input.fragment().starts_with('.') && !input.fragment().starts_with("...") { + return Err(nom_fail_message( + input.clone(), + "Path selection . must be followed by key (identifier or quoted string literal)", + )); + } + + // PathSelection can never start with a naked ->method (instead, use + // $->method or @->method if you want to operate on the current value). + if let Ok((suffix, arrow)) = ranged_span("->")(input.clone()) { + // As soon as we see a -> token, we know what follows must be a + // method name, so we can unconditionally return based on what + // parse_identifier tells us. since MethodArgs::parse is optional, + // the absence of args will never trigger the error case. + return match tuple((parse_identifier, opt(MethodArgs::parse)))(suffix) { + Ok((suffix, (method, args))) => { + let (remainder, rest) = Self::parse_with_depth(suffix, depth + 1)?; + let full_range = merge_ranges(arrow.range(), rest.range()); + Ok(( + remainder, + WithRange::new(Self::Method(method, args, rest), full_range), + )) + } + Err(_) => Err(nom_fail_message( + input.clone(), + "Method name must follow ->", + )), + }; + } + + // Likewise, if the PathSelection has a SubSelection, it must appear at + // the end of a non-empty path. PathList::parse_with_depth is not + // responsible for enforcing a trailing SubSelection in the + // PathWithSubSelection case, since that requirement is checked by + // NamedSelection::parse_path. + if let Ok((suffix, selection)) = SubSelection::parse(input.clone()) { + let selection_range = selection.range(); + return Ok(( + suffix, + WithRange::new(Self::Selection(selection), selection_range), + )); + } + + // The Self::Empty enum case is used to indicate the end of a + // PathSelection that has no SubSelection. + Ok((input.clone(), WithRange::new(Self::Empty, range_if_empty))) + } + + pub(super) fn is_anonymous(&self) -> bool { + self.get_single_key().is_none() + } + + pub(super) fn is_single_key(&self) -> bool { + self.get_single_key().is_some() + } + + pub(super) fn get_single_key(&self) -> Option<&WithRange> { + fn rest_is_empty_or_selection(rest: &WithRange) -> bool { + match rest.as_ref() { + PathList::Selection(_) | PathList::Empty => true, + PathList::Question(tail) => rest_is_empty_or_selection(tail), + // We could have a `_ => false` catch-all case here, but relying + // on the exhaustiveness of this match ensures additions of new + // PathList variants in the future (e.g. PathList::Question) + // will be nudged to consider whether they should be compatible + // with single-key field selections. + PathList::Var(_, _) + | PathList::Key(_, _) + | PathList::Expr(_, _) + | PathList::Method(_, _, _) => false, + } + } + + match self { + Self::Key(key, key_rest) => { + if rest_is_empty_or_selection(key_rest) { + Some(key) + } else { + None + } + } + _ => None, + } + } + + pub(super) fn is_question(&self) -> bool { + matches!(self, Self::Question(_)) + } + + #[allow(unused)] + pub(super) fn from_slice(properties: &[Key], selection: Option) -> Self { + match properties { + [] => selection.map_or(Self::Empty, Self::Selection), + [head, tail @ ..] => Self::Key( + WithRange::new(head.clone(), None), + WithRange::new(Self::from_slice(tail, selection), None), + ), + } + } + + pub(super) fn has_subselection(&self) -> bool { + self.next_subselection().is_some() + } + + /// Find the next subselection, traversing nested chains if needed + pub(super) fn next_subselection(&self) -> Option<&SubSelection> { + match self { + Self::Var(_, tail) => tail.next_subselection(), + Self::Key(_, tail) => tail.next_subselection(), + Self::Expr(_, tail) => tail.next_subselection(), + Self::Method(_, _, tail) => tail.next_subselection(), + Self::Question(tail) => tail.next_subselection(), + Self::Selection(sub) => Some(sub), + Self::Empty => None, + } + } + + #[allow(unused)] + /// Find the next subselection, traversing nested chains if needed. Returns a mutable reference + pub(super) fn next_mut_subselection(&mut self) -> Option<&mut SubSelection> { + match self { + Self::Var(_, tail) => tail.next_mut_subselection(), + Self::Key(_, tail) => tail.next_mut_subselection(), + Self::Expr(_, tail) => tail.next_mut_subselection(), + Self::Method(_, _, tail) => tail.next_mut_subselection(), + Self::Question(tail) => tail.next_mut_subselection(), + Self::Selection(sub) => Some(sub), + Self::Empty => None, + } + } +} + +impl ExternalVarPaths for PathList { + fn external_var_paths(&self) -> Vec<&PathSelection> { + let mut paths = Vec::new(); + match self { + // PathSelection::external_var_paths is responsible for adding all + // variable &PathSelection items to the set, since this + // PathList::Var case cannot be sure it's looking at the beginning + // of the path. However, we call rest.external_var_paths() + // recursively because the tail of the list could contain other full + // PathSelection variable references. + PathList::Var(_, rest) | PathList::Key(_, rest) => { + paths.extend(rest.external_var_paths()); + } + PathList::Expr(expr, rest) => { + paths.extend(expr.external_var_paths()); + paths.extend(rest.external_var_paths()); + } + PathList::Method(_, opt_args, rest) => { + if let Some(args) = opt_args { + for lit_arg in &args.args { + paths.extend(lit_arg.external_var_paths()); + } + } + paths.extend(rest.external_var_paths()); + } + PathList::Question(rest) => { + paths.extend(rest.external_var_paths()); + } + PathList::Selection(sub) => paths.extend(sub.external_var_paths()), + PathList::Empty => {} + } + paths + } +} + +// SubSelection ::= "{" NakedSubSelection "}" + +#[derive(Debug, PartialEq, Eq, Clone, Default)] +pub struct SubSelection { + pub(super) selections: Vec, + pub(super) range: OffsetRange, +} + +impl Ranged for SubSelection { + // Since SubSelection is a struct, we can store its range directly as a + // field of the struct, allowing SubSelection to implement the Ranged trait + // without a WithRange wrapper. + fn range(&self) -> OffsetRange { + self.range.clone() + } +} + +impl SubSelection { + pub(crate) fn parse(input: Span) -> ParseResult { + match tuple(( + spaces_or_comments, + ranged_span("{"), + Self::parse_naked, + spaces_or_comments, + ranged_span("}"), + ))(input) + { + Ok((remainder, (_, open_brace, sub, _, close_brace))) => { + let range = merge_ranges(open_brace.range(), close_brace.range()); + Ok(( + remainder, + Self { + selections: sub.selections, + range, + }, + )) + } + Err(e) => Err(e), + } + } + + fn parse_naked(input: Span) -> ParseResult { + match many0(NamedSelection::parse)(input.clone()) { + Ok((remainder, selections)) => { + // Enforce that if selections has any anonymous NamedSelection + // elements, there is only one and it's the only NamedSelection in + // the SubSelection. + for sel in selections.iter() { + if sel.is_anonymous() && selections.len() > 1 { + return Err(nom_error_message( + input.clone(), + "SubSelection cannot contain multiple elements if it contains an anonymous NamedSelection", + )); + } + } + + let range = merge_ranges( + selections.first().and_then(|first| first.range()), + selections.last().and_then(|last| last.range()), + ); + + Ok((remainder, Self { selections, range })) + } + Err(e) => Err(e), + } + } + + // Returns an Iterator over each &NamedSelection that contributes a single + // name to the output object. This is more complicated than returning + // self.selections.iter() because some NamedSelection::Path elements can + // contribute multiple names if they do no have an Alias. + pub fn selections_iter(&self) -> impl Iterator { + // TODO Implement a NamedSelectionIterator to traverse nested selections + // lazily, rather than using an intermediary vector. + let mut selections = Vec::new(); + for selection in &self.selections { + if selection.has_single_output_key() { + // If the PathSelection has an Alias, then it has a singular + // name and should be visited directly. + selections.push(selection); + } else if let Some(sub) = selection.path.next_subselection() { + // If the PathSelection does not have an Alias but does have a + // SubSelection, then it represents the PathWithSubSelection + // non-terminal from the grammar (see README.md + PR #6076), + // which produces multiple names derived from the SubSelection, + // which need to be recursively collected. + selections.extend(sub.selections_iter()); + } else { + // This no-Alias, no-SubSelection case should be forbidden by + // NamedSelection::parse_path. + debug_assert!(false, "PathSelection without Alias or SubSelection"); + } + } + selections.into_iter() + } + + pub fn append_selection(&mut self, selection: NamedSelection) { + self.selections.push(selection); + } + + pub fn last_selection_mut(&mut self) -> Option<&mut NamedSelection> { + self.selections.last_mut() + } +} + +impl ExternalVarPaths for SubSelection { + fn external_var_paths(&self) -> Vec<&PathSelection> { + let mut paths = Vec::new(); + for selection in &self.selections { + paths.extend(selection.external_var_paths()); + } + paths + } +} + +// Alias ::= Key ":" + +#[derive(Debug, PartialEq, Eq, Clone)] +pub(crate) struct Alias { + pub(super) name: WithRange, + pub(super) range: OffsetRange, +} + +impl Ranged for Alias { + fn range(&self) -> OffsetRange { + self.range.clone() + } +} + +impl Alias { + pub(crate) fn new(name: &str) -> Self { + if is_identifier(name) { + Self::field(name) + } else { + Self::quoted(name) + } + } + + pub(crate) fn field(name: &str) -> Self { + Self { + name: WithRange::new(Key::field(name), None), + range: None, + } + } + + pub(crate) fn quoted(name: &str) -> Self { + Self { + name: WithRange::new(Key::quoted(name), None), + range: None, + } + } + + pub(crate) fn parse(input: Span) -> ParseResult { + tuple((Key::parse, spaces_or_comments, ranged_span(":")))(input).map( + |(input, (name, _, colon))| { + let range = merge_ranges(name.range(), colon.range()); + (input, Self { name, range }) + }, + ) + } +} + +// Key ::= Identifier | LitString + +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub enum Key { + Field(String), + Quoted(String), +} + +impl Key { + pub(crate) fn parse(input: Span) -> ParseResult> { + alt(( + map(parse_identifier, |id| id.take_as(Key::Field)), + map(parse_string_literal, |s| s.take_as(Key::Quoted)), + ))(input) + } + + pub fn field(name: &str) -> Self { + Self::Field(name.to_string()) + } + + pub fn quoted(name: &str) -> Self { + Self::Quoted(name.to_string()) + } + + pub fn into_with_range(self) -> WithRange { + WithRange::new(self, None) + } + + pub fn is_quoted(&self) -> bool { + matches!(self, Self::Quoted(_)) + } + + pub fn to_json(&self) -> JSON { + match self { + Key::Field(name) => JSON::String(name.clone().into()), + Key::Quoted(name) => JSON::String(name.clone().into()), + } + } + + // This method returns the field/property name as a String, and is + // appropriate for accessing JSON properties, in contrast to the dotted + // method below. + pub fn as_string(&self) -> String { + match self { + Key::Field(name) => name.clone(), + Key::Quoted(name) => name.clone(), + } + } + // Like as_string, but without cloning a new String, for times when the Key + // itself lives longer than the &str. + pub fn as_str(&self) -> &str { + match self { + Key::Field(name) => name.as_str(), + Key::Quoted(name) => name.as_str(), + } + } + + // This method is used to implement the Display trait for Key, and includes + // a leading '.' character for string keys, as well as proper quoting for + // Key::Quoted values. However, these additions make key.dotted() unsafe to + // use for accessing JSON properties. + pub fn dotted(&self) -> String { + match self { + Key::Field(field) => format!(".{field}"), + Key::Quoted(field) => { + // JSON encoding is a reliable way to ensure a string that may + // contain special characters (such as '"' characters) is + // properly escaped and double-quoted. + let quoted = serde_json_bytes::Value::String(field.clone().into()).to_string(); + format!(".{quoted}") + } + } + } +} + +impl Display for Key { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let dotted = self.dotted(); + write!(f, "{dotted}") + } +} + +// Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]* + +pub(super) fn is_identifier(input: &str) -> bool { + // TODO Don't use the whole parser for this? + all_consuming(parse_identifier_no_space)(new_span_with_spec( + input, + JSONSelection::default_connect_spec(), + )) + .is_ok() +} + +fn parse_identifier(input: Span) -> ParseResult> { + preceded(spaces_or_comments, parse_identifier_no_space)(input) +} + +fn parse_identifier_no_space(input: Span) -> ParseResult> { + recognize(pair( + one_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"), + many0(one_of( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789", + )), + ))(input) + .map(|(remainder, name)| { + let range = Some(name.location_offset()..remainder.location_offset()); + (remainder, WithRange::new(name.to_string(), range)) + }) +} + +// LitString ::= +// | "'" ("\\'" | [^'])* "'" +// | '"' ('\\"' | [^"])* '"' + +pub(crate) fn parse_string_literal(input: Span) -> ParseResult> { + let input = spaces_or_comments(input)?.0; + let start = input.location_offset(); + let mut input_char_indices = input.char_indices(); + + match input_char_indices.next() { + Some((0, quote @ '\'')) | Some((0, quote @ '"')) => { + let mut escape_next = false; + let mut chars: Vec = Vec::new(); + let mut remainder_opt: Option = None; + + for (i, c) in input_char_indices { + if escape_next { + match c { + 'n' => chars.push('\n'), + _ => chars.push(c), + } + escape_next = false; + continue; + } + if c == '\\' { + escape_next = true; + continue; + } + if c == quote { + remainder_opt = Some(input.slice(i + 1..)); + break; + } + chars.push(c); + } + + remainder_opt + .ok_or_else(|| nom_fail_message(input, "Unterminated string literal")) + .map(|remainder| { + let range = Some(start..remainder.location_offset()); + ( + remainder, + WithRange::new(chars.iter().collect::(), range), + ) + }) + } + + _ => Err(nom_error_message(input, "Not a string literal")), + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Default)] +pub(crate) struct MethodArgs { + pub(super) args: Vec>, + pub(super) range: OffsetRange, +} + +impl Ranged for MethodArgs { + fn range(&self) -> OffsetRange { + self.range.clone() + } +} + +// Comma-separated positional arguments for a method, surrounded by parentheses. +// When an arrow method is used without arguments, the Option for +// the PathSelection::Method will be None, so we can safely define MethodArgs +// using a Vec in all cases (possibly empty but never missing). +impl MethodArgs { + fn parse(input: Span) -> ParseResult { + let input = spaces_or_comments(input)?.0; + let (mut input, open_paren) = ranged_span("(")(input)?; + input = spaces_or_comments(input)?.0; + + let mut args = Vec::new(); + if let Ok((remainder, first)) = LitExpr::parse(input.clone()) { + args.push(first); + input = remainder; + + while let Ok((remainder, _)) = tuple((spaces_or_comments, char(',')))(input.clone()) { + input = spaces_or_comments(remainder)?.0; + if let Ok((remainder, arg)) = LitExpr::parse(input.clone()) { + args.push(arg); + input = remainder; + } else { + break; + } + } + } + + input = spaces_or_comments(input.clone())?.0; + let (input, close_paren) = ranged_span(")")(input.clone())?; + + let range = merge_ranges(open_paren.range(), close_paren.range()); + Ok((input, Self { args, range })) + } +} + +#[cfg(test)] +mod tests { + use apollo_compiler::collections::IndexMap; + use rstest::rstest; + + use super::super::location::strip_ranges::StripRanges; + use super::*; + use crate::assert_debug_snapshot; + use crate::connectors::json_selection::PrettyPrintable; + use crate::connectors::json_selection::SelectionTrie; + use crate::connectors::json_selection::fixtures::Namespace; + use crate::connectors::json_selection::helpers::span_is_all_spaces_or_comments; + use crate::connectors::json_selection::location::new_span; + use crate::selection; + + #[test] + fn test_default_connect_spec() { + // We don't necessarily want to update what + // JSONSelection::default_connect_spec() returns just because + // ConnectSpec::latest() changes, but we want to know when it happens, + // so we can consider updating. + assert_eq!(JSONSelection::default_connect_spec(), ConnectSpec::latest()); + } + + #[test] + fn test_identifier() { + fn check(input: &str, expected_name: &str) { + let (remainder, name) = parse_identifier(new_span(input)).unwrap(); + assert!( + span_is_all_spaces_or_comments(remainder.clone()), + "remainder is `{:?}`", + remainder.clone(), + ); + assert_eq!(name.as_ref(), expected_name); + } + + check("hello", "hello"); + check("hello_world", "hello_world"); + check(" hello_world ", "hello_world"); + check("hello_world_123", "hello_world_123"); + check(" hello ", "hello"); + + fn check_no_space(input: &str, expected_name: &str) { + let name = parse_identifier_no_space(new_span(input)).unwrap().1; + assert_eq!(name.as_ref(), expected_name); + } + + check_no_space("oyez", "oyez"); + check_no_space("oyez ", "oyez"); + + { + let identifier_with_leading_space = new_span(" oyez "); + assert_eq!( + parse_identifier_no_space(identifier_with_leading_space.clone()), + Err(nom::Err::Error(nom::error::Error::from_error_kind( + // The parse_identifier_no_space function does not provide a + // custom error message, since it's only used internally. + // Testing it directly here is somewhat contrived. + identifier_with_leading_space.clone(), + nom::error::ErrorKind::OneOf, + ))), + ); + } + } + + #[test] + fn test_string_literal() { + fn check(input: &str, expected: &str) { + let (remainder, lit) = parse_string_literal(new_span(input)).unwrap(); + assert!( + span_is_all_spaces_or_comments(remainder.clone()), + "remainder is `{:?}`", + remainder.clone(), + ); + assert_eq!(lit.as_ref(), expected); + } + check("'hello world'", "hello world"); + check("\"hello world\"", "hello world"); + check("'hello \"world\"'", "hello \"world\""); + check("\"hello \\\"world\\\"\"", "hello \"world\""); + check("'hello \\'world\\''", "hello 'world'"); + } + + #[test] + fn test_key() { + fn check(input: &str, expected: &Key) { + let (remainder, key) = Key::parse(new_span(input)).unwrap(); + assert!( + span_is_all_spaces_or_comments(remainder.clone()), + "remainder is `{:?}`", + remainder.clone(), + ); + assert_eq!(key.as_ref(), expected); + } + + check("hello", &Key::field("hello")); + check("'hello'", &Key::quoted("hello")); + check(" hello ", &Key::field("hello")); + check("\"hello\"", &Key::quoted("hello")); + check(" \"hello\" ", &Key::quoted("hello")); + } + + #[test] + fn test_alias() { + fn check(input: &str, alias: &str) { + let (remainder, parsed) = Alias::parse(new_span(input)).unwrap(); + assert!( + span_is_all_spaces_or_comments(remainder.clone()), + "remainder is `{:?}`", + remainder.clone(), + ); + assert_eq!(parsed.name.as_str(), alias); + } + + check("hello:", "hello"); + check("hello :", "hello"); + check("hello : ", "hello"); + check(" hello :", "hello"); + check("hello: ", "hello"); + } + + #[test] + fn test_named_selection() { + #[track_caller] + fn assert_result_and_names(input: &str, expected: NamedSelection, names: &[&str]) { + let (remainder, selection) = NamedSelection::parse(new_span(input)).unwrap(); + assert!( + span_is_all_spaces_or_comments(remainder.clone()), + "remainder is `{:?}`", + remainder.clone(), + ); + let selection = selection.strip_ranges(); + assert_eq!(selection, expected); + assert_eq!(selection.names(), names); + assert_eq!( + selection!(input).strip_ranges(), + JSONSelection::named(SubSelection { + selections: vec![expected], + ..Default::default() + },), + ); + } + + assert_result_and_names( + "hello", + NamedSelection::field(None, Key::field("hello").into_with_range(), None), + &["hello"], + ); + + assert_result_and_names( + "hello { world }", + NamedSelection::field( + None, + Key::field("hello").into_with_range(), + Some(SubSelection { + selections: vec![NamedSelection::field( + None, + Key::field("world").into_with_range(), + None, + )], + ..Default::default() + }), + ), + &["hello"], + ); + + assert_result_and_names( + "hi: hello", + NamedSelection::field( + Some(Alias::new("hi")), + Key::field("hello").into_with_range(), + None, + ), + &["hi"], + ); + + assert_result_and_names( + "hi: 'hello world'", + NamedSelection::field( + Some(Alias::new("hi")), + Key::quoted("hello world").into_with_range(), + None, + ), + &["hi"], + ); + + assert_result_and_names( + "hi: hello { world }", + NamedSelection::field( + Some(Alias::new("hi")), + Key::field("hello").into_with_range(), + Some(SubSelection { + selections: vec![NamedSelection::field( + None, + Key::field("world").into_with_range(), + None, + )], + ..Default::default() + }), + ), + &["hi"], + ); + + assert_result_and_names( + "hey: hello { world again }", + NamedSelection::field( + Some(Alias::new("hey")), + Key::field("hello").into_with_range(), + Some(SubSelection { + selections: vec![ + NamedSelection::field(None, Key::field("world").into_with_range(), None), + NamedSelection::field(None, Key::field("again").into_with_range(), None), + ], + ..Default::default() + }), + ), + &["hey"], + ); + + assert_result_and_names( + "hey: 'hello world' { again }", + NamedSelection::field( + Some(Alias::new("hey")), + Key::quoted("hello world").into_with_range(), + Some(SubSelection { + selections: vec![NamedSelection::field( + None, + Key::field("again").into_with_range(), + None, + )], + ..Default::default() + }), + ), + &["hey"], + ); + + assert_result_and_names( + "leggo: 'my ego'", + NamedSelection::field( + Some(Alias::new("leggo")), + Key::quoted("my ego").into_with_range(), + None, + ), + &["leggo"], + ); + + assert_result_and_names( + "'let go': 'my ego'", + NamedSelection::field( + Some(Alias::quoted("let go")), + Key::quoted("my ego").into_with_range(), + None, + ), + &["let go"], + ); + } + + #[test] + fn test_selection() { + assert_eq!( + selection!("").strip_ranges(), + JSONSelection::named(SubSelection { + selections: vec![], + ..Default::default() + }), + ); + + assert_eq!( + selection!(" ").strip_ranges(), + JSONSelection::named(SubSelection { + selections: vec![], + ..Default::default() + }), + ); + + assert_eq!( + selection!("hello").strip_ranges(), + JSONSelection::named(SubSelection { + selections: vec![NamedSelection::field( + None, + Key::field("hello").into_with_range(), + None + )], + ..Default::default() + }), + ); + + assert_eq!( + selection!("$.hello").strip_ranges(), + JSONSelection::path(PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("hello").into_with_range(), + PathList::Empty.into_with_range() + ) + .into_with_range(), + ) + .into_with_range(), + }), + ); + + { + let expected = JSONSelection::named(SubSelection { + selections: vec![NamedSelection { + prefix: NamingPrefix::Alias(Alias::new("hi")), + path: PathSelection::from_slice( + &[ + Key::Field("hello".to_string()), + Key::Field("world".to_string()), + ], + None, + ), + }], + ..Default::default() + }); + + assert_eq!(selection!("hi: hello.world").strip_ranges(), expected); + assert_eq!(selection!("hi: hello .world").strip_ranges(), expected); + assert_eq!(selection!("hi: hello. world").strip_ranges(), expected); + assert_eq!(selection!("hi: hello . world").strip_ranges(), expected); + assert_eq!(selection!("hi: hello.world").strip_ranges(), expected); + assert_eq!(selection!("hi: hello. world").strip_ranges(), expected); + assert_eq!(selection!("hi: hello .world").strip_ranges(), expected); + assert_eq!(selection!("hi: hello . world ").strip_ranges(), expected); + } + + { + let expected = JSONSelection::named(SubSelection { + selections: vec![ + NamedSelection::field(None, Key::field("before").into_with_range(), None), + NamedSelection { + prefix: NamingPrefix::Alias(Alias::new("hi")), + path: PathSelection::from_slice( + &[ + Key::Field("hello".to_string()), + Key::Field("world".to_string()), + ], + None, + ), + }, + NamedSelection::field(None, Key::field("after").into_with_range(), None), + ], + ..Default::default() + }); + + assert_eq!( + selection!("before hi: hello.world after").strip_ranges(), + expected + ); + assert_eq!( + selection!("before hi: hello .world after").strip_ranges(), + expected + ); + assert_eq!( + selection!("before hi: hello. world after").strip_ranges(), + expected + ); + assert_eq!( + selection!("before hi: hello . world after").strip_ranges(), + expected + ); + assert_eq!( + selection!("before hi: hello.world after").strip_ranges(), + expected + ); + assert_eq!( + selection!("before hi: hello .world after").strip_ranges(), + expected + ); + assert_eq!( + selection!("before hi: hello. world after").strip_ranges(), + expected + ); + assert_eq!( + selection!("before hi: hello . world after").strip_ranges(), + expected + ); + } + + { + let expected = JSONSelection::named(SubSelection { + selections: vec![ + NamedSelection::field(None, Key::field("before").into_with_range(), None), + NamedSelection { + prefix: NamingPrefix::Alias(Alias::new("hi")), + path: PathSelection::from_slice( + &[ + Key::Field("hello".to_string()), + Key::Field("world".to_string()), + ], + Some(SubSelection { + selections: vec![ + NamedSelection::field( + None, + Key::field("nested").into_with_range(), + None, + ), + NamedSelection::field( + None, + Key::field("names").into_with_range(), + None, + ), + ], + ..Default::default() + }), + ), + }, + NamedSelection::field(None, Key::field("after").into_with_range(), None), + ], + ..Default::default() + }); + + assert_eq!( + selection!("before hi: hello.world { nested names } after").strip_ranges(), + expected + ); + assert_eq!( + selection!("before hi:hello.world{nested names}after").strip_ranges(), + expected + ); + assert_eq!( + selection!(" before hi : hello . world { nested names } after ").strip_ranges(), + expected + ); + } + + assert_debug_snapshot!(selection!( + " + # Comments are supported because we parse them as whitespace + topLevelAlias: topLevelField { + identifier: 'property name with spaces' + 'unaliased non-identifier property' + 'non-identifier alias': identifier + + # This extracts the value located at the given path and applies a + # selection set to it before renaming the result to pathSelection + pathSelection: some.nested.path { + still: yet + more + properties + } + + # An aliased SubSelection of fields nests the fields together + # under the given alias + siblingGroup: { brother sister } + }" + )); + } + + #[track_caller] + fn check_path_selection(spec: ConnectSpec, input: &str, expected: PathSelection) { + let (remainder, path_selection) = + PathSelection::parse(new_span_with_spec(input, spec)).unwrap(); + assert!( + span_is_all_spaces_or_comments(remainder.clone()), + "remainder is `{:?}`", + remainder.clone(), + ); + let path_without_ranges = path_selection.strip_ranges(); + assert_eq!(&path_without_ranges, &expected); + assert_eq!( + selection!(input, spec).strip_ranges(), + JSONSelection { + inner: TopLevelSelection::Path(path_without_ranges), + spec, + }, + ); + } + + #[rstest] + #[case::v0_2(ConnectSpec::V0_2)] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_path_selection(#[case] spec: ConnectSpec) { + check_path_selection( + spec, + "$.hello", + PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("hello").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + + { + let expected = PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("hello").into_with_range(), + PathList::Key( + Key::field("world").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }; + check_path_selection(spec, "$.hello.world", expected.clone()); + check_path_selection(spec, "$.hello .world", expected.clone()); + check_path_selection(spec, "$.hello. world", expected.clone()); + check_path_selection(spec, "$.hello . world", expected.clone()); + check_path_selection(spec, "$ . hello . world", expected.clone()); + check_path_selection(spec, " $ . hello . world ", expected); + } + + { + let expected = PathSelection::from_slice( + &[ + Key::Field("hello".to_string()), + Key::Field("world".to_string()), + ], + None, + ); + check_path_selection(spec, "hello.world", expected.clone()); + check_path_selection(spec, "hello .world", expected.clone()); + check_path_selection(spec, "hello. world", expected.clone()); + check_path_selection(spec, "hello . world", expected.clone()); + check_path_selection(spec, " hello . world ", expected); + } + + { + let expected = PathSelection::from_slice( + &[ + Key::Field("hello".to_string()), + Key::Field("world".to_string()), + ], + Some(SubSelection { + selections: vec![NamedSelection::field( + None, + Key::field("hello").into_with_range(), + None, + )], + ..Default::default() + }), + ); + check_path_selection(spec, "hello.world{hello}", expected.clone()); + check_path_selection(spec, "hello.world { hello }", expected.clone()); + check_path_selection(spec, "hello .world { hello }", expected.clone()); + check_path_selection(spec, "hello. world { hello }", expected.clone()); + check_path_selection(spec, "hello . world { hello }", expected.clone()); + check_path_selection(spec, " hello . world { hello } ", expected); + } + + { + let expected = PathSelection::from_slice( + &[ + Key::Field("nested".to_string()), + Key::Quoted("string literal".to_string()), + Key::Quoted("property".to_string()), + Key::Field("name".to_string()), + ], + None, + ); + check_path_selection( + spec, + "nested.'string literal'.\"property\".name", + expected.clone(), + ); + check_path_selection( + spec, + "nested. 'string literal'.\"property\".name", + expected.clone(), + ); + check_path_selection( + spec, + "nested.'string literal'. \"property\".name", + expected.clone(), + ); + check_path_selection( + spec, + "nested.'string literal'.\"property\" .name", + expected.clone(), + ); + check_path_selection( + spec, + "nested.'string literal'.\"property\". name", + expected.clone(), + ); + check_path_selection( + spec, + " nested . 'string literal' . \"property\" . name ", + expected, + ); + } + + { + let expected = PathSelection::from_slice( + &[ + Key::Field("nested".to_string()), + Key::Quoted("string literal".to_string()), + ], + Some(SubSelection { + selections: vec![NamedSelection::field( + Some(Alias::new("leggo")), + Key::quoted("my ego").into_with_range(), + None, + )], + ..Default::default() + }), + ); + + check_path_selection( + spec, + "nested.'string literal' { leggo: 'my ego' }", + expected.clone(), + ); + + check_path_selection( + spec, + " nested . 'string literal' { leggo : 'my ego' } ", + expected.clone(), + ); + + check_path_selection( + spec, + "nested. 'string literal' { leggo: 'my ego' }", + expected.clone(), + ); + + check_path_selection( + spec, + "nested . 'string literal' { leggo: 'my ego' }", + expected.clone(), + ); + check_path_selection( + spec, + " nested . \"string literal\" { leggo: 'my ego' } ", + expected, + ); + } + + { + let expected = PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("results").into_with_range(), + PathList::Selection(SubSelection { + selections: vec![NamedSelection::field( + None, + Key::quoted("quoted without alias").into_with_range(), + Some(SubSelection { + selections: vec![ + NamedSelection::field( + None, + Key::field("id").into_with_range(), + None, + ), + NamedSelection::field( + None, + Key::quoted("n a m e").into_with_range(), + None, + ), + ], + ..Default::default() + }), + )], + ..Default::default() + }) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }; + check_path_selection( + spec, + "$.results{'quoted without alias'{id'n a m e'}}", + expected.clone(), + ); + check_path_selection( + spec, + " $ . results { 'quoted without alias' { id 'n a m e' } } ", + expected, + ); + } + + { + let expected = PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("results").into_with_range(), + PathList::Selection(SubSelection { + selections: vec![NamedSelection::field( + Some(Alias::quoted("non-identifier alias")), + Key::quoted("quoted with alias").into_with_range(), + Some(SubSelection { + selections: vec![ + NamedSelection::field( + None, + Key::field("id").into_with_range(), + None, + ), + NamedSelection::field( + Some(Alias::quoted("n a m e")), + Key::field("name").into_with_range(), + None, + ), + ], + ..Default::default() + }), + )], + ..Default::default() + }) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }; + check_path_selection( + spec, + "$.results{'non-identifier alias':'quoted with alias'{id'n a m e':name}}", + expected.clone(), + ); + check_path_selection( + spec, + " $ . results { 'non-identifier alias' : 'quoted with alias' { id 'n a m e': name } } ", + expected, + ); + } + } + + #[rstest] + #[case::v0_2(ConnectSpec::V0_2)] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_path_selection_vars(#[case] spec: ConnectSpec) { + check_path_selection( + spec, + "$this", + PathSelection { + path: PathList::Var( + KnownVariable::External(Namespace::This.to_string()).into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }, + ); + + check_path_selection( + spec, + "$", + PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }, + ); + + check_path_selection( + spec, + "$this { hello }", + PathSelection { + path: PathList::Var( + KnownVariable::External(Namespace::This.to_string()).into_with_range(), + PathList::Selection(SubSelection { + selections: vec![NamedSelection::field( + None, + Key::field("hello").into_with_range(), + None, + )], + ..Default::default() + }) + .into_with_range(), + ) + .into_with_range(), + }, + ); + + check_path_selection( + spec, + "$ { hello }", + PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Selection(SubSelection { + selections: vec![NamedSelection::field( + None, + Key::field("hello").into_with_range(), + None, + )], + ..Default::default() + }) + .into_with_range(), + ) + .into_with_range(), + }, + ); + + check_path_selection( + spec, + "$this { before alias: $args.arg after }", + PathList::Var( + KnownVariable::External(Namespace::This.to_string()).into_with_range(), + PathList::Selection(SubSelection { + selections: vec![ + NamedSelection::field(None, Key::field("before").into_with_range(), None), + NamedSelection { + prefix: NamingPrefix::Alias(Alias::new("alias")), + path: PathSelection { + path: PathList::Var( + KnownVariable::External(Namespace::Args.to_string()) + .into_with_range(), + PathList::Key( + Key::field("arg").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + }, + NamedSelection::field(None, Key::field("after").into_with_range(), None), + ], + ..Default::default() + }) + .into_with_range(), + ) + .into(), + ); + + check_path_selection( + spec, + "$.nested { key injected: $args.arg }", + PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("nested").into_with_range(), + PathList::Selection(SubSelection { + selections: vec![ + NamedSelection::field( + None, + Key::field("key").into_with_range(), + None, + ), + NamedSelection { + prefix: NamingPrefix::Alias(Alias::new("injected")), + path: PathSelection { + path: PathList::Var( + KnownVariable::External(Namespace::Args.to_string()) + .into_with_range(), + PathList::Key( + Key::field("arg").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + }, + ], + ..Default::default() + }) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + + check_path_selection( + spec, + "$args.a.b.c", + PathSelection { + path: PathList::Var( + KnownVariable::External(Namespace::Args.to_string()).into_with_range(), + PathList::from_slice( + &[ + Key::Field("a".to_string()), + Key::Field("b".to_string()), + Key::Field("c".to_string()), + ], + None, + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + + check_path_selection( + spec, + "root.x.y.z", + PathSelection::from_slice( + &[ + Key::Field("root".to_string()), + Key::Field("x".to_string()), + Key::Field("y".to_string()), + Key::Field("z".to_string()), + ], + None, + ), + ); + + check_path_selection( + spec, + "$.data", + PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("data").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + + check_path_selection( + spec, + "$.data.'quoted property'.nested", + PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("data").into_with_range(), + PathList::Key( + Key::quoted("quoted property").into_with_range(), + PathList::Key( + Key::field("nested").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + + #[track_caller] + fn check_path_parse_error( + input: &str, + expected_offset: usize, + expected_message: impl Into, + ) { + let expected_message: String = expected_message.into(); + match PathSelection::parse(new_span_with_spec(input, ConnectSpec::latest())) { + Ok((remainder, path)) => { + panic!( + "Expected error at offset {expected_offset} with message '{expected_message}', but got path {path:?} and remainder {remainder:?}", + ); + } + Err(nom::Err::Error(e) | nom::Err::Failure(e)) => { + assert_eq!(&input[expected_offset..], *e.input.fragment()); + // The PartialEq implementation for LocatedSpan + // unfortunately ignores span.extra, so we have to check + // e.input.extra manually. + assert_eq!( + e.input.extra, + SpanExtra { + spec: ConnectSpec::latest(), + errors: vec![expected_message], + } + ); + } + Err(e) => { + panic!("Unexpected error {e:?}"); + } + } + } + + let single_key_path_error_message = + "Single-key path must be prefixed with $. to avoid ambiguity with field name"; + check_path_parse_error( + new_span("naked").fragment(), + 0, + single_key_path_error_message, + ); + check_path_parse_error( + new_span("naked { hi }").fragment(), + 0, + single_key_path_error_message, + ); + check_path_parse_error( + new_span(" naked { hi }").fragment(), + 2, + single_key_path_error_message, + ); + + let path_key_ambiguity_error_message = + "Path selection . must be followed by key (identifier or quoted string literal)"; + check_path_parse_error( + new_span("valid.$invalid").fragment(), + 5, + path_key_ambiguity_error_message, + ); + check_path_parse_error( + new_span(" valid.$invalid").fragment(), + 7, + path_key_ambiguity_error_message, + ); + check_path_parse_error( + new_span(" valid . $invalid").fragment(), + 8, + path_key_ambiguity_error_message, + ); + + assert_eq!( + selection!("$").strip_ranges(), + JSONSelection::path(PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Empty.into_with_range() + ) + .into_with_range(), + }), + ); + + assert_eq!( + selection!("$this").strip_ranges(), + JSONSelection::path(PathSelection { + path: PathList::Var( + KnownVariable::External(Namespace::This.to_string()).into_with_range(), + PathList::Empty.into_with_range() + ) + .into_with_range(), + }), + ); + + assert_eq!( + selection!("value: $ a { b c }").strip_ranges(), + JSONSelection::named(SubSelection { + selections: vec![ + NamedSelection { + prefix: NamingPrefix::Alias(Alias::new("value")), + path: PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Empty.into_with_range() + ) + .into_with_range(), + }, + }, + NamedSelection::field( + None, + Key::field("a").into_with_range(), + Some(SubSelection { + selections: vec![ + NamedSelection::field( + None, + Key::field("b").into_with_range(), + None + ), + NamedSelection::field( + None, + Key::field("c").into_with_range(), + None + ), + ], + ..Default::default() + }), + ), + ], + ..Default::default() + }), + ); + assert_eq!( + selection!("value: $this { b c }").strip_ranges(), + JSONSelection::named(SubSelection { + selections: vec![NamedSelection { + prefix: NamingPrefix::Alias(Alias::new("value")), + path: PathSelection { + path: PathList::Var( + KnownVariable::External(Namespace::This.to_string()).into_with_range(), + PathList::Selection(SubSelection { + selections: vec![ + NamedSelection::field( + None, + Key::field("b").into_with_range(), + None + ), + NamedSelection::field( + None, + Key::field("c").into_with_range(), + None + ), + ], + ..Default::default() + }) + .into_with_range(), + ) + .into_with_range(), + }, + }], + ..Default::default() + }), + ); + } + + #[test] + fn test_error_snapshots_v0_2() { + let spec = ConnectSpec::V0_2; + + // The .data shorthand is no longer allowed, since it can be mistakenly + // parsed as a continuation of a previous selection. Instead, use $.data + // to achieve the same effect without ambiguity. + assert_debug_snapshot!(JSONSelection::parse_with_spec(".data", spec)); + + // If you want to mix a path selection with other named selections, the + // path selection must have a trailing subselection, to enforce that it + // returns an object with statically known keys, or be inlined/spread + // with a ... token. + assert_debug_snapshot!(JSONSelection::parse_with_spec("id $.object", spec)); + } + + #[test] + fn test_error_snapshots_v0_3() { + let spec = ConnectSpec::V0_3; + + // When this assertion fails, don't panic, but it's time to decide how + // the next-next version should behave in these error cases (possibly + // exactly the same). + assert_eq!(spec, ConnectSpec::next()); + + // The .data shorthand is no longer allowed, since it can be mistakenly + // parsed as a continuation of a previous selection. Instead, use $.data + // to achieve the same effect without ambiguity. + assert_debug_snapshot!(JSONSelection::parse_with_spec(".data", spec)); + + // If you want to mix a path selection with other named selections, the + // path selection must have a trailing subselection, to enforce that it + // returns an object with statically known keys, or be inlined/spread + // with a ... token. + assert_debug_snapshot!(JSONSelection::parse_with_spec("id $.object", spec)); + } + + #[rstest] + #[case::v0_2(ConnectSpec::V0_2)] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_path_selection_at(#[case] spec: ConnectSpec) { + check_path_selection( + spec, + "@", + PathSelection { + path: PathList::Var( + KnownVariable::AtSign.into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }, + ); + + check_path_selection( + spec, + "@.a.b.c", + PathSelection { + path: PathList::Var( + KnownVariable::AtSign.into_with_range(), + PathList::from_slice( + &[ + Key::Field("a".to_string()), + Key::Field("b".to_string()), + Key::Field("c".to_string()), + ], + None, + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + + check_path_selection( + spec, + "@.items->first", + PathSelection { + path: PathList::Var( + KnownVariable::AtSign.into_with_range(), + PathList::Key( + Key::field("items").into_with_range(), + PathList::Method( + WithRange::new("first".to_string(), None), + None, + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + } + + #[rstest] + #[case::v0_2(ConnectSpec::V0_2)] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_expr_path_selections(#[case] spec: ConnectSpec) { + fn check_simple_lit_expr(spec: ConnectSpec, input: &str, expected: LitExpr) { + check_path_selection( + spec, + input, + PathSelection { + path: PathList::Expr( + expected.into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + }, + ); + } + + check_simple_lit_expr(spec, "$(null)", LitExpr::Null); + + check_simple_lit_expr(spec, "$(true)", LitExpr::Bool(true)); + check_simple_lit_expr(spec, "$(false)", LitExpr::Bool(false)); + + check_simple_lit_expr( + spec, + "$(1234)", + LitExpr::Number("1234".parse().expect("serde_json::Number parse error")), + ); + check_simple_lit_expr( + spec, + "$(1234.5678)", + LitExpr::Number("1234.5678".parse().expect("serde_json::Number parse error")), + ); + + check_simple_lit_expr( + spec, + "$('hello world')", + LitExpr::String("hello world".to_string()), + ); + check_simple_lit_expr( + spec, + "$(\"hello world\")", + LitExpr::String("hello world".to_string()), + ); + check_simple_lit_expr( + spec, + "$(\"hello \\\"world\\\"\")", + LitExpr::String("hello \"world\"".to_string()), + ); + + check_simple_lit_expr( + spec, + "$([1, 2, 3])", + LitExpr::Array( + vec!["1".parse(), "2".parse(), "3".parse()] + .into_iter() + .map(|n| { + LitExpr::Number(n.expect("serde_json::Number parse error")) + .into_with_range() + }) + .collect(), + ), + ); + + check_simple_lit_expr(spec, "$({})", LitExpr::Object(IndexMap::default())); + check_simple_lit_expr( + spec, + "$({ a: 1, b: 2, c: 3 })", + LitExpr::Object({ + let mut map = IndexMap::default(); + for (key, value) in &[("a", "1"), ("b", "2"), ("c", "3")] { + map.insert( + Key::field(key).into_with_range(), + LitExpr::Number(value.parse().expect("serde_json::Number parse error")) + .into_with_range(), + ); + } + map + }), + ); + } + + #[test] + fn test_path_expr_with_spaces_v0_2() { + assert_debug_snapshot!(selection!( + " suffix : results -> slice ( $( - 1 ) -> mul ( $args . suffixLength ) ) ", + // Snapshot tests can be brittle when used with (multiple) #[rstest] + // cases, since the filenames of the snapshots do not always take + // into account the differences between the cases, so we hard-code + // the ConnectSpec in tests like this. + ConnectSpec::V0_2 + )); + } + + #[test] + fn test_path_expr_with_spaces_v0_3() { + assert_debug_snapshot!(selection!( + " suffix : results -> slice ( $( - 1 ) -> mul ( $args . suffixLength ) ) ", + ConnectSpec::V0_3 + )); + } + + #[rstest] + #[case::v0_2(ConnectSpec::V0_2)] + #[case::v0_3(ConnectSpec::V0_3)] + fn test_path_methods(#[case] spec: ConnectSpec) { + check_path_selection( + spec, + "data.x->or(data.y)", + PathSelection { + path: PathList::Key( + Key::field("data").into_with_range(), + PathList::Key( + Key::field("x").into_with_range(), + PathList::Method( + WithRange::new("or".to_string(), None), + Some(MethodArgs { + args: vec![ + LitExpr::Path(PathSelection::from_slice( + &[Key::field("data"), Key::field("y")], + None, + )) + .into_with_range(), + ], + ..Default::default() + }), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + + { + fn make_dollar_key_expr(key: &str) -> WithRange { + WithRange::new( + LitExpr::Path(PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field(key).into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }), + None, + ) + } + + let expected = PathSelection { + path: PathList::Key( + Key::field("data").into_with_range(), + PathList::Method( + WithRange::new("query".to_string(), None), + Some(MethodArgs { + args: vec![ + make_dollar_key_expr("a"), + make_dollar_key_expr("b"), + make_dollar_key_expr("c"), + ], + ..Default::default() + }), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }; + check_path_selection(spec, "data->query($.a, $.b, $.c)", expected.clone()); + check_path_selection(spec, "data->query($.a, $.b, $.c )", expected.clone()); + check_path_selection(spec, "data->query($.a, $.b, $.c,)", expected.clone()); + check_path_selection(spec, "data->query($.a, $.b, $.c ,)", expected.clone()); + check_path_selection(spec, "data->query($.a, $.b, $.c , )", expected); + } + + { + let expected = PathSelection { + path: PathList::Key( + Key::field("data").into_with_range(), + PathList::Key( + Key::field("x").into_with_range(), + PathList::Method( + WithRange::new("concat".to_string(), None), + Some(MethodArgs { + args: vec![ + LitExpr::Array(vec![ + LitExpr::Path(PathSelection::from_slice( + &[Key::field("data"), Key::field("y")], + None, + )) + .into_with_range(), + LitExpr::Path(PathSelection::from_slice( + &[Key::field("data"), Key::field("z")], + None, + )) + .into_with_range(), + ]) + .into_with_range(), + ], + ..Default::default() + }), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }; + check_path_selection(spec, "data.x->concat([data.y, data.z])", expected.clone()); + check_path_selection(spec, "data.x->concat([ data.y, data.z ])", expected.clone()); + check_path_selection(spec, "data.x->concat([data.y, data.z,])", expected.clone()); + check_path_selection( + spec, + "data.x->concat([data.y, data.z , ])", + expected.clone(), + ); + check_path_selection(spec, "data.x->concat([data.y, data.z,],)", expected.clone()); + check_path_selection(spec, "data.x->concat([data.y, data.z , ] , )", expected); + } + + check_path_selection( + spec, + "data->method([$ { x2: x->times(2) }, $ { y2: y->times(2) }])", + PathSelection { + path: PathList::Key( + Key::field("data").into_with_range(), + PathList::Method( + WithRange::new("method".to_string(), None), + Some(MethodArgs { + args: vec![LitExpr::Array(vec![ + LitExpr::Path(PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Selection( + SubSelection { + selections: vec![NamedSelection { + prefix: NamingPrefix::Alias(Alias::new("x2")), + path: PathSelection { + path: PathList::Key( + Key::field("x").into_with_range(), + PathList::Method( + WithRange::new( + "times".to_string(), + None, + ), + Some(MethodArgs { + args: vec![LitExpr::Number( + "2".parse().expect( + "serde_json::Number parse error", + ), + ).into_with_range()], + ..Default::default() + }), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + }], + ..Default::default() + }, + ) + .into_with_range(), + ) + .into_with_range(), + }) + .into_with_range(), + LitExpr::Path(PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Selection( + SubSelection { + selections: vec![NamedSelection { + prefix: NamingPrefix::Alias(Alias::new("y2")), + path: PathSelection { + path: PathList::Key( + Key::field("y").into_with_range(), + PathList::Method( + WithRange::new( + "times".to_string(), + None, + ), + Some( + MethodArgs { + args: vec![LitExpr::Number( + "2".parse().expect( + "serde_json::Number parse error", + ), + ).into_with_range()], + ..Default::default() + }, + ), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + }], + ..Default::default() + }, + ) + .into_with_range(), + ) + .into_with_range(), + }) + .into_with_range(), + ]) + .into_with_range()], + ..Default::default() + }), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + } + + #[test] + fn test_path_with_subselection() { + assert_debug_snapshot!(selection!( + r#" + choices->first.message { content role } + "# + )); + + assert_debug_snapshot!(selection!( + r#" + id + created + choices->first.message { content role } + model + "# + )); + + assert_debug_snapshot!(selection!( + r#" + id + created + choices->first.message { content role } + model + choices->last.message { lastContent: content } + "# + )); + + assert_debug_snapshot!(JSONSelection::parse( + r#" + id + created + choices->first.message + model + "# + )); + + assert_debug_snapshot!(JSONSelection::parse( + r#" + id: $this.id + $args.input { + title + body + } + "# + )); + + // Like the selection above, this selection produces an output shape + // with id, title, and body all flattened in a top-level object. + assert_debug_snapshot!(JSONSelection::parse( + r#" + $this { id } + $args { $.input { title body } } + "# + )); + + assert_debug_snapshot!(JSONSelection::parse( + r#" + # Equivalent to id: $this.id + $this { id } + + $args { + __typename: $("Args") + + # Using $. instead of just . prevents .input from + # parsing as a key applied to the $("Args") string. + $.input { title body } + + extra + } + + from: $.from + "# + )); + } + + #[test] + fn test_subselection() { + fn check_parsed(input: &str, expected: SubSelection) { + let (remainder, parsed) = SubSelection::parse(new_span(input)).unwrap(); + assert!( + span_is_all_spaces_or_comments(remainder.clone()), + "remainder is `{:?}`", + remainder.clone(), + ); + assert_eq!(parsed.strip_ranges(), expected); + } + + check_parsed( + " { \n } ", + SubSelection { + selections: vec![], + ..Default::default() + }, + ); + + check_parsed( + "{hello}", + SubSelection { + selections: vec![NamedSelection::field( + None, + Key::field("hello").into_with_range(), + None, + )], + ..Default::default() + }, + ); + + check_parsed( + "{ hello }", + SubSelection { + selections: vec![NamedSelection::field( + None, + Key::field("hello").into_with_range(), + None, + )], + ..Default::default() + }, + ); + + check_parsed( + " { padded } ", + SubSelection { + selections: vec![NamedSelection::field( + None, + Key::field("padded").into_with_range(), + None, + )], + ..Default::default() + }, + ); + + check_parsed( + "{ hello world }", + SubSelection { + selections: vec![ + NamedSelection::field(None, Key::field("hello").into_with_range(), None), + NamedSelection::field(None, Key::field("world").into_with_range(), None), + ], + ..Default::default() + }, + ); + + check_parsed( + "{ hello { world } }", + SubSelection { + selections: vec![NamedSelection::field( + None, + Key::field("hello").into_with_range(), + Some(SubSelection { + selections: vec![NamedSelection::field( + None, + Key::field("world").into_with_range(), + None, + )], + ..Default::default() + }), + )], + ..Default::default() + }, + ); + } + + #[test] + fn test_external_var_paths() { + fn parse(input: &str) -> PathSelection { + PathSelection::parse(new_span(input)) + .unwrap() + .1 + .strip_ranges() + } + + { + let sel = selection!( + r#" + $->echo([$args.arg1, $args.arg2, @.items->first]) + "# + ) + .strip_ranges(); + let args_arg1_path = parse("$args.arg1"); + let args_arg2_path = parse("$args.arg2"); + assert_eq!( + sel.external_var_paths(), + vec![&args_arg1_path, &args_arg2_path] + ); + } + { + let sel = selection!( + r#" + $this.kind->match( + ["A", $this.a], + ["B", $this.b], + ["C", $this.c], + [@, @->to_lower_case], + ) + "# + ) + .strip_ranges(); + let this_kind_path = match &sel.inner { + TopLevelSelection::Path(path) => path, + _ => panic!("Expected PathSelection"), + }; + let this_a_path = parse("$this.a"); + let this_b_path = parse("$this.b"); + let this_c_path = parse("$this.c"); + assert_eq!( + sel.external_var_paths(), + vec![this_kind_path, &this_a_path, &this_b_path, &this_c_path,] + ); + } + { + let sel = selection!( + r#" + data.results->slice($args.start, $args.end) { + id + __typename: $args.type + } + "# + ) + .strip_ranges(); + let start_path = parse("$args.start"); + let end_path = parse("$args.end"); + let args_type_path = parse("$args.type"); + assert_eq!( + sel.external_var_paths(), + vec![&start_path, &end_path, &args_type_path] + ); + } + } + + #[test] + fn test_ranged_locations() { + fn check(input: &str, expected: JSONSelection) { + let parsed = JSONSelection::parse(input).unwrap(); + assert_eq!(parsed, expected); + } + + check( + "hello", + JSONSelection::named(SubSelection { + selections: vec![NamedSelection::field( + None, + WithRange::new(Key::field("hello"), Some(0..5)), + None, + )], + range: Some(0..5), + }), + ); + + check( + " hello ", + JSONSelection::named(SubSelection { + selections: vec![NamedSelection::field( + None, + WithRange::new(Key::field("hello"), Some(2..7)), + None, + )], + range: Some(2..7), + }), + ); + + check( + " hello { hi name }", + JSONSelection::named(SubSelection { + selections: vec![NamedSelection::field( + None, + WithRange::new(Key::field("hello"), Some(2..7)), + Some(SubSelection { + selections: vec![ + NamedSelection::field( + None, + WithRange::new(Key::field("hi"), Some(11..13)), + None, + ), + NamedSelection::field( + None, + WithRange::new(Key::field("name"), Some(14..18)), + None, + ), + ], + range: Some(9..20), + }), + )], + range: Some(2..20), + }), + ); + + check( + "$args.product.id", + JSONSelection::path(PathSelection { + path: WithRange::new( + PathList::Var( + WithRange::new( + KnownVariable::External(Namespace::Args.to_string()), + Some(0..5), + ), + WithRange::new( + PathList::Key( + WithRange::new(Key::field("product"), Some(6..13)), + WithRange::new( + PathList::Key( + WithRange::new(Key::field("id"), Some(14..16)), + WithRange::new(PathList::Empty, Some(16..16)), + ), + Some(13..16), + ), + ), + Some(5..16), + ), + ), + Some(0..16), + ), + }), + ); + + check( + " $args . product . id ", + JSONSelection::path(PathSelection { + path: WithRange::new( + PathList::Var( + WithRange::new( + KnownVariable::External(Namespace::Args.to_string()), + Some(1..6), + ), + WithRange::new( + PathList::Key( + WithRange::new(Key::field("product"), Some(9..16)), + WithRange::new( + PathList::Key( + WithRange::new(Key::field("id"), Some(19..21)), + WithRange::new(PathList::Empty, Some(21..21)), + ), + Some(17..21), + ), + ), + Some(7..21), + ), + ), + Some(1..21), + ), + }), + ); + + check( + "before product:$args.product{id name}after", + JSONSelection::named(SubSelection { + selections: vec![ + NamedSelection::field( + None, + WithRange::new(Key::field("before"), Some(0..6)), + None, + ), + NamedSelection { + prefix: NamingPrefix::Alias(Alias { + name: WithRange::new(Key::field("product"), Some(7..14)), + range: Some(7..15), + }), + path: PathSelection { + path: WithRange::new( + PathList::Var( + WithRange::new( + KnownVariable::External(Namespace::Args.to_string()), + Some(15..20), + ), + WithRange::new( + PathList::Key( + WithRange::new(Key::field("product"), Some(21..28)), + WithRange::new( + PathList::Selection(SubSelection { + selections: vec![ + NamedSelection::field( + None, + WithRange::new( + Key::field("id"), + Some(29..31), + ), + None, + ), + NamedSelection::field( + None, + WithRange::new( + Key::field("name"), + Some(32..36), + ), + None, + ), + ], + range: Some(28..37), + }), + Some(28..37), + ), + ), + Some(20..37), + ), + ), + Some(15..37), + ), + }, + }, + NamedSelection::field( + None, + WithRange::new(Key::field("after"), Some(37..42)), + None, + ), + ], + range: Some(0..42), + }), + ); + } + + #[test] + fn test_variable_reference_no_path() { + let selection = JSONSelection::parse("$this").unwrap(); + let var_paths = selection.external_var_paths(); + assert_eq!(var_paths.len(), 1); + assert_eq!( + var_paths[0].variable_reference(), + Some(VariableReference { + namespace: VariableNamespace { + namespace: Namespace::This, + location: Some(0..5), + }, + selection: { + let mut selection = SelectionTrie::new(); + selection.add_str_path([]); + selection + }, + location: Some(0..5), + }) + ); + } + + #[test] + fn test_variable_reference_with_path() { + let selection = JSONSelection::parse("$this.a.b.c").unwrap(); + let var_paths = selection.external_var_paths(); + assert_eq!(var_paths.len(), 1); + + let var_ref = var_paths[0].variable_reference().unwrap(); + assert_eq!( + var_ref.namespace, + VariableNamespace { + namespace: Namespace::This, + location: Some(0..5) + } + ); + assert_eq!(var_ref.selection.to_string(), "a { b { c } }"); + assert_eq!(var_ref.location, Some(0..11)); + + assert_eq!( + var_ref.selection.key_ranges("a").collect::>(), + vec![6..7] + ); + let a_trie = var_ref.selection.get("a").unwrap(); + assert_eq!(a_trie.key_ranges("b").collect::>(), vec![8..9]); + let b_trie = a_trie.get("b").unwrap(); + assert_eq!(b_trie.key_ranges("c").collect::>(), vec![10..11]); + } + + #[test] + fn test_variable_reference_nested() { + let selection = JSONSelection::parse("a b { c: $this.x.y.z { d } }").unwrap(); + let var_paths = selection.external_var_paths(); + assert_eq!(var_paths.len(), 1); + + let var_ref = var_paths[0].variable_reference().unwrap(); + assert_eq!( + var_ref.namespace, + VariableNamespace { + namespace: Namespace::This, + location: Some(9..14), + } + ); + assert_eq!(var_ref.selection.to_string(), "x { y { z { d } } }"); + assert_eq!(var_ref.location, Some(9..26)); + + assert_eq!( + var_ref.selection.key_ranges("x").collect::>(), + vec![15..16] + ); + let x_trie = var_ref.selection.get("x").unwrap(); + assert_eq!(x_trie.key_ranges("y").collect::>(), vec![17..18]); + let y_trie = x_trie.get("y").unwrap(); + assert_eq!(y_trie.key_ranges("z").collect::>(), vec![19..20]); + let z_trie = y_trie.get("z").unwrap(); + assert_eq!(z_trie.key_ranges("d").collect::>(), vec![23..24]); + } + + #[test] + fn test_external_var_paths_no_variable() { + let selection = JSONSelection::parse("a.b.c").unwrap(); + let var_paths = selection.external_var_paths(); + assert_eq!(var_paths.len(), 0); + } + + #[test] + fn test_naked_literal_path_for_connect_v0_2() { + let spec = ConnectSpec::V0_2; + + let selection_null_stringify_v0_2 = selection!("$(null->jsonStringify)", spec); + assert_eq!( + selection_null_stringify_v0_2.pretty_print(), + "$(null->jsonStringify)" + ); + + let selection_hello_slice_v0_2 = selection!("sliced: $('hello'->slice(1, 3))", spec); + assert_eq!( + selection_hello_slice_v0_2.pretty_print(), + "sliced: $(\"hello\"->slice(1, 3))" + ); + + let selection_true_not_v0_2 = selection!("true->not", spec); + assert_eq!(selection_true_not_v0_2.pretty_print(), "true->not"); + + let selection_false_not_v0_2 = selection!("false->not", spec); + assert_eq!(selection_false_not_v0_2.pretty_print(), "false->not"); + + let selection_object_path_v0_2 = selection!("$({ a: 123 }.a)", spec); + assert_eq!( + selection_object_path_v0_2.pretty_print_with_indentation(true, 0), + "$({ a: 123 }.a)" + ); + + let selection_array_path_v0_2 = selection!("$([1, 2, 3]->get(1))", spec); + assert_eq!( + selection_array_path_v0_2.pretty_print(), + "$([1, 2, 3]->get(1))" + ); + + assert_debug_snapshot!(selection_null_stringify_v0_2); + assert_debug_snapshot!(selection_hello_slice_v0_2); + assert_debug_snapshot!(selection_true_not_v0_2); + assert_debug_snapshot!(selection_false_not_v0_2); + assert_debug_snapshot!(selection_object_path_v0_2); + assert_debug_snapshot!(selection_array_path_v0_2); + } + + #[test] + fn test_optional_key_access() { + let spec = ConnectSpec::V0_3; + + check_path_selection( + spec, + "$.foo?.bar", + PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("foo").into_with_range(), + PathList::Question( + PathList::Key( + Key::field("bar").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + } + + #[test] + fn test_unambiguous_single_key_paths_v0_2() { + let spec = ConnectSpec::V0_2; + + let mul_with_dollars = selection!("a->mul($.b, $.c)", spec); + mul_with_dollars.if_named_else_path( + |named| { + panic!("Expected a path selection, got named: {named:?}"); + }, + |path| { + assert_eq!(path.get_single_key(), None); + assert_eq!(path.pretty_print(), "a->mul($.b, $.c)"); + }, + ); + + assert_debug_snapshot!(mul_with_dollars); + } + + #[test] + fn test_invalid_single_key_paths_v0_2() { + let spec = ConnectSpec::V0_2; + + let a_plus_b_plus_c = JSONSelection::parse_with_spec("a->add(b, c)", spec); + assert_eq!(a_plus_b_plus_c, Err(JSONSelectionParseError { + message: "Named path selection must either begin with alias or ..., or end with subselection".to_string(), + fragment: "a->add(b, c)".to_string(), + offset: 0, + spec: ConnectSpec::V0_2, + })); + + let sum_a_plus_b_plus_c = JSONSelection::parse_with_spec("sum: a->add(b, c)", spec); + assert_eq!( + sum_a_plus_b_plus_c, + Err(JSONSelectionParseError { + message: "nom::error::ErrorKind::Eof".to_string(), + fragment: "(b, c)".to_string(), + offset: 11, + spec: ConnectSpec::V0_2, + }) + ); + } + + #[test] + fn test_optional_method_call() { + let spec = ConnectSpec::V0_3; + + check_path_selection( + spec, + "$.foo?->method", + PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("foo").into_with_range(), + PathList::Question( + PathList::Method( + WithRange::new("method".to_string(), None), + None, + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + } + + #[test] + fn test_chained_optional_accesses() { + let spec = ConnectSpec::V0_3; + + check_path_selection( + spec, + "$.foo?.bar?.baz", + PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("foo").into_with_range(), + PathList::Question( + PathList::Key( + Key::field("bar").into_with_range(), + PathList::Question( + PathList::Key( + Key::field("baz").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + } + + #[test] + fn test_mixed_regular_and_optional_access() { + let spec = ConnectSpec::V0_3; + + check_path_selection( + spec, + "$.foo.bar?.baz", + PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("foo").into_with_range(), + PathList::Key( + Key::field("bar").into_with_range(), + PathList::Question( + PathList::Key( + Key::field("baz").into_with_range(), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + } + + #[test] + fn test_invalid_sequential_question_marks() { + let spec = ConnectSpec::V0_3; + + assert_eq!( + JSONSelection::parse_with_spec("baz: $.foo??.bar", spec), + Err(JSONSelectionParseError { + message: "nom::error::ErrorKind::Eof".to_string(), + fragment: "??.bar".to_string(), + offset: 10, + spec, + }), + ); + + assert_eq!( + JSONSelection::parse_with_spec("baz: $.foo?->echo(null)??.bar", spec), + Err(JSONSelectionParseError { + message: "nom::error::ErrorKind::Eof".to_string(), + fragment: "??.bar".to_string(), + offset: 23, + spec, + }), + ); + } + + #[test] + fn test_invalid_infix_operator_parsing() { + let spec = ConnectSpec::V0_2; + + assert_eq!( + JSONSelection::parse_with_spec("aOrB: $($.a ?? $.b)", spec), + Err(JSONSelectionParseError { + message: "nom::error::ErrorKind::Eof".to_string(), + fragment: "($.a ?? $.b)".to_string(), + offset: 7, + spec, + }), + ); + + assert_eq!( + JSONSelection::parse_with_spec("aOrB: $($.a ?! $.b)", spec), + Err(JSONSelectionParseError { + message: "nom::error::ErrorKind::Eof".to_string(), + fragment: "($.a ?! $.b)".to_string(), + offset: 7, + spec, + }), + ); + } + + #[test] + fn test_optional_chaining_with_subselection() { + let spec = ConnectSpec::V0_3; + + check_path_selection( + spec, + "$.foo?.bar { id name }", + PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("foo").into_with_range(), + PathList::Question( + PathList::Key( + Key::field("bar").into_with_range(), + PathList::Selection(SubSelection { + selections: vec![ + NamedSelection::field( + None, + Key::field("id").into_with_range(), + None, + ), + NamedSelection::field( + None, + Key::field("name").into_with_range(), + None, + ), + ], + ..Default::default() + }) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + } + + #[test] + fn test_optional_method_with_arguments() { + let spec = ConnectSpec::V0_3; + + check_path_selection( + spec, + "$.foo?->filter('active')", + PathSelection { + path: PathList::Var( + KnownVariable::Dollar.into_with_range(), + PathList::Key( + Key::field("foo").into_with_range(), + PathList::Question( + PathList::Method( + WithRange::new("filter".to_string(), None), + Some(MethodArgs { + args: vec![ + LitExpr::String("active".to_string()).into_with_range(), + ], + ..Default::default() + }), + PathList::Empty.into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + ) + .into_with_range(), + }, + ); + } + + #[test] + fn test_unambiguous_single_key_paths_v0_3() { + let spec = ConnectSpec::V0_3; + + let mul_with_dollars = selection!("a->mul($.b, $.c)", spec); + mul_with_dollars.if_named_else_path( + |named| { + panic!("Expected a path selection, got named: {named:?}"); + }, + |path| { + assert_eq!(path.get_single_key(), None); + assert_eq!(path.pretty_print(), "a->mul($.b, $.c)"); + }, + ); + + assert_debug_snapshot!(mul_with_dollars); + } + + #[test] + fn test_valid_single_key_path_v0_3() { + let spec = ConnectSpec::V0_3; + + let a_plus_b_plus_c = JSONSelection::parse_with_spec("a->add(b, c)", spec); + if let Ok(selection) = a_plus_b_plus_c { + selection.if_named_else_path( + |named| { + panic!("Expected a path selection, got named: {named:?}"); + }, + |path| { + assert_eq!(path.pretty_print(), "a->add(b, c)"); + assert_eq!(path.get_single_key(), None); + }, + ); + assert_debug_snapshot!(selection); + } else { + panic!("Expected a valid selection, got error: {a_plus_b_plus_c:?}"); + } + } + + #[test] + fn test_valid_single_key_path_with_alias_v0_3() { + let spec = ConnectSpec::V0_3; + + let sum_a_plus_b_plus_c = JSONSelection::parse_with_spec("sum: a->add(b, c)", spec); + if let Ok(selection) = sum_a_plus_b_plus_c { + selection.if_named_else_path( + |named| { + for selection in named.selections_iter() { + assert_eq!(selection.pretty_print(), "sum: a->add(b, c)"); + assert_eq!( + selection.get_single_key().map(|key| key.as_str()), + Some("sum") + ); + } + }, + |path| { + panic!("Expected any number of named selections, got path: {path:?}"); + }, + ); + assert_debug_snapshot!(selection); + } else { + panic!("Expected a valid selection, got error: {sum_a_plus_b_plus_c:?}"); + } + } + + #[cfg(test)] + mod spread_parsing { + use crate::connectors::ConnectSpec; + use crate::connectors::json_selection::PrettyPrintable; + use crate::selection; + + #[track_caller] + pub(super) fn check(spec: ConnectSpec, input: &str, expected_pretty: &str) { + let selection = selection!(input, spec); + assert_eq!(selection.pretty_print(), expected_pretty); + } + } + + #[test] + fn test_basic_spread_parsing_one_field() { + let spec = ConnectSpec::V0_3; + let expected = "... a"; + spread_parsing::check(spec, "...a", expected); + spread_parsing::check(spec, "... a", expected); + spread_parsing::check(spec, "...a ", expected); + spread_parsing::check(spec, "... a ", expected); + spread_parsing::check(spec, " ... a ", expected); + spread_parsing::check(spec, "...\na", expected); + assert_debug_snapshot!(selection!("...a", spec)); + } + + #[test] + fn test_spread_parsing_spread_a_spread_b() { + let spec = ConnectSpec::V0_3; + let expected = "... a\n... b"; + spread_parsing::check(spec, "...a...b", expected); + spread_parsing::check(spec, "... a ... b", expected); + spread_parsing::check(spec, "... a ...b", expected); + spread_parsing::check(spec, "... a ... b ", expected); + spread_parsing::check(spec, " ... a ... b ", expected); + assert_debug_snapshot!(selection!("...a...b", spec)); + } + + #[test] + fn test_spread_parsing_a_spread_b() { + let spec = ConnectSpec::V0_3; + let expected = "a\n... b"; + spread_parsing::check(spec, "a...b", expected); + spread_parsing::check(spec, "a ... b", expected); + spread_parsing::check(spec, "a\n...b", expected); + spread_parsing::check(spec, "a\n...\nb", expected); + spread_parsing::check(spec, "a...\nb", expected); + spread_parsing::check(spec, " a ... b", expected); + spread_parsing::check(spec, " a ...b", expected); + spread_parsing::check(spec, " a ... b ", expected); + assert_debug_snapshot!(selection!("a...b", spec)); + } + + #[test] + fn test_spread_parsing_spread_a_b() { + let spec = ConnectSpec::V0_3; + let expected = "... a\nb"; + spread_parsing::check(spec, "...a b", expected); + spread_parsing::check(spec, "... a b", expected); + spread_parsing::check(spec, "... a b ", expected); + spread_parsing::check(spec, "... a\nb", expected); + spread_parsing::check(spec, "... a\n b", expected); + spread_parsing::check(spec, " ... a b ", expected); + assert_debug_snapshot!(selection!("...a b", spec)); + } + + #[test] + fn test_spread_parsing_spread_a_b_c() { + let spec = ConnectSpec::V0_3; + let expected = "... a\nb\nc"; + spread_parsing::check(spec, "...a b c", expected); + spread_parsing::check(spec, "... a b c", expected); + spread_parsing::check(spec, "... a b c ", expected); + spread_parsing::check(spec, "... a\nb\nc", expected); + spread_parsing::check(spec, "... a\nb\n c", expected); + spread_parsing::check(spec, " ... a b c ", expected); + spread_parsing::check(spec, "...\na b c", expected); + assert_debug_snapshot!(selection!("...a b c", spec)); + } + + #[test] + fn test_spread_parsing_spread_spread_a_sub_b() { + let spec = ConnectSpec::V0_3; + let expected = "... a {\n b\n}"; + spread_parsing::check(spec, "...a{b}", expected); + spread_parsing::check(spec, "... a { b }", expected); + spread_parsing::check(spec, "...a { b }", expected); + spread_parsing::check(spec, "... a { b } ", expected); + spread_parsing::check(spec, "... a\n{ b }", expected); + spread_parsing::check(spec, "... a\n{b}", expected); + spread_parsing::check(spec, " ... a { b } ", expected); + spread_parsing::check(spec, "...\na { b }", expected); + assert_debug_snapshot!(selection!("...a{b}", spec)); + } + + #[test] + fn test_spread_parsing_spread_a_sub_b_c() { + let spec = ConnectSpec::V0_3; + let expected = "... a {\n b\n c\n}"; + spread_parsing::check(spec, "...a{b c}", expected); + spread_parsing::check(spec, "... a { b c }", expected); + spread_parsing::check(spec, "...a { b c }", expected); + spread_parsing::check(spec, "... a { b c } ", expected); + spread_parsing::check(spec, "... a\n{ b c }", expected); + spread_parsing::check(spec, "... a\n{b c}", expected); + spread_parsing::check(spec, " ... a { b c } ", expected); + spread_parsing::check(spec, "...\na { b c }", expected); + spread_parsing::check(spec, "...\na { b\nc }", expected); + assert_debug_snapshot!(selection!("...a{b c}", spec)); + } + + #[test] + fn test_spread_parsing_spread_a_sub_b_spread_c() { + let spec = ConnectSpec::V0_3; + let expected = "... a {\n b\n ... c\n}"; + spread_parsing::check(spec, "...a{b...c}", expected); + spread_parsing::check(spec, "... a { b ... c }", expected); + spread_parsing::check(spec, "...a { b ... c }", expected); + spread_parsing::check(spec, "... a { b ... c } ", expected); + spread_parsing::check(spec, "... a\n{ b ... c }", expected); + spread_parsing::check(spec, "... a\n{b ... c}", expected); + spread_parsing::check(spec, " ... a { b ... c } ", expected); + spread_parsing::check(spec, "...\na { b ... c }", expected); + spread_parsing::check(spec, "...\na {b ...\nc }", expected); + assert_debug_snapshot!(selection!("...a{b...c}", spec)); + } + + #[test] + fn test_spread_parsing_spread_a_sub_b_spread_c_d() { + let spec = ConnectSpec::V0_3; + let expected = "... a {\n b\n ... c\n d\n}"; + spread_parsing::check(spec, "...a{b...c d}", expected); + spread_parsing::check(spec, "... a { b ... c d }", expected); + spread_parsing::check(spec, "...a { b ... c d }", expected); + spread_parsing::check(spec, "... a { b ... c d } ", expected); + spread_parsing::check(spec, "... a\n{ b ... c d }", expected); + spread_parsing::check(spec, "... a\n{b ... c d}", expected); + spread_parsing::check(spec, " ... a { b ... c d } ", expected); + spread_parsing::check(spec, "...\na { b ... c d }", expected); + spread_parsing::check(spec, "...\na {b ...\nc d }", expected); + assert_debug_snapshot!(selection!("...a{b...c d}", spec)); + } + + #[test] + fn test_spread_parsing_spread_a_sub_spread_b_c_d_spread_e() { + let spec = ConnectSpec::V0_3; + let expected = "... a {\n ... b\n c\n d\n ... e\n}"; + spread_parsing::check(spec, "...a{...b c d...e}", expected); + spread_parsing::check(spec, "... a { ... b c d ... e }", expected); + spread_parsing::check(spec, "...a { ... b c d ... e }", expected); + spread_parsing::check(spec, "... a { ... b c d ... e } ", expected); + spread_parsing::check(spec, "... a\n{ ... b c d ... e }", expected); + spread_parsing::check(spec, "... a\n{... b c d ... e}", expected); + spread_parsing::check(spec, " ... a { ... b c d ... e } ", expected); + spread_parsing::check(spec, "...\na { ... b c d ... e }", expected); + spread_parsing::check(spec, "...\na {...\nb\nc d ...\ne }", expected); + assert_debug_snapshot!(selection!("...a{...b c d...e}", spec)); + } + + #[test] + fn should_parse_null_coalescing_in_connect_0_3() { + assert!(JSONSelection::parse_with_spec("sum: $(a ?? b)", ConnectSpec::V0_3).is_ok()); + assert!(JSONSelection::parse_with_spec("sum: $(a ?! b)", ConnectSpec::V0_3).is_ok()); + } + + #[test] + fn should_not_parse_null_coalescing_in_connect_0_2() { + assert!(JSONSelection::parse_with_spec("sum: $(a ?? b)", ConnectSpec::V0_2).is_err()); + assert!(JSONSelection::parse_with_spec("sum: $(a ?! b)", ConnectSpec::V0_2).is_err()); + } + + #[test] + fn should_not_parse_mixed_operators_in_same_expression() { + let result = JSONSelection::parse_with_spec("sum: $(a ?? b ?! c)", ConnectSpec::V0_3); + + let err = result.expect_err("Expected parse error for mixed operators ?? and ?!"); + assert_eq!( + err.message, + "Found mixed operators ?? and ?!. You can only chain operators of the same kind." + ); + + // Also test the reverse order + let result2 = JSONSelection::parse_with_spec("sum: $(a ?! b ?? c)", ConnectSpec::V0_3); + let err2 = result2.expect_err("Expected parse error for mixed operators ?! and ??"); + assert_eq!( + err2.message, + "Found mixed operators ?! and ??. You can only chain operators of the same kind." + ); + } + + #[test] + fn should_parse_mixed_operators_in_nested_expression() { + let result = JSONSelection::parse_with_spec("sum: $(a ?? $(b ?! c))", ConnectSpec::V0_3); + + assert!(result.is_ok()); + } +} diff --git a/apollo-federation/src/connectors/json_selection/pretty.rs b/apollo-federation/src/connectors/json_selection/pretty.rs new file mode 100644 index 0000000000..e859fbbb7d --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/pretty.rs @@ -0,0 +1,537 @@ +//! Pretty printing utility methods +//! +//! Working with raw JSONSelections when doing snapshot testing is difficult to +//! read and makes the snapshots themselves quite large. This module adds a new +//! pretty printing trait which is then implemented on the various sub types +//! of the JSONSelection tree. + +use itertools::Itertools; + +use super::lit_expr::LitExpr; +use super::lit_expr::LitOp; +use super::parser::Alias; +use super::parser::Key; +use crate::connectors::json_selection::JSONSelection; +use crate::connectors::json_selection::MethodArgs; +use crate::connectors::json_selection::NamedSelection; +use crate::connectors::json_selection::NamingPrefix; +use crate::connectors::json_selection::PathList; +use crate::connectors::json_selection::PathSelection; +use crate::connectors::json_selection::SubSelection; +use crate::connectors::json_selection::TopLevelSelection; + +impl std::fmt::Display for JSONSelection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.pretty_print()) + } +} + +/// Pretty print trait +/// +/// This trait marks a type as supporting pretty printing itself outside of a +/// Display implementation, which might be more useful for snapshots. +pub(crate) trait PrettyPrintable { + /// Pretty print the struct + fn pretty_print(&self) -> String { + self.pretty_print_with_indentation(false, 0) + } + + /// Pretty print the struct, with indentation + /// + /// Each indentation level is marked with 2 spaces, with `inline` signifying + /// that the first line should be not indented. + fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String; +} + +/// Helper method to generate indentation +fn indent_chars(indent: usize) -> String { + " ".repeat(indent) +} + +impl PrettyPrintable for JSONSelection { + fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { + match &self.inner { + TopLevelSelection::Named(named) => named.print_subselections(inline, indentation), + TopLevelSelection::Path(path) => { + path.pretty_print_with_indentation(inline, indentation) + } + } + } +} + +impl PrettyPrintable for SubSelection { + fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { + let mut result = String::new(); + + result.push('{'); + + if self.selections.is_empty() { + result.push('}'); + return result; + } + + if inline { + result.push(' '); + } else { + result.push('\n'); + result.push_str(indent_chars(indentation + 1).as_str()); + } + + result.push_str(&self.print_subselections(inline, indentation + 1)); + + if inline { + result.push(' '); + } else { + result.push('\n'); + result.push_str(indent_chars(indentation).as_str()); + } + + result.push('}'); + + result + } +} + +impl SubSelection { + /// Prints all of the selections in a subselection + fn print_subselections(&self, inline: bool, indentation: usize) -> String { + let separator = if inline { + ' '.to_string() + } else { + format!("\n{}", indent_chars(indentation)) + }; + + self.selections + .iter() + .map(|s| s.pretty_print_with_indentation(inline, indentation)) + .join(separator.as_str()) + } +} + +impl PrettyPrintable for PathSelection { + fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { + let inner = self.path.pretty_print_with_indentation(inline, indentation); + // Because we can't tell where PathList::Key elements appear in the path + // once we're inside PathList::pretty_print_with_indentation, we print + // all PathList::Key elements with a leading '.' character, but we + // remove the initial '.' if the path has more than one element, because + // then the leading '.' is not necessary to disambiguate the key from a + // field. To complicate matters further, inner may begin with spaces due + // to indentation. + let leading_space_count = inner.chars().take_while(|c| *c == ' ').count(); + let suffix = inner[leading_space_count..].to_string(); + if let Some(after_dot) = suffix.strip_prefix('.') { + // Strip the '.' but keep any leading spaces. + format!("{}{}", " ".repeat(leading_space_count), after_dot) + } else { + inner + } + } +} + +impl PrettyPrintable for PathList { + fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { + let mut result = String::new(); + + match self { + Self::Var(var, tail) => { + let rest = tail.pretty_print_with_indentation(inline, indentation); + result.push_str(var.as_str()); + result.push_str(rest.as_str()); + } + Self::Key(key, tail) => { + result.push('.'); + result.push_str(key.pretty_print().as_str()); + let rest = tail.pretty_print_with_indentation(inline, indentation); + result.push_str(rest.as_str()); + } + Self::Expr(expr, tail) => { + let rest = tail.pretty_print_with_indentation(inline, indentation); + result.push_str("$("); + result.push_str( + expr.pretty_print_with_indentation(inline, indentation) + .as_str(), + ); + result.push(')'); + result.push_str(rest.as_str()); + } + Self::Method(method, args, tail) => { + result.push_str("->"); + result.push_str(method.as_str()); + if let Some(args) = args { + result.push_str( + args.pretty_print_with_indentation(inline, indentation) + .as_str(), + ); + } + result.push_str( + tail.pretty_print_with_indentation(inline, indentation) + .as_str(), + ); + } + Self::Question(tail) => { + result.push('?'); + let rest = tail.pretty_print_with_indentation(true, indentation); + result.push_str(rest.as_str()); + } + Self::Selection(sub) => { + let sub = sub.pretty_print_with_indentation(inline, indentation); + result.push(' '); + result.push_str(sub.as_str()); + } + Self::Empty => {} + } + + result + } +} + +impl PrettyPrintable for MethodArgs { + fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { + let mut result = String::new(); + + result.push('('); + + // TODO Break long argument lists across multiple lines, with indentation? + for (i, arg) in self.args.iter().enumerate() { + if i > 0 { + result.push_str(", "); + } + result.push_str( + arg.pretty_print_with_indentation(inline, indentation + 1) + .as_str(), + ); + } + + result.push(')'); + + result + } +} + +impl PrettyPrintable for LitExpr { + fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { + let mut result = String::new(); + + match self { + Self::String(s) => { + let safely_quoted = serde_json_bytes::Value::String(s.clone().into()).to_string(); + result.push_str(safely_quoted.as_str()); + } + Self::Number(n) => result.push_str(n.to_string().as_str()), + Self::Bool(b) => result.push_str(b.to_string().as_str()), + Self::Null => result.push_str("null"), + Self::Object(map) => { + result.push('{'); + + if map.is_empty() { + result.push('}'); + return result; + } + + let mut is_first = true; + for (key, value) in map { + if is_first { + is_first = false; + } else { + result.push(','); + } + + if inline { + result.push(' '); + } else { + result.push('\n'); + result.push_str(indent_chars(indentation + 1).as_str()); + } + + result.push_str(key.pretty_print().as_str()); + result.push_str(": "); + result.push_str( + value + .pretty_print_with_indentation(inline, indentation + 1) + .as_str(), + ); + } + + if inline { + result.push(' '); + } else { + result.push('\n'); + result.push_str(indent_chars(indentation).as_str()); + } + + result.push('}'); + } + Self::Array(vec) => { + result.push('['); + let mut is_first = true; + for value in vec { + if is_first { + is_first = false; + } else { + result.push_str(", "); + } + result.push_str( + value + .pretty_print_with_indentation(inline, indentation) + .as_str(), + ); + } + result.push(']'); + } + Self::Path(path) => { + result.push_str( + path.pretty_print_with_indentation(inline, indentation) + .as_str(), + ); + } + Self::LitPath(literal, subpath) => { + result.push_str( + literal + .pretty_print_with_indentation(inline, indentation) + .as_str(), + ); + result.push_str( + subpath + .pretty_print_with_indentation(inline, indentation) + .as_str(), + ); + } + Self::OpChain(op, operands) => { + let op_str = match op.as_ref() { + LitOp::NullishCoalescing => " ?? ", + LitOp::NoneCoalescing => " ?! ", + }; + + for (i, operand) in operands.iter().enumerate() { + if i > 0 { + result.push_str(op_str); + } + result.push_str( + operand + .pretty_print_with_indentation(inline, indentation) + .as_str(), + ); + } + } + } + + result + } +} + +impl PrettyPrintable for NamedSelection { + fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { + let mut result = String::new(); + + match &self.prefix { + NamingPrefix::None => {} + NamingPrefix::Alias(alias) => { + result.push_str(alias.pretty_print().as_str()); + result.push(' '); + } + NamingPrefix::Spread(token_range) => { + if token_range.is_some() { + result.push_str("... "); + } + } + }; + + // The .trim_start() handles the case when self.path is just a + // SubSelection (i.e., a NamedGroupSelection), since that PathList + // variant typically prints a single leading space. + let pretty_path = self.path.pretty_print_with_indentation(inline, indentation); + result.push_str(pretty_path.trim_start()); + + result + } +} + +impl PrettyPrintable for Alias { + fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { + let mut result = String::new(); + + let name = self.name.pretty_print_with_indentation(inline, indentation); + result.push_str(name.as_str()); + result.push(':'); + + result + } +} + +impl PrettyPrintable for Key { + fn pretty_print_with_indentation(&self, _inline: bool, _indentation: usize) -> String { + match self { + Self::Field(name) => name.clone(), + Self::Quoted(name) => serde_json_bytes::Value::String(name.as_str().into()).to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use crate::connectors::JSONSelection; + use crate::connectors::PathSelection; + use crate::connectors::SubSelection; + use crate::connectors::json_selection::NamedSelection; + use crate::connectors::json_selection::PrettyPrintable; + use crate::connectors::json_selection::location::new_span; + use crate::connectors::json_selection::pretty::indent_chars; + + // Test all valid pretty print permutations + fn test_permutations(selection: impl PrettyPrintable, expected: &str) { + let indentation = 4; + let expected_indented = expected + .lines() + .map(|line| format!("{}{line}", indent_chars(indentation))) + .collect::>() + .join("\n"); + let expected_indented = expected_indented.trim_start(); + + let prettified = selection.pretty_print(); + assert_eq!( + prettified, expected, + "pretty printing did not match: {prettified} != {expected}" + ); + + let prettified_inline = selection.pretty_print_with_indentation(true, indentation); + let expected_inline = collapse_spaces(expected); + assert_eq!( + prettified_inline.trim_start(), + expected_inline.trim_start(), + "pretty printing inline did not match: {prettified_inline} != {}", + expected_indented.trim_start() + ); + + let prettified_indented = selection.pretty_print_with_indentation(false, indentation); + assert_eq!( + prettified_indented, expected_indented, + "pretty printing indented did not match: {prettified_indented} != {expected_indented}" + ); + } + + fn collapse_spaces(s: impl Into) -> String { + let pattern = regex::Regex::new(r"\s+").expect("valid regex"); + pattern.replace_all(s.into().as_str(), " ").to_string() + } + + #[test] + fn it_prints_a_named_selection() { + let selections = [ + // Field + "cool", + "cool: beans", + "cool: beans {\n whoa\n}", + // Path + "cool: one.two.three", + // Quoted + r#"cool: "b e a n s""#, + "cool: \"b e a n s\" {\n a\n b\n}", + // Group + "cool: {\n a\n b\n}", + ]; + for selection in selections { + let (unmatched, named_selection) = NamedSelection::parse(new_span(selection)).unwrap(); + assert!( + unmatched.is_empty(), + "static named selection was not fully parsed: '{selection}' ({named_selection:?}) had unmatched '{unmatched}'" + ); + + test_permutations(named_selection, selection); + } + } + + #[test] + fn it_prints_a_path_selection() { + let paths = [ + // Var + "$.one.two.three", + "$this.a.b", + "$this.id.first {\n username\n}", + // Key + "$.first", + "a.b.c.d.e", + "one.two.three {\n a\n b\n}", + "$.single {\n x\n}", + "results->slice($(-1)->mul($args.suffixLength))", + "$(1234)->add($(5678)->mul(2))", + "$(true)->and($(false)->not)", + "$(12345678987654321)->div(111111111)->eq(111111111)", + "$(\"Product\")->slice(0, $(4)->mul(-1))->eq(\"Pro\")", + "$($args.unnecessary.parens)->eq(42)", + ]; + for path in paths { + let (unmatched, path_selection) = PathSelection::parse(new_span(path)).unwrap(); + assert!( + unmatched.is_empty(), + "static path was not fully parsed: '{path}' ({path_selection:?}) had unmatched '{unmatched}'" + ); + + test_permutations(path_selection, path); + } + } + + #[test] + fn it_prints_a_sub_selection() { + let sub = "{\n a\n b\n}"; + let (unmatched, sub_selection) = SubSelection::parse(new_span(sub)).unwrap(); + assert!( + unmatched.is_empty(), + "static path was not fully parsed: '{sub}' ({sub_selection:?}) had unmatched '{unmatched}'" + ); + + test_permutations(sub_selection, sub); + } + + #[test] + fn it_prints_an_inline_path_with_subselection() { + // This test ensures we do not print a leading ... before some.path, + // even though we will probably want to do so once ... and conditional + // selections are implemented. The printing of the leading ... was due + // to an incomplete removal of experimental support for conditional + // selections, which was put on hold to de-risk the GA release. + let source = "before\nsome.path {\n inline\n me\n}\nafter"; + let sel = JSONSelection::parse(source).unwrap(); + test_permutations(sel, source); + } + + #[test] + fn it_prints_a_nested_sub_selection() { + let sub = "{ + a { + b { + c + } + } + }"; + let sub_indented = "{\n a {\n b {\n c\n }\n }\n}"; + let sub_super_indented = " {\n a {\n b {\n c\n }\n }\n }"; + + let (unmatched, sub_selection) = SubSelection::parse(new_span(sub)).unwrap(); + + assert!( + unmatched.is_empty(), + "static nested sub was not fully parsed: '{sub}' ({sub_selection:?}) had unmatched '{unmatched}'" + ); + + let pretty = sub_selection.pretty_print(); + assert_eq!( + pretty, sub_indented, + "nested sub pretty printing did not match: {pretty} != {sub_indented}" + ); + + let pretty = sub_selection.pretty_print_with_indentation(false, 4); + assert_eq!( + pretty, + sub_super_indented.trim_start(), + "nested inline sub pretty printing did not match: {pretty} != {}", + sub_super_indented.trim_start() + ); + } + + #[test] + fn it_prints_root_selection() { + let root_selection = JSONSelection::parse("id name").unwrap(); + test_permutations(root_selection, "id\nname"); + } +} diff --git a/apollo-federation/src/connectors/json_selection/selection_set.rs b/apollo-federation/src/connectors/json_selection/selection_set.rs new file mode 100644 index 0000000000..d37d0b8189 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/selection_set.rs @@ -0,0 +1,742 @@ +//! Functions for applying a [`SelectionSet`] to a [`JSONSelection`]. This creates a new +//! `JSONSelection` mapping to the fields on the selection set, and excluding parts of the +//! original `JSONSelection` that are not needed by the selection set. + +#![cfg_attr( + not(test), + deny( + clippy::exit, + clippy::panic, + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::unimplemented, + clippy::todo, + missing_docs + ) +)] + +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Node; +use apollo_compiler::executable::Field; +use apollo_compiler::executable::FieldSet; +use apollo_compiler::executable::Selection; +use apollo_compiler::executable::SelectionSet; +use apollo_compiler::name; +use multimap::MultiMap; + +use super::known_var::KnownVariable; +use super::lit_expr::LitExpr; +use super::location::Ranged; +use super::location::WithRange; +use super::parser::MethodArgs; +use super::parser::PathList; +use crate::connectors::JSONSelection; +use crate::connectors::PathSelection; +use crate::connectors::SubSelection; +use crate::connectors::json_selection::Alias; +use crate::connectors::json_selection::NamedSelection; +use crate::connectors::json_selection::NamingPrefix; +use crate::connectors::json_selection::TopLevelSelection; + +impl JSONSelection { + /// Apply a selection set to create a new [`JSONSelection`] + /// + /// Operations from the query planner will never contain key fields (because + /// it already has them from a previous fetch) but we might need those + /// fields for things like sorting entities in a batch. If the optional + /// `required_keys` is provided, we'll merge those fields into the selection + /// set before applying it to the JSONSelection. + pub fn apply_selection_set( + &self, + document: &ExecutableDocument, + selection_set: &SelectionSet, + required_keys: Option<&FieldSet>, + ) -> Self { + let selection_set = required_keys.map_or_else( + || selection_set.clone(), + |keys| { + keys.selection_set.selections.iter().cloned().fold( + selection_set.clone(), + |mut acc, selection| { + acc.push(selection); + acc + }, + ) + }, + ); + + match &self.inner { + TopLevelSelection::Named(sub) => Self { + inner: TopLevelSelection::Named(sub.apply_selection_set(document, &selection_set)), + spec: self.spec, + }, + TopLevelSelection::Path(path) => Self { + inner: TopLevelSelection::Path(path.apply_selection_set(document, &selection_set)), + spec: self.spec, + }, + } + } +} + +impl SubSelection { + /// Apply a selection set to create a new [`SubSelection`] + pub fn apply_selection_set( + &self, + document: &ExecutableDocument, + selection_set: &SelectionSet, + ) -> Self { + let mut new_selections = Vec::new(); + let field_map = map_fields_by_name(document, selection_set); + + // When the operation contains __typename, it might be used to complete + // an entity reference (e.g. `__typename id`) for a subsequent fetch. + // + // NOTE: For reasons I don't understand, persisted queries may contain + // `__typename` for `_entities` queries. We never want to emit + // `__typename: "_Entity"`, so we'll guard against that case. + // + // TODO: this must change before we support interfaces and unions + // because it will emit the abstract type's name which is invalid. + if field_map.contains_key("__typename") && selection_set.ty != name!(_Entity) { + new_selections.push(NamedSelection { + prefix: NamingPrefix::Alias(Alias::new("__typename")), + path: PathSelection { + path: WithRange::new( + PathList::Var( + WithRange::new(KnownVariable::Dollar, None), + WithRange::new( + PathList::Method( + WithRange::new("echo".to_string(), None), + Some(MethodArgs { + args: vec![WithRange::new( + LitExpr::String(selection_set.ty.to_string()), + None, + )], + ..Default::default() + }), + WithRange::new(PathList::Empty, None), + ), + None, + ), + ), + None, + ), + }, + }); + } + + for selection in &self.selections { + if let Some(single_key_for_selection) = selection.get_single_key() { + if let Some(fields) = field_map.get_vec(single_key_for_selection.as_str()) { + for field in fields { + let response_key = field.response_key().as_str(); + let applied_path = selection + .path + .apply_selection_set(document, &field.selection_set); + + if let Some(single_key_for_path_only) = applied_path.get_single_key() { + if response_key == single_key_for_path_only.as_str() { + // No need for an Alias if the path by + // itself has a single key that equals + // the desired response_key. + new_selections.push(NamedSelection { + prefix: NamingPrefix::None, + path: applied_path, + }); + continue; + } + + if response_key == single_key_for_selection.as_str() { + // The response_key matched the existing alias, + // so we don't need to change the alias. + new_selections.push(NamedSelection { + prefix: selection.prefix.clone(), + path: applied_path, + }); + continue; + } + } + + // The response_key is different from both the + // alias and single_key_for_path_only, so we + // need a new explicit alias to ensure + // response_key is generated. + new_selections.push(NamedSelection { + prefix: NamingPrefix::Alias(Alias::new(response_key)), + path: applied_path, + }); + } + } + } else { + // If the NamedSelection::Path does not have a single + // output key (has no alias and is not a single field + // selection), then the path's output will be inlined + // into the parent, which means we care only about the + // intersection between the path's output and the + // incoming selection_set. + new_selections.push(NamedSelection { + prefix: NamingPrefix::None, + path: selection.path.apply_selection_set(document, selection_set), + }); + } + } + + Self { + selections: new_selections, + // Keep the old range even though it may be inaccurate after the + // removal of selections, since it still indicates where the + // original SubSelection came from. + range: self.range.clone(), + } + } +} + +impl PathSelection { + /// Apply a selection set to create a new [`PathSelection`] + pub fn apply_selection_set( + &self, + document: &ExecutableDocument, + selection_set: &SelectionSet, + ) -> Self { + Self { + path: WithRange::new( + self.path.apply_selection_set(document, selection_set), + self.path.range(), + ), + } + } +} + +impl PathList { + pub(crate) fn apply_selection_set( + &self, + document: &ExecutableDocument, + selection_set: &SelectionSet, + ) -> Self { + match self { + Self::Var(name, path) => Self::Var( + name.clone(), + WithRange::new( + path.apply_selection_set(document, selection_set), + path.range(), + ), + ), + Self::Key(key, path) => Self::Key( + key.clone(), + WithRange::new( + path.apply_selection_set(document, selection_set), + path.range(), + ), + ), + Self::Expr(expr, path) => Self::Expr( + expr.clone(), + WithRange::new( + path.apply_selection_set(document, selection_set), + path.range(), + ), + ), + Self::Method(method_name, args, path) => Self::Method( + method_name.clone(), + args.clone(), + WithRange::new( + path.apply_selection_set(document, selection_set), + path.range(), + ), + ), + Self::Question(tail) => Self::Question(WithRange::new( + tail.apply_selection_set(document, selection_set), + tail.range(), + )), + Self::Selection(sub) => { + Self::Selection(sub.apply_selection_set(document, selection_set)) + } + Self::Empty => Self::Empty, + } + } +} + +fn map_fields_by_name<'a>( + document: &'a ExecutableDocument, + set: &'a SelectionSet, +) -> MultiMap> { + let mut map = MultiMap::new(); + map_fields_by_name_impl(document, set, &mut map); + map +} + +fn map_fields_by_name_impl<'a>( + document: &'a ExecutableDocument, + set: &'a SelectionSet, + map: &mut MultiMap>, +) { + for selection in &set.selections { + match selection { + Selection::Field(field) => { + map.insert(field.name.to_string(), field); + } + Selection::FragmentSpread(f) => { + if let Some(fragment) = f.fragment_def(document) { + map_fields_by_name_impl(document, &fragment.selection_set, map); + } + } + Selection::InlineFragment(fragment) => { + map_fields_by_name_impl(document, &fragment.selection_set, map); + } + } + } +} + +#[cfg(test)] +mod tests { + use apollo_compiler::ExecutableDocument; + use apollo_compiler::Schema; + use apollo_compiler::executable::FieldSet; + use apollo_compiler::executable::SelectionSet; + use apollo_compiler::name; + use apollo_compiler::validation::Valid; + use pretty_assertions::assert_eq; + + use crate::assert_snapshot; + + fn selection_set(schema: &Valid, s: &str) -> (ExecutableDocument, SelectionSet) { + let document = ExecutableDocument::parse_and_validate(schema, s, "./").unwrap(); + let selection_set = document + .operations + .anonymous + .as_ref() + .unwrap() + .selection_set + .fields() + .next() + .unwrap() + .selection_set + .clone(); + (document.into_inner(), selection_set) + } + + #[test] + fn test() { + let json = super::JSONSelection::parse( + r###" + $.result { + a + b: c + d: e.f + g + h: 'i-j' + k: { l m: n } + } + "###, + ) + .unwrap(); + + let schema = Schema::parse_and_validate( + r###" + type Query { + t: T + } + + type T { + a: String + b: String + d: String + g: String + h: String + k: K + } + + type K { + l: String + m: String + } + "###, + "./", + ) + .unwrap(); + + let (document, selection_set) = selection_set( + &schema, + "{ t { z: a, y: b, x: d, w: h v: k { u: l t: m } } }", + ); + + let transformed = json.apply_selection_set(&document, &selection_set, None); + assert_eq!( + transformed.to_string(), + r###"$.result { + z: a + y: c + x: e.f + w: "i-j" + v: { + u: l + t: n + } +}"### + ); + } + + #[test] + fn test_star() { + let json_selection = super::JSONSelection::parse( + r###" + $.result { + a + b_alias: b + c { + d + e_alias: e + h: "h" + i: "i" + group: { + j + k + } + } + path_to_f: c.f + } + "###, + ) + .unwrap(); + + let schema = Schema::parse_and_validate( + r###" + type Query { + t: T + } + + type T { + a: String + b_alias: String + c: C + path_to_f: String + } + + type C { + d: String + e_alias: String + h: String + i: String + group: Group + } + + type Group { + j: String + k: String + } + "###, + "./", + ) + .unwrap(); + + let (document, selection_set) = selection_set( + &schema, + "{ t { a b_alias c { e: e_alias h group { j } } path_to_f } }", + ); + + let transformed = json_selection.apply_selection_set(&document, &selection_set, None); + assert_eq!( + transformed.to_string(), + r###"$.result { + a + b_alias: b + c { + e + "h" + group: { + j + } + } + path_to_f: c.f +}"### + ); + + let data = serde_json_bytes::json!({ + "result": { + "a": "a", + "b": "b", + "c": { + "d": "d", + "e": "e", + "f": "f", + "g": "g", + "h": "h", + "i": "i", + "j": "j", + "k": "k", + }, + } + }); + let result = transformed.apply_to(&data); + assert_eq!( + result, + ( + Some(serde_json_bytes::json!( + { + "a": "a", + "b_alias": "b", + "c": { + "e": "e", + "h": "h", + "group": { + "j": "j" + }, + }, + "path_to_f": "f", + })), + vec![] + ) + ); + } + + #[test] + fn test_depth() { + let json = super::JSONSelection::parse( + r###" + $.result { + a { + b { + renamed: c + } + } + } + "###, + ) + .unwrap(); + + let schema = Schema::parse_and_validate( + r###" + type Query { + t: T + } + + type T { + a: A + } + + type A { + b: B + } + + type B { + renamed: String + } + "###, + "./", + ) + .unwrap(); + + let (document, selection_set) = selection_set(&schema, "{ t { a { b { renamed } } } }"); + + let transformed = json.apply_selection_set(&document, &selection_set, None); + assert_eq!( + transformed.to_string(), + r###"$.result { + a { + b { + renamed: c + } + } +}"### + ); + + let data = serde_json_bytes::json!({ + "result": { + "a": { + "b": { + "c": "c", + } + } + } + } + ); + let result = transformed.apply_to(&data); + assert_eq!( + result, + ( + Some(serde_json_bytes::json!({"a": { "b": { "renamed": "c" } } } )), + vec![] + ) + ); + } + + #[test] + fn test_typename() { + let json = super::JSONSelection::parse( + r###" + $.result { + id + author: { + id: authorId + } + } + "###, + ) + .unwrap(); + + let schema = Schema::parse_and_validate( + r###" + type Query { + t: T + } + + type T { + id: ID + author: A + } + + type A { + id: ID + } + "###, + "./", + ) + .unwrap(); + + let (document, selection_set) = + selection_set(&schema, "{ t { id __typename author { __typename id } } }"); + + let transformed = json.apply_selection_set(&document, &selection_set, None); + assert_eq!( + transformed.to_string(), + r###"$.result { + __typename: $->echo("T") + id + author: { + __typename: $->echo("A") + id: authorId + } +}"### + ); + } + + #[test] + fn test_fragments() { + let json = super::JSONSelection::parse( + r###" + reviews: result { + id + product: { upc: product_upc } + author: { id: author_id } + } + "###, + ) + .unwrap(); + + let schema = Schema::parse_and_validate( + r###" + type Query { + _entities(representations: [_Any!]!): [_Entity] + } + + scalar _Any + + union _Entity = Product + + type Product { + upc: String + reviews: [Review] + } + + type Review { + id: ID + product: Product + author: User + } + + type User { + id: ID + } + "###, + "./", + ) + .unwrap(); + + let (document, selection_set) = selection_set( + &schema, + "query ($representations: [_Any!]!) { + _entities(representations: $representations) { + ..._generated_onProduct1_0 + } + } + fragment _generated_onProduct1_0 on Product { + reviews { + id + product { + __typename + upc + } + author { + __typename + id + } + } + }", + ); + + let transformed = json.apply_selection_set(&document, &selection_set, None); + assert_eq!( + transformed.to_string(), + r###"reviews: result { + id + product: { + __typename: $->echo("Product") + upc: product_upc + } + author: { + __typename: $->echo("User") + id: author_id + } +}"### + ); + } + + #[test] + fn test_ensuring_key_fields() { + let json = super::JSONSelection::parse( + r###" + id + store { id } + name + price + "###, + ) + .unwrap(); + + let schema = Schema::parse_and_validate( + r###" + type Query { + product: Product + } + + type Product { + id: ID! + store: Store! + name: String! + price: String + } + + type Store { + id: ID! + } + "###, + "./", + ) + .unwrap(); + + let (document, selection_set) = selection_set(&schema, "{ product { name price } }"); + + let keys = + FieldSet::parse_and_validate(&schema, name!(Product), "id store { id }", "").unwrap(); + + let transformed = json.apply_selection_set(&document, &selection_set, Some(&keys)); + assert_snapshot!(transformed.to_string(), @r" + id + store { + id + } + name + price + "); + } +} diff --git a/apollo-federation/src/connectors/json_selection/selection_trie.rs b/apollo-federation/src/connectors/json_selection/selection_trie.rs new file mode 100644 index 0000000000..00d4b30f9b --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/selection_trie.rs @@ -0,0 +1,435 @@ +use std::fmt::Display; +use std::hash::Hash; +use std::hash::Hasher; +use std::ops::Range; + +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; + +use super::JSONSelection; +use super::Key; +use super::PathList; +use super::PathSelection; +use super::Ranged; +use super::SubSelection; +use super::helpers::quote_if_necessary; +use super::location::WithRange; + +impl JSONSelection { + #[cfg(test)] + pub(crate) fn compute_selection_trie(&self) -> SelectionTrie { + let mut trie = SelectionTrie::new(); + + // TODO Neither external_var_paths nor the root_trie logic below + // properly considers "internal" variables like $ and @, even though + // they could potentially refer to external input data. This state of + // affairs could be improved by examining the tail of each + // &PathSelection for those variables, even if we cannot (yet) + // understand their usage in all cases, such as after an -> method call. + // Ultimately, getting this completely right will require support from + // the shape library tracking the names of all shapes. + + use super::ExternalVarPaths; + use crate::connectors::json_selection::TopLevelSelection; + for path in self.external_var_paths() { + if let PathList::Var(known_var, tail) = path.path.as_ref() { + trie.add_str_with_ranges(known_var.as_str(), path.range()) + .add_path_list(tail); + } else { + // The self.external_var_paths() method should only return + // PathSelection elements whose path starts with PathList::Var. + } + } + + let mut root_trie = SelectionTrie::new(); + match &self.inner { + TopLevelSelection::Path(path) => { + root_trie.add_path_list(&path.path); + } + TopLevelSelection::Named(selection) => { + root_trie.add_subselection(selection); + } + }; + trie.add_str("$root").extend(&root_trie); + + trie + } +} + +impl WithRange { + pub(super) fn compute_selection_trie(&self) -> SelectionTrie { + let mut trie = SelectionTrie::new(); + trie.add_path_list(self); + trie + } +} + +type Ref = std::sync::Arc; + +#[derive(Debug, Eq, Clone)] +pub(crate) struct SelectionTrie { + /// The top-level sub-selections of this [`SelectionTrie`]. + selections: IndexMap>, + + /// Whether the path terminating with this [`SelectionTrie`] node was + /// explicitly added to the trie, rather than existing only as a prefix of + /// other paths that have been added. + is_leaf: bool, + + /// Collected as metadata but ignored by [`PartialEq`] and [`Hash`]. + key_ranges: IndexMap>>, +} + +impl Display for SelectionTrie { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut need_space = false; + + for (key, sub) in self.selections.iter() { + if need_space { + write!(f, " ")?; + } + + if sub.is_empty() { + if sub.is_leaf() { + write!(f, "{}", quote_if_necessary(key))?; + need_space = true; + } + } else { + write!(f, "{} {{ {} }}", quote_if_necessary(key), sub)?; + need_space = true; + } + } + + Ok(()) + } +} + +impl PartialEq for SelectionTrie { + fn eq(&self, other: &Self) -> bool { + self.is_leaf == other.is_leaf && self.selections == other.selections + } +} + +impl Hash for SelectionTrie { + fn hash(&self, state: &mut H) { + self.selections + .iter() + .fold(0, |acc, (key, sub)| { + let mut hasher = std::hash::DefaultHasher::default(); + (key, sub).hash(&mut hasher); + acc ^ hasher.finish() + }) + .hash(state); + } +} + +impl SelectionTrie { + pub(crate) fn new() -> Self { + Self { + is_leaf: false, + selections: IndexMap::default(), + key_ranges: IndexMap::default(), + } + } + + pub(crate) fn is_empty(&self) -> bool { + self.selections.is_empty() + } + + pub(crate) fn keys(&self) -> impl Iterator { + self.selections.keys() + } + + pub(crate) fn get(&self, key: impl Into) -> Option<&SelectionTrie> { + self.selections.get(&key.into()).map(|sub| sub.as_ref()) + } + + pub(crate) fn iter(&self) -> impl Iterator { + self.selections + .iter() + .map(|(key, sub)| (key.as_str(), sub.as_ref())) + } + + pub(crate) fn key_ranges(&self, key: &str) -> impl Iterator> { + self.key_ranges + .get(key) + .into_iter() + .flat_map(|ranges| ranges.iter()) + .cloned() + } + + #[cfg(test)] + pub(crate) fn has_str_path<'a>(&self, path: impl IntoIterator) -> bool { + let mut current = self; + for key in path { + if let Some(sub) = current.get(key) { + current = sub; + } else { + return false; + } + } + current.is_leaf() + } + + #[cfg(test)] + pub(crate) fn add_str_path<'a>( + &mut self, + path: impl IntoIterator, + ) -> &mut Self { + path.into_iter() + .fold(self, |trie, key| trie.add_str(key)) + .set_leaf() + } + + pub(crate) fn add_path_selection(&mut self, path: &PathSelection) -> &mut Self { + self.add_path_list(&path.path) + } + + fn add_path_list(&mut self, path_list: &WithRange) -> &mut Self { + match path_list.as_ref() { + PathList::Key(key, tail) => self.add_key(key).add_path_list(tail), + PathList::Selection(sub) => self.add_subselection(sub), + // If we get to the end of the PathList, mark the path used. + PathList::Empty => self.set_leaf(), + // TODO Support PathList::Method and inputs used within method + // arguments. For now, assume we use the whole path up to the + // unhandled PathList element. + _ => self.set_leaf(), + } + } + + pub(crate) fn add_subselection(&mut self, sub: &SubSelection) -> &mut Self { + for selection in sub.selections_iter() { + self.add_path_selection(&selection.path); + } + self + } + + pub(crate) fn extend(&mut self, other: &SelectionTrie) -> &mut Self { + for (key, sub) in other.selections.iter() { + if let Some(existing) = self.selections.get_mut(key) { + Ref::make_mut(existing).extend(sub); + } else { + // Because sub is an Arc, this clone should be much cheaper than + // inserting an empty trie and then recursively extending it + // while traversing sub. + self.selections.insert(key.clone(), sub.clone()); + } + // Whether or not the key already existed, we update self.key_ranges + // the same way: + self.key_ranges + .entry(key.clone()) + .or_default() + .extend(other.key_ranges(key)); + } + if self.is_leaf() || other.is_leaf() { + self.set_leaf() + } else { + self + } + } + + /// Like [`SelectionTrie::extend`] but producing a new SelectionTrie + /// instance instead of modifying self. + #[cfg(test)] + pub(crate) fn merge(&self, other: &SelectionTrie) -> Self { + let mut merged = SelectionTrie::new(); + merged.extend(self); + merged.extend(other); + merged + } + + fn add_str(&mut self, key: &str) -> &mut Self { + Ref::make_mut( + self.selections + .entry(key.to_string()) + .or_insert_with(|| Ref::new(SelectionTrie::new())), + ) + } + + fn add_str_with_ranges( + &mut self, + key: &str, + ranges: impl IntoIterator>, + ) -> &mut Self { + self.key_ranges + .entry(key.to_string()) + .or_default() + .extend(ranges); + self.add_str(key) + } + + fn add_key(&mut self, key: &WithRange) -> &mut Self { + self.add_str_with_ranges(key.as_str(), key.range()) + } + + fn set_leaf(&mut self) -> &mut Self { + self.is_leaf = true; + self + } + + pub(crate) fn is_leaf(&self) -> bool { + self.is_leaf + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::selection; + + #[test] + fn test_empty() { + let trie = SelectionTrie::new(); + assert!(trie.is_empty()); + assert_eq!(trie.keys().count(), 0); + assert_eq!(trie.iter().count(), 0); + assert_eq!(trie.key_ranges("field").count(), 0); + assert!(!trie.is_leaf()); + + let empty_leaf = { + let mut trie = SelectionTrie::new(); + trie.set_leaf(); + trie + }; + assert!(empty_leaf.is_empty()); + assert_eq!(empty_leaf.keys().count(), 0); + assert_eq!(empty_leaf.iter().count(), 0); + assert_eq!(empty_leaf.key_ranges("saves").count(), 0); + assert!(empty_leaf.is_leaf()); + } + + #[test] + fn test_selection_trie_add_key() { + let mut trie = SelectionTrie::new(); + trie.add_key(&WithRange::new(Key::Field("field".to_string()), Some(0..5))) + .set_leaf(); + + assert!(!trie.is_empty()); + assert_eq!(trie.keys().count(), 1); + assert_eq!(trie.key_ranges("field").count(), 1); + assert!(!trie.is_leaf()); + + assert!(trie.set_leaf().is_leaf()); + assert!(trie.is_leaf()); + + assert_eq!(trie.key_ranges("field").collect::>(), vec![0..5]); + + trie.add_key(&WithRange::new( + Key::Field("field".to_string()), + Some(5..10), + )) + .set_leaf(); + assert_eq!( + trie.key_ranges("field").collect::>(), + vec![0..5, 5..10] + ); + assert_eq!(trie.keys().count(), 1); + + trie.add_key(&WithRange::new( + Key::Field("other".to_string()), + Some(15..20), + )) + .set_leaf(); + assert_eq!(trie.keys().count(), 2); + assert_eq!(trie.key_ranges("other").collect::>(), vec![15..20]); + assert_eq!( + trie.key_ranges("field").collect::>(), + vec![0..5, 5..10] + ); + assert!(trie.is_leaf()); + + assert_eq!(trie.to_string(), "field other"); + } + + #[test] + fn test_selection_trie_add_path() { + let mut trie = SelectionTrie::new(); + trie.add_str_path(["a", "b", "c"]); + + assert!(!trie.is_empty()); + assert_eq!(trie.keys().count(), 1); + assert_eq!(trie.key_ranges("a").count(), 0); + assert_eq!(trie.key_ranges("b").count(), 0); + assert_eq!(trie.key_ranges("c").count(), 0); + assert!(!trie.is_leaf()); + assert_eq!(trie.to_string(), "a { b { c } }"); + + assert!(trie.has_str_path(["a", "b", "c"])); + assert!(!trie.has_str_path(["a", "b"])); + assert!(!trie.has_str_path(["a"])); + assert!(!trie.has_str_path(["b"])); + assert!(!trie.has_str_path(["c"])); + assert!(!trie.has_str_path(["a", "b", "c", "d"])); + assert!(!trie.has_str_path(["a", "b", "c", "d", "e"])); + assert!(!trie.has_str_path([])); + + trie.add_str_path(["a", "c", "e"]); + assert!(trie.has_str_path(["a", "c", "e"])); + assert!(!trie.has_str_path(["a", "c"])); + assert!(!trie.has_str_path(["a"])); + assert!(!trie.has_str_path(["c"])); + assert!(!trie.has_str_path(["e"])); + assert!(!trie.has_str_path(["a", "c", "e", "f"])); + assert!(!trie.has_str_path(["a", "c", "e", "f", "g"])); + assert!(!trie.has_str_path([])); + + trie.add_str_path([]); + assert!(trie.has_str_path([])); + assert!(!trie.has_str_path(["a"])); + + assert_eq!(trie.to_string(), "a { b { c } c { e } }"); + } + + #[test] + fn test_selection_trie_merge() { + let mut trie1 = SelectionTrie::new(); + trie1.add_str_path(["a", "b", "c"]); + trie1.add_str_path(["a", "d", "e"]); + assert_eq!(trie1.to_string(), "a { b { c } d { e } }"); + + let mut trie2 = SelectionTrie::new(); + trie2.add_str_path(["a", "b", "f"]); + trie2.add_str_path(["g", "h"]); + assert_eq!(trie2.to_string(), "a { b { f } } g { h }"); + + let mut merged = trie1.merge(&trie2); + assert_eq!(merged.to_string(), "a { b { c f } d { e } } g { h }"); + + let merged_2_with_1 = trie2.merge(&trie1); + assert_eq!( + merged_2_with_1.to_string(), + "a { b { f c } d { e } } g { h }", + ); + + merged.add_str_path(["a", "b", "x", "y"]); + + assert_eq!( + merged.to_string(), + "a { b { c f x { y } } d { e } } g { h }" + ); + assert_eq!( + merged_2_with_1.to_string(), + "a { b { f c } d { e } } g { h }", + ); + assert_eq!(trie1.to_string(), "a { b { c } d { e } }"); + assert_eq!(trie2.to_string(), "a { b { f } } g { h }"); + } + + #[test] + fn test_whole_selection_trie() { + assert_eq!( + selection!("a { b { c } d { e } }") + .compute_selection_trie() + .to_string(), + "$root { a { b { c } d { e } } }", + ); + + assert_eq!( + selection!("a { b { c: $args.c } d { e: $this.e } }") + .compute_selection_trie() + .to_string(), + "$args { c } $this { e } $root { a { b d } }", + ); + } +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/arrow_path_ranges.snap b/apollo-federation/src/connectors/json_selection/snapshots/arrow_path_ranges.snap new file mode 100644 index 0000000000..3b54f38a73 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/arrow_path_ranges.snap @@ -0,0 +1,84 @@ +--- +source: apollo-federation/src/connectors/json_selection/location.rs +expression: parsed +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "__typename", + ), + range: Some( + 2..12, + ), + }, + range: Some( + 2..13, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: @, + range: Some( + 14..15, + ), + }, + WithRange { + node: Method( + WithRange { + node: "echo", + range: Some( + 19..23, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: String( + "Frog", + ), + range: Some( + 26..32, + ), + }, + ], + range: Some( + 24..36, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 36..36, + ), + }, + ), + range: Some( + 16..36, + ), + }, + ), + range: Some( + 14..36, + ), + }, + }, + }, + ], + range: Some( + 2..36, + ), + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-10.snap b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-10.snap new file mode 100644 index 0000000000..5dbfd81abf --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-10.snap @@ -0,0 +1,166 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a{...b c d...e}\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 5..8, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 8..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 8..9, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 10..11, + ), + }, + WithRange { + node: Empty, + range: Some( + 11..11, + ), + }, + ), + range: Some( + 10..11, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "d", + ), + range: Some( + 12..13, + ), + }, + WithRange { + node: Empty, + range: Some( + 13..13, + ), + }, + ), + range: Some( + 12..13, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + Some( + 13..16, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "e", + ), + range: Some( + 16..17, + ), + }, + WithRange { + node: Empty, + range: Some( + 17..17, + ), + }, + ), + range: Some( + 16..17, + ), + }, + }, + }, + ], + range: Some( + 4..18, + ), + }, + ), + range: Some( + 4..18, + ), + }, + ), + range: Some( + 3..18, + ), + }, + }, + }, + ], + range: Some( + 0..18, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-2.snap b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-2.snap new file mode 100644 index 0000000000..49d142d400 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-2.snap @@ -0,0 +1,76 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a...b\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + Some( + 4..7, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 7..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 7..8, + ), + }, + }, + }, + ], + range: Some( + 0..8, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-3.snap b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-3.snap new file mode 100644 index 0000000000..6706787b90 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-3.snap @@ -0,0 +1,72 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"a...b\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Empty, + range: Some( + 1..1, + ), + }, + ), + range: Some( + 0..1, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + Some( + 1..4, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 4..5, + ), + }, + WithRange { + node: Empty, + range: Some( + 5..5, + ), + }, + ), + range: Some( + 4..5, + ), + }, + }, + }, + ], + range: Some( + 0..5, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-4.snap b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-4.snap new file mode 100644 index 0000000000..8ca4b0b450 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-4.snap @@ -0,0 +1,72 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a b\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + ], + range: Some( + 0..6, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-5.snap b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-5.snap new file mode 100644 index 0000000000..1aeb82b0bb --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-5.snap @@ -0,0 +1,98 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a b c\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 7..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 7..8, + ), + }, + }, + }, + ], + range: Some( + 0..8, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-6.snap b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-6.snap new file mode 100644 index 0000000000..10346f1856 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-6.snap @@ -0,0 +1,80 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a{b}\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + ], + range: Some( + 4..7, + ), + }, + ), + range: Some( + 4..7, + ), + }, + ), + range: Some( + 3..7, + ), + }, + }, + }, + ], + range: Some( + 0..7, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-7.snap b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-7.snap new file mode 100644 index 0000000000..0d53faf547 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-7.snap @@ -0,0 +1,106 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a{b c}\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 7..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 7..8, + ), + }, + }, + }, + ], + range: Some( + 4..9, + ), + }, + ), + range: Some( + 4..9, + ), + }, + ), + range: Some( + 3..9, + ), + }, + }, + }, + ], + range: Some( + 0..9, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-8.snap b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-8.snap new file mode 100644 index 0000000000..7e2fa04527 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-8.snap @@ -0,0 +1,110 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a{b...c}\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + Some( + 6..9, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 9..10, + ), + }, + WithRange { + node: Empty, + range: Some( + 10..10, + ), + }, + ), + range: Some( + 9..10, + ), + }, + }, + }, + ], + range: Some( + 4..11, + ), + }, + ), + range: Some( + 4..11, + ), + }, + ), + range: Some( + 3..11, + ), + }, + }, + }, + ], + range: Some( + 0..11, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-9.snap b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-9.snap new file mode 100644 index 0000000000..8fddf32c3b --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing-9.snap @@ -0,0 +1,136 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a{b...c d}\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + Some( + 6..9, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 9..10, + ), + }, + WithRange { + node: Empty, + range: Some( + 10..10, + ), + }, + ), + range: Some( + 9..10, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "d", + ), + range: Some( + 11..12, + ), + }, + WithRange { + node: Empty, + range: Some( + 12..12, + ), + }, + ), + range: Some( + 11..12, + ), + }, + }, + }, + ], + range: Some( + 4..13, + ), + }, + ), + range: Some( + 4..13, + ), + }, + ), + range: Some( + 3..13, + ), + }, + }, + }, + ], + range: Some( + 0..13, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing.snap b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing.snap new file mode 100644 index 0000000000..bb9b04182d --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing.snap @@ -0,0 +1,46 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + ], + range: Some( + 0..4, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing_one_field.snap b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing_one_field.snap new file mode 100644 index 0000000000..bb9b04182d --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/basic_spread_parsing_one_field.snap @@ -0,0 +1,46 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + ], + range: Some( + 0..4, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/check_many.snap b/apollo-federation/src/connectors/json_selection/snapshots/check_many.snap new file mode 100644 index 0000000000..75be59056f --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/check_many.snap @@ -0,0 +1,46 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(expected_pretty, spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 4..5, + ), + }, + WithRange { + node: Empty, + range: Some( + 5..5, + ), + }, + ), + range: Some( + 4..5, + ), + }, + }, + }, + ], + range: Some( + 0..5, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots-2.snap b/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots-2.snap new file mode 100644 index 0000000000..491507886b --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots-2.snap @@ -0,0 +1,12 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "JSONSelection::parse(\"id $.object\")" +--- +Err( + JSONSelectionParseError { + message: "Named path selection must either begin with alias or ..., or end with subselection", + fragment: "$.object", + offset: 3, + spec: V0_2, + }, +) diff --git a/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots.snap b/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots.snap new file mode 100644 index 0000000000..89549bf906 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots.snap @@ -0,0 +1,12 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "JSONSelection::parse(\".data\")" +--- +Err( + JSONSelectionParseError { + message: "Key paths cannot start with just .key (use $.key instead)", + fragment: ".data", + offset: 0, + spec: V0_2, + }, +) diff --git a/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_2-2.snap b/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_2-2.snap new file mode 100644 index 0000000000..ca40254c84 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_2-2.snap @@ -0,0 +1,12 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "JSONSelection::parse_with_spec(\"id $.object\", spec)" +--- +Err( + JSONSelectionParseError { + message: "Named path selection must either begin with alias or ..., or end with subselection", + fragment: "$.object", + offset: 3, + spec: V0_2, + }, +) diff --git a/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_2.snap b/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_2.snap new file mode 100644 index 0000000000..7417a39d11 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_2.snap @@ -0,0 +1,12 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "JSONSelection::parse_with_spec(\".data\", spec)" +--- +Err( + JSONSelectionParseError { + message: "Key paths cannot start with just .key (use $.key instead)", + fragment: ".data", + offset: 0, + spec: V0_2, + }, +) diff --git a/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_3-2.snap b/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_3-2.snap new file mode 100644 index 0000000000..cc4505e1f8 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_3-2.snap @@ -0,0 +1,12 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "JSONSelection::parse_with_spec(\"id $.object\", spec)" +--- +Err( + JSONSelectionParseError { + message: "SubSelection cannot contain multiple elements if it contains an anonymous NamedSelection", + fragment: "id $.object", + offset: 0, + spec: V0_3, + }, +) diff --git a/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_3.snap b/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_3.snap new file mode 100644 index 0000000000..a75442dfa8 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/error_snapshots_v0_3.snap @@ -0,0 +1,12 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "JSONSelection::parse_with_spec(\".data\", spec)" +--- +Err( + JSONSelectionParseError { + message: "Key paths cannot start with just .key (use $.key instead)", + fragment: ".data", + offset: 0, + spec: V0_3, + }, +) diff --git a/apollo-federation/src/connectors/json_selection/snapshots/expr_path_selections-2.snap b/apollo-federation/src/connectors/json_selection/snapshots/expr_path_selections-2.snap new file mode 100644 index 0000000000..ff2dbd499e --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/expr_path_selections-2.snap @@ -0,0 +1,173 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\" suffix : results -> slice ( $( - 1 ) -> mul ( $args . suffixLength ) ) \",\nspec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "suffix", + ), + range: Some( + 1..7, + ), + }, + range: Some( + 1..9, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "results", + ), + range: Some( + 10..17, + ), + }, + WithRange { + node: Method( + WithRange { + node: "slice", + range: Some( + 21..26, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Expr( + WithRange { + node: Number( + Number(-1), + ), + range: Some( + 32..35, + ), + }, + WithRange { + node: Method( + WithRange { + node: "mul", + range: Some( + 41..44, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 47..52, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "suffixLength", + ), + range: Some( + 55..67, + ), + }, + WithRange { + node: Empty, + range: Some( + 67..67, + ), + }, + ), + range: Some( + 53..67, + ), + }, + ), + range: Some( + 47..67, + ), + }, + }, + ), + range: Some( + 47..67, + ), + }, + ], + range: Some( + 45..69, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 69..69, + ), + }, + ), + range: Some( + 38..69, + ), + }, + ), + range: Some( + 29..69, + ), + }, + }, + ), + range: Some( + 29..69, + ), + }, + ], + range: Some( + 27..71, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 71..71, + ), + }, + ), + range: Some( + 18..71, + ), + }, + ), + range: Some( + 10..71, + ), + }, + }, + }, + ], + range: Some( + 1..71, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/expr_path_selections.snap b/apollo-federation/src/connectors/json_selection/snapshots/expr_path_selections.snap new file mode 100644 index 0000000000..4766c280a0 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/expr_path_selections.snap @@ -0,0 +1,173 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\" suffix : results -> slice ( $( - 1 ) -> mul ( $args . suffixLength ) ) \")" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "suffix", + ), + range: Some( + 1..7, + ), + }, + range: Some( + 1..9, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "results", + ), + range: Some( + 10..17, + ), + }, + WithRange { + node: Method( + WithRange { + node: "slice", + range: Some( + 21..26, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Expr( + WithRange { + node: Number( + Number(-1), + ), + range: Some( + 32..35, + ), + }, + WithRange { + node: Method( + WithRange { + node: "mul", + range: Some( + 41..44, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 47..52, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "suffixLength", + ), + range: Some( + 55..67, + ), + }, + WithRange { + node: Empty, + range: Some( + 67..67, + ), + }, + ), + range: Some( + 53..67, + ), + }, + ), + range: Some( + 47..67, + ), + }, + }, + ), + range: Some( + 47..67, + ), + }, + ], + range: Some( + 45..69, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 69..69, + ), + }, + ), + range: Some( + 38..69, + ), + }, + ), + range: Some( + 29..69, + ), + }, + }, + ), + range: Some( + 29..69, + ), + }, + ], + range: Some( + 27..71, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 71..71, + ), + }, + ), + range: Some( + 18..71, + ), + }, + ), + range: Some( + 10..71, + ), + }, + }, + }, + ], + range: Some( + 1..71, + ), + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-2.snap b/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-2.snap new file mode 100644 index 0000000000..a76c25498b --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-2.snap @@ -0,0 +1,107 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: selection_hello_slice_v0_2 +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "sliced", + ), + range: Some( + 0..6, + ), + }, + range: Some( + 0..7, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Expr( + WithRange { + node: LitPath( + WithRange { + node: String( + "hello", + ), + range: Some( + 10..17, + ), + }, + WithRange { + node: Method( + WithRange { + node: "slice", + range: Some( + 19..24, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Number( + Number(1), + ), + range: Some( + 25..26, + ), + }, + WithRange { + node: Number( + Number(3), + ), + range: Some( + 28..29, + ), + }, + ], + range: Some( + 24..30, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 30..30, + ), + }, + ), + range: Some( + 17..30, + ), + }, + ), + range: Some( + 10..30, + ), + }, + WithRange { + node: Empty, + range: Some( + 31..31, + ), + }, + ), + range: Some( + 8..31, + ), + }, + }, + }, + ], + range: Some( + 0..31, + ), + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-3.snap b/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-3.snap new file mode 100644 index 0000000000..f93b445e2e --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-3.snap @@ -0,0 +1,46 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: selection_true_not_v0_2 +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "true", + ), + range: Some( + 0..4, + ), + }, + WithRange { + node: Method( + WithRange { + node: "not", + range: Some( + 6..9, + ), + }, + None, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 4..9, + ), + }, + ), + range: Some( + 0..9, + ), + }, + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-4.snap b/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-4.snap new file mode 100644 index 0000000000..5a51cef7df --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-4.snap @@ -0,0 +1,46 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: selection_false_not_v0_2 +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "false", + ), + range: Some( + 0..5, + ), + }, + WithRange { + node: Method( + WithRange { + node: "not", + range: Some( + 7..10, + ), + }, + None, + WithRange { + node: Empty, + range: Some( + 10..10, + ), + }, + ), + range: Some( + 5..10, + ), + }, + ), + range: Some( + 0..10, + ), + }, + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-5.snap b/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-5.snap new file mode 100644 index 0000000000..13db73cfc5 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-5.snap @@ -0,0 +1,76 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: selection_object_path_v0_2 +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Expr( + WithRange { + node: LitPath( + WithRange { + node: Object( + { + WithRange { + node: Field( + "a", + ), + range: Some( + 4..5, + ), + }: WithRange { + node: Number( + Number(123), + ), + range: Some( + 7..10, + ), + }, + }, + ), + range: Some( + 2..12, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 13..14, + ), + }, + WithRange { + node: Empty, + range: Some( + 14..14, + ), + }, + ), + range: Some( + 12..14, + ), + }, + ), + range: Some( + 2..14, + ), + }, + WithRange { + node: Empty, + range: Some( + 15..15, + ), + }, + ), + range: Some( + 0..15, + ), + }, + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-6.snap b/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-6.snap new file mode 100644 index 0000000000..7ed39ab4cc --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2-6.snap @@ -0,0 +1,100 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: selection_array_path_v0_2 +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Expr( + WithRange { + node: LitPath( + WithRange { + node: Array( + [ + WithRange { + node: Number( + Number(1), + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Number( + Number(2), + ), + range: Some( + 6..7, + ), + }, + WithRange { + node: Number( + Number(3), + ), + range: Some( + 9..10, + ), + }, + ], + ), + range: Some( + 2..11, + ), + }, + WithRange { + node: Method( + WithRange { + node: "get", + range: Some( + 13..16, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Number( + Number(1), + ), + range: Some( + 17..18, + ), + }, + ], + range: Some( + 16..19, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 19..19, + ), + }, + ), + range: Some( + 11..19, + ), + }, + ), + range: Some( + 2..19, + ), + }, + WithRange { + node: Empty, + range: Some( + 20..20, + ), + }, + ), + range: Some( + 0..20, + ), + }, + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2.snap b/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2.snap new file mode 100644 index 0000000000..e4a7d7762d --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/naked_literal_path_for_connect_v0_2.snap @@ -0,0 +1,57 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: selection_null_stringify_v0_2 +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Expr( + WithRange { + node: LitPath( + WithRange { + node: Null, + range: Some( + 2..6, + ), + }, + WithRange { + node: Method( + WithRange { + node: "jsonStringify", + range: Some( + 8..21, + ), + }, + None, + WithRange { + node: Empty, + range: Some( + 21..21, + ), + }, + ), + range: Some( + 6..21, + ), + }, + ), + range: Some( + 2..21, + ), + }, + WithRange { + node: Empty, + range: Some( + 22..22, + ), + }, + ), + range: Some( + 0..22, + ), + }, + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/optional_field_selections.snap b/apollo-federation/src/connectors/json_selection/snapshots/optional_field_selections.snap new file mode 100644 index 0000000000..0a40b0ff40 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/optional_field_selections.snap @@ -0,0 +1,116 @@ +--- +source: apollo-federation/src/connectors/json_selection/apply_to.rs +expression: author_selection +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "author", + ), + range: Some( + 0..6, + ), + }, + WithRange { + node: Question( + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "age", + ), + range: Some( + 10..13, + ), + }, + WithRange { + node: Empty, + range: Some( + 13..13, + ), + }, + ), + range: Some( + 10..13, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "middleName", + ), + range: Some( + 14..24, + ), + }, + WithRange { + node: Question( + WithRange { + node: Empty, + range: Some( + 25..25, + ), + }, + ), + range: Some( + 24..25, + ), + }, + ), + range: Some( + 14..25, + ), + }, + }, + }, + ], + range: Some( + 8..27, + ), + }, + ), + range: Some( + 8..27, + ), + }, + ), + range: Some( + 6..27, + ), + }, + ), + range: Some( + 0..27, + ), + }, + }, + }, + ], + range: Some( + 0..27, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/parse_with_range_snapshots.snap b/apollo-federation/src/connectors/json_selection/snapshots/parse_with_range_snapshots.snap new file mode 100644 index 0000000000..a244a65310 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/parse_with_range_snapshots.snap @@ -0,0 +1,590 @@ +--- +source: apollo-federation/src/connectors/json_selection/location.rs +expression: "format!(\"{:#?}\", parsed)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "path", + ), + range: Some( + 9..13, + ), + }, + range: Some( + 9..14, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "some", + ), + range: Some( + 15..19, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "nested", + ), + range: Some( + 20..26, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "path", + ), + range: Some( + 27..31, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "isbn", + ), + range: Some( + 34..38, + ), + }, + WithRange { + node: Empty, + range: Some( + 38..38, + ), + }, + ), + range: Some( + 34..38, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "author", + ), + range: Some( + 39..45, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 48..52, + ), + }, + WithRange { + node: Empty, + range: Some( + 52..52, + ), + }, + ), + range: Some( + 48..52, + ), + }, + }, + }, + ], + range: Some( + 46..54, + ), + }, + ), + range: Some( + 46..54, + ), + }, + ), + range: Some( + 39..54, + ), + }, + }, + }, + ], + range: Some( + 32..55, + ), + }, + ), + range: Some( + 32..55, + ), + }, + ), + range: Some( + 26..55, + ), + }, + ), + range: Some( + 19..55, + ), + }, + ), + range: Some( + 15..55, + ), + }, + }, + }, + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "alias", + ), + range: Some( + 64..69, + ), + }, + range: Some( + 64..70, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Quoted( + "not an identifier", + ), + range: Some( + 71..90, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "__typename", + ), + range: Some( + 151..161, + ), + }, + range: Some( + 151..162, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: @, + range: Some( + 163..164, + ), + }, + WithRange { + node: Method( + WithRange { + node: "echo", + range: Some( + 166..170, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: String( + "Frog", + ), + range: Some( + 172..178, + ), + }, + ], + range: Some( + 170..182, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 182..182, + ), + }, + ), + range: Some( + 164..182, + ), + }, + ), + range: Some( + 163..182, + ), + }, + }, + }, + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "wrapped", + ), + range: Some( + 195..202, + ), + }, + range: Some( + 195..203, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 204..205, + ), + }, + WithRange { + node: Method( + WithRange { + node: "echo", + range: Some( + 207..211, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Object( + { + WithRange { + node: Field( + "wrapped", + ), + range: Some( + 214..221, + ), + }: WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: @, + range: Some( + 224..225, + ), + }, + WithRange { + node: Empty, + range: Some( + 225..225, + ), + }, + ), + range: Some( + 224..225, + ), + }, + }, + ), + range: Some( + 224..225, + ), + }, + }, + ), + range: Some( + 212..229, + ), + }, + ], + range: Some( + 211..230, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 230..230, + ), + }, + ), + range: Some( + 205..230, + ), + }, + ), + range: Some( + 204..230, + ), + }, + }, + }, + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "group", + ), + range: Some( + 243..248, + ), + }, + range: Some( + 243..249, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 252..253, + ), + }, + WithRange { + node: Empty, + range: Some( + 253..253, + ), + }, + ), + range: Some( + 252..253, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 254..255, + ), + }, + WithRange { + node: Empty, + range: Some( + 255..255, + ), + }, + ), + range: Some( + 254..255, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 256..257, + ), + }, + WithRange { + node: Empty, + range: Some( + 257..257, + ), + }, + ), + range: Some( + 256..257, + ), + }, + }, + }, + ], + range: Some( + 250..259, + ), + }, + ), + range: Some( + 250..259, + ), + }, + }, + }, + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "arg", + ), + range: Some( + 272..275, + ), + }, + range: Some( + 272..276, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 277..282, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "arg", + ), + range: Some( + 285..288, + ), + }, + WithRange { + node: Empty, + range: Some( + 288..288, + ), + }, + ), + range: Some( + 283..288, + ), + }, + ), + range: Some( + 277..288, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "field", + ), + range: Some( + 301..306, + ), + }, + WithRange { + node: Empty, + range: Some( + 306..306, + ), + }, + ), + range: Some( + 301..306, + ), + }, + }, + }, + ], + range: Some( + 91..316, + ), + }, + ), + range: Some( + 91..316, + ), + }, + ), + range: Some( + 71..316, + ), + }, + }, + }, + ], + range: Some( + 9..316, + ), + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/path_expr_with_spaces_v0_2.snap b/apollo-federation/src/connectors/json_selection/snapshots/path_expr_with_spaces_v0_2.snap new file mode 100644 index 0000000000..ee1a400df1 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/path_expr_with_spaces_v0_2.snap @@ -0,0 +1,173 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\" suffix : results -> slice ( $( - 1 ) -> mul ( $args . suffixLength ) ) \",\nspec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "suffix", + ), + range: Some( + 1..7, + ), + }, + range: Some( + 1..9, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "results", + ), + range: Some( + 10..17, + ), + }, + WithRange { + node: Method( + WithRange { + node: "slice", + range: Some( + 21..26, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Expr( + WithRange { + node: Number( + Number(-1), + ), + range: Some( + 32..35, + ), + }, + WithRange { + node: Method( + WithRange { + node: "mul", + range: Some( + 41..44, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 47..52, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "suffixLength", + ), + range: Some( + 55..67, + ), + }, + WithRange { + node: Empty, + range: Some( + 67..67, + ), + }, + ), + range: Some( + 53..67, + ), + }, + ), + range: Some( + 47..67, + ), + }, + }, + ), + range: Some( + 47..67, + ), + }, + ], + range: Some( + 45..69, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 69..69, + ), + }, + ), + range: Some( + 38..69, + ), + }, + ), + range: Some( + 29..69, + ), + }, + }, + ), + range: Some( + 29..69, + ), + }, + ], + range: Some( + 27..71, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 71..71, + ), + }, + ), + range: Some( + 18..71, + ), + }, + ), + range: Some( + 10..71, + ), + }, + }, + }, + ], + range: Some( + 1..71, + ), + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/path_expr_with_spaces_v0_3.snap b/apollo-federation/src/connectors/json_selection/snapshots/path_expr_with_spaces_v0_3.snap new file mode 100644 index 0000000000..ff2dbd499e --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/path_expr_with_spaces_v0_3.snap @@ -0,0 +1,173 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\" suffix : results -> slice ( $( - 1 ) -> mul ( $args . suffixLength ) ) \",\nspec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "suffix", + ), + range: Some( + 1..7, + ), + }, + range: Some( + 1..9, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "results", + ), + range: Some( + 10..17, + ), + }, + WithRange { + node: Method( + WithRange { + node: "slice", + range: Some( + 21..26, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Expr( + WithRange { + node: Number( + Number(-1), + ), + range: Some( + 32..35, + ), + }, + WithRange { + node: Method( + WithRange { + node: "mul", + range: Some( + 41..44, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 47..52, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "suffixLength", + ), + range: Some( + 55..67, + ), + }, + WithRange { + node: Empty, + range: Some( + 67..67, + ), + }, + ), + range: Some( + 53..67, + ), + }, + ), + range: Some( + 47..67, + ), + }, + }, + ), + range: Some( + 47..67, + ), + }, + ], + range: Some( + 45..69, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 69..69, + ), + }, + ), + range: Some( + 38..69, + ), + }, + ), + range: Some( + 29..69, + ), + }, + }, + ), + range: Some( + 29..69, + ), + }, + ], + range: Some( + 27..71, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 71..71, + ), + }, + ), + range: Some( + 18..71, + ), + }, + ), + range: Some( + 10..71, + ), + }, + }, + }, + ], + range: Some( + 1..71, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-2.snap b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-2.snap new file mode 100644 index 0000000000..3a1f8cb4c5 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-2.snap @@ -0,0 +1,211 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(r#\"\n id\n created\n choices->first.message { content role }\n model\n \"#)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 13..15, + ), + }, + WithRange { + node: Empty, + range: Some( + 15..15, + ), + }, + ), + range: Some( + 13..15, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "created", + ), + range: Some( + 28..35, + ), + }, + WithRange { + node: Empty, + range: Some( + 35..35, + ), + }, + ), + range: Some( + 28..35, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + None, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "choices", + ), + range: Some( + 48..55, + ), + }, + WithRange { + node: Method( + WithRange { + node: "first", + range: Some( + 57..62, + ), + }, + None, + WithRange { + node: Key( + WithRange { + node: Field( + "message", + ), + range: Some( + 63..70, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "content", + ), + range: Some( + 73..80, + ), + }, + WithRange { + node: Empty, + range: Some( + 80..80, + ), + }, + ), + range: Some( + 73..80, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "role", + ), + range: Some( + 81..85, + ), + }, + WithRange { + node: Empty, + range: Some( + 85..85, + ), + }, + ), + range: Some( + 81..85, + ), + }, + }, + }, + ], + range: Some( + 71..87, + ), + }, + ), + range: Some( + 71..87, + ), + }, + ), + range: Some( + 62..87, + ), + }, + ), + range: Some( + 55..87, + ), + }, + ), + range: Some( + 48..87, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "model", + ), + range: Some( + 100..105, + ), + }, + WithRange { + node: Empty, + range: Some( + 105..105, + ), + }, + ), + range: Some( + 100..105, + ), + }, + }, + }, + ], + range: Some( + 13..105, + ), + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-3.snap b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-3.snap new file mode 100644 index 0000000000..78363bcf72 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-3.snap @@ -0,0 +1,316 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(r#\"\n id\n created\n choices->first.message { content role }\n model\n choices->last.message { lastContent: content }\n \"#)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 13..15, + ), + }, + WithRange { + node: Empty, + range: Some( + 15..15, + ), + }, + ), + range: Some( + 13..15, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "created", + ), + range: Some( + 28..35, + ), + }, + WithRange { + node: Empty, + range: Some( + 35..35, + ), + }, + ), + range: Some( + 28..35, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + None, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "choices", + ), + range: Some( + 48..55, + ), + }, + WithRange { + node: Method( + WithRange { + node: "first", + range: Some( + 57..62, + ), + }, + None, + WithRange { + node: Key( + WithRange { + node: Field( + "message", + ), + range: Some( + 63..70, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "content", + ), + range: Some( + 73..80, + ), + }, + WithRange { + node: Empty, + range: Some( + 80..80, + ), + }, + ), + range: Some( + 73..80, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "role", + ), + range: Some( + 81..85, + ), + }, + WithRange { + node: Empty, + range: Some( + 85..85, + ), + }, + ), + range: Some( + 81..85, + ), + }, + }, + }, + ], + range: Some( + 71..87, + ), + }, + ), + range: Some( + 71..87, + ), + }, + ), + range: Some( + 62..87, + ), + }, + ), + range: Some( + 55..87, + ), + }, + ), + range: Some( + 48..87, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "model", + ), + range: Some( + 100..105, + ), + }, + WithRange { + node: Empty, + range: Some( + 105..105, + ), + }, + ), + range: Some( + 100..105, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + None, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "choices", + ), + range: Some( + 118..125, + ), + }, + WithRange { + node: Method( + WithRange { + node: "last", + range: Some( + 127..131, + ), + }, + None, + WithRange { + node: Key( + WithRange { + node: Field( + "message", + ), + range: Some( + 132..139, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "lastContent", + ), + range: Some( + 142..153, + ), + }, + range: Some( + 142..154, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "content", + ), + range: Some( + 155..162, + ), + }, + WithRange { + node: Empty, + range: Some( + 162..162, + ), + }, + ), + range: Some( + 155..162, + ), + }, + }, + }, + ], + range: Some( + 140..164, + ), + }, + ), + range: Some( + 140..164, + ), + }, + ), + range: Some( + 131..164, + ), + }, + ), + range: Some( + 125..164, + ), + }, + ), + range: Some( + 118..164, + ), + }, + }, + }, + ], + range: Some( + 13..164, + ), + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-4.snap b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-4.snap new file mode 100644 index 0000000000..05362dee56 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-4.snap @@ -0,0 +1,12 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "JSONSelection::parse(r#\"\n id\n created\n choices->first.message\n model\n \"#)" +--- +Err( + JSONSelectionParseError { + message: "Named path selection must either begin with alias or ..., or end with subselection", + fragment: "choices->first.message\n model\n ", + offset: 48, + spec: V0_2, + }, +) diff --git a/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-5.snap b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-5.snap new file mode 100644 index 0000000000..c5bf9c1709 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-5.snap @@ -0,0 +1,172 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "JSONSelection::parse(r#\"\n id: $this.id\n $args.input {\n title\n body\n }\n \"#)" +--- +Ok( + JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "id", + ), + range: Some( + 13..15, + ), + }, + range: Some( + 13..16, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 17..22, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 23..25, + ), + }, + WithRange { + node: Empty, + range: Some( + 25..25, + ), + }, + ), + range: Some( + 22..25, + ), + }, + ), + range: Some( + 17..25, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + None, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 38..43, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "input", + ), + range: Some( + 44..49, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "title", + ), + range: Some( + 68..73, + ), + }, + WithRange { + node: Empty, + range: Some( + 73..73, + ), + }, + ), + range: Some( + 68..73, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "body", + ), + range: Some( + 90..94, + ), + }, + WithRange { + node: Empty, + range: Some( + 94..94, + ), + }, + ), + range: Some( + 90..94, + ), + }, + }, + }, + ], + range: Some( + 50..108, + ), + }, + ), + range: Some( + 50..108, + ), + }, + ), + range: Some( + 43..108, + ), + }, + ), + range: Some( + 38..108, + ), + }, + }, + }, + ], + range: Some( + 13..108, + ), + }, + ), + spec: V0_2, + }, +) diff --git a/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-6.snap b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-6.snap new file mode 100644 index 0000000000..5c37656be4 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-6.snap @@ -0,0 +1,213 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "JSONSelection::parse(r#\"\n $this { id }\n $args { $.input { title body } }\n \"#)" +--- +Ok( + JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + None, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 13..18, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 21..23, + ), + }, + WithRange { + node: Empty, + range: Some( + 23..23, + ), + }, + ), + range: Some( + 21..23, + ), + }, + }, + }, + ], + range: Some( + 19..25, + ), + }, + ), + range: Some( + 19..25, + ), + }, + ), + range: Some( + 13..25, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + None, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 38..43, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + None, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 46..47, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "input", + ), + range: Some( + 48..53, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "title", + ), + range: Some( + 56..61, + ), + }, + WithRange { + node: Empty, + range: Some( + 61..61, + ), + }, + ), + range: Some( + 56..61, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "body", + ), + range: Some( + 62..66, + ), + }, + WithRange { + node: Empty, + range: Some( + 66..66, + ), + }, + ), + range: Some( + 62..66, + ), + }, + }, + }, + ], + range: Some( + 54..68, + ), + }, + ), + range: Some( + 54..68, + ), + }, + ), + range: Some( + 47..68, + ), + }, + ), + range: Some( + 46..68, + ), + }, + }, + }, + ], + range: Some( + 44..70, + ), + }, + ), + range: Some( + 44..70, + ), + }, + ), + range: Some( + 38..70, + ), + }, + }, + }, + ], + range: Some( + 13..70, + ), + }, + ), + spec: V0_2, + }, +) diff --git a/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-7.snap b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-7.snap new file mode 100644 index 0000000000..86b3a61a8c --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection-7.snap @@ -0,0 +1,332 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "JSONSelection::parse(r#\"\n # Equivalent to id: $this.id\n $this { id }\n\n $args {\n __typename: $(\"Args\")\n\n # Using $. instead of just . prevents .input from\n # parsing as a key applied to the $(\"Args\") string.\n $.input { title body }\n\n extra\n }\n\n from: $.from\n \"#)" +--- +Ok( + JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + None, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 54..59, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 62..64, + ), + }, + WithRange { + node: Empty, + range: Some( + 64..64, + ), + }, + ), + range: Some( + 62..64, + ), + }, + }, + }, + ], + range: Some( + 60..66, + ), + }, + ), + range: Some( + 60..66, + ), + }, + ), + range: Some( + 54..66, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + None, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 80..85, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "__typename", + ), + range: Some( + 104..114, + ), + }, + range: Some( + 104..115, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Expr( + WithRange { + node: String( + "Args", + ), + range: Some( + 118..124, + ), + }, + WithRange { + node: Empty, + range: Some( + 125..125, + ), + }, + ), + range: Some( + 116..125, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + None, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 277..278, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "input", + ), + range: Some( + 279..284, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "title", + ), + range: Some( + 287..292, + ), + }, + WithRange { + node: Empty, + range: Some( + 292..292, + ), + }, + ), + range: Some( + 287..292, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "body", + ), + range: Some( + 293..297, + ), + }, + WithRange { + node: Empty, + range: Some( + 297..297, + ), + }, + ), + range: Some( + 293..297, + ), + }, + }, + }, + ], + range: Some( + 285..299, + ), + }, + ), + range: Some( + 285..299, + ), + }, + ), + range: Some( + 278..299, + ), + }, + ), + range: Some( + 277..299, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "extra", + ), + range: Some( + 317..322, + ), + }, + WithRange { + node: Empty, + range: Some( + 322..322, + ), + }, + ), + range: Some( + 317..322, + ), + }, + }, + }, + ], + range: Some( + 86..336, + ), + }, + ), + range: Some( + 86..336, + ), + }, + ), + range: Some( + 80..336, + ), + }, + }, + }, + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "from", + ), + range: Some( + 350..354, + ), + }, + range: Some( + 350..355, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 356..357, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "from", + ), + range: Some( + 358..362, + ), + }, + WithRange { + node: Empty, + range: Some( + 362..362, + ), + }, + ), + range: Some( + 357..362, + ), + }, + ), + range: Some( + 356..362, + ), + }, + }, + }, + ], + range: Some( + 54..362, + ), + }, + ), + spec: V0_2, + }, +) diff --git a/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection.snap b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection.snap new file mode 100644 index 0000000000..213886319b --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/path_with_subselection.snap @@ -0,0 +1,121 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(r#\"\n choices->first.message { content role }\n \"#)" +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "choices", + ), + range: Some( + 13..20, + ), + }, + WithRange { + node: Method( + WithRange { + node: "first", + range: Some( + 22..27, + ), + }, + None, + WithRange { + node: Key( + WithRange { + node: Field( + "message", + ), + range: Some( + 28..35, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "content", + ), + range: Some( + 38..45, + ), + }, + WithRange { + node: Empty, + range: Some( + 45..45, + ), + }, + ), + range: Some( + 38..45, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "role", + ), + range: Some( + 46..50, + ), + }, + WithRange { + node: Empty, + range: Some( + 50..50, + ), + }, + ), + range: Some( + 46..50, + ), + }, + }, + }, + ], + range: Some( + 36..52, + ), + }, + ), + range: Some( + 36..52, + ), + }, + ), + range: Some( + 27..52, + ), + }, + ), + range: Some( + 20..52, + ), + }, + ), + range: Some( + 13..52, + ), + }, + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/selection.snap b/apollo-federation/src/connectors/json_selection/snapshots/selection.snap new file mode 100644 index 0000000000..f31eac8af6 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/selection.snap @@ -0,0 +1,425 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"\n # Comments are supported because we parse them as whitespace\n topLevelAlias: topLevelField {\n identifier: 'property name with spaces'\n 'unaliased non-identifier property'\n 'non-identifier alias': identifier\n\n # This extracts the value located at the given path and applies a\n # selection set to it before renaming the result to pathSelection\n pathSelection: some.nested.path {\n still: yet\n more\n properties\n }\n\n # An aliased SubSelection of fields nests the fields together\n # under the given alias\n siblingGroup: { brother sister }\n }\")" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "topLevelAlias", + ), + range: Some( + 86..99, + ), + }, + range: Some( + 86..100, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "topLevelField", + ), + range: Some( + 101..114, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "identifier", + ), + range: Some( + 133..143, + ), + }, + range: Some( + 133..144, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Quoted( + "property name with spaces", + ), + range: Some( + 145..172, + ), + }, + WithRange { + node: Empty, + range: Some( + 172..172, + ), + }, + ), + range: Some( + 145..172, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Quoted( + "unaliased non-identifier property", + ), + range: Some( + 189..224, + ), + }, + WithRange { + node: Empty, + range: Some( + 224..224, + ), + }, + ), + range: Some( + 189..224, + ), + }, + }, + }, + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Quoted( + "non-identifier alias", + ), + range: Some( + 241..263, + ), + }, + range: Some( + 241..264, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "identifier", + ), + range: Some( + 265..275, + ), + }, + WithRange { + node: Empty, + range: Some( + 275..275, + ), + }, + ), + range: Some( + 265..275, + ), + }, + }, + }, + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "pathSelection", + ), + range: Some( + 457..470, + ), + }, + range: Some( + 457..471, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "some", + ), + range: Some( + 472..476, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "nested", + ), + range: Some( + 477..483, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "path", + ), + range: Some( + 484..488, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "still", + ), + range: Some( + 511..516, + ), + }, + range: Some( + 511..517, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "yet", + ), + range: Some( + 518..521, + ), + }, + WithRange { + node: Empty, + range: Some( + 521..521, + ), + }, + ), + range: Some( + 518..521, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "more", + ), + range: Some( + 542..546, + ), + }, + WithRange { + node: Empty, + range: Some( + 546..546, + ), + }, + ), + range: Some( + 542..546, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "properties", + ), + range: Some( + 567..577, + ), + }, + WithRange { + node: Empty, + range: Some( + 577..577, + ), + }, + ), + range: Some( + 567..577, + ), + }, + }, + }, + ], + range: Some( + 489..595, + ), + }, + ), + range: Some( + 489..595, + ), + }, + ), + range: Some( + 483..595, + ), + }, + ), + range: Some( + 476..595, + ), + }, + ), + range: Some( + 472..595, + ), + }, + }, + }, + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "siblingGroup", + ), + range: Some( + 731..743, + ), + }, + range: Some( + 731..744, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "brother", + ), + range: Some( + 747..754, + ), + }, + WithRange { + node: Empty, + range: Some( + 754..754, + ), + }, + ), + range: Some( + 747..754, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "sister", + ), + range: Some( + 755..761, + ), + }, + WithRange { + node: Empty, + range: Some( + 761..761, + ), + }, + ), + range: Some( + 755..761, + ), + }, + }, + }, + ], + range: Some( + 745..763, + ), + }, + ), + range: Some( + 745..763, + ), + }, + }, + }, + ], + range: Some( + 115..777, + ), + }, + ), + range: Some( + 115..777, + ), + }, + ), + range: Some( + 101..777, + ), + }, + }, + }, + ], + range: Some( + 86..777, + ), + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths-2.snap b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths-2.snap new file mode 100644 index 0000000000..bd41ad24ef --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths-2.snap @@ -0,0 +1,140 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: mul_with_dollars +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Method( + WithRange { + node: "mul", + range: Some( + 3..6, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 7..8, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 9..10, + ), + }, + WithRange { + node: Empty, + range: Some( + 10..10, + ), + }, + ), + range: Some( + 8..10, + ), + }, + ), + range: Some( + 7..10, + ), + }, + }, + ), + range: Some( + 7..10, + ), + }, + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 12..13, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 14..15, + ), + }, + WithRange { + node: Empty, + range: Some( + 15..15, + ), + }, + ), + range: Some( + 13..15, + ), + }, + ), + range: Some( + 12..15, + ), + }, + }, + ), + range: Some( + 12..15, + ), + }, + ], + range: Some( + 6..16, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 1..16, + ), + }, + ), + range: Some( + 0..16, + ), + }, + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths-3.snap b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths-3.snap new file mode 100644 index 0000000000..2500a3d2f6 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths-3.snap @@ -0,0 +1,114 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: selection +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Method( + WithRange { + node: "add", + range: Some( + 3..6, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 7..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 7..8, + ), + }, + }, + ), + range: Some( + 7..8, + ), + }, + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 10..11, + ), + }, + WithRange { + node: Empty, + range: Some( + 11..11, + ), + }, + ), + range: Some( + 10..11, + ), + }, + }, + ), + range: Some( + 10..11, + ), + }, + ], + range: Some( + 6..12, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 12..12, + ), + }, + ), + range: Some( + 1..12, + ), + }, + ), + range: Some( + 0..12, + ), + }, + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths.snap b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths.snap new file mode 100644 index 0000000000..7e6e51eb63 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths.snap @@ -0,0 +1,140 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: mul_with_dollars +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Method( + WithRange { + node: "mul", + range: Some( + 3..6, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 7..8, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 9..10, + ), + }, + WithRange { + node: Empty, + range: Some( + 10..10, + ), + }, + ), + range: Some( + 8..10, + ), + }, + ), + range: Some( + 7..10, + ), + }, + }, + ), + range: Some( + 7..10, + ), + }, + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 12..13, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 14..15, + ), + }, + WithRange { + node: Empty, + range: Some( + 15..15, + ), + }, + ), + range: Some( + 13..15, + ), + }, + ), + range: Some( + 12..15, + ), + }, + }, + ), + range: Some( + 12..15, + ), + }, + ], + range: Some( + 6..16, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 1..16, + ), + }, + ), + range: Some( + 0..16, + ), + }, + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_2.snap b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_2.snap new file mode 100644 index 0000000000..7e6e51eb63 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_2.snap @@ -0,0 +1,140 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: mul_with_dollars +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Method( + WithRange { + node: "mul", + range: Some( + 3..6, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 7..8, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 9..10, + ), + }, + WithRange { + node: Empty, + range: Some( + 10..10, + ), + }, + ), + range: Some( + 8..10, + ), + }, + ), + range: Some( + 7..10, + ), + }, + }, + ), + range: Some( + 7..10, + ), + }, + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 12..13, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 14..15, + ), + }, + WithRange { + node: Empty, + range: Some( + 15..15, + ), + }, + ), + range: Some( + 13..15, + ), + }, + ), + range: Some( + 12..15, + ), + }, + }, + ), + range: Some( + 12..15, + ), + }, + ], + range: Some( + 6..16, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 1..16, + ), + }, + ), + range: Some( + 0..16, + ), + }, + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_3-2.snap b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_3-2.snap new file mode 100644 index 0000000000..2500a3d2f6 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_3-2.snap @@ -0,0 +1,114 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: selection +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Method( + WithRange { + node: "add", + range: Some( + 3..6, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 7..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 7..8, + ), + }, + }, + ), + range: Some( + 7..8, + ), + }, + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 10..11, + ), + }, + WithRange { + node: Empty, + range: Some( + 11..11, + ), + }, + ), + range: Some( + 10..11, + ), + }, + }, + ), + range: Some( + 10..11, + ), + }, + ], + range: Some( + 6..12, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 12..12, + ), + }, + ), + range: Some( + 1..12, + ), + }, + ), + range: Some( + 0..12, + ), + }, + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_3-3.snap b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_3-3.snap new file mode 100644 index 0000000000..86199243ea --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_3-3.snap @@ -0,0 +1,138 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: selection +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "sum", + ), + range: Some( + 0..3, + ), + }, + range: Some( + 0..4, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Method( + WithRange { + node: "add", + range: Some( + 8..11, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 12..13, + ), + }, + WithRange { + node: Empty, + range: Some( + 13..13, + ), + }, + ), + range: Some( + 12..13, + ), + }, + }, + ), + range: Some( + 12..13, + ), + }, + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 15..16, + ), + }, + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 15..16, + ), + }, + }, + ), + range: Some( + 15..16, + ), + }, + ], + range: Some( + 11..17, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 17..17, + ), + }, + ), + range: Some( + 6..17, + ), + }, + ), + range: Some( + 5..17, + ), + }, + }, + }, + ], + range: Some( + 0..17, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_3.snap b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_3.snap new file mode 100644 index 0000000000..bd41ad24ef --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/single_key_paths_v0_3.snap @@ -0,0 +1,140 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: mul_with_dollars +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Method( + WithRange { + node: "mul", + range: Some( + 3..6, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 7..8, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 9..10, + ), + }, + WithRange { + node: Empty, + range: Some( + 10..10, + ), + }, + ), + range: Some( + 8..10, + ), + }, + ), + range: Some( + 7..10, + ), + }, + }, + ), + range: Some( + 7..10, + ), + }, + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 12..13, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 14..15, + ), + }, + WithRange { + node: Empty, + range: Some( + 15..15, + ), + }, + ), + range: Some( + 13..15, + ), + }, + ), + range: Some( + 12..15, + ), + }, + }, + ), + range: Some( + 12..15, + ), + }, + ], + range: Some( + 6..16, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 1..16, + ), + }, + ), + range: Some( + 0..16, + ), + }, + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_a_spread_b.snap b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_a_spread_b.snap new file mode 100644 index 0000000000..6706787b90 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_a_spread_b.snap @@ -0,0 +1,72 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"a...b\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Empty, + range: Some( + 1..1, + ), + }, + ), + range: Some( + 0..1, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + Some( + 1..4, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 4..5, + ), + }, + WithRange { + node: Empty, + range: Some( + 5..5, + ), + }, + ), + range: Some( + 4..5, + ), + }, + }, + }, + ], + range: Some( + 0..5, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_b.snap b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_b.snap new file mode 100644 index 0000000000..8ca4b0b450 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_b.snap @@ -0,0 +1,72 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a b\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + ], + range: Some( + 0..6, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_b_c.snap b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_b_c.snap new file mode 100644 index 0000000000..1aeb82b0bb --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_b_c.snap @@ -0,0 +1,98 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a b c\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 7..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 7..8, + ), + }, + }, + }, + ], + range: Some( + 0..8, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_spread_b.snap b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_spread_b.snap new file mode 100644 index 0000000000..49d142d400 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_spread_b.snap @@ -0,0 +1,76 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a...b\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Empty, + range: Some( + 4..4, + ), + }, + ), + range: Some( + 3..4, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + Some( + 4..7, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 7..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 7..8, + ), + }, + }, + }, + ], + range: Some( + 0..8, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_b_c.snap b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_b_c.snap new file mode 100644 index 0000000000..0d53faf547 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_b_c.snap @@ -0,0 +1,106 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a{b c}\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 7..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 7..8, + ), + }, + }, + }, + ], + range: Some( + 4..9, + ), + }, + ), + range: Some( + 4..9, + ), + }, + ), + range: Some( + 3..9, + ), + }, + }, + }, + ], + range: Some( + 0..9, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_b_spread_c.snap b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_b_spread_c.snap new file mode 100644 index 0000000000..7e2fa04527 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_b_spread_c.snap @@ -0,0 +1,110 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a{b...c}\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + Some( + 6..9, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 9..10, + ), + }, + WithRange { + node: Empty, + range: Some( + 10..10, + ), + }, + ), + range: Some( + 9..10, + ), + }, + }, + }, + ], + range: Some( + 4..11, + ), + }, + ), + range: Some( + 4..11, + ), + }, + ), + range: Some( + 3..11, + ), + }, + }, + }, + ], + range: Some( + 0..11, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_b_spread_c_d.snap b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_b_spread_c_d.snap new file mode 100644 index 0000000000..8fddf32c3b --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_b_spread_c_d.snap @@ -0,0 +1,136 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a{b...c d}\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + Some( + 6..9, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 9..10, + ), + }, + WithRange { + node: Empty, + range: Some( + 10..10, + ), + }, + ), + range: Some( + 9..10, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "d", + ), + range: Some( + 11..12, + ), + }, + WithRange { + node: Empty, + range: Some( + 12..12, + ), + }, + ), + range: Some( + 11..12, + ), + }, + }, + }, + ], + range: Some( + 4..13, + ), + }, + ), + range: Some( + 4..13, + ), + }, + ), + range: Some( + 3..13, + ), + }, + }, + }, + ], + range: Some( + 0..13, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_spread_b_c_d_spread_e.snap b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_spread_b_c_d_spread_e.snap new file mode 100644 index 0000000000..5dbfd81abf --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_a_sub_spread_b_c_d_spread_e.snap @@ -0,0 +1,166 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a{...b c d...e}\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 5..8, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 8..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 8..9, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 10..11, + ), + }, + WithRange { + node: Empty, + range: Some( + 11..11, + ), + }, + ), + range: Some( + 10..11, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "d", + ), + range: Some( + 12..13, + ), + }, + WithRange { + node: Empty, + range: Some( + 13..13, + ), + }, + ), + range: Some( + 12..13, + ), + }, + }, + }, + NamedSelection { + prefix: Spread( + Some( + 13..16, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "e", + ), + range: Some( + 16..17, + ), + }, + WithRange { + node: Empty, + range: Some( + 17..17, + ), + }, + ), + range: Some( + 16..17, + ), + }, + }, + }, + ], + range: Some( + 4..18, + ), + }, + ), + range: Some( + 4..18, + ), + }, + ), + range: Some( + 3..18, + ), + }, + }, + }, + ], + range: Some( + 0..18, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_spread_a_sub_b.snap b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_spread_a_sub_b.snap new file mode 100644 index 0000000000..10346f1856 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/spread_parsing_spread_spread_a_sub_b.snap @@ -0,0 +1,80 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: "selection!(\"...a{b}\", spec)" +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Spread( + Some( + 0..3, + ), + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 3..4, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Empty, + range: Some( + 6..6, + ), + }, + ), + range: Some( + 5..6, + ), + }, + }, + }, + ], + range: Some( + 4..7, + ), + }, + ), + range: Some( + 4..7, + ), + }, + ), + range: Some( + 3..7, + ), + }, + }, + }, + ], + range: Some( + 0..7, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/unambiguous_single_key_paths_v0_2.snap b/apollo-federation/src/connectors/json_selection/snapshots/unambiguous_single_key_paths_v0_2.snap new file mode 100644 index 0000000000..7e6e51eb63 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/unambiguous_single_key_paths_v0_2.snap @@ -0,0 +1,140 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: mul_with_dollars +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Method( + WithRange { + node: "mul", + range: Some( + 3..6, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 7..8, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 9..10, + ), + }, + WithRange { + node: Empty, + range: Some( + 10..10, + ), + }, + ), + range: Some( + 8..10, + ), + }, + ), + range: Some( + 7..10, + ), + }, + }, + ), + range: Some( + 7..10, + ), + }, + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 12..13, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 14..15, + ), + }, + WithRange { + node: Empty, + range: Some( + 15..15, + ), + }, + ), + range: Some( + 13..15, + ), + }, + ), + range: Some( + 12..15, + ), + }, + }, + ), + range: Some( + 12..15, + ), + }, + ], + range: Some( + 6..16, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 1..16, + ), + }, + ), + range: Some( + 0..16, + ), + }, + }, + ), + spec: V0_2, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/unambiguous_single_key_paths_v0_3.snap b/apollo-federation/src/connectors/json_selection/snapshots/unambiguous_single_key_paths_v0_3.snap new file mode 100644 index 0000000000..bd41ad24ef --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/unambiguous_single_key_paths_v0_3.snap @@ -0,0 +1,140 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: mul_with_dollars +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Method( + WithRange { + node: "mul", + range: Some( + 3..6, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 7..8, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 9..10, + ), + }, + WithRange { + node: Empty, + range: Some( + 10..10, + ), + }, + ), + range: Some( + 8..10, + ), + }, + ), + range: Some( + 7..10, + ), + }, + }, + ), + range: Some( + 7..10, + ), + }, + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $, + range: Some( + 12..13, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 14..15, + ), + }, + WithRange { + node: Empty, + range: Some( + 15..15, + ), + }, + ), + range: Some( + 13..15, + ), + }, + ), + range: Some( + 12..15, + ), + }, + }, + ), + range: Some( + 12..15, + ), + }, + ], + range: Some( + 6..16, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 1..16, + ), + }, + ), + range: Some( + 0..16, + ), + }, + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/valid_single_key_path_v0_3.snap b/apollo-federation/src/connectors/json_selection/snapshots/valid_single_key_path_v0_3.snap new file mode 100644 index 0000000000..2500a3d2f6 --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/valid_single_key_path_v0_3.snap @@ -0,0 +1,114 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: selection +--- +JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 0..1, + ), + }, + WithRange { + node: Method( + WithRange { + node: "add", + range: Some( + 3..6, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 7..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 7..8, + ), + }, + }, + ), + range: Some( + 7..8, + ), + }, + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 10..11, + ), + }, + WithRange { + node: Empty, + range: Some( + 11..11, + ), + }, + ), + range: Some( + 10..11, + ), + }, + }, + ), + range: Some( + 10..11, + ), + }, + ], + range: Some( + 6..12, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 12..12, + ), + }, + ), + range: Some( + 1..12, + ), + }, + ), + range: Some( + 0..12, + ), + }, + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/json_selection/snapshots/valid_single_key_path_with_alias_v0_3.snap b/apollo-federation/src/connectors/json_selection/snapshots/valid_single_key_path_with_alias_v0_3.snap new file mode 100644 index 0000000000..86199243ea --- /dev/null +++ b/apollo-federation/src/connectors/json_selection/snapshots/valid_single_key_path_with_alias_v0_3.snap @@ -0,0 +1,138 @@ +--- +source: apollo-federation/src/connectors/json_selection/parser.rs +expression: selection +--- +JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "sum", + ), + range: Some( + 0..3, + ), + }, + range: Some( + 0..4, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "a", + ), + range: Some( + 5..6, + ), + }, + WithRange { + node: Method( + WithRange { + node: "add", + range: Some( + 8..11, + ), + }, + Some( + MethodArgs { + args: [ + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 12..13, + ), + }, + WithRange { + node: Empty, + range: Some( + 13..13, + ), + }, + ), + range: Some( + 12..13, + ), + }, + }, + ), + range: Some( + 12..13, + ), + }, + WithRange { + node: Path( + PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "c", + ), + range: Some( + 15..16, + ), + }, + WithRange { + node: Empty, + range: Some( + 16..16, + ), + }, + ), + range: Some( + 15..16, + ), + }, + }, + ), + range: Some( + 15..16, + ), + }, + ], + range: Some( + 11..17, + ), + }, + ), + WithRange { + node: Empty, + range: Some( + 17..17, + ), + }, + ), + range: Some( + 6..17, + ), + }, + ), + range: Some( + 5..17, + ), + }, + }, + }, + ], + range: Some( + 0..17, + ), + }, + ), + spec: V0_3, +} diff --git a/apollo-federation/src/connectors/mod.rs b/apollo-federation/src/connectors/mod.rs new file mode 100644 index 0000000000..7a462b3aa6 --- /dev/null +++ b/apollo-federation/src/connectors/mod.rs @@ -0,0 +1,242 @@ +// No panics allowed from connectors code. +// Crashing the language server is a bad user experience, and panicking in the router is even worse. +#![cfg_attr( + not(test), + deny( + clippy::exit, + clippy::panic, + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::unimplemented, + clippy::todo + ) +)] +#![deny(nonstandard_style)] +#![deny(clippy::redundant_clone)] +#![deny(clippy::manual_while_let_some)] +#![deny(clippy::needless_borrow)] +#![deny(clippy::manual_ok_or)] +#![deny(clippy::needless_collect)] +#![deny(clippy::or_fun_call)] + +use std::hash::Hash; +use std::hash::Hasher; + +use apollo_compiler::Name; + +pub mod expand; +mod header; +mod id; +mod json_selection; +mod models; +pub use models::ProblemLocation; +pub mod runtime; +pub(crate) mod spec; +mod string_template; +pub mod validation; +pub(crate) mod variable; + +use apollo_compiler::name; +use id::ConnectorPosition; +use id::ObjectTypeDefinitionDirectivePosition; +pub use json_selection::ApplyToError; +pub use json_selection::JSONSelection; +pub use json_selection::Key; +pub use json_selection::PathSelection; +pub(crate) use json_selection::SelectionTrie; +pub use json_selection::SubSelection; +pub use models::CustomConfiguration; +pub use models::Header; +use serde::Serialize; +pub use spec::ConnectHTTPArguments; +pub use spec::ConnectSpec; +pub use spec::SourceHTTPArguments; +pub use string_template::Error as StringTemplateError; +pub use string_template::StringTemplate; +pub(crate) use validation::field_set_is_subset; +pub use variable::Namespace; + +pub use self::models::Connector; +pub use self::models::ConnectorErrorsSettings; +pub use self::models::EntityResolver; +pub use self::models::HTTPMethod; +pub use self::models::HeaderSource; +pub use self::models::HttpJsonTransport; +pub use self::models::Label; +pub use self::models::MakeUriError; +pub use self::models::OriginatingDirective; +pub use self::models::SourceName; +pub use self::spec::connect::ConnectBatchArguments; +use crate::schema::position::ObjectFieldDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceFieldDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceFieldDirectivePosition; + +#[derive(Debug, Clone)] +pub struct ConnectId { + pub subgraph_name: String, + pub source_name: Option, + pub named: Option, + pub(crate) directive: ConnectorPosition, +} + +impl ConnectId { + /// Create a synthetic name for this connect ID + /// + /// Until we have a source-aware query planner, we'll need to split up connectors into + /// their own subgraphs when doing planning. Each subgraph will need a name, so we + /// synthesize one using metadata present on the directive. + pub(crate) fn synthetic_name(&self) -> String { + format!("{}_{}", self.subgraph_name, self.directive.synthetic_name()) + } + + /// Connector ID Name + pub fn name(&self) -> String { + self.named + .as_ref() + .map_or_else(|| self.directive.coordinate(), |name| name.to_string()) + } + + pub fn subgraph_source(&self) -> String { + let source = self + .source_name + .as_ref() + .map(SourceName::as_str) + .unwrap_or_default(); + format!("{}.{}", self.subgraph_name, source) + } + + pub fn coordinate(&self) -> String { + format!("{}:{}", self.subgraph_name, self.directive.coordinate()) + } + + /// Intended for tests in apollo-router + pub fn new( + subgraph_name: String, + source_name: Option, + type_name: Name, + field_name: Name, + named: Option, + index: usize, + ) -> Self { + Self { + subgraph_name, + source_name, + named, + directive: ConnectorPosition::Field(ObjectOrInterfaceFieldDirectivePosition { + field: ObjectOrInterfaceFieldDefinitionPosition::Object( + ObjectFieldDefinitionPosition { + type_name, + field_name, + }, + ), + directive_name: name!(connect), + directive_index: index, + }), + } + } + + /// Intended for tests in apollo-router + pub fn new_on_object( + subgraph_name: String, + source_name: Option, + type_name: Name, + named: Option, + index: usize, + ) -> Self { + Self { + subgraph_name, + source_name, + named, + directive: ConnectorPosition::Type(ObjectTypeDefinitionDirectivePosition { + type_name, + directive_name: name!(connect), + directive_index: index, + }), + } + } +} + +impl PartialEq<&str> for ConnectId { + fn eq(&self, other: &&str) -> bool { + let coordinate = self.directive.coordinate(); + let coordinate_non_indexed = coordinate.strip_suffix("[0]").unwrap_or(&coordinate); + &coordinate == other + || &coordinate_non_indexed == other + || self + .named + .as_ref() + .is_some_and(|name| &name.as_str() == other) + } +} + +impl PartialEq for ConnectId { + fn eq(&self, other: &String) -> bool { + self == &other.as_str() + } +} + +impl PartialEq for ConnectId { + fn eq(&self, other: &Self) -> bool { + self.subgraph_name == other.subgraph_name && self.directive == other.directive + } +} + +impl Eq for ConnectId {} + +impl Hash for ConnectId { + fn hash(&self, state: &mut H) { + self.subgraph_name.hash(state); + self.directive.hash(state); + } +} + +impl Serialize for ConnectId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn id_eq_str_with_index_0() { + let id = ConnectId::new( + "subgraph".to_string(), + None, + name!("type"), + name!("field"), + Some(name!("my_id")), + 0, + ); + + assert_eq!(id, "type.field[0]"); + assert_eq!(id, "type.field[0]".to_string()); + assert_eq!(id, "type.field"); + assert_eq!(id, "type.field".to_string()); + assert_eq!(id, "my_id"); + assert_eq!(id, "my_id".to_string()); + } + + #[test] + fn id_eq_str_with_index_non_zero() { + let id = ConnectId::new( + "subgraph".to_string(), + None, + name!("type"), + name!("field"), + Some(name!("my_id")), + 10, + ); + + assert_eq!(id, "type.field[10]"); + assert_eq!(id, "type.field[10]".to_string()); + assert!(id != "type.field"); + assert_eq!(id, "my_id"); + } +} diff --git a/apollo-federation/src/connectors/models.rs b/apollo-federation/src/connectors/models.rs new file mode 100644 index 0000000000..ebd385bdf3 --- /dev/null +++ b/apollo-federation/src/connectors/models.rs @@ -0,0 +1,835 @@ +mod headers; +mod http_json_transport; +mod keys; +mod problem_location; +mod source; + +use std::collections::HashMap; +use std::sync::Arc; + +use apollo_compiler::Name; +use apollo_compiler::Schema; +use apollo_compiler::collections::HashSet; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; +use apollo_compiler::executable::FieldSet; +use apollo_compiler::validation::Valid; +use keys::make_key_field_set_from_variables; +use serde_json::Value; + +pub use self::headers::Header; +pub(crate) use self::headers::HeaderParseError; +pub use self::headers::HeaderSource; +pub use self::headers::OriginatingDirective; +pub use self::http_json_transport::HTTPMethod; +pub use self::http_json_transport::HttpJsonTransport; +pub use self::http_json_transport::MakeUriError; +pub use self::problem_location::ProblemLocation; +pub use self::source::SourceName; +use super::ConnectId; +use super::JSONSelection; +use super::PathSelection; +use super::id::ConnectorPosition; +use super::json_selection::ExternalVarPaths; +use super::spec::connect::ConnectBatchArguments; +use super::spec::connect::ConnectDirectiveArguments; +use super::spec::errors::ErrorsArguments; +use super::spec::source::SourceDirectiveArguments; +use super::variable::Namespace; +use super::variable::VariableReference; +use crate::connectors::ConnectSpec; +use crate::connectors::spec::ConnectLink; +use crate::connectors::spec::extract_connect_directive_arguments; +use crate::connectors::spec::extract_source_directive_arguments; +use crate::error::FederationError; +use crate::error::SingleFederationError; +use crate::internal_error; + +// --- Connector --------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct Connector { + pub id: ConnectId, + pub transport: HttpJsonTransport, + pub selection: JSONSelection, + pub config: Option, + pub max_requests: Option, + + /// The type of entity resolver to use for this connector + pub entity_resolver: Option, + /// Which version of the connect spec is this connector using? + pub spec: ConnectSpec, + + /// The request headers referenced in the connectors request mapping + pub request_headers: HashSet, + /// The request or response headers referenced in the connectors response mapping + pub response_headers: HashSet, + /// Environment and context variable keys referenced in the connector + pub request_variable_keys: IndexMap>, + pub response_variable_keys: IndexMap>, + + pub batch_settings: Option, + + pub error_settings: ConnectorErrorsSettings, + + /// A label for use in debugging and logging. Includes ID, transport method, and path. + pub label: Label, +} + +#[derive(Debug, Clone, Default)] +pub struct ConnectorErrorsSettings { + pub message: Option, + pub source_extensions: Option, + pub connect_extensions: Option, + pub connect_is_success: Option, +} + +impl ConnectorErrorsSettings { + fn from_directive( + connect_errors: Option<&ErrorsArguments>, + source_errors: Option<&ErrorsArguments>, + connect_is_success: Option<&JSONSelection>, + ) -> Self { + let message = connect_errors + .and_then(|e| e.message.as_ref()) + .or_else(|| source_errors.and_then(|e| e.message.as_ref())) + .cloned(); + let source_extensions = source_errors.and_then(|e| e.extensions.as_ref()).cloned(); + let connect_extensions = connect_errors.and_then(|e| e.extensions.as_ref()).cloned(); + let connect_is_success = connect_is_success.cloned(); + Self { + message, + source_extensions, + connect_extensions, + connect_is_success, + } + } + + pub fn variable_references(&self) -> impl Iterator> + '_ { + self.message + .as_ref() + .into_iter() + .flat_map(|m| m.variable_references()) + .chain( + self.source_extensions + .as_ref() + .into_iter() + .flat_map(|m| m.variable_references()), + ) + .chain( + self.connect_extensions + .as_ref() + .into_iter() + .flat_map(|m| m.variable_references()), + ) + .chain( + self.connect_is_success + .as_ref() + .into_iter() + .flat_map(|m| m.variable_references()), + ) + } +} + +pub type CustomConfiguration = Arc>; + +/// Entity resolver type +/// +/// A connector can be used as a potential entity resolver for a type, with +/// extra validation rules based on the transport args and field position within +/// a schema. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntityResolver { + /// The user defined a connector on a field that acts as an entity resolver + Explicit, + + /// The user defined a connector on a field of a type, so we need an entity resolver for that type + Implicit, + + /// The user defined a connector on the type directly and uses the $batch variable + TypeBatch, + + /// The user defined a connector on the type directly and uses the $this variable + TypeSingle, +} + +impl Connector { + /// Get a map of connectors from an apollo_compiler::Schema. + /// + /// Note: the function assumes that we've checked that the schema is valid + /// before calling this function. We can't take a `Valid` or `ValidFederationSchema` + /// because we use this code in validation, which occurs before we've augmented + /// the schema with types from `@link` directives. + pub fn from_schema(schema: &Schema, subgraph_name: &str) -> Result, FederationError> { + let Some(link) = ConnectLink::new(schema) else { + return Ok(Default::default()); + }; + let link = link.map_err(|message| SingleFederationError::UnknownLinkVersion { + message: message.message, + })?; + + let source_arguments = + extract_source_directive_arguments(schema, &link.source_directive_name)?; + + let connect_arguments = + extract_connect_directive_arguments(schema, &link.connect_directive_name)?; + + connect_arguments + .into_iter() + .map(|args| { + Self::from_directives(schema, subgraph_name, link.spec, args, &source_arguments) + }) + .collect::, _>>() + } + + fn from_directives( + schema: &Schema, + subgraph_name: &str, + spec: ConnectSpec, + connect: ConnectDirectiveArguments, + source_arguments: &[SourceDirectiveArguments], + ) -> Result { + let source = connect + .source + .and_then(|name| source_arguments.iter().find(|s| s.name == name)); + let source_name = source.map(|s| s.name.clone()); + + // Create our transport + let connect_http = connect + .http + .ok_or_else(|| internal_error!("@connect(http:) missing"))?; + let source_http = source.map(|s| &s.http); + let transport = HttpJsonTransport::from_directive(connect_http, source_http, spec)?; + + // Get our batch and error settings + let batch_settings = connect.batch; + let connect_errors = connect.errors.as_ref(); + let source_errors = source.and_then(|s| s.errors.as_ref()); + // Use the connector setting if available, otherwise, use source setting + let is_success = connect + .is_success + .as_ref() + .or_else(|| source.and_then(|s| s.is_success.as_ref())); + + let error_settings = + ConnectorErrorsSettings::from_directive(connect_errors, source_errors, is_success); + + // Collect all variables and subselections used in the request mappings + let request_references: IndexSet> = + transport.variable_references().collect(); + + // Collect all variables and subselections used in response mappings (including errors.message and errors.extensions) + let response_references: IndexSet> = connect + .selection + .variable_references() + .chain(error_settings.variable_references()) + .collect(); + + // Store a map of variable names and the set of first-level of keys so we can + // more efficiently clone values for mappings (especially for $context and $env) + let request_variable_keys = extract_variable_key_references(request_references.iter()); + let response_variable_keys = extract_variable_key_references(response_references.iter()); + + // Store a set of header names referenced in mappings (these are second-level keys) + let request_headers = extract_header_references(&request_references); // $request in request mappings + let response_headers = extract_header_references(&response_references); // $request or $response in response mappings + + // Last couple of items here! + let entity_resolver = determine_entity_resolver( + &connect.position, + connect.entity, + schema, + &request_variable_keys, + ); + let label = Label::new( + subgraph_name, + source_name.as_ref(), + &transport, + entity_resolver.as_ref(), + ); + let id = ConnectId { + subgraph_name: subgraph_name.to_string(), + source_name, + named: connect.connector_id, + directive: connect.position, + }; + + Ok(Connector { + id, + transport, + selection: connect.selection, + entity_resolver, + config: None, + max_requests: None, + spec, + request_headers, + response_headers, + request_variable_keys, + response_variable_keys, + batch_settings, + error_settings, + label, + }) + } + + pub(crate) fn variable_references(&self) -> impl Iterator> { + self.transport.variable_references().chain( + self.selection + .external_var_paths() + .into_iter() + .flat_map(PathSelection::variable_reference), + ) + } + + /// Create a field set for a `@key` using `$args`, `$this`, or `$batch` variables. + pub fn resolvable_key(&self, schema: &Schema) -> Result>, String> { + match &self.entity_resolver { + None => Ok(None), + Some(EntityResolver::Explicit) => { + make_key_field_set_from_variables( + schema, + &self.id.directive.base_type_name(schema).ok_or_else(|| { + format!("Missing field {}", self.id.directive.coordinate()) + })?, + self.variable_references(), + Namespace::Args, + ) + } + Some(EntityResolver::Implicit) => { + make_key_field_set_from_variables( + schema, + &self.id.directive.parent_type_name().ok_or_else(|| { + format!("Missing type {}", self.id.directive.coordinate()) + })?, + self.variable_references(), + Namespace::This, + ) + } + Some(EntityResolver::TypeBatch) => { + make_key_field_set_from_variables( + schema, + &self.id.directive.base_type_name(schema).ok_or_else(|| { + format!("Missing type {}", self.id.directive.coordinate()) + })?, + self.variable_references(), + Namespace::Batch, + ) + } + Some(EntityResolver::TypeSingle) => { + make_key_field_set_from_variables( + schema, + &self.id.directive.base_type_name(schema).ok_or_else(|| { + format!("Missing type {}", self.id.directive.coordinate()) + })?, + self.variable_references(), + Namespace::This, + ) + } + } + .map_err(|_| { + format!( + "Failed to create key for connector {}", + self.id.coordinate() + ) + }) + } + + /// Create an identifier for this connector that can be used for configuration and service identification + /// `source_name` will be `None` here when we are using a "sourceless" connector. In this situation, we'll use + /// the `synthetic_name` instead so that we have some kind of a unique identifier for this source. + pub fn source_config_key(&self) -> String { + if let Some(source_name) = &self.id.source_name { + format!("{}.{}", self.id.subgraph_name, source_name) + } else { + format!("{}.{}", self.id.subgraph_name, self.id.synthetic_name()) + } + } + + /// Get the name of the `@connect` directive associated with this [`Connector`] instance. + /// + /// The [`Name`] can be used to help locate the connector within a source file. + pub fn name(&self) -> Name { + match &self.id.directive { + ConnectorPosition::Field(field_position) => field_position.directive_name.clone(), + ConnectorPosition::Type(type_position) => type_position.directive_name.clone(), + } + } + + /// Get the `id`` of the `@connect` directive associated with this [`Connector`] instance. + pub fn id(&self) -> String { + self.id.name() + } +} + +/// A descriptive label for a connector, used for debugging and logging. +#[derive(Debug, Clone)] +pub struct Label(pub String); + +impl Label { + fn new( + subgraph_name: &str, + source: Option<&SourceName>, + transport: &HttpJsonTransport, + entity_resolver: Option<&EntityResolver>, + ) -> Self { + let source = source.map(SourceName::as_str).unwrap_or_default(); + let batch = match entity_resolver { + Some(EntityResolver::TypeBatch) => "[BATCH] ", + _ => "", + }; + Self(format!( + "{batch}{subgraph_name}.{source} {}", + transport.label() + )) + } +} + +impl From<&str> for Label { + fn from(label: &str) -> Self { + Self(label.to_string()) + } +} + +impl AsRef for Label { + fn as_ref(&self) -> &str { + &self.0 + } +} + +fn determine_entity_resolver( + position: &ConnectorPosition, + entity: bool, + schema: &Schema, + request_variables: &IndexMap>, +) -> Option { + match position { + ConnectorPosition::Field(_) => { + match (entity, position.on_root_type(schema)) { + (true, _) => Some(EntityResolver::Explicit), // Query.foo @connect(entity: true) + (_, false) => Some(EntityResolver::Implicit), // Foo.bar @connect + _ => None, + } + } + ConnectorPosition::Type(_) => { + if request_variables.contains_key(&Namespace::Batch) { + Some(EntityResolver::TypeBatch) // Foo @connect($batch) + } else { + Some(EntityResolver::TypeSingle) // Foo @connect($this) + } + } + } +} + +/// Get any headers referenced in the variable references by looking at both Request and Response namespaces. +fn extract_header_references( + variable_references: &IndexSet>, +) -> HashSet { + variable_references + .iter() + .flat_map(|var_ref| { + if var_ref.namespace.namespace != Namespace::Request + && var_ref.namespace.namespace != Namespace::Response + { + Vec::new() + } else { + var_ref + .selection + .get("headers") + .map(|headers_subtrie| headers_subtrie.keys().cloned().collect()) + .unwrap_or_default() + } + }) + .collect() +} + +/// Create a map of variable namespaces like env and context to a set of the +/// root keys referenced in the connector +fn extract_variable_key_references<'a>( + references: impl Iterator>, +) -> IndexMap> { + let mut variable_keys: IndexMap> = IndexMap::default(); + + for var_ref in references { + // make there there's a key for each namespace + let set = variable_keys + .entry(var_ref.namespace.namespace) + .or_default(); + + for key in var_ref.selection.keys() { + set.insert(key.to_string()); + } + } + + variable_keys +} + +#[cfg(test)] +mod tests { + use apollo_compiler::Schema; + use insta::assert_debug_snapshot; + + use super::*; + use crate::ValidFederationSubgraphs; + use crate::schema::FederationSchema; + use crate::supergraph::extract_subgraphs_from_supergraph; + + static SIMPLE_SUPERGRAPH: &str = include_str!("./tests/schemas/simple.graphql"); + static SIMPLE_SUPERGRAPH_V0_2: &str = include_str!("./tests/schemas/simple_v0_2.graphql"); + + fn get_subgraphs(supergraph_sdl: &str) -> ValidFederationSubgraphs { + let schema = Schema::parse(supergraph_sdl, "supergraph.graphql").unwrap(); + let supergraph_schema = FederationSchema::new(schema).unwrap(); + extract_subgraphs_from_supergraph(&supergraph_schema, Some(true)).unwrap() + } + + #[test] + fn test_from_schema() { + let subgraphs = get_subgraphs(SIMPLE_SUPERGRAPH); + let subgraph = subgraphs.get("connectors").unwrap(); + let connectors = Connector::from_schema(subgraph.schema.schema(), "connectors").unwrap(); + assert_debug_snapshot!(&connectors, @r###" + [ + Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "json", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.users), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "https://jsonplaceholder.typicode.com/", + location: 0..37, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/users", + location: 0..6, + }, + ), + ], + }, + method: Get, + headers: [ + Header { + name: "authtoken", + source: From( + "x-auth-token", + ), + }, + Header { + name: "user-agent", + source: Value( + HeaderValue( + StringTemplate { + parts: [ + Constant( + Constant { + value: "Firefox", + location: 0..7, + }, + ), + ], + }, + ), + ), + }, + ], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 3..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 3..7, + ), + }, + }, + }, + ], + range: Some( + 0..7, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: {}, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.json http: GET /users", + ), + }, + Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "json", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.posts), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "https://jsonplaceholder.typicode.com/", + location: 0..37, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/posts", + location: 0..6, + }, + ), + ], + }, + method: Get, + headers: [ + Header { + name: "authtoken", + source: From( + "x-auth-token", + ), + }, + Header { + name: "user-agent", + source: Value( + HeaderValue( + StringTemplate { + parts: [ + Constant( + Constant { + value: "Firefox", + location: 0..7, + }, + ), + ], + }, + ), + ), + }, + ], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "title", + ), + range: Some( + 3..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 3..8, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "body", + ), + range: Some( + 9..13, + ), + }, + WithRange { + node: Empty, + range: Some( + 13..13, + ), + }, + ), + range: Some( + 9..13, + ), + }, + }, + }, + ], + range: Some( + 0..13, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + request_headers: {}, + response_headers: {}, + request_variable_keys: {}, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.json http: GET /posts", + ), + }, + ] + "###); + } + + #[test] + fn test_from_schema_v0_2() { + let subgraphs = get_subgraphs(SIMPLE_SUPERGRAPH_V0_2); + let subgraph = subgraphs.get("connectors").unwrap(); + let connectors = Connector::from_schema(subgraph.schema.schema(), "connectors").unwrap(); + assert_debug_snapshot!(&connectors); + } +} diff --git a/apollo-federation/src/connectors/models/headers.rs b/apollo-federation/src/connectors/models/headers.rs new file mode 100644 index 0000000000..bc95846890 --- /dev/null +++ b/apollo-federation/src/connectors/models/headers.rs @@ -0,0 +1,263 @@ +#![deny(clippy::pedantic)] + +use std::error::Error; +use std::fmt::Debug; +use std::fmt::Display; +use std::fmt::Formatter; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Value; +use apollo_compiler::parser::SourceSpan; +use either::Either; +use http::HeaderName; +use http::header; + +use crate::connectors::ConnectSpec; +use crate::connectors::JSONSelection; +use crate::connectors::header::HeaderValue; +use crate::connectors::spec::http::HEADERS_ARGUMENT_NAME; +use crate::connectors::spec::http::HTTP_HEADER_MAPPING_FROM_ARGUMENT_NAME; +use crate::connectors::spec::http::HTTP_HEADER_MAPPING_NAME_ARGUMENT_NAME; +use crate::connectors::spec::http::HTTP_HEADER_MAPPING_VALUE_ARGUMENT_NAME; +use crate::connectors::string_template; + +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)] +pub enum OriginatingDirective { + Source, + Connect, +} + +#[derive(Clone)] +pub struct Header { + pub name: HeaderName, + pub(crate) name_node: Option>, + pub source: HeaderSource, + pub(crate) source_node: Option>, + pub originating_directive: OriginatingDirective, +} + +impl Header { + /// Get a list of headers from the `headers` argument in a `@connect` or `@source` directive. + pub(crate) fn from_http_arg( + http_arg: &[(Name, Node)], + originating_directive: OriginatingDirective, + spec: ConnectSpec, + ) -> Vec> { + let Some(headers_arg) = http_arg + .iter() + .find_map(|(key, value)| (*key == HEADERS_ARGUMENT_NAME).then_some(value)) + else { + return Vec::new(); + }; + if let Some(values) = headers_arg.as_list() { + values + .iter() + .map(|n| Self::from_single(n, originating_directive, spec)) + .collect() + } else if headers_arg.as_object().is_some() { + vec![Self::from_single(headers_arg, originating_directive, spec)] + } else { + vec![Err(HeaderParseError::Other { + message: format!("`{HEADERS_ARGUMENT_NAME}` must be an object or list of objects"), + node: headers_arg.clone(), + })] + } + } + + /// Create a single `Header` directly, not from schema. Mostly useful for testing. + pub fn from_values( + name: HeaderName, + source: HeaderSource, + originating_directive: OriginatingDirective, + ) -> Self { + Self { + name, + name_node: None, + source, + source_node: None, + originating_directive, + } + } + + /// Build a single [`Self`] from a single entry in the `headers` arg. + fn from_single( + node: &Node, + originating_directive: OriginatingDirective, + spec: ConnectSpec, + ) -> Result { + let mappings = node.as_object().ok_or_else(|| HeaderParseError::Other { + message: "the HTTP header mapping is not an object".to_string(), + node: node.clone(), + })?; + let name_node = mappings + .iter() + .find_map(|(name, value)| { + (*name == HTTP_HEADER_MAPPING_NAME_ARGUMENT_NAME).then_some(value) + }) + .ok_or_else(|| HeaderParseError::Other { + message: format!("missing `{HTTP_HEADER_MAPPING_NAME_ARGUMENT_NAME}` field"), + node: node.clone(), + })?; + let name = name_node + .as_str() + .ok_or_else(|| format!("`{HTTP_HEADER_MAPPING_NAME_ARGUMENT_NAME}` is not a string")) + .and_then(|name_str| { + HeaderName::try_from(name_str) + .map_err(|_| format!("the value `{name_str}` is an invalid HTTP header name")) + }) + .map_err(|message| HeaderParseError::Other { + message, + node: name_node.clone(), + })?; + + if RESERVED_HEADERS.contains(&name) { + return Err(HeaderParseError::Other { + message: format!("header '{name}' is reserved and cannot be set by a connector"), + node: name_node.clone(), + }); + } + + let from = mappings + .iter() + .find(|(name, _value)| *name == HTTP_HEADER_MAPPING_FROM_ARGUMENT_NAME); + let value = mappings + .iter() + .find(|(name, _value)| *name == HTTP_HEADER_MAPPING_VALUE_ARGUMENT_NAME); + + match (from, value) { + (Some(_), None) if STATIC_HEADERS.contains(&name) => { + Err(HeaderParseError::Other{ + message: format!( + "header '{name}' can't be set with `{HTTP_HEADER_MAPPING_FROM_ARGUMENT_NAME}`, only with `{HTTP_HEADER_MAPPING_VALUE_ARGUMENT_NAME}`" + ), + node: name_node.clone() + }) + } + (Some((_, from_node)), None) => { + from_node.as_str() + .ok_or_else(|| format!("`{HTTP_HEADER_MAPPING_FROM_ARGUMENT_NAME}` is not a string")) + .and_then(|from_str| { + HeaderName::try_from(from_str).map_err(|_| { + format!("the value `{from_str}` is an invalid HTTP header name") + }) + }) + .map(|from| Self { + name, + name_node: Some(name_node.clone()), + source: HeaderSource::From(from), + source_node: Some(from_node.clone()), + originating_directive + }) + .map_err(|message| HeaderParseError::Other{ message, node: from_node.clone()}) + } + (None, Some((_, value_node))) => { + value_node + .as_str() + .ok_or_else(|| HeaderParseError::Other{ + message: format!("`{HTTP_HEADER_MAPPING_VALUE_ARGUMENT_NAME}` field in HTTP header mapping must be a string"), + node: value_node.clone() + }) + .and_then(|value_str| { + HeaderValue::parse_with_spec( + value_str, + spec, + ) + .map_err(|err| HeaderParseError::ValueError {err, node: value_node.clone()}) + }) + .map(|value| Self { + name, + name_node: Some(name_node.clone()), + source: HeaderSource::Value(value), + source_node: Some(value_node.clone()), + originating_directive + }) + } + (None, None) => { + Err(HeaderParseError::Other { + message: format!("either `{HTTP_HEADER_MAPPING_FROM_ARGUMENT_NAME}` or `{HTTP_HEADER_MAPPING_VALUE_ARGUMENT_NAME}` must be set"), + node: node.clone(), + }) + }, + (Some((from_name, _)), Some((value_name, _))) => { + Err(HeaderParseError::ConflictingArguments { + message: format!("`{HTTP_HEADER_MAPPING_FROM_ARGUMENT_NAME}` and `{HTTP_HEADER_MAPPING_VALUE_ARGUMENT_NAME}` can't be set at the same time"), + from_location: from_name.location(), + value_location: value_name.location(), + }) + } + } + } +} + +#[allow(clippy::missing_fields_in_debug)] +impl Debug for Header { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Header") + .field("name", &self.name) + .field("source", &self.source) + .finish() + } +} + +#[derive(Clone, Debug)] +pub enum HeaderSource { + From(HeaderName), + Value(HeaderValue), +} + +impl HeaderSource { + pub(crate) fn expressions(&self) -> impl Iterator { + match self { + HeaderSource::From(_) => Either::Left(std::iter::empty()), + HeaderSource::Value(value) => Either::Right(value.expressions().map(|e| &e.expression)), + } + } +} + +#[derive(Debug)] +pub(crate) enum HeaderParseError { + ValueError { + err: string_template::Error, + node: Node, + }, + /// Both `value` and `from` are set + ConflictingArguments { + message: String, + from_location: Option, + value_location: Option, + }, + Other { + message: String, + node: Node, + }, +} + +impl Display for HeaderParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::ConflictingArguments { message, .. } | Self::Other { message, .. } => { + write!(f, "{message}") + } + Self::ValueError { err, .. } => write!(f, "{err}"), + } + } +} + +impl Error for HeaderParseError {} + +const RESERVED_HEADERS: [HeaderName; 11] = [ + header::CONNECTION, + header::PROXY_AUTHENTICATE, + header::PROXY_AUTHORIZATION, + header::TE, + header::TRAILER, + header::TRANSFER_ENCODING, + header::UPGRADE, + header::CONTENT_LENGTH, + header::CONTENT_ENCODING, + header::ACCEPT_ENCODING, + HeaderName::from_static("keep-alive"), +]; + +const STATIC_HEADERS: [HeaderName; 3] = [header::CONTENT_TYPE, header::ACCEPT, header::HOST]; diff --git a/apollo-federation/src/connectors/models/http_json_transport.rs b/apollo-federation/src/connectors/models/http_json_transport.rs new file mode 100644 index 0000000000..4e3748ef2c --- /dev/null +++ b/apollo-federation/src/connectors/models/http_json_transport.rs @@ -0,0 +1,776 @@ +use std::fmt::Display; +use std::fmt::Formatter; +use std::fmt::Write; +use std::iter::once; +use std::str::FromStr; + +use apollo_compiler::collections::IndexMap; +use either::Either; +use http::Uri; +use http::uri::InvalidUri; +use http::uri::InvalidUriParts; +use http::uri::Parts; +use http::uri::PathAndQuery; +use serde_json_bytes::Value; +use serde_json_bytes::json; +use thiserror::Error; + +use super::ProblemLocation; +use crate::connectors::ApplyToError; +use crate::connectors::ConnectSpec; +use crate::connectors::JSONSelection; +use crate::connectors::Namespace; +use crate::connectors::PathSelection; +use crate::connectors::StringTemplate; +use crate::connectors::json_selection::ExternalVarPaths; +use crate::connectors::models::Header; +use crate::connectors::spec::ConnectHTTPArguments; +use crate::connectors::spec::SourceHTTPArguments; +use crate::connectors::string_template; +use crate::connectors::string_template::UriString; +use crate::connectors::string_template::write_value; +use crate::connectors::variable::VariableReference; +use crate::error::FederationError; + +#[derive(Clone, Debug, Default)] +pub struct HttpJsonTransport { + pub source_template: Option, + pub connect_template: StringTemplate, + pub method: HTTPMethod, + pub headers: Vec
, + pub body: Option, + pub source_path: Option, + pub source_query_params: Option, + pub connect_path: Option, + pub connect_query_params: Option, +} + +impl HttpJsonTransport { + pub fn from_directive( + http: ConnectHTTPArguments, + source: Option<&SourceHTTPArguments>, + spec: ConnectSpec, + ) -> Result { + let (method, connect_url) = if let Some(url) = &http.get { + (HTTPMethod::Get, url) + } else if let Some(url) = &http.post { + (HTTPMethod::Post, url) + } else if let Some(url) = &http.patch { + (HTTPMethod::Patch, url) + } else if let Some(url) = &http.put { + (HTTPMethod::Put, url) + } else if let Some(url) = &http.delete { + (HTTPMethod::Delete, url) + } else { + return Err(FederationError::internal("missing http method")); + }; + + let mut headers = http.headers; + for header in source.map(|source| &source.headers).into_iter().flatten() { + if !headers + .iter() + .any(|connect_header| connect_header.name == header.name) + { + headers.push(header.clone()); + } + } + + Ok(Self { + source_template: source.map(|source| source.base_url.template.clone()), + connect_template: StringTemplate::parse_with_spec(connect_url, spec).map_err( + |e: string_template::Error| { + FederationError::internal(format!( + "could not parse URL template: {message}", + message = e.message + )) + }, + )?, + method, + headers, + body: http.body, + source_path: source.and_then(|s| s.path.clone()), + source_query_params: source.and_then(|s| s.query_params.clone()), + connect_path: http.path, + connect_query_params: http.query_params, + }) + } + + pub(super) fn label(&self) -> String { + format!("http: {} {}", self.method, self.connect_template) + } + + pub(crate) fn variable_references(&self) -> impl Iterator> { + let url_selections = self.connect_template.expressions().map(|e| &e.expression); + let header_selections = self + .headers + .iter() + .flat_map(|header| header.source.expressions()); + + let source_selections = self + .source_template + .iter() + .flat_map(|template| template.expressions().map(|e| &e.expression)); + + url_selections + .chain(header_selections) + .chain(source_selections) + .chain(self.body.iter()) + .chain(self.source_path.iter()) + .chain(self.source_query_params.iter()) + .chain(self.connect_path.iter()) + .chain(self.connect_query_params.iter()) + .flat_map(|b| { + b.external_var_paths() + .into_iter() + .flat_map(PathSelection::variable_reference) + }) + } + + pub fn make_uri( + &self, + inputs: &IndexMap, + ) -> Result<(Uri, Vec<(ProblemLocation, ApplyToError)>), MakeUriError> { + let mut uri_parts = Parts::default(); + let mut warnings = Vec::new(); + + let (connect_uri, connect_template_warnings) = + self.connect_template.interpolate_uri(inputs)?; + warnings.extend( + connect_template_warnings + .into_iter() + .map(|warning| (ProblemLocation::ConnectUrl, warning)), + ); + let resolved_source_uri = match &self.source_template { + Some(template) => { + let (uri, source_template_warnings) = template.interpolate_uri(inputs)?; + warnings.extend( + source_template_warnings + .into_iter() + .map(|warning| (ProblemLocation::SourceUrl, warning)), + ); + Some(uri) + } + None => None, + }; + + if let Some(source_uri) = &resolved_source_uri { + uri_parts.scheme = source_uri.scheme().cloned(); + uri_parts.authority = source_uri.authority().cloned(); + } else { + uri_parts.scheme = connect_uri.scheme().cloned(); + uri_parts.authority = connect_uri.authority().cloned(); + } + + let mut path = UriString::new(); + if let Some(source_uri) = &resolved_source_uri { + path.write_without_encoding(source_uri.path())?; + } + if let Some(source_path) = self.source_path.as_ref() { + warnings.extend( + extend_path_from_expression(&mut path, source_path, inputs)? + .into_iter() + .map(|error| (ProblemLocation::SourcePath, error)), + ); + } + let connect_path = connect_uri.path(); + if !connect_path.is_empty() && connect_path != "/" { + if path.ends_with('/') { + path.write_without_encoding(connect_path.trim_start_matches('/'))?; + } else if connect_path.starts_with('/') { + path.write_without_encoding(connect_path)?; + } else { + path.write_without_encoding("/")?; + path.write_without_encoding(connect_path)?; + }; + } + if let Some(connect_path) = self.connect_path.as_ref() { + warnings.extend( + extend_path_from_expression(&mut path, connect_path, inputs)? + .into_iter() + .map(|error| (ProblemLocation::ConnectPath, error)), + ); + } + + let mut query = UriString::new(); + if let Some(source_uri_query) = resolved_source_uri + .as_ref() + .and_then(|source_uri| source_uri.query()) + { + query.write_without_encoding(source_uri_query)?; + } + if let Some(source_query) = self.source_query_params.as_ref() { + warnings.extend( + extend_query_from_expression(&mut query, source_query, inputs)? + .into_iter() + .map(|error| (ProblemLocation::SourceQueryParams, error)), + ); + } + let connect_query = connect_uri.query().unwrap_or_default(); + if !connect_query.is_empty() { + if !query.is_empty() && !query.ends_with('&') { + query.write_without_encoding("&")?; + } + query.write_without_encoding(connect_query)?; + } + if let Some(connect_query) = self.connect_query_params.as_ref() { + warnings.extend( + extend_query_from_expression(&mut query, connect_query, inputs)? + .into_iter() + .map(|error| (ProblemLocation::ConnectQueryParams, error)), + ); + } + + let path = path.into_string(); + let query = query.into_string(); + + uri_parts.path_and_query = Some(match (path.is_empty(), query.is_empty()) { + (true, true) => PathAndQuery::from_static(""), + (true, false) => PathAndQuery::try_from(format!("?{query}"))?, + (false, true) => PathAndQuery::try_from(path)?, + (false, false) => PathAndQuery::try_from(format!("{path}?{query}"))?, + }); + + let uri = Uri::from_parts(uri_parts).map_err(MakeUriError::BuildMergedUri)?; + + Ok((uri, warnings)) + } +} + +/// Path segments can optionally be appended from the `http.path` inputs, each of which are a +/// [`JSONSelection`] expression expected to evaluate to an array. +fn extend_path_from_expression( + path: &mut UriString, + expression: &JSONSelection, + inputs: &IndexMap, +) -> Result, MakeUriError> { + let (value, warnings) = expression.apply_with_vars(&json!({}), inputs); + let Some(value) = value else { + return Ok(warnings); + }; + let Value::Array(values) = value else { + return Err(MakeUriError::PathComponents( + "Expression did not evaluate to an array".into(), + )); + }; + for value in &values { + if !path.ends_with('/') { + path.write_trusted("/")?; + } + write_value(&mut *path, value) + .map_err(|err| MakeUriError::PathComponents(err.to_string()))?; + } + Ok(warnings) +} + +fn extend_query_from_expression( + query: &mut UriString, + expression: &JSONSelection, + inputs: &IndexMap, +) -> Result, MakeUriError> { + let (value, warnings) = expression.apply_with_vars(&json!({}), inputs); + let Some(value) = value else { + return Ok(warnings); + }; + let Value::Object(map) = value else { + return Err(MakeUriError::QueryParams( + "Expression did not evaluate to an object".into(), + )); + }; + + let all_params = map + .iter() + .filter(|(_, value)| !value.is_null()) + .flat_map(|(key, value)| { + if let Value::Array(values) = value { + // If the top-level value is an array, we're going to turn that into repeated params + Either::Left(values.iter().map(|value| (key.as_str(), value))) + } else { + Either::Right(once((key.as_str(), value))) + } + }); + + for (key, value) in all_params { + if !query.is_empty() && !query.ends_with('&') { + query.write_trusted("&")?; + } + query.write_str(key)?; + query.write_trusted("=")?; + write_value(&mut *query, value) + .map_err(|err| MakeUriError::QueryParams(err.to_string()))?; + } + Ok(warnings) +} + +#[derive(Debug, Error)] +pub enum MakeUriError { + #[error("Error building URI: {0}")] + ParsePathAndQuery(#[from] InvalidUri), + #[error("Error building URI: {0}")] + BuildMergedUri(InvalidUriParts), + #[error("Error rendering URI template: {0}")] + TemplateGenerationError(#[from] string_template::Error), + #[error("Internal error building URI")] + WriteError(#[from] std::fmt::Error), + #[error("Error building path components from expression: {0}")] + PathComponents(String), + #[error("Error building query parameters from queryParams: {0}")] + QueryParams(String), +} + +/// The HTTP arguments needed for a connect request +#[derive(Debug, Clone, Copy, Default)] +pub enum HTTPMethod { + #[default] + Get, + Post, + Patch, + Put, + Delete, +} + +impl HTTPMethod { + #[inline] + pub const fn as_str(&self) -> &str { + match self { + HTTPMethod::Get => "GET", + HTTPMethod::Post => "POST", + HTTPMethod::Patch => "PATCH", + HTTPMethod::Put => "PUT", + HTTPMethod::Delete => "DELETE", + } + } +} + +impl FromStr for HTTPMethod { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_uppercase().as_str() { + "GET" => Ok(HTTPMethod::Get), + "POST" => Ok(HTTPMethod::Post), + "PATCH" => Ok(HTTPMethod::Patch), + "PUT" => Ok(HTTPMethod::Put), + "DELETE" => Ok(HTTPMethod::Delete), + _ => Err(format!("Invalid HTTP method: {s}")), + } + } +} + +impl Display for HTTPMethod { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +#[cfg(test)] +mod test_make_uri { + use std::str::FromStr; + + use apollo_compiler::collections::IndexMap; + use pretty_assertions::assert_eq; + use serde_json_bytes::json; + + use super::*; + use crate::connectors::JSONSelection; + + /// Take data from all the places it can come from and make sure they combine in the right order + #[test] + fn merge_all_sources() { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str( + "http://example.com/sourceUri?shared=sourceUri&sourceUri=sourceUri", + ) + .ok(), + connect_template: StringTemplate::parse_with_spec( + "/{$args.connectUri}?shared={$args.connectUri}&{$args.connectUri}={$args.connectUri}", + ConnectSpec::latest(), + ) + .unwrap(), + source_path: JSONSelection::parse("$args.sourcePath").ok(), + connect_path: JSONSelection::parse("$args.connectPath").ok(), + source_query_params: JSONSelection::parse("$args.sourceQuery").ok(), + connect_query_params: JSONSelection::parse("$args.connectQuery").ok(), + ..Default::default() + }; + let inputs = IndexMap::from_iter([( + "$args".to_string(), + json!({ + "connectUri": "connectUri", + "sourcePath": ["sourcePath1", "sourcePath2"], + "connectPath": ["connectPath1", "connectPath2"], + "sourceQuery": {"shared": "sourceQuery", "sourceQuery": "sourceQuery"}, + "connectQuery": {"shared": "connectQuery", "connectQuery": "connectQuery"}, + }), + )]); + let (url, _) = transport.make_uri(&inputs).unwrap(); + assert_eq!( + url.to_string(), + "http://example.com/sourceUri/sourcePath1/sourcePath2/connectUri/connectPath1/connectPath2\ + ?shared=sourceUri&sourceUri=sourceUri\ + &shared=sourceQuery&sourceQuery=sourceQuery\ + &shared=connectUri&connectUri=connectUri\ + &shared=connectQuery&connectQuery=connectQuery" + ); + } + + macro_rules! this { + ($($value:tt)*) => {{ + let mut map = IndexMap::with_capacity_and_hasher(1, Default::default()); + map.insert("$this".to_string(), serde_json_bytes::json!({ $($value)* })); + map + }}; + } + + mod combining_paths { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::connect_only("https://localhost:8080/v1", "/hello")] + #[case::source_only("https://localhost:8080/v1/", "hello")] + #[case::neither("https://localhost:8080/v1", "hello")] + #[case::both("https://localhost:8080/v1/", "/hello")] + fn slashes_between_source_and_connect( + #[case] source_uri: &str, + #[case] connect_path: &str, + ) { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str(source_uri).ok(), + connect_template: connect_path.parse().unwrap(), + ..Default::default() + }; + assert_eq!( + transport + .make_uri(&Default::default()) + .unwrap() + .0 + .to_string(), + "https://localhost:8080/v1/hello" + ); + } + + #[rstest] + #[case::when_base_has_trailing("http://localhost/")] + #[case::when_base_does_not_have_trailing("http://localhost")] + fn handle_slashes_when_adding_path_expression(#[case] base: &str) { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str(base).ok(), + source_path: JSONSelection::parse("$([1, 2])").ok(), + ..Default::default() + }; + assert_eq!( + transport + .make_uri(&Default::default()) + .unwrap() + .0 + .to_string(), + "http://localhost/1/2" + ); + } + + #[test] + fn preserve_trailing_slash_from_connect() { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str("https://localhost:8080/v1").ok(), + connect_template: "/hello/".parse().unwrap(), + ..Default::default() + }; + assert_eq!( + transport + .make_uri(&Default::default()) + .unwrap() + .0 + .to_string(), + "https://localhost:8080/v1/hello/" + ); + } + + #[test] + fn preserve_trailing_slash_from_source() { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str("https://localhost:8080/v1/").ok(), + connect_template: "/".parse().unwrap(), + ..Default::default() + }; + assert_eq!( + transport + .make_uri(&Default::default()) + .unwrap() + .0 + .to_string(), + "https://localhost:8080/v1/" + ); + } + + #[test] + fn preserve_no_trailing_slash_from_source() { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str("https://localhost:8080/v1").ok(), + connect_template: "/".parse().unwrap(), + ..Default::default() + }; + assert_eq!( + transport + .make_uri(&Default::default()) + .unwrap() + .0 + .to_string(), + "https://localhost:8080/v1" + ); + } + + #[test] + fn add_path_before_query_params() { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str("https://localhost:8080/v1?something") + .ok(), + connect_template: "/hello".parse().unwrap(), + ..Default::default() + }; + assert_eq!( + transport + .make_uri(&this! { "id": 42 }) + .unwrap() + .0 + .to_string(), + "https://localhost:8080/v1/hello?something" + ); + } + + #[test] + fn trailing_slash_plus_query_params() { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str("https://localhost:8080/v1/?something") + .ok(), + connect_template: "/hello/".parse().unwrap(), + ..Default::default() + }; + assert_eq!( + transport + .make_uri(&this! { "id": 42 }) + .unwrap() + .0 + .to_string(), + "https://localhost:8080/v1/hello/?something" + ); + } + + #[test] + fn with_trailing_slash_in_base_plus_query_params() { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str("https://localhost:8080/v1/?foo=bar") + .ok(), + connect_template: StringTemplate::parse_with_spec( + "/hello/{$this.id}?id={$this.id}", + ConnectSpec::latest(), + ) + .unwrap(), + ..Default::default() + }; + assert_eq!( + transport + .make_uri(&this! {"id": 42 }) + .unwrap() + .0 + .to_string(), + "https://localhost:8080/v1/hello/42?foo=bar&id=42" + ); + } + } + + mod merge_query { + use pretty_assertions::assert_eq; + + use super::*; + #[test] + fn source_only() { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str("http://localhost/users?a=b").ok(), + connect_template: "/123".parse().unwrap(), + ..Default::default() + }; + assert_eq!( + transport.make_uri(&Default::default()).unwrap().0, + "http://localhost/users/123?a=b" + ); + } + + #[test] + fn connect_only() { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str("http://localhost/users").ok(), + connect_template: "?a=b&c=d".parse().unwrap(), + ..Default::default() + }; + assert_eq!( + transport.make_uri(&Default::default()).unwrap().0, + "http://localhost/users?a=b&c=d" + ) + } + + #[test] + fn combine_from_both_uris() { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str("http://localhost/users?a=b").ok(), + connect_template: "?c=d".parse().unwrap(), + ..Default::default() + }; + assert_eq!( + transport.make_uri(&Default::default()).unwrap().0, + "http://localhost/users?a=b&c=d" + ) + } + + #[test] + fn source_and_connect_have_same_param() { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str("http://localhost/users?a=b").ok(), + connect_template: "?a=d".parse().unwrap(), + ..Default::default() + }; + assert_eq!( + transport.make_uri(&Default::default()).unwrap().0, + "http://localhost/users?a=b&a=d" + ) + } + + #[test] + fn repeated_params_from_array() { + let transport = HttpJsonTransport { + connect_template: "http://localhost".parse().unwrap(), + connect_query_params: JSONSelection::parse("$args.connectQuery").ok(), + ..Default::default() + }; + let inputs = IndexMap::from_iter([( + "$args".to_string(), + json!({ + "connectQuery": {"multi": ["first", "second"]}, + }), + )]); + assert_eq!( + transport.make_uri(&inputs).unwrap().0, + "http://localhost?multi=first&multi=second" + ) + } + } + + #[test] + fn fragments_are_dropped() { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str("http://localhost/source?a=b#SourceFragment") + .ok(), + connect_template: "/connect?c=d#connectFragment".parse().unwrap(), + ..Default::default() + }; + assert_eq!( + transport.make_uri(&Default::default()).unwrap().0, + "http://localhost/source/connect?a=b&c=d" + ) + } + + /// When merging source and connect pieces, we sometimes have to apply encoding as we go. + /// This double-checks that we never _double_ encode pieces. + #[test] + fn pieces_are_not_double_encoded() { + let transport = HttpJsonTransport { + source_template: StringTemplate::from_str( + "http://localhost/source%20path?param=source%20param", + ) + .ok(), + connect_template: "/connect%20path?param=connect%20param".parse().unwrap(), + ..Default::default() + }; + assert_eq!( + transport.make_uri(&Default::default()).unwrap().0, + "http://localhost/source%20path/connect%20path?param=source%20param¶m=connect%20param" + ) + } + + /// Regression test for a very specific case where the resulting `Uri` might not be valid + /// because we did _too little_ work. + #[test] + fn empty_path_and_query() { + let transport = HttpJsonTransport { + source_template: None, + connect_template: "http://localhost/".parse().unwrap(), + ..Default::default() + }; + assert_eq!( + transport.make_uri(&Default::default()).unwrap().0, + "http://localhost/" + ) + } + + #[test] + fn skip_null_query_params() { + let transport = HttpJsonTransport { + source_template: None, + connect_template: "http://localhost/".parse().unwrap(), + connect_query_params: JSONSelection::parse("something: $(null)").ok(), + ..Default::default() + }; + + assert_eq!( + transport.make_uri(&Default::default()).unwrap().0, + "http://localhost/" + ) + } + + #[test] + fn skip_null_path_params() { + let transport = HttpJsonTransport { + source_template: None, + connect_template: "http://localhost/".parse().unwrap(), + connect_path: JSONSelection::parse("$([1, null, 2])").ok(), + ..Default::default() + }; + + assert_eq!( + transport.make_uri(&Default::default()).unwrap().0, + "http://localhost/1/2" + ) + } + + #[test] + fn source_template_variables_retained() { + let transport = HttpJsonTransport { + source_template: StringTemplate::parse_with_spec( + "http://${$config.subdomain}.localhost", + ConnectSpec::latest(), + ) + .ok(), + connect_template: "/connect?c=d".parse().unwrap(), + ..Default::default() + }; + + // Transport variables contain the config reference + transport + .variable_references() + .find(|var_ref| var_ref.namespace.namespace == Namespace::Config) + .unwrap(); + } + + #[test] + fn source_template_interpolated_correctly() { + let transport = HttpJsonTransport { + source_template: StringTemplate::parse_with_spec( + "http://{$config.subdomain}.localhost:{$config.port}", + ConnectSpec::latest(), + ) + .ok(), + connect_template: "/connect?c=d".parse().unwrap(), + ..Default::default() + }; + let mut vars: IndexMap = Default::default(); + vars.insert( + "$config".to_string(), + json!({ "subdomain": "api", "port": 5000 }), + ); + assert_eq!( + transport.make_uri(&vars).unwrap().0, + "http://api.localhost:5000/connect?c=d" + ); + } +} diff --git a/apollo-federation/src/connectors/models/keys.rs b/apollo-federation/src/connectors/models/keys.rs new file mode 100644 index 0000000000..35884bc9b8 --- /dev/null +++ b/apollo-federation/src/connectors/models/keys.rs @@ -0,0 +1,99 @@ +use apollo_compiler::Name; +use apollo_compiler::Schema; +use apollo_compiler::executable::FieldSet; +use apollo_compiler::validation::Valid; +use apollo_compiler::validation::WithErrors; +use itertools::Itertools; + +use super::VariableReference; +use crate::connectors::Namespace; +use crate::connectors::json_selection::SelectionTrie; + +/// Given the variables relevant to entity fetching, synthesize a FieldSet +/// appropriate for use in a @key directive. +pub(crate) fn make_key_field_set_from_variables( + schema: &Schema, + object_type_name: &Name, + variables: impl Iterator>, + namespace: Namespace, +) -> Result>, WithErrors
> { + let params = variables + .filter(|var| var.namespace.namespace == namespace) + .unique() + .collect_vec(); + + if params.is_empty() { + return Ok(None); + } + + // let mut merged = TrieNode::default(); + let mut merged = SelectionTrie::new(); + for param in params { + merged.extend(¶m.selection); + } + + FieldSet::parse_and_validate( + Valid::assume_valid_ref(schema), + object_type_name.clone(), + merged.to_string(), + "", + ) + .map(Some) +} + +#[cfg(test)] +mod tests { + use apollo_compiler::Schema; + use apollo_compiler::name; + + use super::make_key_field_set_from_variables; + use crate::connectors::Namespace; + use crate::connectors::PathSelection; + use crate::connectors::json_selection::location::new_span; + + #[test] + fn test_make_args_field_set_from_variables() { + let result = make_key_field_set_from_variables( + &Schema::parse_and_validate("type Query { t: T } type T { a: A b: ID } type A { b: B c: ID d: ID } type B { c: ID d: ID e: ID }", "").unwrap(), + &name!("T"), + vec![ + PathSelection::parse(new_span("$args.a.b.c")).unwrap().1.variable_reference().unwrap(), + PathSelection::parse(new_span("$args.a.b { d e }")).unwrap().1.variable_reference().unwrap(), + PathSelection::parse(new_span("$args.a.c")).unwrap().1.variable_reference().unwrap(), + PathSelection::parse(new_span("$args.a.d")).unwrap().1.variable_reference().unwrap(), + PathSelection::parse(new_span("$args { b }")).unwrap().1.variable_reference().unwrap(), + ].into_iter(), + Namespace::Args, + ) + .unwrap() + .unwrap(); + + assert_eq!( + result.serialize().no_indent().to_string(), + "a { b { c d e } c d } b" + ); + } + + #[test] + fn test_make_batch_field_set_from_variables() { + let result = make_key_field_set_from_variables( + &Schema::parse_and_validate("type Query { t: T } type T { a: A b: ID } type A { b: B c: ID d: ID } type B { c: ID d: ID e: ID }", "").unwrap(), + &name!("T"), + vec![ + PathSelection::parse(new_span("$batch.a.b.c")).unwrap().1.variable_reference().unwrap(), + PathSelection::parse(new_span("$batch.a.b { d e }")).unwrap().1.variable_reference().unwrap(), + PathSelection::parse(new_span("$batch.a.c")).unwrap().1.variable_reference().unwrap(), + PathSelection::parse(new_span("$batch.a.d")).unwrap().1.variable_reference().unwrap(), + PathSelection::parse(new_span("$batch { b }")).unwrap().1.variable_reference().unwrap(), + ].into_iter(), + Namespace::Batch, + ) + .unwrap() + .unwrap(); + + assert_eq!( + result.serialize().no_indent().to_string(), + "a { b { c d e } c d } b" + ); + } +} diff --git a/apollo-federation/src/connectors/models/problem_location.rs b/apollo-federation/src/connectors/models/problem_location.rs new file mode 100644 index 0000000000..85947dc73a --- /dev/null +++ b/apollo-federation/src/connectors/models/problem_location.rs @@ -0,0 +1,20 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum ProblemLocation { + RequestBody, + SourceUrl, + SourcePath, + SourceQueryParams, + ConnectUrl, + ConnectPath, + ConnectQueryParams, + SourceHeaders, + ConnectHeaders, + IsSuccess, + Selection, + ErrorsMessage, + SourceErrorsExtensions, + ConnectErrorsExtensions, +} diff --git a/apollo-federation/src/connectors/models/source.rs b/apollo-federation/src/connectors/models/source.rs new file mode 100644 index 0000000000..5ccf00d100 --- /dev/null +++ b/apollo-federation/src/connectors/models/source.rs @@ -0,0 +1,240 @@ +use std::fmt; +use std::fmt::Debug; +use std::fmt::Display; +use std::fmt::Formatter; +use std::hash::Hash; +use std::ops::Range; +use std::sync::Arc; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Directive; +use apollo_compiler::ast::Value; +use apollo_compiler::parser::LineColumn; +use apollo_compiler::parser::SourceMap; +use apollo_compiler::schema::Component; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; + +use crate::connectors::spec::connect::CONNECT_SOURCE_ARGUMENT_NAME; +use crate::connectors::spec::source::SOURCE_NAME_ARGUMENT_NAME; +use crate::connectors::validation::Code; +use crate::connectors::validation::Message; + +/// The `name` argument of a `@source` directive. +#[derive(Clone, Eq)] +pub struct SourceName { + pub value: Arc, + node: Option>>, +} + +impl SourceName { + /// Create a `SourceName`, but without checking most validations. + /// + /// Useful for speeding up parsing at runtime & tests. + /// + /// For enhanced validity checks, use [`SourceName::from_directive`] + pub(crate) fn from_directive_permissive( + directive: &Component, + sources: &SourceMap, + ) -> Result { + Self::parse_basics(directive, sources) + } + + /// Cast a string into a `SourceName` for when they don't come from directives + #[must_use] + pub fn cast(name: &str) -> Self { + Self { + value: Arc::from(name), + node: None, + } + } + fn parse_basics( + directive: &Component, + sources: &SourceMap, + ) -> Result { + let coordinate = NameCoordinate { + directive_name: &directive.name, + value: None, + }; + let Some(arg) = directive + .arguments + .iter() + .find(|arg| arg.name == SOURCE_NAME_ARGUMENT_NAME) + else { + return Err(Message { + code: Code::GraphQLError, + message: format!("The {coordinate} argument is required.",), + locations: directive.line_column_range(sources).into_iter().collect(), + }); + }; + let node = &arg.value; + let Some(str_value) = node.as_str() else { + return Err(Message { + message: format!("{coordinate} is invalid; source names must be strings.",), + code: Code::InvalidSourceName, + locations: node.line_column_range(sources).into_iter().collect(), + }); + }; + Ok(Self { + value: Arc::from(str_value), + node: Some(Arc::new(node.clone())), + }) + } + + pub(crate) fn from_connect(directive: &Node) -> Option { + let arg = directive + .arguments + .iter() + .find(|arg| arg.name == CONNECT_SOURCE_ARGUMENT_NAME)?; + let node = &arg.value; + let str_value = node.as_str()?; + Some(Self { + value: Arc::from(str_value), + node: Some(Arc::new(node.clone())), + }) + } + pub(crate) fn from_directive( + directive: &Component, + sources: &SourceMap, + ) -> (Option, Option) { + let name = match Self::parse_basics(directive, sources) { + Ok(name) => name, + Err(message) => return (None, Some(message)), + }; + + let coordinate = NameCoordinate { + directive_name: &directive.name, + value: Some(name.value.clone()), + }; + + let Some(first_char) = name.value.chars().next() else { + let locations = name.locations(sources); + return ( + Some(name), + Some(Message { + code: Code::EmptySourceName, + message: format!("The value for {coordinate} can't be empty.",), + locations, + }), + ); + }; + let message = if !first_char.is_ascii_alphabetic() { + Some(Message { + message: format!( + "{coordinate} is invalid; source names must start with an ASCII letter (a-z or A-Z)", + ), + code: Code::InvalidSourceName, + locations: name.locations(sources), + }) + } else if name.value.len() > 64 { + Some(Message { + message: format!( + "{coordinate} is invalid; source names must be 64 characters or fewer", + ), + code: Code::InvalidSourceName, + locations: name.locations(sources), + }) + } else { + name.value + .chars() + .find(|c| !c.is_ascii_alphanumeric() && *c != '_' && *c != '-').map(|unacceptable| Message { + message: format!( + "{coordinate} can't contain `{unacceptable}`; only ASCII letters, numbers, underscores, or hyphens are allowed", + ), + code: Code::InvalidSourceName, + locations: name.locations(sources), + }) + }; + (Some(name), message) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.value + } + + pub(crate) fn locations(&self, sources: &SourceMap) -> Vec> { + self.node + .as_ref() + .map(|node| node.line_column_range(sources)) + .into_iter() + .flatten() + .collect() + } +} + +impl Display for SourceName { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl Debug for SourceName { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + Debug::fmt(&self.as_str(), f) + } +} + +impl PartialEq> for SourceName { + fn eq(&self, other: &Node) -> bool { + other + .as_str() + .is_some_and(|value| value == self.value.as_ref()) + } +} + +impl PartialEq for SourceName { + fn eq(&self, other: &SourceName) -> bool { + self.value == other.value + } +} + +impl Hash for SourceName { + fn hash(&self, state: &mut H) { + self.value.hash(state); + } +} + +impl Serialize for SourceName { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.value) + } +} + +impl<'de> Deserialize<'de> for SourceName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Arc::deserialize(deserializer)?; + Ok(Self { value, node: None }) + } +} + +struct NameCoordinate<'schema> { + directive_name: &'schema Name, + value: Option>, +} + +impl Display for NameCoordinate<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + if let Some(value) = &self.value { + write!( + f, + "`@{}({SOURCE_NAME_ARGUMENT_NAME}: \"{value}\")`", + self.directive_name, + ) + } else { + write!( + f, + "`@{}({SOURCE_NAME_ARGUMENT_NAME}:)`", + self.directive_name + ) + } + } +} diff --git a/apollo-federation/src/connectors/runtime.rs b/apollo-federation/src/connectors/runtime.rs new file mode 100644 index 0000000000..e2f6fb1afa --- /dev/null +++ b/apollo-federation/src/connectors/runtime.rs @@ -0,0 +1,8 @@ +pub mod debug; +pub mod errors; +pub mod form_encoding; +pub mod http_json_transport; +pub mod inputs; +pub mod key; +pub mod mapping; +pub mod responses; diff --git a/apollo-federation/src/connectors/runtime/debug.rs b/apollo-federation/src/connectors/runtime/debug.rs new file mode 100644 index 0000000000..7ccd1696bc --- /dev/null +++ b/apollo-federation/src/connectors/runtime/debug.rs @@ -0,0 +1,347 @@ +use std::collections::HashMap; + +use itertools::Itertools; +use serde::Deserialize; +use serde::Serialize; +use serde_json_bytes::json; + +use crate::connectors::ConnectorErrorsSettings; +use crate::connectors::HeaderSource; +use crate::connectors::HttpJsonTransport; +use crate::connectors::OriginatingDirective; +use crate::connectors::runtime::mapping::Problem; + +#[derive(Debug, Clone, Default)] +pub struct ConnectorContext { + items: Vec, +} + +#[derive(Debug, Clone)] +pub struct ConnectorContextItem { + problems: Vec, + request: ConnectorDebugHttpRequest, + response: ConnectorDebugHttpResponse, +} + +impl ConnectorContext { + pub fn push_response( + &mut self, + request: Option>, + parts: &http::response::Parts, + json_body: &serde_json_bytes::Value, + selection_data: Option, + error_settings: &ConnectorErrorsSettings, + problems: Vec, + ) { + if let Some(request) = request { + self.items.push(ConnectorContextItem { + request: *request, + response: ConnectorDebugHttpResponse::new( + parts, + json_body, + selection_data, + error_settings, + ), + problems, + }); + } else { + tracing::warn!( + "connectors debugging: couldn't find a matching request for the response" + ); + } + } + + pub fn push_invalid_response( + &mut self, + request: Option>, + parts: &http::response::Parts, + body: &[u8], + error_settings: &ConnectorErrorsSettings, + problems: Vec, + ) { + if let Some(request) = request { + self.items.push(ConnectorContextItem { + request: *request, + response: ConnectorDebugHttpResponse { + status: parts.status.as_u16(), + headers: parts + .headers + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + value.to_str().unwrap_or_default().to_string(), + ) + }) + .collect(), + body: ConnectorDebugBody { + kind: "invalid".to_string(), + content: format!("{body:?}").into(), + selection: None, + }, + errors: if error_settings.message.is_some() + || error_settings.connect_extensions.is_some() + || error_settings.source_extensions.is_some() + { + Some(ConnectorDebugErrors { + message: error_settings.message.as_ref().map(|m| m.to_string()), + source_extensions: error_settings + .source_extensions + .as_ref() + .map(|m| m.to_string()), + connect_extensions: error_settings + .connect_extensions + .as_ref() + .map(|m| m.to_string()), + }) + } else { + None + }, + }, + problems, + }); + } else { + tracing::warn!( + "connectors debugging: couldn't find a matching request for the response" + ); + } + } + + pub fn serialize(self) -> serde_json_bytes::Value { + json!( + self.items + .iter() + .map(|item| { + // Items should be sorted so that they always come out in the same order + let problems = item + .problems + .iter() + .sorted_by_key(|problem| problem.location) + .map( + |Problem { + message, + path, + count, + location, + }| { + // This is the format the Sandbox Debugger expects, don't change + // it without updating that project + json!({ + "location": location, + "details": { + "message": message, + "path": path, + "count": count, + }, + }) + }, + ) + .collect_vec(); + + json!({ + "request": item.request, + "response": item.response, + "problems": problems + }) + }) + .collect::>() + ) + } + + pub fn problems(&self) -> Vec { + self.items + .iter() + .flat_map(|item| item.problems.iter()) + .map(|problem| json!({ "message": problem.message, "path": problem.path })) + .collect() + } +} + +/// JSONSelection Request / Response Data +/// +/// Contains all needed info and responses from the application of a JSONSelection +pub struct SelectionData { + /// The original JSONSelection to resolve + pub source: String, + + /// A mapping of the original selection, taking into account renames and other + /// transformations requested by the client + /// + /// Refer to [`Self::source`] for the original, schema-supplied selection. + pub transformed: String, + + /// The result of applying the selection to JSON. An empty value + /// here can potentially mean that errors were encountered. + pub result: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ConnectorDebugBody { + kind: String, + content: serde_json_bytes::Value, + selection: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ConnectorDebugSelection { + source: String, + transformed: String, + result: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorDebugUri { + base: Option, + path: Option, + query_params: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConnectorDebugErrors { + message: Option, + source_extensions: Option, + connect_extensions: Option, +} + +pub type DebugRequest = (Option>, Vec); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorDebugHttpRequest { + url: String, + method: String, + headers: Vec<(String, String)>, + body: Option, + source_url: Option, + connect_url: ConnectorDebugUri, + source_headers: Option>, + connect_headers: Option>, +} + +impl ConnectorDebugHttpRequest { + pub fn new( + req: &http::Request, + kind: String, + json_body: Option<&serde_json_bytes::Value>, + selection_data: Option, + transport: &HttpJsonTransport, + ) -> Self { + let headers = transport.headers.iter().fold( + HashMap::new(), + |mut acc: HashMap>, header| { + if let HeaderSource::Value(value) = &header.source { + acc.entry(header.originating_directive) + .or_default() + .push((header.name.to_string(), value.to_string())); + } + acc + }, + ); + + ConnectorDebugHttpRequest { + url: req.uri().to_string(), + method: req.method().to_string(), + headers: req + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + value.to_str().unwrap_or_default().to_string(), + ) + }) + .collect(), + body: json_body.map(|body| ConnectorDebugBody { + kind, + content: body.clone(), + selection: selection_data.map(|selection| ConnectorDebugSelection { + source: selection.source, + transformed: selection.transformed, + result: selection.result, + }), + }), + source_url: if transport.source_template.is_some() + || transport.source_path.is_some() + || transport.source_query_params.is_some() + { + Some(ConnectorDebugUri { + base: transport.source_template.clone().map(|u| u.to_string()), + path: transport.source_path.clone().map(|u| u.to_string()), + query_params: transport.source_query_params.clone().map(|u| u.to_string()), + }) + } else { + None + }, + connect_url: ConnectorDebugUri { + base: Some(transport.connect_template.clone().to_string()), + path: transport.connect_path.clone().map(|u| u.to_string()), + query_params: transport + .connect_query_params + .clone() + .map(|u| u.to_string()), + }, + connect_headers: headers.get(&OriginatingDirective::Connect).cloned(), + source_headers: headers.get(&OriginatingDirective::Source).cloned(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectorDebugHttpResponse { + status: u16, + headers: Vec<(String, String)>, + body: ConnectorDebugBody, + errors: Option, +} + +impl ConnectorDebugHttpResponse { + pub fn new( + parts: &http::response::Parts, + json_body: &serde_json_bytes::Value, + selection_data: Option, + error_settings: &ConnectorErrorsSettings, + ) -> Self { + ConnectorDebugHttpResponse { + status: parts.status.as_u16(), + headers: parts + .headers + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + value.to_str().unwrap_or_default().to_string(), + ) + }) + .collect(), + body: ConnectorDebugBody { + kind: "json".to_string(), + content: json_body.clone(), + selection: selection_data.map(|selection| ConnectorDebugSelection { + source: selection.source, + transformed: selection.transformed, + result: selection.result, + }), + }, + errors: if error_settings.message.is_some() + || error_settings.connect_extensions.is_some() + || error_settings.source_extensions.is_some() + { + Some(ConnectorDebugErrors { + message: error_settings.message.as_ref().map(|m| m.to_string()), + source_extensions: error_settings + .source_extensions + .as_ref() + .map(|m| m.to_string()), + connect_extensions: error_settings + .connect_extensions + .as_ref() + .map(|m| m.to_string()), + }) + } else { + None + }, + } + } +} diff --git a/apollo-federation/src/connectors/runtime/errors.rs b/apollo-federation/src/connectors/runtime/errors.rs new file mode 100644 index 0000000000..74cfa106e9 --- /dev/null +++ b/apollo-federation/src/connectors/runtime/errors.rs @@ -0,0 +1,115 @@ +use serde::Serialize; +use serde_json_bytes::ByteString; +use serde_json_bytes::Map; +use serde_json_bytes::Value; + +use crate::connectors::Connector; +use crate::connectors::runtime::key::ResponseKey; + +#[derive(Clone, Debug, Serialize)] +pub struct RuntimeError { + pub message: String, + code: Option, + pub coordinate: Option, + pub subgraph_name: Option, + pub path: String, + pub extensions: Map, +} + +impl RuntimeError { + pub fn new(message: impl Into, response_key: &ResponseKey) -> Self { + Self { + message: message.into(), + code: None, + coordinate: None, + subgraph_name: None, + path: response_key.path_string(), + extensions: Default::default(), + } + } + + pub fn extensions(&self) -> Map { + let mut extensions = Map::default(); + extensions + .entry("code") + .or_insert_with(|| self.code().into()); + if let Some(subgraph_name) = &self.subgraph_name { + extensions + .entry("service") + .or_insert_with(|| Value::String(subgraph_name.clone().into())); + }; + + if let Some(coordinate) = &self.coordinate { + extensions.entry("connector").or_insert_with(|| { + Value::Object(Map::from_iter([( + "coordinate".into(), + Value::String(coordinate.to_string().into()), + )])) + }); + } + + extensions.extend(self.extensions.clone()); + extensions + } + + pub fn extension(mut self, key: K, value: V) -> Self + where + K: Into, + V: Into, + { + self.extensions.insert(key.into(), value.into()); + self + } + + pub fn with_code(mut self, code: impl Into) -> Self { + self.code = Some(code.into()); + self + } + + pub fn code(&self) -> &str { + self.code.as_deref().unwrap_or("CONNECTORS_FETCH") + } +} + +/// An error sending a connector request. This represents a problem with sending the request +/// to the connector, rather than an error returned from the connector itself. +#[derive(Debug, Clone, thiserror::Error)] +pub enum Error { + #[error("Request limit exceeded")] + RequestLimitExceeded, + + #[error("Rate limit exceeded")] + RateLimited, + + #[error("Gateway timeout")] + GatewayTimeout, + + #[error("Connector error: {0}")] + TransportFailure(String), +} + +impl Error { + pub fn to_runtime_error( + &self, + connector: &Connector, + response_key: &ResponseKey, + ) -> RuntimeError { + RuntimeError { + message: self.to_string(), + code: Some(self.code().to_string()), + coordinate: Some(connector.id.coordinate()), + subgraph_name: Some(connector.id.subgraph_name.clone()), + path: response_key.path_string(), + extensions: Default::default(), + } + } + + pub fn code(&self) -> &'static str { + match self { + Self::RequestLimitExceeded => "REQUEST_LIMIT_EXCEEDED", + Self::RateLimited => "REQUEST_RATE_LIMITED", + Self::GatewayTimeout => "GATEWAY_TIMEOUT", + Self::TransportFailure(_) => "HTTP_CLIENT_ERROR", + } + } +} diff --git a/apollo-federation/src/connectors/runtime/form_encoding.rs b/apollo-federation/src/connectors/runtime/form_encoding.rs new file mode 100644 index 0000000000..8d2ee807eb --- /dev/null +++ b/apollo-federation/src/connectors/runtime/form_encoding.rs @@ -0,0 +1,139 @@ +use serde_json_bytes::Value; + +pub(super) fn encode_json_as_form(value: &Value) -> Result { + if value.as_object().is_none() { + return Err("Expected URL-encoded forms to be objects"); + } + + let mut encoded: form_urlencoded::Serializer = + form_urlencoded::Serializer::new(String::new()); + + fn encode(encoded: &mut form_urlencoded::Serializer, value: &Value, prefix: &str) { + match value { + Value::Null => { + encoded.append_pair(prefix, ""); + } + Value::String(s) => { + encoded.append_pair(prefix, s.as_str()); + } + Value::Bool(b) => { + encoded.append_pair(prefix, if *b { "true" } else { "false" }); + } + Value::Number(n) => { + encoded.append_pair(prefix, &n.to_string()); + } + Value::Array(array) => { + for (i, value) in array.iter().enumerate() { + let prefix = format!("{prefix}[{i}]"); + encode(encoded, value, &prefix); + } + } + Value::Object(obj) => { + for (key, value) in obj { + if prefix.is_empty() { + encode(encoded, value, key.as_str()) + } else { + let prefix = format!("{prefix}[{key}]", key = key.as_str()); + encode(encoded, value, &prefix); + }; + } + } + } + } + + encode(&mut encoded, value, ""); + + Ok(encoded.finish()) +} + +#[cfg(test)] +mod tests { + use serde_json_bytes::json; + + use super::*; + + #[test] + fn complex() { + let data = json!({ + "a": 1, + "b": "2", + "c": { + "d": 3, + "e": "4", + "f": { + "g": 5, + "h": "6", + "i": [7, 8, 9], + "j": [ + {"k": 10}, + {"l": 11}, + {"m": 12} + ] + } + } + }); + + let encoded = encode_json_as_form(&data).expect("test case is valid for transformation"); + assert_eq!( + encoded, + "a=1&b=2&c%5Bd%5D=3&c%5Be%5D=4&c%5Bf%5D%5Bg%5D=5&c%5Bf%5D%5Bh%5D=6&c%5Bf%5D%5Bi%5D%5B0%5D=7&c%5Bf%5D%5Bi%5D%5B1%5D=8&c%5Bf%5D%5Bi%5D%5B2%5D=9&c%5Bf%5D%5Bj%5D%5B0%5D%5Bk%5D=10&c%5Bf%5D%5Bj%5D%5B1%5D%5Bl%5D=11&c%5Bf%5D%5Bj%5D%5B2%5D%5Bm%5D=12" + ); + } + + // https://github.com/ljharb/qs/blob/main/test/stringify.js used as reference for these tests + #[rstest::rstest] + #[case(r#"{ "a": "b" }"#, "a=b")] + #[case(r#"{ "a": 1 }"#, "a=1")] + #[case(r#"{ "a": 1, "b": 2 }"#, "a=1&b=2")] + #[case(r#"{ "a": "A_Z" }"#, "a=A_Z")] + #[case(r#"{ "a": "€" }"#, "a=%E2%82%AC")] + #[case(r#"{ "a": "" }"#, "a=%EE%80%80")] + #[case(r#"{ "a": "א" }"#, "a=%D7%90")] + #[case(r#"{ "a": "𐐷" }"#, "a=%F0%90%90%B7")] + #[case(r#"{ "a": { "b": "c" } }"#, "a%5Bb%5D=c")] + #[case( + r#"{ "a": { "b": { "c": { "d": "e" } } } }"#, + "a%5Bb%5D%5Bc%5D%5Bd%5D=e" + )] + #[case(r#"{ "a": ["b", "c", "d"] }"#, "a%5B0%5D=b&a%5B1%5D=c&a%5B2%5D=d")] + #[case(r#"{ "a": [], "b": "zz" }"#, "b=zz")] + #[case( + r#"{ "a": { "b": ["c", "d"] } }"#, + "a%5Bb%5D%5B0%5D=c&a%5Bb%5D%5B1%5D=d" + )] + #[case( + r#"{ "a": [",", "", "c,d%"] }"#, + "a%5B0%5D=%2C&a%5B1%5D=&a%5B2%5D=c%2Cd%25" + )] + #[case(r#"{ "a": ",", "b": "", "c": "c,d%" }"#, "a=%2C&b=&c=c%2Cd%25")] + #[case(r#"{ "a": [{ "b": "c" }] }"#, "a%5B0%5D%5Bb%5D=c")] + #[case( + r#"{ "a": [{ "b": { "c": [1] } }] }"#, + "a%5B0%5D%5Bb%5D%5Bc%5D%5B0%5D=1" + )] + #[case( + r#"{ "a": [{ "b": 1 }, 2, 3] }"#, + "a%5B0%5D%5Bb%5D=1&a%5B1%5D=2&a%5B2%5D=3" + )] + #[case(r#"{ "a": "" }"#, "a=")] + #[case(r#"{ "a": null }"#, "a=")] + #[case(r#"{ "a": { "b": "" } }"#, "a%5Bb%5D=")] + #[case(r#"{ "a": { "b": null } }"#, "a%5Bb%5D=")] + #[case(r#"{ "a": "b c" }"#, "a=b+c")] // RFC 1738, not RFC 3986 with %20 for spaces! + #[case( + r#"{ "my weird field": "~q1!2\"'w$5&7/z8)?" }"#, + // "my%20weird%20field=~q1%212%22%27w%245%267%2Fz8%29%3F" + "my+weird+field=%7Eq1%212%22%27w%245%267%2Fz8%29%3F" + )] + #[case(r#"{ "a": true }"#, "a=true")] + #[case(r#"{ "a": { "b": true } }"#, "a%5Bb%5D=true")] + #[case(r#"{ "b": false }"#, "b=false")] + #[case(r#"{ "b": { "c": false } }"#, "b%5Bc%5D=false")] + // #[case(r#"{ "a": [, "2", , , "1"] }"#, "a%5B1%5D=2&a%5B4%5D=1")] // json doesn't do sparse arrays + + fn stringifies_a_querystring_object(#[case] json: &str, #[case] expected: &str) { + let json = serde_json::from_slice::(json.as_bytes()).unwrap(); + let encoded = encode_json_as_form(&json).expect("test cases are valid for transformation"); + assert_eq!(encoded, expected); + } +} diff --git a/apollo-federation/src/connectors/runtime/http_json_transport.rs b/apollo-federation/src/connectors/runtime/http_json_transport.rs new file mode 100644 index 0000000000..72a7e34757 --- /dev/null +++ b/apollo-federation/src/connectors/runtime/http_json_transport.rs @@ -0,0 +1,421 @@ +use std::sync::Arc; + +use apollo_compiler::collections::IndexMap; +use http::HeaderMap; +use http::HeaderValue; +use http::header::CONTENT_LENGTH; +use http::header::CONTENT_TYPE; +use parking_lot::Mutex; +use serde_json_bytes::Value; +use serde_json_bytes::json; +use thiserror::Error; + +use super::form_encoding::encode_json_as_form; +use crate::connectors::ApplyToError; +use crate::connectors::HTTPMethod; +use crate::connectors::Header; +use crate::connectors::HeaderSource; +use crate::connectors::HttpJsonTransport; +use crate::connectors::MakeUriError; +use crate::connectors::OriginatingDirective; +use crate::connectors::ProblemLocation; +use crate::connectors::runtime::debug::ConnectorContext; +use crate::connectors::runtime::debug::ConnectorDebugHttpRequest; +use crate::connectors::runtime::debug::DebugRequest; +use crate::connectors::runtime::debug::SelectionData; +use crate::connectors::runtime::mapping::Problem; +use crate::connectors::runtime::mapping::aggregate_apply_to_errors; +use crate::connectors::runtime::mapping::aggregate_apply_to_errors_with_problem_locations; + +/// Request to an HTTP transport +#[derive(Debug)] +pub struct HttpRequest { + pub inner: http::Request, + pub debug: DebugRequest, +} + +/// Response from an HTTP transport +#[derive(Debug)] +pub struct HttpResponse { + /// The response parts - the body is consumed by applying the JSON mapping + pub inner: http::response::Parts, +} + +/// Request to an underlying transport +#[derive(Debug)] +pub enum TransportRequest { + /// A request to an HTTP transport + Http(HttpRequest), +} + +/// Response from an underlying transport +#[derive(Debug)] +pub enum TransportResponse { + /// A response from an HTTP transport + Http(HttpResponse), +} + +impl From for TransportRequest { + fn from(value: HttpRequest) -> Self { + Self::Http(value) + } +} + +impl From for TransportResponse { + fn from(value: HttpResponse) -> Self { + Self::Http(value) + } +} + +pub fn make_request( + transport: &HttpJsonTransport, + inputs: IndexMap, + client_headers: &HeaderMap, + debug: &Option>>, +) -> Result<(TransportRequest, Vec), HttpJsonTransportError> { + let (uri, uri_apply_to_errors) = transport.make_uri(&inputs)?; + let uri_mapping_problems = + aggregate_apply_to_errors_with_problem_locations(uri_apply_to_errors); + + let method = transport.method; + let request = http::Request::builder() + .method(transport.method.as_str()) + .uri(uri); + + // add the headers and if content-type is specified, we'll check that when constructing the body + let (mut request, content_type, header_apply_to_errors) = + add_headers(request, client_headers, &transport.headers, &inputs); + let header_mapping_problems = + aggregate_apply_to_errors_with_problem_locations(header_apply_to_errors); + + let is_form_urlencoded = content_type.as_ref() == Some(&mime::APPLICATION_WWW_FORM_URLENCODED); + + let (json_body, form_body, body, content_length, body_apply_to_errors) = + if let Some(ref selection) = transport.body { + let (json_body, apply_to_errors) = selection.apply_with_vars(&json!({}), &inputs); + let mut form_body = None; + let (body, content_length) = if let Some(json_body) = json_body.as_ref() { + if is_form_urlencoded { + let encoded = encode_json_as_form(json_body) + .map_err(HttpJsonTransportError::FormBodySerialization)?; + form_body = Some(encoded.clone()); + let len = encoded.len(); + (encoded, len) + } else { + request = request.header(CONTENT_TYPE, mime::APPLICATION_JSON.essence_str()); + let bytes = serde_json::to_vec(json_body)?; + let len = bytes.len(); + let body_string = serde_json::to_string(json_body)?; + (body_string, len) + } + } else { + ("".into(), 0) + }; + (json_body, form_body, body, content_length, apply_to_errors) + } else { + (None, None, "".into(), 0, vec![]) + }; + + match method { + HTTPMethod::Post | HTTPMethod::Patch | HTTPMethod::Put => { + request = request.header(CONTENT_LENGTH, content_length); + } + _ => {} + } + + let request = request + .body(body) + .map_err(HttpJsonTransportError::InvalidNewRequest)?; + + let body_mapping_problems = + aggregate_apply_to_errors(body_apply_to_errors, ProblemLocation::RequestBody); + + let all_problems: Vec = uri_mapping_problems + .chain(body_mapping_problems) + .chain(header_mapping_problems) + .collect(); + + let debug_request = debug.as_ref().map(|_| { + if is_form_urlencoded { + Box::new(ConnectorDebugHttpRequest::new( + &request, + "form-urlencoded".to_string(), + form_body.map(|s| Value::String(s.into())).as_ref(), + transport.body.as_ref().map(|body| SelectionData { + source: body.to_string(), + transformed: body.to_string(), // no transformation so this is the same + result: json_body, + }), + transport, + )) + } else { + Box::new(ConnectorDebugHttpRequest::new( + &request, + "json".to_string(), + json_body.as_ref(), + transport.body.as_ref().map(|body| SelectionData { + source: body.to_string(), + transformed: body.to_string(), // no transformation so this is the same + result: json_body.clone(), + }), + transport, + )) + } + }); + + Ok(( + TransportRequest::Http(HttpRequest { + inner: request, + debug: (debug_request, all_problems.clone()), + }), + all_problems, + )) +} + +fn add_headers( + mut request: http::request::Builder, + incoming_supergraph_headers: &HeaderMap, + config: &[Header], + inputs: &IndexMap, +) -> ( + http::request::Builder, + Option, + Vec<(ProblemLocation, ApplyToError)>, +) { + let mut content_type = None; + let mut warnings = Vec::new(); + + for header in config { + match &header.source { + HeaderSource::From(from) => { + let values = incoming_supergraph_headers.get_all(from); + let mut propagated = false; + for value in values { + request = request.header(header.name.clone(), value.clone()); + propagated = true; + } + if !propagated { + tracing::warn!("Header '{}' not found in incoming request", header.name); + } + } + HeaderSource::Value(value) => match value.interpolate(inputs) { + Ok((value, apply_to_errors)) => { + warnings.extend(apply_to_errors.iter().cloned().map(|e| { + ( + match header.originating_directive { + OriginatingDirective::Source => ProblemLocation::SourceHeaders, + OriginatingDirective::Connect => ProblemLocation::ConnectHeaders, + }, + e, + ) + })); + + if header.name == CONTENT_TYPE { + content_type = Some(value.clone()); + } + + request = request.header(header.name.clone(), value); + } + Err(err) => { + tracing::error!("Unable to interpolate header value: {:?}", err); + } + }, + } + } + + ( + request, + content_type.and_then(|v| v.to_str().unwrap_or_default().parse().ok()), + warnings, + ) +} + +#[derive(Error, Debug)] +pub enum HttpJsonTransportError { + #[error("Could not generate HTTP request: {0}")] + InvalidNewRequest(#[source] http::Error), + #[error("Could not serialize body: {0}")] + JsonBodySerialization(#[from] serde_json::Error), + #[error("Could not serialize body: {0}")] + FormBodySerialization(&'static str), + #[error(transparent)] + MakeUri(#[from] MakeUriError), +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use http::HeaderMap; + use http::HeaderValue; + use http::header::CONTENT_ENCODING; + use insta::assert_debug_snapshot; + + use super::*; + use crate::connectors::HTTPMethod; + use crate::connectors::HeaderSource; + use crate::connectors::JSONSelection; + use crate::connectors::StringTemplate; + + #[test] + fn test_headers_to_add_no_directives() { + let incoming_supergraph_headers: HeaderMap = vec![ + ("x-rename".parse().unwrap(), "renamed".parse().unwrap()), + ("x-rename".parse().unwrap(), "also-renamed".parse().unwrap()), + ("x-ignore".parse().unwrap(), "ignored".parse().unwrap()), + (CONTENT_ENCODING, "gzip".parse().unwrap()), + ] + .into_iter() + .collect(); + + let request = http::Request::builder(); + let (request, ..) = add_headers( + request, + &incoming_supergraph_headers, + &[], + &IndexMap::with_hasher(Default::default()), + ); + let request = request.body("").unwrap(); + assert!(request.headers().is_empty()); + } + + #[test] + fn test_headers_to_add_with_config() { + let incoming_supergraph_headers: HeaderMap = vec![ + ("x-rename".parse().unwrap(), "renamed".parse().unwrap()), + ("x-rename".parse().unwrap(), "also-renamed".parse().unwrap()), + ("x-ignore".parse().unwrap(), "ignored".parse().unwrap()), + (CONTENT_ENCODING, "gzip".parse().unwrap()), + ] + .into_iter() + .collect(); + + let config = vec![ + Header::from_values( + "x-new-name".parse().unwrap(), + HeaderSource::From("x-rename".parse().unwrap()), + OriginatingDirective::Source, + ), + Header::from_values( + "x-insert".parse().unwrap(), + HeaderSource::Value("inserted".parse().unwrap()), + OriginatingDirective::Connect, + ), + ]; + + let request = http::Request::builder(); + let (request, ..) = add_headers( + request, + &incoming_supergraph_headers, + &config, + &IndexMap::with_hasher(Default::default()), + ); + let request = request.body("").unwrap(); + let result = request.headers(); + assert_eq!(result.len(), 3); + assert_eq!(result.get("x-new-name"), Some(&"renamed".parse().unwrap())); + assert_eq!(result.get("x-insert"), Some(&"inserted".parse().unwrap())); + } + + #[test] + fn make_request() { + let mut vars = IndexMap::default(); + vars.insert("$args".to_string(), json!({ "a": 42 })); + + let req = super::make_request( + &HttpJsonTransport { + source_template: None, + connect_template: StringTemplate::from_str("http://localhost:8080/").unwrap(), + method: HTTPMethod::Post, + body: Some(JSONSelection::parse("$args { a }").unwrap()), + ..Default::default() + }, + vars, + &Default::default(), + &None, + ) + .unwrap(); + + assert_debug_snapshot!(req, @r#" + ( + Http( + HttpRequest { + inner: Request { + method: POST, + uri: http://localhost:8080/, + version: HTTP/1.1, + headers: { + "content-type": "application/json", + "content-length": "8", + }, + body: "{\"a\":42}", + }, + debug: ( + None, + [], + ), + }, + ), + [], + ) + "#); + + let TransportRequest::Http(HttpRequest { inner: req, .. }) = req.0; + let body = req.into_body(); + insta::assert_snapshot!(body, @r#"{"a":42}"#); + } + + #[test] + fn make_request_form_encoded() { + let mut vars = IndexMap::default(); + vars.insert("$args".to_string(), json!({ "a": 42 })); + let headers = vec![Header::from_values( + "content-type".parse().unwrap(), + HeaderSource::Value("application/x-www-form-urlencoded".parse().unwrap()), + OriginatingDirective::Connect, + )]; + + let req = super::make_request( + &HttpJsonTransport { + source_template: None, + connect_template: StringTemplate::from_str("http://localhost:8080/").unwrap(), + method: HTTPMethod::Post, + headers, + body: Some(JSONSelection::parse("$args { a }").unwrap()), + ..Default::default() + }, + vars, + &Default::default(), + &None, + ) + .unwrap(); + + assert_debug_snapshot!(req, @r#" + ( + Http( + HttpRequest { + inner: Request { + method: POST, + uri: http://localhost:8080/, + version: HTTP/1.1, + headers: { + "content-type": "application/x-www-form-urlencoded", + "content-length": "4", + }, + body: "a=42", + }, + debug: ( + None, + [], + ), + }, + ), + [], + ) + "#); + + let TransportRequest::Http(HttpRequest { inner: req, .. }) = req.0; + let body = req.into_body(); + insta::assert_snapshot!(body, @r#"a=42"#); + } +} diff --git a/apollo-federation/src/connectors/runtime/inputs.rs b/apollo-federation/src/connectors/runtime/inputs.rs new file mode 100644 index 0000000000..fdb7625154 --- /dev/null +++ b/apollo-federation/src/connectors/runtime/inputs.rs @@ -0,0 +1,247 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use apollo_compiler::collections::HashSet; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; +use http::HeaderMap; +use http::HeaderValue; +use http::response::Parts; +use serde_json::Value as JsonValue; +use serde_json_bytes::ByteString; +use serde_json_bytes::Map; +use serde_json_bytes::Value; +use serde_json_bytes::json; + +use crate::connectors::Namespace; + +pub trait ContextReader { + fn get_key(&self, key: &str) -> Option; +} + +/// Convert a HeaderMap into a HashMap +pub(crate) fn externalize_header_map( + input: &HeaderMap, +) -> Result>, String> { + let mut output = HashMap::new(); + for (k, v) in input { + let k = k.as_str().to_owned(); + let v = String::from_utf8(v.as_bytes().to_vec()).map_err(|e| e.to_string())?; + output.entry(k).or_insert_with(Vec::new).push(v) + } + Ok(output) +} + +#[derive(Clone, Default)] +pub struct RequestInputs { + pub args: Map, + pub this: Map, + pub batch: Vec>, +} + +impl RequestInputs { + /// Creates a map for use in JSONSelection::apply_with_vars. It only clones + /// values into the map if the variable namespaces (`$args`, `$this`, etc.) + /// are actually referenced in the expressions for URLs, headers, body, or selection. + pub fn merger( + self, + variables_used: &IndexMap>, + ) -> MappingContextMerger<'_> { + MappingContextMerger { + inputs: self, + variables_used, + config: None, + context: None, + status: None, + request: None, + response: None, + env: None, + } + } +} + +impl std::fmt::Debug for RequestInputs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "RequestInputs {{\n args: {},\n this: {},\n batch: {}\n}}", + serde_json::to_string(&self.args).unwrap_or_else(|_| "".to_string()), + serde_json::to_string(&self.this).unwrap_or_else(|_| "".to_string()), + serde_json::to_string(&self.batch).unwrap_or_else(|_| "".to_string()), + ) + } +} + +pub struct MappingContextMerger<'merger> { + pub inputs: RequestInputs, + pub variables_used: &'merger IndexMap>, + pub config: Option, + pub context: Option, + pub status: Option, + pub request: Option, + pub response: Option, + pub env: Option, +} + +impl MappingContextMerger<'_> { + pub fn merge(self) -> IndexMap { + let mut map = + IndexMap::with_capacity_and_hasher(self.variables_used.len(), Default::default()); + // Not all connectors reference $args + if self.variables_used.contains_key(&Namespace::Args) { + map.insert( + Namespace::Args.as_str().into(), + Value::Object(self.inputs.args), + ); + } + + // $this only applies to fields on entity types (not Query or Mutation) + if self.variables_used.contains_key(&Namespace::This) { + map.insert( + Namespace::This.as_str().into(), + Value::Object(self.inputs.this), + ); + } + + // $batch only applies to entity resolvers on types + if self.variables_used.contains_key(&Namespace::Batch) { + map.insert( + Namespace::Batch.as_str().into(), + Value::Array(self.inputs.batch.into_iter().map(Value::Object).collect()), + ); + } + + if let Some(config) = self.config.iter().next() { + map.insert(Namespace::Config.as_str().into(), config.to_owned()); + } + + if let Some(context) = self.context.iter().next() { + map.insert(Namespace::Context.as_str().into(), context.to_owned()); + } + + if let Some(status) = self.status.iter().next() { + map.insert(Namespace::Status.as_str().into(), status.to_owned()); + } + + if let Some(request) = self.request.iter().next() { + map.insert(Namespace::Request.as_str().into(), request.to_owned()); + } + + if let Some(response) = self.response.iter().next() { + map.insert(Namespace::Response.as_str().into(), response.to_owned()); + } + + if let Some(env_vars_used) = self.variables_used.get(&Namespace::Env) { + let env_vars: Map = env_vars_used + .iter() + .flat_map(|key| { + std::env::var(key) + .ok() + .map(|value| (key.as_str().into(), Value::String(value.into()))) + }) + .collect(); + map.insert(Namespace::Env.as_str().into(), Value::Object(env_vars)); + } + + map + } + + pub fn context<'a>(mut self, context: impl ContextReader + 'a) -> Self { + // $context could be a large object, so we only convert it to JSON + // if it's used. It can also be mutated between requests, so we have + // to convert it each time. + if let Some(context_keys) = self.variables_used.get(&Namespace::Context) { + self.context = Some(Value::Object( + context_keys + .iter() + .filter_map(|key| { + context + .get_key(key) + .map(|value| (key.as_str().into(), value)) + }) + .collect(), + )); + } + self + } + + pub fn config(mut self, config: Option<&Arc>>) -> Self { + // $config doesn't change unless the schema reloads, but we can avoid + // the allocation if it's unused. + // We should always have a value for $config, even if it's an empty object, or we end up with "Variable $config not found" which is a confusing error for users + if self.variables_used.contains_key(&Namespace::Config) { + self.config = config.map(|c| json!(c)).or_else(|| Some(json!({}))); + } + self + } + + pub fn status(mut self, status: u16) -> Self { + // $status is available only for response mapping + if self.variables_used.contains_key(&Namespace::Status) { + self.status = Some(Value::Number(status.into())); + } + self + } + + pub fn request( + mut self, + headers_used: &HashSet, + headers: &HeaderMap, + ) -> Self { + // Add headers from the original router request. + // Only include headers that are actually referenced to save on passing around unused headers in memory. + if self.variables_used.contains_key(&Namespace::Request) { + let new_headers = externalize_header_map(headers) + .unwrap_or_default() + .iter() + .filter_map(|(key, value)| { + headers_used.contains(key.as_str()).then_some(( + key.as_str().into(), + value + .iter() + .map(|s| Value::String(s.as_str().into())) + .collect(), + )) + }) + .collect(); + let request_object = json!({ + "headers": Value::Object(new_headers) + }); + self.request = Some(request_object); + } + self + } + + pub fn response( + mut self, + headers_used: &HashSet, + response_parts: Option<&Parts>, + ) -> Self { + // Add headers from the connectors response + // Only include headers that are actually referenced to save on passing around unused headers in memory. + if let (true, Some(response_parts)) = ( + self.variables_used.contains_key(&Namespace::Response), + response_parts, + ) { + let new_headers: Map = + externalize_header_map(&response_parts.headers) + .unwrap_or_default() + .iter() + .filter_map(|(key, value)| { + headers_used.contains(key.as_str()).then_some(( + key.as_str().into(), + value + .iter() + .map(|s| Value::String(s.as_str().into())) + .collect(), + )) + }) + .collect(); + let response_object = json!({ + "headers": Value::Object(new_headers) + }); + self.response = Some(response_object); + } + self + } +} diff --git a/apollo-federation/src/connectors/runtime/key.rs b/apollo-federation/src/connectors/runtime/key.rs new file mode 100644 index 0000000000..bcfc35a8ea --- /dev/null +++ b/apollo-federation/src/connectors/runtime/key.rs @@ -0,0 +1,131 @@ +use std::sync::Arc; + +use apollo_compiler::Name; +use apollo_compiler::executable::FieldSet; +use apollo_compiler::validation::Valid; + +use crate::connectors::JSONSelection; +use crate::connectors::runtime::inputs::RequestInputs; + +#[derive(Clone)] +pub enum ResponseKey { + RootField { + name: String, + selection: Arc, + inputs: RequestInputs, + }, + Entity { + index: usize, + selection: Arc, + inputs: RequestInputs, + }, + EntityField { + index: usize, + field_name: String, + /// Is Some only if the output type is a concrete object type. If it's + /// an interface, it's treated as an interface object and we can't emit + /// a __typename in the response. + typename: Option, + selection: Arc, + inputs: RequestInputs, + }, + BatchEntity { + selection: Arc, + keys: Valid
, + inputs: RequestInputs, + }, +} + +impl ResponseKey { + pub fn selection(&self) -> &JSONSelection { + match self { + ResponseKey::RootField { selection, .. } => selection, + ResponseKey::Entity { selection, .. } => selection, + ResponseKey::EntityField { selection, .. } => selection, + ResponseKey::BatchEntity { selection, .. } => selection, + } + } + + pub fn inputs(&self) -> &RequestInputs { + match self { + ResponseKey::RootField { inputs, .. } => inputs, + ResponseKey::Entity { inputs, .. } => inputs, + ResponseKey::EntityField { inputs, .. } => inputs, + ResponseKey::BatchEntity { inputs, .. } => inputs, + } + } + + /// Returns a serialized representation of the Path from apollo-router. + /// Intended to be parsed into a Path when converting a connectors + /// `RuntimeError` in the router's graphql::Error. + /// + /// This mimics the behavior of a GraphQL subgraph, including the `_entities` + /// field. When the path gets to `FetchNode::response_at_path`, it will be + /// amended and appended to a parent path to create the full path to the + /// field. For example: + /// + /// - parent path: `["posts", @, "user"] + /// - path from key: `["_entities", 0, "user", "profile"]` + /// - result: `["posts", 1, "user", "profile"]` + pub fn path_string(&self) -> String { + match self { + ResponseKey::RootField { name, .. } => name.to_string(), + ResponseKey::Entity { index, .. } => format!("_entities/{index}"), + ResponseKey::EntityField { + index, field_name, .. + } => format!("_entities/{index}/{field_name}"), + ResponseKey::BatchEntity { .. } => "_entities".to_string(), + } + } +} + +impl std::fmt::Debug for ResponseKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::RootField { + name, + selection, + inputs, + } => f + .debug_struct("RootField") + .field("name", name) + .field("selection", &selection.to_string()) + .field("inputs", inputs) + .finish(), + Self::Entity { + index, + selection, + inputs, + } => f + .debug_struct("Entity") + .field("index", index) + .field("selection", &selection.to_string()) + .field("inputs", inputs) + .finish(), + Self::EntityField { + index, + field_name, + typename, + selection, + inputs, + } => f + .debug_struct("EntityField") + .field("index", index) + .field("field_name", field_name) + .field("typename", typename) + .field("selection", &selection.to_string()) + .field("inputs", inputs) + .finish(), + Self::BatchEntity { + selection, + keys, + inputs, + } => f + .debug_struct("BatchEntity") + .field("selection", &selection.to_string()) + .field("key", &keys.serialize().no_indent().to_string()) + .field("inputs", inputs) + .finish(), + } + } +} diff --git a/apollo-federation/src/connectors/runtime/mapping.rs b/apollo-federation/src/connectors/runtime/mapping.rs new file mode 100644 index 0000000000..c9b8fcbea8 --- /dev/null +++ b/apollo-federation/src/connectors/runtime/mapping.rs @@ -0,0 +1,70 @@ +//! Mapping from a Connectors request or response to GraphQL + +use std::collections::HashMap; + +use itertools::Itertools; +use serde::Deserialize; +use serde::Serialize; + +use crate::connectors::ApplyToError; +use crate::connectors::ProblemLocation; + +/// A mapping problem +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Problem { + pub message: String, + pub path: String, + pub count: usize, + pub location: ProblemLocation, +} + +/// Aggregate a list of [`ApplyToError`] into [mapping problems](Problem) +pub fn aggregate_apply_to_errors( + errors: Vec, + location: ProblemLocation, +) -> impl Iterator { + errors + .into_iter() + .fold( + HashMap::default(), + |mut acc: HashMap<(String, String), usize>, err| { + let path = err + .path() + .iter() + .map(|p| match p.as_u64() { + Some(_) => "@", // ignore array indices for grouping + None => p.as_str().unwrap_or_default(), + }) + .join("."); + + acc.entry((err.message().to_string(), path)) + .and_modify(|c| *c += 1) + .or_insert(1); + acc + }, + ) + .into_iter() + .map(move |((message, path), count)| Problem { + message, + path, + count, + location, + }) +} + +/// Aggregate a list of [`ApplyToError`] into [mapping problems](Problem) while preserving [`ProblemLocation`] +pub fn aggregate_apply_to_errors_with_problem_locations( + errors: Vec<(ProblemLocation, ApplyToError)>, +) -> impl Iterator { + errors + .into_iter() + .fold( + HashMap::new(), + |mut acc: HashMap>, (loc, err)| { + acc.entry(loc).or_default().push(err); + acc + }, + ) + .into_iter() + .flat_map(|(location, errors)| aggregate_apply_to_errors(errors, location)) +} diff --git a/apollo-federation/src/connectors/runtime/responses.rs b/apollo-federation/src/connectors/runtime/responses.rs new file mode 100644 index 0000000000..59bf20d403 --- /dev/null +++ b/apollo-federation/src/connectors/runtime/responses.rs @@ -0,0 +1,411 @@ +use apollo_compiler::collections::HashMap; +use apollo_compiler::collections::IndexMap; +use encoding_rs::Encoding; +use encoding_rs::UTF_8; +use http::HeaderMap; +use http::HeaderValue; +use http::header::CONTENT_LENGTH; +use http::header::CONTENT_TYPE; +use http::response::Parts; +use itertools::Itertools; +use mime::Mime; +use serde_json_bytes::ByteString; +use serde_json_bytes::Map; +use serde_json_bytes::Value; + +use crate::connectors::Connector; +use crate::connectors::JSONSelection; +use crate::connectors::ProblemLocation; +use crate::connectors::runtime::errors::RuntimeError; +use crate::connectors::runtime::inputs::ContextReader; +use crate::connectors::runtime::key::ResponseKey; +use crate::connectors::runtime::mapping::Problem; +use crate::connectors::runtime::mapping::aggregate_apply_to_errors; +use crate::connectors::runtime::responses::DeserializeError::ContentDecoding; + +const ENTITIES: &str = "_entities"; +const TYPENAME: &str = "__typename"; + +#[derive(Debug, thiserror::Error)] +pub enum HandleResponseError { + #[error("Merge error: {0}")] + MergeError(String), +} + +/// Converts a response body into a json Value based on the Content-Type header. +pub fn deserialize_response(body: &[u8], headers: &HeaderMap) -> Result { + // If the body is obviously empty, don't try to parse it + if headers + .get(CONTENT_LENGTH) + .and_then(|len| len.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .is_some_and(|content_length| content_length == 0) + { + return Ok(Value::Null); + } + + let content_type = headers + .get(CONTENT_TYPE) + .and_then(|h| h.to_str().ok()?.parse::().ok()); + + if content_type.is_none() + || content_type + .as_ref() + .is_some_and(|ct| ct.subtype() == mime::JSON || ct.suffix() == Some(mime::JSON)) + { + // Treat any JSON-y like content types as JSON + // Also, because the HTTP spec says we should effectively "guess" the content type if there is no content type (None), we're going to guess it is JSON if the server has not specified one + serde_json::from_slice::(body).map_err(DeserializeError::SerdeJson) + } else if content_type + .as_ref() + .is_some_and(|ct| ct.type_() == mime::TEXT && ct.subtype() == mime::PLAIN) + { + // Plain text we can't parse as JSON so we'll instead return it as a JSON string + // Before we can do that, we need to figure out the charset and attempt to decode the string + let encoding = content_type + .as_ref() + .and_then(|ct| Encoding::for_label(ct.get_param("charset")?.as_str().as_bytes())) + .unwrap_or(UTF_8); + let (decoded_body, _, had_errors) = encoding.decode(body); + + if had_errors { + return Err(ContentDecoding(encoding.name())); + } + + Ok(Value::String(decoded_body.into_owned().into())) + } else { + // For any other content types, all we can do is treat it as a JSON null cause we don't know what it is + Ok(Value::Null) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum DeserializeError { + #[error("Could not parse JSON: {0}")] + SerdeJson(#[source] serde_json::Error), + #[error("Could not decode data with content encoding {0}")] + ContentDecoding(&'static str), +} + +pub fn handle_raw_response( + data: &Value, + parts: &Parts, + key: ResponseKey, + connector: &Connector, + context: impl ContextReader, + client_headers: &HeaderMap, +) -> MappedResponse { + let inputs = key + .inputs() + .clone() + .merger(&connector.response_variable_keys) + .config(connector.config.as_ref()) + .context(context) + .status(parts.status.as_u16()) + .request(&connector.response_headers, client_headers) + .response(&connector.response_headers, Some(parts)) + .merge(); + let warnings = Vec::new(); + let (success, warnings) = is_success(connector, data, parts, &inputs, warnings); + if success { + map_response(data, key, inputs, warnings) + } else { + map_error(connector, data, parts, key, inputs, warnings) + } +} + +// If the user has set a custom success condition selector, resolve that expression, +// otherwise default to checking status code is 2XX +fn is_success( + connector: &Connector, + data: &Value, + parts: &Parts, + inputs: &IndexMap, + mut warnings: Vec, +) -> (bool, Vec) { + let Some(is_success_selection) = &connector.error_settings.connect_is_success else { + return (parts.status.is_success(), warnings); + }; + let (res, apply_to_errors) = is_success_selection.apply_with_vars(data, inputs); + warnings.extend(aggregate_apply_to_errors( + apply_to_errors, + ProblemLocation::IsSuccess, + )); + + ( + res.as_ref().and_then(Value::as_bool).unwrap_or_default(), + warnings, + ) +} + +/// Returns a response with data transformed by the selection mapping. +pub(super) fn map_response( + data: &Value, + key: ResponseKey, + inputs: IndexMap, + mut warnings: Vec, +) -> MappedResponse { + let (res, apply_to_errors) = key.selection().apply_with_vars(data, &inputs); + warnings.extend(aggregate_apply_to_errors( + apply_to_errors, + ProblemLocation::Selection, + )); + MappedResponse::Data { + key, + data: res.unwrap_or_else(|| Value::Null), + problems: warnings, + } +} + +/// Returns a `MappedResponse` with a GraphQL error. +pub(super) fn map_error( + connector: &Connector, + data: &Value, + parts: &Parts, + key: ResponseKey, + inputs: IndexMap, + mut warnings: Vec, +) -> MappedResponse { + // Do we have an error message mapping set for this connector? + let message = if let Some(message_selection) = &connector.error_settings.message { + let (res, apply_to_errors) = message_selection.apply_with_vars(data, &inputs); + warnings.extend(aggregate_apply_to_errors( + apply_to_errors, + ProblemLocation::ErrorsMessage, + )); + res.as_ref() + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + } else { + "Request failed".to_string() + }; + + // Now we can create the error object using either the default message or the message calculated by the JSONSelection + let mut error = RuntimeError::new(message, &key); + error.subgraph_name = Some(connector.id.subgraph_name.clone()); + error.coordinate = Some(connector.id.coordinate()); + + // First, we will apply defaults... these may get overwritten below by user configured extensions + error = error.extension( + "http", + Value::Object(Map::from_iter([( + "status".into(), + Value::Number(parts.status.as_u16().into()), + )])), + ); + + // If we have error extensions mapping set for this connector, we will need to grab the code + the remaining extensions and map them to the error object + // We'll merge by applying the source and then the connect. Keep in mind that these will override defaults if the key names are the same. + // Note: that we set the extension code in this if/else but don't actually set it on the error until after the if/else. This is because the compiler + // can't make sense of it in the if/else due to how the builder is constructed. + let mut extension_code = "CONNECTOR_FETCH".to_string(); + if let Some(extensions_selection) = &connector.error_settings.source_extensions { + let (res, apply_to_errors) = extensions_selection.apply_with_vars(data, &inputs); + warnings.extend(aggregate_apply_to_errors( + apply_to_errors, + ProblemLocation::SourceErrorsExtensions, + )); + + // TODO: Currently this "fails silently". In the future, we probably add a warning to the debugger info. + let extensions = res + .and_then(|e| match e { + Value::Object(map) => Some(map), + _ => None, + }) + .unwrap_or_default(); + + if let Some(code) = extensions.get("code") { + extension_code = code.as_str().unwrap_or_default().to_string(); + } + + for (key, value) in extensions { + error = error.extension(key, value); + } + } + + if let Some(extensions_selection) = &connector.error_settings.connect_extensions { + let (res, apply_to_errors) = extensions_selection.apply_with_vars(data, &inputs); + warnings.extend(aggregate_apply_to_errors( + apply_to_errors, + ProblemLocation::ConnectErrorsExtensions, + )); + + // TODO: Currently this "fails silently". In the future, we probably add a warning to the debugger info. + let extensions = res + .and_then(|e| match e { + Value::Object(map) => Some(map), + _ => None, + }) + .unwrap_or_default(); + + if let Some(code) = extensions.get("code") { + extension_code = code.as_str().unwrap_or_default().to_string(); + } + + for (key, value) in extensions { + error = error.extension(key, value); + } + } + + error = error.with_code(extension_code); + + MappedResponse::Error { + error, + key, + problems: warnings, + } +} +// --- MAPPED RESPONSE --------------------------------------------------------- +#[derive(Debug)] +pub enum MappedResponse { + /// This is equivalent to RawResponse::Error, but it also represents errors + /// when the request is semantically unsuccessful (e.g. 404, 500). + Error { + error: RuntimeError, + key: ResponseKey, + problems: Vec, + }, + /// The response data after applying the selection mapping. + Data { + data: Value, + key: ResponseKey, + problems: Vec, + }, +} + +impl MappedResponse { + /// Adds the response data to the `data` map or the error to the `errors` + /// array. How data is added depends on the `ResponseKey`: it's either a + /// property directly on the map, or stored in the `_entities` array. + pub fn add_to_data( + self, + data: &mut Map, + errors: &mut Vec, + count: usize, + ) -> Result<(), HandleResponseError> { + match self { + Self::Error { error, key, .. } => { + match key { + // add a null to the "_entities" array at the right index + ResponseKey::Entity { index, .. } | ResponseKey::EntityField { index, .. } => { + let entities = data + .entry(ENTITIES) + .or_insert(Value::Array(Vec::with_capacity(count))); + entities + .as_array_mut() + .ok_or_else(|| { + HandleResponseError::MergeError("_entities is not an array".into()) + })? + .insert(index, Value::Null); + } + _ => {} + }; + errors.push(error); + } + Self::Data { + data: value, key, .. + } => match key { + ResponseKey::RootField { ref name, .. } => { + data.insert(name.clone(), value); + } + ResponseKey::Entity { index, .. } => { + let entities = data + .entry(ENTITIES) + .or_insert(Value::Array(Vec::with_capacity(count))); + entities + .as_array_mut() + .ok_or_else(|| { + HandleResponseError::MergeError("_entities is not an array".into()) + })? + .insert(index, value); + } + ResponseKey::EntityField { + index, + ref field_name, + ref typename, + .. + } => { + let entities = data + .entry(ENTITIES) + .or_insert(Value::Array(Vec::with_capacity(count))) + .as_array_mut() + .ok_or_else(|| { + HandleResponseError::MergeError("_entities is not an array".into()) + })?; + + match entities.get_mut(index) { + Some(Value::Object(entity)) => { + entity.insert(field_name.clone(), value); + } + _ => { + let mut entity = Map::new(); + if let Some(typename) = typename { + entity.insert(TYPENAME, Value::String(typename.as_str().into())); + } + entity.insert(field_name.clone(), value); + entities.insert(index, Value::Object(entity)); + } + }; + } + ResponseKey::BatchEntity { + selection, + keys, + inputs, + } => { + let Value::Array(values) = value else { + return Err(HandleResponseError::MergeError( + "Response for a batch request does not map to an array".into(), + )); + }; + + let spec = selection.spec(); + let key_selection = JSONSelection::parse_with_spec( + &keys.serialize().no_indent().to_string(), + spec, + ) + .map_err(|e| HandleResponseError::MergeError(e.to_string()))?; + + // Convert representations into keys for use in the map + let key_values = inputs.batch.iter().map(|v| { + key_selection + .apply_to(&Value::Object(v.clone())) + .0 + .unwrap_or(Value::Null) + }); + + // Create a map of keys to entities + let mut map = values + .into_iter() + .filter_map(|v| key_selection.apply_to(&v).0.map(|key| (key, v))) + .collect::>(); + + // Make a list of entities that matches the representations list + let new_entities = key_values + .map(|key| map.remove(&key).unwrap_or(Value::Null)) + .collect_vec(); + + // Because we may have multiple batch entities requests, we should add to ENTITIES as the requests come in so it is additive + let entities = data + .entry(ENTITIES) + .or_insert(Value::Array(Vec::with_capacity(count))); + + entities + .as_array_mut() + .ok_or_else(|| { + HandleResponseError::MergeError("_entities is not an array".into()) + })? + .extend(new_entities); + } + }, + } + + Ok(()) + } + + pub fn problems(&self) -> &[Problem] { + match self { + Self::Error { problems, .. } | Self::Data { problems, .. } => problems, + } + } +} diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__models__tests__from_schema_v0_2.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__models__tests__from_schema_v0_2.snap new file mode 100644 index 0000000000..9a79e5b407 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__models__tests__from_schema_v0_2.snap @@ -0,0 +1,436 @@ +--- +source: apollo-federation/src/connectors/models.rs +expression: "&connectors" +--- +[ + Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "json", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.posts), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "https://jsonplaceholder.typicode.com/", + location: 0..37, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/posts", + location: 0..6, + }, + ), + ], + }, + method: Get, + headers: [ + Header { + name: "authtoken", + source: From( + "x-auth-token", + ), + }, + Header { + name: "user-agent", + source: Value( + HeaderValue( + StringTemplate { + parts: [ + Constant( + Constant { + value: "Firefox", + location: 0..7, + }, + ), + ], + }, + ), + ), + }, + ], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "title", + ), + range: Some( + 3..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 3..8, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "body", + ), + range: Some( + 9..13, + ), + }, + WithRange { + node: Empty, + range: Some( + 13..13, + ), + }, + ), + range: Some( + 9..13, + ), + }, + }, + }, + ], + range: Some( + 0..13, + ), + }, + ), + spec: V0_2, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_2, + request_headers: {}, + response_headers: {}, + request_variable_keys: {}, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.json http: GET /posts", + ), + }, + Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "json", + ), + named: None, + directive: Type( + ObjectTypeDefinitionDirectivePosition { + type_name: "Post", + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "https://jsonplaceholder.typicode.com/", + location: 0..37, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/posts", + location: 0..6, + }, + ), + ], + }, + method: Post, + headers: [ + Header { + name: "authtoken", + source: From( + "x-auth-token", + ), + }, + Header { + name: "user-agent", + source: Value( + HeaderValue( + StringTemplate { + parts: [ + Constant( + Constant { + value: "Firefox", + location: 0..7, + }, + ), + ], + }, + ), + ), + }, + ], + body: Some( + JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: Alias( + Alias { + name: WithRange { + node: Field( + "ids", + ), + range: Some( + 0..3, + ), + }, + range: Some( + 0..4, + ), + }, + ), + path: PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $batch, + range: Some( + 5..11, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 12..14, + ), + }, + WithRange { + node: Empty, + range: Some( + 14..14, + ), + }, + ), + range: Some( + 11..14, + ), + }, + ), + range: Some( + 5..14, + ), + }, + }, + }, + ], + range: Some( + 0..14, + ), + }, + ), + spec: V0_2, + }, + ), + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "title", + ), + range: Some( + 3..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 3..8, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "body", + ), + range: Some( + 9..13, + ), + }, + WithRange { + node: Empty, + range: Some( + 13..13, + ), + }, + ), + range: Some( + 9..13, + ), + }, + }, + }, + ], + range: Some( + 0..13, + ), + }, + ), + spec: V0_2, + }, + config: None, + max_requests: None, + entity_resolver: Some( + TypeBatch, + ), + spec: V0_2, + request_headers: {}, + response_headers: {}, + request_variable_keys: { + $batch: { + "id", + }, + }, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "[BATCH] connectors.json http: POST /posts", + ), + }, +] diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__expressions_with_nested_braces.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__expressions_with_nested_braces.snap new file mode 100644 index 0000000000..34c27cc01f --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__expressions_with_nested_braces.snap @@ -0,0 +1,134 @@ +--- +source: apollo-federation/src/connectors/string_template.rs +expression: "StringTemplate::parse_with_spec(\"const{$config.one { two { three } }}another-const\",\nConnectSpec::latest()).unwrap()" +--- +StringTemplate { + parts: [ + Constant( + Constant { + value: "const", + location: 0..5, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $config, + range: Some( + 0..7, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "one", + ), + range: Some( + 8..11, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "two", + ), + range: Some( + 14..17, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "three", + ), + range: Some( + 20..25, + ), + }, + WithRange { + node: Empty, + range: Some( + 25..25, + ), + }, + ), + range: Some( + 20..25, + ), + }, + }, + }, + ], + range: Some( + 18..27, + ), + }, + ), + range: Some( + 18..27, + ), + }, + ), + range: Some( + 14..27, + ), + }, + }, + }, + ], + range: Some( + 12..29, + ), + }, + ), + range: Some( + 12..29, + ), + }, + ), + range: Some( + 7..29, + ), + }, + ), + range: Some( + 0..29, + ), + }, + }, + ), + spec: V0_2, + }, + location: 6..35, + }, + ), + Constant( + Constant { + value: "another-const", + location: 36..49, + }, + ), + ], +} diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__mixed_constant_and_expression.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__mixed_constant_and_expression.snap new file mode 100644 index 0000000000..912e89c159 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__mixed_constant_and_expression.snap @@ -0,0 +1,66 @@ +--- +source: apollo-federation/src/connectors/string_template.rs +expression: "StringTemplate::from_str(\"text{$config.one}text\").unwrap()" +--- +StringTemplate { + parts: [ + Constant( + Constant { + value: "text", + location: 0..4, + }, + ), + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $config, + range: Some( + 0..7, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "one", + ), + range: Some( + 8..11, + ), + }, + WithRange { + node: Empty, + range: Some( + 11..11, + ), + }, + ), + range: Some( + 7..11, + ), + }, + ), + range: Some( + 0..11, + ), + }, + }, + ), + spec: V0_2, + }, + location: 5..16, + }, + ), + Constant( + Constant { + value: "text", + location: 17..21, + }, + ), + ], +} diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__offset.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__offset.snap new file mode 100644 index 0000000000..ea6f602f6b --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__offset.snap @@ -0,0 +1,63 @@ +--- +source: apollo-federation/src/connectors/string_template.rs +expression: "StringTemplate::::parse(\"text{$config.one}text\", 9).unwrap()" +--- +StringTemplate { + parts: [ + Constant( + Constant { + value: "text", + location: 9..13, + }, + ), + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $config, + range: Some( + 0..7, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "one", + ), + range: Some( + 8..11, + ), + }, + WithRange { + node: Empty, + range: Some( + 11..11, + ), + }, + ), + range: Some( + 7..11, + ), + }, + ), + range: Some( + 0..11, + ), + }, + }, + ), + location: 14..25, + }, + ), + Constant( + Constant { + value: "text", + location: 26..30, + }, + ), + ], +} diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__simple_constant.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__simple_constant.snap new file mode 100644 index 0000000000..5899a2ff66 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__simple_constant.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/string_template.rs +expression: template +--- +StringTemplate { + parts: [ + Constant( + Constant { + value: "text", + location: 0..4, + }, + ), + ], +} diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__simple_expression.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__simple_expression.snap new file mode 100644 index 0000000000..2d7f986b58 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__string_template__test_parse__simple_expression.snap @@ -0,0 +1,54 @@ +--- +source: apollo-federation/src/connectors/string_template.rs +expression: "StringTemplate::from_str(\"{$config.one}\").unwrap()" +--- +StringTemplate { + parts: [ + Expression( + Expression { + expression: JSONSelection { + inner: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $config, + range: Some( + 0..7, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "one", + ), + range: Some( + 8..11, + ), + }, + WithRange { + node: Empty, + range: Some( + 11..11, + ), + }, + ), + range: Some( + 7..11, + ), + }, + ), + range: Some( + 0..11, + ), + }, + }, + ), + spec: V0_2, + }, + location: 1..12, + }, + ), + ], +} diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_path.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_path.snap new file mode 100644 index 0000000000..85f98218f4 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_path.snap @@ -0,0 +1,48 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"http://example.com/abc/def\")" +--- +Ok( + URLTemplate { + base: Some( + Url { + scheme: "http", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "example.com", + ), + ), + port: None, + path: "/", + query: None, + fragment: None, + }, + ), + path: [ + StringTemplate { + parts: [ + Constant( + Constant { + value: "abc", + location: 19..22, + }, + ), + ], + }, + StringTemplate { + parts: [ + Constant( + Constant { + value: "def", + location: 23..26, + }, + ), + ], + }, + ], + query: [], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_path_variable.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_path_variable.snap new file mode 100644 index 0000000000..65e8f06f6e --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_path_variable.snap @@ -0,0 +1,85 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"http://example.com/{$args.abc}/def\")" +--- +Ok( + URLTemplate { + base: Some( + Url { + scheme: "http", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "example.com", + ), + ), + port: None, + path: "/", + query: None, + fragment: None, + }, + ), + path: [ + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "abc", + ), + range: Some( + 6..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 5..9, + ), + }, + ), + range: Some( + 0..9, + ), + }, + }, + ), + location: 20..29, + }, + ), + ], + }, + StringTemplate { + parts: [ + Constant( + Constant { + value: "def", + location: 31..34, + }, + ), + ], + }, + ], + query: [], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_query.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_query.snap new file mode 100644 index 0000000000..0e8c18b5a0 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_query.snap @@ -0,0 +1,50 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"http://example.com?abc=def\")" +--- +Ok( + URLTemplate { + base: Some( + Url { + scheme: "http", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "example.com", + ), + ), + port: None, + path: "/", + query: None, + fragment: None, + }, + ), + path: [], + query: [ + ( + StringTemplate { + parts: [ + Constant( + Constant { + value: "abc", + location: 19..22, + }, + ), + ], + }, + StringTemplate { + parts: [ + Constant( + Constant { + value: "def", + location: 23..26, + }, + ), + ], + }, + ), + ], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_query_variable.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_query_variable.snap new file mode 100644 index 0000000000..4bfa2f5e21 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__absolute_url_with_query_variable.snap @@ -0,0 +1,87 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"http://example.com?abc={$args.abc}\")" +--- +Ok( + URLTemplate { + base: Some( + Url { + scheme: "http", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "example.com", + ), + ), + port: None, + path: "/", + query: None, + fragment: None, + }, + ), + path: [], + query: [ + ( + StringTemplate { + parts: [ + Constant( + Constant { + value: "abc", + location: 19..22, + }, + ), + ], + }, + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "abc", + ), + range: Some( + 6..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 5..9, + ), + }, + ), + range: Some( + 0..9, + ), + }, + }, + ), + location: 24..33, + }, + ), + ], + }, + ), + ], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__basic_absolute_url.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__basic_absolute_url.snap new file mode 100644 index 0000000000..445b6bc77c --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__basic_absolute_url.snap @@ -0,0 +1,27 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"http://example.com\")" +--- +Ok( + URLTemplate { + base: Some( + Url { + scheme: "http", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "example.com", + ), + ), + port: None, + path: "/", + query: None, + fragment: None, + }, + ), + path: [], + query: [], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__expression_missing_closing_bracket.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__expression_missing_closing_bracket.snap new file mode 100644 index 0000000000..b3e5fc1285 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__expression_missing_closing_bracket.snap @@ -0,0 +1,10 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"{$this { x: { y } }\")" +--- +Err( + Error { + message: "Invalid expression, missing closing }", + location: 0..19, + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__nested_braces_in_expression.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__nested_braces_in_expression.snap new file mode 100644 index 0000000000..d26f679990 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__nested_braces_in_expression.snap @@ -0,0 +1,104 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"/position/xz/{$this { x { y } } }\")" +--- +Ok( + URLTemplate { + base: None, + path: [ + StringTemplate { + parts: [ + Constant( + Constant { + value: "position", + location: 1..9, + }, + ), + ], + }, + StringTemplate { + parts: [ + Constant( + Constant { + value: "xz", + location: 10..12, + }, + ), + ], + }, + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Selection( + SubSelection { + selections: [ + Field( + None, + WithRange { + node: Field( + "x", + ), + range: Some( + 8..9, + ), + }, + Some( + SubSelection { + selections: [ + Field( + None, + WithRange { + node: Field( + "y", + ), + range: Some( + 12..13, + ), + }, + None, + ), + ], + range: Some( + 10..15, + ), + }, + ), + ), + ], + range: Some( + 6..17, + ), + }, + ), + range: Some( + 6..17, + ), + }, + ), + range: Some( + 0..17, + ), + }, + }, + ), + location: 14..32, + }, + ), + ], + }, + ], + query: [], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list-2.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list-2.snap new file mode 100644 index 0000000000..8b7f1ebc68 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list-2.snap @@ -0,0 +1,32 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"/abc/def\")" +--- +Ok( + URLTemplate { + base: None, + path: [ + StringTemplate { + parts: [ + Constant( + Constant { + value: "abc", + location: 1..4, + }, + ), + ], + }, + StringTemplate { + parts: [ + Constant( + Constant { + value: "def", + location: 5..8, + }, + ), + ], + }, + ], + query: [], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list-3.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list-3.snap new file mode 100644 index 0000000000..2486dac77e --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list-3.snap @@ -0,0 +1,69 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"/abc/{$args.def}\")" +--- +Ok( + URLTemplate { + base: None, + path: [ + StringTemplate { + parts: [ + Constant( + Constant { + value: "abc", + location: 1..4, + }, + ), + ], + }, + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "def", + ), + range: Some( + 6..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 5..9, + ), + }, + ), + range: Some( + 0..9, + ), + }, + }, + ), + location: 6..15, + }, + ), + ], + }, + ], + query: [], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list-4.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list-4.snap new file mode 100644 index 0000000000..1994edd12d --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list-4.snap @@ -0,0 +1,94 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"/abc/{$this.def.thing}/ghi\")" +--- +Ok( + URLTemplate { + base: None, + path: [ + StringTemplate { + parts: [ + Constant( + Constant { + value: "abc", + location: 1..4, + }, + ), + ], + }, + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "def", + ), + range: Some( + 6..9, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "thing", + ), + range: Some( + 10..15, + ), + }, + WithRange { + node: Empty, + range: Some( + 15..15, + ), + }, + ), + range: Some( + 9..15, + ), + }, + ), + range: Some( + 5..15, + ), + }, + ), + range: Some( + 0..15, + ), + }, + }, + ), + location: 6..21, + }, + ), + ], + }, + StringTemplate { + parts: [ + Constant( + Constant { + value: "ghi", + location: 23..26, + }, + ), + ], + }, + ], + query: [], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list.snap new file mode 100644 index 0000000000..a342b28542 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__path_list.snap @@ -0,0 +1,22 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"/abc\")" +--- +Ok( + URLTemplate { + base: None, + path: [ + StringTemplate { + parts: [ + Constant( + Constant { + value: "abc", + location: 1..4, + }, + ), + ], + }, + ], + query: [], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse-2.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse-2.snap new file mode 100644 index 0000000000..1dce3c0b4b --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse-2.snap @@ -0,0 +1,262 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"/users/{$this.user_id}?a={$args.b}&c={$args.d}&e={$args.f.g}\")" +--- +Ok( + URLTemplate { + base: None, + path: [ + StringTemplate { + parts: [ + Constant( + Constant { + value: "users", + location: 1..6, + }, + ), + ], + }, + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "user_id", + ), + range: Some( + 6..13, + ), + }, + WithRange { + node: Empty, + range: Some( + 13..13, + ), + }, + ), + range: Some( + 5..13, + ), + }, + ), + range: Some( + 0..13, + ), + }, + }, + ), + location: 8..21, + }, + ), + ], + }, + ], + query: [ + ( + StringTemplate { + parts: [ + Constant( + Constant { + value: "a", + location: 23..24, + }, + ), + ], + }, + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 6..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 5..7, + ), + }, + ), + range: Some( + 0..7, + ), + }, + }, + ), + location: 26..33, + }, + ), + ], + }, + ), + ( + StringTemplate { + parts: [ + Constant( + Constant { + value: "c", + location: 35..36, + }, + ), + ], + }, + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "d", + ), + range: Some( + 6..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 5..7, + ), + }, + ), + range: Some( + 0..7, + ), + }, + }, + ), + location: 38..45, + }, + ), + ], + }, + ), + ( + StringTemplate { + parts: [ + Constant( + Constant { + value: "e", + location: 47..48, + }, + ), + ], + }, + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "f", + ), + range: Some( + 6..7, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "g", + ), + range: Some( + 8..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 7..9, + ), + }, + ), + range: Some( + 5..9, + ), + }, + ), + range: Some( + 0..9, + ), + }, + }, + ), + location: 50..59, + }, + ), + ], + }, + ), + ], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse-3.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse-3.snap new file mode 100644 index 0000000000..f4ff526389 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse-3.snap @@ -0,0 +1,135 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"/users/{$this.id}?a={$config.b}#junk\")" +--- +Ok( + URLTemplate { + base: None, + path: [ + StringTemplate { + parts: [ + Constant( + Constant { + value: "users", + location: 1..6, + }, + ), + ], + }, + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 6..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 5..8, + ), + }, + ), + range: Some( + 0..8, + ), + }, + }, + ), + location: 8..16, + }, + ), + ], + }, + ], + query: [ + ( + StringTemplate { + parts: [ + Constant( + Constant { + value: "a", + location: 18..19, + }, + ), + ], + }, + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $config, + range: Some( + 0..7, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "b", + ), + range: Some( + 8..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 7..9, + ), + }, + ), + range: Some( + 0..9, + ), + }, + }, + ), + location: 21..30, + }, + ), + Constant( + Constant { + value: "#junk", + location: 31..36, + }, + ), + ], + }, + ), + ], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse-4.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse-4.snap new file mode 100644 index 0000000000..09f41a72aa --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse-4.snap @@ -0,0 +1,118 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"/location/{$this.lat},{$this.lon}\")" +--- +Ok( + URLTemplate { + base: None, + path: [ + StringTemplate { + parts: [ + Constant( + Constant { + value: "location", + location: 1..9, + }, + ), + ], + }, + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "lat", + ), + range: Some( + 6..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 5..9, + ), + }, + ), + range: Some( + 0..9, + ), + }, + }, + ), + location: 11..20, + }, + ), + Constant( + Constant { + value: ",", + location: 21..22, + }, + ), + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $this, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "lon", + ), + range: Some( + 6..9, + ), + }, + WithRange { + node: Empty, + range: Some( + 9..9, + ), + }, + ), + range: Some( + 5..9, + ), + }, + ), + range: Some( + 0..9, + ), + }, + }, + ), + location: 23..32, + }, + ), + ], + }, + ], + query: [], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse.snap new file mode 100644 index 0000000000..cdae0605f9 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__url_path_template_parse.snap @@ -0,0 +1,92 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"/users/{$config.user_id}?a=b\")" +--- +Ok( + URLTemplate { + base: None, + path: [ + StringTemplate { + parts: [ + Constant( + Constant { + value: "users", + location: 1..6, + }, + ), + ], + }, + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $config, + range: Some( + 0..7, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "user_id", + ), + range: Some( + 8..15, + ), + }, + WithRange { + node: Empty, + range: Some( + 15..15, + ), + }, + ), + range: Some( + 7..15, + ), + }, + ), + range: Some( + 0..15, + ), + }, + }, + ), + location: 8..23, + }, + ), + ], + }, + ], + query: [ + ( + StringTemplate { + parts: [ + Constant( + Constant { + value: "a", + location: 25..26, + }, + ), + ], + }, + StringTemplate { + parts: [ + Constant( + Constant { + value: "b", + location: 27..28, + }, + ), + ], + }, + ), + ], + }, +) diff --git a/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__variable_param_key.snap b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__variable_param_key.snap new file mode 100644 index 0000000000..cc718f98e9 --- /dev/null +++ b/apollo-federation/src/connectors/snapshots/apollo_federation__connectors__url_template__test_parse__variable_param_key.snap @@ -0,0 +1,138 @@ +--- +source: apollo-federation/src/connectors/url_template.rs +expression: "URLTemplate::from_str(\"?{$args.filter.field}={$args.filter.value}\")" +--- +Ok( + URLTemplate { + base: None, + path: [], + query: [ + ( + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "filter", + ), + range: Some( + 6..12, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "field", + ), + range: Some( + 13..18, + ), + }, + WithRange { + node: Empty, + range: Some( + 18..18, + ), + }, + ), + range: Some( + 12..18, + ), + }, + ), + range: Some( + 5..18, + ), + }, + ), + range: Some( + 0..18, + ), + }, + }, + ), + location: 2..20, + }, + ), + ], + }, + StringTemplate { + parts: [ + Expression( + Expression { + expression: Path( + PathSelection { + path: WithRange { + node: Var( + WithRange { + node: $args, + range: Some( + 0..5, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "filter", + ), + range: Some( + 6..12, + ), + }, + WithRange { + node: Key( + WithRange { + node: Field( + "value", + ), + range: Some( + 13..18, + ), + }, + WithRange { + node: Empty, + range: Some( + 18..18, + ), + }, + ), + range: Some( + 12..18, + ), + }, + ), + range: Some( + 5..18, + ), + }, + ), + range: Some( + 0..18, + ), + }, + }, + ), + location: 23..41, + }, + ), + ], + }, + ), + ], + }, +) diff --git a/apollo-federation/src/connectors/spec/connect.rs b/apollo-federation/src/connectors/spec/connect.rs new file mode 100644 index 0000000000..e83e827b44 --- /dev/null +++ b/apollo-federation/src/connectors/spec/connect.rs @@ -0,0 +1,756 @@ +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::Schema; +use apollo_compiler::ast::Directive; +use apollo_compiler::ast::Value; +use apollo_compiler::name; +use itertools::Itertools; + +use super::errors::ERRORS_ARGUMENT_NAME; +use super::errors::ErrorsArguments; +use super::http::HTTP_ARGUMENT_NAME; +use super::http::PATH_ARGUMENT_NAME; +use super::http::QUERY_PARAMS_ARGUMENT_NAME; +use crate::connectors::ConnectSpec; +use crate::connectors::ConnectorPosition; +use crate::connectors::ObjectFieldDefinitionPosition; +use crate::connectors::OriginatingDirective; +use crate::connectors::SourceName; +use crate::connectors::id::ObjectTypeDefinitionDirectivePosition; +use crate::connectors::json_selection::JSONSelection; +use crate::connectors::models::Header; +use crate::connectors::spec::connect_spec_from_schema; +use crate::error::FederationError; +use crate::schema::position::InterfaceFieldDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceFieldDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceFieldDirectivePosition; + +pub(crate) const CONNECT_DIRECTIVE_NAME_IN_SPEC: Name = name!("connect"); +pub(crate) const CONNECT_SOURCE_ARGUMENT_NAME: Name = name!("source"); +pub(crate) const CONNECT_SELECTION_ARGUMENT_NAME: Name = name!("selection"); +pub(crate) const CONNECT_ENTITY_ARGUMENT_NAME: Name = name!("entity"); +pub(crate) const CONNECT_ID_ARGUMENT_NAME: Name = name!("id"); +pub(crate) const CONNECT_HTTP_NAME_IN_SPEC: Name = name!("ConnectHTTP"); +pub(crate) const CONNECT_BATCH_NAME_IN_SPEC: Name = name!("ConnectBatch"); +pub(crate) const CONNECT_BODY_ARGUMENT_NAME: Name = name!("body"); +pub(crate) const BATCH_ARGUMENT_NAME: Name = name!("batch"); +pub(crate) const IS_SUCCESS_ARGUMENT_NAME: Name = name!("isSuccess"); + +pub(super) const DEFAULT_CONNECT_SPEC: ConnectSpec = ConnectSpec::V0_2; + +pub(crate) fn extract_connect_directive_arguments( + schema: &Schema, + name: &Name, +) -> Result, FederationError> { + // connect on fields + schema + .types + .iter() + .filter_map(|(name, ty)| match ty { + apollo_compiler::schema::ExtendedType::Object(node) => { + Some((name, &node.fields, /* is_interface */ false)) + } + apollo_compiler::schema::ExtendedType::Interface(node) => { + Some((name, &node.fields, /* is_interface */ true)) + } + _ => None, + }) + .flat_map(|(type_name, fields, is_interface)| { + fields.iter().flat_map(move |(field_name, field_def)| { + field_def + .directives + .iter() + .enumerate() + .filter(|(_, directive)| directive.name == *name) + .map(move |(i, directive)| { + let field_pos = if is_interface { + ObjectOrInterfaceFieldDefinitionPosition::Interface( + InterfaceFieldDefinitionPosition { + type_name: type_name.clone(), + field_name: field_name.clone(), + }, + ) + } else { + ObjectOrInterfaceFieldDefinitionPosition::Object( + ObjectFieldDefinitionPosition { + type_name: type_name.clone(), + field_name: field_name.clone(), + }, + ) + }; + + let position = + ConnectorPosition::Field(ObjectOrInterfaceFieldDirectivePosition { + field: field_pos, + directive_name: directive.name.clone(), + directive_index: i, + }); + + let connect_spec = + connect_spec_from_schema(schema).unwrap_or(DEFAULT_CONNECT_SPEC); + + ConnectDirectiveArguments::from_position_and_directive( + position, + directive, + connect_spec, + ) + }) + }) + }) + .chain( + // connect on types + schema + .types + .iter() + .filter_map(|(_, ty)| ty.as_object()) + .flat_map(|ty| { + ty.directives + .iter() + .enumerate() + .filter(|(_, directive)| directive.name == *name) + .map(move |(i, directive)| { + let position = + ConnectorPosition::Type(ObjectTypeDefinitionDirectivePosition { + type_name: ty.name.clone(), + directive_name: directive.name.clone(), + directive_index: i, + }); + + let connect_spec = + connect_spec_from_schema(schema).unwrap_or(DEFAULT_CONNECT_SPEC); + + ConnectDirectiveArguments::from_position_and_directive( + position, + directive, + connect_spec, + ) + }) + }), + ) + .collect() +} + +/// Arguments to the `@connect` directive +/// +/// Refer to [ConnectSpecDefinition] for more info. +#[cfg_attr(test, derive(Debug))] +pub(crate) struct ConnectDirectiveArguments { + pub(crate) position: ConnectorPosition, + + /// The upstream source for shared connector configuration. + /// + /// Must match the `name` argument of a @source directive in this schema. + pub(crate) source: Option, + + /// HTTP options for this connector + /// + /// Marked as optional in the GraphQL schema to allow for future transports, + /// but is currently required. + pub(crate) http: Option, + + /// Fields to extract from the upstream JSON response. + /// + /// Uses the JSONSelection syntax to define a mapping of connector response to + /// GraphQL schema. + pub(crate) selection: JSONSelection, + + /// Custom connector ID name + pub(crate) connector_id: Option, + + /// Entity resolver marker + /// + /// Marks this connector as a canonical resolver for an entity (uniquely + /// identified domain model.) If true, the connector must be defined on a field + /// of the Query type. + pub(crate) entity: bool, + + /// Settings for the connector when it is doing a $batch entity resolver + pub(crate) batch: Option, + + /// Configure the error mapping functionality for this connect + pub(crate) errors: Option, + + /// Criteria to use to determine if a request is a success. + /// + /// Uses the JSONSelection to define a success criteria. This JSON Selection + /// _must_ resolve to a boolean value. + pub(crate) is_success: Option, +} + +impl ConnectDirectiveArguments { + fn from_position_and_directive( + position: ConnectorPosition, + value: &Node, + connect_spec: ConnectSpec, + ) -> Result { + let args = &value.arguments; + let directive_name = &value.name; + + // We'll have to iterate over the arg list and keep the properties by their name + let source = SourceName::from_connect(value); + let mut http = None; + let mut selection = None; + let mut entity = None; + let mut connector_id = None; + let mut batch = None; + let mut errors = None; + let mut is_success = None; + for arg in args { + let arg_name = arg.name.as_str(); + + if arg_name == HTTP_ARGUMENT_NAME.as_str() { + let http_value = arg.value.as_object().ok_or_else(|| { + FederationError::internal(format!( + "`http` field in `@{directive_name}` directive is not an object" + )) + })?; + + http = Some(ConnectHTTPArguments::try_from(( + http_value, + directive_name, + connect_spec, + ))?); + } else if arg_name == BATCH_ARGUMENT_NAME.as_str() { + let http_value = arg.value.as_object().ok_or_else(|| { + FederationError::internal(format!( + "`http` field in `@{directive_name}` directive is not an object" + )) + })?; + + batch = Some(ConnectBatchArguments::try_from(( + http_value, + directive_name, + ))?); + } else if arg_name == ERRORS_ARGUMENT_NAME.as_str() { + let http_value = arg.value.as_object().ok_or_else(|| { + FederationError::internal(format!( + "`errors` field in `@{directive_name}` directive is not an object" + )) + })?; + + let errors_value = + ErrorsArguments::try_from((http_value, directive_name, connect_spec))?; + + errors = Some(errors_value); + } else if arg_name == CONNECT_SELECTION_ARGUMENT_NAME.as_str() { + let selection_value = arg.value.as_str().ok_or_else(|| { + FederationError::internal(format!( + "`selection` field in `@{directive_name}` directive is not a string" + )) + })?; + selection = Some( + JSONSelection::parse_with_spec(selection_value, connect_spec) + .map_err(|e| FederationError::internal(e.message))?, + ); + } else if arg_name == CONNECT_ID_ARGUMENT_NAME.as_str() { + let id = arg.value.as_str().ok_or_else(|| { + FederationError::internal(format!( + "`id` field in `@{directive_name}` directive is not a string" + )) + })?; + + connector_id = Some(Name::new(id)?); + } else if arg_name == CONNECT_ENTITY_ARGUMENT_NAME.as_str() { + let entity_value = arg.value.to_bool().ok_or_else(|| { + FederationError::internal(format!( + "`entity` field in `@{directive_name}` directive is not a boolean" + )) + })?; + + entity = Some(entity_value); + } else if arg_name == IS_SUCCESS_ARGUMENT_NAME.as_str() { + let selection_value = arg.value.as_str().ok_or_else(|| { + FederationError::internal(format!( + "`is_success` field in `@{directive_name}` directive is not a string" + )) + })?; + is_success = Some( + JSONSelection::parse_with_spec(selection_value, connect_spec) + .map_err(|e| FederationError::internal(e.message))?, + ); + } + } + + Ok(Self { + position, + source, + http, + connector_id, + selection: selection.ok_or_else(|| { + FederationError::internal(format!( + "`@{directive_name}` directive is missing a selection" + )) + })?, + entity: entity.unwrap_or_default(), + batch, + errors, + is_success, + }) + } +} + +/// The HTTP arguments needed for a connect request +#[cfg_attr(test, derive(Debug))] +pub struct ConnectHTTPArguments { + pub(crate) get: Option, + pub(crate) post: Option, + pub(crate) patch: Option, + pub(crate) put: Option, + pub(crate) delete: Option, + + /// Request body + /// + /// Define a request body using JSONSelection. Selections can include values from + /// field arguments using `$args.argName` and from fields on the parent type using + /// `$this.fieldName`. + pub(crate) body: Option, + + /// Configuration for headers to attach to the request. + /// + /// Overrides headers from the associated @source by name. + pub(crate) headers: Vec
, + + /// A [`JSONSelection`] that should resolve to an array of strings to append to the path. + pub(crate) path: Option, + /// A [`JSONSelection`] that should resolve to an object to convert to query params. + pub(crate) query_params: Option, +} + +impl TryFrom<(&ObjectNode, &Name, ConnectSpec)> for ConnectHTTPArguments { + type Error = FederationError; + + fn try_from( + (values, directive_name, connect_spec): (&ObjectNode, &Name, ConnectSpec), + ) -> Result { + let mut get = None; + let mut post = None; + let mut patch = None; + let mut put = None; + let mut delete = None; + let mut body = None; + let headers: Vec
= + Header::from_http_arg(values, OriginatingDirective::Connect, connect_spec) + .into_iter() + .try_collect() + .map_err(|err| FederationError::internal(err.to_string()))?; + let mut path = None; + let mut query_params = None; + for (name, value) in values { + let name = name.as_str(); + + if name == CONNECT_BODY_ARGUMENT_NAME.as_str() { + let body_value = value.as_str().ok_or_else(|| { + FederationError::internal(format!("`body` field in `@{directive_name}` directive's `http` field is not a string")) + })?; + body = Some( + JSONSelection::parse_with_spec(body_value, connect_spec) + .map_err(|e| FederationError::internal(e.message))?, + ); + } else if name == "GET" { + get = Some(value.as_str().ok_or_else(|| FederationError::internal(format!( + "supplied HTTP template URL in `@{directive_name}` directive's `http` field is not a string" + )))?.to_string()); + } else if name == "POST" { + post = Some(value.as_str().ok_or_else(|| FederationError::internal(format!( + "supplied HTTP template URL in `@{directive_name}` directive's `http` field is not a string" + )))?.to_string()); + } else if name == "PATCH" { + patch = Some(value.as_str().ok_or_else(|| FederationError::internal(format!( + "supplied HTTP template URL in `@{directive_name}` directive's `http` field is not a string" + )))?.to_string()); + } else if name == "PUT" { + put = Some(value.as_str().ok_or_else(|| FederationError::internal(format!( + "supplied HTTP template URL in `@{directive_name}` directive's `http` field is not a string" + )))?.to_string()); + } else if name == "DELETE" { + delete = Some(value.as_str().ok_or_else(|| FederationError::internal(format!( + "supplied HTTP template URL in `@{directive_name}` directive's `http` field is not a string" + )))?.to_string()); + } else if name == PATH_ARGUMENT_NAME.as_str() { + let value = value.as_str().ok_or_else(|| { + FederationError::internal(format!( + "`{PATH_ARGUMENT_NAME}` field in `@{directive_name}` directive's `http` field is not a string" + )) + })?; + path = Some( + JSONSelection::parse_with_spec(value, connect_spec) + .map_err(|e| FederationError::internal(e.message))?, + ); + } else if name == QUERY_PARAMS_ARGUMENT_NAME.as_str() { + let value = value.as_str().ok_or_else(|| { + FederationError::internal(format!( + "`{QUERY_PARAMS_ARGUMENT_NAME}` field in `@{directive_name}` directive's `http` field is not a string" + )) + })?; + query_params = Some( + JSONSelection::parse_with_spec(value, connect_spec) + .map_err(|e| FederationError::internal(e.message))?, + ); + } + } + + Ok(Self { + get, + post, + patch, + put, + delete, + body, + headers, + path, + query_params, + }) + } +} + +/// Settings for the connector when it is doing a $batch entity resolver +#[derive(Clone, Copy, Debug)] +pub struct ConnectBatchArguments { + /// Set a maximum number of requests to be batched together. + /// + /// Over this maximum, will be split into multiple batch requests of `max_size`. + pub max_size: Option, +} + +/// Internal representation of the object type pairs +type ObjectNode = [(Name, Node)]; + +impl TryFrom<(&ObjectNode, &Name)> for ConnectBatchArguments { + type Error = FederationError; + + fn try_from((values, directive_name): (&ObjectNode, &Name)) -> Result { + let mut max_size = None; + for (name, value) in values { + let name = name.as_str(); + + if name == "maxSize" { + let max_size_int = Some(value.to_i32().ok_or_else(|| FederationError::internal(format!( + "supplied 'max_size' field in `@{directive_name}` directive's `batch` field is not a positive integer" + )))?); + // Convert the int to a usize since it is used for chunking an array later. + // Much better to fail here than during the request lifecycle. + max_size = max_size_int.map(|i| usize::try_from(i).map_err(|_| FederationError::internal(format!( + "supplied 'max_size' field in `@{directive_name}` directive's `batch` field is not a positive integer" + )))).transpose()?; + } + } + + Ok(Self { max_size }) + } +} + +#[cfg(test)] +mod tests { + use apollo_compiler::Schema; + use apollo_compiler::name; + + use super::*; + use crate::ValidFederationSubgraphs; + use crate::schema::FederationSchema; + use crate::supergraph::extract_subgraphs_from_supergraph; + + static SIMPLE_SUPERGRAPH: &str = include_str!("../tests/schemas/simple.graphql"); + static IS_SUCCESS_SUPERGRAPH: &str = include_str!("../tests/schemas/is-success.graphql"); + + fn get_subgraphs(supergraph_sdl: &str) -> ValidFederationSubgraphs { + let schema = Schema::parse(supergraph_sdl, "supergraph.graphql").unwrap(); + let supergraph_schema = FederationSchema::new(schema).unwrap(); + extract_subgraphs_from_supergraph(&supergraph_schema, Some(true)).unwrap() + } + + #[test] + fn test_expected_connect_spec_latest() { + // We probably want to update DEFAULT_CONNECT_SPEC when + // ConnectSpec::latest() changes, but we don't want it to happen + // automatically, so this test failure should serve as a signal to + // consider updating. + assert_eq!(DEFAULT_CONNECT_SPEC, ConnectSpec::latest()); + } + + #[test] + fn it_parses_at_connect() { + let subgraphs = get_subgraphs(SIMPLE_SUPERGRAPH); + let subgraph = subgraphs.get("connectors").unwrap(); + let schema = &subgraph.schema; + + let actual_definition = schema + .get_directive_definition(&CONNECT_DIRECTIVE_NAME_IN_SPEC) + .unwrap() + .get(schema.schema()) + .unwrap(); + + insta::assert_snapshot!( + actual_definition.to_string(), + @"directive @connect(source: String, http: connect__ConnectHTTP, batch: connect__ConnectBatch, errors: connect__ConnectorErrors, isSuccess: connect__JSONSelection, selection: connect__JSONSelection!, entity: Boolean = false, id: String) repeatable on FIELD_DEFINITION | OBJECT" + ); + + let fields = schema + .referencers() + .get_directive(CONNECT_DIRECTIVE_NAME_IN_SPEC.as_str()) + .unwrap() + .object_fields + .iter() + .map(|f| f.get(schema.schema()).unwrap().to_string()) + .collect::>() + .join("\n"); + + insta::assert_snapshot!( + fields, + @r###" + users: [User] @connect(source: "json", http: {GET: "/users"}, selection: "id name") + posts: [Post] @connect(source: "json", http: {GET: "/posts"}, selection: "id title body") + "### + ); + } + + #[test] + fn it_extracts_at_connect() { + let subgraphs = get_subgraphs(SIMPLE_SUPERGRAPH); + let subgraph = subgraphs.get("connectors").unwrap(); + let schema = &subgraph.schema; + + // Extract the connects from the schema definition and map them to their `Connect` equivalent + let connects = extract_connect_directive_arguments(schema.schema(), &name!(connect)); + + insta::assert_debug_snapshot!( + connects.unwrap(), + @r###" + [ + ConnectDirectiveArguments { + position: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.users), + directive_name: "connect", + directive_index: 0, + }, + ), + source: Some( + "json", + ), + http: Some( + ConnectHTTPArguments { + get: Some( + "/users", + ), + post: None, + patch: None, + put: None, + delete: None, + body: None, + headers: [], + path: None, + query_params: None, + }, + ), + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 3..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 3..7, + ), + }, + }, + }, + ], + range: Some( + 0..7, + ), + }, + ), + spec: V0_1, + }, + connector_id: None, + entity: false, + batch: None, + errors: None, + is_success: None, + }, + ConnectDirectiveArguments { + position: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.posts), + directive_name: "connect", + directive_index: 0, + }, + ), + source: Some( + "json", + ), + http: Some( + ConnectHTTPArguments { + get: Some( + "/posts", + ), + post: None, + patch: None, + put: None, + delete: None, + body: None, + headers: [], + path: None, + query_params: None, + }, + ), + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "title", + ), + range: Some( + 3..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 3..8, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "body", + ), + range: Some( + 9..13, + ), + }, + WithRange { + node: Empty, + range: Some( + 13..13, + ), + }, + ), + range: Some( + 9..13, + ), + }, + }, + }, + ], + range: Some( + 0..13, + ), + }, + ), + spec: V0_1, + }, + connector_id: None, + entity: false, + batch: None, + errors: None, + is_success: None, + }, + ] + "### + ); + } + + #[test] + fn it_supports_is_success_in_connect() { + let subgraphs = get_subgraphs(IS_SUCCESS_SUPERGRAPH); + let subgraph = subgraphs.get("connectors").unwrap(); + let schema = &subgraph.schema; + + // Extract the connects from the schema definition and map them to their `Connect` equivalent + let connects = + extract_connect_directive_arguments(schema.schema(), &name!(connect)).unwrap(); + for connect in connects { + // Unwrap and fail if is_success doesn't exist on all as expected. + connect.is_success.unwrap(); + } + } +} diff --git a/apollo-federation/src/connectors/spec/errors.rs b/apollo-federation/src/connectors/spec/errors.rs new file mode 100644 index 0000000000..b014847f13 --- /dev/null +++ b/apollo-federation/src/connectors/spec/errors.rs @@ -0,0 +1,64 @@ +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Value; +use apollo_compiler::name; + +use crate::connectors::ConnectSpec; +use crate::connectors::JSONSelection; +use crate::error::FederationError; + +pub(crate) const ERRORS_NAME_IN_SPEC: Name = name!("ConnectorErrors"); +pub(crate) const ERRORS_ARGUMENT_NAME: Name = name!("errors"); +pub(crate) const ERRORS_MESSAGE_ARGUMENT_NAME: Name = name!("message"); +pub(crate) const ERRORS_EXTENSIONS_ARGUMENT_NAME: Name = name!("extensions"); + +/// Configure the error mapping functionality for a source or connect +#[cfg_attr(test, derive(Debug))] +pub(crate) struct ErrorsArguments { + /// Configure the mapping for the "message" portion of an error + pub(crate) message: Option, + + /// Configure the mapping for the "extensions" portion of an error + pub(crate) extensions: Option, +} + +impl TryFrom<(&[(Name, Node)], &Name, ConnectSpec)> for ErrorsArguments { + type Error = FederationError; + + fn try_from( + (values, directive_name, spec): (&[(Name, Node)], &Name, ConnectSpec), + ) -> Result { + let mut message = None; + let mut extensions = None; + for (name, value) in values { + let name = name.as_str(); + + if name == ERRORS_MESSAGE_ARGUMENT_NAME.as_str() { + let message_value = value.as_str().ok_or_else(|| FederationError::internal(format!( + "`message` field in `@{directive_name}` directive's `errors` field is not a string") + ))?; + message = Some( + JSONSelection::parse_with_spec(message_value, spec) + .map_err(|e| FederationError::internal(e.message))?, + ); + } else if name == ERRORS_EXTENSIONS_ARGUMENT_NAME.as_str() { + let extensions_value = value.as_str().ok_or_else(|| FederationError::internal(format!( + "`extensions` field in `@{directive_name}` directive's `errors` field is not a string") + ))?; + extensions = Some( + JSONSelection::parse_with_spec(extensions_value, spec) + .map_err(|e| FederationError::internal(e.message))?, + ); + } else { + return Err(FederationError::internal(format!( + "unknown argument in `@{directive_name}` directive's `errors` field: {name}" + ))); + } + } + + Ok(Self { + message, + extensions, + }) + } +} diff --git a/apollo-federation/src/connectors/spec/http.rs b/apollo-federation/src/connectors/spec/http.rs new file mode 100644 index 0000000000..f3610e8505 --- /dev/null +++ b/apollo-federation/src/connectors/spec/http.rs @@ -0,0 +1,14 @@ +use apollo_compiler::Name; +use apollo_compiler::name; + +pub(crate) const HTTP_HEADER_MAPPING_NAME_IN_SPEC: Name = name!("HTTPHeaderMapping"); +pub(crate) const HTTP_HEADER_MAPPING_NAME_ARGUMENT_NAME: Name = name!("name"); +pub(crate) const HTTP_HEADER_MAPPING_FROM_ARGUMENT_NAME: Name = name!("from"); +pub(crate) const HTTP_HEADER_MAPPING_VALUE_ARGUMENT_NAME: Name = name!("value"); +pub(crate) const HTTP_ARGUMENT_NAME: Name = name!("http"); +pub(crate) const HEADERS_ARGUMENT_NAME: Name = name!("headers"); + +pub(crate) const PATH_ARGUMENT_NAME: Name = name!("path"); +pub(crate) const QUERY_PARAMS_ARGUMENT_NAME: Name = name!("queryParams"); + +pub(crate) const URL_PATH_TEMPLATE_SCALAR_NAME: Name = name!("URLTemplate"); diff --git a/apollo-federation/src/connectors/spec/mod.rs b/apollo-federation/src/connectors/spec/mod.rs new file mode 100644 index 0000000000..126382000a --- /dev/null +++ b/apollo-federation/src/connectors/spec/mod.rs @@ -0,0 +1,296 @@ +//! The GraphQL spec for Connectors. Includes parsing of directives and injection of required definitions. +pub(crate) mod connect; +pub(crate) mod errors; +pub(crate) mod http; +pub(crate) mod source; +mod type_and_directive_specifications; + +use std::fmt::Display; +use std::sync::LazyLock; + +use apollo_compiler::Name; +use apollo_compiler::Schema; +use apollo_compiler::ast::Argument; +use apollo_compiler::ast::Directive; +use apollo_compiler::ast::Value; +use apollo_compiler::name; +use apollo_compiler::schema::Component; +pub use connect::ConnectHTTPArguments; +pub(crate) use connect::extract_connect_directive_arguments; +use itertools::Itertools; +pub use source::SourceHTTPArguments; +pub(crate) use source::extract_source_directive_arguments; +use strum::IntoEnumIterator; +use strum_macros::EnumIter; + +use self::connect::CONNECT_DIRECTIVE_NAME_IN_SPEC; +use self::source::SOURCE_DIRECTIVE_NAME_IN_SPEC; +use crate::connectors::spec::type_and_directive_specifications::directive_specifications; +use crate::connectors::spec::type_and_directive_specifications::type_specifications; +use crate::connectors::validation::Code; +use crate::connectors::validation::Message; +use crate::error::FederationError; +use crate::link::Link; +use crate::link::Purpose; +use crate::link::spec::APOLLO_SPEC_DOMAIN; +use crate::link::spec::Identity; +use crate::link::spec::Url; +use crate::link::spec::Version; +use crate::link::spec_definition::SpecDefinition; +use crate::link::spec_definition::SpecDefinitions; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; + +const CONNECT_IDENTITY_NAME: Name = name!("connect"); + +/// The `@link` in a subgraph which enables connectors +#[derive(Clone, Debug)] +pub(crate) struct ConnectLink { + pub(crate) spec: ConnectSpec, + pub(crate) source_directive_name: Name, + pub(crate) connect_directive_name: Name, + pub(crate) directive: Component, + pub(crate) link: Link, +} + +impl<'schema> ConnectLink { + /// Find the connect link, if any, and validate it. + /// Returns `None` if this is not a connectors subgraph. + /// + /// # Errors + /// - Unknown spec version + pub(super) fn new(schema: &'schema Schema) -> Option> { + let (link, directive) = Link::for_identity(schema, &ConnectSpec::identity())?; + + let spec = match ConnectSpec::try_from(&link.url.version) { + Err(err) => { + let message = format!( + "{err}; should be one of {available_versions}.", + available_versions = ConnectSpec::iter().map(ConnectSpec::as_str).join(", "), + ); + return Some(Err(Message { + code: Code::UnknownConnectorsVersion, + message, + locations: directive + .line_column_range(&schema.sources) + .into_iter() + .collect(), + })); + } + Ok(spec) => spec, + }; + let source_directive_name = link.directive_name_in_schema(&SOURCE_DIRECTIVE_NAME_IN_SPEC); + let connect_directive_name = link.directive_name_in_schema(&CONNECT_DIRECTIVE_NAME_IN_SPEC); + Some(Ok(Self { + spec, + source_directive_name, + connect_directive_name, + directive: directive.clone(), + link, + })) + } +} + +pub(crate) fn connect_spec_from_schema(schema: &Schema) -> Option { + let connect_identity = ConnectSpec::identity(); + Link::for_identity(schema, &connect_identity) + .and_then(|(link, _directive)| ConnectSpec::try_from(&link.url.version).ok()) +} + +impl Display for ConnectLink { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.link) + } +} + +/// The known versions of the connect spec +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, EnumIter)] +pub enum ConnectSpec { + V0_1, + V0_2, + V0_3, +} + +impl PartialOrd for ConnectSpec { + fn partial_cmp(&self, other: &Self) -> Option { + let self_version: Version = (*self).into(); + let other_version: Version = (*other).into(); + self_version.partial_cmp(&other_version) + } +} + +impl ConnectSpec { + /// Returns the most recently released [`ConnectSpec`]. Used only in tests + /// because using it production code leads to sudden accidental upgrades. + #[cfg(test)] + pub(crate) fn latest() -> Self { + Self::V0_2 + } + + /// Returns the next version of the [`ConnectSpec`] to be released. + /// Test-only! + #[cfg(test)] + pub(crate) fn next() -> Self { + Self::V0_3 + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::V0_1 => "0.1", + Self::V0_2 => "0.2", + Self::V0_3 => "0.3", + } + } + + pub(crate) fn identity() -> Identity { + Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: CONNECT_IDENTITY_NAME, + } + } + + pub(crate) fn url(&self) -> Url { + Url { + identity: Self::identity(), + version: (*self).into(), + } + } + + pub(crate) fn join_directive_application(&self) -> Directive { + Directive { + name: name!(join__directive), + arguments: vec![ + Argument { + name: name!("graphs"), + value: Value::List(Vec::new()).into(), + } + .into(), + Argument { + name: name!("name"), + value: Value::String("link".to_string()).into(), + } + .into(), + Argument { + name: name!("args"), + value: Value::Object(vec![( + name!("url"), + Value::String(self.url().to_string()).into(), + )]) + .into(), + } + .into(), + ], + } + } +} + +impl TryFrom<&Version> for ConnectSpec { + type Error = String; + fn try_from(version: &Version) -> Result { + match (version.major, version.minor) { + (0, 1) => Ok(Self::V0_1), + (0, 2) => Ok(Self::V0_2), + (0, 3) => Ok(Self::V0_3), + _ => Err(format!("Unknown connect version: {version}")), + } + } +} + +impl Display for ConnectSpec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl From for Version { + fn from(spec: ConnectSpec) -> Self { + match spec { + ConnectSpec::V0_1 => Version { major: 0, minor: 1 }, + ConnectSpec::V0_2 => Version { major: 0, minor: 2 }, + ConnectSpec::V0_3 => Version { major: 0, minor: 3 }, + } + } +} + +pub(crate) struct ConnectSpecDefinition { + minimum_federation_version: Version, + url: Url, +} + +impl ConnectSpecDefinition { + pub(crate) fn new(version: Version, minimum_federation_version: Version) -> Self { + Self { + url: Url { + identity: ConnectSpec::identity(), + version, + }, + minimum_federation_version, + } + } + + pub(crate) fn from_directive( + directive: &Directive, + ) -> Result, FederationError> { + let Some(url) = directive + .specified_argument_by_name("url") + .and_then(|a| a.as_str()) + else { + return Ok(None); + }; + + let url: Url = url.parse()?; + if url.identity.domain != APOLLO_SPEC_DOMAIN || url.identity.name != CONNECT_IDENTITY_NAME { + return Ok(None); + } + + Ok(CONNECT_VERSIONS.find(&url.version)) + } +} + +impl SpecDefinition for ConnectSpecDefinition { + fn url(&self) -> &Url { + &self.url + } + + fn directive_specs(&self) -> Vec> { + directive_specifications() + } + + fn type_specs(&self) -> Vec> { + type_specifications() + } + + fn minimum_federation_version(&self) -> &Version { + &self.minimum_federation_version + } + + fn purpose(&self) -> Option { + Some(Purpose::EXECUTION) + } +} + +pub(crate) static CONNECT_VERSIONS: LazyLock> = + LazyLock::new(|| { + let mut definitions = SpecDefinitions::new(Identity::connect_identity()); + definitions.add(ConnectSpecDefinition::new( + Version { major: 0, minor: 1 }, + Version { + major: 2, + minor: 10, + }, + )); + definitions.add(ConnectSpecDefinition::new( + Version { major: 0, minor: 2 }, + Version { + major: 2, + minor: 11, + }, + )); + definitions.add(ConnectSpecDefinition::new( + Version { major: 0, minor: 3 }, + Version { + major: 2, + minor: 12, + }, + )); + definitions + }); diff --git a/apollo-federation/src/connectors/spec/source.rs b/apollo-federation/src/connectors/spec/source.rs new file mode 100644 index 0000000000..6799c56c5d --- /dev/null +++ b/apollo-federation/src/connectors/spec/source.rs @@ -0,0 +1,430 @@ +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::Schema; +use apollo_compiler::ast::Value; +use apollo_compiler::name; +use apollo_compiler::parser::SourceMap; +use apollo_compiler::schema::Component; +use apollo_compiler::schema::Directive; +use itertools::Itertools; + +use super::errors::ERRORS_ARGUMENT_NAME; +use super::errors::ErrorsArguments; +use crate::connectors::ConnectSpec; +use crate::connectors::Header; +use crate::connectors::JSONSelection; +use crate::connectors::OriginatingDirective; +use crate::connectors::SourceName; +use crate::connectors::StringTemplate; +use crate::connectors::spec::connect::DEFAULT_CONNECT_SPEC; +use crate::connectors::spec::connect::IS_SUCCESS_ARGUMENT_NAME; +use crate::connectors::spec::connect_spec_from_schema; +use crate::connectors::spec::http::HTTP_ARGUMENT_NAME; +use crate::connectors::spec::http::PATH_ARGUMENT_NAME; +use crate::connectors::spec::http::QUERY_PARAMS_ARGUMENT_NAME; +use crate::connectors::string_template; +use crate::connectors::validation::Code; +use crate::connectors::validation::Message; +use crate::error::FederationError; + +pub(crate) const SOURCE_DIRECTIVE_NAME_IN_SPEC: Name = name!("source"); +pub(crate) const SOURCE_NAME_ARGUMENT_NAME: Name = name!("name"); +pub(crate) const SOURCE_HTTP_NAME_IN_SPEC: Name = name!("SourceHTTP"); + +pub(crate) fn extract_source_directive_arguments( + schema: &Schema, + name: &Name, +) -> Result, FederationError> { + let connect_spec = connect_spec_from_schema(schema).unwrap_or(DEFAULT_CONNECT_SPEC); + schema + .schema_definition + .directives + .iter() + .filter(|directive| directive.name == *name) + .map(|directive| { + SourceDirectiveArguments::from_directive(directive, &schema.sources, connect_spec) + }) + .collect() +} + +/// Arguments to the `@source` directive +#[cfg_attr(test, derive(Debug))] +pub(crate) struct SourceDirectiveArguments { + /// The friendly name of this source for use in `@connect` directives + pub(crate) name: SourceName, + + /// Common HTTP options + pub(crate) http: SourceHTTPArguments, + + /// Configure the error mapping functionality for this source + pub(crate) errors: Option, + + /// Conditional statement to override the default success criteria for responses + pub(crate) is_success: Option, +} + +impl SourceDirectiveArguments { + fn from_directive( + value: &Component, + sources: &SourceMap, + spec: ConnectSpec, + ) -> Result { + let args = &value.arguments; + let directive_name = &value.name; + + // We'll have to iterate over the arg list and keep the properties by their name + let name = SourceName::from_directive_permissive(value, sources).map_err(|message| { + crate::error::SingleFederationError::InvalidGraphQL { + message: message.message, + } + })?; + let mut http = None; + let mut errors = None; + let mut is_success = None; + for arg in args { + let arg_name = arg.name.as_str(); + + if arg_name == HTTP_ARGUMENT_NAME.as_str() { + let http_value = arg.value.as_object().ok_or_else(|| { + FederationError::internal(format!( + "`http` field in `@{directive_name}` directive is not an object" + )) + })?; + let http_value = + SourceHTTPArguments::from_directive(http_value, directive_name, sources, spec)?; + + http = Some(http_value); + } else if arg_name == ERRORS_ARGUMENT_NAME.as_str() { + let http_value = arg.value.as_object().ok_or_else(|| { + FederationError::internal(format!( + "`errors` field in `@{directive_name}` directive is not an object" + )) + })?; + let errors_value = ErrorsArguments::try_from((http_value, directive_name, spec))?; + + errors = Some(errors_value); + } else if arg_name == IS_SUCCESS_ARGUMENT_NAME.as_str() { + let selection_value = arg.value.as_str().ok_or_else(|| { + FederationError::internal(format!( + "`is_success` field in `@{directive_name}` directive is not a string" + )) + })?; + is_success = Some( + JSONSelection::parse_with_spec(selection_value, spec) + .map_err(|e| FederationError::internal(e.message))?, + ); + } + } + + Ok(Self { + name, + http: http.ok_or_else(|| { + FederationError::internal(format!( + "missing `http` field in `@{directive_name}` directive" + )) + })?, + errors, + is_success, + }) + } +} + +/// Parsed `@source(http:)` +#[cfg_attr(test, derive(Debug))] +pub struct SourceHTTPArguments { + /// The base URL containing all sub API endpoints + pub(crate) base_url: BaseUrl, + + /// HTTP headers used when requesting resources from the upstream source. + /// Can be overridden by name with headers in a @connect directive. + pub(crate) headers: Vec
, + pub(crate) path: Option, + pub(crate) query_params: Option, +} + +impl SourceHTTPArguments { + fn from_directive( + values: &[(Name, Node)], + directive_name: &Name, + sources: &SourceMap, + spec: ConnectSpec, + ) -> Result { + let base_url = BaseUrl::parse(values, directive_name, sources, spec) + .map_err(|err| FederationError::internal(err.message))?; + let headers: Vec
= + Header::from_http_arg(values, OriginatingDirective::Source, spec) + .into_iter() + .try_collect() + .map_err(|err| FederationError::internal(err.to_string()))?; + let mut path = None; + let mut query = None; + for (name, value) in values { + let name = name.as_str(); + + if name == PATH_ARGUMENT_NAME.as_str() { + let value = value.as_str().ok_or_else(|| { + FederationError::internal(format!( + "`{PATH_ARGUMENT_NAME}` field in `@{directive_name}` directive's `http.path` field is not a string" + )) + })?; + path = Some( + JSONSelection::parse_with_spec(value, spec) + .map_err(|e| FederationError::internal(e.message))?, + ); + } else if name == QUERY_PARAMS_ARGUMENT_NAME.as_str() { + let value = value.as_str().ok_or_else(|| FederationError::internal(format!( + "`{QUERY_PARAMS_ARGUMENT_NAME}` field in `@{directive_name}` directive's `http.queryParams` field is not a string" + )))?; + query = Some( + JSONSelection::parse_with_spec(value, spec) + .map_err(|e| FederationError::internal(e.message))?, + ); + } + } + + Ok(Self { + base_url, + headers, + path, + query_params: query, + }) + } +} + +/// The `baseURL` argument to the `@source` directive +#[derive(Debug, Clone)] +pub(crate) struct BaseUrl { + pub(crate) template: StringTemplate, + pub(crate) node: Node, +} + +impl BaseUrl { + pub(crate) const ARGUMENT: Name = name!("baseURL"); + + pub(crate) fn parse( + values: &[(Name, Node)], + directive_name: &Name, + sources: &SourceMap, + spec: ConnectSpec, + ) -> Result { + const BASE_URL: Name = BaseUrl::ARGUMENT; + + let value = values + .iter() + .find_map(|(key, value)| (key == &Self::ARGUMENT).then_some(value)) + .ok_or_else(|| Message { + code: Code::GraphQLError, + message: format!("`@{directive_name}` must have a `baseURL` argument."), + locations: directive_name + .line_column_range(sources) + .into_iter() + .collect(), + })?; + let str_value = value.as_str().ok_or_else(|| Message { + code: Code::GraphQLError, + message: format!("`@{directive_name}({BASE_URL}:)` must be a string."), + locations: value.line_column_range(sources).into_iter().collect(), + })?; + let template: StringTemplate = StringTemplate::parse_with_spec( + str_value, + spec, + ).map_err(|inner: string_template::Error| { + Message { + code: Code::InvalidUrl, + message: format!( + "`@{directive_name}({BASE_URL})` value {str_value} is not a valid URL Template: {inner}." + ), + locations: value.line_column_range(sources).into_iter().collect(), + } + })?; + + Ok(Self { + template, + node: value.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use apollo_compiler::Schema; + use http::Uri; + + use super::*; + use crate::ValidFederationSubgraphs; + use crate::connectors::Namespace; + use crate::schema::FederationSchema; + use crate::supergraph::extract_subgraphs_from_supergraph; + + static SIMPLE_SUPERGRAPH: &str = include_str!("../tests/schemas/simple.graphql"); + static TEMPLATED_SOURCE_SUPERGRAPH: &str = + include_str!("../tests/schemas/source-template.graphql"); + static IS_SUCCESS_SOURCE_SUPERGRAPH: &str = + include_str!("../tests/schemas/is-success-source.graphql"); + + fn get_subgraphs(supergraph_sdl: &str) -> ValidFederationSubgraphs { + let schema = Schema::parse(supergraph_sdl, "supergraph.graphql").unwrap(); + let supergraph_schema = FederationSchema::new(schema).unwrap(); + extract_subgraphs_from_supergraph(&supergraph_schema, Some(true)).unwrap() + } + + #[test] + fn it_parses_at_source() { + let subgraphs = get_subgraphs(SIMPLE_SUPERGRAPH); + let subgraph = subgraphs.get("connectors").unwrap(); + + let actual_definition = subgraph + .schema + .get_directive_definition(&SOURCE_DIRECTIVE_NAME_IN_SPEC) + .unwrap() + .get(subgraph.schema.schema()) + .unwrap(); + + insta::assert_snapshot!(actual_definition.to_string(), @"directive @source(name: String!, http: connect__SourceHTTP, errors: connect__ConnectorErrors, isSuccess: connect__JSONSelection) repeatable on SCHEMA"); + + insta::assert_debug_snapshot!( + subgraph.schema + .referencers() + .get_directive(SOURCE_DIRECTIVE_NAME_IN_SPEC.as_str()) + .unwrap(), + @r###" + DirectiveReferencers { + schema: Some( + SchemaDefinitionPosition, + ), + scalar_types: {}, + object_types: {}, + object_fields: {}, + object_field_arguments: {}, + interface_types: {}, + interface_fields: {}, + interface_field_arguments: {}, + union_types: {}, + enum_types: {}, + enum_values: {}, + input_object_types: {}, + input_object_fields: {}, + directive_arguments: {}, + } + "### + ); + } + + #[test] + fn it_extracts_at_source() { + let sources = extract_source_directive_args(SIMPLE_SUPERGRAPH); + + let source = sources.first().unwrap(); + assert_eq!(source.name, SourceName::cast("json")); + assert_eq!( + source + .http + .base_url + .template + .interpolate_uri(&Default::default()) + .unwrap() + .0, + Uri::from_static("https://jsonplaceholder.typicode.com/") + ); + assert_eq!(source.http.path, None); + assert_eq!(source.http.query_params, None); + + insta::assert_debug_snapshot!( + source.http.headers, + @r#" + [ + Header { + name: "authtoken", + source: From( + "x-auth-token", + ), + }, + Header { + name: "user-agent", + source: Value( + HeaderValue( + StringTemplate { + parts: [ + Constant( + Constant { + value: "Firefox", + location: 0..7, + }, + ), + ], + }, + ), + ), + }, + ] + "# + ); + } + + #[test] + fn it_parses_as_template_at_source() { + let directive_args = extract_source_directive_args(TEMPLATED_SOURCE_SUPERGRAPH); + + // Extract the matching templated URL from the matching source or panic if no match + let templated_base_url = directive_args + .iter() + .find(|arg| arg.name == SourceName::cast("json")) + .map(|arg| arg.http.base_url.clone()) + .unwrap() + .template; + assert_eq!( + templated_base_url.to_string(), + "https://${$config.subdomain}.typicode.com/" + ); + + // Ensure config variable exists as expected. + templated_base_url + .expressions() + .flat_map(|exp| exp.expression.variable_references()) + .find(|var_ref| var_ref.namespace.namespace == Namespace::Config) + .unwrap(); + } + + #[test] + fn it_supports_is_success_in_source() { + let spec_from_success_source_subgraph = ConnectSpec::V0_1; + let sources = extract_source_directive_args(IS_SUCCESS_SOURCE_SUPERGRAPH); + let source = sources.first().unwrap(); + assert_eq!(source.name, SourceName::cast("json")); + assert!(source.is_success.is_some()); + let expected = + JSONSelection::parse_with_spec("$status->eq(202)", spec_from_success_source_subgraph) + .unwrap(); + assert_eq!(source.is_success.as_ref().unwrap(), &expected); + } + + fn extract_source_directive_args(graph: &str) -> Vec { + let subgraphs = get_subgraphs(graph); + let subgraph = subgraphs.get("connectors").unwrap(); + let schema = &subgraph.schema; + + // Extract the sources from the schema definition and map them to their `Source` equivalent + let sources = schema + .referencers() + .get_directive(&SOURCE_DIRECTIVE_NAME_IN_SPEC) + .unwrap(); + + let schema_directive_refs = sources.schema.as_ref().unwrap(); + let sources: Result, _> = schema_directive_refs + .get(schema.schema()) + .directives + .iter() + .filter(|directive| directive.name == SOURCE_DIRECTIVE_NAME_IN_SPEC) + .map(|directive| { + let connect_spec = + connect_spec_from_schema(schema.schema()).unwrap_or(DEFAULT_CONNECT_SPEC); + SourceDirectiveArguments::from_directive( + directive, + &schema.schema().sources, + connect_spec, + ) + }) + .collect(); + sources.unwrap() + } +} diff --git a/apollo-federation/src/connectors/spec/type_and_directive_specifications.rs b/apollo-federation/src/connectors/spec/type_and_directive_specifications.rs new file mode 100644 index 0000000000..022ca95172 --- /dev/null +++ b/apollo-federation/src/connectors/spec/type_and_directive_specifications.rs @@ -0,0 +1,871 @@ +//! Code for adding required definitions to a schema. +//! For example, directive definitions and the custom scalars they need. + +use std::sync::Arc; + +use apollo_compiler::Name; +use apollo_compiler::ast::DirectiveLocation; +use apollo_compiler::ast::Type; +use apollo_compiler::ast::Value; +use apollo_compiler::name; +use apollo_compiler::ty; + +use super::connect::BATCH_ARGUMENT_NAME; +use super::connect::CONNECT_BATCH_NAME_IN_SPEC; +use super::connect::CONNECT_BODY_ARGUMENT_NAME; +use super::connect::CONNECT_DIRECTIVE_NAME_IN_SPEC; +use super::connect::CONNECT_ENTITY_ARGUMENT_NAME; +use super::connect::CONNECT_HTTP_NAME_IN_SPEC; +use super::connect::CONNECT_ID_ARGUMENT_NAME; +use super::connect::CONNECT_SELECTION_ARGUMENT_NAME; +use super::connect::CONNECT_SOURCE_ARGUMENT_NAME; +use super::connect::IS_SUCCESS_ARGUMENT_NAME; +use super::errors::ERRORS_ARGUMENT_NAME; +use super::errors::ERRORS_NAME_IN_SPEC; +use super::http::HEADERS_ARGUMENT_NAME; +use super::http::HTTP_ARGUMENT_NAME; +use super::http::HTTP_HEADER_MAPPING_FROM_ARGUMENT_NAME; +use super::http::HTTP_HEADER_MAPPING_NAME_ARGUMENT_NAME; +use super::http::HTTP_HEADER_MAPPING_NAME_IN_SPEC; +use super::http::HTTP_HEADER_MAPPING_VALUE_ARGUMENT_NAME; +use super::http::PATH_ARGUMENT_NAME; +use super::http::QUERY_PARAMS_ARGUMENT_NAME; +use super::http::URL_PATH_TEMPLATE_SCALAR_NAME; +use super::source::BaseUrl; +use super::source::SOURCE_DIRECTIVE_NAME_IN_SPEC; +use super::source::SOURCE_HTTP_NAME_IN_SPEC; +use super::source::SOURCE_NAME_ARGUMENT_NAME; +use crate::connectors::spec::ConnectSpec; +use crate::error::SingleFederationError; +use crate::link::Link; +use crate::schema::FederationSchema; +use crate::schema::type_and_directive_specification::ArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveSpecification; +use crate::schema::type_and_directive_specification::InputObjectTypeSpecification; +use crate::schema::type_and_directive_specification::ScalarTypeSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; + +macro_rules! internal { + ($s:expr) => { + SingleFederationError::Internal { + message: $s.to_string(), + } + }; +} + +fn link(s: &FederationSchema) -> Result, SingleFederationError> { + s.metadata() + .ok_or_else(|| internal!("missing metadata"))? + .for_identity(&ConnectSpec::identity()) + .ok_or_else(|| internal!("missing connect spec")) +} + +pub(crate) const JSON_SELECTION_SCALAR_NAME: Name = name!("JSONSelection"); + +fn json_selection_spec() -> ScalarTypeSpecification { + ScalarTypeSpecification { + name: JSON_SELECTION_SCALAR_NAME, + } +} + +fn url_path_template_spec() -> ScalarTypeSpecification { + ScalarTypeSpecification { + name: URL_PATH_TEMPLATE_SCALAR_NAME, + } +} + +// input HTTPHeaderMapping { +// name: String! +// as: String +// value: [String!] +// } +fn http_header_mapping_spec() -> InputObjectTypeSpecification { + InputObjectTypeSpecification { + name: HTTP_HEADER_MAPPING_NAME_IN_SPEC, + fields: |_| { + Vec::from_iter([ + ArgumentSpecification { + name: HTTP_HEADER_MAPPING_NAME_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String!)), + default_value: Default::default(), + }, + ArgumentSpecification { + name: HTTP_HEADER_MAPPING_FROM_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String)), + default_value: Default::default(), + }, + ArgumentSpecification { + name: HTTP_HEADER_MAPPING_VALUE_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!([String!])), + default_value: Default::default(), + }, + ]) + }, + } +} + +// input ConnectHTTP { +// GET: URLTemplate +// POST: URLTemplate +// PUT: URLTemplate +// PATCH: URLTemplate +// DELETE: URLTemplate +// body: JSONSelection +// headers: [HTTPHeaderMapping!] +// path: JSONSelection +// queryParams: JSONSelection +// } +fn connect_http_spec() -> InputObjectTypeSpecification { + InputObjectTypeSpecification { + name: CONNECT_HTTP_NAME_IN_SPEC, + fields: |_| { + Vec::from_iter([ + ArgumentSpecification { + name: name!(GET), + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&URL_PATH_TEMPLATE_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: Default::default(), + }, + ArgumentSpecification { + name: name!(POST), + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&URL_PATH_TEMPLATE_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: Default::default(), + }, + ArgumentSpecification { + name: name!(PUT), + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&URL_PATH_TEMPLATE_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: Default::default(), + }, + ArgumentSpecification { + name: name!(PATCH), + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&URL_PATH_TEMPLATE_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: Default::default(), + }, + ArgumentSpecification { + name: name!(DELETE), + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&URL_PATH_TEMPLATE_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: Default::default(), + }, + ArgumentSpecification { + name: CONNECT_BODY_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&JSON_SELECTION_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: Default::default(), + }, + ArgumentSpecification { + name: HEADERS_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&HTTP_HEADER_MAPPING_NAME_IN_SPEC); + Ok(Type::List(Box::new(Type::NonNullNamed(name)))) + }, + default_value: Default::default(), + }, + ArgumentSpecification { + name: PATH_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&JSON_SELECTION_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: Default::default(), + }, + ArgumentSpecification { + name: QUERY_PARAMS_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&JSON_SELECTION_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: Default::default(), + }, + ]) + }, + } +} + +// input ConnectBatch { +// maxSize: Int +// } +fn connect_batch_spec() -> InputObjectTypeSpecification { + InputObjectTypeSpecification { + name: CONNECT_BATCH_NAME_IN_SPEC, + fields: |_| { + Vec::from_iter([ArgumentSpecification { + name: name!(maxSize), + get_type: |_, _| Ok(ty!(Int)), + default_value: Default::default(), + }]) + }, + } +} + +// input ConnectorErrors { +// message: JSONSelection +// extensions: JSONSelection +// } +fn connector_errors_spec() -> InputObjectTypeSpecification { + InputObjectTypeSpecification { + name: ERRORS_NAME_IN_SPEC, + fields: |_| { + Vec::from_iter([ + ArgumentSpecification { + name: name!(message), + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&JSON_SELECTION_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: Default::default(), + }, + ArgumentSpecification { + name: name!(extensions), + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&JSON_SELECTION_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: Default::default(), + }, + ]) + }, + } +} + +// input SourceHTTP { +// baseURL: String! +// headers: [HTTPHeaderMapping!] +// path: JSONSelection +// queryParams: JSONSelection +// } +fn source_http_spec() -> InputObjectTypeSpecification { + InputObjectTypeSpecification { + name: SOURCE_HTTP_NAME_IN_SPEC, + fields: |_| { + Vec::from_iter([ + ArgumentSpecification { + name: BaseUrl::ARGUMENT, + get_type: |_, _| Ok(ty!(String!)), + default_value: Default::default(), + }, + ArgumentSpecification { + name: HEADERS_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&HTTP_HEADER_MAPPING_NAME_IN_SPEC); + Ok(Type::List(Box::new(Type::NonNullNamed(name)))) + }, + default_value: Default::default(), + }, + ArgumentSpecification { + name: PATH_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&JSON_SELECTION_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: Default::default(), + }, + ArgumentSpecification { + name: QUERY_PARAMS_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&JSON_SELECTION_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: Default::default(), + }, + ]) + }, + } +} + +pub(crate) fn type_specifications() -> Vec> { + vec![ + Box::new(json_selection_spec()), + Box::new(url_path_template_spec()), + Box::new(http_header_mapping_spec()), + Box::new(connect_http_spec()), + Box::new(connect_batch_spec()), + Box::new(connector_errors_spec()), + Box::new(source_http_spec()), + ] +} + +// connect/v0.1: +// directive @connect( +// source: String +// http: ConnectHTTP +// selection: JSONSelection! +// entity: Boolean = false +// ) repeatable on FIELD_DEFINITION +// +// connect/v0.2: +// directive @connect( +// id: String +// source: String +// http: ConnectHTTP +// selection: JSONSelection! +// entity: Boolean = false +// batch: ConnectBatch +// errors: ConnectErrors +// ) repeatable on FIELD_DEFINITION | OBJECT +fn connect_directive_spec() -> DirectiveSpecification { + DirectiveSpecification::new( + CONNECT_DIRECTIVE_NAME_IN_SPEC, + &[ + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: CONNECT_SOURCE_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String)), + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: HTTP_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&CONNECT_HTTP_NAME_IN_SPEC); + Ok(Type::Named(name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: BATCH_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&CONNECT_BATCH_NAME_IN_SPEC); + Ok(Type::Named(name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: ERRORS_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&ERRORS_NAME_IN_SPEC); + Ok(Type::Named(name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: IS_SUCCESS_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&JSON_SELECTION_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: CONNECT_SELECTION_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&JSON_SELECTION_SCALAR_NAME); + Ok(Type::NonNullNamed(name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: CONNECT_ENTITY_ARGUMENT_NAME, + get_type: |_, _| Ok(Type::Named(name!(Boolean))), + default_value: Some(Value::Boolean(false)), + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + composition_strategy: None, + base_spec: ArgumentSpecification { + name: CONNECT_ID_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String)), + default_value: None, + }, + }, + ], + true, + &[ + DirectiveLocation::FieldDefinition, + DirectiveLocation::Object, + ], + false, + None, + None, + ) +} + +// directive @source( +// name: String! +// http: SourceHTTP +// errors: ConnectorErrors +// ) repeatable on SCHEMA +fn source_directive_spec() -> DirectiveSpecification { + DirectiveSpecification::new( + SOURCE_DIRECTIVE_NAME_IN_SPEC, + &[ + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: SOURCE_NAME_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String!)), + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: HTTP_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&SOURCE_HTTP_NAME_IN_SPEC); + Ok(Type::Named(name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: ERRORS_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&ERRORS_NAME_IN_SPEC); + Ok(Type::Named(name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: IS_SUCCESS_ARGUMENT_NAME, + get_type: |s, _| { + let name = link(s)?.type_name_in_schema(&JSON_SELECTION_SCALAR_NAME); + Ok(Type::Named(name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + ], + true, + &[DirectiveLocation::Schema], + false, + None, + None, + ) +} + +pub(crate) fn directive_specifications() -> Vec> { + vec![ + Box::new(connect_directive_spec()), + Box::new(source_directive_spec()), + ] +} + +#[cfg(test)] +mod tests { + use apollo_compiler::Schema; + use insta::assert_snapshot; + + use crate::connectors::spec::CONNECT_VERSIONS; + use crate::connectors::spec::ConnectSpec; + use crate::link::spec_definition::SpecDefinition; + use crate::schema::FederationSchema; + + #[test] + fn test() { + let schema = Schema::parse(r#" + type Query { hello: String } + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@source"]) + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + enum link__Purpose { SECURITY EXECUTION } + scalar link__Import + "#, "schema.graphql").unwrap(); + + let mut federation_schema = FederationSchema::new(schema).unwrap(); + let link = federation_schema + .metadata() + .unwrap() + .for_identity(&ConnectSpec::identity()) + .unwrap(); + + let spec = CONNECT_VERSIONS.find(&link.url.version).unwrap(); + spec.add_elements_to_schema(&mut federation_schema).unwrap(); + + assert_snapshot!(federation_schema.schema().serialize().to_string(), @r#" + schema { + query: Query + } + + extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@source"]) + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @connect(source: String, http: connect__ConnectHTTP, batch: connect__ConnectBatch, errors: connect__ConnectorErrors, isSuccess: connect__JSONSelection, selection: connect__JSONSelection!, entity: Boolean = false, id: String) repeatable on FIELD_DEFINITION | OBJECT + + directive @source(name: String!, http: connect__SourceHTTP, errors: connect__ConnectorErrors, isSuccess: connect__JSONSelection) repeatable on SCHEMA + + type Query { + hello: String + } + + enum link__Purpose { + SECURITY + EXECUTION + } + + scalar link__Import + + scalar connect__JSONSelection + + scalar connect__URLTemplate + + input connect__HTTPHeaderMapping { + name: String! + from: String + value: [String!] + } + + input connect__ConnectHTTP { + GET: connect__URLTemplate + POST: connect__URLTemplate + PUT: connect__URLTemplate + PATCH: connect__URLTemplate + DELETE: connect__URLTemplate + body: connect__JSONSelection + headers: [connect__HTTPHeaderMapping!] + path: connect__JSONSelection + queryParams: connect__JSONSelection + } + + input connect__ConnectBatch { + maxSize: Int + } + + input connect__ConnectorErrors { + message: connect__JSONSelection + extensions: connect__JSONSelection + } + + input connect__SourceHTTP { + baseURL: String! + headers: [connect__HTTPHeaderMapping!] + path: connect__JSONSelection + queryParams: connect__JSONSelection + } + "#); + } + + #[test] + fn test_v0_2() { + let schema = Schema::parse(r#" + type Query { hello: String } + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source"]) + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + enum link__Purpose { SECURITY EXECUTION } + scalar link__Import + "#, "schema.graphql").unwrap(); + + let mut federation_schema = FederationSchema::new(schema).unwrap(); + let link = federation_schema + .metadata() + .unwrap() + .for_identity(&ConnectSpec::identity()) + .unwrap(); + + let spec = CONNECT_VERSIONS.find(&link.url.version).unwrap(); + spec.add_elements_to_schema(&mut federation_schema).unwrap(); + + assert_snapshot!(federation_schema.schema().serialize().to_string(), @r#" + schema { + query: Query + } + + extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source"]) + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @connect(source: String, http: connect__ConnectHTTP, batch: connect__ConnectBatch, errors: connect__ConnectorErrors, isSuccess: connect__JSONSelection, selection: connect__JSONSelection!, entity: Boolean = false, id: String) repeatable on FIELD_DEFINITION | OBJECT + + directive @source(name: String!, http: connect__SourceHTTP, errors: connect__ConnectorErrors, isSuccess: connect__JSONSelection) repeatable on SCHEMA + + type Query { + hello: String + } + + enum link__Purpose { + SECURITY + EXECUTION + } + + scalar link__Import + + scalar connect__JSONSelection + + scalar connect__URLTemplate + + input connect__HTTPHeaderMapping { + name: String! + from: String + value: [String!] + } + + input connect__ConnectHTTP { + GET: connect__URLTemplate + POST: connect__URLTemplate + PUT: connect__URLTemplate + PATCH: connect__URLTemplate + DELETE: connect__URLTemplate + body: connect__JSONSelection + headers: [connect__HTTPHeaderMapping!] + path: connect__JSONSelection + queryParams: connect__JSONSelection + } + + input connect__ConnectBatch { + maxSize: Int + } + + input connect__ConnectorErrors { + message: connect__JSONSelection + extensions: connect__JSONSelection + } + + input connect__SourceHTTP { + baseURL: String! + headers: [connect__HTTPHeaderMapping!] + path: connect__JSONSelection + queryParams: connect__JSONSelection + } + "#); + } + + #[test] + fn test_v0_2_renames() { + let schema = Schema::parse(r#" + type Query { hello: String } + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/connect/v0.2", import: [ + { name: "@source" as: "@api" } + { name: "JSONSelection" as: "Mapping" } + { name: "ConnectorErrors" as: "ErrorMappings" } + "ConnectHTTP" + ]) + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + enum link__Purpose { SECURITY EXECUTION } + scalar link__Import + "#, "schema.graphql").unwrap(); + + let mut federation_schema = FederationSchema::new(schema).unwrap(); + let link = federation_schema + .metadata() + .unwrap() + .for_identity(&ConnectSpec::identity()) + .unwrap(); + + let spec = CONNECT_VERSIONS.find(&link.url.version).unwrap(); + spec.add_elements_to_schema(&mut federation_schema).unwrap(); + + assert_snapshot!(federation_schema.schema().serialize().to_string(), @r#" + schema { + query: Query + } + + extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/connect/v0.2", import: [{name: "@source", as: "@api"}, {name: "JSONSelection", as: "Mapping"}, {name: "ConnectorErrors", as: "ErrorMappings"}, "ConnectHTTP"]) + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @connect(source: String, http: ConnectHTTP, batch: connect__ConnectBatch, errors: ErrorMappings, isSuccess: Mapping, selection: Mapping!, entity: Boolean = false, id: String) repeatable on FIELD_DEFINITION | OBJECT + + directive @api(name: String!, http: connect__SourceHTTP, errors: ErrorMappings, isSuccess: Mapping) repeatable on SCHEMA + + type Query { + hello: String + } + + enum link__Purpose { + SECURITY + EXECUTION + } + + scalar link__Import + + scalar Mapping + + scalar connect__URLTemplate + + input connect__HTTPHeaderMapping { + name: String! + from: String + value: [String!] + } + + input ConnectHTTP { + GET: connect__URLTemplate + POST: connect__URLTemplate + PUT: connect__URLTemplate + PATCH: connect__URLTemplate + DELETE: connect__URLTemplate + body: Mapping + headers: [connect__HTTPHeaderMapping!] + path: Mapping + queryParams: Mapping + } + + input connect__ConnectBatch { + maxSize: Int + } + + input ErrorMappings { + message: Mapping + extensions: Mapping + } + + input connect__SourceHTTP { + baseURL: String! + headers: [connect__HTTPHeaderMapping!] + path: Mapping + queryParams: Mapping + } + "#); + } + + #[test] + fn test_v0_2_compatible_defs() { + let schema = Schema::parse(r#" + type Query { hello: String } + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/connect/v0.2") + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + enum link__Purpose { SECURITY EXECUTION } + scalar link__Import + + scalar connect__URLTemplate + scalar connect__JSONSelection + input connect__ConnectHTTP { + GET: connect__URLTemplate + } + directive @connect(source: String, http: connect__ConnectHTTP, selection: connect__JSONSelection!) repeatable on FIELD_DEFINITION + "#, "schema.graphql").unwrap(); + + let mut federation_schema = FederationSchema::new(schema).unwrap(); + let link = federation_schema + .metadata() + .unwrap() + .for_identity(&ConnectSpec::identity()) + .unwrap(); + + let spec = CONNECT_VERSIONS.find(&link.url.version).unwrap(); + spec.add_elements_to_schema(&mut federation_schema).unwrap(); + + assert_snapshot!(federation_schema.schema().serialize().to_string(), @r#" + schema { + query: Query + } + + extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/connect/v0.2") + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @connect(source: String, http: connect__ConnectHTTP, selection: connect__JSONSelection!) repeatable on FIELD_DEFINITION + + directive @connect__source(name: String!, http: connect__SourceHTTP, errors: connect__ConnectorErrors, isSuccess: connect__JSONSelection) repeatable on SCHEMA + + type Query { + hello: String + } + + enum link__Purpose { + SECURITY + EXECUTION + } + + scalar link__Import + + scalar connect__URLTemplate + + scalar connect__JSONSelection + + input connect__ConnectHTTP { + GET: connect__URLTemplate + } + + input connect__HTTPHeaderMapping { + name: String! + from: String + value: [String!] + } + + input connect__ConnectBatch { + maxSize: Int + } + + input connect__ConnectorErrors { + message: connect__JSONSelection + extensions: connect__JSONSelection + } + + input connect__SourceHTTP { + baseURL: String! + headers: [connect__HTTPHeaderMapping!] + path: connect__JSONSelection + queryParams: connect__JSONSelection + } + "#); + } + + #[test] + fn test_v0_2_incompatible_defs() { + let schema = Schema::parse(r#" + type Query { hello: String } + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/connect/v0.2") + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + enum link__Purpose { SECURITY EXECUTION } + scalar link__Import + + scalar connect__URLTemplate + scalar connect__JSONSelection + input connect__ConnectHTTP { + GET: connect__URLTemplate + } + directive @connect(source: String, http: connect__ConnectHTTP, selection: connect__JSONSelection!, entity: String!) repeatable on FIELD_DEFINITION + "#, "schema.graphql").unwrap(); + + let mut federation_schema = FederationSchema::new(schema).unwrap(); + let link = federation_schema + .metadata() + .unwrap() + .for_identity(&ConnectSpec::identity()) + .unwrap(); + + let spec = CONNECT_VERSIONS.find(&link.url.version).unwrap(); + let err = spec.add_elements_to_schema(&mut federation_schema).err(); + assert_snapshot!(err.unwrap().to_string(), @r###"Invalid definition for directive "@connect": argument "entity" should have type "Boolean" but found type "String""###) + } +} diff --git a/apollo-federation/src/connectors/string_template.rs b/apollo-federation/src/connectors/string_template.rs new file mode 100644 index 0000000000..36612e2ef5 --- /dev/null +++ b/apollo-federation/src/connectors/string_template.rs @@ -0,0 +1,806 @@ +//! A [`StringTemplate`] is a string containing one or more [`Expression`]s. +//! These are used in connector URIs and headers. +//! +//! Parsing (this module) is done by both the router at startup and composition. Validation +//! (in [`crate::connectors::validation`]) is done only by composition. + +#![allow(rustdoc::private_intra_doc_links)] + +use std::fmt::Display; +use std::fmt::Write; +use std::ops::Range; +use std::str::FromStr; + +use apollo_compiler::collections::IndexMap; +use http::Uri; +use http::uri::PathAndQuery; +use itertools::Itertools; +use serde_json_bytes::Value; + +pub(crate) use self::encoding::UriString; +use super::ApplyToError; +use super::ConnectSpec; +use crate::connectors::JSONSelection; +use crate::connectors::json_selection::helpers::json_to_string; + +pub(crate) const SPECIAL_WHITE_SPACES: [char; 4] = ['\t', '\n', '\x0C', '\r']; + +/// A parsed string template, containing a series of [`Part`]s. +#[derive(Clone, Debug, Default)] +pub struct StringTemplate { + pub(crate) parts: Vec, +} + +impl FromStr for StringTemplate { + type Err = Error; + + /// Parses a [`StringTemplate`] from a &str, using [`ConnectSpec::V0_2`] as + /// the parsing version. This trait implementation should be avoided outside + /// tests because it runs the risk of ignoring the developer's chosen + /// [`ConnectSpec`] if used blindly via `.parse()`, since `FromStr` gives no + /// opportunity to specify additional context like the [`ConnectSpec`]. + fn from_str(s: &str) -> Result { + Self::parse_with_spec(s, ConnectSpec::V0_2) + // If we want to detect risky uses of StringTemplate::from_str for + // templates with JSONSelection expressions, we can reenable this code. + // match Self::parse_with_spec(s, ConnectSpec::latest()) { + // Ok(template) => { + // if let Some(first) = template.expressions().next() { + // Err(Error { + // message: "StringTemplate::from_str should be used only if the template does not contain any JSONSelection expressions".to_string(), + // location: first.location.clone(), + // }) + // } else { + // // If there were no expressions, the ConnectSpec does not + // // matter. + // Ok(template) + // } + // } + // Err(err) => Err(err), + // } + } +} + +impl StringTemplate { + pub fn parse_with_spec(input: &str, spec: ConnectSpec) -> Result { + Self::common_parse_with_spec(input, 0, spec) + } + + /// Parse a [`StringTemplate`] from a particular `offset` according to a + /// given [`ConnectSpec`]. + fn common_parse_with_spec( + input: &str, + mut offset: usize, + spec: ConnectSpec, + ) -> Result { + let mut chars = input.chars().peekable(); + let mut parts = Vec::new(); + while let Some(next) = chars.peek() { + if SPECIAL_WHITE_SPACES.contains(next) { + chars.next(); + offset += 1; + continue; + } else if *next == '{' { + let mut braces_count = 0; // Ignore braces within JSONSelection + let expression = chars + .by_ref() + .skip(1) + .take_while(|c| { + if *c == '{' { + braces_count += 1; + } else if *c == '}' { + braces_count -= 1; + } + braces_count >= 0 + }) + .collect::(); + if braces_count >= 0 { + return Err(Error { + message: "Invalid expression, missing closing }".into(), + location: offset..input.len(), + }); + } + offset += 1; // Account for opening brace + // TODO This should call JSONSelection::parse_with_spec with a + // ConnectSpec, but we don't have that information handy. + let parsed = JSONSelection::parse_with_spec(&expression, spec).map_err(|err| { + let start_of_parse_error = offset + err.offset; + Error { + message: err.message, + location: start_of_parse_error..(offset + expression.len()), + } + })?; + parts.push(Part::Expression(Expression { + expression: parsed, + location: offset..(offset + expression.len()), + })); + offset += expression.len() + 1; // Account for closing brace + } else { + let value = chars + .by_ref() + .peeking_take_while(|c| *c != '{' && !SPECIAL_WHITE_SPACES.contains(c)) + .collect::(); + let len = value.len(); + parts.push(Part::Constant(Constant { + value, + location: offset..offset + len, + })); + offset += len; + } + } + Ok(StringTemplate { parts }) + } + + /// Get all the dynamic [`Expression`] pieces of the template for validation. If interpolating + /// the entire template, use [`Self::interpolate`] instead. + pub(crate) fn expressions(&self) -> impl Iterator { + self.parts.iter().filter_map(|part| { + if let Part::Expression(expression) = part { + Some(expression) + } else { + None + } + }) + } +} + +impl StringTemplate { + /// Interpolate the expressions in the template into a basic string. + /// + /// For URIs, use [`Self::interpolate_uri`] instead. + pub fn interpolate( + &self, + vars: &IndexMap, + ) -> Result<(String, Vec), Error> { + let mut result = String::new(); + let mut warnings = Vec::new(); + for part in &self.parts { + let part_warnings = part.interpolate(vars, &mut result)?; + warnings.extend(part_warnings); + } + Ok((result, warnings)) + } + + /// Interpolate the expression as a URI, percent-encoding parts as needed. + pub fn interpolate_uri( + &self, + vars: &IndexMap, + ) -> Result<(Uri, Vec), Error> { + let mut result = UriString::new(); + let mut warnings = Vec::new(); + for part in &self.parts { + match part { + Part::Constant(constant) => { + // We don't percent-encode constant strings, assuming the user knows what they want. + // `Uri::from_str` will take care of encoding completely illegal characters + + // New lines are used for code organization, but are not wanted in the result + if constant.value.contains(['\n', '\r']) { + // We don't always run this replace because it has a performance cost (allocating a string) + result.write_trusted(&constant.value.replace(['\n', '\r'], "")) + } else { + result.write_trusted(&constant.value) + } + .map_err(|_err| Error { + message: "Error writing string".to_string(), + location: constant.location.clone(), + })?; + } + Part::Expression(_) => { + let part_warnings = part.interpolate(vars, &mut result)?; + warnings.extend(part_warnings); + } + }; + } + let uri = if result.contains("://") { + Uri::from_str(result.as_ref()) + } else { + // Explicitly set this as a relative URI so it doesn't get confused for a domain name + PathAndQuery::from_str(result.as_ref()).map(Uri::from) + } + .map_err(|err| Error { + message: format!("Invalid URI: {err}"), + location: 0..result.as_ref().len(), + })?; + + Ok((uri, warnings)) + } +} + +/// Expressions should be written the same as they were originally, even though we don't keep the +/// original source around. So constants are written as-is and expressions are surrounded with `{ }`. +impl Display for StringTemplate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for part in &self.parts { + match part { + Part::Constant(Constant { value, .. }) => write!(f, "{value}")?, + Part::Expression(Expression { expression, .. }) => write!(f, "{{{expression}}}")?, + } + } + Ok(()) + } +} + +/// A general-purpose error type which includes both a description of the problem and the offset span +/// within the original expression where the problem occurred. Used for both parsing and interpolation. +#[derive(Debug, PartialEq, Eq)] +pub struct Error { + /// A human-readable description of the issue. + pub message: String, + /// The string offsets to the original [`StringTemplate`] (not just the part) where the issue + /// occurred. As per usual, the end of the range is exclusive. + pub(crate) location: Range, +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +/// One piece of a [`StringTemplate`] +#[derive(Clone, Debug)] +pub(crate) enum Part { + /// A constant string literal—the piece of a [`StringTemplate`] _not_ in `{ }` + Constant(Constant), + /// A dynamic piece of a [`StringTemplate`], which came from inside `{ }` originally. + Expression(Expression), +} + +impl Part { + /// Get the original location of the part from the string which was parsed to form the + /// [`StringTemplate`]. + pub(crate) fn location(&self) -> Range { + match self { + Self::Constant(c) => c.location.clone(), + Self::Expression(e) => e.location.clone(), + } + } + + /// Evaluate the expression of the part (if any) and write the result to `output`. + /// + /// # Errors + /// + /// If the expression evaluates to an array or object. + pub(crate) fn interpolate( + &self, + vars: &IndexMap, + mut output: Output, + ) -> Result, Error> { + let mut warnings = Vec::new(); + match self { + Part::Constant(Constant { value, .. }) => { + output.write_str(value).map_err(|err| err.into()) + } + Part::Expression(Expression { expression, .. }) => { + // TODO: do something with the ApplyTo errors + let (value, errs) = expression.apply_with_vars(&Value::Null, vars); + warnings.extend(errs); + write_value(&mut output, value.as_ref().unwrap_or(&Value::Null)) + } + } + .map_err(|err| Error { + message: err.to_string(), + location: self.location(), + })?; + + Ok(warnings) + } +} + +/// A shared definition of what it means to write a [`Value`] into a string. +/// +/// Used for string interpolation in templates and building URIs. +pub(crate) fn write_value( + mut output: Output, + value: &Value, +) -> Result<(), Box> { + match json_to_string(value) { + Ok(result) => write!(output, "{}", result.unwrap_or_default()), + Err(_) => return Err("Expression is not allowed to evaluate to arrays or objects.".into()), + } + .map_err(|err| err.into()) +} + +/// A constant string literal—the piece of a [`StringTemplate`] _not_ in `{ }` +#[derive(Clone, Debug, Default)] +pub(crate) struct Constant { + pub(crate) value: String, + pub(crate) location: Range, +} + +/// A dynamic piece of a [`StringTemplate`], which came from inside `{ }` originally. +#[derive(Clone, Debug)] +pub(crate) struct Expression { + pub(crate) expression: JSONSelection, + pub(crate) location: Range, +} + +impl std::ops::Add<&Constant> for Constant { + type Output = Self; + + fn add(self, rhs: &Self) -> Self::Output { + Self { + value: self.value + &rhs.value, + location: self.location.start..rhs.location.end, + } + } +} + +/// All the percent encoding rules we use for building URIs. +/// +/// The [`AsciiSet`] type is an efficient type used by [`percent_encoding`], +/// but the logic of it is a bit inverted from what we want. +/// An [`AsciiSet`] lists all the characters which should be encoded, rather than those which +/// should be allowed. +/// Following security best practices, we instead define sets by what is +/// explicitly allowed in a given context, so we use `remove()` to _add_ allowed characters to a context. +mod encoding { + use std::fmt::Write; + + use percent_encoding::AsciiSet; + use percent_encoding::NON_ALPHANUMERIC; + use percent_encoding::utf8_percent_encode; + + /// Characters that never need to be percent encoded are allowed by this set. + /// https://www.rfc-editor.org/rfc/rfc3986#section-2.3 + /// In other words, this is the most restrictive set, encoding everything that + /// should _sometimes_ be encoded. We can then explicitly allow additional characters + /// depending on the context. + const USER_INPUT: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'.') + .remove(b'_') + .remove(b'~'); + + /// Reserved characters https://www.rfc-editor.org/rfc/rfc3986#section-2.2 are valid in URLs + /// though not all contexts. The responsibility for these is the developer's in static pieces + /// of templates. + /// + /// We _also_ don't encode `%` because we need to allow users to do manual percent-encoding of + /// all the reserved symbols as-needed (since it's never automatic). Rather than parsing every + /// `%` to see if it's a valid hex sequence, we leave that up to the developer as well since + /// it's a pretty advanced use-case. + /// + /// This is required because percent encoding *is not idempotent* + const STATIC_TRUSTED: &AsciiSet = &USER_INPUT + .remove(b':') + .remove(b'/') + .remove(b'?') + .remove(b'#') + .remove(b'[') + .remove(b']') + .remove(b'@') + .remove(b'!') + .remove(b'$') + .remove(b'&') + .remove(b'\'') + .remove(b'(') + .remove(b')') + .remove(b'*') + .remove(b'+') + .remove(b',') + .remove(b';') + .remove(b'=') + .remove(b'%'); + + pub(crate) struct UriString { + value: String, + } + + impl UriString { + pub(crate) const fn new() -> Self { + Self { + value: String::new(), + } + } + + /// Write a bit of trusted input, like a constant piece of a template, only encoding illegal symbols. + pub(crate) fn write_trusted(&mut self, s: &str) -> std::fmt::Result { + write!( + &mut self.value, + "{}", + utf8_percent_encode(s, STATIC_TRUSTED) + ) + } + + /// Add a pre-encoded string to the URI. Used for merging without duplicating percent-encoding. + pub(crate) fn write_without_encoding(&mut self, s: &str) -> std::fmt::Result { + self.value.write_str(s) + } + + pub(crate) fn contains(&self, pattern: &str) -> bool { + self.value.contains(pattern) + } + + pub(crate) fn ends_with(&self, pattern: char) -> bool { + self.value.ends_with(pattern) + } + + pub(crate) fn into_string(self) -> String { + self.value + } + + pub(crate) fn is_empty(&self) -> bool { + self.value.is_empty() + } + } + + impl Write for UriString { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + write!(&mut self.value, "{}", utf8_percent_encode(s, USER_INPUT)) + } + } + + impl AsRef for UriString { + fn as_ref(&self) -> &str { + &self.value + } + } + + #[cfg(test)] + mod tests { + use percent_encoding::utf8_percent_encode; + + use super::*; + + /// This test is basically checking our understanding of how `AsciiSet` works. + #[test] + fn user_input_encodes_everything_but_unreserved() { + for i in 0..=255u8 { + let character = i as char; + let string = character.to_string(); + let encoded = utf8_percent_encode(&string, USER_INPUT); + for encoded_char in encoded.into_iter().flat_map(|slice| slice.chars()) { + if character.is_ascii_alphanumeric() + || character == '-' + || character == '.' + || character == '_' + || character == '~' + { + assert_eq!( + encoded_char, character, + "{character} should not have been encoded" + ); + } else { + assert!( + encoded_char.is_ascii_alphanumeric() || encoded_char == '%', // percent encoding + "{encoded_char} was not encoded" + ); + } + } + } + } + } +} + +#[cfg(test)] +mod test_parse { + use insta::assert_debug_snapshot; + + use super::*; + + #[test] + fn simple_constant() { + let template = StringTemplate::from_str("text").expect("simple template should be valid"); + assert_debug_snapshot!(template); + } + + #[test] + fn simple_expression() { + assert_debug_snapshot!( + StringTemplate::parse_with_spec("{$config.one}", ConnectSpec::latest()).unwrap() + ); + } + #[test] + fn mixed_constant_and_expression() { + assert_debug_snapshot!( + StringTemplate::parse_with_spec("text{$config.one}text", ConnectSpec::latest()) + .unwrap() + ); + } + + #[test] + fn expressions_with_nested_braces() { + assert_debug_snapshot!( + StringTemplate::parse_with_spec( + "const{$config.one { two { three } }}another-const", + ConnectSpec::latest() + ) + .unwrap() + ); + } + + #[test] + fn missing_closing_braces() { + assert_debug_snapshot!( + StringTemplate::parse_with_spec("{$config.one", ConnectSpec::latest()), + @r###" + Err( + Error { + message: "Invalid expression, missing closing }", + location: 0..12, + }, + ) + "### + ) + } +} + +#[cfg(test)] +mod test_interpolate { + use insta::assert_debug_snapshot; + use pretty_assertions::assert_eq; + use serde_json_bytes::json; + + use super::*; + #[test] + fn test_interpolate() { + let template = + StringTemplate::parse_with_spec("before {$config.one} after", ConnectSpec::latest()) + .unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": "foo"})); + assert_eq!(template.interpolate(&vars).unwrap().0, "before foo after"); + } + + #[test] + fn test_interpolate_missing_value() { + let template = + StringTemplate::parse_with_spec("{$config.one}", ConnectSpec::latest()).unwrap(); + let vars = IndexMap::default(); + assert_eq!(template.interpolate(&vars).unwrap().0, ""); + } + + #[test] + fn test_interpolate_value_array() { + let template = + StringTemplate::parse_with_spec("{$config.one}", ConnectSpec::latest()).unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": ["one", "two"]})); + assert_debug_snapshot!( + template.interpolate(&vars), + @r###" + Err( + Error { + message: "Expression is not allowed to evaluate to arrays or objects.", + location: 1..12, + }, + ) + "### + ); + } + + #[test] + fn test_interpolate_value_bool() { + let template = + StringTemplate::parse_with_spec("{$config.one}", ConnectSpec::latest()).unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": true})); + assert_eq!(template.interpolate(&vars).unwrap().0, "true"); + } + + #[test] + fn test_interpolate_value_null() { + let template = + StringTemplate::parse_with_spec("{$config.one}", ConnectSpec::latest()).unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": null})); + assert_eq!(template.interpolate(&vars).unwrap().0, ""); + } + + #[test] + fn test_interpolate_value_number() { + let template = + StringTemplate::parse_with_spec("{$config.one}", ConnectSpec::latest()).unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": 1})); + assert_eq!(template.interpolate(&vars).unwrap().0, "1"); + } + + #[test] + fn test_interpolate_value_object() { + let template = + StringTemplate::parse_with_spec("{$config.one}", ConnectSpec::latest()).unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": {}})); + assert_debug_snapshot!( + template.interpolate(&vars), + @r###" + Err( + Error { + message: "Expression is not allowed to evaluate to arrays or objects.", + location: 1..12, + }, + ) + "### + ); + } + + #[test] + fn test_interpolate_value_string() { + let template = + StringTemplate::parse_with_spec("{$config.one}", ConnectSpec::latest()).unwrap(); + let mut vars = IndexMap::default(); + vars.insert("$config".to_string(), json!({"one": "string"})); + assert_eq!(template.interpolate(&vars).unwrap().0, "string"); + } +} + +#[cfg(test)] +mod test_interpolate_uri { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::connectors::StringTemplate; + + macro_rules! this { + ($($value:tt)*) => {{ + let mut map = indexmap::IndexMap::with_capacity_and_hasher(1, Default::default()); + map.insert("$this".to_string(), serde_json_bytes::json!({ $($value)* })); + map + }}; + } + + #[rstest] + #[case::leading_slash("/path")] + #[case::trailing_slash("path/")] + #[case::sandwich_slash("/path/")] + #[case::no_slash("path")] + #[case::query_params("?something&something")] + #[case::fragment("#blah")] + fn relative_uris(#[case] val: &str) { + let template = StringTemplate::from_str(val).unwrap(); + let (uri, _) = template + .interpolate_uri(&Default::default()) + .expect("case was valid URI"); + assert!(uri.path_and_query().is_some()); + assert!(uri.authority().is_none()); + } + + #[rstest] + #[case::http("http://example.com/something")] + #[case::https("https://example.com/something")] + #[case::ipv4("http://127.0.0.1/something")] + #[case::ipv6("http://[::1]/something")] + #[case::with_port("http://localhost:8080/something")] + fn absolute_uris(#[case] val: &str) { + let template = StringTemplate::from_str(val).unwrap(); + let (uri, _) = template + .interpolate_uri(&Default::default()) + .expect("case was valid URI"); + assert!(uri.path_and_query().is_some()); + assert!(uri.authority().is_some()); + assert!(uri.scheme().is_some()); + assert_eq!(uri.to_string(), val); + } + + /// Values are all strings, they can't have semantic value for HTTP. That means no dynamic paths, + /// no nested query params, etc. When we expand values, we have to make sure they're safe. + #[test] + fn expression_encoding() { + let vars = &this! { + "path": "/some/path", + "question_mark": "a?b", + "ampersand": "a&b=b", + "hash": "a#b", + }; + + let template = StringTemplate::parse_with_spec("http://localhost/{$this.path}/{$this.question_mark}?a={$this.ampersand}&c={$this.hash}", ConnectSpec::latest()) + .expect("Failed to parse URL template"); + let (url, _) = template + .interpolate_uri(vars) + .expect("Failed to generate URL"); + + assert_eq!( + url.to_string(), + "http://localhost/%2Fsome%2Fpath/a%3Fb?a=a%26b%3Db&c=a%23b" + ); + } + + /// The resulting values of each expression are always [`Value`]s, for which we have a + /// set way of encoding each as a string. + #[test] + fn json_value_serialization() { + // `extra` would be illegal (we don't serialize arrays), but any unused values should be ignored + let vars = &this! { + "int": 1, + "float": 1.2, + "bool": true, + "null": null, + "string": "string", + "extra": [] + }; + + let template = StringTemplate::parse_with_spec( + "/{$this.int}/{$this.float}/{$this.bool}/{$this.null}/{$this.string}", + ConnectSpec::latest(), + ) + .unwrap(); + + let (uri, _) = template.interpolate(vars).expect("Failed to interpolate"); + + assert_eq!(uri, "/1/1.2/true//string") + } + + #[test] + fn special_symbols_in_literal() { + let literal = "/?brackets=[]&comma=,&parens=()&semi=;&colon=:&at=@&dollar=$&excl=!&plus=+&astr=*"='"; + let template = StringTemplate::from_str(literal).expect("Failed to parse URL template"); + let (url, _) = template + .interpolate_uri(&Default::default()) + .expect("Failed to generate URL"); + + assert_eq!(url.to_string(), literal); + } + + /// If a user writes a string template that includes _illegal_ characters which must be encoded, + /// we still encode them to avoid runtime errors. + #[test] + fn auto_encode_illegal_literal_characters() { + let template = StringTemplate::from_str("https://example.com/😈 \\") + .expect("Failed to parse URL template"); + + let (url, _) = template + .interpolate_uri(&Default::default()) + .expect("Failed to generate URL"); + assert_eq!(url.to_string(), "https://example.com/%F0%9F%98%88%20%5C") + } + + /// Because we don't encode a bunch of characters that are situationally disallowed + /// (for flexibility of the connector author), we also need to allow that they can manually + /// percent encode characters themselves as-needed. + #[test] + fn allow_manual_percent_encoding() { + let template = StringTemplate::from_str("https://example.com/%20") + .expect("Failed to parse URL template"); + + let (url, _) = template + .interpolate_uri(&Default::default()) + .expect("Failed to generate URL"); + assert_eq!(url.to_string(), "https://example.com/%20") + } + + /// Multi-line GraphQL strings are super useful for long templates. We need to make sure they're + /// properly handled when generating URIs, though. New lines should be ignored. + #[test] + fn multi_line_templates() { + let template = StringTemplate::from_str( + "https://example.com\n/broken\npath\n/path\n?param=value\n¶m=\r\nvalue&\nparam\n=\nvalue", + ) + .expect("Failed to parse URL template"); + let (url, _) = template + .interpolate_uri(&Default::default()) + .expect("Failed to generate URL"); + + assert_eq!( + url.to_string(), + "https://example.com/brokenpath/path?param=value¶m=value¶m=value" + ) + } +} + +#[cfg(test)] +mod test_get_expressions { + use super::*; + + #[test] + fn test_variable_references() { + let value = StringTemplate::parse_with_spec( + "a {$this.a.b.c} b {$args.a.b.c} c {$config.a.b.c}", + ConnectSpec::latest(), + ) + .unwrap(); + let references: Vec<_> = value + .expressions() + .map(|e| e.expression.to_string()) + .collect(); + assert_eq!( + references, + vec!["$this.a.b.c", "$args.a.b.c", "$config.a.b.c"] + ); + } +} diff --git a/apollo-federation/src/connectors/tests/schemas/duplicated_id.graphql b/apollo-federation/src/connectors/tests/schemas/duplicated_id.graphql new file mode 100644 index 0000000000..2160f89bf9 --- /dev/null +++ b/apollo-federation/src/connectors/tests/schemas/duplicated_id.graphql @@ -0,0 +1,126 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @join__directive( + graphs: [CONNECTORS] + name: "link" + args: { + url: "https://specs.apollo.dev/connect/v0.3" + import: ["@connect", "@source"] + } + ) + @join__directive( + graphs: [CONNECTORS] + name: "source" + args: { + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + } + ) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar join__DirectiveArguments + +scalar join__FieldSet + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://unused") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Post @join__type(graph: CONNECTORS) { + id: ID! + title: String + body: String +} + +type Query @join__type(graph: CONNECTORS) { + users: [User] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { id: "duplicated_id", source: "json", http: { GET: "/users" }, selection: "id name" } + ) + posts: [Post] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + id: "duplicated_id", + source: "json" + http: { GET: "/posts" } + selection: "id title body" + } + ) +} + +type User @join__type(graph: CONNECTORS, key: "id", resolvable: false) { + id: ID! + name: String +} diff --git a/apollo-federation/src/connectors/tests/schemas/duplicated_id.yaml b/apollo-federation/src/connectors/tests/schemas/duplicated_id.yaml new file mode 100644 index 0000000000..341c8f6002 --- /dev/null +++ b/apollo-federation/src/connectors/tests/schemas/duplicated_id.yaml @@ -0,0 +1,47 @@ +subgraphs: + connectors: + routing_url: http://unused + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source( + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + ) + + type Query { + users: [User] + @connect( + id: "duplicated_id" + source: "json" + http: { GET: "/users" } + selection: "id name" + ) + + posts: [Post] + @connect( + id: "duplicated_id" + source: "json" + http: { GET: "/posts" } + selection: "id title body" + ) + } + + type User @key(fields: "id", resolvable: false) { + id: ID! + name: String + } + + type Post { + id: ID! + title: String + body: String + } \ No newline at end of file diff --git a/apollo-federation/src/connectors/tests/schemas/is-success-source.graphql b/apollo-federation/src/connectors/tests/schemas/is-success-source.graphql new file mode 100644 index 0000000000..f48a7ccda1 --- /dev/null +++ b/apollo-federation/src/connectors/tests/schemas/is-success-source.graphql @@ -0,0 +1,126 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) + @join__directive( + graphs: [CONNECTORS] + name: "link" + args: { + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + } + ) + @join__directive( + graphs: [CONNECTORS] + name: "source" + args: { + name: "json" + isSuccess: "$status->eq(202)" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + } + ) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar join__DirectiveArguments + +scalar join__FieldSet + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://unused") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Post @join__type(graph: CONNECTORS) { + id: ID! + title: String + body: String +} + +type Query @join__type(graph: CONNECTORS) { + users: [User] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { source: "json", http: { GET: "/users" }, selection: "id name" } + ) + posts: [Post] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "json" + http: { GET: "/posts" } + selection: "id title body" + } + ) +} + +type User @join__type(graph: CONNECTORS, key: "id", resolvable: false) { + id: ID! + name: String +} \ No newline at end of file diff --git a/apollo-federation/src/connectors/tests/schemas/is-success-source.yaml b/apollo-federation/src/connectors/tests/schemas/is-success-source.yaml new file mode 100644 index 0000000000..9bb9a3c5c0 --- /dev/null +++ b/apollo-federation/src/connectors/tests/schemas/is-success-source.yaml @@ -0,0 +1,48 @@ +# rover supergraph compose --config src/connectors/tests/schemas/simple.yaml > src/connectors/tests/schemas/simple.graphql +federation_version: =2.7.3-testing.0 +subgraphs: + connectors: + routing_url: http://unused + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source( + name: "json" + isSuccess: "$status->eq(202)" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + ) + + type Query { + users: [User] + @connect( + source: "json" + http: { GET: "/users" } + selection: "id name" + ) + + posts: [Post] + @connect( + source: "json" + http: { GET: "/posts" } + selection: "id title body" + ) + } + + type User @key(fields: "id", resolvable: false) { + id: ID! + name: String + } + + type Post { + id: ID! + title: String + body: String + } \ No newline at end of file diff --git a/apollo-federation/src/connectors/tests/schemas/is-success.graphql b/apollo-federation/src/connectors/tests/schemas/is-success.graphql new file mode 100644 index 0000000000..4fe9b420c5 --- /dev/null +++ b/apollo-federation/src/connectors/tests/schemas/is-success.graphql @@ -0,0 +1,126 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) + @join__directive( + graphs: [CONNECTORS] + name: "link" + args: { + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + } + ) + @join__directive( + graphs: [CONNECTORS] + name: "source" + args: { + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + } + ) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar join__DirectiveArguments + +scalar join__FieldSet + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://unused") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Post @join__type(graph: CONNECTORS) { + id: ID! + title: String + body: String +} + +type Query @join__type(graph: CONNECTORS) { + users: [User] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { source: "json", http: { GET: "/users" }, selection: "id name", isSuccess:"id->eq('yay')" } + ) + posts: [Post] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "json" + http: { GET: "/posts" } + selection: "id title body" + isSuccess: "id->eq('cool')" + } + ) +} + +type User @join__type(graph: CONNECTORS, key: "id", resolvable: false) { + id: ID! + name: String +} \ No newline at end of file diff --git a/apollo-federation/src/connectors/tests/schemas/is-success.yaml b/apollo-federation/src/connectors/tests/schemas/is-success.yaml new file mode 100644 index 0000000000..5de086707b --- /dev/null +++ b/apollo-federation/src/connectors/tests/schemas/is-success.yaml @@ -0,0 +1,49 @@ +# rover supergraph compose --config src/connectors/tests/schemas/simple.yaml > src/connectors/tests/schemas/simple.graphql +federation_version: =2.7.3-testing.0 +subgraphs: + connectors: + routing_url: http://unused + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source( + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + ) + + type Query { + users: [User] + @connect( + source: "json" + http: { GET: "/users" } + selection: "id name" + isSuccess: "id->eq('yay')" + ) + + posts: [Post] + @connect( + source: "json" + http: { GET: "/posts" } + selection: "id title body" + isSuccess: "id->eq('cool')" + ) + } + + type User @key(fields: "id", resolvable: false) { + id: ID! + name: String + } + + type Post { + id: ID! + title: String + body: String + } \ No newline at end of file diff --git a/apollo-federation/src/connectors/tests/schemas/simple.graphql b/apollo-federation/src/connectors/tests/schemas/simple.graphql new file mode 100644 index 0000000000..c17ccb5b15 --- /dev/null +++ b/apollo-federation/src/connectors/tests/schemas/simple.graphql @@ -0,0 +1,125 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) + @join__directive( + graphs: [CONNECTORS] + name: "link" + args: { + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + } + ) + @join__directive( + graphs: [CONNECTORS] + name: "source" + args: { + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + } + ) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar join__DirectiveArguments + +scalar join__FieldSet + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://unused") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Post @join__type(graph: CONNECTORS) { + id: ID! + title: String + body: String +} + +type Query @join__type(graph: CONNECTORS) { + users: [User] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { source: "json", http: { GET: "/users" }, selection: "id name" } + ) + posts: [Post] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "json" + http: { GET: "/posts" } + selection: "id title body" + } + ) +} + +type User @join__type(graph: CONNECTORS, key: "id", resolvable: false) { + id: ID! + name: String +} diff --git a/apollo-federation/src/connectors/tests/schemas/simple.yaml b/apollo-federation/src/connectors/tests/schemas/simple.yaml new file mode 100644 index 0000000000..3e70a3d54d --- /dev/null +++ b/apollo-federation/src/connectors/tests/schemas/simple.yaml @@ -0,0 +1,47 @@ +# rover supergraph compose --config src/connectors/tests/schemas/simple.yaml > src/connectors/tests/schemas/simple.graphql +federation_version: =2.7.3-testing.0 +subgraphs: + connectors: + routing_url: http://unused + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source( + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + ) + + type Query { + users: [User] + @connect( + source: "json" + http: { GET: "/users" } + selection: "id name" + ) + + posts: [Post] + @connect( + source: "json" + http: { GET: "/posts" } + selection: "id title body" + ) + } + + type User @key(fields: "id", resolvable: false) { + id: ID! + name: String + } + + type Post { + id: ID! + title: String + body: String + } \ No newline at end of file diff --git a/apollo-federation/src/connectors/tests/schemas/simple_v0_2.graphql b/apollo-federation/src/connectors/tests/schemas/simple_v0_2.graphql new file mode 100644 index 0000000000..0d976e8237 --- /dev/null +++ b/apollo-federation/src/connectors/tests/schemas/simple_v0_2.graphql @@ -0,0 +1,82 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS2], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS, CONNECTORS2], name: "source", args: {name: "json", http: {baseURL: "https://jsonplaceholder.typicode.com/", headers: [{name: "AuthToken", from: "X-Auth-Token"}, {name: "user-agent", value: "Firefox"}]}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://unused") + CONNECTORS2 @join__graph(name: "connectors2", url: "http://unused") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Post + @join__type(graph: CONNECTORS) + @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {POST: "/posts", body: "ids: $batch.id"}, selection: "id title body"}) +{ + id: ID! + title: String + body: String +} + +type Query + @join__type(graph: CONNECTORS) + @join__type(graph: CONNECTORS2) +{ + posts: [Post] @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/posts"}, selection: "id title body"}) + users: [User] @join__field(graph: CONNECTORS2) @join__directive(graphs: [CONNECTORS2], name: "connect", args: {source: "json", http: {GET: "/users"}, selection: "id name"}) +} + +type User + @join__type(graph: CONNECTORS2, key: "id", resolvable: false) +{ + id: ID! + name: String +} diff --git a/apollo-federation/src/connectors/tests/schemas/simple_v0_2.yaml b/apollo-federation/src/connectors/tests/schemas/simple_v0_2.yaml new file mode 100644 index 0000000000..eac26550cc --- /dev/null +++ b/apollo-federation/src/connectors/tests/schemas/simple_v0_2.yaml @@ -0,0 +1,65 @@ +# rover supergraph compose --config src/connectors/tests/schemas/simple_v0_2.yaml --federation-version='=2.11.0' > src/connectors/tests/schemas/simple_v0_2.graphql +subgraphs: + connectors: + routing_url: http://unused + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect", "@source"]) + @source( + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + ) + + type Query { + posts: [Post] + @connect( + source: "json" + http: { GET: "/posts" } + selection: "id title body" + ) + } + + type Post @connect(source: "json", http: { POST: "/posts", body: "ids: $$batch.id" }, selection: "id title body") { + id: ID! + title: String + body: String + } + connectors2: + routing_url: http://unused + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source( + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + ) + + type Query { + users: [User] + @connect( + source: "json" + http: { GET: "/users" } + selection: "id name" + ) + } + + type User @key(fields: "id", resolvable: false) { + id: ID! + name: String + } diff --git a/apollo-federation/src/connectors/tests/schemas/source-template.graphql b/apollo-federation/src/connectors/tests/schemas/source-template.graphql new file mode 100644 index 0000000000..c24ca8e22b --- /dev/null +++ b/apollo-federation/src/connectors/tests/schemas/source-template.graphql @@ -0,0 +1,125 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) + @join__directive( + graphs: [CONNECTORS] + name: "link" + args: { + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + } + ) + @join__directive( + graphs: [CONNECTORS] + name: "source" + args: { + name: "json" + http: { + baseURL: "https://${$config.subdomain}.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + } + ) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar join__DirectiveArguments + +scalar join__FieldSet + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://unused") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Post @join__type(graph: CONNECTORS) { + id: ID! + title: String + body: String +} + +type Query @join__type(graph: CONNECTORS) { + users: [User] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { source: "json", http: { GET: "/users" }, selection: "id name" } + ) + posts: [Post] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "json" + http: { GET: "/posts" } + selection: "id title body" + } + ) +} + +type User @join__type(graph: CONNECTORS, key: "id", resolvable: false) { + id: ID! + name: String +} diff --git a/apollo-federation/src/connectors/tests/schemas/source-template.yaml b/apollo-federation/src/connectors/tests/schemas/source-template.yaml new file mode 100644 index 0000000000..0c23b8c0b3 --- /dev/null +++ b/apollo-federation/src/connectors/tests/schemas/source-template.yaml @@ -0,0 +1,51 @@ +# rover supergraph compose --config src/connectors/tests/schemas/simple.yaml > src/connectors/tests/schemas/simple.graphql +federation_version: =2.7.3-testing.0 +subgraphs: + connectors: + sources: + json: + $config: + subdomain: jsonplaceholder + routing_url: http://unused + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source( + name: "json" + http: { + baseURL: "https://${$config.subdomain}.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + ) + + type Query { + users: [User] + @connect( + source: "json" + http: { GET: "/users" } + selection: "id name" + ) + + posts: [Post] + @connect( + source: "json" + http: { GET: "/posts" } + selection: "id title body" + ) + } + + type User @key(fields: "id", resolvable: false) { + id: ID! + name: String + } + + type Post { + id: ID! + title: String + body: String + } \ No newline at end of file diff --git a/apollo-federation/src/connectors/validation/connect.rs b/apollo-federation/src/connectors/validation/connect.rs new file mode 100644 index 0000000000..836105188f --- /dev/null +++ b/apollo-federation/src/connectors/validation/connect.rs @@ -0,0 +1,417 @@ +//! Parsing and validation of `@connect` directives + +use std::collections::HashMap; +use std::fmt; +use std::ops::Range; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Value; +use apollo_compiler::parser::LineColumn; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::ObjectType; +use hashbrown::HashSet; +use itertools::Itertools; +use multi_try::MultiTry; + +use self::entity::validate_entity_arg; +use self::selection::Selection; +use super::Code; +use super::Message; +use super::coordinates::ConnectDirectiveCoordinate; +use super::errors::ErrorsCoordinate; +use super::errors::IsSuccessArgument; +use crate::connectors::Namespace; +use crate::connectors::SourceName; +use crate::connectors::id::ConnectedElement; +use crate::connectors::id::ObjectCategory; +use crate::connectors::spec::connect::CONNECT_ID_ARGUMENT_NAME; +use crate::connectors::spec::connect::CONNECT_SOURCE_ARGUMENT_NAME; +use crate::connectors::spec::source::SOURCE_NAME_ARGUMENT_NAME; +use crate::connectors::validation::connect::http::Http; +use crate::connectors::validation::errors::Errors; +use crate::connectors::validation::graphql::SchemaInfo; + +mod entity; +mod http; +mod selection; + +pub(super) fn fields_seen_by_all_connects( + schema: &SchemaInfo, + all_source_names: &[SourceName], +) -> Result, Vec> { + let mut messages = Vec::new(); + let mut connects = Vec::new(); + + for extended_type in schema.types.values().filter(|ty| !ty.is_built_in()) { + let ExtendedType::Object(node) = extended_type else { + continue; + }; + let (connects_for_type, messages_for_type) = + Connect::find_on_type(node, schema, all_source_names); + connects.extend(connects_for_type); + messages.extend(messages_for_type); + } + + let mut seen_fields = Vec::new(); + let mut valid_id_names: HashMap<_, Vec<_>> = HashMap::new(); + for connect in connects { + if let Some(name) = connect.id.and_then(|value| value.as_str()) { + match Name::new(name) { + Ok(name) => { + valid_id_names.entry(name).or_insert_with(Vec::new).push( + connect + .id + .and_then(|node| node.line_column_range(&schema.sources)), + ); + } + Err(err) => { + let locations = connect + .id + .and_then(|node| node.line_column_range(&schema.sources)) + .map(|loc| vec![loc]) + .unwrap_or_default(); + messages.push(Message { + code: Code::InvalidConnectorIdName, + message: err.to_string(), + locations, + }); + } + } + } + match connect.type_check() { + Ok(seen_fields_for_connect) => { + seen_fields.extend( + seen_fields_for_connect + .into_iter() + .map(|field| (field.object_name, field.field_name)), + ); + } + Err(messages_for_connect) => { + messages.extend(messages_for_connect); + } + } + } + + let non_unique_errors = valid_id_names + .into_iter() + .map(|(name, locations)| { + ( + name, + locations + .into_iter() + .flatten() + .collect::>>(), + ) + }) + .filter(|(_, locations)| locations.len() > 1) + .map(|(name, locations)| Message { + code: Code::DuplicateIdName, + message: format!( + "`@connector` directive must have unique `id`. `{name}` has {} repetitions", + locations.len() + ), + locations, + }); + messages.extend(non_unique_errors); + + if messages.is_empty() { + Ok(seen_fields) + } else { + Err(messages) + } +} + +/// A parsed `@connect` directive +struct Connect<'schema> { + selection: Selection<'schema>, + http: Http<'schema>, + errors: Errors<'schema>, + is_success: Option>, + coordinate: ConnectDirectiveCoordinate<'schema>, + schema: &'schema SchemaInfo<'schema>, + id: Option<&'schema Node>, +} + +impl<'schema> Connect<'schema> { + /// Find and parse any `@connect` directives on this type or its fields. + fn find_on_type( + object: &'schema Node, + schema: &'schema SchemaInfo, + source_names: &'schema [SourceName], + ) -> (Vec, Vec) { + let object_category = if schema + .schema_definition + .query + .as_ref() + .is_some_and(|query| query.name == object.name) + { + ObjectCategory::Query + } else if schema + .schema_definition + .mutation + .as_ref() + .is_some_and(|mutation| mutation.name == object.name) + { + ObjectCategory::Mutation + } else { + ObjectCategory::Other + }; + + let directives_on_type = object + .directives + .iter() + .filter(|directive| directive.name == *schema.connect_directive_name()) + .map(|directive| ConnectDirectiveCoordinate { + directive, + element: ConnectedElement::Type { type_def: object }, + }); + + let directives_on_fields = object.fields.values().flat_map(|field| { + field + .directives + .iter() + .filter(|directive| directive.name == *schema.connect_directive_name()) + .map(|directive| ConnectDirectiveCoordinate { + directive, + element: ConnectedElement::Field { + parent_type: object, + parent_category: object_category, + field_def: field, + }, + }) + }); + + let (connects, messages): (Vec, Vec>) = directives_on_type + .chain(directives_on_fields) + .map(|coordinate| Self::parse(coordinate, schema, source_names)) + .partition_result(); + + let messages: Vec = messages.into_iter().flatten().collect(); + + (connects, messages) + } + + /// Parse the `@connect` directive and run just enough checks to be able to use it at runtime. + /// More advanced checks are done in [`Self::type_check`]. + /// + /// Three sub-pieces are parsed: + /// 1. `@connect(http:)` with [`Http::parse`] + /// 2. `@connect(source:)` with [`validate_source_name`] + /// 3. `@connect(selection:)` with [`Selection::parse`] + /// + /// `selection` and `source` are _always_ checked and their errors are returned. + /// The order these two run in doesn't matter. + /// `http` can't be validated without knowing whether a `source` was set, so it's only checked if `source` is valid. + fn parse( + coordinate: ConnectDirectiveCoordinate<'schema>, + schema: &'schema SchemaInfo, + source_names: &[SourceName], + ) -> Result> { + if coordinate.element.is_root_type(schema) { + return Err(vec![Message { + code: Code::ConnectOnRoot, + message: format!( + "Cannot use `@{connect_directive_name}` on root types like `{object_name}`", + object_name = coordinate.element.base_type_name(), + connect_directive_name = schema.connect_directive_name(), + ), + locations: coordinate + .directive + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }]); + } + + let (selection, http, errors, is_success) = Selection::parse(coordinate, schema) + .map_err(|err| vec![err]) + .and_try( + validate_source_name(coordinate, source_names, schema) + .map_err(|err| vec![err]) + .and_then(|source_name| Http::parse(coordinate, source_name.as_ref(), schema)), + ) + .and_try(Errors::parse( + ErrorsCoordinate::Connect { + connect: coordinate, + }, + schema, + )) + .and_try( + IsSuccessArgument::parse_for_connector(coordinate, schema).map_err(|err| vec![err]), + ) + .map_err(|nested| nested.into_iter().flatten().collect_vec())?; + + let id = coordinate + .directive + .argument_by_name(CONNECT_ID_ARGUMENT_NAME.as_str(), schema) + // ID Argument is optional + .map(Some) + .unwrap_or_default(); + + Ok(Self { + selection, + http, + errors, + is_success, + coordinate, + schema, + id, + }) + } + + fn type_check(self) -> Result, Vec> { + let mut messages = Vec::new(); + + let all_variables = self + .selection + .variables() + .chain(self.http.variables()) + .chain(self.errors.variables()) + .collect::>(); + if all_variables.contains(&Namespace::Batch) && all_variables.contains(&Namespace::This) { + messages.push(Message { + code: Code::ConnectBatchAndThis, + message: format!( + "In {}: connectors cannot use both $this and $batch", + self.coordinate + ), + locations: self + .coordinate + .directive + .line_column_range(&self.schema.sources) + .into_iter() + .collect(), + }); + } + + messages.extend(validate_entity_arg(self.coordinate, self.schema).err()); + messages.extend( + self.http + .type_check(self.schema) + .err() + .into_iter() + .flatten(), + ); + messages.extend( + self.errors + .type_check(self.schema) + .err() + .into_iter() + .flatten(), + ); + + if let Some(is_success_argument) = self.is_success { + messages.extend(is_success_argument.type_check(self.schema).err()); + } + + let mut seen: Vec = match self.selection.type_check(self.schema) { + // TODO: use ResolvedField struct at all levels + Ok(seen) => seen + .into_iter() + .map(|(object_name, field_name)| ResolvedField { + object_name, + field_name, + }) + .collect(), + Err(message) => { + messages.push(message); + return Err(messages); + } + }; + + if let ConnectedElement::Field { + parent_type, + field_def, + .. + } = self.coordinate.element + { + // mark the field with a @connect directive as seen + seen.push(ResolvedField { + object_name: parent_type.name.clone(), + field_name: field_def.name.clone(), + }); + // direct recursion isn't allowed, like a connector on User.friends: [User] + if &parent_type.name == field_def.ty.inner_named_type() { + messages.push(Message { + code: Code::CircularReference, + message: format!( + "Direct circular reference detected in `{}.{}: {}`. For more information, see https://go.apollo.dev/connectors/limitations#circular-references", + parent_type.name, + field_def.name, + field_def.ty + ), + locations: field_def.line_column_range(&self.schema.sources).into_iter().collect(), + }); + } + } + + if messages.is_empty() { + Ok(seen) + } else { + Err(messages) + } + } +} + +/// A field that is resolved by a connect directive +pub(super) struct ResolvedField { + pub object_name: Name, + pub field_name: Name, +} + +fn validate_source_name( + coordinate: ConnectDirectiveCoordinate, + source_names: &[SourceName], + schema: &SchemaInfo, +) -> Result, Message> { + let Some(source_name) = SourceName::from_connect(coordinate.directive) else { + return Ok(None); + }; + + if source_names.contains(&source_name) { + return Ok(Some(source_name)); + } + // A source name was set but doesn't match a defined source + // TODO: Pick a suggestion that's not just the first defined source + let qualified_directive = ConnectSourceCoordinate { + connect: coordinate, + source: source_name.as_str(), + }; + if let Some(first_source_name) = source_names.first() { + Err(Message { + code: Code::SourceNameMismatch, + message: format!( + "{qualified_directive} does not match any defined sources. Did you mean \"{first_source_name}\"?", + first_source_name = first_source_name.as_str(), + ), + locations: source_name.locations(&schema.sources), + }) + } else { + Err(Message { + code: Code::NoSourcesDefined, + message: format!( + "{qualified_directive} specifies a source, but none are defined. Try adding `@{source_directive_name}({SOURCE_NAME_ARGUMENT_NAME}: \"{value}\")` to the schema.", + source_directive_name = schema.source_directive_name(), + value = source_name, + ), + locations: source_name.locations(&schema.sources), + }) + } +} + +struct ConnectSourceCoordinate<'schema> { + source: &'schema str, + connect: ConnectDirectiveCoordinate<'schema>, +} +impl fmt::Display for ConnectSourceCoordinate<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "`@{connect_directive_name}({CONNECT_SOURCE_ARGUMENT_NAME}: \"{source}\")` on `{element}`", + connect_directive_name = self.connect.directive.name, + element = self.connect.element, + source = self.source, + ) + } +} diff --git a/apollo-federation/src/connectors/validation/connect/entity.rs b/apollo-federation/src/connectors/validation/connect/entity.rs new file mode 100644 index 0000000000..78875ef12b --- /dev/null +++ b/apollo-federation/src/connectors/validation/connect/entity.rs @@ -0,0 +1,351 @@ +//! Validations for `@connect` on types/the `@connect(entity:)` argument. + +use std::fmt; +use std::fmt::Display; + +use apollo_compiler::Node; +use apollo_compiler::ast::Argument; +use apollo_compiler::ast::FieldDefinition; +use apollo_compiler::ast::InputValueDefinition; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::InputObjectType; +use apollo_compiler::schema::ObjectType; + +use super::Code; +use super::Message; +use super::ObjectCategory; +use crate::connectors::expand::visitors::FieldVisitor; +use crate::connectors::expand::visitors::GroupVisitor; +use crate::connectors::id::ConnectedElement; +use crate::connectors::spec::connect::CONNECT_ENTITY_ARGUMENT_NAME; +use crate::connectors::validation::coordinates::ConnectDirectiveCoordinate; +use crate::connectors::validation::graphql::SchemaInfo; + +/// Applies additional validations to `@connect` if `entity` is `true`. +pub(super) fn validate_entity_arg( + connect: ConnectDirectiveCoordinate, + schema: &SchemaInfo, +) -> Result<(), Message> { + let Some(entity_arg) = connect + .directive + .arguments + .iter() + .find(|arg| arg.name == CONNECT_ENTITY_ARGUMENT_NAME) + else { + return Ok(()); + }; + + let entity_arg_value = &entity_arg.value; + let Some(value) = entity_arg_value.to_bool() else { + return Ok(()); // The default value is always okay + }; + + let coordinate = Coordinate { connect, value }; + + let (field, category) = match (connect.element, value) { + (ConnectedElement::Field { .. }, false) | (ConnectedElement::Type { .. }, true) => { + // Explicit values set to the default are always okay + return Ok(()); + } + (ConnectedElement::Type { .. }, false) => { + // `@connect` on a type is _always_ an entity resolver, so this is an error + return Err(Message { + code: Code::ConnectOnTypeMustBeEntity, + message: format!( + "{coordinate} is invalid. `entity` can't be false for connectors on types." + ), + locations: entity_arg + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }); + } + ( + // For `entity: true` on fields, we have additional checks we now need to run + ConnectedElement::Field { + field_def, + parent_category, + .. + }, + true, + ) => (field_def, parent_category), + }; + + if category != ObjectCategory::Query { + return Err(Message { + code: Code::EntityNotOnRootQuery, + message: format!( + "{coordinate} is invalid. Entity resolvers can only be declared on root `Query` fields.", + ), + locations: entity_arg + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }); + } + + let Some(object_type) = schema.get_object(field.ty.inner_named_type()) else { + return Err(Message { + code: Code::EntityTypeInvalid, + message: format!( + "{coordinate} is invalid. Entity connectors must return object types.", + ), + locations: entity_arg + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }); + }; + + if field.ty.is_list() || field.ty.is_non_null() { + return Err(Message { + code: Code::EntityTypeInvalid, + message: format!( + "{coordinate} is invalid. Entity connectors must return non-list, nullable, object types. See https://go.apollo.dev/connectors/entity-rules", + ), + locations: entity_arg + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }); + } + + if field.arguments.is_empty() { + return Err(Message { + code: Code::EntityResolverArgumentMismatch, + message: format!( + "`{coordinate}` must have arguments when using `entity: true`. See https://go.apollo.dev/connectors/entity-rules", + coordinate = coordinate.connect.element, + ), + locations: entity_arg + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }); + } + + ArgumentVisitor { + schema, + entity_arg, + coordinate, + } + .walk(Group::Root { + field, + entity_type: object_type, + }) + .map(|_| ()) +} + +#[derive(Clone, Debug)] +enum Group<'schema> { + /// The entity itself, we're matching argument names & types to these fields + Root { + field: &'schema Node, + entity_type: &'schema Node, + }, + /// A child field of the entity we're matching against an input type. + Child { + input_type: &'schema Node, + entity_type: &'schema ExtendedType, + }, +} + +#[derive(Clone, Debug)] +struct Field<'schema> { + node: &'schema Node, + /// The object which has a field that we're comparing against + object_type: &'schema ObjectType, + /// The field definition of the input that correlates to a field on the entity + input_field: &'schema ExtendedType, + /// The field of the entity that we're comparing against, part of `object_type` + entity_field: &'schema ExtendedType, +} + +/// Visitor for entity resolver arguments. +/// This validates that the arguments match fields on the entity type. +/// +/// Since input types may contain fields with subtypes, and the fields of those subtypes can be +/// part of composite keys, this potentially requires visiting a tree. +struct ArgumentVisitor<'schema> { + schema: &'schema SchemaInfo<'schema>, + entity_arg: &'schema Node, + coordinate: Coordinate<'schema>, +} + +impl<'schema> GroupVisitor, Field<'schema>> for ArgumentVisitor<'schema> { + fn try_get_group_for_field( + &self, + field: &Field<'schema>, + ) -> Result>, Self::Error> { + Ok( + // Each input type within an argument to the entity field is another group to visit + if let ExtendedType::InputObject(input_object_type) = field.input_field { + Some(Group::Child { + input_type: input_object_type, + entity_type: field.entity_field, + }) + } else { + None + }, + ) + } + + fn enter_group(&mut self, group: &Group<'schema>) -> Result>, Self::Error> { + match group { + Group::Root { + field, entity_type, .. + } => self.enter_root_group(field, entity_type), + Group::Child { + input_type, + entity_type, + .. + } => self.enter_child_group(input_type, entity_type), + } + } + + fn exit_group(&mut self) -> Result<(), Self::Error> { + Ok(()) + } +} + +impl<'schema> FieldVisitor> for ArgumentVisitor<'schema> { + type Error = Message; + + fn visit(&mut self, field: Field<'schema>) -> Result<(), Self::Error> { + let ok = match field.input_field { + ExtendedType::InputObject(_) => field.entity_field.is_object(), + ExtendedType::Scalar(_) | ExtendedType::Enum(_) => { + field.input_field == field.entity_field + } + _ => true, + }; + if ok { + Ok(()) + } else { + Err(Message { + code: Code::EntityResolverArgumentMismatch, + message: format!( + "`{coordinate}({field_name}:)` is of type `{input_type}`, but must match `{object}.{field_name}` of type `{entity_type}` because `entity` is `true`.", + coordinate = self.coordinate.connect.element, + field_name = field.node.name.as_str(), + object = field.object_type.name, + input_type = field.input_field.name(), + entity_type = field.entity_field.name(), + ), + locations: field + .node + .line_column_range(&self.schema.sources) + .into_iter() + .chain(self.entity_arg.line_column_range(&self.schema.sources)) + .collect(), + }) + } + } +} + +impl<'schema> ArgumentVisitor<'schema> { + fn enter_root_group( + &mut self, + field: &'schema Node, + entity_type: &'schema Node, + ) -> Result>, >>::Error> { + // At the root level, visit each argument to the entity field + field.arguments.iter().filter_map(|arg| { + if let Some(input_type) = self.schema.types.get(arg.ty.inner_named_type()) { + // Check that the argument has a corresponding field on the entity type + if let Some(entity_field) = entity_type.fields.get(&*arg.name) + .and_then(|entity_field| self.schema.types.get(entity_field.ty.inner_named_type())) { + Some(Ok(Field { + node: arg, + input_field: input_type, + entity_field, + object_type: entity_type, + })) + } else { + Some(Err(Message { + code: Code::EntityResolverArgumentMismatch, + message: format!( + "`{coordinate}` has invalid arguments. Argument `{arg_name}` does not have a matching field `{arg_name}` on type `{entity_type}`.", + coordinate = self.coordinate.connect.element, + arg_name = &*arg.name, + entity_type = entity_type.name, + ), + locations: arg + .line_column_range(&self.schema.sources) + .into_iter() + .chain(self.entity_arg.line_column_range(&self.schema.sources)) + .collect(), + })) + } + } else { + // The input type is missing - this will be reported elsewhere, so just ignore + None + } + }).collect() + } + + fn enter_child_group( + &mut self, + child_input_type: &'schema Node, + entity_type: &'schema ExtendedType, + ) -> Result>, >>::Error> { + // At the child level, visit each field on the input type + let ExtendedType::Object(entity_object_type) = entity_type else { + // Entity type was not an object type - this will be reported by field visitor + return Ok(Vec::new()); + }; + child_input_type.fields.iter().filter_map(|(name, input_field)| { + if let Some(entity_field) = entity_object_type.fields.get(name) { + let entity_field_type = entity_field.ty.inner_named_type(); + let input_type = self.schema.types.get(input_field.ty.inner_named_type())?; + + self.schema.types.get(entity_field_type).map(|entity_type| Ok(Field { + node: input_field, + object_type: entity_object_type, + input_field: input_type, + entity_field: entity_type, + })) + } else { + // The input type field does not have a corresponding field on the entity type + Some(Err(Message { + code: Code::EntityResolverArgumentMismatch, + message: format!( + "`{coordinate}` has invalid arguments. Field `{name}` on `{input_type}` does not have a matching field `{name}` on `{entity_type}`.", + coordinate = self.coordinate.connect.element, + input_type = child_input_type.name, + entity_type = entity_object_type.name, + ), + locations: input_field + .line_column_range(&self.schema.sources) + .into_iter() + .chain(self.entity_arg.line_column_range(&self.schema.sources)) + .collect(), + })) + } + }).collect() + } +} + +/// Contains info about a `@connect(entity:)` argument location so it can be displayed in error +/// messages. +#[derive(Clone, Copy)] +struct Coordinate<'schema> { + connect: ConnectDirectiveCoordinate<'schema>, + value: bool, +} + +impl Display for Coordinate<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let Self { + connect: ConnectDirectiveCoordinate { directive, element }, + value, + } = self; + write!( + f, + "`@{connect_directive_name}({CONNECT_ENTITY_ARGUMENT_NAME}: {value})` on `{element}`", + connect_directive_name = directive.name, + value = value, + element = element, + ) + } +} diff --git a/apollo-federation/src/connectors/validation/connect/http.rs b/apollo-federation/src/connectors/validation/connect/http.rs new file mode 100644 index 0000000000..fd0bb2e2c5 --- /dev/null +++ b/apollo-federation/src/connectors/validation/connect/http.rs @@ -0,0 +1,434 @@ +//! Parsing and validation for `@connect(http:)` + +use std::fmt::Display; +use std::str::FromStr; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Value; +use multi_try::MultiTry; +use shape::Shape; + +use crate::connectors::HTTPMethod; +use crate::connectors::Namespace; +use crate::connectors::SourceName; +use crate::connectors::spec::connect::CONNECT_BODY_ARGUMENT_NAME; +use crate::connectors::spec::connect::CONNECT_SOURCE_ARGUMENT_NAME; +use crate::connectors::spec::http::HTTP_ARGUMENT_NAME; +use crate::connectors::string_template; +use crate::connectors::string_template::Part; +use crate::connectors::string_template::StringTemplate; +use crate::connectors::validation::Code; +use crate::connectors::validation::Message; +use crate::connectors::validation::coordinates::ConnectDirectiveCoordinate; +use crate::connectors::validation::coordinates::ConnectHTTPCoordinate; +use crate::connectors::validation::coordinates::HttpHeadersCoordinate; +use crate::connectors::validation::coordinates::HttpMethodCoordinate; +use crate::connectors::validation::expression; +use crate::connectors::validation::expression::Context; +use crate::connectors::validation::expression::MappingArgument; +use crate::connectors::validation::expression::parse_mapping_argument; +use crate::connectors::validation::expression::scalars; +use crate::connectors::validation::graphql::SchemaInfo; +use crate::connectors::validation::graphql::subslice_location; +use crate::connectors::validation::http::UrlProperties; +use crate::connectors::validation::http::headers::Headers; +use crate::connectors::validation::http::url::validate_url_scheme; + +/// A valid, parsed (but not type-checked) `@connect(http:)`. +/// +/// TODO: Use this when creating a `HttpJsonTransport` as well. +pub(super) struct Http<'schema> { + transport: Transport<'schema>, + body: Option>, + headers: Headers<'schema>, +} + +impl<'schema> Http<'schema> { + /// Parse the `@connect(http:)` argument and run just enough checks to be able to use the + /// argument at runtime. More advanced checks are done in [`Self::type_check`]. + /// + /// Three sub-pieces are always parsed, and the errors from _all_ of those pieces are returned + /// together in the event of failure: + /// 1. `http.body` with [`Body::parse`] + /// 2. `http.headers` with [`Headers::parse`] + /// 3. `http.` (for example, `http.GET`) with [`Transport::parse`] + /// + /// The order these pieces run in doesn't matter and shouldn't affect the output. + pub(super) fn parse( + coordinate: ConnectDirectiveCoordinate<'schema>, + source_name: Option<&SourceName>, + schema: &'schema SchemaInfo, + ) -> Result> { + let Some((http_arg, http_arg_node)) = coordinate + .directive + .specified_argument_by_name(&HTTP_ARGUMENT_NAME) + .and_then(|arg| Some((arg.as_object()?, arg))) + else { + return Err(vec![Message { + code: Code::GraphQLError, + message: format!("{coordinate} must have a `{HTTP_ARGUMENT_NAME}` argument."), + locations: coordinate + .directive + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }]); + }; + + Body::parse(http_arg, coordinate, schema) + .map_err(|err| vec![err]) + .and_try(Headers::parse( + http_arg, + HttpHeadersCoordinate::Connect { + connect: coordinate, + }, + schema, + )) + .and_try(Transport::parse( + http_arg, + ConnectHTTPCoordinate::from(coordinate), + http_arg_node, + source_name, + schema, + )) + .map_err(|nested| nested.into_iter().flatten().collect()) + .map(|(body, headers, transport)| Self { + body, + headers, + transport, + }) + } + + /// Type-check the `@connect(http:)` directive. + /// + /// Does things like ensuring that every accessed variable actually exists and that expressions + /// used in the URL and headers result in scalars. + /// + /// TODO: Return some type checking results, like extracted keys? + pub(super) fn type_check(self, schema: &SchemaInfo) -> Result<(), Vec> { + let Self { + transport, + body, + headers, + } = self; + + let mut errors = Vec::new(); + if let Some(body) = body { + errors.extend(body.type_check(schema).err()); + } + + errors.extend(headers.type_check(schema).err().into_iter().flatten()); + errors.extend(transport.type_check(schema)); + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } + + pub(super) fn variables(&self) -> impl Iterator + '_ { + self.transport + .url + .expressions() + .flat_map(|e| { + e.expression + .variable_references() + .map(|var_ref| var_ref.namespace.namespace) + }) + .chain(self.body.as_ref().into_iter().flat_map(|b| { + b.mapping + .variable_references() + .map(|var_ref| var_ref.namespace.namespace) + })) + } +} + +struct Body<'schema> { + mapping: MappingArgument, + coordinate: BodyCoordinate<'schema>, +} + +impl<'schema> Body<'schema> { + pub(super) fn parse( + http_arg: &'schema [(Name, Node)], + connect: ConnectDirectiveCoordinate<'schema>, + schema: &'schema SchemaInfo, + ) -> Result, Message> { + let Some((_, value)) = http_arg + .iter() + .find(|(name, _)| name == &CONNECT_BODY_ARGUMENT_NAME) + else { + return Ok(None); + }; + let coordinate = BodyCoordinate { connect }; + + let mapping = parse_mapping_argument(value, coordinate, Code::InvalidBody, schema)?; + + Ok(Some(Self { + mapping, + coordinate, + })) + } + + /// Check that the selection of the body matches the inputs at this location. + /// + /// TODO: check keys here? + pub(super) fn type_check(self, schema: &SchemaInfo) -> Result<(), Message> { + let Self { + mapping, + coordinate, + } = self; + expression::validate( + &mapping.expression, + &Context::for_connect_request( + schema, + coordinate.connect, + &mapping.node, + Code::InvalidBody, + ), + &Shape::unknown([]), + ) + .map_err(|mut message| { + message.message = format!("In {coordinate}: {message}", message = message.message); + message + }) + } +} + +#[derive(Clone, Copy)] +struct BodyCoordinate<'schema> { + connect: ConnectDirectiveCoordinate<'schema>, +} + +impl Display for BodyCoordinate<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "`@{connect_directive_name}({HTTP_ARGUMENT_NAME}: {{{CONNECT_BODY_ARGUMENT_NAME}:}})` on `{element}`", + connect_directive_name = self.connect.directive.name, + element = self.connect.element + ) + } +} + +/// The `@connect(http.:)` arg +struct Transport<'schema> { + // TODO: once this is shared with `HttpJsonTransport`, this will be used + #[allow(dead_code)] + method: HTTPMethod, + url: StringTemplate, + coordinate: HttpMethodCoordinate<'schema>, + + url_properties: UrlProperties<'schema>, +} + +impl<'schema> Transport<'schema> { + fn parse( + http_arg: &'schema [(Name, Node)], + coordinate: ConnectHTTPCoordinate<'schema>, + http_arg_node: &Node, + source_name: Option<&SourceName>, + schema: &'schema SchemaInfo<'schema>, + ) -> Result> { + let source_map = &schema.sources; + let mut methods = http_arg + .iter() + .filter_map(|(method, value)| { + HTTPMethod::from_str(method) + .ok() + .map(|method| (method, value)) + }) + .peekable(); + + let Some((method, method_value)) = methods.next() else { + return Err(vec![Message { + code: Code::MissingHttpMethod, + message: format!("{coordinate} must specify an HTTP method."), + locations: http_arg_node + .line_column_range(source_map) + .into_iter() + .collect(), + }]); + }; + + if methods.peek().is_some() { + let locations = method_value + .line_column_range(source_map) + .into_iter() + .chain(methods.filter_map(|(_, node)| node.line_column_range(source_map))) + .collect(); + return Err(vec![Message { + code: Code::MultipleHttpMethods, + message: format!("{coordinate} cannot specify more than one HTTP method."), + locations, + }]); + } + + let url_properties = UrlProperties::parse_for_connector( + coordinate.connect_directive_coordinate, + schema, + http_arg, + )?; + + let coordinate = HttpMethodCoordinate { + connect: coordinate.connect_directive_coordinate, + method, + node: method_value, + }; + + let url_string = coordinate.node.as_str().ok_or_else(|| { + vec![Message { + code: Code::GraphQLError, + message: format!("The value for {coordinate} must be a string."), + locations: coordinate + .node + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }] + })?; + let url = StringTemplate::parse_with_spec(url_string, schema.connect_link.spec) + .map_err(|string_template::Error { message, location }| Message { + code: Code::InvalidUrl, + message: format!("In {coordinate}: {message}"), + locations: subslice_location(coordinate.node, location, schema) + .into_iter() + .collect(), + }) + .map_err(|e| vec![e])?; + + if source_name.is_some() { + return if url_string.starts_with("http://") || url_string.starts_with("https://") { + Err(vec![Message { + code: Code::AbsoluteConnectUrlWithSource, + message: format!( + "{coordinate} contains the absolute URL {raw_value} while also specifying a `{CONNECT_SOURCE_ARGUMENT_NAME}`. Either remove the `{CONNECT_SOURCE_ARGUMENT_NAME}` argument or change the URL to be relative.", + raw_value = coordinate.node + ), + locations: coordinate + .node + .line_column_range(source_map) + .into_iter() + .collect(), + }]) + } else { + Ok(Self { + method, + url, + coordinate, + url_properties, + }) + }; + } else { + validate_absolute_connect_url(&url, coordinate, coordinate.node, schema) + .map_err(|e| vec![e])?; + } + Ok(Self { + method, + url, + coordinate, + url_properties, + }) + } + + /// Type-check the `@connect(http::)` directive. + /// + /// TODO: Return input shapes for keys instead reparsing for `Connector::resolvable_key` later + fn type_check(self, schema: &SchemaInfo) -> Vec { + let expression_context = Context::for_connect_request( + schema, + self.coordinate.connect, + self.coordinate.node, + Code::InvalidUrl, + ); + + let mut messages = Vec::new(); + for expression in self.url.expressions() { + messages.extend( + expression::validate(expression, &expression_context, &scalars()) + .err() + .into_iter() + .map(|mut err| { + err.message = format!( + "In {coordinate}: {}", + err.message, + coordinate = self.coordinate + ); + err + }), + ); + } + + messages.extend(self.url_properties.type_check(schema)); + + messages + } +} + +/// Additional validation rules when using `@connect` without `source:` +fn validate_absolute_connect_url( + url: &StringTemplate, + coordinate: HttpMethodCoordinate, + value: &Node, + schema: &SchemaInfo, +) -> Result<(), Message> { + let mut is_relative = true; + let mut dynamic_in_domain = None; + + // Check each part of the string template that *should* result in a static, valid base URI (scheme+host+port). + // - if we don't encounter a scheme, it's not a valid absolute URL + // - once we encounter a character that ends the authority (starts the path, query, or fragment) we break + // - if we encounter a dynamic part before we break, we have an illegal dynamic component + for part in &url.parts { + match part { + Part::Constant(constant) => { + let value = match constant.value.split_once("://") { + Some((_scheme, rest)) => { + is_relative = false; + rest + } + None => &constant.value, + }; + if value.contains('/') || value.contains('?') || value.contains('#') { + break; + } + } + Part::Expression(dynamic) => { + dynamic_in_domain = Some(dynamic); + } + } + } + + if is_relative { + return Err(Message { + code: Code::RelativeConnectUrlWithoutSource, + message: format!( + "{coordinate} specifies the relative URL {raw_value}, but no `{CONNECT_SOURCE_ARGUMENT_NAME}` is defined. Either use an absolute URL including scheme (e.g. https://), or add a `@{source_directive_name}`.", + raw_value = coordinate.node, + source_directive_name = schema.source_directive_name(), + ), + locations: coordinate + .node + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }); + } + if let Some(dynamic) = dynamic_in_domain { + return Err(Message { + code: Code::InvalidUrl, + message: format!( + "{coordinate} must not contain dynamic pieces in the domain section (before the first `/` or `?`).", + ), + locations: subslice_location(value, dynamic.location.clone(), schema) + .into_iter() + .collect(), + }); + } + + validate_url_scheme(url, coordinate, value, schema)?; + + Ok(()) +} diff --git a/apollo-federation/src/connectors/validation/connect/selection.rs b/apollo-federation/src/connectors/validation/connect/selection.rs new file mode 100644 index 0000000000..069b73aec6 --- /dev/null +++ b/apollo-federation/src/connectors/validation/connect/selection.rs @@ -0,0 +1,475 @@ +//! Validate and check semantics of the `@connect(selection:)` argument + +use std::fmt::Display; +use std::iter::once; +use std::ops::Range; + +use apollo_compiler::Node; +use apollo_compiler::ast::FieldDefinition; +use apollo_compiler::ast::Value; +use apollo_compiler::parser::LineColumn; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::ObjectType; +use itertools::Itertools; + +use self::variables::VariableResolver; +use super::Code; +use super::Message; +use super::Name; +use crate::connectors::JSONSelection; +use crate::connectors::Namespace; +use crate::connectors::PathSelection; +use crate::connectors::SubSelection; +use crate::connectors::expand::visitors::FieldVisitor; +use crate::connectors::expand::visitors::GroupVisitor; +use crate::connectors::id::ConnectedElement; +use crate::connectors::json_selection::ExternalVarPaths; +use crate::connectors::json_selection::NamedSelection; +use crate::connectors::json_selection::Ranged; +use crate::connectors::spec::connect::CONNECT_SELECTION_ARGUMENT_NAME; +use crate::connectors::validation::coordinates::ConnectDirectiveCoordinate; +use crate::connectors::validation::coordinates::SelectionCoordinate; +use crate::connectors::validation::expression::MappingArgument; +use crate::connectors::validation::expression::parse_mapping_argument; +use crate::connectors::validation::graphql::SchemaInfo; +use crate::connectors::validation::graphql::subslice_location; +use crate::connectors::variable::Phase; +use crate::connectors::variable::Target; +use crate::connectors::variable::VariableContext; + +mod variables; + +/// The `@connect(selection:)` argument +pub(super) struct Selection<'schema> { + parsed: JSONSelection, + node: Node, + coordinate: SelectionCoordinate<'schema>, +} + +impl<'schema> Selection<'schema> { + pub(super) fn parse( + connect_directive: ConnectDirectiveCoordinate<'schema>, + schema: &'schema SchemaInfo<'schema>, + ) -> Result { + let coordinate = SelectionCoordinate::from(connect_directive); + let selection_arg = connect_directive + .directive + .arguments + .iter() + .find(|arg| arg.name == CONNECT_SELECTION_ARGUMENT_NAME) + .ok_or_else(|| Message { + code: Code::GraphQLError, + message: format!("{coordinate} is required."), + locations: connect_directive + .directive + .line_column_range(&schema.sources) + .into_iter() + .collect(), + })?; + + let MappingArgument { expression, node } = parse_mapping_argument( + &selection_arg.value, + coordinate, + Code::InvalidSelection, + schema, + )?; + + Ok(Self { + parsed: expression.expression, + node, + coordinate, + }) + } + + /// Type check the selection using the visitor pattern, returning a list of seen fields as + /// (ObjectName, FieldName) pairs. + pub(super) fn type_check(self, schema: &SchemaInfo) -> Result, Message> { + let coordinate = self.coordinate.connect; + let context = VariableContext::new(&coordinate.element, Phase::Response, Target::Body); + validate_selection_variables( + &VariableResolver::new(context.clone(), schema), + self.coordinate, + &self.node, + schema, + context, + self.parsed.external_var_paths(), + )?; + + match coordinate.element { + ConnectedElement::Field { + parent_type, + field_def, + .. + } => { + let Some(return_type) = schema.get_object(field_def.ty.inner_named_type()) else { + // TODO: Validate scalar return types + return Ok(Vec::new()); + }; + let Some(sub_selection) = self.parsed.next_subselection() else { + // TODO: Validate scalar selections + return Ok(Vec::new()); + }; + + let group = Group { + selection: sub_selection, + ty: return_type, + definition: Some(field_def), + }; + + SelectionValidator::new( + schema, + PathPart::Root(parent_type), + &self.node, + self.coordinate, + ) + .walk(group) + .map(|validator| validator.seen_fields) + } + ConnectedElement::Type { type_def } => { + let Some(sub_selection) = self.parsed.next_subselection() else { + // TODO: Validate scalar selections + return Ok(Vec::new()); + }; + + let group = Group { + selection: sub_selection, + ty: type_def, + definition: None, + }; + + SelectionValidator::new( + schema, + PathPart::Root(type_def), + &self.node, + self.coordinate, + ) + .walk(group) + .map(|validator| validator.seen_fields) + } + } + } + + pub(super) fn variables(&self) -> impl Iterator + '_ { + self.parsed + .variable_references() + .map(|var_ref| var_ref.namespace.namespace) + } +} + +/// Validate variable references in a JSON Selection +pub(super) fn validate_selection_variables<'a>( + variable_resolver: &VariableResolver, + coordinate: impl Display, + selection_str: &Node, + schema: &SchemaInfo, + context: VariableContext, + variable_paths: impl IntoIterator, +) -> Result<(), Message> { + for path in variable_paths { + if let Some(reference) = path.variable_reference() { + variable_resolver + .resolve(&reference, selection_str) + .map_err(|mut err| { + err.message = format!("In {coordinate}: {message}", message = err.message); + err + })?; + } else if let Some(reference) = path.variable_reference::() { + return Err(Message { + code: context.error_code(), + message: format!( + "In {coordinate}: unknown variable `{namespace}`, must be one of {available}", + namespace = reference.namespace.namespace.as_str(), + available = context.namespaces_joined(), + ), + locations: reference + .namespace + .location + .iter() + .flat_map(|range| subslice_location(selection_str, range.clone(), schema)) + .collect(), + }); + } + } + Ok(()) +} + +struct SelectionValidator<'schema> { + schema: &'schema SchemaInfo<'schema>, + root: PathPart<'schema>, + path: Vec>, + node: &'schema Node, + coordinate: SelectionCoordinate<'schema>, + seen_fields: Vec<(Name, Name)>, +} + +impl<'schema> SelectionValidator<'schema> { + const fn new( + schema: &'schema SchemaInfo<'schema>, + root: PathPart<'schema>, + node: &'schema Node, + coordinate: SelectionCoordinate<'schema>, + ) -> Self { + Self { + schema, + root, + path: Vec::new(), + node, + coordinate, + seen_fields: Vec::new(), + } + } +} + +impl SelectionValidator<'_> { + fn check_for_circular_reference( + &self, + field: Field, + object: &Node, + ) -> Result<(), Message> { + for (depth, seen_part) in self.path_with_root().enumerate() { + let (seen_type, ancestor_field) = match seen_part { + PathPart::Root(root) => (root, None), + PathPart::Field { ty, definition } => (ty, Some(definition)), + }; + + if seen_type == object { + return Err(Message { + code: Code::CircularReference, + message: format!( + "Circular reference detected in {coordinate}: type `{new_object_name}` appears more than once in `{selection_path}`. For more information, see https://go.apollo.dev/connectors/limitations#circular-references", + coordinate = &self.coordinate, + selection_path = self.path_string(field.definition), + new_object_name = object.name, + ), + // TODO: make a helper function for easier range collection + locations: self + .get_range_location(field.inner_range()) + // Skip over fields which duplicate the location of the selection + .chain(if depth > 1 { + ancestor_field + .and_then(|def| def.line_column_range(&self.schema.sources)) + } else { + None + }) + .chain(field.definition.line_column_range(&self.schema.sources)) + .collect(), + }); + } + } + Ok(()) + } + + fn get_selection_location( + &self, + selection: &impl Ranged, + ) -> impl Iterator> { + selection + .range() + .and_then(|range| subslice_location(self.node, range, self.schema)) + .into_iter() + } + + fn get_range_location( + &self, + selection: Option>, + ) -> impl Iterator> { + selection + .as_ref() + .and_then(|range| subslice_location(self.node, range.clone(), self.schema)) + .into_iter() + } + + fn path_with_root(&self) -> impl Iterator> { + once(self.root).chain(self.path.iter().copied()) + } + + fn path_string(&self, tail: &FieldDefinition) -> String { + self.path_with_root() + .map(|part| match part { + PathPart::Root(ty) => ty.name.as_str(), + PathPart::Field { definition, .. } => definition.name.as_str(), + }) + .chain(once(tail.name.as_str())) + .join(".") + } + + fn last_field(&self) -> &PathPart<'_> { + self.path.last().unwrap_or(&self.root) + } +} + +#[derive(Clone, Copy, Debug)] +struct Field<'schema> { + selection: &'schema NamedSelection, + definition: &'schema Node, +} + +impl<'schema> Field<'schema> { + fn next_subselection(&self) -> Option<&'schema SubSelection> { + self.selection.next_subselection() + } + + fn inner_range(&self) -> Option> { + self.selection.range() + } +} + +#[derive(Clone, Copy, Debug)] +enum PathPart<'a> { + // Query, Mutation, Subscription OR an Entity type + Root(&'a Node), + Field { + definition: &'a Node, + ty: &'a Node, + }, +} + +impl PathPart<'_> { + const fn ty(&self) -> &Node { + match self { + PathPart::Root(ty) => ty, + PathPart::Field { ty, .. } => ty, + } + } +} + +#[derive(Clone, Debug)] +struct Group<'schema> { + selection: &'schema SubSelection, + ty: &'schema Node, + definition: Option<&'schema Node>, +} + +// TODO: Once there is location data for JSONSelection, return multiple errors instead of stopping +// at the first +impl<'schema> GroupVisitor, Field<'schema>> for SelectionValidator<'schema> { + /// If the both the selection and the schema agree that this field is an object, then we + /// provide it back to the visitor to be walked. + /// + /// This does no validation, as we have to do that on the field level anyway. + fn try_get_group_for_field( + &self, + field: &Field<'schema>, + ) -> Result>, Self::Error> { + let Some(selection) = field.next_subselection() else { + return Ok(None); + }; + let Some(ty) = self + .schema + .get_object(field.definition.ty.inner_named_type()) + else { + return Ok(None); + }; + Ok(Some(Group { + selection, + ty, + definition: Some(field.definition), + })) + } + + /// Get all the fields for an object type / selection. + /// Returns an error if a selection points at a field which does not exist on the schema. + fn enter_group(&mut self, group: &Group<'schema>) -> Result>, Self::Error> { + // This is `None` at the root of a connector on a type, and we've already added the root path part + if let Some(definition) = group.definition { + self.path.push(PathPart::Field { + definition, + ty: group.ty, + }); + } + + group.selection.selections_iter().flat_map(|selection| { + let mut results = Vec::new(); + for field_name in selection.names() { + if let Some(definition) = group.ty.fields.get(field_name) { + results.push(Ok(Field { + selection, + definition, + })); + } else { + results.push(Err(Message { + code: Code::SelectedFieldNotFound, + message: format!( + "{coordinate} contains field `{field_name}`, which does not exist on `{parent_type}`.", + coordinate = &self.coordinate, + parent_type = group.ty.name, + ), + locations: self.get_selection_location(selection).collect(), + })); + } + } + results + }).collect() + } + + fn exit_group(&mut self) -> Result<(), Self::Error> { + self.path.pop(); + Ok(()) + } +} + +impl<'schema> FieldVisitor> for SelectionValidator<'schema> { + type Error = Message; + + fn visit(&mut self, field: Field<'schema>) -> Result<(), Self::Error> { + let field_name = field.definition.name.as_str(); + let type_name = field.definition.ty.inner_named_type(); + let coordinate = self.coordinate; + let field_type = self.schema.types.get(type_name).ok_or_else(|| Message { + code: Code::GraphQLError, + message: format!( + "{coordinate} contains field `{field_name}`, which has undefined type `{type_name}.", + ), + locations: self.get_range_location(field.inner_range()).collect(), + })?; + let is_group = field.next_subselection().is_some(); + + self.seen_fields.push(( + self.last_field().ty().name.clone(), + field.definition.name.clone(), + )); + + if !field.definition.arguments.is_empty() { + return Err(Message { + code: Code::ConnectorsFieldWithArguments, + message: format!( + "{coordinate} selects field `{parent_type}.{field_name}`, which has arguments. Only fields with a connector can have arguments.", + parent_type = self.last_field().ty().name, + ), + locations: self + .get_range_location(field.inner_range()) + .chain(field.definition.line_column_range(&self.schema.sources)) + .collect(), + }); + } + + match (field_type, is_group) { + (ExtendedType::Object(object), true) => { + self.check_for_circular_reference(field, object) + } + (_, true) => Err(Message { + code: Code::GroupSelectionIsNotObject, + message: format!( + "{coordinate} selects a group `{field_name}{{}}`, but `{parent_type}.{field_name}` is of type `{type_name}` which is not an object.", + parent_type = self.last_field().ty().name, + ), + locations: self + .get_range_location(field.inner_range()) + .chain(field.definition.line_column_range(&self.schema.sources)) + .collect(), + }), + (ExtendedType::Object(_), false) => Err(Message { + code: Code::GroupSelectionRequiredForObject, + message: format!( + "`{parent_type}.{field_name}` is an object, so {coordinate} must select a group `{field_name}{{}}`.", + parent_type = self.last_field().ty().name, + ), + locations: self + .get_range_location(field.inner_range()) + .chain(field.definition.line_column_range(&self.schema.sources)) + .collect(), + }), + (_, false) => Ok(()), + } + } +} diff --git a/apollo-federation/src/connectors/validation/connect/selection/variables.rs b/apollo-federation/src/connectors/validation/connect/selection/variables.rs new file mode 100644 index 0000000000..2ef0af91fa --- /dev/null +++ b/apollo-federation/src/connectors/validation/connect/selection/variables.rs @@ -0,0 +1,265 @@ +//! Variable validation. + +use std::collections::HashMap; + +use apollo_compiler::Node; +use apollo_compiler::Schema; +use apollo_compiler::ast::FieldDefinition; +use apollo_compiler::ast::Type; +use apollo_compiler::ast::Value; +use apollo_compiler::schema::Component; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::ObjectType; +use itertools::Itertools; + +use crate::connectors::id::ConnectedElement; +use crate::connectors::json_selection::SelectionTrie; +use crate::connectors::validation::Code; +use crate::connectors::validation::Message; +use crate::connectors::validation::graphql::SchemaInfo; +use crate::connectors::validation::graphql::subslice_location; +use crate::connectors::variable::Namespace; +use crate::connectors::variable::VariableContext; +use crate::connectors::variable::VariableReference; + +pub(crate) struct VariableResolver<'a> { + context: VariableContext<'a>, + schema: &'a SchemaInfo<'a>, + resolvers: HashMap>, +} + +impl<'a> VariableResolver<'a> { + pub(super) fn new(context: VariableContext<'a>, schema: &'a SchemaInfo<'a>) -> Self { + let mut resolvers = HashMap::>::new(); + + match context.element { + ConnectedElement::Field { + parent_type, + field_def, + .. + } => { + resolvers.insert( + Namespace::This, + Box::new(ThisResolver::new(parent_type, field_def)), + ); + resolvers.insert(Namespace::Args, Box::new(ArgsResolver::new(field_def))); + } + ConnectedElement::Type { .. } => {} // TODO: $batch + } + + Self { + context, + schema, + resolvers, + } + } + + pub(super) fn resolve( + &self, + reference: &VariableReference, + node: &Node, + ) -> Result<(), Message> { + if !self + .context + .available_namespaces() + .contains(&reference.namespace.namespace) + { + return Err(Message { + code: self.context.error_code(), + message: format!( + "variable `{namespace}` is not valid at this location, must be one of {available}", + namespace = reference.namespace.namespace.as_str(), + available = self.context.namespaces_joined(), + ), + locations: reference + .namespace + .location + .iter() + .flat_map(|range| subslice_location(node, range.clone(), self.schema)) + .collect(), + }); + } + if let Some(resolver) = self.resolvers.get(&reference.namespace.namespace) { + resolver.check(reference, node, self.schema)?; + } + Ok(()) + } +} + +/// Checks that the variables are valid within a specific namespace +pub(crate) trait NamespaceResolver { + fn check( + &self, + reference: &VariableReference, + node: &Node, + schema: &SchemaInfo, + ) -> Result<(), Message>; +} + +pub(super) fn resolve_type<'schema>( + schema: &'schema Schema, + ty: &Type, + field: &Component, +) -> Result<&'schema ExtendedType, Message> { + schema + .types + .get(ty.inner_named_type()) + .ok_or_else(|| Message { + code: Code::GraphQLError, + message: format!("The type {ty} is referenced but not defined in the schema.",), + locations: field + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }) +} + +/// Resolve a variable reference path relative to a type. Assumes that the first element of the +/// path has already been resolved to the type, and validates any remainder. +fn resolve_path( + schema: &SchemaInfo, + path_selection: &SelectionTrie, + node: &Node, + field_type: &Type, + field: &Component, +) -> Result<(), Message> { + let parent_is_nullable = !field_type.is_non_null(); + + for (nested_field_name, sub_trie) in path_selection.iter() { + let nested_field_type = resolve_type(schema, field_type, field) + .and_then(|extended_type| { + match extended_type { + ExtendedType::Enum(_) | ExtendedType::Scalar(_) => None, + ExtendedType::Object(object) => object.fields.get(nested_field_name).map(|field| &field.ty), + ExtendedType::InputObject(input_object) => input_object.fields.get(nested_field_name).map(|field| field.ty.as_ref()), + // TODO: at the time of writing, you can't declare interfaces or unions in connectors schemas at all, so these aren't tested + ExtendedType::Interface(interface) => interface.fields.get(nested_field_name).map(|field| &field.ty), + ExtendedType::Union(_) => { + return Err(Message { + code: Code::UnsupportedVariableType, + message: format!( + "The type {field_type} is a union, which is not supported in variables yet.", + ), + locations: field + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }) + }, + } + .ok_or_else(|| Message { + code: Code::UndefinedField, + message: format!( + "`{field_type}` does not have a field named `{nested_field_name}`." + ), + locations: path_selection + .key_ranges(nested_field_name) + .flat_map(|range| subslice_location(node, range, schema)) + .collect(), + }) + }) + .map(|extended_type| { + if parent_is_nullable && extended_type.is_non_null() { + // This .clone() might not be necessary if .nullable() did + // not take ownership of extended_type. + extended_type.clone().nullable() + } else { + extended_type.clone() + } + })?; + + resolve_path(schema, sub_trie, node, &nested_field_type, field)?; + } + + Ok(()) +} + +/// Resolves variables in the `$this` namespace +pub(crate) struct ThisResolver<'a> { + object: &'a ObjectType, + field: &'a Component, +} + +impl<'a> ThisResolver<'a> { + pub(crate) const fn new(object: &'a ObjectType, field: &'a Component) -> Self { + Self { object, field } + } +} + +impl NamespaceResolver for ThisResolver<'_> { + fn check( + &self, + reference: &VariableReference, + node: &Node, + schema: &SchemaInfo, + ) -> Result<(), Message> { + for (root, sub_trie) in reference.selection.iter() { + let fields = &self.object.fields; + + let field_type = fields + .get(root) + .ok_or_else(|| Message { + code: Code::UndefinedField, + message: format!( + "`{object}` does not have a field named `{root}`", + object = self.object.name, + ), + locations: reference + .selection + .key_ranges(root) + .flat_map(|range| subslice_location(node, range, schema)) + .collect(), + }) + .map(|field| field.ty.clone())?; + + resolve_path(schema, sub_trie, node, &field_type, self.field)?; + } + + Ok(()) + } +} + +/// Resolves variables in the `$args` namespace +pub(crate) struct ArgsResolver<'a> { + field: &'a Component, +} + +impl<'a> ArgsResolver<'a> { + pub(crate) const fn new(field: &'a Component) -> Self { + Self { field } + } +} + +impl NamespaceResolver for ArgsResolver<'_> { + fn check( + &self, + reference: &VariableReference, + node: &Node, + schema: &SchemaInfo, + ) -> Result<(), Message> { + for (root, sub_trie) in reference.selection.iter() { + let field_type = self + .field + .arguments + .iter() + .find(|arg| arg.name == root) + .map(|arg| arg.ty.clone()) + .ok_or_else(|| Message { + code: Code::UndefinedArgument, + message: format!( + "`{object}` does not have an argument named `{root}`", + object = self.field.name, + ), + locations: reference + .selection + .key_ranges(root) + .flat_map(|range| subslice_location(node, range, schema)) + .collect(), + })?; + + resolve_path(schema, sub_trie, node, &field_type, self.field)?; + } + + Ok(()) + } +} diff --git a/apollo-federation/src/connectors/validation/coordinates.rs b/apollo-federation/src/connectors/validation/coordinates.rs new file mode 100644 index 0000000000..6bc4136bb8 --- /dev/null +++ b/apollo-federation/src/connectors/validation/coordinates.rs @@ -0,0 +1,209 @@ +use std::fmt; +use std::fmt::Display; +use std::fmt::Formatter; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Directive; +use apollo_compiler::ast::Value; + +use super::DirectiveName; +use crate::connectors::HTTPMethod; +use crate::connectors::SourceName; +use crate::connectors::id::ConnectedElement; +use crate::connectors::spec::connect::CONNECT_SELECTION_ARGUMENT_NAME; +use crate::connectors::spec::connect::IS_SUCCESS_ARGUMENT_NAME; +use crate::connectors::spec::http::HEADERS_ARGUMENT_NAME; +use crate::connectors::spec::http::HTTP_ARGUMENT_NAME; +use crate::connectors::spec::source::BaseUrl; +use crate::connectors::validation::errors::ErrorsCoordinate; + +/// The location of a `@connect` directive. +#[derive(Clone, Copy)] +pub(super) struct ConnectDirectiveCoordinate<'a> { + pub(super) directive: &'a Node, + pub(super) element: ConnectedElement<'a>, +} + +impl Display for ConnectDirectiveCoordinate<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + let Self { directive, element } = self; + write!( + f, + "`@{directive_name}` on `{element}`", + directive_name = directive.name + ) + } +} + +/// The location of a `@source` directive. +#[derive(Clone)] +pub(super) struct SourceDirectiveCoordinate<'a> { + pub(crate) name: SourceName, + pub(super) directive: &'a Node, +} + +impl Display for SourceDirectiveCoordinate<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + let Self { name, directive } = self; + write!( + f, + "`@{directive_name}(name: \"{name}\")`", + directive_name = directive.name + ) + } +} + +#[derive(Clone, Copy)] +pub(super) struct SelectionCoordinate<'a> { + pub(crate) connect: ConnectDirectiveCoordinate<'a>, +} + +impl Display for SelectionCoordinate<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + let ConnectDirectiveCoordinate { directive, element } = self.connect; + write!( + f, + "`@{connect_directive_name}({CONNECT_SELECTION_ARGUMENT_NAME}:)` on `{element}`", + connect_directive_name = directive.name + ) + } +} + +impl<'a> From> for SelectionCoordinate<'a> { + fn from(connect_directive_coordinate: ConnectDirectiveCoordinate<'a>) -> Self { + Self { + connect: connect_directive_coordinate, + } + } +} + +/// The coordinate of an `HTTP` arg within a connect directive. + +#[derive(Clone, Copy)] +pub(super) struct ConnectHTTPCoordinate<'a> { + pub(crate) connect_directive_coordinate: ConnectDirectiveCoordinate<'a>, +} + +impl Display for ConnectHTTPCoordinate<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + let ConnectDirectiveCoordinate { directive, element } = self.connect_directive_coordinate; + write!( + f, + "`@{connect_directive_name}({HTTP_ARGUMENT_NAME}:)` on `{element}`", + connect_directive_name = directive.name + ) + } +} + +impl<'a> From> for ConnectHTTPCoordinate<'a> { + fn from(connect_directive_coordinate: ConnectDirectiveCoordinate<'a>) -> Self { + Self { + connect_directive_coordinate, + } + } +} + +/// The coordinate of an `HTTP.method` arg within the `@connect` directive. +#[derive(Clone, Copy)] +pub(super) struct HttpMethodCoordinate<'a> { + pub(crate) connect: ConnectDirectiveCoordinate<'a>, + pub(crate) method: HTTPMethod, + pub(crate) node: &'a Node, +} + +impl Display for HttpMethodCoordinate<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + let Self { + connect: ConnectDirectiveCoordinate { directive, element }, + method, + node: _node, + } = self; + write!( + f, + "`{method}` in `@{connect_directive_name}({HTTP_ARGUMENT_NAME}:)` on `{element}`", + connect_directive_name = directive.name, + ) + } +} + +/// The `baseURL` argument for the `@source` directive +#[derive(Clone, Copy)] +pub(super) struct BaseUrlCoordinate<'a> { + pub(crate) source_directive_name: &'a DirectiveName, +} + +impl Display for BaseUrlCoordinate<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + let Self { + source_directive_name, + } = self; + write!(f, "`@{source_directive_name}({}:)`", BaseUrl::ARGUMENT) + } +} + +pub(super) fn source_http_argument_coordinate(source_directive_name: &DirectiveName) -> String { + format!("`@{source_directive_name}({HTTP_ARGUMENT_NAME}:)`") +} + +/// Coordinate for an `HTTP.headers` argument in `@source` or `@connect`. +#[derive(Clone, Copy)] +pub(super) enum HttpHeadersCoordinate<'a> { + Source { + directive_name: &'a Name, + }, + Connect { + connect: ConnectDirectiveCoordinate<'a>, + }, +} + +impl Display for HttpHeadersCoordinate<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::Connect { + connect: ConnectDirectiveCoordinate { directive, element }, + } => { + write!( + f, + "`@{connect_directive_name}({HTTP_ARGUMENT_NAME}.{HEADERS_ARGUMENT_NAME}:)` on `{element}`", + connect_directive_name = directive.name + ) + } + Self::Source { directive_name } => { + write!( + f, + "`@{directive_name}({HTTP_ARGUMENT_NAME}.{HEADERS_ARGUMENT_NAME}:)`", + ) + } + } + } +} + +/// The `isSuccess` argument for the `@source` directive +#[derive(Clone)] +pub(crate) struct IsSuccessCoordinate<'schema> { + pub(crate) coordinate: ErrorsCoordinate<'schema>, +} + +impl Display for IsSuccessCoordinate<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match &self.coordinate { + ErrorsCoordinate::Source { source } => { + write!( + f, + "`@{directive_name}(name: \"{source_name}\" {IS_SUCCESS_ARGUMENT_NAME}:)`", + directive_name = source.directive.name, + source_name = source.name + ) + } + ErrorsCoordinate::Connect { connect } => { + write!( + f, + "`@{directive_name}({IS_SUCCESS_ARGUMENT_NAME}:)` on `{element}`", + directive_name = connect.directive.name, + element = connect.element + ) + } + } + } +} diff --git a/apollo-federation/src/connectors/validation/errors.rs b/apollo-federation/src/connectors/validation/errors.rs new file mode 100644 index 0000000000..2bfda7b242 --- /dev/null +++ b/apollo-federation/src/connectors/validation/errors.rs @@ -0,0 +1,405 @@ +//! Parsing and validation for `@connect(errors:)` or `@source(errors:)` + +use std::fmt; +use std::fmt::Display; +use std::fmt::Formatter; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Value; +use multi_try::MultiTry; +use shape::Shape; + +use super::coordinates::ConnectDirectiveCoordinate; +use super::coordinates::IsSuccessCoordinate; +use super::coordinates::SourceDirectiveCoordinate; +use super::expression::MappingArgument; +use super::expression::parse_mapping_argument; +use crate::connectors::JSONSelection; +use crate::connectors::Namespace; +use crate::connectors::spec::connect::IS_SUCCESS_ARGUMENT_NAME; +use crate::connectors::spec::errors::ERRORS_ARGUMENT_NAME; +use crate::connectors::spec::errors::ERRORS_EXTENSIONS_ARGUMENT_NAME; +use crate::connectors::spec::errors::ERRORS_MESSAGE_ARGUMENT_NAME; +use crate::connectors::string_template::Expression; +use crate::connectors::validation::Code; +use crate::connectors::validation::Message; +use crate::connectors::validation::expression; +use crate::connectors::validation::expression::Context; +use crate::connectors::validation::graphql::SchemaInfo; + +/// A valid, parsed (but not type-checked) `@connect(errors:)` or `@source(errors:)`. +pub(super) struct Errors<'schema> { + message: Option>, + extensions: Option>, +} + +impl<'schema> Errors<'schema> { + /// Parse the `@connect(errors:)` or `@source(errors:)` argument and run just enough checks to be able to use the + /// argument at runtime. More advanced checks are done in [`Self::type_check`]. + /// + /// Two sub-pieces are always parsed, and the errors from _all_ of those pieces are returned + /// together in the event of failure: + /// 1. `errors.message` with [`ErrorsMessage::parse`] + /// 2. `errors.extensions` with [`ErrorsExtensions::parse`] + /// + /// The order these pieces run in doesn't matter and shouldn't affect the output. + pub(super) fn parse( + coordinate: ErrorsCoordinate<'schema>, + schema: &'schema SchemaInfo, + ) -> Result> { + let directive = match &coordinate { + ErrorsCoordinate::Source { source } => source.directive, + ErrorsCoordinate::Connect { connect } => connect.directive, + }; + let Some(arg) = directive.specified_argument_by_name(&ERRORS_ARGUMENT_NAME) else { + return Ok(Self { + message: None, + extensions: None, + }); + }; + + if let Some(errors_arg) = arg.as_object() { + ErrorsMessage::parse(errors_arg, coordinate.clone(), schema) + .and_try(ErrorsExtensions::parse(errors_arg, coordinate, schema)) + .map(|(message, extensions)| Self { + message, + extensions, + }) + } else { + Err(vec![Message { + code: Code::GraphQLError, + message: format!( + "{coordinate} `{ERRORS_ARGUMENT_NAME}` argument must be an object." + ), + locations: arg.line_column_range(&schema.sources).into_iter().collect(), + }]) + } + } + + /// Type-check the `@connect(errors:)` or `@source(errors:)` directive. + /// + /// Runs [`ErrorsMessage::type_check`] and [`ErrorsExtensions::type_check`] + /// + /// TODO: Return some type checking results, like extracted keys? + pub(super) fn type_check(self, schema: &SchemaInfo) -> Result<(), Vec> { + let Self { + message, + extensions, + } = self; + + let mut errors = Vec::new(); + if let Some(message) = message { + errors.extend(message.type_check(schema).err()); + } + + if let Some(extensions) = extensions { + errors.extend(extensions.type_check(schema).err()); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } + + pub(super) fn variables(&self) -> impl Iterator + '_ { + self.message + .as_ref() + .into_iter() + .flat_map(|m| { + m.mapping + .variable_references() + .map(|var_ref| var_ref.namespace.namespace) + }) + .chain(self.extensions.as_ref().into_iter().flat_map(|e| { + e.selection + .variable_references() + .map(|var_ref| var_ref.namespace.namespace) + })) + } +} + +struct ErrorsMessage<'schema> { + mapping: MappingArgument, + coordinate: ErrorsMessageCoordinate<'schema>, +} + +impl<'schema> ErrorsMessage<'schema> { + pub(super) fn parse( + errors_arg: &'schema [(Name, Node)], + coordinate: ErrorsCoordinate<'schema>, + schema: &'schema SchemaInfo, + ) -> Result, Message> { + let Some((_, value)) = errors_arg + .iter() + .find(|(name, _)| name == &ERRORS_MESSAGE_ARGUMENT_NAME) + else { + return Ok(None); + }; + let coordinate = ErrorsMessageCoordinate { coordinate }; + + let mapping = parse_mapping_argument( + value, + coordinate.clone(), + Code::InvalidErrorsMessage, + schema, + )?; + + Ok(Some(Self { + mapping, + coordinate, + })) + } + + /// Check that only available variables are used, and the expression results in a string + pub(super) fn type_check(self, schema: &SchemaInfo) -> Result<(), Message> { + let Self { + mapping, + coordinate, + } = self; + let context = match coordinate.coordinate { + ErrorsCoordinate::Source { .. } => { + &Context::for_source_response(schema, &mapping.node, Code::InvalidErrorsMessage) + } + ErrorsCoordinate::Connect { connect } => &Context::for_connect_response( + schema, + connect, + &mapping.node, + Code::InvalidErrorsMessage, + ), + }; + + expression::validate(&mapping.expression, context, &Shape::string([])).map_err( + |mut message| { + message.message = format!("In {coordinate}: {message}", message = message.message); + message + }, + ) + } +} + +/// Coordinate for an `errors` argument in `@source` or `@connect`. +#[derive(Clone)] +pub(super) enum ErrorsCoordinate<'a> { + Source { + source: SourceDirectiveCoordinate<'a>, + }, + Connect { + connect: ConnectDirectiveCoordinate<'a>, + }, +} + +impl Display for ErrorsCoordinate<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::Connect { connect } => { + write!(f, "{connect}") + } + Self::Source { source } => { + write!(f, "{source}") + } + } + } +} + +#[derive(Clone)] +struct ErrorsMessageCoordinate<'schema> { + coordinate: ErrorsCoordinate<'schema>, +} + +impl Display for ErrorsMessageCoordinate<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match &self.coordinate { + ErrorsCoordinate::Source { source } => { + write!( + f, + "`@{directive_name}(name: \"{source_name}\" {ERRORS_ARGUMENT_NAME}.{ERRORS_MESSAGE_ARGUMENT_NAME}:)`", + directive_name = source.directive.name, + source_name = source.name + ) + } + ErrorsCoordinate::Connect { connect } => { + write!( + f, + "`@{directive_name}({ERRORS_ARGUMENT_NAME}.{ERRORS_MESSAGE_ARGUMENT_NAME}:)` on `{element}`", + directive_name = connect.directive.name, + element = connect.element + ) + } + } + } +} + +struct ErrorsExtensions<'schema> { + selection: JSONSelection, + node: Node, + coordinate: ErrorsExtensionsCoordinate<'schema>, +} + +impl<'schema> ErrorsExtensions<'schema> { + pub(super) fn parse( + errors_arg: &'schema [(Name, Node)], + coordinate: ErrorsCoordinate<'schema>, + schema: &'schema SchemaInfo, + ) -> Result, Message> { + let Some((_, value)) = errors_arg + .iter() + .find(|(name, _)| name == &ERRORS_EXTENSIONS_ARGUMENT_NAME) + else { + return Ok(None); + }; + let coordinate = ErrorsExtensionsCoordinate { coordinate }; + + let MappingArgument { expression, node } = parse_mapping_argument( + value, + coordinate.clone(), + Code::InvalidErrorsMessage, + schema, + )?; + + Ok(Some(Self { + selection: expression.expression, + node, + coordinate, + })) + } + + /// Check that the selection only uses allowed variables and evaluates to an object + pub(super) fn type_check(self, schema: &SchemaInfo) -> Result<(), Message> { + let Self { + selection, + node, + coordinate, + } = self; + let context = match coordinate.coordinate { + ErrorsCoordinate::Source { .. } => { + &Context::for_source_response(schema, &node, Code::InvalidErrorsMessage) + } + ErrorsCoordinate::Connect { connect } => { + &Context::for_connect_response(schema, connect, &node, Code::InvalidErrorsMessage) + } + }; + + expression::validate( + &Expression { + expression: selection, + location: 0..node + .location() + .map(|location| location.node_len()) + .unwrap_or_default(), + }, + context, + &Shape::dict(Shape::unknown([]), []), + ) + .map_err(|mut message| { + message.message = format!("In {coordinate}: {message}", message = message.message); + message + }) + } +} + +#[derive(Clone)] +struct ErrorsExtensionsCoordinate<'schema> { + coordinate: ErrorsCoordinate<'schema>, +} + +impl Display for ErrorsExtensionsCoordinate<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match &self.coordinate { + ErrorsCoordinate::Source { source } => { + write!( + f, + "`@{directive_name}(name: \"{source_name}\" {ERRORS_ARGUMENT_NAME}.{ERRORS_EXTENSIONS_ARGUMENT_NAME}:)`", + directive_name = source.directive.name, + source_name = source.name + ) + } + ErrorsCoordinate::Connect { connect } => { + write!( + f, + "`@{directive_name}({ERRORS_ARGUMENT_NAME}.{ERRORS_EXTENSIONS_ARGUMENT_NAME}:)` on `{element}`", + directive_name = connect.directive.name, + element = connect.element + ) + } + } + } +} + +/// The `@connect(isSuccess:)` or `@source(isSuccess:)` argument +pub(crate) struct IsSuccessArgument<'schema> { + expression: Expression, + node: Node, + coordinate: IsSuccessCoordinate<'schema>, +} + +impl<'schema> IsSuccessArgument<'schema> { + pub(crate) fn parse_for_connector( + connect: ConnectDirectiveCoordinate<'schema>, + schema: &'schema SchemaInfo, + ) -> Result, Message> { + Self::parse( + IsSuccessCoordinate { + coordinate: ErrorsCoordinate::Connect { connect }, + }, + schema, + ) + } + + pub(crate) fn parse_for_source( + source: SourceDirectiveCoordinate<'schema>, + schema: &'schema SchemaInfo, + ) -> Result, Message> { + Self::parse( + IsSuccessCoordinate { + coordinate: ErrorsCoordinate::Source { source }, + }, + schema, + ) + } + + fn parse( + coordinate: IsSuccessCoordinate<'schema>, + schema: &'schema SchemaInfo, + ) -> Result, Message> { + let directive = match &coordinate.coordinate { + ErrorsCoordinate::Source { source } => source.directive, + ErrorsCoordinate::Connect { connect } => connect.directive, + }; + // If the `isSuccess` argument cannot be found in provided args, Error + let Some(value) = directive.specified_argument_by_name(&IS_SUCCESS_ARGUMENT_NAME) else { + return Ok(None); + }; + + let MappingArgument { expression, node } = + parse_mapping_argument(value, coordinate.clone(), Code::InvalidIsSuccess, schema)?; + + Ok(Some(Self { + expression, + coordinate, + node, + })) + } + + /// Check that only available variables are used, and the expression results in a boolean + pub(crate) fn type_check(self, schema: &SchemaInfo<'_>) -> Result<(), Message> { + let context = match self.coordinate.coordinate { + ErrorsCoordinate::Source { .. } => { + &Context::for_source_response(schema, &self.node, Code::InvalidIsSuccess) + } + ErrorsCoordinate::Connect { connect } => { + &Context::for_connect_response(schema, connect, &self.node, Code::InvalidIsSuccess) + } + }; + expression::validate(&self.expression, context, &Shape::bool([])).map_err(|mut message| { + message.message = format!( + "In {coordinate}: {message}", + coordinate = self.coordinate, + message = message.message + ); + message + }) + } +} diff --git a/apollo-federation/src/connectors/validation/expression.rs b/apollo-federation/src/connectors/validation/expression.rs new file mode 100644 index 0000000000..287d3957ed --- /dev/null +++ b/apollo-federation/src/connectors/validation/expression.rs @@ -0,0 +1,954 @@ +//! This module is all about validating [`Expression`]s for a given context. This isn't done at +//! runtime, _only_ during composition because it could be expensive. + +use std::fmt::Display; +use std::ops::Range; +use std::str::FromStr; +use std::sync::LazyLock; + +use apollo_compiler::Node; +use apollo_compiler::ast::Value; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::parser::LineColumn; +use itertools::Itertools; +use shape::NamedShapePathKey; +use shape::Shape; +use shape::ShapeCase; +use shape::graphql::shape_for_arguments; +use shape::location::Location; +use shape::location::SourceId; + +use crate::connectors::JSONSelection; +use crate::connectors::Namespace; +use crate::connectors::id::ConnectedElement; +use crate::connectors::id::ObjectCategory; +use crate::connectors::string_template::Expression; +use crate::connectors::validation::Code; +use crate::connectors::validation::Message; +use crate::connectors::validation::coordinates::ConnectDirectiveCoordinate; +use crate::connectors::validation::graphql::SchemaInfo; +use crate::connectors::validation::graphql::subslice_location; +use crate::connectors::variable::VariableReference; + +static REQUEST_SHAPE: LazyLock = LazyLock::new(|| { + Shape::record( + [( + "headers".to_string(), + Shape::dict(Shape::list(Shape::string([]), []), []), + )] + .into(), + [], + ) +}); + +static RESPONSE_SHAPE: LazyLock = LazyLock::new(|| { + Shape::record( + [( + "headers".to_string(), + Shape::dict(Shape::list(Shape::string([]), []), []), + )] + .into(), + [], + ) +}); + +fn env_shape() -> Shape { + Shape::dict(Shape::string([]), []) +} + +/// Details about the available variables and shapes for the current expression. +/// These should be consistent for all pieces of a connector in the request phase. +pub(super) struct Context<'schema> { + pub(crate) schema: &'schema SchemaInfo<'schema>, + var_lookup: IndexMap, + node: &'schema Node, + /// The code that all resulting messages will use + /// TODO: make code dynamic based on coordinate so new validations can be warnings + code: Code, + /// Used to determine if `$root` is available (aka: we're mapping a response, not a request) + has_response_body: bool, +} + +impl<'schema> Context<'schema> { + /// Create a context valid for expressions within the URI or headers of a `@connect` directive + pub(super) fn for_connect_request( + schema: &'schema SchemaInfo, + coordinate: ConnectDirectiveCoordinate, + node: &'schema Node, + code: Code, + ) -> Self { + match coordinate.element { + ConnectedElement::Field { + parent_type, + field_def, + parent_category, + } => { + let mut var_lookup: IndexMap = [ + (Namespace::Args, shape_for_arguments(field_def)), + (Namespace::Config, Shape::unknown([])), + (Namespace::Context, Shape::unknown([])), + (Namespace::Request, REQUEST_SHAPE.clone()), + (Namespace::Env, env_shape()), + ] + .into_iter() + .collect(); + + if matches!(parent_category, ObjectCategory::Other) { + var_lookup.insert(Namespace::This, Shape::from(parent_type)); + } + + Self { + schema, + var_lookup, + node, + code, + has_response_body: false, + } + } + ConnectedElement::Type { type_def } => { + let var_lookup: IndexMap = [ + (Namespace::This, Shape::from(type_def)), + (Namespace::Batch, Shape::list(Shape::from(type_def), [])), + (Namespace::Config, Shape::unknown([])), + (Namespace::Context, Shape::unknown([])), + (Namespace::Request, REQUEST_SHAPE.clone()), + (Namespace::Env, env_shape()), + ] + .into_iter() + .collect(); + + Self { + schema, + var_lookup, + node, + code, + has_response_body: false, + } + } + } + } + + /// Create a context valid for expressions within the errors.message or errors.extension of the `@connect` directive + /// TODO: We might be able to re-use this for the "selection" field later down the road + pub(super) fn for_connect_response( + schema: &'schema SchemaInfo, + coordinate: ConnectDirectiveCoordinate, + node: &'schema Node, + code: Code, + ) -> Self { + match coordinate.element { + ConnectedElement::Field { + parent_type, + field_def, + parent_category, + } => { + let mut var_lookup: IndexMap = [ + (Namespace::Args, shape_for_arguments(field_def)), + (Namespace::Config, Shape::unknown([])), + (Namespace::Context, Shape::unknown([])), + (Namespace::Status, Shape::int([])), + (Namespace::Request, REQUEST_SHAPE.clone()), + (Namespace::Response, RESPONSE_SHAPE.clone()), + (Namespace::Env, env_shape()), + ] + .into_iter() + .collect(); + + if matches!(parent_category, ObjectCategory::Other) { + var_lookup.insert(Namespace::This, Shape::from(parent_type)); + } + + Self { + schema, + var_lookup, + node, + code, + has_response_body: true, + } + } + ConnectedElement::Type { type_def } => { + let var_lookup: IndexMap = [ + (Namespace::This, Shape::from(type_def)), + (Namespace::Batch, Shape::list(Shape::from(type_def), [])), + (Namespace::Config, Shape::unknown([])), + (Namespace::Context, Shape::unknown([])), + (Namespace::Status, Shape::int([])), + (Namespace::Request, REQUEST_SHAPE.clone()), + (Namespace::Response, RESPONSE_SHAPE.clone()), + (Namespace::Env, env_shape()), + ] + .into_iter() + .collect(); + + Self { + schema, + var_lookup, + node, + code, + has_response_body: true, + } + } + } + } + + /// Create a context valid for expressions within the `@source` directive + pub(super) fn for_source( + schema: &'schema SchemaInfo, + node: &'schema Node, + code: Code, + ) -> Self { + let var_lookup: IndexMap = [ + (Namespace::Config, Shape::unknown([])), + (Namespace::Context, Shape::unknown([])), + (Namespace::Request, REQUEST_SHAPE.clone()), + (Namespace::Env, env_shape()), + ] + .into_iter() + .collect(); + Self { + schema, + var_lookup, + node, + code, + has_response_body: false, + } + } + + /// Create a context valid for expressions within the errors.message or errors.extension of the `@source` directive + /// Note that we can't use stuff like "this" here cause we have no idea what the "type" is when on a @source block + pub(super) fn for_source_response( + schema: &'schema SchemaInfo, + node: &'schema Node, + code: Code, + ) -> Self { + let var_lookup: IndexMap = [ + (Namespace::Config, Shape::unknown([])), + (Namespace::Context, Shape::unknown([])), + (Namespace::Status, Shape::int([])), + (Namespace::Request, REQUEST_SHAPE.clone()), + (Namespace::Response, RESPONSE_SHAPE.clone()), + (Namespace::Env, env_shape()), + ] + .into_iter() + .collect(); + + Self { + schema, + var_lookup, + node, + code, + has_response_body: true, + } + } + + /// Create a context valid for expressions within the `baseURL` property of the `@source` directive + pub(super) fn for_source_url( + schema: &'schema SchemaInfo, + node: &'schema Node, + code: Code, + ) -> Self { + let var_lookup: IndexMap = [ + (Namespace::Config, Shape::unknown([])), + (Namespace::Env, env_shape()), + ] + .into_iter() + .collect(); + + Self { + schema, + var_lookup, + node, + code, + has_response_body: false, + } + } +} + +pub(crate) fn scalars() -> Shape { + Shape::one( + vec![ + Shape::int([]), + Shape::float([]), + Shape::bool(None), + Shape::string(None), + Shape::null([]), + Shape::none(), + ], + [], + ) +} + +/// Take a single expression and check that it's valid for the given context. This checks that +/// the expression can be executed given the known args and that the output shape is as expected. +pub(crate) fn validate( + expression: &Expression, + context: &Context, + expected_shape: &Shape, +) -> Result<(), Message> { + // TODO: this check should be done in the shape checking, but currently + // shape resolution can drop references to inputs if the expressions ends with + // a method, i.e. `$batch.id->joinNotNull(',')` — this resolves to simply + // `Unknown`, so variables are dropped and cannot be checked. + for variable_ref in expression.expression.variable_references() { + let namespace = variable_ref.namespace.namespace; + if !context.var_lookup.contains_key(&namespace) { + let message = if namespace == Namespace::Batch { + "`$batch` may only be used when `@connect` is applied to a type.".to_string() + } else { + format!( + "{} is not valid here, must be one of {}", + namespace, + context.var_lookup.keys().map(|ns| ns.as_str()).join(", ") + ) + }; + return Err(Message { + code: context.code, + message, + locations: variable_ref + .location + .iter() + .filter_map(|location| { + subslice_location( + context.node, + location.start + expression.location.start + ..location.end + expression.location.start, + context.schema, + ) + }) + .collect(), + }); + } + } + + let shape = expression.expression.shape(); + + let actual_shape = resolve_shape(&shape, context, expression)?; + if let Some(mismatch) = expected_shape + .validate(&actual_shape) + .into_iter() + // Unknown satisfies nothing, but we have to allow it for things like `$config` + .find(|mismatch| !mismatch.received.is_unknown()) + { + Err(Message { + code: context.code, + message: format!( + "{} values aren't valid here", + shape_name(&mismatch.received) + ), + locations: transform_locations(&mismatch.received.locations, context, expression), + }) + } else { + Ok(()) + } +} + +/// Validate that the shape is an acceptable output shape for an Expression. +fn resolve_shape( + shape: &Shape, + context: &Context, + expression: &Expression, +) -> Result { + match shape.case() { + ShapeCase::One(shapes) => { + let mut inners = Vec::new(); + for inner in shapes { + inners.push(resolve_shape( + &inner.with_locations(shape.locations.clone()), + context, + expression, + )?); + } + Ok(Shape::one(inners, [])) + } + ShapeCase::All(shapes) => { + let mut inners = Vec::new(); + for inner in shapes { + inners.push(resolve_shape( + &inner.with_locations(shape.locations.clone()), + context, + expression, + )?); + } + Ok(Shape::all(inners, [])) + } + ShapeCase::Name(name, key) => { + let mut resolved = if name.value == "$root" { + // For response mapping, $root (aka the response body) is allowed so we will exit out early here + // However, $root is not allowed for requests so we will error below + if context.has_response_body { + return Ok(Shape::unknown([])); + } + + let mut key_str = key.iter().map(|key| key.to_string()).join("."); + if !key_str.is_empty() { + key_str = format!("`{key_str}` "); + } + return Err(Message { + code: context.code, + message: format!( + "{key_str}must start with one of {namespaces}", + namespaces = context.var_lookup.keys().map(|ns| ns.as_str()).join(", "), + ), + locations: transform_locations( + key.first() + .map(|key| &key.locations) + .unwrap_or(&shape.locations), + context, + expression, + ), + }); + } else if name.value.starts_with('$') { + let namespace = Namespace::from_str(&name.value).map_err(|_| Message { + code: context.code, + message: format!( + "unknown variable `{name}`, must be one of {namespaces}", + namespaces = context.var_lookup.keys().map(|ns| ns.as_str()).join(", ") + ), + locations: transform_locations(&shape.locations, context, expression), + })?; + context + .var_lookup + .get(&namespace) + .ok_or_else(|| Message { + code: context.code, + message: format!( + "{namespace} is not valid here, must be one of {namespaces}", + namespaces = context.var_lookup.keys().map(|ns| ns.as_str()).join(", "), + ), + locations: transform_locations(&shape.locations, context, expression), + })? + .clone() + } else { + context + .schema + .shape_lookup + .get(name.value.as_str()) + .cloned() + .ok_or_else(|| Message { + code: context.code, + message: format!("unknown type `{name}`"), + locations: transform_locations(&name.locations, context, expression), + })? + }; + resolved.locations.extend(shape.locations.iter().cloned()); + let mut path = name.value.clone(); + for key in key { + let child = resolved.child(key.clone()); + if child.is_none() { + let message = match key.value { + NamedShapePathKey::AnyIndex | NamedShapePathKey::Index(_) => { + format!("`{path}` is not an array or string") + } + + NamedShapePathKey::AnyField | NamedShapePathKey::Field(_) => { + format!("`{path}` doesn't have a field named `{key}`") + } + }; + return Err(Message { + code: context.code, + message, + locations: transform_locations(&key.locations, context, expression), + }); + } + resolved = child; + path = format!("{path}.{key}"); + } + resolve_shape(&resolved, context, expression) + } + ShapeCase::Error(shape::Error { message, .. }) => Err(Message { + code: context.code, + message: message.clone(), + locations: transform_locations(&shape.locations, context, expression), + }), + ShapeCase::Array { prefix, tail } => { + let prefix = prefix + .iter() + .map(|shape| resolve_shape(shape, context, expression)) + .collect::, _>>()?; + let tail = resolve_shape(tail, context, expression)?; + Ok(Shape::array(prefix, tail, shape.locations.clone())) + } + ShapeCase::Object { fields, rest } => { + let mut resolved_fields = Shape::empty_map(); + for (key, value) in fields { + resolved_fields.insert(key.clone(), resolve_shape(value, context, expression)?); + } + let resolved_rest = resolve_shape(rest, context, expression)?; + Ok(Shape::object( + resolved_fields, + resolved_rest, + shape.locations.clone(), + )) + } + ShapeCase::None + | ShapeCase::Bool(_) + | ShapeCase::String(_) + | ShapeCase::Int(_) + | ShapeCase::Float + | ShapeCase::Null + | ShapeCase::Unknown => Ok(shape.clone()), + } +} + +fn transform_locations<'a>( + locations: impl IntoIterator, + context: &Context, + expression: &Expression, +) -> Vec> { + let mut locations: Vec<_> = locations + .into_iter() + .filter_map(|location| match &location.source_id { + SourceId::GraphQL(file_id) => context + .schema + .sources + .get(file_id) + .and_then(|source| source.get_line_column_range(location.span.clone())), + SourceId::Other(_) => { + // Right now, this always refers to the JSONSelection location + subslice_location( + context.node, + location.span.start + expression.location.start + ..location.span.end + expression.location.start, + context.schema, + ) + } + }) + .collect(); + if locations.is_empty() { + // Highlight the whole expression + locations.extend(subslice_location( + context.node, + expression.location.start..expression.location.end, + context.schema, + )) + } + locations +} + +/// A simplified shape name for error messages +fn shape_name(shape: &Shape) -> &'static str { + match shape.case() { + ShapeCase::Bool(_) => "boolean", + ShapeCase::String(_) => "string", + ShapeCase::Int(_) => "number", + ShapeCase::Float => "number", + ShapeCase::Null => "null", + ShapeCase::Array { .. } => "array", + ShapeCase::Object { .. } => "object", + ShapeCase::One(_) => "union", + ShapeCase::All(_) => "intersection", + ShapeCase::Name(_, _) => "unknown", + ShapeCase::Unknown => "unknown", + ShapeCase::None => "none", + ShapeCase::Error(_) => "error", + } +} + +pub(crate) struct MappingArgument { + pub(crate) expression: Expression, + pub(crate) node: Node, +} + +impl MappingArgument { + pub(crate) fn variable_references(&self) -> impl Iterator> { + self.expression.expression.variable_references() + } +} + +pub(crate) fn parse_mapping_argument( + node: &Node, + coordinate: impl Display, + code: Code, + schema: &SchemaInfo, +) -> Result { + let Some(string) = node.as_str() else { + return Err(Message { + code: Code::GraphQLError, + message: format!("{coordinate} must be a string."), + locations: node + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }); + }; + + let selection = match JSONSelection::parse_with_spec(string, schema.connect_link.spec) { + Ok(selection) => selection, + Err(e) => { + return Err(Message { + code, + message: format!("{coordinate} is not valid: {e}"), + locations: subslice_location(node, e.offset..e.offset + 1, schema) + .into_iter() + .collect(), + }); + } + }; + + if selection.is_empty() { + return Err(Message { + code, + message: format!("{coordinate} is empty"), + locations: node + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }); + } + + Ok(MappingArgument { + expression: Expression { + expression: selection, + location: 0..string.len(), + }, + node: node.clone(), + }) +} + +#[cfg(test)] +mod tests { + use apollo_compiler::Schema; + use line_col::LineColLookup; + use rstest::rstest; + + use super::*; + use crate::connectors::ConnectSpec; + use crate::connectors::JSONSelection; + use crate::connectors::validation::ConnectLink; + + fn expression(selection: &str, spec: ConnectSpec) -> Expression { + Expression { + expression: JSONSelection::parse_with_spec(selection, spec).unwrap(), + location: 0..0, + } + } + + const SCHEMA: &str = r#" + extend schema + @link( + url: "https://specs.apollo.dev/connect/CONNECT_SPEC_VERSION", + import: ["@connect", "@source"] + ) + @source(name: "v2", http: { baseURL: "http://127.0.0.1" }) + + type Query { + aField( + int: Int + string: String + customScalar: CustomScalar + object: InputObject + array: [InputObject] + multiLevel: MultiLevelInput + ): AnObject @connect(source: "v2", http: {GET: """{EXPRESSION}"""}) + something: String + } + + scalar CustomScalar + + input InputObject { + bool: Boolean + } + + type AnObject { + bool: Boolean + } + + input MultiLevelInput { + inner: MultiLevel + } + + type MultiLevel { + nested: String + } + "#; + + fn schema_for(selection: &str, spec: ConnectSpec) -> String { + let version_string = format!("v{}", spec.as_str()); + SCHEMA + .replace("CONNECT_SPEC_VERSION", version_string.as_str()) + .replace("EXPRESSION", selection) + } + + fn validate_with_context( + selection: &str, + expected: Shape, + spec: ConnectSpec, + ) -> Result<(), Message> { + let schema_str = schema_for(selection, spec); + let schema = Schema::parse(&schema_str, "schema").unwrap(); + let object = schema.get_object("Query").unwrap(); + let field = &object.fields["aField"]; + let directive = field.directives.get("connect").unwrap(); + let schema_info = + SchemaInfo::new(&schema, &schema_str, ConnectLink::new(&schema).unwrap()?); + debug_assert_eq!(schema_info.connect_link.spec, spec); + let expr_string = directive + .argument_by_name("http", &schema) + .unwrap() + .as_object() + .unwrap() + .first() + .unwrap() + .1 + .clone(); + let coordinate = ConnectDirectiveCoordinate { + element: ConnectedElement::Field { + parent_type: object, + field_def: field, + parent_category: ObjectCategory::Query, + }, + directive, + }; + let context = + Context::for_connect_request(&schema_info, coordinate, &expr_string, Code::InvalidUrl); + validate(&expression(selection, spec), &context, &expected) + } + + /// Given a full expression replaced in `{EXPRESSION}` above, find the line/col of a substring. + fn location_of_expression( + part: &str, + full_expression: &str, + spec: ConnectSpec, + ) -> Range { + let schema = schema_for(full_expression, spec); + let line_col_lookup = LineColLookup::new(&schema); + let expression_offset = schema.find(full_expression).unwrap() - 1; + let start_offset = expression_offset + full_expression.find(part).unwrap(); + let (start_line, start_col) = line_col_lookup.get(start_offset); + let (end_line, end_col) = line_col_lookup.get(start_offset + part.len()); + LineColumn { + line: start_line, + column: start_col, + }..LineColumn { + line: end_line, + column: end_col, + } + } + + #[rstest] + #[case::int("$(1)")] + #[case::float("$(1.0)")] + #[case::bool("$(true)")] + #[case::string("$(\"hello\")")] + #[case::null("$(null)")] + #[case::property_of_object("$({\"a\": 1}).a")] + #[case::echo_valid_constants("$->echo(1)")] + #[case::map_unknown("$config->map(@)->first")] + #[case::map_scalar("$(1)->map(@)->last")] + #[case::match_only_valid_values("$config->match([1, 1], [2, true])")] + #[case::first("$([1, 2])->first")] + #[case::first_type_unknown("$config.something->first")] + #[case::last("$([1, 2])->last")] + #[case::last_type_unknown("$config.something->last")] + #[case::slice_of_string("$(\"hello\")->slice(0, 2)")] + #[case::slice_when_type_unknown("$config.something->slice(0, 2)")] + #[case::size_when_type_unknown("$config.something->size")] + #[case::size_of_array("$([])->size")] + #[case::size_of_entries("$config->entries->size")] + #[case::size_of_slice("$([1, 2, 3])->slice(0, 2)->size")] + #[case::slice_after_match("$config->match([1, \"something\"], [2, \"another\"])->slice(0, 2)")] + #[case("$args.int")] + #[case("$args.string")] + #[case("$args.customScalar")] + #[case("$args.object.bool")] + #[case("$args.array->echo(1)")] + #[case("$args.int->map(@)->last")] + #[case::chained_methods("$args.array->map(@)->slice(0,2)->first.bool")] + #[case::match_scalars("$args.string->match([\"hello\", \"world\"], [@, null])")] + #[case::slice("$args.string->slice(0, 2)")] + #[case::size("$args.array->size")] + #[case::first("$args.array->first.bool")] + #[case::last("$args.array->last.bool")] + #[case::multi_level_input("$args.multiLevel.inner.nested")] + fn valid_expressions(#[case] selection: &str) { + // If this fails, another ConnectSpec version has probably been added, + // and should be accounted for in the loop below. + assert_eq!(ConnectSpec::next(), ConnectSpec::V0_3); + + for spec in [ConnectSpec::V0_1, ConnectSpec::V0_2, ConnectSpec::V0_3] { + validate_with_context(selection, scalars(), spec).unwrap(); + } + } + + #[rstest] + #[case::array("$([])")] + #[case::object("$({\"a\": 1})")] + #[case::missing_property_of_object("$({\"a\": 1}).b")] + #[case::missing_property_of_in_array("$([{\"a\": 1}]).b")] + #[case::last("$([1, 2])")] + #[case::unknown_var("$args.unknown")] + #[case::arg_is_array("$args.array")] + #[case::arg_is_object("$args.object")] + #[case::unknown_field_on_object("$args.object.unknown")] + #[case::this_on_query("$this.something")] + #[case::bare_field_no_var("something")] + fn common_invalid_expressions(#[case] selection: &str) { + // If this fails, another ConnectSpec version has probably been added, + // and should be accounted for in the loop below. + assert_eq!(ConnectSpec::next(), ConnectSpec::V0_3); + + for spec in [ConnectSpec::V0_1, ConnectSpec::V0_2, ConnectSpec::V0_3] { + let err = validate_with_context(selection, scalars(), spec); + assert!(err.is_err()); + assert!( + !err.err().unwrap().locations.is_empty(), + "Every error should have at least one location" + ); + } + } + + #[rstest] + // These cases require method shape checking, which was enabled in v0.3: + #[case::echo_invalid_constants("$->echo([])")] + #[case::map_scalar("$(1)->map(@)")] + #[case::map_array("$([])->map(@)")] + #[case::match_some_invalid_values("$config->match([1, 1], [2, {}])")] + #[case::slice_of_array("$([])->slice(0, 2)")] + #[case::entries("$config.something->entries")] + #[case::map_array("$args.array->map(@)")] + #[case::slice_array("$args.array->slice(0, 2)")] + #[case::entries_scalar("$args.int->entries")] + #[case::first("$args.array->first")] + #[case::last("$args.array->last")] + fn invalid_expressions_with_method_shape_checking(#[case] selection: &str) { + // If this fails, another ConnectSpec version has probably been added, + // and should probably be tested here in addition to v0.3. + assert_eq!(ConnectSpec::next(), ConnectSpec::V0_3); + + let spec = ConnectSpec::V0_3; + let err = validate_with_context(selection, scalars(), spec); + assert!(err.is_err()); + assert!( + !err.err().unwrap().locations.is_empty(), + "Every error should have at least one location" + ); + } + + #[test] + fn bare_field_with_path() { + let selection = "something.blah"; + let err = validate_with_context(selection, scalars(), ConnectSpec::latest()) + .expect_err("missing property is unknown"); + let expected_location = + location_of_expression("something", selection, ConnectSpec::latest()); + assert!( + err.message.contains("`something.blah`"), + "{} didn't reference missing arg", + err.message + ); + assert!( + err.message.contains("$args"), + "{} didn't provide suggested variables", + err.message + ); + assert!( + err.locations.contains(&expected_location), + "The expected location {:?} wasn't included in {:?}", + expected_location, + err.locations + ); + } + + #[test] + fn object_in_url() { + let selection = "$args.object"; + let err = validate_with_context(selection, scalars(), ConnectSpec::latest()) + .expect_err("objects are not allowed"); + let expected_location = location_of_expression("object", selection, ConnectSpec::latest()); + assert!( + err.locations.contains(&expected_location), + "The expected location {:?} wasn't included in {:?}", + expected_location, + err.locations + ); + } + + #[test] + fn nested_unknown_property() { + let selection = "$args.multiLevel.inner.unknown"; + let err = validate_with_context(selection, scalars(), ConnectSpec::latest()) + .expect_err("missing property is unknown"); + assert!( + err.message.contains("`MultiLevel`"), + "{} didn't reference type", + err.message + ); + assert!( + err.message.contains("`unknown`"), + "{} didn't reference field name", + err.message + ); + assert!( + err.locations.contains(&location_of_expression( + "unknown", + selection, + ConnectSpec::latest() + )), + "The relevant piece of the expression wasn't included in {:?}", + err.locations + ); + } + + #[test] + fn unknown_var_in_scalar() { + let selection = r#"$({"something": $blahblahblah})"#; + let err = validate_with_context(selection, Shape::unknown([]), ConnectSpec::latest()) + .expect_err("unknown variable is unknown"); + assert!( + err.message.contains("`$blahblahblah`"), + "{} didn't reference variable", + err.message + ); + assert!( + err.locations.contains(&location_of_expression( + "$blahblahblah", + selection, + ConnectSpec::latest() + )), + "The relevant piece of the expression wasn't included in {:?}", + err.locations + ); + } + + #[test] + fn subselection_of_literal_with_missing_field() { + let selection = r#"$({"a": 1}) { b }"#; + let err = validate_with_context(selection, Shape::unknown([]), ConnectSpec::latest()) + .expect_err("invalid property is an error"); + assert!( + err.message.contains("`b`"), + "{} didn't reference variable", + err.message + ); + assert!( + err.locations.contains(&location_of_expression( + "b", + selection, + ConnectSpec::latest() + )), + "The relevant piece of the expression wasn't included in {:?}", + err.locations + ); + } + + #[test] + fn subselection_of_literal_in_array_with_missing_field() { + let selection = r#"$([{"a": 1}]) { b }"#; + let err = validate_with_context(selection, Shape::unknown([]), ConnectSpec::latest()) + .expect_err("invalid property is an error"); + assert!( + err.message.contains("`b`"), + "{} didn't reference variable", + err.message + ); + assert!( + err.locations.contains(&location_of_expression( + "b", + selection, + ConnectSpec::latest() + )), + "The relevant piece of the expression wasn't included in {:?}", + err.locations + ); + } +} diff --git a/apollo-federation/src/connectors/validation/graphql.rs b/apollo-federation/src/connectors/validation/graphql.rs new file mode 100644 index 0000000000..eab064c334 --- /dev/null +++ b/apollo-federation/src/connectors/validation/graphql.rs @@ -0,0 +1,92 @@ +//! Helper structs & functions for dealing with GraphQL schemas +use std::ops::Deref; + +use apollo_compiler::Name; +use apollo_compiler::Schema; +use apollo_compiler::collections::IndexMap; +use line_col::LineColLookup; +use shape::Shape; + +mod strings; + +pub(super) use strings::subslice_location; + +use crate::connectors::spec::ConnectLink; + +pub(crate) struct SchemaInfo<'schema> { + pub(crate) schema: &'schema Schema, + len: usize, + lookup: LineColLookup<'schema>, + pub(crate) connect_link: ConnectLink, + /// A lookup map for the Shapes computed from GraphQL types. + pub(crate) shape_lookup: IndexMap<&'schema str, Shape>, +} + +impl<'schema> SchemaInfo<'schema> { + pub(crate) fn new( + schema: &'schema Schema, + src: &'schema str, + connect_link: ConnectLink, + ) -> Self { + Self { + schema, + len: src.len(), + lookup: LineColLookup::new(src), + connect_link, + shape_lookup: shape::graphql::shapes_for_schema(schema), + } + } + + /// Get the 1-based line and column values for an offset into this schema. + /// + /// # Returns + /// The line and column, or `None` if the offset is not within the schema. + pub(crate) fn line_col(&self, offset: usize) -> Option<(usize, usize)> { + if offset > self.len { + None + } else { + Some(self.lookup.get(offset)) + } + } + + #[inline] + pub(crate) fn source_directive_name(&self) -> &Name { + &self.connect_link.source_directive_name + } + + #[inline] + pub(crate) fn connect_directive_name(&self) -> &Name { + &self.connect_link.connect_directive_name + } +} + +impl Deref for SchemaInfo<'_> { + type Target = Schema; + + fn deref(&self) -> &Self::Target { + self.schema + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn line_col_lookup() { + let src = r#" + extend schema @link(url: "https://specs.apollo.dev/connect/v0.1") + type Query { + foo: String + } + "#; + let schema = Schema::parse(src, "testSchema").unwrap(); + + let schema_info = + SchemaInfo::new(&schema, src, ConnectLink::new(&schema).unwrap().unwrap()); + + assert_eq!(schema_info.line_col(0), Some((1, 1))); + assert_eq!(schema_info.line_col(4), Some((2, 4))); + assert_eq!(schema_info.line_col(200), None); + } +} diff --git a/apollo-federation/src/connectors/validation/graphql/strings.rs b/apollo-federation/src/connectors/validation/graphql/strings.rs new file mode 100644 index 0000000000..36ef4ddd80 --- /dev/null +++ b/apollo-federation/src/connectors/validation/graphql/strings.rs @@ -0,0 +1,285 @@ +//! Helpers for dealing with GraphQL literal strings and locations within them. +//! +//! GraphQL string literals can be either standard single-line strings surrounded by a single +//! set of quotes, or a multi-line block string surrounded by triple quotes. +//! +//! Standard strings may contain escape sequences, while block strings contain verbatim text. +//! Block strings additionally have any common indent and leading whitespace lines removed. +//! +//! See: + +use std::ops::Range; + +use apollo_compiler::Node; +use apollo_compiler::ast::Value; +use apollo_compiler::parser::LineColumn; +use apollo_compiler::parser::SourceMap; +use nom::AsChar; + +use crate::connectors::validation::graphql::SchemaInfo; + +const fn is_whitespace(c: char) -> bool { + matches!(c, ' ' | '\t') +} + +fn is_whitespace_line(line: &str) -> bool { + line.is_empty() || line.chars().all(is_whitespace) +} + +#[derive(Clone, Copy)] +enum GraphQLString<'schema> { + Standard { + data: Data<'schema>, + }, + Block { + data: Data<'schema>, + + /// The common indent + common_indent: usize, + }, +} + +#[derive(Clone, Copy)] +struct Data<'schema> { + /// The original string from the source file, excluding the surrounding quotes + raw_string: &'schema str, + + /// Where `raw_string` _starts_ in the source text + raw_offset: usize, +} + +impl<'schema> GraphQLString<'schema> { + fn new(value: &'schema Node, sources: &'schema SourceMap) -> Result { + // Get the raw string value from the source file. This is just the raw string without any + // of the escape sequence processing or whitespace/newline modifications mentioned above. + let source_span = value.location().ok_or(())?; + let file = sources.get(&source_span.file_id()).ok_or(())?; + let source_text = file.source_text(); + let start_of_quotes = source_span.offset(); + let end_of_quotes = source_span.end_offset(); + let raw_string_with_quotes = source_text.get(start_of_quotes..end_of_quotes).ok_or(())?; + + // Count the number of double-quote characters + let num_quotes = raw_string_with_quotes + .chars() + .take_while(|c| matches!(c, '"')) + .count(); + + // Get the raw string with the quotes removed + let raw_string = source_text + .get(start_of_quotes + num_quotes..end_of_quotes - num_quotes) + .ok_or(())?; + + Ok(if num_quotes == 3 { + GraphQLString::Block { + data: Data { + raw_string, + raw_offset: start_of_quotes + num_quotes, + }, + common_indent: raw_string + .lines() + .skip(1) + .filter_map(|line| { + let length = line.len(); + let indent = line.chars().take_while(|&c| is_whitespace(c)).count(); + (indent < length).then_some(indent) + }) + .min() + .unwrap_or(0), + } + } else { + GraphQLString::Standard { + data: Data { + raw_string, + raw_offset: start_of_quotes + num_quotes, + }, + } + }) + } + + fn line_col_for_subslice( + &self, + substring_location: Range, + schema_info: &SchemaInfo, + ) -> Option> { + let start_offset = self.true_offset(substring_location.start)?; + let end_offset = self.true_offset(substring_location.end)?; + + let (line, column) = schema_info.line_col(start_offset)?; + let start = LineColumn { line, column }; + let (line, column) = schema_info.line_col(end_offset)?; + let end = LineColumn { line, column }; + + Some(start..end) + } + + /// Given an offset into the compiled string, compute the true offset in the raw source string. + /// See: https://spec.graphql.org/October2021/#sec-String-Value + fn true_offset(&self, input_offset: usize) -> Option { + match self { + GraphQLString::Standard { data } => { + // For standard strings, handle escape sequences + let mut i = 0usize; + let mut true_offset = data.raw_offset; + let mut chars = data.raw_string.chars(); + while i < input_offset { + let ch = chars.next()?; + true_offset += 1; + if ch == '\\' { + let next = chars.next()?; + true_offset += 1; + if next == 'u' { + // Determine the length of the codepoint in bytes. For example, \uFDFD + // is 3 bytes when encoded in UTF-8 (0xEF,0xB7,0xBD). + let codepoint: String = (&mut chars).take(4).collect(); + let codepoint = u32::from_str_radix(&codepoint, 16).ok()?; + i += char::from_u32(codepoint)?.len(); + true_offset += 4; + continue; + } + } + i += ch.len(); + } + Some(true_offset) + } + GraphQLString::Block { + data, + common_indent, + } => { + // For block strings, handle whitespace changes + let mut skip_chars = 0usize; + let mut skip_lines = data + .raw_string + .lines() + .take_while(|&line| is_whitespace_line(line)) + .count(); + let mut i = 0usize; + let mut true_offset = data.raw_offset; + let mut chars = data.raw_string.chars(); + while i < input_offset { + let ch = chars.next()?; + true_offset += 1; + if skip_chars > 0 { + if ch == '\n' { + skip_chars = *common_indent; + i += 1; + } else { + skip_chars -= 1; + } + continue; + } + if skip_lines > 0 { + if ch == '\n' { + skip_lines -= 1; + if skip_lines == 0 { + skip_chars = *common_indent; + } + } + continue; + } + if ch == '\n' { + skip_chars = *common_indent; + } + if ch != '\r' { + i += ch.len(); + } + } + Some(true_offset + skip_chars) + } + } + } +} + +pub(crate) fn subslice_location( + value: &Node, + substring_location: Range, + schema: &SchemaInfo, +) -> Option> { + GraphQLString::new(value, &schema.sources) + .ok() + .and_then(|string| string.line_col_for_subslice(substring_location, schema)) +} + +#[cfg(test)] +mod tests { + use apollo_compiler::Node; + use apollo_compiler::Schema; + use apollo_compiler::ast::Value; + use apollo_compiler::parser::LineColumn; + use apollo_compiler::schema::ExtendedType; + use pretty_assertions::assert_eq; + + use super::*; + use crate::connectors::validation::ConnectLink; + use crate::connectors::validation::graphql::SchemaInfo; + + const SCHEMA: &str = r#"extend schema @link(url: "https://specs.apollo.dev/connect/v0.1") + type Query { + field: String @connect( + http: { + GET: "https://example.com" + }, + selection: """ + something + somethingElse { + nested + } + """ + ) + } + "#; + + fn connect_argument<'schema>(schema: &'schema Schema, name: &str) -> &'schema Node { + let ExtendedType::Object(query) = schema.types.get("Query").unwrap() else { + panic!("Query type not found"); + }; + let field = query.fields.get("field").unwrap(); + let directive = field.directives.get("connect").unwrap(); + directive.specified_argument_by_name(name).unwrap() + } + + #[test] + fn standard_string() { + let schema = Schema::parse(SCHEMA, "test.graphql").unwrap(); + let http = connect_argument(&schema, "http").as_object().unwrap(); + let value = &http[0].1; + + let string = GraphQLString::new(value, &schema.sources).unwrap(); + let schema_info = + SchemaInfo::new(&schema, SCHEMA, ConnectLink::new(&schema).unwrap().unwrap()); + assert_eq!( + string.line_col_for_subslice(2..5, &schema_info), + Some( + LineColumn { + line: 5, + column: 25 + }..LineColumn { + line: 5, + column: 28 + } + ) + ); + } + + #[test] + fn block_string() { + let schema = Schema::parse(SCHEMA, "test.graphql").unwrap(); + let value = connect_argument(&schema, "selection"); + + let string = GraphQLString::new(value, &schema.sources).unwrap(); + let schema_info = + SchemaInfo::new(&schema, SCHEMA, ConnectLink::new(&schema).unwrap().unwrap()); + assert_eq!( + string.line_col_for_subslice(28..34, &schema_info), + Some( + LineColumn { + line: 10, + column: 15 + }..LineColumn { + line: 10, + column: 21 + } + ) + ); + } +} diff --git a/apollo-federation/src/connectors/validation/http.rs b/apollo-federation/src/connectors/validation/http.rs new file mode 100644 index 0000000000..08591ae730 --- /dev/null +++ b/apollo-federation/src/connectors/validation/http.rs @@ -0,0 +1,4 @@ +pub(super) mod headers; +pub(super) mod url; +pub(super) mod url_properties; +pub(super) use url_properties::UrlProperties; diff --git a/apollo-federation/src/connectors/validation/http/headers.rs b/apollo-federation/src/connectors/validation/http/headers.rs new file mode 100644 index 0000000000..352b14c073 --- /dev/null +++ b/apollo-federation/src/connectors/validation/http/headers.rs @@ -0,0 +1,149 @@ +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Value; +use http::HeaderName; +use indexmap::IndexMap; + +use crate::connectors::HeaderSource; +use crate::connectors::OriginatingDirective; +use crate::connectors::models::Header; +use crate::connectors::models::HeaderParseError; +use crate::connectors::string_template; +use crate::connectors::validation::Code; +use crate::connectors::validation::Message; +use crate::connectors::validation::coordinates::HttpHeadersCoordinate; +use crate::connectors::validation::expression; +use crate::connectors::validation::expression::scalars; +use crate::connectors::validation::graphql::SchemaInfo; +use crate::connectors::validation::graphql::subslice_location; + +pub(crate) struct Headers<'schema> { + headers: Vec
, + coordinate: HttpHeadersCoordinate<'schema>, +} + +impl<'schema> Headers<'schema> { + pub(crate) fn parse( + http_arg: &'schema [(Name, Node)], + coordinate: HttpHeadersCoordinate<'schema>, + schema: &SchemaInfo, + ) -> Result> { + let sources = &schema.sources; + let mut messages = Vec::new(); + let originating_directive = match coordinate { + HttpHeadersCoordinate::Source { .. } => OriginatingDirective::Source, + HttpHeadersCoordinate::Connect { .. } => OriginatingDirective::Connect, + }; + #[allow(clippy::mutable_key_type)] + let mut headers: IndexMap = IndexMap::new(); + let connect_spec = schema.connect_link.spec; + for header in Header::from_http_arg(http_arg, originating_directive, connect_spec) { + let header = match header { + Ok(header) => header, + Err(err) => { + let (message, locations) = match err { + HeaderParseError::Other { message, node } => ( + message, + node.line_column_range(sources).into_iter().collect(), + ), + HeaderParseError::ConflictingArguments { + message, + from_location, + value_location, + } => ( + message, + from_location + .iter() + .chain(value_location.iter()) + .flat_map(|span| span.line_column_range(sources)) + .collect(), + ), + HeaderParseError::ValueError { + err: string_template::Error { message, location }, + node, + } => ( + message, + subslice_location(&node, location, schema) + .into_iter() + .collect(), + ), + }; + messages.push(Message { + code: Code::InvalidHeader, + message: format!("In {coordinate} {message}"), + locations, + }); + continue; + } + }; + if let Some(duplicate) = headers.get(&header.name) { + messages.push(Message { + code: Code::HttpHeaderNameCollision, + message: format!( + "Duplicate header names are not allowed. The header name '{name}' at {coordinate} is already defined.", + name = header.name + ), + locations: header.name_node.as_ref().and_then(|name| name.line_column_range(sources)) + .into_iter() + .chain( + duplicate.name_node.as_ref().and_then(|name| name.line_column_range(sources)) + ) + .collect(), + }); + continue; + } + headers.insert(header.name.clone(), header); + } + if messages.is_empty() { + Ok(Self { + headers: headers.into_values().collect(), + coordinate, + }) + } else { + Err(messages) + } + } + + // TODO: return extracted keys here? + pub(crate) fn type_check(self, schema: &SchemaInfo) -> Result<(), Vec> { + let coordinate = self.coordinate; + let mut messages = Vec::new(); + for header in self.headers { + let HeaderSource::Value(header_value) = &header.source else { + continue; + }; + let Some(node) = header.source_node.as_ref() else { + continue; + }; + let expression_context = match coordinate { + HttpHeadersCoordinate::Source { .. } => { + expression::Context::for_source(schema, node, Code::InvalidHeader) + } + HttpHeadersCoordinate::Connect { connect, .. } => { + expression::Context::for_connect_request( + schema, + connect, + node, + Code::InvalidHeader, + ) + } + }; + messages.extend( + header_value + .expressions() + .filter_map(|expression| { + expression::validate(expression, &expression_context, &scalars()).err() + }) + .map(|mut err| { + err.message = format!("In {coordinate}: {}", err.message); + err + }), + ) + } + if messages.is_empty() { + Ok(()) + } else { + Err(messages) + } + } +} diff --git a/apollo-federation/src/connectors/validation/http/url.rs b/apollo-federation/src/connectors/validation/http/url.rs new file mode 100644 index 0000000000..5cef18c481 --- /dev/null +++ b/apollo-federation/src/connectors/validation/http/url.rs @@ -0,0 +1,55 @@ +use std::fmt::Display; + +use apollo_compiler::Node; +use apollo_compiler::ast::Value; +use http::uri::Scheme; + +use crate::connectors::StringTemplate; +use crate::connectors::validation::Code; +use crate::connectors::validation::Message; +use crate::connectors::validation::graphql::SchemaInfo; +use crate::connectors::validation::graphql::subslice_location; + +pub(crate) fn validate_url_scheme( + template: &StringTemplate, + coordinate: impl Display, + value: &Node, + schema: &SchemaInfo, +) -> Result<(), Message> { + // Evaluate the template, replacing all dynamic expressions with empty strings. This should result in a valid + // URL because of the URL building logic in `interpolate_uri`, even if the result is illogical with missing values. + let (url, _) = template + .interpolate_uri(&Default::default()) + .map_err(|err| Message { + message: format!("In {coordinate}: {err}"), + code: Code::InvalidUrl, + locations: value + .line_column_range(&schema.sources) + .into_iter() + .collect(), + })?; + let Some(scheme) = url.scheme() else { + return Err(Message { + code: Code::InvalidUrlScheme, + message: format!("Base URL for {coordinate} did not start with http:// or https://.",), + locations: value + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }); + }; + if *scheme == Scheme::HTTP || *scheme == Scheme::HTTPS { + Ok(()) + } else { + let scheme_location = 0..scheme.as_str().len(); + Err(Message { + code: Code::InvalidUrlScheme, + message: format!( + "The value {value} for {coordinate} must be http or https, got {scheme}.", + ), + locations: subslice_location(value, scheme_location, schema) + .into_iter() + .collect(), + }) + } +} diff --git a/apollo-federation/src/connectors/validation/http/url_properties.rs b/apollo-federation/src/connectors/validation/http/url_properties.rs new file mode 100644 index 0000000000..fef93c9934 --- /dev/null +++ b/apollo-federation/src/connectors/validation/http/url_properties.rs @@ -0,0 +1,207 @@ +use std::fmt; +use std::sync::LazyLock; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Value; +use itertools::Itertools; +use shape::Shape; +use strum::IntoEnumIterator; +use strum_macros::EnumIter; + +use crate::connectors::validation::Code; +use crate::connectors::validation::Message; +use crate::connectors::validation::SchemaInfo; +use crate::connectors::validation::coordinates::ConnectDirectiveCoordinate; +use crate::connectors::validation::coordinates::SourceDirectiveCoordinate; +use crate::connectors::validation::expression; +use crate::connectors::validation::expression::MappingArgument; +use crate::connectors::validation::expression::parse_mapping_argument; + +pub(in crate::connectors::validation) struct UrlProperties<'schema> { + properties: Vec>, +} + +impl<'schema> UrlProperties<'schema> { + pub(in crate::connectors::validation) fn parse_for_connector( + connector: ConnectDirectiveCoordinate<'schema>, + schema: &'schema SchemaInfo<'schema>, + http_arg: &'schema [(Name, Node)], + ) -> Result> { + Self::parse(&ConnectOrSource::Connect(connector), schema, http_arg) + } + + pub(in crate::connectors::validation) fn parse_for_source( + source_coordinate: SourceDirectiveCoordinate<'schema>, + schema: &'schema SchemaInfo<'schema>, + http_arg: &'schema [(Name, Node)], + ) -> Result> { + Self::parse( + &ConnectOrSource::Source(source_coordinate), + schema, + http_arg, + ) + } + + fn parse( + directive: &ConnectOrSource<'schema>, + schema: &'schema SchemaInfo<'schema>, + http_arg: &'schema [(Name, Node)], + ) -> Result> { + let (properties, errors): (Vec, Vec) = http_arg + .iter() + .filter_map(|(name, value)| { + PropertyName::iter() + .find(|prop_name| prop_name.as_str() == name.as_str()) + .map(|name| (name, value)) + }) + .map(|(property, value)| { + let coordinate = Coordinate { + directive: directive.clone(), + property, + }; + let mapping = + parse_mapping_argument(value, &coordinate, Code::InvalidUrlProperty, schema)?; + Ok(Property { + coordinate, + mapping, + }) + }) + .partition_result(); + + if !errors.is_empty() { + return Err(errors); + } + + Ok(Self { properties }) + } + + pub(in crate::connectors::validation) fn type_check( + &self, + schema: &SchemaInfo<'_>, + ) -> Vec { + let mut messages = vec![]; + + for property in &self.properties { + messages.extend(self.property_type_check(property, schema).err()); + } + + messages + } + + fn property_type_check( + &self, + property: &Property<'_>, + schema: &SchemaInfo<'_>, + ) -> Result<(), Message> { + let context = match property.coordinate.directive { + ConnectOrSource::Source(_) => expression::Context::for_source( + schema, + &property.mapping.node, + Code::InvalidUrlProperty, + ), + ConnectOrSource::Connect(coord) => expression::Context::for_connect_request( + schema, + coord, + &property.mapping.node, + Code::InvalidUrlProperty, + ), + }; + + expression::validate( + &property.mapping.expression, + &context, + property.expected_shape(), + ) + .map_err(|e| { + let message = format!("{} is invalid: {}", property.coordinate, e.message); + Message { message, ..e } + }) + } +} + +struct Property<'schema> { + coordinate: Coordinate<'schema>, + mapping: MappingArgument, +} + +impl Property<'_> { + fn expected_shape(&self) -> &Shape { + match self.coordinate.property { + PropertyName::Path => &PATH_SHAPE, + PropertyName::QueryParams => &QUERY_SHAPE, + } + } +} + +#[derive(Clone)] +struct Coordinate<'schema> { + directive: ConnectOrSource<'schema>, + property: PropertyName, +} + +impl fmt::Display for Coordinate<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match &self.directive { + ConnectOrSource::Source(source) => { + write!(f, "In {source}, the `{}` argument", self.property) + } + ConnectOrSource::Connect(connect) => { + write!(f, "In {connect}, the `{}` argument", self.property) + } + } + } +} + +#[derive(Clone)] +enum ConnectOrSource<'schema> { + Source(SourceDirectiveCoordinate<'schema>), + Connect(ConnectDirectiveCoordinate<'schema>), +} + +impl fmt::Display for ConnectOrSource<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ConnectOrSource::Source(source) => write!(f, "{source}"), + ConnectOrSource::Connect(connect) => write!(f, "{connect}"), + } + } +} + +#[derive(Clone, Copy, EnumIter)] +enum PropertyName { + Path, + QueryParams, +} + +impl PropertyName { + const fn as_str(&self) -> &'static str { + match self { + PropertyName::Path => "path", + PropertyName::QueryParams => "queryParams", + } + } +} + +impl fmt::Display for PropertyName { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +static PATH_SHAPE: LazyLock = LazyLock::new(|| { + Shape::list( + Shape::one( + [ + Shape::string([]), + Shape::int([]), + Shape::float([]), + Shape::bool([]), + ], + [], + ), + [], + ) +}); + +static QUERY_SHAPE: LazyLock = LazyLock::new(|| Shape::dict(Shape::unknown([]), [])); diff --git a/apollo-federation/src/connectors/validation/mod.rs b/apollo-federation/src/connectors/validation/mod.rs new file mode 100644 index 0000000000..181a3e6001 --- /dev/null +++ b/apollo-federation/src/connectors/validation/mod.rs @@ -0,0 +1,338 @@ +//! Validation of the `@source` and `@connect` directives. + +mod connect; +mod coordinates; +mod errors; +mod expression; +mod graphql; +mod http; +mod schema; +mod source; + +use std::ops::Range; + +use apollo_compiler::Name; +use apollo_compiler::Schema; +use apollo_compiler::parser::LineColumn; +use apollo_compiler::schema::SchemaBuilder; +use itertools::Itertools; +pub(crate) use schema::field_set_is_subset; +use strum_macros::Display; +use strum_macros::IntoStaticStr; + +use crate::connectors::ConnectSpec; +use crate::connectors::spec::ConnectLink; +use crate::connectors::spec::source::SOURCE_DIRECTIVE_NAME_IN_SPEC; +use crate::connectors::validation::connect::fields_seen_by_all_connects; +use crate::connectors::validation::graphql::SchemaInfo; +use crate::connectors::validation::source::SourceDirective; + +/// The result of a validation pass on a subgraph +#[derive(Debug)] +pub struct ValidationResult { + /// All validation errors encountered. + pub errors: Vec, + + /// Whether the validated subgraph contained connector directives + pub has_connectors: bool, + + /// The parsed (and potentially invalid) schema of the subgraph + pub schema: Schema, + + /// The optionally transformed schema to be used in later steps. + pub transformed: String, +} + +/// Validate the connectors-related directives `@source` and `@connect`. +/// +/// This function attempts to collect as many validation errors as possible, so it does not bail +/// out as soon as it encounters one. +pub fn validate(mut source_text: String, file_name: &str) -> ValidationResult { + let schema = SchemaBuilder::new() + .adopt_orphan_extensions() + .parse(&source_text, file_name) + .build() + .unwrap_or_else(|schema_with_errors| schema_with_errors.partial); + let link = match ConnectLink::new(&schema) { + None => { + return ValidationResult { + errors: Vec::new(), + has_connectors: false, + schema, + transformed: source_text, + }; + } + Some(Err(err)) => { + return ValidationResult { + errors: vec![err], + has_connectors: true, + schema, + transformed: source_text, + }; + } + Some(Ok(link)) => link, + }; + let schema_info = SchemaInfo::new(&schema, &source_text, link); + + let (source_directives, mut messages) = SourceDirective::find(&schema_info); + let all_source_names = source_directives + .iter() + .map(|directive| directive.name.clone()) + .collect_vec(); + + for source in source_directives { + messages.extend(source.type_check()); + } + + match fields_seen_by_all_connects(&schema_info, &all_source_names) { + Ok(fields_seen_by_connectors) => { + // Don't run schema-wide checks if any connectors failed to validate + messages.extend(schema::validate( + &schema_info, + file_name, + fields_seen_by_connectors, + )) + } + Err(errs) => { + messages.extend(errs); + } + } + + if schema_info.source_directive_name() == DEFAULT_SOURCE_DIRECTIVE_NAME + && messages + .iter() + .any(|error| error.code == Code::NoSourcesDefined) + { + messages.push(Message { + code: Code::NoSourceImport, + message: format!("The `@{SOURCE_DIRECTIVE_NAME_IN_SPEC}` directive is not imported. Try adding `@{SOURCE_DIRECTIVE_NAME_IN_SPEC}` to `import` for `{link}`", link=schema_info.connect_link), + locations: schema_info.connect_link.directive.line_column_range(&schema.sources) + .into_iter() + .collect(), + }); + } + + // Auto-upgrade the schema as the _last_ step, so that error messages from earlier don't have + // incorrect line/col info if we mess this up + if schema_info.connect_link.spec == ConnectSpec::V0_1 { + if let Some(version_range) = + schema_info + .connect_link + .directive + .location() + .and_then(|link_range| { + let version_offset = source_text + .get(link_range.offset()..link_range.end_offset())? + .find(ConnectSpec::V0_1.as_str())?; + let start = link_range.offset() + version_offset; + let end = start + ConnectSpec::V0_1.as_str().len(); + Some(start..end) + }) + { + source_text.replace_range(version_range, ConnectSpec::V0_2.as_str()); + } else { + messages.push(Message { + code: Code::UnknownConnectorsVersion, + message: "Failed to auto-upgrade 0.1 to 0.2, you must manually update the version in `@link`".to_string(), + locations: schema_info.connect_link.directive.line_column_range(&schema.sources) + .into_iter() + .collect(), + }); + return ValidationResult { + errors: messages, + has_connectors: true, + schema, + transformed: source_text, + }; + }; + } + + ValidationResult { + errors: messages, + has_connectors: true, + schema, + transformed: source_text, + } +} + +const DEFAULT_SOURCE_DIRECTIVE_NAME: &str = "connect__source"; + +type DirectiveName = Name; + +#[derive(Debug, Clone)] +pub struct Message { + /// A unique, per-error code to allow consuming tools to take specific actions. These codes + /// should not change once stabilized. + pub code: Code, + /// A human-readable message describing the error. These messages are not stable, tools should + /// not rely on them remaining the same. + /// + /// # Formatting messages + /// 1. Messages should be complete sentences, starting with capitalization as appropriate and + /// ending with punctuation. + /// 2. When referring to elements of the schema, use + /// [schema coordinates](https://github.com/graphql/graphql-wg/blob/main/rfcs/SchemaCoordinates.md) + /// with any additional information added as required for clarity (e.g., the value of an arg). + /// 3. When referring to code elements (including schema coordinates), surround them with + /// backticks. This clarifies that `Type.field` is not ending a sentence with its period. + pub message: String, + pub locations: Vec>, +} + +/// The error code that will be shown to users when a validation fails during composition. +/// +/// Note that these codes are global, not scoped to connectors, so they should attempt to be +/// unique across all pieces of composition, including JavaScript components. +#[derive(Clone, Copy, Debug, Display, Eq, IntoStaticStr, PartialEq)] +#[strum(serialize_all = "SCREAMING_SNAKE_CASE")] +pub enum Code { + /// A problem with GraphQL syntax or semantics was found. These will usually be caught before + /// this validation process. + GraphQLError, + /// Indicates two connector sources with the same name were created. + DuplicateSourceName, + /// Indicates two connector IDs with the same name were created. + DuplicateIdName, + /// The `name` provided for a `@source` was invalid. + InvalidSourceName, + /// No `name` was provided when creating a connector source with `@source`. + EmptySourceName, + /// Connector ID name must be `alphanumeric_`. + InvalidConnectorIdName, + /// A URL provided to `@source` or `@connect` was not valid. + InvalidUrl, + /// A URL scheme provided to `@source` or `@connect` was not `http` or `https`. + InvalidUrlScheme, + /// The `source` argument used in a `@connect` directive doesn't match any named connector + /// sources created with `@source`. + SourceNameMismatch, + /// Connectors currently don't support subscription operations. + SubscriptionInConnectors, + /// The `@connect` is using a `source`, but the URL is absolute. This is not allowed because + /// the `@source` URL will be joined with the `@connect` URL, so the `@connect` URL should + /// only be a path. + AbsoluteConnectUrlWithSource, + /// The `@connect` directive is using a relative URL (path only) but does not define a `source`. + /// This is a specialization of [`Self::InvalidUrl`]. + RelativeConnectUrlWithoutSource, + /// This is a specialization of [`Self::SourceNameMismatch`] that indicates no sources were defined. + NoSourcesDefined, + /// The subgraph doesn't import the `@source` directive. This isn't necessarily a problem, but + /// is likely a mistake. + NoSourceImport, + /// The `@connect` directive has multiple HTTP methods when only one is allowed. + MultipleHttpMethods, + /// The `@connect` directive is missing an HTTP method. + MissingHttpMethod, + /// The `@connect` directive's `entity` argument should only be used on the root `Query` field. + EntityNotOnRootQuery, + /// The arguments to the entity reference resolver do not match the entity type. + EntityResolverArgumentMismatch, + /// The `@connect` directive's `entity` argument should only be used with non-list, nullable, object types. + EntityTypeInvalid, + /// A `@key` was defined without a corresponding entity connector. + MissingEntityConnector, + /// The provided selection mapping in a `@connect`s `selection` was not valid. + InvalidSelection, + /// The `http.body` provided in `@connect` was not valid. + InvalidBody, + /// The `errors.message` provided in `@connect` or `@source` was not valid. + InvalidErrorsMessage, + /// The `isSuccess` mapping provided in `@connect` or `@source` was not valid. + InvalidIsSuccess, + /// A circular reference was detected in a `@connect` directive's `selection` argument. + CircularReference, + /// A field included in a `@connect` directive's `selection` argument is not defined on the corresponding type. + SelectedFieldNotFound, + /// A group selection mapping (`a { b }`) was used, but the field is not an object. + GroupSelectionIsNotObject, + /// The `name` mapping must be unique for all headers. + HttpHeaderNameCollision, + /// A provided header in `@source` or `@connect` was not valid. + InvalidHeader, + /// Certain directives are not allowed when using connectors. + ConnectorsUnsupportedFederationDirective, + /// Abstract types are not allowed when using connectors. + ConnectorsUnsupportedAbstractType, + /// Fields that return an object type must use a group selection mapping `{}`. + GroupSelectionRequiredForObject, + /// The schema includes fields that aren't resolved by a connector. + ConnectorsUnresolvedField, + /// A field resolved by a connector has arguments defined. + ConnectorsFieldWithArguments, + /// Connector batch key is not reflected in the output selection + ConnectorsBatchKeyNotInSelection, + /// Connector batch key is derived from a non-root variable such as `$this` or `$context`. + ConnectorsNonRootBatchKey, + /// A `@key` could not be resolved for the given combination of variables. + ConnectorsCannotResolveKey, + /// Part of the `@connect` refers to an `$args` which is not defined. + UndefinedArgument, + /// Part of the `@connect` refers to an `$this` which is not defined. + UndefinedField, + /// A type used in a variable is not yet supported (i.e., unions). + UnsupportedVariableType, + /// The version set in the connectors `@link` URL is not recognized. + UnknownConnectorsVersion, + /// When `@connect` is applied to a type, `entity` can't be set to `false` + ConnectOnTypeMustBeEntity, + /// `@connect` cannot be applied to a query, mutation, or subscription root type + ConnectOnRoot, + /// Using both `$batch` and `$this` is not allowed + ConnectBatchAndThis, + /// Invalid URL property + InvalidUrlProperty, +} + +impl Code { + pub fn severity(&self) -> Severity { + match self { + Self::NoSourceImport => Severity::Warning, + _ => Severity::Error, + } + } +} + +/// Given the [`Code`] of a [`Message`], how important is that message? +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Severity { + /// This is an error, validation as failed. + Error, + /// The user probably wants to know about this, but it doesn't halt composition. + Warning, +} + +#[cfg(test)] +mod test_validate_source { + use std::fs::read_to_string; + + use insta::assert_snapshot; + use insta::glob; + use pretty_assertions::assert_str_eq; + + use super::*; + + #[test] + fn validation_tests() { + insta::with_settings!({prepend_module_to_snapshot => false}, { + glob!("test_data", "**/*.graphql", |path| { + let schema = read_to_string(path).unwrap(); + let start_time = std::time::Instant::now(); + let result = validate(schema.clone(), path.to_str().unwrap()); + let end_time = std::time::Instant::now(); + assert_snapshot!(format!("{:#?}", result.errors)); + if path.parent().is_some_and(|parent| parent.ends_with("transformed")) { + assert_snapshot!(&diff::lines(&schema, &result.transformed).into_iter().filter_map(|res| match res { + diff::Result::Left(line) => Some(format!("- {line}")), + diff::Result::Right(line) => Some(format!("+ {line}")), + diff::Result::Both(_, _) => None, + }).join("\n")); + } else { + assert_str_eq!(schema, result.transformed, "Schema should not have been transformed by validations") + } + + assert!(end_time - start_time < std::time::Duration::from_millis(100)); + }); + }); + } +} diff --git a/apollo-federation/src/connectors/validation/schema.rs b/apollo-federation/src/connectors/validation/schema.rs new file mode 100644 index 0000000000..781e5190d1 --- /dev/null +++ b/apollo-federation/src/connectors/validation/schema.rs @@ -0,0 +1,515 @@ +//! Validations that check the entire connectors schema together: + +use std::ops::Range; + +use apollo_compiler::Name; +use apollo_compiler::Schema; +use apollo_compiler::ast::Directive; +use apollo_compiler::collections::IndexSet; +use apollo_compiler::executable::FieldSet; +use apollo_compiler::executable::Selection; +use apollo_compiler::name; +use apollo_compiler::parser::LineColumn; +use apollo_compiler::parser::Parser; +use apollo_compiler::parser::SourceMap; +use apollo_compiler::parser::SourceSpan; +use apollo_compiler::schema::Component; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::ObjectType; +use apollo_compiler::validation::Valid; +use hashbrown::HashSet; +use indexmap::IndexMap; +use itertools::Itertools; +use shape::Shape; +use shape::ShapeCase; +use shape::ShapeVisitor; + +use self::keys::EntityKeyChecker; +use self::keys::field_set_error; +pub(crate) use self::keys::field_set_is_subset; +use crate::connectors::Connector; +use crate::connectors::EntityResolver::TypeBatch; +use crate::connectors::Namespace::Batch; +use crate::connectors::json_selection::SelectionTrie; +use crate::connectors::validation::Code; +use crate::connectors::validation::Message; +use crate::connectors::validation::graphql::SchemaInfo; +use crate::link::Import; +use crate::link::Link; +use crate::link::federation_spec_definition::FEDERATION_FIELDS_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_KEY_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_RESOLVABLE_ARGUMENT_NAME; +use crate::link::spec::Identity; +use crate::subgraph::spec::CONTEXT_DIRECTIVE_NAME; +use crate::subgraph::spec::EXTERNAL_DIRECTIVE_NAME; +use crate::subgraph::spec::FROM_CONTEXT_DIRECTIVE_NAME; + +mod keys; + +pub(super) fn validate( + schema: &SchemaInfo, + file_name: &str, + fields_seen_by_connectors: Vec<(Name, Name)>, +) -> Vec { + let messages: Vec = check_for_disallowed_type_definitions(schema) + .chain(check_conflicting_directives(schema)) + .collect(); + if !messages.is_empty() { + return messages; + } + check_seen_fields(schema, fields_seen_by_connectors) + .chain(advanced_validations(schema, file_name)) + .collect() +} + +fn check_for_disallowed_type_definitions(schema: &SchemaInfo) -> impl Iterator { + let subscription_name = schema + .schema_definition + .subscription + .as_ref() + .map(|sub| &sub.name); + schema + .types + .values() + .filter_map(move |extended_type| match extended_type { + ExtendedType::Union(union_type) => Some(abstract_type_error( + SourceSpan::recompose(union_type.location(), union_type.name.location()), + &schema.sources, + "union", + )), + ExtendedType::Interface(interface) => Some(abstract_type_error( + SourceSpan::recompose(interface.location(), interface.name.location()), + &schema.sources, + "interface", + )), + ExtendedType::Object(obj) if subscription_name.is_some_and(|name| name == &obj.name) => { + Some(Message { + code: Code::SubscriptionInConnectors, + message: format!( + "A subscription root type is not supported when using `@{connect_directive_name}`.", + connect_directive_name = schema.connect_directive_name(), + ), + locations: obj.name.line_column_range(&schema.sources).into_iter().collect(), + }) + } + _ => None, + }) +} + +/// Certain federation directives are not allowed when using connectors. +/// We produce errors for any which were imported, even if not used. +fn check_conflicting_directives(schema: &Schema) -> Vec { + let Some((fed_link, fed_link_directive)) = + Link::for_identity(schema, &Identity::federation_identity()) + else { + return Vec::new(); + }; + + // TODO: make the `Link` code retain locations directly instead of reparsing stuff for validation + let imports = fed_link_directive + .specified_argument_by_name(&name!("import")) + .and_then(|arg| arg.as_list()) + .into_iter() + .flatten() + .filter_map(|value| Import::from_value(value).ok().map(|import| (value, import))) + .collect_vec(); + + let disallowed_imports = [CONTEXT_DIRECTIVE_NAME, FROM_CONTEXT_DIRECTIVE_NAME]; + fed_link + .imports + .into_iter() + .filter_map(|import| { + disallowed_imports + .contains(&import.element) + .then(|| Message { + code: Code::ConnectorsUnsupportedFederationDirective, + message: format!( + "The directive `@{import}` is not supported when using connectors.", + import = import.alias.as_ref().unwrap_or(&import.element) + ), + locations: imports + .iter() + .find_map(|(value, reparsed)| { + (*reparsed == *import).then(|| value.line_column_range(&schema.sources)) + }) + .flatten() + .into_iter() + .collect(), + }) + }) + .collect() +} + +fn abstract_type_error(node: Option, source_map: &SourceMap, keyword: &str) -> Message { + Message { + code: Code::ConnectorsUnsupportedAbstractType, + message: format!( + "Abstract schema types, such as `{keyword}`, are not supported when using connectors. You can check out our documentation at https://go.apollo.dev/connectors/best-practices#abstract-schema-types-are-unsupported." + ), + locations: node + .and_then(|location| location.line_column_range(source_map)) + .into_iter() + .collect(), + } +} + +/// Check that all fields defined in the schema are resolved by a connector. +fn check_seen_fields( + schema: &SchemaInfo, + fields_seen_by_connectors: Vec<(Name, Name)>, +) -> impl Iterator { + let federation = Link::for_identity(schema, &Identity::federation_identity()); + let external_directive_name = federation.map_or(EXTERNAL_DIRECTIVE_NAME, |(link, _)| { + link.directive_name_in_schema(&EXTERNAL_DIRECTIVE_NAME) + }); + + let all_fields: IndexSet<_> = schema + .types + .values() + .filter_map(|extended_type| { + if extended_type.is_built_in() { + return None; + } + let coord = |(name, _): (&Name, _)| (extended_type.name().clone(), name.clone()); + + // ignore all fields on objects marked @external + if extended_type + .directives() + .iter() + .any(|dir| dir.name == external_directive_name) + { + return None; + } + + match extended_type { + ExtendedType::Object(object) => { + // ignore fields marked @external + Some( + object + .fields + .iter() + .filter(|(_, def)| { + !def.directives + .iter() + .any(|dir| dir.name == external_directive_name) + }) + .map(coord), + ) + } + ExtendedType::Interface(_) => None, // TODO: when interfaces are supported (probably should include fields from implementing/member types as well) + _ => None, + } + }) + .flatten() + .collect(); + + let mut seen_fields = fields_seen_by_resolvable_keys(schema); + seen_fields.extend(fields_seen_by_connectors); + + (&all_fields - &seen_fields).into_iter().map(move |(parent_type, field_name)| { + let Ok(field_def) = schema.type_field(&parent_type, &field_name) else { + // This should never happen, but if it does, we don't want to panic + return Message { + code: Code::GraphQLError, + message: format!( + "Field `{parent_type}.{field_name}` is missing from the schema.", + ), + locations: Vec::new(), + }; + }; + Message { + code: Code::ConnectorsUnresolvedField, + message: format!( + "No connector resolves field `{parent_type}.{field_name}`. It must have a `@{connect_directive_name}` directive or appear in `@{connect_directive_name}(selection:)`.", + connect_directive_name = schema.connect_directive_name() + ), + locations: field_def.line_column_range(&schema.sources).into_iter().collect(), + } + }) +} + +fn fields_seen_by_resolvable_keys(schema: &SchemaInfo) -> IndexSet<(Name, Name)> { + let mut seen_fields = IndexSet::default(); + let objects = schema.types.values().filter_map(|node| node.as_object()); + // Mark resolvable key fields as seen + let mut selections: Vec<(Name, Selection)> = objects + .clone() + .flat_map(|object| { + resolvable_key_fields(object, schema).flat_map(|(field_set, _)| { + field_set + .selection_set + .selections + .iter() + .map(|selection| (object.name.clone(), selection.clone())) + .collect::>() + }) + }) + .collect(); + while !selections.is_empty() { + if let Some((type_name, selection)) = selections.pop() + && let Some(field) = selection.as_field() + { + let t = (type_name, field.name.clone()); + if !seen_fields.contains(&t) { + seen_fields.insert(t); + field.selection_set.selections.iter().for_each(|selection| { + selections.push((field.ty().inner_named_type().clone(), selection.clone())); + }); + } + } + } + + seen_fields +} + +/// For an object type, get all the keys (and directive nodes) that are resolvable. +/// +/// The [`FieldSet`] returned here is what goes in the `fields` argument, so `id` in `@key(fields: "id")` +fn resolvable_key_fields<'a>( + object: &'a ObjectType, + schema: &'a Schema, +) -> impl Iterator)> { + object + .directives + .iter() + .filter(|directive| directive.name == FEDERATION_KEY_DIRECTIVE_NAME_IN_SPEC) + .filter(|directive| { + directive + .arguments + .iter() + .find(|arg| arg.name == FEDERATION_RESOLVABLE_ARGUMENT_NAME) + .and_then(|arg| arg.value.to_bool()) + .unwrap_or(true) + }) + .filter_map(|directive| { + directive + .arguments + .iter() + .find(|arg| arg.name == FEDERATION_FIELDS_ARGUMENT_NAME) + .map(|arg| &arg.value) + .and_then(|value| value.as_str()) + .and_then(|fields_str| { + Parser::new() + .parse_field_set( + Valid::assume_valid_ref(schema), + object.name.clone(), + fields_str.to_string(), + "", + ) + .ok() + .map(|field_set| (field_set, directive)) + }) + }) +} + +fn advanced_validations(schema: &SchemaInfo, subgraph_name: &str) -> Vec { + let mut messages = Vec::new(); + + let Ok(connectors) = Connector::from_schema(schema, subgraph_name) else { + return messages; + }; + + let mut entity_checker = EntityKeyChecker::default(); + + for (field_set, directive) in find_all_resolvable_keys(schema) { + entity_checker.add_key(&field_set, directive); + } + + for connector in &connectors { + if connector.entity_resolver == Some(TypeBatch) { + let input_trie = compute_batch_input_trie(connector); + match SelectionSetWalker::new(connector.name(), schema, &input_trie) + .walk(&connector.selection.shape(), connector) + { + Ok(res) => messages.extend(res), + Err(err) => messages.push(err), + } + } + } + + for connector in connectors { + match connector.resolvable_key(schema) { + Ok(None) => continue, + Err(_) => { + let variables = connector.variable_references().collect_vec(); + messages.push(field_set_error(&variables, &connector, schema)); + } + Ok(Some(field_set)) => { + entity_checker.add_connector(field_set); + } + } + } + + if !messages.is_empty() { + // Don't produce errors about unresolved keys if we _know_ some of the generated keys are wrong + return messages; + } + + entity_checker.check_for_missing_entity_connectors(schema) +} + +fn compute_batch_input_trie(connector: &Connector) -> SelectionTrie { + let mut trie = SelectionTrie::new(); + connector + .variable_references() + .filter(|var| var.namespace.namespace == Batch) + .for_each(|var| { + let _ = &trie.extend(&var.selection); + }); + trie +} + +struct SelectionSetWalker<'walker> { + name: Name, + schema: &'walker SchemaInfo<'walker>, + trie: &'walker SelectionTrie, + unmapped_fields: HashSet, +} + +impl<'walker> SelectionSetWalker<'walker> { + fn new(name: Name, schema: &'walker SchemaInfo<'walker>, trie: &'walker SelectionTrie) -> Self { + SelectionSetWalker { + name, + schema, + trie, + unmapped_fields: HashSet::new(), + } + } +} + +#[derive(Debug, thiserror::Error)] +enum ShapeVisitorError<'error> { + #[error( + "The `@connect` directive on `{connector}` specifies a `$batch` entity resolver, but the field `{unset}` could not be found in `@connect(selection: ...)`" + )] + BatchKeyNotSubsetOfOutputShape { + connector: String, + unset: &'error String, + locations: Vec>, + }, + #[error("Attempted to resolve key on unexpected shape `{shape_str}`")] + UnexpectedKeyOnShape { + shape_str: String, + locations: Vec>, + }, + #[error( + "`$batch` fields must be mapped from the API response body. Variables such as `$context` and `$this` are not supported" + )] + NonRootBatch(Vec>), +} + +impl From> for Message { + fn from(value: ShapeVisitorError) -> Self { + match &value { + ShapeVisitorError::BatchKeyNotSubsetOfOutputShape { locations, .. } => Message { + code: Code::ConnectorsBatchKeyNotInSelection, + message: value.to_string(), + locations: locations.clone(), + }, + ShapeVisitorError::UnexpectedKeyOnShape { locations, .. } => Message { + code: Code::ConnectorsUnresolvedField, + message: value.to_string(), + locations: locations.clone(), + }, + ShapeVisitorError::NonRootBatch(locations) => Message { + code: Code::ConnectorsNonRootBatchKey, + message: value.to_string(), + locations: locations.clone(), + }, + } + } +} + +impl SelectionSetWalker<'_> { + const ROOT_SHAPE: &'static str = "$root"; + + fn walk( + mut self, + output_shape: &Shape, + connector: &Connector, + ) -> Result, Message> { + output_shape.visit_shape(&mut self)?; + + // Collect messages from unset Names + let mut vec = Vec::new(); + for unset in &self.unmapped_fields { + vec.push( + ShapeVisitorError::BatchKeyNotSubsetOfOutputShape { + connector: connector.id.directive.simple_name(), + unset, + locations: self + .name + .line_column_range(&self.schema.sources) + .into_iter() + .collect(), + } + .into(), + ); + } + Ok(vec) + } +} +impl<'walker> ShapeVisitor for SelectionSetWalker<'walker> { + type Error = ShapeVisitorError<'walker>; + type Output = (); + + fn default(&mut self, shape: &Shape) -> Result { + Err(ShapeVisitorError::UnexpectedKeyOnShape { + shape_str: shape.pretty_print(), + locations: self + .name + .line_column_range(&self.schema.sources) + .into_iter() + .collect(), + }) + } + + fn visit_object( + &mut self, + _: &Shape, + fields: &IndexMap, + _: &Shape, + ) -> Result { + for (key, sub_selection) in self.trie.iter() { + // Object should contain all keys in the selection set. + // If not, then the key is unmapped. + let Some(next_shape) = fields.get(key) else { + self.unmapped_fields.insert(key.to_string()); + continue; + }; + + // Check that next shape doesn't come from a non-`$root` field. + if let ShapeCase::Name(root, _) = next_shape.case() + && root.value != Self::ROOT_SHAPE + { + return Err(ShapeVisitorError::NonRootBatch( + self.name + .line_column_range(&self.schema.sources) + .into_iter() + .collect(), + )); + } + + // If key has no nested selections, then we can stop walking down this branch. + if sub_selection.is_empty() { + continue; + } + + // Continue walking with nested selection sets + let mut nested = SelectionSetWalker::new(self.name.clone(), self.schema, sub_selection); + next_shape.visit_shape(&mut nested)?; + self.unmapped_fields + .extend(nested.unmapped_fields.into_iter()); + } + Ok(()) + } +} + +fn find_all_resolvable_keys(schema: &Schema) -> Vec<(FieldSet, &Component)> { + schema + .types + .values() + .filter_map(|extended_type| extended_type.as_object()) + .flat_map(|object| resolvable_key_fields(object, schema)) + .collect() +} diff --git a/apollo-federation/src/connectors/validation/schema/keys.rs b/apollo-federation/src/connectors/validation/schema/keys.rs new file mode 100644 index 0000000000..5532ee06d2 --- /dev/null +++ b/apollo-federation/src/connectors/validation/schema/keys.rs @@ -0,0 +1,242 @@ +//! Validations to make sure that all `@key` directives in the schema correspond to at least +//! one connector. + +use std::fmt; +use std::fmt::Formatter; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::Schema; +use apollo_compiler::ast::Directive; +use apollo_compiler::collections::HashMap; +use apollo_compiler::executable::FieldSet; +use apollo_compiler::executable::Selection; +use apollo_compiler::validation::Valid; +use itertools::Itertools; + +use crate::connectors::Connector; +use crate::connectors::Namespace; +use crate::connectors::validation::Code; +use crate::connectors::validation::Message; +use crate::connectors::variable::VariableReference; +use crate::link::federation_spec_definition::FEDERATION_FIELDS_ARGUMENT_NAME; + +/// Collects keys and entity connectors for comparison and validation. +#[derive(Default)] +pub(crate) struct EntityKeyChecker<'schema> { + /// Any time we see `type T @key(fields: "f")` (with resolvable: true) + resolvable_keys: Vec<(FieldSet, &'schema Node, &'schema Name)>, + /// Any time we see either: + /// - `type Query { t(f: X): T @connect(entity: true) }` (Explicit entity resolver) + /// - `type T { f: X g: Y @connect(... $this.f ...) }` (Implicit entity resolver) + entity_connectors: HashMap>>, +} + +impl<'schema> EntityKeyChecker<'schema> { + pub(crate) fn add_key(&mut self, field_set: &FieldSet, directive: &'schema Node) { + self.resolvable_keys + .push((field_set.clone(), directive, &directive.name)); + } + + pub(crate) fn add_connector(&mut self, field_set: Valid
) { + self.entity_connectors + .entry(field_set.selection_set.ty.clone()) + .or_default() + .push(field_set); + } + + /// For each @key we've seen, check if there's a corresponding entity connector + /// by semantically comparing the @key field set with the synthesized field set + /// from the connector's arguments. + /// + /// The comparison is done by checking if the @key field set is a subset of the + /// entity connector's field set. It's not equality because we convert `@external`/ + /// `@requires` fields to keys for simplicity's sake. + pub(crate) fn check_for_missing_entity_connectors(&self, schema: &Schema) -> Vec { + let mut messages = Vec::new(); + + for (key, directive, _) in &self.resolvable_keys { + let for_type = self.entity_connectors.get(&key.selection_set.ty); + let key_exists = for_type.is_some_and(|connectors| { + connectors + .iter() + .any(|connector| field_set_is_subset(key, connector)) + }); + if !key_exists { + messages.push(Message { + code: Code::MissingEntityConnector, + message: format!( + "Entity resolution for `@key(fields: \"{}\")` on `{}` is not implemented by a connector. See https://go.apollo.dev/connectors/entity-rules", + directive.argument_by_name(&FEDERATION_FIELDS_ARGUMENT_NAME, schema).ok().and_then(|arg| arg.as_str()).unwrap_or_default(), + key.selection_set.ty, + ), + locations: directive + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }); + } + } + + messages + } +} + +impl fmt::Debug for EntityKeyChecker<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("EntityKeyChecker") + .field( + "resolvable_keys", + &self + .resolvable_keys + .iter() + .map(|(fs, _, _)| { + format!( + "... on {} {}", + fs.selection_set.ty, + fs.selection_set.serialize().no_indent() + ) + }) + .collect_vec(), + ) + .field( + "entity_connectors", + &self + .entity_connectors + .values() + .flatten() + .map(|fs| { + format!( + "... on {} {}", + fs.selection_set.ty, + fs.selection_set.serialize().no_indent() + ) + }) + .collect_vec(), + ) + .finish() + } +} + +pub(crate) fn field_set_error( + variables: &[VariableReference], + connector: &Connector, + schema: &Schema, +) -> Message { + Message { + code: Code::ConnectorsCannotResolveKey, + message: format!( + "Variables used in connector (`{}`) on type `{}` cannot be used to create a valid `@key` directive.", + variables.iter().join("`, `"), + connector.id.directive.simple_name() + ), + locations: connector + .name() + .line_column_range(&schema.sources) + .into_iter() + .collect(), + } +} + +fn selection_is_subset(x: &Selection, y: &Selection) -> bool { + match (x, y) { + (Selection::Field(x), Selection::Field(y)) => { + x.name == y.name + && x.alias == y.alias + && vec_includes_as_set( + &x.selection_set.selections, + &y.selection_set.selections, + selection_is_subset, + ) + } + (Selection::InlineFragment(x), Selection::InlineFragment(y)) => { + x.type_condition == y.type_condition + && vec_includes_as_set( + &x.selection_set.selections, + &y.selection_set.selections, + selection_is_subset, + ) + } + _ => false, + } +} + +/// Returns true if `inner` is a subset of `outer`. +/// +/// Note: apollo_federation::operation::SelectionSet has its own `contains` +/// method I'd love to use, but it requires a ValidFederationSchema, which +/// we don't have during validation. This code can be removed after we rewrite +/// composition in rust and connector validations happen after schema validation +/// and `@link` enrichment. +pub(crate) fn field_set_is_subset(inner: &FieldSet, outer: &FieldSet) -> bool { + inner.selection_set.ty == outer.selection_set.ty + && vec_includes_as_set( + &outer.selection_set.selections, + &inner.selection_set.selections, + selection_is_subset, + ) +} + +// `this` vector includes `other` vector as a set +fn vec_includes_as_set(this: &[T], other: &[T], item_matches: impl Fn(&T, &T) -> bool) -> bool { + other.iter().all(|other_node| { + this.iter() + .any(|this_node| item_matches(this_node, other_node)) + }) +} + +#[cfg(test)] +mod tests { + use apollo_compiler::Schema; + use apollo_compiler::executable::FieldSet; + use apollo_compiler::name; + use apollo_compiler::validation::Valid; + use rstest::rstest; + + use super::field_set_is_subset; + + fn schema() -> Valid { + Schema::parse_and_validate( + r#" + type Query { + t: T + } + + type T { + a: String + b: B + c: String + } + + type B { + x: String + y: String + } + "#, + "", + ) + .unwrap() + } + + #[rstest] + #[case("a", "a")] + #[case("a b { x } c", "a b { x } c")] + #[case("a", "a c")] + #[case("b { x }", "b { x y }")] + fn test_field_set_is_subset(#[case] inner: &str, #[case] outer: &str) { + let schema = schema(); + let inner = FieldSet::parse_and_validate(&schema, name!(T), inner, "inner").unwrap(); + let outer = FieldSet::parse_and_validate(&schema, name!(T), outer, "outer").unwrap(); + assert!(field_set_is_subset(&inner, &outer)); + } + + #[rstest] + #[case("a b { x } c", "a")] + #[case("b { x y }", "b { x }")] + fn test_field_set_is_not_subset(#[case] inner: &str, #[case] outer: &str) { + let schema = schema(); + let inner = FieldSet::parse_and_validate(&schema, name!(T), inner, "inner").unwrap(); + let outer = FieldSet::parse_and_validate(&schema, name!(T), outer, "outer").unwrap(); + assert!(!field_set_is_subset(&inner, &outer)); + } +} diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests.snap new file mode 100644 index 0000000000..99be3eb58f --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_absolute_url.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@all_fields_selected.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@all_fields_selected.graphql.snap new file mode 100644 index 0000000000..828cb3a95b --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@all_fields_selected.graphql.snap @@ -0,0 +1,42 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/all_fields_selected.graphql +--- +[ + Message { + code: ConnectorsUnresolvedField, + message: "No connector resolves field `T.unselected`. It must have a `@connect` directive or appear in `@connect(selection:)`.", + locations: [ + 34:3..34:22, + ], + }, + Message { + code: ConnectorsUnresolvedField, + message: "No connector resolves field `T.secondUnused`. It must have a `@connect` directive or appear in `@connect(selection:)`.", + locations: [ + 49:3..49:23, + ], + }, + Message { + code: ConnectorsUnresolvedField, + message: "No connector resolves field `C.unselected`. It must have a `@connect` directive or appear in `@connect(selection:)`.", + locations: [ + 55:3..55:21, + ], + }, + Message { + code: ConnectorsUnresolvedField, + message: "No connector resolves field `D.unselected`. It must have a `@connect` directive or appear in `@connect(selection:)`.", + locations: [ + 60:3..60:21, + ], + }, + Message { + code: ConnectorsUnresolvedField, + message: "No connector resolves field `Unused.unselected`. It must have a `@connect` directive or appear in `@connect(selection:)`.", + locations: [ + 64:3..64:18, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@all_fields_selected_repro.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@all_fields_selected_repro.graphql.snap new file mode 100644 index 0000000000..7439a8c752 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@all_fields_selected_repro.graphql.snap @@ -0,0 +1,35 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/all_fields_selected_repro.graphql +--- +[ + Message { + code: ConnectorsUnresolvedField, + message: "No connector resolves field `Cart.items`. It must have a `@connect` directive or appear in `@connect(selection:)`.", + locations: [ + 13:3..13:19, + ], + }, + Message { + code: ConnectorsUnresolvedField, + message: "No connector resolves field `Variant.id`. It must have a `@connect` directive or appear in `@connect(selection:)`.", + locations: [ + 17:3..17:10, + ], + }, + Message { + code: ConnectorsUnresolvedField, + message: "No connector resolves field `Variant.price`. It must have a `@connect` directive or appear in `@connect(selection:)`.", + locations: [ + 18:3..18:27, + ], + }, + Message { + code: MissingEntityConnector, + message: "Entity resolution for `@key(fields: \"userId\")` on `Cart` is not implemented by a connector. See https://go.apollo.dev/connectors/entity-rules", + locations: [ + 11:11..11:33, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch.graphql.snap new file mode 100644 index 0000000000..bab8e385e8 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch.graphql.snap @@ -0,0 +1,106 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/batch.graphql +--- +[ + Message { + code: ConnectOnRoot, + message: "Cannot use `@connect` on root types like `Query`", + locations: [ + 9:12..9:77, + ], + }, + Message { + code: ConnectOnRoot, + message: "Cannot use `@connect` on root types like `Mutation`", + locations: [ + 25:3..25:71, + ], + }, + Message { + code: SourceNameMismatch, + message: "`@connect(source: \"missing\")` on `T` does not match any defined sources. Did you mean \"json\"?", + locations: [ + 31:13..31:22, + ], + }, + Message { + code: InvalidBody, + message: "In `@connect(http: {body:})` on `Query.ts`: `$batch` may only be used when `@connect` is applied to a type.", + locations: [ + 14:40..14:49, + ], + }, + Message { + code: InvalidUrl, + message: "In `POST` in `@connect(http:)` on `Query.ts`: `$batch` may only be used when `@connect` is applied to a type.", + locations: [ + 19:31..19:40, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `T`: $args is not valid here, must be one of $this, $batch, $config, $context, $request, $env", + locations: [ + 37:29..37:37, + ], + }, + Message { + code: SelectedFieldNotFound, + message: "`@connect(selection:)` on `T` contains field `typo`, which does not exist on `T`.", + locations: [ + 43:25..43:29, + ], + }, + Message { + code: CircularReference, + message: "Circular reference detected in `@connect(selection:)` on `T`: type `T` appears more than once in `T.friends`. For more information, see https://go.apollo.dev/connectors/limitations#circular-references", + locations: [ + 48:20..48:48, + 79:3..84:6, + ], + }, + Message { + code: ConnectOnTypeMustBeEntity, + message: "`@connect(entity: false)` on `T` is invalid. `entity` can't be false for connectors on types.", + locations: [ + 54:5..54:18, + ], + }, + Message { + code: InvalidSelection, + message: "In `@connect(selection:)` on `T`: variable `$batch` is not valid at this location, must be one of $args, $config, $context, $env, $request, $response, $status, $this", + locations: [ + 64:35..64:41, + ], + }, + Message { + code: ConnectBatchAndThis, + message: "In `@connect` on `T`: connectors cannot use both $this and $batch", + locations: [ + 66:3..70:4, + ], + }, + Message { + code: ConnectBatchAndThis, + message: "In `@connect` on `T`: connectors cannot use both $this and $batch", + locations: [ + 71:3..75:4, + ], + }, + Message { + code: CircularReference, + message: "Direct circular reference detected in `T.friends: [T]`. For more information, see https://go.apollo.dev/connectors/limitations#circular-references", + locations: [ + 79:3..84:6, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `T.listRelationship`: `$batch` may only be used when `@connect` is applied to a type.", + locations: [ + 88:28..88:55, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_alias_happy_path.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_alias_happy_path.graphql.snap new file mode 100644 index 0000000000..e0627f2a9e --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_alias_happy_path.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/batch/batch_alias_happy_path.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_incorrect_context_key.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_incorrect_context_key.graphql.snap new file mode 100644 index 0000000000..9fb8a8f29c --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_incorrect_context_key.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/batch/batch_incorrect_context_key.graphql +--- +[ + Message { + code: ConnectorsNonRootBatchKey, + message: "`$batch` fields must be mapped from the API response body. Variables such as `$context` and `$this` are not supported", + locations: [ + 12:4..12:11, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_incorrect_field.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_incorrect_field.graphql.snap new file mode 100644 index 0000000000..26cf8ac77a --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_incorrect_field.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/batch/batch_incorrect_field.graphql +--- +[ + Message { + code: ConnectorsBatchKeyNotInSelection, + message: "The `@connect` directive on `User` specifies a `$batch` entity resolver, but the field `foo` could not be found in `@connect(selection: ...)`", + locations: [ + 12:4..12:11, + ], + }, + Message { + code: ConnectorsCannotResolveKey, + message: "Variables used in connector (`$batch { foo }`) on type `User` cannot be used to create a valid `@key` directive.", + locations: [ + 12:4..12:11, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_missing_key.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_missing_key.graphql.snap new file mode 100644 index 0000000000..b01f59d1f3 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_missing_key.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/batch/batch_missing_key.graphql +--- +[ + Message { + code: ConnectorsBatchKeyNotInSelection, + message: "The `@connect` directive on `User` specifies a `$batch` entity resolver, but the field `id` could not be found in `@connect(selection: ...)`", + locations: [ + 12:4..12:11, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_missing_nested_key.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_missing_nested_key.graphql.snap new file mode 100644 index 0000000000..8e63d06b7c --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_missing_nested_key.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/batch/batch_missing_nested_key.graphql +--- +[ + Message { + code: ConnectorsBatchKeyNotInSelection, + message: "The `@connect` directive on `User` specifies a `$batch` entity resolver, but the field `id` could not be found in `@connect(selection: ...)`", + locations: [ + 12:4..12:11, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_nested_keys.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_nested_keys.graphql.snap new file mode 100644 index 0000000000..df648f5223 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@batch__batch_nested_keys.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/batch/batch_nested_keys.graphql +--- +[ + Message { + code: ConnectorsUnresolvedField, + message: "No connector resolves field `A.c`. It must have a `@connect` directive or appear in `@connect(selection:)`.", + locations: [ + 23:3..23:9, + ], + }, + Message { + code: ConnectorsBatchKeyNotInSelection, + message: "The `@connect` directive on `T` specifies a `$batch` entity resolver, but the field `c` could not be found in `@connect(selection: ...)`", + locations: [ + 12:4..12:11, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@body_selection.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@body_selection.graphql.snap new file mode 100644 index 0000000000..74f56bc37e --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@body_selection.graphql.snap @@ -0,0 +1,35 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/body_selection.graphql +--- +[ + Message { + code: InvalidBody, + message: "In `@connect(http: {body:})` on `Query.dollar`: must start with one of $args, $config, $context, $request, $env", + locations: [ + 12:20..12:21, + ], + }, + Message { + code: InvalidBody, + message: "In `@connect(http: {body:})` on `Query.dollarField`: `foo` must start with one of $args, $config, $context, $request, $env", + locations: [ + 20:22..20:25, + ], + }, + Message { + code: InvalidBody, + message: "In `@connect(http: {body:})` on `Query.invalidArrowMethod`: Method ->no_such_method not found", + locations: [ + 44:49..44:63, + ], + }, + Message { + code: InvalidBody, + message: "In `@connect(http: {body:})` on `Query.invalidVariable`: unknown variable `$nosuchvariable`, must be one of $args, $config, $context, $request, $env", + locations: [ + 52:32..52:47, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@circular_reference.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@circular_reference.graphql.snap new file mode 100644 index 0000000000..2370cf9397 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@circular_reference.graphql.snap @@ -0,0 +1,15 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/circular_reference.graphql +--- +[ + Message { + code: CircularReference, + message: "Circular reference detected in `@connect(selection:)` on `Query.me`: type `User` appears more than once in `Query.me.friends`. For more information, see https://go.apollo.dev/connectors/limitations#circular-references", + locations: [ + 9:65..9:77, + 14:5..14:22, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@circular_reference_2.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@circular_reference_2.graphql.snap new file mode 100644 index 0000000000..d386a99451 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@circular_reference_2.graphql.snap @@ -0,0 +1,15 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/circular_reference_2.graphql +--- +[ + Message { + code: CircularReference, + message: "Circular reference detected in `@connect(selection:)` on `Track.modules`: type `Track` appears more than once in `Track.modules.track`. For more information, see https://go.apollo.dev/connectors/limitations#circular-references", + locations: [ + 18:28..18:50, + 25:3..25:15, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@circular_reference_3.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@circular_reference_3.graphql.snap new file mode 100644 index 0000000000..d41f3dc5df --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@circular_reference_3.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/circular_reference_3.graphql +--- +[ + Message { + code: CircularReference, + message: "Direct circular reference detected in `User.friends: [User!]!`. For more information, see https://go.apollo.dev/connectors/limitations#circular-references", + locations: [ + 11:3..15:6, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@connect_source_name_mismatch.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@connect_source_name_mismatch.graphql.snap new file mode 100644 index 0000000000..6e69f22ef5 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@connect_source_name_mismatch.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/connect_source_name_mismatch.graphql +--- +[ + Message { + code: SourceNameMismatch, + message: "`@connect(source: \"v1\")` on `Query.resources` does not match any defined sources. Did you mean \"v2\"?", + locations: [ + 10:22..10:26, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@connect_source_undefined.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@connect_source_undefined.graphql.snap new file mode 100644 index 0000000000..ffcc59fd17 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@connect_source_undefined.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/connect_source_undefined.graphql +--- +[ + Message { + code: NoSourcesDefined, + message: "`@connect(source: \"v1\")` on `Query.resources` specifies a source, but none are defined. Try adding `@source(name: \"v1\")` to the schema.", + locations: [ + 9:22..9:26, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@connect_spec_version_error.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@connect_spec_version_error.graphql.snap new file mode 100644 index 0000000000..fa65f50e8c --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@connect_spec_version_error.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/connect_spec_version_error.graphql +--- +[ + Message { + code: UnknownConnectorsVersion, + message: "Unknown connect version: 0.99; should be one of 0.1, 0.2, 0.3.", + locations: [ + 2:3..5:4, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@denest_scalars.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@denest_scalars.graphql.snap new file mode 100644 index 0000000000..3fb6bcc2a5 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@denest_scalars.graphql.snap @@ -0,0 +1,15 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/denest_scalars.graphql +--- +[ + Message { + code: GroupSelectionRequiredForObject, + message: "`User.street` is an object, so `@connect(selection:)` on `Query.me` must select a group `street{}`.", + locations: [ + 15:9..15:15, + 25:3..25:17, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@denest_scalars2.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@denest_scalars2.graphql.snap new file mode 100644 index 0000000000..ef9de7e8dc --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@denest_scalars2.graphql.snap @@ -0,0 +1,15 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/denest_scalars2.graphql +--- +[ + Message { + code: GroupSelectionIsNotObject, + message: "`@connect(selection:)` on `Query.me` selects a group `street{}`, but `User.street` is of type `String` which is not an object.", + locations: [ + 15:9..15:31, + 25:3..25:17, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@disallowed_abstract_types.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@disallowed_abstract_types.graphql.snap new file mode 100644 index 0000000000..e00ad05c91 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@disallowed_abstract_types.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/disallowed_abstract_types.graphql +--- +[ + Message { + code: ConnectorsUnsupportedAbstractType, + message: "Abstract schema types, such as `interface`, are not supported when using connectors. You can check out our documentation at https://go.apollo.dev/connectors/best-practices#abstract-schema-types-are-unsupported.", + locations: [ + 21:1..21:18, + ], + }, + Message { + code: ConnectorsUnsupportedAbstractType, + message: "Abstract schema types, such as `union`, are not supported when using connectors. You can check out our documentation at https://go.apollo.dev/connectors/best-practices#abstract-schema-types-are-unsupported.", + locations: [ + 25:1..25:12, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@disallowed_federation_imports.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@disallowed_federation_imports.graphql.snap new file mode 100644 index 0000000000..0e554c77d4 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@disallowed_federation_imports.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/disallowed_federation_imports.graphql +--- +[ + Message { + code: ConnectorsUnsupportedFederationDirective, + message: "The directive `@context` is not supported when using connectors.", + locations: [ + 6:7..6:17, + ], + }, + Message { + code: ConnectorsUnsupportedFederationDirective, + message: "The directive `@fromContext` is not supported when using connectors.", + locations: [ + 7:7..7:21, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@duplicate_source_name.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@duplicate_source_name.graphql.snap new file mode 100644 index 0000000000..498f7bc74d --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@duplicate_source_name.graphql.snap @@ -0,0 +1,15 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/duplicate_source_name.graphql +--- +[ + Message { + code: DuplicateSourceName, + message: "Every `@source(name:)` must be unique. Found duplicate name \"v1\".", + locations: [ + 6:3..6:61, + 7:3..7:61, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@duplicated_ids.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@duplicated_ids.graphql.snap new file mode 100644 index 0000000000..525b209902 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@duplicated_ids.graphql.snap @@ -0,0 +1,15 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/duplicated_ids.graphql +--- +[ + Message { + code: DuplicateIdName, + message: "`@connector` directive must have unique `id`. `duplicated_id` has 2 repetitions", + locations: [ + 8:13..8:28, + 17:13..17:28, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@empty_selection.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@empty_selection.graphql.snap new file mode 100644 index 0000000000..227f2cde53 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@empty_selection.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/empty_selection.graphql +--- +[ + Message { + code: InvalidSelection, + message: "`@connect(selection:)` on `Query.emptySelection` is empty", + locations: [ + 13:18..13:29, + ], + }, + Message { + code: InvalidBody, + message: "`@connect(http: {body:})` on `Query.emptySelection` is empty", + locations: [ + 12:41..12:45, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@empty_source_name.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@empty_source_name.graphql.snap new file mode 100644 index 0000000000..21a5be24aa --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@empty_source_name.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/empty_source_name.graphql +--- +[ + Message { + code: EmptySourceName, + message: "The value for `@source(name: \"\")` can't be empty.", + locations: [ + 6:17..6:19, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@env-vars.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@env-vars.graphql.snap new file mode 100644 index 0000000000..a6a667dfc6 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@env-vars.graphql.snap @@ -0,0 +1,35 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/env-vars.graphql +--- +[ + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)`: object values aren't valid here", + locations: [ + 19:36..19:40, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)`: `$env.foo` doesn't have a field named `bar`", + locations: [ + 20:45..20:48, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.invalidObject`: object values aren't valid here", + locations: [ + 36:44..36:48, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.invalidPath`: `$env.foo` doesn't have a field named `bar`", + locations: [ + 41:53..41:56, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@errors.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@errors.graphql.snap new file mode 100644 index 0000000000..407620e817 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@errors.graphql.snap @@ -0,0 +1,58 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/errors.graphql +--- +[ + Message { + code: InvalidErrorsMessage, + message: "In `@source(name: \"invalid_source_message_not_string\" errors.message:)`: object values aren't valid here", + locations: [ + 13:25..13:47, + ], + }, + Message { + code: InvalidErrorsMessage, + message: "In `@source(name: \"invalid_source_args_variable\" errors.extensions:)`: $args is not valid here, must be one of $config, $context, $status, $request, $response, $env", + locations: [ + 23:63..23:76, + ], + }, + Message { + code: InvalidErrorsMessage, + message: "In `@source(name: \"invalid_source_this_variable\" errors.extensions:)`: $this is not valid here, must be one of $config, $context, $status, $request, $response, $env", + locations: [ + 28:63..28:76, + ], + }, + Message { + code: InvalidErrorsMessage, + message: "`@connect(errors.message:)` on `Query.invalid_sourceless_empty_message` is empty", + locations: [ + 114:26..114:28, + ], + }, + Message { + code: InvalidErrorsMessage, + message: "`@connect(errors.extensions:)` on `Query.invalid_sourceless_empty_extensions` is empty", + locations: [ + 119:97..119:99, + ], + }, + Message { + code: InvalidErrorsMessage, + message: "In `@connect(errors.message:)` on `Query.invalid_sourceless_message_not_string`: object values aren't valid here", + locations: [ + 86:27..86:49, + ], + }, + Message { + code: InvalidErrorsMessage, + message: "In `@connect(errors.message:)` on `Query.invalid_sourceless_message_not_string_from_args`: number values aren't valid here", + locations: [ + 97:58..97:61, + 97:51..97:61, + 100:33..100:38, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@fields_with_arguments.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@fields_with_arguments.graphql.snap new file mode 100644 index 0000000000..ca19b48fec --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@fields_with_arguments.graphql.snap @@ -0,0 +1,15 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/fields_with_arguments.graphql +--- +[ + Message { + code: ConnectorsFieldWithArguments, + message: "`@connect(selection:)` on `Query.ts` selects field `T.field`, which has arguments. Only fields with a connector can have arguments.", + locations: [ + 11:7..11:12, + 18:3..18:29, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@group_selection_on_scalar.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@group_selection_on_scalar.graphql.snap new file mode 100644 index 0000000000..1d7f558d91 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@group_selection_on_scalar.graphql.snap @@ -0,0 +1,15 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/group_selection_on_scalar.graphql +--- +[ + Message { + code: GroupSelectionIsNotObject, + message: "`@connect(selection:)` on `Query.me` selects a group `id{}`, but `User.id` is of type `ID` which is not an object.", + locations: [ + 8:78..8:87, + 12:5..12:12, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__disallowed_header_names.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__disallowed_header_names.graphql.snap new file mode 100644 index 0000000000..e2bf51389e --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__disallowed_header_names.graphql.snap @@ -0,0 +1,182 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/headers/disallowed_header_names.graphql +--- +[ + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'connection' is reserved and cannot be set by a connector", + locations: [ + 11:17..11:29, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'proxy-authenticate' is reserved and cannot be set by a connector", + locations: [ + 12:17..12:37, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'proxy-authorization' is reserved and cannot be set by a connector", + locations: [ + 13:17..13:38, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'te' is reserved and cannot be set by a connector", + locations: [ + 14:17..14:21, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'trailer' is reserved and cannot be set by a connector", + locations: [ + 15:17..15:26, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'transfer-encoding' is reserved and cannot be set by a connector", + locations: [ + 16:17..16:36, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'upgrade' is reserved and cannot be set by a connector", + locations: [ + 17:17..17:26, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-length' is reserved and cannot be set by a connector", + locations: [ + 18:17..18:33, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-encoding' is reserved and cannot be set by a connector", + locations: [ + 19:17..19:35, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'host' can't be set with `from`, only with `value`", + locations: [ + 20:17..20:23, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'accept-encoding' is reserved and cannot be set by a connector", + locations: [ + 21:17..21:34, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-length' is reserved and cannot be set by a connector", + locations: [ + 22:17..22:33, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-type' can't be set with `from`, only with `value`", + locations: [ + 23:17..23:31, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'accept' can't be set with `from`, only with `value`", + locations: [ + 24:17..24:25, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'connection' is reserved and cannot be set by a connector", + locations: [ + 33:17..33:29, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'proxy-authenticate' is reserved and cannot be set by a connector", + locations: [ + 34:17..34:37, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'proxy-authorization' is reserved and cannot be set by a connector", + locations: [ + 35:17..35:38, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'te' is reserved and cannot be set by a connector", + locations: [ + 36:17..36:21, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'trailer' is reserved and cannot be set by a connector", + locations: [ + 37:17..37:26, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'transfer-encoding' is reserved and cannot be set by a connector", + locations: [ + 38:17..38:36, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'upgrade' is reserved and cannot be set by a connector", + locations: [ + 39:17..39:26, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-length' is reserved and cannot be set by a connector", + locations: [ + 40:17..40:33, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-encoding' is reserved and cannot be set by a connector", + locations: [ + 41:17..41:35, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'accept-encoding' is reserved and cannot be set by a connector", + locations: [ + 43:17..43:34, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-length' is reserved and cannot be set by a connector", + locations: [ + 44:17..44:33, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__disallowed_header_names_v0.2.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__disallowed_header_names_v0.2.graphql.snap new file mode 100644 index 0000000000..0bac5116b5 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__disallowed_header_names_v0.2.graphql.snap @@ -0,0 +1,182 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/headers/disallowed_header_names_v0.2.graphql +--- +[ + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'connection' is reserved and cannot be set by a connector", + locations: [ + 11:17..11:29, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'proxy-authenticate' is reserved and cannot be set by a connector", + locations: [ + 12:17..12:37, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'proxy-authorization' is reserved and cannot be set by a connector", + locations: [ + 13:17..13:38, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'te' is reserved and cannot be set by a connector", + locations: [ + 14:17..14:21, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'trailer' is reserved and cannot be set by a connector", + locations: [ + 15:17..15:26, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'transfer-encoding' is reserved and cannot be set by a connector", + locations: [ + 16:17..16:36, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'upgrade' is reserved and cannot be set by a connector", + locations: [ + 17:17..17:26, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-length' is reserved and cannot be set by a connector", + locations: [ + 18:17..18:33, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-encoding' is reserved and cannot be set by a connector", + locations: [ + 19:17..19:35, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'host' can't be set with `from`, only with `value`", + locations: [ + 20:17..20:23, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'accept-encoding' is reserved and cannot be set by a connector", + locations: [ + 21:17..21:34, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-length' is reserved and cannot be set by a connector", + locations: [ + 22:17..22:33, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-type' can't be set with `from`, only with `value`", + locations: [ + 23:17..23:31, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'accept' can't be set with `from`, only with `value`", + locations: [ + 24:17..24:25, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'connection' is reserved and cannot be set by a connector", + locations: [ + 33:17..33:29, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'proxy-authenticate' is reserved and cannot be set by a connector", + locations: [ + 34:17..34:37, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'proxy-authorization' is reserved and cannot be set by a connector", + locations: [ + 35:17..35:38, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'te' is reserved and cannot be set by a connector", + locations: [ + 36:17..36:21, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'trailer' is reserved and cannot be set by a connector", + locations: [ + 37:17..37:26, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'transfer-encoding' is reserved and cannot be set by a connector", + locations: [ + 38:17..38:36, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'upgrade' is reserved and cannot be set by a connector", + locations: [ + 39:17..39:26, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-length' is reserved and cannot be set by a connector", + locations: [ + 40:17..40:33, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-encoding' is reserved and cannot be set by a connector", + locations: [ + 41:17..41:35, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'accept-encoding' is reserved and cannot be set by a connector", + locations: [ + 42:17..42:34, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-length' is reserved and cannot be set by a connector", + locations: [ + 43:17..43:33, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__expressions_that_evaluate_to_invalid_types_v0_2.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__expressions_that_evaluate_to_invalid_types_v0_2.graphql.snap new file mode 100644 index 0000000000..02db9b9804 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__expressions_that_evaluate_to_invalid_types_v0_2.graphql.snap @@ -0,0 +1,23 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/headers/expressions_that_evaluate_to_invalid_types.graphql +--- +[ + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)`: object values aren't valid here", + locations: [ + 12:45..12:47, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.blah`: array values aren't valid here", + locations: [ + 19:10..19:27, + 19:10..19:27, + 21:73..21:80, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__expressions_that_evaluate_to_invalid_types_v0_3.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__expressions_that_evaluate_to_invalid_types_v0_3.graphql.snap new file mode 100644 index 0000000000..96342e07c8 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__expressions_that_evaluate_to_invalid_types_v0_3.graphql.snap @@ -0,0 +1,38 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/headers/expressions_that_evaluate_to_invalid_types_v0_3.graphql +--- +[ + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)`: array values aren't valid here", + locations: [ + 11:50..11:52, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)`: object values aren't valid here", + locations: [ + 12:45..12:47, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)`: object values aren't valid here", + locations: [ + 13:73..13:75, + 13:55..13:60, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.blah`: array values aren't valid here", + locations: [ + 19:10..19:27, + 19:10..19:27, + 21:73..21:80, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_connect_http_headers.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_connect_http_headers.graphql.snap new file mode 100644 index 0000000000..fd73b3c5f1 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_connect_http_headers.graphql.snap @@ -0,0 +1,72 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/headers/invalid_connect_http_headers.graphql +--- +[ + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.resources` either `from` or `value` must be set", + locations: [ + 12:11..12:39, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.resources` missing `name` field", + locations: [ + 13:11..13:37, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.resources` `from` and `value` can't be set at the same time", + locations: [ + 14:37..14:41, + 14:61..14:66, + ], + }, + Message { + code: HttpHeaderNameCollision, + message: "Duplicate header names are not allowed. The header name 'x-name-collision' at `@connect(http.headers:)` on `Query.resources` is already defined.", + locations: [ + 16:19..16:37, + 15:19..15:37, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.resources` the value `` is an invalid HTTP header name", + locations: [ + 17:19..17:37, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.resources` the value `` is an invalid HTTP header name", + locations: [ + 18:43..18:61, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.resources` header 'content-length' is reserved and cannot be set by a connector", + locations: [ + 23:19..23:35, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.resources` header 'content-type' can't be set with `from`, only with `value`", + locations: [ + 24:19..24:33, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.resources` header 'accept' can't be set with `from`, only with `value`", + locations: [ + 25:19..25:27, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_namespace_in_header_variables.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_namespace_in_header_variables.graphql.snap new file mode 100644 index 0000000000..c50f38ebdd --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_namespace_in_header_variables.graphql.snap @@ -0,0 +1,56 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/headers/invalid_namespace_in_header_variables.graphql +--- +[ + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)`: unknown variable `$foo`, must be one of $config, $context, $request, $env", + locations: [ + 11:49..11:53, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)`: $this is not valid here, must be one of $config, $context, $request, $env", + locations: [ + 12:62..12:71, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)`: `config.bar` must start with one of $config, $context, $request, $env", + locations: [ + 13:56..13:62, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.scalar`: unknown variable `$foo`, must be one of $args, $config, $context, $request, $env", + locations: [ + 24:49..24:53, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.scalar`: $status is not valid here, must be one of $args, $config, $context, $request, $env", + locations: [ + 25:62..25:69, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.scalar`: $this is not valid here, must be one of $args, $config, $context, $request, $env", + locations: [ + 26:47..26:52, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.scalar`: `config.bar` must start with one of $args, $config, $context, $request, $env", + locations: [ + 27:56..27:62, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_nested_paths_in_header_variables.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_nested_paths_in_header_variables.graphql.snap new file mode 100644 index 0000000000..7ab5a371b3 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_nested_paths_in_header_variables.graphql.snap @@ -0,0 +1,35 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/headers/invalid_nested_paths_in_header_variables.graphql +--- +[ + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.scalar`: `$args.scalar` doesn't have a field named `blah`", + locations: [ + 13:60..13:64, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.object`: `InputObject` doesn't have a field named `fieldThatDoesntExist`", + locations: [ + 23:59..23:79, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.enum`: `Enum` doesn't have a field named `cantHaveFields`", + locations: [ + 33:56..33:70, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Object.newField`: `$this` doesn't have a field named `fieldThatDoesntExist`", + locations: [ + 47:53..47:73, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_source_http_headers.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_source_http_headers.graphql.snap new file mode 100644 index 0000000000..ff882eb374 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@headers__invalid_source_http_headers.graphql.snap @@ -0,0 +1,65 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/headers/invalid_source_http_headers.graphql +--- +[ + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` either `from` or `value` must be set", + locations: [ + 13:9..13:37, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` missing `name` field", + locations: [ + 14:9..14:35, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` `from` and `value` can't be set at the same time", + locations: [ + 15:35..15:39, + 15:59..15:64, + ], + }, + Message { + code: HttpHeaderNameCollision, + message: "Duplicate header names are not allowed. The header name 'x-name-collision' at `@source(http.headers:)` is already defined.", + locations: [ + 17:17..17:35, + 16:17..16:35, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` the value `` is an invalid HTTP header name", + locations: [ + 18:17..18:35, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` the value `` is an invalid HTTP header name", + locations: [ + 19:41..19:59, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-length' is reserved and cannot be set by a connector", + locations: [ + 24:17..24:33, + ], + }, + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)` header 'content-type' can't be set with `from`, only with `value`", + locations: [ + 25:17..25:31, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_chars_in_source_name.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_chars_in_source_name.graphql.snap new file mode 100644 index 0000000000..22f4138a4d --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_chars_in_source_name.graphql.snap @@ -0,0 +1,35 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/invalid_chars_in_source_name.graphql +--- +[ + Message { + code: InvalidSourceName, + message: "`@source(name: \"u$ers\")` can't contain `$`; only ASCII letters, numbers, underscores, or hyphens are allowed", + locations: [ + 6:17..6:24, + ], + }, + Message { + code: InvalidSourceName, + message: "`@source(name: \"1\")` is invalid; source names must start with an ASCII letter (a-z or A-Z)", + locations: [ + 7:17..7:20, + ], + }, + Message { + code: InvalidSourceName, + message: "`@source(name: \"no.dots\")` can't contain `.`; only ASCII letters, numbers, underscores, or hyphens are allowed", + locations: [ + 8:17..8:26, + ], + }, + Message { + code: InvalidSourceName, + message: "`@source(name: \"areallylongnamethatisoversixtythreecharacterstakesalongwhiletotypebutthisshoulddoit\")` is invalid; source names must be 64 characters or fewer", + locations: [ + 10:11..10:96, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_id_name.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_id_name.graphql.snap new file mode 100644 index 0000000000..9a999290a9 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_id_name.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/invalid_id_name.graphql +--- +[ + Message { + code: InvalidConnectorIdName, + message: "`invalid.id` is not a valid GraphQL name", + locations: [ + 10:18..10:30, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_namespace_in_body_selection.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_namespace_in_body_selection.graphql.snap new file mode 100644 index 0000000000..50d93f1e73 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_namespace_in_body_selection.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/invalid_namespace_in_body_selection.graphql +--- +[ + Message { + code: InvalidBody, + message: "In `@connect(http: {body:})` on `Mutation.createUser`: $status is not valid here, must be one of $args, $config, $context, $request, $env", + locations: [ + 21:17..21:24, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_nested_paths_in_json_selection.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_nested_paths_in_json_selection.graphql.snap new file mode 100644 index 0000000000..81982309d6 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_nested_paths_in_json_selection.graphql.snap @@ -0,0 +1,35 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/invalid_nested_paths_in_json_selection.graphql +--- +[ + Message { + code: InvalidBody, + message: "In `@connect(http: {body:})` on `Query.scalar`: `$args.scalar` doesn't have a field named `blah`", + locations: [ + 12:34..12:38, + ], + }, + Message { + code: InvalidBody, + message: "In `@connect(http: {body:})` on `Query.object`: `InputObject` doesn't have a field named `fieldThatDoesntExist`", + locations: [ + 20:33..20:53, + ], + }, + Message { + code: InvalidBody, + message: "In `@connect(http: {body:})` on `Query.enum`: `Enum` doesn't have a field named `cantHaveFields`", + locations: [ + 28:30..28:44, + ], + }, + Message { + code: InvalidBody, + message: "In `@connect(http: {body:})` on `Object.newField`: `$this` doesn't have a field named `fieldThatDoesntExist`", + locations: [ + 40:27..40:47, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_selection_syntax.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_selection_syntax.graphql.snap new file mode 100644 index 0000000000..12dd3dc547 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@invalid_selection_syntax.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/invalid_selection_syntax.graphql +--- +[ + Message { + code: InvalidSelection, + message: "`@connect(selection:)` on `Query.something` is not valid: nom::error::ErrorKind::Eof: &how", + locations: [ + 8:87..8:88, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_connect_happy_path.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_connect_happy_path.graphql.snap new file mode 100644 index 0000000000..0e5f533051 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_connect_happy_path.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/is_success/is_success_connect_happy_path.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_invalid_connect.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_invalid_connect.graphql.snap new file mode 100644 index 0000000000..137ac6187e --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_invalid_connect.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/is_success/is_success_invalid_connect.graphql +--- +[ + Message { + code: InvalidIsSuccess, + message: "In `@connect(isSuccess:)` on `Query.users`: $this is not valid here, must be one of $args, $config, $context, $status, $request, $response, $env", + locations: [ + 21:19..21:73, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_invalid_sources.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_invalid_sources.graphql.snap new file mode 100644 index 0000000000..093762ab9b --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_invalid_sources.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/is_success/is_success_invalid_sources.graphql +--- +[ + Message { + code: InvalidIsSuccess, + message: "In `@source(name: \"using_disallowed_var\" isSuccess:)`: $this is not valid here, must be one of $config, $context, $status, $request, $response, $env", + locations: [ + 13:17..13:39, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_source_happy_path.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_source_happy_path.graphql.snap new file mode 100644 index 0000000000..e67ca77e8e --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@is_success__is_success_source_happy_path.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/is_success/is_success_source_happy_path.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__arg_is_object_but_field_is_not.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__arg_is_object_but_field_is_not.graphql.snap new file mode 100644 index 0000000000..f4628c3c05 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__arg_is_object_but_field_is_not.graphql.snap @@ -0,0 +1,15 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/arg_is_object_but_field_is_not.graphql +--- +[ + Message { + code: EntityResolverArgumentMismatch, + message: "`Query.product(id:)` is of type `ProductInput`, but must match `Product.id` of type `ID` because `entity` is `true`.", + locations: [ + 6:11..6:28, + 10:7..10:19, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__arg_type_doesnt_match_field_type.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__arg_type_doesnt_match_field_type.graphql.snap new file mode 100644 index 0000000000..6d2fbdb0d4 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__arg_type_doesnt_match_field_type.graphql.snap @@ -0,0 +1,15 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/arg_type_doesnt_match_field_type.graphql +--- +[ + Message { + code: EntityResolverArgumentMismatch, + message: "`Query.product(id:)` is of type `String`, but must match `Product.id` of type `ID` because `entity` is `true`.", + locations: [ + 6:11..6:22, + 10:7..10:19, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__composite_key_doesnt_match.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__composite_key_doesnt_match.graphql.snap new file mode 100644 index 0000000000..8b2c14df82 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__composite_key_doesnt_match.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/composite_key_doesnt_match.graphql +--- +[ + Message { + code: MissingEntityConnector, + message: "Entity resolution for `@key(fields: \"id store { id country { key_id region } }\")` on `Product` is not implemented by a connector. See https://go.apollo.dev/connectors/entity-rules", + locations: [ + 17:14..17:71, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_arg_field_arg_name_mismatch.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_arg_field_arg_name_mismatch.graphql.snap new file mode 100644 index 0000000000..ec72d3b605 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_arg_field_arg_name_mismatch.graphql.snap @@ -0,0 +1,15 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_arg_field_arg_name_mismatch.graphql +--- +[ + Message { + code: EntityResolverArgumentMismatch, + message: "`Query.product` has invalid arguments. Argument `id` does not have a matching field `id` on type `Product`.", + locations: [ + 6:11..6:18, + 10:7..10:19, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_arg_field_arg_name_mismatch_composite_key.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_arg_field_arg_name_mismatch_composite_key.graphql.snap new file mode 100644 index 0000000000..bc75804720 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_arg_field_arg_name_mismatch_composite_key.graphql.snap @@ -0,0 +1,15 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_arg_field_arg_name_mismatch_composite_key.graphql +--- +[ + Message { + code: EntityResolverArgumentMismatch, + message: "`Query.product` has invalid arguments. Field `id` on `CountryInput` does not have a matching field `id` on `Country`.", + locations: [ + 42:3..42:10, + 12:7..12:19, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_false_on_type.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_false_on_type.graphql.snap new file mode 100644 index 0000000000..eed327653b --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_false_on_type.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_false_on_type.graphql +--- +[ + Message { + code: ConnectOnTypeMustBeEntity, + message: "`@connect(entity: false)` on `User` is invalid. `entity` can't be false for connectors on types.", + locations: [ + 7:5..7:18, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_on_list_type.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_on_list_type.graphql.snap new file mode 100644 index 0000000000..2a54f27d48 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_on_list_type.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_on_list_type.graphql +--- +[ + Message { + code: EntityTypeInvalid, + message: "`@connect(entity: true)` on `Query.users` is invalid. Entity connectors must return non-list, nullable, object types. See https://go.apollo.dev/connectors/entity-rules", + locations: [ + 8:7..8:19, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_on_non_root_field.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_on_non_root_field.graphql.snap new file mode 100644 index 0000000000..2f866a21cb --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_on_non_root_field.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_on_non_root_field.graphql +--- +[ + Message { + code: EntityNotOnRootQuery, + message: "`@connect(entity: true)` on `User.favoriteColor` is invalid. Entity resolvers can only be declared on root `Query` fields.", + locations: [ + 19:7..19:19, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_returning_non_null_type.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_returning_non_null_type.graphql.snap new file mode 100644 index 0000000000..b8a1927664 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_returning_non_null_type.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_returning_non_null_type.graphql +--- +[ + Message { + code: EntityTypeInvalid, + message: "`@connect(entity: true)` on `Query.user` is invalid. Entity connectors must return non-list, nullable, object types. See https://go.apollo.dev/connectors/entity-rules", + locations: [ + 8:7..8:19, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_returning_scalar.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_returning_scalar.graphql.snap new file mode 100644 index 0000000000..92b863c8a9 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__entity_true_returning_scalar.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_returning_scalar.graphql +--- +[ + Message { + code: EntityTypeInvalid, + message: "`@connect(entity: true)` on `Query.name` is invalid. Entity connectors must return object types.", + locations: [ + 8:7..8:19, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__mismatch_batch.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__mismatch_batch.graphql.snap new file mode 100644 index 0000000000..63d13fb718 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__mismatch_batch.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/mismatch_batch.graphql +--- +[ + Message { + code: InvalidBody, + message: "In `@connect(http: {body:})` on `Product`: `$batch` doesn't have a field named `id`", + locations: [ + 7:59..7:61, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__mismatch_composite_key_batch.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__mismatch_composite_key_batch.graphql.snap new file mode 100644 index 0000000000..3638b12dd0 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__mismatch_composite_key_batch.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/mismatch_composite_key_batch.graphql +--- +[ + Message { + code: InvalidBody, + message: "In `@connect(http: {body:})` on `Product`: `Country` doesn't have a field named `id`", + locations: [ + 13:41..13:43, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__multiple_keys_not_all_resolved.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__multiple_keys_not_all_resolved.graphql.snap new file mode 100644 index 0000000000..01b0ac15b8 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__multiple_keys_not_all_resolved.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/multiple_keys_not_all_resolved.graphql +--- +[ + Message { + code: MissingEntityConnector, + message: "Entity resolution for `@key(fields: \"id store { id country { key_id region } }\")` on `Product` is not implemented by a connector. See https://go.apollo.dev/connectors/entity-rules", + locations: [ + 19:3..19:60, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__multiple_keys_not_all_resolved_batch.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__multiple_keys_not_all_resolved_batch.graphql.snap new file mode 100644 index 0000000000..ae0949ebb4 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__multiple_keys_not_all_resolved_batch.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/multiple_keys_not_all_resolved_batch.graphql +--- +[ + Message { + code: MissingEntityConnector, + message: "Entity resolution for `@key(fields: \"id store { id country { key_id region } }\")` on `Product` is not implemented by a connector. See https://go.apollo.dev/connectors/entity-rules", + locations: [ + 8:3..8:60, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__no_args_for_entity_true.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__no_args_for_entity_true.graphql.snap new file mode 100644 index 0000000000..4cbfadf6ac --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__no_args_for_entity_true.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/no_args_for_entity_true.graphql +--- +[ + Message { + code: EntityResolverArgumentMismatch, + message: "`Query.product` must have arguments when using `entity: true`. See https://go.apollo.dev/connectors/entity-rules", + locations: [ + 10:7..10:19, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__unrelated_keys.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__unrelated_keys.graphql.snap new file mode 100644 index 0000000000..fc1c25f76c --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__invalid__unrelated_keys.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/unrelated_keys.graphql +--- +[ + Message { + code: MissingEntityConnector, + message: "Entity resolution for `@key(fields: \"id\")` on `Store` is not implemented by a connector. See https://go.apollo.dev/connectors/entity-rules", + locations: [ + 21:12..21:30, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__basic_implicit_key.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__basic_implicit_key.graphql.snap new file mode 100644 index 0000000000..ede6d2d865 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__basic_implicit_key.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/basic_implicit_key.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__basic_implicit_key_batch.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__basic_implicit_key_batch.graphql.snap new file mode 100644 index 0000000000..af80232bd6 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__basic_implicit_key_batch.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/basic_implicit_key_batch.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_non_resolvable_key.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_non_resolvable_key.graphql.snap new file mode 100644 index 0000000000..9a89d58974 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_non_resolvable_key.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_non_resolvable_key.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_non_resolvable_key_batch.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_non_resolvable_key_batch.graphql.snap new file mode 100644 index 0000000000..074095caae --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_non_resolvable_key_batch.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_non_resolvable_key_batch.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_one_of_multiple_keys.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_one_of_multiple_keys.graphql.snap new file mode 100644 index 0000000000..3cb5505e83 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_one_of_multiple_keys.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_one_of_multiple_keys.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_one_of_multiple_keys_batch.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_one_of_multiple_keys_batch.graphql.snap new file mode 100644 index 0000000000..c310ad6a45 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_connector_matches_one_of_multiple_keys_batch.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_one_of_multiple_keys_batch.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_field_counts_as_key_resolver.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_field_counts_as_key_resolver.graphql.snap new file mode 100644 index 0000000000..6b96177b78 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_field_counts_as_key_resolver.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_field_counts_as_key_resolver.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_field_counts_as_key_resolver_batch.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_field_counts_as_key_resolver_batch.graphql.snap new file mode 100644 index 0000000000..5898cd27d2 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_field_counts_as_key_resolver_batch.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_field_counts_as_key_resolver_batch.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_true_on_type.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_true_on_type.graphql.snap new file mode 100644 index 0000000000..257a00740d --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__entity_true_on_type.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_true_on_type.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__mix_explicit_and_implicit.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__mix_explicit_and_implicit.graphql.snap new file mode 100644 index 0000000000..947defd6e4 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__mix_explicit_and_implicit.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/mix_explicit_and_implicit.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__mix_explicit_and_implicit_batch.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__mix_explicit_and_implicit_batch.graphql.snap new file mode 100644 index 0000000000..050f66f6d6 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__mix_explicit_and_implicit_batch.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/mix_explicit_and_implicit_batch.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__multiple_entity_connectors_for_multiple_keys.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__multiple_entity_connectors_for_multiple_keys.graphql.snap new file mode 100644 index 0000000000..c5a91a1dac --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__multiple_entity_connectors_for_multiple_keys.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/multiple_entity_connectors_for_multiple_keys.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__multiple_entity_connectors_for_multiple_keys_batch.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__multiple_entity_connectors_for_multiple_keys_batch.graphql.snap new file mode 100644 index 0000000000..6c59167b6a --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@keys_and_entities__valid__multiple_entity_connectors_for_multiple_keys_batch.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/multiple_entity_connectors_for_multiple_keys_batch.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_connect_on_mutation_field.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_connect_on_mutation_field.graphql.snap new file mode 100644 index 0000000000..0330018be8 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_connect_on_mutation_field.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/missing_connect_on_mutation_field.graphql +--- +[ + Message { + code: ConnectorsUnresolvedField, + message: "No connector resolves field `Mutation.setMessage`. It must have a `@connect` directive or appear in `@connect(selection:)`.", + locations: [ + 9:3..9:38, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_connect_on_query_field.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_connect_on_query_field.graphql.snap new file mode 100644 index 0000000000..e13eb53cfb --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_connect_on_query_field.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/missing_connect_on_query_field.graphql +--- +[ + Message { + code: ConnectorsUnresolvedField, + message: "No connector resolves field `Query.resources`. It must have a `@connect` directive or appear in `@connect(selection:)`.", + locations: [ + 9:5..9:26, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_http_method_on_connect.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_http_method_on_connect.graphql.snap new file mode 100644 index 0000000000..a8e5e68118 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_http_method_on_connect.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/missing_http_method_on_connect.graphql +--- +[ + Message { + code: MissingHttpMethod, + message: "`@connect(http:)` on `Query.resources` must specify an HTTP method.", + locations: [ + 9:54..9:56, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_source_import.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_source_import.graphql.snap new file mode 100644 index 0000000000..4b48a019dc --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@missing_source_import.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/missing_source_import.graphql +--- +[ + Message { + code: NoSourcesDefined, + message: "`@connect(source: \"v2\")` on `Query.resources` specifies a source, but none are defined. Try adding `@connect__source(name: \"v2\")` to the schema.", + locations: [ + 7:22..7:26, + ], + }, + Message { + code: NoSourceImport, + message: "The `@source` directive is not imported. Try adding `@source` to `import` for `@link(url: \"https://specs.apollo.dev/connect/v0.2\", import: [\"@connect\"])`", + locations: [ + 2:3..2:76, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@multiple_errors.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@multiple_errors.graphql.snap new file mode 100644 index 0000000000..a9cf336b74 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@multiple_errors.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/multiple_errors.graphql +--- +[ + Message { + code: InvalidSourceName, + message: "`@source(name: \"u$ers\")` can't contain `$`; only ASCII letters, numbers, underscores, or hyphens are allowed", + locations: [ + 6:17..6:24, + ], + }, + Message { + code: InvalidUrlScheme, + message: "The value \"ftp://127.0.0.1\" for `@source(baseURL:)` must be http or https, got ftp.", + locations: [ + 6:44..6:47, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@multiple_http_methods_on_connect.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@multiple_http_methods_on_connect.graphql.snap new file mode 100644 index 0000000000..b3c4767bad --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@multiple_http_methods_on_connect.graphql.snap @@ -0,0 +1,15 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/multiple_http_methods_on_connect.graphql +--- +[ + Message { + code: MultipleHttpMethods, + message: "`@connect(http:)` on `Query.resources` cannot specify more than one HTTP method.", + locations: [ + 12:20..12:32, + 12:42..12:53, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@non_root_circular_reference.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@non_root_circular_reference.graphql.snap new file mode 100644 index 0000000000..3763b7a10d --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@non_root_circular_reference.graphql.snap @@ -0,0 +1,16 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/non_root_circular_reference.graphql +--- +[ + Message { + code: CircularReference, + message: "Circular reference detected in `@connect(selection:)` on `Query.user`: type `Book` appears more than once in `Query.user.favoriteBooks.author.books`. For more information, see https://go.apollo.dev/connectors/limitations#circular-references", + locations: [ + 19:15..21:16, + 31:3..31:24, + 41:3..41:16, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@question_v0_2.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@question_v0_2.graphql.snap new file mode 100644 index 0000000000..3f7deed690 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@question_v0_2.graphql.snap @@ -0,0 +1,28 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/question_v0_2.graphql +--- +[ + Message { + code: InvalidSelection, + message: "`@connect(selection:)` on `Query.valid` is not valid: nom::error::ErrorKind::Eof: ?\notherField: a?.b\notherField2: c?->d\notherField3: e? {\n simpleField\n}", + locations: [ + 19:31..19:32, + ], + }, + Message { + code: InvalidSelection, + message: "`@connect(selection:)` on `Query.invalid_selection_dollar` is not valid: Named path selection must either begin with alias or ..., or end with subselection: $?", + locations: [ + 29:96..29:97, + ], + }, + Message { + code: InvalidSelection, + message: "`@connect(selection:)` on `Query.invalid_question_subselection` is not valid: nom::error::ErrorKind::Eof: ? {\n b\n}?", + locations: [ + 35:11..35:12, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@question_v0_3.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@question_v0_3.graphql.snap new file mode 100644 index 0000000000..0eacb5c277 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@question_v0_3.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/question_v0_3.graphql +--- +[ + Message { + code: InvalidSelection, + message: "`@connect(selection:)` on `Query.invalid_question_subselection` is not valid: nom::error::ErrorKind::Eof: ?", + locations: [ + 38:8..38:9, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@renamed_connect_directive.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@renamed_connect_directive.graphql.snap new file mode 100644 index 0000000000..5499c7c143 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@renamed_connect_directive.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/renamed_connect_directive.graphql +--- +[ + Message { + code: ConnectorsUnresolvedField, + message: "No connector resolves field `Query.resources`. It must have a `@data` directive or appear in `@data(selection:)`.", + locations: [ + 9:5..9:26, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@request_headers.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@request_headers.graphql.snap new file mode 100644 index 0000000000..90a84daf57 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@request_headers.graphql.snap @@ -0,0 +1,59 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/request_headers.graphql +--- +[ + Message { + code: InvalidHeader, + message: "In `@source(http.headers:)`: array values aren't valid here", + locations: [ + 5:101..5:111, + 5:101..5:111, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.failOnArray`: array values aren't valid here", + locations: [ + 28:68..28:78, + 28:68..28:78, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.failOnArray`: array values aren't valid here", + locations: [ + 27:61..27:71, + 27:61..27:71, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.failOnInvalidRequestProperty`: `$request` doesn't have a field named `x`", + locations: [ + 36:60..36:61, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.failOnInvalidRequestProperty`: `$request` doesn't have a field named `x`", + locations: [ + 35:53..35:54, + ], + }, + Message { + code: InvalidHeader, + message: "In `@connect(http.headers:)` on `Query.failOnInvalidObject`: object values aren't valid here", + locations: [ + 44:60..44:67, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.failOnInvalidObject`: object values aren't valid here", + locations: [ + 43:53..43:60, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@select_nonexistant_group.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@select_nonexistant_group.graphql.snap new file mode 100644 index 0000000000..3e2d13e5ea --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@select_nonexistant_group.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/select_nonexistant_group.graphql +--- +[ + Message { + code: SelectedFieldNotFound, + message: "`@connect(selection:)` on `Query.me` contains field `group`, which does not exist on `User`.", + locations: [ + 8:81..8:93, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@source_directive_rename.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@source_directive_rename.graphql.snap new file mode 100644 index 0000000000..d767778c5f --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@source_directive_rename.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/source_directive_rename.graphql +--- +[ + Message { + code: InvalidUrlScheme, + message: "Base URL for `@api(baseURL:)` did not start with http:// or https://.", + locations: [ + 6:40..6:54, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@subscriptions_with_connectors.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@subscriptions_with_connectors.graphql.snap new file mode 100644 index 0000000000..027cd2c733 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@subscriptions_with_connectors.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/subscriptions_with_connectors.graphql +--- +[ + Message { + code: SubscriptionInConnectors, + message: "A subscription root type is not supported when using `@connect`.", + locations: [ + 13:6..13:18, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@transformed__upgrade_0.1.graphql-2.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@transformed__upgrade_0.1.graphql-2.snap new file mode 100644 index 0000000000..86872f2d23 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@transformed__upgrade_0.1.graphql-2.snap @@ -0,0 +1,7 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "&diff::lines(&schema,\n&result.transformed).into_iter().filter_map(|res| match res\n{\n diff::Result::Left(line) => Some(format!(\"- {line}\")),\n diff::Result::Right(line) => Some(format!(\"+ {line}\")),\n diff::Result::Both(_, _) => None,\n}).join(\"\\n\")" +input_file: apollo-federation/src/connectors/validation/test_data/transformed/upgrade_0.1.graphql +--- +- url: "https://specs.apollo.dev/connect/v0.1", ++ url: "https://specs.apollo.dev/connect/v0.2", diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@transformed__upgrade_0.1.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@transformed__upgrade_0.1.graphql.snap new file mode 100644 index 0000000000..ded855768f --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@transformed__upgrade_0.1.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/transformed/upgrade_0.1.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__absolute_connect_url_with_source.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__absolute_connect_url_with_source.graphql.snap new file mode 100644 index 0000000000..98e56a5095 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__absolute_connect_url_with_source.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/absolute_connect_url_with_source.graphql +--- +[ + Message { + code: AbsoluteConnectUrlWithSource, + message: "`GET` in `@connect(http:)` on `Query.resources` contains the absolute URL \"http://127.0.0.1/resources\" while also specifying a `source`. Either remove the `source` argument or change the URL to be relative.", + locations: [ + 12:20..12:48, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__expressions-in-domain.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__expressions-in-domain.graphql.snap new file mode 100644 index 0000000000..b748fe8128 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__expressions-in-domain.graphql.snap @@ -0,0 +1,35 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/expressions-in-domain.graphql +--- +[ + Message { + code: InvalidUrl, + message: "`GET` in `@connect(http:)` on `Query.entireDomain` must not contain dynamic pieces in the domain section (before the first `/` or `?`).", + locations: [ + 10:29..10:47, + ], + }, + Message { + code: InvalidUrl, + message: "`GET` in `@connect(http:)` on `Query.endOfDomain` must not contain dynamic pieces in the domain section (before the first `/` or `?`).", + locations: [ + 15:40..15:58, + ], + }, + Message { + code: InvalidUrl, + message: "`GET` in `@connect(http:)` on `Query.startOfDomain` must not contain dynamic pieces in the domain section (before the first `/` or `?`).", + locations: [ + 20:29..20:47, + ], + }, + Message { + code: InvalidUrl, + message: "`GET` in `@connect(http:)` on `Query.middleOfDomain` must not contain dynamic pieces in the domain section (before the first `/` or `?`).", + locations: [ + 25:36..25:54, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid-jsonselection-in-expression.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid-jsonselection-in-expression.graphql.snap new file mode 100644 index 0000000000..8fd010ee47 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid-jsonselection-in-expression.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/invalid-jsonselection-in-expression.graphql +--- +[ + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.resources`: nom::error::ErrorKind::Eof", + locations: [ + 12:27..12:28, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid-path-parameter.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid-path-parameter.graphql.snap new file mode 100644 index 0000000000..ce6ac57962 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid-path-parameter.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/invalid-path-parameter.graphql +--- +[ + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.resources`: unknown variable `$blah`, must be one of $args, $config, $context, $request, $env", + locations: [ + 12:23..12:28, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_connect_url.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_connect_url.graphql.snap new file mode 100644 index 0000000000..259a15aa9c --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_connect_url.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_connect_url.graphql +--- +[ + Message { + code: RelativeConnectUrlWithoutSource, + message: "`GET` in `@connect(http:)` on `Query.resources` specifies the relative URL \"127.0.0.1\", but no `source` is defined. Either use an absolute URL including scheme (e.g. https://), or add a `@connect__source`.", + locations: [ + 5:47..5:58, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_connect_url_scheme.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_connect_url_scheme.graphql.snap new file mode 100644 index 0000000000..e9d9e55e24 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_connect_url_scheme.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_connect_url_scheme.graphql +--- +[ + Message { + code: InvalidUrlScheme, + message: "The value \"file://data.json\" for `GET` in `@connect(http:)` on `Query.resources` must be http or https, got file.", + locations: [ + 6:28..6:32, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_namespace_in_url_template_variables.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_namespace_in_url_template_variables.graphql.snap new file mode 100644 index 0000000000..ad1adeafd1 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_namespace_in_url_template_variables.graphql.snap @@ -0,0 +1,28 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_namespace_in_url_template_variables.graphql +--- +[ + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.unknown`: unknown variable `$foo`, must be one of $args, $config, $context, $request, $env", + locations: [ + 11:31..11:35, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.invalid`: $status is not valid here, must be one of $args, $config, $context, $request, $env", + locations: [ + 18:31..18:42, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.nodollar`: `config.bar` must start with one of $args, $config, $context, $request, $env", + locations: [ + 25:31..25:37, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_nested_paths_in_url_template_variables.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_nested_paths_in_url_template_variables.graphql.snap new file mode 100644 index 0000000000..e4be53be04 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_nested_paths_in_url_template_variables.graphql.snap @@ -0,0 +1,35 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_nested_paths_in_url_template_variables.graphql +--- +[ + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.scalar`: `$args.scalar` doesn't have a field named `blah`", + locations: [ + 10:62..10:66, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.object`: `InputObject` doesn't have a field named `fieldThatDoesntExist`", + locations: [ + 15:61..15:81, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.enum`: `Enum` doesn't have a field named `cantHaveFields`", + locations: [ + 20:58..20:72, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Object.newField`: `$this` doesn't have a field named `fieldThatDoesntExist`", + locations: [ + 29:55..29:75, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_source_url.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_source_url.graphql.snap new file mode 100644 index 0000000000..d7b32f3544 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_source_url.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url.graphql +--- +[ + Message { + code: InvalidUrlScheme, + message: "Base URL for `@source(baseURL:)` did not start with http:// or https://.", + locations: [ + 6:40..6:51, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_source_url_scheme.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_source_url_scheme.graphql.snap new file mode 100644 index 0000000000..1f5cb1ef07 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_source_url_scheme.graphql.snap @@ -0,0 +1,14 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url_scheme.graphql +--- +[ + Message { + code: InvalidUrlScheme, + message: "The value \"file://data.json\" for `@source(baseURL:)` must be http or https, got file.", + locations: [ + 6:41..6:45, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_source_url_template.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_source_url_template.graphql.snap new file mode 100644 index 0000000000..91a811c024 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_source_url_template.graphql.snap @@ -0,0 +1,28 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url_template.graphql +--- +[ + Message { + code: InvalidUrl, + message: "Invalid @source `baseURL` template: $args is not valid here, must be one of $config, $env", + locations: [ + 6:50..6:59, + ], + }, + Message { + code: InvalidUrl, + message: "Invalid @source `baseURL` template: $request is not valid here, must be one of $config, $env", + locations: [ + 6:62..6:75, + ], + }, + Message { + code: InvalidUrl, + message: "Invalid @source `baseURL` template: object values aren't valid here", + locations: [ + 7:52..7:54, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_types.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_types.graphql.snap new file mode 100644 index 0000000000..39dfd0b78e --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__invalid_types.graphql.snap @@ -0,0 +1,45 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_types.graphql +--- +[ + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.argIsArray`: array values aren't valid here", + locations: [ + 8:16..8:29, + 8:16..8:29, + 10:47..10:50, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.argIsObject`: object values aren't valid here", + locations: [ + 36:1..38:2, + 14:22..14:27, + 14:17..14:27, + 16:47..16:50, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `This.thisIsArray`: array values aren't valid here", + locations: [ + 22:5..22:32, + 22:5..22:32, + 25:47..25:54, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `This.requiresAnObject`: object values aren't valid here", + locations: [ + 40:1..42:2, + 28:15..28:21, + 28:5..28:31, + 31:51..31:59, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__relative_connect_url_without_source.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__relative_connect_url_without_source.graphql.snap new file mode 100644 index 0000000000..7427381bc6 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__relative_connect_url_without_source.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/relative_connect_url_without_source.graphql +--- +[ + Message { + code: RelativeConnectUrlWithoutSource, + message: "`GET` in `@connect(http:)` on `Query.resources` specifies the relative URL \"/resources\", but no `source` is defined. Either use an absolute URL including scheme (e.g. https://), or add a `@connect__source`.", + locations: [ + 5:47..5:59, + ], + }, + Message { + code: RelativeConnectUrlWithoutSource, + message: "`GET` in `@connect(http:)` on `Query.dynamic` specifies the relative URL \"{$dynamic}\", but no `source` is defined. Either use an absolute URL including scheme (e.g. https://), or add a `@connect__source`.", + locations: [ + 6:57..6:69, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__this_on_root_types.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__this_on_root_types.graphql.snap new file mode 100644 index 0000000000..f4499544ac --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__this_on_root_types.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/this_on_root_types.graphql +--- +[ + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.requiresThis`: $this is not valid here, must be one of $args, $config, $context, $request, $env", + locations: [ + 11:39..11:51, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Mutation.requiresThis`: $this is not valid here, must be one of $args, $config, $context, $request, $env", + locations: [ + 20:37..20:49, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__undefined_arg_in_url_template.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__undefined_arg_in_url_template.graphql.snap new file mode 100644 index 0000000000..2472b29a0e --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__undefined_arg_in_url_template.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/undefined_arg_in_url_template.graphql +--- +[ + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.resources`: `$args` doesn't have a field named `blah`", + locations: [ + 10:45..10:49, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Query.resources`: `$args` doesn't have a field named `something`", + locations: [ + 10:68..10:77, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__undefined_this_in_url_template.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__undefined_this_in_url_template.graphql.snap new file mode 100644 index 0000000000..5c3518a1b0 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__undefined_this_in_url_template.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/undefined_this_in_url_template.graphql +--- +[ + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Something.resources`: `$this` doesn't have a field named `blah`", + locations: [ + 19:45..19:49, + ], + }, + Message { + code: InvalidUrl, + message: "In `GET` in `@connect(http:)` on `Something.resources`: `$this` doesn't have a field named `something`", + locations: [ + 19:68..19:77, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid-expressions-after-domain.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid-expressions-after-domain.graphql.snap new file mode 100644 index 0000000000..5d10659c0f --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid-expressions-after-domain.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/valid-expressions-after-domain.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_connect_absolute_multiline.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_connect_absolute_multiline.graphql.snap new file mode 100644 index 0000000000..b873db7544 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_connect_absolute_multiline.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_absolute_multiline.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_connect_absolute_url.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_connect_absolute_url.graphql.snap new file mode 100644 index 0000000000..4973dc36fa --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_connect_absolute_url.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_absolute_url.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_connect_multiline.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_connect_multiline.graphql.snap new file mode 100644 index 0000000000..f934db2c22 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_connect_multiline.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_multiline.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_source_url_template.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_source_url_template.graphql.snap new file mode 100644 index 0000000000..0ad6377858 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@uri_templates__valid_source_url_template.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/uri_templates/valid_source_url_template.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@url_properties__invalid_mappings.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@url_properties__invalid_mappings.graphql.snap new file mode 100644 index 0000000000..1b26e19139 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@url_properties__invalid_mappings.graphql.snap @@ -0,0 +1,35 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/url_properties/invalid_mappings.graphql +--- +[ + Message { + code: InvalidUrlProperty, + message: "In `@source(name: \"v1\")`, the `path` argument is not valid: nom::error::ErrorKind::Eof: .", + locations: [ + 8:52..8:53, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@source(name: \"v1\")`, the `queryParams` argument is not valid: nom::error::ErrorKind::Eof: .", + locations: [ + 8:70..8:71, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@connect` on `Query.resources`, the `path` argument is not valid: nom::error::ErrorKind::Eof: .", + locations: [ + 15:32..15:33, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@connect` on `Query.resources`, the `queryParams` argument is not valid: nom::error::ErrorKind::Eof: .", + locations: [ + 15:50..15:51, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@url_properties__path.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@url_properties__path.graphql.snap new file mode 100644 index 0000000000..854a1e74ef --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@url_properties__path.graphql.snap @@ -0,0 +1,58 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/url_properties/path.graphql +--- +[ + Message { + code: InvalidUrlProperty, + message: "In `@source(name: \"v2\")`, the `path` argument is invalid: `*.bad` must start with one of $config, $context, $request, $env", + locations: [ + 13:70..13:73, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@source(name: \"v3\")`, the `path` argument is invalid: string values aren't valid here", + locations: [ + 16:54..16:59, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@source(name: \"v4\")`, the `path` argument is invalid: object values aren't valid here", + locations: [ + 20:54..20:66, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@connect` on `Query.resources`, the `path` argument is invalid: `*.bad` must start with one of $args, $config, $context, $request, $env", + locations: [ + 35:53..35:56, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@connect` on `Query.resources`, the `path` argument is invalid: string values aren't valid here", + locations: [ + 36:55..36:60, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@connect` on `Query.resources`, the `path` argument is invalid: object values aren't valid here", + locations: [ + 39:34..39:46, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@connect` on `Query.resources`, the `path` argument is invalid: string values aren't valid here", + locations: [ + 24:16..24:22, + 24:13..24:22, + 47:59..47:60, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@url_properties__query_params.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@url_properties__query_params.graphql.snap new file mode 100644 index 0000000000..8cc9d00cb6 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@url_properties__query_params.graphql.snap @@ -0,0 +1,58 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/url_properties/query_params.graphql +--- +[ + Message { + code: InvalidUrlProperty, + message: "In `@source(name: \"v3\")`, the `queryParams` argument is invalid: `*.bad` must start with one of $config, $context, $request, $env", + locations: [ + 19:59..19:62, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@source(name: \"v4\")`, the `queryParams` argument is invalid: array values aren't valid here", + locations: [ + 23:61..23:63, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@source(name: \"v5\")`, the `queryParams` argument is invalid: string values aren't valid here", + locations: [ + 27:61..27:66, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@connect` on `Query.resources`, the `queryParams` argument is invalid: `*.bad` must start with one of $args, $config, $context, $request, $env", + locations: [ + 47:39..47:42, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@connect` on `Query.resources`, the `queryParams` argument is invalid: string values aren't valid here", + locations: [ + 52:41..52:46, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@connect` on `Query.resources`, the `queryParams` argument is invalid: array values aren't valid here", + locations: [ + 57:41..57:48, + ], + }, + Message { + code: InvalidUrlProperty, + message: "In `@connect` on `Query.resources`, the `queryParams` argument is invalid: string values aren't valid here", + locations: [ + 31:16..31:22, + 31:13..31:22, + 67:45..67:46, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@valid_large_body.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@valid_large_body.graphql.snap new file mode 100644 index 0000000000..09df114c94 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@valid_large_body.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/valid_large_body.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@valid_no_connect_on_resolvable_key_field.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@valid_no_connect_on_resolvable_key_field.graphql.snap new file mode 100644 index 0000000000..f73457dd54 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@valid_no_connect_on_resolvable_key_field.graphql.snap @@ -0,0 +1,6 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", errors)" +input_file: apollo-federation/src/connectors/validation/test_data/valid_no_connect_on_resolvable_key_field.graphql +--- +[] diff --git a/apollo-federation/src/connectors/validation/snapshots/validation_tests@valid_selection_with_escapes.graphql.snap b/apollo-federation/src/connectors/validation/snapshots/validation_tests@valid_selection_with_escapes.graphql.snap new file mode 100644 index 0000000000..ec596d99c3 --- /dev/null +++ b/apollo-federation/src/connectors/validation/snapshots/validation_tests@valid_selection_with_escapes.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/validation/mod.rs +expression: "format!(\"{:#?}\", result.errors)" +input_file: apollo-federation/src/connectors/validation/test_data/valid_selection_with_escapes.graphql +--- +[ + Message { + code: InvalidSelection, + message: "`@connect(selection:)` on `Query.block` is not valid: Path selection . must be followed by key (identifier or quoted string literal): .", + locations: [ + 16:30..16:31, + ], + }, + Message { + code: InvalidSelection, + message: "`@connect(selection:)` on `Query.standard` is not valid: Path selection . must be followed by key (identifier or quoted string literal): .", + locations: [ + 22:95..22:96, + ], + }, +] diff --git a/apollo-federation/src/connectors/validation/source.rs b/apollo-federation/src/connectors/validation/source.rs new file mode 100644 index 0000000000..0a31129218 --- /dev/null +++ b/apollo-federation/src/connectors/validation/source.rs @@ -0,0 +1,249 @@ +//! Validates `@source` directives + +use apollo_compiler::ast::Directive; +use apollo_compiler::schema::Component; +use hashbrown::HashMap; + +use super::coordinates::SourceDirectiveCoordinate; +use super::errors::ErrorsCoordinate; +use super::errors::IsSuccessArgument; +use super::http::UrlProperties; +use crate::connectors::SourceName; +use crate::connectors::spec::http::HTTP_ARGUMENT_NAME; +use crate::connectors::spec::source::BaseUrl; +use crate::connectors::spec::source::SOURCE_NAME_ARGUMENT_NAME; +use crate::connectors::string_template::Part; +use crate::connectors::validation::Code; +use crate::connectors::validation::Message; +use crate::connectors::validation::coordinates::BaseUrlCoordinate; +use crate::connectors::validation::coordinates::HttpHeadersCoordinate; +use crate::connectors::validation::coordinates::source_http_argument_coordinate; +use crate::connectors::validation::errors::Errors; +use crate::connectors::validation::expression; +use crate::connectors::validation::expression::Context; +use crate::connectors::validation::expression::scalars; +use crate::connectors::validation::graphql::SchemaInfo; +use crate::connectors::validation::http::headers::Headers; +use crate::connectors::validation::http::url::validate_url_scheme; + +/// A `@source` directive along with any errors related to it. +pub(super) struct SourceDirective<'schema> { + pub(crate) name: SourceName, + directive: &'schema Component, + base_url: Option, + url_properties: Option>, + headers: Option>, + is_success: Option>, + errors: Option>, + schema: &'schema SchemaInfo<'schema>, +} + +impl<'schema> SourceDirective<'schema> { + pub(super) fn find(schema: &'schema SchemaInfo) -> (Vec, Vec) { + let source_directive_name = schema.source_directive_name(); + let mut directives = Vec::new(); + let mut messages = Vec::new(); + for directive in &schema.schema_definition.directives { + if directive.name != *source_directive_name { + continue; + } + let (directive, new_messages) = Self::from_directive(directive, schema); + directives.extend(directive); + messages.extend(new_messages); + } + let mut valid_source_names = HashMap::new(); + for directive in &directives { + valid_source_names + .entry(directive.name.as_str()) + .or_insert_with(Vec::new) + .extend(directive.directive.node.line_column_range(&schema.sources)); + } + for (name, locations) in valid_source_names { + if locations.len() > 1 { + messages.push(Message { + message: format!("Every `@{source_directive_name}({SOURCE_NAME_ARGUMENT_NAME}:)` must be unique. Found duplicate name \"{name}\"."), + code: Code::DuplicateSourceName, + locations, + }); + } + } + (directives, messages) + } + + fn from_directive( + directive: &'schema Component, + schema: &'schema SchemaInfo<'schema>, + ) -> (Option>, Vec) { + let mut messages = Vec::new(); + let (name, name_messages) = SourceName::from_directive(directive, &schema.sources); + messages.extend(name_messages); + let Some(name) = name else { + return (None, messages); + }; + + let coordinate = SourceDirectiveCoordinate { + directive, + name: name.clone(), + }; + + let errors = match Errors::parse( + ErrorsCoordinate::Source { + source: coordinate.clone(), + }, + schema, + ) { + Ok(errors) => Some(errors), + Err(errs) => { + messages.extend(errs); + None + } + }; + + let is_success = match IsSuccessArgument::parse_for_source(coordinate.clone(), schema) { + Ok(is_success) => is_success, + Err(err) => { + messages.push(err); + None + } + }; + + let Some(http_arg) = directive + .specified_argument_by_name(&HTTP_ARGUMENT_NAME) + .and_then(|arg| arg.as_object()) + else { + messages.push(Message { + code: Code::GraphQLError, + message: format!( + "{coordinate} must have a `{HTTP_ARGUMENT_NAME}` argument.", + coordinate = source_http_argument_coordinate(&directive.name), + ), + locations: directive + .line_column_range(&schema.sources) + .into_iter() + .collect(), + }); + return ( + Some(SourceDirective { + name, + schema, + directive, + is_success, + base_url: None, + url_properties: None, + headers: None, + errors: None, + }), + messages, + ); + }; + + let base_url = match BaseUrl::parse( + http_arg, + &directive.name, + &schema.sources, + schema.connect_link.spec, + ) { + Ok(base_url) => { + // Only do URL validation for baseUrl if there are NO expressions. This is because with expressions, + // we don't know the values at composition time so we can't tell if it is valid. This can result in us + // saying it is NOT valid when at run time it would be. + if base_url + .template + .parts + .iter() + .all(|p| !matches!(p, Part::Expression(_))) + { + messages.extend( + validate_url_scheme( + &base_url.template, + BaseUrlCoordinate { + source_directive_name: &directive.name, + }, + &base_url.node, + schema, + ) + .err(), + ); + } + + Some(base_url) + } + Err(message) => { + messages.push(message); + None + } + }; + + let url_properties = match UrlProperties::parse_for_source(coordinate, schema, http_arg) { + Ok(url_properties) => Some(url_properties), + Err(errs) => { + messages.extend(errs); + None + } + }; + + let headers = match Headers::parse( + http_arg, + HttpHeadersCoordinate::Source { + directive_name: &directive.name, + }, + schema, + ) { + Ok(headers) => Some(headers), + Err(header_messages) => { + messages.extend(header_messages); + None + } + }; + + ( + Some(SourceDirective { + name, + directive, + base_url, + url_properties, + is_success, + headers, + errors, + schema, + }), + messages, + ) + } + + /// Type-check each source, doing compile-time-only validations. + /// + /// Do not call this at runtime, as it may be prohibitively expensive. + pub(crate) fn type_check(self) -> Vec { + let mut messages = Vec::new(); + if let Some(errors) = self.errors { + messages.extend(errors.type_check(self.schema).err().into_iter().flatten()); + } + if let Some(url) = self.base_url { + let expression_context = + Context::for_source_url(self.schema, &url.node, Code::InvalidUrl); + for expression in url.template.expressions() { + messages.extend( + expression::validate(expression, &expression_context, &scalars()) + .err() + .into_iter() + .map(|mut err| { + err.message = + format!("Invalid @source `baseURL` template: {}", err.message); + err + }), + ); + } + } + if let Some(is_success_argument) = self.is_success { + messages.extend(is_success_argument.type_check(self.schema).err()); + } + if let Some(url_properties) = self.url_properties { + messages.extend(url_properties.type_check(self.schema)); + } + if let Some(headers) = self.headers { + messages.extend(headers.type_check(self.schema).err().into_iter().flatten()); + } + messages + } +} diff --git a/apollo-federation/src/connectors/validation/test_data/all_fields_selected.graphql b/apollo-federation/src/connectors/validation/test_data/all_fields_selected.graphql new file mode 100644 index 0000000000..9d86c42ba3 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/all_fields_selected.graphql @@ -0,0 +1,73 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.10" + import: ["@key", "@external", "@requires"] + ) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + ts: [T] + @connect( + http: { GET: "http://test/ts" } + selection: """ + id + child { + id + } + wrapped: { + id + } + unwrapped: foo.bar + """ + ) + @connect( + http: { GET: "http://test/v2/ts" } + selection: """ + id + secondUsed + """ + ) +} + +type T @key(fields: "id") { + id: ID! + unselected: String! + child: C + wrapped: D + unwrapped: String! + external: External @external + external2: External2 @external + computed: String! + @requires(fields: "external") + @connect( + http: { + GET: "http://test/computed?id={$this.id}&external={$this.external.id}&external2={$this.external2.id}" + } + selection: "$" + ) + + secondUnused: String + secondUsed: String +} + +type C { + id: ID! + unselected: String +} + +type D { + id: ID! + unselected: String +} + +type Unused { + unselected: ID! +} + +type External { + id: ID! @external +} + +type External2 @external { + id: ID! +} diff --git a/apollo-federation/src/connectors/validation/test_data/all_fields_selected_repro.graphql b/apollo-federation/src/connectors/validation/test_data/all_fields_selected_repro.graphql new file mode 100644 index 0000000000..84efd72b91 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/all_fields_selected_repro.graphql @@ -0,0 +1,19 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.10" + import: ["@key", "@requires", "@override", "@external", "@shareable"] + ) + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@source", "@connect"] + ) + +type Cart @key(fields: "userId") { + userId: ID! + items: [Variant] # whoops forgot the @connect +} + +type Variant @key(fields: "id", resolvable: false) { + id: ID! + price: Float! @shareable +} diff --git a/apollo-federation/src/connectors/validation/test_data/batch.graphql b/apollo-federation/src/connectors/validation/test_data/batch.graphql new file mode 100644 index 0000000000..b96b60a306 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/batch.graphql @@ -0,0 +1,91 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11") + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@source", "@connect"] + ) + @source(name: "json", http: { baseURL: "http://test" }) + +type Query @connect(source: "json", http: { GET: "/query" }, selection: "x") { + ts: [T] + @connect(source: "json", http: { GET: "/ts" }, selection: "id") + @connect( + source: "json" + http: { POST: "/ts", body: "ids: $batch.id" } + selection: "id" + ) + @connect( + source: "json" + http: { POST: "/ts?ids={$batch.id}" } + selection: "id" + ) +} + +type Mutation + @connect(source: "json", http: { GET: "/mutation" }, selection: "y") { + y: String +} + +type T + @connect( + source: "missing" + http: { GET: "/ts?json={$batch.id->jsonStringify}" } + selection: "id name username" + ) + @connect( + source: "json" + http: { GET: "/ts?json={$args.id}" } + selection: "id name username" + ) + @connect( + source: "json" + http: { POST: "/ts", body: "id: $this.id" } + selection: "id name typo" + ) + @connect( + source: "json" + http: { POST: "/circular", body: "id: $batch.id" } + selection: "id friends { id name username }" + ) + @connect( + source: "json" + http: { GET: "/ts?json={$batch.id->jsonStringify}" } + selection: "id name username" + entity: false # NOPE + ) + @connect( + source: "json" + http: { GET: "/ts?json={$batch.foo->jsonStringify}" } + selection: "id name username" + ) + @connect( + source: "json" + http: { GET: "/ts?json={$batch.id->jsonStringify}" } + selection: "id name username: $batch.id" + ) + @connect( + source: "json" + http: { GET: "/ts?json={$batch.id->jsonStringify}&id={$this.id}" } # $batch & $this + selection: "id name username" + ) + @connect( + source: "json" + http: { GET: "/ts?json={$batch.id->joinNotNull(',')}" } + selection: "id name: $this.name username" # $batch & $this + ) { + id: ID! + name: String + username: String + friends: [T] + @connect( # this is also a circular reference error + source: "json" + http: { GET: "/friends/{$this.id}" } + selection: "id name username" + ) + listRelationship: [String] + @connect( + source: "json" + http: { GET: "/list/{$batch.id->joinNotNull(',')}" } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/batch/batch_alias_happy_path.graphql b/apollo-federation/src/connectors/validation/test_data/batch/batch_alias_happy_path.graphql new file mode 100644 index 0000000000..b59ff44ec9 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/batch/batch_alias_happy_path.graphql @@ -0,0 +1,29 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "my-source", http: { baseURL: "http://127.0.0.1" }) + +type Query { + users: [User] + @connect(http: { GET: "http://localhost:4001/users" }, selection: "id") +} + +type User + @connect( + source: "my-source" + http: { + GET: "/users?a={$context.id}&ids={$batch.id->joinNotNull(',')}" + } + selection: """ + $.results { + id: foo.bar + name + profilePic: profile_pic + } + """ + ) +{ + id: ID! + name: String + profilePic: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/batch/batch_incorrect_context_key.graphql b/apollo-federation/src/connectors/validation/test_data/batch/batch_incorrect_context_key.graphql new file mode 100644 index 0000000000..c4bff33626 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/batch/batch_incorrect_context_key.graphql @@ -0,0 +1,29 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "my-source", http: { baseURL: "http://127.0.0.1" }) + +type Query { + users: [User] + @connect(http: { GET: "http://localhost:4001/users" }, selection: "id") +} + +type User + @connect( + source: "my-source" + http: { + GET: "/users?ids={$batch.id->joinNotNull(',')}" + } + selection: """ + $.results { + id: $context.id_stuff + name + profilePic: profile_pic + } + """ + ) +{ + id: ID! + name: String + profilePic: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/batch/batch_incorrect_field.graphql b/apollo-federation/src/connectors/validation/test_data/batch/batch_incorrect_field.graphql new file mode 100644 index 0000000000..5f8fa6260d --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/batch/batch_incorrect_field.graphql @@ -0,0 +1,29 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "my-source", http: { baseURL: "http://127.0.0.1" }) + +type Query { + users: [User] + @connect(http: { GET: "http://localhost:4001/users" }, selection: "id") +} + +type User + @connect( + source: "my-source" + http: { + GET: "/users?ids={$batch.foo->joinNotNull(',')}" + } + selection: """ + $.results { + id + name + profilePic: profile_pic + } + """ + ) +{ + id: ID! + name: String + profilePic: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/batch/batch_missing_key.graphql b/apollo-federation/src/connectors/validation/test_data/batch/batch_missing_key.graphql new file mode 100644 index 0000000000..222b465b4c --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/batch/batch_missing_key.graphql @@ -0,0 +1,28 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "my-source", http: { baseURL: "http://127.0.0.1" }) + +type Query { + users: [User] + @connect(http: { GET: "http://localhost:4001/users" }, selection: "id") +} + +type User + @connect( + source: "my-source" + http: { + GET: "/users?ids={$batch.id->joinNotNull(',')}" + } + selection: """ + $.results { + name + profilePic: profile_pic + } + """ + ) +{ + id: ID! + name: String + profilePic: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/batch/batch_missing_nested_key.graphql b/apollo-federation/src/connectors/validation/test_data/batch/batch_missing_nested_key.graphql new file mode 100644 index 0000000000..222b465b4c --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/batch/batch_missing_nested_key.graphql @@ -0,0 +1,28 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "my-source", http: { baseURL: "http://127.0.0.1" }) + +type Query { + users: [User] + @connect(http: { GET: "http://localhost:4001/users" }, selection: "id") +} + +type User + @connect( + source: "my-source" + http: { + GET: "/users?ids={$batch.id->joinNotNull(',')}" + } + selection: """ + $.results { + name + profilePic: profile_pic + } + """ + ) +{ + id: ID! + name: String + profilePic: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/batch/batch_nested_keys.graphql b/apollo-federation/src/connectors/validation/test_data/batch/batch_nested_keys.graphql new file mode 100644 index 0000000000..56d8562e09 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/batch/batch_nested_keys.graphql @@ -0,0 +1,24 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "my-source", http: { baseURL: "http://127.0.0.1" }) + +type Query { + users: [T] + @connect(http: { GET: "http://localhost:4001/users" }, selection: "b") +} + +type T + @connect( + http: { GET: "http://localhost/?ids={$($batch.a { id c })->jsonStringify}" } + selection: "a { id } b" + ) +{ + a: A + b: Int +} + +type A { + id: ID! + c: Int +} diff --git a/apollo-federation/src/connectors/validation/test_data/body_selection.graphql b/apollo-federation/src/connectors/validation/test_data/body_selection.graphql new file mode 100644 index 0000000000..01d0261187 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/body_selection.graphql @@ -0,0 +1,56 @@ +extend schema +@link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect"] +) + +type Query { + dollar: String + @connect( + http: { + POST: "http://127.0.0.1", + body: "$" # INVALID - there is no input + } + selection: "$" + ) + dollarField: String + @connect( + http: { + POST: "http://127.0.0.1", + body: "$.foo" # INVALID - there is no input + } + selection: "$" + ) + objectLiteral: String + @connect( + http: { + POST: "http://127.0.0.1", + body: "$({ userid: 'foo' })" # VALID + } + selection: "$" + ) + objectLiteralWithVariable(userid: ID!): String + @connect( + http: { + POST: "http://127.0.0.1", + body: "$({ userid: $args.userid })" # VALID + } + selection: "$" + ) + invalidArrowMethod(userid: ID!): String + @connect( + http: { + POST: "http://127.0.0.1", + body: "$({ userid: $args.userid })->no_such_method" # INVALID - no such method + } + selection: "$" + ) + invalidVariable(userid: ID!): String + @connect( + http: { + POST: "http://127.0.0.1", + body: "$({ userid: $nosuchvariable })" # INVALID - no such variable + } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/circular_reference.graphql b/apollo-federation/src/connectors/validation/test_data/circular_reference.graphql new file mode 100644 index 0000000000..b22786416d --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/circular_reference.graphql @@ -0,0 +1,15 @@ +extend schema +@link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect"] +) + +type Query { + me: User + @connect(http: {GET: "http://127.0.0.1/me"}, selection: "id friends {id}") +} + +type User { + id: ID! + friends: [User!]! +} diff --git a/apollo-federation/src/connectors/validation/test_data/circular_reference_2.graphql b/apollo-federation/src/connectors/validation/test_data/circular_reference_2.graphql new file mode 100644 index 0000000000..c59d1e0ac9 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/circular_reference_2.graphql @@ -0,0 +1,26 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + track(id: ID!): Track + @connect( + http: { GET: "http://track/{$args.id}" } + selection: "id" + entity: true + ) +} + +type Track { + id: ID! + modules: [Module] + @connect( + http: { GET: "http://track/{$this.id}/modules" } + selection: "id title track: { id: trackId }" + ) +} + +type Module { + id: ID! + title: String + track: Track +} diff --git a/apollo-federation/src/connectors/validation/test_data/circular_reference_3.graphql b/apollo-federation/src/connectors/validation/test_data/circular_reference_3.graphql new file mode 100644 index 0000000000..82b2cac917 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/circular_reference_3.graphql @@ -0,0 +1,16 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + me: User @connect(http: { GET: "http://127.0.0.1/me" }, selection: "id name") +} + +type User { + id: ID! + name: String + friends: [User!]! # this can't ever work because `{ me { friends { friends { ... } } } }` will always fail + @connect( + http: { GET: "http://127.0.0.1/users/{$this.id}/friends" } + selection: "id name" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/connect_source_name_mismatch.graphql b/apollo-federation/src/connectors/validation/test_data/connect_source_name_mismatch.graphql new file mode 100644 index 0000000000..ca9066d800 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/connect_source_name_mismatch.graphql @@ -0,0 +1,11 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v2", http: { baseURL: "http://127.0.0.1" }) + +type Query { + resources: [String!]! + @connect(source: "v1", http: { GET: "/resources" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/connect_source_undefined.graphql b/apollo-federation/src/connectors/validation/test_data/connect_source_undefined.graphql new file mode 100644 index 0000000000..951b538752 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/connect_source_undefined.graphql @@ -0,0 +1,10 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + +type Query { + resources: [String!]! + @connect(source: "v1", http: { GET: "/resources" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/connect_spec_version_error.graphql b/apollo-federation/src/connectors/validation/test_data/connect_spec_version_error.graphql new file mode 100644 index 0000000000..3852b0a695 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/connect_spec_version_error.graphql @@ -0,0 +1,5 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.99" + import: ["@connect", "@source"] + ) diff --git a/apollo-federation/src/connectors/validation/test_data/denest_scalars.graphql b/apollo-federation/src/connectors/validation/test_data/denest_scalars.graphql new file mode 100644 index 0000000000..cbd736c5ad --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/denest_scalars.graphql @@ -0,0 +1,31 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + me: User + @connect( + http: { GET: "http://127.0.0.1/something" } + selection: """ + id + $.name { + firstName: first + lastName: last + } + $.address { + street + } + """ + ) +} + +type User { + id: ID! + firstName: String + lastName: String + street: Street +} + +type Street { + number: Int + name: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/denest_scalars2.graphql b/apollo-federation/src/connectors/validation/test_data/denest_scalars2.graphql new file mode 100644 index 0000000000..f718095f83 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/denest_scalars2.graphql @@ -0,0 +1,26 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + me: User + @connect( + http: { GET: "http://127.0.0.1/something" } + selection: """ + id + $.name { + firstName: first + lastName: last + } + $.address { + street { number name } + } + """ + ) +} + +type User { + id: ID! + firstName: String + lastName: String + street: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/disallowed_abstract_types.graphql b/apollo-federation/src/connectors/validation/test_data/disallowed_abstract_types.graphql new file mode 100644 index 0000000000..08a457e086 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/disallowed_abstract_types.graphql @@ -0,0 +1,48 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + products: [Product] + @connect( + http: { GET: "http://127.0.0.1:8000/products" } + selection: """ + $.results { + id + title + author { name } + director { name } + } + """ + ) + search(title: String): [Media] + @connect(http: { GET: "http://127.0.0.1:8000/media" }, selection: "$") +} + +interface Product { + id: ID! +} + +union Media = Book | Film | Music + +type Book implements Product { + id: ID + title: String + author: Person +} + +type Film implements Product { + id: ID + title: String + director: Person +} + +type Music { + id: ID + title: String + singer: Person +} + +type Person { + id: ID + name: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/disallowed_federation_imports.graphql b/apollo-federation/src/connectors/validation/test_data/disallowed_federation_imports.graphql new file mode 100644 index 0000000000..06b9ac430a --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/disallowed_federation_imports.graphql @@ -0,0 +1,22 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.8" + import: [ + "@key" + "@context" + "@fromContext" + "@interfaceObject" + "@external" + "@requires" + "@provides" + ] + ) + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + +type Query { + resources: [String!]! + @connect(http: { GET: "http://127.0.0.1" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/duplicate_source_name.graphql b/apollo-federation/src/connectors/validation/test_data/duplicate_source_name.graphql new file mode 100644 index 0000000000..73b21d170b --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/duplicate_source_name.graphql @@ -0,0 +1,12 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v1", http: { baseURL: "http://127.0.0.1" }) + @source(name: "v1", http: { baseURL: "http://localhost" }) + +type Query { + resources: [String!]! + @connect(source: "v1", http: { GET: "/resources" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/duplicated_ids.graphql b/apollo-federation/src/connectors/validation/test_data/duplicated_ids.graphql new file mode 100644 index 0000000000..8f1c31737d --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/duplicated_ids.graphql @@ -0,0 +1,29 @@ +extend schema +@link(url: "https://specs.apollo.dev/federation/v2.12") +@link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + block: T + @connect( + id: "duplicated_id" + http: { GET: "http://127.0.0.1/something" } + selection: """ + one + two + """ + ) + standard: T + @connect( + id: "duplicated_id" + http: { GET: "http://127.0.0.1/something" } + selection: """ + one + two + """ + ) +} + +type T { + one: String + two: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/empty_selection.graphql b/apollo-federation/src/connectors/validation/test_data/empty_selection.graphql new file mode 100644 index 0000000000..85248b7850 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/empty_selection.graphql @@ -0,0 +1,15 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v2", http: { baseURL: "http://127.0.0.1" }) + +type Query { + emptySelection: [String!]! + @connect( + source: "v2" + http: { POST: "/resources", body: " " } + selection: "# comment" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/empty_source_name.graphql b/apollo-federation/src/connectors/validation/test_data/empty_source_name.graphql new file mode 100644 index 0000000000..75dca45df3 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/empty_source_name.graphql @@ -0,0 +1,11 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "", http: { baseURL: "http://127.0.0.1" }) + +type Query { + resources: [String!]! + @connect(source: "", http: { GET: "/resources" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/env-vars.graphql b/apollo-federation/src/connectors/validation/test_data/env-vars.graphql new file mode 100644 index 0000000000..d4b2afe491 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/env-vars.graphql @@ -0,0 +1,44 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10") + @link( + url: "https://specs.apollo.dev/connect/v0.3" + import: ["@source", "@connect"] + ) + @source( + name: "valid-source" + http: { + baseURL: "http://localhost:3000" + headers: [{ name: "x-env", value: "{$env.VALID}" }] + } + ) + @source( + name: "invalid-source" + http: { + baseURL: "http://localhost:3000" + headers: [ + { name: "x-env1", value: "{$env}" } + { name: "x-env2", value: "{$env.foo.bar}" } + ] + } + ) + +type Query { + validRequest: String + @connect( + http: { GET: "http://localhost:3000/{$env.ENV_VAR}" } + selection: "$" + ) + validResponse: String + @connect(http: { GET: "http://localhost:3000/" }, selection: "$env.ENV_VAR") + + invalidObject: String + @connect( + http: { GET: "http://localhost:3000/{$env}" } + selection: "$env" # TODO: This should be an error + ) + invalidPath: String + @connect( + http: { GET: "http://localhost:3000/{$env.foo.bar}" } + selection: "$env.baz.quux" # TODO: This should be an error + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/errors.graphql b/apollo-federation/src/connectors/validation/test_data/errors.graphql new file mode 100644 index 0000000000..49938b7155 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/errors.graphql @@ -0,0 +1,120 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect", "@source"]) + @source( + name: "valid_source" + http: { + baseURL: "http://127.0.0.1" + errors: { message: "error.message", extensions: "code: error.code status: $status" } + } + ) + @source( + name: "invalid_source_message_not_string" + http: { baseURL: "http://127.0.0.1" } + errors: { message: "message: error.message", extensions: "code: error.code" } + ) + @source( + name: "invalid_source_extensions_not_object" + http: { baseURL: "http://127.0.0.1" } + errors: { message: "error.message", extensions: "error.code" } + ) + @source( + name: "invalid_source_args_variable" + http: { baseURL: "http://127.0.0.1" } + errors: { message: "error.message", extensions: "myValue: $args.myValue" } + ) + @source( + name: "invalid_source_this_variable" + http: { baseURL: "http://127.0.0.1" } + errors: { message: "error.message", extensions: "myValue: $this.myField" } + ) + +type Query { + valid_with_source: [String!]! @connect(source: "valid_source", http: { GET: "/" }, selection: "$") + + invalid_source_message_not_string: [String!]! + @connect(source: "invalid_source_message_not_string", http: { GET: "/" }, selection: "$") + + invalid_source_extensions_not_object: [String!]! + @connect(source: "invalid_source_extensions_not_object", http: { GET: "/" }, selection: "$") + + invalid_source_args_variable: [String!]! + @connect(source: "invalid_source_args_variable", http: { GET: "/" }, selection: "$") + + invalid_source_this_variable: [String!]! + @connect(source: "invalid_source_this_variable", http: { GET: "/" }, selection: "$") + + valid_override_source: [String!]! + @connect( + source: "valid_source" + http: { GET: "/" } + errors: { message: "error.message", extensions: "code: error.code status: $status" } + selection: "$" + ) + + valid_sourceless: [String!]! + @connect( + http: { GET: "http://127.0.0.1" } + errors: { message: "error.message", extensions: "code: error.code status: $status" } + selection: "$" + ) + + valid_sourceless_only_message: [String!]! + @connect(http: { GET: "http://127.0.0.1" }, errors: { message: "error.message" }, selection: "$") + + valid_sourceless_only_extensions: [String!]! + @connect( + http: { GET: "http://127.0.0.1" } + errors: { extensions: "code: error.code status: $status" } + selection: "$" + ) + + valid_sourceless_literal_string: [String!]! + @connect( + http: { GET: "http://127.0.0.1" } + errors: { + message: """ + $("An error!") + """ + extensions: "status: $status" + } + selection: "$" + ) + + invalid_sourceless_message_not_string: [String!]! + @connect( + http: { GET: "http://127.0.0.1" } + errors: { message: "message: error.message", extensions: "code: error.code status: $status" } + selection: "$" + ) + + invalid_sourceless_extensions_not_object: [String!]! + @connect( + http: { GET: "http://127.0.0.1" } + errors: { message: "error.message", extensions: "error.code" } + selection: "$" + ) + + invalid_sourceless_message_not_string_from_args(myArg: Int): [String!]! + @connect( + http: { GET: "http://127.0.0.1" } + errors: { message: "$args.myArg", extensions: "code: error.code status: $status" } + selection: "$" + ) + + invalid_sourceless_extensions_not_object(myArg: Int): [String!]! + @connect( + http: { GET: "http://127.0.0.1" } + errors: { message: "error.message", extensions: "$args.myArg" } + selection: "$" + ) + + invalid_sourceless_empty_message: [String!]! + @connect( + http: { GET: "http://127.0.0.1" } + errors: { message: "", extensions: "code: error.code status: $status" } + selection: "$" + ) + + invalid_sourceless_empty_extensions: [String!]! + @connect(http: { GET: "http://127.0.0.1" }, errors: { message: "error.message", extensions: "" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/fields_with_arguments.graphql b/apollo-federation/src/connectors/validation/test_data/fields_with_arguments.graphql new file mode 100644 index 0000000000..d7d3ea95d3 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/fields_with_arguments.graphql @@ -0,0 +1,21 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + ts(first: Int): [T] + @connect( + http: { GET: "http://test/ts?first={$args.first}" } + selection: """ + id + field + """ + ) +} + +type T @key(fields: "id") { + id: ID! + field(foo: String): String + other(bar: String): String + @connect(http: { GET: "http://test/other?bar={$args.bar}" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/group_selection_on_scalar.graphql b/apollo-federation/src/connectors/validation/test_data/group_selection_on_scalar.graphql new file mode 100644 index 0000000000..9a5f533c15 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/group_selection_on_scalar.graphql @@ -0,0 +1,13 @@ +extend schema +@link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect"] +) + +type Query { + me: User @connect(http: {GET: "http://127.0.0.1/something"}, selection: "id {blah}") +} + +type User { + id: ID! +} diff --git a/apollo-federation/src/connectors/validation/test_data/headers/disallowed_header_names.graphql b/apollo-federation/src/connectors/validation/test_data/headers/disallowed_header_names.graphql new file mode 100644 index 0000000000..a7e2d466a5 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/headers/disallowed_header_names.graphql @@ -0,0 +1,78 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source( + name: "reserved-propagate" + http: { + baseURL: "http://127.0.0.1" + headers: [ + { name: "connection", from: "connection" } + { name: "proxy-authenticate", from: "proxy-authenticate" } + { name: "proxy-authorization", from: "proxy-authorization" } + { name: "te", from: "te" } + { name: "trailer", from: "trailer" } + { name: "transfer-encoding", from: "transfer-encoding" } + { name: "upgrade", from: "upgrade" } + { name: "content-length", from: "content-length" } + { name: "content-encoding", from: "content-encoding" } + { name: "host", from: "host" } + { name: "accept-encoding", from: "accept-encoding" } + { name: "Content-Length", from: "Content-Length" } + { name: "Content-Type", from: "Content-Type" } + { name: "accept", from: "accept" } + ] + } + ) + @source( + name: "reserved-static" + http: { + baseURL: "http://127.0.0.1" + headers: [ + { name: "connection", value: "connection" } + { name: "proxy-authenticate", value: "proxy-authenticate" } + { name: "proxy-authorization", value: "proxy-authorization" } + { name: "te", value: "te" } + { name: "trailer", value: "trailer" } + { name: "transfer-encoding", value: "transfer-encoding" } + { name: "upgrade", value: "upgrade" } + { name: "content-length", value: "content-length" } + { name: "content-encoding", value: "content-encoding" } + { name: "host", value: "host" } + { name: "accept-encoding", value: "accept-encoding" } + { name: "Content-Length", value: "Content-Length" } + { name: "Content-Type", value: "Content-Type" } + { name: "accept", value: "accept" } + ] + } + ) + @source( + name: "allowed-static" + http: { + baseURL: "http://127.0.0.1" + headers: [ + { name: "Content-Type", value: "allows static" } + { name: "accept", value: "allows static" } + ] + } + ) + +type Query { + resources: [String!]! + @connect( + source: "reserved-propagate" + http: { GET: "/resources" } + selection: "$" + ) + @connect( + source: "reserved-static" + http: { GET: "/resources" } + selection: "$" + ) + @connect( + source: "allowed-static" + http: { GET: "/resources" } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/headers/disallowed_header_names_v0.2.graphql b/apollo-federation/src/connectors/validation/test_data/headers/disallowed_header_names_v0.2.graphql new file mode 100644 index 0000000000..734f31e92e --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/headers/disallowed_header_names_v0.2.graphql @@ -0,0 +1,78 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source( + name: "reserved-propagate" + http: { + baseURL: "http://127.0.0.1" + headers: [ + { name: "connection", from: "connection" } + { name: "proxy-authenticate", from: "proxy-authenticate" } + { name: "proxy-authorization", from: "proxy-authorization" } + { name: "te", from: "te" } + { name: "trailer", from: "trailer" } + { name: "transfer-encoding", from: "transfer-encoding" } + { name: "upgrade", from: "upgrade" } + { name: "content-length", from: "content-length" } + { name: "content-encoding", from: "content-encoding" } + { name: "host", from: "host" } + { name: "accept-encoding", from: "accept-encoding" } + { name: "Content-Length", from: "Content-Length" } + { name: "Content-Type", from: "Content-Type" } + { name: "accept", from: "accept" } + ] + } + ) + @source( + name: "reserved-static" + http: { + baseURL: "http://127.0.0.1" + headers: [ + { name: "connection", value: "connection" } + { name: "proxy-authenticate", value: "proxy-authenticate" } + { name: "proxy-authorization", value: "proxy-authorization" } + { name: "te", value: "te" } + { name: "trailer", value: "trailer" } + { name: "transfer-encoding", value: "transfer-encoding" } + { name: "upgrade", value: "upgrade" } + { name: "content-length", value: "content-length" } + { name: "content-encoding", value: "content-encoding" } + { name: "accept-encoding", value: "accept-encoding" } + { name: "Content-Length", value: "Content-Length" } + { name: "Content-Type", value: "Content-Type" } + { name: "accept", value: "accept" } + ] + } + ) + @source( + name: "allowed-static" + http: { + baseURL: "http://127.0.0.1" + headers: [ + { name: "Content-Type", value: "allows static" } + { name: "accept", value: "allows static" } + { name: "host", value: "allows static" } + ] + } + ) + +type Query { + resources: [String!]! + @connect( + source: "reserved-propagate" + http: { GET: "/resources" } + selection: "$" + ) + @connect( + source: "reserved-static" + http: { GET: "/resources" } + selection: "$" + ) + @connect( + source: "allowed-static" + http: { GET: "/resources" } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/headers/expressions_that_evaluate_to_invalid_types_v0_2.graphql b/apollo-federation/src/connectors/validation/test_data/headers/expressions_that_evaluate_to_invalid_types_v0_2.graphql new file mode 100644 index 0000000000..47c5001ef2 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/headers/expressions_that_evaluate_to_invalid_types_v0_2.graphql @@ -0,0 +1,24 @@ +extend schema +@link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] +) +@source( + name: "v1" + http: { + baseURL: "http://127.0.0.1" + headers: [ + { name: "an-array", value: "{$->echo([])}" }, + { name: "an-object", value: "{$({})}"}, + { name: "maybe-object", value: "{$config->match([1, 1], [2, {}])}" }, + ] + } +) + +type Query { + blah(anArray: [String]): String @connect( + source: "v1", + http: {GET: "/blah", headers: {name: "an-array", value: "{$args.anArray}"}}, + selection: "$" + ) +} \ No newline at end of file diff --git a/apollo-federation/src/connectors/validation/test_data/headers/expressions_that_evaluate_to_invalid_types_v0_3.graphql b/apollo-federation/src/connectors/validation/test_data/headers/expressions_that_evaluate_to_invalid_types_v0_3.graphql new file mode 100644 index 0000000000..06ecd17013 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/headers/expressions_that_evaluate_to_invalid_types_v0_3.graphql @@ -0,0 +1,24 @@ +extend schema +@link( + url: "https://specs.apollo.dev/connect/v0.3" + import: ["@connect", "@source"] +) +@source( + name: "v1" + http: { + baseURL: "http://127.0.0.1" + headers: [ + { name: "an-array", value: "{$->echo([])}" }, + { name: "an-object", value: "{$({})}"}, + { name: "maybe-object", value: "{$config->match([1, 1], [2, {}])}" }, + ] + } +) + +type Query { + blah(anArray: [String]): String @connect( + source: "v1", + http: {GET: "/blah", headers: {name: "an-array", value: "{$args.anArray}"}}, + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/headers/invalid_connect_http_headers.graphql b/apollo-federation/src/connectors/validation/test_data/headers/invalid_connect_http_headers.graphql new file mode 100644 index 0000000000..3d056692f3 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/headers/invalid_connect_http_headers.graphql @@ -0,0 +1,31 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + resources: [String!]! + @connect( + http: { + GET: "http://127.0.0.1:8000/resources" + headers: [ + { name: "valid-with-value", value: "text/html" } + { name: "valid-with-from", from: "valid-with-from" } + { name: "no-from-or-value" } + { from: "x-missing-name" } + { name: "from-and-value", from: "from-and-value", value: "text/html" } + { name: "x-name-collision", value: "text/html" } + { name: "X-NAME-COLLISION", from: "x-name-collision" } + { name: "", value: "invalid.header.name" } + { name: "x-invalid-from", from: "" } + { + name: "x-emoji-value" + value: " Value with 😊 emoji " + } + { name: "Content-Length", value: "Is a reserved header" } + { name: "Content-Type", from: "Cant-Be-Dynamic" } + { name: "accept", from: "Is a reserved header" } + { name: "accept", value: "application/json; version=v4.0" } # ok + ] + } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/headers/invalid_namespace_in_header_variables.graphql b/apollo-federation/src/connectors/validation/test_data/headers/invalid_namespace_in_header_variables.graphql new file mode 100644 index 0000000000..c684e5ce4a --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/headers/invalid_namespace_in_header_variables.graphql @@ -0,0 +1,32 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source( + name: "v1" + http: { + baseURL: "https://127.0.0.1" + headers: [ + { name: "x-unknown-namespace", value: "{$foo.bar}" } + { name: "x-invalid-location-for-namespace", value: "{$this.bar}" } + { name: "x-namespace-missing-dollar", value: "{config.bar}" } + ] + } + ) + +type Query { + scalar(bar: String): String + @connect( + http: { + GET: "http://127.0.0.1" + headers: [ + { name: "x-unknown-namespace", value: "{$foo.bar}"} + { name: "x-invalid-location-for-namespace", value: "{$status}" } + { name: "x-no-this-on-root", value: "{$this}" } + { name: "x-namespace-missing-dollar", value: "{config.bar}" } + ] + } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/headers/invalid_nested_paths_in_header_variables.graphql b/apollo-federation/src/connectors/validation/test_data/headers/invalid_nested_paths_in_header_variables.graphql new file mode 100644 index 0000000000..772fa0fc9a --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/headers/invalid_nested_paths_in_header_variables.graphql @@ -0,0 +1,60 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + +type Query { + scalar(scalar: String): String + @connect( + http: { + GET: "http://127.0.0.1" + headers: [ + { name: "x-custom-header", value: "{$args.scalar.blah}"} + ] + } + selection: "$" + ) + object(input: InputObject): Object + @connect( + http: { + GET: "http://127.0.0.1" + headers: [ + { name: "x-custom-header", value: "{$args.input.fieldThatDoesntExist}"} + ] + } + selection: "id" + ) + enum(enum: Enum): Enum + @connect( + http: { + GET: "http://127.0.0.1" + headers: [ + { name: "x-custom-header", value: "{$args.enum.cantHaveFields}"} + ] + } + selection: "$" + ) +} + +type Object { + id: ID! + newField: String + @connect( + http: { + GET: "http://127.0.0.1" + headers: [ + { name: "x-custom-header", value: "{$this.fieldThatDoesntExist}"} + ] + } + selection: "$" + ) +} + +input InputObject { + id: ID! +} + +enum Enum { + VALUE +} diff --git a/apollo-federation/src/connectors/validation/test_data/headers/invalid_source_http_headers.graphql b/apollo-federation/src/connectors/validation/test_data/headers/invalid_source_http_headers.graphql new file mode 100644 index 0000000000..2aef3bd320 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/headers/invalid_source_http_headers.graphql @@ -0,0 +1,33 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source( + name: "v1" + http: { + baseURL: "http://127.0.0.1" + headers: [ + { name: "valid-with-value", value: "text/html" } + { name: "valid-with-from", from: "valid-with-from" } + { name: "no-from-or-value" } + { from: "x-missing-name" } + { name: "from-and-value", from: "from-and-value", value: "text/html" } + { name: "x-name-collision", value: "text/html" } + { name: "X-NAME-COLLISION", from: "x-name-collision" } + { name: "", value: "invalid.header.name" } + { name: "x-invalid-from", from: "" } + { + name: "x-emoji-value" + value: " Value with 😊 emoji " + } + { name: "Content-Length", value: "Is a reserved header" } + { name: "Content-Type", from: "Cant-Be-Dynamic" } + ] + } + ) + +type Query { + resources: [String!]! + @connect(source: "v1", http: { GET: "/resources" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/invalid_chars_in_source_name.graphql b/apollo-federation/src/connectors/validation/test_data/invalid_chars_in_source_name.graphql new file mode 100644 index 0000000000..377b76f71e --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/invalid_chars_in_source_name.graphql @@ -0,0 +1,31 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "u$ers", http: { baseURL: "http://127.0.0.1" }) + @source(name: "1", http: { baseURL: "http://127.0.0.1" }) + @source(name: "no.dots", http: { baseURL: "http://127.0.0.1" }) + @source( + name: "areallylongnamethatisoversixtythreecharacterstakesalongwhiletotypebutthisshoulddoit" + http: { baseURL: "http://127.0.0.1" } + ) + @source( # Check all valid chars + name: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-", + http: { baseURL: "http://127.0.0.1" } + ) + +type Query { + resources1: [String!]! + @connect(source: "u$ers", http: { GET: "/resources" }, selection: "$") + resources2: [String!]! + @connect(source: "1", http: { GET: "/resources" }, selection: "$") + resources3: [String!]! + @connect(source: "no.dots", http: { GET: "/resources" }, selection: "$") + resources4: [String!]! + @connect( + source: "areallylongnamethatisoversixtythreecharacterstakesalongwhiletotypebutthisshoulddoit" + http: { GET: "/resources" } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/invalid_id_name.graphql b/apollo-federation/src/connectors/validation/test_data/invalid_id_name.graphql new file mode 100644 index 0000000000..1b168c8f32 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/invalid_id_name.graphql @@ -0,0 +1,11 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "name", http: { baseURL: "http://127.0.0.1" }) + +type Query { + resources: [String!]! + @connect(id: "invalid.id", source: "name", http: { GET: "/resources" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/invalid_namespace_in_body_selection.graphql b/apollo-federation/src/connectors/validation/test_data/invalid_namespace_in_body_selection.graphql new file mode 100644 index 0000000000..170a160613 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/invalid_namespace_in_body_selection.graphql @@ -0,0 +1,25 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect"] + ) + +type Mutation { + createUser(id: ID!): String + @connect( + http: { + POST: "http://127.0.0.1/users/{$args.id}" + body: """ + id: $args.id + foo: $config.foo + bar: { + bar: $config.bar + baz: { + baz: $config.baz + } + } + status: $status + """ + } + selection: "$status" + ) diff --git a/apollo-federation/src/connectors/validation/test_data/invalid_nested_paths_in_json_selection.graphql b/apollo-federation/src/connectors/validation/test_data/invalid_nested_paths_in_json_selection.graphql new file mode 100644 index 0000000000..1cc73c03d9 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/invalid_nested_paths_in_json_selection.graphql @@ -0,0 +1,52 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + +type Query { + scalar(scalar: String): String + @connect( + http: { + POST: "http://127.0.0.1" + body: "foo: $args.scalar.blah" + } + selection: "$" + ) + object(input: InputObject): Object + @connect( + http: { + POST: "http://127.0.0.1" + body: "foo: $args.input.fieldThatDoesntExist" + } + selection: "id" + ) + enum(enum: Enum): Enum + @connect( + http: { + POST: "http://127.0.0.1" + body: "foo: $args.enum.cantHaveFields" + } + selection: "$" + ) +} + +type Object { + id: ID! + newField: String + @connect( + http: { + POST: "http://127.0.0.1" + body: "foo: $this.fieldThatDoesntExist" + } + selection: "$" + ) +} + +input InputObject { + id: ID! +} + +enum Enum { + VALUE +} diff --git a/apollo-federation/src/connectors/validation/test_data/invalid_selection_syntax.graphql b/apollo-federation/src/connectors/validation/test_data/invalid_selection_syntax.graphql new file mode 100644 index 0000000000..e5ce18737c --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/invalid_selection_syntax.graphql @@ -0,0 +1,9 @@ +extend schema +@link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect"] +) + +type Query { + something: String @connect(http: {GET: "http://127.0.0.1/something"}, selection: "&how") +} \ No newline at end of file diff --git a/apollo-federation/src/connectors/validation/test_data/is_success/is_success_connect_happy_path.graphql b/apollo-federation/src/connectors/validation/test_data/is_success/is_success_connect_happy_path.graphql new file mode 100644 index 0000000000..08bfea8fba --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/is_success/is_success_connect_happy_path.graphql @@ -0,0 +1,42 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect", "@source"]) + @source( + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + ) + +type Query { + users: [User] + @connect( + source: "json" + http: { GET: "/users" } + selection: "id name" + isSuccess: "id->eq('yay')" + ) + + posts: [Post] + @connect( + source: "json" + http: { GET: "/posts" } + selection: "id title body" + isSuccess: "id->eq('cool')" + ) +} + +type User @key(fields: "id", resolvable: false) { + id: ID! + name: String +} + +type Post { + id: ID! + title: String + body: String +} \ No newline at end of file diff --git a/apollo-federation/src/connectors/validation/test_data/is_success/is_success_invalid_connect.graphql b/apollo-federation/src/connectors/validation/test_data/is_success/is_success_invalid_connect.graphql new file mode 100644 index 0000000000..dec6bd7ce3 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/is_success/is_success_invalid_connect.graphql @@ -0,0 +1,42 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect", "@source"]) + @source( + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + ) + +type Query { + users: [User] + @connect( + source: "json" + http: { GET: "/users" } + selection: "id name" + isSuccess: "$this.headers.'x-my-header'->first->eq('Illegal Arg!')" + ) + + posts: [Post] + @connect( + source: "json" + http: { GET: "/posts" } + selection: "id title body" + isSuccess: "$status->jsonStringify" + ) +} + +type User @key(fields: "id", resolvable: false) { + id: ID! + name: String +} + +type Post { + id: ID! + title: String + body: String +} \ No newline at end of file diff --git a/apollo-federation/src/connectors/validation/test_data/is_success/is_success_invalid_sources.graphql b/apollo-federation/src/connectors/validation/test_data/is_success/is_success_invalid_sources.graphql new file mode 100644 index 0000000000..545645a689 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/is_success/is_success_invalid_sources.graphql @@ -0,0 +1,44 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect", "@source"]) + @source( + name: "bad_return_type" + isSuccess: "$->echo('string, not bool')" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + } + ) + @source( + name: "using_disallowed_var" + isSuccess: "$this.a->eq('eeeeep!')" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + } + ) + +type Query { + users: [User] + @connect( + source: "using_disallowed_var" + http: { GET: "/users" } + selection: "id name" + ) + + posts: [Post] + @connect( + source: "bad_return_type" + http: { GET: "/posts" } + selection: "id title body" + ) +} + +type User @key(fields: "id", resolvable: false) { + id: ID! + name: String +} + +type Post { + id: ID! + title: String + body: String +} \ No newline at end of file diff --git a/apollo-federation/src/connectors/validation/test_data/is_success/is_success_source_happy_path.graphql b/apollo-federation/src/connectors/validation/test_data/is_success/is_success_source_happy_path.graphql new file mode 100644 index 0000000000..4b7377140a --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/is_success/is_success_source_happy_path.graphql @@ -0,0 +1,41 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect", "@source"]) + @source( + name: "json" + isSuccess: "id->eq('yay')" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "AuthToken", from: "X-Auth-Token" } + { name: "user-agent", value: "Firefox" } + ] + } + ) + +type Query { + users: [User] + @connect( + source: "json" + http: { GET: "/users" } + selection: "id name" + ) + + posts: [Post] + @connect( + source: "json" + http: { GET: "/posts" } + selection: "id title body" + ) +} + +type User @key(fields: "id", resolvable: false) { + id: ID! + name: String +} + +type Post { + id: ID! + title: String + body: String +} \ No newline at end of file diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/arg_is_object_but_field_is_not.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/arg_is_object_but_field_is_not.graphql new file mode 100644 index 0000000000..7914549296 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/arg_is_object_but_field_is_not.graphql @@ -0,0 +1,21 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + product(id: ProductInput!): Product + @connect( + http: { GET: "http://myapi/products/{$args.id.id}" } + selection: "id name" + entity: true + ) +} + +type Product { + id: ID! + name: String +} + +input ProductInput { + id: ID! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/arg_type_doesnt_match_field_type.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/arg_type_doesnt_match_field_type.graphql new file mode 100644 index 0000000000..cdd77f80a1 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/arg_type_doesnt_match_field_type.graphql @@ -0,0 +1,17 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + product(id: String!): Product + @connect( + http: { GET: "http://myapi/products/{$args.id}" } + selection: "id name" + entity: true + ) +} + +type Product { + id: ID! + name: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/composite_key_doesnt_match.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/composite_key_doesnt_match.graphql new file mode 100644 index 0000000000..a5c4c5507c --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/composite_key_doesnt_match.graphql @@ -0,0 +1,47 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + "The auto-key `store` field doesn't match the explicit composite key, so that key is unresolved" + product(id: ID!, store: StoreInput!): Product + @connect( + http: { + GET: "http://myapi/region/{$args.store.country.region}/country/{$args.store.country.id}/store/{$args.store.id}/products/{$args.id}" + } + selection: "id store { id country { id key_id region } } name" + entity: true + ) +} + +type Product @key(fields: "id store { id country { key_id region } }") { + id: ID! + store: Store! + name: String +} + +type Store { + id: ID! + country: Country +} + +type Country { + id: ID! + key_id: ID! + region: Region +} + +input StoreInput { + id: ID! + country: CountryInput! +} + +enum Region { + AMERICAS + EUROPE +} + +input CountryInput { + id: ID! + region: Region! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_arg_field_arg_name_mismatch.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_arg_field_arg_name_mismatch.graphql new file mode 100644 index 0000000000..5513dc5889 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_arg_field_arg_name_mismatch.graphql @@ -0,0 +1,17 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + product(id: ID!): Product + @connect( + http: { GET: "http://myapi/products/{$args.id}" } + selection: "not_named_id name" + entity: true + ) +} + +type Product { + not_named_id: ID! + name: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_arg_field_arg_name_mismatch_composite_key.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_arg_field_arg_name_mismatch_composite_key.graphql new file mode 100644 index 0000000000..aba541b6bc --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_arg_field_arg_name_mismatch_composite_key.graphql @@ -0,0 +1,44 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + product(id: ID!, store: StoreInput!): Product + @connect( + http: { + GET: "http://myapi/region/{$args.store.country.region}/country/{$args.store.country.id}/store/{$args.store.id}/products/{$args.id}" + } + selection: "id store { id country { not_named_id region } } name" + entity: true + ) +} + +type Product @key(fields: "id store { id country { not_named_id region } }") { + id: ID! + store: Store! + name: String +} + +type Store { + id: ID! + country: Country +} + +type Country { + not_named_id: ID! + region: Region +} + +input StoreInput { + id: ID! + country: CountryInput! +} + +enum Region { + AMERICAS + EUROPE +} +input CountryInput { + id: ID! + region: Region! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_false_on_type.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_false_on_type.graphql new file mode 100644 index 0000000000..b08bb354d8 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_false_on_type.graphql @@ -0,0 +1,12 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type User + @connect( + http: { GET: "http://127.0.0.1:8000/resources/{$this.id}" } + entity: false + selection: "id name" + ) { + id: ID! + name: String! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_on_list_type.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_on_list_type.graphql new file mode 100644 index 0000000000..37ccdda9de --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_on_list_type.graphql @@ -0,0 +1,16 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + users(id: ID!): [User] + @connect( + http: { GET: "http://127.0.0.1:8000/resources" } + entity: true + selection: "id name" + ) +} + +type User { + id: ID! + name: String! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_on_non_root_field.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_on_non_root_field.graphql new file mode 100644 index 0000000000..c084b38864 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_on_non_root_field.graphql @@ -0,0 +1,22 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + user(id: ID!): User + @connect( + http: { GET: "http://127.0.0.1:8000/resources/{$args.id}" } + entity: true + selection: "id name" + ) +} + +type User { + id: ID! + name: String! + favoriteColor: String + @connect( + http: { GET: "http://127.0.0.1:8000/resources" } + entity: true + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_returning_non_null_type.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_returning_non_null_type.graphql new file mode 100644 index 0000000000..546dcd1fbd --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_returning_non_null_type.graphql @@ -0,0 +1,16 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + user(id: ID!): User! + @connect( + http: { GET: "http://127.0.0.1:8000/resources/{$args.id}" } + entity: true + selection: "id name" + ) +} + +type User { + id: ID! + name: String! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_returning_scalar.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_returning_scalar.graphql new file mode 100644 index 0000000000..e50d668836 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/entity_true_returning_scalar.graphql @@ -0,0 +1,11 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + name: String + @connect( + http: { GET: "http://127.0.0.1:8000/resources" } + entity: true + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/mismatch_batch.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/mismatch_batch.graphql new file mode 100644 index 0000000000..16759e38f2 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/mismatch_batch.graphql @@ -0,0 +1,12 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Product + @connect( + http: { POST: "http://myapi/products/", body: "$batch.id" } + selection: "not_named_id name" + ) { + not_named_id: ID! + name: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/mismatch_composite_key_batch.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/mismatch_composite_key_batch.graphql new file mode 100644 index 0000000000..47df9eff5d --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/mismatch_composite_key_batch.graphql @@ -0,0 +1,46 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Product + @key(fields: "id store { id country { not_named_id region } }") + @connect( + http: { + POST: "http://myapi/region/" + body: """ + ids: $batch.id + store_ids: $batch.store.id + country_ids: $batch.store.country.id + country_regions: $batch.store.country.region + """ + } + selection: "id store { id country { not_named_id region } } name" + ) { + id: ID! + store: Store! + name: String +} + +type Store { + id: ID! + country: Country +} + +type Country { + not_named_id: ID! + region: Region +} + +input StoreInput { + id: ID! + country: CountryInput! +} + +enum Region { + AMERICAS + EUROPE +} +input CountryInput { + id: ID! + region: Region! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/multiple_keys_not_all_resolved.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/multiple_keys_not_all_resolved.graphql new file mode 100644 index 0000000000..215e19580e --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/multiple_keys_not_all_resolved.graphql @@ -0,0 +1,50 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + "The auto-key here matches the first `@key`, but the second `@key` is still unresolved" + product(id: ID!, store: StoreInput!): Product + @connect( + http: { + GET: "http://myapi/region/{$args.store.country.region}/country/{$args.store.country.id}/store/{$args.store.id}/products/{$args.id}" + } + selection: "id store { id country { id key_id key_id2 region } } name" + entity: true + ) +} + +type Product + @key(fields: "id store { id country { id region } }") + @key(fields: "id store { id country { key_id region } }") { + id: ID! + store: Store! + name: String +} + +type Store { + id: ID! + country: Country +} + +type Country { + id: ID! + key_id: ID! + key_id2: ID! + region: Region +} + +input StoreInput { + id: ID! + country: CountryInput! +} + +enum Region { + AMERICAS + EUROPE +} + +input CountryInput { + id: ID! + region: Region! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/multiple_keys_not_all_resolved_batch.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/multiple_keys_not_all_resolved_batch.graphql new file mode 100644 index 0000000000..0e4e9dde2d --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/multiple_keys_not_all_resolved_batch.graphql @@ -0,0 +1,41 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +"The auto-key here matches the first `@key`, but the second `@key` is still unresolved" +type Product + @key(fields: "id store { id country { id region } }") + @key(fields: "id store { id country { key_id region } }") + @connect( + http: { + POST: "http://myapi/" + body: """ + ids: $batch.id + store_ids: $batch.store.id + country_ids: $batch.store.country.id + country_regions: $batch.store.country.region + """ + } + selection: "id store { id country { id key_id key_id2 region } } name" + ) { + id: ID! + store: Store! + name: String +} + +type Store { + id: ID! + country: Country +} + +type Country { + id: ID! + key_id: ID! + key_id2: ID! + region: Region +} + +enum Region { + AMERICAS + EUROPE +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/no_args_for_entity_true.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/no_args_for_entity_true.graphql new file mode 100644 index 0000000000..ad24dc55b3 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/no_args_for_entity_true.graphql @@ -0,0 +1,17 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + product: Product + @connect( + http: { GET: "http://myapi/products" } + selection: "id name" + entity: true + ) +} + +type Product { + id: ID! + name: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/unrelated_keys.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/unrelated_keys.graphql new file mode 100644 index 0000000000..cc383e1333 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/invalid/unrelated_keys.graphql @@ -0,0 +1,28 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + products: [Product] + @connect( + http: { GET: "http://myapi/products" } + selection: "id name store { id } seller { id }" + ) +} + +type Product { + id: ID! + name: String + store: Store + seller: Seller +} + +# error +type Store @key(fields: "id") { + id: ID! +} + +# no error +type Seller @key(fields: "id", resolvable: false) { + id: ID! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/basic_implicit_key.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/basic_implicit_key.graphql new file mode 100644 index 0000000000..3898615620 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/basic_implicit_key.graphql @@ -0,0 +1,16 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + user(id: ID!): User + @connect( + http: { GET: "http://127.0.0.1:8000/resources/{$args.id}" } + entity: true + selection: "id name" + ) +} + +type User { + id: ID! + name: String! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/basic_implicit_key_batch.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/basic_implicit_key_batch.graphql new file mode 100644 index 0000000000..20e6f76f04 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/basic_implicit_key_batch.graphql @@ -0,0 +1,11 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type User + @connect( + http: { POST: "http://127.0.0.1:8000/resources/", body: "$batch.id" } + selection: "id name" + ) { + id: ID! + name: String! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_non_resolvable_key.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_non_resolvable_key.graphql new file mode 100644 index 0000000000..4616faa44f --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_non_resolvable_key.graphql @@ -0,0 +1,17 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + product(id: ID!): Product + @connect( + http: { GET: "http://127.0.0.1:8000/v1/products/{$args.id}" } + entity: true + selection: "id name" + ) +} + +type Product @key(fields: "id", resolvable: false) { + id: ID! + name: String! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_non_resolvable_key_batch.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_non_resolvable_key_batch.graphql new file mode 100644 index 0000000000..3c06ffd95c --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_non_resolvable_key_batch.graphql @@ -0,0 +1,13 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Product + @key(fields: "id", resolvable: false) + @connect( + http: { POST: "http://127.0.0.1:8000/v1/products/", body: "$batch.id" } + selection: "id name" + ) { + id: ID! + name: String! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_one_of_multiple_keys.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_one_of_multiple_keys.graphql new file mode 100644 index 0000000000..f68a299b18 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_one_of_multiple_keys.graphql @@ -0,0 +1,47 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + product(id: ID!, store: StoreInput!): Product + @connect( + http: { + GET: "http://myapi/region/{$args.store.country.region}/country/{$args.store.country.id}/store/{$args.store.id}/products/{$args.id}" + } + selection: "id store { id country { id key_id region } } name" + entity: true + ) +} + +type Product + @key(fields: "id store { id country { key_id region } }", resolvable: false) + @key(fields: "id store { id country { id region } }") { + id: ID! + store: Store! + name: String +} + +type Store { + id: ID! + country: Country +} + +type Country { + id: ID! + key_id: ID! + region: Region +} + +input StoreInput { + id: ID! + country: CountryInput! +} + +enum Region { + AMERICAS + EUROPE +} +input CountryInput { + id: ID! + region: Region! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_one_of_multiple_keys_batch.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_one_of_multiple_keys_batch.graphql new file mode 100644 index 0000000000..f105adeebb --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_connector_matches_one_of_multiple_keys_batch.graphql @@ -0,0 +1,48 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Product + @key(fields: "id store { id country { key_id region } }", resolvable: false) + @key(fields: "id store { id country { id region } }") + @connect( + http: { + POST: "http://myapi/" + body: """ + ids: $batch.id + store_ids: $batch.store.id + country_ids: $batch.store.country.id + country_regions: $batch.store.country.region + """ + } + selection: "id store { id country { id key_id region } } name" + ) { + id: ID! + store: Store! + name: String +} + +type Store { + id: ID! + country: Country +} + +type Country { + id: ID! + key_id: ID! + region: Region +} + +input StoreInput { + id: ID! + country: CountryInput! +} + +enum Region { + AMERICAS + EUROPE +} +input CountryInput { + id: ID! + region: Region! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_field_counts_as_key_resolver.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_field_counts_as_key_resolver.graphql new file mode 100644 index 0000000000..9926bbc79b --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_field_counts_as_key_resolver.graphql @@ -0,0 +1,37 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@key"]) + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v2", http: { baseURL: "http://localhost" }) + +type Query { + price(id: ID!): Price + @connect( + source: "v2" + http: { GET: "/price/{$args.id}" } + selection: "id" + entity: true + ) +} + +type Sku { + id: ID! +} + +type Product @key(fields: "sku { id }") { + sku: Sku! + price: Price + @connect( + source: "v2" + http: { GET: "/products/", body: "$this { sku { id } }" } + selection: """ + id: default_price + """ + ) +} + +type Price { + id: ID! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_field_counts_as_key_resolver_batch.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_field_counts_as_key_resolver_batch.graphql new file mode 100644 index 0000000000..11e4475791 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_field_counts_as_key_resolver_batch.graphql @@ -0,0 +1,32 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v2", http: { baseURL: "http://localhost" }) + +type Sku { + id: ID! +} + +type Product @key(fields: "sku { id }") { + sku: Sku! + price: Price + @connect( + source: "v2" + http: { GET: "/products/", body: "$this.sku.id" } + selection: """ + id: default_price + """ + ) +} + +type Price + @connect( + source: "v2" + http: { POST: "/price/", body: "$batch.id" } + selection: "id" + ) { + id: ID! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_true_on_type.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_true_on_type.graphql new file mode 100644 index 0000000000..adce794511 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/entity_true_on_type.graphql @@ -0,0 +1,12 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type User + @connect( + http: { GET: "http://127.0.0.1:8000/resources/{$this.id}" } + entity: true + selection: "id name" + ) { + id: ID! + name: String! +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/mix_explicit_and_implicit.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/mix_explicit_and_implicit.graphql new file mode 100644 index 0000000000..33d34312d1 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/mix_explicit_and_implicit.graphql @@ -0,0 +1,24 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + implicit(id: ID!): Product + @connect( + http: { GET: "http://myapi/products/{$args.id}" } + selection: "id key_id name" + entity: true + ) + explicit(key_id: ID!): Product + @connect( + http: { GET: "http://myapi/products/{$args.key_id}" } + selection: "id key_id name" + entity: true + ) +} + +type Product @key(fields: "key_id") { + id: ID! + key_id: ID! + name: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/mix_explicit_and_implicit_batch.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/mix_explicit_and_implicit_batch.graphql new file mode 100644 index 0000000000..1816c74de9 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/mix_explicit_and_implicit_batch.graphql @@ -0,0 +1,20 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Product + @key(fields: "key_id") + @connect( + http: { POST: "http://myapi/products/", body: "$batch.id" } + selection: "id key_id name" + entity: true + ) + @connect( + http: { POST: "http://myapi/products/", body: "$batch.key_id" } + selection: "id key_id name" + entity: true + ) { + id: ID! + key_id: ID! + name: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/multiple_entity_connectors_for_multiple_keys.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/multiple_entity_connectors_for_multiple_keys.graphql new file mode 100644 index 0000000000..08bc32503d --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/multiple_entity_connectors_for_multiple_keys.graphql @@ -0,0 +1,31 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@key"]) + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v1", http: { baseURL: "http://localhost" }) + @source(name: "v2", http: { baseURL: "http://localhost" }) + +type Query { + productById(id: ID!): Product + @connect( + source: "v1" + http: { GET: "/products/{$args.id}" } + selection: "id sku name" + entity: true + ) + productBySku(sku: ID!): Product + @connect( + source: "v2" + http: { GET: "/products/{$args.sku}" } + selection: "id sku name" + entity: true + ) +} + +type Product @key(fields: "id") @key(fields: "sku") { + id: ID! + sku: ID! + name: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/multiple_entity_connectors_for_multiple_keys_batch.graphql b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/multiple_entity_connectors_for_multiple_keys_batch.graphql new file mode 100644 index 0000000000..ba283f3287 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/keys_and_entities/valid/multiple_entity_connectors_for_multiple_keys_batch.graphql @@ -0,0 +1,26 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v1", http: { baseURL: "http://localhost" }) + @source(name: "v2", http: { baseURL: "http://localhost" }) + +type Product + @key(fields: "id") + @key(fields: "sku") + @connect( + source: "v1" + http: { POST: "/products/", body: "$batch.id" } + selection: "id sku name" + ) + @connect( + source: "v2" + http: { POST: "/products/", body: "$batch.sku" } + selection: "id sku name" + ) { + id: ID! + sku: ID! + name: String +} diff --git a/apollo-federation/src/connectors/validation/test_data/missing_connect_on_mutation_field.graphql b/apollo-federation/src/connectors/validation/test_data/missing_connect_on_mutation_field.graphql new file mode 100644 index 0000000000..69292055b6 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/missing_connect_on_mutation_field.graphql @@ -0,0 +1,10 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v2", http: { baseURL: "http://127.0.0.1" }) + +type Mutation { + setMessage(message: String): String +} diff --git a/apollo-federation/src/connectors/validation/test_data/missing_connect_on_query_field.graphql b/apollo-federation/src/connectors/validation/test_data/missing_connect_on_query_field.graphql new file mode 100644 index 0000000000..19ba436e28 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/missing_connect_on_query_field.graphql @@ -0,0 +1,10 @@ +extend schema +@link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] +) +@source(name: "v2", http: {baseURL: "http://127.0.0.1"}) + +type Query { + resources: [String!]! +} diff --git a/apollo-federation/src/connectors/validation/test_data/missing_http_method_on_connect.graphql b/apollo-federation/src/connectors/validation/test_data/missing_http_method_on_connect.graphql new file mode 100644 index 0000000000..53f920215d --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/missing_http_method_on_connect.graphql @@ -0,0 +1,10 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@source", "@connect"] + ) + @source(name: "v2", http: { baseURL: "http://127.0.0.1" }) + +type Query { + resources: [String!]! @connect(source: "v2", http: {}, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/missing_source_import.graphql b/apollo-federation/src/connectors/validation/test_data/missing_source_import.graphql new file mode 100644 index 0000000000..d4b3a57345 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/missing_source_import.graphql @@ -0,0 +1,8 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + @source(name: "v2", http: { baseURL: "http://127.0.0.1" }) + +type Query { + resources: [String!]! + @connect(source: "v2", http: { GET: "/resources" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/multiple_errors.graphql b/apollo-federation/src/connectors/validation/test_data/multiple_errors.graphql new file mode 100644 index 0000000000..8abd5ceefa --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/multiple_errors.graphql @@ -0,0 +1,11 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "u$ers", http: { baseURL: "ftp://127.0.0.1" }) + +type Query { + resources: [String!]! + @connect(source: "u$ers", http: { GET: "/resources" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/multiple_http_methods_on_connect.graphql b/apollo-federation/src/connectors/validation/test_data/multiple_http_methods_on_connect.graphql new file mode 100644 index 0000000000..5429f57166 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/multiple_http_methods_on_connect.graphql @@ -0,0 +1,15 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@source", "@connect"] + ) + @source(name: "v2", http: { baseURL: "http://127.0.0.1" }) + +type Query { + resources: [String!]! + @connect( + source: "v2" + http: { GET: "/resources", DELETE: "/resource" } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/non_root_circular_reference.graphql b/apollo-federation/src/connectors/validation/test_data/non_root_circular_reference.graphql new file mode 100644 index 0000000000..5776e1a5e2 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/non_root_circular_reference.graphql @@ -0,0 +1,42 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v2", http: { baseURL: "http://127.0.0.1" }) + +type Query { + user(id: ID!): User + @connect( + http: { GET: "https://api.example.com/users/{$args.id}" } + selection: """ + id + name + favoriteBooks { + id + author { + id + books { + id + } + } + } + """ + ) +} + +type User { + id: ID! + name: String + favoriteBooks: [Book] +} + +type Book { + id: ID! + author: Author +} + +type Author { + id: ID! + books: [Book] +} diff --git a/apollo-federation/src/connectors/validation/test_data/question_v0_2.graphql b/apollo-federation/src/connectors/validation/test_data/question_v0_2.graphql new file mode 100644 index 0000000000..21c6a69795 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/question_v0_2.graphql @@ -0,0 +1,40 @@ +extend schema @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type User { + simpleField: String + otherField: String + otherField2: String + otherField3: OtherEntity +} + +type OtherEntity { + simpleField: String +} + +type Query { + valid: [User] + @connect( + http: { GET: "http://127.0.0.1" } + selection: """ + simpleField: simpleField? + otherField: a?.b + otherField2: c?->d + otherField3: e? { + simpleField + } + """ + ) + + # TODO: This won't fail until selection is checked against Shape + invalid_selection_dollar: [String!]! @connect(http: { GET: "http://127.0.0.1" }, selection: "$?") + + invalid_question_subselection: [String!]! + @connect( + http: { GET: "http://127.0.0.1" } + selection: """ + a: a? { + b + }? + """ + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/question_v0_3.graphql b/apollo-federation/src/connectors/validation/test_data/question_v0_3.graphql new file mode 100644 index 0000000000..26deea107a --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/question_v0_3.graphql @@ -0,0 +1,41 @@ +extend schema @link(url: "https://specs.apollo.dev/connect/v0.3", import: ["@connect"]) + +type User { + simpleField: String + otherField: String + otherField2: String + otherField3: OtherEntity +} + +type OtherEntity { + simpleField: String +} + +type Query { + valid: [User] + @connect( + http: { GET: "http://127.0.0.1" } + selection: """ + simpleField: simpleField? + otherField: a?.b + otherField2: c?->d + otherField3: e? { + simpleField + } + """ + ) + + # This is an unusual use of ? but not invalid by our rules, as $ could be + # null in some situations. + valid_selection_dollar: [String!]! @connect(http: { GET: "http://127.0.0.1" }, selection: "$?") + + invalid_question_subselection: [String!]! + @connect( + http: { GET: "http://127.0.0.1" } + selection: """ + a: a? { + b + }? + """ + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/renamed_connect_directive.graphql b/apollo-federation/src/connectors/validation/test_data/renamed_connect_directive.graphql new file mode 100644 index 0000000000..056764b406 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/renamed_connect_directive.graphql @@ -0,0 +1,10 @@ +extend schema +@link( + url: "https://specs.apollo.dev/connect/v0.2" + import: [{name: "@connect", as: "@data"}, "@source"] +) +@source(name: "v2", http: {baseURL: "http://127.0.0.1"}) + +type Query { + resources: [String!]! +} diff --git a/apollo-federation/src/connectors/validation/test_data/request_headers.graphql b/apollo-federation/src/connectors/validation/test_data/request_headers.graphql new file mode 100644 index 0000000000..38525c9c2f --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/request_headers.graphql @@ -0,0 +1,48 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect", "@source"]) + @source( + name: "invalid_api" + http: { baseURL: "http://127.0.0.1", headers: [{ name: "x-my-header", value: "{$request.headers.someheader}" }] } + ) + @source( + name: "awesome_api" + http: { + baseURL: "http://127.0.0.1" + headers: [{ name: "x-my-header", value: "{$request.headers.someheader->first}" }] + } + ) + +type Query { + pass: [String!]! + @connect( + http: { + GET: "http://127.0.0.1/?something={$request.headers.someheader->first}" + headers: [{ name: "x-my-header", value: "{$request.headers.someheader->first}" }] + } + selection: "$" + ) + failOnArray: [String!]! + @connect( + http: { + GET: "http://127.0.0.1/?something={$request.headers.someheader}" + headers: [{ name: "x-my-header", value: "{$request.headers.someheader}" }] + } + selection: "$" + ) + failOnInvalidRequestProperty: [String!]! + @connect( + http: { + GET: "http://127.0.0.1/?something={$request.x}" + headers: [{ name: "x-my-header", value: "{$request.x}" }] + } + selection: "$" + ) + failOnInvalidObject: [String!]! + @connect( + http: { + GET: "http://127.0.0.1/?something={$request.headers}" + headers: [{ name: "x-my-header", value: "{$request.headers}" }] + } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/select_nonexistant_group.graphql b/apollo-federation/src/connectors/validation/test_data/select_nonexistant_group.graphql new file mode 100644 index 0000000000..ab194b86df --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/select_nonexistant_group.graphql @@ -0,0 +1,13 @@ +extend schema +@link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect"] +) + +type Query { + me: User @connect(http: {GET: "http://127.0.0.1/something"}, selection: "id group { id }") +} + +type User { + id: ID! +} diff --git a/apollo-federation/src/connectors/validation/test_data/source_directive_rename.graphql b/apollo-federation/src/connectors/validation/test_data/source_directive_rename.graphql new file mode 100644 index 0000000000..af6a332418 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/source_directive_rename.graphql @@ -0,0 +1,11 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", { name: "@source", as: "@api" }] + ) + @api(name: "users", http: { baseURL: "blahblahblah" }) + +type Query { + resources: [String!]! + @connect(source: "users", http: { GET: "/resources" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/subscriptions_with_connectors.graphql b/apollo-federation/src/connectors/validation/test_data/subscriptions_with_connectors.graphql new file mode 100644 index 0000000000..73d481bf6a --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/subscriptions_with_connectors.graphql @@ -0,0 +1,15 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v2", http: { baseURL: "http://127.0.0.1" }) + +type Query { + resources: [String!]! + @connect(source: "v2", http: { GET: "/resources" }, selection: "$") +} + +type Subscription { + resourceAdded: String! +} diff --git a/apollo-federation/src/connectors/validation/test_data/transformed/upgrade_0.1.graphql b/apollo-federation/src/connectors/validation/test_data/transformed/upgrade_0.1.graphql new file mode 100644 index 0000000000..55cc2d986a --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/transformed/upgrade_0.1.graphql @@ -0,0 +1,17 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.10" + import: ["@key", "@external", "@requires"] + ) + @link( + url: "https://specs.apollo.dev/connect/v0.1", + import: ["@connect"] + ) + +type Query { + something: String + @connect( + http: { GET: "http://localhost" } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/absolute_connect_url_with_source.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/absolute_connect_url_with_source.graphql new file mode 100644 index 0000000000..563de77a2d --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/absolute_connect_url_with_source.graphql @@ -0,0 +1,15 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v2", http: { baseURL: "http://127.0.0.1" }) + +type Query { + resources: [String!]! + @connect( + source: "v2" + http: { GET: "http://127.0.0.1/resources" } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/expressions-in-domain.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/expressions-in-domain.graphql new file mode 100644 index 0000000000..c62047068a --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/expressions-in-domain.graphql @@ -0,0 +1,28 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + +type Query { + entireDomain: String + @connect( + http: { GET: "http://{$config.notAllowed}" } + selection: "$" + ) + endOfDomain: String + @connect( + http: { GET: "http://example.com{$config.notAllowed}" } + selection: "$" + ) + startOfDomain: String + @connect( + http: { GET: "http://{$config.notAllowed}.example.com" } + selection: "$" + ) + middleOfDomain: String + @connect( + http: { GET: "http://example{$config.notAllowed}.com" } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid-jsonselection-in-expression.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid-jsonselection-in-expression.graphql new file mode 100644 index 0000000000..82facb2bf0 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid-jsonselection-in-expression.graphql @@ -0,0 +1,15 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v2", http: { baseURL: "http://127.0.0.1" }) + +type Query { + resources: [String!]! + @connect( + source: "v2" + http: { GET: "/{blah!}" } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid-path-parameter.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid-path-parameter.graphql new file mode 100644 index 0000000000..79c5eced7a --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid-path-parameter.graphql @@ -0,0 +1,15 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v2", http: { baseURL: "http://127.0.0.1" }) + +type Query { + resources: [String!]! + @connect( + source: "v2" + http: { GET: "/{$blah}" } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_connect_url.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_connect_url.graphql new file mode 100644 index 0000000000..75e1408f5a --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_connect_url.graphql @@ -0,0 +1,6 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + resources: [String!]! @connect(http: { GET: "127.0.0.1" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_connect_url_scheme.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_connect_url_scheme.graphql new file mode 100644 index 0000000000..8c949d51d8 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_connect_url_scheme.graphql @@ -0,0 +1,7 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + resources: [String!]! + @connect(http: { GET: "file://data.json" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_namespace_in_url_template_variables.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_namespace_in_url_template_variables.graphql new file mode 100644 index 0000000000..ab2d7c2132 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_namespace_in_url_template_variables.graphql @@ -0,0 +1,29 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect"] + ) + +type Query { + unknown(bar: String): String + @connect( + http: { + GET: "http://127.0.0.1/{$foo.bar}" + } + selection: "$" + ) + invalid(bar: String): String + @connect( + http: { + GET: "http://127.0.0.1/{$status.bar}" + } + selection: "$" + ) + nodollar(bar: String): String + @connect( + http: { + GET: "http://127.0.0.1/{config.bar}" + } + selection: "$" + ) +} \ No newline at end of file diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_nested_paths_in_url_template_variables.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_nested_paths_in_url_template_variables.graphql new file mode 100644 index 0000000000..53803942cf --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_nested_paths_in_url_template_variables.graphql @@ -0,0 +1,40 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + +type Query { + scalar(scalar: String): String + @connect( + http: { GET: "http://127.0.0.1?something={$args.scalar.blah}" } + selection: "$" + ) + object(input: InputObject): Object + @connect( + http: { GET: "http://127.0.0.1?something={$args.input.fieldThatDoesntExist}" } + selection: "id" + ) + enum(enum: Enum): Enum + @connect( + http: { GET: "http://127.0.0.1?something={$args.enum.cantHaveFields}" } + selection: "$" + ) +} + +type Object { + id: ID! + newField: String + @connect( + http: { GET: "http://127.0.0.1?something={$this.fieldThatDoesntExist}" } + selection: "$" + ) +} + +input InputObject { + id: ID! +} + +enum Enum { + VALUE +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url.graphql new file mode 100644 index 0000000000..ac6a9961f8 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url.graphql @@ -0,0 +1,11 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v1", http: { baseURL: "127.0.0.1" }) + +type Query { + resources: [String!]! + @connect(source: "v1", http: { GET: "/resources" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url_scheme.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url_scheme.graphql new file mode 100644 index 0000000000..33fd6e49da --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url_scheme.graphql @@ -0,0 +1,11 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v1", http: { baseURL: "file://data.json" }) + +type Query { + resources: [String!]! + @connect(source: "v1", http: { GET: "/resources" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url_template.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url_template.graphql new file mode 100644 index 0000000000..d3a188e6a0 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_source_url_template.graphql @@ -0,0 +1,15 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source(name: "v1", http: { baseURL: "https://{$args.var}:{$request.port}" }) + @source(name: "v2", http: { baseURL: "https://{$({})}" }) + + +type Query { + resources: [String!]! + @connect(source: "v1", http: { GET: "/resources" }, selection: "$") + things: [String!]! + @connect(source: "v2", http: { GET: "/things" }, selection: "$") +} \ No newline at end of file diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_types.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_types.graphql new file mode 100644 index 0000000000..2db8affdb9 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/invalid_types.graphql @@ -0,0 +1,42 @@ +extend schema +@link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect"] +) + +type Query { + argIsArray(val: [String]): String + @connect( + http: { GET: "http://127.0.0.1/{$args.val}" } + selection: "$" + ) + + argIsObject(val: Input): String + @connect( + http: { GET: "http://127.0.0.1/{$args.val}" } + selection: "$" + ) +} + +type This { + anArray: [String] @external + thisIsArray: String + @connect( + http: { GET: "http://127.0.0.1/{$this.anArray}" } + selection: "$" + ) + anObject: Object @external + requiresAnObject: String + @connect( + http: { GET: "http://127.0.0.1?obj={$this.anObject}" } + selection: "$" + ) +} + +input Input { + val: String +} + +type Object { + stuff: String @external +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/relative_connect_url_without_source.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/relative_connect_url_without_source.graphql new file mode 100644 index 0000000000..9f161f7311 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/relative_connect_url_without_source.graphql @@ -0,0 +1,7 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + resources: [String!]! @connect(http: { GET: "/resources" }, selection: "$") + dynamic(domain: String): String @connect(http: { GET: "{$dynamic}" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/this_on_root_types.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/this_on_root_types.graphql new file mode 100644 index 0000000000..59168daf20 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/this_on_root_types.graphql @@ -0,0 +1,23 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + +type Query { + static: String @connect(http: { GET: "http://127.0.0.1/static" }, selection: "$") + requiresThis: String + @connect( + http: { GET: "http://127.0.0.1/{$this.static}"} + selection: "$" + ) +} + +type Mutation { + static: String @connect(http: { GET: "http://127.0.0.1/static" }, selection: "$") + requiresThis: String + @connect( + http: { GET: "http://127.0.0.1/{$this.static}"} + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/undefined_arg_in_url_template.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/undefined_arg_in_url_template.graphql new file mode 100644 index 0000000000..11ad0d6e02 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/undefined_arg_in_url_template.graphql @@ -0,0 +1,13 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + +type Query { + resources: [String!]! + @connect( + http: { GET: "http://127.0.0.1/{$args.blah}?something={$args.something}" } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/undefined_this_in_url_template.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/undefined_this_in_url_template.graphql new file mode 100644 index 0000000000..d1f771cd20 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/undefined_this_in_url_template.graphql @@ -0,0 +1,22 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + +type Query { + things: [Something] + @connect( + http: { GET: "http://127.0.0.1/somethings"} + selection: "id" + ) +} + +type Something { + id: ID! + resources: [String!]! + @connect( + http: { GET: "http://127.0.0.1/{$this.blah}?something={$this.something}" } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/valid-expressions-after-domain.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/valid-expressions-after-domain.graphql new file mode 100644 index 0000000000..4ed0cf54a6 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/valid-expressions-after-domain.graphql @@ -0,0 +1,23 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + +type Query { + path: String + @connect( + http: { GET: "http://example.com/{$config.path}" } + selection: "$" + ) + inQuery: String + @connect( + http: { GET: "http://example.com?{$config.query}" } + selection: "$" + ) + inFragment: String + @connect( + http: { GET: "http://example.com#{$config.fragment}" } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_absolute_multiline.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_absolute_multiline.graphql new file mode 100644 index 0000000000..03f552f636 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_absolute_multiline.graphql @@ -0,0 +1,25 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + products(paramName: String, paramValue: String): [Product] + @connect( + http: { GET: """ + https://ecommerce.demo-api.apollo.dev + /products?{$args.paramName}={$args.paramValue} eq 'foo' + """} + selection: """ + $.products { + id + name + description + } + """ + ) +} + +type Product { + id: ID, + name: String, + description: String, +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_absolute_url.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_absolute_url.graphql new file mode 100644 index 0000000000..a1b32325f9 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_absolute_url.graphql @@ -0,0 +1,12 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + resources(anArg: String): [String!]! + @connect( + http: { + GET: "http://127.0.0.1:8000/ with spaces /{$args.anArg}?{$args.anArg}={$args.anArg}" + } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_multiline.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_multiline.graphql new file mode 100644 index 0000000000..d85328e5ef --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/valid_connect_multiline.graphql @@ -0,0 +1,10 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + resources(anArg: String): [String!]! + @connect(http: { GET: """ + http://127.0.0.1:8000/ + {$args.anArg} + ?{$args.anArg}={$args.anArg}""" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/uri_templates/valid_source_url_template.graphql b/apollo-federation/src/connectors/validation/test_data/uri_templates/valid_source_url_template.graphql new file mode 100644 index 0000000000..ca718759b2 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/uri_templates/valid_source_url_template.graphql @@ -0,0 +1,18 @@ +extend schema + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect", "@source"]) + @source(name: "v1", http: { baseURL: "https://{$env.var}:{$config.port}" }) + @source(name: "v2", http: { baseURL: "https://{$env.ENVIRONMENT}" }) + @source(name: "v3", http: { baseURL: "{$env.BASE_URL}" }) + @source( + name: "v4" + http: { + baseURL: "http://{$env.ENVIRONMENT->eq('development')->match([true, 'localhost:20002'], [false, 'api.coolbeans.prod' ])}" + } + ) + +type Query { + resources: [String!]! @connect(source: "v1", http: { GET: "/resources" }, selection: "$") + things: [String!]! @connect(source: "v2", http: { GET: "/things" }, selection: "$") + stuff: [String!]! @connect(source: "v3", http: { GET: "/stuff" }, selection: "$") + items: [String!]! @connect(source: "v4", http: { GET: "/items" }, selection: "$") +} diff --git a/apollo-federation/src/connectors/validation/test_data/url_properties/invalid_mappings.graphql b/apollo-federation/src/connectors/validation/test_data/url_properties/invalid_mappings.graphql new file mode 100644 index 0000000000..d6a794da37 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/url_properties/invalid_mappings.graphql @@ -0,0 +1,18 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source( + name: "v1" + http: { baseURL: "https://example.com", path: ".", queryParams: "." } + ) + +type Query { + resources: [String!]! + @connect( + source: "v1" + http: { GET: "/", path: ".", queryParams: "." } + selection: "$" + ) +} diff --git a/apollo-federation/src/connectors/validation/test_data/url_properties/path.graphql b/apollo-federation/src/connectors/validation/test_data/url_properties/path.graphql new file mode 100644 index 0000000000..202672bb0f --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/url_properties/path.graphql @@ -0,0 +1,48 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source( + name: "v1" + http: { + baseURL: "https://example.com" + path: "$(['good', 42, true, null, ''])" + } + ) + @source(name: "v2", http: { baseURL: "https://example.com", path: "bad" }) + @source( + name: "v3" + http: { baseURL: "https://example.com", path: "$('bad')" } + ) + @source( + name: "v4" + http: { baseURL: "https://example.com", path: "$({ a: 'bad' })" } + ) + +type Query { + resources(s: String, i: Int, f: Float, b: Boolean): [String!]! + @connect( + source: "v2" + http: { GET: "/", path: "$(['good', 42, true, null, ''])" } + selection: "$" + ) + @connect( + source: "v2" + http: { GET: "/", path: "$([$args.s, $args.i, $args.f, $args.b])" } + selection: "$" + ) + @connect(source: "v2", http: { GET: "/", path: "bad" }, selection: "$") + @connect(source: "v2", http: { GET: "/", path: "$('bad')" }, selection: "$") + @connect( + source: "v2" + http: { GET: "/", path: "$({ a: 'bad' })" } + selection: "$" + ) + @connect( + source: "v2" + http: { GET: "/", path: "$config.path" } + selection: "$" + ) # unknown + @connect(source: "v2", http: { GET: "/", path: "$args.s" }, selection: "$") # string, not allowed +} diff --git a/apollo-federation/src/connectors/validation/test_data/url_properties/query_params.graphql b/apollo-federation/src/connectors/validation/test_data/url_properties/query_params.graphql new file mode 100644 index 0000000000..48ee004d03 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/url_properties/query_params.graphql @@ -0,0 +1,70 @@ +extend schema + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source( + name: "v1" + http: { baseURL: "https://example.com", queryParams: "$config.query" } + ) + @source( + name: "v2" + http: { + baseURL: "https://example.com" + queryParams: "$({ s: 's', i: 1, f: .9, b: false, n: null })" + } + ) + @source( + name: "v3" + http: { baseURL: "https://example.com", queryParams: "bad" } + ) + @source( + name: "v4" + http: { baseURL: "https://example.com", queryParams: "$([])" } + ) + @source( + name: "v5" + http: { baseURL: "https://example.com", queryParams: "$('bad')" } + ) + +type Query { + resources(s: String, i: Int, f: Float, b: Boolean): [String!]! + @connect( + source: "v2" + http: { GET: "/", queryParams: "$args" } + selection: "$" + ) + @connect( + source: "v2" + http: { + GET: "/" + queryParams: "$({ s: 's', i: 1, f: .9, b: false, n: null })" + } + selection: "$" + ) + @connect( + source: "v2" + http: { GET: "/", queryParams: "bad" } + selection: "$" + ) + @connect( + source: "v2" + http: { GET: "/", queryParams: "$('bad')" } + selection: "$" + ) + @connect( + source: "v2" + http: { GET: "/", queryParams: "$(['bad'])" } + selection: "$" + ) + @connect( + source: "v2" + http: { GET: "/", queryParams: "$config.query" } + selection: "$" + ) # unknown + @connect( + source: "v2" + http: { GET: "/", queryParams: "$args.s" } + selection: "$" + ) # string, not allowed +} diff --git a/apollo-federation/src/connectors/validation/test_data/valid_large_body.graphql b/apollo-federation/src/connectors/validation/test_data/valid_large_body.graphql new file mode 100644 index 0000000000..e2874cddf3 --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/valid_large_body.graphql @@ -0,0 +1,116 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10") + @link( + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + ) + @source( + name: "api" + http: {baseURL: "https://api.example.com"} + ) + +type Mutation { + mutate(input: [Input!]): String + @connect( + source: "api" + http: { + POST: "/" + body: """ + $args.input { + FIELD1: field1 + field2 + field3 + field4 + field5 + FIELD6: field6 + field7 { + field1 + field2 + field3 + field4 + field5 + field6 + field7 + field8 + field9 + field10 { + field + field2 + } + field11 { + field + field2 + field3 + field4 + field5 + field6 + field7 + field8 + field9 + field10 + } + field12 + field13 + field14 + field15 + field16 + field17 + field18 + field19 + } + } + """ + } + selection: "$" + ) +} + +input Input { + field1: String! + field2: String! + field3: String! + field4: String! + field5: String! + field6: String! + field7: Field7! +} + +input Field7 { + field1: String! + field2: String! + field3: String! + field4: String! + field5: String! + field6: String! + field7: String! + field8: String! + field9: String! + field10: [Field10!] + field11: [Field11!] + field12: String! + field13: String! + field14: String! + field15: String! + field16: String! + field17: String! + field18: String! + field19: String! +} + +input Field10 { + field: String! + field2: String! +} + +input Field11 { + field: String! + field2: String! + field3: String! + field4: String! + field5: String! + field6: String! + field7: String! + field8: String! + field9: String! + field10: String! +} diff --git a/apollo-federation/src/connectors/validation/test_data/valid_selection_with_escapes.graphql b/apollo-federation/src/connectors/validation/test_data/valid_selection_with_escapes.graphql new file mode 100644 index 0000000000..0c248ce70b --- /dev/null +++ b/apollo-federation/src/connectors/validation/test_data/valid_selection_with_escapes.graphql @@ -0,0 +1,30 @@ +extend schema +@link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]) + +type Query { + block: T + @connect( + http: { GET: "http://127.0.0.1/something" } + selection: """ + + + one + two + + + + unicode:$('﷽é€中π'). + """ + ) + standard: T + @connect( + http: { GET: "http://127.0.0.1/something" } + selection: "\n\n\none two\t\t\t\n\n\nunicode:$('\uFDFD\u0065\u0301\u20AC\u4E2D\u03C0')." + ) +} + +type T { + one: String + two: String + unicode: String +} diff --git a/apollo-federation/src/connectors/variable.rs b/apollo-federation/src/connectors/variable.rs new file mode 100644 index 0000000000..c5d03a1369 --- /dev/null +++ b/apollo-federation/src/connectors/variable.rs @@ -0,0 +1,179 @@ +//! Variables used in connector directives `@connect` and `@source`. + +use std::fmt::Display; +use std::fmt::Formatter; +use std::ops::Range; +use std::str::FromStr; + +use itertools::Itertools; + +use super::id::ConnectedElement; +use super::json_selection::SelectionTrie; +use crate::connectors::validation::Code; + +/// A variable context for Apollo Connectors. Variables are used within a `@connect` or `@source` +/// [`Directive`], are used in a particular [`Phase`], and have a specific [`Target`]. +#[derive(Clone, PartialEq)] +pub(crate) struct VariableContext<'schema> { + /// The field definition or type the directive is on + pub(crate) element: &'schema ConnectedElement<'schema>, + + pub(super) phase: Phase, + pub(super) target: Target, +} + +impl<'schema> VariableContext<'schema> { + pub(crate) const fn new( + element: &'schema ConnectedElement<'schema>, + phase: Phase, + target: Target, + ) -> Self { + Self { + element, + phase, + target, + } + } + + /// Get the variable namespaces that are available in this context + pub(crate) fn available_namespaces(&self) -> impl Iterator { + match &self.phase { + Phase::Response => { + vec![ + Namespace::Args, + Namespace::Config, + Namespace::Context, + Namespace::Status, + Namespace::This, + Namespace::Request, + Namespace::Response, + Namespace::Env, + ] + } + } + .into_iter() + } + + /// Get the list of namespaces joined as a comma separated list + pub(crate) fn namespaces_joined(&self) -> String { + self.available_namespaces() + .map(|s| s.to_string()) + .sorted() + .join(", ") + } + + /// Get the error code for this context + pub(crate) const fn error_code(&self) -> Code { + match self.target { + Target::Body => Code::InvalidSelection, + } + } +} + +/// The phase an expression is associated with +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum Phase { + /// The response phase + Response, +} + +/// The target of an expression containing a variable reference +#[allow(unused)] +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum Target { + /// The expression is used in the body of a request or response + Body, +} + +/// The variable namespaces defined for Apollo Connectors +#[derive(PartialEq, Eq, Clone, Copy, Hash)] +pub enum Namespace { + Args, + Config, + Context, + Status, + This, + Batch, + Request, + Response, + Env, +} + +impl Namespace { + pub const fn as_str(&self) -> &'static str { + match self { + Self::Args => "$args", + Self::Config => "$config", + Self::Context => "$context", + Self::Status => "$status", + Self::This => "$this", + Self::Batch => "$batch", + Self::Request => "$request", + Self::Response => "$response", + Self::Env => "$env", + } + } +} + +impl FromStr for Namespace { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "$args" => Ok(Self::Args), + "$config" => Ok(Self::Config), + "$context" => Ok(Self::Context), + "$status" => Ok(Self::Status), + "$this" => Ok(Self::This), + "$batch" => Ok(Self::Batch), + "$request" => Ok(Self::Request), + "$response" => Ok(Self::Response), + "$env" => Ok(Self::Env), + _ => Err(()), + } + } +} + +impl std::fmt::Debug for Namespace { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl Display for Namespace { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// A variable reference. Consists of a namespace starting with a `$` and an optional path +/// separated by '.' characters. +#[derive(Debug, Eq, PartialEq, Clone, Hash)] +pub struct VariableReference { + /// The namespace of the variable - `$this`, `$args`, `$status`, etc. + pub namespace: VariableNamespace, + + /// The path elements of this reference. For example, the reference `$this.a.b.c` + /// has path elements `a`, `b`, `c`. May be empty in some cases, as in the reference `$status`. + pub(crate) selection: SelectionTrie, + + /// The location of the reference within the original text. + pub(crate) location: Option>, +} + +impl Display for VariableReference { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(self.namespace.namespace.to_string().as_str())?; + if !self.selection.is_empty() { + write!(f, " {{ {} }}", self.selection)?; + } + Ok(()) + } +} + +/// A namespace in a variable reference, like `$this` in `$this.a.b.c` +#[derive(Debug, Eq, PartialEq, Clone, Hash)] +pub struct VariableNamespace { + pub namespace: N, + pub(crate) location: Option>, +} diff --git a/apollo-federation/src/correctness/mod.rs b/apollo-federation/src/correctness/mod.rs new file mode 100644 index 0000000000..b3baf95097 --- /dev/null +++ b/apollo-federation/src/correctness/mod.rs @@ -0,0 +1,109 @@ +pub mod query_plan_analysis; +#[cfg(test)] +pub mod query_plan_analysis_test; +mod query_plan_soundness; +#[cfg(test)] +pub mod query_plan_soundness_test; +pub mod response_shape; +pub mod response_shape_compare; +#[cfg(test)] +pub mod response_shape_compare_test; +#[cfg(test)] +pub mod response_shape_test; +mod subgraph_constraint; + +use std::fmt; +use std::sync::Arc; + +use apollo_compiler::ExecutableDocument; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::validation::Valid; +use query_plan_analysis::AnalysisContext; + +use crate::FederationError; +use crate::compat::coerce_executable_values; +use crate::correctness::response_shape_compare::ComparisonError; +use crate::correctness::response_shape_compare::compare_response_shapes_with_constraint; +use crate::query_plan::QueryPlan; +use crate::schema::ValidFederationSchema; + +//================================================================================================== +// Public API + +#[derive(derive_more::From, Debug)] +pub enum CorrectnessError { + /// Correctness checker's own error + FederationError(FederationError), + /// Error in the input that is subject to comparison + ComparisonError(ComparisonError), +} + +impl fmt::Display for CorrectnessError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CorrectnessError::FederationError(err) => { + write!(f, "Correctness check failed to complete: {err}") + } + CorrectnessError::ComparisonError(err) => { + write!(f, "Correctness error found:\n{}", err.description()) + } + } + } +} + +/// Check if `this`'s response shape is a subset of `other`'s response shape. +pub fn compare_operations( + schema: &ValidFederationSchema, + this: &Valid, + other: &Valid, +) -> Result<(), CorrectnessError> { + let this_rs = response_shape::compute_response_shape_for_operation(this, schema)?; + let other_rs = response_shape::compute_response_shape_for_operation(other, schema)?; + tracing::debug!( + "compare_operations:\nResponse shape (left): {this_rs}\nResponse shape (right): {other_rs}" + ); + Ok(response_shape_compare::compare_response_shapes( + &this_rs, &other_rs, + )?) +} + +/// Check the correctness of the query plan against the schema and input operation by comparing +/// the response shape of the input operation and the response shape of the query plan. +/// - The input operation's response shape is supposed to be a subset of the input operation's. +pub fn check_plan( + api_schema: &ValidFederationSchema, + supergraph_schema: &ValidFederationSchema, + subgraphs_by_name: &IndexMap, ValidFederationSchema>, + operation_doc: &Valid, + plan: &QueryPlan, +) -> Result<(), CorrectnessError> { + // Coerce constant expressions in the input operation document since query planner does it for + // subgraph fetch operations. But, this may be unnecessary in the future (see ROUTER-816). + let mut operation_doc = operation_doc.clone().into_inner(); + coerce_executable_values(api_schema.schema(), &mut operation_doc); + let operation_doc = operation_doc + .validate(api_schema.schema()) + .map_err(FederationError::from)?; + + let op_rs = response_shape::compute_response_shape_for_operation(&operation_doc, api_schema)?; + let root_type = response_shape::compute_the_root_type_condition_for_operation(&operation_doc)?; + let context = AnalysisContext::new(supergraph_schema.clone(), subgraphs_by_name); + let plan_rs = + query_plan_analysis::interpret_query_plan(&context, &root_type, plan).map_err(|e| { + ComparisonError::new(format!( + "Failed to compute the response shape from query plan:\n{e}" + )) + })?; + tracing::debug!( + "check_plan:\nOperation response shape: {op_rs}\nQuery plan response shape: {plan_rs}" + ); + + let path_constraint = subgraph_constraint::SubgraphConstraint::at_root(subgraphs_by_name); + let assumption = response_shape::Clause::default(); // empty assumption at the top level + compare_response_shapes_with_constraint(&path_constraint, &assumption, &op_rs, &plan_rs).map_err(|e| { + ComparisonError::new(format!( + "Response shape from query plan does not match response shape from input operation:\n{e}" + )) + })?; + Ok(()) +} diff --git a/apollo-federation/src/correctness/query_plan_analysis.rs b/apollo-federation/src/correctness/query_plan_analysis.rs new file mode 100644 index 0000000000..7bbab1fa98 --- /dev/null +++ b/apollo-federation/src/correctness/query_plan_analysis.rs @@ -0,0 +1,976 @@ +// Analyze a QueryPlan and compute its overall response shape + +use std::collections::HashSet; +use std::sync::Arc; + +use apollo_compiler::collections::IndexMap; +use apollo_compiler::executable::Field; +use apollo_compiler::executable::Name; +use itertools::Itertools; + +use super::query_plan_soundness::check_requires; +use super::response_shape::Clause; +use super::response_shape::DefinitionVariant; +use super::response_shape::Literal; +use super::response_shape::NormalizedTypeCondition; +use super::response_shape::PossibleDefinitions; +use super::response_shape::PossibleDefinitionsPerTypeCondition; +use super::response_shape::ResponseShape; +use super::response_shape::compute_response_shape_for_entity_fetch_operation; +use super::response_shape::compute_response_shape_for_operation; +use super::response_shape_compare::collect_definitions_for_type_condition; +use super::response_shape_compare::collect_variants_for_boolean_condition; +use crate::FederationError; +use crate::SingleFederationError; +use crate::bail; +use crate::query_plan::ConditionNode; +use crate::query_plan::DeferNode; +use crate::query_plan::FetchDataPathElement; +use crate::query_plan::FetchDataRewrite; +use crate::query_plan::FetchNode; +use crate::query_plan::FlattenNode; +use crate::query_plan::ParallelNode; +use crate::query_plan::PlanNode; +use crate::query_plan::QueryPlan; +use crate::query_plan::SequenceNode; +use crate::query_plan::TopLevelPlanNode; +use crate::schema::ValidFederationSchema; +use crate::schema::position::ObjectTypeDefinitionPosition; + +//================================================================================================== +// ResponseShape extra methods to support query plan analysis + +impl ResponseShape { + /// Simplify the boolean conditions in the response shape so that there are no redundant + /// conditions on fields by removing conditions that are also present on an ancestor field. + fn simplify_boolean_conditions(&self) -> Self { + self.inner_simplify_boolean_conditions(&Clause::default()) + } + + fn inner_simplify_boolean_conditions(&self, inherited_clause: &Clause) -> Self { + let mut result = ResponseShape::new(self.default_type_condition().clone()); + for (key, defs) in self.iter() { + let mut updated_defs = PossibleDefinitions::default(); + for (type_cond, defs_per_type_cond) in defs.iter() { + let updated_variants = + defs_per_type_cond + .conditional_variants() + .iter() + .filter_map(|variant| { + let new_clause = variant.boolean_clause().clone(); + inherited_clause.concatenate_and_simplify(&new_clause).map( + |(inherited_clause, field_clause)| { + let sub_rs = + variant.sub_selection_response_shape().as_ref().map(|rs| { + rs.inner_simplify_boolean_conditions(&inherited_clause) + }); + variant.with_updated_fields(field_clause, sub_rs) + }, + ) + }); + let updated_defs_per_type_cond = defs_per_type_cond + .with_updated_conditional_variants(updated_variants.collect()); + updated_defs.insert(type_cond.clone(), updated_defs_per_type_cond); + } + result.insert(key.clone(), updated_defs); + } + result + } + + /// concatenate `added_clause` to each field's boolean condition (only at the top level) + /// then simplify the boolean conditions below the top-level. + fn concatenate_and_simplify_boolean_conditions( + &self, + inherited_clause: &Clause, + added_clause: &Clause, + ) -> Self { + let mut result = ResponseShape::new(self.default_type_condition().clone()); + for (key, defs) in self.iter() { + let mut updated_defs = PossibleDefinitions::default(); + for (type_cond, defs_per_type_cond) in defs.iter() { + let updated_variants = + defs_per_type_cond + .conditional_variants() + .iter() + .filter_map(|variant| { + let new_clause = if added_clause.is_always_true() { + variant.boolean_clause().clone() + } else { + variant.boolean_clause().concatenate(added_clause)? + }; + inherited_clause.concatenate_and_simplify(&new_clause).map( + |(inherited_clause, field_clause)| { + let sub_rs = + variant.sub_selection_response_shape().as_ref().map(|rs| { + rs.inner_simplify_boolean_conditions(&inherited_clause) + }); + variant.with_updated_fields(field_clause, sub_rs) + }, + ) + }); + let updated_defs_per_type_cond = defs_per_type_cond + .with_updated_conditional_variants(updated_variants.collect()); + updated_defs.insert(type_cond.clone(), updated_defs_per_type_cond); + } + result.insert(key.clone(), updated_defs); + } + result + } + + /// Add a new condition to a ResponseShape. + /// - This method is intended for the top-level response shape. + pub(crate) fn add_boolean_conditions(&self, clause: &Clause) -> Self { + self.concatenate_and_simplify_boolean_conditions(&Clause::default(), clause) + } +} + +//================================================================================================== +// Interpretation of QueryPlan +// - `interpret_*_node` functions returns the response shape that will be fetched by the node, +// including its sub-nodes. +// - They take the `state` parameter that represents everything that has been fetched so far, +// which is used to check the soundness property of the query plan. + +/// The common data used by `interpret_query_plan` function and its subroutines. +/// - Contains the supergraph schema and all subgraph schemas. +pub struct AnalysisContext<'a> { + supergraph_schema: ValidFederationSchema, + subgraphs_by_name: &'a IndexMap, ValidFederationSchema>, +} + +impl AnalysisContext<'_> { + pub fn new( + supergraph_schema: ValidFederationSchema, + subgraphs_by_name: &IndexMap, ValidFederationSchema>, + ) -> AnalysisContext<'_> { + AnalysisContext { + supergraph_schema, + subgraphs_by_name, + } + } + + pub fn supergraph_schema(&self) -> &ValidFederationSchema { + &self.supergraph_schema + } + + pub fn subgraphs_by_name(&self) -> &IndexMap, ValidFederationSchema> { + self.subgraphs_by_name + } + + fn get_subgraph_schema(&self, subgraph_name: &str) -> Option<&ValidFederationSchema> { + self.subgraphs_by_name.get(subgraph_name) + } +} + +fn format_federation_error(e: FederationError) -> String { + match e { + FederationError::SingleFederationError(e) => match e { + SingleFederationError::Internal { message } => message, + _ => e.to_string(), + }, + _ => e.to_string(), + } +} + +/// Computes the overall ResponseShape of the query plan while checking the soundness of each +/// entity fetch. +pub fn interpret_query_plan( + context: &AnalysisContext, + root_type: &Name, + plan: &QueryPlan, +) -> Result { + let state = ResponseShape::new(root_type.clone()); + let Some(plan_node) = &plan.node else { + // empty plan + return Ok(state); + }; + interpret_top_level_plan_node(context, &state, plan_node) +} + +fn interpret_top_level_plan_node( + context: &AnalysisContext, + state: &ResponseShape, + node: &TopLevelPlanNode, +) -> Result { + let conditions = vec![]; + match node { + TopLevelPlanNode::Fetch(fetch) => interpret_fetch_node(context, state, &conditions, fetch), + TopLevelPlanNode::Sequence(sequence) => { + interpret_sequence_node(context, state, &conditions, sequence) + } + TopLevelPlanNode::Parallel(parallel) => { + interpret_parallel_node(context, state, &conditions, parallel) + } + TopLevelPlanNode::Flatten(flatten) => { + interpret_flatten_node(context, state, &conditions, flatten) + } + TopLevelPlanNode::Condition(condition) => { + interpret_condition_node(context, state, &conditions, condition) + } + TopLevelPlanNode::Defer(defer) => interpret_defer_node(context, state, &conditions, defer), + TopLevelPlanNode::Subscription(subscription) => { + interpret_subscription_node(context, state, &conditions, subscription) + } + } +} + +/// `conditions` are accumulated conditions to be applied at each fetch node's response shape. +fn interpret_plan_node( + context: &AnalysisContext, + state: &ResponseShape, + conditions: &[Literal], + node: &PlanNode, +) -> Result { + match node { + PlanNode::Fetch(fetch) => interpret_fetch_node(context, state, conditions, fetch), + PlanNode::Sequence(sequence) => { + interpret_sequence_node(context, state, conditions, sequence) + } + PlanNode::Parallel(parallel) => { + interpret_parallel_node(context, state, conditions, parallel) + } + PlanNode::Flatten(flatten) => interpret_flatten_node(context, state, conditions, flatten), + PlanNode::Condition(condition) => { + interpret_condition_node(context, state, conditions, condition) + } + PlanNode::Defer(defer) => interpret_defer_node(context, state, conditions, defer), + } +} + +/// `type_filter`: The type condition to apply to the response shape. +/// - This is from the previous path elements. +/// - It can be empty if there is no type conditions. +/// - Also, multiple type conditions can be accumulated (meaning the conjunction of them). +fn rename_at_path( + schema: &ValidFederationSchema, + response: &ResponseShape, + mut type_filter: Vec, + path: &[FetchDataPathElement], + new_name: Name, +) -> Result { + let Some((first, rest)) = path.split_first() else { + return Err("rename_at_path: unexpected empty path".to_string()); + }; + match first { + FetchDataPathElement::Key(name, _conditions) => { + if _conditions.is_some() { + return Err("rename_at_path: unexpected key conditions".to_string()); + } + let Some(defs) = response.get(name) else { + // If the response doesn't have the named key, skip it and return the same response. + return Ok(response.clone()); + }; + let rename_here = rest.is_empty(); + // Compute the normalized type condition for the type filter. + let type_filter = if let Some((first_type, rest_of_types)) = type_filter.split_first() { + let Some(mut type_condition) = + NormalizedTypeCondition::from_type_name(first_type.clone(), schema) + .map_err(format_federation_error)? + else { + return Err(format!( + "rename_at_path: unexpected empty type condition: {first_type}" + )); + }; + for type_name in rest_of_types { + let Some(updated) = type_condition + .add_type_name(type_name.clone(), schema) + .map_err(format_federation_error)? + else { + return Err(format!( + "rename_at_path: inconsistent type conditions: {type_filter:?}" + )); + }; + type_condition = updated; + } + Some(type_condition) + } else { + None + }; + // Apply renaming in every matching sub-response. + let mut updated_defs = PossibleDefinitions::default(); // for the old name + let mut target_defs = PossibleDefinitions::default(); // for the new name + for (type_cond, defs_per_type_cond) in defs.iter() { + if let Some(type_filter) = &type_filter + && !type_filter.implies(type_cond) + { + // Not applicable => same as before + updated_defs.insert(type_cond.clone(), defs_per_type_cond.clone()); + continue; + } + if rename_here { + // move all definitions to the target_defs + target_defs.insert(type_cond.clone(), defs_per_type_cond.clone()); + continue; + } + + // otherwise, rename in the sub-response + let updated_variants = + defs_per_type_cond + .conditional_variants() + .iter() + .map(|variant| { + let Some(sub_state) = variant.sub_selection_response_shape() else { + return Err(format!( + "No sub-selection at path: {}", + path.iter().join(".") + )); + }; + let updated_sub_state = rename_at_path( + schema, + sub_state, + Default::default(), // new type filter + rest, + new_name.clone(), + )?; + Ok( + variant + .with_updated_sub_selection_response_shape(updated_sub_state), + ) + }); + let updated_variants: Result, _> = updated_variants.collect(); + let updated_variants = updated_variants?; + let updated_defs_per_type_cond = + defs_per_type_cond.with_updated_conditional_variants(updated_variants); + updated_defs.insert(type_cond.clone(), updated_defs_per_type_cond); + } + let mut result = response.clone(); + result.insert(name.clone(), updated_defs); + if rename_here { + // also, update the new response key + let prev_defs = result.get(&new_name); + match prev_defs { + None => { + result.insert(new_name, target_defs); + } + Some(prev_defs) => { + let mut merged_defs = prev_defs.clone(); + for (type_cond, defs_per_type_cond) in target_defs.iter() { + let existed = + merged_defs.insert(type_cond.clone(), defs_per_type_cond.clone()); + if existed { + return Err(format!( + "rename_at_path: new name/type already exists: {new_name} on {type_cond}" + )); + } + } + result.insert(new_name, merged_defs); + } + } + } + Ok(result) + } + FetchDataPathElement::AnyIndex(_conditions) => { + Err("rename_at_path: unexpected AnyIndex path element".to_string()) + } + FetchDataPathElement::TypenameEquals(type_name) => { + type_filter.push(type_name.clone()); + rename_at_path(schema, response, type_filter, rest, new_name) + } + FetchDataPathElement::Parent => { + Err("rename_at_path: unexpected parent path element".to_string()) + } + } +} + +fn apply_output_rewrite( + schema: &ValidFederationSchema, + response: &ResponseShape, + rewrite: &FetchDataRewrite, +) -> Result { + match rewrite { + FetchDataRewrite::ValueSetter(_) => { + Err("apply_output_rewrite: unexpected value setter".to_string()) + } + FetchDataRewrite::KeyRenamer(renamer) => rename_at_path( + schema, + response, + Default::default(), // new type filter + &renamer.path, + renamer.rename_key_to.clone(), + ), + } +} + +fn check_input_rewrite(rewrite: &FetchDataRewrite) -> Result<(), String> { + match rewrite { + FetchDataRewrite::KeyRenamer(rename) => Err(format!( + "check_input_rewrite: unexpected key renamer: {rename:?}" + )), + FetchDataRewrite::ValueSetter(_) => { + // This case is only created in `compute_input_rewrites_on_key_fetch`. It overwrites + // the existing `__typename` response value. But, it won't affect the response shape + // anyways. So, we can ignore it here. + Ok(()) + } + } +} + +fn interpret_fetch_node( + context: &AnalysisContext, + state: &ResponseShape, + conditions: &[Literal], + fetch: &FetchNode, +) -> Result { + let schema = &context.supergraph_schema; + let operation_doc = fetch + .operation_document + .as_parsed() + .map_err(|e| e.to_string())?; + let boolean_clause = Clause::from_literals(conditions); + let mut result = if !fetch.requires.is_empty() { + // Response shapes per entity selection + let response_shapes = + compute_response_shape_for_entity_fetch_operation(operation_doc, schema).map_err( + |e| { + format!( + "Failed to compute the response shape from fetch node: {}\nnode: {fetch}", + format_federation_error(e), + ) + }, + )?; + + // Soundness check + // TODO: also check `context_rewrites` requirements. + let subgraph_name = &fetch.subgraph_name; + let Some(subgraph_schema) = context.get_subgraph_schema(subgraph_name) else { + return Err(format!( + "Subgraph schema not found for {subgraph_name}:\n{fetch}" + )); + }; + check_requires( + context, + subgraph_schema, + state, + &boolean_clause, + &response_shapes, + &fetch.requires, + ) + .map_err(|e| format!("{e}\nfetch node: {fetch}"))?; + + // Compute the merged result from the individual entity response shapes. + merge_response_shapes(response_shapes.iter()).map_err(|e| { + format!( + "Failed to merge response shapes in fetch node: {}\nnode: {fetch}", + format_federation_error(e), + ) + }) + } else { + compute_response_shape_for_operation(operation_doc, schema).map_err(|e| { + format!( + "Failed to compute the response shape from fetch node: {}\nnode: {fetch}", + format_federation_error(e), + ) + }) + } + .map(|rs| rs.add_boolean_conditions(&boolean_clause))?; + for rewrite in fetch.input_rewrites.iter() { + check_input_rewrite(rewrite)?; + } + for rewrite in &fetch.output_rewrites { + result = apply_output_rewrite(schema, &result, rewrite)?; + } + if !fetch.context_rewrites.is_empty() { + result = remove_context_arguments(&fetch.context_rewrites, &result)?; + } + Ok(result) +} + +fn merge_response_shapes<'a>( + mut iter: impl Iterator, +) -> Result { + let Some(first) = iter.next() else { + bail!("No response shapes to merge") + }; + let mut result = first.clone(); + for rs in iter { + result.merge_with(rs)?; + } + Ok(result) +} + +/// Remove context arguments that are added to fetch operations. +/// Returns a new response shape with all field arguments referencing a context variable removed. +fn remove_context_arguments( + context_rewrites: &[Arc], + response: &ResponseShape, +) -> Result { + let context_variables: Result, _> = context_rewrites + .iter() + .map(|rewrite| match rewrite.as_ref() { + FetchDataRewrite::KeyRenamer(renamer) => Ok(renamer.rename_key_to.clone()), + FetchDataRewrite::ValueSetter(_) => { + Err("unexpected value setter in context rewrites".to_string()) + } + }) + .collect(); + Ok(remove_context_arguments_in_response_shape( + &context_variables?, + response, + )) +} + +/// `context_variables`: the set of context variable names +fn remove_context_arguments_in_response_shape( + context_variables: &HashSet, + response_shape: &ResponseShape, +) -> ResponseShape { + let mut result = ResponseShape::new(response_shape.default_type_condition().clone()); + for (key, defs) in response_shape.iter() { + let mut updated_defs = PossibleDefinitions::default(); + for (type_cond, defs_per_type_cond) in defs.iter() { + let updated_selection_key = remove_context_arguments_in_field( + context_variables, + defs_per_type_cond.field_selection_key(), + ); + let updated_variants = + defs_per_type_cond + .conditional_variants() + .iter() + .map(|variant| { + let updated_representative_field = remove_context_arguments_in_field( + context_variables, + variant.representative_field(), + ); + let sub_rs = variant.sub_selection_response_shape().as_ref().map(|rs| { + remove_context_arguments_in_response_shape(context_variables, rs) + }); + DefinitionVariant::new( + variant.boolean_clause().clone(), + updated_representative_field, + sub_rs, + ) + }); + let updated_variants: Vec<_> = updated_variants.collect(); + let updated_defs_per_type_cond = + PossibleDefinitionsPerTypeCondition::new(updated_selection_key, updated_variants); + updated_defs.insert(type_cond.clone(), updated_defs_per_type_cond); + } + result.insert(key.clone(), updated_defs); + } + result +} + +/// `context_variables`: the set of context variable names +fn remove_context_arguments_in_field(context_variables: &HashSet, field: &Field) -> Field { + let arguments = field + .arguments + .iter() + .filter_map(|arg| { + // see if the argument value is one of the context variables + match arg.value.as_variable() { + Some(var) if context_variables.contains(var) => None, + _ => Some(arg.clone()), + } + }) + .collect(); + Field { + arguments, + ..field.clone() + } +} + +/// Add a literal to the conditions +fn append_literal(conditions: &[Literal], literal: Literal) -> Vec { + let mut result = conditions.to_vec(); + result.push(literal); + result +} + +fn interpret_condition_node( + context: &AnalysisContext, + state: &ResponseShape, + conditions: &[Literal], + condition: &ConditionNode, +) -> Result { + let condition_variable = &condition.condition_variable; + match (&condition.if_clause, &condition.else_clause) { + (None, None) => Err("Condition node must have either if or else clause".to_string()), + (Some(if_clause), None) => { + let literal = Literal::Pos(condition_variable.clone()); + let sub_conditions = append_literal(conditions, literal); + Ok(interpret_plan_node( + context, + state, + &sub_conditions, + if_clause, + )?) + } + (None, Some(else_clause)) => { + let literal = Literal::Neg(condition_variable.clone()); + let sub_conditions = append_literal(conditions, literal); + Ok(interpret_plan_node( + context, + state, + &sub_conditions, + else_clause, + )?) + } + (Some(if_clause), Some(else_clause)) => { + let lit_pos = Literal::Pos(condition_variable.clone()); + let lit_neg = Literal::Neg(condition_variable.clone()); + let sub_conditions_pos = append_literal(conditions, lit_pos); + let sub_conditions_neg = append_literal(conditions, lit_neg); + let if_val = interpret_plan_node(context, state, &sub_conditions_pos, if_clause)?; + let else_val = interpret_plan_node(context, state, &sub_conditions_neg, else_clause)?; + let mut result = if_val; + result.merge_with(&else_val).map_err(|e| { + format!("Failed to merge response shapes from then and else clauses:\n{e}",) + })?; + Ok(result) + } + } +} + +/// The inner recursive part of `interpret_flatten_node`. +fn interpret_plan_node_at_path( + context: &AnalysisContext, + state: &ResponseShape, + conditions: &[Literal], + type_condition: Option<&Vec>, + path: &[FetchDataPathElement], + node: &PlanNode, +) -> Result, String> { + let Some((first, rest)) = path.split_first() else { + return Ok(Some(interpret_plan_node(context, state, conditions, node)?)); + }; + match first { + FetchDataPathElement::Key(response_key, next_type_condition) => { + let Some(state_defs) = state.get(response_key) else { + // If some sub-states don't have the key, skip them and return None. + return Ok(None); + }; + let response_defs = interpret_plan_node_for_matching_conditions( + context, + state_defs, + type_condition, + conditions, + next_type_condition, + rest, + node, + )?; + if response_defs.is_empty() { + // No state definitions match the given condition => return None + return Ok(None); + } + let mut response_shape = ResponseShape::new(state.default_type_condition().clone()); + response_shape.insert(response_key.clone(), response_defs); + Ok(Some(response_shape)) + } + FetchDataPathElement::AnyIndex(next_type_condition) => { + if type_condition.is_some() { + return Err("flatten: unexpected multiple type conditions".to_string()); + } + let type_condition = next_type_condition.as_ref(); + interpret_plan_node_at_path(context, state, conditions, type_condition, rest, node) + } + FetchDataPathElement::TypenameEquals(_type_name) => { + Err("flatten: unexpected TypenameEquals variant".to_string()) + } + FetchDataPathElement::Parent => Err("flatten: unexpected Parent variant".to_string()), + } +} + +/// Interpret the plan node for all matching conditions. +/// Returns a collection of definitions by the type and Boolean conditions. +fn interpret_plan_node_for_matching_conditions( + context: &AnalysisContext, + state_defs: &PossibleDefinitions, + type_condition: Option<&Vec>, + boolean_conditions: &[Literal], + next_type_condition: &Option>, + next_path: &[FetchDataPathElement], + node: &PlanNode, +) -> Result { + // Note: `next_type_condition` is applied to the next key down the path. + let schema = &context.supergraph_schema; + let normalized_type_cond = normalize_type_condition(schema, &type_condition)?; + + let mut response_defs = PossibleDefinitions::default(); + if let Some(type_cond) = normalized_type_cond { + // Type-conditioned fetching => only consider one type condition. + if let Some(response_per_type_cond) = interpret_plan_node_under_type_condition( + context, + state_defs, + &type_cond, + boolean_conditions, + next_type_condition, + next_path, + node, + )? { + response_defs.insert(type_cond, response_per_type_cond); + } + } else { + // No type-conditioned fetching => consider each type condition separately. + for (type_cond, _defs_per_type_cond) in state_defs.iter() { + if let Some(response_per_type_cond) = interpret_plan_node_under_type_condition( + context, + state_defs, + type_cond, + boolean_conditions, + next_type_condition, + next_path, + node, + )? { + response_defs.insert(type_cond.clone(), response_per_type_cond); + } + } + } + Ok(response_defs) +} + +fn normalize_type_condition( + schema: &ValidFederationSchema, + type_condition: &Option<&Vec>, +) -> Result, String> { + let Some(type_condition) = type_condition else { + return Ok(None); + }; + let obj_types: Result, _> = type_condition + .iter() + .map(|name| { + let ty: ObjectTypeDefinitionPosition = schema.get_type(name.clone())?.try_into()?; + Ok(ty) + }) + .collect(); + let obj_types = obj_types.map_err(format_federation_error)?; + let result = NormalizedTypeCondition::from_object_types(obj_types.into_iter()) + .map_err(format_federation_error)?; + Ok(Some(result)) +} + +fn interpret_plan_node_under_type_condition( + context: &AnalysisContext, + state_defs: &PossibleDefinitions, + type_cond: &NormalizedTypeCondition, + conditions: &[Literal], + next_type_condition: &Option>, + next_path: &[FetchDataPathElement], + node: &PlanNode, +) -> Result, String> { + // `state_defs` may have multiple overlapping type conditions. We merge them so that the plan + // node is interpreted in the merged state. That means the `requires` conditions can be checked + // against the one whole state, instead of split states. + let Some(merged_state_def) = collect_definitions_for_type_condition(state_defs, type_cond) + .map_err(|e| e.description().to_string())? + else { + // Logically, if no definitions are found for the given type condition (in case of + // type-conditioned fetching), that means this plan node is infeasible (kind of dead-code). + // However, since that's not expected to happen in our query plans, we report an error + // here. + return Err(format!( + "No matching definitions in state for type condition: {type_cond}" + )); + }; + // Interpret the node under every Boolean combination. + let response_variants = merged_state_def + .conditional_variants() + .iter() + .filter_map(|variant| { + let sub_rs = interpret_plan_node_under_boolean_condition( + context, + &merged_state_def, + variant.boolean_clause(), + conditions, + next_type_condition, + next_path, + node, + ); + match sub_rs { + Ok(None) => None, + Ok(Some(sub_rs)) => { + Some(Ok(variant.with_updated_sub_selection_response_shape(sub_rs))) + } + Err(e) => Some(Err(e)), + } + }); + let response_variants: Result, _> = response_variants.collect(); + let response_variants = response_variants?; + if !response_variants.is_empty() { + Ok(Some( + merged_state_def.with_updated_conditional_variants(response_variants), + )) + } else { + // None of the variants are applicable. + Ok(None) + } +} + +fn interpret_plan_node_under_boolean_condition( + context: &AnalysisContext, + state_def: &PossibleDefinitionsPerTypeCondition, + variant_clause: &Clause, + conditions: &[Literal], + next_type_condition: &Option>, + next_path: &[FetchDataPathElement], + node: &PlanNode, +) -> Result, String> { + // We are considering variants that satisfy both the `variant_clause` and the fetch + // `conditions`. We concatenate them into a single full condition. + let Some(full_clause) = variant_clause.concatenate(&Clause::from_literals(conditions)) else { + // This variant's clause is false under the current conditions => skip infeasible variant + return Ok(None); + }; + // Collect all applicable variants into a single merged one for the same reason as explained + // in the `interpret_plan_node_under_type_condition` function. + let Some(merged_variant) = collect_variants_for_boolean_condition(state_def, &full_clause) + .map_err(|e| e.description().to_string())? + else { + // We must have at least one variant for the given clause. + return Err(format!( + "Internal error: failed to collect applicable variants for full clause `{full_clause}`" + )); + }; + let Some(sub_state) = merged_variant.sub_selection_response_shape() else { + // A sub-selection is expected at the FlattenNode path. + return Err(format!("No sub-selection for variant: {merged_variant}")); + }; + interpret_plan_node_at_path( + context, + sub_state, + conditions, + next_type_condition.as_ref(), + next_path, + node, + ) +} + +fn interpret_flatten_node( + context: &AnalysisContext, + state: &ResponseShape, + conditions: &[Literal], + flatten: &FlattenNode, +) -> Result { + let response_shape = interpret_plan_node_at_path( + context, + state, + conditions, + None, // no type condition at the top level + &flatten.path, + &flatten.node, + )?; + let Some(response_shape) = response_shape else { + // `flatten.path` is addressing a non-existing response object. + // Ideally, this should not happen, but QP may try to fetch infeasible selections. + // TODO: Report this as a over-fetching later. + return Ok(ResponseShape::new(state.default_type_condition().clone())); + }; + Ok(response_shape.simplify_boolean_conditions()) +} + +fn interpret_sequence_node( + context: &AnalysisContext, + state: &ResponseShape, + conditions: &[Literal], + sequence: &SequenceNode, +) -> Result { + let mut state = state.clone(); + let mut response_shape = ResponseShape::new(state.default_type_condition().clone()); + for node in &sequence.nodes { + let node_rs = interpret_plan_node(context, &state, conditions, node)?; + + // Update both state and response_shape + state.merge_with(&node_rs).map_err(|e| { + format!( + "Failed to merge state in sequence node: {e}\ + node: {node}", + ) + })?; + response_shape.merge_with(&node_rs).map_err(|e| { + format!( + "Failed to merge response shapes in sequence node: {e}\ + node: {node}" + ) + })?; + } + Ok(response_shape) +} + +fn interpret_parallel_node( + context: &AnalysisContext, + state: &ResponseShape, + conditions: &[Literal], + parallel: &ParallelNode, +) -> Result { + let mut response_shape = ResponseShape::new(state.default_type_condition().clone()); + for node in ¶llel.nodes { + // Note: Use the same original state for each parallel node + let node_rs = interpret_plan_node(context, state, conditions, node)?; + response_shape.merge_with(&node_rs).map_err(|e| { + format!( + "Failed to merge response shapes in parallel node: {e}\ + node: {node}" + ) + })?; + } + Ok(response_shape) +} + +fn interpret_defer_node( + context: &AnalysisContext, + state: &ResponseShape, + conditions: &[Literal], + defer: &DeferNode, +) -> Result { + let mut response_shape; + let mut state = state.clone(); + if let Some(primary_node) = &defer.primary.node { + response_shape = interpret_plan_node(context, &state, conditions, primary_node)?; + // Update the `state` after the primary node + state.merge_with(&response_shape).map_err(|e| { + format!( + "Failed to merge state in defer node: {e}\ + state: {state}\ + primary node response shape: {response_shape}", + ) + })?; + } else { + response_shape = ResponseShape::new(state.default_type_condition().clone()); + } + + // interpret the deferred nodes and merge their response shapes. + for defer_block in &defer.deferred { + let Some(node) = &defer_block.node else { + // Nothing to do => skip + continue; + }; + // Note: Use the same state (after the primary) for each deferred node + let defer_rs = interpret_plan_node(context, &state, conditions, node)?; + response_shape.merge_with(&defer_rs).map_err(|e| { + format!( + "Failed to merge response shapes in deferred node: {e}\ + previous response_shape: {response_shape}\ + deferred block response shape: {defer_rs}", + ) + })?; + } + Ok(response_shape) +} + +fn interpret_subscription_node( + context: &AnalysisContext, + state: &ResponseShape, + conditions: &[Literal], + subscription: &crate::query_plan::SubscriptionNode, +) -> Result { + let mut response_shape = + interpret_fetch_node(context, state, conditions, &subscription.primary)?; + if let Some(rest) = &subscription.rest { + let mut state = state.clone(); + state.merge_with(&response_shape).map_err(|e| { + format!( + "Failed to merge state in subscription node: {e}\ + state: {state}\ + primary response shape: {response_shape}", + ) + })?; + let rest_rs = interpret_plan_node(context, &state, conditions, rest)?; + response_shape.merge_with(&rest_rs).map_err(|e| { + format!( + "Failed to merge response shapes in subscription node: {e}\ + previous response shape: {response_shape}\ + rest response shape: {rest_rs}", + ) + })?; + } + Ok(response_shape) +} diff --git a/apollo-federation/src/correctness/query_plan_analysis_test.rs b/apollo-federation/src/correctness/query_plan_analysis_test.rs new file mode 100644 index 0000000000..6c30a60099 --- /dev/null +++ b/apollo-federation/src/correctness/query_plan_analysis_test.rs @@ -0,0 +1,385 @@ +use apollo_compiler::ExecutableDocument; + +use super::query_plan_analysis::AnalysisContext; +use super::query_plan_analysis::interpret_query_plan; +use super::response_shape::ResponseShape; +use super::*; +use crate::query_plan::query_planner; + +// The schema used in these tests. +const SCHEMA_STR: &str = r#" +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) +{ + query: Query +} + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +interface I + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id") + @join__type(graph: S) +{ + id: ID! + data_a(arg: Int!): String! @join__field(graph: A) + data_b(arg: Int!): String! @join__field(graph: B) + data(arg: Int!): Int! @join__field(graph: S) +} + +scalar join__FieldSet + +enum join__Graph { + A @join__graph(name: "A", url: "local-tests/correctness-issues/boolean-condition-overfetch.graphql?subgraph=A") + B @join__graph(name: "B", url: "local-tests/correctness-issues/boolean-condition-overfetch.graphql?subgraph=B") + S @join__graph(name: "S", url: "local-tests/correctness-issues/boolean-condition-overfetch.graphql?subgraph=S") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type P implements I + @join__implements(graph: A, interface: "I") + @join__implements(graph: B, interface: "I") + @join__implements(graph: S, interface: "I") + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id") + @join__type(graph: S, key: "id") +{ + id: ID! + data_a(arg: Int!): String! @join__field(graph: A) + a_p: Int! @join__field(graph: A) @join__field(graph: S, external: true) + data_b(arg: Int!): String! @join__field(graph: B) + data(arg: Int!): Int! @join__field(graph: S) + s_p: Int! @join__field(graph: S, requires: "a_p") +} + +type Query + @join__type(graph: A) + @join__type(graph: B) + @join__type(graph: S) +{ + test_i: I! @join__field(graph: S) +} + +type T implements I + @join__implements(graph: A, interface: "I") + @join__implements(graph: B, interface: "I") + @join__implements(graph: S, interface: "I") + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id") + @join__type(graph: S, key: "id") +{ + id: ID! + data_a(arg: Int!): String! @join__field(graph: A) + nested: I! @join__field(graph: A) + data_b(arg: Int!): String! @join__field(graph: B) + data(arg: Int!): Int! @join__field(graph: S) +} +"#; + +pub(crate) fn plan_response_shape_with_schema(schema_str: &str, op_str: &str) -> ResponseShape { + // Initialization + let config = query_planner::QueryPlannerConfig { + generate_query_fragments: false, + type_conditioned_fetching: false, + incremental_delivery: query_planner::QueryPlanIncrementalDeliveryConfig { + enable_defer: true, + }, + ..Default::default() + }; + let supergraph = crate::Supergraph::new(schema_str).unwrap(); + let planner = query_planner::QueryPlanner::new(&supergraph, config).unwrap(); + + // Parse the schema and operation + let api_schema = planner.api_schema(); + let op = + ExecutableDocument::parse_and_validate(api_schema.schema(), op_str, "op.graphql").unwrap(); + + // Plan the query + let query_plan = planner + .build_query_plan(&op, None, Default::default()) + .unwrap(); + + // Compare response shapes + let op_rs = response_shape::compute_response_shape_for_operation(&op, api_schema).unwrap(); + let root_type = response_shape::compute_the_root_type_condition_for_operation(&op).unwrap(); + let supergraph_schema = planner.supergraph_schema(); + let subgraphs_by_name = supergraph + .extract_subgraphs() + .unwrap() + .into_iter() + .map(|(name, subgraph)| (name, subgraph.schema)) + .collect(); + let context = AnalysisContext::new(supergraph_schema.clone(), &subgraphs_by_name); + let plan_rs = interpret_query_plan(&context, &root_type, &query_plan).unwrap(); + let path_constraint = subgraph_constraint::SubgraphConstraint::at_root(&subgraphs_by_name); + let assumption = response_shape::Clause::default(); // empty assumption at the top level + assert!( + compare_response_shapes_with_constraint(&path_constraint, &assumption, &op_rs, &plan_rs) + .is_ok() + ); + + plan_rs +} + +fn plan_response_shape(op_str: &str) -> ResponseShape { + plan_response_shape_with_schema(SCHEMA_STR, op_str) +} + +//================================================================================================= +// Basic tests + +#[test] +fn test_single_fetch() { + let op_str = r#" + query { + test_i { + data(arg: 0) + alias1: data(arg: 1) + alias2: data(arg: 1) + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + test_i -----> test_i { + __typename -----> __typename + data -----> data(arg: 0) + alias1 -----> data(arg: 1) + alias2 -----> data(arg: 1) + } + } + "###); +} + +#[test] +fn test_empty_plan() { + let op_str = r#" + query($v0: Boolean!) { + test_i @include(if: $v0) @skip(if:true) { + data(arg: 0) + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + } + "###); +} + +#[test] +fn test_condition_node() { + let op_str = r#" + query($v1: Boolean!) { + test_i @include(if: $v1) { + data(arg: 0) + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + test_i -may-> test_i if v1 { + __typename -----> __typename + data -----> data(arg: 0) + } + } + "###); +} + +#[test] +fn test_sequence_node() { + let op_str = r#" + query($v1: Boolean!) { + test_i @include(if: $v1) { + data(arg: 0) + data_a(arg: 0) + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + test_i -may-> test_i if v1 { + __typename -----> __typename + data -----> data(arg: 0) + id -----> id + data_a -----> data_a(arg: 0) + } + } + "###); +} + +#[test] +fn test_parallel_node() { + let op_str = r#" + query($v1: Boolean!) { + test_i @include(if: $v1) { + data(arg: 0) + data_a(arg: 0) + data_b(arg: 0) + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + test_i -may-> test_i if v1 { + __typename -----> __typename + data -----> data(arg: 0) + id -----> id + data_b -----> data_b(arg: 0) + data_a -----> data_a(arg: 0) + } + } + "###); +} + +#[test] +fn test_defer_node() { + let op_str = r#" + query($v1: Boolean!) { + test_i @include(if: $v1) { + data(arg: 0) + ... @defer { + data_a(arg: 0) + } + ... @defer { + data_b(arg: 0) + } + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + test_i -may-> test_i if v1 { + __typename -----> __typename + data -----> data(arg: 0) + id -----> id + data_b -----> data_b(arg: 0) + data_a -----> data_a(arg: 0) + } + } + "###); +} + +#[test] +fn test_defer_node_nested() { + let op_str = r#" + query($v1: Boolean!) { + test_i @include(if: $v1) { + data(arg: 0) + ... on T @defer { + nested { + ... @defer { + data_b(arg: 1) + } + } + } + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + test_i -may-> test_i if v1 { + __typename -may-> __typename on I + __typename -may-> __typename on T + data -----> data(arg: 0) + id -may-> id on T + nested -may-> nested on T { + __typename -----> __typename + id -----> id + data_b -----> data_b(arg: 1) + } + } + } + "###); +} + +// QP missing ConditionNode bug (FED-505). +// - Note: The correctness checker won't report this, since it's an over-fetching issue. +#[test] +fn test_missing_boolean_condition_over_fetch() { + let op_str = r#" + query($v0: Boolean!) { + test_i { + ... on P @include(if: $v0) { + s_p + } + ... on P @skip(if: $v0) { + a_p + } + } + } + "#; + // Note: `s_p -may-> s_p on P` is supposed to have `if v0` condition. + let rs = plan_response_shape(op_str); + insta::assert_snapshot!(rs, @r###" + { + test_i -----> test_i { + __typename -may-> __typename on I + __typename -may-> __typename on P if v0 + __typename -may-> __typename on P if ¬v0 + id -may-> id on P if v0 + id -may-> id on P if ¬v0 + a_p -may-> a_p on P if v0 + a_p -may-> a_p on P if ¬v0 + s_p -may-> s_p on P + } + } + "###); +} + +// Related to FED-505, but QP is still correct in this case. +#[test] +fn test_missing_boolean_condition_still_correct() { + let op_str = r#" + query($v0: Boolean!) { + test_i { + ... on P @include(if: $v0) { + s_p + } + ... on P @skip(if: $v0) { + s_p + } + } + } + "#; + // Note: `s_p -may-> s_p on P` below is missing Boolean conditions, but still correct. + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + test_i -----> test_i { + __typename -may-> __typename on I + __typename -may-> __typename on P if v0 + __typename -may-> __typename on P if ¬v0 + id -may-> id on P if v0 + id -may-> id on P if ¬v0 + a_p -may-> a_p on P if v0 + a_p -may-> a_p on P if ¬v0 + s_p -may-> s_p on P + } + } + "###); +} diff --git a/apollo-federation/src/correctness/query_plan_soundness.rs b/apollo-federation/src/correctness/query_plan_soundness.rs new file mode 100644 index 0000000000..5a821dd87d --- /dev/null +++ b/apollo-federation/src/correctness/query_plan_soundness.rs @@ -0,0 +1,660 @@ +use std::fmt; + +use apollo_compiler::Node; +use apollo_compiler::ast; +use apollo_compiler::executable; +use apollo_compiler::executable::FieldSet; +use apollo_compiler::executable::Name; +use itertools::Itertools; + +use super::query_plan_analysis::AnalysisContext; +use super::response_shape::Clause; +use super::response_shape::PossibleDefinitions; +use super::response_shape::ResponseShape; +use super::response_shape::compute_response_shape_for_selection_set; +use super::response_shape_compare::compare_representative_field; +use super::response_shape_compare::compare_response_shapes_with_constraint; +use super::subgraph_constraint::SubgraphConstraint; +use crate::FederationError; +use crate::bail; +use crate::internal_error; +use crate::link::federation_spec_definition::FederationSpecDefinition; +use crate::link::federation_spec_definition::KeyDirectiveArguments; +use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; +use crate::query_plan::requires_selection; +use crate::schema::ValidFederationSchema; +use crate::schema::position::CompositeTypeDefinitionPosition; +use crate::schema::position::INTROSPECTION_TYPENAME_FIELD_NAME; +use crate::schema::position::TypeDefinitionPosition; +use crate::utils::FallibleIterator; + +/// Query plan analysis error enum +#[derive(Debug, derive_more::From)] +pub(crate) enum AnalysisErrorMessage { + /// Correctness checker's internal error + FederationError(FederationError), + /// Error in the input query plan + QueryPlanError(String), +} + +pub(crate) struct AnalysisError { + message: AnalysisErrorMessage, + context: Vec, +} + +impl AnalysisError { + pub(crate) fn with_context(mut self, context: impl Into) -> Self { + self.context.push(context.into()); + self + } +} + +impl fmt::Display for AnalysisErrorMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AnalysisErrorMessage::FederationError(err) => write!(f, "{err}"), + AnalysisErrorMessage::QueryPlanError(err) => write!(f, "{err}"), + } + } +} + +impl fmt::Display for AnalysisError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{}", self.message)?; + let num_contexts = self.context.len(); + for (i, ctx) in self.context.iter().enumerate() { + writeln!(f, "[{index}/{num_contexts}] {ctx}", index = i + 1)?; + } + Ok(()) + } +} + +impl From for AnalysisError { + fn from(err: FederationError) -> Self { + AnalysisError { + message: AnalysisErrorMessage::FederationError(err), + context: vec![], + } + } +} + +impl From for AnalysisError { + fn from(err: String) -> Self { + AnalysisError { + message: AnalysisErrorMessage::QueryPlanError(err), + context: vec![], + } + } +} + +//================================================================================================== +// Checking subgraph fetch requirements + +fn compute_response_shape_for_field_set( + schema: &ValidFederationSchema, + parent_type: Name, + field_set: &str, +) -> Result { + // Similar to `crate::schema::field_set::parse_field_set` function. + let field_set = + FieldSet::parse_and_validate(schema.schema(), parent_type, field_set, "field_set.graphql")?; + compute_response_shape_for_selection_set(schema, &field_set.selection_set) +} + +fn compute_response_shape_for_field_set_with_typename( + schema: &ValidFederationSchema, + parent_type: Name, + field_set: &str, +) -> Result { + // Similar to `crate::schema::field_set::parse_field_set` function. + let field_set = + FieldSet::parse_and_validate(schema.schema(), parent_type, field_set, "field_set.graphql")?; + let mut selection_set = field_set.into_inner().selection_set; + let typename = selection_set + .new_field(schema.schema(), INTROSPECTION_TYPENAME_FIELD_NAME.clone()) + .map_err(|_| { + internal_error!( + "Unexpected error: {} not found in schema", + INTROSPECTION_TYPENAME_FIELD_NAME + ) + })?; + selection_set.push(typename); + compute_response_shape_for_selection_set(schema, &selection_set) +} + +/// Used for FetchNode's `requires` field values +/// - The `requires` items are a single inline fragment. +/// - Computes a response shape of the inline fragment and returns it. +fn compute_response_shape_for_require_selection( + schema: &ValidFederationSchema, + require: &requires_selection::Selection, +) -> Result { + let requires_selection::Selection::InlineFragment(inline) = require else { + bail!("Expected require selection to be an inline fragment, but got: {require:#?}") + }; + let Some(type_condition) = &inline.type_condition else { + bail!("Expected a type condition on require inline fragment") + }; + // Convert the `inline`'s selection set into the `executable::SelectionSet` type, + // so we can use `compute_response_shape_for_selection_set` function. + let selections = convert_requires_selections(schema, type_condition, &inline.selections)?; + let mut selection_set = executable::SelectionSet::new(type_condition.clone()); + selection_set.extend(selections); + compute_response_shape_for_selection_set(schema, &selection_set) +} + +/// Converts a `requires_selection::Selection` array into a Vec of `executable::Selection`. +fn convert_requires_selections( + schema: &ValidFederationSchema, + ty: &Name, + selections: &[requires_selection::Selection], +) -> Result, FederationError> { + let mut result = Vec::new(); + for selection in selections { + match selection { + requires_selection::Selection::Field(field) => { + let field_def = get_field_definition(schema, ty, &field.name)?; + // Note: `field` has no alias, arguments nor directive applications. + let converted = executable::Field::new(field.name.clone(), field_def); + let converted = if field.selections.is_empty() { + converted + } else { + let sub_ty = converted.ty().inner_named_type().clone(); + let sub_selections = + convert_requires_selections(schema, &sub_ty, &field.selections)?; + converted.with_selections(sub_selections) + }; + result.push(converted.into()); + } + requires_selection::Selection::InlineFragment(inline) => { + let converted = if let Some(type_condition) = &inline.type_condition { + executable::InlineFragment::with_type_condition(type_condition.clone()) + } else { + executable::InlineFragment::without_type_condition(ty.clone()) + }; + let sub_ty = converted.selection_set.ty.clone(); + let sub_selections = + convert_requires_selections(schema, &sub_ty, &inline.selections)?; + result.push(converted.with_selections(sub_selections).into()); + } + } + } + Ok(result) +} + +/// Get the `ast::FieldDefinition` for the given type and field name from the schema. +fn get_field_definition( + schema: &ValidFederationSchema, + parent_type: &Name, + field_name: &Name, +) -> Result, FederationError> { + let parent_type_pos: CompositeTypeDefinitionPosition = + schema.get_type(parent_type.clone())?.try_into()?; + let field_def_pos = parent_type_pos.field(field_name.clone())?; + field_def_pos + .get(schema.schema()) + .map(|component| component.node.clone()) + .map_err(|err| err.into()) +} + +/// Collect `@requires` conditions from the fields used in the fetch response shape based on their +/// definitions in the subgraph schema. +/// - `response_shape`: the fetch response shape for the entity +/// - Only consider the fields at the root level of the response shape. +/// - The fields may have Boolean conditions. Then, the computed conditions will inherit them. +/// - The collected conditions are merged into a single response shape. +fn collect_require_condition( + supergraph_schema: &ValidFederationSchema, + subgraph_schema: &ValidFederationSchema, + federation_spec_definition: &FederationSpecDefinition, + response_shape: &ResponseShape, +) -> Result { + let requires_directive_definition = + federation_spec_definition.requires_directive_definition(subgraph_schema)?; + let parent_type = response_shape.default_type_condition(); + let mut result = ResponseShape::new(parent_type.clone()); + let all_variants = response_shape + .iter() + .flat_map(|(_key, defs)| defs.iter()) + .flat_map(|(_, per_type_cond)| per_type_cond.conditional_variants()); + for variant in all_variants { + let field_def = &variant.representative_field().definition; + for directive in field_def + .directives + .get_all(&requires_directive_definition.name) + { + let requires_application = + federation_spec_definition.requires_directive_arguments(directive)?; + let rs = compute_response_shape_for_field_set( + supergraph_schema, + parent_type.clone(), + requires_application.fields, + )?; + result.merge_with(&rs.add_boolean_conditions(variant.boolean_clause()))?; + } + } + Ok(result) +} + +/// Check if the `@requires` and `@key` conditions match a `requires` item & the current state. +/// - `type_condition`: the type condition for the entity fetch +/// - `boolean_clause`: the Boolean condition on the fetch node +/// - `entity_require_shape`: the response shape of the `requires` item specified on the fetch node +/// - `require_condition`: the response shape of the computed require condition (from `@requires`). +/// - Note: `entity_require_shape`'s type condition only needs to be a subset of the +/// `type_condition`. So, they don't have to be the same. It happens when the entity's +/// subgraph type is an interface object type. +/// - The key directive and `require_condition` put together must have the same set of response +/// keys as the `entity_require_shape`. +fn key_directive_matches( + context: &AnalysisContext, + state: &ResponseShape, + boolean_clause: &Clause, + entity_require_shape: &ResponseShape, + require_condition: &ResponseShape, + key_directive_application: &KeyDirectiveArguments<'_>, +) -> Result<(), AnalysisError> { + // Note: We use the `entity_require_shape`'s type to interpret the `@key` directive's field + // set, instead of the entity's actual type, since the fetch node's `requires` item may + // be on a more specific type than the entity's type in subgraph. + let key_type_condition = entity_require_shape.default_type_condition(); + let key_condition = compute_response_shape_for_field_set_with_typename( + context.supergraph_schema(), + key_type_condition.clone(), + key_directive_application.fields, + )?; + // `condition`: the whole condition computed from the fetch query & subgraph schema. + let mut condition = key_condition.clone(); + condition.merge_with(require_condition)?; + // Check if `entity_require_shape` is a subset of `condition` in terms of response keys. + if !key_only_compare_response_shapes(entity_require_shape, &condition) { + return Err(format!( + "The `requires` item does not match the subgraph schema\n\ + * @key field set: {key_condition}\n\ + * @requires field set: {require_condition}" + ) + .into()); + } + let final_require_shape = condition.add_boolean_conditions(boolean_clause); + let path_constraint = SubgraphConstraint::at_root(context.subgraphs_by_name()); + let assumption = Clause::default(); // empty assumption at the top level + compare_response_shapes_with_constraint( + &path_constraint, + &assumption, + &final_require_shape, + state, + ) + .map_err(|e| { + format!( + "The state does not satisfy the subgraph schema requirements:\n\ + * schema requires: ({condition_type}) {condition}\n\ + * Comparison error:\n{e}\n\ + * state: ({state_type}) {state}", + condition_type = condition.default_type_condition(), + state_type = state.default_type_condition(), + ) + .into() + }) +} + +/// Check if the entity's `@key`/`@requires` conditions are satisfied in the current `state`. +/// Also, checks if the fetch node's `requires` item for the entity matches the `@key`/`@requires` +/// conditions. +/// - `state`: the input state +/// - `boolean_clause`: the fetch node's Boolean conditions +/// - `entity_response_shape`: the fetch node's response shape for the entity +/// - `entity_require`: the fetch node's `requires` array item for the entity +fn check_require( + context: &AnalysisContext, + subgraph_schema: &ValidFederationSchema, + state: &ResponseShape, + boolean_clause: &Clause, + entity_response_shape: &ResponseShape, + entity_require: &requires_selection::Selection, +) -> Result<(), AnalysisError> { + let subgraph_entity_type_name = entity_response_shape.default_type_condition(); + let subgraph_entity_type_pos = subgraph_schema.get_type(subgraph_entity_type_name.clone())?; + let directives = match &subgraph_entity_type_pos { + TypeDefinitionPosition::Object(type_pos) => { + let type_def = type_pos + .get(subgraph_schema.schema()) + .map_err(FederationError::from)?; + &type_def.directives + } + TypeDefinitionPosition::Interface(type_pos) => { + let type_def = type_pos + .get(subgraph_schema.schema()) + .map_err(FederationError::from)?; + &type_def.directives + } + _ => bail!("check_require: unexpected kind of entity type: {subgraph_entity_type_name}"), + }; + + let entity_require_shape = + compute_response_shape_for_require_selection(context.supergraph_schema(), entity_require) + .map_err(|err| { + format!( + "check_require: failed to compute response shape:\n{err}\n\ + require selection: {entity_require}" + ) + })?; + + let federation_spec_definition = get_federation_spec_definition_from_subgraph(subgraph_schema)?; + // Collect all applicable `@requires` field sets and put them in a response shape. + let require_condition = collect_require_condition( + context.supergraph_schema(), + subgraph_schema, + federation_spec_definition, + entity_response_shape, + ) + .map_err(|err| { + format!( + "check_require: failed to collect require conditions from the subgraph schema:\n\ + {err}\n\ + entity_response_shape: {entity_response_shape}" + ) + })?; + + // Find the matching `@key` directive + // - Note: The type condition may have multiple `@key` directives. Try find one that works. + let key_directive_definition = + federation_spec_definition.key_directive_definition(subgraph_schema)?; + let mut mismatch_cases = Vec::new(); + let mut unresolvable_cases = Vec::new(); + let found = directives + .get_all(&key_directive_definition.name) + .map(|directive| federation_spec_definition.key_directive_arguments(directive)) + .ok_and_any(|ref key_directive_application| { + match key_directive_matches( + context, + state, + boolean_clause, + &entity_require_shape, + &require_condition, + key_directive_application, + ) { + Ok(_) => { + if key_directive_application.resolvable { + true + } else { + unresolvable_cases.push(format!( + "The matched @key directive is not resolvable.\n\ + * @key field set: {key_field_set}", + key_field_set = key_directive_application.fields + )); + false + } + } + Err(e) => { + mismatch_cases.push(e); + false + } + } + })?; + if found { + Ok(()) + } else { + // soundness error + if unresolvable_cases.is_empty() { + if mismatch_cases.len() == 1 { + Err(format!( + "check_require: no matching require condition found (@key didn't match)\n\ + * plan requires: {entity_require_shape}\n\ + Mismatch description:\n{mismatch_cases}", + mismatch_cases = mismatch_cases.iter().map(|e| e.to_string()).join("\n") + ) + .into()) + } else { + Err(format!( + "check_require: no matching require condition found (all @key directives failed to match)\n\ + * plan requires: {entity_require_shape}\n\ + Mismatches:\n{mismatch_cases}", + mismatch_cases = mismatch_cases.iter().enumerate().map(|(i, e)| + format!("[{index}/{bound}] {e}", index=i+1, bound=mismatch_cases.len()) + ).join("\n") + ).into()) + } + } else { + Err(format!( + "check_require: @key matched, but none of them are resolvable.\n\ + * plan requires: {entity_require_shape}\n\ + Unresolvable cases:\n{unresolvable_cases}", + unresolvable_cases = unresolvable_cases + .iter() + .enumerate() + .map(|(i, e)| format!( + "[{index}/{bound}] {e}", + index = i + 1, + bound = unresolvable_cases.len() + )) + .join("\n") + ) + .into()) + } + } +} + +/// Check subgraph requirements for all entity fetches +/// - `state`: the input state +/// - `boolean_clause`: the fetch node's Boolean conditions +/// - `response_shapes`: the fetch node's response shape for entities +/// - `requires`: the fetch node's `requires` field value for entities +pub(crate) fn check_requires( + context: &AnalysisContext, + subgraph_schema: &ValidFederationSchema, + state: &ResponseShape, + boolean_clause: &Clause, + response_shapes: &[ResponseShape], + requires: &[requires_selection::Selection], +) -> Result<(), AnalysisError> { + // The `requires` array and `response_shape` array should match. So, usually they are 1:1. + // However, the entity fetch operation may be simplified by merging identical entity fetches. + // In that case, the `requires` array may have more items than the `response_shape` array and + // we need to find a corresponding entity response shape item for each `requires` item. + match requires.len().cmp(&response_shapes.len()) { + std::cmp::Ordering::Less => Err( + "check_requires: Fewer number of requires items than entity fetch cases" + .to_string() + .into(), + ), + std::cmp::Ordering::Equal => { + // 1:1 match + for (rs, require) in response_shapes.iter().zip(requires.iter()) { + check_require( + context, + subgraph_schema, + state, + boolean_clause, + rs, + require, + ) + .map_err(|e| { + e.with_context(format!( + "check_requires: Subgraph require check failed for type condition: {type_condition}", + type_condition = rs.default_type_condition() + )) + })?; + } + Ok(()) + } + std::cmp::Ordering::Greater => { + // 1:many => check if each `requires` item has a matching entity fetch case + requires.iter().try_for_each(|require| { + let mut errors = Vec::new(); + let has_any = response_shapes.iter().any(|rs| { + match check_require( + context, + subgraph_schema, + state, + boolean_clause, + rs, + require, + ) { + Ok(_) => true, + Err(e) => { errors.push(e); false } + } + }); + if has_any { + Ok(()) + } else { + Err(format!( + "check_requires: Subgraph require check failed to find a matching entity fetch for the requires item: {require}\nErrors:\n{errors}", + errors = errors.iter().enumerate().map(|(i, e)| + format!("[{index}/{bound}] {e}", index=i+1, bound=errors.len()) + ).join("\n"), + ).into()) + } + }) + } + } +} + +//================================================================================================== +// Key-only ResponseShape comparison +// - This is used to verify that each `requires` item on the fetch node represents the actual +// subgraph constraints specified in the subgraph schema's `@key/@requires` directives. +// - The `requires` items on FetchNode only have the response key name (field name) without +// arguments, directives nor aliases. +// * Thus, their definitions (and response shapes) won't have Boolean conditions. +// - `@key/@requires` directive's field set selections can't have aliases nor directives. +// * Note: `@requires` response shape inherits the Boolean condition from their individual entity +// fetch selections. Thus, Boolean conditions can be different and one response key +// may have multiple Boolean variants. +// - `requires` items and `@key/@requires` directive's field set selections have this common +// property: Their response keys will always be the same as their definition's field name. +// * Even though the field arguments are missing in the `requires` items, since aliasing is not +// allowed, we can still match them just by their their response keys. +// - Note: The type condition on `requires` item can be different from its corresponding entity +// type. The entity type can be a superset of the `requires` item's type condition, when the +// entity type in the subgraph is an interface object type. +// - Argument convention: For each comparison function, +// * `this` is the response shape of the `requires` item. +// * `other` is the response shape of the `@key/@requires` directives from the subgraph schema. +// - Essentially, this module is a simplified version of the `response_shape_compare` module. +// +// Example fetch node: +// FetchNode(service_name: "supply") { +// ... on Movie { # suppose `Movie` implements the `Product` interface. +// id # from a `@key` directive +// data # from a `@requires` directive (but the argument is missing) +// } +// } => { +// ... on Product { # `Product` is an interface object type in this subgraph. +// name +// sku @include(if: $includeSku) +// # The `@requires` response shape inherits this Boolean condition. +// } +// } +// +// Suppose the `Product` type is defined in the subgraph schema as following, +// type Product @key(fields: "id") { +// id: ID! +// name: String! +// sku: String! @requires(fields: "data(arg: 42)") +// } + +mod key_only_response_shape_compare { + use super::super::response_shape::DefinitionVariant; + use super::super::response_shape::PossibleDefinitionsPerTypeCondition; + use super::*; + + /// Check if the key set of `this` is the same as that of `other` (ignoring their definitions) + pub(super) fn key_only_compare_response_shapes( + this: &ResponseShape, + other: &ResponseShape, + ) -> bool { + // Should have the exact same set of response keys. + this.len() == other.len() + && this.iter().all(|(key, this_def)| { + let Some(other_def) = other.get(key) else { + return false; + }; + key_only_compare_possible_definitions(this_def, other_def) + }) + } + + fn key_only_compare_possible_definitions( + this: &PossibleDefinitions, + other: &PossibleDefinitions, + ) -> bool { + // Should have the exact same set of type conditions. + this.len() == other.len() + && this.iter().all(|(this_cond, this_def)| { + let Some(other_def) = other.get(this_cond) else { + return false; + }; + key_only_compare_possible_definitions_per_type_condition(this_def, other_def) + }) + } + + fn key_only_compare_possible_definitions_per_type_condition( + this: &PossibleDefinitionsPerTypeCondition, + other: &PossibleDefinitionsPerTypeCondition, + ) -> bool { + // The `this` should have exactly one variant with no Boolean conditions, since the + // `requires` item won't have this detail. + // On the other hand, the `other` can have variants with Boolean conditions. But, the + // variants are merged since their Boolean conditions are ignored. + if this.conditional_variants().len() != 1 { + return false; + } + this.conditional_variants().iter().all(|this_def| { + let Some(merged_def) = merge_variants_ignoring_boolean_conditions(other) else { + return false; + }; + key_only_compare_definition_variant(this_def, &merged_def) + }) + } + + fn merge_variants_ignoring_boolean_conditions( + other: &PossibleDefinitionsPerTypeCondition, + ) -> Option { + // Note: Similar to `collect_variants_for_boolean_condition`'s implementation. + let mut iter = other.conditional_variants().iter(); + let first = iter.next()?; + let mut result_sub = first.sub_selection_response_shape().cloned(); + for variant in iter { + if compare_representative_field( + variant.representative_field(), + first.representative_field(), + ) + .is_err() + { + // Unexpected: GraphQL invariant violation + return None; + } + match (&mut result_sub, variant.sub_selection_response_shape()) { + (None, None) => {} + (Some(result_sub), Some(variant_sub)) => { + let result = result_sub.merge_with(variant_sub); + if result.is_err() { + return None; + } + } + _ => { + return None; + } + } + } + Some(first.with_updated_fields(Clause::default(), result_sub)) + } + + fn key_only_compare_definition_variant( + this: &DefinitionVariant, + other: &DefinitionVariant, + ) -> bool { + // Note: The `boolean_clause` of DefinitionVariant is ignored. + match ( + this.sub_selection_response_shape(), + other.sub_selection_response_shape(), + ) { + (None, None) => true, + (Some(this_sub), Some(other_sub)) => { + key_only_compare_response_shapes(this_sub, other_sub) + } + _ => false, + } + } +} + +use key_only_response_shape_compare::key_only_compare_response_shapes; diff --git a/apollo-federation/src/correctness/query_plan_soundness_test.rs b/apollo-federation/src/correctness/query_plan_soundness_test.rs new file mode 100644 index 0000000000..7fdfc463f7 --- /dev/null +++ b/apollo-federation/src/correctness/query_plan_soundness_test.rs @@ -0,0 +1,189 @@ +use super::query_plan_analysis_test::plan_response_shape_with_schema; +use super::response_shape::ResponseShape; + +// The schema used in these tests. +const SCHEMA_STR: &str = r#" +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) +{ + query: Query +} + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar join__FieldSet + +enum join__Graph { + A @join__graph(name: "A", url: "test-template.graphql?subgraph=A") + S @join__graph(name: "S", url: "test-template.graphql?subgraph=S") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type P + @join__type(graph: A) + @join__type(graph: S, key: "id") +{ + id: ID! + p_data(arg: Int!): Int! @join__field(graph: A, external: true) @join__field(graph: S) +} + +type Query + @join__type(graph: A) + @join__type(graph: S) +{ + start_t: T! @join__field(graph: S) +} + +type T + @join__type(graph: A, key: "id") + @join__type(graph: S, key: "id") +{ + id: ID! + data: Int! @join__field(graph: A, requires: "pre(arg: 0)") + data2: Int! @join__field(graph: A, requires: "pre2(arg: 2)") + data3: Int! @join__field(graph: A, requires: "p { p_data(arg: 1) }") + pre(arg: Int!): Int! @join__field(graph: A, external: true) @join__field(graph: S) + pre2(arg: Int!): Int! @join__field(graph: A, external: true) @join__field(graph: S) + p: P! @join__field(graph: A) +} +"#; + +fn plan_response_shape(op_str: &str) -> ResponseShape { + plan_response_shape_with_schema(SCHEMA_STR, op_str) +} + +//================================================================================================= +// Basic tests + +#[test] +fn test_requires_basic() { + let op_str = r#" + query { + start_t { + data + data2 + data3 + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + start_t -----> start_t { + __typename -----> __typename + id -----> id + pre2 -----> pre2(arg: 2) + pre -----> pre(arg: 0) + p -----> p { + __typename -----> __typename + id -----> id + p_data -----> p_data(arg: 1) + } + data -----> data + data2 -----> data2 + data3 -----> data3 + } + } + "###); +} + +#[test] +fn test_requires_conditional() { + let op_str = r#" + query($v0: Boolean!) { + start_t { + data + data2 @include(if: $v0) + } + } + "#; + // Note: `pre2` is conditional just like `data2` is conditional. + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + start_t -----> start_t { + __typename -----> __typename + id -----> id + pre2 -may-> pre2(arg: 2) if v0 + pre -----> pre(arg: 0) + data -----> data + data2 -may-> data2 if v0 + } + } + "###); +} + +#[test] +fn test_requires_conditional_multiple_variants() { + let op_str = r#" + query($v0: Boolean!) { + start_t { + data + data @include(if: $v0) # creates multi variant requires + } + } + "#; + // Note: `pre` has two conditional variants just like the `data`. + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + start_t -----> start_t { + __typename -----> __typename + id -----> id + pre -may-> pre(arg: 0) if v0 + pre -may-> pre(arg: 0) + data -may-> data + data -may-> data if v0 + } + } + "###); +} + +#[test] +fn test_requires_external_under_non_external() { + let op_str = r#" + query { + start_t { + data3 + } + } + "#; + // Note: `p` is a nested selection set from a `@requires` directive. + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + start_t -----> start_t { + __typename -----> __typename + id -----> id + p -----> p { + __typename -----> __typename + id -----> id + p_data -----> p_data(arg: 1) + } + data3 -----> data3 + } + } + "###); +} diff --git a/apollo-federation/src/correctness/response_shape.rs b/apollo-federation/src/correctness/response_shape.rs new file mode 100644 index 0000000000..628dbb147e --- /dev/null +++ b/apollo-federation/src/correctness/response_shape.rs @@ -0,0 +1,1510 @@ +use std::fmt; +use std::sync::Arc; + +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; +use apollo_compiler::executable::Field; +use apollo_compiler::executable::Fragment; +use apollo_compiler::executable::FragmentMap; +use apollo_compiler::executable::Operation; +use apollo_compiler::executable::Selection; +use apollo_compiler::executable::SelectionSet; +use apollo_compiler::validation::Valid; + +use crate::FederationError; +use crate::bail; +use crate::display_helpers; +use crate::ensure; +use crate::internal_error; +use crate::schema::ValidFederationSchema; +use crate::schema::position::CompositeTypeDefinitionPosition; +use crate::schema::position::INTROSPECTION_TYPENAME_FIELD_NAME; +use crate::schema::position::InterfaceTypeDefinitionPosition; +use crate::schema::position::ObjectTypeDefinitionPosition; +use crate::utils::FallibleIterator; + +//================================================================================================== +// Vec utilities + +fn vec_sorted_by(src: &[T], compare: impl Fn(&T, &T) -> std::cmp::Ordering) -> Vec { + let mut sorted = src.to_owned(); + sorted.sort_by(&compare); + sorted +} + +//================================================================================================== +// Type conditions + +fn get_interface_implementers<'a>( + interface: &InterfaceTypeDefinitionPosition, + schema: &'a ValidFederationSchema, +) -> Result<&'a IndexSet, FederationError> { + Ok(&schema + .referencers() + .get_interface_type(&interface.type_name)? + .object_types) +} + +/// Does `x` implies `y`? (`x`'s possible types is a subset of `y`'s possible types) +/// - All type-definition positions are in the given schema. +// Note: Similar to `runtime_types_intersect` (avoids using `possible_runtime_types`) +fn runtime_types_implies( + x: &CompositeTypeDefinitionPosition, + y: &CompositeTypeDefinitionPosition, + schema: &ValidFederationSchema, +) -> Result { + use CompositeTypeDefinitionPosition::*; + match (x, y) { + (Object(x), Object(y)) => Ok(x == y), + (Object(object), Union(union)) => { + // Union members must be object types in GraphQL. + let union_type = union.get(schema.schema())?; + Ok(union_type.members.contains(&object.type_name)) + } + (Union(union), Object(object)) => { + // Is `object` the only member of `union`? + let union_type = union.get(schema.schema())?; + Ok(union_type.members.len() == 1 && union_type.members.contains(&object.type_name)) + } + (Object(object), Interface(interface)) => { + // Interface implementers must be object types in GraphQL. + let interface_implementers = get_interface_implementers(interface, schema)?; + Ok(interface_implementers.contains(object)) + } + (Interface(interface), Object(object)) => { + // Is `object` the only implementer of `interface`? + let interface_implementers = get_interface_implementers(interface, schema)?; + Ok(interface_implementers.len() == 1 && interface_implementers.contains(object)) + } + + (Union(x), Union(y)) if x == y => Ok(true), + (Union(x), Union(y)) => { + let (x, y) = (x.get(schema.schema())?, y.get(schema.schema())?); + Ok(x.members.is_subset(&y.members)) + } + + (Interface(x), Interface(y)) if x == y => Ok(true), + (Interface(x), Interface(y)) => { + let x = get_interface_implementers(x, schema)?; + let y = get_interface_implementers(y, schema)?; + Ok(x.is_subset(y)) + } + + (Union(union), Interface(interface)) => { + let union = union.get(schema.schema())?; + let interface_implementers = get_interface_implementers(interface, schema)?; + Ok(union.members.iter().all(|m| { + let m_ty = ObjectTypeDefinitionPosition::new(m.name.clone()); + interface_implementers.contains(&m_ty) + })) + } + (Interface(interface), Union(union)) => { + let interface_implementers = get_interface_implementers(interface, schema)?; + let union = union.get(schema.schema())?; + Ok(interface_implementers + .iter() + .all(|t| union.members.contains(&t.type_name))) + } + } +} + +/// Constructs a set of object types +/// - Slow: calls `possible_runtime_types` and sorts the result. +/// - Note: May return an empty set if the type has no runtime types. +fn get_ground_types( + ty: &CompositeTypeDefinitionPosition, + schema: &ValidFederationSchema, +) -> Result, FederationError> { + let mut result = schema.possible_runtime_types(ty.clone())?; + result.sort_by(|a, b| a.type_name.cmp(&b.type_name)); + Ok(result.into_iter().collect()) +} + +/// A sequence of type conditions applied (used for display) +// - This displays a type condition as an intersection of named types. +// - If the vector is empty, it means a "deduced type condition". +// Thus, we may not know how to display such a composition of types. +// That can happen when a more specific type condition is computed +// than the one that was explicitly provided. +#[derive(Debug, Clone)] +struct DisplayTypeCondition(Vec); + +impl DisplayTypeCondition { + fn new(ty: CompositeTypeDefinitionPosition) -> Self { + DisplayTypeCondition(vec![ty]) + } + + fn deduced() -> Self { + DisplayTypeCondition(Vec::new()) + } + + /// Construct a new type condition with a named type condition added. + fn add_type_name( + &self, + name: Name, + schema: &ValidFederationSchema, + ) -> Result { + let ty: CompositeTypeDefinitionPosition = schema.get_type(name)?.try_into()?; + if self + .0 + .iter() + .fallible_any(|t| runtime_types_implies(t, &ty, schema))? + { + return Ok(self.clone()); + } + // filter out existing conditions that are implied by `ty`. + let mut buf = Vec::new(); + for t in &self.0 { + if !runtime_types_implies(&ty, t, schema)? { + buf.push(t.clone()); + } + } + buf.push(ty); + buf.sort_by(|a, b| a.type_name().cmp(b.type_name())); + Ok(DisplayTypeCondition(buf)) + } +} + +/// Aggregated type conditions that are normalized for comparison +#[derive(Debug, Clone)] +pub struct NormalizedTypeCondition { + // The set of object types that are used for comparison. + // - The ground_set must be non-empty. + // - The ground_set must be sorted by type name. + ground_set: Vec, + + // Simplified type condition for display. + for_display: DisplayTypeCondition, +} + +impl PartialEq for NormalizedTypeCondition { + fn eq(&self, other: &Self) -> bool { + self.ground_set == other.ground_set + } +} + +impl Eq for NormalizedTypeCondition {} + +impl std::hash::Hash for NormalizedTypeCondition { + fn hash(&self, state: &mut H) { + self.ground_set.hash(state); + } +} + +// Public constructors & accessors +impl NormalizedTypeCondition { + /// Construct a new type condition with a single named type condition. + /// - Returns None if the name type has no runtime types (an interface with no implementors). + pub(crate) fn from_type_name( + name: Name, + schema: &ValidFederationSchema, + ) -> Result, FederationError> { + let ty: CompositeTypeDefinitionPosition = schema.get_type(name)?.try_into()?; + let ground_set = get_ground_types(&ty, schema)?; + if ground_set.is_empty() { + return Ok(None); + } + Ok(Some(NormalizedTypeCondition { + ground_set, + for_display: DisplayTypeCondition::new(ty), + })) + } + + pub(crate) fn from_object_type(ty: &ObjectTypeDefinitionPosition) -> Self { + NormalizedTypeCondition { + ground_set: vec![ty.clone()], + for_display: DisplayTypeCondition::new(ty.clone().into()), + } + } + + /// Precondition: `types` must be non-empty. + pub(crate) fn from_object_types( + types: impl Iterator, + ) -> Result { + let mut ground_set: Vec<_> = types.collect(); + if ground_set.is_empty() { + bail!("Unexpected empty type list for from_object_types") + } + ground_set.sort_by(|a, b| a.type_name.cmp(&b.type_name)); + Ok(NormalizedTypeCondition { + ground_set, + for_display: DisplayTypeCondition::deduced(), + }) + } + + pub(crate) fn ground_set(&self) -> &[ObjectTypeDefinitionPosition] { + &self.ground_set + } + + /// Is this type condition represented by a single named type? + pub fn is_named_type(&self, type_name: &Name) -> bool { + // Check the display type first. + let Some((first, rest)) = self.for_display.0.split_first() else { + return false; + }; + if rest.is_empty() && first.type_name() == type_name { + return true; + } + + // Check the ground set. + let Some((first, rest)) = self.ground_set.split_first() else { + return false; + }; + rest.is_empty() && first.type_name == *type_name + } + + /// Is this type condition a named object type? + pub fn is_named_object_type(&self) -> bool { + let Some((display_first, display_rest)) = self.for_display.0.split_first() else { + // Deduced condition is not an object type. + return false; + }; + display_rest.is_empty() && display_first.is_object_type() + } + + pub fn implies(&self, other: &Self) -> bool { + self.ground_set.iter().all(|t| other.ground_set.contains(t)) + } +} + +impl NormalizedTypeCondition { + /// Construct a new type condition with a named type condition added. + /// - Returns None if the new type condition is unsatisfiable. + pub(crate) fn add_type_name( + &self, + name: Name, + schema: &ValidFederationSchema, + ) -> Result, FederationError> { + let other_ty: CompositeTypeDefinitionPosition = + schema.get_type(name.clone())?.try_into()?; + let other_types = get_ground_types(&other_ty, schema)?; + let ground_set: Vec = self + .ground_set + .iter() + .filter(|t| other_types.contains(t)) + .cloned() + .collect(); + if ground_set.is_empty() { + // Unsatisfiable condition + Ok(None) + } else { + let for_display = if ground_set.len() == self.ground_set.len() { + // unchanged + self.for_display.clone() + } else { + self.for_display.add_type_name(name, schema)? + }; + Ok(Some(NormalizedTypeCondition { + ground_set, + for_display, + })) + } + } + + /// Compute the `field`'s type condition considering the parent type condition. + /// - Returns None if the resulting type condition has no possible object types. + fn field_type_condition( + &self, + field: &Field, + schema: &ValidFederationSchema, + ) -> Result, FederationError> { + let declared_type = field.ty().inner_named_type(); + + // Collect all possible object types for the field in the given parent type condition. + let mut types = IndexSet::default(); + for ty_pos in &self.ground_set { + let ty_def = ty_pos.get(schema.schema())?; + let Some(field_def) = ty_def.fields.get(&field.name) else { + continue; + }; + let field_ty = field_def.ty.inner_named_type().clone(); + types.insert(field_ty); + } + + // Simple case #1 - The collected types is just a single named type. + if types.len() == 1 + && let Some(first) = types.first() + { + return NormalizedTypeCondition::from_type_name(first.clone(), schema); + } + + // Grind the type names into object types. + let mut ground_types = IndexSet::default(); + for ty in &types { + let pos = schema.get_type(ty.clone())?.try_into()?; + let pos_types = schema.possible_runtime_types(pos)?; + ground_types.extend(pos_types.into_iter()); + } + if ground_types.is_empty() { + return Ok(None); + } + + // Simple case #2 - `declared_type` is same as the collected types. + if let Some(declared_type_cond) = + NormalizedTypeCondition::from_type_name(declared_type.clone(), schema)? + && declared_type_cond.ground_set.len() == ground_types.len() + && declared_type_cond + .ground_set + .iter() + .all(|t| ground_types.contains(t)) + { + return Ok(Some(declared_type_cond)); + } + + Ok(Some(NormalizedTypeCondition::from_object_types( + ground_types.into_iter(), + )?)) + } +} + +//================================================================================================== +// Boolean conditions + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Literal { + Pos(Name), // positive occurrence of the variable with the given name + Neg(Name), // negated variable with the given name +} + +impl Literal { + pub fn variable(&self) -> &Name { + match self { + Literal::Pos(name) | Literal::Neg(name) => name, + } + } + + pub fn polarity(&self) -> bool { + matches!(self, Literal::Pos(_)) + } +} + +// A clause is a conjunction of literals. +// Empty Clause means "true". +// "false" can't be represented. Any cases with false condition must be dropped entirely. +// This vector must be sorted by the variable name. +// This vector must be deduplicated (every variant appears only once). +// Thus, no conflicting literals are allowed (e.g., `x` and `¬x`). +#[derive(Debug, Clone, Default, Eq)] +pub struct Clause(Vec); + +impl Clause { + pub fn literals(&self) -> &[Literal] { + &self.0 + } + + pub fn is_always_true(&self) -> bool { + self.0.is_empty() + } + + /// check if `self` implies `other` + /// - The literals in `other` is a subset of `self`. + pub fn implies(&self, other: &Clause) -> bool { + let mut self_variables: IndexMap = IndexMap::default(); + // Assume that `self` has no conflicts. + for lit in &self.0 { + self_variables.insert(lit.variable().clone(), lit.polarity()); + } + other.0.iter().all(|lit| { + self_variables + .get(lit.variable()) + .is_some_and(|pol| *pol == lit.polarity()) + }) + } + + /// Creates a clause from a vector of literals. + pub fn from_literals(literals: &[Literal]) -> Self { + let variables: IndexMap = literals + .iter() + .map(|lit| (lit.variable().clone(), lit.polarity())) + .collect(); + Self::from_variable_map(&variables) + } + + /// Creates a clause from a variable-to-Boolean mapping. + /// variables: variable name (Name) -> polarity (bool) + fn from_variable_map(variables: &IndexMap) -> Self { + let mut buf: Vec = variables + .iter() + .map(|(name, polarity)| match polarity { + false => Literal::Neg(name.clone()), + true => Literal::Pos(name.clone()), + }) + .collect(); + buf.sort_by(|a, b| a.variable().cmp(b.variable())); + Clause(buf) + } + + /// `self` ∧ `other` (logical conjunction of clauses, which is also set-union) + /// - Returns None if there is a conflict. + pub fn concatenate(&self, other: &Clause) -> Option { + let mut variables: IndexMap = IndexMap::default(); + // Assume that `self` has no conflicts. + for lit in &self.0 { + variables.insert(lit.variable().clone(), lit.polarity()); + } + for lit in &other.0 { + let var = lit.variable(); + let entry = variables.entry(var.clone()).or_insert(lit.polarity()); + if *entry != lit.polarity() { + return None; // conflict + } + } + Some(Self::from_variable_map(&variables)) + } + + /// `self` - `other` (set subtraction) + /// - Returns None if `self` and `other` are conflicting. + pub fn subtract(&self, other: &Clause) -> Option { + let mut other_variables: IndexMap = IndexMap::default(); + for lit in &other.0 { + other_variables.insert(lit.variable().clone(), lit.polarity()); + } + + let mut variables: IndexMap = IndexMap::default(); + for lit in &self.0 { + let var = lit.variable(); + if let Some(pol) = other_variables.get(var) { + if *pol == lit.polarity() { + // Match => Skip `lit` + continue; + } else { + // Conflict + return None; + } + } else { + // Keep `lit` + variables.insert(var.clone(), lit.polarity()); + } + } + Some(Self::from_variable_map(&variables)) + } + + fn add_selection_directives( + &self, + directives: &ast::DirectiveList, + ) -> Result, FederationError> { + let Some(selection_clause) = boolean_clause_from_directives(directives)? else { + // The condition is unsatisfiable within the field itself. + return Ok(None); + }; + Ok(self.concatenate(&selection_clause)) + } + + /// Returns a clause with everything included and a simplified version of the `clause`. + /// - The simplified clause does not include variables that are already in `self`. + pub fn concatenate_and_simplify(&self, clause: &Clause) -> Option<(Clause, Clause)> { + let mut all_variables: IndexMap = IndexMap::default(); + // Load `self` on `variables`. + // - Assume that `self` has no conflicts. + for lit in &self.0 { + all_variables.insert(lit.variable().clone(), lit.polarity()); + } + + let mut added_variables: IndexMap = IndexMap::default(); + for lit in &clause.0 { + let var = lit.variable(); + match all_variables.entry(var.clone()) { + indexmap::map::Entry::Occupied(entry) => { + if entry.get() != &lit.polarity() { + return None; // conflict + } + } + indexmap::map::Entry::Vacant(entry) => { + entry.insert(lit.polarity()); + added_variables.insert(var.clone(), lit.polarity()); + } + } + } + Some(( + Self::from_variable_map(&all_variables), + Self::from_variable_map(&added_variables), + )) + } +} + +impl PartialEq for Clause { + fn eq(&self, other: &Self) -> bool { + // assume: The underlying vectors are deduplicated. + self.0.len() == other.0.len() && self.0.iter().all(|l| other.0.contains(l)) + } +} + +//================================================================================================== +// Normalization of Field Selection + +/// Extracts the Boolean clause from the directive list. +// Similar to `Conditions::from_directives` in `conditions.rs`. +fn boolean_clause_from_directives( + directives: &ast::DirectiveList, +) -> Result, FederationError> { + let mut variables = IndexMap::default(); // variable name (Name) -> polarity (bool) + if let Some(skip) = directives.get("skip") { + let Some(value) = skip.specified_argument_by_name("if") else { + bail!("missing @skip(if:) argument") + }; + + match value.as_ref() { + // Constant @skip(if: true) can never match + ast::Value::Boolean(true) => return Ok(None), + // Constant @skip(if: false) always matches + ast::Value::Boolean(_) => {} + ast::Value::Variable(name) => { + variables.insert(name.clone(), false); + } + _ => { + bail!("expected boolean or variable `if` argument, got {value}") + } + } + } + + if let Some(include) = directives.get("include") { + let Some(value) = include.specified_argument_by_name("if") else { + bail!("missing @include(if:) argument") + }; + + match value.as_ref() { + // Constant @include(if: false) can never match + ast::Value::Boolean(false) => return Ok(None), + // Constant @include(if: true) always matches + ast::Value::Boolean(true) => {} + // If both @skip(if: $var) and @include(if: $var) exist, the condition can also + // never match + ast::Value::Variable(name) => { + if variables.insert(name.clone(), true) == Some(false) { + // Conflict found + return Ok(None); + } + } + _ => { + bail!("expected boolean or variable `if` argument, got {value}") + } + } + } + Ok(Some(Clause::from_variable_map(&variables))) +} + +fn normalize_ast_value(v: &mut ast::Value) { + // special cases + match v { + // Sort object fields by name + ast::Value::Object(fields) => { + fields.sort_by(|a, b| a.0.cmp(&b.0)); + for (_name, value) in fields { + normalize_ast_value(value.make_mut()); + } + } + + // Recurse into list items. + ast::Value::List(items) => { + for value in items { + normalize_ast_value(value.make_mut()); + } + } + + _ => (), // otherwise, do nothing + } +} + +fn normalized_arguments(args: &[Node]) -> Vec> { + // sort by name + let mut args = vec_sorted_by(args, |a, b| a.name.cmp(&b.name)); + // normalize argument values in place + for arg in &mut args { + normalize_ast_value(arg.make_mut().value.make_mut()); + } + args +} + +fn remove_conditions_from_directives(directives: &ast::DirectiveList) -> ast::DirectiveList { + directives + .iter() + .filter(|d| d.name != "skip" && d.name != "include") + .cloned() + .collect() +} + +pub type FieldSelectionKey = Field; + +// Extract the selection key +fn field_selection_key(field: &Field) -> FieldSelectionKey { + Field { + definition: field.definition.clone(), + alias: None, // not used for comparison + name: field.name.clone(), + arguments: normalized_arguments(&field.arguments), + directives: ast::DirectiveList::default(), // not used for comparison + selection_set: SelectionSet::new(field.selection_set.ty.clone()), // not used for comparison + } +} + +fn eq_field_selection_key(a: &FieldSelectionKey, b: &FieldSelectionKey) -> bool { + // Note: Arguments are expected to be normalized. + a.name == b.name && a.arguments == b.arguments +} + +//================================================================================================== +// ResponseShape + +/// Simplified field value used for display purposes +fn field_display(field: &Field) -> Field { + Field { + definition: field.definition.clone(), + alias: None, // not used for display + name: field.name.clone(), + arguments: field.arguments.clone(), + directives: remove_conditions_from_directives(&field.directives), + selection_set: SelectionSet::new(field.selection_set.ty.clone()), // not used for display + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct DefinitionVariant { + /// Boolean clause is the secondary key after NormalizedTypeCondition as primary key. + boolean_clause: Clause, + + /// Representative field selection for definition/display (see `fn field_display`). + /// - This is the first field of the same field selection key in depth-first order as + /// defined by `CollectFields` and `ExecuteField` algorithms in the GraphQL spec. + representative_field: Field, + + /// Different variants can have different sets of sub-selections (if any). + sub_selection_response_shape: Option, +} + +impl DefinitionVariant { + pub fn boolean_clause(&self) -> &Clause { + &self.boolean_clause + } + + pub fn representative_field(&self) -> &Field { + &self.representative_field + } + + pub fn sub_selection_response_shape(&self) -> Option<&ResponseShape> { + self.sub_selection_response_shape.as_ref() + } + + pub fn with_updated_clause(&self, boolean_clause: Clause) -> Self { + DefinitionVariant { + boolean_clause, + representative_field: self.representative_field.clone(), + sub_selection_response_shape: self.sub_selection_response_shape.clone(), + } + } + + pub fn with_updated_sub_selection_response_shape(&self, new_shape: ResponseShape) -> Self { + DefinitionVariant { + boolean_clause: self.boolean_clause.clone(), + representative_field: self.representative_field.clone(), + sub_selection_response_shape: Some(new_shape), + } + } + + pub fn with_updated_fields( + &self, + boolean_clause: Clause, + sub_selection_response_shape: Option, + ) -> Self { + DefinitionVariant { + boolean_clause, + sub_selection_response_shape, + representative_field: self.representative_field.clone(), + } + } + + pub fn new( + boolean_clause: Clause, + representative_field: Field, + sub_selection_response_shape: Option, + ) -> Self { + DefinitionVariant { + boolean_clause, + representative_field, + sub_selection_response_shape, + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct PossibleDefinitionsPerTypeCondition { + /// The key for comparison (only used for GraphQL invariant check). + /// - Under each type condition, all variants must have the same selection key. + field_selection_key: FieldSelectionKey, + + /// Under each type condition, there may be multiple variants with different Boolean conditions. + conditional_variants: Vec, + // - Every variant's Boolean condition must be unique. + // - Note: The Boolean conditions between variants may not be mutually exclusive. +} + +impl PossibleDefinitionsPerTypeCondition { + pub fn field_selection_key(&self) -> &FieldSelectionKey { + &self.field_selection_key + } + + pub fn conditional_variants(&self) -> &[DefinitionVariant] { + &self.conditional_variants + } + + pub fn with_updated_conditional_variants(&self, new_variants: Vec) -> Self { + PossibleDefinitionsPerTypeCondition { + field_selection_key: self.field_selection_key.clone(), + conditional_variants: new_variants, + } + } + + pub fn new( + field_selection_key: FieldSelectionKey, + conditional_variants: Vec, + ) -> Self { + PossibleDefinitionsPerTypeCondition { + field_selection_key, + conditional_variants, + } + } + + pub(crate) fn insert_variant( + &mut self, + variant: DefinitionVariant, + ) -> Result<(), FederationError> { + for existing in &mut self.conditional_variants { + if existing.boolean_clause == variant.boolean_clause { + // Merge response shapes (MergeSelectionSets from GraphQL spec 6.4.3) + match ( + &mut existing.sub_selection_response_shape, + variant.sub_selection_response_shape, + ) { + (None, None) => {} // nothing to do + (Some(existing_rs), Some(ref variant_rs)) => { + existing_rs.merge_with(variant_rs)?; + } + (None, Some(_)) | (Some(_), None) => { + unreachable!("mismatched sub-selection options") + } + } + return Ok(()); + } + } + self.conditional_variants.push(variant); + Ok(()) + } +} + +/// All possible definitions that a response key can have. +/// - At the top level, all possibilities are indexed by the type condition. +/// - However, they are not necessarily mutually exclusive. +#[derive(Debug, Default, PartialEq, Eq, Clone)] +pub struct PossibleDefinitions( + IndexMap, +); + +// Public accessors +impl PossibleDefinitions { + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn iter( + &self, + ) -> impl Iterator< + Item = ( + &NormalizedTypeCondition, + &PossibleDefinitionsPerTypeCondition, + ), + > { + self.0.iter() + } + + pub fn get( + &self, + type_cond: &NormalizedTypeCondition, + ) -> Option<&PossibleDefinitionsPerTypeCondition> { + self.0.get(type_cond) + } + + pub fn insert( + &mut self, + type_condition: NormalizedTypeCondition, + value: PossibleDefinitionsPerTypeCondition, + ) -> bool { + self.0.insert(type_condition, value).is_some() + } +} + +impl PossibleDefinitions { + fn insert_possible_definition( + &mut self, + type_conditions: NormalizedTypeCondition, + boolean_clause: Clause, // the aggregate boolean condition of the current selection set + representative_field: Field, + sub_selection_response_shape: Option, + ) -> Result<(), FederationError> { + let field_selection_key = field_selection_key(&representative_field); + let entry = self.0.entry(type_conditions); + let insert_variant = |per_type_cond: &mut PossibleDefinitionsPerTypeCondition| { + let value = DefinitionVariant { + boolean_clause, + representative_field, + sub_selection_response_shape, + }; + per_type_cond.insert_variant(value) + }; + match entry { + indexmap::map::Entry::Vacant(e) => { + // New type condition + let empty_per_type_cond = PossibleDefinitionsPerTypeCondition { + field_selection_key, + conditional_variants: vec![], + }; + insert_variant(e.insert(empty_per_type_cond))?; + } + indexmap::map::Entry::Occupied(mut e) => { + // GraphQL invariant: per_type_cond.field_selection_key must be the same + // as the given field_selection_key. + if !eq_field_selection_key(&e.get().field_selection_key, &field_selection_key) { + return Err(internal_error!( + "field_selection_key was expected to be the same\nexisting: {}\nadding: {}", + e.get().field_selection_key, + field_selection_key, + )); + } + insert_variant(e.get_mut())?; + } + }; + Ok(()) + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ResponseShape { + /// The default type condition is only used for display. + default_type_condition: Name, + definitions_per_response_key: IndexMap, +} + +impl ResponseShape { + pub fn default_type_condition(&self) -> &Name { + &self.default_type_condition + } + + pub fn is_empty(&self) -> bool { + self.definitions_per_response_key.is_empty() + } + + pub fn len(&self) -> usize { + self.definitions_per_response_key.len() + } + + pub fn iter(&self) -> impl Iterator { + self.definitions_per_response_key.iter() + } + + pub fn get(&self, response_key: &Name) -> Option<&PossibleDefinitions> { + self.definitions_per_response_key.get(response_key) + } + + pub fn insert(&mut self, response_key: Name, value: PossibleDefinitions) -> bool { + self.definitions_per_response_key + .insert(response_key, value) + .is_some() + } + + pub fn new(default_type_condition: Name) -> Self { + ResponseShape { + default_type_condition, + definitions_per_response_key: IndexMap::default(), + } + } + + pub fn merge_with(&mut self, other: &Self) -> Result<(), FederationError> { + for (response_key, other_defs) in &other.definitions_per_response_key { + let value = self + .definitions_per_response_key + .entry(response_key.clone()) + .or_default(); + for (type_condition, per_type_cond) in &other_defs.0 { + for variant in &per_type_cond.conditional_variants { + value.insert_possible_definition( + type_condition.clone(), + variant.boolean_clause.clone(), + variant.representative_field.clone(), + variant.sub_selection_response_shape.clone(), + )?; + } + } + } + Ok(()) + } +} + +//================================================================================================== +// ResponseShape computation from operation + +struct ResponseShapeContext { + schema: ValidFederationSchema, + fragment_defs: Arc>>, // fragment definitions in the operation + parent_type: Name, // the type of the current selection set + type_condition: NormalizedTypeCondition, // accumulated type condition down from the parent field. + inherited_clause: Clause, // accumulated conditions from the root up to parent field + current_clause: Clause, // accumulated conditions down from the parent field + skip_introspection: bool, // true for input operation's root contexts only +} + +impl ResponseShapeContext { + fn process_selection( + &self, + response_shape: &mut ResponseShape, + selection: &Selection, + ) -> Result<(), FederationError> { + match selection { + Selection::Field(field) => self.process_field_selection(response_shape, field), + Selection::FragmentSpread(fragment_spread) => { + let fragment_def = + get_fragment_definition(&self.fragment_defs, &fragment_spread.fragment_name)?; + // Note: `@skip/@include` directives are not allowed on fragment definitions. + // Thus, no need to check their directives for Boolean conditions. + self.process_fragment_selection( + response_shape, + fragment_def.type_condition(), + &fragment_spread.directives, + &fragment_def.selection_set, + ) + } + Selection::InlineFragment(inline_fragment) => { + let fragment_type_condition = inline_fragment + .type_condition + .as_ref() + .unwrap_or(&self.parent_type); + self.process_fragment_selection( + response_shape, + fragment_type_condition, + &inline_fragment.directives, + &inline_fragment.selection_set, + ) + } + } + } + + fn process_field_selection( + &self, + response_shape: &mut ResponseShape, + field: &Node, + ) -> Result<(), FederationError> { + // Skip __typename fields in the input root context. + if self.skip_introspection && field.name == *INTROSPECTION_TYPENAME_FIELD_NAME { + return Ok(()); + } + // Skip introspection fields since QP ignores them. + // (see comments on `FieldSelection::from_field`) + if is_introspection_field_name(&field.name) { + return Ok(()); + } + let Some(field_clause) = self + .current_clause + .add_selection_directives(&field.directives)? + else { + // Unsatisfiable local condition under the parent field => skip + return Ok(()); + }; + let Some((inherited_clause, field_clause)) = self + .inherited_clause + .concatenate_and_simplify(&field_clause) + else { + // Unsatisfiable full condition from the root => skip + return Ok(()); + }; + // Process the field's sub-selection + let sub_selection_response_shape: Option = if field.selection_set.is_empty() + { + None + } else { + // The field's declared type may not be the most specific type (in case of up-casting). + + // internal invariant check + ensure!( + *field.ty().inner_named_type() == field.selection_set.ty, + "internal invariant failure: field's type does not match with its selection set's type" + ); + + // A brand new context with the new type condition. + // - Still inherits the boolean conditions for simplification purposes. + let parent_type = field.selection_set.ty.clone(); + self.type_condition + .field_type_condition(field, &self.schema)? + .map(|type_condition| { + let context = ResponseShapeContext { + schema: self.schema.clone(), + fragment_defs: self.fragment_defs.clone(), + parent_type, + type_condition, + inherited_clause, + current_clause: Clause::default(), // empty + skip_introspection: false, // false by default + }; + context.process_selection_set(&field.selection_set) + }) + .transpose()? + }; + // Record this selection's definition. + let value = response_shape + .definitions_per_response_key + .entry(field.response_key().clone()) + .or_default(); + value.insert_possible_definition( + self.type_condition.clone(), + field_clause, + field_display(field), + sub_selection_response_shape, + ) + } + + /// For both inline fragments and fragment spreads + fn process_fragment_selection( + &self, + response_shape: &mut ResponseShape, + fragment_type_condition: &Name, + directives: &ast::DirectiveList, + selection_set: &SelectionSet, + ) -> Result<(), FederationError> { + // internal invariant check + ensure!( + *fragment_type_condition == selection_set.ty, + "internal invariant failure: fragment's type condition does not match with its selection set's type" + ); + + let Some(type_condition) = NormalizedTypeCondition::add_type_name( + &self.type_condition, + fragment_type_condition.clone(), + &self.schema, + )? + else { + // Unsatisfiable type condition => skip + return Ok(()); + }; + let Some(current_clause) = self.current_clause.add_selection_directives(directives)? else { + // Unsatisfiable local condition under the parent field => skip + return Ok(()); + }; + // check if `self.inherited_clause` and `current_clause` are unsatisfiable together. + if self.inherited_clause.concatenate(¤t_clause).is_none() { + // Unsatisfiable full condition from the root => skip + return Ok(()); + } + + // The inner context with a new type condition. + // Note: Non-conditional directives on inline spreads are ignored. + let context = ResponseShapeContext { + schema: self.schema.clone(), + fragment_defs: self.fragment_defs.clone(), + parent_type: fragment_type_condition.clone(), + type_condition, + inherited_clause: self.inherited_clause.clone(), // no change + current_clause, + skip_introspection: self.skip_introspection, + }; + context.process_selection_set_within(response_shape, selection_set) + } + + /// Using an existing response shape + fn process_selection_set_within( + &self, + response_shape: &mut ResponseShape, + selection_set: &SelectionSet, + ) -> Result<(), FederationError> { + for selection in &selection_set.selections { + self.process_selection(response_shape, selection)?; + } + Ok(()) + } + + /// For a new sub-ResponseShape + /// - This corresponds to the `CollectFields` algorithm in the GraphQL specification. + fn process_selection_set( + &self, + selection_set: &SelectionSet, + ) -> Result { + let mut response_shape = ResponseShape::new(selection_set.ty.clone()); + self.process_selection_set_within(&mut response_shape, selection_set)?; + Ok(response_shape) + } +} + +fn is_introspection_field_name(name: &Name) -> bool { + name == "__schema" || name == "__type" +} + +fn get_operation_and_fragment_definitions( + operation_doc: &Valid, +) -> Result<(Node, Arc), FederationError> { + let mut op_iter = operation_doc.operations.iter(); + let Some(first) = op_iter.next() else { + bail!("Operation not found") + }; + if op_iter.next().is_some() { + bail!("Multiple operations are not supported") + } + + let fragment_defs = Arc::new(operation_doc.fragments.clone()); + Ok((first.clone(), fragment_defs)) +} + +fn get_fragment_definition<'a>( + fragment_defs: &'a Arc>>, + fragment_name: &Name, +) -> Result<&'a Node, FederationError> { + let fragment_def = fragment_defs + .get(fragment_name) + .ok_or_else(|| internal_error!("Fragment definition not found: {}", fragment_name))?; + Ok(fragment_def) +} + +pub fn compute_response_shape_for_operation( + operation_doc: &Valid, + schema: &ValidFederationSchema, +) -> Result { + let (operation, fragment_defs) = get_operation_and_fragment_definitions(operation_doc)?; + + // Start a new root context and process the root selection set. + // - Not using `process_selection_set` because there is no parent context. + let parent_type = operation.selection_set.ty.clone(); + let Some(type_condition) = + NormalizedTypeCondition::from_type_name(parent_type.clone(), schema)? + else { + bail!("Unexpected empty type condition for the root type: {parent_type}") + }; + let context = ResponseShapeContext { + schema: schema.clone(), + fragment_defs, + parent_type, + type_condition, + inherited_clause: Clause::default(), // empty + current_clause: Clause::default(), // empty + skip_introspection: true, // true for root context + }; + context.process_selection_set(&operation.selection_set) +} + +pub fn compute_the_root_type_condition_for_operation( + operation_doc: &Valid, +) -> Result { + let (operation, _) = get_operation_and_fragment_definitions(operation_doc)?; + Ok(operation.selection_set.ty.clone()) +} + +/// Entity fetch operation may have multiple entity selections. +/// This function returns a vector of response shapes per each individual entity selection. +pub fn compute_response_shape_for_entity_fetch_operation( + operation_doc: &Valid, + schema: &ValidFederationSchema, +) -> Result, FederationError> { + let (operation, fragment_defs) = get_operation_and_fragment_definitions(operation_doc)?; + + // drill down the `_entities` selection set + let mut sel_iter = operation.selection_set.selections.iter(); + let Some(first_selection) = sel_iter.next() else { + bail!("Entity fetch is expected to have at least one selection") + }; + if sel_iter.next().is_some() { + bail!("Entity fetch is expected to have exactly one selection") + } + let Selection::Field(field) = first_selection else { + bail!("Entity fetch is expected to have a field selection only") + }; + if field.name != crate::subgraph::spec::ENTITIES_QUERY { + bail!("Entity fetch is expected to have a field selection named `_entities`") + } + + field + .selection_set + .selections + .iter() + .map(|selection| { + let type_condition = get_fragment_type_condition(&fragment_defs, selection)?; + let Some(normalized_type_condition) = + NormalizedTypeCondition::from_type_name(type_condition.clone(), schema)? + else { + bail!("Unexpected empty type condition for the entity type: {type_condition}") + }; + let context = ResponseShapeContext { + schema: schema.clone(), + fragment_defs: fragment_defs.clone(), + parent_type: type_condition.clone(), + type_condition: normalized_type_condition, + inherited_clause: Clause::default(), // empty + current_clause: Clause::default(), // empty + skip_introspection: false, // false by default + }; + let mut response_shape = ResponseShape::new(type_condition); + context.process_selection(&mut response_shape, selection)?; + Ok(response_shape) + }) + .collect() +} + +fn get_fragment_type_condition( + fragment_defs: &Arc, + selection: &Selection, +) -> Result { + Ok(match selection { + Selection::FragmentSpread(fragment_spread) => { + let fragment_def = + get_fragment_definition(fragment_defs, &fragment_spread.fragment_name)?; + fragment_def.type_condition().clone() + } + Selection::InlineFragment(inline) => { + let Some(type_condition) = &inline.type_condition else { + bail!( + "Expected a type condition on the inline fragment under the `_entities` selection" + ) + }; + type_condition.clone() + } + _ => bail!("Expected a fragment under the `_entities` selection"), + }) +} + +/// Used for field sets like `@key`/`@requires` fields. +pub fn compute_response_shape_for_selection_set( + schema: &ValidFederationSchema, + selection_set: &SelectionSet, +) -> Result { + let type_condition = &selection_set.ty; + let Some(normalized_type_condition) = + NormalizedTypeCondition::from_type_name(type_condition.clone(), schema)? + else { + bail!("Unexpected empty type condition for field set: {type_condition}") + }; + let context = ResponseShapeContext { + schema: schema.clone(), + fragment_defs: Default::default(), // empty + parent_type: type_condition.clone(), + type_condition: normalized_type_condition, + inherited_clause: Clause::default(), // empty + current_clause: Clause::default(), // empty + skip_introspection: false, // false by default + }; + context.process_selection_set(selection_set) +} + +//================================================================================================== +// ResponseShape display +// - This section is only for display and thus untrusted. + +impl fmt::Display for DisplayTypeCondition { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.0.is_empty() { + return write!(f, ""); + } + for (i, cond) in self.0.iter().enumerate() { + if i > 0 { + write!(f, " ∩ ")?; + } + write!(f, "{}", cond.type_name())?; + } + Ok(()) + } +} + +impl fmt::Display for NormalizedTypeCondition { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.ground_set.is_empty() { + return Err(fmt::Error); + } + + write!(f, "{}", self.for_display)?; + if self.for_display.0.len() != 1 { + write!(f, " = {{")?; + for (i, ty) in self.ground_set.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}", ty.type_name)?; + } + write!(f, "}}")?; + } + Ok(()) + } +} + +impl fmt::Display for Clause { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.0.is_empty() { + write!(f, "true") + } else { + for (i, l) in self.0.iter().enumerate() { + if i > 0 { + write!(f, " ∧ ")?; + } + match l { + Literal::Pos(v) => write!(f, "{v}")?, + Literal::Neg(v) => write!(f, "¬{v}")?, + } + } + Ok(()) + } + } +} + +impl DefinitionVariant { + fn write_indented(&self, state: &mut display_helpers::State<'_, '_>) -> fmt::Result { + let field_display = &self.representative_field; + let boolean_str = if !self.boolean_clause.is_always_true() { + format!(" if {}", self.boolean_clause) + } else { + "".to_string() + }; + state.write(format_args!("{field_display} (on ){boolean_str}"))?; + if let Some(sub_selection_response_shape) = &self.sub_selection_response_shape { + state.write(" ")?; + sub_selection_response_shape.write_indented(state)?; + } + Ok(()) + } +} + +impl fmt::Display for DefinitionVariant { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.write_indented(&mut display_helpers::State::new(f)) + } +} + +impl PossibleDefinitionsPerTypeCondition { + fn has_boolean_conditions(&self) -> bool { + self.conditional_variants.len() > 1 + || self + .conditional_variants + .first() + .is_some_and(|variant| !variant.boolean_clause.is_always_true()) + } + + fn write_indented(&self, state: &mut display_helpers::State<'_, '_>) -> fmt::Result { + for (i, variant) in self.conditional_variants.iter().enumerate() { + if i > 0 { + state.new_line()?; + } + variant.write_indented(state)?; + } + Ok(()) + } +} + +impl fmt::Display for PossibleDefinitionsPerTypeCondition { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.write_indented(&mut display_helpers::State::new(f)) + } +} + +impl PossibleDefinitions { + /// Is conditional on runtime type? + fn has_type_conditions(&self, default_type_condition: &Name) -> bool { + self.0.len() > 1 + || self.0.first().is_some_and(|(type_condition, _)| { + !type_condition.is_named_type(default_type_condition) + }) + } + + /// Has multiple possible definitions or has any boolean conditions? + /// Note: This method may miss a type condition. So, check `has_type_conditions` as well. + fn has_multiple_definitions(&self) -> bool { + self.0.len() > 1 + || self + .0 + .first() + .is_some_and(|(_, per_type_cond)| per_type_cond.has_boolean_conditions()) + } + + fn write_indented(&self, state: &mut display_helpers::State<'_, '_>) -> fmt::Result { + let arrow_sym = if self.has_multiple_definitions() { + "-may->" + } else { + "----->" + }; + let mut is_first = true; + for (type_condition, per_type_cond) in &self.0 { + for variant in &per_type_cond.conditional_variants { + let field_display = &variant.representative_field; + let type_cond_str = format!(" on {type_condition}"); + let boolean_str = if !variant.boolean_clause.is_always_true() { + format!(" if {}", variant.boolean_clause) + } else { + "".to_string() + }; + if is_first { + is_first = false; + } else { + state.new_line()?; + } + state.write(format_args!( + "{arrow_sym} {field_display}{type_cond_str}{boolean_str}" + ))?; + if let Some(sub_selection_response_shape) = &variant.sub_selection_response_shape { + state.write(" ")?; + sub_selection_response_shape.write_indented(state)?; + } + } + } + Ok(()) + } +} + +impl fmt::Display for PossibleDefinitions { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.write_indented(&mut display_helpers::State::new(f)) + } +} + +impl ResponseShape { + fn write_indented(&self, state: &mut display_helpers::State<'_, '_>) -> fmt::Result { + state.write("{")?; + state.indent_no_new_line(); + for (response_key, defs) in &self.definitions_per_response_key { + let has_type_cond = defs.has_type_conditions(&self.default_type_condition); + let arrow_sym = if has_type_cond || defs.has_multiple_definitions() { + "-may->" + } else { + "----->" + }; + for (type_condition, per_type_cond) in &defs.0 { + for variant in &per_type_cond.conditional_variants { + let field_display = &variant.representative_field; + let type_cond_str = if has_type_cond { + format!(" on {type_condition}") + } else { + "".to_string() + }; + let boolean_str = if !variant.boolean_clause.is_always_true() { + format!(" if {}", variant.boolean_clause) + } else { + "".to_string() + }; + state.new_line()?; + state.write(format_args!( + "{response_key} {arrow_sym} {field_display}{type_cond_str}{boolean_str}" + ))?; + if let Some(sub_selection_response_shape) = + &variant.sub_selection_response_shape + { + state.write(" ")?; + sub_selection_response_shape.write_indented(state)?; + } + } + } + } + state.dedent()?; + state.write("}") + } +} + +impl fmt::Display for ResponseShape { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.write_indented(&mut display_helpers::State::new(f)) + } +} diff --git a/apollo-federation/src/correctness/response_shape_compare.rs b/apollo-federation/src/correctness/response_shape_compare.rs new file mode 100644 index 0000000000..f469bf3490 --- /dev/null +++ b/apollo-federation/src/correctness/response_shape_compare.rs @@ -0,0 +1,582 @@ +// Compare response shapes from a query plan and an input operation. + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast; +use apollo_compiler::collections::IndexSet; +use apollo_compiler::executable::Field; +use itertools::Itertools; + +use super::response_shape::Clause; +use super::response_shape::DefinitionVariant; +use super::response_shape::FieldSelectionKey; +use super::response_shape::Literal; +use super::response_shape::NormalizedTypeCondition; +use super::response_shape::PossibleDefinitions; +use super::response_shape::PossibleDefinitionsPerTypeCondition; +use super::response_shape::ResponseShape; +use crate::schema::position::ObjectTypeDefinitionPosition; + +#[derive(Debug, derive_more::Display)] +pub struct ComparisonError { + description: String, +} + +impl ComparisonError { + pub fn description(&self) -> &str { + &self.description + } + + pub fn new(description: String) -> ComparisonError { + ComparisonError { description } + } + + pub fn add_description(self: ComparisonError, description: &str) -> ComparisonError { + ComparisonError { + description: format!("{}\n{}", self.description, description), + } + } +} + +macro_rules! check_match_eq { + ($a:expr, $b:expr) => { + if $a != $b { + let message = format!( + "mismatch between {} and {}:\nleft: {:?}\nright: {:?}", + stringify!($a), + stringify!($b), + $a, + $b, + ); + return Err(ComparisonError::new(message)); + } + }; +} + +/// Path-specific type constraints on top of GraphQL type conditions. +pub(crate) trait PathConstraint +where + Self: Sized, +{ + /// Returns a new path constraint under the given type condition. + fn under_type_condition(&self, type_cond: &NormalizedTypeCondition) -> Self; + + /// Returns a new path constraint for field's response shape. + fn for_field(&self, representative_field: &Field) -> Result; + + /// Is `ty` allowed under the path constraint? + fn allows(&self, _ty: &ObjectTypeDefinitionPosition) -> bool; + + /// Is `defs` feasible under the path constraint? + fn allows_any(&self, _defs: &PossibleDefinitions) -> bool; +} + +struct DummyPathConstraint; + +impl PathConstraint for DummyPathConstraint { + fn under_type_condition(&self, _type_cond: &NormalizedTypeCondition) -> Self { + DummyPathConstraint + } + + fn for_field(&self, _representative_field: &Field) -> Result { + Ok(DummyPathConstraint) + } + + fn allows(&self, _ty: &ObjectTypeDefinitionPosition) -> bool { + true + } + + fn allows_any(&self, _defs: &PossibleDefinitions) -> bool { + true + } +} + +// Check if `this` is a subset of `other`. +pub fn compare_response_shapes( + this: &ResponseShape, + other: &ResponseShape, +) -> Result<(), ComparisonError> { + let assumption = Clause::default(); // empty assumption at the top level + compare_response_shapes_with_constraint(&DummyPathConstraint, &assumption, this, other) +} + +/// Check if `this` is a subset of `other`, but also use the `PathConstraint` to ignore infeasible +/// type conditions in `other`. +/// - `assumption`: Boolean literals that are assumed to be true. This may affect the +/// interpretation of the `this` and `other` response shapes. +pub(crate) fn compare_response_shapes_with_constraint( + path_constraint: &T, + assumption: &Clause, + this: &ResponseShape, + other: &ResponseShape, +) -> Result<(), ComparisonError> { + // Note: `default_type_condition` is for display. + // Only response key and definitions are compared. + this.iter().try_for_each(|(key, this_def)| { + let Some(other_def) = other.get(key) else { + // check this_def's type conditions are feasible under the path constraint. + if !path_constraint.allows_any(this_def) { + return Ok(()); + } + return Err(ComparisonError::new(format!("missing response key: {key}"))); + }; + compare_possible_definitions(path_constraint, assumption, this_def, other_def) + .map_err(|e| e.add_description(&format!("mismatch for response key: {key}"))) + }) +} + +/// Collect and merge all definitions applicable to the given type condition. +/// Returns `None` if no definitions are applicable. +pub(crate) fn collect_definitions_for_type_condition( + defs: &PossibleDefinitions, + filter_cond: &NormalizedTypeCondition, +) -> Result, ComparisonError> { + let mut filter_iter = defs + .iter() + .filter(|(type_cond, _)| filter_cond.implies(type_cond)); + let Some((_type_cond, first)) = filter_iter.next() else { + return Ok(None); + }; + let mut digest = first.clone(); + // Merge the rest of filter_iter into digest. + filter_iter.try_for_each(|(type_cond, def)| + def.conditional_variants() + .iter() + .try_for_each(|variant| digest.insert_variant(variant.clone())) + .map_err(|e| { + ComparisonError::new(format!( + "collect_definitions_for_type_condition failed for {filter_cond}\ntype_cond: {type_cond}\nerror: {e}", + )) + }) + )?; + Ok(Some(digest)) +} + +fn path_constraint_allows_type_condition( + path_constraint: &T, + type_cond: &NormalizedTypeCondition, +) -> bool { + type_cond + .ground_set() + .iter() + .any(|ty| path_constraint.allows(ty)) +} + +fn detail_single_object_type_condition(type_cond: &NormalizedTypeCondition) -> String { + let Some(ground_ty) = type_cond.ground_set().iter().next() else { + return "".to_string(); + }; + if !type_cond.is_named_object_type() { + format!(" (has single object type: {ground_ty})") + } else { + "".to_string() + } +} + +fn compare_possible_definitions( + path_constraint: &T, + assumption: &Clause, + this: &PossibleDefinitions, + other: &PossibleDefinitions, +) -> Result<(), ComparisonError> { + this.iter().try_for_each(|(this_cond, this_def)| { + if !path_constraint_allows_type_condition(path_constraint, this_cond) { + // Skip `this_cond` since it's not satisfiable under the path constraint. + return Ok(()); + } + + let updated_constraint = path_constraint.under_type_condition(this_cond); + + // First try: Use the single exact match (common case). + if let Some(other_def) = other.get(this_cond) + && let Ok(result) = compare_possible_definitions_per_type_condition( + &updated_constraint, + assumption, + this_def, + other_def, + ) + { + return Ok(result); + } + // fall through + + // Second try: Collect all definitions implied by the `this_cond`. + if let Some(other_def) = collect_definitions_for_type_condition(other, this_cond)? { + let result = compare_possible_definitions_per_type_condition( + &updated_constraint, + assumption, + this_def, + &other_def, + ); + match result { + Ok(result) => return Ok(result), + Err(err) => { + // See if we can case-split over ground set items. + if this_cond.ground_set().len() == 1 { + // Single object type has no other option. Stop and report the error. + let detail = detail_single_object_type_condition(this_cond); + return Err(err.add_description(&format!( + "mismatch for type condition: {this_cond}{detail}", + ))); + } + // fall through + } + } + // fall through + }; + + // Finally: Case-split over individual ground types. + let ground_set_iter = this_cond.ground_set().iter(); + let mut ground_set_iter = ground_set_iter.filter(|ty| path_constraint.allows(ty)); + ground_set_iter.try_for_each(|ground_ty| { + let filter_cond = NormalizedTypeCondition::from_object_type(ground_ty); + let Some(other_def) = collect_definitions_for_type_condition(other, &filter_cond)? + else { + return Err(ComparisonError::new(format!( + "no definitions found for type condition: {this_cond} (case: {ground_ty})" + ))); + }; + let updated_constraint = path_constraint.under_type_condition(&filter_cond); + compare_possible_definitions_per_type_condition( + &updated_constraint, + assumption, + this_def, + &other_def, + ) + .map_err(|e| { + e.add_description(&format!( + "mismatch for type condition: {this_cond} (case: {ground_ty})" + )) + }) + }) + }) +} + +fn compare_possible_definitions_per_type_condition( + path_constraint: &T, + assumption: &Clause, + this: &PossibleDefinitionsPerTypeCondition, + other: &PossibleDefinitionsPerTypeCondition, +) -> Result<(), ComparisonError> { + compare_field_selection_key(this.field_selection_key(), other.field_selection_key()).map_err( + |e| { + e.add_description( + "mismatch in field selection key of PossibleDefinitionsPerTypeCondition", + ) + }, + )?; + this.conditional_variants() + .iter() + .try_for_each(|this_variant| { + solve_boolean_constraints(path_constraint, assumption, this_variant, other) + }) +} + +/// Under the given `assumption` and `this_variant`'s clause, match `this_variant` against +/// `other`'s variants. +/// - `this_variant` may match a set of `other`'s variants collectively, even if there are no +/// individual matching variant. Thus, this function tries to collect/merge all implied variants +/// and then compare. +/// - Note that we may need to case-split over Boolean variables. It happens when there are more +/// Boolean variables used in the `other`'s variants. This function tries to find the smallest +/// set of missing Boolean variables to case-split. It starts with the empty set, then tries +/// increasingly larger sets until a matching subset is found. For each set of variables, it +/// checks if every possible combination of Boolean values (hypothesis) has a match. +fn solve_boolean_constraints( + path_constraint: &T, + assumption: &Clause, + this_variant: &DefinitionVariant, + other: &PossibleDefinitionsPerTypeCondition, +) -> Result<(), ComparisonError> { + let Some(base_clause) = this_variant.boolean_clause().concatenate(assumption) else { + // This variant is infeasible. Skip. + return Ok(()); + }; + let hypothesis_groups = extract_boolean_hypotheses(&base_clause, other); + // Try each hypothesis group and see if any one works + let mut errors = Vec::new(); + for group in &hypothesis_groups { + // In each group, every hypothesis must match. + let result = group.iter().try_for_each(|hypothesis| { + let Some(full_clause) = base_clause.concatenate(hypothesis) else { + // Inconsistent hypothesis (a bug in extract_boolean_hypotheses) + return Err(ComparisonError::new(format!( + "Internal error: inconsistent generated hypothesis {hypothesis}\n\ + - assumption: {assumption}\n\ + - this_clause: {this_clause}", + this_clause = this_variant.boolean_clause() + ))); + }; + let Some(other_variant) = collect_variants_for_boolean_condition(other, &full_clause)? else { + return Err(ComparisonError::new(format!( + "no variants found for Boolean condition in solve_boolean_constraints: {full_clause}" + ))); + }; + compare_definition_variant(path_constraint, &full_clause, this_variant, &other_variant) + .map_err(|e| { + e.add_description(&format!( + "mismatched variants for hypothesis: {hypothesis}\n\ + - Assumption: {assumption}\n\ + - this_clause: {this_clause}\n\ + - Full condition: {full_clause}", + this_clause = this_variant.boolean_clause()) + ) + }) + }); + match result { + Ok(()) => { + return Ok(()); + } + Err(e) => { + let group_str = group.iter().join(", "); + errors.push(format!( + "solve_boolean_constraints: group: {group_str}\n\ + detail: {e}", + )); + } + } + } + // None worked => error + Err(ComparisonError::new(format!( + "Failed to solve Boolean constraints w/ assumption {assumption}\n\ + this_variant: {this_variant}\n\ + other: {other}\n\ + detail: {}", + errors.iter().join("\n") + ))) +} + +/// A set of variable names. +/// Must be sorted by the variable name. +type BooleanVariables = Vec; + +/// Generate sets of hypotheses to case-split over that are applicable to the target `defs`. +/// - Construct hypotheses based on the variables used in the Boolean conditions in `defs`. +/// - Excludes the literals in the `assumption` since it's already assumed to be true. +/// - If there are variants with no extra Boolean variables, it will generate a no-hypothesis +/// group, which contains only one empty clause. +fn extract_boolean_hypotheses( + assumption: &Clause, + defs: &PossibleDefinitionsPerTypeCondition, +) -> Vec> { + // Collect sets of variables that can be used to case-split over. + let mut variable_groups = IndexSet::default(); + for variant in defs.conditional_variants() { + let Some(remaining_condition) = variant.boolean_clause().subtract(assumption) else { + // Skip unsatisfiable variants. + continue; + }; + // Collect variables from the remaining condition. + // Invariant: Clauses are expected to be sorted by the variable name. + let vars: BooleanVariables = remaining_condition + .literals() + .iter() + .map(|lit| lit.variable()) + .cloned() + .collect(); + variable_groups.insert(vars); + } + // Generate groups of Boolean hypotheses. + variable_groups + .into_iter() + .map(|group| generate_clauses(&group)) + .collect() +} + +/// Generate all possible clauses from the given variables. +/// - If `vars` is empty, it will return a single empty clause. +fn generate_clauses(vars: &[Name]) -> Vec { + let mut state = Vec::new(); + let mut result = Vec::new(); + fn inner_generate(state: &mut Vec, result: &mut Vec, remaining_vars: &[Name]) { + match remaining_vars { + [] => { + result.push(Clause::from_literals(state)); + } + [var, rest @ ..] => { + state.push(Literal::Pos(var.clone())); + inner_generate(state, result, rest); + state.pop(); + state.push(Literal::Neg(var.clone())); + inner_generate(state, result, rest); + state.pop(); + } + } + } + inner_generate(&mut state, &mut result, vars); + result +} + +/// Collect all variants implied by the Boolean condition and merge them into one. +/// Returns `None` if no variants are applicable. +pub(crate) fn collect_variants_for_boolean_condition( + defs: &PossibleDefinitionsPerTypeCondition, + filter_cond: &Clause, +) -> Result, ComparisonError> { + let mut iter = defs + .conditional_variants() + .iter() + .filter(|variant| filter_cond.implies(variant.boolean_clause())); + let Some(first) = iter.next() else { + return Ok(None); + }; + let mut result_sub = first.sub_selection_response_shape().cloned(); + for variant in iter { + compare_representative_field(variant.representative_field(), first.representative_field()) + .map_err(|e| { + e.add_description("mismatch in representative_field under definition variant") + })?; + match (&mut result_sub, variant.sub_selection_response_shape()) { + (None, None) => {} + (Some(result_sub), Some(variant_sub)) => { + result_sub.merge_with(variant_sub).map_err(|e| { + ComparisonError::new(format!("failed to merge implied variants: {e}")) + })?; + } + _ => { + return Err(ComparisonError::new( + "mismatch in sub-selections of implied variants".to_string(), + )); + } + } + } + Ok(Some( + first.with_updated_fields(filter_cond.clone(), result_sub), + )) +} + +/// Precondition: this.boolean_clause() + hypothesis implies other.boolean_clause(). +fn compare_definition_variant( + path_constraint: &T, + hypothesis: &Clause, + this: &DefinitionVariant, + other: &DefinitionVariant, +) -> Result<(), ComparisonError> { + // Note: `this.boolean_clause()` and `other.boolean_clause()` may not match due to the + // hypothesis on `this` or weaker condition on the `other`. + compare_representative_field(this.representative_field(), other.representative_field()) + .map_err(|e| { + e.add_description("mismatch in representative_field under definition variant") + })?; + match ( + this.sub_selection_response_shape(), + other.sub_selection_response_shape(), + ) { + (None, None) => Ok(()), + (Some(this_sub), Some(other_sub)) => { + let field_constraint = path_constraint.for_field(this.representative_field())?; + compare_response_shapes_with_constraint( + &field_constraint, + hypothesis, + this_sub, + other_sub, + ) + .map_err(|e| { + e.add_description(&format!( + "mismatch in response shape under definition variant: ---> {} if {}", + this.representative_field(), + this.boolean_clause() + )) + }) + } + _ => Err(ComparisonError::new( + "mismatch in compare_definition_variant".to_string(), + )), + } +} + +fn compare_field_selection_key( + this: &FieldSelectionKey, + other: &FieldSelectionKey, +) -> Result<(), ComparisonError> { + check_match_eq!(this.name, other.name); + // Note: Arguments are expected to be normalized. + check_match_eq!(this.arguments, other.arguments); + Ok(()) +} + +pub(crate) fn compare_representative_field( + this: &Field, + other: &Field, +) -> Result<(), ComparisonError> { + check_match_eq!(this.name, other.name); + // Note: Arguments and directives are NOT normalized. + if !same_ast_arguments(&this.arguments, &other.arguments) { + return Err(ComparisonError::new(format!( + "mismatch in representative field arguments: {:?} vs {:?}", + this.arguments, other.arguments + ))); + } + if !same_directives(&this.directives, &other.directives) { + return Err(ComparisonError::new(format!( + "mismatch in representative field directives: {:?} vs {:?}", + this.directives, other.directives + ))); + } + Ok(()) +} + +//================================================================================================== +// AST comparison functions + +fn same_ast_argument_value(x: &ast::Value, y: &ast::Value) -> bool { + match (x, y) { + // Object fields may be in different order. + (ast::Value::Object(x), ast::Value::Object(y)) => vec_matches_sorted_by( + x, + y, + |(xx_name, _), (yy_name, _)| xx_name.cmp(yy_name), + |(_, xx_val), (_, yy_val)| same_ast_argument_value(xx_val, yy_val), + ), + + // Recurse into list items. + (ast::Value::List(x), ast::Value::List(y)) => { + vec_matches(x, y, |xx, yy| same_ast_argument_value(xx, yy)) + } + + _ => x == y, // otherwise, direct compare + } +} + +fn same_ast_argument(x: &ast::Argument, y: &ast::Argument) -> bool { + x.name == y.name && same_ast_argument_value(&x.value, &y.value) +} + +fn same_ast_arguments(x: &[Node], y: &[Node]) -> bool { + vec_matches_sorted_by( + x, + y, + |a, b| a.name.cmp(&b.name), + |a, b| same_ast_argument(a, b), + ) +} + +fn same_directives(x: &ast::DirectiveList, y: &ast::DirectiveList) -> bool { + vec_matches_sorted_by( + x, + y, + |a, b| a.name.cmp(&b.name), + |a, b| a.name == b.name && same_ast_arguments(&a.arguments, &b.arguments), + ) +} + +//================================================================================================== +// Vec comparison functions + +fn vec_matches(this: &[T], other: &[T], item_matches: impl Fn(&T, &T) -> bool) -> bool { + this.len() == other.len() + && std::iter::zip(this, other).all(|(this, other)| item_matches(this, other)) +} + +fn vec_matches_sorted_by( + this: &[T], + other: &[T], + compare: impl Fn(&T, &T) -> std::cmp::Ordering, + item_matches: impl Fn(&T, &T) -> bool, +) -> bool { + let mut this_sorted = this.to_owned(); + let mut other_sorted = other.to_owned(); + this_sorted.sort_by(&compare); + other_sorted.sort_by(&compare); + vec_matches(&this_sorted, &other_sorted, item_matches) +} diff --git a/apollo-federation/src/correctness/response_shape_compare_test.rs b/apollo-federation/src/correctness/response_shape_compare_test.rs new file mode 100644 index 0000000000..79729c159c --- /dev/null +++ b/apollo-federation/src/correctness/response_shape_compare_test.rs @@ -0,0 +1,342 @@ +use apollo_compiler::ExecutableDocument; +use apollo_compiler::schema::Schema; + +use super::compare_operations; +use super::*; +use crate::ValidFederationSchema; + +// The schema used in these tests. +const SCHEMA_STR: &str = r#" + type Query { + test_i: I! + test_j: J! + test_u: U! + test_v: V! + } + + interface I { + id: ID! + data(arg: Int!): String! + } + + interface J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + } + + type R implements I & J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + r: Int! + } + + type S implements I & J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + s: Int! + } + + type T implements I { + id: ID! + data(arg: Int!): String! + t: Int! + } + + type X implements J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + x: String! + } + + type Y { + id: ID! + y: String! + } + + type Z implements J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + z: String! + } + + union U = R | S | X + union V = R | S | Y + + directive @mod(arg: Int!) on FIELD +"#; + +fn compare_operation_docs(this: &str, other: &str) -> Result<(), CorrectnessError> { + let schema = Schema::parse_and_validate(SCHEMA_STR, "schema.graphql").unwrap(); + let schema = ValidFederationSchema::new(schema).unwrap(); + let this_op = + ExecutableDocument::parse_and_validate(schema.schema(), this, "this.graphql").unwrap(); + let other_op = + ExecutableDocument::parse_and_validate(schema.schema(), other, "other.graphql").unwrap(); + compare_operations(&schema, &this_op, &other_op) +} + +fn assert_compare_operation_docs(this: &str, other: &str) { + if let Err(err) = compare_operation_docs(this, other) { + match err { + CorrectnessError::FederationError(err) => { + panic!("{err}"); + } + CorrectnessError::ComparisonError(err) => { + panic!("compare_operation_docs failed: {err}"); + } + } + } +} + +#[test] +fn test_basic_pass() { + let x = r#" + query { + test_i { + id + } + } + "#; + let y = r#" + query { + test_i { + id + } + } + "#; + compare_operation_docs(x, y).unwrap(); +} + +#[test] +fn test_basic_fail() { + let x = r#" + query { + test_i { + id + } + } + "#; + let y = r#" + query { + test_i { + __typename + } + } + "#; + assert!(compare_operation_docs(x, y).is_err()); +} + +#[test] +fn test_implied_condition() { + let x = r#" + query($v0: Boolean!) { + test_i @include(if: $v0) { + id + } + } + "#; + let y = r#" + query { + test_i { + id + } + } + "#; + compare_operation_docs(x, y).unwrap(); +} + +#[test] +fn test_implied_condition2() { + let x = r#" + query($v0: Boolean!, $v1: Boolean!) { + test_i @include(if: $v0) @skip(if: $v1) { + id + } + } + "#; + let y = r#" + query($v0: Boolean!) { + test_i @include(if: $v0) { + id + } + } + "#; + compare_operation_docs(x, y).unwrap(); +} + +#[test] +fn test_boolean_condition_case_split_basic() { + // x.test_i has no Boolean conditions. + let x = r#" + query { + test_i { + id + } + } + "#; + // x.test_i has multiple variants split over one variable. + let y = r#" + query($v0: Boolean!) { + test_i { + id @include(if: $v0) + id @skip(if: $v0) + } + } + "#; + assert_compare_operation_docs(x, y); +} + +#[test] +fn test_boolean_condition_case_split_1() { + // x.test_i has no Boolean conditions. + let x = r#" + query { + test_i { + id + } + } + "#; + // x.test_i has multiple variants split over one variable. + let y = r#" + query($v0: Boolean!) { + test_i @include(if: $v0) { + id + } + test_i @skip(if: $v0) { + id + } + } + "#; + assert_compare_operation_docs(x, y); +} + +#[test] +fn test_boolean_condition_case_split_2() { + // x.test_i has a condition with one variable. + let x = r#" + query($v0: Boolean!) { + test_i @include(if: $v0) { + id + data(arg: 0) + data1: data(arg: 1) + } + } + "#; + // y.test_i has multiple variants split over two variables. + let y = r#" + query($v0: Boolean!, $v1: Boolean!) { + test_i { + id + } + test_i @include(if: $v0) { + data(arg: 0) + } + ... @include(if: $v1) { + test_i @include(if: $v0) { + data1: data(arg: 1) + data2: data(arg: 2) # irrelevant + } + } + test_i @include(if: $v0) @skip(if: $v1) { + data1: data(arg: 1) + data3: data(arg: 3) # irrelevant + } + } + "#; + assert_compare_operation_docs(x, y); +} + +#[test] +fn test_boolean_condition_case_split_3() { + // x.test_i has no Boolean conditions. + let x = r#" + query { + test_i { + id + data(arg: 0) + } + } + "#; + // y.test_i has multiple variants split over one variable at different levels. + let y = r#" + query($v0: Boolean!) { + test_i { + id + } + test_i @include(if: $v0) { + data(arg: 0) + } + test_i { + data(arg: 0) @skip(if: $v0) + } + } + "#; + assert_compare_operation_docs(x, y); +} + +#[test] +fn test_boolean_condition_case_split_4() { + // x.test_i has no Boolean conditions. + let x = r#" + query { + test_j { + object(id: "1") { + data(arg: 0) + } + } + } + "#; + // y.test_i has multiple variants split over one variable at different non-consecutive levels. + let y = r#" + query($v0: Boolean!) { + test_j @include(if: $v0) { + object(id: "1") { + data(arg: 0) + } + } + test_j { + object(id: "1") { + data(arg: 0) @skip(if: $v0) + } + } + } + "#; + assert_compare_operation_docs(x, y); +} + +#[test] +fn test_boolean_condition_case_split_5() { + // x.test_i has a condition with one variable. + let x = r#" + query($v0: Boolean!) { + test_i @include(if: $v0) { + id + data(arg: 0) + data1: data(arg: 1) + } + } + "#; + // y.test_i has multiple variants split over two variables. + let y = r#" + query($v0: Boolean!, $v1: Boolean!) { + test_i { + id + } + test_i @include(if: $v0) { + data(arg: 0) + } + test_i @include(if: $v0) { + data1: data(arg: 1) @include(if: $v1) + } + test_i @include(if: $v0) @skip(if: $v1) { + data1: data(arg: 1) + } + } + "#; + assert_compare_operation_docs(x, y); +} diff --git a/apollo-federation/src/correctness/response_shape_test.rs b/apollo-federation/src/correctness/response_shape_test.rs new file mode 100644 index 0000000000..7b47bf5e53 --- /dev/null +++ b/apollo-federation/src/correctness/response_shape_test.rs @@ -0,0 +1,519 @@ +use apollo_compiler::ExecutableDocument; +use apollo_compiler::schema::Schema; + +use super::*; +use crate::ValidFederationSchema; + +// The schema used in these tests. +const SCHEMA_STR: &str = r#" + type Query { + test_i: I! + test_j: J! + test_u: U! + test_v: V! + } + + interface I { + id: ID! + data(arg: Int!): String! + } + + interface J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + } + + type R implements I & J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + r: Int! + } + + type S implements I & J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + s: Int! + } + + type T implements I { + id: ID! + data(arg: Int!): String! + t: Int! + } + + type X implements J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + x: String! + } + + type Y { + id: ID! + y: String! + } + + type Z implements J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + z: String! + } + + union U = R | S | X + union V = R | S | Y + + directive @mod(arg: Int!) on FIELD +"#; + +fn response_shape(op_str: &str) -> response_shape::ResponseShape { + let schema = Schema::parse_and_validate(SCHEMA_STR, "schema.graphql").unwrap(); + let schema = ValidFederationSchema::new(schema).unwrap(); + let op = ExecutableDocument::parse_and_validate(schema.schema(), op_str, "op.graphql").unwrap(); + response_shape::compute_response_shape_for_operation(&op, &schema).unwrap() +} + +//================================================================================================= +// Basic response key and alias tests + +#[test] +fn test_aliases() { + let op_str = r#" + query { + test_i { + data(arg: 0) + alias1: data(arg: 1) + alias2: data(arg: 1) + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -----> test_i { + data -----> data(arg: 0) + alias1 -----> data(arg: 1) + alias2 -----> data(arg: 1) + } + } + "###); +} + +//================================================================================================= +// Type condition tests + +#[test] +fn test_type_conditions_over_multiple_different_types() { + let op_str = r#" + query { + test_i { + ... on R { + data(arg: 0) + } + ... on S { + data(arg: 1) + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -----> test_i { + data -may-> data(arg: 0) on R + data -may-> data(arg: 1) on S + } + } + "###); +} + +#[test] +fn test_type_conditions_over_multiple_different_interface_types() { + // These two intersections are distinct type conditions. + // - `U ∧ I` = {R, S} + // - `U ∧ J` = `U` = {R, S, X} + let op_str = r#" + query { + test_u { + ... on I { + data(arg: 0) + } + ... on J { + data(arg: 0) + } + } + } + "#; + // Note: The set {R, S} has no corresponding named type definition in the schema, while + // `U ∧ J` is just the same as `U`. + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_u -----> test_u { + data -may-> data(arg: 0) on I ∩ U = {R, S} + data -may-> data(arg: 0) on U + } + } + "###); +} + +#[test] +fn test_type_conditions_merge_same_object_type() { + // Testing equivalent conditions: `U ∧ R` = `U ∧ I ∧ R` = `U ∧ R ∧ I` = `R` + // Also, that's different from `U ∧ I` = {R, S}. + let op_str = r#" + query { + test_u { + ... on R { + data(arg: 0) + } + ... on I { + ... on R { + data(arg: 0) + } + } + ... on R { + ... on I { + data(arg: 0) + } + } + ... on I { # different condition + data(arg: 0) + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_u -----> test_u { + data -may-> data(arg: 0) on R + data -may-> data(arg: 0) on I ∩ U = {R, S} + } + } + "###); +} + +#[test] +fn test_type_conditions_merge_equivalent_intersections() { + // Testing equivalent conditions: `U ∧ I ∧ J` = `U ∧ J ∧ I` = `U ∧ I`= {R, S} + // Note: The order of applied type conditions is irrelevant. + let op_str = r#" + query { + test_u { + ... on I { + ... on J { + data(arg: 0) + } + } + ... on J { + ... on I { + data(arg: 0) + } + } + ... on I { + data(arg: 0) + } + } + } + "#; + // Note: They are merged into the same condition `I ∧ U`, since that is minimal. + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_u -----> test_u { + data -may-> data(arg: 0) on I ∩ U = {R, S} + } + } + "###); +} + +#[test] +fn test_type_conditions_merge_different_but_equivalent_intersection_expressions() { + // Testing equivalent conditions: `V ∧ I` = `V ∧ J` = `V ∧ J ∧ I` = {R, S} + // Note: Those conditions have different sets of types. But, they are still equivalent. + let op_str = r#" + query { + test_v { + ... on I { + data(arg: 0) + } + ... on J { + data(arg: 0) + } + ... on J { + ... on I { + data(arg: 0) + } + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_v -----> test_v { + data -may-> data(arg: 0) on I ∩ V = {R, S} + } + } + "###); +} + +#[test] +fn test_type_conditions_empty_intersection() { + // Testing unsatisfiable conditions: `U ∧ I ∧ T`= ∅ + let op_str = r#" + query { + test_u { + ... on I { + ... on T { + infeasible: data(arg: 0) + } + } + } + } + "#; + // Note: The response shape under `test_u` is empty. + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_u -----> test_u { + } + } + "###); +} + +//================================================================================================= +// Boolean condition tests + +#[test] +fn test_boolean_conditions_constants() { + let op_str = r#" + query { + test_i { + # constant true conditions + merged: data(arg: 0) + merged: data(arg: 0) @include(if: true) + merged: data(arg: 0) @skip(if: false) + + # constant false conditions + infeasible_1: data(arg: 0) @include(if: false) + infeasible_2: data(arg: 0) @skip(if: true) + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -----> test_i { + merged -----> data(arg: 0) + } + } + "###); +} + +#[test] +fn test_boolean_conditions_different_multiple_conditions() { + let op_str = r#" + query($v0: Boolean!, $v1: Boolean!, $v2: Boolean!) { + test_i @include(if: $v0) { + data(arg: 0) + data(arg: 0) @include(if: $v1) + ... @include(if: $v1) { + data(arg: 0) @include(if: $v2) + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -may-> test_i if v0 { + data -may-> data(arg: 0) + data -may-> data(arg: 0) if v1 + data -may-> data(arg: 0) if v1 ∧ v2 + } + } + "###); +} + +#[test] +fn test_boolean_conditions_unsatisfiable_conditions() { + let op_str = r#" + query($v0: Boolean!, $v1: Boolean!) { + test_i @include(if: $v0) { + # conflict directly within the field directives + infeasible_1: data(arg: 0) @include(if: $v1) @skip(if: $v1) + # conflicting with the parent inline fragment + ... @skip(if: $v1) { + infeasible_2: data(arg: 0) @include(if: $v1) + } + infeasible_3: data(arg: 0) @skip(if: $v0) # conflicting with the parent-selection condition + } + } + "#; + // Note: The response shape under `test_i` is empty. + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -may-> test_i if v0 { + } + } + "###); +} + +//================================================================================================= +// Non-conditional directive tests + +#[test] +fn test_non_conditional_directives() { + let op_str = r#" + query { + test_i { + data(arg: 0) @mod(arg: 0) # different only in directives + data(arg: 0) @mod(arg: 1) # different only in directives + data(arg: 0) # no directives + } + } + "#; + // Note: All `data` definitions are merged, but the first selection (in depth-first order) is + // chosen as the representative. + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -----> test_i { + data -----> data(arg: 0) @mod(arg: 0) + } + } + "###); +} + +//================================================================================================= +// Fragment spread tests + +#[test] +fn test_fragment_spread() { + let op_str = r#" + query($v0: Boolean!) { + test_i @include(if: $v0) { + merge_1: data(arg: 0) + ...F + } + } + + fragment F on I { + merge_1: data(arg: 0) + from_fragment: data(arg: 0) + infeasible_1: data(arg: 0) @skip(if: $v0) + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -may-> test_i if v0 { + merge_1 -----> data(arg: 0) + from_fragment -----> data(arg: 0) + } + } + "###); +} + +//================================================================================================= +// Sub-selection merging tests + +#[test] +fn test_merge_sub_selection_sets() { + let op_str = r#" + query($v0: Boolean!, $v1: Boolean!) { + test_j { + object(id: 0) { + merged_1: data(arg: 0) + ... on R { + merged_2: data(arg: 0) + } + merged_3: data(arg: 0) @include(if: $v0) + } + object(id: 0) { + merged_1: data(arg: 0) + ... on S { + merged_2: data(arg: 1) + } + merged_3: data(arg: 0) @include(if: $v1) + } + object(id: 0) { + merged_3: data(arg: 0) # no condition + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_j -----> test_j { + object -----> object(id: 0) { + merged_1 -----> data(arg: 0) + merged_2 -may-> data(arg: 0) on R + merged_2 -may-> data(arg: 1) on S + merged_3 -may-> data(arg: 0) if v0 + merged_3 -may-> data(arg: 0) if v1 + merged_3 -may-> data(arg: 0) + } + } + } + "###); +} + +#[test] +fn test_not_merge_sub_selection_sets_under_different_type_conditions() { + let op_str = r#" + query { + test_j { + object(id: 0) { + unmerged: data(arg: 0) + } + # unmerged due to parents with different type conditions + ... on R { + object(id: 0) { + unmerged: data(arg: 0) + } + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_j -----> test_j { + object -may-> object(id: 0) on J { + unmerged -----> data(arg: 0) + } + object -may-> object(id: 0) on R { + unmerged -----> data(arg: 0) + } + } + } + "###); +} + +#[test] +fn test_merge_sub_selection_sets_with_boolean_conditions() { + let op_str = r#" + query($v0: Boolean!, $v1: Boolean!) { + test_j { + object(id: 0) @include(if: $v0) { + merged: data(arg: 0) + unmerged: data(arg: 0) + } + object(id: 0) @include(if: $v0) { + merged: data(arg: 0) @include(if: $v0) + } + # unmerged due to parents with different Boolean conditions + object(id: 0) @include(if: $v1) { + unmerged: data(arg: 0) + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_j -----> test_j { + object -may-> object(id: 0) if v0 { + merged -----> data(arg: 0) + unmerged -----> data(arg: 0) + } + object -may-> object(id: 0) if v1 { + unmerged -----> data(arg: 0) + } + } + } + "###); +} diff --git a/apollo-federation/src/correctness/subgraph_constraint.rs b/apollo-federation/src/correctness/subgraph_constraint.rs new file mode 100644 index 0000000000..6ab19058d5 --- /dev/null +++ b/apollo-federation/src/correctness/subgraph_constraint.rs @@ -0,0 +1,155 @@ +// Path-specific constraints imposed by subgraph schemas. + +use std::sync::Arc; + +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; +use apollo_compiler::executable::Field; + +use super::response_shape::NormalizedTypeCondition; +use super::response_shape::PossibleDefinitions; +use super::response_shape_compare::ComparisonError; +use super::response_shape_compare::PathConstraint; +use crate::ValidFederationSchema; +use crate::error::FederationError; +use crate::internal_error; +use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; +use crate::schema::position::CompositeTypeDefinitionPosition; +use crate::schema::position::ObjectTypeDefinitionPosition; +use crate::utils::FallibleIterator; + +pub(crate) struct SubgraphConstraint<'a> { + /// Reference to the all subgraph schemas in the supergraph. + subgraphs_by_name: &'a IndexMap, ValidFederationSchema>, + + /// possible_subgraphs: The set of subgraphs that are possible under the current context. + possible_subgraphs: IndexSet>, + + /// subgraph_types: The set of object types that are possible under the current context. + /// - Note: The empty subgraph_types means all types are possible. + subgraph_types: IndexSet, +} + +/// Is the object type resolvable in the subgraph schema? +fn is_resolvable( + ty_pos: &ObjectTypeDefinitionPosition, + schema: &ValidFederationSchema, +) -> Result { + let federation_spec_definition = get_federation_spec_definition_from_subgraph(schema)?; + let key_directive_definition = federation_spec_definition.key_directive_definition(schema)?; + let ty_def = ty_pos.get(schema.schema())?; + ty_def + .directives + .get_all(&key_directive_definition.name) + .map(|directive| federation_spec_definition.key_directive_arguments(directive)) + .find_ok(|key_directive_application| key_directive_application.resolvable) + .map(|result| result.is_some()) +} + +impl<'a> SubgraphConstraint<'a> { + pub(crate) fn at_root( + subgraphs_by_name: &'a IndexMap, ValidFederationSchema>, + ) -> Self { + let all_subgraphs = subgraphs_by_name.keys().cloned().collect(); + SubgraphConstraint { + subgraphs_by_name, + possible_subgraphs: all_subgraphs, + subgraph_types: Default::default(), + } + } + + // Current subgraphs + entity subgraphs + fn possible_subgraphs_for_type( + &self, + ty_pos: &ObjectTypeDefinitionPosition, + ) -> Result>, FederationError> { + let mut result = self.possible_subgraphs.clone(); + for (subgraph_name, subgraph_schema) in self.subgraphs_by_name.iter() { + if let Some(entity_ty_pos) = subgraph_schema.entity_type()? { + let entity_ty_def = entity_ty_pos.get(subgraph_schema.schema())?; + if entity_ty_def.members.contains(&ty_pos.type_name) + && is_resolvable(ty_pos, subgraph_schema)? + { + result.insert(subgraph_name.clone()); + } + } + } + Ok(result) + } + + // (Parent type & field type consistency in subgraphs) Considering the field's possible parent + // types ( `self.subgraph_types`) and their possible entity subgraphs, find all object types + // that the field can resolve to. + fn subgraph_types_for_field(&self, field_name: &str) -> Result { + let mut possible_subgraphs = IndexSet::default(); + let mut subgraph_types = IndexSet::default(); + for parent_type in &self.subgraph_types { + let candidate_subgraphs = self.possible_subgraphs_for_type(parent_type)?; + for subgraph_name in candidate_subgraphs.iter() { + let Some(subgraph_schema) = self.subgraphs_by_name.get(subgraph_name) else { + return Err(internal_error!("subgraph not found: {subgraph_name}")); + }; + // check if this subgraph has the definition for ` { }` + let Some(parent_type) = parent_type.try_get(subgraph_schema.schema()) else { + continue; + }; + let Some(field) = parent_type.fields.get(field_name) else { + continue; + }; + let field_type_name = field.ty.inner_named_type(); + let field_type_pos = subgraph_schema.get_type(field_type_name.clone())?; + if let Ok(composite_type) = + CompositeTypeDefinitionPosition::try_from(field_type_pos) + { + let ground_set = subgraph_schema.possible_runtime_types(composite_type)?; + possible_subgraphs.insert(subgraph_name.clone()); + subgraph_types.extend(ground_set.into_iter()); + } + } + } + Ok(SubgraphConstraint { + subgraphs_by_name: self.subgraphs_by_name, + possible_subgraphs, + subgraph_types, + }) + } +} + +impl PathConstraint for SubgraphConstraint<'_> { + fn under_type_condition(&self, type_cond: &NormalizedTypeCondition) -> Self { + SubgraphConstraint { + subgraphs_by_name: self.subgraphs_by_name, + possible_subgraphs: self.possible_subgraphs.clone(), + subgraph_types: type_cond.ground_set().iter().cloned().collect(), + } + } + + fn for_field(&self, representative_field: &Field) -> Result { + self.subgraph_types_for_field(&representative_field.name) + .map_err(|e| { + // Note: This is an internal federation error, not a comparison error. + // But, we are only allowed to return `ComparisonError` to keep the + // response_shape_compare module free from internal errors. + ComparisonError::new(format!( + "failed to compute subgraph types for {} on {:?} due to an error:\n{e}", + representative_field.name, self.subgraph_types, + )) + }) + } + + fn allows(&self, ty: &ObjectTypeDefinitionPosition) -> bool { + self.subgraph_types.is_empty() || self.subgraph_types.contains(ty) + } + + fn allows_any(&self, defs: &PossibleDefinitions) -> bool { + if self.subgraph_types.is_empty() { + return true; + } + let intersects = |ground_set: &[ObjectTypeDefinitionPosition]| { + // See if `self.subgraph_types` and `ground_set` have any intersection. + ground_set.iter().any(|ty| self.subgraph_types.contains(ty)) + }; + defs.iter() + .any(|(type_cond, _)| intersects(type_cond.ground_set())) + } +} diff --git a/apollo-federation/src/display_helpers.rs b/apollo-federation/src/display_helpers.rs index 330ec64003..a88e683549 100644 --- a/apollo-federation/src/display_helpers.rs +++ b/apollo-federation/src/display_helpers.rs @@ -1,9 +1,6 @@ use std::fmt; -use std::fmt::Debug; use std::fmt::Display; -use serde::Serializer; - pub(crate) struct State<'fmt, 'fmt2> { indent_level: usize, output: &'fmt mut fmt::Formatter<'fmt2>, @@ -70,7 +67,7 @@ pub(crate) fn write_indented_lines( pub(crate) struct DisplaySlice<'a, T>(pub(crate) &'a [T]); -impl<'a, T: Display> Display for DisplaySlice<'a, T> { +impl Display for DisplaySlice<'_, T> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "[")?; let mut iter = self.0.iter(); @@ -98,30 +95,3 @@ impl Display for DisplayOption { } } } - -pub(crate) fn serialize_as_debug_string(data: &T, ser: S) -> Result -where - T: Debug, - S: Serializer, -{ - ser.serialize_str(&format!("{data:?}")) -} - -pub(crate) fn serialize_as_string(data: &T, ser: S) -> Result -where - T: ToString, - S: Serializer, -{ - ser.serialize_str(&data.to_string()) -} - -pub(crate) fn serialize_optional_vec_as_string( - data: &Option>, - ser: S, -) -> Result -where - T: Display, - S: Serializer, -{ - serialize_as_string(&DisplayOption(data.as_deref().map(DisplaySlice)), ser) -} diff --git a/apollo-federation/src/error/mod.rs b/apollo-federation/src/error/mod.rs index cd163ecdab..9516c8a364 100644 --- a/apollo-federation/src/error/mod.rs +++ b/apollo-federation/src/error/mod.rs @@ -1,17 +1,23 @@ -use std::backtrace::Backtrace; +pub(crate) mod suggestion; + use std::cmp::Ordering; use std::fmt::Display; use std::fmt::Formatter; use std::fmt::Write; +use std::ops::Range; +use std::sync::LazyLock; -use apollo_compiler::executable::GetOperationError; -use apollo_compiler::validation::DiagnosticList; -use apollo_compiler::validation::WithErrors; use apollo_compiler::InvalidNameError; use apollo_compiler::Name; -use lazy_static::lazy_static; +use apollo_compiler::ast::OperationType; +use apollo_compiler::parser::LineColumn; +use apollo_compiler::validation::DiagnosticList; +use apollo_compiler::validation::WithErrors; +use crate::subgraph::SubgraphError; use crate::subgraph::spec::FederationSpecError; +use crate::subgraph::typestate::HasMetadata; +use crate::subgraph::typestate::Subgraph; /// Create an internal error. /// @@ -50,7 +56,7 @@ macro_rules! internal_error { #[macro_export] macro_rules! bail { ( $( $arg:tt )+ ) => { - return Err($crate::internal_error!( $( $arg )+ ).into()); + return Err($crate::internal_error!( $( $arg )+ ).into()) } } @@ -84,7 +90,7 @@ macro_rules! ensure { #[cfg(not(debug_assertions))] if !$expr { - $crate::internal_error!( $( $arg )+ ); + $crate::bail!( $( $arg )+ ); } } } @@ -109,14 +115,253 @@ impl From for String { #[derive(Clone, Debug, strum_macros::Display, PartialEq, Eq)] pub enum UnsupportedFeatureKind { - #[strum(to_string = "defer")] - Defer, - #[strum(to_string = "context")] - Context, #[strum(to_string = "alias")] Alias, } +/// Modeled after `SubgraphLocation` defined in `apollo_composition`, so this struct can be +/// converted to it. +#[derive(Clone, Debug)] +pub struct SubgraphLocation { + /// Subgraph name + pub subgraph: String, // TODO: Change this to `Arc`, once `Merger` is updated. + /// Source code range in the subgraph schema document + pub range: Range, +} + +pub type Locations = Vec; + +pub(crate) trait HasLocations { + fn locations(&self, subgraph: &Subgraph) -> Locations; +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum CompositionError { + #[error("[{subgraph}] {error}")] + SubgraphError { + subgraph: String, + error: SingleFederationError, + locations: Locations, + }, + #[error("{message}")] + EmptyMergedEnumType { + message: String, + locations: Locations, + }, + #[error("{message}")] + EnumValueMismatch { message: String }, + #[error("{message}")] + ExternalArgumentTypeMismatch { message: String }, + #[error("{message}")] + ExternalTypeMismatch { message: String }, + #[error("{message}")] + ExternalArgumentDefaultMismatch { message: String }, + #[error("{message}")] + InvalidGraphQL { message: String }, + #[error(transparent)] + InvalidGraphQLName(InvalidNameError), + #[error(r#"{message} in @fromContext substring "{context}""#)] + FromContextParseError { context: String, message: String }, + #[error( + "Unsupported custom directive @{name} on fragment spread. Due to query transformations during planning, the router requires directives on fragment spreads to support both the FRAGMENT_SPREAD and INLINE_FRAGMENT locations." + )] + UnsupportedSpreadDirective { name: Name }, + #[error("{message}")] + DirectiveDefinitionInvalid { message: String }, + #[error("{message}")] + TypeDefinitionInvalid { message: String }, + #[error("{message}")] + InterfaceObjectUsageError { message: String }, + #[error("{message}")] + TypeKindMismatch { message: String }, + #[error("{message}")] + ShareableHasMismatchedRuntimeTypes { message: String }, + #[error("{message}")] + SatisfiabilityError { message: String }, + #[error("{message}")] + MaxValidationSubgraphPathsExceeded { message: String }, + #[error("{message}")] + InternalError { message: String }, + #[error("{message}")] + ExternalArgumentMissing { message: String }, + #[error("{message}")] + ExternalMissingOnBase { message: String }, + #[error("{message}")] + MergedDirectiveApplicationOnExternal { message: String }, + #[error("{message}")] + LinkImportNameMismatch { message: String }, + #[error("{message}")] + InvalidFieldSharing { + message: String, + locations: Locations, + }, + #[error( + "[{subgraph}] Type \"{dest}\" is an extension type, but there is no type definition for \"{dest}\" in any subgraph." + )] + ExtensionWithNoBase { + subgraph: String, + dest: String, + locations: Locations, + }, +} + +impl CompositionError { + pub fn code(&self) -> ErrorCode { + match self { + Self::SubgraphError { error, .. } => error.code(), + Self::EmptyMergedEnumType { .. } => ErrorCode::EmptyMergedEnumType, + Self::EnumValueMismatch { .. } => ErrorCode::EnumValueMismatch, + Self::ExternalTypeMismatch { .. } => ErrorCode::ExternalTypeMismatch, + Self::ExternalArgumentTypeMismatch { .. } => ErrorCode::ExternalArgumentTypeMismatch, + Self::ExternalArgumentDefaultMismatch { .. } => { + ErrorCode::ExternalArgumentDefaultMismatch + } + Self::InvalidGraphQL { .. } => ErrorCode::InvalidGraphQL, + Self::InvalidGraphQLName(..) => ErrorCode::InvalidGraphQL, + Self::FromContextParseError { .. } => ErrorCode::InvalidGraphQL, + Self::UnsupportedSpreadDirective { .. } => ErrorCode::InvalidGraphQL, + Self::DirectiveDefinitionInvalid { .. } => ErrorCode::DirectiveDefinitionInvalid, + Self::TypeDefinitionInvalid { .. } => ErrorCode::TypeDefinitionInvalid, + Self::InterfaceObjectUsageError { .. } => ErrorCode::InterfaceObjectUsageError, + Self::TypeKindMismatch { .. } => ErrorCode::TypeKindMismatch, + Self::ShareableHasMismatchedRuntimeTypes { .. } => { + ErrorCode::ShareableHasMismatchedRuntimeTypes + } + Self::SatisfiabilityError { .. } => ErrorCode::SatisfiabilityError, + Self::MaxValidationSubgraphPathsExceeded { .. } => { + ErrorCode::MaxValidationSubgraphPathsExceeded + } + Self::InternalError { .. } => ErrorCode::Internal, + Self::ExternalArgumentMissing { .. } => ErrorCode::ExternalArgumentMissing, + Self::ExternalMissingOnBase { .. } => ErrorCode::ExternalMissingOnBase, + Self::MergedDirectiveApplicationOnExternal { .. } => { + ErrorCode::MergedDirectiveApplicationOnExternal + } + Self::LinkImportNameMismatch { .. } => ErrorCode::LinkImportNameMismatch, + Self::InvalidFieldSharing { .. } => ErrorCode::InvalidFieldSharing, + Self::ExtensionWithNoBase { .. } => ErrorCode::ExtensionWithNoBase, + } + } + + pub(crate) fn append_message(self, appendix: impl Display) -> Self { + match self { + Self::EmptyMergedEnumType { message, locations } => Self::EmptyMergedEnumType { + message: format!("{message}{appendix}"), + locations, + }, + Self::EnumValueMismatch { message } => Self::EnumValueMismatch { + message: format!("{message}{appendix}"), + }, + Self::ExternalTypeMismatch { message } => Self::ExternalTypeMismatch { + message: format!("{message}{appendix}"), + }, + Self::ExternalArgumentTypeMismatch { message } => Self::ExternalArgumentTypeMismatch { + message: format!("{message}{appendix}"), + }, + Self::ExternalArgumentDefaultMismatch { message } => { + Self::ExternalArgumentDefaultMismatch { + message: format!("{message}{appendix}"), + } + } + Self::InvalidGraphQL { message } => Self::InvalidGraphQL { + message: format!("{message}{appendix}"), + }, + Self::DirectiveDefinitionInvalid { message } => Self::DirectiveDefinitionInvalid { + message: format!("{message}{appendix}"), + }, + Self::TypeDefinitionInvalid { message } => Self::TypeDefinitionInvalid { + message: format!("{message}{appendix}"), + }, + Self::InterfaceObjectUsageError { message } => Self::InterfaceObjectUsageError { + message: format!("{message}{appendix}"), + }, + Self::TypeKindMismatch { message } => Self::TypeKindMismatch { + message: format!("{message}{appendix}"), + }, + Self::ShareableHasMismatchedRuntimeTypes { message } => { + Self::ShareableHasMismatchedRuntimeTypes { + message: format!("{message}{appendix}"), + } + } + Self::SatisfiabilityError { message } => Self::SatisfiabilityError { + message: format!("{message}{appendix}"), + }, + Self::MaxValidationSubgraphPathsExceeded { message } => { + Self::MaxValidationSubgraphPathsExceeded { + message: format!("{message}{appendix}"), + } + } + Self::InternalError { message } => Self::InternalError { + message: format!("{message}{appendix}"), + }, + Self::ExternalArgumentMissing { message } => Self::ExternalArgumentMissing { + message: format!("{message}{appendix}"), + }, + Self::ExternalMissingOnBase { message } => Self::ExternalMissingOnBase { + message: format!("{message}{appendix}"), + }, + Self::MergedDirectiveApplicationOnExternal { message } => { + Self::MergedDirectiveApplicationOnExternal { + message: format!("{message}{appendix}"), + } + } + Self::LinkImportNameMismatch { message } => Self::LinkImportNameMismatch { + message: format!("{message}{appendix}"), + }, + Self::InvalidFieldSharing { message, locations } => Self::InvalidFieldSharing { + message: format!("{message}{appendix}"), + locations, + }, + // Remaining errors do not have an obvious way to appending a message, so we just return self. + Self::SubgraphError { .. } + | Self::InvalidGraphQLName(..) + | Self::FromContextParseError { .. } + | Self::UnsupportedSpreadDirective { .. } + | Self::ExtensionWithNoBase { .. } => self, + } + } + + pub fn locations(&self) -> &[SubgraphLocation] { + match self { + Self::SubgraphError { locations, .. } + | Self::EmptyMergedEnumType { locations, .. } + | Self::ExtensionWithNoBase { locations, .. } + | Self::InvalidFieldSharing { locations, .. } => locations, + _ => &[], + } + } +} + +impl SubgraphError { + pub fn to_composition_errors(&self) -> impl Iterator { + self.errors + .iter() + .map(move |error| CompositionError::SubgraphError { + subgraph: self.subgraph.clone(), + error: error.error.clone(), + locations: error + .locations + .iter() + .map(|range| SubgraphLocation { + subgraph: self.subgraph.clone(), + range: range.clone(), + }) + .collect(), + }) + } +} + +/* TODO(@tylerbloom): This is currently not needed. SingleFederation errors are aggregated using + * MultipleFederationErrors. This is then turned into a FederationError, then in a SubgraphError, + * and finally into a CompositionError. Not implementing this yet also ensures that any + * SingleFederationErrors that are intented on becoming SubgraphErrors still do. +impl> From for SingleCompositionError { + fn from(_value: E) -> Self { + todo!() + } +} +*/ + #[derive(Debug, Clone, thiserror::Error)] pub enum SingleFederationError { #[error( @@ -126,15 +371,27 @@ pub enum SingleFederationError { #[error("An internal error has occurred, please report this bug to Apollo. Details: {0}")] #[allow(private_interfaces)] // users should not inspect this. InternalRebaseError(#[from] crate::operation::RebaseError), - #[error("{diagnostics}")] - InvalidGraphQL { diagnostics: DiagnosticList }, + // This is a known bug that will take time to fix, and does not require reporting. + #[error("{message}")] + InternalUnmergeableFields { message: String }, + // InvalidGraphQL: We need to be able to modify the message text from apollo-compiler. So, we + // format the DiagnosticData into String here. We can add additional data as + // necessary. + #[error("{message}")] + InvalidGraphQL { message: String }, #[error(transparent)] InvalidGraphQLName(#[from] InvalidNameError), #[error("Subgraph invalid: {message}")] InvalidSubgraph { message: String }, #[error("Operation name not found")] UnknownOperation, - #[error("Unsupported custom directive @{name} on fragment spread. Due to query transformations during planning, the router requires directives on fragment spreads to support both the FRAGMENT_SPREAD and INLINE_FRAGMENT locations.")] + #[error("Must provide operation name if query contains multiple operations")] + OperationNameNotProvided, + #[error(r#"{message} in @fromContext substring "{context}""#)] + FromContextParseError { context: String, message: String }, + #[error( + "Unsupported custom directive @{name} on fragment spread. Due to query transformations during planning, the router requires directives on fragment spreads to support both the FRAGMENT_SPREAD and INLINE_FRAGMENT locations." + )] UnsupportedSpreadDirective { name: Name }, #[error("{message}")] DirectiveDefinitionInvalid { message: String }, @@ -150,52 +407,138 @@ pub enum SingleFederationError { UnknownFederationLinkVersion { message: String }, #[error("{message}")] UnknownLinkVersion { message: String }, - #[error("{message}")] - KeyFieldsHasArgs { message: String }, - #[error("{message}")] - ProvidesFieldsHasArgs { message: String }, - #[error("{message}")] - ProvidesFieldsMissingExternal { message: String }, - #[error("{message}")] - RequiresFieldsMissingExternal { message: String }, + #[error( + "On type \"{target_type}\", for {application}: field {inner_coordinate} cannot be included because it has arguments (fields with argument are not allowed in @key)" + )] + KeyFieldsHasArgs { + target_type: Name, + application: String, + inner_coordinate: String, + }, + #[error( + "On field \"{coordinate}\", for {application}: field {inner_coordinate} cannot be included because it has arguments (fields with argument are not allowed in @provides)" + )] + ProvidesFieldsHasArgs { + coordinate: String, + application: String, + inner_coordinate: String, + }, + #[error("On field \"{coordinate}\", for {application}: {message}")] + ProvidesFieldsMissingExternal { + coordinate: String, + application: String, + message: String, + }, + #[error("On field \"{coordinate}\", for {application}: {message}")] + RequiresFieldsMissingExternal { + coordinate: String, + application: String, + message: String, + }, #[error("{message}")] KeyUnsupportedOnInterface { message: String }, #[error("{message}")] ProvidesUnsupportedOnInterface { message: String }, #[error("{message}")] RequiresUnsupportedOnInterface { message: String }, - #[error("{message}")] - KeyDirectiveInFieldsArgs { message: String }, - #[error("{message}")] - ProvidesDirectiveInFieldsArgs { message: String }, - #[error("{message}")] - RequiresDirectiveInFieldsArgs { message: String }, + #[error( + "On type \"{target_type}\", for {application}: cannot have directive applications in the @key(fields:) argument but found {applied_directives}." + )] + KeyHasDirectiveInFieldsArg { + target_type: Name, + application: String, + applied_directives: String, + }, + #[error( + "On field \"{coordinate}\", for {application}: cannot have directive applications in the @provides(fields:) argument but found {applied_directives}." + )] + ProvidesHasDirectiveInFieldsArg { + coordinate: String, + application: String, + applied_directives: String, + }, + #[error( + "On field \"{coordinate}\", for {application}: cannot have directive applications in the @requires(fields:) argument but found {applied_directives}." + )] + RequiresHasDirectiveInFieldsArg { + coordinate: String, + application: String, + applied_directives: String, + }, #[error("{message}")] ExternalUnused { message: String }, - #[error("{message}")] - TypeWithOnlyUnusedExternal { message: String }, + #[error( + "Type {type_name} contains only external fields and all those fields are all unused (they do not appear in any @key, @provides or @requires)." + )] + TypeWithOnlyUnusedExternal { type_name: Name }, #[error("{message}")] ProvidesOnNonObjectField { message: String }, - #[error("{message}")] - KeyInvalidFieldsType { message: String }, - #[error("{message}")] - ProvidesInvalidFieldsType { message: String }, - #[error("{message}")] - RequiresInvalidFieldsType { message: String }, - #[error("{message}")] - KeyInvalidFields { message: String }, - #[error("{message}")] - ProvidesInvalidFields { message: String }, - #[error("{message}")] - RequiresInvalidFields { message: String }, - #[error("{message}")] - KeyFieldsSelectInvalidType { message: String }, - #[error("{message}")] - RootQueryUsed { message: String }, - #[error("{message}")] - RootMutationUsed { message: String }, - #[error("{message}")] - RootSubscriptionUsed { message: String }, + #[error( + "On type \"{target_type}\", for {application}: Invalid value for argument \"fields\": must be a string." + )] + KeyInvalidFieldsType { + target_type: Name, + application: String, + }, + #[error( + "On field \"{coordinate}\", for {application}: Invalid value for argument \"fields\": must be a string." + )] + ProvidesInvalidFieldsType { + coordinate: String, + application: String, + }, + #[error( + "On field \"{coordinate}\", for {application}: Invalid value for argument \"fields\": must be a string." + )] + RequiresInvalidFieldsType { + coordinate: String, + application: String, + }, + #[error("On type \"{target_type}\", for {application}: {message}")] + KeyInvalidFields { + target_type: Name, + application: String, + message: String, + }, + #[error("On field \"{coordinate}\", for {application}: {message}")] + ProvidesInvalidFields { + coordinate: String, + application: String, + message: String, + }, + #[error("On field \"{coordinate}\", for {application}: {message}")] + RequiresInvalidFields { + coordinate: String, + application: String, + message: String, + }, + #[error("On type \"{target_type}\", for {application}: {message}")] + KeyFieldsSelectInvalidType { + target_type: Name, + application: String, + message: String, + }, + #[error( + "The schema has a type named \"{expected_name}\" but it is not set as the query root type (\"{found_name}\" is instead): this is not supported by federation. If a root type does not use its default name, there should be no other type with that default name." + )] + RootQueryUsed { + expected_name: Name, + found_name: Name, + }, + #[error( + "The schema has a type named \"{expected_name}\" but it is not set as the mutation root type (\"{found_name}\" is instead): this is not supported by federation. If a root type does not use its default name, there should be no other type with that default name." + )] + RootMutationUsed { + expected_name: Name, + found_name: Name, + }, + #[error( + "The schema has a type named \"{expected_name}\" but it is not set as the subscription root type (\"{found_name}\" is instead): this is not supported by federation. If a root type does not use its default name, there should be no other type with that default name." + )] + RootSubscriptionUsed { + expected_name: Name, + found_name: Name, + }, #[error("{message}")] InvalidSubgraphName { message: String }, #[error("{message}")] @@ -203,8 +546,6 @@ pub enum SingleFederationError { #[error("{message}")] InterfaceFieldNoImplem { message: String }, #[error("{message}")] - TypeKindMismatch { message: String }, - #[error("{message}")] ExternalTypeMismatch { message: String }, #[error("{message}")] ExternalCollisionWithAnotherDirective { message: String }, @@ -239,8 +580,6 @@ pub enum SingleFederationError { #[error("{message}")] InvalidLinkIdentifier { message: String }, #[error("{message}")] - LinkImportNameMismatch { message: String }, - #[error("{message}")] ReferencedInaccessible { message: String }, #[error("{message}")] DefaultValueUsesInaccessible { message: String }, @@ -295,6 +634,38 @@ pub enum SingleFederationError { InterfaceKeyMissingImplementationType { message: String }, #[error("@defer is not supported on subscriptions")] DeferredSubscriptionUnsupported, + #[error("{message}")] + QueryPlanComplexityExceeded { message: String }, + #[error("the caller requested cancellation")] + PlanningCancelled, + #[error("No plan was found when subgraphs were disabled")] + NoPlanFoundWithDisabledSubgraphs, + #[error("Context name \"{name}\" may not contain an underscore.")] + ContextNameContainsUnderscore { name: String }, + #[error("Context name \"{name}\" is invalid. It should have only alphanumeric characters.")] + ContextNameInvalid { name: String }, + #[error("{message}")] + ContextNotSet { message: String }, + #[error("{message}")] + NoContextReferenced { message: String }, + #[error("{message}")] + NoSelectionForContext { message: String }, + #[error("{message}")] + ContextNoResolvableKey { message: String }, + #[error("@cost cannot be applied to interface \"{interface}.{field}\"")] + CostAppliedToInterfaceField { interface: Name, field: Name }, + #[error("{message}")] + ContextSelectionInvalid { message: String }, + #[error("{message}")] + ListSizeAppliedToNonList { message: String }, + #[error("{message}")] + ListSizeInvalidAssumedSize { message: String }, + #[error("{message}")] + ListSizeInvalidSlicingArgument { message: String }, + #[error("{message}")] + ListSizeInvalidSizedField { message: String }, + #[error("{message}")] + InvalidTagName { message: String }, } impl SingleFederationError { @@ -302,15 +673,19 @@ impl SingleFederationError { match self { SingleFederationError::Internal { .. } => ErrorCode::Internal, SingleFederationError::InternalRebaseError { .. } => ErrorCode::Internal, + SingleFederationError::InternalUnmergeableFields { .. } => ErrorCode::Internal, SingleFederationError::InvalidGraphQL { .. } | SingleFederationError::InvalidGraphQLName(_) => ErrorCode::InvalidGraphQL, SingleFederationError::InvalidSubgraph { .. } => ErrorCode::InvalidGraphQL, - // TODO(@goto-bus-stop): this should have a different error code: it's not the graphql - // that's invalid, but the operation name - SingleFederationError::UnknownOperation => ErrorCode::InvalidGraphQL, + // Technically it's not invalid graphql, but it is invalid syntax inside graphql... + SingleFederationError::FromContextParseError { .. } => ErrorCode::InvalidGraphQL, // TODO(@goto-bus-stop): this should have a different error code: it's not invalid, // just unsupported due to internal limitations. SingleFederationError::UnsupportedSpreadDirective { .. } => ErrorCode::InvalidGraphQL, + // TODO(@goto-bus-stop): this should have a different error code: it's not the graphql + // that's invalid, but the operation name + SingleFederationError::UnknownOperation => ErrorCode::InvalidGraphQL, + SingleFederationError::OperationNameNotProvided => ErrorCode::InvalidGraphQL, SingleFederationError::DirectiveDefinitionInvalid { .. } => { ErrorCode::DirectiveDefinitionInvalid } @@ -321,7 +696,6 @@ impl SingleFederationError { SingleFederationError::UnsupportedFederationVersion { .. } => { ErrorCode::UnsupportedFederationVersion } - SingleFederationError::UnsupportedLinkedFeature { .. } => { ErrorCode::UnsupportedLinkedFeature } @@ -346,13 +720,13 @@ impl SingleFederationError { SingleFederationError::RequiresUnsupportedOnInterface { .. } => { ErrorCode::RequiresUnsupportedOnInterface } - SingleFederationError::KeyDirectiveInFieldsArgs { .. } => { + SingleFederationError::KeyHasDirectiveInFieldsArg { .. } => { ErrorCode::KeyDirectiveInFieldsArgs } - SingleFederationError::ProvidesDirectiveInFieldsArgs { .. } => { + SingleFederationError::ProvidesHasDirectiveInFieldsArg { .. } => { ErrorCode::ProvidesDirectiveInFieldsArgs } - SingleFederationError::RequiresDirectiveInFieldsArgs { .. } => { + SingleFederationError::RequiresHasDirectiveInFieldsArg { .. } => { ErrorCode::RequiresDirectiveInFieldsArgs } SingleFederationError::ExternalUnused { .. } => ErrorCode::ExternalUnused, @@ -383,7 +757,6 @@ impl SingleFederationError { SingleFederationError::InterfaceFieldNoImplem { .. } => { ErrorCode::InterfaceFieldNoImplem } - SingleFederationError::TypeKindMismatch { .. } => ErrorCode::TypeKindMismatch, SingleFederationError::ExternalTypeMismatch { .. } => ErrorCode::ExternalTypeMismatch, SingleFederationError::ExternalCollisionWithAnotherDirective { .. } => { ErrorCode::ExternalCollisionWithAnotherDirective @@ -419,9 +792,6 @@ impl SingleFederationError { ErrorCode::InvalidLinkDirectiveUsage } SingleFederationError::InvalidLinkIdentifier { .. } => ErrorCode::InvalidLinkIdentifier, - SingleFederationError::LinkImportNameMismatch { .. } => { - ErrorCode::LinkImportNameMismatch - } SingleFederationError::ReferencedInaccessible { .. } => { ErrorCode::ReferencedInaccessible } @@ -482,6 +852,67 @@ impl SingleFederationError { ErrorCode::InterfaceKeyMissingImplementationType } SingleFederationError::DeferredSubscriptionUnsupported => ErrorCode::Internal, + SingleFederationError::QueryPlanComplexityExceeded { .. } => { + ErrorCode::QueryPlanComplexityExceededError + } + SingleFederationError::PlanningCancelled => ErrorCode::Internal, + SingleFederationError::NoPlanFoundWithDisabledSubgraphs => { + ErrorCode::NoPlanFoundWithDisabledSubgraphs + } + SingleFederationError::ContextNameContainsUnderscore { .. } => { + ErrorCode::ContextNameContainsUnderscore + } + SingleFederationError::ContextNameInvalid { .. } => ErrorCode::ContextNameInvalid, + SingleFederationError::ContextNotSet { .. } => ErrorCode::ContextNotSet, + SingleFederationError::NoContextReferenced { .. } => ErrorCode::NoContextReferenced, + SingleFederationError::NoSelectionForContext { .. } => ErrorCode::NoSelectionForContext, + SingleFederationError::ContextNoResolvableKey { .. } => { + ErrorCode::ContextNoResolvableKey + } + SingleFederationError::ContextSelectionInvalid { .. } => { + ErrorCode::ContextSelectionInvalid + } + SingleFederationError::CostAppliedToInterfaceField { .. } => { + ErrorCode::CostAppliedToInterfaceField + } + SingleFederationError::ListSizeAppliedToNonList { .. } => { + ErrorCode::ListSizeAppliedToNonList + } + SingleFederationError::ListSizeInvalidAssumedSize { .. } => { + ErrorCode::ListSizeInvalidAssumedSize + } + SingleFederationError::ListSizeInvalidSlicingArgument { .. } => { + ErrorCode::ListSizeInvalidSlicingArgument + } + SingleFederationError::ListSizeInvalidSizedField { .. } => { + ErrorCode::ListSizeInvalidSizedField + } + SingleFederationError::InvalidTagName { .. } => ErrorCode::InvalidTagName, + } + } + + pub fn code_string(&self) -> String { + self.code().definition().code().to_string() + } + + pub(crate) fn root_already_used( + operation_type: OperationType, + expected_name: Name, + found_name: Name, + ) -> Self { + match operation_type { + OperationType::Query => Self::RootQueryUsed { + expected_name, + found_name, + }, + OperationType::Mutation => Self::RootMutationUsed { + expected_name, + found_name, + }, + OperationType::Subscription => Self::RootSubscriptionUsed { + expected_name, + found_name, + }, } } } @@ -492,12 +923,6 @@ impl From for FederationError { } } -impl From for FederationError { - fn from(_: GetOperationError) -> Self { - SingleFederationError::UnknownOperation.into() - } -} - impl From for FederationError { fn from(err: FederationSpecError) -> Self { // TODO: When we get around to finishing the composition port, we should really switch it to @@ -515,16 +940,20 @@ impl From for FederationError { } } -#[derive(Debug, Clone, thiserror::Error)] +#[derive(Debug, Clone, thiserror::Error, Default)] pub struct MultipleFederationErrors { - pub errors: Vec, + pub(crate) errors: Vec, } impl MultipleFederationErrors { + pub fn new() -> Self { + Self { errors: vec![] } + } + pub fn push(&mut self, error: FederationError) { match error { - FederationError::SingleFederationError { inner, .. } => { - self.errors.push(inner); + FederationError::SingleFederationError(error) => { + self.errors.push(error); } FederationError::MultipleFederationErrors(errors) => { self.errors.extend(errors.errors); @@ -534,6 +963,16 @@ impl MultipleFederationErrors { } } } + + pub(crate) fn and_try(mut self, other: Result<(), FederationError>) -> Self { + match other { + Ok(_) => self, + Err(e) => { + self.push(e); + self + } + } + } } impl Display for MultipleFederationErrors { @@ -553,12 +992,6 @@ impl Display for MultipleFederationErrors { } } -impl From for SingleFederationError { - fn from(diagnostics: DiagnosticList) -> Self { - SingleFederationError::InvalidGraphQL { diagnostics } - } -} - impl FromIterator for MultipleFederationErrors { fn from_iter>(iter: T) -> Self { Self { @@ -591,22 +1024,14 @@ impl Display for AggregateFederationError { } } -/// Work around thiserror, which when an error field has a type named `Backtrace` -/// "helpfully" implements `Error::provides` even though that API is not stable yet: -/// -type ThiserrorTrustMeThisIsTotallyNotABacktrace = Backtrace; - // PORT_NOTE: Often times, JS functions would either throw/return a GraphQLError, return a vector // of GraphQLErrors, or take a vector of GraphQLErrors and group them together under an // AggregateGraphQLError which itself would have a specific error message and code, and throw that. // We represent all these cases with an enum, and delegate to the members. -#[derive(thiserror::Error)] +#[derive(Clone, thiserror::Error)] pub enum FederationError { - #[error("{inner}")] - SingleFederationError { - inner: SingleFederationError, - trace: ThiserrorTrustMeThisIsTotallyNotABacktrace, - }, + #[error(transparent)] + SingleFederationError(#[from] SingleFederationError), #[error(transparent)] MultipleFederationErrors(#[from] MultipleFederationErrors), #[error(transparent)] @@ -616,25 +1041,26 @@ pub enum FederationError { impl std::fmt::Debug for FederationError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - Self::SingleFederationError { inner, trace } => write!(f, "{inner}\n{trace}"), + Self::SingleFederationError(inner) => std::fmt::Debug::fmt(inner, f), Self::MultipleFederationErrors(inner) => std::fmt::Debug::fmt(inner, f), Self::AggregateFederationError(inner) => std::fmt::Debug::fmt(inner, f), } } } -impl From for FederationError { - fn from(inner: SingleFederationError) -> Self { - Self::SingleFederationError { - inner, - trace: Backtrace::capture(), - } - } -} - impl From for FederationError { fn from(value: DiagnosticList) -> Self { - SingleFederationError::from(value).into() + let errors: Vec<_> = value + .iter() + .map(|d| SingleFederationError::InvalidGraphQL { + message: d.to_string(), + }) + .collect(); + match errors.len().cmp(&1) { + Ordering::Less => internal_error!("diagnostic list is unexpectedly empty"), + Ordering::Equal => errors[0].clone().into(), + Ordering::Greater => MultipleFederationErrors { errors }.into(), + } } } @@ -644,6 +1070,14 @@ impl From> for FederationError { } } +// Used for when we condition on a type `T: TryInto`, but we have an infallible conversion of +// `T: Into`. This allows us to unwrap the `Result` with `?`. +impl From for FederationError { + fn from(_: std::convert::Infallible) -> Self { + unreachable!("Infallible should never be converted to FederationError") + } +} + impl FederationError { pub fn internal(message: impl Into) -> Self { SingleFederationError::Internal { @@ -651,8 +1085,79 @@ impl FederationError { } .into() } + + pub fn merge(self, other: Self) -> Self { + let mut result = MultipleFederationErrors::new(); + result.push(self); + result.push(other); + result.into() + } + + pub fn into_errors(self) -> Vec { + match self { + FederationError::SingleFederationError(e) => vec![e], + FederationError::MultipleFederationErrors(e) => e.errors, + FederationError::AggregateFederationError(e) => e.causes, + } + } + + pub fn errors(&self) -> Vec<&SingleFederationError> { + match self { + FederationError::SingleFederationError(e) => vec![e], + FederationError::MultipleFederationErrors(e) => e.errors.iter().collect(), + FederationError::AggregateFederationError(e) => e.causes.iter().collect(), + } + } + + pub fn has_invalid_graphql_error(&self) -> bool { + self.errors() + .into_iter() + .any(|e| matches!(e, SingleFederationError::InvalidGraphQL { .. })) + } +} + +// Similar to `multi_try` crate, but with `FederationError` instead of `Vec`. +pub trait MultiTry { + type Output; + + fn and_try(self, other: Result) -> Self::Output; } +impl MultiTry for Result<(), FederationError> { + type Output = Result; + + fn and_try(self, other: Result) -> Result { + match (self, other) { + (Ok(_a), Ok(b)) => Ok(b), + (Ok(_a), Err(b)) => Err(b), + (Err(a), Ok(_b)) => Err(a), + (Err(a), Err(b)) => Err(a.merge(b)), + } + } +} + +pub trait MultiTryAll: Sized + Iterator { + /// Apply `predicate` on all elements of the iterator, collecting all errors (if any). + /// - Returns Ok(()), if all elements are Ok. + /// - Otherwise, returns a FederationError with all errors. + /// - Note: Not to be confused with `try_for_each`, which stops on the first error. + fn try_for_all(self, mut predicate: F) -> Result<(), FederationError> + where + F: FnMut(Self::Item) -> Result<(), FederationError>, + { + let mut errors = MultipleFederationErrors::new(); + for item in self { + match predicate(item) { + Ok(()) => {} + Err(e) => errors.push(e), + } + } + errors.into_result() + } +} + +impl MultiTryAll for I {} + impl MultipleFederationErrors { /// Converts into `Result<(), FederationError>`. /// - The return value can be either Ok, Err with a SingleFederationError or MultipleFederationErrors, @@ -762,118 +1267,157 @@ impl ErrorCodeCategory { } } -lazy_static! { - static ref INVALID_GRAPHQL: ErrorCodeDefinition = ErrorCodeDefinition::new( +static INVALID_GRAPHQL: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "INVALID_GRAPHQL".to_owned(), "A schema is invalid GraphQL: it violates one of the rule of the specification.".to_owned(), None, - ); - - static ref DIRECTIVE_DEFINITION_INVALID: ErrorCodeDefinition = ErrorCodeDefinition::new( + ) +}); +static DIRECTIVE_DEFINITION_INVALID: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "DIRECTIVE_DEFINITION_INVALID".to_owned(), "A built-in or federation directive has an invalid definition in the schema.".to_owned(), Some(ErrorCodeMetadata { replaces: &["TAG_DEFINITION_INVALID"], ..DEFAULT_METADATA.clone() }), - ); + ) +}); - static ref TYPE_DEFINITION_INVALID: ErrorCodeDefinition = ErrorCodeDefinition::new( +static TYPE_DEFINITION_INVALID: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "TYPE_DEFINITION_INVALID".to_owned(), "A built-in or federation type has an invalid definition in the schema.".to_owned(), None, - ); + ) +}); - static ref UNSUPPORTED_LINKED_FEATURE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static UNSUPPORTED_LINKED_FEATURE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "UNSUPPORTED_LINKED_FEATURE".to_owned(), "Indicates that a feature used in a @link is either unsupported or is used with unsupported options.".to_owned(), None, - ); + ) +}); - static ref UNKNOWN_FEDERATION_LINK_VERSION: ErrorCodeDefinition = ErrorCodeDefinition::new( +static UNKNOWN_FEDERATION_LINK_VERSION: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "UNKNOWN_FEDERATION_LINK_VERSION".to_owned(), "The version of federation in a @link directive on the schema is unknown.".to_owned(), None, - ); + ) +}); - static ref UNKNOWN_LINK_VERSION: ErrorCodeDefinition = ErrorCodeDefinition::new( +static UNKNOWN_LINK_VERSION: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "UNKNOWN_LINK_VERSION".to_owned(), "The version of @link set on the schema is unknown.".to_owned(), Some(ErrorCodeMetadata { added_in: "2.1.0", replaces: &[], }), - ); + ) +}); - static ref FIELDS_HAS_ARGS: ErrorCodeCategory = ErrorCodeCategory::new_federation_directive( +static FIELDS_HAS_ARGS: LazyLock> = LazyLock::new(|| { + ErrorCodeCategory::new_federation_directive( "FIELDS_HAS_ARGS".to_owned(), - Box::new(|directive| format!("The `fields` argument of a `@{}` directive includes a field defined with arguments (which is not currently supported).", directive)), - None, - ); - - static ref KEY_FIELDS_HAS_ARGS: ErrorCodeDefinition = FIELDS_HAS_ARGS.create_code("key".to_owned()); - static ref PROVIDES_FIELDS_HAS_ARGS: ErrorCodeDefinition = FIELDS_HAS_ARGS.create_code("provides".to_owned()); - - static ref DIRECTIVE_FIELDS_MISSING_EXTERNAL: ErrorCodeCategory = ErrorCodeCategory::new_federation_directive( - "FIELDS_MISSING_EXTERNAL".to_owned(), - Box::new(|directive| format!("The `fields` argument of a `@{}` directive includes a field that is not marked as `@external`.", directive)), - Some(ErrorCodeMetadata { - added_in: FED1_CODE, - replaces: &[], - }), - ); - - static ref PROVIDES_FIELDS_MISSING_EXTERNAL: ErrorCodeDefinition = - DIRECTIVE_FIELDS_MISSING_EXTERNAL.create_code("provides".to_owned()); - static ref REQUIRES_FIELDS_MISSING_EXTERNAL: ErrorCodeDefinition = - DIRECTIVE_FIELDS_MISSING_EXTERNAL.create_code("requires".to_owned()); - - static ref DIRECTIVE_UNSUPPORTED_ON_INTERFACE: ErrorCodeCategory = ErrorCodeCategory::new_federation_directive( - "UNSUPPORTED_ON_INTERFACE".to_owned(), Box::new(|directive| { - let suffix = if directive == "key" { - "only supported when @linking to federation 2.3+" - } else { - "not (yet) supported" - }; format!( - "A `@{}` directive is used on an interface, which is {}.", - directive, suffix + "The `fields` argument of a `@{directive}` directive includes a field defined with arguments (which is not currently supported)." ) }), None, - ); + ) +}); + +static KEY_FIELDS_HAS_ARGS: LazyLock = + LazyLock::new(|| FIELDS_HAS_ARGS.create_code("key".to_owned())); + +static PROVIDES_FIELDS_HAS_ARGS: LazyLock = + LazyLock::new(|| FIELDS_HAS_ARGS.create_code("provides".to_owned())); + +static DIRECTIVE_FIELDS_MISSING_EXTERNAL: LazyLock> = LazyLock::new( + || { + ErrorCodeCategory::new_federation_directive( + "FIELDS_MISSING_EXTERNAL".to_owned(), + Box::new(|directive| { + format!( + "The `fields` argument of a `@{directive}` directive includes a field that is not marked as `@external`." + ) + }), + Some(ErrorCodeMetadata { + added_in: FED1_CODE, + replaces: &[], + }), + ) + }, +); + +static PROVIDES_FIELDS_MISSING_EXTERNAL: LazyLock = + LazyLock::new(|| DIRECTIVE_FIELDS_MISSING_EXTERNAL.create_code("provides".to_owned())); +static REQUIRES_FIELDS_MISSING_EXTERNAL: LazyLock = + LazyLock::new(|| DIRECTIVE_FIELDS_MISSING_EXTERNAL.create_code("requires".to_owned())); + +static DIRECTIVE_UNSUPPORTED_ON_INTERFACE: LazyLock> = + LazyLock::new(|| { + ErrorCodeCategory::new_federation_directive( + "UNSUPPORTED_ON_INTERFACE".to_owned(), + Box::new(|directive| { + let suffix = if directive == "key" { + "only supported when @linking to federation 2.3+" + } else { + "not (yet) supported" + }; + format!("A `@{directive}` directive is used on an interface, which is {suffix}.") + }), + None, + ) + }); - static ref KEY_UNSUPPORTED_ON_INTERFACE: ErrorCodeDefinition = - DIRECTIVE_UNSUPPORTED_ON_INTERFACE.create_code("key".to_owned()); - static ref PROVIDES_UNSUPPORTED_ON_INTERFACE: ErrorCodeDefinition = - DIRECTIVE_UNSUPPORTED_ON_INTERFACE.create_code("provides".to_owned()); - static ref REQUIRES_UNSUPPORTED_ON_INTERFACE: ErrorCodeDefinition = - DIRECTIVE_UNSUPPORTED_ON_INTERFACE.create_code("requires".to_owned()); +static KEY_UNSUPPORTED_ON_INTERFACE: LazyLock = + LazyLock::new(|| DIRECTIVE_UNSUPPORTED_ON_INTERFACE.create_code("key".to_owned())); +static PROVIDES_UNSUPPORTED_ON_INTERFACE: LazyLock = + LazyLock::new(|| DIRECTIVE_UNSUPPORTED_ON_INTERFACE.create_code("provides".to_owned())); +static REQUIRES_UNSUPPORTED_ON_INTERFACE: LazyLock = + LazyLock::new(|| DIRECTIVE_UNSUPPORTED_ON_INTERFACE.create_code("requires".to_owned())); - static ref DIRECTIVE_IN_FIELDS_ARG: ErrorCodeCategory = ErrorCodeCategory::new_federation_directive( +static DIRECTIVE_IN_FIELDS_ARG: LazyLock> = LazyLock::new(|| { + ErrorCodeCategory::new_federation_directive( "DIRECTIVE_IN_FIELDS_ARG".to_owned(), - Box::new(|directive| format!("The `fields` argument of a `@{}` directive includes some directive applications. This is not supported", directive)), + Box::new(|directive| { + format!( + "The `fields` argument of a `@{directive}` directive includes some directive applications. This is not supported" + ) + }), Some(ErrorCodeMetadata { added_in: "2.1.0", replaces: &[], }), - ); - - static ref KEY_DIRECTIVE_IN_FIELDS_ARGS: ErrorCodeDefinition = DIRECTIVE_IN_FIELDS_ARG.create_code("key".to_owned()); - static ref PROVIDES_DIRECTIVE_IN_FIELDS_ARGS: ErrorCodeDefinition = DIRECTIVE_IN_FIELDS_ARG.create_code("provides".to_owned()); - static ref REQUIRES_DIRECTIVE_IN_FIELDS_ARGS: ErrorCodeDefinition = DIRECTIVE_IN_FIELDS_ARG.create_code("requires".to_owned()); - - static ref EXTERNAL_UNUSED: ErrorCodeDefinition = ErrorCodeDefinition::new( + ) +}); + +static KEY_DIRECTIVE_IN_FIELDS_ARGS: LazyLock = + LazyLock::new(|| DIRECTIVE_IN_FIELDS_ARG.create_code("key".to_owned())); +static PROVIDES_DIRECTIVE_IN_FIELDS_ARGS: LazyLock = + LazyLock::new(|| DIRECTIVE_IN_FIELDS_ARG.create_code("provides".to_owned())); +static REQUIRES_DIRECTIVE_IN_FIELDS_ARGS: LazyLock = + LazyLock::new(|| DIRECTIVE_IN_FIELDS_ARG.create_code("requires".to_owned())); + +static EXTERNAL_UNUSED: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "EXTERNAL_UNUSED".to_owned(), "An `@external` field is not being used by any instance of `@key`, `@requires`, `@provides` or to satisfy an interface implementation.".to_owned(), Some(ErrorCodeMetadata { added_in: FED1_CODE, replaces: &[], }), - ); +) +}); - static ref TYPE_WITH_ONLY_UNUSED_EXTERNAL: ErrorCodeDefinition = ErrorCodeDefinition::new( +static TYPE_WITH_ONLY_UNUSED_EXTERNAL: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "TYPE_WITH_ONLY_UNUSED_EXTERNAL".to_owned(), [ "A federation 1 schema has a composite type comprised only of unused external fields.".to_owned(), @@ -881,429 +1425,748 @@ lazy_static! { "But when federation 1 schema are automatically migrated to federation 2 ones, unused external fields are automatically removed, and in rare case this can leave a type empty. If that happens, an error with this code will be raised".to_owned() ].join(" "), None, - ); +) +}); - static ref PROVIDES_ON_NON_OBJECT_FIELD: ErrorCodeDefinition = ErrorCodeDefinition::new( +static PROVIDES_ON_NON_OBJECT_FIELD: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "PROVIDES_ON_NON_OBJECT_FIELD".to_owned(), - "A `@provides` directive is used to mark a field whose base type is not an object type.".to_owned(), + "A `@provides` directive is used to mark a field whose base type is not an object type." + .to_owned(), None, - ); + ) +}); - static ref DIRECTIVE_INVALID_FIELDS_TYPE: ErrorCodeCategory = ErrorCodeCategory::new_federation_directive( +static DIRECTIVE_INVALID_FIELDS_TYPE: LazyLock> = LazyLock::new(|| { + ErrorCodeCategory::new_federation_directive( "INVALID_FIELDS_TYPE".to_owned(), - Box::new(|directive| format!("The value passed to the `fields` argument of a `@{}` directive is not a string.", directive)), + Box::new(|directive| { + format!( + "The value passed to the `fields` argument of a `@{directive}` directive is not a string." + ) + }), None, - ); - - static ref KEY_INVALID_FIELDS_TYPE: ErrorCodeDefinition = - DIRECTIVE_INVALID_FIELDS_TYPE.create_code("key".to_owned()); - static ref PROVIDES_INVALID_FIELDS_TYPE: ErrorCodeDefinition = - DIRECTIVE_INVALID_FIELDS_TYPE.create_code("provides".to_owned()); - static ref REQUIRES_INVALID_FIELDS_TYPE: ErrorCodeDefinition = - DIRECTIVE_INVALID_FIELDS_TYPE.create_code("requires".to_owned()); - - static ref DIRECTIVE_INVALID_FIELDS: ErrorCodeCategory = ErrorCodeCategory::new_federation_directive( + ) +}); + +static KEY_INVALID_FIELDS_TYPE: LazyLock = + LazyLock::new(|| DIRECTIVE_INVALID_FIELDS_TYPE.create_code("key".to_owned())); +static PROVIDES_INVALID_FIELDS_TYPE: LazyLock = + LazyLock::new(|| DIRECTIVE_INVALID_FIELDS_TYPE.create_code("provides".to_owned())); +static REQUIRES_INVALID_FIELDS_TYPE: LazyLock = + LazyLock::new(|| DIRECTIVE_INVALID_FIELDS_TYPE.create_code("requires".to_owned())); + +static DIRECTIVE_INVALID_FIELDS: LazyLock> = LazyLock::new(|| { + ErrorCodeCategory::new_federation_directive( "INVALID_FIELDS".to_owned(), - Box::new(|directive| format!("The `fields` argument of a `@{}` directive is invalid (it has invalid syntax, includes unknown fields, ...).", directive)), + Box::new(|directive| { + format!( + "The `fields` argument of a `@{directive}` directive is invalid (it has invalid syntax, includes unknown fields, ...)." + ) + }), None, - ); - - static ref KEY_INVALID_FIELDS: ErrorCodeDefinition = - DIRECTIVE_INVALID_FIELDS.create_code("key".to_owned()); - static ref PROVIDES_INVALID_FIELDS: ErrorCodeDefinition = - DIRECTIVE_INVALID_FIELDS.create_code("provides".to_owned()); - static ref REQUIRES_INVALID_FIELDS: ErrorCodeDefinition = - DIRECTIVE_INVALID_FIELDS.create_code("requires".to_owned()); - - static ref KEY_FIELDS_SELECT_INVALID_TYPE: ErrorCodeDefinition = ErrorCodeDefinition::new( + ) +}); + +static KEY_INVALID_FIELDS: LazyLock = + LazyLock::new(|| DIRECTIVE_INVALID_FIELDS.create_code("key".to_owned())); +static PROVIDES_INVALID_FIELDS: LazyLock = + LazyLock::new(|| DIRECTIVE_INVALID_FIELDS.create_code("provides".to_owned())); +static REQUIRES_INVALID_FIELDS: LazyLock = + LazyLock::new(|| DIRECTIVE_INVALID_FIELDS.create_code("requires".to_owned())); + +static KEY_FIELDS_SELECT_INVALID_TYPE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "KEY_FIELDS_SELECT_INVALID_TYPE".to_owned(), "The `fields` argument of `@key` directive includes a field whose type is a list, interface, or union type. Fields of these types cannot be part of a `@key`".to_owned(), Some(ErrorCodeMetadata { added_in: FED1_CODE, replaces: &[], }), - ); +) +}); - static ref ROOT_TYPE_USED: ErrorCodeCategory = ErrorCodeCategory::new( +static ROOT_TYPE_USED: LazyLock> = LazyLock::new(|| { + ErrorCodeCategory::new( Box::new(|element| { let kind: String = element.into(); format!("ROOT_{}_USED", kind.to_uppercase()) }), Box::new(|element| { let kind: String = element.into(); - format!("A subgraph's schema defines a type with the name `{}`, while also specifying a _different_ type name as the root query object. This is not allowed.", kind) + format!( + "A subgraph's schema defines a type with the name `{kind}`, while also specifying a _different_ type name as the root query object. This is not allowed." + ) }), Some(ErrorCodeMetadata { added_in: FED1_CODE, replaces: &[], - }) - - ); - - static ref ROOT_QUERY_USED: ErrorCodeDefinition = ROOT_TYPE_USED.create_code(SchemaRootKind::Query); - static ref ROOT_MUTATION_USED: ErrorCodeDefinition = ROOT_TYPE_USED.create_code(SchemaRootKind::Mutation); - static ref ROOT_SUBSCRIPTION_USED: ErrorCodeDefinition = ROOT_TYPE_USED.create_code(SchemaRootKind::Subscription); - - static ref INVALID_SUBGRAPH_NAME: ErrorCodeDefinition = ErrorCodeDefinition::new( + }), + ) +}); + +static ROOT_QUERY_USED: LazyLock = + LazyLock::new(|| ROOT_TYPE_USED.create_code(SchemaRootKind::Query)); +static ROOT_MUTATION_USED: LazyLock = + LazyLock::new(|| ROOT_TYPE_USED.create_code(SchemaRootKind::Mutation)); +static ROOT_SUBSCRIPTION_USED: LazyLock = + LazyLock::new(|| ROOT_TYPE_USED.create_code(SchemaRootKind::Subscription)); + +static INVALID_SUBGRAPH_NAME: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "INVALID_SUBGRAPH_NAME".to_owned(), - "A subgraph name is invalid (subgraph names cannot be a single underscore (\"_\")).".to_owned(), + "A subgraph name is invalid (subgraph names cannot be a single underscore (\"_\"))." + .to_owned(), None, - ); + ) +}); - static ref NO_QUERIES: ErrorCodeDefinition = ErrorCodeDefinition::new( +static NO_QUERIES: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "NO_QUERIES".to_owned(), "None of the composed subgraphs expose any query.".to_owned(), None, - ); + ) +}); - static ref INTERFACE_FIELD_NO_IMPLEM: ErrorCodeDefinition = ErrorCodeDefinition::new( +static INTERFACE_FIELD_NO_IMPLEM: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "INTERFACE_FIELD_NO_IMPLEM".to_owned(), "After subgraph merging, an implementation is missing a field of one of the interface it implements (which can happen for valid subgraphs).".to_owned(), None, - ); + ) +}); - static ref TYPE_KIND_MISMATCH: ErrorCodeDefinition = ErrorCodeDefinition::new( +static TYPE_KIND_MISMATCH: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "TYPE_KIND_MISMATCH".to_owned(), "A type has the same name in different subgraphs, but a different kind. For instance, one definition is an object type but another is an interface.".to_owned(), Some(ErrorCodeMetadata { replaces: &["VALUE_TYPE_KIND_MISMATCH", "EXTENSION_OF_WRONG_KIND", "ENUM_MISMATCH_TYPE"], ..DEFAULT_METADATA.clone() }), - ); + ) +}); - static ref EXTERNAL_TYPE_MISMATCH: ErrorCodeDefinition = ErrorCodeDefinition::new( +static EXTERNAL_TYPE_MISMATCH: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "EXTERNAL_TYPE_MISMATCH".to_owned(), "An `@external` field has a type that is incompatible with the declaration(s) of that field in other subgraphs.".to_owned(), Some(ErrorCodeMetadata { added_in: FED1_CODE, replaces: &[], }), - ); + ) +}); - static ref EXTERNAL_COLLISION_WITH_ANOTHER_DIRECTIVE: ErrorCodeDefinition = ErrorCodeDefinition::new( - "EXTERNAL_COLLISION_WITH_ANOTHER_DIRECTIVE".to_owned(), - "The @external directive collides with other directives in some situations.".to_owned(), - Some(ErrorCodeMetadata { - added_in: "2.1.0", - replaces: &[], - }), - ); +static EXTERNAL_COLLISION_WITH_ANOTHER_DIRECTIVE: LazyLock = + LazyLock::new(|| { + ErrorCodeDefinition::new( + "EXTERNAL_COLLISION_WITH_ANOTHER_DIRECTIVE".to_owned(), + "The @external directive collides with other directives in some situations.".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.1.0", + replaces: &[], + }), + ) + }); - static ref EXTERNAL_ARGUMENT_MISSING: ErrorCodeDefinition = ErrorCodeDefinition::new( +static EXTERNAL_ARGUMENT_MISSING: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "EXTERNAL_ARGUMENT_MISSING".to_owned(), "An `@external` field is missing some arguments present in the declaration(s) of that field in other subgraphs.".to_owned(), None, - ); + ) +}); - static ref EXTERNAL_ARGUMENT_TYPE_MISMATCH: ErrorCodeDefinition = ErrorCodeDefinition::new( +static EXTERNAL_ARGUMENT_TYPE_MISMATCH: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "EXTERNAL_ARGUMENT_TYPE_MISMATCH".to_owned(), "An `@external` field declares an argument with a type that is incompatible with the corresponding argument in the declaration(s) of that field in other subgraphs.".to_owned(), None, - ); - + ) +}); - static ref EXTERNAL_ARGUMENT_DEFAULT_MISMATCH: ErrorCodeDefinition = ErrorCodeDefinition::new( +static EXTERNAL_ARGUMENT_DEFAULT_MISMATCH: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "EXTERNAL_ARGUMENT_DEFAULT_MISMATCH".to_owned(), "An `@external` field declares an argument with a default that is incompatible with the corresponding argument in the declaration(s) of that field in other subgraphs.".to_owned(), None, - ); + ) +}); - static ref EXTERNAL_ON_INTERFACE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static EXTERNAL_ON_INTERFACE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "EXTERNAL_ON_INTERFACE".to_owned(), "The field of an interface type is marked with `@external`: as external is about marking field not resolved by the subgraph and as interface field are not resolved (only implementations of those fields are), an \"external\" interface field is nonsensical".to_owned(), None, - ); + ) +}); - static ref MERGED_DIRECTIVE_APPLICATION_ON_EXTERNAL: ErrorCodeDefinition = ErrorCodeDefinition::new( +static MERGED_DIRECTIVE_APPLICATION_ON_EXTERNAL: LazyLock = LazyLock::new( + || { + ErrorCodeDefinition::new( "MERGED_DIRECTIVE_APPLICATION_ON_EXTERNAL".to_owned(), "In a subgraph, a field is both marked @external and has a merged directive applied to it".to_owned(), None, - ); + ) + }, +); - static ref FIELD_TYPE_MISMATCH: ErrorCodeDefinition = ErrorCodeDefinition::new( +static FIELD_TYPE_MISMATCH: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "FIELD_TYPE_MISMATCH".to_owned(), "A field has a type that is incompatible with other declarations of that field in other subgraphs.".to_owned(), Some(ErrorCodeMetadata { replaces: &["VALUE_TYPE_FIELD_TYPE_MISMATCH"], ..DEFAULT_METADATA.clone() }), - ); + ) +}); - static ref FIELD_ARGUMENT_TYPE_MISMATCH: ErrorCodeDefinition = ErrorCodeDefinition::new( +static FIELD_ARGUMENT_TYPE_MISMATCH: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "FIELD_ARGUMENT_TYPE_MISMATCH".to_owned(), "An argument (of a field/directive) has a type that is incompatible with that of other declarations of that same argument in other subgraphs.".to_owned(), Some(ErrorCodeMetadata { replaces: &["VALUE_TYPE_INPUT_VALUE_MISMATCH"], ..DEFAULT_METADATA.clone() }), - ); + ) +}); - static ref INPUT_FIELD_DEFAULT_MISMATCH: ErrorCodeDefinition = ErrorCodeDefinition::new( +static INPUT_FIELD_DEFAULT_MISMATCH: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "INPUT_FIELD_DEFAULT_MISMATCH".to_owned(), "An input field has a default value that is incompatible with other declarations of that field in other subgraphs.".to_owned(), None, - ); + ) +}); - static ref FIELD_ARGUMENT_DEFAULT_MISMATCH: ErrorCodeDefinition = ErrorCodeDefinition::new( +static FIELD_ARGUMENT_DEFAULT_MISMATCH: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "FIELD_ARGUMENT_DEFAULT_MISMATCH".to_owned(), "An argument (of a field/directive) has a default value that is incompatible with that of other declarations of that same argument in other subgraphs.".to_owned(), None, - ); + ) +}); - static ref EXTENSION_WITH_NO_BASE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static EXTENSION_WITH_NO_BASE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "EXTENSION_WITH_NO_BASE".to_owned(), "A subgraph is attempting to `extend` a type that is not originally defined in any known subgraph.".to_owned(), Some(ErrorCodeMetadata { added_in: FED1_CODE, replaces: &[], }), - ); + ) +}); - static ref EXTERNAL_MISSING_ON_BASE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static EXTERNAL_MISSING_ON_BASE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "EXTERNAL_MISSING_ON_BASE".to_owned(), "A field is marked as `@external` in a subgraph but with no non-external declaration in any other subgraph.".to_owned(), Some(ErrorCodeMetadata { added_in: FED1_CODE, replaces: &[], }), - ); + ) +}); - static ref INVALID_FIELD_SHARING: ErrorCodeDefinition = ErrorCodeDefinition::new( +static INVALID_FIELD_SHARING: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "INVALID_FIELD_SHARING".to_owned(), - "A field that is non-shareable in at least one subgraph is resolved by multiple subgraphs.".to_owned(), + "A field that is non-shareable in at least one subgraph is resolved by multiple subgraphs." + .to_owned(), None, - ); + ) +}); - static ref INVALID_SHAREABLE_USAGE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static INVALID_SHAREABLE_USAGE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "INVALID_SHAREABLE_USAGE".to_owned(), "The `@shareable` federation directive is used in an invalid way.".to_owned(), Some(ErrorCodeMetadata { added_in: "2.1.2", replaces: &[], }), - ); + ) +}); - static ref INVALID_LINK_DIRECTIVE_USAGE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static INVALID_LINK_DIRECTIVE_USAGE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "INVALID_LINK_DIRECTIVE_USAGE".to_owned(), - "An application of the @link directive is invalid/does not respect the specification.".to_owned(), + "An application of the @link directive is invalid/does not respect the specification." + .to_owned(), None, - ); + ) +}); - static ref INVALID_LINK_IDENTIFIER: ErrorCodeDefinition = ErrorCodeDefinition::new( +static INVALID_LINK_IDENTIFIER: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "INVALID_LINK_IDENTIFIER".to_owned(), - "A url/version for a @link feature is invalid/does not respect the specification.".to_owned(), + "A url/version for a @link feature is invalid/does not respect the specification." + .to_owned(), Some(ErrorCodeMetadata { added_in: "2.1.0", replaces: &[], }), - ); + ) +}); - static ref LINK_IMPORT_NAME_MISMATCH: ErrorCodeDefinition = ErrorCodeDefinition::new( +static LINK_IMPORT_NAME_MISMATCH: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "LINK_IMPORT_NAME_MISMATCH".to_owned(), "The import name for a merged directive (as declared by the relevant `@link(import:)` argument) is inconsistent between subgraphs.".to_owned(), None, - ); + ) +}); - static ref REFERENCED_INACCESSIBLE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static REFERENCED_INACCESSIBLE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "REFERENCED_INACCESSIBLE".to_owned(), "An element is marked as @inaccessible but is referenced by an element visible in the API schema.".to_owned(), None, - ); + ) +}); - static ref DEFAULT_VALUE_USES_INACCESSIBLE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static DEFAULT_VALUE_USES_INACCESSIBLE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "DEFAULT_VALUE_USES_INACCESSIBLE".to_owned(), "An element is marked as @inaccessible but is used in the default value of an element visible in the API schema.".to_owned(), None, - ); + ) +}); - static ref QUERY_ROOT_TYPE_INACCESSIBLE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static QUERY_ROOT_TYPE_INACCESSIBLE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "QUERY_ROOT_TYPE_INACCESSIBLE".to_owned(), "An element is marked as @inaccessible but is the query root type, which must be visible in the API schema.".to_owned(), None, - ); + ) +}); - static ref REQUIRED_INACCESSIBLE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static REQUIRED_INACCESSIBLE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "REQUIRED_INACCESSIBLE".to_owned(), "An element is marked as @inaccessible but is required by an element visible in the API schema.".to_owned(), None, - ); + ) +}); - static ref IMPLEMENTED_BY_INACCESSIBLE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static IMPLEMENTED_BY_INACCESSIBLE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "IMPLEMENTED_BY_INACCESSIBLE".to_owned(), "An element is marked as @inaccessible but implements an element visible in the API schema.".to_owned(), None, - ); -} + ) +}); -// The above lazy_static! block hits recursion limit if we try to add more to it, so we start a -// new block here. -lazy_static! { - static ref DISALLOWED_INACCESSIBLE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static DISALLOWED_INACCESSIBLE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "DISALLOWED_INACCESSIBLE".to_owned(), "An element is marked as @inaccessible that is not allowed to be @inaccessible.".to_owned(), None, - ); + ) +}); - static ref ONLY_INACCESSIBLE_CHILDREN: ErrorCodeDefinition = ErrorCodeDefinition::new( +static ONLY_INACCESSIBLE_CHILDREN: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "ONLY_INACCESSIBLE_CHILDREN".to_owned(), "A type visible in the API schema has only @inaccessible children.".to_owned(), None, - ); + ) +}); - static ref REQUIRED_INPUT_FIELD_MISSING_IN_SOME_SUBGRAPH: ErrorCodeDefinition = ErrorCodeDefinition::new( +static REQUIRED_INPUT_FIELD_MISSING_IN_SOME_SUBGRAPH: LazyLock = LazyLock::new( + || { + ErrorCodeDefinition::new( "REQUIRED_INPUT_FIELD_MISSING_IN_SOME_SUBGRAPH".to_owned(), "A field of an input object type is mandatory in some subgraphs, but the field is not defined in all the subgraphs that define the input object type.".to_owned(), None, - ); + ) + }, +); - static ref REQUIRED_ARGUMENT_MISSING_IN_SOME_SUBGRAPH: ErrorCodeDefinition = ErrorCodeDefinition::new( +static REQUIRED_ARGUMENT_MISSING_IN_SOME_SUBGRAPH: LazyLock = LazyLock::new( + || { + ErrorCodeDefinition::new( "REQUIRED_ARGUMENT_MISSING_IN_SOME_SUBGRAPH".to_owned(), "An argument of a field or directive definition is mandatory in some subgraphs, but the argument is not defined in all the subgraphs that define the field or directive definition.".to_owned(), None, - ); + ) + }, +); - static ref EMPTY_MERGED_INPUT_TYPE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static EMPTY_MERGED_INPUT_TYPE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "EMPTY_MERGED_INPUT_TYPE".to_owned(), "An input object type has no field common to all the subgraphs that define the type. Merging that type would result in an invalid empty input object type.".to_owned(), None, - ); + ) +}); - static ref ENUM_VALUE_MISMATCH: ErrorCodeDefinition = ErrorCodeDefinition::new( +static ENUM_VALUE_MISMATCH: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "ENUM_VALUE_MISMATCH".to_owned(), "An enum type that is used as both an input and output type has a value that is not defined in all the subgraphs that define the enum type.".to_owned(), None, - ); + ) +}); - static ref EMPTY_MERGED_ENUM_TYPE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static EMPTY_MERGED_ENUM_TYPE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "EMPTY_MERGED_ENUM_TYPE".to_owned(), "An enum type has no value common to all the subgraphs that define the type. Merging that type would result in an invalid empty enum type.".to_owned(), None, - ); + ) +}); - static ref SHAREABLE_HAS_MISMATCHED_RUNTIME_TYPES: ErrorCodeDefinition = ErrorCodeDefinition::new( +static SHAREABLE_HAS_MISMATCHED_RUNTIME_TYPES: LazyLock = LazyLock::new( + || { + ErrorCodeDefinition::new( "SHAREABLE_HAS_MISMATCHED_RUNTIME_TYPES".to_owned(), "A shareable field return type has mismatched possible runtime types in the subgraphs in which the field is declared. As shared fields must resolve the same way in all subgraphs, this is almost surely a mistake.".to_owned(), None, - ); + ) + }, +); - static ref SATISFIABILITY_ERROR: ErrorCodeDefinition = ErrorCodeDefinition::new( +static SATISFIABILITY_ERROR: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "SATISFIABILITY_ERROR".to_owned(), "Subgraphs can be merged, but the resulting supergraph API would have queries that cannot be satisfied by those subgraphs.".to_owned(), None, - ); + ) +}); - static ref OVERRIDE_FROM_SELF_ERROR: ErrorCodeDefinition = ErrorCodeDefinition::new( +static MAX_VALIDATION_SUBGRAPH_PATHS_EXCEEDED: LazyLock = + LazyLock::new(|| { + ErrorCodeDefinition::new( + "MAX_VALIDATION_SUBGRAPH_PATHS_EXCEEDED".to_owned(), + "The maximum number of validation subgraph paths has been exceeded.".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.8.0", + replaces: &[], + }), + ) + }); + +static OVERRIDE_FROM_SELF_ERROR: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "OVERRIDE_FROM_SELF_ERROR".to_owned(), - "Field with `@override` directive has \"from\" location that references its own subgraph.".to_owned(), + "Field with `@override` directive has \"from\" location that references its own subgraph." + .to_owned(), None, - ); + ) +}); - static ref OVERRIDE_SOURCE_HAS_OVERRIDE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static OVERRIDE_SOURCE_HAS_OVERRIDE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "OVERRIDE_SOURCE_HAS_OVERRIDE".to_owned(), "Field which is overridden to another subgraph is also marked @override.".to_owned(), None, - ); + ) +}); - static ref OVERRIDE_COLLISION_WITH_ANOTHER_DIRECTIVE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static OVERRIDE_COLLISION_WITH_ANOTHER_DIRECTIVE: LazyLock = LazyLock::new( + || { + ErrorCodeDefinition::new( "OVERRIDE_COLLISION_WITH_ANOTHER_DIRECTIVE".to_owned(), "The @override directive cannot be used on external fields, nor to override fields with either @external, @provides, or @requires.".to_owned(), None, - ); + ) + }, +); - static ref OVERRIDE_ON_INTERFACE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static OVERRIDE_ON_INTERFACE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "OVERRIDE_ON_INTERFACE".to_owned(), "The @override directive cannot be used on the fields of an interface type.".to_owned(), Some(ErrorCodeMetadata { added_in: "2.3.0", replaces: &[], }), - ); + ) +}); - static ref UNSUPPORTED_FEATURE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static UNSUPPORTED_FEATURE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "UNSUPPORTED_FEATURE".to_owned(), "Indicates an error due to feature currently unsupported by federation.".to_owned(), Some(ErrorCodeMetadata { added_in: "2.1.0", replaces: &[], }), - ); + ) +}); - static ref INVALID_FEDERATION_SUPERGRAPH: ErrorCodeDefinition = ErrorCodeDefinition::new( +static INVALID_FEDERATION_SUPERGRAPH: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "INVALID_FEDERATION_SUPERGRAPH".to_owned(), "Indicates that a schema provided for an Apollo Federation supergraph is not a valid supergraph schema.".to_owned(), Some(ErrorCodeMetadata { added_in: "2.1.0", replaces: &[], }), - ); + ) +}); - static ref DOWNSTREAM_SERVICE_ERROR: ErrorCodeDefinition = ErrorCodeDefinition::new( +static DOWNSTREAM_SERVICE_ERROR: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "DOWNSTREAM_SERVICE_ERROR".to_owned(), "Indicates an error in a subgraph service query during query execution in a federated service.".to_owned(), Some(ErrorCodeMetadata { added_in: FED1_CODE, replaces: &[], }), - ); + ) +}); - static ref DIRECTIVE_COMPOSITION_ERROR: ErrorCodeDefinition = ErrorCodeDefinition::new( +static DIRECTIVE_COMPOSITION_ERROR: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "DIRECTIVE_COMPOSITION_ERROR".to_owned(), "Error when composing custom directives.".to_owned(), Some(ErrorCodeMetadata { added_in: "2.1.0", replaces: &[], }), - ); + ) +}); - static ref INTERFACE_OBJECT_USAGE_ERROR: ErrorCodeDefinition = ErrorCodeDefinition::new( +static INTERFACE_OBJECT_USAGE_ERROR: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "INTERFACE_OBJECT_USAGE_ERROR".to_owned(), "Error in the usage of the @interfaceObject directive.".to_owned(), Some(ErrorCodeMetadata { added_in: "2.3.0", replaces: &[], }), - ); + ) +}); - static ref INTERFACE_KEY_NOT_ON_IMPLEMENTATION: ErrorCodeDefinition = ErrorCodeDefinition::new( +static INTERFACE_KEY_NOT_ON_IMPLEMENTATION: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "INTERFACE_KEY_NOT_ON_IMPLEMENTATION".to_owned(), "A `@key` is defined on an interface type, but is not defined (or is not resolvable) on at least one of the interface implementations".to_owned(), Some(ErrorCodeMetadata { added_in: "2.3.0", replaces: &[], }), - ); + ) +}); - static ref INTERFACE_KEY_MISSING_IMPLEMENTATION_TYPE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static INTERFACE_KEY_MISSING_IMPLEMENTATION_TYPE: LazyLock = LazyLock::new( + || { + ErrorCodeDefinition::new( "INTERFACE_KEY_MISSING_IMPLEMENTATION_TYPE".to_owned(), "A subgraph has a `@key` on an interface type, but that subgraph does not define an implementation (in the supergraph) of that interface".to_owned(), Some(ErrorCodeMetadata { added_in: "2.3.0", replaces: &[], }), - ); + ) + }, +); - static ref INTERNAL: ErrorCodeDefinition = ErrorCodeDefinition::new( +static INTERNAL: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "INTERNAL".to_owned(), "An internal federation error occured.".to_owned(), None, - ); + ) +}); + +static ERROR_CODE_MISSING: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "ERROR_CODE_MISSING".to_owned(), + "An internal federation error occurred when translating a federation error into an error code".to_owned(), + None, + ) +}); - static ref UNSUPPORTED_FEDERATION_VERSION: ErrorCodeDefinition = ErrorCodeDefinition::new( +static UNSUPPORTED_FEDERATION_VERSION: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "UNSUPPORTED_FEDERATION_VERSION".to_owned(), "Supergraphs composed with federation version 1 are not supported. Please recompose your supergraph with federation version 2 or greater".to_owned(), None, - ); + ) +}); - static ref UNSUPPORTED_FEDERATION_DIRECTIVE: ErrorCodeDefinition = ErrorCodeDefinition::new( +static UNSUPPORTED_FEDERATION_DIRECTIVE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( "UNSUPPORTED_FEDERATION_DIRECTIVE".to_owned(), - "Indicates that the specified specification version is outside of supported range".to_owned(), + "Indicates that the specified specification version is outside of supported range" + .to_owned(), None, + ) +}); + +static QUERY_PLAN_COMPLEXITY_EXCEEDED: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "QUERY_PLAN_COMPLEXITY_EXCEEDED".to_owned(), + "Indicates that provided query has too many possible ways to generate a plan and cannot be planned in a reasonable amount of time" + .to_owned(), + None, + ) +}); + +static NO_PLAN_FOUND_WITH_DISABLED_SUBGRAPHS: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "NO_PLAN_FOUND_WITH_DISABLED_SUBGRAPHS".to_owned(), + "Indicates that the provided query could not be query planned due to subgraphs being disabled" + .to_owned(), + None, + ) +}); - ); -} +static COST_APPLIED_TO_INTERFACE_FIELD: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "COST_APPLIED_TO_INTERFACE_FIELD".to_owned(), + "The `@cost` directive must be applied to concrete types".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.9.2", + replaces: &[], + }), + ) +}); + +static LIST_SIZE_APPLIED_TO_NON_LIST: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "LIST_SIZE_APPLIED_TO_NON_LIST".to_owned(), + "The `@listSize` directive must be applied to list types".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.9.2", + replaces: &[], + }), + ) +}); + +static LIST_SIZE_INVALID_ASSUMED_SIZE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "LIST_SIZE_INVALID_ASSUMED_SIZE".to_owned(), + "The `@listSize` directive assumed size cannot be negative".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.9.2", + replaces: &[], + }), + ) +}); + +static LIST_SIZE_INVALID_SLICING_ARGUMENT: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "LIST_SIZE_INVALID_SLICING_ARGUMENT".to_owned(), + "The `@listSize` directive must have existing integer slicing arguments".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.9.2", + replaces: &[], + }), + ) +}); + +static LIST_SIZE_INVALID_SIZED_FIELD: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "LIST_SIZE_INVALID_SIZED_FIELD".to_owned(), + "The `@listSize` directive must reference existing list fields as sized fields".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.9.2", + replaces: &[], + }), + ) +}); + +static CONTEXT_NAME_CONTAINS_UNDERSCORE: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "CONTEXT_NAME_CONTAINS_UNDERSCORE".to_owned(), + "Context name is invalid.".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.8.0", + replaces: &[], + }), + ) +}); + +static CONTEXT_NAME_INVALID: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "CONTEXT_NAME_INVALID".to_owned(), + "Context name is invalid.".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.8.0", + replaces: &[], + }), + ) +}); + +static CONTEXT_NOT_SET: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "CONTEXT_NOT_SET".to_owned(), + "Context is never set for context trying to be used".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.8.0", + replaces: &[], + }), + ) +}); + +static NO_CONTEXT_REFERENCED: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "NO_CONTEXT_REFERENCED".to_owned(), + "Selection in @fromContext field argument does not reference a context".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.8.0", + replaces: &[], + }), + ) +}); -#[derive(Debug, strum_macros::EnumIter)] +static NO_SELECTION_FOR_CONTEXT: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "NO_SELECTION_FOR_CONTEXT".to_owned(), + "field parameter in @fromContext must contain a selection set".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.8.0", + replaces: &[], + }), + ) +}); + +static CONTEXT_NO_RESOLVABLE_KEY: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "CONTEXT_NO_RESOLVABLE_KEY".to_owned(), + "If an ObjectType uses a @fromContext, at least one of its keys must be resolvable" + .to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.8.0", + replaces: &[], + }), + ) +}); + +static CONTEXT_SELECTION_INVALID: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "CONTEXT_SELECTION_INVALID".to_owned(), + "The selection set is invalid".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.8.0", + replaces: &[], + }), + ) +}); + +static INVALID_TAG_NAME: LazyLock = LazyLock::new(|| { + ErrorCodeDefinition::new( + "INVALID_TAG_NAME".to_owned(), + "Invalid value for argument \"name\" in application of @tag.".to_owned(), + Some(ErrorCodeMetadata { + added_in: "2.0.0", + replaces: &[], + }), + ) +}); + +#[derive(Debug, PartialEq, strum_macros::EnumIter)] pub enum ErrorCode { + ErrorCodeMissing, Internal, InvalidGraphQL, DirectiveDefinitionInvalid, @@ -1370,6 +2233,7 @@ pub enum ErrorCode { EmptyMergedEnumType, ShareableHasMismatchedRuntimeTypes, SatisfiabilityError, + MaxValidationSubgraphPathsExceeded, OverrideFromSelfError, OverrideSourceHasOverride, OverrideCollisionWithAnotherDirective, @@ -1383,12 +2247,26 @@ pub enum ErrorCode { InterfaceKeyMissingImplementationType, UnsupportedFederationVersion, UnsupportedFederationDirective, + QueryPlanComplexityExceededError, + NoPlanFoundWithDisabledSubgraphs, + CostAppliedToInterfaceField, + ListSizeAppliedToNonList, + ListSizeInvalidAssumedSize, + ListSizeInvalidSlicingArgument, + ListSizeInvalidSizedField, + ContextNameInvalid, + ContextNameContainsUnderscore, + ContextNotSet, + NoContextReferenced, + NoSelectionForContext, + ContextNoResolvableKey, + ContextSelectionInvalid, + InvalidTagName, } impl ErrorCode { pub fn definition(&self) -> &'static ErrorCodeDefinition { match self { - // TODO: We should determine the code and doc info for internal errors. ErrorCode::Internal => &INTERNAL, ErrorCode::InvalidGraphQL => &INVALID_GRAPHQL, ErrorCode::DirectiveDefinitionInvalid => &DIRECTIVE_DEFINITION_INVALID, @@ -1465,6 +2343,9 @@ impl ErrorCode { &SHAREABLE_HAS_MISMATCHED_RUNTIME_TYPES } ErrorCode::SatisfiabilityError => &SATISFIABILITY_ERROR, + ErrorCode::MaxValidationSubgraphPathsExceeded => { + &MAX_VALIDATION_SUBGRAPH_PATHS_EXCEEDED + } ErrorCode::OverrideFromSelfError => &OVERRIDE_FROM_SELF_ERROR, ErrorCode::OverrideSourceHasOverride => &OVERRIDE_SOURCE_HAS_OVERRIDE, ErrorCode::OverrideCollisionWithAnotherDirective => { @@ -1482,6 +2363,22 @@ impl ErrorCode { } ErrorCode::UnsupportedFederationVersion => &UNSUPPORTED_FEDERATION_VERSION, ErrorCode::UnsupportedFederationDirective => &UNSUPPORTED_FEDERATION_DIRECTIVE, + ErrorCode::QueryPlanComplexityExceededError => &QUERY_PLAN_COMPLEXITY_EXCEEDED, + ErrorCode::NoPlanFoundWithDisabledSubgraphs => &NO_PLAN_FOUND_WITH_DISABLED_SUBGRAPHS, + ErrorCode::CostAppliedToInterfaceField => &COST_APPLIED_TO_INTERFACE_FIELD, + ErrorCode::ListSizeAppliedToNonList => &LIST_SIZE_APPLIED_TO_NON_LIST, + ErrorCode::ListSizeInvalidAssumedSize => &LIST_SIZE_INVALID_ASSUMED_SIZE, + ErrorCode::ListSizeInvalidSlicingArgument => &LIST_SIZE_INVALID_SLICING_ARGUMENT, + ErrorCode::ListSizeInvalidSizedField => &LIST_SIZE_INVALID_SIZED_FIELD, + ErrorCode::ContextNameContainsUnderscore => &CONTEXT_NAME_CONTAINS_UNDERSCORE, + ErrorCode::ContextNameInvalid => &CONTEXT_NAME_INVALID, + ErrorCode::ContextNotSet => &CONTEXT_NOT_SET, + ErrorCode::NoContextReferenced => &NO_CONTEXT_REFERENCED, + ErrorCode::NoSelectionForContext => &NO_SELECTION_FOR_CONTEXT, + ErrorCode::ContextNoResolvableKey => &CONTEXT_NO_RESOLVABLE_KEY, + ErrorCode::ContextSelectionInvalid => &CONTEXT_SELECTION_INVALID, + ErrorCode::InvalidTagName => &INVALID_TAG_NAME, + ErrorCode::ErrorCodeMissing => &ERROR_CODE_MISSING, } } } diff --git a/apollo-federation/src/error/suggestion.rs b/apollo-federation/src/error/suggestion.rs new file mode 100644 index 0000000000..766a207a81 --- /dev/null +++ b/apollo-federation/src/error/suggestion.rs @@ -0,0 +1,47 @@ +use levenshtein::levenshtein; + +use crate::utils::human_readable; + +pub(crate) fn suggestion_list( + input: &str, + options: impl IntoIterator, +) -> Vec { + let threshold = 1 + (input.len() as f64 * 0.4).floor() as usize; + let input_lowercase = input.to_lowercase(); + let mut result = Vec::new(); + for option in options { + // Special casing so that if the only mismatch is in upper/lower-case, then the option is + // always shown. + let distance = if input_lowercase == option.to_lowercase() { + 1 + } else { + levenshtein(input, &option) + }; + if distance <= threshold { + result.push((option, distance)); + } + } + result.sort_by(|x, y| x.1.cmp(&y.1)); + result.into_iter().map(|(s, _)| s.to_string()).collect() +} + +const MAX_SUGGESTIONS: usize = 5; + +/// Given [ A, B, C ], returns "Did you mean A, B, or C?". +pub(crate) fn did_you_mean(suggestions: impl IntoIterator) -> String { + const MESSAGE: &str = "Did you mean "; + + let suggestion_str = human_readable::join_strings( + suggestions + .into_iter() + .take(MAX_SUGGESTIONS) + .map(|s| format!("\"{s}\"")), + human_readable::JoinStringsOptions { + separator: ", ", + first_separator: None, + last_separator: Some(", or "), + output_length_limit: None, + }, + ); + format!("{MESSAGE}{suggestion_str}?") +} diff --git a/apollo-federation/src/lib.rs b/apollo-federation/src/lib.rs index 99d340e881..e7f37e1b35 100644 --- a/apollo-federation/src/lib.rs +++ b/apollo-federation/src/lib.rs @@ -27,50 +27,119 @@ mod api_schema; mod compat; +pub mod composition; +pub mod connectors; +#[cfg(feature = "correctness")] +pub mod correctness; mod display_helpers; pub mod error; pub mod link; pub mod merge; +mod merger; pub(crate) mod operation; pub mod query_graph; pub mod query_plan; pub mod schema; pub mod subgraph; -pub(crate) mod supergraph; +pub mod supergraph; + pub(crate) mod utils; +use apollo_compiler::Schema; use apollo_compiler::ast::NamedType; +use apollo_compiler::collections::HashSet; use apollo_compiler::validation::Valid; -use apollo_compiler::Schema; +use itertools::Itertools; +use link::cache_tag_spec_definition::CACHE_TAG_VERSIONS; use link::join_spec_definition::JOIN_VERSIONS; use schema::FederationSchema; +use strum::IntoEnumIterator; pub use crate::api_schema::ApiSchemaOptions; +use crate::connectors::ConnectSpec; use crate::error::FederationError; +use crate::error::MultiTryAll; +use crate::error::MultipleFederationErrors; use crate::error::SingleFederationError; +use crate::link::authenticated_spec_definition::AUTHENTICATED_VERSIONS; +use crate::link::context_spec_definition::CONTEXT_VERSIONS; +use crate::link::context_spec_definition::ContextSpecDefinition; +use crate::link::cost_spec_definition::COST_VERSIONS; +use crate::link::inaccessible_spec_definition::INACCESSIBLE_VERSIONS; use crate::link::join_spec_definition::JoinSpecDefinition; +use crate::link::link_spec_definition::CORE_VERSIONS; use crate::link::link_spec_definition::LinkSpecDefinition; +use crate::link::policy_spec_definition::POLICY_VERSIONS; +use crate::link::requires_scopes_spec_definition::REQUIRES_SCOPES_VERSIONS; use crate::link::spec::Identity; +use crate::link::spec::Url; +use crate::link::spec::Version; +use crate::link::spec_definition::SpecDefinition; use crate::link::spec_definition::SpecDefinitions; -use crate::merge::merge_subgraphs; +use crate::link::tag_spec_definition::TAG_VERSIONS; use crate::merge::MergeFailure; +use crate::merge::merge_subgraphs; use crate::schema::ValidFederationSchema; use crate::subgraph::ValidSubgraph; pub use crate::supergraph::ValidFederationSubgraph; pub use crate::supergraph::ValidFederationSubgraphs; -pub(crate) type SupergraphSpecs = (&'static LinkSpecDefinition, &'static JoinSpecDefinition); +pub mod internal_lsp_api { + pub use crate::subgraph::schema_diff_expanded_from_initial; +} + +/// Internal API for the apollo-composition crate. +pub mod internal_composition_api { + use super::*; + use crate::schema::validators::cache_tag; + use crate::subgraph::typestate; + + #[derive(Default)] + pub struct ValidationResult { + /// If `errors` is empty, validation was successful. + pub errors: Vec, + } + + /// Validates `@cacheTag` directives in the original (unexpanded) subgraph schema. + /// * name: Subgraph name + /// * url: Subgraph URL + /// * sdl: Subgraph schema + /// * Returns a `ValidationResult` if validation finished (either successfully or with + /// validation errors). + /// * Or, a `FederationError` if validation stopped due to an internal error. + pub fn validate_cache_tag_directives( + name: &str, + url: &str, + sdl: &str, + ) -> Result { + let subgraph = + typestate::Subgraph::parse(name, url, sdl).map_err(|e| e.into_federation_error())?; + let subgraph = subgraph + .expand_links() + .map_err(|e| e.into_federation_error())?; + let mut result = ValidationResult::default(); + cache_tag::validate_cache_tag_directives(subgraph.schema(), &mut result.errors)?; + Ok(result) + } +} + +pub(crate) type SupergraphSpecs = ( + &'static LinkSpecDefinition, + &'static JoinSpecDefinition, + Option<&'static ContextSpecDefinition>, +); pub(crate) fn validate_supergraph_for_query_planning( supergraph_schema: &FederationSchema, ) -> Result { - validate_supergraph(supergraph_schema, &JOIN_VERSIONS) + validate_supergraph(supergraph_schema, &JOIN_VERSIONS, &CONTEXT_VERSIONS) } /// Checks that required supergraph directives are in the schema, and returns which ones were used. pub(crate) fn validate_supergraph( supergraph_schema: &FederationSchema, join_versions: &'static SpecDefinitions, + context_versions: &'static SpecDefinitions, ) -> Result { let Some(metadata) = supergraph_schema.metadata() else { return Err(SingleFederationError::InvalidFederationSupergraph { @@ -94,25 +163,68 @@ pub(crate) fn validate_supergraph( ), }.into()); }; - Ok((link_spec_definition, join_spec_definition)) + let context_spec_definition = metadata.for_identity(&Identity::context_identity()).map(|context_link| { + context_versions.find(&context_link.url.version).ok_or_else(|| { + SingleFederationError::InvalidFederationSupergraph { + message: format!( + "Invalid supergraph: uses unsupported context spec version {} (supported versions: {})", + context_link.url.version, + context_versions.versions().join(", "), + ), + } + }) + }).transpose()?; + if let Some(connect_link) = metadata.for_identity(&ConnectSpec::identity()) { + ConnectSpec::try_from(&connect_link.url.version) + .map_err(|message| SingleFederationError::UnknownLinkVersion { message })?; + } + Ok(( + link_spec_definition, + join_spec_definition, + context_spec_definition, + )) } +#[derive(Debug)] pub struct Supergraph { pub schema: ValidFederationSchema, } impl Supergraph { - pub fn new(schema_str: &str) -> Result { + pub fn new_with_spec_check( + schema_str: &str, + supported_specs: &[Url], + ) -> Result { let schema = Schema::parse_and_validate(schema_str, "schema.graphql")?; - Self::from_schema(schema) + Self::from_schema(schema, Some(supported_specs)) } - pub fn from_schema(schema: Valid) -> Result { - let schema = schema.into_inner(); + /// Same as `new_with_spec_check(...)` with the default set of supported specs. + pub fn new(schema_str: &str) -> Result { + Self::new_with_spec_check(schema_str, &default_supported_supergraph_specs()) + } + + /// Same as `new_with_spec_check(...)` with the specs supported by Router. + pub fn new_with_router_specs(schema_str: &str) -> Result { + Self::new_with_spec_check(schema_str, &router_supported_supergraph_specs()) + } + + /// Construct from a pre-validation supergraph schema, which will be validated. + /// * `supported_specs`: (optional) If provided, checks if all EXECUTION/SECURITY specs are + /// supported. + pub fn from_schema( + schema: Valid, + supported_specs: Option<&[Url]>, + ) -> Result { + let schema: Schema = schema.into_inner(); let schema = FederationSchema::new(schema)?; let _ = validate_supergraph_for_query_planning(&schema)?; + if let Some(supported_specs) = supported_specs { + check_spec_support(&schema, supported_specs)?; + } + Ok(Self { // We know it's valid because the input was. schema: schema.assume_valid()?, @@ -151,3 +263,239 @@ const _: () = { pub(crate) fn is_leaf_type(schema: &Schema, ty: &NamedType) -> bool { schema.get_scalar(ty).is_some() || schema.get_enum(ty).is_some() } + +pub fn default_supported_supergraph_specs() -> Vec { + fn urls(defs: &SpecDefinitions) -> impl Iterator { + defs.iter().map(|(_, def)| def.url()).cloned() + } + + urls(&CORE_VERSIONS) + .chain(urls(&JOIN_VERSIONS)) + .chain(urls(&TAG_VERSIONS)) + .chain(urls(&INACCESSIBLE_VERSIONS)) + .collect() +} + +/// default_supported_supergraph_specs() + additional specs supported by Router +pub fn router_supported_supergraph_specs() -> Vec { + fn urls(defs: &SpecDefinitions) -> impl Iterator { + defs.iter().map(|(_, def)| def.url()).cloned() + } + + // PORT_NOTE: "https://specs.apollo.dev/source/v0.1" is listed in the JS version. But, it is + // not ported here, since it has been fully deprecated. + default_supported_supergraph_specs() + .into_iter() + .chain(urls(&AUTHENTICATED_VERSIONS)) + .chain(urls(&REQUIRES_SCOPES_VERSIONS)) + .chain(urls(&POLICY_VERSIONS)) + .chain(urls(&CONTEXT_VERSIONS)) + .chain(urls(&COST_VERSIONS)) + .chain(urls(&CACHE_TAG_VERSIONS)) + .chain(ConnectSpec::iter().map(|s| s.url())) + .collect() +} + +fn is_core_version_zero_dot_one(url: &Url) -> bool { + CORE_VERSIONS + .find(&Version { major: 0, minor: 1 }) + .is_some_and(|v| *v.url() == *url) +} + +fn check_spec_support( + schema: &FederationSchema, + supported_specs: &[Url], +) -> Result<(), FederationError> { + let Some(metadata) = schema.metadata() else { + // This can't happen since `validate_supergraph_for_query_planning` already checked. + bail!("Schema must have metadata"); + }; + let mut errors = MultipleFederationErrors::new(); + let link_spec = metadata.link_spec_definition()?; + if is_core_version_zero_dot_one(link_spec.url()) { + let has_link_with_purpose = metadata + .all_links() + .iter() + .any(|link| link.purpose.is_some()); + if has_link_with_purpose { + // PORT_NOTE: This is unreachable since the schema is validated before this check in + // Rust and a apollo-compiler error will have been raised already. This is + // still kept for historic reasons and potential fix in the future. However, + // it didn't seem worth changing the router's workflow so this specialized + // error message can be displayed. + errors.push(SingleFederationError::UnsupportedLinkedFeature { + message: format!( + "the `for:` argument is unsupported by version {version} of the core spec.\n\ + Please upgrade to at least @core v0.2 (https://specs.apollo.dev/core/v0.2).", + version = link_spec.url().version), + }.into()); + } + } + + let supported_specs: HashSet<_> = supported_specs.iter().collect(); + errors + .and_try(metadata.all_links().iter().try_for_all(|link| { + let Some(purpose) = link.purpose else { + return Ok(()); + }; + if !is_core_version_zero_dot_one(&link.url) + && purpose != link::Purpose::EXECUTION + && purpose != link::Purpose::SECURITY + { + return Ok(()); + } + + let link_url = &link.url; + if supported_specs.contains(link_url) { + Ok(()) + } else { + Err(SingleFederationError::UnsupportedLinkedFeature { + message: format!("feature {link_url} is for: {purpose} but is unsupported"), + } + .into()) + } + })) + .into_result() +} + +#[cfg(test)] +mod test_supergraph { + use pretty_assertions::assert_str_eq; + + use super::*; + use crate::internal_composition_api::ValidationResult; + use crate::internal_composition_api::validate_cache_tag_directives; + + #[test] + fn validates_connect_spec_is_known() { + let res = Supergraph::new( + r#" + extend schema @link(url: "https://specs.apollo.dev/connect/v99.99") + + # Required stuff for the supergraph to parse at all, not what we're testing + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + scalar link__Import + enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION + } + type Query {required: ID!} + "#, + ) + .expect_err("Unknown spec version did not cause error"); + assert_str_eq!(res.to_string(), "Unknown connect version: 99.99"); + } + + #[track_caller] + fn build_and_validate(name: &str, url: &str, sdl: &str) -> ValidationResult { + validate_cache_tag_directives(name, url, sdl).unwrap() + } + + #[test] + fn it_validates_cache_tag_directives() { + // Ok with older federation versions without @cacheTag directive. + let res = build_and_validate( + "accounts", + "accounts.graphql", + r#" + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.11" + import: ["@key"] + ) + + type Query { + topProducts(first: Int = 5): [Product] + } + + type Product + @key(fields: "upc") + @key(fields: "name") { + upc: String! + name: String! + price: Int + weight: Int + } + "#, + ); + + assert!(res.errors.is_empty()); + + // validation error test + let res = build_and_validate( + "accounts", + "https://accounts", + r#" + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.12" + import: ["@key", "@cacheTag"] + ) + + type Query { + topProducts(first: Int = 5): [Product] + @cacheTag(format: "topProducts") + @cacheTag(format: "topProducts-{$args.first}") + } + + type Product + @key(fields: "upc") + @key(fields: "name") + @cacheTag(format: "product-{$key.upc}") { + upc: String! + name: String! + price: Int + weight: Int + } + "#, + ); + + assert_eq!( + res.errors + .into_iter() + .map(|err| err.to_string()) + .collect::>(), + vec!["Each entity field referenced in a @cacheTag format (applied on entity type) must be a member of every @key field set. In other words, when there are multiple @key fields on the type, the referenced field(s) must be limited to their intersection. Bad cacheTag format \"product-{$key.upc}\" on type \"Product\"".to_string()] + ); + + // valid usage test + let res = build_and_validate( + "accounts", + "accounts.graphql", + r#" + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.12" + import: ["@key", "@cacheTag"] + ) + + type Query { + topProducts(first: Int = 5): [Product] + @cacheTag(format: "topProducts") + @cacheTag(format: "topProducts-{$args.first}") + } + + type Product + @key(fields: "upc") + @cacheTag(format: "product-{$key.upc}") { + upc: String! + name: String! + price: Int + weight: Int + } + "#, + ); + + assert!(res.errors.is_empty()); + } +} diff --git a/apollo-federation/src/link/argument.rs b/apollo-federation/src/link/argument.rs index 12702dadb2..c800844662 100644 --- a/apollo-federation/src/link/argument.rs +++ b/apollo-federation/src/link/argument.rs @@ -1,10 +1,11 @@ use std::ops::Deref; -use apollo_compiler::ast::Value; -use apollo_compiler::schema::Directive; use apollo_compiler::Name; use apollo_compiler::Node; +use apollo_compiler::ast::Value; +use apollo_compiler::schema::Directive; +use crate::bail; use crate::error::FederationError; use crate::error::SingleFederationError; use crate::link::graphql_definition::BooleanOrVariable; @@ -99,22 +100,6 @@ pub(crate) fn directive_optional_boolean_argument( } } -#[allow(dead_code)] -pub(crate) fn directive_required_boolean_argument( - application: &Node, - name: &Name, -) -> Result { - directive_optional_boolean_argument(application, name)?.ok_or_else(|| { - SingleFederationError::Internal { - message: format!( - "Required argument \"{}\" of directive \"@{}\" was not present.", - name, application.name - ), - } - .into() - }) -} - pub(crate) fn directive_optional_variable_boolean_argument( application: &Node, name: &Name, @@ -132,3 +117,20 @@ pub(crate) fn directive_optional_variable_boolean_argument( None => Ok(None), } } + +pub(crate) fn directive_optional_list_argument<'a>( + application: &'a Node, + name: &'_ Name, +) -> Result]>, FederationError> { + match application.specified_argument_by_name(name) { + None => Ok(None), + Some(value) => match value.as_ref() { + Value::Null => Ok(None), + Value::List(values) => Ok(Some(values.as_slice())), + _ => bail!( + r#"Argument "{name}" of directive "@{}" must be a list."#, + application.name + ), + }, + } +} diff --git a/apollo-federation/src/link/authenticated_spec_definition.rs b/apollo-federation/src/link/authenticated_spec_definition.rs new file mode 100644 index 0000000000..50c0d05adf --- /dev/null +++ b/apollo-federation/src/link/authenticated_spec_definition.rs @@ -0,0 +1,123 @@ +use std::sync::LazyLock; + +use apollo_compiler::Name; +use apollo_compiler::name; +use apollo_compiler::schema::DirectiveLocation; + +use crate::link::Purpose; +use crate::link::spec::Identity; +use crate::link::spec::Url; +use crate::link::spec::Version; +use crate::link::spec_definition::SpecDefinition; +use crate::link::spec_definition::SpecDefinitions; +use crate::schema::type_and_directive_specification::DirectiveSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; + +pub(crate) const AUTHENTICATED_DIRECTIVE_NAME_IN_SPEC: Name = name!("authenticated"); + +#[derive(Clone)] +pub(crate) struct AuthenticatedSpecDefinition { + url: Url, + minimum_federation_version: Version, +} + +impl AuthenticatedSpecDefinition { + pub(crate) fn new(version: Version, minimum_federation_version: Version) -> Self { + Self { + url: Url { + identity: Identity::authenticated_identity(), + version, + }, + minimum_federation_version, + } + } + + fn directive_specification(&self) -> Box { + Box::new(DirectiveSpecification::new( + AUTHENTICATED_DIRECTIVE_NAME_IN_SPEC, + &[], + false, // not repeatable + &[ + DirectiveLocation::FieldDefinition, + DirectiveLocation::Object, + DirectiveLocation::Interface, + DirectiveLocation::Scalar, + DirectiveLocation::Enum, + ], + true, // composes + Some(&|v| AUTHENTICATED_VERSIONS.get_dyn_minimum_required_version(v)), + None, + )) + } +} + +impl SpecDefinition for AuthenticatedSpecDefinition { + fn url(&self) -> &Url { + &self.url + } + + fn directive_specs(&self) -> Vec> { + vec![self.directive_specification()] + } + + fn type_specs(&self) -> Vec> { + vec![] + } + + fn minimum_federation_version(&self) -> &Version { + &self.minimum_federation_version + } + + fn purpose(&self) -> Option { + Some(Purpose::SECURITY) + } +} + +pub(crate) static AUTHENTICATED_VERSIONS: LazyLock> = + LazyLock::new(|| { + let mut definitions = SpecDefinitions::new(Identity::authenticated_identity()); + definitions.add(AuthenticatedSpecDefinition::new( + Version { major: 0, minor: 1 }, + Version { major: 2, minor: 5 }, + )); + definitions + }); + +#[cfg(test)] +mod test { + use itertools::Itertools; + + use crate::subgraph::test_utils::BuildOption; + use crate::subgraph::test_utils::build_inner_expanded; + + fn trivial_schema() -> crate::schema::FederationSchema { + build_inner_expanded("type Query { hello: String }", BuildOption::AsFed2) + .unwrap() + .schema() + .to_owned() + } + + fn authenticated_spec_directives_snapshot(schema: &crate::schema::FederationSchema) -> String { + schema + .schema() + .directive_definitions + .iter() + .filter_map(|(name, def)| { + if name.as_str().starts_with("authenticated") { + Some(def.to_string()) + } else { + None + } + }) + .join("\n") + } + + #[test] + fn authenticated_spec_v0_1_definitions() { + let schema = trivial_schema(); + let snapshot = authenticated_spec_directives_snapshot(&schema); + let expected = + r#"directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM"#; + assert_eq!(snapshot.trim(), expected.trim()); + } +} diff --git a/apollo-federation/src/link/cache_tag_spec_definition.rs b/apollo-federation/src/link/cache_tag_spec_definition.rs new file mode 100644 index 0000000000..bf54aea366 --- /dev/null +++ b/apollo-federation/src/link/cache_tag_spec_definition.rs @@ -0,0 +1,95 @@ +use std::sync::LazyLock; + +use apollo_compiler::schema::DirectiveLocation; + +use super::federation_spec_definition::FEDERATION_CACHE_TAG_DIRECTIVE_NAME_IN_SPEC; +use super::federation_spec_definition::FEDERATION_FORMAT_ARGUMENT_NAME; +use crate::link::Purpose; +use crate::link::spec::Identity; +use crate::link::spec::Url; +use crate::link::spec::Version; +use crate::link::spec_definition::SpecDefinition; +use crate::link::spec_definition::SpecDefinitions; +use crate::schema::type_and_directive_specification::ArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; + +pub(crate) struct CacheTagSpecDefinition { + url: Url, + minimum_federation_version: Version, +} + +impl CacheTagSpecDefinition { + pub(crate) fn new(version: Version, minimum_federation_version: Version) -> Self { + Self { + url: Url { + identity: Identity::cache_tag_identity(), + version, + }, + minimum_federation_version, + } + } + + fn directive_locations(&self) -> Vec { + vec![ + DirectiveLocation::FieldDefinition, + DirectiveLocation::Object, + ] + } + + fn directive_specification(&self) -> Box { + // TODO: Port the JS federation PR (#3274), once Rust composition is implemented. + Box::new(DirectiveSpecification::new( + FEDERATION_CACHE_TAG_DIRECTIVE_NAME_IN_SPEC, + &[DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: FEDERATION_FORMAT_ARGUMENT_NAME, + get_type: |_, _| Ok(apollo_compiler::ty!(String!)), + default_value: None, + }, + composition_strategy: None, + }], + true, // repeatable + &self.directive_locations(), + true, // composes + Some(&|v| CACHE_TAG_VERSIONS.get_dyn_minimum_required_version(v)), + None, + )) + } +} + +impl SpecDefinition for CacheTagSpecDefinition { + fn url(&self) -> &Url { + &self.url + } + + fn directive_specs(&self) -> Vec> { + vec![self.directive_specification()] + } + + fn type_specs(&self) -> Vec> { + vec![] + } + + fn minimum_federation_version(&self) -> &Version { + &self.minimum_federation_version + } + + fn purpose(&self) -> Option { + None + } +} + +pub(crate) static CACHE_TAG_VERSIONS: LazyLock> = + LazyLock::new(|| { + let mut definitions = SpecDefinitions::new(Identity::cache_tag_identity()); + definitions.add(CacheTagSpecDefinition::new( + Version { major: 0, minor: 1 }, + Version { + major: 2, + minor: 12, + }, + )); + definitions + }); diff --git a/apollo-federation/src/link/context_spec_definition.rs b/apollo-federation/src/link/context_spec_definition.rs new file mode 100644 index 0000000000..ac31ba3eff --- /dev/null +++ b/apollo-federation/src/link/context_spec_definition.rs @@ -0,0 +1,187 @@ +use std::sync::LazyLock; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Directive; +use apollo_compiler::ast::DirectiveDefinition; +use apollo_compiler::ast::DirectiveLocation; +use apollo_compiler::ast::Type; +use apollo_compiler::ty; + +use super::federation_spec_definition::get_federation_spec_definition_from_subgraph; +use crate::bail; +use crate::error::FederationError; +use crate::internal_error; +use crate::link::Purpose; +use crate::link::argument::directive_required_string_argument; +use crate::link::federation_spec_definition::FEDERATION_CONTEXT_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_FIELD_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_FROM_CONTEXT_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_NAME_ARGUMENT_NAME; +use crate::link::spec::Identity; +use crate::link::spec::Url; +use crate::link::spec::Version; +use crate::link::spec_definition::SpecDefinition; +use crate::link::spec_definition::SpecDefinitions; +use crate::schema::FederationSchema; +use crate::schema::type_and_directive_specification::ArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveSpecification; +use crate::schema::type_and_directive_specification::ScalarTypeSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; +use crate::subgraph::spec::CONTEXTFIELDVALUE_SCALAR_NAME; + +pub(crate) struct ContextDirectiveArguments<'doc> { + pub(crate) name: &'doc str, +} + +#[derive(Clone)] +pub(crate) struct ContextSpecDefinition { + url: Url, + minimum_federation_version: Version, +} + +impl ContextSpecDefinition { + pub(crate) fn new(version: Version, minimum_federation_version: Version) -> Self { + Self { + url: Url { + identity: Identity::context_identity(), + version, + }, + minimum_federation_version, + } + } + + pub(crate) fn context_directive_definition<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result<&'schema Node, FederationError> { + self.directive_definition(schema, &FEDERATION_CONTEXT_DIRECTIVE_NAME_IN_SPEC)? + .ok_or_else(|| internal_error!("Unexpectedly could not find context spec in schema")) + } + + pub(crate) fn context_directive_arguments<'doc>( + &self, + application: &'doc Node, + ) -> Result, FederationError> { + Ok(ContextDirectiveArguments { + name: directive_required_string_argument(application, &FEDERATION_NAME_ARGUMENT_NAME)?, + }) + } + + fn field_argument_specification() -> DirectiveArgumentSpecification { + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: FEDERATION_FIELD_ARGUMENT_NAME, + get_type: |_schema, link| { + let Some(link) = link else { + bail!( + "Type {FEDERATION_FIELD_ARGUMENT_NAME} shouldn't be added without being attached to a @link spec" + ) + }; + let type_name = link.type_name_in_schema(&CONTEXTFIELDVALUE_SCALAR_NAME); + Ok(Type::nullable(Type::Named(type_name))) + }, + default_value: None, + }, + composition_strategy: None, + } + } + + fn for_federation_schema(schema: &FederationSchema) -> Option<&'static Self> { + let link = schema + .metadata()? + .for_identity(&Identity::context_identity())?; + CONTEXT_VERSIONS.find(&link.url.version) + } + + #[allow(dead_code)] + pub(crate) fn context_directive_name( + schema: &FederationSchema, + ) -> Result, FederationError> { + if let Some(spec) = Self::for_federation_schema(schema) { + spec.directive_name_in_schema(schema, &FEDERATION_CONTEXT_DIRECTIVE_NAME_IN_SPEC) + } else if let Ok(fed_spec) = get_federation_spec_definition_from_subgraph(schema) { + fed_spec.directive_name_in_schema(schema, &FEDERATION_CONTEXT_DIRECTIVE_NAME_IN_SPEC) + } else { + Ok(None) + } + } + + #[allow(dead_code)] + pub(crate) fn from_context_directive_name( + schema: &FederationSchema, + ) -> Result, FederationError> { + if let Some(spec) = Self::for_federation_schema(schema) { + spec.directive_name_in_schema(schema, &FEDERATION_FROM_CONTEXT_DIRECTIVE_NAME_IN_SPEC) + } else if let Ok(fed_spec) = get_federation_spec_definition_from_subgraph(schema) { + fed_spec + .directive_name_in_schema(schema, &FEDERATION_FROM_CONTEXT_DIRECTIVE_NAME_IN_SPEC) + } else { + Ok(None) + } + } +} + +impl SpecDefinition for ContextSpecDefinition { + fn url(&self) -> &Url { + &self.url + } + + fn directive_specs(&self) -> Vec> { + let context_spec = DirectiveSpecification::new( + FEDERATION_CONTEXT_DIRECTIVE_NAME_IN_SPEC, + &[DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: FEDERATION_NAME_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String!)), + default_value: None, + }, + composition_strategy: None, + }], + true, + &[ + DirectiveLocation::Interface, + DirectiveLocation::Object, + DirectiveLocation::Union, + ], + true, + Some(&|v| CONTEXT_VERSIONS.get_dyn_minimum_required_version(v)), + None, // TODO: Add transform + ); + let from_context_spec = DirectiveSpecification::new( + FEDERATION_FROM_CONTEXT_DIRECTIVE_NAME_IN_SPEC, + &[Self::field_argument_specification()], + false, + &[DirectiveLocation::ArgumentDefinition], + false, + None, + None, + ); + vec![Box::new(context_spec), Box::new(from_context_spec)] + } + + fn type_specs(&self) -> Vec> { + vec![Box::new(ScalarTypeSpecification { + name: CONTEXTFIELDVALUE_SCALAR_NAME, + })] + } + + fn minimum_federation_version(&self) -> &Version { + &self.minimum_federation_version + } + + fn purpose(&self) -> Option { + Some(Purpose::SECURITY) + } +} + +pub(crate) static CONTEXT_VERSIONS: LazyLock> = + LazyLock::new(|| { + let mut definitions = SpecDefinitions::new(Identity::context_identity()); + definitions.add(ContextSpecDefinition::new( + Version { major: 0, minor: 1 }, + Version { major: 2, minor: 8 }, + )); + definitions + }); diff --git a/apollo-federation/src/link/cost_spec_definition.rs b/apollo-federation/src/link/cost_spec_definition.rs index 9e2aed07cc..c1c1da7f68 100644 --- a/apollo-federation/src/link/cost_spec_definition.rs +++ b/apollo-federation/src/link/cost_spec_definition.rs @@ -1,62 +1,75 @@ +use std::sync::LazyLock; + +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::ast::Argument; use apollo_compiler::ast::Directive; -use apollo_compiler::collections::IndexMap; +use apollo_compiler::ast::DirectiveList; +use apollo_compiler::ast::DirectiveLocation; +use apollo_compiler::ast::FieldDefinition; +use apollo_compiler::ast::InputValueDefinition; use apollo_compiler::name; use apollo_compiler::schema::Component; -use apollo_compiler::schema::EnumType; -use apollo_compiler::schema::ObjectType; -use apollo_compiler::schema::ScalarType; -use apollo_compiler::Name; -use apollo_compiler::Node; -use lazy_static::lazy_static; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::Value; +use apollo_compiler::ty; +use indexmap::IndexSet; use crate::error::FederationError; +use crate::internal_error; +use crate::link::Purpose; +use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; use crate::link::spec::Identity; use crate::link::spec::Url; use crate::link::spec::Version; use crate::link::spec_definition::SpecDefinition; use crate::link::spec_definition::SpecDefinitions; +use crate::schema::FederationSchema; +use crate::schema::argument_composition_strategies::ArgumentCompositionStrategy; use crate::schema::position::EnumTypeDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; use crate::schema::position::ScalarTypeDefinitionPosition; -use crate::schema::FederationSchema; +use crate::schema::type_and_directive_specification::ArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; -pub(crate) const COST_DIRECTIVE_NAME_IN_SPEC: Name = name!("cost"); -pub(crate) const COST_DIRECTIVE_NAME_DEFAULT: Name = name!("federation__cost"); - -pub(crate) const LIST_SIZE_DIRECTIVE_NAME_IN_SPEC: Name = name!("listSize"); -pub(crate) const LIST_SIZE_DIRECTIVE_NAME_DEFAULT: Name = name!("federation__listSize"); +const COST_DIRECTIVE_NAME: Name = name!("cost"); +const COST_DIRECTIVE_WEIGHT_ARGUMENT_NAME: Name = name!("weight"); +const LIST_SIZE_DIRECTIVE_NAME: Name = name!("listSize"); +const LIST_SIZE_DIRECTIVE_ASSUMED_SIZE_ARGUMENT_NAME: Name = name!("assumedSize"); +const LIST_SIZE_DIRECTIVE_SLICING_ARGUMENTS_ARGUMENT_NAME: Name = name!("slicingArguments"); +const LIST_SIZE_DIRECTIVE_SIZED_FIELDS_ARGUMENT_NAME: Name = name!("sizedFields"); +const LIST_SIZE_DIRECTIVE_REQUIRE_ONE_SLICING_ARGUMENT_ARGUMENT_NAME: Name = + name!("requireOneSlicingArgument"); #[derive(Clone)] -pub(crate) struct CostSpecDefinition { +pub struct CostSpecDefinition { url: Url, - minimum_federation_version: Option, + minimum_federation_version: Version, } macro_rules! propagate_demand_control_directives { ($func_name:ident, $directives_ty:ty, $wrap_ty:expr) => { pub(crate) fn $func_name( - &self, - subgraph_schema: &FederationSchema, + supergraph_schema: &FederationSchema, source: &$directives_ty, + subgraph_schema: &FederationSchema, dest: &mut $directives_ty, - original_directive_names: &IndexMap, ) -> Result<(), FederationError> { - let cost_directive_name = original_directive_names.get(&COST_DIRECTIVE_NAME_IN_SPEC); - let cost_directive = cost_directive_name.and_then(|name| source.get(name.as_str())); + let cost_directive = Self::cost_directive_name(supergraph_schema)? + .and_then(|name| source.get(name.as_str())); if let Some(cost_directive) = cost_directive { - dest.push($wrap_ty(self.cost_directive( + dest.push($wrap_ty(Self::cost_directive( subgraph_schema, cost_directive.arguments.clone(), )?)); } - let list_size_directive_name = - original_directive_names.get(&LIST_SIZE_DIRECTIVE_NAME_IN_SPEC); - let list_size_directive = - list_size_directive_name.and_then(|name| source.get(name.as_str())); + let list_size_directive = Self::list_size_directive_name(supergraph_schema)? + .and_then(|name| source.get(name.as_str())); if let Some(list_size_directive) = list_size_directive { - dest.push($wrap_ty(self.list_size_directive( + dest.push($wrap_ty(Self::list_size_directive( subgraph_schema, list_size_directive.arguments.clone(), )?)); @@ -68,34 +81,31 @@ macro_rules! propagate_demand_control_directives { } macro_rules! propagate_demand_control_directives_to_position { - ($func_name:ident, $source_ty:ty, $dest_ty:ty) => { + ($func_name:ident, $source_ty:ty, $pos_ty:ty) => { pub(crate) fn $func_name( - &self, + supergraph_schema: &FederationSchema, subgraph_schema: &mut FederationSchema, - source: &Node<$source_ty>, - dest: &$dest_ty, - original_directive_names: &IndexMap, + pos: &$pos_ty, ) -> Result<(), FederationError> { - let cost_directive_name = original_directive_names.get(&COST_DIRECTIVE_NAME_IN_SPEC); - let cost_directive = - cost_directive_name.and_then(|name| source.directives.get(name.as_str())); + let source = pos.get(supergraph_schema.schema())?; + let cost_directive = Self::cost_directive_name(supergraph_schema)? + .and_then(|name| source.directives.get(name.as_str())); if let Some(cost_directive) = cost_directive { - dest.insert_directive( + pos.insert_directive( subgraph_schema, - Component::from( - self.cost_directive(subgraph_schema, cost_directive.arguments.clone())?, - ), + Component::from(Self::cost_directive( + subgraph_schema, + cost_directive.arguments.clone(), + )?), )?; } - let list_size_directive_name = - original_directive_names.get(&LIST_SIZE_DIRECTIVE_NAME_IN_SPEC); - let list_size_directive = - list_size_directive_name.and_then(|name| source.directives.get(name.as_str())); + let list_size_directive = Self::list_size_directive_name(supergraph_schema)? + .and_then(|name| source.directives.get(name.as_str())); if let Some(list_size_directive) = list_size_directive { - dest.insert_directive( + pos.insert_directive( subgraph_schema, - Component::from(self.list_size_directive( + Component::from(Self::list_size_directive( subgraph_schema, list_size_directive.arguments.clone(), )?), @@ -108,7 +118,7 @@ macro_rules! propagate_demand_control_directives_to_position { } impl CostSpecDefinition { - pub(crate) fn new(version: Version, minimum_federation_version: Option) -> Self { + pub(crate) fn new(version: Version, minimum_federation_version: Version) -> Self { Self { url: Url { identity: Identity::cost_identity(), @@ -119,35 +129,32 @@ impl CostSpecDefinition { } pub(crate) fn cost_directive( - &self, schema: &FederationSchema, arguments: Vec>, ) -> Result { - let name = self - .directive_name_in_schema(schema, &COST_DIRECTIVE_NAME_IN_SPEC)? - .unwrap_or(COST_DIRECTIVE_NAME_DEFAULT); + let name = Self::cost_directive_name(schema)?.ok_or_else(|| { + internal_error!("The \"@cost\" directive is undefined in the target schema") + })?; Ok(Directive { name, arguments }) } pub(crate) fn list_size_directive( - &self, schema: &FederationSchema, arguments: Vec>, ) -> Result { - let name = self - .directive_name_in_schema(schema, &LIST_SIZE_DIRECTIVE_NAME_IN_SPEC)? - .unwrap_or(LIST_SIZE_DIRECTIVE_NAME_DEFAULT); + let name = Self::list_size_directive_name(schema)?.ok_or_else(|| { + internal_error!("The \"@listSize\" directive is undefined in the target schema") + })?; Ok(Directive { name, arguments }) } propagate_demand_control_directives!( propagate_demand_control_directives, - apollo_compiler::ast::DirectiveList, + DirectiveList, Node::new ); - propagate_demand_control_directives_to_position!( propagate_demand_control_directives_for_enum, EnumType, @@ -163,6 +170,154 @@ impl CostSpecDefinition { ScalarType, ScalarTypeDefinitionPosition ); + + fn for_federation_schema(schema: &FederationSchema) -> Option<&'static Self> { + let link = schema + .metadata()? + .for_identity(&Identity::cost_identity())?; + COST_VERSIONS.find(&link.url.version) + } + + /// Returns the name of the `@cost` directive in the given schema, accounting for import aliases or specification name + /// prefixes such as `@federation__cost`. This checks the linked cost specification, if there is one, and falls back + /// to the federation spec. + pub(crate) fn cost_directive_name( + schema: &FederationSchema, + ) -> Result, FederationError> { + if let Some(spec) = Self::for_federation_schema(schema) { + spec.directive_name_in_schema(schema, &COST_DIRECTIVE_NAME) + } else if let Ok(fed_spec) = get_federation_spec_definition_from_subgraph(schema) { + fed_spec.directive_name_in_schema(schema, &COST_DIRECTIVE_NAME) + } else { + Ok(None) + } + } + + /// Returns the name of the `@listSize` directive in the given schema, accounting for import aliases or specification name + /// prefixes such as `@federation__listSize`. This checks the linked cost specification, if there is one, and falls back + /// to the federation spec. + pub(crate) fn list_size_directive_name( + schema: &FederationSchema, + ) -> Result, FederationError> { + if let Some(spec) = Self::for_federation_schema(schema) { + spec.directive_name_in_schema(schema, &LIST_SIZE_DIRECTIVE_NAME) + } else if let Ok(fed_spec) = get_federation_spec_definition_from_subgraph(schema) { + fed_spec.directive_name_in_schema(schema, &LIST_SIZE_DIRECTIVE_NAME) + } else { + Ok(None) + } + } + + pub fn cost_directive_from_argument( + schema: &FederationSchema, + argument: &InputValueDefinition, + ty: &ExtendedType, + ) -> Result, FederationError> { + let directive_name = Self::cost_directive_name(schema)?; + if let Some(name) = directive_name.as_ref() { + Ok(CostDirective::from_directives(name, &argument.directives) + .or(CostDirective::from_schema_directives(name, ty.directives()))) + } else { + Ok(None) + } + } + + pub fn cost_directive_from_field( + schema: &FederationSchema, + field: &FieldDefinition, + ty: &ExtendedType, + ) -> Result, FederationError> { + let directive_name = Self::cost_directive_name(schema)?; + if let Some(name) = directive_name.as_ref() { + Ok(CostDirective::from_directives(name, &field.directives) + .or(CostDirective::from_schema_directives(name, ty.directives()))) + } else { + Ok(None) + } + } + + pub fn list_size_directive_from_field_definition( + schema: &FederationSchema, + field: &FieldDefinition, + ) -> Result, FederationError> { + let directive_name = Self::list_size_directive_name(schema)?; + if let Some(name) = directive_name.as_ref() { + Ok(ListSizeDirective::from_field_definition(name, field)) + } else { + Ok(None) + } + } + + fn cost_directive_specification() -> DirectiveSpecification { + DirectiveSpecification::new( + COST_DIRECTIVE_NAME, + &[DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: COST_DIRECTIVE_WEIGHT_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(Int!)), + default_value: None, + }, + composition_strategy: Some(ArgumentCompositionStrategy::Max), + }], + false, + &[ + DirectiveLocation::ArgumentDefinition, + DirectiveLocation::Enum, + DirectiveLocation::FieldDefinition, + DirectiveLocation::InputFieldDefinition, + DirectiveLocation::Object, + DirectiveLocation::Scalar, + ], + true, + Some(&|v| COST_VERSIONS.get_dyn_minimum_required_version(v)), + None, + ) + } + + fn list_size_directive_specification() -> DirectiveSpecification { + DirectiveSpecification::new( + LIST_SIZE_DIRECTIVE_NAME, + &[ + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: LIST_SIZE_DIRECTIVE_ASSUMED_SIZE_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(Int)), + default_value: None, + }, + composition_strategy: Some(ArgumentCompositionStrategy::NullableMax), + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: LIST_SIZE_DIRECTIVE_SLICING_ARGUMENTS_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!([String!])), + default_value: None, + }, + composition_strategy: Some(ArgumentCompositionStrategy::NullableUnion), + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: LIST_SIZE_DIRECTIVE_SIZED_FIELDS_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!([String!])), + default_value: None, + }, + composition_strategy: Some(ArgumentCompositionStrategy::NullableUnion), + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: LIST_SIZE_DIRECTIVE_REQUIRE_ONE_SLICING_ARGUMENT_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(Boolean)), + default_value: Some(Value::Boolean(true)), + }, + composition_strategy: Some(ArgumentCompositionStrategy::NullableAnd), + }, + ], + false, + &[DirectiveLocation::FieldDefinition], + true, + Some(&|v| COST_VERSIONS.get_dyn_minimum_required_version(v)), + None, + ) + } } impl SpecDefinition for CostSpecDefinition { @@ -170,18 +325,128 @@ impl SpecDefinition for CostSpecDefinition { &self.url } - fn minimum_federation_version(&self) -> Option<&Version> { - self.minimum_federation_version.as_ref() + fn directive_specs(&self) -> Vec> { + vec![ + Box::new(Self::cost_directive_specification()), + Box::new(Self::list_size_directive_specification()), + ] + } + + fn type_specs(&self) -> Vec> { + vec![] + } + + fn minimum_federation_version(&self) -> &Version { + &self.minimum_federation_version + } + + fn purpose(&self) -> Option { + None } } -lazy_static! { - pub(crate) static ref COST_VERSIONS: SpecDefinitions = { +pub(crate) static COST_VERSIONS: LazyLock> = + LazyLock::new(|| { let mut definitions = SpecDefinitions::new(Identity::cost_identity()); definitions.add(CostSpecDefinition::new( Version { major: 0, minor: 1 }, - Some(Version { major: 2, minor: 9 }), + Version { major: 2, minor: 9 }, )); definitions - }; + }); + +pub struct CostDirective { + weight: i32, +} + +impl CostDirective { + pub fn weight(&self) -> f64 { + self.weight as f64 + } + + pub(crate) fn from_directives( + directive_name: &Name, + directives: &DirectiveList, + ) -> Option { + directives + .get(directive_name)? + .specified_argument_by_name(&COST_DIRECTIVE_WEIGHT_ARGUMENT_NAME)? + .to_i32() + .map(|weight| Self { weight }) + } + + pub(crate) fn from_schema_directives( + directive_name: &Name, + directives: &apollo_compiler::schema::DirectiveList, + ) -> Option { + directives + .get(directive_name)? + .specified_argument_by_name(&COST_DIRECTIVE_WEIGHT_ARGUMENT_NAME)? + .to_i32() + .map(|weight| Self { weight }) + } +} + +pub struct ListSizeDirective { + pub assumed_size: Option, + pub slicing_argument_names: Option>, + pub sized_fields: Option>, + pub require_one_slicing_argument: bool, +} + +impl ListSizeDirective { + pub fn from_field_definition( + directive_name: &Name, + definition: &FieldDefinition, + ) -> Option { + let directive = definition.directives.get(directive_name)?; + let assumed_size = Self::assumed_size(directive); + let slicing_argument_names = Self::slicing_argument_names(directive); + let sized_fields = Self::sized_fields(directive); + let require_one_slicing_argument = + Self::require_one_slicing_argument(directive).unwrap_or(true); + + Some(Self { + assumed_size, + slicing_argument_names, + sized_fields, + require_one_slicing_argument, + }) + } + + fn assumed_size(directive: &Directive) -> Option { + directive + .specified_argument_by_name(&LIST_SIZE_DIRECTIVE_ASSUMED_SIZE_ARGUMENT_NAME)? + .to_i32() + } + + fn slicing_argument_names(directive: &Directive) -> Option> { + let names = directive + .specified_argument_by_name(&LIST_SIZE_DIRECTIVE_SLICING_ARGUMENTS_ARGUMENT_NAME)? + .as_list()? + .iter() + .flat_map(|arg| arg.as_str()) + .map(String::from) + .collect(); + Some(names) + } + + fn sized_fields(directive: &Directive) -> Option> { + let fields = directive + .specified_argument_by_name(&LIST_SIZE_DIRECTIVE_SIZED_FIELDS_ARGUMENT_NAME)? + .as_list()? + .iter() + .flat_map(|arg| arg.as_str()) + .map(String::from) + .collect(); + Some(fields) + } + + fn require_one_slicing_argument(directive: &Directive) -> Option { + directive + .specified_argument_by_name( + &LIST_SIZE_DIRECTIVE_REQUIRE_ONE_SLICING_ARGUMENT_ARGUMENT_NAME, + )? + .to_bool() + } } diff --git a/apollo-federation/src/link/database.rs b/apollo-federation/src/link/database.rs index ced0dc7b07..f21fc4d509 100644 --- a/apollo-federation/src/link/database.rs +++ b/apollo-federation/src/link/database.rs @@ -1,19 +1,68 @@ use std::borrow::Cow; use std::sync::Arc; +use apollo_compiler::Schema; use apollo_compiler::ast::Directive; use apollo_compiler::ast::DirectiveLocation; +use apollo_compiler::collections::HashSet; use apollo_compiler::collections::IndexMap; use apollo_compiler::schema::DirectiveDefinition; use apollo_compiler::ty; -use apollo_compiler::Schema; -use crate::link::spec::Identity; -use crate::link::spec::Url; +use crate::SpecDefinition; +use crate::link::DEFAULT_LINK_NAME; use crate::link::Link; use crate::link::LinkError; use crate::link::LinksMetadata; -use crate::link::DEFAULT_LINK_NAME; +use crate::link::federation_spec_definition::FED_1; +use crate::link::federation_spec_definition::FEDERATION_VERSIONS; +use crate::link::federation_spec_definition::FederationSpecDefinition; +use crate::link::federation_spec_definition::fed1_link_imports; +use crate::link::spec::Identity; +use crate::link::spec::Url; +use crate::link::spec::Version; + +fn find_federation_spec_for_version<'a>(version: &Version) -> Option<&'a FederationSpecDefinition> { + if *version == (Version { major: 1, minor: 0 }) { + Some(&FED_1) + } else { + FEDERATION_VERSIONS.find(version) + } +} + +fn validate_federation_imports(link: &Link) -> Result<(), LinkError> { + let Some(federation_spec) = find_federation_spec_for_version(&link.url.version) else { + return Err(LinkError::InvalidImport(format!( + "Unexpected federation version: {}", + link.url.version + ))); + }; + let federation_directives: HashSet<_> = federation_spec + .directive_specs() + .iter() + .map(|spec| spec.name().clone()) + .collect(); + let federation_types: HashSet<_> = federation_spec + .type_specs() + .iter() + .map(|spec| spec.name().clone()) + .collect(); + + for imp in &link.imports { + if imp.is_directive && !federation_directives.contains(&imp.element) { + return Err(LinkError::InvalidImport(format!( + "Cannot import unknown federation directive \"@{}\".", + imp.element, + ))); + } else if !imp.is_directive && !federation_types.contains(&imp.element) { + return Err(LinkError::InvalidImport(format!( + "Cannot import unknown federation element \"{}\".", + imp.element, + ))); + } + } + Ok(()) +} /// Extract @link metadata from a schema. pub fn links_metadata(schema: &Schema) -> Result, LinkError> { @@ -56,7 +105,17 @@ pub fn links_metadata(schema: &Schema) -> Result, LinkErro .iter() .filter(|d| d.name == *link_name_in_schema); for application in link_applications { - let link = Arc::new(Link::from_directive_application(application)?); + let mut link = Link::from_directive_application(application)?; + if link.url.identity == Identity::federation_identity() && link.url.version.major == 1 { + // add fake imports for the fed1 federation link. + if !link.imports.is_empty() { + return Err(LinkError::BootstrapError(format!( + "fed1 @link should not have imports: {link}", + ))); + } + link.imports = fed1_link_imports(); + } + let link = Arc::new(link); links.push(Arc::clone(&link)); if by_identity .insert(link.url.identity.clone(), Arc::clone(&link)) @@ -80,21 +139,25 @@ pub fn links_metadata(schema: &Schema) -> Result, LinkErro // We do a 2nd pass to collect and validate all the imports (it's a separate path so we // know all the names of the spec linked in the schema). for link in &links { + if link.url.identity == Identity::federation_identity() { + validate_federation_imports(link)?; + } + for import in &link.imports { let imported_name = import.imported_name(); let element_map = if import.is_directive { // the name of each spec (in the schema) acts as an implicit import for a // directive of the same name. So one cannot import a direcitive with the // same name than a linked spec. - if let Some(other) = by_name_in_schema.get(imported_name) { - if !Arc::ptr_eq(other, link) { - return Err(LinkError::BootstrapError(format!( - "import for '{}' of {} conflicts with spec {}", - import.imported_display_name(), - link.url, - other.url - ))); - } + if let Some(other) = by_name_in_schema.get(imported_name) + && !Arc::ptr_eq(other, link) + { + return Err(LinkError::BootstrapError(format!( + "import for '{}' of {} conflicts with spec {}", + import.imported_display_name(), + link.url, + other.url + ))); } &mut directives_by_imported_name } else { @@ -129,6 +192,8 @@ pub fn links_metadata(schema: &Schema) -> Result, LinkErro /// ```graphql /// directive @_ANY_NAME_(url: String!, as: String) repeatable on SCHEMA /// directive @_ANY_NAME_(url: String, as: String) repeatable on SCHEMA +/// directive @_ANY_NAME_(url: String!) repeatable on SCHEMA +/// directive @_ANY_NAME_(url: String) repeatable on SCHEMA /// ``` fn is_link_directive_definition(definition: &DirectiveDefinition) -> bool { definition.repeatable @@ -143,7 +208,7 @@ fn is_link_directive_definition(definition: &DirectiveDefinition) -> bool { }) && definition .argument_by_name("as") - .is_some_and(|argument| *argument.ty == ty!(String)) + .is_none_or(|argument| *argument.ty == ty!(String)) } /// Returns true if the given definition matches the @core definition. @@ -172,8 +237,7 @@ fn is_core_directive_definition(definition: &DirectiveDefinition) -> bool { }) && definition .argument_by_name("as") - // Definition may be omitted in old graphs - .map_or(true, |argument| *argument.ty == ty!(String)) + .is_none_or(|argument| *argument.ty == ty!(String)) } /// Returns whether a given directive is the @link or @core directive that imports the @link or @@ -193,7 +257,7 @@ fn is_bootstrap_directive(schema: &Schema, directive: &Directive) -> bool { .specified_argument_by_name("as") .and_then(|value| value.as_str()) .unwrap_or(default_link_name.as_str()); - return url.map_or(false, |url| { + return url.is_ok_and(|url| { url.identity == Identity::link_identity() && directive.name == expected_name }); } @@ -209,7 +273,7 @@ fn is_bootstrap_directive(schema: &Schema, directive: &Directive) -> bool { .specified_argument_by_name("as") .and_then(|value| value.as_str()) .unwrap_or("core"); - return url.map_or(false, |url| { + return url.is_ok_and(|url| { url.identity == Identity::core_identity() && directive.name == expected_name }); } @@ -222,10 +286,10 @@ mod tests { use apollo_compiler::name; use super::*; - use crate::link::spec::Version; - use crate::link::spec::APOLLO_SPEC_DOMAIN; use crate::link::Import; use crate::link::Purpose; + use crate::link::spec::APOLLO_SPEC_DOMAIN; + use crate::link::spec::Version; #[test] fn explicit_root_directive_import() -> Result<(), LinkError> { @@ -251,9 +315,10 @@ mod tests { let meta = links_metadata(&schema)?; let meta = meta.expect("should have metadata"); - assert!(meta - .source_link_of_directive(&name!("inaccessible")) - .is_some()); + assert!( + meta.source_link_of_directive(&name!("inaccessible")) + .is_some() + ); Ok(()) } @@ -280,9 +345,10 @@ mod tests { let schema = Schema::parse(schema, "lonk.graphqls").unwrap(); let meta = links_metadata(&schema)?.expect("should have metadata"); - assert!(meta - .source_link_of_directive(&name!("inaccessible")) - .is_some()); + assert!( + meta.source_link_of_directive(&name!("inaccessible")) + .is_some() + ); Ok(()) } @@ -319,9 +385,10 @@ mod tests { let schema = Schema::parse(schema, "care.graphqls").unwrap(); let meta = links_metadata(&schema)?.expect("should have metadata"); - assert!(meta - .source_link_of_directive(&name!("join__graph")) - .is_some()); + assert!( + meta.source_link_of_directive(&name!("join__graph")) + .is_some() + ); Ok(()) } @@ -358,9 +425,10 @@ mod tests { let meta = links_metadata(&schema)?; let meta = meta.expect("should have metadata"); - assert!(meta - .source_link_of_directive(&name!("myDirective")) - .is_some()); + assert!( + meta.source_link_of_directive(&name!("myDirective")) + .is_some() + ); Ok(()) } @@ -509,7 +577,7 @@ mod tests { let schema = Schema::parse(schema, "testSchema").unwrap(); let errors = links_metadata(&schema).expect_err("should error"); // TODO Multiple errors - insta::assert_snapshot!(errors, @r###"Invalid use of @link in schema: invalid sub-value for @link(import:) argument: values should be either strings or input object values of the form { name: "", as: "" }."###); + insta::assert_snapshot!(errors, @r###"Invalid use of @link in schema: in "2", invalid sub-value for @link(import:) argument: values should be either strings or input object values of the form { name: "", as: "" }."###); } #[test] @@ -534,11 +602,9 @@ mod tests { let schema = Schema::parse(schema, "testSchema").unwrap(); let errors = links_metadata(&schema).expect_err("should error"); // TODO Multiple errors - insta::assert_snapshot!(errors, @"Invalid use of @link in schema: invalid alias 'myKey' for import name '@key': should start with '@' since the imported name does"); + insta::assert_snapshot!(errors, @r###"Invalid use of @link in schema: in "{name: "@key", as: "myKey"}", invalid alias 'myKey' for import name '@key': should start with '@' since the imported name does"###); } - // TODO Implement - /* #[test] fn errors_on_importing_unknown_elements_for_known_features() { let schema = r#" @@ -557,8 +623,65 @@ mod tests { let schema = Schema::parse(schema, "testSchema").unwrap(); let errors = links_metadata(&schema).expect_err("should error"); - insta::assert_snapshot!(errors, @""); + insta::assert_snapshot!(errors, @"Unknown import: Cannot import unknown federation directive \"@foo\"."); + + // TODO Support multiple errors, in the meantime we'll just clone the code and run again + let schema = r#" + extend schema @link(url: "https://specs.apollo.dev/link/v1.0") + extend schema @link( + url: "https://specs.apollo.dev/federation/v2.0", + import: [ "key", { name: "@sharable" } ] + ) + + type Query { + q: Int + } + + directive @link(url: String, as: String, import: [Import], for: link__Purpose) repeatable on SCHEMA + "#; + + let schema = Schema::parse(schema, "testSchema").unwrap(); + let errors = links_metadata(&schema).expect_err("should error"); + insta::assert_snapshot!(errors, @"Unknown import: Cannot import unknown federation element \"key\"."); + + let schema = r#" + extend schema @link(url: "https://specs.apollo.dev/link/v1.0") + extend schema @link( + url: "https://specs.apollo.dev/federation/v2.0", + import: [ { name: "@sharable" } ] + ) + + type Query { + q: Int + } + + directive @link(url: String, as: String, import: [Import], for: link__Purpose) repeatable on SCHEMA + "#; + + let schema = Schema::parse(schema, "testSchema").unwrap(); + let errors = links_metadata(&schema).expect_err("should error"); + insta::assert_snapshot!(errors, @"Unknown import: Cannot import unknown federation directive \"@sharable\"."); + } + } + + #[test] + fn allowed_link_directive_definitions() -> Result<(), LinkError> { + let link_defs = [ + "directive @link(url: String!, as: String) repeatable on SCHEMA", + "directive @link(url: String, as: String) repeatable on SCHEMA", + "directive @link(url: String!) repeatable on SCHEMA", + "directive @link(url: String) repeatable on SCHEMA", + ]; + let schema_prefix = r#" + extend schema @link(url: "https://specs.apollo.dev/link/v1.0") + type Query { x: Int } + "#; + for link_def in link_defs { + let schema_doc = format!("{schema_prefix}\n{link_def}"); + let schema = Schema::parse(&schema_doc, "test.graphql").unwrap(); + let meta = links_metadata(&schema)?; + assert!(meta.is_some(), "should have metadata for: {link_def}"); } - */ + Ok(()) } } diff --git a/apollo-federation/src/link/federation_spec_definition.rs b/apollo-federation/src/link/federation_spec_definition.rs index 184e93b690..3419929d68 100644 --- a/apollo-federation/src/link/federation_spec_definition.rs +++ b/apollo-federation/src/link/federation_spec_definition.rs @@ -1,29 +1,49 @@ +use std::sync::Arc; +use std::sync::LazyLock; + +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::ast::Argument; +use apollo_compiler::ast::DirectiveLocation; +use apollo_compiler::ast::Type; use apollo_compiler::name; use apollo_compiler::schema::Directive; use apollo_compiler::schema::DirectiveDefinition; use apollo_compiler::schema::ExtendedType; use apollo_compiler::schema::UnionType; use apollo_compiler::schema::Value; -use apollo_compiler::Name; -use apollo_compiler::Node; -use lazy_static::lazy_static; +use apollo_compiler::ty; +use crate::ContextSpecDefinition; use crate::error::FederationError; use crate::error::SingleFederationError; +use crate::internal_error; +use crate::link; use crate::link::argument::directive_optional_boolean_argument; use crate::link::argument::directive_optional_string_argument; use crate::link::argument::directive_required_string_argument; -use crate::link::cost_spec_definition::CostSpecDefinition; +use crate::link::authenticated_spec_definition::AUTHENTICATED_VERSIONS; use crate::link::cost_spec_definition::COST_VERSIONS; +use crate::link::inaccessible_spec_definition::INACCESSIBLE_VERSIONS; +use crate::link::policy_spec_definition::POLICY_VERSIONS; +use crate::link::requires_scopes_spec_definition::REQUIRES_SCOPES_VERSIONS; use crate::link::spec::Identity; use crate::link::spec::Url; use crate::link::spec::Version; use crate::link::spec_definition::SpecDefinition; use crate::link::spec_definition::SpecDefinitions; +use crate::link::tag_spec_definition::TAG_VERSIONS; use crate::schema::FederationSchema; +use crate::schema::type_and_directive_specification::ArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveSpecification; +use crate::schema::type_and_directive_specification::ScalarTypeSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; +pub(crate) const FEDERATION_ANY_TYPE_NAME_IN_SPEC: Name = name!("_Any"); +pub(crate) const FEDERATION_CACHE_TAG_DIRECTIVE_NAME_IN_SPEC: Name = name!("cacheTag"); pub(crate) const FEDERATION_ENTITY_TYPE_NAME_IN_SPEC: Name = name!("_Entity"); +pub(crate) const FEDERATION_SERVICE_TYPE_NAME_IN_SPEC: Name = name!("_Service"); pub(crate) const FEDERATION_KEY_DIRECTIVE_NAME_IN_SPEC: Name = name!("key"); pub(crate) const FEDERATION_INTERFACEOBJECT_DIRECTIVE_NAME_IN_SPEC: Name = name!("interfaceObject"); pub(crate) const FEDERATION_EXTENDS_DIRECTIVE_NAME_IN_SPEC: Name = name!("extends"); @@ -32,31 +52,71 @@ pub(crate) const FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC: Name = name!("requi pub(crate) const FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC: Name = name!("provides"); pub(crate) const FEDERATION_SHAREABLE_DIRECTIVE_NAME_IN_SPEC: Name = name!("shareable"); pub(crate) const FEDERATION_OVERRIDE_DIRECTIVE_NAME_IN_SPEC: Name = name!("override"); +pub(crate) const FEDERATION_CONTEXT_DIRECTIVE_NAME_IN_SPEC: Name = name!("context"); +pub(crate) const FEDERATION_FROM_CONTEXT_DIRECTIVE_NAME_IN_SPEC: Name = name!("fromContext"); +pub(crate) const FEDERATION_TAG_DIRECTIVE_NAME_IN_SPEC: Name = name!("tag"); +pub(crate) const FEDERATION_COMPOSEDIRECTIVE_DIRECTIVE_NAME_IN_SPEC: Name = + name!("composeDirective"); +pub(crate) const FEDERATION_FIELDSET_TYPE_NAME_IN_SPEC: Name = name!("FieldSet"); pub(crate) const FEDERATION_FIELDS_ARGUMENT_NAME: Name = name!("fields"); +pub(crate) const FEDERATION_FORMAT_ARGUMENT_NAME: Name = name!("format"); pub(crate) const FEDERATION_RESOLVABLE_ARGUMENT_NAME: Name = name!("resolvable"); pub(crate) const FEDERATION_REASON_ARGUMENT_NAME: Name = name!("reason"); pub(crate) const FEDERATION_FROM_ARGUMENT_NAME: Name = name!("from"); pub(crate) const FEDERATION_OVERRIDE_LABEL_ARGUMENT_NAME: Name = name!("label"); +pub(crate) const FEDERATION_USED_OVERRIDEN_ARGUMENT_NAME: Name = name!("usedOverridden"); +pub(crate) const FEDERATION_CONTEXT_ARGUMENT_NAME: Name = name!("contextArguments"); +pub(crate) const FEDERATION_SELECTION_ARGUMENT_NAME: Name = name!("selection"); +pub(crate) const FEDERATION_TYPE_ARGUMENT_NAME: Name = name!("type"); +pub(crate) const FEDERATION_GRAPH_ARGUMENT_NAME: Name = name!("graph"); +pub(crate) const FEDERATION_NAME_ARGUMENT_NAME: Name = name!("name"); +pub(crate) const FEDERATION_FIELD_ARGUMENT_NAME: Name = name!("field"); + +pub(crate) const FEDERATION_OPERATION_TYPES: [Name; 3] = [ + FEDERATION_ANY_TYPE_NAME_IN_SPEC, + FEDERATION_ENTITY_TYPE_NAME_IN_SPEC, + FEDERATION_SERVICE_TYPE_NAME_IN_SPEC, +]; pub(crate) struct KeyDirectiveArguments<'doc> { pub(crate) fields: &'doc str, pub(crate) resolvable: bool, } +pub(crate) struct ExternalDirectiveArguments<'doc> { + pub(crate) reason: Option<&'doc str>, +} + pub(crate) struct RequiresDirectiveArguments<'doc> { pub(crate) fields: &'doc str, } +pub(crate) struct TagDirectiveArguments<'doc> { + pub(crate) name: &'doc str, +} + pub(crate) struct ProvidesDirectiveArguments<'doc> { pub(crate) fields: &'doc str, } +pub(crate) struct ContextDirectiveArguments<'doc> { + pub(crate) name: &'doc str, +} + +pub(crate) struct FromContextDirectiveArguments<'doc> { + pub(crate) field: &'doc str, +} + pub(crate) struct OverrideDirectiveArguments<'doc> { pub(crate) from: &'doc str, pub(crate) label: Option<&'doc str>, } +pub(crate) struct CacheTagDirectiveArguments<'doc> { + pub(crate) format: &'doc str, +} + #[derive(Debug)] pub(crate) struct FederationSpecDefinition { url: Url, @@ -72,6 +132,32 @@ impl FederationSpecDefinition { } } + // PORT_NOTE: a port of `federationSpec` from JS + pub(crate) fn for_version(version: &Version) -> Result<&'static Self, FederationError> { + FEDERATION_VERSIONS + .find(version) + .ok_or_else(|| internal_error!("Unknown Federation spec version: {version}")) + } + + // PORT_NOTE: a port of `latestFederationSpec`, which is defined as `federationSpec()` in JS. + pub(crate) fn latest() -> &'static Self { + // Note: The `unwrap()` calls won't panic, since `FEDERATION_VERSIONS` will always have at + // least one version. + let latest_version = FEDERATION_VERSIONS.versions().last().unwrap(); + Self::for_version(latest_version).unwrap() + } + + /// Some users rely on auto-expanding fed v1 graphs with fed v2 directives. While technically + /// we should only expand @tag directive from v2 definitions, we will continue expanding other + /// directives (up to v2.4) to ensure backwards compatibility. + pub(crate) fn auto_expanded_federation_spec() -> &'static Self { + Self::for_version(&Version { major: 2, minor: 4 }).unwrap() + } + + pub(crate) fn is_fed1(&self) -> bool { + self.version().satisfies(&Version { major: 1, minor: 0 }) + } + pub(crate) fn entity_type_definition<'schema>( &self, schema: &'schema FederationSchema, @@ -89,8 +175,7 @@ impl FederationSpecDefinition { None => Ok(None), _ => Err(SingleFederationError::Internal { message: format!( - "Unexpectedly found non-union for federation spec's \"{}\" type definition", - FEDERATION_ENTITY_TYPE_NAME_IN_SPEC + "Unexpectedly found non-union for federation spec's \"{FEDERATION_ENTITY_TYPE_NAME_IN_SPEC}\" type definition" ), } .into()), @@ -105,8 +190,7 @@ impl FederationSpecDefinition { .ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Unexpectedly could not find federation spec's \"@{}\" directive definition", - FEDERATION_KEY_DIRECTIVE_NAME_IN_SPEC + "Unexpectedly could not find federation spec's \"@{FEDERATION_KEY_DIRECTIVE_NAME_IN_SPEC}\" directive definition" ), } .into() @@ -152,7 +236,7 @@ impl FederationSpecDefinition { application, &FEDERATION_RESOLVABLE_ARGUMENT_NAME, )? - .unwrap_or(false), + .unwrap_or(Self::resolvable_argument_default_value()), }) } @@ -167,8 +251,7 @@ impl FederationSpecDefinition { .ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Unexpectedly could not find federation spec's \"@{}\" directive definition", - FEDERATION_INTERFACEOBJECT_DIRECTIVE_NAME_IN_SPEC + "Unexpectedly could not find federation spec's \"@{FEDERATION_INTERFACEOBJECT_DIRECTIVE_NAME_IN_SPEC}\" directive definition" ), }.into() }) @@ -203,12 +286,18 @@ impl FederationSpecDefinition { self.directive_definition(schema, &FEDERATION_EXTENDS_DIRECTIVE_NAME_IN_SPEC)? .ok_or_else(|| { FederationError::internal(format!( - "Unexpectedly could not find federation spec's \"@{}\" directive definition", - FEDERATION_EXTENDS_DIRECTIVE_NAME_IN_SPEC + "Unexpectedly could not find federation spec's \"@{FEDERATION_EXTENDS_DIRECTIVE_NAME_IN_SPEC}\" directive definition" )) }) } + pub(crate) fn external_directive_name_in_schema( + &self, + schema: &FederationSchema, + ) -> Result, FederationError> { + self.directive_name_in_schema(schema, &FEDERATION_EXTERNAL_DIRECTIVE_NAME_IN_SPEC) + } + pub(crate) fn external_directive_definition<'schema>( &self, schema: &'schema FederationSchema, @@ -217,8 +306,7 @@ impl FederationSpecDefinition { .ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Unexpectedly could not find federation spec's \"@{}\" directive definition", - FEDERATION_EXTERNAL_DIRECTIVE_NAME_IN_SPEC + "Unexpectedly could not find federation spec's \"@{FEDERATION_EXTERNAL_DIRECTIVE_NAME_IN_SPEC}\" directive definition" ), }.into() }) @@ -230,7 +318,7 @@ impl FederationSpecDefinition { reason: Option, ) -> Result { let name_in_schema = self - .directive_name_in_schema(schema, &FEDERATION_EXTERNAL_DIRECTIVE_NAME_IN_SPEC)? + .external_directive_name_in_schema(schema)? .ok_or_else(|| SingleFederationError::Internal { message: "Unexpectedly could not find federation spec in schema".to_owned(), })?; @@ -247,6 +335,53 @@ impl FederationSpecDefinition { }) } + pub(crate) fn external_directive_arguments<'doc>( + &self, + application: &'doc Node, + ) -> Result, FederationError> { + Ok(ExternalDirectiveArguments { + reason: directive_optional_string_argument( + application, + &FEDERATION_REASON_ARGUMENT_NAME, + )?, + }) + } + + pub(crate) fn tag_directive_definition<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result<&'schema Node, FederationError> { + self.directive_definition(schema, &FEDERATION_TAG_DIRECTIVE_NAME_IN_SPEC)? + .ok_or_else(|| { + SingleFederationError::Internal { + message: format!( + "Unexpectedly could not find federation spec's \"@{FEDERATION_TAG_DIRECTIVE_NAME_IN_SPEC}\" directive definition" + ), + }.into() + }) + } + + #[allow(unused)] + pub(crate) fn tag_directive( + &self, + schema: &FederationSchema, + name: String, + ) -> Result { + let name_in_schema = self + .directive_name_in_schema(schema, &FEDERATION_TAG_DIRECTIVE_NAME_IN_SPEC)? + .ok_or_else(|| SingleFederationError::Internal { + message: "Unexpectedly could not find federation spec in schema".to_owned(), + })?; + let mut arguments = vec![Node::new(Argument { + name: FEDERATION_NAME_ARGUMENT_NAME, + value: Node::new(Value::String(name)), + })]; + Ok(Directive { + name: name_in_schema, + arguments, + }) + } + pub(crate) fn requires_directive_definition<'schema>( &self, schema: &'schema FederationSchema, @@ -255,13 +390,21 @@ impl FederationSpecDefinition { .ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Unexpectedly could not find federation spec's \"@{}\" directive definition", - FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC + "Unexpectedly could not find federation spec's \"@{FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC}\" directive definition" ), }.into() }) } + pub(crate) fn tag_directive_arguments<'doc>( + &self, + application: &'doc Node, + ) -> Result, FederationError> { + Ok(TagDirectiveArguments { + name: directive_required_string_argument(application, &FEDERATION_NAME_ARGUMENT_NAME)?, + }) + } + pub(crate) fn requires_directive( &self, schema: &FederationSchema, @@ -301,8 +444,7 @@ impl FederationSpecDefinition { .ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Unexpectedly could not find federation spec's \"@{}\" directive definition", - FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC + "Unexpectedly could not find federation spec's \"@{FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC}\" directive definition" ), }.into() }) @@ -339,6 +481,13 @@ impl FederationSpecDefinition { }) } + pub(crate) fn shareable_directive_name_in_schema( + &self, + schema: &FederationSchema, + ) -> Result, FederationError> { + self.directive_name_in_schema(schema, &FEDERATION_SHAREABLE_DIRECTIVE_NAME_IN_SPEC) + } + pub(crate) fn shareable_directive_definition<'schema>( &self, schema: &'schema FederationSchema, @@ -346,8 +495,7 @@ impl FederationSpecDefinition { self.directive_definition(schema, &FEDERATION_SHAREABLE_DIRECTIVE_NAME_IN_SPEC)? .ok_or_else(|| { FederationError::internal(format!( - "Unexpectedly could not find federation spec's \"@{}\" directive definition", - FEDERATION_SHAREABLE_DIRECTIVE_NAME_IN_SPEC + "Unexpectedly could not find federation spec's \"@{FEDERATION_SHAREABLE_DIRECTIVE_NAME_IN_SPEC}\" directive definition" )) }) } @@ -357,7 +505,7 @@ impl FederationSpecDefinition { schema: &FederationSchema, ) -> Result { let name_in_schema = self - .directive_name_in_schema(schema, &FEDERATION_SHAREABLE_DIRECTIVE_NAME_IN_SPEC)? + .shareable_directive_name_in_schema(schema)? .ok_or_else(|| SingleFederationError::Internal { message: "Unexpectedly could not find federation spec in schema".to_owned(), })?; @@ -374,8 +522,7 @@ impl FederationSpecDefinition { self.directive_definition(schema, &FEDERATION_OVERRIDE_DIRECTIVE_NAME_IN_SPEC)? .ok_or_else(|| { FederationError::internal(format!( - "Unexpectedly could not find federation spec's \"@{}\" directive definition", - FEDERATION_OVERRIDE_DIRECTIVE_NAME_IN_SPEC + "Unexpectedly could not find federation spec's \"@{FEDERATION_OVERRIDE_DIRECTIVE_NAME_IN_SPEC}\" directive definition" )) }) } @@ -422,30 +569,473 @@ impl FederationSpecDefinition { }) } - pub(crate) fn get_cost_spec_definition( + pub(crate) fn context_directive_definition<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result<&'schema Node, FederationError> { + self.directive_definition(schema, &FEDERATION_CONTEXT_DIRECTIVE_NAME_IN_SPEC)? + .ok_or_else(|| { + FederationError::internal(format!( + "Unexpectedly could not find federation spec's \"@{FEDERATION_CONTEXT_DIRECTIVE_NAME_IN_SPEC}\" directive definition", + )) + }) + } + + pub(crate) fn context_directive( + &self, + schema: &FederationSchema, + name: String, + ) -> Result { + let name_in_schema = self + .directive_name_in_schema(schema, &FEDERATION_CONTEXT_DIRECTIVE_NAME_IN_SPEC)? + .ok_or_else(|| SingleFederationError::Internal { + message: "Unexpectedly could not find federation spec in schema".to_owned(), + })?; + + let arguments = vec![Node::new(Argument { + name: FEDERATION_NAME_ARGUMENT_NAME, + value: Node::new(Value::String(name)), + })]; + + Ok(Directive { + name: name_in_schema, + arguments, + }) + } + + pub(crate) fn context_directive_arguments<'doc>( + &self, + application: &'doc Node, + ) -> Result, FederationError> { + Ok(ContextDirectiveArguments { + name: directive_required_string_argument(application, &FEDERATION_NAME_ARGUMENT_NAME)?, + }) + } + + // The directive is named `@fromContext`. This is confusing for clippy, as + // `from` is a conventional prefix used in conversion methods, which do not + // take `self` as an argument. This function does **not** perform + // conversion, but extracts `@fromContext` directive definition. + #[allow(clippy::wrong_self_convention)] + pub(crate) fn from_context_directive_definition<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result<&'schema Node, FederationError> { + self.directive_definition(schema, &FEDERATION_FROM_CONTEXT_DIRECTIVE_NAME_IN_SPEC)? + .ok_or_else(|| { + FederationError::internal(format!( + "Unexpectedly could not find federation spec's \"@{FEDERATION_FROM_CONTEXT_DIRECTIVE_NAME_IN_SPEC}\" directive definition", + )) + }) + } + + // The directive is named `@fromContext`. This is confusing for clippy, as + // `from` is a conventional prefix used in conversion methods, which do not + // take `self` as an argument. This function does **not** perform + // conversion, but extracts `@fromContext` directive. + #[allow(clippy::wrong_self_convention)] + pub(crate) fn from_context_directive( &self, schema: &FederationSchema, - ) -> Option<&'static CostSpecDefinition> { - schema - .metadata() - .and_then(|metadata| metadata.for_identity(&Identity::cost_identity())) - .and_then(|link| COST_VERSIONS.find(&link.url.version)) - .or_else(|| COST_VERSIONS.find_for_federation_version(self.version())) + name: String, + ) -> Result { + let name_in_schema = self + .directive_name_in_schema(schema, &FEDERATION_FROM_CONTEXT_DIRECTIVE_NAME_IN_SPEC)? + .ok_or_else(|| SingleFederationError::Internal { + message: "Unexpectedly could not find federation spec in schema".to_owned(), + })?; + + let arguments = vec![Node::new(Argument { + name: FEDERATION_FIELD_ARGUMENT_NAME, + value: Node::new(Value::String(name)), + })]; + + Ok(Directive { + name: name_in_schema, + arguments, + }) + } + + // The directive is named `@fromContext`. This is confusing for clippy, as + // `from` is a conventional prefix used in conversion methods, which do not + // take `self` as an argument. This function does **not** perform + // conversion, but extracts `@fromContext` directive arguments. + #[allow(clippy::wrong_self_convention)] + pub(crate) fn from_context_directive_arguments<'doc>( + &self, + application: &'doc Node, + ) -> Result, FederationError> { + Ok(FromContextDirectiveArguments { + field: directive_required_string_argument( + application, + &FEDERATION_FIELD_ARGUMENT_NAME, + )?, + }) + } + + pub(crate) fn cache_tag_directive_definition<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result<&'schema Node, FederationError> { + self.directive_definition(schema, &FEDERATION_CACHE_TAG_DIRECTIVE_NAME_IN_SPEC)? + .ok_or_else(|| { + FederationError::internal(format!( + "Unexpectedly could not find federation spec's \"@{FEDERATION_CACHE_TAG_DIRECTIVE_NAME_IN_SPEC}\" directive definition", + )) + }) + } + + pub(crate) fn cache_tag_directive_arguments<'doc>( + &self, + application: &'doc Node, + ) -> Result, FederationError> { + Ok(CacheTagDirectiveArguments { + format: directive_required_string_argument( + application, + &FEDERATION_FORMAT_ARGUMENT_NAME, + )?, + }) + } + + fn key_directive_specification() -> DirectiveSpecification { + DirectiveSpecification::new( + FEDERATION_KEY_DIRECTIVE_NAME_IN_SPEC, + &[ + Self::fields_argument_specification(), + Self::resolvable_argument_specification(), + ], + true, + &[DirectiveLocation::Object, DirectiveLocation::Interface], + false, + None, + None, + ) + } + + fn fields_argument_specification() -> DirectiveArgumentSpecification { + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: FEDERATION_FIELDS_ARGUMENT_NAME, + get_type: |schema, _| field_set_type(schema), + default_value: None, + }, + composition_strategy: None, + } + } + + fn resolvable_argument_default_value() -> bool { + true + } + + fn resolvable_argument_specification() -> DirectiveArgumentSpecification { + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: FEDERATION_RESOLVABLE_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(Boolean)), + default_value: Some(Value::Boolean(Self::resolvable_argument_default_value())), + }, + composition_strategy: None, + } + } + + fn requires_directive_specification() -> DirectiveSpecification { + DirectiveSpecification::new( + FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC, + &[Self::fields_argument_specification()], + false, + &[DirectiveLocation::FieldDefinition], + false, + None, + None, + ) + } + + fn provides_directive_specification() -> DirectiveSpecification { + DirectiveSpecification::new( + FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC, + &[Self::fields_argument_specification()], + false, + &[DirectiveLocation::FieldDefinition], + false, + None, + None, + ) + } + + fn external_directive_specification() -> DirectiveSpecification { + DirectiveSpecification::new( + FEDERATION_EXTERNAL_DIRECTIVE_NAME_IN_SPEC, + &[DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: FEDERATION_REASON_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String)), + default_value: None, + }, + composition_strategy: None, + }], + false, + &[ + DirectiveLocation::Object, + DirectiveLocation::FieldDefinition, + ], + false, + None, + None, + ) + } + + fn extends_directive_specification() -> DirectiveSpecification { + DirectiveSpecification::new( + FEDERATION_EXTENDS_DIRECTIVE_NAME_IN_SPEC, + &[], + false, + &[DirectiveLocation::Object, DirectiveLocation::Interface], + false, + None, + None, + ) + } + + fn shareable_directive_specification(&self) -> DirectiveSpecification { + DirectiveSpecification::new( + FEDERATION_SHAREABLE_DIRECTIVE_NAME_IN_SPEC, + &[], + self.version().ge(&Version { major: 2, minor: 2 }), + &[ + DirectiveLocation::Object, + DirectiveLocation::FieldDefinition, + ], + false, + None, + None, + ) + } + + fn override_directive_specification(&self) -> DirectiveSpecification { + let mut args = vec![DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: FEDERATION_FROM_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String!)), + default_value: None, + }, + composition_strategy: None, + }]; + if self.version().satisfies(&Version { major: 2, minor: 7 }) { + args.push(DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: FEDERATION_OVERRIDE_LABEL_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String)), + default_value: None, + }, + composition_strategy: None, + }); + } + DirectiveSpecification::new( + FEDERATION_OVERRIDE_DIRECTIVE_NAME_IN_SPEC, + &args, + false, + &[DirectiveLocation::FieldDefinition], + false, + None, + None, + ) + } + + // NOTE: due to the long-standing subgraph-js bug we'll continue to define name argument + // as nullable and rely on validations to ensure that value is set. + fn compose_directive_directive_specification() -> DirectiveSpecification { + DirectiveSpecification::new( + FEDERATION_COMPOSEDIRECTIVE_DIRECTIVE_NAME_IN_SPEC, + &[DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: FEDERATION_NAME_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String)), + default_value: None, + }, + composition_strategy: None, + }], + true, + &[DirectiveLocation::Schema], + false, + None, + None, + ) + } + + fn interface_object_directive_directive_specification() -> DirectiveSpecification { + DirectiveSpecification::new( + FEDERATION_INTERFACEOBJECT_DIRECTIVE_NAME_IN_SPEC, + &[], + false, + &[DirectiveLocation::Object], + false, + None, + None, + ) + } + + fn cache_tag_directive_specification() -> DirectiveSpecification { + DirectiveSpecification::new( + FEDERATION_CACHE_TAG_DIRECTIVE_NAME_IN_SPEC, + &[DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: FEDERATION_FORMAT_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String!)), + default_value: None, + }, + composition_strategy: None, + }], + true, + &[ + DirectiveLocation::Object, + DirectiveLocation::Interface, + DirectiveLocation::FieldDefinition, + ], + false, + None, + None, + ) } } +fn field_set_type(schema: &FederationSchema) -> Result { + // PORT_NOTE: `schema.subgraph_metadata` is not accessible, since it's not validated, yet. + // PORT_NOTE: No counterpart for metadata.fieldSetType. Use FederationSchema::field_set_type. + schema + .field_set_type() + .map(|pos| Type::non_null(Type::Named(pos.type_name))) +} + impl SpecDefinition for FederationSpecDefinition { fn url(&self) -> &Url { &self.url } - fn minimum_federation_version(&self) -> Option<&Version> { + fn directive_specs(&self) -> Vec> { + let mut specs: Vec> = vec![ + Box::new(Self::key_directive_specification()), + Box::new(Self::requires_directive_specification()), + Box::new(Self::provides_directive_specification()), + Box::new(Self::external_directive_specification()), + ]; + // Federation 2.3+ use tag spec v0.3, otherwise use v0.2 + if self.version().satisfies(&Version { major: 2, minor: 3 }) { + if let Some(tag_spec) = TAG_VERSIONS.find(&Version { major: 0, minor: 3 }) { + specs.extend(tag_spec.directive_specs()); + } + } else if let Some(tag_spec) = TAG_VERSIONS.find(&Version { major: 0, minor: 2 }) { + specs.extend(tag_spec.directive_specs()); + } + specs.push(Box::new(Self::extends_directive_specification())); + + if self.is_fed1() { + // PORT_NOTE: Fed 1 has `@key`, `@requires`, `@provides`, `@external`, `@tag` (v0.2) and `@extends`. + // The specs we return at this point correspond to `legacyFederationDirectives` in JS. + return specs; + } + + specs.push(Box::new(self.shareable_directive_specification())); + + if let Some(inaccessible_spec) = + INACCESSIBLE_VERSIONS.get_dyn_minimum_required_version(self.version()) + { + specs.extend(inaccessible_spec.directive_specs()); + } + + specs.push(Box::new(self.override_directive_specification())); + + if self.version().satisfies(&Version { major: 2, minor: 1 }) { + specs.push(Box::new(Self::compose_directive_directive_specification())); + } + + if self.version().satisfies(&Version { major: 2, minor: 3 }) { + specs.push(Box::new( + Self::interface_object_directive_directive_specification(), + )); + } + + if self.version().satisfies(&Version { major: 2, minor: 5 }) { + if let Some(auth_spec) = AUTHENTICATED_VERSIONS.find(&Version { major: 0, minor: 1 }) { + specs.extend(auth_spec.directive_specs()); + } + if let Some(requires_scopes_spec) = + REQUIRES_SCOPES_VERSIONS.find(&Version { major: 0, minor: 1 }) + { + specs.extend(requires_scopes_spec.directive_specs()); + } + } + + if self.version().satisfies(&Version { major: 2, minor: 6 }) + && let Some(policy_spec) = POLICY_VERSIONS.find(&Version { major: 0, minor: 1 }) + { + specs.extend(policy_spec.directive_specs()); + } + + if self.version().satisfies(&Version { major: 2, minor: 8 }) { + let context_spec_definitions = + ContextSpecDefinition::new(self.version().clone(), Version { major: 2, minor: 8 }) + .directive_specs(); + specs.extend(context_spec_definitions); + } + + if self.version().satisfies(&Version { major: 2, minor: 9 }) + && let Some(cost_spec) = COST_VERSIONS.find(&Version { major: 0, minor: 1 }) + { + specs.extend(cost_spec.directive_specs()); + } + + if self.version().satisfies(&Version { + major: 2, + minor: 12, + }) { + specs.push(Box::new(Self::cache_tag_directive_specification())); + } + + specs + } + + fn type_specs(&self) -> Vec> { + let mut type_specs: Vec> = + vec![Box::new(ScalarTypeSpecification { + name: FEDERATION_FIELDSET_TYPE_NAME_IN_SPEC, + })]; + + if self.version().satisfies(&Version { major: 2, minor: 5 }) + && let Some(requires_scopes_spec) = + REQUIRES_SCOPES_VERSIONS.find(&Version { major: 0, minor: 1 }) + { + type_specs.extend(requires_scopes_spec.type_specs()); + } + + if self.version().satisfies(&Version { major: 2, minor: 6 }) + && let Some(policy_spec) = POLICY_VERSIONS.find(&Version { major: 0, minor: 1 }) + { + type_specs.extend(policy_spec.type_specs()); + } + + if self.version().satisfies(&Version { major: 2, minor: 8 }) { + type_specs.extend( + ContextSpecDefinition::new(self.version().clone(), Version { major: 2, minor: 8 }) + .type_specs(), + ); + } + type_specs + } + + fn minimum_federation_version(&self) -> &Version { + &self.url.version + } + + fn purpose(&self) -> Option { None } } -lazy_static! { - pub(crate) static ref FEDERATION_VERSIONS: SpecDefinitions = { +pub(crate) static FED_1: LazyLock = + LazyLock::new(|| FederationSpecDefinition::new(Version { major: 1, minor: 0 })); + +pub(crate) static FEDERATION_VERSIONS: LazyLock> = + LazyLock::new(|| { let mut definitions = SpecDefinitions::new(Identity::federation_identity()); definitions.add(FederationSpecDefinition::new(Version { major: 2, @@ -487,24 +1077,68 @@ lazy_static! { major: 2, minor: 9, })); + definitions.add(FederationSpecDefinition::new(Version { + major: 2, + minor: 10, + })); + definitions.add(FederationSpecDefinition::new(Version { + major: 2, + minor: 11, + })); + definitions.add(FederationSpecDefinition::new(Version { + major: 2, + minor: 12, + })); definitions - }; -} + }); pub(crate) fn get_federation_spec_definition_from_subgraph( schema: &FederationSchema, ) -> Result<&'static FederationSpecDefinition, FederationError> { - let federation_link = schema + if let Some(federation_link) = schema .metadata() .as_ref() .and_then(|metadata| metadata.for_identity(&Identity::federation_identity())) - .ok_or_else(|| SingleFederationError::Internal { - message: "Subgraph unexpectedly does not use federation spec".to_owned(), - })?; - Ok(FEDERATION_VERSIONS - .find(&federation_link.url.version) - .ok_or_else(|| SingleFederationError::Internal { - message: "Subgraph unexpectedly does not use a supported federation spec version" - .to_owned(), - })?) + { + if FED_1.url.version == federation_link.url.version { + return Ok(&FED_1); + } + FEDERATION_VERSIONS + .find(&federation_link.url.version) + .ok_or_else(|| internal_error!( + "Subgraph unexpectedly does not use a supported federation spec version. Requested version: {}", + federation_link.url.version, + )) + } else { + // No federation link found in schema. The default is v1.0. + Ok(&FED_1) + } +} + +/// Creates a fake imports for fed 1 link directive. +/// - Fed 1 does not support `import` argument, but we use it to simulate fed 1 behavior. +// PORT_NOTE: From `FAKE_FED1_CORE_FEATURE_TO_RENAME_TYPES` in JS +// Federation 1 has that specificity that it wasn't using @link to name-space federation elements, +// and so to "distinguish" the few federation type names, it prefixed those with a `_`. That is, +// the `FieldSet` type was named `_FieldSet` in federation1. To handle this without too much effort, +// we use a fake `Link` with imports for all the fed1 types to use those specific "aliases" +// and we pass it when adding those types. This allows to reuse the same `TypeSpecification` objects +// for both fed1 and fed2. +pub(crate) fn fed1_link_imports() -> Vec> { + let type_specs = FED_1.type_specs(); + let directive_specs = FED_1.directive_specs(); + let type_imports = type_specs.iter().map(|spec| link::Import { + element: spec.name().clone(), + is_directive: false, + alias: Some(Name::new_unchecked(&format!("_{}", spec.name()))), + }); + let directive_imports = directive_specs.iter().map(|spec| link::Import { + element: spec.name().clone(), + is_directive: true, + alias: None, + }); + type_imports + .chain(directive_imports) + .map(Arc::new) + .collect() } diff --git a/apollo-federation/src/link/graphql_definition.rs b/apollo-federation/src/link/graphql_definition.rs index bb2b54f2d1..7966417c87 100644 --- a/apollo-federation/src/link/graphql_definition.rs +++ b/apollo-federation/src/link/graphql_definition.rs @@ -1,10 +1,10 @@ use std::fmt::Display; +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::ast::Value; use apollo_compiler::executable::Directive; use apollo_compiler::name; -use apollo_compiler::Name; -use apollo_compiler::Node; use serde::Serialize; use crate::error::FederationError; diff --git a/apollo-federation/src/link/inaccessible_spec_definition.rs b/apollo-federation/src/link/inaccessible_spec_definition.rs index fe72ba8c57..f6c62e44d2 100644 --- a/apollo-federation/src/link/inaccessible_spec_definition.rs +++ b/apollo-federation/src/link/inaccessible_spec_definition.rs @@ -1,5 +1,8 @@ use std::fmt; +use std::sync::LazyLock; +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use apollo_compiler::name; @@ -11,18 +14,17 @@ use apollo_compiler::schema::ExtendedType; use apollo_compiler::schema::FieldDefinition; use apollo_compiler::schema::InputValueDefinition; use apollo_compiler::schema::Value; -use apollo_compiler::Name; -use apollo_compiler::Node; -use lazy_static::lazy_static; use crate::error::FederationError; use crate::error::MultipleFederationErrors; use crate::error::SingleFederationError; +use crate::link::Purpose; use crate::link::spec::Identity; use crate::link::spec::Url; use crate::link::spec::Version; use crate::link::spec_definition::SpecDefinition; use crate::link::spec_definition::SpecDefinitions; +use crate::schema::FederationSchema; use crate::schema::position; use crate::schema::position::DirectiveDefinitionPosition; use crate::schema::position::EnumValueDefinitionPosition; @@ -33,17 +35,18 @@ use crate::schema::position::ObjectFieldArgumentDefinitionPosition; use crate::schema::position::ObjectFieldDefinitionPosition; use crate::schema::position::SchemaRootDefinitionKind; use crate::schema::position::TypeDefinitionPosition; -use crate::schema::FederationSchema; +use crate::schema::type_and_directive_specification::DirectiveSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; pub(crate) const INACCESSIBLE_DIRECTIVE_NAME_IN_SPEC: Name = name!("inaccessible"); pub(crate) struct InaccessibleSpecDefinition { url: Url, - minimum_federation_version: Option, + minimum_federation_version: Version, } impl InaccessibleSpecDefinition { - pub(crate) fn new(version: Version, minimum_federation_version: Option) -> Self { + pub(crate) fn new(version: Version, minimum_federation_version: Version) -> Self { Self { url: Url { identity: Identity::inaccessible_identity(), @@ -91,6 +94,41 @@ impl InaccessibleSpecDefinition { ) -> Result<(), FederationError> { remove_inaccessible_elements(schema, self) } + + fn directive_specification(&self) -> Box { + let locations: &[DirectiveLocation] = + if self.url.version == (Version { major: 0, minor: 1 }) { + &[ + DirectiveLocation::FieldDefinition, + DirectiveLocation::Object, + DirectiveLocation::Interface, + DirectiveLocation::Union, + ] + } else { + &[ + DirectiveLocation::FieldDefinition, + DirectiveLocation::Object, + DirectiveLocation::Interface, + DirectiveLocation::Union, + DirectiveLocation::ArgumentDefinition, + DirectiveLocation::Scalar, + DirectiveLocation::Enum, + DirectiveLocation::EnumValue, + DirectiveLocation::InputObject, + DirectiveLocation::InputFieldDefinition, + ] + }; + + Box::new(DirectiveSpecification::new( + INACCESSIBLE_DIRECTIVE_NAME_IN_SPEC, + &[], + false, // not repeatable + locations, + true, // composes + Some(&|v| INACCESSIBLE_VERSIONS.get_dyn_minimum_required_version(v)), + None, + )) + } } impl SpecDefinition for InaccessibleSpecDefinition { @@ -98,25 +136,37 @@ impl SpecDefinition for InaccessibleSpecDefinition { &self.url } - fn minimum_federation_version(&self) -> Option<&Version> { - self.minimum_federation_version.as_ref() + fn directive_specs(&self) -> Vec> { + vec![self.directive_specification()] + } + + fn type_specs(&self) -> Vec> { + // No type specs for @inaccessible + vec![] + } + + fn minimum_federation_version(&self) -> &Version { + &self.minimum_federation_version + } + + fn purpose(&self) -> Option { + Some(Purpose::SECURITY) } } -lazy_static! { - pub(crate) static ref INACCESSIBLE_VERSIONS: SpecDefinitions = { +pub(crate) static INACCESSIBLE_VERSIONS: LazyLock> = + LazyLock::new(|| { let mut definitions = SpecDefinitions::new(Identity::inaccessible_identity()); definitions.add(InaccessibleSpecDefinition::new( Version { major: 0, minor: 1 }, - None, + Version { major: 1, minor: 0 }, )); definitions.add(InaccessibleSpecDefinition::new( Version { major: 0, minor: 2 }, - Some(Version { major: 2, minor: 0 }), + Version { major: 2, minor: 0 }, )); definitions - }; -} + }); fn is_type_system_location(location: DirectiveLocation) -> bool { matches!( @@ -328,19 +378,18 @@ fn validate_inaccessible_in_arguments( }.into()); } - if !arg_inaccessible { - if let (Some(default_value), Some(arg_type)) = + if !arg_inaccessible + && let (Some(default_value), Some(arg_type)) = (&arg.default_value, types.get(arg.ty.inner_named_type())) - { - validate_inaccessible_in_default_value( - schema, - inaccessible_directive, - arg_type, - default_value, - format!("{usage_position}({arg_name}:)"), - errors, - )?; - } + { + validate_inaccessible_in_default_value( + schema, + inaccessible_directive, + arg_type, + default_value, + format!("{usage_position}({arg_name}:)"), + errors, + )?; } } Ok(()) @@ -424,8 +473,8 @@ fn validate_inaccessible_in_fields( }.into()); } } else if arg.is_required() { - // When an argument is accessible and required, we check that - // it isn't marked inaccessible in any interface implemented by + // When an argument is accessible and required, we check that it + // isn't marked inaccessible in any interface implemented by // the argument's field. This is because the GraphQL spec // requires that any arguments of an implementing field that // aren't in its implemented field are optional. @@ -502,7 +551,7 @@ fn validate_inaccessible_in_fields( } /// Generic way to check for @inaccessible directives on a position or its parents. -trait IsInaccessibleExt { +pub(crate) trait IsInaccessibleExt { /// Does this element, or any of its parents, have an @inaccessible directive? /// /// May return Err if `self` is an element that does not exist in the schema. @@ -896,20 +945,20 @@ fn validate_inaccessible( }.into()); } - if !field_inaccessible { - if let (Some(default_value), Some(field_type)) = ( + if !field_inaccessible + && let (Some(default_value), Some(field_type)) = ( &field.default_value, schema.schema().types.get(field.ty.inner_named_type()), - ) { - validate_inaccessible_in_default_value( - schema, - &inaccessible_directive, - field_type, - default_value, - input_object_position.field(field.name.clone()).to_string(), - &mut errors, - )?; - } + ) + { + validate_inaccessible_in_default_value( + schema, + &inaccessible_directive, + field_type, + default_value, + input_object_position.field(field.name.clone()).to_string(), + &mut errors, + )?; } } @@ -987,7 +1036,7 @@ fn validate_inaccessible( .collect::>() .join(", "); errors.push(SingleFederationError::DisallowedInaccessible { - message: format!("Directive `{position}` cannot use @inaccessible because it may be applied to these type-system locations: {}", type_system_locations), + message: format!("Directive `{position}` cannot use @inaccessible because it may be applied to these type-system locations: {type_system_locations}"), }.into()); } } else { diff --git a/apollo-federation/src/link/join_spec_definition.rs b/apollo-federation/src/link/join_spec_definition.rs index 1328cbdcb7..88d82b89cc 100644 --- a/apollo-federation/src/link/join_spec_definition.rs +++ b/apollo-federation/src/link/join_spec_definition.rs @@ -1,14 +1,28 @@ +use std::collections::HashMap; +use std::fmt::Display; +use std::sync::LazyLock; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Argument; +use apollo_compiler::ast::DirectiveLocation; +use apollo_compiler::ast::Type; +use apollo_compiler::ast::Value; use apollo_compiler::name; +use apollo_compiler::schema::Component; use apollo_compiler::schema::Directive; use apollo_compiler::schema::DirectiveDefinition; use apollo_compiler::schema::EnumType; +use apollo_compiler::schema::EnumValueDefinition; use apollo_compiler::schema::ExtendedType; -use apollo_compiler::Name; -use apollo_compiler::Node; -use lazy_static::lazy_static; +use apollo_compiler::ty; +use itertools::Itertools; +use super::argument::directive_optional_list_argument; +use crate::bail; use crate::error::FederationError; use crate::error::SingleFederationError; +use crate::link::Purpose; use crate::link::argument::directive_optional_boolean_argument; use crate::link::argument::directive_optional_enum_argument; use crate::link::argument::directive_optional_string_argument; @@ -20,6 +34,16 @@ use crate::link::spec::Version; use crate::link::spec_definition::SpecDefinition; use crate::link::spec_definition::SpecDefinitions; use crate::schema::FederationSchema; +use crate::schema::position::EnumValueDefinitionPosition; +use crate::schema::type_and_directive_specification::ArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveSpecification; +use crate::schema::type_and_directive_specification::EnumTypeSpecification; +use crate::schema::type_and_directive_specification::InputObjectTypeSpecification; +use crate::schema::type_and_directive_specification::ScalarTypeSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; +use crate::subgraph::typestate::Subgraph; +use crate::subgraph::typestate::Validated; pub(crate) const JOIN_GRAPH_ENUM_NAME_IN_SPEC: Name = name!("Graph"); pub(crate) const JOIN_GRAPH_DIRECTIVE_NAME_IN_SPEC: Name = name!("graph"); @@ -28,6 +52,11 @@ pub(crate) const JOIN_FIELD_DIRECTIVE_NAME_IN_SPEC: Name = name!("field"); pub(crate) const JOIN_IMPLEMENTS_DIRECTIVE_NAME_IN_SPEC: Name = name!("implements"); pub(crate) const JOIN_UNIONMEMBER_DIRECTIVE_NAME_IN_SPEC: Name = name!("unionMember"); pub(crate) const JOIN_ENUMVALUE_DIRECTIVE_NAME_IN_SPEC: Name = name!("enumValue"); +pub(crate) const JOIN_DIRECTIVE_DIRECTIVE_NAME_IN_SPEC: Name = name!("directive"); + +pub(crate) const JOIN_FIELD_SET_NAME_IN_SPEC: Name = name!("FieldSet"); +pub(crate) const JOIN_DIRECTIVE_ARGUMENTS_NAME_IN_SPEC: Name = name!("DirectiveArguments"); +pub(crate) const JOIN_CONTEXT_ARGUMENT_NAME_IN_SPEC: Name = name!("ContextArgument"); pub(crate) const JOIN_NAME_ARGUMENT_NAME: Name = name!("name"); pub(crate) const JOIN_URL_ARGUMENT_NAME: Name = name!("url"); @@ -45,6 +74,9 @@ pub(crate) const JOIN_OVERRIDE_LABEL_ARGUMENT_NAME: Name = name!("overrideLabel" pub(crate) const JOIN_USEROVERRIDDEN_ARGUMENT_NAME: Name = name!("usedOverridden"); pub(crate) const JOIN_INTERFACE_ARGUMENT_NAME: Name = name!("interface"); pub(crate) const JOIN_MEMBER_ARGUMENT_NAME: Name = name!("member"); +pub(crate) const JOIN_CONTEXTARGUMENTS_ARGUMENT_NAME: Name = name!("contextArguments"); +pub(crate) const JOIN_DIRECTIVE_ARGS_ARGUMENT_NAME: Name = name!("args"); +pub(crate) const JOIN_DIRECTIVE_GRAPHS_ARGUMENT_NAME: Name = name!("graphs"); pub(crate) struct GraphDirectiveArguments<'doc> { pub(crate) name: &'doc str, @@ -59,6 +91,80 @@ pub(crate) struct TypeDirectiveArguments<'doc> { pub(crate) is_interface_object: bool, } +pub(crate) struct ContextArgument<'doc> { + pub(crate) name: &'doc str, + pub(crate) type_: &'doc str, + pub(crate) context: &'doc str, + pub(crate) selection: &'doc str, +} + +impl<'doc> TryFrom<&'doc Value> for ContextArgument<'doc> { + type Error = FederationError; + + fn try_from(value: &'doc Value) -> Result { + fn insert_value<'a>( + name: &str, + field: &mut Option<&'a Value>, + value: &'a Value, + ) -> Result<(), FederationError> { + if let Some(first_value) = field { + bail!( + r#"Input field "{name}" in contextArguments is repeated with value "{value}" (previous value was "{first_value}")"# + ) + } + let _ = field.insert(value); + Ok(()) + } + + fn field_or_else<'a>( + field_name: &'static str, + field: Option<&'a Value>, + ) -> Result<&'a str, FederationError> { + field + .ok_or_else(|| { + FederationError::internal(format!( + r#"Input field "{field_name}" is missing from contextArguments"# + )) + })? + .as_str() + .ok_or_else(|| { + FederationError::internal(format!( + r#"Input field "{field_name}" in contextArguments is not a string"# + )) + }) + } + + let Value::Object(input_object) = value else { + bail!(r#"Item "{value}" in contextArguments list is not an object"#) + }; + let mut name = None; + let mut type_ = None; + let mut context = None; + let mut selection = None; + for (input_field_name, value) in input_object { + match input_field_name.as_str() { + "name" => insert_value(input_field_name, &mut name, value)?, + "type" => insert_value(input_field_name, &mut type_, value)?, + "context" => insert_value(input_field_name, &mut context, value)?, + "selection" => insert_value(input_field_name, &mut selection, value)?, + _ => bail!(r#"Found unknown contextArguments input field "{input_field_name}""#), + } + } + + let name = field_or_else("name", name)?; + let type_ = field_or_else("type", type_)?; + let context = field_or_else("context", context)?; + let selection = field_or_else("selection", selection)?; + + Ok(Self { + name, + type_, + context, + selection, + }) + } +} + pub(crate) struct FieldDirectiveArguments<'doc> { pub(crate) graph: Option, pub(crate) requires: Option<&'doc str>, @@ -68,6 +174,7 @@ pub(crate) struct FieldDirectiveArguments<'doc> { pub(crate) override_: Option<&'doc str>, pub(crate) override_label: Option<&'doc str>, pub(crate) user_overridden: Option, + pub(crate) context_arguments: Option>>, } pub(crate) struct ImplementsDirectiveArguments<'doc> { @@ -87,11 +194,40 @@ pub(crate) struct EnumValueDirectiveArguments { #[derive(Clone)] pub(crate) struct JoinSpecDefinition { url: Url, - minimum_federation_version: Option, + minimum_federation_version: Version, +} + +/// Sanitize a subgraph name to be a valid GraphQL enum value +/// Based on sanitizeGraphQLName from joinSpec.ts +fn sanitize_graphql_name(name: &str) -> String { + let mut result = String::new(); + for (i, ch) in name.chars().enumerate() { + if i == 0 && ch.is_ascii_digit() { + result.push('_'); + } + if ch.is_alphanumeric() || ch == '_' { + result.push(ch.to_ascii_uppercase()); + } else { + result.push('_'); + } + } + + if !result.is_empty() { + let chars: Vec = result.chars().collect(); + let mut i = chars.len() - 1; + while i > 0 && chars[i].is_ascii_digit() { + i -= 1; + } + if i < chars.len() - 1 && chars[i] == '_' { + result.push('_'); + } + } + + result } impl JoinSpecDefinition { - pub(crate) fn new(version: Version, minimum_federation_version: Option) -> Self { + pub(crate) fn new(version: Version, minimum_federation_version: Version) -> Self { Self { url: Url { identity: Identity::join_identity(), @@ -110,13 +246,12 @@ impl JoinSpecDefinition { .ok_or_else(|| SingleFederationError::Internal { message: "Unexpectedly could not find join spec in schema".to_owned(), })?; - if let ExtendedType::Enum(ref type_) = type_ { + if let ExtendedType::Enum(type_) = type_ { Ok(type_) } else { Err(SingleFederationError::Internal { message: format!( - "Unexpectedly found non-enum for join spec's \"{}\" enum definition", - JOIN_GRAPH_ENUM_NAME_IN_SPEC, + "Unexpectedly found non-enum for join spec's \"{JOIN_GRAPH_ENUM_NAME_IN_SPEC}\" enum definition", ), } .into()) @@ -228,6 +363,17 @@ impl JoinSpecDefinition { application, &JOIN_USEROVERRIDDEN_ARGUMENT_NAME, )?, + context_arguments: directive_optional_list_argument( + application, + &JOIN_CONTEXTARGUMENTS_ARGUMENT_NAME, + )? + .map(|values| { + values + .iter() + .map(|value| ContextArgument::try_from(value.as_ref())) + .try_collect() + }) + .transpose()?, }) } @@ -288,6 +434,34 @@ impl JoinSpecDefinition { }) } + pub(crate) fn union_member_directive( + &self, + schema: &FederationSchema, + subgraph_name: &Name, + member_name: &str, + ) -> Result { + let Ok(Some(name_in_schema)) = + self.directive_name_in_schema(schema, &JOIN_UNIONMEMBER_DIRECTIVE_NAME_IN_SPEC) + else { + bail!("Unexpectedly could not find unionMember directive in schema"); + }; + Ok(Directive { + name: name_in_schema, + arguments: vec![ + Node::new(Argument { + name: JOIN_GRAPH_DIRECTIVE_NAME_IN_SPEC, + value: Node::new(Value::Enum(subgraph_name.clone())), + }), + { + Node::new(Argument { + name: JOIN_MEMBER_ARGUMENT_NAME, + value: Node::new(Value::String(member_name.to_owned())), + }) + }, + ], + }) + } + pub(crate) fn enum_value_directive_definition<'schema>( &self, schema: &'schema FederationSchema, @@ -313,6 +487,652 @@ impl JoinSpecDefinition { graph: directive_required_enum_argument(application, &JOIN_GRAPH_ARGUMENT_NAME)?, }) } + + pub(crate) fn enum_value_directive( + &self, + schema: &FederationSchema, + subgraph_name: &Name, + ) -> Result { + let name_in_schema = self + .directive_name_in_schema(schema, &JOIN_ENUMVALUE_DIRECTIVE_NAME_IN_SPEC)? + .ok_or_else(|| SingleFederationError::Internal { + message: "Unexpectedly could not find enumValue directive in schema".to_owned(), + })?; + Ok(Directive { + name: name_in_schema, + arguments: vec![Node::new(Argument { + name: JOIN_GRAPH_DIRECTIVE_NAME_IN_SPEC, + value: Node::new(Value::Enum(subgraph_name.clone())), + })], + }) + } + + /// @join__graph + fn graph_directive_specification(&self) -> DirectiveSpecification { + DirectiveSpecification::new( + JOIN_GRAPH_DIRECTIVE_NAME_IN_SPEC, + &[ + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_NAME_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String!)), + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_URL_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String!)), + default_value: None, + }, + composition_strategy: None, + }, + ], + false, + &[DirectiveLocation::EnumValue], + false, + Some(&|v| JOIN_VERSIONS.get_dyn_minimum_required_version(v)), + None, + ) + } + + /// @join__type + fn type_directive_specification(&self) -> DirectiveSpecification { + let mut args = vec![ + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_GRAPH_ARGUMENT_NAME, + get_type: |_schema, link| { + let graph_name = link.map_or(JOIN_GRAPH_ENUM_NAME_IN_SPEC, |link| { + link.type_name_in_schema(&JOIN_GRAPH_ENUM_NAME_IN_SPEC) + }); + Ok(Type::NonNullNamed(graph_name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_KEY_ARGUMENT_NAME, + get_type: |_schema, link| { + let field_set_name = link.map_or(JOIN_FIELD_SET_NAME_IN_SPEC, |link| { + link.type_name_in_schema(&JOIN_FIELD_SET_NAME_IN_SPEC) + }); + Ok(Type::Named(field_set_name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + ]; + if *self.version() >= (Version { major: 0, minor: 2 }) { + args.push(DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_EXTENSION_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(Boolean!)), + default_value: Some(Value::Boolean(false)), + }, + composition_strategy: None, + }); + args.push(DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_RESOLVABLE_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(Boolean!)), + default_value: Some(Value::Boolean(true)), + }, + composition_strategy: None, + }); + } + if *self.version() >= (Version { major: 0, minor: 3 }) { + args.push(DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_ISINTERFACEOBJECT_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(Boolean!)), + default_value: Some(Value::Boolean(false)), + }, + composition_strategy: None, + }); + } + + DirectiveSpecification::new( + JOIN_TYPE_DIRECTIVE_NAME_IN_SPEC, + &args, + *self.version() >= (Version { major: 0, minor: 2 }), + &[ + DirectiveLocation::Object, + DirectiveLocation::Interface, + DirectiveLocation::Union, + DirectiveLocation::Enum, + DirectiveLocation::InputObject, + DirectiveLocation::Scalar, + ], + false, + Some(&|v| JOIN_VERSIONS.get_dyn_minimum_required_version(v)), + None, + ) + } + + pub(crate) fn type_directive( + &self, + graph: Name, + key_fields: Option>, + extension: Option, + resolvable: Option, + is_interface_object: Option, + ) -> Directive { + let mut args = vec![Node::new(Argument { + name: JOIN_GRAPH_ARGUMENT_NAME, + value: Node::new(Value::Enum(graph)), + })]; + if let Some(key_fields) = key_fields { + args.push(Node::new(Argument { + name: JOIN_KEY_ARGUMENT_NAME, + value: key_fields, + })); + } + + if *self.version() >= (Version { major: 0, minor: 2 }) { + if let Some(extension) = extension { + args.push(Node::new(Argument { + name: JOIN_EXTENSION_ARGUMENT_NAME, + value: Node::new(Value::Boolean(extension)), + })); + } + if let Some(resolvable) = resolvable { + args.push(Node::new(Argument { + name: JOIN_RESOLVABLE_ARGUMENT_NAME, + value: Node::new(Value::Boolean(resolvable)), + })); + } + } + + if *self.version() >= (Version { major: 0, minor: 3 }) + && let Some(is_interface_object) = is_interface_object + { + args.push(Node::new(Argument { + name: JOIN_ISINTERFACEOBJECT_ARGUMENT_NAME, + value: Node::new(Value::Boolean(is_interface_object)), + })); + } + + Directive { + name: JOIN_TYPE_DIRECTIVE_NAME_IN_SPEC, + arguments: args, + } + } + + /// @join__field + fn field_directive_specification(&self) -> DirectiveSpecification { + let mut args = vec![ + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_REQUIRES_ARGUMENT_NAME, + get_type: |_schema, link| { + let field_set_name = link.map_or(JOIN_FIELD_SET_NAME_IN_SPEC, |link| { + link.type_name_in_schema(&JOIN_FIELD_SET_NAME_IN_SPEC) + }); + Ok(Type::Named(field_set_name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_PROVIDES_ARGUMENT_NAME, + get_type: |_schema, link| { + let field_set_name = link.map_or(JOIN_FIELD_SET_NAME_IN_SPEC, |link| { + link.type_name_in_schema(&JOIN_FIELD_SET_NAME_IN_SPEC) + }); + Ok(Type::Named(field_set_name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + ]; + // The `graph` argument used to be non-nullable, but @interfaceObject makes us add some field in + // the supergraph that don't "directly" come from any subgraph (they indirectly are inherited from + // an `@interfaceObject` type), and to indicate that, we use a `@join__field(graph: null)` annotation. + if *self.version() >= (Version { major: 0, minor: 3 }) { + args.insert( + 0, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_GRAPH_ARGUMENT_NAME, + get_type: |_schema, link| { + let graph_name = link.map_or(JOIN_GRAPH_ENUM_NAME_IN_SPEC, |link| { + link.type_name_in_schema(&JOIN_GRAPH_ENUM_NAME_IN_SPEC) + }); + Ok(Type::Named(graph_name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + ); + } else { + args.insert( + 0, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_GRAPH_ARGUMENT_NAME, + get_type: |_schema, link| { + let graph_name = link.map_or(JOIN_GRAPH_ENUM_NAME_IN_SPEC, |link| { + link.type_name_in_schema(&JOIN_GRAPH_ENUM_NAME_IN_SPEC) + }); + Ok(Type::NonNullNamed(graph_name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + ); + } + + if *self.version() >= (Version { major: 0, minor: 2 }) { + args.push(DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_TYPE_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String)), + default_value: None, + }, + composition_strategy: None, + }); + args.push(DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_EXTERNAL_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(Boolean)), + default_value: None, + }, + composition_strategy: None, + }); + args.push(DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_OVERRIDE_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String)), + default_value: None, + }, + composition_strategy: None, + }); + args.push(DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_USEROVERRIDDEN_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(Boolean)), + default_value: None, + }, + composition_strategy: None, + }); + } + if *self.version() >= (Version { major: 0, minor: 4 }) { + args.push(DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_OVERRIDE_LABEL_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String)), + default_value: None, + }, + composition_strategy: None, + }); + } + if *self.version() >= (Version { major: 0, minor: 5 }) { + args.push(DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_CONTEXTARGUMENTS_ARGUMENT_NAME, + get_type: |_schema, link| { + let context_arg_name = link + .map_or(JOIN_CONTEXT_ARGUMENT_NAME_IN_SPEC, |link| { + link.type_name_in_schema(&JOIN_CONTEXT_ARGUMENT_NAME_IN_SPEC) + }); + Ok(Type::List(Box::new(Type::NonNullNamed(context_arg_name)))) + }, + default_value: None, + }, + composition_strategy: None, + }); + } + + DirectiveSpecification::new( + JOIN_FIELD_DIRECTIVE_NAME_IN_SPEC, + &args, + true, // repeatable + &[ + DirectiveLocation::FieldDefinition, + DirectiveLocation::InputFieldDefinition, + ], + false, // doesn't compose + Some(&|v| JOIN_VERSIONS.get_dyn_minimum_required_version(v)), + None, + ) + } + + /// @join__implements + fn implements_directive_spec(&self) -> Option { + if *self.version() < (Version { major: 0, minor: 2 }) { + return None; + } + Some(DirectiveSpecification::new( + JOIN_IMPLEMENTS_DIRECTIVE_NAME_IN_SPEC, + &[ + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_GRAPH_ARGUMENT_NAME, + get_type: |_schema, link| { + let graph_name = link.map_or(JOIN_GRAPH_ENUM_NAME_IN_SPEC, |link| { + link.type_name_in_schema(&JOIN_GRAPH_ENUM_NAME_IN_SPEC) + }); + Ok(Type::NonNullNamed(graph_name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_INTERFACE_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String!)), + default_value: None, + }, + composition_strategy: None, + }, + ], + true, // repeatable + &[DirectiveLocation::Object, DirectiveLocation::Interface], + false, // doesn't compose + Some(&|v| JOIN_VERSIONS.get_dyn_minimum_required_version(v)), + None, + )) + } + + /// Creates an instance of the `@join__implements` directive. Since we do not allow renaming of + /// join spec directives, this is infallible and always applies the directive with the standard + /// name. + pub(crate) fn implements_directive(&self, graph: Name, interface: &str) -> Directive { + Directive { + name: JOIN_IMPLEMENTS_DIRECTIVE_NAME_IN_SPEC, + arguments: vec![ + Node::new(Argument { + name: JOIN_GRAPH_ARGUMENT_NAME, + value: Node::new(Value::Enum(graph)), + }), + Node::new(Argument { + name: JOIN_INTERFACE_ARGUMENT_NAME, + value: Node::new(Value::String(interface.to_owned())), + }), + ], + } + } + + /// @join__unionMember + fn union_member_directive_spec(&self) -> Option { + if *self.version() < (Version { major: 0, minor: 3 }) { + return None; + } + Some(DirectiveSpecification::new( + JOIN_UNIONMEMBER_DIRECTIVE_NAME_IN_SPEC, + &[ + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_GRAPH_ARGUMENT_NAME, + get_type: |_schema, link| { + let graph_name = link.map_or(JOIN_GRAPH_ENUM_NAME_IN_SPEC, |link| { + link.type_name_in_schema(&JOIN_GRAPH_ENUM_NAME_IN_SPEC) + }); + Ok(Type::NonNullNamed(graph_name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_MEMBER_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String!)), + default_value: None, + }, + composition_strategy: None, + }, + ], + true, // repeatable + &[DirectiveLocation::Union], + false, // doesn't compose + Some(&|v| JOIN_VERSIONS.get_dyn_minimum_required_version(v)), + None, + )) + } + + /// @join__enumValue + pub(crate) fn enum_value_directive_spec(&self) -> Option { + if *self.version() < (Version { major: 0, minor: 3 }) { + return None; + } + Some(DirectiveSpecification::new( + JOIN_ENUMVALUE_DIRECTIVE_NAME_IN_SPEC, + &[DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_GRAPH_ARGUMENT_NAME, + get_type: |_schema, link| { + let graph_name = link.map_or(JOIN_GRAPH_ENUM_NAME_IN_SPEC, |link| { + link.type_name_in_schema(&JOIN_GRAPH_ENUM_NAME_IN_SPEC) + }); + Ok(Type::NonNullNamed(graph_name)) + }, + default_value: None, + }, + composition_strategy: None, + }], + true, // repeatable + &[DirectiveLocation::EnumValue], + false, // doesn't compose + Some(&|v| JOIN_VERSIONS.get_dyn_minimum_required_version(v)), + None, + )) + } + + /// @join__directive + fn directive_directive_spec(&self) -> Option { + if *self.version() < (Version { major: 0, minor: 4 }) { + return None; + } + Some(DirectiveSpecification::new( + JOIN_DIRECTIVE_DIRECTIVE_NAME_IN_SPEC, + &[ + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_DIRECTIVE_GRAPHS_ARGUMENT_NAME, + get_type: |_schema, link| { + let graph_name = link.map_or(JOIN_GRAPH_ENUM_NAME_IN_SPEC, |link| { + link.type_name_in_schema(&JOIN_GRAPH_ENUM_NAME_IN_SPEC) + }); + Ok(Type::List(Box::new(Type::NonNullNamed(graph_name)))) + }, + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_NAME_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String!)), + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_DIRECTIVE_ARGS_ARGUMENT_NAME, + get_type: |_schema, link| { + let directive_args_name = + link.map_or(JOIN_DIRECTIVE_ARGUMENTS_NAME_IN_SPEC, |link| { + link.type_name_in_schema(&JOIN_DIRECTIVE_ARGUMENTS_NAME_IN_SPEC) + }); + Ok(Type::Named(directive_args_name)) + }, + default_value: None, + }, + composition_strategy: None, + }, + ], + true, // repeatable + &[ + DirectiveLocation::Schema, + DirectiveLocation::Object, + DirectiveLocation::Interface, + DirectiveLocation::FieldDefinition, + ], + false, // doesn't compose + Some(&|v| JOIN_VERSIONS.get_dyn_minimum_required_version(v)), + None, + )) + } + + /// Creates an instance of the `@join__directive` directive. Since we do not allow renaming of + /// join spec directives, this is infallible and always applies the directive with the standard + /// name. + pub(crate) fn directive_directive( + &self, + name: &Name, + graphs: impl IntoIterator, + args: impl IntoIterator>, + ) -> Directive { + Directive { + name: JOIN_DIRECTIVE_DIRECTIVE_NAME_IN_SPEC, + arguments: vec![ + Node::new(Argument { + name: JOIN_NAME_ARGUMENT_NAME, + value: Node::new(Value::String(name.to_string())), + }), + Node::new(Argument { + name: JOIN_DIRECTIVE_GRAPHS_ARGUMENT_NAME, + value: Node::new(Value::List( + graphs + .into_iter() + .map(|g| Node::new(Value::Enum(g))) + .collect(), + )), + }), + Node::new(Argument { + name: JOIN_DIRECTIVE_ARGS_ARGUMENT_NAME, + value: Node::new(Value::Object( + args.into_iter() + .map(|arg| (arg.name.clone(), arg.value.clone())) + .collect(), + )), + }), + ], + } + } + + /// @join__owner + fn owner_directive_spec(&self) -> Option { + if *self.version() != (Version { major: 0, minor: 1 }) { + return None; + } + Some(DirectiveSpecification::new( + name!("owner"), + &[DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: JOIN_GRAPH_ARGUMENT_NAME, + get_type: |_schema, link| { + let graph_name = link.map_or(JOIN_GRAPH_ENUM_NAME_IN_SPEC, |link| { + link.type_name_in_schema(&JOIN_GRAPH_ENUM_NAME_IN_SPEC) + }); + Ok(Type::NonNullNamed(graph_name)) + }, + default_value: None, + }, + composition_strategy: None, + }], + false, // not repeatable + &[DirectiveLocation::Object], + false, // doesn't compose + Some(&|v| JOIN_VERSIONS.get_dyn_minimum_required_version(v)), + None, + )) + } + + /// Populate the graph enum with subgraph information and return the mapping + /// from subgraph names to their corresponding enum names in the supergraph + pub(crate) fn populate_graph_enum( + &self, + schema: &mut FederationSchema, + subgraphs: &[Subgraph], + ) -> Result, FederationError> { + // Collect sanitized names and group subgraphs by sanitized name (like JS MultiMap) + let mut sanitized_name_to_subgraphs: HashMap>> = + HashMap::new(); + + for subgraph in subgraphs { + let sanitized = sanitize_graphql_name(&subgraph.name); + sanitized_name_to_subgraphs + .entry(sanitized) + .or_default() + .push(subgraph); + } + + // Create mapping from subgraph names to enum names (matches JS subgraphToEnumName) + let mut subgraph_to_enum_name = HashMap::new(); + + // Get the graph directive name once (used for all enum values) + let graph_directive_name = self + .directive_name_in_schema(schema, &JOIN_GRAPH_DIRECTIVE_NAME_IN_SPEC)? + .ok_or_else(|| SingleFederationError::Internal { + message: "Could not find graph directive name in schema".to_owned(), + })?; + + // Get the graph enum name to access it directly from the schema + let graph_enum_name = self + .type_name_in_schema(schema, &JOIN_GRAPH_ENUM_NAME_IN_SPEC)? + .ok_or_else(|| SingleFederationError::Internal { + message: "Could not find graph enum name in schema".to_owned(), + })?; + // Process each sanitized name and its subgraphs + for (sanitized_name, subgraphs_for_name) in sanitized_name_to_subgraphs { + for (index, subgraph) in subgraphs_for_name.iter().enumerate() { + let enum_name = if index == 0 { + // First subgraph gets the base sanitized name + sanitized_name.clone() + } else { + // Subsequent subgraphs get _1, _2, etc. + format!("{sanitized_name}_{index}") + }; + + let enum_value_name = Name::new(enum_name.as_str())?; + + subgraph_to_enum_name.insert(subgraph.name.clone(), enum_value_name.clone()); + + // Add the enum value to the schema + let mut enum_value = EnumValueDefinition { + description: None, + value: enum_value_name.clone(), + directives: Default::default(), + }; + + // Add @join__graph directive to the enum value + let mut graph_directive = Directive::new(graph_directive_name.clone()); + graph_directive.arguments.push(Node::new(Argument { + name: JOIN_NAME_ARGUMENT_NAME, + value: Node::new(Value::String(subgraph.name.clone())), + })); + graph_directive.arguments.push(Node::new(Argument { + name: JOIN_URL_ARGUMENT_NAME, + value: Node::new(Value::String(subgraph.url.clone())), + })); + + enum_value.directives.push(Node::new(graph_directive)); + + let enum_value_position = EnumValueDefinitionPosition { + type_name: graph_enum_name.clone(), + value_name: enum_value_name.clone(), + }; + + enum_value_position.insert(schema, Component::new(enum_value))?; + } + } + + Ok(subgraph_to_enum_name) + } } impl SpecDefinition for JoinSpecDefinition { @@ -320,34 +1140,402 @@ impl SpecDefinition for JoinSpecDefinition { &self.url } - fn minimum_federation_version(&self) -> Option<&Version> { - self.minimum_federation_version.as_ref() + fn directive_specs(&self) -> Vec> { + let mut specs: Vec> = vec![ + Box::new(self.graph_directive_specification()), + Box::new(self.type_directive_specification()), + Box::new(self.field_directive_specification()), + ]; + if let Some(spec) = self.implements_directive_spec() { + specs.push(Box::new(spec)); + } + if let Some(spec) = self.union_member_directive_spec() { + specs.push(Box::new(spec)); + } + if let Some(spec) = self.enum_value_directive_spec() { + specs.push(Box::new(spec)); + } + if let Some(spec) = self.directive_directive_spec() { + specs.push(Box::new(spec)); + } + if let Some(spec) = self.owner_directive_spec() { + specs.push(Box::new(spec)); + } + + specs + } + + fn type_specs(&self) -> Vec> { + let mut specs: Vec> = Vec::new(); + + // Enum Graph + specs.push(Box::new(EnumTypeSpecification { + name: JOIN_GRAPH_ENUM_NAME_IN_SPEC, + values: vec![], // Initialized with no values, but graphs will be added later as they get merged in + })); + + // Scalar FieldSet + specs.push(Box::new(ScalarTypeSpecification { + name: JOIN_FIELD_SET_NAME_IN_SPEC, + })); + + // Scalar DirectiveArguments (v0.4+) + if *self.version() >= (Version { major: 0, minor: 4 }) { + specs.push(Box::new(ScalarTypeSpecification { + name: JOIN_DIRECTIVE_ARGUMENTS_NAME_IN_SPEC, + })); + } + + if *self.version() >= (Version { major: 0, minor: 5 }) { + // Scalar FieldValue (v0.5+) + specs.push(Box::new(ScalarTypeSpecification { + name: name!("FieldValue"), + })); + + // InputObject join__ContextArgument (v0.5+) + specs.push(Box::new(InputObjectTypeSpecification { + name: name!("ContextArgument"), + fields: |_| { + vec![ + ArgumentSpecification { + name: name!("name"), + get_type: |_, _| Ok(ty!(String!)), + default_value: None, + }, + ArgumentSpecification { + name: name!("type"), + get_type: |_, _| Ok(ty!(String!)), + default_value: None, + }, + ArgumentSpecification { + name: name!("context"), + get_type: |_, _| Ok(ty!(String!)), + default_value: None, + }, + ArgumentSpecification { + name: name!("selection"), + get_type: |_schema, link| { + let field_value_name = link.map_or(name!("FieldValue"), |link| { + link.type_name_in_schema(&name!("FieldValue")) + }); + Ok(Type::Named(field_value_name)) + }, + default_value: None, + }, + ] + }, + })); + } + + specs + } + + fn minimum_federation_version(&self) -> &Version { + &self.minimum_federation_version + } + + fn purpose(&self) -> Option { + Some(Purpose::EXECUTION) } } -lazy_static! { - pub(crate) static ref JOIN_VERSIONS: SpecDefinitions = { +/// The versions are as follows: +/// - 0.1: this is the version used by federation 1 composition. Federation 2 is still able to read supergraphs +/// using that verison for backward compatibility, but never writes this spec version is not expressive enough +/// for federation 2 in general. +/// - 0.2: this is the original version released with federation 2. +/// - 0.3: adds the `isInterfaceObject` argument to `@join__type`, and make the `graph` in `@join__field` skippable. +/// - 0.4: adds the optional `overrideLabel` argument to `@join_field` for progressive override. +/// - 0.5: adds the `contextArguments` argument to `@join_field` for setting context. +pub(crate) static JOIN_VERSIONS: LazyLock> = + LazyLock::new(|| { let mut definitions = SpecDefinitions::new(Identity::join_identity()); definitions.add(JoinSpecDefinition::new( Version { major: 0, minor: 1 }, - None, + Version { major: 1, minor: 0 }, )); definitions.add(JoinSpecDefinition::new( Version { major: 0, minor: 2 }, - None, + Version { major: 1, minor: 0 }, )); definitions.add(JoinSpecDefinition::new( Version { major: 0, minor: 3 }, - Some(Version { major: 2, minor: 0 }), + Version { major: 2, minor: 0 }, )); definitions.add(JoinSpecDefinition::new( Version { major: 0, minor: 4 }, - Some(Version { major: 2, minor: 7 }), + Version { major: 2, minor: 7 }, )); definitions.add(JoinSpecDefinition::new( Version { major: 0, minor: 5 }, - Some(Version { major: 2, minor: 8 }), + Version { major: 2, minor: 8 }, )); definitions - }; + }); + +/// Represents a valid enum value in GraphQL, used for building `join__Graph`. +/// +/// This was previously duplicated in both `merge.rs` and `merger.rs` but has been +/// consolidated here as it's specifically related to join spec functionality. +#[derive(Clone, Debug)] +pub(crate) struct EnumValue(Name); + +impl EnumValue { + pub(crate) fn new(raw: &str) -> Result { + let prefix = if raw.starts_with(char::is_numeric) { + Some('_') + } else { + None + }; + let name = prefix + .into_iter() + .chain(raw.chars()) + .map(|c| match c { + 'a'..='z' => c.to_ascii_uppercase(), + 'A'..='Z' | '0'..='9' => c, + _ => '_', + }) + .collect::(); + Name::new(&name) + .map(Self) + .map_err(|_| format!("Failed to transform {raw} into a valid GraphQL name. Got {name}")) + } + + pub(crate) fn to_name(&self) -> Name { + self.0.clone() + } + + #[cfg(test)] + pub(crate) fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl From for Name { + fn from(ev: EnumValue) -> Self { + ev.0 + } +} + +impl From for EnumValue { + fn from(name: Name) -> Self { + EnumValue(name) + } +} + +impl Display for EnumValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod test_enum_value { + use super::EnumValue; + + #[test] + fn basic() { + let ev = EnumValue::new("subgraph").unwrap(); + assert_eq!(ev.as_str(), "SUBGRAPH"); + } + + #[test] + fn with_underscores() { + let ev = EnumValue::new("a_subgraph").unwrap(); + assert_eq!(ev.as_str(), "A_SUBGRAPH"); + } + + #[test] + fn with_hyphens() { + let ev = EnumValue::new("a-subgraph").unwrap(); + assert_eq!(ev.as_str(), "A_SUBGRAPH"); + } + + #[test] + fn special_symbols() { + let ev = EnumValue::new("a$ubgraph").unwrap(); + assert_eq!(ev.as_str(), "A_UBGRAPH"); + } + + #[test] + fn digit_first_char() { + let ev = EnumValue::new("1subgraph").unwrap(); + assert_eq!(ev.as_str(), "_1SUBGRAPH"); + } + + #[test] + fn digit_last_char() { + let ev = EnumValue::new("subgraph_1").unwrap(); + assert_eq!(ev.as_str(), "SUBGRAPH_1"); + } +} + +#[cfg(test)] +mod test { + use apollo_compiler::ast::Argument; + use apollo_compiler::name; + + use super::*; + use crate::link::DEFAULT_LINK_NAME; + use crate::link::link_spec_definition::LINK_DIRECTIVE_FOR_ARGUMENT_NAME; + use crate::link::link_spec_definition::LINK_DIRECTIVE_URL_ARGUMENT_NAME; + use crate::schema::position::SchemaDefinitionPosition; + use crate::subgraph::test_utils::BuildOption; + use crate::subgraph::test_utils::build_inner_expanded; + + impl JoinSpecDefinition { + fn link(&self) -> Directive { + Directive { + name: DEFAULT_LINK_NAME, + arguments: vec![ + Node::new(Argument { + name: LINK_DIRECTIVE_URL_ARGUMENT_NAME, + value: self.url.to_string().into(), + }), + Node::new(Argument { + name: LINK_DIRECTIVE_FOR_ARGUMENT_NAME, + value: Node::new(Value::Enum(name!("EXECUTION"))), + }), + ], + } + } + } + + fn trivial_schema() -> FederationSchema { + build_inner_expanded("type Query { hello: String }", BuildOption::AsFed2) + .unwrap() + .schema() + .to_owned() + } + + fn get_schema_with_join(version: Version) -> FederationSchema { + let mut schema = trivial_schema(); + let join_spec = JOIN_VERSIONS.find(&version).unwrap(); + SchemaDefinitionPosition + .insert_directive(&mut schema, join_spec.link().into()) + .unwrap(); + join_spec.add_elements_to_schema(&mut schema).unwrap(); + schema + } + + fn join_spec_directives_snapshot(schema: &FederationSchema) -> String { + schema + .schema() + .directive_definitions + .iter() + .filter_map(|(name, def)| { + if name.as_str().starts_with("join__") { + Some(def.to_string()) + } else { + None + } + }) + .join("\n") + } + + fn join_spec_types_snapshot(schema: &FederationSchema) -> String { + schema + .schema() + .types + .iter() + .filter_map(|(name, ty)| { + if name.as_str().starts_with("join__") { + Some(ty.to_string()) + } else { + None + } + }) + .join("") + } + + #[test] + fn join_spec_v0_1_definitions() { + let schema = get_schema_with_join(Version { major: 0, minor: 1 }); + + insta::assert_snapshot!(join_spec_types_snapshot(&schema), @r#"enum join__Graph +scalar join__FieldSet +"#); + insta::assert_snapshot!(join_spec_directives_snapshot(&schema), @r#"directive @join__graph(name: String!, url: String!) on ENUM_VALUE +directive @join__type(graph: join__Graph!, key: join__FieldSet) on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR +directive @join__field(graph: join__Graph!, requires: join__FieldSet, provides: join__FieldSet) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @join__owner(graph: join__Graph!) on OBJECT +"#); + } + + #[test] + fn join_spec_v0_2_definitions() { + let schema = get_schema_with_join(Version { major: 0, minor: 2 }); + + insta::assert_snapshot!(join_spec_types_snapshot(&schema), @r#"enum join__Graph +scalar join__FieldSet +"#); + + insta::assert_snapshot!(join_spec_directives_snapshot(&schema), @r#"directive @join__graph(name: String!, url: String!) on ENUM_VALUE +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR +directive @join__field(graph: join__Graph!, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE +"#); + } + + #[test] + fn join_spec_v0_3_definitions() { + let schema = get_schema_with_join(Version { major: 0, minor: 3 }); + + insta::assert_snapshot!(join_spec_types_snapshot(&schema), @r#"enum join__Graph +scalar join__FieldSet +"#); + + insta::assert_snapshot!(join_spec_directives_snapshot(&schema), @r#"directive @join__graph(name: String!, url: String!) on ENUM_VALUE +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE +"#); + } + + #[test] + fn join_spec_v0_4_definitions() { + let schema = get_schema_with_join(Version { major: 0, minor: 4 }); + + insta::assert_snapshot!(join_spec_types_snapshot(&schema), @r#"enum join__Graph +scalar join__FieldSet +scalar join__DirectiveArguments +"#); + + insta::assert_snapshot!(join_spec_directives_snapshot(&schema), @r#"directive @join__graph(name: String!, url: String!) on ENUM_VALUE +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION +"#); + } + + #[test] + fn join_spec_v0_5_definitions() { + let schema = get_schema_with_join(Version { major: 0, minor: 5 }); + + insta::assert_snapshot!(join_spec_types_snapshot(&schema), @r#"enum join__Graph +scalar join__FieldSet +scalar join__DirectiveArguments +scalar join__FieldValue +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue +} +"#); + + insta::assert_snapshot!(join_spec_directives_snapshot(&schema), @r#"directive @join__graph(name: String!, url: String!) on ENUM_VALUE +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION +"#); + } } diff --git a/apollo-federation/src/link/link_spec_definition.rs b/apollo-federation/src/link/link_spec_definition.rs index f1ed4035f7..81282eef2e 100644 --- a/apollo-federation/src/link/link_spec_definition.rs +++ b/apollo-federation/src/link/link_spec_definition.rs @@ -1,27 +1,351 @@ -use lazy_static::lazy_static; +use std::sync::Arc; +use std::sync::LazyLock; +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Argument; +use apollo_compiler::ast::Directive; +use apollo_compiler::ast::DirectiveLocation; +use apollo_compiler::ast::Type; +use apollo_compiler::ast::Value; +use apollo_compiler::name; +use apollo_compiler::schema::Component; +use apollo_compiler::ty; +use itertools::Itertools; + +use crate::bail; +use crate::error::FederationError; +use crate::error::MultiTry; +use crate::error::MultiTryAll; +use crate::error::SingleFederationError; +use crate::link::DEFAULT_IMPORT_SCALAR_NAME; +use crate::link::DEFAULT_PURPOSE_ENUM_NAME; +use crate::link::Import; +use crate::link::Link; +use crate::link::Purpose; +use crate::link::argument::directive_optional_list_argument; +use crate::link::argument::directive_optional_string_argument; use crate::link::spec::Identity; use crate::link::spec::Url; use crate::link::spec::Version; use crate::link::spec_definition::SpecDefinition; use crate::link::spec_definition::SpecDefinitions; +use crate::schema::FederationSchema; +use crate::schema::SchemaElement; +use crate::schema::position::SchemaDefinitionPosition; +use crate::schema::type_and_directive_specification::ArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveSpecification; +use crate::schema::type_and_directive_specification::EnumTypeSpecification; +use crate::schema::type_and_directive_specification::EnumValueSpecification; +use crate::schema::type_and_directive_specification::ScalarTypeSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; + +pub(crate) const LINK_DIRECTIVE_AS_ARGUMENT_NAME: Name = name!("as"); +pub(crate) const LINK_DIRECTIVE_URL_ARGUMENT_NAME: Name = name!("url"); +pub(crate) const LINK_DIRECTIVE_FOR_ARGUMENT_NAME: Name = name!("for"); +pub(crate) const LINK_DIRECTIVE_IMPORT_ARGUMENT_NAME: Name = name!("import"); +pub(crate) const LINK_DIRECTIVE_FEATURE_ARGUMENT_NAME: Name = name!("feature"); // Fed 1's `url` argument pub(crate) struct LinkSpecDefinition { url: Url, - minimum_federation_version: Option, + minimum_federation_version: Version, } impl LinkSpecDefinition { pub(crate) fn new( version: Version, - minimum_federation_version: Option, identity: Identity, + minimum_federation_version: Version, ) -> Self { Self { url: Url { identity, version }, minimum_federation_version, } } + + fn create_definition_argument_specifications(&self) -> Vec { + let mut specs = vec![ + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: self.url_arg_name(), + get_type: |_, _| Ok(ty!(String)), + default_value: None, + }, + composition_strategy: None, + }, + DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: LINK_DIRECTIVE_AS_ARGUMENT_NAME, + get_type: |_, _| Ok(ty!(String)), + default_value: None, + }, + composition_strategy: None, + }, + ]; + if self.supports_purpose() { + specs.push(DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: LINK_DIRECTIVE_FOR_ARGUMENT_NAME, + get_type: |_schema, link| { + let Some(link) = link else { + bail!( + "Type {DEFAULT_PURPOSE_ENUM_NAME} shouldn't be added without being attached to a @link spec" + ) + }; + Ok(Type::Named(link.type_name_in_schema(&DEFAULT_PURPOSE_ENUM_NAME))) + }, + default_value: None, + }, + composition_strategy: None, + }); + } + if self.supports_import() { + specs.push(DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: LINK_DIRECTIVE_IMPORT_ARGUMENT_NAME, + get_type: |_, link| { + let Some(link) = link else { + bail!( + "Type {DEFAULT_IMPORT_SCALAR_NAME} shouldn't be added without being attached to a @link spec" + ) + }; + Ok(Type::List(Box::new(Type::Named( + link.type_name_in_schema(&DEFAULT_IMPORT_SCALAR_NAME), + )))) + }, + default_value: None, + }, + composition_strategy: None, + }); + } + specs + } + + fn supports_purpose(&self) -> bool { + self.version().gt(&Version { major: 0, minor: 1 }) + } + + fn supports_import(&self) -> bool { + self.version().satisfies(&Version { major: 1, minor: 0 }) + } + + pub(crate) fn url_arg_name(&self) -> Name { + if self.url.identity.name == Identity::core_identity().name { + LINK_DIRECTIVE_FEATURE_ARGUMENT_NAME + } else { + LINK_DIRECTIVE_URL_ARGUMENT_NAME + } + } + + /// Add `self` (the @link spec definition) and a directive application of it to the schema. + // Note: we may want to allow some `import` as argument to this method. When we do, we need to + // watch for imports of `Purpose` and `Import` and add the types under their imported name. + pub(crate) fn add_to_schema( + &self, + schema: &mut FederationSchema, + alias: Option, + ) -> Result<(), FederationError> { + self.add_definitions_to_schema(schema, alias.clone(), vec![])?; + + // This adds `@link(url: "https://specs.apollo.dev/link/v1.0")` to the "schema" definition. + // And we have a choice to add it either the main definition, or to an `extend schema`. + // + // In theory, always adding it to the main definition should be safe since even if some + // root operations can be defined in extensions, you shouldn't have an extension without a + // definition, and so we should never be in a case where _all_ root operations are defined + // in extensions (which would be a problem for printing the definition itself since it's + // syntactically invalid to have a schema definition with no operations). + // + // In practice however, graphQL-js has historically accepted extensions without definition + // for schema, and we even abuse this a bit with federation out of convenience, so we could + // end up in the situation where if we put the directive on the definition, it cannot be + // printed properly due to the user having defined all its root operations in an extension. + // + // We could always add the directive to an extension, and that could kind of work but: + // 1. the core/link spec says that the link-to-link application should be the first `@link` + // of the schema, but if user put some `@link` on their schema definition but we always + // put the link-to-link on an extension, then we're kind of not respecting our own spec + // (in practice, our own code can actually handle this as it does not strongly rely on + // that "it should be the first" rule, but that would set a bad example). + // 2. earlier versions (pre-#1875) were always putting that directive on the definition, + // and we wanted to avoid surprising users by changing that for not reason. + // + // So instead, we put the directive on the schema definition unless some extensions exists + // but no definition does (that is, no non-extension elements are populated). + // + // Side-note: this test must be done _before_ we call `insert_directive`, otherwise it + // would take it into account. + + let name = alias.as_ref().unwrap_or(&self.url.identity.name).clone(); + let mut arguments = vec![Node::new(Argument { + name: self.url_arg_name(), + value: self.url.to_string().into(), + })]; + if let Some(alias) = alias { + arguments.push(Node::new(Argument { + name: LINK_DIRECTIVE_AS_ARGUMENT_NAME, + value: alias.to_string().into(), + })); + } + + let schema_definition = SchemaDefinitionPosition.get(schema.schema()); + SchemaDefinitionPosition.insert_directive_at( + schema, + Component { + origin: schema_definition.origin_to_use(), + node: Node::new(Directive { name, arguments }), + }, + 0, // @link to link spec should be first + )?; + Ok(()) + } + + pub(crate) fn extract_alias_and_imports_on_missing_link_directive_definition( + application: &Node, + ) -> Result<(Option, Vec>), FederationError> { + // PORT_NOTE: This is really logic encapsulated from onMissingDirectiveDefinition() in the + // JS codebase's FederationBlueprint, but moved here since it's all link-specific. The logic + // itself has a lot of problems, but we're porting it as-is for now, and we'll address the + // problems with it in a later version bump. + let url = + directive_optional_string_argument(application, &LINK_DIRECTIVE_URL_ARGUMENT_NAME)?; + if let Some(url) = url + && url.starts_with(&LinkSpecDefinition::latest().url.identity.to_string()) + { + let alias = + directive_optional_string_argument(application, &LINK_DIRECTIVE_AS_ARGUMENT_NAME)? + .map(Name::new) + .transpose()?; + let imports = directive_optional_list_argument( + application, + &LINK_DIRECTIVE_IMPORT_ARGUMENT_NAME, + )? + .into_iter() + .flatten() + .map(|value| Ok::<_, FederationError>(Arc::new(Import::from_value(value)?))) + .process_results(|r| r.collect::>())?; + return Ok((alias, imports)); + } + Ok((None, vec![])) + } + + pub(crate) fn add_definitions_to_schema( + &self, + schema: &mut FederationSchema, + alias: Option, + imports: Vec>, + ) -> Result<(), FederationError> { + if let Some(metadata) = schema.metadata() { + let link_spec_def = metadata.link_spec_definition()?; + if link_spec_def.url.identity == *self.identity() { + // Already exists with the same version, let it be. + return Ok(()); + } + let self_fmt = format!("{}/{}", self.identity(), self.version()); + return Err(SingleFederationError::InvalidLinkDirectiveUsage { + message: format!( + "Cannot add link spec {self_fmt} to the schema, it already has {existing_def}", + existing_def = link_spec_def.url + ), + } + .into()); + } + + // The @link spec is special in that it is the one that bootstrap everything, and by the + // time this method is called, the `schema` may not yet have any `schema.metadata()` set up + // yet. To have `check_or_add` calls below still work, we pass a mock link object with the + // proper information. + let mock_link = Arc::new(Link { + url: self.url.clone(), + spec_alias: alias, + imports, + purpose: None, + }); + Ok(()) + .and_try(create_link_purpose_type_spec().check_or_add(schema, Some(&mock_link))) + .and_try(create_link_import_type_spec().check_or_add(schema, Some(&mock_link))) + .and_try( + self.directive_specs() + .into_iter() + .try_for_all(|spec| spec.check_or_add(schema, Some(&mock_link))), + ) + } + + pub(crate) fn apply_feature_to_schema( + &self, + schema: &mut FederationSchema, + feature: &dyn SpecDefinition, + alias: Option, + purpose: Option, + imports: Option>, + ) -> Result<(), FederationError> { + let mut directive = Directive::new(self.url.identity.name.clone()); + directive.arguments.push(Node::new(Argument { + name: self.url_arg_name(), + value: Node::new(feature.to_string().into()), + })); + if let Some(alias) = alias { + directive.arguments.push(Node::new(Argument { + name: LINK_DIRECTIVE_AS_ARGUMENT_NAME, + value: Node::new(alias.to_string().into()), + })); + } + if let Some(purpose) = &purpose { + if self.supports_purpose() { + directive.arguments.push(Node::new(Argument { + name: LINK_DIRECTIVE_FOR_ARGUMENT_NAME, + value: Node::new(Value::Enum(purpose.into())), + })); + } else { + return Err(SingleFederationError::InvalidLinkDirectiveUsage { + message: format!( + "Cannot apply feature {} with purpose since the schema's @core/@link version does not support it.", feature.to_string() + ), + }.into()); + } + } + if let Some(imports) = imports + && !imports.is_empty() + { + if self.supports_import() { + directive.arguments.push(Node::new(Argument { + name: LINK_DIRECTIVE_IMPORT_ARGUMENT_NAME, + value: Node::new(Value::List( + imports.into_iter().map(|i| Node::new(i.into())).collect(), + )), + })) + } else { + return Err(SingleFederationError::InvalidLinkDirectiveUsage { + message: format!( + "Cannot apply feature {} with imports since the schema's @core/@link version does not support it.", + feature.to_string() + ), + }.into()); + } + } + + SchemaDefinitionPosition.insert_directive(schema, Component::new(directive))?; + feature.add_elements_to_schema(schema)?; + + Ok(()) + } + + #[allow(unused)] + pub(crate) fn fed1_latest() -> &'static Self { + // Note: The `unwrap()` calls won't panic, since `CORE_VERSIONS` will always have at + // least one version. + let latest_version = CORE_VERSIONS.versions().last().unwrap(); + CORE_VERSIONS.find(latest_version).unwrap() + } + + /// PORT_NOTE: This is a port of the `linkSpec`, which is defined as `LINK_VERSIONS.latest()`. + pub(crate) fn latest() -> &'static Self { + // Note: The `unwrap()` calls won't panic, since `LINK_VERSIONS` will always have at + // least one version. + let latest_version = LINK_VERSIONS.versions().last().unwrap(); + LINK_VERSIONS.find(latest_version).unwrap() + } } impl SpecDefinition for LinkSpecDefinition { @@ -29,33 +353,96 @@ impl SpecDefinition for LinkSpecDefinition { &self.url } - fn minimum_federation_version(&self) -> Option<&Version> { - self.minimum_federation_version.as_ref() + fn directive_specs(&self) -> Vec> { + vec![Box::new(DirectiveSpecification::new( + self.url().identity.name.clone(), + &self.create_definition_argument_specifications(), + true, + &[DirectiveLocation::Schema], + false, + None, + None, + ))] + } + + fn type_specs(&self) -> Vec> { + let mut specs: Vec> = Vec::with_capacity(2); + if self.supports_purpose() { + specs.push(Box::new(create_link_purpose_type_spec())) + } + if self.supports_import() { + specs.push(Box::new(create_link_import_type_spec())) + } + specs + } + + fn minimum_federation_version(&self) -> &Version { + &self.minimum_federation_version + } + + fn add_elements_to_schema( + &self, + _schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + // Link is special and the @link directive is added in `add_to_schema` above + Ok(()) + } + + fn purpose(&self) -> Option { + None + } +} + +fn create_link_purpose_type_spec() -> EnumTypeSpecification { + EnumTypeSpecification { + name: DEFAULT_PURPOSE_ENUM_NAME, + values: vec![ + EnumValueSpecification { + name: name!("SECURITY"), + description: Some( + "`SECURITY` features provide metadata necessary to securely resolve fields." + .to_string(), + ), + }, + EnumValueSpecification { + name: name!("EXECUTION"), + description: Some( + "`EXECUTION` features provide metadata necessary for operation execution." + .to_string(), + ), + }, + ], + } +} + +fn create_link_import_type_spec() -> ScalarTypeSpecification { + ScalarTypeSpecification { + name: DEFAULT_IMPORT_SCALAR_NAME, } } -lazy_static! { - pub(crate) static ref CORE_VERSIONS: SpecDefinitions = { +pub(crate) static CORE_VERSIONS: LazyLock> = + LazyLock::new(|| { let mut definitions = SpecDefinitions::new(Identity::core_identity()); definitions.add(LinkSpecDefinition::new( Version { major: 0, minor: 1 }, - None, Identity::core_identity(), + Version { major: 1, minor: 0 }, )); definitions.add(LinkSpecDefinition::new( Version { major: 0, minor: 2 }, - Some(Version { major: 2, minor: 0 }), Identity::core_identity(), + Version { major: 2, minor: 0 }, )); definitions - }; - pub(crate) static ref LINK_VERSIONS: SpecDefinitions = { + }); +pub(crate) static LINK_VERSIONS: LazyLock> = + LazyLock::new(|| { let mut definitions = SpecDefinitions::new(Identity::link_identity()); definitions.add(LinkSpecDefinition::new( Version { major: 1, minor: 0 }, - Some(Version { major: 2, minor: 0 }), Identity::link_identity(), + Version { major: 2, minor: 0 }, )); definitions - }; -} + }); diff --git a/apollo-federation/src/link/mod.rs b/apollo-federation/src/link/mod.rs index 76a59da2ea..aa31762fcf 100644 --- a/apollo-federation/src/link/mod.rs +++ b/apollo-federation/src/link/mod.rs @@ -1,40 +1,49 @@ +use std::collections::HashMap; use std::fmt; use std::str; use std::sync::Arc; +use apollo_compiler::InvalidNameError; +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::Schema; use apollo_compiler::ast::Directive; use apollo_compiler::ast::Value; use apollo_compiler::collections::IndexMap; use apollo_compiler::name; use apollo_compiler::schema::Component; -use apollo_compiler::InvalidNameError; -use apollo_compiler::Name; -use apollo_compiler::Node; -use apollo_compiler::Schema; use thiserror::Error; use crate::error::FederationError; use crate::error::SingleFederationError; -use crate::link::link_spec_definition::LinkSpecDefinition; use crate::link::link_spec_definition::CORE_VERSIONS; use crate::link::link_spec_definition::LINK_VERSIONS; +use crate::link::link_spec_definition::LinkSpecDefinition; use crate::link::spec::Identity; use crate::link::spec::Url; pub(crate) mod argument; -pub(crate) mod cost_spec_definition; +pub(crate) mod authenticated_spec_definition; +pub(crate) mod cache_tag_spec_definition; +pub(crate) mod context_spec_definition; +pub mod cost_spec_definition; pub mod database; pub(crate) mod federation_spec_definition; pub(crate) mod graphql_definition; pub(crate) mod inaccessible_spec_definition; pub(crate) mod join_spec_definition; pub(crate) mod link_spec_definition; +pub(crate) mod policy_spec_definition; +pub(crate) mod requires_scopes_spec_definition; pub mod spec; pub(crate) mod spec_definition; +pub(crate) mod tag_spec_definition; pub const DEFAULT_LINK_NAME: Name = name!("link"); pub const DEFAULT_IMPORT_SCALAR_NAME: Name = name!("Import"); pub const DEFAULT_PURPOSE_ENUM_NAME: Name = name!("Purpose"); +pub(crate) const IMPORT_AS_ARGUMENT: Name = name!("as"); +pub(crate) const IMPORT_NAME_ARGUMENT: Name = name!("name"); // TODO: we should provide proper "diagnostic" here, linking to ast, accumulating more than one // error and whatnot. @@ -44,6 +53,8 @@ pub enum LinkError { InvalidName(#[from] InvalidNameError), #[error("Invalid use of @link in schema: {0}")] BootstrapError(String), + #[error("Unknown import: {0}")] + InvalidImport(String), } // TODO: Replace LinkError usages with FederationError. @@ -56,7 +67,7 @@ impl From for FederationError { } } -#[derive(Eq, PartialEq, Debug)] +#[derive(Clone, Copy, Eq, PartialEq, Debug)] pub enum Purpose { SECURITY, EXECUTION, @@ -64,13 +75,12 @@ pub enum Purpose { impl Purpose { pub fn from_value(value: &Value) -> Result { - if let Value::Enum(value) = value { - Ok(value.parse::()?) - } else { - Err(LinkError::BootstrapError( - "invalid `purpose` value, should be an enum".to_string(), - )) - } + value + .as_enum() + .ok_or_else(|| { + LinkError::BootstrapError("invalid `purpose` value, should be an enum".to_string()) + }) + .and_then(|value| value.parse()) } } @@ -82,8 +92,7 @@ impl str::FromStr for Purpose { "SECURITY" => Ok(Purpose::SECURITY), "EXECUTION" => Ok(Purpose::EXECUTION), _ => Err(LinkError::BootstrapError(format!( - "invalid/unrecognized `purpose` value '{}'", - s + "invalid/unrecognized `purpose` value '{s}'" ))), } } @@ -130,11 +139,19 @@ impl Import { match value { Value::String(str) => { if let Some(directive_name) = str.strip_prefix('@') { - Ok(Import { element: Name::new(directive_name)?, is_directive: true, alias: None }) + Ok(Import { + element: Name::new(directive_name)?, + is_directive: true, + alias: None, + }) } else { - Ok(Import { element: Name::new(str)?, is_directive: false, alias: None }) + Ok(Import { + element: Name::new(str)?, + is_directive: false, + alias: None, + }) } - }, + } Value::Object(fields) => { let mut name: Option<&str> = None; let mut alias: Option<&str> = None; @@ -142,51 +159,62 @@ impl Import { match k.as_str() { "name" => { name = Some(v.as_str().ok_or_else(|| { - LinkError::BootstrapError("invalid value for `name` field in @link(import:) argument: must be a string".to_string()) + LinkError::BootstrapError(format!(r#"in "{}", invalid value for `name` field in @link(import:) argument: must be a string"#, value.serialize().no_indent())) })?) }, "as" => { alias = Some(v.as_str().ok_or_else(|| { - LinkError::BootstrapError("invalid value for `as` field in @link(import:) argument: must be a string".to_string()) + LinkError::BootstrapError(format!(r#"in "{}", invalid value for `as` field in @link(import:) argument: must be a string"#, value.serialize().no_indent())) })?) }, - _ => Err(LinkError::BootstrapError(format!("unknown field `{k}` in @link(import:) argument")))? + _ => Err(LinkError::BootstrapError(format!(r#"in "{}", unknown field `{k}` in @link(import:) argument"#, value.serialize().no_indent())))? } } - if let Some(element) = name { - if let Some(directive_name) = element.strip_prefix('@') { - if let Some(alias_str) = alias.as_ref() { - let Some(alias_str) = alias_str.strip_prefix('@') else { - return Err(LinkError::BootstrapError(format!("invalid alias '{}' for import name '{}': should start with '@' since the imported name does", alias_str, element))); - }; - alias = Some(alias_str); - } - Ok(Import { - element: Name::new(directive_name)?, - is_directive: true, - alias: alias.map(Name::new).transpose()?, - }) - } else { - if let Some(alias) = &alias { - if alias.starts_with('@') { - return Err(LinkError::BootstrapError(format!("invalid alias '{}' for import name '{}': should not start with '@' (or, if {} is a directive, then the name should start with '@')", alias, element, element))); - } - } - Ok(Import { - element: Name::new(element)?, - is_directive: false, - alias: alias.map(Name::new).transpose()?, - }) + let Some(element) = name else { + return Err(LinkError::BootstrapError(format!( + r#"in "{}", invalid entry in @link(import:) argument, missing mandatory `name` field"#, + value.serialize().no_indent() + ))); + }; + if let Some(directive_name) = element.strip_prefix('@') { + if let Some(alias_str) = alias.as_ref() { + let Some(alias_str) = alias_str.strip_prefix('@') else { + return Err(LinkError::BootstrapError(format!( + r#"in "{}", invalid alias '{alias_str}' for import name '{element}': should start with '@' since the imported name does"#, + value.serialize().no_indent() + ))); + }; + alias = Some(alias_str); } + Ok(Import { + element: Name::new(directive_name)?, + is_directive: true, + alias: alias.map(Name::new).transpose()?, + }) } else { - Err(LinkError::BootstrapError("invalid entry in @link(import:) argument, missing mandatory `name` field".to_string())) + if let Some(alias) = &alias + && alias.starts_with('@') + { + return Err(LinkError::BootstrapError(format!( + r#"in "{}", invalid alias '{alias}' for import name '{element}': should not start with '@' (or, if {element} is a directive, then the name should start with '@')"#, + value.serialize().no_indent() + ))); + } + Ok(Import { + element: Name::new(element)?, + is_directive: false, + alias: alias.map(Name::new).transpose()?, + }) } - }, - _ => Err(LinkError::BootstrapError("invalid sub-value for @link(import:) argument: values should be either strings or input object values of the form { name: \"\", as: \"\" }.".to_string())) + } + _ => Err(LinkError::BootstrapError(format!( + r#"in "{}", invalid sub-value for @link(import:) argument: values should be either strings or input object values of the form {{ name: "", as: "" }}."#, + value.serialize().no_indent() + ))), } } - pub fn element_display_name(&self) -> impl fmt::Display + '_ { + pub fn element_display_name(&self) -> impl fmt::Display { DisplayName { name: &self.element, is_directive: self.is_directive, @@ -194,10 +222,10 @@ impl Import { } pub fn imported_name(&self) -> &Name { - return self.alias.as_ref().unwrap_or(&self.element); + self.alias.as_ref().unwrap_or(&self.element) } - pub fn imported_display_name(&self) -> impl fmt::Display + '_ { + pub fn imported_display_name(&self) -> impl fmt::Display { DisplayName { name: self.imported_name(), is_directive: self.is_directive, @@ -211,7 +239,7 @@ struct DisplayName<'s> { is_directive: bool, } -impl<'s> fmt::Display for DisplayName<'s> { +impl fmt::Display for DisplayName<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.is_directive { f.write_str("@")?; @@ -235,7 +263,27 @@ impl fmt::Display for Import { } } -#[derive(Debug, Eq, PartialEq)] +#[allow(clippy::from_over_into)] +impl Into for Import { + fn into(self) -> Value { + if let Some(alias) = self.alias { + Value::Object(vec![ + ( + IMPORT_NAME_ARGUMENT, + Node::new(Value::String(self.element.to_string())), + ), + ( + IMPORT_AS_ARGUMENT, + Node::new(Value::String(alias.to_string())), + ), + ]) + } else { + Value::String(self.element.to_string()) + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] pub struct Link { pub url: Url, pub spec_alias: Option, @@ -263,6 +311,24 @@ impl Link { } } + pub(crate) fn directive_name_in_schema_for_core_arguments( + spec_url: &Url, + spec_name_in_schema: &Name, + imports: &[Import], + directive_name_in_spec: &Name, + ) -> Name { + if let Some(element_import) = imports + .iter() + .find(|i| i.element == *directive_name_in_spec) + { + element_import.imported_name().clone() + } else if spec_url.identity.name == *directive_name_in_spec { + spec_name_in_schema.clone() + } else { + Name::new_unchecked(format!("{spec_name_in_schema}__{directive_name_in_spec}").as_str()) + } + } + pub fn type_name_in_schema(&self, name: &Name) -> Name { // Similar to directives, but the special case of a directive name matching the spec // name does not apply to types. @@ -367,12 +433,12 @@ impl fmt::Display for Link { let alias = self .spec_alias .as_ref() - .map(|a| format!(r#", as: "{}""#, a)) + .map(|a| format!(r#", as: "{a}""#)) .unwrap_or("".to_string()); let purpose = self .purpose .as_ref() - .map(|p| format!(r#", for: {}"#, p)) + .map(|p| format!(r#", for: {p}"#)) .unwrap_or("".to_string()); write!(f, r#"@link(url: "{}"{alias}{imports}{purpose})"#, self.url) } @@ -384,7 +450,7 @@ pub struct LinkedElement { pub import: Option>, } -#[derive(Default, Eq, PartialEq, Debug)] +#[derive(Clone, Default, Eq, PartialEq, Debug)] pub struct LinksMetadata { pub(crate) links: Vec>, pub(crate) by_identity: IndexMap>, @@ -394,6 +460,7 @@ pub struct LinksMetadata { } impl LinksMetadata { + // PORT_NOTE: Call this as a replacement for `CoreFeatures.coreItself` from JS. pub(crate) fn link_spec_definition( &self, ) -> Result<&'static LinkSpecDefinition, FederationError> { @@ -420,11 +487,11 @@ impl LinksMetadata { } pub fn all_links(&self) -> &[Arc] { - return self.links.as_ref(); + self.links.as_ref() } pub fn for_identity(&self, identity: &Identity) -> Option> { - return self.by_identity.get(identity).cloned(); + self.by_identity.get(identity).cloned() } pub fn source_link_of_type(&self, type_name: &Name) -> Option { @@ -476,4 +543,17 @@ impl LinksMetadata { }) }) } + + pub(crate) fn import_to_feature_url_map(&self) -> HashMap { + let directive_entries = self + .directives_by_imported_name + .iter() + .map(|(name, (link, _))| (name.to_string(), link.url.clone())); + let type_entries = self + .types_by_imported_name + .iter() + .map(|(name, (link, _))| (name.to_string(), link.url.clone())); + + directive_entries.chain(type_entries).collect() + } } diff --git a/apollo-federation/src/link/policy_spec_definition.rs b/apollo-federation/src/link/policy_spec_definition.rs new file mode 100644 index 0000000000..7a8d215923 --- /dev/null +++ b/apollo-federation/src/link/policy_spec_definition.rs @@ -0,0 +1,170 @@ +use std::sync::LazyLock; + +use apollo_compiler::Name; +use apollo_compiler::ast::Type; +use apollo_compiler::name; +use apollo_compiler::schema::DirectiveLocation; + +use crate::link::Purpose; +use crate::link::spec::Identity; +use crate::link::spec::Url; +use crate::link::spec::Version; +use crate::link::spec_definition::SpecDefinition; +use crate::link::spec_definition::SpecDefinitions; +use crate::schema::argument_composition_strategies::ArgumentCompositionStrategy; +use crate::schema::type_and_directive_specification::ArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveSpecification; +use crate::schema::type_and_directive_specification::ScalarTypeSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; + +pub(crate) const POLICY_DIRECTIVE_NAME_IN_SPEC: Name = name!("policy"); +pub(crate) const POLICY_POLICY_TYPE_NAME_IN_SPEC: Name = name!("Policy"); +pub(crate) const POLICY_POLICIES_ARGUMENT_NAME: Name = name!("policies"); + +#[derive(Clone)] +pub(crate) struct PolicySpecDefinition { + url: Url, + minimum_federation_version: Version, +} + +impl PolicySpecDefinition { + pub(crate) fn new(version: Version, minimum_federation_version: Version) -> Self { + Self { + url: Url { + identity: Identity::policy_identity(), + version, + }, + minimum_federation_version, + } + } + + fn directive_specification(&self) -> Box { + Box::new(DirectiveSpecification::new( + POLICY_DIRECTIVE_NAME_IN_SPEC, + &[DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: POLICY_POLICIES_ARGUMENT_NAME, + get_type: |_schema, link| { + let policy_type_name = link + .map(|l| l.type_name_in_schema(&POLICY_POLICY_TYPE_NAME_IN_SPEC)) + .unwrap_or(POLICY_POLICY_TYPE_NAME_IN_SPEC); + // The type is [[Policy!]!]! + Ok(Type::NonNullList(Box::new(Type::NonNullList(Box::new( + Type::NonNullNamed(policy_type_name), + ))))) + }, + default_value: None, + }, + composition_strategy: Some(ArgumentCompositionStrategy::Union), + }], + false, // not repeatable + &[ + DirectiveLocation::FieldDefinition, + DirectiveLocation::Object, + DirectiveLocation::Interface, + DirectiveLocation::Scalar, + DirectiveLocation::Enum, + ], + true, // composes + Some(&|v| POLICY_VERSIONS.get_dyn_minimum_required_version(v)), + None, + )) + } + + fn scalar_type_specification(&self) -> Box { + Box::new(ScalarTypeSpecification { + name: POLICY_POLICY_TYPE_NAME_IN_SPEC, + }) + } +} + +impl SpecDefinition for PolicySpecDefinition { + fn url(&self) -> &Url { + &self.url + } + + fn directive_specs(&self) -> Vec> { + vec![self.directive_specification()] + } + + fn type_specs(&self) -> Vec> { + vec![self.scalar_type_specification()] + } + + fn minimum_federation_version(&self) -> &Version { + &self.minimum_federation_version + } + + fn purpose(&self) -> Option { + Some(Purpose::SECURITY) + } +} + +pub(crate) static POLICY_VERSIONS: LazyLock> = + LazyLock::new(|| { + let mut definitions = SpecDefinitions::new(Identity::policy_identity()); + definitions.add(PolicySpecDefinition::new( + Version { major: 0, minor: 1 }, + Version { major: 2, minor: 6 }, + )); + definitions + }); + +#[cfg(test)] +mod test { + use itertools::Itertools; + + use crate::schema::FederationSchema; + use crate::subgraph::test_utils::BuildOption; + use crate::subgraph::test_utils::build_inner_expanded; + + fn trivial_schema() -> FederationSchema { + build_inner_expanded("type Query { hello: String }", BuildOption::AsFed2) + .unwrap() + .schema() + .to_owned() + } + + fn policy_spec_directives_snapshot(schema: &FederationSchema) -> String { + schema + .schema() + .directive_definitions + .iter() + .filter_map(|(name, def)| { + if name.as_str().starts_with("policy") { + Some(def.to_string()) + } else { + None + } + }) + .join("\n") + } + + fn policy_spec_types_snapshot(schema: &FederationSchema) -> String { + schema + .schema() + .types + .iter() + .filter_map(|(name, ty)| { + if name.as_str().ends_with("__Policy") { + Some(ty.to_string()) + } else { + None + } + }) + .join("") + } + + #[test] + fn policy_spec_v0_1_definitions() { + let schema = trivial_schema(); + let types_snapshot = policy_spec_types_snapshot(&schema); + let expected_types = r#"scalar federation__Policy"#; + assert_eq!(types_snapshot.trim(), expected_types.trim()); + + let directives_snapshot = policy_spec_directives_snapshot(&schema); + let expected_directives = r#"directive @policy(policies: [[federation__Policy!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM"#; + assert_eq!(directives_snapshot.trim(), expected_directives.trim()); + } +} diff --git a/apollo-federation/src/link/requires_scopes_spec_definition.rs b/apollo-federation/src/link/requires_scopes_spec_definition.rs new file mode 100644 index 0000000000..2b79841b78 --- /dev/null +++ b/apollo-federation/src/link/requires_scopes_spec_definition.rs @@ -0,0 +1,173 @@ +use std::sync::LazyLock; + +use apollo_compiler::Name; +use apollo_compiler::ast::Type; +use apollo_compiler::name; +use apollo_compiler::schema::DirectiveLocation; + +use crate::link::Purpose; +use crate::link::spec::Identity; +use crate::link::spec::Url; +use crate::link::spec::Version; +use crate::link::spec_definition::SpecDefinition; +use crate::link::spec_definition::SpecDefinitions; +use crate::schema::argument_composition_strategies::ArgumentCompositionStrategy; +use crate::schema::type_and_directive_specification::ArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveSpecification; +use crate::schema::type_and_directive_specification::ScalarTypeSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; + +pub(crate) const REQUIRES_SCOPES_DIRECTIVE_NAME_IN_SPEC: Name = name!("requiresScopes"); +pub(crate) const REQUIRES_SCOPES_SCOPE_TYPE_NAME_IN_SPEC: Name = name!("Scope"); +pub(crate) const REQUIRES_SCOPES_SCOPES_ARGUMENT_NAME: Name = name!("scopes"); + +#[derive(Clone)] +pub(crate) struct RequiresScopesSpecDefinition { + url: Url, + minimum_federation_version: Version, +} + +impl RequiresScopesSpecDefinition { + pub(crate) fn new(version: Version, minimum_federation_version: Version) -> Self { + Self { + url: Url { + identity: Identity::requires_scopes_identity(), + version, + }, + minimum_federation_version, + } + } + + fn directive_specification(&self) -> Box { + Box::new(DirectiveSpecification::new( + REQUIRES_SCOPES_DIRECTIVE_NAME_IN_SPEC, + &[DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: REQUIRES_SCOPES_SCOPES_ARGUMENT_NAME, + get_type: |_schema, link| { + let scope_type_name = link + .map(|l| { + l.type_name_in_schema(&REQUIRES_SCOPES_SCOPE_TYPE_NAME_IN_SPEC) + }) + .unwrap_or(REQUIRES_SCOPES_SCOPE_TYPE_NAME_IN_SPEC); + // The type is [[Scope!]!]! + Ok(Type::NonNullList(Box::new(Type::NonNullList(Box::new( + Type::NonNullNamed(scope_type_name), + ))))) + }, + default_value: None, + }, + composition_strategy: Some(ArgumentCompositionStrategy::Union), + }], + false, // not repeatable + &[ + DirectiveLocation::FieldDefinition, + DirectiveLocation::Object, + DirectiveLocation::Interface, + DirectiveLocation::Scalar, + DirectiveLocation::Enum, + ], + true, // composes + Some(&|v| REQUIRES_SCOPES_VERSIONS.get_dyn_minimum_required_version(v)), + None, + )) + } + + fn scalar_type_specification(&self) -> Box { + Box::new(ScalarTypeSpecification { + name: REQUIRES_SCOPES_SCOPE_TYPE_NAME_IN_SPEC, + }) + } +} + +impl SpecDefinition for RequiresScopesSpecDefinition { + fn url(&self) -> &Url { + &self.url + } + + fn directive_specs(&self) -> Vec> { + vec![self.directive_specification()] + } + + fn type_specs(&self) -> Vec> { + vec![self.scalar_type_specification()] + } + + fn minimum_federation_version(&self) -> &Version { + &self.minimum_federation_version + } + + fn purpose(&self) -> Option { + Some(Purpose::SECURITY) + } +} + +pub(crate) static REQUIRES_SCOPES_VERSIONS: LazyLock< + SpecDefinitions, +> = LazyLock::new(|| { + let mut definitions = SpecDefinitions::new(Identity::requires_scopes_identity()); + definitions.add(RequiresScopesSpecDefinition::new( + Version { major: 0, minor: 1 }, + Version { major: 2, minor: 5 }, + )); + definitions +}); + +#[cfg(test)] +mod test { + use itertools::Itertools; + + use crate::schema::FederationSchema; + use crate::subgraph::test_utils::BuildOption; + use crate::subgraph::test_utils::build_inner_expanded; + + fn trivial_schema() -> FederationSchema { + build_inner_expanded("type Query { hello: String }", BuildOption::AsFed2) + .unwrap() + .schema() + .to_owned() + } + + fn requires_scopes_spec_directives_snapshot(schema: &FederationSchema) -> String { + schema + .schema() + .directive_definitions + .iter() + .filter_map(|(name, def)| { + if name.as_str().starts_with("requiresScopes") { + Some(def.to_string()) + } else { + None + } + }) + .join("\n") + } + + fn requires_scopes_spec_types_snapshot(schema: &FederationSchema) -> String { + schema + .schema() + .types + .iter() + .filter_map(|(name, ty)| { + if name.as_str().ends_with("__Scope") { + Some(ty.to_string()) + } else { + None + } + }) + .join("\n") + } + + #[test] + fn requires_scopes_spec_v0_1_definitions() { + let schema = trivial_schema(); + let types_snapshot = requires_scopes_spec_types_snapshot(&schema); + let expected_types = r#"scalar federation__Scope"#; + assert_eq!(types_snapshot.trim(), expected_types.trim()); + + let directives_snapshot: String = requires_scopes_spec_directives_snapshot(&schema); + let expected_directives = r#"directive @requiresScopes(scopes: [[federation__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM"#; + assert_eq!(directives_snapshot.trim(), expected_directives.trim()); + } +} diff --git a/apollo-federation/src/link/spec.rs b/apollo-federation/src/link/spec.rs index 5c1386644b..9caf1c841a 100644 --- a/apollo-federation/src/link/spec.rs +++ b/apollo-federation/src/link/spec.rs @@ -2,8 +2,8 @@ use std::fmt; use std::str; -use apollo_compiler::name; use apollo_compiler::Name; +use apollo_compiler::name; use thiserror::Error; use crate::error::FederationError; @@ -95,10 +95,59 @@ impl Identity { name: name!("cost"), } } + + pub fn context_identity() -> Identity { + Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: name!("context"), + } + } + + pub fn tag_identity() -> Identity { + Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: name!("tag"), + } + } + + pub fn requires_scopes_identity() -> Identity { + Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: name!("requiresScopes"), + } + } + + pub fn authenticated_identity() -> Identity { + Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: name!("authenticated"), + } + } + + pub fn policy_identity() -> Identity { + Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: name!("policy"), + } + } + + pub fn connect_identity() -> Identity { + Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: name!("connect"), + } + } + + pub fn cache_tag_identity() -> Identity { + Identity { + domain: APOLLO_SPEC_DOMAIN.to_string(), + name: name!("cacheTag"), + } + } } /// The version of a `@link` specification, in the form of a major and minor version numbers. -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)] pub struct Version { /// The major number part of the version. pub major: u32, @@ -126,10 +175,10 @@ impl str::FromStr for Version { ))?; let major = major.parse::().map_err(|_| { - SpecError::ParseError(format!("invalid major version number '{}'", major)) + SpecError::ParseError(format!("invalid major version number '{major}'")) })?; let minor = minor.parse::().map_err(|_| { - SpecError::ParseError(format!("invalid minor version number '{}'", minor)) + SpecError::ParseError(format!("invalid minor version number '{minor}'")) })?; Ok(Version { major, minor }) @@ -174,7 +223,7 @@ impl Version { } /// A `@link` specification url, which identifies a specific version of a specification. -#[derive(Clone, PartialEq, Eq, Debug)] +#[derive(Clone, PartialEq, Eq, Debug, Hash)] pub struct Url { /// The identity of the `@link` specification pointed by this url. pub identity: Identity, @@ -236,7 +285,7 @@ impl str::FromStr for Url { ))?; let path_remainder = segments.collect::>(); let domain = if path_remainder.is_empty() { - format!("{}://{}", scheme, url_domain) + format!("{scheme}://{url_domain}") } else { format!("{}://{}/{}", scheme, url_domain, path_remainder.join("/")) }; @@ -246,8 +295,7 @@ impl str::FromStr for Url { }) } Err(e) => Err(SpecError::ParseError(format!( - "invalid specification url: {}", - e + "invalid specification url: {e}" ))), } } diff --git a/apollo-federation/src/link/spec_definition.rs b/apollo-federation/src/link/spec_definition.rs index 1fb084afe5..66e65275ca 100644 --- a/apollo-federation/src/link/spec_definition.rs +++ b/apollo-federation/src/link/spec_definition.rs @@ -1,23 +1,50 @@ -use std::collections::btree_map::Keys; use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::collections::btree_map::Keys; use std::sync::Arc; +use std::sync::LazyLock; -use apollo_compiler::schema::DirectiveDefinition; -use apollo_compiler::schema::ExtendedType; use apollo_compiler::Name; use apollo_compiler::Node; +use apollo_compiler::schema::DirectiveDefinition; +use apollo_compiler::schema::ExtendedType; +use crate::AUTHENTICATED_VERSIONS; +use crate::CACHE_TAG_VERSIONS; +use crate::CONTEXT_VERSIONS; +use crate::COST_VERSIONS; +use crate::INACCESSIBLE_VERSIONS; +use crate::POLICY_VERSIONS; +use crate::REQUIRES_SCOPES_VERSIONS; +use crate::TAG_VERSIONS; +use crate::connectors::spec::CONNECT_VERSIONS; +use crate::ensure; use crate::error::FederationError; +use crate::error::MultipleFederationErrors; use crate::error::SingleFederationError; +use crate::link::Import; +use crate::link::Link; +use crate::link::Purpose; use crate::link::spec::Identity; use crate::link::spec::Url; use crate::link::spec::Version; -use crate::link::Link; use crate::schema::FederationSchema; +use crate::schema::type_and_directive_specification::DirectiveCompositionSpecification; +use crate::schema::type_and_directive_specification::DirectiveSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; +#[allow(dead_code)] pub(crate) trait SpecDefinition { fn url(&self) -> &Url; - fn minimum_federation_version(&self) -> Option<&Version>; + + fn directive_specs(&self) -> Vec>; + + fn type_specs(&self) -> Vec>; + + fn minimum_federation_version(&self) -> &Version; + + fn purpose(&self) -> Option; fn identity(&self) -> &Identity { &self.url().identity @@ -27,23 +54,6 @@ pub(crate) trait SpecDefinition { &self.url().version } - fn is_spec_directive_name( - &self, - schema: &FederationSchema, - name_in_schema: &Name, - ) -> Result { - let Some(metadata) = schema.metadata() else { - return Err(SingleFederationError::Internal { - message: "Schema is not a core schema (add @link first)".to_owned(), - } - .into()); - }; - Ok(metadata - .source_link_of_directive(name_in_schema) - .map(|e| e.link.url.identity == *self.identity()) - .unwrap_or(false)) - } - fn is_spec_type_name( &self, schema: &FederationSchema, @@ -96,8 +106,7 @@ pub(crate) trait SpecDefinition { .ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Unexpectedly could not find spec directive \"@{}\" in schema", - name + "Unexpectedly could not find spec directive \"@{name}\" in schema" ), } .into() @@ -120,8 +129,7 @@ pub(crate) trait SpecDefinition { .ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Unexpectedly could not find spec type \"{}\" in schema", - name + "Unexpectedly could not find spec type \"{name}\" in schema" ), } .into() @@ -136,10 +144,7 @@ pub(crate) trait SpecDefinition { schema: &FederationSchema, ) -> Result>, FederationError> { let Some(metadata) = schema.metadata() else { - return Err(SingleFederationError::Internal { - message: "Schema is not a core schema (add @link first)".to_owned(), - } - .into()); + return Ok(None); }; Ok(metadata.for_identity(self.identity())) } @@ -147,6 +152,35 @@ pub(crate) trait SpecDefinition { fn to_string(&self) -> String { self.url().to_string() } + + fn add_elements_to_schema(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { + let link = self.link_in_schema(schema)?; + ensure!( + link.is_some(), + "The {self_url} specification should have been added to the schema before this is called", + self_url = self.url() + ); + let mut errors = MultipleFederationErrors { errors: vec![] }; + for type_spec in self.type_specs() { + if let Err(err) = type_spec.check_or_add(schema, link.as_ref()) { + errors.push(err); + } + } + + for directive_spec in self.directive_specs() { + if let Err(err) = directive_spec.check_or_add(schema, link.as_ref()) { + errors.push(err); + } + } + + if errors.errors.len() > 1 { + Err(FederationError::MultipleFederationErrors(errors)) + } else if let Some(error) = errors.errors.pop() { + Err(FederationError::SingleFederationError(error)) + } else { + Ok(()) + } + } } #[derive(Clone)] @@ -182,18 +216,103 @@ impl SpecDefinitions { self.definitions.get(requested) } - pub(crate) fn find_for_federation_version(&self, federation_version: &Version) -> Option<&T> { - for definition in self.definitions.values() { - if let Some(minimum_federation_version) = definition.minimum_federation_version() { - if minimum_federation_version >= federation_version { - return Some(definition); - } - } + pub(crate) fn versions(&self) -> Keys<'_, Version, T> { + self.definitions.keys() + } + + pub(crate) fn latest(&self) -> &T { + self.definitions + .last_key_value() + .expect("There should always be at least one version defined") + .1 + } + + pub(crate) fn iter(&self) -> impl Iterator { + self.definitions.iter() + } + + pub(crate) fn get_minimum_required_version( + &'static self, + federation_version: &Version, + ) -> Option<&'static T> { + self.definitions + .values() + .find(|spec| federation_version.satisfies(spec.minimum_federation_version())) + } + + pub(crate) fn get_dyn_minimum_required_version( + &'static self, + federation_version: &Version, + ) -> Option<&'static dyn SpecDefinition> { + self.get_minimum_required_version(federation_version) + .map(|spec| spec as &dyn SpecDefinition) + } +} + +pub(crate) struct SpecRegistry { + definitions_by_url: HashMap, + available_versions_by_identity: HashMap>, +} + +impl SpecRegistry { + pub(crate) fn new() -> Self { + Self { + definitions_by_url: HashMap::new(), + available_versions_by_identity: HashMap::new(), } - None } - pub(crate) fn versions(&self) -> Keys { - self.definitions.keys() + pub(crate) fn extend( + &mut self, + definitions: &'static SpecDefinitions, + ) { + for (v, spec) in definitions.iter() { + self.definitions_by_url.insert(spec.url().clone(), spec); + self.available_versions_by_identity + .entry(spec.url().identity.clone()) + .or_default() + .insert(v.clone()); + } + } + + pub(crate) fn get_definition(&self, url: &Url) -> Option<&&(dyn SpecDefinition + Sync)> { + self.definitions_by_url.get(url) + } + + pub(crate) fn get_versions(&self, identity: &Identity) -> Option<&BTreeSet> { + self.available_versions_by_identity.get(identity) + } + + /// Generates the composition spec for an imported directive. Currently, this generates the + /// entire spec, then loops over available directive specifications and clones the applicable + /// directive. An alternative would be to mark everything as `Sync` and store them on the + /// individual feature specs, but we have omitted this for now due to a non-trivial (~10%) + /// increase in heap usage that affects query planning. + #[allow(dead_code)] + pub(crate) fn get_composition_spec( + &self, + source: &Link, + directive_import: &Import, + ) -> Option { + let specs = self.get_definition(&source.url)?.directive_specs(); + let spec = specs + .iter() + .find(|s| *s.name() == directive_import.element)?; + let directive_spec: DirectiveSpecification = spec.as_any().downcast_ref().cloned()?; + directive_spec.composition } } + +pub(crate) static SPEC_REGISTRY: LazyLock = LazyLock::new(|| { + let mut registry = SpecRegistry::new(); + registry.extend(&AUTHENTICATED_VERSIONS); + registry.extend(&CACHE_TAG_VERSIONS); + registry.extend(&CONNECT_VERSIONS); + registry.extend(&CONTEXT_VERSIONS); + registry.extend(&COST_VERSIONS); + registry.extend(&INACCESSIBLE_VERSIONS); + registry.extend(&POLICY_VERSIONS); + registry.extend(&REQUIRES_SCOPES_VERSIONS); + registry.extend(&TAG_VERSIONS); + registry +}); diff --git a/apollo-federation/src/link/tag_spec_definition.rs b/apollo-federation/src/link/tag_spec_definition.rs new file mode 100644 index 0000000000..7569095b07 --- /dev/null +++ b/apollo-federation/src/link/tag_spec_definition.rs @@ -0,0 +1,118 @@ +use std::sync::LazyLock; + +use apollo_compiler::name; +use apollo_compiler::schema::DirectiveLocation; + +use crate::link::Purpose; +use crate::link::federation_spec_definition::FEDERATION_TAG_DIRECTIVE_NAME_IN_SPEC; +use crate::link::spec::Identity; +use crate::link::spec::Url; +use crate::link::spec::Version; +use crate::link::spec_definition::SpecDefinition; +use crate::link::spec_definition::SpecDefinitions; +use crate::schema::type_and_directive_specification::ArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveArgumentSpecification; +use crate::schema::type_and_directive_specification::DirectiveSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; + +pub(crate) struct TagSpecDefinition { + url: Url, + minimum_federation_version: Version, +} + +impl TagSpecDefinition { + pub(crate) fn new(version: Version, minimum_federation_version: Version) -> Self { + Self { + url: Url { + identity: Identity::tag_identity(), + version, + }, + minimum_federation_version, + } + } + + fn directive_locations(&self) -> Vec { + // v0.1: FIELD_DEFINITION, OBJECT, INTERFACE, UNION + // v0.2: + ARGUMENT_DEFINITION, SCALAR, ENUM, ENUM_VALUE, INPUT_OBJECT, INPUT_FIELD_DEFINITION + // v0.3+: + SCHEMA + let mut locations = vec![ + DirectiveLocation::FieldDefinition, + DirectiveLocation::Object, + DirectiveLocation::Interface, + DirectiveLocation::Union, + ]; + if self.url.version != (Version { major: 0, minor: 1 }) { + locations.extend([ + DirectiveLocation::ArgumentDefinition, + DirectiveLocation::Scalar, + DirectiveLocation::Enum, + DirectiveLocation::EnumValue, + DirectiveLocation::InputObject, + DirectiveLocation::InputFieldDefinition, + ]); + if self.url.version != (Version { major: 0, minor: 2 }) { + locations.push(DirectiveLocation::Schema); + } + } + locations + } + + fn directive_specification(&self) -> Box { + Box::new(DirectiveSpecification::new( + FEDERATION_TAG_DIRECTIVE_NAME_IN_SPEC, + &[DirectiveArgumentSpecification { + base_spec: ArgumentSpecification { + name: name!("name"), + get_type: |_, _| Ok(apollo_compiler::ty!(String!)), + default_value: None, + }, + composition_strategy: None, + }], + true, // repeatable + &self.directive_locations(), + true, // composes + Some(&|v| TAG_VERSIONS.get_dyn_minimum_required_version(v)), + None, + )) + } +} + +impl SpecDefinition for TagSpecDefinition { + fn url(&self) -> &Url { + &self.url + } + + fn directive_specs(&self) -> Vec> { + vec![self.directive_specification()] + } + + fn type_specs(&self) -> Vec> { + vec![] + } + + fn minimum_federation_version(&self) -> &Version { + &self.minimum_federation_version + } + + fn purpose(&self) -> Option { + None + } +} + +pub(crate) static TAG_VERSIONS: LazyLock> = + LazyLock::new(|| { + let mut definitions = SpecDefinitions::new(Identity::tag_identity()); + definitions.add(TagSpecDefinition::new( + Version { major: 0, minor: 1 }, + Version { major: 1, minor: 0 }, + )); + definitions.add(TagSpecDefinition::new( + Version { major: 0, minor: 2 }, + Version { major: 1, minor: 0 }, + )); + definitions.add(TagSpecDefinition::new( + Version { major: 0, minor: 3 }, + Version { major: 2, minor: 0 }, + )); + definitions + }); diff --git a/apollo-federation/src/merge.rs b/apollo-federation/src/merge.rs index 231fb973b0..dd69ba15e9 100644 --- a/apollo-federation/src/merge.rs +++ b/apollo-federation/src/merge.rs @@ -1,8 +1,13 @@ +mod fields; + use std::fmt::Debug; use std::fmt::Formatter; use std::iter; use std::sync::Arc; +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::Schema; use apollo_compiler::ast::Argument; use apollo_compiler::ast::Directive; use apollo_compiler::ast::DirectiveDefinition; @@ -11,13 +16,16 @@ use apollo_compiler::ast::DirectiveLocation; use apollo_compiler::ast::EnumValueDefinition; use apollo_compiler::ast::FieldDefinition; use apollo_compiler::ast::NamedType; +use apollo_compiler::ast::Type; use apollo_compiler::ast::Value; +use apollo_compiler::collections::HashMap; use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use apollo_compiler::name; use apollo_compiler::schema::Component; use apollo_compiler::schema::EnumType; use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::Implementers; use apollo_compiler::schema::InputObjectType; use apollo_compiler::schema::InputValueDefinition; use apollo_compiler::schema::InterfaceType; @@ -26,15 +34,15 @@ use apollo_compiler::schema::ScalarType; use apollo_compiler::schema::UnionType; use apollo_compiler::ty; use apollo_compiler::validation::Valid; -use apollo_compiler::Name; -use apollo_compiler::Node; -use apollo_compiler::Schema; use indexmap::map::Entry::Occupied; use indexmap::map::Entry::Vacant; use indexmap::map::Iter; use itertools::Itertools; +use crate::ValidFederationSubgraph; +use crate::ValidFederationSubgraphs; use crate::error::FederationError; +use crate::link::LinksMetadata; use crate::link::federation_spec_definition::FEDERATION_EXTERNAL_DIRECTIVE_NAME_IN_SPEC; use crate::link::federation_spec_definition::FEDERATION_FIELDS_ARGUMENT_NAME; use crate::link::federation_spec_definition::FEDERATION_FROM_ARGUMENT_NAME; @@ -44,17 +52,15 @@ use crate::link::federation_spec_definition::FEDERATION_OVERRIDE_DIRECTIVE_NAME_ use crate::link::federation_spec_definition::FEDERATION_OVERRIDE_LABEL_ARGUMENT_NAME; use crate::link::federation_spec_definition::FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC; use crate::link::federation_spec_definition::FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC; -use crate::link::inaccessible_spec_definition::InaccessibleSpecDefinition; use crate::link::inaccessible_spec_definition::INACCESSIBLE_DIRECTIVE_NAME_IN_SPEC; +use crate::link::inaccessible_spec_definition::InaccessibleSpecDefinition; +use crate::link::join_spec_definition::EnumValue; use crate::link::join_spec_definition::JOIN_OVERRIDE_LABEL_ARGUMENT_NAME; use crate::link::spec::Identity; use crate::link::spec::Version; use crate::link::spec_definition::SpecDefinition; -use crate::link::LinksMetadata; use crate::schema::ValidFederationSchema; use crate::subgraph::ValidSubgraph; -use crate::ValidFederationSubgraph; -use crate::ValidFederationSubgraphs; type MergeWarning = String; type MergeError = String; @@ -63,6 +69,7 @@ struct Merger { errors: Vec, composition_hints: Vec, needs_inaccessible: bool, + interface_objects: IndexSet, } pub struct MergeSuccess { @@ -85,7 +92,7 @@ impl From for MergeFailure { } pub struct MergeFailure { - pub schema: Option, + pub schema: Option>, pub errors: Vec, pub composition_hints: Vec, } @@ -125,6 +132,7 @@ impl Merger { composition_hints: Vec::new(), errors: Vec::new(), needs_inaccessible: false, + interface_objects: IndexSet::default(), } } @@ -134,17 +142,26 @@ impl Merger { .map(|(_, subgraph)| subgraph) .collect_vec(); subgraphs.sort_by(|s1, s2| s1.name.cmp(&s2.name)); - let mut subgraphs_and_enum_values: Vec<(&ValidFederationSubgraph, Name)> = Vec::new(); + let mut subgraphs_and_enum_values = Vec::new(); + let mut enum_values = IndexSet::default(); for subgraph in &subgraphs { - // TODO: Implement JS codebase's name transform (which always generates a valid GraphQL - // name and avoids collisions). - if let Ok(subgraph_name) = Name::new(&subgraph.name.to_uppercase()) { - subgraphs_and_enum_values.push((subgraph, subgraph_name)); + let enum_value = match EnumValue::new(&subgraph.name) { + Ok(enum_value) => enum_value, + Err(err) => { + self.errors.push(err); + continue; + } + }; + + // Ensure that enum values are unique after normalizing them + let enum_value = if enum_values.contains(&enum_value.to_string()) { + EnumValue::new(&format!("{}_{}", subgraph.name, enum_values.len())) + .expect("adding a suffix always works") } else { - self.errors.push(String::from( - "Subgraph name couldn't be transformed into valid GraphQL name", - )); - } + enum_value + }; + enum_values.insert(enum_value.to_string()); + subgraphs_and_enum_values.push((subgraph, enum_value)) } if !self.errors.is_empty() { return Err(MergeFailure { @@ -185,35 +202,35 @@ impl Merger { ExtendedType::Enum(value) => self.merge_enum_type( &mut supergraph.types, &relevant_directives, - subgraph_name.clone(), + subgraph_name, type_name.clone(), value, ), ExtendedType::InputObject(value) => self.merge_input_object_type( &mut supergraph.types, &relevant_directives, - subgraph_name.clone(), + subgraph_name, type_name.clone(), value, ), ExtendedType::Interface(value) => self.merge_interface_type( &mut supergraph.types, &relevant_directives, - subgraph_name.clone(), + subgraph_name, type_name.clone(), value, ), ExtendedType::Object(value) => self.merge_object_type( &mut supergraph.types, &relevant_directives, - subgraph_name.clone(), + subgraph_name, type_name.clone(), value, ), ExtendedType::Union(value) => self.merge_union_type( &mut supergraph.types, &relevant_directives, - subgraph_name.clone(), + subgraph_name, type_name.clone(), value, ), @@ -239,6 +256,9 @@ impl Merger { } } + let implementers_map = supergraph.implementers_map(); + self.add_interface_object_fields(&mut supergraph.types, implementers_map)?; + if self.needs_inaccessible { add_core_feature_inaccessible(&mut supergraph); } @@ -252,13 +272,60 @@ impl Merger { }) } else { Err(MergeFailure { - schema: Some(supergraph), + schema: Some(Box::new(supergraph)), composition_hints: self.composition_hints.to_owned(), errors: self.errors.to_owned(), }) } } + fn add_interface_object_fields( + &mut self, + types: &mut IndexMap, + implementers_map: HashMap, + ) -> Result<(), MergeFailure> { + for interface_object_name in self.interface_objects.iter() { + let Some(ExtendedType::Interface(intf_def)) = types.get(interface_object_name) else { + return Err(MergeFailure { + schema: None, + composition_hints: self.composition_hints.to_owned(), + errors: vec![format!("Interface {} not found", interface_object_name)], + }); + }; + let fields = intf_def.fields.clone(); + + if let Some(implementers) = implementers_map.get(interface_object_name) { + for implementer in implementers.iter() { + types.entry(implementer.clone()).and_modify(|f| { + if let ExtendedType::Object(obj) = f { + let obj = obj.make_mut(); + for (field_name, field_def) in fields.iter() { + let mut field_def = field_def.clone(); + let field_def = field_def.make_mut(); + field_def.directives = field_def + .directives + .iter() + .filter(|d| d.name != name!("join__field")) + .cloned() + .collect(); + field_def.directives.push(Node::new(Directive { + name: name!("join__field"), + arguments: vec![], + })); + + obj.fields + .entry(field_name.clone()) + .or_insert(field_def.clone().into()); + } + } + }); + } + }; + } + + Ok(()) + } + fn merge_descriptions(&mut self, merged: &mut Option, new: &Option) { match (&mut *merged, new) { (_, None) => {} @@ -298,7 +365,7 @@ impl Merger { &mut self, types: &mut IndexMap, metadata: &DirectiveNames, - subgraph_name: Name, + subgraph_name: &EnumValue, enum_name: NamedType, enum_type: &Node, ) { @@ -344,7 +411,7 @@ impl Merger { arguments: vec![ (Node::new(Argument { name: name!("graph"), - value: Node::new(Value::Enum(subgraph_name.clone())), + value: Node::new(Value::Enum(subgraph_name.to_name())), })), ], })); @@ -358,7 +425,7 @@ impl Merger { &mut self, types: &mut IndexMap, directive_names: &DirectiveNames, - subgraph_name: Name, + subgraph_name: &EnumValue, input_object_name: NamedType, input_object: &Node, ) { @@ -368,7 +435,7 @@ impl Merger { if let ExtendedType::InputObject(obj) = existing_type { let join_type_directives = - join_type_applied_directive(subgraph_name, iter::empty(), false); + join_type_applied_directive(subgraph_name.clone(), iter::empty(), false); let mutable_object = obj.make_mut(); mutable_object.directives.extend(join_type_directives); @@ -381,23 +448,42 @@ impl Merger { for (field_name, field) in input_object.fields.iter() { let existing_field = mutable_object.fields.entry(field_name.clone()); - match existing_field { - Vacant(_i) => { - // TODO warning - mismatch on input fields - } - Occupied(mut i) => { - self.add_inaccessible( - directive_names, - &mut i.get_mut().make_mut().directives, - &field.directives, - ); + let supergraph_field = match existing_field { + Vacant(i) => i.insert(Component::new(InputValueDefinition { + name: field.name.clone(), + description: field.description.clone(), + ty: field.ty.clone(), + default_value: field.default_value.clone(), + directives: Default::default(), + })), + Occupied(i) => { + i.into_mut() // merge_options(&i.get_mut().description, &field.description); // TODO check description // TODO check type // TODO check default value // TODO process directives } - } + }; + + self.add_inaccessible( + directive_names, + &mut supergraph_field.make_mut().directives, + &field.directives, + ); + + let join_field_directive = join_field_applied_directive( + subgraph_name, + None, + None, + false, + None, + Some(&field.ty), + ); + supergraph_field + .make_mut() + .directives + .push(Node::new(join_field_directive)); } } else { // TODO conflict on type @@ -408,7 +494,7 @@ impl Merger { &mut self, types: &mut IndexMap, directive_names: &DirectiveNames, - subgraph_name: Name, + subgraph_name: &EnumValue, interface_name: NamedType, interface: &Node, ) { @@ -419,7 +505,7 @@ impl Merger { if let ExtendedType::Interface(intf) = existing_type { let key_directives = interface.directives.get_all(&directive_names.key); let join_type_directives = - join_type_applied_directive(subgraph_name, key_directives, false); + join_type_applied_directive(subgraph_name.clone(), key_directives, false); let mutable_intf = intf.make_mut(); mutable_intf.directives.extend(join_type_directives); @@ -429,32 +515,69 @@ impl Merger { &interface.directives, ); + interface + .implements_interfaces + .iter() + .for_each(|intf_name| { + // IndexSet::insert deduplicates + mutable_intf.implements_interfaces.insert(intf_name.clone()); + let join_implements_directive = + join_implements_applied_directive(subgraph_name.clone(), intf_name); + mutable_intf.directives.push(join_implements_directive); + }); + for (field_name, field) in interface.fields.iter() { let existing_field = mutable_intf.fields.entry(field_name.clone()); - match existing_field { - Vacant(i) => { + let supergraph_field = match existing_field { + Occupied(f) => { + f.into_mut() + // TODO check description + // TODO check type + // TODO check default value + // TODO process directives + } + Vacant(f) => { // TODO warning mismatch missing fields - let f = i.insert(Component::new(FieldDefinition { + f.insert(Component::new(FieldDefinition { name: field.name.clone(), description: field.description.clone(), arguments: vec![], ty: field.ty.clone(), directives: Default::default(), - })); - - self.add_inaccessible( - directive_names, - &mut f.make_mut().directives, - &field.directives, - ); - } - Occupied(_i) => { - // TODO check description - // TODO check type - // TODO check default value - // TODO process directives + })) } - } + }; + + fields::merge_arguments( + field.arguments.iter(), + &mut supergraph_field.make_mut().arguments, + self, + directive_names, + ); + self.merge_descriptions( + &mut supergraph_field.make_mut().description, + &field.description, + ); + + self.add_inaccessible( + directive_names, + &mut supergraph_field.make_mut().directives, + &field.directives, + ); + + let join_field_directive = join_field_applied_directive( + subgraph_name, + None, + None, + false, + None, + Some(&field.ty), + ); + + supergraph_field + .make_mut() + .directives + .push(Node::new(join_field_directive)); } } else { // TODO conflict on type @@ -465,7 +588,7 @@ impl Merger { &mut self, types: &mut IndexMap, directive_names: &DirectiveNames, - subgraph_name: Name, + subgraph_name: &EnumValue, object_name: NamedType, object: &Node, ) { @@ -473,7 +596,7 @@ impl Merger { let existing_type = types .entry(object_name.clone()) .or_insert(copy_object_type_stub( - object_name.clone(), + object_name, object, is_interface_object, )); @@ -533,35 +656,12 @@ impl Merger { &field.directives, ); - for arg in field.arguments.iter() { - let arguments_to_merge = &mut supergraph_field.make_mut().arguments; - let argument_to_merge = arguments_to_merge - .iter_mut() - .find_map(|a| (a.name == arg.name).then(|| a.make_mut())); - - if let Some(argument) = argument_to_merge { - self.add_inaccessible( - directive_names, - &mut argument.directives, - &arg.directives, - ); - } else { - let mut argument = InputValueDefinition { - name: arg.name.clone(), - description: arg.description.clone(), - directives: Default::default(), - ty: arg.ty.clone(), - default_value: arg.default_value.clone(), - }; - - self.add_inaccessible( - directive_names, - &mut argument.directives, - &arg.directives, - ); - arguments_to_merge.push(argument.into()); - }; - } + fields::merge_arguments( + field.arguments.iter(), + &mut supergraph_field.make_mut().arguments, + self, + directive_names, + ); let requires_directive_option = field .directives @@ -594,11 +694,12 @@ impl Merger { .is_some(); let join_field_directive = join_field_applied_directive( - subgraph_name.clone(), + subgraph_name, requires_directive_option, provides_directive_option, external_field, overrides_directive_option, + Some(&field.ty), ); supergraph_field @@ -610,11 +711,106 @@ impl Merger { // https://github.com/apollographql/federation/blob/0d8a88585d901dff6844fdce1146a4539dec48df/composition-js/src/merging/merge.ts#L1648 } } else if let ExtendedType::Interface(intf) = existing_type { - // TODO support interface object + self.interface_objects.insert(intf.name.clone()); + let key_directives = object.directives.get_all(&directive_names.key); let join_type_directives = - join_type_applied_directive(subgraph_name, key_directives, true); - intf.make_mut().directives.extend(join_type_directives); + join_type_applied_directive(subgraph_name.clone(), key_directives, true); + let mutable_object = intf.make_mut(); + mutable_object.directives.extend(join_type_directives); + self.merge_descriptions(&mut mutable_object.description, &object.description); + self.add_inaccessible( + directive_names, + &mut mutable_object.directives, + &object.directives, + ); + + for (field_name, field) in object.fields.iter() { + // skip federation built-in queries + if field_name == "_service" || field_name == "_entities" { + continue; + } + + let existing_field = mutable_object.fields.entry(field_name.clone()); + let supergraph_field = match existing_field { + Occupied(f) => { + // check description + // check type + // check args + f.into_mut() + } + Vacant(f) => f.insert(Component::new(FieldDefinition { + name: field.name.clone(), + description: field.description.clone(), + arguments: vec![], + directives: Default::default(), + ty: field.ty.clone(), + })), + }; + self.merge_descriptions( + &mut supergraph_field.make_mut().description, + &field.description, + ); + + self.add_inaccessible( + directive_names, + &mut supergraph_field.make_mut().directives, + &field.directives, + ); + + fields::merge_arguments( + field.arguments.iter(), + &mut supergraph_field.make_mut().arguments, + self, + directive_names, + ); + let requires_directive_option = field + .directives + .get_all(&directive_names.requires) + .next() + .and_then(|p| directive_string_arg_value(p, &FEDERATION_FIELDS_ARGUMENT_NAME)); + + let provides_directive_option = field + .directives + .get_all(&directive_names.provides) + .next() + .and_then(|p| directive_string_arg_value(p, &FEDERATION_FIELDS_ARGUMENT_NAME)); + + let overrides_directive_option = field + .directives + .get_all(&directive_names.r#override) + .next() + .and_then(|p| { + let overrides_from = + directive_string_arg_value(p, &FEDERATION_FROM_ARGUMENT_NAME); + let overrides_label = + directive_string_arg_value(p, &FEDERATION_OVERRIDE_LABEL_ARGUMENT_NAME); + overrides_from.map(|from| (from, overrides_label)) + }); + + let external_field = field + .directives + .get_all(&directive_names.external) + .next() + .is_some(); + + let join_field_directive = join_field_applied_directive( + subgraph_name, + requires_directive_option, + provides_directive_option, + external_field, + overrides_directive_option, + Some(&field.ty), + ); + + supergraph_field + .make_mut() + .directives + .push(Node::new(join_field_directive)); + + // TODO: implement needsJoinField to avoid adding join__field when unnecessary + // https://github.com/apollographql/federation/blob/0d8a88585d901dff6844fdce1146a4539dec48df/composition-js/src/merging/merge.ts#L1648 + } }; // TODO merge fields } @@ -623,14 +819,13 @@ impl Merger { &mut self, types: &mut IndexMap, directive_names: &DirectiveNames, - subgraph_name: Name, + subgraph_name: &EnumValue, union_name: NamedType, union: &Node, ) { - let existing_type = types.entry(union_name.clone()).or_insert(copy_union_type( - union_name.clone(), - union.description.clone(), - )); + let existing_type = types + .entry(union_name.clone()) + .or_insert(copy_union_type(union_name, union.description.clone())); if let ExtendedType::Union(u) = existing_type { let join_type_directives = @@ -650,7 +845,7 @@ impl Merger { arguments: vec![ Node::new(Argument { name: name!("graph"), - value: Node::new(Value::Enum(subgraph_name.clone())), + value: Node::new(Value::Enum(subgraph_name.to_name())), }), Node::new(Argument { name: name!("member"), @@ -666,7 +861,7 @@ impl Merger { &mut self, types: &mut IndexMap, directive_names: &DirectiveNames, - subgraph_name: Name, + subgraph_name: EnumValue, scalar_name: NamedType, ty: &Node, ) { @@ -676,7 +871,7 @@ impl Merger { if let ExtendedType::Scalar(s) = existing_type { let join_type_directives = - join_type_applied_directive(subgraph_name.clone(), iter::empty(), false); + join_type_applied_directive(subgraph_name, iter::empty(), false); s.make_mut().directives.extend(join_type_directives); self.add_inaccessible( directive_names, @@ -927,7 +1122,7 @@ fn copy_union_type(union_name: Name, description: Option>) -> Extended } fn join_type_applied_directive<'a>( - subgraph_name: Name, + subgraph_name: EnumValue, key_directives: impl Iterator> + Sized, is_interface_object: bool, ) -> Vec> { @@ -935,7 +1130,7 @@ fn join_type_applied_directive<'a>( name: name!("join__type"), arguments: vec![Node::new(Argument { name: name!("graph"), - value: Node::new(Value::Enum(subgraph_name)), + value: Node::new(Value::Enum(subgraph_name.into())), })], }; if is_interface_object { @@ -978,7 +1173,7 @@ fn join_type_applied_directive<'a>( } fn join_implements_applied_directive( - subgraph_name: Name, + subgraph_name: EnumValue, intf_name: &Name, ) -> Component { Component::new(Directive { @@ -986,7 +1181,7 @@ fn join_implements_applied_directive( arguments: vec![ Node::new(Argument { name: name!("graph"), - value: Node::new(Value::Enum(subgraph_name)), + value: Node::new(Value::Enum(subgraph_name.into())), }), Node::new(Argument { name: name!("interface"), @@ -1140,9 +1335,9 @@ fn link_purpose_enum_type() -> (Name, EnumType) { // TODO join spec fn add_core_feature_join( supergraph: &mut Schema, - subgraphs_and_enum_values: &Vec<(&ValidFederationSubgraph, Name)>, + subgraphs_and_enum_values: &Vec<(&ValidFederationSubgraph, EnumValue)>, ) { - // @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + // @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) supergraph .schema_definition .make_mut() @@ -1152,7 +1347,7 @@ fn add_core_feature_join( arguments: vec![ Node::new(Argument { name: name!("url"), - value: "https://specs.apollo.dev/join/v0.3".into(), + value: "https://specs.apollo.dev/join/v0.5".into(), }), Node::new(Argument { name: name!("for"), @@ -1172,6 +1367,77 @@ fn add_core_feature_join( .types .insert(join_field_set_name, join_field_set_scalar); + // scalar join__FieldValue + let join_field_value_name = name!("join__FieldValue"); + let join_field_value_scalar = ExtendedType::Scalar(Node::new(ScalarType { + directives: Default::default(), + name: join_field_value_name.clone(), + description: None, + })); + supergraph + .types + .insert(join_field_value_name, join_field_value_scalar); + + // input join__ContextArgument { + // name: String! + // type: String! + // context: String! + // selection: join__FieldValue! + // } + let join_context_argument_name = name!("join__ContextArgument"); + let join_context_argument_input = ExtendedType::InputObject(Node::new(InputObjectType { + description: None, + name: join_context_argument_name.clone(), + directives: Default::default(), + fields: vec![ + ( + name!("name"), + Component::new(InputValueDefinition { + name: name!("name"), + description: None, + directives: Default::default(), + ty: ty!(String!).into(), + default_value: None, + }), + ), + ( + name!("type"), + Component::new(InputValueDefinition { + name: name!("type"), + description: None, + directives: Default::default(), + ty: ty!(String!).into(), + default_value: None, + }), + ), + ( + name!("context"), + Component::new(InputValueDefinition { + name: name!("context"), + description: None, + directives: Default::default(), + ty: ty!(String!).into(), + default_value: None, + }), + ), + ( + name!("selection"), + Component::new(InputValueDefinition { + name: name!("selection"), + description: None, + directives: Default::default(), + ty: ty!(join__FieldValue!).into(), + default_value: None, + }), + ), + ] + .into_iter() + .collect(), + })); + supergraph + .types + .insert(join_context_argument_name, join_context_argument_input); + let join_graph_directive_definition = join_graph_directive_definition(); supergraph.directive_definitions.insert( join_graph_directive_definition.name.clone(), @@ -1208,6 +1474,24 @@ fn add_core_feature_join( Node::new(join_enum_value_directive_definition), ); + // scalar join__DirectiveArguments + let join_directive_arguments_name = name!("join__DirectiveArguments"); + let join_directive_arguments_scalar = ExtendedType::Scalar(Node::new(ScalarType { + directives: Default::default(), + name: join_directive_arguments_name.clone(), + description: None, + })); + supergraph.types.insert( + join_directive_arguments_name, + join_directive_arguments_scalar, + ); + + let join_directive_directive_definition = join_directive_directive_definition(); + supergraph.directive_definitions.insert( + join_directive_directive_definition.name.clone(), + Node::new(join_directive_directive_definition), + ); + let (name, join_graph_enum_type) = join_graph_enum_type(subgraphs_and_enum_values); supergraph.types.insert(name, join_graph_enum_type.into()); } @@ -1229,6 +1513,44 @@ fn join_enum_value_directive_definition() -> DirectiveDefinition { } } +/// directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION +fn join_directive_directive_definition() -> DirectiveDefinition { + DirectiveDefinition { + name: name!("join__directive"), + description: None, + arguments: vec![ + Node::new(InputValueDefinition { + name: name!("graphs"), + description: None, + directives: Default::default(), + ty: ty!([join__Graph!]).into(), + default_value: None, + }), + Node::new(InputValueDefinition { + name: name!("name"), + description: None, + directives: Default::default(), + ty: ty!(String!).into(), + default_value: None, + }), + Node::new(InputValueDefinition { + name: name!("args"), + description: None, + directives: Default::default(), + ty: ty!(join__DirectiveArguments!).into(), + default_value: None, + }), + ], + locations: vec![ + DirectiveLocation::Schema, + DirectiveLocation::Object, + DirectiveLocation::Interface, + DirectiveLocation::FieldDefinition, + ], + repeatable: true, + } +} + /// directive @field( /// graph: Graph, /// requires: FieldSet, @@ -1299,6 +1621,13 @@ fn join_field_directive_definition() -> DirectiveDefinition { ty: ty!(Boolean).into(), default_value: None, }), + Node::new(InputValueDefinition { + name: name!("contextArguments"), + description: None, + directives: Default::default(), + ty: ty!([join__ContextArgument!]).into(), + default_value: None, + }), ], locations: vec![ DirectiveLocation::FieldDefinition, @@ -1308,18 +1637,22 @@ fn join_field_directive_definition() -> DirectiveDefinition { } } +// NOTE: the logic for constructing the contextArguments argument +// is not trivial and is not implemented here. For connectors "expansion", +// it's handled in carryover.rs. fn join_field_applied_directive( - subgraph_name: Name, + subgraph_name: &EnumValue, requires: Option<&str>, provides: Option<&str>, external: bool, overrides: Option<(&str, Option<&str>)>, // from, label + r#type: Option<&Type>, ) -> Directive { let mut join_field_directive = Directive { name: name!("join__field"), arguments: vec![Node::new(Argument { name: name!("graph"), - value: Node::new(Value::Enum(subgraph_name)), + value: Node::new(Value::Enum(subgraph_name.to_name())), })], }; if let Some(required_fields) = requires { @@ -1352,6 +1685,12 @@ fn join_field_applied_directive( })); } } + if let Some(r#type) = r#type { + join_field_directive.arguments.push(Node::new(Argument { + name: name!("type"), + value: r#type.to_string().into(), + })); + } join_field_directive } @@ -1498,7 +1837,7 @@ fn join_union_member_directive_definition() -> DirectiveDefinition { /// enum Graph fn join_graph_enum_type( - subgraphs_and_enum_values: &Vec<(&ValidFederationSubgraph, Name)>, + subgraphs_and_enum_values: &Vec<(&ValidFederationSubgraph, EnumValue)>, ) -> (Name, EnumType) { let join_graph_enum_name = name!("join__Graph"); let mut join_graph_enum_type = EnumType { @@ -1524,7 +1863,7 @@ fn join_graph_enum_type( let graph = EnumValueDefinition { description: None, directives: DirectiveList(vec![Node::new(join_graph_applied_directive)]), - value: subgraph_name.clone(), + value: subgraph_name.to_name(), }; join_graph_enum_type .values @@ -1535,7 +1874,10 @@ fn join_graph_enum_type( fn add_core_feature_inaccessible(supergraph: &mut Schema) { // @link(url: "https://specs.apollo.dev/inaccessible/v0.2") - let spec = InaccessibleSpecDefinition::new(Version { major: 0, minor: 2 }, None); + let spec = InaccessibleSpecDefinition::new( + Version { major: 0, minor: 2 }, + Version { major: 2, minor: 0 }, + ); supergraph .schema_definition @@ -1588,145 +1930,4 @@ fn merge_directive( } #[cfg(test)] -mod tests { - use apollo_compiler::Schema; - use insta::assert_snapshot; - - use crate::merge::merge_federation_subgraphs; - use crate::schema::ValidFederationSchema; - use crate::ValidFederationSubgraph; - use crate::ValidFederationSubgraphs; - - #[test] - fn test_steel_thread() { - let one_sdl = - include_str!("./sources/connect/expand/merge/connector_Query_users_0.graphql"); - let two_sdl = include_str!("./sources/connect/expand/merge/connector_Query_user_0.graphql"); - let three_sdl = include_str!("./sources/connect/expand/merge/connector_User_d_1.graphql"); - let graphql_sdl = include_str!("./sources/connect/expand/merge/graphql.graphql"); - - let mut subgraphs = ValidFederationSubgraphs::new(); - subgraphs - .add(ValidFederationSubgraph { - name: "connector_Query_users_0".to_string(), - url: "".to_string(), - schema: ValidFederationSchema::new( - Schema::parse_and_validate(one_sdl, "./connector_Query_users_0.graphql") - .unwrap(), - ) - .unwrap(), - }) - .unwrap(); - subgraphs - .add(ValidFederationSubgraph { - name: "connector_Query_user_0".to_string(), - url: "".to_string(), - schema: ValidFederationSchema::new( - Schema::parse_and_validate(two_sdl, "./connector_Query_user_0.graphql") - .unwrap(), - ) - .unwrap(), - }) - .unwrap(); - subgraphs - .add(ValidFederationSubgraph { - name: "connector_User_d_1".to_string(), - url: "".to_string(), - schema: ValidFederationSchema::new( - Schema::parse_and_validate(three_sdl, "./connector_User_d_1.graphql").unwrap(), - ) - .unwrap(), - }) - .unwrap(); - subgraphs - .add(ValidFederationSubgraph { - name: "graphql".to_string(), - url: "".to_string(), - schema: ValidFederationSchema::new( - Schema::parse_and_validate(graphql_sdl, "./graphql.graphql").unwrap(), - ) - .unwrap(), - }) - .unwrap(); - - let result = merge_federation_subgraphs(subgraphs).unwrap(); - - let schema = result.schema.into_inner(); - let validation = schema.clone().validate(); - assert!(validation.is_ok(), "{:?}", validation); - - assert_snapshot!(schema.serialize()); - } - - #[test] - fn test_basic() { - let one_sdl = include_str!("./sources/connect/expand/merge/basic_1.graphql"); - let two_sdl = include_str!("./sources/connect/expand/merge/basic_2.graphql"); - - let mut subgraphs = ValidFederationSubgraphs::new(); - subgraphs - .add(ValidFederationSubgraph { - name: "basic_1".to_string(), - url: "".to_string(), - schema: ValidFederationSchema::new( - Schema::parse_and_validate(one_sdl, "./basic_1.graphql").unwrap(), - ) - .unwrap(), - }) - .unwrap(); - subgraphs - .add(ValidFederationSubgraph { - name: "basic_2".to_string(), - url: "".to_string(), - schema: ValidFederationSchema::new( - Schema::parse_and_validate(two_sdl, "./basic_2.graphql").unwrap(), - ) - .unwrap(), - }) - .unwrap(); - - let result = merge_federation_subgraphs(subgraphs).unwrap(); - - let schema = result.schema.into_inner(); - let validation = schema.clone().validate(); - assert!(validation.is_ok(), "{:?}", validation); - - assert_snapshot!(schema.serialize()); - } - - #[test] - fn test_inaccessible() { - let one_sdl = include_str!("./sources/connect/expand/merge/inaccessible.graphql"); - let two_sdl = include_str!("./sources/connect/expand/merge/inaccessible_2.graphql"); - - let mut subgraphs = ValidFederationSubgraphs::new(); - subgraphs - .add(ValidFederationSubgraph { - name: "inaccessible".to_string(), - url: "".to_string(), - schema: ValidFederationSchema::new( - Schema::parse_and_validate(one_sdl, "./inaccessible.graphql").unwrap(), - ) - .unwrap(), - }) - .unwrap(); - subgraphs - .add(ValidFederationSubgraph { - name: "inaccessible_2".to_string(), - url: "".to_string(), - schema: ValidFederationSchema::new( - Schema::parse_and_validate(two_sdl, "./inaccessible_2.graphql").unwrap(), - ) - .unwrap(), - }) - .unwrap(); - - let result = merge_federation_subgraphs(subgraphs).unwrap(); - - let schema = result.schema.into_inner(); - let validation = schema.clone().validate(); - assert!(validation.is_ok(), "{:?}", validation); - - assert_snapshot!(schema.serialize()); - } -} +mod tests; diff --git a/apollo-federation/src/merge/fields.rs b/apollo-federation/src/merge/fields.rs new file mode 100644 index 0000000000..acebed495d --- /dev/null +++ b/apollo-federation/src/merge/fields.rs @@ -0,0 +1,35 @@ +use std::slice::Iter; + +use apollo_compiler::Node; +use apollo_compiler::ast::InputValueDefinition; + +use crate::merge::DirectiveNames; +use crate::merge::Merger; + +pub(super) fn merge_arguments( + arguments: Iter>, + arguments_to_merge: &mut Vec>, + merger: &mut Merger, + directive_names: &DirectiveNames, +) { + for arg in arguments { + let argument_to_merge = arguments_to_merge + .iter_mut() + .find_map(|a| (a.name == arg.name).then(|| a.make_mut())); + + if let Some(argument) = argument_to_merge { + merger.add_inaccessible(directive_names, &mut argument.directives, &arg.directives); + } else { + let mut argument = InputValueDefinition { + name: arg.name.clone(), + description: arg.description.clone(), + directives: Default::default(), + ty: arg.ty.clone(), + default_value: arg.default_value.clone(), + }; + + merger.add_inaccessible(directive_names, &mut argument.directives, &arg.directives); + arguments_to_merge.push(argument.into()); + } + } +} diff --git a/apollo-federation/src/snapshots/apollo_federation__merge__tests__basic.snap b/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__basic.snap similarity index 54% rename from apollo-federation/src/snapshots/apollo_federation__merge__tests__basic.snap rename to apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__basic.snap index 1b63df9134..443c7765b0 100644 --- a/apollo-federation/src/snapshots/apollo_federation__merge__tests__basic.snap +++ b/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__basic.snap @@ -1,9 +1,10 @@ --- -source: apollo-federation/src/merge.rs +source: apollo-federation/src/merge/tests.rs expression: schema.serialize() --- -schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) { query: Query + mutation: Mutation } directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA @@ -12,7 +13,7 @@ directive @join__graph(name: String!, url: String!) on ENUM_VALUE directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION -directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT @@ -20,6 +21,8 @@ directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + enum link__Purpose { """ SECURITY features provide metadata necessary to securely resolve fields. @@ -33,29 +36,43 @@ scalar link__Import scalar join__FieldSet +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + enum join__Graph { BASIC_1 @join__graph(name: "basic_1", url: "") BASIC_2 @join__graph(name: "basic_2", url: "") } type Query @join__type(graph: BASIC_1) @join__type(graph: BASIC_2) { - i: I @join__field(graph: BASIC_1) @join__field(graph: BASIC_2) - u: U @join__field(graph: BASIC_1) @join__field(graph: BASIC_2) - f(x: ID, y: YInput): T @join__field(graph: BASIC_1) @join__field(graph: BASIC_2) + i: I @join__field(graph: BASIC_1, type: "I") @join__field(graph: BASIC_2, type: "I") + u: U @join__field(graph: BASIC_1, type: "U") @join__field(graph: BASIC_2, type: "U") + f(x: ID, y: YInput): T @join__field(graph: BASIC_1, type: "T") @join__field(graph: BASIC_2, type: "T") } interface I @join__type(graph: BASIC_1) @join__type(graph: BASIC_2) { - id: ID! + id: ID! @join__field(graph: BASIC_1, type: "ID!") @join__field(graph: BASIC_2, type: "ID!") + f(x: ID, y: YInput): T @join__field(graph: BASIC_2, type: "T") } type A implements I @join__type(graph: BASIC_1) @join__implements(graph: BASIC_1, interface: "I") @join__type(graph: BASIC_2) @join__implements(graph: BASIC_2, interface: "I") { - id: ID! @join__field(graph: BASIC_1) @join__field(graph: BASIC_2) - a: S @join__field(graph: BASIC_1) @join__field(graph: BASIC_2) + id: ID! @join__field(graph: BASIC_1, type: "ID!") @join__field(graph: BASIC_2, type: "ID!") + a: S @join__field(graph: BASIC_1, type: "S") @join__field(graph: BASIC_2, type: "S") + f(x: ID, y: YInput): T @join__field(graph: BASIC_2, type: "T") } type B implements I @join__type(graph: BASIC_1) @join__implements(graph: BASIC_1, interface: "I") @join__type(graph: BASIC_2) @join__implements(graph: BASIC_2, interface: "I") { - id: ID! @join__field(graph: BASIC_1) @join__field(graph: BASIC_2) - b: E @join__field(graph: BASIC_1) @join__field(graph: BASIC_2) + id: ID! @join__field(graph: BASIC_1, type: "ID!") @join__field(graph: BASIC_2, type: "ID!") + b: E @join__field(graph: BASIC_1, type: "E") @join__field(graph: BASIC_2, type: "E") + f(x: ID, y: YInput): T @join__field(graph: BASIC_2, type: "T") } union U @join__type(graph: BASIC_1) @join__unionMember(graph: BASIC_1, member: "A") @join__unionMember(graph: BASIC_1, member: "B") @join__type(graph: BASIC_2) @join__unionMember(graph: BASIC_2, member: "A") @join__unionMember(graph: BASIC_2, member: "B") = A | B @@ -68,14 +85,23 @@ enum E @join__type(graph: BASIC_1) @join__type(graph: BASIC_2) { } type T @join__type(graph: BASIC_1) @join__type(graph: BASIC_2) { - x: ID @join__field(graph: BASIC_1) @join__field(graph: BASIC_2) - y: Y @join__field(graph: BASIC_1) @join__field(graph: BASIC_2) + x: ID @join__field(graph: BASIC_1, type: "ID") @join__field(graph: BASIC_2, type: "ID") + y: Y @join__field(graph: BASIC_1, type: "Y") @join__field(graph: BASIC_2, type: "Y") } type Y @join__type(graph: BASIC_1) @join__type(graph: BASIC_2) { - z: ID @join__field(graph: BASIC_1) @join__field(graph: BASIC_2) + z: ID @join__field(graph: BASIC_1, type: "ID") @join__field(graph: BASIC_2, type: "ID") } input YInput @join__type(graph: BASIC_1) @join__type(graph: BASIC_2) { - z: ID + z: ID @join__field(graph: BASIC_1, type: "ID") @join__field(graph: BASIC_2, type: "ID") +} + +type Mutation @join__type(graph: BASIC_1) @join__type(graph: BASIC_2) { + m: M @join__field(graph: BASIC_1, type: "M") + m2(x: ID, y: YInput): M @join__field(graph: BASIC_2, type: "M") +} + +type M @join__type(graph: BASIC_1) @join__type(graph: BASIC_2) { + n: String @join__field(graph: BASIC_1, type: "String") @join__field(graph: BASIC_2, type: "String") } diff --git a/apollo-federation/src/snapshots/apollo_federation__merge__tests__inaccessible.snap b/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__inaccessible.snap similarity index 66% rename from apollo-federation/src/snapshots/apollo_federation__merge__tests__inaccessible.snap rename to apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__inaccessible.snap index 29501545a7..3e1f7a8e84 100644 --- a/apollo-federation/src/snapshots/apollo_federation__merge__tests__inaccessible.snap +++ b/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__inaccessible.snap @@ -1,8 +1,8 @@ --- -source: apollo-federation/src/merge.rs +source: apollo-federation/src/merge/tests.rs expression: schema.serialize() --- -schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) { +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) { query: Query } @@ -12,7 +12,7 @@ directive @join__graph(name: String!, url: String!) on ENUM_VALUE directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION -directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT @@ -20,6 +20,8 @@ directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION enum link__Purpose { @@ -35,6 +37,17 @@ scalar link__Import scalar join__FieldSet +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + enum join__Graph { INACCESSIBLE @join__graph(name: "inaccessible", url: "") INACCESSIBLE_2 @join__graph(name: "inaccessible_2", url: "") @@ -43,19 +56,19 @@ enum join__Graph { type Query @join__type(graph: INACCESSIBLE) @join__type(graph: INACCESSIBLE_2) { a( input: Input @inaccessible, - ): A @join__field(graph: INACCESSIBLE) - b: B @inaccessible @join__field(graph: INACCESSIBLE) - as: [A] @inaccessible @join__field(graph: INACCESSIBLE_2) + ): A @join__field(graph: INACCESSIBLE, type: "A") + b: B @inaccessible @join__field(graph: INACCESSIBLE, type: "B") + as: [A] @inaccessible @join__field(graph: INACCESSIBLE_2, type: "[A]") } type A @join__type(graph: INACCESSIBLE, key: "id") @join__type(graph: INACCESSIBLE_2, key: "id") { - id: ID! @join__field(graph: INACCESSIBLE) @join__field(graph: INACCESSIBLE_2) - c: Int @inaccessible @join__field(graph: INACCESSIBLE) @join__field(graph: INACCESSIBLE_2) - d: Enum @inaccessible @join__field(graph: INACCESSIBLE) + id: ID! @join__field(graph: INACCESSIBLE, type: "ID!") @join__field(graph: INACCESSIBLE_2, type: "ID!") + c: Int @inaccessible @join__field(graph: INACCESSIBLE, type: "Int") @join__field(graph: INACCESSIBLE_2, type: "Int") + d: Enum @inaccessible @join__field(graph: INACCESSIBLE, type: "Enum") } type B implements Interface @join__type(graph: INACCESSIBLE) @inaccessible @join__implements(graph: INACCESSIBLE, interface: "Interface") { - b: Scalar @join__field(graph: INACCESSIBLE) + b: Scalar @join__field(graph: INACCESSIBLE, type: "Scalar") } enum Enum @join__type(graph: INACCESSIBLE) @inaccessible { @@ -65,14 +78,14 @@ enum Enum @join__type(graph: INACCESSIBLE) @inaccessible { } input Input @join__type(graph: INACCESSIBLE) @inaccessible { - a: Int @inaccessible - b: String + a: Int @inaccessible @join__field(graph: INACCESSIBLE, type: "Int") + b: String @join__field(graph: INACCESSIBLE, type: "String") } scalar Scalar @join__type(graph: INACCESSIBLE) @inaccessible interface Interface @join__type(graph: INACCESSIBLE) @inaccessible { - b: Scalar + b: Scalar @inaccessible @join__field(graph: INACCESSIBLE, type: "Scalar") } union Union @join__type(graph: INACCESSIBLE) @inaccessible @join__unionMember(graph: INACCESSIBLE, member: "A") @join__unionMember(graph: INACCESSIBLE, member: "B") = A | B diff --git a/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__input_types.snap b/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__input_types.snap new file mode 100644 index 0000000000..fe8f199dac --- /dev/null +++ b/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__input_types.snap @@ -0,0 +1,69 @@ +--- +source: apollo-federation/src/merge/tests.rs +expression: schema.serialize() +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + ONE @join__graph(name: "one", url: "") +} + +type Query @join__type(graph: ONE) { + a(input: AInput!): A @join__field(graph: ONE, type: "A") +} + +type A @join__type(graph: ONE) { + id: ID! @join__field(graph: ONE, type: "ID!") + b: String @join__field(graph: ONE, type: "String") +} + +input AInput @join__type(graph: ONE) { + id: ID! @join__field(graph: ONE, type: "ID!") + b: BInput @join__field(graph: ONE, type: "BInput") +} + +input BInput @join__type(graph: ONE) { + id: ID! @join__field(graph: ONE, type: "ID!") +} diff --git a/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__interface_implementing_interface.snap b/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__interface_implementing_interface.snap new file mode 100644 index 0000000000..8e6f4fd4e1 --- /dev/null +++ b/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__interface_implementing_interface.snap @@ -0,0 +1,76 @@ +--- +source: apollo-federation/src/merge/tests.rs +expression: schema.serialize() +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + ONE @join__graph(name: "one", url: "") +} + +type Query @join__type(graph: ONE) { + i: I @join__field(graph: ONE, type: "I") +} + +interface Node @join__type(graph: ONE) { + id: ID! @join__field(graph: ONE, type: "ID!") +} + +interface I implements Node @join__type(graph: ONE) @join__implements(graph: ONE, interface: "Node") { + id: ID! @join__field(graph: ONE, type: "ID!") + i: String @join__field(graph: ONE, type: "String") +} + +type A implements I & Node @join__type(graph: ONE) @join__implements(graph: ONE, interface: "I") @join__implements(graph: ONE, interface: "Node") { + id: ID! @join__field(graph: ONE, type: "ID!") + i: String @join__field(graph: ONE, type: "String") + a: String @join__field(graph: ONE, type: "String") +} + +type B implements I & Node @join__type(graph: ONE) @join__implements(graph: ONE, interface: "I") @join__implements(graph: ONE, interface: "Node") { + id: ID! @join__field(graph: ONE, type: "ID!") + i: String @join__field(graph: ONE, type: "String") + b: String @join__field(graph: ONE, type: "String") +} diff --git a/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__interface_object.snap b/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__interface_object.snap new file mode 100644 index 0000000000..d94538a344 --- /dev/null +++ b/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__interface_object.snap @@ -0,0 +1,83 @@ +--- +source: apollo-federation/src/merge/tests.rs +expression: schema.serialize() +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + INTERFACE_OBJECT_1 @join__graph(name: "interface_object_1", url: "") + INTERFACE_OBJECT_2 @join__graph(name: "interface_object_2", url: "") + INTERFACE_OBJECT_3 @join__graph(name: "interface_object_3", url: "") +} + +interface Itf @join__type(graph: INTERFACE_OBJECT_1, key: "id") @join__type(graph: INTERFACE_OBJECT_2, isInterfaceObject: true, key: "id") @join__type(graph: INTERFACE_OBJECT_3, isInterfaceObject: true, key: "id") { + id: ID! @join__field(graph: INTERFACE_OBJECT_1, type: "ID!") @join__field(graph: INTERFACE_OBJECT_2, type: "ID!") @join__field(graph: INTERFACE_OBJECT_3, type: "ID!") + c: Int! @join__field(graph: INTERFACE_OBJECT_2, type: "Int!") @join__field(graph: INTERFACE_OBJECT_3, type: "Int!") + d: Int! @join__field(graph: INTERFACE_OBJECT_3, type: "Int!") +} + +type T1 implements Itf @join__type(graph: INTERFACE_OBJECT_1, key: "id") @join__implements(graph: INTERFACE_OBJECT_1, interface: "Itf") { + id: ID! @join__field(graph: INTERFACE_OBJECT_1, type: "ID!") + a: String @join__field(graph: INTERFACE_OBJECT_1, type: "String") + c: Int! @join__field + d: Int! @join__field +} + +type T2 implements Itf @join__type(graph: INTERFACE_OBJECT_1, key: "id") @join__implements(graph: INTERFACE_OBJECT_1, interface: "Itf") { + id: ID! @join__field(graph: INTERFACE_OBJECT_1, type: "ID!") + b: String @join__field(graph: INTERFACE_OBJECT_1, type: "String") + c: Int! @join__field + d: Int! @join__field +} + +type Query @join__type(graph: INTERFACE_OBJECT_1) @join__type(graph: INTERFACE_OBJECT_2) @join__type(graph: INTERFACE_OBJECT_3) { + itfs: [Itf] @join__field(graph: INTERFACE_OBJECT_2, type: "[Itf]") + itf(id: ID!): Itf @join__field(graph: INTERFACE_OBJECT_3, type: "Itf") + itf2(id: ID!): Itf2 @join__field(graph: INTERFACE_OBJECT_3, type: "Itf2") +} + +interface Itf2 @join__type(graph: INTERFACE_OBJECT_3, isInterfaceObject: true, key: "id") { + id: ID! @join__field(graph: INTERFACE_OBJECT_3, type: "ID!") +} diff --git a/apollo-federation/src/snapshots/apollo_federation__merge__tests__steel_thread.snap b/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__steel_thread.snap similarity index 68% rename from apollo-federation/src/snapshots/apollo_federation__merge__tests__steel_thread.snap rename to apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__steel_thread.snap index 54ee822eea..4bb3354a8b 100644 --- a/apollo-federation/src/snapshots/apollo_federation__merge__tests__steel_thread.snap +++ b/apollo-federation/src/merge/snapshots/apollo_federation__merge__tests__steel_thread.snap @@ -1,8 +1,8 @@ --- -source: apollo-federation/src/merge.rs +source: apollo-federation/src/merge/tests.rs expression: schema.serialize() --- -schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) { +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) { query: Query } @@ -12,7 +12,7 @@ directive @join__graph(name: String!, url: String!) on ENUM_VALUE directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION -directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT @@ -20,6 +20,8 @@ directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION enum link__Purpose { @@ -35,6 +37,17 @@ scalar link__Import scalar join__FieldSet +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + enum join__Graph { CONNECTOR_QUERY_USER_0 @join__graph(name: "connector_Query_user_0", url: "") CONNECTOR_QUERY_USERS_0 @join__graph(name: "connector_Query_users_0", url: "") @@ -43,15 +56,15 @@ enum join__Graph { } type User @join__type(graph: CONNECTOR_QUERY_USER_0, key: "id") @join__type(graph: CONNECTOR_QUERY_USERS_0) @join__type(graph: CONNECTOR_USER_D_1, key: "__typename") @join__type(graph: GRAPHQL, key: "id") { - id: ID! @join__field(graph: CONNECTOR_QUERY_USER_0) @join__field(graph: CONNECTOR_QUERY_USERS_0) @join__field(graph: GRAPHQL) - a: String @join__field(graph: CONNECTOR_QUERY_USER_0) @join__field(graph: CONNECTOR_QUERY_USERS_0) - b: String @join__field(graph: CONNECTOR_QUERY_USER_0) - c: String @join__field(graph: CONNECTOR_USER_D_1, external: true) @join__field(graph: GRAPHQL) - d: String @join__field(graph: CONNECTOR_USER_D_1, requires: "c") + id: ID! @join__field(graph: CONNECTOR_QUERY_USER_0, type: "ID!") @join__field(graph: CONNECTOR_QUERY_USERS_0, type: "ID!") @join__field(graph: GRAPHQL, type: "ID!") + a: String @join__field(graph: CONNECTOR_QUERY_USER_0, type: "String") @join__field(graph: CONNECTOR_QUERY_USERS_0, type: "String") + b: String @join__field(graph: CONNECTOR_QUERY_USER_0, type: "String") + c: String @join__field(graph: CONNECTOR_USER_D_1, external: true, type: "String") @join__field(graph: GRAPHQL, type: "String") + d: String @join__field(graph: CONNECTOR_USER_D_1, requires: "c", type: "String") } type Query @join__type(graph: CONNECTOR_QUERY_USER_0) @join__type(graph: CONNECTOR_QUERY_USERS_0) @join__type(graph: CONNECTOR_USER_D_1) @join__type(graph: GRAPHQL) { - user(id: ID!): User @join__field(graph: CONNECTOR_QUERY_USER_0) - users(limit: Int): [User] @join__field(graph: CONNECTOR_QUERY_USERS_0) - _: ID @inaccessible @join__field(graph: CONNECTOR_USER_D_1) + user(id: ID!): User @join__field(graph: CONNECTOR_QUERY_USER_0, type: "User") + users(limit: Int): [User] @join__field(graph: CONNECTOR_QUERY_USERS_0, type: "[User]") + _: ID @inaccessible @join__field(graph: CONNECTOR_USER_D_1, type: "ID") } diff --git a/apollo-federation/src/merge/testdata/input_types/one.graphql b/apollo-federation/src/merge/testdata/input_types/one.graphql new file mode 100644 index 0000000000..8b2b4ffd12 --- /dev/null +++ b/apollo-federation/src/merge/testdata/input_types/one.graphql @@ -0,0 +1,107 @@ +schema { + query: Query +} + +extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.9") + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +directive @federation__key( + fields: federation__FieldSet! + resolvable: Boolean = true +) repeatable on OBJECT | INTERFACE + +directive @federation__requires( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__provides( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION + +directive @federation__tag( + name: String! +) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @federation__extends on OBJECT | INTERFACE + +directive @federation__shareable on OBJECT | FIELD_DEFINITION + +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @federation__override( + from: String! + label: String +) on FIELD_DEFINITION + +directive @federation__composeDirective(name: String) repeatable on SCHEMA + +directive @federation__interfaceObject on OBJECT + +directive @federation__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__requiresScopes( + scopes: [[federation__Scope!]!]! +) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__cost( + weight: Int! +) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @federation__listSize( + assumedSize: Int + slicingArguments: [String!] + sizedFields: [String!] + requireOneSlicingArgument: Boolean = true +) on FIELD_DEFINITION + +scalar link__Import + +enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +scalar federation__FieldSet + +scalar federation__Scope + +scalar _Any + +type _Service { + sdl: String +} + +type Query { + _service: _Service! + a(input: AInput!): A +} + +type A { + id: ID! + b: String +} + +input AInput { + id: ID! + b: BInput +} + +input BInput { + id: ID! +} diff --git a/apollo-federation/src/merge/testdata/interface_implementing_interface/one.graphql b/apollo-federation/src/merge/testdata/interface_implementing_interface/one.graphql new file mode 100644 index 0000000000..58fe7ca8fa --- /dev/null +++ b/apollo-federation/src/merge/testdata/interface_implementing_interface/one.graphql @@ -0,0 +1,114 @@ +schema { + query: Query +} + +extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.9") + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +directive @federation__key( + fields: federation__FieldSet! + resolvable: Boolean = true +) repeatable on OBJECT | INTERFACE + +directive @federation__requires( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__provides( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION + +directive @federation__tag( + name: String! +) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @federation__extends on OBJECT | INTERFACE + +directive @federation__shareable on OBJECT | FIELD_DEFINITION + +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @federation__override( + from: String! + label: String +) on FIELD_DEFINITION + +directive @federation__composeDirective(name: String) repeatable on SCHEMA + +directive @federation__interfaceObject on OBJECT + +directive @federation__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__requiresScopes( + scopes: [[federation__Scope!]!]! +) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__cost( + weight: Int! +) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @federation__listSize( + assumedSize: Int + slicingArguments: [String!] + sizedFields: [String!] + requireOneSlicingArgument: Boolean = true +) on FIELD_DEFINITION + +scalar link__Import + +enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +scalar federation__FieldSet + +scalar federation__Scope + +scalar _Any + +type _Service { + sdl: String +} + +type Query { + _service: _Service! + i: I +} + +interface Node { + id: ID! +} + +interface I implements Node { + id: ID! + i: String +} + +type A implements I & Node { + id: ID! + i: String + a: String +} + +type B implements I & Node { + id: ID! + i: String + b: String +} diff --git a/apollo-federation/src/merge/testdata/interface_object/one.graphql b/apollo-federation/src/merge/testdata/interface_object/one.graphql new file mode 100644 index 0000000000..92816f382d --- /dev/null +++ b/apollo-federation/src/merge/testdata/interface_object/one.graphql @@ -0,0 +1,109 @@ +schema { + query: Query +} + +extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.9") + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +directive @federation__key( + fields: federation__FieldSet! + resolvable: Boolean = true +) repeatable on OBJECT | INTERFACE + +directive @federation__requires( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__provides( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION + +directive @federation__tag( + name: String! +) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @federation__extends on OBJECT | INTERFACE + +directive @federation__shareable on OBJECT | FIELD_DEFINITION + +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @federation__override( + from: String! + label: String +) on FIELD_DEFINITION + +directive @federation__composeDirective(name: String) repeatable on SCHEMA + +directive @federation__interfaceObject on OBJECT + +directive @federation__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__requiresScopes( + scopes: [[federation__Scope!]!]! +) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__cost( + weight: Int! +) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @federation__listSize( + assumedSize: Int + slicingArguments: [String!] + sizedFields: [String!] + requireOneSlicingArgument: Boolean = true +) on FIELD_DEFINITION + +scalar link__Import + +enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +scalar federation__FieldSet + +scalar federation__Scope + +interface Itf @federation__key(fields: "id", resolvable: true) { + id: ID! +} + +type T1 implements Itf @federation__key(fields: "id", resolvable: true) { + id: ID! + a: String +} + +type T2 implements Itf @federation__key(fields: "id", resolvable: true) { + id: ID! + b: String +} + +scalar _Any + +type _Service { + sdl: String +} + +union _Entity = T1 | T2 + +type Query { + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} diff --git a/apollo-federation/src/merge/testdata/interface_object/three.graphql b/apollo-federation/src/merge/testdata/interface_object/three.graphql new file mode 100644 index 0000000000..f277d11df9 --- /dev/null +++ b/apollo-federation/src/merge/testdata/interface_object/three.graphql @@ -0,0 +1,98 @@ +schema { + query: Query +} + +extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.9") + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +directive @federation__key( + fields: federation__FieldSet! + resolvable: Boolean = true +) repeatable on OBJECT | INTERFACE + +directive @federation__requires( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__provides( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION + +directive @federation__tag( + name: String! +) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @federation__extends on OBJECT | INTERFACE + +directive @federation__shareable on OBJECT | FIELD_DEFINITION + +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @federation__override( + from: String! + label: String +) on FIELD_DEFINITION + +directive @federation__composeDirective(name: String) repeatable on SCHEMA + +directive @federation__interfaceObject on OBJECT + +directive @federation__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__requiresScopes( + scopes: [[federation__Scope!]!]! +) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__cost( + weight: Int! +) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @federation__listSize( + assumedSize: Int + slicingArguments: [String!] + sizedFields: [String!] + requireOneSlicingArgument: Boolean = true +) on FIELD_DEFINITION + +scalar link__Import + +enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +scalar federation__FieldSet + +scalar federation__Scope + +type Itf @federation__interfaceObject @federation__key(fields: "id") { + c: Int! + d: Int! + id: ID! +} + +# This doesn't really make sense but it was a bug +type Itf2 @federation__interfaceObject @federation__key(fields: "id") { + id: ID! +} + +type Query { + itf(id: ID!): Itf + itf2(id: ID!): Itf2 +} diff --git a/apollo-federation/src/merge/testdata/interface_object/two.graphql b/apollo-federation/src/merge/testdata/interface_object/two.graphql new file mode 100644 index 0000000000..d40557b508 --- /dev/null +++ b/apollo-federation/src/merge/testdata/interface_object/two.graphql @@ -0,0 +1,93 @@ +schema { + query: Query +} + +extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.9") + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +directive @federation__key( + fields: federation__FieldSet! + resolvable: Boolean = true +) repeatable on OBJECT | INTERFACE + +directive @federation__requires( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__provides( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION + +directive @federation__tag( + name: String! +) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @federation__extends on OBJECT | INTERFACE + +directive @federation__shareable on OBJECT | FIELD_DEFINITION + +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @federation__override( + from: String! + label: String +) on FIELD_DEFINITION + +directive @federation__composeDirective(name: String) repeatable on SCHEMA + +directive @federation__interfaceObject on OBJECT + +directive @federation__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__requiresScopes( + scopes: [[federation__Scope!]!]! +) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__cost( + weight: Int! +) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @federation__listSize( + assumedSize: Int + slicingArguments: [String!] + sizedFields: [String!] + requireOneSlicingArgument: Boolean = true +) on FIELD_DEFINITION + +scalar link__Import + +enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +scalar federation__FieldSet + +scalar federation__Scope + +type Itf + @federation__interfaceObject + @federation__key(fields: "id", resolvable: true) { + c: Int! + id: ID! +} + +type Query { + itfs: [Itf] +} diff --git a/apollo-federation/src/merge/tests.rs b/apollo-federation/src/merge/tests.rs new file mode 100644 index 0000000000..5ac5263394 --- /dev/null +++ b/apollo-federation/src/merge/tests.rs @@ -0,0 +1,123 @@ +use apollo_compiler::Schema; +use insta::assert_snapshot; + +use crate::ValidFederationSubgraph; +use crate::ValidFederationSubgraphs; +use crate::merge::merge_federation_subgraphs; +use crate::schema::ValidFederationSchema; + +macro_rules! subgraphs { + ($($name:expr => $file:expr),* $(,)?) => {{ + let mut subgraphs = ValidFederationSubgraphs::new(); + + $( + subgraphs.add(ValidFederationSubgraph { + name: $name.to_string(), + url: "".to_string(), + schema: ValidFederationSchema::new( + Schema::parse_and_validate(include_str!($file), $file).unwrap(), + ) + .unwrap(), + }).unwrap(); + )* + + subgraphs + }}; +} + +#[test] +fn test_steel_thread() { + let subgraphs = subgraphs! { + "connector_Query_users_0" => "../connectors/expand/merge/connector_Query_users_0.graphql", + "connector_Query_user_0" => "../connectors/expand/merge/connector_Query_user_0.graphql", + "connector_User_d_1" => "../connectors/expand/merge/connector_User_d_1.graphql", + "graphql" => "../connectors/expand/merge/graphql.graphql", + }; + + let result = merge_federation_subgraphs(subgraphs).unwrap(); + + let schema = result.schema.into_inner(); + let validation = schema.clone().validate(); + assert!(validation.is_ok(), "{validation:?}"); + + assert_snapshot!(schema.serialize()); +} + +#[test] +fn test_basic() { + let subgraphs = subgraphs! { + "basic_1" => "../connectors/expand/merge/basic_1.graphql", + "basic_2" => "../connectors/expand/merge/basic_2.graphql", + }; + + let result = merge_federation_subgraphs(subgraphs).unwrap(); + + let schema = result.schema.into_inner(); + let validation = schema.clone().validate(); + assert!(validation.is_ok(), "{validation:?}"); + + assert_snapshot!(schema.serialize()); +} + +#[test] +fn test_inaccessible() { + let subgraphs = subgraphs! { + "inaccessible" => "../connectors/expand/merge/inaccessible.graphql", + "inaccessible_2" => "../connectors/expand/merge/inaccessible_2.graphql", + }; + + let result = merge_federation_subgraphs(subgraphs).unwrap(); + + let schema = result.schema.into_inner(); + let validation = schema.clone().validate(); + assert!(validation.is_ok(), "{validation:?}"); + + assert_snapshot!(schema.serialize()); +} + +#[test] +fn test_interface_object() { + let subgraphs = subgraphs! { + "interface_object_1" => "./testdata/interface_object/one.graphql", + "interface_object_2" => "./testdata/interface_object/two.graphql", + "interface_object_3" => "./testdata/interface_object/three.graphql", + }; + + let result = merge_federation_subgraphs(subgraphs).unwrap(); + + let schema = result.schema.into_inner(); + let validation = schema.clone().validate(); + assert!(validation.is_ok(), "{validation:?}"); + + assert_snapshot!(schema.serialize()); +} + +#[test] +fn test_input_types() { + let subgraphs = subgraphs! { + "one" => "./testdata/input_types/one.graphql", + }; + + let result = merge_federation_subgraphs(subgraphs).unwrap(); + + let schema = result.schema.into_inner(); + let validation = schema.clone().validate(); + assert!(validation.is_ok(), "{validation:?}"); + + assert_snapshot!(schema.serialize()); +} + +#[test] +fn test_interface_implementing_interface() { + let subgraphs = subgraphs! { + "one" => "./testdata/interface_implementing_interface/one.graphql", + }; + + let result = merge_federation_subgraphs(subgraphs).unwrap(); + + let schema = result.schema.into_inner(); + let validation = schema.clone().validate(); + assert!(validation.is_ok(), "{validation:?}"); + + assert_snapshot!(schema.serialize()); +} diff --git a/apollo-federation/src/merger/compose_directive_manager.rs b/apollo-federation/src/merger/compose_directive_manager.rs new file mode 100644 index 0000000000..babaee8554 --- /dev/null +++ b/apollo-federation/src/merger/compose_directive_manager.rs @@ -0,0 +1,26 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +use apollo_compiler::Name; + +pub(crate) struct ComposeDirectiveManager { + merge_directive_map: HashMap>, +} + +impl ComposeDirectiveManager { + pub(crate) fn new() -> Self { + Self { + merge_directive_map: HashMap::new(), + } + } + + pub(crate) fn should_compose_directive( + &self, + subgraph_name: &str, + directive_name: &Name, + ) -> bool { + self.merge_directive_map + .get(subgraph_name) + .is_some_and(|set| set.contains(directive_name)) + } +} diff --git a/apollo-federation/src/merger/error_reporter.rs b/apollo-federation/src/merger/error_reporter.rs new file mode 100644 index 0000000000..0a75794a60 --- /dev/null +++ b/apollo-federation/src/merger/error_reporter.rs @@ -0,0 +1,228 @@ +use std::collections::HashMap; +use std::fmt::Display; +use std::ops::Range; + +use apollo_compiler::parser::LineColumn; + +use crate::error::CompositionError; +use crate::error::SingleFederationError; +use crate::error::SubgraphLocation; +use crate::merger::hints::HintCode; +use crate::merger::merge::Sources; +use crate::supergraph::CompositionHint; +use crate::utils::human_readable::JoinStringsOptions; +use crate::utils::human_readable::human_readable_subgraph_names; +use crate::utils::human_readable::join_strings; + +pub(crate) struct ErrorReporter { + errors: Vec, + hints: Vec, + names: Vec, +} + +impl ErrorReporter { + pub(crate) fn new(names: Vec) -> Self { + Self { + errors: Vec::new(), + hints: Vec::new(), + names, + } + } + + #[allow(dead_code)] + pub(crate) fn add_subgraph_error( + &mut self, + name: &str, + error: impl Into, + locations: Vec>, + ) { + let error = CompositionError::SubgraphError { + subgraph: name.into(), + error: error.into(), + locations: locations + .iter() + .map(|range| SubgraphLocation { + subgraph: name.into(), + range: range.clone(), + }) + .collect(), + }; + self.errors.push(error); + } + + #[allow(dead_code)] + pub(crate) fn add_error(&mut self, error: CompositionError) { + self.errors.push(error); + } + + #[allow(dead_code)] + pub(crate) fn add_hint(&mut self, hint: CompositionHint) { + self.hints.push(hint); + } + + pub(crate) fn has_hints(&self) -> bool { + !self.hints.is_empty() + } + + pub(crate) fn has_errors(&self) -> bool { + !self.errors.is_empty() + } + + pub(crate) fn into_errors_and_hints(self) -> (Vec, Vec) { + (self.errors, self.hints) + } + + pub(crate) fn report_mismatch_error( + &mut self, + error: CompositionError, + mismatched_element: &T, + subgraph_elements: &Sources, + mismatch_accessor: impl Fn(&T, bool) -> Option, + ) { + self.report_mismatch( + Some(mismatched_element), + subgraph_elements, + mismatch_accessor, + |elt, names| format!("{} in {}", elt, names.unwrap_or("undefined".to_string())), + |elt, names| format!("{elt} in {names}"), + |myself, distribution, _: Vec| { + let distribution_str = join_strings( + distribution.iter(), + JoinStringsOptions { + first_separator: Some(" and "), + separator: ", ", + last_separator: Some(" but "), + output_length_limit: None, + }, + ); + myself.add_error(error.append_message(distribution_str)); + }, + Some(|elt: Option<&T>| elt.is_none()), + false, + ); + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn report_mismatch_hint( + &mut self, + code: HintCode, + message: String, + supergraph_element: &T, + subgraph_elements: &Sources, + element_to_string: impl Fn(&T, bool) -> Option, + supergraph_element_printer: impl Fn(&str, Option) -> String, + other_elements_printer: impl Fn(&str, &str) -> String, + ignore_predicate: Option) -> bool>, + include_missing_sources: bool, + no_end_of_message_dot: bool, + ) { + self.report_mismatch( + Some(supergraph_element), + subgraph_elements, + element_to_string, + supergraph_element_printer, + other_elements_printer, + |myself, distribution, _: Vec| { + let distribution_str = join_strings( + distribution.iter(), + JoinStringsOptions { + first_separator: Some(" and "), + separator: ", ", + last_separator: Some(" but "), + output_length_limit: None, + }, + ); + let suffix = if no_end_of_message_dot { "" } else { "." }; + myself.add_hint(CompositionHint { + code: code.code().to_string(), + message: format!("{message}{distribution_str}{suffix}"), + locations: Default::default(), // TODO + }); + }, + ignore_predicate, + include_missing_sources, + ); + } + + /// Reports a mismatch between a supergraph element and subgraph elements. + /// Not meant to be used directly: use `report_mismatch_error` or `report_mismatch_hint` instead. + /// + /// TODO: The generic parameter `U` is meant to represent AST nodes (or locations) that are attached to error messages. + /// When we decide on an implementation for those, they should be added to `ast_nodes` below. + #[allow(clippy::too_many_arguments)] + fn report_mismatch( + &mut self, + supergraph_element: Option<&T>, + subgraph_elements: &Sources, + mismatch_accessor: impl Fn(&T, bool) -> Option, + supergraph_element_printer: impl Fn(&str, Option) -> String, + other_elements_printer: impl Fn(&str, &str) -> String, + reporter: impl FnOnce(&mut Self, Vec, Vec), + ignore_predicate: Option) -> bool>, + include_missing_sources: bool, + ) { + let mut distribution_map = HashMap::new(); + #[allow(unused_mut)] // We need this to be mutable when we decide how to handle AST nodes + let mut ast_nodes: Vec = Vec::new(); + let process_subgraph_element = + |name: &str, + subgraph_element: &T, + distribution_map: &mut HashMap>| { + if ignore_predicate + .as_ref() + .is_some_and(|pred| pred(Some(subgraph_element))) + { + return; + } + let element = mismatch_accessor(subgraph_element, false); + distribution_map + .entry(element.unwrap_or("".to_string())) + .or_default() + .push(name.to_string()); + // TODO: Get AST node equivalent and push onto `ast_nodes` + }; + if include_missing_sources { + for (i, name) in self.names.iter().enumerate() { + if let Some(Some(subgraph_element)) = subgraph_elements.get(&i) { + process_subgraph_element(name, subgraph_element, &mut distribution_map); + } else { + distribution_map + .entry("".to_string()) + .or_default() + .push(name.to_string()); + } + } + } else { + for (i, name) in self.names.iter().enumerate() { + if let Some(Some(subgraph_element)) = subgraph_elements.get(&i) { + process_subgraph_element(name, subgraph_element, &mut distribution_map); + } + } + } + let supergraph_mismatch = supergraph_element + .and_then(|se| mismatch_accessor(se, true)) + .unwrap_or_default(); + assert!( + distribution_map.len() > 1, + "Should not have been called for {}", + supergraph_element + .map(|elt| elt.to_string()) + .unwrap_or_else(|| "undefined".to_string()) + ); + let mut distribution = Vec::new(); + let subgraphs_like_supergraph = distribution_map.get(&supergraph_mismatch); + // We always add the "supergraph" first (proper formatting of hints rely on this in particular) + distribution.push(supergraph_element_printer( + &supergraph_mismatch, + subgraphs_like_supergraph.map(|s| human_readable_subgraph_names(s.iter())), + )); + for (v, names) in distribution_map.iter() { + if v == &supergraph_mismatch { + continue; // Skip the supergraph element as it's already added + } + let names_str = human_readable_subgraph_names(names.iter()); + distribution.push(other_elements_printer(v, &names_str)); + } + reporter(self, distribution, ast_nodes); + } +} diff --git a/apollo-federation/src/merger/hints.rs b/apollo-federation/src/merger/hints.rs new file mode 100644 index 0000000000..9510bd7e76 --- /dev/null +++ b/apollo-federation/src/merger/hints.rs @@ -0,0 +1,451 @@ +#[allow(dead_code)] +use std::sync::LazyLock; + +#[derive(Debug)] +#[allow(dead_code)] +pub(crate) enum HintLevel { + Warn, + Info, + Debug, +} + +#[allow(dead_code)] +impl HintLevel { + pub(crate) fn name(&self) -> &'static str { + match self { + HintLevel::Warn => "WARN", + HintLevel::Info => "INFO", + HintLevel::Debug => "DEBUG", + } + } +} + +#[derive(Debug)] +pub(crate) struct HintCodeDefinition { + code: String, + level: HintLevel, + description: String, +} + +#[allow(dead_code)] +impl HintCodeDefinition { + pub(crate) fn new( + code: impl Into, + level: HintLevel, + description: impl Into, + ) -> Self { + Self { + code: code.into(), + level, + description: description.into(), + } + } + + pub(crate) fn code(&self) -> &str { + &self.code + } + + pub(crate) fn level(&self) -> &HintLevel { + &self.level + } + + pub(crate) fn description(&self) -> &str { + &self.description + } +} + +#[derive(Clone, Debug)] +#[allow(dead_code)] +pub(crate) enum HintCode { + InconsistentButCompatibleFieldType, + InconsistentButCompatibleArgumentType, + InconsistentDefaultValuePresence, + InconsistentEntity, + InconsistentObjectValueTypeField, + InconsistentInterfaceValueTypeField, + InconsistentInputObjectField, + InconsistentUnionMember, + InconsistentEnumValueForInputEnum, + InconsistentEnumValueForOutputEnum, + InconsistentTypeSystemDirectiveRepeatable, + InconsistentTypeSystemDirectiveLocations, + InconsistentExecutableDirectivePresence, + NoExecutableDirectiveLocationsIntersection, + InconsistentExecutableDirectiveRepeatable, + InconsistentExecutableDirectiveLocations, + InconsistentDescription, + InconsistentArgumentPresence, + FromSubgraphDoesNotExist, + OverriddenFieldCanBeRemoved, + OverrideDirectiveCanBeRemoved, + OverrideMigrationInProgress, + UnusedEnumType, + InconsistentNonRepeatableDirectiveArguments, + MergedNonRepeatableDirectiveArguments, + DirectiveCompositionInfo, + DirectiveCompositionWarn, + InconsistentRuntimeTypesForShareableReturn, + ImplicitlyUpgradedFederationVersion, + ContextualArgumentNotContextualInAllSubgraphs, +} + +#[allow(dead_code)] +impl HintCode { + pub(crate) fn definition(&self) -> &'static HintCodeDefinition { + match self { + HintCode::InconsistentButCompatibleFieldType => &INCONSISTENT_BUT_COMPATIBLE_FIELD_TYPE, + HintCode::InconsistentButCompatibleArgumentType => { + &INCONSISTENT_BUT_COMPATIBLE_ARGUMENT_TYPE + } + HintCode::InconsistentDefaultValuePresence => &INCONSISTENT_DEFAULT_VALUE_PRESENCE, + HintCode::InconsistentEntity => &INCONSISTENT_ENTITY, + HintCode::InconsistentObjectValueTypeField => &INCONSISTENT_OBJECT_VALUE_TYPE_FIELD, + HintCode::InconsistentInterfaceValueTypeField => { + &INCONSISTENT_INTERFACE_VALUE_TYPE_FIELD + } + HintCode::InconsistentInputObjectField => &INCONSISTENT_INPUT_OBJECT_FIELD, + HintCode::InconsistentUnionMember => &INCONSISTENT_UNION_MEMBER, + HintCode::InconsistentEnumValueForInputEnum => &INCONSISTENT_ENUM_VALUE_FOR_INPUT_ENUM, + HintCode::InconsistentEnumValueForOutputEnum => { + &INCONSISTENT_ENUM_VALUE_FOR_OUTPUT_ENUM + } + HintCode::InconsistentTypeSystemDirectiveRepeatable => { + &INCONSISTENT_TYPE_SYSTEM_DIRECTIVE_REPEATABLE + } + HintCode::InconsistentTypeSystemDirectiveLocations => { + &INCONSISTENT_TYPE_SYSTEM_DIRECTIVE_LOCATIONS + } + HintCode::InconsistentExecutableDirectivePresence => { + &INCONSISTENT_EXECUTABLE_DIRECTIVE_PRESENCE + } + HintCode::NoExecutableDirectiveLocationsIntersection => { + &NO_EXECUTABLE_DIRECTIVE_LOCATIONS_INTERSECTION + } + HintCode::InconsistentExecutableDirectiveRepeatable => { + &INCONSISTENT_EXECUTABLE_DIRECTIVE_REPEATABLE + } + HintCode::InconsistentExecutableDirectiveLocations => { + &INCONSISTENT_EXECUTABLE_DIRECTIVE_LOCATIONS + } + HintCode::InconsistentDescription => &INCONSISTENT_DESCRIPTION, + HintCode::InconsistentArgumentPresence => &INCONSISTENT_ARGUMENT_PRESENCE, + HintCode::FromSubgraphDoesNotExist => &FROM_SUBGRAPH_DOES_NOT_EXIST, + HintCode::OverriddenFieldCanBeRemoved => &OVERRIDDEN_FIELD_CAN_BE_REMOVED, + HintCode::OverrideDirectiveCanBeRemoved => &OVERRIDE_DIRECTIVE_CAN_BE_REMOVED, + HintCode::OverrideMigrationInProgress => &OVERRIDE_MIGRATION_IN_PROGRESS, + HintCode::UnusedEnumType => &UNUSED_ENUM_TYPE, + HintCode::InconsistentNonRepeatableDirectiveArguments => { + &INCONSISTENT_NON_REPEATABLE_DIRECTIVE_ARGUMENTS + } + HintCode::MergedNonRepeatableDirectiveArguments => { + &MERGED_NON_REPEATABLE_DIRECTIVE_ARGUMENTS + } + HintCode::DirectiveCompositionInfo => &DIRECTIVE_COMPOSITION_INFO, + HintCode::DirectiveCompositionWarn => &DIRECTIVE_COMPOSITION_WARN, + HintCode::InconsistentRuntimeTypesForShareableReturn => { + &INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN + } + HintCode::ImplicitlyUpgradedFederationVersion => { + &IMPLICITLY_UPGRADED_FEDERATION_VERSION + } + HintCode::ContextualArgumentNotContextualInAllSubgraphs => { + &CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS + } + } + } + + pub(crate) fn code(&self) -> &str { + self.definition().code() + } +} + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_BUT_COMPATIBLE_FIELD_TYPE: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_BUT_COMPATIBLE_FIELD_TYPE", + HintLevel::Warn, + "Field has inconsistent but compatible type across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_BUT_COMPATIBLE_ARGUMENT_TYPE: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_BUT_COMPATIBLE_ARGUMENT_TYPE", + HintLevel::Warn, + "Argument has inconsistent but compatible type across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_DEFAULT_VALUE_PRESENCE: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_DEFAULT_VALUE_PRESENCE", + HintLevel::Warn, + "Default value presence is inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_ENTITY: LazyLock = LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_ENTITY", + HintLevel::Warn, + "Entity definition is inconsistent across subgraphs", + ) +}); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_OBJECT_VALUE_TYPE_FIELD: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_OBJECT_VALUE_TYPE_FIELD", + HintLevel::Warn, + "Object value type field is inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_INTERFACE_VALUE_TYPE_FIELD: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_INTERFACE_VALUE_TYPE_FIELD", + HintLevel::Warn, + "Interface value type field is inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_INPUT_OBJECT_FIELD: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_INPUT_OBJECT_FIELD", + HintLevel::Warn, + "Input object field is inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_UNION_MEMBER: LazyLock = LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_UNION_MEMBER", + HintLevel::Warn, + "Union member is inconsistent across subgraphs", + ) +}); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_ENUM_VALUE_FOR_INPUT_ENUM: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_ENUM_VALUE_FOR_INPUT_ENUM", + HintLevel::Warn, + "Enum value for input enum is inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_ENUM_VALUE_FOR_OUTPUT_ENUM: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_ENUM_VALUE_FOR_OUTPUT_ENUM", + HintLevel::Warn, + "Enum value for output enum is inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_TYPE_SYSTEM_DIRECTIVE_REPEATABLE: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_TYPE_SYSTEM_DIRECTIVE_REPEATABLE", + HintLevel::Warn, + "Type system directive repeatable property is inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_TYPE_SYSTEM_DIRECTIVE_LOCATIONS: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_TYPE_SYSTEM_DIRECTIVE_LOCATIONS", + HintLevel::Warn, + "Type system directive locations are inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_EXECUTABLE_DIRECTIVE_PRESENCE: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_EXECUTABLE_DIRECTIVE_PRESENCE", + HintLevel::Warn, + "Executable directive presence is inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static NO_EXECUTABLE_DIRECTIVE_LOCATIONS_INTERSECTION: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "NO_EXECUTABLE_DIRECTIVE_LOCATIONS_INTERSECTION", + HintLevel::Warn, + "No intersection between executable directive locations across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_EXECUTABLE_DIRECTIVE_REPEATABLE: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_EXECUTABLE_DIRECTIVE_REPEATABLE", + HintLevel::Warn, + "Executable directive repeatable property is inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_EXECUTABLE_DIRECTIVE_LOCATIONS: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_EXECUTABLE_DIRECTIVE_LOCATIONS", + HintLevel::Warn, + "Executable directive locations are inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_DESCRIPTION: LazyLock = LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_DESCRIPTION", + HintLevel::Warn, + "Description is inconsistent across subgraphs", + ) +}); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_ARGUMENT_PRESENCE: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_ARGUMENT_PRESENCE", + HintLevel::Warn, + "Argument presence is inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static FROM_SUBGRAPH_DOES_NOT_EXIST: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "FROM_SUBGRAPH_DOES_NOT_EXIST", + HintLevel::Warn, + "From subgraph does not exist", + ) + }); + +#[allow(dead_code)] +pub(crate) static OVERRIDDEN_FIELD_CAN_BE_REMOVED: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "OVERRIDDEN_FIELD_CAN_BE_REMOVED", + HintLevel::Info, + "Overridden field can be removed", + ) + }); + +#[allow(dead_code)] +pub(crate) static OVERRIDE_DIRECTIVE_CAN_BE_REMOVED: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "OVERRIDE_DIRECTIVE_CAN_BE_REMOVED", + HintLevel::Info, + "Override directive can be removed", + ) + }); + +#[allow(dead_code)] +pub(crate) static OVERRIDE_MIGRATION_IN_PROGRESS: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "OVERRIDE_MIGRATION_IN_PROGRESS", + HintLevel::Info, + "Override migration is in progress", + ) + }); + +#[allow(dead_code)] +pub(crate) static UNUSED_ENUM_TYPE: LazyLock = LazyLock::new(|| { + HintCodeDefinition::new("UNUSED_ENUM_TYPE", HintLevel::Warn, "Enum type is unused") +}); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_NON_REPEATABLE_DIRECTIVE_ARGUMENTS: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_NON_REPEATABLE_DIRECTIVE_ARGUMENTS", + HintLevel::Warn, + "Non-repeatable directive arguments are inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static MERGED_NON_REPEATABLE_DIRECTIVE_ARGUMENTS: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "MERGED_NON_REPEATABLE_DIRECTIVE_ARGUMENTS", + HintLevel::Info, + "Non-repeatable directive arguments have been merged", + ) + }); + +#[allow(dead_code)] +pub(crate) static DIRECTIVE_COMPOSITION_INFO: LazyLock = LazyLock::new(|| { + HintCodeDefinition::new( + "DIRECTIVE_COMPOSITION_INFO", + HintLevel::Info, + "Directive composition information", + ) +}); + +#[allow(dead_code)] +pub(crate) static DIRECTIVE_COMPOSITION_WARN: LazyLock = LazyLock::new(|| { + HintCodeDefinition::new( + "DIRECTIVE_COMPOSITION_WARN", + HintLevel::Warn, + "Directive composition warning", + ) +}); + +#[allow(dead_code)] +pub(crate) static INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN", + HintLevel::Warn, + "Runtime types for shareable return are inconsistent across subgraphs", + ) + }); + +#[allow(dead_code)] +pub(crate) static IMPLICITLY_UPGRADED_FEDERATION_VERSION: LazyLock = + LazyLock::new(|| { + HintCodeDefinition::new( + "IMPLICITLY_UPGRADED_FEDERATION_VERSION", + HintLevel::Info, + "Federation version has been implicitly upgraded", + ) + }); + +#[allow(dead_code)] +pub(crate) static CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS: LazyLock< + HintCodeDefinition, +> = LazyLock::new(|| { + HintCodeDefinition::new( + "CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS", + HintLevel::Warn, + "Contextual argument is not contextual in all subgraphs", + ) +}); diff --git a/apollo-federation/src/merger/merge_enum.rs b/apollo-federation/src/merger/merge_enum.rs new file mode 100644 index 0000000000..3237836deb --- /dev/null +++ b/apollo-federation/src/merger/merge_enum.rs @@ -0,0 +1,706 @@ +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::FieldDefinition; +use apollo_compiler::ast::InputValueDefinition; +use apollo_compiler::collections::IndexSet; +use apollo_compiler::schema::Component; +use apollo_compiler::schema::EnumType; +use apollo_compiler::schema::EnumValueDefinition; + +use crate::bail; +use crate::error::CompositionError; +use crate::error::FederationError; +use crate::link::inaccessible_spec_definition::IsInaccessibleExt; +use crate::merger::hints::HintCode; +use crate::merger::merge::Merger; +use crate::merger::merge::Sources; +use crate::schema::position::EnumTypeDefinitionPosition; +use crate::schema::position::EnumValueDefinitionPosition; +use crate::supergraph::CompositionHint; + +#[derive(Debug, Clone)] +pub(crate) enum EnumExampleAst { + #[allow(dead_code)] + Field(Node), + #[allow(dead_code)] + Input(Node), +} + +#[derive(Debug, Clone)] +pub(crate) struct EnumExample { + #[allow(dead_code)] + pub coordinate: String, + #[allow(dead_code)] + pub element_ast: Option, +} + +#[derive(Debug, Clone)] +pub(crate) enum EnumTypeUsage { + #[allow(dead_code)] + Input { + input_example: EnumExample, + }, + #[allow(dead_code)] + Output { + output_example: EnumExample, + }, + #[allow(dead_code)] + Both { + input_example: EnumExample, + output_example: EnumExample, + }, + Unused, +} + +impl Merger { + /// Merge enum type from multiple subgraphs + #[allow(dead_code)] + pub(crate) fn merge_enum( + &mut self, + sources: Sources>, + dest: &EnumTypeDefinitionPosition, + ) -> Result<(), FederationError> { + let usage = self.enum_usages.get(dest.type_name.as_str()).cloned().unwrap_or_else(|| { + // If the enum is unused, we have a choice to make. We could skip the enum entirely (after all, exposing an unreferenced type mostly "pollutes" the supergraph API), but + // some evidence shows that many a user have such unused enums in federation 1 and having those removed from their API might be surprising. We could merge it as + // an "input-only" or as a "input/output" type, but the hints/errors generated in both those cases would be confusing in that case, and while we could amend them + // for this case, it would complicate things and doesn't feel like it would feel very justified. So we merge it as an "output" type, which is the least contraining + // option. We do raise an hint though so users can notice this. + let usage = EnumTypeUsage::Unused; + self.error_reporter.add_hint(CompositionHint { + code: HintCode::UnusedEnumType.code().to_string(), + message: format!( + "Enum type \"{}\" is defined but unused. It will be included in the supergraph with all the values appearing in any subgraph (\"as if\" it was only used as an output type).", + dest.type_name + ), + locations: Default::default(), // PORT_NOTE: No locations in JS implementation. + }); + usage + }); + + let mut enum_values: IndexSet = Default::default(); + + enum_values.extend( + sources + .iter() + .filter_map(|(_, source)| source.as_ref()) + .flat_map(|source| source.values.values()) + .map(|value| value.node.value.clone()), + ); + + // Merge each enum value + for value_name in enum_values { + let value_pos = dest.value(value_name); + self.merge_enum_value(&sources, &value_pos, &usage)?; + } + + // We could be left with an enum type with no values, and that's invalid in graphQL + if dest.get(self.merged.schema())?.values.is_empty() { + self.error_reporter.add_error(CompositionError::EmptyMergedEnumType { + message: format!( + "None of the values of enum type \"{}\" are defined consistently in all the subgraphs defining that type. As only values common to all subgraphs are merged, this would result in an empty type.", + dest.type_name + ), + locations: self.source_locations(&sources), + }); + } + + Ok(()) + } + + /// Merge a specific enum value across subgraphs + /// Returns true if the value should be removed from the enum + fn merge_enum_value( + &mut self, + sources: &Sources>, + value_pos: &EnumValueDefinitionPosition, + usage: &EnumTypeUsage, + ) -> Result<(), FederationError> { + // We merge directives (and description while at it) on the value even though we might remove it later in that function, + // but we do so because: + // 1. this will catch any problems merging the description/directives (which feels like a good thing). + // 2. it easier to see if the value is marked @inaccessible. + + let value_sources: Sources<&Component> = sources + .iter() + .map(|(&idx, source)| { + let source_value = source + .as_ref() + .and_then(|enum_type| enum_type.values.get(&value_pos.value_name)); + (idx, source_value) + }) + .collect(); + + // create new dest for the value + let dest = Component::new(EnumValueDefinition { + description: None, + value: value_pos.value_name.clone(), + directives: Default::default(), + }); + value_pos.insert(&mut self.merged, dest)?; + // TODO: Implement these helper methods - for now skip the actual merging + // self.merge_description(&value_sources, &mut dest); + // self.record_applied_directives_to_merge(&value_sources, &mut dest); + self.add_join_enum_value(&value_sources, value_pos)?; + + let is_inaccessible = match &self.inaccessible_directive_name_in_supergraph { + Some(name) => value_pos.is_inaccessible(&self.merged, name)?, + None => false, + }; + + // The merging strategy depends on the enum type usage: + // - if it is _only_ used in position of Input type, we merge it with an "intersection" strategy (like other input types/things). + // - if it is _only_ used in position of Output type, we merge it with an "union" strategy (like other output types/things). + // - otherwise, it's used as both input and output and we can only merge it if it has the same values in all subgraphs. + // So in particular, the value will be in the supergraph only if it is either an "output only" enum, or if the value is in all subgraphs. + // Note that (like for input object fields), manually marking the value as @inaccessible let's use skips any check and add the value + // regardless of inconsistencies. + let violates_intersection_requirement = !is_inaccessible + && sources.values().any(|source| { + source + .as_ref() + .is_some_and(|enum_type| !enum_type.values.contains_key(&value_pos.value_name)) + }); + // We have a source (subgraph) that _has_ the enum type but not that particular enum value. If we're in the "both input and output usages", + // that's where we have to fail. But if we're in the "only input" case, we simply don't merge that particular value and hint about it. + match usage { + EnumTypeUsage::Both { + input_example, + output_example, + } if violates_intersection_requirement => { + self.report_mismatch_error_with_specifics( + CompositionError::EnumValueMismatch { + message: format!( + "Enum type \"{}\" is used as both input type (for example, as type of \"{}\") and output type (for example, as type of \"{}\"), but value \"{}\" is not defined in all the subgraphs defining \"{}\": ", + &value_pos.type_name, input_example.coordinate, output_example.coordinate, &value_pos.value_name, &value_pos.type_name + ), + }, + sources, + |source| { + source.as_ref().map_or("no", |enum_type| { + if enum_type.values.contains_key(&value_pos.value_name) { "yes" } else { "no" } + }) + }, + ); + } + EnumTypeUsage::Input { .. } if violates_intersection_requirement => { + self.report_mismatch_hint( + HintCode::InconsistentEnumValueForInputEnum, + format!( + "Value \"{}\" of enum type \"{}\" will not be part of the supergraph as it is not defined in all the subgraphs defining \"{}\": ", + &value_pos.value_name, &value_pos.type_name, &value_pos.type_name + ), + sources, + |source| { + source.as_ref().is_some_and(|enum_type| { + enum_type.values.contains_key(&value_pos.value_name) + }) + }, + ); + value_pos.remove(&mut self.merged)?; + } + EnumTypeUsage::Output { .. } | EnumTypeUsage::Unused => { + self.hint_on_inconsistent_output_enum_value( + sources, + &value_pos.type_name, + &value_pos.value_name, + ); + } + _ => { + // will get here if violates_intersection_requirement is false and usage is either Both or Input. + } + } + Ok(()) + } + + fn add_join_enum_value( + &mut self, + sources: &Sources<&Component>, + value_pos: &EnumValueDefinitionPosition, + ) -> Result<(), FederationError> { + for (&idx, source) in sources.iter() { + if source.is_some() { + // Get the join spec name for this subgraph + let subgraph_name = &self.names[idx]; + let Some(join_spec_name) = self.subgraph_names_to_join_spec_name.get(subgraph_name) + else { + bail!( + "Could not find join spec name for subgraph '{}'", + subgraph_name + ); + }; + + let directive = self + .join_spec_definition + .enum_value_directive(&self.merged, join_spec_name)?; + let _ = value_pos.insert_directive(&mut self.merged, Node::new(directive)); + } + } + Ok(()) + } + + // TODO: These error reporting functions are not yet fully implemented + fn hint_on_inconsistent_output_enum_value( + &mut self, + sources: &Sources>, + dest_name: &Name, + value_name: &Name, + ) { + // As soon as we find a subgraph that has the type but not the member, we hint. + for enum_type in sources.values().flatten() { + if !enum_type.values.contains_key(value_name) { + self.report_mismatch_hint( + HintCode::InconsistentEnumValueForOutputEnum, + format!( + "Value \"{value_name}\" of enum type \"{dest_name}\" has been added to the supergraph but is only defined in a subset of the subgraphs defining \"{dest_name}\": " + ), + sources, + |source| { + source.as_ref().is_some_and(|enum_type| { + enum_type.values.contains_key(value_name) + }) + }, + ); + return; + } + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use apollo_compiler::Node; + use apollo_compiler::Schema; + use apollo_compiler::name; + use apollo_compiler::schema::ComponentName; + use apollo_compiler::schema::ComponentOrigin; + use apollo_compiler::schema::InterfaceType; + use apollo_compiler::schema::ObjectType; + use apollo_compiler::schema::UnionType; + + use super::*; + use crate::JOIN_VERSIONS; + use crate::SpecDefinition; + use crate::error::ErrorCode; + use crate::link::federation_spec_definition::FEDERATION_VERSIONS; + use crate::link::link_spec_definition::LINK_VERSIONS; + use crate::link::spec::Version; + use crate::merger::compose_directive_manager::ComposeDirectiveManager; + use crate::merger::error_reporter::ErrorReporter; + use crate::merger::merge::CompositionOptions; + use crate::schema::FederationSchema; + use crate::schema::position::EnumTypeDefinitionPosition; + use crate::schema::position::PositionLookupError; + + fn insert_enum_type(schema: &mut FederationSchema, name: Name) -> Result<(), FederationError> { + let status_pos = EnumTypeDefinitionPosition { + type_name: name.clone(), + }; + let dest = Node::new(EnumType { + name: name.clone(), + description: None, + directives: Default::default(), + values: Default::default(), + }); + status_pos.pre_insert(schema)?; + status_pos.insert(schema, dest)?; + Ok(()) + } + + // Helper function to create a minimal merger instance for testing + // This only initializes what's needed for merge_enum() testing + pub(crate) fn create_test_merger() -> Result { + let link_spec_definition = LINK_VERSIONS + .find(&Version { major: 1, minor: 0 }) + .expect("LINK_VERSIONS should have version 1.0"); + let join_spec_definition = JOIN_VERSIONS + .find(&Version { major: 0, minor: 5 }) + .expect("JOIN_VERSIONS should have version 0.5"); + + let schema = Schema::builder() + .adopt_orphan_extensions() + .parse( + r#" + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + enum join__Graph { + A @join__graph(name: "A", url: "http://localhost:4002/") + B @join__graph(name: "B", url: "http://localhost:4003/") + } + + scalar link__Import + + enum link__Purpose { + SECURITY + EXECUTION + } + "#, + "", + ) + .build()?; + let mut schema = FederationSchema::new(schema)?; + insert_enum_type(&mut schema, name!("Status"))?; + insert_enum_type(&mut schema, name!("UnusedStatus"))?; + + // Add interface I + let interface_pos = crate::schema::position::InterfaceTypeDefinitionPosition { + type_name: name!("I"), + }; + let interface_type = Node::new(InterfaceType { + description: None, + name: name!("I"), + implements_interfaces: Default::default(), + directives: Default::default(), + fields: Default::default(), + }); + interface_pos.pre_insert(&mut schema)?; + interface_pos.insert(&mut schema, interface_type)?; + + // Add object type A implementing I + let object_pos = crate::schema::position::ObjectTypeDefinitionPosition { + type_name: name!("A"), + }; + let mut object_type = ObjectType { + description: None, + name: name!("A"), + implements_interfaces: Default::default(), + directives: Default::default(), + fields: Default::default(), + }; + object_type + .implements_interfaces + .insert(ComponentName::from(name!("I"))); + object_pos.pre_insert(&mut schema)?; + object_pos.insert(&mut schema, Node::new(object_type))?; + + // Add union U with member A + let union_pos = crate::schema::position::UnionTypeDefinitionPosition { + type_name: name!("U"), + }; + let mut union_type = UnionType { + description: None, + name: name!("U"), + directives: Default::default(), + members: Default::default(), + }; + union_type.members.insert(ComponentName::from(name!("A"))); + union_pos.pre_insert(&mut schema)?; + union_pos.insert(&mut schema, Node::new(union_type))?; + + Ok(Merger { + subgraphs: vec![], + options: CompositionOptions::default(), + names: vec!["subgraph1".to_string(), "subgraph2".to_string()], + compose_directive_manager: ComposeDirectiveManager::new(), + error_reporter: ErrorReporter::new(vec![ + "subgraph1".to_string(), + "subgraph2".to_string(), + ]), + merged: schema, + subgraph_names_to_join_spec_name: [ + ( + "subgraph1".to_string(), + Name::new("SUBGRAPH1").expect("Valid name"), + ), + ( + "subgraph2".to_string(), + Name::new("SUBGRAPH2").expect("Valid name"), + ), + ] + .into_iter() + .collect(), + merged_federation_directive_names: Default::default(), + merged_federation_directive_in_supergraph_by_directive_name: Default::default(), + enum_usages: Default::default(), + fields_with_from_context: Default::default(), + fields_with_override: Default::default(), + subgraph_enum_values: Vec::new(), + inaccessible_directive_name_in_supergraph: None, + link_spec_definition, + join_spec_definition, + join_directive_identities: Default::default(), + schema_to_import_to_feature_url: Default::default(), + latest_federation_version_used: FEDERATION_VERSIONS.latest().version().clone(), + }) + } + + // Helper function to create enum type with values + fn create_enum_type(name: &str, values: &[&str]) -> Node { + let mut enum_type = EnumType { + name: Name::new(name).expect("Valid enum type name"), + description: None, + directives: Default::default(), + values: Default::default(), + }; + + for value_name in values { + let value_name_obj = Name::new(value_name).expect("Valid enum value name"); + let value_def = Component { + origin: ComponentOrigin::Definition, + node: Node::new(EnumValueDefinition { + description: None, + value: value_name_obj.clone(), + directives: Default::default(), + }), + }; + enum_type.values.insert(value_name_obj, value_def); + } + + Node::new(enum_type) + } + + fn get_enum_values( + merger: &Merger, + enum_name: &str, + ) -> Result, PositionLookupError> { + let enum_pos = EnumTypeDefinitionPosition { + type_name: Name::new_unchecked(enum_name), + }; + Ok(enum_pos + .get(merger.merged.schema())? + .values + .keys() + .map(|key| key.to_string()) + .collect::>()) + } + + #[test] + fn test_merge_enum_output_only_enum_includes_all_values() { + let mut merger = create_test_merger().expect("valid Merger object"); + + // Create enum types from different subgraphs + let enum1 = create_enum_type("Status", &["ACTIVE", "INACTIVE"]); + let enum2 = create_enum_type("Status", &["ACTIVE", "PENDING"]); + + let sources: Sources> = + [(0, Some(enum1)), (1, Some(enum2))].into_iter().collect(); + + let dest = EnumTypeDefinitionPosition { + type_name: Name::new("Status").expect("Valid enum name"), + }; + + // Set up usage as output-only (union strategy) + merger.enum_usages.insert( + "Status".to_string(), + EnumTypeUsage::Output { + output_example: EnumExample { + coordinate: "field1".to_string(), + element_ast: None, + }, + }, + ); + + // Merge should include all values from all subgraphs for output-only enum + let result = merger.merge_enum(sources, &dest); + + assert!(result.is_ok()); + let enum_vals = + get_enum_values(&merger, "Status").expect("enum should exist in the supergraph"); + assert_eq!(enum_vals.len(), 3); // ACTIVE, INACTIVE, PENDING + assert!(enum_vals.contains(&"ACTIVE".to_string())); + assert!(enum_vals.contains(&"INACTIVE".to_string())); + assert!(enum_vals.contains(&"PENDING".to_string())); + } + + #[test] + fn test_merge_enum_input_only_enum_includes_intersection() { + let mut merger = create_test_merger().expect("valid Merger object"); + + // Create enum types from different subgraphs + let enum1 = create_enum_type("Status", &["ACTIVE", "INACTIVE"]); + let enum2 = create_enum_type("Status", &["ACTIVE", "PENDING"]); + + let sources: Sources> = + [(0, Some(enum1)), (1, Some(enum2))].into_iter().collect(); + + let dest = EnumTypeDefinitionPosition { + type_name: Name::new("Status").expect("Valid enum name"), + }; + + // Set up usage as input-only (intersection strategy) + merger.enum_usages.insert( + "Status".to_string(), + EnumTypeUsage::Input { + input_example: EnumExample { + coordinate: "field1".to_string(), + element_ast: None, + }, + }, + ); + + // Merge should only include common values for input-only enum + let result = merger.merge_enum(sources, &dest); + + assert!(result.is_ok()); + // Only ACTIVE should remain (intersection) + // INACTIVE and PENDING should be removed with hints + let enum_vals = + get_enum_values(&merger, "Status").expect("enum should exist in the supergraph"); + assert_eq!(enum_vals.len(), 1); + assert!(enum_vals.contains(&"ACTIVE".to_string())); + } + + #[test] + fn test_merge_enum_both_input_output_requires_all_values_consistent() { + let mut merger = create_test_merger().expect("valid Merger object"); + + // Create enum types from different subgraphs with inconsistent values + let enum1 = create_enum_type("Status", &["ACTIVE", "INACTIVE"]); + let enum2 = create_enum_type("Status", &["ACTIVE", "PENDING"]); + + let sources: Sources> = + [(0, Some(enum1)), (1, Some(enum2))].into_iter().collect(); + + let dest = EnumTypeDefinitionPosition { + type_name: Name::new("Status").expect("Valid enum name"), + }; + + // Set up usage as both input and output (requires consistency) + let usage = EnumTypeUsage::Both { + input_example: EnumExample { + coordinate: "field1".to_string(), + element_ast: None, + }, + output_example: EnumExample { + coordinate: "field2".to_string(), + element_ast: None, + }, + }; + + merger.enum_usages.insert("Status".to_string(), usage); + + // This should generate an error for inconsistent values + let result = merger.merge_enum(sources, &dest); + + // The function should complete but the error reporter should have errors + assert!(result.is_ok()); + assert!( + merger.error_reporter.has_errors(), + "Expected errors to be reported for inconsistent enum values" + ); + } + + #[test] + fn test_merge_enum_empty_result_generates_error() { + let mut merger = create_test_merger().expect("valid Merger object"); + + // Create enum types that will result in empty enum after merging + let enum1 = create_enum_type("Status", &["INACTIVE"]); + let enum2 = create_enum_type("Status", &["PENDING"]); + + let sources: Sources> = + [(0, Some(enum1)), (1, Some(enum2))].into_iter().collect(); + + let dest = EnumTypeDefinitionPosition { + type_name: Name::new("Status").expect("Valid enum name"), + }; + + // Set up usage as input-only (intersection strategy) + merger.enum_usages.insert( + "Status".to_string(), + EnumTypeUsage::Input { + input_example: EnumExample { + coordinate: "field1".to_string(), + element_ast: None, + }, + }, + ); + + let result = merger.merge_enum(sources, &dest); + + assert!(result.is_ok()); + // Should be empty after merging + let enum_vals = + get_enum_values(&merger, "Status").expect("enum should exist in the supergraph"); + assert_eq!(enum_vals.len(), 0); + + // Error reporter should have an EmptyMergedEnumType error + let (errors, _hints) = merger.error_reporter.into_errors_and_hints(); + assert!(errors.len() == 1); + assert!(errors[0].code() == ErrorCode::EmptyMergedEnumType); + } + + #[test] + fn test_merge_enum_unused_enum_treated_as_output() { + let mut merger = create_test_merger().expect("valid Merger object"); + + // Create enum types from different subgraphs + let enum1 = create_enum_type("UnusedStatus", &["ACTIVE", "INACTIVE"]); + let enum2 = create_enum_type("UnusedStatus", &["ACTIVE", "PENDING"]); + + let sources: Sources> = + [(0, Some(enum1)), (1, Some(enum2))].into_iter().collect(); + + let dest = EnumTypeDefinitionPosition { + type_name: Name::new("UnusedStatus").expect("Valid enum name"), + }; + + // Don't set usage - this should trigger the unused enum path + // which treats it as output-only + + let result = merger.merge_enum(sources, &dest); + + assert!(result.is_ok()); + // Should include all values (treated as output-only) + let enum_vals = + get_enum_values(&merger, "UnusedStatus").expect("enum should exist in the supergraph"); + assert_eq!(enum_vals.len(), 3); // ACTIVE, INACTIVE, PENDING + assert!(enum_vals.contains(&"ACTIVE".to_string())); + assert!(enum_vals.contains(&"INACTIVE".to_string())); + assert!(enum_vals.contains(&"PENDING".to_string())); + // Should generate an UnusedEnumType hint + } + + #[test] + fn test_merge_enum_identical_values_across_subgraphs() { + let mut merger = create_test_merger().expect("valid Merger object"); + + // Create identical enum types from different subgraphs + let enum1 = create_enum_type("Status", &["ACTIVE", "INACTIVE", "PENDING"]); + let enum2 = create_enum_type("Status", &["ACTIVE", "INACTIVE", "PENDING"]); + + let sources: Sources> = + [(0, Some(enum1)), (1, Some(enum2))].into_iter().collect(); + + let dest = EnumTypeDefinitionPosition { + type_name: Name::new("Status").expect("Valid enum name"), + }; + + // Set up usage as both input and output + merger.enum_usages.insert( + "Status".to_string(), + EnumTypeUsage::Both { + input_example: EnumExample { + coordinate: "field1".to_string(), + element_ast: None, + }, + output_example: EnumExample { + coordinate: "field2".to_string(), + element_ast: None, + }, + }, + ); + + let result = merger.merge_enum(sources, &dest); + + assert!(result.is_ok()); + // Should include all values since they're consistent + let enum_vals = + get_enum_values(&merger, "Status").expect("enum should exist in the supergraph"); + assert_eq!(enum_vals.len(), 3); // ACTIVE, INACTIVE, PENDING + assert!(enum_vals.contains(&"ACTIVE".to_string())); + assert!(enum_vals.contains(&"INACTIVE".to_string())); + assert!(enum_vals.contains(&"PENDING".to_string())); + // Should not generate any errors or hints + } +} diff --git a/apollo-federation/src/merger/merge_field.rs b/apollo-federation/src/merger/merge_field.rs new file mode 100644 index 0000000000..1355f4cf22 --- /dev/null +++ b/apollo-federation/src/merger/merge_field.rs @@ -0,0 +1,1646 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Argument; +use apollo_compiler::ast::DirectiveList; +use apollo_compiler::ast::InputValueDefinition; +use apollo_compiler::ast::Type; +use apollo_compiler::ast::Value; +use apollo_compiler::name; +use apollo_compiler::schema::Directive; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::FieldDefinition; + +use crate::bail; +use crate::error::CompositionError; +use crate::error::FederationError; +use crate::error::HasLocations; +use crate::error::SubgraphLocation; +use crate::link::federation_spec_definition::FEDERATION_CONTEXT_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_EXTERNAL_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_FIELD_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_FIELDS_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_FROM_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_GRAPH_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_NAME_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_OVERRIDE_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_OVERRIDE_LABEL_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_SELECTION_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_TYPE_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_USED_OVERRIDEN_ARGUMENT_NAME; +use crate::merger::merge::Merger; +use crate::merger::merge::Sources; +use crate::merger::merge::map_sources; +use crate::schema::position::CompositeTypeDefinitionPosition; +use crate::schema::position::DirectiveTargetPosition; +use crate::schema::position::FieldDefinitionPosition; +use crate::schema::position::InterfaceTypeDefinitionPosition; +use crate::schema::position::ObjectFieldArgumentDefinitionPosition; +use crate::schema::position::ObjectFieldDefinitionPosition; +use crate::schema::position::ObjectTypeDefinitionPosition; +use crate::schema::validators::from_context::parse_context; +use crate::utils::human_readable::human_readable_subgraph_names; +use crate::utils::human_readable::human_readable_types; + +#[derive(Debug, Clone)] +struct SubgraphWithIndex { + subgraph: String, + idx: usize, +} + +#[derive(Debug, Clone)] +struct SubgraphField { + subgraph: SubgraphWithIndex, + #[allow(dead_code)] + field: FieldDefinitionPosition, +} + +trait HasSubgraph { + fn subgraph(&self) -> &str; +} + +impl HasSubgraph for SubgraphWithIndex { + fn subgraph(&self) -> &str { + &self.subgraph + } +} + +impl HasSubgraph for SubgraphField { + fn subgraph(&self) -> &str { + &self.subgraph.subgraph + } +} + +impl SubgraphField { + fn locations(&self, merger: &Merger) -> Vec { + let Some(subgraph) = merger.subgraphs.get(self.subgraph.idx) else { + // Skip if the subgraph is not found + // Note: This is unexpected in production, but it happens in unit tests. + return Vec::new(); + }; + self.field.locations(subgraph) + } +} + +impl Merger { + #[allow(dead_code)] + pub(crate) fn merge_field( + &mut self, + sources: &Sources, + dest: &DirectiveTargetPosition, + ) -> Result<(), FederationError> { + let every_source_is_external = sources.iter().all(|(i, source)| { + let Some(metadata) = self.subgraphs.get(*i).map(|s| s.metadata()) else { + // If subgraph not found, consider it not external to fail safely + return false; + }; + match source { + None => self + .fields_in_source_if_abstracted_by_interface_object(dest, *i) + .iter() + .all(|f| { + let field_pos = match f { + DirectiveTargetPosition::ObjectField(pos) => { + FieldDefinitionPosition::Object(pos.clone()) + } + DirectiveTargetPosition::InterfaceField(pos) => { + FieldDefinitionPosition::Interface(pos.clone()) + } + _ => return false, // Input objects and other non-field positions are not external + }; + metadata.external_metadata().is_external(&field_pos) + }), + Some(s) => { + let field_pos = match s { + DirectiveTargetPosition::ObjectField(pos) => { + FieldDefinitionPosition::Object(pos.clone()) + } + DirectiveTargetPosition::InterfaceField(pos) => { + FieldDefinitionPosition::Interface(pos.clone()) + } + _ => return false, // Input objects and other non-field positions are not external + }; + metadata.external_metadata().is_external(&field_pos) + } + } + }); + + if every_source_is_external { + let defining_subgraphs: Vec = sources + .iter() + .filter_map(|(i, source)| { + match source { + Some(_source_field) => { + // Direct field definition in this subgraph + Some(self.names[*i].clone()) + } + None => { + // Check for interface object fields + let itf_object_fields = + self.fields_in_source_if_abstracted_by_interface_object(dest, *i); + if itf_object_fields.is_empty() { + return None; + } + + // Build description for interface object abstraction + Some(format!( + "{} (through @interfaceObject {})", + self.names[*i], + human_readable_types(itf_object_fields.iter().map(|f| { + match f { + DirectiveTargetPosition::ObjectField(pos) => { + pos.type_name.to_string() + } + DirectiveTargetPosition::InterfaceField(pos) => { + pos.type_name.to_string() + } + DirectiveTargetPosition::InputObjectField(pos) => { + pos.type_name.to_string() + } + _ => "unknown".to_string(), + } + })) + )) + } + } + }) + .collect(); + + // Create and report composition error + let error = CompositionError::ExternalMissingOnBase { + message: format!( + "Field \"{}\" is marked @external on all the subgraphs in which it is listed ({}).", + dest, + defining_subgraphs.join(", ") + ), + }; + + self.error_reporter.add_error(error); + + return Ok(()); + } + + let without_external = self.validate_and_filter_external(sources); + + // Note that we don't truly merge externals: we don't want, for instance, a field that is non-nullable everywhere to appear nullable in the + // supergraph just because someone fat-fingered the type in an external definition. But after merging the non-external definitions, we + // validate the external ones are consistent. + + self.merge_description(&without_external, dest); + self.record_applied_directives_to_merge(&without_external, dest); + self.add_arguments_shallow(&without_external, dest); + let dest_field = match dest { + DirectiveTargetPosition::ObjectField(pos) => pos.get(self.merged.schema())?, + DirectiveTargetPosition::InterfaceField(pos) => pos.get(self.merged.schema())?, + _ => { + return Ok(()); // Skip input object fields and other non-field positions + } + }; + let dest_arguments = dest_field.arguments.clone(); + for dest_arg in dest_arguments.iter() { + let subgraph_args = map_sources(&without_external, |field| { + field.as_ref().and_then(|f| { + let field_def = match f { + DirectiveTargetPosition::ObjectField(pos) => { + pos.get(self.merged.schema()).ok()? + } + DirectiveTargetPosition::InterfaceField(pos) => { + pos.get(self.merged.schema()).ok()? + } + _ => return None, // Input objects and other positions don't have field arguments + }; + field_def + .arguments + .iter() + .find(|arg| arg.name == dest_arg.name) + .cloned() + }) + }); + self.merge_argument(&subgraph_args, dest_arg)?; + } + + // Note that due to @interfaceObject, it's possible that `withoutExternal` is "empty" (has no + // non-undefined at all) but to still get here. That is, we can have: + // ``` + // # First subgraph + // interface I { + // id: ID! + // x: Int + // } + // + // type T implements I @key(fields: "id") { + // id: ID! + // x: Int @external + // y: Int @requires(fields: "x") + // } + // ``` + // and + // ``` + // # Second subgraph + // type I @interfaceObject @key(fields: "id") { + // id: ID! + // x: Int + // } + // ``` + // In that case, it is valid to mark `T.x` external because it is provided by + // another subgraph, the second one, through the interfaceObject object on I. + // But because the first subgraph is the only one to have `T` and `x` is + // external there, `withoutExternal` will be false. + // + // Anyway, we still need to merge a type in the supergraph, so in that case + // we use merge the external declarations directly. + + // Use sources with defined values if available, otherwise fall back to original sources + // This mirrors the TypeScript logic: someSources(withoutExternal, isDefined) ? withoutExternal : sources + let sources_for_type_merge = + if Self::some_sources(&without_external, |source, _| source.is_some()) { + &without_external + } else { + sources + }; + + // Transform FieldDefinitionPosition sources to Type sources + let type_sources: Sources = sources_for_type_merge + .iter() + .map(|(idx, field_pos)| { + let type_option = field_pos + .as_ref() + .and_then(|pos| match pos { + DirectiveTargetPosition::ObjectField(p) => p.get(self.merged.schema()).ok(), + DirectiveTargetPosition::InterfaceField(p) => { + p.get(self.merged.schema()).ok() + } + _ => None, // Input objects and other positions don't participate in type merging + }) + .map(|field_def| field_def.ty.clone()); + (*idx, type_option) + }) + .collect(); + + // Get mutable access to the dest field definition for type merging + let dest_field_component = match dest { + DirectiveTargetPosition::ObjectField(pos) => pos.get(self.merged.schema())?, + DirectiveTargetPosition::InterfaceField(pos) => pos.get(self.merged.schema())?, + _ => { + return Ok(()); // Skip input object fields and other non-field positions + } + } + .clone(); + let mut dest_field_ast = dest_field_component.as_ref().clone(); + let dest_parent_type_name = match dest { + DirectiveTargetPosition::ObjectField(pos) => &pos.type_name, + DirectiveTargetPosition::InterfaceField(pos) => &pos.type_name, + DirectiveTargetPosition::InputObjectField(pos) => &pos.type_name, + _ => { + bail!("Invalid field position for parent extraction: {:?}", dest); + } + }; + let all_types_equal = self.merge_type_reference( + &type_sources, + &mut dest_field_ast, + false, + dest_parent_type_name, + )?; + + if self.has_external(sources) { + // Convert to FieldDefinitionPosition types for external field validation + let field_sources: Sources = sources + .iter() + .map(|(idx, source)| { + match source { + Some(DirectiveTargetPosition::ObjectField(pos)) => { + (*idx, Some(FieldDefinitionPosition::Object(pos.clone()))) + } + Some(DirectiveTargetPosition::InterfaceField(pos)) => { + (*idx, Some(FieldDefinitionPosition::Interface(pos.clone()))) + } + Some(_) => (*idx, None), // Non-field positions become None + None => (*idx, None), + } + }) + .collect(); + + let field_dest = match dest { + DirectiveTargetPosition::ObjectField(pos) => { + FieldDefinitionPosition::Object(pos.clone()) + } + DirectiveTargetPosition::InterfaceField(pos) => { + FieldDefinitionPosition::Interface(pos.clone()) + } + _ => return Ok(()), // Skip validation for non-field positions + }; + + self.validate_external_fields(&field_sources, &field_dest, all_types_equal)?; + } + // Create a default merge context for basic field merging + // (advanced override scenarios would provide a more sophisticated context) + let merge_context = FieldMergeContext::default(); + self.add_join_field(sources, dest, all_types_equal, &merge_context)?; + self.add_join_directive_directives(sources, dest)?; + Ok(()) + } + + fn fields_in_source_if_abstracted_by_interface_object( + &self, + dest_field: &DirectiveTargetPosition, + source_idx: usize, + ) -> Vec { + // Get the parent type of the destination field + let parent_in_supergraph = match dest_field { + DirectiveTargetPosition::ObjectField(field) => { + CompositeTypeDefinitionPosition::Object(field.parent()) + } + DirectiveTargetPosition::InterfaceField(field) => { + CompositeTypeDefinitionPosition::Interface(field.parent()) + } + _ => return Vec::new(), // Input objects and other positions can't be abstracted by interface objects + }; + + // Check if parent is an object type, if not or if it exists in the source schema, return empty + if !parent_in_supergraph.is_object_type() { + return Vec::new(); + } + + let subgraph_schema = self.subgraphs[source_idx].validated_schema().schema(); + if subgraph_schema + .types + .contains_key(parent_in_supergraph.type_name()) + { + return Vec::new(); + } + + let field_name = match dest_field { + DirectiveTargetPosition::ObjectField(pos) => &pos.field_name, + DirectiveTargetPosition::InterfaceField(pos) => &pos.field_name, + DirectiveTargetPosition::InputObjectField(pos) => &pos.field_name, + _ => { + return Vec::new(); // Skip non-field positions + } + }; + + let parent_object = match dest_field { + DirectiveTargetPosition::ObjectField(pos) => { + CompositeTypeDefinitionPosition::Object(ObjectTypeDefinitionPosition { + type_name: pos.type_name.clone(), + }) + } + DirectiveTargetPosition::InterfaceField(pos) => { + CompositeTypeDefinitionPosition::Interface(InterfaceTypeDefinitionPosition { + type_name: pos.type_name.clone(), + }) + } + _ => { + return Vec::new(); // Input objects and other non-composite field types don't have interfaces + } + }; + + // Get the object type from the supergraph to access its implemented interfaces + let Ok(composite_type) = parent_object.get(self.merged.schema()) else { + return Vec::new(); + }; + + // Extract implements_interfaces from the composite type + let implements_interfaces = match composite_type { + ExtendedType::Object(obj) => &obj.implements_interfaces, + ExtendedType::Interface(iface) => &iface.implements_interfaces, + _ => { + return Vec::new(); // Union types don't have implements_interfaces + } + }; + + // Find interface object fields that provide this field + implements_interfaces + .iter() + .filter_map(|interface_name| { + // Get the interface type from the supergraph + let interface_type = self.merged.schema().types.get(&interface_name.name)?; + let ExtendedType::Interface(interface_def) = interface_type else { + return None; // Skip if not an interface type + }; + + // Check if this interface field exists + if !interface_def.fields.contains_key(field_name) { + return None; + } + + // Note that since the type is an interface in the supergraph, we can assume that + // if it is an object type in the subgraph, then it is an @interfaceObject. + let type_in_subgraph = subgraph_schema.types.get(&interface_name.name)?; + + // If it's an object type in the subgraph (while being an interface in supergraph), + // it must be an @interfaceObject + if let ExtendedType::Object(obj_type) = type_in_subgraph { + // Check if the object type has the field + if obj_type.fields.contains_key(field_name) { + Some(DirectiveTargetPosition::ObjectField( + ObjectFieldDefinitionPosition { + type_name: interface_name.name.clone(), + field_name: field_name.clone(), + }, + )) + } else { + None + } + } else { + None + } + }) + .collect() + } + + fn validate_and_filter_external( + &mut self, + sources: &Sources, + ) -> Sources { + sources + .iter() + .fold(Sources::default(), |mut filtered, (i, source)| { + match source { + // If no source or not external, mirror the input + None => { + filtered.insert(*i, source.clone()); + filtered + } + Some(field_pos) + if !match field_pos { + DirectiveTargetPosition::ObjectField(pos) => self.is_field_external( + *i, + &FieldDefinitionPosition::Object(pos.clone()), + ), + DirectiveTargetPosition::InterfaceField(pos) => self.is_field_external( + *i, + &FieldDefinitionPosition::Interface(pos.clone()), + ), + _ => false, // Non-field positions can't be external + } => + { + filtered.insert(*i, source.clone()); + filtered + } + // External field: filter out but validate directives + Some(field_pos) => { + filtered.insert(*i, None); + + // Validate that external fields don't have merged directives + // We don't allow "merged" directives on external fields because as far as merging goes, external fields don't really + // exist and allowing "merged" directives on them is dodgy. To take examples, having a `@deprecated` or `@tag` on + // an external feels unclear semantically: should it deprecate/tag the field? Essentially we're saying that "no it + // shouldn't" and so it's clearer to reject it. + // Note that if we change our mind on this semantic and wanted directives on external to propagate, then we'll also + // need to update the merging of fields since external fields are filtered out (by this very method). + + // Convert DirectiveTargetPosition to FieldDefinitionPosition for @external validation + if let Some(field_def_pos) = match field_pos { + DirectiveTargetPosition::ObjectField(pos) => { + Some(FieldDefinitionPosition::Object(pos.clone())) + } + DirectiveTargetPosition::InterfaceField(pos) => { + Some(FieldDefinitionPosition::Interface(pos.clone())) + } + _ => None, // @external only applies to object and interface fields + } { + // Ignore validation result: validation errors are reported via error_reporter, + // and field lookup failures are treated as non-fatal during filtering + let _ = self.validate_external_field_directives(*i, &field_def_pos); + } + filtered + } + } + }) + } + + #[allow(dead_code)] + fn validate_external_field_directives( + &mut self, + source_idx: usize, + field_pos: &FieldDefinitionPosition, + ) -> Result<(), FederationError> { + // Get the field definition to check its directives + let field_def = field_pos.get(self.subgraphs[source_idx].validated_schema().schema())?; + + // Check each directive for violations + for directive in &field_def.directives { + if self.is_merged_directive(&self.names[source_idx], directive) { + // Contrarily to most of the errors during merging that "merge" errors for related elements, we're logging one + // error for every application here. But this is because the error is somewhat subgraph specific and is + // unlikely to span multiple subgraphs. In fact, we could almost have thrown this error during subgraph validation + // if this wasn't for the fact that it is only thrown for directives being merged and so is more logical to + // be thrown only when merging. + + let error = CompositionError::MergedDirectiveApplicationOnExternal { + message: format!( + "Cannot apply merged directive @{} to external field \"{}\" (in subgraph \"{}\")", + directive.name, field_pos, self.names[source_idx] + ), + }; + + self.error_reporter.add_error(error); + } + } + + Ok(()) + } + + fn is_field_external(&self, source_idx: usize, field: &FieldDefinitionPosition) -> bool { + // Use the subgraph metadata to check if field is external + self.subgraphs[source_idx] + .metadata() + .is_field_external(field) + } + + /// Check if any of the provided sources contains external fields + /// Uses some_sources for efficient checking + fn has_external(&self, sources: &Sources) -> bool { + Self::some_sources(sources, |source, idx| match source { + Some(field_pos) => match field_pos { + DirectiveTargetPosition::ObjectField(pos) => { + self.is_field_external(idx, &FieldDefinitionPosition::Object(pos.clone())) + } + DirectiveTargetPosition::InterfaceField(pos) => { + self.is_field_external(idx, &FieldDefinitionPosition::Interface(pos.clone())) + } + _ => false, // Non-field positions can't be external + }, + None => false, + }) + } + + /// Validate external field constraints across subgraphs + fn validate_external_fields( + &mut self, + sources: &Sources, + dest: &FieldDefinitionPosition, + all_types_equal: bool, + ) -> Result<(), FederationError> { + // Get the destination field definition for validation + let dest_field = dest.get(self.merged.schema())?; + let dest_field_ty = dest_field.ty.clone(); + let dest_args = dest_field.arguments.to_vec(); + + // Phase 1: Collection - collect all error types into separate sets + let mut has_invalid_types = false; + let mut invalid_args_presence = HashSet::new(); + let mut invalid_args_types = HashSet::new(); + let mut invalid_args_defaults = HashSet::new(); + + for (source_idx, source) in sources.iter() { + let Some(source_field_pos) = source else { + continue; + }; + + // Only validate external fields + if !self.is_field_external(*source_idx, source_field_pos) { + continue; + } + + let source_field = + source_field_pos.get(self.subgraphs[*source_idx].validated_schema().schema())?; + let source_args = source_field.arguments.to_vec(); + + // To be valid, an external field must use the same type as the merged field (or "at least" a subtype). + let is_subtype = if all_types_equal { + false + } else { + self.is_strict_subtype(&dest_field_ty, &source_field.ty) + .unwrap_or(false) + }; + + if !(dest_field_ty == source_field.ty || is_subtype) { + has_invalid_types = true; + } + + // For arguments, it should at least have all the arguments of the merged, and their type needs to be supertypes (contravariance). + // We also require the default is that of the supergraph (maybe we could relax that, but we should decide how we want + // to deal with field with arguments in @key, @provides, @requires first as this could impact it). + for dest_arg in &dest_args { + let name = &dest_arg.name; + let Some(source_arg) = source_args.iter().find(|arg| &arg.name == name) else { + invalid_args_presence.insert(name.clone()); + continue; + }; + let arg_is_subtype = self + .is_strict_subtype(&source_arg.ty, &dest_arg.ty) + .unwrap_or(false); + if dest_arg.ty != source_arg.ty && !arg_is_subtype { + invalid_args_types.insert(name.clone()); + } + // TODO: Use valueEquals instead of != for proper GraphQL value comparison + // See: https://github.com/apollographql/federation/blob/4653320016ed4202a229d9ab5933ad3f13e5b6c0/composition-js/src/merging/merge.ts#L1877 + if dest_arg.default_value != source_arg.default_value { + invalid_args_defaults.insert(name.clone()); + } + } + } + + // Phase 2: Reporting - report errors in groups, matching JS version order + if has_invalid_types { + self.error_reporter.report_mismatch_error::( + CompositionError::ExternalTypeMismatch { + message: format!( + "Type of field \"{dest}\" is incompatible across subgraphs (where marked @external): it has ", + ), + }, + dest, + sources, + |source, _| Some(format!("type \"{source}\"")), + ); + } + + for arg_name in &invalid_args_presence { + self.report_mismatch_error_with_specifics( + CompositionError::ExternalArgumentMissing { + message: format!( + "Field \"{dest}\" is missing argument \"{arg_name}\" in some subgraphs where it is marked @external: " + ), + }, + &self.argument_sources(sources, arg_name)?, + |source| source.as_ref().map_or("", |_| ""), + ); + } + + for arg_name in &invalid_args_types { + let argument_pos = ObjectFieldArgumentDefinitionPosition { + type_name: dest.type_name().clone(), + field_name: dest.field_name().clone(), + argument_name: arg_name.clone(), + }; + self.error_reporter.report_mismatch_error::( + CompositionError::ExternalArgumentTypeMismatch { + message: format!( + "Type of argument \"{argument_pos}\" is incompatible across subgraphs (where \"{dest}\" is marked @external): it has ", + ), + }, + &argument_pos, + &self.argument_sources(sources, arg_name)?, + |source, _| Some(format!("type \"{source}\"")), + ); + } + + for arg_name in &invalid_args_defaults { + let argument_pos = ObjectFieldArgumentDefinitionPosition { + type_name: dest.type_name().clone(), + field_name: dest.field_name().clone(), + argument_name: arg_name.clone(), + }; + self.error_reporter.report_mismatch_error::( + CompositionError::ExternalArgumentDefaultMismatch { + message: format!( + "Argument \"{argument_pos}\" has incompatible defaults across subgraphs (where \"{dest}\" is marked @external): it has ", + ), + }, + &argument_pos, + &self.argument_sources(sources, arg_name)?, + |source, _| Some(format!("default value {source:?}")), // TODO: Need proper value formatting + ); + } + + Ok(()) + } + + /// Create argument sources from field sources for a specific argument + /// Transforms Sources into Sources + fn argument_sources( + &self, + sources: &Sources, + dest_arg_name: &Name, + ) -> Result, FederationError> { + let mut arg_sources = Sources::default(); + + for (source_idx, source_field_pos) in sources.iter() { + let arg_position = if let Some(field_pos) = source_field_pos { + // Get the field definition to check if it has the argument + let field_def = + field_pos.get(self.subgraphs[*source_idx].validated_schema().schema())?; + + // Check if the field has this argument + if field_def + .arguments + .iter() + .any(|arg| &arg.name == dest_arg_name) + { + Some(ObjectFieldArgumentDefinitionPosition { + type_name: field_pos.type_name().clone(), + field_name: field_pos.field_name().clone(), + argument_name: dest_arg_name.clone(), + }) + } else { + None + } + } else { + None + }; + + arg_sources.insert(*source_idx, arg_position); + } + + Ok(arg_sources) + } + + #[allow(dead_code)] + fn validate_field_sharing( + &mut self, + sources: &Sources, + dest: &FieldDefinitionPosition, + merge_context: &FieldMergeContext, + ) -> Result<(), FederationError> { + let mut shareable_sources: Vec = Vec::new(); + let mut non_shareable_sources: Vec = Vec::new(); + let mut all_resolving: Vec = Vec::new(); + + // Helper function to categorize a field + let mut categorize_field = + |idx: usize, subgraph: String, field: &FieldDefinitionPosition| { + if !self.subgraphs[idx] + .metadata() + .is_field_fully_external(field) + { + all_resolving.push(SubgraphField { + subgraph: SubgraphWithIndex { + subgraph: subgraph.clone(), + idx, + }, + field: field.clone(), + }); + if self.subgraphs[idx].metadata().is_field_shareable(field) { + shareable_sources.push(SubgraphWithIndex { subgraph, idx }); + } else { + non_shareable_sources.push(SubgraphWithIndex { subgraph, idx }); + } + } + }; + + // Iterate over sources and categorize fields + for (idx, source) in sources.iter() { + if let Some(field) = source { + if !merge_context.is_used_overridden(*idx) + && !merge_context.is_unused_overridden(*idx) + { + let subgraph = self.names[*idx].clone(); + categorize_field(*idx, subgraph, field); + } + } else { + let target: DirectiveTargetPosition = + DirectiveTargetPosition::try_from(dest.clone())?; + let itf_object_fields = + self.fields_in_source_if_abstracted_by_interface_object(&target, *idx); + for field in itf_object_fields { + let field_pos = FieldDefinitionPosition::try_from(field.clone()) + .map_err(|err| FederationError::internal(err.to_string()))?; + let subgraph_str = format!( + "{} (through @interfaceObject field \"{}.{}\")", + self.names[*idx], + field_pos.type_name(), + field_pos.field_name() + ); + categorize_field(*idx, subgraph_str, &field_pos); + } + } + } + + fn print_subgraphs(arr: &[T]) -> String { + human_readable_subgraph_names(arr.iter().map(|s| s.subgraph())) + } + + if !non_shareable_sources.is_empty() + && (!shareable_sources.is_empty() || non_shareable_sources.len() > 1) + { + let resolving_subgraphs = print_subgraphs(&all_resolving); + let non_shareables = if shareable_sources.is_empty() { + "all of them".to_string() + } else { + print_subgraphs(&non_shareable_sources) + }; + + // A common error that can lead here is misspelling the `from` argument of an @override directive. In that case, the + // @override will essentially be ignored (we'll have logged a warning, but the error we're about to log will overshadow it) and + // the two field instances will violate the sharing rules. Since the error is ultimately related to @override, it + // can be hard for users to understand why they're getting a shareability error. We detect this case and offer an additional hint + // about what the problem might be in the error message. Note that even if we do find an @override with an unknown target, we + // cannot be 100% sure this is the issue, because it could also be targeting a subgraph that has just been removed, in which + // case the shareability error is legitimate. Keeping the shareability error with a strong hint should be sufficient in practice. + // Note: if there are multiple non-shareable fields with "target-less overrides", we only hint about one of them, because that's + // easier and almost certainly sufficient to draw the user's attention to potential typos in @override usage. + let subgraph_with_targetless_override = non_shareable_sources + .iter() + .find(|s| merge_context.has_override_with_unknown_target(s.idx)); + + let extra_hint = if let Some(s) = subgraph_with_targetless_override { + format!( + " (please note that \"{}.{}\" has an @override directive in {} that targets an unknown subgraph so this could be due to misspelling the @override(from:) argument)", + dest.type_name(), + dest.field_name(), + s.subgraph, + ) + } else { + "".to_string() + }; + self.error_reporter.add_error(CompositionError::InvalidFieldSharing { + message: format!( + "Non-shareable field \"{}.{}\" is resolved from multiple subgraphs: it is resolved from {} and defined as non-shareable in {}{}", + dest.type_name(), + dest.field_name(), + resolving_subgraphs, + non_shareables, + extra_hint, + ), + locations: all_resolving.iter().flat_map(|field| + field.locations(self) + ).collect(), + }); + } + Ok(()) + } +} + +// ============================================================================ +// Join Field Directive Management +// ============================================================================ + +/// Properties tracked for each source index during field merging. +#[derive(Debug, Default, Clone)] +pub(crate) struct FieldMergeContextProperties { + pub used_overridden: bool, + pub unused_overridden: bool, + #[allow(dead_code)] + pub override_with_unknown_target: bool, + pub override_label: Option, +} + +/// Context for field merging, holding per-source-index properties. +#[derive(Debug, Default, Clone)] +pub(crate) struct FieldMergeContext { + props: HashMap, +} + +impl FieldMergeContext { + #[allow(dead_code)] + pub(crate) fn new>(indices: I) -> Self { + let mut props = HashMap::new(); + for i in indices { + props.insert(i, FieldMergeContextProperties::default()); + } + FieldMergeContext { props } + } + + #[allow(dead_code)] + pub(crate) fn is_used_overridden(&self, idx: usize) -> bool { + self.props + .get(&idx) + .map(|p| p.used_overridden) + .unwrap_or(false) + } + + #[allow(dead_code)] + pub(crate) fn is_unused_overridden(&self, idx: usize) -> bool { + self.props + .get(&idx) + .map(|p| p.unused_overridden) + .unwrap_or(false) + } + + #[allow(dead_code)] + pub(crate) fn set_used_overridden(&mut self, idx: usize) { + if let Some(p) = self.props.get_mut(&idx) { + p.used_overridden = true; + } + } + + #[allow(dead_code)] + pub(crate) fn set_unused_overridden(&mut self, idx: usize) { + if let Some(p) = self.props.get_mut(&idx) { + p.unused_overridden = true; + } + } + + #[allow(dead_code)] + pub(crate) fn set_override_with_unknown_target(&mut self, idx: usize) { + if let Some(p) = self.props.get_mut(&idx) { + p.override_with_unknown_target = true; + } + } + + #[allow(dead_code)] + pub(crate) fn set_override_label(&mut self, idx: usize, label: String) { + if let Some(p) = self.props.get_mut(&idx) { + p.override_label = Some(label); + } + } + + #[allow(dead_code)] + pub(crate) fn override_label(&self, idx: usize) -> Option<&str> { + self.props + .get(&idx) + .and_then(|p| p.override_label.as_deref()) + } + + #[allow(dead_code)] + pub(crate) fn has_override_with_unknown_target(&self, idx: usize) -> bool { + self.props + .get(&idx) + .map(|p| p.override_with_unknown_target) + .unwrap_or(false) + } + + #[allow(dead_code)] + pub(crate) fn some(&self, mut predicate: F) -> bool + where + F: FnMut(&FieldMergeContextProperties, usize) -> bool, + { + self.props.iter().any(|(&i, p)| predicate(p, i)) + } +} + +enum JoinableField<'a> { + Output(&'a FieldDefinition), + Input(&'a InputValueDefinition), +} + +impl<'a> JoinableField<'a> { + fn ty(&self) -> &Type { + match self { + JoinableField::Output(field) => &field.ty, + JoinableField::Input(input) => &input.ty, + } + } + + fn directives(&self) -> &DirectiveList { + match self { + JoinableField::Output(field) => &field.directives, + JoinableField::Input(input) => &input.directives, + } + } + + fn arguments(&self) -> Vec> { + match self { + JoinableField::Output(field) => field.arguments.clone(), + JoinableField::Input(input) => vec![Node::new((*input).clone())], + } + } +} + +impl Merger { + /// Adds a join__field directive to a field definition with appropriate arguments. + /// This constructs the directive with graph, external, requires, provides, type, + /// override, overrideLabel, usedOverridden, and contextArguments as needed. + #[allow(dead_code)] + pub(crate) fn add_join_field( + &mut self, + sources: &Sources, + dest: &DirectiveTargetPosition, + all_types_equal: bool, + merge_context: &FieldMergeContext, + ) -> Result<(), FederationError> { + let parent_name = match dest { + DirectiveTargetPosition::ObjectField(pos) => &pos.type_name, + DirectiveTargetPosition::InterfaceField(pos) => &pos.type_name, + DirectiveTargetPosition::InputObjectField(pos) => &pos.type_name, + _ => { + bail!("Invalid DirectiveTargetPosition for join field: {:?}", dest); + } + }; + + // Skip if no join__field directive is required for this field. + match self.needs_join_field(sources, parent_name, all_types_equal, merge_context) { + Ok(needs) if !needs => return Ok(()), // No join__field needed, exit early + Err(_) => return Ok(()), // Skip on error - invalid parent name + Ok(_) => {} // needs join field, continue + } + + // Filter source fields by override usage and override label presence. + let sources_with_override = sources.iter().filter_map(|(&idx, source_opt)| { + let used_overridden = merge_context.is_used_overridden(idx); + let unused_overridden = merge_context.is_unused_overridden(idx); + let override_label = merge_context.override_label(idx); + + match source_opt { + None => None, + Some(_) if unused_overridden && override_label.is_none() => None, + Some(source) => Some((idx, source, used_overridden, override_label)), + } + }); + + // Iterate through valid source fields. + for (idx, source, used_overridden, override_label) in sources_with_override { + // Resolve the graph enum value for this subgraph index. + let Some(graph_name) = self.subgraph_enum_values.get(idx) else { + continue; + }; + + let graph_value = Value::Enum(graph_name.to_name()); + + let field_def = match source { + DirectiveTargetPosition::ObjectField(pos) => { + let def = pos + .get(self.subgraphs[idx].schema().schema()) + .map_err(|err| { + FederationError::internal(format!( + "Cannot find object field definition for subgraph {}: {}", + self.subgraphs[idx].name, err + )) + })?; + JoinableField::Output(def) + } + DirectiveTargetPosition::InterfaceField(pos) => { + let def = pos + .get(self.subgraphs[idx].schema().schema()) + .map_err(|err| { + FederationError::internal(format!( + "Cannot find interface field definition for subgraph {}: {}", + self.subgraphs[idx].name, err + )) + })?; + JoinableField::Output(def) + } + DirectiveTargetPosition::InputObjectField(pos) => { + let def = pos + .get(self.subgraphs[idx].schema().schema()) + .map_err(|err| { + FederationError::internal(format!( + "Cannot find input object field definition for subgraph {}: {}", + self.subgraphs[idx].name, err + )) + })?; + JoinableField::Input(def) + } + _ => continue, + }; + + let type_string = field_def.ty().to_string(); + + let subgraph = &self.subgraphs[idx]; + + let external = match source { + DirectiveTargetPosition::ObjectField(pos) => { + self.is_field_external(idx, &FieldDefinitionPosition::Object(pos.clone())) + } + DirectiveTargetPosition::InterfaceField(pos) => { + self.is_field_external(idx, &FieldDefinitionPosition::Interface(pos.clone())) + } + _ => false, // Non-field positions can't be external + }; + + let requires = self.get_field_set( + &field_def, + subgraph.requires_directive_name().ok().flatten().as_ref(), + ); + + let provides = self.get_field_set( + &field_def, + subgraph.provides_directive_name().ok().flatten().as_ref(), + ); + + let override_from = self.get_override_from( + &field_def, + subgraph.override_directive_name().ok().flatten().as_ref(), + ); + + let context_arguments = self.extract_context_arguments(idx, &field_def)?; + + // Build @join__field directive with applicable arguments + let mut builder = JoinFieldBuilder::new() + .arg(&FEDERATION_GRAPH_ARGUMENT_NAME, graph_value) + .maybe_bool_arg(&FEDERATION_EXTERNAL_DIRECTIVE_NAME_IN_SPEC, external) + .maybe_arg(&FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC, requires) + .maybe_arg(&FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC, provides) + .maybe_arg(&FEDERATION_OVERRIDE_DIRECTIVE_NAME_IN_SPEC, override_from) + .maybe_arg(&FEDERATION_OVERRIDE_LABEL_ARGUMENT_NAME, override_label) + .maybe_bool_arg(&FEDERATION_USED_OVERRIDEN_ARGUMENT_NAME, used_overridden) + .maybe_arg(&FEDERATION_CONTEXT_ARGUMENT_NAME, context_arguments.clone()); + + // Include field type if not uniform across subgraphs. + if !all_types_equal && !type_string.is_empty() { + builder = builder.arg(&FEDERATION_TYPE_ARGUMENT_NAME, type_string); + } + + // Attach the constructed directive to the destination field definition. + dest.insert_directive(&mut self.merged, builder.build())?; + } + + Ok(()) + } + + #[allow(dead_code)] + pub(crate) fn needs_join_field( + &self, + sources: &Sources, + parent_name: &Name, + all_types_equal: bool, + merge_context: &FieldMergeContext, + ) -> Result { + // Type mismatch across subgraphs always requires join__field + if !all_types_equal { + return Ok(true); + } + + // Used overrides or override labels require join__field + if merge_context.some(|props, _| props.used_overridden || props.override_label.is_some()) { + return Ok(true); + } + + // Check if any field has @fromContext directive + for source in sources.values().flatten() { + // Check if THIS specific source is in fields_with_from_context + match source { + DirectiveTargetPosition::ObjectField(obj_field) => { + if self + .fields_with_from_context + .object_fields + .contains(obj_field) + { + return Ok(true); + } + } + DirectiveTargetPosition::InterfaceField(intf_field) => { + if self + .fields_with_from_context + .interface_fields + .contains(intf_field) + { + return Ok(true); + } + } + _ => continue, // Input object fields and other directive targets don't have @fromContext arguments, skip + } + } + + // We can avoid the join__field if: + // 1) the field exists in all sources having the field parent type, + // 2) none of the field instance has a @requires or @provides. + // 3) none of the field is @external. + for (&idx, source_opt) in sources { + let overridden = merge_context.is_unused_overridden(idx); + match source_opt { + Some(source_pos) => { + if !overridden && let Some(subgraph) = self.subgraphs.get(idx) { + // Check if field is external + let is_external = match source_pos { + DirectiveTargetPosition::ObjectField(pos) => self.is_field_external( + idx, + &FieldDefinitionPosition::Object(pos.clone()), + ), + DirectiveTargetPosition::InterfaceField(pos) => self.is_field_external( + idx, + &FieldDefinitionPosition::Interface(pos.clone()), + ), + _ => false, // Non-field positions can't be external + }; + if is_external { + return Ok(true); + } + + // Check for requires and provides directives using subgraph-specific metadata + if let Ok(Some(provides_directive_name)) = + subgraph.provides_directive_name() + && !source_pos + .get_applied_directives(subgraph.schema(), &provides_directive_name) + .is_empty() + { + return Ok(true); + } + if let Ok(Some(requires_directive_name)) = + subgraph.requires_directive_name() + && !source_pos + .get_applied_directives(subgraph.schema(), &requires_directive_name) + .is_empty() + { + return Ok(true); + } + } + } + None => { + // This subgraph does not have the field, so if it has the field type, we need a join__field. + if let Some(subgraph) = self.subgraphs.get(idx) + && subgraph + .schema() + .try_get_type(parent_name.clone()) + .is_some() + { + return Ok(true); + } + } + } + } + + Ok(false) + } + + #[allow(dead_code)] + fn extract_context_arguments( + &self, + idx: usize, + source: &JoinableField, + ) -> Result, FederationError> { + let subgraph_name = self.subgraphs[idx].name.clone(); + + // Check if the @fromContext directive is defined in the schema + // If the directive is not defined in the schema, we cannot extract context arguments + let Ok(Some(from_context_name)) = self.subgraphs[idx].from_context_directive_name() else { + return Ok(None); + }; + + let directive_name = &from_context_name; + + let mut context_args: Vec> = vec![]; + + for arg in source.arguments().iter() { + let Some(directive) = arg.directives.get(directive_name) else { + continue; + }; + + let Some(field) = directive + .specified_argument_by_name(&FEDERATION_FIELD_ARGUMENT_NAME) + .and_then(|v| v.as_str()) + else { + continue; + }; + + let (Some(context), Some(selection)) = parse_context(field) else { + continue; + }; + + let prefixed_context = format!("{subgraph_name}__{context}"); + + context_args.push(Node::new(Value::Object(vec![ + (name!("context"), Node::new(Value::String(prefixed_context))), + ( + FEDERATION_NAME_ARGUMENT_NAME, + Node::new(Value::String(arg.name.to_string())), + ), + ( + FEDERATION_TYPE_ARGUMENT_NAME, + Node::new(Value::String(arg.ty.to_string())), + ), + ( + FEDERATION_SELECTION_ARGUMENT_NAME, + Node::new(Value::String(selection)), + ), + ]))); + } + + Ok((!context_args.is_empty()).then(|| Value::List(context_args))) + } + + /// Extract field set from directive + #[allow(dead_code)] + fn get_field_set( + &self, + field_def: &JoinableField, + directive_name: Option<&Name>, + ) -> Option { + let directive_name = directive_name?; + + field_def + .directives() + .get(directive_name)? + .specified_argument_by_name(&FEDERATION_FIELDS_ARGUMENT_NAME)? + .as_str() + .map(|v| v.to_string()) + } + + /// Extract override "from" argument + #[allow(dead_code)] + fn get_override_from( + &self, + field_def: &JoinableField, + directive_name: Option<&Name>, + ) -> Option { + let directive_name = directive_name?; + + Some( + field_def + .directives() + .get(directive_name)? + .specified_argument_by_name(&FEDERATION_FROM_ARGUMENT_NAME)? + .as_str()? + .to_string(), + ) + } +} + +/// Simple builder for join__field directives (minimal version for compatibility) +#[allow(dead_code)] +struct JoinFieldBuilder { + arguments: Vec>, +} + +#[allow(dead_code)] +impl JoinFieldBuilder { + fn new() -> Self { + Self { + arguments: Vec::new(), + } + } + + fn arg>(mut self, key: &Name, value: T) -> Self { + self.arguments.push(Node::new(Argument { + name: key.clone(), + value: Node::new(value.into()), + })); + self + } + + fn maybe_arg>(self, key: &Name, value: Option) -> Self { + if let Some(v) = value { + self.arg(key, v) + } else { + self + } + } + + fn maybe_bool_arg(self, key: &Name, condition: bool) -> Self { + if condition { + self.arg(key, Value::Boolean(true)) + } else { + self + } + } + + fn build(self) -> Directive { + Directive { + name: name!("join__field"), + arguments: self.arguments, + } + } +} + +#[cfg(test)] +mod join_field_tests { + use apollo_compiler::Name; + use apollo_compiler::collections::IndexMap; + + use super::*; + use crate::merger::merge_enum::tests::create_test_merger; + use crate::schema::position::DirectiveTargetPosition; + use crate::schema::position::ObjectFieldDefinitionPosition; + + // Helper function to create merge context + fn make_merge_context( + used_overridden: Vec, + override_labels: Vec>, + ) -> FieldMergeContext { + let indices: Vec = (0..used_overridden.len()).collect(); + let mut context = FieldMergeContext::new(indices); + + for (idx, &used) in used_overridden.iter().enumerate() { + if used { + context.set_used_overridden(idx); + } + } + + for (idx, label) in override_labels.into_iter().enumerate() { + if let Some(label_str) = label { + context.set_override_label(idx, label_str); + } + } + + context + } + + // Core logic tests + #[test] + fn test_types_differ_emits_join_field() { + let merger = create_test_merger().expect("valid Merger object"); + let sources: Sources = [ + ( + 0, + Some(DirectiveTargetPosition::ObjectField( + ObjectFieldDefinitionPosition { + type_name: Name::new("Parent").unwrap(), + field_name: Name::new("foo").unwrap(), + }, + )), + ), + ( + 1, + Some(DirectiveTargetPosition::ObjectField( + ObjectFieldDefinitionPosition { + type_name: Name::new("Parent").unwrap(), + field_name: Name::new("foo").unwrap(), + }, + )), + ), + ] + .into_iter() + .collect(); + let ctx = make_merge_context(vec![false, false], vec![None, None]); + + let result = merger.needs_join_field(&sources, &name!("Parent"), false, &ctx); + assert!( + result.unwrap(), + "Should emit join field when types differ (all_types_equal = false)" + ); + } + + #[test] + fn test_all_types_equal_no_directives_skips() { + let merger = create_test_merger().expect("valid Merger object"); + let sources: Sources = [ + ( + 0, + Some(DirectiveTargetPosition::ObjectField( + ObjectFieldDefinitionPosition { + type_name: Name::new("Parent").unwrap(), + field_name: Name::new("foo").unwrap(), + }, + )), + ), + ( + 1, + Some(DirectiveTargetPosition::ObjectField( + ObjectFieldDefinitionPosition { + type_name: Name::new("Parent").unwrap(), + field_name: Name::new("foo").unwrap(), + }, + )), + ), + ] + .into_iter() + .collect(); + let ctx = make_merge_context(vec![false, false], vec![None, None]); + + let result = merger.needs_join_field(&sources, &name!("Parent"), true, &ctx); + assert!( + !result.unwrap(), + "Should skip join field when types equal and no directives" + ); + } + + #[test] + fn test_needs_join_field_returns_true_when_types_differ() { + let merger = create_test_merger().expect("valid Merger object"); + let sources: Sources = [( + 0, + Some(DirectiveTargetPosition::ObjectField( + ObjectFieldDefinitionPosition { + type_name: Name::new("Parent").unwrap(), + field_name: Name::new("foo").unwrap(), + }, + )), + )] + .into_iter() + .collect(); + let ctx = make_merge_context(vec![false], vec![None]); + + // When all_types_equal = false, needs_join_field should return true + let result = merger.needs_join_field(&sources, &name!("Parent"), false, &ctx); + assert!( + result.unwrap(), + "needs_join_field should return true when types differ" + ); + } + + #[test] + fn test_needs_join_field_returns_false_when_not_needed() { + let merger = create_test_merger().expect("valid Merger object"); + let sources: Sources = [ + ( + 0, + Some(DirectiveTargetPosition::ObjectField( + ObjectFieldDefinitionPosition { + type_name: Name::new("Parent").unwrap(), + field_name: Name::new("foo").unwrap(), + }, + )), + ), + ( + 1, + Some(DirectiveTargetPosition::ObjectField( + ObjectFieldDefinitionPosition { + type_name: Name::new("Parent").unwrap(), + field_name: Name::new("foo").unwrap(), + }, + )), + ), + ] + .into_iter() + .collect(); + let ctx = make_merge_context(vec![false, false], vec![None, None]); + + // When all_types_equal = true and no special conditions, needs_join_field should return false + let result = merger.needs_join_field(&sources, &name!("Parent"), true, &ctx); + assert!( + !result.unwrap(), + "needs_join_field should return false when no join field is needed" + ); + } + + #[test] + fn test_add_join_field_early_returns_when_not_needed() { + let mut merger = create_test_merger().expect("valid Merger object"); + + // Set up a scenario where needs_join_field returns false + let sources: Sources = [( + 0, + Some(DirectiveTargetPosition::ObjectField( + ObjectFieldDefinitionPosition { + type_name: Name::new("Parent").unwrap(), + field_name: Name::new("foo").unwrap(), + }, + )), + )] + .into_iter() + .collect(); + let ctx = make_merge_context(vec![false], vec![None]); + let dest = DirectiveTargetPosition::ObjectField(ObjectFieldDefinitionPosition { + type_name: Name::new("Parent").unwrap(), + field_name: Name::new("foo").unwrap(), + }); + + // Verify needs_join_field returns false first + let needs_join = merger.needs_join_field(&sources, &name!("Parent"), true, &ctx); + assert!( + !needs_join.unwrap(), + "needs_join_field should return false when all types equal and no special conditions" + ); + + // This should early return without trying to access subgraphs or subgraph_enum_values + // because needs_join_field returns false when all_types_equal = true and no special conditions + merger.add_join_field(&sources, &dest, true, &ctx).unwrap(); + + // The test passes if no panic occurs (the early return worked) + } + + #[test] + fn test_field_merge_context_integration() { + // Test that FieldMergeContext can be created and used + let merge_context = FieldMergeContext::default(); + + // Test basic methods that should be available + let unused = merge_context.is_unused_overridden(0); + let label = merge_context.override_label(0); + + // Assert the expected behavior + assert!( + !unused, + "Default context should not have unused overridden fields" + ); + assert!( + label.is_none(), + "Default context should not have override labels" + ); + + // Test that Sources works correctly with our types + let mut sources: Sources = IndexMap::default(); + sources.insert(0, None); + + // Test iteration + for (idx, field_opt) in &sources { + assert_eq!(*idx, 0); + assert!(field_opt.is_none()); + } + + // Test that we can create a FieldMergeContext + let context = FieldMergeContext::new(vec![0, 1]); + assert!( + !context.is_used_overridden(0), + "New context should not have used overridden fields" + ); + assert!( + !context.is_used_overridden(1), + "New context should not have used overridden fields" + ); + } + + // Note: Tests for federation directive detection (like @provides, @requires, @external) + // are not included here because they require full subgraph schemas with federation directives. + // The create_test_merger() function creates a minimal merger with empty subgraphs, + // so the federation directive detection logic in needs_join_field() cannot access + // actual field definitions. These scenarios should be tested in integration tests + // that set up complete federation schemas. + + #[test] + fn test_field_merge_context_behavior() { + // Test FieldMergeContext behavior that our methods rely on + let ctx = make_merge_context( + vec![false, true, false], + vec![None, Some("label1".to_string()), None], + ); + + // Test override detection + assert!(!ctx.is_used_overridden(0)); + assert!(ctx.is_used_overridden(1)); + assert!(!ctx.is_used_overridden(2)); + + // Test override labels + assert_eq!(ctx.override_label(0), None); + assert_eq!(ctx.override_label(1), Some("label1")); + assert_eq!(ctx.override_label(2), None); + + // Test some() method + let has_override = + ctx.some(|props, _idx| props.used_overridden || props.override_label.is_some()); + assert!(has_override, "Should detect override conditions"); + + let no_override_ctx = make_merge_context(vec![false, false], vec![None, None]); + let no_override = no_override_ctx + .some(|props, _idx| props.used_overridden || props.override_label.is_some()); + assert!( + !no_override, + "Should not detect override conditions when none exist" + ); + } +} diff --git a/apollo-federation/src/merger/merge_links.rs b/apollo-federation/src/merger/merge_links.rs new file mode 100644 index 0000000000..47f76b470b --- /dev/null +++ b/apollo-federation/src/merger/merge_links.rs @@ -0,0 +1,287 @@ +use std::collections::HashMap; + +use apollo_compiler::Name; +use apollo_compiler::ast::DirectiveDefinition; +use apollo_compiler::collections::IndexMap; + +use crate::bail; +use crate::error::CompositionError; +use crate::error::FederationError; +use crate::link::Import; +use crate::link::Link; +use crate::link::spec::Identity; +use crate::link::spec::Url; +use crate::link::spec_definition::SPEC_REGISTRY; +use crate::link::spec_definition::SpecDefinition; +use crate::merger::merge::MergedDirectiveInfo; +use crate::merger::merge::Merger; +use crate::schema::type_and_directive_specification::DirectiveCompositionSpecification; + +#[allow(dead_code)] +pub(crate) struct CoreDirectiveInSubgraphs { + url: Url, + name: Name, + definitions_per_subgraph: HashMap, + composition_spec: DirectiveCompositionSpecification, +} + +#[allow(dead_code)] +struct CoreDirectiveInSupergraph { + spec_in_supergraph: &'static dyn SpecDefinition, + name_in_feature: Name, + name_in_supergraph: Name, + composition_spec: DirectiveCompositionSpecification, +} + +impl Merger { + pub(crate) fn collect_core_directives_to_compose( + &self, + ) -> Result, FederationError> { + // Groups directives by their feature and major version (we use negative numbers for + // pre-1.0 version numbers on the minor, since all minors are incompatible). + let mut directives_per_feature_and_version: HashMap< + String, + HashMap, + > = HashMap::new(); + + for subgraph in &self.subgraphs { + let Some(features) = subgraph.schema().metadata() else { + bail!("Subgraphs should be core schemas") + }; + + for (directive, referencers) in &subgraph.schema().referencers().directives { + let Some((source, import)) = features.directives_by_imported_name.get(directive) + else { + continue; + }; + if referencers.len() == 0 { + continue; + } + let Some(composition_spec) = SPEC_REGISTRY.get_composition_spec(source, import) + else { + continue; + }; + let Some(definition) = subgraph + .schema() + .schema() + .directive_definitions + .get(directive) + else { + bail!( + "Missing directive definition for @{directive} in {}", + source + ) + }; + + let fqn = format!("{}-{}", import.element, source.url.identity); + let for_feature = directives_per_feature_and_version.entry(fqn).or_default(); + + let major = if source.url.version.major > 0 { + source.url.version.major as i32 + } else { + -(source.url.version.minor as i32) + }; + + if let Some(for_version) = for_feature.get_mut(&major) { + if source.url.version > for_version.url.version { + for_version.url = source.url.clone(); + } + for_version + .definitions_per_subgraph + .insert(subgraph.name.clone(), (**definition).clone()); + } else { + let for_version = CoreDirectiveInSubgraphs { + url: source.url.clone(), + name: import.element.clone(), + definitions_per_subgraph: HashMap::from([( + subgraph.name.clone(), + (**definition).clone(), + )]), + composition_spec, + }; + for_feature.insert(major, for_version); + } + } + } + + Ok(directives_per_feature_and_version + .into_iter() + .flat_map(|(_, values)| values.into_values()) + .collect()) + } + + #[allow(dead_code)] + pub(crate) fn validate_and_maybe_add_specs( + &mut self, + directives_merge_info: &[CoreDirectiveInSubgraphs], + ) -> Result<(), FederationError> { + let mut supergraph_info_by_identity: HashMap> = + HashMap::new(); + + for subgraph_core_directive in directives_merge_info { + let mut name_in_supergraph: Option<&Name> = None; + for subgraph in &self.subgraphs { + let Some(directive) = subgraph_core_directive + .definitions_per_subgraph + .get(&subgraph.name) + else { + continue; + }; + + if name_in_supergraph.is_none() { + name_in_supergraph = Some(&directive.name); + } else if name_in_supergraph.is_some_and(|n| *n != subgraph_core_directive.name) { + let definition_sources: IndexMap<_, _> = self + .subgraphs + .iter() + .enumerate() + .map(|(idx, s)| { + ( + idx, + subgraph_core_directive + .definitions_per_subgraph + .get(&s.name), + ) + }) + .collect(); + self.error_reporter.report_mismatch_error::<_, ()>( + CompositionError::LinkImportNameMismatch { + message: format!("The \"@{}\" directive (from {}) is imported with mismatched name between subgraphs: it is imported as", directive.name, subgraph_core_directive.url), + }, + &directive, + &definition_sources, + |def, _| Some(format!("\"@{}\"", def.name)), + ); + return Ok(()); + } + } + + // If we get here with `name_in_supergraph` unset, it means there is no usage for the + // directive at all, and we don't bother adding the spec to the supergraph. + let Some(name_in_supergraph) = name_in_supergraph else { + continue; + }; + let Some(spec_in_supergraph) = + (subgraph_core_directive + .composition_spec + .supergraph_specification)(&self.latest_federation_version_used) + else { + continue; + }; + let supergraph_info = supergraph_info_by_identity + .entry(spec_in_supergraph.identity().clone()) + .or_insert(vec![CoreDirectiveInSupergraph { + spec_in_supergraph, + name_in_feature: subgraph_core_directive.name.clone(), + name_in_supergraph: name_in_supergraph.clone(), + composition_spec: subgraph_core_directive.composition_spec.clone(), + }]); + assert!( + supergraph_info + .iter() + .all(|s| s.spec_in_supergraph.url() == spec_in_supergraph.url()), + "Spec {} directives disagree on version for supergraph", + spec_in_supergraph.url() + ); + } + + for supergraph_core_directives in supergraph_info_by_identity.values() { + let mut imports = Vec::new(); + for supergraph_core_directive in supergraph_core_directives { + let default_name_in_supergraph = Link::directive_name_in_schema_for_core_arguments( + supergraph_core_directive.spec_in_supergraph.url(), + &supergraph_core_directive + .spec_in_supergraph + .url() + .identity + .name, + &[], + &supergraph_core_directive.name_in_feature, + ); + if supergraph_core_directive.name_in_supergraph != default_name_in_supergraph { + let alias = if supergraph_core_directive.name_in_feature + == supergraph_core_directive.name_in_supergraph + { + None + } else { + Some(supergraph_core_directive.name_in_supergraph.clone()) + }; + imports.push(Import { + element: supergraph_core_directive.name_in_feature.clone(), + is_directive: true, + alias, + }); + } + } + self.link_spec_definition.apply_feature_to_schema( + &mut self.merged, + supergraph_core_directives[0].spec_in_supergraph, + None, + supergraph_core_directives[0].spec_in_supergraph.purpose(), + Some(imports), + )?; + + let Some(links_metadata) = self.merged.metadata() else { + bail!("Missing links metadata in supergraph schema"); + }; + let feature = links_metadata.for_identity( + &supergraph_core_directives[0] + .spec_in_supergraph + .url() + .identity, + ); + for supergraph_core_directive in supergraph_core_directives { + let arguments_merger = if let Some(merger_factory) = supergraph_core_directive + .composition_spec + .argument_merger + .as_ref() + { + Some(merger_factory(&self.merged, feature.as_ref())?) + } else { + None + }; + let Some(definition) = self + .merged + .schema() + .directive_definitions + .get(&supergraph_core_directive.name_in_supergraph) + else { + bail!( + "Could not find directive definition for @{} in supergraph schema", + supergraph_core_directive.name_in_supergraph + ); + }; + self.merged_federation_directive_names + .insert(supergraph_core_directive.name_in_supergraph.to_string()); + self.merged_federation_directive_in_supergraph_by_directive_name + .insert( + supergraph_core_directive.name_in_supergraph.clone(), + MergedDirectiveInfo { + definition: (**definition).clone(), + arguments_merger, + static_argument_transform: supergraph_core_directive + .composition_spec + .static_argument_transform + .clone(), + }, + ); + // If we encounter the @inaccessible directive, we need to record its definition so + // certain merge validations that care about @inaccessible can act accordingly. + if *supergraph_core_directive.spec_in_supergraph.identity() + == Identity::inaccessible_identity() + && supergraph_core_directive.name_in_feature + == supergraph_core_directive + .spec_in_supergraph + .url() + .identity + .name + { + self.inaccessible_directive_name_in_supergraph = + Some(supergraph_core_directive.name_in_supergraph.clone()); + } + } + } + + Ok(()) + } +} diff --git a/apollo-federation/src/merger/merge_type.rs b/apollo-federation/src/merger/merge_type.rs new file mode 100644 index 0000000000..41a96871e9 --- /dev/null +++ b/apollo-federation/src/merger/merge_type.rs @@ -0,0 +1,194 @@ +use apollo_compiler::Name; +use apollo_compiler::schema::Component; + +use crate::bail; +use crate::error::CompositionError; +use crate::error::FederationError; +use crate::error::HasLocations; +use crate::link::federation_spec_definition::FEDERATION_EXTENDS_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_FIELDS_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_RESOLVABLE_ARGUMENT_NAME; +use crate::merger::merge::Merger; +use crate::merger::merge::Sources; +use crate::schema::SchemaElement; +use crate::schema::position::TypeDefinitionPosition; + +impl Merger { + pub(in crate::merger) fn merge_type(&mut self, type_def: &Name) -> Result<(), FederationError> { + let Ok(dest) = self.merged.get_type(type_def.clone()) else { + bail!( + "Type \"{}\" is missing, but it should have been shallow-copied to the supergraph schema", + type_def + ); + }; + let sources: Sources = self + .subgraphs + .iter() + .enumerate() + .map(|(idx, subgraph)| (idx, subgraph.schema().get_type(type_def.clone()).ok())) + .collect(); + + self.check_for_extension_with_no_base(&sources, &dest); + self.merge_description(&sources, &dest); + self.add_join_type(&sources, &dest)?; + self.record_applied_directives_to_merge(&sources, &dest); + self.add_join_directive_directives(&sources, &dest)?; + match dest { + TypeDefinitionPosition::Scalar(_) => { + // Since we don't handle applied directives yet, we have nothing specific to do for scalars. + } + TypeDefinitionPosition::Object(obj) => { + self.merge_object(obj); + } + TypeDefinitionPosition::Interface(itf) => { + self.merge_interface(itf); + } + TypeDefinitionPosition::Union(un) => { + let sources = sources + .iter() + .map(|(idx, pos)| { + if let Some(TypeDefinitionPosition::Union(p)) = pos { + let schema = self.subgraphs[*idx].schema().schema(); + (*idx, p.get(schema).ok().cloned()) + } else { + (*idx, None) + } + }) + .collect(); + self.merge_union(sources, &un)?; + } + TypeDefinitionPosition::Enum(en) => { + let sources = sources + .iter() + .map(|(idx, pos)| { + if let Some(TypeDefinitionPosition::Enum(p)) = pos { + let schema = self.subgraphs[*idx].schema().schema(); + (*idx, p.get(schema).ok().cloned()) + } else { + (*idx, None) + } + }) + .collect(); + self.merge_enum(sources, &en)?; + } + TypeDefinitionPosition::InputObject(io) => { + self.merge_input_object(io); + } + } + + Ok(()) + } + + /// Records errors if this type is defined as an extension in all subgraphs where it is + /// present. This is a good candidate to move to the pre-merge validations phase, but we + /// currently check during merge because it requires visiting all types in the schema. + fn check_for_extension_with_no_base( + &mut self, + sources: &Sources, + dest: &TypeDefinitionPosition, + ) { + if dest.is_object_type() && self.merged.is_root_type(dest.type_name()) { + return; + } + + let mut subgraphs_with_extension = Vec::with_capacity(sources.len()); + for (idx, source) in sources.iter() { + let Some(source) = source else { + continue; + }; + let subgraph = &self.subgraphs[*idx]; + let schema = subgraph.schema().schema(); + let Ok(element) = source.get(schema) else { + continue; + }; + + if element.has_extension_elements() { + let subgraph_name = subgraph.name.to_string(); + let element_locations = element.locations(subgraph); + subgraphs_with_extension.push((subgraph_name, element_locations)); + } else { + return; + } + } + + for (subgraph, locations) in subgraphs_with_extension { + self.error_reporter_mut() + .add_error(CompositionError::ExtensionWithNoBase { + subgraph, + dest: dest.type_name().to_string(), + locations, + }); + } + } + + /// Applies a `@join__type` directive to the merged schema for each `@key` in the source + /// schema. If this type has no `@key` applications, a single `@join__type` is applied for this + /// type. + fn add_join_type( + &mut self, + sources: &Sources, + dest: &TypeDefinitionPosition, + ) -> Result<(), FederationError> { + for (idx, source) in sources.iter() { + let Some(source) = source else { + continue; + }; + + let subgraph = &self.subgraphs[*idx]; + let is_interface_object = subgraph.is_interface_object_type(source); + + let key_directive_name = subgraph.key_directive_name()?; + let keys = if let Some(key_directive_name) = &key_directive_name { + source.get_applied_directives(subgraph.schema(), key_directive_name) + } else { + Vec::new() + }; + + let name = self.join_spec_name(*idx)?.clone(); + + if keys.is_empty() { + // If there are no keys, we apply a single `@join__type` for this type. + let directive = self.join_spec_definition.type_directive( + name, + None, + None, + None, + is_interface_object.then_some(is_interface_object), + ); + dest.insert_directive(&mut self.merged, Component::new(directive))?; + } else { + // If this type has keys, we apply a `@join__type` for each key. + let extends_directive_name = subgraph + .extends_directive_name() + .ok() + .flatten() + .clone() + .unwrap_or(FEDERATION_EXTENDS_DIRECTIVE_NAME_IN_SPEC); + + for key in keys { + let extension = key.origin.extension_id().is_some() + || source.has_applied_directive(subgraph.schema(), &extends_directive_name); + let key_fields = + key.specified_argument_by_name(&FEDERATION_FIELDS_ARGUMENT_NAME); + let key_resolvable = + key.specified_argument_by_name(&FEDERATION_RESOLVABLE_ARGUMENT_NAME); + + // Since the boolean values for this directive default to `false`, we return + // `None` instead of `Some(false)` so the value can be omitted from the + // supergraph schema. This generates smaller schemas and makes it easier to + // check equality between schemas which may not support some of the arguments. + let directive = self.join_spec_definition.type_directive( + name.clone(), + key_fields.cloned(), + extension.then_some(extension), + key_resolvable.and_then(|v| v.to_bool()), + is_interface_object.then_some(is_interface_object), + ); + dest.insert_directive(&mut self.merged, Component::new(directive))?; + } + } + } + + Ok(()) + } +} diff --git a/apollo-federation/src/merger/merge_union.rs b/apollo-federation/src/merger/merge_union.rs new file mode 100644 index 0000000000..ca0165c824 --- /dev/null +++ b/apollo-federation/src/merger/merge_union.rs @@ -0,0 +1,324 @@ +use apollo_compiler::Node; +use apollo_compiler::schema::Component; +use apollo_compiler::schema::ComponentName; +use apollo_compiler::schema::UnionType; + +use crate::error::FederationError; +use crate::merger::hints::HintCode; +use crate::merger::merge::Merger; +use crate::merger::merge::Sources; +use crate::schema::position::UnionTypeDefinitionPosition; + +impl Merger { + /// Merge union type from multiple subgraphs + #[allow(dead_code)] + pub(crate) fn merge_union( + &mut self, + sources: Sources>, + dest: &UnionTypeDefinitionPosition, + ) -> Result<(), FederationError> { + // Collect all union members from all sources + for union_type in sources.values().flatten() { + for member_name in union_type.members.iter() { + if !dest + .get(self.merged.schema())? + .members + .contains(member_name) + { + // Add the member type to the destination union + dest.insert_member(&mut self.merged, member_name.clone())?; + } + } + } + + // For each member in the destination union, add join directives and check for inconsistencies + let member_names: Vec = dest + .get(self.merged.schema())? + .members + .iter() + .cloned() + .collect(); + for member_name in member_names { + self.add_join_union_member(&sources, dest, &member_name)?; + self.hint_on_inconsistent_union_member(&sources, dest, &member_name); + } + + Ok(()) + } + + /// Add @join__unionMember directive to union members + fn add_join_union_member( + &mut self, + sources: &Sources>, + dest: &UnionTypeDefinitionPosition, + member_name: &ComponentName, + ) -> Result<(), FederationError> { + // Add @join__unionMember directive for each subgraph that has this member + for (&idx, source) in sources.iter() { + if let Some(union_type) = source + && union_type.members.contains(member_name) + { + // Get the join spec name for this subgraph + let name_in_join_spec = self.join_spec_name(idx)?; + + let directive = self.join_spec_definition.union_member_directive( + &self.merged, + name_in_join_spec, + member_name.as_ref(), + )?; + + // Apply the directive to the destination union + dest.insert_directive(&mut self.merged, Component::new(directive))?; + } + } + + Ok(()) + } + + /// Generate hint for inconsistent union member across subgraphs + fn hint_on_inconsistent_union_member( + &mut self, + sources: &Sources>, + dest: &UnionTypeDefinitionPosition, + member_name: &ComponentName, + ) { + for union_type in sources.values().flatten() { + // As soon as we find a subgraph that has the union type but not the member, we hint + if !union_type.members.contains(member_name) { + self.report_mismatch_hint( + HintCode::InconsistentUnionMember, + format!( + "Union type \"{}\" includes member type \"{}\" in some but not all defining subgraphs: ", + dest.type_name, member_name + ), + sources, + |source| { + if let Some(union_type) = source { + union_type.members.contains(member_name) + } else { + false + } + }, + ); + return; + } + } + } +} + +#[cfg(test)] +mod tests { + use apollo_compiler::Name; + use apollo_compiler::ast::Value; + use apollo_compiler::schema::ObjectType; + + use super::*; + use crate::merger::merge_enum::tests::create_test_merger; + use crate::schema::position::ObjectTypeDefinitionPosition; + + // Helper function to create a union type for testing + fn create_union_type(name: &str, member_names: &[&str]) -> Node { + let mut union_type = UnionType { + description: None, + name: Name::new_unchecked(name), + directives: Default::default(), + members: Default::default(), + }; + + for member_name in member_names { + let name_value = Name::new(member_name).expect("Valid name"); + let component_name = ComponentName::from(name_value); + union_type.members.insert(component_name); + } + + Node::new(union_type) + } + + // Helper function to create an object type for testing + fn create_object_type(name: &str) -> Node { + let object_type = ObjectType { + description: None, + name: Name::new(name).expect("Valid name"), + directives: Default::default(), + fields: Default::default(), + implements_interfaces: Default::default(), + }; + + Node::new(object_type) + } + + fn insert_union_type(merger: &mut Merger, name: &str) -> Result<(), FederationError> { + let union_pos = UnionTypeDefinitionPosition { + type_name: Name::new(name).expect("Valid name"), + }; + let union_type = create_union_type(name, &[]); + union_pos.pre_insert(&mut merger.merged)?; + union_pos.insert(&mut merger.merged, union_type)?; + Ok(()) + } + + fn insert_object_type(merger: &mut Merger, name: &str) -> Result<(), FederationError> { + let object_pos = ObjectTypeDefinitionPosition { + type_name: Name::new(name).expect("Valid name"), + }; + let object_type = create_object_type(name); + object_pos.pre_insert(&mut merger.merged)?; + object_pos.insert(&mut merger.merged, object_type)?; + Ok(()) + } + + // Helper function to create UnionTypeDefinitionPosition for testing + fn create_union_position(name: &str) -> UnionTypeDefinitionPosition { + UnionTypeDefinitionPosition { + type_name: Name::new(name).expect("Valid name"), + } + } + + #[test] + fn test_union_type_creation() { + let union1 = create_union_type("SearchResult", &["User", "Post"]); + assert_eq!(union1.members.len(), 2); + assert!( + union1 + .members + .contains(&ComponentName::from(Name::new("User").expect("Valid name"))) + ); + assert!( + union1 + .members + .contains(&ComponentName::from(Name::new("Post").expect("Valid name"))) + ); + } + + #[test] + fn test_merge_union_combines_all_members() { + let mut merger = create_test_merger().expect("Valid merger"); + + // create types in supergraph + insert_union_type(&mut merger, "SearchResult").expect("added SearchResult to supergraph"); + insert_object_type(&mut merger, "User").expect("added User to supergraph"); + insert_object_type(&mut merger, "Post").expect("added Post to supergraph"); + insert_object_type(&mut merger, "Comment").expect("added Comment to supergraph"); + // Create union types with different members + let union1 = create_union_type("SearchResult", &["User", "Post"]); + let union2 = create_union_type("SearchResult", &["User", "Comment"]); + + let sources: Sources> = + [(0, Some(union1)), (1, Some(union2))].into_iter().collect(); + + let dest = create_union_position("SearchResult"); + + let result = merger.merge_union(sources, &dest); + assert!(result.is_ok()); + // Should contain all unique members from both sources + let members = &dest + .get(merger.merged.schema()) + .expect("union in supergraph") + .members; + assert_eq!(members.len(), 3); + assert!(members.contains(&ComponentName::from(Name::new("User").expect("Valid name")))); + assert!(members.contains(&ComponentName::from(Name::new("Post").expect("Valid name")))); + assert!(members.contains(&ComponentName::from( + Name::new("Comment").expect("Valid name") + ))); + } + + #[test] + fn test_merge_union_identical_members_across_subgraphs() { + let mut merger = create_test_merger().expect("Valid merger"); + + // create types in supergraph + insert_union_type(&mut merger, "SearchResult").expect("added SearchResult to supergraph"); + insert_object_type(&mut merger, "User").expect("added User to supergraph"); + insert_object_type(&mut merger, "Post").expect("added Post to supergraph"); + + // Create union types with identical members + let union1 = create_union_type("SearchResult", &["User", "Post"]); + let union2 = create_union_type("SearchResult", &["User", "Post"]); + + let sources: Sources> = + [(0, Some(union1)), (1, Some(union2))].into_iter().collect(); + + let dest = create_union_position("SearchResult"); + + let result = merger.merge_union(sources, &dest); + + assert!(result.is_ok()); + let members = &dest + .get(merger.merged.schema()) + .expect("union in supergraph") + .members; + + // Should contain both members + assert_eq!(members.len(), 2); + assert!(members.contains(&ComponentName::from(Name::new("User").expect("Valid name")))); + assert!(members.contains(&ComponentName::from(Name::new("Post").expect("Valid name")))); + + // Verify that no hints were generated + let (_errors, hints) = merger.error_reporter.into_errors_and_hints(); + assert_eq!(hints.len(), 0); + } + + #[test] + fn test_hint_on_inconsistent_union_member() { + let mut merger = create_test_merger().expect("Valid merger"); + + // create types in supergraph + insert_union_type(&mut merger, "SearchResult").expect("added SearchResult to supergraph"); + insert_object_type(&mut merger, "User").expect("added User to supergraph"); + insert_object_type(&mut merger, "Post").expect("added Post to supergraph"); + + // Create union types where one subgraph is missing a member + let union1 = create_union_type("SearchResult", &["User", "Post"]); + let union2 = create_union_type("SearchResult", &["User"]); // Missing Post + + let sources: Sources> = + [(0, Some(union1)), (1, Some(union2))].into_iter().collect(); + + let dest = create_union_position("SearchResult"); + let result = merger.merge_union(sources, &dest); + + assert!(result.is_ok()); + // Verify that hint was generated + let (_errors, hints) = merger.error_reporter.into_errors_and_hints(); + assert_eq!(hints.len(), 1); + assert!( + hints[0] + .code() + .contains(HintCode::InconsistentUnionMember.code()) + ); + assert!(hints[0].message.contains("Post")); + assert!(hints[0].message.contains("SearchResult")); + + // validate join__unionMember directives + let added_directives = dest.get_applied_directives( + &merger.merged, + &Name::new("join__unionMember").expect("Valid name"), + ); + assert_eq!(added_directives.len(), 3); + assert!( + added_directives + .iter() + .any(|d| d.arguments.iter().any(|arg| arg.name == "graph" + && arg.value == Node::new(Value::Enum(Name::new_unchecked("SUBGRAPH1")))) + && d.arguments.iter().any(|arg| arg.name == "member" + && arg.value == Node::new(Value::String("User".to_string())))) + ); + assert!( + added_directives + .iter() + .any(|d| d.arguments.iter().any(|arg| arg.name == "graph" + && arg.value == Node::new(Value::Enum(Name::new_unchecked("SUBGRAPH1")))) + && d.arguments.iter().any(|arg| arg.name == "member" + && arg.value == Node::new(Value::String("Post".to_string())))) + ); + assert!( + added_directives + .iter() + .any(|d| d.arguments.iter().any(|arg| arg.name == "graph" + && arg.value == Node::new(Value::Enum(Name::new_unchecked("SUBGRAPH2")))) + && d.arguments.iter().any(|arg| arg.name == "member" + && arg.value == Node::new(Value::String("User".to_string())))) + ); + } +} diff --git a/apollo-federation/src/merger/merger.rs b/apollo-federation/src/merger/merger.rs new file mode 100644 index 0000000000..1d62f75592 --- /dev/null +++ b/apollo-federation/src/merger/merger.rs @@ -0,0 +1,2257 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::rc::Rc; +use std::sync::LazyLock; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::Schema; +use apollo_compiler::ast::Argument; +use apollo_compiler::ast::Directive; +use apollo_compiler::ast::DirectiveDefinition; +use apollo_compiler::ast::FieldDefinition; +use apollo_compiler::ast::InputValueDefinition; +use apollo_compiler::ast::NamedType; +use apollo_compiler::ast::Type; +use apollo_compiler::ast::Value; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::schema::Component; +use apollo_compiler::schema::EnumValueDefinition; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::validation::Valid; +use countmap::CountMap; +use indexmap::IndexSet; +use itertools::Itertools; + +use crate::LinkSpecDefinition; +use crate::bail; +use crate::error::CompositionError; +use crate::error::FederationError; +use crate::error::SubgraphLocation; +use crate::internal_error; +use crate::link::DEFAULT_LINK_NAME; +use crate::link::Link; +use crate::link::federation_spec_definition::FEDERATION_OPERATION_TYPES; +use crate::link::federation_spec_definition::FEDERATION_VERSIONS; +use crate::link::join_spec_definition::EnumValue; +use crate::link::join_spec_definition::JOIN_DIRECTIVE_DIRECTIVE_NAME_IN_SPEC; +use crate::link::join_spec_definition::JOIN_VERSIONS; +use crate::link::join_spec_definition::JoinSpecDefinition; +use crate::link::link_spec_definition::LINK_VERSIONS; +use crate::link::spec::Identity; +use crate::link::spec::Url; +use crate::link::spec::Version; +use crate::link::spec_definition::SPEC_REGISTRY; +use crate::link::spec_definition::SpecDefinition; +use crate::merger::compose_directive_manager::ComposeDirectiveManager; +use crate::merger::error_reporter::ErrorReporter; +use crate::merger::hints::HintCode; +use crate::merger::merge_enum::EnumExample; +use crate::merger::merge_enum::EnumExampleAst; +use crate::merger::merge_enum::EnumTypeUsage; +use crate::schema::FederationSchema; +use crate::schema::directive_location::DirectiveLocationExt; +use crate::schema::position::DirectiveDefinitionPosition; +use crate::schema::position::DirectiveTargetPosition; +use crate::schema::position::HasDescription; +use crate::schema::position::InputObjectTypeDefinitionPosition; +use crate::schema::position::InterfaceTypeDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceTypeDefinitionPosition; +use crate::schema::position::ObjectTypeDefinitionPosition; +use crate::schema::position::SchemaDefinitionPosition; +use crate::schema::position::TypeDefinitionPosition; +use crate::schema::referencer::DirectiveReferencers; +use crate::schema::type_and_directive_specification::ArgumentMerger; +use crate::schema::type_and_directive_specification::StaticArgumentsTransform; +use crate::subgraph::typestate::Subgraph; +use crate::subgraph::typestate::Validated; +use crate::supergraph::CompositionHint; +use crate::utils::human_readable::human_readable_subgraph_names; +use crate::utils::iter_into_single_item; + +static NON_MERGED_CORE_FEATURES: LazyLock<[Identity; 4]> = LazyLock::new(|| { + [ + Identity::federation_identity(), + Identity::link_identity(), + Identity::core_identity(), + Identity::connect_identity(), + ] +}); + +/// In JS, this is encoded indirectly in `isGraphQLBuiltInDirective`. Regardless of whether +/// the end user redefined these directives, we consider them built-in for merging. +static BUILT_IN_DIRECTIVES: [&str; 6] = [ + "skip", + "include", + "deprecated", + "specifiedBy", + "defer", + "stream", +]; + +/// Type alias for Sources mapping - maps subgraph indices to optional values +pub(crate) type Sources = IndexMap>; + +#[derive(Debug)] +pub(crate) struct MergeResult { + #[allow(dead_code)] + pub(crate) supergraph: Option>, + #[allow(dead_code)] + pub(crate) errors: Vec, + #[allow(dead_code)] + pub(crate) hints: Vec, +} + +pub(in crate::merger) struct MergedDirectiveInfo { + pub(in crate::merger) definition: DirectiveDefinition, + pub(in crate::merger) arguments_merger: Option, + pub(in crate::merger) static_argument_transform: Option>, +} + +#[derive(Debug, Default)] +pub(crate) struct CompositionOptions { + // Add options as needed - for now keeping it minimal + /// Maximum allowable number of outstanding subgraph paths to validate during satisfiability. + pub(crate) max_validation_subgraph_paths: Option, +} + +#[allow(unused)] +pub(crate) struct Merger { + pub(in crate::merger) subgraphs: Vec>, + pub(in crate::merger) options: CompositionOptions, + pub(in crate::merger) compose_directive_manager: ComposeDirectiveManager, + pub(in crate::merger) names: Vec, + pub(in crate::merger) error_reporter: ErrorReporter, + pub(in crate::merger) merged: FederationSchema, + pub(in crate::merger) subgraph_names_to_join_spec_name: HashMap, + pub(in crate::merger) merged_federation_directive_names: HashSet, + pub(in crate::merger) merged_federation_directive_in_supergraph_by_directive_name: + HashMap, + pub(in crate::merger) enum_usages: HashMap, + pub(in crate::merger) fields_with_from_context: DirectiveReferencers, + pub(in crate::merger) fields_with_override: DirectiveReferencers, + pub(in crate::merger) subgraph_enum_values: Vec, + pub(in crate::merger) inaccessible_directive_name_in_supergraph: Option, + pub(in crate::merger) schema_to_import_to_feature_url: HashMap>, + pub(in crate::merger) link_spec_definition: &'static LinkSpecDefinition, + pub(in crate::merger) join_directive_identities: HashSet, + pub(in crate::merger) join_spec_definition: &'static JoinSpecDefinition, + pub(in crate::merger) latest_federation_version_used: Version, +} + +/// Abstraction for schema elements that have types that can be merged. +/// +/// This replaces the TypeScript `NamedSchemaElementWithType` interface, +/// providing a unified way to handle type merging for both field definitions +/// and input value definitions (arguments). +pub(crate) trait SchemaElementWithType { + // + fn coordinate(&self, parent_name: &str) -> String; + fn set_type(&mut self, typ: Type); + fn enum_example_ast(&self) -> Option; +} + +impl SchemaElementWithType for FieldDefinition { + fn coordinate(&self, parent_name: &str) -> String { + format!("{}.{}", parent_name, self.name) + } + fn set_type(&mut self, typ: Type) { + self.ty = typ; + } + fn enum_example_ast(&self) -> Option { + Some(EnumExampleAst::Field(Node::new(self.clone()))) + } +} + +impl SchemaElementWithType for InputValueDefinition { + fn coordinate(&self, parent_name: &str) -> String { + format!("{}.{}", parent_name, self.name) + } + fn set_type(&mut self, typ: Type) { + self.ty = typ.into(); + } + fn enum_example_ast(&self) -> Option { + Some(EnumExampleAst::Input(Node::new(self.clone()))) + } +} + +#[allow(unused)] +impl Merger { + pub(crate) fn new( + subgraphs: Vec>, + options: CompositionOptions, + ) -> Result { + let names: Vec = subgraphs.iter().map(|s| s.name.clone()).collect(); + let mut error_reporter = ErrorReporter::new(names.clone()); + let latest_federation_version_used = + Self::get_latest_federation_version_used(&subgraphs, &mut error_reporter).clone(); + let Some(join_spec) = + JOIN_VERSIONS.get_minimum_required_version(&latest_federation_version_used) + else { + bail!( + "No join spec version found for federation version {}", + latest_federation_version_used + ) + }; + let Some(link_spec_definition) = + LINK_VERSIONS.get_minimum_required_version(&latest_federation_version_used) + else { + bail!( + "No link spec version found for federation version {}", + latest_federation_version_used + ) + }; + let fields_with_from_context = Self::get_fields_with_from_context_directive(&subgraphs); + let fields_with_override = Self::get_fields_with_override_directive(&subgraphs); + + let schema_to_import_to_feature_url = subgraphs + .iter() + .map(|s| { + ( + s.name.clone(), + s.schema() + .metadata() + .map(|l| l.import_to_feature_url_map()) + .unwrap_or_default(), + ) + }) + .collect(); + let merged = FederationSchema::new(Schema::new())?; + let join_directive_identities = HashSet::from([Identity::connect_identity()]); + + let mut merger = Self { + subgraphs, + options, + names, + compose_directive_manager: ComposeDirectiveManager::new(), + error_reporter, + merged, + subgraph_names_to_join_spec_name: HashMap::new(), + merged_federation_directive_names: HashSet::new(), + merged_federation_directive_in_supergraph_by_directive_name: HashMap::new(), + enum_usages: HashMap::new(), + fields_with_from_context, + fields_with_override, + schema_to_import_to_feature_url, + link_spec_definition, + join_directive_identities, + inaccessible_directive_name_in_supergraph: None, + join_spec_definition: join_spec, + subgraph_enum_values: Vec::new(), + latest_federation_version_used, + }; + + // Now call prepare_supergraph as a member function + merger.prepare_supergraph()?; + + Ok(merger) + } + + fn get_latest_federation_version_used<'a>( + subgraphs: &'a [Subgraph], + error_reporter: &mut ErrorReporter, + ) -> &'a Version { + subgraphs + .iter() + .map(|subgraph| { + Self::get_latest_federation_version_used_in_subgraph(subgraph, error_reporter) + }) + .max() + .unwrap_or_else(|| FEDERATION_VERSIONS.latest().version()) + } + + fn get_latest_federation_version_used_in_subgraph<'a>( + subgraph: &'a Subgraph, + error_reporter: &mut ErrorReporter, + ) -> &'a Version { + let linked_federation_version = subgraph.metadata().federation_spec_definition().version(); + + let linked_features = subgraph.schema().all_features().unwrap_or_default(); + let spec_with_max_implied_version = linked_features + .iter() + .max_by_key(|spec| spec.minimum_federation_version()); + + if let Some(spec) = spec_with_max_implied_version + && spec + .minimum_federation_version() + .satisfies(linked_federation_version) + && spec + .minimum_federation_version() + .gt(linked_federation_version) + { + error_reporter.add_hint(CompositionHint { + code: HintCode::ImplicitlyUpgradedFederationVersion + .code() + .to_string(), + message: format!( + "Subgraph {} has been implicitly upgraded from federation {} to {}", + subgraph.name, + linked_federation_version, + spec.minimum_federation_version() + ), + locations: Default::default(), // TODO: need @link directive application AST node + }); + return spec.minimum_federation_version(); + } + linked_federation_version + } + + fn get_fields_with_from_context_directive( + subgraphs: &[Subgraph], + ) -> DirectiveReferencers { + subgraphs + .iter() + .fold(Default::default(), |mut acc, subgraph| { + if let Ok(Some(directive_name)) = subgraph.from_context_directive_name() + && let Ok(referencers) = subgraph + .schema() + .referencers() + .get_directive(&directive_name) + { + acc.extend(referencers); + } + acc + }) + } + + fn get_fields_with_override_directive( + subgraphs: &[Subgraph], + ) -> DirectiveReferencers { + subgraphs + .iter() + .fold(Default::default(), |mut acc, subgraph| { + if let Ok(Some(directive_name)) = subgraph.override_directive_name() + && let Ok(referencers) = subgraph + .schema() + .referencers() + .get_directive(&directive_name) + { + acc.extend(referencers); + } + acc + }) + } + + fn prepare_supergraph(&mut self) -> Result<(), FederationError> { + // Add the @link specification to the merged schema + self.link_spec_definition + .add_to_schema(&mut self.merged, None)?; + + // Apply the @join specification to the schema + self.link_spec_definition.apply_feature_to_schema( + &mut self.merged, + self.join_spec_definition, + None, + self.join_spec_definition.purpose(), + None, // imports + )?; + + let directives_merge_info = self.collect_core_directives_to_compose()?; + + self.validate_and_maybe_add_specs(&directives_merge_info)?; + + // Populate the graph enum with subgraph information and store the mapping + self.subgraph_names_to_join_spec_name = self + .join_spec_definition + .populate_graph_enum(&mut self.merged, &self.subgraphs)?; + + Ok(()) + } + + /// Get the join spec name for a subgraph by index (ported from JavaScript joinSpecName()) + pub(crate) fn join_spec_name(&self, subgraph_index: usize) -> Result<&Name, FederationError> { + let subgraph_name = &self.names[subgraph_index]; + self.subgraph_names_to_join_spec_name + .get(subgraph_name) + .ok_or_else(|| { + internal_error!( + "Could not find join spec name for subgraph '{}'", + subgraph_name + ) + }) + } + + /// Get access to the merged schema + pub(crate) fn schema(&self) -> &FederationSchema { + &self.merged + } + + /// Get access to the error reporter + pub(crate) fn error_reporter(&self) -> &ErrorReporter { + &self.error_reporter + } + + /// Get mutable access to the error reporter + pub(crate) fn error_reporter_mut(&mut self) -> &mut ErrorReporter { + &mut self.error_reporter + } + + /// Get access to the subgraph names + pub(crate) fn subgraph_names(&self) -> &[String] { + &self.names + } + + /// Get access to the enum usages + pub(crate) fn enum_usages(&self) -> &HashMap { + &self.enum_usages + } + + /// Get mutable access to the enum usages + pub(crate) fn enum_usages_mut(&mut self) -> &mut HashMap { + &mut self.enum_usages + } + + /// Check if there are any errors + pub(crate) fn has_errors(&self) -> bool { + self.error_reporter.has_errors() + } + + /// Check if there are any hints + pub(crate) fn has_hints(&self) -> bool { + self.error_reporter.has_hints() + } + + /// Get enum usage for a specific enum type + pub(crate) fn get_enum_usage(&self, enum_name: &str) -> Option<&EnumTypeUsage> { + self.enum_usages.get(enum_name) + } + + pub(crate) fn merge(mut self) -> MergeResult { + // Validate compose directive manager + self.validate_compose_directive_manager(); + + // Add core features to the merged schema + self.add_core_features(); + + // Create empty objects for all types and directive definitions + self.add_types_shallow(); + self.add_directives_shallow(); + + let object_types = self.get_merged_object_type_names(); + let interface_types = self.get_merged_interface_type_names(); + let union_types = self.get_merged_union_type_names(); + let enum_types = self.get_merged_enum_type_names(); + let scalar_types = self.get_merged_scalar_type_names(); + let input_object_types = self.get_merged_input_object_type_names(); + + // Merge implements relationships for object and interface types + for object_type in &object_types { + self.merge_implements(object_type); + } + + for interface_type in &interface_types { + self.merge_implements(interface_type); + } + + // Merge union types + for union_type in &union_types { + self.merge_type(union_type); + } + + // Merge schema definition (root types) + self.merge_schema_definition(); + + // Merge non-union and non-enum types + for type_def in &scalar_types { + self.merge_type(type_def); + } + for type_def in &input_object_types { + self.merge_type(type_def); + } + + // Merge directive definitions + self.merge_directive_definitions(); + + // Merge enum types last + for enum_type in &enum_types { + self.merge_type(enum_type); + } + + // Validate that we have a query root type + self.validate_query_root(); + + // Merge all applied directives + self.merge_all_applied_directives(); + + // Add missing interface object fields to implementations + self.add_missing_interface_object_fields_to_implementations(); + + // Post-merge validations if no errors so far + if !self.error_reporter.has_errors() { + self.post_merge_validations(); + } + + // Return result + let (errors, hints) = self.error_reporter.into_errors_and_hints(); + if !errors.is_empty() { + MergeResult { + supergraph: None, + errors, + hints, + } + } else { + let valid_schema = Valid::assume_valid(self.merged); + MergeResult { + supergraph: Some(valid_schema), + errors, + hints, + } + } + } + + // Methods called directly by merge() - implemented with todo!() for now + + fn validate_compose_directive_manager(&mut self) { + todo!("Implement compose directive manager validation") + } + + fn add_core_features(&mut self) { + todo!("Implement adding core features to merged schema") + } + + fn add_types_shallow(&mut self) { + let mut mismatched_types = HashSet::new(); + let mut types_with_interface_object = HashSet::new(); + + for subgraph in &self.subgraphs { + for pos in subgraph.schema().get_types() { + if !self.is_merged_type(subgraph, &pos) { + continue; + } + + let mut expects_interface = false; + if subgraph.is_interface_object_type(&pos) { + expects_interface = true; + types_with_interface_object.insert(pos.clone()); + } + if let Ok(previous) = self.merged.get_type(pos.type_name().clone()) { + if expects_interface + && !matches!(previous, TypeDefinitionPosition::Interface(_)) + { + mismatched_types.insert(pos.clone()); + } + if !expects_interface && previous != pos { + mismatched_types.insert(pos.clone()); + } + } else if expects_interface { + let itf_pos = InterfaceTypeDefinitionPosition { + type_name: pos.type_name().clone(), + }; + itf_pos.pre_insert(&mut self.merged); + itf_pos.insert_empty(&mut self.merged); + } else { + pos.pre_insert(&mut self.merged); + pos.insert_empty(&mut self.merged); + } + } + } + + for mismatched_type in mismatched_types.iter() { + self.report_mismatched_type_definitions(mismatched_type, &types_with_interface_object); + } + + // Most invalid use of @interfaceObject are reported as a mismatch above, but one exception is the + // case where a type is used only with @interfaceObject, but there is no corresponding interface + // definition in any subgraph. + for type_ in types_with_interface_object.iter() { + if mismatched_types.contains(type_) { + continue; + } + + let mut found_interface = false; + let mut subgraphs_with_type = HashSet::new(); + for subgraph in &self.subgraphs { + let type_in_subgraph = subgraph.schema().get_type(type_.type_name().clone()); + if matches!(type_in_subgraph, Ok(TypeDefinitionPosition::Interface(_))) { + found_interface = true; + break; + } + if type_in_subgraph.is_ok() { + subgraphs_with_type.insert(subgraph.name.clone()); + } + } + + // Note that there is meaningful way in which the supergraph could work in this situation, expect maybe if + // the type is unused, because validation composition would complain it cannot find the `__typename` in path + // leading to that type. But the error here is a bit more "direct"/user friendly than what post-merging + // validation would return, so we make this a hard error, not just a warning. + if !found_interface { + self.error_reporter.add_error(CompositionError::InterfaceObjectUsageError { message: format!( + "Type \"{}\" is declared with @interfaceObject in all the subgraphs in which it is defined (it is defined in {} but should be defined as an interface in at least one subgraph)", + type_.type_name(), + human_readable_subgraph_names(subgraphs_with_type.iter()) + ) }); + } + } + } + + fn is_merged_type( + &self, + subgraph: &Subgraph, + type_: &TypeDefinitionPosition, + ) -> bool { + if type_.is_introspection_type() || FEDERATION_OPERATION_TYPES.contains(type_.type_name()) { + return false; + } + + let type_feature = subgraph + .schema() + .metadata() + .and_then(|links| links.source_link_of_type(type_.type_name())); + let exists_and_is_excluded = type_feature + .is_some_and(|link| NON_MERGED_CORE_FEATURES.contains(&link.link.url.identity)); + !exists_and_is_excluded + } + + fn report_mismatched_type_definitions( + &mut self, + mismatched_type: &TypeDefinitionPosition, + types_with_interface_object: &HashSet, + ) { + let sources = self + .subgraphs + .iter() + .enumerate() + .map(|(idx, sg)| { + ( + idx, + sg.schema() + .get_type(mismatched_type.type_name().clone()) + .ok(), + ) + }) + .collect(); + let type_kind_to_string = |type_def: &TypeDefinitionPosition, _| { + let type_kind_description = if types_with_interface_object.contains(type_def) { + "Interface Object Type (Object Type with @interfaceObject)".to_string() + } else { + type_def.kind().replace("Type", " Type") + }; + Some(type_kind_description) + }; + // TODO: Second type param is supposed to be representation of AST nodes + self.error_reporter + .report_mismatch_error::( + CompositionError::TypeKindMismatch { + message: format!( + "Type \"{}\" has mismatched kind: it is defined as ", + mismatched_type.type_name() + ), + }, + mismatched_type, + &sources, + type_kind_to_string, + ); + } + + fn add_directives_shallow(&mut self) -> Result<(), FederationError> { + for subgraph in self.subgraphs.iter() { + for (name, definition) in subgraph.schema().schema().directive_definitions.iter() { + if self.merged.get_directive_definition(name).is_none() + && self.is_merged_directive_definition(&subgraph.name, definition) + { + let pos = DirectiveDefinitionPosition { + directive_name: name.clone(), + }; + pos.pre_insert(&mut self.merged)?; + pos.insert(&mut self.merged, definition.clone())?; + } + } + } + Ok(()) + } + + pub(in crate::merger) fn is_merged_directive( + &self, + subgraph_name: &str, + directive: &Directive, + ) -> bool { + if self + .compose_directive_manager + .should_compose_directive(subgraph_name, &directive.name) + { + return true; + } + + self.merged_federation_directive_names + .contains(directive.name.as_str()) + || BUILT_IN_DIRECTIVES.contains(&directive.name.as_str()) + } + + fn is_merged_directive_definition( + &self, + subgraph_name: &str, + definition: &DirectiveDefinition, + ) -> bool { + if self + .compose_directive_manager + .should_compose_directive(subgraph_name, &definition.name) + { + return true; + } + + !BUILT_IN_DIRECTIVES.contains(&definition.name.as_str()) + && definition + .locations + .iter() + .any(|loc| loc.is_executable_location()) + } + + /// Gets the names of all Object types that should be merged. This excludes types that are part + /// of the link or join specs. Assumes all candidate types have at least been shallow-copied to + /// the supergraph schema already. + fn get_merged_object_type_names(&self) -> Vec { + self.merged + .referencers() + .object_types + .keys() + .filter(|n| self.should_merge_type(n)) + .cloned() + .collect_vec() + } + + /// Gets the names of all Interface types that should be merged. This excludes types that are + /// part of the link or join specs. Assumes all candidate types have at least been + /// shallow-copied to the supergraph schema already. + fn get_merged_interface_type_names(&self) -> Vec { + self.merged + .referencers() + .interface_types + .keys() + .filter(|n| self.should_merge_type(n)) + .cloned() + .collect_vec() + } + + /// Gets the names of all Union types that should be merged. This excludes types that are part + /// of the link or join specs. Assumes all candidate types have at least been shallow-copied to + /// the supergraph schema already. + fn get_merged_union_type_names(&self) -> Vec { + self.merged + .referencers() + .union_types + .keys() + .filter(|n| self.should_merge_type(n)) + .cloned() + .collect_vec() + } + + /// Gets the names of all InputObject types that should be merged. This excludes types that are + /// part of the link or join specs. Assumes all candidate types have at least been shallow-copied + /// to the supergraph schema already. + fn get_merged_input_object_type_names(&self) -> Vec { + self.merged + .referencers() + .input_object_types + .keys() + .filter(|n| self.should_merge_type(n)) + .cloned() + .collect_vec() + } + + /// Gets the names of all Scalar types that should be merged. This excludes types that are part + /// of the link or join specs. Assumes all candidate types have at least been shallow-copied to + /// the supergraph schema already. + fn get_merged_scalar_type_names(&self) -> Vec { + self.merged + .referencers() + .scalar_types + .keys() + .filter(|n| self.should_merge_type(n)) + .cloned() + .collect_vec() + } + + /// Gets the names of all Enum types that should be merged. This excludes types that are part + /// of the link or join specs. Assumes all candidate types have at least been shallow-copied to + /// the supergraph schema already. + fn get_merged_enum_type_names(&self) -> Vec { + self.merged + .referencers() + .enum_types + .keys() + .filter(|n| self.should_merge_type(n)) + .cloned() + .collect_vec() + } + + fn should_merge_type(&self, name: &Name) -> bool { + !self + .link_spec_definition + .is_spec_type_name(&self.merged, name) + .unwrap_or(false) + && !self + .join_spec_definition + .is_spec_type_name(&self.merged, name) + .unwrap_or(false) + } + + fn merge_implements(&mut self, type_def: &Name) -> Result<(), FederationError> { + let dest = self.merged.get_type(type_def.clone())?; + let dest: ObjectOrInterfaceTypeDefinitionPosition = dest.try_into().map_err(|_| { + internal_error!( + "Expected type {} to be an Object or Interface type, but it is not", + type_def + ) + })?; + let mut implemented = IndexSet::new(); + for (idx, subgraph) in self.subgraphs.iter().enumerate() { + let Some(ty) = subgraph.schema().schema().types.get(type_def) else { + continue; + }; + let graph_name = self.join_spec_name(idx)?.clone(); + match ty { + ExtendedType::Object(obj) => { + for implemented_itf in obj.implements_interfaces.iter() { + implemented.insert(implemented_itf.clone()); + let join_implements = self + .join_spec_definition + .implements_directive(graph_name.clone(), implemented_itf); + dest.insert_directive(&mut self.merged, Component::new(join_implements))?; + } + } + ExtendedType::Interface(itf) => { + for implemented_itf in itf.implements_interfaces.iter() { + implemented.insert(implemented_itf.clone()); + let join_implements = self + .join_spec_definition + .implements_directive(graph_name.clone(), implemented_itf); + dest.insert_directive(&mut self.merged, Component::new(join_implements))?; + } + } + _ => continue, + } + } + for implemented_itf in implemented { + dest.insert_implements_interface(&mut self.merged, implemented_itf)?; + } + Ok(()) + } + + pub(crate) fn merge_object(&mut self, obj: ObjectTypeDefinitionPosition) { + todo!("Implement merge_object") + } + + pub(crate) fn merge_interface(&mut self, itf: InterfaceTypeDefinitionPosition) { + todo!("Implement merge_interface") + } + + pub(crate) fn merge_input_object(&mut self, io: InputObjectTypeDefinitionPosition) { + todo!("Implement merge_input_object") + } + + fn merge_schema_definition(&mut self) { + let sources: Sources = self + .subgraphs + .iter() + .enumerate() + .map(|(idx, subgraph)| (idx, Some(SchemaDefinitionPosition {}))) + .collect(); + let dest = SchemaDefinitionPosition {}; + + self.merge_description(&sources, &dest); + self.record_applied_directives_to_merge(&sources, &dest); + self.add_join_directive_directives(&sources, &dest); + } + + fn merge_directive_definitions(&mut self) { + todo!("Implement directive definition merging") + } + + fn validate_query_root(&mut self) { + todo!("Implement query root validation") + } + + fn merge_all_applied_directives(&mut self) { + todo!("Implement merging of all applied directives") + } + + fn merge_applied_directive( + &mut self, + name: &Name, + sources: Sources>, + dest: &mut FederationSchema, + ) -> Result<(), FederationError> { + let Some(directive_in_supergraph) = self + .merged_federation_directive_in_supergraph_by_directive_name + .get(name) + else { + // Definition is missing, so we assume there is nothing to merge. + return Ok(()); + }; + + // Accumulate all positions of the directive in the source schemas + let all_schema_referencers = sources + .values() + .filter_map(|subgraph| subgraph.as_ref()) + .fold(DirectiveReferencers::default(), |mut acc, subgraph| { + if let Ok(drs) = subgraph.schema().referencers().get_directive(name) { + acc.extend(drs); + } + acc + }); + + for pos in all_schema_referencers.iter() { + // In JS, there are several methods for checking if directive applications are the same, and the static + // argument transforms are only applied for repeatable directives. In this version, we rely on the `Eq` + // and `Hash` implementations of `Directive` to deduplicate applications, and the argument transforms + // are applied up front so they are available in all locations. + let mut directive_sources: Sources = Default::default(); + let directive_counts = sources + .iter() + .flat_map(|(idx, subgraph)| { + if let Some(subgraph) = subgraph { + let directives = Self::directive_applications_with_transformed_arguments( + &pos, + directive_in_supergraph, + subgraph, + ); + directive_sources.insert(*idx, directives.first().cloned()); + directives + } else { + vec![] + } + }) + .counts(); + + if directive_in_supergraph.definition.repeatable { + for directive in directive_counts.keys() { + pos.insert_directive(dest, (*directive).clone())?; + } + } else if directive_counts.len() == 1 { + let only_application = directive_counts.iter().next().unwrap().0.clone(); + pos.insert_directive(dest, only_application)?; + } else if let Some(merger) = &directive_in_supergraph.arguments_merger { + // When we have multiple unique applications of the directive, and there is a + // supplied argument merger, then we merge each of the arguments into a combined + // directive. + let mut merged_directive = Directive::new(name.clone()); + for arg_def in &directive_in_supergraph.definition.arguments { + let values = directive_counts + .keys() + .filter_map(|d| { + d.specified_argument_by_name(name) + .or(arg_def.default_value.as_ref()) + .map(|v| v.as_ref()) + }) + .cloned() + .collect_vec(); + if let Some(merged_value) = (merger.merge)(name, &values)? { + let merged_arg = Argument { + name: arg_def.name.clone(), + value: Node::new(merged_value), + }; + merged_directive.arguments.push(Node::new(merged_arg)); + } + } + pos.insert_directive(dest, merged_directive)?; + self.error_reporter.add_hint(CompositionHint { + code: HintCode::MergedNonRepeatableDirectiveArguments.code().to_string(), + message: format!( + "Directive @{name} is applied to \"{pos}\" in multiple subgraphs with different arguments. Merging strategies used by arguments: {}", + directive_in_supergraph.arguments_merger.as_ref().map_or("undefined".to_string(), |m| (m.to_string)()) + ), + locations: Default::default(), // PORT_NOTE: No locations in JS implementation. + }); + } else if let Some(most_used_directive) = directive_counts + .into_iter() + .max_by_key(|(_, count)| *count) + .map(|(directive, _)| directive) + { + // When there is no argument merger, we use the application appearing in the most + // subgraphs. Adding it to the destination here allows the error reporter to + // determine which one we selected when it's looking through the sources. + pos.insert_directive(dest, most_used_directive.clone())?; + self.error_reporter.report_mismatch_hint::( + HintCode::InconsistentNonRepeatableDirectiveArguments, + format!("Non-repeatable directive @{name} is applied to \"{pos}\" in multiple subgraphs but with incompatible arguments. "), + &most_used_directive, + &directive_sources, + |elt, _| if elt.arguments.is_empty() { + Some("no arguments".to_string()) + } else { + Some(format!("arguments: [{}]", elt.arguments.iter().map(|arg| format!("{}: {}", arg.name, arg.value)).join(", "))) + }, + |application, subgraphs| format!("The supergraph will use {} (from {}), but found ", application, subgraphs.unwrap_or_else(|| "undefined".to_string())), + |application, subgraphs| format!("{application} in {subgraphs}"), + None::) -> bool>, + false, + false, + ); + } + } + + Ok(()) + } + + fn directive_applications_with_transformed_arguments( + pos: &DirectiveTargetPosition, + merge_info: &MergedDirectiveInfo, + subgraph: &Subgraph, + ) -> Vec { + let mut applications = Vec::new(); + if let Some(arg_transform) = &merge_info.static_argument_transform { + for application in + pos.get_applied_directives(subgraph.schema(), &merge_info.definition.name) + { + let mut transformed_application = Directive::new(application.name.clone()); + let indexed_args: IndexMap = application + .arguments + .iter() + .map(|a| (a.name.clone(), a.value.as_ref().clone())) + .collect(); + transformed_application.arguments = arg_transform(subgraph, indexed_args) + .into_iter() + .map(|(name, value)| { + Node::new(Argument { + name, + value: Node::new(value), + }) + }) + .collect(); + applications.push(transformed_application); + } + } + applications + } + + fn add_missing_interface_object_fields_to_implementations(&mut self) { + todo!("Implement adding missing interface object fields to implementations") + } + + fn post_merge_validations(&mut self) { + todo!("Implement post-merge validations") + } + + /// Core type merging logic for GraphQL Federation composition. + /// + /// Merges type references from multiple subgraphs following Federation variance rules: + /// - For output positions: uses the most general (supertype) when types are compatible + /// - For input positions: uses the most specific (subtype) when types are compatible + /// - Reports errors for incompatible types, hints for compatible but inconsistent types + /// - Tracks enum usage for validation purposes + pub(crate) fn merge_type_reference( + &mut self, + sources: &Sources, + dest: &mut TElement, + is_input_position: bool, + parent_type_name: &str, // We need this for the coordinate as FieldDefinition lack parent context + ) -> Result + where + TElement: SchemaElementWithType, + { + // Validate sources + if sources.is_empty() { + self.error_reporter_mut() + .add_error(CompositionError::InternalError { + message: format!( + "No type sources provided for merging {}", + dest.coordinate(parent_type_name) + ), + }); + return Ok(false); + } + + // Build iterator over the non-None source types + let mut iter = sources.values().filter_map(Option::as_ref); + let mut has_subtypes = false; + let mut has_incompatible = false; + + // Grab the first type (if any) to initialise comparison + let Some(mut typ) = iter.next() else { + // No concrete type found in any subgraph — this should not normally happen + let error = CompositionError::InternalError { + message: format!( + "No type sources provided for {} across subgraphs", + dest.coordinate(parent_type_name) + ), + }; + self.error_reporter_mut().add_error(error); + return Ok(false); + }; + + // Determine the merged type following GraphQL Federation variance rules + for source_type in iter { + if Self::same_type(typ, source_type) { + // Types are identical + continue; + } else if let Ok(true) = self.is_strict_subtype(source_type, typ) { + // current typ is a subtype of source_type (source_type is more general) + has_subtypes = true; + if is_input_position { + // For input: upgrade to the supertype + typ = source_type; + } + } else if let Ok(true) = self.is_strict_subtype(typ, source_type) { + // source_type is a subtype of current typ (current typ is more general) + has_subtypes = true; + if !is_input_position { + // For output: keep the supertype; for input: adopt the subtype + typ = source_type; + } + } else { + has_incompatible = true; + } + } + + // Copy the type reference to the destination schema + let copied_type = self.copy_type_reference(typ)?; + + dest.set_type(copied_type); + + let ast_node = dest.enum_example_ast(); + self.track_enum_usage( + typ, + dest.coordinate(parent_type_name), + ast_node, + is_input_position, + ); + + let element_kind = if is_input_position { + "argument" + } else { + "field" + }; + + if has_incompatible { + // Report incompatible type error + let error_code_str = if is_input_position { + "ARGUMENT_TYPE_MISMATCH" + } else { + "FIELD_TYPE_MISMATCH" + }; + + let error = CompositionError::InternalError { + message: format!( + "Type of {} \"{}\" is incompatible across subgraphs", + element_kind, + dest.coordinate(parent_type_name) + ), + }; + + self.error_reporter_mut().report_mismatch_error::( + error, + typ, + sources, + |typ, _is_supergraph| Some(format!("type \"{typ}\"")), + ); + + Ok(false) + } else if has_subtypes { + // Report compatibility hint for subtype relationships + let hint_code = if is_input_position { + HintCode::InconsistentButCompatibleArgumentType + } else { + HintCode::InconsistentButCompatibleFieldType + }; + + let type_class = if is_input_position { + "supertype" + } else { + "subtypes" + }; + + self.error_reporter_mut().report_mismatch_hint::( + hint_code, + format!( + "Type of {} \"{}\" is inconsistent but compatible across subgraphs:", + element_kind, + dest.coordinate(parent_type_name) + ), + typ, + sources, + |typ, _is_supergraph| Some(format!("type \"{typ}\"")), + |elt, subgraphs| { + format!( + "will use type \"{}\" (from {}) in supergraph but \"{}\" has ", + elt, + subgraphs.unwrap_or_else(|| "undefined".to_string()), + dest.coordinate(parent_type_name) + ) + }, + |elt, subgraphs| format!("{type_class} \"{elt}\" in {subgraphs}"), + None::) -> bool>, + false, + false, + ); + + Ok(false) + } else { + Ok(true) + } + } + + fn track_enum_usage( + &mut self, + typ: &Type, + element_name: String, + element_ast: Option, + is_input_position: bool, + ) { + // Get the base type (unwrap nullability and list wrappers) + let base_type_name = typ.inner_named_type(); + + // Check if it's an enum type + if let Some(&ExtendedType::Enum(_)) = self.schema().schema().types.get(base_type_name) { + let default_example = || EnumExample { + coordinate: element_name, + element_ast: element_ast.clone(), + }; + + // Compute the new usage directly based on existing record and current position + let new_usage = match self.enum_usages().get(base_type_name.as_str()) { + Some(EnumTypeUsage::Input { input_example }) if !is_input_position => { + EnumTypeUsage::Both { + input_example: input_example.clone(), + output_example: default_example(), + } + } + Some(EnumTypeUsage::Input { input_example }) + | Some(EnumTypeUsage::Both { input_example, .. }) + if is_input_position => + { + EnumTypeUsage::Input { + input_example: input_example.clone(), + } + } + Some(EnumTypeUsage::Output { output_example }) if is_input_position => { + EnumTypeUsage::Both { + input_example: default_example(), + output_example: output_example.clone(), + } + } + Some(EnumTypeUsage::Output { output_example }) + | Some(EnumTypeUsage::Both { output_example, .. }) + if !is_input_position => + { + EnumTypeUsage::Output { + output_example: output_example.clone(), + } + } + _ if is_input_position => EnumTypeUsage::Input { + input_example: default_example(), + }, + _ => EnumTypeUsage::Output { + output_example: default_example(), + }, + }; + + // Store updated usage + self.enum_usages_mut() + .insert(base_type_name.to_string(), new_usage); + } + } + + fn same_type(dest_type: &Type, source_type: &Type) -> bool { + match (dest_type, source_type) { + (Type::Named(n1), Type::Named(n2)) => n1 == n2, + (Type::NonNullNamed(n1), Type::NonNullNamed(n2)) => n1 == n2, + (Type::List(inner1), Type::List(inner2)) => Self::same_type(inner1, inner2), + (Type::NonNullList(inner1), Type::NonNullList(inner2)) => { + Self::same_type(inner1, inner2) + } + _ => false, + } + } + + pub(in crate::merger) fn is_strict_subtype( + &self, + potential_supertype: &Type, + potential_subtype: &Type, + ) -> Result { + // Hardcoded subtyping rules based on the default configuration: + // - Direct: Interface/union subtyping relationships + // - NonNullableDowngrade: NonNull T is subtype of T + // - ListPropagation: [T] is subtype of [U] if T is subtype of U + // - NonNullablePropagation: NonNull T is subtype of NonNull U if T is subtype of U + // - ListUpgrade is NOT supported (was excluded by default) + + match (potential_subtype, potential_supertype) { + // -------- List & NonNullList -------- + // ListPropagation: [T] is subtype of [U] if T is subtype of U + (Type::List(inner_sub), Type::List(inner_super)) => { + self.is_strict_subtype(inner_super, inner_sub) + } + // NonNullablePropagation and NonNullableDowngrade + (Type::NonNullList(inner_sub), Type::NonNullList(inner_super)) + | (Type::NonNullList(inner_sub), Type::List(inner_super)) => { + self.is_strict_subtype(inner_super, inner_sub) + } + + // Anything else with list on the left is not a strict subtype + (Type::List(_), _) | (Type::NonNullList(_), _) => Ok(false), + + // -------- Named & NonNullNamed -------- + // Same named type => not strict subtype + (Type::Named(a), Type::Named(b)) | (Type::Named(a), Type::NonNullNamed(b)) + if a == b => + { + Ok(false) + } + (Type::NonNullNamed(a), Type::NonNullNamed(b)) if a == b => Ok(false), + + // NonNull downgrade: T! ⊑ T + (Type::NonNullNamed(sub), Type::Named(super_)) if sub == super_ => Ok(true), + + // Interface/Union relationships (includes downgrade handled above) + (Type::Named(sub), Type::Named(super_)) + | (Type::Named(sub), Type::NonNullNamed(super_)) + | (Type::NonNullNamed(sub), Type::Named(super_)) + | (Type::NonNullNamed(sub), Type::NonNullNamed(super_)) => { + self.is_named_type_subtype(super_, sub) + } + + // ListUpgrade not supported; any other combination is not strict + _ => Ok(false), + } + } + + fn is_named_type_subtype( + &self, + potential_supertype: &NamedType, + potential_subtype: &NamedType, + ) -> Result { + let Some(subtype_def) = self.schema().schema().types.get(potential_subtype) else { + bail!("Cannot find type '{}' in schema", potential_subtype); + }; + + let Some(supertype_def) = self.schema().schema().types.get(potential_supertype) else { + bail!("Cannot find type '{}' in schema", potential_supertype); + }; + + // Direct subtyping relationships (interface/union) are always supported + match (subtype_def, supertype_def) { + // Object type implementing an interface + (ExtendedType::Object(obj), ExtendedType::Interface(_)) => { + Ok(obj.implements_interfaces.contains(potential_supertype)) + } + // Interface extending another interface + (ExtendedType::Interface(sub_intf), ExtendedType::Interface(_)) => { + Ok(sub_intf.implements_interfaces.contains(potential_supertype)) + } + // Object type that is a member of a union + (ExtendedType::Object(_), ExtendedType::Union(union_type)) => { + Ok(union_type.members.contains(potential_subtype)) + } + // Interface that is a member of a union (if supported) + (ExtendedType::Interface(_), ExtendedType::Union(union_type)) => { + Ok(union_type.members.contains(potential_subtype)) + } + _ => Ok(false), + } + } + + pub(crate) fn copy_type_reference( + &mut self, + source_type: &Type, + ) -> Result { + // Check if the type is already defined in the target schema + let target_schema = self.schema().schema(); + + let name = source_type.inner_named_type(); + if !target_schema.types.contains_key(name) { + self.error_reporter_mut() + .add_error(CompositionError::InternalError { + message: format!("Cannot find type '{name}' in target schema"), + }); + } + + Ok(source_type.clone()) + } + + pub(in crate::merger) fn merge_description(&mut self, sources: &Sources, dest: &T) + where + T: HasDescription + std::fmt::Display, + { + let mut descriptions: CountMap = CountMap::new(); + + for (idx, source) in sources { + // Skip if source has no description + let Some(source_desc) = source + .as_ref() + .and_then(|s| s.description(self.subgraphs[*idx].schema())) + else { + continue; + }; + + descriptions.insert_or_increment(source_desc.trim().to_string()); + } + // we don't want to raise a hint if a description is "" + descriptions.remove(&String::new()); + + if !descriptions.is_empty() { + if let Some((description, _)) = iter_into_single_item(descriptions.iter()) { + dest.set_description(&mut self.merged, Some(Node::new_str(description))); + } else { + // Find the description with the highest count + if let Some((idx, _)) = descriptions + .iter() + .enumerate() + .max_by_key(|(_, (_, counts))| *counts) + { + // Get the description at the found index + if let Some((description, _)) = descriptions.iter().nth(idx) { + dest.set_description(&mut self.merged, Some(Node::new_str(description))); + } + } + // TODO: Currently showing full descriptions in the hint + // messages, which is probably fine in some cases. However this + // might get less helpful if the description appears to differ + // by a very small amount (a space, a single character typo) and + // even more so the bigger the description is, and we could + // improve the experience here. For instance, we could print the + // supergraph description but then show other descriptions as + // diffs from that (using, say, + // https://www.npmjs.com/package/diff). And we could even switch + // between diff/non-diff modes based on the levenshtein + // distances between the description we found. That said, we + // should decide if we want to bother here: maybe we can leave + // it to studio so handle a better experience (as it can more UX + // wise). + let coordinate = dest.to_string(); + let name = if !coordinate.is_empty() { + "Element {coordinate}" + } else { + "The schema definition" + }; + self.error_reporter.report_mismatch_hint::( + HintCode::InconsistentDescription, + format!("{name} has inconsistent descriptions across the subgraphs. "), + dest, + sources, + |elem, _is_supergraph| { + elem.description(&self.merged).map(|desc| desc.to_string()) + }, + |desc, subgraphs| { + format!( + "The supergraph will use description (from {}):\n{}", + subgraphs.unwrap_or_else(|| "undefined".to_string()), + Self::description_string(desc, " ") + ) + }, + |desc, subgraphs| { + format!( + "\nIn {}, the description is:\n{}", + subgraphs, + Self::description_string(desc, " ") + ) + }, + Some(|elem: Option<&T>| { + if let Some(el) = elem { + el.description(&self.merged).is_none() + } else { + true + } + }), + false, + true, + ); + } + } + } + + pub(in crate::merger) fn description_string(to_indent: &str, indentation: &str) -> String { + format!( + "{indentation}\"\"\"\n{indentation}{}\n{indentation}\"\"\"", + to_indent.replace('\n', &format!("\n{indentation}")) + ) + } + + /// This method gets called at various points during the merge to allow subgraph directive + /// applications to be reflected (unapplied) in the supergraph, using the + /// @join__directive(graphs, name, args) directive. + pub(in crate::merger) fn add_join_directive_directives( + &mut self, + sources: &Sources, + dest: &T, + ) -> Result<(), FederationError> + where + // If we implemented a `HasDirectives` trait for this bound, we could call that instead + // of cloning and converting to `DirectiveTargetPosition`. + T: Clone + TryInto, + FederationError: From<>::Error>, + { + // Joins are grouped by directive name and arguments. So, a directive with the same + // arguments in multiple subgraphs is merged with a single `@join__directive` that + // specifies both graphs. If two applications have different arguments, each application + // gets its own `@join__directive` specifying the different arugments per graph. + let mut joins_by_directive_name: HashMap< + Name, + HashMap>, IndexSet>, + > = HashMap::new(); + let mut links_to_persist: Vec<(Url, Directive)> = Vec::new(); + + for (idx, source) in sources.iter() { + let Some(source) = source else { + continue; + }; + let graph = self.join_spec_name(*idx)?; + let schema = self.subgraphs[*idx].schema(); + let Some(link_import_identity_url_map) = schema.metadata() else { + continue; + }; + let Ok(Some(link_directive_name)) = self + .link_spec_definition + .directive_name_in_schema(schema, &DEFAULT_LINK_NAME) + else { + continue; + }; + + let source: DirectiveTargetPosition = source.clone().try_into()?; + for directive in source.get_all_applied_directives(schema).iter() { + let mut should_include_as_join_directive = false; + + if directive.name == link_directive_name { + if let Ok(link) = Link::from_directive_application(directive) { + should_include_as_join_directive = + self.should_use_join_directive_for_url(&link.url); + + if should_include_as_join_directive + && SPEC_REGISTRY.get_definition(&link.url).is_some() + { + links_to_persist.push((link.url.clone(), directive.as_ref().clone())); + } + } + } else if let Some(url_for_directive) = + link_import_identity_url_map.source_link_of_directive(&directive.name) + { + should_include_as_join_directive = + self.should_use_join_directive_for_url(&url_for_directive.link.url); + } + + if should_include_as_join_directive { + let existing_joins = joins_by_directive_name + .entry(directive.name.clone()) + .or_default(); + let existing_graphs_with_these_arguments = existing_joins + .entry(directive.arguments.clone()) + .or_default(); + existing_graphs_with_these_arguments.insert(graph.clone()); + } + } + } + + let Some(link_directive_name) = self + .link_spec_definition + .directive_name_in_schema(&self.merged, &DEFAULT_LINK_NAME)? + else { + bail!( + "Link directive must exist in the supergraph schema in order to apply join directives" + ); + }; + + // When adding links to the supergraph schema, we have to pick a single version (see + // `Merger::validate_and_maybe_add_specs` for spec selection). For pre-1.0 specs, like the + // join spec, we generally take the latest known version because they are not necessarily + // compatible from version to version. This means upgrading composition version will likely + // change the output supergraph schema. Here, when we encounter a link directive, we + // preserve the version the subgraph used in a `@join__directive` so the query planner can + // extract the subgraph schemas with correct links. + let mut latest_or_highest_link_by_identity: HashMap = + HashMap::new(); + for (url, link_directive) in links_to_persist { + if let Some((existing_url, existing_directive)) = + latest_or_highest_link_by_identity.get_mut(&url.identity) + { + if url.version > existing_url.version { + *existing_url = url; + *existing_directive = link_directive; + } + } else { + latest_or_highest_link_by_identity + .insert(url.identity.clone(), (url, link_directive)); + } + } + + let dest: DirectiveTargetPosition = dest.clone().try_into()?; + for (_, directive) in latest_or_highest_link_by_identity.into_values() { + // We insert the directive as it was in the subgraph, but with the name of `@link` in + // the supergraph, in case it was renamed in the subgraph. + dest.insert_directive( + &mut self.merged, + Directive { + name: link_directive_name.clone(), + arguments: directive.arguments, + }, + )?; + } + + let Ok(join_directive_name) = self + .join_spec_definition + .directive_name_in_schema(&self.merged, &JOIN_DIRECTIVE_DIRECTIVE_NAME_IN_SPEC) + else { + // If we got here and have no definition for `@join__directive`, then we're probably + // operating on a schema that uses join v0.3 or earlier. We don't want to break those + // schemas, but we also can't insert the directives. + return Ok(()); + }; + + for (name, args_to_graphs_map) in joins_by_directive_name { + for (args, graphs) in args_to_graphs_map { + dest.insert_directive( + &mut self.merged, + self.join_spec_definition + .directive_directive(&name, graphs, args), + )?; + } + } + + Ok(()) + } + + fn should_use_join_directive_for_url(&self, url: &Url) -> bool { + self.join_directive_identities.contains(&url.identity) + } + + pub(in crate::merger) fn add_arguments_shallow(&mut self, _sources: &Sources, _dest: &T) { + todo!("Implement add_arguments_shallow") + } + + pub(in crate::merger) fn record_applied_directives_to_merge( + &mut self, + _sources: &Sources, + _dest: &T, + ) { + todo!("Implement record_applied_directives_to_merge") + } + + fn is_inaccessible_directive_in_supergraph(&self, _value: &EnumValueDefinition) -> bool { + todo!("Implement is_inaccessible_directive_in_supergraph") + } + + /// Like Iterator::any, but for Sources maps - checks if any source satisfies the predicate + pub(in crate::merger) fn some_sources(sources: &Sources, mut predicate: F) -> bool + where + F: FnMut(&Option, usize) -> bool, + { + sources.iter().any(|(idx, source)| predicate(source, *idx)) + } + + // TODO: These error reporting functions are not yet fully implemented + pub(crate) fn report_mismatch_error_with_specifics( + &mut self, + error: CompositionError, + sources: &Sources, + accessor: impl Fn(&Option) -> &str, + ) { + // Build a detailed error message by showing which subgraphs have/don't have the element + let mut details = Vec::new(); + let mut has_subgraphs = Vec::new(); + let mut missing_subgraphs = Vec::new(); + + for (&idx, source) in sources.iter() { + let subgraph_name = if idx < self.names.len() { + &self.names[idx] + } else { + "unknown" + }; + + let result = accessor(source); + if result == "yes" { + has_subgraphs.push(subgraph_name); + } else { + missing_subgraphs.push(subgraph_name); + } + } + + // Format the subgraph lists + if !has_subgraphs.is_empty() { + details.push(format!("defined in {}", has_subgraphs.join(", "))); + } + if !missing_subgraphs.is_empty() { + details.push(format!("but not in {}", missing_subgraphs.join(", "))); + } + + // Create the enhanced error with details + let enhanced_error = match error { + CompositionError::EnumValueMismatch { message } => { + CompositionError::EnumValueMismatch { + message: format!("{}{}", message, details.join(" ")), + } + } + // Add other error types as needed + other => other, + }; + + self.error_reporter.add_error(enhanced_error); + } + + pub(crate) fn report_mismatch_hint( + &mut self, + code: HintCode, + message: String, + sources: &Sources>, + accessor: impl Fn(&Option>) -> bool, + ) { + // Build detailed hint message showing which subgraphs have/don't have the element + let mut has_subgraphs = Vec::new(); + let mut missing_subgraphs = Vec::new(); + + for (&idx, source) in sources.iter() { + let subgraph_name = if idx < self.names.len() { + &self.names[idx] + } else { + "unknown" + }; + let result = accessor(source); + if result { + has_subgraphs.push(subgraph_name); + } else { + missing_subgraphs.push(subgraph_name); + } + } + + let detailed_message = format!( + "{}defined in {} but not in {}", + message, + has_subgraphs.join(", "), + missing_subgraphs.join(", ") + ); + + // Add the hint to the error reporter + let hint = CompositionHint { + code: code.definition().code().to_string(), + message: detailed_message, + locations: self.source_locations(sources), + }; + self.error_reporter.add_hint(hint); + } + + /// Merge argument definitions from subgraphs + pub(in crate::merger) fn merge_argument( + &mut self, + _sources: &Sources>, + _dest: &Node, + ) -> Result<(), FederationError> { + // TODO: Implement argument merging logic + // This should merge argument definitions from multiple subgraphs + // including type validation, default value merging, etc. + Ok(()) + } + + pub(crate) fn source_locations(&self, sources: &Sources>) -> Vec { + let mut result = Vec::new(); + for (subgraph_id, node) in sources { + let Some(node) = node else { + continue; // Skip if the node is None + }; + let Some(subgraph) = self.subgraphs.get(*subgraph_id) else { + // Skip if the subgraph is not found + // Note: This is unexpected in production, but it happens in unit tests. + continue; + }; + let locations = subgraph + .schema() + .node_locations(node) + .map(|loc| SubgraphLocation { + subgraph: subgraph.name.clone(), + range: loc, + }); + result.extend(locations); + } + result + } +} + +// Public function to start the merging process +#[allow(dead_code)] +pub(crate) fn merge_subgraphs( + subgraphs: Vec>, + options: CompositionOptions, +) -> Result { + Ok(Merger::new(subgraphs, options)?.merge()) +} + +/// Map over sources, applying a function to each element +/// TODO: Consider moving this into a trait or Sources +pub(in crate::merger) fn map_sources(sources: &Sources, f: F) -> Sources +where + F: Fn(&Option) -> Option, +{ + sources + .iter() + .map(|(idx, source)| (*idx, f(source))) + .collect() +} + +#[cfg(test)] +pub(crate) mod tests { + use apollo_compiler::Name; + use apollo_compiler::Node; + use apollo_compiler::ast::FieldDefinition; + use apollo_compiler::ast::InputValueDefinition; + use apollo_compiler::schema::ComponentName; + use apollo_compiler::schema::EnumType; + use apollo_compiler::schema::ExtendedType; + use apollo_compiler::schema::InterfaceType; + use apollo_compiler::schema::ObjectType; + use apollo_compiler::schema::UnionType; + + use super::*; + + /// Test helper struct for type merging tests + /// In production, this trait is implemented by real schema elements like FieldDefinition and InputValueDefinition + #[derive(Debug, Clone)] + pub(crate) struct TestSchemaElement { + pub(crate) coordinate: String, + pub(crate) typ: Option, + } + + impl SchemaElementWithType for TestSchemaElement { + fn coordinate(&self, parent_name: &str) -> String { + format!("{}.{}", parent_name, self.coordinate) + } + + fn set_type(&mut self, typ: Type) { + self.typ = Some(typ); + } + fn enum_example_ast(&self) -> Option { + Some(EnumExampleAst::Field(Node::new(FieldDefinition { + name: Name::new("dummy").unwrap(), + description: None, + arguments: vec![], + directives: Default::default(), + ty: Type::Named(Name::new("String").unwrap()), + }))) + } + } + + fn create_test_schema() -> Schema { + let mut schema = Schema::new(); + + // Add interface I + let interface_type = InterfaceType { + description: None, + name: Name::new("I").unwrap(), + implements_interfaces: Default::default(), + directives: Default::default(), + fields: Default::default(), + }; + schema.types.insert( + Name::new("I").unwrap(), + ExtendedType::Interface(Node::new(interface_type)), + ); + + // Add object type A implementing I + let mut object_type = ObjectType { + description: None, + name: Name::new("A").unwrap(), + implements_interfaces: Default::default(), + directives: Default::default(), + fields: Default::default(), + }; + object_type + .implements_interfaces + .insert(ComponentName::from(Name::new("I").unwrap())); + schema.types.insert( + Name::new("A").unwrap(), + ExtendedType::Object(Node::new(object_type)), + ); + + // Add union U with member A + let mut union_type = UnionType { + description: None, + name: Name::new("U").unwrap(), + directives: Default::default(), + members: Default::default(), + }; + union_type + .members + .insert(ComponentName::from(Name::new("A").unwrap())); + schema.types.insert( + Name::new("U").unwrap(), + ExtendedType::Union(Node::new(union_type)), + ); + + // Add enum Status for enum usage tracking tests + let enum_type = EnumType { + description: None, + name: Name::new("Status").unwrap(), + directives: Default::default(), + values: Default::default(), + }; + schema.types.insert( + Name::new("Status").unwrap(), + ExtendedType::Enum(Node::new(enum_type)), + ); + + schema + } + + fn create_test_merger() -> Result { + crate::merger::merge_enum::tests::create_test_merger() + } + + #[test] + fn same_types() { + let _schema = create_test_schema(); + let mut merger = create_test_merger().expect("Failed to create test merger"); + + let mut sources: Sources = (0..2).map(|i| (i, None)).collect(); + sources.insert(0, Some(Type::Named(Name::new("String").unwrap()))); + sources.insert(1, Some(Type::Named(Name::new("String").unwrap()))); + + let result = merger.merge_type_reference( + &sources, + &mut TestSchemaElement { + coordinate: "testField".to_string(), + typ: None, + }, + false, + Name::new("Parent").unwrap().as_str(), + ); + + // Check that there are no errors or hints + assert!(result.is_ok()); + assert!(!merger.has_errors()); + assert_eq!(merger.enum_usages().len(), 0); + } + + #[test] + fn nullable_vs_non_nullable() { + let _schema = create_test_schema(); + let mut merger = create_test_merger().expect("Failed to create test merger"); + + let mut sources: Sources = (0..2).map(|i| (i, None)).collect(); + sources.insert(0, Some(Type::NonNullNamed(Name::new("String").unwrap()))); + sources.insert(1, Some(Type::Named(Name::new("String").unwrap()))); + + // For output types, should use the more general type (nullable) + let result = merger.merge_type_reference( + &sources, + &mut TestSchemaElement { + coordinate: "testField".to_string(), + typ: None, + }, + false, + Name::new("Parent").unwrap().as_str(), + ); + // Check that there are no errors but there might be hints + assert!(result.is_ok()); + assert!(!merger.has_errors()); + assert_eq!(merger.enum_usages().len(), 0); + + // Create a new merger for the next test since we can't clear the reporter + let mut merger = create_test_merger().expect("Failed to create test merger"); + + // For input types, should use the more specific type (non-nullable) + let _result = merger.merge_type_reference( + &sources, + &mut TestSchemaElement { + coordinate: "testArg".to_string(), + typ: None, + }, + true, + Name::new("Parent").unwrap().as_str(), + ); + // Check that there are no errors but there might be hints + assert!(!merger.has_errors()); + assert_eq!(merger.enum_usages().len(), 0); + } + + #[test] + fn interface_subtype() { + let _schema = create_test_schema(); + let mut merger = create_test_merger().expect("Failed to create test merger"); + + let mut sources: Sources = (0..2).map(|i| (i, None)).collect(); + sources.insert(0, Some(Type::Named(Name::new("I").unwrap()))); + sources.insert(1, Some(Type::Named(Name::new("A").unwrap()))); + + // For output types, should use the more general type (interface) + let result = merger.merge_type_reference( + &sources, + &mut TestSchemaElement { + coordinate: "testField".to_string(), + typ: None, + }, + false, + Name::new("Parent").unwrap().as_str(), + ); + // Check that there are no errors but there might be hints + assert!(result.is_ok()); + assert!(!merger.has_errors()); + assert_eq!(merger.enum_usages().len(), 0); + + // For input types, should use the more specific type (implementing type) + let _result = merger.merge_type_reference( + &sources, + &mut TestSchemaElement { + coordinate: "testArg".to_string(), + typ: None, + }, + true, + Name::new("Parent").unwrap().as_str(), + ); + // Check that there are no errors but there might be hints + assert!(!merger.has_errors()); + assert_eq!(merger.enum_usages().len(), 0); + } + + #[test] + fn incompatible_types() { + let _schema = create_test_schema(); + let mut merger = create_test_merger().expect("Failed to create test merger"); + + let mut sources: Sources = (0..2).map(|i| (i, None)).collect(); + sources.insert(0, Some(Type::Named(Name::new("String").unwrap()))); + sources.insert(1, Some(Type::Named(Name::new("Int").unwrap()))); + + let _result = merger.merge_type_reference( + &sources, + &mut TestSchemaElement { + coordinate: "testField".to_string(), + typ: None, + }, + false, + Name::new("Parent").unwrap().as_str(), + ); + // Check that there are errors for incompatible types + assert!(merger.has_errors()); + assert_eq!(merger.enum_usages().len(), 0); + } + + #[test] + fn enum_usage_tracking() { + let _schema = create_test_schema(); + let mut merger = create_test_merger().expect("Failed to create test merger"); + + // Test enum usage in output position + let mut sources: Sources = (0..2).map(|i| (i, None)).collect(); + sources.insert(0, Some(Type::Named(Name::new("Status").unwrap()))); + sources.insert(1, Some(Type::Named(Name::new("Status").unwrap()))); + + let _ = merger.merge_type_reference( + &sources, + &mut TestSchemaElement { + coordinate: "user_status".to_string(), + typ: None, + }, + false, + Name::new("Parent").unwrap().as_str(), + ); + + // Test enum usage in input position + let mut arg_sources: Sources = (0..2).map(|i| (i, None)).collect(); + arg_sources.insert(0, Some(Type::Named(Name::new("Status").unwrap()))); + arg_sources.insert(1, Some(Type::Named(Name::new("Status").unwrap()))); + + let _ = merger.merge_type_reference( + &arg_sources, + &mut TestSchemaElement { + coordinate: "status_filter".to_string(), + typ: None, + }, + true, + Name::new("Parent").unwrap().as_str(), + ); + + // Verify enum usage tracking + let enum_usage = merger.get_enum_usage("Status"); + assert!(enum_usage.is_some()); + + let usage = enum_usage.unwrap(); + match usage { + EnumTypeUsage::Both { + input_example, + output_example, + } => { + assert_eq!(input_example.coordinate, "Parent.status_filter"); + assert_eq!(output_example.coordinate, "Parent.user_status"); + } + _ => panic!("Expected Both usage, got {usage:?}"), + } + } + + #[test] + fn enum_usage_output_only() { + let _schema = create_test_schema(); + let mut merger = create_test_merger().expect("Failed to create test merger"); + + // Track enum in output position only + let mut sources: Sources = (0..2).map(|i| (i, None)).collect(); + sources.insert(0, Some(Type::Named(Name::new("Status").unwrap()))); + sources.insert(1, Some(Type::Named(Name::new("Status").unwrap()))); + + let _ = merger.merge_type_reference( + &sources, + &mut TestSchemaElement { + coordinate: "status_out".to_string(), + typ: None, + }, + false, + Name::new("Parent").unwrap().as_str(), + ); + + let usage = merger.get_enum_usage("Status").expect("usage"); + match usage { + EnumTypeUsage::Output { output_example } => { + assert_eq!(output_example.coordinate, "Parent.status_out"); + } + _ => panic!("Expected Output usage"), + } + } + + #[test] + fn enum_usage_input_only() { + let _schema = create_test_schema(); + let mut merger = create_test_merger().expect("Failed to create test merger"); + + // Track enum in input position only + let mut sources: Sources = (0..2).map(|i| (i, None)).collect(); + sources.insert(0, Some(Type::Named(Name::new("Status").unwrap()))); + sources.insert(1, Some(Type::Named(Name::new("Status").unwrap()))); + + let _ = merger.merge_type_reference( + &sources, + &mut TestSchemaElement { + coordinate: "status_in".to_string(), + typ: None, + }, + true, + Name::new("Parent").unwrap().as_str(), + ); + + let usage = merger.get_enum_usage("Status").expect("usage"); + match usage { + EnumTypeUsage::Input { input_example } => { + assert_eq!(input_example.coordinate, "Parent.status_in"); + } + _ => panic!("Expected Input usage"), + } + } + + #[test] + fn empty_sources_reports_error() { + let _schema = create_test_schema(); + let mut merger = create_test_merger().expect("Failed to create test merger"); + + // Test with empty sources + let sources: Sources = IndexMap::default(); + let mut element = TestSchemaElement { + coordinate: "f".into(), + typ: None, + }; + + let result = merger.merge_type_reference( + &sources, + &mut element, + false, + Name::new("Parent").unwrap().as_str(), + ); + + // The implementation returns Ok(false) but adds an error to the error reporter + match result { + Ok(false) => {} // Expected + Ok(true) => panic!("Expected Ok(false), got Ok(true)"), + Err(e) => panic!("Expected Ok(false), got Err: {e:?}"), + } + assert!( + merger.has_errors(), + "Expected an error to be reported for empty sources" + ); + } + + #[test] + fn sources_with_no_defined_types_reports_error() { + let _schema = create_test_schema(); + let mut merger = create_test_merger().expect("Failed to create test merger"); + + let sources: Sources = (0..2).map(|i| (i, None)).collect(); + // both entries None by default + + let mut element = TestSchemaElement { + coordinate: "f".into(), + typ: None, + }; + + let result = merger.merge_type_reference( + &sources, + &mut element, + false, + Name::new("Parent").unwrap().as_str(), + ); + + // The implementation skips None sources, finds no result_type, + // then returns Ok(false) but adds an error to the error reporter + match result { + Ok(false) => {} // Expected + Ok(true) => panic!("Expected Ok(false), got Ok(true)"), + Err(e) => panic!("Expected Ok(false), got Err: {e:?}"), + } + assert!( + merger.has_errors(), + "Expected an error to be reported when no sources have types defined" + ); + } + + #[test] + fn merge_with_field_definition_element() { + let _schema = create_test_schema(); + let mut merger = create_test_merger().expect("Failed to create test merger"); + + // Prepare a field definition in the schema + let mut field_def = FieldDefinition { + name: Name::new("field").unwrap(), + description: None, + arguments: vec![], + directives: Default::default(), + ty: Type::Named(Name::new("String").unwrap()), + }; + let mut sources: Sources = (0..1).map(|i| (i, None)).collect(); + sources.insert(0, Some(Type::Named(Name::new("String").unwrap()))); + + // Call merge_type_reference on a FieldDefinition (TElement = FieldDefinition) + let res = merger.merge_type_reference( + &sources, + &mut field_def, + false, + Name::new("Parent").unwrap().as_str(), + ); + assert!( + res.is_ok(), + "Merging identical types on a FieldDefinition should return true" + ); + assert_eq!( + match field_def.ty.clone() { + Type::Named(n) => n.to_string(), + _ => String::new(), + }, + "String" + ); + } + + #[test] + fn merge_with_input_value_definition_element() { + let _schema = create_test_schema(); + let mut merger = create_test_merger().expect("Failed to create test merger"); + + // Prepare an input value definition (argument) type + let mut input_def = InputValueDefinition { + name: Name::new("arg").unwrap(), + description: None, + default_value: None, + directives: Default::default(), + ty: Type::Named(Name::new("Int").unwrap()).into(), + }; + let mut sources: Sources = (0..2).map(|i| (i, None)).collect(); + sources.insert(0, Some(Type::Named(Name::new("Int").unwrap()))); + sources.insert(1, Some(Type::NonNullNamed(Name::new("Int").unwrap()))); + + // In input position, non-null should be overridden by nullable + let res = merger.merge_type_reference( + &sources, + &mut input_def, + true, + Name::new("Parent").unwrap().as_str(), + ); + assert!(res.is_ok(), "Input position merging should work"); + assert_eq!( + match input_def.ty.as_ref() { + Type::Named(n) => n.as_str(), + _ => "", + }, + "Int" + ); + } +} diff --git a/apollo-federation/src/merger/mod.rs b/apollo-federation/src/merger/mod.rs new file mode 100644 index 0000000000..f40bec5d94 --- /dev/null +++ b/apollo-federation/src/merger/mod.rs @@ -0,0 +1,10 @@ +mod compose_directive_manager; +pub(crate) mod error_reporter; +pub(crate) mod hints; +#[path = "merger.rs"] +pub(crate) mod merge; +pub(crate) mod merge_enum; +mod merge_field; +mod merge_links; +mod merge_type; +mod merge_union; diff --git a/apollo-federation/src/operation/contains.rs b/apollo-federation/src/operation/contains.rs index 904fcdb9d0..7b855bf94b 100644 --- a/apollo-federation/src/operation/contains.rs +++ b/apollo-federation/src/operation/contains.rs @@ -1,7 +1,6 @@ use apollo_compiler::executable; use super::FieldSelection; -use super::FragmentSpreadSelection; use super::HasSelectionKey; use super::InlineFragmentSelection; use super::Selection; @@ -61,14 +60,9 @@ impl Selection { (Selection::Field(self_field), Selection::Field(other_field)) => { self_field.containment(other_field, options) } - ( - Selection::InlineFragment(self_fragment), - Selection::InlineFragment(_) | Selection::FragmentSpread(_), - ) => self_fragment.containment(other, options), - ( - Selection::FragmentSpread(self_fragment), - Selection::InlineFragment(_) | Selection::FragmentSpread(_), - ) => self_fragment.containment(other, options), + (Selection::InlineFragment(self_fragment), Selection::InlineFragment(_)) => { + self_fragment.containment(other, options) + } _ => Containment::NotContained, } } @@ -99,31 +93,16 @@ impl FieldSelection { self_selection.containment(other_selection, options) } (None, Some(_)) | (Some(_), None) => { - debug_assert!(false, "field selections have the same element, so if one does not have a subselection, neither should the other one"); + debug_assert!( + false, + "field selections have the same element, so if one does not have a subselection, neither should the other one" + ); Containment::NotContained } } } } -impl FragmentSpreadSelection { - pub(crate) fn containment( - &self, - other: &Selection, - options: ContainmentOptions, - ) -> Containment { - match other { - // Using keys here means that @defer fragments never compare equal. - // This is a bit odd but it is consistent: the selection set data structure would not - // even try to compare two @defer fragments, because their keys are different. - Selection::FragmentSpread(other) if self.spread.key() == other.spread.key() => self - .selection_set - .containment(&other.selection_set, options), - _ => Containment::NotContained, - } - } -} - impl InlineFragmentSelection { pub(crate) fn containment( &self, @@ -244,8 +223,10 @@ mod tests { ) .unwrap(); let schema = ValidFederationSchema::new(schema).unwrap(); - let left = Operation::parse(schema.clone(), left, "left.graphql", None).unwrap(); - let right = Operation::parse(schema.clone(), right, "right.graphql", None).unwrap(); + let left = Operation::parse(schema.clone(), left, "left.graphql") + .expect("operation is valid and can be parsed"); + let right = Operation::parse(schema, right, "right.graphql") + .expect("operation is valid and can be parsed"); left.selection_set.containment( &right.selection_set, @@ -335,28 +316,6 @@ mod tests { ), Containment::Equal ); - // These select the same things, but containment also counts fragment namedness - assert_eq!( - containment( - "{ intf { ... on HasA { a } } }", - "{ intf { ...named } } fragment named on HasA { a }", - ), - Containment::NotContained - ); - assert_eq!( - containment( - "{ intf { ...named } } fragment named on HasA { a intfField }", - "{ intf { ...named } } fragment named on HasA { a }", - ), - Containment::StrictlyContained - ); - assert_eq!( - containment( - "{ intf { ...named } } fragment named on HasA { a }", - "{ intf { ...named } } fragment named on HasA { a intfField }", - ), - Containment::NotContained - ); } #[test] diff --git a/apollo-federation/src/operation/directive_list.rs b/apollo-federation/src/operation/directive_list.rs index ad716dd1b4..d18063d41a 100644 --- a/apollo-federation/src/operation/directive_list.rs +++ b/apollo-federation/src/operation/directive_list.rs @@ -7,10 +7,14 @@ use std::ops::Deref; use std::sync::Arc; use std::sync::OnceLock; -use apollo_compiler::executable; use apollo_compiler::Name; use apollo_compiler::Node; +use apollo_compiler::collections::IndexSet; +use apollo_compiler::executable; +use serde::Serialize; +use super::DEFER_DIRECTIVE_NAME; +use super::DEFER_LABEL_ARGUMENT_NAME; use super::sort_arguments; /// Compare sorted input values, which means specifically establishing an order between the variants @@ -102,16 +106,23 @@ fn compare_sorted_arguments( static EMPTY_DIRECTIVE_LIST: executable::DirectiveList = executable::DirectiveList(vec![]); /// Contents for a non-empty directive list. -#[derive(Debug, Clone)] +// NOTE: For serialization, we skip everything but the directives. This will require manually +// implementing `Deserialize` as all other fields are derived from the directives. This could also +// mean flattening the serialization and making this type deserialize from +// `executable::DirectiveList` directly. +#[derive(Debug, Clone, Serialize)] struct DirectiveListInner { // Cached hash: hashing may be expensive with deeply nested values or very many directives, // so we only want to do it once. // The hash is eagerly precomputed because we expect to, most of the time, hash a DirectiveList // at least once (when inserting its selection into a selection map). + #[serde(skip)] hash: u64, // Mutable access to the underlying directive list should not be handed out because `sort_order` // may get out of sync. + #[serde(serialize_with = "crate::utils::serde_bridge::serialize_exe_directive_list")] directives: executable::DirectiveList, + #[serde(skip)] sort_order: Vec, } @@ -166,7 +177,7 @@ impl DirectiveListInner { /// /// This list is cheaply cloneable, but not intended for frequent mutations. /// When the list is empty, it does not require an allocation. -#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] pub(crate) struct DirectiveList { inner: Option>, } @@ -296,9 +307,7 @@ impl DirectiveList { // Nothing to do on an empty list return None; }; - let Some(index) = inner.directives.iter().position(|dir| dir.name == name) else { - return None; - }; + let index = inner.directives.iter().position(|dir| dir.name == name)?; // The directive exists and is the only directive: switch to the empty representation if inner.len() == 1 { @@ -326,6 +335,18 @@ impl DirectiveList { inner.rehash(); Some(item) } + + /// Removes @defer directive from self if it has a matching label. + pub(crate) fn remove_defer(&mut self, defer_labels: &IndexSet) { + let label = self + .get(&DEFER_DIRECTIVE_NAME) + .and_then(|directive| directive.specified_argument_by_name(&DEFER_LABEL_ARGUMENT_NAME)) + .and_then(|arg| arg.as_str()); + + if label.is_some_and(|label| defer_labels.contains(label)) { + self.remove_one(&DEFER_DIRECTIVE_NAME); + } + } } /// Iterate over a [`DirectiveList`] in a consistent sort order. diff --git a/apollo-federation/src/operation/merging.rs b/apollo-federation/src/operation/merging.rs index 6b8e89193c..8616f12f1b 100644 --- a/apollo-federation/src/operation/merging.rs +++ b/apollo-federation/src/operation/merging.rs @@ -3,23 +3,21 @@ use std::sync::Arc; use apollo_compiler::collections::IndexMap; -use super::selection_map; use super::FieldSelection; use super::FieldSelectionValue; -use super::FragmentSpreadSelection; -use super::FragmentSpreadSelectionValue; use super::HasSelectionKey as _; use super::InlineFragmentSelection; use super::InlineFragmentSelectionValue; -use super::NamedFragments; use super::Selection; use super::SelectionSet; use super::SelectionValue; +use super::selection_map; use crate::bail; use crate::ensure; use crate::error::FederationError; +use crate::error::SingleFederationError; -impl<'a> FieldSelectionValue<'a> { +impl FieldSelectionValue<'_> { /// Merges the given field selections into this one. /// /// # Preconditions @@ -42,12 +40,23 @@ impl<'a> FieldSelectionValue<'a> { other_field.schema == self_field.schema, "Cannot merge field selections from different schemas", ); - ensure!( - other_field.field_position == self_field.field_position, - "Cannot merge field selection for field \"{}\" into a field selection for field \"{}\"", - other_field.field_position, - self_field.field_position, - ); + if other_field.field_position != self_field.field_position { + return Err(SingleFederationError::InternalUnmergeableFields { + message: format!( + "Cannot merge field selection for field \"{}\" into a field selection for \ + field \"{}\". This is a known query planning bug in the old Javascript \ + query planner that was silently ignored. The Rust-native query planner \ + does not address this bug at this time, but in some cases does catch when \ + this bug occurs. If you're seeing this message, this bug was likely \ + triggered by one of the field selections mentioned previously having an \ + alias that was the same name as the field in the other field selection. \ + The recommended workaround is to change this alias to a different one in \ + your operation.", + other_field.field_position, self_field.field_position, + ), + } + .into()); + } if self.get().selection_set.is_some() { let Some(other_selection_set) = &other.selection_set else { bail!( @@ -70,7 +79,7 @@ impl<'a> FieldSelectionValue<'a> { } } -impl<'a> InlineFragmentSelectionValue<'a> { +impl InlineFragmentSelectionValue<'_> { /// Merges the given normalized inline fragment selections into this one. /// /// # Preconditions @@ -92,7 +101,8 @@ impl<'a> InlineFragmentSelectionValue<'a> { "Cannot merge inline fragment from different schemas", ); ensure!( - other_inline_fragment.parent_type_position == self_inline_fragment.parent_type_position, + other_inline_fragment.parent_type_position + == self_inline_fragment.parent_type_position, "Cannot merge inline fragment of parent type \"{}\" into an inline fragment of parent type \"{}\"", other_inline_fragment.parent_type_position, self_inline_fragment.parent_type_position, @@ -105,36 +115,6 @@ impl<'a> InlineFragmentSelectionValue<'a> { } } -impl<'a> FragmentSpreadSelectionValue<'a> { - /// Merges the given normalized fragment spread selections into this one. - /// - /// # Preconditions - /// All selections must have the same selection key (fragment name + directives). - /// Otherwise this function produces invalid output. - /// - /// # Errors - /// Returns an error if the parent type or schema of any selection does not match `self`'s. - fn merge_into<'op>( - &mut self, - others: impl Iterator, - ) -> Result<(), FederationError> { - let self_fragment_spread = &self.get().spread; - for other in others { - let other_fragment_spread = &other.spread; - ensure!( - other_fragment_spread.schema == self_fragment_spread.schema, - "Cannot merge fragment spread from different schemas", - ); - // Nothing to do since the fragment spread is already part of the selection set. - // Fragment spreads are uniquely identified by fragment name and applied directives. - // Since there is already an entry for the same fragment spread, there is no point - // in attempting to merge its sub-selections, as the underlying entry should be - // exactly the same as the currently processed one. - } - Ok(()) - } -} - impl SelectionSet { /// NOTE: This is a private API and should be used with care, use `add_selection_set` instead. /// @@ -178,7 +158,6 @@ impl SelectionSet { others: impl Iterator, ) -> Result<(), FederationError> { let mut fields = IndexMap::default(); - let mut fragment_spreads = IndexMap::default(); let mut inline_fragments = IndexMap::default(); let target = Arc::make_mut(&mut self.selections); for other_selection in others { @@ -197,32 +176,22 @@ impl SelectionSet { .or_insert_with(Vec::new) .push(other_field_selection); } - Selection::FragmentSpread(self_fragment_spread_selection) => { - let Selection::FragmentSpread(other_fragment_spread_selection) = - other_selection - else { - bail!( - "Fragment spread selection key for fragment \"{}\" references non-field selection", - self_fragment_spread_selection.spread.fragment_name, - ); - }; - fragment_spreads - .entry(other_key.to_owned_key()) - .or_insert_with(Vec::new) - .push(other_fragment_spread_selection); - } Selection::InlineFragment(self_inline_fragment_selection) => { let Selection::InlineFragment(other_inline_fragment_selection) = other_selection else { bail!( "Inline fragment selection key under parent type \"{}\" {}references non-field selection", - self_inline_fragment_selection.inline_fragment.parent_type_position, - self_inline_fragment_selection.inline_fragment.type_condition_position.clone() - .map_or_else( - String::new, - |cond| format!("(type condition: {}) ", cond), - ), + self_inline_fragment_selection + .inline_fragment + .parent_type_position, + self_inline_fragment_selection + .inline_fragment + .type_condition_position + .clone() + .map_or_else(String::new, |cond| format!( + "(type condition: {cond}) " + ),), ); }; inline_fragments @@ -247,17 +216,6 @@ impl SelectionSet { )?; } } - SelectionValue::FragmentSpread(mut self_fragment_spread_selection) => { - if let Some(other_fragment_spread_selections) = - fragment_spreads.shift_remove(&key) - { - self_fragment_spread_selection.merge_into( - other_fragment_spread_selections - .iter() - .map(|selection| &***selection), - )?; - } - } SelectionValue::InlineFragment(mut self_inline_fragment_selection) => { if let Some(other_inline_fragment_selections) = inline_fragments.shift_remove(&key) @@ -322,9 +280,7 @@ impl SelectionSet { self.merge_into(std::iter::once(selection_set)) } - /// Rebase given `SelectionSet` on self and then inserts it into the inner map. Assumes that given - /// selection set does not reference ANY named fragments. If it does, Use `add_selection_set_with_fragments` - /// instead. + /// Rebase given `SelectionSet` on self and then inserts it into the inner map. /// /// Should any sub selection with the same key already exist in the map, the existing selection /// and the given selection are merged, replacing the existing selection while keeping the same @@ -336,25 +292,7 @@ impl SelectionSet { &mut self, selection_set: &SelectionSet, ) -> Result<(), FederationError> { - self.add_selection_set_with_fragments(selection_set, &Default::default()) - } - - /// Rebase given `SelectionSet` on self with the specified fragments and then inserts it into the - /// inner map. - /// - /// Should any sub selection with the same key already exist in the map, the existing selection - /// and the given selection are merged, replacing the existing selection while keeping the same - /// insertion index. - /// - /// # Errors - /// Returns an error if either selection set contains invalid GraphQL that prevents the merge. - pub(crate) fn add_selection_set_with_fragments( - &mut self, - selection_set: &SelectionSet, - named_fragments: &NamedFragments, - ) -> Result<(), FederationError> { - let rebased = - selection_set.rebase_on(&self.type_position, named_fragments, &self.schema)?; + let rebased = selection_set.rebase_on(&self.type_position, &self.schema)?; self.add_local_selection_set(&rebased) } } diff --git a/apollo-federation/src/operation/mod.rs b/apollo-federation/src/operation/mod.rs index 5c0dc7c71f..cba4b198fe 100644 --- a/apollo-federation/src/operation/mod.rs +++ b/apollo-federation/src/operation/mod.rs @@ -13,43 +13,49 @@ //! [`Field`], and the selection type is [`FieldSelection`]. use std::borrow::Cow; +use std::cmp::Ordering; use std::fmt::Display; use std::fmt::Formatter; use std::hash::Hash; +use std::hash::Hasher; use std::ops::Deref; -use std::sync::atomic; use std::sync::Arc; +use std::sync::atomic; -use apollo_compiler::collections::HashSet; +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast; +use apollo_compiler::collections::HashMap; use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use apollo_compiler::executable; +use apollo_compiler::executable::FieldSet; +use apollo_compiler::executable::Fragment; use apollo_compiler::name; use apollo_compiler::schema::Directive; +use apollo_compiler::ty; use apollo_compiler::validation::Valid; -use apollo_compiler::Name; -use apollo_compiler::Node; use itertools::Itertools; -use serde::Serialize; use crate::compat::coerce_executable_values; use crate::error::FederationError; use crate::error::SingleFederationError; use crate::link::graphql_definition::BooleanOrVariable; use crate::link::graphql_definition::DeferDirectiveArguments; -use crate::query_graph::graph_path::OpPathElement; -use crate::query_plan::conditions::Conditions; +use crate::query_graph::graph_path::operation::OpPathElement; use crate::query_plan::FetchDataKeyRenamer; use crate::query_plan::FetchDataPathElement; use crate::query_plan::FetchDataRewrite; +use crate::query_plan::conditions::Conditions; +use crate::schema::ValidFederationSchema; use crate::schema::definitions::types_can_be_merged; use crate::schema::position::AbstractTypeDefinitionPosition; use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::FieldDefinitionPosition; use crate::schema::position::InterfaceTypeDefinitionPosition; use crate::schema::position::SchemaRootDefinitionKind; -use crate::schema::ValidFederationSchema; -use crate::utils::FallibleIterator; +use crate::supergraph::GRAPHQL_STRING_TYPE_NAME; +use crate::utils::MultiIndexMap; mod contains; mod directive_list; @@ -64,6 +70,8 @@ pub(crate) use contains::*; pub(crate) use directive_list::DirectiveList; pub(crate) use merging::*; pub(crate) use rebase::*; +#[cfg(test)] +pub(crate) use tests::never_cancel; pub(crate) const TYPENAME_FIELD: Name = name!("__typename"); @@ -72,12 +80,11 @@ static NEXT_ID: atomic::AtomicUsize = atomic::AtomicUsize::new(1); /// Opaque wrapper of the unique selection ID type. /// -/// Note that we shouldn't add `derive(Serialize, Deserialize)` to this without changing the types -/// to be something like UUIDs. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] -// NOTE(@TylerBloom): This feature gate can be removed once the condition in the comment above is -// met. Note that there are `serde(skip)` statements that should be removed once this is removed. -#[cfg_attr(feature = "snapshot_tracing", derive(Serialize))] +/// NOTE: This ID does not ensure that IDs are unique because its internal counter resets on +/// startup. It currently implements `Serialize` for debugging purposes. It should not implement +/// `Deserialize`, and, more specifically, it should not be used for caching until uniqueness is +/// provided (i.e. the inner type is a `Uuid` or the like). +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, serde::Serialize)] pub(crate) struct SelectionId(usize); impl SelectionId { @@ -92,9 +99,12 @@ impl SelectionId { /// All arguments and input object values are sorted in a consistent order. /// /// This type is immutable and cheaply cloneable. -#[derive(Clone, PartialEq, Eq, Default)] +#[derive(Clone, PartialEq, Eq, Default, serde::Serialize)] pub(crate) struct ArgumentList { /// The inner list *must* be sorted with `sort_arguments`. + #[serde( + serialize_with = "crate::utils::serde_bridge::serialize_optional_slice_of_exe_argument_nodes" + )] inner: Option]>>, } @@ -186,8 +196,8 @@ impl ArgumentList { /// - Stores the schema that the operation is queried against. /// - Swaps `operation_type` with `root_kind` (using the analogous apollo-federation type). /// - Encloses collection types in `Arc`s to facilitate cheaper cloning. -/// - Stores the fragments used by this operation (the executable document the operation was taken -/// from may contain other fragments that are not used by this operation). +/// - Expands all named fragments into inline fragments. +/// - Deduplicates all selections within its selection sets. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Operation { pub(crate) schema: ValidFederationSchema, @@ -196,7 +206,6 @@ pub struct Operation { pub(crate) variables: Arc>>, pub(crate) directives: DirectiveList, pub(crate) selection_set: SelectionSet, - pub(crate) named_fragments: NamedFragments, } impl Operation { @@ -206,36 +215,27 @@ impl Operation { schema: ValidFederationSchema, source_text: &str, source_name: &str, - operation_name: Option<&str>, ) -> Result { let document = apollo_compiler::ExecutableDocument::parse_and_validate( schema.schema(), source_text, source_name, )?; - Operation::from_operation_document(schema, &document, operation_name) - } - - pub fn from_operation_document( - schema: ValidFederationSchema, - document: &Valid, - operation_name: Option<&str>, - ) -> Result { - let operation = document.operations.get(operation_name).map_err(|_| { - FederationError::internal(format!("No operation named {operation_name:?}")) - })?; - let named_fragments = NamedFragments::new(&document.fragments, &schema); - let selection_set = - SelectionSet::from_selection_set(&operation.selection_set, &named_fragments, &schema)?; - Ok(Operation { - schema, + let operation = document.operations.iter().next().expect("operation exists"); + let normalized_operation = Operation { + schema: schema.clone(), root_kind: operation.operation_type.into(), name: operation.name.clone(), variables: Arc::new(operation.variables.clone()), directives: operation.directives.clone().into(), - selection_set, - named_fragments, - }) + selection_set: SelectionSet::from_selection_set( + &operation.selection_set, + &FragmentSpreadCache::init(&document.fragments, &schema, &never_cancel), + &schema, + &never_cancel, + )?, + }; + Ok(normalized_operation) } } @@ -243,7 +243,7 @@ impl Operation { /// - For the type, stores the schema and the position in that schema instead of just the /// `NamedType`. /// - Stores selections in a map so they can be normalized efficiently. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, serde::Serialize)] pub(crate) struct SelectionSet { #[serde(skip)] pub(crate) schema: ValidFederationSchema, @@ -259,10 +259,15 @@ impl PartialEq for SelectionSet { impl Eq for SelectionSet {} +impl Hash for SelectionSet { + fn hash(&self, state: &mut H) { + self.selections.hash(state); + } +} + mod selection_map; pub(crate) use selection_map::FieldSelectionValue; -pub(crate) use selection_map::FragmentSpreadSelectionValue; pub(crate) use selection_map::HasSelectionKey; pub(crate) use selection_map::InlineFragmentSelectionValue; pub(crate) use selection_map::SelectionKey; @@ -271,22 +276,12 @@ pub(crate) use selection_map::SelectionValue; /// An analogue of the apollo-compiler type `Selection` that stores our other selection analogues /// instead of the apollo-compiler types. -#[derive(Debug, Clone, PartialEq, Eq, derive_more::IsVariant, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::IsVariant, serde::Serialize)] pub(crate) enum Selection { Field(Arc), - FragmentSpread(Arc), InlineFragment(Arc), } -/// Element enum that is more general than OpPathElement. -/// - Used for operation optimization. -#[derive(Debug, Clone, derive_more::From)] -pub(crate) enum OperationElement { - Field(Field), - FragmentSpread(FragmentSpread), - InlineFragment(InlineFragment), -} - impl Selection { pub(crate) fn from_field(field: Field, sub_selections: Option) -> Self { Self::Field(Arc::new(field.with_subselection(sub_selections))) @@ -312,40 +307,9 @@ impl Selection { } } - /// Build a selection from an OperationElement and a sub-selection set. - /// - `named_fragments`: Named fragment definitions that are rebased for the element's schema. - pub(crate) fn from_operation_element( - element: OperationElement, - sub_selections: Option, - named_fragments: &NamedFragments, - ) -> Result { - match element { - OperationElement::Field(field) => Ok(Self::from_field(field, sub_selections)), - OperationElement::FragmentSpread(fragment_spread) => { - if sub_selections.is_some() { - return Err(FederationError::internal( - "unexpected fragment spread with sub-selections", - )); - } - Ok(FragmentSpreadSelection::new(fragment_spread, named_fragments)?.into()) - } - OperationElement::InlineFragment(inline_fragment) => { - let Some(sub_selections) = sub_selections else { - return Err(FederationError::internal( - "unexpected inline fragment without sub-selections", - )); - }; - Ok(InlineFragmentSelection::new(inline_fragment, sub_selections).into()) - } - } - } - pub(crate) fn schema(&self) -> &ValidFederationSchema { match self { Selection::Field(field_selection) => &field_selection.field.schema, - Selection::FragmentSpread(fragment_spread_selection) => { - &fragment_spread_selection.spread.schema - } Selection::InlineFragment(inline_fragment_selection) => { &inline_fragment_selection.inline_fragment.schema } @@ -355,41 +319,20 @@ impl Selection { fn directives(&self) -> &DirectiveList { match self { Selection::Field(field_selection) => &field_selection.field.directives, - Selection::FragmentSpread(fragment_spread_selection) => { - &fragment_spread_selection.spread.directives - } Selection::InlineFragment(inline_fragment_selection) => { &inline_fragment_selection.inline_fragment.directives } } } - pub(crate) fn element(&self) -> Result { + pub(crate) fn element(&self) -> OpPathElement { match self { Selection::Field(field_selection) => { - Ok(OpPathElement::Field(field_selection.field.clone())) + OpPathElement::Field(field_selection.field.clone()) } - Selection::FragmentSpread(_) => Err(SingleFederationError::Internal { - message: "Fragment spread does not have element".to_owned(), - } - .into()), - Selection::InlineFragment(inline_fragment_selection) => Ok( - OpPathElement::InlineFragment(inline_fragment_selection.inline_fragment.clone()), - ), - } - } - - pub(crate) fn operation_element(&self) -> Result { - match self { - Selection::Field(field_selection) => { - Ok(OperationElement::Field(field_selection.field.clone())) + Selection::InlineFragment(inline_fragment_selection) => { + OpPathElement::InlineFragment(inline_fragment_selection.inline_fragment.clone()) } - Selection::FragmentSpread(fragment_spread_selection) => Ok( - OperationElement::FragmentSpread(fragment_spread_selection.spread.clone()), - ), - Selection::InlineFragment(inline_fragment_selection) => Ok( - OperationElement::InlineFragment(inline_fragment_selection.inline_fragment.clone()), - ), } } @@ -397,7 +340,6 @@ impl Selection { pub(crate) fn selection_set(&self) -> Option<&SelectionSet> { match self { Selection::Field(field_selection) => field_selection.selection_set.as_ref(), - Selection::FragmentSpread(_) => None, Selection::InlineFragment(inline_fragment_selection) => { Some(&inline_fragment_selection.selection_set) } @@ -437,9 +379,6 @@ impl Selection { Selection::InlineFragment(inline) => { Ok(self_conditions.merge(inline.selection_set.conditions()?)) } - Selection::FragmentSpread(_x) => Err(FederationError::internal( - "Unexpected fragment spread in Selection::conditions()", - )), } } } @@ -462,9 +401,6 @@ impl Selection { .with_updated_selection_set(selection_set) .into()) } - Selection::FragmentSpread(_) => { - Err(FederationError::internal("unexpected fragment spread")) - } } } @@ -480,8 +416,8 @@ impl Selection { /// Apply the `mapper` to self.selection_set, if it exists, and return a new `Selection`. /// - Note: The returned selection may have no subselection set or an empty one if the mapper - /// returns so, which may make the returned selection invalid. It's caller's responsibility - /// to appropriately handle invalid return values. + /// returns so, which may make the returned selection invalid. It's caller's responsibility + /// to appropriately handle invalid return values. pub(crate) fn map_selection_set( &self, mapper: impl FnOnce(&SelectionSet) -> Result, FederationError>, @@ -494,19 +430,12 @@ impl Selection { } } - pub(crate) fn any_element( - &self, - parent_type_position: CompositeTypeDefinitionPosition, - predicate: &mut impl FnMut(OpPathElement) -> Result, - ) -> Result { + pub(crate) fn any_element(&self, predicate: &mut impl FnMut(OpPathElement) -> bool) -> bool { match self { Selection::Field(field_selection) => field_selection.any_element(predicate), Selection::InlineFragment(inline_fragment_selection) => { inline_fragment_selection.any_element(predicate) } - Selection::FragmentSpread(fragment_spread_selection) => { - fragment_spread_selection.any_element(parent_type_position, predicate) - } } } } @@ -517,12 +446,6 @@ impl From for Selection { } } -impl From for Selection { - fn from(value: FragmentSpreadSelection) -> Self { - Self::FragmentSpread(value.into()) - } -} - impl From for Selection { fn from(value: InlineFragmentSelection) -> Self { Self::InlineFragment(value.into()) @@ -533,51 +456,74 @@ impl HasSelectionKey for Selection { fn key(&self) -> SelectionKey<'_> { match self { Selection::Field(field_selection) => field_selection.key(), - Selection::FragmentSpread(fragment_spread_selection) => fragment_spread_selection.key(), Selection::InlineFragment(inline_fragment_selection) => inline_fragment_selection.key(), } } } -#[derive(Debug, Clone, PartialEq, Eq, derive_more::From)] -pub(crate) enum SelectionOrSet { - Selection(Selection), - SelectionSet(SelectionSet), +impl Ord for Selection { + fn cmp(&self, other: &Self) -> Ordering { + fn compare_directives(d1: &DirectiveList, d2: &DirectiveList) -> Ordering { + if d1 == d2 { + Ordering::Equal + } else if d1.is_empty() { + Ordering::Less + } else if d2.is_empty() { + Ordering::Greater + } else { + d1.to_string().cmp(&d2.to_string()) + } + } + + match (self, other) { + (Selection::Field(f1), Selection::Field(f2)) => { + // cannot have two fields with the same response name so no need to check args or directives + f1.field.response_name().cmp(f2.field.response_name()) + } + (Selection::Field(_), _) => Ordering::Less, + (Selection::InlineFragment(_), Selection::Field(_)) => Ordering::Greater, + (Selection::InlineFragment(i1), Selection::InlineFragment(i2)) => { + // compare type conditions and then directives + let first_type_position = &i1.inline_fragment.type_condition_position; + let second_type_position = &i2.inline_fragment.type_condition_position; + match (first_type_position, second_type_position) { + (Some(t1), Some(t2)) => { + let compare_type_conditions = t1.type_name().cmp(t2.type_name()); + if compare_type_conditions == Ordering::Equal { + // compare directive lists + compare_directives( + &i1.inline_fragment.directives, + &i2.inline_fragment.directives, + ) + } else { + compare_type_conditions + } + } + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => { + // compare directive lists + compare_directives( + &i1.inline_fragment.directives, + &i2.inline_fragment.directives, + ) + } + } + } + } + } } -/// An analogue of the apollo-compiler type `Fragment` with these changes: -/// - Stores the type condition explicitly, which means storing the schema and position (in -/// apollo-compiler, this is in the `SelectionSet`). -/// - Encloses collection types in `Arc`s to facilitate cheaper cloning. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct Fragment { - pub(crate) schema: ValidFederationSchema, - pub(crate) name: Name, - pub(crate) type_condition_position: CompositeTypeDefinitionPosition, - pub(crate) directives: DirectiveList, - pub(crate) selection_set: SelectionSet, +impl PartialOrd for Selection { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } } -impl Fragment { - fn from_fragment( - fragment: &executable::Fragment, - named_fragments: &NamedFragments, - schema: &ValidFederationSchema, - ) -> Result { - Ok(Self { - schema: schema.clone(), - name: fragment.name.clone(), - type_condition_position: schema - .get_type(fragment.type_condition().clone())? - .try_into()?, - directives: fragment.directives.clone().into(), - selection_set: SelectionSet::from_selection_set( - &fragment.selection_set, - named_fragments, - schema, - )?, - }) - } +#[derive(Debug, Clone, PartialEq, Eq, derive_more::From)] +pub(crate) enum SelectionOrSet { + Selection(Selection), + SelectionSet(SelectionSet), } mod field_selection { @@ -595,10 +541,10 @@ mod field_selection { use crate::operation::SelectionKey; use crate::operation::SelectionSet; use crate::query_plan::FetchDataPathElement; + use crate::schema::ValidFederationSchema; use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::FieldDefinitionPosition; use crate::schema::position::TypeDefinitionPosition; - use crate::schema::ValidFederationSchema; /// An analogue of the apollo-compiler type `Field` with these changes: /// - Makes the selection set optional. This is because `SelectionSet` requires a type of @@ -609,7 +555,7 @@ mod field_selection { /// - For the field definition, stores the schema and the position in that schema instead of just /// the `FieldDefinition` (which contains no references to the parent type or schema). /// - Encloses collection types in `Arc`s to facilitate cheaper cloning. - #[derive(Debug, Clone, PartialEq, Eq, Serialize)] + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] pub(crate) struct FieldSelection { pub(crate) field: Field, pub(crate) selection_set: Option, @@ -659,9 +605,7 @@ mod field_selection { pub(crate) schema: ValidFederationSchema, pub(crate) field_position: FieldDefinitionPosition, pub(crate) alias: Option, - #[serde(serialize_with = "crate::display_helpers::serialize_as_debug_string")] pub(crate) arguments: ArgumentList, - #[serde(serialize_with = "crate::display_helpers::serialize_as_string")] pub(crate) directives: DirectiveList, pub(crate) sibling_typename: Option, } @@ -783,33 +727,33 @@ mod field_selection { self, selection_set: Option, ) -> FieldSelection { - if cfg!(debug_assertions) { - if let Some(ref selection_set) = selection_set { - if let Ok(field_type) = self.output_base_type() { - if let Ok(field_type_position) = - CompositeTypeDefinitionPosition::try_from(field_type) - { - debug_assert_eq!( - field_type_position, - selection_set.type_position, - "Field and its selection set should point to the same type position [field position: {}, selection position: {}]", field_type_position, selection_set.type_position, - ); - debug_assert_eq!( - self.schema, selection_set.schema, - "Field and its selection set should point to the same schema", - ); - } else { - debug_assert!( - false, - "Field with subselection does not reference CompositeTypePosition" - ); - } + if cfg!(debug_assertions) + && let Some(ref selection_set) = selection_set + { + if let Ok(field_type) = self.output_base_type() { + if let Ok(field_type_position) = + CompositeTypeDefinitionPosition::try_from(field_type) + { + debug_assert_eq!( + field_type_position, selection_set.type_position, + "Field and its selection set should point to the same type position [field position: {}, selection position: {}]", + field_type_position, selection_set.type_position, + ); + debug_assert_eq!( + self.schema, selection_set.schema, + "Field and its selection set should point to the same schema", + ); } else { debug_assert!( false, "Field with subselection does not reference CompositeTypePosition" ); } + } else { + debug_assert!( + false, + "Field with subselection does not reference CompositeTypePosition" + ); } } @@ -834,161 +778,6 @@ pub(crate) use field_selection::Field; pub(crate) use field_selection::FieldSelection; pub(crate) use field_selection::SiblingTypename; -mod fragment_spread_selection { - use apollo_compiler::Name; - use serde::Serialize; - - use crate::operation::is_deferred_selection; - use crate::operation::DirectiveList; - use crate::operation::HasSelectionKey; - use crate::operation::SelectionId; - use crate::operation::SelectionKey; - use crate::operation::SelectionSet; - use crate::schema::position::CompositeTypeDefinitionPosition; - use crate::schema::ValidFederationSchema; - - #[derive(Debug, Clone, PartialEq, Eq, Serialize)] - pub(crate) struct FragmentSpreadSelection { - pub(crate) spread: FragmentSpread, - pub(crate) selection_set: SelectionSet, - } - - impl HasSelectionKey for FragmentSpreadSelection { - fn key(&self) -> SelectionKey<'_> { - self.spread.key() - } - } - - /// An analogue of the apollo-compiler type `FragmentSpread` with these changes: - /// - Stores the schema (may be useful for directives). - /// - Encloses collection types in `Arc`s to facilitate cheaper cloning. - #[derive(Debug, Clone, Serialize)] - pub(crate) struct FragmentSpread { - #[serde(skip)] - pub(crate) schema: ValidFederationSchema, - pub(crate) fragment_name: Name, - pub(crate) type_condition_position: CompositeTypeDefinitionPosition, - // directives applied on the fragment spread selection - #[serde(serialize_with = "crate::display_helpers::serialize_as_string")] - pub(crate) directives: DirectiveList, - // directives applied within the fragment definition - // - // PORT_NOTE: The JS codebase combined the fragment spread's directives with the fragment - // definition's directives. This was invalid GraphQL as those directives may not be applicable - // on different locations. While we now keep track of those references, they are currently ignored. - #[serde(serialize_with = "crate::display_helpers::serialize_as_string")] - pub(crate) fragment_directives: DirectiveList, - #[cfg_attr(not(feature = "snapshot_tracing"), serde(skip))] - pub(crate) selection_id: SelectionId, - } - - impl PartialEq for FragmentSpread { - fn eq(&self, other: &Self) -> bool { - self.key() == other.key() - } - } - - impl Eq for FragmentSpread {} - - impl HasSelectionKey for FragmentSpread { - fn key(&self) -> SelectionKey<'_> { - if is_deferred_selection(&self.directives) { - SelectionKey::Defer { - deferred_id: self.selection_id, - } - } else { - SelectionKey::FragmentSpread { - fragment_name: &self.fragment_name, - directives: &self.directives, - } - } - } - } -} - -pub(crate) use fragment_spread_selection::FragmentSpread; -pub(crate) use fragment_spread_selection::FragmentSpreadSelection; - -impl FragmentSpreadSelection { - /// Normalize this fragment spread into a "normalized" spread representation with following - /// modifications - /// - Stores the schema (may be useful for directives). - /// - Encloses list of directives in `Arc`s to facilitate cheaper cloning. - /// - Stores unique selection ID (used for deferred fragments) - pub(crate) fn from_fragment_spread( - fragment_spread: &executable::FragmentSpread, - fragment: &Node, - ) -> Result { - let spread = FragmentSpread::from_fragment(fragment, &fragment_spread.directives); - Ok(FragmentSpreadSelection { - spread, - selection_set: fragment.selection_set.clone(), - }) - } - - pub(crate) fn from_fragment( - fragment: &Node, - directives: &executable::DirectiveList, - ) -> Self { - let spread = FragmentSpread::from_fragment(fragment, directives); - Self { - spread, - selection_set: fragment.selection_set.clone(), - } - } - - /// Creates a fragment spread selection (in an optimized operation). - /// - `named_fragments`: Named fragment definitions that are rebased for the element's schema. - pub(crate) fn new( - fragment_spread: FragmentSpread, - named_fragments: &NamedFragments, - ) -> Result { - let fragment_name = &fragment_spread.fragment_name; - let fragment = named_fragments.get(fragment_name).ok_or_else(|| { - FederationError::internal(format!("Fragment {} not found", fragment_name)) - })?; - debug_assert_eq!(fragment_spread.schema, fragment.schema); - Ok(Self { - spread: fragment_spread, - selection_set: fragment.selection_set.clone(), - }) - } - - pub(crate) fn any_element( - &self, - parent_type_position: CompositeTypeDefinitionPosition, - predicate: &mut impl FnMut(OpPathElement) -> Result, - ) -> Result { - let inline_fragment = InlineFragment { - schema: self.spread.schema.clone(), - parent_type_position, - type_condition_position: Some(self.spread.type_condition_position.clone()), - directives: self.spread.directives.clone(), - selection_id: self.spread.selection_id, - }; - if predicate(inline_fragment.into())? { - return Ok(true); - } - self.selection_set.any_element(predicate) - } -} - -impl FragmentSpread { - pub(crate) fn from_fragment( - fragment: &Node, - spread_directives: &executable::DirectiveList, - ) -> FragmentSpread { - FragmentSpread { - schema: fragment.schema.clone(), - fragment_name: fragment.name.clone(), - type_condition_position: fragment.type_condition_position.clone(), - directives: spread_directives.clone().into(), - fragment_directives: fragment.directives.clone(), - selection_id: SelectionId::new(), - } - } -} - mod inline_fragment_selection { use std::hash::Hash; use std::hash::Hasher; @@ -996,17 +785,17 @@ mod inline_fragment_selection { use serde::Serialize; use crate::error::FederationError; - use crate::link::graphql_definition::defer_directive_arguments; use crate::link::graphql_definition::DeferDirectiveArguments; - use crate::operation::is_deferred_selection; + use crate::link::graphql_definition::defer_directive_arguments; use crate::operation::DirectiveList; use crate::operation::HasSelectionKey; use crate::operation::SelectionId; use crate::operation::SelectionKey; use crate::operation::SelectionSet; + use crate::operation::is_deferred_selection; use crate::query_plan::FetchDataPathElement; - use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::ValidFederationSchema; + use crate::schema::position::CompositeTypeDefinitionPosition; /// An analogue of the apollo-compiler type `InlineFragment` with these changes: /// - Stores the inline fragment data (other than the selection set) in `InlineFragment`, @@ -1016,7 +805,7 @@ mod inline_fragment_selection { /// - Stores the parent type explicitly, which means storing the position (in apollo-compiler, this /// is in the parent selection set). /// - Encloses collection types in `Arc`s to facilitate cheaper cloning. - #[derive(Debug, Clone, PartialEq, Eq, Serialize)] + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] pub(crate) struct InlineFragmentSelection { pub(crate) inline_fragment: InlineFragment, pub(crate) selection_set: SelectionSet, @@ -1063,9 +852,7 @@ mod inline_fragment_selection { pub(crate) schema: ValidFederationSchema, pub(crate) parent_type_position: CompositeTypeDefinitionPosition, pub(crate) type_condition_position: Option, - #[serde(serialize_with = "crate::display_helpers::serialize_as_string")] pub(crate) directives: DirectiveList, - #[cfg_attr(not(feature = "snapshot_tracing"), serde(skip))] pub(crate) selection_id: SelectionId, } @@ -1155,40 +942,10 @@ pub(crate) use inline_fragment_selection::InlineFragmentSelection; use self::selection_map::OwnedSelectionKey; use crate::schema::position::INTROSPECTION_TYPENAME_FIELD_NAME; -/// A simple MultiMap implementation using IndexMap with Vec as its value type. -/// - Preserves the insertion order of keys and values. -struct MultiIndexMap(IndexMap>); - -impl Deref for MultiIndexMap { - type Target = IndexMap>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl MultiIndexMap -where - K: Eq + Hash, -{ - fn new() -> Self { - Self(IndexMap::default()) - } - - fn insert(&mut self, key: K, value: V) { - self.0.entry(key).or_default().push(value); - } - - fn extend>(&mut self, iterable: I) { - for (key, value) in iterable { - self.insert(key, value); - } - } -} - /// the return type of `lazy_map` function's `mapper` closure argument #[derive(derive_more::From)] pub(crate) enum SelectionMapperReturn { + #[allow(unused)] // may be better to keep unused than to add back when necessary None, Selection(Selection), SelectionList(Vec), @@ -1247,10 +1004,8 @@ impl SelectionSet { self.parent_type.clone(), selection, )); - } else if let Ok(element) = selection.element() { - if let Some(set) = selection.selection_set().cloned() { - self.stack.push((element, Self::new(set))); - } + } else if let Some(set) = selection.selection_set().cloned() { + self.stack.push((selection.element(), Self::new(set))); } } Some((element, top)) => { @@ -1327,9 +1082,11 @@ impl SelectionSet { schema.schema(), type_position.type_name().clone(), source_text, - )?; - let named_fragments = NamedFragments::new(&IndexMap::default(), &schema); - SelectionSet::from_selection_set(&selection_set, &named_fragments, &schema) + false, + )? + .0; + let fragments = Default::default(); + SelectionSet::from_selection_set(&selection_set, &fragments, &schema, &never_cancel) } pub(crate) fn is_empty(&self) -> bool { @@ -1366,8 +1123,9 @@ impl SelectionSet { /// case. pub(crate) fn from_selection_set( selection_set: &executable::SelectionSet, - fragments: &NamedFragments, + fragments_cache: &FragmentSpreadCache, schema: &ValidFederationSchema, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, ) -> Result { let type_position: CompositeTypeDefinitionPosition = schema.get_type(selection_set.ty.clone())?.try_into()?; @@ -1376,8 +1134,9 @@ impl SelectionSet { &selection_set.selections, &type_position, &mut normalized_selections, - fragments, + fragments_cache, schema, + check_cancellation, )?; let mut merged = SelectionSet { schema: schema.clone(), @@ -1393,17 +1152,20 @@ impl SelectionSet { selections: &[executable::Selection], parent_type_position: &CompositeTypeDefinitionPosition, destination: &mut Vec, - fragments: &NamedFragments, + fragments_cache: &FragmentSpreadCache, schema: &ValidFederationSchema, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, ) -> Result<(), FederationError> { for selection in selections { + check_cancellation()?; match selection { executable::Selection::Field(field_selection) => { let Some(normalized_field_selection) = FieldSelection::from_field( field_selection, parent_type_position, - fragments, + fragments_cache, schema, + check_cancellation, )? else { continue; @@ -1411,24 +1173,29 @@ impl SelectionSet { destination.push(Selection::from(normalized_field_selection)); } executable::Selection::FragmentSpread(fragment_spread_selection) => { - let Some(fragment) = fragments.get(&fragment_spread_selection.fragment_name) - else { - return Err(SingleFederationError::Internal { - message: format!( - "Fragment spread referenced non-existent fragment \"{}\"", - fragment_spread_selection.fragment_name, - ), - } - .into()); - }; - // if we don't expand fragments, we need to normalize it - let normalized_fragment_spread = FragmentSpreadSelection::from_fragment_spread( + // convert to inline fragment + let inline_fragment_selection = InlineFragmentSelection::from_fragment_spread( + parent_type_position, // the parent type of this inline selection fragment_spread_selection, - fragment, + fragments_cache, + schema, )?; - destination.push(Selection::FragmentSpread(Arc::new( - normalized_fragment_spread, - ))); + // We can hoist/collapse named fragments if their type condition is on the + // parent type and they don't have any directives. + let fragment_type_condition = inline_fragment_selection + .inline_fragment + .type_condition_position + .clone(); + if fragment_type_condition + .is_some_and(|position| &position == parent_type_position) + && fragment_spread_selection.directives.is_empty() + { + destination.extend(inline_fragment_selection.selection_set); + } else { + destination.push(Selection::InlineFragment(Arc::new( + inline_fragment_selection, + ))); + } } executable::Selection::InlineFragment(inline_fragment_selection) => { let is_on_parent_type = @@ -1451,16 +1218,18 @@ impl SelectionSet { &inline_fragment_selection.selection_set.selections, parent_type_position, destination, - fragments, + fragments_cache, schema, + check_cancellation, )?; } else { let normalized_inline_fragment_selection = InlineFragmentSelection::from_inline_fragment( inline_fragment_selection, parent_type_position, - fragments, + fragments_cache, schema, + check_cancellation, )?; destination.push(Selection::InlineFragment(Arc::new( normalized_inline_fragment_selection, @@ -1472,69 +1241,6 @@ impl SelectionSet { Ok(()) } - pub(crate) fn expand_all_fragments(&self) -> Result { - let mut expanded_selections = vec![]; - SelectionSet::expand_selection_set(&mut expanded_selections, self)?; - - let mut expanded = SelectionSet { - schema: self.schema.clone(), - type_position: self.type_position.clone(), - selections: Arc::new(SelectionMap::new()), - }; - expanded.merge_selections_into(expanded_selections.iter())?; - Ok(expanded) - } - - fn expand_selection_set( - destination: &mut Vec, - selection_set: &SelectionSet, - ) -> Result<(), FederationError> { - for value in selection_set.selections.values() { - match value { - Selection::Field(field_selection) => { - let selections = match &field_selection.selection_set { - Some(s) => Some(s.expand_all_fragments()?), - None => None, - }; - destination.push(Selection::from_field( - field_selection.field.clone(), - selections, - )) - } - Selection::FragmentSpread(spread_selection) => { - // We can hoist/collapse named fragments if their type condition is on the - // parent type and they don't have any directives. - if spread_selection.spread.type_condition_position - == selection_set.type_position - && spread_selection.spread.directives.is_empty() - { - SelectionSet::expand_selection_set( - destination, - &spread_selection.selection_set, - )?; - } else { - // convert to inline fragment - let expanded = InlineFragmentSelection::from_fragment_spread_selection( - selection_set.type_position.clone(), // the parent type of this inline selection - spread_selection, - )?; - destination.push(Selection::InlineFragment(Arc::new(expanded))); - } - } - Selection::InlineFragment(inline_selection) => { - destination.push( - InlineFragmentSelection::new( - inline_selection.inline_fragment.clone(), - inline_selection.selection_set.expand_all_fragments()?, - ) - .into(), - ); - } - } - } - Ok(()) - } - /// Modifies the provided selection set to optimize the handling of __typename selections for query planning. /// /// __typename information can always be provided by any subgraph declaring that type. While this data can be @@ -1557,7 +1263,7 @@ impl SelectionSet { /// so we can efficiently generate query plans. In order to prevent the query planner from spending time /// exploring those useless __typename options, we "remove" the unnecessary __typename selections from the /// operation. Since we need to ensure that the __typename field will still need to be queried, we "tag" - /// one of the "sibling" selections (using "attachement") to remember that __typename needs to be added + /// one of the "sibling" selections (using "attachment") to remember that __typename needs to be added /// back eventually. The core query planning algorithm will ignore that tag, and because __typename has been /// otherwise removed, we'll save any related work. As we build the final query plan, we'll check back for /// those "tags" and add back the __typename selections. As this only happen after the query planning @@ -1600,15 +1306,6 @@ impl SelectionSet { .get_selection_set_mut() .optimize_sibling_typenames(interface_types_with_interface_objects)?; } - SelectionValue::FragmentSpread(fragment_spread) => { - // at this point in time all fragment spreads should have been converted into inline fragments - return Err(FederationError::internal( - format!( - "Error while optimizing sibling typename information, selection set contains {} named fragment", - fragment_spread.get().spread.fragment_name - ) - )); - } } } @@ -1636,36 +1333,35 @@ impl SelectionSet { Ok(()) } - pub(crate) fn without_empty_branches(&self) -> Result>, FederationError> { + pub(crate) fn without_empty_branches(&self) -> Option> { let filtered = self.filter_recursive_depth_first(&mut |sel| match sel { - Selection::Field(field) => Ok(if let Some(set) = &field.selection_set { - !set.is_empty() - } else { - true - }), - Selection::InlineFragment(inline) => Ok(!inline.selection_set.is_empty()), - Selection::FragmentSpread(_) => { - Err(FederationError::internal("unexpected fragment spread")) + Selection::Field(field) => { + if let Some(set) = &field.selection_set { + !set.is_empty() + } else { + true + } } - })?; - Ok(if filtered.selections.is_empty() { + Selection::InlineFragment(inline) => !inline.selection_set.is_empty(), + }); + if filtered.selections.is_empty() { None } else { Some(filtered) - }) + } } pub(crate) fn filter_recursive_depth_first( &self, - predicate: &mut dyn FnMut(&Selection) -> Result, - ) -> Result, FederationError> { - match self.selections.filter_recursive_depth_first(predicate)? { - Cow::Borrowed(_) => Ok(Cow::Borrowed(self)), - Cow::Owned(selections) => Ok(Cow::Owned(Self { + predicate: &mut dyn FnMut(&Selection) -> bool, + ) -> Cow<'_, Self> { + match self.selections.filter_recursive_depth_first(predicate) { + Cow::Borrowed(_) => Cow::Borrowed(self), + Cow::Owned(selections) => Cow::Owned(Self { schema: self.schema.clone(), type_position: self.type_position.clone(), selections: Arc::new(selections), - })), + }), } } @@ -1674,7 +1370,7 @@ impl SelectionSet { /// This tries to be smart about including or excluding the whole selection set. /// - If all selections have the same condition, returns that condition. /// - If selections in the set have different conditions, the selection set must always be - /// included, so the individual selections can be evaluated. + /// included, so the individual selections can be evaluated. /// /// # Errors /// Returns an error if the selection set contains a fragment spread, or if any of the @@ -1711,7 +1407,6 @@ impl SelectionSet { schema: &ValidFederationSchema, parent_type: &CompositeTypeDefinitionPosition, selections: impl Iterator, - named_fragments: &NamedFragments, ) -> Result { let mut iter = selections; let Some(first) = iter.next() else { @@ -1723,22 +1418,16 @@ impl SelectionSet { let Some(second) = iter.next() else { // Optimize for the simple case of a single selection, as we don't have to do anything // complex to merge the sub-selections. - return first.rebase_on(parent_type, named_fragments, schema); + return first.rebase_on(parent_type, schema); }; - let element = first - .operation_element()? - .rebase_on(parent_type, schema, named_fragments)?; + let element = first.element().rebase_on(parent_type, schema)?; let sub_selection_parent_type: Option = element.sub_selection_type_position()?; let Some(ref sub_selection_parent_type) = sub_selection_parent_type else { // This is a leaf, so all updates should correspond ot the same field and we just use the first. - return Selection::from_operation_element( - element, - /*sub_selection*/ None, - named_fragments, - ); + return Selection::from_element(element, /*sub_selection*/ None); }; // This case has a sub-selection. Merge all sub-selection updates. @@ -1758,9 +1447,8 @@ impl SelectionSet { schema, sub_selection_parent_type, sub_selection_updates.values().map(|v| v.iter()), - named_fragments, )?); - Selection::from_operation_element(element, updated_sub_selection, named_fragments) + Selection::from_element(element, updated_sub_selection) } /// Build a selection set by aggregating all items from the `selection_key_groups` iterator. @@ -1771,10 +1459,9 @@ impl SelectionSet { schema: &ValidFederationSchema, parent_type: &CompositeTypeDefinitionPosition, selection_key_groups: impl Iterator>, - named_fragments: &NamedFragments, ) -> Result { selection_key_groups - .map(|group| Self::make_selection(schema, parent_type, group, named_fragments)) + .map(|group| Self::make_selection(schema, parent_type, group)) .try_collect() .map(|result| SelectionSet { schema: schema.clone(), @@ -1796,7 +1483,6 @@ impl SelectionSet { // `Arc::make_mut` on the `Arc` fields of `self` didn't seem better than cloning Arc instances. pub(crate) fn lazy_map( &self, - named_fragments: &NamedFragments, mut mapper: impl FnMut(&Selection) -> Result, ) -> Result { let mut iter = self.selections.values(); @@ -1848,13 +1534,12 @@ impl SelectionSet { &self.schema, &self.type_position, updated_selections.values().map(|v| v.iter()), - named_fragments, ) } pub(crate) fn add_back_typename_in_attachments(&self) -> Result { - self.lazy_map(/*named_fragments*/ &Default::default(), |selection| { - let selection_element = selection.element()?; + self.lazy_map(|selection| { + let selection_element = selection.element(); let updated = selection .map_selection_set(|ss| ss.add_back_typename_in_attachments().map(Some))?; let Some(sibling_typename) = selection_element.sibling_typename() else { @@ -1864,7 +1549,7 @@ impl SelectionSet { // We need to add the query __typename for the current type in the current group. let field_element = Field::new_introspection_typename( &self.schema, - &selection.element()?.parent_type_position(), + &selection.element().parent_type_position(), sibling_typename.alias().cloned(), ); let typename_selection = @@ -1877,9 +1562,9 @@ impl SelectionSet { /// /// __typename is added to the sub selection set of a given selection in following conditions /// * if a given selection is a field, we add a __typename sub selection if its selection set type - /// position is an abstract type + /// position is an abstract type /// * if a given selection is a fragment, we only add __typename sub selection if fragment specifies - /// type condition and that type condition is an abstract type. + /// type condition and that type condition is an abstract type. pub(crate) fn add_typename_field_for_abstract_types( &self, parent_type_if_abstract: Option, @@ -1904,9 +1589,6 @@ impl SelectionSet { .selection_set .as_ref() .map(|s| s.type_position.clone()), - Selection::FragmentSpread(fragment_selection) => { - Some(fragment_selection.spread.type_condition_position.clone()) - } Selection::InlineFragment(inline_fragment_selection) => { inline_fragment_selection .inline_fragment @@ -1955,7 +1637,7 @@ impl SelectionSet { /// /// The final selections are optional. If `path` ends on a leaf field, then no followup /// selections would make sense. - /// When final selections are provided, unecessary fragments will be automatically removed + /// When final selections are provided, unnecessary fragments will be automatically removed /// at the junction between the path and those final selections. /// /// For instance, suppose that we have: @@ -1982,8 +1664,9 @@ impl SelectionSet { let Some(sub_selection_type) = element.sub_selection_type_position()? else { return Err(FederationError::internal("unexpected error: add_at_path encountered a field that is not of a composite type".to_string())); }; + let element_key = element.key().to_owned_key(); let mut selection = Arc::make_mut(&mut self.selections) - .entry(ele.key()) + .entry(element_key.as_borrowed_key()) .or_insert(|| { Selection::from_element( element, @@ -2000,9 +1683,6 @@ impl SelectionSet { SelectionValue::InlineFragment(fragment) => fragment .get_selection_set_mut() .add_at_path(path, selection_set)?, - SelectionValue::FragmentSpread(_fragment) => { - return Err(FederationError::internal("add_at_path encountered a named fragment spread which should never happen".to_string())); - } }; } // If we have no sub-path, we can add the selection. @@ -2040,11 +1720,9 @@ impl SelectionSet { sub_selection_type_pos.clone(), ); for selection in selections.iter() { - selection_set.add_local_selection(&selection.rebase_on( - &sub_selection_type_pos, - &NamedFragments::default(), - &self.schema, - )?)?; + selection_set.add_local_selection( + &selection.rebase_on(&sub_selection_type_pos, &self.schema)?, + )?; } Ok::<_, FederationError>(selection_set) }) @@ -2123,7 +1801,7 @@ impl SelectionSet { let mut selection_map = SelectionMap::new(); for selection in self.selections.values() { - let path_element = selection.element()?.as_path_element(); + let path_element = selection.element().as_path_element(); let subselection_aliases = remaining .iter() .filter_map(|alias| { @@ -2165,9 +1843,6 @@ impl SelectionSet { .insert(selection.with_updated_selection_set(updated_selection_set)?); } } - Selection::FragmentSpread(_) => { - return Err(FederationError::internal("unexpected fragment spread")) - } } } @@ -2178,15 +1853,6 @@ impl SelectionSet { }) } - /// In a normalized selection set containing only fields and inline fragments, - /// iterate over all the fields that may be selected. - /// - /// # Preconditions - /// The selection set must not contain named fragment spreads. - pub(crate) fn field_selections(&self) -> FieldSelectionsIter<'_> { - FieldSelectionsIter::new(self.selections.values()) - } - /// # Preconditions /// The selection set must not contain named fragment spreads. fn fields_in_set(&self) -> Vec { @@ -2198,12 +1864,6 @@ impl SelectionSet { path: Vec::new(), field: field.clone(), }), - Selection::FragmentSpread(_fragment) => { - debug_assert!( - false, - "unexpected fragment spreads in expanded fetch operation" - ); - } Selection::InlineFragment(inline_fragment) => { let condition = inline_fragment .inline_fragment @@ -2299,13 +1959,10 @@ impl SelectionSet { /// their fragment selection sets are recursed into. Note this method is short-circuiting. // PORT_NOTE: The JS codebase calls this "some()", but that's easy to confuse with "Some" in // Rust. - pub(crate) fn any_element( - &self, - predicate: &mut impl FnMut(OpPathElement) -> Result, - ) -> Result { + pub(crate) fn any_element(&self, predicate: &mut impl FnMut(OpPathElement) -> bool) -> bool { self.selections .values() - .fallible_any(|selection| selection.any_element(self.type_position.clone(), predicate)) + .any(|selection| selection.any_element(predicate)) } } @@ -2327,36 +1984,6 @@ impl<'a> IntoIterator for &'a SelectionSet { } } -pub(crate) struct FieldSelectionsIter<'sel> { - stack: Vec>, -} - -impl<'sel> FieldSelectionsIter<'sel> { - fn new(iter: selection_map::Values<'sel>) -> Self { - Self { stack: vec![iter] } - } -} - -impl<'sel> Iterator for FieldSelectionsIter<'sel> { - type Item = &'sel Arc; - - fn next(&mut self) -> Option { - match self.stack.last_mut()?.next() { - None if self.stack.len() == 1 => None, - None => { - self.stack.pop(); - self.next() - } - Some(Selection::Field(field)) => Some(field), - Some(Selection::InlineFragment(frag)) => { - self.stack.push(frag.selection_set.selections.values()); - self.next() - } - Some(Selection::FragmentSpread(_frag)) => unreachable!(), - } - } -} - #[derive(Clone, Debug)] pub(crate) struct SelectionSetAtPath { path: Vec, @@ -2394,7 +2021,7 @@ fn compute_aliases_for_non_merging_fields( let mut seen_response_names: IndexMap = IndexMap::default(); // - `s.selections` must be fragment-spread-free. - fn rebased_fields_in_set(s: &SelectionSetAtPath) -> impl Iterator + '_ { + fn rebased_fields_in_set(s: &SelectionSetAtPath) -> impl Iterator { s.selections.iter().flat_map(|s2| { s2.fields_in_set() .into_iter() @@ -2457,12 +2084,12 @@ fn compute_aliases_for_non_merging_fields( }; } } else { - // We need to alias the new occurence. + // We need to alias the new occurrence. let alias = gen_alias_name(response_name, &seen_response_names); // Given how we generate aliases, it's is very unlikely that the generated alias will conflict with any of the other response name // at the level, but it's theoretically possible. By adding the alias to the seen names, we ensure that in the remote change that - // this ever happen, we'll avoid the conflict by giving another alias to the followup occurence. + // this ever happen, we'll avoid the conflict by giving another alias to the followup occurrence. let selections = match field.selection_set.as_ref() { Some(s) => { let mut p = path.clone(); @@ -2531,10 +2158,10 @@ fn compute_aliases_for_non_merging_fields( fn gen_alias_name(base_name: &Name, unavailable_names: &IndexMap) -> Name { let mut counter = 0usize; loop { - if let Ok(name) = Name::try_from(format!("{base_name}__alias_{counter}")) { - if !unavailable_names.contains_key(&name) { - return name; - } + if let Ok(name) = Name::try_from(format!("{base_name}__alias_{counter}")) + && !unavailable_names.contains_key(&name) + { + return name; } counter += 1; } @@ -2565,8 +2192,9 @@ impl FieldSelection { pub(crate) fn from_field( field: &executable::Field, parent_type_position: &CompositeTypeDefinitionPosition, - fragments: &NamedFragments, + fragments_cache: &FragmentSpreadCache, schema: &ValidFederationSchema, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, ) -> Result, FederationError> { // Skip __schema/__type introspection fields as router takes care of those, and they do not // need to be query planned. @@ -2596,8 +2224,9 @@ impl FieldSelection { selection_set: if is_composite { Some(SelectionSet::from_selection_set( &field.selection_set, - fragments, + fragments_cache, schema, + check_cancellation, )?) } else { None @@ -2612,19 +2241,16 @@ impl FieldSelection { } } - pub(crate) fn any_element( - &self, - predicate: &mut impl FnMut(OpPathElement) -> Result, - ) -> Result { - if predicate(self.field.clone().into())? { - return Ok(true); + pub(crate) fn any_element(&self, predicate: &mut impl FnMut(OpPathElement) -> bool) -> bool { + if predicate(self.field.clone().into()) { + return true; } - if let Some(selection_set) = &self.selection_set { - if selection_set.any_element(predicate)? { - return Ok(true); - } + if let Some(selection_set) = &self.selection_set + && selection_set.any_element(predicate) + { + return true; } - Ok(false) + false } } @@ -2632,16 +2258,6 @@ impl Field { pub(crate) fn parent_type_position(&self) -> CompositeTypeDefinitionPosition { self.field_position.parent() } - - pub(crate) fn types_can_be_merged(&self, other: &Self) -> Result { - let self_definition = self.field_position.get(self.schema().schema())?; - let other_definition = other.field_position.get(self.schema().schema())?; - types_can_be_merged( - &self_definition.ty, - &other_definition.ty, - self.schema().schema(), - ) - } } impl InlineFragmentSelection { @@ -2671,8 +2287,9 @@ impl InlineFragmentSelection { pub(crate) fn from_inline_fragment( inline_fragment: &executable::InlineFragment, parent_type_position: &CompositeTypeDefinitionPosition, - fragments: &NamedFragments, + fragments_cache: &FragmentSpreadCache, schema: &ValidFederationSchema, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, ) -> Result { let type_condition_position: Option = if let Some(type_condition) = &inline_fragment.type_condition { @@ -2680,8 +2297,12 @@ impl InlineFragmentSelection { } else { None }; - let new_selection_set = - SelectionSet::from_selection_set(&inline_fragment.selection_set, fragments, schema)?; + let new_selection_set = SelectionSet::from_selection_set( + &inline_fragment.selection_set, + fragments_cache, + schema, + check_cancellation, + )?; let new_inline_fragment = InlineFragment { schema: schema.clone(), parent_type_position: parent_type_position.clone(), @@ -2695,13 +2316,27 @@ impl InlineFragmentSelection { )) } - pub(crate) fn from_fragment_spread_selection( - parent_type_position: CompositeTypeDefinitionPosition, - fragment_spread_selection: &Arc, + pub(crate) fn from_fragment_spread( + parent_type_position: &CompositeTypeDefinitionPosition, + fragment_spread: &executable::FragmentSpread, + fragments_cache: &FragmentSpreadCache, + schema: &ValidFederationSchema, ) -> Result { - let schema = fragment_spread_selection.spread.schema.schema(); - for directive in fragment_spread_selection.spread.directives.iter() { - let Some(definition) = schema.directive_definitions.get(&directive.name) else { + let valid_schema = schema.schema(); + // verify fragment exists + let Some(fragment_selection) = fragments_cache.get(&fragment_spread.fragment_name) else { + return Err(SingleFederationError::Internal { + message: format!( + "Fragment spread referenced non-existent fragment \"{}\"", + fragment_spread.fragment_name, + ), + } + .into()); + }; + + // verify fragment spread directives can be applied on inline fragments + for directive in fragment_spread.directives.iter() { + let Some(definition) = valid_schema.directive_definitions.get(&directive.name) else { return Err(FederationError::internal(format!( "Undefined directive {}", directive.name @@ -2718,44 +2353,19 @@ impl InlineFragmentSelection { } } - // Note: We assume that fragment_spread_selection.spread.type_condition_position is the same as - // fragment_spread_selection.selection_set.type_position. + // Note: We assume that fragment.type_condition() is the same as fragment.selection_set.ty. Ok(InlineFragmentSelection::new( InlineFragment { - schema: fragment_spread_selection.spread.schema.clone(), - parent_type_position, - type_condition_position: Some( - fragment_spread_selection - .spread - .type_condition_position - .clone(), - ), - directives: fragment_spread_selection.spread.directives.clone(), + schema: schema.clone(), + parent_type_position: parent_type_position.clone(), + type_condition_position: Some(fragment_selection.type_position.clone()), + directives: fragment_spread.directives.clone().into(), selection_id: SelectionId::new(), }, - fragment_spread_selection - .selection_set - .expand_all_fragments()?, + fragment_selection.clone(), )) } - /// Construct a new InlineFragmentSelection out of a selection set. - /// - The new type condition will be the same as the selection set's type. - pub(crate) fn from_selection_set( - parent_type_position: CompositeTypeDefinitionPosition, - selection_set: SelectionSet, - directives: DirectiveList, - ) -> Self { - let inline_fragment_data = InlineFragment { - schema: selection_set.schema.clone(), - parent_type_position, - type_condition_position: selection_set.type_position.clone().into(), - directives, - selection_id: SelectionId::new(), - }; - InlineFragmentSelection::new(inline_fragment_data, selection_set) - } - pub(crate) fn casted_type(&self) -> &CompositeTypeDefinitionPosition { self.inline_fragment .type_condition_position @@ -2785,185 +2395,14 @@ impl InlineFragmentSelection { .is_subtype(type_condition.type_name(), parent.type_name()) } - pub(crate) fn any_element( - &self, - predicate: &mut impl FnMut(OpPathElement) -> Result, - ) -> Result { - if predicate(self.inline_fragment.clone().into())? { - return Ok(true); + pub(crate) fn any_element(&self, predicate: &mut impl FnMut(OpPathElement) -> bool) -> bool { + if predicate(self.inline_fragment.clone().into()) { + return true; } self.selection_set.any_element(predicate) } } -/// This uses internal copy-on-write optimization to make `Clone` cheap. -/// However a cloned `NamedFragments` still behaves like a deep copy: -/// unlike in JS where we can have multiple references to a mutable map, -/// here modifying a cloned map will leave the original unchanged. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub(crate) struct NamedFragments { - fragments: Arc>>, -} - -impl NamedFragments { - pub(crate) fn new( - fragments: &IndexMap>, - schema: &ValidFederationSchema, - ) -> NamedFragments { - // JS PORT - In order to normalize Fragments we need to process them in dependency order. - // - // In JS implementation mapInDependencyOrder method was called when rebasing/filtering/expanding selection sets. - // Since resulting `IndexMap` of `NormalizedFragments` will be already sorted, we only need to map it once - // when creating the `NamedFragments`. - NamedFragments::initialize_in_dependency_order(fragments, schema) - } - - pub(crate) fn is_empty(&self) -> bool { - self.fragments.len() == 0 - } - - pub(crate) fn len(&self) -> usize { - self.fragments.len() - } - - pub(crate) fn iter(&self) -> impl Iterator> { - self.fragments.values() - } - - pub(crate) fn iter_rev(&self) -> impl Iterator> { - self.fragments.values().rev() - } - - pub(crate) fn iter_mut(&mut self) -> indexmap::map::IterMut<'_, Name, Node> { - Arc::make_mut(&mut self.fragments).iter_mut() - } - - // Calls `retain` on the underlying `IndexMap`. - pub(crate) fn retain(&mut self, mut predicate: impl FnMut(&Name, &Node) -> bool) { - Arc::make_mut(&mut self.fragments).retain(|name, fragment| predicate(name, fragment)); - } - - fn insert(&mut self, fragment: Fragment) { - Arc::make_mut(&mut self.fragments).insert(fragment.name.clone(), Node::new(fragment)); - } - - pub(crate) fn get(&self, name: &str) -> Option<&Node> { - self.fragments.get(name) - } - - pub(crate) fn contains(&self, name: &str) -> bool { - self.fragments.contains_key(name) - } - - /// JS PORT NOTE: In JS implementation this method was named mapInDependencyOrder and accepted a lambda to - /// apply transformation on the fragments. It was called when rebasing/filtering/expanding selection sets. - /// JS PORT NOTE: In JS implementation this method was potentially returning `undefined`. In order to simplify the code - /// we will always return `NamedFragments` even if they are empty. - /// - /// We normalize passed in fragments in their dependency order, i.e. if a fragment A uses another fragment B, then we will - /// normalize B _before_ attempting to normalize A. Normalized fragments have access to previously normalized fragments. - fn initialize_in_dependency_order( - fragments: &IndexMap>, - schema: &ValidFederationSchema, - ) -> NamedFragments { - struct FragmentDependencies { - fragment: Node, - depends_on: Vec, - } - - // Note: We use IndexMap to stabilize the ordering of the result, which influences - // the outcome of `map_to_expanded_selection_sets`. - let mut fragments_map: IndexMap = IndexMap::default(); - for fragment in fragments.values() { - let mut fragment_usages = IndexMap::default(); - NamedFragments::collect_fragment_usages(&fragment.selection_set, &mut fragment_usages); - let usages: Vec = fragment_usages.keys().cloned().collect::>(); - fragments_map.insert( - fragment.name.clone(), - FragmentDependencies { - fragment: fragment.clone(), - depends_on: usages, - }, - ); - } - - let mut removed_fragments: IndexSet = IndexSet::default(); - let mut mapped_fragments = NamedFragments::default(); - while !fragments_map.is_empty() { - // Note that graphQL specifies that named fragments cannot have cycles (https://spec.graphql.org/draft/#sec-Fragment-spreads-must-not-form-cycles) - // and so we're guaranteed that on every iteration, at least one element of the map is removed (so the `while` loop will terminate). - fragments_map.retain(|name, info| { - let can_remove = info - .depends_on - .iter() - .all(|n| mapped_fragments.contains(n) || removed_fragments.contains(n)); - if can_remove { - if let Ok(normalized) = - Fragment::from_fragment(&info.fragment, &mapped_fragments, schema) - { - // TODO this actually throws in JS code -> should we also throw? - // JS code has methods for - // * add and throw exception if entry already there - // * add_if_not_exists - // Rust IndexMap exposes insert (that overwrites) and try_insert (that throws) - mapped_fragments.insert(normalized); - } else { - removed_fragments.insert(name.clone()); - } - } - // keep only the elements that cannot be removed - !can_remove - }); - } - mapped_fragments - } - - /// Just like our `SelectionSet::used_fragments`, but with apollo-compiler types - fn collect_fragment_usages( - selection_set: &executable::SelectionSet, - aggregator: &mut IndexMap, - ) { - selection_set.selections.iter().for_each(|s| match s { - executable::Selection::Field(f) => { - NamedFragments::collect_fragment_usages(&f.selection_set, aggregator); - } - executable::Selection::InlineFragment(i) => { - NamedFragments::collect_fragment_usages(&i.selection_set, aggregator); - } - executable::Selection::FragmentSpread(f) => { - let current_count = aggregator.entry(f.fragment_name.clone()).or_default(); - *current_count += 1; - } - }) - } - - /// When we rebase named fragments on a subgraph schema, only a subset of what the fragment handles may belong - /// to that particular subgraph. And there are a few sub-cases where that subset is such that we basically need or - /// want to consider to ignore the fragment for that subgraph, and that is when: - /// 1. the subset that apply is actually empty. The fragment wouldn't be valid in this case anyway. - /// 2. the subset is a single leaf field: in that case, using the one field directly is just shorter than using - /// the fragment, so we consider the fragment don't really apply to that subgraph. Technically, using the - /// fragment could still be of value if the fragment name is a lot smaller than the one field name, but it's - /// enough of a niche case that we ignore it. Note in particular that one sub-case of this rule that is likely - /// to be common is when the subset ends up being just `__typename`: this would basically mean the fragment - /// don't really apply to the subgraph, and that this will ensure this is the case. - pub(crate) fn is_selection_set_worth_using(selection_set: &SelectionSet) -> bool { - if selection_set.selections.len() == 0 { - return false; - } - if selection_set.selections.len() == 1 { - // true if NOT field selection OR non-leaf field - return if let Some(Selection::Field(field_selection)) = selection_set.selections.first() - { - field_selection.selection_set.is_some() - } else { - true - }; - } - true - } -} - // @defer handling: removing and normalization const DEFER_DIRECTIVE_NAME: Name = name!("defer"); @@ -2982,7 +2421,7 @@ pub(crate) struct NormalizedDefer { } struct DeferNormalizer { - used_labels: HashSet, + used_labels: IndexSet, assigned_labels: IndexSet, conditions: IndexMap>, label_offset: usize, @@ -2991,19 +2430,19 @@ struct DeferNormalizer { impl DeferNormalizer { fn new(selection_set: &SelectionSet) -> Result { let mut digest = Self { - used_labels: HashSet::default(), + used_labels: IndexSet::default(), label_offset: 0, assigned_labels: IndexSet::default(), conditions: IndexMap::default(), }; let mut stack = selection_set.into_iter().collect::>(); while let Some(selection) = stack.pop() { - if let Selection::InlineFragment(inline) = selection { - if let Some(args) = inline.inline_fragment.defer_directive_arguments()? { - let DeferDirectiveArguments { label, if_: _ } = args; - if let Some(label) = label { - digest.used_labels.insert(label); - } + if let Selection::InlineFragment(inline) = selection + && let Some(args) = inline.inline_fragment.defer_directive_arguments()? + { + let DeferDirectiveArguments { label, if_: _ } = args; + if let Some(label) = label { + digest.used_labels.insert(label); } } stack.extend(selection.selection_set().into_iter().flatten()); @@ -3027,77 +2466,6 @@ impl DeferNormalizer { } } -#[derive(Debug, Clone, Copy)] -enum DeferFilter<'a> { - All, - Labels(&'a IndexSet), -} - -impl DeferFilter<'_> { - fn remove_defer(&self, directive_list: &mut DirectiveList, schema: &apollo_compiler::Schema) { - match self { - Self::All => { - directive_list.remove_one(&DEFER_DIRECTIVE_NAME); - } - Self::Labels(set) => { - let label = directive_list - .get(&DEFER_DIRECTIVE_NAME) - .and_then(|directive| { - directive - .argument_by_name(&DEFER_LABEL_ARGUMENT_NAME, schema) - .ok() - }) - .and_then(|arg| arg.as_str()); - if label.is_some_and(|label| set.contains(label)) { - directive_list.remove_one(&DEFER_DIRECTIVE_NAME); - } - } - } - } -} - -impl Fragment { - /// Returns true if the fragment's selection set contains the @defer directive. - fn has_defer(&self) -> bool { - self.selection_set.has_defer() - } - - fn without_defer( - &self, - filter: DeferFilter<'_>, - named_fragments: &NamedFragments, - ) -> Result { - let selection_set = self.selection_set.without_defer(filter, named_fragments)?; - Ok(Fragment { - schema: self.schema.clone(), - name: self.name.clone(), - type_condition_position: self.type_condition_position.clone(), - directives: self.directives.clone(), - selection_set, - }) - } -} - -impl NamedFragments { - /// Creates new fragment definitions with the @defer directive removed. - fn without_defer(&self, filter: DeferFilter<'_>) -> Result { - let mut new_fragments = NamedFragments { - fragments: Default::default(), - }; - // The iteration is in dependency order: when we iterate a fragment A that depends on - // fragment B, we know that we have already processed fragment B. - // This implies that all references to other fragments will already be part of - // `new_fragments`. Note that we must process all fragments that depend on each other, even - // if a fragment doesn't actually use @defer itself, to make sure that the `.selection_set` - // values on each selection are up to date. - for fragment in self.iter() { - let fragment = fragment.without_defer(filter, &new_fragments)?; - new_fragments.insert(fragment); - } - Ok(new_fragments) - } -} - impl FieldSelection { /// Returns true if the selection or any of its subselections uses the @defer directive. fn has_defer(&self) -> bool { @@ -3106,35 +2474,17 @@ impl FieldSelection { } } -impl FragmentSpread { - /// Returns true if the fragment spread has a @defer directive. - fn has_defer(&self) -> bool { - self.directives.has(&DEFER_DIRECTIVE_NAME) - } - - fn without_defer(&self, filter: DeferFilter<'_>) -> Result { - let mut without_defer = self.clone(); - filter.remove_defer(&mut without_defer.directives, without_defer.schema.schema()); - Ok(without_defer) - } -} - -impl FragmentSpreadSelection { - fn has_defer(&self) -> bool { - self.spread.has_defer() || self.selection_set.has_defer() - } -} - impl InlineFragment { /// Returns true if the fragment has a @defer directive. fn has_defer(&self) -> bool { self.directives.has(&DEFER_DIRECTIVE_NAME) } - fn without_defer(&self, filter: DeferFilter<'_>) -> Result { - let mut without_defer = self.clone(); - filter.remove_defer(&mut without_defer.directives, without_defer.schema.schema()); - Ok(without_defer) + /// Create a new inline fragment without @defer directive applications that have a matching label. + fn reduce_defer(&self, defer_labels: &IndexSet) -> Result { + let mut reduce_defer = self.clone(); + reduce_defer.directives.remove_defer(defer_labels); + Ok(reduce_defer) } } @@ -3220,20 +2570,14 @@ impl Selection { pub(crate) fn has_defer(&self) -> bool { match self { Selection::Field(field_selection) => field_selection.has_defer(), - Selection::FragmentSpread(fragment_spread_selection) => { - fragment_spread_selection.has_defer() - } Selection::InlineFragment(inline_fragment_selection) => { inline_fragment_selection.has_defer() } } } - fn without_defer( - &self, - filter: DeferFilter<'_>, - named_fragments: &NamedFragments, - ) -> Result { + /// Create a new selection without @defer directive applications that have a matching label. + fn reduce_defer(&self, defer_labels: &IndexSet) -> Result { match self { Selection::Field(field) => { let Some(selection_set) = field @@ -3245,18 +2589,12 @@ impl Selection { }; Ok(field - .with_updated_selection_set(Some( - selection_set.without_defer(filter, named_fragments)?, - )) + .with_updated_selection_set(Some(selection_set.reduce_defer(defer_labels)?)) .into()) } - Selection::FragmentSpread(frag) => { - let spread = frag.spread.without_defer(filter)?; - Ok(FragmentSpreadSelection::new(spread, named_fragments)?.into()) - } Selection::InlineFragment(frag) => { - let inline_fragment = frag.inline_fragment.without_defer(filter)?; - let selection_set = frag.selection_set.without_defer(filter, named_fragments)?; + let inline_fragment = frag.inline_fragment.reduce_defer(defer_labels)?; + let selection_set = frag.selection_set.reduce_defer(defer_labels)?; Ok(InlineFragmentSelection::new(inline_fragment, selection_set).into()) } } @@ -3273,9 +2611,6 @@ impl Selection { .transpose()?, ), ))), - Selection::FragmentSpread(_spread) => { - Err(FederationError::internal("unexpected fragment spread")) - } Selection::InlineFragment(inline) => inline .with_updated_selection_set( inline.selection_set.clone().normalize_defer(normalizer)?, @@ -3287,19 +2622,13 @@ impl Selection { } impl SelectionSet { - /// Create a new selection set without @defer directive applications. - fn without_defer( - &self, - filter: DeferFilter<'_>, - named_fragments: &NamedFragments, - ) -> Result { - let mut without_defer = - SelectionSet::empty(self.schema.clone(), self.type_position.clone()); + /// Create a new selection set without @defer directive applications that have a matching label. + fn reduce_defer(&self, defer_labels: &IndexSet) -> Result { + let mut reduce_defer = SelectionSet::empty(self.schema.clone(), self.type_position.clone()); for selection in self.selections.values() { - without_defer - .add_local_selection(&selection.without_defer(filter, named_fragments)?)?; + reduce_defer.add_local_selection(&selection.reduce_defer(defer_labels)?)?; } - Ok(without_defer) + Ok(reduce_defer) } fn has_defer(&self) -> bool { @@ -3327,24 +2656,6 @@ impl SelectionSet { impl Operation { fn has_defer(&self) -> bool { self.selection_set.has_defer() - || self - .named_fragments - .fragments - .values() - .any(|f| f.has_defer()) - } - - /// Create a new operation without @defer directive applications. - pub(crate) fn without_defer(mut self) -> Result { - if self.has_defer() { - let named_fragments = self.named_fragments.without_defer(DeferFilter::All)?; - self.selection_set = self - .selection_set - .without_defer(DeferFilter::All, &named_fragments)?; - self.named_fragments = named_fragments; - } - debug_assert!(!self.has_defer()); - Ok(self) } /// Create a new operation without specific @defer(label:) directive applications. @@ -3353,13 +2664,7 @@ impl Operation { labels: &IndexSet, ) -> Result { if self.has_defer() { - let named_fragments = self - .named_fragments - .without_defer(DeferFilter::Labels(labels))?; - self.selection_set = self - .selection_set - .without_defer(DeferFilter::Labels(labels), &named_fragments)?; - self.named_fragments = named_fragments; + self.selection_set = self.selection_set.reduce_defer(labels)?; } Ok(self) } @@ -3400,49 +2705,6 @@ impl Operation { } } -// Collect fragment usages from operation types. - -impl Selection { - fn collect_used_fragment_names(&self, aggregator: &mut IndexMap) { - match self { - Selection::Field(field_selection) => { - if let Some(s) = &field_selection.selection_set { - s.collect_used_fragment_names(aggregator) - } - } - Selection::InlineFragment(inline) => { - inline.selection_set.collect_used_fragment_names(aggregator); - } - Selection::FragmentSpread(fragment) => { - let current_count = aggregator - .entry(fragment.spread.fragment_name.clone()) - .or_default(); - *current_count += 1; - } - } - } -} - -impl SelectionSet { - pub(crate) fn collect_used_fragment_names(&self, aggregator: &mut IndexMap) { - for s in self.selections.values() { - s.collect_used_fragment_names(aggregator); - } - } - - pub(crate) fn used_fragments(&self) -> IndexMap { - let mut usages = IndexMap::default(); - self.collect_used_fragment_names(&mut usages); - usages - } -} - -impl Fragment { - pub(crate) fn collect_used_fragment_names(&self, aggregator: &mut IndexMap) { - self.selection_set.collect_used_fragment_names(aggregator) - } -} - // Collect used variables from operation types. pub(crate) struct VariableCollector<'s> { @@ -3510,21 +2772,10 @@ impl<'s> VariableCollector<'s> { self.visit_selection_set(&selection.selection_set); } - fn visit_fragment_spread(&mut self, fragment: &'s FragmentSpread) { - self.visit_directive_list(&fragment.directives); - self.visit_directive_list(&fragment.fragment_directives); - } - - fn visit_fragment_spread_selection(&mut self, selection: &'s FragmentSpreadSelection) { - self.visit_fragment_spread(&selection.spread); - self.visit_selection_set(&selection.selection_set); - } - fn visit_selection(&mut self, selection: &'s Selection) { match selection { Selection::Field(field) => self.visit_field_selection(field), Selection::InlineFragment(frag) => self.visit_inline_fragment_selection(frag), - Selection::FragmentSpread(frag) => self.visit_fragment_spread_selection(frag), } } @@ -3540,16 +2791,6 @@ impl<'s> VariableCollector<'s> { } } -impl Fragment { - /// Returns the variable names that are used by this fragment. - pub(crate) fn used_variables(&self) -> IndexSet<&'_ Name> { - let mut collector = VariableCollector::new(); - collector.visit_directive_list(&self.directives); - collector.visit_selection_set(&self.selection_set); - collector.into_inner() - } -} - impl SelectionSet { /// Returns the variable names that are used by this selection set, including through fragment /// spreads. @@ -3578,18 +2819,6 @@ impl TryFrom<&Operation> for executable::Operation { } } -impl TryFrom<&Fragment> for executable::Fragment { - type Error = FederationError; - - fn try_from(normalized_fragment: &Fragment) -> Result { - Ok(Self { - name: normalized_fragment.name.clone(), - directives: normalized_fragment.directives.iter().cloned().collect(), - selection_set: (&normalized_fragment.selection_set).try_into()?, - }) - } -} - impl TryFrom<&SelectionSet> for executable::SelectionSet { type Error = FederationError; @@ -3597,21 +2826,32 @@ impl TryFrom<&SelectionSet> for executable::SelectionSet { let mut flattened = vec![]; for normalized_selection in val.selections.values() { let selection: executable::Selection = normalized_selection.try_into()?; - if let executable::Selection::Field(field) = &selection { - if field.name == *INTROSPECTION_TYPENAME_FIELD_NAME - && field.directives.is_empty() - && field.alias.is_none() - { - // Move the plain __typename to the start of the selection set. - // This looks nicer, and matches existing tests. - // Note: The plain-ness is also defined in `Field::is_plain_typename_field`. - // PORT_NOTE: JS does this in `selectionsInPrintOrder` - flattened.insert(0, selection); - continue; - } + if let executable::Selection::Field(field) = &selection + && field.name == *INTROSPECTION_TYPENAME_FIELD_NAME + && field.directives.is_empty() + && field.alias.is_none() + { + // Move the plain __typename to the start of the selection set. + // This looks nicer, and matches existing tests. + // Note: The plain-ness is also defined in `Field::is_plain_typename_field`. + // PORT_NOTE: JS does this in `selectionsInPrintOrder` + flattened.insert(0, selection); + continue; } flattened.push(selection); } + if flattened.is_empty() { + // In theory, for valid operations, we shouldn't have empty selection sets (field + // selections whose type is a leaf will have an undefined selection set, not an empty + // one). We do "abuse" this a bit however when create query "witness" during + // composition validation where, to make it easier for users to locate the issue, we + // want the created witness query to stop where the validation problem lies, even if + // we're not on a leaf type. To make this look nice and explicit, we handle that case + // by create a fake selection set that just contains an ellipsis, indicate there is + // supposed to be more but we elided it for clarity. And yes, the whole thing is a bit + // of a hack, albeit a convenient one. + flattened.push(ellipsis_field()?); + } Ok(Self { ty: val.type_position.type_name().clone(), selections: flattened, @@ -3619,6 +2859,26 @@ impl TryFrom<&SelectionSet> for executable::SelectionSet { } } +/// Create a synthetic field named "...". +fn ellipsis_field() -> Result { + let field_name = Name::new_unchecked("..."); + let field_def = ast::FieldDefinition { + description: None, + ty: ty!(String), + name: field_name.clone(), + arguments: vec![], + directives: Default::default(), + }; + Ok(executable::Selection::Field(Node::new(executable::Field { + definition: Node::new(field_def), + alias: None, + name: field_name, + arguments: vec![], + directives: Default::default(), + selection_set: executable::SelectionSet::new(GRAPHQL_STRING_TYPE_NAME), + }))) +} + impl TryFrom<&Selection> for executable::Selection { type Error = FederationError; @@ -3627,11 +2887,6 @@ impl TryFrom<&Selection> for executable::Selection { Selection::Field(normalized_field_selection) => executable::Selection::Field( Node::new(normalized_field_selection.deref().try_into()?), ), - Selection::FragmentSpread(normalized_fragment_spread_selection) => { - executable::Selection::FragmentSpread(Node::new( - normalized_fragment_spread_selection.deref().into(), - )) - } Selection::InlineFragment(normalized_inline_fragment_selection) => { executable::Selection::InlineFragment(Node::new( normalized_inline_fragment_selection.deref().try_into()?, @@ -3717,39 +2972,12 @@ impl TryFrom<&InlineFragmentSelection> for executable::InlineFragment { } } -impl From<&FragmentSpreadSelection> for executable::FragmentSpread { - fn from(val: &FragmentSpreadSelection) -> Self { - let normalized_fragment_spread = &val.spread; - Self { - fragment_name: normalized_fragment_spread.fragment_name.to_owned(), - directives: normalized_fragment_spread - .directives - .iter() - .cloned() - .collect(), - } - } -} - impl TryFrom for Valid { type Error = FederationError; fn try_from(value: Operation) -> Result { let operation = executable::Operation::try_from(&value)?; - let fragments = value - .named_fragments - .fragments - .iter() - .map(|(name, fragment)| { - Ok(( - name.clone(), - Node::new(executable::Fragment::try_from(&**fragment)?), - )) - }) - .collect::, FederationError>>()?; - let mut document = executable::ExecutableDocument::new(); - document.fragments = fragments; document.operations.insert(operation); coerce_executable_values(value.schema.schema(), &mut document); Ok(document.validate(value.schema.schema())?) @@ -3764,31 +2992,35 @@ impl Display for Operation { Ok(operation) => operation, Err(_) => return Err(std::fmt::Error), }; - for fragment_def in self.named_fragments.iter() { - fragment_def.fmt(f)?; - f.write_str("\n\n")?; - } operation.serialize().fmt(f) } } -impl Display for Fragment { +impl Display for SelectionSet { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let fragment: executable::Fragment = match self.try_into() { - Ok(fragment) => fragment, + let selection_set: executable::SelectionSet = match self.try_into() { + Ok(selection_set) => selection_set, Err(_) => return Err(std::fmt::Error), }; - fragment.serialize().fmt(f) + selection_set.serialize().no_indent().fmt(f) } } -impl Display for SelectionSet { +pub(crate) struct FieldSetDisplay>(pub(crate) T); + +impl> Display for FieldSetDisplay { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let selection_set: executable::SelectionSet = match self.try_into() { + let selection_set: executable::SelectionSet = match self.0.as_ref().try_into() { Ok(selection_set) => selection_set, Err(_) => return Err(std::fmt::Error), }; - selection_set.serialize().no_indent().fmt(f) + FieldSet { + sources: Default::default(), + selection_set, + } + .serialize() + .no_indent() + .fmt(f) } } @@ -3822,13 +3054,6 @@ impl Display for InlineFragmentSelection { } } -impl Display for FragmentSpreadSelection { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let fragment_spread: executable::FragmentSpread = self.into(); - fragment_spread.serialize().no_indent().fmt(f) - } -} - impl Display for Field { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { // We create a selection with an empty selection set here, relying on `apollo-rs` to skip @@ -3854,22 +3079,117 @@ impl Display for InlineFragment { } } -impl Display for FragmentSpread { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let data = self; - f.write_str("...")?; - f.write_str(&data.fragment_name)?; - data.directives.serialize().no_indent().fmt(f) +/// Holds normalized selection sets of provided fragments. +/// +/// PORT_NOTE: The JS codebase combined the fragment spread's directives with the fragment +/// definition's directives. This was invalid GraphQL as those directives may not be applicable +/// on different locations. Fragment directives are currently ignored. We validate whether +/// fragment spread directives can be applied to inline fragment and raise an error if they +/// are not applicable. +#[derive(Default)] +pub(crate) struct FragmentSpreadCache { + fragment_selection_sets: Arc>, +} + +impl FragmentSpreadCache { + // in order to normalize selection sets, we need to process them in dependency order + fn init( + fragments: &IndexMap>, + schema: &ValidFederationSchema, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, + ) -> Self { + FragmentSpreadCache::normalize_in_dependency_order(fragments, schema, check_cancellation) } -} -impl Display for OperationElement { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - OperationElement::Field(field) => field.fmt(f), - OperationElement::InlineFragment(inline_fragment) => inline_fragment.fmt(f), - OperationElement::FragmentSpread(fragment_spread) => fragment_spread.fmt(f), + fn insert(&mut self, fragment_name: &Name, selection_set: SelectionSet) { + Arc::make_mut(&mut self.fragment_selection_sets) + .insert(fragment_name.clone(), selection_set); + } + + pub(crate) fn get(&self, name: &str) -> Option<&SelectionSet> { + self.fragment_selection_sets.get(name) + } + + pub(crate) fn contains(&self, name: &str) -> bool { + self.fragment_selection_sets.contains_key(name) + } + + // We normalize passed in fragments in their dependency order, i.e. if a fragment A uses another fragment B, then we will + // normalize B _before_ attempting to normalize A. Normalized fragments have access to previously normalized fragments. + fn normalize_in_dependency_order( + fragments: &IndexMap>, + schema: &ValidFederationSchema, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, + ) -> FragmentSpreadCache { + struct FragmentDependencies { + fragment: Node, + depends_on: Vec, + } + + // Note: We use IndexMap to stabilize the ordering of the result so we can + // normalize them in order. + let mut fragments_map: IndexMap = IndexMap::default(); + for fragment in fragments.values() { + let mut fragment_usages = IndexMap::default(); + FragmentSpreadCache::collect_fragment_usages( + &fragment.selection_set, + &mut fragment_usages, + ); + let usages: Vec = fragment_usages.keys().cloned().collect::>(); + fragments_map.insert( + fragment.name.clone(), + FragmentDependencies { + fragment: fragment.clone(), + depends_on: usages, + }, + ); } + + let mut removed_fragments: IndexSet = IndexSet::default(); + let mut cache = FragmentSpreadCache::default(); + while !fragments_map.is_empty() { + // Note that graphQL specifies that named fragments cannot have cycles (https://spec.graphql.org/draft/#sec-Fragment-spreads-must-not-form-cycles) + // and so we're guaranteed that on every iteration, at least one element of the map is removed (so the `while` loop will terminate). + fragments_map.retain(|name, info| { + let can_remove = info + .depends_on + .iter() + .all(|n| cache.contains(n) || removed_fragments.contains(n)); + if can_remove { + if let Ok(normalized) = SelectionSet::from_selection_set( + &info.fragment.selection_set, + &cache, + schema, + check_cancellation, + ) { + cache.insert(&info.fragment.name, normalized); + } else { + removed_fragments.insert(name.clone()); + } + } + // keep only the elements that cannot be removed + !can_remove + }); + } + cache + } + /// Just like our `SelectionSet::used_fragments`, but with apollo-compiler types + fn collect_fragment_usages( + selection_set: &executable::SelectionSet, + aggregator: &mut IndexMap, + ) { + selection_set.selections.iter().for_each(|s| match s { + executable::Selection::Field(f) => { + FragmentSpreadCache::collect_fragment_usages(&f.selection_set, aggregator); + } + executable::Selection::InlineFragment(i) => { + FragmentSpreadCache::collect_fragment_usages(&i.selection_set, aggregator); + } + executable::Selection::FragmentSpread(f) => { + let current_count = aggregator.entry(f.fragment_name.clone()).or_default(); + *current_count += 1; + } + }) } } @@ -3884,24 +3204,24 @@ impl Display for OperationElement { /// their parent type matches. pub(crate) fn normalize_operation( operation: &executable::Operation, - named_fragments: NamedFragments, + fragments: &IndexMap>, schema: &ValidFederationSchema, interface_types_with_interface_objects: &IndexSet, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, ) -> Result { - let mut normalized_selection_set = - SelectionSet::from_selection_set(&operation.selection_set, &named_fragments, schema)?; - normalized_selection_set = normalized_selection_set.expand_all_fragments()?; + let fragment_cache = FragmentSpreadCache::init(fragments, schema, check_cancellation); + let mut normalized_selection_set = SelectionSet::from_selection_set( + &operation.selection_set, + &fragment_cache, + schema, + check_cancellation, + )?; // We clear up the fragments since we've expanded all. // Also note that expanding fragment usually generate unnecessary fragments/inefficient - // selections, so it basically always make sense to flatten afterwards. Besides, fragment - // reuse (done by `optimize`) relies on the fact that its input is normalized to work properly, - // so all the more reason to do it here. + // selections, so it basically always make sense to flatten afterwards. // PORT_NOTE: This was done in `Operation.expandAllFragments`, but it's moved here. - normalized_selection_set = normalized_selection_set.flatten_unnecessary_fragments( - &normalized_selection_set.type_position, - &named_fragments, - schema, - )?; + normalized_selection_set = normalized_selection_set + .flatten_unnecessary_fragments(&normalized_selection_set.type_position, schema)?; remove_introspection(&mut normalized_selection_set); normalized_selection_set.optimize_sibling_typenames(interface_types_with_interface_objects)?; @@ -3912,7 +3232,6 @@ pub(crate) fn normalize_operation( variables: Arc::new(operation.variables.clone()), directives: operation.directives.clone().into(), selection_set: normalized_selection_set, - named_fragments, }; Ok(normalized_operation) } diff --git a/apollo-federation/src/operation/optimize.rs b/apollo-federation/src/operation/optimize.rs index c7e54b23f7..f62f2da457 100644 --- a/apollo-federation/src/operation/optimize.rs +++ b/apollo-federation/src/operation/optimize.rs @@ -35,182 +35,33 @@ //! ## `reuse_fragments` methods (putting everything together) //! Recursive optimization of selection and selection sets. +use std::ops::Deref; use std::sync::Arc; -use apollo_compiler::collections::IndexMap; -use apollo_compiler::collections::IndexSet; -use apollo_compiler::executable; -use apollo_compiler::executable::VariableDefinition; use apollo_compiler::Name; use apollo_compiler::Node; +use apollo_compiler::collections::HashMap; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::executable; +use apollo_compiler::executable::Fragment; +use apollo_compiler::executable::FragmentMap; +use apollo_compiler::validation::Valid; -use super::Containment; -use super::ContainmentOptions; -use super::DirectiveList; -use super::Field; use super::FieldSelection; -use super::Fragment; -use super::FragmentSpreadSelection; use super::HasSelectionKey; use super::InlineFragmentSelection; -use super::NamedFragments; use super::Operation; use super::Selection; +use super::SelectionId; use super::SelectionMapperReturn; use super::SelectionOrSet; use super::SelectionSet; +use crate::compat::coerce_executable_values; use crate::error::FederationError; -use crate::operation::FragmentSpread; -use crate::operation::SelectionValue; use crate::schema::position::CompositeTypeDefinitionPosition; - -#[derive(Debug)] -struct ReuseContext<'a> { - fragments: &'a NamedFragments, - operation_variables: Option>, -} - -impl<'a> ReuseContext<'a> { - fn for_fragments(fragments: &'a NamedFragments) -> Self { - Self { - fragments, - operation_variables: None, - } - } - - // Taking two separate parameters so the caller can still mutate the operation's selection set. - fn for_operation( - fragments: &'a NamedFragments, - operation_variables: &'a [Node], - ) -> Self { - Self { - fragments, - operation_variables: Some(operation_variables.iter().map(|var| &var.name).collect()), - } - } -} - -//============================================================================= -// Add __typename field for abstract types in named fragment definitions - -impl NamedFragments { - // - Expands all nested fragments - // - Applies the provided `mapper` to each selection set of the expanded fragments. - // - Finally, re-fragments the nested fragments. - // - `mapper` must return a fragment-spread-free selection set. - fn map_to_expanded_selection_sets( - &self, - mut mapper: impl FnMut(&SelectionSet) -> Result, - ) -> Result { - let mut result = NamedFragments::default(); - // Note: `self.fragments` has insertion order topologically sorted. - for fragment in self.fragments.values() { - let expanded_selection_set = fragment - .selection_set - .expand_all_fragments()? - .flatten_unnecessary_fragments( - &fragment.type_condition_position, - &Default::default(), - &fragment.schema, - )?; - let mut mapped_selection_set = mapper(&expanded_selection_set)?; - // `mapped_selection_set` must be fragment-spread-free. - mapped_selection_set.reuse_fragments(&ReuseContext::for_fragments(&result))?; - let updated = Fragment { - selection_set: mapped_selection_set, - schema: fragment.schema.clone(), - name: fragment.name.clone(), - type_condition_position: fragment.type_condition_position.clone(), - directives: fragment.directives.clone(), - }; - result.insert(updated); - } - Ok(result) - } - - pub(crate) fn add_typename_field_for_abstract_types_in_named_fragments( - &self, - ) -> Result { - // This method is a bit tricky due to potentially nested fragments. More precisely, suppose that - // we have: - // fragment MyFragment on T { - // a { - // b { - // ...InnerB - // } - // } - // } - // - // fragment InnerB on B { - // __typename - // x - // y - // } - // then if we were to "naively" add `__typename`, the first fragment would end up being: - // fragment MyFragment on T { - // a { - // __typename - // b { - // __typename - // ...InnerX - // } - // } - // } - // but that's not ideal because the inner-most `__typename` is already within `InnerX`. And that - // gets in the way to re-adding fragments (the `SelectionSet::reuse_fragments` method) because if we start - // with: - // { - // a { - // __typename - // b { - // __typename - // x - // y - // } - // } - // } - // and add `InnerB` first, we get: - // { - // a { - // __typename - // b { - // ...InnerB - // } - // } - // } - // and it becomes tricky to recognize the "updated-with-typename" version of `MyFragment` now (we "seem" - // to miss a `__typename`). - // - // Anyway, to avoid this issue, what we do is that for every fragment, we: - // 1. expand any nested fragments in its selection. - // 2. add `__typename` where we should in that expanded selection. - // 3. re-optimize all fragments (using the "updated-with-typename" versions). - // which is what `mapToExpandedSelectionSets` gives us. - - if self.is_empty() { - // PORT_NOTE: This was an assertion failure in JS version. But, it's actually ok to - // return unchanged if empty. - return Ok(self.clone()); - } - let updated = self.map_to_expanded_selection_sets(|ss| { - // Note: Since `ss` won't have any fragment spreads, `add_typename_field_for_abstract_types`'s return - // value won't have any fragment spreads. - ss.add_typename_field_for_abstract_types(/*parent_type_if_abstract*/ None) - })?; - // PORT_NOTE: The JS version asserts if `updated` is empty or not. But, we really want to - // check the `updated` has the same set of fragments. To avoid performance hit, only the - // size is checked here. - if updated.len() != self.len() { - return Err(FederationError::internal( - "Unexpected change in the number of fragments", - )); - } - Ok(updated) - } -} - +use crate::schema::position::INTROSPECTION_TYPENAME_FIELD_NAME; //============================================================================= -// Selection/SelectionSet intersection/minus operations +// Selection/SelectionSet minus operation impl Selection { // PORT_NOTE: The definition of `minus` and `intersection` functions when either `self` or @@ -234,26 +85,6 @@ impl Selection { } Ok(None) } - - /// Computes the set-intersection of self and other - /// - If there are respective sub-selections, then we compute their intersections and add them - /// (if not empty). - /// - Otherwise, the intersection is same as `self`. - fn intersection(&self, other: &Selection) -> Result, FederationError> { - if let (Some(self_sub_selection), Some(other_sub_selection)) = - (self.selection_set(), other.selection_set()) - { - let common = self_sub_selection.intersection(other_sub_selection)?; - if common.is_empty() { - return Ok(None); - } else { - return self - .with_updated_selections(self_sub_selection.type_position.clone(), common) - .map(Some); - } - } - Ok(Some(self.clone())) - } } impl SelectionSet { @@ -279,3382 +110,1236 @@ impl SelectionSet { iter, )) } +} - /// Computes the set-intersection of self and other - fn intersection(&self, other: &SelectionSet) -> Result { - if self.is_empty() { - return Ok(self.clone()); - } - if other.is_empty() { - return Ok(other.clone()); +impl From for SelectionMapperReturn { + fn from(value: SelectionOrSet) -> Self { + match value { + SelectionOrSet::Selection(selection) => selection.into(), + SelectionOrSet::SelectionSet(selections) => { + // The items in a selection set needs to be cloned here, since it's sub-selections + // are contained in an `Arc`. + Vec::from_iter(selections.selections.values().cloned()).into() + } } - - let iter = self - .selections - .values() - .map(|v| { - if let Some(other_v) = other.selections.get(v.key()) { - v.intersection(other_v) - } else { - Ok(None) - } - }) - .collect::, _>>()? // early break in case of Err - .into_iter() - .flatten(); - Ok(SelectionSet::from_raw_selections( - self.schema.clone(), - self.type_position.clone(), - iter, - )) } } -//============================================================================= -// Collect applicable fragments at given type. - -impl Fragment { - /// Whether this fragment may apply _directly_ at the provided type, meaning that the fragment - /// sub-selection (_without_ the fragment condition, hence the "directly") can be normalized at - /// `ty` without overly "widening" the runtime types. - /// - /// * `ty` - the type at which we're looking at applying the fragment - // - // The runtime types of the fragment condition must be at least as general as those of the - // provided `ty`. Otherwise, putting it at `ty` without its condition would "generalize" - // more than the fragment meant to (and so we'd "widen" the runtime types more than what the - // query meant to. - fn can_apply_directly_at_type( - &self, - ty: &CompositeTypeDefinitionPosition, - ) -> Result { - // Short-circuit #1: the same type => trivially true. - if self.type_condition_position == *ty { - return Ok(true); - } - - // Short-circuit #2: The type condition is not an abstract type (too restrictive). - // - It will never cover all of the runtime types of `ty` unless it's the same type, which is - // already checked by short-circuit #1. - if !self.type_condition_position.is_abstract_type() { - return Ok(false); - } - - // Short-circuit #3: The type condition is not an object (due to short-circuit #2) nor a - // union type, but the `ty` may be too general. - // - In other words, the type condition must be an interface but `ty` is a (different) - // interface or a union. - // PORT_NOTE: In JS, this check was later on the return statement (negated). But, this - // should be checked before `possible_runtime_types` check, since this is - // cheaper to execute. - // PORT_NOTE: This condition may be too restrictive (potentially a bug leading to - // suboptimal compression). If ty is a union whose members all implements the - // type condition (interface). Then, this function should've returned true. - // Thus, `!ty.is_union_type()` might be needed. - if !self.type_condition_position.is_union_type() && !ty.is_object_type() { - return Ok(false); - } - - // Check if the type condition is a superset of the provided type. - // - The fragment condition must be at least as general as the provided type. - let condition_types = self - .schema - .possible_runtime_types(self.type_condition_position.clone())?; - let ty_types = self.schema.possible_runtime_types(ty.clone())?; - Ok(condition_types.is_superset(&ty_types)) +impl Operation { + /// Optimize the parsed size of the operation by generating fragments from selection sets that + /// occur multiple times in the operation. + pub(crate) fn generate_fragments( + self, + ) -> Result, FederationError> { + let mut generator = FragmentGenerator::new(&self.selection_set); + let minified_selection = generator.minify(&self.selection_set)?; + let fragments = generator.into_inner(); + + let operation_type: executable::OperationType = self.root_kind.into(); + let operation = executable::Operation { + operation_type, + name: self.name.clone(), + variables: self.variables.deref().clone(), + directives: self.directives.iter().cloned().collect(), + selection_set: minified_selection, + }; + let mut document = executable::ExecutableDocument::new(); + document.operations.insert(operation); + document.fragments = fragments; + coerce_executable_values(self.schema.schema(), &mut document); + Ok(document.validate(self.schema.schema())?) } } -impl NamedFragments { - /// Returns fragments that can be applied directly at the given type. - fn get_all_may_apply_directly_at_type<'a>( - &'a self, - ty: &'a CompositeTypeDefinitionPosition, - ) -> impl Iterator, FederationError>> + 'a { - self.iter().filter_map(|fragment| { - fragment - .can_apply_directly_at_type(ty) - .map(|can_apply| can_apply.then_some(fragment)) - .transpose() - }) - } +#[derive(Clone, PartialEq, Eq, Hash)] +struct SelectionCountKey<'a> { + type_position: &'a CompositeTypeDefinitionPosition, + selection_set: &'a SelectionSet, } -//============================================================================= -// Field validation - -// PORT_NOTE: Not having a validator and having a FieldsConflictValidator with empty -// `by_response_name` map has no difference in behavior. So, we could drop the `Option` from -// `Option`. However, `None` validator makes it clearer that validation is -// unnecessary. -struct FieldsConflictValidator { - by_response_name: IndexMap>>>, +struct SelectionCountValue { + selection_id: SelectionId, + count: usize, } -impl FieldsConflictValidator { - /// Build a field merging validator for a selection set. - /// - /// # Preconditions - /// The selection set must not contain named fragment spreads. - fn from_selection_set(selection_set: &SelectionSet) -> Self { - Self::for_level(&[selection_set]) +impl SelectionCountValue { + fn new() -> Self { + SelectionCountValue { + selection_id: SelectionId::new(), + count: 0, + } } +} - fn for_level<'a>(level: &[&'a SelectionSet]) -> Self { - // Group `level`'s fields by the response-name/field - let mut at_level: IndexMap>> = - IndexMap::default(); - for selection_set in level { - for field_selection in selection_set.field_selections() { - let response_name = field_selection.field.response_name(); - let at_response_name = at_level.entry(response_name.clone()).or_default(); - let entry = at_response_name - .entry(field_selection.field.clone()) - .or_default(); - if let Some(ref field_selection_set) = field_selection.selection_set { - entry.push(field_selection_set); - } - } - } +#[derive(Default)] +struct FragmentGenerator<'a> { + selection_counts: HashMap, SelectionCountValue>, + minimized_fragments: IndexMap, +} - // Collect validators per response-name/field - let mut by_response_name = IndexMap::default(); - for (response_name, fields) in at_level { - let mut at_response_name: IndexMap>> = - IndexMap::default(); - for (field, selection_sets) in fields { - if selection_sets.is_empty() { - at_response_name.insert(field, None); - } else { - let validator = Arc::new(Self::for_level(&selection_sets)); - at_response_name.insert(field, Some(validator)); - } - } - by_response_name.insert(response_name, at_response_name); +/// Returns a consistent GraphQL name for the given index. +fn fragment_name(mut index: usize) -> Name { + /// https://spec.graphql.org/draft/#NameContinue + const NAME_CHARS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; + /// https://spec.graphql.org/draft/#NameStart + const NAME_START_CHARS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"; + + if index < NAME_START_CHARS.len() { + Name::new_static_unchecked(&NAME_START_CHARS[index..index + 1]) + } else { + let mut s = String::new(); + + let i = index % NAME_START_CHARS.len(); + s.push(NAME_START_CHARS.as_bytes()[i].into()); + index /= NAME_START_CHARS.len(); + + while index > 0 { + let i = index % NAME_CHARS.len(); + s.push(NAME_CHARS.as_bytes()[i].into()); + index /= NAME_CHARS.len(); } - Self { by_response_name } + + Name::new_unchecked(&s) } +} - fn for_field<'v>(&'v self, field: &Field) -> impl Iterator> + 'v { - self.by_response_name - .get(field.response_name()) - .into_iter() - .flat_map(|by_response_name| by_response_name.values()) - .flatten() - .cloned() +impl<'a> FragmentGenerator<'a> { + fn next_name(&self) -> Name { + fragment_name(self.minimized_fragments.len()) } - fn has_same_response_shape( - &self, - other: &FieldsConflictValidator, - ) -> Result { - for (response_name, self_fields) in self.by_response_name.iter() { - let Some(other_fields) = other.by_response_name.get(response_name) else { - continue; - }; + fn new(selection_set: &'a SelectionSet) -> Self { + let mut generator = FragmentGenerator::default(); + generator.collect_selection_usages(selection_set); + generator + } - for (self_field, self_validator) in self_fields { - for (other_field, other_validator) in other_fields { - if !self_field.types_can_be_merged(other_field)? { - return Ok(false); - } + fn increment_selection_count(&mut self, selection_set: &'a SelectionSet) { + let selection_key = SelectionCountKey { + type_position: &selection_set.type_position, + selection_set, + }; + let entry = self + .selection_counts + .entry(selection_key) + .or_insert(SelectionCountValue::new()); + entry.count += 1; + } - if let Some(self_validator) = self_validator { - if let Some(other_validator) = other_validator { - if !self_validator.has_same_response_shape(other_validator)? { - return Ok(false); - } - } + /// Recursively iterate over all selections to capture counts of how many times given selection + /// occurs within the operation. + fn collect_selection_usages(&mut self, selection_set: &'a SelectionSet) { + for selection in selection_set.selections.values() { + match selection { + Selection::Field(field) => { + if let Some(field_selection_set) = &field.selection_set { + self.increment_selection_count(field_selection_set); + self.collect_selection_usages(field_selection_set); } } + Selection::InlineFragment(frag) => { + self.increment_selection_count(&frag.selection_set); + self.collect_selection_usages(&frag.selection_set); + } } } - Ok(true) } - fn do_merge_with(&self, other: &FieldsConflictValidator) -> Result { - for (response_name, self_fields) in self.by_response_name.iter() { - let Some(other_fields) = other.by_response_name.get(response_name) else { - continue; - }; - - // We're basically checking - // [FieldsInSetCanMerge](https://spec.graphql.org/draft/#FieldsInSetCanMerge()), but - // from 2 set of fields (`self_fields` and `other_fields`) of the same response that we - // know individually merge already. - for (self_field, self_validator) in self_fields { - for (other_field, other_validator) in other_fields { - if !self_field.types_can_be_merged(other_field)? { - return Ok(false); - } - - let p1 = self_field.parent_type_position(); - let p2 = other_field.parent_type_position(); - if p1 == p2 || !p1.is_object_type() || !p2.is_object_type() { - // Additional checks of `FieldsInSetCanMerge` when same parent type or one - // isn't object - if self_field.name() != other_field.name() - || self_field.arguments != other_field.arguments - { - return Ok(false); - } - if let (Some(self_validator), Some(other_validator)) = - (self_validator, other_validator) - { - if !self_validator.do_merge_with(other_validator)? { - return Ok(false); - } - } - } else { - // Otherwise, the sub-selection must pass - // [SameResponseShape](https://spec.graphql.org/draft/#SameResponseShape()). - if let (Some(self_validator), Some(other_validator)) = - (self_validator, other_validator) - { - if !self_validator.has_same_response_shape(other_validator)? { - return Ok(false); - } - } + /// Recursively iterates over all selections to check if their selection sets are used multiple + /// times within the operation. Every selection set that is used more than once will be extracted + /// as a named fragment. + fn minify( + &mut self, + selection_set: &SelectionSet, + ) -> Result { + let mut new_selection_set = + executable::SelectionSet::new(selection_set.type_position.type_name().clone()); + let mut new_selections = vec![]; + for selection in selection_set.selections.values() { + match selection { + Selection::Field(field) => { + let minified_field_selection = self.minify_field_selection(field)?; + if let executable::Selection::Field(field) = &minified_field_selection + && field.name == *INTROSPECTION_TYPENAME_FIELD_NAME + && field.directives.is_empty() + && field.alias.is_none() + { + // Move the plain __typename to the start of the selection set. + // This looks nicer, and matches existing tests. + // Note: The plain-ness is also defined in `Field::is_plain_typename_field`. + // PORT_NOTE: JS does this in `selectionsInPrintOrder` + new_selections.insert(0, minified_field_selection); + continue; } + new_selections.push(minified_field_selection); + } + Selection::InlineFragment(inline_fragment) => { + let minified_selection = + self.minify_inline_fragment_selection(&new_selection_set, inline_fragment)?; + new_selections.push(minified_selection); } } } - Ok(true) + new_selection_set.extend(new_selections); + Ok(new_selection_set) } - fn do_merge_with_all<'a>( - &self, - mut iter: impl Iterator, - ) -> Result { - iter.try_fold(true, |acc, v| Ok(acc && v.do_merge_with(self)?)) - } -} - -struct FieldsConflictMultiBranchValidator { - validators: Vec>, - used_spread_trimmed_part_at_level: Vec>, -} - -impl FieldsConflictMultiBranchValidator { - fn new(validators: Vec>) -> Self { - Self { - validators, - used_spread_trimmed_part_at_level: Vec::new(), - } - } - - fn from_initial_validator(validator: FieldsConflictValidator) -> Self { - Self { - validators: vec![Arc::new(validator)], - used_spread_trimmed_part_at_level: Vec::new(), + fn minify_field_selection( + &mut self, + field_selection: &Arc, + ) -> Result { + let field = &field_selection.field; + let definition = field + .field_position + .get(field.schema.schema())? + .node + .to_owned(); + let mut minified_field = executable::Field::new(field.name().to_owned(), definition) + .with_opt_alias(field.alias.to_owned()) + .with_arguments(field.arguments.deref().to_owned()) + .with_directives(field.directives.iter().cloned()); + + if let Some(field_selection_set) = &field_selection.selection_set { + let selection_key = SelectionCountKey { + type_position: &field_selection_set.type_position, + selection_set: field_selection_set, + }; + let minified_selection_set = match self.selection_counts.get(&selection_key) { + Some(count_entry) if count_entry.count > 1 => { + // extract named fragment OR use one that already exists + let unique_fragment_id = count_entry.selection_id; + let fragment = + if let Some(existing) = self.minimized_fragments.get(&unique_fragment_id) { + existing + } else { + self.create_new_fragment(unique_fragment_id, field_selection_set)? + }; + + // create new field selection set with just a fragment spread + let fragment_spread = executable::FragmentSpread::new(fragment.name.clone()); + let mut new_field_selection_set = executable::SelectionSet::new( + field_selection_set.type_position.type_name().clone(), + ); + new_field_selection_set.push(executable::Selection::FragmentSpread(Node::new( + fragment_spread, + ))); + new_field_selection_set + } + _ => { + // minify current sub selection as it cannot be updated with a fragment reference + self.minify(field_selection_set)? + } + }; + minified_field = minified_field.with_selections(minified_selection_set.selections); } + Ok(executable::Selection::from(minified_field)) } - fn for_field(&self, field: &Field) -> Self { - let for_all_branches = self.validators.iter().flat_map(|v| v.for_field(field)); - Self::new(for_all_branches.collect()) - } - - // When this method is used in the context of `try_optimize_with_fragments`, we know that the - // fragment, restricted to the current parent type, matches a subset of the sub-selection. - // However, there is still one case we we cannot use it that we need to check, and this is if - // using the fragment would create a field "conflict" (in the sense of the graphQL spec - // [`FieldsInSetCanMerge`](https://spec.graphql.org/draft/#FieldsInSetCanMerge())) and thus - // create an invalid selection. To be clear, `at_type.selections` cannot create a conflict, - // since it is a subset of the target selection set and it is valid by itself. *But* there may - // be some part of the fragment that is not `at_type.selections` due to being "dead branches" - // for type `parent_type`. And while those branches _are_ "dead" as far as execution goes, the - // `FieldsInSetCanMerge` validation does not take this into account (it's 1st step says - // "including visiting fragments and inline fragments" but has no logic regarding ignoring any - // fragment that may not apply due to the intersection of runtime types between multiple - // fragment being empty). - fn check_can_reuse_fragment_and_track_it( + fn minify_inline_fragment_selection( &mut self, - fragment_restriction: &FragmentRestrictionAtType, - ) -> Result { - // No validator means that everything in the fragment selection was part of the selection - // we're optimizing away (by using the fragment), and we know the original selection was - // ok, so nothing to check. - let Some(validator) = &fragment_restriction.validator else { - return Ok(true); // Nothing to check; Trivially ok. + parent_selection_set: &executable::SelectionSet, + inline_fragment: &Arc, + ) -> Result { + let selection_key = SelectionCountKey { + type_position: &inline_fragment.selection_set.type_position, + selection_set: &inline_fragment.selection_set, }; + let minified_selection = match self.selection_counts.get(&selection_key) { + Some(count_entry) if count_entry.count > 1 => { + // extract named fragment OR use one that already exists + let unique_fragment_id = count_entry.selection_id; + let fragment = if let Some(existing) = + self.minimized_fragments.get(&unique_fragment_id) + { + existing + } else { + self.create_new_fragment(unique_fragment_id, &inline_fragment.selection_set)? + }; - if !validator.do_merge_with_all(self.validators.iter().map(Arc::as_ref))? { - return Ok(false); - } - - // We need to make sure the trimmed parts of `fragment` merges with the rest of the - // selection, but also that it merge with any of the trimmed parts of any fragment we have - // added already. - // Note: this last condition means that if 2 fragment conflict on their "trimmed" parts, - // then the choice of which is used can be based on the fragment ordering and selection - // order, which may not be optimal. This feels niche enough that we keep it simple for now, - // but we can revisit this decision if we run into real cases that justify it (but making - // it optimal would be a involved in general, as in theory you could have complex - // dependencies of fragments that conflict, even cycles, and you need to take the size of - // fragments into account to know what's best; and even then, this could even depend on - // overall usage, as it can be better to reuse a fragment that is used in other places, - // than to use one for which it's the only usage. Adding to all that the fact that conflict - // can happen in sibling branches). - if !validator.do_merge_with_all( - self.used_spread_trimmed_part_at_level - .iter() - .map(Arc::as_ref), - )? { - return Ok(false); - } + let directives = &inline_fragment.inline_fragment.directives; + let skip_include_only = directives + .iter() + .all(|d| matches!(d.name.as_str(), "skip" | "include")); - // We're good, but track the fragment. - self.used_spread_trimmed_part_at_level - .push(validator.clone()); - Ok(true) + if skip_include_only { + // convert inline fragment selection to a fragment spread + let spread = executable::FragmentSpread::new(fragment.name.clone()) + .with_directives(directives.iter().cloned()); + executable::Selection::from(spread) + } else { + // cannot lift out inline selection directly as it has directives + // extract named fragment from inline fragment selections + let fragment_spread = executable::FragmentSpread::new(fragment.name.clone()); + let type_condition = inline_fragment + .inline_fragment + .type_condition_position + .clone() + .map(|type_condition| type_condition.type_name().clone()); + let minified_inline_fragment = parent_selection_set + .new_inline_fragment(type_condition) + .with_selection(fragment_spread) + .with_directives(directives.iter().cloned()); + executable::Selection::from(minified_inline_fragment) + } + } + _ => { + // inline fragment is only used once so we should keep it + // still need to minify its sub selections + let new_inline_selection_set = self.minify(&inline_fragment.selection_set)?; + let type_condition = inline_fragment + .inline_fragment + .type_condition_position + .clone() + .map(|type_condition| type_condition.type_name().clone()); + let minified_inline_fragment = parent_selection_set + .new_inline_fragment(type_condition) + .with_selections(new_inline_selection_set.selections) + .with_directives(inline_fragment.inline_fragment.directives.iter().cloned()); + executable::Selection::from(minified_inline_fragment) + } + }; + Ok(minified_selection) } -} - -//============================================================================= -// Matching fragments with selection set (`try_optimize_with_fragments`) - -/// Return type for `expanded_selection_set_at_type` method. -struct FragmentRestrictionAtType { - /// Selections that are expanded from a given fragment at a given type and then normalized. - /// - This represents the part of given type's sub-selections that are covered by the fragment. - selections: SelectionSet, - - /// A runtime validator to check the fragment selections against other fields. - /// - `None` means that there is nothing to check. - /// - See `check_can_reuse_fragment_and_track_it` for more details. - validator: Option>, -} - -#[derive(Default)] -struct FragmentRestrictionAtTypeCache { - map: IndexMap<(Name, CompositeTypeDefinitionPosition), Arc>, -} -impl FragmentRestrictionAtTypeCache { - fn expanded_selection_set_at_type( + fn create_new_fragment( &mut self, - fragment: &Fragment, - ty: &CompositeTypeDefinitionPosition, - ) -> Result, FederationError> { - // I would like to avoid the Arc here, it seems unnecessary, but with `.entry()` - // the lifetime does not really want to work out. - // (&'cache mut self) -> Result<&'cache FragmentRestrictionAtType> - match self.map.entry((fragment.name.clone(), ty.clone())) { - indexmap::map::Entry::Occupied(entry) => Ok(Arc::clone(entry.get())), - indexmap::map::Entry::Vacant(entry) => Ok(Arc::clone( - entry.insert(Arc::new(fragment.expanded_selection_set_at_type(ty)?)), - )), - } - } -} - -impl FragmentRestrictionAtType { - fn new(selections: SelectionSet, validator: Option) -> Self { - Self { - selections, - validator: validator.map(Arc::new), - } - } - - // It's possible that while the fragment technically applies at `parent_type`, it's "rebasing" on - // `parent_type` is empty, or contains only `__typename`. For instance, suppose we have - // a union `U = A | B | C`, and then a fragment: - // ```graphql - // fragment F on U { - // ... on A { - // x - // } - // ... on B { - // y - // } - // } - // ``` - // It is then possible to apply `F` when the parent type is `C`, but this ends up selecting - // nothing at all. - // - // Using `F` in those cases is, while not 100% incorrect, at least not productive, and so we - // skip it that case. This is essentially an optimization. - fn is_useless(&self) -> bool { - let mut iter = self.selections.iter(); - let Some(first) = iter.next() else { - return true; + unique_fragment_id: SelectionId, + selection_set: &SelectionSet, + ) -> Result<&Fragment, FederationError> { + // minify current selection set and extract named fragment + let minified_selection_set = self.minify(selection_set)?; + let new_fragment = Fragment { + name: self.next_name(), + selection_set: minified_selection_set, + directives: Default::default(), }; - iter.next().is_none() && first.is_typename_field() - } -} - -impl Fragment { - /// Computes the expanded selection set of this fragment along with its validator to check - /// against other fragments applied under the same selection set. - fn expanded_selection_set_at_type( - &self, - ty: &CompositeTypeDefinitionPosition, - ) -> Result { - let expanded_selection_set = self.selection_set.expand_all_fragments()?; - let selection_set = expanded_selection_set.flatten_unnecessary_fragments( - ty, - /*named_fragments*/ &Default::default(), - &self.schema, - )?; - - if !self.type_condition_position.is_object_type() { - // When the type condition of the fragment is not an object type, the - // `FieldsInSetCanMerge` rule is more restrictive and any fields can create conflicts. - // Thus, we have to use the full validator in this case. (see - // https://github.com/graphql/graphql-spec/issues/1085 for details.) - return Ok(FragmentRestrictionAtType::new( - selection_set.clone(), - Some(FieldsConflictValidator::from_selection_set( - &expanded_selection_set, - )), - )); - } - // Use a smaller validator for efficiency. - // Note that `trimmed` is the difference of 2 selections that may not have been normalized - // on the same parent type, so in practice, it is possible that `trimmed` contains some of - // the selections that `selectionSet` contains, but that they have been simplified in - // `selectionSet` in such a way that the `minus` call does not see it. However, it is not - // trivial to deal with this, and it is fine given that we use trimmed to create the - // validator because we know the non-trimmed parts cannot create field conflict issues so - // we're trying to build a smaller validator, but it's ok if trimmed is not as small as it - // theoretically can be. - let trimmed = expanded_selection_set.minus(&selection_set)?; - let validator = - (!trimmed.is_empty()).then(|| FieldsConflictValidator::from_selection_set(&trimmed)); - Ok(FragmentRestrictionAtType::new( - selection_set.clone(), - validator, - )) + self.minimized_fragments + .insert(unique_fragment_id, new_fragment); + Ok(self.minimized_fragments.get(&unique_fragment_id).unwrap()) } - /// Checks whether `self` fragment includes the other fragment (`other_fragment_name`). - // - // Note that this is slightly different from `self` "using" `other_fragment` in that this - // essentially checks if the full selection set of `other_fragment` is contained by `self`, so - // this only look at "top-level" usages. - // - // Note that this is guaranteed to return `false` if passed self's name. - // Note: This is a heuristic looking for the other named fragment used directly in the - // selection set. It may not return `true` even though the other fragment's selections - // are actually covered by self's selection set. - // PORT_NOTE: The JS version memoizes the result of this function. But, the current Rust port - // does not. - fn includes(&self, other_fragment_name: &Name) -> bool { - if self.name == *other_fragment_name { - return false; + /// Consumes the generator and returns the fragments it generated. + fn into_inner(self) -> FragmentMap { + let mut fragments = FragmentMap::default(); + for (_, fragment) in &self.minimized_fragments { + fragments.insert(fragment.name.clone(), Node::new(fragment.clone())); } - - self.selection_set.selections.values().any(|selection| { - matches!( - selection, - Selection::FragmentSpread(fragment) if fragment.spread.fragment_name == *other_fragment_name - ) - }) + fragments } } -enum FullMatchingFragmentCondition<'a> { - ForFieldSelection, - ForInlineFragmentSelection { - // the type condition and directives on an inline fragment selection. - type_condition_position: &'a CompositeTypeDefinitionPosition, - directives: &'a DirectiveList, - }, -} +//============================================================================= +// Tests -impl<'a> FullMatchingFragmentCondition<'a> { - /// Determines whether the given fragment is allowed to match the whole selection set by itself - /// (without another selection set wrapping it). - fn check(&self, fragment: &Node) -> bool { - match self { - // We can never apply a fragments that has directives on it at the field level. - Self::ForFieldSelection => fragment.directives.is_empty(), - - // To be able to use a matching inline fragment, it needs to have either no directives, - // or if it has some, then: - // 1. All it's directives should also be on the current element. - // 2. The type condition of this element should be the fragment's condition. because - // If those 2 conditions are true, we can replace the whole current inline fragment - // with the match spread and directives will still match. - Self::ForInlineFragmentSelection { - type_condition_position, - directives, - } => { - if fragment.directives.is_empty() { - return true; - } +#[cfg(test)] +mod tests { + use super::*; + use crate::operation::tests::*; - // PORT_NOTE: The JS version handles `@defer` directive differently. However, Rust - // version can't have `@defer` at this point (see comments on `enum SelectionKey` - // definition) - fragment.type_condition_position == **type_condition_position - && fragment - .directives - .iter() - .all(|d1| directives.iter().any(|d2| d1 == d2)) - } - } + #[test] + fn generated_fragment_names() { + assert_eq!(fragment_name(0), "a"); + assert_eq!(fragment_name(100), "Vb"); + assert_eq!(fragment_name(usize::MAX), "oS5Uz8g3Iqw"); } -} -/// The return type for `SelectionSet::try_optimize_with_fragments`. -#[derive(derive_more::From)] -enum SelectionSetOrFragment { - SelectionSet(SelectionSet), - Fragment(Node), -} - -impl SelectionSet { - /// Reduce the list of applicable fragments by eliminating fragments that directly include - /// another fragment. - // - // We have found the list of fragments that applies to some subset of sub-selection. In - // general, we want to now produce the selection set with spread for those fragments plus - // any selection that is not covered by any of the fragments. For instance, suppose that - // `subselection` is `{ a b c d e }` and we have found that `fragment F1 on X { a b c }` - // and `fragment F2 on X { c d }` applies, then we will generate `{ ...F1 ...F2 e }`. - // - // In that example, `c` is covered by both fragments. And this is fine in this example as - // it is worth using both fragments in general. A special case of this however is if a - // fragment is entirely included into another. That is, consider that we now have `fragment - // F1 on X { a ...F2 }` and `fragment F2 on X { b c }`. In that case, the code above would - // still match both `F1 and `F2`, but as `F1` includes `F2` already, we really want to only - // use `F1`. So in practice, we filter away any fragment spread that is known to be - // included in another one that applies. - // - // TODO: note that the logic used for this is theoretically a bit sub-optimal. That is, we - // only check if one of the fragment happens to directly include a spread for another - // fragment at top-level as in the example above. We do this because it is cheap to check - // and is likely the most common case of this kind of inclusion. But in theory, we would - // have `fragment F1 on X { a b c }` and `fragment F2 on X { b c }`, in which case `F2` is - // still included in `F1`, but we'd have to work harder to figure this out and it's unclear - // it's a good tradeoff. And while you could argue that it's on the user to define its - // fragments a bit more optimally, it's actually a tad more complex because we're looking - // at fragments in a particular context/parent type. Consider an interface `I` and: - // ```graphql - // fragment F3 on I { - // ... on X { - // a - // } - // ... on Y { - // b - // c - // } - // } - // - // fragment F4 on I { - // ... on Y { - // c - // } - // ... on Z { - // d - // } - // } - // ``` - // In that case, neither fragment include the other per-se. But what if we have - // sub-selection `{ b c }` but where parent type is `Y`. In that case, both `F3` and `F4` - // applies, and in that particular context, `F3` is fully included in `F4`. Long story - // short, we'll currently return `{ ...F3 ...F4 }` in that case, but it would be - // technically better to return only `F4`. However, this feels niche, and it might be - // costly to verify such inclusions, so not doing it for now. - fn reduce_applicable_fragments( - applicable_fragments: &mut Vec<(Node, Arc)>, - ) { - // Note: It's not possible for two fragments to include each other. So, we don't need to - // worry about inclusion cycles. - let included_fragments: IndexSet = applicable_fragments - .iter() - .filter(|(fragment, _)| { - applicable_fragments - .iter() - .any(|(other_fragment, _)| other_fragment.includes(&fragment.name)) - }) - .map(|(fragment, _)| fragment.name.clone()) - .collect(); + /// + /// empty branches removal + /// + mod test_empty_branch_removal { + use apollo_compiler::name; - applicable_fragments.retain(|(fragment, _)| !included_fragments.contains(&fragment.name)); - } + use super::*; + use crate::operation::SelectionKey; - /// Try to reuse existing fragments to optimize this selection set. - /// Returns either - /// - a new selection set partially optimized by re-using given `fragments`, or - /// - a single fragment that covers the full selection set. - // PORT_NOTE: Moved from `Selection` class in JS code to SelectionSet struct in Rust. - // PORT_NOTE: `parent_type` argument seems always to be the same as `self.type_position`. - // PORT_NOTE: In JS, this was called `tryOptimizeWithFragments`. - fn try_apply_fragments( - &self, - parent_type: &CompositeTypeDefinitionPosition, - context: &ReuseContext<'_>, - validator: &mut FieldsConflictMultiBranchValidator, - fragments_at_type: &mut FragmentRestrictionAtTypeCache, - full_match_condition: FullMatchingFragmentCondition, - ) -> Result { - // We limit to fragments whose selection could be applied "directly" at `parent_type`, - // meaning without taking the fragment condition into account. The idea being that if the - // fragment condition would be needed inside `parent_type`, then that condition will not - // have been "normalized away" and so we want for this very call to be called on the - // fragment whose type _is_ the fragment condition (at which point, this - // `can_apply_directly_at_type` method will apply. Also note that this is because we have - // this restriction that calling `expanded_selection_set_at_type` is ok. - let candidates = context - .fragments - .get_all_may_apply_directly_at_type(parent_type); - - // First, we check which of the candidates do apply inside the selection set, if any. If we - // find a candidate that applies to the whole selection set, then we stop and only return - // that one candidate. Otherwise, we cumulate in `applicable_fragments` the list of fragments - // that applies to a subset. - let mut applicable_fragments = Vec::new(); - for candidate in candidates { - let candidate = candidate?; - let at_type = - fragments_at_type.expanded_selection_set_at_type(candidate, parent_type)?; - if at_type.is_useless() { - continue; + const TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL: &str = r#" + type Query { + t: T + u: Int } - // I don't love this, but fragments may introduce new fields to the operation, including - // fields that use variables that are not declared in the operation. There are two ways - // to work around this: adjusting the fragments so they only list the fields that we - // actually need, or excluding fragments that introduce variable references from reuse. - // The former would be ideal, as we would not execute more fields than required. It's - // also much trickier to do. The latter fixes this particular issue but leaves the - // output in a less than ideal state. - // The consideration here is: `generate_query_fragments` has significant advantages - // over fragment reuse, and so we do not want to invest a lot of time into improving - // fragment reuse. We do the simple, less-than-ideal thing. - if let Some(variable_definitions) = &context.operation_variables { - let fragment_variables = candidate.used_variables(); - if fragment_variables - .difference(variable_definitions) - .next() - .is_some() - { - continue; - } + type T { + a: Int + b: Int + c: C } - // As we check inclusion, we ignore the case where the fragment queries __typename - // but the `self` does not. The rational is that querying `__typename` - // unnecessarily is mostly harmless (it always works and it's super cheap) so we - // don't want to not use a fragment just to save querying a `__typename` in a few - // cases. But the underlying context of why this matters is that the query planner - // always requests __typename for abstract type, and will do so in fragments too, - // but we can have a field that _does_ return an abstract type within a fragment, - // but that _does not_ end up returning an abstract type when applied in a "more - // specific" context (think a fragment on an interface I1 where a inside field - // returns another interface I2, but applied in the context of a implementation - // type of I1 where that particular field returns an implementation of I2 rather - // than I2 directly; we would have added __typename to the fragment (because it's - // all interfaces), but the selection itself, which only deals with object type, - // may not have __typename requested; using the fragment might still be a good - // idea, and querying __typename needlessly is a very small price to pay for that). - let res = self.containment( - &at_type.selections, - ContainmentOptions { - ignore_missing_typename: true, - }, - ); - match res { - Containment::Equal if full_match_condition.check(candidate) => { - if !validator.check_can_reuse_fragment_and_track_it(&at_type)? { - // We cannot use it at all, so no point in adding to `applicable_fragments`. - continue; - } - // Special case: Found a fragment that covers the full selection set. - return Ok(candidate.clone().into()); - } - // Note that if a fragment applies to only a subset of the sub-selections, then we - // really only can use it if that fragment is defined _without_ directives. - Containment::Equal | Containment::StrictlyContained - if candidate.directives.is_empty() => - { - applicable_fragments.push((candidate.clone(), at_type)); - } - // Not eligible; Skip it. - _ => (), + type C { + x: String + y: String } - } + "#; - if applicable_fragments.is_empty() { - return Ok(self.clone().into()); // Not optimizable + fn operation_without_empty_branches(operation: &Operation) -> Option { + operation + .selection_set + .without_empty_branches() + .map(|s| s.to_string()) } - // Narrow down the list of applicable fragments by removing those that are included in - // another. - Self::reduce_applicable_fragments(&mut applicable_fragments); - - // Build a new optimized selection set. - let mut not_covered_so_far = self.clone(); - let mut optimized = SelectionSet::empty(self.schema.clone(), self.type_position.clone()); - for (fragment, at_type) in applicable_fragments { - if !validator.check_can_reuse_fragment_and_track_it(&at_type)? { - continue; - } - let not_covered = self.minus(&at_type.selections)?; - not_covered_so_far = not_covered_so_far.intersection(¬_covered)?; - - // PORT_NOTE: The JS version uses `parent_type` as the "sourceType", which may be - // different from `fragment.type_condition_position`. But, Rust version does - // not have "sourceType" field for `FragmentSpreadSelection`. - let fragment_selection = FragmentSpreadSelection::from_fragment( - &fragment, - /*directives*/ &Default::default(), - ); - optimized.add_local_selection(&fragment_selection.into())?; + fn without_empty_branches(query: &str) -> Option { + let operation = + parse_operation(&parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), query); + operation_without_empty_branches(&operation) } - optimized.add_local_selection_set(¬_covered_so_far)?; - Ok(optimized.into()) - } -} - -//============================================================================= -// Retain fragments in selection sets while expanding the rest + // To test `without_empty_branches` method, we need to test operations with empty selection + // sets. However, such operations can't be constructed from strings, since the parser will + // reject them. Thus, we first create a valid query with non-empty selection sets and then + // clear some of them. + // PORT_NOTE: The JS tests use `astSSet` function to construct queries with + // empty selection sets using graphql-js's SelectionSetNode API. In Rust version, + // instead of re-creating such API, we will selectively clear selection sets. -impl Selection { - /// Expand fragments that are not in the `fragments_to_keep`. - // PORT_NOTE: The JS version's name was `expandFragments`, which was confusing with - // `expand_all_fragments`. So, it was renamed to `retain_fragments`. - fn retain_fragments( - &self, - parent_type: &CompositeTypeDefinitionPosition, - fragments_to_keep: &NamedFragments, - ) -> Result { - match self { - Selection::FragmentSpread(fragment) => { - if fragments_to_keep.contains(&fragment.spread.fragment_name) { - // Keep this spread - Ok(self.clone().into()) - } else { - // Expand the fragment - let expanded_sub_selections = - fragment.selection_set.retain_fragments(fragments_to_keep)?; - if *parent_type == fragment.spread.type_condition_position - && fragment.spread.directives.is_empty() - { - // The fragment is of the same type as the parent, so we can just use - // the expanded sub-selections directly. - Ok(expanded_sub_selections.into()) - } else { - // Create an inline fragment since type condition is necessary. - let inline = InlineFragmentSelection::from_selection_set( - parent_type.clone(), - expanded_sub_selections, - fragment.spread.directives.clone(), - ); - Ok(Selection::from(inline).into()) - } + fn clear_selection_set_at_path( + ss: &mut SelectionSet, + path: &[Name], + ) -> Result<(), FederationError> { + match path.split_first() { + None => { + // Base case + ss.selections = Default::default(); + Ok(()) } - } - - // Otherwise, expand the sub-selections. - _ => Ok(self - .map_selection_set(|selection_set| { - Ok(Some(selection_set.retain_fragments(fragments_to_keep)?)) - })? - .into()), - } - } -} -// Note: `retain_fragments` methods may return a selection or a selection set. -impl From for SelectionMapperReturn { - fn from(value: SelectionOrSet) -> Self { - match value { - SelectionOrSet::Selection(selection) => selection.into(), - SelectionOrSet::SelectionSet(selections) => { - // The items in a selection set needs to be cloned here, since it's sub-selections - // are contained in an `Arc`. - Vec::from_iter(selections.selections.values().cloned()).into() + Some((first, rest)) => { + let result = Arc::make_mut(&mut ss.selections).get_mut(SelectionKey::Field { + response_name: first, + directives: &Default::default(), + }); + let Some(mut value) = result else { + return Err(FederationError::internal("No matching field found")); + }; + match value.get_selection_set_mut() { + None => Err(FederationError::internal( + "Sub-selection expected, but not found.", + )), + Some(sub_selection_set) => { + // Recursive case + clear_selection_set_at_path(sub_selection_set, rest)?; + Ok(()) + } + } + } } } - } -} -impl SelectionSet { - /// Expand fragments that are not in the `fragments_to_keep`. - // PORT_NOTE: The JS version's name was `expandFragments`, which was confusing with - // `expand_all_fragments`. So, it was renamed to `retain_fragments`. - fn retain_fragments( - &self, - fragments_to_keep: &NamedFragments, - ) -> Result { - self.lazy_map(fragments_to_keep, |selection| { - Ok(selection - .retain_fragments(&self.type_position, fragments_to_keep)? - .into()) - }) - } -} - -//============================================================================= -// Optimize (or reduce) the named fragments in the query -// -// Things to consider: -// - Unused fragment definitions can be dropped without an issue. -// - Dropping low-usage named fragments and expanding them may insert other fragments resulting in -// increased usage of those inserted. -// -// Example: -// ```graphql -// query { -// ...F1 -// } -// -// fragment F1 { -// a { ...F2 } -// b { ...F2 } -// } -// -// fragment F2 { -// // something -// } -// ``` -// then at this point where we've only counted usages in the query selection, `usages` will be -// `{ F1: 1, F2: 0 }`. But we do not want to expand _both_ F1 and F2. Instead, we want to expand -// F1 first, and then realize that this increases F2 usages to 2, which means we stop there and keep F2. - -impl NamedFragments { - /// Updates `self` by computing the reduced set of NamedFragments that are used in the - /// selection set and other fragments at least `min_usage_to_optimize` times. Also, computes - /// the new selection set that uses only the reduced set of fragments by expanding the other - /// ones. - /// - Returned selection set will be normalized. - fn reduce( - &mut self, - selection_set: &SelectionSet, - min_usage_to_optimize: u32, - ) -> Result { - // Call `reduce_inner` repeatedly until we reach a fix-point, since newly computed - // selection set may drop some fragment references due to normalization, which could lead - // to further reduction. - // - It is hard to avoid this chain reaction, since we need to account for the effects of - // normalization. - let mut last_size = self.len(); - let mut last_selection_set = selection_set.clone(); - while last_size > 0 { - let new_selection_set = - self.reduce_inner(&last_selection_set, min_usage_to_optimize)?; - - // Reached a fix-point => stop - if self.len() == last_size { - // Assumes that `new_selection_set` is the same as `last_selection_set` in this - // case. - break; + #[test] + fn operation_not_modified_if_no_empty_branches() { + let test_vec = vec!["{ t { a } }", "{ t { a b } }", "{ t { a c { x y } } }"]; + for query in test_vec { + assert_eq!(without_empty_branches(query).unwrap(), query); } - - // If we've expanded some fragments but kept others, then it's not 100% impossible that - // some fragment was used multiple times in some expanded fragment(s), but that - // post-expansion all of it's usages are "dead" branches that are removed by the final - // `flatten_unnecessary_fragments`. In that case though, we need to ensure we don't include the now-unused - // fragment in the final list of fragments. - // TODO: remark that the same reasoning could leave a single instance of a fragment - // usage, so if we really really want to never have less than `minUsagesToOptimize`, we - // could do some loop of `expand then flatten` unless all fragments are provably used - // enough. We don't bother, because leaving this is not a huge deal and it's not worth - // the complexity, but it could be that we can refactor all this later to avoid this - // case without additional complexity. - - // Prepare the next iteration - last_size = self.len(); - last_selection_set = new_selection_set; } - Ok(last_selection_set) - } - /// The inner loop body of `reduce` method. - fn reduce_inner( - &mut self, - selection_set: &SelectionSet, - min_usage_to_optimize: u32, - ) -> Result { - let mut usages = selection_set.used_fragments(); - - // Short-circuiting: Nothing was used => Drop everything (selection_set is unchanged). - if usages.is_empty() { - *self = Default::default(); - return Ok(selection_set.clone()); - } + #[test] + fn removes_simple_empty_branches() { + { + // query to test: "{ t { a c { } } }" + let expected = "{ t { a } }"; - // Determine which one to retain. - // - Calculate the usage count of each fragment in both query and other fragment definitions. - // - If a fragment is to keep, fragments used in it are counted. - // - If a fragment is to drop, fragments used in it are counted and multiplied by its usage. - // - Decide in reverse dependency order, so that at each step, the fragment being visited - // has following properties: - // - It is either indirectly used by a previous fragment; Or, not used directly by any - // one visited & retained before. - // - Its usage count should be correctly calculated as if dropped fragments were expanded. - // - We take advantage of the fact that `NamedFragments` is already sorted in dependency - // order. - // PORT_NOTE: The `computeFragmentsToKeep` function is implemented here. - let original_size = self.len(); - for fragment in self.iter_rev() { - let usage_count = usages.get(&fragment.name).copied().unwrap_or_default(); - if usage_count >= min_usage_to_optimize { - // Count indirect usages within the fragment definition. - fragment.collect_used_fragment_names(&mut usages); - } else { - // Compute the new usage count after expanding the `fragment`. - Self::update_usages(&mut usages, fragment, usage_count); + // Since the parser won't accept empty selection set, we first create + // a valid query and then clear the selection set. + let valid_query = r#"{ t { a c { x } } }"#; + let mut operation = parse_operation( + &parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), + valid_query, + ); + clear_selection_set_at_path( + &mut operation.selection_set, + &[name!("t"), name!("c")], + ) + .unwrap(); + // Note: Unfortunately, this assertion won't work since SelectionSet.to_string() can't + // display empty selection set. + // assert_eq!(operation.selection_set.to_string(), "{ t { a c { } } }"); + assert_eq!( + operation_without_empty_branches(&operation).unwrap(), + expected + ); } - } - - self.retain(|name, _fragment| { - let usage_count = usages.get(name).copied().unwrap_or_default(); - usage_count >= min_usage_to_optimize - }); - - // Short-circuiting: Nothing was dropped (fully used) => Nothing to change. - if self.len() == original_size { - return Ok(selection_set.clone()); - } - // Update the fragment definitions in `self` after reduction. - // Note: This is an unfortunate clone, since `self` can't be passed to `retain_fragments`, - // while being mutated. - let fragments_to_keep = self.clone(); - for (_, fragment) in self.iter_mut() { - Node::make_mut(fragment).selection_set = fragment - .selection_set - .retain_fragments(&fragments_to_keep)? - .flatten_unnecessary_fragments( - &fragment.selection_set.type_position, - &fragments_to_keep, - &fragment.schema, - )?; - } + { + // query to test: "{ t { c { } a } }" + let expected = "{ t { a } }"; - // Compute the new selection set based on the new reduced set of fragments. - // Note that optimizing all fragments to potentially re-expand some is not entirely - // optimal, but it's unclear how to do otherwise, and it probably don't matter too much in - // practice (we only call this optimization on the final computed query plan, so not a very - // hot path; plus in most cases we won't even reach that point either because there is no - // fragment, or none will have been optimized away so we'll exit above). - let reduced_selection_set = selection_set.retain_fragments(self)?; - - // Expanding fragments could create some "inefficiencies" that we wouldn't have if we - // hadn't re-optimized the fragments to de-optimize it later, so we do a final "flatten" - // pass to remove those. - reduced_selection_set.flatten_unnecessary_fragments( - &reduced_selection_set.type_position, - self, - &selection_set.schema, - ) - } + let valid_query = r#"{ t { c { x } a } }"#; + let mut operation = parse_operation( + &parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), + valid_query, + ); + clear_selection_set_at_path( + &mut operation.selection_set, + &[name!("t"), name!("c")], + ) + .unwrap(); + assert_eq!( + operation_without_empty_branches(&operation).unwrap(), + expected + ); + } - fn update_usages( - usages: &mut IndexMap, - fragment: &Node, - usage_count: u32, - ) { - let mut inner_usages = IndexMap::default(); - fragment.collect_used_fragment_names(&mut inner_usages); + { + // query to test: "{ t { } }" + let expected = None; - for (name, inner_count) in inner_usages { - *usages.entry(name).or_insert(0) += inner_count * usage_count; + let valid_query = r#"{ t { a } }"#; + let mut operation = parse_operation( + &parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), + valid_query, + ); + clear_selection_set_at_path(&mut operation.selection_set, &[name!("t")]).unwrap(); + assert_eq!(operation_without_empty_branches(&operation), expected); + } } - } -} -//============================================================================= -// `reuse_fragments` methods (putting everything together) + #[test] + fn removes_cascading_empty_branches() { + { + // query to test: "{ t { c { } } }" + let expected = None; -impl Selection { - fn reuse_fragments_inner( - &self, - context: &ReuseContext<'_>, - validator: &mut FieldsConflictMultiBranchValidator, - fragments_at_type: &mut FragmentRestrictionAtTypeCache, - ) -> Result { - match self { - Selection::Field(field) => Ok(field - .reuse_fragments_inner(context, validator, fragments_at_type)? - .into()), - Selection::FragmentSpread(_) => Ok(self.clone()), // Do nothing - Selection::InlineFragment(inline_fragment) => Ok(inline_fragment - .reuse_fragments_inner(context, validator, fragments_at_type)? - .into()), - } - } -} + let valid_query = r#"{ t { c { x } } }"#; + let mut operation = parse_operation( + &parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), + valid_query, + ); + clear_selection_set_at_path( + &mut operation.selection_set, + &[name!("t"), name!("c")], + ) + .unwrap(); + assert_eq!(operation_without_empty_branches(&operation), expected); + } -impl FieldSelection { - fn reuse_fragments_inner( - &self, - context: &ReuseContext<'_>, - validator: &mut FieldsConflictMultiBranchValidator, - fragments_at_type: &mut FragmentRestrictionAtTypeCache, - ) -> Result { - let Some(base_composite_type): Option = - self.field.output_base_type()?.try_into().ok() - else { - return Ok(self.clone()); - }; - let Some(ref selection_set) = self.selection_set else { - return Ok(self.clone()); - }; + { + // query to test: "{ u t { c { } } }" + let expected = "{ u }"; - let mut field_validator = validator.for_field(&self.field); - - // First, see if we can reuse fragments for the selection of this field. - let opt = selection_set.try_apply_fragments( - &base_composite_type, - context, - &mut field_validator, - fragments_at_type, - FullMatchingFragmentCondition::ForFieldSelection, - )?; - - let mut optimized = match opt { - SelectionSetOrFragment::Fragment(fragment) => { - let fragment_selection = FragmentSpreadSelection::from_fragment( - &fragment, - /*directives*/ &Default::default(), + let valid_query = r#"{ u t { c { x } } }"#; + let mut operation = parse_operation( + &parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), + valid_query, + ); + clear_selection_set_at_path( + &mut operation.selection_set, + &[name!("t"), name!("c")], + ) + .unwrap(); + assert_eq!( + operation_without_empty_branches(&operation).unwrap(), + expected ); - SelectionSet::from_selection(base_composite_type, fragment_selection.into()) } - SelectionSetOrFragment::SelectionSet(selection_set) => selection_set, - }; - optimized = - optimized.reuse_fragments_inner(context, &mut field_validator, fragments_at_type)?; - Ok(self.with_updated_selection_set(Some(optimized))) - } -} - -/// Return type for `InlineFragmentSelection::reuse_fragments`. -#[derive(derive_more::From)] -enum FragmentSelection { - // Note: Enum variants are named to match those of `Selection`. - InlineFragment(InlineFragmentSelection), - FragmentSpread(FragmentSpreadSelection), -} -impl From for Selection { - fn from(value: FragmentSelection) -> Self { - match value { - FragmentSelection::InlineFragment(inline_fragment) => inline_fragment.into(), - FragmentSelection::FragmentSpread(fragment_spread) => fragment_spread.into(), - } - } -} + { + // query to test: "{ t { c { } } u }" + let expected = "{ u }"; -impl InlineFragmentSelection { - fn reuse_fragments_inner( - &self, - context: &ReuseContext<'_>, - validator: &mut FieldsConflictMultiBranchValidator, - fragments_at_type: &mut FragmentRestrictionAtTypeCache, - ) -> Result { - let optimized; - - let type_condition_position = &self.inline_fragment.type_condition_position; - if let Some(type_condition_position) = type_condition_position { - let opt = self.selection_set.try_apply_fragments( - type_condition_position, - context, - validator, - fragments_at_type, - FullMatchingFragmentCondition::ForInlineFragmentSelection { - type_condition_position, - directives: &self.inline_fragment.directives, - }, - )?; - - match opt { - SelectionSetOrFragment::Fragment(fragment) => { - // We're fully matching the sub-selection. If the fragment condition is also - // this element condition, then we can replace the whole element by the spread - // (not just the sub-selection). - if *type_condition_position == fragment.type_condition_position { - // Optimized as `...`, dropping the original inline spread (`self`). - - // Note that `FullMatchingFragmentCondition::ForInlineFragmentSelection` - // above guarantees that this element directives are a superset of the - // fragment directives. But there can be additional directives, and in that - // case they should be kept on the spread. - // PORT_NOTE: We are assuming directives on fragment definitions are - // carried over to their spread sites as JS version does, which - // is handled differently in Rust version (see `FragmentSpread`). - let directives: executable::DirectiveList = self - .inline_fragment - .directives - .iter() - .filter(|d1| !fragment.directives.iter().any(|d2| *d1 == d2)) - .cloned() - .collect(); - return Ok( - FragmentSpreadSelection::from_fragment(&fragment, &directives).into(), - ); - } else { - // Otherwise, we keep this element and use a sub-selection with just the spread. - // Optimized as `...on { ... }` - optimized = SelectionSet::from_selection( - type_condition_position.clone(), - FragmentSpreadSelection::from_fragment( - &fragment, - /*directives*/ &Default::default(), - ) - .into(), - ); - } - } - SelectionSetOrFragment::SelectionSet(selection_set) => { - optimized = selection_set; - } + let valid_query = r#"{ t { c { x } } u }"#; + let mut operation = parse_operation( + &parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), + valid_query, + ); + clear_selection_set_at_path( + &mut operation.selection_set, + &[name!("t"), name!("c")], + ) + .unwrap(); + assert_eq!( + operation_without_empty_branches(&operation).unwrap(), + expected + ); } - } else { - optimized = self.selection_set.clone(); } - - Ok(InlineFragmentSelection::new( - self.inline_fragment.clone(), - // Then, recurse inside the field sub-selection (note that if we matched some fragments - // above, this recursion will "ignore" those as `FragmentSpreadSelection`'s - // `reuse_fragments()` is a no-op). - optimized.reuse_fragments_inner(context, validator, fragments_at_type)?, - ) - .into()) } -} -impl SelectionSet { - fn reuse_fragments_inner( - &self, - context: &ReuseContext<'_>, - validator: &mut FieldsConflictMultiBranchValidator, - fragments_at_type: &mut FragmentRestrictionAtTypeCache, - ) -> Result { - self.lazy_map(context.fragments, |selection| { - Ok(selection - .reuse_fragments_inner(context, validator, fragments_at_type)? - .into()) - }) - } + mod fragment_generation { + use apollo_compiler::ExecutableDocument; + use apollo_compiler::validation::Valid; - fn contains_fragment_spread(&self) -> bool { - self.iter().any(|selection| { - matches!(selection, Selection::FragmentSpread(_)) - || selection - .selection_set() - .map(|subselection| subselection.contains_fragment_spread()) - .unwrap_or(false) - }) - } + use crate::correctness::compare_operations; + use crate::operation::tests::assert_equal_ops; + use crate::operation::tests::parse_and_expand; + use crate::operation::tests::parse_operation; + use crate::operation::tests::parse_schema; - /// ## Errors - /// Returns an error if the selection set contains a named fragment spread. - fn reuse_fragments(&mut self, context: &ReuseContext<'_>) -> Result<(), FederationError> { - if context.fragments.is_empty() { - return Ok(()); - } + #[test] + fn extracts_common_selections() { + let schema_doc = r#" + type Query { + t1: T + t2: T + } - if self.contains_fragment_spread() { - return Err(FederationError::internal("reuse_fragments() must only be used on selection sets that do not contain named fragment spreads")); - } + type T { + a: String + b: String + c: Int + } + "#; + let schema = parse_schema(schema_doc); + let query = parse_operation( + &schema, + r#" + query { + t1 { + a + b + c + } + t2 { + a + b + c + } + } + "#, + ); - // Calling reuse_fragments() will not match a fragment that would have expanded at - // top-level. That is, say we have the selection set `{ x y }` for a top-level `Query`, and - // we have a fragment - // ``` - // fragment F on Query { - // x - // y - // } - // ``` - // then calling `self.reuse_fragments(fragments)` would only apply check if F apply to - // `x` and then `y`. - // - // To ensure the fragment match in this case, we "wrap" the selection into a trivial - // fragment of the selection parent, so in the example above, we create selection `... on - // Query { x y }`. With that, `reuse_fragments` will correctly match on the `on Query` - // fragment; after which we can unpack the final result. - let wrapped = InlineFragmentSelection::from_selection_set( - self.type_position.clone(), // parent type - self.clone(), // selection set - Default::default(), // directives - ); - let mut validator = FieldsConflictMultiBranchValidator::from_initial_validator( - FieldsConflictValidator::from_selection_set(self), - ); - let optimized = wrapped.reuse_fragments_inner( - context, - &mut validator, - &mut FragmentRestrictionAtTypeCache::default(), - )?; - - // Now, it's possible we matched a full fragment, in which case `optimized` will be just - // the named fragment, and in that case we return a singleton selection with just that. - // Otherwise, it's our wrapping inline fragment with the sub-selections optimized, and we - // just return that subselection. - *self = match optimized { - FragmentSelection::FragmentSpread(spread) => { - SelectionSet::from_selection(self.type_position.clone(), spread.into()) + let original: Valid = + query.clone().try_into().expect("valid document"); + let minified = query + .generate_fragments() + .expect("successfully generated fragments"); + insta::assert_snapshot!(minified, @r###" + { + t1 { + ...a + } + t2 { + ...a + } } - FragmentSelection::InlineFragment(inline_fragment) => inline_fragment.selection_set, - }; - Ok(()) - } -} - -impl Operation { - // PORT_NOTE: The JS version of `reuse_fragments` takes an optional `minUsagesToOptimize` argument. - // However, it's only used in tests. So, it's removed in the Rust version. - const DEFAULT_MIN_USAGES_TO_OPTIMIZE: u32 = 2; - // `fragments` - rebased fragment definitions for the operation's subgraph - // - `self.selection_set` must be fragment-spread-free. - fn reuse_fragments_inner( - &mut self, - fragments: &NamedFragments, - min_usages_to_optimize: u32, - ) -> Result<(), FederationError> { - if fragments.is_empty() { - return Ok(()); - } + fragment a on T { + a + b + c + } + "###); - // Optimize the operation's selection set by re-using existing fragments. - let before_optimization = self.selection_set.clone(); - self.selection_set - .reuse_fragments(&ReuseContext::for_operation(fragments, &self.variables))?; - if before_optimization == self.selection_set { - return Ok(()); + assert_equal_ops!(&schema, &original, &minified); } - // Optimize the named fragment definitions by dropping low-usage ones. - let mut final_fragments = fragments.clone(); - let final_selection_set = - final_fragments.reduce(&self.selection_set, min_usages_to_optimize)?; + #[test] + fn extracts_common_order_independent_selections() { + let schema_doc = r#" + type Query { + t1: T + t2: T + } - self.selection_set = final_selection_set; - self.named_fragments = final_fragments; - Ok(()) - } + type T { + a: String + b: String + c: Int + } + "#; + let schema = parse_schema(schema_doc); + let query = parse_operation( + &schema, + r#" + query { + t1 { + a + b + c + } + t2 { + c + b + a + } + } + "#, + ); - /// Optimize the parsed size of the operation by applying fragment spreads. Fragment spreads - /// are reused from the original user-provided fragments. - /// - /// `fragments` - rebased fragment definitions for the operation's subgraph - /// - // PORT_NOTE: In JS, this function was called "optimize". - pub(crate) fn reuse_fragments( - &mut self, - fragments: &NamedFragments, - ) -> Result<(), FederationError> { - self.reuse_fragments_inner(fragments, Self::DEFAULT_MIN_USAGES_TO_OPTIMIZE) - } + let original: Valid = + query.clone().try_into().expect("valid document"); + let minified = query + .generate_fragments() + .expect("successfully generated fragments"); + insta::assert_snapshot!(minified, @r###" + { + t1 { + ...a + } + t2 { + ...a + } + } - /// Optimize the parsed size of the operation by generating fragments based on the selections - /// in the operation. - pub(crate) fn generate_fragments(&mut self) -> Result<(), FederationError> { - // Currently, this method simply pulls out every inline fragment into a named fragment. If - // multiple inline fragments are the same, they use the same named fragment. - // - // This method can generate named fragments that are only used once. It's not ideal, but it - // also doesn't seem that bad. Avoiding this is possible but more work, and keeping this - // as simple as possible is a big benefit for now. - // - // When we have more advanced correctness testing, we can add more features to fragment - // generation, like factoring out partial repeated slices of selection sets or only - // introducing named fragments for patterns that occur more than once. - let mut generator = FragmentGenerator::default(); - generator.visit_selection_set(&mut self.selection_set)?; - self.named_fragments = generator.into_inner(); - Ok(()) - } + fragment a on T { + a + b + c + } + "###); - /// Used by legacy roundtrip tests. - /// - This lowers `min_usages_to_optimize` to `1` in order to make it easier to write unit tests. - #[cfg(test)] - fn reuse_fragments_for_roundtrip_test( - &mut self, - fragments: &NamedFragments, - ) -> Result<(), FederationError> { - self.reuse_fragments_inner(fragments, /*min_usages_to_optimize*/ 1) - } + assert_equal_ops!(&schema, &original, &minified); + } - // PORT_NOTE: This mirrors the JS version's `Operation.expandAllFragments`. But this method is - // mainly for unit tests. The actual port of `expandAllFragments` is in `normalize_operation`. - #[cfg(test)] - fn expand_all_fragments_and_normalize(&self) -> Result { - let selection_set = self - .selection_set - .expand_all_fragments()? - .flatten_unnecessary_fragments( - &self.selection_set.type_position, - &self.named_fragments, - &self.schema, - )?; - Ok(Self { - named_fragments: Default::default(), - selection_set, - ..self.clone() - }) - } -} - -#[derive(Debug, Default)] -struct FragmentGenerator { - fragments: NamedFragments, - // XXX(@goto-bus-stop): This is temporary to support mismatch testing with JS! - names: IndexMap<(String, usize), usize>, -} - -impl FragmentGenerator { - // XXX(@goto-bus-stop): This is temporary to support mismatch testing with JS! - // In the future, we will just use `.next_name()`. - fn generate_name(&mut self, frag: &InlineFragmentSelection) -> Name { - use std::fmt::Write as _; - - let type_condition = frag - .inline_fragment - .type_condition_position - .as_ref() - .map_or_else( - || "undefined".to_string(), - |condition| condition.to_string(), - ); - let selections = frag.selection_set.selections.len(); - let mut name = format!("_generated_on{type_condition}{selections}"); - - let key = (type_condition, selections); - let index = self - .names - .entry(key) - .and_modify(|index| *index += 1) - .or_default(); - _ = write!(&mut name, "_{index}"); - - Name::new_unchecked(&name) - } - - /// Is a selection set worth using for a newly generated named fragment? - fn is_worth_using(selection_set: &SelectionSet) -> bool { - let mut iter = selection_set.iter(); - let Some(first) = iter.next() else { - // An empty selection is not worth using (and invalid!) - return false; - }; - let Selection::Field(field) = first else { - return true; - }; - // If there's more than one selection, or one selection with a subselection, - // it's probably worth using - iter.next().is_some() || field.selection_set.is_some() - } - - /// Modify the selection set so that eligible inline fragments are moved to named fragment spreads. - fn visit_selection_set( - &mut self, - selection_set: &mut SelectionSet, - ) -> Result<(), FederationError> { - let mut new_selection_set = SelectionSet::empty( - selection_set.schema.clone(), - selection_set.type_position.clone(), - ); - - for selection in Arc::make_mut(&mut selection_set.selections).values_mut() { - match selection { - SelectionValue::Field(mut field) => { - if let Some(selection_set) = field.get_selection_set_mut() { - self.visit_selection_set(selection_set)?; - } - new_selection_set - .add_local_selection(&Selection::Field(Arc::clone(field.get())))?; - } - SelectionValue::FragmentSpread(frag) => { - new_selection_set - .add_local_selection(&Selection::FragmentSpread(Arc::clone(frag.get())))?; - } - SelectionValue::InlineFragment(frag) - if !Self::is_worth_using(&frag.get().selection_set) => - { - new_selection_set - .add_local_selection(&Selection::InlineFragment(Arc::clone(frag.get())))?; - } - SelectionValue::InlineFragment(mut candidate) => { - self.visit_selection_set(candidate.get_selection_set_mut())?; - - // XXX(@goto-bus-stop): This is temporary to support mismatch testing with JS! - // JS federation does not consider fragments without a type condition. - if candidate - .get() - .inline_fragment - .type_condition_position - .is_none() - { - new_selection_set.add_local_selection(&Selection::InlineFragment( - Arc::clone(candidate.get()), - ))?; - continue; - } - - let directives = &candidate.get().inline_fragment.directives; - let skip_include = directives - .iter() - .map(|directive| match directive.name.as_str() { - "skip" | "include" => Ok(directive.clone()), - _ => Err(()), - }) - .collect::>(); - - // If there are any directives *other* than @skip and @include, - // we can't just transfer them to the generated fragment spread, - // so we have to keep this inline fragment. - let Ok(skip_include) = skip_include else { - new_selection_set.add_local_selection(&Selection::InlineFragment( - Arc::clone(candidate.get()), - ))?; - continue; - }; - - // XXX(@goto-bus-stop): This is temporary to support mismatch testing with JS! - // JS does not special-case @skip and @include. It never extracts a fragment if - // there's any directives on it. This code duplicates the body from the - // previous condition so it's very easy to remove when we're ready :) - if !skip_include.is_empty() { - new_selection_set.add_local_selection(&Selection::InlineFragment( - Arc::clone(candidate.get()), - ))?; - continue; - } - - let existing = self.fragments.iter().find(|existing| { - existing.type_condition_position - == candidate.get().inline_fragment.casted_type() - && existing.selection_set == candidate.get().selection_set - }); - - let existing = if let Some(existing) = existing { - existing - } else { - // XXX(@goto-bus-stop): This is temporary to support mismatch testing with JS! - // This should be reverted to `self.next_name();` when we're ready. - let name = self.generate_name(candidate.get()); - self.fragments.insert(Fragment { - schema: selection_set.schema.clone(), - name: name.clone(), - type_condition_position: candidate.get().inline_fragment.casted_type(), - directives: Default::default(), - selection_set: candidate.get().selection_set.clone(), - }); - self.fragments.get(&name).unwrap() - }; - new_selection_set.add_local_selection(&Selection::from( - FragmentSpreadSelection { - spread: FragmentSpread { - schema: selection_set.schema.clone(), - fragment_name: existing.name.clone(), - type_condition_position: existing.type_condition_position.clone(), - directives: skip_include.into(), - fragment_directives: existing.directives.clone(), - selection_id: crate::operation::SelectionId::new(), - }, - selection_set: existing.selection_set.clone(), - }, - ))?; - } - } - } - - *selection_set = new_selection_set; - - Ok(()) - } - - /// Consumes the generator and returns the fragments it generated. - fn into_inner(self) -> NamedFragments { - self.fragments - } -} - -//============================================================================= -// Tests - -#[cfg(test)] -mod tests { - use apollo_compiler::ExecutableDocument; - - use super::*; - use crate::operation::tests::*; - - macro_rules! assert_without_fragments { - ($operation: expr, @$expected: literal) => {{ - let without_fragments = $operation.expand_all_fragments_and_normalize().unwrap(); - insta::assert_snapshot!(without_fragments, @$expected); - without_fragments - }}; - } - - macro_rules! assert_optimized { - ($operation: expr, $named_fragments: expr, @$expected: literal) => {{ - let mut optimized = $operation.clone(); - optimized.reuse_fragments(&$named_fragments).unwrap(); - validate_operation(&$operation.schema, &optimized.to_string()); - insta::assert_snapshot!(optimized, @$expected) - }}; - } - - /// Returns a consistent GraphQL name for the given index. - fn fragment_name(mut index: usize) -> Name { - /// https://spec.graphql.org/draft/#NameContinue - const NAME_CHARS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; - /// https://spec.graphql.org/draft/#NameStart - const NAME_START_CHARS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"; - - if index < NAME_START_CHARS.len() { - Name::new_static_unchecked(&NAME_START_CHARS[index..index + 1]) - } else { - let mut s = String::new(); - - let i = index % NAME_START_CHARS.len(); - s.push(NAME_START_CHARS.as_bytes()[i].into()); - index /= NAME_START_CHARS.len(); - - while index > 0 { - let i = index % NAME_CHARS.len(); - s.push(NAME_CHARS.as_bytes()[i].into()); - index /= NAME_CHARS.len(); - } - - Name::new_unchecked(&s) - } - } - - #[test] - fn generated_fragment_names() { - assert_eq!(fragment_name(0), "a"); - assert_eq!(fragment_name(100), "Vb"); - assert_eq!(fragment_name(usize::MAX), "oS5Uz8g3Iqw"); - } - - #[test] - fn duplicate_fragment_spreads_after_fragment_expansion() { - // This is a regression test for FED-290, making sure `make_select` method can handle - // duplicate fragment spreads. - // During optimization, `make_selection` may merge multiple fragment spreads with the same - // key. This can happen in the case below where `F1` and `F2` are expanded and generating - // two duplicate `F_shared` spreads in the definition of `fragment F_target`. - let schema_doc = r#" - type Query { - t: T + #[test] + fn does_not_extract_different_sub_selections() { + let schema_doc = r#" + type Query { + t1: T t2: T - } + } - type T { - id: ID! - a: Int! - b: Int! - c: Int! - } - "#; + type T { + a: String + b: String + c: Int + } + "#; + let schema = parse_schema(schema_doc); + let query = parse_operation( + &schema, + r#" + query { + t1 { + a + b + } + t2 { + a + b + c + } + } + "#, + ); - let query = r#" - fragment F_shared on T { - id + let original: Valid = + query.clone().try_into().expect("valid document"); + let minified = query + .generate_fragments() + .expect("no fragments were generated"); + insta::assert_snapshot!(minified, @r###" + { + t1 { + a + b + } + t2 { a - } - fragment F1 on T { - ...F_shared b - } - - fragment F2 on T { - ...F_shared c + } } + "###); - fragment F_target on T { - ...F1 - ...F2 - } - - query { - t { - ...F_target - } - t2 { - ...F_target - } - } - "#; - - let operation = parse_operation(&parse_schema(schema_doc), query); - let expanded = operation.expand_all_fragments_and_normalize().unwrap(); - assert_optimized!(expanded, operation.named_fragments, @r###" - fragment F_target on T { - id - a - b - c - } - - { - t { - ...F_target - } - t2 { - ...F_target - } + assert_equal_ops!(&schema, &original, &minified); } - "###); - } - #[test] - fn optimize_fragments_using_other_fragments_when_possible() { - let schema = r#" + #[test] + fn does_not_extract_selections_on_different_types() { + let schema_doc = r#" type Query { - t: I + t1: T1 + t2: T2 } - interface I { - b: Int - u: U + type T1 { + a: String + b: String + c: Int } - type T1 implements I { - a: Int - b: Int - u: U + type T2 { + a: String + b: String + c: Int } + "#; + let schema = parse_schema(schema_doc); + let query = parse_operation( + &schema, + r#" + query { + t1 { + a + b + c + } + t2 { + a + b + c + } + } + "#, + ); - type T2 implements I { - x: String - y: String - b: Int - u: U + let original: Valid = + query.clone().try_into().expect("valid document"); + let minified = query + .generate_fragments() + .expect("no fragments were generated"); + insta::assert_snapshot!(minified, @r###" + { + t1 { + a + b + c } - - union U = T1 | T2 - "#; - - let query = r#" - fragment OnT1 on T1 { + t2 { a b + c } + } + "###); - fragment OnT2 on T2 { - x - y - } + assert_equal_ops!(&schema, &original, &minified); + } - fragment OnI on I { - b + #[test] + fn extracts_common_inline_fragment_selections() { + let schema_doc = r#" + type Query { + i1: I + i2: I } - fragment OnU on U { - ...OnI - ...OnT1 - ...OnT2 + interface I { + a: String } - query { - t { - ...OnT1 - ...OnT2 - ...OnI - u { - ...OnU + type T implements I { + a: String + b: String + c: Int + } + "#; + let schema = parse_schema(schema_doc); + let query = parse_operation( + &schema, + r#" + query { + i1 { + ... on T { + a + b + c + } + } + i2 { + ... on T { + a + b + c + } } } - } - "#; + "#, + ); - let operation = parse_operation(&parse_schema(schema), query); + let original: Valid = + query.clone().try_into().expect("valid document"); + let minified = query + .generate_fragments() + .expect("successfully generated fragments"); + insta::assert_snapshot!(minified, @r###" + { + i1 { + ...b + } + i2 { + ...b + } + } - let expanded = assert_without_fragments!( - operation, - @r###" - { - t { - ... on T1 { + fragment a on T { a b + c } - ... on T2 { - x - y - } - b - u { - ... on I { - b - } - ... on T1 { - a - b - } - ... on T2 { - x - y - } + + fragment b on I { + ...a } - } + "###); + assert_equal_ops!(&schema, &original, &minified); } - "### - ); - assert_optimized!(expanded, operation.named_fragments, @r###" - fragment OnU on U { - ... on I { - b - } - ... on T1 { - a - b - } - ... on T2 { - x - y - } + #[test] + fn extracts_common_field_and_inline_fragment_selections() { + let schema_doc = r#" + type Query { + i: I + t: T + } + + interface I { + a: String } - { - t { - ...OnU - u { - ...OnU + type T implements I { + a: String + b: String + c: Int + } + "#; + let schema = parse_schema(schema_doc); + let query = parse_operation( + &schema, + r#" + query { + i { + ... on T { + a + b + c + } + } + t { + a + b + c } } - } - "###); - } + "#, + ); - #[test] - fn handles_fragments_using_other_fragments() { - let schema = r#" - type Query { - t: I + let original: Valid = + query.clone().try_into().expect("valid document"); + let minified = query + .generate_fragments() + .expect("successfully generated fragments"); + insta::assert_snapshot!(minified, @r###" + { + i { + ...a + } + t { + ...a } + } - interface I { - b: Int - c: Int - u1: U - u2: U + fragment a on T { + a + b + c + } + "###); + assert_equal_ops!(&schema, &original, &minified); + } + + #[test] + fn extracts_common_sub_selections() { + let schema_doc = r#" + type Query { + t1: T + t2: T } - type T1 implements I { - a: Int - b: Int + type T { + a: String + b: String c: Int - me: T1 - u1: U - u2: U + v: V } - type T2 implements I { + type V { x: String y: String - b: Int - c: Int - u1: U - u2: U } - - union U = T1 | T2 - "#; - - let query = r#" - fragment OnT1 on T1 { - a - b - } - - fragment OnT2 on T2 { - x - y - } - - fragment OnI on I { - b - c - } - - fragment OnU on U { - ...OnI - ...OnT1 - ...OnT2 - } - - query { - t { - ...OnT1 - ...OnT2 - u1 { - ...OnU - } - u2 { - ...OnU - } - ... on T1 { - me { - ...OnI - } - } - } - } - "#; - - let operation = parse_operation(&parse_schema(schema), query); - - let expanded = assert_without_fragments!( - &operation, - @r###" - { - t { - ... on T1 { + "#; + let schema = parse_schema(schema_doc); + let query = parse_operation( + &schema, + r#" + query { + t1 { a b - me { - b - c - } - } - ... on T2 { - x - y - } - u1 { - ... on I { - b - c - } - ... on T1 { - a - b - } - ... on T2 { + v { x y } } - u2 { - ... on I { - b - c - } - ... on T1 { - a - b - } - ... on T2 { + t2 { + a + b + c + v { x y } } } - } - "###); + "#, + ); - // We should reuse and keep all fragments, because 1) onU is used twice and 2) - // all the other ones are used once in the query, and once in onU definition. - assert_optimized!(expanded, operation.named_fragments, @r###" - fragment OnT1 on T1 { + let original: Valid = + query.clone().try_into().expect("valid document"); + let minified = query + .generate_fragments() + .expect("successfully generated fragments"); + insta::assert_snapshot!(minified, @r###" + { + t1 { a b + v { + ...a + } } - - fragment OnT2 on T2 { - x - y - } - - fragment OnI on I { + t2 { + a b c - } - - fragment OnU on U { - ...OnI - ...OnT1 - ...OnT2 - } - - { - t { - ... on T1 { - ...OnT1 - me { - ...OnI - } - } - ...OnT2 - u1 { - ...OnU - } - u2 { - ...OnU - } + v { + ...a } } - "###); - } - - macro_rules! test_fragments_roundtrip { - ($schema_doc: expr, $query: expr, @$expanded: literal) => {{ - let schema = parse_schema($schema_doc); - let operation = parse_operation(&schema, $query); - let without_fragments = operation.expand_all_fragments_and_normalize().unwrap(); - insta::assert_snapshot!(without_fragments, @$expanded); - - let mut optimized = without_fragments; - optimized.reuse_fragments(&operation.named_fragments).unwrap(); - validate_operation(&operation.schema, &optimized.to_string()); - assert_eq!(optimized.to_string(), operation.to_string()); - }}; - } + } - /// Tests ported from JS codebase rely on special behavior of - /// `Operation::reuse_fragments_for_roundtrip_test` that is specific for testing, since it makes it - /// easier to write tests. - macro_rules! test_fragments_roundtrip_legacy { - ($schema_doc: expr, $query: expr, @$expanded: literal) => {{ - let schema = parse_schema($schema_doc); - let operation = parse_operation(&schema, $query); - let without_fragments = operation.expand_all_fragments_and_normalize().unwrap(); - insta::assert_snapshot!(without_fragments, @$expanded); - - let mut optimized = without_fragments; - optimized.reuse_fragments_for_roundtrip_test(&operation.named_fragments).unwrap(); - validate_operation(&operation.schema, &optimized.to_string()); - assert_eq!(optimized.to_string(), operation.to_string()); - }}; - } + fragment a on V { + x + y + } + "###); + assert_equal_ops!(&schema, &original, &minified); + } - #[test] - fn handles_fragments_with_nested_selections() { - let schema_doc = r#" + #[test] + fn extracts_common_complex_selections() { + let schema_doc = r#" type Query { - t1a: T1 - t2a: T1 + t1: T + t2: T } - type T1 { - t2: T2 + type T { + a: String + b: String + c: Int + v: V } - type T2 { + type V { x: String y: String } - "#; - - let query = r#" - fragment OnT1 on T1 { - t2 { - x - } - } - + "#; + let schema = parse_schema(schema_doc); + let query = parse_and_expand( + &schema, + r#" query { - t1a { - ...OnT1 - t2 { - y - } - } - t2a { - ...OnT1 - } - } - "#; - - test_fragments_roundtrip!(schema_doc, query, @r###" - { - t1a { - t2 { + t1 { + a + b + c + v { x y } } - t2a { - t2 { - x + t2 { + a + b + c + v { + ...FragmentV } } } - "###); - } - - #[test] - fn handles_nested_fragments_with_field_intersection() { - let schema_doc = r#" - type Query { - t: T - } - - type T { - a: A - b: Int - } - - type A { - x: String - y: String - z: String - } - "#; - - // The subtlety here is that `FA` contains `__typename` and so after we're reused it, the - // selection will look like: - // { - // t { - // a { - // ...FA - // } - // } - // } - // But to recognize that `FT` can be reused from there, we need to be able to see that - // the `__typename` that `FT` wants is inside `FA` (and since FA applies on the parent type `A` - // directly, it is fine to reuse). - let query = r#" - fragment FA on A { - __typename - x - y - } - - fragment FT on T { - a { - __typename - ...FA - } - } - query { - t { - ...FT + fragment FragmentV on V { + x + y } + "#, + ) + .expect("query is valid"); + + let original: Valid = + query.clone().try_into().expect("valid document"); + let minified = query + .generate_fragments() + .expect("successfully generated fragments"); + insta::assert_snapshot!(minified, @r###" + { + t1 { + ...b + } + t2 { + ...b + } } - "#; - test_fragments_roundtrip_legacy!(schema_doc, query, @r###" - { - t { - a { - __typename + fragment a on V { x y } - } + + fragment b on T { + a + b + c + v { + ...a + } + } + "###); + assert_equal_ops!(&schema, &original, &minified); } - "###); - } - #[test] - fn handles_fragment_matching_subset_of_field_selection() { - let schema_doc = r#" + #[test] + fn handles_include_skip() { + let schema_doc = r#" type Query { - t: T + t1: T + t2: T } type T { a: String - b: B + b: String c: Int - d: D + v: V } - type B { + type V { x: String y: String } - - type D { - m: String - n: String - } - "#; - - let query = r#" - fragment FragT on T { - b { - __typename - x - } - c - d { - m - } - } - - { - t { - ...FragT - d { - n - } + "#; + let schema = parse_schema(schema_doc); + let query = parse_and_expand( + &schema, + r#" + query { + t1 { a - } - } - "#; - - test_fragments_roundtrip_legacy!(schema_doc, query, @r###" - { - t { - b { - __typename + b + c + v @include(if: true) { x + y } + } + t2 { + a + b c - d { - m - n + v { + ...FragmentV @skip(if: false) } - a } } - "###); - } - - #[test] - fn handles_fragment_matching_subset_of_inline_fragment_selection() { - // Pretty much the same test than the previous one, but matching inside a fragment selection inside - // of inside a field selection. - // PORT_NOTE: ` implements I` was added in the definition of `type T`, so that validation can pass. - let schema_doc = r#" - type Query { - i: I - } - - interface I { - a: String - } - - type T implements I { - a: String - b: B - c: Int - d: D - } - - type B { - x: String - y: String - } - - type D { - m: String - n: String - } - "#; - - let query = r#" - fragment FragT on T { - b { - __typename - x - } - c - d { - m - } - } + fragment FragmentV on V { + x + y + } + "#, + ) + .expect("query is valid"); + + let original: Valid = + query.clone().try_into().expect("valid document"); + let minified = query + .generate_fragments() + .expect("successfully generated fragments"); + insta::assert_snapshot!(minified, @r###" { - i { - ... on T { - ...FragT - d { - n - } - a + t1 { + a + b + c + v @include(if: true) { + ...a } } - } - "#; - - test_fragments_roundtrip_legacy!(schema_doc, query, @r###" - { - i { - ... on T { - b { - __typename - x - } - c - d { - m - n - } - a + t2 { + a + b + c + v { + ...a @skip(if: false) } } } - "###); - } - #[test] - fn intersecting_fragments() { - let schema_doc = r#" + fragment a on V { + x + y + } + "###); + assert_equal_ops!(&schema, &original, &minified); + } + + #[test] + fn handles_skip_on_inline_fragments() { + let schema_doc = r#" type Query { - t: T + i1: I + i2: I } - type T { + interface I { a: String - b: B - c: Int - d: D - } - - type B { - x: String - y: String } - type D { - m: String - n: String + type T implements I { + a: String + b: String + c: Int } - "#; - - // Note: the code that reuse fragments iterates on fragments in the order they are defined - // in the document, but when it reuse a fragment, it puts it at the beginning of the - // selection (somewhat random, it just feel often easier to read), so the net effect on - // this example is that `Frag2`, which will be reused after `Frag1` will appear first in - // the re-optimized selection. So we put it first in the input too so that input and output - // actually match (the `testFragmentsRoundtrip` compares strings, so it is sensible to - // ordering; we could theoretically use `Operation.equals` instead of string equality, - // which wouldn't really on ordering, but `Operation.equals` is not entirely trivial and - // comparing strings make problem a bit more obvious). - let query = r#" - fragment Frag1 on T { - b { - x - } - c - d { - m - } - } - - fragment Frag2 on T { - a - b { - __typename - x - } - d { - m - n - } - } - - { - t { - ...Frag1 - ...Frag2 - } - } - "#; - - // PORT_NOTE: `__typename` and `x`'s placements are switched in Rust. - test_fragments_roundtrip_legacy!(schema_doc, query, @r###" - { - t { - b { - __typename - x + "#; + let schema = parse_schema(schema_doc); + let query = parse_and_expand( + &schema, + r#" + query { + i1 { + ... on T @skip(if: false) { + a + b + c } - c - d { - m - n + } + i2 { + ... on T { + a + b + c } - a } } - "###); - } - - #[test] - fn fragments_application_makes_type_condition_trivial() { - let schema_doc = r#" - type Query { - t: T - } - - interface I { - x: String - } - - type T implements I { - x: String - a: String - } - "#; - - let query = r#" - fragment FragI on I { - x - ... on T { - a - } - } - - { - t { - ...FragI - } - } - "#; - - test_fragments_roundtrip_legacy!(schema_doc, query, @r###" - { - t { - x - a - } - } - "###); - } - - #[test] - fn handles_fragment_matching_at_the_top_level_of_another_fragment() { - let schema_doc = r#" - type Query { - t: T - } - - type T { - a: String - u: U - } - - type U { - x: String - y: String - } - "#; - - let query = r#" - fragment Frag1 on T { - a - } - - fragment Frag2 on T { - u { - x - y - } - ...Frag1 - } - - fragment Frag3 on Query { - t { - ...Frag2 - } - } - - { - ...Frag3 - } - "#; - - test_fragments_roundtrip_legacy!(schema_doc, query, @r###" - { - t { - u { - x - y - } - a - } - } - "###); - } - - #[test] - fn handles_fragments_used_in_context_where_they_get_trimmed() { - let schema_doc = r#" - type Query { - t1: T1 - } - - interface I { - x: Int - } - - type T1 implements I { - x: Int - y: Int - } - - type T2 implements I { - x: Int - z: Int - } - "#; - - let query = r#" - fragment FragOnI on I { - ... on T1 { - y - } - ... on T2 { - z - } - } - - { - t1 { - ...FragOnI - } - } - "#; - - test_fragments_roundtrip_legacy!(schema_doc, query, @r###" - { - t1 { - y - } - } - "###); - } - - #[test] - fn handles_fragments_used_in_the_context_of_non_intersecting_abstract_types() { - let schema_doc = r#" - type Query { - i2: I2 - } - - interface I1 { - x: Int - } - - interface I2 { - y: Int - } - - interface I3 { - z: Int - } - - type T1 implements I1 & I2 { - x: Int - y: Int - } - - type T2 implements I1 & I3 { - x: Int - z: Int - } - "#; - - let query = r#" - fragment FragOnI1 on I1 { - ... on I2 { - y - } - ... on I3 { - z - } - } - - { - i2 { - ...FragOnI1 - } - } - "#; - - test_fragments_roundtrip_legacy!(schema_doc, query, @r###" - { - i2 { - ... on I1 { - ... on I2 { - y - } - ... on I3 { - z - } - } - } - } - "###); - } - - #[test] - fn handles_fragments_on_union_in_context_with_limited_intersection() { - let schema_doc = r#" - type Query { - t1: T1 - } - - union U = T1 | T2 - - type T1 { - x: Int - } - - type T2 { - y: Int - } - "#; - - let query = r#" - fragment OnU on U { - ... on T1 { - x - } - ... on T2 { - y - } - } - - { - t1 { - ...OnU - } - } - "#; - - test_fragments_roundtrip_legacy!(schema_doc, query, @r###" - { - t1 { - x - } - } - "###); - } - - #[test] - fn off_by_1_error() { - let schema = r#" - type Query { - t: T - } - type T { - id: String! - a: A - v: V - } - type A { - id: String! - } - type V { - t: T! - } - "#; - - let query = r#" - { - t { - ...TFrag - v { - t { - id - a { - __typename - id - } - } - } - } - } - - fragment TFrag on T { - __typename - id - } - "#; - - let operation = parse_operation(&parse_schema(schema), query); - - let expanded = assert_without_fragments!( - operation, - @r###" - { - t { - __typename - id - v { - t { - id - a { - __typename - id - } - } - } - } - } - "### - ); - - assert_optimized!(expanded, operation.named_fragments, @r###" - fragment TFrag on T { - __typename - id - } - - { - t { - ...TFrag - v { - t { - ...TFrag - a { - __typename - id - } - } - } - } - } - "###); - } - - #[test] - fn removes_all_unused_fragments() { - let schema = r#" - type Query { - t1: T1 - } - - union U1 = T1 | T2 | T3 - union U2 = T2 | T3 - - type T1 { - x: Int - } - - type T2 { - y: Int - } - - type T3 { - z: Int - } - "#; - - let query = r#" - query { - t1 { - ...Outer - } - } - - fragment Outer on U1 { - ... on T1 { - x - } - ... on T2 { - ... Inner - } - ... on T3 { - ... Inner - } - } - - fragment Inner on U2 { - ... on T2 { - y - } - } - "#; - - let operation = parse_operation(&parse_schema(schema), query); - - let expanded = assert_without_fragments!( - operation, - @r###" - { - t1 { - x - } - } - "### - ); - - // This is a bit of contrived example, but the reusing code will be able - // to figure out that the `Outer` fragment can be reused and will initially - // do so, but it's only use once, so it will expand it, which yields: - // { - // t1 { - // ... on T1 { - // x - // } - // ... on T2 { - // ... Inner - // } - // ... on T3 { - // ... Inner - // } - // } - // } - // and so `Inner` will not be expanded (it's used twice). Except that - // the `flatten_unnecessary_fragments` code is apply then and will _remove_ both instances - // of `.... Inner`. Which is ok, but we must make sure the fragment - // itself is removed since it is not used now, which this test ensures. - assert_optimized!(expanded, operation.named_fragments, @r###" - { - t1 { - x - } - } - "###); - } - - #[test] - fn removes_fragments_only_used_by_unused_fragments() { - // Similar to the previous test, but we artificially add a - // fragment that is only used by the fragment that is finally - // unused. - let schema = r#" - type Query { - t1: T1 - } - - union U1 = T1 | T2 | T3 - union U2 = T2 | T3 - - type T1 { - x: Int - } - - type T2 { - y1: Y - y2: Y - } - - type T3 { - z: Int - } - - type Y { - v: Int - } - "#; - - let query = r#" - query { - t1 { - ...Outer - } - } - - fragment Outer on U1 { - ... on T1 { - x - } - ... on T2 { - ... Inner - } - ... on T3 { - ... Inner - } - } - - fragment Inner on U2 { - ... on T2 { - y1 { - ...WillBeUnused - } - y2 { - ...WillBeUnused - } - } - } - - fragment WillBeUnused on Y { - v - } - "#; - - let operation = parse_operation(&parse_schema(schema), query); - - let expanded = assert_without_fragments!( - operation, - @r###" - { - t1 { - x - } - } - "### - ); - - assert_optimized!(expanded, operation.named_fragments, @r###" - { - t1 { - x - } - } - "###); - } - - #[test] - fn keeps_fragments_used_by_other_fragments() { - let schema = r#" - type Query { - t1: T - t2: T - } - - type T { - a1: Int - a2: Int - b1: B - b2: B - } - - type B { - x: Int - y: Int - } - "#; - - let query = r#" - query { - t1 { - ...TFields - } - t2 { - ...TFields - } - } - - fragment TFields on T { - ...DirectFieldsOfT - b1 { - ...BFields - } - b2 { - ...BFields - } - } - - fragment DirectFieldsOfT on T { - a1 - a2 - } - - fragment BFields on B { - x - y - } - "#; - - let operation = parse_operation(&parse_schema(schema), query); - - let expanded = assert_without_fragments!( - operation, - @r###" - { - t1 { - a1 - a2 - b1 { - x - y - } - b2 { - x - y - } - } - t2 { - a1 - a2 - b1 { - x - y - } - b2 { - x - y - } - } - } - "### - ); - - // The `DirectFieldsOfT` fragments should not be kept as it is used only once within `TFields`, - // but the `BFields` one should be kept. - assert_optimized!(expanded, operation.named_fragments, @r###" - fragment BFields on B { - x - y - } - - fragment TFields on T { - a1 - a2 - b1 { - ...BFields - } - b2 { - ...BFields - } - } - - { - t1 { - ...TFields - } - t2 { - ...TFields - } - } - "###); - } - - /// - /// applied directives - /// - - #[test] - fn reuse_fragments_with_same_directive_in_the_fragment_selection() { - let schema_doc = r#" - type Query { - t1: T - t2: T - t3: T - } - - type T { - a: Int - b: Int - c: Int - d: Int - } - "#; - - let query = r#" - fragment DirectiveInDef on T { - a @include(if: $cond1) - } - - query myQuery($cond1: Boolean!, $cond2: Boolean!) { - t1 { - a - } - t2 { - ...DirectiveInDef - } - t3 { - a @include(if: $cond2) - } - } - "#; - - test_fragments_roundtrip_legacy!(schema_doc, query, @r###" - query myQuery($cond1: Boolean!, $cond2: Boolean!) { - t1 { - a - } - t2 { - a @include(if: $cond1) - } - t3 { - a @include(if: $cond2) - } - } - "###); - } - - #[test] - fn reuse_fragments_with_directives_on_inline_fragments() { - let schema_doc = r#" - type Query { - t1: T - t2: T - t3: T - } - - type T { - a: Int - b: Int - c: Int - d: Int - } - "#; - - let query = r#" - fragment NoDirectiveDef on T { - a - } - - query myQuery($cond1: Boolean!) { - t1 { - ...NoDirectiveDef - } - t2 { - ...NoDirectiveDef @include(if: $cond1) - } - } - "#; - - test_fragments_roundtrip!(schema_doc, query, @r###" - query myQuery($cond1: Boolean!) { - t1 { - a - } - t2 { - ... on T @include(if: $cond1) { - a - } - } - } - "###); - } - - #[test] - fn reuse_fragments_with_directive_on_typename() { - let schema = r#" - type Query { - t1: T - t2: T - t3: T - } - - type T { - a: Int - b: Int - c: Int - d: Int - } - "#; - let query = r#" - query A ($if: Boolean!) { - t1 { b a ...x } - t2 { ...x } - } - query B { - # Because this inline fragment is exactly the same shape as `x`, - # except for a `__typename` field, it may be tempting to reuse it. - # But `x.__typename` has a directive with a variable, and this query - # does not have that variable declared, so it can't be used. - t3 { ... on T { a c } } - } - fragment x on T { - __typename @include(if: $if) - a - c - } - "#; - let schema = parse_schema(schema); - let query = ExecutableDocument::parse_and_validate(schema.schema(), query, "query.graphql") - .unwrap(); - - let operation_a = - Operation::from_operation_document(schema.clone(), &query, Some("A")).unwrap(); - let operation_b = - Operation::from_operation_document(schema.clone(), &query, Some("B")).unwrap(); - let expanded_b = operation_b.expand_all_fragments_and_normalize().unwrap(); - - assert_optimized!(expanded_b, operation_a.named_fragments, @r###" - query B { - t3 { - a - c - } - } - "###); - } - - #[test] - fn reuse_fragments_with_non_intersecting_types() { - let schema = r#" - type Query { - t: T - s: S - s2: S - i: I - } - - interface I { - a: Int - b: Int - } - - type T implements I { - a: Int - b: Int - - c: Int - d: Int - } - type S implements I { - a: Int - b: Int - - f: Int - g: Int - } - "#; - let query = r#" - query A ($if: Boolean!) { - t { ...x } - s { ...x } - i { ...x } - } - query B { - s { - # this matches fragment x once it is flattened, - # because the `...on T` condition does not intersect with our - # current type `S` - __typename - a b + "#, + ) + .expect("query is valid"); + + let original: Valid = + query.clone().try_into().expect("valid document"); + let minified = query + .generate_fragments() + .expect("successfully generated fragments"); + insta::assert_snapshot!(minified, @r###" + { + i1 { + ...a @skip(if: false) } - s2 { - # same snippet to get it to use the fragment - __typename - a b + i2 { + ...a } } - fragment x on I { - __typename - a - b - ... on T { c d @include(if: $if) } - } - "#; - let schema = parse_schema(schema); - let query = ExecutableDocument::parse_and_validate(schema.schema(), query, "query.graphql") - .unwrap(); - - let operation_a = - Operation::from_operation_document(schema.clone(), &query, Some("A")).unwrap(); - let operation_b = - Operation::from_operation_document(schema.clone(), &query, Some("B")).unwrap(); - let expanded_b = operation_b.expand_all_fragments_and_normalize().unwrap(); - - assert_optimized!(expanded_b, operation_a.named_fragments, @r###" - query B { - s { - __typename - a - b - } - s2 { - __typename - a - b - } - } - "###); - } - - /// - /// empty branches removal - /// - - mod test_empty_branch_removal { - use apollo_compiler::name; - - use super::*; - use crate::operation::SelectionKey; - - const TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL: &str = r#" - type Query { - t: T - u: Int - } - - type T { - a: Int - b: Int - c: C - } - - type C { - x: String - y: String - } - "#; - - fn operation_without_empty_branches(operation: &Operation) -> Option { - operation - .selection_set - .without_empty_branches() - .unwrap() - .map(|s| s.to_string()) - } - - fn without_empty_branches(query: &str) -> Option { - let operation = - parse_operation(&parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), query); - operation_without_empty_branches(&operation) - } - - // To test `without_empty_branches` method, we need to test operations with empty selection - // sets. However, such operations can't be constructed from strings, since the parser will - // reject them. Thus, we first create a valid query with non-empty selection sets and then - // clear some of them. - // PORT_NOTE: The JS tests use `astSSet` function to construct queries with - // empty selection sets using graphql-js's SelectionSetNode API. In Rust version, - // instead of re-creating such API, we will selectively clear selection sets. - - fn clear_selection_set_at_path( - ss: &mut SelectionSet, - path: &[Name], - ) -> Result<(), FederationError> { - match path.split_first() { - None => { - // Base case - ss.selections = Default::default(); - Ok(()) - } - - Some((first, rest)) => { - let result = Arc::make_mut(&mut ss.selections).get_mut(SelectionKey::Field { - response_name: first, - directives: &Default::default(), - }); - let Some(mut value) = result else { - return Err(FederationError::internal("No matching field found")); - }; - match value.get_selection_set_mut() { - None => Err(FederationError::internal( - "Sub-selection expected, but not found.", - )), - Some(sub_selection_set) => { - // Recursive case - clear_selection_set_at_path(sub_selection_set, rest)?; - Ok(()) - } - } - } - } - } - - #[test] - fn operation_not_modified_if_no_empty_branches() { - let test_vec = vec!["{ t { a } }", "{ t { a b } }", "{ t { a c { x y } } }"]; - for query in test_vec { - assert_eq!(without_empty_branches(query).unwrap(), query); - } - } - - #[test] - fn removes_simple_empty_branches() { - { - // query to test: "{ t { a c { } } }" - let expected = "{ t { a } }"; - - // Since the parser won't accept empty selection set, we first create - // a valid query and then clear the selection set. - let valid_query = r#"{ t { a c { x } } }"#; - let mut operation = parse_operation( - &parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), - valid_query, - ); - clear_selection_set_at_path( - &mut operation.selection_set, - &[name!("t"), name!("c")], - ) - .unwrap(); - // Note: Unfortunately, this assertion won't work since SelectionSet.to_string() can't - // display empty selection set. - // assert_eq!(operation.selection_set.to_string(), "{ t { a c { } } }"); - assert_eq!( - operation_without_empty_branches(&operation).unwrap(), - expected - ); - } - - { - // query to test: "{ t { c { } a } }" - let expected = "{ t { a } }"; - - let valid_query = r#"{ t { c { x } a } }"#; - let mut operation = parse_operation( - &parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), - valid_query, - ); - clear_selection_set_at_path( - &mut operation.selection_set, - &[name!("t"), name!("c")], - ) - .unwrap(); - assert_eq!( - operation_without_empty_branches(&operation).unwrap(), - expected - ); - } - - { - // query to test: "{ t { } }" - let expected = None; - - let valid_query = r#"{ t { a } }"#; - let mut operation = parse_operation( - &parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), - valid_query, - ); - clear_selection_set_at_path(&mut operation.selection_set, &[name!("t")]).unwrap(); - assert_eq!(operation_without_empty_branches(&operation), expected); - } - } - - #[test] - fn removes_cascading_empty_branches() { - { - // query to test: "{ t { c { } } }" - let expected = None; - - let valid_query = r#"{ t { c { x } } }"#; - let mut operation = parse_operation( - &parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), - valid_query, - ); - clear_selection_set_at_path( - &mut operation.selection_set, - &[name!("t"), name!("c")], - ) - .unwrap(); - assert_eq!(operation_without_empty_branches(&operation), expected); - } - - { - // query to test: "{ u t { c { } } }" - let expected = "{ u }"; - - let valid_query = r#"{ u t { c { x } } }"#; - let mut operation = parse_operation( - &parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), - valid_query, - ); - clear_selection_set_at_path( - &mut operation.selection_set, - &[name!("t"), name!("c")], - ) - .unwrap(); - assert_eq!( - operation_without_empty_branches(&operation).unwrap(), - expected - ); - } - - { - // query to test: "{ t { c { } } u }" - let expected = "{ u }"; - let valid_query = r#"{ t { c { x } } u }"#; - let mut operation = parse_operation( - &parse_schema(TEST_SCHEMA_FOR_EMPTY_BRANCH_REMOVAL), - valid_query, - ); - clear_selection_set_at_path( - &mut operation.selection_set, - &[name!("t"), name!("c")], - ) - .unwrap(); - assert_eq!( - operation_without_empty_branches(&operation).unwrap(), - expected - ); + fragment a on T { + a + b + c } + "###); + assert_equal_ops!(&schema, &original, &minified); } } } diff --git a/apollo-federation/src/operation/rebase.rs b/apollo-federation/src/operation/rebase.rs index ffc16d1b01..4e6ca427ef 100644 --- a/apollo-federation/src/operation/rebase.rs +++ b/apollo-federation/src/operation/rebase.rs @@ -3,28 +3,18 @@ //! Often, the change is between equivalent types from different schemas, but selections can also //! be rebased from one type to another in the same schema. -use apollo_compiler::Name; -use itertools::Itertools; - -use super::runtime_types_intersect; use super::Field; use super::FieldSelection; -use super::Fragment; -use super::FragmentSpread; -use super::FragmentSpreadSelection; use super::InlineFragment; use super::InlineFragmentSelection; -use super::NamedFragments; -use super::OperationElement; use super::Selection; -use super::SelectionId; use super::SelectionSet; use super::TYPENAME_FIELD; -use crate::ensure; +use super::runtime_types_intersect; use crate::error::FederationError; +use crate::schema::ValidFederationSchema; use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::OutputTypeDefinitionPosition; -use crate::schema::ValidFederationSchema; use crate::utils::FallibleIterator; fn print_possible_runtimes( @@ -45,55 +35,26 @@ fn print_possible_runtimes( ) } -/// Options for handling rebasing errors. -#[derive(Clone, Copy, Default)] -enum OnNonRebaseableSelection { - /// Drop the selection that can't be rebased and continue. - Drop, - /// Propagate the rebasing error. - #[default] - Error, -} - impl Selection { fn rebase_inner( &self, parent_type: &CompositeTypeDefinitionPosition, - named_fragments: &NamedFragments, schema: &ValidFederationSchema, - on_non_rebaseable_selection: OnNonRebaseableSelection, ) -> Result { match self { Selection::Field(field) => field - .rebase_inner( - parent_type, - named_fragments, - schema, - on_non_rebaseable_selection, - ) + .rebase_inner(parent_type, schema) .map(|field| field.into()), - Selection::FragmentSpread(spread) => spread.rebase_inner( - parent_type, - named_fragments, - schema, - on_non_rebaseable_selection, - ), - Selection::InlineFragment(inline) => inline.rebase_inner( - parent_type, - named_fragments, - schema, - on_non_rebaseable_selection, - ), + Selection::InlineFragment(inline) => inline.rebase_inner(parent_type, schema), } } pub(crate) fn rebase_on( &self, parent_type: &CompositeTypeDefinitionPosition, - named_fragments: &NamedFragments, schema: &ValidFederationSchema, ) -> Result { - self.rebase_inner(parent_type, named_fragments, schema, Default::default()) + self.rebase_inner(parent_type, schema) } fn can_add_to( @@ -103,10 +64,6 @@ impl Selection { ) -> Result { match self { Selection::Field(field) => field.can_add_to(parent_type, schema), - // Since `rebaseOn` never fails, we copy the logic here and always return `true`. But as - // mentioned in `rebaseOn`, this leaves it a bit to the caller to know what they're - // doing. - Selection::FragmentSpread(_) => Ok(true), Selection::InlineFragment(inline) => inline.can_add_to(parent_type, schema), } } @@ -114,20 +71,15 @@ impl Selection { #[derive(Debug, Clone, thiserror::Error)] pub(crate) enum RebaseError { - #[error("Cannot add selection of field `{field_position}` to selection set of parent type `{parent_type}`")] + #[error( + "Cannot add selection of field `{field_position}` to selection set of parent type `{parent_type}`" + )] CannotRebase { field_position: crate::schema::position::FieldDefinitionPosition, parent_type: CompositeTypeDefinitionPosition, }, - #[error("Cannot add selection of field `{field_position}` to selection set of parent type `{parent_type}` that is potentially an interface object type at runtime")] - InterfaceObjectTypename { - field_position: crate::schema::position::FieldDefinitionPosition, - parent_type: CompositeTypeDefinitionPosition, - }, #[error("Cannot rebase composite field selection because its subselection is empty")] EmptySelectionSet, - #[error("Cannot rebase {fragment_name} fragment if it isn't part of the provided fragments")] - MissingFragment { fragment_name: Name }, #[error( "Cannot add fragment of condition `{}` (runtimes: [{}]) to parent type `{}` (runtimes: [{}])", type_condition.as_ref().map_or_else(Default::default, |t| t.to_string()), @@ -145,18 +97,6 @@ pub(crate) enum RebaseError { }, } -impl FederationError { - fn is_rebase_error(&self) -> bool { - matches!( - self, - crate::error::FederationError::SingleFederationError { - inner: crate::error::SingleFederationError::InternalRebaseError(_), - .. - } - ) - } -} - impl From for FederationError { fn from(value: RebaseError) -> Self { crate::error::SingleFederationError::from(value).into() @@ -176,24 +116,10 @@ impl Field { } if self.name() == &TYPENAME_FIELD { - // TODO interface object info should be precomputed in QP constructor - return if schema - .possible_runtime_types(parent_type.clone())? - .iter() - .map(|t| schema.is_interface_object_type(t.clone().into())) - .process_results(|mut iter| iter.any(|b| b))? - { - Err(RebaseError::InterfaceObjectTypename { - field_position: self.field_position.clone(), - parent_type: parent_type.clone(), - } - .into()) - } else { - let mut updated_field = self.clone(); - updated_field.schema = schema.clone(); - updated_field.field_position = parent_type.introspection_typename_field(); - Ok(updated_field) - }; + let mut updated_field = self.clone(); + updated_field.schema = schema.clone(); + updated_field.field_position = parent_type.introspection_typename_field(); + return Ok(updated_field); } let field_from_parent = parent_type.field(self.name().clone())?; @@ -265,19 +191,44 @@ impl Field { }; return Ok(Some(schema.get_type(type_name.clone())?.try_into()?)); } - if self.can_rebase_on(parent_type)? { - let Some(type_name) = parent_type - .field(data.field_position.field_name().clone()) - .ok() - .and_then(|field_pos| field_pos.get(schema.schema()).ok()) - .map(|field| field.ty.inner_named_type()) - else { + if !self.can_rebase_on(parent_type)? { + return Ok(None); + } + let Some(field_definition) = parent_type + .field(data.field_position.field_name().clone()) + .ok() + .and_then(|field_pos| field_pos.get(schema.schema()).ok()) + else { + return Ok(None); + }; + if let Some(federation_spec_definition) = schema + .subgraph_metadata() + .map(|d| d.federation_spec_definition()) + { + let from_context_directive_definition_name = &federation_spec_definition + .from_context_directive_definition(schema)? + .name; + // We need to ensure that all arguments with `@fromContext` are provided. If the + // would-be parent type's field has an argument with `@fromContext` and that argument + // has no value/data in this field, then we return `None` to indicate the rebase isn't + // possible. + if field_definition.arguments.iter().any(|arg_definition| { + arg_definition + .directives + .has(from_context_directive_definition_name) + && !data + .arguments + .iter() + .any(|arg| arg.name == arg_definition.name) + }) { return Ok(None); - }; - Ok(Some(schema.get_type(type_name.clone())?.try_into()?)) - } else { - Ok(None) + } } + Ok(Some( + schema + .get_type(field_definition.ty.inner_named_type().clone())? + .try_into()?, + )) } } @@ -285,9 +236,7 @@ impl FieldSelection { fn rebase_inner( &self, parent_type: &CompositeTypeDefinitionPosition, - named_fragments: &NamedFragments, schema: &ValidFederationSchema, - on_non_rebaseable_selection: OnNonRebaseableSelection, ) -> Result { if &self.field.schema == schema && &self.field.field_position.parent() == parent_type { // we are rebasing field on the same parent within the same schema - we can just return self @@ -320,12 +269,7 @@ impl FieldSelection { }); } - let rebased_selection_set = selection_set.rebase_inner( - &rebased_base_type, - named_fragments, - schema, - on_non_rebaseable_selection, - )?; + let rebased_selection_set = selection_set.rebase_inner(&rebased_base_type, schema)?; if rebased_selection_set.selections.is_empty() { Err(RebaseError::EmptySelectionSet.into()) } else { @@ -359,150 +303,6 @@ impl FieldSelection { } } -impl FragmentSpread { - /// - `named_fragments`: named fragment definitions that are rebased for the subgraph. - // Note: Unlike other `rebase_on`, this method should only be used during fetch operation - // optimization. Thus, it's rebasing within the same subgraph schema. - pub(crate) fn rebase_on( - &self, - parent_type: &CompositeTypeDefinitionPosition, - schema: &ValidFederationSchema, - named_fragments: &NamedFragments, - ) -> Result { - let Some(named_fragment) = named_fragments.get(&self.fragment_name) else { - return Err(RebaseError::MissingFragment { - fragment_name: self.fragment_name.clone(), - } - .into()); - }; - ensure!( - *schema == self.schema, - "Fragment spread should only be rebased within the same subgraph" - ); - ensure!( - *schema == named_fragment.schema, - "Referenced named fragment should've been rebased for the subgraph" - ); - if runtime_types_intersect( - parent_type, - &named_fragment.type_condition_position, - &self.schema, - ) { - Ok(FragmentSpread::from_fragment( - named_fragment, - &self.directives, - )) - } else { - Err(RebaseError::NonIntersectingCondition { - type_condition: named_fragment.type_condition_position.clone().into(), - parent_type: parent_type.clone(), - schema: schema.clone(), - } - .into()) - } - } -} - -impl FragmentSpreadSelection { - fn rebase_inner( - &self, - parent_type: &CompositeTypeDefinitionPosition, - named_fragments: &NamedFragments, - schema: &ValidFederationSchema, - on_non_rebaseable_selection: OnNonRebaseableSelection, - ) -> Result { - // We preserve the parent type here, to make sure we don't lose context, but we actually don't - // want to expand the spread as that would compromise the code that optimize subgraph fetches to re-use named - // fragments. - // - // This is a little bit iffy, because the fragment may not apply at this parent type, but we - // currently leave it to the caller to ensure this is not a mistake. But most of the - // QP code works on selections with fully expanded fragments, so this code (and that of `can_add_to` - // on come into play in the code for reusing fragments, and that code calls those methods - // appropriately. - if self.spread.schema == *schema && self.spread.type_condition_position == *parent_type { - return Ok(self.clone().into()); - } - - let rebase_on_same_schema = self.spread.schema == *schema; - let Some(named_fragment) = named_fragments.get(&self.spread.fragment_name) else { - // If we're rebasing on another schema (think a subgraph), then named fragments will have been rebased on that, and some - // of them may not contain anything that is on that subgraph, in which case they will not have been included at all. - // If so, then as long as we're not asked to error if we cannot rebase, then we're happy to skip that spread (since again, - // it expands to nothing that applies on the schema). - return Err(RebaseError::MissingFragment { - fragment_name: self.spread.fragment_name.clone(), - } - .into()); - }; - - // Lastly, if we rebase on a different schema, it's possible the fragment type does not intersect the - // parent type. For instance, the parent type could be some object type T while the fragment is an - // interface I, and T may implement I in the supergraph, but not in a particular subgraph (of course, - // if I doesn't exist at all in the subgraph, then we'll have exited above, but I may exist in the - // subgraph, just not be implemented by T for some reason). In that case, we can't reuse the fragment - // as its spread is essentially invalid in that position, so we have to replace it by the expansion - // of that fragment, which we rebase on the parentType (which in turn, will remove anythings within - // the fragment selection that needs removing, potentially everything). - if !rebase_on_same_schema - && !runtime_types_intersect( - parent_type, - &named_fragment.type_condition_position, - schema, - ) - { - // Note that we've used the rebased `named_fragment` to check the type intersection because we needed to - // compare runtime types "for the schema we're rebasing into". But now that we're deciding to not reuse - // this rebased fragment, what we rebase is the selection set of the non-rebased fragment. And that's - // important because the very logic we're hitting here may need to happen inside the rebase on the - // fragment selection, but that logic would not be triggered if we used the rebased `named_fragment` since - // `rebase_on_same_schema` would then be 'true'. - let expanded_selection_set = self.selection_set.rebase_inner( - parent_type, - named_fragments, - schema, - on_non_rebaseable_selection, - )?; - // In theory, we could return the selection set directly, but making `SelectionSet.rebase_on` sometimes - // return a `SelectionSet` complicate things quite a bit. So instead, we encapsulate the selection set - // in an "empty" inline fragment. This make for non-really-optimal selection sets in the (relatively - // rare) case where this is triggered, but in practice this "inefficiency" is removed by future calls - // to `flatten_unnecessary_fragments`. - return if expanded_selection_set.selections.is_empty() { - Err(RebaseError::EmptySelectionSet.into()) - } else { - Ok(InlineFragmentSelection::new( - InlineFragment { - schema: schema.clone(), - parent_type_position: parent_type.clone(), - type_condition_position: None, - directives: Default::default(), - selection_id: SelectionId::new(), - }, - expanded_selection_set, - ) - .into()) - }; - } - - let spread = FragmentSpread::from_fragment(named_fragment, &self.spread.directives); - Ok(FragmentSpreadSelection { - spread, - selection_set: named_fragment.selection_set.clone(), - } - .into()) - } - - pub(crate) fn rebase_on( - &self, - parent_type: &CompositeTypeDefinitionPosition, - named_fragments: &NamedFragments, - schema: &ValidFederationSchema, - ) -> Result { - self.rebase_inner(parent_type, named_fragments, schema, Default::default()) - } -} - impl InlineFragment { fn casted_type_if_add_to( &self, @@ -574,6 +374,17 @@ impl InlineFragment { CompositeTypeDefinitionPosition::try_from(rebased_condition_position) }) { + // Root types can always be rebased and the type condition is unnecessary. + // Moreover, the actual subgraph might have renamed the root types, but the + // supergraph schema does not contain that information. + // Note: We only handle when the rebased condition is the same as the parent type. They + // could be different in rare cases, but that will be fixed after the + // source-awareness initiative is complete. + if rebased_condition == *parent_type + && parent_schema.is_root_type(rebased_condition.type_name()) + { + return (true, None); + } // chained if let chains are not yet supported // see https://github.com/rust-lang/rust/issues/53667 if runtime_types_intersect(parent_type, &rebased_condition, parent_schema) { @@ -593,9 +404,7 @@ impl InlineFragmentSelection { fn rebase_inner( &self, parent_type: &CompositeTypeDefinitionPosition, - named_fragments: &NamedFragments, schema: &ValidFederationSchema, - on_non_rebaseable_selection: OnNonRebaseableSelection, ) -> Result { if &self.inline_fragment.schema == schema && self.inline_fragment.parent_type_position == *parent_type @@ -612,12 +421,9 @@ impl InlineFragmentSelection { // we are within the same schema - selection set does not have to be rebased Ok(InlineFragmentSelection::new(rebased_fragment, self.selection_set.clone()).into()) } else { - let rebased_selection_set = self.selection_set.rebase_inner( - &rebased_casted_type, - named_fragments, - schema, - on_non_rebaseable_selection, - )?; + let rebased_selection_set = self + .selection_set + .rebase_inner(&rebased_casted_type, schema)?; if rebased_selection_set.selections.is_empty() { // empty selection set Err(RebaseError::EmptySelectionSet.into()) @@ -651,59 +457,16 @@ impl InlineFragmentSelection { } } -impl OperationElement { - pub(crate) fn rebase_on( - &self, - parent_type: &CompositeTypeDefinitionPosition, - schema: &ValidFederationSchema, - named_fragments: &NamedFragments, - ) -> Result { - match self { - OperationElement::Field(field) => Ok(field.rebase_on(parent_type, schema)?.into()), - OperationElement::FragmentSpread(fragment) => Ok(fragment - .rebase_on(parent_type, schema, named_fragments)? - .into()), - OperationElement::InlineFragment(inline) => { - Ok(inline.rebase_on(parent_type, schema)?.into()) - } - } - } - - pub(crate) fn sub_selection_type_position( - &self, - ) -> Result, FederationError> { - match self { - OperationElement::Field(field) => Ok(field.output_base_type()?.try_into().ok()), - OperationElement::FragmentSpread(_) => Ok(None), // No sub-selection set - OperationElement::InlineFragment(inline) => Ok(Some(inline.casted_type())), - } - } -} - impl SelectionSet { fn rebase_inner( &self, parent_type: &CompositeTypeDefinitionPosition, - named_fragments: &NamedFragments, schema: &ValidFederationSchema, - on_non_rebaseable_selection: OnNonRebaseableSelection, ) -> Result { let rebased_results = self .selections .values() - .map(|selection| { - selection.rebase_inner( - parent_type, - named_fragments, - schema, - on_non_rebaseable_selection, - ) - }) - // Remove selections with rebase errors if requested - .filter(|result| { - matches!(on_non_rebaseable_selection, OnNonRebaseableSelection::Error) - || !result.as_ref().is_err_and(|err| err.is_rebase_error()) - }); + .map(|selection| selection.rebase_inner(parent_type, schema)); Ok(SelectionSet { schema: schema.clone(), @@ -720,10 +483,9 @@ impl SelectionSet { pub(crate) fn rebase_on( &self, parent_type: &CompositeTypeDefinitionPosition, - named_fragments: &NamedFragments, schema: &ValidFederationSchema, ) -> Result { - self.rebase_inner(parent_type, named_fragments, schema, Default::default()) + self.rebase_inner(parent_type, schema) } /// Returns true if the selection set would select cleanly from the given type in the given @@ -738,641 +500,3 @@ impl SelectionSet { .fallible_all(|selection| selection.can_add_to(parent_type, schema)) } } - -impl NamedFragments { - pub(crate) fn rebase_on( - &self, - schema: &ValidFederationSchema, - ) -> Result { - let mut rebased_fragments = NamedFragments::default(); - for fragment in self.fragments.values() { - if let Some(rebased_type) = schema - .get_type(fragment.type_condition_position.type_name().clone()) - .ok() - .and_then(|ty| CompositeTypeDefinitionPosition::try_from(ty).ok()) - { - if let Ok(mut rebased_selection) = fragment.selection_set.rebase_inner( - &rebased_type, - &rebased_fragments, - schema, - OnNonRebaseableSelection::Drop, - ) { - // Rebasing can leave some inefficiencies in some case (particularly when a spread has to be "expanded", see `FragmentSpreadSelection.rebaseOn`), - // so we do a top-level normalization to keep things clean. - rebased_selection = rebased_selection.flatten_unnecessary_fragments( - &rebased_type, - &rebased_fragments, - schema, - )?; - if NamedFragments::is_selection_set_worth_using(&rebased_selection) { - let fragment = Fragment { - schema: schema.clone(), - name: fragment.name.clone(), - type_condition_position: rebased_type.clone(), - directives: fragment.directives.clone(), - selection_set: rebased_selection, - }; - rebased_fragments.insert(fragment); - } - } - } - } - Ok(rebased_fragments) - } -} - -#[cfg(test)] -mod tests { - use apollo_compiler::collections::IndexSet; - use apollo_compiler::name; - - use crate::operation::normalize_operation; - use crate::operation::tests::parse_schema_and_operation; - use crate::operation::tests::parse_subgraph; - use crate::operation::NamedFragments; - use crate::schema::position::InterfaceTypeDefinitionPosition; - - #[test] - fn skips_unknown_fragment_fields() { - let operation_fragments = r#" -query TestQuery { - t { - ...FragOnT - } -} - -fragment FragOnT on T { - v0 - v1 - v2 - u1 { - v3 - v4 - v5 - } - u2 { - v4 - v5 - } -} - -type Query { - t: T -} - -type T { - v0: Int - v1: Int - v2: Int - u1: U - u2: U -} - -type U { - v3: Int - v4: Int - v5: Int -} -"#; - let (schema, mut executable_document) = parse_schema_and_operation(operation_fragments); - assert!( - !executable_document.fragments.is_empty(), - "operation should have some fragments" - ); - - if let Some(operation) = executable_document.operations.named.get_mut("TestQuery") { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - - let subgraph_schema = r#"type Query { - _: Int -} - -type T { - v1: Int - u1: U -} - -type U { - v3: Int - v5: Int -}"#; - let subgraph = parse_subgraph("A", subgraph_schema); - let rebased_fragments = normalized_operation.named_fragments.rebase_on(&subgraph); - assert!(rebased_fragments.is_ok()); - let rebased_fragments = rebased_fragments.unwrap(); - assert!(!rebased_fragments.is_empty()); - assert!(rebased_fragments.contains(&name!("FragOnT"))); - let rebased_fragment = rebased_fragments.fragments.get("FragOnT").unwrap(); - - insta::assert_snapshot!(rebased_fragment, @r###" - fragment FragOnT on T { - v1 - u1 { - v3 - v5 - } - } - "###); - } - } - - #[test] - fn skips_unknown_fragment_on_condition() { - let operation_fragments = r#" -query TestQuery { - t { - ...FragOnT - } - u { - ...FragOnU - } -} - -fragment FragOnT on T { - x - y -} - -fragment FragOnU on U { - x - y -} - -type Query { - t: T - u: U -} - -type T { - x: Int - y: Int -} - -type U { - x: Int - y: Int -} -"#; - let (schema, mut executable_document) = parse_schema_and_operation(operation_fragments); - assert!( - !executable_document.fragments.is_empty(), - "operation should have some fragments" - ); - assert_eq!(2, executable_document.fragments.len()); - - if let Some(operation) = executable_document.operations.named.get_mut("TestQuery") { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - - let subgraph_schema = r#"type Query { - t: T -} - -type T { - x: Int - y: Int -}"#; - let subgraph = parse_subgraph("A", subgraph_schema); - let rebased_fragments = normalized_operation.named_fragments.rebase_on(&subgraph); - assert!(rebased_fragments.is_ok()); - let rebased_fragments = rebased_fragments.unwrap(); - assert!(!rebased_fragments.is_empty()); - assert!(rebased_fragments.contains(&name!("FragOnT"))); - assert!(!rebased_fragments.contains(&name!("FragOnU"))); - let rebased_fragment = rebased_fragments.fragments.get("FragOnT").unwrap(); - - let expected = r#"fragment FragOnT on T { - x - y -}"#; - let actual = rebased_fragment.to_string(); - assert_eq!(actual, expected); - } - } - - #[test] - fn skips_unknown_type_within_fragment() { - let operation_fragments = r#" -query TestQuery { - i { - ...FragOnI - } -} - -fragment FragOnI on I { - id - otherId - ... on T1 { - x - } - ... on T2 { - y - } -} - -type Query { - i: I -} - -interface I { - id: ID! - otherId: ID! -} - -type T1 implements I { - id: ID! - otherId: ID! - x: Int -} - -type T2 implements I { - id: ID! - otherId: ID! - y: Int -} -"#; - let (schema, mut executable_document) = parse_schema_and_operation(operation_fragments); - assert!( - !executable_document.fragments.is_empty(), - "operation should have some fragments" - ); - - if let Some(operation) = executable_document.operations.named.get_mut("TestQuery") { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - - let subgraph_schema = r#"type Query { - i: I -} - -interface I { - id: ID! -} - -type T2 implements I { - id: ID! - y: Int -} -"#; - let subgraph = parse_subgraph("A", subgraph_schema); - let rebased_fragments = normalized_operation.named_fragments.rebase_on(&subgraph); - assert!(rebased_fragments.is_ok()); - let rebased_fragments = rebased_fragments.unwrap(); - assert!(!rebased_fragments.is_empty()); - assert!(rebased_fragments.contains(&name!("FragOnI"))); - let rebased_fragment = rebased_fragments.fragments.get("FragOnI").unwrap(); - - let expected = r#"fragment FragOnI on I { - id - ... on T2 { - y - } -}"#; - let actual = rebased_fragment.to_string(); - assert_eq!(actual, expected); - } - } - - #[test] - fn skips_typename_on_possible_interface_objects_within_fragment() { - let operation_fragments = r#" -query TestQuery { - i { - ...FragOnI - } -} - -fragment FragOnI on I { - __typename - id - x -} - -type Query { - i: I -} - -interface I { - id: ID! - x: String! -} - -type T implements I { - id: ID! - x: String! -} -"#; - - let (schema, mut executable_document) = parse_schema_and_operation(operation_fragments); - assert!( - !executable_document.fragments.is_empty(), - "operation should have some fragments" - ); - - if let Some(operation) = executable_document.operations.named.get_mut("TestQuery") { - let mut interface_objects: IndexSet = - IndexSet::default(); - interface_objects.insert(InterfaceTypeDefinitionPosition { - type_name: name!("I"), - }); - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &interface_objects, - ) - .unwrap(); - - let subgraph_schema = r#"extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.5", import: [{ name: "@interfaceObject" }, { name: "@key" }]) - -directive @link(url: String, as: String, import: [link__Import]) repeatable on SCHEMA - -directive @key(fields: federation__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE - -directive @interfaceObject on OBJECT - -type Query { - i: I -} - -type I @interfaceObject @key(fields: "id") { - id: ID! - x: String! -} - -scalar link__Import - -scalar federation__FieldSet -"#; - let subgraph = parse_subgraph("A", subgraph_schema); - let rebased_fragments = normalized_operation.named_fragments.rebase_on(&subgraph); - assert!(rebased_fragments.is_ok()); - let rebased_fragments = rebased_fragments.unwrap(); - assert!(!rebased_fragments.is_empty()); - assert!(rebased_fragments.contains(&name!("FragOnI"))); - let rebased_fragment = rebased_fragments.fragments.get("FragOnI").unwrap(); - - let expected = r#"fragment FragOnI on I { - id - x -}"#; - let actual = rebased_fragment.to_string(); - assert_eq!(actual, expected); - } - } - - #[test] - fn skips_fragments_with_trivial_selections() { - let operation_fragments = r#" -query TestQuery { - t { - ...F1 - ...F2 - ...F3 - } -} - -fragment F1 on T { - a - b -} - -fragment F2 on T { - __typename - a - b -} - -fragment F3 on T { - __typename - a - b - c - d -} - -type Query { - t: T -} - -type T { - a: Int - b: Int - c: Int - d: Int -} -"#; - let (schema, mut executable_document) = parse_schema_and_operation(operation_fragments); - assert!( - !executable_document.fragments.is_empty(), - "operation should have some fragments" - ); - - if let Some(operation) = executable_document.operations.named.get_mut("TestQuery") { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - - let subgraph_schema = r#"type Query { - t: T -} - -type T { - c: Int - d: Int -} -"#; - let subgraph = parse_subgraph("A", subgraph_schema); - let rebased_fragments = normalized_operation.named_fragments.rebase_on(&subgraph); - assert!(rebased_fragments.is_ok()); - let rebased_fragments = rebased_fragments.unwrap(); - // F1 reduces to nothing, and F2 reduces to just __typename so we shouldn't keep them. - assert_eq!(1, rebased_fragments.len()); - assert!(rebased_fragments.contains(&name!("F3"))); - let rebased_fragment = rebased_fragments.fragments.get("F3").unwrap(); - - let expected = r#"fragment F3 on T { - __typename - c - d -}"#; - let actual = rebased_fragment.to_string(); - assert_eq!(actual, expected); - } - } - - #[test] - fn handles_skipped_fragments_within_fragments() { - let operation_fragments = r#" -query TestQuery { - ...TheQuery -} - -fragment TheQuery on Query { - t { - x - ... GetU - } -} - -fragment GetU on T { - u { - y - z - } -} - -type Query { - t: T -} - -type T { - x: Int - u: U -} - -type U { - y: Int - z: Int -} -"#; - let (schema, mut executable_document) = parse_schema_and_operation(operation_fragments); - assert!( - !executable_document.fragments.is_empty(), - "operation should have some fragments" - ); - - if let Some(operation) = executable_document.operations.named.get_mut("TestQuery") { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - - let subgraph_schema = r#"type Query { - t: T -} - -type T { - x: Int -}"#; - let subgraph = parse_subgraph("A", subgraph_schema); - let rebased_fragments = normalized_operation.named_fragments.rebase_on(&subgraph); - assert!(rebased_fragments.is_ok()); - let rebased_fragments = rebased_fragments.unwrap(); - // F1 reduces to nothing, and F2 reduces to just __typename so we shouldn't keep them. - assert_eq!(1, rebased_fragments.len()); - assert!(rebased_fragments.contains(&name!("TheQuery"))); - let rebased_fragment = rebased_fragments.fragments.get("TheQuery").unwrap(); - - let expected = r#"fragment TheQuery on Query { - t { - x - } -}"#; - let actual = rebased_fragment.to_string(); - assert_eq!(actual, expected); - } - } - - #[test] - fn handles_subtypes_within_subgraphs() { - let operation_fragments = r#" -query TestQuery { - ...TQuery -} - -fragment TQuery on Query { - t { - x - y - ... on T { - z - } - } -} - -type Query { - t: I -} - -interface I { - x: Int - y: Int -} - -type T implements I { - x: Int - y: Int - z: Int -} -"#; - let (schema, mut executable_document) = parse_schema_and_operation(operation_fragments); - assert!( - !executable_document.fragments.is_empty(), - "operation should have some fragments" - ); - - if let Some(operation) = executable_document.operations.named.get_mut("TestQuery") { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - - let subgraph_schema = r#"type Query { - t: T -} - -type T { - x: Int - y: Int - z: Int -} -"#; - - let subgraph = parse_subgraph("A", subgraph_schema); - let rebased_fragments = normalized_operation.named_fragments.rebase_on(&subgraph); - assert!(rebased_fragments.is_ok()); - let rebased_fragments = rebased_fragments.unwrap(); - // F1 reduces to nothing, and F2 reduces to just __typename so we shouldn't keep them. - assert_eq!(1, rebased_fragments.len()); - assert!(rebased_fragments.contains(&name!("TQuery"))); - let rebased_fragment = rebased_fragments.fragments.get("TQuery").unwrap(); - - let expected = r#"fragment TQuery on Query { - t { - x - y - z - } -}"#; - let actual = rebased_fragment.to_string(); - assert_eq!(actual, expected); - } - } -} diff --git a/apollo-federation/src/operation/selection_map.rs b/apollo-federation/src/operation/selection_map.rs index 19857ba274..8642c7d9b0 100644 --- a/apollo-federation/src/operation/selection_map.rs +++ b/apollo-federation/src/operation/selection_map.rs @@ -1,22 +1,24 @@ use std::borrow::Cow; use std::hash::BuildHasher; +use std::hash::Hash; +use std::hash::Hasher; use std::sync::Arc; use apollo_compiler::Name; use hashbrown::DefaultHashBuilder; use hashbrown::HashTable; -use serde::ser::SerializeSeq; +use itertools::Itertools; use serde::Serialize; +use serde::ser::SerializeSeq; use crate::error::FederationError; -use crate::operation::field_selection::FieldSelection; -use crate::operation::fragment_spread_selection::FragmentSpreadSelection; -use crate::operation::inline_fragment_selection::InlineFragmentSelection; use crate::operation::DirectiveList; use crate::operation::Selection; use crate::operation::SelectionId; use crate::operation::SelectionSet; use crate::operation::SiblingTypename; +use crate::operation::field_selection::FieldSelection; +use crate::operation::inline_fragment_selection::InlineFragmentSelection; /// A selection "key" (unrelated to the federation `@key` directive) is an identifier of a selection /// (field, inline fragment, or fragment spread) that is used to determine whether two selections @@ -34,26 +36,22 @@ pub(crate) enum SelectionKey<'a> { /// The field alias (if specified) or field name in the resulting selection set. response_name: &'a Name, /// directives applied on the field - #[serde(serialize_with = "crate::display_helpers::serialize_as_string")] directives: &'a DirectiveList, }, FragmentSpread { /// The name of the fragment. fragment_name: &'a Name, /// Directives applied on the fragment spread (does not contain @defer). - #[serde(serialize_with = "crate::display_helpers::serialize_as_string")] directives: &'a DirectiveList, }, InlineFragment { /// The optional type condition of the fragment. type_condition: Option<&'a Name>, /// Directives applied on the fragment spread (does not contain @defer). - #[serde(serialize_with = "crate::display_helpers::serialize_as_string")] directives: &'a DirectiveList, }, Defer { /// Unique selection ID used to distinguish deferred fragment spreads that cannot be merged. - #[cfg_attr(not(feature = "snapshot_tracing"), serde(skip))] deferred_id: SelectionId, }, } @@ -142,12 +140,12 @@ impl OwnedSelectionKey { } } +#[cfg(test)] impl<'a> SelectionKey<'a> { /// Create a selection key for a specific field name. /// /// This is available for tests only as selection keys should not normally be created outside of /// `HasSelectionKey::key`. - #[cfg(test)] pub(crate) fn field_name(name: &'a Name) -> Self { static EMPTY_LIST: DirectiveList = DirectiveList::new(); SelectionKey::Field { @@ -200,6 +198,14 @@ impl PartialEq for SelectionMap { impl Eq for SelectionMap {} +impl Hash for SelectionMap { + fn hash(&self, state: &mut H) { + self.values() + .sorted() + .for_each(|hash_key| hash_key.hash(state)); + } +} + impl Serialize for SelectionMap { fn serialize(&self, serializer: S) -> Result where @@ -228,7 +234,7 @@ pub(crate) type IntoValues = std::vec::IntoIter; /// matches the given key. /// /// The returned function panics if the index is out of bounds. -fn key_eq<'a>(selections: &'a [Selection], key: SelectionKey<'a>) -> impl Fn(&Bucket) -> bool + 'a { +fn key_eq(selections: &[Selection], key: SelectionKey<'_>) -> impl Fn(&Bucket) -> bool { move |bucket| selections[bucket.index].key() == key } @@ -252,19 +258,14 @@ impl SelectionMap { self.selections.is_empty() } - /// Returns the first selection in the map, or None if the map is empty. - pub(crate) fn first(&self) -> Option<&Selection> { - self.selections.first() - } - /// Computes the hash of a selection key. - fn hash(&self, key: SelectionKey<'_>) -> u64 { + fn hash_key(&self, key: SelectionKey<'_>) -> u64 { self.hash_builder.hash_one(key) } /// Returns true if the given key exists in the map. pub(crate) fn contains_key(&self, key: SelectionKey<'_>) -> bool { - let hash = self.hash(key); + let hash = self.hash_key(key); self.table .find(hash, key_eq(&self.selections, key)) .is_some() @@ -272,13 +273,13 @@ impl SelectionMap { /// Returns true if the given key exists in the map. pub(crate) fn get(&self, key: SelectionKey<'_>) -> Option<&Selection> { - let hash = self.hash(key); + let hash = self.hash_key(key); let bucket = self.table.find(hash, key_eq(&self.selections, key))?; Some(&self.selections[bucket.index]) } pub(crate) fn get_mut(&mut self, key: SelectionKey<'_>) -> Option> { - let hash = self.hash(key); + let hash = self.hash_key(key); let bucket = self.table.find_mut(hash, key_eq(&self.selections, key))?; Some(SelectionValue::new(&mut self.selections[bucket.index])) } @@ -302,7 +303,7 @@ impl SelectionMap { assert!(self.table.capacity() >= self.selections.len()); self.table.clear(); for (index, selection) in self.selections.iter().enumerate() { - let hash = self.hash(selection.key()); + let hash = self.hash_key(selection.key()); self.table .insert_unique(hash, Bucket { index, hash }, |existing| existing.hash); } @@ -318,13 +319,13 @@ impl SelectionMap { } pub(crate) fn insert(&mut self, value: Selection) { - let hash = self.hash(value.key()); + let hash = self.hash_key(value.key()); self.raw_insert(hash, value); } /// Remove a selection from the map. Returns the selection and its numeric index. pub(crate) fn remove(&mut self, key: SelectionKey<'_>) -> Option<(usize, Selection)> { - let hash = self.hash(key); + let hash = self.hash_key(key); let entry = self .table .find_entry(hash, key_eq(&self.selections, key)) @@ -370,7 +371,7 @@ impl SelectionMap { /// Provides mutable access to a selection key. A new selection can be inserted or an existing /// selection modified. pub(super) fn entry<'a>(&'a mut self, key: SelectionKey<'a>) -> Entry<'a> { - let hash = self.hash(key); + let hash = self.hash_key(key); let slot = self.table.find_entry(hash, key_eq(&self.selections, key)); match slot { Ok(occupied) => { @@ -413,16 +414,16 @@ impl SelectionMap { /// filtering has happened on all the selections of its sub-selection. pub(crate) fn filter_recursive_depth_first( &self, - predicate: &mut dyn FnMut(&Selection) -> Result, - ) -> Result, FederationError> { + predicate: &mut dyn FnMut(&Selection) -> bool, + ) -> Cow<'_, Self> { fn recur_sub_selections<'sel>( selection: &'sel Selection, - predicate: &mut dyn FnMut(&Selection) -> Result, - ) -> Result, FederationError> { - Ok(match selection { + predicate: &mut dyn FnMut(&Selection) -> bool, + ) -> Cow<'sel, Selection> { + match selection { Selection::Field(field) => { if let Some(sub_selections) = &field.selection_set { - match sub_selections.filter_recursive_depth_first(predicate)? { + match sub_selections.filter_recursive_depth_first(predicate) { Cow::Borrowed(_) => Cow::Borrowed(selection), Cow::Owned(new) => { Cow::Owned(Selection::from_field(field.field.clone(), Some(new))) @@ -434,7 +435,7 @@ impl SelectionMap { } Selection::InlineFragment(fragment) => match fragment .selection_set - .filter_recursive_depth_first(predicate)? + .filter_recursive_depth_first(predicate) { Cow::Borrowed(_) => Cow::Borrowed(selection), Cow::Owned(selection_set) => Cow::Owned(Selection::InlineFragment(Arc::new( @@ -444,20 +445,17 @@ impl SelectionMap { ), ))), }, - Selection::FragmentSpread(_) => { - return Err(FederationError::internal("unexpected fragment spread")) - } - }) + } } let mut iter = self.values(); let mut enumerated = (&mut iter).enumerate(); let mut new_map: Self; loop { let Some((index, selection)) = enumerated.next() else { - return Ok(Cow::Borrowed(self)); + return Cow::Borrowed(self); }; - let filtered = recur_sub_selections(selection, predicate)?; - let keep = predicate(&filtered)?; + let filtered = recur_sub_selections(selection, predicate); + let keep = predicate(&filtered); if keep && matches!(filtered, Cow::Borrowed(_)) { // Nothing changed so far, continue without cloning continue; @@ -472,12 +470,12 @@ impl SelectionMap { break; } for selection in iter { - let filtered = recur_sub_selections(selection, predicate)?; - if predicate(&filtered)? { + let filtered = recur_sub_selections(selection, predicate); + if predicate(&filtered) { new_map.insert(filtered.into_owned()); } } - Ok(Cow::Owned(new_map)) + Cow::Owned(new_map) } } @@ -502,7 +500,6 @@ where #[derive(Debug)] pub(crate) enum SelectionValue<'a> { Field(FieldSelectionValue<'a>), - FragmentSpread(FragmentSpreadSelectionValue<'a>), InlineFragment(InlineFragmentSelectionValue<'a>), } @@ -512,9 +509,6 @@ impl<'a> SelectionValue<'a> { Selection::Field(field_selection) => { SelectionValue::Field(FieldSelectionValue::new(field_selection)) } - Selection::FragmentSpread(fragment_spread_selection) => SelectionValue::FragmentSpread( - FragmentSpreadSelectionValue::new(fragment_spread_selection), - ), Selection::InlineFragment(inline_fragment_selection) => SelectionValue::InlineFragment( InlineFragmentSelectionValue::new(inline_fragment_selection), ), @@ -524,7 +518,6 @@ impl<'a> SelectionValue<'a> { pub(super) fn key(&self) -> SelectionKey<'_> { match self { Self::Field(field) => field.get().key(), - Self::FragmentSpread(frag) => frag.get().key(), Self::InlineFragment(frag) => frag.get().key(), } } @@ -534,7 +527,6 @@ impl<'a> SelectionValue<'a> { pub(super) fn get_selection_set_mut(&mut self) -> Option<&mut SelectionSet> { match self { SelectionValue::Field(field) => field.get_selection_set_mut(), - SelectionValue::FragmentSpread(frag) => Some(frag.get_selection_set_mut()), SelectionValue::InlineFragment(frag) => Some(frag.get_selection_set_mut()), } } @@ -561,24 +553,6 @@ impl<'a> FieldSelectionValue<'a> { } } -#[derive(Debug)] -pub(crate) struct FragmentSpreadSelectionValue<'a>(&'a mut Arc); - -impl<'a> FragmentSpreadSelectionValue<'a> { - pub(crate) fn new(fragment_spread_selection: &'a mut Arc) -> Self { - Self(fragment_spread_selection) - } - - pub(crate) fn get(&self) -> &Arc { - self.0 - } - - #[cfg(test)] - pub(crate) fn get_selection_set_mut(&mut self) -> &mut SelectionSet { - &mut Arc::make_mut(self.0).selection_set - } -} - #[derive(Debug)] pub(crate) struct InlineFragmentSelectionValue<'a>(&'a mut Arc); diff --git a/apollo-federation/src/operation/simplify.rs b/apollo-federation/src/operation/simplify.rs index 29d4c55d9f..85d4f7d283 100644 --- a/apollo-federation/src/operation/simplify.rs +++ b/apollo-federation/src/operation/simplify.rs @@ -3,20 +3,17 @@ use std::sync::Arc; use apollo_compiler::executable; use apollo_compiler::name; -use super::runtime_types_intersect; use super::DirectiveList; use super::Field; use super::FieldSelection; -use super::FragmentSpreadSelection; use super::InlineFragmentSelection; -use super::NamedFragments; use super::Selection; use super::SelectionMap; use super::SelectionSet; -use crate::ensure; +use super::runtime_types_intersect; use crate::error::FederationError; -use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::ValidFederationSchema; +use crate::schema::position::CompositeTypeDefinitionPosition; #[derive(Debug, Clone, PartialEq, Eq, derive_more::From)] pub(crate) enum SelectionOrSet { @@ -28,18 +25,12 @@ impl Selection { fn flatten_unnecessary_fragments( &self, parent_type: &CompositeTypeDefinitionPosition, - named_fragments: &NamedFragments, schema: &ValidFederationSchema, ) -> Result, FederationError> { match self { - Selection::Field(field) => { - field.flatten_unnecessary_fragments(parent_type, named_fragments, schema) - } - Selection::FragmentSpread(spread) => { - spread.flatten_unnecessary_fragments(parent_type, named_fragments, schema) - } + Selection::Field(field) => field.flatten_unnecessary_fragments(parent_type, schema), Selection::InlineFragment(inline) => { - inline.flatten_unnecessary_fragments(parent_type, named_fragments, schema) + inline.flatten_unnecessary_fragments(parent_type, schema) } } } @@ -49,7 +40,6 @@ impl FieldSelection { fn flatten_unnecessary_fragments( &self, parent_type: &CompositeTypeDefinitionPosition, - named_fragments: &NamedFragments, schema: &ValidFederationSchema, ) -> Result, FederationError> { let field_position = @@ -71,11 +61,7 @@ impl FieldSelection { let field_composite_type_position: CompositeTypeDefinitionPosition = field_element.output_base_type()?.try_into()?; let mut normalized_selection: SelectionSet = selection_set - .flatten_unnecessary_fragments( - &field_composite_type_position, - named_fragments, - schema, - )?; + .flatten_unnecessary_fragments(&field_composite_type_position, schema)?; let mut selection = self.with_updated_element(field_element); if normalized_selection.is_empty() { @@ -116,41 +102,10 @@ impl FieldSelection { } } -impl FragmentSpreadSelection { - fn flatten_unnecessary_fragments( - &self, - parent_type: &CompositeTypeDefinitionPosition, - named_fragments: &NamedFragments, - schema: &ValidFederationSchema, - ) -> Result, FederationError> { - let this_condition = self.spread.type_condition_position.clone(); - // This method assumes by contract that `parent_type` runtimes intersects `self.inline_fragment.parent_type_position`'s, - // but `parent_type` runtimes may be a subset. So first check if the selection should not be discarded on that account (that - // is, we should not keep the selection if its condition runtimes don't intersect at all with those of - // `parent_type` as that would ultimately make an invalid selection set). - if (self.spread.schema != *schema || this_condition != *parent_type) - && !runtime_types_intersect(&this_condition, parent_type, schema) - { - return Ok(None); - } - - // We must update the spread parent type if necessary since we're not going deeper, - // or we'll be fundamentally losing context. - ensure!( - self.spread.schema == *schema, - "Should not try to flatten_unnecessary_fragments using a type from another schema", - ); - - let rebased_fragment_spread = self.rebase_on(parent_type, named_fragments, schema)?; - Ok(Some(SelectionOrSet::Selection(rebased_fragment_spread))) - } -} - impl InlineFragmentSelection { fn flatten_unnecessary_fragments( self: &Arc, parent_type: &CompositeTypeDefinitionPosition, - named_fragments: &NamedFragments, schema: &ValidFederationSchema, ) -> Result, FederationError> { let this_condition = self.inline_fragment.type_condition_position.as_ref(); @@ -158,13 +113,12 @@ impl InlineFragmentSelection { // but `parent_type` runtimes may be a subset. So first check if the selection should not be discarded on that account (that // is, we should not keep the selection if its condition runtimes don't intersect at all with those of // `parent_type` as that would ultimately make an invalid selection set). - if let Some(type_condition) = this_condition { - if (self.inline_fragment.schema != *schema + if let Some(type_condition) = this_condition + && (self.inline_fragment.schema != *schema || self.inline_fragment.parent_type_position != *parent_type) - && !runtime_types_intersect(type_condition, parent_type, schema) - { - return Ok(None); - } + && !runtime_types_intersect(type_condition, parent_type, schema) + { + return Ok(None); } // We know the condition is "valid", but it may not be useful. That said, if the condition has directives, @@ -175,17 +129,15 @@ impl InlineFragmentSelection { // 2. if it's the same type as the current type: it's not restricting types further. // 3. if the current type is an object more generally: because in that case the condition // cannot be restricting things further (it's typically a less precise interface/union). - let useless_fragment = this_condition.map_or(true, |type_condition| { + let useless_fragment = this_condition.is_none_or(|type_condition| { self.inline_fragment.schema == *schema && type_condition == parent_type }); if useless_fragment || parent_type.is_object_type() { // Try to skip this fragment and flatten_unnecessary_fragments self.selection_set with `parent_type`, // instead of its original type. - let selection_set = self.selection_set.flatten_unnecessary_fragments( - parent_type, - named_fragments, - schema, - )?; + let selection_set = self + .selection_set + .flatten_unnecessary_fragments(parent_type, schema)?; return if selection_set.is_empty() { Ok(None) } else { @@ -194,9 +146,9 @@ impl InlineFragmentSelection { // Note: Rebasing after flattening, since rebasing before that can error out. // Or, `flatten_unnecessary_fragments` could `rebase` at the same time. let selection_set = if useless_fragment { - selection_set.clone() + selection_set } else { - selection_set.rebase_on(parent_type, named_fragments, schema)? + selection_set.rebase_on(parent_type, schema)? }; Ok(Some(SelectionOrSet::SelectionSet(selection_set))) }; @@ -206,7 +158,6 @@ impl InlineFragmentSelection { // Note: This selection_set is not rebased here yet. It will be rebased later as necessary. let selection_set = self.selection_set.flatten_unnecessary_fragments( &self.selection_set.type_position, - named_fragments, &self.selection_set.schema, )?; // It could be that nothing was satisfiable. @@ -261,24 +212,14 @@ impl InlineFragmentSelection { let mut liftable_selections = SelectionMap::new(); for selection in selection_set.selections.values() { match selection { - Selection::FragmentSpread(spread_selection) => { - let type_condition = &spread_selection.spread.type_condition_position; - if type_condition.is_object_type() - && runtime_types_intersect(parent_type, type_condition, schema) - { - liftable_selections.insert(selection.clone()); - } - } Selection::InlineFragment(inline_fragment_selection) => { if let Some(type_condition) = &inline_fragment_selection .inline_fragment .type_condition_position + && type_condition.is_object_type() + && runtime_types_intersect(parent_type, type_condition, schema) { - if type_condition.is_object_type() - && runtime_types_intersect(parent_type, type_condition, schema) - { - liftable_selections.insert(selection.clone()); - } + liftable_selections.insert(selection.clone()); }; } _ => continue, @@ -288,8 +229,7 @@ impl InlineFragmentSelection { // If we can lift all selections, then that just mean we can get rid of the current fragment altogether if liftable_selections.len() == selection_set.selections.len() { // Rebasing is necessary since this normalized sub-selection set changed its parent. - let rebased_selection_set = - selection_set.rebase_on(parent_type, named_fragments, schema)?; + let rebased_selection_set = selection_set.rebase_on(parent_type, schema)?; return Ok(Some(SelectionOrSet::SelectionSet(rebased_selection_set))); } @@ -305,7 +245,7 @@ impl InlineFragmentSelection { let rebased_inline_fragment = self.inline_fragment.rebase_on(parent_type, schema)?; - let mut nonliftable_selections = selection_set.selections.clone(); + let mut nonliftable_selections = selection_set.selections; Arc::make_mut(&mut nonliftable_selections) .retain(|k, _| !liftable_selections.contains_key(k)); @@ -323,7 +263,7 @@ impl InlineFragmentSelection { // Since liftable_selections are changing their parent, we need to rebase them. liftable_selections = liftable_selections .into_values() - .map(|sel| sel.rebase_on(parent_type, named_fragments, schema)) + .map(|sel| sel.rebase_on(parent_type, schema)) .collect::>()?; let mut final_selection_map = SelectionMap::new(); @@ -348,11 +288,8 @@ impl InlineFragmentSelection { let rebased_inline_fragment = self.inline_fragment.rebase_on(parent_type, schema)?; let rebased_casted_type = rebased_inline_fragment.casted_type(); // Re-flatten with the rebased casted type, which could further flatten away. - let selection_set = selection_set.flatten_unnecessary_fragments( - &rebased_casted_type, - named_fragments, - schema, - )?; + let selection_set = + selection_set.flatten_unnecessary_fragments(&rebased_casted_type, schema)?; if selection_set.is_empty() { Ok(None) } else { @@ -361,7 +298,7 @@ impl InlineFragmentSelection { // Note: Rebasing after flattening, since rebasing before that can error out. // Or, `flatten_unnecessary_fragments` could `rebase` at the same time. let rebased_selection_set = - selection_set.rebase_on(&rebased_casted_type, named_fragments, schema)?; + selection_set.rebase_on(&rebased_casted_type, schema)?; Ok(Some( Selection::InlineFragment(Arc::new(InlineFragmentSelection::new( rebased_inline_fragment, @@ -453,7 +390,6 @@ impl SelectionSet { pub(super) fn flatten_unnecessary_fragments( &self, parent_type: &CompositeTypeDefinitionPosition, - named_fragments: &NamedFragments, schema: &ValidFederationSchema, ) -> Result { let mut normalized_selections = Self { @@ -463,7 +399,7 @@ impl SelectionSet { }; for selection in self.selections.values() { if let Some(selection_or_set) = - selection.flatten_unnecessary_fragments(parent_type, named_fragments, schema)? + selection.flatten_unnecessary_fragments(parent_type, schema)? { match selection_or_set { SelectionOrSet::Selection(normalized_selection) => { @@ -471,9 +407,8 @@ impl SelectionSet { } SelectionOrSet::SelectionSet(normalized_set) => { // Since the `selection` has been expanded/lifted, we use - // `add_selection_set_with_fragments` to make sure it's rebased. - normalized_selections - .add_selection_set_with_fragments(&normalized_set, named_fragments)?; + // `add_selection_set` to make sure it's rebased. + normalized_selections.add_selection_set(&normalized_set)?; } } } @@ -536,23 +471,17 @@ type Query { } "#, "query.graphql", - None, ) .unwrap(); let expanded_and_flattened = operation .selection_set - .flatten_unnecessary_fragments( - &operation.selection_set.type_position, - &NamedFragments::default(), - &schema, - ) + .flatten_unnecessary_fragments(&operation.selection_set.type_position, &schema) .unwrap(); // Use apollo-compiler's selection set printer directly instead of the minimized // apollo-federation printer - let compiler_set = - apollo_compiler::executable::SelectionSet::try_from(&expanded_and_flattened).unwrap(); + let compiler_set = executable::SelectionSet::try_from(&expanded_and_flattened).unwrap(); insta::assert_snapshot!(compiler_set, @r#" { diff --git a/apollo-federation/src/operation/tests/defer.rs b/apollo-federation/src/operation/tests/defer.rs deleted file mode 100644 index 8c37ea164d..0000000000 --- a/apollo-federation/src/operation/tests/defer.rs +++ /dev/null @@ -1,194 +0,0 @@ -use super::parse_operation; -use super::parse_schema; - -const DEFAULT_SCHEMA: &str = r#" -type A { - one: Int - two: Int - three: Int - b: B -} - -type B { - one: Boolean - two: Boolean - three: Boolean - a: A -} - -union AorB = A | B - -type Query { - a: A - b: B - either: AorB -} - -directive @defer(if: Boolean! = true, label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT -"#; - -#[test] -fn without_defer_simple() { - let schema = parse_schema(DEFAULT_SCHEMA); - - let operation = parse_operation( - &schema, - r#" - { - ... @defer { a { one } } - b { - ... @defer { two } - } - } - "#, - ); - - let without_defer = operation.without_defer().unwrap(); - - insta::assert_snapshot!(without_defer, @r#" - { - ... { - a { - one - } - } - b { - ... { - two - } - } - } - "#); -} - -#[test] -fn without_defer_named_fragment() { - let schema = parse_schema(DEFAULT_SCHEMA); - - let operation = parse_operation( - &schema, - r#" - { - b { ...frag @defer } - either { ...frag } - } - fragment frag on B { - two - } - "#, - ); - - let without_defer = operation.without_defer().unwrap(); - - insta::assert_snapshot!(without_defer, @r#" - fragment frag on B { - two - } - - { - b { - ...frag - } - either { - ...frag - } - } - "#); -} - -#[test] -fn without_defer_merges_fragment() { - let schema = parse_schema(DEFAULT_SCHEMA); - - let operation = parse_operation( - &schema, - r#" - { - a { one } - either { - ... on B { - one - } - ... on B @defer { - two - } - } - } - "#, - ); - - let without_defer = operation.without_defer().unwrap(); - - insta::assert_snapshot!(without_defer, @r#" - { - a { - one - } - either { - ... on B { - one - two - } - } - } - "#); -} - -#[test] -fn without_defer_fragment_references() { - let schema = parse_schema(DEFAULT_SCHEMA); - - let operation = parse_operation( - &schema, - r#" - fragment a on A { - ... @defer { ...b } - } - fragment b on A { - one - b { - ...c @defer - } - } - fragment c on B { - two - } - fragment entry on Query { - a { ...a } - } - - { ...entry } - "#, - ); - - let without_defer = operation.without_defer().unwrap(); - - insta::assert_snapshot!(without_defer, @r###" - fragment c on B { - two - } - - fragment b on A { - one - b { - ...c - } - } - - fragment a on A { - ... { - ...b - } - } - - fragment entry on Query { - a { - ...a - } - } - - { - ...entry - } - "###); -} diff --git a/apollo-federation/src/operation/tests/mod.rs b/apollo-federation/src/operation/tests/mod.rs index 6988b8e659..2d6a81a440 100644 --- a/apollo-federation/src/operation/tests/mod.rs +++ b/apollo-federation/src/operation/tests/mod.rs @@ -1,202 +1,210 @@ +use apollo_compiler::ExecutableDocument; use apollo_compiler::collections::IndexSet; use apollo_compiler::name; +use apollo_compiler::parser::Parser; use apollo_compiler::schema::Schema; -use apollo_compiler::ExecutableDocument; -use super::normalize_operation; use super::Field; use super::Name; -use super::NamedFragments; use super::Operation; use super::Selection; use super::SelectionKey; use super::SelectionSet; +use super::normalize_operation; +use crate::SingleFederationError; use crate::error::FederationError; -use crate::query_graph::graph_path::OpPathElement; +use crate::query_graph::graph_path::operation::OpPathElement; +use crate::schema::ValidFederationSchema; use crate::schema::position::InterfaceTypeDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; -use crate::schema::ValidFederationSchema; -use crate::subgraph::Subgraph; -mod defer; +macro_rules! assert_normalized { + ($schema_doc: expr, $query: expr, @$expected: literal) => {{ + let schema = parse_schema($schema_doc); + let without_fragments = parse_and_expand(&schema, $query).expect("operation is valid and can be normalized"); + insta::assert_snapshot!(without_fragments, @$expected); + without_fragments + }}; +} + +macro_rules! assert_normalized_equal { + ($schema_doc: expr, $query: expr, $expected: literal) => {{ + let normalized = assert_normalized!($schema_doc, $query, @$expected); + + let schema = parse_schema($schema_doc); + let original_document = ExecutableDocument::parse_and_validate(schema.schema(), $query, "query.graphql").expect("valid document"); + let normalized_document = normalized.clone().try_into().expect("valid normalized document"); + // since compare operations just check if a query is subset of another one + // we verify that both A ⊆ B and B ⊆ A are true which means that A = B + compare_operations(&schema, &original_document, &normalized_document).expect("original query is a subset of the normalized one"); + compare_operations(&schema, &normalized_document, &original_document).expect("normalized query is a subset of original one"); + normalized + }}; +} + +macro_rules! assert_equal_ops { + ($schema: expr, $original_document: expr, $minified_document: expr) => { + // since compare operations just check if a query is subset of another one + // we verify that both A ⊆ B and B ⊆ A are true which means that A = B + compare_operations($schema, $original_document, $minified_document) + .expect("original document is a subset of minified one"); + compare_operations($schema, $minified_document, $original_document) + .expect("minified document is a subset of original one"); + }; +} +pub(super) use assert_equal_ops; + +use crate::correctness::compare_operations; pub(super) fn parse_schema_and_operation( schema_and_operation: &str, ) -> (ValidFederationSchema, ExecutableDocument) { - let (schema, executable_document) = - apollo_compiler::parse_mixed_validate(schema_and_operation, "document.graphql").unwrap(); + let (schema, executable_document) = Parser::new() + .parse_mixed_validate(schema_and_operation, "document.graphql") + .expect("valid schema and operation"); let executable_document = executable_document.into_inner(); - let schema = ValidFederationSchema::new(schema).unwrap(); + let schema = ValidFederationSchema::new(schema).expect("valid federation schema"); (schema, executable_document) } -pub(super) fn parse_subgraph(name: &str, schema: &str) -> ValidFederationSchema { - let parsed_schema = - Subgraph::parse_and_expand(name, &format!("https://{name}"), schema).unwrap(); - ValidFederationSchema::new(parsed_schema.schema).unwrap() -} - pub(super) fn parse_schema(schema_doc: &str) -> ValidFederationSchema { - let schema = Schema::parse_and_validate(schema_doc, "schema.graphql").unwrap(); - ValidFederationSchema::new(schema).unwrap() + let schema = Schema::parse_and_validate(schema_doc, "schema.graphql").expect("valid schema"); + ValidFederationSchema::new(schema).expect("valid federation schema") } pub(super) fn parse_operation(schema: &ValidFederationSchema, query: &str) -> Operation { - Operation::parse(schema.clone(), query, "query.graphql", None).unwrap() + Operation::parse(schema.clone(), query, "query.graphql").expect("valid operation") } pub(super) fn parse_and_expand( schema: &ValidFederationSchema, query: &str, ) -> Result { - let doc = apollo_compiler::ExecutableDocument::parse_and_validate( - schema.schema(), - query, - "query.graphql", - )?; + let doc = ExecutableDocument::parse_and_validate(schema.schema(), query, "query.graphql")?; let operation = doc .operations - .anonymous - .as_ref() - .expect("must have anonymous operation"); - let fragments = NamedFragments::new(&doc.fragments, schema); - - normalize_operation(operation, fragments, schema, &Default::default()) + .iter() + .next() + .expect("must have an operation"); + + normalize_operation( + operation, + &doc.fragments, + schema, + &Default::default(), + &never_cancel, + ) } -/// Parse and validate the query similarly to `parse_operation`, but does not construct the -/// `Operation` struct. -pub(super) fn validate_operation(schema: &ValidFederationSchema, query: &str) { - apollo_compiler::ExecutableDocument::parse_and_validate( - schema.schema(), - query, - "query.graphql", - ) - .unwrap(); +/// The `normalize_operation()` function has a `check_cancellation` parameter that we'll want to +/// configure to never cancel during tests. We create a convenience function here for that purpose. +pub(crate) fn never_cancel() -> Result<(), SingleFederationError> { + Ok(()) } #[test] fn expands_named_fragments() { - let operation_with_named_fragment = r#" -query NamedFragmentQuery { - foo { - id - ...Bar - } -} - -fragment Bar on Foo { - bar - baz -} - -type Query { - foo: Foo -} + let schema = r#" + type Query { + foo: Foo + } -type Foo { - id: ID! - bar: String! - baz: Int -} -"#; - let (schema, mut executable_document) = - parse_schema_and_operation(operation_with_named_fragment); - if let Some(operation) = executable_document - .operations - .named - .get_mut("NamedFragmentQuery") - { - let mut normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - normalized_operation.named_fragments = Default::default(); - insta::assert_snapshot!(normalized_operation, @r###" - query NamedFragmentQuery { - foo { - id - bar - baz - } - } - "###); - } + type Foo { + id: ID! + bar: String! + baz: Int + } + "#; + let operation = r#" + query { + foo { + id + ...Bar + } + } + fragment Bar on Foo { + bar + baz + } + "#; + assert_normalized_equal!( + schema, + operation, + r###" + { + foo { + id + bar + baz + } + } + "### + ); } #[test] fn expands_and_deduplicates_fragments() { - let operation_with_named_fragment = r#" -query NestedFragmentQuery { - foo { - ...FirstFragment - ...SecondFragment - } -} - -fragment FirstFragment on Foo { - id - bar - baz -} + let schema = r#" + type Query { + foo: Foo + } -fragment SecondFragment on Foo { - id - bar -} + type Foo { + id: ID! + bar: String! + baz: String + } + "#; + let operation = r#" + query { + foo { + ...FirstFragment + ...SecondFragment + } + } -type Query { - foo: Foo -} + fragment FirstFragment on Foo { + id + bar + baz + } -type Foo { - id: ID! - bar: String! - baz: String -} -"#; - let (schema, mut executable_document) = - parse_schema_and_operation(operation_with_named_fragment); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let mut normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - normalized_operation.named_fragments = Default::default(); - insta::assert_snapshot!(normalized_operation, @r###" - query NestedFragmentQuery { - foo { - id - bar - baz - } - } - "###); - } + fragment SecondFragment on Foo { + id + bar + } + "#; + assert_normalized_equal!( + schema, + operation, + r###" + { + foo { + id + bar + baz + } + } + "### + ); } #[test] fn can_remove_introspection_selections() { let operation_with_introspection = r#" -query TestIntrospectionQuery { - __schema { - types { - name - } - } -} + query TestIntrospectionQuery { + __schema { + types { + name + } + } + } -type Query { - foo: String -} -"#; + type Query { + foo: String + } + "#; let (schema, mut executable_document) = parse_schema_and_operation(operation_with_introspection); if let Some(operation) = executable_document @@ -206,9 +214,10 @@ type Query { { let normalized_operation = normalize_operation( operation, - NamedFragments::new(&executable_document.fragments, &schema), + &executable_document.fragments, &schema, &IndexSet::default(), + &never_cancel, ) .unwrap(); @@ -218,794 +227,674 @@ type Query { #[test] fn merge_same_fields_without_directives() { - let operation_string = r#" -query Test { - t { - v1 - } - t { - v2 - } -} - -type Query { - t: T -} + let schema = r#" + type Query { + t: T + } -type T { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = parse_schema_and_operation(operation_string); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test { - t { - v1 - v2 - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } + type T { + v1: Int + v2: String + } + "#; + let operation = r#" + query { + t { + v1 + } + t { + v2 + } + } + "#; + assert_normalized_equal!( + schema, + operation, + r###" + { + t { + v1 + v2 + } + } + "### + ); } #[test] fn merge_same_fields_with_same_directive() { - let operation_with_directives = r#" -query Test($skipIf: Boolean!) { - t @skip(if: $skipIf) { - v1 - } - t @skip(if: $skipIf) { - v2 - } -} - -type Query { - t: T -} + let schema = r#" + type Query { + t: T + } -type T { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = parse_schema_and_operation(operation_with_directives); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test($skipIf: Boolean!) { - t @skip(if: $skipIf) { - v1 - v2 - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } + type T { + v1: Int + v2: String + } + "#; + let operation = r#" + query Test($skipIf: Boolean!) { + t @skip(if: $skipIf) { + v1 + } + t @skip(if: $skipIf) { + v2 + } + } + "#; + assert_normalized_equal!( + schema, + operation, + r###" + query Test($skipIf: Boolean!) { + t @skip(if: $skipIf) { + v1 + v2 + } + } + "### + ); } #[test] fn merge_same_fields_with_same_directive_but_different_arg_order() { - let operation_with_directives_different_arg_order = r#" -query Test($skipIf: Boolean!) { - t @customSkip(if: $skipIf, label: "foo") { - v1 - } - t @customSkip(label: "foo", if: $skipIf) { - v2 - } -} - -directive @customSkip(if: Boolean!, label: String!) on FIELD | INLINE_FRAGMENT + let schema = r#" + directive @customSkip(if: Boolean!, label: String!) on FIELD | INLINE_FRAGMENT -type Query { - t: T -} + type Query { + t: T + } -type T { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = - parse_schema_and_operation(operation_with_directives_different_arg_order); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test($skipIf: Boolean!) { - t @customSkip(if: $skipIf, label: "foo") { - v1 - v2 - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } + type T { + v1: Int + v2: String + } + "#; + let operation = r#" + query Test($skipIf: Boolean!) { + t @customSkip(if: $skipIf, label: "foo") { + v1 + } + t @customSkip(label: "foo", if: $skipIf) { + v2 + } + } + "#; + assert_normalized_equal!( + schema, + operation, + r###" + query Test($skipIf: Boolean!) { + t @customSkip(if: $skipIf, label: "foo") { + v1 + v2 + } + } + "### + ); } #[test] fn do_not_merge_when_only_one_field_specifies_directive() { - let operation_one_field_with_directives = r#" -query Test($skipIf: Boolean!) { - t { - v1 - } - t @skip(if: $skipIf) { - v2 - } -} - -type Query { - t: T -} + let schema = r#" + type Query { + t: T + } -type T { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = - parse_schema_and_operation(operation_one_field_with_directives); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test($skipIf: Boolean!) { - t { - v1 - } - t @skip(if: $skipIf) { - v2 - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } + type T { + v1: Int + v2: String + } + "#; + let operation = r#" + query Test($skipIf: Boolean!) { + t { + v1 + } + t @skip(if: $skipIf) { + v2 + } + } + "#; + assert_normalized_equal!( + schema, + operation, + r###" + query Test($skipIf: Boolean!) { + t { + v1 + } + t @skip(if: $skipIf) { + v2 + } + } + "### + ); } #[test] fn do_not_merge_when_fields_have_different_directives() { - let operation_different_directives = r#" -query Test($skip1: Boolean!, $skip2: Boolean!) { - t @skip(if: $skip1) { - v1 - } - t @skip(if: $skip2) { - v2 - } -} + let schema = r#" + type Query { + t: T + } -type Query { - t: T -} - -type T { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = - parse_schema_and_operation(operation_different_directives); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test($skip1: Boolean!, $skip2: Boolean!) { - t @skip(if: $skip1) { - v1 - } - t @skip(if: $skip2) { - v2 - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } + type T { + v1: Int + v2: String + } + "#; + let operation = r#" + query Test($skip1: Boolean!, $skip2: Boolean!) { + t @skip(if: $skip1) { + v1 + } + t @skip(if: $skip2) { + v2 + } + } + "#; + assert_normalized_equal!( + schema, + operation, + r###" + query Test($skip1: Boolean!, $skip2: Boolean!) { + t @skip(if: $skip1) { + v1 + } + t @skip(if: $skip2) { + v2 + } + } + "### + ); } #[test] fn do_not_merge_fields_with_defer_directive() { - let operation_defer_fields = r#" -query Test { - t { - ... @defer { - v1 - } - } - t { - ... @defer { - v2 - } - } -} - -directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT - -type Query { - t: T -} + let schema = r#" + directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT -type T { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = parse_schema_and_operation(operation_defer_fields); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test { - t { - ... @defer { - v1 - } - ... @defer { - v2 - } - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } -} + type Query { + t: T + } -#[test] -fn merge_nested_field_selections() { - let nested_operation = r#" -query Test { - t { - t1 - ... @defer { - v { - v1 + type T { + v1: Int + v2: String } - } - } - t { - t1 - t2 - ... @defer { - v { - v2 + "#; + let operation = r#" + query Test { + t { + ... @defer { + v1 + } + } + t { + ... @defer { + v2 + } + } } - } - } + "#; + assert_normalized_equal!( + schema, + operation, + r###" + query Test { + t { + ... @defer { + v1 + } + ... @defer { + v2 + } + } + } + "### + ); } -directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT +#[test] +fn merge_nested_field_selections() { + let schema = r#" + directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT -type Query { - t: T -} + type Query { + t: T + } -type T { - t1: Int - t2: String - v: V -} + type T { + t1: Int + t2: String + v: V + } -type V { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = parse_schema_and_operation(nested_operation); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test { - t { - t1 - ... @defer { - v { - v1 + type V { + v1: Int + v2: String } - } - t2 - ... @defer { - v { - v2 + "#; + let nested_operation = r#" + query Test { + t { + t1 + ... @defer { + v { + v1 + } + } + } + t { + t1 + t2 + ... @defer { + v { + v2 + } + } + } } - } - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } + "#; + assert_normalized_equal!( + schema, + nested_operation, + r###" + query Test { + t { + t1 + ... @defer { + v { + v1 + } + } + t2 + ... @defer { + v { + v2 + } + } + } + } + "### + ); } // // inline fragments // - #[test] fn merge_same_fragment_without_directives() { - let operation_with_fragments = r#" -query Test { - t { - ... on T { - v1 - } - ... on T { - v2 - } - } -} - -type Query { - t: T -} + let schema = r#" + type Query { + t: T + } -type T { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = parse_schema_and_operation(operation_with_fragments); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test { - t { - v1 - v2 - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } + type T { + v1: Int + v2: String + } + "#; + let operation_with_fragments = r#" + query Test { + t { + ... on T { + v1 + } + ... on T { + v2 + } + } + } + "#; + assert_normalized_equal!( + schema, + operation_with_fragments, + r###" + query Test { + t { + v1 + v2 + } + } + "### + ); } #[test] fn merge_same_fragments_with_same_directives() { - let operation_fragments_with_directives = r#" -query Test($skipIf: Boolean!) { - t { - ... on T @skip(if: $skipIf) { - v1 - } - ... on T @skip(if: $skipIf) { - v2 - } - } -} - -type Query { - t: T -} + let schema = r#" + type Query { + t: T + } -type T { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = - parse_schema_and_operation(operation_fragments_with_directives); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test($skipIf: Boolean!) { - t { - ... on T @skip(if: $skipIf) { - v1 - v2 - } - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } + type T { + v1: Int + v2: String + } + "#; + let operation_fragments_with_directives = r#" + query Test($skipIf: Boolean!) { + t { + ... on T @skip(if: $skipIf) { + v1 + } + ... on T @skip(if: $skipIf) { + v2 + } + } + } + "#; + assert_normalized_equal!( + schema, + operation_fragments_with_directives, + r###" + query Test($skipIf: Boolean!) { + t { + ... on T @skip(if: $skipIf) { + v1 + v2 + } + } + } + "### + ); } #[test] fn merge_same_fragments_with_same_directive_but_different_arg_order() { - let operation_fragments_with_directives_args_order = r#" -query Test($skipIf: Boolean!) { - t { - ... on T @customSkip(if: $skipIf, label: "foo") { - v1 - } - ... on T @customSkip(label: "foo", if: $skipIf) { - v2 - } - } -} - -directive @customSkip(if: Boolean!, label: String!) on FIELD | INLINE_FRAGMENT + let schema = r#" + directive @customSkip(if: Boolean!, label: String!) on FIELD | INLINE_FRAGMENT -type Query { - t: T -} + type Query { + t: T + } -type T { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = - parse_schema_and_operation(operation_fragments_with_directives_args_order); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test($skipIf: Boolean!) { - t { - ... on T @customSkip(if: $skipIf, label: "foo") { - v1 - v2 - } - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } + type T { + v1: Int + v2: String + } + "#; + let operation_fragments_with_directives_args_order = r#" + query Test($skipIf: Boolean!) { + t { + ... on T @customSkip(if: $skipIf, label: "foo") { + v1 + } + ... on T @customSkip(label: "foo", if: $skipIf) { + v2 + } + } + } + "#; + assert_normalized_equal!( + schema, + operation_fragments_with_directives_args_order, + r###" + query Test($skipIf: Boolean!) { + t { + ... on T @customSkip(if: $skipIf, label: "foo") { + v1 + v2 + } + } + } + "### + ); } #[test] fn do_not_merge_when_only_one_fragment_specifies_directive() { - let operation_one_fragment_with_directive = r#" -query Test($skipIf: Boolean!) { - t { - ... on T { - v1 - } - ... on T @skip(if: $skipIf) { - v2 - } - } -} - -type Query { - t: T -} + let schema = r#" + type Query { + t: T + } -type T { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = - parse_schema_and_operation(operation_one_fragment_with_directive); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test($skipIf: Boolean!) { - t { - v1 - ... on T @skip(if: $skipIf) { - v2 - } - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } + type T { + v1: Int + v2: String + } + "#; + let operation_one_fragment_with_directive = r#" + query Test($skipIf: Boolean!) { + t { + ... on T { + v1 + } + ... on T @skip(if: $skipIf) { + v2 + } + } + } + "#; + assert_normalized_equal!( + schema, + operation_one_fragment_with_directive, + r###" + query Test($skipIf: Boolean!) { + t { + v1 + ... on T @skip(if: $skipIf) { + v2 + } + } + } + "### + ); } #[test] fn do_not_merge_when_fragments_have_different_directives() { - let operation_fragments_with_different_directive = r#" -query Test($skip1: Boolean!, $skip2: Boolean!) { - t { - ... on T @skip(if: $skip1) { - v1 - } - ... on T @skip(if: $skip2) { - v2 - } - } -} - -type Query { - t: T -} + let schema = r#" + type Query { + t: T + } -type T { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = - parse_schema_and_operation(operation_fragments_with_different_directive); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test($skip1: Boolean!, $skip2: Boolean!) { - t { - ... on T @skip(if: $skip1) { - v1 - } - ... on T @skip(if: $skip2) { - v2 - } - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } + type T { + v1: Int + v2: String + } + "#; + let operation_fragments_with_different_directive = r#" + query Test($skip1: Boolean!, $skip2: Boolean!) { + t { + ... on T @skip(if: $skip1) { + v1 + } + ... on T @skip(if: $skip2) { + v2 + } + } + } + "#; + assert_normalized_equal!( + schema, + operation_fragments_with_different_directive, + r###" + query Test($skip1: Boolean!, $skip2: Boolean!) { + t { + ... on T @skip(if: $skip1) { + v1 + } + ... on T @skip(if: $skip2) { + v2 + } + } + } + "### + ); } #[test] fn do_not_merge_fragments_with_defer_directive() { - let operation_fragments_with_defer = r#" -query Test { - t { - ... on T @defer { - v1 - } - ... on T @defer { - v2 - } - } -} - -directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + let schema = r#" + directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT -type Query { - t: T -} + type Query { + t: T + } -type T { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = - parse_schema_and_operation(operation_fragments_with_defer); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test { - t { - ... on T @defer { - v1 - } - ... on T @defer { - v2 - } - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } + type T { + v1: Int + v2: String + } + "#; + let operation_fragments_with_defer = r#" + query Test { + t { + ... on T @defer { + v1 + } + ... on T @defer { + v2 + } + } + } + "#; + assert_normalized_equal!( + schema, + operation_fragments_with_defer, + r###" + query Test { + t { + ... on T @defer { + v1 + } + ... on T @defer { + v2 + } + } + } + "### + ); } #[test] fn merge_nested_fragments() { - let operation_nested_fragments = r#" -query Test { - t { - ... on T { - t1 - } - ... on T { - v { - v1 - } - } - } - t { - ... on T { - t1 - t2 - } - ... on T { - v { - v2 + let schema = r#" + type Query { + t: T } - } - } -} -type Query { - t: T -} - -type T { - t1: Int - t2: String - v: V -} + type T { + t1: Int + t2: String + v: V + } -type V { - v1: Int - v2: String -} -"#; - let (schema, mut executable_document) = parse_schema_and_operation(operation_nested_fragments); - if let Some((_, operation)) = executable_document.operations.named.first_mut() { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query Test { - t { - t1 - v { - v1 - v2 - } - t2 - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } else { - panic!("unable to parse document") - } + type V { + v1: Int + v2: String + } + "#; + let operation_nested_fragments = r#" + query Test { + t { + ... on T { + t1 + } + ... on T { + v { + v1 + } + } + } + t { + ... on T { + t1 + t2 + } + ... on T { + v { + v2 + } + } + } + } + "#; + assert_normalized_equal!( + schema, + operation_nested_fragments, + r###" + query Test { + t { + t1 + v { + v1 + v2 + } + t2 + } + } + "### + ); } #[test] fn removes_sibling_typename() { - let operation_with_typename = r#" -query TestQuery { - foo { - __typename - v1 - v2 - } -} - -type Query { - foo: Foo -} - -type Foo { - v1: ID! - v2: String -} -"#; - let (schema, mut executable_document) = parse_schema_and_operation(operation_with_typename); - if let Some(operation) = executable_document.operations.named.get_mut("TestQuery") { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query TestQuery { - foo { - v1 - v2 - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } -} - -#[test] -fn keeps_typename_if_no_other_selection() { - let operation_with_single_typename = r#" -query TestQuery { - foo { - __typename - } -} - -type Query { - foo: Foo -} + let schema = r#" + type Query { + foo: Foo + } -type Foo { - v1: ID! - v2: String -} -"#; - let (schema, mut executable_document) = - parse_schema_and_operation(operation_with_single_typename); - if let Some(operation) = executable_document.operations.named.get_mut("TestQuery") { - let normalized_operation = normalize_operation( - operation, - NamedFragments::new(&executable_document.fragments, &schema), - &schema, - &IndexSet::default(), - ) - .unwrap(); - let expected = r#"query TestQuery { - foo { - __typename - } -}"#; - let actual = normalized_operation.to_string(); - assert_eq!(expected, actual); - } + type Foo { + v1: ID! + v2: String + } + "#; + let operation_with_typename = r#" + query TestQuery { + foo { + __typename + v1 + v2 + } + } + "#; + // The __typename selection is hidden (attached to its sibling). + assert_normalized!(schema, operation_with_typename, @r###" + query TestQuery { + foo { + v1 + v2 + } + } + "###); +} + +#[test] +fn keeps_typename_if_no_other_selection() { + let schema = r#" + type Query { + foo: Foo + } + + type Foo { + v1: ID! + v2: String + } + "#; + let operation_with_single_typename = r#" + query TestQuery { + foo { + __typename + } + } + "#; + // The __typename selection is kept because it's the only selection. + assert_normalized_equal!( + schema, + operation_with_single_typename, + r###" + query TestQuery { + foo { + __typename + } + } + "### + ); } #[test] @@ -1043,9 +932,10 @@ scalar FieldSet let normalized_operation = normalize_operation( operation, - NamedFragments::new(&executable_document.fragments, &schema), + &executable_document.fragments, &schema, &interface_objects, + &never_cancel, ) .unwrap(); let expected = r#"query TestQuery { @@ -1064,7 +954,7 @@ scalar FieldSet /// https://github.com/apollographql/federation-next/pull/290#discussion_r1587200664 #[test] fn converting_operation_types() { - let schema = apollo_compiler::Schema::parse_and_validate( + let schema = Schema::parse_and_validate( r#" interface Intf { intfField: Int @@ -1091,7 +981,7 @@ fn converting_operation_types() { .unwrap(); let schema = ValidFederationSchema::new(schema).unwrap(); insta::assert_snapshot!(Operation::parse( - schema.clone(), + schema, r#" { intf { @@ -1102,19 +992,14 @@ fn converting_operation_types() { fragment frag on HasA { intfField } "#, "operation.graphql", - None, ) .unwrap(), @r###" - fragment frag on HasA { - intfField - } - { intf { ... on HasA { a + intfField } - ...frag } } "###); @@ -1182,9 +1067,10 @@ mod make_selection_tests { let (schema, executable_document) = parse_schema_and_operation(SAMPLE_OPERATION_DOC); let normalized_operation = normalize_operation( executable_document.operations.get(None).unwrap(), - Default::default(), + &Default::default(), &schema, &Default::default(), + &never_cancel, ) .unwrap(); @@ -1200,7 +1086,7 @@ mod make_selection_tests { base_selection_set.type_position.clone(), selection.clone(), ); - Selection::from_element(base.element().unwrap(), Some(subselections)).unwrap() + Selection::from_element(base.element(), Some(subselections)).unwrap() }; let foo_with_a = clone_selection_at_path(foo, &[name!("a")]); @@ -1208,9 +1094,8 @@ mod make_selection_tests { let foo_with_c = clone_selection_at_path(foo, &[name!("c")]); let new_selection = SelectionSet::make_selection( &schema, - &foo.element().unwrap().parent_type_position(), + &foo.element().parent_type_position(), [foo_with_c, foo_with_b, foo_with_a].iter(), - /*named_fragments*/ &Default::default(), ) .unwrap(); // Make sure the ordering of c, b and a is preserved. @@ -1228,7 +1113,7 @@ mod lazy_map_tests { ss: &SelectionSet, pred: &impl Fn(&Selection) -> bool, ) -> Result { - ss.lazy_map(/*named_fragments*/ &Default::default(), |s| { + ss.lazy_map(|s| { if !pred(s) { return Ok(SelectionMapperReturn::None); } @@ -1281,9 +1166,10 @@ mod lazy_map_tests { let (schema, executable_document) = parse_schema_and_operation(SAMPLE_OPERATION_DOC); let normalized_operation = normalize_operation( executable_document.operations.get(None).unwrap(), - Default::default(), + &Default::default(), &schema, &Default::default(), + &never_cancel, ) .unwrap(); @@ -1315,14 +1201,14 @@ mod lazy_map_tests { ss: &SelectionSet, pred: &impl Fn(&Selection) -> bool, ) -> Result { - ss.lazy_map(/*named_fragments*/ &Default::default(), |s| { + ss.lazy_map(|s| { let to_add_typename = pred(s); let updated = s.map_selection_set(|ss| add_typename_if(ss, pred).map(Some))?; if !to_add_typename { return Ok(updated.into()); } - let parent_type_pos = s.element()?.parent_type_position(); + let parent_type_pos = s.element().parent_type_position(); // "__typename" field let field_element = Field::new_introspection_typename(s.schema(), &parent_type_pos, None); @@ -1339,9 +1225,10 @@ mod lazy_map_tests { let (schema, executable_document) = parse_schema_and_operation(SAMPLE_OPERATION_DOC); let normalized_operation = normalize_operation( executable_document.operations.get(None).unwrap(), - Default::default(), + &Default::default(), &schema, &Default::default(), + &never_cancel, ) .unwrap(); @@ -1398,9 +1285,7 @@ const ADD_AT_PATH_TEST_SCHEMA: &str = r#" #[test] fn add_at_path_merge_scalar_fields() { - let schema = - apollo_compiler::Schema::parse_and_validate(ADD_AT_PATH_TEST_SCHEMA, "schema.graphql") - .unwrap(); + let schema = Schema::parse_and_validate(ADD_AT_PATH_TEST_SCHEMA, "schema.graphql").unwrap(); let schema = ValidFederationSchema::new(schema).unwrap(); let mut selection_set = SelectionSet::empty( @@ -1427,9 +1312,7 @@ fn add_at_path_merge_scalar_fields() { #[test] fn add_at_path_merge_subselections() { - let schema = - apollo_compiler::Schema::parse_and_validate(ADD_AT_PATH_TEST_SCHEMA, "schema.graphql") - .unwrap(); + let schema = Schema::parse_and_validate(ADD_AT_PATH_TEST_SCHEMA, "schema.graphql").unwrap(); let schema = ValidFederationSchema::new(schema).unwrap(); let mut selection_set = SelectionSet::empty( @@ -1462,7 +1345,7 @@ fn add_at_path_merge_subselections() { &path_to_c, Some( &SelectionSet::parse( - schema.clone(), + schema, ObjectTypeDefinitionPosition::new(name!("C")).into(), "e(arg: 1)", ) @@ -1477,9 +1360,7 @@ fn add_at_path_merge_subselections() { #[test] fn add_at_path_collapses_unnecessary_fragments() { - let schema = - apollo_compiler::Schema::parse_and_validate(ADD_AT_PATH_TEST_SCHEMA, "schema.graphql") - .unwrap(); + let schema = Schema::parse_and_validate(ADD_AT_PATH_TEST_SCHEMA, "schema.graphql").unwrap(); let schema = ValidFederationSchema::new(schema).unwrap(); let mut selection_set = SelectionSet::empty( @@ -1548,14 +1429,14 @@ fn test_expand_all_fragments1() { "#; let (schema, executable_document) = parse_schema_and_operation(operation_with_named_fragment); if let Ok(operation) = executable_document.operations.get(None) { - let mut normalized_operation = normalize_operation( + let normalized_operation = normalize_operation( operation, - NamedFragments::new(&executable_document.fragments, &schema), + &executable_document.fragments, &schema, &IndexSet::default(), + &never_cancel, ) .unwrap(); - normalized_operation.named_fragments = Default::default(); insta::assert_snapshot!(normalized_operation, @r###" { i1 { @@ -1603,7 +1484,7 @@ fn used_variables() { "#; let valid = parse_schema(schema); - let operation = Operation::parse(valid, query, "used_variables.graphql", None).unwrap(); + let operation = Operation::parse(valid, query, "used_variables.graphql").unwrap(); let mut variables = operation .selection_set @@ -1672,10 +1553,6 @@ fn directive_propagation() { ) .expect("directive applications to be valid"); insta::assert_snapshot!(query, @r###" - fragment DirectiveOnDef on T @fragDefOnly @fragAll { - a - } - { t2 { ... on T @fragInlineOnly @fragAll { @@ -1704,3 +1581,398 @@ fn directive_propagation() { .expect_err("directive @fragSpreadOnly to be rejected"); insta::assert_snapshot!(err, @"Unsupported custom directive @fragSpreadOnly on fragment spread. Due to query transformations during planning, the router requires directives on fragment spreads to support both the FRAGMENT_SPREAD and INLINE_FRAGMENT locations."); } + +#[test] +fn handles_fragment_matching_at_the_top_level_of_another_fragment() { + let schema_doc = r#" + type Query { + t: T + } + + type T { + a: String + u: U + } + + type U { + x: String + y: String + } + "#; + + let query = r#" + fragment Frag1 on T { + a + } + + fragment Frag2 on T { + u { + x + y + } + ...Frag1 + } + + fragment Frag3 on Query { + t { + ...Frag2 + } + } + + { + ...Frag3 + } + "#; + + assert_normalized_equal!( + schema_doc, + query, + r###" + { + t { + u { + x + y + } + a + } + } + "### + ); +} + +#[test] +fn handles_fragments_used_in_context_where_they_get_trimmed() { + let schema_doc = r#" + type Query { + t1: T1 + } + + interface I { + x: Int + } + + type T1 implements I { + x: Int + y: Int + } + + type T2 implements I { + x: Int + z: Int + } + "#; + + let query = r#" + fragment FragOnI on I { + ... on T1 { + y + } + ... on T2 { + z + } + } + + { + t1 { + ...FragOnI + } + } + "#; + + assert_normalized_equal!( + schema_doc, + query, + r###" + { + t1 { + y + } + } + "### + ); +} + +#[test] +fn handles_fragments_on_union_in_context_with_limited_intersection() { + let schema_doc = r#" + type Query { + t1: T1 + } + + union U = T1 | T2 + + type T1 { + x: Int + } + + type T2 { + y: Int + } + "#; + + let query = r#" + fragment OnU on U { + ... on T1 { + x + } + ... on T2 { + y + } + } + + { + t1 { + ...OnU + } + } + "#; + + assert_normalized_equal!( + schema_doc, + query, + r###" + { + t1 { + x + } + } + "### + ); +} + +#[test] +fn off_by_1_error() { + let schema = r#" + type Query { + t: T + } + type T { + id: String! + a: A + v: V + } + type A { + id: String! + } + type V { + t: T! + } + "#; + + let query = r#" + { + t { + ...TFrag + v { + t { + id + a { + __typename + id + } + } + } + } + } + + fragment TFrag on T { + __typename + id + } + "#; + + // The __typename selections are hidden (attached to their siblings). + assert_normalized!(schema, query,@r###" + { + t { + id + v { + t { + id + a { + id + } + } + } + } + } + "### + ); +} + +/// +/// applied directives +/// + +#[test] +fn fragments_with_same_directive_in_the_fragment_selection() { + let schema_doc = r#" + type Query { + t1: T + t2: T + t3: T + } + + type T { + a: Int + b: Int + c: Int + d: Int + } + "#; + + let query = r#" + fragment DirectiveInDef on T { + a @include(if: $cond1) + } + + query ($cond1: Boolean!, $cond2: Boolean!) { + t1 { + a + } + t2 { + ...DirectiveInDef + } + t3 { + a @include(if: $cond2) + } + } + "#; + + assert_normalized_equal!( + schema_doc, + query, + r###" + query($cond1: Boolean!, $cond2: Boolean!) { + t1 { + a + } + t2 { + a @include(if: $cond1) + } + t3 { + a @include(if: $cond2) + } + } + "### + ); +} + +#[test] +fn fragments_with_directive_on_typename() { + let schema = r#" + type Query { + t1: T + t2: T + t3: T + } + + type T { + a: Int + b: Int + c: Int + d: Int + } + "#; + let query = r#" + query ($if: Boolean!) { + t1 { b a ...x } + t2 { ...x } + } + fragment x on T { + __typename @include(if: $if) + a + c + } + "#; + + // The __typename selections are kept since they have directive applications. + assert_normalized_equal!( + schema, + query, + r###" + query($if: Boolean!) { + t1 { + b + a + __typename @include(if: $if) + c + } + t2 { + __typename @include(if: $if) + a + c + } + } + "### + ); +} + +#[test] +fn fragments_with_non_intersecting_types() { + let schema = r#" + type Query { + t: T + s: S + i: I + } + + interface I { + a: Int + b: Int + } + + type T implements I { + a: Int + b: Int + + c: Int + d: Int + } + type S implements I { + a: Int + b: Int + + f: Int + g: Int + } + "#; + let query = r#" + query ($if: Boolean!) { + t { ...x } + s { ...x } + i { ...x } + } + fragment x on I { + __typename + a + b + ... on T { c d @include(if: $if) } + } + "#; + + // The __typename selection is hidden (attached to its sibling). + assert_normalized!(schema, query, @r###" + query($if: Boolean!) { + t { + a + b + c + d @include(if: $if) + } + s { + a + b + } + i { + a + b + ... on T { + c + d @include(if: $if) + } + } + } + "###); +} diff --git a/apollo-federation/src/query_graph/build_query_graph.rs b/apollo-federation/src/query_graph/build_query_graph.rs index fa8da0cec8..bb142fb0f7 100644 --- a/apollo-federation/src/query_graph/build_query_graph.rs +++ b/apollo-federation/src/query_graph/build_query_graph.rs @@ -1,38 +1,43 @@ use std::sync::Arc; -use apollo_compiler::collections::HashMap; +use apollo_compiler::Name; +use apollo_compiler::Schema; use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use apollo_compiler::schema::DirectiveList as ComponentDirectiveList; use apollo_compiler::schema::ExtendedType; use apollo_compiler::validation::Valid; -use apollo_compiler::Name; -use apollo_compiler::Schema; +use itertools::Itertools; +use petgraph::Direction; use petgraph::graph::EdgeIndex; use petgraph::graph::NodeIndex; use petgraph::visit::EdgeRef; -use petgraph::Direction; use strum::IntoEnumIterator; +use crate::bail; use crate::error::FederationError; use crate::error::SingleFederationError; -use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; use crate::link::federation_spec_definition::FederationSpecDefinition; use crate::link::federation_spec_definition::KeyDirectiveArguments; -use crate::operation::merge_selection_sets; +use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; use crate::operation::Selection; use crate::operation::SelectionSet; +use crate::operation::merge_selection_sets; +use crate::query_graph::ContextCondition; use crate::query_graph::OverrideCondition; use crate::query_graph::QueryGraph; use crate::query_graph::QueryGraphEdge; use crate::query_graph::QueryGraphEdgeTransition; use crate::query_graph::QueryGraphNode; use crate::query_graph::QueryGraphNodeType; +use crate::query_plan::query_planning_traversal::non_local_selections_estimation::precompute_non_local_selection_metadata; +use crate::schema::ValidFederationSchema; use crate::schema::field_set::parse_field_set; use crate::schema::position::AbstractTypeDefinitionPosition; use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::FieldDefinitionPosition; use crate::schema::position::InterfaceTypeDefinitionPosition; +use crate::schema::position::ObjectFieldArgumentDefinitionPosition; use crate::schema::position::ObjectFieldDefinitionPosition; use crate::schema::position::ObjectOrInterfaceTypeDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; @@ -41,8 +46,10 @@ use crate::schema::position::SchemaRootDefinitionKind; use crate::schema::position::SchemaRootDefinitionPosition; use crate::schema::position::TypeDefinitionPosition; use crate::schema::position::UnionTypeDefinitionPosition; -use crate::schema::ValidFederationSchema; +use crate::schema::validators::from_context::parse_context; use crate::supergraph::extract_subgraphs_from_supergraph; +use crate::utils::FallibleIterator; +use crate::validate_supergraph_for_query_planning; /// Builds a "federated" query graph based on the provided supergraph and API schema. /// @@ -59,39 +66,85 @@ pub fn build_federated_query_graph( for_query_planning: Option, ) -> Result { let for_query_planning = for_query_planning.unwrap_or(true); - let mut query_graph = QueryGraph { + let query_graph = QueryGraph { // Note this name is a dummy initial name that gets overridden as we build the query graph. current_source: "".into(), graph: Default::default(), sources: Default::default(), subgraphs_by_name: Default::default(), + supergraph_schema: Default::default(), types_to_nodes_by_source: Default::default(), root_kinds_to_nodes_by_source: Default::default(), non_trivial_followup_edges: Default::default(), + arguments_to_context_ids_by_source: Default::default(), + override_condition_labels: Default::default(), + non_local_selection_metadata: Default::default(), }; - let subgraphs = - extract_subgraphs_from_supergraph(&supergraph_schema, validate_extracted_subgraphs)?; - for (subgraph_name, subgraph) in subgraphs { - let builder = SchemaQueryGraphBuilder::new( - query_graph, - subgraph_name, - subgraph.schema, - Some(api_schema.clone()), - for_query_planning, - )?; - query_graph = builder.build()?; + let query_graph = + extract_subgraphs_from_supergraph(&supergraph_schema, validate_extracted_subgraphs)? + .into_iter() + .fallible_fold(query_graph, |query_graph, (subgraph_name, subgraph)| { + SchemaQueryGraphBuilder::new( + query_graph, + subgraph_name, + subgraph.schema, + Some(api_schema.clone()), + for_query_planning, + Default::default(), + )? + .build() + })?; + FederatedQueryGraphBuilder::new(query_graph, supergraph_schema)?.build() +} + +// PORT_NOTE: Corresponds to `buildSupergraphAPIQueryGraph` from JS. +/// Builds a "supergraph API" query graph based on the provided supergraph schema. +/// +/// A "supergraph API" query graph is one that is used to reason about queries against said +/// supergraph API. +/// +/// * schema: the schema of the supergraph for which to build the query graph. +/// The provided schema should generally be a "supergraph" as generated by composition merging. +/// Note however that the query graph built by this method is only based on the supergraph API +/// and doesn't rely on the join spec directives, so it is valid to also directly pass a schema +/// that directly corresponds to the supergraph API. +pub fn build_supergraph_api_query_graph( + supergraph_schema: ValidFederationSchema, + api_schema: ValidFederationSchema, +) -> Result { + let (_, join_spec, _) = validate_supergraph_for_query_planning(&supergraph_schema)?; + let join_field_definition = join_spec.field_directive_definition(&supergraph_schema)?; + let join_field_applications = supergraph_schema + .referencers() + .get_directive_applications(&supergraph_schema, &join_field_definition.name)?; + + let mut override_labels_by_field = IndexMap::default(); + for (pos, node) in join_field_applications { + let Ok(pos) = FieldDefinitionPosition::try_from(pos.clone()) else { + // @join__field can also appear on input object fields, which can't be overridden, so we + // just skip those fields here. + continue; + }; + let args = join_spec.field_directive_arguments(node)?; + if let Some(override_label) = args.override_label { + override_labels_by_field.insert(pos, Arc::from(override_label)); + } } - let federated_builder = FederatedQueryGraphBuilder::new(query_graph, supergraph_schema)?; - query_graph = federated_builder.build()?; - Ok(query_graph) + + build_query_graph("supergraph".into(), api_schema, override_labels_by_field) } /// Builds a query graph based on the provided schema (usually an API schema outside of testing). /// /// Assumes the given schemas have been validated. -pub fn build_query_graph( +/// * override_labels_by_field: Only used for API schema graph during satisfiability validation. +/// Should be empty(`Default::default`) for federated query graphs, since it will be handled by +/// the `handle_progressive_overrides` method. +/// PORT_NOTE: It corresponds to the `overrideLabelsByCoordinate` in JS. +pub(crate) fn build_query_graph( name: Arc, schema: ValidFederationSchema, + override_labels_by_field: IndexMap>, ) -> Result { let mut query_graph = QueryGraph { // Note this name is a dummy initial name that gets overridden as we build the query graph. @@ -99,11 +152,22 @@ pub fn build_query_graph( graph: Default::default(), sources: Default::default(), subgraphs_by_name: Default::default(), + supergraph_schema: Default::default(), types_to_nodes_by_source: Default::default(), root_kinds_to_nodes_by_source: Default::default(), non_trivial_followup_edges: Default::default(), + arguments_to_context_ids_by_source: Default::default(), + override_condition_labels: Default::default(), + non_local_selection_metadata: Default::default(), }; - let builder = SchemaQueryGraphBuilder::new(query_graph, name, schema, None, false)?; + let builder = SchemaQueryGraphBuilder::new( + query_graph, + name, + schema, + None, + false, + override_labels_by_field, + )?; query_graph = builder.build()?; Ok(query_graph) } @@ -121,7 +185,7 @@ impl BaseQueryGraphBuilder { .insert(source.clone(), IndexMap::default()); query_graph .root_kinds_to_nodes_by_source - .insert(source.clone(), IndexMap::default()); + .insert(source, IndexMap::default()); Self { query_graph } } @@ -135,15 +199,12 @@ impl BaseQueryGraphBuilder { tail: NodeIndex, transition: QueryGraphEdgeTransition, conditions: Option>, + override_condition: Option, ) -> Result<(), FederationError> { self.query_graph.graph.add_edge( head, tail, - QueryGraphEdge { - transition, - conditions, - override_condition: None, - }, + QueryGraphEdge::new(transition, conditions, override_condition), ); let head_weight = self.query_graph.node_weight(head)?; let tail_weight = self.query_graph.node_weight(tail)?; @@ -226,12 +287,92 @@ impl BaseQueryGraphBuilder { root_kinds_to_nodes.insert(root_kind, node); Ok(()) } + + /// Precompute which followup edges for a given edge are non-trivial. + fn precompute_non_trivial_followup_edges(&mut self) -> Result<(), FederationError> { + for edge in self.query_graph.graph.edge_indices() { + let edge_weight = self.query_graph.edge_weight(edge)?; + let (_, tail) = self.query_graph.edge_endpoints(edge)?; + let out_edges = self.query_graph.out_edges(tail); + let mut non_trivial_followups = Vec::with_capacity(out_edges.len()); + for followup_edge_ref in out_edges { + let followup_edge_weight = followup_edge_ref.weight(); + match edge_weight.transition { + QueryGraphEdgeTransition::KeyResolution => { + // After taking a key from subgraph A to B, there is no point of following + // that up with another key to subgraph C if that key has the same + // conditions. This is because, due to the way key edges are created, if we + // have a key (with some conditions X) from B to C, then we are guaranteed + // to also have a key (with the same conditions X) from A to C, and so it's + // that later key we should be using in the first place. In other words, + // it's never better to do 2 hops rather than 1. + if matches!( + followup_edge_weight.transition, + QueryGraphEdgeTransition::KeyResolution + ) { + let Some(conditions) = &edge_weight.conditions else { + return Err(SingleFederationError::Internal { + message: "Key resolution edge unexpectedly missing conditions" + .to_owned(), + } + .into()); + }; + let Some(followup_conditions) = &followup_edge_weight.conditions else { + return Err(SingleFederationError::Internal { + message: "Key resolution edge unexpectedly missing conditions" + .to_owned(), + } + .into()); + }; + + if conditions == followup_conditions { + continue; + } + } + } + QueryGraphEdgeTransition::RootTypeResolution { .. } => { + // A 'RootTypeResolution' means that a query reached the query type (or + // another root type) in some subgraph A and we're looking at jumping to + // another subgraph B. But like for keys, there is no point in trying to + // jump directly to yet another subpraph C from B, since we can always jump + // directly from A to C and it's better. + if matches!( + followup_edge_weight.transition, + QueryGraphEdgeTransition::RootTypeResolution { .. } + ) { + continue; + } + } + QueryGraphEdgeTransition::SubgraphEnteringTransition => { + // This is somewhat similar to 'RootTypeResolution' except that we're + // starting the query. Still, we shouldn't do "start of query" -> B -> C, + // since we can do "start of query" -> C and that's always better. + if matches!( + followup_edge_weight.transition, + QueryGraphEdgeTransition::RootTypeResolution { .. } + ) { + continue; + } + } + _ => {} + } + non_trivial_followups.push(followup_edge_ref.id()); + } + self.query_graph + .non_trivial_followup_edges + .insert(edge, non_trivial_followups); + } + Ok(()) + } } struct SchemaQueryGraphBuilder { base: BaseQueryGraphBuilder, subgraph: Option, for_query_planning: bool, + /// Used for API schema query graph during satisfiability validation. + /// PORT_NOTE: It corresponds to the `overrideLabelsByCoordinate` in JS. + override_labels_by_field: IndexMap>, } struct SchemaQueryGraphBuilderSubgraphData { @@ -248,6 +389,7 @@ impl SchemaQueryGraphBuilder { schema: ValidFederationSchema, api_schema: Option, for_query_planning: bool, + override_labels_by_field: IndexMap>, ) -> Result { let subgraph = if let Some(api_schema) = api_schema { let federation_spec_definition = get_federation_spec_definition_from_subgraph(&schema)?; @@ -263,6 +405,7 @@ impl SchemaQueryGraphBuilder { base, subgraph, for_query_planning, + override_labels_by_field, }) } @@ -284,6 +427,8 @@ impl SchemaQueryGraphBuilder { if self.for_query_planning { self.add_additional_abstract_type_edges()?; } + // This method adds no nodes/edges, but just precomputes followup edge information. + self.base.precompute_non_trivial_followup_edges()?; Ok(self.base.build()) } @@ -340,21 +485,21 @@ impl SchemaQueryGraphBuilder { output_type_definition_position: OutputTypeDefinitionPosition, ) -> Result { let type_name = output_type_definition_position.type_name().clone(); - if let Some(existing) = self.base.query_graph.types_to_nodes()?.get(&type_name) { - if let Some(first_node) = existing.first() { - return if existing.len() == 1 { - Ok(*first_node) - } else { - Err(SingleFederationError::Internal { - message: format!( - "Only one node should have been created for type \"{}\", got {}", - type_name, - existing.len(), - ), - } - .into()) - }; - } + if let Some(existing) = self.base.query_graph.types_to_nodes()?.get(&type_name) + && let Some(first_node) = existing.first() + { + return if existing.len() == 1 { + Ok(*first_node) + } else { + Err(SingleFederationError::Internal { + message: format!( + "Only one node should have been created for type \"{}\", got {}", + type_name, + existing.len(), + ), + } + .into()) + }; } let node = self .base @@ -375,12 +520,12 @@ impl SchemaQueryGraphBuilder { if self.subgraph.is_some() { self.maybe_add_interface_fields_edges(pos.clone(), node)?; } - self.add_abstract_type_edges(pos.clone().into(), node)?; + self.add_abstract_type_edges(pos.into(), node)?; } OutputTypeDefinitionPosition::Union(pos) => { // Add the special-case __typename edge for unions. self.add_edge_for_field(pos.introspection_typename_field().into(), node, false)?; - self.add_abstract_type_edges(pos.clone().into(), node)?; + self.add_abstract_type_edges(pos.into(), node)?; } // Any other case (scalar or enum; input objects are not possible here) is terminal and // has no edges to consider. @@ -466,10 +611,36 @@ impl SchemaQueryGraphBuilder { if !skip_edge { let transition = QueryGraphEdgeTransition::FieldCollection { source: self.base.query_graph.current_source.clone(), - field_definition_position, + field_definition_position: field_definition_position.clone(), is_part_of_provides: false, }; - self.base.add_edge(head, tail, transition, None)?; + if let Some(override_label) = self + .override_labels_by_field + .get(&field_definition_position) + { + self.base.add_edge( + head, + tail, + transition.clone(), + None, + Some(OverrideCondition { + label: override_label.clone(), + condition: true, + }), + )?; + self.base.add_edge( + head, + tail, + transition, + None, + Some(OverrideCondition { + label: override_label.clone(), + condition: false, + }), + )?; + } else { + self.base.add_edge(head, tail, transition, None, None)?; + } } Ok(()) } @@ -610,7 +781,7 @@ impl SchemaQueryGraphBuilder { from_type_position: abstract_type_definition_position.clone().into(), to_type_position: pos.into(), }; - self.base.add_edge(head, tail, transition, None)?; + self.base.add_edge(head, tail, transition, None, None)?; } Ok(()) } @@ -719,8 +890,7 @@ impl SchemaQueryGraphBuilder { _ => { return Err(SingleFederationError::Internal { message: format!( - "Type \"{}\" was abstract in subgraph but not in API schema", - type_name, + "Type \"{type_name}\" was abstract in subgraph but not in API schema", ), } .into()); @@ -850,7 +1020,8 @@ impl SchemaQueryGraphBuilder { from_type_position: t1.abstract_type_definition_position.clone().into(), to_type_position: t2.abstract_type_definition_position.clone().into(), }; - self.base.add_edge(t1_node, t2_node, transition, None)?; + self.base + .add_edge(t1_node, t2_node, transition, None, None)?; } if add_t2_to_t1 { let transition = QueryGraphEdgeTransition::Downcast { @@ -858,7 +1029,8 @@ impl SchemaQueryGraphBuilder { from_type_position: t2.abstract_type_definition_position.clone().into(), to_type_position: t1.abstract_type_definition_position.clone().into(), }; - self.base.add_edge(t2_node, t1_node, transition, None)?; + self.base + .add_edge(t2_node, t1_node, transition, None, None)?; } } } @@ -938,8 +1110,13 @@ impl SchemaQueryGraphBuilder { .into(), to_type_position: interface_type_definition_position.into(), }; - self.base - .add_edge(entity_type_node, interface_type_node, transition, None)?; + self.base.add_edge( + entity_type_node, + interface_type_node, + transition, + None, + None, + )?; } Ok(()) @@ -960,9 +1137,10 @@ struct FederatedQueryGraphBuilder { impl FederatedQueryGraphBuilder { fn new( - query_graph: QueryGraph, + mut query_graph: QueryGraph, supergraph_schema: ValidFederationSchema, ) -> Result { + query_graph.supergraph_schema = Some(supergraph_schema.clone()); let base = BaseQueryGraphBuilder::new( query_graph, FEDERATED_GRAPH_ROOT_SOURCE.into(), @@ -970,6 +1148,7 @@ impl FederatedQueryGraphBuilder { // here (note that empty schemas have no Query type, making them invalid GraphQL). ValidFederationSchema::new(Valid::assume_valid(Schema::new()))?, ); + let subgraphs = FederatedQueryGraphBuilderSubgraphs::new(&base)?; Ok(FederatedQueryGraphBuilder { base, @@ -986,6 +1165,7 @@ impl FederatedQueryGraphBuilder { self.handle_key()?; self.handle_requires()?; self.handle_progressive_overrides()?; + self.handle_context()?; // Note that @provides must be handled last when building since it requires copying nodes // and their edges, and it's easier to reason about this if we know previous self.handle_provides()?; @@ -994,7 +1174,11 @@ impl FederatedQueryGraphBuilder { // more details). self.handle_interface_object()?; // This method adds no nodes/edges, but just precomputes followup edge information. - self.precompute_non_trivial_followup_edges()?; + self.base.precompute_non_trivial_followup_edges()?; + // This method adds no nodes/edges, but just precomputes metadata for estimating the count + // of non_local_selections. + self.base.query_graph.non_local_selection_metadata = + precompute_non_local_selection_metadata(&self.base.query_graph)?; Ok(self.base.build()) } @@ -1011,15 +1195,14 @@ impl FederatedQueryGraphBuilder { } fn add_federated_root_nodes(&mut self) -> Result<(), FederationError> { - let mut root_kinds = IndexSet::default(); - for (source, root_kinds_to_nodes) in &self.base.query_graph.root_kinds_to_nodes_by_source { - if *source == self.base.query_graph.current_source { - continue; - } - for root_kind in root_kinds_to_nodes.keys() { - root_kinds.insert(*root_kind); - } - } + let root_kinds = self + .base + .query_graph + .root_kinds_to_nodes_by_source + .iter() + .filter(|(source, _)| **source != self.base.query_graph.current_source) + .flat_map(|(_, root_kind_to_nodes)| root_kind_to_nodes.keys().copied()) + .collect::>(); for root_kind in root_kinds { self.base.create_root_node(root_kind.into(), root_kind)?; } @@ -1117,8 +1300,7 @@ impl FederatedQueryGraphBuilder { .get(type_pos.type_name()) .ok_or_else(|| SingleFederationError::Internal { message: format!( - "Type \"{}\" unexpectedly missing from subgraph \"{}\"", - type_pos, source, + "Type \"{type_pos}\" unexpectedly missing from subgraph \"{source}\"", ), })? .directives(); @@ -1142,14 +1324,12 @@ impl FederatedQueryGraphBuilder { // allow a type to be an entity in some subgraphs but not others, this is not // the place to impose that restriction, and this may be at least temporarily // useful to allow convert a type to an entity). - let Ok(type_pos): Result = - type_pos.clone().try_into() + let Ok(type_pos) = + ObjectOrInterfaceTypeDefinitionPosition::try_from(type_pos.clone()) else { return Err(SingleFederationError::Internal { message: format!( - "Invalid \"@key\" application on non-object/interface type \"{}\" in subgraph \"{}\"", - type_pos, - source, + "Invalid \"@key\" application on non-object/interface type \"{type_pos}\" in subgraph \"{source}\"", ) }.into()); }; @@ -1157,6 +1337,7 @@ impl FederatedQueryGraphBuilder { schema, type_pos.type_name().clone(), application.fields, + true, )?); // Note that each subgraph has a key edge to itself (when head == tail below). @@ -1175,9 +1356,7 @@ impl FederatedQueryGraphBuilder { let head = other_nodes.first().ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Types-to-nodes set unexpectedly empty for type \"{}\" in subgraph \"{}\"", - type_pos, - other_source, + "Types-to-nodes set unexpectedly empty for type \"{type_pos}\" in subgraph \"{other_source}\"", ), } })?; @@ -1188,9 +1367,7 @@ impl FederatedQueryGraphBuilder { return Err( SingleFederationError::Internal { message: format!( - "Types-to-nodes set unexpectedly had more than one element for type \"{}\" in subgraph \"{}\"", - type_pos, - other_source, + "Types-to-nodes set unexpectedly had more than one element for type \"{type_pos}\" in subgraph \"{other_source}\"", ), } .into() @@ -1219,9 +1396,7 @@ impl FederatedQueryGraphBuilder { else { return Err(SingleFederationError::Internal { message: format!( - "Type \"{}\" was marked with \"@interfaceObject\" in subgraph \"{}\", but was non-interface in supergraph", - type_pos, - other_source, + "Type \"{type_pos}\" was marked with \"@interfaceObject\" in subgraph \"{other_source}\", but was non-interface in supergraph", ) }.into()); }; @@ -1246,9 +1421,7 @@ impl FederatedQueryGraphBuilder { let head = implementation_nodes.first().ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Types-to-nodes set unexpectedly empty for type \"{}\" in subgraph \"{}\"", - implementation_type_in_supergraph_pos, - other_source, + "Types-to-nodes set unexpectedly empty for type \"{implementation_type_in_supergraph_pos}\" in subgraph \"{other_source}\"", ), } })?; @@ -1256,9 +1429,7 @@ impl FederatedQueryGraphBuilder { return Err( SingleFederationError::Internal { message: format!( - "Types-to-nodes set unexpectedly had more than one element for type \"{}\" in subgraph \"{}\"", - implementation_type_in_supergraph_pos, - other_source, + "Types-to-nodes set unexpectedly had more than one element for type \"{implementation_type_in_supergraph_pos}\" in subgraph \"{other_source}\"", ), }.into() ); @@ -1282,6 +1453,7 @@ impl FederatedQueryGraphBuilder { .type_name() .clone(), application.fields, + true, ) else { // Ignored on purpose: it just means the key is not usable on this // subgraph. @@ -1326,8 +1498,7 @@ impl FederatedQueryGraphBuilder { if edge_weight.conditions.is_some() { return Err(SingleFederationError::Internal { message: format!( - "Field-collection edge for field \"{}\" unexpectedly had conditions", - field_definition_position, + "Field-collection edge for field \"{field_definition_position}\" unexpectedly had conditions", ), } .into()); @@ -1348,6 +1519,7 @@ impl FederatedQueryGraphBuilder { &self.supergraph_schema, field_definition_position.parent().type_name().clone(), application.fields, + true, )?; all_conditions.push(conditions); } @@ -1385,15 +1557,16 @@ impl FederatedQueryGraphBuilder { /// override condition of `false`, whereas the "to" subgraph will have an /// override condition of `true`. fn handle_progressive_overrides(&mut self) -> Result<(), FederationError> { - let mut edge_to_conditions: HashMap = Default::default(); + let mut edge_to_conditions: IndexMap = Default::default(); + let mut override_condition_labels: IndexSet> = Default::default(); fn collect_edge_condition( query_graph: &QueryGraph, target_graph: &str, target_field: &ObjectFieldDefinitionPosition, - label: &str, + label: &Arc, condition: bool, - edge_to_conditions: &mut HashMap, + edge_to_conditions: &mut IndexMap, ) -> Result<(), FederationError> { let target_field = FieldDefinitionPosition::Object(target_field.clone()); let subgraph_nodes = query_graph @@ -1419,7 +1592,7 @@ impl FederatedQueryGraphBuilder { edge_to_conditions.insert( edge.id(), OverrideCondition { - label: label.to_string(), + label: label.clone(), condition, }, ); @@ -1445,6 +1618,10 @@ impl FederatedQueryGraphBuilder { .federation_spec_definition .override_directive_arguments(directive)?; if let Some(label) = application.label { + if !override_condition_labels.contains(label) { + override_condition_labels.insert(label.into()); + } + let label = override_condition_labels.get(label).unwrap(); collect_edge_condition( &self.base.query_graph, to_subgraph_name, @@ -1471,6 +1648,196 @@ impl FederatedQueryGraphBuilder { let mutable_edge = self.base.query_graph.edge_weight_mut(edge)?; mutable_edge.override_condition = Some(condition); } + self.base.query_graph.override_condition_labels = override_condition_labels; + + Ok(()) + } + + fn handle_context(&mut self) -> Result<(), FederationError> { + let mut subgraph_to_args: IndexMap, Vec> = + Default::default(); + let mut coordinate_map: IndexMap< + Arc, + IndexMap>, + > = Default::default(); + for (subgraph_name, subgraph) in self.base.query_graph.subgraphs() { + let subgraph_data = self.subgraphs.get(subgraph_name)?; + let Some(context_refs) = &subgraph + .referencers() + .directives + .get(&subgraph_data.context_directive_definition_name) + else { + continue; + }; + let Some(from_context_refs) = &subgraph + .referencers() + .directives + .get(&subgraph_data.from_context_directive_definition_name) + else { + continue; + }; + + // Collect data for @context + let mut context_name_to_types: IndexMap< + &str, + IndexSet, + > = Default::default(); + for object_def_pos in &context_refs.object_types { + let object = object_def_pos.get(subgraph.schema())?; + for dir in object + .directives + .get_all(subgraph_data.context_directive_definition_name.as_str()) + { + let application = subgraph_data + .federation_spec_definition + .context_directive_arguments(dir)?; + context_name_to_types + .entry(application.name) + .or_default() + .insert(object_def_pos.clone().into()); + } + } + for interface_def_pos in &context_refs.interface_types { + let interface = interface_def_pos.get(subgraph.schema())?; + for dir in interface + .directives + .get_all(subgraph_data.context_directive_definition_name.as_str()) + { + let application = subgraph_data + .federation_spec_definition + .context_directive_arguments(dir)?; + context_name_to_types + .entry(application.name) + .or_default() + .insert(interface_def_pos.clone().into()); + } + } + for union_def_pos in &context_refs.union_types { + let union = union_def_pos.get(subgraph.schema())?; + for dir in union + .directives + .get_all(subgraph_data.context_directive_definition_name.as_str()) + { + let application = subgraph_data + .federation_spec_definition + .context_directive_arguments(dir)?; + context_name_to_types + .entry(application.name) + .or_default() + .insert(union_def_pos.clone().into()); + } + } + + // Collect data for @fromContext + let coordinate_map = coordinate_map.entry(subgraph_name.clone()).or_default(); + for object_field_arg in &from_context_refs.object_field_arguments { + let input_value = object_field_arg.get(subgraph.schema())?; + subgraph_to_args + .entry(subgraph_name.clone()) + .or_default() + .push(object_field_arg.clone()); + let field_coordinate = object_field_arg.parent(); + let Some(dir) = input_value.directives.get( + subgraph_data + .from_context_directive_definition_name + .as_str(), + ) else { + bail!( + "Argument {} unexpectedly missing @fromContext directive", + object_field_arg + ); + }; + let application = subgraph_data + .federation_spec_definition + .from_context_directive_arguments(dir)?; + + // if parse_context returns None, assume that the @fromContext validator will return the actual error + // it isn't necessary to throw here, we can just ignore it + let (Some(context), Some(selection)) = parse_context(application.field) else { + continue; + }; + let Some(types_with_context_set) = context_name_to_types.get(context.as_str()) + else { + continue; + }; + let conditions = ContextCondition { + context, + subgraph_name: subgraph_name.clone(), + selection, + types_with_context_set: types_with_context_set.clone(), + argument_name: object_field_arg.argument_name.to_owned(), + argument_coordinate: object_field_arg.clone(), + argument_type: input_value.ty.clone(), + }; + coordinate_map + .entry(field_coordinate.clone()) + .or_default() + .push(conditions); + } + } + + for edge in self.base.query_graph.graph.edge_indices() { + let edge_weight = self.base.query_graph.edge_weight(edge)?; + let QueryGraphEdgeTransition::FieldCollection { + source, + field_definition_position, + .. + } = &edge_weight.transition + else { + continue; + }; + let FieldDefinitionPosition::Object(obj_field) = field_definition_position else { + continue; + }; + let Some(contexts) = coordinate_map.get_mut(source) else { + continue; + }; + let Some(required_contexts) = contexts.get(obj_field) else { + continue; + }; + self.base + .query_graph + .edge_weight_mut(edge)? + .required_contexts + .extend_from_slice(required_contexts); + } + + // Add the context argument mapping + self.base.query_graph.arguments_to_context_ids_by_source = self + .base + .query_graph + .subgraphs() + .enumerate() + .filter_map(|(index, (source, _))| { + subgraph_to_args + .get_key_value(source) + .map(|(source, args)| (index, source, args)) + }) + .map(|(index, source, args)| { + Ok::<_, FederationError>(( + source.clone(), + args.iter() + // TODO: We're manually sorting by the actual GraphQL coordinate string here + // to mimic the behavior of JS code. In the future, we could just sort + // the argument position in the natural tuple-based way. + .sorted_by_key(|arg| { + format!( + "{}.{}({}:)", + arg.type_name, arg.field_name, arg.argument_name + ) + }) + .enumerate() + .map(|(i, arg)| { + Ok::<_, FederationError>(( + arg.clone(), + format!("contextualArgument_{}_{}", index + 1, i).try_into()?, + )) + }) + .process_results(|r| r.collect())?, + )) + }) + .process_results(|r| r.collect())?; + Ok(()) } @@ -1509,6 +1876,7 @@ impl FederatedQueryGraphBuilder { schema, field_type_pos.type_name().clone(), application.fields, + true, )?; all_conditions.push(conditions); } @@ -1626,8 +1994,7 @@ impl FederatedQueryGraphBuilder { .get(tail_type) .ok_or_else(|| SingleFederationError::Internal { message: format!( - "Types-to-nodes map missing type \"{}\" in subgraph \"{}\"", - tail_type, source, + "Types-to-nodes map missing type \"{tail_type}\" in subgraph \"{source}\"", ), })?; // Note because this is an IndexSet, the non-provides should be first @@ -1646,9 +2013,7 @@ impl FederatedQueryGraphBuilder { return Err( SingleFederationError::Internal { message: format!( - "Missing non-provides node for type \"{}\" in subgraph \"{}\"", - tail_type, - source, + "Missing non-provides node for type \"{tail_type}\" in subgraph \"{source}\"", ) }.into() ); @@ -1666,10 +2031,10 @@ impl FederatedQueryGraphBuilder { }; if let Some(selections) = &field_selection.selection_set { let new_tail = Self::copy_for_provides(base, tail, provide_id)?; - base.add_edge(node, new_tail, transition, None)?; + base.add_edge(node, new_tail, transition, None, None)?; stack.push((new_tail, selections)) } else { - base.add_edge(node, tail, transition, None)?; + base.add_edge(node, tail, transition, None, None)?; } } } @@ -1716,8 +2081,7 @@ impl FederatedQueryGraphBuilder { .ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Shouldn't have selection \"{}\" in an @provides, as its type condition has no query graph edge", - inline_fragment_selection, + "Shouldn't have selection \"{inline_fragment_selection}\" in an @provides, as its type condition has no query graph edge", ) } })?; @@ -1730,13 +2094,6 @@ impl FederatedQueryGraphBuilder { stack.push((node, &inline_fragment_selection.selection_set)); } } - Selection::FragmentSpread(_) => { - return Err(SingleFederationError::Internal { - message: "Unexpectedly found named fragment in FieldSet scalar" - .to_owned(), - } - .into()); - } } } } @@ -1782,8 +2139,7 @@ impl FederatedQueryGraphBuilder { .get_mut(type_pos.type_name()) .ok_or_else(|| SingleFederationError::Internal { message: format!( - "Unexpectedly missing @provides type \"{}\" in types-to-nodes map", - type_pos, + "Unexpectedly missing @provides type \"{type_pos}\" in types-to-nodes map", ), })? .insert(new_node); @@ -1892,8 +2248,7 @@ impl FederatedQueryGraphBuilder { .get(&type_pos.type_name) .ok_or_else(|| SingleFederationError::Internal { message: format!( - "Types-to-nodes map missing type \"{}\" in subgraph \"{}\"", - type_pos, source, + "Types-to-nodes map missing type \"{type_pos}\" in subgraph \"{source}\"", ), })?; // Note because this is an IndexSet, the non-provides should be first since it was @@ -1910,8 +2265,7 @@ impl FederatedQueryGraphBuilder { let Some(node) = node else { return Err(SingleFederationError::Internal { message: format!( - "Missing non-provides node for type \"{}\" in subgraph \"{}\"", - type_pos, source, + "Missing non-provides node for type \"{type_pos}\" in subgraph \"{source}\"", ), } .into()); @@ -1924,9 +2278,7 @@ impl FederatedQueryGraphBuilder { else { return Err(SingleFederationError::Internal { message: format!( - "Type \"{}\" was marked with \"@interfaceObject\" in subgraph \"{}\", but was non-interface in supergraph", - type_pos, - source, + "Type \"{type_pos}\" was marked with \"@interfaceObject\" in subgraph \"{source}\", but was non-interface in supergraph", ) }.into()); }; @@ -1934,6 +2286,10 @@ impl FederatedQueryGraphBuilder { schema, type_in_supergraph_pos.type_name.clone(), "__typename", + // We don't validate here because __typename queried against a composite type is + // guaranteed to be valid. If the field set becomes non-trivial in the future, + // this should be updated accordingly. + false, )?); for implementation_type_in_supergraph_pos in self .supergraph_schema @@ -1958,84 +2314,6 @@ impl FederatedQueryGraphBuilder { } Ok(()) } - - /// Precompute which followup edges for a given edge are non-trivial. - fn precompute_non_trivial_followup_edges(&mut self) -> Result<(), FederationError> { - for edge in self.base.query_graph.graph.edge_indices() { - let edge_weight = self.base.query_graph.edge_weight(edge)?; - let (_, tail) = self.base.query_graph.edge_endpoints(edge)?; - let out_edges = self.base.query_graph.out_edges(tail); - let mut non_trivial_followups = Vec::with_capacity(out_edges.len()); - for followup_edge_ref in out_edges { - let followup_edge_weight = followup_edge_ref.weight(); - match edge_weight.transition { - QueryGraphEdgeTransition::KeyResolution => { - // After taking a key from subgraph A to B, there is no point of following - // that up with another key to subgraph C if that key has the same - // conditions. This is because, due to the way key edges are created, if we - // have a key (with some conditions X) from B to C, then we are guaranteed - // to also have a key (with the same conditions X) from A to C, and so it's - // that later key we should be using in the first place. In other words, - // it's never better to do 2 hops rather than 1. - if matches!( - followup_edge_weight.transition, - QueryGraphEdgeTransition::KeyResolution - ) { - let Some(conditions) = &edge_weight.conditions else { - return Err(SingleFederationError::Internal { - message: "Key resolution edge unexpectedly missing conditions" - .to_owned(), - } - .into()); - }; - let Some(followup_conditions) = &followup_edge_weight.conditions else { - return Err(SingleFederationError::Internal { - message: "Key resolution edge unexpectedly missing conditions" - .to_owned(), - } - .into()); - }; - - if conditions == followup_conditions { - continue; - } - } - } - QueryGraphEdgeTransition::RootTypeResolution { .. } => { - // A 'RootTypeResolution' means that a query reached the query type (or - // another root type) in some subgraph A and we're looking at jumping to - // another subgraph B. But like for keys, there is no point in trying to - // jump directly to yet another subpraph C from B, since we can always jump - // directly from A to C and it's better. - if matches!( - followup_edge_weight.transition, - QueryGraphEdgeTransition::RootTypeResolution { .. } - ) { - continue; - } - } - QueryGraphEdgeTransition::SubgraphEnteringTransition => { - // This is somewhat similar to 'RootTypeResolution' except that we're - // starting the query. Still, we shouldn't do "start of query" -> B -> C, - // since we can do "start of query" -> C and that's always better. - if matches!( - followup_edge_weight.transition, - QueryGraphEdgeTransition::RootTypeResolution { .. } - ) { - continue; - } - } - _ => {} - } - non_trivial_followups.push(followup_edge_ref.id()); - } - self.base - .query_graph - .non_trivial_followup_edges - .insert(edge, non_trivial_followups); - } - Ok(()) - } } const FEDERATED_GRAPH_ROOT_SOURCE: &str = "_"; @@ -2074,8 +2352,7 @@ impl FederatedQueryGraphBuilderSubgraphs { // means they should include an @interfaceObject definition. SingleFederationError::Internal { message: format!( - "Subgraph \"{}\" unexpectedly missing @interfaceObject definition", - source, + "Subgraph \"{source}\" unexpectedly missing @interfaceObject definition", ), } })?; @@ -2083,6 +2360,14 @@ impl FederatedQueryGraphBuilderSubgraphs { .override_directive_definition(schema)? .name .clone(); + let context_directive_definition_name = federation_spec_definition + .context_directive_definition(schema)? + .name + .clone(); + let from_context_directive_definition_name = federation_spec_definition + .from_context_directive_definition(schema)? + .name + .clone(); subgraphs.map.insert( source.clone(), FederatedQueryGraphBuilderSubgraphData { @@ -2092,6 +2377,8 @@ impl FederatedQueryGraphBuilderSubgraphs { provides_directive_definition_name, interface_object_directive_definition_name, overrides_directive_definition_name, + context_directive_definition_name, + from_context_directive_definition_name, }, ); } @@ -2118,6 +2405,8 @@ struct FederatedQueryGraphBuilderSubgraphData { provides_directive_definition_name: Name, interface_object_directive_definition_name: Name, overrides_directive_definition_name: Name, + context_directive_definition_name: Name, + from_context_directive_definition_name: Name, } #[derive(Debug)] @@ -2130,7 +2419,7 @@ struct QueryGraphEdgeData { impl QueryGraphEdgeData { fn add_to(self, builder: &mut BaseQueryGraphBuilder) -> Result<(), FederationError> { - builder.add_edge(self.head, self.tail, self.transition, self.conditions) + builder.add_edge(self.head, self.tail, self.transition, self.conditions, None) } } @@ -2153,34 +2442,35 @@ fn resolvable_key_applications<'doc>( #[cfg(test)] mod tests { + use apollo_compiler::Name; + use apollo_compiler::Schema; use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use apollo_compiler::name; - use apollo_compiler::Name; - use apollo_compiler::Schema; + use petgraph::Direction; use petgraph::graph::NodeIndex; use petgraph::visit::EdgeRef; - use petgraph::Direction; + use super::*; use crate::error::FederationError; - use crate::query_graph::build_query_graph::build_query_graph; use crate::query_graph::QueryGraph; use crate::query_graph::QueryGraphEdgeTransition; use crate::query_graph::QueryGraphNode; use crate::query_graph::QueryGraphNodeType; + use crate::query_graph::build_query_graph::build_query_graph; + use crate::schema::ValidFederationSchema; use crate::schema::position::ObjectOrInterfaceTypeDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; use crate::schema::position::OutputTypeDefinitionPosition; use crate::schema::position::ScalarTypeDefinitionPosition; use crate::schema::position::SchemaRootDefinitionKind; - use crate::schema::ValidFederationSchema; const SCHEMA_NAME: &str = "test"; fn test_query_graph_from_schema_sdl(sdl: &str) -> Result { let schema = ValidFederationSchema::new(Schema::parse_and_validate(sdl, "schema.graphql")?)?; - build_query_graph(SCHEMA_NAME.into(), schema) + build_query_graph(SCHEMA_NAME.into(), schema, Default::default()) } fn assert_node_type( @@ -2233,7 +2523,7 @@ mod tests { field_pos.get(schema.schema())?; let expected_field_transition = QueryGraphEdgeTransition::FieldCollection { source: SCHEMA_NAME.into(), - field_definition_position: field_pos.clone().into(), + field_definition_position: field_pos.into(), is_part_of_provides: false, }; let mut tails = query_graph @@ -2438,4 +2728,104 @@ mod tests { Ok(()) } + + #[test] + fn test_build_supergraph_api_query_graph() { + let supergraph_sdl = r#" +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar join__DirectiveArguments + +scalar join__FieldSet + +enum join__Graph { + A @join__graph(name: "A", url: "/Users/duckki/work/dev/federation-test-lab/local-tests/federation-tests/progressive-override/progressive-override-basic2.graphql?subgraph=A") + B @join__graph(name: "B", url: "/Users/duckki/work/dev/federation-test-lab/local-tests/federation-tests/progressive-override/progressive-override-basic2.graphql?subgraph=B") + ENTRYPOINT @join__graph(name: "entrypoint", url: "/Users/duckki/work/dev/federation-test-lab/local-tests/federation-tests/progressive-override/progressive-override-basic2.graphql?subgraph=entrypoint") + MONOLITH @join__graph(name: "monolith", url: "/Users/duckki/work/dev/federation-test-lab/local-tests/federation-tests/progressive-override/progressive-override-basic2.graphql?subgraph=monolith") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: A) + @join__type(graph: B) + @join__type(graph: ENTRYPOINT) + @join__type(graph: MONOLITH) +{ + test: T! @join__field(graph: ENTRYPOINT) +} + +type T + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id") + @join__type(graph: ENTRYPOINT, key: "id") + @join__type(graph: MONOLITH, key: "id") +{ + id: ID! + data1: Int! @join__field(graph: A, override: "monolith", overrideLabel: "percent(50)") @join__field(graph: MONOLITH, overrideLabel: "percent(50)") + data2: Int! @join__field(graph: B, override: "monolith", overrideLabel: "percent(90)") @join__field(graph: MONOLITH, overrideLabel: "percent(90)") +} + "#; + let supergraph = crate::Supergraph::new_with_router_specs(supergraph_sdl).unwrap(); + let api_schema = supergraph.to_api_schema(Default::default()).unwrap(); + let query_graph = + build_supergraph_api_query_graph(supergraph.schema.clone(), api_schema).unwrap(); + // Nodes for the type `T` + let nodes = query_graph.nodes_for_type(&name!("T")).unwrap(); + // Edges with an override condition + let edges = nodes.iter().flat_map(|node| { + let edges = query_graph.out_edges(*node); + edges.into_iter().filter_map(|edge_ref| { + edge_ref + .weight() + .override_condition + .as_ref() + .map(|_| edge_ref.weight().to_string()) + }) + }); + // Make sure edges are multiplexed over true/false Boolean conditions. + assert_eq!( + edges.collect::>(), + vec![ + "percent(50) = true ⊢ data1", + "percent(50) = false ⊢ data1", + "percent(90) = true ⊢ data2", + "percent(90) = false ⊢ data2", + ] + ); + } } diff --git a/apollo-federation/src/query_graph/condition_resolver.rs b/apollo-federation/src/query_graph/condition_resolver.rs index e547f2a235..592703503d 100644 --- a/apollo-federation/src/query_graph/condition_resolver.rs +++ b/apollo-federation/src/query_graph/condition_resolver.rs @@ -1,18 +1,35 @@ -// PORT_NOTE: Unlike in JS version, `QueryPlanningTraversal` does not have a -// `CachingConditionResolver` as a field, but instead implements the `ConditionResolver` -// trait directly using `ConditionResolverCache`. use std::sync::Arc; +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Type; use apollo_compiler::collections::IndexMap; use petgraph::graph::EdgeIndex; use crate::error::FederationError; +use crate::operation::SelectionSet; +use crate::query_graph::QueryGraph; use crate::query_graph::graph_path::ExcludedConditions; use crate::query_graph::graph_path::ExcludedDestinations; -use crate::query_graph::graph_path::OpGraphPathContext; +use crate::query_graph::graph_path::operation::OpGraphPathContext; use crate::query_graph::path_tree::OpPathTree; use crate::query_plan::QueryPlanCost; +#[derive(Debug, Clone)] +pub(crate) struct ContextMapEntry { + pub(crate) levels_in_data_path: usize, + pub(crate) levels_in_query_path: usize, + pub(crate) path_tree: Option>, + pub(crate) selection_set: SelectionSet, + // PORT_NOTE: This field was renamed from the JS name (`paramName`) to better align with naming + // in ContextCondition. + pub(crate) argument_name: Name, + // PORT_NOTE: This field was renamed from the JS name (`argType`) to better align with naming in + // ContextCondition. + pub(crate) argument_type: Node, + pub(crate) context_id: Name, +} + /// Note that `ConditionResolver`s are guaranteed to be only called for edge with conditions. pub(crate) trait ConditionResolver { fn resolve( @@ -21,6 +38,7 @@ pub(crate) trait ConditionResolver { context: &OpGraphPathContext, excluded_destinations: &ExcludedDestinations, excluded_conditions: &ExcludedConditions, + extra_conditions: Option<&SelectionSet>, ) -> Result; } @@ -29,17 +47,41 @@ pub(crate) enum ConditionResolution { Satisfied { cost: QueryPlanCost, path_tree: Option>, + context_map: Option>, }, Unsatisfied { - // NOTE: This seems to be a false positive... - #[allow(dead_code)] reason: Option, }, } +impl std::fmt::Display for ConditionResolution { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConditionResolution::Satisfied { + cost, + path_tree, + context_map, + } => { + writeln!(f, "Satisfied: cost={cost}")?; + if let Some(path_tree) = path_tree { + writeln!(f, "path_tree:\n{path_tree}")?; + } + if let Some(context_map) = context_map { + writeln!(f, ", context_map:\n{context_map:?}")?; + } + Ok(()) + } + ConditionResolution::Unsatisfied { reason } => { + writeln!(f, "Unsatisfied: reason={reason:?}") + } + } + } +} + #[derive(Debug, Clone)] pub(crate) enum UnsatisfiedConditionReason { NoPostRequireKey, + NoSetContext, } impl ConditionResolution { @@ -47,6 +89,7 @@ impl ConditionResolution { Self::Satisfied { cost: 0.0, path_tree: None, + context_map: None, } } @@ -91,7 +134,11 @@ impl ConditionResolverCache { context: &OpGraphPathContext, excluded_destinations: &ExcludedDestinations, excluded_conditions: &ExcludedConditions, + extra_conditions: Option<&SelectionSet>, ) -> ConditionResolutionCacheResult { + if extra_conditions.is_some() { + return ConditionResolutionCacheResult::NotApplicable; + } // We don't cache if there is a context or excluded conditions because those would impact the resolution and // we don't want to cache a value per-context and per-excluded-conditions (we also don't cache per-excluded-edges though // instead we cache a value only for the first-see excluded edges; see above why that work in practice). @@ -130,10 +177,96 @@ impl ConditionResolverCache { } } +/// A query plan resolver for edge conditions that caches the outcome per edge. +// PORT_NOTE: This ports the `cachingConditionResolver` function from JS. In JS version, the +// function creates a closure capturing the QueryPlanningTraversal/ValidationTraversal +// instance itself The same would be infeasible to implement in Rust due to the cyclic +// references. Instead, in Rust, it is implemented as `CachingConditionResolver` and +// `ConditionResolver` traits that will be implemented by `QueryPlanningTraversal` and +// `ValidationTraversal` structs. +pub(crate) trait CachingConditionResolver { + fn query_graph(&self) -> &QueryGraph; + + fn resolve_without_cache( + &self, + edge: EdgeIndex, + context: &OpGraphPathContext, + excluded_destinations: &ExcludedDestinations, + excluded_conditions: &ExcludedConditions, + extra_conditions: Option<&SelectionSet>, + ) -> Result; + + fn resolver_cache(&mut self) -> &mut ConditionResolverCache; + + fn resolve_with_cache( + &mut self, + edge: EdgeIndex, + context: &OpGraphPathContext, + excluded_destinations: &ExcludedDestinations, + excluded_conditions: &ExcludedConditions, + extra_conditions: Option<&SelectionSet>, + ) -> Result { + let cache_result = self.resolver_cache().contains( + edge, + context, + excluded_destinations, + excluded_conditions, + extra_conditions, + ); + + if let ConditionResolutionCacheResult::Hit(cached_resolution) = cache_result { + return Ok(cached_resolution); + } + + let resolution = self.resolve_without_cache( + edge, + context, + excluded_destinations, + excluded_conditions, + extra_conditions, + )?; + // See if this resolution is eligible to be inserted into the cache. + if cache_result.is_miss() { + self.resolver_cache() + .insert(edge, resolution.clone(), excluded_destinations.clone()); + } + Ok(resolution) + } +} + +/// Blanket implementation of `ConditionResolver` for any type that implements +/// `CachingConditionResolver`. +impl ConditionResolver for T { + fn resolve( + &mut self, + edge: EdgeIndex, + context: &OpGraphPathContext, + excluded_destinations: &ExcludedDestinations, + excluded_conditions: &ExcludedConditions, + extra_conditions: Option<&SelectionSet>, + ) -> Result { + // Invariant check: The edge must have conditions. + let graph = &self.query_graph(); + let edge_data = graph.edge_weight(edge)?; + assert!( + edge_data.conditions.is_some() || extra_conditions.is_some(), + "Should not have been called for edge without conditions" + ); + + self.resolve_with_cache( + edge, + context, + excluded_destinations, + excluded_conditions, + extra_conditions, + ) + } +} + #[cfg(test)] mod tests { use super::*; - use crate::query_graph::graph_path::OpGraphPathContext; + use crate::query_graph::graph_path::operation::OpGraphPathContext; //use crate::link::graphql_definition::{OperationConditional, OperationConditionalKind, BooleanOrVariable}; #[test] @@ -145,14 +278,17 @@ mod tests { let empty_destinations = ExcludedDestinations::default(); let empty_conditions = ExcludedConditions::default(); - assert!(cache - .contains( - edge1, - &empty_context, - &empty_destinations, - &empty_conditions - ) - .is_miss()); + assert!( + cache + .contains( + edge1, + &empty_context, + &empty_destinations, + &empty_conditions, + None + ) + .is_miss() + ); cache.insert( edge1, @@ -160,24 +296,30 @@ mod tests { empty_destinations.clone(), ); - assert!(cache - .contains( - edge1, - &empty_context, - &empty_destinations, - &empty_conditions - ) - .is_hit(),); + assert!( + cache + .contains( + edge1, + &empty_context, + &empty_destinations, + &empty_conditions, + None + ) + .is_hit(), + ); let edge2 = EdgeIndex::new(2); - assert!(cache - .contains( - edge2, - &empty_context, - &empty_destinations, - &empty_conditions - ) - .is_miss()); + assert!( + cache + .contains( + edge2, + &empty_context, + &empty_destinations, + &empty_conditions, + None + ) + .is_miss() + ); } } diff --git a/apollo-federation/src/query_graph/graph_path.rs b/apollo-federation/src/query_graph/graph_path.rs index c01e1753dc..6dd3e4ee48 100644 --- a/apollo-federation/src/query_graph/graph_path.rs +++ b/apollo-federation/src/query_graph/graph_path.rs @@ -3,15 +3,22 @@ use std::fmt::Display; use std::fmt::Formatter; use std::fmt::Write; use std::hash::Hash; -use std::ops::Deref; -use std::sync::atomic; +use std::marker::PhantomData; use std::sync::Arc; +use std::sync::LazyLock; +use std::sync::atomic; +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Type; use apollo_compiler::ast::Value; use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use either::Either; use itertools::Itertools; +use itertools::izip; +use operation::OpGraphPathContext; +use operation::OpPathElement; use petgraph::graph::EdgeIndex; use petgraph::graph::EdgeReference; use petgraph::graph::NodeIndex; @@ -19,44 +26,47 @@ use petgraph::visit::EdgeRef; use tracing::debug; use tracing::debug_span; -use crate::display_helpers::write_indented_lines; -use crate::display_helpers::DisplayOption; -use crate::display_helpers::DisplaySlice; -use crate::display_helpers::State as IndentedFormatter; +use super::condition_resolver::ContextMapEntry; +use crate::bail; use crate::error::FederationError; -use crate::is_leaf_type; use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; -use crate::link::graphql_definition::BooleanOrVariable; use crate::link::graphql_definition::DeferDirectiveArguments; -use crate::link::graphql_definition::OperationConditional; -use crate::link::graphql_definition::OperationConditionalKind; -use crate::operation::DirectiveList; use crate::operation::Field; -use crate::operation::HasSelectionKey; -use crate::operation::InlineFragment; -use crate::operation::SelectionId; -use crate::operation::SelectionKey; +use crate::operation::FieldSetDisplay; +use crate::operation::Selection; use crate::operation::SelectionSet; -use crate::operation::SiblingTypename; +use crate::query_graph::OverrideConditions; +use crate::query_graph::QueryGraph; +use crate::query_graph::QueryGraphEdge; +use crate::query_graph::QueryGraphEdgeTransition; +use crate::query_graph::QueryGraphNode; +use crate::query_graph::QueryGraphNodeType; use crate::query_graph::condition_resolver::ConditionResolution; use crate::query_graph::condition_resolver::ConditionResolver; use crate::query_graph::condition_resolver::UnsatisfiedConditionReason; use crate::query_graph::path_tree::OpPathTree; -use crate::query_graph::QueryGraph; -use crate::query_graph::QueryGraphEdgeTransition; -use crate::query_graph::QueryGraphNodeType; -use crate::query_plan::query_planner::EnabledOverrideConditions; use crate::query_plan::FetchDataPathElement; use crate::query_plan::QueryPlanCost; -use crate::schema::position::AbstractTypeDefinitionPosition; -use crate::schema::position::Captures; +use crate::schema::ValidFederationSchema; +use crate::schema::field_set::parse_field_value_without_validation; +use crate::schema::field_set::validate_field_value; use crate::schema::position::CompositeTypeDefinitionPosition; -use crate::schema::position::InterfaceFieldDefinitionPosition; -use crate::schema::position::ObjectOrInterfaceTypeDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; use crate::schema::position::OutputTypeDefinitionPosition; -use crate::schema::position::TypeDefinitionPosition; -use crate::schema::ValidFederationSchema; +use crate::schema::position::SchemaRootDefinitionKind; +use crate::utils::FallibleIterator; +use crate::utils::logging::snapshot; + +pub(crate) mod operation; +pub(crate) mod transition; + +#[derive(Clone, serde::Serialize, Debug, Eq, PartialEq)] +pub(crate) struct ContextUsageEntry { + pub(crate) context_id: Name, + pub(crate) relative_path: Vec, + pub(crate) selection_set: SelectionSet, + pub(crate) subgraph_argument_type: Node, +} /// An immutable path in a query graph. /// @@ -102,8 +112,7 @@ use crate::schema::ValidFederationSchema; #[derive(Clone, serde::Serialize)] pub(crate) struct GraphPath where - TTrigger: Eq + Hash, - Arc: Into, + TTrigger: Eq + Hash + GraphPathTriggerVariant, TEdge: Copy + Into>, EdgeIndex: Into, { @@ -117,7 +126,7 @@ where /// The node at which the path stops. This should be the tail of the last non-`None` edge in the /// path if such edge exists, but if there are only `None` edges (or if there are zero edges), /// this will still exist (and the head and tail of the path will be the same). - pub(crate) tail: NodeIndex, + tail: NodeIndex, /// The edges composing the path. edges: Vec, /// The triggers associated to each edge in the path. @@ -157,12 +166,20 @@ where // TODO(@TylerBloom): Add in once defer is supported. #[serde(skip)] defer_on_tail: Option, + // PORT_NOTE: This field was renamed because the JS name (`contextToSelection`) implied it was + // a map to selections, which it isn't. + /// The IDs of contexts that have matched at the edge, for each edge in the path. + matching_context_ids: Vec>, + // PORT_NOTE: This field was renamed because the JS name (`parameterToContext`) left confusion + // to how a parameter was different from an argument. + /// Maps of @fromContext arguments to info about the contexts used in those arguments, for each + /// edge in the path. + arguments_to_context_usages: Vec>, } impl std::fmt::Debug for GraphPath where - TTrigger: Eq + Hash, - Arc: Into, + TTrigger: Eq + Hash + GraphPathTriggerVariant, TEdge: Copy + Into>, EdgeIndex: Into, // In addition to the bounds of the GraphPath struct, also require Debug: @@ -183,6 +200,8 @@ where runtime_types_of_tail, runtime_types_before_tail_if_last_is_cast, defer_on_tail, + matching_context_ids: _, + arguments_to_context_usages: _, } = self; f.debug_struct("GraphPath") @@ -207,12 +226,6 @@ where } } -#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::From)] -pub(crate) enum GraphPathTrigger { - Op(Arc), - Transition(Arc), -} - #[derive(Debug, Clone, serde::Serialize)] pub(crate) struct SubgraphEnteringEdgeInfo { /// The index within the `edges` array. @@ -224,376 +237,34 @@ pub(crate) struct SubgraphEnteringEdgeInfo { /// Wrapper for an override ID, which indicates a relationship between a group of `OpGraphPath`s /// where one "overrides" the others in the group. /// -/// Note that we shouldn't add `derive(Serialize, Deserialize)` to this without changing the types -/// to be something like UUIDs. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] -// NOTE(@TylerBloom): This feature gate can be removed once the condition in the comment above is -// met. -#[cfg_attr(feature = "snapshot_tracing", derive(serde::Serialize))] +/// NOTE: This ID does not ensure that IDs are unique because its internal counter resets on +/// startup. It currently implements `Serialize` for debugging purposes. It should not implement +/// `Deserialize`, and, more specifically, it should not be used for caching until uniqueness is +/// provided (i.e. the inner type is a `Uuid` or the like). +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, serde::Serialize)] pub(crate) struct OverrideId(usize); -/// Global storage for the counter used to allocate `OverrideId`s. -static NEXT_OVERRIDE_ID: atomic::AtomicUsize = atomic::AtomicUsize::new(1); +// Global storage for the counter used to uniquely identify selections +static NEXT_ID: atomic::AtomicUsize = atomic::AtomicUsize::new(1); impl OverrideId { fn new() -> Self { // atomically increment global counter - Self(NEXT_OVERRIDE_ID.fetch_add(1, atomic::Ordering::AcqRel)) - } -} - -/// The item type for [`GraphPath::iter`] -pub(crate) type GraphPathItem<'path, TTrigger, TEdge> = - (TEdge, &'path Arc, &'path Option>); - -/// A `GraphPath` whose triggers are operation elements (essentially meaning that the path has been -/// guided by a GraphQL operation). -// PORT_NOTE: As noted in the docs for `GraphPath`, we omit a type parameter for the root node, -// whose constraint is instead checked at runtime. This means the `OpRootPath` type in the JS -// codebase is replaced with this one. -pub(crate) type OpGraphPath = GraphPath>; - -#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::From, serde::Serialize)] -pub(crate) enum OpGraphPathTrigger { - OpPathElement(OpPathElement), - Context(OpGraphPathContext), -} - -impl Display for OpGraphPathTrigger { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - OpGraphPathTrigger::OpPathElement(ele) => ele.fmt(f), - OpGraphPathTrigger::Context(ctx) => ctx.fmt(f), - } - } -} - -/// A path of operation elements within a GraphQL operation. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize)] -pub(crate) struct OpPath(pub(crate) Vec>); - -impl Deref for OpPath { - type Target = [Arc]; - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl Display for OpPath { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - for (i, element) in self.0.iter().enumerate() { - if i > 0 { - write!(f, "::")?; - } - match element.deref() { - OpPathElement::Field(field) => write!(f, "{field}")?, - OpPathElement::InlineFragment(fragment) => write!(f, "{fragment}")?, - } - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::From, serde::Serialize)] -pub(crate) enum OpPathElement { - Field(Field), - InlineFragment(InlineFragment), -} - -impl HasSelectionKey for OpPathElement { - fn key(&self) -> SelectionKey { - match self { - OpPathElement::Field(field) => field.key(), - OpPathElement::InlineFragment(fragment) => fragment.key(), - } - } -} - -impl OpPathElement { - pub(crate) fn directives(&self) -> &DirectiveList { - match self { - OpPathElement::Field(field) => &field.directives, - OpPathElement::InlineFragment(inline_fragment) => &inline_fragment.directives, - } - } - - pub(crate) fn schema(&self) -> &ValidFederationSchema { - match self { - OpPathElement::Field(field) => field.schema(), - OpPathElement::InlineFragment(fragment) => fragment.schema(), - } - } - - pub(crate) fn is_terminal(&self) -> Result { - match self { - OpPathElement::Field(field) => field.is_leaf(), - OpPathElement::InlineFragment(_) => Ok(false), - } - } - - pub(crate) fn sibling_typename(&self) -> Option<&SiblingTypename> { - match self { - OpPathElement::Field(field) => field.sibling_typename(), - OpPathElement::InlineFragment(_) => None, - } - } - - pub(crate) fn parent_type_position(&self) -> CompositeTypeDefinitionPosition { - match self { - OpPathElement::Field(field) => field.field_position.parent(), - OpPathElement::InlineFragment(inline) => inline.parent_type_position.clone(), - } - } - - pub(crate) fn sub_selection_type_position( - &self, - ) -> Result, FederationError> { - match self { - OpPathElement::Field(field) => Ok(field.output_base_type()?.try_into().ok()), - OpPathElement::InlineFragment(inline) => Ok(Some(inline.casted_type())), - } - } - - pub(crate) fn extract_operation_conditionals( - &self, - ) -> Result, FederationError> { - let mut conditionals = vec![]; - // PORT_NOTE: We explicitly use the order `Skip` and `Include` here, to align with the order - // used by the JS codebase. - for kind in [ - OperationConditionalKind::Skip, - OperationConditionalKind::Include, - ] { - let directive_name: &'static str = (&kind).into(); - if let Some(application) = self.directives().get(directive_name) { - let Some(arg) = application.specified_argument_by_name("if") else { - return Err(FederationError::internal(format!( - "@{} missing required argument \"if\"", - directive_name - ))); - }; - let value = match arg.deref() { - Value::Variable(variable_name) => { - BooleanOrVariable::Variable(variable_name.clone()) - } - Value::Boolean(boolean) => BooleanOrVariable::Boolean(*boolean), - _ => { - return Err(FederationError::internal(format!( - "@{} has invalid value {} for argument \"if\"", - directive_name, - arg.serialize().no_indent() - ))); - } - }; - conditionals.push(OperationConditional { kind, value }) - } - } - Ok(conditionals) - } - - pub(crate) fn with_updated_directives(&self, directives: DirectiveList) -> OpPathElement { - match self { - OpPathElement::Field(field) => { - OpPathElement::Field(field.with_updated_directives(directives)) - } - OpPathElement::InlineFragment(inline_fragment) => { - OpPathElement::InlineFragment(inline_fragment.with_updated_directives(directives)) - } - } - } - - pub(crate) fn as_path_element(&self) -> Option { - match self { - OpPathElement::Field(field) => Some(field.as_path_element()), - OpPathElement::InlineFragment(inline_fragment) => inline_fragment.as_path_element(), - } - } - - pub(crate) fn defer_directive_args(&self) -> Option { - match self { - OpPathElement::Field(_) => None, // @defer cannot be on field at the moment - OpPathElement::InlineFragment(inline_fragment) => { - inline_fragment.defer_directive_arguments().ok().flatten() - } - } - } - - /// Returns this fragment element but with any @defer directive on it removed. - /// - /// This method will return `None` if, upon removing @defer, the fragment has no conditions nor - /// any remaining applied directives (meaning that it carries no information whatsoever and can be - /// ignored). - pub(crate) fn without_defer(&self) -> Option { - match self { - Self::Field(_) => Some(self.clone()), - Self::InlineFragment(inline_fragment) => { - let updated_directives: DirectiveList = inline_fragment - .directives - .iter() - .filter(|directive| directive.name != "defer") - .cloned() - .collect(); - if inline_fragment.type_condition_position.is_none() - && updated_directives.is_empty() - { - return None; - } - if inline_fragment.directives.len() == updated_directives.len() { - Some(self.clone()) - } else { - // PORT_NOTE: We won't need to port `this.copyAttachementsTo(updated);` line here - // since `with_updated_directives` clones the whole `self` and thus sibling - // type names should be copied as well. - Some(self.with_updated_directives(updated_directives)) - } - } - } - } - - pub(crate) fn rebase_on( - &self, - parent_type: &CompositeTypeDefinitionPosition, - schema: &ValidFederationSchema, - ) -> Result { - match self { - OpPathElement::Field(field) => Ok(field.rebase_on(parent_type, schema)?.into()), - OpPathElement::InlineFragment(inline) => { - Ok(inline.rebase_on(parent_type, schema)?.into()) - } - } - } -} - -impl Display for OpPathElement { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - OpPathElement::Field(field) => field.fmt(f), - OpPathElement::InlineFragment(inline_fragment) => inline_fragment.fmt(f), - } - } -} - -impl From for OpGraphPathTrigger { - fn from(value: Field) -> Self { - OpPathElement::from(value).into() - } -} - -impl From for OpGraphPathTrigger { - fn from(value: InlineFragment) -> Self { - OpPathElement::from(value).into() - } -} - -/// Records, as we walk a path within a GraphQL operation, important directives encountered -/// (currently `@include` and `@skip` with their conditions). -#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize)] -pub(crate) struct OpGraphPathContext { - /// A list of conditionals (e.g. `[{ kind: Include, value: true}, { kind: Skip, value: $foo }]`) - /// in the reverse order in which they were applied (so the first element is the inner-most - /// applied include/skip). - conditionals: Arc>, -} - -impl OpGraphPathContext { - pub(crate) fn with_context_of( - &self, - operation_element: &OpPathElement, - ) -> Result { - if operation_element.directives().is_empty() { - return Ok(self.clone()); - } - - let mut new_conditionals = operation_element.extract_operation_conditionals()?; - if new_conditionals.is_empty() { - return Ok(self.clone()); - } - new_conditionals.extend(self.iter().cloned()); - Ok(OpGraphPathContext { - conditionals: Arc::new(new_conditionals), - }) - } - - pub(crate) fn is_empty(&self) -> bool { - self.conditionals.is_empty() - } - - pub(crate) fn iter(&self) -> impl Iterator { - self.conditionals.iter() - } -} - -impl Display for OpGraphPathContext { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "[")?; - let mut iter = self.conditionals.iter(); - if let Some(cond) = iter.next() { - write!(f, "@{}(if: {})", cond.kind, cond.value)?; - iter.try_for_each(|cond| write!(f, ", @{}(if: {})", cond.kind, cond.value))?; - } - write!(f, "]") + Self(NEXT_ID.fetch_add(1, atomic::Ordering::AcqRel)) } } -/// A vector of graph paths that are being considered simultaneously by the query planner as an -/// option for a path within a GraphQL operation. These arise since the edge to take in a query -/// graph may depend on outcomes that are only known at query plan execution time, and we account -/// for this by splitting a path into multiple paths (one for each possible outcome). The common -/// example is abstract types, where we may end up taking a different edge depending on the runtime -/// type (e.g. during type explosion). -#[derive(Clone, serde::Serialize)] -pub(crate) struct SimultaneousPaths(pub(crate) Vec>); - -impl SimultaneousPaths { - pub(crate) fn fmt_indented(&self, f: &mut IndentedFormatter) -> std::fmt::Result { - match self.0.as_slice() { - [] => f.write(""), - - [first] => f.write_fmt(format_args!("{first}")), - - _ => { - f.write("{")?; - write_indented_lines(f, &self.0, |f, elem| f.write(elem))?; - f.write("}") - } - } - } -} - -impl std::fmt::Debug for SimultaneousPaths { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.debug_list() - .entries(self.0.iter().map(ToString::to_string)) - .finish() - } -} - -impl Display for SimultaneousPaths { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - self.fmt_indented(&mut IndentedFormatter::new(f)) - } -} +pub(crate) type MatchingContextIds = IndexSet; +pub(crate) type ArgumentsToContextUsages = IndexMap; -/// One of the options for an `OpenBranch` (see the documentation of that struct for details). This -/// includes the simultaneous paths we are traversing for the option, along with metadata about the -/// traversal. -// PORT_NOTE: The JS codebase stored a `ConditionResolver` callback here, but it was the same for -// a given traversal (and cached resolution across the traversal), so we accordingly store it in -// `QueryPlanTraversal` and pass it down when needed instead. -#[derive(Debug, Clone, serde::Serialize)] -pub(crate) struct SimultaneousPathsWithLazyIndirectPaths { - pub(crate) paths: SimultaneousPaths, - pub(crate) context: OpGraphPathContext, - pub(crate) excluded_destinations: ExcludedDestinations, - pub(crate) excluded_conditions: ExcludedConditions, - pub(crate) lazily_computed_indirect_paths: Vec>, -} - -impl Display for SimultaneousPathsWithLazyIndirectPaths { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.paths) - } -} +/// The item type for [`GraphPath::iter`] +pub(crate) type GraphPathItem<'path, TTrigger, TEdge> = ( + TEdge, + &'path Arc, + &'path Option>, + Option<&'path MatchingContextIds>, + Option<&'path ArgumentsToContextUsages>, +); /// A "set" of excluded destinations (i.e. subgraph names). Note that we use a `Vec` instead of set /// because this is used in pretty hot paths (the whole path computation is CPU intensive) and will @@ -663,83 +334,35 @@ impl Default for ExcludedConditions { } } -#[derive(Clone, serde::Serialize)] -pub(crate) struct IndirectPaths +#[derive(serde::Serialize)] +pub(crate) struct IndirectPaths where - TTrigger: Eq + Hash, - Arc: Into, + TTrigger: Eq + Hash + GraphPathTriggerVariant, TEdge: Copy + Into>, EdgeIndex: Into, + UnadvanceableClosures: Into, { paths: Arc>>>, - dead_ends: Arc, + #[serde(skip_serializing)] + dead_ends: TDeadEnds, } -type OpIndirectPaths = IndirectPaths>; +#[derive(Clone)] +pub(crate) struct UnadvanceableClosures(Arc>); -impl std::fmt::Debug for OpIndirectPaths { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.debug_struct("OpIndirectPaths") - .field( - "paths", - &self - .paths - .iter() - .map(ToString::to_string) - .collect::>(), - ) - .field("dead_ends", &self.dead_ends) - .finish() - } +impl From for () { + fn from(_: UnadvanceableClosures) -> Self {} } -impl OpIndirectPaths { - /// When `self` is just-computed indirect paths and given a field that we're trying to advance - /// after those paths, this method filters any paths that should not be considered. - /// - /// Currently, this handles the case where the key used at the end of the indirect path contains - /// (at top level) the field being queried. Or to make this more concrete, if we're trying to - /// collect field `id`, and the path's last edge was using key `id`, then we can ignore that - /// path because this implies that there is a way to fetch `id` "some other way". - pub(crate) fn filter_non_collecting_paths_for_field( - &self, - field: &Field, - ) -> Result { - // We only handle leaves; Things are more complex for non-leaves. - if !field.is_leaf()? { - return Ok(self.clone()); - } - - let mut filtered = vec![]; - for path in self.paths.iter() { - if let Some(Some(last_edge)) = path.edges.last() { - let last_edge_weight = path.graph.edge_weight(*last_edge)?; - if matches!( - last_edge_weight.transition, - QueryGraphEdgeTransition::KeyResolution - ) { - if let Some(conditions) = &last_edge_weight.conditions { - if conditions.contains_top_level_field(field)? { - continue; - } - } - } - } - filtered.push(path.clone()) - } - Ok(if filtered.len() == self.paths.len() { - self.clone() - } else { - OpIndirectPaths { - paths: Arc::new(filtered), - dead_ends: self.dead_ends.clone(), - } - }) - } -} +type UnadvanceableClosure = Arc< + LazyLock< + Result, + Box Result + Send + Sync>, + >, +>; #[derive(Debug, Clone, serde::Serialize)] -struct Unadvanceables(Vec); +pub(crate) struct Unadvanceables(Arc>>); impl Display for Unadvanceables { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { @@ -756,8 +379,48 @@ impl Display for Unadvanceables { } } +impl Unadvanceables { + #[allow(dead_code)] + pub(crate) fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub(crate) fn iter(&self) -> impl Iterator> { + self.0.iter() + } +} + +impl TryFrom for Unadvanceables { + type Error = FederationError; + + fn try_from(value: UnadvanceableClosure) -> Result { + (*value).clone() + } +} + +impl TryFrom for Unadvanceables { + type Error = FederationError; + + fn try_from(value: UnadvanceableClosures) -> Result { + Ok(Unadvanceables(Arc::new( + value + .0 + .iter() + .map(|closure| (**closure).clone()) + .process_results(|iter| { + // Lending iterators aren't in Rust yet, so using a loop. + let mut all_unadvanceables = vec![]; + for unadvanceables in iter { + all_unadvanceables.extend(unadvanceables.0.iter().cloned()); + } + all_unadvanceables + })?, + ))) + } +} + #[derive(Debug, Clone, serde::Serialize)] -struct Unadvanceable { +pub(crate) struct Unadvanceable { reason: UnadvanceableReason, from_subgraph: Arc, to_subgraph: Arc, @@ -774,52 +437,41 @@ impl Display for Unadvanceable { } } -#[derive(Debug, Clone, strum_macros::Display, serde::Serialize)] -// PORT_NOTE: This is only used by composition, which is not ported to Rust yet. -enum UnadvanceableReason {} - -/// One of the options for a `ClosedBranch` (see the documentation of that struct for details). Note -/// there is an optimization here, in that if some ending section of the path within the GraphQL -/// operation can be satisfied by a query to a single subgraph, then we just record that selection -/// set, and the `SimultaneousPaths` ends at the node at which that query is made instead of a node -/// for the leaf field. The selection set gets copied "as-is" into the `FetchNode`, and also avoids -/// extra `GraphPath` creation and work during `PathTree` merging. -#[derive(Debug, serde::Serialize)] -pub(crate) struct ClosedPath { - pub(crate) paths: SimultaneousPaths, - pub(crate) selection_set: Option>, -} +impl Unadvanceable { + #[allow(dead_code)] + pub(crate) fn reason(&self) -> &UnadvanceableReason { + &self.reason + } -impl ClosedPath { - pub(crate) fn flatten( - &self, - ) -> impl Iterator>)> { - self.paths - .0 - .iter() - .map(|path| (path.as_ref(), self.selection_set.as_ref())) + /// Returns `self.from_subgraph`. It's named `source_subgraph`, since `fn from_subgraph()` may + /// be ambiguous with the Rust `from_*` convention. + pub(crate) fn source_subgraph(&self) -> &str { + &self.from_subgraph } -} -impl Display for ClosedPath { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - if let Some(ref selection_set) = self.selection_set { - write!(f, "{} -> {}", self.paths, selection_set) - } else { - write!(f, "{}", self.paths) - } + /// Returns `self.to_subgraph`. It's named `dest_subgraph`, since `fn to_subgraph()` may + /// be ambiguous with the Rust `to_*` convention. + #[allow(dead_code)] + pub(crate) fn dest_subgraph(&self) -> &str { + &self.to_subgraph } -} -/// A list of the options generated during query planning for a specific "closed branch", which is a -/// full/closed path in a GraphQL operation (i.e. one that ends in a leaf field). -#[derive(Debug, serde::Serialize)] -pub(crate) struct ClosedBranch(pub(crate) Vec>); + pub(crate) fn details(&self) -> &str { + &self.details + } +} -/// A list of the options generated during query planning for a specific "open branch", which is a -/// partial/open path in a GraphQL operation (i.e. one that does not end in a leaf field). -#[derive(Debug, serde::Serialize)] -pub(crate) struct OpenBranch(pub(crate) Vec); +#[derive(Debug, Clone, strum_macros::Display, serde::Serialize)] +#[allow(dead_code)] +pub(crate) enum UnadvanceableReason { + UnsatisfiableKeyCondition, + UnsatisfiableRequiresCondition, + UnresolvableInterfaceObject, + NoMatchingTransition, + UnreachableType, + IgnoredIndirectPath, + UnsatisfiableOverrideCondition, +} // A drop-in replacement for `BinaryHeap`, but behaves more like JS QP's `popMin` method. struct MaxHeap @@ -846,21 +498,38 @@ where // multiple maximum items. // Note: `position_max` returns the last of the equally maximum items. Thus, we use // `position_min_by` by reversing the ordering. - let pos = self.items.iter().position_min_by(|a, b| b.cmp(a)); - let Some(pos) = pos else { - return None; - }; + let pos = self.items.iter().position_min_by(|a, b| b.cmp(a))?; Some(self.items.remove(pos)) } } +/// `GraphPath` is generic over two types, `TTrigger` and `TEdge`. This trait helps abstract over +/// the `TTrigger` type bound. A `TTrigger` is one of the two types that make up the variants of +/// the `GraphPathTrigger`. Rather than trying to cast into concrete types and cast back (and +/// potentially raise errors), this trait provides ways to access the data needed within. +pub(crate) trait GraphPathTriggerVariant: Eq + Hash + std::fmt::Debug { + fn get_field_parent_type(&self) -> Option; + fn get_field_mut(&mut self) -> Option<&mut Field>; + fn get_op_path_element(&self) -> Option<&OpPathElement>; +} + impl GraphPath where - TTrigger: Eq + Hash + std::fmt::Debug, - Arc: Into, - TEdge: Copy + Into> + std::fmt::Debug, + TTrigger: GraphPathTriggerVariant + Display + Send + Sync + 'static, + TEdge: Copy + Into> + std::fmt::Debug + Send + Sync + 'static, EdgeIndex: Into, { + pub(crate) fn graph(&self) -> &Arc { + &self.graph + } + pub(crate) fn tail(&self) -> NodeIndex { + self.tail + } + pub(crate) fn runtime_types_of_tail(&self) -> &Arc> { + &self.runtime_types_of_tail + } + + /// Creates a new (empty) path starting at the provided `head` node. pub(crate) fn new(graph: Arc, head: NodeIndex) -> Result { let mut path = Self { graph, @@ -875,11 +544,25 @@ where runtime_types_of_tail: Arc::new(IndexSet::default()), runtime_types_before_tail_if_last_is_cast: None, defer_on_tail: None, + matching_context_ids: Vec::default(), + arguments_to_context_usages: Vec::default(), }; path.runtime_types_of_tail = Arc::new(path.head_possible_runtime_types()?); Ok(path) } + /// Creates a new (empty) path starting from the root node in `graph` corresponding to the + /// provided `root_kind`. + pub(crate) fn from_graph_root( + graph: Arc, + root_kind: SchemaRootDefinitionKind, + ) -> Result { + let Some(root_node) = graph.root_kinds_to_nodes()?.get(&root_kind).copied() else { + bail!("Unexpectedly no root node for the root kind {}", root_kind); + }; + GraphPath::new(graph, root_node) + } + fn head_possible_runtime_types( &self, ) -> Result, FederationError> { @@ -898,7 +581,7 @@ where pub(crate) fn add( &self, - trigger: TTrigger, + mut trigger: TTrigger, edge: TEdge, condition_resolution: ConditionResolution, defer: Option, @@ -906,6 +589,7 @@ where let ConditionResolution::Satisfied { path_tree: condition_path_tree, cost: condition_cost, + context_map, } = condition_resolution else { return Err(FederationError::internal( @@ -916,6 +600,8 @@ where let mut edges = self.edges.clone(); let mut edge_triggers = self.edge_triggers.clone(); let mut edge_conditions = self.edge_conditions.clone(); + let mut matching_context_ids = self.matching_context_ids.clone(); + let mut arguments_to_context_usages = self.arguments_to_context_usages.clone(); let mut last_subgraph_entering_edge_info = if defer.is_none() { self.last_subgraph_entering_edge_info.clone() } else { @@ -926,6 +612,8 @@ where edges.push(edge); edge_triggers.push(Arc::new(trigger)); edge_conditions.push(condition_path_tree); + matching_context_ids.push(None); + arguments_to_context_usages.push(None); return Ok(GraphPath { graph: self.graph.clone(), head: self.head, @@ -945,6 +633,8 @@ where ), runtime_types_before_tail_if_last_is_cast: None, defer_on_tail: defer, + matching_context_ids, + arguments_to_context_usages, }); }; @@ -953,147 +643,140 @@ where let tail_weight = self.graph.node_weight(self.tail)?; if self.tail != edge_head { return Err(FederationError::internal(format!( - "Cannot add edge {} to path ending at {}", - edge_weight, tail_weight + "Cannot add edge {edge_weight} to path ending at {tail_weight}" ))); } - if let Some(path_tree) = &condition_path_tree { - if edge_weight.conditions.is_none() { - return Err(FederationError::internal(format!( - "Unexpectedly got conditions paths {} for edge {} without conditions", - path_tree, edge_weight, - ))); - } + if let Some(path_tree) = &condition_path_tree + && edge_weight.conditions.is_none() + { + return Err(FederationError::internal(format!( + "Unexpectedly got conditions paths {path_tree} for edge {edge_weight} without conditions", + ))); } if matches!( edge_weight.transition, QueryGraphEdgeTransition::Downcast { .. } - ) { - if let Some(Some(last_edge)) = self.edges.last().map(|e| (*e).into()) { - let Some(last_edge_trigger) = self.edge_triggers.last() else { + ) && let Some(Some(last_edge)) = self.edges.last().map(|e| (*e).into()) + { + let Some(last_edge_trigger) = self.edge_triggers.last() else { + return Err(FederationError::internal( + "Could not find corresponding trigger for edge", + )); + }; + if let Some(OpPathElement::InlineFragment(last_operation_element)) = + last_edge_trigger.get_op_path_element() + && last_operation_element.directives.is_empty() + { + // This mean we have 2 typecasts back-to-back, and that means the + // previous operation element might not be useful on this path. More + // precisely, the previous typecast was only useful if it restricted the + // possible runtime types of the type on which it applied more than the + // current typecast does (but note that if the previous typecast had + // directives, we keep it no matter what in case those directives are + // important). + // + // That is, we're in the case where we have (somewhere potentially deep + // in a query): + // f { # field 'f' of type A + // ... on B { + // ... on C { + // # more stuff + // } + // } + // } + // If the intersection of A and C is non empty and included (or equal) + // to the intersection of A and B, then there is no reason to have + // `... on B` at all because: + // 1. you can do `... on C` on `f` directly since the intersection of A + // and C is non-empty. + // 2. `... on C` restricts strictly more than `... on B` and so the + // latter can't impact the result. + // So if we detect that we're in that situation, we remove the + // `... on B` (but note that this is an optimization, keeping `... on B` + // wouldn't be incorrect, just useless). + let Some(runtime_types_before_tail) = + &self.runtime_types_before_tail_if_last_is_cast + else { return Err(FederationError::internal( - "Could not find corresponding trigger for edge", + "Could not find runtime types of path prior to inline fragment", )); }; - if let GraphPathTrigger::Op(last_operation_element) = - last_edge_trigger.clone().into() + let new_runtime_types_of_tail = self + .graph + .advance_possible_runtime_types(runtime_types_before_tail, Some(new_edge))?; + if !new_runtime_types_of_tail.is_empty() + && new_runtime_types_of_tail.is_subset(&self.runtime_types_of_tail) { - if let OpGraphPathTrigger::OpPathElement(OpPathElement::InlineFragment( - last_operation_element, - )) = last_operation_element.as_ref() - { - if last_operation_element.directives.is_empty() { - // This mean we have 2 typecasts back-to-back, and that means the - // previous operation element might not be useful on this path. More - // precisely, the previous typecast was only useful if it restricted the - // possible runtime types of the type on which it applied more than the - // current typecast does (but note that if the previous typecast had - // directives, we keep it no matter what in case those directives are - // important). - // - // That is, we're in the case where we have (somewhere potentially deep - // in a query): - // f { # field 'f' of type A - // ... on B { - // ... on C { - // # more stuff - // } - // } - // } - // If the intersection of A and C is non empty and included (or equal) - // to the intersection of A and B, then there is no reason to have - // `... on B` at all because: - // 1. you can do `... on C` on `f` directly since the intersection of A - // and C is non-empty. - // 2. `... on C` restricts strictly more than `... on B` and so the - // latter can't impact the result. - // So if we detect that we're in that situation, we remove the - // `... on B` (but note that this is an optimization, keeping `... on B` - // wouldn't be incorrect, just useless). - let Some(runtime_types_before_tail) = - &self.runtime_types_before_tail_if_last_is_cast - else { - return Err(FederationError::internal( - "Could not find runtime types of path prior to inline fragment", - )); - }; - let new_runtime_types_of_tail = - self.graph.advance_possible_runtime_types( - runtime_types_before_tail, - Some(new_edge), - )?; - if !new_runtime_types_of_tail.is_empty() - && new_runtime_types_of_tail.is_subset(&self.runtime_types_of_tail) - { - debug!("Previous cast {last_operation_element:?} is made obsolete by new cast {trigger:?}, removing from path."); - // Note that `edge` starts at the node we wish to eliminate from the - // path. So we need to replace it with the edge going directly from - // the previous node to the new tail for this path. - // - // PORT_NOTE: The JS codebase has a bug where it doesn't check that - // the searched edges are downcast edges. We fix that here. - let (last_edge_head, _) = self.graph.edge_endpoints(last_edge)?; - let edge_tail_weight = self.graph.node_weight(edge_tail)?; - let mut new_edge = None; - for new_edge_ref in self.graph.out_edges(last_edge_head) { - if !matches!( - new_edge_ref.weight().transition, - QueryGraphEdgeTransition::Downcast { .. } - ) { - continue; - } - if self.graph.node_weight(new_edge_ref.target())?.type_ - == edge_tail_weight.type_ - { - new_edge = Some(new_edge_ref.id()); - break; - } - } - if let Some(new_edge) = new_edge { - // We replace the previous operation element with this one. - edges.pop(); - edge_triggers.pop(); - edge_conditions.pop(); - edges.push(new_edge.into()); - edge_triggers.push(Arc::new(trigger)); - edge_conditions.push(condition_path_tree); - return Ok(GraphPath { - graph: self.graph.clone(), - head: self.head, - tail: edge_tail, - edges, - edge_triggers, - edge_conditions, - last_subgraph_entering_edge_info: self - .last_subgraph_entering_edge_info - .clone(), - own_path_ids: self.own_path_ids.clone(), - overriding_path_ids: self.overriding_path_ids.clone(), - runtime_types_of_tail: Arc::new(new_runtime_types_of_tail), - runtime_types_before_tail_if_last_is_cast: self - .runtime_types_before_tail_if_last_is_cast - .clone(), - // We know the edge is a `DownCast`, so if there is no new - // `@defer` taking precedence, we just inherit the prior - // version. - defer_on_tail: if defer.is_some() { - defer - } else { - self.defer_on_tail.clone() - }, - }); - } - } + debug!( + "Previous cast {last_operation_element:?} is made obsolete by new cast {trigger:?}, removing from path." + ); + // Note that `edge` starts at the node we wish to eliminate from the + // path. So we need to replace it with the edge going directly from + // the previous node to the new tail for this path. + // + // PORT_NOTE: The JS codebase has a bug where it doesn't check that + // the searched edges are downcast edges. We fix that here. + let (last_edge_head, _) = self.graph.edge_endpoints(last_edge)?; + let edge_tail_weight = self.graph.node_weight(edge_tail)?; + let mut new_edge = None; + for new_edge_ref in self.graph.out_edges(last_edge_head) { + if !matches!( + new_edge_ref.weight().transition, + QueryGraphEdgeTransition::Downcast { .. } + ) { + continue; + } + if self.graph.node_weight(new_edge_ref.target())?.type_ + == edge_tail_weight.type_ + { + new_edge = Some(new_edge_ref.id()); + break; } } + if let Some(new_edge) = new_edge { + // We replace the previous operation element with this one. + edges.pop(); + edge_triggers.pop(); + edge_conditions.pop(); + edges.push(new_edge.into()); + edge_triggers.push(Arc::new(trigger)); + edge_conditions.push(condition_path_tree); + return Ok(GraphPath { + graph: self.graph.clone(), + head: self.head, + tail: edge_tail, + edges, + edge_triggers, + edge_conditions, + last_subgraph_entering_edge_info: self + .last_subgraph_entering_edge_info + .clone(), + own_path_ids: self.own_path_ids.clone(), + overriding_path_ids: self.overriding_path_ids.clone(), + runtime_types_of_tail: Arc::new(new_runtime_types_of_tail), + runtime_types_before_tail_if_last_is_cast: self + .runtime_types_before_tail_if_last_is_cast + .clone(), + // We know the edge is a `DownCast`, so if there is no new + // `@defer` taking precedence, we just inherit the prior + // version. + defer_on_tail: if defer.is_some() { + defer + } else { + self.defer_on_tail.clone() + }, + matching_context_ids: self.matching_context_ids.clone(), + arguments_to_context_usages: self.arguments_to_context_usages.clone(), + }); + } } } } if matches!( edge_weight.transition, - QueryGraphEdgeTransition::KeyResolution { .. } + QueryGraphEdgeTransition::KeyResolution ) { // We're adding a `@key` edge. If the last edge to that point is an `@interfaceObject` // fake downcast, and if our destination type is not an `@interfaceObject` itself, then @@ -1142,16 +825,61 @@ where // We know last edge is not a cast. runtime_types_before_tail_if_last_is_cast: None, defer_on_tail: defer, + matching_context_ids: self.matching_context_ids.clone(), + arguments_to_context_usages: self.arguments_to_context_usages.clone(), }); } } - edges.push(edge); - edge_triggers.push(Arc::new(trigger)); - edge_conditions.push(condition_path_tree); - if defer.is_none() && self.graph.is_cross_subgraph_edge(new_edge)? { - last_subgraph_entering_edge_info = Some(SubgraphEnteringEdgeInfo { - index: self.edges.len(), + let (new_edge_conditions, new_matching_context_ids, new_arguments_to_context_usages) = + self.merge_edge_conditions_with_resolution(&condition_path_tree, &context_map); + let last_arguments_to_context_usages = new_arguments_to_context_usages.last(); + + if let Some(Some(last_arguments_to_context_usages)) = last_arguments_to_context_usages { + // TODO: Perhaps it is better to explicitly cast this to `GraphPathTriggerRefMut` and + // pull out the field from there. + if let Some(field) = trigger.get_field_mut() { + // We need to add the extra @fromContext arguments to the trigger, but its likely + // pointing to a schema that doesn't have them, so we update the trigger to use the + // appropriate subgraph schema and position first. + let QueryGraphEdgeTransition::FieldCollection { + source, + field_definition_position, + .. + } = &edge_weight.transition + else { + bail!( + "Unexpectedly found field trigger for non-field edge {}", + edge_weight + ); + }; + field.schema = self.graph.schema_by_source(source)?.clone(); + field.field_position = field_definition_position.clone(); + + // Now we can append the extra @fromContext arguments. + let updated_field_arguments = + last_arguments_to_context_usages + .iter() + .map(|(argument_name, usage_entry)| { + Node::new(apollo_compiler::executable::Argument { + name: argument_name.clone(), + value: Node::new(Value::Variable(usage_entry.context_id.clone())), + }) + }); + field.arguments = field + .arguments + .iter() + .cloned() + .chain(updated_field_arguments) + .collect(); + } + } + + edges.push(edge); + edge_triggers.push(Arc::new(trigger)); + if defer.is_none() && self.graph.is_cross_subgraph_edge(new_edge)? { + last_subgraph_entering_edge_info = Some(SubgraphEnteringEdgeInfo { + index: self.edges.len(), conditions_cost: condition_cost, }); } @@ -1161,7 +889,7 @@ where tail: edge_tail, edges, edge_triggers, - edge_conditions, + edge_conditions: new_edge_conditions, // Again, we don't want to set `last_subgraph_entering_edge_info` if we're entering a // `@defer` (see above). last_subgraph_entering_edge_info, @@ -1193,23 +921,105 @@ where } else { None }, + matching_context_ids: new_matching_context_ids, + arguments_to_context_usages: new_arguments_to_context_usages, }) } + #[allow(clippy::type_complexity)] + fn merge_edge_conditions_with_resolution( + &self, + condition_path_tree: &Option>, + context_map: &Option>, + ) -> ( + Vec>>, + Vec>>, + Vec>>, + ) { + let mut edge_conditions = self.edge_conditions.clone(); + let mut matching_context_ids = self.matching_context_ids.clone(); + let mut arguments_to_context_usages = self.arguments_to_context_usages.clone(); + + edge_conditions.push(condition_path_tree.clone()); + matching_context_ids.push(None); + if context_map.is_none() || context_map.as_ref().is_some_and(|m| m.is_empty()) { + arguments_to_context_usages.push(None); + ( + edge_conditions, + matching_context_ids, + arguments_to_context_usages, + ) + } else { + let mut new_arguments_to_context_usages = IndexMap::default(); + for (_, entry) in context_map.iter().flat_map(|map| map.iter()) { + let idx = edge_conditions.len() - entry.levels_in_query_path - 1; + + if let Some(path_tree) = &entry.path_tree { + let merged_conditions = edge_conditions[idx] + .as_ref() + .map_or_else(|| path_tree.clone(), |condition| condition.merge(path_tree)); + edge_conditions[idx] = Some(merged_conditions); + } + matching_context_ids[idx] + .get_or_insert_with(Default::default) + .insert(entry.context_id.clone()); + + new_arguments_to_context_usages.insert( + entry.argument_name.clone(), + ContextUsageEntry { + context_id: entry.context_id.clone(), + relative_path: vec![ + FetchDataPathElement::Parent; + entry.levels_in_data_path + ], + selection_set: entry.selection_set.clone(), + subgraph_argument_type: entry.argument_type.clone(), + }, + ); + } + arguments_to_context_usages.push(Some(new_arguments_to_context_usages)); + ( + edge_conditions, + matching_context_ids, + arguments_to_context_usages, + ) + } + } + + pub(crate) fn head_node(&self) -> Result<&QueryGraphNode, FederationError> { + self.graph.node_weight(self.head) + } + + pub(crate) fn tail_node(&self) -> Result<&QueryGraphNode, FederationError> { + self.graph.node_weight(self.tail) + } + pub(crate) fn iter(&self) -> impl Iterator> { debug_assert_eq!(self.edges.len(), self.edge_triggers.len()); debug_assert_eq!(self.edges.len(), self.edge_conditions.len()); - self.edges - .iter() - .copied() - .zip(&self.edge_triggers) - .zip(&self.edge_conditions) - .map(|((edge, trigger), condition)| (edge, trigger, condition)) + debug_assert_eq!(self.edges.len(), self.matching_context_ids.len()); + debug_assert_eq!(self.edges.len(), self.arguments_to_context_usages.len()); + izip!( + self.edges.iter().copied(), + &self.edge_triggers, + &self.edge_conditions, + &self.matching_context_ids, + &self.arguments_to_context_usages, + ) + .map( + |(edge, trigger, condition, matching_context_ids, arguments_to_context_usages)| { + ( + edge, + trigger, + condition, + matching_context_ids.as_ref(), + arguments_to_context_usages.as_ref(), + ) + }, + ) } - pub(crate) fn next_edges( - &self, - ) -> Result + Iterator, FederationError> { + pub(crate) fn next_edges(&self) -> Result, FederationError> { let get_id = |edge_ref: EdgeReference<_>| edge_ref.id(); if self.defer_on_tail.is_some() { @@ -1231,17 +1041,18 @@ where // avoids some of the edges that we know we don't need to check because they are guaranteed // to be inefficient after the last edge. Note that is purely an optimization (see // https://github.com/apollographql/federation/pull/1653 for more details). - if let Some(last_edge) = self.edges.last() { - if let Some(last_edge) = (*last_edge).into() { - let Some(non_trivial_followup_edges) = - self.graph.non_trivial_followup_edges.get(&last_edge) - else { - return Err(FederationError::internal( - "Unexpectedly missing entry for non-trivial followup edges map", - )); - }; - return Ok(Either::Right(non_trivial_followup_edges.iter().copied())); - } + if let Some(last_edge) = self.edges.last() + && let Some(last_edge) = (*last_edge).into() + { + let Some(non_trivial_followup_edges) = + self.graph.non_trivial_followup_edges.get(&last_edge) + else { + return Err(FederationError::internal(format!( + "Unexpectedly missing entry for {last_edge} in non-trivial followup edges map", + last_edge = EdgeIndexDisplay::new(last_edge, &self.graph) + ))); + }; + return Ok(Either::Right(non_trivial_followup_edges.iter().copied())); } Ok(Either::Left( @@ -1282,20 +1093,20 @@ where let tail_schema = self.graph.schema_by_source(&tail_weight.source)?; let tail_schema_definition = &tail_schema.schema().schema_definition; - if let Some(query_type_name) = &tail_schema_definition.query { - if tail_type_pos.type_name() == &query_type_name.name { - continue; - } + if let Some(query_type_name) = &tail_schema_definition.query + && tail_type_pos.type_name() == &query_type_name.name + { + continue; } - if let Some(mutation_type_name) = &tail_schema_definition.mutation { - if tail_type_pos.type_name() == &mutation_type_name.name { - continue; - } + if let Some(mutation_type_name) = &tail_schema_definition.mutation + && tail_type_pos.type_name() == &mutation_type_name.name + { + continue; } - if let Some(subscription_type_name) = &tail_schema_definition.subscription { - if tail_type_pos.type_name() == &subscription_type_name.name { - continue; - } + if let Some(subscription_type_name) = &tail_schema_definition.subscription + && tail_type_pos.type_name() == &subscription_type_name.name + { + continue; } return Ok(false); } @@ -1355,10 +1166,12 @@ where == Some(self.edges.len() - 2)) } + /* #[cfg_attr( feature = "snapshot_tracing", tracing::instrument(skip_all, level = "trace", name = "GraphPath::can_satisfy_conditions") )] + */ fn can_satisfy_conditions( &self, edge: EdgeIndex, @@ -1368,67 +1181,279 @@ where excluded_conditions: &ExcludedConditions, ) -> Result { let edge_weight = self.graph.edge_weight(edge)?; - if edge_weight.conditions.is_none() { + if edge_weight.conditions.is_none() && edge_weight.required_contexts.is_empty() { return Ok(ConditionResolution::no_conditions()); } + + /* Resolve context conditions */ + + let mut total_cost = 0.; + let mut context_map: IndexMap = IndexMap::default(); + + if !edge_weight.required_contexts.is_empty() { + let mut was_unsatisfied = false; + for ctx in &edge_weight.required_contexts { + let mut levels_in_data_path = 0; + for (mut levels_in_query_path, (e, trigger)) in self + .edges + .iter() + .zip(self.edge_triggers.iter()) + .rev() + .enumerate() + { + let parent_type = trigger.get_field_parent_type(); + levels_in_query_path += 1; + if parent_type.is_some() { + levels_in_data_path += 1; + } + let Some(e) = (*e).into() else { continue }; + if was_unsatisfied || context_map.contains_key(&ctx.argument_name) { + continue; + } + // There's a context match if in the supergraph schema, the field's parent type + // is equal to or a subtype of one of the @context types. + let Some(parent_type) = parent_type else { + continue; + }; + let supergraph_schema = self.graph.supergraph_schema()?; + let parent_type_in_supergraph: CompositeTypeDefinitionPosition = + supergraph_schema + .get_type(parent_type.type_name().clone())? + .try_into()?; + if !ctx.types_with_context_set.iter().fallible_any(|pos| { + if pos.type_name() == parent_type_in_supergraph.type_name() { + return Ok::<_, FederationError>(true); + } + match &parent_type_in_supergraph { + CompositeTypeDefinitionPosition::Object(parent_type_in_supergraph) => { + if parent_type_in_supergraph + .get(supergraph_schema.schema())? + .implements_interfaces + .iter() + .any(|item| &item.name == pos.type_name()) + { + return Ok(true); + } + } + CompositeTypeDefinitionPosition::Interface( + parent_type_in_supergraph, + ) => { + if parent_type_in_supergraph + .get(supergraph_schema.schema())? + .implements_interfaces + .iter() + .any(|item| &item.name == pos.type_name()) + { + return Ok(true); + } + } + _ => {} + } + let pos_in_supergraph: CompositeTypeDefinitionPosition = supergraph_schema + .get_type(pos.type_name().clone())? + .try_into()?; + if let CompositeTypeDefinitionPosition::Union(pos_in_supergraph) = + &pos_in_supergraph + && pos_in_supergraph + .get(supergraph_schema.schema())? + .members + .iter() + .any(|item| &item.name == parent_type_in_supergraph.type_name()) + { + return Ok(true); + } + Ok(false) + })? { + continue; + } + + // We have a match, so parse the selection set against the field's parent type + // in the supergraph schema. + let mut selection_set = parse_field_value_without_validation( + &supergraph_schema, + parent_type_in_supergraph.type_name().clone(), + &ctx.selection, + )?; + + // In the current version of @context (v0.1), type conditions (if they appear at + // all) are only allowed at top-level, and are only allowed to reference object + // types. Due to duck-typing semantics, there may be type conditions that have + // an empty intersection in possible runtime types with their parent type, which + // means we need to remove those type conditions for the selection set to be + // considered valid GraphQL. + let possible_runtime_types = + supergraph_schema.possible_runtime_types(parent_type_in_supergraph)?; + selection_set.selection_set.selections = selection_set + .selection_set + .selections + .into_iter() + .map(|selection| { + let apollo_compiler::executable::Selection::InlineFragment( + inline_fragment, + ) = &selection + else { + return Ok::<_, FederationError>(Some(selection)); + }; + let Some(type_condition) = &inline_fragment.type_condition else { + return Ok(Some(selection)); + }; + let type_condition_pos: ObjectTypeDefinitionPosition = + supergraph_schema + .get_type(type_condition.clone())? + .try_into()?; + if possible_runtime_types.contains(&type_condition_pos) { + return Ok(Some(selection)); + } + Ok(None) + }) + .process_results(|r| r.flatten().collect())?; + + // The field set should be valid now, so we'll validate and convert to our + // selection set representation. + let selection_set = validate_field_value(&supergraph_schema, selection_set)?; + let resolution = condition_resolver.resolve( + e, + context, + excluded_destinations, + excluded_conditions, + Some(&selection_set), + )?; + let context_id = self.graph.context_id_by_source_and_argument( + &ctx.subgraph_name, + &ctx.argument_coordinate, + )?; + match &resolution { + ConditionResolution::Satisfied { + cost, path_tree, .. + } => { + total_cost += cost; + let entry = ContextMapEntry { + levels_in_data_path, + levels_in_query_path, + path_tree: path_tree.clone(), + selection_set, + argument_name: ctx.argument_name.clone(), + argument_type: ctx.argument_type.clone(), + context_id: context_id.clone(), + }; + context_map.insert(ctx.argument_name.clone(), entry); + } + ConditionResolution::Unsatisfied { .. } => was_unsatisfied = true, + } + } + } + + if edge_weight + .required_contexts + .iter() + .any(|ctx| !context_map.contains_key(&ctx.argument_name)) + { + debug!("@fromContext requires a context that is not set in graph path"); + return Ok(ConditionResolution::Unsatisfied { + reason: Some(UnsatisfiedConditionReason::NoSetContext), + }); + } + + if was_unsatisfied { + debug!("@fromContext selection set is unsatisfied"); + return Ok(ConditionResolution::Unsatisfied { reason: None }); + } + + // it's possible that we will need to create a new fetch group at this point, in which + // case we'll need to collect the keys to jump back to this object as a precondition + // for satisfying it. + let (edge_head, _) = self.graph.edge_endpoints(edge)?; + if self.graph.get_locally_satisfiable_key(edge_head)?.is_none() { + debug!("Post-context conditions cannot be satisfied"); + return Ok(ConditionResolution::Unsatisfied { + reason: Some(UnsatisfiedConditionReason::NoPostRequireKey), + }); + } + + if edge_weight.conditions.is_none() { + return Ok(ConditionResolution::Satisfied { + cost: total_cost, + path_tree: None, + context_map: Some(context_map), + }); + } + } + + /* Resolve all other conditions */ + debug_span!("Checking conditions {conditions} on edge {edge_weight}"); - let resolution = condition_resolver.resolve( + let mut resolution = condition_resolver.resolve( edge, context, excluded_destinations, excluded_conditions, + None, )?; - if let Some(Some(last_edge)) = self.edges.last().map(|e| (*e).into()) { - if matches!( + if matches!(resolution, ConditionResolution::Unsatisfied { .. }) { + return Ok(ConditionResolution::Unsatisfied { reason: None }); + } + if let Some(Some(last_edge)) = self.edges.last().map(|e| (*e).into()) + && matches!( edge_weight.transition, QueryGraphEdgeTransition::FieldCollection { .. } + ) + { + let last_edge_weight = self.graph.edge_weight(last_edge)?; + if !matches!( + last_edge_weight.transition, + QueryGraphEdgeTransition::KeyResolution ) { - let last_edge_weight = self.graph.edge_weight(last_edge)?; - if !matches!( - last_edge_weight.transition, - QueryGraphEdgeTransition::KeyResolution - ) { - let in_same_subgraph = if let ConditionResolution::Satisfied { - path_tree: Some(path_tree), - .. - } = &resolution - { - path_tree.is_all_in_same_subgraph()? - } else { - true + let in_same_subgraph = if let ConditionResolution::Satisfied { + path_tree: Some(path_tree), + .. + } = &resolution + { + path_tree.is_all_in_same_subgraph()? + } else { + true + }; + if in_same_subgraph { + debug!("@requires conditions are satisfied, but validating post-require key."); + let (edge_head, _) = self.graph.edge_endpoints(edge)?; + if self.graph.get_locally_satisfiable_key(edge_head)?.is_none() { + debug!("Post-require conditions cannot be satisfied"); + return Ok(ConditionResolution::Unsatisfied { + reason: Some(UnsatisfiedConditionReason::NoPostRequireKey), + }); }; - if in_same_subgraph { - debug!( - "@requires conditions are satisfied, but validating post-require key." - ); - let (edge_head, _) = self.graph.edge_endpoints(edge)?; - if self.graph.get_locally_satisfiable_key(edge_head)?.is_none() { - debug!("Post-require conditions cannot be satisfied"); - return Ok(ConditionResolution::Unsatisfied { - reason: Some(UnsatisfiedConditionReason::NoPostRequireKey), - }); - }; - // We're in a case where we have an `@requires` application (we have - // conditions and the new edge has a `FieldCollection` transition) and we - // have to jump to another subgraph to satisfy the `@requires`, which means - // we need to use a key on "the current subgraph" to resume collecting the - // field with the `@requires`. `get_locally_satisfiable_key()` essentially - // tells us that we have such key, and that's good enough here. Note that - // the way the code is organised, we don't use an actual edge of the query - // graph, so we cannot use `condition_resolver` and so it's not easy to get - // a proper cost or tree. That's ok in the sense that the cost of the key is - // negligible because we know it's a "local" one (there is no subgraph jump) - // and the code to build plan will deal with adding that key anyway (so not - // having the tree is ok). - // TODO(Sylvain): the whole handling of `@requires` is a bit too complex and - // hopefully we might be able to clean that up, but it's unclear to me how - // at the moment and it may not be a small change so this will have to do - // for now. - } + // We're in a case where we have an `@requires` application (we have + // conditions and the new edge has a `FieldCollection` transition) and we + // have to jump to another subgraph to satisfy the `@requires`, which means + // we need to use a key on "the current subgraph" to resume collecting the + // field with the `@requires`. `get_locally_satisfiable_key()` essentially + // tells us that we have such key, and that's good enough here. Note that + // the way the code is organised, we don't use an actual edge of the query + // graph, so we cannot use `condition_resolver` and so it's not easy to get + // a proper cost or tree. That's ok in the sense that the cost of the key is + // negligible because we know it's a "local" one (there is no subgraph jump) + // and the code to build plan will deal with adding that key anyway (so not + // having the tree is ok). + // TODO(Sylvain): the whole handling of `@requires` is a bit too complex and + // hopefully we might be able to clean that up, but it's unclear to me how + // at the moment and it may not be a small change so this will have to do + // for now. } } } - debug!("Condition resolution: {resolution:?}"); + if let ConditionResolution::Satisfied { + cost, + context_map: ctx_map, + .. + } = &mut resolution + { + *cost += total_cost; + *ctx_map = Some(context_map); + } + snapshot!( + "ConditionResolution", + resolution.to_string(), + "Condition resolution" + ); Ok(resolution) } @@ -1441,13 +1466,13 @@ where tracing::instrument(skip_all, level = "trace") )] #[allow(clippy::too_many_arguments)] - fn advance_with_non_collecting_and_type_preserving_transitions( + fn advance_with_non_collecting_and_type_preserving_transitions( self: &Arc, context: &OpGraphPathContext, condition_resolver: &mut impl ConditionResolver, excluded_destinations: &ExcludedDestinations, excluded_conditions: &ExcludedConditions, - override_conditions: &EnabledOverrideConditions, + override_conditions: &OverrideConditions, transition_and_context_to_trigger: impl Fn( &QueryGraphEdgeTransition, &OpGraphPathContext, @@ -1456,9 +1481,13 @@ where &Arc, NodeIndex, &Arc, - &EnabledOverrideConditions, - ) -> Option, - ) -> Result, FederationError> { + &OverrideConditions, + ) -> Result, FederationError>, + disabled_subgraphs: &IndexSet>, + ) -> Result, FederationError> + where + UnadvanceableClosures: Into, + { // If we're asked for indirect paths after an "@interfaceObject fake down cast" but that // down cast comes just after non-collecting edge(s), then we can ignore the ask (skip // indirect paths from there). The reason is that the presence of the non-collecting edges @@ -1467,9 +1496,42 @@ where // on, so any indirect path from that fake down cast will have a valid indirect path // before it, and so will have been taken into account independently. if self.last_edge_is_interface_object_fake_down_cast_after_entering_subgraph()? { + let path = self.clone(); return Ok(IndirectPaths { paths: Arc::new(vec![]), - dead_ends: Arc::new(Unadvanceables(vec![])), + dead_ends: UnadvanceableClosures(Arc::new(vec![ + Arc::new(LazyLock::new(Box::new(move || { + let path_tail_weight = path.graph.node_weight(path.tail)?; + let reachable_subgraphs = path.next_edges()?.map(|edge| { + let edge_weight = path.graph.edge_weight(edge)?; + let (_, edge_tail) = path.graph.edge_endpoints(edge)?; + let edge_tail_weight = path.graph.node_weight(edge_tail)?; + Ok::<_, FederationError>( + if !edge_weight.transition.collect_operation_elements() + && edge_tail_weight.source != path_tail_weight.source + { + Some(edge_tail_weight.source.clone()) + } else { + None + } + ) + }).process_results( + |iter| iter.flatten().collect::>() + )?; + Ok(Unadvanceables(Arc::new(reachable_subgraphs.into_iter().map(|subgraph| { + Arc::new(Unadvanceable { + reason: UnadvanceableReason::IgnoredIndirectPath, + from_subgraph: path_tail_weight.source.clone(), + to_subgraph: subgraph.clone(), + details: format!( + "ignoring moving from \"{}\" to \"{}\" as a more direct option exists", + path_tail_weight.source, + subgraph, + ), + }) + }).collect()))) + }))) + ])).into(), }); } @@ -1489,7 +1551,7 @@ where Option<(Arc>, QueryPlanCost)>; let mut best_path_by_source: IndexMap, BestPathInfo> = IndexMap::default(); - let dead_ends = vec![]; + let mut dead_end_closures: Vec = vec![]; // Note that through `excluded` we avoid taking the same edge from multiple options. But // that means it's important we try the smallest paths first. That is, if we could in theory // have path A -> B and A -> C -> B, and we can do B -> D, then we want to keep A -> B -> D, @@ -1498,17 +1560,22 @@ where heap.push(HeapElement(self.clone())); while let Some(HeapElement(to_advance)) = heap.pop() { - debug!("From {to_advance:?}"); + debug!("From {to_advance}"); let span = debug_span!(" |"); let _guard = span.enter(); + let mut does_non_collecting_next_edge_exist = false; for edge in to_advance.next_edges()? { - debug!("Testing edge {edge:?}"); - let span = debug_span!(" |"); - let _guard = span.enter(); let edge_weight = self.graph.edge_weight(edge)?; if edge_weight.transition.collect_operation_elements() { continue; } + does_non_collecting_next_edge_exist = true; + debug!( + "Testing edge {edge}", + edge = EdgeIndexDisplay::new(edge, &self.graph) + ); + let span = debug_span!(" |"); + let _guard = span.enter(); let (edge_head, edge_tail) = self.graph.edge_endpoints(edge)?; let edge_tail_weight = self.graph.node_weight(edge_tail)?; @@ -1517,6 +1584,11 @@ where continue; } + if disabled_subgraphs.contains(&edge_tail_weight.source) { + debug!("Ignored: subgraph is disabled"); + continue; + } + // If the edge takes us back to the subgraph in which we started, we're not really // interested (we've already checked for a direct transition from that original // subgraph). One exception though is if we're just after a @defer, in which case @@ -1547,34 +1619,33 @@ where let prev_for_source = match prev_for_source { Some(Some(prev_for_source)) => Some(prev_for_source), Some(None) => { - debug!("Ignored: we've shown before than going to {original_source:?} is not productive"); + debug!( + "Ignored: we've shown before than going to {original_source:?} is not productive" + ); continue; } None => None, }; - if let Some(prev_for_source) = prev_for_source { - if (prev_for_source.0.edges.len() < to_advance.edges.len() + 1) + if let Some(prev_for_source) = prev_for_source + && ((prev_for_source.0.edges.len() < to_advance.edges.len() + 1) || (prev_for_source.0.edges.len() == to_advance.edges.len() + 1 - && prev_for_source.1 <= 1.0) - { - debug!( - "Ignored: a better (shorter) path to the same subgraph already added" - ); - // We've already found another path that gets us to the same subgraph rather - // than the edge we're about to check. If that previous path is strictly - // shorter than the path we'd obtain with the new edge, then we don't - // consider this edge (it's a longer way to get to the same place). And if - // the previous path is the same size (as the one obtained with that edge), - // but that previous path's cost for getting the condition was 0 or 1, then - // the new edge cannot really improve on this and we don't bother with it. - // - // Note that a cost of 0 can only happen during composition validation where - // all costs are 0 to mean "we don't care about costs". This effectively - // means that for validation, as soon as we have a path to a subgraph, we - // ignore other options even if they may be "faster". - continue; - } + && prev_for_source.1 <= 1.0)) + { + debug!("Ignored: a better (shorter) path to the same subgraph already added"); + // We've already found another path that gets us to the same subgraph rather + // than the edge we're about to check. If that previous path is strictly + // shorter than the path we'd obtain with the new edge, then we don't + // consider this edge (it's a longer way to get to the same place). And if + // the previous path is the same size (as the one obtained with that edge), + // but that previous path's cost for getting the condition was 0 or 1, then + // the new edge cannot really improve on this and we don't bother with it. + // + // Note that a cost of 0 can only happen during composition validation where + // all costs are 0 to mean "we don't care about costs". This effectively + // means that for validation, as soon as we have a path to a subgraph, we + // ignore other options even if they may be "faster". + continue; } if excluded_conditions.is_excluded(edge_weight.conditions.as_ref()) { @@ -1597,221 +1668,371 @@ where &excluded_destinations.add_excluded(&edge_tail_weight.source), excluded_conditions, )?; - if let ConditionResolution::Satisfied { path_tree, cost } = condition_resolution { - debug!("Condition satisfied"); + + let ConditionResolution::Satisfied { + path_tree, + cost, + context_map, + } = condition_resolution + else { drop(guard); - // We can get to `edge_tail_weight.source` with that edge. But if we had already - // found another path to the same subgraph, we want to replace it with this one - // only if either 1) it is shorter or 2) if it's of equal size, only if the - // condition cost is lower than the previous one. - if let Some(prev_for_source) = prev_for_source { - if prev_for_source.0.edges.len() == to_advance.edges.len() + 1 - && prev_for_source.1 <= cost - { - debug!("Ignored: a better (less costly) path to the same subgraph already added"); - continue; - } - } + debug!("Condition unsatisfiable"); + let to_advance = to_advance.clone(); + dead_end_closures.push(Arc::new(LazyLock::new(Box::new(move || { + let to_advance_tail_weight = to_advance.graph.node_weight(to_advance.tail)?; + let edge_weight = to_advance.graph.edge_weight(edge)?; + let (edge_head, edge_tail) = to_advance.graph.edge_endpoints(edge)?; + let edge_head_weight = to_advance.graph.node_weight(edge_head)?; + let edge_tail_weight = to_advance.graph.node_weight(edge_tail)?; + let from_subgraph = &to_advance_tail_weight.source; + let to_subgraph = &edge_tail_weight.source; + let from_subgraph_schema = to_advance.graph.schema_by_source(from_subgraph)?; + let Some(conditions) = &edge_weight.conditions else { + bail!("Unsatisfiable conditions unexpectedly missing on edge"); + }; + let has_overridden_field = Self::condition_has_overridden_fields_in_subgraph( + from_subgraph_schema, + conditions, + )?; + let extra_message = if has_overridden_field { + format!( + " (note that some of those key fields are overridden in \"{from_subgraph}\")", + ) + } else { + "".to_owned() + }; + Ok(Unadvanceables(Arc::new(vec![Arc::new(Unadvanceable { + reason: UnadvanceableReason::UnsatisfiableKeyCondition, + from_subgraph: from_subgraph.clone(), + to_subgraph: to_subgraph.clone(), + details: format!( + "cannot move to subgraph \"{}\" using @key(fields: \"{}\") of \"{}\", the key field(s) cannot be resolved from subgraph \"{}\"{}", + to_subgraph, + FieldSetDisplay(conditions), + edge_head_weight.type_, + from_subgraph, + extra_message + ), + })]))) + })))); + continue; + }; + drop(guard); + debug!("Condition satisfied"); + + // We can get to `edge_tail_weight.source` with that edge. But if we had already + // found another path to the same subgraph, we want to replace it with this one only + // if either 1) it is shorter or 2) if it's of equal size, only if the condition + // cost is lower than the previous one. + if let Some(prev_for_source) = prev_for_source + && prev_for_source.0.edges.len() == to_advance.edges.len() + 1 + && prev_for_source.1 <= cost + { + debug!( + "Ignored: a better (less costly) path to the same subgraph already added" + ); + continue; + } - // It's important we minimize the number of options this method returns, because - // during query planning with many fields, options here translate to state - // explosion. This is why above we eliminated edges that provably have better - // options. - // - // But we can do a slightly more involved check. Suppose we have a few subgraphs - // A, B and C, and suppose that we're considering an edge from B to C. We can - // then look at which subgraph we were in before reaching B (which can be "none" - // if the query starts at B), and let say that it is A. In other words, if we - // use the edge we're considering, we'll be looking at a path like: - // ... -> A -> B -> -> C - // - // Now, we can fairly easily check if the fields we collected in B (the ``) can be also collected directly (without keys, nor requires) - // from A and if after that we could take an edge to C. If we can do all that, - // then we know that the path we're considering is strictly less efficient than: - // ... -> A -> -> C - // - // Furthermore, since we've confirmed its a valid path, it will be found by - // another branch of the algorithm. In that case, we can ignore the edge to C, - // knowing a better path exists. Doing this drastically reduces state explosion - // in a number of cases. - if let Some(last_subgraph_entering_edge_info) = - &to_advance.last_subgraph_entering_edge_info - { - let Some(last_subgraph_entering_edge) = - to_advance.edges[last_subgraph_entering_edge_info.index].into() - else { - return Err(FederationError::internal( - "Subgraph-entering edge is unexpectedly absent", - )); + // It's important we minimize the number of options this method returns, because + // during query planning with many fields, options here translate to state + // explosion. This is why above we eliminated edges that provably have better + // options. + // + // But we can do a slightly more involved check. Suppose we have a few subgraphs A, + // B and C, and suppose that we're considering an edge from B to C. We can then look + // at which subgraph we were in before reaching B (which can be "none" if the query + // starts at B), and let say that it is A. In other words, if we use the edge we're + // considering, we'll be looking at a path like: + // ... -> A -> B -> -> C + // + // Now, we can fairly easily check if the fields we collected in B (the ``) can be also collected directly (without keys, nor requires) from + // A and if after that we could take an edge to C. If we can do all that, then we + // know that the path we're considering is strictly less efficient than: + // ... -> A -> -> C + // + // Furthermore, since we've confirmed it's a valid path, it will be found by another + // branch of the algorithm. In that case, we can ignore the edge to C, knowing a + // better path exists. Doing this drastically reduces state explosion in a number of + // cases. + if let Some(last_subgraph_entering_edge_info) = + &to_advance.last_subgraph_entering_edge_info + { + let Some(last_subgraph_entering_edge) = + to_advance.edges[last_subgraph_entering_edge_info.index].into() + else { + return Err(FederationError::internal( + "Subgraph-entering edge is unexpectedly absent", + )); + }; + + let (last_subgraph_entering_edge_head, last_subgraph_entering_edge_tail) = + self.graph.edge_endpoints(last_subgraph_entering_edge)?; + let last_subgraph_entering_edge_tail_weight = + self.graph.node_weight(last_subgraph_entering_edge_tail)?; + let QueryGraphNodeType::SchemaType(last_subgraph_entering_edge_tail_type_pos) = + &last_subgraph_entering_edge_tail_weight.type_ + else { + return Err(FederationError::internal( + "Subgraph-entering edge tail is unexpectedly a federated root", + )); + }; + if Some(last_subgraph_entering_edge_tail_type_pos) != tail_type_pos { + let last_subgraph_entering_edge_weight = + self.graph.edge_weight(last_subgraph_entering_edge)?; + + // If the previous subgraph is an actual subgraph, the head of the last + // subgraph-entering edge would be where a direct path starts. If the + // previous subgraph is a federated root, we instead take the previous + // subgraph to be the destination subgraph of this edge, and that subgraph's + // root of the same root kind (if it exists) would be where a direct path + // starts. + let direct_path_start_node = if matches!( + last_subgraph_entering_edge_weight.transition, + QueryGraphEdgeTransition::SubgraphEnteringTransition + ) { + let root = to_advance.head; + let root_weight = self.graph.node_weight(root)?; + let QueryGraphNodeType::FederatedRootType(root_kind) = + &root_weight.type_ + else { + return Err(FederationError::internal( + "Encountered non-root path with a subgraph-entering transition", + )); + }; + self.graph + .root_kinds_to_nodes_by_source(&edge_tail_weight.source)? + .get(root_kind) + .copied() + } else { + Some(last_subgraph_entering_edge_head) }; - let (last_subgraph_entering_edge_head, last_subgraph_entering_edge_tail) = - self.graph.edge_endpoints(last_subgraph_entering_edge)?; - let last_subgraph_entering_edge_tail_weight = - self.graph.node_weight(last_subgraph_entering_edge_tail)?; - let QueryGraphNodeType::SchemaType( - last_subgraph_entering_edge_tail_type_pos, - ) = &last_subgraph_entering_edge_tail_weight.type_ - else { - return Err(FederationError::internal( - "Subgraph-entering edge tail is unexpectedly a federated root", - )); + // If the previous subgraph is a federated root, as noted above we take the + // previous subgraph to instead be the destination subgraph of this edge, so + // we must manually indicate that here. + let is_edge_to_previous_subgraph = if matches!( + last_subgraph_entering_edge_weight.transition, + QueryGraphEdgeTransition::SubgraphEnteringTransition + ) { + true + } else { + let last_subgraph_entering_edge_head_weight = + self.graph.node_weight(last_subgraph_entering_edge_head)?; + last_subgraph_entering_edge_head_weight.source + == edge_tail_weight.source }; - if Some(last_subgraph_entering_edge_tail_type_pos) != tail_type_pos { - let last_subgraph_entering_edge_weight = - self.graph.edge_weight(last_subgraph_entering_edge)?; - - // If the previous subgraph is an actual subgraph, the head of the last - // subgraph-entering edge would be where a direct path starts. If the - // previous subgraph is a federated root, we instead take the previous - // subgraph to be the destination subgraph of this edge, and that - // subgraph's root of the same root kind (if it exists) would be where a - // direct path starts. - let direct_path_start_node = if matches!( - last_subgraph_entering_edge_weight.transition, - QueryGraphEdgeTransition::SubgraphEnteringTransition - ) { - let root = to_advance.head; - let root_weight = self.graph.node_weight(root)?; - let QueryGraphNodeType::FederatedRootType(root_kind) = - &root_weight.type_ + + let direct_path_end_node = + if let Some(direct_path_start_node) = direct_path_start_node { + let QueryGraphNodeType::SchemaType(edge_tail_type_pos) = + &edge_tail_weight.type_ else { - return Err(FederationError::internal("Encountered non-root path with a subgraph-entering transition")); + return Err(FederationError::internal( + "Edge tail is unexpectedly a federated root", + )); }; - self.graph - .root_kinds_to_nodes_by_source(&edge_tail_weight.source)? - .get(root_kind) - .copied() - } else { - Some(last_subgraph_entering_edge_head) - }; - - // If the previous subgraph is a federated root, as noted above we take - // the previous subgraph to instead be the destination subgraph of this - // edge, so we must manually indicate that here. - let is_edge_to_previous_subgraph = if matches!( - last_subgraph_entering_edge_weight.transition, - QueryGraphEdgeTransition::SubgraphEnteringTransition - ) { - true + to_advance.check_direct_path_from_node( + last_subgraph_entering_edge_info.index + 1, + direct_path_start_node, + edge_tail_type_pos, + &node_and_trigger_to_edge, + override_conditions, + )? } else { - let last_subgraph_entering_edge_head_weight = - self.graph.node_weight(last_subgraph_entering_edge_head)?; - last_subgraph_entering_edge_head_weight.source - == edge_tail_weight.source + None }; - let direct_path_end_node = - if let Some(direct_path_start_node) = direct_path_start_node { - let QueryGraphNodeType::SchemaType(edge_tail_type_pos) = - &edge_tail_weight.type_ - else { - return Err(FederationError::internal( - "Edge tail is unexpectedly a federated root", - )); - }; - to_advance.check_direct_path_from_node( - last_subgraph_entering_edge_info.index + 1, - direct_path_start_node, - edge_tail_type_pos, - &node_and_trigger_to_edge, - override_conditions, - )? + if let Some(direct_path_end_node) = direct_path_end_node { + let direct_key_edge_max_cost = last_subgraph_entering_edge_info + .conditions_cost + + if is_edge_to_previous_subgraph { + 0.0 } else { - None + cost }; - - if let Some(direct_path_end_node) = direct_path_end_node { - let direct_key_edge_max_cost = last_subgraph_entering_edge_info - .conditions_cost - + if is_edge_to_previous_subgraph { - 0.0 - } else { - cost - }; - if is_edge_to_previous_subgraph - || self.graph.has_satisfiable_direct_key_edge( - direct_path_end_node, - &edge_tail_weight.source, - condition_resolver, - direct_key_edge_max_cost, - )? - { - debug!("Ignored: edge correspond to a detour by subgraph {} from subgraph {:?}: ", edge_tail_weight.source, self.graph.node_weight(last_subgraph_entering_edge_head)?.source); + if is_edge_to_previous_subgraph + || self.graph.has_satisfiable_direct_key_edge( + direct_path_end_node, + &edge_tail_weight.source, + condition_resolver, + direct_key_edge_max_cost, + )? + { + debug!( + "Ignored: edge correspond to a detour by subgraph {} from subgraph {:?}: ", + edge_tail_weight.source, + self.graph + .node_weight(last_subgraph_entering_edge_head)? + .source + ); + debug!( + "we have a direct path from {} to {} in {}.", + self.graph + .node_weight(last_subgraph_entering_edge_head)? + .type_, + edge_tail_weight.type_, + self.graph + .node_weight(last_subgraph_entering_edge_head)? + .source + ); + if !is_edge_to_previous_subgraph { debug!( - "we have a direct path from {} to {} in {}.", - self.graph - .node_weight(last_subgraph_entering_edge_head)? - .type_, - edge_tail_weight.type_, - self.graph - .node_weight(last_subgraph_entering_edge_head)? - .source + "And, it can move to {} from there", + edge_tail_weight.source ); - if !is_edge_to_previous_subgraph { - debug!( - "And, it can move to {} from there", - edge_tail_weight.source - ); - } - // We just found that going to the previous subgraph is useless - // because there is a more direct path. But we additionally - // record that this previous subgraph should be avoided - // altogether because some other longer path could try to get - // back to that same source but defeat this specific check due - // to having taken another edge first (and thus the last - // subgraph-entering edge is different). - // - // What we mean here is that if `to_advance` path is - // ... -> A -> B -> - // and we just found that we don't want to keep - // ... -> A -> B -> -> A - // because we know - // ... -> A -> - // is possible directly, then we don't want this - // method to later add - // ... -> A -> B -> -> C -> A - // as that is equally not useful. - best_path_by_source - .insert(edge_tail_weight.source.clone(), None); - continue; } + // We just found that going to the previous subgraph is useless + // because there is a more direct path. But we additionally record + // that this previous subgraph should be avoided altogether because + // some other longer path could try to get back to that same source + // but defeat this specific check due to having taken another edge + // first (and thus the last subgraph-entering edge is different). + // + // What we mean here is that if `to_advance` path is + // ... -> A -> B -> + // and we just found that we don't want to keep + // ... -> A -> B -> -> A + // because we know + // ... -> A -> + // is possible directly, then we don't want this method to later add + // ... -> A -> B -> -> C -> A + // as that is equally not useful. + best_path_by_source.insert(edge_tail_weight.source.clone(), None); + // We also record a dead-end because this optimization might make us + // return no path at all, and having recorded no-dead ends would + // break an assertion in `advance_with_transition()` that assumes + // that if we have recorded no-dead end, that's because we have no + // key edges. But note that this "dead end" message shouldn't really + // ever reach users. + let to_advance = to_advance.clone(); + dead_end_closures.push(Arc::new(LazyLock::new(Box::new(move || { + let to_advance_tail_weight = + to_advance.graph.node_weight(to_advance.tail)?; + let edge_weight = to_advance.graph.edge_weight(edge)?; + let (edge_head, edge_tail) = + to_advance.graph.edge_endpoints(edge)?; + let edge_head_weight = to_advance.graph.node_weight(edge_head)?; + let edge_tail_weight = to_advance.graph.node_weight(edge_tail)?; + let Some(conditions) = &edge_weight.conditions else { + bail!("Conditions unexpectedly missing on edge"); + }; + Ok(Unadvanceables(Arc::new(vec![Arc::new(Unadvanceable { + reason: UnadvanceableReason::IgnoredIndirectPath, + from_subgraph: to_advance_tail_weight.source.clone(), + to_subgraph: edge_tail_weight.source.clone(), + details: format!( + "ignoring moving to subgraph \"{}\" using @key(fields: \"{}\") of \"{}\" because there is a more direct path in {} that avoids {} altogether", + edge_tail_weight.source, + FieldSetDisplay(conditions), + edge_head_weight.type_, + edge_tail_weight.source, + to_advance_tail_weight.source, + ), + })]))) + })))); + continue; } } } + } - let updated_path = Arc::new(to_advance.add( - transition_and_context_to_trigger(&edge_weight.transition, context), - edge.into(), - ConditionResolution::Satisfied { cost, path_tree }, - None, - )?); - best_path_by_source.insert( - edge_tail_weight.source.clone(), - Some((updated_path.clone(), cost)), - ); - // It can be necessary to "chain" keys, because different subgraphs may have - // different keys exposed, and so we when we took a key, we want to check if - // there is a new key we can now use that takes us to other subgraphs. For other - // non-collecting edges ('RootTypeResolution' and 'SubgraphEnteringTransition') - // however, chaining never give us additional value. - // - // One exception is the case of self-edges (which stay on the same node), as - // those will only be looked at just after a @defer to handle potentially - // re-entering the same subgraph. When we take this, there's no point in looking - // for chaining since we'll independently check the other edges already. - if matches!( - edge_weight.transition, - QueryGraphEdgeTransition::KeyResolution - ) { - let edge_head_weight = self.graph.node_weight(edge_head)?; - if edge_head_weight.source != edge_tail_weight.source { - heap.push(HeapElement(updated_path)); - } + let updated_path = Arc::new(to_advance.add( + transition_and_context_to_trigger(&edge_weight.transition, context), + edge.into(), + ConditionResolution::Satisfied { + cost, + path_tree, + context_map, + }, + None, + )?); + best_path_by_source.insert( + edge_tail_weight.source.clone(), + Some((updated_path.clone(), cost)), + ); + // It can be necessary to "chain" keys, because different subgraphs may have + // different keys exposed, and so we when we took a key, we want to check if there + // is a new key we can now use that takes us to other subgraphs. For other + // non-collecting edges ('RootTypeResolution' and 'SubgraphEnteringTransition') + // however, chaining never give us additional value. + // + // One exception is the case of self-edges (which stay on the same node), as those + // will only be looked at just after a @defer to handle potentially re-entering the + // same subgraph. When we take this, there's no point in looking for chaining since + // we'll independently check the other edges already. + if matches!( + edge_weight.transition, + QueryGraphEdgeTransition::KeyResolution + ) { + let edge_head_weight = self.graph.node_weight(edge_head)?; + if edge_head_weight.source != edge_tail_weight.source { + heap.push(HeapElement(updated_path)); } + } + } + if !does_non_collecting_next_edge_exist { + // The subtlety here is that this may either mean that there are no non-collecting + // edges from the tail of this path, or that there are some but that they're + // considered "trivial" edges since `next_edges()` above may end up calling + // `QueryGraph::non_trivial_followup_edges`. In the latter case, this means there is + // a key we could use, but it get us back to the previous vertex in the path, which + // is useless. But we distinguish that case to 1) make the error messaging more + // helpful for debugging, and 2) much more importantly, to record a "dead-end" for + // this path. + let out_edges = to_advance + .graph + .out_edges(to_advance.tail) + .into_iter() + .filter(|edge_ref| !edge_ref.weight().transition.collect_operation_elements()) + .map(|edge_ref| edge_ref.id()) + .collect::>(); + if out_edges.is_empty() { + debug!( + "Nothing to try for {}: it has no non-collecting outbound edges", + to_advance, + ); } else { - debug!("Condition unsatisfiable: {condition_resolution:?}"); + debug!( + "Nothing to try for {}: it only has \"trivial\" non-collecting outbound edges", + to_advance, + ); + let to_advance = to_advance.clone(); + let original_source = original_source.clone(); + dead_end_closures.push(Arc::new(LazyLock::new(Box::new(move || { + let to_advance_tail_weight = to_advance.graph.node_weight(to_advance.tail)?; + Ok(Unadvanceables(Arc::new(out_edges.into_iter().map(|edge| { + let edge_weight = to_advance.graph.edge_weight(edge)?; + let (edge_head, edge_tail) = to_advance.graph.edge_endpoints(edge)?; + let edge_head_weight = to_advance.graph.node_weight(edge_head)?; + let edge_tail_weight = to_advance.graph.node_weight(edge_tail)?; + Ok::<_, FederationError>( + if edge_tail_weight.source != to_advance_tail_weight.source + && edge_tail_weight.source != original_source + { + let Some(conditions) = &edge_weight.conditions else { + bail!("@key field unexpectedly had no conditions"); + }; + Some(Arc::new(Unadvanceable { + reason: UnadvanceableReason::IgnoredIndirectPath, + from_subgraph: to_advance_tail_weight.source.clone(), + to_subgraph: edge_tail_weight.source.clone(), + details: format!( + "ignoring moving to subgraph \"{}\" using @key(fields: \"{}\") of \"{}\" because there is a more direct path in {} that avoids {} altogether", + edge_tail_weight.source, + FieldSetDisplay(conditions), + edge_head_weight.type_, + edge_tail_weight.source, + to_advance_tail_weight.source, + ) + })) + } else { + None + } + ) + }).process_results(|iter| iter.flatten().collect())?))) + })))); } } } @@ -1824,34 +2045,104 @@ where .map(|p| p.0) .collect(), ), - dead_ends: Arc::new(Unadvanceables(dead_ends)), + dead_ends: UnadvanceableClosures(Arc::new(dead_end_closures)).into(), }) } - /// Checks whether the partial path starting at the given edge index has an alternative path - /// starting from the given node, where only direct edges are considered, and returns the node - /// such a path ends on if it exists. Additionally, this method checks that the ending node has - /// the given type. - // PORT_NOTE: In the JS codebase, this was named `checkDirectPathFromPreviousSubgraphTo`. We've - // also generalized this method a bit by shifting certain logic into the caller. - fn check_direct_path_from_node( - &self, - start_index: usize, - start_node: NodeIndex, - end_type_position: &OutputTypeDefinitionPosition, - node_and_trigger_to_edge: impl Fn( - &Arc, + // PORT_NOTE: Named `conditionHasOverriddenFieldsInSource()` in the JS codebase, but we've + // changed source to subgraph here to clarify that we don't need to handle the federated root + // source. + fn condition_has_overridden_fields_in_subgraph( + subgraph_schema: &ValidFederationSchema, + conditions: &SelectionSet, + ) -> Result { + let Some(metadata) = subgraph_schema.subgraph_metadata() else { + bail!("Selection set should originate from a federation subgraph schema"); + }; + let external_directive_definition_name = &metadata + .federation_spec_definition() + .external_directive_definition(subgraph_schema)? + .name; + let mut stack = vec![conditions]; + while let Some(selection_set) = stack.pop() { + for selection in selection_set.selections.values() { + if let Some(sub_selection_set) = selection.selection_set() { + stack.push(sub_selection_set); + } + if let Selection::Field(selection) = selection { + let field_pos = selection.field.field_position.clone(); + // The subtlety here is that the definition of the fields in the conditions are + // not the one of the subgraph we care about here in general, because the + // conditions on key edges are those of the destination of the edge, and here + // we want to check if the field is overridden in the source of the edge. Hence, + // we get the matching definition in the input schema. + let Ok(type_pos_in_subgraph) = + subgraph_schema.get_type(field_pos.type_name().clone()) + else { + continue; + }; + let Ok(type_pos_in_subgraph) = + ObjectTypeDefinitionPosition::try_from(type_pos_in_subgraph) + else { + continue; + }; + let field_pos_in_subgraph = + type_pos_in_subgraph.field(field_pos.field_name().clone()); + let Ok(field_in_subgraph) = field_pos_in_subgraph.get(subgraph_schema.schema()) + else { + continue; + }; + let Some(application) = field_in_subgraph + .directives + .get(external_directive_definition_name) + else { + continue; + }; + let arguments = metadata + .federation_spec_definition() + .external_directive_arguments(application)?; + let Some(reason) = arguments.reason else { + continue; + }; + if reason == "[overridden]" { + return Ok(true); + } + } + } + } + Ok(false) + } + + /// Checks whether the partial path starting at the given edge index has an alternative path + /// starting from the given node, where only direct edges are considered, and returns the node + /// such a path ends on if it exists. Additionally, this method checks that the ending node has + /// the given type. + // PORT_NOTE: In the JS codebase, this was named `checkDirectPathFromPreviousSubgraphTo`. We've + // also generalized this method a bit by shifting certain logic into the caller. + fn check_direct_path_from_node( + &self, + start_index: usize, + start_node: NodeIndex, + end_type_position: &OutputTypeDefinitionPosition, + node_and_trigger_to_edge: impl Fn( + &Arc, NodeIndex, &Arc, - &EnabledOverrideConditions, - ) -> Option, - override_conditions: &EnabledOverrideConditions, + &OverrideConditions, + ) -> Result, FederationError>, + override_conditions: &OverrideConditions, ) -> Result, FederationError> { + // TODO: Temporary fix to avoid optimization if context exists, a permanent fix is here: + // https://github.com/apollographql/federation/pull/3017#pullrequestreview-2083949094 + if self.graph.is_context_used() { + return Ok(None); + } + let mut current_node = start_node; for index in start_index..self.edges.len() { let trigger = &self.edge_triggers[index]; let Some(edge) = - node_and_trigger_to_edge(&self.graph, current_node, trigger, override_conditions) + node_and_trigger_to_edge(&self.graph, current_node, trigger, override_conditions)? else { return Ok(None); }; @@ -1887,19 +2178,41 @@ where } } +// Query graph accessors +// - `self` is used to access the underlying query graph, not its path data. +// - These methods will be useful in other modules of the crate. +impl GraphPath +where + TTrigger: GraphPathTriggerVariant + Display, + TEdge: Copy + Into>, + EdgeIndex: Into, +{ + pub(crate) fn schema_by_source( + &self, + source: &str, + ) -> Result<&ValidFederationSchema, FederationError> { + self.graph.schema_by_source(source) + } + + pub(crate) fn edge_weight( + &self, + edge_index: EdgeIndex, + ) -> Result<&QueryGraphEdge, FederationError> { + self.graph.edge_weight(edge_index) + } +} + /// `BinaryHeap::pop` returns the "greatest" element. We want the one with the fewest edges. /// This wrapper compares by *reverse* comparison of edge count. struct HeapElement(Arc>) where - TTrigger: Eq + Hash, - Arc: Into, + TTrigger: Eq + Hash + GraphPathTriggerVariant, TEdge: Copy + Into>, EdgeIndex: Into; impl PartialEq for HeapElement where - TTrigger: Eq + Hash, - Arc: Into, + TTrigger: Eq + Hash + GraphPathTriggerVariant, TEdge: Copy + Into>, EdgeIndex: Into, { @@ -1910,8 +2223,7 @@ where impl Eq for HeapElement where - TTrigger: Eq + Hash, - Arc: Into, + TTrigger: Eq + Hash + GraphPathTriggerVariant, TEdge: Copy + Into>, EdgeIndex: Into, { @@ -1919,8 +2231,7 @@ where impl PartialOrd for HeapElement where - TTrigger: Eq + Hash, - Arc: Into, + TTrigger: Eq + Hash + GraphPathTriggerVariant, TEdge: Copy + Into>, EdgeIndex: Into, { @@ -1931,8 +2242,7 @@ where impl Ord for HeapElement where - TTrigger: Eq + Hash, - Arc: Into, + TTrigger: Eq + Hash + GraphPathTriggerVariant, TEdge: Copy + Into>, EdgeIndex: Into, { @@ -1941,2021 +2251,104 @@ where } } -impl OpGraphPath { - fn next_edge_for_field( - &self, - field: &Field, - override_conditions: &EnabledOverrideConditions, - ) -> Option { - self.graph - .edge_for_field(self.tail, field, override_conditions) - } - - fn next_edge_for_inline_fragment(&self, inline_fragment: &InlineFragment) -> Option { - self.graph - .edge_for_inline_fragment(self.tail, inline_fragment) - } - - fn add_field_edge( - &self, - operation_field: Field, - edge: EdgeIndex, - condition_resolver: &mut impl ConditionResolver, - context: &OpGraphPathContext, - ) -> Result, FederationError> { - let condition_resolution = self.can_satisfy_conditions( - edge, - condition_resolver, - context, - &Default::default(), - &Default::default(), - )?; - if matches!(condition_resolution, ConditionResolution::Satisfied { .. }) { - self.add( - operation_field.into(), - edge.into(), - condition_resolution, - None, - ) - .map(Some) - } else { - Ok(None) - } - } - - pub(crate) fn mark_overriding( - &self, - others: &[SimultaneousPaths], - ) -> (OpGraphPath, Vec) { - let new_id = OverrideId::new(); - let mut new_own_path_ids = self.overriding_path_ids.as_ref().clone(); - new_own_path_ids.insert(new_id); - let new_self = OpGraphPath { - own_path_ids: Arc::new(new_own_path_ids), - ..self.clone() - }; - let new_others = others - .iter() - .map(|option| { - SimultaneousPaths( - option - .0 - .iter() - .map(|path| { - let mut new_overriding_path_ids = - path.overriding_path_ids.as_ref().clone(); - new_overriding_path_ids.insert(new_id); - Arc::new(OpGraphPath { - overriding_path_ids: Arc::new(new_overriding_path_ids), - ..path.as_ref().clone() - }) - }) - .collect(), - ) - }) - .collect(); - (new_self, new_others) - } - - pub(crate) fn subgraph_jumps(&self) -> Result { - self.subgraph_jumps_at_idx(0) - } - - fn subgraph_jumps_at_idx(&self, start_index: usize) -> Result { - self.edges[start_index..] - .iter() - .flatten() - .try_fold(0, |sum, &edge_index| { - let (start, end) = self.graph.edge_endpoints(edge_index)?; - let start = self.graph.node_weight(start)?; - let end = self.graph.node_weight(end)?; - let changes_subgraph = start.source != end.source; - Ok(sum + if changes_subgraph { 1 } else { 0 }) - }) - } +struct EdgeIndexDisplay<'graph, TEdge> +where + TEdge: Copy + Into>, + EdgeIndex: Into, +{ + _phantom_data_for_edge: PhantomData, + graph: &'graph Arc, + edge_index: EdgeIndex, +} - fn find_longest_common_prefix_length( - &self, - other: &OpGraphPath, - ) -> Result { - if self.head != other.head { - return Err(FederationError::internal( - "Paths unexpectedly did not start at the same node.", - )); +impl<'graph, TEdge> EdgeIndexDisplay<'graph, TEdge> +where + TEdge: Copy + Into>, + EdgeIndex: Into, +{ + fn new(edge_index: EdgeIndex, graph: &'graph Arc) -> Self { + Self { + _phantom_data_for_edge: Default::default(), + graph, + edge_index, } - - Ok(self - .edges - .iter() - .zip(&other.edges) - .position(|(self_edge, other_edge)| self_edge != other_edge) - .unwrap_or_else(|| self.edges.len().min(other.edges.len()))) - } - - /// Looks for the longest common prefix for `self` and `other` (assuming that both paths are - /// built as options for the same "query path"), and then compares whether each path has - /// subgraph jumps after said prefix. - /// - /// Note this method always return something, but the longest common prefix considered may very - /// well be empty. Also note that this method assumes that the 2 paths have the same root, and - /// will fail if that's not the case. - /// - /// Returns the comparison of whether `self` and `other` have subgraph jumps after said prefix - /// (e.g. `Ordering::Less` means `self` has zero subgraph jumps after said prefix while `other` - /// has at least one). If they both have subgraph jumps or neither has subgraph jumps, then we - /// return `Ordering::Equal`. - fn compare_subgraph_jumps_after_last_common_node( - &self, - other: &OpGraphPath, - ) -> Result { - let longest_common_prefix_len = self.find_longest_common_prefix_length(other)?; - let self_jumps = self.subgraph_jumps_at_idx(longest_common_prefix_len)? > 0; - let other_jumps = other.subgraph_jumps_at_idx(longest_common_prefix_len)? > 0; - Ok(self_jumps.cmp(&other_jumps)) - } - - pub(crate) fn terminate_with_non_requested_typename_field( - &self, - override_conditions: &EnabledOverrideConditions, - ) -> Result { - // If the last step of the path was a fragment/type-condition, we want to remove it before - // we get __typename. The reason is that this avoid cases where this method would make us - // build plans like: - // { - // foo { - // __typename - // ... on A { - // __typename - // } - // ... on B { - // __typename - // } - // } - // Instead, we just generate: - // { - // foo { - // __typename - // } - // } - // Note it's ok to do this because the __typename we add is _not_ requested, it is just - // added in cases where we need to ensure a selection is not empty, and so this - // transformation is fine to do. - let path = self.truncate_trailing_downcasts()?; - let tail_weight = self.graph.node_weight(path.tail)?; - let QueryGraphNodeType::SchemaType(tail_type_pos) = &tail_weight.type_ else { - return Err(FederationError::internal( - "Unexpectedly found federated root node as tail", - )); - }; - let Ok(tail_type_pos) = CompositeTypeDefinitionPosition::try_from(tail_type_pos.clone()) - else { - return Ok(path); - }; - let typename_field = Field::new_introspection_typename( - self.graph.schema_by_source(&tail_weight.source)?, - &tail_type_pos, - None, - ); - let Some(edge) = self - .graph - .edge_for_field(path.tail, &typename_field, override_conditions) - else { - return Err(FederationError::internal( - "Unexpectedly missing edge for __typename field", - )); - }; - path.add( - typename_field.into(), - Some(edge), - ConditionResolution::no_conditions(), - None, - ) } +} - /// Remove all trailing downcast edges and `None` edges. - fn truncate_trailing_downcasts(&self) -> Result { - let mut runtime_types = Arc::new(self.head_possible_runtime_types()?); - let mut last_edge_index = None; - let mut last_runtime_types = runtime_types.clone(); - for (edge_index, edge) in self.edges.iter().enumerate() { - runtime_types = Arc::new( - self.graph - .advance_possible_runtime_types(&runtime_types, *edge)?, - ); - let Some(edge) = edge else { - continue; - }; - let edge_weight = self.graph.edge_weight(*edge)?; - if !matches!( - edge_weight.transition, - QueryGraphEdgeTransition::Downcast { .. } - ) { - last_edge_index = Some(edge_index); - last_runtime_types = runtime_types.clone(); - } - } - let Some(last_edge_index) = last_edge_index else { - // PORT_NOTE: The JS codebase just returns the same path if all edges are downcast or - // `None` edges. This is likely a bug, so we instead return the empty path here. - return OpGraphPath::new(self.graph.clone(), self.head); - }; - let prefix_length = last_edge_index + 1; - if prefix_length == self.edges.len() { - return Ok(self.clone()); - } - let Some(last_edge) = self.edges[last_edge_index] else { - return Err(FederationError::internal( - "Unexpectedly found None for last non-downcast, non-None edge", - )); - }; - let (_, last_edge_tail) = self.graph.edge_endpoints(last_edge)?; - Ok(OpGraphPath { - graph: self.graph.clone(), - head: self.head, - tail: last_edge_tail, - edges: self.edges[0..prefix_length].to_vec(), - edge_triggers: self.edge_triggers[0..prefix_length].to_vec(), - edge_conditions: self.edge_conditions[0..prefix_length].to_vec(), - last_subgraph_entering_edge_info: self.last_subgraph_entering_edge_info.clone(), - own_path_ids: self.own_path_ids.clone(), - overriding_path_ids: self.overriding_path_ids.clone(), - runtime_types_of_tail: last_runtime_types, - runtime_types_before_tail_if_last_is_cast: None, - // TODO: The JS codebase copied this from the current path, which seems like a bug. - defer_on_tail: self.defer_on_tail.clone(), - }) +impl Display for EdgeIndexDisplay<'_, TEdge> +where + TEdge: Copy + Into>, + EdgeIndex: Into, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let e = self.edge_index; + let graph = self.graph.graph(); + let (head_idx, tail_idx) = graph.edge_endpoints(e).ok_or(std::fmt::Error)?; + let head = &graph[head_idx]; + let tail = &graph[tail_idx]; + let edge = &graph[e]; + let transition = &edge.transition; + write!(f, "{head} -> {tail} ({transition})") } +} - pub(crate) fn is_equivalent_save_for_type_explosion_to( - &self, - other: &OpGraphPath, - ) -> Result { - // We're looking at the specific case where both paths are basically equivalent except for a - // single step of type-explosion, so if either of the paths don't start and end on the - // same node, or if `other` is not exactly 1 more step than `self`, we're done. - if !(self.head == other.head - && self.tail == other.tail - && self.edges.len() == other.edges.len() - 1) - { - return Ok(false); - } - - // If the above is true, then we find the first difference in the paths. - let Some(diff_pos) = self - .edges - .iter() - .zip(&other.edges) - .position(|(self_edge, other_edge)| self_edge != other_edge) - else { - // All edges are the same, but the `other` path has an extra edge. This can't be a type - // explosion + key resolution, so we consider them not equivalent here. - // - // PORT_NOTE: The JS codebase returns `true` here, claiming the paths are the same. This - // isn't true though as we're skipping the last element of `other` in the JS codebase - // (and while that edge can't change the `tail`, it doesn't mean that `self` subsumes - // `other`). We fix this bug here by returning `false` instead of `true`. - return Ok(false); - }; - - // If the first difference is not a "type-explosion", i.e. if `other` is a cast from an - // interface to one of the implementation, then we're not in the case we're looking for. - let Some(self_edge) = self.edges[diff_pos] else { - return Ok(false); - }; - let Some(other_edge) = other.edges[diff_pos] else { - return Ok(false); - }; - let other_edge_weight = other.graph.edge_weight(other_edge)?; - let QueryGraphEdgeTransition::Downcast { - from_type_position, .. - } = &other_edge_weight.transition - else { - return Ok(false); - }; - if !matches!( - from_type_position, - CompositeTypeDefinitionPosition::Interface(_) - ) { - return Ok(false); +impl Display for GraphPath +where + TTrigger: Eq + Hash + Display + GraphPathTriggerVariant, + TEdge: Copy + Into>, + EdgeIndex: Into, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + // If the path is length is 0 return "[]" + // Traverse the path, getting the of the edge. + let graph = self.graph.graph(); + let head = &graph[self.head]; + let head_is_root_node = head.is_root_node(); + if head_is_root_node && self.edges.is_empty() { + return write!(f, "_"); } - - // At this point, we want both paths to take the "same" key, but because one is starting - // from the interface while the other one from an implementation, they won't be technically - // the "same" edge index. So we check that both are key-resolution edges, to the same - // subgraph and type, and with the same condition. - let Some(other_next_edge) = other.edges[diff_pos + 1] else { - return Ok(false); - }; - let (_, self_edge_tail) = other.graph.edge_endpoints(self_edge)?; - let self_edge_weight = other.graph.edge_weight(self_edge)?; - let (_, other_next_edge_tail) = other.graph.edge_endpoints(other_next_edge)?; - let other_next_edge_weight = other.graph.edge_weight(other_next_edge)?; - if !(matches!( - self_edge_weight.transition, - QueryGraphEdgeTransition::KeyResolution - ) && matches!( - other_next_edge_weight.transition, - QueryGraphEdgeTransition::KeyResolution - ) && self_edge_tail == other_next_edge_tail - && self_edge_weight.conditions == other_next_edge_weight.conditions) - { - return Ok(false); + if !head_is_root_node { + write!(f, "{head}")?; } - - // So far, so good. Check that the rest of the paths are equal. Note that starts with - // `diff_pos + 1` for `self`, but `diff_pos + 2` for `other` since we looked at two edges - // there instead of one. - return Ok(self.edges[(diff_pos + 1)..] + self.edges .iter() - .zip(other.edges[(diff_pos + 2)..].iter()) - .all(|(self_edge, other_edge)| self_edge == other_edge)); - } - - /// This method is used to detect when using an interface field "directly" could fail (i.e. lead - /// to a dead end later for the query path) while type-exploding may succeed. - /// - /// In general, taking a field from an interface directly or through it's implementation by - /// type-exploding leads to the same option, and so taking one or the other is more of a matter - /// of "which is more efficient". But there is a special case where this may not be true, and - /// this is when all of the following hold: - /// 1. The interface is implemented by an entity type. - /// 2. The field being looked at is @shareable. - /// 3. The field type has a different set of fields (and less fields) in the "current" subgraph - /// than in another one. - /// - /// For instance, consider if some Subgraph A has this schema: - /// """ - /// type Query { - /// i: I - /// } - /// - /// interface I { - /// s: S - /// } - /// - /// type T implements I @key(fields: "id") { - /// id: ID! - /// s: S @shareable - /// } - /// - /// type S @shareable { - /// x: Int - /// } - /// """ - /// and if some Subgraph B has this schema: - /// """ - /// type T @key(fields: "id") { - /// id: ID! - /// s: S @shareable - /// } - /// - /// type S @shareable { - /// x: Int - /// y: Int - /// } - /// """ - /// and suppose that `{ i { s { y } } }` is queried. If we follow `I.s` in subgraph A then the - /// `y` field cannot be found, because `S` not being an entity means we cannot "jump" to - /// subgraph B (even if it was, there may not be a usable key to jump between the 2 subgraphs). - /// However, if we "type-explode" into implementation `T`, then we can jump to subgraph B from - /// that, at which point we can reach `y`. - /// - /// So the goal of this method is to detect when we might be in such a case: when we are, we - /// will have to consider type explosion on top of the direct route in case that direct route - /// ends up "not panning out" (note that by the time this method is called, we're only looking - /// at the options for type `I.s`; we do not know yet if `y` is queried next and so cannot tell - /// if type explosion will be necessary or not). - // PORT_NOTE: In the JS code, this method was a free-standing function called "anImplementationIsEntityWithFieldShareable". - fn has_an_entity_implementation_with_shareable_field( - &self, - source: &Arc, - interface_field_pos: InterfaceFieldDefinitionPosition, - ) -> Result { - let fed_schema = self.graph.schema_by_source(source)?; - let schema = fed_schema.schema(); - let fed_spec = get_federation_spec_definition_from_subgraph(fed_schema)?; - let key_directive = fed_spec.key_directive_definition(fed_schema)?; - let shareable_directive = fed_spec.shareable_directive_definition(fed_schema)?; - for implementation_type_pos in - fed_schema.possible_runtime_types(interface_field_pos.parent().into())? - { - let implementing_type = implementation_type_pos.get(schema)?; - if !implementing_type.directives.has(&key_directive.name) { - continue; - } - let implementing_field = implementation_type_pos - .field(interface_field_pos.field_name.clone()) - .get(schema)?; - if !implementing_field.directives.has(&shareable_directive.name) { - continue; - } + .cloned() + .enumerate() + .try_for_each(|(i, e)| match e.into() { + Some(e) => { + let tail = graph.edge_endpoints(e).ok_or(std::fmt::Error)?.1; + let node = &graph[tail]; + if i == 0 && head_is_root_node { + write!(f, "{node}") + } else { + let edge = &graph[e]; + let label = edge.to_string(); - // Returning `true` for this method has a cost: it will make us consider type-explosion for `itf`, and this can - // sometime lead to a large number of additional paths to explore, which can have a substantial cost. So we want - // to limit it if we can avoid it. As it happens, we should return `true` if it is possible that "something" - // (some field) in the type of `field` is reachable in _another_ subgraph but no in the one of the current path. - // And while it's not trivial to check this in general, there are some easy cases we can eliminate. For instance, - // if the type in the current subgraph has only leaf fields, we can check that all other subgraphs reachable - // from the implementation have the same set of leaf fields. - let implementing_field_base_type_name = implementing_field.ty.inner_named_type(); - if is_leaf_type(schema, implementing_field_base_type_name) { - continue; - } - let Some(implementing_field_base_type) = - schema.get_object(implementing_field_base_type_name) - else { - // We officially "don't know", so we return "true" so type-explosion is tested. - return Ok(true); - }; - if implementing_field_base_type - .fields - .values() - .any(|f| !is_leaf_type(schema, f.ty.inner_named_type())) - { - // Similar to above, we declare we "don't know" and test type-explosion. - return Ok(true); - } - for node in self.graph.nodes_for_type(&implementing_type.name)? { - let node = self.graph.node_weight(*node)?; - let tail = self.graph.node_weight(self.tail)?; - if node.source == tail.source { - continue; - } - let node_fed_schema = self.graph.schema_by_source(&node.source)?; - let node_schema = node_fed_schema.schema(); - let node_fed_spec = get_federation_spec_definition_from_subgraph(node_fed_schema)?; - let node_shareable_directive = - node_fed_spec.shareable_directive_definition(node_fed_schema)?; - let build_err = || { - Err(FederationError::internal(format!( - "{implementation_type_pos} is an object in {} but a {} in {}", - tail.source, node.type_, node.source - ))) - }; - let QueryGraphNodeType::SchemaType(node_type_pos) = &node.type_ else { - return build_err(); - }; - let node_type_pos: ObjectOrInterfaceTypeDefinitionPosition = - node_type_pos.clone().try_into()?; - let node_field_pos = node_type_pos.field(interface_field_pos.field_name.clone()); - let Some(node_field) = node_field_pos.try_get(node_schema) else { - continue; - }; - if !node_field.directives.has(&node_shareable_directive.name) { - continue; - } - let node_field_base_type_name = node_field.ty.inner_named_type(); - if implementing_field_base_type_name != node_field_base_type_name { - // We have a genuine difference here, so we should explore type explosion. - return Ok(true); + if label.is_empty() { + write!(f, " --> {node}") + } else { + write!(f, " --[{label}]--> {node}") + } + } } - let node_field_base_type_pos = - node_fed_schema.get_type(node_field_base_type_name.clone())?; - let Some(node_field_base_type_pos): Option< - ObjectOrInterfaceTypeDefinitionPosition, - > = node_field_base_type_pos.try_into().ok() else { - // Similar to above, we have a genuine difference. - return Ok(true); - }; - - if !node_field_base_type_pos.fields(node_schema)?.all(|f| { - implementing_field_base_type - .fields - .contains_key(f.field_name()) - }) { - // Similar to above, we have a genuine difference. - return Ok(true); + None => write!(f, " ({}) ", self.edge_triggers[i].as_ref()), + })?; + if let Some(label) = self.defer_on_tail.as_ref().and_then(|d| d.label.as_ref()) { + write!(f, "")?; + } + if !self.runtime_types_of_tail.is_empty() { + write!(f, " (types: [")?; + let mut iter = self.runtime_types_of_tail.iter(); + if let Some(ty) = iter.next() { + // First item + write!(f, "{ty}")?; + // The rest + for ty in iter { + write!(f, " {ty}")?; } - // Note that if the type is the same and the fields are a subset too, then we know - // the return types of those fields must be leaf types, or merging would have - // complained. } - return Ok(false); - } - Ok(false) - } - - /// For the first element of the pair, the data has the same meaning as in - /// `SimultaneousPathsWithLazyIndirectPaths.advance_with_operation_element()`. We also actually - /// need to return a `Vec` of options of simultaneous paths (because when we type explode, we - /// create simultaneous paths, but as a field might be resolved by multiple subgraphs, we may - /// have also created multiple options). - /// - /// For the second element, it is true if the result only has type-exploded results. - #[cfg_attr(feature = "snapshot_tracing", tracing::instrument( - skip_all, - level = "trace", - name = "GraphPath::advance_with_operation_element" - fields(label = operation_element.to_string()) - ))] - fn advance_with_operation_element( - &self, - supergraph_schema: ValidFederationSchema, - operation_element: &OpPathElement, - context: &OpGraphPathContext, - condition_resolver: &mut impl ConditionResolver, - override_conditions: &EnabledOverrideConditions, - ) -> Result<(Option>, Option), FederationError> { - let span = debug_span!("Trying to advance {self} directly with {operation_element}"); - let _guard = span.enter(); - let tail_weight = self.graph.node_weight(self.tail)?; - let QueryGraphNodeType::SchemaType(tail_type_pos) = &tail_weight.type_ else { - // We cannot advance any operation from here. We need to take the initial non-collecting - // edges first. - debug!("Cannot advance federated graph root with direct operations"); - return Ok((None, None)); - }; - match operation_element { - OpPathElement::Field(operation_field) => { - match tail_type_pos { - OutputTypeDefinitionPosition::Object(tail_type_pos) => { - // Just take the edge corresponding to the field, if it exists and can be - // used. - let Some(edge) = - self.next_edge_for_field(operation_field, override_conditions) - else { - debug!( - "No edge for field {operation_field} on object type {tail_weight}" - ); - return Ok((None, None)); - }; - - // If the tail type is an `@interfaceObject`, it's possible that the - // requested field is a field of an implementation of the interface. Because - // we found an edge, we know that the interface object has the field and we - // can use the edge. However, we can't add the operation field as-is to this - // path, since it's referring to a parent type that is not in the current - // subgraph. We must instead use the tail's type, so we change the field - // accordingly. - // - // TODO: It would be good to understand what parts of query planning rely - // on triggers being valid within a subgraph. - let mut operation_field = operation_field.clone(); - if self.tail_is_interface_object()? - && *operation_field.field_position.type_name() - != tail_type_pos.type_name - { - let field_on_tail_type = tail_type_pos - .field(operation_field.field_position.field_name().clone()); - if field_on_tail_type - .try_get(self.graph.schema_by_source(&tail_weight.source)?.schema()) - .is_none() - { - let edge_weight = self.graph.edge_weight(edge)?; - return Err(FederationError::internal(format!( - "Unexpectedly missing {} for {} from path {}", - operation_field, edge_weight, self, - ))); - } - operation_field = Field { - schema: self.graph.schema_by_source(&tail_weight.source)?.clone(), - field_position: field_on_tail_type.into(), - alias: operation_field.alias.clone(), - arguments: operation_field.arguments.clone(), - directives: operation_field.directives.clone(), - sibling_typename: operation_field.sibling_typename.clone(), - } - } - - let field_path = self.add_field_edge( - operation_field, - edge, - condition_resolver, - context, - )?; - match &field_path { - Some(_) => debug!("Collected field on object type {tail_weight}"), - None => debug!( - "Cannot satisfy @requires on field for object type {tail_weight}" - ), - } - Ok((field_path.map(|p| vec![p.into()]), None)) - } - OutputTypeDefinitionPosition::Interface(tail_type_pos) => { - // Due to `@interfaceObject`, we could be in a case where the field asked is - // not on the interface but rather on one of it's implementations. This can - // happen if we just entered the subgraph on an interface `@key` and are - // and coming from an `@interfaceObject`. In that case, we'll skip checking - // for a direct interface edge and simply cast into that implementation - // below. - let field_is_of_an_implementation = - *operation_field.field_position.type_name() != tail_type_pos.type_name; - - // First, we check if there is a direct edge from the interface (which only - // happens if we're in a subgraph that knows all of the implementations of - // that interface globally and all of them resolve the field). If there is - // one, then we have 2 options: - // - We take that edge. - // - We type-explode (like when we don't have a direct interface edge). - // We want to avoid looking at both options if we can because it multiplies - // planning work quickly if we always check both options. And in general, - // taking the interface edge is better than type explosion "if it works", - // so we distinguish a number of cases where we know that either: - // - Type-exploding cannot work unless taking the interface edge also does - // (the `has_an_entity_implementation_with_shareable_field()` call). - // - Type-exploding cannot be more efficient than the direct path (when no - // `@provides` are involved; if a `@provides` is involved in one of the - // implementations, then type-exploding may lead to a shorter overall - // plan thanks to that `@provides`). - let interface_edge = if field_is_of_an_implementation { - None - } else { - self.next_edge_for_field(operation_field, override_conditions) - }; - let interface_path = if let Some(interface_edge) = &interface_edge { - let field_path = self.add_field_edge( - operation_field.clone(), - *interface_edge, - condition_resolver, - context, - )?; - if field_path.is_none() { - let interface_edge_weight = - self.graph.edge_weight(*interface_edge)?; - return Err(FederationError::internal(format!( - "Interface edge {} unexpectedly had conditions", - interface_edge_weight - ))); - } - field_path - } else { - None - }; - let direct_path_overrides_type_explosion = - if let Some(interface_edge) = &interface_edge { - // There are 2 separate cases where we going to do both "direct" and - // "type-exploding" options: - // 1. There is an `@provides`: in that case the "type-exploding - // case can legitimately be more efficient and we want to = - // consider it "all the way" - // 2. In the sub-case of - // `!has_an_entity_implementation_with_shareable_field(...)`, - // where we want to have the type-exploding option only for the - // case where the "direct" one fails later. But in that case, - // we'll remember that if the direct option pans out, then we can - // ignore the type-exploding one. - // `direct_path_overrides_type_explosion` indicates that we're in - // the 2nd case above, not the 1st one. - operation_field - .field_position - .is_introspection_typename_field() - || (!self.graph.is_provides_edge(*interface_edge)? - && !self.graph.has_an_implementation_with_provides( - &tail_weight.source, - tail_type_pos.field( - operation_field.field_position.field_name().clone(), - ), - )?) - } else { - false - }; - if direct_path_overrides_type_explosion { - // We can special-case terminal (leaf) fields: as long they have no - // `@provides`, then the path ends there and there is no need to check - // type explosion "in case the direct path doesn't pan out". - // Additionally, if we're not in the case where an implementation - // is an entity with a shareable field, then there is no case where the - // direct case wouldn't "pan out" but the type explosion would, so we - // can ignore type-exploding there too. - // - // TODO: We should re-assess this when we support `@requires` on - // interface fields (typically, should we even try to type-explode - // if the direct edge cannot be satisfied? Probably depends on the exact - // semantics of `@requires` on interface fields). - let operation_field_type_name = operation_field - .field_position - .get(operation_field.schema.schema())? - .ty - .inner_named_type(); - let is_operation_field_type_leaf = matches!( - operation_field - .schema - .get_type(operation_field_type_name.clone())?, - TypeDefinitionPosition::Scalar(_) | TypeDefinitionPosition::Enum(_) - ); - if is_operation_field_type_leaf - || !self.has_an_entity_implementation_with_shareable_field( - &tail_weight.source, - tail_type_pos - .field(operation_field.field_position.field_name().clone()), - )? - { - let Some(interface_path) = interface_path else { - return Err(FederationError::internal( - "Unexpectedly missing interface path", - )); - }; - debug!("Collecting (leaf) field on interface {tail_weight} without type-exploding"); - return Ok((Some(vec![interface_path.into()]), None)); - } - debug!("Collecting field on interface {tail_weight} as 1st option"); - } - - // There are 2 main cases to handle here: - // - The most common is that it's a field of the interface that is queried, - // and so we should type-explode because either we didn't had a direct - // edge, or `@provides` makes it potentially worthwhile to check with type - // explosion. - // - But, as mentioned earlier, we could be in the case where the field - // queried is actually of one of the implementation of the interface. In - // that case, we only want to consider that one implementation. - let implementations = if field_is_of_an_implementation { - let CompositeTypeDefinitionPosition::Object(field_parent_pos) = - &operation_field.field_position.parent() - else { - return Err(FederationError::internal( - format!( - "{} requested on {}, but field's parent {} is not an object type", - operation_field.field_position, - tail_type_pos, - operation_field.field_position.type_name() - ) - )); - }; - if !self.runtime_types_of_tail.contains(field_parent_pos) { - return Err(FederationError::internal( - format!( - "{} requested on {}, but field's parent {} is not an implementation type", - operation_field.field_position, - tail_type_pos, - operation_field.field_position.type_name() - ) - )); - } - debug!("Casting into requested type {field_parent_pos}"); - Arc::new(IndexSet::from_iter([field_parent_pos.clone()])) - } else { - match &interface_path { - Some(_) => debug!("No direct edge: type exploding interface {tail_weight} into possible runtime types {:?}", self.runtime_types_of_tail), - None => debug!("Type exploding interface {tail_weight} into possible runtime types {:?} as 2nd option", self.runtime_types_of_tail), - } - self.runtime_types_of_tail.clone() - }; - - // We type-explode. For all implementations, we need to call - // `advance_with_operation_element()` on a made-up inline fragment. If - // any gives us empty options, we bail. - let mut options_for_each_implementation = vec![]; - for implementation_type_pos in implementations.as_ref() { - debug!("Handling implementation {implementation_type_pos}"); - let span = debug_span!(" |"); - let guard = span.enter(); - let implementation_inline_fragment = InlineFragment { - schema: self.graph.schema_by_source(&tail_weight.source)?.clone(), - parent_type_position: tail_type_pos.clone().into(), - type_condition_position: Some( - implementation_type_pos.clone().into(), - ), - directives: Default::default(), - selection_id: SelectionId::new(), - }; - let implementation_options = - SimultaneousPathsWithLazyIndirectPaths::new( - self.clone().into(), - context.clone(), - Default::default(), - Default::default(), - ) - .advance_with_operation_element( - supergraph_schema.clone(), - &implementation_inline_fragment.into(), - condition_resolver, - override_conditions, - )?; - // If we find no options for that implementation, we bail (as we need to - // simultaneously advance all implementations). - let Some(mut implementation_options) = implementation_options else { - drop(guard); - debug!("Cannot collect field from {implementation_type_pos}: stopping with options [{interface_path:?}]"); - return Ok((interface_path.map(|p| vec![p.into()]), None)); - }; - // If the new inline fragment makes it so that we're on an unsatisfiable - // branch, we just ignore that implementation. - if implementation_options.is_empty() { - debug!("Cannot ever get {implementation_type_pos} from this branch, ignoring it"); - continue; - } - // For each option, we call `advance_with_operation_element()` again on - // our own operation element (the field), which gives us some options - // (or not and we bail). - let mut field_options = vec![]; - debug!( - "Trying to collect field from options {implementation_options:?}" - ); - for implementation_option in &mut implementation_options { - let span = debug_span!("For {implementation_option}"); - let _guard = span.enter(); - let field_options_for_implementation = implementation_option - .advance_with_operation_element( - supergraph_schema.clone(), - operation_element, - condition_resolver, - override_conditions, - )?; - let Some(field_options_for_implementation) = - field_options_for_implementation - else { - debug!("Cannot collect field"); - continue; - }; - // Advancing a field should never get us into an unsatisfiable - // condition (only fragments can). - if field_options_for_implementation.is_empty() { - return Err(FederationError::internal(format!( - "Unexpected unsatisfiable path after {}", - operation_field - ))); - } - debug!( - "Collected field: adding {field_options_for_implementation:?}" - ); - field_options.extend( - field_options_for_implementation - .into_iter() - .map(|s| s.paths), - ); - } - // If we find no options to advance that implementation, we bail (as we - // need to simultaneously advance all implementations). - if field_options.is_empty() { - drop(guard); - debug!("Cannot collect field from {implementation_type_pos}: stopping with options [{}]", DisplayOption::new(&interface_path)); - return Ok((interface_path.map(|p| vec![p.into()]), None)); - }; - debug!("Collected field from {implementation_type_pos}"); - options_for_each_implementation.push(field_options); - } - let all_options = SimultaneousPaths::flat_cartesian_product( - options_for_each_implementation, - )?; - if let Some(interface_path) = interface_path { - let (interface_path, all_options) = - if direct_path_overrides_type_explosion { - interface_path.mark_overriding(&all_options) - } else { - (interface_path, all_options) - }; - let options = vec![interface_path.into()] - .into_iter() - .chain(all_options) - .collect::>(); - debug!("With type-exploded options: {}", DisplaySlice(&options)); - Ok((Some(options), None)) - } else { - debug!("With type-exploded options: {}", DisplaySlice(&all_options)); - // TODO: This appears to be the only place returning non-None for the - // 2nd argument, so this could be Option<(Vec, bool)> - // instead. - Ok((Some(all_options), Some(true))) - } - } - OutputTypeDefinitionPosition::Union(_) => { - let Some(typename_edge) = - self.next_edge_for_field(operation_field, override_conditions) - else { - return Err(FederationError::internal( - "Should always have an edge for __typename edge on an union", - )); - }; - let field_path = self.add_field_edge( - operation_field.clone(), - typename_edge, - condition_resolver, - context, - )?; - debug!("Trivial collection of __typename for union"); - Ok((field_path.map(|p| vec![p.into()]), None)) - } - _ => { - // Only object, interfaces, and unions (only for __typename) have fields, so - // the query should have been flagged invalid if a field was selected on - // something else. - Err(FederationError::internal(format!( - "Unexpectedly found field {} on non-composite type {}", - operation_field, tail_type_pos, - ))) - } - } - } - OpPathElement::InlineFragment(operation_inline_fragment) => { - let type_condition_name = operation_inline_fragment - .type_condition_position - .as_ref() - .map(|pos| pos.type_name()) - .unwrap_or_else(|| tail_type_pos.type_name()) - .clone(); - if type_condition_name == *tail_type_pos.type_name() { - // If there is no type condition (or the condition is the type we're already - // on), it means we're essentially just applying some directives (could be a - // `@skip`/`@include` for instance). This doesn't make us take any edge, but if - // the operation element does has directives, we record it. - debug!("No edge to take for condition {operation_inline_fragment} from current type"); - let fragment_path = if operation_inline_fragment.directives.is_empty() { - self.clone() - } else { - self.add( - operation_inline_fragment.clone().into(), - None, - ConditionResolution::no_conditions(), - operation_inline_fragment.defer_directive_arguments()?, - )? - }; - return Ok((Some(vec![fragment_path.into()]), None)); - } - match tail_type_pos { - OutputTypeDefinitionPosition::Interface(_) - | OutputTypeDefinitionPosition::Union(_) => { - let tail_type_pos: AbstractTypeDefinitionPosition = - tail_type_pos.clone().try_into()?; - - // If we have an edge for the typecast, take that. - if let Some(edge) = - self.next_edge_for_inline_fragment(operation_inline_fragment) - { - let edge_weight = self.graph.edge_weight(edge)?; - if edge_weight.conditions.is_some() { - return Err(FederationError::internal( - "Unexpectedly found condition on inline fragment collecting edge" - )); - } - let fragment_path = self.add( - operation_inline_fragment.clone().into(), - Some(edge), - ConditionResolution::no_conditions(), - operation_inline_fragment.defer_directive_arguments()?, - )?; - debug!("Using type-casting edge for {type_condition_name} from current type"); - return Ok((Some(vec![fragment_path.into()]), None)); - } - - // Otherwise, check what the intersection is between the possible runtime - // types of the tail type and the ones of the typecast. We need to be able - // to go into all those types simultaneously (a.k.a. type explosion). - let from_types = self.runtime_types_of_tail.clone(); - let to_types = supergraph_schema.possible_runtime_types( - supergraph_schema - .get_type(type_condition_name.clone())? - .try_into()?, - )?; - let intersection = from_types.intersection(&to_types); - debug!("Trying to type-explode into intersection between current type and {type_condition_name} = [{}]", intersection.clone().format(",")); - let mut options_for_each_implementation = vec![]; - for implementation_type_pos in intersection { - let span = debug_span!("Trying {implementation_type_pos}"); - let guard = span.enter(); - let implementation_inline_fragment = InlineFragment { - schema: self.graph.schema_by_source(&tail_weight.source)?.clone(), - parent_type_position: tail_type_pos.clone().into(), - type_condition_position: Some( - implementation_type_pos.clone().into(), - ), - directives: operation_inline_fragment.directives.clone(), - selection_id: SelectionId::new(), - }; - let implementation_options = - SimultaneousPathsWithLazyIndirectPaths::new( - self.clone().into(), - context.clone(), - Default::default(), - Default::default(), - ) - .advance_with_operation_element( - supergraph_schema.clone(), - &implementation_inline_fragment.into(), - condition_resolver, - override_conditions, - )?; - let Some(implementation_options) = implementation_options else { - drop(guard); - debug!("Cannot advance into {implementation_type_pos} from current type: no options for operation."); - return Ok((None, None)); - }; - // If the new inline fragment makes it so that we're on an unsatisfiable - // branch, we just ignore that implementation. - if implementation_options.is_empty() { - debug!("Cannot ever get type name from this branch, ignoring it"); - continue; - } - options_for_each_implementation.push( - implementation_options - .into_iter() - .map(|s| s.paths) - .collect(), - ); - debug!("Advanced into type from current type: {options_for_each_implementation:?}"); - } - let all_options = SimultaneousPaths::flat_cartesian_product( - options_for_each_implementation, - )?; - debug!("Type-exploded options: {}", DisplaySlice(&all_options)); - Ok((Some(all_options), None)) - } - OutputTypeDefinitionPosition::Object(tail_type_pos) => { - // We've already handled the case of a fragment whose type condition is the - // same as the tail type. But the fragment might be for either: - // - A super-type of the tail type. In which case, we're pretty much in the - // same case than if there were no particular type condition. - // - If the tail type is an `@interfaceObject`, then this can be an - // implementation type of the interface in the supergraph. In that case, - // the type condition is not a known type of the subgraph, but the - // subgraph might still be able to handle some of fields, so in that case, - // we essentially "ignore" the fragment for now. We will re-add it back - // later for fields that are not in the current subgraph after we've taken - // an `@key` for the interface. - // - An incompatible type. This can happen for a type that intersects a - // super-type of the tail type (since GraphQL allows a fragment as long as - // there is an intersection). In that case, the whole operation element - // simply cannot ever return anything. - let type_condition_pos = supergraph_schema.get_type(type_condition_name)?; - let abstract_type_condition_pos: Option = - type_condition_pos.clone().try_into().ok(); - if let Some(type_condition_pos) = abstract_type_condition_pos { - if supergraph_schema - .possible_runtime_types(type_condition_pos.clone().into())? - .contains(tail_type_pos) - { - debug!("Type is a super-type of the current type. No edge to take"); - // Type condition is applicable on the tail type, so the types are - // already exploded but the condition can reference types from the - // supergraph that are not present in the local subgraph. - // - // If the operation element has applied directives we need to - // convert it to an inline fragment without type condition, - // otherwise we ignore the fragment altogether. - if operation_inline_fragment.directives.is_empty() { - return Ok((Some(vec![self.clone().into()]), None)); - } - let operation_inline_fragment = InlineFragment { - schema: self - .graph - .schema_by_source(&tail_weight.source)? - .clone(), - parent_type_position: tail_type_pos.clone().into(), - type_condition_position: None, - directives: operation_inline_fragment.directives.clone(), - selection_id: SelectionId::new(), - }; - let defer_directive_arguments = - operation_inline_fragment.defer_directive_arguments()?; - let fragment_path = self.add( - operation_inline_fragment.into(), - None, - ConditionResolution::no_conditions(), - defer_directive_arguments, - )?; - return Ok((Some(vec![fragment_path.into()]), None)); - } - } - - if self.tail_is_interface_object()? { - let mut fake_downcast_edge = None; - for edge in self.next_edges()? { - let edge_weight = self.graph.edge_weight(edge)?; - let QueryGraphEdgeTransition::InterfaceObjectFakeDownCast { - to_type_name, - .. - } = &edge_weight.transition - else { - continue; - }; - if type_condition_pos.type_name() == to_type_name { - fake_downcast_edge = Some(edge); - break; - }; - } - if let Some(fake_downcast_edge) = fake_downcast_edge { - let condition_resolution = self.can_satisfy_conditions( - fake_downcast_edge, - condition_resolver, - context, - &Default::default(), - &Default::default(), - )?; - if matches!( - condition_resolution, - ConditionResolution::Unsatisfied { .. } - ) { - return Ok((None, None)); - } - let fragment_path = self.add( - operation_inline_fragment.clone().into(), - Some(fake_downcast_edge), - condition_resolution, - operation_inline_fragment.defer_directive_arguments()?, - )?; - return Ok((Some(vec![fragment_path.into()]), None)); - } - } - - debug!("Cannot ever get type from current type: returning empty branch"); - // The operation element we're dealing with can never return results (the - // type conditions applied have no intersection). This means we can fulfill - // this operation element (by doing nothing and returning an empty result), - // which we indicate by return ingan empty list of options. - Ok((Some(vec![]), None)) - } - _ => { - // We shouldn't have a fragment on a non-composite type. - Err(FederationError::internal(format!( - "Unexpectedly found inline fragment {} on non-composite type {}", - operation_inline_fragment, tail_type_pos, - ))) - } - } - } - } - } - - /// Given an `OpGraphPath` and a `SimultaneousPaths` that represent 2 different options to reach - /// the same query leaf field, checks if one can be shown to be always "better" (more - /// efficient/optimal) than the other one, regardless of any surrounding context (i.e. - /// regardless of what the rest of the query plan would be for any other query leaf field). - /// - /// Returns the comparison of the complexity of `self` and `other` (e.g. `Ordering::Less` means - /// `self` is better/has less complexity than `other`). If we can't guarantee anything (at least - /// "out of context"), then we return `Ordering::Equal`. - fn compare_single_vs_multi_path_options_complexity_out_of_context( - &self, - other: &SimultaneousPaths, - ) -> Result { - // This handles the same case as the single-path-only case, but compares the single path - // against each path of the `SimultaneousPaths`, and only "ignores" the `SimultaneousPaths` - // if all its paths can be ignored. - // - // Note that this happens less often than the single-path-only case, but with `@provides` on - // an interface, you can have cases where on one hand you can get something completely on - // the current subgraph, but the type-exploded case has to still be generated due to the - // leaf field not being the one just after the "provided" interface. - for other_path in other.0.iter() { - // Note: Not sure if it is possible for a path of the `SimultaneousPaths` option to - // subsume the single-path one in practice, but if it does, we ignore it because it's - // not obvious that this is enough to get rid of `self` (maybe if `self` is provably a - // bit costlier than one of the paths of `other`, but `other` may have many paths and - // could still be collectively worst than `self`). - if self.compare_single_path_options_complexity_out_of_context(other_path)? - != Ordering::Less - { - return Ok(Ordering::Equal); - } - } - Ok(Ordering::Less) - } - - /// Given 2 `OpGraphPath`s that represent 2 different paths to reach the same query leaf field, - /// checks if one can be shown to be always "better" (more efficient/optimal) than the other - /// one, regardless of any surrounding context (i.e. regardless of what the rest of the query - /// plan would be for any other query leaf field). - /// - /// Returns the comparison of the complexity of `self` and `other` (e.g. `Ordering::Less` means - /// `self` is better/has less complexity than `other`). If we can't guarantee anything (at least - /// "out of context"), then we return `Ordering::Equal`. - fn compare_single_path_options_complexity_out_of_context( - &self, - other: &OpGraphPath, - ) -> Result { - // Currently, this method only handles the case where we have something like: - // - `self`: -[t]-> T(A) -[u]-> U(A) -[x] -> Int(A) - // - `other`: -[t]-> T(A) -[key]-> T(B) -[u]-> U(B) -[x] -> Int(B) - // That is, where we have 2 choices that are identical up to the "end", when one stays in - // the subgraph (`self`, which stays in A) while the other uses a key to get to another - // subgraph (`other`, going to B). - // - // In such a case, whatever else the query plan might be doing, it can never be "worse" - // to use `self` than to use `other` because both will force the same "fetch dependency - // graph node" up to the end, but `other` may force one more fetch that `self` does not. - // Do note that we say "may" above, because the rest of the query plan may very well have a - // forced choice like: - // - `option`: -[t]-> T(A) -[key]-> T(B) -[u]-> U(B) -[y] -> Int(B) - // in which case the query plan will have the jump from A to B after `t` regardless of - // whether we use `self` or `other`, but while in that particular case `self` and `other` - // are about comparable in terms of performance, `self` is still not worse than `other` (and - // in other situations, `self` may be genuinely be better). - // - // Note that this is in many ways just a generalization of a heuristic we use earlier for - // leaf fields. That is, we will never get as input to this method something like: - // - `self`: -[t]-> T(A) -[x] -> Int(A) - // - `other`: -[t]-> T(A) -[key]-> T(B) -[x] -> Int(B) - // because when the code is asked for the options for `x` after ` -[t]-> T(A)`, - // it notices that `x` is a leaf and is in `A`, so it doesn't ever look for alternative - // paths. But this only works for direct leaves of an entity. In the example at the start, - // field `u` makes this not work, because when we compute choices for `u`, we don't yet know - // what comes after that, and so we have to take the option of going to subgraph `B` into - // account (it may very well be that whatever comes after `u` is not in `A`, for instance). - let self_tail_weight = self.graph.node_weight(self.tail)?; - let other_tail_weight = self.graph.node_weight(other.tail)?; - if self_tail_weight.source != other_tail_weight.source { - // As described above, we want to know if one of the paths has no jumps at all (after - // the common prefix) while the other has some. - self.compare_subgraph_jumps_after_last_common_node(other) - } else { - Ok(Ordering::Equal) - } - } -} - -impl Display for OpGraphPath { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - // If the path is length is 0 return "[]" - // Traverse the path, getting the of the edge. - let head = &self.graph.graph()[self.head]; - let head_is_root_node = head.is_root_node(); - if head_is_root_node && self.edges.is_empty() { - return write!(f, "_"); - } - if !head_is_root_node { - write!(f, "{head}")?; - } - self.edges - .iter() - .cloned() - .enumerate() - .try_for_each(|(i, e)| match e { - Some(e) => { - let tail = self.graph.graph().edge_endpoints(e).unwrap().1; - let node = &self.graph.graph()[tail]; - if i == 0 && head_is_root_node { - write!(f, "{node}") - } else { - let edge = &self.graph.graph()[e]; - let label = edge.transition.to_string(); - - if let Some(conditions) = &edge.conditions { - write!(f, " --[{conditions} ⊢ {label}]--> {node}") - } else if !matches!( - edge.transition, - QueryGraphEdgeTransition::SubgraphEnteringTransition - ) { - write!(f, " --[{label}]--> {node}") - } else { - core::fmt::Result::Ok(()) - } - } - } - None => write!(f, " ({}) ", self.edge_triggers[i].as_ref()), - })?; - if let Some(label) = self.defer_on_tail.as_ref().and_then(|d| d.label.as_ref()) { - write!(f, "")?; - } - if !self.runtime_types_of_tail.is_empty() { - write!(f, " (types: [")?; - for ty in self.runtime_types_of_tail.iter() { - write!(f, "{ty}")?; - } - write!(f, "])")?; + write!(f, "])")?; } Ok(()) } } - -impl SimultaneousPaths { - /// Given options generated for the advancement of each path of a `SimultaneousPaths`, generate - /// the options for the `SimultaneousPaths` as a whole. - fn flat_cartesian_product( - options_for_each_path: Vec>, - ) -> Result, FederationError> { - // This can be written more tersely with a bunch of `reduce()`/`flat_map()`s and friends, - // but when interfaces type-explode into many implementations, this can end up with fairly - // large `Vec`s and be a bottleneck, and a more iterative version that pre-allocates `Vec`s - // is quite a bit faster. - if options_for_each_path.is_empty() { - return Ok(vec![]); - } - - // Track, for each path, which option index we're at. - let mut option_indexes = vec![0; options_for_each_path.len()]; - - // Pre-allocate `Vec` for the result. - let num_options = options_for_each_path - .iter() - .fold(1_usize, |product, options| { - product.saturating_mul(options.len()) - }); - if num_options > 1_000_000 { - return Err(FederationError::internal(format!( - "flat_cartesian_product: excessive number of combinations: {num_options}" - ))); - } - let mut product = Vec::with_capacity(num_options); - - // Compute the cartesian product. - for _ in 0..num_options { - let num_simultaneous_paths = options_for_each_path - .iter() - .zip(&option_indexes) - .map(|(options, option_index)| options[*option_index].0.len()) - .sum(); - let mut simultaneous_paths = Vec::with_capacity(num_simultaneous_paths); - - for (options, option_index) in options_for_each_path.iter().zip(&option_indexes) { - simultaneous_paths.extend(options[*option_index].0.iter().cloned()); - } - product.push(SimultaneousPaths(simultaneous_paths)); - - for (options, option_index) in options_for_each_path.iter().zip(&mut option_indexes) { - if *option_index == options.len() - 1 { - *option_index = 0 - } else { - *option_index += 1; - break; - } - } - } - - Ok(product) - } - - /// Given 2 `SimultaneousPaths` that represent 2 different options to reach the same query leaf - /// field, checks if one can be shown to be always "better" (more efficient/optimal) than the - /// other one, regardless of any surrounding context (i.e. regardless of what the rest of the - /// query plan would be for any other query leaf field). - /// - /// Note that this method is used on the final options of a given "query path", so all the - /// heuristics done within `GraphPath` to avoid unnecessary options have already been applied - /// (e.g. avoiding the consideration of paths that do 2 successive key jumps when there is a - /// 1-jump equivalent), so this focus on what can be done is based on the fact that the path - /// considered is "finished". - /// - /// Returns the comparison of the complexity of `self` and `other` (e.g. `Ordering::Less` means - /// `self` is better/has less complexity than `other`). If we can't guarantee anything (at least - /// "out of context"), then we return `Ordering::Equal`. - fn compare_options_complexity_out_of_context( - &self, - other: &SimultaneousPaths, - ) -> Result { - match (self.0.as_slice(), other.0.as_slice()) { - ([a], [b]) => a.compare_single_path_options_complexity_out_of_context(b), - ([a], _) => a.compare_single_vs_multi_path_options_complexity_out_of_context(other), - (_, [b]) => b - .compare_single_vs_multi_path_options_complexity_out_of_context(self) - .map(Ordering::reverse), - _ => Ok(Ordering::Equal), - } - } -} - -impl From> for SimultaneousPaths { - fn from(value: Arc) -> Self { - Self(vec![value]) - } -} - -impl From for SimultaneousPaths { - fn from(value: OpGraphPath) -> Self { - Self::from(Arc::new(value)) - } -} - -impl SimultaneousPathsWithLazyIndirectPaths { - pub(crate) fn new( - paths: SimultaneousPaths, - context: OpGraphPathContext, - excluded_destinations: ExcludedDestinations, - excluded_conditions: ExcludedConditions, - ) -> SimultaneousPathsWithLazyIndirectPaths { - SimultaneousPathsWithLazyIndirectPaths { - lazily_computed_indirect_paths: std::iter::repeat_with(|| None) - .take(paths.0.len()) - .collect(), - paths, - context, - excluded_destinations, - excluded_conditions, - } - } - - /// For a given "input" path (identified by an idx in `paths`), each of its indirect options. - fn indirect_options( - &mut self, - updated_context: &OpGraphPathContext, - path_index: usize, - condition_resolver: &mut impl ConditionResolver, - override_conditions: &EnabledOverrideConditions, - ) -> Result { - // Note that the provided context will usually be one we had during construction (the - // `updated_context` will be `self.context` updated by whichever operation we're looking at, - // but only operation elements with a @skip/@include will change the context so it's pretty - // rare), which is why we save recomputation by caching the computed value in that case, but - // in case it's different, we compute without caching. - if *updated_context != self.context { - self.compute_indirect_paths(path_index, condition_resolver, override_conditions)?; - } - if let Some(indirect_paths) = &self.lazily_computed_indirect_paths[path_index] { - Ok(indirect_paths.clone()) - } else { - let new_indirect_paths = - self.compute_indirect_paths(path_index, condition_resolver, override_conditions)?; - self.lazily_computed_indirect_paths[path_index] = Some(new_indirect_paths.clone()); - Ok(new_indirect_paths) - } - } - - fn compute_indirect_paths( - &self, - path_index: usize, - condition_resolver: &mut impl ConditionResolver, - overridden_conditions: &EnabledOverrideConditions, - ) -> Result { - self.paths.0[path_index].advance_with_non_collecting_and_type_preserving_transitions( - &self.context, - condition_resolver, - &self.excluded_destinations, - &self.excluded_conditions, - overridden_conditions, - // The transitions taken by this method are non-collecting transitions, in which case - // the trigger is the context (which is really a hack to provide context information for - // keys during fetch dependency graph updating). - |_, context| OpGraphPathTrigger::Context(context.clone()), - |graph, node, trigger, overridden_conditions| { - graph.edge_for_op_graph_path_trigger(node, trigger, overridden_conditions) - }, - ) - } - - fn create_lazy_options( - &self, - options: Vec, - context: OpGraphPathContext, - ) -> Vec { - options - .into_iter() - .map(|paths| { - SimultaneousPathsWithLazyIndirectPaths::new( - paths, - context.clone(), - self.excluded_destinations.clone(), - self.excluded_conditions.clone(), - ) - }) - .collect() - } - - /// Returns `None` if the operation cannot be dealt with/advanced. Otherwise, it returns a `Vec` - /// of options we can be in after advancing the operation, each option being a set of - /// simultaneous paths in the subgraphs (a single path in the simple case, but type exploding - /// may make us explore multiple paths simultaneously). - /// - /// The lists of options can be empty, which has the special meaning that the operation is - /// guaranteed to have no results (it corresponds to unsatisfiable conditions), meaning that as - /// far as query planning goes, we can just ignore the operation but otherwise continue. - // PORT_NOTE: In the JS codebase, this was named `advanceSimultaneousPathsWithOperation`. - pub(crate) fn advance_with_operation_element( - &mut self, - supergraph_schema: ValidFederationSchema, - operation_element: &OpPathElement, - condition_resolver: &mut impl ConditionResolver, - override_conditions: &EnabledOverrideConditions, - ) -> Result>, FederationError> { - debug!( - "Trying to advance paths for operation: path = {}, operation = {operation_element}", - self.paths - ); - let span = debug_span!(" |"); - let _guard = span.enter(); - let updated_context = self.context.with_context_of(operation_element)?; - let mut options_for_each_path = vec![]; - - // To call the mutating method `indirect_options()`, we need to not hold any immutable - // references to `self`, which means cloning these paths when iterating. - let paths = self.paths.0.clone(); - for (path_index, path) in paths.iter().enumerate() { - debug!("Computing options for {path}"); - let span = debug_span!(" |"); - let gaurd = span.enter(); - let mut options = None; - let should_reenter_subgraph = path.defer_on_tail.is_some() - && matches!(operation_element, OpPathElement::Field(_)); - if !should_reenter_subgraph { - debug!("Direct options"); - let span = debug_span!(" |"); - let gaurd = span.enter(); - let (advance_options, has_only_type_exploded_results) = path - .advance_with_operation_element( - supergraph_schema.clone(), - operation_element, - &updated_context, - condition_resolver, - override_conditions, - )?; - debug!("{advance_options:?}"); - drop(gaurd); - // If we've got some options, there are a number of cases where there is no point - // looking for indirect paths: - // - If the operation element is terminal: this means we just found a direct edge - // that is terminal, so no indirect options could be better (this is not true for - // non-terminal operation element, where the direct route may end up being a dead - // end later). One exception however is when `advanceWithOperationElement()` - // type-exploded (meaning that we're on an interface), because in that case, the - // type-exploded options have already taken indirect edges into account, so it's - // possible that an indirect edge _from the interface_ could be better, but only - // if there wasn't a "true" direct edge on the interface, which is what - // `has_only_type_exploded_results` tells us. - // - If we get options, but an empty set of them, which signifies the operation - // element corresponds to unsatisfiable conditions and we can essentially ignore - // it. - // - If the operation element is a fragment in general: if we were able to find a - // direct option, that means the type is known in the "current" subgraph, and so - // we'll still be able to take any indirect edges that we could take now later, - // for the follow-up operation element. And pushing the decision will give us more - // context and may avoid a bunch of state explosion in practice. - if let Some(advance_options) = advance_options { - if advance_options.is_empty() - || (operation_element.is_terminal()? - && !has_only_type_exploded_results.unwrap_or(false)) - || matches!(operation_element, OpPathElement::InlineFragment(_)) - { - debug!("Final options for {path}: {advance_options:?}"); - // Note that if options is empty, that means this particular "branch" is - // unsatisfiable, so we should just ignore it. - if !advance_options.is_empty() { - options_for_each_path.push(advance_options); - } - continue; - } else { - options = Some(advance_options); - } - } - } - - // If there was not a valid direct path (or we didn't check those because we entered a - // defer), that's ok, we'll just try with non-collecting edges. - let mut options = options.unwrap_or_else(Vec::new); - if let OpPathElement::Field(operation_field) = operation_element { - // Add whatever options can be obtained by taking some non-collecting edges first. - let paths_with_non_collecting_edges = self - .indirect_options( - &updated_context, - path_index, - condition_resolver, - override_conditions, - )? - .filter_non_collecting_paths_for_field(operation_field)?; - if !paths_with_non_collecting_edges.paths.is_empty() { - debug!( - "{} indirect paths", - paths_with_non_collecting_edges.paths.len() - ); - for path_with_non_collecting_edges in - paths_with_non_collecting_edges.paths.iter() - { - debug!("For indirect path {path_with_non_collecting_edges}:"); - let span = debug_span!(" |"); - let _gaurd = span.enter(); - let (advance_options, _) = path_with_non_collecting_edges - .advance_with_operation_element( - supergraph_schema.clone(), - operation_element, - &updated_context, - condition_resolver, - override_conditions, - )?; - // If we can't advance the operation element after that path, ignore it, - // it's just not an option. - let Some(advance_options) = advance_options else { - debug!("Ignoring: cannot be advanced with {operation_element}"); - continue; - }; - debug!("Adding valid option: {advance_options:?}"); - // `advance_with_operation_element()` can return an empty `Vec` only if the - // operation element is a fragment with a type condition that, on top of the - // "current" type is unsatisfiable. But as we've only taken type-preserving - // transitions, we cannot get an empty result at this point if we didn't get - // one when testing direct transitions above (in which case we would have - // exited the method early). - if advance_options.is_empty() { - return Err(FederationError::internal(format!( - "Unexpected empty options after non-collecting path {} for {}", - path_with_non_collecting_edges, operation_element, - ))); - } - // There is a special case we can deal with now. Namely, suppose we have a - // case where a query is reaching an interface I in a subgraph S1, we query - // some field of that interface x, and say that x is provided in subgraph S2 - // but by an @interfaceObject for I. - // - // As we look for direct options for I.x in S1 initially, we won't find `x`, - // so we will try to type-explode I (let's say into implementations A and - // B). And in some cases doing so is necessary, but it may also lead to the - // type-exploding option to look like: - // [ - // I(S1) -[... on A]-> A(S1) -[key]-> I(S2) -[x] -> Int(S2), - // I(S1) -[... on B]-> B(S1) -[key]-> I(S2) -[x] -> Int(S2), - // ] - // But as we look at indirect options now (still from I in S1), we will note - // that we can also do: - // I(S1) -[key]-> I(S2) -[x] -> Int(S2), - // And while both options are technically valid, the new one really subsumes - // the first one: there is no point in type-exploding to take a key to the - // same exact subgraph if using the key on the interface directly works. - // - // So here, we look for that case and remove any type-exploding option that - // the new path renders unnecessary. Do note that we only make that check - // when the new option is a single-path option, because this gets kind of - // complicated otherwise. - if path_with_non_collecting_edges.tail_is_interface_object()? { - for indirect_option in &advance_options { - if indirect_option.0.len() == 1 { - let mut new_options = vec![]; - for option in options { - let mut is_equivalent = true; - for path in &option.0 { - is_equivalent = is_equivalent - && indirect_option.0[0] - .is_equivalent_save_for_type_explosion_to( - path, - )?; - } - if !is_equivalent { - new_options.push(option) - } - } - options = new_options; - } - } - } - options.extend(advance_options); - } - } else { - debug!("no indirect paths"); - } - } - - // If we were entering a @defer, we've skipped the potential "direct" options because we - // need an "indirect" one (a key/root query) to be able to actually defer. But in rare - // cases, it's possible we actually couldn't resolve the key fields needed to take a key - // but could still find a direct path. If so, it means it's a corner case where we - // cannot do query-planner-based-@defer and have to fall back on not deferring. - if options.is_empty() && should_reenter_subgraph { - let span = debug_span!( - "Cannot defer (no indirect options); falling back to direct options" - ); - let _guard = span.enter(); - let (advance_options, _) = path.advance_with_operation_element( - supergraph_schema.clone(), - operation_element, - &updated_context, - condition_resolver, - override_conditions, - )?; - options = advance_options.unwrap_or_else(Vec::new); - debug!("{options:?}"); - } - - // At this point, if options is empty, it means we found no ways to advance the - // operation element for this path, so we should return `None`. - if options.is_empty() { - drop(gaurd); - debug!("No valid options for {operation_element}, aborting."); - return Ok(None); - } else { - options_for_each_path.push(options); - } - } - - let all_options = SimultaneousPaths::flat_cartesian_product(options_for_each_path)?; - debug!("{all_options:?}"); - Ok(Some(self.create_lazy_options(all_options, updated_context))) - } -} - -// PORT_NOTE: JS passes a ConditionResolver here, we do not: see port note for -// `SimultaneousPathsWithLazyIndirectPaths` -pub(crate) fn create_initial_options( - initial_path: GraphPath>, - initial_type: &QueryGraphNodeType, - initial_context: OpGraphPathContext, - condition_resolver: &mut impl ConditionResolver, - excluded_edges: ExcludedDestinations, - excluded_conditions: ExcludedConditions, - override_conditions: &EnabledOverrideConditions, -) -> Result, FederationError> { - let initial_paths = SimultaneousPaths::from(initial_path); - let mut lazy_initial_path = SimultaneousPathsWithLazyIndirectPaths::new( - initial_paths, - initial_context.clone(), - excluded_edges, - excluded_conditions, - ); - - if initial_type.is_federated_root_type() { - let initial_options = lazy_initial_path.indirect_options( - &initial_context, - 0, - condition_resolver, - override_conditions, - )?; - let options = initial_options - .paths - .iter() - .cloned() - .map(SimultaneousPaths::from) - .collect(); - Ok(lazy_initial_path.create_lazy_options(options, initial_context)) - } else { - Ok(vec![lazy_initial_path]) - } -} - -impl ClosedBranch { - /// This method is called on a closed branch (i.e. on all the possible options found to get a - /// particular leaf of the query being planned), and when there is more than one option, it - /// tries a last effort at checking an option can be shown to be less efficient than another one - /// _whatever the rest of the query plan is_ (that is, whatever the options for any other leaf - /// of the query are). - /// - /// In practice, this compares all pairs of options and calls the heuristics of - /// `compare_options_complexity_out_of_context()` on them to see if one strictly subsumes the - /// other (and if that's the case, the subsumed one is ignored). - pub(crate) fn maybe_eliminate_strictly_more_costly_paths( - self, - ) -> Result { - if self.0.len() <= 1 { - return Ok(self); - } - - // Keep track of which options should be kept. - let mut keep_options = vec![true; self.0.len()]; - for option_index in 0..(self.0.len()) { - if !keep_options[option_index] { - continue; - } - // We compare the current option to every other remaining option. - // - // PORT_NOTE: We don't technically need to iterate in reverse order here, but the JS - // codebase does, and we do the same to ensure the result is the same. (The JS codebase - // requires this because it removes from the array it's iterating through.) - let option = &self.0[option_index]; - let mut keep_option = true; - for (other_option, keep_other_option) in self.0[(option_index + 1)..] - .iter() - .zip(&mut keep_options[(option_index + 1)..]) - .rev() - { - if !*keep_other_option { - continue; - } - match option - .paths - .compare_options_complexity_out_of_context(&other_option.paths)? - { - Ordering::Less => { - *keep_other_option = false; - } - Ordering::Equal => {} - Ordering::Greater => { - keep_option = false; - break; - } - } - } - if !keep_option { - keep_options[option_index] = false; - } - } - - Ok(ClosedBranch( - self.0 - .into_iter() - .zip(&keep_options) - .filter(|(_, &keep_option)| keep_option) - .map(|(option, _)| option) - .collect(), - )) - } -} - -impl OpPath { - pub(crate) fn len(&self) -> usize { - self.0.len() - } - - pub(crate) fn is_empty(&self) -> bool { - self.len() == 0 - } - - pub(crate) fn strip_prefix(&self, maybe_prefix: &Self) -> Option { - self.0 - .strip_prefix(&*maybe_prefix.0) - .map(|slice| Self(slice.to_vec())) - } - - pub(crate) fn with_pushed(&self, element: Arc) -> Self { - let mut new = self.0.clone(); - new.push(element); - Self(new) - } - - pub(crate) fn conditional_directives(&self) -> DirectiveList { - self.0 - .iter() - .flat_map(|path_element| { - path_element - .directives() - .iter() - .filter(|d| d.name == "include" || d.name == "skip") - }) - .cloned() - .collect() - } - - /// Filter any fragment element in the provided path whose type condition does not exist in the provided schema. - /// Not that if the fragment element should be filtered but it has applied directives, then we preserve those applications by - /// replacing with a fragment with no condition (but if there are no directive, we simply remove the fragment from the path). - // JS PORT NOTE: this method was called filterOperationPath in JS codebase - pub(crate) fn filter_on_schema(&self, schema: &ValidFederationSchema) -> OpPath { - let mut filtered: Vec> = vec![]; - for element in &self.0 { - match element.as_ref() { - OpPathElement::InlineFragment(fragment) => { - if let Some(type_condition) = &fragment.type_condition_position { - if schema.get_type(type_condition.type_name().clone()).is_err() { - if element.directives().is_empty() { - continue; // skip this element - } else { - // Replace this element with an unconditioned inline fragment - let updated_fragment = fragment.with_updated_type_condition(None); - filtered.push(Arc::new(OpPathElement::InlineFragment( - updated_fragment, - ))); - } - } else { - filtered.push(element.clone()); - } - } else { - filtered.push(element.clone()); - } - } - _ => { - filtered.push(element.clone()); - } - } - } - OpPath(filtered) - } - - pub(crate) fn has_only_fragments(&self) -> bool { - // JS PORT NOTE: this was checking for FragmentElement which was used for both inline fragments and spreads - self.0 - .iter() - .all(|p| matches!(p.as_ref(), OpPathElement::InlineFragment(_))) - } -} - -pub(crate) fn concat_paths_in_parents( - first: &Option>, - second: &Option>, -) -> Option> { - if let (Some(first), Some(second)) = (first, second) { - Some(Arc::new(concat_op_paths(first.deref(), second.deref()))) - } else { - None - } -} - -pub(crate) fn concat_op_paths(head: &OpPath, tail: &OpPath) -> OpPath { - // While this is mainly a simple array concatenation, we optimize slightly by recognizing if the - // tail path starts by a fragment selection that is useless given the end of the head path - let Some(last_of_head) = head.last() else { - return tail.clone(); - }; - let mut result = head.clone(); - if tail.is_empty() { - return result; - } - let conditionals = head.conditional_directives(); - let tail_path = tail.0.clone(); - - // Note that in practice, we may be able to eliminate a few elements at the beginning of the path - // due do conditionals ('@skip' and '@include'). Indeed, a (tail) path crossing multiple conditions - // may start with: [ ... on X @include(if: $c1), ... on X @skip(if: $c2), (...)], but if `head` - // already ends on type `X` _and_ both the conditions on `$c1` and `$c2` are already found on `head`, - // then we can remove both fragments in `tail`. - let mut tail_iter = tail_path.iter(); - for tail_node in &mut tail_iter { - if !is_useless_followup_element(last_of_head, tail_node, &conditionals) - .is_ok_and(|is_useless| is_useless) - { - result.0.push(tail_node.clone()); - break; - } - } - result.0.extend(tail_iter.cloned()); - result -} - -fn is_useless_followup_element( - first: &OpPathElement, - followup: &OpPathElement, - conditionals: &DirectiveList, -) -> Result { - let type_of_first: Option = match first { - OpPathElement::Field(field) => Some(field.output_base_type()?.try_into()?), - OpPathElement::InlineFragment(fragment) => fragment.type_condition_position.clone(), - }; - - let Some(type_of_first) = type_of_first else { - return Ok(false); - }; - - // The followup is useless if it's a fragment (with no directives we would want to preserve) whose type - // is already that of the first element (or a supertype). - return match followup { - OpPathElement::Field(_) => Ok(false), - OpPathElement::InlineFragment(fragment) => { - let Some(type_of_second) = fragment.type_condition_position.clone() else { - return Ok(false); - }; - - let are_useless_directives = fragment.directives.is_empty() - || fragment.directives.iter().all(|d| conditionals.contains(d)); - let is_same_type = type_of_first.type_name() == type_of_second.type_name(); - let is_subtype = first - .schema() - .schema() - .is_subtype(type_of_second.type_name(), type_of_first.type_name()); - Ok(are_useless_directives && (is_same_type || is_subtype)) - } - }; -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use apollo_compiler::Name; - use apollo_compiler::Schema; - use petgraph::stable_graph::EdgeIndex; - use petgraph::stable_graph::NodeIndex; - - use crate::operation::Field; - use crate::query_graph::build_query_graph::build_query_graph; - use crate::query_graph::condition_resolver::ConditionResolution; - use crate::query_graph::graph_path::OpGraphPath; - use crate::query_graph::graph_path::OpGraphPathTrigger; - use crate::query_graph::graph_path::OpPathElement; - use crate::schema::position::ObjectFieldDefinitionPosition; - use crate::schema::ValidFederationSchema; - - #[test] - fn path_display() { - let src = r#" - type Query - { - t: T - } - - type T - { - otherId: ID! - id: ID! - } - "#; - let schema = Schema::parse_and_validate(src, "./").unwrap(); - let schema = ValidFederationSchema::new(schema).unwrap(); - let name = "S1".into(); - let graph = build_query_graph(name, schema.clone()).unwrap(); - let path = OpGraphPath::new(Arc::new(graph), NodeIndex::new(0)).unwrap(); - // NOTE: in general GraphPath would be used against a federated supergraph which would have - // a root node [query](_)* followed by a Query(S1) node - // This test is run against subgraph schema meaning it will start from Query(S1) node instead - assert_eq!(path.to_string(), "Query(S1) (types: [Query])"); - let pos = ObjectFieldDefinitionPosition { - type_name: Name::new("T").unwrap(), - field_name: Name::new("t").unwrap(), - }; - let field = Field::from_position(&schema, pos.into()); - let trigger = OpGraphPathTrigger::OpPathElement(OpPathElement::Field(field)); - let path = path - .add( - trigger, - Some(EdgeIndex::new(3)), - ConditionResolution::Satisfied { - cost: 0.0, - path_tree: None, - }, - None, - ) - .unwrap(); - assert_eq!(path.to_string(), "Query(S1) --[t]--> T(S1) (types: [T])"); - let pos = ObjectFieldDefinitionPosition { - type_name: Name::new("ID").unwrap(), - field_name: Name::new("id").unwrap(), - }; - let field = Field::from_position(&schema, pos.into()); - let trigger = OpGraphPathTrigger::OpPathElement(OpPathElement::Field(field)); - let path = path - .add( - trigger, - Some(EdgeIndex::new(1)), - ConditionResolution::Satisfied { - cost: 0.0, - path_tree: None, - }, - None, - ) - .unwrap(); - assert_eq!( - path.to_string(), - "Query(S1) --[t]--> T(S1) --[id]--> ID(S1)" - ); - } -} diff --git a/apollo-federation/src/query_graph/graph_path/operation.rs b/apollo-federation/src/query_graph/graph_path/operation.rs new file mode 100644 index 0000000000..32084c2a6c --- /dev/null +++ b/apollo-federation/src/query_graph/graph_path/operation.rs @@ -0,0 +1,2626 @@ +use std::cmp::Ordering; +use std::fmt::Display; +use std::fmt::Formatter; +use std::ops::Deref; +use std::sync::Arc; + +use apollo_compiler::ast::Value; +use apollo_compiler::collections::IndexSet; +use itertools::Itertools; +use petgraph::graph::EdgeIndex; +use tracing::debug; +use tracing::debug_span; + +use crate::display_helpers::DisplayOption; +use crate::display_helpers::DisplaySlice; +use crate::display_helpers::State as IndentedFormatter; +use crate::display_helpers::write_indented_lines; +use crate::error::FederationError; +use crate::error::SingleFederationError; +use crate::is_leaf_type; +use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; +use crate::link::graphql_definition::BooleanOrVariable; +use crate::link::graphql_definition::DeferDirectiveArguments; +use crate::link::graphql_definition::OperationConditional; +use crate::link::graphql_definition::OperationConditionalKind; +use crate::operation::DirectiveList; +use crate::operation::Field; +use crate::operation::HasSelectionKey; +use crate::operation::InlineFragment; +use crate::operation::Selection; +use crate::operation::SelectionId; +use crate::operation::SelectionKey; +use crate::operation::SelectionSet; +use crate::operation::SiblingTypename; +use crate::query_graph::OverrideConditions; +use crate::query_graph::QueryGraphEdgeTransition; +use crate::query_graph::QueryGraphNodeType; +use crate::query_graph::condition_resolver::ConditionResolution; +use crate::query_graph::condition_resolver::ConditionResolver; +use crate::query_graph::graph_path::ExcludedConditions; +use crate::query_graph::graph_path::ExcludedDestinations; +use crate::query_graph::graph_path::GraphPath; +use crate::query_graph::graph_path::GraphPathTriggerVariant; +use crate::query_graph::graph_path::IndirectPaths; +use crate::query_graph::graph_path::OverrideId; +use crate::query_graph::path_tree::Preference; +use crate::query_plan::FetchDataPathElement; +use crate::schema::ValidFederationSchema; +use crate::schema::position::AbstractTypeDefinitionPosition; +use crate::schema::position::CompositeTypeDefinitionPosition; +use crate::schema::position::InterfaceFieldDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceTypeDefinitionPosition; +use crate::schema::position::OutputTypeDefinitionPosition; +use crate::schema::position::TypeDefinitionPosition; +use crate::utils::logging::format_open_branch; + +/// A `GraphPath` whose triggers are operation elements (essentially meaning that the path has been +/// guided by a GraphQL operation). +// PORT_NOTE: As noted in the docs for `GraphPath`, we omit a type parameter for the root node, +// whose constraint is instead checked at runtime. This means the `OpRootPath` type in the JS +// codebase is replaced with this one. +pub(crate) type OpGraphPath = GraphPath>; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::From, serde::Serialize)] +pub(crate) enum OpGraphPathTrigger { + OpPathElement(OpPathElement), + Context(OpGraphPathContext), +} + +impl Display for OpGraphPathTrigger { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + OpGraphPathTrigger::OpPathElement(ele) => ele.fmt(f), + OpGraphPathTrigger::Context(ctx) => ctx.fmt(f), + } + } +} + +impl GraphPathTriggerVariant for OpGraphPathTrigger { + fn get_field_parent_type(&self) -> Option { + match self { + OpGraphPathTrigger::OpPathElement(OpPathElement::Field(field)) => { + Some(field.field_position.parent()) + } + _ => None, + } + } + + fn get_field_mut(&mut self) -> Option<&mut Field> { + match self { + OpGraphPathTrigger::OpPathElement(OpPathElement::Field(field)) => Some(field), + _ => None, + } + } + + fn get_op_path_element(&self) -> Option<&OpPathElement> { + match self { + OpGraphPathTrigger::OpPathElement(ele) => Some(ele), + OpGraphPathTrigger::Context(_) => None, + } + } +} + +/// A path of operation elements within a GraphQL operation. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize)] +pub(crate) struct OpPath(pub(crate) Vec>); + +impl Deref for OpPath { + type Target = [Arc]; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Display for OpPath { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + for (i, element) in self.0.iter().enumerate() { + if i > 0 { + write!(f, "::")?; + } + match element.deref() { + OpPathElement::Field(field) => write!(f, "{field}")?, + OpPathElement::InlineFragment(fragment) => write!(f, "{fragment}")?, + } + } + Ok(()) + } +} + +impl Preference for OpPathElement { + fn preferred_over(&self, other: &Self) -> Option { + match (self, other) { + (OpPathElement::Field(x), OpPathElement::Field(y)) => { + // We prefer the one with a sibling typename (= Less). + // Otherwise, not comparable. + match (&x.sibling_typename, &y.sibling_typename) { + (Some(_), None) => Some(true), + (None, Some(_)) => Some(false), + _ => None, + } + } + _ => None, + } + } +} + +impl Preference for OpGraphPathTrigger { + fn preferred_over(&self, other: &Self) -> Option { + match (self, other) { + (OpGraphPathTrigger::OpPathElement(x), OpGraphPathTrigger::OpPathElement(y)) => { + x.preferred_over(y) + } + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::From, serde::Serialize)] +pub(crate) enum OpPathElement { + Field(Field), + InlineFragment(InlineFragment), +} + +impl HasSelectionKey for OpPathElement { + fn key(&self) -> SelectionKey<'_> { + match self { + OpPathElement::Field(field) => field.key(), + OpPathElement::InlineFragment(fragment) => fragment.key(), + } + } +} + +impl OpPathElement { + pub(crate) fn directives(&self) -> &DirectiveList { + match self { + OpPathElement::Field(field) => &field.directives, + OpPathElement::InlineFragment(inline_fragment) => &inline_fragment.directives, + } + } + + pub(crate) fn schema(&self) -> &ValidFederationSchema { + match self { + OpPathElement::Field(field) => field.schema(), + OpPathElement::InlineFragment(fragment) => fragment.schema(), + } + } + + pub(crate) fn is_terminal(&self) -> Result { + match self { + OpPathElement::Field(field) => field.is_leaf(), + OpPathElement::InlineFragment(_) => Ok(false), + } + } + + pub(crate) fn sibling_typename(&self) -> Option<&SiblingTypename> { + match self { + OpPathElement::Field(field) => field.sibling_typename(), + OpPathElement::InlineFragment(_) => None, + } + } + + pub(crate) fn parent_type_position(&self) -> CompositeTypeDefinitionPosition { + match self { + OpPathElement::Field(field) => field.field_position.parent(), + OpPathElement::InlineFragment(inline) => inline.parent_type_position.clone(), + } + } + + pub(crate) fn sub_selection_type_position( + &self, + ) -> Result, FederationError> { + match self { + OpPathElement::Field(field) => Ok(field.output_base_type()?.try_into().ok()), + OpPathElement::InlineFragment(inline) => Ok(Some(inline.casted_type())), + } + } + + pub(crate) fn extract_operation_conditionals( + &self, + ) -> Result, FederationError> { + let mut conditionals = vec![]; + // PORT_NOTE: We explicitly use the order `Skip` and `Include` here, to align with the order + // used by the JS codebase. + for kind in [ + OperationConditionalKind::Skip, + OperationConditionalKind::Include, + ] { + let directive_name: &'static str = (&kind).into(); + if let Some(application) = self.directives().get(directive_name) { + let Some(arg) = application.specified_argument_by_name("if") else { + return Err(FederationError::internal(format!( + "@{directive_name} missing required argument \"if\"" + ))); + }; + let value = match arg.deref() { + Value::Variable(variable_name) => { + BooleanOrVariable::Variable(variable_name.clone()) + } + Value::Boolean(boolean) => BooleanOrVariable::Boolean(*boolean), + _ => { + return Err(FederationError::internal(format!( + "@{} has invalid value {} for argument \"if\"", + directive_name, + arg.serialize().no_indent() + ))); + } + }; + conditionals.push(OperationConditional { kind, value }) + } + } + Ok(conditionals) + } + + pub(crate) fn with_updated_directives(&self, directives: DirectiveList) -> OpPathElement { + match self { + OpPathElement::Field(field) => { + OpPathElement::Field(field.with_updated_directives(directives)) + } + OpPathElement::InlineFragment(inline_fragment) => { + OpPathElement::InlineFragment(inline_fragment.with_updated_directives(directives)) + } + } + } + + pub(crate) fn as_path_element(&self) -> Option { + match self { + OpPathElement::Field(field) => Some(field.as_path_element()), + OpPathElement::InlineFragment(inline_fragment) => inline_fragment.as_path_element(), + } + } + + pub(crate) fn defer_directive_args(&self) -> Option { + match self { + OpPathElement::Field(_) => None, // @defer cannot be on field at the moment + OpPathElement::InlineFragment(inline_fragment) => { + inline_fragment.defer_directive_arguments().ok().flatten() + } + } + } + + pub(crate) fn has_defer(&self) -> bool { + match self { + OpPathElement::Field(_) => false, + OpPathElement::InlineFragment(inline_fragment) => { + inline_fragment.directives.has("defer") + } + } + } + + /// Returns this fragment element but with any @defer directive on it removed. + /// + /// This method will return `None` if, upon removing @defer, the fragment has no conditions nor + /// any remaining applied directives (meaning that it carries no information whatsoever and can be + /// ignored). + pub(crate) fn without_defer(&self) -> Option { + match self { + Self::Field(_) => Some(self.clone()), + Self::InlineFragment(inline_fragment) => { + let updated_directives: DirectiveList = inline_fragment + .directives + .iter() + .filter(|directive| directive.name != "defer") + .cloned() + .collect(); + if inline_fragment.type_condition_position.is_none() + && updated_directives.is_empty() + { + return None; + } + if inline_fragment.directives.len() == updated_directives.len() { + Some(self.clone()) + } else { + // PORT_NOTE: We won't need to port `this.copyAttachementsTo(updated);` line here + // since `with_updated_directives` clones the whole `self` and thus sibling + // type names should be copied as well. + Some(self.with_updated_directives(updated_directives)) + } + } + } + } + + pub(crate) fn rebase_on( + &self, + parent_type: &CompositeTypeDefinitionPosition, + schema: &ValidFederationSchema, + ) -> Result { + match self { + OpPathElement::Field(field) => Ok(field.rebase_on(parent_type, schema)?.into()), + OpPathElement::InlineFragment(inline) => { + Ok(inline.rebase_on(parent_type, schema)?.into()) + } + } + } +} + +impl Display for OpPathElement { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + OpPathElement::Field(field) => field.fmt(f), + OpPathElement::InlineFragment(inline_fragment) => inline_fragment.fmt(f), + } + } +} + +impl From for OpGraphPathTrigger { + fn from(value: Field) -> Self { + OpPathElement::from(value).into() + } +} + +impl From for OpGraphPathTrigger { + fn from(value: InlineFragment) -> Self { + OpPathElement::from(value).into() + } +} + +/// Records, as we walk a path within a GraphQL operation, important directives encountered +/// (currently `@include` and `@skip` with their conditions). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize)] +pub(crate) struct OpGraphPathContext { + /// A list of conditionals (e.g. `[{ kind: Include, value: true}, { kind: Skip, value: $foo }]`) + /// in the reverse order in which they were applied (so the first element is the inner-most + /// applied include/skip). + conditionals: Arc>, +} + +impl OpGraphPathContext { + pub(crate) fn with_context_of( + &self, + operation_element: &OpPathElement, + ) -> Result { + if operation_element.directives().is_empty() { + return Ok(self.clone()); + } + + let mut new_conditionals = operation_element.extract_operation_conditionals()?; + if new_conditionals.is_empty() { + return Ok(self.clone()); + } + new_conditionals.extend(self.iter().cloned()); + Ok(OpGraphPathContext { + conditionals: Arc::new(new_conditionals), + }) + } + + pub(crate) fn is_empty(&self) -> bool { + self.conditionals.is_empty() + } + + pub(crate) fn iter(&self) -> impl Iterator { + self.conditionals.iter() + } +} + +impl Display for OpGraphPathContext { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "[")?; + let mut iter = self.conditionals.iter(); + if let Some(cond) = iter.next() { + write!(f, "@{}(if: {})", cond.kind, cond.value)?; + iter.try_for_each(|cond| write!(f, ", @{}(if: {})", cond.kind, cond.value))?; + } + write!(f, "]") + } +} + +/// A vector of graph paths that are being considered simultaneously by the query planner as an +/// option for a path within a GraphQL operation. These arise since the edge to take in a query +/// graph may depend on outcomes that are only known at query plan execution time, and we account +/// for this by splitting a path into multiple paths (one for each possible outcome). The common +/// example is abstract types, where we may end up taking a different edge depending on the runtime +/// type (e.g. during type explosion). +#[derive(Clone, serde::Serialize)] +pub(crate) struct SimultaneousPaths(pub(crate) Vec>); + +impl SimultaneousPaths { + pub(crate) fn fmt_indented(&self, f: &mut IndentedFormatter) -> std::fmt::Result { + match self.0.as_slice() { + [] => f.write(""), + + [first] => f.write_fmt(format_args!("{first}")), + + _ => { + f.write("{")?; + write_indented_lines(f, &self.0, |f, elem| f.write(elem))?; + f.write("}") + } + } + } +} + +impl std::fmt::Debug for SimultaneousPaths { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_list() + .entries(self.0.iter().map(ToString::to_string)) + .finish() + } +} + +impl Display for SimultaneousPaths { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.fmt_indented(&mut IndentedFormatter::new(f)) + } +} + +/// One of the options for an `OpenBranch` (see the documentation of that struct for details). This +/// includes the simultaneous paths we are traversing for the option, along with metadata about the +/// traversal. +// PORT_NOTE: The JS codebase stored a `ConditionResolver` callback here, but it was the same for +// a given traversal (and cached resolution across the traversal), so we accordingly store it in +// `QueryPlanTraversal` and pass it down when needed instead. +#[derive(Debug, Clone, serde::Serialize)] +pub(crate) struct SimultaneousPathsWithLazyIndirectPaths { + pub(crate) paths: SimultaneousPaths, + pub(crate) context: OpGraphPathContext, + pub(crate) excluded_destinations: ExcludedDestinations, + pub(crate) excluded_conditions: ExcludedConditions, + pub(crate) lazily_computed_indirect_paths: Vec>, +} + +impl Display for SimultaneousPathsWithLazyIndirectPaths { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.paths) + } +} + +type OpIndirectPaths = IndirectPaths, ()>; + +impl Clone for OpIndirectPaths { + fn clone(&self) -> Self { + Self { + paths: self.paths.clone(), + dead_ends: (), + } + } +} + +impl std::fmt::Debug for OpIndirectPaths { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpIndirectPaths") + .field( + "paths", + &self + .paths + .iter() + .map(ToString::to_string) + .collect::>(), + ) + .field("dead_ends", &self.dead_ends) + .finish() + } +} + +impl OpIndirectPaths { + /// When `self` is just-computed indirect paths and given a field that we're trying to advance + /// after those paths, this method filters any paths that should not be considered. + /// + /// Currently, this handles the case where the key used at the end of the indirect path contains + /// (at top level) the field being queried. Or to make this more concrete, if we're trying to + /// collect field `id`, and the path's last edge was using key `id`, then we can ignore that + /// path because this implies that there is a way to fetch `id` "some other way". + pub(crate) fn filter_non_collecting_paths_for_field( + &self, + field: &Field, + ) -> Result { + // We only handle leaves; Things are more complex for non-leaves. + if !field.is_leaf()? { + return Ok(self.clone()); + } + + let mut filtered = vec![]; + for path in self.paths.iter() { + if let Some(Some(last_edge)) = path.edges.last() { + let last_edge_weight = path.graph.edge_weight(*last_edge)?; + if matches!( + last_edge_weight.transition, + QueryGraphEdgeTransition::KeyResolution + ) && let Some(conditions) = &last_edge_weight.conditions + && conditions.contains_top_level_field(field)? + { + continue; + } + } + filtered.push(path.clone()) + } + Ok(if filtered.len() == self.paths.len() { + self.clone() + } else { + OpIndirectPaths { + paths: Arc::new(filtered), + dead_ends: (), + } + }) + } +} + +/// One of the options for a `ClosedBranch` (see the documentation of that struct for details). Note +/// there is an optimization here, in that if some ending section of the path within the GraphQL +/// operation can be satisfied by a query to a single subgraph, then we just record that selection +/// set, and the `SimultaneousPaths` ends at the node at which that query is made instead of a node +/// for the leaf field. The selection set gets copied "as-is" into the `FetchNode`, and also avoids +/// extra `GraphPath` creation and work during `PathTree` merging. +#[derive(Debug, serde::Serialize)] +pub(crate) struct ClosedPath { + pub(crate) paths: SimultaneousPaths, + pub(crate) selection_set: Option>, +} + +impl ClosedPath { + pub(crate) fn flatten( + &self, + ) -> impl Iterator>)> { + self.paths + .0 + .iter() + .map(|path| (path.as_ref(), self.selection_set.as_ref())) + } +} + +impl Display for ClosedPath { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if let Some(ref selection_set) = self.selection_set { + write!(f, "{} -> {}", self.paths, selection_set) + } else { + write!(f, "{}", self.paths) + } + } +} + +/// A list of the options generated during query planning for a specific "closed branch", which is a +/// full/closed path in a GraphQL operation (i.e. one that ends in a leaf field). +#[derive(Debug, serde::Serialize)] +pub(crate) struct ClosedBranch(pub(crate) Vec>); + +/// A list of the options generated during query planning for a specific "open branch", which is a +/// partial/open path in a GraphQL operation (i.e. one that does not end in a leaf field). +#[derive(Debug, serde::Serialize)] +pub(crate) struct OpenBranch(pub(crate) Vec); + +#[derive(Debug, serde::Serialize)] +pub(crate) struct OpenBranchAndSelections { + /// The options for this open branch. + pub(crate) open_branch: OpenBranch, + /// A stack of the remaining selections to plan from the node this open branch ends on. + pub(crate) selections: Vec, +} + +impl Display for OpenBranchAndSelections { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let Some((current_selection, remaining_selections)) = self.selections.split_last() else { + return Ok(()); + }; + format_open_branch(f, &(current_selection, &self.open_branch.0))?; + write!(f, " * Remaining selections:")?; + if remaining_selections.is_empty() { + writeln!(f, " (none)")?; + } else { + // Print in reverse order since remaining selections are processed in that order. + writeln!(f)?; // newline + for selection in remaining_selections.iter().rev() { + writeln!(f, " - {selection}")?; + } + } + Ok(()) + } +} + +impl OpGraphPath { + fn next_edge_for_field( + &self, + field: &Field, + override_conditions: &OverrideConditions, + ) -> Option { + self.graph + .edge_for_field(self.tail, field, override_conditions) + } + + fn next_edge_for_inline_fragment(&self, inline_fragment: &InlineFragment) -> Option { + self.graph + .edge_for_inline_fragment(self.tail, inline_fragment) + } + + fn add_field_edge( + &self, + operation_field: Field, + edge: EdgeIndex, + condition_resolver: &mut impl ConditionResolver, + context: &OpGraphPathContext, + ) -> Result, FederationError> { + let condition_resolution = self.can_satisfy_conditions( + edge, + condition_resolver, + context, + &Default::default(), + &Default::default(), + )?; + if matches!(condition_resolution, ConditionResolution::Satisfied { .. }) { + self.add( + operation_field.into(), + edge.into(), + condition_resolution, + None, + ) + .map(Some) + } else { + Ok(None) + } + } + + pub(crate) fn mark_overriding( + &self, + others: &[SimultaneousPaths], + ) -> (OpGraphPath, Vec) { + let new_id = OverrideId::new(); + let mut new_own_path_ids = self.overriding_path_ids.as_ref().clone(); + new_own_path_ids.insert(new_id); + let new_self = OpGraphPath { + own_path_ids: Arc::new(new_own_path_ids), + ..self.clone() + }; + let new_others = others + .iter() + .map(|option| { + SimultaneousPaths( + option + .0 + .iter() + .map(|path| { + let mut new_overriding_path_ids = + path.overriding_path_ids.as_ref().clone(); + new_overriding_path_ids.insert(new_id); + Arc::new(OpGraphPath { + overriding_path_ids: Arc::new(new_overriding_path_ids), + ..path.as_ref().clone() + }) + }) + .collect(), + ) + }) + .collect(); + (new_self, new_others) + } + + pub(crate) fn subgraph_jumps(&self) -> Result { + self.subgraph_jumps_at_idx(0) + } + + fn subgraph_jumps_at_idx(&self, start_index: usize) -> Result { + self.edges[start_index..] + .iter() + .flatten() + .try_fold(0, |sum, &edge_index| { + let (start, end) = self.graph.edge_endpoints(edge_index)?; + let start = self.graph.node_weight(start)?; + let end = self.graph.node_weight(end)?; + let changes_subgraph = start.source != end.source; + Ok(sum + if changes_subgraph { 1 } else { 0 }) + }) + } + + fn find_longest_common_prefix_length( + &self, + other: &OpGraphPath, + ) -> Result { + if self.head != other.head { + return Err(FederationError::internal( + "Paths unexpectedly did not start at the same node.", + )); + } + + Ok(self + .edges + .iter() + .zip(&other.edges) + .position(|(self_edge, other_edge)| self_edge != other_edge) + .unwrap_or_else(|| self.edges.len().min(other.edges.len()))) + } + + /// Looks for the longest common prefix for `self` and `other` (assuming that both paths are + /// built as options for the same "query path"), and then compares whether each path has + /// subgraph jumps after said prefix. + /// + /// Note this method always return something, but the longest common prefix considered may very + /// well be empty. Also note that this method assumes that the 2 paths have the same root, and + /// will fail if that's not the case. + /// + /// Returns the comparison of whether `self` and `other` have subgraph jumps after said prefix + /// (e.g. `Ordering::Less` means `self` has zero subgraph jumps after said prefix while `other` + /// has at least one). If they both have subgraph jumps or neither has subgraph jumps, then we + /// return `Ordering::Equal`. + fn compare_subgraph_jumps_after_last_common_node( + &self, + other: &OpGraphPath, + ) -> Result { + let longest_common_prefix_len = self.find_longest_common_prefix_length(other)?; + let self_jumps = self.subgraph_jumps_at_idx(longest_common_prefix_len)? > 0; + let other_jumps = other.subgraph_jumps_at_idx(longest_common_prefix_len)? > 0; + Ok(self_jumps.cmp(&other_jumps)) + } + + pub(crate) fn terminate_with_non_requested_typename_field( + &self, + override_conditions: &OverrideConditions, + ) -> Result { + // If the last step of the path was a fragment/type-condition, we want to remove it before + // we get __typename. The reason is that this avoid cases where this method would make us + // build plans like: + // { + // foo { + // __typename + // ... on A { + // __typename + // } + // ... on B { + // __typename + // } + // } + // Instead, we just generate: + // { + // foo { + // __typename + // } + // } + // Note it's ok to do this because the __typename we add is _not_ requested, it is just + // added in cases where we need to ensure a selection is not empty, and so this + // transformation is fine to do. + let path = self.truncate_trailing_downcasts()?; + let tail_weight = self.graph.node_weight(path.tail)?; + let QueryGraphNodeType::SchemaType(tail_type_pos) = &tail_weight.type_ else { + return Err(FederationError::internal( + "Unexpectedly found federated root node as tail", + )); + }; + let Ok(tail_type_pos) = CompositeTypeDefinitionPosition::try_from(tail_type_pos.clone()) + else { + return Ok(path); + }; + let typename_field = Field::new_introspection_typename( + self.graph.schema_by_source(&tail_weight.source)?, + &tail_type_pos, + None, + ); + let Some(edge) = self + .graph + .edge_for_field(path.tail, &typename_field, override_conditions) + else { + return Err(FederationError::internal( + "Unexpectedly missing edge for __typename field", + )); + }; + path.add( + typename_field.into(), + Some(edge), + ConditionResolution::no_conditions(), + None, + ) + } + + /// Remove all trailing downcast edges and `None` edges. + fn truncate_trailing_downcasts(&self) -> Result { + let mut runtime_types = Arc::new(self.head_possible_runtime_types()?); + let mut last_edge_index = None; + let mut last_runtime_types = runtime_types.clone(); + for (edge_index, edge) in self.edges.iter().enumerate() { + runtime_types = Arc::new( + self.graph + .advance_possible_runtime_types(&runtime_types, *edge)?, + ); + let Some(edge) = edge else { + continue; + }; + let edge_weight = self.graph.edge_weight(*edge)?; + if !matches!( + edge_weight.transition, + QueryGraphEdgeTransition::Downcast { .. } + ) { + last_edge_index = Some(edge_index); + last_runtime_types = runtime_types.clone(); + } + } + let Some(last_edge_index) = last_edge_index else { + // PORT_NOTE: The JS codebase just returns the same path if all edges are downcast or + // `None` edges. This is likely a bug, so we instead return the empty path here. + return OpGraphPath::new(self.graph.clone(), self.head); + }; + let prefix_length = last_edge_index + 1; + if prefix_length == self.edges.len() { + return Ok(self.clone()); + } + let Some(last_edge) = self.edges[last_edge_index] else { + return Err(FederationError::internal( + "Unexpectedly found None for last non-downcast, non-None edge", + )); + }; + let (_, last_edge_tail) = self.graph.edge_endpoints(last_edge)?; + Ok(OpGraphPath { + graph: self.graph.clone(), + head: self.head, + tail: last_edge_tail, + edges: self.edges[0..prefix_length].to_vec(), + edge_triggers: self.edge_triggers[0..prefix_length].to_vec(), + edge_conditions: self.edge_conditions[0..prefix_length].to_vec(), + last_subgraph_entering_edge_info: self.last_subgraph_entering_edge_info.clone(), + own_path_ids: self.own_path_ids.clone(), + overriding_path_ids: self.overriding_path_ids.clone(), + runtime_types_of_tail: last_runtime_types, + runtime_types_before_tail_if_last_is_cast: None, + // TODO: The JS codebase copied this from the current path, which seems like a bug. + defer_on_tail: self.defer_on_tail.clone(), + // PORT_NOTE: The JS codebase doesn't properly truncate these fields, this is a bug + // which we fix here. + matching_context_ids: self.matching_context_ids[0..prefix_length].to_vec(), + arguments_to_context_usages: self.arguments_to_context_usages[0..prefix_length] + .to_vec(), + }) + } + + pub(crate) fn is_equivalent_save_for_type_explosion_to( + &self, + other: &OpGraphPath, + ) -> Result { + // We're looking at the specific case where both paths are basically equivalent except for a + // single step of type-explosion, so if either of the paths don't start and end on the + // same node, or if `other` is not exactly 1 more step than `self`, we're done. + if !(self.head == other.head + && self.tail == other.tail + && self.edges.len() == other.edges.len() - 1) + { + return Ok(false); + } + + // If the above is true, then we find the first difference in the paths. + let Some(diff_pos) = self + .edges + .iter() + .zip(&other.edges) + .position(|(self_edge, other_edge)| self_edge != other_edge) + else { + // All edges are the same, but the `other` path has an extra edge. This can't be a type + // explosion + key resolution, so we consider them not equivalent here. + // + // PORT_NOTE: The JS codebase returns `true` here, claiming the paths are the same. This + // isn't true though as we're skipping the last element of `other` in the JS codebase + // (and while that edge can't change the `tail`, it doesn't mean that `self` subsumes + // `other`). We fix this bug here by returning `false` instead of `true`. + return Ok(false); + }; + + // If the first difference is not a "type-explosion", i.e. if `other` is a cast from an + // interface to one of the implementation, then we're not in the case we're looking for. + let Some(self_edge) = self.edges[diff_pos] else { + return Ok(false); + }; + let Some(other_edge) = other.edges[diff_pos] else { + return Ok(false); + }; + let other_edge_weight = other.graph.edge_weight(other_edge)?; + let QueryGraphEdgeTransition::Downcast { + from_type_position, .. + } = &other_edge_weight.transition + else { + return Ok(false); + }; + if !matches!( + from_type_position, + CompositeTypeDefinitionPosition::Interface(_) + ) { + return Ok(false); + } + + // At this point, we want both paths to take the "same" key, but because one is starting + // from the interface while the other one from an implementation, they won't be technically + // the "same" edge index. So we check that both are key-resolution edges, to the same + // subgraph and type, and with the same condition. + let Some(other_next_edge) = other.edges[diff_pos + 1] else { + return Ok(false); + }; + let (_, self_edge_tail) = other.graph.edge_endpoints(self_edge)?; + let self_edge_weight = other.graph.edge_weight(self_edge)?; + let (_, other_next_edge_tail) = other.graph.edge_endpoints(other_next_edge)?; + let other_next_edge_weight = other.graph.edge_weight(other_next_edge)?; + if !(matches!( + self_edge_weight.transition, + QueryGraphEdgeTransition::KeyResolution + ) && matches!( + other_next_edge_weight.transition, + QueryGraphEdgeTransition::KeyResolution + ) && self_edge_tail == other_next_edge_tail + && self_edge_weight.conditions == other_next_edge_weight.conditions) + { + return Ok(false); + } + + // So far, so good. Check that the rest of the paths are equal. Note that starts with + // `diff_pos + 1` for `self`, but `diff_pos + 2` for `other` since we looked at two edges + // there instead of one. + Ok(self.edges[(diff_pos + 1)..] + .iter() + .zip(other.edges[(diff_pos + 2)..].iter()) + .all(|(self_edge, other_edge)| self_edge == other_edge)) + } + + /// This method is used to detect when using an interface field "directly" could fail (i.e. lead + /// to a dead end later for the query path) while type-exploding may succeed. + /// + /// In general, taking a field from an interface directly or through it's implementation by + /// type-exploding leads to the same option, and so taking one or the other is more of a matter + /// of "which is more efficient". But there is a special case where this may not be true, and + /// this is when all of the following hold: + /// 1. The interface is implemented by an entity type. + /// 2. The field being looked at is @shareable. + /// 3. The field type has a different set of fields (and less fields) in the "current" subgraph + /// than in another one. + /// + /// For instance, consider if some Subgraph A has this schema: + /// """ + /// type Query { + /// i: I + /// } + /// + /// interface I { + /// s: S + /// } + /// + /// type T implements I @key(fields: "id") { + /// id: ID! + /// s: S @shareable + /// } + /// + /// type S @shareable { + /// x: Int + /// } + /// """ + /// and if some Subgraph B has this schema: + /// """ + /// type T @key(fields: "id") { + /// id: ID! + /// s: S @shareable + /// } + /// + /// type S @shareable { + /// x: Int + /// y: Int + /// } + /// """ + /// and suppose that `{ i { s { y } } }` is queried. If we follow `I.s` in subgraph A then the + /// `y` field cannot be found, because `S` not being an entity means we cannot "jump" to + /// subgraph B (even if it was, there may not be a usable key to jump between the 2 subgraphs). + /// However, if we "type-explode" into implementation `T`, then we can jump to subgraph B from + /// that, at which point we can reach `y`. + /// + /// So the goal of this method is to detect when we might be in such a case: when we are, we + /// will have to consider type explosion on top of the direct route in case that direct route + /// ends up "not panning out" (note that by the time this method is called, we're only looking + /// at the options for type `I.s`; we do not know yet if `y` is queried next and so cannot tell + /// if type explosion will be necessary or not). + // PORT_NOTE: In the JS code, this method was a free-standing function called "anImplementationIsEntityWithFieldShareable". + fn has_an_entity_implementation_with_shareable_field( + &self, + source: &Arc, + interface_field_pos: InterfaceFieldDefinitionPosition, + ) -> Result { + let fed_schema = self.graph.schema_by_source(source)?; + let schema = fed_schema.schema(); + let fed_spec = get_federation_spec_definition_from_subgraph(fed_schema)?; + let key_directive = fed_spec.key_directive_definition(fed_schema)?; + let shareable_directive = fed_spec.shareable_directive_definition(fed_schema)?; + for implementation_type_pos in + fed_schema.possible_runtime_types(interface_field_pos.parent().into())? + { + let implementing_type = implementation_type_pos.get(schema)?; + if !implementing_type.directives.has(&key_directive.name) { + continue; + } + let implementing_field = implementation_type_pos + .field(interface_field_pos.field_name.clone()) + .get(schema)?; + if !implementing_field.directives.has(&shareable_directive.name) { + continue; + } + + // Returning `true` for this method has a cost: it will make us consider type-explosion for `itf`, and this can + // sometime lead to a large number of additional paths to explore, which can have a substantial cost. So we want + // to limit it if we can avoid it. As it happens, we should return `true` if it is possible that "something" + // (some field) in the type of `field` is reachable in _another_ subgraph but no in the one of the current path. + // And while it's not trivial to check this in general, there are some easy cases we can eliminate. For instance, + // if the type in the current subgraph has only leaf fields, we can check that all other subgraphs reachable + // from the implementation have the same set of leaf fields. + let implementing_field_base_type_name = implementing_field.ty.inner_named_type(); + if is_leaf_type(schema, implementing_field_base_type_name) { + continue; + } + let Some(implementing_field_base_type) = + schema.get_object(implementing_field_base_type_name) + else { + // We officially "don't know", so we return "true" so type-explosion is tested. + return Ok(true); + }; + if implementing_field_base_type + .fields + .values() + .any(|f| !is_leaf_type(schema, f.ty.inner_named_type())) + { + // Similar to above, we declare we "don't know" and test type-explosion. + return Ok(true); + } + for node in self.graph.nodes_for_type(&implementing_type.name)? { + let node = self.graph.node_weight(*node)?; + let tail = self.graph.node_weight(self.tail)?; + if node.source == tail.source { + continue; + } + let node_fed_schema = self.graph.schema_by_source(&node.source)?; + let node_schema = node_fed_schema.schema(); + let node_fed_spec = get_federation_spec_definition_from_subgraph(node_fed_schema)?; + let node_shareable_directive = + node_fed_spec.shareable_directive_definition(node_fed_schema)?; + let build_err = || { + Err(FederationError::internal(format!( + "{implementation_type_pos} is an object in {} but a {} in {}", + tail.source, node.type_, node.source + ))) + }; + let QueryGraphNodeType::SchemaType(node_type_pos) = &node.type_ else { + return build_err(); + }; + let node_type_pos: ObjectOrInterfaceTypeDefinitionPosition = + node_type_pos.clone().try_into()?; + let node_field_pos = node_type_pos.field(interface_field_pos.field_name.clone()); + let Some(node_field) = node_field_pos.try_get(node_schema) else { + continue; + }; + if !node_field.directives.has(&node_shareable_directive.name) { + continue; + } + let node_field_base_type_name = node_field.ty.inner_named_type(); + if implementing_field_base_type_name != node_field_base_type_name { + // We have a genuine difference here, so we should explore type explosion. + return Ok(true); + } + let node_field_base_type_pos = + node_fed_schema.get_type(node_field_base_type_name.clone())?; + let Some(node_field_base_type_pos): Option< + ObjectOrInterfaceTypeDefinitionPosition, + > = node_field_base_type_pos.try_into().ok() else { + // Similar to above, we have a genuine difference. + return Ok(true); + }; + + if !node_field_base_type_pos.fields(node_schema)?.all(|f| { + implementing_field_base_type + .fields + .contains_key(f.field_name()) + }) { + // Similar to above, we have a genuine difference. + return Ok(true); + } + // Note that if the type is the same and the fields are a subset too, then we know + // the return types of those fields must be leaf types, or merging would have + // complained. + } + return Ok(false); + } + Ok(false) + } + + /// For the first element of the pair, the data has the same meaning as in + /// `SimultaneousPathsWithLazyIndirectPaths.advance_with_operation_element()`. We also actually + /// need to return a `Vec` of options of simultaneous paths (because when we type explode, we + /// create simultaneous paths, but as a field might be resolved by multiple subgraphs, we may + /// have also created multiple options). + /// + /// For the second element, it is true if the result only has type-exploded results. + #[cfg_attr(feature = "snapshot_tracing", tracing::instrument( + skip_all, + level = "trace", + name = "GraphPath::advance_with_operation_element" + fields(label = operation_element.to_string()) + ))] + #[allow(clippy::too_many_arguments)] + fn advance_with_operation_element( + &self, + supergraph_schema: ValidFederationSchema, + operation_element: &OpPathElement, + context: &OpGraphPathContext, + condition_resolver: &mut impl ConditionResolver, + override_conditions: &OverrideConditions, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, + disabled_subgraphs: &IndexSet>, + ) -> Result<(Option>, Option), FederationError> { + let span = debug_span!( + "Trying to advance directly", + from = %self, + operation_element = %operation_element, + ); + let _guard = span.enter(); + let tail_weight = self.graph.node_weight(self.tail)?; + let QueryGraphNodeType::SchemaType(tail_type_pos) = &tail_weight.type_ else { + // We cannot advance any operation from here. We need to take the initial non-collecting + // edges first. + debug!("Cannot advance federated graph root with direct operations"); + return Ok((None, None)); + }; + match operation_element { + OpPathElement::Field(operation_field) => { + match tail_type_pos { + OutputTypeDefinitionPosition::Object(tail_type_pos) => { + // Just take the edge corresponding to the field, if it exists and can be + // used. + let Some(edge) = + self.next_edge_for_field(operation_field, override_conditions) + else { + debug!( + "No edge for field {operation_field} on object type {tail_weight}" + ); + return Ok((None, None)); + }; + + // If the tail type is an `@interfaceObject`, it's possible that the + // requested field is a field of an implementation of the interface. Because + // we found an edge, we know that the interface object has the field and we + // can use the edge. However, we can't add the operation field as-is to this + // path, since it's referring to a parent type that is not in the current + // subgraph. We must instead use the tail's type, so we change the field + // accordingly. + // + // TODO: It would be good to understand what parts of query planning rely + // on triggers being valid within a subgraph. + let mut operation_field = operation_field.clone(); + if self.tail_is_interface_object()? + && *operation_field.field_position.type_name() + != tail_type_pos.type_name + { + let field_on_tail_type = tail_type_pos + .field(operation_field.field_position.field_name().clone()); + if field_on_tail_type + .try_get(self.graph.schema_by_source(&tail_weight.source)?.schema()) + .is_none() + { + let edge_weight = self.graph.edge_weight(edge)?; + return Err(FederationError::internal(format!( + "Unexpectedly missing {operation_field} for {edge_weight} from path {self}", + ))); + } + operation_field = Field { + schema: self.graph.schema_by_source(&tail_weight.source)?.clone(), + field_position: field_on_tail_type.into(), + alias: operation_field.alias.clone(), + arguments: operation_field.arguments.clone(), + directives: operation_field.directives.clone(), + sibling_typename: operation_field.sibling_typename.clone(), + } + } + + let field_path = self.add_field_edge( + operation_field, + edge, + condition_resolver, + context, + )?; + match &field_path { + Some(_) => debug!("Collected field on object type {tail_weight}"), + None => debug!( + "Cannot satisfy @requires on field for object type {tail_weight}" + ), + } + Ok((field_path.map(|p| vec![p.into()]), None)) + } + OutputTypeDefinitionPosition::Interface(tail_type_pos) => { + // Due to `@interfaceObject`, we could be in a case where the field asked is + // not on the interface but rather on one of it's implementations. This can + // happen if we just entered the subgraph on an interface `@key` and are + // and coming from an `@interfaceObject`. In that case, we'll skip checking + // for a direct interface edge and simply cast into that implementation + // below. + let field_is_of_an_implementation = + *operation_field.field_position.type_name() != tail_type_pos.type_name; + + // First, we check if there is a direct edge from the interface (which only + // happens if we're in a subgraph that knows all of the implementations of + // that interface globally and all of them resolve the field). If there is + // one, then we have 2 options: + // - We take that edge. + // - We type-explode (like when we don't have a direct interface edge). + // We want to avoid looking at both options if we can because it multiplies + // planning work quickly if we always check both options. And in general, + // taking the interface edge is better than type explosion "if it works", + // so we distinguish a number of cases where we know that either: + // - Type-exploding cannot work unless taking the interface edge also does + // (the `has_an_entity_implementation_with_shareable_field()` call). + // - Type-exploding cannot be more efficient than the direct path (when no + // `@provides` are involved; if a `@provides` is involved in one of the + // implementations, then type-exploding may lead to a shorter overall + // plan thanks to that `@provides`). + let interface_edge = if field_is_of_an_implementation { + None + } else { + self.next_edge_for_field(operation_field, override_conditions) + }; + let interface_path = if let Some(interface_edge) = &interface_edge { + let field_path = self.add_field_edge( + operation_field.clone(), + *interface_edge, + condition_resolver, + context, + )?; + if field_path.is_none() { + let interface_edge_weight = + self.graph.edge_weight(*interface_edge)?; + return Err(FederationError::internal(format!( + "Interface edge {interface_edge_weight} unexpectedly had conditions" + ))); + } + field_path + } else { + None + }; + let direct_path_overrides_type_explosion = + if let Some(interface_edge) = &interface_edge { + // There are 2 separate cases where we going to do both "direct" and + // "type-exploding" options: + // 1. There is an `@provides`: in that case the "type-exploding + // case can legitimately be more efficient and we want to = + // consider it "all the way" + // 2. In the sub-case of + // `!has_an_entity_implementation_with_shareable_field(...)`, + // where we want to have the type-exploding option only for the + // case where the "direct" one fails later. But in that case, + // we'll remember that if the direct option pans out, then we can + // ignore the type-exploding one. + // `direct_path_overrides_type_explosion` indicates that we're in + // the 2nd case above, not the 1st one. + operation_field + .field_position + .is_introspection_typename_field() + || (!self.graph.is_provides_edge(*interface_edge)? + && !self.graph.has_an_implementation_with_provides( + &tail_weight.source, + tail_type_pos.field( + operation_field.field_position.field_name().clone(), + ), + )?) + } else { + false + }; + if direct_path_overrides_type_explosion { + // We can special-case terminal (leaf) fields: as long they have no + // `@provides`, then the path ends there and there is no need to check + // type explosion "in case the direct path doesn't pan out". + // Additionally, if we're not in the case where an implementation + // is an entity with a shareable field, then there is no case where the + // direct case wouldn't "pan out" but the type explosion would, so we + // can ignore type-exploding there too. + // + // TODO: We should re-assess this when we support `@requires` on + // interface fields (typically, should we even try to type-explode + // if the direct edge cannot be satisfied? Probably depends on the exact + // semantics of `@requires` on interface fields). + let operation_field_type_name = operation_field + .field_position + .get(operation_field.schema.schema())? + .ty + .inner_named_type(); + let is_operation_field_type_leaf = matches!( + operation_field + .schema + .get_type(operation_field_type_name.clone())?, + TypeDefinitionPosition::Scalar(_) | TypeDefinitionPosition::Enum(_) + ); + if is_operation_field_type_leaf + || !self.has_an_entity_implementation_with_shareable_field( + &tail_weight.source, + tail_type_pos + .field(operation_field.field_position.field_name().clone()), + )? + { + let Some(interface_path) = interface_path else { + return Err(FederationError::internal( + "Unexpectedly missing interface path", + )); + }; + debug!( + "Collecting (leaf) field on interface {tail_weight} without type-exploding" + ); + return Ok((Some(vec![interface_path.into()]), None)); + } + debug!("Collecting field on interface {tail_weight} as 1st option"); + } + + // There are 2 main cases to handle here: + // - The most common is that it's a field of the interface that is queried, + // and so we should type-explode because either we didn't had a direct + // edge, or `@provides` makes it potentially worthwhile to check with type + // explosion. + // - But, as mentioned earlier, we could be in the case where the field + // queried is actually of one of the implementation of the interface. In + // that case, we only want to consider that one implementation. + let implementations = if field_is_of_an_implementation { + let CompositeTypeDefinitionPosition::Object(field_parent_pos) = + &operation_field.field_position.parent() + else { + return Err(FederationError::internal(format!( + "{} requested on {}, but field's parent {} is not an object type", + operation_field.field_position, + tail_type_pos, + operation_field.field_position.type_name() + ))); + }; + if !self.runtime_types_of_tail.contains(field_parent_pos) { + return Err(FederationError::internal(format!( + "{} requested on {}, but field's parent {} is not an implementation type", + operation_field.field_position, + tail_type_pos, + operation_field.field_position.type_name() + ))); + } + debug!("Casting into requested type {field_parent_pos}"); + Arc::new(IndexSet::from_iter([field_parent_pos.clone()])) + } else { + match &interface_path { + Some(_) => debug!( + "No direct edge: type exploding interface {tail_weight} into possible runtime types {:?}", + self.runtime_types_of_tail + ), + None => debug!( + "Type exploding interface {tail_weight} into possible runtime types {:?} as 2nd option", + self.runtime_types_of_tail + ), + } + self.runtime_types_of_tail.clone() + }; + + // We type-explode. For all implementations, we need to call + // `advance_with_operation_element()` on a made-up inline fragment. If + // any gives us empty options, we bail. + let mut options_for_each_implementation = vec![]; + for implementation_type_pos in implementations.as_ref() { + debug!("Handling implementation {implementation_type_pos}"); + let span = debug_span!(" |"); + let guard = span.enter(); + let implementation_inline_fragment = InlineFragment { + schema: self.graph.schema_by_source(&tail_weight.source)?.clone(), + parent_type_position: tail_type_pos.clone().into(), + type_condition_position: Some( + implementation_type_pos.clone().into(), + ), + directives: Default::default(), + selection_id: SelectionId::new(), + }; + let implementation_options = + SimultaneousPathsWithLazyIndirectPaths::new( + self.clone().into(), + context.clone(), + Default::default(), + Default::default(), + ) + .advance_with_operation_element( + supergraph_schema.clone(), + &implementation_inline_fragment.into(), + condition_resolver, + override_conditions, + check_cancellation, + disabled_subgraphs, + )?; + // If we find no options for that implementation, we bail (as we need to + // simultaneously advance all implementations). + let Some(mut implementation_options) = implementation_options else { + drop(guard); + debug!( + "Cannot collect field from {implementation_type_pos}: stopping with options [{interface_path:?}]" + ); + return Ok((interface_path.map(|p| vec![p.into()]), None)); + }; + // If the new inline fragment makes it so that we're on an unsatisfiable + // branch, we just ignore that implementation. + if implementation_options.is_empty() { + debug!( + "Cannot ever get {implementation_type_pos} from this branch, ignoring it" + ); + continue; + } + // For each option, we call `advance_with_operation_element()` again on + // our own operation element (the field), which gives us some options + // (or not and we bail). + let mut field_options = vec![]; + debug!( + "Trying to collect field from options {implementation_options:?}" + ); + for implementation_option in &mut implementation_options { + let span = debug_span!( + "implementation option", + implementation_option = %implementation_option + ); + let _guard = span.enter(); + let field_options_for_implementation = implementation_option + .advance_with_operation_element( + supergraph_schema.clone(), + operation_element, + condition_resolver, + override_conditions, + check_cancellation, + disabled_subgraphs, + )?; + let Some(field_options_for_implementation) = + field_options_for_implementation + else { + debug!("Cannot collect field"); + continue; + }; + // Advancing a field should never get us into an unsatisfiable + // condition (only fragments can). + if field_options_for_implementation.is_empty() { + return Err(FederationError::internal(format!( + "Unexpected unsatisfiable path after {operation_field}" + ))); + } + debug!( + "Collected field: adding {field_options_for_implementation:?}" + ); + field_options.extend( + field_options_for_implementation + .into_iter() + .map(|s| s.paths), + ); + } + // If we find no options to advance that implementation, we bail (as we + // need to simultaneously advance all implementations). + if field_options.is_empty() { + drop(guard); + debug!( + "Cannot collect field from {implementation_type_pos}: stopping with options [{}]", + DisplayOption::new(&interface_path) + ); + return Ok((interface_path.map(|p| vec![p.into()]), None)); + }; + debug!("Collected field from {implementation_type_pos}"); + options_for_each_implementation.push(field_options); + } + let all_options = SimultaneousPaths::flat_cartesian_product( + options_for_each_implementation, + check_cancellation, + )?; + if let Some(interface_path) = interface_path { + let (interface_path, all_options) = + if direct_path_overrides_type_explosion { + interface_path.mark_overriding(&all_options) + } else { + (interface_path, all_options) + }; + let options = vec![interface_path.into()] + .into_iter() + .chain(all_options) + .collect::>(); + debug!("With type-exploded options: {}", DisplaySlice(&options)); + Ok((Some(options), None)) + } else { + debug!("With type-exploded options: {}", DisplaySlice(&all_options)); + // TODO: This appears to be the only place returning non-None for the + // 2nd argument, so this could be Option<(Vec, bool)> + // instead. + Ok((Some(all_options), Some(true))) + } + } + OutputTypeDefinitionPosition::Union(_) => { + let Some(typename_edge) = + self.next_edge_for_field(operation_field, override_conditions) + else { + return Err(FederationError::internal( + "Should always have an edge for __typename edge on an union", + )); + }; + let field_path = self.add_field_edge( + operation_field.clone(), + typename_edge, + condition_resolver, + context, + )?; + debug!("Trivial collection of __typename for union"); + Ok((field_path.map(|p| vec![p.into()]), None)) + } + _ => { + // Only object, interfaces, and unions (only for __typename) have fields, so + // the query should have been flagged invalid if a field was selected on + // something else. + Err(FederationError::internal(format!( + "Unexpectedly found field {operation_field} on non-composite type {tail_type_pos}", + ))) + } + } + } + OpPathElement::InlineFragment(operation_inline_fragment) => { + let type_condition_name = operation_inline_fragment + .type_condition_position + .as_ref() + .map(|pos| pos.type_name()) + .unwrap_or_else(|| tail_type_pos.type_name()) + .clone(); + if type_condition_name == *tail_type_pos.type_name() { + // If there is no type condition (or the condition is the type we're already + // on), it means we're essentially just applying some directives (could be a + // `@skip`/`@include` for instance). This doesn't make us take any edge, but if + // the operation element does has directives, we record it. + debug!( + "No edge to take for condition {operation_inline_fragment} from current type" + ); + let fragment_path = if operation_inline_fragment.directives.is_empty() { + self.clone() + } else { + self.add( + operation_inline_fragment.clone().into(), + None, + ConditionResolution::no_conditions(), + operation_inline_fragment.defer_directive_arguments()?, + )? + }; + return Ok((Some(vec![fragment_path.into()]), None)); + } + match tail_type_pos { + OutputTypeDefinitionPosition::Interface(_) + | OutputTypeDefinitionPosition::Union(_) => { + let tail_type_pos: AbstractTypeDefinitionPosition = + tail_type_pos.clone().try_into()?; + + // If we have an edge for the typecast, take that. + if let Some(edge) = + self.next_edge_for_inline_fragment(operation_inline_fragment) + { + let edge_weight = self.graph.edge_weight(edge)?; + if edge_weight.conditions.is_some() { + return Err(FederationError::internal( + "Unexpectedly found condition on inline fragment collecting edge", + )); + } + let fragment_path = self.add( + operation_inline_fragment.clone().into(), + Some(edge), + ConditionResolution::no_conditions(), + operation_inline_fragment.defer_directive_arguments()?, + )?; + debug!( + "Using type-casting edge for {type_condition_name} from current type" + ); + return Ok((Some(vec![fragment_path.into()]), None)); + } + + // Otherwise, check what the intersection is between the possible runtime + // types of the tail type and the ones of the typecast. We need to be able + // to go into all those types simultaneously (a.k.a. type explosion). + let from_types = self.runtime_types_of_tail.clone(); + let to_types = supergraph_schema.possible_runtime_types( + supergraph_schema + .get_type(type_condition_name.clone())? + .try_into()?, + )?; + let intersection = from_types.intersection(&to_types); + debug!( + "Trying to type-explode into intersection between current type and {type_condition_name} = [{}]", + intersection.clone().format(",") + ); + let mut options_for_each_implementation = vec![]; + for implementation_type_pos in intersection { + let span = debug_span!( + "attempt type explosion", + implementation_type = %implementation_type_pos + ); + let guard = span.enter(); + let implementation_inline_fragment = InlineFragment { + schema: self.graph.schema_by_source(&tail_weight.source)?.clone(), + parent_type_position: tail_type_pos.clone().into(), + type_condition_position: Some( + implementation_type_pos.clone().into(), + ), + directives: operation_inline_fragment.directives.clone(), + selection_id: SelectionId::new(), + }; + let implementation_options = + SimultaneousPathsWithLazyIndirectPaths::new( + self.clone().into(), + context.clone(), + Default::default(), + Default::default(), + ) + .advance_with_operation_element( + supergraph_schema.clone(), + &implementation_inline_fragment.into(), + condition_resolver, + override_conditions, + check_cancellation, + disabled_subgraphs, + )?; + let Some(implementation_options) = implementation_options else { + drop(guard); + debug!( + "Cannot advance into {implementation_type_pos} from current type: no options for operation." + ); + return Ok((None, None)); + }; + // If the new inline fragment makes it so that we're on an unsatisfiable + // branch, we just ignore that implementation. + if implementation_options.is_empty() { + debug!("Cannot ever get type name from this branch, ignoring it"); + continue; + } + options_for_each_implementation.push( + implementation_options + .into_iter() + .map(|s| s.paths) + .collect(), + ); + debug!( + "Advanced into type from current type: {options_for_each_implementation:?}" + ); + } + let all_options = SimultaneousPaths::flat_cartesian_product( + options_for_each_implementation, + check_cancellation, + )?; + debug!("Type-exploded options: {}", DisplaySlice(&all_options)); + Ok((Some(all_options), None)) + } + OutputTypeDefinitionPosition::Object(tail_type_pos) => { + // We've already handled the case of a fragment whose type condition is the + // same as the tail type. But the fragment might be for either: + // - A super-type of the tail type. In which case, we're pretty much in the + // same case than if there were no particular type condition. + // - If the tail type is an `@interfaceObject`, then this can be an + // implementation type of the interface in the supergraph. In that case, + // the type condition is not a known type of the subgraph, but the + // subgraph might still be able to handle some of fields, so in that case, + // we essentially "ignore" the fragment for now. We will re-add it back + // later for fields that are not in the current subgraph after we've taken + // an `@key` for the interface. + // - An incompatible type. This can happen for a type that intersects a + // super-type of the tail type (since GraphQL allows a fragment as long as + // there is an intersection). In that case, the whole operation element + // simply cannot ever return anything. + let type_condition_pos = supergraph_schema.get_type(type_condition_name)?; + let abstract_type_condition_pos: Option = + type_condition_pos.clone().try_into().ok(); + if let Some(type_condition_pos) = abstract_type_condition_pos + && supergraph_schema + .possible_runtime_types(type_condition_pos.into())? + .contains(tail_type_pos) + { + debug!("Type is a super-type of the current type. No edge to take"); + // Type condition is applicable on the tail type, so the types are + // already exploded but the condition can reference types from the + // supergraph that are not present in the local subgraph. + // + // If the operation element has applied directives we need to + // convert it to an inline fragment without type condition, + // otherwise we ignore the fragment altogether. + if operation_inline_fragment.directives.is_empty() { + return Ok((Some(vec![self.clone().into()]), None)); + } + let operation_inline_fragment = InlineFragment { + schema: self.graph.schema_by_source(&tail_weight.source)?.clone(), + parent_type_position: tail_type_pos.clone().into(), + type_condition_position: None, + directives: operation_inline_fragment.directives.clone(), + selection_id: SelectionId::new(), + }; + let defer_directive_arguments = + operation_inline_fragment.defer_directive_arguments()?; + let fragment_path = self.add( + operation_inline_fragment.into(), + None, + ConditionResolution::no_conditions(), + defer_directive_arguments, + )?; + return Ok((Some(vec![fragment_path.into()]), None)); + } + + if self.tail_is_interface_object()? { + let mut fake_downcast_edge = None; + for edge in self.next_edges()? { + let edge_weight = self.graph.edge_weight(edge)?; + let QueryGraphEdgeTransition::InterfaceObjectFakeDownCast { + to_type_name, + .. + } = &edge_weight.transition + else { + continue; + }; + if type_condition_pos.type_name() == to_type_name { + fake_downcast_edge = Some(edge); + break; + }; + } + if let Some(fake_downcast_edge) = fake_downcast_edge { + let condition_resolution = self.can_satisfy_conditions( + fake_downcast_edge, + condition_resolver, + context, + &Default::default(), + &Default::default(), + )?; + if matches!( + condition_resolution, + ConditionResolution::Unsatisfied { .. } + ) { + return Ok((None, None)); + } + let fragment_path = self.add( + operation_inline_fragment.clone().into(), + Some(fake_downcast_edge), + condition_resolution, + operation_inline_fragment.defer_directive_arguments()?, + )?; + return Ok((Some(vec![fragment_path.into()]), None)); + } + } + + debug!("Cannot ever get type from current type: returning empty branch"); + // The operation element we're dealing with can never return results (the + // type conditions applied have no intersection). This means we can fulfill + // this operation element (by doing nothing and returning an empty result), + // which we indicate by return ingan empty list of options. + Ok((Some(vec![]), None)) + } + _ => { + // We shouldn't have a fragment on a non-composite type. + Err(FederationError::internal(format!( + "Unexpectedly found inline fragment {operation_inline_fragment} on non-composite type {tail_type_pos}", + ))) + } + } + } + } + } + + /// Given an `OpGraphPath` and a `SimultaneousPaths` that represent 2 different options to reach + /// the same query leaf field, checks if one can be shown to be always "better" (more + /// efficient/optimal) than the other one, regardless of any surrounding context (i.e. + /// regardless of what the rest of the query plan would be for any other query leaf field). + /// + /// Returns the comparison of the complexity of `self` and `other` (e.g. `Ordering::Less` means + /// `self` is better/has less complexity than `other`). If we can't guarantee anything (at least + /// "out of context"), then we return `Ordering::Equal`. + fn compare_single_vs_multi_path_options_complexity_out_of_context( + &self, + other: &SimultaneousPaths, + ) -> Result { + // This handles the same case as the single-path-only case, but compares the single path + // against each path of the `SimultaneousPaths`, and only "ignores" the `SimultaneousPaths` + // if all its paths can be ignored. + // + // Note that this happens less often than the single-path-only case, but with `@provides` on + // an interface, you can have cases where on one hand you can get something completely on + // the current subgraph, but the type-exploded case has to still be generated due to the + // leaf field not being the one just after the "provided" interface. + for other_path in other.0.iter() { + // Note: Not sure if it is possible for a path of the `SimultaneousPaths` option to + // subsume the single-path one in practice, but if it does, we ignore it because it's + // not obvious that this is enough to get rid of `self` (maybe if `self` is provably a + // bit costlier than one of the paths of `other`, but `other` may have many paths and + // could still be collectively worst than `self`). + if self.compare_single_path_options_complexity_out_of_context(other_path)? + != Ordering::Less + { + return Ok(Ordering::Equal); + } + } + Ok(Ordering::Less) + } + + /// Given 2 `OpGraphPath`s that represent 2 different paths to reach the same query leaf field, + /// checks if one can be shown to be always "better" (more efficient/optimal) than the other + /// one, regardless of any surrounding context (i.e. regardless of what the rest of the query + /// plan would be for any other query leaf field). + /// + /// Returns the comparison of the complexity of `self` and `other` (e.g. `Ordering::Less` means + /// `self` is better/has less complexity than `other`). If we can't guarantee anything (at least + /// "out of context"), then we return `Ordering::Equal`. + fn compare_single_path_options_complexity_out_of_context( + &self, + other: &OpGraphPath, + ) -> Result { + // Currently, this method only handles the case where we have something like: + // - `self`: -[t]-> T(A) -[u]-> U(A) -[x] -> Int(A) + // - `other`: -[t]-> T(A) -[key]-> T(B) -[u]-> U(B) -[x] -> Int(B) + // That is, where we have 2 choices that are identical up to the "end", when one stays in + // the subgraph (`self`, which stays in A) while the other uses a key to get to another + // subgraph (`other`, going to B). + // + // In such a case, whatever else the query plan might be doing, it can never be "worse" + // to use `self` than to use `other` because both will force the same "fetch dependency + // graph node" up to the end, but `other` may force one more fetch that `self` does not. + // Do note that we say "may" above, because the rest of the query plan may very well have a + // forced choice like: + // - `option`: -[t]-> T(A) -[key]-> T(B) -[u]-> U(B) -[y] -> Int(B) + // in which case the query plan will have the jump from A to B after `t` regardless of + // whether we use `self` or `other`, but while in that particular case `self` and `other` + // are about comparable in terms of performance, `self` is still not worse than `other` (and + // in other situations, `self` may be genuinely be better). + // + // Note that this is in many ways just a generalization of a heuristic we use earlier for + // leaf fields. That is, we will never get as input to this method something like: + // - `self`: -[t]-> T(A) -[x] -> Int(A) + // - `other`: -[t]-> T(A) -[key]-> T(B) -[x] -> Int(B) + // because when the code is asked for the options for `x` after ` -[t]-> T(A)`, + // it notices that `x` is a leaf and is in `A`, so it doesn't ever look for alternative + // paths. But this only works for direct leaves of an entity. In the example at the start, + // field `u` makes this not work, because when we compute choices for `u`, we don't yet know + // what comes after that, and so we have to take the option of going to subgraph `B` into + // account (it may very well be that whatever comes after `u` is not in `A`, for instance). + let self_tail_weight = self.graph.node_weight(self.tail)?; + let other_tail_weight = self.graph.node_weight(other.tail)?; + if self_tail_weight.source != other_tail_weight.source { + // As described above, we want to know if one of the paths has no jumps at all (after + // the common prefix) while the other has some. + self.compare_subgraph_jumps_after_last_common_node(other) + } else { + Ok(Ordering::Equal) + } + } +} + +impl SimultaneousPaths { + /// Given options generated for the advancement of each path of a `SimultaneousPaths`, generate + /// the options for the `SimultaneousPaths` as a whole. + fn flat_cartesian_product( + options_for_each_path: Vec>, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, + ) -> Result, FederationError> { + // This can be written more tersely with a bunch of `reduce()`/`flat_map()`s and friends, + // but when interfaces type-explode into many implementations, this can end up with fairly + // large `Vec`s and be a bottleneck, and a more iterative version that pre-allocates `Vec`s + // is quite a bit faster. + if options_for_each_path.is_empty() { + return Ok(vec![]); + } + + // Track, for each path, which option index we're at. + let mut option_indexes = vec![0; options_for_each_path.len()]; + + // Pre-allocate `Vec` for the result. + let num_options = options_for_each_path + .iter() + .fold(1_usize, |product, options| { + product.saturating_mul(options.len()) + }); + if num_options > 1_000_000 { + return Err(SingleFederationError::QueryPlanComplexityExceeded { + message: format!( + "Excessive number of combinations for a given path: {num_options}" + ), + } + .into()); + } + let mut product = Vec::with_capacity(num_options); + + // Compute the cartesian product. + for _ in 0..num_options { + check_cancellation()?; + let num_simultaneous_paths = options_for_each_path + .iter() + .zip(&option_indexes) + .map(|(options, option_index)| options[*option_index].0.len()) + .sum(); + let mut simultaneous_paths = Vec::with_capacity(num_simultaneous_paths); + + for (options, option_index) in options_for_each_path.iter().zip(&option_indexes) { + simultaneous_paths.extend(options[*option_index].0.iter().cloned()); + } + product.push(SimultaneousPaths(simultaneous_paths)); + + for (options, option_index) in options_for_each_path.iter().zip(&mut option_indexes) { + if *option_index == options.len() - 1 { + *option_index = 0 + } else { + *option_index += 1; + break; + } + } + } + + Ok(product) + } + + /// Given 2 `SimultaneousPaths` that represent 2 different options to reach the same query leaf + /// field, checks if one can be shown to be always "better" (more efficient/optimal) than the + /// other one, regardless of any surrounding context (i.e. regardless of what the rest of the + /// query plan would be for any other query leaf field). + /// + /// Note that this method is used on the final options of a given "query path", so all the + /// heuristics done within `GraphPath` to avoid unnecessary options have already been applied + /// (e.g. avoiding the consideration of paths that do 2 successive key jumps when there is a + /// 1-jump equivalent), so this focus on what can be done is based on the fact that the path + /// considered is "finished". + /// + /// Returns the comparison of the complexity of `self` and `other` (e.g. `Ordering::Less` means + /// `self` is better/has less complexity than `other`). If we can't guarantee anything (at least + /// "out of context"), then we return `Ordering::Equal`. + fn compare_options_complexity_out_of_context( + &self, + other: &SimultaneousPaths, + ) -> Result { + match (self.0.as_slice(), other.0.as_slice()) { + ([a], [b]) => a.compare_single_path_options_complexity_out_of_context(b), + ([a], _) => a.compare_single_vs_multi_path_options_complexity_out_of_context(other), + (_, [b]) => b + .compare_single_vs_multi_path_options_complexity_out_of_context(self) + .map(Ordering::reverse), + _ => Ok(Ordering::Equal), + } + } +} + +impl From> for SimultaneousPaths { + fn from(value: Arc) -> Self { + Self(vec![value]) + } +} + +impl From for SimultaneousPaths { + fn from(value: OpGraphPath) -> Self { + Self::from(Arc::new(value)) + } +} + +impl SimultaneousPathsWithLazyIndirectPaths { + pub(crate) fn new( + paths: SimultaneousPaths, + context: OpGraphPathContext, + excluded_destinations: ExcludedDestinations, + excluded_conditions: ExcludedConditions, + ) -> SimultaneousPathsWithLazyIndirectPaths { + SimultaneousPathsWithLazyIndirectPaths { + lazily_computed_indirect_paths: std::iter::repeat_with(|| None) + .take(paths.0.len()) + .collect(), + paths, + context, + excluded_destinations, + excluded_conditions, + } + } + + /// For a given "input" path (identified by an idx in `paths`), each of its indirect options. + fn indirect_options( + &mut self, + path_index: usize, + condition_resolver: &mut impl ConditionResolver, + override_conditions: &OverrideConditions, + disabled_subgraphs: &IndexSet>, + ) -> Result { + if let Some(indirect_paths) = &self.lazily_computed_indirect_paths[path_index] { + Ok(indirect_paths.clone()) + } else { + let new_indirect_paths = self.compute_indirect_paths( + path_index, + condition_resolver, + override_conditions, + disabled_subgraphs, + )?; + self.lazily_computed_indirect_paths[path_index] = Some(new_indirect_paths.clone()); + Ok(new_indirect_paths) + } + } + + fn compute_indirect_paths( + &self, + path_index: usize, + condition_resolver: &mut impl ConditionResolver, + override_conditions: &OverrideConditions, + disabled_subgraphs: &IndexSet>, + ) -> Result { + self.paths.0[path_index].advance_with_non_collecting_and_type_preserving_transitions( + &self.context, + condition_resolver, + &self.excluded_destinations, + &self.excluded_conditions, + override_conditions, + // The transitions taken by this method are non-collecting transitions, in which case + // the trigger is the context (which is really a hack to provide context information for + // keys during fetch dependency graph updating). + |_, context| OpGraphPathTrigger::Context(context.clone()), + |graph, node, trigger, override_conditions| { + Ok(graph.edge_for_op_graph_path_trigger(node, trigger, override_conditions)) + }, + disabled_subgraphs, + ) + } + + fn create_lazy_options( + &self, + options: Vec, + context: OpGraphPathContext, + ) -> Vec { + options + .into_iter() + .map(|paths| { + SimultaneousPathsWithLazyIndirectPaths::new( + paths, + context.clone(), + self.excluded_destinations.clone(), + self.excluded_conditions.clone(), + ) + }) + .collect() + } + + /// Returns `None` if the operation cannot be dealt with/advanced. Otherwise, it returns a `Vec` + /// of options we can be in after advancing the operation, each option being a set of + /// simultaneous paths in the subgraphs (a single path in the simple case, but type exploding + /// may make us explore multiple paths simultaneously). + /// + /// The lists of options can be empty, which has the special meaning that the operation is + /// guaranteed to have no results (it corresponds to unsatisfiable conditions), meaning that as + /// far as query planning goes, we can just ignore the operation but otherwise continue. + // PORT_NOTE: In the JS codebase, this was named `advanceSimultaneousPathsWithOperation`. + pub(crate) fn advance_with_operation_element( + &mut self, + supergraph_schema: ValidFederationSchema, + operation_element: &OpPathElement, + condition_resolver: &mut impl ConditionResolver, + override_conditions: &OverrideConditions, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, + disabled_subgraphs: &IndexSet>, + ) -> Result>, FederationError> { + debug!( + "Trying to advance paths for operation: path = {}, operation = {operation_element}", + self.paths + ); + let span = debug_span!(" |"); + let _guard = span.enter(); + let updated_context = self.context.with_context_of(operation_element)?; + let mut options_for_each_path = vec![]; + + // To call the mutating method `indirect_options()`, we need to not hold any immutable + // references to `self`, which means cloning these paths when iterating. + let paths = self.paths.0.clone(); + for (path_index, path) in paths.iter().enumerate() { + check_cancellation()?; + debug!("Computing options for {path}"); + let span = debug_span!(" |"); + let gaurd = span.enter(); + let mut options = None; + let should_reenter_subgraph = path.defer_on_tail.is_some() + && matches!(operation_element, OpPathElement::Field(_)); + if !should_reenter_subgraph { + debug!("Direct options"); + let span = debug_span!(" |"); + let gaurd = span.enter(); + let (advance_options, has_only_type_exploded_results) = path + .advance_with_operation_element( + supergraph_schema.clone(), + operation_element, + &updated_context, + condition_resolver, + override_conditions, + check_cancellation, + disabled_subgraphs, + )?; + debug!("{advance_options:?}"); + drop(gaurd); + // If we've got some options, there are a number of cases where there is no point + // looking for indirect paths: + // - If the operation element is terminal: this means we just found a direct edge + // that is terminal, so no indirect options could be better (this is not true for + // non-terminal operation element, where the direct route may end up being a dead + // end later). One exception however is when `advanceWithOperationElement()` + // type-exploded (meaning that we're on an interface), because in that case, the + // type-exploded options have already taken indirect edges into account, so it's + // possible that an indirect edge _from the interface_ could be better, but only + // if there wasn't a "true" direct edge on the interface, which is what + // `has_only_type_exploded_results` tells us. + // - If we get options, but an empty set of them, which signifies the operation + // element corresponds to unsatisfiable conditions and we can essentially ignore + // it. + // - If the operation element is a fragment in general: if we were able to find a + // direct option, that means the type is known in the "current" subgraph, and so + // we'll still be able to take any indirect edges that we could take now later, + // for the follow-up operation element. And pushing the decision will give us more + // context and may avoid a bunch of state explosion in practice. + if let Some(advance_options) = advance_options { + if advance_options.is_empty() + || (operation_element.is_terminal()? + && !has_only_type_exploded_results.unwrap_or(false)) + || matches!(operation_element, OpPathElement::InlineFragment(_)) + { + debug!("Final options for {path}: {advance_options:?}"); + // Note that if options is empty, that means this particular "branch" is + // unsatisfiable, so we should just ignore it. + if !advance_options.is_empty() { + options_for_each_path.push(advance_options); + } + continue; + } else { + options = Some(advance_options); + } + } + } + + // If there was not a valid direct path (or we didn't check those because we entered a + // defer), that's ok, we'll just try with non-collecting edges. + let mut options = options.unwrap_or_else(Vec::new); + if let OpPathElement::Field(operation_field) = operation_element { + // Add whatever options can be obtained by taking some non-collecting edges first. + let paths_with_non_collecting_edges = self + .indirect_options( + path_index, + condition_resolver, + override_conditions, + disabled_subgraphs, + )? + .filter_non_collecting_paths_for_field(operation_field)?; + if !paths_with_non_collecting_edges.paths.is_empty() { + debug!( + "{} indirect paths", + paths_with_non_collecting_edges.paths.len() + ); + for path_with_non_collecting_edges in + paths_with_non_collecting_edges.paths.iter() + { + debug!("For indirect path {path_with_non_collecting_edges}:"); + let span = debug_span!(" |"); + let _gaurd = span.enter(); + let (advance_options, _) = path_with_non_collecting_edges + .advance_with_operation_element( + supergraph_schema.clone(), + operation_element, + &updated_context, + condition_resolver, + override_conditions, + check_cancellation, + disabled_subgraphs, + )?; + // If we can't advance the operation element after that path, ignore it, + // it's just not an option. + let Some(advance_options) = advance_options else { + debug!("Ignoring: cannot be advanced with {operation_element}"); + continue; + }; + debug!("Adding valid option: {advance_options:?}"); + // `advance_with_operation_element()` can return an empty `Vec` only if the + // operation element is a fragment with a type condition that, on top of the + // "current" type is unsatisfiable. But as we've only taken type-preserving + // transitions, we cannot get an empty result at this point if we didn't get + // one when testing direct transitions above (in which case we would have + // exited the method early). + if advance_options.is_empty() { + return Err(FederationError::internal(format!( + "Unexpected empty options after non-collecting path {path_with_non_collecting_edges} for {operation_element}", + ))); + } + // There is a special case we can deal with now. Namely, suppose we have a + // case where a query is reaching an interface I in a subgraph S1, we query + // some field of that interface x, and say that x is provided in subgraph S2 + // but by an @interfaceObject for I. + // + // As we look for direct options for I.x in S1 initially, we won't find `x`, + // so we will try to type-explode I (let's say into implementations A and + // B). And in some cases doing so is necessary, but it may also lead to the + // type-exploding option to look like: + // [ + // I(S1) -[... on A]-> A(S1) -[key]-> I(S2) -[x] -> Int(S2), + // I(S1) -[... on B]-> B(S1) -[key]-> I(S2) -[x] -> Int(S2), + // ] + // But as we look at indirect options now (still from I in S1), we will note + // that we can also do: + // I(S1) -[key]-> I(S2) -[x] -> Int(S2), + // And while both options are technically valid, the new one really subsumes + // the first one: there is no point in type-exploding to take a key to the + // same exact subgraph if using the key on the interface directly works. + // + // So here, we look for that case and remove any type-exploding option that + // the new path renders unnecessary. Do note that we only make that check + // when the new option is a single-path option, because this gets kind of + // complicated otherwise. + if path_with_non_collecting_edges.tail_is_interface_object()? { + for indirect_option in &advance_options { + if indirect_option.0.len() == 1 { + let mut new_options = vec![]; + for option in options { + let mut is_equivalent = true; + for path in &option.0 { + is_equivalent = is_equivalent + && indirect_option.0[0] + .is_equivalent_save_for_type_explosion_to( + path, + )?; + } + if !is_equivalent { + new_options.push(option) + } + } + options = new_options; + } + } + } + options.extend(advance_options); + } + } else { + debug!("no indirect paths"); + } + } + + // If we were entering a @defer, we've skipped the potential "direct" options because we + // need an "indirect" one (a key/root query) to be able to actually defer. But in rare + // cases, it's possible we actually couldn't resolve the key fields needed to take a key + // but could still find a direct path. If so, it means it's a corner case where we + // cannot do query-planner-based-@defer and have to fall back on not deferring. + if options.is_empty() && should_reenter_subgraph { + let span = debug_span!( + "Cannot defer (no indirect options); falling back to direct options" + ); + let _guard = span.enter(); + let (advance_options, _) = path.advance_with_operation_element( + supergraph_schema.clone(), + operation_element, + &updated_context, + condition_resolver, + override_conditions, + check_cancellation, + disabled_subgraphs, + )?; + options = advance_options.unwrap_or_else(Vec::new); + debug!("{options:?}"); + } + + // At this point, if options is empty, it means we found no ways to advance the + // operation element for this path, so we should return `None`. + if options.is_empty() { + drop(gaurd); + debug!("No valid options for {operation_element}, aborting."); + return Ok(None); + } else { + options_for_each_path.push(options); + } + } + + let all_options = + SimultaneousPaths::flat_cartesian_product(options_for_each_path, check_cancellation)?; + debug!("{all_options:?}"); + Ok(Some(self.create_lazy_options(all_options, updated_context))) + } +} + +// PORT_NOTE: JS passes a ConditionResolver here, we do not: see port note for +// `SimultaneousPathsWithLazyIndirectPaths` +#[allow(clippy::too_many_arguments)] +pub(crate) fn create_initial_options( + initial_path: GraphPath>, + initial_type: &QueryGraphNodeType, + initial_context: OpGraphPathContext, + condition_resolver: &mut impl ConditionResolver, + excluded_edges: ExcludedDestinations, + excluded_conditions: ExcludedConditions, + override_conditions: &OverrideConditions, + disabled_subgraphs: &IndexSet>, +) -> Result, FederationError> { + let initial_paths = SimultaneousPaths::from(initial_path); + let mut lazy_initial_path = SimultaneousPathsWithLazyIndirectPaths::new( + initial_paths, + initial_context.clone(), + excluded_edges, + excluded_conditions, + ); + + if initial_type.is_federated_root_type() { + let initial_options = lazy_initial_path.indirect_options( + 0, + condition_resolver, + override_conditions, + disabled_subgraphs, + )?; + let options = initial_options + .paths + .iter() + .cloned() + .map(SimultaneousPaths::from) + .collect(); + Ok(lazy_initial_path.create_lazy_options(options, initial_context)) + } else { + Ok(vec![lazy_initial_path]) + } +} + +impl ClosedBranch { + /// This method is called on a closed branch (i.e. on all the possible options found to get a + /// particular leaf of the query being planned), and when there is more than one option, it + /// tries a last effort at checking an option can be shown to be less efficient than another one + /// _whatever the rest of the query plan is_ (that is, whatever the options for any other leaf + /// of the query are). + /// + /// In practice, this compares all pairs of options and calls the heuristics of + /// `compare_options_complexity_out_of_context()` on them to see if one strictly subsumes the + /// other (and if that's the case, the subsumed one is ignored). + pub(crate) fn maybe_eliminate_strictly_more_costly_paths( + self, + ) -> Result { + if self.0.len() <= 1 { + return Ok(self); + } + + // Keep track of which options should be kept. + let mut keep_options = vec![true; self.0.len()]; + for option_index in 0..(self.0.len()) { + if !keep_options[option_index] { + continue; + } + // We compare the current option to every other remaining option. + // + // PORT_NOTE: We don't technically need to iterate in reverse order here, but the JS + // codebase does, and we do the same to ensure the result is the same. (The JS codebase + // requires this because it removes from the array it's iterating through.) + let option = &self.0[option_index]; + let mut keep_option = true; + for (other_option, keep_other_option) in self.0[(option_index + 1)..] + .iter() + .zip(&mut keep_options[(option_index + 1)..]) + .rev() + { + if !*keep_other_option { + continue; + } + match option + .paths + .compare_options_complexity_out_of_context(&other_option.paths)? + { + Ordering::Less => { + *keep_other_option = false; + } + Ordering::Equal => {} + Ordering::Greater => { + keep_option = false; + break; + } + } + } + if !keep_option { + keep_options[option_index] = false; + } + } + + Ok(ClosedBranch( + self.0 + .into_iter() + .zip(&keep_options) + .filter(|&(_, &keep_option)| keep_option) + .map(|(option, _)| option) + .collect(), + )) + } +} + +impl OpPath { + pub(crate) fn len(&self) -> usize { + self.0.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub(crate) fn strip_prefix(&self, maybe_prefix: &Self) -> Option { + self.0 + .strip_prefix(&*maybe_prefix.0) + .map(|slice| Self(slice.to_vec())) + } + + pub(crate) fn with_pushed(&self, element: Arc) -> Self { + let mut new = self.0.clone(); + new.push(element); + Self(new) + } + + pub(crate) fn conditional_directives(&self) -> DirectiveList { + self.0 + .iter() + .flat_map(|path_element| { + path_element + .directives() + .iter() + .filter(|d| d.name == "include" || d.name == "skip") + }) + .cloned() + .collect() + } + + /// Filter any fragment element in the provided path whose type condition does not exist in the provided schema. + /// Not that if the fragment element should be filtered but it has applied directives, then we preserve those applications by + /// replacing with a fragment with no condition (but if there are no directive, we simply remove the fragment from the path). + // JS PORT NOTE: this method was called filterOperationPath in JS codebase + pub(crate) fn filter_on_schema(&self, schema: &ValidFederationSchema) -> OpPath { + let mut filtered: Vec> = vec![]; + for element in &self.0 { + match element.as_ref() { + OpPathElement::InlineFragment(fragment) => { + if let Some(type_condition) = &fragment.type_condition_position { + if schema.get_type(type_condition.type_name().clone()).is_err() { + if element.directives().is_empty() { + continue; // skip this element + } else { + // Replace this element with an unconditioned inline fragment + let updated_fragment = fragment.with_updated_type_condition(None); + filtered.push(Arc::new(OpPathElement::InlineFragment( + updated_fragment, + ))); + } + } else { + filtered.push(element.clone()); + } + } else { + filtered.push(element.clone()); + } + } + _ => { + filtered.push(element.clone()); + } + } + } + OpPath(filtered) + } + + pub(crate) fn has_only_fragments(&self) -> bool { + // JS PORT NOTE: this was checking for FragmentElement which was used for both inline fragments and spreads + self.0 + .iter() + .all(|p| matches!(p.as_ref(), OpPathElement::InlineFragment(_))) + } +} + +pub(crate) fn concat_paths_in_parents( + first: &Option>, + second: &Option>, +) -> Option> { + if let (Some(first), Some(second)) = (first, second) { + Some(Arc::new(concat_op_paths(first.deref(), second.deref()))) + } else { + None + } +} + +pub(crate) fn concat_op_paths(head: &OpPath, tail: &OpPath) -> OpPath { + // While this is mainly a simple array concatenation, we optimize slightly by recognizing if the + // tail path starts by a fragment selection that is useless given the end of the head path + let Some(last_of_head) = head.last() else { + return tail.clone(); + }; + let mut result = head.clone(); + if tail.is_empty() { + return result; + } + let conditionals = head.conditional_directives(); + let tail_path = tail.0.clone(); + + // Note that in practice, we may be able to eliminate a few elements at the beginning of the path + // due do conditionals ('@skip' and '@include'). Indeed, a (tail) path crossing multiple conditions + // may start with: [ ... on X @include(if: $c1), ... on X @skip(if: $c2), (...)], but if `head` + // already ends on type `X` _and_ both the conditions on `$c1` and `$c2` are already found on `head`, + // then we can remove both fragments in `tail`. + let mut tail_iter = tail_path.iter(); + for tail_node in &mut tail_iter { + if !is_useless_followup_element(last_of_head, tail_node, &conditionals) + .is_ok_and(|is_useless| is_useless) + { + result.0.push(tail_node.clone()); + break; + } + } + result.0.extend(tail_iter.cloned()); + result +} + +fn is_useless_followup_element( + first: &OpPathElement, + followup: &OpPathElement, + conditionals: &DirectiveList, +) -> Result { + let type_of_first: Option = match first { + OpPathElement::Field(field) => Some(field.output_base_type()?.try_into()?), + OpPathElement::InlineFragment(fragment) => fragment.type_condition_position.clone(), + }; + + let Some(type_of_first) = type_of_first else { + return Ok(false); + }; + + // The followup is useless if it's a fragment (with no directives we would want to preserve) whose type + // is already that of the first element (or a supertype). + match followup { + OpPathElement::Field(_) => Ok(false), + OpPathElement::InlineFragment(fragment) => { + let Some(type_of_second) = fragment.type_condition_position.clone() else { + return Ok(false); + }; + + let are_useless_directives = fragment.directives.is_empty() + || fragment.directives.iter().all(|d| conditionals.contains(d)); + let is_same_type = type_of_first.type_name() == type_of_second.type_name(); + let is_subtype = first + .schema() + .schema() + .is_subtype(type_of_second.type_name(), type_of_first.type_name()); + Ok(are_useless_directives && (is_same_type || is_subtype)) + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use apollo_compiler::Name; + use apollo_compiler::Schema; + use petgraph::stable_graph::EdgeIndex; + use petgraph::stable_graph::NodeIndex; + + use crate::operation::Field; + use crate::query_graph::build_query_graph::build_query_graph; + use crate::query_graph::condition_resolver::ConditionResolution; + use crate::query_graph::graph_path::operation::OpGraphPath; + use crate::query_graph::graph_path::operation::OpGraphPathTrigger; + use crate::query_graph::graph_path::operation::OpPathElement; + use crate::schema::ValidFederationSchema; + use crate::schema::position::ObjectFieldDefinitionPosition; + + #[test] + fn path_display() { + let src = r#" + type Query + { + t: T + } + + type T + { + otherId: ID! + id: ID! + } + "#; + let schema = Schema::parse_and_validate(src, "./").unwrap(); + let schema = ValidFederationSchema::new(schema).unwrap(); + let name = "S1".into(); + let graph = build_query_graph(name, schema.clone(), Default::default()).unwrap(); + let path = OpGraphPath::new(Arc::new(graph), NodeIndex::new(0)).unwrap(); + // NOTE: in general GraphPath would be used against a federated supergraph which would have + // a root node [query](_)* followed by a Query(S1) node + // This test is run against subgraph schema meaning it will start from Query(S1) node instead + assert_eq!(path.to_string(), "Query(S1) (types: [Query])"); + let pos = ObjectFieldDefinitionPosition { + type_name: Name::new("T").unwrap(), + field_name: Name::new("t").unwrap(), + }; + let field = Field::from_position(&schema, pos.into()); + let trigger = OpGraphPathTrigger::OpPathElement(OpPathElement::Field(field)); + let path = path + .add( + trigger, + Some(EdgeIndex::new(3)), + ConditionResolution::Satisfied { + cost: 0.0, + path_tree: None, + context_map: None, + }, + None, + ) + .unwrap(); + assert_eq!(path.to_string(), "Query(S1) --[t]--> T(S1) (types: [T])"); + let pos = ObjectFieldDefinitionPosition { + type_name: Name::new("ID").unwrap(), + field_name: Name::new("id").unwrap(), + }; + let field = Field::from_position(&schema, pos.into()); + let trigger = OpGraphPathTrigger::OpPathElement(OpPathElement::Field(field)); + let path = path + .add( + trigger, + Some(EdgeIndex::new(1)), + ConditionResolution::Satisfied { + cost: 0.0, + path_tree: None, + context_map: None, + }, + None, + ) + .unwrap(); + assert_eq!( + path.to_string(), + "Query(S1) --[t]--> T(S1) --[id]--> ID(S1)" + ); + } +} diff --git a/apollo-federation/src/query_graph/graph_path/transition.rs b/apollo-federation/src/query_graph/graph_path/transition.rs new file mode 100644 index 0000000000..fc8a7b15f3 --- /dev/null +++ b/apollo-federation/src/query_graph/graph_path/transition.rs @@ -0,0 +1,1011 @@ +use std::fmt::Display; +use std::fmt::Formatter; +use std::ops::Not; +use std::sync::Arc; +use std::sync::LazyLock; + +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; +use either::Either; +use itertools::Itertools; +use petgraph::graph::EdgeIndex; +use tracing::debug; +use tracing::debug_span; + +use crate::bail; +use crate::display_helpers::DisplaySlice; +use crate::ensure; +use crate::error::FederationError; +use crate::operation::Field; +use crate::operation::Selection; +use crate::query_graph::OverrideConditions; +use crate::query_graph::QueryGraphEdgeTransition; +use crate::query_graph::QueryGraphNodeType; +use crate::query_graph::condition_resolver::ConditionResolution; +use crate::query_graph::condition_resolver::ConditionResolver; +use crate::query_graph::condition_resolver::UnsatisfiedConditionReason; +use crate::query_graph::graph_path::GraphPath; +use crate::query_graph::graph_path::GraphPathTriggerVariant; +use crate::query_graph::graph_path::IndirectPaths; +use crate::query_graph::graph_path::Unadvanceable; +use crate::query_graph::graph_path::UnadvanceableClosure; +use crate::query_graph::graph_path::UnadvanceableClosures; +use crate::query_graph::graph_path::UnadvanceableReason; +use crate::query_graph::graph_path::Unadvanceables; +use crate::schema::ValidFederationSchema; +use crate::schema::field_set::parse_field_set; +use crate::schema::position::CompositeTypeDefinitionPosition; +use crate::schema::position::FieldDefinitionPosition; +use crate::schema::position::OutputTypeDefinitionPosition; +use crate::schema::position::TypeDefinitionPosition; +use crate::utils::human_readable::HumanReadableListOptions; +use crate::utils::human_readable::HumanReadableListPrefix; +use crate::utils::human_readable::human_readable_list; +use crate::utils::human_readable::human_readable_subgraph_names; +use crate::utils::iter_into_single_item; + +/// A `GraphPath` whose triggers are query graph transitions in some other query graph (essentially +/// meaning that the path has been guided by a walk through that other query graph). +pub(crate) type TransitionGraphPath = GraphPath; + +impl GraphPathTriggerVariant for QueryGraphEdgeTransition { + fn get_field_parent_type(&self) -> Option { + match self { + QueryGraphEdgeTransition::FieldCollection { + field_definition_position, + .. + } => Some(field_definition_position.parent()), + _ => None, + } + } + + fn get_field_mut(&mut self) -> Option<&mut Field> { + None + } + + fn get_op_path_element(&self) -> Option<&super::operation::OpPathElement> { + None + } +} + +/// Wraps a "composition validation" path (one built from `QueryGraphEdgeTransition`s) along with +/// some of the information necessary to compute the indirect paths following that path, and caches +/// the result of that computation when triggered. +/// +/// In other words, this is a `GraphPath` plus lazy memoization +/// of the computation of its following indirect options. +/// +/// The rationale is that after we've reached a given path, we might never need to compute the +/// indirect paths following it (maybe all the fields we'll care about will be available "directly", +/// i.e. from the same subgraph), or we might need to compute it once, or we might need those paths +/// multiple times; but the way the algorithm works, we don't know this in advance. So this +/// abstraction ensures that we only compute such indirect paths lazily, if we ever need them, while +/// ensuring we don't recompute those paths multiple times if we do need them multiple times. +#[derive(Clone)] +pub(crate) struct TransitionPathWithLazyIndirectPaths { + pub(crate) path: Arc, + pub(crate) lazily_computed_indirect_paths: Option, +} + +impl Display for TransitionPathWithLazyIndirectPaths { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.path.fmt(f) + } +} + +pub(crate) type TransitionIndirectPaths = + IndirectPaths; + +impl Clone for TransitionIndirectPaths { + fn clone(&self) -> Self { + Self { + paths: self.paths.clone(), + dead_ends: self.dead_ends.clone(), + } + } +} + +impl TransitionGraphPath { + /// Because Fed 1 used to (somewhat wrongly) require @external on key fields of type extensions + /// and because Fed 2 allows you to avoid type extensions, users upgrading might try to remove + /// `extend` from their schema, but forget to remove the @external on their key field. The + /// problem is that doing that makes the key field truly external, and that could easily make + /// @requires condition not satisfiable (because the key you'd need to get for @requires is now + /// external). To help the user understand that mistake, we add a specific mention to this + /// potential problem if the type is indeed an entity. + fn warn_on_key_fields_marked_external( + subgraph_schema: &ValidFederationSchema, + type_: &CompositeTypeDefinitionPosition, + ) -> Result { + let Some(metadata) = subgraph_schema.subgraph_metadata() else { + bail!("Type should originate from a federation subgraph schema"); + }; + let key_directive_definition_name = &metadata + .federation_spec_definition() + .key_directive_definition(subgraph_schema)? + .name; + let type_def = type_.get(subgraph_schema.schema())?; + if !type_def.directives().has(key_directive_definition_name) { + return Ok("".to_owned()); + } + let external_directive_definition_name = &metadata + .federation_spec_definition() + .external_directive_definition(subgraph_schema)? + .name; + let key_fields_marked_external = type_def + .directives() + .get_all(key_directive_definition_name) + .map(|application| { + let arguments = metadata + .federation_spec_definition() + .key_directive_arguments(application)?; + let fields = parse_field_set( + subgraph_schema, + type_def.name().clone(), + arguments.fields, + true, + )?; + Ok::<_, FederationError>(fields.into_iter().flat_map(|field| { + let Selection::Field(field) = field else { + return None; + }; + if !field + .field + .directives + .has(external_directive_definition_name) + { + return None; + } + Some(field.field.name().clone()) + })) + }) + .process_results(|iter| iter.flatten().collect::>())?; + if key_fields_marked_external.is_empty() { + return Ok("".to_owned()); + } + // PORT_NOTE: The JS codebase did the same logic here as `human_readable_list()`, except it: + // 1. didn't impose a max output length, and + // 2. didn't insert an "and" between the last two elements, if multiple. + // + // These are oversights, and we accordingly just call `human_readable_list()` here, and + // impose the default output length limit of 100 characters. + let fields_list = human_readable_list( + key_fields_marked_external + .iter() + .map(|field| format!("\"{field}\"")), + HumanReadableListOptions { + prefix: Some(HumanReadableListPrefix { + singular: "field", + plural: "fields", + }), + ..Default::default() + }, + ); + Ok(format!( + " (please ensure that this is not due to key {fields_list} being accidentally marked @external)" + )) + } + + // PORT_NOTE: Named `findOverriddingSourcesIfOverridden()` in the JS codebase, but we've changed + // sources to subgraphs here to clarify that we don't need to handle the federated root source. + fn find_overriding_subgraphs_if_overridden( + field_pos_in_subgraph: &FieldDefinitionPosition, + field_subgraph: &Arc, + subgraphs: &IndexMap, ValidFederationSchema>, + ) -> Result>, FederationError> { + subgraphs + .iter() + .map(|(other_subgraph, other_subgraph_schema)| { + if other_subgraph == field_subgraph { + return Ok(None); + } + let Some(type_pos_in_other_subgraph) = other_subgraph_schema + .try_get_type(field_pos_in_subgraph.parent().type_name().clone()) + else { + return Ok(None); + }; + let TypeDefinitionPosition::Object(type_pos_in_other_subgraph) = + type_pos_in_other_subgraph + else { + return Ok(None); + }; + let Ok(field_in_other_subgraph) = type_pos_in_other_subgraph + .field(field_pos_in_subgraph.field_name().clone()) + .get(other_subgraph_schema.schema()) + else { + return Ok(None); + }; + let Some(metadata) = other_subgraph_schema.subgraph_metadata() else { + bail!("Subgraph schema unexpectedly missing metadata"); + }; + // TODO: The @override directive may genuinely not exist in the subgraph schema, and + // override_directive_definition() should arguably return a `Result, _>` + // instead, to distinguish between that case and invariant violations. + let Ok(override_directive_definition) = metadata + .federation_spec_definition() + .override_directive_definition(other_subgraph_schema) + else { + return Ok(None); + }; + let Some(application) = field_in_other_subgraph + .directives + .get(&override_directive_definition.name) + else { + return Ok(None); + }; + if field_subgraph.as_ref() + != metadata + .federation_spec_definition() + .override_directive_arguments(application)? + .from + { + return Ok(None); + } + Ok(Some(other_subgraph.clone())) + }) + .process_results(|iter| iter.flatten().collect()) + } + + /// For the first element of the pair, the data has the same meaning as in + /// `SimultaneousPathsWithLazyIndirectPaths.advance_with_operation_element()`. We also actually + /// need to return a `Vec` of options of simultaneous paths (because when we type explode, we + /// create simultaneous paths, but as a field might be resolved by multiple subgraphs, we may + /// have also created multiple options). + /// + /// For the second element, it is true if the result only has type-exploded results. + // PORT_NOTE: In the JS codebase, this was named `advancePathWithDirectTransition`. + fn advance_with_transition( + &self, + transition: &QueryGraphEdgeTransition, + condition_resolver: &mut impl ConditionResolver, + override_conditions: &Arc, + ) -> Result>, UnadvanceableClosures>, FederationError> { + ensure!( + transition.collect_operation_elements(), + "Supergraphs shouldn't have transitions that don't collect elements", + ); + let mut to_advance = Either::Left(self); + if let QueryGraphEdgeTransition::FieldCollection { + source, + field_definition_position, + .. + } = transition + { + let tail_weight = self.graph.node_weight(self.tail)?; + if let Ok(tail_type) = + CompositeTypeDefinitionPosition::try_from(tail_weight.type_.clone()) + && field_definition_position.parent().type_name() != tail_type.type_name() + && !self.tail_is_interface_object()? + { + // Usually, when we collect a field, the path should already be on the type of + // that field. But one exception is due to the fact that a type condition may be + // "absorbed" by an @interfaceObject, and once we've taken a key on the + // interface to another subgraph (the tail is not the interface object anymore), + // we need to "restore" the type condition first. + let updated_path = self.advance_with_transition( + &QueryGraphEdgeTransition::Downcast { + source: source.clone(), + from_type_position: tail_type.clone(), + to_type_position: field_definition_position.parent().clone(), + }, + condition_resolver, + override_conditions, + )?; + // The case we described above should be the only case we capture here, and so + // the current subgraph must have the implementation type (it may not have the + // field we want, but it must have the type) and so we should be able to advance + // to it. + let Either::Left(updated_path) = updated_path else { + bail!( + "Advancing {} for {} unexpectedly gave unadvanceables", + self, + transition, + ); + }; + // Also note that there is currently no case where we should have more than one + // option. + let num_options = updated_path.len(); + let Some(updated_path) = iter_into_single_item(updated_path.into_iter()) else { + bail!( + "Advancing {} for {} unexpectedly gave {} options", + self, + transition, + num_options, + ); + }; + to_advance = Either::Right(updated_path); + // We can now continue on dealing with the actual field. + } + } + + let mut options: Vec> = vec![]; + let mut dead_end_closures: Vec = vec![]; + + for edge in to_advance.next_edges()? { + let edge_weight = to_advance.graph.edge_weight(edge)?; + // The edge must match the transition. If it doesn't, we cannot use it. + if !edge_weight + .transition + .matches_supergraph_transition(transition)? + { + continue; + } + + if edge_weight.override_condition.is_some() + && !edge_weight.satisfies_override_conditions(override_conditions) + { + let graph = to_advance.graph.clone(); + let override_conditions = override_conditions.clone(); + dead_end_closures.push(Arc::new(LazyLock::new(Box::new(move || { + let edge_weight = graph.edge_weight(edge)?; + let Some(override_condition) = &edge_weight.override_condition else { + bail!("Edge unexpectedly had no override condition"); + }; + let (head, tail) = graph.edge_endpoints(edge)?; + let head_weight = graph.node_weight(head)?; + let tail_weight = graph.node_weight(tail)?; + Ok(Unadvanceables(Arc::new(vec![Arc::new(Unadvanceable { + reason: UnadvanceableReason::UnsatisfiableOverrideCondition, + from_subgraph: head_weight.source.clone(), + to_subgraph: tail_weight.source.clone(), + details: format!( + "Unable to take edge {} because override condition \"{}\" is {}", + edge_weight, + override_condition.label, + override_conditions.contains_key(&override_condition.label) + ), + })]))) + })))); + continue; + } + + // Additionally, we can only take an edge if we can satisfy its conditions. + let condition_resolution = self.can_satisfy_conditions( + edge, + condition_resolver, + &Default::default(), + &Default::default(), + &Default::default(), + )?; + match condition_resolution { + ConditionResolution::Satisfied { .. } => { + options.push(Arc::new(to_advance.add( + transition.clone(), + edge, + condition_resolution, + None, + )?)); + } + ConditionResolution::Unsatisfied { reason } => { + let transition = transition.clone(); + let graph = to_advance.graph.clone(); + dead_end_closures.push(Arc::new(LazyLock::new(Box::new(move || { + let edge_weight = graph.edge_weight(edge)?; + let (head, _) = graph.edge_endpoints(edge)?; + let head_weight = graph.node_weight(head)?; + match &edge_weight.transition { + QueryGraphEdgeTransition::FieldCollection { + field_definition_position, + .. + } => { + // Condition on a field means a @requires. + let subgraph_schema = + graph.schema_by_source(&head_weight.source)?; + let parent_type_pos_in_subgraph: CompositeTypeDefinitionPosition = + subgraph_schema.get_type( + field_definition_position.parent().type_name().clone() + )?.try_into()?; + let details = match reason { + Some(UnsatisfiedConditionReason::NoPostRequireKey) => { + // PORT_NOTE: The original JS codebase was printing + // "@require" in the error message, this has been fixed + // below to "@requires". + format!( + "@requires condition on field \"{}\" can be satisfied but missing usable key on \"{}\" in subgraph \"{}\" to resume query", + field_definition_position, + parent_type_pos_in_subgraph, + head_weight.source, + ) + } + Some(UnsatisfiedConditionReason::NoSetContext) => { + format!( + "could not find a match for required context for field \"{field_definition_position}\"", + ) + } + None => { + // TODO: This isn't necessarily just because an @requires + // condition was unsatisfied, but could also be because a + // @fromContext condition was unsatisfied. + // PORT_NOTE: The original JS codebase was printing + // "@require" in the error message, this has been fixed + // below to "@requires". + format!( + "cannot satisfy @requires conditions on field \"{}\"{}", + field_definition_position, + Self::warn_on_key_fields_marked_external( + subgraph_schema, + &parent_type_pos_in_subgraph, + )?, + ) + } + }; + Ok(Unadvanceables(Arc::new(vec![Arc::new(Unadvanceable { + reason: UnadvanceableReason::UnsatisfiableRequiresCondition, + from_subgraph: head_weight.source.clone(), + to_subgraph: head_weight.source.clone(), + details, + })]))) + } + QueryGraphEdgeTransition::InterfaceObjectFakeDownCast { + from_type_position, + .. + } => { + // The condition on such edges is only __typename, so it essentially + // means that an @interfaceObject exists but there is no reachable + // subgraph with a @key on an interface to find out the proper + // implementation. + let details = match reason { + Some(UnsatisfiedConditionReason::NoPostRequireKey) => { + format!( + "@interfaceObject type \"{}\" misses a resolvable key to resume query once the implementation type has been resolved", + from_type_position.type_name(), + ) + } + _ => { + format!( + "no subgraph can be reached to resolve the implementation type of @interfaceObject type \"{}\"", + from_type_position.type_name(), + ) + } + }; + Ok(Unadvanceables(Arc::new(vec![Arc::new(Unadvanceable { + reason: UnadvanceableReason::UnresolvableInterfaceObject, + from_subgraph: head_weight.source.clone(), + to_subgraph: head_weight.source.clone(), + details, + })]))) + } + _ => { + bail!( + "Shouldn't have conditions on direct transition {}", + transition, + ); + } + } + })))); + } + } + } + + if !options.is_empty() { + return Ok(Either::Left(options)); + } + + let transition = transition.clone(); + let graph = self.graph.clone(); + let tail = self.tail; + Ok(Either::Right(UnadvanceableClosures(Arc::new(vec![ + Arc::new(LazyLock::new(Box::new(move || { + let dead_ends: Unadvanceables = + UnadvanceableClosures(Arc::new(dead_end_closures)).try_into()?; + if !dead_ends.is_empty() { + return Ok(dead_ends); + } + let tail_weight = graph.node_weight(tail)?; + let QueryGraphNodeType::SchemaType(tail_type_pos) = &tail_weight.type_ else { + bail!("Subgraph path tail was unexpectedly a federation root node"); + }; + let subgraph = &tail_weight.source; + let details = match transition { + QueryGraphEdgeTransition::FieldCollection { + field_definition_position, + .. + } => 'details: { + let subgraph_schema = graph.schema_by_source(subgraph)?; + let Some(metadata) = subgraph_schema.subgraph_metadata() else { + bail!("Type should originate from a federation subgraph schema"); + }; + let external_directive_definition_name = &metadata + .federation_spec_definition() + .external_directive_definition(subgraph_schema)? + .name; + let parent_type_pos = field_definition_position.parent(); + let parent_type_pos_in_subgraph = + subgraph_schema.try_get_type(parent_type_pos.type_name().clone()); + if parent_type_pos_in_subgraph.is_none() + && tail_type_pos.type_name() != parent_type_pos.type_name() + { + // This is due to us looking for an implementation field, but the + // subgraph not having that implementation because it uses + // @interfaceObject on an interface of that implementation. + break 'details format!( + "cannot find implementation type \"{parent_type_pos}\" (supergraph interface \"{tail_type_pos}\" is declared with @interfaceObject in \"{subgraph}\")", + ); + } + let Some(Ok(parent_type_pos_in_subgraph)) = parent_type_pos_in_subgraph + .map(CompositeTypeDefinitionPosition::try_from) + else { + break 'details format!( + "cannot find field \"{field_definition_position}\"", + ); + }; + let Ok(field_pos_in_subgraph) = parent_type_pos_in_subgraph + .field(field_definition_position.field_name().clone()) + else { + break 'details format!( + "cannot find field \"{field_definition_position}\"", + ); + }; + let Ok(field_in_subgraph) = + field_pos_in_subgraph.get(subgraph_schema.schema()) + else { + break 'details format!( + "cannot find field \"{field_definition_position}\"", + ); + }; + // The subgraph has the field but no corresponding edge. This should only + // happen if the field is external. + let Some(application) = field_in_subgraph + .directives + .get(external_directive_definition_name) + else { + bail!( + "{} in {} is not external but there is no corresponding edge", + field_pos_in_subgraph, + subgraph, + ); + }; + // The field is external in the subgraph extracted from the supergraph, but + // it might have been forced to be external due to being a "used" overridden + // field, in which case we want to amend the message to avoid confusing the + // user. Note that the subgraph extraction marks such "forced external due + // to being overridden" fields by setting the "reason" to "[overridden]". + let external_reason = metadata + .federation_spec_definition() + .external_directive_arguments(application)? + .reason; + let overriding_subgraphs = if external_reason == Some("[overridden]") { + Self::find_overriding_subgraphs_if_overridden( + &field_pos_in_subgraph, + subgraph, + graph.subgraph_schemas(), + )? + } else { + vec![] + }; + if overriding_subgraphs.is_empty() { + format!( + "field \"{field_definition_position}\" is not resolvable because marked @external", + ) + } else { + format!( + "field \"{}\" is not resolvable because it is overridden by {}", + field_definition_position, + human_readable_subgraph_names(overriding_subgraphs.iter()), + ) + } + } + QueryGraphEdgeTransition::Downcast { + to_type_position, .. + } => { + format!("cannot find type \"{to_type_position}\"") + } + _ => { + bail!("Unhandled direct transition {}", transition); + } + }; + Ok(Unadvanceables(Arc::new(vec![Arc::new(Unadvanceable { + reason: UnadvanceableReason::NoMatchingTransition, + from_subgraph: subgraph.clone(), + to_subgraph: subgraph.clone(), + details, + })]))) + }))), + ])))) + } +} + +impl TransitionPathWithLazyIndirectPaths { + // PORT_NOTE: Named `initial()` in the JS codebase, but conventionally in Rust this kind of + // constructor is named `new()`. + pub(crate) fn new(path: Arc) -> Self { + Self { + path, + lazily_computed_indirect_paths: None, + } + } + + fn indirect_options( + &mut self, + condition_resolver: &mut impl ConditionResolver, + override_conditions: &OverrideConditions, + ) -> Result { + if let Some(indirect_paths) = &self.lazily_computed_indirect_paths { + Ok(indirect_paths.clone()) + } else { + let new_indirect_paths = + self.compute_indirect_paths(condition_resolver, override_conditions)?; + self.lazily_computed_indirect_paths = Some(new_indirect_paths.clone()); + Ok(new_indirect_paths) + } + } + + fn compute_indirect_paths( + &self, + condition_resolver: &mut impl ConditionResolver, + override_conditions: &OverrideConditions, + ) -> Result { + self.path + .advance_with_non_collecting_and_type_preserving_transitions( + &Default::default(), + condition_resolver, + &Default::default(), + &Default::default(), + override_conditions, + // The transitions taken by this method are non-collecting transitions, in which case + // the trigger is the context (which is really a hack to provide context information for + // keys during fetch dependency graph updating). + |transition, _| transition.clone(), + |graph, node, trigger, override_conditions| { + graph.edge_for_transition_graph_path_trigger(node, trigger, override_conditions) + }, + &Default::default(), + ) + } + + /// Returns an `UnadvanceableClosures` if there is no way to advance the path with this + /// transition. Otherwise, it returns a list of options (paths) we can be in after advancing the + /// transition. The lists of options can be empty, which has the special meaning that the + /// transition is guaranteed to have no results (due to unsatisfiable type conditions), meaning + /// that as far as composition validation goes, we can ignore that transition (and anything that + /// follows) and otherwise continue. + // PORT_NOTE: In the JS codebase, this was named `advancePathWithTransition`. + pub(crate) fn advance_with_transition( + &mut self, + transition: &QueryGraphEdgeTransition, + target_type: &OutputTypeDefinitionPosition, + api_schema: &ValidFederationSchema, + condition_resolver: &mut impl ConditionResolver, + override_conditions: &Arc, + ) -> Result< + Either, UnadvanceableClosures>, + FederationError, + > { + // The `transition` comes from the supergraph. Now, it is possible that a transition can be + // expressed on the supergraph, but corresponds to an "unsatisfiable" condition on the + // subgraph. Let's consider: + // - Subgraph A: + // type Query { + // get: [I] + // } + // + // interface I { + // k: Int + // } + // + // type T1 implements I @key(fields: "k") { + // k: Int + // a: String + // } + // + // type T2 implements I @key(fields: "k") { + // k: Int + // b: String + // } + // + // - Subgraph B: + // interface I { + // k: Int + // } + // + // type T1 implements I @key(fields: "k") { + // k: Int + // myself: I + // } + // + // On the resulting supergraph, we will have a path for: + // { + // get { + // ... on T1 { + // myself { + // ... on T2 { + // b + // } + // } + // } + // } + // } + // + // As we compute possible subgraph paths, the `myself` field will get us in subgraph `B` + // through `T1`'s key. However, as we look at transition `... on T2` from subgraph `B`, we + // have no such type/transition. But this does not mean that the subgraphs shouldn't + // compose. What it really means is that the corresponding query above can be done, but is + // guaranteed to never return anything (essentially, we can query subgraph `B` but will + // never get a `T2`, so the result of the query should be empty). + // + // So we need to handle this case, and we do this first. Note that the only kind of + // transition that can give use this is a 'DownCast' transition. Also note that if the + // subgraph type we're on is an @interfaceObject type, then we also can't be in this + // situation as an @interfaceObject type "stands in" for all the possible implementations of + // that interface. And one way to detect if the subgraph type an @interfaceObject is to + // check if the subgraph type is an object type while the supergraph type is an interface + // one. + if let QueryGraphEdgeTransition::Downcast { + from_type_position, .. + } = &transition + { + let tail_weight = self.path.graph.node_weight(self.path.tail)?; + let QueryGraphNodeType::SchemaType(tail_type_pos) = &tail_weight.type_ else { + bail!("Subgraph path tail was unexpectedly a federation root node"); + }; + if !(matches!( + from_type_position, + CompositeTypeDefinitionPosition::Interface(_) + ) && matches!(tail_type_pos, OutputTypeDefinitionPosition::Object(_))) + { + // If we consider a "downcast" transition, it means that the target of that cast is + // composite, but also that the last type of the subgraph path is also composite + // (that type is essentially the "source" of the cast). + let target_type: CompositeTypeDefinitionPosition = + target_type.clone().try_into()?; + let runtime_types_in_supergraph = + api_schema.possible_runtime_types(target_type.clone())?; + let runtime_types_in_subgraph = &self.path.runtime_types_of_tail; + // If the intersection is empty, it means whatever field got us here can never + // resolve into an object of the type we're casting into. Essentially, we'll be fine + // building a plan for this transition and whatever comes next: it'll just return + // nothing. + if runtime_types_in_subgraph.is_disjoint(&runtime_types_in_supergraph) { + debug!( + "No intersection between casted type {} and the possible types in this subgraph", + target_type, + ); + return Ok(Either::Left(vec![])); + } + } + } + + debug!("Trying to advance {} for {}", self.path, transition,); + let span = debug_span!(" |"); + let options_guard = span.enter(); + debug!("Direct options:"); + let span = debug_span!(" |"); + let direct_options_guard = span.enter(); + let direct_options = self.path.advance_with_transition( + transition, + condition_resolver, + override_conditions, + )?; + let mut options: Vec> = vec![]; + let mut dead_end_closures: Vec = vec![]; + match direct_options { + Either::Left(direct_options) => { + drop(direct_options_guard); + debug!("{}", DisplaySlice(&direct_options)); + // If we can fulfill the transition directly (without taking an edge) and the target + // type is "terminal", then there is no point in computing all the options. + if !direct_options.is_empty() && target_type.is_leaf_type() { + drop(options_guard); + debug!( + "reached leaf type {} so not trying indirect paths", + target_type, + ); + return Ok(Either::Left( + direct_options + .into_iter() + .map(TransitionPathWithLazyIndirectPaths::new) + .collect(), + )); + } + options = direct_options; + } + Either::Right(closures) => { + drop(direct_options_guard); + debug!("No direct options"); + dead_end_closures = Arc::unwrap_or_clone(closures.0) + } + } + // Otherwise, let's try non-collecting edges and see if we can find some (more) options + // there. + debug!("Computing indirect paths:"); + let span = debug_span!(" |"); + let indirect_options_guard = span.enter(); + let paths_with_non_collecting_edges = + self.indirect_options(condition_resolver, override_conditions)?; + if !paths_with_non_collecting_edges.paths.is_empty() { + drop(indirect_options_guard); + debug!( + "{} indirect paths: {}", + paths_with_non_collecting_edges.paths.len(), + DisplaySlice(&paths_with_non_collecting_edges.paths), + ); + debug!("Validating indirect options:"); + let span = debug_span!(" |"); + let _guard = span.enter(); + for non_collecting_path in paths_with_non_collecting_edges.paths.iter() { + debug!("For indirect path {}:", non_collecting_path,); + let span = debug_span!(" |"); + let indirect_option_guard = span.enter(); + let paths_with_transition = non_collecting_path.advance_with_transition( + transition, + condition_resolver, + override_conditions, + )?; + match paths_with_transition { + Either::Left(mut paths_with_transition) => { + drop(indirect_option_guard); + debug!( + "Adding valid option: {}", + DisplaySlice(&paths_with_transition) + ); + options.append(&mut paths_with_transition); + } + Either::Right(closures) => { + drop(indirect_option_guard); + debug!("Cannot be advanced with {}", transition); + dead_end_closures.extend(closures.0.iter().cloned()); + } + } + } + } else { + drop(indirect_options_guard); + debug!("no indirect paths"); + } + if !options.is_empty() { + drop(options_guard); + debug!("{}", DisplaySlice(&options)); + return Ok(Either::Left( + options + .into_iter() + .map(TransitionPathWithLazyIndirectPaths::new) + .collect(), + )); + } else { + drop(options_guard); + debug!("Cannot advance {} for this path", transition); + } + + let transition = transition.clone(); + let graph = self.path.graph.clone(); + let tail = self.path.tail; + let indirect_dead_end_closures = paths_with_non_collecting_edges.dead_ends.0; + Ok(Either::Right(UnadvanceableClosures(Arc::new(vec![ + Arc::new(LazyLock::new(Box::new(move || { + dead_end_closures.extend(indirect_dead_end_closures.iter().cloned()); + let mut all_dead_ends: Vec> = Arc::unwrap_or_clone( + Unadvanceables::try_from(UnadvanceableClosures(Arc::new(dead_end_closures)))?.0, + ); + if let QueryGraphEdgeTransition::FieldCollection { + field_definition_position, + .. + } = &transition + { + let parent_type_pos = field_definition_position.parent(); + let tail_weight = graph.node_weight(tail)?; + let QueryGraphNodeType::SchemaType(tail_type_pos) = &tail_weight.type_ else { + bail!("Subgraph path tail was unexpectedly a federation root node"); + }; + let subgraphs_with_dead_ends = all_dead_ends + .iter() + .map(|unadvanceable| unadvanceable.to_subgraph.clone()) + .collect::>(); + for (subgraph, subgraph_schema) in graph.subgraphs() { + if subgraphs_with_dead_ends.contains(subgraph) { + continue; + } + let parent_type_pos_in_subgraph = + subgraph_schema.try_get_type(parent_type_pos.type_name().clone()); + let Some(Ok(parent_type_pos_in_subgraph)) = parent_type_pos_in_subgraph + .map(CompositeTypeDefinitionPosition::try_from) + else { + continue; + }; + let Ok(field_pos_in_subgraph) = parent_type_pos_in_subgraph + .field(field_definition_position.field_name().clone()) + else { + continue; + }; + let Ok(_field_in_subgraph) = + field_pos_in_subgraph.get(subgraph_schema.schema()) + else { + continue; + }; + // The subgraph has the type we're looking for, but we have recorded no + // dead-ends. This means there is no edge to that type, and thus either: + // 1. it has no keys, or + // 2. the path to advance it is an @interfaceObject type, the type we look + // for is an implementation of that interface, and there are no keys on + // the interface. + let tail_type_pos_in_subgraph = + subgraph_schema.try_get_type(tail_type_pos.type_name().clone()); + if let Some(tail_type_pos_in_subgraph) = tail_type_pos_in_subgraph { + // `tail_type_pos_in_subgraph` exists, so it's either equal to + // `parent_type_pos_in_subgraph`, or it's an interface of it. In any + // case, it's composite. + let Ok(tail_type_pos_in_subgraph) = + CompositeTypeDefinitionPosition::try_from( + tail_type_pos_in_subgraph.clone(), + ) + else { + bail!( + "Type {} in {} should be composite", + tail_type_pos_in_subgraph, + subgraph, + ); + }; + let Some(metadata) = subgraph_schema.subgraph_metadata() else { + bail!("Type should originate from a federation subgraph schema"); + }; + let key_directive_definition_name = &metadata + .federation_spec_definition() + .key_directive_definition(subgraph_schema)? + .name; + let tail_type_def = + tail_type_pos_in_subgraph.get(subgraph_schema.schema())?; + let key_resolvables = tail_type_def + .directives() + .get_all(key_directive_definition_name) + .map(|application| { + Ok::<_, FederationError>( + metadata + .federation_spec_definition() + .key_directive_arguments(application)? + .resolvable, + ) + }) + .process_results(|iter| iter.collect::>())?; + ensure!( + key_resolvables.iter().all(Not::not), + "After transition {}, expected type {} in {} to have no resolvable keys", + transition, + parent_type_pos_in_subgraph, + subgraph, + ); + let kind_of_type = + if tail_type_pos_in_subgraph == parent_type_pos_in_subgraph { + "type" + } else { + "interface" + }; + let explanation = if key_resolvables.is_empty() { + format!( + "{kind_of_type} \"{tail_type_pos}\" has no @key defined in subgraph \"{subgraph}\"", + ) + } else { + format!( + "none of the @key defined on {kind_of_type} \"{tail_type_pos}\" in subgraph \"{subgraph}\" are resolvable (they are all declared with their \"resolvable\" argument set to false)", + ) + }; + all_dead_ends.push(Arc::new(Unadvanceable { + reason: UnadvanceableReason::UnreachableType, + from_subgraph: tail_weight.source.clone(), + to_subgraph: subgraph.clone(), + details: format!( + "cannot move to subgraph \"{subgraph}\", which has field \"{field_definition_position}\", because {explanation}", + ), + })) + } else { + // This means that: + // 1. the type of the path we're trying to advance is different from the + // transition we're considering, and that should only happen if the + // path is on an @interfaceObject type, and + // 2. the subgraph we're looking at actually doesn't have that interface + // (to be able to jump to that subgraph, we would need the interface + // and it would need to have a resolvable key, but it has neither). + all_dead_ends.push(Arc::new(Unadvanceable { + reason: UnadvanceableReason::UnreachableType, + from_subgraph: tail_weight.source.clone(), + to_subgraph: subgraph.clone(), + details: format!( + "cannot move to subgraph \"{subgraph}\", which has field \"{field_definition_position}\", because interface \"{tail_type_pos}\" is not defined in this subgraph (to jump to \"{subgraph}\", it would need to both define interface \"{tail_type_pos}\" and have a @key on it)", + ), + })) + } + } + } + + Ok(Unadvanceables(Arc::new(all_dead_ends))) + }))), + ])))) + } +} diff --git a/apollo-federation/src/query_graph/mod.rs b/apollo-federation/src/query_graph/mod.rs index 736f7af567..59e882fdf4 100644 --- a/apollo-federation/src/query_graph/mod.rs +++ b/apollo-federation/src/query_graph/mod.rs @@ -1,33 +1,39 @@ use std::fmt::Display; use std::fmt::Formatter; use std::hash::Hash; +use std::ops::Deref; +use std::ops::DerefMut; use std::sync::Arc; +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use apollo_compiler::schema::NamedType; -use apollo_compiler::Name; +use apollo_compiler::schema::Type; +use petgraph::Direction; use petgraph::graph::DiGraph; use petgraph::graph::EdgeIndex; use petgraph::graph::EdgeReference; use petgraph::graph::NodeIndex; use petgraph::visit::EdgeRef; -use petgraph::Direction; +use crate::ensure; use crate::error::FederationError; use crate::error::SingleFederationError; use crate::internal_error; use crate::operation::Field; use crate::operation::InlineFragment; use crate::operation::SelectionSet; +use crate::schema::ValidFederationSchema; use crate::schema::field_set::parse_field_set; use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::FieldDefinitionPosition; use crate::schema::position::InterfaceFieldDefinitionPosition; +use crate::schema::position::ObjectFieldArgumentDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; use crate::schema::position::OutputTypeDefinitionPosition; use crate::schema::position::SchemaRootDefinitionKind; -use crate::schema::ValidFederationSchema; use crate::utils::FallibleIterator; pub mod build_query_graph; @@ -37,16 +43,17 @@ pub mod output; pub(crate) mod path_tree; pub use build_query_graph::build_federated_query_graph; +pub use build_query_graph::build_supergraph_api_query_graph; +use graph_path::operation::OpGraphPathContext; +use graph_path::operation::OpGraphPathTrigger; +use graph_path::operation::OpPathElement; use crate::query_graph::condition_resolver::ConditionResolution; use crate::query_graph::condition_resolver::ConditionResolver; use crate::query_graph::graph_path::ExcludedConditions; use crate::query_graph::graph_path::ExcludedDestinations; -use crate::query_graph::graph_path::OpGraphPathContext; -use crate::query_graph::graph_path::OpGraphPathTrigger; -use crate::query_graph::graph_path::OpPathElement; -use crate::query_plan::query_planner::EnabledOverrideConditions; use crate::query_plan::QueryPlanCost; +use crate::query_plan::query_planning_traversal::non_local_selections_estimation; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct QueryGraphNode { @@ -79,7 +86,7 @@ impl Display for QueryGraphNode { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}({})", self.type_, self.source)?; if let Some(provide_id) = self.provide_id { - write!(f, "-{}", provide_id)?; + write!(f, "-{provide_id}")?; } if self.is_root_node() { write!(f, "*")?; @@ -131,6 +138,26 @@ impl TryFrom for ObjectTypeDefinitionPosition { } } +/// Contains all of the data necessary to connect the object field argument (`argument_coordinate`) +/// with the `@fromContext` to its (grand)parent types contain a matching selection. +#[derive(Debug, PartialEq, Clone)] +pub struct ContextCondition { + context: String, + subgraph_name: Arc, + // This is purposely left unparsed in query graphs, due to @fromContext selection sets being + // duck-typed. + selection: String, + types_with_context_set: IndexSet, + // PORT_NOTE: This field was renamed because the JS name (`namedParameter`) left confusion to + // how it was different from the argument name. + argument_name: Name, + // PORT_NOTE: This field was renamed because the JS name (`coordinate`) was too vague. + argument_coordinate: ObjectFieldArgumentDefinitionPosition, + // PORT_NOTE: This field was renamed from the JS name (`argType`) for consistency with the rest + // of the naming in this struct. + argument_type: Node, +} + #[derive(Debug, PartialEq, Clone)] pub(crate) struct QueryGraphEdge { /// Indicates what kind of edge this is and what the edge does/represents. For instance, if the @@ -155,15 +182,28 @@ pub(crate) struct QueryGraphEdge { /// one of them has an @override with a label. If the override condition /// matches the query plan parameters, this edge can be taken. pub(crate) override_condition: Option, + /// All arguments with `@fromContext` that need to be matched to an upstream graph path field + /// whose parent type has the corresponding `@context`. + pub(crate) required_contexts: Vec, } impl QueryGraphEdge { - fn satisfies_override_conditions( - &self, - conditions_to_check: &EnabledOverrideConditions, - ) -> bool { + pub(crate) fn new( + transition: QueryGraphEdgeTransition, + conditions: Option>, + override_condition: Option, + ) -> Self { + Self { + transition, + conditions, + override_condition, + required_contexts: Vec::new(), + } + } + + fn satisfies_override_conditions(&self, conditions_to_check: &OverrideConditions) -> bool { if let Some(override_condition) = &self.override_condition { - override_condition.condition == conditions_to_check.contains(&override_condition.label) + override_condition.check(conditions_to_check) } else { true } @@ -194,12 +234,53 @@ impl Display for QueryGraphEdge { } } } + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct OverrideCondition { - pub(crate) label: String, + pub(crate) label: Arc, pub(crate) condition: bool, } +impl OverrideCondition { + pub(crate) fn check(&self, override_conditions: &OverrideConditions) -> bool { + override_conditions.get(&self.label) == Some(&self.condition) + } +} + +/// For query planning, this is a map of all override condition labels to whether that label is set. +/// For composition satisfiability, this is the same thing, but it's only some of the override +/// conditions. Specifically, for top-level queries in satisfiability, this will only contain those +/// override conditions encountered in the path. For conditions queries in satisfiability, this will +/// be an empty map. +#[derive(Debug, Clone, Default)] +pub(crate) struct OverrideConditions(IndexMap, bool>); + +impl Deref for OverrideConditions { + type Target = IndexMap, bool>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for OverrideConditions { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl OverrideConditions { + pub(crate) fn new(graph: &QueryGraph, enabled_conditions: &IndexSet) -> Self { + Self( + graph + .override_condition_labels + .iter() + .map(|label| (label.clone(), enabled_conditions.contains(label.as_ref()))) + .collect(), + ) + } +} + impl Display for OverrideCondition { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{} = {}", self.label, self.condition) @@ -277,6 +358,56 @@ impl QueryGraphEdgeTransition { QueryGraphEdgeTransition::InterfaceObjectFakeDownCast { .. } => true, } } + + pub(crate) fn matches_supergraph_transition( + &self, + other: &Self, + ) -> Result { + ensure!( + other.collect_operation_elements(), + "Supergraphs shouldn't have a transition that doesn't collect elements; got {}", + other, + ); + Ok(match self { + QueryGraphEdgeTransition::FieldCollection { + field_definition_position, + .. + } => { + let QueryGraphEdgeTransition::FieldCollection { + field_definition_position: other_field_definition_position, + .. + } = other + else { + return Ok(false); + }; + field_definition_position.field_name() + == other_field_definition_position.field_name() + } + QueryGraphEdgeTransition::Downcast { + to_type_position, .. + } => { + let QueryGraphEdgeTransition::Downcast { + to_type_position: other_to_type_position, + .. + } = other + else { + return Ok(false); + }; + to_type_position.type_name() == other_to_type_position.type_name() + } + QueryGraphEdgeTransition::InterfaceObjectFakeDownCast { to_type_name, .. } => { + let QueryGraphEdgeTransition::InterfaceObjectFakeDownCast { + to_type_name: other_to_type_name, + .. + } = other + else { + return Ok(false); + }; + to_type_name == other_to_type_name + } + _ => false, + }) + } } impl Display for QueryGraphEdgeTransition { @@ -297,13 +428,13 @@ impl Display for QueryGraphEdgeTransition { write!(f, "key()") } QueryGraphEdgeTransition::RootTypeResolution { root_kind } => { - write!(f, "{}()", root_kind) + write!(f, "{root_kind}()") } QueryGraphEdgeTransition::SubgraphEnteringTransition => { write!(f, "∅") } QueryGraphEdgeTransition::InterfaceObjectFakeDownCast { to_type_name, .. } => { - write!(f, "... on {}", to_type_name) + write!(f, "... on {to_type_name}") } } } @@ -327,6 +458,8 @@ pub struct QueryGraph { /// same as `sources`, but is missing the dummy source FEDERATED_GRAPH_ROOT_SOURCE which isn't /// really a subgraph. subgraphs_by_name: IndexMap, ValidFederationSchema>, + /// For federated query graphs, this is the supergraph schema; otherwise, this is `None`. + supergraph_schema: Option, /// A map (keyed by source) that associates type names of the underlying schema on which this /// query graph was built to each of the nodes that points to a type of that name. Note that for /// a "federated" query graph source, each type name will only map to a single node. @@ -357,6 +490,18 @@ pub struct QueryGraph { /// lowered composition validation on a big composition (100+ subgraphs) from ~4 minutes to /// ~10 seconds. non_trivial_followup_edges: IndexMap>, + // PORT_NOTE: This field was renamed from the JS name (`subgraphToArgIndices`) to better + // align with downstream code. + /// Maps subgraph names to another map, for any subgraph with usages of `@fromContext`. This + /// other map then maps subgraph argument positions/coordinates (for `@fromContext` arguments) + /// to a unique identifier string (specifically, unique across pairs of subgraph names and + /// argument coordinates). This identifier is called the "context ID". + arguments_to_context_ids_by_source: + IndexMap, IndexMap>, + override_condition_labels: IndexSet>, + /// To speed up the estimation of counting non-local selections, we precompute specific metadata + /// about the query graph and store that here. + non_local_selection_metadata: non_local_selections_estimation::QueryGraphMetadata, } impl QueryGraph { @@ -368,6 +513,16 @@ impl QueryGraph { &self.graph } + pub(crate) fn override_condition_labels(&self) -> &IndexSet> { + &self.override_condition_labels + } + + pub(crate) fn supergraph_schema(&self) -> Result { + self.supergraph_schema + .clone() + .ok_or_else(|| internal_error!("Supergraph schema unexpectedly missing")) + } + pub(crate) fn node_weight(&self, node: NodeIndex) -> Result<&QueryGraphNode, FederationError> { self.graph .node_weight(node) @@ -409,7 +564,7 @@ impl QueryGraph { .ok_or_else(|| internal_error!("Edge unexpectedly missing")) } - fn schema(&self) -> Result<&ValidFederationSchema, FederationError> { + pub(crate) fn schema(&self) -> Result<&ValidFederationSchema, FederationError> { self.schema_by_source(&self.current_source) } @@ -446,7 +601,7 @@ impl QueryGraph { self.types_to_nodes_by_source(&self.current_source) } - fn types_to_nodes_by_source( + pub(super) fn types_to_nodes_by_source( &self, source: &str, ) -> Result<&IndexMap>, FederationError> { @@ -511,19 +666,42 @@ impl QueryGraph { }) } + pub(crate) fn context_id_by_source_and_argument( + &self, + source: &str, + argument: &ObjectFieldArgumentDefinitionPosition, + ) -> Result<&Name, FederationError> { + self.arguments_to_context_ids_by_source + .get(source) + .and_then(|r| r.get(argument)) + .ok_or_else(|| { + internal_error!("context ID unexpectedly missing for @fromContext argument") + }) + } + + pub(crate) fn is_context_used(&self) -> bool { + !self.arguments_to_context_ids_by_source.is_empty() + } + + pub(crate) fn non_local_selection_metadata( + &self, + ) -> &non_local_selections_estimation::QueryGraphMetadata { + &self.non_local_selection_metadata + } + /// All outward edges from the given node (including self-key and self-root-type-resolution /// edges). Primarily used by `@defer`, when needing to re-enter a subgraph for a deferred /// section. pub(crate) fn out_edges_with_federation_self_edges( &self, node: NodeIndex, - ) -> Vec> { + ) -> Vec> { Self::sorted_edges(self.graph.edges_directed(node, Direction::Outgoing)) } /// The outward edges from the given node, minus self-key and self-root-type-resolution edges, /// as they're rarely useful (currently only used by `@defer`). - pub(crate) fn out_edges(&self, node: NodeIndex) -> Vec> { + pub(crate) fn out_edges(&self, node: NodeIndex) -> Vec> { Self::sorted_edges(self.graph.edges_directed(node, Direction::Outgoing).filter( |edge_ref| { !(edge_ref.source() == edge_ref.target() @@ -555,6 +733,10 @@ impl QueryGraph { edges } + pub(crate) fn is_terminal(&self, node: NodeIndex) -> bool { + self.graph.edges_directed(node, Direction::Outgoing).count() == 0 + } + pub(crate) fn is_self_key_or_root_edge( &self, edge: EdgeIndex, @@ -599,6 +781,7 @@ impl QueryGraph { &OpGraphPathContext::default(), &ExcludedDestinations::default(), &ExcludedConditions::default(), + None, )?; let ConditionResolution::Satisfied { cost, .. } = condition_resolution else { continue; @@ -618,7 +801,9 @@ impl QueryGraph { ) -> Result, FederationError> { let edge_head = self.edge_head_weight(edge_index)?; let QueryGraphNodeType::SchemaType(type_position) = &edge_head.type_ else { - return Err(FederationError::internal("Unable to compute locally_satisfiable_key. Edge head was unexpectedly pointing to a federated root type")); + return Err(FederationError::internal( + "Unable to compute locally_satisfiable_key. Edge head was unexpectedly pointing to a federated root type", + )); }; let Some(subgraph_schema) = self.sources.get(&edge_head.source) else { return Err(FederationError::internal(format!( @@ -652,6 +837,7 @@ impl QueryGraph { subgraph_schema, composite_type_position.type_name().clone(), key_value.fields, + true, ) }) .find_ok(|selection| !external_metadata.selects_any_external_field(selection)) @@ -661,7 +847,7 @@ impl QueryGraph { &self, node: NodeIndex, field: &Field, - override_conditions: &EnabledOverrideConditions, + override_conditions: &OverrideConditions, ) -> Option { let mut candidates = self.out_edges(node).into_iter().filter_map(|edge_ref| { let edge_weight = edge_ref.weight(); @@ -743,7 +929,7 @@ impl QueryGraph { &self, node: NodeIndex, op_graph_path_trigger: &OpGraphPathTrigger, - override_conditions: &EnabledOverrideConditions, + override_conditions: &OverrideConditions, ) -> Option> { let OpGraphPathTrigger::OpPathElement(op_path_element) = op_graph_path_trigger else { return None; @@ -763,6 +949,25 @@ impl QueryGraph { } } + pub(crate) fn edge_for_transition_graph_path_trigger( + &self, + node: NodeIndex, + transition_graph_path_trigger: &QueryGraphEdgeTransition, + override_conditions: &OverrideConditions, + ) -> Result, FederationError> { + for edge_ref in self.out_edges(node) { + let edge_weight = edge_ref.weight(); + if edge_weight + .transition + .matches_supergraph_transition(transition_graph_path_trigger)? + && edge_weight.satisfies_override_conditions(override_conditions) + { + return Ok(Some(edge_ref.id())); + } + } + Ok(None) + } + /// Given the possible runtime types at the head of the given edge, returns the possible runtime /// types after traversing the edge. // PORT_NOTE: Named `updateRuntimeTypes` in the JS codebase. @@ -783,17 +988,15 @@ impl QueryGraph { "Unexpectedly encountered federation root node as tail node.", )); }; - return match &edge_weight.transition { + match &edge_weight.transition { QueryGraphEdgeTransition::FieldCollection { source, field_definition_position, .. } => { - let Ok(_): Result = - tail_type_pos.clone().try_into() - else { + if CompositeTypeDefinitionPosition::try_from(tail_type_pos.clone()).is_err() { return Ok(IndexSet::default()); - }; + } let schema = self.schema_by_source(source)?; let mut new_possible_runtime_types = IndexSet::default(); for possible_runtime_type in possible_runtime_types { @@ -848,7 +1051,7 @@ impl QueryGraph { QueryGraphEdgeTransition::InterfaceObjectFakeDownCast { .. } => { Ok(possible_runtime_types.clone()) } - }; + } } /// Returns a selection set that can be used as a key for the given type, and that can be @@ -889,7 +1092,7 @@ impl QueryGraph { key.specified_argument_by_name("fields") .and_then(|arg| arg.as_str()) }) - .map(|value| parse_field_set(schema, ty.name().clone(), value)) + .map(|value| parse_field_set(schema, ty.name().clone(), value, true)) .find_ok(|selection| { !metadata .external_metadata() @@ -924,8 +1127,7 @@ impl QueryGraph { let schema = self.schema_by_source(source)?; let Some(metadata) = schema.subgraph_metadata() else { return Err(FederationError::internal(format!( - "Interface should have come from a federation subgraph {}", - source + "Interface should have come from a federation subgraph {source}" ))); }; diff --git a/apollo-federation/src/query_graph/output.rs b/apollo-federation/src/query_graph/output.rs index 4b68c2d047..57efb2146b 100644 --- a/apollo-federation/src/query_graph/output.rs +++ b/apollo-federation/src/query_graph/output.rs @@ -23,7 +23,7 @@ fn label_edge(edge: &QueryGraphEdge) -> String { if label.is_empty() { String::new() } else { - format!("label=\"{}\"", edge) + format!("label=\"{edge}\"") } } @@ -67,7 +67,7 @@ fn to_dot_federated(graph: &QueryGraph) -> Result { fn label_cluster_node(node: &QueryGraphNode) -> String { let provide_id = match node.provide_id { - Some(id) => format!("#{}", id), + Some(id) => format!("#{id}"), None => String::new(), }; format!(r#"label="{}{}@{}""#, node.type_, provide_id, node.source) @@ -132,17 +132,17 @@ fn to_dot_federated(graph: &QueryGraph) -> Result { // Supergraph edges for i in stable_graph.edge_indices() { - if edge_across_clusters(&stable_graph, i) { - if let Some((n1, n2)) = stable_graph.edge_endpoints(i) { - let edge = &stable_graph[i]; - writeln!( - dot_str, - " {} -> {} [{}]", - n1.index(), - n2.index(), - label_edge(edge) - )?; - } + if edge_across_clusters(&stable_graph, i) + && let Some((n1, n2)) = stable_graph.edge_endpoints(i) + { + let edge = &stable_graph[i]; + writeln!( + dot_str, + " {} -> {} [{}]", + n1.index(), + n2.index(), + label_edge(edge) + )?; } } diff --git a/apollo-federation/src/query_graph/path_tree.rs b/apollo-federation/src/query_graph/path_tree.rs index cf758bed37..43bc4749fa 100644 --- a/apollo-federation/src/query_graph/path_tree.rs +++ b/apollo-federation/src/query_graph/path_tree.rs @@ -9,13 +9,15 @@ use petgraph::graph::EdgeIndex; use petgraph::graph::NodeIndex; use serde::Serialize; +use super::graph_path::ArgumentsToContextUsages; +use super::graph_path::MatchingContextIds; use crate::error::FederationError; use crate::operation::SelectionSet; -use crate::query_graph::graph_path::GraphPathItem; -use crate::query_graph::graph_path::OpGraphPath; -use crate::query_graph::graph_path::OpGraphPathTrigger; use crate::query_graph::QueryGraph; use crate::query_graph::QueryGraphNode; +use crate::query_graph::graph_path::GraphPathItem; +use crate::query_graph::graph_path::operation::OpGraphPath; +use crate::query_graph::graph_path::operation::OpGraphPathTrigger; use crate::utils::FallibleIterator; /// A "merged" tree representation for a vector of `GraphPath`s that start at a common query graph @@ -92,6 +94,14 @@ where pub(crate) conditions: Option>, /// The child `PathTree` reached by taking the edge. pub(crate) tree: Arc>, + // PORT_NOTE: This field was renamed because the JS name (`contextToSelection`) implied it was + // a map to selections, which it isn't. + /// The IDs of contexts that have matched at the edge. + pub(crate) matching_context_ids: Option, + // PORT_NOTE: This field was renamed because the JS name (`parameterToContext`) left confusion + // to how a parameter was different from an argument. + /// A map of @fromContext arguments to info about the contexts used in those arguments. + pub(crate) arguments_to_context_usages: Option, } impl PartialEq for PathTreeChild @@ -173,12 +183,10 @@ impl OpPathTree { for child in self.childs.iter() { let index = child.edge.unwrap_or_else(EdgeIndex::end); write!(f, "\n{indent} -> [{}] ", index.index())?; - if include_conditions { - if let Some(ref child_cond) = child.conditions { - write!(f, "!! {{\n{indent} ")?; - child_cond.fmt_internal(f, &child_indent, /*include_conditions*/ true)?; - write!(f, "\n{indent} }}")?; - } + if include_conditions && let Some(ref child_cond) = child.conditions { + write!(f, "!! {{\n{indent} ")?; + child_cond.fmt_internal(f, &child_indent, /*include_conditions*/ true)?; + write!(f, "\n{indent} }}")?; } write!(f, "{} = ", child.trigger)?; child @@ -196,9 +204,18 @@ impl Display for OpPathTree { } } +/// A partial ordering over type `T` in terms of preference. +/// - Similar to PartialOrd, but equivalence is unnecessary. +pub(crate) trait Preference { + /// - Returns None, if `self` and `other` are incomparable or equivalent. + /// - Returns Some(true), if `self` is preferred over `other`. + /// - Returns Some(false), if `other` is preferred over `self`. + fn preferred_over(&self, other: &Self) -> Option; +} + impl PathTree where - TTrigger: Eq + Hash, + TTrigger: Eq + Hash + Preference, TEdge: Copy + Hash + Eq + Into>, { /// Returns the `QueryGraphNode` represented by `self.node`. @@ -233,21 +250,31 @@ where } struct PathTreeChildInputs<'inputs, TTrigger, GraphPathIter> { - /// trigger: the final trigger value + /// trigger: the final trigger value chosen amongst the candidate triggers /// - Two equivalent triggers can have minor differences in the sibling_typename. /// This field holds the final trigger value that will be used. - /// PORT_NOTE: The JS QP used the last trigger value. So, we are following that - /// to avoid mismatches. But, it can be revisited. - /// We may want to keep or merge the sibling_typename values. + /// + /// PORT_NOTE: The JS QP used the last trigger value, since the next trigger value + /// overwrites the `trigger` field. Instead, Rust QP adopts the one with the + /// sibling_typename set or the first one if none are set. trigger: &'inputs Arc, conditions: Option>, sub_paths_and_selections: Vec<(GraphPathIter, Option<&'inputs Arc>)>, + matching_context_ids: Option, + arguments_to_context_usages: Option, } let mut local_selection_sets = Vec::new(); for (mut graph_path_iter, selection) in graph_paths_and_selections { - let Some((generic_edge, trigger, conditions)) = graph_path_iter.next() else { + let Some(( + generic_edge, + trigger, + conditions, + matching_context_ids, + arguments_to_context_usages, + )) = graph_path_iter.next() + else { // End of an input `GraphPath` if let Some(selection) = selection { local_selection_sets.push(selection.clone()); @@ -272,8 +299,22 @@ where match for_edge.by_unique_trigger.entry(trigger) { Entry::Occupied(entry) => { let existing = entry.into_mut(); - existing.trigger = trigger; + if trigger.preferred_over(existing.trigger) == Some(true) { + existing.trigger = trigger; + } existing.conditions = merge_conditions(&existing.conditions, conditions); + if let Some(other) = matching_context_ids { + existing + .matching_context_ids + .get_or_insert_with(Default::default) + .extend(other.iter().cloned()); + } + if let Some(other) = arguments_to_context_usages { + existing + .arguments_to_context_usages + .get_or_insert_with(Default::default) + .extend(other.iter().map(|(k, v)| (k.clone(), v.clone()))); + } existing .sub_paths_and_selections .push((graph_path_iter, selection)) @@ -284,6 +325,8 @@ where trigger, conditions: conditions.clone(), sub_paths_and_selections: vec![(graph_path_iter, selection)], + matching_context_ids: matching_context_ids.cloned(), + arguments_to_context_usages: arguments_to_context_usages.cloned(), }); } } @@ -301,6 +344,8 @@ where by_unique_edge.target_node, child.sub_paths_and_selections, )?), + matching_context_ids: child.matching_context_ids.clone(), + arguments_to_context_usages: child.arguments_to_context_usages.clone(), })) } } @@ -334,6 +379,18 @@ where (Some(cond_a), Some(cond_b)) => cond_a.equals_same_root(cond_b), _ => false, } + && match (&a.matching_context_ids, &b.matching_context_ids) { + (Some(_), Some(_)) => a.matching_context_ids == b.matching_context_ids, + (_, _) => + a.matching_context_ids.as_ref().map(|c| c.is_empty()).unwrap_or(true) && + b.matching_context_ids.as_ref().map(|c| c.is_empty()).unwrap_or(true) + } + && match (&a.arguments_to_context_usages, &b.arguments_to_context_usages) { + (Some(_), Some(_)) => a.arguments_to_context_usages == b.arguments_to_context_usages, + (_, _) => + a.arguments_to_context_usages.as_ref().map(|c| c.is_empty()).unwrap_or(true) && + b.arguments_to_context_usages.as_ref().map(|c| c.is_empty()).unwrap_or(true) + } && a.tree.equals_same_root(&b.tree) }) } @@ -415,6 +472,14 @@ where trigger: child.trigger.clone(), conditions: merge_conditions(&child.conditions, &other_child.conditions), tree: child.tree.merge(&other_child.tree), + matching_context_ids: merge_matching_context_ids( + &child.matching_context_ids, + &other_child.matching_context_ids, + ), + arguments_to_context_usages: merge_arguments_to_context_usages( + &child.arguments_to_context_usages, + &other_child.arguments_to_context_usages, + ), }) } else { childs.push(other_child.clone()) @@ -436,6 +501,40 @@ where } } +fn merge_matching_context_ids( + a: &Option, + b: &Option, +) -> Option { + match (a, b) { + (Some(a), Some(b)) => { + let mut merged: MatchingContextIds = Default::default(); + merged.extend(a.iter().cloned()); + merged.extend(b.iter().cloned()); + Some(merged) + } + (Some(a), None) => Some(a.clone()), + (None, Some(b)) => Some(b.clone()), + (None, None) => None, + } +} + +fn merge_arguments_to_context_usages( + a: &Option, + b: &Option, +) -> Option { + match (a, b) { + (Some(a), Some(b)) => { + let mut merged: ArgumentsToContextUsages = Default::default(); + merged.extend(a.iter().map(|(k, v)| (k.clone(), v.clone()))); + merged.extend(b.iter().map(|(k, v)| (k.clone(), v.clone()))); + Some(merged) + } + (Some(a), None) => Some(a.clone()), + (None, Some(b)) => Some(b.clone()), + (None, None) => None, + } +} + fn merge_conditions( a: &Option>, b: &Option>, @@ -474,30 +573,32 @@ mod tests { use std::sync::Arc; use apollo_compiler::ExecutableDocument; + use apollo_compiler::parser::Parser; use petgraph::stable_graph::NodeIndex; use petgraph::visit::EdgeRef; use crate::error::FederationError; - use crate::operation::normalize_operation; use crate::operation::Field; + use crate::operation::never_cancel; + use crate::operation::normalize_operation; + use crate::query_graph::QueryGraph; + use crate::query_graph::QueryGraphEdgeTransition; use crate::query_graph::build_query_graph::build_query_graph; use crate::query_graph::condition_resolver::ConditionResolution; - use crate::query_graph::graph_path::OpGraphPath; - use crate::query_graph::graph_path::OpGraphPathTrigger; - use crate::query_graph::graph_path::OpPathElement; + use crate::query_graph::graph_path::operation::OpGraphPath; + use crate::query_graph::graph_path::operation::OpGraphPathTrigger; + use crate::query_graph::graph_path::operation::OpPathElement; use crate::query_graph::path_tree::OpPathTree; - use crate::query_graph::QueryGraph; - use crate::query_graph::QueryGraphEdgeTransition; - use crate::schema::position::SchemaRootDefinitionKind; use crate::schema::ValidFederationSchema; + use crate::schema::position::SchemaRootDefinitionKind; // NB: stole from operation.rs fn parse_schema_and_operation( schema_and_operation: &str, ) -> (ValidFederationSchema, ExecutableDocument) { - let (schema, executable_document) = - apollo_compiler::parse_mixed_validate(schema_and_operation, "document.graphql") - .unwrap(); + let (schema, executable_document) = Parser::new() + .parse_mixed_validate(schema_and_operation, "document.graphql") + .unwrap(); let executable_document = executable_document.into_inner(); let schema = ValidFederationSchema::new(schema).unwrap(); (schema, executable_document) @@ -507,6 +608,7 @@ mod tests { ConditionResolution::Satisfied { cost: 0.0, path_tree: None, + context_map: None, } } @@ -591,8 +693,14 @@ mod tests { let (schema, mut executable_document) = parse_schema_and_operation(src); let (op_name, operation) = executable_document.operations.named.first_mut().unwrap(); - let query_graph = - Arc::new(build_query_graph(op_name.to_string().into(), schema.clone()).unwrap()); + let query_graph = Arc::new( + build_query_graph( + op_name.to_string().into(), + schema.clone(), + Default::default(), + ) + .unwrap(), + ); let path1 = build_graph_path(&query_graph, SchemaRootDefinitionKind::Query, &["t", "id"]).unwrap(); @@ -612,17 +720,21 @@ mod tests { "Query(Test) --[t]--> T(Test) --[otherId]--> ID(Test)" ); - let normalized_operation = - normalize_operation(operation, Default::default(), &schema, &Default::default()) - .unwrap(); + let normalized_operation = normalize_operation( + operation, + &Default::default(), + &schema, + &Default::default(), + &never_cancel, + ) + .unwrap(); let selection_set = Arc::new(normalized_operation.selection_set); let paths = vec![ (&path1, Some(&selection_set)), (&path2, Some(&selection_set)), ]; - let path_tree = - OpPathTree::from_op_paths(query_graph.to_owned(), NodeIndex::new(0), &paths).unwrap(); + let path_tree = OpPathTree::from_op_paths(query_graph, NodeIndex::new(0), &paths).unwrap(); let computed = path_tree.to_string(); let expected = r#"Query(Test): -> [3] t = T(Test): diff --git a/apollo-federation/src/query_plan/conditions.rs b/apollo-federation/src/query_plan/conditions.rs index f202fd4058..7097bafecf 100644 --- a/apollo-federation/src/query_plan/conditions.rs +++ b/apollo-federation/src/query_plan/conditions.rs @@ -1,22 +1,22 @@ +use std::fmt::Display; use std::sync::Arc; +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::ast::Directive; use apollo_compiler::collections::IndexMap; use apollo_compiler::executable::Value; -use apollo_compiler::Name; -use apollo_compiler::Node; use indexmap::map::Entry; use serde::Serialize; use crate::bail; use crate::error::FederationError; use crate::operation::DirectiveList; -use crate::operation::NamedFragments; use crate::operation::Selection; use crate::operation::SelectionMap; use crate::operation::SelectionMapperReturn; use crate::operation::SelectionSet; -use crate::query_graph::graph_path::OpPathElement; +use crate::query_graph::graph_path::operation::OpPathElement; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub(crate) enum ConditionKind { @@ -35,17 +35,50 @@ impl ConditionKind { } } -/// This struct is meant for tracking whether a selection set in a `FetchDependencyGraphNode` needs +impl Display for ConditionKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_str().fmt(f) + } +} + +/// Represents a combined set of conditions. +/// +/// This struct is meant for tracking whether a selection set in a [FetchDependencyGraphNode] needs /// to be queried, based on the `@skip`/`@include` applications on the selections within. -/// Accordingly, there is much logic around merging and short-circuiting; `OperationConditional` is +/// Accordingly, there is much logic around merging and short-circuiting; [OperationConditional] is /// the more appropriate struct when trying to record the original structure/intent of those /// `@skip`/`@include` applications. +/// +/// [FetchDependencyGraphNode]: crate::query_plan::fetch_dependency_graph::FetchDependencyGraphNode +/// [OperationConditional]: crate::link::graphql_definition::OperationConditional #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) enum Conditions { Variables(VariableConditions), Boolean(bool), } +impl Display for Conditions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // This uses GraphQL directive syntax. + // Add brackets to distinguish it from a real directive list. + write!(f, "[")?; + + match self { + Conditions::Boolean(constant) => write!(f, "{constant:?}")?, + Conditions::Variables(variables) => { + for (index, (name, kind)) in variables.iter().enumerate() { + if index > 0 { + write!(f, " ")?; + } + write!(f, "@{kind}(if: ${name})")?; + } + } + } + + write!(f, "]") + } +} + /// A list of variable conditions, represented as a map from variable names to whether that variable /// is negated in the condition. We maintain the invariant that there's at least one condition (i.e. /// the map is non-empty), and that there's at most one condition per variable name. @@ -96,23 +129,28 @@ impl VariableConditions { } } -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct VariableCondition { - variable: Name, - kind: ConditionKind, -} - impl Conditions { - /// Create conditions from a map of variable conditions. If empty, instead returns a - /// condition that always evaluates to true. + /// Create conditions from a map of variable conditions. + /// + /// If empty, instead returns a condition that always evaluates to true. fn from_variables(map: IndexMap) -> Self { if map.is_empty() { - Self::Boolean(true) + Self::always() } else { Self::Variables(VariableConditions::new_unchecked(map)) } } + /// Create conditions that always evaluate to true. + pub(crate) const fn always() -> Self { + Self::Boolean(true) + } + + /// Create conditions that always evaluate to false. + pub(crate) const fn never() -> Self { + Self::Boolean(false) + } + /// Parse @skip and @include conditions from a directive list. /// /// # Errors @@ -127,7 +165,7 @@ impl Conditions { match value.as_ref() { // Constant @skip(if: true) can never match - Value::Boolean(true) => return Ok(Self::Boolean(false)), + Value::Boolean(true) => return Ok(Self::never()), // Constant @skip(if: false) always matches Value::Boolean(_) => {} Value::Variable(name) => { @@ -146,7 +184,7 @@ impl Conditions { match value.as_ref() { // Constant @include(if: false) can never match - Value::Boolean(false) => return Ok(Self::Boolean(false)), + Value::Boolean(false) => return Ok(Self::never()), // Constant @include(if: true) always matches Value::Boolean(true) => {} // If both @skip(if: $var) and @include(if: $var) exist, the condition can also @@ -155,7 +193,7 @@ impl Conditions { if variables.insert(name.clone(), ConditionKind::Include) == Some(ConditionKind::Skip) { - return Ok(Self::Boolean(false)); + return Ok(Self::never()); } } _ => { @@ -167,10 +205,34 @@ impl Conditions { Ok(Self::from_variables(variables)) } - // TODO(@goto-bus-stop): what exactly is the difference between this and `Self::merge`? - pub(crate) fn update_with(&self, new_conditions: &Self) -> Self { - match (new_conditions, self) { - (Conditions::Boolean(_), _) | (_, Conditions::Boolean(_)) => new_conditions.clone(), + /// Returns a new set of conditions that omits those conditions that are already handled by the + /// argument. + /// + /// For example, if we have a selection set like so: + /// ```graphql + /// { + /// a @skip(if: $a) { + /// b @skip(if: $a) @include(if: $b) { + /// c + /// } + /// } + /// } + /// ``` + /// Then we may call `b.conditions().update_with( a.conditions() )`, and get: + /// ```graphql + /// { + /// a @skip(if: $a) { + /// b @include(if: $b) { + /// c + /// } + /// } + /// } + /// ``` + /// because the `@skip(if: $a)` condition in `b` must always match, as implied by + /// being nested inside `a`. + pub(crate) fn update_with(&self, handled_conditions: &Self) -> Self { + match (self, handled_conditions) { + (Conditions::Boolean(_), _) | (_, Conditions::Boolean(_)) => self.clone(), (Conditions::Variables(new_conditions), Conditions::Variables(handled_conditions)) => { let mut filtered = IndexMap::default(); for (cond_name, &cond_kind) in new_conditions.0.iter() { @@ -179,7 +241,7 @@ impl Conditions { // If we've already handled that exact condition, we can skip it. // But if we've already handled the _negation_ of this condition, then this mean the overall conditions // are unreachable and we can just return `false` directly. - return Conditions::Boolean(false); + return Conditions::never(); } Some(_) => {} None => { @@ -198,7 +260,7 @@ impl Conditions { match (self, other) { // Absorbing element (Conditions::Boolean(false), _) | (_, Conditions::Boolean(false)) => { - Conditions::Boolean(false) + Conditions::never() } // Neutral element @@ -207,7 +269,7 @@ impl Conditions { (Conditions::Variables(self_vars), Conditions::Variables(other_vars)) => { match self_vars.merge(other_vars) { Some(vars) => Conditions::Variables(vars), - None => Conditions::Boolean(false), + None => Conditions::never(), } } } @@ -228,8 +290,8 @@ pub(crate) fn remove_conditions_from_selection_set( Ok(selection_set.clone()) } Conditions::Variables(variable_conditions) => { - selection_set.lazy_map(&NamedFragments::default(), |selection| { - let element = selection.element()?; + selection_set.lazy_map(|selection| { + let element = selection.element(); // We remove any of the conditions on the element and recurse. let updated_element = remove_conditions_of_element(element.clone(), variable_conditions); @@ -313,10 +375,6 @@ pub(crate) fn remove_unneeded_top_level_fragment_directives( } } } - _ => { - // TODO should we apply same logic as for inline_fragment "just in case"? - return Err(FederationError::internal("unexpected fragment spread")); - } } } @@ -366,3 +424,168 @@ fn matches_condition_for_kind( None => false, } } + +#[cfg(test)] +mod tests { + use apollo_compiler::ExecutableDocument; + use apollo_compiler::Schema; + + use super::*; + + fn parse(directives: &str) -> Conditions { + let schema = + Schema::parse_and_validate("type Query { a: String }", "schema.graphql").unwrap(); + let doc = + ExecutableDocument::parse(&schema, format!("{{ a {directives} }}"), "query.graphql") + .unwrap(); + let operation = doc.operations.get(None).unwrap(); + let directives = operation.selection_set.selections[0].directives(); + Conditions::from_directives(&DirectiveList::from(directives.clone())).unwrap() + } + + #[test] + fn merge_conditions() { + assert_eq!( + parse("@skip(if: $a)") + .merge(parse("@include(if: $b)")) + .to_string(), + "[@skip(if: $a) @include(if: $b)]", + "combine skip/include" + ); + assert_eq!( + parse("@skip(if: $a)") + .merge(parse("@skip(if: $b)")) + .to_string(), + "[@skip(if: $a) @skip(if: $b)]", + "combine multiple skips" + ); + assert_eq!( + parse("@include(if: $a)") + .merge(parse("@include(if: $b)")) + .to_string(), + "[@include(if: $a) @include(if: $b)]", + "combine multiple includes" + ); + assert_eq!( + parse("@skip(if: $a)").merge(parse("@include(if: $a)")), + Conditions::never(), + "skip/include with same variable conflicts" + ); + assert_eq!( + parse("@skip(if: $a)").merge(Conditions::always()), + parse("@skip(if: $a)"), + "merge with `true` returns original" + ); + assert_eq!( + Conditions::always().merge(Conditions::always()), + Conditions::always(), + "merge with `true` returns original" + ); + assert_eq!( + parse("@skip(if: $a)").merge(Conditions::never()), + Conditions::never(), + "merge with `false` returns `false`" + ); + assert_eq!( + parse("@include(if: $a)").merge(Conditions::never()), + Conditions::never(), + "merge with `false` returns `false`" + ); + assert_eq!( + Conditions::always().merge(Conditions::never()), + Conditions::never(), + "merge with `false` returns `false`" + ); + assert_eq!( + parse("@skip(if: true)").merge(parse("@include(if: $a)")), + Conditions::never(), + "@skip with hardcoded if: true can never evaluate to true" + ); + assert_eq!( + parse("@skip(if: false)").merge(parse("@include(if: $a)")), + parse("@include(if: $a)"), + "@skip with hardcoded if: false returns other side" + ); + assert_eq!( + parse("@include(if: true)").merge(parse("@include(if: $a)")), + parse("@include(if: $a)"), + "@include with hardcoded if: true returns other side" + ); + assert_eq!( + parse("@include(if: false)").merge(parse("@include(if: $a)")), + Conditions::never(), + "@include with hardcoded if: false can never evaluate to true" + ); + } + + #[test] + fn update_conditions() { + assert_eq!( + parse("@skip(if: $a)") + .merge(parse("@include(if: $b)")) + .update_with(&parse("@include(if: $b)")), + parse("@skip(if: $a)"), + "trim @include(if:) condition" + ); + assert_eq!( + parse("@skip(if: $a)") + .merge(parse("@include(if: $b)")) + .update_with(&parse("@skip(if: $a)")), + parse("@include(if: $b)"), + "trim @skip(if:) condition" + ); + + let list = parse("@skip(if: $a)") + .merge(parse("@skip(if: $b)")) + .merge(parse("@skip(if: $c)")) + .merge(parse("@skip(if: $d)")) + .merge(parse("@skip(if: $e)")); + let handled = parse("@skip(if: $b)").merge(parse("@skip(if: $e)")); + assert_eq!( + list.update_with(&handled), + parse("@skip(if: $a)") + .merge(parse("@skip(if: $c)")) + .merge(parse("@skip(if: $d)")), + "trim multiple conditions" + ); + + let list = parse("@include(if: $a)") + .merge(parse("@include(if: $b)")) + .merge(parse("@include(if: $c)")) + .merge(parse("@include(if: $d)")) + .merge(parse("@include(if: $e)")); + let handled = parse("@include(if: $b)").merge(parse("@include(if: $e)")); + assert_eq!( + list.update_with(&handled), + parse("@include(if: $a)") + .merge(parse("@include(if: $c)")) + .merge(parse("@include(if: $d)")), + "trim multiple conditions" + ); + + let list = parse("@include(if: $a)") + .merge(parse("@include(if: $b)")) + .merge(parse("@include(if: $c)")) + .merge(parse("@include(if: $d)")) + .merge(parse("@include(if: $e)")); + // It may technically be correct to return `never()` here? + // But the result for query planning is the same either way, as these conditions will never + // be reached. + assert_eq!( + list.update_with(&Conditions::never()), + list, + "update with constant does not affect conditions" + ); + + let list = parse("@include(if: $a)") + .merge(parse("@include(if: $b)")) + .merge(parse("@include(if: $c)")) + .merge(parse("@include(if: $d)")) + .merge(parse("@include(if: $e)")); + assert_eq!( + list.update_with(&Conditions::always()), + list, + "update with constant does not affect conditions" + ); + } +} diff --git a/apollo-federation/src/query_plan/display.rs b/apollo-federation/src/query_plan/display.rs index b6416590e1..2ccce25a36 100644 --- a/apollo-federation/src/query_plan/display.rs +++ b/apollo-federation/src/query_plan/display.rs @@ -3,8 +3,8 @@ use std::fmt; use apollo_compiler::executable; use super::*; -use crate::display_helpers::write_indented_lines; use crate::display_helpers::State; +use crate::display_helpers::write_indented_lines; impl QueryPlan { fn write_indented(&self, state: &mut State<'_, '_>) -> fmt::Result { @@ -96,14 +96,15 @@ impl FetchNode { state.write(") {")?; state.indent()?; - if let Some(v) = requires.as_ref() { - if !v.is_empty() { - write_selections(state, v)?; - state.write(" =>")?; - state.new_line()?; - } + if !requires.is_empty() { + write_requires_selections(state, requires)?; + state.write(" =>")?; + state.new_line()?; } - write_operation(state, operation_document)?; + write_operation( + state, + operation_document.as_parsed().map_err(|_| fmt::Error)?, + )?; state.dedent()?; state.write("},") @@ -236,16 +237,8 @@ impl PrimaryDeferBlock { state.write("Primary {")?; if sub_selection.is_some() || node.is_some() { if let Some(sub_selection) = sub_selection { - // Manually indent and write the newline - // to prevent a duplicate indent from `.new_line()` and `.initial_indent_level()`. - state.indent_no_new_line(); - state.write("\n")?; - - state.write( - sub_selection - .serialize() - .initial_indent_level(state.indent_level()), - )?; + state.indent()?; + state.write(sub_selection)?; if node.is_some() { state.write(":")?; state.new_line()?; @@ -301,7 +294,7 @@ impl DeferredDeferBlock { state.indent()?; if let Some(sub_selection) = sub_selection { - write_selections(state, &sub_selection.selections)?; + state.write(sub_selection)?; state.write(":")?; } if sub_selection.is_some() && node.is_some() { @@ -345,10 +338,10 @@ fn write_selections( state: &mut State<'_, '_>, mut selections: &[executable::Selection], ) -> fmt::Result { - if let Some(executable::Selection::Field(field)) = selections.first() { - if field.name == "_entities" { - selections = &field.selection_set.selections - } + if let Some(executable::Selection::Field(field)) = selections.first() + && field.name == "_entities" + { + selections = &field.selection_set.selections } state.write("{")?; @@ -364,6 +357,70 @@ fn write_selections( state.write("}") } +fn write_requires_selections( + state: &mut State<'_, '_>, + selections: &[requires_selection::Selection], +) -> fmt::Result { + state.write("{")?; + + // Manually indent and write the newline + // to prevent a duplicate indent from `.new_line()` and `.initial_indent_level()`. + state.indent()?; + if let Some((first, rest)) = selections.split_first() { + write_requires_selection(state, first)?; + for sel in rest { + state.new_line()?; + write_requires_selection(state, sel)?; + } + } + state.dedent()?; + + state.write("}") +} + +fn write_requires_selection( + state: &mut State<'_, '_>, + selection: &requires_selection::Selection, +) -> fmt::Result { + match selection { + requires_selection::Selection::Field(requires_selection::Field { + alias, + name, + selections, + }) => { + if let Some(alias) = alias { + state.write(alias)?; + state.write(": ")?; + } + state.write(name)?; + if !selections.is_empty() { + state.write(" ")?; + write_requires_selections(state, selections)?; + } + } + requires_selection::Selection::InlineFragment(requires_selection::InlineFragment { + type_condition, + selections, + }) => { + if let Some(type_name) = type_condition { + state.write("... on ")?; + state.write(type_name)?; + state.write(" ")?; + } else { + state.write("... ")?; + } + write_requires_selections(state, selections)?; + } + } + Ok(()) +} + +impl fmt::Display for requires_selection::Selection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write_requires_selection(&mut State::new(f), self) + } +} + /// PORT_NOTE: Corresponds to `GroupPath.updatedResponsePath` in `buildPlan.ts` impl fmt::Display for FetchDataPathElement { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -377,12 +434,13 @@ impl fmt::Display for FetchDataPathElement { write_conditions(conditions, f) } Self::TypenameEquals(name) => write!(f, "... on {name}"), + Self::Parent => write!(f, ".."), } } } -fn write_conditions(conditions: &[Name], f: &mut fmt::Formatter<'_>) -> fmt::Result { - if !conditions.is_empty() { +fn write_conditions(conditions: &Option>, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(conditions) = conditions { write!(f, "|[{}]", conditions.join(",")) } else { Ok(()) @@ -392,14 +450,8 @@ fn write_conditions(conditions: &[Name], f: &mut fmt::Formatter<'_>) -> fmt::Res impl fmt::Display for QueryPathElement { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Field(field) => f.write_str(field.response_key()), - Self::InlineFragment(inline) => { - if let Some(cond) = &inline.type_condition { - write!(f, "... on {cond}") - } else { - Ok(()) - } - } + Self::Field { response_key } => f.write_str(response_key), + Self::InlineFragment { type_condition } => write!(f, "... on {type_condition}"), } } } diff --git a/apollo-federation/src/query_plan/fetch_dependency_graph.rs b/apollo-federation/src/query_plan/fetch_dependency_graph.rs index ffffcba928..600c3484a5 100644 --- a/apollo-federation/src/query_plan/fetch_dependency_graph.rs +++ b/apollo-federation/src/query_plan/fetch_dependency_graph.rs @@ -1,23 +1,21 @@ use std::fmt::Write as _; use std::iter; use std::ops::Deref; -use std::sync::atomic::AtomicU64; use std::sync::Arc; use std::sync::OnceLock; +use std::sync::atomic::AtomicU64; +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::ast::Argument; use apollo_compiler::ast::Directive; use apollo_compiler::ast::OperationType; use apollo_compiler::ast::Type; -use apollo_compiler::collections::HashMap; use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use apollo_compiler::executable; use apollo_compiler::executable::VariableDefinition; use apollo_compiler::name; -use apollo_compiler::schema; -use apollo_compiler::Name; -use apollo_compiler::Node; use itertools::Itertools; use multimap::MultiMap; use petgraph::stable_graph::EdgeIndex; @@ -27,7 +25,9 @@ use petgraph::visit::EdgeRef; use petgraph::visit::IntoNodeReferences; use serde::Serialize; +use super::FetchDataKeyRenamer; use super::query_planner::SubgraphOperationCompression; +use crate::bail; use crate::display_helpers::DisplayOption; use crate::error::FederationError; use crate::error::SingleFederationError; @@ -43,27 +43,30 @@ use crate::operation::Selection; use crate::operation::SelectionId; use crate::operation::SelectionMap; use crate::operation::SelectionSet; -use crate::operation::VariableCollector; use crate::operation::TYPENAME_FIELD; -use crate::query_graph::graph_path::concat_op_paths; -use crate::query_graph::graph_path::concat_paths_in_parents; -use crate::query_graph::graph_path::OpGraphPathContext; -use crate::query_graph::graph_path::OpGraphPathTrigger; -use crate::query_graph::graph_path::OpPath; -use crate::query_graph::graph_path::OpPathElement; -use crate::query_graph::path_tree::OpPathTree; -use crate::query_graph::path_tree::PathTreeChild; +use crate::operation::VariableCollector; use crate::query_graph::QueryGraph; use crate::query_graph::QueryGraphEdgeTransition; use crate::query_graph::QueryGraphNodeType; -use crate::query_plan::conditions::remove_conditions_from_selection_set; -use crate::query_plan::conditions::remove_unneeded_top_level_fragment_directives; -use crate::query_plan::conditions::Conditions; -use crate::query_plan::fetch_dependency_graph_processor::FetchDependencyGraphProcessor; +use crate::query_graph::graph_path::operation::OpGraphPathContext; +use crate::query_graph::graph_path::operation::OpGraphPathTrigger; +use crate::query_graph::graph_path::operation::OpPath; +use crate::query_graph::graph_path::operation::OpPathElement; +use crate::query_graph::graph_path::operation::concat_op_paths; +use crate::query_graph::graph_path::operation::concat_paths_in_parents; +use crate::query_graph::path_tree::OpPathTree; +use crate::query_graph::path_tree::PathTreeChild; use crate::query_plan::FetchDataPathElement; use crate::query_plan::FetchDataRewrite; use crate::query_plan::FetchDataValueSetter; use crate::query_plan::QueryPlanCost; +use crate::query_plan::conditions::Conditions; +use crate::query_plan::conditions::remove_conditions_from_selection_set; +use crate::query_plan::conditions::remove_unneeded_top_level_fragment_directives; +use crate::query_plan::fetch_dependency_graph_processor::FetchDependencyGraphProcessor; +use crate::query_plan::requires_selection; +use crate::query_plan::serializable_document::SerializableDocument; +use crate::schema::ValidFederationSchema; use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::FieldDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; @@ -71,11 +74,11 @@ use crate::schema::position::OutputTypeDefinitionPosition; use crate::schema::position::PositionLookupError; use crate::schema::position::SchemaRootDefinitionKind; use crate::schema::position::TypeDefinitionPosition; -use crate::schema::ValidFederationSchema; use crate::subgraph::spec::ANY_SCALAR_NAME; use crate::subgraph::spec::ENTITIES_QUERY; use crate::supergraph::FEDERATION_REPRESENTATIONS_ARGUMENTS_NAME; use crate::supergraph::FEDERATION_REPRESENTATIONS_VAR_NAME; +use crate::utils::iter_into_single_item; use crate::utils::logging::snapshot; /// Represents the value of a `@defer(label:)` argument. @@ -86,7 +89,7 @@ type DeferRef = String; /// Like a multimap with a Set instead of a Vec for value storage. #[derive(Debug, Clone, Default)] struct DeferredNodes { - inner: HashMap>>, + inner: IndexMap>>, } impl DeferredNodes { fn new() -> Self { @@ -108,7 +111,7 @@ impl DeferredNodes { fn iter(&self) -> impl Iterator)> { self.inner .iter() - .flat_map(|(defer_ref, nodes)| std::iter::repeat(defer_ref).zip(nodes.iter().copied())) + .flat_map(|(defer_ref, nodes)| iter::repeat(defer_ref).zip(nodes.iter().copied())) } /// Consume the map and yield each element. This is provided as a standalone method and not an @@ -117,7 +120,7 @@ impl DeferredNodes { self.inner.into_iter().flat_map(|(defer_ref, nodes)| { // Cloning the key is a bit wasteful, but keys are typically very small, // and this map is also very small. - std::iter::repeat_with(move || defer_ref.clone()).zip(nodes) + iter::repeat_with(move || defer_ref.clone()).zip(nodes) }) } } @@ -167,6 +170,8 @@ pub(crate) struct FetchDependencyGraphNode { inputs: Option>, /// Input rewrites for query plan execution to perform prior to executing the fetch. input_rewrites: Arc>>, + /// Rewrites that will need to occur to store contextual data for future use + context_inputs: Vec, /// As query plan execution runs, it accumulates fetch data into a response object. This is the /// path at which to merge in the data for this particular fetch. merge_at: Option>, @@ -210,9 +215,10 @@ impl FetchIdGenerator { pub(crate) struct FetchSelectionSet { /// The selection set to be fetched from the subgraph. pub(crate) selection_set: Arc, - /// The conditions determining whether the fetch should be executed (which must be recomputed - /// from the selection set when it changes). - pub(crate) conditions: Conditions, + /// The conditions determining whether the fetch should be executed, derived from the selection + /// set. + #[serde(skip)] + conditions: OnceLock, } // PORT_NOTE: The JS codebase additionally has a property `onUpdateCallback`. This was only ever @@ -225,6 +231,8 @@ pub(crate) struct FetchInputs { /// The supergraph schema (primarily used for validation of added selection sets). #[serde(skip)] supergraph_schema: ValidFederationSchema, + /// Contexts used as inputs + used_contexts: IndexMap>, } /// Represents a dependency between two subgraph fetches, namely that the tail/child depends on the @@ -501,15 +509,14 @@ impl FetchDependencyGraphNodePath { type_conditioned_fetching_enabled: bool, root_type: CompositeTypeDefinitionPosition, ) -> Result { - let root_possible_types = if type_conditioned_fetching_enabled { + let root_possible_types: IndexSet = if type_conditioned_fetching_enabled { schema.possible_runtime_types(root_type)? } else { Default::default() } .into_iter() - .map(|pos| Ok(pos.get(schema.schema())?.name.clone())) - .collect::, _>>() - .map_err(|e: PositionLookupError| FederationError::from(e))?; + .map(|pos| Ok::<_, PositionLookupError>(pos.get(schema.schema())?.name.clone())) + .process_results(|c| c.sorted().collect())?; Ok(Self { schema, @@ -569,12 +576,13 @@ impl FetchDependencyGraphNodePath { None => self.possible_types.clone(), Some(tcp) => { let element_possible_types = self.schema.possible_runtime_types(tcp.clone())?; - element_possible_types + self.possible_types .iter() .filter(|&possible_type| { - self.possible_types.contains(&possible_type.type_name) + element_possible_types + .contains(&ObjectTypeDefinitionPosition::new(possible_type.clone())) }) - .map(|possible_type| possible_type.type_name.clone()) + .cloned() .collect() } }, @@ -584,15 +592,11 @@ impl FetchDependencyGraphNodePath { } fn advance_field_type(&self, element: &Field) -> Result, FederationError> { - if !element - .output_base_type() - .map(|base_type| base_type.is_composite_type()) - .unwrap_or_default() - { + if !element.output_base_type()?.is_composite_type() { return Ok(Default::default()); } - let mut res = self + let mut res: IndexSet = self .possible_types .clone() .into_iter() @@ -608,17 +612,13 @@ impl FetchDependencyGraphNodePath { .schema .possible_runtime_types(typ)? .into_iter() - .map(|ctdp| ctdp.type_name) - .collect::>()) + .map(|ctdp| ctdp.type_name)) }) - .collect::>, FederationError>>()? - .into_iter() - .flatten() - .collect::>(); + .process_results::<_, _, FederationError, _>(|c| c.flatten().collect())?; res.sort(); - Ok(res.into_iter().collect()) + Ok(res) } fn updated_response_path( @@ -641,17 +641,22 @@ impl FetchDependencyGraphNodePath { match new_path.pop() { Some(FetchDataPathElement::AnyIndex(_)) => { - new_path.push(FetchDataPathElement::AnyIndex( + new_path.push(FetchDataPathElement::AnyIndex(Some( conditions.iter().cloned().collect(), - )); + ))); } Some(FetchDataPathElement::Key(name, _)) => { new_path.push(FetchDataPathElement::Key( name, - conditions.iter().cloned().collect(), + Some(conditions.iter().cloned().collect()), )); } Some(other) => new_path.push(other), + // TODO: We should be emitting type conditions here on no element like the + // JS code, which requires a new FetchDataPathElement variant in Rust. + // This really has to do with a hack we did to avoid changing fetch + // data paths too much, in which type conditions ought to be their own + // variant entirely. None => {} } } @@ -665,8 +670,8 @@ impl FetchDependencyGraphNodePath { let mut type_ = &field.field_position.get(field.schema.schema())?.ty; loop { match type_ { - schema::Type::Named(_) | schema::Type::NonNullNamed(_) => break, - schema::Type::List(inner) | schema::Type::NonNullList(inner) => { + Type::Named(_) | Type::NonNullNamed(_) => break, + Type::List(inner) | Type::NonNullList(inner) => { new_path.push(FetchDataPathElement::AnyIndex(Default::default())); type_ = inner } @@ -679,16 +684,6 @@ impl FetchDependencyGraphNodePath { } } -/// If the `iter` yields a single element, return it. Else return `None`. -fn iter_into_single_item(mut iter: impl Iterator) -> Option { - let item = iter.next()?; - if iter.next().is_none() { - Some(item) - } else { - None - } -} - impl FetchDependencyGraph { pub(crate) fn new( supergraph_schema: ValidFederationSchema, @@ -791,6 +786,7 @@ impl FetchDependencyGraph { cached_cost: None, must_preserve_selection_set: false, is_known_useful: false, + context_inputs: Vec::new(), }))) } @@ -826,7 +822,7 @@ impl FetchDependencyGraph { // 1. is for the same subgraph // 2. has the same merge_at // 3. is for the same entity type (we don't reuse nodes for different entities just yet, - // as this can create unecessary dependencies that gets in the way of some optimizations; + // as this can create unnecessary dependencies that gets in the way of some optimizations; // the final optimizations in `reduceAndOptimize` will however later merge nodes // on the same subgraph and mergeAt when possible). // 4. is not part of our conditions or our conditions ancestors @@ -919,7 +915,7 @@ impl FetchDependencyGraph { parent_node_id, child_id, Arc::new(FetchDependencyGraphEdge { - path: path_in_parent.clone(), + path: path_in_parent, }), ); } @@ -944,7 +940,7 @@ impl FetchDependencyGraph { return true; } - // No risk of inifite loop as the graph is acyclic: + // No risk of infinite loop as the graph is acyclic: let mut to_check = haystack.clone(); while let Some(next) = to_check.pop() { for parent in self.parents_of(next) { @@ -1024,7 +1020,7 @@ impl FetchDependencyGraph { Ok(parent_inputs.contains(node_inputs)) } - fn children_of(&self, node_id: NodeIndex) -> impl Iterator + '_ { + fn children_of(&self, node_id: NodeIndex) -> impl Iterator { self.graph .neighbors_directed(node_id, petgraph::Direction::Outgoing) } @@ -1038,15 +1034,12 @@ impl FetchDependencyGraph { .find(|p| p.parent_node_id == maybe_parent_id) } - fn parents_of(&self, node_id: NodeIndex) -> impl Iterator + '_ { + fn parents_of(&self, node_id: NodeIndex) -> impl Iterator { self.graph .neighbors_directed(node_id, petgraph::Direction::Incoming) } - fn parents_relations_of( - &self, - node_id: NodeIndex, - ) -> impl Iterator + '_ { + fn parents_relations_of(&self, node_id: NodeIndex) -> impl Iterator { self.graph .edges_directed(node_id, petgraph::Direction::Incoming) .map(|edge| ParentRelation { @@ -1070,9 +1063,7 @@ impl FetchDependencyGraph { /// largest ID is the last node that was created. Due to the above, sorting by node IDs may still /// result in different iteration order than the JS code, but in practice might be enough to /// ensure correct plans. - fn sorted_nodes<'graph>( - nodes: impl Iterator + 'graph, - ) -> impl Iterator + 'graph { + fn sorted_nodes(nodes: impl Iterator) -> impl Iterator { nodes.sorted_by_key(|n| n.index()) } @@ -1347,10 +1338,6 @@ impl FetchDependencyGraph { // Some helper functions let try_get_type_condition = |selection: &Selection| match selection { - Selection::FragmentSpread(fragment) => { - Some(fragment.spread.type_condition_position.clone()) - } - Selection::InlineFragment(inline) => { inline.inline_fragment.type_condition_position.clone() } @@ -1711,7 +1698,7 @@ impl FetchDependencyGraph { children.push(child_index); } else { let Some(child_defer_ref) = &child.defer_ref else { - panic!( + bail!( "{} has defer_ref `{}`, so its child {} cannot have a top-level defer_ref.", node.display(node_index), DisplayOption(node.defer_ref.as_ref()), @@ -1773,7 +1760,10 @@ impl FetchDependencyGraph { .graph .node_weight_mut(node_index) .ok_or_else(|| FederationError::internal("Node unexpectedly missing"))?; - let conditions = handled_conditions.update_with(&node.selection_set.conditions); + let conditions = node + .selection_set + .conditions()? + .update_with(&handled_conditions); let new_handled_conditions = conditions.clone().merge(handled_conditions); let processed = processor.on_node( @@ -1808,7 +1798,7 @@ impl FetchDependencyGraph { )?; let reduced_sequence = - processor.reduce_sequence(std::iter::once(processed).chain(main_sequence)); + processor.reduce_sequence(iter::once(processed).chain(main_sequence)); Ok(( processor.on_conditions(&conditions, reduced_sequence), all_deferred_nodes, @@ -2343,11 +2333,11 @@ impl FetchDependencyGraph { &parent.selection_set.selection_set.schema, parent_op_path, )?; - let new_node_is_unneeded = node + let node_is_unneeded = node .selection_set .selection_set .can_rebase_on(&type_at_path, &parent.selection_set.selection_set.schema)?; - Ok(new_node_is_unneeded) + Ok(node_is_unneeded) } fn type_at_path( @@ -2369,8 +2359,7 @@ impl FetchDependencyGraph { .map_or_else( |_| { Err(FederationError::internal(format!( - "Invalid call from {} starting at {}: {} is not composite", - path, parent_type, field_position + "Invalid call from {path} starting at {parent_type}: {field_position} is not composite" ))) }, Ok, @@ -2384,8 +2373,7 @@ impl FetchDependencyGraph { .map_or_else( |_| { Err(FederationError::internal(format!( - "Invalid call from {} starting at {}: {} is not composite", - path, parent_type, type_condition_position + "Invalid call from {path} starting at {parent_type}: {type_condition_position} is not composite" ))) }, Ok, @@ -2510,6 +2498,14 @@ impl FetchDependencyGraphNode { Ok(()) } + fn add_input_context(&mut self, context: Name, ty: Node) -> Result<(), FederationError> { + let Some(inputs) = &mut self.inputs else { + bail!("Shouldn't try to add inputs to a root fetch node") + }; + Arc::make_mut(inputs).add_context(context, ty); + Ok(()) + } + fn copy_inputs(&mut self, other: &FetchDependencyGraphNode) -> Result<(), FederationError> { if let Some(other_inputs) = other.inputs.clone() { let inputs = self.inputs.get_or_insert_with(|| { @@ -2522,6 +2518,10 @@ impl FetchDependencyGraphNode { for rewrite in other.input_rewrites.iter() { input_rewrites.push(rewrite.clone()); } + + for context_input in &other.context_inputs { + self.add_context_renamer(context_input.clone()); + } } Ok(()) } @@ -2562,11 +2562,11 @@ impl FetchDependencyGraphNode { } } - pub(crate) fn cost(&mut self) -> Result { + pub(crate) fn cost(&mut self) -> QueryPlanCost { if self.cached_cost.is_none() { - self.cached_cost = Some(self.selection_set.selection_set.cost(1.0)?) + self.cached_cost = Some(self.selection_set.selection_set.cost(1.0)) } - Ok(self.cached_cost.unwrap()) + self.cached_cost.unwrap() } pub(crate) fn to_plan_node( @@ -2581,14 +2581,29 @@ impl FetchDependencyGraphNode { if self.selection_set.selection_set.selections.is_empty() { return Ok(None); } + let context_variable_definitions = self.inputs.iter().flat_map(|inputs| { + inputs.used_contexts.iter().map(|(context, ty)| { + Node::new(VariableDefinition { + name: context.clone(), + ty: ty.clone(), + default_value: None, + directives: Default::default(), + }) + }) + }); + let variable_definitions = variable_definitions + .iter() + .cloned() + .chain(context_variable_definitions) + .collect::>(); let (selection, output_rewrites) = - self.finalize_selection(variable_definitions, handled_conditions)?; + self.finalize_selection(&variable_definitions, handled_conditions)?; let input_nodes = self .inputs .as_ref() .map(|inputs| { inputs.to_selection_set_nodes( - variable_definitions, + &variable_definitions, handled_conditions, &self.parent_type, ) @@ -2636,45 +2651,33 @@ impl FetchDependencyGraphNode { &operation_name, )? }; - let operation = - operation_compression.compress(&self.subgraph_name, subgraph_schema, operation)?; - let operation_document = operation.try_into().map_err(|err| match err { - FederationError::SingleFederationError { - inner: SingleFederationError::InvalidGraphQL { diagnostics }, - .. - } => FederationError::internal(format!( - "Query planning produced an invalid subgraph operation.\n{diagnostics}" - )), - _ => err, - })?; + let operation_document = operation_compression.compress(operation)?; // this function removes unnecessary pieces of the query plan requires selection set. // PORT NOTE: this function was called trimSelectioNodes in the JS implementation fn trim_requires_selection_set( selection_set: &executable::SelectionSet, - ) -> Vec { + ) -> Vec { selection_set .selections .iter() .filter_map(|s| match s { - executable::Selection::Field(field) => Some(executable::Selection::from( - executable::Field::new(field.name.clone(), field.definition.clone()) - .with_selections(trim_requires_selection_set(&field.selection_set)), - )), + executable::Selection::Field(field) => Some( + requires_selection::Selection::Field(requires_selection::Field { + alias: None, + name: field.name.clone(), + selections: trim_requires_selection_set(&field.selection_set), + }), + ), executable::Selection::InlineFragment(inline_fragment) => { - let new_fragment = inline_fragment - .type_condition - .clone() - .map(executable::InlineFragment::with_type_condition) - .unwrap_or_else(|| { - executable::InlineFragment::without_type_condition( - inline_fragment.selection_set.ty.clone(), - ) - }) - .with_selections(trim_requires_selection_set( - &inline_fragment.selection_set, - )); - Some(executable::Selection::from(new_fragment)) + Some(requires_selection::Selection::InlineFragment( + requires_selection::InlineFragment { + type_condition: inline_fragment.type_condition.clone(), + selections: trim_requires_selection_set( + &inline_fragment.selection_set, + ), + }, + )) } executable::Selection::FragmentSpread(_) => None, }) @@ -2688,13 +2691,19 @@ impl FetchDependencyGraphNode { .as_ref() .map(executable::SelectionSet::try_from) .transpose()? - .map(|selection_set| trim_requires_selection_set(&selection_set)), - operation_document, + .map(|selection_set| trim_requires_selection_set(&selection_set)) + .unwrap_or_default(), + operation_document: SerializableDocument::from_parsed(operation_document), operation_name, operation_kind: self.root_kind.into(), input_rewrites: self.input_rewrites.clone(), output_rewrites, - context_rewrites: Default::default(), + context_rewrites: self + .context_inputs + .iter() + .cloned() + .map(|r| Arc::new(r.into())) + .collect(), })); Ok(Some(if let Some(path) = self.merge_at.clone() { @@ -2746,7 +2755,7 @@ impl FetchDependencyGraphNode { /// Return a concise display for this node. The node index in the graph /// must be passed in externally. - fn display(&self, index: NodeIndex) -> impl std::fmt::Display + '_ { + fn display(&self, index: NodeIndex) -> impl std::fmt::Display { use std::fmt; use std::fmt::Display; use std::fmt::Formatter; @@ -2817,7 +2826,7 @@ impl FetchDependencyGraphNode { // A variation of `fn display` with multiline output, which is more suitable for // GraphViz output. - pub(crate) fn multiline_display(&self, index: NodeIndex) -> impl std::fmt::Display + '_ { + pub(crate) fn multiline_display(&self, index: NodeIndex) -> impl std::fmt::Display { use std::fmt; use std::fmt::Display; use std::fmt::Formatter; @@ -2909,6 +2918,84 @@ impl FetchDependencyGraphNode { }; Some(format!("{subgraph_name}-{merge_at_str}")) } + + fn add_context_renamer(&mut self, renamer: FetchDataKeyRenamer) { + // XXX(@goto-bus-stop): this looks like it should be an IndexSet! + if !self.context_inputs.contains(&renamer) { + self.context_inputs.push(renamer); + } + } + + fn add_context_renamers_for_selection_set( + &mut self, + selection_set: Option<&SelectionSet>, + relative_path: Vec, + alias: Name, + ) -> Result<(), FederationError> { + let selection_set = match selection_set { + Some(selection_set) if !selection_set.is_empty() => selection_set, + _ => { + self.add_context_renamer(FetchDataKeyRenamer { + path: relative_path, + rename_key_to: alias, + }); + return Ok(()); + } + }; + + for selection in selection_set { + match selection { + Selection::Field(field_selection) => { + if matches!(relative_path.last(), Some(FetchDataPathElement::Parent)) + && selection_set.type_position.type_name() != "Query" + { + for possible_runtime_type in selection_set + .schema + .possible_runtime_types(selection_set.type_position.clone())? + { + let mut new_relative_path = relative_path.clone(); + new_relative_path.push(FetchDataPathElement::TypenameEquals( + possible_runtime_type.type_name.clone(), + )); + self.add_context_renamers_for_selection_set( + Some(selection_set), + new_relative_path, + alias.clone(), + )?; + } + } else { + let mut new_relative_path = relative_path.clone(); + new_relative_path.push(FetchDataPathElement::Key( + field_selection.field.field_position.field_name().clone(), + Default::default(), + )); + self.add_context_renamers_for_selection_set( + field_selection.selection_set.as_ref(), + new_relative_path, + alias.clone(), + )?; + } + } + Selection::InlineFragment(inline_fragment_selection) => { + if let Some(type_condition) = &inline_fragment_selection + .inline_fragment + .type_condition_position + { + let mut new_relative_path = relative_path.clone(); + new_relative_path.push(FetchDataPathElement::TypenameEquals( + type_condition.type_name().clone(), + )); + self.add_context_renamers_for_selection_set( + Some(&inline_fragment_selection.selection_set), + new_relative_path, + alias.clone(), + )?; + } + } + } + } + Ok(()) + } } fn operation_for_entities_fetch( @@ -2926,12 +3013,12 @@ fn operation_for_entities_fetch( })?; let query_type = match subgraph_schema.get_type(query_type_name.clone())? { - crate::schema::position::TypeDefinitionPosition::Object(o) => o, + TypeDefinitionPosition::Object(o) => o, _ => { return Err(SingleFederationError::InvalidSubgraph { message: "the root query type must be an object".to_string(), } - .into()) + .into()); } }; @@ -2946,7 +3033,7 @@ fn operation_for_entities_fetch( .into()); } - let entities = FieldDefinitionPosition::Object(query_type.field(ENTITIES_QUERY.clone())); + let entities = FieldDefinitionPosition::Object(query_type.field(ENTITIES_QUERY)); let entities_call = Selection::from_element( OpPathElement::Field(Field { @@ -2983,7 +3070,6 @@ fn operation_for_entities_fetch( variables: Arc::new(variable_definitions), directives: operation_directives.clone(), selection_set, - named_fragments: Default::default(), }) } @@ -3002,7 +3088,6 @@ fn operation_for_query_fetch( variables: Arc::new(variable_definitions), directives: operation_directives.clone(), selection_set, - named_fragments: Default::default(), }) } @@ -3025,7 +3110,7 @@ fn representations_variable_definition( } impl SelectionSet { - pub(crate) fn cost(&self, depth: QueryPlanCost) -> Result { + pub(crate) fn cost(&self, depth: QueryPlanCost) -> QueryPlanCost { // The cost is essentially the number of elements in the selection, // but we make deep element cost a tiny bit more, // mostly to make things a tad more deterministic @@ -3034,22 +3119,17 @@ impl SelectionSet { // and one that doesn't, and both will be almost identical, // except that the type-exploded field will be a different depth; // by favoring lesser depth in that case, we favor not type-exploding). - self.selections.values().try_fold(0.0, |sum, selection| { + self.selections.values().fold(0.0, |sum, selection| { let subselections = match selection { Selection::Field(field) => field.selection_set.as_ref(), Selection::InlineFragment(inline) => Some(&inline.selection_set), - Selection::FragmentSpread(_) => { - return Err(FederationError::internal( - "unexpected fragment spread in FetchDependencyGraphNode", - )) - } }; let subselections_cost = if let Some(selection_set) = subselections { - selection_set.cost(depth + 1.0)? + selection_set.cost(depth + 1.0) } else { 0.0 }; - Ok(sum + depth + subselections_cost) + sum + depth + subselections_cost }) } } @@ -3060,9 +3140,8 @@ impl FetchSelectionSet { type_position: CompositeTypeDefinitionPosition, ) -> Result { let selection_set = Arc::new(SelectionSet::empty(schema, type_position)); - let conditions = selection_set.conditions()?; Ok(Self { - conditions, + conditions: OnceLock::new(), selection_set, }) } @@ -3073,19 +3152,35 @@ impl FetchSelectionSet { selection_set: Option<&Arc>, ) -> Result<(), FederationError> { Arc::make_mut(&mut self.selection_set).add_at_path(path_in_node, selection_set)?; - // TODO: when calling this multiple times, maybe only re-compute conditions at the end? - // Or make it lazily-initialized and computed on demand? - self.conditions = self.selection_set.conditions()?; + self.conditions.take(); Ok(()) } fn add_selections(&mut self, selection_set: &Arc) -> Result<(), FederationError> { Arc::make_mut(&mut self.selection_set).add_selection_set(selection_set)?; - // TODO: when calling this multiple times, maybe only re-compute conditions at the end? - // Or make it lazily-initialized and computed on demand? - self.conditions = self.selection_set.conditions()?; + self.conditions.take(); Ok(()) } + + /// The conditions determining whether the fetch should be executed. + fn conditions(&self) -> Result<&Conditions, FederationError> { + // This is a bit inefficient, because `get_or_try_init` is unstable. + // https://github.com/rust-lang/rust/issues/109737 + // + // Essentially we do `.get()` twice. This is still much better than eagerly recomputing the + // selection set all the time, though :) + if let Some(conditions) = self.conditions.get() { + return Ok(conditions); + } + + // Separating this call and the `.get_or_init` call means we could, if called from multiple + // threads, do the same work twice. + // The query planner does not use multiple threads for a single plan at the moment, and + // even if it did, occasionally computing this twice would still be better than eagerly + // recomputing it after every change. + let conditions = self.selection_set.conditions()?; + Ok(self.conditions.get_or_init(|| conditions)) + } } impl FetchInputs { @@ -3093,6 +3188,7 @@ impl FetchInputs { Self { selection_sets_per_parent_type: Default::default(), supergraph_schema, + used_contexts: Default::default(), } } @@ -3118,7 +3214,12 @@ impl FetchInputs { other .selection_sets_per_parent_type .values() - .try_for_each(|selections| self.add(selections)) + .try_for_each(|selections| self.add(selections))?; + other + .used_contexts + .iter() + .for_each(|(context, ty)| self.add_context(context.clone(), ty.clone())); + Ok(()) } fn contains(&self, other: &Self) -> bool { @@ -3130,7 +3231,13 @@ impl FetchInputs { return false; } } - true + if self.used_contexts.len() < other.used_contexts.len() { + return false; + } + other + .used_contexts + .keys() + .all(|context| self.used_contexts.contains_key(context)) } fn equals(&self, other: &Self) -> bool { @@ -3153,8 +3260,13 @@ impl FetchInputs { } // so far so good } - // all clear - true + if self.used_contexts.len() != other.used_contexts.len() { + return false; + } + other + .used_contexts + .keys() + .all(|context| self.used_contexts.contains_key(context)) } fn to_selection_set_nodes( @@ -3177,6 +3289,10 @@ impl FetchInputs { selections: Arc::new(selections), }) } + + fn add_context(&mut self, context: Name, ty: Node) { + self.used_contexts.insert(context, ty); + } } impl std::fmt::Display for FetchInputs { @@ -3195,7 +3311,7 @@ impl std::fmt::Display for FetchInputs { // We can safely unwrap because we know the len >= 1. write!(f, "{}", iter.next().unwrap())?; for x in iter { - write!(f, ",{}", x)?; + write!(f, ",{x}")?; } write!(f, "]") } @@ -3244,7 +3360,7 @@ impl DeferTracking { if let Some(parent_ref) = &defer_context.current_defer_ref { let Some(parent_info) = self.deferred.get_mut(parent_ref) else { - panic!("Cannot find info for parent {parent_ref} or {label}"); + bail!("Cannot find info for parent {parent_ref} or {label}") }; parent_info.deferred.insert(label.clone()); @@ -3335,6 +3451,7 @@ struct ComputeNodesStackItem<'a> { node_path: FetchDependencyGraphNodePath, context: &'a OpGraphPathContext, defer_context: DeferContext, + context_to_condition_nodes: Arc>>, } #[cfg_attr( @@ -3348,6 +3465,7 @@ pub(crate) fn compute_nodes_for_tree( initial_node_path: FetchDependencyGraphNodePath, initial_defer_context: DeferContext, initial_conditions: &OpGraphPathContext, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, ) -> Result, FederationError> { snapshot!("OpPathTree", initial_tree.to_string(), "path_tree"); let mut stack = vec![ComputeNodesStackItem { @@ -3356,9 +3474,11 @@ pub(crate) fn compute_nodes_for_tree( node_path: initial_node_path, context: initial_conditions, defer_context: initial_defer_context, + context_to_condition_nodes: Arc::new(Default::default()), }]; let mut created_nodes = IndexSet::default(); while let Some(stack_item) = stack.pop() { + check_cancellation()?; let node = FetchDependencyGraph::node_weight_mut(&mut dependency_graph.graph, stack_item.node_id)?; for selection_set in &stack_item.tree.local_selection_sets { @@ -3404,6 +3524,7 @@ pub(crate) fn compute_nodes_for_tree( edge_id, new_context, &mut created_nodes, + check_cancellation, )?); } QueryGraphEdgeTransition::RootTypeResolution { root_kind } => { @@ -3420,7 +3541,7 @@ pub(crate) fn compute_nodes_for_tree( _ => { return Err(FederationError::internal(format!( "Unexpected non-collecting edge {edge}" - ))) + ))); } } } @@ -3431,6 +3552,7 @@ pub(crate) fn compute_nodes_for_tree( child, operation, &mut created_nodes, + check_cancellation, )?); } } @@ -3455,6 +3577,7 @@ fn compute_nodes_for_key_resolution<'a>( edge_id: EdgeIndex, new_context: &'a OpGraphPathContext, created_nodes: &mut IndexSet, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, ) -> Result, FederationError> { let edge = stack_item.tree.graph.edge_weight(edge_id)?; let Some(conditions) = &child.conditions else { @@ -3470,6 +3593,7 @@ fn compute_nodes_for_key_resolution<'a>( stack_item.node_path.clone(), stack_item.defer_context.for_conditions(), &Default::default(), + check_cancellation, )?; created_nodes.extend(conditions_nodes.iter().copied()); // Then we can "take the edge", creating a new node. @@ -3525,10 +3649,10 @@ fn compute_nodes_for_key_resolution<'a>( let mut iter = dependency_graph.parents_relations_of(condition_node); if let (Some(condition_node_parent), None) = (iter.next(), iter.next()) { // There is exactly one parent - if condition_node_parent.parent_node_id == stack_item.node_id { - if let Some(condition_path) = condition_node_parent.path_in_parent { - path = condition_path.strip_prefix(path_in_parent).map(Arc::new) - } + if condition_node_parent.parent_node_id == stack_item.node_id + && let Some(condition_path) = condition_node_parent.path_in_parent + { + path = condition_path.strip_prefix(path_in_parent).map(Arc::new) } } drop(iter); @@ -3595,6 +3719,7 @@ fn compute_nodes_for_key_resolution<'a>( )?), context: new_context, defer_context: updated_defer_context, + context_to_condition_nodes: stack_item.context_to_condition_nodes.clone(), }) } @@ -3696,6 +3821,7 @@ fn compute_nodes_for_root_type_resolution<'a>( context: new_context, defer_context: updated_defer_context, + context_to_condition_nodes: stack_item.context_to_condition_nodes.clone(), }) } @@ -3706,6 +3832,7 @@ fn compute_nodes_for_op_path_element<'a>( child: &'a Arc>>, operation_element: &OpPathElement, created_nodes: &mut IndexSet, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, ) -> Result, FederationError> { let Some(edge_id) = child.edge else { // A null edge means that the operation does nothing @@ -3723,7 +3850,7 @@ fn compute_nodes_for_op_path_element<'a>( // If the operation contains other directives or a non-trivial type condition, // we need to preserve it and so we add operation. // Otherwise, we just skip it as a minor optimization (it makes the subgraph query - // slighly smaller and on complex queries, it might also deduplicate similar selections). + // slightly smaller and on complex queries, it might also deduplicate similar selections). return Ok(ComputeNodesStackItem { tree: &child.tree, node_id: stack_item.node_id, @@ -3735,11 +3862,13 @@ fn compute_nodes_for_op_path_element<'a>( }, context: stack_item.context, defer_context: updated_defer_context, + context_to_condition_nodes: stack_item.context_to_condition_nodes.clone(), }); }; let (source_id, dest_id) = stack_item.tree.graph.edge_endpoints(edge_id)?; let source = stack_item.tree.graph.node_weight(source_id)?; let dest = stack_item.tree.graph.node_weight(dest_id)?; + let edge = stack_item.tree.graph.edge_weight(edge_id)?; if source.source != dest.source { return Err(FederationError::internal(format!( "Collecting edge {edge_id:?} for {operation_element:?} \ @@ -3793,64 +3922,268 @@ fn compute_nodes_for_op_path_element<'a>( node_path: stack_item.node_path.clone(), context: stack_item.context, defer_context: updated_defer_context, + context_to_condition_nodes: stack_item.context_to_condition_nodes.clone(), }; if let Some(conditions) = &child.conditions { // We have @requires or some other dependency to create nodes for. - let (required_node_id, require_path) = handle_requires( + let conditions_node_data = handle_conditions_tree( dependency_graph, - edge_id, conditions, (stack_item.node_id, &stack_item.node_path), - stack_item.context, + // If setting a context, add __typename to the site where we are retrieving context from + // since the context rewrites path will start with a type condition. + if child.matching_context_ids.is_some() { + Some(edge_id) + } else { + None + }, &updated.defer_context, created_nodes, + check_cancellation, )?; - updated.node_id = required_node_id; - updated.node_path = require_path; - } - if let OpPathElement::Field(field) = &updated_operation { - if *field.name() == TYPENAME_FIELD { - // Because of the optimization done in `QueryPlanner.optimizeSiblingTypenames`, - // we will rarely get an explicit `__typename` edge here. - // But one case where it can happen is where an @interfaceObject was involved, - // and we had to force jumping to another subgraph for getting the "true" `__typename`. - // However, this case can sometimes lead to fetch dependency node - // that only exists for that `__typename` resolution and that "looks" useless. - // That is, we could have a fetch dependency node that looks like: - // ``` - // Fetch(service: "Subgraph2") { - // { - // ... on I { - // __typename - // id - // } - // } => - // { - // ... on I { - // __typename - // } - // } - // } - // ``` - // but the trick is that the `__typename` in the input - // will be the name of the interface itself (`I` in this case) - // but the one return after the fetch will the name of the actual implementation - // (some implementation of `I`). - // *But* we later have optimizations that would remove such a node, - // on the node that the output is included in the input, - // which is in general the right thing to do - // (and genuinely ensure that some useless nodes created when handling - // complex @require gets eliminated). - // So we "protect" the node in this case to ensure - // that later optimization doesn't kick in in this case. + + if let Some(matching_context_ids) = &child.matching_context_ids { + let mut condition_nodes = vec![conditions_node_data.conditions_merge_node_id]; + condition_nodes.extend(&conditions_node_data.created_node_ids); + let mut context_to_condition_nodes = + stack_item.context_to_condition_nodes.deref().clone(); + for context in matching_context_ids { + context_to_condition_nodes.insert(context.clone(), condition_nodes.clone()); + } + updated.context_to_condition_nodes = Arc::new(context_to_condition_nodes); + } + + if edge.conditions.is_some() { + // This edge needs the conditions just fetched, to be provided via _entities (@requires + // or fake interface object downcast). So we create the post-@requires group, adding the + // subgraph jump (if it isn't optimized away). + let (required_node_id, require_path) = create_post_requires_node( + dependency_graph, + edge_id, + (stack_item.node_id, &stack_item.node_path), + stack_item.context, + conditions_node_data, + created_nodes, + )?; + updated.node_id = required_node_id; + updated.node_path = require_path; + } + } + + // If the edge uses context variables, every context used must be set in a different parent + // node or else we need to create a new one. + if let Some(arguments_to_context_usages) = &child.arguments_to_context_usages { + let mut conditions_nodes: IndexSet = Default::default(); + let mut is_subgraph_jump_needed = false; + for context_usage in arguments_to_context_usages.values() { + let Some(context_nodes) = updated + .context_to_condition_nodes + .get(&context_usage.context_id) + else { + bail!( + "Could not find condition nodes for context {}", + context_usage.context_id + ); + }; + conditions_nodes.extend(context_nodes); + if context_nodes + .first() + .is_some_and(|node_id| *node_id == updated.node_id) + { + is_subgraph_jump_needed = true; + } + } + if is_subgraph_jump_needed { + if updated.node_id != stack_item.node_id { + bail!("Node created by post-@requires handling shouldn't have set context already"); + } + + let source_type: CompositeTypeDefinitionPosition = source.type_.clone().try_into()?; + let source_schema: ValidFederationSchema = dependency_graph + .federated_query_graph + .schema_by_source(&source.source)? + .clone(); + let path_in_parent = &stack_item.node_path.path_in_node; + // NOTE: We should re-examine defer-handling for path elements in this function in the + // future to ensure they're working as intended. + let new_node_id = dependency_graph.get_or_create_key_node( + &source.source, + &stack_item.node_path.response_path, + &source_type, + ParentRelation { + parent_node_id: stack_item.node_id, + path_in_parent: Some(Arc::clone(path_in_parent)), + }, + &conditions_nodes, + None, + )?; + created_nodes.insert(new_node_id); + updated.node_id = new_node_id; + updated.node_path = stack_item + .node_path + .for_new_key_fetch(create_fetch_initial_path( + &dependency_graph.supergraph_schema, + &source_type, + stack_item.context, + )?); + + let Some(key_condition) = stack_item + .tree + .graph + .get_locally_satisfiable_key(source_id)? + else { + bail!( + "can_satisfy_conditions() validation should have required a key to be present for edge {}", + edge, + ) + }; + let mut key_inputs = + SelectionSet::for_composite_type(source_schema.clone(), source_type.clone()); + key_inputs.add_selection_set(&key_condition)?; + let node = FetchDependencyGraph::node_weight_mut( + &mut dependency_graph.graph, + stack_item.node_id, + )?; + node.selection_set + .add_at_path(path_in_parent, Some(&Arc::new(key_inputs)))?; + + let Ok(input_type) = CompositeTypeDefinitionPosition::try_from( + dependency_graph + .supergraph_schema + .get_type(source_type.type_name().clone())?, + ) else { + bail!( + "Type {} should exist in the supergraph and be a composite type", + source_type.type_name() + ); + }; + let mut input_selection_set = SelectionSet::for_composite_type( + dependency_graph.supergraph_schema.clone(), + input_type.clone(), + ); + input_selection_set.add_selection_set(&key_condition)?; + let inputs = wrap_input_selections( + &dependency_graph.supergraph_schema, + &input_type, + input_selection_set, + stack_item.context, + ); + let input_rewrites = compute_input_rewrites_on_key_fetch( + source_type.type_name(), + &source_type, + &source_schema, + )?; let updated_node = FetchDependencyGraph::node_weight_mut( &mut dependency_graph.graph, updated.node_id, )?; - updated_node.must_preserve_selection_set = true + updated_node.add_inputs(&inputs, input_rewrites.into_iter().flatten())?; + + // Add the condition nodes as parent nodes. + for parent_node_id in conditions_nodes { + dependency_graph.add_parent( + updated.node_id, + ParentRelation { + parent_node_id, + path_in_parent: None, + }, + ); + } + + // Add context renamers. + for context_entry in arguments_to_context_usages.values() { + let updated_node = FetchDependencyGraph::node_weight_mut( + &mut dependency_graph.graph, + updated.node_id, + )?; + updated_node.add_input_context( + context_entry.context_id.clone(), + context_entry.subgraph_argument_type.clone(), + )?; + updated_node.add_context_renamers_for_selection_set( + Some(&context_entry.selection_set), + context_entry.relative_path.clone(), + context_entry.context_id.clone(), + )?; + } + } else { + // In this case we can just continue with the current node, but we need to add the + // condition nodes as parents and the context renamers. + for parent_node_id in conditions_nodes { + dependency_graph.add_parent( + updated.node_id, + ParentRelation { + parent_node_id, + path_in_parent: None, + }, + ); + } + let num_fields = updated + .node_path + .path_in_node + .iter() + .filter(|e| matches!((**e).deref(), OpPathElement::Field(_))) + .count(); + for context_entry in arguments_to_context_usages.values() { + let new_relative_path = &context_entry.relative_path + [..(context_entry.relative_path.len() - num_fields)]; + let updated_node = FetchDependencyGraph::node_weight_mut( + &mut dependency_graph.graph, + updated.node_id, + )?; + updated_node.add_input_context( + context_entry.context_id.clone(), + context_entry.subgraph_argument_type.clone(), + )?; + updated_node.add_context_renamers_for_selection_set( + Some(&context_entry.selection_set), + new_relative_path.to_vec(), + context_entry.context_id.clone(), + )?; + } } } - let edge = child.tree.graph.edge_weight(edge_id)?; + + if let OpPathElement::Field(field) = &updated_operation + && *field.name() == TYPENAME_FIELD + { + // Because of the optimization done in `QueryPlanner.optimizeSiblingTypenames`, + // we will rarely get an explicit `__typename` edge here. + // But one case where it can happen is where an @interfaceObject was involved, + // and we had to force jumping to another subgraph for getting the "true" `__typename`. + // However, this case can sometimes lead to fetch dependency node + // that only exists for that `__typename` resolution and that "looks" useless. + // That is, we could have a fetch dependency node that looks like: + // ``` + // Fetch(service: "Subgraph2") { + // { + // ... on I { + // __typename + // id + // } + // } => + // { + // ... on I { + // __typename + // } + // } + // } + // ``` + // but the trick is that the `__typename` in the input + // will be the name of the interface itself (`I` in this case) + // but the one return after the fetch will the name of the actual implementation + // (some implementation of `I`). + // *But* we later have optimizations that would remove such a node, + // on the node that the output is included in the input, + // which is in general the right thing to do + // (and genuinely ensure that some useless nodes created when handling + // complex @require gets eliminated). + // So we "protect" the node in this case to ensure + // that later optimization doesn't kick in in this case. + let updated_node = + FetchDependencyGraph::node_weight_mut(&mut dependency_graph.graph, updated.node_id)?; + updated_node.must_preserve_selection_set = true + } if let QueryGraphEdgeTransition::InterfaceObjectFakeDownCast { .. } = &edge.transition { // We shouldn't add the operation "as is" as it's a down-cast but we're "faking it". // However, if the operation has directives, we should preserve that. @@ -3897,7 +4230,7 @@ fn wrap_selection_with_type_and_conditions( InlineFragment { schema: supergraph_schema.clone(), parent_type_position: wrapping_type.clone(), - type_condition_position: Some(type_condition.clone()), + type_condition_position: Some(type_condition), directives: Default::default(), // None selection_id: SelectionId::new(), }, @@ -3914,11 +4247,13 @@ fn wrap_selection_with_type_and_conditions( context.iter().fold(initial, |acc, cond| { let directive = Directive { name: cond.kind.name(), - arguments: vec![Argument { - name: name!("if"), - value: cond.value.clone().into(), - } - .into()], + arguments: vec![ + Argument { + name: name!("if"), + value: cond.value.clone().into(), + } + .into(), + ], }; wrap_in_fragment( InlineFragment { @@ -4064,134 +4399,185 @@ fn extract_defer_from_operation( Ok((updated_operation_element, updated_context)) } -fn handle_requires( +struct ConditionsNodeData { + conditions_merge_node_id: NodeIndex, + path_in_conditions_merge_node_id: Option>, + created_node_ids: Vec, + is_fully_local_requires: bool, +} + +/// Computes nodes for conditions imposed by @requires, @fromContext, and @interfaceObject, merging +/// them into ancestors as an optimization if possible. This does not modify the current node to +/// use the condition data as input, nor does it create parent-child relationships with created +/// nodes and the current node. +fn handle_conditions_tree( dependency_graph: &mut FetchDependencyGraph, - query_graph_edge_id: EdgeIndex, - requires_conditions: &OpPathTree, + conditions: &OpPathTree, (fetch_node_id, fetch_node_path): (NodeIndex, &FetchDependencyGraphNodePath), - context: &OpGraphPathContext, + query_graph_edge_id_if_typename_needed: Option, defer_context: &DeferContext, created_nodes: &mut IndexSet, -) -> Result<(NodeIndex, FetchDependencyGraphNodePath), FederationError> { - // @requires should be on an entity type, and we only support object types right now - let head = dependency_graph - .federated_query_graph - .edge_head_weight(query_graph_edge_id)?; - let entity_type_schema = dependency_graph - .federated_query_graph - .schema_by_source(&head.source)? - .clone(); - let QueryGraphNodeType::SchemaType(OutputTypeDefinitionPosition::Object(entity_type_position)) = - head.type_.clone() - else { - return Err(FederationError::internal( - "@requires applied on non-entity object type", - )); - }; - - // In many cases, we can optimize @requires by merging the requirement to previously existing nodes. However, - // we only do this when the current node has only a single parent (it's hard to reason about it otherwise). - // But the current node could have multiple parents due to the graph lacking minimality, and we don't want that - // to needlessly prevent us from this optimization. So we do a graph reduction first (which effectively - // just eliminate unnecessary edges). To illustrate, we could be in a case like: + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, +) -> Result { + // In many cases, we can optimize conditions by merging the fields into previously existing + // nodes. However, we only do this when the current node has only a single parent (it's hard to + // reason about it otherwise). But the current node could have multiple parents due to the graph + // lacking minimality, and we don't want that to needlessly prevent us from this optimization. + // So we do a graph reduction first (which effectively just eliminates unnecessary edges). To + // illustrate, we could be in a case like: // 1 // / \ // 0 --- 2 - // with current node 2. And while the node currently has 2 parents, the `reduce` step will ensure - // the edge `0 --- 2` is removed (since the dependency of 2 on 0 is already provide transitively through 1). + // with current node 2. And while the node currently has 2 parents, the `reduce` step will + // ensure the edge `0 --- 2` is removed (since the dependency of 2 on 0 is already provided + // transitively through 1). dependency_graph.reduce(); - let single_parent = iter_into_single_item(dependency_graph.parents_relations_of(fetch_node_id)); - // In general, we should do like for an edge, and create a new node _for the current subgraph_ - // that depends on the created_nodes and have the created nodes depend on the current one. - // However, we can be more efficient in general (and this is expected by the user) because - // required fields will usually come just after a key edge (at the top of a fetch node). - // In that case (when the path is only type_casts), we can put the created nodes directly - // as dependency of the current node, avoiding creation of a new one. Additionally, if the + // In general, we should do like for a key edge, and create a new node _for the current + // subgraph_ that depends on the created nodes and have the created nodes depend on the current + // one. However, we can be more efficient in general (and this is expected by the user) because + // condition fields will usually come just after a key edge (at the top of a fetch node). + // In that case (when the path is only type conditions), we can put the created nodes directly + // as dependencies of the current node, avoiding creation of a new one. Additionally, if the // node we're coming from is our "direct parent", we can merge it to said direct parent (which - // effectively means that the parent node will collect the provides before taking the edge - // to our current node). - if single_parent.is_some() && fetch_node_path.path_in_node.has_only_fragments() { - // Should do `if let` but it requires extra indentation. - let parent = single_parent.unwrap(); - - // We start by computing the nodes for the conditions. We do this using a copy of the current - // node (with only the inputs) as that allows to modify this copy without modifying `node`. - let fetch_node = dependency_graph.node_weight(fetch_node_id)?; - let subgraph_name = fetch_node.subgraph_name.clone(); - let Some(merge_at) = fetch_node.merge_at.clone() else { - return Err(FederationError::internal(format!( - "Fetch node {} merge_at_path is required but was missing", - fetch_node_id.index() - ))); + // effectively means that the parent node will collect subgraph-local condition fields before + // taking the edge to our current node). + let copied_node_id_and_parent = + match iter_into_single_item(dependency_graph.parents_relations_of(fetch_node_id)) { + Some(parent) if fetch_node_path.path_in_node.has_only_fragments() => { + // Since we may want the condition fields in this case to be added into an earlier + // node, we create a copy of the current node (with only the inputs), and the + // condition fields will be added to this node instead of the current one. + let fetch_node = dependency_graph.node_weight(fetch_node_id)?; + let subgraph_name = fetch_node.subgraph_name.clone(); + let Some(merge_at) = fetch_node.merge_at.clone() else { + bail!( + "Fetch node {} merge_at_path is required but was missing", + fetch_node_id.index() + ); + }; + let defer_ref = fetch_node.defer_ref.clone(); + let copied_node_id = + dependency_graph.new_key_node(&subgraph_name, merge_at, defer_ref)?; + dependency_graph.add_parent(copied_node_id, parent.clone()); + dependency_graph.copy_inputs(copied_node_id, fetch_node_id)?; + Some((copied_node_id, parent)) + } + _ => None, }; - let defer_ref = fetch_node.defer_ref.clone(); - let new_node_id = - dependency_graph.new_key_node(&subgraph_name, merge_at, defer_ref.clone())?; - dependency_graph.add_parent(new_node_id, parent.clone()); - dependency_graph.copy_inputs(new_node_id, fetch_node_id)?; - let newly_created_node_ids = compute_nodes_for_tree( - dependency_graph, - requires_conditions, - new_node_id, - fetch_node_path.clone(), - defer_context.for_conditions(), - &OpGraphPathContext::default(), - )?; - if newly_created_node_ids.is_empty() { - // All conditions were local. Just merge the newly created node back into the current node (we didn't need it) - // and continue. - if !dependency_graph.can_merge_sibling_in(fetch_node_id, new_node_id)? { - return Err(FederationError::internal(format!( + let condition_node_id = match &copied_node_id_and_parent { + Some((copied_node_id, _)) => *copied_node_id, + None => fetch_node_id, + }; + if let Some(query_graph_edge_id) = query_graph_edge_id_if_typename_needed { + let head = dependency_graph + .federated_query_graph + .edge_head_weight(query_graph_edge_id)?; + let head_type: CompositeTypeDefinitionPosition = head.type_.clone().try_into()?; + let head_schema = dependency_graph + .federated_query_graph + .schema_by_source(&head.source)? + .clone(); + let typename_field = Arc::new(OpPathElement::Field(Field::new_introspection_typename( + &head_schema, + &head_type, + None, + ))); + let typename_path = fetch_node_path.path_in_node.with_pushed(typename_field); + let condition_node = + FetchDependencyGraph::node_weight_mut(&mut dependency_graph.graph, condition_node_id)?; + condition_node + .selection_set_mut() + .add_at_path(&typename_path, None)?; + } + + // Compute the node changes/additions introduced by the conditions path tree, using either the + // current node or the copy of the current node if we expect to optimize. + let newly_created_node_ids = compute_nodes_for_tree( + dependency_graph, + conditions, + condition_node_id, + fetch_node_path.clone(), + defer_context.for_conditions(), + &OpGraphPathContext::default(), + check_cancellation, + )?; + + if newly_created_node_ids.is_empty() { + // All conditions were local. If we copied the node expecting to optimize, just merge it + // back into the current node (we didn't need it) and continue. + // + // NOTE: This behavior is largely to maintain backwards compatibility with @requires. For + // @fromContext, it still may be useful to merge the conditions into the parent if possible. + // but we leave this optimization for later. + if let Some((copied_node_id, _)) = copied_node_id_and_parent { + if !dependency_graph.can_merge_sibling_in(fetch_node_id, copied_node_id)? { + bail!( "We should be able to merge {} into {} by construction", - new_node_id.index(), + copied_node_id.index(), fetch_node_id.index() - ))); + ); } - dependency_graph.merge_sibling_in(fetch_node_id, new_node_id)?; - return Ok((fetch_node_id, fetch_node_path.clone())); + dependency_graph.merge_sibling_in(fetch_node_id, copied_node_id)?; } + return Ok(ConditionsNodeData { + conditions_merge_node_id: fetch_node_id, + path_in_conditions_merge_node_id: Some(Arc::new(Default::default())), + created_node_ids: vec![], + is_fully_local_requires: true, + }); + } - // We know the @requires needs `newly_created_node_ids`. We do want to know however if any of the conditions was - // fetched from our `new_node`. If not, then this means that the `newly_created_node_ids` don't really depend on - // the current `node` and can be dependencies of the parent (or even merged into this parent). + if let Some((copied_node_id, parent)) = copied_node_id_and_parent { + // We know the conditions depend on at least one created node. We do want to know, however, + // if any of the condition fields was fetched from our copied node. If not, then this means + // that the created nodes don't really depend on the current node and can be dependencies + // of the parent (or even merged into the parent). // - // So we want to know if anything in `new_node` selection cannot be fetched directly from the parent. - // For that, we first remove any of `new_node` inputs from its selection: in most case, `new_node` - // will just contain the key needed to jump back to its parent, and those would usually be the same - // as the inputs. And since by definition we know `new_node`'s inputs are already fetched, we - // know they are not things that we need. Then, we check if what remains (often empty) can be - // directly fetched from the parent. If it can, then we can just merge `new_node` into that parent. - // Otherwise, we will have to "keep it". - // Note: it is to be sure this test is not polluted by other things in `node` that we created `new_node`. - dependency_graph.remove_inputs_from_selection(new_node_id)?; - - let new_node_is_not_needed = dependency_graph.is_node_unneeded(new_node_id, &parent)?; + // So we want to know if anything in the copied node's selections cannot be fetched directly + // from the parent. For that, we first remove any of the copied node's inputs from its + // selections: in most cases, the copied node will just contain the key needed to jump back + // to its parent, and those would usually be the same as its inputs. And since by definition + // we know copied node's inputs are already fetched, we know they are not things that we + // need. Then, we check if what remains (often empty) can be directly fetched from the + // parent. If it can, then we can just merge the copied node into that parent. Otherwise, we + // will have to "keep it". + // + // NOTE: We have explicitly copied the current node without its selections, so the current + // node's fields should not pollute this check on the copied node. + dependency_graph.remove_inputs_from_selection(copied_node_id)?; + let copied_node_is_unneeded = dependency_graph.is_node_unneeded(copied_node_id, &parent)?; let mut unmerged_node_ids: Vec = Vec::new(); - if new_node_is_not_needed { - // Up to this point, `new_node` had no parent, so let's first merge `new_node` to the parent, thus "rooting" - // its children to it. Note that we just checked that `new_node` selection was just its inputs, so - // we know that merging it to the parent is mostly a no-op from that POV, except maybe for requesting - // a few additional `__typename` we didn't before (due to the exclusion of `__typename` in the `new_node_is_unneeded` check) - dependency_graph.merge_child_in(parent.parent_node_id, new_node_id)?; - - // Now, all created groups are going to be descendant of `parentGroup`. But some of them may actually be - // mergeable into it. + if copied_node_is_unneeded { + // We've removed the copied node's inputs from its own selections, and confirmed the + // remaining fields can be fetched from the parent. As an optimization, we now merge it + // into the parent, thus "rooting" the copied node's children to that parent. Note that + // the copied node's selections are often empty after removing inputs, so merging it + // into the parent is usually a no-op from that POV, except maybe for requesting + // a few additional `__typename`s we didn't before. + dependency_graph.merge_child_in(parent.parent_node_id, copied_node_id)?; + + // Now, all created nodes are going to be descendants of the parent node. But some of + // them may actually be mergeable into it. for created_node_id in newly_created_node_ids { - // Note that `created_node_id` may not be a direct child of `parent_node_id`, but `can_merge_child_in` just return `false` in - // that case, yielding the behaviour we want (not trying to merge it in). + // Note that `created_node_id` may not be a direct child of `parent_node_id`, but + // `can_merge_child_in()` just returns `false` in that case, yielding the behavior + // we want (not trying to merge it in). if dependency_graph.can_merge_child_in(parent.parent_node_id, created_node_id)? { dependency_graph.merge_child_in(parent.parent_node_id, created_node_id)?; } else { unmerged_node_ids.push(created_node_id); - // `created_node_id` cannot be merged into `parent_node_id`, which may typically be because they are not to the same - // subgraph. However, while `created_node_id` currently depend on `parent_node_id` (directly or indirectly), that - // dependency just come from the fact that `parent_node_id` is the parent of the node whose @requires we're - // dealing with. And in practice, it could well be that some of the fetches needed for that require don't - // really depend on anything that parent fetches and could be done in parallel with it. If we detect that - // this is the case for `created_node_id`, we can move it "up the chain of dependency". + // `created_node_id` cannot be merged into `parent_node_id`, which may typically + // be because they aren't to the same subgraph. However, while `created_node_id` + // currently depends on `parent_node_id` (directly or indirectly), that + // dependency just comes from the fact that `parent_node_id` is the parent of + // the node whose conditions we're dealing with. And in practice, it could well + // be that some of the fetches needed for those conditions don't really depend + // on anything that the parent fetches and could be done in parallel with it. If + // we detect that this is the case for `created_node_id`, we can move it "up the + // chain of dependencies". let mut current_parent = parent.clone(); while dependency_graph.is_child_of_with_artificial_dependency( created_node_id, @@ -4204,10 +4590,10 @@ fn handle_requires( .parents_relations_of(current_parent.parent_node_id) .collect(); if grand_parents.is_empty() { - return Err(FederationError::internal(format!( + bail!( "Fetch node {} is not top-level, so it should have parents", current_parent.parent_node_id.index() - ))); + ); } for grand_parent_relation in &grand_parents { dependency_graph.add_parent( @@ -4233,26 +4619,26 @@ fn handle_requires( } } } else { - // We cannot merge `new_node_id` to the parent, either because there it fetches some things necessary to the - // @requires, or because we had more than one parent and don't know how to handle this (unsure if the later - // can actually happen at this point tbh (?)). But there is no reason not to merge `new_node_id` back to `fetch_node_id` - // so we do that first. - if !dependency_graph.can_merge_sibling_in(fetch_node_id, new_node_id)? { - return Err(FederationError::internal(format!( + // We cannot merge the copied node into the parent because it fetches some conditions + // fields that can't be fetched from the parent. We bail on this specific optimization, + // and accordingly merge the copied node back to the original current node. + if !dependency_graph.can_merge_sibling_in(fetch_node_id, copied_node_id)? { + bail!( "We should be able to merge {} into {} by construction", - new_node_id.index(), + copied_node_id.index(), fetch_node_id.index() - ))); + ); }; - dependency_graph.merge_sibling_in(fetch_node_id, new_node_id)?; - - // The created node depend on `fetch_node` and the dependency cannot be moved to the parent in - // this case. However, we might still be able to merge some created nodes directly in the - // parent. But for this to be true, we should essentially make sure that the dependency - // on `node` is not a "true" dependency. That is, if the created node inputs are the same - // as `node` inputs (and said created node is the same subgraph as the parent of - // `node`, then it means we depend only on values that are already in the parent and - // can merge the node). + dependency_graph.merge_sibling_in(fetch_node_id, copied_node_id)?; + + // The created nodes depend on the current node, and the dependency cannot be moved to + // the parent in this case. However, we might still be able to merge some created nodes + // directly into the parent. But for this to be true, we should essentially make sure + // that the dependency on the current node is not a "true" dependency. That is, if a + // created node's inputs are the same as the current node's inputs (and said created + // node is the same subgraph as the parent of the current node), then it means we depend + // only on values that are already fetched by the parent and/or its ancestors, and + // can merge that created node into the parent. if parent.path_in_parent.is_some() { for created_node_id in newly_created_node_ids { if dependency_graph.can_merge_grand_child_in( @@ -4269,14 +4655,87 @@ fn handle_requires( } } - // If we've merged all the created nodes, then all the "requires" are handled _before_ we get to the - // current node, so we can "continue" with the current node. - if unmerged_node_ids.is_empty() { - // We still need to add the stuffs we require though (but `node` already has a key in its inputs, - // we don't need one). + created_nodes.extend(unmerged_node_ids.clone()); + Ok(ConditionsNodeData { + conditions_merge_node_id: if copied_node_is_unneeded { + parent.parent_node_id + } else { + fetch_node_id + }, + path_in_conditions_merge_node_id: if copied_node_is_unneeded { + parent.path_in_parent + } else { + Some(Arc::new(Default::default())) + }, + created_node_ids: unmerged_node_ids, + is_fully_local_requires: false, + }) + } else { + // We're in the somewhat simpler case where the conditions are queried somewhere in the + // middle of a subgraph fetch (so, not just after having jumped to that subgraph), or + // there's more than one parent. In that case, there isn't much optimisation we can easily + // do, so we leave the nodes as-is. + created_nodes.extend(newly_created_node_ids.clone()); + Ok(ConditionsNodeData { + conditions_merge_node_id: fetch_node_id, + path_in_conditions_merge_node_id: Some(Arc::new(Default::default())), + created_node_ids: newly_created_node_ids.into_iter().collect(), + is_fully_local_requires: false, + }) + } +} + +/// Adds a @requires edge into the node at the given path, instead making a new node if optimization +/// cannot place that edge in the given node. This function assumes handle_conditions_tree() has +/// already been called, and accordingly takes its outputs. +fn create_post_requires_node( + dependency_graph: &mut FetchDependencyGraph, + query_graph_edge_id: EdgeIndex, + (fetch_node_id, fetch_node_path): (NodeIndex, &FetchDependencyGraphNodePath), + context: &OpGraphPathContext, + conditions_node_data: ConditionsNodeData, + created_nodes: &mut IndexSet, +) -> Result<(NodeIndex, FetchDependencyGraphNodePath), FederationError> { + // @requires should be on an entity type, and we only support object types right now. + let head = dependency_graph + .federated_query_graph + .edge_head_weight(query_graph_edge_id)?; + let entity_type_schema = dependency_graph + .federated_query_graph + .schema_by_source(&head.source)? + .clone(); + let QueryGraphNodeType::SchemaType(OutputTypeDefinitionPosition::Object(entity_type_position)) = + head.type_.clone() + else { + bail!("@requires applied on non-entity object type"); + }; + + // If all required fields could be fetched locally, we "continue" with the current node. + if conditions_node_data.is_fully_local_requires { + return Ok((fetch_node_id, fetch_node_path.clone())); + } + + // NOTE: The code paths diverge below similar to handle_conditions_tree(), checking whether we + // tried optimizing based on whether there's a single parent and whether the path in the node is + // only type conditions. This is largely meant to just keep behavior the same as before and be + // aligned with the JS query planner. This could change in the future though, to permit simpler + // handling and further optimization. (There's also some arguably buggy behavior in this + // function we ought to resolve in the future.) + let parent_if_tried_optimizing = + match iter_into_single_item(dependency_graph.parents_relations_of(fetch_node_id)) { + Some(parent) if fetch_node_path.path_in_node.has_only_fragments() => Some(parent), + _ => None, + }; + + if let Some(parent) = parent_if_tried_optimizing { + // If all created nodes were merged into ancestors, then those nodes' data are fetched + // _before_ we get to the current node, so we "continue" with the current node. + if conditions_node_data.created_node_ids.is_empty() { + // We still need to add the required fields as inputs to the current node (but the node + // should already have a key in its inputs, so we don't need to add that). let inputs = inputs_for_require( dependency_graph, - entity_type_position.clone(), + entity_type_position, entity_type_schema, query_graph_edge_id, context, @@ -4289,51 +4748,51 @@ fn handle_requires( return Ok((fetch_node_id, fetch_node_path.clone())); } - // If we get here, it means that @require needs the information from `unmerged_nodes` (plus whatever has - // been merged before) _and_ those rely on some information from the current `fetch_node` (if they hadn't, we - // would have been able to merge `new_node` to `fetch_node`'s parent). So the group we should return, which - // is the node where the "post-@require" fields will be added, needs to be a new node that depends - // on all those `unmerged_nodes`. - let post_require_node_id = dependency_graph.new_key_node( - &subgraph_name, + // If we get here, it means that @requires needs the fields from the created nodes (plus + // potentially whatever has been merged before). So the node we should return, which is the + // node where the "post-@requires" fields will be given as input, needs to a be a new node + // that depends on all those created nodes. + let fetch_node = dependency_graph.node_weight(fetch_node_id)?; + let target_subgraph = fetch_node.subgraph_name.clone(); + let defer_ref = fetch_node.defer_ref.clone(); + let post_requires_node_id = dependency_graph.new_key_node( + &target_subgraph, fetch_node_path.response_path.clone(), defer_ref, )?; - // Note that `post_require_node` cannot generally be merged in any of the `unmerged_nodes` and we don't provide a `path`. - for unmerged_node_id in &unmerged_node_ids { + // Note that the post-requires node cannot generally be merged into any of the created + // nodes, and we accordingly don't provide a path in those created nodes. + for created_node_id in &conditions_node_data.created_node_ids { dependency_graph.add_parent( - post_require_node_id, + post_requires_node_id, ParentRelation { - parent_node_id: *unmerged_node_id, + parent_node_id: *created_node_id, path_in_parent: None, }, ); } - // That node also need, in general, to depend on the current `fetch_node`. That said, if we detected that the @require - // didn't need anything of said `node` (if `new_node_is_unneeded`), then we can depend on the parent instead. - if new_node_is_not_needed { - dependency_graph.add_parent(post_require_node_id, parent.clone()); - } else { - dependency_graph.add_parent( - post_require_node_id, - ParentRelation { - parent_node_id: fetch_node_id, - path_in_parent: Some(Arc::new(OpPath::default())), - }, - ) - } + // The post-requires node also needs to, in general, depend on the node that the @requires + // conditions were merged into (either the current node or its parent). + dependency_graph.add_parent( + post_requires_node_id, + ParentRelation { + parent_node_id: conditions_node_data.conditions_merge_node_id, + path_in_parent: conditions_node_data.path_in_conditions_merge_node_id, + }, + ); - // Note(Sylvain): I'm not 100% sure about this assert in the sense that while I cannot think of a case where `parent.path_in_parent` wouldn't - // exist, the code paths are complex enough that I'm not able to prove this easily and could easily be missing something. That said, - // we need the path here, so this will have to do for now, and if this ever breaks in practice, we'll at least have an example to - // guide us toward improving/fixing. + // NOTE(Sylvain): I'm not 100% sure about this assert in the sense that while I cannot think + // of a case where `parent.path_in_parent` wouldn't exist, the code paths are complex enough + // that I'm not able to prove this easily and could easily be missing something. That said, + // we need the path here, so this will have to do for now, and if this ever breaks in + // practice, we'll at least have an example to guide us toward improving/fixing the code. let Some(parent_path) = &parent.path_in_parent else { - return Err(FederationError::internal(format!( + bail!( "Missing path_in_parent for @require on {} with group {} and parent {}", query_graph_edge_id.index(), fetch_node_id.index(), parent.parent_node_id.index() - ))); + ); }; let path_for_parent = path_for_parent( dependency_graph, @@ -4349,61 +4808,43 @@ fn handle_requires( query_graph_edge_id, context, parent.parent_node_id, - post_require_node_id, + post_requires_node_id, )?; - created_nodes.extend(unmerged_node_ids); - created_nodes.insert(post_require_node_id); + created_nodes.insert(post_requires_node_id); let initial_fetch_path = create_fetch_initial_path( &dependency_graph.supergraph_schema, - &entity_type_position.clone().into(), + &entity_type_position.into(), context, )?; let new_path = fetch_node_path.for_new_key_fetch(initial_fetch_path); - Ok((post_require_node_id, new_path)) + Ok((post_requires_node_id, new_path)) } else { - // We're in the somewhat simpler case where a @require happens somewhere in the middle of a subgraph query (so, not - // just after having jumped to that subgraph). In that case, there isn't tons of optimisation we can do: we have to - // see what satisfying the @require necessitate, and if it needs anything from another subgraph, we have to stop the - // current subgraph fetch there, get the requirements from other subgraphs, and then resume the query of that particular subgraph. - let new_created_nodes = compute_nodes_for_tree( - dependency_graph, - requires_conditions, - fetch_node_id, - fetch_node_path.clone(), - defer_context.for_conditions(), - &OpGraphPathContext::default(), - )?; - // If we didn't create any node, that means the whole condition was fetched from the current node - // and we're good. - if new_created_nodes.is_empty() { - return Ok((fetch_node_id, fetch_node_path.clone())); - } - - // We need to create a new name, on the same subgraph `group`, where we resume fetching the field for - // which we handle the @requires _after_ we've dealt with the `requires_conditions_nodes`. - // Note that we know the conditions will include a key for our node so we can resume properly. + // We need to create a new node on the same subgraph as the current node, where we resume + // fetching the field for which we handle the @requires _after_ we've dealt with any created + // nodes. Note that during option generation, we already ensured a key exists, so the node + // can resume properly. let fetch_node = dependency_graph.node_weight(fetch_node_id)?; let target_subgraph = fetch_node.subgraph_name.clone(); let defer_ref = fetch_node.defer_ref.clone(); - let new_node_id = dependency_graph.new_key_node( + let post_requires_node_id = dependency_graph.new_key_node( &target_subgraph, fetch_node_path.response_path.clone(), defer_ref, )?; - let new_node = dependency_graph.node_weight(new_node_id)?; - let merge_at = new_node.merge_at.clone(); - let parent_type = new_node.parent_type.clone(); - for created_node_id in &new_created_nodes { + let post_requires_node = dependency_graph.node_weight(post_requires_node_id)?; + let merge_at = post_requires_node.merge_at.clone(); + let parent_type = post_requires_node.parent_type.clone(); + for created_node_id in &conditions_node_data.created_node_ids { let created_node = dependency_graph.node_weight(*created_node_id)?; - // Usually, computing the path of our new group into the created groups - // is not entirely trivial, but there is at least the relatively common - // case where the 2 groups we look at have: - // 1) the same `mergeAt`, and - // 2) the same parentType; in that case, we can basically infer those 2 - // groups apply at the same "place" and so the "path in parent" is - // empty. TODO: it should probably be possible to generalize this by - // checking the `mergeAt` plus analyzing the selection but that - // warrants some reflection... + // Usually, computing the path of the post-requires node in the created nodes is not + // entirely trivial, but there is at least one relatively common case where the 2 nodes + // we look at have (1) the same merge-at, and (2) the same parent type. + // + // In that case, we can basically infer those 2 nodes apply at the same "place" and so + // the "path in parent" is empty. + // + // TODO(Sylvain): it should probably be possible to generalize this by checking the + // `merge_at` plus analyzing the selection, but that warrants some reflection... let new_path = if merge_at == created_node.merge_at && parent_type == created_node.parent_type { Some(Arc::new(OpPath::default())) @@ -4414,7 +4855,7 @@ fn handle_requires( parent_node_id: *created_node_id, path_in_parent: new_path, }; - dependency_graph.add_parent(new_node_id, new_parent_relation); + dependency_graph.add_parent(post_requires_node_id, new_parent_relation); } add_post_require_inputs( @@ -4425,17 +4866,16 @@ fn handle_requires( query_graph_edge_id, context, fetch_node_id, - new_node_id, + post_requires_node_id, )?; - created_nodes.extend(new_created_nodes); - created_nodes.insert(new_node_id); + created_nodes.insert(post_requires_node_id); let initial_fetch_path = create_fetch_initial_path( &dependency_graph.supergraph_schema, - &entity_type_position.clone().into(), + &entity_type_position.into(), context, )?; let new_path = fetch_node_path.for_new_key_fetch(initial_fetch_path); - Ok((new_node_id, new_path)) + Ok((post_requires_node_id, new_path)) } } @@ -4675,7 +5115,7 @@ mod tests { ) .unwrap(); - let valid_schema = ValidFederationSchema::new(schema.clone()).unwrap(); + let valid_schema = ValidFederationSchema::new(schema).unwrap(); let foo = object_field_element(&valid_schema, name!("Query"), name!("foo")); let frag = inline_fragment_element(&valid_schema, name!("Foo"), Some(name!("Foo_1"))); @@ -4737,7 +5177,7 @@ mod tests { ) .unwrap(); - let valid_schema = ValidFederationSchema::new(schema.clone()).unwrap(); + let valid_schema = ValidFederationSchema::new(schema).unwrap(); let foo = object_field_element(&valid_schema, name!("Query"), name!("foo")); let frag = inline_fragment_element(&valid_schema, name!("Foo"), Some(name!("Foo_1"))); @@ -4815,16 +5255,18 @@ mod tests { FetchDataPathElement::TypenameEquals(_) => { unimplemented!() } + FetchDataPathElement::Parent => { + unimplemented!() + } }) .join(".") ) } - fn cond_to_string(conditions: &[Name]) -> String { - if conditions.is_empty() { - return Default::default(); + fn cond_to_string(conditions: &Option>) -> String { + if let Some(conditions) = conditions { + return format!("|[{}]", conditions.iter().map(|n| n.to_string()).join(",")); } - - format!("|[{}]", conditions.iter().map(|n| n.to_string()).join(",")) + Default::default() } } diff --git a/apollo-federation/src/query_plan/fetch_dependency_graph_processor.rs b/apollo-federation/src/query_plan/fetch_dependency_graph_processor.rs index 0a3c6465d3..a9fd6e6019 100644 --- a/apollo-federation/src/query_plan/fetch_dependency_graph_processor.rs +++ b/apollo-federation/src/query_plan/fetch_dependency_graph_processor.rs @@ -1,21 +1,19 @@ use std::sync::Arc; -use apollo_compiler::collections::IndexSet; -use apollo_compiler::executable::VariableDefinition; use apollo_compiler::Name; use apollo_compiler::Node; +use apollo_compiler::collections::IndexSet; +use apollo_compiler::executable; +use apollo_compiler::executable::VariableDefinition; +use super::QueryPathElement; use super::conditions::ConditionKind; use super::query_planner::SubgraphOperationCompression; -use super::QueryPathElement; use crate::error::FederationError; use crate::operation::DirectiveList; use crate::operation::SelectionSet; -use crate::query_graph::graph_path::OpPathElement; use crate::query_graph::QueryGraph; -use crate::query_plan::conditions::Conditions; -use crate::query_plan::fetch_dependency_graph::DeferredInfo; -use crate::query_plan::fetch_dependency_graph::FetchDependencyGraphNode; +use crate::query_graph::graph_path::operation::OpPathElement; use crate::query_plan::ConditionNode; use crate::query_plan::DeferNode; use crate::query_plan::DeferredDeferBlock; @@ -25,6 +23,9 @@ use crate::query_plan::PlanNode; use crate::query_plan::PrimaryDeferBlock; use crate::query_plan::QueryPlanCost; use crate::query_plan::SequenceNode; +use crate::query_plan::conditions::Conditions; +use crate::query_plan::fetch_dependency_graph::DeferredInfo; +use crate::query_plan::fetch_dependency_graph::FetchDependencyGraphNode; /// Constant used during query plan cost computation to account for the base cost of doing a fetch, /// that is the fact any fetch imply some networking cost, request serialization/deserialization, @@ -53,7 +54,7 @@ pub(crate) struct FetchDependencyGraphToQueryPlanProcessor { operation_directives: DirectiveList, operation_compression: SubgraphOperationCompression, operation_name: Option, - assigned_defer_labels: Option>, + assigned_defer_labels: IndexSet, counter: u32, } @@ -166,7 +167,7 @@ impl FetchDependencyGraphProcessor node: &mut FetchDependencyGraphNode, _handled_conditions: &Conditions, ) -> Result { - Ok(FETCH_COST + node.cost()?) + Ok(FETCH_COST + node.cost()) } /// We don't take conditions into account in costing for now @@ -251,7 +252,7 @@ impl FetchDependencyGraphToQueryPlanProcessor { operation_directives: DirectiveList, operation_compression: SubgraphOperationCompression, operation_name: Option, - assigned_defer_labels: Option>, + assigned_defer_labels: IndexSet, ) -> Self { Self { variable_definitions, @@ -301,7 +302,7 @@ impl FetchDependencyGraphProcessor, DeferredDeferBlock> // Note that currently `ConditionNode` only works for variables // (`ConditionNode.condition` is expected to be a variable name and nothing else). // We could change that, but really, why have a trivial `ConditionNode` - // when we can optimise things righ away. + // when we can optimise things right away. condition.then_some(value) } Conditions::Variables(variables) => { @@ -341,29 +342,20 @@ impl FetchDependencyGraphProcessor, DeferredDeferBlock> node: Option, ) -> Result { /// Produce a query path with only the relevant elements: fields and type conditions. - fn op_path_to_query_path( - path: &[Arc], - ) -> Result, FederationError> { + fn op_path_to_query_path(path: &[Arc]) -> Vec { path.iter() - .map( - |element| -> Result, FederationError> { - match &**element { - OpPathElement::Field(field) => { - Ok(Some(QueryPathElement::Field(field.try_into()?))) - } - OpPathElement::InlineFragment(inline) => { - match &inline.type_condition_position { - Some(_) => Ok(Some(QueryPathElement::InlineFragment( - inline.try_into()?, - ))), - None => Ok(None), - } - } - } - }, - ) - .filter_map(|result| result.transpose()) - .collect::, _>>() + .filter_map(|element| match &**element { + OpPathElement::Field(field) => Some(QueryPathElement::Field { + response_key: field.response_name().clone(), + }), + OpPathElement::InlineFragment(inline) => inline + .type_condition_position + .as_ref() + .map(|cond| QueryPathElement::InlineFragment { + type_condition: cond.type_name().clone(), + }), + }) + .collect() } Ok(DeferredDeferBlock { @@ -373,25 +365,22 @@ impl FetchDependencyGraphProcessor, DeferredDeferBlock> .cloned() .map(|id| DeferredDependency { id }) .collect(), - label: if self - .assigned_defer_labels - .as_ref() - .is_some_and(|set| set.contains(&defer_info.label)) - { + label: if self.assigned_defer_labels.contains(&defer_info.label) { None } else { Some(defer_info.label.clone()) }, - query_path: op_path_to_query_path(&defer_info.path.full_path)?, + query_path: op_path_to_query_path(&defer_info.path.full_path), // Note that if the deferred block has nested @defer, // then the `value` is going to be a `DeferNode` // and we'll use it's own `subselection`, so we don't need it here. sub_selection: if defer_info.deferred.is_empty() { defer_info .sub_selection - .without_empty_branches()? - .map(|filtered| filtered.as_ref().try_into()) + .without_empty_branches() + .map(|filtered| executable::SelectionSet::try_from(filtered.as_ref())) .transpose()? + .map(|selection_set| selection_set.serialize().no_indent().to_string()) } else { None }, @@ -408,9 +397,10 @@ impl FetchDependencyGraphProcessor, DeferredDeferBlock> Ok(Some(PlanNode::Defer(DeferNode { primary: PrimaryDeferBlock { sub_selection: sub_selection - .without_empty_branches()? - .map(|filtered| filtered.as_ref().try_into()) - .transpose()?, + .without_empty_branches() + .map(|filtered| executable::SelectionSet::try_from(filtered.as_ref())) + .transpose()? + .map(|selection_set| selection_set.serialize().no_indent().to_string()), node: main.map(Box::new), }, deferred, @@ -467,7 +457,7 @@ fn flat_wrap_nodes( let mut iter = nodes.into_iter().flatten(); let first = iter.next()?; let Some(second) = iter.next() else { - return Some(first.clone()); + return Some(first); }; let mut nodes = Vec::new(); for node in [first, second].into_iter().chain(iter) { diff --git a/apollo-federation/src/query_plan/generate.rs b/apollo-federation/src/query_plan/generate.rs index 4001b70f6f..0de6cbde4e 100644 --- a/apollo-federation/src/query_plan/generate.rs +++ b/apollo-federation/src/query_plan/generate.rs @@ -125,7 +125,7 @@ where // all plans are guaranteed to be more costly than `initial` anyway. // Note that save for `initial`, // we always compute `partialCost` as the pros of exiting some branches early are large enough - // that it outweight computing some costs unecessarily from time to time. + // that it outweigh computing some costs unnecessarily from time to time. let mut stack = VecDeque::new(); stack.push_back(Partial { partial_plan: initial, @@ -147,10 +147,10 @@ where // If we've found some plan already, // and the partial we have is already more costly than that, // then no point continuing with it. - if let (Some((_, min_cost)), Some(partial_cost)) = (&min, &partial_cost) { - if partial_cost >= min_cost { - continue; - } + if let (Some((_, min_cost)), Some(partial_cost)) = (&min, &partial_cost) + && partial_cost >= min_cost + { + continue; } // Does not panic as we only ever insert in the stack with non-empty `remaining` @@ -221,11 +221,11 @@ fn insert_in_stack( } fn pick_next(opt_index: Option, remaining: &Choices) -> usize { - if let Some(index) = opt_index { - if let Some(choice) = remaining.get(index) { - assert!(choice.is_some(), "Invalid index {index}"); - return index; - } + if let Some(index) = opt_index + && let Some(choice) = remaining.get(index) + { + assert!(choice.is_some(), "Invalid index {index}"); + return index; } remaining .iter() @@ -251,7 +251,7 @@ mod tests { target_len: usize, } - impl<'a> PlanBuilder for TestPlanBuilder<'a> { + impl PlanBuilder for TestPlanBuilder<'_> { fn add_to_plan( &mut self, partial_plan: &Plan, diff --git a/apollo-federation/src/query_plan/mod.rs b/apollo-federation/src/query_plan/mod.rs index d8c90c2c4a..6d9cf6a636 100644 --- a/apollo-federation/src/query_plan/mod.rs +++ b/apollo-federation/src/query_plan/mod.rs @@ -1,9 +1,9 @@ use std::sync::Arc; -use apollo_compiler::executable; -use apollo_compiler::validation::Valid; use apollo_compiler::ExecutableDocument; use apollo_compiler::Name; +use apollo_compiler::executable; +use serde::Deserialize; use serde::Serialize; use crate::query_plan::query_planner::QueryPlanningStatistics; @@ -15,68 +15,65 @@ pub(crate) mod fetch_dependency_graph_processor; pub mod generate; pub mod query_planner; pub(crate) mod query_planning_traversal; +pub mod requires_selection; +pub mod serializable_document; pub type QueryPlanCost = f64; -#[derive(Debug, Default, PartialEq, Serialize)] +#[derive(Debug, Default, PartialEq, Serialize, Deserialize)] pub struct QueryPlan { pub node: Option, pub statistics: QueryPlanningStatistics, } -#[derive(Debug, PartialEq, derive_more::From, Serialize)] +#[derive(Debug, PartialEq, derive_more::From, Serialize, Deserialize)] pub enum TopLevelPlanNode { Subscription(SubscriptionNode), - #[from(types(FetchNode))] + #[from(FetchNode, Box)] Fetch(Box), Sequence(SequenceNode), Parallel(ParallelNode), Flatten(FlattenNode), Defer(DeferNode), - #[from(types(ConditionNode))] + #[from(ConditionNode, Box)] Condition(Box), } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SubscriptionNode { pub primary: Box, // XXX(@goto-bus-stop) Is this not just always a SequenceNode? pub rest: Option>, } -#[derive(Debug, Clone, PartialEq, derive_more::From, Serialize)] +#[derive(Debug, Clone, PartialEq, derive_more::From, Serialize, Deserialize)] pub enum PlanNode { - #[from(types(FetchNode))] + #[from(FetchNode, Box)] Fetch(Box), Sequence(SequenceNode), Parallel(ParallelNode), Flatten(FlattenNode), Defer(DeferNode), - #[from(types(ConditionNode))] + #[from(ConditionNode, Box)] Condition(Box), } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FetchNode { pub subgraph_name: Arc, /// Optional identifier for the fetch for defer support. All fetches of a given plan will be /// guaranteed to have a unique `id`. pub id: Option, pub variable_usages: Vec, - /// `Selection`s in apollo-rs _can_ have a `FragmentSpread`, but this `Selection` is - /// specifically typing the `requires` key in a built query plan, where there can't be - /// `FragmentSpread`. - // PORT_NOTE: This was its own type in the JS codebase, but it's likely simpler to just have the - // constraint be implicit for router instead of creating a new type. - #[serde(serialize_with = "crate::display_helpers::serialize_optional_vec_as_string")] - pub requires: Option>, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default)] + pub requires: Vec, // PORT_NOTE: We don't serialize the "operation" string in this struct, as these query plan // nodes are meant for direct consumption by router (without any serdes), so we leave the // question of whether it needs to be serialized to router. - #[serde(serialize_with = "crate::display_helpers::serialize_as_string")] - pub operation_document: Valid, + pub operation_document: serializable_document::SerializableDocument, pub operation_name: Option, - #[serde(serialize_with = "crate::display_helpers::serialize_as_string")] + #[serde(with = "crate::utils::serde_bridge::operation_type")] pub operation_kind: executable::OperationType, /// Optionally describe a number of "rewrites" that query plan executors should apply to the /// data that is sent as the input of this fetch. Note that such rewrites should only impact the @@ -88,21 +85,22 @@ pub struct FetchNode { /// received from a fetch (and before it is applied to the current in-memory results). pub output_rewrites: Vec>, /// Similar to the other kinds of rewrites. This is a mechanism to convert a contextual path into - /// an argument to a resolver + /// an argument to a resolver. Note value setters are currently unused here, but may be used in + /// the future. pub context_rewrites: Vec>, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SequenceNode { pub nodes: Vec, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ParallelNode { pub nodes: Vec, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FlattenNode { pub path: Vec, pub node: Box, @@ -124,7 +122,7 @@ pub struct FlattenNode { /// we implement more advanced server-side heuristics to decide if deferring is judicious or not. /// This allows the executor of the plan to consistently send a defer-abiding multipart response to /// the client. -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DeferNode { /// The "primary" part of a defer, that is the non-deferred part (though could be deferred /// itself for a nested defer). @@ -136,7 +134,7 @@ pub struct DeferNode { } /// The primary block of a `DeferNode`. -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PrimaryDeferBlock { /// The part of the original query that "selects" the data to send in that primary response /// once the plan in `node` completes). Note that if the parent `DeferNode` is nested, then it @@ -144,8 +142,7 @@ pub struct PrimaryDeferBlock { /// sub-selection will start at that parent `DeferredNode.query_path`. Note that this can be /// `None` in the rare case that everything in the original query is deferred (which is not very /// useful in practice, but not disallowed by the @defer spec at the moment). - #[serde(skip)] - pub sub_selection: Option, + pub sub_selection: Option, /// The plan to get all the data for the primary block. Same notes as for subselection: usually /// defined, but can be undefined in some corner cases where nothing is to be done in the /// primary block. @@ -153,7 +150,7 @@ pub struct PrimaryDeferBlock { } /// A deferred block of a `DeferNode`. -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DeferredDeferBlock { /// References one or more fetch node(s) (by `id`) within `DeferNode.primary.node`. The plan of /// this deferred part should not be started until all such fetches return. @@ -165,8 +162,7 @@ pub struct DeferredDeferBlock { pub query_path: Vec, /// The part of the original query that "selects" the data to send in the deferred response /// (once the plan in `node` completes). Will be set _unless_ `node` is a `DeferNode` itself. - #[serde(serialize_with = "crate::display_helpers::serialize_as_debug_string")] - pub sub_selection: Option, + pub sub_selection: Option, /// The plan to get all the data for this deferred block. Usually set, but can be `None` for a /// `@defer` application where everything has been fetched in the "primary block" (i.e. when /// this deferred block only exists to expose what should be send to the upstream client in a @@ -176,13 +172,13 @@ pub struct DeferredDeferBlock { pub node: Option>, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DeferredDependency { /// A `FetchNode` ID. pub id: String, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ConditionNode { pub condition_variable: Name, pub if_clause: Option>, @@ -193,14 +189,14 @@ pub struct ConditionNode { /// /// A rewrite usually identifies some sub-part of the data and some action to perform on that /// sub-part. -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, derive_more::From)] pub enum FetchDataRewrite { ValueSetter(FetchDataValueSetter), KeyRenamer(FetchDataKeyRenamer), } /// A rewrite that sets a value at the provided path of the data it is applied to. -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FetchDataValueSetter { /// Path to the value that is set by this "rewrite". pub path: Vec, @@ -210,7 +206,7 @@ pub struct FetchDataValueSetter { } /// A rewrite that renames the key at the provided path of the data it is applied to. -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FetchDataKeyRenamer { /// Path to the key that is renamed by this "rewrite". pub path: Vec, @@ -219,9 +215,10 @@ pub struct FetchDataKeyRenamer { } /// Vectors of this element match path(s) to a value in fetch data. Each element is (1) a key in -/// object data, (2) _any_ index in array data (often serialized as `@`), or (3) a typename -/// constraint on the object data at that point in the path(s) (a path should only match for objects -/// whose `__typename` is the provided type). +/// object data, (2) _any_ index in array data (often serialized as `@`), (3) a typename constraint +/// on the object data at that point in the path(s) (a path should only match for objects whose +/// `__typename` is the provided type), or (4) a parent indicator to move upwards one level in the +/// object. /// /// It's possible for vectors of this element to match no paths in fetch data, e.g. if an object key /// doesn't exist, or if an object's `__typename` doesn't equal the provided one. If this occurs, @@ -233,30 +230,34 @@ pub struct FetchDataKeyRenamer { /// Note that the `@` is currently optional in some contexts, as query plan execution may assume /// upon encountering array data in a path that it should match the remaining path to the array's /// elements. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum FetchDataPathElement { - Key(Name, Conditions), - AnyIndex(Conditions), + Key(Name, Option), + AnyIndex(Option), TypenameEquals(Name), + Parent, } pub type Conditions = Vec; /// Vectors of this element match a path in a query. Each element is (1) a field in a query, or (2) /// an inline fragment in a query. -#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, Deserialize)] pub enum QueryPathElement { - #[serde(serialize_with = "crate::display_helpers::serialize_as_string")] - Field(executable::Field), - #[serde(serialize_with = "crate::display_helpers::serialize_as_string")] - InlineFragment(executable::InlineFragment), + Field { response_key: Name }, + InlineFragment { type_condition: Name }, } -impl QueryPlan { - fn new(node: impl Into, statistics: QueryPlanningStatistics) -> Self { - Self { - node: Some(node.into()), - statistics, +impl PlanNode { + /// Returns the kind of plan node this is as a human-readable string. Exact output not guaranteed. + fn node_kind(&self) -> &'static str { + match self { + Self::Fetch(_) => "Fetch", + Self::Sequence(_) => "Sequence", + Self::Parallel(_) => "Parallel", + Self::Flatten(_) => "Flatten", + Self::Defer(_) => "Defer", + Self::Condition(_) => "Condition", } } } diff --git a/apollo-federation/src/query_plan/query_planner.rs b/apollo-federation/src/query_plan/query_planner.rs index f6f4062760..c27421784a 100644 --- a/apollo-federation/src/query_plan/query_planner.rs +++ b/apollo-federation/src/query_plan/query_planner.rs @@ -1,45 +1,52 @@ use std::cell::Cell; use std::num::NonZeroU32; -use std::ops::Deref; +use std::ops::ControlFlow; use std::sync::Arc; -use apollo_compiler::collections::HashSet; +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Name; use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use apollo_compiler::validation::Valid; -use apollo_compiler::ExecutableDocument; -use apollo_compiler::Name; use itertools::Itertools; +use serde::Deserialize; use serde::Serialize; use tracing::trace; -use super::fetch_dependency_graph::FetchIdGenerator; use super::ConditionNode; +use super::QueryPlanCost; +use super::fetch_dependency_graph::FetchIdGenerator; +use crate::ApiSchemaOptions; +use crate::Supergraph; +use crate::bail; use crate::error::FederationError; use crate::error::SingleFederationError; -use crate::operation::normalize_operation; -use crate::operation::NamedFragments; +use crate::internal_error; use crate::operation::NormalizedDefer; use crate::operation::Operation; use crate::operation::SelectionSet; -use crate::query_graph::build_federated_query_graph; -use crate::query_graph::path_tree::OpPathTree; +use crate::operation::normalize_operation; +use crate::query_graph::OverrideConditions; use crate::query_graph::QueryGraph; use crate::query_graph::QueryGraphNodeType; -use crate::query_plan::fetch_dependency_graph::compute_nodes_for_tree; +use crate::query_graph::build_federated_query_graph; +use crate::query_graph::path_tree::OpPathTree; +use crate::query_plan::PlanNode; +use crate::query_plan::QueryPlan; +use crate::query_plan::SequenceNode; +use crate::query_plan::TopLevelPlanNode; use crate::query_plan::fetch_dependency_graph::FetchDependencyGraph; use crate::query_plan::fetch_dependency_graph::FetchDependencyGraphNodePath; +use crate::query_plan::fetch_dependency_graph::compute_nodes_for_tree; use crate::query_plan::fetch_dependency_graph_processor::FetchDependencyGraphProcessor; use crate::query_plan::fetch_dependency_graph_processor::FetchDependencyGraphToCostProcessor; use crate::query_plan::fetch_dependency_graph_processor::FetchDependencyGraphToQueryPlanProcessor; use crate::query_plan::query_planning_traversal::BestQueryPlanInfo; use crate::query_plan::query_planning_traversal::QueryPlanningParameters; use crate::query_plan::query_planning_traversal::QueryPlanningTraversal; -use crate::query_plan::FetchNode; -use crate::query_plan::PlanNode; -use crate::query_plan::QueryPlan; -use crate::query_plan::SequenceNode; -use crate::query_plan::TopLevelPlanNode; +use crate::query_plan::query_planning_traversal::convert_type_from_subgraph; +use crate::query_plan::query_planning_traversal::non_local_selections_estimation; +use crate::schema::ValidFederationSchema; use crate::schema::position::AbstractTypeDefinitionPosition; use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::InterfaceTypeDefinitionPosition; @@ -47,28 +54,12 @@ use crate::schema::position::ObjectTypeDefinitionPosition; use crate::schema::position::OutputTypeDefinitionPosition; use crate::schema::position::SchemaRootDefinitionKind; use crate::schema::position::TypeDefinitionPosition; -use crate::schema::ValidFederationSchema; use crate::utils::logging::snapshot; -use crate::ApiSchemaOptions; -use crate::Supergraph; -pub(crate) const CONTEXT_DIRECTIVE: &str = "context"; - -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, Serialize)] pub struct QueryPlannerConfig { - /// Whether the query planner should try to reuse the named fragments of the planned query in - /// subgraph fetches. - /// - /// Reusing fragments requires complicated validations, so it can take a long time on large - /// queries with many fragments. This option may be removed in the future in favour of - /// [`generate_query_fragments`][QueryPlannerConfig::generate_query_fragments]. - /// - /// Defaults to false. - pub reuse_query_fragments: bool, - - /// If enabled, the query planner will extract inline fragments into fragment - /// definitions before sending queries to subgraphs. This can significantly - /// reduce the size of the query sent to subgraphs. + /// If enabled, the query planner will attempt to extract common subselections into named + /// fragments. This can significantly reduce the size of the query sent to subgraphs. /// /// Defaults to false. pub generate_query_fragments: bool, @@ -85,7 +76,7 @@ pub struct QueryPlannerConfig { // Side-note: implemented as an object instead of single boolean because we expect to add more // to this soon enough. In particular, once defer-passthrough to subgraphs is implemented, the // idea would be to add a new `passthrough_subgraphs` option that is the list of subgraphs to - // which we can pass-through some @defer (and it would be empty by default). Similarly, once we + // which we can pass through some @defer (and it would be empty by default). Similarly, once we // support @stream, grouping the options here will make sense too. pub incremental_delivery: QueryPlanIncrementalDeliveryConfig, @@ -107,7 +98,6 @@ pub struct QueryPlannerConfig { impl Default for QueryPlannerConfig { fn default() -> Self { Self { - reuse_query_fragments: false, generate_query_fragments: false, subgraph_graphql_validation: false, incremental_delivery: Default::default(), @@ -117,7 +107,7 @@ impl Default for QueryPlannerConfig { } } -#[derive(Debug, Clone, Default, Hash)] +#[derive(Debug, Clone, Default, Hash, Serialize)] pub struct QueryPlanIncrementalDeliveryConfig { /// Enables `@defer` support in the query planner, breaking up the query plan with [DeferNode]s /// as appropriate. @@ -128,16 +118,12 @@ pub struct QueryPlanIncrementalDeliveryConfig { /// Defaults to false. /// /// [DeferNode]: crate::query_plan::DeferNode + #[serde(default)] pub enable_defer: bool, } -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, Serialize)] pub struct QueryPlannerDebugConfig { - /// If used and the supergraph is built from a single subgraph, then user queries do not go - /// through the normal query planning and instead a fetch to the one subgraph is built directly - /// from the input query. - pub bypass_planner_for_single_subgraph: bool, - /// Query planning is an exploratory process. Depending on the specificities and feature used by /// subgraphs, there could exist may different theoretical valid (if not always efficient) plans /// for a given query, and at a high level, the query planner generates those possible choices, @@ -175,7 +161,6 @@ pub struct QueryPlannerDebugConfig { impl Default for QueryPlannerDebugConfig { fn default() -> Self { Self { - bypass_planner_for_single_subgraph: false, max_evaluated_plans: NonZeroU32::new(10_000).unwrap(), paths_limit: None, } @@ -183,22 +168,16 @@ impl Default for QueryPlannerDebugConfig { } // PORT_NOTE: renamed from PlanningStatistics in the JS codebase. -#[derive(Debug, PartialEq, Default, Serialize)] +#[derive(Debug, PartialEq, Default, Serialize, Deserialize)] pub struct QueryPlanningStatistics { pub evaluated_plan_count: Cell, + pub evaluated_plan_paths: Cell, + /// `best_plan_cost` can be NaN, if the cost is not computed or irrelevant. + pub best_plan_cost: f64, } -impl QueryPlannerConfig { - /// Panics if options are used together in unsupported ways. - fn assert_valid(&self) { - if self.incremental_delivery.enable_defer { - assert!(!self.debug.bypass_planner_for_single_subgraph, "Cannot use the `debug.bypass_planner_for_single_subgraph` query planner option when @defer support is enabled"); - } - } -} - -#[derive(Debug, Default, Clone)] -pub struct QueryPlanOptions { +#[derive(Clone)] +pub struct QueryPlanOptions<'a> { /// A set of labels which will be used _during query planning_ to /// enable/disable edges with a matching label in their override condition. /// Edges with override conditions require their label to be present or absent @@ -206,16 +185,53 @@ pub struct QueryPlanOptions { /// progressive @override feature. // PORT_NOTE: In JS implementation this was a Map pub override_conditions: Vec, + /// An optional function that will be called to check if the query plan should be cancelled. + /// + /// Cooperative cancellation occurs when the original client has abandoned the query. + /// When this happens, the query plan should be cancelled to free up resources. + /// + /// This function should return `ControlFlow::Break` if the query plan should be cancelled. + /// + /// Defaults to `None`. + pub check_for_cooperative_cancellation: Option<&'a dyn Fn() -> ControlFlow<()>>, + /// Impose a limit on the number of non-local selections, which can be a + /// performance hazard. On by default. + pub non_local_selections_limit_enabled: bool, + /// Names of subgraphs that are disabled and should be avoided during + /// planning. If this is non-empty, query planner may error if it cannot + /// find a plan that doesn't use the disabled subgraphs, specifically with + /// `SingleFederationError::NoPlanFoundWithDisabledSubgraphs`. + pub disabled_subgraph_names: IndexSet, } -#[derive(Debug, Default, Clone)] -pub(crate) struct EnabledOverrideConditions(HashSet); - -impl Deref for EnabledOverrideConditions { - type Target = HashSet; +impl Default for QueryPlanOptions<'_> { + fn default() -> Self { + Self { + override_conditions: Vec::new(), + check_for_cooperative_cancellation: None, + non_local_selections_limit_enabled: true, + disabled_subgraph_names: Default::default(), + } + } +} - fn deref(&self) -> &Self::Target { - &self.0 +impl std::fmt::Debug for QueryPlanOptions<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("QueryPlanOptions") + .field("override_conditions", &self.override_conditions) + .field( + "check_for_cooperative_cancellation", + if self.check_for_cooperative_cancellation.is_some() { + &"Some(...)" + } else { + &"None" + }, + ) + .field( + "non_local_selections_limit_enabled", + &self.non_local_selections_limit_enabled, + ) + .finish() } } @@ -231,7 +247,7 @@ pub struct QueryPlanner { /// subgraphs. // PORT_NOTE: Named `inconsistentAbstractTypesRuntimes` in the JS codebase, which was slightly // confusing. - abstract_types_with_inconsistent_runtime_types: IndexSet, + abstract_types_with_inconsistent_runtime_types: IndexSet, } impl QueryPlanner { @@ -243,9 +259,6 @@ impl QueryPlanner { supergraph: &Supergraph, config: QueryPlannerConfig, ) -> Result { - config.assert_valid(); - Self::check_unsupported_features(supergraph)?; - let supergraph_schema = supergraph.schema.clone(); let api_schema = supergraph.to_api_schema(ApiSchemaOptions { include_defer: config.incremental_delivery.enable_defer, @@ -327,6 +340,7 @@ impl QueryPlanner { .get_types() .filter_map(|position| AbstractTypeDefinitionPosition::try_from(position).ok()) .filter(|position| is_inconsistent(position.clone())) + .map(|position| position.type_name().clone()) .collect::>(); Ok(Self { @@ -356,76 +370,44 @@ impl QueryPlanner { ) -> Result { let operation = document .operations - .get(operation_name.as_ref().map(|name| name.as_str()))?; - + .get(operation_name.as_ref().map(|name| name.as_str())) + .map_err(|_| { + if operation_name.is_some() { + SingleFederationError::UnknownOperation + } else { + SingleFederationError::OperationNameNotProvided + } + })?; if operation.selection_set.is_empty() { // This should never happen because `operation` comes from a known-valid document. - // We could panic here but we are returning a `Result` already anyways, so shrug! - return Err(FederationError::internal( - "Invalid operation: empty selection set", - )); + crate::bail!("Invalid operation: empty selection set") } let is_subscription = operation.is_subscription(); let statistics = QueryPlanningStatistics::default(); - if self.config.debug.bypass_planner_for_single_subgraph { - let mut subgraphs = self.federated_query_graph.subgraphs(); - if let (Some((subgraph_name, _subgraph_schema)), None) = - (subgraphs.next(), subgraphs.next()) - { - let node = FetchNode { - subgraph_name: subgraph_name.clone(), - operation_document: document.clone(), - operation_name: operation.name.clone(), - operation_kind: operation.operation_type, - id: None, - variable_usages: operation - .variables - .iter() - .map(|var| var.name.clone()) - .collect(), - requires: Default::default(), - input_rewrites: Default::default(), - output_rewrites: Default::default(), - context_rewrites: Default::default(), - }; - - return Ok(QueryPlan::new(node, statistics)); - } - } - let normalized_operation = normalize_operation( operation, - NamedFragments::new(&document.fragments, &self.api_schema), + &document.fragments, &self.api_schema, &self.interface_types_with_interface_objects, + &|| { + QueryPlanningParameters::check_cancellation_with( + &options.check_for_cooperative_cancellation, + ) + }, )?; - let (normalized_operation, assigned_defer_labels, defer_conditions, has_defers) = - if self.config.incremental_delivery.enable_defer { - let NormalizedDefer { - operation, - assigned_defer_labels, - defer_conditions, - has_defers, - } = normalized_operation.with_normalized_defer()?; - if has_defers && is_subscription { - return Err(SingleFederationError::DeferredSubscriptionUnsupported.into()); - } - ( - operation, - Some(assigned_defer_labels), - Some(defer_conditions), - has_defers, - ) - } else { - // If defer is not enabled, we remove all @defer from the query. This feels cleaner do this once here than - // having to guard all the code dealing with defer later, and is probably less error prone too (less likely - // to end up passing through a @defer to a subgraph by mistake). - (normalized_operation.without_defer()?, None, None, false) - }; + let NormalizedDefer { + operation: normalized_operation, + assigned_defer_labels, + defer_conditions, + has_defers, + } = normalized_operation.with_normalized_defer()?; + if has_defers && is_subscription { + return Err(SingleFederationError::DeferredSubscriptionUnsupported.into()); + } if normalized_operation.selection_set.is_empty() { return Ok(QueryPlan::default()); @@ -446,24 +428,14 @@ impl QueryPlanner { .root_kinds_to_nodes()? .get(&normalized_operation.root_kind) else { - panic!( + bail!( "Shouldn't have a {0} operation if the subgraphs don't have a {0} root", normalized_operation.root_kind - ); + ) }; let operation_compression = if self.config.generate_query_fragments { SubgraphOperationCompression::GenerateFragments - } else if self.config.reuse_query_fragments { - // For all subgraph fetches we query `__typename` on every abstract types (see - // `FetchDependencyGraphNode::to_plan_node`) so if we want to have a chance to reuse - // fragments, we should make sure those fragments also query `__typename` for every - // abstract type. - SubgraphOperationCompression::ReuseFragments(RebasedFragments::new( - normalized_operation - .named_fragments - .add_typename_field_for_abstract_types_in_named_fragments()?, - )) } else { SubgraphOperationCompression::Disabled }; @@ -488,22 +460,43 @@ impl QueryPlanner { .clone() .into(), config: self.config.clone(), - override_conditions: EnabledOverrideConditions(HashSet::from_iter( - options.override_conditions, - )), + override_conditions: OverrideConditions::new( + &self.federated_query_graph, + &IndexSet::from_iter(options.override_conditions), + ), + check_for_cooperative_cancellation: options.check_for_cooperative_cancellation, fetch_id_generator: Arc::new(FetchIdGenerator::new()), + disabled_subgraphs: self + .federated_query_graph + .subgraphs() + .filter_map(|(subgraph, _)| { + if options.disabled_subgraph_names.contains(subgraph.as_ref()) { + Some(subgraph.clone()) + } else { + None + } + }) + .collect(), }; - let root_node = match defer_conditions { - Some(defer_conditions) if !defer_conditions.is_empty() => { - compute_plan_for_defer_conditionals( - &mut parameters, - &mut processor, - defer_conditions, - )? - } - _ => compute_plan_internal(&mut parameters, &mut processor, has_defers)?, - }; + let mut non_local_selection_state = options + .non_local_selections_limit_enabled + .then(non_local_selections_estimation::State::default); + let (root_node, cost) = if !defer_conditions.is_empty() { + compute_plan_for_defer_conditionals( + &mut parameters, + &mut processor, + defer_conditions, + &mut non_local_selection_state, + ) + } else { + compute_plan_internal( + &mut parameters, + &mut processor, + has_defers, + &mut non_local_selection_state, + ) + }?; let root_node = match root_node { // If this is a subscription, we want to make sure that we return a SubscriptionNode rather than a PlanNode @@ -517,10 +510,11 @@ impl QueryPlanner { ), Some(PlanNode::Sequence(root_node)) if is_subscription => { let Some((primary, rest)) = root_node.nodes.split_first() else { - unreachable!("Sequence must have at least one node"); + // TODO(@goto-bus-stop): We could probably guarantee this in the type system + bail!("Invalid query plan: Sequence must have at least one node"); }; let PlanNode::Fetch(primary) = primary.clone() else { - unreachable!("Primary node of a subscription is not a Fetch"); + bail!("Invalid query plan: Primary node of a subscription is not a Fetch"); }; let rest = PlanNode::Sequence(SequenceNode { nodes: rest.to_vec(), @@ -533,9 +527,10 @@ impl QueryPlanner { )) } Some(node) if is_subscription => { - unreachable!( - "Unexpected top level PlanNode: '{node:?}' when processing subscription" - ) + bail!( + "Invalid query plan for subscription: unexpected {} at root", + node.node_kind() + ); } Some(PlanNode::Fetch(inner)) => Some(TopLevelPlanNode::Fetch(inner)), Some(PlanNode::Sequence(inner)) => Some(TopLevelPlanNode::Sequence(inner)), @@ -548,7 +543,10 @@ impl QueryPlanner { let plan = QueryPlan { node: root_node, - statistics, + statistics: QueryPlanningStatistics { + best_plan_cost: cost, + ..statistics + }, }; snapshot!( @@ -569,40 +567,19 @@ impl QueryPlanner { &self.api_schema } - fn check_unsupported_features(supergraph: &Supergraph) -> Result<(), FederationError> { - // We will only check for `@context` direcive, since - // `@fromContext` can only be used if `@context` is already - // applied, and we assume a correctly composed supergraph. - // - // `@context` can only be applied on Object Types, Interface - // Types and Unions. For simplicity of this function, we just - // check all 'extended_type` directives. - let has_set_context = supergraph - .schema - .schema() - .types - .values() - .any(|extended_type| extended_type.directives().has(CONTEXT_DIRECTIVE)); - if has_set_context { - let message = "\ - `experimental_query_planner_mode: new` or `both` cannot yet \ - be used with `@context`. \ - Remove uses of `@context` to try the experimental query planner, \ - otherwise switch back to `legacy` or `both_best_effort`.\ - "; - return Err(SingleFederationError::UnsupportedFeature { - message: message.to_owned(), - kind: crate::error::UnsupportedFeatureKind::Context, - } - .into()); - } - Ok(()) + pub fn supergraph_schema(&self) -> &ValidFederationSchema { + &self.supergraph_schema + } + + pub fn override_condition_labels(&self) -> &IndexSet> { + self.federated_query_graph.override_condition_labels() } } fn compute_root_serial_dependency_graph( parameters: &QueryPlanningParameters, has_defers: bool, + non_local_selection_state: &mut Option, ) -> Result, FederationError> { let QueryPlanningParameters { supergraph_schema, @@ -629,14 +606,24 @@ fn compute_root_serial_dependency_graph( mut fetch_dependency_graph, path_tree: mut prev_path, .. - } = compute_root_parallel_best_plan(parameters, selection_set, has_defers)?; + } = compute_root_parallel_best_plan( + parameters, + selection_set, + has_defers, + non_local_selection_state, + )?; let mut prev_subgraph = only_root_subgraph(&fetch_dependency_graph)?; for selection_set in split_roots { let BestQueryPlanInfo { fetch_dependency_graph: new_dep_graph, path_tree: new_path, .. - } = compute_root_parallel_best_plan(parameters, selection_set, has_defers)?; + } = compute_root_parallel_best_plan( + parameters, + selection_set, + has_defers, + non_local_selection_state, + )?; let new_subgraph = only_root_subgraph(&new_dep_graph)?; if new_subgraph == prev_subgraph { // The new operation (think 'mutation' operation) is on the same subgraph than the previous one, so we can concat them in a single fetch @@ -657,15 +644,17 @@ fn compute_root_serial_dependency_graph( ); compute_root_fetch_groups( operation.root_kind, + federated_query_graph, &mut fetch_dependency_graph, &prev_path, parameters.config.type_conditioned_fetching, + &|| parameters.check_cancellation(), )?; } else { // PORT_NOTE: It is unclear if they correct thing to do here is get the next ID, use // the current ID that is inside the fetch dep graph's ID generator, or to use the // starting ID. Because this method ensure uniqueness between IDs, this approach was - // taken; however, it could be the case that this causes unforseen issues. + // taken; however, it could be the case that this causes unforeseen issues. digest.push(std::mem::replace( &mut fetch_dependency_graph, new_dep_graph, @@ -694,9 +683,11 @@ fn only_root_subgraph(graph: &FetchDependencyGraph) -> Result, Federati )] pub(crate) fn compute_root_fetch_groups( root_kind: SchemaRootDefinitionKind, + federated_query_graph: &QueryGraph, dependency_graph: &mut FetchDependencyGraph, path: &OpPathTree, type_conditioned_fetching_enabled: bool, + check_cancellation: &dyn Fn() -> Result<(), SingleFederationError>, ) -> Result<(), FederationError> { // The root of the pathTree is one of the "fake" root of the subgraphs graph, // which belongs to no subgraph but points to each ones. @@ -716,7 +707,7 @@ pub(crate) fn compute_root_fetch_groups( ty => { return Err(FederationError::internal(format!( "expected an object type for the root of a subgraph, found {ty}" - ))) + ))); } }; let fetch_dependency_node = dependency_graph.get_or_create_root_node( @@ -729,6 +720,12 @@ pub(crate) fn compute_root_fetch_groups( dependency_graph.to_dot(), "tree_with_root_node" ); + let subgraph_schema = federated_query_graph.schema_by_source(subgraph_name)?; + let supergraph_root_type = convert_type_from_subgraph( + root_type, + subgraph_schema, + &dependency_graph.supergraph_schema, + )?; compute_nodes_for_tree( dependency_graph, &child.tree, @@ -736,10 +733,11 @@ pub(crate) fn compute_root_fetch_groups( FetchDependencyGraphNodePath::new( dependency_graph.supergraph_schema.clone(), type_conditioned_fetching_enabled, - root_type, + supergraph_root_type, )?, Default::default(), &Default::default(), + check_cancellation, )?; } Ok(()) @@ -748,22 +746,29 @@ pub(crate) fn compute_root_fetch_groups( fn compute_root_parallel_dependency_graph( parameters: &QueryPlanningParameters, has_defers: bool, -) -> Result { + non_local_selection_state: &mut Option, +) -> Result<(FetchDependencyGraph, QueryPlanCost), FederationError> { trace!("Starting process to construct a parallel fetch dependency graph"); let selection_set = parameters.operation.selection_set.clone(); - let best_plan = compute_root_parallel_best_plan(parameters, selection_set, has_defers)?; + let best_plan = compute_root_parallel_best_plan( + parameters, + selection_set, + has_defers, + non_local_selection_state, + )?; snapshot!( "FetchDependencyGraph", best_plan.fetch_dependency_graph.to_dot(), "Fetch dependency graph returned from compute_root_parallel_best_plan" ); - Ok(best_plan.fetch_dependency_graph) + Ok((best_plan.fetch_dependency_graph, best_plan.cost)) } fn compute_root_parallel_best_plan( parameters: &QueryPlanningParameters, selection: SelectionSet, has_defers: bool, + non_local_selection_state: &mut Option, ) -> Result { let planning_traversal = QueryPlanningTraversal::new( parameters, @@ -771,6 +776,7 @@ fn compute_root_parallel_best_plan( has_defers, parameters.operation.root_kind, FetchDependencyGraphToCostProcessor, + non_local_selection_state.as_mut(), )?; // Getting no plan means the query is essentially unsatisfiable (it's a valid query, but we can prove it will never return a result), @@ -784,11 +790,18 @@ fn compute_plan_internal( parameters: &mut QueryPlanningParameters, processor: &mut FetchDependencyGraphToQueryPlanProcessor, has_defers: bool, -) -> Result, FederationError> { + non_local_selection_state: &mut Option, +) -> Result<(Option, QueryPlanCost), FederationError> { let root_kind = parameters.operation.root_kind; - let (main, deferred, primary_selection) = if root_kind == SchemaRootDefinitionKind::Mutation { - let dependency_graphs = compute_root_serial_dependency_graph(parameters, has_defers)?; + let (main, deferred, primary_selection, cost) = if root_kind + == SchemaRootDefinitionKind::Mutation + { + let dependency_graphs = compute_root_serial_dependency_graph( + parameters, + has_defers, + non_local_selection_state, + )?; let mut main = None; let mut deferred = vec![]; let mut primary_selection = None::; @@ -810,9 +823,14 @@ fn compute_plan_internal( None => primary_selection = new_selection, } } - (main, deferred, primary_selection) + // No cost computation necessary. Return NaN for cost. + (main, deferred, primary_selection, f64::NAN) } else { - let mut dependency_graph = compute_root_parallel_dependency_graph(parameters, has_defers)?; + let (mut dependency_graph, cost) = compute_root_parallel_dependency_graph( + parameters, + has_defers, + non_local_selection_state, + )?; let (main, deferred) = dependency_graph.process(&mut *processor, root_kind)?; snapshot!( @@ -823,16 +841,17 @@ fn compute_plan_internal( // XXX(@goto-bus-stop) Maybe `.defer_tracking` should be on the return value of `process()`..? let primary_selection = dependency_graph.defer_tracking.primary_selection; - (main, deferred, primary_selection) + (main, deferred, primary_selection, cost) }; if deferred.is_empty() { - Ok(main) + Ok((main, cost)) } else { let Some(primary_selection) = primary_selection else { unreachable!("Should have had a primary selection created"); }; - processor.reduce_defer(main, &primary_selection, deferred) + let reduced_main = processor.reduce_defer(main, &primary_selection, deferred)?; + Ok((reduced_main, cost)) } } @@ -840,13 +859,14 @@ fn compute_plan_for_defer_conditionals( parameters: &mut QueryPlanningParameters, processor: &mut FetchDependencyGraphToQueryPlanProcessor, defer_conditions: IndexMap>, -) -> Result, FederationError> { + non_local_selection_state: &mut Option, +) -> Result<(Option, QueryPlanCost), FederationError> { generate_condition_nodes( parameters.operation.clone(), defer_conditions.iter(), &mut |op| { parameters.operation = op; - compute_plan_internal(parameters, processor, true) + compute_plan_internal(parameters, processor, true, non_local_selection_state) }, ) } @@ -854,61 +874,36 @@ fn compute_plan_for_defer_conditionals( fn generate_condition_nodes<'a>( op: Arc, mut conditions: impl Clone + Iterator)>, - on_final_operation: &mut impl FnMut(Arc) -> Result, FederationError>, -) -> Result, FederationError> { + on_final_operation: &mut impl FnMut( + Arc, + ) -> Result<(Option, f64), FederationError>, +) -> Result<(Option, f64), FederationError> { match conditions.next() { None => on_final_operation(op), Some((cond, labels)) => { let else_op = Arc::unwrap_or_clone(op.clone()).reduce_defer(labels)?; let if_op = op; + let (if_node, if_cost) = + generate_condition_nodes(if_op, conditions.clone(), on_final_operation)?; + let (else_node, else_cost) = generate_condition_nodes( + Arc::new(else_op), + conditions.clone(), + on_final_operation, + )?; let node = ConditionNode { condition_variable: cond.clone(), - if_clause: generate_condition_nodes(if_op, conditions.clone(), on_final_operation)? - .map(Box::new), - else_clause: generate_condition_nodes( - Arc::new(else_op), - conditions.clone(), - on_final_operation, - )? - .map(Box::new), + if_clause: if_node.map(Box::new), + else_clause: else_node.map(Box::new), }; - Ok(Some(PlanNode::Condition(Box::new(node)))) - } - } -} - -/// Tracks fragments from the original operation, along with versions rebased on other subgraphs. -pub(crate) struct RebasedFragments { - original_fragments: NamedFragments, - /// Map key: subgraph name - rebased_fragments: IndexMap, NamedFragments>, -} - -impl RebasedFragments { - fn new(fragments: NamedFragments) -> Self { - Self { - original_fragments: fragments, - rebased_fragments: Default::default(), + Ok(( + Some(PlanNode::Condition(Box::new(node))), + if_cost.max(else_cost), + )) } } - - fn for_subgraph( - &mut self, - subgraph_name: impl Into>, - subgraph_schema: &ValidFederationSchema, - ) -> &NamedFragments { - self.rebased_fragments - .entry(subgraph_name.into()) - .or_insert_with(|| { - self.original_fragments - .rebase_on(subgraph_schema) - .unwrap_or_default() - }) - } } pub(crate) enum SubgraphOperationCompression { - ReuseFragments(RebasedFragments), GenerateFragments, Disabled, } @@ -917,23 +912,22 @@ impl SubgraphOperationCompression { /// Compress a subgraph operation. pub(crate) fn compress( &mut self, - subgraph_name: &Arc, - subgraph_schema: &ValidFederationSchema, operation: Operation, - ) -> Result { + ) -> Result, FederationError> { match self { - Self::ReuseFragments(fragments) => { - let rebased = fragments.for_subgraph(Arc::clone(subgraph_name), subgraph_schema); - let mut operation = operation; - operation.reuse_fragments(rebased)?; - Ok(operation) - } - Self::GenerateFragments => { - let mut operation = operation; - operation.generate_fragments()?; - Ok(operation) + Self::GenerateFragments => Ok(operation.generate_fragments()?), + Self::Disabled => { + let operation_document = operation.try_into().map_err(|err: FederationError| { + if err.has_invalid_graphql_error() { + internal_error!( + "Query planning produced an invalid subgraph operation.\n{err}" + ) + } else { + err + } + })?; + Ok(operation_document) } - Self::Disabled => Ok(operation), } } } @@ -941,7 +935,6 @@ impl SubgraphOperationCompression { #[cfg(test)] mod tests { use super::*; - use crate::subgraph::Subgraph; const TEST_SUPERGRAPH: &str = r#" schema @@ -1296,68 +1289,7 @@ type User } #[test] - fn bypass_planner_for_single_subgraph() { - let a = Subgraph::parse_and_expand( - "A", - "https://A", - r#" - type Query { - a: A - } - type A { - b: B - } - type B { - x: Int - y: String - } - "#, - ) - .unwrap(); - let subgraphs = vec![&a]; - let supergraph = Supergraph::compose(subgraphs).unwrap(); - let api_schema = supergraph.to_api_schema(Default::default()).unwrap(); - - let document = ExecutableDocument::parse_and_validate( - api_schema.schema(), - r#" - { - a { - b { - x - y - } - } - } - "#, - "", - ) - .unwrap(); - - let mut config = QueryPlannerConfig::default(); - config.debug.bypass_planner_for_single_subgraph = true; - let planner = QueryPlanner::new(&supergraph, config).unwrap(); - let plan = planner - .build_query_plan(&document, None, Default::default()) - .unwrap(); - insta::assert_snapshot!(plan, @r###" - QueryPlan { - Fetch(service: "A") { - { - a { - b { - x - y - } - } - } - }, - } - "###); - } - - #[test] - fn test_optimize_basic() { + fn test_optimize_no_fragments_generated() { let supergraph = Supergraph::new(TEST_SUPERGRAPH).unwrap(); let api_schema = supergraph.to_api_schema(Default::default()).unwrap(); let document = ExecutableDocument::parse_and_validate( @@ -1383,7 +1315,7 @@ type User .unwrap(); let config = QueryPlannerConfig { - reuse_query_fragments: true, + generate_query_fragments: true, ..Default::default() }; let planner = QueryPlanner::new(&supergraph, config).unwrap(); @@ -1395,150 +1327,15 @@ type User Fetch(service: "accounts") { { userById(id: 1) { - ...userFields id - } - another_user: userById(id: 2) { - ...userFields - } - } - - fragment userFields on User { - name - email - } - }, - } - "###); - } - - #[test] - fn test_optimize_inline_fragment() { - let supergraph = Supergraph::new(TEST_SUPERGRAPH).unwrap(); - let api_schema = supergraph.to_api_schema(Default::default()).unwrap(); - let document = ExecutableDocument::parse_and_validate( - api_schema.schema(), - r#" - { - userById(id: 1) { - id - ...userFields - }, - partial_optimize: userById(id: 2) { - ... on User { - id - name - email - } - }, - full_optimize: userById(id: 3) { - ... on User { - name - email - } - } - } - fragment userFields on User { name email - } - "#, - "operation.graphql", - ) - .unwrap(); - - let config = QueryPlannerConfig { - reuse_query_fragments: true, - ..Default::default() - }; - let planner = QueryPlanner::new(&supergraph, config).unwrap(); - let plan = planner - .build_query_plan(&document, None, Default::default()) - .unwrap(); - insta::assert_snapshot!(plan, @r###" - QueryPlan { - Fetch(service: "accounts") { - { - userById(id: 1) { - ...userFields - id - } - partial_optimize: userById(id: 2) { - ...userFields - id } - full_optimize: userById(id: 3) { - ...userFields - } - } - - fragment userFields on User { - name - email - } - }, - } - "###); - } - - #[test] - fn test_optimize_fragment_definition() { - let supergraph = Supergraph::new(TEST_SUPERGRAPH).unwrap(); - let api_schema = supergraph.to_api_schema(Default::default()).unwrap(); - let document = ExecutableDocument::parse_and_validate( - api_schema.schema(), - r#" - { - userById(id: 1) { - ...F1 - ...F2 - }, - case2: userById(id: 2) { - id - name - email - }, - } - fragment F1 on User { - name - email - } - fragment F2 on User { - id + another_user: userById(id: 2) { name email - } - "#, - "operation.graphql", - ) - .unwrap(); - - let config = QueryPlannerConfig { - reuse_query_fragments: true, - ..Default::default() - }; - let planner = QueryPlanner::new(&supergraph, config).unwrap(); - let plan = planner - .build_query_plan(&document, None, Default::default()) - .unwrap(); - // Make sure `fragment F2` contains `...F1`. - insta::assert_snapshot!(plan, @r###" - QueryPlan { - Fetch(service: "accounts") { - { - userById(id: 1) { - ...F2 - } - case2: userById(id: 2) { - ...F2 } } - - fragment F2 on User { - name - email - id - } }, } "###); @@ -1546,33 +1343,18 @@ type User #[test] fn drop_operation_root_level_typename() { - let subgraph1 = Subgraph::parse_and_expand( - "Subgraph1", - "https://Subgraph1", - r#" - type Query { - t: T - } - - type T @key(fields: "id") { - id: ID! - x: Int - } - "#, - ) - .unwrap(); - let subgraphs = vec![&subgraph1]; - let supergraph = Supergraph::compose(subgraphs).unwrap(); + let supergraph = Supergraph::new(TEST_SUPERGRAPH).unwrap(); let planner = QueryPlanner::new(&supergraph, Default::default()).unwrap(); + let document = ExecutableDocument::parse_and_validate( planner.api_schema().schema(), r#" - query { - __typename - t { - x - } + { + __typename + bestRatedProducts { + id } + } "#, "operation.graphql", ) @@ -1580,12 +1362,14 @@ type User let plan = planner .build_query_plan(&document, None, Default::default()) .unwrap(); + // Note: There should be no `__typename` selection at the root level. insta::assert_snapshot!(plan, @r###" QueryPlan { - Fetch(service: "Subgraph1") { + Fetch(service: "reviews") { { - t { - x + bestRatedProducts { + __typename + id } } }, diff --git a/apollo-federation/src/query_plan/query_planning_traversal.rs b/apollo-federation/src/query_plan/query_planning_traversal.rs index 055498218f..18623ed00b 100644 --- a/apollo-federation/src/query_plan/query_planning_traversal.rs +++ b/apollo-federation/src/query_plan/query_planning_traversal.rs @@ -1,5 +1,7 @@ +use std::ops::ControlFlow; use std::sync::Arc; +use apollo_compiler::Name; use apollo_compiler::collections::IndexSet; use petgraph::graph::EdgeIndex; use petgraph::graph::NodeIndex; @@ -7,48 +9,50 @@ use serde::Serialize; use tracing::trace; use super::fetch_dependency_graph::FetchIdGenerator; +use crate::ensure; use crate::error::FederationError; +use crate::error::SingleFederationError; use crate::operation::Operation; use crate::operation::Selection; use crate::operation::SelectionSet; +use crate::query_graph::OverrideConditions; +use crate::query_graph::QueryGraph; +use crate::query_graph::QueryGraphNodeType; +use crate::query_graph::condition_resolver::CachingConditionResolver; use crate::query_graph::condition_resolver::ConditionResolution; -use crate::query_graph::condition_resolver::ConditionResolutionCacheResult; -use crate::query_graph::condition_resolver::ConditionResolver; use crate::query_graph::condition_resolver::ConditionResolverCache; -use crate::query_graph::graph_path::create_initial_options; -use crate::query_graph::graph_path::ClosedBranch; -use crate::query_graph::graph_path::ClosedPath; use crate::query_graph::graph_path::ExcludedConditions; use crate::query_graph::graph_path::ExcludedDestinations; -use crate::query_graph::graph_path::OpGraphPath; -use crate::query_graph::graph_path::OpGraphPathContext; -use crate::query_graph::graph_path::OpPathElement; -use crate::query_graph::graph_path::OpenBranch; -use crate::query_graph::graph_path::SimultaneousPaths; -use crate::query_graph::graph_path::SimultaneousPathsWithLazyIndirectPaths; +use crate::query_graph::graph_path::operation::ClosedBranch; +use crate::query_graph::graph_path::operation::ClosedPath; +use crate::query_graph::graph_path::operation::OpGraphPath; +use crate::query_graph::graph_path::operation::OpGraphPathContext; +use crate::query_graph::graph_path::operation::OpPathElement; +use crate::query_graph::graph_path::operation::OpenBranch; +use crate::query_graph::graph_path::operation::OpenBranchAndSelections; +use crate::query_graph::graph_path::operation::SimultaneousPaths; +use crate::query_graph::graph_path::operation::SimultaneousPathsWithLazyIndirectPaths; +use crate::query_graph::graph_path::operation::create_initial_options; use crate::query_graph::path_tree::OpPathTree; -use crate::query_graph::QueryGraph; -use crate::query_graph::QueryGraphNodeType; -use crate::query_plan::fetch_dependency_graph::compute_nodes_for_tree; +use crate::query_plan::QueryPlanCost; use crate::query_plan::fetch_dependency_graph::FetchDependencyGraph; use crate::query_plan::fetch_dependency_graph::FetchDependencyGraphNodePath; +use crate::query_plan::fetch_dependency_graph::compute_nodes_for_tree; use crate::query_plan::fetch_dependency_graph_processor::FetchDependencyGraphProcessor; use crate::query_plan::fetch_dependency_graph_processor::FetchDependencyGraphToCostProcessor; -use crate::query_plan::generate::generate_all_plans_and_find_best; use crate::query_plan::generate::PlanBuilder; -use crate::query_plan::query_planner::compute_root_fetch_groups; -use crate::query_plan::query_planner::EnabledOverrideConditions; +use crate::query_plan::generate::generate_all_plans_and_find_best; use crate::query_plan::query_planner::QueryPlannerConfig; use crate::query_plan::query_planner::QueryPlanningStatistics; -use crate::query_plan::QueryPlanCost; -use crate::schema::position::AbstractTypeDefinitionPosition; +use crate::query_plan::query_planner::compute_root_fetch_groups; +use crate::schema::ValidFederationSchema; use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; use crate::schema::position::SchemaRootDefinitionKind; -use crate::schema::ValidFederationSchema; -use crate::utils::logging::format_open_branch; use crate::utils::logging::snapshot; +pub(crate) mod non_local_selections_estimation; + #[cfg(feature = "snapshot_tracing")] mod snapshot_helper { // A module to import functions only used within `snapshot!(...)` macros. @@ -79,12 +83,32 @@ pub(crate) struct QueryPlanningParameters<'a> { /// subgraphs. // PORT_NOTE: Named `inconsistentAbstractTypesRuntimes` in the JS codebase, which was slightly // confusing. - pub(crate) abstract_types_with_inconsistent_runtime_types: - Arc>, + pub(crate) abstract_types_with_inconsistent_runtime_types: Arc>, /// The configuration for the query planner. pub(crate) config: QueryPlannerConfig, pub(crate) statistics: &'a QueryPlanningStatistics, - pub(crate) override_conditions: EnabledOverrideConditions, + pub(crate) override_conditions: OverrideConditions, + pub(crate) check_for_cooperative_cancellation: Option<&'a dyn Fn() -> ControlFlow<()>>, + pub(crate) disabled_subgraphs: IndexSet>, +} + +impl QueryPlanningParameters<'_> { + pub(crate) fn check_cancellation(&self) -> Result<(), SingleFederationError> { + Self::check_cancellation_with(&self.check_for_cooperative_cancellation) + } + + pub(crate) fn check_cancellation_with( + check: &Option<&dyn Fn() -> ControlFlow<()>>, + ) -> Result<(), SingleFederationError> { + if let Some(check) = check { + match check() { + ControlFlow::Continue(()) => Ok(()), + ControlFlow::Break(()) => Err(SingleFederationError::PlanningCancelled), + } + } else { + Ok(()) + } + } } pub(crate) struct QueryPlanningTraversal<'a, 'b> { @@ -121,34 +145,6 @@ pub(crate) struct QueryPlanningTraversal<'a, 'b> { resolver_cache: ConditionResolverCache, } -#[derive(Debug, Serialize)] -pub(crate) struct OpenBranchAndSelections { - /// The options for this open branch. - open_branch: OpenBranch, - /// A stack of the remaining selections to plan from the node this open branch ends on. - selections: Vec, -} - -impl std::fmt::Display for OpenBranchAndSelections { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Some((current_selection, remaining_selections)) = self.selections.split_last() else { - return Ok(()); - }; - format_open_branch(f, &(current_selection, &self.open_branch.0))?; - write!(f, " * Remaining selections:")?; - if remaining_selections.is_empty() { - writeln!(f, " (none)")?; - } else { - // Print in reverse order since remaining selections are processed in that order. - writeln!(f)?; // newline - for selection in remaining_selections.iter().rev() { - writeln!(f, " - {selection}")?; - } - } - Ok(()) - } -} - struct PlanInfo { fetch_dependency_graph: FetchDependencyGraph, path_tree: Arc, @@ -187,6 +183,29 @@ impl BestQueryPlanInfo { } } +pub(crate) fn convert_type_from_subgraph( + ty: CompositeTypeDefinitionPosition, + subgraph_schema: &ValidFederationSchema, + supergraph_schema: &ValidFederationSchema, +) -> Result { + if subgraph_schema.is_interface_object_type(ty.clone().into())? { + let type_in_supergraph_pos: CompositeTypeDefinitionPosition = supergraph_schema + .get_type(ty.type_name().clone())? + .try_into()?; + ensure!( + matches!( + type_in_supergraph_pos, + CompositeTypeDefinitionPosition::Interface(_) + ), + "Type {} should be an interface in the supergraph", + ty.type_name() + ); + Ok(type_in_supergraph_pos) + } else { + Ok(ty) + } +} + impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { #[cfg_attr( feature = "snapshot_tracing", @@ -202,6 +221,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { has_defers: bool, root_kind: SchemaRootDefinitionKind, cost_processor: FetchDependencyGraphToCostProcessor, + non_local_selection_state: Option<&mut non_local_selections_estimation::State>, ) -> Result { Self::new_inner( parameters, @@ -210,6 +230,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { parameters.fetch_id_generator.clone(), root_kind, cost_processor, + non_local_selection_state, Default::default(), Default::default(), Default::default(), @@ -229,6 +250,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { id_generator: Arc, root_kind: SchemaRootDefinitionKind, cost_processor: FetchDependencyGraphToCostProcessor, + non_local_selection_state: Option<&mut non_local_selections_estimation::State>, initial_context: OpGraphPathContext, excluded_destinations: ExcludedDestinations, excluded_conditions: ExcludedConditions, @@ -257,7 +279,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { // query graph. let tail = parameters .federated_query_graph - .node_weight(initial_path.tail)?; + .node_weight(initial_path.tail())?; // Two-step initialization: initializing open_branches requires a condition resolver, // which `QueryPlanningTraversal` is. @@ -282,10 +304,24 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { excluded_destinations, excluded_conditions, ¶meters.override_conditions, + ¶meters.disabled_subgraphs, )?; traversal.open_branches = map_options_to_selections(selection_set, initial_options); + if let Some(non_local_selection_state) = non_local_selection_state + && traversal + .check_non_local_selections_limit_exceeded_at_root(non_local_selection_state)? + { + return Err(SingleFederationError::QueryPlanComplexityExceeded { + message: format!( + "Number of non-local selections exceeds limit of {}", + Self::MAX_NON_LOCAL_SELECTIONS, + ), + } + .into()); + } + Ok(traversal) } @@ -314,6 +350,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { )] fn find_best_plan_inner(&mut self) -> Result, FederationError> { while !self.open_branches.is_empty() { + self.parameters.check_cancellation()?; snapshot!( "OpenBranches", snapshot_helper::open_branches_to_string(&self.open_branches), @@ -347,7 +384,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { } } self.compute_best_plan_from_closed_branches()?; - return Ok(self.best_plan.as_ref()); + Ok(self.best_plan.as_ref()) } /// Returns whether to terminate planning immediately, and any new open branches to push onto @@ -365,7 +402,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { selection: &Selection, options: &mut Vec, ) -> Result<(bool, Option), FederationError> { - let operation_element = selection.element()?; + let operation_element = selection.element(); let mut new_options = vec![]; let mut no_followups: bool = false; @@ -376,11 +413,14 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { ); for option in options.iter_mut() { + self.parameters.check_cancellation()?; let followups_for_option = option.advance_with_operation_element( self.parameters.supergraph_schema.clone(), &operation_element, /*resolver*/ self, &self.parameters.override_conditions, + &|| self.parameters.check_cancellation(), + &self.parameters.disabled_subgraphs, )?; let Some(followups_for_option) = followups_for_option else { // There is no valid way to advance the current operation element from this option @@ -393,15 +433,23 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { no_followups = true; break; } + + let evaluated_paths_count = &self.parameters.statistics.evaluated_plan_paths; + let simultaneous_indirect_path_count: usize = + followups_for_option.iter().map(|p| p.paths.0.len()).sum(); + evaluated_paths_count + .set(evaluated_paths_count.get() + simultaneous_indirect_path_count); + new_options.extend(followups_for_option); - if let Some(options_limit) = self.parameters.config.debug.paths_limit { - if new_options.len() > options_limit as usize { - // TODO: Create a new error code for this error kind. - return Err(FederationError::internal(format!( - "Too many options generated for {}, reached the limit of {}.", - selection, options_limit, - ))); - } + if let Some(options_limit) = self.parameters.config.debug.paths_limit + && new_options.len() > options_limit as usize + { + return Err(SingleFederationError::QueryPlanComplexityExceeded { + message: format!( + "Too many options generated for {selection}, reached the limit of {options_limit}.", + ), + } + .into()); } } @@ -473,10 +521,15 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { // happen for a top-level query planning (unless the supergraph has *not* been // validated), but can happen when computing sub-plans for a key condition. return if self.is_top_level { - Err(FederationError::internal(format!( - "Was not able to find any options for {}: This shouldn't have happened.", - selection, - ))) + if self.parameters.disabled_subgraphs.is_empty() { + Err(FederationError::internal(format!( + "Was not able to find any options for {selection}: This shouldn't have happened.", + ))) + } else { + // If subgraphs were disabled, this could be expected, and we indicate this in + // the error accordingly. + Err(SingleFederationError::NoPlanFoundWithDisabledSubgraphs.into()) + } } else { // Indicate to the caller that query planning should terminate with no plan. Ok((true, None)) @@ -487,7 +540,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { let mut all_tail_nodes = IndexSet::default(); for option in &new_options { for path in &option.paths.0 { - all_tail_nodes.insert(path.tail); + all_tail_nodes.insert(path.tail()); } } if self.selection_set_is_fully_local_from_all_nodes(selection_set, &all_tail_nodes)? @@ -564,7 +617,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { // To guarantee that the selection is fully local from the provided vertex/type, we must have: // - no edge crossing subgraphs from that vertex. // - the type must be compositeType (mostly just ensuring the selection make sense). - // - everything in the selection must be avaiable in the type (which `rebaseOn` essentially validates). + // - everything in the selection must be available in the type (which `rebaseOn` essentially validates). // - the selection must not "type-cast" into any abstract type that has inconsistent runtimes acrosse subgraphs. The reason for the // later condition is that `selection` is originally a supergraph selection, but that we're looking to apply "as-is" to a subgraph. // But suppose it has a `... on I` where `I` is an interface. Then it's possible that `I` includes "more" types in the supergraph @@ -578,16 +631,15 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { let check_result = selection.any_element(&mut |element| match element { OpPathElement::InlineFragment(inline_fragment) => { match &inline_fragment.type_condition_position { - Some(type_condition) => Ok(self + Some(type_condition) => self .parameters .abstract_types_with_inconsistent_runtime_types - .iter() - .any(|ty| ty.type_name() == type_condition.type_name())), - None => Ok(false), + .contains(type_condition.type_name()), + None => false, } } - _ => Ok(false), - })?; + _ => false, + }); has_inconsistent_abstract_types = Some(check_result); Ok(check_result) } @@ -838,11 +890,11 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { } /// Look at how many plans we'd have to generate and if it's "too much" - /// reduce it to something manageable by arbitrarilly throwing out options. + /// reduce it to something manageable by arbitrarily throwing out options. /// This effectively means that when a query has too many options, /// we give up on always finding the "best" query plan in favor of an "ok" query plan. /// - /// TODO: currently, when we need to reduce options, we do so somewhat arbitrarilly. + /// TODO: currently, when we need to reduce options, we do so somewhat arbitrarily. /// More precisely, we reduce the branches with the most options first /// and then drop the last option of the branch, /// repeating until we have a reasonable number of plans to consider. @@ -1004,9 +1056,11 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { if is_root_path_tree { compute_root_fetch_groups( self.root_kind, + &self.parameters.federated_query_graph, dependency_graph, path_tree, type_conditioned_fetching_enabled, + &|| self.parameters.check_cancellation(), )?; } else { let query_graph_node = path_tree.graph.node_weight(path_tree.node)?; @@ -1024,6 +1078,15 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { self.root_kind, root_type.clone(), )?; + let subgraph_schema = self + .parameters + .federated_query_graph + .schema_by_source(&query_graph_node.source)?; + let supergraph_root_type = convert_type_from_subgraph( + root_type, + subgraph_schema, + &dependency_graph.supergraph_schema, + )?; compute_nodes_for_tree( dependency_graph, path_tree, @@ -1031,10 +1094,11 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { FetchDependencyGraphNodePath::new( dependency_graph.supergraph_schema.clone(), self.parameters.config.type_conditioned_fetching, - root_type, + supergraph_root_type, )?, Default::default(), &Default::default(), + &|| self.parameters.check_cancellation(), )?; } @@ -1060,17 +1124,16 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { context: &OpGraphPathContext, excluded_destinations: &ExcludedDestinations, excluded_conditions: &ExcludedConditions, + extra_conditions: Option<&SelectionSet>, ) -> Result { let graph = &self.parameters.federated_query_graph; let head = graph.edge_endpoints(edge)?.0; // Note: `QueryPlanningTraversal::resolve` method asserts that the edge has conditions before // calling this method. - let edge_conditions = graph - .edge_weight(edge)? - .conditions - .as_ref() - .unwrap() - .as_ref(); + let edge_conditions = match extra_conditions { + Some(set) => set, + None => graph.edge_weight(edge)?.conditions.as_ref().unwrap(), + }; let parameters = QueryPlanningParameters { head, head_must_be_root: graph.node_weight(head)?.is_root_node(), @@ -1087,6 +1150,8 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { statistics: self.parameters.statistics, override_conditions: self.parameters.override_conditions.clone(), fetch_id_generator: self.parameters.fetch_id_generator.clone(), + check_for_cooperative_cancellation: self.parameters.check_for_cooperative_cancellation, + disabled_subgraphs: self.parameters.disabled_subgraphs.clone(), }; let best_plan_opt = QueryPlanningTraversal::new_inner( ¶meters, @@ -1095,6 +1160,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { self.id_generator.clone(), self.root_kind, self.cost_processor, + None, context.clone(), excluded_destinations.clone(), excluded_conditions.add_item(edge_conditions), @@ -1104,6 +1170,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { Some(best_plan) => Ok(ConditionResolution::Satisfied { cost: best_plan.cost, path_tree: Some(best_plan.path_tree), + context_map: None, }), None => Ok(ConditionResolution::unsatisfied_conditions()), } @@ -1162,44 +1229,30 @@ impl<'a: 'b, 'b> PlanBuilder> for QueryPlanningTravers } } -// PORT_NOTE: In JS version, QueryPlanningTraversal has `conditionResolver` field, which -// is a closure calling `this.resolveConditionPlan` (`this` is captured here). -// The same would be infeasible to implement in Rust due to the cyclic references. -// Thus, instead of `condition_resolver` field, QueryPlanningTraversal was made to -// implement `ConditionResolver` trait along with `resolver_cache` field. -impl<'a> ConditionResolver for QueryPlanningTraversal<'a, '_> { - /// A query plan resolver for edge conditions that caches the outcome per edge. - fn resolve( - &mut self, +impl CachingConditionResolver for QueryPlanningTraversal<'_, '_> { + fn query_graph(&self) -> &QueryGraph { + &self.parameters.federated_query_graph + } + + fn resolver_cache(&mut self) -> &mut ConditionResolverCache { + &mut self.resolver_cache + } + + fn resolve_without_cache( + &self, edge: EdgeIndex, context: &OpGraphPathContext, excluded_destinations: &ExcludedDestinations, excluded_conditions: &ExcludedConditions, + extra_conditions: Option<&SelectionSet>, ) -> Result { - // Invariant check: The edge must have conditions. - let graph = &self.parameters.federated_query_graph; - let edge_data = graph.edge_weight(edge)?; - assert!( - edge_data.conditions.is_some(), - "Should not have been called for edge without conditions" - ); - - let cache_result = - self.resolver_cache - .contains(edge, context, excluded_destinations, excluded_conditions); - - if let ConditionResolutionCacheResult::Hit(cached_resolution) = cache_result { - return Ok(cached_resolution); - } - - let resolution = - self.resolve_condition_plan(edge, context, excluded_destinations, excluded_conditions)?; - // See if this resolution is eligible to be inserted into the cache. - if cache_result.is_miss() { - self.resolver_cache - .insert(edge, resolution.clone(), excluded_destinations.clone()); - } - Ok(resolution) + self.resolve_condition_plan( + edge, + context, + excluded_destinations, + excluded_conditions, + extra_conditions, + ) } } @@ -1238,7 +1291,7 @@ fn test_prune_and_reorder_first_branch() { assert_eq!(branches, expected) } // Either the first branch had strictly more options than the second, - // so it is still at its correct potition after removing one option… + // so it is still at its correct position after removing one option… assert( &["abcdE", "fgh", "ijk", "lmn", "op"], &["abcd", "fgh", "ijk", "lmn", "op"], diff --git a/apollo-federation/src/query_plan/query_planning_traversal/non_local_selections_estimation.rs b/apollo-federation/src/query_plan/query_planning_traversal/non_local_selections_estimation.rs new file mode 100644 index 0000000000..82593fa878 --- /dev/null +++ b/apollo-federation/src/query_plan/query_planning_traversal/non_local_selections_estimation.rs @@ -0,0 +1,987 @@ +use apollo_compiler::Name; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; +use apollo_compiler::schema::ExtendedType; +use indexmap::map::Entry; +use petgraph::graph::NodeIndex; +use petgraph::visit::EdgeRef; +use petgraph::visit::IntoNodeReferences; + +use crate::bail; +use crate::error::FederationError; +use crate::operation::Selection; +use crate::operation::SelectionSet; +use crate::query_graph::OverrideCondition; +use crate::query_graph::QueryGraph; +use crate::query_graph::QueryGraphEdgeTransition; +use crate::query_graph::graph_path::operation::OpPathElement; +use crate::query_plan::query_planning_traversal::QueryPlanningTraversal; +use crate::schema::position::CompositeTypeDefinitionPosition; +use crate::schema::position::INTROSPECTION_TYPENAME_FIELD_NAME; +use crate::schema::position::ObjectTypeDefinitionPosition; + +impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { + pub(super) const MAX_NON_LOCAL_SELECTIONS: u64 = 100_000; + + /// This calls `check_non_local_selections_limit_exceeded()` for each of the selections in the + /// open branches stack; see that function's doc comment for more information. + pub(super) fn check_non_local_selections_limit_exceeded_at_root( + &self, + state: &mut State, + ) -> Result { + for branch in &self.open_branches { + let tail_nodes = branch + .open_branch + .0 + .iter() + .flat_map(|option| option.paths.0.iter().map(|path| path.tail())) + .collect::>(); + let tail_nodes_info = self.estimate_nodes_with_indirect_options(tail_nodes)?; + + // Note that top-level selections aren't avoided via fully-local selection set + // optimization, so we always add them here. + if Self::update_count( + branch.selections.len(), + tail_nodes_info.next_nodes.len(), + state, + ) { + return Ok(true); + } + + for selection in &branch.selections { + if let Some(selection_set) = selection.selection_set() { + let selection_has_defer = selection.element().has_defer(); + let next_nodes = self.estimate_next_nodes_for_selection( + &selection.element(), + &tail_nodes_info, + state, + )?; + if self.check_non_local_selections_limit_exceeded( + selection_set, + &next_nodes, + selection_has_defer, + state, + )? { + return Ok(true); + } + } + } + } + Ok(false) + } + + /// When recursing through a selection set to generate options from each element, there is an + /// optimization that allows us to avoid option exploration if a selection set is "fully local" + /// from all the possible nodes we could be at in the query graph. + /// + /// This function computes an approximate upper bound on the number of selections in a selection + /// set that wouldn't be avoided by such an optimization (i.e. the "non-local" selections), and + /// adds it to the given count in the state. Note that the count for a given selection set is + /// scaled by an approximate upper bound on the possible number of tail nodes for paths ending + /// at that selection set. If at any point, the count exceeds `Self::MAX_NON_LOCAL_SELECTIONS`, + /// then this function will return `true`. + /// + /// This function's code is closely related to `selection_set_is_fully_local_from_all_nodes()` + /// (which implements the aforementioned optimization). However, when it comes to traversing the + /// query graph, we generally ignore the effects of edge pre-conditions and other optimizations + /// to option generation for efficiency's sake, giving us an upper bound since the extra nodes + /// may fail some of the checks (e.g. the selection set may not rebase on them). + /// + /// Note that this function takes in whether the parent selection of the selection set has + /// @defer, as that affects whether the optimization is disabled for that selection set. + fn check_non_local_selections_limit_exceeded( + &self, + selection_set: &SelectionSet, + parent_nodes: &NextNodesInfo, + parent_selection_has_defer: bool, + state: &mut State, + ) -> Result { + // Compute whether the selection set is non-local, and if so, add its selections to the + // count. Any of the following causes the selection set to be non-local. + // 1. The selection set's nodes having at least one reachable cross-subgraph edge. + // 2. The parent selection having @defer. + // 3. Any selection in the selection set having @defer. + // 4. Any selection in the selection set being an inline fragment whose type condition + // has inconsistent runtime types across subgraphs. + // 5. Any selection in the selection set being unable to be rebased on the selection + // set's nodes. + // 6. Any nested selection sets causing the count to be incremented. + let mut selection_set_is_non_local = parent_nodes + .next_nodes_have_reachable_cross_subgraph_edges + || parent_selection_has_defer; + for selection in selection_set.selections.values() { + let element = selection.element(); + let selection_has_defer = element.has_defer(); + let selection_has_inconsistent_runtime_types = + if let OpPathElement::InlineFragment(inline_fragment) = element { + inline_fragment + .type_condition_position + .map(|type_condition_pos| { + self.parameters + .abstract_types_with_inconsistent_runtime_types + .contains(type_condition_pos.type_name()) + }) + .unwrap_or_default() + } else { + false + }; + + let old_count = state.count; + if let Some(selection_set) = selection.selection_set() { + let next_nodes = self.estimate_next_nodes_for_selection( + &selection.element(), + parent_nodes, + state, + )?; + if self.check_non_local_selections_limit_exceeded( + selection_set, + &next_nodes, + selection_has_defer, + state, + )? { + return Ok(true); + } + } + + selection_set_is_non_local = selection_set_is_non_local + || selection_has_defer + || selection_has_inconsistent_runtime_types + || (old_count != state.count); + } + // Determine whether the selection can be rebased on all selection set nodes (without + // indirect options). This is more expensive, so we do this last/only if needed. Note + // that we were originally calling a slightly modified `can_add_to()` to mimic the logic + // in `selection_set_is_fully_local_from_all_nodes()`, but this ended up being rather + // expensive in practice, so an optimized version using precomputation is used below. + if !selection_set_is_non_local && !parent_nodes.next_nodes.is_empty() { + let metadata = self + .parameters + .federated_query_graph + .non_local_selection_metadata(); + for selection in selection_set.selections.values() { + match selection { + Selection::Field(field) => { + // Note that while the precomputed metadata accounts for @fromContext, + // it doesn't account for checking whether the operation field's parent + // type either matches the subgraph schema's parent type name or is an + // interface type. Given current composition rules, this should always + // be the case when rebasing supergraph/API schema queries onto one of + // its subgraph schema, so we avoid the check here for performance. + let Some(rebaseable_parent_nodes) = metadata + .fields_to_rebaseable_parent_nodes + .get(field.field.name()) + else { + selection_set_is_non_local = true; + break; + }; + if !parent_nodes.next_nodes.is_subset(rebaseable_parent_nodes) { + selection_set_is_non_local = true; + break; + } + } + Selection::InlineFragment(inline_fragment) => { + let Some(type_condition_pos) = + &inline_fragment.inline_fragment.type_condition_position + else { + // Inline fragments without type conditions can always be rebased. + continue; + }; + let Some(rebaseable_parent_nodes) = metadata + .inline_fragments_to_rebaseable_parent_nodes + .get(type_condition_pos.type_name()) + else { + selection_set_is_non_local = true; + break; + }; + if !parent_nodes.next_nodes.is_subset(rebaseable_parent_nodes) { + selection_set_is_non_local = true; + break; + } + } + } + } + } + if selection_set_is_non_local + && Self::update_count( + selection_set.selections.len(), + parent_nodes.next_nodes.len(), + state, + ) + { + return Ok(true); + } + Ok(false) + } + + /// Updates the non-local selection set count in the state, returning true if this causes the + /// count to exceed `Self::MAX_NON_LOCAL_SELECTIONS`. + fn update_count(num_selections: usize, num_parent_nodes: usize, state: &mut State) -> bool { + let Ok(num_selections) = u64::try_from(num_selections) else { + return true; + }; + let Ok(num_parent_nodes) = u64::try_from(num_parent_nodes) else { + return true; + }; + let Some(additional_count) = num_selections.checked_mul(num_parent_nodes) else { + return true; + }; + if let Some(new_count) = state + .count + .checked_add(additional_count) + .take_if(|v| *v <= Self::MAX_NON_LOCAL_SELECTIONS) + { + state.count = new_count; + } else { + return true; + }; + false + } + + /// In `check_non_local_selections_limit_exceeded()`, when handling a given selection for a set + /// of parent nodes (including indirect options), this function can be used to estimate an + /// upper bound on the next nodes after taking the selection (also with indirect options). + fn estimate_next_nodes_for_selection( + &self, + element: &OpPathElement, + parent_nodes: &NextNodesInfo, + state: &mut State, + ) -> Result { + let cache = state + .next_nodes_cache + .entry(match element { + OpPathElement::Field(field) => { + SelectionKey::Field(field.field_position.field_name().clone()) + } + OpPathElement::InlineFragment(inline_fragment) => { + let Some(type_condition_pos) = &inline_fragment.type_condition_position else { + return Ok(parent_nodes.clone()); + }; + SelectionKey::InlineFragment(type_condition_pos.type_name().clone()) + } + }) + .or_default(); + let mut next_nodes = NextNodesInfo::default(); + for type_name in &parent_nodes.next_nodes_with_indirect_options.types { + match cache.types_to_next_nodes.entry(type_name.clone()) { + Entry::Occupied(entry) => next_nodes.extend(entry.get()), + Entry::Vacant(entry) => { + let Some(indirect_options) = self + .parameters + .federated_query_graph + .non_local_selection_metadata() + .types_to_indirect_options + .get(type_name) + else { + bail!("Unexpectedly missing node information for cached type."); + }; + let new_next_nodes = self.estimate_next_nodes_for_selection_without_caching( + element, + indirect_options.same_type_options.iter(), + )?; + next_nodes.extend(entry.insert(new_next_nodes)); + } + } + } + for node in &parent_nodes + .next_nodes_with_indirect_options + .remaining_nodes + { + match cache.remaining_nodes_to_next_nodes.entry(*node) { + Entry::Occupied(entry) => next_nodes.extend(entry.get()), + Entry::Vacant(entry) => { + let new_next_nodes = self.estimate_next_nodes_for_selection_without_caching( + element, + std::iter::once(node), + )?; + next_nodes.extend(entry.insert(new_next_nodes)); + } + } + } + Ok(next_nodes) + } + + /// Estimate an upper bound on the next nodes after taking the selection on the given parent + /// nodes. Because we're just trying for an upper bound, we assume we can always take + /// type-preserving non-collecting transitions, we ignore any conditions on the selection + /// edge, and we always type-explode. (We do account for override conditions, which are + /// relatively straightforward.) + /// + /// Since we're iterating through next nodes in the process, for efficiency sake we also + /// compute whether there are any reachable cross-subgraph edges from the next nodes + /// (without indirect options). This method assumes that inline fragments have type + /// conditions. + fn estimate_next_nodes_for_selection_without_caching<'c>( + &self, + element: &OpPathElement, + parent_nodes: impl Iterator, + ) -> Result { + let mut next_nodes = IndexSet::default(); + let nodes_to_object_type_downcasts = &self + .parameters + .federated_query_graph + .non_local_selection_metadata() + .nodes_to_object_type_downcasts; + match element { + OpPathElement::Field(field) => { + let Some(field_endpoints) = self + .parameters + .federated_query_graph + .non_local_selection_metadata() + .fields_to_endpoints + .get(field.name()) + else { + return Ok(Default::default()); + }; + let mut process_head_node = |node: NodeIndex| { + let Some(target) = field_endpoints.get(&node) else { + return; + }; + match target { + FieldTarget::NonOverride(target) => { + next_nodes.insert(*target); + } + FieldTarget::Override(target, condition) => { + if condition.check(&self.parameters.override_conditions) { + next_nodes.insert(*target); + } + } + } + }; + for node in parent_nodes { + // As an upper bound for efficiency sake, we consider both non-type-exploded + // and type-exploded options. + process_head_node(*node); + let Some(object_type_downcasts) = nodes_to_object_type_downcasts.get(node) + else { + continue; + }; + match object_type_downcasts { + ObjectTypeDowncasts::NonInterfaceObject(downcasts) => { + for node in downcasts.values() { + process_head_node(*node); + } + } + ObjectTypeDowncasts::InterfaceObject(_) => { + // Interface object fake downcasts only go back to the + // self node, so we ignore them. + } + } + } + } + OpPathElement::InlineFragment(inline_fragment) => { + let Some(type_condition_pos) = &inline_fragment.type_condition_position else { + bail!("Inline fragment unexpectedly had no type condition") + }; + let inline_fragment_endpoints = self + .parameters + .federated_query_graph + .non_local_selection_metadata() + .inline_fragments_to_endpoints + .get(type_condition_pos.type_name()); + // If we end up computing runtime types for the type condition, only do it once. + let mut possible_runtime_types = None; + for node in parent_nodes { + // We check whether there's already a (maybe fake) downcast edge for the + // type condition (note that we've inserted fake downcasts for same-type + // type conditions into the metadata). + if let Some(next_node) = inline_fragment_endpoints.and_then(|e| e.get(node)) { + next_nodes.insert(*next_node); + continue; + } + + // If not, then we need to type explode across the possible runtime types + // (in the supergraph schema) for the type condition. + let Some(downcasts) = nodes_to_object_type_downcasts.get(node) else { + continue; + }; + let possible_runtime_types = match &possible_runtime_types { + Some(possible_runtime_types) => possible_runtime_types, + None => { + let type_condition_in_supergraph_pos = self + .parameters + .supergraph_schema + .get_type(type_condition_pos.type_name().clone())?; + possible_runtime_types.insert( + self.parameters.supergraph_schema.possible_runtime_types( + type_condition_in_supergraph_pos.try_into()?, + )?, + ) + } + }; + + match downcasts { + ObjectTypeDowncasts::NonInterfaceObject(downcasts) => { + for (type_name, target_node) in downcasts { + if possible_runtime_types.contains(&ObjectTypeDefinitionPosition { + type_name: type_name.clone(), + }) { + next_nodes.insert(*target_node); + } + } + } + ObjectTypeDowncasts::InterfaceObject(downcasts) => { + for type_name in downcasts { + if possible_runtime_types.contains(&ObjectTypeDefinitionPosition { + type_name: type_name.clone(), + }) { + // Note that interface object fake downcasts are self edges, + // so we're done once we find one. + next_nodes.insert(*node); + break; + } + } + } + } + } + } + } + + self.estimate_nodes_with_indirect_options(next_nodes) + } + + /// Estimate the indirect options for the given next nodes, and add them to the given nodes. + /// As an upper bound for efficiency's sake, we assume we can take any indirect option (i.e. + /// ignore any edge conditions). + fn estimate_nodes_with_indirect_options( + &self, + next_nodes: IndexSet, + ) -> Result { + let mut next_nodes_info = NextNodesInfo { + next_nodes, + ..Default::default() + }; + for next_node in &next_nodes_info.next_nodes { + let next_node_weight = self + .parameters + .federated_query_graph + .node_weight(*next_node)?; + next_nodes_info.next_nodes_have_reachable_cross_subgraph_edges = next_nodes_info + .next_nodes_have_reachable_cross_subgraph_edges + || next_node_weight.has_reachable_cross_subgraph_edges; + + let next_node_type_pos: CompositeTypeDefinitionPosition = + next_node_weight.type_.clone().try_into()?; + if let Some(options_metadata) = self + .parameters + .federated_query_graph + .non_local_selection_metadata() + .types_to_indirect_options + .get(next_node_type_pos.type_name()) + { + // If there's an entry in `types_to_indirect_options` for the type, then the + // complete digraph for T is non-empty, so we add its type. If it's our first + // time seeing this type, we also add any of the complete digraph's interface + // object options. + if next_nodes_info + .next_nodes_with_indirect_options + .types + .insert(next_node_type_pos.type_name().clone()) + { + next_nodes_info + .next_nodes_with_indirect_options + .types + .extend(options_metadata.interface_object_options.iter().cloned()); + } + // If the node is a member of the complete digraph, then we don't need to + // separately add the remaining node. + if options_metadata.same_type_options.contains(next_node) { + continue; + } + } + // We need to add the remaining node, and if its our first time seeing it, we also + // add any of its interface object options. + if next_nodes_info + .next_nodes_with_indirect_options + .remaining_nodes + .insert(*next_node) + && let Some(options) = self + .parameters + .federated_query_graph + .non_local_selection_metadata() + .remaining_nodes_to_interface_object_options + .get(next_node) + { + next_nodes_info + .next_nodes_with_indirect_options + .types + .extend(options.iter().cloned()); + } + } + + Ok(next_nodes_info) + } +} + +/// Precompute relevant metadata about the query graph for speeding up the estimation of the +/// count of non-local selections. Note that none of the algorithms used in this function should +/// take any longer algorithmically as the rest of query graph creation (and similarly for +/// query graph memory). +pub(crate) fn precompute_non_local_selection_metadata( + graph: &QueryGraph, +) -> Result { + let mut nodes_to_interface_object_options: IndexMap> = + Default::default(); + let mut metadata = QueryGraphMetadata::default(); + + for edge_ref in graph.graph().edge_references() { + match &edge_ref.weight().transition { + QueryGraphEdgeTransition::FieldCollection { + field_definition_position, + .. + } => { + // We skip selections where the tail is a non-composite type, as we'll never + // need to estimate the next nodes for such selections. + if CompositeTypeDefinitionPosition::try_from( + graph.node_weight(edge_ref.target())?.type_.clone(), + ) + .is_err() + { + continue; + }; + let target = edge_ref + .weight() + .override_condition + .clone() + .map(|condition| FieldTarget::Override(edge_ref.target(), condition)) + .unwrap_or_else(|| FieldTarget::NonOverride(edge_ref.target())); + metadata + .fields_to_endpoints + .entry(field_definition_position.field_name().clone()) + .or_default() + .insert(edge_ref.source(), target); + } + QueryGraphEdgeTransition::Downcast { + to_type_position, .. + } => { + if to_type_position.is_object_type() { + let ObjectTypeDowncasts::NonInterfaceObject(downcasts) = metadata + .nodes_to_object_type_downcasts + .entry(edge_ref.source()) + .or_insert_with(|| { + ObjectTypeDowncasts::NonInterfaceObject(Default::default()) + }) + else { + bail!("Unexpectedly found interface object with regular object downcasts") + }; + downcasts.insert(to_type_position.type_name().clone(), edge_ref.target()); + } + metadata + .inline_fragments_to_endpoints + .entry(to_type_position.type_name().clone()) + .or_default() + .insert(edge_ref.source(), edge_ref.target()); + } + QueryGraphEdgeTransition::InterfaceObjectFakeDownCast { to_type_name, .. } => { + // Note that fake downcasts for interface objects are only created to "fake" + // object types. + let ObjectTypeDowncasts::InterfaceObject(downcasts) = metadata + .nodes_to_object_type_downcasts + .entry(edge_ref.source()) + .or_insert_with(|| ObjectTypeDowncasts::InterfaceObject(Default::default())) + else { + bail!("Unexpectedly found abstract type with interface object downcasts") + }; + downcasts.insert(to_type_name.clone()); + metadata + .inline_fragments_to_endpoints + .entry(to_type_name.clone()) + .or_default() + .insert(edge_ref.source(), edge_ref.target()); + } + QueryGraphEdgeTransition::KeyResolution + | QueryGraphEdgeTransition::RootTypeResolution { .. } => { + let head_type_pos: CompositeTypeDefinitionPosition = graph + .node_weight(edge_ref.source())? + .type_ + .clone() + .try_into()?; + let tail_type_pos: CompositeTypeDefinitionPosition = graph + .node_weight(edge_ref.target())? + .type_ + .clone() + .try_into()?; + if head_type_pos.type_name() == tail_type_pos.type_name() { + // In this case, we have a non-interface-object key resolution edge or a + // root type resolution edge. The tail must be part of the complete digraph + // for the tail's type, so we record the member. + metadata + .types_to_indirect_options + .entry(tail_type_pos.type_name().clone()) + .or_default() + .same_type_options + .insert(edge_ref.target()); + } else { + // Otherwise, this must be an interface object key resolution edge. We don't + // know the members of the complete digraph for the head's type yet, so we + // can't set the metadata yet, and instead store the head to interface + // object type mapping in a temporary map. + nodes_to_interface_object_options + .entry(edge_ref.source()) + .or_default() + .insert(tail_type_pos.type_name().clone()); + } + } + QueryGraphEdgeTransition::SubgraphEnteringTransition => {} + } + } + + // Now that we've finished computing members of the complete digraphs, we can properly track + // interface object options. + for (node, options) in nodes_to_interface_object_options { + let node_type_pos: CompositeTypeDefinitionPosition = + graph.node_weight(node)?.type_.clone().try_into()?; + if let Some(options_metadata) = metadata + .types_to_indirect_options + .get_mut(node_type_pos.type_name()) + && options_metadata.same_type_options.contains(&node) + { + options_metadata + .interface_object_options + .extend(options.into_iter()); + continue; + } + metadata + .remaining_nodes_to_interface_object_options + .insert(node, options); + } + + // The interface object options for the complete digraphs are now correct, but we need to + // subtract these from any interface object options for remaining nodes. + for (node, options) in metadata + .remaining_nodes_to_interface_object_options + .iter_mut() + { + let node_type_pos: CompositeTypeDefinitionPosition = + graph.node_weight(*node)?.type_.clone().try_into()?; + let Some(IndirectOptionsMetadata { + interface_object_options, + .. + }) = metadata + .types_to_indirect_options + .get(node_type_pos.type_name()) + else { + continue; + }; + options.retain(|type_name| !interface_object_options.contains(type_name)); + } + + // If this subtraction left any interface object option sets empty, we remove them. + metadata + .remaining_nodes_to_interface_object_options + .retain(|_, options| !options.is_empty()); + + // For all composite type nodes, we pretend that there's a self-downcast edge for that type, + // as this simplifies next node calculation. + for (node, node_weight) in graph.graph().node_references() { + let Ok(node_type_pos) = + CompositeTypeDefinitionPosition::try_from(node_weight.type_.clone()) + else { + continue; + }; + metadata + .inline_fragments_to_endpoints + .entry(node_type_pos.type_name().clone()) + .or_default() + .insert(node, node); + if node_type_pos.is_object_type() + && !graph + .schema_by_source(&node_weight.source)? + .is_interface_object_type(node_type_pos.clone().into())? + { + let ObjectTypeDowncasts::NonInterfaceObject(downcasts) = metadata + .nodes_to_object_type_downcasts + .entry(node) + .or_insert_with(|| ObjectTypeDowncasts::NonInterfaceObject(Default::default())) + else { + bail!( + "Unexpectedly found object type with interface object downcasts in supergraph" + ) + }; + downcasts.insert(node_type_pos.type_name().clone(), node); + } + } + + // For each subgraph schema, we iterate through its composite types, so that we can collect + // metadata relevant to rebasing. + for (subgraph_name, subgraph_schema) in graph.subgraph_schemas() { + // We pass through each composite type, recording whether the field can be rebased on it + // along with interface implements/union membership relationships. + let mut fields_to_rebaseable_types: IndexMap> = Default::default(); + let mut object_types_to_implementing_composite_types: IndexMap> = + Default::default(); + let Some(subgraph_metadata) = subgraph_schema.subgraph_metadata() else { + bail!("Subgraph schema unexpectedly did not have subgraph metadata") + }; + let from_context_directive_definition_name = &subgraph_metadata + .federation_spec_definition() + .from_context_directive_definition(subgraph_schema)? + .name; + for (type_name, type_) in &subgraph_schema.schema().types { + match type_ { + ExtendedType::Object(type_) => { + // Record fields that don't contain @fromContext as being rebaseable (also + // including __typename). + for (field_name, field_definition) in &type_.fields { + if field_definition.arguments.iter().any(|arg_definition| { + arg_definition + .directives + .has(from_context_directive_definition_name) + }) { + continue; + } + fields_to_rebaseable_types + .entry(field_name.clone()) + .or_default() + .insert(type_name.clone()); + } + fields_to_rebaseable_types + .entry(INTROSPECTION_TYPENAME_FIELD_NAME.clone()) + .or_default() + .insert(type_name.clone()); + // Record the object type as implementing itself. + let implementing_composite_types = object_types_to_implementing_composite_types + .entry(type_name.clone()) + .or_default(); + implementing_composite_types.insert(type_name.clone()); + // For each implements, record the interface type as an implementing type. + if !type_.implements_interfaces.is_empty() { + implementing_composite_types.extend( + type_ + .implements_interfaces + .iter() + .map(|interface_name| interface_name.name.clone()), + ); + } + } + ExtendedType::Interface(type_) => { + // Record fields that don't contain @fromContext as being rebaseable (also + // including __typename). + for (field_name, field_definition) in &type_.fields { + if field_definition.arguments.iter().any(|arg_definition| { + arg_definition + .directives + .has(from_context_directive_definition_name) + }) { + continue; + } + fields_to_rebaseable_types + .entry(field_name.clone()) + .or_default() + .insert(type_name.clone()); + } + fields_to_rebaseable_types + .entry(INTROSPECTION_TYPENAME_FIELD_NAME.clone()) + .or_default() + .insert(type_name.clone()); + } + ExtendedType::Union(type_) => { + // Just record the __typename field as being rebaseable. + fields_to_rebaseable_types + .entry(INTROSPECTION_TYPENAME_FIELD_NAME.clone()) + .or_default() + .insert(type_name.clone()); + // For each member, record the union type as an implementing type. + for member_name in &type_.members { + object_types_to_implementing_composite_types + .entry(member_name.name.clone()) + .or_default() + .insert(type_name.clone()); + } + } + _ => {} + } + } + + // With the interface implements/union membership relationships, we can compute which + // pairs of types have at least one possible runtime type in their intersection, and + // are thus rebaseable. + let mut inline_fragments_to_rebaseable_types: IndexMap> = + Default::default(); + for implementing_types in object_types_to_implementing_composite_types.values() { + for type_name in implementing_types { + inline_fragments_to_rebaseable_types + .entry(type_name.clone()) + .or_default() + .extend(implementing_types.iter().cloned()) + } + } + + // Finally, we can compute the nodes for the rebaseable types, as we'll be working with + // those instead of types when checking whether an operation element can be rebased. + let types_to_nodes = graph.types_to_nodes_by_source(subgraph_name)?; + for (field_name, types) in fields_to_rebaseable_types { + metadata + .fields_to_rebaseable_parent_nodes + .entry(field_name) + .or_default() + .extend( + types + .iter() + .flat_map(|type_| types_to_nodes.get(type_).map(|nodes| nodes.iter())) + .flatten() + .cloned(), + ); + } + for (type_condition_name, types) in inline_fragments_to_rebaseable_types { + metadata + .inline_fragments_to_rebaseable_parent_nodes + .entry(type_condition_name) + .or_default() + .extend( + types + .iter() + .flat_map(|type_| types_to_nodes.get(type_).map(|nodes| nodes.iter())) + .flatten() + .cloned(), + ) + } + } + Ok(metadata) +} + +/// During query graph creation, we pre-compute metadata that helps us greatly speed up the +/// estimation process during request execution. The expected time and memory consumed for +/// pre-computation is (in the worst case) expected to be on the order of the number of nodes +/// plus the number of edges. +/// +/// Note that when the below field docs talk about a "complete digraph", they are referring to +/// the graph theory concept: https://en.wikipedia.org/wiki/Complete_graph +#[derive(Debug, Default)] +pub(crate) struct QueryGraphMetadata { + /// When a (resolvable) @key exists on a type T in a subgraph, a key resolution edge is + /// created from every subgraph's type T to that subgraph's type T. This similarly holds for + /// root type resolution edges. This means that the nodes of type T with such a @key (or are + /// operation root types) form a complete digraph in the query graph. These indirect options + /// effectively occur as a group in our estimation process, so we track group members here + /// per type name, and precompute units of work relative to these groups. + /// + /// Interface object types I in a subgraph will only sometimes create a key resolution edge + /// between an implementing type T in a subgraph and that subgraph's type I. This means the + /// nodes of the complete digraph for I are indirect options for such nodes of type T. We + /// track any such types I that are reachable for at least one node in the complete digraph + /// for type T here as well. + types_to_indirect_options: IndexMap, + /// For nodes of a type T that aren't in their complete digraph (due to not having a @key), + /// these remaining nodes will have the complete digraph of T (and any interface object + /// complete digraphs) as indirect options, but these remaining nodes may separately have + /// more indirect options that are not options for the complete digraph of T, specifically + /// if the complete digraph for T has no key resolution edges to an interface object I, but + /// this remaining node does. We keep track of such interface object types for those + /// remaining nodes here. + remaining_nodes_to_interface_object_options: IndexMap>, + /// A map of field names to the endpoints of field query graph edges with that field name. Note + /// we additionally store the progressive overrides label, if the edge is conditioned on it. + fields_to_endpoints: IndexMap>, + /// A map of type condition names to endpoints of downcast query graph edges with that type + /// condition name, including fake downcasts for interface objects, and a non-existent edge that + /// represents a type condition name equal to the parent type. + inline_fragments_to_endpoints: IndexMap>, + /// A map of composite type nodes to their downcast edges that lead specifically to an object + /// type (i.e., the possible runtime types of the node's type). + nodes_to_object_type_downcasts: IndexMap, + /// A map of field names to parent nodes whose corresponding type and schema can be rebased on + /// by the field. + fields_to_rebaseable_parent_nodes: IndexMap>, + /// A map of type condition names to parent nodes whose corresponding type and schema can be + /// rebased on by an inline fragment with that type condition. + inline_fragments_to_rebaseable_parent_nodes: IndexMap>, +} + +/// Indirect option metadata for the complete digraph for type T. See [QueryGraphMetadata] for +/// more information about how we group indirect options into complete digraphs. +#[derive(Debug, Default)] +pub(crate) struct IndirectOptionsMetadata { + /// The members of the complete digraph for type T. + same_type_options: IndexSet, + /// Any interface object types I that are reachable for at least one node in the complete + /// digraph for type T. + interface_object_options: IndexSet, +} + +#[derive(Debug)] +enum FieldTarget { + /// Normal non-overridden fields, which don't have a label condition. + NonOverride(NodeIndex), + /// Overridden fields, which have a label condition. + Override(NodeIndex, OverrideCondition), +} + +#[derive(Debug)] +enum ObjectTypeDowncasts { + /// Normal non-interface-object types have regular downcasts to their object type nodes. + NonInterfaceObject(IndexMap), + /// Interface object types have "fake" downcasts to nodes that are really the self node. + InterfaceObject(IndexSet), +} + +#[derive(Debug, Default)] +pub(crate) struct State { + /// An estimation of the number of non-local selections for the whole operation (where the count + /// for a given selection set is scaled by the number of tail nodes at that selection set). Note + /// this does not count selections from recursive query planning. + pub(crate) count: u64, + /// Whenever we take a selection on a set of nodes with indirect options, we cache the + /// resulting nodes here. + next_nodes_cache: IndexMap, +} + +#[derive(Debug, PartialEq, Eq, Hash)] +enum SelectionKey { + /// For field selections, this is the field's name. + Field(Name), + /// For inline fragment selections, this is the type condition's name. + InlineFragment(Name), +} + +/// See [QueryGraphMetadata] for more information about how we group indirect options into +/// complete digraphs. +#[derive(Debug, Default)] +struct NextNodesCache { + /// This is the merged next node info for selections on the set of nodes in the complete + /// digraph for the given type T. Note that this does not merge in the next node info for + /// any interface object options reachable from nodes in that complete digraph for T. + types_to_next_nodes: IndexMap, + /// This is the next node info for selections on the given node. Note that this does not + /// merge in the next node info for any interface object options reachable from that node. + remaining_nodes_to_next_nodes: IndexMap, +} + +#[derive(Clone, Debug, Default)] +struct NextNodesInfo { + /// The next nodes after taking the selection. + next_nodes: IndexSet, + /// Whether any cross-subgraph edges are reachable from any next nodes. + next_nodes_have_reachable_cross_subgraph_edges: bool, + /// These are the next nodes along with indirect options, represented succinctly by the + /// types of any complete digraphs along with remaining nodes. + next_nodes_with_indirect_options: NodesWithIndirectOptionsInfo, +} + +impl NextNodesInfo { + fn extend(&mut self, other: &Self) { + self.next_nodes.extend(other.next_nodes.iter().cloned()); + self.next_nodes_have_reachable_cross_subgraph_edges = self + .next_nodes_have_reachable_cross_subgraph_edges + || other.next_nodes_have_reachable_cross_subgraph_edges; + self.next_nodes_with_indirect_options + .extend(&other.next_nodes_with_indirect_options); + } +} + +#[derive(Clone, Debug, Default)] +struct NodesWithIndirectOptionsInfo { + /// For indirect options that are representable as complete digraphs for a type T, these are + /// those types. + types: IndexSet, + /// For any nodes of type T that aren't in their complete digraphs for type T, these are + /// those nodes. + remaining_nodes: IndexSet, +} + +impl NodesWithIndirectOptionsInfo { + fn extend(&mut self, other: &Self) { + self.types.extend(other.types.iter().cloned()); + self.remaining_nodes + .extend(other.remaining_nodes.iter().cloned()); + } +} diff --git a/apollo-federation/src/query_plan/requires_selection.rs b/apollo-federation/src/query_plan/requires_selection.rs new file mode 100644 index 0000000000..c180600e50 --- /dev/null +++ b/apollo-federation/src/query_plan/requires_selection.rs @@ -0,0 +1,34 @@ +//! A selection set representation for `FetchNode::requires`: +//! +//! * Does not contain fragment spreads +//! * Is (de)serializable + +use apollo_compiler::Name; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "PascalCase", tag = "kind")] +pub enum Selection { + Field(Field), + InlineFragment(InlineFragment), +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Field { + #[serde(skip_serializing_if = "Option::is_none")] + pub alias: Option, + pub name: Name, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default)] + pub selections: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InlineFragment { + #[serde(skip_serializing_if = "Option::is_none")] + pub type_condition: Option, + pub selections: Vec, +} diff --git a/apollo-federation/src/query_plan/serializable_document.rs b/apollo-federation/src/query_plan/serializable_document.rs new file mode 100644 index 0000000000..ad6fefbc27 --- /dev/null +++ b/apollo-federation/src/query_plan/serializable_document.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Schema; +use apollo_compiler::validation::Valid; +use apollo_compiler::validation::WithErrors; +use serde::Deserialize; +use serde::Serialize; + +/// Like `Valid>` but can be (de)serialized as a string in GraphQL syntax. +/// +/// The relevant schema is required to parse from string but not available during deserialization, +/// so this contains a dual (either or both) “string” or “parsed” representation. +/// Accessing the latter is fallible, and requires an explicit initialization step to provide the schema. +#[derive(Clone)] +pub struct SerializableDocument { + serialized: String, + /// Ideally this would be always present, + /// but we don’t have access to the relevant schema during `Deserialize`. + parsed: Option>>, +} + +impl SerializableDocument { + pub fn from_string(serialized: impl Into) -> Self { + Self { + serialized: serialized.into(), + parsed: None, + } + } + + pub fn from_parsed(parsed: impl Into>>) -> Self { + let parsed = parsed.into(); + Self { + serialized: parsed.serialize().no_indent().to_string(), + parsed: Some(parsed), + } + } + + pub fn as_serialized(&self) -> &str { + &self.serialized + } + + #[allow(clippy::result_large_err)] + pub fn init_parsed( + &mut self, + subgraph_schema: &Valid, + ) -> Result<&Arc>, WithErrors> { + match &mut self.parsed { + Some(parsed) => Ok(parsed), + option => { + let parsed = Arc::new(ExecutableDocument::parse_and_validate( + subgraph_schema, + &self.serialized, + "operation.graphql", + )?); + Ok(option.insert(parsed)) + } + } + } + + pub fn as_parsed( + &self, + ) -> Result<&Arc>, SerializableDocumentNotInitialized> { + self.parsed + .as_ref() + .ok_or(SerializableDocumentNotInitialized) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("Failed to call `SerializableDocument::init_parsed` after creating a query plan")] +pub struct SerializableDocumentNotInitialized; + +impl Serialize for SerializableDocument { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.as_serialized().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for SerializableDocument { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(Self::from_string(String::deserialize(deserializer)?)) + } +} + +impl PartialEq for SerializableDocument { + fn eq(&self, other: &Self) -> bool { + self.as_serialized() == other.as_serialized() + } +} + +impl std::fmt::Debug for SerializableDocument { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(self.as_serialized(), f) + } +} + +impl std::fmt::Display for SerializableDocument { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(self.as_serialized(), f) + } +} diff --git a/apollo-federation/src/schema/argument_composition_strategies.rs b/apollo-federation/src/schema/argument_composition_strategies.rs index e231d98222..31a28429d0 100644 --- a/apollo-federation/src/schema/argument_composition_strategies.rs +++ b/apollo-federation/src/schema/argument_composition_strategies.rs @@ -1,7 +1,10 @@ +use std::sync::LazyLock; + use apollo_compiler::ast::Value; -use apollo_compiler::name; +use apollo_compiler::collections::HashSet; +use apollo_compiler::collections::IndexSet; use apollo_compiler::schema::Type; -use lazy_static::lazy_static; +use apollo_compiler::ty; use crate::schema::FederationSchema; @@ -10,57 +13,64 @@ use crate::schema::FederationSchema; pub(crate) enum ArgumentCompositionStrategy { Max, Min, - Sum, + // Sum, Intersection, Union, + NullableAnd, + NullableMax, + NullableUnion, } -lazy_static! { - pub(crate) static ref MAX_STRATEGY: MaxArgumentCompositionStrategy = - MaxArgumentCompositionStrategy::new(); - pub(crate) static ref MIN_STRATEGY: MinArgumentCompositionStrategy = - MinArgumentCompositionStrategy::new(); - pub(crate) static ref SUM_STRATEGY: SumArgumentCompositionStrategy = - SumArgumentCompositionStrategy::new(); - pub(crate) static ref INTERSECTION_STRATEGY: IntersectionArgumentCompositionStrategy = - IntersectionArgumentCompositionStrategy {}; - pub(crate) static ref UNION_STRATEGY: UnionArgumentCompositionStrategy = - UnionArgumentCompositionStrategy {}; -} +pub(crate) static MAX_STRATEGY: LazyLock = + LazyLock::new(MaxArgumentCompositionStrategy::new); +pub(crate) static MIN_STRATEGY: LazyLock = + LazyLock::new(MinArgumentCompositionStrategy::new); +// pub(crate) static SUM_STRATEGY: LazyLock = +// LazyLock::new(SumArgumentCompositionStrategy::new); +pub(crate) static INTERSECTION_STRATEGY: LazyLock = + LazyLock::new(|| IntersectionArgumentCompositionStrategy {}); +pub(crate) static UNION_STRATEGY: LazyLock = + LazyLock::new(|| UnionArgumentCompositionStrategy {}); +pub(crate) static NULLABLE_AND_STRATEGY: LazyLock = + LazyLock::new(NullableAndArgumentCompositionStrategy::new); +pub(crate) static NULLABLE_MAX_STRATEGY: LazyLock = + LazyLock::new(NullableMaxArgumentCompositionStrategy::new); +pub(crate) static NULLABLE_UNION_STRATEGY: LazyLock = + LazyLock::new(|| NullableUnionArgumentCompositionStrategy {}); impl ArgumentCompositionStrategy { - pub(crate) fn name(&self) -> &str { + fn get_impl(&self) -> &dyn ArgumentComposition { match self { - Self::Max => MAX_STRATEGY.name(), - Self::Min => MIN_STRATEGY.name(), - Self::Sum => SUM_STRATEGY.name(), - Self::Intersection => INTERSECTION_STRATEGY.name(), - Self::Union => UNION_STRATEGY.name(), + Self::Max => &*MAX_STRATEGY, + Self::Min => &*MIN_STRATEGY, + // Self::Sum => &*SUM_STRATEGY, + Self::Intersection => &*INTERSECTION_STRATEGY, + Self::Union => &*UNION_STRATEGY, + Self::NullableAnd => &*NULLABLE_AND_STRATEGY, + Self::NullableMax => &*NULLABLE_MAX_STRATEGY, + Self::NullableUnion => &*NULLABLE_UNION_STRATEGY, } } + pub(crate) fn name(&self) -> &str { + self.get_impl().name() + } + pub(crate) fn is_type_supported( &self, schema: &FederationSchema, ty: &Type, ) -> Result<(), String> { - match self { - Self::Max => MAX_STRATEGY.is_type_supported(schema, ty), - Self::Min => MIN_STRATEGY.is_type_supported(schema, ty), - Self::Sum => SUM_STRATEGY.is_type_supported(schema, ty), - Self::Intersection => INTERSECTION_STRATEGY.is_type_supported(schema, ty), - Self::Union => UNION_STRATEGY.is_type_supported(schema, ty), - } + self.get_impl().is_type_supported(schema, ty) } - pub(crate) fn merge_values(&self, values: &[Value]) -> Value { - match self { - Self::Max => MAX_STRATEGY.merge_values(values), - Self::Min => MIN_STRATEGY.merge_values(values), - Self::Sum => SUM_STRATEGY.merge_values(values), - Self::Intersection => INTERSECTION_STRATEGY.merge_values(values), - Self::Union => UNION_STRATEGY.merge_values(values), - } + /// Merges the argument values by the specified strategy. + /// - `None` return value indicates that the merged value is undefined (meaning the argument + /// should be omitted). + /// - PORT_NOTE: The JS implementation could handle `undefined` input values. However, in Rust, + /// undefined values should be omitted in `values`, instead. + pub(crate) fn merge_values(&self, values: &[Value]) -> Option { + self.get_impl().merge_values(values) } } @@ -76,8 +86,13 @@ pub(crate) trait ArgumentComposition { fn name(&self) -> &str; /// Is the type `ty` supported by this validator? fn is_type_supported(&self, schema: &FederationSchema, ty: &Type) -> Result<(), String>; - /// Assumes that schemas are validated by `is_type_supported`. - fn merge_values(&self, values: &[Value]) -> Value; + /// Merges the argument values. + /// - Assumes that schemas are validated by `is_type_supported`. + /// - `None` return value indicates that the merged value is undefined (meaning the argument + /// should be omitted). + /// - PORT_NOTE: The JS implementation could handle `undefined` input values. However, in Rust, + /// undefined values should be omitted in `values`, instead. + fn merge_values(&self, values: &[Value]) -> Option; } #[derive(Clone)] @@ -87,11 +102,7 @@ pub(crate) struct FixedTypeSupportValidator { impl FixedTypeSupportValidator { fn is_type_supported(&self, _schema: &FederationSchema, ty: &Type) -> Result<(), String> { - let is_supported = self - .supported_types - .iter() - .any(|supported_ty| *supported_ty == *ty); - if is_supported { + if self.supported_types.contains(ty) { return Ok(()); } @@ -104,10 +115,6 @@ impl FixedTypeSupportValidator { } } -fn int_type() -> Type { - Type::Named(name!("Int")) -} - fn support_any_non_null_array(ty: &Type) -> Result<(), String> { if !ty.is_non_null() || !ty.is_list() { Err("non-nullable list types of any type".to_string()) @@ -116,6 +123,76 @@ fn support_any_non_null_array(ty: &Type) -> Result<(), String> { } } +/// Support for nullable or non-nullable list types of any type. +fn support_any_array(ty: &Type) -> Result<(), String> { + if ty.is_list() { + Ok(()) + } else { + Err("list types of any type".to_string()) + } +} + +fn max_int_value<'a>(values: impl Iterator) -> Value { + values + .filter_map(|val| match val { + // project the value to i32 (GraphQL `Int` scalar type is a signed 32-bit integer) + Value::Int(i) => i.try_to_i32().ok().map(|i| (val, i)), + _ => None, // Shouldn't happen + }) + .max_by(|x, y| x.1.cmp(&y.1)) + .map(|(val, _)| val) + .cloned() + // PORT_NOTE: JS uses `Math.max` which returns `-Infinity` for empty values. + // Here, we use `i32::MIN`. + .unwrap_or_else(|| Value::Int(i32::MIN.into())) +} + +fn min_int_value<'a>(values: impl Iterator) -> Value { + values + .filter_map(|val| match val { + // project the value to i32 (GraphQL `Int` scalar type is a signed 32-bit integer) + Value::Int(i) => i.try_to_i32().ok().map(|i| (val, i)), + _ => None, // Shouldn't happen + }) + .min_by(|x, y| x.1.cmp(&y.1)) + .map(|(val, _)| val) + .cloned() + // PORT_NOTE: JS uses `Math.min` which returns `+Infinity` for empty values. + // Here, we use `i32::MAX` as default value. + .unwrap_or_else(|| Value::Int(i32::MAX.into())) +} + +fn union_list_values<'a>(values: impl Iterator) -> Value { + // Each item in `values` must be a Value::List(...). + // Using `IndexSet` to maintain order and uniqueness. + // TODO: port `valueEquals` from JS to Rust + let mut result = IndexSet::default(); + for val in values { + result.extend(val.as_list().unwrap_or_default().iter().cloned()); + } + Value::List(result.into_iter().collect()) +} + +/// Merge nullable values. +/// - Returns `None` if all values are `null` or values are empty. +/// - Otherwise, calls `merge_values` argument with all the non-null values and returns the result. +// NOTE: This function makes the assumption that for the directive argument +// being merged, it is not "nullable with non-null default" in the supergraph +// schema (this kind of type/default combo is confusing and should be avoided, +// if possible). This assumption allows this function to replace null with +// None, which makes for a cleaner supergraph schema. +fn merge_nullable_values( + values: &[Value], + merge_values: impl Fn(&[&Value]) -> Value, +) -> Option { + // Filter out `null` values. + let values = values.iter().filter(|v| !v.is_null()).collect::>(); + if values.is_empty() { + return None; // No values to merge, return None (instead of null) + } + merge_values(&values).into() +} + // MAX #[derive(Clone)] pub(crate) struct MaxArgumentCompositionStrategy { @@ -126,7 +203,7 @@ impl MaxArgumentCompositionStrategy { fn new() -> Self { Self { validator: FixedTypeSupportValidator { - supported_types: vec![int_type().non_null()], + supported_types: vec![ty!(Int!)], }, } } @@ -141,18 +218,8 @@ impl ArgumentComposition for MaxArgumentCompositionStrategy { self.validator.is_type_supported(schema, ty) } - // TODO: check if this neeeds to be an Result to avoid the panic!() - // https://apollographql.atlassian.net/browse/FED-170 - fn merge_values(&self, values: &[Value]) -> Value { - values - .iter() - .map(|val| match val { - Value::Int(i) => i.try_to_i32().unwrap(), - _ => panic!("Unexpected value type"), - }) - .max() - .unwrap_or_default() - .into() + fn merge_values(&self, values: &[Value]) -> Option { + max_int_value(values.iter()).into() } } @@ -166,7 +233,7 @@ impl MinArgumentCompositionStrategy { fn new() -> Self { Self { validator: FixedTypeSupportValidator { - supported_types: vec![int_type().non_null()], + supported_types: vec![ty!(Int!)], }, } } @@ -181,120 +248,190 @@ impl ArgumentComposition for MinArgumentCompositionStrategy { self.validator.is_type_supported(schema, ty) } - // TODO: check if this neeeds to be an Result to avoid the panic!() - // https://apollographql.atlassian.net/browse/FED-170 - fn merge_values(&self, values: &[Value]) -> Value { - values - .iter() - .map(|val| match val { - Value::Int(i) => i.try_to_i32().unwrap(), - _ => panic!("Unexpected value type"), - }) - .min() - .unwrap_or_default() - .into() + fn merge_values(&self, values: &[Value]) -> Option { + min_int_value(values.iter()).into() } } +// NOTE: This doesn't work today because directive applications are de-duped +// before being merged, we'd need to modify merge logic if we need this kind +// of behavior. // SUM +// #[derive(Clone)] +// pub(crate) struct SumArgumentCompositionStrategy { +// validator: FixedTypeSupportValidator, +// } + +// impl SumArgumentCompositionStrategy { +// fn new() -> Self { +// Self { +// validator: FixedTypeSupportValidator { +// supported_types: vec![ty!(Int!)], +// }, +// } +// } +// } + +// impl ArgumentComposition for SumArgumentCompositionStrategy { +// fn name(&self) -> &str { +// "SUM" +// } + +// fn is_type_supported(&self, schema: &FederationSchema, ty: &Type) -> Result<(), String> { +// self.validator.is_type_supported(schema, ty) +// } + +// fn merge_values(&self, values: &[Value]) -> Value { +// values +// .iter() +// .map(|val| match val { +// // project the value to i32 (GraphQL `Int` scalar type is a signed 32-bit integer) +// Value::Int(i) => i.try_to_i32().unwrap_or(0), +// _ => 0, // Shouldn't happen +// }) +// // Note: `.sum()` would panic if the sum overflows. +// .fold(0_i32, |acc, i| acc.saturating_add(i)) +// .into() +// } +// } + +// INTERSECTION #[derive(Clone)] -pub(crate) struct SumArgumentCompositionStrategy { - validator: FixedTypeSupportValidator, +pub(crate) struct IntersectionArgumentCompositionStrategy {} + +impl ArgumentComposition for IntersectionArgumentCompositionStrategy { + fn name(&self) -> &str { + "INTERSECTION" + } + + fn is_type_supported(&self, _schema: &FederationSchema, ty: &Type) -> Result<(), String> { + support_any_non_null_array(ty) + } + + fn merge_values(&self, values: &[Value]) -> Option { + // Each item in `values` must be a Value::List(...). + let Some((first, rest)) = values.split_first() else { + return Value::List(Vec::new()).into(); // Fallback for empty list + }; + + let mut result = first.as_list().unwrap_or_default().to_owned(); + for val in rest { + // TODO: port `valueEquals` from JS to Rust + let val_set: HashSet<&Value> = val + .as_list() + .unwrap_or_default() + .iter() + .map(|v| v.as_ref()) + .collect(); + result.retain(|result_item| val_set.contains(result_item.as_ref())); + } + Value::List(result).into() + } } -impl SumArgumentCompositionStrategy { +// UNION +#[derive(Clone)] +pub(crate) struct UnionArgumentCompositionStrategy {} + +impl ArgumentComposition for UnionArgumentCompositionStrategy { + fn name(&self) -> &str { + "UNION" + } + + fn is_type_supported(&self, _schema: &FederationSchema, ty: &Type) -> Result<(), String> { + support_any_non_null_array(ty) + } + + fn merge_values(&self, values: &[Value]) -> Option { + union_list_values(values.iter()).into() + } +} + +// NULLABLE_AND +#[derive(Clone)] +pub(crate) struct NullableAndArgumentCompositionStrategy { + type_validator: FixedTypeSupportValidator, +} + +impl NullableAndArgumentCompositionStrategy { fn new() -> Self { Self { - validator: FixedTypeSupportValidator { - supported_types: vec![int_type().non_null()], + type_validator: FixedTypeSupportValidator { + supported_types: vec![ty!(Boolean), ty!(Boolean!)], }, } } } -impl ArgumentComposition for SumArgumentCompositionStrategy { +impl ArgumentComposition for NullableAndArgumentCompositionStrategy { fn name(&self) -> &str { - "SUM" + "NULLABLE_AND" } fn is_type_supported(&self, schema: &FederationSchema, ty: &Type) -> Result<(), String> { - self.validator.is_type_supported(schema, ty) + self.type_validator.is_type_supported(schema, ty) } - // TODO: check if this neeeds to be an Result to avoid the panic!() - // https://apollographql.atlassian.net/browse/FED-170 - fn merge_values(&self, values: &[Value]) -> Value { - values - .iter() - .map(|val| match val { - Value::Int(i) => i.try_to_i32().unwrap(), - _ => panic!("Unexpected value type"), - }) - .sum::() - .into() + fn merge_values(&self, values: &[Value]) -> Option { + merge_nullable_values(values, |values| { + values + .iter() + .all(|v| { + if let Value::Boolean(b) = v { + *b + } else { + false // shouldn't happen + } + }) + .into() + }) } } -// INTERSECTION +// NULLABLE_MAX #[derive(Clone)] -pub(crate) struct IntersectionArgumentCompositionStrategy {} +pub(crate) struct NullableMaxArgumentCompositionStrategy { + type_validator: FixedTypeSupportValidator, +} -impl ArgumentComposition for IntersectionArgumentCompositionStrategy { +impl NullableMaxArgumentCompositionStrategy { + fn new() -> Self { + Self { + type_validator: FixedTypeSupportValidator { + supported_types: vec![ty!(Int), ty!(Int!)], + }, + } + } +} + +impl ArgumentComposition for NullableMaxArgumentCompositionStrategy { fn name(&self) -> &str { - "INTERSECTION" + "NULLABLE_MAX" } - fn is_type_supported(&self, _schema: &FederationSchema, ty: &Type) -> Result<(), String> { - support_any_non_null_array(ty) + fn is_type_supported(&self, schema: &FederationSchema, ty: &Type) -> Result<(), String> { + self.type_validator.is_type_supported(schema, ty) } - // TODO: check if this neeeds to be an Result to avoid the panic!() - // https://apollographql.atlassian.net/browse/FED-170 - fn merge_values(&self, values: &[Value]) -> Value { - // Each item in `values` must be a Value::List(...). - values - .split_first() - .map(|(first, rest)| { - let first_ls = first.as_list().unwrap(); - // Not a super efficient implementation, but we don't expect large problem sizes. - let mut result = first_ls.to_vec(); - for val in rest { - let val_ls = val.as_list().unwrap(); - result.retain(|result_item| val_ls.iter().any(|n| *n == *result_item)); - } - Value::List(result) - }) - .unwrap() + fn merge_values(&self, values: &[Value]) -> Option { + merge_nullable_values(values, |values| max_int_value(values.iter().copied())) } } -// UNION +// NULLABLE_UNION #[derive(Clone)] -pub(crate) struct UnionArgumentCompositionStrategy {} +pub(crate) struct NullableUnionArgumentCompositionStrategy {} -impl ArgumentComposition for UnionArgumentCompositionStrategy { +impl ArgumentComposition for NullableUnionArgumentCompositionStrategy { fn name(&self) -> &str { - "UNION" + "NULLABLE_UNION" } fn is_type_supported(&self, _schema: &FederationSchema, ty: &Type) -> Result<(), String> { - support_any_non_null_array(ty) + support_any_array(ty) } - // TODO: check if this neeeds to be an Result to avoid the panic!() - // https://apollographql.atlassian.net/browse/FED-170 - fn merge_values(&self, values: &[Value]) -> Value { - // Each item in `values` must be a Value::List(...). - // Not a super efficient implementation, but we don't expect large problem sizes. - let mut result = Vec::new(); - for val in values { - let val_ls = val.as_list().unwrap(); - for x in val_ls.iter() { - if !result.contains(x) { - result.push(x.clone()); - } - } - } - Value::List(result) + fn merge_values(&self, values: &[Value]) -> Option { + merge_nullable_values(values, |values| union_list_values(values.iter().copied())) } } diff --git a/apollo-federation/src/schema/blueprint.rs b/apollo-federation/src/schema/blueprint.rs new file mode 100644 index 0000000000..fddbfab5aa --- /dev/null +++ b/apollo-federation/src/schema/blueprint.rs @@ -0,0 +1,361 @@ +use std::collections::HashSet; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Directive; +use apollo_compiler::ty; + +use crate::bail; +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::error::suggestion::did_you_mean; +use crate::error::suggestion::suggestion_list; +use crate::link::DEFAULT_LINK_NAME; +use crate::link::Link; +use crate::link::federation_spec_definition::FEDERATION_FIELDS_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_KEY_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_VERSIONS; +use crate::link::federation_spec_definition::FederationSpecDefinition; +use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; +use crate::link::link_spec_definition::LinkSpecDefinition; +use crate::link::spec::Identity; +use crate::link::spec_definition::SpecDefinition; +use crate::schema::FederationSchema; +use crate::schema::ValidFederationSchema; +use crate::schema::compute_subgraph_metadata; +use crate::schema::position::DirectiveDefinitionPosition; +use crate::schema::subgraph_metadata::SubgraphMetadata; +use crate::schema::validators::context::validate_context_directives; +use crate::schema::validators::cost::validate_cost_directives; +use crate::schema::validators::external::validate_external_directives; +use crate::schema::validators::from_context::validate_from_context_directives; +use crate::schema::validators::interface_object::validate_interface_object_directives; +use crate::schema::validators::key::validate_key_directives; +use crate::schema::validators::list_size::validate_list_size_directives; +use crate::schema::validators::provides::validate_provides_directives; +use crate::schema::validators::requires::validate_requires_directives; +use crate::schema::validators::shareable::validate_shareable_directives; +use crate::schema::validators::tag::validate_tag_directives; +use crate::supergraph::FEDERATION_ENTITIES_FIELD_NAME; +use crate::supergraph::FEDERATION_SERVICE_FIELD_NAME; + +pub(crate) struct FederationBlueprint {} + +#[allow(dead_code)] +impl FederationBlueprint { + pub(crate) fn on_missing_directive_definition( + schema: &mut FederationSchema, + directive: &Node, + ) -> Result, FederationError> { + if directive.name == DEFAULT_LINK_NAME { + let (alias, imports) = + LinkSpecDefinition::extract_alias_and_imports_on_missing_link_directive_definition( + directive, + )?; + LinkSpecDefinition::latest().add_definitions_to_schema(schema, alias, imports)?; + Ok(schema.get_directive_definition(&directive.name)) + } else { + Ok(None) + } + } + + pub(crate) fn on_directive_definition_and_schema_parsed( + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + // PORT_NOTE: JS version calls `completeSubgraphSchema`. But, in Rust, it's implemented + // directly in this method and `Subgraph::expand_links`. + let federation_spec = get_federation_spec_definition_from_subgraph(schema)?; + if federation_spec.is_fed1() { + Self::remove_federation_definitions_broken_in_known_ways(schema)?; + } + federation_spec.add_elements_to_schema(schema)?; + Self::expand_known_features(schema) + } + + pub(crate) fn ignore_parsed_field(schema: &FederationSchema, field_name: &str) -> bool { + // Historically, federation 1 has accepted invalid schema, including some where the Query + // type included the definition of `_entities` (so `_entities(representations: [_Any!]!): + // [_Entity]!`) but _without_ defining the `_Any` or `_Entity` type. So while we want to be + // stricter for fed2 (so this kind of really weird case can be fixed), we want fed2 to + // accept as much fed1 schema as possible. + // + // So, to avoid this problem, we ignore the _entities and _service fields if we parse them + // from a fed1 input schema. Those will be added back anyway (along with the proper types) + // post-parsing. + if !(FEDERATION_OPERATION_FIELDS.iter().any(|f| *f == field_name)) { + return false; + } + if let Some(metadata) = &schema.subgraph_metadata { + !metadata.is_fed_2_schema() + } else { + false + } + } + + pub(crate) fn on_constructed(schema: &mut FederationSchema) -> Result<(), FederationError> { + if schema.subgraph_metadata.is_none() { + schema.subgraph_metadata = compute_subgraph_metadata(schema)?.map(Box::new); + } + Ok(()) + } + + fn on_added_core_feature( + schema: &mut FederationSchema, + feature: &Link, + ) -> Result<(), FederationError> { + if feature.url.identity == Identity::federation_identity() { + FEDERATION_VERSIONS + .find(&feature.url.version) + .iter() + .try_for_each(|spec| spec.add_elements_to_schema(schema))?; + } + Ok(()) + } + + pub(crate) fn on_validation( + schema: &ValidFederationSchema, + meta: &SubgraphMetadata, + ) -> Result<(), FederationError> { + let mut error_collector = MultipleFederationErrors { errors: Vec::new() }; + + // We skip the rest of validation for fed1 schemas because there is a number of validations that is stricter than what fed 1 + // accepted, and some of those issues are fixed by `SchemaUpgrader`. So insofar as any fed 1 schma is ultimately converted + // to a fed 2 one before composition, then skipping some validation on fed 1 schema is fine. + if !meta.is_fed_2_schema() { + return error_collector.into_result(); + } + + let context_map = validate_context_directives(schema, &mut error_collector)?; + validate_from_context_directives(schema, meta, &context_map, &mut error_collector)?; + validate_key_directives(schema, meta, &mut error_collector)?; + validate_provides_directives(schema, meta, &mut error_collector)?; + validate_requires_directives(schema, meta, &mut error_collector)?; + validate_external_directives(schema, meta, &mut error_collector)?; + validate_interface_object_directives(schema, meta, &mut error_collector)?; + validate_shareable_directives(schema, meta, &mut error_collector)?; + validate_cost_directives(schema, &mut error_collector)?; + validate_list_size_directives(schema, &mut error_collector)?; + validate_tag_directives(schema, &mut error_collector)?; + + error_collector.into_result() + } + + // Allows to intercept some apollo-compiler error messages when we can provide additional + // guidance to users. + pub(crate) fn on_invalid_graphql_error( + schema: &FederationSchema, + message: String, + ) -> SingleFederationError { + // PORT_NOTE: The following comment is from the JS version. + // For now, the main additional guidance we provide is around directives, where we could + // provide additional help in 2 main ways: + // - if a directive name is likely misspelled. + // - for fed 2 schema, if a federation directive is referred under it's "default" naming + // but is not properly imported (not enforced in the method but rather in the + // `FederationBlueprint`). + // + // Note that intercepting/parsing error messages to modify them is never ideal, but + // pragmatically, it's probably better than rewriting the relevant rules entirely (in that + // case, our "copied" rule may not benefit any potential apollo-compiler's improvements for + // instance). And while such parsing is fragile, in that it'll break if the original + // message change, we have unit tests to surface any such breakage so it's not really a + // risk. + + let matcher = regex::Regex::new(r#"^Error: cannot find directive `@([^`]+)`"#).unwrap(); + let Some(capture) = matcher.captures(&message) else { + // return as-is + return SingleFederationError::InvalidGraphQL { message }; + }; + let Some(matched) = capture.get(1) else { + // return as-is + return SingleFederationError::InvalidGraphQL { message }; + }; + + let directive_name = matched.as_str(); + let options: Vec<_> = schema + .get_directive_definitions() + .map(|d| d.directive_name.to_string()) + .collect(); + let suggestions = suggestion_list(directive_name, options); + if suggestions.is_empty() { + return Self::on_unknown_directive_validation_error(schema, directive_name, &message); + } + + let did_you_mean = did_you_mean(suggestions.iter().map(|s| format!("@{s}"))); + SingleFederationError::InvalidGraphQL { + message: format!("{message}{did_you_mean}\n"), + } + } + + fn on_unknown_directive_validation_error( + schema: &FederationSchema, + unknown_directive_name: &str, + error_message: &str, + ) -> SingleFederationError { + let Some(metadata) = &schema.subgraph_metadata else { + return SingleFederationError::Internal { + message: "Missing subgraph metadata".to_string(), + }; + }; + let is_fed2 = metadata.is_fed_2_schema(); + let all_directive_names = all_default_federation_directive_names(); + if all_directive_names.contains(unknown_directive_name) { + // The directive name is "unknown" but it is a default federation directive name. So it + // means one of a few things happened: + // 1. it's a fed1 schema but the directive is fed2 only (only possible case for + // fed1 schema). + // 2. the directive has not been imported at all (so needs to be prefixed for it to + // work). + // 3. the directive has an `import`, but it's been aliased to another name. + + if !is_fed2 { + // Case #1. + return SingleFederationError::InvalidGraphQL { + message: format!( + r#"{error_message} If you meant the "@{unknown_directive_name}" federation 2 directive, note that this schema is a federation 1 schema. To be a federation 2 schema, it needs to @link to the federation specification v2."# + ), + }; + } + + let Ok(Some(name_in_schema)) = metadata + .federation_spec_definition() + .directive_name_in_schema(schema, &Name::new_unchecked(unknown_directive_name)) + else { + return SingleFederationError::Internal { + message: format!( + "Unexpectedly could not find directive \"@{unknown_directive_name}\" in schema" + ), + }; + }; + let federation_link_name = &metadata.federation_spec_definition().identity().name; + let federation_prefix = format!("{federation_link_name}__"); + if name_in_schema.starts_with(&federation_prefix) { + // Case #2. There is no import for that directive. + return SingleFederationError::InvalidGraphQL { + message: format!( + r#"{error_message} If you meant the "@{unknown_directive_name}" federation directive, you should use fully-qualified name "@{name_in_schema}" or add "@{unknown_directive_name}" to the \`import\` argument of the @link to the federation specification."# + ), + }; + } else { + // Case #3. There's an import, but it's renamed. + return SingleFederationError::InvalidGraphQL { + message: format!( + r#"{error_message} If you meant the "@{unknown_directive_name}" federation directive, you should use "@{name_in_schema}" as it is imported under that name in the @link to the federation specification of this schema."# + ), + }; + } + } else if !is_fed2 { + // We could get here when a fed1 schema tried to use a fed2 directive but misspelled it. + let suggestions = suggestion_list( + unknown_directive_name, + all_directive_names.iter().map(|name| name.to_string()), + ); + if !suggestions.is_empty() { + let did_you_mean = did_you_mean(suggestions.iter().map(|s| format!("@{s}"))); + let note = if suggestions.len() == 1 { + "it is a federation 2 directive" + } else { + "they are federation 2 directives" + }; + return SingleFederationError::InvalidGraphQL { + message: format!( + "{error_message}{did_you_mean} If so, note that {note} but this schema is a federation 1 one. To be a federation 2 schema, it needs to @link to the federation specification v2." + ), + }; + } + // fall-through + } + SingleFederationError::InvalidGraphQL { + message: error_message.to_string(), + } + } + + fn apply_directives_after_parsing() -> bool { + true + } + + fn remove_federation_definitions_broken_in_known_ways( + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + // We special case @key, @requires and @provides because we've seen existing user schemas where those + // have been defined in an invalid way, but in a way that fed1 wasn't rejecting. So for convenience, + // if we detect one of those case, we just remove the definition and let the code afteward add the + // proper definition back. + // Note that, in a perfect world, we'd do this within the `SchemaUpgrader`. But the way the code + // is organised, this method is called before we reach the `SchemaUpgrader`, and it doesn't seem + // worth refactoring things drastically for that minor convenience. + for directive_name in &[ + FEDERATION_KEY_DIRECTIVE_NAME_IN_SPEC, + FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC, + FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC, + ] { + if let Some(pos) = schema.get_directive_definition(directive_name) { + let directive = pos.get(schema.schema())?; + // We shouldn't have applications at the time of this writing because `completeSubgraphSchema`, which calls this, + // is only called: + // 1. during schema parsing, by `FederationBluePrint.onDirectiveDefinitionAndSchemaParsed`, and that is called + // before we process any directive applications. + // 2. by `setSchemaAsFed2Subgraph`, but as the name imply, this trickles to `completeFed2SubgraphSchema`, not + // this one method. + // In other words, there is currently no way to create a full fed1 schema first, and get that method called + // second. If that changes (no real reason but...), we'd have to modify this because when we remove the + // definition to re-add the "correct" version, we'd have to re-attach existing applications (doable but not + // done). This assert is so we notice it quickly if that ever happens (again, unlikely, because fed1 schema + // is a backward compatibility thing and there is no reason to expand that too much in the future). + if schema.referencers().get_directive(directive_name)?.len() > 0 { + bail!( + "Subgraph has applications of @{directive_name} but we are trying to remove the definition." + ); + } + + // The patterns we recognize and "correct" (by essentially ignoring the definition) are: + // 1. if the definition has no arguments at all. + // 2. if the `fields` argument is declared as nullable. + // 3. if the `fields` argument type is named "FieldSet" instead of "_FieldSet". + // All of these correspond to things we've seen in user schemas. + // + // To be on the safe side, we check that `fields` is the only argument. That's because + // fed2 accepts the optional `resolvable` arg for @key, fed1 only ever had one arguemnt. + // If the user had defined more arguments _and_ provided values for the extra argument, + // removing the definition would create validation errors that would be hard to understand. + if directive.arguments.is_empty() + || (directive.arguments.len() == 1 + && directive + .argument_by_name(&FEDERATION_FIELDS_ARGUMENT_NAME) + .is_some_and(|fields| { + *fields.ty == ty!(String) + || *fields.ty == ty!(_FieldSet) + || *fields.ty == ty!(FieldSet) + })) + { + pos.remove(schema)?; + } + } + } + Ok(()) + } + + fn expand_known_features(schema: &mut FederationSchema) -> Result<(), FederationError> { + for feature in schema.all_features()? { + feature.add_elements_to_schema(schema)?; + } + + Ok(()) + } +} + +pub(crate) const FEDERATION_OPERATION_FIELDS: [Name; 2] = [ + FEDERATION_SERVICE_FIELD_NAME, + FEDERATION_ENTITIES_FIELD_NAME, +]; + +fn all_default_federation_directive_names() -> HashSet { + FederationSpecDefinition::latest() + .directive_specs() + .iter() + .map(|spec| spec.name().clone()) + .collect() +} diff --git a/apollo-federation/src/schema/definitions.rs b/apollo-federation/src/schema/definitions.rs index 7df65bfc2f..ed18d9df67 100644 --- a/apollo-federation/src/schema/definitions.rs +++ b/apollo-federation/src/schema/definitions.rs @@ -1,6 +1,6 @@ +use apollo_compiler::Schema; use apollo_compiler::ast::NamedType; use apollo_compiler::ast::Type; -use apollo_compiler::Schema; use crate::error::FederationError; use crate::error::SingleFederationError; @@ -11,7 +11,7 @@ fn is_composite_type(ty: &NamedType, schema: &Schema) -> Result { - return types_can_be_merged(inner1, inner2, schema) + return types_can_be_merged(inner1, inner2, schema); } _ => return Ok(false), }; diff --git a/apollo-federation/src/schema/directive_location.rs b/apollo-federation/src/schema/directive_location.rs new file mode 100644 index 0000000000..52a7543f2a --- /dev/null +++ b/apollo-federation/src/schema/directive_location.rs @@ -0,0 +1,40 @@ +use apollo_compiler::ast::DirectiveLocation; + +pub(crate) trait DirectiveLocationExt { + fn is_executable_location(&self) -> bool; + #[allow(dead_code)] + fn is_type_system_location(&self) -> bool; +} + +impl DirectiveLocationExt for DirectiveLocation { + fn is_executable_location(&self) -> bool { + matches!( + self, + DirectiveLocation::Query + | DirectiveLocation::Mutation + | DirectiveLocation::Subscription + | DirectiveLocation::Field + | DirectiveLocation::FragmentDefinition + | DirectiveLocation::FragmentSpread + | DirectiveLocation::InlineFragment + | DirectiveLocation::VariableDefinition + ) + } + + fn is_type_system_location(&self) -> bool { + matches!( + self, + DirectiveLocation::Schema + | DirectiveLocation::Scalar + | DirectiveLocation::Object + | DirectiveLocation::FieldDefinition + | DirectiveLocation::ArgumentDefinition + | DirectiveLocation::Interface + | DirectiveLocation::Union + | DirectiveLocation::Enum + | DirectiveLocation::EnumValue + | DirectiveLocation::InputObject + | DirectiveLocation::InputFieldDefinition, + ) + } +} diff --git a/apollo-federation/src/schema/field_set.rs b/apollo-federation/src/schema/field_set.rs index a0a9375405..6086b9a57a 100644 --- a/apollo-federation/src/schema/field_set.rs +++ b/apollo-federation/src/schema/field_set.rs @@ -1,23 +1,22 @@ -use apollo_compiler::collections::IndexMap; +use apollo_compiler::Node; +use apollo_compiler::Schema; use apollo_compiler::executable; use apollo_compiler::executable::FieldSet; use apollo_compiler::schema::ExtendedType; use apollo_compiler::schema::NamedType; use apollo_compiler::validation::Valid; -use apollo_compiler::Schema; use crate::error::FederationError; use crate::error::MultipleFederationErrors; use crate::error::SingleFederationError; -use crate::operation::NamedFragments; use crate::operation::Selection; use crate::operation::SelectionSet; +use crate::schema::ValidFederationSchema; use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::FieldDefinitionPosition; use crate::schema::position::InterfaceTypeDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; use crate::schema::position::UnionTypeDefinitionPosition; -use crate::schema::ValidFederationSchema; // Federation spec does not allow the alias syntax in field set strings. // However, since `parse_field_set` uses the standard GraphQL parser, which allows aliases, @@ -29,11 +28,6 @@ fn check_absence_of_aliases(selection_set: &SelectionSet) -> Result<(), Federati ) -> Result<(), FederationError> { for selection in selection_set.iter() { match selection { - Selection::FragmentSpread(_) => { - return Err(FederationError::internal( - "check_absence_of_aliases(): unexpected fragment spread", - )) - } Selection::InlineFragment(frag) => check_absence_of_aliases(&frag.selection_set)?, Selection::Field(field) => { if let Some(alias) = &field.field.alias { @@ -63,43 +57,240 @@ fn check_absence_of_aliases(selection_set: &SelectionSet) -> Result<(), Federati pub(crate) fn parse_field_set( schema: &ValidFederationSchema, parent_type_name: NamedType, - value: &str, + field_set: &str, + validate: bool, ) -> Result { // Note this parsing takes care of adding curly braces ("{" and "}") if they aren't in the // string. - let field_set = FieldSet::parse_and_validate( - schema.schema(), - parent_type_name, - value, - "field_set.graphql", - )?; + let field_set = if validate { + FieldSet::parse_and_validate( + schema.schema(), + parent_type_name, + field_set, + "field_set.graphql", + )? + } else { + Valid::assume_valid(FieldSet::parse( + schema.schema(), + parent_type_name, + field_set, + "field_set.graphql", + )?) + }; - // field set should not contain any named fragments - let named_fragments = NamedFragments::new(&IndexMap::default(), schema); + // A field set should not contain any named fragments. + let fragments = Default::default(); let selection_set = - SelectionSet::from_selection_set(&field_set.selection_set, &named_fragments, schema)?; + SelectionSet::from_selection_set(&field_set.selection_set, &fragments, schema, &|| + // never cancel + Ok(()))?; - // Validate the field set has no aliases. - check_absence_of_aliases(&selection_set)?; + // Validate that the field set has no aliases. + if validate { + check_absence_of_aliases(&selection_set)?; + } Ok(selection_set) } -/// This exists because there's a single callsite in extract_subgraphs_from_supergraph() that needs -/// to parse field sets before the schema has finished building. Outside that case, you should -/// always use `parse_field_set()` instead. -// TODO: As noted in the single callsite, ideally we could move the parsing to after extraction, but -// it takes time to determine whether that impacts correctness, so we're leaving it for later. +/// When we first see a field set in a GraphQL document, there are some constraints which can make +/// calling `parse_field_set()` on it difficult to do. +/// 1. We may not have a `ValidFederationSchema` yet, since we may be in the process of building +/// one. This is the case in `extract_subgraphs_from_supergraph()`. +/// TODO: As noted in the callsite in `extract_subgraphs_from_supergraph()`, ideally we could +/// move the parsing to after extraction, but it takes time to determine whether that impacts +/// correctness, so we're leaving it for later. +/// 2. GraphQL documents, due to historical reasons, may have field sets containing enum values that +/// are mistakenly written as strings, but which we still want to support for backwards +/// compatibility. +/// 3. We may prefer an apollo_compiler `SelectionSet` during tests. +/// +/// This function exists for these purposes, and returns an apollo_compiler `SelectionSet`, +/// optionally trying to fix the aforementioned string/enum value issues. This function also returns +/// a boolean indicating whether the field set was modified to fix such string/enum value issues. +/// +/// Outside these specific purposes, you should prefer to use `parse_field_set()` instead. pub(crate) fn parse_field_set_without_normalization( schema: &Valid, parent_type_name: NamedType, - value: &str, -) -> Result { + field_set: &str, + fix_string_enum_values: bool, +) -> Result<(executable::SelectionSet, bool), FederationError> { // Note this parsing takes care of adding curly braces ("{" and "}") if they aren't in the // string. - let field_set = - FieldSet::parse_and_validate(schema, parent_type_name, value, "field_set.graphql")?; - Ok(field_set.into_inner().selection_set) + let (field_set, is_modified) = if fix_string_enum_values { + let mut field_set = FieldSet::parse( + schema, + parent_type_name.clone(), + field_set, + "field_set.graphql", + )?; + let is_modified = fix_string_enum_values_in_field_set(schema, &mut field_set); + field_set.validate(schema)?; + // `FieldSet::validate()` strangely doesn't return `Valid
`, so we instead use + // `Valid::assume_valid()` here. + (Valid::assume_valid(field_set), is_modified) + } else { + ( + FieldSet::parse_and_validate(schema, parent_type_name, field_set, "field_set.graphql")?, + false, + ) + }; + Ok((field_set.into_inner().selection_set, is_modified)) +} + +/// In the past, arguments in field sets may have mistakenly provided strings when they meant to +/// provide enum values. This was erroneously permitted because the representation of a GraphQL +/// value in JS doesn't really distinguish between the two, and the JS GraphQL value validation code +/// strangely permits enum values when a string type is expected. This ends up being okay-ish in the +/// JS code, since it later ends up using the type of the argument to try inferring whether it +/// should be a string or enum value. +/// +/// This doesn't occur in Rust, since the representation of a GraphQL value properly distinguishes +/// between strings and enum values, and validation checks this accordingly. However, to continue +/// accepting supergraph schemas that may have this mistake, the Rust code does this type inference +/// when extracting subgraphs instead. This inference is performed by this function, flipping the +/// type from string to enum value when relevant (if the field set is invalid in some other way, +/// this function skips the invalidity, assuming that later field set validation will catch this). +/// In a future major release, we may error on such string/enum value mistakes instead. +fn fix_string_enum_values_in_field_set(schema: &Valid, field_set: &mut FieldSet) -> bool { + fix_string_enum_values_in_selection_set(schema, &mut field_set.selection_set) +} + +fn fix_string_enum_values_in_selection_set( + schema: &Valid, + selection_set: &mut executable::SelectionSet, +) -> bool { + let mut is_modified = false; + for selection in selection_set.selections.iter_mut() { + match selection { + executable::Selection::Field(field) => { + let field = field.make_mut(); + for argument in field.arguments.iter_mut() { + let Some(type_) = field + .definition + .argument_by_name(&argument.name) + .map(|def| &def.ty) + else { + continue; + }; + let argument = argument.make_mut(); + if fix_string_enum_values_in_input_value(schema, type_, &mut argument.value) { + is_modified = true; + } + } + if fix_string_enum_values_in_directives(schema, &mut field.directives) { + is_modified = true; + } + if fix_string_enum_values_in_selection_set(schema, &mut field.selection_set) { + is_modified = true; + } + } + executable::Selection::FragmentSpread(fragment_spread) => { + let fragment_spread = fragment_spread.make_mut(); + if fix_string_enum_values_in_directives(schema, &mut fragment_spread.directives) { + is_modified = true; + } + } + executable::Selection::InlineFragment(inline_fragment) => { + let inline_fragment = inline_fragment.make_mut(); + if fix_string_enum_values_in_directives(schema, &mut inline_fragment.directives) { + is_modified = true; + } + if fix_string_enum_values_in_selection_set( + schema, + &mut inline_fragment.selection_set, + ) { + is_modified = true; + } + } + } + } + is_modified +} + +fn fix_string_enum_values_in_directives( + schema: &Valid, + directives: &mut executable::DirectiveList, +) -> bool { + let mut is_modified = false; + for directive in directives.iter_mut() { + let Some(directive_definition) = schema.directive_definitions.get(&directive.name) else { + continue; + }; + let directive = directive.make_mut(); + for argument in directive.arguments.iter_mut() { + let Some(type_) = directive_definition + .argument_by_name(&argument.name) + .map(|def| &def.ty) + else { + continue; + }; + let argument = argument.make_mut(); + if fix_string_enum_values_in_input_value(schema, type_, &mut argument.value) { + is_modified = true; + } + } + } + is_modified +} + +fn fix_string_enum_values_in_input_value( + schema: &Valid, + type_: &executable::Type, + value: &mut Node, +) -> bool { + let mut is_modified = false; + let Some(type_definition) = schema.types.get(type_.inner_named_type()) else { + return is_modified; + }; + let value = value.make_mut(); + match value { + executable::Value::Int(_) + | executable::Value::Float(_) + | executable::Value::Boolean(_) + | executable::Value::Null + | executable::Value::Variable(_) + | executable::Value::Enum(_) => {} + executable::Value::String(string_value) => { + let ExtendedType::Enum(type_definition) = type_definition else { + return is_modified; + }; + let Ok(enum_value) = executable::Name::new(string_value) else { + return is_modified; + }; + if !type_definition.values.contains_key(&enum_value) { + return is_modified; + } + *value = executable::Value::Enum(enum_value); + is_modified = true; + } + executable::Value::List(values) => { + if !type_.is_list() { + return is_modified; + } + let type_ = type_.item_type(); + for value in values { + if fix_string_enum_values_in_input_value(schema, type_, value) { + is_modified = true; + } + } + } + executable::Value::Object(values) => { + let ExtendedType::InputObject(type_definition) = type_definition else { + return is_modified; + }; + for (key, value) in values { + let Some(type_) = type_definition.fields.get(key).map(|def| &def.ty) else { + continue; + }; + if fix_string_enum_values_in_input_value(schema, type_, value) { + is_modified = true; + } + } + } + } + is_modified } // PORT_NOTE: The JS codebase called this `collectTargetFields()`, but this naming didn't make it @@ -109,19 +300,35 @@ pub(crate) fn parse_field_set_without_normalization( pub(crate) fn collect_target_fields_from_field_set( schema: &Valid, parent_type_name: NamedType, - value: &str, + field_set: &str, + validate: bool, ) -> Result, FederationError> { - // Note this parsing takes care of adding curly braces ("{" and "}") if they aren't in the - // string. - let field_set = - FieldSet::parse_and_validate(schema, parent_type_name, value, "field_set.graphql")?; + // Note this parsing takes care of adding curly braces ("{" and "}") if they aren't in the string. + let field_set = if validate { + FieldSet::parse_and_validate(schema, parent_type_name, field_set, "field_set.graphql")? + } else { + // This case exists for when a directive's field set uses an interface I with implementer O, and conditions + // I on O, but the actual phrase "type O implements I" only exists in another subgraph. Ideally, this wouldn't + // be allowed, but it would be a breaking change to remove it, thus it's supported for legacy reasons. + Valid::assume_valid( + FieldSet::parse(schema, parent_type_name, field_set, "field_set.graphql") + // If we failed to parse, we want to continue collecting leaf fields from the partial result. This is + // useful for when we are collecting used fields, for example, so we can avoid extra error messages + // about fields that are used in otherwise invalid field sets. + .unwrap_or_else(|e| e.partial), + ) + }; let mut stack = vec![&field_set.selection_set]; let mut fields = vec![]; while let Some(selection_set) = stack.pop() { let Some(parent_type) = schema.types.get(&selection_set.ty) else { - return Err(FederationError::internal( - "Unexpectedly missing selection set type from schema.", - )); + if validate { + return Err(FederationError::internal( + "Unexpectedly missing selection set type from schema.", + )); + } else { + continue; + } }; let parent_type_position: CompositeTypeDefinitionPosition = match parent_type { ExtendedType::Object(_) => ObjectTypeDefinitionPosition { @@ -137,9 +344,13 @@ pub(crate) fn collect_target_fields_from_field_set( } .into(), _ => { - return Err(FederationError::internal( - "Unexpectedly encountered non-composite type for selection set.", - )); + if validate { + return Err(FederationError::internal( + "Unexpectedly encountered non-composite type for selection set.", + )); + } else { + continue; + } } }; // The stack iterates through what we push in reverse order, so we iterate through @@ -153,9 +364,13 @@ pub(crate) fn collect_target_fields_from_field_set( } } executable::Selection::FragmentSpread(_) => { - return Err(FederationError::internal( - "Unexpectedly encountered fragment spread in FieldSet.", - )); + if validate { + return Err(FederationError::internal( + "Unexpectedly encountered fragment spread in FieldSet.", + )); + } else { + continue; + } } executable::Selection::InlineFragment(inline_fragment) => { stack.push(&inline_fragment.selection_set); @@ -166,29 +381,62 @@ pub(crate) fn collect_target_fields_from_field_set( Ok(fields) } +pub(crate) fn parse_field_value_without_validation( + schema: &ValidFederationSchema, + parent_type_name: NamedType, + field_value: &str, +) -> Result { + // Note this parsing takes care of adding curly braces ("{" and "}") if they aren't in the + // string. + Ok(FieldSet::parse( + schema.schema(), + parent_type_name, + field_value, + "field_set.graphql", + )?) +} + +// Similar to parse_field_set(), we explicitly forbid aliases for field values. In this case though, +// it's because field value evaluation semantics means aliases would be stripped out and have no +// effect. +pub(crate) fn validate_field_value( + schema: &ValidFederationSchema, + field_value: FieldSet, +) -> Result { + field_value.validate(schema.schema())?; + + // A field value should not contain any named fragments. + let fragments = Default::default(); + let selection_set = + SelectionSet::from_selection_set(&field_value.selection_set, &fragments, schema, &|| { + // never cancel + Ok(()) + })?; + + // Validate that the field value has no aliases. + check_absence_of_aliases(&selection_set)?; + + Ok(selection_set) +} + #[cfg(test)] mod tests { use apollo_compiler::Name; + use crate::Supergraph; use crate::error::FederationError; use crate::query_graph::build_federated_query_graph; - use crate::subgraph::Subgraph; - use crate::Supergraph; #[test] fn test_aliases_in_field_set() -> Result<(), FederationError> { - let sdl = r#" - type Query { - a: Int! @requires(fields: "r1: r") - r: Int! @external - } - "#; - - let subgraph = Subgraph::parse_and_expand("S1", "http://S1", sdl).unwrap(); - let supergraph = Supergraph::compose([&subgraph].to_vec()).unwrap(); - let err = super::parse_field_set(&supergraph.schema, Name::new("Query").unwrap(), "r1: r") - .map(|_| "Unexpected success") // ignore the Ok value - .expect_err("Expected alias error"); + // Note: `field-set-alias.graphqls` has multiple alias errors in the same field set. + let schema_str = include_str!("fixtures/field-set-alias.graphqls"); + let supergraph = Supergraph::new(schema_str).expect("Expected supergraph schema to parse"); + // Note: `Supergraph::new` does not error out on aliases in field sets. + // We call `parse_field_set` directly to test the alias error. + let err = + super::parse_field_set(&supergraph.schema, Name::new("T").unwrap(), "r1: r", true) + .expect_err("Expected alias error"); assert_eq!( err.to_string(), r#"Cannot use alias "r1" in "r1: r": aliases are not currently supported in the used directive"# @@ -198,22 +446,12 @@ mod tests { #[test] fn test_aliases_in_field_set_via_build_federated_query_graph() -> Result<(), FederationError> { - // NB: This tests multiple alias errors in the same field set. - let sdl = r#" - type Query { - a: Int! @requires(fields: "r1: r s q1: q") - r: Int! @external - s: String! @external - q: String! @external - } - "#; - - let subgraph = Subgraph::parse_and_expand("S1", "http://S1", sdl).unwrap(); - let supergraph = Supergraph::compose([&subgraph].to_vec()).unwrap(); + // Note: `field-set-alias.graphqls` has multiple alias errors in the same field set. + let schema_str = include_str!("fixtures/field-set-alias.graphqls"); + let supergraph = Supergraph::new(schema_str).expect("Expected supergraph schema to parse"); let api_schema = supergraph.to_api_schema(Default::default())?; // Testing via `build_federated_query_graph` function, which validates the @requires directive. let err = build_federated_query_graph(supergraph.schema, api_schema, None, None) - .map(|_| "Unexpected success") // ignore the Ok value .expect_err("Expected alias error"); assert_eq!( err.to_string(), diff --git a/apollo-federation/src/schema/fixtures/field-set-alias.graphqls b/apollo-federation/src/schema/fixtures/field-set-alias.graphqls new file mode 100644 index 0000000000..9768739e74 --- /dev/null +++ b/apollo-federation/src/schema/fixtures/field-set-alias.graphqls @@ -0,0 +1,59 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) +{ + query: Query +} + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar join__FieldSet + +enum join__Graph { + A @join__graph(name: "A", url: "error-field-set-alias.graphql?subgraph=A") + B @join__graph(name: "B", url: "error-field-set-alias.graphql?subgraph=B") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: A) + @join__type(graph: B) +{ + start: T! @join__field(graph: A) +} + +type T + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id") +{ + id: ID! + r: Int! @join__field(graph: A) @join__field(graph: B, external: true) + s: String! @join__field(graph: A) @join__field(graph: B, external: true) + q: String! @join__field(graph: A) @join__field(graph: B, external: true) + a: Int! @join__field(graph: B, requires: "r1: r s q1: q") +} diff --git a/apollo-federation/src/schema/fixtures/shareable_fields.graphqls b/apollo-federation/src/schema/fixtures/shareable_fields.graphqls new file mode 100644 index 0000000000..a4c7af198a --- /dev/null +++ b/apollo-federation/src/schema/fixtures/shareable_fields.graphqls @@ -0,0 +1,112 @@ +extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.9") + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar link__Import + +enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +directive @federation__key( + fields: federation__FieldSet! + resolvable: Boolean = true +) repeatable on OBJECT | INTERFACE + +directive @federation__requires( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__provides( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION + +directive @federation__tag( + name: String! +) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @federation__extends on OBJECT | INTERFACE + +directive @federation__shareable on OBJECT | FIELD_DEFINITION + +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @federation__override( + from: String! + label: String +) on FIELD_DEFINITION + +directive @federation__composeDirective(name: String) repeatable on SCHEMA + +directive @federation__interfaceObject on OBJECT + +directive @federation__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__requiresScopes( + scopes: [[federation__Scope!]!]! +) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__cost( + weight: Int! +) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @federation__listSize( + assumedSize: Int + slicingArguments: [String!] + sizedFields: [String!] + requireOneSlicingArgument: Boolean = true +) on FIELD_DEFINITION + +directive @federation__fromContext( + field: federation__ContextFieldValue +) on ARGUMENT_DEFINITION + +directive @federation__context( + name: String! +) repeatable on INTERFACE | OBJECT | UNION + +scalar federation__ContextFieldValue + +scalar federation__FieldSet + +scalar federation__Scope + +type Query { + o1: O1 + o2: O2 + o3WithExternals: [O3] @federation__provides(fields: "externalField") + o3WithoutExternals: [O3] +} + +type O1 @federation__shareable { + a: Int + b: String +} + +type O2 { + c: Int + d: String @federation__shareable +} + +type O3 { + c: String + externalField: Int @federation__external + externalFieldNeverProvided: String @federation__external +} diff --git a/apollo-federation/src/schema/fixtures/used_fields.graphqls b/apollo-federation/src/schema/fixtures/used_fields.graphqls new file mode 100644 index 0000000000..af646bf805 --- /dev/null +++ b/apollo-federation/src/schema/fixtures/used_fields.graphqls @@ -0,0 +1,147 @@ +extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.9") + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar link__Import + +enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +directive @federation__key( + fields: federation__FieldSet! + resolvable: Boolean = true +) repeatable on OBJECT | INTERFACE + +directive @federation__requires( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__provides( + fields: federation__FieldSet! +) on FIELD_DEFINITION + +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION + +directive @federation__tag( + name: String! +) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @federation__extends on OBJECT | INTERFACE + +directive @federation__shareable on OBJECT | FIELD_DEFINITION + +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @federation__override( + from: String! + label: String +) on FIELD_DEFINITION + +directive @federation__composeDirective(name: String) repeatable on SCHEMA + +directive @federation__interfaceObject on OBJECT + +directive @federation__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__requiresScopes( + scopes: [[federation__Scope!]!]! +) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__cost( + weight: Int! +) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @federation__listSize( + assumedSize: Int + slicingArguments: [String!] + sizedFields: [String!] + requireOneSlicingArgument: Boolean = true +) on FIELD_DEFINITION + +directive @federation__fromContext( + field: federation__ContextFieldValue +) on ARGUMENT_DEFINITION + +directive @federation__context( + name: String! +) repeatable on INTERFACE | OBJECT | UNION + +scalar federation__ContextFieldValue + +scalar federation__FieldSet + +scalar federation__Scope + +type Query { + i1: I1 + o2: O2 + o3: O3 + o4WithExternals: O4 @federation__provides(fields: "externalField") + o4WithoutExternals: O4 + o5: O5Context +} + +interface I1 { + a: Int +} + +type O1 implements I1 { + a: Int + b: String +} + +type O2 { + hasRequirement: String + @federation__requires(fields: "isRequired isAlsoRequired") + isRequired: Boolean + isAlsoRequired: Int +} + +type O3 @federation__key(fields: "keyField1 subKey { keyField2 }") { + keyField1: String + subKey: O3SubKey + nonKeyField: String +} + +type O3SubKey { + keyField2: String +} + +type O4 { + c: String + externalField: Int @federation__external + externalFieldNeverProvided: String @federation__external +} + +type O5Context @federation__context(name: "o5_context") { + usedInContext: String + notUsedInContext: Int + wrapper: O5Wrapper +} + +type O5Wrapper { + inner: O5 +} + +type O5 { + usesContext( + arg1: String + @federation__fromContext(field: "$o5_context { usedInContext }") + ): String +} diff --git a/apollo-federation/src/schema/locations.rs b/apollo-federation/src/schema/locations.rs new file mode 100644 index 0000000000..97100d64f3 --- /dev/null +++ b/apollo-federation/src/schema/locations.rs @@ -0,0 +1,51 @@ +use apollo_compiler::Node; +use apollo_compiler::schema::ExtendedType; + +use crate::error::HasLocations; +use crate::error::Locations; +use crate::error::SubgraphLocation; +use crate::schema::position::FieldDefinitionPosition; +use crate::subgraph::typestate::HasMetadata; +use crate::subgraph::typestate::Subgraph; + +impl HasLocations for ExtendedType { + fn locations(&self, subgraph: &Subgraph) -> Locations { + match self { + Self::Scalar(node) => node.locations(subgraph), + Self::Object(node) => node.locations(subgraph), + Self::Interface(node) => node.locations(subgraph), + Self::Union(node) => node.locations(subgraph), + Self::Enum(node) => node.locations(subgraph), + Self::InputObject(node) => node.locations(subgraph), + } + } +} + +impl HasLocations for Node { + fn locations(&self, subgraph: &Subgraph) -> Locations { + subgraph + .schema() + .node_locations(self) + .map(|range| SubgraphLocation { + subgraph: subgraph.name.to_string(), + range, + }) + .collect() + } +} + +impl HasLocations for FieldDefinitionPosition { + fn locations(&self, subgraph: &Subgraph) -> Vec { + let schema = subgraph.schema(); + let Ok(node) = self.get(schema.schema()) else { + return Vec::new(); + }; + schema + .node_locations(node) + .map(|range| SubgraphLocation { + subgraph: subgraph.name.clone(), + range, + }) + .collect() + } +} diff --git a/apollo-federation/src/schema/mod.rs b/apollo-federation/src/schema/mod.rs index 3def48068b..33034f46af 100644 --- a/apollo-federation/src/schema/mod.rs +++ b/apollo-federation/src/schema/mod.rs @@ -1,25 +1,62 @@ use std::hash::Hash; use std::hash::Hasher; use std::ops::Deref; +use std::ops::Range; use std::sync::Arc; +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::Schema; +use apollo_compiler::ast::Directive; +use apollo_compiler::ast::FieldDefinition; +use apollo_compiler::ast::Value; use apollo_compiler::collections::IndexSet; +use apollo_compiler::executable::FieldSet; +use apollo_compiler::parser::LineColumn; +use apollo_compiler::schema::ComponentOrigin; use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::ExtensionId; +use apollo_compiler::schema::SchemaDefinition; use apollo_compiler::validation::Valid; -use apollo_compiler::Name; -use apollo_compiler::Schema; +use apollo_compiler::validation::WithErrors; +use itertools::Itertools; +use position::DirectiveTargetPosition; +use position::FieldArgumentDefinitionPosition; +use position::ObjectOrInterfaceTypeDefinitionPosition; +use position::TagDirectiveTargetPosition; use referencer::Referencers; +use crate::bail; use crate::error::FederationError; use crate::error::SingleFederationError; -use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; -use crate::link::federation_spec_definition::FEDERATION_ENTITY_TYPE_NAME_IN_SPEC; +use crate::internal_error; +use crate::link::Link; use crate::link::LinksMetadata; +use crate::link::context_spec_definition::ContextSpecDefinition; +use crate::link::cost_spec_definition; +use crate::link::cost_spec_definition::CostSpecDefinition; +use crate::link::federation_spec_definition::CacheTagDirectiveArguments; +use crate::link::federation_spec_definition::ContextDirectiveArguments; +use crate::link::federation_spec_definition::FEDERATION_ENTITY_TYPE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_FIELDS_ARGUMENT_NAME; +use crate::link::federation_spec_definition::FEDERATION_FIELDSET_TYPE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_SERVICE_TYPE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FederationSpecDefinition; +use crate::link::federation_spec_definition::FromContextDirectiveArguments; +use crate::link::federation_spec_definition::KeyDirectiveArguments; +use crate::link::federation_spec_definition::ProvidesDirectiveArguments; +use crate::link::federation_spec_definition::RequiresDirectiveArguments; +use crate::link::federation_spec_definition::TagDirectiveArguments; +use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; +use crate::link::spec::Version; +use crate::link::spec_definition::SPEC_REGISTRY; +use crate::link::spec_definition::SpecDefinition; use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::DirectiveDefinitionPosition; use crate::schema::position::EnumTypeDefinitionPosition; use crate::schema::position::InputObjectTypeDefinitionPosition; use crate::schema::position::InterfaceTypeDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceFieldDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; use crate::schema::position::ScalarTypeDefinitionPosition; use crate::schema::position::TypeDefinitionPosition; @@ -27,14 +64,19 @@ use crate::schema::position::UnionTypeDefinitionPosition; use crate::schema::subgraph_metadata::SubgraphMetadata; pub(crate) mod argument_composition_strategies; +pub(crate) mod blueprint; pub(crate) mod definitions; +pub(crate) mod directive_location; pub(crate) mod field_set; +pub(crate) mod locations; pub(crate) mod position; pub(crate) mod referencer; +pub(crate) mod schema_upgrader; pub(crate) mod subgraph_metadata; +pub(crate) mod validators; -fn compute_subgraph_metadata( - schema: &Valid, +pub(crate) fn compute_subgraph_metadata( + schema: &FederationSchema, ) -> Result, FederationError> { Ok( if let Ok(federation_spec_definition) = get_federation_spec_definition_from_subgraph(schema) @@ -48,7 +90,7 @@ fn compute_subgraph_metadata( pub(crate) mod type_and_directive_specification; /// A GraphQL schema with federation data. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct FederationSchema { schema: Schema, referencers: Referencers, @@ -77,7 +119,7 @@ impl FederationSchema { } /// Returns all the types in the schema, minus builtins. - pub(crate) fn get_types(&self) -> impl Iterator + '_ { + pub(crate) fn get_types(&self) -> impl Iterator { self.schema .types .iter() @@ -101,7 +143,7 @@ impl FederationSchema { pub(crate) fn get_directive_definitions( &self, - ) -> impl Iterator + '_ { + ) -> impl Iterator { self.schema .directive_definitions .keys() @@ -119,7 +161,7 @@ impl FederationSchema { .types .get(&type_name) .ok_or_else(|| SingleFederationError::Internal { - message: format!("Schema has no type \"{}\"", type_name), + message: format!("Schema has no type \"{type_name}\""), })?; Ok(match type_ { ExtendedType::Scalar(_) => ScalarTypeDefinitionPosition { type_name }.into(), @@ -135,6 +177,13 @@ impl FederationSchema { self.get_type(type_name).ok() } + pub(crate) fn is_root_type(&self, type_name: &Name) -> bool { + self.schema() + .schema_definition + .iter_root_operations() + .any(|op| *op.1 == *type_name) + } + /// Return the possible runtime types for a definition. /// /// For a union, the possible runtime types are its members. @@ -163,8 +212,34 @@ impl FederationSchema { }) } + /// Return all implementing types (i.e. both object and interface) for an interface definition. + /// + /// Note this always allocates a set for the result. Avoid calling it frequently. + pub(crate) fn all_implementation_types( + &self, + interface_type_definition_position: &InterfaceTypeDefinitionPosition, + ) -> Result, FederationError> { + let referencers = self + .referencers() + .get_interface_type(&interface_type_definition_position.type_name)?; + Ok(referencers + .object_types + .iter() + .cloned() + .map(ObjectOrInterfaceTypeDefinitionPosition::from) + .chain( + referencers + .interface_types + .iter() + .cloned() + .map(ObjectOrInterfaceTypeDefinitionPosition::from), + ) + .collect()) + } + /// Similar to `Self::validate` but returns `self` as part of the error should it be needed by /// the caller + #[allow(clippy::result_large_err)] // lint is accurate but this is not in a hot path pub(crate) fn validate_or_return_self( mut self, ) -> Result { @@ -195,6 +270,7 @@ impl FederationSchema { } /// Note that a subgraph may have no "entities" and so no `_Entity` type. + // PORT_NOTE: Corresponds to `FederationMetadata.entityType` in JS pub(crate) fn entity_type( &self, ) -> Result, FederationError> { @@ -207,12 +283,947 @@ impl FederationSchema { type_name: FEDERATION_ENTITY_TYPE_NAME_IN_SPEC, })), Some(_) => Err(FederationError::internal(format!( - "Unexpectedly found non-union for federation spec's `{}` type definition", - FEDERATION_ENTITY_TYPE_NAME_IN_SPEC + "Unexpectedly found non-union for federation spec's `{FEDERATION_ENTITY_TYPE_NAME_IN_SPEC}` type definition" ))), None => Ok(None), } } + + // PORT_NOTE: Corresponds to `FederationMetadata.serviceType` in JS + pub(crate) fn service_type(&self) -> Result { + // Note: `_Service` type name can't be renamed. + match self.schema.types.get(&FEDERATION_SERVICE_TYPE_NAME_IN_SPEC) { + Some(ExtendedType::Object(_)) => Ok(ObjectTypeDefinitionPosition { + type_name: FEDERATION_SERVICE_TYPE_NAME_IN_SPEC, + }), + Some(_) => bail!( + "Unexpected type found for federation spec's `{spec_name}` type definition", + spec_name = FEDERATION_SERVICE_TYPE_NAME_IN_SPEC, + ), + None => bail!( + "Unexpected: type not found for federation spec's `{spec_name}`", + spec_name = FEDERATION_SERVICE_TYPE_NAME_IN_SPEC, + ), + } + } + + // PORT_NOTE: Corresponds to `FederationMetadata.isFed2Schema` in JS + // This works even if the schema bootstrapping was not completed. + pub(crate) fn is_fed_2(&self) -> bool { + self.federation_link() + .is_some_and(|link| link.url.version.satisfies(&Version { major: 2, minor: 0 })) + } + + // PORT_NOTE: Corresponds to `FederationMetadata.federationFeature` in JS + fn federation_link(&self) -> Option<&Arc> { + self.metadata().and_then(|metadata| { + metadata + .by_identity + .get(FederationSpecDefinition::latest().identity()) + }) + } + + // PORT_NOTE: Corresponds to `FederationMetadata.fieldSetType` in JS. + pub(crate) fn field_set_type(&self) -> Result { + let name_in_schema = + self.federation_type_name_in_schema(FEDERATION_FIELDSET_TYPE_NAME_IN_SPEC)?; + match self.schema.types.get(&name_in_schema) { + Some(ExtendedType::Scalar(_)) => Ok(ScalarTypeDefinitionPosition { + type_name: name_in_schema, + }), + Some(_) => bail!( + "Unexpected type found for federation spec's `{name_in_schema}` type definition" + ), + None => { + bail!("Unexpected: type not found for federation spec's `{name_in_schema}`") + } + } + } + + // PORT_NOTE: Corresponds to `FederationMetadata.federationTypeNameInSchema` in JS. + // Note: Unfortunately, this overlaps with `ValidFederationSchema`'s + // `federation_type_name_in_schema` method. This method was added because it's used + // during composition before `ValidFederationSchema` is created. + pub(crate) fn federation_type_name_in_schema( + &self, + name: Name, + ) -> Result { + // Currently, the types used to define the federation operations, that is _Any, _Entity and + // _Service, are not considered part of the federation spec, and are instead hardcoded to + // the names above. The reason being that there is no way to maintain backward + // compatibility with fed2 if we were to add those to the federation spec without requiring + // users to add those types to their @link `import`, and that wouldn't be a good user + // experience (because most users don't really know what those types are/do). And so we + // special case it. + if name.starts_with('_') { + return Ok(name); + } + + if self.is_fed_2() { + let Some(links) = self.metadata() else { + bail!("Schema should be a core schema") + }; + let Some(federation_link) = links + .by_identity + .get(FederationSpecDefinition::latest().identity()) + else { + bail!("Schema should have the latest federation link") + }; + Ok(federation_link.type_name_in_schema(&name)) + } else { + // The only type here so far is the the `FieldSet` one. And in fed1, it's called `_FieldSet`, so ... + Name::new(&format!("_{name}")) + .map_err(|e| internal_error!("Invalid name `_{name}`: {e}")) + } + } + + /// For subgraph schemas where the `@context` directive is a federation spec directive. + pub(crate) fn context_directive_applications( + &self, + ) -> FallibleDirectiveIterator> { + let federation_spec = get_federation_spec_definition_from_subgraph(self)?; + let context_directive_definition = federation_spec.context_directive_definition(self)?; + let context_directive_referencers = self + .referencers() + .get_directive(&context_directive_definition.name)?; + + let mut applications = Vec::new(); + for interface_type_position in &context_directive_referencers.interface_types { + match interface_type_position.get(self.schema()) { + Ok(interface_type) => { + let directives = &interface_type.directives; + for directive in directives.get_all(&context_directive_definition.name) { + let arguments = federation_spec.context_directive_arguments(directive); + applications.push(arguments.map(|args| ContextDirective { + arguments: args, + target: interface_type_position.clone().into(), + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + for object_type_position in &context_directive_referencers.object_types { + match object_type_position.get(self.schema()) { + Ok(object_type) => { + let directives = &object_type.directives; + for directive in directives.get_all(&context_directive_definition.name) { + let arguments = federation_spec.context_directive_arguments(directive); + applications.push(arguments.map(|args| ContextDirective { + arguments: args, + target: object_type_position.clone().into(), + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + for union_type_position in &context_directive_referencers.union_types { + match union_type_position.get(self.schema()) { + Ok(union_type) => { + let directives = &union_type.directives; + for directive in directives.get_all(&context_directive_definition.name) { + let arguments = federation_spec.context_directive_arguments(directive); + applications.push(arguments.map(|args| ContextDirective { + arguments: args, + target: union_type_position.clone().into(), + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + Ok(applications) + } + + /// For supergraph schemas where the `@context` directive is a "context" spec directive. + pub(crate) fn context_directive_applications_in_supergraph( + &self, + context_spec: &ContextSpecDefinition, + ) -> FallibleDirectiveIterator> { + let context_directive_definition = context_spec.context_directive_definition(self)?; + let context_directive_referencers = self + .referencers() + .get_directive(&context_directive_definition.name)?; + let mut applications = Vec::new(); + for type_pos in context_directive_referencers.composite_type_positions() { + let directive_apps = + type_pos.get_applied_directives(self, &context_directive_definition.name); + for app in directive_apps { + let arguments = context_spec.context_directive_arguments(app); + applications.push(arguments.map(|args| ContextDirective { + // Note: `ContextDirectiveArguments` is also defined in `context_spec_definition` module. + // So, it is converted to the one defined in this module. + arguments: ContextDirectiveArguments { name: args.name }, + target: type_pos.clone(), + })); + } + } + Ok(applications) + } + + #[allow(clippy::wrong_self_convention)] + pub(crate) fn from_context_directive_applications( + &self, + ) -> FallibleDirectiveIterator> { + let federation_spec = get_federation_spec_definition_from_subgraph(self)?; + let from_context_directive_definition = + federation_spec.from_context_directive_definition(self)?; + let from_context_directive_referencers = self + .referencers() + .get_directive(&from_context_directive_definition.name)?; + + let mut applications = Vec::new(); + for interface_field_argument_position in + &from_context_directive_referencers.interface_field_arguments + { + match interface_field_argument_position.get(self.schema()) { + Ok(interface_field_argument) => { + let directives = &interface_field_argument.directives; + for directive in directives.get_all(&from_context_directive_definition.name) { + let arguments = federation_spec.from_context_directive_arguments(directive); + applications.push(arguments.map(|args| FromContextDirective { + arguments: args, + target: interface_field_argument_position.clone().into(), + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + for object_field_argument_position in + &from_context_directive_referencers.object_field_arguments + { + match object_field_argument_position.get(self.schema()) { + Ok(object_field_argument) => { + let directives = &object_field_argument.directives; + for directive in directives.get_all(&from_context_directive_definition.name) { + let arguments = federation_spec.from_context_directive_arguments(directive); + applications.push(arguments.map(|args| FromContextDirective { + arguments: args, + target: object_field_argument_position.clone().into(), + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + Ok(applications) + } + + pub(crate) fn key_directive_applications(&self) -> FallibleDirectiveIterator> { + let federation_spec = get_federation_spec_definition_from_subgraph(self)?; + let key_directive_definition = federation_spec.key_directive_definition(self)?; + let key_directive_referencers = self + .referencers() + .get_directive(&key_directive_definition.name)?; + + let mut applications: Vec> = Vec::new(); + for object_type_position in &key_directive_referencers.object_types { + match object_type_position.get(self.schema()) { + Ok(object_type) => { + let directives = &object_type.directives; + for directive in directives.get_all(&key_directive_definition.name) { + if !matches!( + directive + .argument_by_name(&FEDERATION_FIELDS_ARGUMENT_NAME, self.schema()) + .map(|arg| arg.as_ref()), + Ok(Value::String(_)), + ) { + // Not ideal, but the call to `federation_spec.key_directive_arguments` below will return an internal error + // when this isn't the right type. We preempt that here to provide a better error to the user during validation. + applications.push(Err(SingleFederationError::KeyInvalidFieldsType { + target_type: object_type_position.type_name.clone(), + application: directive.to_string(), + } + .into())) + } else { + let arguments = federation_spec.key_directive_arguments(directive); + applications.push(arguments.map(|args| KeyDirective { + arguments: args, + schema_directive: directive, + sibling_directives: directives, + target: object_type_position.clone().into(), + })); + } + } + } + Err(error) => applications.push(Err(error.into())), + } + } + for interface_type_position in &key_directive_referencers.interface_types { + match interface_type_position.get(self.schema()) { + Ok(interface_type) => { + let directives = &interface_type.directives; + for directive in directives.get_all(&key_directive_definition.name) { + let arguments = federation_spec.key_directive_arguments(directive); + applications.push(arguments.map(|args| KeyDirective { + arguments: args, + schema_directive: directive, + sibling_directives: directives, + target: interface_type_position.clone().into(), + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + Ok(applications) + } + + pub(crate) fn provides_directive_applications( + &self, + ) -> FallibleDirectiveIterator> { + let federation_spec = get_federation_spec_definition_from_subgraph(self)?; + let provides_directive_definition = federation_spec.provides_directive_definition(self)?; + let provides_directive_referencers = self + .referencers() + .get_directive(&provides_directive_definition.name)?; + + let mut applications: Vec> = Vec::new(); + for field_definition_position in provides_directive_referencers.object_or_interface_fields() + { + match field_definition_position.get(self.schema()) { + Ok(field_definition) => { + let directives = &field_definition.directives; + for provides_directive_application in + directives.get_all(&provides_directive_definition.name) + { + if !matches!( + provides_directive_application + .argument_by_name(&FEDERATION_FIELDS_ARGUMENT_NAME, self.schema()) + .map(|arg| arg.as_ref()), + Ok(Value::String(_)), + ) { + // Not ideal, but the call to `federation_spec.provides_directive_arguments` below will return an internal error + // when this isn't the right type. We preempt that here to provide a better error to the user during validation. + applications.push(Err( + SingleFederationError::ProvidesInvalidFieldsType { + coordinate: field_definition_position.coordinate(), + application: provides_directive_application.to_string(), + } + .into(), + )) + } else { + let arguments = federation_spec + .provides_directive_arguments(provides_directive_application); + applications.push(arguments.map(|args| ProvidesDirective { + arguments: args, + schema_directive: provides_directive_application, + target: field_definition_position.clone(), + target_return_type: field_definition.ty.inner_named_type(), + })); + } + } + } + Err(error) => applications.push(Err(error.into())), + } + } + Ok(applications) + } + + pub(crate) fn requires_directive_applications( + &self, + ) -> FallibleDirectiveIterator> { + let federation_spec = get_federation_spec_definition_from_subgraph(self)?; + let requires_directive_definition = federation_spec.requires_directive_definition(self)?; + let requires_directive_referencers = self + .referencers() + .get_directive(&requires_directive_definition.name)?; + + let mut applications = Vec::new(); + for field_definition_position in requires_directive_referencers.object_or_interface_fields() + { + match field_definition_position.get(self.schema()) { + Ok(field_definition) => { + let directives = &field_definition.directives; + for requires_directive_application in + directives.get_all(&requires_directive_definition.name) + { + if !matches!( + requires_directive_application + .argument_by_name(&FEDERATION_FIELDS_ARGUMENT_NAME, self.schema()) + .map(|arg| arg.as_ref()), + Ok(Value::String(_)), + ) { + // Not ideal, but the call to `federation_spec.requires_directive_arguments` below will return an internal error + // when this isn't the right type. We preempt that here to provide a better error to the user during validation. + applications.push(Err( + SingleFederationError::RequiresInvalidFieldsType { + coordinate: field_definition_position.coordinate(), + application: requires_directive_application.to_string(), + } + .into(), + )) + } else { + let arguments = federation_spec + .requires_directive_arguments(requires_directive_application); + applications.push(arguments.map(|args| RequiresDirective { + arguments: args, + schema_directive: requires_directive_application, + target: field_definition_position.clone(), + })); + } + } + } + Err(error) => applications.push(Err(error.into())), + } + } + Ok(applications) + } + + pub(crate) fn tag_directive_applications(&self) -> FallibleDirectiveIterator> { + let federation_spec = get_federation_spec_definition_from_subgraph(self)?; + let tag_directive_definition = federation_spec.tag_directive_definition(self)?; + let tag_directive_referencers = self + .referencers() + .get_directive(&tag_directive_definition.name)?; + + let mut applications = Vec::new(); + // Schema + if let Some(schema_position) = &tag_directive_referencers.schema { + let schema_def = schema_position.get(self.schema()); + let directives = &schema_def.directives; + for tag_directive_application in directives.get_all(&tag_directive_definition.name) { + let arguments = federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::Schema(schema_position.clone()), + directive: tag_directive_application, + })); + } + } + // Interface types + for interface_type_position in &tag_directive_referencers.interface_types { + match interface_type_position.get(self.schema()) { + Ok(interface_type) => { + let directives = &interface_type.directives; + for tag_directive_application in + directives.get_all(&tag_directive_definition.name) + { + let arguments = + federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::Interface( + interface_type_position.clone(), + ), + directive: tag_directive_application, + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + // Interface fields + for field_definition_position in &tag_directive_referencers.interface_fields { + match field_definition_position.get(self.schema()) { + Ok(field_definition) => { + let directives = &field_definition.directives; + for tag_directive_application in + directives.get_all(&tag_directive_definition.name) + { + let arguments = + federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::InterfaceField( + field_definition_position.clone(), + ), + directive: tag_directive_application, + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + // Interface field arguments + for argument_definition_position in &tag_directive_referencers.interface_field_arguments { + match argument_definition_position.get(self.schema()) { + Ok(argument_definition) => { + let directives = &argument_definition.directives; + for tag_directive_application in + directives.get_all(&tag_directive_definition.name) + { + let arguments = + federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::ArgumentDefinition( + argument_definition_position.clone().into(), + ), + directive: tag_directive_application, + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + // Object types + for object_type_position in &tag_directive_referencers.object_types { + match object_type_position.get(self.schema()) { + Ok(object_type) => { + let directives = &object_type.directives; + for tag_directive_application in + directives.get_all(&tag_directive_definition.name) + { + let arguments = + federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::Object( + object_type_position.clone(), + ), + directive: tag_directive_application, + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + // Object fields + for field_definition_position in &tag_directive_referencers.object_fields { + match field_definition_position.get(self.schema()) { + Ok(field_definition) => { + let directives = &field_definition.directives; + for tag_directive_application in + directives.get_all(&tag_directive_definition.name) + { + let arguments = + federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::ObjectField( + field_definition_position.clone(), + ), + directive: tag_directive_application, + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + // Object field arguments + for argument_definition_position in &tag_directive_referencers.object_field_arguments { + match argument_definition_position.get(self.schema()) { + Ok(argument_definition) => { + let directives = &argument_definition.directives; + for tag_directive_application in + directives.get_all(&tag_directive_definition.name) + { + let arguments = + federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::ArgumentDefinition( + argument_definition_position.clone().into(), + ), + directive: tag_directive_application, + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + // Union types + for union_type_position in &tag_directive_referencers.union_types { + match union_type_position.get(self.schema()) { + Ok(union_type) => { + let directives = &union_type.directives; + for tag_directive_application in + directives.get_all(&tag_directive_definition.name) + { + let arguments = + federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::Union(union_type_position.clone()), + directive: tag_directive_application, + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + + // Scalar types + for scalar_type_position in &tag_directive_referencers.scalar_types { + match scalar_type_position.get(self.schema()) { + Ok(scalar_type) => { + let directives = &scalar_type.directives; + for tag_directive_application in + directives.get_all(&tag_directive_definition.name) + { + let arguments = + federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::Scalar( + scalar_type_position.clone(), + ), + directive: tag_directive_application, + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + // Enum types + for enum_type_position in &tag_directive_referencers.enum_types { + match enum_type_position.get(self.schema()) { + Ok(enum_type) => { + let directives = &enum_type.directives; + for tag_directive_application in + directives.get_all(&tag_directive_definition.name) + { + let arguments = + federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::Enum(enum_type_position.clone()), + directive: tag_directive_application, + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + // Enum values + for enum_value_position in &tag_directive_referencers.enum_values { + match enum_value_position.get(self.schema()) { + Ok(enum_value) => { + let directives = &enum_value.directives; + for tag_directive_application in + directives.get_all(&tag_directive_definition.name) + { + let arguments = + federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::EnumValue( + enum_value_position.clone(), + ), + directive: tag_directive_application, + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + // Input object types + for input_object_type_position in &tag_directive_referencers.input_object_types { + match input_object_type_position.get(self.schema()) { + Ok(input_object_type) => { + let directives = &input_object_type.directives; + for tag_directive_application in + directives.get_all(&tag_directive_definition.name) + { + let arguments = + federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::InputObject( + input_object_type_position.clone(), + ), + directive: tag_directive_application, + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + // Input field definitions + for input_field_definition_position in &tag_directive_referencers.input_object_fields { + match input_field_definition_position.get(self.schema()) { + Ok(input_field_definition) => { + let directives = &input_field_definition.directives; + for tag_directive_application in + directives.get_all(&tag_directive_definition.name) + { + let arguments = + federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::InputObjectFieldDefinition( + input_field_definition_position.clone(), + ), + directive: tag_directive_application, + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + // Directive definition arguments + for directive_definition_position in &tag_directive_referencers.directive_arguments { + match directive_definition_position.get(self.schema()) { + Ok(directive_definition) => { + let directives = &directive_definition.directives; + for tag_directive_application in + directives.get_all(&tag_directive_definition.name) + { + let arguments = + federation_spec.tag_directive_arguments(tag_directive_application); + applications.push(arguments.map(|args| TagDirective { + arguments: args, + target: TagDirectiveTargetPosition::DirectiveArgumentDefinition( + directive_definition_position.clone(), + ), + directive: tag_directive_application, + })); + } + } + Err(error) => applications.push(Err(error.into())), + } + } + + Ok(applications) + } + + pub(crate) fn list_size_directive_applications( + &self, + ) -> FallibleDirectiveIterator> { + let Some(list_size_directive_name) = CostSpecDefinition::list_size_directive_name(self)? + else { + return Ok(Vec::new()); + }; + let Ok(list_size_directive_referencers) = self + .referencers() + .get_directive(list_size_directive_name.as_str()) + else { + return Ok(Vec::new()); + }; + + let mut applications = Vec::new(); + for field_definition_position in + list_size_directive_referencers.object_or_interface_fields() + { + let field_definition = field_definition_position.get(self.schema())?; + match CostSpecDefinition::list_size_directive_from_field_definition( + self, + field_definition, + ) { + Ok(Some(list_size_directive)) => { + applications.push(Ok(ListSizeDirective { + directive: list_size_directive, + parent_type: field_definition_position.type_name().clone(), + target: field_definition, + })); + } + Ok(None) => { + // No listSize directive found, continue + } + Err(error) => { + applications.push(Err(error)); + } + } + } + + Ok(applications) + } + + pub(crate) fn cache_tag_directive_applications( + &self, + ) -> FallibleDirectiveIterator> { + let federation_spec = get_federation_spec_definition_from_subgraph(self)?; + let Ok(cache_tag_directive_definition) = + federation_spec.cache_tag_directive_definition(self) + else { + return Ok(Vec::new()); + }; + + let result = self + .referencers() + .get_directive_applications(self, &cache_tag_directive_definition.name)? + .map(|(pos, application)| { + let arguments = federation_spec.cache_tag_directive_arguments(application); + arguments.map(|args| CacheTagDirective { + arguments: args, + target: pos, + }) + }) + .collect(); + Ok(result) + } + + pub(crate) fn is_interface(&self, type_name: &Name) -> bool { + self.referencers().interface_types.contains_key(type_name) + } + + pub(crate) fn all_features( + &self, + ) -> Result, FederationError> { + let Some(links) = self.metadata() else { + return Ok(Vec::new()); + }; + + let mut features: Vec<&'static (dyn SpecDefinition)> = + Vec::with_capacity(links.all_links().len()); + + for link in links.all_links() { + if let Some(spec) = SPEC_REGISTRY.get_definition(&link.url) { + features.push(*spec); + } else if let Some(supported_versions) = SPEC_REGISTRY.get_versions(&link.url.identity) + { + return Err( + SingleFederationError::UnknownLinkVersion { + message: format!( + "Detected unsupported {} specification version {}. Please upgrade to a composition version which supports that version, or select one of the following supported versions: {}.", + link.url.identity.name, + link.url.version, + supported_versions.iter().join(", ") + ), + }.into()); + } + } + + Ok(features) + } + + pub(crate) fn node_locations( + &self, + node: &Node, + ) -> impl Iterator> { + node.line_column_range(&self.schema().sources).into_iter() + } +} + +type FallibleDirectiveIterator = Result>, FederationError>; + +pub(crate) struct ContextDirective<'schema> { + /// The parsed arguments of this `@context` application + arguments: ContextDirectiveArguments<'schema>, + /// The schema position to which this directive is applied + target: CompositeTypeDefinitionPosition, +} + +impl ContextDirective<'_> { + pub(crate) fn arguments(&self) -> &ContextDirectiveArguments<'_> { + &self.arguments + } + + pub(crate) fn target(&self) -> &CompositeTypeDefinitionPosition { + &self.target + } +} + +pub(crate) struct FromContextDirective<'schema> { + /// The parsed arguments of this `@fromContext` application + arguments: FromContextDirectiveArguments<'schema>, + /// The schema position to which this directive is applied + target: FieldArgumentDefinitionPosition, +} + +pub(crate) struct KeyDirective<'schema> { + /// The parsed arguments of this `@key` application + arguments: KeyDirectiveArguments<'schema>, + /// The original `Directive` instance from the AST with unparsed arguments + schema_directive: &'schema apollo_compiler::schema::Component, + /// The `DirectiveList` containing all directives applied to the target position, including this one + sibling_directives: &'schema apollo_compiler::schema::DirectiveList, + /// The schema position to which this directive is applied + target: ObjectOrInterfaceTypeDefinitionPosition, +} + +impl HasFields for KeyDirective<'_> { + fn fields(&self) -> &str { + self.arguments.fields + } + + fn target_type(&self) -> &Name { + self.target.type_name() + } +} + +impl KeyDirective<'_> { + pub(crate) fn target(&self) -> &ObjectOrInterfaceTypeDefinitionPosition { + &self.target + } +} + +pub(crate) struct ListSizeDirective<'schema> { + /// The parsed directive + directive: cost_spec_definition::ListSizeDirective, + /// The parent type of `target` + parent_type: Name, + /// The schema position to which this directive is applied + target: &'schema FieldDefinition, +} + +pub(crate) struct ProvidesDirective<'schema> { + /// The parsed arguments of this `@provides` application + arguments: ProvidesDirectiveArguments<'schema>, + /// The original `Directive` instance from the AST with unparsed arguments + schema_directive: &'schema Node, + /// The schema position to which this directive is applied + /// - Although the directive is not allowed on interfaces, we still need to collect them + /// for validation purposes. + target: ObjectOrInterfaceFieldDefinitionPosition, + /// The return type of the target field + target_return_type: &'schema Name, +} + +impl HasFields for ProvidesDirective<'_> { + /// The string representation of the field set + fn fields(&self) -> &str { + self.arguments.fields + } + + /// The type from which the field set selects + fn target_type(&self) -> &Name { + self.target_return_type + } +} + +pub(crate) struct RequiresDirective<'schema> { + /// The parsed arguments of this `@requires` application + arguments: RequiresDirectiveArguments<'schema>, + /// The original `Directive` instance from the AST with unparsed arguments + schema_directive: &'schema Node, + /// The schema position to which this directive is applied + /// - Although the directive is not allowed on interfaces, we still need to collect them + /// for validation purposes. + target: ObjectOrInterfaceFieldDefinitionPosition, +} + +impl HasFields for RequiresDirective<'_> { + fn fields(&self) -> &str { + self.arguments.fields + } + + fn target_type(&self) -> &Name { + self.target.type_name() + } +} + +pub(crate) struct TagDirective<'schema> { + /// The parsed arguments of this `@tag` application + arguments: TagDirectiveArguments<'schema>, + /// The schema position to which this directive is applied + target: TagDirectiveTargetPosition, // TODO: Make this a reference + /// Reference to the directive in the schema + directive: &'schema Node, +} + +pub(crate) struct CacheTagDirective<'schema> { + /// The parsed arguments of this `@cacheTag` application + arguments: CacheTagDirectiveArguments<'schema>, + /// The schema position to which this directive is applied + target: DirectiveTargetPosition, +} + +pub(crate) trait HasFields { + fn fields(&self) -> &str; + fn target_type(&self) -> &Name; + + fn parse_fields(&self, schema: &Schema) -> Result> { + FieldSet::parse( + Valid::assume_valid_ref(schema), + self.target_type().clone(), + self.fields(), + "field_set.graphql", + ) + } } /// A GraphQL schema with federation data that is known to be valid, and cheap to clone. @@ -229,7 +1240,8 @@ impl ValidFederationSchema { } /// Construct a ValidFederationSchema by assuming the given FederationSchema is valid. - fn new_assume_valid( + #[allow(clippy::result_large_err)] // lint is accurate but this is not in a hot path + pub fn new_assume_valid( mut schema: FederationSchema, ) -> Result { // Populating subgraph metadata requires a mutable FederationSchema, while computing the subgraph @@ -267,7 +1279,7 @@ impl ValidFederationSchema { ) -> Result { // Currently, the types used to define the federation operations, that is _Any, _Entity and _Service, // are not considered part of the federation spec, and are instead hardcoded to the names above. - // The reason being that there is no way to maintain backward compatbility with fed2 if we were to add + // The reason being that there is no way to maintain backward compatibility with fed2 if we were to add // those to the federation spec without requiring users to add those types to their @link `import`, // and that wouldn't be a good user experience (because most users don't really know what those types // are/do). And so we special case it. @@ -333,3 +1345,92 @@ impl std::fmt::Debug for ValidFederationSchema { write!(f, "ValidFederationSchema @ {:?}", Arc::as_ptr(&self.schema)) } } + +pub(crate) trait SchemaElement { + /// Returns true in the first tuple element if `self` has a definition. + /// Returns a set of extension IDs in the second tuple element, if any. + fn definition_and_extensions(&self) -> (bool, IndexSet<&ExtensionId>); + + fn extensions(&self) -> IndexSet<&ExtensionId> { + self.definition_and_extensions().1 + } + + fn has_non_extension_elements(&self) -> bool { + self.definition_and_extensions().0 + } + + fn has_extension_elements(&self) -> bool { + !self.extensions().is_empty() + } + + fn origin_to_use(&self) -> ComponentOrigin { + let extensions = self.extensions(); + // Find an arbitrary extension origin if the schema definition has any extension elements. + // Note: No defined ordering between origins. + let first_extension = extensions.first(); + if let Some(first_extension) = first_extension { + // If there is an extension, use the first extension. + ComponentOrigin::Extension((*first_extension).clone()) + } else { + // Use the existing definition if exists, or maybe a new definition if no definition + // nor extensions exist. + ComponentOrigin::Definition + } + } +} + +impl SchemaElement for SchemaDefinition { + fn definition_and_extensions(&self) -> (bool, IndexSet<&ExtensionId>) { + let mut extensions = IndexSet::default(); + let mut has_definition = false; + let origins = self + .directives + .iter() + .map(|component| &component.origin) + .chain(self.query.iter().map(|name| &name.origin)) + .chain(self.mutation.iter().map(|name| &name.origin)) + .chain(self.subscription.iter().map(|name| &name.origin)); + for origin in origins { + if let Some(extension_id) = origin.extension_id() { + extensions.insert(extension_id); + } else { + has_definition = true; + } + } + (has_definition, extensions) + } +} + +impl SchemaElement for ExtendedType { + fn definition_and_extensions(&self) -> (bool, IndexSet<&ExtensionId>) { + let mut extensions = IndexSet::default(); + let mut has_definition = false; + let directive_origins = self.directives().iter().map(|component| &component.origin); + let other_origins = match self { + ExtendedType::Scalar(_) => Vec::new(), + ExtendedType::Object(t) => t + .implements_interfaces + .iter() + .map(|itf| &itf.origin) + .chain(t.fields.values().map(|f| &f.origin)) + .collect(), + ExtendedType::Interface(t) => t + .implements_interfaces + .iter() + .map(|itf| &itf.origin) + .chain(t.fields.values().map(|f| &f.origin)) + .collect(), + ExtendedType::Union(t) => t.members.iter().map(|m| &m.origin).collect(), + ExtendedType::Enum(t) => t.values.values().map(|v| &v.origin).collect(), + ExtendedType::InputObject(t) => t.fields.values().map(|f| &f.origin).collect(), + }; + for origin in directive_origins.chain(other_origins.into_iter()) { + if let Some(extension_id) = origin.extension_id() { + extensions.insert(extension_id); + } else { + has_definition = true; + } + } + (has_definition, extensions) + } +} diff --git a/apollo-federation/src/schema/position.rs b/apollo-federation/src/schema/position.rs index 99701a5db1..6b743efd68 100644 --- a/apollo-federation/src/schema/position.rs +++ b/apollo-federation/src/schema/position.rs @@ -3,11 +3,15 @@ use std::fmt::Display; use std::fmt::Formatter; use std::ops::Deref; +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::Schema; use apollo_compiler::ast; -use apollo_compiler::collections::IndexSet; +use apollo_compiler::ast::Argument; use apollo_compiler::name; use apollo_compiler::schema::Component; use apollo_compiler::schema::ComponentName; +use apollo_compiler::schema::ComponentOrigin; use apollo_compiler::schema::Directive; use apollo_compiler::schema::DirectiveDefinition; use apollo_compiler::schema::EnumType; @@ -21,18 +25,16 @@ use apollo_compiler::schema::ObjectType; use apollo_compiler::schema::ScalarType; use apollo_compiler::schema::SchemaDefinition; use apollo_compiler::schema::UnionType; -use apollo_compiler::Name; -use apollo_compiler::Node; -use apollo_compiler::Schema; use either::Either; -use lazy_static::lazy_static; use serde::Serialize; use strum::IntoEnumIterator; +use crate::bail; use crate::error::FederationError; use crate::error::SingleFederationError; use crate::link::database::links_metadata; use crate::link::spec_definition::SpecDefinition; +use crate::schema::FederationSchema; use crate::schema::referencer::DirectiveReferencers; use crate::schema::referencer::EnumTypeReferencers; use crate::schema::referencer::InputObjectTypeReferencers; @@ -41,12 +43,6 @@ use crate::schema::referencer::ObjectTypeReferencers; use crate::schema::referencer::Referencers; use crate::schema::referencer::ScalarTypeReferencers; use crate::schema::referencer::UnionTypeReferencers; -use crate::schema::FederationSchema; - -// This is the "captures" trick for dealing with return position impl trait (RPIT), as noted in -// https://rust-lang.github.io/rfcs/3498-lifetime-capture-rules-2024.html#the-captures-trick -pub(crate) trait Captures {} -impl Captures for T {} /// A zero-allocation error representation for position lookups, /// because many of these errors are actually immediately discarded. @@ -156,6 +152,159 @@ macro_rules! infallible_conversions { } } +/// Makes `description` field API available for use with generic types +pub(crate) trait HasDescription { + fn description<'schema>(&self, schema: &'schema FederationSchema) + -> Option<&'schema Node>; + fn set_description( + &self, + schema: &mut FederationSchema, + description: Option>, + ) -> Result<(), FederationError>; +} + +macro_rules! impl_has_description_for { + ($struct_name:ident) => { + impl HasDescription for $struct_name { + fn description<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Option<&'schema Node> { + self.try_get(&schema.schema)?.description.as_ref() + } + + fn set_description( + &self, + schema: &mut FederationSchema, + description: Option>, + ) -> Result<(), FederationError> { + self.make_mut(&mut schema.schema)?.make_mut().description = description; + Ok(()) + } + } + }; +} + +impl_has_description_for!(DirectiveDefinitionPosition); +impl_has_description_for!(ScalarTypeDefinitionPosition); +impl_has_description_for!(ObjectTypeDefinitionPosition); +impl_has_description_for!(InterfaceTypeDefinitionPosition); +impl_has_description_for!(UnionTypeDefinitionPosition); +impl_has_description_for!(EnumTypeDefinitionPosition); +impl_has_description_for!(InputObjectTypeDefinitionPosition); +impl_has_description_for!(ObjectFieldDefinitionPosition); +impl_has_description_for!(InterfaceFieldDefinitionPosition); +impl_has_description_for!(EnumValueDefinitionPosition); + +// Irregular implementations of HasDescription +impl HasDescription for SchemaDefinitionPosition { + fn description<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Option<&'schema Node> { + self.get(&schema.schema).description.as_ref() + } + + fn set_description( + &self, + schema: &mut FederationSchema, + description: Option>, + ) -> Result<(), FederationError> { + self.make_mut(&mut schema.schema).make_mut().description = description; + Ok(()) + } +} + +impl HasDescription for FieldDefinitionPosition { + fn description<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Option<&'schema Node> { + match self { + FieldDefinitionPosition::Object(field) => field.description(schema), + FieldDefinitionPosition::Interface(field) => field.description(schema), + FieldDefinitionPosition::Union(field) => field.description(schema), + } + } + + fn set_description( + &self, + schema: &mut FederationSchema, + description: Option>, + ) -> Result<(), FederationError> { + match self { + FieldDefinitionPosition::Object(field) => field.set_description(schema, description), + FieldDefinitionPosition::Interface(field) => field.set_description(schema, description), + FieldDefinitionPosition::Union(field) => field.set_description(schema, description), + } + } +} + +impl HasDescription for UnionTypenameFieldDefinitionPosition { + fn description<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Option<&'schema Node> { + self.get(&schema.schema) + .map_or(None, |field| field.description.as_ref()) + } + + fn set_description( + &self, + _schema: &mut FederationSchema, + _description: Option>, + ) -> Result<(), FederationError> { + bail!("Description is immutable for union typename fields") + } +} + +impl HasDescription for DirectiveTargetPosition { + fn description<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Option<&'schema Node> { + match self { + Self::Schema(pos) => pos.description(schema), + Self::ScalarType(pos) => pos.description(schema), + Self::ObjectType(pos) => pos.description(schema), + Self::ObjectField(pos) => pos.description(schema), + Self::InterfaceType(pos) => pos.description(schema), + Self::InterfaceField(pos) => pos.description(schema), + Self::UnionType(pos) => pos.description(schema), + Self::EnumType(pos) => pos.description(schema), + Self::EnumValue(pos) => pos.description(schema), + Self::InputObjectType(pos) => pos.description(schema), + _ => None, + } + } + + fn set_description( + &self, + schema: &mut FederationSchema, + description: Option>, + ) -> Result<(), FederationError> { + match self { + Self::Schema(pos) => pos.set_description(schema, description), + Self::ScalarType(pos) => pos.set_description(schema, description), + Self::ObjectType(pos) => pos.set_description(schema, description), + Self::ObjectField(pos) => pos.set_description(schema, description), + Self::InterfaceType(pos) => pos.set_description(schema, description), + Self::InterfaceField(pos) => pos.set_description(schema, description), + Self::UnionType(pos) => pos.set_description(schema, description), + Self::EnumType(pos) => pos.set_description(schema, description), + Self::EnumValue(pos) => pos.set_description(schema, description), + Self::InputObjectType(pos) => pos.set_description(schema, description), + _ => Err(FederationError::SingleFederationError( + SingleFederationError::Internal { + message: String::from( + "No valid conversion from DirectiveTargetPosition to desired type.", + ), + }, + )), + } + } +} + #[derive(Clone, PartialEq, Eq, Hash, derive_more::From, derive_more::Display)] pub(crate) enum TypeDefinitionPosition { Scalar(ScalarTypeDefinitionPosition), @@ -189,6 +338,14 @@ impl TypeDefinitionPosition { ) } + pub(crate) fn is_introspection_type(&self) -> bool { + self.type_name().starts_with("__") + } + + pub(crate) fn is_object_type(&self) -> bool { + matches!(self, TypeDefinitionPosition::Object(_)) + } + pub(crate) fn type_name(&self) -> &Name { match self { TypeDefinitionPosition::Scalar(type_) => &type_.type_name, @@ -200,6 +357,17 @@ impl TypeDefinitionPosition { } } + pub(crate) fn kind(&self) -> &'static str { + match self { + TypeDefinitionPosition::Object(_) => "ObjectType", + TypeDefinitionPosition::Interface(_) => "InterfaceType", + TypeDefinitionPosition::Union(_) => "UnionType", + TypeDefinitionPosition::Enum(_) => "EnumType", + TypeDefinitionPosition::Scalar(_) => "ScalarType", + TypeDefinitionPosition::InputObject(_) => "InputObjectType", + } + } + fn describe(&self) -> &'static str { match self { TypeDefinitionPosition::Scalar(_) => ScalarTypeDefinitionPosition::EXPECTED, @@ -233,6 +401,258 @@ impl TypeDefinitionPosition { )), } } + + pub(crate) fn insert_directive( + &self, + schema: &mut FederationSchema, + directive: Component, + ) -> Result<(), FederationError> { + match self { + TypeDefinitionPosition::Scalar(type_) => type_.insert_directive(schema, directive), + TypeDefinitionPosition::Object(type_) => type_.insert_directive(schema, directive), + TypeDefinitionPosition::Interface(type_) => type_.insert_directive(schema, directive), + TypeDefinitionPosition::Union(type_) => type_.insert_directive(schema, directive), + TypeDefinitionPosition::Enum(type_) => type_.insert_directive(schema, directive), + TypeDefinitionPosition::InputObject(type_) => type_.insert_directive(schema, directive), + } + } + + pub(crate) fn rename( + &self, + schema: &mut FederationSchema, + new_name: Name, + ) -> Result<(), FederationError> { + match self { + TypeDefinitionPosition::Scalar(type_) => type_.rename(schema, new_name.clone())?, + TypeDefinitionPosition::Object(type_) => type_.rename(schema, new_name.clone())?, + TypeDefinitionPosition::Interface(type_) => type_.rename(schema, new_name.clone())?, + TypeDefinitionPosition::Union(type_) => type_.rename(schema, new_name.clone())?, + TypeDefinitionPosition::Enum(type_) => type_.rename(schema, new_name.clone())?, + TypeDefinitionPosition::InputObject(type_) => type_.rename(schema, new_name.clone())?, + } + + if let Some(existing_type) = schema.schema.types.swap_remove(self.type_name()) { + schema.schema.types.insert(new_name, existing_type); + } + + Ok(()) + } + + pub(crate) fn remove_extensions( + &self, + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + match self { + TypeDefinitionPosition::Scalar(type_) => type_.remove_extensions(schema), + TypeDefinitionPosition::Object(type_) => type_.remove_extensions(schema), + TypeDefinitionPosition::Interface(type_) => type_.remove_extensions(schema), + TypeDefinitionPosition::Union(type_) => type_.remove_extensions(schema), + TypeDefinitionPosition::Enum(type_) => type_.remove_extensions(schema), + TypeDefinitionPosition::InputObject(type_) => type_.remove_extensions(schema), + } + } + + pub(crate) fn has_applied_directive( + &self, + schema: &FederationSchema, + directive_name: &Name, + ) -> bool { + match self { + TypeDefinitionPosition::Scalar(type_) => { + type_.has_applied_directive(schema, directive_name) + } + TypeDefinitionPosition::Object(type_) => { + type_.has_applied_directive(schema, directive_name) + } + TypeDefinitionPosition::Interface(type_) => { + type_.has_applied_directive(schema, directive_name) + } + TypeDefinitionPosition::Union(type_) => { + type_.has_applied_directive(schema, directive_name) + } + TypeDefinitionPosition::Enum(type_) => { + type_.has_applied_directive(schema, directive_name) + } + TypeDefinitionPosition::InputObject(type_) => { + type_.has_applied_directive(schema, directive_name) + } + } + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Component> { + match self { + TypeDefinitionPosition::Scalar(type_) => { + type_.get_applied_directives(schema, directive_name) + } + TypeDefinitionPosition::Object(type_) => { + type_.get_applied_directives(schema, directive_name) + } + TypeDefinitionPosition::Interface(type_) => { + type_.get_applied_directives(schema, directive_name) + } + TypeDefinitionPosition::Union(type_) => { + type_.get_applied_directives(schema, directive_name) + } + TypeDefinitionPosition::Enum(type_) => { + type_.get_applied_directives(schema, directive_name) + } + TypeDefinitionPosition::InputObject(type_) => { + type_.get_applied_directives(schema, directive_name) + } + } + } + + /// Remove a directive application. + #[allow(unused)] + pub(crate) fn remove_directive( + &self, + schema: &mut FederationSchema, + directive: &Component, + ) { + match self { + TypeDefinitionPosition::Scalar(type_) => type_.remove_directive(schema, directive), + TypeDefinitionPosition::Object(type_) => type_.remove_directive(schema, directive), + TypeDefinitionPosition::Interface(type_) => type_.remove_directive(schema, directive), + TypeDefinitionPosition::Union(type_) => type_.remove_directive(schema, directive), + TypeDefinitionPosition::Enum(type_) => type_.remove_directive(schema, directive), + TypeDefinitionPosition::InputObject(type_) => type_.remove_directive(schema, directive), + } + } + + pub(crate) fn pre_insert(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { + match self { + TypeDefinitionPosition::Scalar(type_) => type_.pre_insert(schema), + TypeDefinitionPosition::Object(type_) => type_.pre_insert(schema), + TypeDefinitionPosition::Interface(type_) => type_.pre_insert(schema), + TypeDefinitionPosition::Union(type_) => type_.pre_insert(schema), + TypeDefinitionPosition::Enum(type_) => type_.pre_insert(schema), + TypeDefinitionPosition::InputObject(type_) => type_.pre_insert(schema), + } + } + + /// Inserts a new empty type with this position's type name into the schema. + /// This is used during passes where we shallow-copy types from schema to schema. + pub(crate) fn insert_empty( + &self, + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + match self { + TypeDefinitionPosition::Scalar(type_) => type_.insert( + schema, + Node::new(ScalarType { + description: None, + name: self.type_name().clone(), + directives: Default::default(), + }), + ), + TypeDefinitionPosition::Object(type_) => type_.insert( + schema, + Node::new(ObjectType { + description: None, + name: self.type_name().clone(), + implements_interfaces: Default::default(), + fields: Default::default(), + directives: Default::default(), + }), + ), + TypeDefinitionPosition::Interface(type_) => type_.insert_empty(schema), + TypeDefinitionPosition::Union(type_) => type_.insert( + schema, + Node::new(UnionType { + description: None, + name: self.type_name().clone(), + members: Default::default(), + directives: Default::default(), + }), + ), + TypeDefinitionPosition::Enum(type_) => type_.insert( + schema, + Node::new(EnumType { + description: None, + name: self.type_name().clone(), + values: Default::default(), + directives: Default::default(), + }), + ), + TypeDefinitionPosition::InputObject(type_) => type_.insert( + schema, + Node::new(InputObjectType { + description: None, + name: self.type_name().clone(), + fields: Default::default(), + directives: Default::default(), + }), + ), + } + } +} + +impl From<&ExtendedType> for TypeDefinitionPosition { + fn from(ty: &ExtendedType) -> Self { + match ty { + ExtendedType::Scalar(v) => { + TypeDefinitionPosition::Scalar(ScalarTypeDefinitionPosition { + type_name: v.name.clone(), + }) + } + ExtendedType::Object(v) => { + TypeDefinitionPosition::Object(ObjectTypeDefinitionPosition { + type_name: v.name.clone(), + }) + } + ExtendedType::Interface(v) => { + TypeDefinitionPosition::Interface(InterfaceTypeDefinitionPosition { + type_name: v.name.clone(), + }) + } + ExtendedType::Union(v) => TypeDefinitionPosition::Union(UnionTypeDefinitionPosition { + type_name: v.name.clone(), + }), + ExtendedType::Enum(v) => TypeDefinitionPosition::Enum(EnumTypeDefinitionPosition { + type_name: v.name.clone(), + }), + ExtendedType::InputObject(v) => { + TypeDefinitionPosition::InputObject(InputObjectTypeDefinitionPosition { + type_name: v.name.clone(), + }) + } + } + } +} + +impl HasDescription for TypeDefinitionPosition { + fn description<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Option<&'schema Node> { + match self { + Self::Scalar(ty) => ty.description(schema), + Self::Object(ty) => ty.description(schema), + Self::Interface(ty) => ty.description(schema), + Self::Union(ty) => ty.description(schema), + Self::Enum(ty) => ty.description(schema), + Self::InputObject(ty) => ty.description(schema), + } + } + + fn set_description( + &self, + schema: &mut FederationSchema, + description: Option>, + ) -> Result<(), FederationError> { + match self { + Self::Scalar(ty) => ty.set_description(schema, description), + Self::Object(ty) => ty.set_description(schema, description), + Self::Interface(ty) => ty.set_description(schema, description), + Self::Union(ty) => ty.set_description(schema, description), + Self::Enum(ty) => ty.set_description(schema, description), + Self::InputObject(ty) => ty.set_description(schema, description), + } + } } fallible_conversions!(TypeDefinitionPosition::Scalar -> ScalarTypeDefinitionPosition); @@ -271,6 +691,13 @@ impl Debug for OutputTypeDefinitionPosition { impl OutputTypeDefinitionPosition { const EXPECTED: &'static str = "an output type"; + pub(crate) fn is_leaf_type(&self) -> bool { + matches!( + self, + OutputTypeDefinitionPosition::Scalar(_) | OutputTypeDefinitionPosition::Enum(_) + ) + } + pub(crate) fn type_name(&self) -> &Name { match self { OutputTypeDefinitionPosition::Scalar(type_) => &type_.type_name, @@ -402,6 +829,42 @@ impl CompositeTypeDefinitionPosition { )), } } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Component> { + match self { + CompositeTypeDefinitionPosition::Object(type_) => { + type_.get_applied_directives(schema, directive_name) + } + CompositeTypeDefinitionPosition::Interface(type_) => { + type_.get_applied_directives(schema, directive_name) + } + CompositeTypeDefinitionPosition::Union(type_) => { + type_.get_applied_directives(schema, directive_name) + } + } + } + + pub(crate) fn insert_directive( + &self, + schema: &mut FederationSchema, + directive: Component, + ) -> Result<(), FederationError> { + match self { + CompositeTypeDefinitionPosition::Object(type_) => { + type_.insert_directive(schema, directive) + } + CompositeTypeDefinitionPosition::Interface(type_) => { + type_.insert_directive(schema, directive) + } + CompositeTypeDefinitionPosition::Union(type_) => { + type_.insert_directive(schema, directive) + } + } + } } fallible_conversions!(CompositeTypeDefinitionPosition::Object -> ObjectTypeDefinitionPosition); @@ -485,10 +948,8 @@ impl ObjectOrInterfaceTypeDefinitionPosition { pub(crate) fn fields<'a>( &'a self, schema: &'a Schema, - ) -> Result< - impl Iterator + Captures<&'a ()>, - FederationError, - > { + ) -> Result, FederationError> + { match self { ObjectOrInterfaceTypeDefinitionPosition::Object(type_) => Ok(Either::Left( type_.fields(schema)?.map(|field| field.into()), @@ -498,6 +959,28 @@ impl ObjectOrInterfaceTypeDefinitionPosition { )), } } + + pub(crate) fn insert_directive( + &self, + schema: &mut FederationSchema, + directive: Component, + ) -> Result<(), FederationError> { + match self { + Self::Object(type_) => type_.insert_directive(schema, directive), + Self::Interface(type_) => type_.insert_directive(schema, directive), + } + } + + pub(crate) fn insert_implements_interface( + &self, + schema: &mut FederationSchema, + interface_name: ComponentName, + ) -> Result<(), FederationError> { + match self { + Self::Object(type_) => type_.insert_implements_interface(schema, interface_name), + Self::Interface(type_) => type_.insert_implements_interface(schema, interface_name), + } + } } fallible_conversions!(ObjectOrInterfaceTypeDefinitionPosition::Object -> ObjectTypeDefinitionPosition); @@ -553,6 +1036,50 @@ impl FieldDefinitionPosition { } } + #[allow(unused)] + pub(crate) fn has_applied_directive( + &self, + schema: &FederationSchema, + directive_name: &Name, + ) -> bool { + match self { + FieldDefinitionPosition::Object(field) => !field + .get_applied_directives(schema, directive_name) + .is_empty(), + FieldDefinitionPosition::Interface(field) => !field + .get_applied_directives(schema, directive_name) + .is_empty(), + FieldDefinitionPosition::Union(_) => false, + } + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Node> { + match self { + FieldDefinitionPosition::Object(field) => { + field.get_applied_directives(schema, directive_name) + } + FieldDefinitionPosition::Interface(field) => { + field.get_applied_directives(schema, directive_name) + } + FieldDefinitionPosition::Union(_) => vec![], + } + } + + pub(crate) fn remove_directive( + &self, + schema: &mut FederationSchema, + directive: &Node, + ) { + match self { + FieldDefinitionPosition::Object(field) => field.remove_directive(schema, directive), + FieldDefinitionPosition::Interface(field) => field.remove_directive(schema, directive), + FieldDefinitionPosition::Union(_) => (), + } + } pub(crate) fn get<'schema>( &self, schema: &'schema Schema, @@ -570,10 +1097,30 @@ impl FieldDefinitionPosition { ) -> Option<&'schema Component> { self.get(schema).ok() } + + pub(crate) fn is_interface(&self) -> bool { + matches!(self, FieldDefinitionPosition::Interface(_)) + } } infallible_conversions!(ObjectOrInterfaceFieldDefinitionPosition::{Object, Interface} -> FieldDefinitionPosition); +impl TryFrom for FieldDefinitionPosition { + type Error = &'static str; + + fn try_from(dl: DirectiveTargetPosition) -> Result { + match dl { + DirectiveTargetPosition::ObjectField(field) => { + Ok(FieldDefinitionPosition::Object(field)) + } + DirectiveTargetPosition::InterfaceField(field) => { + Ok(FieldDefinitionPosition::Interface(field)) + } + _ => Err("No valid conversion"), + } + } +} + #[derive(Clone, PartialEq, Eq, Hash, derive_more::From, derive_more::Display)] pub(crate) enum ObjectOrInterfaceFieldDefinitionPosition { Object(ObjectFieldDefinitionPosition), @@ -592,6 +1139,13 @@ impl Debug for ObjectOrInterfaceFieldDefinitionPosition { impl ObjectOrInterfaceFieldDefinitionPosition { const EXPECTED: &'static str = "an object/interface field"; + pub(crate) fn type_name(&self) -> &Name { + match self { + ObjectOrInterfaceFieldDefinitionPosition::Object(field) => &field.type_name, + ObjectOrInterfaceFieldDefinitionPosition::Interface(field) => &field.type_name, + } + } + pub(crate) fn field_name(&self) -> &Name { match self { ObjectOrInterfaceFieldDefinitionPosition::Object(field) => &field.field_name, @@ -653,14 +1207,37 @@ impl ObjectOrInterfaceFieldDefinitionPosition { } } } + + pub(crate) fn coordinate(&self) -> String { + format!("{}.{}", self.type_name(), self.field_name()) + } } fallible_conversions!(FieldDefinitionPosition::{Object, Interface} -> ObjectOrInterfaceFieldDefinitionPosition); -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::Display)] pub(crate) struct SchemaDefinitionPosition; impl SchemaDefinitionPosition { + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> impl Iterator> { + self.get(&schema.schema).directives.iter() + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Component> { + let schema_def = self.get(&schema.schema); + schema_def + .directives + .iter() + .filter(|d| d.name == *directive_name) + .collect() + } pub(crate) fn get<'schema>(&self, schema: &'schema Schema) -> &'schema Node { &schema.schema_definition } @@ -676,6 +1253,15 @@ impl SchemaDefinitionPosition { &self, schema: &mut FederationSchema, directive: Component, + ) -> Result<(), FederationError> { + self.insert_directive_at(schema, directive, self.get(&schema.schema).directives.len()) + } + + pub(crate) fn insert_directive_at( + &self, + schema: &mut FederationSchema, + directive: Component, + index: usize, ) -> Result<(), FederationError> { let schema_definition = self.make_mut(&mut schema.schema); if schema_definition @@ -692,7 +1278,10 @@ impl SchemaDefinitionPosition { .into()); } let name = directive.name.clone(); - schema_definition.make_mut().directives.push(directive); + schema_definition + .make_mut() + .directives + .insert(index, directive); self.insert_directive_name_references(&mut schema.referencers, &name)?; schema.links_metadata = links_metadata(&schema.schema)?.map(Box::new); Ok(()) @@ -757,8 +1346,7 @@ impl SchemaDefinitionPosition { let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Schema definition's directive application \"@{}\" does not refer to an existing directive.", - name, + "Schema definition's directive application \"@{name}\" does not refer to an existing directive.", ), } })?; @@ -789,6 +1377,73 @@ impl SchemaDefinitionPosition { } } +#[derive(Clone, PartialEq, Eq, Hash, derive_more::From, derive_more::Display)] +pub(crate) enum TagDirectiveTargetPosition { + ObjectField(ObjectFieldDefinitionPosition), + InterfaceField(InterfaceFieldDefinitionPosition), + UnionField(UnionTypenameFieldDefinitionPosition), + Object(ObjectTypeDefinitionPosition), + Interface(InterfaceTypeDefinitionPosition), + Union(UnionTypeDefinitionPosition), + ArgumentDefinition(FieldArgumentDefinitionPosition), + Scalar(ScalarTypeDefinitionPosition), + Enum(EnumTypeDefinitionPosition), + EnumValue(EnumValueDefinitionPosition), + InputObject(InputObjectTypeDefinitionPosition), + InputObjectFieldDefinition(InputObjectFieldDefinitionPosition), + Schema(SchemaDefinitionPosition), + DirectiveArgumentDefinition(DirectiveArgumentDefinitionPosition), +} + +impl Debug for TagDirectiveTargetPosition { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::ObjectField(p) => write!(f, "ObjectField({p})"), + Self::InterfaceField(p) => write!(f, "InterfaceField({p})"), + Self::UnionField(p) => write!(f, "UnionField({p})"), + Self::Object(p) => write!(f, "Object({p})"), + Self::Interface(p) => write!(f, "Interface({p})"), + Self::Union(p) => write!(f, "Union({p})"), + Self::ArgumentDefinition(p) => write!(f, "ArgumentDefinition({p})"), + Self::Scalar(p) => write!(f, "Scalar({p})"), + Self::Enum(p) => write!(f, "Enum({p})"), + Self::EnumValue(p) => write!(f, "EnumValue({p})"), + Self::InputObject(p) => write!(f, "InputObject({p})"), + Self::InputObjectFieldDefinition(p) => write!(f, "InputObjectFieldDefinition({p})"), + Self::Schema(p) => write!(f, "Schema({p})"), + Self::DirectiveArgumentDefinition(p) => { + write!(f, "DirectiveArgumentDefinition({p})") + } + } + } +} + +fallible_conversions!(TagDirectiveTargetPosition::Object -> ObjectTypeDefinitionPosition); +fallible_conversions!(TagDirectiveTargetPosition::Interface -> InterfaceTypeDefinitionPosition); +fallible_conversions!(TagDirectiveTargetPosition::Union -> UnionTypeDefinitionPosition); +fallible_conversions!(TagDirectiveTargetPosition::Scalar -> ScalarTypeDefinitionPosition); +fallible_conversions!(TagDirectiveTargetPosition::Enum -> EnumTypeDefinitionPosition); +fallible_conversions!(TagDirectiveTargetPosition::InputObject -> InputObjectTypeDefinitionPosition); + +impl TryFrom for FieldDefinitionPosition { + type Error = &'static str; + + fn try_from(dl: TagDirectiveTargetPosition) -> Result { + match dl { + TagDirectiveTargetPosition::ObjectField(field) => { + Ok(FieldDefinitionPosition::Object(field)) + } + TagDirectiveTargetPosition::InterfaceField(field) => { + Ok(FieldDefinitionPosition::Interface(field)) + } + TagDirectiveTargetPosition::UnionField(field) => { + Ok(FieldDefinitionPosition::Union(field)) + } + _ => Err("No valid conversion"), + } + } +} + #[derive( Debug, Copy, @@ -848,14 +1503,14 @@ impl SchemaRootDefinitionPosition { match self.root_kind { SchemaRootDefinitionKind::Query => schema_definition.query.as_ref().ok_or_else(|| { SingleFederationError::Internal { - message: format!("Schema definition has no root {} type", self), + message: format!("Schema definition has no root {self} type"), } .into() }), SchemaRootDefinitionKind::Mutation => { schema_definition.mutation.as_ref().ok_or_else(|| { SingleFederationError::Internal { - message: format!("Schema definition has no root {} type", self), + message: format!("Schema definition has no root {self} type"), } .into() }) @@ -863,7 +1518,7 @@ impl SchemaRootDefinitionPosition { SchemaRootDefinitionKind::Subscription => { schema_definition.subscription.as_ref().ok_or_else(|| { SingleFederationError::Internal { - message: format!("Schema definition has no root {} type", self), + message: format!("Schema definition has no root {self} type"), } .into() }) @@ -885,7 +1540,7 @@ impl SchemaRootDefinitionPosition { ) -> Result<(), FederationError> { if self.try_get(&schema.schema).is_some() { return Err(SingleFederationError::Internal { - message: format!("Root {} already exists on schema definition", self), + message: format!("Root {self} already exists on schema definition"), } .into()); } @@ -974,6 +1629,32 @@ impl SchemaRootDefinitionPosition { object_type_referencers.schema_roots.shift_remove(self); Ok(()) } + + fn rename_type( + &self, + schema: &mut FederationSchema, + new_name: Name, + ) -> Result<(), FederationError> { + let parent = self.parent().make_mut(&mut schema.schema).make_mut(); + match self.root_kind { + SchemaRootDefinitionKind::Query => { + if let Some(query) = &mut parent.query { + query.name = new_name; + } + } + SchemaRootDefinitionKind::Mutation => { + if let Some(mutation) = &mut parent.mutation { + mutation.name = new_name; + } + } + SchemaRootDefinitionKind::Subscription => { + if let Some(subscription) = &mut parent.subscription { + subscription.name = new_name; + } + } + } + Ok(()) + } } impl Display for SchemaRootDefinitionPosition { @@ -1050,16 +1731,7 @@ impl ScalarTypeDefinitionPosition { pub(crate) fn pre_insert(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { if schema.referencers.contains_type_name(&self.type_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.type_name) - || GRAPHQL_BUILTIN_SCALAR_NAMES.contains(&self.type_name) - { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" has already been pre-inserted", self), - } - .into()); + bail!(r#"Type "{self}" has already been pre-inserted"#); } schema .referencers @@ -1087,22 +1759,10 @@ impl ScalarTypeDefinitionPosition { .scalar_types .contains_key(&self.type_name) { - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" has not been pre-inserted", self), - } - .into()); + bail!(r#"Type "{self}" has not been pre-inserted"#); } if schema.schema.types.contains_key(&self.type_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.type_name) - || GRAPHQL_BUILTIN_SCALAR_NAMES.contains(&self.type_name) - { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" already exists in schema", self), - } - .into()); + bail!(r#"Type "{self}" already exists in schema"#); } schema .schema @@ -1155,7 +1815,7 @@ impl ScalarTypeDefinitionPosition { .scalar_types .shift_remove(&self.type_name) .ok_or_else(|| SingleFederationError::Internal { - message: format!("Schema missing referencers for type \"{}\"", self), + message: format!("Schema missing referencers for type \"{self}\""), })?, )) } @@ -1222,9 +1882,7 @@ impl ScalarTypeDefinitionPosition { let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Scalar type \"{}\"'s directive application \"@{}\" does not refer to an existing directive.", - self, - name, + "Scalar type \"{self}\"'s directive application \"@{name}\" does not refer to an existing directive.", ), } })?; @@ -1238,6 +1896,103 @@ impl ScalarTypeDefinitionPosition { }; directive_referencers.scalar_types.shift_remove(self); } + + fn rename(&self, schema: &mut FederationSchema, new_name: Name) -> Result<(), FederationError> { + self.make_mut(&mut schema.schema)?.make_mut().name = new_name.clone(); + + if let Some(scalar_type_referencers) = + schema.referencers.scalar_types.swap_remove(&self.type_name) + { + for pos in scalar_type_referencers.object_fields.iter() { + pos.rename_type(schema, new_name.clone())?; + } + for pos in scalar_type_referencers.object_field_arguments.iter() { + pos.rename_type(schema, new_name.clone())?; + } + for pos in scalar_type_referencers.interface_fields.iter() { + pos.rename_type(schema, new_name.clone())?; + } + for pos in scalar_type_referencers.interface_field_arguments.iter() { + pos.rename_type(schema, new_name.clone())?; + } + for pos in scalar_type_referencers.input_object_fields.iter() { + pos.rename_type(schema, new_name.clone())?; + } + schema + .referencers + .scalar_types + .insert(new_name, scalar_type_referencers); + } + + Ok(()) + } + + fn remove_extensions(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { + for directive in self + .make_mut(&mut schema.schema)? + .make_mut() + .directives + .iter_mut() + { + directive.origin = ComponentOrigin::Definition; + } + Ok(()) + } + + pub(crate) fn has_applied_directive( + &self, + schema: &FederationSchema, + directive_name: &Name, + ) -> bool { + if let Some(type_) = self.try_get(schema.schema()) { + return type_ + .directives + .iter() + .any(|directive| &directive.name == directive_name); + } + false + } + + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result>, FederationError> { + Ok(self.get(&schema.schema)?.directives.iter()) + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Component> { + if let Some(field) = self.try_get(&schema.schema) { + field + .directives + .iter() + .filter(|directive| &directive.name == directive_name) + .collect() + } else { + Vec::new() + } + } + + pub(crate) fn remove_directive( + &self, + schema: &mut FederationSchema, + directive: &Component, + ) { + let Some(obj) = self.try_make_mut(&mut schema.schema) else { + return; + }; + if !obj.directives.iter().any(|other_directive| { + (other_directive.name == directive.name) && !other_directive.ptr_eq(directive) + }) { + self.remove_directive_name_references(&mut schema.referencers, &directive.name); + } + obj.make_mut() + .directives + .retain(|other_directive| !other_directive.ptr_eq(directive)); + } } impl Display for ScalarTypeDefinitionPosition { @@ -1277,16 +2032,10 @@ impl ObjectTypeDefinitionPosition { self.field(name!("__type")) } - // TODO: Once the new lifetime capturing rules for return position impl trait (RPIT) land in - // Rust edition 2024, we will no longer need the "captures" trick here, as noted in - // https://rust-lang.github.io/rfcs/3498-lifetime-capture-rules-2024.html pub(crate) fn fields<'a>( &'a self, schema: &'a Schema, - ) -> Result< - impl Iterator + Captures<&'a ()>, - FederationError, - > { + ) -> Result, FederationError> { Ok(self .get(schema)? .fields @@ -1321,7 +2070,7 @@ impl ObjectTypeDefinitionPosition { self.get(schema).ok() } - fn make_mut<'schema>( + pub(crate) fn make_mut<'schema>( &self, schema: &'schema mut Schema, ) -> Result<&'schema mut Node, PositionLookupError> { @@ -1354,14 +2103,7 @@ impl ObjectTypeDefinitionPosition { pub(crate) fn pre_insert(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { if schema.referencers.contains_type_name(&self.type_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.type_name) { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" has already been pre-inserted", self), - } - .into()); + bail!(r#"Type "{self}" has already been pre-inserted"#); } schema .referencers @@ -1389,20 +2131,10 @@ impl ObjectTypeDefinitionPosition { .object_types .contains_key(&self.type_name) { - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" has not been pre-inserted", self), - } - .into()); + bail!(r#"Type "{self}" has not been pre-inserted"#); } if schema.schema.types.contains_key(&self.type_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.type_name) { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" already exists in schema", self), - } - .into()); + bail!(r#"Type "{self}" already exists in schema"#); } schema .schema @@ -1480,7 +2212,7 @@ impl ObjectTypeDefinitionPosition { .object_types .shift_remove(&self.type_name) .ok_or_else(|| SingleFederationError::Internal { - message: format!("Schema missing referencers for type \"{}\"", self), + message: format!("Schema missing referencers for type \"{self}\""), })?, )) } @@ -1637,9 +2369,7 @@ impl ObjectTypeDefinitionPosition { let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Object type \"{}\"'s directive application \"@{}\" does not refer to an existing directive.", - self, - name, + "Object type \"{self}\"'s directive application \"@{name}\" does not refer to an existing directive.", ), } })?; @@ -1662,9 +2392,7 @@ impl ObjectTypeDefinitionPosition { let interface_type_referencers = referencers.interface_types.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Object type \"{}\"'s implements \"{}\" does not refer to an existing interface.", - self, - name, + "Object type \"{self}\"'s implements \"{name}\" does not refer to an existing interface.", ), } })?; @@ -1700,20 +2428,135 @@ impl ObjectTypeDefinitionPosition { Ok(()) } - fn remove_root_query_references( + fn remove_root_query_references( + &self, + schema: &Schema, + referencers: &mut Referencers, + ) -> Result<(), FederationError> { + let introspection_schema_field = self.introspection_schema_field(); + if let Some(field) = introspection_schema_field.try_get(schema) { + introspection_schema_field.remove_references(field, referencers, true)?; + } + let introspection_type_field = self.introspection_type_field(); + if let Some(field) = introspection_type_field.try_get(schema) { + introspection_type_field.remove_references(field, referencers, true)?; + } + Ok(()) + } + + fn rename(&self, schema: &mut FederationSchema, new_name: Name) -> Result<(), FederationError> { + self.make_mut(&mut schema.schema)?.make_mut().name = new_name.clone(); + + if let Some(object_type_referencers) = + schema.referencers.object_types.swap_remove(&self.type_name) + { + for pos in object_type_referencers.schema_roots.iter() { + pos.rename_type(schema, new_name.clone())?; + } + for pos in object_type_referencers.object_fields.iter() { + pos.rename_type(schema, new_name.clone())?; + } + for pos in object_type_referencers.interface_fields.iter() { + pos.rename_type(schema, new_name.clone())?; + } + for pos in object_type_referencers.union_types.iter() { + pos.rename_member(schema, &self.type_name, new_name.clone())?; + } + + schema + .referencers + .object_types + .insert(new_name, object_type_referencers); + } + + Ok(()) + } + + fn rename_implemented_interface( + &self, + schema: &mut FederationSchema, + old_name: &Name, + new_name: Name, + ) -> Result<(), FederationError> { + let type_ = self.make_mut(&mut schema.schema)?.make_mut(); + type_.implements_interfaces.swap_remove(old_name); + type_.implements_interfaces.insert(new_name.into()); + Ok(()) + } + + fn remove_extensions(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { + let type_ = self.make_mut(&mut schema.schema)?.make_mut(); + for directive in type_.directives.iter_mut() { + directive.origin = ComponentOrigin::Definition; + } + type_.implements_interfaces = type_ + .implements_interfaces + .iter() + .map(|i| { + let mut i = i.clone(); + i.origin = ComponentOrigin::Definition; + i + }) + .collect(); + for (_, field) in type_.fields.iter_mut() { + field.origin = ComponentOrigin::Definition; + } + Ok(()) + } + + pub(crate) fn has_applied_directive( + &self, + schema: &FederationSchema, + directive_name: &Name, + ) -> bool { + if let Some(type_) = self.try_get(schema.schema()) { + return type_ + .directives + .iter() + .any(|directive| &directive.name == directive_name); + } + false + } + + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result>, FederationError> { + Ok(self.get(&schema.schema)?.directives.iter()) + } + + pub(crate) fn get_applied_directives<'schema>( &self, - schema: &Schema, - referencers: &mut Referencers, - ) -> Result<(), FederationError> { - let introspection_schema_field = self.introspection_schema_field(); - if let Some(field) = introspection_schema_field.try_get(schema) { - introspection_schema_field.remove_references(field, referencers, true)?; + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Component> { + if let Some(field) = self.try_get(&schema.schema) { + field + .directives + .iter() + .filter(|directive| &directive.name == directive_name) + .collect() + } else { + Vec::new() } - let introspection_type_field = self.introspection_type_field(); - if let Some(field) = introspection_type_field.try_get(schema) { - introspection_type_field.remove_references(field, referencers, true)?; + } + + pub(crate) fn remove_directive( + &self, + schema: &mut FederationSchema, + directive: &Component, + ) { + let Some(obj) = self.try_make_mut(&mut schema.schema) else { + return; + }; + if !obj.directives.iter().any(|other_directive| { + (other_directive.name == directive.name) && !other_directive.ptr_eq(directive) + }) { + self.remove_directive_name_references(&mut schema.referencers, &directive.name); } - Ok(()) + obj.make_mut() + .directives + .retain(|other_directive| !other_directive.ptr_eq(directive)); } } @@ -1729,6 +2572,21 @@ impl Debug for ObjectTypeDefinitionPosition { } } +#[derive(Clone, PartialEq, Eq, Hash, derive_more::From, derive_more::Display)] +pub(crate) enum FieldArgumentDefinitionPosition { + Interface(InterfaceFieldArgumentDefinitionPosition), + Object(ObjectFieldArgumentDefinitionPosition), +} + +impl Debug for FieldArgumentDefinitionPosition { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Interface(p) => write!(f, "Interface({p})"), + Self::Object(p) => write!(f, "Object({p})"), + } + } +} + #[derive(Clone, PartialEq, Eq, Hash, Serialize)] pub(crate) struct ObjectFieldDefinitionPosition { pub(crate) type_name: Name, @@ -1775,7 +2633,7 @@ impl ObjectFieldDefinitionPosition { self.get(schema).ok() } - fn make_mut<'schema>( + pub(crate) fn make_mut<'schema>( &self, schema: &'schema mut Schema, ) -> Result<&'schema mut Component, PositionLookupError> { @@ -1824,10 +2682,7 @@ impl ObjectFieldDefinitionPosition { .into()); } if self.try_get(&schema.schema).is_some() { - return Err(SingleFederationError::Internal { - message: format!("Object field \"{}\" already exists in schema", self), - } - .into()); + bail!(r#"Object field "{self}" already exists in schema"#); } self.parent() .make_mut(&mut schema.schema)? @@ -1897,6 +2752,29 @@ impl ObjectFieldDefinitionPosition { self.insert_directive_name_references(&mut schema.referencers, &name) } + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result>, FederationError> { + Ok(self.get(&schema.schema)?.directives.iter()) + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Node> { + if let Some(field) = self.try_get(&schema.schema) { + field + .directives + .iter() + .filter(|directive| &directive.name == directive_name) + .collect() + } else { + Vec::new() + } + } + /// Remove a directive application from this position by name. pub(crate) fn remove_directive_name(&self, schema: &mut FederationSchema, name: &str) { let Some(field) = self.try_make_mut(&mut schema.schema) else { @@ -1936,10 +2814,7 @@ impl ObjectFieldDefinitionPosition { allow_built_ins: bool, ) -> Result<(), FederationError> { if !allow_built_ins && is_graphql_reserved_name(&self.field_name) { - return Err(SingleFederationError::Internal { - message: format!("Cannot insert reserved object field \"{}\"", self), - } - .into()); + bail!(r#"Cannot insert reserved object field "{self}""#); } validate_node_directives(field.directives.deref())?; for directive_reference in field.directives.iter() { @@ -1961,10 +2836,7 @@ impl ObjectFieldDefinitionPosition { allow_built_ins: bool, ) -> Result<(), FederationError> { if !allow_built_ins && is_graphql_reserved_name(&self.field_name) { - return Err(SingleFederationError::Internal { - message: format!("Cannot remove reserved object field \"{}\"", self), - } - .into()); + bail!(r#"Cannot remove reserved object field "{self}""#); } for directive_reference in field.directives.iter() { self.remove_directive_name_references(referencers, &directive_reference.name); @@ -1985,9 +2857,7 @@ impl ObjectFieldDefinitionPosition { let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Object field \"{}\"'s directive application \"@{}\" does not refer to an existing directive.", - self, - name, + "Object field \"{self}\"'s directive application \"@{name}\" does not refer to an existing directive.", ), } })?; @@ -2031,15 +2901,11 @@ impl ObjectFieldDefinitionPosition { { enum_type_referencers.object_fields.insert(self.clone()); } else { - return Err( - FederationError::internal( - format!( - "Object field \"{}\"'s inner type \"{}\" does not refer to an existing output type.", - self, - output_type_reference.deref(), - ) - ) - ); + return Err(FederationError::internal(format!( + "Object field \"{}\"'s inner type \"{}\" does not refer to an existing output type.", + self, + output_type_reference.deref(), + ))); } Ok(()) } @@ -2072,6 +2938,16 @@ impl ObjectFieldDefinitionPosition { enum_type_referencers.object_fields.shift_remove(self); } } + + fn rename_type( + &self, + schema: &mut FederationSchema, + new_name: Name, + ) -> Result<(), FederationError> { + let field = self.make_mut(&mut schema.schema)?.make_mut(); + rename_type(&mut field.ty, new_name); + Ok(()) + } } impl Display for ObjectFieldDefinitionPosition { @@ -2086,7 +2962,7 @@ impl Debug for ObjectFieldDefinitionPosition { } } -#[derive(Clone, PartialEq, Eq, Hash)] +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub(crate) struct ObjectFieldArgumentDefinitionPosition { pub(crate) type_name: Name, pub(crate) field_name: Name, @@ -2094,6 +2970,27 @@ pub(crate) struct ObjectFieldArgumentDefinitionPosition { } impl ObjectFieldArgumentDefinitionPosition { + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result>, FederationError> { + Ok(self.get(&schema.schema)?.directives.iter()) + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Node> { + if let Some(arg) = self.try_get(&schema.schema) { + arg.directives + .iter() + .filter(|d| &d.name == directive_name) + .collect() + } else { + Vec::new() + } + } pub(crate) fn parent(&self) -> ObjectFieldDefinitionPosition { ObjectFieldDefinitionPosition { type_name: self.type_name.clone(), @@ -2172,6 +3069,30 @@ impl ObjectFieldArgumentDefinitionPosition { Ok(()) } + pub(crate) fn insert_directive( + &self, + schema: &mut FederationSchema, + directive: Node, + ) -> Result<(), FederationError> { + let argument = self.make_mut(&mut schema.schema)?; + if argument + .directives + .iter() + .any(|other_directive| other_directive.ptr_eq(&directive)) + { + return Err(SingleFederationError::Internal { + message: format!( + "Directive application \"@{}\" already exists on object field argument \"{}\"", + directive.name, self, + ), + } + .into()); + } + let name = directive.name.clone(); + argument.make_mut().directives.push(directive); + self.insert_directive_name_references(&mut schema.referencers, &name) + } + /// Remove a directive application from this position by name. pub(crate) fn remove_directive_name(&self, schema: &mut FederationSchema, name: &str) { let Some(argument) = self.try_make_mut(&mut schema.schema) else { @@ -2190,10 +3111,7 @@ impl ObjectFieldArgumentDefinitionPosition { referencers: &mut Referencers, ) -> Result<(), FederationError> { if is_graphql_reserved_name(&self.argument_name) { - return Err(SingleFederationError::Internal { - message: format!("Cannot insert reserved object field argument \"{}\"", self), - } - .into()); + bail!(r#"Cannot insert reserved object field argument "{self}""#); } validate_node_directives(argument.directives.deref())?; for directive_reference in argument.directives.iter() { @@ -2208,10 +3126,7 @@ impl ObjectFieldArgumentDefinitionPosition { referencers: &mut Referencers, ) -> Result<(), FederationError> { if is_graphql_reserved_name(&self.argument_name) { - return Err(SingleFederationError::Internal { - message: format!("Cannot remove reserved object field argument \"{}\"", self), - } - .into()); + bail!(r#"Cannot remove reserved object field argument "{self}""#); } for directive_reference in argument.directives.iter() { self.remove_directive_name_references(referencers, &directive_reference.name); @@ -2228,9 +3143,7 @@ impl ObjectFieldArgumentDefinitionPosition { let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Object field argument \"{}\"'s directive application \"@{}\" does not refer to an existing directive.", - self, - name, + "Object field argument \"{self}\"'s directive application \"@{name}\" does not refer to an existing directive.", ), } })?; @@ -2309,6 +3222,16 @@ impl ObjectFieldArgumentDefinitionPosition { .shift_remove(self); } } + + fn rename_type( + &self, + schema: &mut FederationSchema, + new_name: Name, + ) -> Result<(), FederationError> { + let field = self.make_mut(&mut schema.schema)?.make_mut(); + rename_type(field.ty.make_mut(), new_name); + Ok(()) + } } impl Display for ObjectFieldArgumentDefinitionPosition { @@ -2327,6 +3250,58 @@ impl Debug for ObjectFieldArgumentDefinitionPosition { } } +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct ObjectOrInterfaceFieldDirectivePosition { + pub(crate) field: ObjectOrInterfaceFieldDefinitionPosition, + pub(crate) directive_name: Name, + pub(crate) directive_index: usize, +} + +impl ObjectOrInterfaceFieldDirectivePosition { + // NOTE: this is used only for connectors "expansion" code and can be + // deleted after connectors switches to use the composition port + pub(crate) fn add_argument( + &self, + schema: &mut FederationSchema, + argument: Node, + ) -> Result<(), FederationError> { + let directive = match self.field { + ObjectOrInterfaceFieldDefinitionPosition::Object(ref field) => { + let field = field.make_mut(&mut schema.schema)?; + + field + .make_mut() + .directives + .get_mut(self.directive_index) + .ok_or_else(|| SingleFederationError::Internal { + message: format!( + "Object field \"{}\"'s directive application at index {} does not exist", + self.field, self.directive_index, + ), + })? + } + ObjectOrInterfaceFieldDefinitionPosition::Interface(ref field) => { + let field = field.make_mut(&mut schema.schema)?; + + field + .make_mut() + .directives + .get_mut(self.directive_index) + .ok_or_else(|| SingleFederationError::Internal { + message: format!( + "Interface field \"{}\"'s directive application at index {} does not exist", + self.field, self.directive_index, + ), + })? + } + }; + + directive.make_mut().arguments.push(argument); + + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] pub(crate) struct InterfaceTypeDefinitionPosition { pub(crate) type_name: Name, @@ -2335,6 +3310,10 @@ pub(crate) struct InterfaceTypeDefinitionPosition { impl InterfaceTypeDefinitionPosition { const EXPECTED: &'static str = "an interface type"; + pub(crate) fn new(type_name: Name) -> Self { + Self { type_name } + } + pub(crate) fn field(&self, field_name: Name) -> InterfaceFieldDefinitionPosition { InterfaceFieldDefinitionPosition { type_name: self.type_name.clone(), @@ -2346,16 +3325,10 @@ impl InterfaceTypeDefinitionPosition { self.field(INTROSPECTION_TYPENAME_FIELD_NAME.clone()) } - // TODO: Once the new lifetime capturing rules for return position impl trait (RPIT) land in - // Rust edition 2024, we will no longer need the "captures" trick here, as noted in - // https://rust-lang.github.io/rfcs/3498-lifetime-capture-rules-2024.html pub(crate) fn fields<'a>( &'a self, schema: &'a Schema, - ) -> Result< - impl Iterator + Captures<&'a ()>, - FederationError, - > { + ) -> Result, FederationError> { Ok(self .get(schema)? .fields @@ -2423,14 +3396,7 @@ impl InterfaceTypeDefinitionPosition { pub(crate) fn pre_insert(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { if schema.referencers.contains_type_name(&self.type_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.type_name) { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" has already been pre-inserted", self), - } - .into()); + bail!(r#"Type "{self}" has already been pre-inserted"#); } schema .referencers @@ -2458,20 +3424,10 @@ impl InterfaceTypeDefinitionPosition { .interface_types .contains_key(&self.type_name) { - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" has not been pre-inserted", self), - } - .into()); + bail!(r#"Type "{self}" has not been pre-inserted"#); } if schema.schema.types.contains_key(&self.type_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.type_name) { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" already exists in schema", self), - } - .into()); + bail!(r#"Type "{self}" already exists in schema"#); } schema .schema @@ -2484,6 +3440,22 @@ impl InterfaceTypeDefinitionPosition { ) } + pub(crate) fn insert_empty( + &self, + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + self.insert( + schema, + Node::new(InterfaceType { + description: None, + name: self.type_name.clone(), + implements_interfaces: Default::default(), + fields: Default::default(), + directives: Default::default(), + }), + ) + } + /// Remove this interface from the schema, and any direct references to the interface. /// /// This can make the schema invalid if this interface is referenced by a field that is the only @@ -2548,7 +3520,7 @@ impl InterfaceTypeDefinitionPosition { .interface_types .shift_remove(&self.type_name) .ok_or_else(|| SingleFederationError::Internal { - message: format!("Schema missing referencers for type \"{}\"", self), + message: format!("Schema missing referencers for type \"{self}\""), })?, )) } @@ -2675,9 +3647,7 @@ impl InterfaceTypeDefinitionPosition { let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Interface type \"{}\"'s directive application \"@{}\" does not refer to an existing directive.", - self, - name, + "Interface type \"{self}\"'s directive application \"@{name}\" does not refer to an existing directive.", ), } })?; @@ -2700,9 +3670,7 @@ impl InterfaceTypeDefinitionPosition { let interface_type_referencers = referencers.interface_types.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Interface type \"{}\"'s implements \"{}\" does not refer to an existing interface.", - self, - name, + "Interface type \"{self}\"'s implements \"{name}\" does not refer to an existing interface.", ), } })?; @@ -2720,6 +3688,122 @@ impl InterfaceTypeDefinitionPosition { .interface_types .shift_remove(self); } + + fn rename(&self, schema: &mut FederationSchema, new_name: Name) -> Result<(), FederationError> { + self.make_mut(&mut schema.schema)?.make_mut().name = new_name.clone(); + + if let Some(interface_type_referencers) = schema + .referencers + .interface_types + .swap_remove(&self.type_name) + { + for pos in interface_type_referencers.object_types.iter() { + pos.rename_implemented_interface(schema, &self.type_name, new_name.clone())?; + } + for pos in interface_type_referencers.object_fields.iter() { + pos.rename_type(schema, new_name.clone())?; + } + for pos in interface_type_referencers.interface_types.iter() { + pos.rename_implemented_interface(schema, &self.type_name, new_name.clone())?; + } + for pos in interface_type_referencers.interface_fields.iter() { + pos.rename_type(schema, new_name.clone())?; + } + schema + .referencers + .interface_types + .insert(new_name, interface_type_referencers); + } + + Ok(()) + } + + fn rename_implemented_interface( + &self, + schema: &mut FederationSchema, + old_name: &Name, + new_name: Name, + ) -> Result<(), FederationError> { + let type_ = self.make_mut(&mut schema.schema)?.make_mut(); + type_.implements_interfaces.swap_remove(old_name); + type_.implements_interfaces.insert(new_name.into()); + Ok(()) + } + + fn remove_extensions(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { + let type_ = self.make_mut(&mut schema.schema)?.make_mut(); + for directive in type_.directives.iter_mut() { + directive.origin = ComponentOrigin::Definition; + } + type_.implements_interfaces = type_ + .implements_interfaces + .iter() + .map(|i| { + let mut i = i.clone(); + i.origin = ComponentOrigin::Definition; + i + }) + .collect(); + for (_, field) in type_.fields.iter_mut() { + field.origin = ComponentOrigin::Definition; + } + Ok(()) + } + + pub(crate) fn has_applied_directive( + &self, + schema: &FederationSchema, + directive_name: &Name, + ) -> bool { + if let Some(type_) = self.try_get(schema.schema()) { + return type_ + .directives + .iter() + .any(|directive| &directive.name == directive_name); + } + false + } + + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result>, FederationError> { + Ok(self.get(&schema.schema)?.directives.iter()) + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Component> { + if let Some(field) = self.try_get(&schema.schema) { + field + .directives + .iter() + .filter(|directive| &directive.name == directive_name) + .collect() + } else { + Vec::new() + } + } + + pub(crate) fn remove_directive( + &self, + schema: &mut FederationSchema, + directive: &Component, + ) { + let Some(obj) = self.try_make_mut(&mut schema.schema) else { + return; + }; + if !obj.directives.iter().any(|other_directive| { + (other_directive.name == directive.name) && !other_directive.ptr_eq(directive) + }) { + self.remove_directive_name_references(&mut schema.referencers, &directive.name); + } + obj.make_mut() + .directives + .retain(|other_directive| !other_directive.ptr_eq(directive)); + } } impl Display for InterfaceTypeDefinitionPosition { @@ -2823,10 +3907,7 @@ impl InterfaceFieldDefinitionPosition { .into()); } if self.try_get(&schema.schema).is_some() { - return Err(SingleFederationError::Internal { - message: format!("Interface field \"{}\" already exists in schema", self), - } - .into()); + bail!(r#"Interface field "{self}" already exists in schema"#); } self.parent() .make_mut(&mut schema.schema)? @@ -2896,6 +3977,29 @@ impl InterfaceFieldDefinitionPosition { self.insert_directive_name_references(&mut schema.referencers, &name) } + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result>, FederationError> { + Ok(self.get(&schema.schema)?.directives.iter()) + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Node> { + if let Some(field) = self.try_get(&schema.schema) { + field + .directives + .iter() + .filter(|directive| &directive.name == directive_name) + .collect() + } else { + Vec::new() + } + } + /// Remove a directive application from this position by name. pub(crate) fn remove_directive_name(&self, schema: &mut FederationSchema, name: &str) { let Some(field) = self.try_make_mut(&mut schema.schema) else { @@ -2935,10 +4039,7 @@ impl InterfaceFieldDefinitionPosition { allow_built_ins: bool, ) -> Result<(), FederationError> { if !allow_built_ins && is_graphql_reserved_name(&self.field_name) { - return Err(SingleFederationError::Internal { - message: format!("Cannot insert reserved interface field \"{}\"", self), - } - .into()); + bail!(r#"Cannot insert reserved interface field "{self}""#); } validate_node_directives(field.directives.deref())?; for directive_reference in field.directives.iter() { @@ -2960,10 +4061,7 @@ impl InterfaceFieldDefinitionPosition { allow_built_ins: bool, ) -> Result<(), FederationError> { if !allow_built_ins && is_graphql_reserved_name(&self.field_name) { - return Err(SingleFederationError::Internal { - message: format!("Cannot remove reserved interface field \"{}\"", self), - } - .into()); + bail!(r#"Cannot remove reserved interface field "{self}""#); } for directive_reference in field.directives.iter() { self.remove_directive_name_references(referencers, &directive_reference.name); @@ -2984,9 +4082,7 @@ impl InterfaceFieldDefinitionPosition { let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Interface field \"{}\"'s directive application \"@{}\" does not refer to an existing directive.", - self, - name, + "Interface field \"{self}\"'s directive application \"@{name}\" does not refer to an existing directive.", ), } })?; @@ -3034,13 +4130,11 @@ impl InterfaceFieldDefinitionPosition { { enum_type_referencers.interface_fields.insert(self.clone()); } else { - return Err(FederationError::internal( - format!( - "Interface field \"{}\"'s inner type \"{}\" does not refer to an existing output type.", - self, - output_type_reference.deref(), - ) - )); + return Err(FederationError::internal(format!( + "Interface field \"{}\"'s inner type \"{}\" does not refer to an existing output type.", + self, + output_type_reference.deref(), + ))); } Ok(()) } @@ -3075,6 +4169,21 @@ impl InterfaceFieldDefinitionPosition { enum_type_referencers.interface_fields.shift_remove(self); } } + + fn rename_type( + &self, + schema: &mut FederationSchema, + new_name: Name, + ) -> Result<(), FederationError> { + let field = self.make_mut(&mut schema.schema)?.make_mut(); + match field.ty.clone() { + ast::Type::Named(_) => field.ty = ast::Type::Named(new_name), + ast::Type::NonNullNamed(_) => field.ty = ast::Type::NonNullNamed(new_name), + ast::Type::List(_) => todo!(), + ast::Type::NonNullList(_) => todo!(), + } + Ok(()) + } } impl Display for InterfaceFieldDefinitionPosition { @@ -3091,6 +4200,27 @@ pub(crate) struct InterfaceFieldArgumentDefinitionPosition { } impl InterfaceFieldArgumentDefinitionPosition { + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result>, FederationError> { + Ok(self.get(&schema.schema)?.directives.iter()) + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Node> { + if let Some(arg) = self.try_get(&schema.schema) { + arg.directives + .iter() + .filter(|d| &d.name == directive_name) + .collect() + } else { + Vec::new() + } + } pub(crate) fn parent(&self) -> InterfaceFieldDefinitionPosition { InterfaceFieldDefinitionPosition { type_name: self.type_name.clone(), @@ -3170,6 +4300,32 @@ impl InterfaceFieldArgumentDefinitionPosition { Ok(()) } + pub(crate) fn insert_directive( + &self, + schema: &mut FederationSchema, + directive: Node, + ) -> Result<(), FederationError> { + let argument = self.make_mut(&mut schema.schema)?; + if argument + .directives + .iter() + .any(|other_directive| other_directive.ptr_eq(&directive)) + { + return Err( + SingleFederationError::Internal { + message: format!( + "Directive application \"@{}\" already exists on interface field argument \"{}\"", + directive.name, + self, + ) + }.into() + ); + } + let name = directive.name.clone(); + argument.make_mut().directives.push(directive); + self.insert_directive_name_references(&mut schema.referencers, &name) + } + /// Remove a directive application from this position by name. pub(crate) fn remove_directive_name(&self, schema: &mut FederationSchema, name: &str) { let Some(argument) = self.try_make_mut(&mut schema.schema) else { @@ -3188,13 +4344,7 @@ impl InterfaceFieldArgumentDefinitionPosition { referencers: &mut Referencers, ) -> Result<(), FederationError> { if is_graphql_reserved_name(&self.argument_name) { - return Err(SingleFederationError::Internal { - message: format!( - "Cannot insert reserved interface field argument \"{}\"", - self - ), - } - .into()); + bail!(r#"Cannot insert reserved interface field argument "{self}""#); } validate_node_directives(argument.directives.deref())?; for directive_reference in argument.directives.iter() { @@ -3209,13 +4359,7 @@ impl InterfaceFieldArgumentDefinitionPosition { referencers: &mut Referencers, ) -> Result<(), FederationError> { if is_graphql_reserved_name(&self.argument_name) { - return Err(SingleFederationError::Internal { - message: format!( - "Cannot remove reserved interface field argument \"{}\"", - self - ), - } - .into()); + bail!(r#"Cannot remove reserved interface field argument "{self}""#); } for directive_reference in argument.directives.iter() { self.remove_directive_name_references(referencers, &directive_reference.name); @@ -3232,9 +4376,7 @@ impl InterfaceFieldArgumentDefinitionPosition { let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Interface field argument \"{}\"'s directive application \"@{}\" does not refer to an existing directive.", - self, - name, + "Interface field argument \"{self}\"'s directive application \"@{name}\" does not refer to an existing directive.", ), } })?; @@ -3278,13 +4420,11 @@ impl InterfaceFieldArgumentDefinitionPosition { .interface_field_arguments .insert(self.clone()); } else { - return Err(FederationError::internal( - format!( - "Interface field argument \"{}\"'s inner type \"{}\" does not refer to an existing input type.", - self, - input_type_reference.deref(), - ) - )); + return Err(FederationError::internal(format!( + "Interface field argument \"{}\"'s inner type \"{}\" does not refer to an existing input type.", + self, + input_type_reference.deref(), + ))); } Ok(()) } @@ -3315,6 +4455,23 @@ impl InterfaceFieldArgumentDefinitionPosition { .shift_remove(self); } } + + fn rename_type( + &self, + schema: &mut FederationSchema, + new_name: Name, + ) -> Result<(), FederationError> { + let argument = self.make_mut(&mut schema.schema)?.make_mut(); + match argument.ty.as_ref() { + ast::Type::Named(_) => *argument.ty.make_mut() = ast::Type::Named(new_name), + ast::Type::NonNullNamed(_) => { + *argument.ty.make_mut() = ast::Type::NonNullNamed(new_name) + } + ast::Type::List(_) => todo!(), + ast::Type::NonNullList(_) => todo!(), + } + Ok(()) + } } impl Display for InterfaceFieldArgumentDefinitionPosition { @@ -3401,14 +4558,7 @@ impl UnionTypeDefinitionPosition { pub(crate) fn pre_insert(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { if schema.referencers.contains_type_name(&self.type_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.type_name) { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" has already been pre-inserted", self), - } - .into()); + bail!(r#"Type "{self}" has already been pre-inserted"#); } schema .referencers @@ -3432,20 +4582,10 @@ impl UnionTypeDefinitionPosition { .into()); } if !schema.referencers.union_types.contains_key(&self.type_name) { - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" has not been pre-inserted", self), - } - .into()); + bail!(r#"Type "{self}" has not been pre-inserted"#); } if schema.schema.types.contains_key(&self.type_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.type_name) { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" already exists in schema", self), - } - .into()); + bail!(r#"Type "{self}" already exists in schema"#); } schema .schema @@ -3506,7 +4646,7 @@ impl UnionTypeDefinitionPosition { .union_types .shift_remove(&self.type_name) .ok_or_else(|| SingleFederationError::Internal { - message: format!("Schema missing referencers for type \"{}\"", self), + message: format!("Schema missing referencers for type \"{self}\""), })?, )) } @@ -3618,9 +4758,7 @@ impl UnionTypeDefinitionPosition { let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Union type \"{}\"'s directive application \"@{}\" does not refer to an existing directive.", - self, - name, + "Union type \"{self}\"'s directive application \"@{name}\" does not refer to an existing directive.", ), } })?; @@ -3643,8 +4781,7 @@ impl UnionTypeDefinitionPosition { let object_type_referencers = referencers.object_types.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Union type \"{}\"'s member \"{}\" does not refer to an existing object.", - self, name, + "Union type \"{self}\"'s member \"{name}\" does not refer to an existing object.", ), } })?; @@ -3658,6 +4795,106 @@ impl UnionTypeDefinitionPosition { }; object_type_referencers.union_types.shift_remove(self); } + + fn rename(&self, schema: &mut FederationSchema, new_name: Name) -> Result<(), FederationError> { + self.make_mut(&mut schema.schema)?.make_mut().name = new_name.clone(); + + if let Some(union_type_referencers) = + schema.referencers.union_types.swap_remove(&self.type_name) + { + schema + .referencers + .union_types + .insert(new_name, union_type_referencers); + } + + Ok(()) + } + + fn rename_member( + &self, + schema: &mut FederationSchema, + old_name: &Name, + new_name: Name, + ) -> Result<(), FederationError> { + let type_ = self.make_mut(&mut schema.schema)?.make_mut(); + type_.members.swap_remove(old_name); + type_.members.insert(new_name.into()); + + Ok(()) + } + + fn remove_extensions(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { + let type_ = self.make_mut(&mut schema.schema)?.make_mut(); + for directive in type_.directives.iter_mut() { + directive.origin = ComponentOrigin::Definition; + } + type_.members = type_ + .members + .iter() + .map(|m| { + let mut m = m.clone(); + m.origin = ComponentOrigin::Definition; + m + }) + .collect(); + Ok(()) + } + + pub(crate) fn has_applied_directive( + &self, + schema: &FederationSchema, + directive_name: &Name, + ) -> bool { + if let Some(type_) = self.try_get(schema.schema()) { + return type_ + .directives + .iter() + .any(|directive| &directive.name == directive_name); + } + false + } + + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result>, FederationError> { + Ok(self.get(&schema.schema)?.directives.iter()) + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Component> { + if let Some(field) = self.try_get(&schema.schema) { + field + .directives + .iter() + .filter(|directive| &directive.name == directive_name) + .collect() + } else { + Vec::new() + } + } + + pub(crate) fn remove_directive( + &self, + schema: &mut FederationSchema, + directive: &Component, + ) { + let Some(obj) = self.try_make_mut(&mut schema.schema) else { + return; + }; + if !obj.directives.iter().any(|other_directive| { + (other_directive.name == directive.name) && !other_directive.ptr_eq(directive) + }) { + self.remove_directive_name_references(&mut schema.referencers, &directive.name); + } + obj.make_mut() + .directives + .retain(|other_directive| !other_directive.ptr_eq(directive)); + } } impl Display for UnionTypeDefinitionPosition { @@ -3717,8 +4954,7 @@ impl UnionTypenameFieldDefinitionPosition { scalar_type_referencers.union_fields.insert(self.clone()); } else { return Err(FederationError::internal(format!( - "Schema missing referencers for type \"{}\"", - output_type_reference + "Schema missing referencers for type \"{output_type_reference}\"" ))); } Ok(()) @@ -3815,14 +5051,7 @@ impl EnumTypeDefinitionPosition { pub(crate) fn pre_insert(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { if schema.referencers.contains_type_name(&self.type_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.type_name) { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" has already been pre-inserted", self), - } - .into()); + bail!(r#"Type "{self}" has already been pre-inserted"#); } schema .referencers @@ -3843,20 +5072,10 @@ impl EnumTypeDefinitionPosition { .into()); } if !schema.referencers.enum_types.contains_key(&self.type_name) { - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" has not been pre-inserted", self), - } - .into()); + bail!(r#"Type "{self}" has not been pre-inserted"#); } if schema.schema.types.contains_key(&self.type_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.type_name) { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" already exists in schema", self), - } - .into()); + bail!(r#"Type "{self}" already exists in schema"#); } schema .schema @@ -3912,7 +5131,7 @@ impl EnumTypeDefinitionPosition { .enum_types .shift_remove(&self.type_name) .ok_or_else(|| SingleFederationError::Internal { - message: format!("Schema missing referencers for type \"{}\"", self), + message: format!("Schema missing referencers for type \"{self}\""), })?, )) } @@ -3992,9 +5211,7 @@ impl EnumTypeDefinitionPosition { let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Enum type \"{}\"'s directive application \"@{}\" does not refer to an existing directive.", - self, - name, + "Enum type \"{self}\"'s directive application \"@{name}\" does not refer to an existing directive.", ), } })?; @@ -4008,6 +5225,102 @@ impl EnumTypeDefinitionPosition { }; directive_referencers.enum_types.shift_remove(self); } + + fn rename(&self, schema: &mut FederationSchema, new_name: Name) -> Result<(), FederationError> { + self.make_mut(&mut schema.schema)?.make_mut().name = new_name.clone(); + + if let Some(enum_type_referencers) = + schema.referencers.enum_types.swap_remove(&self.type_name) + { + for pos in enum_type_referencers.object_fields.iter() { + pos.rename_type(schema, new_name.clone())?; + } + for pos in enum_type_referencers.object_field_arguments.iter() { + pos.rename_type(schema, new_name.clone())?; + } + for pos in enum_type_referencers.interface_fields.iter() { + pos.rename_type(schema, new_name.clone())?; + } + for pos in enum_type_referencers.interface_field_arguments.iter() { + pos.rename_type(schema, new_name.clone())?; + } + for pos in enum_type_referencers.input_object_fields.iter() { + pos.rename_type(schema, new_name.clone())?; + } + schema + .referencers + .enum_types + .insert(new_name, enum_type_referencers); + } + + Ok(()) + } + + fn remove_extensions(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { + let type_ = self.make_mut(&mut schema.schema)?.make_mut(); + for directive in type_.directives.iter_mut() { + directive.origin = ComponentOrigin::Definition; + } + for (_, v) in type_.values.iter_mut() { + v.origin = ComponentOrigin::Definition; + } + Ok(()) + } + + pub(crate) fn has_applied_directive( + &self, + schema: &FederationSchema, + directive_name: &Name, + ) -> bool { + if let Some(type_) = self.try_get(schema.schema()) { + return type_ + .directives + .iter() + .any(|directive| &directive.name == directive_name); + } + false + } + + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result>, FederationError> { + Ok(self.get(&schema.schema)?.directives.iter()) + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Component> { + if let Some(field) = self.try_get(&schema.schema) { + field + .directives + .iter() + .filter(|directive| &directive.name == directive_name) + .collect() + } else { + Vec::new() + } + } + + pub(crate) fn remove_directive( + &self, + schema: &mut FederationSchema, + directive: &Component, + ) { + let Some(obj) = self.try_make_mut(&mut schema.schema) else { + return; + }; + if !obj.directives.iter().any(|other_directive| { + (other_directive.name == directive.name) && !other_directive.ptr_eq(directive) + }) { + self.remove_directive_name_references(&mut schema.referencers, &directive.name); + } + obj.make_mut() + .directives + .retain(|other_directive| !other_directive.ptr_eq(directive)); + } } impl Display for EnumTypeDefinitionPosition { @@ -4023,6 +5336,27 @@ pub(crate) struct EnumValueDefinitionPosition { } impl EnumValueDefinitionPosition { + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result>, FederationError> { + Ok(self.get(&schema.schema)?.directives.iter()) + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Node> { + if let Some(val) = self.try_get(&schema.schema) { + val.directives + .iter() + .filter(|d| &d.name == directive_name) + .collect() + } else { + Vec::new() + } + } pub(crate) fn parent(&self) -> EnumTypeDefinitionPosition { EnumTypeDefinitionPosition { type_name: self.type_name.clone(), @@ -4088,10 +5422,7 @@ impl EnumValueDefinitionPosition { .into()); } if self.try_get(&schema.schema).is_some() { - return Err(SingleFederationError::Internal { - message: format!("Enum value \"{}\" already exists in schema", self,), - } - .into()); + bail!(r#"Enum value "{self}" already exists in schema"#); } self.parent() .make_mut(&mut schema.schema)? @@ -4118,6 +5449,30 @@ impl EnumValueDefinitionPosition { Ok(()) } + pub(crate) fn insert_directive( + &self, + schema: &mut FederationSchema, + directive: Node, + ) -> Result<(), FederationError> { + let value = self.make_mut(&mut schema.schema)?; + if value + .directives + .iter() + .any(|other_directive| other_directive.ptr_eq(&directive)) + { + return Err(SingleFederationError::Internal { + message: format!( + "Directive application \"@{}\" already exists on enum value \"{}\"", + directive.name, self, + ), + } + .into()); + } + let name = directive.name.clone(); + value.make_mut().directives.push(directive); + self.insert_directive_name_references(&mut schema.referencers, &name) + } + /// Remove a directive application from this position by name. pub(crate) fn remove_directive_name(&self, schema: &mut FederationSchema, name: &str) { let Some(value) = self.try_make_mut(&mut schema.schema) else { @@ -4136,9 +5491,7 @@ impl EnumValueDefinitionPosition { referencers: &mut Referencers, ) -> Result<(), FederationError> { if is_graphql_reserved_name(&self.value_name) { - return Err(FederationError::internal(format!( - "Cannot insert reserved enum value \"{self}\"" - ))); + bail!(r#"Cannot insert reserved enum value "{self}""#); } validate_node_directives(value.directives.deref())?; for directive_reference in value.directives.iter() { @@ -4153,10 +5506,7 @@ impl EnumValueDefinitionPosition { referencers: &mut Referencers, ) -> Result<(), FederationError> { if is_graphql_reserved_name(&self.value_name) { - return Err(SingleFederationError::Internal { - message: format!("Cannot remove reserved enum value \"{}\"", self), - } - .into()); + bail!(r#"Cannot remove reserved enum value "{self}""#); } for directive_reference in value.directives.iter() { self.remove_directive_name_references(referencers, &directive_reference.name); @@ -4172,9 +5522,7 @@ impl EnumValueDefinitionPosition { let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Enum value \"{}\"'s directive application \"@{}\" does not refer to an existing directive.", - self, - name, + "Enum value \"{self}\"'s directive application \"@{name}\" does not refer to an existing directive.", ), } })?; @@ -4271,14 +5619,7 @@ impl InputObjectTypeDefinitionPosition { pub(crate) fn pre_insert(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { if schema.referencers.contains_type_name(&self.type_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.type_name) { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" has already been pre-inserted", self), - } - .into()); + bail!(r#"Type "{self}" has already been pre-inserted"#); } schema .referencers @@ -4306,20 +5647,10 @@ impl InputObjectTypeDefinitionPosition { .input_object_types .contains_key(&self.type_name) { - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" has not been pre-inserted", self), - } - .into()); + bail!(r#"Type "{self}" has not been pre-inserted"#); } if schema.schema.types.contains_key(&self.type_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.type_name) { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Type \"{}\" already exists in schema", self), - } - .into()); + bail!(r#"Type "{self}" already exists in schema"#); } schema .schema @@ -4393,7 +5724,7 @@ impl InputObjectTypeDefinitionPosition { .input_object_types .shift_remove(&self.type_name) .ok_or_else(|| SingleFederationError::Internal { - message: format!("Schema missing referencers for type \"{}\"", self), + message: format!("Schema missing referencers for type \"{self}\""), })?, )) } @@ -4434,7 +5765,6 @@ impl InputObjectTypeDefinitionPosition { .retain(|other_directive| other_directive.name != name); } - /// Remove a directive application. fn insert_references( &self, type_: &Node, @@ -4463,34 +5793,115 @@ impl InputObjectTypeDefinitionPosition { self.field(field_name.clone()) .remove_references(field, referencers)?; } - Ok(()) + Ok(()) + } + + fn insert_directive_name_references( + &self, + referencers: &mut Referencers, + name: &Name, + ) -> Result<(), FederationError> { + let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { + SingleFederationError::Internal { + message: format!( + "Input object type \"{self}\"'s directive application \"@{name}\" does not refer to an existing directive.", + ), + } + })?; + directive_referencers + .input_object_types + .insert(self.clone()); + Ok(()) + } + + fn remove_directive_name_references(&self, referencers: &mut Referencers, name: &str) { + let Some(directive_referencers) = referencers.directives.get_mut(name) else { + return; + }; + directive_referencers.input_object_types.shift_remove(self); + } + + fn rename(&self, schema: &mut FederationSchema, new_name: Name) -> Result<(), FederationError> { + self.make_mut(&mut schema.schema)?.make_mut().name = new_name.clone(); + + if let Some(input_object_type_referencers) = schema + .referencers + .input_object_types + .swap_remove(&self.type_name) + { + schema + .referencers + .input_object_types + .insert(new_name, input_object_type_referencers); + } + + Ok(()) + } + + fn remove_extensions(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { + let type_ = self.make_mut(&mut schema.schema)?.make_mut(); + for directive in type_.directives.iter_mut() { + directive.origin = ComponentOrigin::Definition; + } + for (_, field) in type_.fields.iter_mut() { + field.origin = ComponentOrigin::Definition; + } + Ok(()) + } + + pub(crate) fn has_applied_directive( + &self, + schema: &FederationSchema, + directive_name: &Name, + ) -> bool { + if let Some(type_) = self.try_get(schema.schema()) { + return type_ + .directives + .iter() + .any(|directive| &directive.name == directive_name); + } + false } - fn insert_directive_name_references( + pub(crate) fn get_all_applied_directives<'schema>( &self, - referencers: &mut Referencers, - name: &Name, - ) -> Result<(), FederationError> { - let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { - SingleFederationError::Internal { - message: format!( - "Input object type \"{}\"'s directive application \"@{}\" does not refer to an existing directive.", - self, - name, - ), - } - })?; - directive_referencers - .input_object_types - .insert(self.clone()); - Ok(()) + schema: &'schema FederationSchema, + ) -> Result>, FederationError> { + Ok(self.get(&schema.schema)?.directives.iter()) } - fn remove_directive_name_references(&self, referencers: &mut Referencers, name: &str) { - let Some(directive_referencers) = referencers.directives.get_mut(name) else { + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Component> { + if let Some(field) = self.try_get(&schema.schema) { + field + .directives + .iter() + .filter(|directive| &directive.name == directive_name) + .collect() + } else { + Vec::new() + } + } + + pub(crate) fn remove_directive( + &self, + schema: &mut FederationSchema, + directive: &Component, + ) { + let Some(obj) = self.try_make_mut(&mut schema.schema) else { return; }; - directive_referencers.input_object_types.shift_remove(self); + if !obj.directives.iter().any(|other_directive| { + (other_directive.name == directive.name) && !other_directive.ptr_eq(directive) + }) { + self.remove_directive_name_references(&mut schema.referencers, &directive.name); + } + obj.make_mut() + .directives + .retain(|other_directive| !other_directive.ptr_eq(directive)); } } @@ -4507,6 +5918,29 @@ pub(crate) struct InputObjectFieldDefinitionPosition { } impl InputObjectFieldDefinitionPosition { + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result>, FederationError> { + Ok(self.get(&schema.schema)?.directives.iter()) + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Node> { + if let Some(field) = self.try_get(&schema.schema) { + field + .directives + .iter() + .filter(|d| &d.name == directive_name) + .collect() + } else { + Vec::new() + } + } + pub(crate) fn parent(&self) -> InputObjectTypeDefinitionPosition { InputObjectTypeDefinitionPosition { type_name: self.type_name.clone(), @@ -4578,10 +6012,7 @@ impl InputObjectFieldDefinitionPosition { .into()); } if self.try_get(&schema.schema).is_some() { - return Err(SingleFederationError::Internal { - message: format!("Input object field \"{}\" already exists in schema", self), - } - .into()); + bail!(r#"Input object field "{self}" already exists in schema"#); } self.parent() .make_mut(&mut schema.schema)? @@ -4625,6 +6056,30 @@ impl InputObjectFieldDefinitionPosition { Ok(()) } + pub(crate) fn insert_directive( + &self, + schema: &mut FederationSchema, + directive: Node, + ) -> Result<(), FederationError> { + let field = self.make_mut(&mut schema.schema)?; + if field + .directives + .iter() + .any(|other_directive| other_directive.ptr_eq(&directive)) + { + return Err(SingleFederationError::Internal { + message: format!( + "Directive application \"@{}\" already exists on input object field \"{}\"", + directive.name, self, + ), + } + .into()); + } + let name = directive.name.clone(); + field.make_mut().directives.push(directive); + self.insert_directive_name_references(&mut schema.referencers, &name) + } + /// Remove a directive application from this position by name. pub(crate) fn remove_directive_name(&self, schema: &mut FederationSchema, name: &str) { let Some(field) = self.try_make_mut(&mut schema.schema) else { @@ -4643,10 +6098,7 @@ impl InputObjectFieldDefinitionPosition { referencers: &mut Referencers, ) -> Result<(), FederationError> { if is_graphql_reserved_name(&self.field_name) { - return Err(SingleFederationError::Internal { - message: format!("Cannot insert reserved input object field \"{}\"", self), - } - .into()); + bail!(r#"Cannot insert reserved input object field "{self}""#); } validate_node_directives(field.directives.deref())?; for directive_reference in field.directives.iter() { @@ -4661,10 +6113,7 @@ impl InputObjectFieldDefinitionPosition { referencers: &mut Referencers, ) -> Result<(), FederationError> { if is_graphql_reserved_name(&self.field_name) { - return Err(SingleFederationError::Internal { - message: format!("Cannot remove reserved input object field \"{}\"", self), - } - .into()); + bail!(r#"Cannot remove reserved input object field "{self}""#); } for directive_reference in field.directives.iter() { self.remove_directive_name_references(referencers, &directive_reference.name); @@ -4681,9 +6130,7 @@ impl InputObjectFieldDefinitionPosition { let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Input object field \"{}\"'s directive application \"@{}\" does not refer to an existing directive.", - self, - name, + "Input object field \"{self}\"'s directive application \"@{name}\" does not refer to an existing directive.", ), } })?; @@ -4725,13 +6172,11 @@ impl InputObjectFieldDefinitionPosition { .input_object_fields .insert(self.clone()); } else { - return Err(FederationError::internal( - format!( - "Input object field \"{}\"'s inner type \"{}\" does not refer to an existing input type.", - self, - input_type_reference.deref(), - ) - )); + return Err(FederationError::internal(format!( + "Input object field \"{}\"'s inner type \"{}\" does not refer to an existing input type.", + self, + input_type_reference.deref(), + ))); } Ok(()) } @@ -4760,6 +6205,15 @@ impl InputObjectFieldDefinitionPosition { .shift_remove(self); } } + + fn rename_type( + &self, + schema: &mut FederationSchema, + new_name: Name, + ) -> Result<(), FederationError> { + self.make_mut(&mut schema.schema)?.make_mut().name = new_name; + Ok(()) + } } impl Display for InputObjectFieldDefinitionPosition { @@ -4814,16 +6268,7 @@ impl DirectiveDefinitionPosition { .directives .contains_key(&self.directive_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.directive_name) - || GRAPHQL_BUILTIN_DIRECTIVE_NAMES.contains(&self.directive_name) - { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Directive \"{}\" has already been pre-inserted", self), - } - .into()); + bail!(r#"Directive "{self}" has already been pre-inserted"#); } schema .referencers @@ -4842,26 +6287,14 @@ impl DirectiveDefinitionPosition { .directives .contains_key(&self.directive_name) { - return Err(SingleFederationError::Internal { - message: format!("Directive \"{}\" has not been pre-inserted", self), - } - .into()); + bail!(r#"Directive "{self}" has not been pre-inserted"#); } if schema .schema .directive_definitions .contains_key(&self.directive_name) { - // TODO: Allow built-in shadowing instead of ignoring them - if is_graphql_reserved_name(&self.directive_name) - || GRAPHQL_BUILTIN_DIRECTIVE_NAMES.contains(&self.directive_name) - { - return Ok(()); - } - return Err(SingleFederationError::Internal { - message: format!("Directive \"{}\" already exists in schema", self), - } - .into()); + bail!(r#"Directive "{self}" already exists in schema"#); } schema .schema @@ -4941,7 +6374,7 @@ impl DirectiveDefinitionPosition { .directives .shift_remove(&self.directive_name) .ok_or_else(|| SingleFederationError::Internal { - message: format!("Schema missing referencers for directive \"{}\"", self), + message: format!("Schema missing referencers for directive \"{self}\""), })?, )) } @@ -4984,6 +6417,29 @@ pub(crate) struct DirectiveArgumentDefinitionPosition { } impl DirectiveArgumentDefinitionPosition { + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Result>, FederationError> { + Ok(self.get(&schema.schema)?.directives.iter()) + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Node> { + if let Some(argument) = self.try_get(&schema.schema) { + argument + .directives + .iter() + .filter(|d| &d.name == directive_name) + .collect() + } else { + Vec::new() + } + } + pub(crate) fn parent(&self) -> DirectiveDefinitionPosition { DirectiveDefinitionPosition { directive_name: self.directive_name.clone(), @@ -5051,6 +6507,30 @@ impl DirectiveArgumentDefinitionPosition { Ok(()) } + pub(crate) fn insert_directive( + &self, + schema: &mut FederationSchema, + directive: Node, + ) -> Result<(), FederationError> { + let argument = self.make_mut(&mut schema.schema)?; + if argument + .directives + .iter() + .any(|other_directive| other_directive.ptr_eq(&directive)) + { + return Err(SingleFederationError::Internal { + message: format!( + "Directive application \"@{}\" already exists on directive argument \"{}\"", + directive.name, self, + ), + } + .into()); + } + let name = directive.name.clone(); + argument.make_mut().directives.push(directive); + self.insert_directive_name_references(&mut schema.referencers, &name) + } + /// Remove a directive application from this position by name. pub(crate) fn remove_directive_name(&self, schema: &mut FederationSchema, name: &str) { let Some(argument) = self.try_make_mut(&mut schema.schema) else { @@ -5069,10 +6549,7 @@ impl DirectiveArgumentDefinitionPosition { referencers: &mut Referencers, ) -> Result<(), FederationError> { if is_graphql_reserved_name(&self.argument_name) { - return Err(SingleFederationError::Internal { - message: format!("Cannot insert reserved directive argument \"{}\"", self), - } - .into()); + bail!(r#"Cannot insert reserved directive argument "{self}""#); } validate_node_directives(argument.directives.deref())?; for directive_reference in argument.directives.iter() { @@ -5087,10 +6564,7 @@ impl DirectiveArgumentDefinitionPosition { referencers: &mut Referencers, ) -> Result<(), FederationError> { if is_graphql_reserved_name(&self.argument_name) { - return Err(SingleFederationError::Internal { - message: format!("Cannot remove reserved directive argument \"{}\"", self), - } - .into()); + bail!(r#"Cannot remove reserved directive argument "{self}""#); } for directive_reference in argument.directives.iter() { self.remove_directive_name_references(referencers, &directive_reference.name); @@ -5107,9 +6581,7 @@ impl DirectiveArgumentDefinitionPosition { let directive_referencers = referencers.directives.get_mut(name).ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Directive argument \"{}\"'s directive application \"@{}\" does not refer to an existing directive.", - self, - name, + "Directive argument \"{self}\"'s directive application \"@{name}\" does not refer to an existing directive.", ), } })?; @@ -5151,13 +6623,11 @@ impl DirectiveArgumentDefinitionPosition { .directive_arguments .insert(self.clone()); } else { - return Err(FederationError::internal( - format!( - "Directive argument \"{}\"'s inner type \"{}\" does not refer to an existing input type.", - self, - input_type_reference.deref(), - ) - )); + return Err(FederationError::internal(format!( + "Directive argument \"{}\"'s inner type \"{}\" does not refer to an existing input type.", + self, + input_type_reference.deref(), + ))); } Ok(()) } @@ -5194,33 +6664,226 @@ impl Display for DirectiveArgumentDefinitionPosition { } } +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::Display)] +pub(crate) enum DirectiveTargetPosition { + Schema(SchemaDefinitionPosition), + ScalarType(ScalarTypeDefinitionPosition), + ObjectType(ObjectTypeDefinitionPosition), + ObjectField(ObjectFieldDefinitionPosition), + ObjectFieldArgument(ObjectFieldArgumentDefinitionPosition), + InterfaceType(InterfaceTypeDefinitionPosition), + InterfaceField(InterfaceFieldDefinitionPosition), + InterfaceFieldArgument(InterfaceFieldArgumentDefinitionPosition), + UnionType(UnionTypeDefinitionPosition), + EnumType(EnumTypeDefinitionPosition), + EnumValue(EnumValueDefinitionPosition), + InputObjectType(InputObjectTypeDefinitionPosition), + InputObjectField(InputObjectFieldDefinitionPosition), + DirectiveArgument(DirectiveArgumentDefinitionPosition), +} + +impl DirectiveTargetPosition { + pub(crate) fn get_all_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + ) -> Vec<&'schema Node> { + match self { + Self::Schema(pos) => pos + .get_all_applied_directives(schema) + .map(|component| &component.node) + .collect(), + Self::ScalarType(pos) => pos + .get_all_applied_directives(schema) + .map(|it| it.map(|component| &component.node).collect()) + .unwrap_or_default(), + Self::ObjectType(pos) => pos + .get_all_applied_directives(schema) + .map(|it| it.map(|component| &component.node).collect()) + .unwrap_or_default(), + Self::ObjectField(pos) => pos + .get_all_applied_directives(schema) + .map(|it| it.collect()) + .unwrap_or_default(), + Self::ObjectFieldArgument(pos) => pos + .get_all_applied_directives(schema) + .map(|it| it.collect()) + .unwrap_or_default(), + Self::InterfaceType(pos) => pos + .get_all_applied_directives(schema) + .map(|it| it.map(|component| &component.node).collect()) + .unwrap_or_default(), + Self::InterfaceField(pos) => pos + .get_all_applied_directives(schema) + .map(|it| it.collect()) + .unwrap_or_default(), + Self::InterfaceFieldArgument(pos) => pos + .get_all_applied_directives(schema) + .map(|it| it.collect()) + .unwrap_or_default(), + Self::UnionType(pos) => pos + .get_all_applied_directives(schema) + .map(|it| it.map(|component| &component.node).collect()) + .unwrap_or_default(), + Self::EnumType(pos) => pos + .get_all_applied_directives(schema) + .map(|it| it.map(|component| &component.node).collect()) + .unwrap_or_default(), + Self::EnumValue(pos) => pos + .get_all_applied_directives(schema) + .map(|it| it.collect()) + .unwrap_or_default(), + Self::InputObjectType(pos) => pos + .get_all_applied_directives(schema) + .map(|it| it.map(|component| &component.node).collect()) + .unwrap_or_default(), + Self::InputObjectField(pos) => pos + .get_all_applied_directives(schema) + .map(|it| it.collect()) + .unwrap_or_default(), + Self::DirectiveArgument(pos) => pos + .get_all_applied_directives(schema) + .map(|it| it.collect()) + .unwrap_or_default(), + } + } + + pub(crate) fn get_applied_directives<'schema>( + &self, + schema: &'schema FederationSchema, + directive_name: &Name, + ) -> Vec<&'schema Node> { + match self { + Self::Schema(pos) => pos + .get_applied_directives(schema, directive_name) + .iter() + .map(|d| &d.node) + .collect(), + Self::ScalarType(pos) => pos + .get_applied_directives(schema, directive_name) + .iter() + .map(|d| &d.node) + .collect(), + Self::ObjectType(pos) => pos + .get_applied_directives(schema, directive_name) + .iter() + .map(|d| &d.node) + .collect(), + Self::ObjectField(pos) => pos.get_applied_directives(schema, directive_name), + Self::ObjectFieldArgument(pos) => pos.get_applied_directives(schema, directive_name), + Self::InterfaceType(pos) => pos + .get_applied_directives(schema, directive_name) + .iter() + .map(|d| &d.node) + .collect(), + Self::InterfaceField(pos) => pos.get_applied_directives(schema, directive_name), + Self::InterfaceFieldArgument(pos) => pos.get_applied_directives(schema, directive_name), + Self::UnionType(pos) => pos + .get_applied_directives(schema, directive_name) + .iter() + .map(|d| &d.node) + .collect(), + Self::EnumType(pos) => pos + .get_applied_directives(schema, directive_name) + .iter() + .map(|d| &d.node) + .collect(), + Self::EnumValue(pos) => pos.get_applied_directives(schema, directive_name), + Self::InputObjectType(pos) => pos + .get_applied_directives(schema, directive_name) + .iter() + .map(|d| &d.node) + .collect(), + Self::InputObjectField(pos) => pos.get_applied_directives(schema, directive_name), + Self::DirectiveArgument(pos) => pos.get_applied_directives(schema, directive_name), + } + } + + pub(crate) fn insert_directive( + &self, + schema: &mut FederationSchema, + directive: Directive, + ) -> Result<(), FederationError> { + match self { + Self::Schema(pos) => pos.insert_directive(schema, Component::new(directive)), + Self::ScalarType(pos) => pos.insert_directive(schema, Component::new(directive)), + Self::ObjectType(pos) => pos.insert_directive(schema, Component::new(directive)), + Self::ObjectField(pos) => pos.insert_directive(schema, Node::new(directive)), + Self::ObjectFieldArgument(pos) => pos.insert_directive(schema, Node::new(directive)), + Self::InterfaceType(pos) => pos.insert_directive(schema, Component::new(directive)), + Self::InterfaceField(pos) => pos.insert_directive(schema, Node::new(directive)), + Self::InterfaceFieldArgument(pos) => pos.insert_directive(schema, Node::new(directive)), + Self::UnionType(pos) => pos.insert_directive(schema, Component::new(directive)), + Self::EnumType(pos) => pos.insert_directive(schema, Component::new(directive)), + Self::EnumValue(pos) => pos.insert_directive(schema, Node::new(directive)), + Self::InputObjectType(pos) => pos.insert_directive(schema, Component::new(directive)), + Self::InputObjectField(pos) => pos.insert_directive(schema, Node::new(directive)), + Self::DirectiveArgument(pos) => pos.insert_directive(schema, Node::new(directive)), + } + } +} + +impl From for DirectiveTargetPosition { + fn from(pos: ObjectOrInterfaceFieldDefinitionPosition) -> Self { + match pos { + ObjectOrInterfaceFieldDefinitionPosition::Object(pos) => { + DirectiveTargetPosition::ObjectField(pos) + } + ObjectOrInterfaceFieldDefinitionPosition::Interface(pos) => { + DirectiveTargetPosition::InterfaceField(pos) + } + } + } +} + +impl From for DirectiveTargetPosition { + fn from(pos: ObjectTypeDefinitionPosition) -> Self { + DirectiveTargetPosition::ObjectType(pos) + } +} + +impl From for DirectiveTargetPosition { + fn from(pos: SchemaDefinitionPosition) -> Self { + DirectiveTargetPosition::Schema(pos) + } +} + +impl From for DirectiveTargetPosition { + fn from(pos: TypeDefinitionPosition) -> Self { + match pos { + TypeDefinitionPosition::Scalar(scalar) => Self::ScalarType(scalar), + TypeDefinitionPosition::Object(object) => Self::ObjectType(object), + TypeDefinitionPosition::Interface(itf) => Self::InterfaceType(itf), + TypeDefinitionPosition::Union(union) => Self::UnionType(union), + TypeDefinitionPosition::Enum(enm) => Self::EnumType(enm), + TypeDefinitionPosition::InputObject(input_object) => { + Self::InputObjectType(input_object) + } + } + } +} + +impl TryFrom for DirectiveTargetPosition { + type Error = PositionConvertError; + + fn try_from(value: FieldDefinitionPosition) -> Result { + match value { + FieldDefinitionPosition::Object(obj_field) => Ok(Self::ObjectField(obj_field)), + FieldDefinitionPosition::Interface(itf_field) => Ok(Self::InterfaceField(itf_field)), + // The only field that can occur here is `__typename` on a Union, and meta fields + // cannot have directives + FieldDefinitionPosition::Union(_) => Err(PositionConvertError { + actual: value, + expected: "DirectiveTargetPosition", + }), + } + } +} + pub(crate) fn is_graphql_reserved_name(name: &str) -> bool { name.starts_with("__") } -lazy_static! { - static ref GRAPHQL_BUILTIN_SCALAR_NAMES: IndexSet = { - IndexSet::from_iter([ - name!("Int"), - name!("Float"), - name!("String"), - name!("Boolean"), - name!("ID"), - ]) - }; - static ref GRAPHQL_BUILTIN_DIRECTIVE_NAMES: IndexSet = { - IndexSet::from_iter([ - name!("include"), - name!("skip"), - name!("deprecated"), - name!("specifiedBy"), - name!("defer"), - ]) - }; - // This is static so that UnionTypenameFieldDefinitionPosition.field_name() can return `&Name`, - // like the other field_name() methods in this file. - pub(crate) static ref INTROSPECTION_TYPENAME_FIELD_NAME: Name = name!("__typename"); -} +pub(crate) static INTROSPECTION_TYPENAME_FIELD_NAME: Name = name!("__typename"); fn validate_component_directives( directives: &[Component], @@ -5284,6 +6947,15 @@ fn validate_arguments(arguments: &[Node]) -> Result<(), Fe Ok(()) } +fn rename_type(ast_type: &mut ast::Type, new_name: Name) { + match ast_type { + ast::Type::Named(name) => *name = new_name, + ast::Type::NonNullNamed(name) => *name = new_name, + ast::Type::List(boxed) => rename_type(boxed, new_name), + ast::Type::NonNullList(boxed) => rename_type(boxed, new_name), + } +} + impl FederationSchema { /// Note that the input schema must be partially valid, in that: /// @@ -5294,110 +6966,132 @@ impl FederationSchema { /// /// The input schema may be otherwise invalid GraphQL (e.g. it may not contain a Query type). If /// you want a ValidFederationSchema, use ValidFederationSchema::new() instead. - pub(crate) fn new(schema: Schema) -> Result { - let metadata = links_metadata(&schema)?; - let mut referencers: Referencers = Default::default(); + pub(crate) fn new(schema: Schema) -> Result { + let mut schema = Self::new_uninitialized(schema)?; + schema.collect_links_metadata()?; + schema.collect_shallow_references(); + schema.collect_deep_references()?; + Ok(schema) + } + + pub(crate) fn new_uninitialized(schema: Schema) -> Result { + Ok(Self { + schema, + referencers: Default::default(), + links_metadata: None, + subgraph_metadata: None, + }) + } - // Shallow pass to populate referencers for types/directives. - for (type_name, type_) in schema.types.iter() { + pub(crate) fn collect_links_metadata(&mut self) -> Result<(), FederationError> { + self.links_metadata = links_metadata(self.schema())?.map(Box::new); + Ok(()) + } + + pub(crate) fn collect_shallow_references(&mut self) { + for (type_name, type_) in self.schema.types.iter() { match type_ { ExtendedType::Scalar(_) => { - referencers + self.referencers .scalar_types .insert(type_name.clone(), Default::default()); } ExtendedType::Object(_) => { - referencers + self.referencers .object_types .insert(type_name.clone(), Default::default()); } ExtendedType::Interface(_) => { - referencers + self.referencers .interface_types .insert(type_name.clone(), Default::default()); } ExtendedType::Union(_) => { - referencers + self.referencers .union_types .insert(type_name.clone(), Default::default()); } ExtendedType::Enum(_) => { - referencers + self.referencers .enum_types .insert(type_name.clone(), Default::default()); } ExtendedType::InputObject(_) => { - referencers + self.referencers .input_object_types .insert(type_name.clone(), Default::default()); } } } - for directive_name in schema.directive_definitions.keys() { - referencers + + for directive_name in self.schema.directive_definitions.keys() { + self.referencers .directives .insert(directive_name.clone(), Default::default()); } + } - // Deep pass to find references. + pub(crate) fn collect_deep_references(&mut self) -> Result<(), FederationError> { SchemaDefinitionPosition.insert_references( - &schema.schema_definition, - &schema, - &mut referencers, + &self.schema.schema_definition, + &self.schema, + &mut self.referencers, )?; - for (type_name, type_) in schema.types.iter() { + for (type_name, type_) in self.schema.types.iter() { match type_ { ExtendedType::Scalar(type_) => { ScalarTypeDefinitionPosition { type_name: type_name.clone(), } - .insert_references(type_, &mut referencers)?; + .insert_references(type_, &mut self.referencers)?; } ExtendedType::Object(type_) => { ObjectTypeDefinitionPosition { type_name: type_name.clone(), } - .insert_references(type_, &schema, &mut referencers)?; + .insert_references( + type_, + &self.schema, + &mut self.referencers, + )?; } ExtendedType::Interface(type_) => { InterfaceTypeDefinitionPosition { type_name: type_name.clone(), } - .insert_references(type_, &schema, &mut referencers)?; + .insert_references( + type_, + &self.schema, + &mut self.referencers, + )?; } ExtendedType::Union(type_) => { UnionTypeDefinitionPosition { type_name: type_name.clone(), } - .insert_references(type_, &mut referencers)?; + .insert_references(type_, &mut self.referencers)?; } ExtendedType::Enum(type_) => { EnumTypeDefinitionPosition { type_name: type_name.clone(), } - .insert_references(type_, &mut referencers)?; + .insert_references(type_, &mut self.referencers)?; } ExtendedType::InputObject(type_) => { InputObjectTypeDefinitionPosition { type_name: type_name.clone(), } - .insert_references(type_, &mut referencers)?; + .insert_references(type_, &mut self.referencers)?; } } } - for (directive_name, directive) in schema.directive_definitions.iter() { + for (directive_name, directive) in self.schema.directive_definitions.iter() { DirectiveDefinitionPosition { directive_name: directive_name.clone(), } - .insert_references(directive, &mut referencers)?; + .insert_references(directive, &mut self.referencers)?; } - - Ok(FederationSchema { - schema, - referencers, - links_metadata: metadata.map(Box::new), - subgraph_metadata: None, - }) + Ok(()) } } @@ -5478,4 +7172,112 @@ mod tests { } "#); } + + #[test] + fn rename_type() { + let schema = Schema::parse_and_validate( + r#" + schema { + query: MyQuery + } + + type MyQuery { + a: MyData + } + + interface OtherInterface { + b: MyValue + } + + interface IMyData implements OtherInterface { + b: MyValue + } + + type MyData implements IMyData & OtherInterface { + b: MyValue + c: String + } + + type OtherData { + d: String + e: MyAorB + } + + union MyUnionData = MyData | OtherData + + scalar MyValue + + enum MyAorB { + A + B + } + "#, + "test-schema.graphqls", + ) + .unwrap(); + let mut schema = FederationSchema::new(schema.into_inner()).unwrap(); + + let query_position = ObjectTypeDefinitionPosition::new(name!("MyQuery")); + let interface_position = InterfaceTypeDefinitionPosition { + type_name: name!("IMyData"), + }; + let data_position = ObjectTypeDefinitionPosition::new(name!("MyData")); + let scalar_position = ScalarTypeDefinitionPosition { + type_name: name!("MyValue"), + }; + let union_position = UnionTypeDefinitionPosition { + type_name: name!("MyUnionData"), + }; + let enum_position = EnumTypeDefinitionPosition { + type_name: name!("MyAorB"), + }; + + query_position.rename(&mut schema, name!("Query")).unwrap(); + interface_position + .rename(&mut schema, name!("IData")) + .unwrap(); + data_position.rename(&mut schema, name!("Data")).unwrap(); + scalar_position.rename(&mut schema, name!("Value")).unwrap(); + union_position + .rename(&mut schema, name!("UnionData")) + .unwrap(); + enum_position.rename(&mut schema, name!("AorB")).unwrap(); + + insta::assert_snapshot!(schema.schema(), @r#" + schema { + query: Query + } + + type Query { + a: Data + } + + interface OtherInterface { + b: Value + } + + interface IData implements OtherInterface { + b: Value + } + + type Data implements OtherInterface & IData { + b: Value + c: String + } + + type OtherData { + d: String + e: AorB + } + + union UnionData = OtherData | Data + + scalar Value + + enum AorB { + A + B + } + "#); + } } diff --git a/apollo-federation/src/schema/referencer.rs b/apollo-federation/src/schema/referencer.rs index 63022c435b..06b996be12 100644 --- a/apollo-federation/src/schema/referencer.rs +++ b/apollo-federation/src/schema/referencer.rs @@ -1,10 +1,16 @@ +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast; use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; -use apollo_compiler::Name; +use super::FederationSchema; use crate::error::FederationError; use crate::error::SingleFederationError; +use crate::internal_error; +use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::DirectiveArgumentDefinitionPosition; +use crate::schema::position::DirectiveTargetPosition; use crate::schema::position::EnumTypeDefinitionPosition; use crate::schema::position::EnumValueDefinitionPosition; use crate::schema::position::InputObjectFieldDefinitionPosition; @@ -14,6 +20,7 @@ use crate::schema::position::InterfaceFieldDefinitionPosition; use crate::schema::position::InterfaceTypeDefinitionPosition; use crate::schema::position::ObjectFieldArgumentDefinitionPosition; use crate::schema::position::ObjectFieldDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceFieldDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; use crate::schema::position::ScalarTypeDefinitionPosition; use crate::schema::position::SchemaDefinitionPosition; @@ -59,12 +66,25 @@ impl Referencers { name: &str, ) -> Result<&DirectiveReferencers, FederationError> { self.directives.get(name).ok_or_else(|| { - SingleFederationError::Internal { - message: "Directive referencers unexpectedly missing directive".to_owned(), - } - .into() + internal_error!("Directive referencers unexpectedly missing directive `{name}`") }) } + + pub(crate) fn get_directive_applications<'schema>( + &self, + schema: &'schema FederationSchema, + name: &Name, + ) -> Result< + impl Iterator)>, + FederationError, + > { + let directive_referencers = self.get_directive(name)?; + Ok(directive_referencers.iter().flat_map(|pos| { + pos.get_applied_directives(schema, name) + .into_iter() + .map(move |directive_application| (pos.clone(), directive_application)) + })) + } } #[derive(Debug, Clone, Default)] @@ -78,6 +98,18 @@ pub(crate) struct ScalarTypeReferencers { pub(crate) directive_arguments: IndexSet, } +impl ScalarTypeReferencers { + pub(crate) fn len(&self) -> usize { + self.object_fields.len() + + self.object_field_arguments.len() + + self.interface_fields.len() + + self.interface_field_arguments.len() + + self.union_fields.len() + + self.input_object_fields.len() + + self.directive_arguments.len() + } +} + #[derive(Debug, Clone, Default)] pub(crate) struct ObjectTypeReferencers { pub(crate) schema_roots: IndexSet, @@ -86,6 +118,15 @@ pub(crate) struct ObjectTypeReferencers { pub(crate) union_types: IndexSet, } +impl ObjectTypeReferencers { + pub(crate) fn len(&self) -> usize { + self.schema_roots.len() + + self.object_fields.len() + + self.interface_fields.len() + + self.union_types.len() + } +} + #[derive(Debug, Clone, Default)] pub(crate) struct InterfaceTypeReferencers { pub(crate) object_types: IndexSet, @@ -110,6 +151,17 @@ pub(crate) struct EnumTypeReferencers { pub(crate) directive_arguments: IndexSet, } +impl EnumTypeReferencers { + pub(crate) fn len(&self) -> usize { + self.object_fields.len() + + self.object_field_arguments.len() + + self.interface_fields.len() + + self.interface_field_arguments.len() + + self.input_object_fields.len() + + self.directive_arguments.len() + } +} + #[derive(Debug, Clone, Default)] pub(crate) struct InputObjectTypeReferencers { pub(crate) object_field_arguments: IndexSet, @@ -118,6 +170,15 @@ pub(crate) struct InputObjectTypeReferencers { pub(crate) directive_arguments: IndexSet, } +impl InputObjectTypeReferencers { + pub(crate) fn len(&self) -> usize { + self.object_field_arguments.len() + + self.interface_field_arguments.len() + + self.input_object_fields.len() + + self.directive_arguments.len() + } +} + #[derive(Debug, Clone, Default)] pub(crate) struct DirectiveReferencers { pub(crate) schema: Option, @@ -135,3 +196,143 @@ pub(crate) struct DirectiveReferencers { pub(crate) input_object_fields: IndexSet, pub(crate) directive_arguments: IndexSet, } + +impl DirectiveReferencers { + pub(crate) fn object_or_interface_fields( + &self, + ) -> impl Iterator { + self.object_fields + .iter() + .map(|pos| ObjectOrInterfaceFieldDefinitionPosition::Object(pos.clone())) + .chain( + self.interface_fields + .iter() + .map(|pos| ObjectOrInterfaceFieldDefinitionPosition::Interface(pos.clone())), + ) + } + + pub(crate) fn composite_type_positions( + &self, + ) -> impl Iterator { + self.object_types + .iter() + .map(|t| CompositeTypeDefinitionPosition::from(t.clone())) + .chain(self.interface_types.iter().map(|t| t.clone().into())) + .chain(self.union_types.iter().map(|t| t.clone().into())) + } + + pub(crate) fn extend(&mut self, other: &Self) { + if let Some(schema) = &other.schema { + self.schema = Some(schema.clone()); + } + self.scalar_types.extend(other.scalar_types.iter().cloned()); + self.object_types.extend(other.object_types.iter().cloned()); + self.object_fields + .extend(other.object_fields.iter().cloned()); + self.object_field_arguments + .extend(other.object_field_arguments.iter().cloned()); + self.interface_types + .extend(other.interface_types.iter().cloned()); + self.interface_fields + .extend(other.interface_fields.iter().cloned()); + self.interface_field_arguments + .extend(other.interface_field_arguments.iter().cloned()); + self.union_types.extend(other.union_types.iter().cloned()); + self.enum_types.extend(other.enum_types.iter().cloned()); + self.enum_values.extend(other.enum_values.iter().cloned()); + self.input_object_types + .extend(other.input_object_types.iter().cloned()); + self.input_object_fields + .extend(other.input_object_fields.iter().cloned()); + self.directive_arguments + .extend(other.directive_arguments.iter().cloned()); + } + + pub(crate) fn iter(&self) -> impl Iterator { + let schema = self + .schema + .iter() + .cloned() + .map(DirectiveTargetPosition::Schema); + let scalar_types = self + .scalar_types + .iter() + .cloned() + .map(DirectiveTargetPosition::ScalarType); + let object_types = self + .object_types + .iter() + .cloned() + .map(DirectiveTargetPosition::ObjectType); + let object_fields = self + .object_fields + .iter() + .cloned() + .map(DirectiveTargetPosition::ObjectField); + let object_field_arguments = self + .object_field_arguments + .iter() + .cloned() + .map(DirectiveTargetPosition::ObjectFieldArgument); + let interface_types = self + .interface_types + .iter() + .cloned() + .map(DirectiveTargetPosition::InterfaceType); + let interface_fields = self + .interface_fields + .iter() + .cloned() + .map(DirectiveTargetPosition::InterfaceField); + let interface_field_arguments = self + .interface_field_arguments + .iter() + .cloned() + .map(DirectiveTargetPosition::InterfaceFieldArgument); + let union_types = self + .union_types + .iter() + .cloned() + .map(DirectiveTargetPosition::UnionType); + let enum_types = self + .enum_types + .iter() + .cloned() + .map(DirectiveTargetPosition::EnumType); + let enum_values = self + .enum_values + .iter() + .cloned() + .map(DirectiveTargetPosition::EnumValue); + let input_object_types = self + .input_object_types + .iter() + .cloned() + .map(DirectiveTargetPosition::InputObjectType); + let input_object_fields = self + .input_object_fields + .iter() + .cloned() + .map(DirectiveTargetPosition::InputObjectField); + let directive_arguments = self + .directive_arguments + .iter() + .cloned() + .map(DirectiveTargetPosition::DirectiveArgument); + + schema + .chain(scalar_types) + .chain(object_types) + .chain(object_fields) + .chain(object_field_arguments) + .chain(interface_types) + .chain(interface_fields) + .chain(interface_field_arguments) + .chain(union_types) + .chain(enum_types) + .chain(enum_values) + .chain(input_object_types) + .chain(input_object_fields) + .chain(directive_arguments) + } +} diff --git a/apollo-federation/src/schema/schema_upgrader.rs b/apollo-federation/src/schema/schema_upgrader.rs new file mode 100644 index 0000000000..7bc5c4b7d8 --- /dev/null +++ b/apollo-federation/src/schema/schema_upgrader.rs @@ -0,0 +1,1505 @@ +use std::collections::HashSet; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Directive; +use apollo_compiler::ast::Value; +use apollo_compiler::collections::HashMap; +use apollo_compiler::name; +use apollo_compiler::schema::Component; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::validation::Valid; + +use super::FederationSchema; +use super::TypeDefinitionPosition; +use super::field_set::collect_target_fields_from_field_set; +use super::position::DirectiveDefinitionPosition; +use super::position::FieldDefinitionPosition; +use super::position::InterfaceFieldDefinitionPosition; +use super::position::InterfaceTypeDefinitionPosition; +use super::position::ObjectFieldDefinitionPosition; +use super::position::ObjectTypeDefinitionPosition; +use crate::error::CompositionError; +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::link::federation_spec_definition::FederationSpecDefinition; +use crate::link::spec_definition::SpecDefinition; +use crate::schema::SchemaElement; +use crate::schema::SubgraphMetadata; +use crate::subgraph::SubgraphError; +use crate::subgraph::typestate::Expanded; +use crate::subgraph::typestate::Subgraph; +use crate::subgraph::typestate::Upgraded; +use crate::subgraph::typestate::add_federation_link_to_schema; +use crate::subgraph::typestate::expand_schema; +use crate::supergraph::GRAPHQL_SUBSCRIPTION_TYPE_NAME; +use crate::supergraph::remove_inactive_requires_and_provides_from_subgraph; +use crate::utils::FallibleIterator; + +// TODO should this module be under subgraph mod? +#[derive(Debug)] +pub(crate) struct SchemaUpgrader { + subgraphs: HashMap>, + object_type_map: HashMap>, +} + +#[derive(Clone, Debug)] +struct TypeInfo { + pos: TypeDefinitionPosition, + metadata: SubgraphMetadata, +} + +#[derive(Debug)] +struct UpgradeMetadata { + subgraph_name: String, + key_directive_name: Option, + requires_directive_name: Option, + provides_directive_name: Option, + extends_directive_name: Option, + metadata: SubgraphMetadata, +} + +impl SchemaUpgrader { + pub(crate) fn new(subgraphs: &[Subgraph]) -> Self { + let mut object_type_map: HashMap> = Default::default(); + for subgraph in subgraphs.iter() { + for pos in subgraph.schema().get_types() { + if matches!( + pos, + TypeDefinitionPosition::Object(_) | TypeDefinitionPosition::Interface(_) + ) { + object_type_map + .entry(pos.type_name().clone()) + .or_default() + .insert( + subgraph.name.clone(), + TypeInfo { + pos: pos.clone(), + metadata: subgraph.metadata().clone(), // TODO: Prefer not to clone + }, + ); + } + } + } + + let subgraphs: HashMap> = subgraphs + .iter() + .map(|subgraph| (subgraph.name.clone(), subgraph.clone())) + .collect(); + SchemaUpgrader { + subgraphs, + object_type_map, + } + } + + fn get_subgraph_by_name(&self, name: &String) -> Option<&Subgraph> { + self.subgraphs.get(name) + } + + pub(crate) fn upgrade( + &self, + subgraph: Subgraph, + ) -> Result, SubgraphError> { + let subgraph_name = subgraph.name.clone(); + self.upgrade_inner(subgraph) + .map_err(|e| SubgraphError::new_without_locations(subgraph_name, e)) + } + + pub(crate) fn upgrade_inner( + &self, + subgraph: Subgraph, + ) -> Result, FederationError> { + // Run pre-upgrade validations to check for issues that would prevent upgrade + let upgrade_metadata = UpgradeMetadata { + subgraph_name: subgraph.name.clone(), + key_directive_name: subgraph.key_directive_name()?.clone(), + requires_directive_name: subgraph.requires_directive_name()?.clone(), + provides_directive_name: subgraph.provides_directive_name()?.clone(), + extends_directive_name: subgraph.extends_directive_name()?.clone(), + metadata: subgraph.metadata().clone(), + }; + self.pre_upgrade_validations(&upgrade_metadata, &subgraph)?; + + // TODO avoid cloning here + let mut schema = subgraph.schema().clone(); + + // Fix federation directive arguments (fields) to ensure they're proper strings + // Note: Implementation simplified for compilation purposes + self.fix_federation_directives_arguments(&mut schema)?; + + self.remove_links(&mut schema)?; + + // re-expand all federation directive definitions + let federation_spec = FederationSpecDefinition::auto_expanded_federation_spec(); + add_federation_link_to_schema(&mut schema.schema, federation_spec.version())?; + let mut schema = expand_schema(schema.into_inner())?; + + self.remove_external_on_interface(&mut schema); + + self.remove_external_on_object_types(&mut schema); + + // Note that we remove all external on type extensions first, so we don't have to care about it later in @key, @provides and @requires. + self.remove_external_on_type_extensions(&upgrade_metadata, &mut schema)?; + + self.fix_inactive_provides_and_requires(&mut schema)?; + + self.remove_type_extensions(&upgrade_metadata, &mut schema)?; + + self.remove_directives_on_interface(&upgrade_metadata, &mut schema)?; + + // Note that this rule rely on being after `remove_directives_on_interface` in practice (in that it doesn't check interfaces). + self.remove_provides_on_non_composite(&mut schema)?; + + // Note that this should come _after_ all the other changes that may remove/update federation directives, since those may create unused + // externals. Which is why this is toward the end. + self.remove_unused_externals(&upgrade_metadata, &mut schema)?; + + self.add_shareable(&upgrade_metadata, &mut schema)?; + + self.remove_tag_on_external(&upgrade_metadata, &mut schema)?; + + let upgraded_subgraph = + Subgraph::new(subgraph.name.as_str(), subgraph.url.as_str(), schema.schema) + // This error will be wrapped up as a SubgraphError in `Self::upgrade` + .assume_expanded() + .map_err(|err| err.into_federation_error())? + .assume_upgraded(); + Ok(upgraded_subgraph) + } + + // integrates checkForExtensionWithNoBase from the JS code + fn pre_upgrade_validations( + &self, + upgrade_metadata: &UpgradeMetadata, + subgraph: &Subgraph, + ) -> Result<(), FederationError> { + let schema = subgraph.schema(); + + // Iterate through all types and check if they're federation type extensions without a base + for type_pos in schema.get_types() { + if self.is_root_type_extension(&type_pos, upgrade_metadata, schema) + || !self.is_federation_type_extension(&type_pos, upgrade_metadata, schema)? + { + continue; + } + + // Get the type name based on the type position + let type_name = type_pos.type_name(); + + // Check if any other subgraph has a proper definition for this type + let has_non_extension_definition = self + .object_type_map + .get(type_name) + .map(|subgraph_types| { + subgraph_types + .iter() + .filter(|(subgraph_name, _)| { + // Fixed: dereference the string for comparison + subgraph_name.as_str() != subgraph.name.as_str() + }) + .fallible_any(|(other_name, type_info)| { + let Some(other_subgraph) = self.get_subgraph_by_name(other_name) else { + return Ok(false); + }; + let extended_type = + type_info.pos.get(other_subgraph.schema().schema())?; + Ok::(extended_type.has_non_extension_elements()) + }) + }) + .unwrap_or(Ok(false))?; + + if !has_non_extension_definition { + return Err(SingleFederationError::ExtensionWithNoBase { + message: format!("Type \"{type_name}\" is an extension type, but there is no type definition for \"{type_name}\" in any subgraph.") + }.into()); + } + } + + Ok(()) + } + + // Either we have a string, or we have a list of strings that we need to combine + fn make_fields_string_if_not(arg: &Node) -> Result, FederationError> { + if let Some(arg_list) = arg.as_list() { + // Collect all strings from the list + let combined = arg_list + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(" "); + + return Ok(Some(combined)); + } + if let Some(enum_value) = arg.as_enum() { + return Ok(Some(enum_value.as_str().to_string())); + } + Ok(None) + } + + fn fix_federation_directives_arguments( + &self, + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + // both @provides and @requires will only have an object_fields referencer + for directive_name in ["requires", "provides"] { + let referencers = schema.referencers().get_directive(directive_name)?; + for field in &referencers.object_fields.clone() { + let field_type = field.make_mut(&mut schema.schema)?.make_mut(); + + for directive in field_type.directives.get_all_mut(directive_name) { + if let Some(arg) = directive + .make_mut() + .specified_argument_by_name_mut("fields") + && let Some(new_fields_string) = Self::make_fields_string_if_not(arg)? + { + *arg.make_mut() = Value::String(new_fields_string); + } + } + } + } + + // now do the exact same thing for @key. The difference is that the directive location will be object_types + // rather than object_fields + let referencers = schema.referencers().get_directive("key")?; + for field in &referencers.object_types.clone() { + let field_type = field.make_mut(&mut schema.schema)?.make_mut(); + + for directive in field_type.directives.iter_mut().filter(|d| d.name == "key") { + if let Some(arg) = directive + .make_mut() + .specified_argument_by_name_mut("fields") + && let Some(new_fields_string) = Self::make_fields_string_if_not(arg)? + { + *arg.make_mut() = Value::String(new_fields_string); + } + } + } + Ok(()) + } + + fn remove_links(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { + // for @core, we want to remove both the definition and all references, but for other + // federation directives, just remove the definitions + let directives_to_remove = [ + name!("extends"), + name!("key"), + name!("provides"), + name!("requires"), + name!("external"), + name!("tag"), + ]; + + let definitions: Vec = + schema.get_directive_definitions().collect(); + for definition in &definitions { + if directives_to_remove.contains(&definition.directive_name) { + schema + .schema + .directive_definitions + .shift_remove(&definition.directive_name); + schema + .referencers + .directives + .shift_remove(&definition.directive_name) + .ok_or_else(|| SingleFederationError::Internal { + message: format!( + "Schema missing referencers for directive \"{}\"", + &definition.directive_name + ), + })?; + } else if definition.directive_name == name!("core") { + definition.remove(schema)?; + } + } + + // now remove other federation types + if let Some(TypeDefinitionPosition::Enum(enum_obj)) = + schema.try_get_type(name!("core__Purpose")) + { + enum_obj.remove(schema)?; + } + if let Some(TypeDefinitionPosition::Scalar(scalar_obj)) = + schema.try_get_type(name!("core__Import")) + { + scalar_obj.remove(schema)?; + } + if let Some(TypeDefinitionPosition::Scalar(scalar_obj)) = + schema.try_get_type(name!("_FieldSet")) + { + scalar_obj.remove(schema)?; + } + if let Some(TypeDefinitionPosition::Scalar(scalar_obj)) = schema.try_get_type(name!("_Any")) + { + scalar_obj.remove(schema)?; + } + if let Some(TypeDefinitionPosition::Object(obj)) = schema.try_get_type(name!("_Service")) { + obj.remove(schema)?; + } + if let Some(TypeDefinitionPosition::Union(union_obj)) = + schema.try_get_type(name!("_Entity")) + { + union_obj.remove(schema)?; + } + Ok(()) + } + + fn remove_external_on_interface(&self, schema: &mut FederationSchema) { + let Some(metadata) = &schema.subgraph_metadata else { + return; + }; + let Ok(external_directive) = metadata + .federation_spec_definition() + .external_directive_definition(schema) + else { + return; + }; + let mut to_delete: Vec<(InterfaceFieldDefinitionPosition, Node)> = vec![]; + for (itf_name, ty) in schema.schema().types.iter() { + let ExtendedType::Interface(itf) = ty else { + continue; + }; + let interface_pos = InterfaceTypeDefinitionPosition::new(itf_name.clone()); + for (field_name, field) in &itf.fields { + let pos = interface_pos.field(field_name.clone()); + let external_directive = field + .node + .directives + .iter() + .find(|d| d.name == external_directive.name); + if let Some(external_directive) = external_directive { + to_delete.push((pos, external_directive.clone())); + } + } + } + for (pos, directive) in to_delete { + pos.remove_directive(schema, &directive); + } + } + + fn remove_external_on_object_types(&self, schema: &mut FederationSchema) { + let Some(metadata) = &schema.subgraph_metadata else { + return; + }; + let Ok(external_directive) = metadata + .federation_spec_definition() + .external_directive_definition(schema) + else { + return; + }; + let mut to_delete: Vec<(ObjectTypeDefinitionPosition, Component)> = vec![]; + for (obj_name, ty) in &schema.schema().types { + let ExtendedType::Object(_) = ty else { + continue; + }; + + let object_pos = ObjectTypeDefinitionPosition::new(obj_name.clone()); + let directives = object_pos.get_applied_directives(schema, &external_directive.name); + if !directives.is_empty() { + to_delete.push((object_pos, directives[0].clone())); + } + } + for (pos, directive) in to_delete { + pos.remove_directive(schema, &directive); + } + } + + fn remove_external_on_type_extensions( + &self, + upgrade_metadata: &UpgradeMetadata, + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + let Some(metadata) = &schema.subgraph_metadata else { + return Ok(()); + }; + let types: Vec<_> = schema.get_types().collect(); + let key_directive = metadata + .federation_spec_definition() + .key_directive_definition(schema)?; + let external_directive = metadata + .federation_spec_definition() + .external_directive_definition(schema)?; + + let mut to_remove = vec![]; + for ty in &types { + if !ty.is_composite_type() + || (!self.is_federation_type_extension(ty, upgrade_metadata, schema)? + && !self.is_root_type_extension(ty, upgrade_metadata, schema)) + { + continue; + } + + let key_applications = ty.get_applied_directives(schema, &key_directive.name); + if !key_applications.is_empty() { + for directive in key_applications { + let args = metadata + .federation_spec_definition() + .key_directive_arguments(directive)?; + for field in collect_target_fields_from_field_set( + Valid::assume_valid_ref(schema.schema()), + ty.type_name().clone(), + args.fields, + false, + )? { + let external = + field.get_applied_directives(schema, &external_directive.name); + if !external.is_empty() { + to_remove.push((field.clone(), external[0].clone())); + } + } + } + } else { + // ... but if the extension does _not_ have a key, then if the extension has a field that is + // part of the _1st_ key on the subgraph owning the type, then this field is not considered + // external (yes, it's pretty damn random, and it's even worst in that even if the extension + // does _not_ have the "field of the _1st_ key on the subgraph owning the type", then the + // query planner will still request it to the subgraph, generating an invalid query; but + // we ignore that here). Note however that because other subgraphs may have already been + // upgraded, we don't know which is the "type owner", so instead we look up at the first + // key of every other subgraph. It's not 100% what fed1 does, but we're in very-strange + // case territory in the first place, so this is probably good enough (that is, there is + // customer schema for which what we do here matter but not that I know of for which it's + // not good enough). + let Some(entries) = self.object_type_map.get(ty.type_name()) else { + continue; + }; + for (subgraph_name, info) in entries.iter() { + if subgraph_name == upgrade_metadata.subgraph_name.as_str() { + continue; + } + let Some(other_schema) = self.get_subgraph_by_name(subgraph_name) else { + continue; + }; + let keys_in_other = info.pos.get_applied_directives( + other_schema.schema(), + &info + .metadata + .federation_spec_definition() + .key_directive_definition(other_schema.schema())? + .name, + ); + if keys_in_other.is_empty() { + continue; + } + let directive = keys_in_other[0]; + let args = metadata + .federation_spec_definition() + .key_directive_arguments(directive)?; + for field in collect_target_fields_from_field_set( + Valid::assume_valid_ref(schema.schema()), + ty.type_name().clone(), + args.fields, + false, + )? { + if TypeDefinitionPosition::from(field.parent()) != info.pos { + continue; + } + let external = + field.get_applied_directives(schema, &external_directive.name); + if !external.is_empty() { + to_remove.push((field.clone(), external[0].clone())); + } + } + } + } + } + + for (pos, directive) in &to_remove { + pos.remove_directive(schema, directive); + } + Ok(()) + } + + fn fix_inactive_provides_and_requires( + &self, + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + let cloned_schema = schema.clone(); + remove_inactive_requires_and_provides_from_subgraph( + &cloned_schema, // TODO: I don't know what this value should be + schema, + ) + } + + fn remove_type_extensions( + &self, + upgrade_metadata: &UpgradeMetadata, + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + let types: Vec<_> = schema.get_types().collect(); + for ty in types { + if !self.is_federation_type_extension(&ty, upgrade_metadata, schema)? + && !self.is_root_type_extension(&ty, upgrade_metadata, schema) + { + continue; + } + ty.remove_extensions(schema)?; + } + Ok(()) + } + + /// Whether the type represents a type extension in the sense of federation 1. + /// That is, type extensions are a thing in GraphQL, but federation 1 overloads the notion for entities. This method + /// returns true if the type is used in the federation 1 sense of an extension. + /// We recognize federation 1 type extensions as type extensions that: + /// 1. are on object types or interface types (note that federation 1 doesn't really handle interface type extensions properly but it "accepts" them + /// so we do it here too). + /// 2. do not have a definition for the same type in the same subgraph (this is a GraphQL extension otherwise). + /// + /// Not that type extensions in federation 1 generally have a @key, but in reality the code considers something a type extension even without + /// it (which one could argue is an unintended bug of fed1 since this leads to various problems). So, we don't check for the presence of @key here. + fn is_federation_type_extension( + &self, + ty: &TypeDefinitionPosition, + upgrade_metadata: &UpgradeMetadata, + schema: &FederationSchema, + ) -> Result { + let type_ = ty.get(schema.schema())?; + let has_extend = upgrade_metadata + .extends_directive_name + .as_ref() + .is_some_and(|extends| type_.directives().has(extends.as_str())); + Ok((type_.has_extension_elements() || has_extend) + && (type_.is_object() || type_.is_interface()) + && (has_extend || !type_.has_non_extension_elements())) + } + + /// Whether the type is a root type but is declared only as an extension, which federation 1 actually accepts. + fn is_root_type_extension( + &self, + pos: &TypeDefinitionPosition, + upgrade_metadata: &UpgradeMetadata, + schema: &FederationSchema, + ) -> bool { + if !matches!(pos, TypeDefinitionPosition::Object(_)) || !Self::is_root_type(schema, pos) { + return false; + } + let Ok(ty) = pos.get(schema.schema()) else { + return false; + }; + let has_extends_directive = upgrade_metadata + .extends_directive_name + .as_ref() + .is_some_and(|extends| ty.directives().has(extends.as_str())); + + has_extends_directive || (ty.has_extension_elements() && !ty.has_non_extension_elements()) + } + + fn is_root_type(schema: &FederationSchema, ty: &TypeDefinitionPosition) -> bool { + schema.is_root_type(ty.type_name()) + } + + fn remove_directives_on_interface( + &self, + upgrade_metadata: &UpgradeMetadata, + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + if let Some(key) = &upgrade_metadata.key_directive_name { + for pos in schema + .referencers() + .get_directive(key)? + .interface_types + .clone() + { + pos.remove_directive_name(schema, key); + + let fields: Vec<_> = pos.fields(schema.schema())?.collect(); + for field in fields { + if let Some(provides) = &upgrade_metadata.provides_directive_name { + field.remove_directive_name(schema, provides); + } + if let Some(requires) = &upgrade_metadata.requires_directive_name { + field.remove_directive_name(schema, requires); + } + } + } + } + + Ok(()) + } + + fn remove_provides_on_non_composite( + &self, + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + let Some(metadata) = &schema.subgraph_metadata else { + return Ok(()); + }; + + let provides_directive = metadata + .federation_spec_definition() + .provides_directive_definition(schema)?; + + #[allow(clippy::iter_overeager_cloned)] // TODO: remove this + let references_to_remove: Vec<_> = schema + .referencers() + .get_directive(provides_directive.name.as_str())? + .object_fields + .iter() + .cloned() + .filter(|ref_field| { + schema + .get_type(ref_field.type_name.clone()) + .map(|t| !t.is_composite_type()) + .unwrap_or(false) + }) + .collect(); + for reference in &references_to_remove { + reference.remove(schema)?; + } + Ok(()) + } + + fn remove_unused_externals( + &self, + upgrade_metadata: &UpgradeMetadata, + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + let mut error = MultipleFederationErrors::new(); + let mut fields_to_remove: HashSet = HashSet::new(); + let mut types_to_remove: HashSet = HashSet::new(); + for type_ in schema.get_types() { + // @external is already removed from interfaces so we only need to process objects + if let Ok(pos) = ObjectTypeDefinitionPosition::try_from(type_) { + let mut has_fields = false; + for field in pos.fields(schema.schema())? { + has_fields = true; + let field_def = FieldDefinitionPosition::from(field.clone()); + let metadata = &upgrade_metadata.metadata; + if metadata.is_field_external(&field_def) && !metadata.is_field_used(&field_def) + { + fields_to_remove.insert(field); + } + } + if !has_fields { + let is_referenced = schema + .referencers + .object_types + .get(&pos.type_name) + .is_some_and(|r| r.len() > 0); + if is_referenced { + error + .errors + .push(SingleFederationError::TypeWithOnlyUnusedExternal { + type_name: pos.type_name.clone(), + }); + } else { + types_to_remove.insert(pos); + } + } + } + } + + for field in fields_to_remove { + field.remove(schema)?; + } + for type_ in types_to_remove { + type_.remove(schema)?; + } + error.into_result() + } + + fn add_shareable( + &self, + upgrade_metadata: &UpgradeMetadata, + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + let Some(metadata) = &schema.subgraph_metadata else { + return Ok(()); + }; + + let Some(key_directive_name) = &upgrade_metadata.key_directive_name else { + return Ok(()); + }; + + let shareable_directive_name = metadata + .federation_spec_definition() + .shareable_directive_definition(schema)? + .name + .clone(); + + let mut fields_to_add_shareable = vec![]; + let mut types_to_add_shareable = vec![]; + for type_pos in schema.get_types() { + let has_key_directive = type_pos.has_applied_directive(schema, key_directive_name); + let is_root_type = Self::is_root_type(schema, &type_pos); + let TypeDefinitionPosition::Object(obj_pos) = type_pos else { + continue; + }; + let obj_name = &obj_pos.type_name; + // Skip Subscription root type - no shareable needed + if obj_pos.type_name == GRAPHQL_SUBSCRIPTION_TYPE_NAME { + continue; + } + if has_key_directive || is_root_type { + for field in obj_pos.fields(schema.schema())? { + let obj_field = FieldDefinitionPosition::Object(field.clone()); + if metadata.is_field_shareable(&obj_field) { + continue; + } + let Some(entries) = self.object_type_map.get(obj_name) else { + continue; + }; + let type_in_other_subgraphs = entries.iter().any(|(subgraph_name, info)| { + let field_exists = self + .get_subgraph_by_name(subgraph_name) + .unwrap() + .schema() + .schema() + .type_field(&field.type_name, &field.field_name) + .is_ok(); + + if (subgraph_name != upgrade_metadata.subgraph_name.as_str()) + && field_exists + && (!info.metadata.is_field_external(&obj_field) + || info.metadata.is_field_partially_external(&obj_field)) + { + return true; + } + false + }); + if type_in_other_subgraphs + && !obj_field.has_applied_directive(schema, &shareable_directive_name) + { + fields_to_add_shareable.push(field.clone()); + } + } + } else { + let Some(entries) = self.object_type_map.get(obj_name) else { + continue; + }; + let type_in_other_subgraphs = entries.iter().any(|(subgraph_name, _info)| { + if subgraph_name != upgrade_metadata.subgraph_name.as_str() { + return true; + } + false + }); + if type_in_other_subgraphs + && !obj_pos.has_applied_directive(schema, &shareable_directive_name) + { + types_to_add_shareable.push(obj_pos.clone()); + } + } + } + for pos in &fields_to_add_shareable { + pos.insert_directive( + schema, + Node::new(Directive { + name: shareable_directive_name.clone(), + arguments: vec![], + }), + )?; + } + for pos in &types_to_add_shareable { + pos.insert_directive( + schema, + Component::new(Directive { + name: shareable_directive_name.clone(), + arguments: vec![], + }), + )?; + } + Ok(()) + } + + fn remove_tag_on_external( + &self, + upgrade_metadata: &UpgradeMetadata, + schema: &mut FederationSchema, + ) -> Result<(), FederationError> { + let applications = schema.tag_directive_applications()?; + let mut to_delete: Vec<(FieldDefinitionPosition, Node)> = vec![]; + if let Some(metadata) = &schema.subgraph_metadata { + applications + .iter() + .try_for_each(|application| -> Result<(), FederationError> { + if let Ok(application) = application + && let Ok(target) = + FieldDefinitionPosition::try_from(application.target.clone()) + && metadata.external_metadata().is_external(&target) + { + let used_in_other_definitions = self.subgraphs.iter().fallible_any( + |(name, subgraph)| -> Result { + if &upgrade_metadata.subgraph_name != name { + // check to see if the field is external in the other subgraphs + if let Some(other_metadata) = + &subgraph.schema().subgraph_metadata + && !other_metadata.external_metadata().is_external(&target) + { + // at this point, we need to check to see if there is a @tag directive on the other subgraph that matches the current application + let other_applications = + subgraph.schema().tag_directive_applications()?; + return other_applications.iter().fallible_any( + |other_app_result| -> Result { + if let Ok(other_tag_directive) = + (*other_app_result).as_ref() + && application.target + == other_tag_directive.target + && application.arguments.name + == other_tag_directive.arguments.name + { + return Ok(true); + } + Ok(false) + }, + ); + } + } + Ok(false) + }, + ); + if used_in_other_definitions? { + // remove @tag + to_delete.push((target, application.directive.clone())); + } + } + Ok(()) + })?; + } + for (pos, directive) in to_delete { + match pos { + FieldDefinitionPosition::Object(target) => { + target.remove_directive(schema, &directive); + } + FieldDefinitionPosition::Interface(target) => { + target.remove_directive(schema, &directive); + } + FieldDefinitionPosition::Union(_target) => { + todo!(); + } + } + } + Ok(()) + } +} + +// PORT_NOTE: In JS, this returns upgraded subgraphs along with a set of messages about what changed. +// However, those messages were never used, so we have omitted them here. +pub fn upgrade_subgraphs_if_necessary( + subgraphs: Vec>, +) -> Result>, Vec> { + // if all subgraphs are fed 2, there is no upgrade to be done + if subgraphs + .iter() + .all(|subgraph| subgraph.metadata().is_fed_2_schema()) + { + return Ok(subgraphs.into_iter().map(|s| s.assume_upgraded()).collect()); + } + + let mut subgraphs_using_interface_object = vec![]; + let mut fed_1_subgraphs = vec![]; + let mut errors: Vec = vec![]; + let schema_upgrader: SchemaUpgrader = SchemaUpgrader::new(&subgraphs); + let upgraded_subgraphs: Vec> = subgraphs + .into_iter() + .map(|subgraph| { + if !subgraph.metadata().is_fed_2_schema() { + fed_1_subgraphs.push(subgraph.name.clone()); + schema_upgrader.upgrade(subgraph) + } else { + if is_interface_object_used(&subgraph) + .map_err(|e| SubgraphError::new_without_locations(subgraph.name.clone(), e))? + { + subgraphs_using_interface_object.push(subgraph.name.clone()) + }; + Ok(subgraph.assume_upgraded()) + } + }) + .filter_map(|r| r.map_err(|e| errors.extend(e.to_composition_errors())).ok()) + .collect(); + + if !errors.is_empty() { + return Err(errors); + } + + if !subgraphs_using_interface_object.is_empty() { + fn format_subgraph_names(subgraph_names: Vec) -> String { + let prefix = if subgraph_names.len() == 1 { + "subgraph" + } else { + "subgraphs" + }; + let formatted_subgraphs = subgraph_names + .iter() + .map(|s| format!("\"{s}\"")) + .collect::>() + .join(" "); + format!("{prefix} {formatted_subgraphs}") + } + + let interface_object_subgraphs = format_subgraph_names(subgraphs_using_interface_object); + let fed_v1_subgraphs = format_subgraph_names(fed_1_subgraphs); + return Err(vec![CompositionError::InterfaceObjectUsageError { + message: format!( + "The @interfaceObject directive can only be used if all subgraphs have \ + federation 2 subgraph schema (schema with a `@link` to \"https://specs.apollo.dev/federation\" \ + version 2.0 or newer): @interfaceObject is used in {interface_object_subgraphs} but \ + {fed_v1_subgraphs} is not a federation 2 subgraph schema." + ), + }]); + } + Ok(upgraded_subgraphs) +} + +fn is_interface_object_used(subgraph: &Subgraph) -> Result { + if let Some(interface_object_def) = subgraph + .metadata() + .federation_spec_definition() + .interface_object_directive_definition(subgraph.schema())? + { + let referencers = subgraph + .schema() + .referencers() + .get_directive(interface_object_def.name.as_str())?; + if !referencers.object_types.is_empty() { + return Ok(true); + } + } + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS_UPGRADED: &str = r#"@link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.4", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"])"#; + + #[test] + fn upgrades_complex_schema() { + let s1 = Subgraph::parse( + "s1", + "", + r#" + type Query { + products: [Product!]! @provides(fields: "upc description") + } + + interface I @key(fields: "upc") { + upc: ID! + description: String @external + } + + extend type Product implements I @key(fields: "upc") { + upc: ID! @external + name: String @external + inventory: Int @requires(fields: "upc") + description: String @external + } + + # A type with a genuine 'graphqQL' extension, to ensure the extend don't get removed. + type Random { + x: Int + } + + extend type Random { + y: Int + } + "#, + ) + .expect("parses schema") + .expand_links() + .expect("expands schema"); + + // Note that no changes are really expected on that 2nd schema: it is just there to make the example not throw due to + // then Product type extension having no "base". + let s2 = Subgraph::parse( + "s2", + "", + r#" + type Product @key(fields: "upc") { + upc: ID! + name: String + description: String + } + "#, + ) + .expect("parses schema") + .expand_links() + .expect("expands schema"); + + let [s1, _s2]: [Subgraph; 2] = upgrade_subgraphs_if_necessary(vec![s1, s2]) + .expect("upgrades schema") + .try_into() + .expect("Expected 2 elements"); + + insta::assert_snapshot!( + s1.schema().schema().to_string(), @r###" + schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.4", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"]) { + query: Query + } + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @key(fields: federation__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + + directive @requires(fields: federation__FieldSet!) on FIELD_DEFINITION + + directive @provides(fields: federation__FieldSet!) on FIELD_DEFINITION + + directive @external(reason: String) on OBJECT | FIELD_DEFINITION + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + + directive @extends on OBJECT | INTERFACE + + directive @shareable repeatable on OBJECT | FIELD_DEFINITION + + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + directive @override(from: String!) on FIELD_DEFINITION + + directive @composeDirective(name: String) repeatable on SCHEMA + + directive @interfaceObject on OBJECT + + type Query { + products: [Product!]! @provides(fields: "description") + _entities(representations: [_Any!]!): [_Entity]! @shareable + _service: _Service! @shareable + } + + interface I { + upc: ID! + description: String + } + + type Random { + x: Int + } + + extend type Random { + y: Int + } + + type Product implements I @key(fields: "upc") { + upc: ID! + inventory: Int + description: String @external + } + + enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION + } + + scalar link__Import + + scalar federation__FieldSet + + scalar _Any + + type _Service @shareable { + sdl: String + } + + union _Entity = Product + "### + ); + } + + #[test] + #[ignore] + fn update_federation_directive_non_string_arguments() { + let s = Subgraph::parse( + "s", + "", + r#" + type Query { + a: A + } + + type A @key(fields: id) @key(fields: ["id", "x"]) { + id: String + x: Int + } + "#, + ) + .expect("parses schema") + .expand_links() + .expect("expands schema"); + + let [s]: [Subgraph; 1] = upgrade_subgraphs_if_necessary(vec![s]) + .expect("upgrades schema") + .try_into() + .expect("Expected 1 element"); + + insta::assert_snapshot!( + s.schema().schema().to_string(), + r#" + schema + FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS_UPGRADED + { + query: Query + } + + type Query { + a: A + } + + type A @key(fields: "id") @key(fields: "id x") { + id: String + x: Int + } + "# + .replace( + "FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS_UPGRADED", + FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS_UPGRADED + ) + ); + } + + #[test] + fn remove_tag_on_external_field_if_found_on_definition() { + let s1 = Subgraph::parse( + "s1", + "", + r#" + type Query { + a: A @provides(fields: "y") + } + + type A @key(fields: "id") { + id: String + x: Int + y: Int @external @tag(name: "a tag") + } + "#, + ) + .expect("parses schema") + .expand_links() + .expect("expands schema"); + + let s2 = Subgraph::parse( + "s2", + "", + r#" + type A @key(fields: "id") { + id: String + y: Int @tag(name: "a tag") + } + "#, + ) + .expect("parses schema") + .expand_links() + .expect("expands schema"); + + let [s1, s2]: [Subgraph; 2] = upgrade_subgraphs_if_necessary(vec![s1, s2]) + .expect("upgrades schema") + .try_into() + .expect("Expected 2 elements"); + + let type_a_in_s1 = s1 + .schema() + .schema() + .get_object("A") + .unwrap() + .fields + .get("y") + .unwrap(); + let type_a_in_s2 = s2 + .schema() + .schema() + .get_object("A") + .unwrap() + .fields + .get("y") + .unwrap(); + + assert_eq!(type_a_in_s1.directives.get_all("tag").count(), 0); + assert_eq!( + type_a_in_s2 + .directives + .get_all("tag") + .map(|d| d.to_string()) + .collect::>(), + vec![r#"@tag(name: "a tag")"#] + ); + } + + #[test] + fn reject_interface_object_usage_if_not_all_subgraphs_are_fed2() { + // Note that this test both validates the rejection of fed1 subgraph when @interfaceObject is used somewhere, but also + // illustrate why we do so: fed1 schema can use @key on interface for backward compatibility, but it is ignored and + // the schema upgrader removes them. Given that actual support for @key on interfaces is necesarry to make @interfaceObject + // work, it would be really confusing to not reject the example below right away, since it "looks" like it the @key on + // the interface in the 2nd subgraph should work, but it actually won't. + + let s1 = Subgraph::parse("s1", "", r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: [ "@key", "@interfaceObject"]) + + type Query { + a: A + } + + type A @key(fields: "id") @interfaceObject { + id: String + x: Int + } + "#) + .expect("parses schema") + .expand_links() + .expect("expands schema"); + + let s2 = Subgraph::parse( + "s2", + "", + r#" + interface A @key(fields: "id") { + id: String + y: Int + } + + type X implements A @key(fields: "id") { + id: String + y: Int + } + "#, + ) + .expect("parses schema") + .expand_links() + .expect("expands schema"); + + let errors = upgrade_subgraphs_if_necessary(vec![s1, s2]).expect_err("should fail"); + assert_eq!(errors.len(), 1); + assert_eq!( + errors[0].to_string(), + r#"The @interfaceObject directive can only be used if all subgraphs have federation 2 subgraph schema (schema with a `@link` to "https://specs.apollo.dev/federation" version 2.0 or newer): @interfaceObject is used in subgraph "s1" but subgraph "s2" is not a federation 2 subgraph schema."# + ); + } + + #[test] + fn handles_addition_of_shareable_when_external_is_used_on_type() { + let s1 = Subgraph::parse( + "s1", + "", + r#" + type Query { + t1: T + } + + type T @key(fields: "id") { + id: String + x: Int + } + "#, + ) + .expect("parses schema") + .expand_links() + .expect("expands schema"); + + let s2 = Subgraph::parse( + "s2", + "", + r#" + type Query { + t2: T + } + + type T @external { + x: Int + } + "#, + ) + .expect("parses schema") + .expand_links() + .expect("expands schema"); + + let [s1, s2]: [Subgraph; 2] = upgrade_subgraphs_if_necessary(vec![s1, s2]) + .expect("upgrades schema") + .try_into() + .expect("Expected 2 elements"); + + // 2 things must happen here: + // 1. the @external on type `T` in s2 should be removed, as @external on types were no-ops in fed1 (but not in fed2 anymore, hence the removal) + // 2. field `T.x` in s1 must be marked @shareable since it is resolved by s2 (since again, it's @external annotation is ignored). + assert!( + s2.schema() + .schema() + .types + .get("T") + .is_some_and(|t| !t.directives().has("external")) + ); + assert!( + s1.schema() + .schema() + .type_field("T", "x") + .is_ok_and(|f| f.directives.has("shareable")) + ); + } + + #[test] + fn fully_upgrades_schema_with_no_link_directives() { + let subgraph = Subgraph::parse( + "subgraph", + "", + r#" + type Query { + hello: String + } + "#, + ) + .expect("parses schema") + .expand_links() + .expect("expands schema"); + + let [subgraph]: [Subgraph; 1] = upgrade_subgraphs_if_necessary(vec![subgraph]) + .expect("upgrades schema") + .try_into() + .expect("Expected 1 element"); + // Note: this test mostly exists for dev awareness. By design, this will + // always require updating when the fed spec version is updated, so hopefully + // you're reading this comment. Existing schemas which don't include a @link + // directive usage will be upgraded to the latest version of the federation + // spec. The downstream effect of this auto-upgrading behavior is: + // + // GraphOS users who select the new build track you're going to introduce will + // immediately start composing with the latest specs without having to update + // their @link federation spec version in any of their subgraphs. For this to + // be ok, they need to first update to a router version which supports + // whatever changes you've introduced in the new spec version. Take care to + // ensure that things are released in the correct order. + // + // Ideally, in the future we ensure that GraphOS users are on a version of + // router that supports the build pipeline they're upgrading to, but that + // mechanism isn't in place yet. + // - Trevor + insta::assert_snapshot!( + subgraph.schema().schema().to_string(), + @r###" + schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.4", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"]) { + query: Query + } + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @key(fields: federation__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + + directive @requires(fields: federation__FieldSet!) on FIELD_DEFINITION + + directive @provides(fields: federation__FieldSet!) on FIELD_DEFINITION + + directive @external(reason: String) on OBJECT | FIELD_DEFINITION + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + + directive @extends on OBJECT | INTERFACE + + directive @shareable repeatable on OBJECT | FIELD_DEFINITION + + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + directive @override(from: String!) on FIELD_DEFINITION + + directive @composeDirective(name: String) repeatable on SCHEMA + + directive @interfaceObject on OBJECT + + type Query { + hello: String + _service: _Service! + } + + enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION + } + + scalar link__Import + + scalar federation__FieldSet + + scalar _Any + + type _Service { + sdl: String + } + "### + ); + } + + #[test] + fn does_not_add_shareable_to_subscriptions() { + let subgraph1 = Subgraph::parse( + "subgraph1", + "", + r#" + type Query { + hello: String + } + + type Subscription { + update: String! + } + "#, + ) + .expect("parses schema") + .expand_links() + .expect("expands schema"); + + let subgraph2 = Subgraph::parse( + "subgraph2", + "", + r#" + type Query { + hello: String + } + + type Subscription { + update: String! + } + "#, + ) + .expect("parses schema") + .expand_links() + .expect("expands schema"); + + let [subgraph1, subgraph2]: [Subgraph; 2] = + upgrade_subgraphs_if_necessary(vec![subgraph1, subgraph2]) + .expect("upgrades schema") + .try_into() + .expect("Expected 2 elements"); + assert!( + !subgraph1 + .schema() + .schema() + .to_string() + .contains("update: String! @shareable") + ); + assert!( + !subgraph2 + .schema() + .schema() + .to_string() + .contains("update: String! @shareable") + ); + assert!( + subgraph1 + .schema() + .schema() + .types + .get("Subscription") + .is_some_and(|s| !s.directives().has("shareable")) + ); + assert!( + subgraph2 + .schema() + .schema() + .types + .get("Subscription") + .is_some_and(|s| !s.directives().has("shareable")) + ); + } +} diff --git a/apollo-federation/src/schema/subgraph_metadata.rs b/apollo-federation/src/schema/subgraph_metadata.rs index 7655f8395b..9707da5890 100644 --- a/apollo-federation/src/schema/subgraph_metadata.rs +++ b/apollo-federation/src/schema/subgraph_metadata.rs @@ -1,6 +1,10 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +use apollo_compiler::Schema; use apollo_compiler::collections::IndexSet; +use apollo_compiler::schema::ExtendedType; use apollo_compiler::validation::Valid; -use apollo_compiler::Schema; use crate::error::FederationError; use crate::link::federation_spec_definition::FederationSpecDefinition; @@ -8,12 +12,15 @@ use crate::link::spec::Version; use crate::link::spec_definition::SpecDefinition; use crate::operation::Selection; use crate::operation::SelectionSet; +use crate::schema::FederationSchema; use crate::schema::field_set::collect_target_fields_from_field_set; +use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::FieldDefinitionPosition; -use crate::schema::position::ObjectOrInterfaceTypeDefinitionPosition; -use crate::schema::FederationSchema; +use crate::schema::position::InterfaceFieldDefinitionPosition; +use crate::schema::position::ObjectFieldDefinitionPosition; +use crate::schema::validators::from_context::parse_context; -fn unwrap_schema(fed_schema: &Valid) -> &Valid { +fn unwrap_schema(fed_schema: &FederationSchema) -> &Valid { // Okay to assume valid because `fed_schema` is known to be valid. Valid::assume_valid_ref(fed_schema.schema()) } @@ -24,17 +31,43 @@ fn unwrap_schema(fed_schema: &Valid) -> &Valid { pub(crate) struct SubgraphMetadata { federation_spec_definition: &'static FederationSpecDefinition, external_metadata: ExternalMetadata, + context_fields: IndexSet, + interface_constraint_fields: IndexSet, + key_fields: IndexSet, + provided_fields: IndexSet, + required_fields: IndexSet, + shareable_fields: IndexSet, } +#[allow(dead_code)] impl SubgraphMetadata { pub(super) fn new( - schema: &Valid, + schema: &FederationSchema, federation_spec_definition: &'static FederationSpecDefinition, ) -> Result { let external_metadata = ExternalMetadata::new(schema, federation_spec_definition)?; + let context_fields = Self::collect_fields_used_by_context_directive(schema)?; + let interface_constraint_fields = + Self::collect_fields_used_to_satisfy_interface_constraints(schema)?; + let key_fields = Self::collect_key_fields(schema)?; + let provided_fields = Self::collect_provided_fields(schema)?; + let required_fields = Self::collect_required_fields(schema)?; + let shareable_fields = if federation_spec_definition.is_fed1() { + // `@shareable` is not used in Fed 1 schemas. + Default::default() + } else { + Self::collect_shareable_fields(schema, federation_spec_definition)? + }; + Ok(Self { federation_spec_definition, external_metadata, + context_fields, + interface_constraint_fields, + key_fields, + provided_fields, + required_fields, + shareable_fields, }) } @@ -45,6 +78,227 @@ impl SubgraphMetadata { pub(crate) fn external_metadata(&self) -> &ExternalMetadata { &self.external_metadata } + + pub(crate) fn is_fed_2_schema(&self) -> bool { + self.federation_spec_definition() + .version() + .satisfies(&Version { major: 2, minor: 0 }) + } + + pub(crate) fn is_field_external(&self, field: &FieldDefinitionPosition) -> bool { + self.external_metadata().is_external(field) + } + + pub(crate) fn is_field_external_in_implementer(&self, field: &FieldDefinitionPosition) -> bool { + self.external_metadata().is_external_in_implementer(field) + } + + pub(crate) fn is_field_fake_external(&self, field: &FieldDefinitionPosition) -> bool { + self.external_metadata().is_fake_external(field) + } + + pub(crate) fn is_field_fully_external(&self, field: &FieldDefinitionPosition) -> bool { + self.is_field_external(field) && self.provided_fields.contains(field) + } + + pub(crate) fn is_field_partially_external(&self, field: &FieldDefinitionPosition) -> bool { + self.is_field_external(field) && !self.provided_fields.contains(field) + } + + pub(crate) fn is_field_shareable(&self, field: &FieldDefinitionPosition) -> bool { + self.key_fields.contains(field) + || self.shareable_fields.contains(field) + // Fed2 schemas reject provides on non-external field, but fed1 doesn't (at least not always). + // We call this on fed1 schema upgrader. So let's make sure we ignore non-external fields. + || (self.provided_fields.contains(field) && self.is_field_external(field)) + } + + pub(crate) fn is_field_used(&self, field: &FieldDefinitionPosition) -> bool { + self.context_fields.contains(field) + || self.interface_constraint_fields.contains(field) + || self.key_fields.contains(field) + || self.provided_fields.contains(field) + || self.required_fields.contains(field) + } + + pub(crate) fn selection_selects_any_external_field(&self, selection: &SelectionSet) -> bool { + self.external_metadata() + .selects_any_external_field(selection) + } + + fn collect_key_fields( + schema: &FederationSchema, + ) -> Result, FederationError> { + let mut key_fields = IndexSet::default(); + let Ok(applications) = schema.key_directive_applications() else { + return Ok(Default::default()); + }; + for key_directive in applications.into_iter().filter_map(|res| res.ok()) { + key_fields.extend(collect_target_fields_from_field_set( + unwrap_schema(schema), + key_directive.target.type_name().clone(), + key_directive.arguments.fields, + false, + )?); + } + Ok(key_fields) + } + + fn collect_provided_fields( + schema: &FederationSchema, + ) -> Result, FederationError> { + let mut provided_fields = IndexSet::default(); + let Ok(applications) = schema.provides_directive_applications() else { + return Ok(Default::default()); + }; + for provides_directive in applications.into_iter().filter_map(|res| res.ok()) { + provided_fields.extend(collect_target_fields_from_field_set( + unwrap_schema(schema), + provides_directive.target_return_type.clone(), + provides_directive.arguments.fields, + false, + )?); + } + Ok(provided_fields) + } + + fn collect_required_fields( + schema: &FederationSchema, + ) -> Result, FederationError> { + let mut required_fields = IndexSet::default(); + let Ok(applications) = schema.requires_directive_applications() else { + return Ok(Default::default()); + }; + for requires_directive in applications.into_iter().filter_map(|d| d.ok()) { + required_fields.extend(collect_target_fields_from_field_set( + unwrap_schema(schema), + requires_directive.target.type_name().clone(), + requires_directive.arguments.fields, + false, + )?); + } + Ok(required_fields) + } + + fn collect_shareable_fields( + schema: &FederationSchema, + federation_spec_definition: &'static FederationSpecDefinition, + ) -> Result, FederationError> { + let mut shareable_fields = IndexSet::default(); + // PORT_NOTE: The comment below is from the JS code. It doesn't seem to apply to the Rust code. + // @shareable is only available on fed2 schemas, but the schema upgrader call this on fed1 schemas as a shortcut to + // identify key fields (because if we know nothing is marked @shareable, then the only fields that are shareable + // by default are key fields). + let Some(shareable_directive_name) = + federation_spec_definition.shareable_directive_name_in_schema(schema)? + else { + return Ok(shareable_fields); + }; + let shareable_directive_referencers = schema + .referencers + .get_directive(&shareable_directive_name)?; + + // Fields of shareable object types are shareable + for object_type_position in &shareable_directive_referencers.object_types { + shareable_fields.extend( + object_type_position + .fields(schema.schema())? + .map(FieldDefinitionPosition::Object), + ); + } + + // Fields with @shareable directly applied are shareable + shareable_fields.extend( + shareable_directive_referencers + .object_fields + .iter() + .cloned() + .map(FieldDefinitionPosition::Object), + ); + + Ok(shareable_fields) + } + + fn collect_fields_used_by_context_directive( + schema: &FederationSchema, + ) -> Result, FederationError> { + let Ok(context_directive_applications) = schema.context_directive_applications() else { + return Ok(Default::default()); + }; + let Ok(from_context_directive_applications) = schema.from_context_directive_applications() + else { + return Ok(Default::default()); + }; + + let mut used_context_fields = IndexSet::default(); + let mut entry_points: HashMap> = + HashMap::new(); + + for context_directive in context_directive_applications + .into_iter() + .filter_map(|d| d.ok()) + { + if !entry_points.contains_key(context_directive.arguments.name) { + entry_points.insert(context_directive.arguments.name.to_string(), HashSet::new()); + } + entry_points + .get_mut(context_directive.arguments.name) + .expect("was just inserted") + .insert(context_directive.target); + } + for from_context_directive in from_context_directive_applications + .into_iter() + .filter_map(|d| d.ok()) + { + let (Some(context), Some(selection)) = + parse_context(from_context_directive.arguments.field) + else { + continue; + }; + if let Some(entry_point) = entry_points.get(context.as_str()) { + for context_type in entry_point { + used_context_fields.extend(collect_target_fields_from_field_set( + unwrap_schema(schema), + context_type.type_name().clone(), + selection.as_str(), + false, + )?); + } + } + } + Ok(used_context_fields) + } + + fn collect_fields_used_to_satisfy_interface_constraints( + schema: &FederationSchema, + ) -> Result, FederationError> { + let mut interface_constraint_fields = IndexSet::default(); + for ty in schema.schema().types.values() { + if let ExtendedType::Interface(itf) = ty { + let possible_runtime_types: Vec<_> = schema + .schema() + .implementers_map() + .get(&itf.name) + .map_or(&Default::default(), |impls| &impls.objects) + .iter() + .filter_map(|ty| schema.schema().get_object(ty)) + .collect(); + + for field_name in itf.fields.keys() { + for object_type in &possible_runtime_types { + interface_constraint_fields.insert(FieldDefinitionPosition::Object( + ObjectFieldDefinitionPosition { + type_name: object_type.name.clone(), + field_name: field_name.clone(), + }, + )); + } + } + } + } + + Ok(interface_constraint_fields) + } } // PORT_NOTE: The JS codebase called this `ExternalTester`, but this naming didn't make it @@ -60,11 +314,14 @@ pub(crate) struct ExternalMetadata { fake_external_fields: IndexSet, /// Fields that are external because their parent type has an `@external` directive. fields_on_external_types: IndexSet, + /// Fields which are not necessarily external on their source interface but have an implementation + /// which does mark that field as external. + fields_with_external_implementation: IndexSet, } impl ExternalMetadata { fn new( - schema: &Valid, + schema: &FederationSchema, federation_spec_definition: &'static FederationSpecDefinition, ) -> Result { let external_fields = Self::collect_external_fields(federation_spec_definition, schema)?; @@ -84,24 +341,49 @@ impl ExternalMetadata { Default::default() }; + // We want to be able to check if an interface field has an implementation which marks it as external. + // We take the external fields we already collected and find possible interface candidates for object + // types. Then, we filter down to those candidates which actually have the field we're looking at. + let fields_with_external_implementation = external_fields + .iter() + .flat_map(|external_field| { + let Ok(ExtendedType::Object(ty)) = external_field.parent().get(schema.schema()) + else { + return vec![]; + }; + ty.implements_interfaces + .iter() + .map(|itf| { + FieldDefinitionPosition::Interface(InterfaceFieldDefinitionPosition { + type_name: itf.name.clone(), + field_name: external_field.field_name().clone(), + }) + }) + .filter(|candidate_field| candidate_field.try_get(schema.schema()).is_some()) + .collect() + }) + .collect(); + Ok(Self { external_fields, fake_external_fields, fields_on_external_types, + fields_with_external_implementation, }) } fn collect_external_fields( federation_spec_definition: &'static FederationSpecDefinition, - schema: &Valid, + schema: &FederationSchema, ) -> Result, FederationError> { - let external_directive_definition = federation_spec_definition - .external_directive_definition(schema)? - .clone(); + let Some(external_directive_name) = + federation_spec_definition.external_directive_name_in_schema(schema)? + else { + return Ok(Default::default()); + }; - let external_directive_referencers = schema - .referencers - .get_directive(&external_directive_definition.name)?; + let external_directive_referencers = + schema.referencers.get_directive(&external_directive_name)?; let mut external_fields = IndexSet::default(); @@ -124,48 +406,38 @@ impl ExternalMetadata { fn collect_fake_externals( federation_spec_definition: &'static FederationSpecDefinition, - schema: &Valid, + schema: &FederationSchema, ) -> Result, FederationError> { let mut fake_external_fields = IndexSet::default(); - let extends_directive_definition = - federation_spec_definition.extends_directive_definition(schema)?; - let key_directive_definition = - federation_spec_definition.key_directive_definition(schema)?; - let key_directive_referencers = schema - .referencers - .get_directive(&key_directive_definition.name)?; - let mut key_type_positions: Vec = vec![]; - for object_type_position in &key_directive_referencers.object_types { - key_type_positions.push(object_type_position.clone().into()); - } - for interface_type_position in &key_directive_referencers.interface_types { - key_type_positions.push(interface_type_position.clone().into()); - } - for type_position in key_type_positions { - let directives = match &type_position { - ObjectOrInterfaceTypeDefinitionPosition::Object(pos) => { - &pos.get(schema.schema())?.directives - } - ObjectOrInterfaceTypeDefinitionPosition::Interface(pos) => { - &pos.get(schema.schema())?.directives - } - }; - let has_extends_directive = directives.has(&extends_directive_definition.name); - for key_directive_application in directives.get_all(&key_directive_definition.name) { - // PORT_NOTE: The JS codebase treats the "extend" GraphQL keyword as applying to - // only the extension it's on, while it treats the "@extends" directive as applying - // to all definitions/extensions in the subgraph. We accordingly do the same. - if has_extends_directive - || key_directive_application.origin.extension_id().is_some() - { - let key_directive_arguments = federation_spec_definition - .key_directive_arguments(key_directive_application)?; - fake_external_fields.extend(collect_target_fields_from_field_set( - unwrap_schema(schema), - type_position.type_name().clone(), - key_directive_arguments.fields, - )?); - } + let (Ok(extends_directive_definition), Ok(key_directive_applications)) = ( + federation_spec_definition.extends_directive_definition(schema), + schema.key_directive_applications(), + ) else { + return Ok(Default::default()); + }; + for key_directive in key_directive_applications + .into_iter() + .filter_map(|k| k.ok()) + { + let has_extends_directive = key_directive + .sibling_directives + .has(&extends_directive_definition.name); + // PORT_NOTE: The JS codebase treats the "extend" GraphQL keyword as applying to + // only the extension it's on, while it treats the "@extends" directive as applying + // to all definitions/extensions in the subgraph. We accordingly do the same. + if has_extends_directive + || key_directive + .schema_directive + .origin + .extension_id() + .is_some() + { + fake_external_fields.extend(collect_target_fields_from_field_set( + unwrap_schema(schema), + key_directive.target.type_name().clone(), + key_directive.arguments.fields, + false, + )?); } } Ok(fake_external_fields) @@ -173,15 +445,16 @@ impl ExternalMetadata { fn collect_fields_on_external_types( federation_spec_definition: &'static FederationSpecDefinition, - schema: &Valid, + schema: &FederationSchema, ) -> Result, FederationError> { - let external_directive_definition = federation_spec_definition - .external_directive_definition(schema)? - .clone(); + let Some(external_directive_name) = + federation_spec_definition.external_directive_name_in_schema(schema)? + else { + return Ok(Default::default()); + }; - let external_directive_referencers = schema - .referencers - .get_directive(&external_directive_definition.name)?; + let external_directive_referencers = + schema.referencers.get_directive(&external_directive_name)?; let mut fields_on_external_types = IndexSet::default(); for object_type_position in &external_directive_referencers.object_types { @@ -206,6 +479,14 @@ impl ExternalMetadata { && !self.is_fake_external(field_definition_position) } + pub(crate) fn is_external_in_implementer( + &self, + field_definition_position: &FieldDefinitionPosition, + ) -> bool { + self.fields_with_external_implementation + .contains(field_definition_position) + } + pub(crate) fn is_fake_external( &self, field_definition_position: &FieldDefinitionPosition, @@ -216,17 +497,100 @@ impl ExternalMetadata { pub(crate) fn selects_any_external_field(&self, selection_set: &SelectionSet) -> bool { for selection in selection_set.selections.values() { - if let Selection::Field(field_selection) = selection { - if self.is_external(&field_selection.field.field_position) { - return true; - } + if let Selection::Field(field_selection) = selection + && self.is_external(&field_selection.field.field_position) + { + return true; } - if let Some(selection_set) = selection.selection_set() { - if self.selects_any_external_field(selection_set) { - return true; - } + if let Some(selection_set) = selection.selection_set() + && self.selects_any_external_field(selection_set) + { + return true; } } false } } + +#[cfg(test)] +mod tests { + use apollo_compiler::Name; + + use crate::schema::FederationSchema; + use crate::schema::position::FieldDefinitionPosition; + use crate::schema::position::ObjectFieldDefinitionPosition; + + #[test] + fn subgraph_metadata_is_field_shareable() { + let schema_str = include_str!("fixtures/shareable_fields.graphqls"); + let schema = apollo_compiler::Schema::parse(schema_str, "shareable_fields.graphqls") + .expect("valid schema"); + let fed_schema = FederationSchema::new(schema) + .expect("federation schema") + .validate_or_return_self() + .map_err(|(_, diagnostics)| diagnostics) + .expect("valid federation schema"); + let meta = fed_schema.subgraph_metadata().expect("has metadata"); + + // Fields on @shareable object types are shareable + assert!(meta.is_field_shareable(&field("O1", "a"))); + assert!(meta.is_field_shareable(&field("O1", "b"))); + + // Fields directly marked with @shareable are shareable + assert!(meta.is_field_shareable(&field("O2", "d"))); + + // Fields marked as @external and provided by some path in the graph are shareable + assert!(meta.is_field_shareable(&field("O3", "externalField"))); + + // Remaining fields are not shareable + assert!(!meta.is_field_shareable(&field("O2", "c"))); + assert!(!meta.is_field_shareable(&field("O3", "c"))); + assert!(!meta.is_field_shareable(&field("O3", "externalFieldNeverProvided"))); + } + + #[test] + fn subgraph_metadata_is_field_used() { + let schema_str = include_str!("fixtures/used_fields.graphqls"); + let schema = apollo_compiler::Schema::parse(schema_str, "used_fields.graphqls") + .expect("valid schema"); + let fed_schema = FederationSchema::new(schema) + .expect("federation schema") + .validate_or_return_self() + .map_err(|(_, diagnostics)| diagnostics) + .expect("valid federation schema"); + let meta = fed_schema.subgraph_metadata().expect("has metadata"); + + // Fields that can satisfy interface constraints are used + assert!(meta.is_field_used(&field("O1", "a"))); + + // Fields required by @requires are used + assert!(meta.is_field_used(&field("O2", "isRequired"))); + assert!(meta.is_field_used(&field("O2", "isAlsoRequired"))); + + // Fields that are part of a @key are used + assert!(meta.is_field_used(&field("O3", "keyField1"))); + assert!(meta.is_field_used(&field("O3", "subKey"))); + assert!(meta.is_field_used(&field("O3SubKey", "keyField2"))); + + // Fields that are @external and provided by some path in the graph are used + assert!(meta.is_field_used(&field("O4", "externalField"))); + + // Fields pulled from @context are used + assert!(meta.is_field_used(&field("O5Context", "usedInContext"))); + + // Remaining fields are not considered used + assert!(!meta.is_field_used(&field("O1", "b"))); + assert!(!meta.is_field_used(&field("O2", "hasRequirement"))); + assert!(!meta.is_field_used(&field("O3", "nonKeyField"))); + assert!(!meta.is_field_used(&field("O4", "c"))); + assert!(!meta.is_field_used(&field("O4", "externalFieldNeverProvided"))); + assert!(!meta.is_field_used(&field("O5Context", "notUsedInContext"))); + } + + fn field(type_name: &str, field_name: &str) -> FieldDefinitionPosition { + FieldDefinitionPosition::Object(ObjectFieldDefinitionPosition { + type_name: Name::new_unchecked(type_name), + field_name: Name::new_unchecked(field_name), + }) + } +} diff --git a/apollo-federation/src/schema/type_and_directive_specification.rs b/apollo-federation/src/schema/type_and_directive_specification.rs index 3396c8e0d9..d7d15156e4 100644 --- a/apollo-federation/src/schema/type_and_directive_specification.rs +++ b/apollo-federation/src/schema/type_and_directive_specification.rs @@ -4,6 +4,12 @@ // Rather than littering this module with `#[allow(dead_code)]`s or adding a config_atr to the // crate wide directive, allowing dead code here seems like the best options +use std::any::Any; +use std::rc::Rc; +use std::sync::Arc; + +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::ast::DirectiveLocation; use apollo_compiler::ast::FieldDefinition; use apollo_compiler::ast::Value; @@ -15,25 +21,32 @@ use apollo_compiler::schema::DirectiveDefinition; use apollo_compiler::schema::EnumType; use apollo_compiler::schema::EnumValueDefinition; use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::InputObjectType; use apollo_compiler::schema::InputValueDefinition; use apollo_compiler::schema::ObjectType; use apollo_compiler::schema::ScalarType; use apollo_compiler::schema::Type; use apollo_compiler::schema::UnionType; -use apollo_compiler::Name; -use apollo_compiler::Node; +use itertools::Itertools; +use crate::bail; use crate::error::FederationError; use crate::error::MultipleFederationErrors; use crate::error::SingleFederationError; +use crate::link::Link; +use crate::link::spec::Version; +use crate::link::spec_definition::SpecDefinition; +use crate::schema::FederationSchema; use crate::schema::argument_composition_strategies::ArgumentCompositionStrategy; use crate::schema::position::DirectiveDefinitionPosition; use crate::schema::position::EnumTypeDefinitionPosition; +use crate::schema::position::InputObjectTypeDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; use crate::schema::position::ScalarTypeDefinitionPosition; use crate::schema::position::TypeDefinitionPosition; use crate::schema::position::UnionTypeDefinitionPosition; -use crate::schema::FederationSchema; +use crate::subgraph::typestate::Subgraph; +use crate::subgraph::typestate::Validated; ////////////////////////////////////////////////////////////////////////////// // Field and Argument Specifications @@ -43,27 +56,40 @@ use crate::schema::FederationSchema; pub(crate) struct ArgumentSpecification { pub(crate) name: Name, // PORT_NOTE: In TS, get_type returns `InputType`. - pub(crate) get_type: fn(schema: &FederationSchema) -> Result, + pub(crate) get_type: + fn(schema: &FederationSchema, link: Option<&Arc>) -> Result, pub(crate) default_value: Option, } +impl ArgumentSpecification { + pub(crate) fn resolve( + &self, + schema: &FederationSchema, + link: Option<&Arc>, + ) -> Result { + let ty = (self.get_type)(schema, link)?; + Ok(ResolvedArgumentSpecification { + name: self.name.clone(), + ty, + default_value: self.default_value.clone(), + }) + } +} + /// The resolved version of `ArgumentSpecification` pub(crate) struct ResolvedArgumentSpecification { pub(crate) name: Name, pub(crate) ty: Type, - default_value: Option, + pub(crate) default_value: Option, } -impl From<&ResolvedArgumentSpecification> for InputValueDefinition { - fn from(arg_spec: &ResolvedArgumentSpecification) -> Self { +impl From for InputValueDefinition { + fn from(arg_spec: ResolvedArgumentSpecification) -> Self { InputValueDefinition { description: None, - name: arg_spec.name.clone(), - ty: Node::new(arg_spec.ty.clone()), - default_value: arg_spec - .default_value - .as_ref() - .map(|v| Node::new(v.clone())), + name: arg_spec.name, + ty: Node::new(arg_spec.ty), + default_value: arg_spec.default_value.map(Node::new), directives: Default::default(), } } @@ -75,14 +101,14 @@ pub(crate) struct FieldSpecification { pub(crate) arguments: Vec, } -impl From<&FieldSpecification> for FieldDefinition { - fn from(field_spec: &FieldSpecification) -> Self { +impl From for FieldDefinition { + fn from(field_spec: FieldSpecification) -> Self { FieldDefinition { description: None, name: field_spec.name.clone(), arguments: field_spec .arguments - .iter() + .into_iter() .map(|arg| Node::new(arg.into())) .collect(), ty: field_spec.ty.clone(), @@ -95,8 +121,33 @@ impl From<&FieldSpecification> for FieldDefinition { // Type Specifications pub(crate) trait TypeAndDirectiveSpecification { - // PORT_NOTE: The JS version takes additional optional arguments `feature` and `asBuiltIn`. - fn check_or_add(&self, schema: &mut FederationSchema) -> Result<(), FederationError>; + /// Returns the spec name (not the name in the schema). + fn name(&self) -> &Name; + + // PORT_NOTE: The JS version takes additional optional argument `asBuiltIn`. + // - The JS version only sets it `true` for GraphQL built-in types and directives. + // - In Rust, GraphQL built-in definitions are added by `collect_shallow_references`, which + // copies `apollo-compiler`'s Schema definitions. So, `asBuiltIn` is not needed. + fn check_or_add( + &self, + schema: &mut FederationSchema, + link: Option<&Arc>, + ) -> Result<(), FederationError>; + + /// Cast to `Any` to allow downcasting refs to concrete implementations + fn as_any(&self) -> &dyn Any; +} + +/// Retrieves the actual type name in the importing schema via `@link`; Otherwise, returns `name`. +fn actual_type_name(name: &Name, link: Option<&Arc>) -> Name { + link.map(|link| link.type_name_in_schema(name)) + .unwrap_or_else(|| name.clone()) +} + +/// Retrieves the actual directive name in the importing schema via `@link`; Otherwise, returns `name`. +fn actual_directive_name(name: &Name, link: Option<&Arc>) -> Name { + link.map(|link| link.directive_name_in_schema(name)) + .unwrap_or_else(|| name.clone()) } pub(crate) struct ScalarTypeSpecification { @@ -104,15 +155,24 @@ pub(crate) struct ScalarTypeSpecification { } impl TypeAndDirectiveSpecification for ScalarTypeSpecification { - fn check_or_add(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { - let existing = schema.try_get_type(self.name.clone()); + fn name(&self) -> &Name { + &self.name + } + + fn check_or_add( + &self, + schema: &mut FederationSchema, + link: Option<&Arc>, + ) -> Result<(), FederationError> { + let actual_name = actual_type_name(&self.name, link); + let existing = schema.try_get_type(actual_name.clone()); if let Some(existing) = existing { // Ignore redundant type specifications if they are are both scalar types. return ensure_expected_type_kind(TypeKind::Scalar, &existing); } let type_pos = ScalarTypeDefinitionPosition { - type_name: self.name.clone(), + type_name: actual_name, }; type_pos.pre_insert(schema)?; type_pos.insert( @@ -124,6 +184,10 @@ impl TypeAndDirectiveSpecification for ScalarTypeSpecification { }), ) } + + fn as_any(&self) -> &dyn Any { + self + } } pub(crate) struct ObjectTypeSpecification { @@ -132,9 +196,18 @@ pub(crate) struct ObjectTypeSpecification { } impl TypeAndDirectiveSpecification for ObjectTypeSpecification { - fn check_or_add(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { + fn name(&self) -> &Name { + &self.name + } + + fn check_or_add( + &self, + schema: &mut FederationSchema, + link: Option<&Arc>, + ) -> Result<(), FederationError> { + let actual_name = actual_type_name(&self.name, link); let field_specs = (self.fields)(schema); - let existing = schema.try_get_type(self.name.clone()); + let existing = schema.try_get_type(actual_name.clone()); if let Some(existing) = existing { // ensure existing definition is an object type ensure_expected_type_kind(TypeKind::Object, &existing)?; @@ -152,13 +225,13 @@ impl TypeAndDirectiveSpecification for ObjectTypeSpecification { } let mut field_map = IndexMap::default(); - for ref field_spec in field_specs { + for field_spec in field_specs { let field_def: FieldDefinition = field_spec.into(); - field_map.insert(field_spec.name.clone(), Component::new(field_def)); + field_map.insert(field_def.name.clone(), Component::new(field_def)); } let type_pos = ObjectTypeDefinitionPosition { - type_name: self.name.clone(), + type_name: actual_name, }; type_pos.pre_insert(schema)?; type_pos.insert( @@ -172,23 +245,34 @@ impl TypeAndDirectiveSpecification for ObjectTypeSpecification { }), ) } + + fn as_any(&self) -> &dyn Any { + self + } } -pub(crate) struct UnionTypeSpecification -where - F: Fn(&FederationSchema) -> IndexSet, -{ +type UnionTypeMembersFn = dyn Fn(&FederationSchema) -> IndexSet; + +pub(crate) struct UnionTypeSpecification { pub(crate) name: Name, - pub(crate) members: F, + pub(crate) members: Box, } -impl TypeAndDirectiveSpecification for UnionTypeSpecification -where - F: Fn(&FederationSchema) -> IndexSet, -{ - fn check_or_add(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { +impl TypeAndDirectiveSpecification for UnionTypeSpecification { + fn name(&self) -> &Name { + &self.name + } + + fn check_or_add( + &self, + schema: &mut FederationSchema, + link: Option<&Arc>, + ) -> Result<(), FederationError> { + let actual_name = actual_type_name(&self.name, link); let members = (self.members)(schema); - let existing = schema.try_get_type(self.name.clone()); + // PORT_NOTE: The JS version sorts the members by name. + // TODO(ROUTER-1223): Sort members here. Currently, doing it breaks `plugins::cache` tests. + let existing = schema.try_get_type(actual_name.clone()); // ensure new union has at least one member if members.is_empty() { @@ -207,10 +291,13 @@ where let existing_type = existing.get(schema.schema())?; let ExtendedType::Union(existing_union_type) = existing_type else { return Err(FederationError::internal(format!( - "Expected ExtendedType::Object but got {}", + "Expected ExtendedType::Union but got {}", TypeKind::from(existing_type) ))); }; + // This is kind of fragile in a core schema world where members may have been renamed, + // but we currently only use this one for the _Entity type where that shouldn't be an + // issue. if existing_union_type.members != members { let union_type_name = &self.name; let expected_member_names: Vec = existing_union_type @@ -229,7 +316,7 @@ where } let type_pos = UnionTypeDefinitionPosition { - type_name: self.name.clone(), + type_name: actual_name, }; type_pos.pre_insert(schema)?; type_pos.insert( @@ -242,6 +329,10 @@ where }), ) } + + fn as_any(&self) -> &dyn Any { + self + } } pub(crate) struct EnumValueSpecification { @@ -255,8 +346,17 @@ pub(crate) struct EnumTypeSpecification { } impl TypeAndDirectiveSpecification for EnumTypeSpecification { - fn check_or_add(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { - let existing = schema.try_get_type(self.name.clone()); + fn name(&self) -> &Name { + &self.name + } + + fn check_or_add( + &self, + schema: &mut FederationSchema, + link: Option<&Arc>, + ) -> Result<(), FederationError> { + let actual_name = actual_type_name(&self.name, link); + let existing = schema.try_get_type(actual_name.clone()); if let Some(existing) = existing { ensure_expected_type_kind(TypeKind::Enum, &existing)?; let existing_type = existing.get(schema.schema())?; @@ -272,20 +372,22 @@ impl TypeAndDirectiveSpecification for EnumTypeSpecification { .iter() .map(|val| val.0.clone()) .collect(); - let actual_value_set: IndexSet = + let expected_value_set: IndexSet = self.values.iter().map(|val| val.name.clone()).collect(); - if existing_value_set != actual_value_set { + if existing_value_set != expected_value_set { let enum_type_name = &self.name; - let expected_value_names: Vec = existing_value_set + let expected_value_names: Vec = expected_value_set .iter() + .sorted_by(|a, b| a.cmp(b)) .map(|name| name.to_string()) .collect(); - let actual_value_names: Vec = actual_value_set + let actual_value_names: Vec = existing_value_set .iter() + .sorted_by(|a, b| a.cmp(b)) .map(|name| name.to_string()) .collect(); return Err(SingleFederationError::TypeDefinitionInvalid { - message: format!("Invalid definition of type {enum_type_name}: expected values [{}] but found [{}].", + message: format!(r#"Invalid definition for type "{enum_type_name}": expected values [{}] but found [{}]."#, expected_value_names.join(", "), actual_value_names.join(", ")) }.into()); } @@ -293,7 +395,7 @@ impl TypeAndDirectiveSpecification for EnumTypeSpecification { } let type_pos = EnumTypeDefinitionPosition { - type_name: self.name.clone(), + type_name: actual_name, }; type_pos.pre_insert(schema)?; type_pos.insert( @@ -305,6 +407,8 @@ impl TypeAndDirectiveSpecification for EnumTypeSpecification { values: self .values .iter() + // PORT_NOTE: The JS version sorts the enum values by name. + // TODO(ROUTER-1223): Sort enum values here. (Also, see the union type above.) .map(|val| { ( val.name.clone(), @@ -319,6 +423,88 @@ impl TypeAndDirectiveSpecification for EnumTypeSpecification { }), ) } + + fn as_any(&self) -> &dyn Any { + self + } +} + +pub(crate) struct InputObjectTypeSpecification { + pub(crate) name: Name, + pub(crate) fields: fn(&FederationSchema) -> Vec, +} + +impl TypeAndDirectiveSpecification for InputObjectTypeSpecification { + fn name(&self) -> &Name { + &self.name + } + + fn check_or_add( + &self, + schema: &mut FederationSchema, + link: Option<&Arc>, + ) -> Result<(), FederationError> { + let actual_name = actual_type_name(&self.name, link); + let field_specs = (self.fields)(schema); + let existing = schema.try_get_type(actual_name.clone()); + if let Some(existing) = existing { + // ensure existing definition is InputObject + ensure_expected_type_kind(TypeKind::InputObject, &existing)?; + let existing_type = existing.get(schema.schema())?; + let ExtendedType::InputObject(existing_obj_type) = existing_type else { + return Err(FederationError::internal(format!( + "Expected ExtendedType::InputObject but got {}", + TypeKind::from(existing_type) + ))); + }; + + // ensure all expected fields are present in the existing object type + let mut new_definition_fields = Vec::with_capacity(field_specs.len()); + for field_spec in field_specs { + let field_def = field_spec.resolve(schema, link)?; + new_definition_fields.push(field_def); + } + let existing_definition_fields: Vec<_> = existing_obj_type + .fields + .values() + .map(|v| v.node.clone()) + .collect(); + let errors = ensure_same_arguments( + new_definition_fields.as_slice(), + existing_definition_fields.as_slice(), + schema, + format!("input object type {actual_name}").as_str(), + |s| SingleFederationError::TypeDefinitionInvalid { + message: s.to_string(), + }, + ); + return MultipleFederationErrors::from_iter(errors).into_result(); + } + + let mut field_map = IndexMap::default(); + for field_spec in field_specs { + let field_def: InputValueDefinition = field_spec.resolve(schema, link)?.into(); + field_map.insert(field_def.name.clone(), Component::new(field_def)); + } + + let type_pos = InputObjectTypeDefinitionPosition { + type_name: actual_name, + }; + type_pos.pre_insert(schema)?; + type_pos.insert( + schema, + Node::new(InputObjectType { + description: None, + name: type_pos.type_name.clone(), + directives: Default::default(), + fields: field_map, + }), + ) + } + + fn as_any(&self) -> &dyn Any { + self + } } ////////////////////////////////////////////////////////////////////////////// @@ -330,25 +516,37 @@ pub(crate) struct DirectiveArgumentSpecification { pub(crate) composition_strategy: Option, } -type ArgumentMergerFn = dyn Fn(&str, &[Value]) -> Value; +/// Merges the argument values by the specified strategy. +/// - `None` return value indicates that the merged value is undefined (meaning the argument +/// should be omitted). +/// - PORT_NOTE: The JS implementation could handle `undefined` input values. However, in Rust, +/// undefined values should be omitted in `values`, instead. +type ArgumentMergerFn = dyn Fn(&str, &[Value]) -> Result, FederationError>; pub(crate) struct ArgumentMerger { pub(crate) merge: Box, pub(crate) to_string: Box String>, } +/// Returns the version of directive spec definition required for the given Federation version to +/// be used in the supergraph. +type SupergraphSpecification = dyn Fn(&Version) -> Option<&'static dyn SpecDefinition>; + type ArgumentMergerFactory = - dyn Fn(&FederationSchema) -> Result; + dyn Fn(&FederationSchema, Option<&Arc>) -> Result; + +pub(crate) type StaticArgumentsTransform = + dyn Fn(&Subgraph, IndexMap) -> IndexMap; +#[derive(Clone)] pub(crate) struct DirectiveCompositionSpecification { - pub(crate) supergraph_specification: - fn( - federation_version: crate::link::spec::Version, - ) -> Box, + pub(crate) supergraph_specification: &'static SupergraphSpecification, /// Factory function returning an actual argument merger for given federation schema. - pub(crate) argument_merger: Option>, + pub(crate) argument_merger: Option>, + pub(crate) static_argument_transform: Option>, } +#[derive(Clone)] pub(crate) struct DirectiveSpecification { pub(crate) name: Name, pub(crate) composition: Option, @@ -357,9 +555,6 @@ pub(crate) struct DirectiveSpecification { locations: Vec, } -// TODO: revisit DirectiveSpecification::new() API once we start porting -// composition. -// https://apollographql.atlassian.net/browse/FED-172 impl DirectiveSpecification { pub(crate) fn new( name: Name, @@ -367,73 +562,41 @@ impl DirectiveSpecification { repeatable: bool, locations: &[DirectiveLocation], composes: bool, - supergraph_specification: Option< - fn( - federation_version: crate::link::spec::Version, - ) -> Box, - >, + supergraph_specification: Option<&'static SupergraphSpecification>, + static_argument_transform: Option>, ) -> Self { let mut composition: Option = None; if composes { - assert!( supergraph_specification.is_some(), - "Should provide a @link specification to use in supergraph for directive @{name} if it composes"); - let mut argument_merger: Option> = None; - let arg_strategies_iter = args - .iter() - .filter(|arg| arg.composition_strategy.is_some()) - .map(|arg| { - ( - arg.base_spec.name.to_string(), - arg.composition_strategy.unwrap(), - ) - }); + let Some(supergraph_specification) = supergraph_specification else { + panic!( + "Should provide a @link specification to use in supergraph for directive @{name} if it composes" + ); + }; + let mut argument_merger: Option> = None; + let arg_strategies_iter = args.iter().filter_map(|arg| { + Some((arg.base_spec.name.to_string(), arg.composition_strategy?)) + }); let arg_strategies: IndexMap = IndexMap::from_iter(arg_strategies_iter); if !arg_strategies.is_empty() { - assert!(!repeatable, "Invalid directive specification for @{name}: @{name} is repeatable and should not define composition strategy for its arguments"); - assert!(arg_strategies.len() == args.len(), "Invalid directive specification for @{name}: not all arguments define a composition strategy"); - let name_capture = name.clone(); - let args_capture = args.to_vec(); - argument_merger = Some(Box::new(move |schema: &FederationSchema| -> Result { - for arg in args_capture.iter() { - let strategy = arg.composition_strategy.as_ref().unwrap(); - let arg_name = &arg.base_spec.name; - let arg_type = (arg.base_spec.get_type)(schema)?; - assert!(!arg_type.is_list(), "Should have gotten error getting type for @{name_capture}({arg_name}:), but got {arg_type}"); - strategy.is_type_supported(schema, &arg_type).map_err(|support_msg| { - let strategy_name = strategy.name(); - SingleFederationError::DirectiveDefinitionInvalid { - message: format!("Invalid composition strategy {strategy_name} for argument @{name_capture}({arg_name}:) of type {arg_type}; {strategy_name} only supports ${support_msg}") - } - })?; - } - let arg_strategies_capture = arg_strategies.clone(); - let arg_strategies_capture2 = arg_strategies.clone(); - Ok(ArgumentMerger { - merge: Box::new(move |arg_name: &str, values: &[Value]| { - let Some(strategy) = arg_strategies_capture.get(arg_name) else { - panic!("`Should have a strategy for {arg_name}") - }; - strategy.merge_values(values) - }), - to_string: Box::new(move || { - if arg_strategies_capture2.is_empty() { - "".to_string() - } - else { - let arg_strategy_strings: Vec = arg_strategies_capture2 - .iter() - .map(|(arg_name, strategy)| format!("{arg_name}: {}", strategy.name())) - .collect(); - format!("{{ {} }}", arg_strategy_strings.join(", ")) - } - }), - }) - })); + assert!( + !repeatable, + "Invalid directive specification for @{name}: @{name} is repeatable and should not define composition strategy for its arguments" + ); + assert!( + arg_strategies.len() == args.len(), + "Invalid directive specification for @{name}: not all arguments define a composition strategy" + ); + argument_merger = Some(directive_argument_merger( + name.clone(), + args.to_vec(), + arg_strategies, + )); } composition = Some(DirectiveCompositionSpecification { - supergraph_specification: supergraph_specification.unwrap(), + supergraph_specification, argument_merger, + static_argument_transform, }) } Self { @@ -446,12 +609,66 @@ impl DirectiveSpecification { } } +fn directive_argument_merger( + directive_name: Name, + arg_specs: Vec, + arg_strategies: IndexMap, +) -> Rc { + Rc::new(move |schema, link| { + for arg in arg_specs.iter() { + let strategy = arg.composition_strategy.as_ref().unwrap(); + let arg_name = &arg.base_spec.name; + let arg_type = (arg.base_spec.get_type)(schema, link)?; + assert!( + !arg_type.is_list(), + "Should have gotten error getting type for @{directive_name}({arg_name}:), but got {arg_type}" + ); + strategy.is_type_supported(schema, &arg_type).map_err(|support_msg| { + let strategy_name = strategy.name(); + SingleFederationError::DirectiveDefinitionInvalid { + message: format!("Invalid composition strategy {strategy_name} for argument @{directive_name}({arg_name}:) of type {arg_type}; {strategy_name} only supports ${support_msg}") + } + })?; + } + let arg_strategies_capture = arg_strategies.clone(); + let arg_strategies_capture2 = arg_strategies.clone(); + Ok(ArgumentMerger { + merge: Box::new(move |arg_name: &str, values: &[Value]| { + let Some(strategy) = arg_strategies_capture.get(arg_name) else { + bail!("`Should have a strategy for {arg_name}") + }; + Ok(strategy.merge_values(values)) + }), + to_string: Box::new(move || { + if arg_strategies_capture2.is_empty() { + "".to_string() + } else { + let arg_strategy_strings: Vec = arg_strategies_capture2 + .iter() + .map(|(arg_name, strategy)| format!("{arg_name}: {}", strategy.name())) + .collect(); + format!("{{ {} }}", arg_strategy_strings.join(", ")) + } + }), + }) + }) +} + impl TypeAndDirectiveSpecification for DirectiveSpecification { - fn check_or_add(&self, schema: &mut FederationSchema) -> Result<(), FederationError> { + fn name(&self) -> &Name { + &self.name + } + + fn check_or_add( + &self, + schema: &mut FederationSchema, + link: Option<&Arc>, + ) -> Result<(), FederationError> { + let actual_name = actual_directive_name(&self.name, link); let mut resolved_args = Vec::new(); let mut errors = MultipleFederationErrors { errors: vec![] }; for arg in self.args.iter() { - match (arg.base_spec.get_type)(schema) { + match (arg.base_spec.get_type)(schema, link) { Ok(arg_type) => { resolved_args.push(ResolvedArgumentSpecification { name: arg.base_spec.name.clone(), @@ -460,17 +677,17 @@ impl TypeAndDirectiveSpecification for DirectiveSpecification { }); } Err(err) => { - errors.errors.push(err); + errors.push(err); } }; } errors.into_result()?; - let existing = schema.get_directive_definition(&self.name); + let existing = schema.get_directive_definition(&actual_name); if let Some(existing) = existing { let existing_directive = existing.get(schema.schema())?; return ensure_same_directive_structure( existing_directive, - &self.name, + &actual_name, &resolved_args, self.repeatable, &self.locations, @@ -479,16 +696,16 @@ impl TypeAndDirectiveSpecification for DirectiveSpecification { } let directive_pos = DirectiveDefinitionPosition { - directive_name: self.name.clone(), + directive_name: actual_name.clone(), }; directive_pos.pre_insert(schema)?; directive_pos.insert( schema, Node::new(DirectiveDefinition { description: None, - name: self.name.clone(), + name: actual_name, arguments: resolved_args - .iter() + .into_iter() .map(|arg| Node::new(arg.into())) .collect(), repeatable: self.repeatable, @@ -496,10 +713,18 @@ impl TypeAndDirectiveSpecification for DirectiveSpecification { }), ) } + + fn as_any(&self) -> &dyn Any { + self + } } ////////////////////////////////////////////////////////////////////////////// // Helper functions for TypeSpecification implementations +// Argument naming conventions: +// - `existing` or `actual`: the existing definition as defined in the schema. +// - `expected`: the expected definition either by the Federation assumption or from the +// TypeAndDirectiveSpecification. // TODO: Consider moving this to the schema module. #[derive(Clone, PartialEq, Eq, Hash, derive_more::Display)] @@ -596,13 +821,13 @@ fn is_valid_input_type_redefinition( fn default_value_message(value: Option<&Value>) -> String { match value { None => "no default value".to_string(), - Some(value) => format!("default value {}", value), + Some(value) => format!("default value {value}"), } } fn ensure_same_arguments( - expected: &[Node], - actual: &[ResolvedArgumentSpecification], + expected: &[ResolvedArgumentSpecification], + actual: &[Node], schema: &FederationSchema, what: &str, generate_error: fn(&str) -> SingleFederationError, @@ -612,7 +837,7 @@ fn ensure_same_arguments( // ensure expected arguments are a subset of actual arguments. for expected_arg in expected { let actual_arg = actual.iter().find(|x| x.name == expected_arg.name); - if actual_arg.is_none() { + let Some(actual_arg) = actual_arg else { // Not declaring an optional argument is ok: that means you won't be able to pass a non-default value in your schema, but we allow you that. // But missing a required argument it not ok. if expected_arg.ty.is_non_null() && expected_arg.default_value.is_none() { @@ -622,12 +847,11 @@ fn ensure_same_arguments( ))); } continue; - } + }; // ensure expected argument and actual argument have the same type. - let actual_arg = actual_arg.unwrap(); // TODO: Make it easy to get a cloned (inner) type from a Node. - let mut actual_type = actual_arg.ty.clone(); + let mut actual_type = actual_arg.ty.as_ref().clone(); if actual_type.is_non_null() && !expected_arg.ty.is_non_null() { // It's ok to redefine an optional argument as mandatory. For instance, if you want to force people on your team to provide a "deprecation reason", you can // redefine @deprecated as `directive @deprecated(reason: String!)...` to get validation. In other words, you are allowed to always pass an argument that @@ -636,22 +860,22 @@ fn ensure_same_arguments( } // ensure argument type is compatible with the expected one and // argument's default value (if any) is compatible with the expected one - if *expected_arg.ty != actual_type - && is_valid_input_type_redefinition(&expected_arg.ty, &actual_type, schema) + if expected_arg.ty != actual_type + && !is_valid_input_type_redefinition(&expected_arg.ty, &actual_type, schema) { let arg_name = &expected_arg.name; let expected_type = &expected_arg.ty; errors.push(generate_error(&format!( - r#"Invalid definition for {what}: Argument "{arg_name}" should have type {expected_type} but found type {actual_type}"# + r#"Invalid definition for {what}: argument "{arg_name}" should have type "{expected_type}" but found type "{actual_type}""# ))); - } else if !actual_type.is_non_null() - && expected_arg.default_value.as_deref() != actual_arg.default_value.as_ref() + } else if !actual_arg.ty.is_non_null() // we mutate actual_type above, so we need to check against the original + && expected_arg.default_value.as_ref() != actual_arg.default_value.as_deref() { let arg_name = &expected_arg.name; - let expected_value = default_value_message(expected_arg.default_value.as_deref()); - let actual_value = default_value_message(actual_arg.default_value.as_ref()); + let expected_value = default_value_message(expected_arg.default_value.as_ref()); + let actual_value = default_value_message(actual_arg.default_value.as_deref()); errors.push(generate_error(&format!( - r#"Invalid definition for {what}: Argument "{arg_name}" should have {expected_value} but found {actual_value}"# + r#"Invalid definition for {what}: argument "{arg_name}" should have {expected_value} but found {actual_value}"# ))); } } @@ -671,44 +895,51 @@ fn ensure_same_arguments( errors } +// The `existing_obj_type` is the definition that is defined in the schema. +// And the `expected_fields` are the expected fields from the specification. +// The existing (= actual) field definitions must be compatible with the expected ones. fn ensure_same_fields( existing_obj_type: &ObjectType, - actual_fields: &[FieldSpecification], + expected_fields: &[FieldSpecification], schema: &FederationSchema, ) -> Vec { let obj_type_name = existing_obj_type.name.clone(); let mut errors = vec![]; - // ensure all actual fields are a subset of the existing object type's fields. - for actual_field_def in actual_fields { - let actual_field_name = &actual_field_def.name; - let expected_field = existing_obj_type.fields.get(actual_field_name); - if expected_field.is_none() { + // ensure all expected fields are a subset of the existing object type's fields. + for expected_field_def in expected_fields { + let field_name = &expected_field_def.name; + let existing_field = existing_obj_type.fields.get(field_name); + let Some(existing_field) = existing_field else { errors.push(SingleFederationError::TypeDefinitionInvalid { message: format!( - "Invalid definition of type {}: missing field {}", - obj_type_name, actual_field_name + "Invalid definition of type {obj_type_name}: missing field {field_name}" ), }); continue; - } + }; // ensure field types are as expected - let expected_field = expected_field.unwrap(); - if actual_field_def.ty != expected_field.ty { - let expected_field_type = &expected_field.ty; - let actual_field_type = &actual_field_def.ty; + // We allow adding non-nullability because we've seen redefinition of the federation + // _Service type with type String! for the `sdl` field and we don't want to break backward + // compatibility as this doesn't feel too harmful. + let mut existing_field_type = existing_field.ty.clone(); + if !expected_field_def.ty.is_non_null() && existing_field_type.is_non_null() { + existing_field_type = existing_field_type.nullable(); + } + if expected_field_def.ty != existing_field_type { + let expected_field_type = &expected_field_def.ty; errors.push(SingleFederationError::TypeDefinitionInvalid { - message: format!("Invalid definition for field {actual_field_name} of type {obj_type_name}: should have type {expected_field_type} but found type {actual_field_type}") + message: format!("Invalid definition for field {field_name} of type {obj_type_name}: should have type {expected_field_type} but found type {existing_field_type}") }); } // ensure field arguments are as expected let mut arg_errors = ensure_same_arguments( - &expected_field.arguments, - &actual_field_def.arguments, + &expected_field_def.arguments, + &existing_field.arguments, schema, - &format!(r#"field "{}.{}""#, obj_type_name, expected_field.name), + &format!(r#"field "{}.{}""#, obj_type_name, existing_field.name), |s| SingleFederationError::TypeDefinitionInvalid { message: s.to_string(), }, @@ -719,6 +950,9 @@ fn ensure_same_fields( errors } +// The `existing_directive` is the definition that is defined in the schema. +// And the rest of arguments are the expected directive definition from the specification. +// The existing (= actual) definition must be compatible with the expected one. fn ensure_same_directive_structure( existing_directive: &DirectiveDefinition, name: &Name, @@ -729,20 +963,20 @@ fn ensure_same_directive_structure( ) -> Result<(), FederationError> { let directive_name = format!("@{name}"); let mut arg_errors = ensure_same_arguments( - &existing_directive.arguments, args, + &existing_directive.arguments, schema, - &format!(r#"directive {directive_name}"#), + &format!(r#"directive "{directive_name}""#), |s| SingleFederationError::DirectiveDefinitionInvalid { message: s.to_string(), }, ); // It's ok to say you'll never repeat a repeatable directive. It's not ok to repeat one that isn't. - if !existing_directive.repeatable && repeatable { + if existing_directive.repeatable && !repeatable { arg_errors.push(SingleFederationError::DirectiveDefinitionInvalid { message: format!( - "Invalid definition for directive {directive_name}: {directive_name} should not be repeatable" + r#"Invalid definition for directive "{directive_name}": "{directive_name}" should not be repeatable"# ), }); } @@ -750,11 +984,12 @@ fn ensure_same_directive_structure( // Similarly, it's ok to say that you will never use a directive in some locations, but not that // you will use it in places not allowed by what is expected. // Ensure `locations` is a subset of `existing_directive.locations`. - if !locations + if !existing_directive + .locations .iter() - .all(|loc| existing_directive.locations.contains(loc)) + .all(|loc| locations.contains(loc)) { - let actual_locations: Vec = locations.iter().map(|loc| loc.to_string()).collect(); + let expected_locations: Vec = locations.iter().map(|loc| loc.to_string()).collect(); let existing_locations: Vec = existing_directive .locations .iter() @@ -762,8 +997,8 @@ fn ensure_same_directive_structure( .collect(); arg_errors.push(SingleFederationError::DirectiveDefinitionInvalid { message: format!( - "Invalid definition for directive {directive_name}: {directive_name} should have locations [{}] but found [{}]", - existing_locations.join(", "), actual_locations.join(", ") + r#"Invalid definition for directive "{directive_name}": "{directive_name}" should have locations {}, but found (non-subset) {}"#, + expected_locations.join(", "), existing_locations.join(", ") ), }); } @@ -778,14 +1013,12 @@ mod tests { use super::ArgumentSpecification; use super::DirectiveArgumentSpecification; - use crate::error::SingleFederationError; - use crate::link::link_spec_definition::LinkSpecDefinition; - use crate::link::spec::Identity; + use crate::link::link_spec_definition::LINK_VERSIONS; use crate::link::spec::Version; use crate::link::spec_definition::SpecDefinition; + use crate::schema::FederationSchema; use crate::schema::argument_composition_strategies::ArgumentCompositionStrategy; use crate::schema::type_and_directive_specification::DirectiveSpecification; - use crate::schema::FederationSchema; #[test] #[should_panic( @@ -799,6 +1032,7 @@ mod tests { &[DirectiveLocation::Object], true, None, + None, ); } @@ -807,27 +1041,15 @@ mod tests { expected = "Invalid directive specification for @foo: not all arguments define a composition strategy" )] fn must_have_a_merge_strategy_on_all_arguments_if_any() { - fn link_spec(_version: Version) -> Box { - Box::new(LinkSpecDefinition::new( - Version { major: 1, minor: 0 }, - None, - Identity { - domain: String::from("https://specs.apollo.dev/link/v1.0"), - name: name!("link"), - }, - )) - } - DirectiveSpecification::new( name!("foo"), &[ DirectiveArgumentSpecification { base_spec: ArgumentSpecification { name: name!("v1"), - get_type: - move |_schema: &FederationSchema| -> Result { - Ok(Type::Named(name!("Int"))) - }, + get_type: move |_schema: &FederationSchema, _link| { + Ok(Type::Named(name!("Int"))) + }, default_value: None, }, composition_strategy: Some(ArgumentCompositionStrategy::Max), @@ -835,10 +1057,9 @@ mod tests { DirectiveArgumentSpecification { base_spec: ArgumentSpecification { name: name!("v2"), - get_type: - move |_schema: &FederationSchema| -> Result { - Ok(Type::Named(name!("Int"))) - }, + get_type: move |_schema: &FederationSchema, _link| { + Ok(Type::Named(name!("Int"))) + }, default_value: None, }, composition_strategy: None, @@ -847,7 +1068,12 @@ mod tests { false, &[DirectiveLocation::Object], true, - Some(link_spec) + Some(&|_| { + LINK_VERSIONS + .find(&Version { major: 1, minor: 0 }) + .map(|v| v as &dyn SpecDefinition) + }), + None, ); } @@ -856,26 +1082,12 @@ mod tests { expected = "Invalid directive specification for @foo: @foo is repeatable and should not define composition strategy for its arguments" )] fn must_be_not_be_repeatable_if_it_has_a_merge_strategy() { - fn link_spec(_version: Version) -> Box { - Box::new(LinkSpecDefinition::new( - Version { major: 1, minor: 0 }, - None, - Identity { - domain: String::from("https://specs.apollo.dev/link/v1.0"), - name: name!("link"), - }, - )) - } - DirectiveSpecification::new( name!("foo"), &[DirectiveArgumentSpecification { base_spec: ArgumentSpecification { name: name!("v"), - get_type: - move |_schema: &FederationSchema| -> Result { - Ok(Type::Named(name!("Int"))) - }, + get_type: move |_schema, _link| Ok(Type::Named(name!("Int"))), default_value: None, }, composition_strategy: Some(ArgumentCompositionStrategy::Max), @@ -883,7 +1095,12 @@ mod tests { true, &[DirectiveLocation::Object], true, - Some(link_spec), + Some(&|_| { + LINK_VERSIONS + .find(&Version { major: 1, minor: 0 }) + .map(|v| v as &dyn SpecDefinition) + }), + None, ); } } diff --git a/apollo-federation/src/schema/validators/cache_tag.rs b/apollo-federation/src/schema/validators/cache_tag.rs new file mode 100644 index 0000000000..40ca8a459e --- /dev/null +++ b/apollo-federation/src/schema/validators/cache_tag.rs @@ -0,0 +1,674 @@ +use std::fmt; +use std::ops::Range; + +use apollo_compiler::Name; +use apollo_compiler::ast; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::executable; +use apollo_compiler::executable::SelectionSet; +use apollo_compiler::parser::LineColumn; +use apollo_compiler::validation::Valid; +use itertools::Itertools; + +use crate::bail; +use crate::connectors::ConnectSpec; +use crate::connectors::SelectionTrie; +use crate::connectors::StringTemplate; +use crate::connectors::StringTemplateError; +use crate::connectors::spec::connect_spec_from_schema; +use crate::error::ErrorCode; +use crate::error::FederationError; +use crate::internal_error; +use crate::link::federation_spec_definition::CacheTagDirectiveArguments; +use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; +use crate::schema::FederationSchema; +use crate::schema::position::DirectiveTargetPosition; +use crate::schema::position::ObjectFieldDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceTypeDefinitionPosition; +use crate::schema::position::ObjectTypeDefinitionPosition; +use crate::schema::position::TypeDefinitionPosition; + +const DEFAULT_CONNECT_SPEC: ConnectSpec = ConnectSpec::V0_2; + +pub(crate) fn validate_cache_tag_directives( + schema: &FederationSchema, + errors: &mut Vec, +) -> Result<(), FederationError> { + let applications = schema.cache_tag_directive_applications()?; + for application in applications { + match application { + Ok(cache_tag_directive) => match &cache_tag_directive.target { + DirectiveTargetPosition::ObjectField(field) => validate_application_on_field( + schema, + errors, + field, + &cache_tag_directive.arguments, + )?, + DirectiveTargetPosition::ObjectType(type_pos) => { + validate_application_on_object_type( + schema, + errors, + type_pos, + &cache_tag_directive.arguments, + )?; + } + _ => bail!("Unexpected directive target"), + }, + Err(error) => errors.push(Message { + error: CacheTagValidationError::FederationError { error }, + locations: Vec::new(), + }), + } + } + Ok(()) +} + +fn validate_application_on_field( + schema: &FederationSchema, + errors: &mut Vec, + field: &ObjectFieldDefinitionPosition, + args: &CacheTagDirectiveArguments, +) -> Result<(), FederationError> { + let field_def = field.get(schema.schema())?; + + // validate it's on a root field + if !schema.is_root_type(&field.type_name) { + let error = CacheTagValidationError::CacheTagNotOnRootField { + type_name: field.type_name.clone(), + field_name: field.field_name.clone(), + }; + errors.push(Message::new(schema, field_def, error)); + return Ok(()); + } + + // validate the arguments + validate_args_on_field(schema, errors, field, args)?; + Ok(()) +} + +fn validate_application_on_object_type( + schema: &FederationSchema, + errors: &mut Vec, + type_pos: &ObjectTypeDefinitionPosition, + args: &CacheTagDirectiveArguments, +) -> Result<(), FederationError> { + // validate the type is a resolvable entity + let fed_spec = get_federation_spec_definition_from_subgraph(schema)?; + let key_directive_def = fed_spec.key_directive_definition(schema)?; + let type_def = type_pos.get(schema.schema())?; + let is_resolvable = type_def + .directives + .get_all(&key_directive_def.name) + .map(|directive_app| { + let key_args = fed_spec.key_directive_arguments(directive_app)?; + Ok::<_, FederationError>(key_args.resolvable) + }) + .process_results(|mut iter| iter.any(|x| x))?; + if !is_resolvable { + let error = + CacheTagValidationError::CacheTagEntityNotResolvable(type_pos.type_name.clone()); + errors.push(Message::new(schema, type_def, error)); + return Ok(()); + } + + // validate the arguments + validate_args_on_object_type(schema, errors, type_pos, args)?; + Ok(()) +} + +fn validate_args_on_field( + schema: &FederationSchema, + errors: &mut Vec, + field: &ObjectFieldDefinitionPosition, + args: &CacheTagDirectiveArguments, +) -> Result<(), FederationError> { + let field_def = field.get(schema.schema())?; + let connect_spec = connect_spec_from_schema(schema.schema()).unwrap_or(DEFAULT_CONNECT_SPEC); + let format = match StringTemplate::parse_with_spec(args.format, connect_spec) { + Ok(format) => format, + Err(err) => { + errors.push(Message::new(schema, field_def, err.into())); + return Ok(()); + } + }; + let new_errors = format.expressions().filter_map(|expr| { + expr.expression.if_named_else_path( + |_named| { + Some(CacheTagValidationError::CacheTagInvalidFormat { + message: format!("\"{}\"", expr.expression), + }) + }, + |path| match path.variable_reference::() { + Some(var_ref) => { + // Check the namespace + if var_ref.namespace.namespace != "$args" { + return Some( + CacheTagValidationError::CacheTagInvalidFormatArgumentOnRootField, + ); + } + + // Check the selection + let fields = field_def + .arguments + .iter() + .map(|arg| (arg.name.clone(), arg.ty.as_ref())) + .collect::>(); + match validate_args_selection(schema, &fields, &var_ref.selection) { + Ok(_) => None, + Err(_err) => Some(CacheTagValidationError::CacheTagFormatArgumentUnknown { + type_name: field.type_name.clone(), + field_name: field.field_name.clone(), + format: format.to_string(), + }), + } + } + None => None, + }, + ) + }); + errors.extend(new_errors.map(|err| Message::new(schema, field_def, err))); + Ok(()) +} + +fn validate_args_selection( + schema: &FederationSchema, + fields: &IndexMap, + selection: &SelectionTrie, +) -> Result<(), CacheTagValidationError> { + for (key, sel) in selection.iter() { + let name = Name::new(key).map_err(|_| CacheTagValidationError::CacheTagInvalidFormat { + message: format!("invalid field selection name \"{key}\""), + })?; + let field = + fields + .get(&name) + .ok_or_else(|| CacheTagValidationError::CacheTagInvalidFormat { + message: format!("unknown field \"{name}\""), + })?; + let type_name = field.inner_named_type(); + let type_def = schema.get_type(type_name.clone())?; + if !sel.is_leaf() { + let type_def = ObjectOrInterfaceTypeDefinitionPosition::try_from(type_def).map_err( + |_| CacheTagValidationError::CacheTagInvalidFormat { + message: format!( + "invalid path element \"{name}\", which is not an object or interface type" + ), + }, + )?; + let next_fields = type_def + .fields(schema.schema())? + .map(|field_pos| { + let field_def = field_pos + .get(schema.schema()) + .map_err(FederationError::from)?; + Ok::<_, CacheTagValidationError>(( + field_pos.field_name().clone(), + &field_def.ty, + )) + }) + .collect::, _>>()?; + validate_args_selection(schema, &next_fields, sel)?; + } else { + // A leaf field should have a scalar type. + if !matches!(&type_def, TypeDefinitionPosition::Scalar(_)) { + return Err(CacheTagValidationError::CacheTagInvalidFormat { + message: format!( + "invalid path ending at \"{name}\", which is not a scalar type" + ), + }); + } + } + } + Ok(()) +} + +fn validate_args_on_object_type( + schema: &FederationSchema, + errors: &mut Vec, + type_pos: &ObjectTypeDefinitionPosition, + args: &CacheTagDirectiveArguments, +) -> Result<(), FederationError> { + let type_def = type_pos.get(schema.schema())?; + let connect_spec = connect_spec_from_schema(schema.schema()).unwrap_or(DEFAULT_CONNECT_SPEC); + let format = match StringTemplate::parse_with_spec(args.format, connect_spec) { + Ok(format) => format, + Err(err) => { + errors.push(Message::new(schema, type_def, err.into())); + return Ok(()); + } + }; + let res = format.expressions().filter_map(|expr| { + expr.expression.if_named_else_path( + |_named| { + Some(Err(CacheTagValidationError::CacheTagInvalidFormat { + message: format!("\"{}\"", expr.expression), + })) + }, + |path| match path.variable_reference::() { + Some(var_ref) => { + // Check the namespace + if var_ref.namespace.namespace != "$key" { + return Some(Err( + CacheTagValidationError::CacheTagInvalidFormatArgumentOnEntity { + type_name: type_pos.type_name.clone(), + format: format.to_string(), + }, + )); + } + + // Build the selection set based on what's in the variable, so if it's + // $key.a.b it will generate { a { b } } + let mut selection_set = SelectionSet::new(type_pos.type_name.clone()); + match build_selection_set(&mut selection_set, schema, &var_ref.selection) { + Ok(_) => Some(Ok(selection_set)), + Err(err) => Some(Err(err)), + } + } + None => None, + }, + ) + }); + let mut format_selections = Vec::new(); + let mut has_error = false; + for item in res { + match item { + Ok(sel) => format_selections.push(executable::FieldSet { + selection_set: sel, + sources: Default::default(), + }), + Err(err) => { + has_error = true; + errors.push(Message::new(schema, type_def, err)); + } + } + } + if has_error { + return Ok(()); + } + + // Check if all field sets coming from all collected StringTemplate ($key.a.b) from cacheTag + // directives are each a subset of each entity keys + let entity_key_field_sets = get_entity_key_field_sets(schema, type_pos)?; + let is_correct = format_selections.into_iter().all(|format_field_set| { + entity_key_field_sets.iter().all(|key_field_set| { + crate::connectors::field_set_is_subset(&format_field_set, key_field_set) + }) + }); + + if !is_correct { + let error = CacheTagValidationError::CacheTagInvalidFormatFieldSetOnEntity { + type_name: type_pos.type_name.clone(), + format: format.to_string(), + }; + errors.push(Message::new(schema, type_def, error)); + } + Ok(()) +} + +fn get_entity_key_field_sets( + schema: &FederationSchema, + type_pos: &ObjectTypeDefinitionPosition, +) -> Result, FederationError> { + let fed_spec = get_federation_spec_definition_from_subgraph(schema)?; + let key_directive_def = fed_spec.key_directive_definition(schema)?; + let type_def = type_pos.get(schema.schema())?; + type_def + .directives + .get_all(&key_directive_def.name) + .map(|directive_app| { + let key_args = fed_spec.key_directive_arguments(directive_app)?; + executable::FieldSet::parse( + Valid::assume_valid_ref(schema.schema()), + type_pos.type_name.clone(), + key_args.fields, + "field_set", + ) + .map_err(|err| internal_error!("cannot parse field set for entity keys: {err}")) + }) + .process_results(|iter| iter.collect()) +} + +/// Build the selection set based on what's in the variable, so if it's $key.a.b it will generate { a { b } } +fn build_selection_set( + selection_set: &mut SelectionSet, + schema: &FederationSchema, + selection: &SelectionTrie, +) -> Result<(), CacheTagValidationError> { + for (key, sel) in selection.iter() { + let name = Name::new(key).map_err(|_| CacheTagValidationError::CacheTagInvalidFormat { + message: format!("invalid field selection name \"{key}\""), + })?; + let mut new_field = selection_set + .new_field(schema.schema(), name.clone()) + .map_err(|_| CacheTagValidationError::CacheTagInvalidFormat { + message: format!("cannot create selection set with \"{key}\""), + })?; + let new_field_type_def = schema + .get_type(new_field.ty().inner_named_type().clone()) + .map_err(|_| CacheTagValidationError::CacheTagInvalidFormat { + message: format!("invalid field selection name \"{key}\""), + })?; + + if !sel.is_leaf() { + ObjectOrInterfaceTypeDefinitionPosition::try_from(new_field_type_def).map_err( + |_| CacheTagValidationError::CacheTagInvalidFormat { + message: format!( + "invalid path element \"{name}\", which is not an object or interface type" + ), + }, + )?; + build_selection_set(&mut new_field.selection_set, schema, sel)?; + } else { + // A leaf field should have a scalar type. + if !matches!(&new_field_type_def, TypeDefinitionPosition::Scalar(_)) { + return Err(CacheTagValidationError::CacheTagInvalidFormat { + message: format!( + "invalid path ending at \"{name}\", which is not a scalar type" + ), + }); + } + } + selection_set.push(new_field); + } + + Ok(()) +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +// Error messages + +// Note: This is modeled after the Connectors' `Message` struct. +#[derive(Debug, Clone)] +pub struct Message { + error: CacheTagValidationError, + pub locations: Vec>, +} + +impl Message { + fn new( + schema: &FederationSchema, + node: &apollo_compiler::Node, + error: CacheTagValidationError, + ) -> Self { + Self { + error, + locations: schema.node_locations(node).collect(), + } + } + + pub fn code(&self) -> String { + self.error.code() + } + + pub fn message(&self) -> String { + self.error.to_string() + } +} + +impl fmt::Display for Message { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.error) + } +} + +/// `@cacheTag` validation errors +// Note: This is expected to be merged with `CompositionError` and Connectors errors later. +#[derive(Debug, Clone, thiserror::Error, strum_macros::IntoStaticStr)] +#[strum(serialize_all = "SCREAMING_SNAKE_CASE")] +enum CacheTagValidationError { + #[error("{error}")] + FederationError { error: FederationError }, + #[error("cacheTag format is invalid: {message}")] + CacheTagInvalidFormat { message: String }, + #[error( + "error on field \"{field_name}\" on type \"{type_name}\": cacheTag can only apply on root fields" + )] + CacheTagNotOnRootField { type_name: Name, field_name: Name }, + #[error("cacheTag applied on root fields can only reference arguments in format using $args")] + CacheTagInvalidFormatArgumentOnRootField, + #[error("cacheTag applied on types can only reference arguments in format using $key")] + CacheTagInvalidFormatArgumentOnEntity { type_name: Name, format: String }, + #[error( + "Object \"{0}\" is not an entity. cacheTag can only apply on resolvable entities, object containing at least 1 @key directive and resolvable" + )] + CacheTagEntityNotResolvable(Name), + #[error( + "Unknown arguments used with $args in cacheTag format \"{format}\" on field \"{field_name}\" for type \"{type_name}\"" + )] + CacheTagFormatArgumentUnknown { + type_name: Name, + field_name: Name, + format: String, + }, + #[error( + "Each entity field referenced in a @cacheTag format (applied on entity type) must be a member of every @key field set. In other words, when there are multiple @key fields on the type, the referenced field(s) must be limited to their intersection. Bad cacheTag format \"{format}\" on type \"{type_name}\"" + )] + CacheTagInvalidFormatFieldSetOnEntity { type_name: Name, format: String }, +} + +impl CacheTagValidationError { + fn code(&self) -> String { + match self { + // Special handling for FederationError + CacheTagValidationError::FederationError { error } => match error { + FederationError::SingleFederationError(inner) => { + inner.code().definition().code().to_string() + } + FederationError::MultipleFederationErrors(inner) => { + let code = match inner.errors.first() { + // Error is unexpectedly empty. Treat it as an internal error. + None => ErrorCode::Internal, + Some(e) => e.code(), + }; + // Convert to string + code.definition().code().to_string() + } + FederationError::AggregateFederationError(inner) => inner.code.clone(), + }, + // For the rest of cases + _ => { + let code: &str = self.into(); + code.to_string() + } + } + } +} + +impl From for CacheTagValidationError { + fn from(error: FederationError) -> Self { + CacheTagValidationError::FederationError { error } + } +} + +impl From for CacheTagValidationError { + fn from(error: StringTemplateError) -> Self { + CacheTagValidationError::CacheTagInvalidFormat { + message: error.to_string(), + } + } +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +// Unit tests + +#[cfg(test)] +mod tests { + use super::*; + use crate::subgraph::test_utils::BuildOption; + use crate::subgraph::test_utils::build_inner_expanded; + + #[test] + fn test_api_test() { + const SCHEMA: &str = r#" + type Product @key(fields: "upc") + @cacheTag(format: "{ namedField }") + @cacheTag(format: "{$args}") + { + upc: String! + name: String + } + + type Query { + topProducts(first: Int = 5): [Product] + @cacheTag(format: "{$this}") + @cacheTag(format: "{$key}") + } + "#; + + let subgraph = build_inner_expanded(SCHEMA, BuildOption::AsFed2).unwrap(); + let mut errors = Vec::new(); + validate_cache_tag_directives(subgraph.schema(), &mut errors).unwrap(); + assert_eq!( + errors.iter().map(|e| e.code()).collect::>(), + vec![ + "CACHE_TAG_INVALID_FORMAT", + "CACHE_TAG_INVALID_FORMAT_ARGUMENT_ON_ENTITY", + "CACHE_TAG_INVALID_FORMAT_ARGUMENT_ON_ROOT_FIELD", + "CACHE_TAG_INVALID_FORMAT_ARGUMENT_ON_ROOT_FIELD", + ], + ); + } + + #[track_caller] + fn build_and_validate(schema: &str) { + let subgraph = build_inner_expanded(schema, BuildOption::AsFed2).unwrap(); + let mut errors = Vec::new(); + validate_cache_tag_directives(subgraph.schema(), &mut errors).unwrap(); + assert!(errors.is_empty()); + } + + #[track_caller] + fn build_for_errors(schema: &str) -> Vec { + let subgraph = build_inner_expanded(schema, BuildOption::AsFed2).unwrap(); + let mut errors = Vec::new(); + validate_cache_tag_directives(subgraph.schema(), &mut errors).unwrap(); + errors.iter().map(|e| e.to_string()).collect() + } + + #[test] + fn test_valid_format_string() { + const SCHEMA: &str = r#" + type Product @key(fields: "upc") + @cacheTag(format: "product-{$key.upc}") + { + upc: String! + name: String + } + + type Query { + topProducts(first: Int = 5): [Product] + @cacheTag(format: "topProducts") + @cacheTag(format: "topProducts-{$args.first}") + } + "#; + build_and_validate(SCHEMA); + } + + #[test] + fn test_invalid_format_selection() { + const SCHEMA: &str = r#" + type Product @key(fields: "upc") + @cacheTag(format: "{ namedField }") + @cacheTag(format: "{$args}") + { + upc: String! + name: String + } + + type Query { + topProducts(first: Int = 5): [Product] + @cacheTag(format: "{$this}") + @cacheTag(format: "{$key}") + } + "#; + assert_eq!( + build_for_errors(SCHEMA), + vec![ + "cacheTag format is invalid: \"namedField\"", + "cacheTag applied on types can only reference arguments in format using $key", + "cacheTag applied on root fields can only reference arguments in format using $args", + "cacheTag applied on root fields can only reference arguments in format using $args", + ] + ); + } + + #[test] + fn test_invalid_format_path_selection() { + const SCHEMA: &str = r#" + type Test { + a: Int! + b: Int! + } + + type Product @key(fields: "upc test { a }") + @cacheTag(format: "product-{$key.somethingElse}") + @cacheTag(format: "product-{$key.test}") + @cacheTag(format: "product-{$key.test.a}") + @cacheTag(format: "product-{$key.test.b}") + { + upc: String! + test: Test! + name: String + } + + type Query { + topProducts(first: Int = 5): [Product] + @cacheTag(format: "topProducts") + @cacheTag(format: "topProducts-{$args.second}") + } + "#; + assert_eq!( + build_for_errors(SCHEMA), + vec![ + "cacheTag format is invalid: cannot create selection set with \"somethingElse\"", + "cacheTag format is invalid: invalid path ending at \"test\", which is not a scalar type", + "Each entity field referenced in a @cacheTag format (applied on entity type) must be a member of every @key field set. In other words, when there are multiple @key fields on the type, the referenced field(s) must be limited to their intersection. Bad cacheTag format \"product-{$key.test.b}\" on type \"Product\"", + "Unknown arguments used with $args in cacheTag format \"topProducts-{$args.second}\" on field \"topProducts\" for type \"Query\"", + ] + ); + } + + #[test] + fn test_valid_format_string_multiple_keys() { + const SCHEMA: &str = r#" + type Product @key(fields: "upc x") + @key(fields: "upc y") + @cacheTag(format: "product-{$key.upc}") + { + upc: String! + x: Int! + y: Int! + name: String + } + "#; + build_and_validate(SCHEMA); + } + + #[test] + fn test_invalid_format_string_multiple_keys() { + const SCHEMA: &str = r#" + type Product @key(fields: "upc x") + @key(fields: "upc y") + @cacheTag(format: "product-{$key.x}") + { + upc: String! + x: Int! + y: Int! + name: String + } + "#; + assert_eq!( + build_for_errors(SCHEMA), + vec![ + "Each entity field referenced in a @cacheTag format (applied on entity type) must be a member of every @key field set. In other words, when there are multiple @key fields on the type, the referenced field(s) must be limited to their intersection. Bad cacheTag format \"product-{$key.x}\" on type \"Product\"" + ] + ); + } + + #[test] + fn test_latest_connect_spec() { + // This test exists to find out when ConnectSpec::latest() changes, so + // we can decide whether to update DEFAULT_CONNECT_SPEC. + assert_eq!(DEFAULT_CONNECT_SPEC, ConnectSpec::latest()); + } +} diff --git a/apollo-federation/src/schema/validators/context.rs b/apollo-federation/src/schema/validators/context.rs new file mode 100644 index 0000000000..56e792282c --- /dev/null +++ b/apollo-federation/src/schema/validators/context.rs @@ -0,0 +1,164 @@ +use std::collections::HashMap; + +use apollo_compiler::Name; + +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::schema::FederationSchema; + +pub(crate) fn validate_context_directives( + schema: &FederationSchema, + errors: &mut MultipleFederationErrors, +) -> Result>, FederationError> { + let context_rules: Vec> = vec![ + Box::new(DenyUnderscoreInContextName::new()), + Box::new(DenyInvalidContextName::new()), + ]; + + let mut context_to_type_map: HashMap> = HashMap::new(); + + let Ok(context_directives) = schema.context_directive_applications() else { + // if we get an error, we probably are pre fed 2.8 + return Ok(context_to_type_map); + }; + for context_directive in context_directives { + match context_directive { + Ok(context) => { + let name = context.arguments.name.to_string(); + + // Apply each validation rule + for rule in context_rules.iter() { + rule.validate(&name, errors); + } + + // Track which types use each context name + let types = context_to_type_map.entry(name).or_default(); + types.push(context.target.type_name().clone()); + } + Err(e) => errors.push(e), + } + } + Ok(context_to_type_map) +} + +/// Trait for context name validators +trait ContextValidator { + fn validate(&self, context_name: &str, errors: &mut MultipleFederationErrors); +} + +/// Validator that ensures context names don't contain underscores +struct DenyUnderscoreInContextName {} + +impl DenyUnderscoreInContextName { + fn new() -> Self { + Self {} + } +} + +impl ContextValidator for DenyUnderscoreInContextName { + fn validate(&self, context_name: &str, errors: &mut MultipleFederationErrors) { + if context_name.contains('_') { + errors.push( + SingleFederationError::ContextNameContainsUnderscore { + name: context_name.to_string(), + } + .into(), + ); + } + } +} + +/// Validator that ensures context names only contain valid alphanumeric characters +/// and start with a letter +struct DenyInvalidContextName {} + +impl DenyInvalidContextName { + fn new() -> Self { + Self {} + } +} + +impl ContextValidator for DenyInvalidContextName { + fn validate(&self, context_name: &str, errors: &mut MultipleFederationErrors) { + if !context_name.chars().all(|c| c.is_alphanumeric()) + || !context_name + .chars() + .next() + .is_some_and(|c| c.is_alphabetic()) + { + errors.push( + SingleFederationError::ContextNameInvalid { + name: context_name.to_string(), + } + .into(), + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deny_underscore_in_context_name() { + let mut errors = MultipleFederationErrors::new(); + let rule = DenyUnderscoreInContextName::new(); + + rule.validate("invalid_name", &mut errors); + + assert_eq!(errors.errors.len(), 1); + assert!( + matches!( + errors.errors[0].clone(), + SingleFederationError::ContextNameContainsUnderscore { name } if name == "invalid_name" + ), + "Expected an error about underscore in context name, but got: {:?}", + errors.errors[0] + ); + + // Test valid case + let mut errors = MultipleFederationErrors::new(); + rule.validate("validName", &mut errors); + assert_eq!(errors.errors.len(), 0, "Expected no errors for valid name"); + } + + #[test] + fn deny_invalid_context_name() { + let mut errors = MultipleFederationErrors::new(); + let rule = DenyInvalidContextName::new(); + + // Test name starting with number + rule.validate("123invalid", &mut errors); + + assert_eq!(errors.errors.len(), 1); + assert!( + matches!( + errors.errors[0].clone(), + SingleFederationError::ContextNameInvalid { name } if name == "123invalid" + ), + "Expected an error about invalid context name, but got: {:?}", + errors.errors[0] + ); + + // Test name with special characters + let mut errors = MultipleFederationErrors::new(); + rule.validate("invalid$name", &mut errors); + + assert_eq!(errors.errors.len(), 1); + assert!( + matches!( + errors.errors[0].clone(), + SingleFederationError::ContextNameInvalid { name } if name == "invalid$name" + ), + "Expected an error about invalid context name, but got: {:?}", + errors.errors[0] + ); + + // Test valid case + let mut errors = MultipleFederationErrors::new(); + rule.validate("validName123", &mut errors); + assert_eq!(errors.errors.len(), 0, "Expected no errors for valid name"); + } +} diff --git a/apollo-federation/src/schema/validators/cost.rs b/apollo-federation/src/schema/validators/cost.rs new file mode 100644 index 0000000000..8b7c813364 --- /dev/null +++ b/apollo-federation/src/schema/validators/cost.rs @@ -0,0 +1,30 @@ +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::link::cost_spec_definition::CostSpecDefinition; +use crate::schema::FederationSchema; + +pub(crate) fn validate_cost_directives( + schema: &FederationSchema, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + let Some(cost_directive_name) = CostSpecDefinition::cost_directive_name(schema)? else { + return Ok(()); + }; + let Ok(cost_directive_referencers) = schema + .referencers() + .get_directive(cost_directive_name.as_str()) + else { + // This just returns an Err if the directive is not found, which is fine in this case. + return Ok(()); + }; + for interface_field in &cost_directive_referencers.interface_fields { + errors + .errors + .push(SingleFederationError::CostAppliedToInterfaceField { + interface: interface_field.type_name.clone(), + field: interface_field.field_name.clone(), + }); + } + Ok(()) +} diff --git a/apollo-federation/src/schema/validators/external.rs b/apollo-federation/src/schema/validators/external.rs new file mode 100644 index 0000000000..40b74d0d15 --- /dev/null +++ b/apollo-federation/src/schema/validators/external.rs @@ -0,0 +1,71 @@ +// the `@external` directive validation + +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::schema::FederationSchema; +use crate::schema::position::InterfaceTypeDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceTypeDefinitionPosition; +use crate::schema::subgraph_metadata::SubgraphMetadata; + +pub(crate) fn validate_external_directives( + schema: &FederationSchema, + metadata: &SubgraphMetadata, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + validate_no_external_on_interface_fields(schema, metadata, errors)?; + validate_all_external_fields_used(schema, metadata, errors)?; + Ok(()) +} + +fn validate_no_external_on_interface_fields( + schema: &FederationSchema, + metadata: &SubgraphMetadata, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + for type_name in schema.referencers().interface_types.keys() { + let type_pos: InterfaceTypeDefinitionPosition = + schema.get_type(type_name.clone())?.try_into()?; + for field_pos in type_pos.fields(schema.schema())? { + let is_external = metadata + .external_metadata() + .is_external(&field_pos.clone().into()); + if is_external { + errors.push(SingleFederationError::ExternalOnInterface { + message: format!( + r#"Interface type field "{field_pos}" is marked @external but @external is not allowed on interface fields."# + ), + }.into()) + } + } + } + Ok(()) +} + +// Checks that all fields marked @external is used in a federation directive (@key, @provides or +// @requires) _or_ to satisfy an interface implementation. Otherwise, the field declaration is +// somewhat useless. +fn validate_all_external_fields_used( + schema: &FederationSchema, + metadata: &SubgraphMetadata, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + for type_pos in schema.get_types() { + let Ok(type_pos) = ObjectOrInterfaceTypeDefinitionPosition::try_from(type_pos) else { + continue; + }; + type_pos.fields(schema.schema())? + .for_each(|field| { + let field = field.into(); + if !metadata.is_field_external(&field) || metadata.is_field_used(&field) { + return; + } + errors.push(SingleFederationError::ExternalUnused { + message: format!( + r#"Field "{field}" is marked @external but is not used in any federation directive (@key, @provides, @requires) or to satisfy an interface; the field declaration has no use and should be removed (or the field should not be @external)."# + ), + }.into()); + }); + } + Ok(()) +} diff --git a/apollo-federation/src/schema/validators/from_context.rs b/apollo-federation/src/schema/validators/from_context.rs new file mode 100644 index 0000000000..00825c5ee2 --- /dev/null +++ b/apollo-federation/src/schema/validators/from_context.rs @@ -0,0 +1,3095 @@ +use std::collections::HashMap; +use std::sync::LazyLock; + +use apollo_compiler::Name; +use apollo_compiler::ast::DirectiveList; +use apollo_compiler::ast::Type; +use apollo_compiler::collections::HashSet; +use apollo_compiler::executable::Field; +use apollo_compiler::executable::FieldSet; +use apollo_compiler::executable::Selection; +use apollo_compiler::executable::SelectionSet; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::validation::Valid; +use regex::Regex; + +use crate::bail; +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::schema::FederationSchema; +use crate::schema::FromContextDirective; +use crate::schema::position::CompositeTypeDefinitionPosition; +use crate::schema::position::FieldArgumentDefinitionPosition; +use crate::schema::position::InterfaceTypeDefinitionPosition; +use crate::schema::position::ObjectTypeDefinitionPosition; +use crate::schema::position::TypeDefinitionPosition; +use crate::schema::subgraph_metadata::SubgraphMetadata; +use crate::schema::validators::DeniesAliases; +use crate::schema::validators::DeniesDirectiveApplications; +use crate::schema::validators::DenyAliases; +use crate::schema::validators::DenyFieldsWithDirectiveApplications; +use crate::schema::validators::SchemaFieldSetValidator; +use crate::utils::FallibleIterator; +use crate::utils::iter_into_single_item; + +pub(crate) fn validate_from_context_directives( + schema: &FederationSchema, + meta: &SubgraphMetadata, + context_map: &HashMap>, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + let from_context_rules: Vec> = vec![ + Box::new(DenyOnAbstractType::new()), + Box::new(DenyOnInterfaceImplementation::new()), + Box::new(RequireContextExists::new(context_map)), + Box::new(RequireResolvableKey::new()), + Box::new(DenyDefaultValues::new()), + ]; + + let Ok(from_context_directives) = schema.from_context_directive_applications() else { + // if we get an error, we probably are pre fed 2.8 + return Ok(()); + }; + + for from_context_directive in from_context_directives { + match from_context_directive { + Ok(from_context) => { + // Parse context and selection from the field value + let field = from_context.arguments.field.to_string(); + let (context, selection) = parse_context(&field); + + // Apply each validation rule + for rule in from_context_rules.iter() { + rule.validate( + &from_context.target, + schema, + meta, + &context, + &selection, + errors, + )?; + } + + // after RequireContextExists, we will have errored if either the context or selection is not present + let (Some(context), Some(selection)) = (&context, &selection) else { + bail!("Context and selection must be present"); + }; + + // We need the context locations from the context map for this target + if let Some(set_context_locations) = context_map.get(context) + && let Err(validation_error) = validate_field_value( + context, + selection, + &from_context, + set_context_locations, + schema, + errors, + ) + { + errors.push(validation_error); + } + } + Err(e) => errors.push(e), + } + } + + Ok(()) +} + +/// Parses a field string that contains a context reference and optional selection. +/// +/// The function expects a string in the format "$contextName selection" where: +/// - The string must start with a '$' followed by a context name +/// - The context name must be a valid identifier (starting with letter/underscore, followed by alphanumeric/underscore) +/// - An optional selection can follow the context name +/// +/// Returns a tuple of (Option, Option) where: +/// - First element is Some(context_name) if a valid context was found, None otherwise +/// - Second element is Some(selection) if a valid selection was found after the context, None otherwise +/// +/// Examples: +/// - "$userContext userId" -> (Some("userContext"), Some("userId")) +/// - "$context { prop }" -> (Some("context"), Some("{ prop }")) +/// - "invalid" -> (None, None) +pub(crate) fn parse_context(field: &str) -> (Option, Option) { + // PORT_NOTE: The original JS regex, as shown below + // /^(?:[\n\r\t ,]|#[^\n\r]*(?![^\n\r]))*\$(?:[\n\r\t ,]|#[^\n\r]*(?![^\n\r]))*([A-Za-z_]\w*(?!\w))([\s\S]*)$/ + // makes use of negative lookaheads, which aren't supported natively by Rust's regex crate. + // There's a fancy_regex crate which does support this, but in the specific case above, the + // negative lookaheads are just used to ensure strict *-greediness for the preceding expression + // (i.e., it guarantees those *-expressions match greedily and won't backtrack). + // + // We can emulate that in this case by matching a series of regexes instead of a single regex, + // where for each regex, the relevant *-expression doesn't backtrack by virtue of the rest of + // the haystack guaranteeing a match. Also note that Rust has (?s:.) to match all characters + // including newlines, which we use in place of JS's common regex workaround of [\s\S]. + fn strip_leading_ignored_tokens(input: &str) -> Option<&str> { + iter_into_single_item(CONTEXT_PARSING_LEADING_PATTERN.captures_iter(input)) + .and_then(|c| c.get(1)) + .map(|m| m.as_str()) + } + + let Some(dollar_start) = strip_leading_ignored_tokens(field) else { + return (None, None); + }; + + let mut dollar_iter = dollar_start.chars(); + if dollar_iter.next() != Some('$') { + return (None, None); + } + let after_dollar = dollar_iter.as_str(); + + let Some(context_start) = strip_leading_ignored_tokens(after_dollar) else { + return (None, None); + }; + let Some(context_captures) = + iter_into_single_item(CONTEXT_PARSING_CONTEXT_PATTERN.captures_iter(context_start)) + else { + return (None, None); + }; + + let context = match context_captures.get(1).map(|m| m.as_str()) { + Some(context) if !context.is_empty() => context, + _ => { + return (None, None); + } + }; + let selection = match context_captures.get(2).map(|m| m.as_str()) { + Some(selection) => { + let Some(selection) = strip_leading_ignored_tokens(selection) else { + return (Some(context.to_owned()), None); + }; + if !selection.is_empty() { + selection + } else { + return (Some(context.to_owned()), None); + } + } + _ => { + return (Some(context.to_owned()), None); + } + }; + // PORT_NOTE: apollo_compiler's parsing code for field sets requires ignored tokens to be + // pre-stripped if curly braces are missing, so we additionally do that here. + let Some(selection) = strip_leading_ignored_tokens(selection) else { + return (Some(context.to_owned()), None); + }; + (Some(context.to_owned()), Some(selection.to_owned())) +} + +static CONTEXT_PARSING_LEADING_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r#"^(?:[\n\r\t ,]|#[^\n\r]*)*((?s:.)*)$"#).unwrap()); + +static CONTEXT_PARSING_CONTEXT_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r#"^([A-Za-z_](?-u:\w)*)((?s:.)*)$"#).unwrap()); + +#[derive(Debug, PartialEq)] +enum SelectionType { + Error, + Field, + InlineFragment { + type_conditions: std::collections::HashSet, + }, +} + +/// Validates a field value selection format and returns whether it's a field or inline fragment +fn validate_selection_format( + context: &str, + selection_set: &SelectionSet, + from_context_parent: &FieldArgumentDefinitionPosition, + errors: &mut MultipleFederationErrors, +) -> SelectionType { + // if it's a field, we expect there to be only one selection. + // if it's an inline fragment, we expect there to be a type_condition on every selection + let mut type_conditions = std::collections::HashSet::new(); + let mut has_field = false; + let mut has_inline_fragment = false; + for selection in selection_set.selections.iter() { + match selection { + // note that the fact that this selection is the only selection will be checked in the caller + Selection::Field(_) => { + has_field = true; + } + Selection::InlineFragment(fragment) => { + has_inline_fragment = true; + if let Some(type_condition) = &fragment.type_condition { + type_conditions.insert(type_condition.to_string()); + } else { + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!( + "Context \"{context}\" is used in \"{from_context_parent}\" but the selection is invalid: inline fragments must have type conditions" + ), + } + .into(), + ); + return SelectionType::Error; + } + } + Selection::FragmentSpread(_) => { + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!( + "Context \"{context}\" is used in \"{from_context_parent}\" but the selection is invalid: fragment spreads are not allowed" + ), + } + .into(), + ); + return SelectionType::Error; + } + } + } + + if has_field && has_inline_fragment { + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!("Context \"{context}\" is used in \"{from_context_parent}\" but the selection is invalid: multiple fields could be selected"), + } + .into(), + ); + return SelectionType::Error; + } else if has_field { + return SelectionType::Field; + } + + if type_conditions.len() != selection_set.selections.len() { + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!( + "Context \"{context}\" is used in \"{from_context_parent}\" but the selection is invalid: type conditions have the same name" + ), + } + .into(), + ); + return SelectionType::Error; + } + SelectionType::InlineFragment { type_conditions } +} + +fn validate_field_value( + context: &str, + selection: &String, + applied_directive: &FromContextDirective, + set_context_locations: &[Name], + schema: &FederationSchema, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + let argument_rules: Vec>> = vec![ + Box::new(DenyAliases::new()), + Box::new(DenyFieldsWithDirectiveApplications::new()), + ]; + + let target = &applied_directive.target; + // Get the expected type from the target argument + let expected_type = match target { + FieldArgumentDefinitionPosition::Object(pos) => match pos.get(schema.schema()) { + Ok(arg_def) => arg_def.ty.item_type(), + Err(_) => bail!("could not find position in schema"), + }, + FieldArgumentDefinitionPosition::Interface(pos) => match pos.get(schema.schema()) { + Ok(arg_def) => arg_def.ty.item_type(), + Err(_) => bail!("could not find position in schema"), + }, + }; + + let mut used_type_conditions: HashSet = Default::default(); + + let mut selection_type = SelectionType::Error; + // For each set context location, validate the selection + for location_name in set_context_locations { + // Try to create a composite type position from the location name + let Some(extended_type) = schema.schema().types.get(location_name) else { + continue; + }; + let Ok(location) = + CompositeTypeDefinitionPosition::try_from(TypeDefinitionPosition::from(extended_type)) + else { + continue; + }; + + // TODO [FED-672]: The union case should fail here if the property does not exist in all sub types, + // but it currently doesn't. We'll need to fix that validation + // see test_context_fails_on_union_missing_prop + let result = FieldSet::parse( + Valid::assume_valid_ref(schema.schema()), + location.type_name().clone(), + selection, + "from_context.graphql", + ); + + if result.is_err() { + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!( + "Context \"{}\" is used in \"{}\" but the selection is invalid for type \"{}\".", + context, target, location.type_name().clone(), + ), + } + .into(), + ); + return Ok(()); + } + let fields = result.unwrap(); + // TODO: Is it necessary to perform these validations on every iteration or can we do it only once? + for rule in argument_rules.iter() { + rule.visit(location_name, &fields, applied_directive, errors); + } + if !errors.errors.is_empty() { + return Ok(()); + } + selection_type = validate_selection_format(context, &fields.selection_set, target, errors); + // if there was an error, just return, we've already added it to the errorCollector + if selection_type == SelectionType::Error { + return Ok(()); + } + + let selection_set = &fields.selection_set; + // Check for multiple selections (only when it's a field selection, not inline fragments) + if selection_type == SelectionType::Field && selection_set.selections.len() > 1 { + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!( + "Context \"{context}\" is used in \"{target}\" but the selection is invalid: multiple selections are made" + ), + } + .into(), + ); + return Ok(()); + } + + match &selection_type { + SelectionType::Field => { + // For field selections, validate the type + let type_position = TypeDefinitionPosition::from(location.clone()); + + let resolved_type = validate_field_value_type( + context, + &type_position, + selection_set, + schema, + target, + errors, + )?; + + let Some(resolved_type) = resolved_type else { + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!( + "Context \"{context}\" is used in \"{target}\" but the selection is invalid: the type of the selection does not match the expected type \"{expected_type}\"" + ), + } + .into(), + ); + return Ok(()); + }; + if !is_valid_implementation_field_type(expected_type, &resolved_type) { + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!( + "Context \"{context}\" is used in \"{target}\" but the selection is invalid: the type of the selection \"{resolved_type}\" does not match the expected type \"{expected_type}\"" + ), + } + .into(), + ); + return Ok(()); + } + } + SelectionType::InlineFragment { type_conditions } => { + // For inline fragment selections, validate each fragment + for selection in selection_set.selections.iter() { + if let Selection::InlineFragment(frag) = selection + && let Some(type_condition) = &frag.type_condition + { + let Some(extended_type) = + schema.schema().types.get(type_condition.as_str()) + else { + errors.push( + SingleFederationError::ContextSelectionInvalid { message: format!( + "Inline fragment type condition invalid. Type '{}' does not exist in schema.", type_condition.as_str() + ) } + .into(), + ); + continue; + }; + let frag_type_position = TypeDefinitionPosition::from(extended_type); + if ObjectTypeDefinitionPosition::try_from(frag_type_position.clone()) + .is_err() + { + errors.push( + SingleFederationError::ContextSelectionInvalid { message: + "Inline fragment type condition invalid: type conditions must be an object type".to_string() + }.into(), + ); + continue; + } + + if let Ok(Some(resolved_type)) = validate_field_value_type( + context, + &frag_type_position, + &frag.selection_set, + schema, + target, + errors, + ) { + // For inline fragments, remove NonNull wrapper as other subgraphs may not define this + // This matches the TypeScript behavior + if !is_valid_implementation_field_type(expected_type, &resolved_type) { + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!( + "Context \"{context}\" is used in \"{target}\" but the selection is invalid: the type of the selection \"{resolved_type}\" does not match the expected type \"{expected_type}\"" + ), + } + .into(), + ); + return Ok(()); + } + used_type_conditions.insert(type_condition.as_str().to_string()); + } else { + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!( + "Context \"{context}\" is used in \"{target}\" but the selection is invalid: the type of the selection does not match the expected type \"{expected_type}\"" + ), + } + .into(), + ); + return Ok(()); + } + } + } + let context_location_names: std::collections::HashSet = + set_context_locations + .iter() + .map(|name| name.as_str().to_string()) + .collect(); + + let mut has_matching_condition = false; + for type_condition in type_conditions { + if context_location_names.contains(type_condition) { + has_matching_condition = true; + break; + } else { + // get the type + let Some(type_condition_type) = + schema.schema().types.get(type_condition.as_str()) + else { + bail!("Type not found for type condition: {}", type_condition); + }; + let interfaces = match type_condition_type { + ExtendedType::Interface(intf) => intf + .implements_interfaces + .iter() + .map(|i| i.name.clone()) + .collect(), + ExtendedType::Object(obj) => obj + .implements_interfaces + .iter() + .map(|i| i.name.clone()) + .collect(), + _ => vec![], + }; + if interfaces + .iter() + .any(|itf| context_location_names.contains(itf.as_str())) + { + has_matching_condition = true; + break; + } + } + } + if !has_matching_condition && !type_conditions.is_empty() { + // No type condition matches any context location + let context_locations_str = set_context_locations + .iter() + .map(|name| name.as_str()) + .collect::>() + .join(", "); + + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!( + "Context \"{context}\" is used in \"{target}\" but the selection is invalid: no type condition matches the location \"{context_locations_str}\"" + ), + } + .into(), + ); + } + } + SelectionType::Error => return Ok(()), + } + } + + // Check for unused type conditions + if let SelectionType::InlineFragment { type_conditions } = selection_type { + for type_condition in &type_conditions { + if !used_type_conditions.contains(type_condition) { + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!( + "Context \"{context}\" is used in \"{target}\" but the selection is invalid: type condition \"{type_condition}\" is never used" + ), + } + .into(), + ); + } + } + } + + Ok(()) +} + +fn is_valid_implementation_field_type(field_type: &Type, implemented_field_type: &Type) -> bool { + // If fieldType is a Non-Null type: + match (field_type, implemented_field_type) { + (Type::NonNullNamed(field_name), Type::NonNullNamed(impl_name)) => { + // Let nullableType be the unwrapped nullable type of fieldType. + let field_type_nullable = Type::Named(field_name.clone()); + let implemented_field_type_nullable = Type::Named(impl_name.clone()); + is_valid_implementation_field_type( + &field_type_nullable, + &implemented_field_type_nullable, + ) + } + (Type::NonNullNamed(field_name), Type::Named(_)) => { + let field_type_nullable = Type::Named(field_name.clone()); + is_valid_implementation_field_type(&field_type_nullable, implemented_field_type) + } + (Type::NonNullList(field_inner), Type::NonNullList(impl_inner)) => { + let field_type_nullable = (**field_inner).clone(); + let implemented_field_type_nullable = (**impl_inner).clone(); + is_valid_implementation_field_type( + &field_type_nullable, + &implemented_field_type_nullable, + ) + } + (Type::NonNullList(field_inner), Type::List(_)) => { + let field_type_nullable = (**field_inner).clone(); + is_valid_implementation_field_type(&field_type_nullable, implemented_field_type) + } + (Type::List(field_inner), Type::List(impl_inner)) => { + let field_type_inner = (**field_inner).clone(); + let implemented_type_inner = (**impl_inner).clone(); + is_valid_implementation_field_type(&field_type_inner, &implemented_type_inner) + } + (Type::Named(field_name), Type::Named(impl_name)) => field_name == impl_name, + _ => false, + } +} + +/// Trait for @fromContext directive validators +trait FromContextValidator { + fn validate( + &self, + target: &FieldArgumentDefinitionPosition, + schema: &FederationSchema, + meta: &SubgraphMetadata, + context: &Option, + selection: &Option, + errors: &mut MultipleFederationErrors, + ) -> Result<(), FederationError>; +} + +/// Validator that denies @fromContext on abstract types +struct DenyOnAbstractType {} + +impl DenyOnAbstractType { + fn new() -> Self { + Self {} + } +} + +impl FromContextValidator for DenyOnAbstractType { + fn validate( + &self, + target: &FieldArgumentDefinitionPosition, + _schema: &FederationSchema, + _meta: &SubgraphMetadata, + _context: &Option, + _selection: &Option, + errors: &mut MultipleFederationErrors, + ) -> Result<(), FederationError> { + if let FieldArgumentDefinitionPosition::Interface(_) = target { + errors.push( + SingleFederationError::ContextNotSet { + message: format!( + "@fromContext argument cannot be used on a field that exists on an abstract type \"{target}\"." + ), + } + .into(), + ); + } + Ok(()) + } +} + +/// Validator that denies @fromContext on fields implementing an interface +struct DenyOnInterfaceImplementation {} + +impl DenyOnInterfaceImplementation { + fn new() -> Self { + Self {} + } +} + +impl FromContextValidator for DenyOnInterfaceImplementation { + fn validate( + &self, + target: &FieldArgumentDefinitionPosition, + schema: &FederationSchema, + _meta: &SubgraphMetadata, + _context: &Option, + _selection: &Option, + errors: &mut MultipleFederationErrors, + ) -> Result<(), FederationError> { + if let FieldArgumentDefinitionPosition::Object(position) = target { + let obj = position.parent().parent().get(schema.schema())?; + let field = position.parent().field_name; + for implemented in &obj.implements_interfaces { + let itf = InterfaceTypeDefinitionPosition { + type_name: implemented.name.clone(), + }; + let field = itf.fields(schema.schema())?.find(|f| f.field_name == field); + if field.is_some() { + errors.push( + SingleFederationError::ContextNotSet { + message: format!( + "@fromContext argument cannot be used on a field implementing an interface field \"{target}\"." + ), + } + .into(), + ); + } + } + } + Ok(()) + } +} + +/// Validator that checks if the referenced context exists +struct RequireContextExists<'a> { + context_map: &'a HashMap>, +} + +impl<'a> RequireContextExists<'a> { + fn new(context_map: &'a HashMap>) -> Self { + Self { context_map } + } +} + +impl<'a> FromContextValidator for RequireContextExists<'a> { + fn validate( + &self, + target: &FieldArgumentDefinitionPosition, + _schema: &FederationSchema, + _meta: &SubgraphMetadata, + context: &Option, + selection: &Option, + errors: &mut MultipleFederationErrors, + ) -> Result<(), FederationError> { + let context = context.as_ref().map(|s| s.as_str()).unwrap_or(""); + let selection = selection.as_ref().map(|s| s.as_str()).unwrap_or(""); + if context.is_empty() { + errors.push( + SingleFederationError::NoContextReferenced { + message: format!( + "@fromContext argument does not reference a context \"${context} {selection}\"." + ), + } + .into(), + ); + } else if !self.context_map.contains_key(context) { + errors.push( + SingleFederationError::ContextNotSet { + message: format!( + "Context \"{context}\" is used at location \"{target}\" but is never set." + ), + } + .into(), + ); + } else if selection.is_empty() { + errors.push( + SingleFederationError::NoSelectionForContext { + message: format!( + "@fromContext directive in field \"{target}\" has no selection" + ), + } + .into(), + ); + } + Ok(()) + } +} + +/// Validator that requires at least one resolvable key on the type +struct RequireResolvableKey {} + +impl RequireResolvableKey { + fn new() -> Self { + Self {} + } +} + +impl FromContextValidator for RequireResolvableKey { + fn validate( + &self, + target: &FieldArgumentDefinitionPosition, + schema: &FederationSchema, + meta: &SubgraphMetadata, + _context: &Option, + _selection: &Option, + errors: &mut MultipleFederationErrors, + ) -> Result<(), FederationError> { + if let FieldArgumentDefinitionPosition::Object(position) = target { + let parent = position.parent().parent(); + let key_directive = meta + .federation_spec_definition() + .key_directive_definition(schema)?; + if parent + .get_applied_directives(schema, &key_directive.name) + .iter() + .fallible_filter(|application| -> Result { + let arguments = meta + .federation_spec_definition() + .key_directive_arguments(application)?; + Ok(arguments.resolvable) + }) + .collect::, _>>()? + .is_empty() + { + errors.push( + SingleFederationError::ContextNoResolvableKey { + message: format!( + "Object \"{parent}\" has no resolvable key but has a field with a contextual argument." + ), + } + .into(), + ); + } + } + Ok(()) + } +} + +/// Validator that denies @fromContext arguments with default values +struct DenyDefaultValues {} + +impl DenyDefaultValues { + fn new() -> Self { + Self {} + } +} + +impl FromContextValidator for DenyDefaultValues { + fn validate( + &self, + target: &FieldArgumentDefinitionPosition, + schema: &FederationSchema, + _meta: &SubgraphMetadata, + _context: &Option, + _selection: &Option, + errors: &mut MultipleFederationErrors, + ) -> Result<(), FederationError> { + // Check if the argument has a default value + let has_default = match target { + FieldArgumentDefinitionPosition::Object(position) => { + if let Ok(arg_def) = position.get(schema.schema()) { + arg_def.default_value.is_some() + } else { + false + } + } + FieldArgumentDefinitionPosition::Interface(position) => { + if let Ok(arg_def) = position.get(schema.schema()) { + arg_def.default_value.is_some() + } else { + false + } + } + }; + + if has_default { + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!( + "@fromContext arguments may not have a default value: \"{target}\"." + ), + } + .into(), + ); + } + Ok(()) + } +} + +impl DeniesAliases for FromContextDirective<'_> { + fn error(&self, _alias: &Name, _field: &Field) -> SingleFederationError { + let (context, _) = parse_context(self.arguments.field); + SingleFederationError::ContextSelectionInvalid { + message: format!( + "Context \"{}\" is used in \"{}\" but the selection is invalid: aliases are not allowed in the selection", + context.unwrap_or("unknown".to_string()), + self.target + ), + } + } +} + +impl DeniesDirectiveApplications for FromContextDirective<'_> { + fn error(&self, _: &DirectiveList) -> SingleFederationError { + let (context, _) = parse_context(self.arguments.field); + SingleFederationError::ContextSelectionInvalid { + message: format!( + "Context \"{}\" is used in \"{}\" but the selection is invalid: directives are not allowed in the selection", + context.unwrap_or("unknown".to_string()), + self.target + ), + } + } +} + +#[allow(dead_code, clippy::only_used_in_recursion)] +fn validate_field_value_type_inner( + selection_set: &SelectionSet, + schema: &FederationSchema, + from_context_parent: &FieldArgumentDefinitionPosition, + errors: &mut MultipleFederationErrors, +) -> Option { + let mut types_array = Vec::new(); + + if selection_set.selections.is_empty() { + types_array.push(Type::Named(selection_set.ty.clone())); + } + + for selection in selection_set.selections.iter() { + if let Selection::Field(field) = selection + && let Some(nested_type) = validate_field_value_type_inner( + &field.selection_set, + schema, + from_context_parent, + errors, + ) + { + types_array.push(nested_type); + } + // } else { + // if let Ok(field_def) = field.field.field_position.get(schema.schema()) { + // let base_type = &field_def.ty; + // types_array.push(base_type); + // } + // } + } + + if types_array.is_empty() { + return None; + } + types_array + .into_iter() + .map(Some) + .reduce(|acc, item| match (acc, item) { + (Some(acc), Some(item)) => { + if acc == item { + Some(acc) + } else if acc.is_assignable_to(&item) { + Some(item) + } else if item.is_assignable_to(&acc) { + Some(acc) + } else { + None + } + } + _ => None, + }) + .flatten() +} + +#[allow(dead_code)] +fn validate_field_value_type( + context: &str, + current_type: &TypeDefinitionPosition, + selection_set: &SelectionSet, + schema: &FederationSchema, + from_context_parent: &FieldArgumentDefinitionPosition, + errors: &mut MultipleFederationErrors, +) -> Result, FederationError> { + if let Some(metadata) = &schema.subgraph_metadata + && let Some(interface_object_directive) = metadata + .federation_spec_definition() + .interface_object_directive_definition(schema)? + && current_type.has_applied_directive(schema, &interface_object_directive.name) + { + errors.push( + SingleFederationError::ContextSelectionInvalid { + message: format!("Context \"{}\" is used in \"{}\" but the selection is invalid: One of the types in the selection is an interfaceObject: \"{}\".", context, from_context_parent, current_type.type_name()) + } + .into(), + ); + } + Ok(validate_field_value_type_inner( + selection_set, + schema, + from_context_parent, + errors, + )) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use crate::error::MultipleFederationErrors; + use crate::error::SingleFederationError; + use crate::subgraph::test_utils::build_and_expand; + + #[test] + // Port note: This test validates @fromContext on abstract types which is forbidden + // No direct JS equivalent, but relates to JS test "forbid contextual arguments on interfaces" + fn test_deny_on_abstract_type() { + // Create a test schema with @fromContext on an interface field + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext"]) + + type Query { + test: String + } + + interface Entity { + id(contextArg: ID! @fromContext(field: "$userContext userId")): ID! + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = HashMap::new(); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + + // We expect an error for the @fromContext on an abstract type + assert!( + errors.errors.iter().any(|e| matches!( + e, + SingleFederationError::ContextNotSet { message } if message == "@fromContext argument cannot be used on a field that exists on an abstract type \"Entity.id(contextArg:)\"." + )), + "Expected an error about abstract type" + ); + } + + #[test] + // Port note: Ported from JS test "forbid contextual arguments on interfaces" + fn test_deny_on_interface_implementation() { + // Create a test schema with @fromContext on a field implementing an interface + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext"]) + + type Query { + user: User + } + + interface Entity { + id: ID! + } + + type User implements Entity { + id(contextArg: ID! @fromContext(field: "$userContext userId")): ID! + name: String + } + + type UserContext @context(name: "userContext") { + userId: ID! + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + + // We expect an error for the @fromContext on a field implementing an interface + assert!( + errors.errors.iter().any(|e| matches!( + e, + SingleFederationError::ContextNotSet { message } if message == "@fromContext argument cannot be used on a field implementing an interface field \"User.id(contextArg:)\"." + )), + "Expected an error about implementing an interface field" + ); + } + + #[test] + // Port note: Combines logic from JS tests "context is never set" and "context variable does not appear in selection" + fn test_require_context_exists() { + // Create a test schema with @fromContext on a field + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext"]) + + type Query { + user(id: ID! @fromContext(field: "$userContext id")): User + invalid(id: ID! @fromContext(field: "$invalidContext id")): User + noContext(id: ID! @fromContext(field: "$noSelection")): User + } + + type User @context(name: "userContext") @context(name: "noSelection") { + id: ID! + name: String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect_err("validates fromContext directives"); + } + + #[test] + // Port note: Ported from JS test "at least one key on an object that uses a context must be resolvable" + fn test_require_resolvable_key() { + // Create a test schema with @fromContext but no resolvable key + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + user(id: ID! @fromContext(field: "$userContext { userId }")): User + } + + type User @context(name: "userContext") @key(fields: "id", resolvable: false) { + id: ID! + name: String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + + // We expect an error for the missing resolvable key + let resolvable_key_error = errors + .errors + .iter() + .find(|e| matches!(e, SingleFederationError::ContextNoResolvableKey { .. })); + + // Note: This might not be detected depending on the actual implementation + // as there is a resolvable: false key + if let Some(error) = resolvable_key_error { + assert!( + matches!( + error, + SingleFederationError::ContextNoResolvableKey { message } if message == "Object \"Query\" has no resolvable key but has a field with a contextual argument." + ), + "Expected an error about no resolvable key" + ); + } + } + + #[test] + // Port note: Tests context parsing logic - no direct JS equivalent as this is implementation detail + fn test_parse_context() { + let fields = [ + ("$context { prop }", ("context", "{ prop }")), + ( + "$context ... on A { prop } ... on B { prop }", + ("context", "... on A { prop } ... on B { prop }"), + ), + ( + "$topLevelQuery { me { locale } }", + ("topLevelQuery", "{ me { locale } }"), + ), + ( + "$context { a { b { c { prop }}} }", + ("context", "{ a { b { c { prop }}} }"), + ), + ( + "$ctx { identifiers { legacyUserId } }", + ("ctx", "{ identifiers { legacyUserId } }"), + ), + ( + "$retailCtx { identifiers { id5 } }", + ("retailCtx", "{ identifiers { id5 } }"), + ), + ("$retailCtx { mid }", ("retailCtx", "{ mid }")), + ( + "$widCtx { identifiers { wid } }", + ("widCtx", "{ identifiers { wid } }"), + ), + ]; + for (field, (known_context, known_selection)) in fields { + let (context, selection) = parse_context(field); + assert_eq!(context, Some(known_context.to_string())); + assert_eq!(selection, Some(known_selection.to_string())); + } + // Ensure we don't backtrack in the comment regex. + assert_eq!( + parse_context("#comment $fakeContext fakeSelection"), + (None, None) + ); + assert_eq!( + parse_context("$ #comment fakeContext fakeSelection"), + (None, None) + ); + + // Test valid context reference + let (parsed_context, parsed_selection) = parse_context("$contextA userId"); + assert_eq!(parsed_context, Some("contextA".to_string())); + assert_eq!(parsed_selection, Some("userId".to_string())); + + // Test no delimiter + let (parsed_context, parsed_selection) = parse_context("invalidFormat"); + assert_eq!(parsed_context, None); + assert_eq!(parsed_selection, None); + + // // Test space in context + let (parsed_context, parsed_selection) = parse_context("$ selection"); + assert_eq!(parsed_context, Some("selection".to_string())); + assert_eq!(parsed_selection, None); + + // Test empty selection + let (parsed_context, parsed_selection) = parse_context("$context "); + assert_eq!(parsed_context, Some("context".to_string())); + assert_eq!(parsed_selection, None); + + // Test multiple delimiters (should only split on first) + let (parsed_context, parsed_selection) = + parse_context("$contextA multiple fields selected"); + assert_eq!(parsed_context, Some("contextA".to_string())); + assert_eq!( + parsed_selection, + Some("multiple fields selected".to_string()) + ); + } + + #[test] + // Port note: Tests field value type validation logic - no direct JS equivalent as this is implementation detail + fn test_validate_field_value_type_single_field() { + use crate::schema::position::FieldArgumentDefinitionPosition; + use crate::schema::position::ObjectFieldArgumentDefinitionPosition; + use crate::schema::position::ObjectTypeDefinitionPosition; + + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext"]) + + type Query { + contextual(id: ID): User + } + + type User { + id: ID + name: String + age: Int + email: String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let user_type = TypeDefinitionPosition::Object(ObjectTypeDefinitionPosition::new( + Name::new_unchecked("User"), + )); + let query_contextual_arg_pos = + FieldArgumentDefinitionPosition::Object(ObjectFieldArgumentDefinitionPosition { + type_name: Name::new_unchecked("Query"), + field_name: Name::new_unchecked("contextual"), + argument_name: Name::new_unchecked("id"), + }); + + // Test case 1: Single field selection - should return the field type + + let fields = FieldSet::parse( + Valid::assume_valid_ref(subgraph.schema().schema()), + user_type.type_name().clone(), + "id", + "from_context.graphql", + ) + .expect("valid field set"); + + let result = validate_field_value_type( + "userContext", + &user_type, + &fields.selection_set, + subgraph.schema(), + &query_contextual_arg_pos, + &mut errors, + ) + .expect("valid field value type"); + + assert!( + result.is_some(), + "Should return a type for single field selection" + ); + assert_eq!( + result.unwrap().inner_named_type().as_str(), + "ID", + "Should return ID type" + ); + assert!( + errors.errors.is_empty(), + "Should not have validation errors" + ); + } + + #[test] + // Port note: Tests field value type validation logic - no direct JS equivalent as this is implementation detail + fn test_validate_field_value_type_consistent_fields() { + use crate::schema::position::FieldArgumentDefinitionPosition; + use crate::schema::position::ObjectFieldArgumentDefinitionPosition; + use crate::schema::position::ObjectTypeDefinitionPosition; + + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext"]) + + type Query { + contextual(id: ID): User + } + + type User { + id: ID + userId: ID + identifier: ID + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let user_type = TypeDefinitionPosition::Object(ObjectTypeDefinitionPosition::new( + Name::new_unchecked("User"), + )); + let query_contextual_arg_pos = + FieldArgumentDefinitionPosition::Object(ObjectFieldArgumentDefinitionPosition { + type_name: Name::new_unchecked("Query"), + field_name: Name::new_unchecked("contextual"), + argument_name: Name::new_unchecked("id"), + }); + + // Test case: Multiple fields with same type - should return common type + let fields = FieldSet::parse( + Valid::assume_valid_ref(subgraph.schema().schema()), + user_type.type_name().clone(), + "{ id userId identifier }", + "from_context.graphql", + ) + .expect("valid field set"); + + let result = validate_field_value_type( + "userContext", + &user_type, + &fields.selection_set, + subgraph.schema(), + &query_contextual_arg_pos, + &mut errors, + ) + .expect("valid field value type"); + + assert!( + result.is_some(), + "Should return a type for consistent field types" + ); + assert_eq!( + result.unwrap().inner_named_type().as_str(), + "ID", + "Should return common ID type" + ); + assert!( + errors.errors.is_empty(), + "Should not have validation errors" + ); + } + + #[test] + // Port note: Tests field value type validation logic - no direct JS equivalent as this is implementation detail + fn test_validate_field_value_type_inconsistent_fields() { + use crate::schema::position::FieldArgumentDefinitionPosition; + use crate::schema::position::ObjectFieldArgumentDefinitionPosition; + use crate::schema::position::ObjectTypeDefinitionPosition; + + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext"]) + + type Query { + contextual(id: ID): User + } + + type User { + id: ID + name: String + age: Int + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let user_type = TypeDefinitionPosition::Object(ObjectTypeDefinitionPosition::new( + Name::new_unchecked("User"), + )); + let query_contextual_arg_pos = + FieldArgumentDefinitionPosition::Object(ObjectFieldArgumentDefinitionPosition { + type_name: Name::new_unchecked("Query"), + field_name: Name::new_unchecked("contextual"), + argument_name: Name::new_unchecked("id"), + }); + + // Test case: Multiple fields with different types - should return None + let fields = FieldSet::parse( + Valid::assume_valid_ref(subgraph.schema().schema()), + user_type.type_name().clone(), + "{ id name age }", + "from_context.graphql", + ) + .expect("valid field set"); + + let result = validate_field_value_type( + "userContext", + &user_type, + &fields.selection_set, + subgraph.schema(), + &query_contextual_arg_pos, + &mut errors, + ) + .expect("valid field value type"); + + assert!( + result.is_none(), + "Should return None for inconsistent field types" + ); + assert!( + errors.errors.is_empty(), + "Should not have validation errors for type mismatch" + ); + } + + #[test] + // Port note: Tests field value type validation logic - no direct JS equivalent as this is implementation detail + fn test_validate_field_value_type_nested_selection() { + use crate::schema::position::FieldArgumentDefinitionPosition; + use crate::schema::position::ObjectFieldArgumentDefinitionPosition; + use crate::schema::position::ObjectTypeDefinitionPosition; + + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext"]) + + type Query { + contextual(id: ID): User + } + + type User { + profile: Profile + settings: Profile + } + + type Profile { + id: ID + name: String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let user_type = TypeDefinitionPosition::Object(ObjectTypeDefinitionPosition::new( + Name::new_unchecked("User"), + )); + let query_contextual_arg_pos = + FieldArgumentDefinitionPosition::Object(ObjectFieldArgumentDefinitionPosition { + type_name: Name::new_unchecked("Query"), + field_name: Name::new_unchecked("contextual"), + argument_name: Name::new_unchecked("id"), + }); + + // Test case: Nested selection with consistent types + let fields = FieldSet::parse( + Valid::assume_valid_ref(subgraph.schema().schema()), + user_type.type_name().clone(), + "{ profile { id } settings { id } }", + "from_context.graphql", + ) + .expect("valid field set"); + + let result = validate_field_value_type( + "userContext", + &user_type, + &fields.selection_set, + subgraph.schema(), + &query_contextual_arg_pos, + &mut errors, + ) + .expect("valid field value type"); + + assert!( + result.is_some(), + "Should return a type for nested consistent selections" + ); + assert_eq!( + result.unwrap().inner_named_type().as_str(), + "ID", + "Should return ID type from nested selection" + ); + assert!( + errors.errors.is_empty(), + "Should not have validation errors" + ); + } + + #[test] + // Port note: Tests field value type validation logic - no direct JS equivalent as this is implementation detail + fn test_validate_field_value_type_nested_inconsistent() { + use crate::schema::position::FieldArgumentDefinitionPosition; + use crate::schema::position::ObjectFieldArgumentDefinitionPosition; + use crate::schema::position::ObjectTypeDefinitionPosition; + + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext"]) + + type Query { + contextual(id: ID): User + } + + type User { + profile: Profile + settings: Settings + } + + type Profile { + id: ID + } + + type Settings { + name: String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let user_type = TypeDefinitionPosition::Object(ObjectTypeDefinitionPosition::new( + Name::new_unchecked("User"), + )); + let query_contextual_arg_pos = + FieldArgumentDefinitionPosition::Object(ObjectFieldArgumentDefinitionPosition { + type_name: Name::new_unchecked("Query"), + field_name: Name::new_unchecked("contextual"), + argument_name: Name::new_unchecked("id"), + }); + + // Test case: Nested selection with inconsistent types + let fields = FieldSet::parse( + Valid::assume_valid_ref(subgraph.schema().schema()), + user_type.type_name().clone(), + "{ profile { id } settings { name } }", + "from_context.graphql", + ) + .expect("valid field set"); + + let result = validate_field_value_type( + "userContext", + &user_type, + &fields.selection_set, + subgraph.schema(), + &query_contextual_arg_pos, + &mut errors, + ) + .expect("valid field value type"); + + assert!( + result.is_none(), + "Should return None for nested inconsistent selections" + ); + assert!( + errors.errors.is_empty(), + "Should not have validation errors for type mismatch" + ); + } + + #[test] + // Port note: Relates to JS test "context selection references an @interfaceObject" + fn test_validate_field_value_type_interface_object_error() { + use crate::schema::position::FieldArgumentDefinitionPosition; + use crate::schema::position::ObjectFieldArgumentDefinitionPosition; + use crate::schema::position::ObjectTypeDefinitionPosition; + + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@interfaceObject"]) + + type Query { + contextual(id: ID): User + } + + type User @interfaceObject { + id: ID + name: String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let user_type = TypeDefinitionPosition::Object(ObjectTypeDefinitionPosition::new( + Name::new_unchecked("User"), + )); + let query_contextual_arg_pos = + FieldArgumentDefinitionPosition::Object(ObjectFieldArgumentDefinitionPosition { + type_name: Name::new_unchecked("Query"), + field_name: Name::new_unchecked("contextual"), + argument_name: Name::new_unchecked("id"), + }); + + // Test case: Interface object should generate error + let fields = FieldSet::parse( + Valid::assume_valid_ref(subgraph.schema().schema()), + user_type.type_name().clone(), + "{ id }", + "from_context.graphql", + ) + .expect("valid field set"); + + let result = validate_field_value_type( + "userContext", + &user_type, + &fields.selection_set, + subgraph.schema(), + &query_contextual_arg_pos, + &mut errors, + ) + .expect("valid field value type"); + + // Should still return the type but generate an error + assert!( + result.is_some(), + "Should still return a type even with interface object error" + ); + assert!( + !errors.errors.is_empty(), + "Should have validation error for interface object" + ); + assert!( + errors.errors.iter().any(|e| matches!( + e, + SingleFederationError::ContextSelectionInvalid { message } if message == "Context \"userContext\" is used in \"Query.contextual(id:)\" but the selection is invalid: One of the types in the selection is an interfaceObject: \"User\"." + )), + "Should have specific interface object error" + ); + } + + #[test] + // Port note: Tests field value type validation logic - no direct JS equivalent as this is implementation detail + fn test_validate_field_value_type_wrapped_types() { + use crate::schema::position::FieldArgumentDefinitionPosition; + use crate::schema::position::ObjectFieldArgumentDefinitionPosition; + use crate::schema::position::ObjectTypeDefinitionPosition; + + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext"]) + + type Query { + contextual(id: ID): User + } + + type User { + id: ID + idNonNull: ID! + ids: [ID] + idsNonNull: [ID!]! + idsNonNullList: [ID!]! + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let user_type = TypeDefinitionPosition::Object(ObjectTypeDefinitionPosition::new( + Name::new_unchecked("User"), + )); + let query_contextual_arg_pos = + FieldArgumentDefinitionPosition::Object(ObjectFieldArgumentDefinitionPosition { + type_name: Name::new_unchecked("Query"), + field_name: Name::new_unchecked("contextual"), + argument_name: Name::new_unchecked("id"), + }); + + // Test case: Multiple fields with same base type but different wrappers - should return common base type + let fields = FieldSet::parse( + Valid::assume_valid_ref(subgraph.schema().schema()), + user_type.type_name().clone(), + "{ id idNonNull ids idsNonNull }", + "from_context.graphql", + ) + .expect("valid field set"); + + let result = validate_field_value_type( + "userContext", + &user_type, + &fields.selection_set, + subgraph.schema(), + &query_contextual_arg_pos, + &mut errors, + ) + .expect("valid field value type"); + + assert!( + result.is_some(), + "Should return a type for wrapped types with same base" + ); + assert_eq!( + result.unwrap().inner_named_type().as_str(), + "ID", + "Should return common base type ID" + ); + assert!( + errors.errors.is_empty(), + "Should not have validation errors" + ); + } + + #[test] + // Port note: Tests field value type validation logic - no direct JS equivalent as this is implementation detail + fn test_validate_field_value_type_deep_nesting() { + use crate::schema::position::FieldArgumentDefinitionPosition; + use crate::schema::position::ObjectFieldArgumentDefinitionPosition; + use crate::schema::position::ObjectTypeDefinitionPosition; + + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext"]) + + type Query { + contextual(id: ID): User + } + + type User { + profile: Profile + } + + type Profile { + settings: Settings + } + + type Settings { + id: ID + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let user_type = TypeDefinitionPosition::Object(ObjectTypeDefinitionPosition::new( + Name::new_unchecked("User"), + )); + let query_contextual_arg_pos = + FieldArgumentDefinitionPosition::Object(ObjectFieldArgumentDefinitionPosition { + type_name: Name::new_unchecked("Query"), + field_name: Name::new_unchecked("contextual"), + argument_name: Name::new_unchecked("id"), + }); + + // Test case: Deep nesting - should return the deeply nested field type + let fields = FieldSet::parse( + Valid::assume_valid_ref(subgraph.schema().schema()), + user_type.type_name().clone(), + "{ profile { settings { id } } }", + "from_context.graphql", + ) + .expect("valid field set"); + + let result = validate_field_value_type( + "userContext", + &user_type, + &fields.selection_set, + subgraph.schema(), + &query_contextual_arg_pos, + &mut errors, + ) + .expect("valid field value type"); + + assert!( + result.is_some(), + "Should return a type for deeply nested selection" + ); + assert_eq!( + result.unwrap().inner_named_type().as_str(), + "ID", + "Should return the deeply nested field type" + ); + assert!( + errors.errors.is_empty(), + "Should not have validation errors" + ); + } + + #[test] + // Port note: Ported from JS test "vanilla setContext - success case" + fn test_validate_field_value_basic_success() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + parent: Parent + } + + type Parent @context(name: "userContext") @key(fields: "id") { + id: ID! + name: String + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: String @fromContext(field: "$userContext name")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let applied_directives = subgraph + .schema() + .from_context_directive_applications() + .expect("valid from context directive"); + let applied_directive = applied_directives + .first() + .expect("at least one from context directive") + .as_ref() + .expect("valid from context directive"); + let (context, selection) = parse_context(applied_directive.arguments.field); + + let set_context_locations = vec![Name::new_unchecked("Parent")]; + + let result = validate_field_value( + &context.expect("valid context"), + &selection.expect("valid selection"), + applied_directive, + &set_context_locations, + subgraph.schema(), + &mut errors, + ); + assert!(result.is_ok(), "Should validate successfully"); + assert!( + errors.errors.is_empty(), + "Should not have validation errors" + ); + } + + #[test] + // Port note: Ported from JS test "resolved field is not available in context" + fn test_validate_field_value_invalid_selection() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + parent: Parent + } + + type Parent @context(name: "userContext") @key(fields: "id") { + id: ID! + name: String + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: String! @fromContext(field: "$userContext nonExistentField")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let set_context_locations = vec![Name::new_unchecked("Parent")]; + + let applied_directives = subgraph + .schema() + .from_context_directive_applications() + .expect("valid from context directive"); + let applied_directive = applied_directives + .first() + .expect("at least one from context directive") + .as_ref() + .expect("valid from context directive"); + let (context, selection) = parse_context(applied_directive.arguments.field); + + let result = validate_field_value( + &context.expect("valid context"), + &selection.expect("valid selection"), + applied_directive, + &set_context_locations, + subgraph.schema(), + &mut errors, + ); + + assert!(result.is_ok(), "Function should complete"); + // Should have errors for invalid field selection + assert!( + !errors.errors.is_empty(), + "Should have validation errors for invalid selection" + ); + assert!( + errors.errors.iter().any(|e| matches!( + e, + SingleFederationError::ContextSelectionInvalid { message } if message == "Context \"userContext\" is used in \"Target.value(contextArg:)\" but the selection is invalid for type \"Parent\"." + )), + "Should have specific invalid selection error" + ); + } + + #[test] + // Port note: Ported from JS test "setContext with multiple contexts (duck typing) - type mismatch" + fn test_validate_field_value_type_mismatch() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + parent: Parent + } + + type Parent @context(name: "userContext") @key(fields: "id") { + id: ID! + name: String + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: ID! @fromContext(field: "$userContext name")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let set_context_locations = vec![Name::new_unchecked("Parent")]; + + let applied_directives = subgraph + .schema() + .from_context_directive_applications() + .expect("valid from context directive"); + let applied_directive = applied_directives + .first() + .expect("at least one from context directive") + .as_ref() + .expect("valid from context directive"); + let (context, selection) = parse_context(applied_directive.arguments.field); + + let result = validate_field_value( + &context.expect("valid context"), + &selection.expect("valid selection"), + applied_directive, + &set_context_locations, + subgraph.schema(), + &mut errors, + ); + + assert!(result.is_ok(), "Function should complete"); + // Should have errors for type mismatch between String and ID + assert!( + !errors.errors.is_empty(), + "Should have validation errors for type mismatch" + ); + assert!( + errors.errors.iter().any(|e| matches!( + e, + SingleFederationError::ContextSelectionInvalid { message } if message == "Context \"userContext\" is used in \"Target.value(contextArg:)\" but the selection is invalid: the type of the selection \"String\" does not match the expected type \"ID!\"" + )), + "Should have specific type mismatch error" + ); + } + + #[test] + // Port note: Ported from JS test "setContext with multiple contexts (type conditions) - success" + fn test_validate_field_value_inline_fragments() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + parent: Parent + } + + type Parent @context(name: "userContext") @key(fields: "id") { + id: ID! + name: String + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: String! @fromContext(field: "$userContext ... on Parent { name }")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let set_context_locations = vec![Name::new_unchecked("Parent")]; + + let applied_directives = subgraph + .schema() + .from_context_directive_applications() + .expect("valid from context directive"); + let applied_directive = applied_directives + .first() + .expect("at least one from context directive") + .as_ref() + .expect("valid from context directive"); + let (context, selection) = parse_context(applied_directive.arguments.field); + + let result = validate_field_value( + &context.expect("valid context"), + &selection.expect("valid selection"), + applied_directive, + &set_context_locations, + subgraph.schema(), + &mut errors, + ); + + assert!(result.is_ok(), "Should handle inline fragments"); + // The validation should detect that this is an inline fragment format + } + + #[test] + // Port note: Ported from JS test "setContext with multiple contexts (type conditions) - success" + fn test_validate_field_value_type_conditions_same_name() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + parent: Parent + } + + type Parent @context(name: "userContext") @key(fields: "id") { + id: ID! + name: String + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: String! @fromContext(field: "$userContext ... on Parent { name } ... on Parent { name }")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let set_context_locations = vec![Name::new_unchecked("Parent")]; + + let applied_directives = subgraph + .schema() + .from_context_directive_applications() + .expect("valid from context directive"); + let applied_directive = applied_directives + .first() + .expect("at least one from context directive") + .as_ref() + .expect("valid from context directive"); + let (context, selection) = parse_context(applied_directive.arguments.field); + + let result = validate_field_value( + &context.expect("valid context"), + &selection.expect("valid selection"), + applied_directive, + &set_context_locations, + subgraph.schema(), + &mut errors, + ); + + assert!(result.is_ok(), "Should handle inline fragments"); + assert!(!errors.errors.is_empty(), "Should have validation error"); + assert!( + errors.errors.iter().any(|e| matches!( + e, + SingleFederationError::ContextSelectionInvalid { message } if message == "Context \"userContext\" is used in \"Target.value(contextArg:)\" but the selection is invalid: type conditions have the same name" + )), + "Should have specific type conditions same name error" + ); + } + + #[test] + // Port note: Ported from JS test "setContext with multiple contexts (duck typing) - type mismatch" + fn test_validate_field_value_type_mismatch_multiple_contexts() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + foo: Foo + bar: Bar + } + + type Foo @context(name: "context") @key(fields: "id") { + id: ID! + prop: String! + } + + type Bar @context(name: "context") @key(fields: "id") { + id: ID! + prop: Int! + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: String @fromContext(field: "$context { prop }")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let set_context_locations = vec![Name::new_unchecked("Foo"), Name::new_unchecked("Bar")]; + + let applied_directives = subgraph + .schema() + .from_context_directive_applications() + .expect("valid from context directive"); + let applied_directive = applied_directives + .first() + .expect("at least one from context directive") + .as_ref() + .expect("valid from context directive"); + let (context, selection) = parse_context(applied_directive.arguments.field); + + let result = validate_field_value( + &context.expect("valid context"), + &selection.expect("valid selection"), + applied_directive, + &set_context_locations, + subgraph.schema(), + &mut errors, + ); + + assert!(result.is_ok(), "Function should complete"); + // Should have errors for type mismatch between String and Int from different context types + assert!( + !errors.errors.is_empty(), + "Should have validation errors for type mismatch" + ); + assert!( + errors.errors.iter().any(|e| matches!( + e, + SingleFederationError::ContextSelectionInvalid { message } if message == "Context \"context\" is used in \"Target.value(contextArg:)\" but the selection is invalid: the type of the selection \"Int\" does not match the expected type \"String\"" + )), + "Should have specific type mismatch error" + ); + } + + #[test] + // Port note: Ported from JS test "context variable does not appear in selection" + fn test_validate_field_value_no_context_reference() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + parent: Parent + } + + type Parent @context(name: "context") @key(fields: "id") { + id: ID! + prop: String! + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: String! @fromContext(field: "prop")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect_err("unparseable fromContext directive"); + } + + #[test] + // Port note: Ported from JS test "selection contains more than one value" + fn test_validate_field_value_multiple_selections() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + parent: Parent + } + + type Parent @context(name: "context") @key(fields: "id") { + id: ID! + prop: String! + name: String! + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: String! @fromContext(field: "$context { id prop }")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let set_context_locations = vec![Name::new_unchecked("Parent")]; + + let applied_directives = subgraph + .schema() + .from_context_directive_applications() + .expect("valid from context directive"); + let applied_directive = applied_directives + .first() + .expect("at least one from context directive") + .as_ref() + .expect("valid from context directive"); + let (context, selection) = parse_context(applied_directive.arguments.field); + + let result = validate_field_value( + &context.expect("valid context"), + &selection.expect("valid selection"), + applied_directive, + &set_context_locations, + subgraph.schema(), + &mut errors, + ); + + assert!(result.is_ok(), "Function should complete"); + // Should have validation error for multiple selections + assert!( + !errors.errors.is_empty(), + "Should have validation errors for multiple selections" + ); + assert!( + errors.errors.iter().any(|e| matches!( + e, + SingleFederationError::ContextSelectionInvalid { message } if message == "Context \"context\" is used in \"Target.value(contextArg:)\" but the selection is invalid: multiple selections are made" + )), + "Should have specific multiple selections error" + ); + } + + #[test] + // Port note: Ported from JS test "context selection contains a query directive" + fn test_validate_field_value_with_directives() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + directive @testDirective on FIELD + + type Query { + parent: Parent + } + + type Parent @context(name: "context") @key(fields: "id") { + id: ID! + prop: String! + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: String! @fromContext(field: "$context { prop @testDirective }")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let set_context_locations = vec![Name::new_unchecked("Parent")]; + + let applied_directives = subgraph + .schema() + .from_context_directive_applications() + .expect("valid from context directive"); + let applied_directive = applied_directives + .first() + .expect("at least one from context directive") + .as_ref() + .expect("valid from context directive"); + let (context, selection) = parse_context(applied_directive.arguments.field); + + let result = validate_field_value( + &context.expect("valid context"), + &selection.expect("valid selection"), + applied_directive, + &set_context_locations, + subgraph.schema(), + &mut errors, + ); + assert!(result.is_ok(), "Function should complete"); + // Should have errors for directives in selection + assert!( + !errors.errors.is_empty(), + "Should have validation errors for directives" + ); + assert!( + errors.errors.iter().any(|e| matches!( + e, + SingleFederationError::ContextSelectionInvalid { message } if message == "Context \"context\" is used in \"Target.value(contextArg:)\" but the selection is invalid: directives are not allowed in the selection" + )), + "Should have specific directive error" + ); + } + + #[test] + // Port note: Ported from JS test "context selection contains an alias" + fn test_validate_field_value_with_aliases() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + parent: Parent + } + + type Parent @context(name: "context") @key(fields: "id") { + id: ID! + prop: String! + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: String! @fromContext(field: "$context { alias: prop }")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let set_context_locations = vec![Name::new_unchecked("Parent")]; + + let applied_directives = subgraph + .schema() + .from_context_directive_applications() + .expect("valid from context directive"); + let applied_directive = applied_directives + .first() + .expect("at least one from context directive") + .as_ref() + .expect("valid from context directive"); + let (context, selection) = parse_context(applied_directive.arguments.field); + + let result = validate_field_value( + &context.expect("valid context"), + &selection.expect("valid selection"), + applied_directive, + &set_context_locations, + subgraph.schema(), + &mut errors, + ); + + assert!(result.is_ok(), "Function should complete"); + // Should have errors for aliases in selection + assert!( + !errors.errors.is_empty(), + "Should have validation errors for aliases" + ); + assert!( + errors.errors.iter().any(|e| matches!( + e, + SingleFederationError::ContextSelectionInvalid { message } if message == "Context \"context\" is used in \"Target.value(contextArg:)\" but the selection is invalid: aliases are not allowed in the selection" + )), + "Should have specific alias error" + ); + } + + #[test] + // Port note: Ported from JS test "type matches no type conditions" + fn test_validate_field_value_type_condition_no_match() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + bar: Bar + } + + type Foo @key(fields: "id") { + id: ID! + prop: String! + } + + type Bar @context(name: "context") @key(fields: "id") { + id: ID! + prop2: String! + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: String! @fromContext(field: "$context ... on Foo { prop }")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let set_context_locations = vec![Name::new_unchecked("Bar")]; + + let applied_directives = subgraph + .schema() + .from_context_directive_applications() + .expect("valid from context directive"); + let applied_directive = applied_directives + .first() + .expect("at least one from context directive") + .as_ref() + .expect("valid from context directive"); + let (context, selection) = parse_context(applied_directive.arguments.field); + + let result = validate_field_value( + &context.expect("valid context"), + &selection.expect("valid selection"), + applied_directive, + &set_context_locations, + subgraph.schema(), + &mut errors, + ); + + assert!(result.is_ok(), "Function should complete"); + // Should have validation error when type condition doesn't match the context location + // In this case, we have "... on Foo" but the context is set on "Bar" + assert!( + !errors.errors.is_empty(), + "Should have validation errors for type condition mismatch" + ); + + assert!( + errors.errors.iter().any(|e| matches!( + e, + SingleFederationError::ContextSelectionInvalid { message } if message == "Context \"context\" is used in \"Target.value(contextArg:)\" but the selection is invalid: no type condition matches the location \"Bar\"" + )), + "Should have specific type condition mismatch error" + ); + } + + #[test] + // Port note: Ported from JS test "forbid contextual arguments on interfaces" + fn test_deny_fromcontext_on_interface_field() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + test: String + } + + interface Entity { + id(contextArg: ID! @fromContext(field: "$userContext userId")): ID! + } + + type UserContext @context(name: "userContext") { + userId: ID! + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + + // We expect an error for the @fromContext on an abstract type (interface) + assert!(!errors.errors.is_empty(), "Should have validation errors"); + assert!( + errors.errors.iter().any(|e| matches!( + e, + SingleFederationError::ContextNotSet { message } if message == "@fromContext argument cannot be used on a field that exists on an abstract type \"Entity.id(contextArg:)\"." + )), + "Expected an error about abstract type" + ); + } + + #[test] + // Port note: Ported from JS test "invalid context name shouldn't throw" + fn test_empty_context_name() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + test: String + } + + type TestType @context(name: "") @key(fields: "id") { + id: ID! + prop: String! + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // Validate context directives to catch empty context name + let _context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ); + + // Should have validation error for empty context name + // Note: This depends on the context validator catching empty names + // If no error is generated here, it means the validation is not implemented yet + } + + #[test] + #[ignore] + // Port note: Ported from JS test "@context fails on union when type is missing prop" + fn test_context_fails_on_union_missing_prop() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + t: T + } + + union T @context(name: "context") = T1 | T2 + + type T1 @key(fields: "id") @context(name: "context") { + id: ID! + prop: String! + a: String! + } + + type T2 @key(fields: "id") @context(name: "context") { + id: ID! + b: String! + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: String @fromContext(field: "$context { prop }")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + let set_context_locations = vec![Name::new_unchecked("T")]; + + let applied_directives = subgraph + .schema() + .from_context_directive_applications() + .expect("valid from context directive"); + let applied_directive = applied_directives + .first() + .expect("at least one from context directive") + .as_ref() + .expect("valid from context directive"); + let (context, selection) = parse_context(applied_directive.arguments.field); + + let result = validate_field_value( + &context.expect("valid context"), + &selection.expect("valid selection"), + applied_directive, + &set_context_locations, + subgraph.schema(), + &mut errors, + ); + assert!(result.is_ok(), "Function should complete"); + // Should have errors because T2 doesn't have the "prop" field + assert!( + !errors.errors.is_empty(), + "Should have validation errors for missing field in union member" + ); + assert!( + errors.errors.iter().any(|e| matches!( + e, + SingleFederationError::ContextSelectionInvalid { message } if message == "Context \"context\" is used in \"Target.value(contextArg:)\" but the selection is invalid for type \"T2\"." + )), + "Should have specific union field error" + ); + } + + #[test] + // Port note: Ported from JS test "context name is invalid" + fn test_context_name_with_underscore() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + parent: Parent + } + + type Parent @context(name: "_context") @key(fields: "id") { + id: ID! + prop: String! + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: String! @fromContext(field: "$_context prop")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + + // Should have error for context name with underscore + assert!( + !errors.errors.is_empty(), + "Should have validation errors for underscore in context name" + ); + // Note: The specific error type depends on the context validator implementation + } + + #[test] + // Port note: Ported from JS test "forbid default values on contextual arguments" + fn test_forbid_default_values_on_contextual_arguments() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + parent: Parent + } + + type Parent @context(name: "context") @key(fields: "id") { + id: ID! + prop: String! + } + + type Target @key(fields: "targetId") { + targetId: ID! + value(contextArg: String = "default" @fromContext(field: "$context prop")): String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + + // Should have error for default values on @fromContext arguments + assert!( + !errors.errors.is_empty(), + "Should have validation errors for default values on contextual arguments" + ); + // Note: This validation may need to be implemented in the fromContext validator + } + + #[test] + // Port note: Ported from JS test "vanilla setContext - success case" + fn test_vanilla_setcontext_success() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field(a: String @fromContext(field: "$context { prop }")): Int! + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + + // This should succeed without any validation errors + assert!( + errors.errors.is_empty(), + "Should not have validation errors for valid basic @fromContext usage" + ); + } + + #[test] + // Port note: Ported from JS test "using a list as input to @fromContext" + fn test_using_list_as_input_to_fromcontext() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: [String]! + } + + type U @key(fields: "id") { + id: ID! + field(a: [String] @fromContext(field: "$context { prop }")): Int! + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + + // This should succeed without any validation errors + assert!( + errors.errors.is_empty(), + "Should not have validation errors for valid list type usage" + ); + } + + #[test] + // Port note: Ported from JS test "setContext with multiple contexts (duck typing) - success" + fn test_set_context_multiple_contexts_duck_typing_success() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + foo: Foo! + bar: Bar! + } + + type Foo @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type Bar @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field(a: String @fromContext(field: "$context { prop }")): Int! + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + + // This should succeed because both Foo and Bar have the same field type + assert!( + errors.errors.is_empty(), + "Should not have validation errors for duck typing with same field types" + ); + } + + #[test] + // Port note: Ported from JS test "setContext with multiple contexts (type conditions) - success" + fn test_set_context_multiple_contexts_type_conditions_success() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + foo: Foo! + bar: Bar! + } + + type Foo @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type Bar @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop2: String! + } + + type U @key(fields: "id") { + id: ID! + field(a: String @fromContext(field: "$context ... on Foo { prop } ... on Bar { prop2 }")): Int! + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + + assert!( + errors.errors.is_empty(), + "Should not have validation errors for valid interface context" + ); + } + + #[test] + // Port note: Ported from JS test "setContext on interface - success" + fn test_set_context_on_interface_success() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + i: I! + } + + interface I @context(name: "context") { + prop: String! + } + + type T implements I @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field(a: String @fromContext(field: "$context { prop }")): Int! + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + + // This should succeed with interface context + assert!( + errors.errors.is_empty(), + "Should not have validation errors for valid interface context" + ); + } + + #[test] + // Port note: Ported from JS test "setContext on interface with type condition - success" + fn test_set_context_on_interface_with_type_condition_success() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + i: I! + } + + interface I @context(name: "context") { + prop: String! + } + + type T implements I @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field(a: String @fromContext(field: "$context ... on T { prop }")): Int! + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + assert!( + errors.errors.is_empty(), + "Should not have validation errors for valid interface context" + ); + } + + #[test] + // Port note: Ported from JS test "nullability mismatch is ok if contextual value is non-nullable" + fn test_nullability_mismatch_ok_if_contextual_value_non_nullable() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field(a: String @fromContext(field: "$context { prop }")): Int! + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + // This should succeed - nullability mismatch is ok if contextual value is non-nullable + assert!( + errors.errors.is_empty(), + "Should not have validation errors for nullability mismatch when contextual value is non-nullable" + ); + } + + #[test] + #[ignore] // TODO: Fix this if we decide we care, but probably not worth the effort + // Port note: Ported from JS test "contextual argument on a directive definition argument" + fn test_fromcontext_on_directive_definition() { + let schema_str = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@context", "@fromContext", "@key"]) + + directive @testDirective( + contextArg: String @fromContext(field: "$context prop") + ) on FIELD_DEFINITION + + type Query { + parent: Parent + } + + type Parent @context(name: "context") @key(fields: "id") { + id: ID! + prop: String! + } + + type Target @key(fields: "targetId") { + targetId: ID! + value: String + } + "#; + + let subgraph = build_and_expand(schema_str); + let mut errors = MultipleFederationErrors::new(); + + // First validate context directives to build the context map + let context_map = crate::schema::validators::context::validate_context_directives( + subgraph.schema(), + &mut errors, + ) + .expect("validates context directives"); + + // Then validate fromContext directives + validate_from_context_directives( + subgraph.schema(), + subgraph.metadata(), + &context_map, + &mut errors, + ) + .expect("validates fromContext directives"); + + assert!( + !errors.errors.is_empty(), + "Should have validation errors for @fromContext on directive definition argument" + ); + } +} diff --git a/apollo-federation/src/schema/validators/interface_object.rs b/apollo-federation/src/schema/validators/interface_object.rs new file mode 100644 index 0000000000..37b7709675 --- /dev/null +++ b/apollo-federation/src/schema/validators/interface_object.rs @@ -0,0 +1,166 @@ +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::schema::ValidFederationSchema; +use crate::schema::field_set::parse_field_set; +use crate::schema::position::InterfaceTypeDefinitionPosition; +use crate::schema::subgraph_metadata::SubgraphMetadata; +use crate::utils::human_readable::HumanReadableListOptions; +use crate::utils::human_readable::HumanReadableListPrefix; +use crate::utils::human_readable::human_readable_list; + +pub(crate) fn validate_interface_object_directives( + schema: &ValidFederationSchema, + metadata: &SubgraphMetadata, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + validate_keys_on_interfaces_are_also_on_all_implementations(schema, metadata, errors)?; + validate_interface_objects_are_on_entities(schema, metadata, errors)?; + Ok(()) +} + +fn validate_keys_on_interfaces_are_also_on_all_implementations( + schema: &ValidFederationSchema, + metadata: &SubgraphMetadata, + error_collector: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + let key_directive_definition_name = &metadata + .federation_spec_definition() + .key_directive_definition(schema)? + .name; + for type_pos in schema.get_types() { + let Ok(type_pos) = InterfaceTypeDefinitionPosition::try_from(type_pos) else { + continue; + }; + let implementation_types = schema.possible_runtime_types(type_pos.clone().into())?; + let type_ = type_pos.get(schema.schema())?; + for application in type_.directives.get_all(key_directive_definition_name) { + let arguments = metadata + .federation_spec_definition() + .key_directive_arguments(application)?; + // Note that we will have validated all @key field sets by this point, so we skip + // re-validating here. + let fields = parse_field_set(schema, type_.name.clone(), arguments.fields, false)?; + let mut implementations_with_non_resolvable_keys = vec![]; + let mut implementations_with_missing_keys = vec![]; + for implementation_type_pos in &implementation_types { + let implementation_type = implementation_type_pos.get(schema.schema())?; + let mut matching_application_arguments = None; + for implementation_application in implementation_type + .directives + .get_all(key_directive_definition_name) + { + let implementation_arguments = metadata + .federation_spec_definition() + .key_directive_arguments(implementation_application)?; + let implementation_fields = parse_field_set( + schema, + implementation_type.name.clone(), + implementation_arguments.fields, + false, + )?; + if implementation_fields == fields { + matching_application_arguments = Some(implementation_arguments); + break; + } + } + if let Some(matching_application_arguments) = matching_application_arguments { + // TODO: This code assumes there's at most one matching application for a + // given fieldset, but I'm not sure whether other validation code guarantees + // this. + if arguments.resolvable && !matching_application_arguments.resolvable { + implementations_with_non_resolvable_keys.push(implementation_type_pos); + } + } else { + implementations_with_missing_keys.push(implementation_type_pos); + } + } + + if !implementations_with_missing_keys.is_empty() { + let types_list = human_readable_list( + implementations_with_missing_keys + .iter() + .map(|pos| format!("\"{pos}\"")), + HumanReadableListOptions { + prefix: Some(HumanReadableListPrefix { + singular: "type", + plural: "types", + }), + ..Default::default() + }, + ); + error_collector.errors.push( + SingleFederationError::InterfaceKeyNotOnImplementation { + message: format!( + "Key {} on interface type \"{}\" is missing on implementation {}.", + application.serialize(), + type_pos, + types_list, + ), + }, + ) + } else if !implementations_with_non_resolvable_keys.is_empty() { + let types_list = human_readable_list( + implementations_with_non_resolvable_keys + .iter() + .map(|pos| format!("\"{pos}\"")), + HumanReadableListOptions { + prefix: Some(HumanReadableListPrefix { + singular: "type", + plural: "types", + }), + ..Default::default() + }, + ); + error_collector.errors.push( + SingleFederationError::InterfaceKeyNotOnImplementation { + message: format!( + "Key {} on interface type \"{}\" should be resolvable on all implementation types, but is declared with argument \"@key(resolvable:)\" set to false in {}.", + application.serialize(), + type_pos, + types_list, + ) + } + ) + } + } + } + Ok(()) +} + +fn validate_interface_objects_are_on_entities( + schema: &ValidFederationSchema, + metadata: &SubgraphMetadata, + error_collector: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + let Some(interface_object_directive_definition) = &metadata + .federation_spec_definition() + .interface_object_directive_definition(schema)? + else { + return Ok(()); + }; + let key_directive_definition_name = &metadata + .federation_spec_definition() + .key_directive_definition(schema)? + .name; + for type_pos in &schema + .referencers + .get_directive(&interface_object_directive_definition.name)? + .object_types + { + if !type_pos + .get(schema.schema())? + .directives + .has(key_directive_definition_name) + { + error_collector.errors.push( + SingleFederationError::InterfaceObjectUsageError { + message: format!( + "The @interfaceObject directive can only be applied to entity types but type \"{type_pos}\" has no @key in this subgraph." + ) + } + ) + } + } + Ok(()) +} diff --git a/apollo-federation/src/schema/validators/key.rs b/apollo-federation/src/schema/validators/key.rs new file mode 100644 index 0000000000..8da69a8977 --- /dev/null +++ b/apollo-federation/src/schema/validators/key.rs @@ -0,0 +1,189 @@ +use apollo_compiler::Name; +use apollo_compiler::Schema; +use apollo_compiler::ast::DirectiveList; +use apollo_compiler::executable::Field; +use apollo_compiler::validation::DiagnosticList; +use apollo_compiler::validation::Valid; +use itertools::Itertools; + +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::link::federation_spec_definition::FEDERATION_KEY_DIRECTIVE_NAME_IN_SPEC; +use crate::link::spec::Version; +use crate::link::spec_definition::SpecDefinition; +use crate::schema::FederationSchema; +use crate::schema::HasFields; +use crate::schema::KeyDirective; +use crate::schema::subgraph_metadata::SubgraphMetadata; +use crate::schema::validators::DeniesAliases; +use crate::schema::validators::DeniesArguments; +use crate::schema::validators::DeniesDirectiveApplications; +use crate::schema::validators::DenyAliases; +use crate::schema::validators::DenyFieldsWithArguments; +use crate::schema::validators::DenyFieldsWithDirectiveApplications; +use crate::schema::validators::SchemaFieldSetValidator; +use crate::schema::validators::deny_unsupported_directive_on_interface_type; +use crate::schema::validators::normalize_diagnostic_message; + +pub(crate) fn validate_key_directives( + schema: &FederationSchema, + meta: &SubgraphMetadata, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + let directive_name = meta + .federation_spec_definition() + .directive_name_in_schema(schema, &FEDERATION_KEY_DIRECTIVE_NAME_IN_SPEC)? + .unwrap_or(FEDERATION_KEY_DIRECTIVE_NAME_IN_SPEC); + + let fieldset_rules: Vec>> = vec![ + Box::new(DenyUnionAndInterfaceFields::new(schema.schema())), + Box::new(DenyAliases::new()), + Box::new(DenyFieldsWithDirectiveApplications::new()), + Box::new(DenyFieldsWithArguments::new()), + ]; + + let allow_on_interface = + meta.federation_spec_definition().version() >= &Version { major: 2, minor: 3 }; + + for key_directive in schema.key_directive_applications()? { + match key_directive { + Ok(key) => { + if !allow_on_interface { + deny_unsupported_directive_on_interface_type( + &directive_name, + &key, + schema, + errors, + ); + } + match key.parse_fields(schema.schema()) { + Ok(fields) => { + let existing_error_count = errors.errors.len(); + for rule in fieldset_rules.iter() { + rule.visit(key.target.type_name(), &fields, &key, errors); + } + + // We apply federation-specific validation rules without validating first to maintain compatibility with existing messaging, + // but if we get to this point without errors, we want to make sure it's still a valid selection. + let did_not_find_errors = existing_error_count == errors.errors.len(); + if did_not_find_errors + && let Err(validation_error) = + fields.validate(Valid::assume_valid_ref(schema.schema())) + { + errors.push(invalid_fields_error_from_diagnostics( + &key, + validation_error, + )); + } + } + Err(e) => errors.push(invalid_fields_error_from_diagnostics(&key, e.errors)), + } + } + Err(e) => errors.push(e), + } + } + Ok(()) +} + +fn invalid_fields_error_from_diagnostics( + key: &KeyDirective, + diagnostics: DiagnosticList, +) -> FederationError { + let mut errors = MultipleFederationErrors::new(); + for diagnostic in diagnostics.iter() { + errors.errors.push(SingleFederationError::KeyInvalidFields { + target_type: key.target.type_name().clone(), + application: key.schema_directive.to_string(), + message: normalize_diagnostic_message(diagnostic), + }) + } + errors.into() +} + +/// Instances of `@key(fields:)` cannot select interface or union fields +struct DenyUnionAndInterfaceFields<'schema> { + schema: &'schema Schema, +} + +impl<'schema> DenyUnionAndInterfaceFields<'schema> { + fn new(schema: &'schema Schema) -> Self { + Self { schema } + } +} + +impl SchemaFieldSetValidator> for DenyUnionAndInterfaceFields<'_> { + fn visit_field( + &self, + parent_ty: &Name, + field: &Field, + directive: &KeyDirective, + errors: &mut MultipleFederationErrors, + ) { + let inner_ty = field.definition.ty.inner_named_type(); + if let Some(ty) = self.schema.types.get(inner_ty) { + if ty.is_union() { + errors + .errors + .push(SingleFederationError::KeyFieldsSelectInvalidType { + target_type: directive.target.type_name().clone(), + application: directive.schema_directive.to_string(), + message: format!( + "field \"{}.{}\" is a Union type which is not allowed in @key", + parent_ty, field.name + ), + }) + } else if ty.is_interface() { + errors + .errors + .push(SingleFederationError::KeyFieldsSelectInvalidType { + target_type: directive.target.type_name().clone(), + application: directive.schema_directive.to_string(), + message: format!( + "field \"{}.{}\" is an Interface type which is not allowed in @key", + parent_ty, field.name + ), + }) + } + } + self.visit_selection_set( + field.ty().inner_named_type(), + &field.selection_set, + directive, + errors, + ); + } +} + +impl DeniesAliases for KeyDirective<'_> { + fn error(&self, alias: &Name, field: &Field) -> SingleFederationError { + SingleFederationError::KeyInvalidFields { + target_type: self.target.type_name().clone(), + application: self.schema_directive.to_string(), + message: format!( + "Cannot use alias \"{alias}\" in \"{alias}: {}\": aliases are not currently supported in @key", + field.name + ), + } + } +} + +impl DeniesArguments for KeyDirective<'_> { + fn error(&self, type_name: &Name, field: &Field) -> SingleFederationError { + SingleFederationError::KeyFieldsHasArgs { + target_type: self.target.type_name().clone(), + application: self.schema_directive.to_string(), + inner_coordinate: format!("{}.{}", type_name, field.name), + } + } +} + +impl DeniesDirectiveApplications for KeyDirective<'_> { + fn error(&self, directives: &DirectiveList) -> SingleFederationError { + SingleFederationError::KeyHasDirectiveInFieldsArg { + target_type: self.target.type_name().clone(), + application: self.schema_directive.to_string(), + applied_directives: directives.iter().map(|d| d.to_string()).join(", "), + } + } +} diff --git a/apollo-federation/src/schema/validators/list_size.rs b/apollo-federation/src/schema/validators/list_size.rs new file mode 100644 index 0000000000..f3dcadd045 --- /dev/null +++ b/apollo-federation/src/schema/validators/list_size.rs @@ -0,0 +1,154 @@ +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::ty; + +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::schema::FederationSchema; +use crate::schema::ListSizeDirective; + +pub(crate) fn validate_list_size_directives( + schema: &FederationSchema, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + for list_size_directive in schema.list_size_directive_applications()? { + match list_size_directive { + Ok(list_size) => { + validate_applied_to_list(&list_size, errors); + validate_assumed_size_not_negative(&list_size, errors); + validate_slicing_arguments_are_valid_integers(&list_size, errors); + validate_sized_fields_are_valid_lists(schema, &list_size, errors); + } + Err(e) => errors.push(e), + } + } + Ok(()) +} + +/// Validate that `@listSize` is only applied to lists per +/// https://ibm.github.io/graphql-specs/cost-spec.html#sec-Valid-List-Size-Target +fn validate_applied_to_list(list_size: &ListSizeDirective, errors: &mut MultipleFederationErrors) { + let has_sized_fields = list_size + .directive + .sized_fields + .as_ref() + .is_some_and(|s| !s.is_empty()); + if !has_sized_fields && !list_size.target.ty.is_list() { + errors + .errors + .push(SingleFederationError::ListSizeAppliedToNonList { + message: format!( + "\"{}.{}\" is not a list", + list_size.parent_type, list_size.target.name + ), + }); + } +} + +/// Validate assumed size, but we differ from https://ibm.github.io/graphql-specs/cost-spec.html#sec-Valid-Assumed-Size. +/// Assumed size is used as a backup for slicing arguments in the event they are both specified. +/// The spec aims to rule out cases when the assumed size will never be used because there is always +/// a slicing argument. Two applications which are compliant with that validation rule can be merged +/// into an application which is not compliant, thus we need to handle this case gracefully at runtime regardless. +/// We omit this check to keep the validations to those that will otherwise cause runtime failures. +/// +/// With all that said, assumed size should not be negative. +fn validate_assumed_size_not_negative( + list_size: &ListSizeDirective, + errors: &mut MultipleFederationErrors, +) { + if let Some(size) = list_size.directive.assumed_size + && size < 0 + { + errors + .errors + .push(SingleFederationError::ListSizeInvalidAssumedSize { + message: format!( + "Assumed size of \"{}.{}\" cannot be negative", + list_size.parent_type, list_size.target.name + ), + }); + } +} + +/// Validate `slicingArguments` select valid integer arguments on the target type per +/// https://ibm.github.io/graphql-specs/cost-spec.html#sec-Valid-Slicing-Arguments-Target +fn validate_slicing_arguments_are_valid_integers( + list_size: &ListSizeDirective, + errors: &mut MultipleFederationErrors, +) { + let Some(slicing_argument_names) = list_size.directive.slicing_argument_names.as_ref() else { + return; + }; + for arg_name in slicing_argument_names { + if let Some(slicing_argument) = list_size.target.argument_by_name(arg_name.as_str()) { + if *slicing_argument.ty != ty!(Int) && *slicing_argument.ty != ty!(Int!) { + errors + .errors + .push(SingleFederationError::ListSizeInvalidSlicingArgument { + message: format!( + "Slicing argument \"{}.{}({}:)\" must be Int or Int!", + list_size.parent_type, list_size.target.name, arg_name, + ), + }); + } + } else { + errors + .errors + .push(SingleFederationError::ListSizeInvalidSlicingArgument { + message: format!( + "Slicing argument \"{arg_name}\" is not an argument of \"{}.{}\"", + list_size.parent_type, list_size.target.name + ), + }); + } + } +} + +/// Validate `sizedFields` select valid list fields on the target type per +/// https://ibm.github.io/graphql-specs/cost-spec.html#sec-Valid-Sized-Fields-Target +fn validate_sized_fields_are_valid_lists( + schema: &FederationSchema, + list_size: &ListSizeDirective, + errors: &mut MultipleFederationErrors, +) { + let Some(sized_field_names) = list_size.directive.sized_fields.as_ref() else { + return; + }; + let target_type = list_size.target.ty.inner_named_type(); + let fields = match schema.schema().types.get(target_type) { + Some(ExtendedType::Object(obj)) => &obj.fields, + Some(ExtendedType::Interface(itf)) => &itf.fields, + _ => { + errors + .errors + .push(SingleFederationError::ListSizeInvalidSizedField { + message: format!( + "Sized fields cannot be used because \"{target_type}\" is not a composite type" + ), + }); + return; + } + }; + for field_name in sized_field_names { + if let Some(field) = fields.get(field_name.as_str()) { + if !field.ty.is_list() { + errors + .errors + .push(SingleFederationError::ListSizeAppliedToNonList { + message: format!( + "Sized field \"{target_type}.{field_name}\" is not a list" + ), + }); + } + } else { + errors + .errors + .push(SingleFederationError::ListSizeInvalidSizedField { + message: format!( + "Sized field \"{field_name}\" is not a field on type \"{target_type}\"" + ), + }) + } + } +} diff --git a/apollo-federation/src/schema/validators/merged.rs b/apollo-federation/src/schema/validators/merged.rs new file mode 100644 index 0000000000..d73730666c --- /dev/null +++ b/apollo-federation/src/schema/validators/merged.rs @@ -0,0 +1,372 @@ +use std::sync::LazyLock; + +use apollo_compiler::Name; +use apollo_compiler::schema::Directive; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::FieldDefinition; +use itertools::Itertools; +use regex::Regex; + +use crate::ValidFederationSubgraphs; +use crate::bail; +use crate::ensure; +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::schema::ValidFederationSchema; +use crate::schema::field_set::parse_field_set; +use crate::schema::position::CompositeTypeDefinitionPosition; +use crate::schema::position::InterfaceTypeDefinitionPosition; +use crate::schema::position::ObjectFieldDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceTypeDefinitionPosition; +use crate::utils::human_readable::human_readable_subgraph_names; + +// PORT_NOTE: Named `postMergeValidations` in the JS codebase, but adjusted here to follow the +// naming convention in this directory. Note that this was normally a method in `Merger`, but as +// noted below, the logic overlaps with subgraph validation logic, so to facilitate that future +// de-duplication we're putting it here. +// TODO: The code here largely duplicates logic that is in subgraph schema validation, except that +// when it detects an error, it provides an error in terms of subgraph inputs (rather than what the +// merged/supergraph schema). We could try to avoid that duplication in the future. +// TODO: This code is currently unused. When it is eventually used, it will likely need to return +// `CompositionError` (the `MultipleFederationErrors` might also need to be replace with +// `Vec`). This will likely require adding variants to the comp error and a way +// to convert fed errors into comp errors (though, there are probably better ways to implement this +// besides conversion from fed to comp errors). +#[allow(dead_code)] +pub(crate) fn validate_merged_schema( + supergraph_schema: &ValidFederationSchema, + subgraphs: &ValidFederationSubgraphs, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + for type_pos in supergraph_schema.get_types() { + let Ok(type_pos) = ObjectOrInterfaceTypeDefinitionPosition::try_from(type_pos) else { + continue; + }; + let interface_names = match &type_pos { + ObjectOrInterfaceTypeDefinitionPosition::Object(type_pos) => { + &type_pos + .get(supergraph_schema.schema())? + .implements_interfaces + } + ObjectOrInterfaceTypeDefinitionPosition::Interface(type_pos) => { + &type_pos + .get(supergraph_schema.schema())? + .implements_interfaces + } + }; + for interface_name in interface_names { + let interface_pos = InterfaceTypeDefinitionPosition::new(interface_name.name.clone()); + for interface_field_pos in interface_pos.fields(supergraph_schema.schema())? { + let field_pos = type_pos.field(interface_field_pos.field_name.clone()); + if field_pos.get(supergraph_schema.schema()).is_err() { + // This means that the type was defined (or at least implemented the interface) + // only in subgraphs where the interface didn't have that field. + let subgraphs_with_interface_field = subgraphs + .subgraphs + .values() + .filter(|subgraph| interface_pos.get(subgraph.schema.schema()).is_ok()) + .map(|subgraph| subgraph.name.clone()) + .collect::>(); + let subgraphs_with_type_implementing_interface = subgraphs + .subgraphs + .values() + .filter(|subgraph| { + let Some(subgraph_type) = + subgraph.schema.schema().types.get(type_pos.type_name()) + else { + return false; + }; + match &subgraph_type { + ExtendedType::Object(subgraph_type) => { + subgraph_type.implements_interfaces.contains(interface_name) + } + ExtendedType::Interface(subgraph_type) => { + subgraph_type.implements_interfaces.contains(interface_name) + } + _ => false, + } + }) + .map(|subgraph| subgraph.name.clone()) + .collect::>(); + errors.push(SingleFederationError::InterfaceFieldNoImplem { + message: format!( + "Interface field \"{}\" is declared in {} but type \"{}\" which implements \"${}\" only in {} does not have field \"{}\"", + interface_field_pos, + human_readable_subgraph_names(subgraphs_with_interface_field.iter()), + type_pos, + interface_name, + human_readable_subgraph_names(subgraphs_with_type_implementing_interface.iter()), + interface_field_pos.field_name, + ) + }.into()) + } + + // TODO: Should we validate more? Can we have some invalid implementation of a field + // post-merging? + } + } + } + + // We need to redo some validation for @requires after merging. The reason is that each subgraph + // validates that its own @requires are valid relative to its own schema, but "requirements" are + // really requested from _other_ subgraphs (by definition of @requires really), and there are a + // few situations (see the details below) where validity within the @requires-declaring subgraph + // does not entail validity for all subgraphs that would have to provide those "requirements". + // To summarize, we need to re-validate every @requires against the supergraph to guarantee it + // will always work at runtime. + for subgraph in subgraphs.subgraphs.values() { + let Some(metadata) = &subgraph.schema.subgraph_metadata else { + bail!("Subgraph schema unexpectedly missing metadata"); + }; + let requires_directive_definition_name = &metadata + .federation_spec_definition() + .requires_directive_definition(&subgraph.schema)? + .name; + let requires_referencers = subgraph + .schema + .referencers + .get_directive(requires_directive_definition_name)?; + // Note that @requires is only supported on object fields. + for parent_field_pos in &requires_referencers.object_fields { + let Some(requires_directive) = parent_field_pos + .get(subgraph.schema.schema())? + .directives + .get(requires_directive_definition_name) + else { + bail!("@requires unexpectedly missing from field that references it"); + }; + let requires_arguments = &metadata + .federation_spec_definition() + .requires_directive_arguments(requires_directive)?; + // The type should exist in the supergraph schema. There are a few types we don't merge, + // but those are from specific link/core features and they shouldn't have @requires. In + // fact, if we were to not merge a type with a @requires, this would essentially mean + // that @requires would not work, so its worth catching the issue early if this ever + // happens for some reason. And of course, the type should be composite since it's also + // one in at least the subgraph we're currently checking. + let parent_type_pos_in_supergraph: CompositeTypeDefinitionPosition = supergraph_schema + .get_type(parent_field_pos.type_name.clone())? + .try_into()?; + // TODO: Once parse_field_set() gains the ability to rewrite error messages, we should + // explicitly disable that here (as it's subgraph-specific error rewriting). + let Some(error) = parse_field_set( + supergraph_schema, + parent_type_pos_in_supergraph.type_name().clone(), + requires_arguments.fields, + true, + ) + .err() else { + continue; + }; + // Providing a useful error message to the user here is tricky in the general case + // because what we checked is that a given subgraph @requires application is invalid "on + // the supergraph", but the user seeing the error will not have the supergraph, so we + // need to express the error in terms of the subgraphs. + // + // But in practice, there is only a handful of cases that can trigger an error here. + // Indeed, at this point we know that: + // - The @requires application is valid in its original subgraph. + // - There was no merging errors (we don't call this method otherwise). + // This eliminates the risk of the error being due to some invalid syntax, some + // selection set on a non-composite type or missing selection set on a composite one + // (merging would have errored), some unknown field in the field set (output types + // are merged by union, so any field in the subgraph will be in the supergraph), or even + // any error due to the types of fields involved (because the merged type is always a + // (non-strict) supertype of its counterpart in any subgraph, and anything that could be + // queried in a subtype can be queried on a supertype). + // + // As such, the only errors that we can have here are due to field arguments: because + // they are merged by intersection, it _is_ possible that something that is valid in a + // subgraph is not valid in the supergraph. And the only 2 things that can make such an + // invalidity are: + // 1. An argument may not be in the supergraph: it is in the subgraph, but not in all + // the subgraphs having the field, and the `@requires` passes a concrete value to + // that argument. + // 2. The type of an argument in the supergraph is a strict subtype of the type of that + // argument in the subgraph (the one with the `@requires`) _and_ the @requires + // field set relies on the type difference. Now, argument types are input types, and + // the only subtyping difference that can occur with input types is related to + // nullability (input types support neither interfaces nor unions), so the only case + // this can happen is if a field `x` has some argument `a` with type `A` in the + // subgraph but type `A!` with no default in the supergraph, _and_ the `@requires` + // field set queries that field `x` _without_ a value for `a` (valid when `a` has + // type `A` but not with `A!` and no default). + // So to ensure we provide good error messages, we brute-force detecting those 2 + // possible cases and have a special treatment for each. + // + // Note that this detection is based on pattern-matching the error message, which is + // somewhat fragile, but because we only have 2 cases, we can easily cover them with + // unit tests, which means there is no practical risk of a message change breaking this + // code and being released undetected. A cleaner implementation would probably require + // having error codes and variants for all the GraphQL validations. The apollo-compiler + // crate has this already, but it's crate-private and potentially unstable, so we can't + // use that for now. + for error in error.into_errors() { + let SingleFederationError::InvalidGraphQL { message } = error else { + errors.push(error.into()); + continue; + }; + if let Some(captures) = + APOLLO_COMPILER_UNDEFINED_ARGUMENT_PATTERN.captures(&message) + { + let Some(argument_name) = captures.get(1).map(|m| m.as_str()) else { + bail!("Unexpectedly no argument name in undefined argument error regex") + }; + let Some(type_name) = captures.get(2).map(|m| m.as_str()) else { + bail!("Unexpectedly no type name in undefined argument error regex") + }; + let Some(field_name) = captures.get(3).map(|m| m.as_str()) else { + bail!("Unexpectedly no field name in undefined argument error regex") + }; + add_requires_error( + parent_field_pos, + requires_directive, + &subgraph.name, + type_name, + field_name, + argument_name, + |field_definition| { + Ok(field_definition.argument_by_name(argument_name).is_none()) + }, + |incompatible_subgraphs| { + Ok(format!( + "cannot provide a value for argument \"{argument_name}\" of field \"{field_name}\" as argument \"{argument_name}\" is not defined in {incompatible_subgraphs}", + )) + }, + subgraphs, + errors, + )?; + continue; + } + if let Some(captures) = APOLLO_COMPILER_REQUIRED_ARGUMENT_PATTERN.captures(&message) + { + let Some(type_name) = captures.get(1).map(|m| m.as_str()) else { + bail!("Unexpectedly no type name in required argument error regex"); + }; + let Some(field_name) = captures.get(2).map(|m| m.as_str()) else { + bail!("Unexpectedly no field name in required argument error regex"); + }; + let Some(argument_name) = captures.get(3).map(|m| m.as_str()) else { + bail!("Unexpectedly no argument name in required argument error regex"); + }; + add_requires_error( + parent_field_pos, + requires_directive, + &subgraph.name, + type_name, + field_name, + argument_name, + |field_definition| { + Ok(field_definition + .argument_by_name(argument_name) + .map(|arg| arg.is_required()) + .unwrap_or_default()) + }, + |incompatible_subgraphs| { + Ok(format!( + "no value provided for argument \"{argument_name}\" of field \"{field_name}\" but a value is mandatory as \"{argument_name}\" is required in {incompatible_subgraphs}", + )) + }, + subgraphs, + errors, + )?; + continue; + } + bail!( + "Unexpected error throw by {} when evaluated on supergraph: {}", + requires_directive, + message, + ); + } + } + } + + Ok(()) +} + +// This matches the error message for `DiagnosticData::UndefinedArgument` as defined in +// https://github.com/apollographql/apollo-rs/blob/apollo-compiler%401.28.0/crates/apollo-compiler/src/validation/diagnostics.rs#L36 +static APOLLO_COMPILER_UNDEFINED_ARGUMENT_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r#"the argument `((?-u:\w)+)` is not supported by `((?-u:\w)+)\.((?-u:\w)+)`"#) + .unwrap() +}); + +// This matches the error message for `DiagnosticData::RequiredArgument` as defined in +// https://github.com/apollographql/apollo-rs/blob/apollo-compiler%401.28.0/crates/apollo-compiler/src/validation/diagnostics.rs#L88 +static APOLLO_COMPILER_REQUIRED_ARGUMENT_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new( + r#"the required argument `((?-u:\w)+)\.((?-u:\w)+)\(((?-u:\w)+):\)` is not provided"#, + ) + .unwrap() +}); + +// TODO: This method is transtively not used. Once `validate_merged_schema` is used and transformed +// to handle composition errors rather than (or in conjunction with) federation errors, this +// function will need to be able to return either SubgraphErrors or composition errors. +#[allow(clippy::too_many_arguments)] +fn add_requires_error( + requires_parent_field_pos: &ObjectFieldDefinitionPosition, + requires_application: &Directive, + subgraph_name: &str, + type_name: &str, + field_name: &str, + argument_name: &str, + is_field_incompatible: impl Fn(&FieldDefinition) -> Result, + message_for_incompatible_subgraphs: impl Fn(&str) -> Result, + subgraphs: &ValidFederationSubgraphs, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + let type_name = Name::new(type_name)?; + let field_name = Name::new(field_name)?; + let argument_name = Name::new(argument_name)?; + let incompatible_subgraph_names = subgraphs + .subgraphs + .values() + .map(|other_subgraph| { + if other_subgraph.name == subgraph_name { + return Ok(None); + } + let Ok(type_pos_in_other_subgraph) = other_subgraph.schema.get_type(type_name.clone()) + else { + return Ok(None); + }; + let Ok(type_pos_in_other_subgraph) = + ObjectOrInterfaceTypeDefinitionPosition::try_from(type_pos_in_other_subgraph) + else { + return Ok(None); + }; + let Some(field_in_other_subgraph) = type_pos_in_other_subgraph + .field(field_name.clone()) + .try_get(other_subgraph.schema.schema()) + else { + return Ok(None); + }; + let is_field_incompatible = is_field_incompatible(field_in_other_subgraph)?; + if is_field_incompatible { + Ok::<_, FederationError>(Some(other_subgraph.name.to_string())) + } else { + Ok(None) + } + }) + .process_results(|iter| iter.flatten().collect::>())?; + ensure!( + !incompatible_subgraph_names.is_empty(), + "Got error on argument \"{}\" of field \"{}\" but no \"incompatible\" subgraphs", + argument_name, + field_name, + ); + let incompatible_subgraph_names = + human_readable_subgraph_names(incompatible_subgraph_names.into_iter()); + let message = message_for_incompatible_subgraphs(&incompatible_subgraph_names)?; + + errors.push( + SingleFederationError::RequiresInvalidFields { + coordinate: requires_parent_field_pos.to_string(), + application: requires_application.to_string(), + message, + } + .into(), + ); + Ok(()) +} diff --git a/apollo-federation/src/schema/validators/mod.rs b/apollo-federation/src/schema/validators/mod.rs new file mode 100644 index 0000000000..9281fe89cf --- /dev/null +++ b/apollo-federation/src/schema/validators/mod.rs @@ -0,0 +1,369 @@ +use apollo_compiler::Name; +use apollo_compiler::ast::DirectiveList; +use apollo_compiler::diagnostic::Diagnostic; +use apollo_compiler::executable::Field; +use apollo_compiler::executable::FieldSet; +use apollo_compiler::executable::InlineFragment; +use apollo_compiler::executable::Selection; +use apollo_compiler::executable::SelectionSet; +use apollo_compiler::validation::DiagnosticData; + +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::schema::FederationSchema; +use crate::schema::KeyDirective; +use crate::schema::ProvidesDirective; +use crate::schema::RequiresDirective; +use crate::schema::position::FieldDefinitionPosition; +use crate::schema::position::InterfaceFieldDefinitionPosition; +use crate::schema::position::ObjectFieldDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceFieldDefinitionPosition; +use crate::schema::subgraph_metadata::SubgraphMetadata; + +pub(crate) mod cache_tag; +pub(crate) mod context; +pub(crate) mod cost; +pub(crate) mod external; +pub(crate) mod from_context; +pub(crate) mod interface_object; +pub(crate) mod key; +pub(crate) mod list_size; +pub(crate) mod merged; +pub(crate) mod provides; +pub(crate) mod requires; +pub(crate) mod root_fields; +pub(crate) mod shareable; +pub(crate) mod tag; + +/// A trait for validating FieldSets used in schema directives. Do not use this +/// to validate FieldSets used in operations. This will skip named fragments +/// because they aren't available in the context of a schema. +trait SchemaFieldSetValidator { + fn visit_field( + &self, + parent_ty: &Name, + field: &Field, + directive: &D, + errors: &mut MultipleFederationErrors, + ); + + fn visit( + &self, + parent_ty: &Name, + field_set: &FieldSet, + directive: &D, + errors: &mut MultipleFederationErrors, + ) { + self.visit_selection_set(parent_ty, &field_set.selection_set, directive, errors) + } + + fn visit_inline_fragment( + &self, + parent_ty: &Name, + fragment: &InlineFragment, + directive: &D, + errors: &mut MultipleFederationErrors, + ) { + self.visit_selection_set( + fragment.type_condition.as_ref().unwrap_or(parent_ty), + &fragment.selection_set, + directive, + errors, + ); + } + + fn visit_selection_set( + &self, + parent_ty: &Name, + selection_set: &SelectionSet, + directive: &D, + errors: &mut MultipleFederationErrors, + ) { + for selection in &selection_set.selections { + match selection { + Selection::Field(field) => { + self.visit_field(parent_ty, field, directive, errors); + } + Selection::FragmentSpread(_) => { + // no-op; fragment spreads are not supported in schemas + } + Selection::InlineFragment(fragment) => { + self.visit_inline_fragment(parent_ty, fragment, directive, errors); + } + } + } + } +} + +trait DeniesAliases { + fn error(&self, alias: &Name, field: &Field) -> SingleFederationError; +} + +pub(crate) struct DenyAliases {} + +impl DenyAliases { + pub(crate) fn new() -> Self { + Self {} + } +} + +impl SchemaFieldSetValidator for DenyAliases { + fn visit_field( + &self, + _parent_ty: &Name, + field: &Field, + directive: &D, + errors: &mut MultipleFederationErrors, + ) { + // This largely duplicates the logic of `check_absence_of_aliases`, which was implemented for the QP rewrite. + // That requires a valid schema and some operation data, which we don't have because were only working with a + // schema. Additionally, that implementation uses a slightly different error message than that used by the JS + // version of composition. + if let Some(alias) = field.alias.as_ref() { + errors.errors.push(directive.error(alias, field)); + } + self.visit_selection_set( + field.ty().inner_named_type(), + &field.selection_set, + directive, + errors, + ); + } +} + +trait DeniesArguments { + fn error(&self, parent_ty: &Name, field: &Field) -> SingleFederationError; +} + +pub(crate) struct DenyFieldsWithArguments {} + +impl DenyFieldsWithArguments { + pub(crate) fn new() -> Self { + Self {} + } +} + +impl SchemaFieldSetValidator for DenyFieldsWithArguments { + fn visit_field( + &self, + parent_ty: &Name, + field: &Field, + directive: &D, + errors: &mut MultipleFederationErrors, + ) { + if !field.definition.arguments.is_empty() { + errors.errors.push(directive.error(parent_ty, field)); + } + self.visit_selection_set( + field.ty().inner_named_type(), + &field.selection_set, + directive, + errors, + ); + } +} + +trait DeniesDirectiveApplications { + fn error(&self, directives: &DirectiveList) -> SingleFederationError; +} + +pub(crate) struct DenyFieldsWithDirectiveApplications {} + +impl DenyFieldsWithDirectiveApplications { + pub(crate) fn new() -> Self { + Self {} + } +} + +impl SchemaFieldSetValidator + for DenyFieldsWithDirectiveApplications +{ + fn visit_field( + &self, + _parent_ty: &Name, + field: &Field, + directive: &D, + errors: &mut MultipleFederationErrors, + ) { + if !field.directives.is_empty() { + errors.errors.push(directive.error(&field.directives)) + } + self.visit_selection_set( + field.ty().inner_named_type(), + &field.selection_set, + directive, + errors, + ); + } + + fn visit_inline_fragment( + &self, + parent_ty: &Name, + fragment: &InlineFragment, + directive: &D, + errors: &mut MultipleFederationErrors, + ) { + if !fragment.directives.is_empty() { + errors.errors.push(directive.error(&fragment.directives)); + } + self.visit_selection_set( + fragment.type_condition.as_ref().unwrap_or(parent_ty), + &fragment.selection_set, + directive, + errors, + ); + } +} + +trait DeniesNonExternalLeafFields { + fn error(&self, parent_ty: &Name, field: &Field) -> SingleFederationError; + fn error_for_fake_external_field( + &self, + parent_ty: &Name, + field: &Field, + ) -> SingleFederationError; +} + +struct DenyNonExternalLeafFields<'schema> { + schema: &'schema FederationSchema, + meta: &'schema SubgraphMetadata, +} + +impl<'schema> DenyNonExternalLeafFields<'schema> { + pub(crate) fn new(schema: &'schema FederationSchema, meta: &'schema SubgraphMetadata) -> Self { + Self { schema, meta } + } +} + +impl SchemaFieldSetValidator for DenyNonExternalLeafFields<'_> { + fn visit_field( + &self, + parent_ty: &Name, + field: &Field, + directive: &D, + errors: &mut MultipleFederationErrors, + ) { + let pos = if self.schema.is_interface(parent_ty) { + FieldDefinitionPosition::Interface(InterfaceFieldDefinitionPosition { + type_name: parent_ty.clone(), + field_name: field.name.clone(), + }) + } else { + FieldDefinitionPosition::Object(ObjectFieldDefinitionPosition { + type_name: parent_ty.clone(), + field_name: field.name.clone(), + }) + }; + + if self.meta.is_field_external(&pos) + || (pos.is_interface() && self.meta.is_field_external_in_implementer(&pos)) + { + return; + } + + // PORT_NOTE: In JS, this uses a `hasExternalInParents` flag to determine if the field is external. + // Since this logic is isolated to this one rule, we return early if we encounter an external field, + // so we know that no parent is external if we make it to this point. + let is_leaf = field.selection_set.is_empty(); + if is_leaf { + if self.meta.is_field_fake_external(&pos) { + errors + .errors + .push(directive.error_for_fake_external_field(parent_ty, field)); + } else { + errors.errors.push(directive.error(parent_ty, field)); + } + } else { + self.visit_selection_set(parent_ty, &field.selection_set, directive, errors); + } + } +} + +pub(crate) trait AppliesOnType { + fn applied_type(&self) -> &Name; + fn unsupported_on_interface_error(message: String) -> SingleFederationError; +} + +impl AppliesOnType for KeyDirective<'_> { + fn applied_type(&self) -> &Name { + self.target.type_name() + } + + fn unsupported_on_interface_error(message: String) -> SingleFederationError { + SingleFederationError::KeyUnsupportedOnInterface { message } + } +} + +pub(crate) trait AppliesOnField { + fn applied_field(&self) -> &ObjectOrInterfaceFieldDefinitionPosition; + fn unsupported_on_interface_error(message: String) -> SingleFederationError; +} + +impl AppliesOnField for RequiresDirective<'_> { + fn applied_field(&self) -> &ObjectOrInterfaceFieldDefinitionPosition { + &self.target + } + + fn unsupported_on_interface_error(message: String) -> SingleFederationError { + SingleFederationError::RequiresUnsupportedOnInterface { message } + } +} + +impl AppliesOnField for ProvidesDirective<'_> { + fn applied_field(&self) -> &ObjectOrInterfaceFieldDefinitionPosition { + &self.target + } + + fn unsupported_on_interface_error(message: String) -> SingleFederationError { + SingleFederationError::ProvidesUnsupportedOnInterface { message } + } +} + +pub(crate) fn deny_unsupported_directive_on_interface_type( + directive_name: &Name, + directive_application: &D, + schema: &FederationSchema, + errors: &mut MultipleFederationErrors, +) { + let applied_type = directive_application.applied_type(); + if schema.is_interface(applied_type) { + let directive_display = format!("@{directive_name}"); + errors.push( + D::unsupported_on_interface_error( + format!( + r#"Cannot use {directive_display} on interface "{applied_type}": {directive_display} is not yet supported on interfaces"#, + ), + ) + .into(), + ); + } +} + +pub(crate) fn deny_unsupported_directive_on_interface_field( + directive_name: &Name, + directive_application: &D, + schema: &FederationSchema, + errors: &mut MultipleFederationErrors, +) { + let applied_field = directive_application.applied_field(); + let parent_type = applied_field.parent(); + if schema.is_interface(parent_type.type_name()) { + let directive_display = format!("@{directive_name}"); + errors.push( + D::unsupported_on_interface_error( + format!( + r#"Cannot use {directive_display} on field "{applied_field}" of parent type "{parent_type}": {directive_display} is not yet supported within interfaces"#, + ), + ) + .into(), + ); + } +} + +pub(crate) fn normalize_diagnostic_message(diagnostic: Diagnostic<'_, DiagnosticData>) -> String { + diagnostic + .error + .unstable_compat_message() // Attempt to convert to something closer to the original JS error messages + .unwrap_or_else(|| diagnostic.error.to_string()) // Using `diagnostic.error` strips the potentially misleading location info from the message + .replace("syntax error:", "Syntax error:") +} diff --git a/apollo-federation/src/schema/validators/provides.rs b/apollo-federation/src/schema/validators/provides.rs new file mode 100644 index 0000000000..8080e69cdd --- /dev/null +++ b/apollo-federation/src/schema/validators/provides.rs @@ -0,0 +1,187 @@ +use apollo_compiler::Name; +use apollo_compiler::ast::DirectiveList; +use apollo_compiler::executable::Field; +use apollo_compiler::validation::DiagnosticList; +use apollo_compiler::validation::Valid; +use itertools::Itertools; + +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::link::federation_spec_definition::FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC; +use crate::link::spec_definition::SpecDefinition; +use crate::schema::FederationSchema; +use crate::schema::HasFields; +use crate::schema::ProvidesDirective; +use crate::schema::subgraph_metadata::SubgraphMetadata; +use crate::schema::validators::DeniesAliases; +use crate::schema::validators::DeniesArguments; +use crate::schema::validators::DeniesDirectiveApplications; +use crate::schema::validators::DeniesNonExternalLeafFields; +use crate::schema::validators::DenyAliases; +use crate::schema::validators::DenyFieldsWithArguments; +use crate::schema::validators::DenyFieldsWithDirectiveApplications; +use crate::schema::validators::DenyNonExternalLeafFields; +use crate::schema::validators::SchemaFieldSetValidator; +use crate::schema::validators::deny_unsupported_directive_on_interface_field; +use crate::schema::validators::normalize_diagnostic_message; + +pub(crate) fn validate_provides_directives( + schema: &FederationSchema, + metadata: &SubgraphMetadata, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + let provides_directive_name = metadata + .federation_spec_definition() + .directive_name_in_schema(schema, &FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC)? + .unwrap_or(FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC); + + let fieldset_rules: Vec>> = vec![ + Box::new(DenyAliases::new()), + Box::new(DenyFieldsWithDirectiveApplications::new()), + Box::new(DenyFieldsWithArguments::new()), + Box::new(DenyNonExternalLeafFields::new(schema, metadata)), + ]; + + for provides_directive in schema.provides_directive_applications()? { + match provides_directive { + Ok(provides) => { + deny_unsupported_directive_on_interface_field( + &provides_directive_name, + &provides, + schema, + errors, + ); + + // PORT NOTE: In JS, these two checks are done inside the `targetTypeExtractor`. + if metadata.is_field_external(&provides.target.clone().into()) { + errors.errors.push( + SingleFederationError::ExternalCollisionWithAnotherDirective { + message: format!( + "Cannot have both @provides and @external on field \"{}.{}\"", + provides.target.type_name(), + provides.target.field_name() + ), + }, + ); + continue; + } + if !schema + .schema() + .types + .get(provides.target_return_type.as_str()) + .is_some_and(|t| t.is_object() || t.is_interface() || t.is_union()) + { + errors.errors.push(SingleFederationError::ProvidesOnNonObjectField { message: format!("Invalid @provides directive on field \"{}.{}\": field has type \"{}\" which is not a Composite Type", provides.target.type_name(), provides.target.field_name(), provides.target_return_type) }); + continue; + } + + // PORT NOTE: Think of this as `validateFieldSet`, but the set of rules are already filtered to account for what were boolean flags in JS + match provides.parse_fields(schema.schema()) { + Ok(fields) => { + let existing_error_count = errors.errors.len(); + for rule in fieldset_rules.iter() { + rule.visit(provides.target_return_type, &fields, &provides, errors); + } + + // We apply federation-specific validation rules without validating first to maintain compatibility with existing messaging, + // but if we get to this point without errors, we want to make sure it's still a valid selection. + let did_not_find_errors = existing_error_count == errors.errors.len(); + if did_not_find_errors + && let Err(validation_error) = + fields.validate(Valid::assume_valid_ref(schema.schema())) + { + errors.push(invalid_fields_error_from_diagnostics( + &provides, + validation_error, + )); + } + } + Err(e) => { + errors.push(invalid_fields_error_from_diagnostics(&provides, e.errors)) + } + } + } + Err(e) => errors.push(e), + } + } + Ok(()) +} + +fn invalid_fields_error_from_diagnostics( + provides: &ProvidesDirective, + diagnostics: DiagnosticList, +) -> FederationError { + let mut errors = MultipleFederationErrors::new(); + for diagnostic in diagnostics.iter() { + errors + .errors + .push(SingleFederationError::ProvidesInvalidFields { + coordinate: provides.target.coordinate(), + application: provides.schema_directive.to_string(), + message: normalize_diagnostic_message(diagnostic), + }) + } + errors.into() +} + +impl DeniesAliases for ProvidesDirective<'_> { + fn error(&self, alias: &Name, field: &Field) -> SingleFederationError { + SingleFederationError::ProvidesInvalidFields { + coordinate: self.target.coordinate(), + application: self.schema_directive.to_string(), + message: format!( + "Cannot use alias \"{alias}\" in \"{alias}: {}\": aliases are not currently supported in @provides", + field.name + ), + } + } +} + +impl DeniesArguments for ProvidesDirective<'_> { + fn error(&self, parent_ty: &Name, field: &Field) -> SingleFederationError { + SingleFederationError::ProvidesFieldsHasArgs { + coordinate: self.target.coordinate(), + application: self.schema_directive.to_string(), + inner_coordinate: format!("{}.{}", parent_ty, field.name), + } + } +} + +impl DeniesDirectiveApplications for ProvidesDirective<'_> { + fn error(&self, directives: &DirectiveList) -> SingleFederationError { + SingleFederationError::ProvidesHasDirectiveInFieldsArg { + coordinate: self.target.coordinate(), + application: self.schema_directive.to_string(), + applied_directives: directives.iter().map(|d| d.to_string()).join(", "), + } + } +} + +impl DeniesNonExternalLeafFields for ProvidesDirective<'_> { + fn error(&self, parent_ty: &Name, field: &Field) -> SingleFederationError { + SingleFederationError::ProvidesFieldsMissingExternal { + coordinate: self.target.coordinate(), + application: self.schema_directive.to_string(), + message: format!( + "field \"{}.{}\" should not be part of a @provides since it is already provided by this subgraph (it is not marked @external)", + parent_ty, field.name + ), + } + } + + fn error_for_fake_external_field( + &self, + parent_ty: &Name, + field: &Field, + ) -> SingleFederationError { + SingleFederationError::ProvidesFieldsMissingExternal { + coordinate: self.target.coordinate(), + application: self.schema_directive.to_string(), + message: format!( + "field \"{}.{}\" should not be part of a @provides since it is already \"effectively\" provided by this subgraph (while it is marked @external, it is a @key field of an extension type, which are not internally considered external for historical/backward compatibility reasons)", + parent_ty, field.name + ), + } + } +} diff --git a/apollo-federation/src/schema/validators/requires.rs b/apollo-federation/src/schema/validators/requires.rs new file mode 100644 index 0000000000..7cdd052c28 --- /dev/null +++ b/apollo-federation/src/schema/validators/requires.rs @@ -0,0 +1,155 @@ +use apollo_compiler::Name; +use apollo_compiler::ast::DirectiveList; +use apollo_compiler::executable::Field; +use apollo_compiler::validation::DiagnosticList; +use apollo_compiler::validation::Valid; +use itertools::Itertools; + +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::link::federation_spec_definition::FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC; +use crate::link::spec_definition::SpecDefinition; +use crate::schema::FederationSchema; +use crate::schema::HasFields; +use crate::schema::RequiresDirective; +use crate::schema::subgraph_metadata::SubgraphMetadata; +use crate::schema::validators::DeniesAliases; +use crate::schema::validators::DeniesDirectiveApplications; +use crate::schema::validators::DeniesNonExternalLeafFields; +use crate::schema::validators::DenyAliases; +use crate::schema::validators::DenyFieldsWithDirectiveApplications; +use crate::schema::validators::DenyNonExternalLeafFields; +use crate::schema::validators::SchemaFieldSetValidator; +use crate::schema::validators::deny_unsupported_directive_on_interface_field; +use crate::schema::validators::normalize_diagnostic_message; + +pub(crate) fn validate_requires_directives( + schema: &FederationSchema, + meta: &SubgraphMetadata, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + let requires_directive_name = meta + .federation_spec_definition() + .directive_name_in_schema(schema, &FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC)? + .unwrap_or(FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC); + + let fieldset_rules: Vec>> = vec![ + Box::new(DenyAliases::new()), + Box::new(DenyFieldsWithDirectiveApplications::new()), + Box::new(DenyNonExternalLeafFields::new(schema, meta)), + ]; + + for requires_directive in schema.requires_directive_applications()? { + match requires_directive { + Ok(requires) => { + deny_unsupported_directive_on_interface_field( + &requires_directive_name, + &requires, + schema, + errors, + ); + match requires.parse_fields(schema.schema()) { + Ok(fields) => { + let existing_error_count = errors.errors.len(); + for rule in fieldset_rules.iter() { + rule.visit(requires.target.type_name(), &fields, &requires, errors); + } + + // We apply federation-specific validation rules without validating first to maintain compatibility with existing messaging, + // but if we get to this point without errors, we want to make sure it's still a valid selection. + let did_not_find_errors = existing_error_count == errors.errors.len(); + if did_not_find_errors + && let Err(validation_error) = + fields.validate(Valid::assume_valid_ref(schema.schema())) + { + errors.push(invalid_fields_error_from_diagnostics( + &requires, + validation_error, + )); + } + } + Err(e) => { + errors.push(invalid_fields_error_from_diagnostics(&requires, e.errors)) + } + } + } + Err(e) => errors.push(e), + } + } + Ok(()) +} + +fn invalid_fields_error_from_diagnostics( + requires: &RequiresDirective, + diagnostics: DiagnosticList, +) -> FederationError { + let mut errors = MultipleFederationErrors::new(); + for diagnostic in diagnostics.iter() { + let mut message = normalize_diagnostic_message(diagnostic); + if message.starts_with("Cannot query field") { + message = format!( + "{message} If the field is defined in another subgraph, you need to add it to this subgraph with @external." + ) + } + errors + .errors + .push(SingleFederationError::RequiresInvalidFields { + coordinate: requires.target.coordinate(), + application: requires.schema_directive.to_string(), + message, + }) + } + errors.into() +} + +impl DeniesAliases for RequiresDirective<'_> { + fn error(&self, alias: &Name, field: &Field) -> SingleFederationError { + SingleFederationError::RequiresInvalidFields { + coordinate: self.target.coordinate(), + application: self.schema_directive.to_string(), + message: format!( + "Cannot use alias \"{alias}\" in \"{alias}: {}\": aliases are not currently supported in @requires", + field.name + ), + } + } +} + +impl DeniesDirectiveApplications for RequiresDirective<'_> { + fn error(&self, directives: &DirectiveList) -> SingleFederationError { + SingleFederationError::RequiresHasDirectiveInFieldsArg { + coordinate: self.target.coordinate(), + application: self.schema_directive.to_string(), + applied_directives: directives.iter().map(|d| d.to_string()).join(", "), + } + } +} + +impl DeniesNonExternalLeafFields for RequiresDirective<'_> { + fn error(&self, parent_ty: &Name, field: &Field) -> SingleFederationError { + SingleFederationError::RequiresFieldsMissingExternal { + coordinate: self.target.coordinate(), + application: self.schema_directive.to_string(), + message: format!( + "field \"{}.{}\" should not be part of a @requires since it is already provided by this subgraph (it is not marked @external)", + parent_ty, field.name + ), + } + } + + fn error_for_fake_external_field( + &self, + parent_ty: &Name, + field: &Field, + ) -> SingleFederationError { + SingleFederationError::RequiresFieldsMissingExternal { + coordinate: self.target.coordinate(), + application: self.schema_directive.to_string(), + message: format!( + "field \"{}.{}\" should not be part of a @requires since it is already \"effectively\" provided by this subgraph (while it is marked @external, it is a @key field of an extension type, which are not internally considered external for historical/backward compatibility reasons)", + parent_ty, field.name + ), + } + } +} diff --git a/apollo-federation/src/schema/validators/root_fields.rs b/apollo-federation/src/schema/validators/root_fields.rs new file mode 100644 index 0000000000..21f9e4eced --- /dev/null +++ b/apollo-federation/src/schema/validators/root_fields.rs @@ -0,0 +1,137 @@ +use apollo_compiler::ast::OperationType; + +use crate::error::CompositionError; +use crate::subgraph::typestate::HasMetadata; +use crate::subgraph::typestate::Subgraph; + +/// We rename root types to their default names prior to merging, so we should never get an error +/// from this validation. +/// +/// See [`crate::subgraph::typestate::Subgraph::normalize_root_types()`]. +pub(crate) fn validate_consistent_root_fields( + subgraphs: &[Subgraph], +) -> Result<(), Vec> { + if subgraphs.is_empty() { + return Ok(()); + } + + let mut errors = Vec::with_capacity(3); + if !is_operation_name_consistent(OperationType::Mutation, subgraphs) { + errors.push(CompositionError::InternalError { + message: "Should not have incompatible root type for Mutation".to_string(), + }); + } + if !is_operation_name_consistent(OperationType::Query, subgraphs) { + errors.push(CompositionError::InternalError { + message: "Should not have incompatible root type for Query".to_string(), + }); + } + if !is_operation_name_consistent(OperationType::Subscription, subgraphs) { + errors.push(CompositionError::InternalError { + message: "Should not have incompatible root type for Subscription".to_string(), + }); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } +} + +fn is_operation_name_consistent( + operation_type: OperationType, + subgraphs: &[Subgraph], +) -> bool { + let mut operation_name = None; + + for subgraph in subgraphs { + if let Some(name) = subgraph.schema().schema().root_operation(operation_type) { + if let Some(existing_name) = operation_name { + if existing_name != name { + return false; + } + } else { + operation_name = Some(name); + } + } + } + + true +} + +#[cfg(test)] +mod tests { + use itertools::Itertools; + + use super::*; + use crate::subgraph::typestate::Subgraph; + + #[test] + fn reports_inconsistent_root_field_types() { + let s1 = Subgraph::parse( + "s1", + "", + r#" + type Mutation { + data: String + } + + type Query { + data: String + } + + type Subscription { + data: String + } + "#, + ) + .unwrap() + .assume_expanded() + .unwrap() + .assume_upgraded() + .assume_validated() + .unwrap(); + + let s2 = Subgraph::parse( + "s2", + "", + r#" + extend schema { + mutation: MyMutation + query: MyQuery + subscription: MySubscription + } + + type MyMutation { + data: String + } + + type MyQuery { + data: String + } + + type MySubscription { + data: String + } + "#, + ) + .unwrap() + .assume_expanded() + .unwrap() + .assume_upgraded() + .assume_validated() + .unwrap(); + + let res = validate_consistent_root_fields(&[s1, s2]).unwrap_err(); + let errors = res.iter().map(|e| e.to_string()).collect_vec(); + assert_eq!( + errors, + vec![ + "Should not have incompatible root type for Mutation".to_string(), + "Should not have incompatible root type for Query".to_string(), + "Should not have incompatible root type for Subscription".to_string() + ] + ); + } +} diff --git a/apollo-federation/src/schema/validators/shareable.rs b/apollo-federation/src/schema/validators/shareable.rs new file mode 100644 index 0000000000..f8a7b614e3 --- /dev/null +++ b/apollo-federation/src/schema/validators/shareable.rs @@ -0,0 +1,106 @@ +use apollo_compiler::Name; +use itertools::Itertools; + +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::link::federation_spec_definition::FEDERATION_SHAREABLE_DIRECTIVE_NAME_IN_SPEC; +use crate::schema::FederationSchema; +use crate::schema::referencer::DirectiveReferencers; +use crate::schema::subgraph_metadata::SubgraphMetadata; + +pub(crate) fn validate_shareable_directives( + schema: &FederationSchema, + meta: &SubgraphMetadata, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + let directive_name = meta + .federation_spec_definition() + .shareable_directive_name_in_schema(schema)? + .unwrap_or(FEDERATION_SHAREABLE_DIRECTIVE_NAME_IN_SPEC); + let shareable_referencers = schema.referencers().get_directive(&directive_name)?; + + validate_shareable_not_repeated_on_same_type_declaration( + schema, + &directive_name, + shareable_referencers, + errors, + )?; + validate_shareable_not_repeated_on_same_field_declaration( + schema, + &directive_name, + shareable_referencers, + errors, + )?; + validate_shareable_not_applied_to_interface_fields( + schema, + &directive_name, + shareable_referencers, + errors, + )?; + + Ok(()) +} + +fn validate_shareable_not_repeated_on_same_type_declaration( + schema: &FederationSchema, + directive_name: &Name, + shareable_referencers: &DirectiveReferencers, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + for pos in shareable_referencers.object_types.iter() { + let shareable_applications = pos.get_applied_directives(schema, directive_name); + let count_by_extension = shareable_applications + .iter() + .counts_by(|x| x.origin.extension_id()); + if count_by_extension.iter().any(|(_, count)| *count > 1) { + errors.push( + SingleFederationError::InvalidShareableUsage { + message: format!("Invalid duplicate application of @shareable on the same type declaration of \"{}\": @shareable is only repeatable on types so it can be used simultaneously on a type definition and its extensions, but it should not be duplicated on the same definition/extension declaration", pos.type_name) + }.into(), + ); + } + } + + Ok(()) +} + +fn validate_shareable_not_repeated_on_same_field_declaration( + schema: &FederationSchema, + directive_name: &Name, + shareable_referencers: &DirectiveReferencers, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + for pos in shareable_referencers.object_fields.iter() { + let shareable_applications = pos.get_applied_directives(schema, directive_name); + if shareable_applications.len() > 1 { + errors.push( + SingleFederationError::InvalidShareableUsage { + message: format!("Invalid duplicate application of @shareable on field \"{}.{}\": @shareable is only repeatable on types so it can be used simultaneously on a type definition and its extensions, but it should not be duplicated on the same definition/extension declaration", pos.type_name, pos.field_name) + }.into(), + ); + } + } + + Ok(()) +} + +fn validate_shareable_not_applied_to_interface_fields( + schema: &FederationSchema, + directive_name: &Name, + shareable_referencers: &DirectiveReferencers, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + for pos in shareable_referencers.interface_fields.iter() { + let shareable_applications = pos.get_applied_directives(schema, directive_name); + if !shareable_applications.is_empty() { + errors.push( + SingleFederationError::InvalidShareableUsage { + message: format!("Invalid use of @shareable on field \"{}.{}\": only object type fields can be marked with @shareable", pos.type_name, pos.field_name) + }.into(), + ); + } + } + + Ok(()) +} diff --git a/apollo-federation/src/schema/validators/tag.rs b/apollo-federation/src/schema/validators/tag.rs new file mode 100644 index 0000000000..513ac53808 --- /dev/null +++ b/apollo-federation/src/schema/validators/tag.rs @@ -0,0 +1,52 @@ +use std::sync::LazyLock; + +use regex::Regex; + +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::schema::FederationSchema; +use crate::schema::position::TagDirectiveTargetPosition; + +/// Regex pattern that matches valid tag names: starts with underscore or letter, +/// followed by any combination of hyphens, underscores, forward slashes, digits, or letters +static TAG_NAME_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^[_A-Za-z][-/_0-9A-Za-z]*$").expect("Invalid regex pattern")); + +const MAX_TAG_LENGTH: usize = 128; + +pub(crate) fn validate_tag_directives( + schema: &FederationSchema, + errors: &mut MultipleFederationErrors, +) -> Result<(), FederationError> { + let tag_applications = schema.tag_directive_applications()?; + + for tag_directive_result in tag_applications { + let tag_directive = match tag_directive_result { + Ok(directive) => directive, + Err(error) => { + errors.push(error); + continue; + } + }; + + let tag_name = tag_directive.arguments.name; + + // Validate tag name length and pattern + if tag_name.len() > MAX_TAG_LENGTH || !TAG_NAME_PATTERN.is_match(tag_name) { + let message = if matches!(tag_directive.target, TagDirectiveTargetPosition::Schema(_)) { + format!( + "Schema root has invalid @tag directive value '{tag_name}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + } else { + format!( + "Schema element {} has invalid @tag directive value '{}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores.", + tag_directive.target, tag_name + ) + }; + errors.push(SingleFederationError::InvalidTagName { message }.into()); + } + } + + Ok(()) +} diff --git a/apollo-federation/src/sources/connect/json_selection/README.md b/apollo-federation/src/sources/connect/json_selection/README.md deleted file mode 100644 index f194782395..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/README.md +++ /dev/null @@ -1,860 +0,0 @@ -# What is `JSONSelection` syntax? - -One of the most fundamental goals of the connectors project is that a GraphQL -subgraph schema, all by itself, should be able to encapsulate and selectively -re-expose any JSON-speaking data source as strongly-typed GraphQL, using a -declarative annotation syntax based on the `@source` and `@connect` directives, -with no need for additional resolver code, and without having to run a subgraph -server. - -Delivering on this goal entails somehow transforming arbitrary JSON into -GraphQL-shaped JSON without writing any procedural transformation code. Instead, -these transformations are expressed using a static, declarative string literal -syntax, which resembles GraphQL operation syntax but also supports a number of -other features necessary/convenient for processing arbitrary JSON. - -The _static_ part is important, since we need to be able to tell, by examining a -given `JSONSelection` string at composition time, exactly what shape its output -will have, even though we cannot anticipate every detail of every possible JSON -input that will be encountered at runtime. As a benefit of this static analysis, -we can then validate that the connector schema reliably generates the expected -GraphQL data types. - -In GraphQL terms, this syntax is represented by the `JSONSelection` scalar type, -whose grammar and semantics are detailed in this document. Typically, string -literals obeying this grammar will be passed as the `selection` argument to the -`@connect` directive, which is used to annotate fields of object types within a -subgraph schema. - -In terms of this Rust implementation, the string syntax is parsed into a -`JSONSelection` enum, which implements the `ApplyTo` trait for processing -incoming JSON and producing GraphQL-friendly JSON output. - -## Guiding principles - -As the `JSONSelection` syntax was being designed, and as we consider future -improvements, we should adhere to the following principles: - -1. Since `JSONSelection` syntax resembles GraphQL operation syntax and will - often be used in close proximity to GraphQL operations, whenever an element - of `JSONSelection` syntax looks the same as GraphQL, its behavior and - semantics should be the same as (or at least analogous to) GraphQL. It is - preferable, therefore, to invent new (non-GraphQL) `JSONSelection` syntax - when we want to introduce behaviors that are not part of GraphQL, or when - GraphQL syntax is insufficiently expressive to accomplish a particular - JSON-processing task. For example, `->` method syntax is better for inline - transformations that reusing/abusing GraphQL field argument syntax. - -2. It must be possible to statically determine the output shape (object - properties, array types, and nested value shapes) produced by a - `JSONSelection` string. JSON data encountered at runtime may be inherently - dynamic and unpredicatable, but we must be able to validate the output shape - matches the GraphQL schema. Because we can assume all input data is some kind - of JSON, for types whose shape cannot be statically determined, the GraphQL - `JSON` scalar type can be used as an "any" type, though this should be - avoided because it limits the developer's ability to subselect fields of the - opaque `JSON` value in GraphQL operations. - -3. Backwards compatibility should be maintained as we release new versions of - the `JSONSelection` syntax along with new versions of the (forthcoming) - `@link(url: "https://specs.apollo.dev/connect/vX.Y")` specification. Wherever - possible, we should only add new functionality, not remove or change existing - functionality, unless we are releasing a new major version (and even then we - should be careful not to create unnecessary upgrade work for developers). - -## Formal grammar - -[Extended Backus-Naur Form](https://en.wikipedia.org/wiki/Extended_Backus%E2%80%93Naur_form) -(EBNF) provides a compact way to describe the complete `JSONSelection` grammar. - -This grammar is more for future reference than initial explanation, so don't -worry if it doesn't seem helpful yet, as every rule will be explained in detail -below. - -```ebnf -JSONSelection ::= NakedSubSelection | PathSelection -NakedSubSelection ::= NamedSelection* StarSelection? -SubSelection ::= "{" NakedSubSelection "}" -NamedSelection ::= NamedPathSelection | NamedFieldSelection | NamedQuotedSelection | NamedGroupSelection -NamedPathSelection ::= Alias PathSelection -NamedFieldSelection ::= Alias? Identifier SubSelection? -NamedQuotedSelection ::= Alias StringLiteral SubSelection? -NamedGroupSelection ::= Alias SubSelection -Alias ::= Identifier ":" -PathSelection ::= (VarPath | KeyPath) SubSelection? -VarPath ::= "$" (NO_SPACE Identifier)? PathStep* -KeyPath ::= Key PathStep+ -PathStep ::= "." Key | "->" Identifier MethodArgs? -Key ::= Identifier | StringLiteral -Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]* -StringLiteral ::= "'" ("\\'" | [^'])* "'" | '"' ('\\"' | [^"])* '"' -MethodArgs ::= "(" (JSLiteral ("," JSLiteral)*)? ")" -JSLiteral ::= JSPrimitive | JSObject | JSArray | PathSelection -JSPrimitive ::= StringLiteral | JSNumber | "true" | "false" | "null" -JSNumber ::= "-"? (UnsignedInt ("." [0-9]*)? | "." [0-9]+) -UnsignedInt ::= "0" | [1-9] NO_SPACE [0-9]* -JSObject ::= "{" (JSProperty ("," JSProperty)*)? "}" -JSProperty ::= Key ":" JSLiteral -JSArray ::= "[" (JSLiteral ("," JSLiteral)*)? "]" -StarSelection ::= Alias? "*" SubSelection? -NO_SPACE ::= !SpacesOrComments -SpacesOrComments ::= (Spaces | Comment)+ -Spaces ::= (" " | "\t" | "\r" | "\n")+ -Comment ::= "#" [^\n]* -``` - -### How to read this grammar - -Every valid `JSONSelection` string can be parsed by starting with the -`JSONSelection` non-terminal and repeatedly applying one of the expansions on -the right side of the `::=` operator, with alternatives separated by the `|` -operator. Every `CamelCase` identifier on the left side of the `::=` operator -can be recursively expanded into one of its right-side alternatives. - -Methodically trying out all these alternatives is the fundamental job of the -parser. Parsing succeeds when only terminal tokens remain (quoted text or -regular expression character classes). - -Ambiguities can be resolved by applying the alternatives left to right, -accepting the first set of expansions that fully matches the input tokens. An -example where this kind of ordering matters is the `NamedSelection` rule, which -specifies parsing `NamedPathSelection` before `NamedFieldSelection` and -`NamedQuotedSelection`, so the entire path will be consumed, rather than -mistakenly consuming only the first key in the path as a field name. - -As in many regular expression syntaxes, the `*` and `+` operators denote -repetition (_zero or more_ and _one or more_, respectively), `?` denotes -optionality (_zero or one_), parentheses allow grouping, `"quoted"` or -`'quoted'` text represents raw characters that cannot be expanded further, and -`[...]` specifies character ranges. - -### Whitespace, comments, and `NO_SPACE` - -In many parsers, whitespace and comments are handled by the lexer, which -performs tokenization before the parser sees the input. This approach can -simplify the grammar, because the parser doesn't need to worry about whitespace -or comments, and can focus instead on parsing the structure of the input tokens. - -The grammar shown above adopts this convention. In other words, instead of -explicitly specifying everywhere whitespace and comments are allowed, we -verbally declare that **whitespace and comments are _allowed_ between any -tokens, except where explicitly forbidden by the `NO_SPACE` notation**. The -`NO_SPACE ::= !SpacesOrComments` rule is called _negative lookahead_ in many -parsing systems. Spaces are also implicitly _required_ if omitting them would -undesirably result in parsing adjacent tokens as one token, though the grammar -cannot enforce this requirement. - -While the current Rust parser implementation does not have a formal lexical -analysis phase, the `spaces_or_comments` function is used extensively to consume -whitespace and `#`-style comments wherever they might appear between tokens. The -negative lookahead of `NO_SPACE` is enforced by _avoiding_ `spaces_or_comments` -in a few key places: - -```ebnf -VarPath ::= "$" (NO_SPACE Identifier)? PathStep* -Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]* -UnsignedInt ::= "0" | [1-9] NO_SPACE [0-9]* -``` - -These rules mean the `$` of a `$variable` cannot be separated from the -identifier part (so `$ var` is invalid), and the first character of a -multi-character `Identifier` or `UnsignedInt` must not be separated from the -remaining characters. - -Make sure you use `spaces_or_comments` generously when modifying or adding to -the grammar implementation, or parsing may fail in cryptic ways when the input -contains seemingly harmless whitespace or comment characters. - -### GraphQL string literals vs. `JSONSelection` string literals - -Since the `JSONSelection` syntax is meant to be embedded within GraphQL string -literals, and GraphQL shares the same `'...'` and `"..."` string literal syntax -as `JSONSelection`, it can be visually confusing to embed a `JSONSelection` -string literal (denoted by the `StringLiteral` non-terminal) within a GraphQL -string. - -Fortunately, GraphQL also supports multi-line string literals, delimited by -triple quotes (`"""` or `'''`), which allow using single- or double-quoted -`JSONSelection` string literals freely within the GraphQL string, along with -newlines and `#`-style comments. - -While it can be convenient to write short `JSONSelection` strings inline using -`"` or `'` quotes at the GraphQL level, multi-line string literals are strongly -recommended (with comments!) for any `JSONSelection` string that would overflow -the margin of a typical text editor. - -## Rule-by-rule grammar explanation - -This section discusses each non-terminal production in the `JSONSelection` -grammar, using a visual representation of the EBNF syntax called "railroad -diagrams" to illustrate the possible expansions of each rule. In case you need -to generate new diagrams or regenerate existing ones, you can use [this online -generator](https://rr.red-dove.com/ui), whose source code is available -[here](https://github.com/GuntherRademacher/rr). - -The railroad metaphor comes from the way you read the diagram: start at the ▶▶ -arrows on the far left side, and proceed along any path a train could take -(without reversing) until you reach the ▶◀ arrows on the far right side. -Whenever your "train" stops at a non-terminal node, recursively repeat the -process using the diagram for that non-terminal. When you reach a terminal -token, the input must match that token at the current position to proceed. If -you get stuck, restart from the last untaken branch. The input is considered -valid if you can reach the end of the original diagram, and invalid if you -exhaust all possible alternatives without reaching the end. - -I like to think every stop along the railroad has a gift shop and restrooms, so -feel free to take your time and enjoy the journey. - -### `JSONSelection ::=` - -![JSONSelection](./grammar/JSONSelection.svg) - -The `JSONSelection` non-terminal is the top-level entry point for the grammar, -and appears nowhere else within the rest of the grammar. It can be either a -`NakedSubSelection` (for selecting multiple named items) or a `PathSelection` -(for selecting a single anonymous value from a given path). When the -`PathSelection` option is chosen at this level, the entire `JSONSelection` must -be that single path, without any other named selections. - -### `NakedSubSelection ::=` - -![NakedSubSelection](./grammar/NakedSubSelection.svg) - -A `NakedSubSelection` is a `SubSelection` without the surrounding `{` and `}` -braces. It can appear at the top level of a `JSONSelection`, but otherwise -appears only as part of the `SubSelection` rule, meaning it must have braces -everywhere except at the top level. - -Because a `NakedSubSelection` can contain any number of `NamedSelection` items -(including zero), and may have no `StarSelection`, it's possible for the -`NakedSelection` to be fully empty. In these unusual cases, whitespace and -comments are still allowed, and the result of the selection will always be an -empty object. - -In the Rust implementation, there is no dedicated `NakedSubSelection` struct, as -we use the `SubSelection` struct to represent the meaningful contents of the -selection, regardless of whether it has braces. The `NakedSubSelection` -non-terminal is just a grammatical convenience, to avoid repetition between -`JSONSelection` and `SubSelection`. - -### `SubSelection ::=` - -![SubSelection](./grammar/SubSelection.svg) - -A `SubSelection` is a `NakedSubSelection` surrounded by `{` and `}`, and is used -to select specific properties from the preceding object, much like a nested -selection set in a GraphQL operation. - -Note that `SubSelection` may appear recursively within itself, as part of one of -the various `NamedSelection` rules. This recursion allows for arbitrarily deep -nesting of selections, which is necessary to handle complex JSON structures. - -### `NamedSelection ::=` - -![NamedSelection](./grammar/NamedSelection.svg) - -Every possible production of the `NamedSelection` non-terminal corresponds to a -named property in the output object, though each one obtains its value from the -input object in a slightly different way. - -### `NamedPathSelection ::=` - -![NamedPathSelection](./grammar/NamedPathSelection.svg) - -Since `PathSelection` returns an anonymous value extracted from the given path, -if you want to use a `PathSelection` alongside other `NamedSelection` items, you -have to prefix it with an `Alias`, turning it into a `NamedPathSelection`. - -For example, you cannot omit the `pathName:` alias in the following -`NakedSubSelection`, because `some.nested.path` has no output name by itself: - -```graphql -position { x y } -pathName: some.nested.path { a b c } -scalarField -``` - -The ordering of alternatives in the `NamedSelection` rule is important, so the -`NamedPathSelection` alternative can be considered before `NamedFieldSelection` -and `NamedQuotedSelection`, because a `NamedPathSelection` such as `pathName: -some.nested.path` has a prefix that looks like a `NamedFieldSelection`: -`pathName: some`, causing an error when the parser encounters the remaining -`.nested.path` text. Some parsers would resolve this ambiguity by forbidding `.` -in the lookahead for `Named{Field,Quoted}Selection`, but negative lookahead is -tricky for this parser (see similar discussion regarding `NO_SPACE`), so instead -we greedily parse `NamedPathSelection` first, when possible, since that ensures -the whole path will be consumed. - -### `NamedFieldSelection ::=` - -![NamedFieldSelection](./grammar/NamedFieldSelection.svg) - -The `NamedFieldSelection` non-terminal is the option most closely resembling -GraphQL field selections, where the field name must be an `Identifier`, may have -an `Alias`, and may have a `SubSelection` to select nested properties (which -requires the field's value to be an object rather than a scalar). - -In practice, whitespace is often required to keep multiple consecutive -`NamedFieldSelection` identifiers separate, but is not strictly necessary when -there is no ambiguity, as when an identifier follows a preceding subselection: -`a{b}c`. - -### `NamedQuotedSelection ::=` - -![NamedQuotedSelection](./grammar/NamedQuotedSelection.svg) - -Since arbitrary JSON objects can have properties that are not identifiers, we -need a version of `NamedFieldSelection` that allows for quoted property names as -opposed to identifiers. - -However, since our goal is always to produce an output that is safe for GraphQL -consumption, an `Alias` is strictly required in this case, and it must be a -valid GraphQL `Identifier`: - -```graphql -first -second: "second property" { x y z } -third { a b } -``` - -Besides extracting the `first` and `third` fields in typical GraphQL fashion, -this selection extracts the `second property` field as `second`, subselecting -`x`, `y`, and `z` from the extracted object. The final object will have the -properties `first`, `second`, and `third`. - -### `NamedGroupSelection ::=` - -![NamedGroupSelection](./grammar/NamedGroupSelection.svg) - -Sometimes you will need to take a group of named properties and nest them under -a new name in the output object. The `NamedGroupSelection` syntax allows you to -provide an `Alias` followed by a `SubSelection` that contains the named -properties to be grouped. The `Alias` is mandatory because the grouped object -would otherwise be anonymous. - -For example, if the input JSON has `firstName` and `lastName` fields, but you -want to represent them under a single `names` field in the output object, you -could use the following `NamedGroupSelection`: - -```graphql -names: { - first: firstName - last: lastName -} -# Also allowed: -firstName -lastName -``` - -A common use case for `NamedGroupSelection` is to create nested objects from -scalar ID fields: - -```graphql -postID -title -author: { - id: authorID - name: authorName -} -``` - -This convention is useful when the `Author` type is an entity with `@key(fields: -"id")`, and you want to select fields from `post` and `post.author` in the same -query, without directly handling the `post.authorID` field in GraphQL. - -### `Alias ::=` - -![Alias](./grammar/Alias.svg) - -Analogous to a GraphQL alias, the `Alias` syntax allows for renaming properties -from the input JSON to match the desired output shape. - -In addition to renaming, `Alias` can provide names to otherwise anonymous -structures, such as those selected by `PathSelection`, `NamedGroupSelection`, or -`StarSelection` syntax. - -Because we always want to generate GraphQL-safe output properties, an `Alias` -must be a valid GraphQL identifier, rather than a quoted string. - -### `PathSelection ::=` - -![PathSelection](./grammar/PathSelection.svg) - -A `PathSelection` is a `VarPath` or `KeyPath` followed by an optional -`SubSelection`. The purpose of a `PathSelection` is to extract a single -anonymous value from the input JSON, without preserving the nested structure of -the keys along the path. - -Since properties along the path may be either `Identifier` or `StringLiteral` -values, you are not limited to selecting only properties that are valid GraphQL -field names, e.g. `myID: people."Ben Newman".id`. This is a slight departure -from JavaScript syntax, which would use `people["Ben Newman"].id` to achieve the -same result. Using `.` for all steps along the path is more consistent, and -aligns with the goal of keeping all property names statically analyzable, since -it does not suggest dynamic properties like `people[$name].id` are allowed. - -Often, the whole `JSONSelection` string serves as a `PathSelection`, in cases -where you want to extract a single nested value from the input JSON, without -selecting any other named properties: - -```graphql -type Query { - authorName(isbn: ID!): String @connect( - source: "BOOKS" - http: { GET: "/books/{$args.isbn}"} - selection: "author.name" - ) -} -``` - -If you need to select other named properties, you can still use a -`PathSelection` as part of a `NakedSubSelection`, as long as you give it an -`Alias`: - -```graphql -type Query { - book(isbn: ID!): Book @connect( - source: "BOOKS" - http: { GET: "/books/{$args.isbn}"} - selection: """ - title - year: publication.year - authorName: author.name - """ - ) -} -``` - -### `VarPath ::=` - -![VarPath](./grammar/VarPath.svg) - -A `VarPath` is a `PathSelection` that begins with a `$variable` reference, which -allows embedding arbitrary variables and their sub-properties within the output -object, rather than always selecting a property from the input object. The -`variable` part must be an `Identifier`, and must not be separated from the `$` -by whitespace. - -In the Rust implementation, input variables are passed as JSON to the -`apply_with_vars` method of the `ApplyTo` trait, providing additional context -besides the input JSON. Unlike GraphQL, the provided variables do not all have -to be consumed, since variables like `$this` may have many more possible keys -than you actually want to use. - -Variable references are especially useful when you want to refer to field -arguments (like `$args.some.arg` or `$args { x y }`) or sibling fields of the -current GraphQL object (like `$this.sibling` or `sibs: $this { brother sister -}`). - -Injecting a known argument value comes in handy when your REST endpoint does not -return the property you need: - -```graphql -type Query { - user(id: ID!): User @connect( - source: "USERS" - http: { GET: "/users/{$args.id}"} - selection: """ - # For some reason /users/{$args.id} returns an object with name - # and email but no id, so we inject the id manually: - id: $args.id - name - email - """ - ) -} - -type User @key(fields: "id") { - id: ID! - name: String - email: String -} -``` - -In addition to variables like `$this` and `$args`, a special `$` variable is -always bound to the current value being processed, which allows you to transform -input data that looks like this - -```json -{ - "id": 123, - "name": "Ben", - "friend_ids": [234, 345, 456] -} -``` - -into output data that looks like this - -```json -{ - "id": 123, - "name": "Ben", - "friends": [ - { "id": 234 }, - { "id": 345 }, - { "id": 456 } - ] -} -``` - -using the following `JSONSelection` string: - -```graphql -id name friends: friend_ids { id: $ } -``` - -Because `friend_ids` is an array, the `{ id: $ }` selection maps over each -element of the array, with `$` taking on the value of each scalar ID in turn. -See [the FAQ](#what-about-arrays) for more discussion of this array-handling -behavior. - -The `$` variable is also essential for disambiguating a `KeyPath` consisting of -only one key from a `NamedFieldSelection` with no `Alias`. For example, -`$.result` extracts the `result` property as an anonymous value from the current -object, where as `result` would select an object that still has the `result` -property. - -### `KeyPath ::=` - -![KeyPath](./grammar/KeyPath.svg) - -A `KeyPath` is a `PathSelection` that begins with a `Key` (referring to a -property of the current object) and is followed by a sequence of at least one -`PathStep`, where each `PathStep` either selects a nested key or invokes a `->` -method against the preceding value. - -For example: - -```graphql -items: data.nested.items { id name } -firstItem: data.nested.items->first { id name } -firstItemName: data.nested.items->first.name -``` - -An important ambiguity arises when you want to extract a `PathSelection` -consisting of only a single key, such as `data` by itself. Since there is no `.` -to disambiguate the path from an ordinary `NamedFieldSelection`, the `KeyPath` -rule is inadequate. Instead, you should use a `VarPath` (which also counts as a -`PathSelection`), where the variable is the special `$` character, which -represents the current value being processed: - -```graphql -$.data { id name } -``` - -This will produce a single object with `id` and `name` fields, without the -enclosing `data` property. Equivalently, you could manually unroll this example -to the following `NakedSubSelection`: - -```graphql -id: data.id -name: data.name -``` - -In this case, the `$.` is no longer necessary because `data.id` and `data.name` -are unambiguously `KeyPath` selections. - -> For backwards compatibility with earlier versions of the `JSONSelection` -syntax that did not support the `$` variable, you can also use a leading `.` -character (so `.data { id name }`, or even `.data.id` or `.data.name`) to mean -the same thing as `$.`, but this is no longer recommended, since `.data` is easy -to mistype and misread, compared to `$.data`. - -### `PathStep ::=` - -![PathStep](./grammar/PathStep.svg) - -A `PathStep` is a single step along a `VarPath` or `KeyPath`, which can either -select a nested key using `.` or invoke a method using `->`. - -Keys selected using `.` can be either `Identifier` or `StringLiteral` names, but -method names invoked using `->` must be `Identifier` names, and must be -registered in the `JSONSelection` parser in order to be recognized. - -For the time being, only a fixed set of known methods are supported, though this -list may grow and/or become user-configurable in the future: - -> Full disclosure: even this list is still aspirational, but suggestive of the -> kinds of methods that are likely to be supported in the next version of the -> `JSONSelection` parser. - -```graphql -list->first { id name } -list->last.name -list->slice($args.start, $args.end) -list->reverse -some.value->times(2) -some.value->plus($addend) -some.value->minus(100) -some.value->div($divisor) -isDog: kind->eq("dog") -isNotCat: kind->neq("cat") -__typename: kind->match({ "dog": "Dog", "cat": "Cat" }) -decoded: utf8Bytes->decode("utf-8") -utf8Bytes: string->encode("utf-8") -encoded: bytes->encode("base64") -``` - -### `MethodArgs ::=` - -![MethodArgs](./grammar/MethodArgs.svg) - -When a `PathStep` invokes an `->operator` method, the method invocation may -optionally take a sequence of comma-separated `JSLiteral` arguments in -parentheses, as in `list->slice(0, 5)` or `kilometers: miles->times(1.60934)`. - -Methods do not have to take arguments, as in `list->first` or `list->last`, -which is why `MethodArgs` is optional in `PathStep`. - -### `Key ::=` - -![Key](./grammar/Key.svg) - -A property name occurring along a dotted `PathSelection`, either an `Identifier` -or a `StringLiteral`. - -### `Identifier ::=` - -![Identifier](./grammar/Identifier.svg) - -Any valid GraphQL field name. If you need to select a property that is not -allowed by this rule, use a `NamedQuotedSelection` instead. - -In some languages, identifiers can include `$` characters, but `JSONSelection` -syntax aims to match GraphQL grammar, which does not allow `$` in field names. -Instead, the `$` is reserved for denoting variables in `VarPath` selections. - -### `StringLiteral ::=` - -![StringLiteral](./grammar/StringLiteral.svg) - -A string literal that can be single-quoted or double-quoted, and may contain any -characters except the quote character that delimits the string. The backslash -character `\` can be used to escape the quote character within the string. - -Note that the `\\'` and `\\"` tokens correspond to character sequences -consisting of two characters: a literal backslash `\` followed by a single quote -`'` or double quote `"` character, respectively. The double backslash is -important so the backslash can stand alone, without escaping the quote -character. - -You can avoid most of the headaches of escaping by choosing your outer quote -characters wisely. If your string contains many double quotes, use single quotes -to delimit the string, and vice versa, as in JavaScript. - -### `JSLiteral ::=` - -![JSLiteral](./grammar/JSLiteral.svg) - -A `JSLiteral` represents a JSON-like value that can be passed inline as part of -`MethodArgs`. - -The `JSLiteral` mini-language diverges from JSON by allowing symbolic -`PathSelection` values (which may refer to variables or fields) in addition to -the usual JSON primitives. This allows `->` methods to be parameterized in -powerful ways, e.g. `page: list->slice(0, $limit)`. - -Also, as a minor syntactic convenience, `JSObject` literals can have -`Identifier` or `StringLiteral` keys, whereas JSON objects can have only -double-quoted string literal keys. - -### `JSPrimitive ::=` - -![JSPrimitive](./grammar/JSPrimitive.svg) - -Analogous to a JSON primitive value, with the only differences being that -`JSNumber` does not currently support the exponential syntax, and -`StringLiteral` values can be single-quoted as well as double-quoted. - -### `JSNumber ::=` - -![JSNumber](./grammar/JSNumber.svg) - -A numeric literal that is possibly negative and may contain a fractional -component. The integer component is required unless a fractional component is -present, and the fractional component can have zero digits when the integer -component is present (as in `-123.`), but the fractional component must have at -least one digit when there is no integer component, since `.` is not a valid -numeric literal by itself. Leading and trailing zeroes are essential for the -fractional component, but leading zeroes are disallowed for the integer -component, except when the integer component is exactly zero. - -### `UnsignedInt ::=` - -![UnsignedInt](./grammar/UnsignedInt.svg) - -The integer component of a `JSNumber`, which must be either `0` or an integer -without any leading zeroes. - -### `JSObject ::=` - -![JSObject](./grammar/JSObject.svg) - -A sequence of `JSProperty` items within curly braces, as in JavaScript. - -Trailing commas are not currently allowed, but could be supported in the future. - -### `JSProperty ::=` - -![JSProperty](./grammar/JSProperty.svg) - -A key-value pair within a `JSObject`. Note that the `Key` may be either an -`Identifier` or a `StringLiteral`, as in JavaScript. This is a little different -from JSON, which allows double-quoted strings only. - -### `JSArray ::=` - -![JSArray](./grammar/JSArray.svg) - -A list of `JSLiteral` items within square brackets, as in JavaScript. - -Trailing commas are not currently allowed, but could be supported in the future. - -### `StarSelection ::=` - -![StarSelection](./grammar/StarSelection.svg) - -The `StarSelection` non-terminal is uncommon when working with GraphQL, since it -selects all remaining properties of an object, which can be difficult to -represent using static GraphQL types, without resorting to the catch-all `JSON` -scalar type. Still, a `StarSelection` can be useful for consuming JSON -dictionaries with dynamic keys, or for capturing unexpected properties for -debugging purposes. - -When used, a `StarSelection` must come after any `NamedSelection` items within a -given `NakedSubSelection`. - -A common use case for `StarSelection` is capturing all properties not otherwise -selected using a field called `allOtherFields`, which must have a generic `JSON` -type in the GraphQL schema: - -```graphql -knownField -anotherKnownField -allOtherFields: * -``` - -Note that `knownField` and `anotherKnownField` will not be included in the -`allOtherFields` output, since they are selected explicitly. In this sense, the -`*` functions a bit like object `...rest` syntax in JavaScript. - -If you happen to know these other fields all have certain properties, you can -restrict the `*` selection to just those properties: - -```graphql -knownField { id name } -allOtherFields: * { id } -``` - -Sometimes a REST API will return a dictionary result with an unknown set of -dynamic keys but values of some known type, such as a map of ISBN numbers to -`Book` objects: - -```graphql -booksByISBN: result.books { * { title author { name } } -``` - -Because the set of ISBN numbers is statically unknowable, the type of -`booksByISBN` would have to be `JSON` in the GraphQL schema, but it can still be -useful to select known properties from the `Book` objects within the -`result.books` dictionary, so you don't return more GraphQL data than necessary. - -The grammar technically allows a `StarSelection` with neither an `Alias` nor a -`SubSelection`, but this is not a useful construct from a GraphQL perspective, -since it provides no output fields that can be reliably typed by a GraphQL -schema. This form has some use cases when working with `JSONSelection` outside -of GraphQL, but they are not relevant here. - -### `NO_SPACE ::= !SpacesOrComments` - -The `NO_SPACE` non-terminal is used to enforce the absence of whitespace or -comments between certain tokens. See [Whitespace, comments, and -`NO_SPACE`](#whitespace-comments-and-no_space) for more information. There is no -diagram for this rule because the `!` negative lookahead operator is not -supported by the railroad diagram generator. - -### `SpacesOrComments ::=` - -![SpacesOrComments](./grammar/SpacesOrComments.svg) - -A run of either whitespace or comments involving at least one character, which -are handled equivalently (ignored) by the parser. - -### `Spaces ::=` - -![Spaces](./grammar/Spaces.svg) - -A run of at least one whitespace character, including spaces, tabs, carriage -returns, and newlines. - -Note that we generally allow any amount of whitespace between tokens, so the -`Spaces` non-terminal is not explicitly used in most places where whitespace is -allowed, though it could be used to enforce the presence of some whitespace, if -desired. - -### `Comment ::=` - -![Comment](./grammar/Comment.svg) - -A `#` character followed by any number of characters up to the next newline -character. Comments are allowed anywhere whitespace is allowed, and are handled -like whitespace (i.e. ignored) by the parser. - -## FAQ - -### What about arrays? - -As with standard GraphQL operation syntax, there is no explicit representation -of array-valued fields in this grammar, but (as with GraphQL) a `SubSelection` -following an array-valued field or `PathSelection` will be automatically applied -to every element of the array, thereby preserving/mapping/sub-selecting the -array structure. - -Conveniently, this handling of arrays also makes sense within dotted -`PathSelection` elements, which do not exist in GraphQL. Consider the following -selections, assuming the `author` property of the JSON object has an object -value with a child property called `articles` whose value is an array of -`Article` objects, which have `title`, `date`, `byline`, and `author` -properties: - -```graphql -@connect( - selection: "author.articles.title" #1 - selection: "author.articles { title }" #2 - selection: "author.articles { title date }" #3 - selection: "author.articles.byline.place" #4 - selection: "author.articles.byline { place date }" #5 - selection: "author.articles { name: author.name place: byline.place }" #6 - selection: "author.articles { titleDateAlias: { title date } }" #7 -) -``` - -These selections should produce the following result shapes: - -1. an array of `title` strings -2. an array of `{ title }` objects -3. an array of `{ title date }` objects -4. an array of `place` strings -5. an array of `{ place date }` objects -6. an array of `{ name place }` objects -7. an array of `{ titleDateAlias }` objects - -If the `author.articles` value happened not to be an array, this syntax would -resolve a single result in each case, instead of an array, but the -`JSONSelection` syntax would not have to change to accommodate this possibility. - -If the top-level JSON input itself is an array, then the whole `JSONSelection` -will be applied to each element of that array, and the result will be an array -of those results. - -Compared to dealing explicitly with hard-coded array indices, this automatic -array mapping behavior is much easier to reason about, once you get the hang of -it. If you're familiar with how arrays are handled during GraphQL execution, -it's essentially the same principle, extended to the additional syntaxes -introduced by `JSONSelection`. - -### Why a string-based syntax, rather than first-class syntax? - -### What about field argument syntax? - -### What future `JSONSelection` syntax is under consideration? diff --git a/apollo-federation/src/sources/connect/json_selection/apply_to.rs b/apollo-federation/src/sources/connect/json_selection/apply_to.rs deleted file mode 100644 index 08cb5d0fcb..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/apply_to.rs +++ /dev/null @@ -1,1312 +0,0 @@ -/// ApplyTo is a trait for applying a JSONSelection to a JSON value, collecting -/// any/all errors encountered in the process. -use std::hash::Hash; -use std::hash::Hasher; - -use apollo_compiler::collections::IndexMap; -use apollo_compiler::collections::IndexSet; -use itertools::Itertools; -use serde_json_bytes::json; -use serde_json_bytes::Map; -use serde_json_bytes::Value as JSON; - -use super::helpers::json_type_name; -use super::parser::*; - -pub trait ApplyTo { - // Applying a selection to a JSON value produces a new JSON value, along - // with any/all errors encountered in the process. The value is represented - // as an Option to allow for undefined/missing values (which JSON does not - // explicitly support), which are distinct from null values (which it does - // support). - fn apply_to(&self, data: &JSON) -> (Option, Vec) { - self.apply_with_vars(data, &IndexMap::default()) - } - - fn apply_with_vars( - &self, - data: &JSON, - vars: &IndexMap, - ) -> (Option, Vec) { - let mut input_path = vec![]; - // Using IndexSet over HashSet to preserve the order of the errors. - let mut errors = IndexSet::default(); - let value = self.apply_to_path(data, vars, &mut input_path, &mut errors); - (value, errors.into_iter().collect()) - } - - // This is the trait method that should be implemented and called - // recursively by the various JSONSelection types. - fn apply_to_path( - &self, - data: &JSON, - vars: &IndexMap, - input_path: &mut Vec, - errors: &mut IndexSet, - ) -> Option; - - // When array is encountered, the Self selection will be applied to each - // element of the array, producing a new array. - fn apply_to_array( - &self, - data_array: &[JSON], - vars: &IndexMap, - input_path: &mut Vec, - errors: &mut IndexSet, - ) -> Option { - let mut output = Vec::with_capacity(data_array.len()); - - for (i, element) in data_array.iter().enumerate() { - input_path.push(JSON::Number(i.into())); - let value = self.apply_to_path(element, vars, input_path, errors); - input_path.pop(); - // When building an Object, we can simply omit missing properties - // and report an error, but when building an Array, we need to - // insert null values to preserve the original array indices/length. - output.push(value.unwrap_or(JSON::Null)); - } - - Some(JSON::Array(output)) - } -} - -#[derive(Debug, Eq, PartialEq, Clone)] -pub struct ApplyToError(JSON); - -impl Hash for ApplyToError { - fn hash(&self, hasher: &mut H) { - // Although serde_json::Value (aka JSON) does not implement the Hash - // trait, we can convert self.0 to a JSON string and hash that. To do - // this properly, we should ensure all object keys are serialized in - // lexicographic order before hashing, but the only object keys we use - // are "message" and "path", and they always appear in that order. - self.0.to_string().hash(hasher) - } -} - -impl ApplyToError { - fn new(message: &str, path: &[JSON]) -> Self { - Self(json!({ - "message": message, - "path": JSON::Array(path.to_vec()), - })) - } - - #[cfg(test)] - fn from_json(json: &JSON) -> Self { - if let JSON::Object(error) = json { - if let Some(JSON::String(message)) = error.get("message") { - if let Some(JSON::Array(path)) = error.get("path") { - if path - .iter() - .all(|element| matches!(element, JSON::String(_) | JSON::Number(_))) - { - // Instead of simply returning Self(json.clone()), we - // enforce that the "message" and "path" properties are - // always in that order, as promised in the comment in - // the hash method above. - return Self(json!({ - "message": message, - "path": path, - })); - } - } - } - } - panic!("invalid ApplyToError JSON: {:?}", json); - } - - pub fn message(&self) -> Option<&str> { - self.0 - .as_object() - .and_then(|v| v.get("message")) - .and_then(|s| s.as_str()) - } - - pub fn path(&self) -> Option { - self.0 - .as_object() - .and_then(|v| v.get("path")) - .and_then(|p| p.as_array()) - .map(|l| l.iter().filter_map(|v| v.as_str()).join(".")) - } -} - -impl ApplyTo for JSONSelection { - fn apply_to_path( - &self, - data: &JSON, - vars: &IndexMap, - input_path: &mut Vec, - errors: &mut IndexSet, - ) -> Option { - if let JSON::Array(array) = data { - return self.apply_to_array(array, vars, input_path, errors); - } - - match self { - // Because we represent a JSONSelection::Named as a SubSelection, we - // can fully delegate apply_to_path to SubSelection::apply_to_path. - // Even if we represented Self::Named as a Vec, we - // could still delegate to SubSelection::apply_to_path, but we would - // need to create a temporary SubSelection to wrap the selections - // Vec. - Self::Named(named_selections) => { - named_selections.apply_to_path(data, vars, input_path, errors) - } - Self::Path(path_selection) => { - path_selection.apply_to_path(data, vars, input_path, errors) - } - } - } -} - -impl ApplyTo for NamedSelection { - fn apply_to_path( - &self, - data: &JSON, - vars: &IndexMap, - input_path: &mut Vec, - errors: &mut IndexSet, - ) -> Option { - if let JSON::Array(array) = data { - return self.apply_to_array(array, vars, input_path, errors); - } - - let mut output = Map::new(); - - #[rustfmt::skip] // cargo fmt butchers this closure's formatting - let mut field_quoted_helper = | - alias: Option<&Alias>, - key: Key, - selection: &Option, - input_path: &mut Vec, - | { - input_path.push(key.to_json()); - let name = key.as_string(); - if let Some(child) = data.get(name.clone()) { - let output_name = alias.map_or(&name, |alias| &alias.name); - if let Some(selection) = selection { - let value = selection.apply_to_path(child, vars, input_path, errors); - if let Some(value) = value { - output.insert(output_name.clone(), value); - } - } else { - output.insert(output_name.clone(), child.clone()); - } - } else { - errors.insert(ApplyToError::new( - format!( - "Property {} not found in {}", - key.dotted(), - json_type_name(data), - ).as_str(), - input_path, - )); - } - input_path.pop(); - }; - - match self { - Self::Field(alias, name, selection) => { - field_quoted_helper( - alias.as_ref(), - Key::Field(name.clone()), - selection, - input_path, - ); - } - Self::Quoted(alias, name, selection) => { - field_quoted_helper( - Some(alias), - Key::Quoted(name.clone()), - selection, - input_path, - ); - } - Self::Path(alias, path_selection) => { - let value = path_selection.apply_to_path(data, vars, input_path, errors); - if let Some(value) = value { - output.insert(alias.name.clone(), value); - } - } - Self::Group(alias, sub_selection) => { - let value = sub_selection.apply_to_path(data, vars, input_path, errors); - if let Some(value) = value { - output.insert(alias.name.clone(), value); - } - } - }; - - Some(JSON::Object(output)) - } -} - -impl ApplyTo for PathSelection { - fn apply_to_path( - &self, - data: &JSON, - vars: &IndexMap, - input_path: &mut Vec, - errors: &mut IndexSet, - ) -> Option { - if let JSON::Array(array) = data { - return self.apply_to_array(array, vars, input_path, errors); - } - - match self { - Self::Var(var_name, tail) => { - if var_name == "$" { - // Because $ refers to the current value, we keep using - // input_path instead of creating a new var_path here. - tail.apply_to_path(data, vars, input_path, errors) - } else if let Some(var_data) = vars.get(var_name) { - let mut var_path = vec![json!(var_name)]; - tail.apply_to_path(var_data, vars, &mut var_path, errors) - } else { - errors.insert(ApplyToError::new( - format!("Variable {} not found", var_name).as_str(), - &[json!(var_name)], - )); - None - } - } - Self::Key(key, tail) => { - input_path.push(key.to_json()); - - if !matches!(data, JSON::Object(_)) { - errors.insert(ApplyToError::new( - format!( - "Property {} not found in {}", - key.dotted(), - json_type_name(data), - ) - .as_str(), - input_path, - )); - input_path.pop(); - return None; - } - - let result = if let Some(child) = match key { - Key::Field(name) => data.get(name), - Key::Quoted(name) => data.get(name), - Key::Index(index) => data.get(index), - } { - tail.apply_to_path(child, vars, input_path, errors) - } else { - errors.insert(ApplyToError::new( - format!( - "Property {} not found in {}", - key.dotted(), - json_type_name(data), - ) - .as_str(), - input_path, - )); - None - }; - - input_path.pop(); - - result - } - Self::Selection(selection) => { - // If data is not an object here, this recursive apply_to_path - // call will handle the error. - selection.apply_to_path(data, vars, input_path, errors) - } - Self::Empty => { - // If data is not an object here, we want to preserve its value - // without an error. - Some(data.clone()) - } - } - } -} - -impl ApplyTo for SubSelection { - fn apply_to_path( - &self, - data: &JSON, - vars: &IndexMap, - input_path: &mut Vec, - errors: &mut IndexSet, - ) -> Option { - if let JSON::Array(array) = data { - return self.apply_to_array(array, vars, input_path, errors); - } - - let (data_map, data_really_primitive) = match data { - JSON::Object(data_map) => (data_map.clone(), false), - _primitive => (Map::new(), true), - }; - - let mut output = Map::new(); - let mut input_names = IndexSet::default(); - - for named_selection in &self.selections { - let value = named_selection.apply_to_path(data, vars, input_path, errors); - - // If value is an object, extend output with its keys and their values. - if let Some(JSON::Object(key_and_value)) = value { - output.extend(key_and_value); - } - - // If there is a star selection, we need to keep track of the - // *original* names of the fields that were explicitly selected, - // because we will need to omit them from what the * matches. - if self.star.is_some() { - match named_selection { - NamedSelection::Field(_, name, _) => { - input_names.insert(name.as_str()); - } - NamedSelection::Quoted(_, name, _) => { - input_names.insert(name.as_str()); - } - NamedSelection::Path(_, path_selection) => { - if let PathSelection::Key(key, _) = path_selection { - match key { - Key::Field(name) | Key::Quoted(name) => { - input_names.insert(name.as_str()); - } - // While Property::Index may be used to - // represent the input_path during apply_to_path - // when arrays are encountered, it will never be - // used to represent the parsed structure of any - // actual selection string, becase arrays are - // processed automatically/implicitly and their - // indices are never explicitly selected. This - // means the numeric Property::Index case cannot - // affect the keys selected by * selections, so - // input_names does not need updating here. - Key::Index(_) => {} - }; - } - } - // The contents of groups do not affect the keys matched by - // * selections in the parent object (outside the group). - NamedSelection::Group(_, _) => {} - }; - } - } - - match &self.star { - // Aliased but not subselected, e.g. "a b c rest: *" - Some(StarSelection(Some(alias), None)) => { - let mut star_output = Map::new(); - for (key, value) in &data_map { - if !input_names.contains(key.as_str()) { - star_output.insert(key.clone(), value.clone()); - } - } - output.insert(alias.name.clone(), JSON::Object(star_output)); - } - // Aliased and subselected, e.g. "alias: * { hello }" - Some(StarSelection(Some(alias), Some(selection))) => { - let mut star_output = Map::new(); - for (key, value) in &data_map { - if !input_names.contains(key.as_str()) { - if let Some(selected) = - selection.apply_to_path(value, vars, input_path, errors) - { - star_output.insert(key.clone(), selected); - } - } - } - output.insert(alias.name.clone(), JSON::Object(star_output)); - } - // Not aliased but subselected, e.g. "parent { * { hello } }" - Some(StarSelection(None, Some(selection))) => { - for (key, value) in &data_map { - if !input_names.contains(key.as_str()) { - if let Some(selected) = - selection.apply_to_path(value, vars, input_path, errors) - { - output.insert(key.clone(), selected); - } - } - } - } - // Neither aliased nor subselected, e.g. "parent { * }" or just "*" - Some(StarSelection(None, None)) => { - for (key, value) in &data_map { - if !input_names.contains(key.as_str()) { - output.insert(key.clone(), value.clone()); - } - } - } - // No * selection present, e.g. "parent { just some properties }" - None => {} - }; - - if data_really_primitive && output.is_empty() { - return Some(data.clone()); - } - - Some(JSON::Object(output)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::selection; - - #[test] - fn test_apply_to_selection() { - let data = json!({ - "hello": "world", - "nested": { - "hello": "world", - "world": "hello", - }, - "array": [ - { "hello": "world 0" }, - { "hello": "world 1" }, - { "hello": "world 2" }, - ], - }); - - let check_ok = |selection: JSONSelection, expected_json: JSON| { - let (actual_json, errors) = selection.apply_to(&data); - assert_eq!(actual_json, Some(expected_json)); - assert_eq!(errors, vec![]); - }; - - check_ok(selection!("hello"), json!({"hello": "world"})); - - check_ok( - selection!("nested"), - json!({ - "nested": { - "hello": "world", - "world": "hello", - }, - }), - ); - - check_ok(selection!(".nested.hello"), json!("world")); - check_ok(selection!("$.nested.hello"), json!("world")); - - check_ok(selection!(".nested.world"), json!("hello")); - check_ok(selection!("$.nested.world"), json!("hello")); - - check_ok( - selection!("nested hello"), - json!({ - "hello": "world", - "nested": { - "hello": "world", - "world": "hello", - }, - }), - ); - - check_ok( - selection!("array { hello }"), - json!({ - "array": [ - { "hello": "world 0" }, - { "hello": "world 1" }, - { "hello": "world 2" }, - ], - }), - ); - - check_ok( - selection!("greetings: array { hello }"), - json!({ - "greetings": [ - { "hello": "world 0" }, - { "hello": "world 1" }, - { "hello": "world 2" }, - ], - }), - ); - - check_ok( - selection!(".array { hello }"), - json!([ - { "hello": "world 0" }, - { "hello": "world 1" }, - { "hello": "world 2" }, - ]), - ); - - check_ok( - selection!("worlds: .array.hello"), - json!({ - "worlds": [ - "world 0", - "world 1", - "world 2", - ], - }), - ); - - check_ok( - selection!("worlds: $.array.hello"), - json!({ - "worlds": [ - "world 0", - "world 1", - "world 2", - ], - }), - ); - - check_ok( - selection!(".array.hello"), - json!(["world 0", "world 1", "world 2",]), - ); - - check_ok( - selection!("$.array.hello"), - json!(["world 0", "world 1", "world 2",]), - ); - - check_ok( - selection!("nested grouped: { hello worlds: .array.hello }"), - json!({ - "nested": { - "hello": "world", - "world": "hello", - }, - "grouped": { - "hello": "world", - "worlds": [ - "world 0", - "world 1", - "world 2", - ], - }, - }), - ); - - check_ok( - selection!("nested grouped: { hello worlds: $.array.hello }"), - json!({ - "nested": { - "hello": "world", - "world": "hello", - }, - "grouped": { - "hello": "world", - "worlds": [ - "world 0", - "world 1", - "world 2", - ], - }, - }), - ); - } - - #[test] - fn test_apply_to_star_selections() { - let data = json!({ - "englishAndGreekLetters": { - "a": { "en": "ay", "gr": "alpha" }, - "b": { "en": "bee", "gr": "beta" }, - "c": { "en": "see", "gr": "gamma" }, - "d": { "en": "dee", "gr": "delta" }, - "e": { "en": "ee", "gr": "epsilon" }, - "f": { "en": "eff", "gr": "phi" }, - }, - "englishAndSpanishNumbers": [ - { "en": "one", "es": "uno" }, - { "en": "two", "es": "dos" }, - { "en": "three", "es": "tres" }, - { "en": "four", "es": "cuatro" }, - { "en": "five", "es": "cinco" }, - { "en": "six", "es": "seis" }, - ], - "asciiCharCodes": { - "A": 65, - "B": 66, - "C": 67, - "D": 68, - "E": 69, - "F": 70, - "G": 71, - }, - "books": { - "9780262533751": { - "title": "The Geometry of Meaning", - "author": "Peter Gärdenfors", - }, - "978-1492674313": { - "title": "P is for Pterodactyl: The Worst Alphabet Book Ever", - "author": "Raj Haldar", - }, - "9780262542456": { - "title": "A Biography of the Pixel", - "author": "Alvy Ray Smith", - }, - } - }); - - let check_ok = |selection: JSONSelection, expected_json: JSON| { - let (actual_json, errors) = selection.apply_to(&data); - assert_eq!(actual_json, Some(expected_json)); - assert_eq!(errors, vec![]); - }; - - check_ok( - selection!("englishAndGreekLetters { * { en }}"), - json!({ - "englishAndGreekLetters": { - "a": { "en": "ay" }, - "b": { "en": "bee" }, - "c": { "en": "see" }, - "d": { "en": "dee" }, - "e": { "en": "ee" }, - "f": { "en": "eff" }, - }, - }), - ); - - check_ok( - selection!("englishAndGreekLetters { C: .c.en * { gr }}"), - json!({ - "englishAndGreekLetters": { - "a": { "gr": "alpha" }, - "b": { "gr": "beta" }, - "C": "see", - "d": { "gr": "delta" }, - "e": { "gr": "epsilon" }, - "f": { "gr": "phi" }, - }, - }), - ); - - check_ok( - selection!("englishAndGreekLetters { A: a B: b rest: * }"), - json!({ - "englishAndGreekLetters": { - "A": { "en": "ay", "gr": "alpha" }, - "B": { "en": "bee", "gr": "beta" }, - "rest": { - "c": { "en": "see", "gr": "gamma" }, - "d": { "en": "dee", "gr": "delta" }, - "e": { "en": "ee", "gr": "epsilon" }, - "f": { "en": "eff", "gr": "phi" }, - }, - }, - }), - ); - - check_ok( - selection!(".'englishAndSpanishNumbers' { en rest: * }"), - json!([ - { "en": "one", "rest": { "es": "uno" } }, - { "en": "two", "rest": { "es": "dos" } }, - { "en": "three", "rest": { "es": "tres" } }, - { "en": "four", "rest": { "es": "cuatro" } }, - { "en": "five", "rest": { "es": "cinco" } }, - { "en": "six", "rest": { "es": "seis" } }, - ]), - ); - - // To include/preserve all remaining properties from an object in the output - // object, we support a naked * selection (no alias or subselection). This - // is useful when the values of the properties are scalar, so a subselection - // isn't possible, and we want to preserve all properties of the original - // object. These unnamed properties may not be useful for GraphQL unless the - // whole object is considered as opaque JSON scalar data, but we still need - // to support preserving JSON when it has scalar properties. - check_ok( - selection!("asciiCharCodes { ay: A bee: B * }"), - json!({ - "asciiCharCodes": { - "ay": 65, - "bee": 66, - "C": 67, - "D": 68, - "E": 69, - "F": 70, - "G": 71, - }, - }), - ); - - check_ok( - selection!("asciiCharCodes { * } gee: .asciiCharCodes.G"), - json!({ - "asciiCharCodes": data.get("asciiCharCodes").unwrap(), - "gee": 71, - }), - ); - - check_ok( - selection!("books { * { title } }"), - json!({ - "books": { - "9780262533751": { - "title": "The Geometry of Meaning", - }, - "978-1492674313": { - "title": "P is for Pterodactyl: The Worst Alphabet Book Ever", - }, - "9780262542456": { - "title": "A Biography of the Pixel", - }, - }, - }), - ); - - check_ok( - selection!("books { authorsByISBN: * { author } }"), - json!({ - "books": { - "authorsByISBN": { - "9780262533751": { - "author": "Peter Gärdenfors", - }, - "978-1492674313": { - "author": "Raj Haldar", - }, - "9780262542456": { - "author": "Alvy Ray Smith", - }, - }, - }, - }), - ); - } - - #[test] - fn test_apply_to_errors() { - let data = json!({ - "hello": "world", - "nested": { - "hello": 123, - "world": true, - }, - "array": [ - { "hello": 1, "goodbye": "farewell" }, - { "hello": "two" }, - { "hello": 3.0, "smello": "yellow" }, - ], - }); - - assert_eq!( - selection!("hello").apply_to(&data), - (Some(json!({"hello": "world"})), vec![],) - ); - - let yellow_errors_expected = vec![ApplyToError::from_json(&json!({ - "message": "Property .yellow not found in object", - "path": ["yellow"], - }))]; - assert_eq!( - selection!("yellow").apply_to(&data), - (Some(json!({})), yellow_errors_expected.clone()) - ); - assert_eq!( - selection!(".yellow").apply_to(&data), - (None, yellow_errors_expected.clone()) - ); - assert_eq!( - selection!("$.yellow").apply_to(&data), - (None, yellow_errors_expected.clone()) - ); - - assert_eq!( - selection!(".nested.hello").apply_to(&data), - (Some(json!(123)), vec![],) - ); - - let quoted_yellow_expected = ( - None, - vec![ApplyToError::from_json(&json!({ - "message": "Property .\"yellow\" not found in object", - "path": ["nested", "yellow"], - }))], - ); - assert_eq!( - selection!(".nested.'yellow'").apply_to(&data), - quoted_yellow_expected, - ); - assert_eq!( - selection!("$.nested.'yellow'").apply_to(&data), - quoted_yellow_expected, - ); - - let nested_path_expected = ( - Some(json!({ - "world": true, - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Property .hola not found in object", - "path": ["nested", "hola"], - })), - ApplyToError::from_json(&json!({ - "message": "Property .yellow not found in object", - "path": ["nested", "yellow"], - })), - ], - ); - assert_eq!( - selection!(".nested { hola yellow world }").apply_to(&data), - nested_path_expected, - ); - assert_eq!( - selection!("$.nested { hola yellow world }").apply_to(&data), - nested_path_expected, - ); - - let partial_array_expected = ( - Some(json!({ - "partial": [ - { "hello": 1, "goodbye": "farewell" }, - { "hello": "two" }, - { "hello": 3.0 }, - ], - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Property .goodbye not found in object", - "path": ["array", 1, "goodbye"], - })), - ApplyToError::from_json(&json!({ - "message": "Property .goodbye not found in object", - "path": ["array", 2, "goodbye"], - })), - ], - ); - assert_eq!( - selection!("partial: .array { hello goodbye }").apply_to(&data), - partial_array_expected, - ); - assert_eq!( - selection!("partial: $.array { hello goodbye }").apply_to(&data), - partial_array_expected, - ); - - assert_eq!( - selection!("good: .array.hello bad: .array.smello").apply_to(&data), - ( - Some(json!({ - "good": [ - 1, - "two", - 3.0, - ], - "bad": [ - null, - null, - "yellow", - ], - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Property .smello not found in object", - "path": ["array", 0, "smello"], - })), - ApplyToError::from_json(&json!({ - "message": "Property .smello not found in object", - "path": ["array", 1, "smello"], - })), - ], - ) - ); - - assert_eq!( - selection!("array { hello smello }").apply_to(&data), - ( - Some(json!({ - "array": [ - { "hello": 1 }, - { "hello": "two" }, - { "hello": 3.0, "smello": "yellow" }, - ], - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Property .smello not found in object", - "path": ["array", 0, "smello"], - })), - ApplyToError::from_json(&json!({ - "message": "Property .smello not found in object", - "path": ["array", 1, "smello"], - })), - ], - ) - ); - - assert_eq!( - selection!(".nested { grouped: { hello smelly world } }").apply_to(&data), - ( - Some(json!({ - "grouped": { - "hello": 123, - "world": true, - }, - })), - vec![ApplyToError::from_json(&json!({ - "message": "Property .smelly not found in object", - "path": ["nested", "smelly"], - })),], - ) - ); - - assert_eq!( - selection!("alias: .nested { grouped: { hello smelly world } }").apply_to(&data), - ( - Some(json!({ - "alias": { - "grouped": { - "hello": 123, - "world": true, - }, - }, - })), - vec![ApplyToError::from_json(&json!({ - "message": "Property .smelly not found in object", - "path": ["nested", "smelly"], - })),], - ) - ); - } - - #[test] - fn test_apply_to_nested_arrays() { - let data = json!({ - "arrayOfArrays": [ - [ - { "x": 0, "y": 0 }, - ], - [ - { "x": 1, "y": 0 }, - { "x": 1, "y": 1 }, - { "x": 1, "y": 2 }, - ], - [ - { "x": 2, "y": 0 }, - { "x": 2, "y": 1 }, - ], - [], - [ - null, - { "x": 4, "y": 1 }, - { "x": 4, "why": 2 }, - null, - { "x": 4, "y": 4 }, - ] - ], - }); - - let array_of_arrays_x_expected = ( - Some(json!([[0], [1, 1, 1], [2, 2], [], [null, 4, 4, null, 4],])), - vec![ - ApplyToError::from_json(&json!({ - "message": "Property .x not found in null", - "path": ["arrayOfArrays", 4, 0, "x"], - })), - ApplyToError::from_json(&json!({ - "message": "Property .x not found in null", - "path": ["arrayOfArrays", 4, 3, "x"], - })), - ], - ); - assert_eq!( - selection!(".arrayOfArrays.x").apply_to(&data), - array_of_arrays_x_expected, - ); - assert_eq!( - selection!("$.arrayOfArrays.x").apply_to(&data), - array_of_arrays_x_expected, - ); - - let array_of_arrays_y_expected = ( - Some(json!([ - [0], - [0, 1, 2], - [0, 1], - [], - [null, 1, null, null, 4], - ])), - vec![ - ApplyToError::from_json(&json!({ - "message": "Property .y not found in null", - "path": ["arrayOfArrays", 4, 0, "y"], - })), - ApplyToError::from_json(&json!({ - "message": "Property .y not found in object", - "path": ["arrayOfArrays", 4, 2, "y"], - })), - ApplyToError::from_json(&json!({ - "message": "Property .y not found in null", - "path": ["arrayOfArrays", 4, 3, "y"], - })), - ], - ); - assert_eq!( - selection!(".arrayOfArrays.y").apply_to(&data), - array_of_arrays_y_expected - ); - assert_eq!( - selection!("$.arrayOfArrays.y").apply_to(&data), - array_of_arrays_y_expected - ); - - assert_eq!( - selection!("alias: arrayOfArrays { x y }").apply_to(&data), - ( - Some(json!({ - "alias": [ - [ - { "x": 0, "y": 0 }, - ], - [ - { "x": 1, "y": 0 }, - { "x": 1, "y": 1 }, - { "x": 1, "y": 2 }, - ], - [ - { "x": 2, "y": 0 }, - { "x": 2, "y": 1 }, - ], - [], - [ - null, - { "x": 4, "y": 1 }, - { "x": 4 }, - null, - { "x": 4, "y": 4 }, - ] - ], - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Property .x not found in null", - "path": ["arrayOfArrays", 4, 0, "x"], - })), - ApplyToError::from_json(&json!({ - "message": "Property .y not found in null", - "path": ["arrayOfArrays", 4, 0, "y"], - })), - ApplyToError::from_json(&json!({ - "message": "Property .y not found in object", - "path": ["arrayOfArrays", 4, 2, "y"], - })), - ApplyToError::from_json(&json!({ - "message": "Property .x not found in null", - "path": ["arrayOfArrays", 4, 3, "x"], - })), - ApplyToError::from_json(&json!({ - "message": "Property .y not found in null", - "path": ["arrayOfArrays", 4, 3, "y"], - })), - ], - ), - ); - - let array_of_arrays_x_y_expected = ( - Some(json!({ - "ys": [ - [0], - [0, 1, 2], - [0, 1], - [], - [null, 1, null, null, 4], - ], - "xs": [ - [0], - [1, 1, 1], - [2, 2], - [], - [null, 4, 4, null, 4], - ], - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Property .y not found in null", - "path": ["arrayOfArrays", 4, 0, "y"], - })), - ApplyToError::from_json(&json!({ - "message": "Property .y not found in object", - "path": ["arrayOfArrays", 4, 2, "y"], - })), - ApplyToError::from_json(&json!({ - // Reversing the order of "path" and "message" here to make - // sure that doesn't affect the deduplication logic. - "path": ["arrayOfArrays", 4, 3, "y"], - "message": "Property .y not found in null", - })), - ApplyToError::from_json(&json!({ - "message": "Property .x not found in null", - "path": ["arrayOfArrays", 4, 0, "x"], - })), - ApplyToError::from_json(&json!({ - "message": "Property .x not found in null", - "path": ["arrayOfArrays", 4, 3, "x"], - })), - ], - ); - assert_eq!( - selection!("ys: .arrayOfArrays.y xs: .arrayOfArrays.x").apply_to(&data), - array_of_arrays_x_y_expected, - ); - assert_eq!( - selection!("ys: $.arrayOfArrays.y xs: $.arrayOfArrays.x").apply_to(&data), - array_of_arrays_x_y_expected, - ); - } - - #[test] - fn test_apply_to_variable_expressions() { - let id_object = selection!("id: $").apply_to(&json!(123)); - assert_eq!(id_object, (Some(json!({"id": 123})), vec![])); - - let data = json!({ - "id": 123, - "name": "Ben", - "friend_ids": [234, 345, 456] - }); - - assert_eq!( - selection!("id name friends: friend_ids { id: $ }").apply_to(&data), - ( - Some(json!({ - "id": 123, - "name": "Ben", - "friends": [ - { "id": 234 }, - { "id": 345 }, - { "id": 456 }, - ], - })), - vec![], - ), - ); - - let mut vars = IndexMap::default(); - vars.insert("$args".to_string(), json!({ "id": "id from args" })); - assert_eq!( - selection!("id: $args.id name").apply_with_vars(&data, &vars), - ( - Some(json!({ - "id": "id from args", - "name": "Ben" - })), - vec![], - ), - ); - assert_eq!( - selection!("id: $args.id name").apply_to(&data), - ( - Some(json!({ - "name": "Ben" - })), - vec![ApplyToError::from_json(&json!({ - "message": "Variable $args not found", - "path": ["$args"], - }))], - ), - ); - let mut vars_without_args_id = IndexMap::default(); - vars_without_args_id.insert("$args".to_string(), json!({ "unused": "ignored" })); - assert_eq!( - selection!("id: $args.id name").apply_with_vars(&data, &vars_without_args_id), - ( - Some(json!({ - "name": "Ben" - })), - vec![ApplyToError::from_json(&json!({ - "message": "Property .id not found in object", - "path": ["$args", "id"], - }))], - ), - ); - } - - #[test] - fn test_apply_to_non_identifier_properties() { - let data = json!({ - "not an identifier": [ - { "also.not.an.identifier": 0 }, - { "also.not.an.identifier": 1 }, - { "also.not.an.identifier": 2 }, - ], - "another": { - "pesky string literal!": { - "identifier": 123, - "{ evil braces }": true, - }, - }, - }); - - assert_eq!( - // The grammar enforces that we must always provide identifier aliases - // for non-identifier properties, so the data we get back will always be - // GraphQL-safe. - selection!("alias: 'not an identifier' { safe: 'also.not.an.identifier' }") - .apply_to(&data), - ( - Some(json!({ - "alias": [ - { "safe": 0 }, - { "safe": 1 }, - { "safe": 2 }, - ], - })), - vec![], - ), - ); - - assert_eq!( - selection!(".'not an identifier'.'also.not.an.identifier'").apply_to(&data), - (Some(json!([0, 1, 2])), vec![],), - ); - - assert_eq!( - selection!(".\"not an identifier\" { safe: \"also.not.an.identifier\" }") - .apply_to(&data), - ( - Some(json!([ - { "safe": 0 }, - { "safe": 1 }, - { "safe": 2 }, - ])), - vec![], - ), - ); - - assert_eq!( - selection!( - "another { - pesky: 'pesky string literal!' { - identifier - evil: '{ evil braces }' - } - }" - ) - .apply_to(&data), - ( - Some(json!({ - "another": { - "pesky": { - "identifier": 123, - "evil": true, - }, - }, - })), - vec![], - ), - ); - - assert_eq!( - selection!(".another.'pesky string literal!'.'{ evil braces }'").apply_to(&data), - (Some(json!(true)), vec![],), - ); - - assert_eq!( - selection!(".another.'pesky string literal!'.\"identifier\"").apply_to(&data), - (Some(json!(123)), vec![],), - ); - } -} diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Comment.svg b/apollo-federation/src/sources/connect/json_selection/grammar/Comment.svg deleted file mode 100644 index a28134cc17..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/Comment.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - # - - - [^\n] - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSArray.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSArray.svg deleted file mode 100644 index cd7d51ed75..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSArray.svg +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - [ - - - - JSLiteral - - - - , - - - ] - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSLiteral.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSLiteral.svg deleted file mode 100644 index 74a592e7e7..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSLiteral.svg +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - JSPrimitive - - - - - JSObject - - - - - JSArray - - - - - PathSelection - - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSNumber.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSNumber.svg deleted file mode 100644 index 413b1fe835..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSNumber.svg +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - - - UnsignedInt - - - - . - - - [0-9] - - - . - - - [0-9] - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg deleted file mode 100644 index c828cdaf35..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - NakedSubSelection - - - - - PathSelection - - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSObject.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSObject.svg deleted file mode 100644 index d305abdacd..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSObject.svg +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - { - - - - JSProperty - - - - , - - - } - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSPrimitive.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSPrimitive.svg deleted file mode 100644 index 1b3d452739..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSPrimitive.svg +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - StringLiteral - - - - - JSNumber - - - - true - - - false - - - null - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/KeyPath.svg b/apollo-federation/src/sources/connect/json_selection/grammar/KeyPath.svg deleted file mode 100644 index 191de27c46..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/KeyPath.svg +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - Key - - - - - PathStep - - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/MethodArgs.svg b/apollo-federation/src/sources/connect/json_selection/grammar/MethodArgs.svg deleted file mode 100644 index c000e67e4a..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/MethodArgs.svg +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - ( - - - - JSLiteral - - - - , - - - ) - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedFieldSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedFieldSelection.svg deleted file mode 100644 index d2934e6c78..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NamedFieldSelection.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - Alias - - - - - Identifier - - - - - SubSelection - - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg deleted file mode 100644 index 743cbaf26d..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - Alias - - - - - SubSelection - - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedPathSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedPathSelection.svg deleted file mode 100644 index c00795281d..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NamedPathSelection.svg +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - Alias - - - - - PathSelection - - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg deleted file mode 100644 index 0a28dac9b3..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - Alias - - - - - StringLiteral - - - - - SubSelection - - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedSelection.svg deleted file mode 100644 index 5ee79391fb..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NamedSelection.svg +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - NamedFieldSelection - - - - - NamedQuotedSelection - - - - - NamedPathSelection - - - - - NamedGroupSelection - - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg deleted file mode 100644 index 575d9840a5..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - VarPath - - - - - KeyPath - - - - - SubSelection - - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg deleted file mode 100644 index 2b4615d2e9..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - Alias - - - - * - - - - SubSelection - - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/StringLiteral.svg b/apollo-federation/src/sources/connect/json_selection/grammar/StringLiteral.svg deleted file mode 100644 index 229fe4e594..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/StringLiteral.svg +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - - - - ' - - - \\' - - - [^'] - - - ' - - - " - - - \\" - - - [^"] - - - " - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/SubSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/SubSelection.svg deleted file mode 100644 index 12284c811c..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/SubSelection.svg +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - { - - - - NakedSubSelection - - - - } - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/UnsignedInt.svg b/apollo-federation/src/sources/connect/json_selection/grammar/UnsignedInt.svg deleted file mode 100644 index 3d8fdde47e..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/UnsignedInt.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - [1-9] - - - - NO_SPACE - - - - [0-9] - - - 0 - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/VarPath.svg b/apollo-federation/src/sources/connect/json_selection/grammar/VarPath.svg deleted file mode 100644 index 0017afb15f..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/grammar/VarPath.svg +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - $ - - - - NO_SPACE - - - - - Identifier - - - - - PathStep - - - - - diff --git a/apollo-federation/src/sources/connect/json_selection/graphql.rs b/apollo-federation/src/sources/connect/json_selection/graphql.rs deleted file mode 100644 index b67935c05f..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/graphql.rs +++ /dev/null @@ -1,197 +0,0 @@ -// The JSONSelection syntax is intended to be more generic than GraphQL, capable -// of transforming any aribitrary JSON in arbitrary ways, without assuming the -// universal availability of __typename or other convenient GraphQL-isms. -// However, since we are using the JSONSelection syntax to generate -// GraphQL-shaped output JSON, it's helpful to have some GraphQL-specific -// utilities. -// -// This file contains several trait implementations that allow converting from -// the JSONSelection type to a corresponding GraphQL selection set, where (for -// example) PathSelection syntax is expanded to ordinary nested selection sets. -// The resulting JSON will retain the nested structure of the GraphQL selection -// sets, and thus be more verbose than the output of the JSONSelection syntax, -// but may be easier to use for validating the selection against a GraphQL -// schema, using existing code for validating GraphQL operations. - -use apollo_compiler::ast; -use apollo_compiler::ast::Selection as GraphQLSelection; -use apollo_compiler::Name; - -use super::parser::JSONSelection; -use super::parser::NamedSelection; -use super::parser::PathSelection; -use super::parser::StarSelection; -use super::parser::SubSelection; - -#[derive(Default)] -struct GraphQLSelections(Vec>); - -impl GraphQLSelections { - fn valid_selections(self) -> Vec { - self.0.into_iter().filter_map(|i| i.ok()).collect() - } -} - -impl From> for GraphQLSelections { - fn from(val: Vec) -> Self { - Self(val.into_iter().map(Ok).collect()) - } -} - -impl From for Vec { - fn from(val: JSONSelection) -> Vec { - match val { - JSONSelection::Named(named_selections) => { - GraphQLSelections::from(named_selections).valid_selections() - } - JSONSelection::Path(path_selection) => path_selection.into(), - } - } -} - -fn new_field(name: String, selection: Option) -> GraphQLSelection { - GraphQLSelection::Field( - apollo_compiler::ast::Field { - alias: None, - name: Name::new_unchecked(&name), - arguments: Default::default(), - directives: Default::default(), - selection_set: selection - .map(GraphQLSelections::valid_selections) - .unwrap_or_default(), - } - .into(), - ) -} - -impl From for Vec { - fn from(val: NamedSelection) -> Vec { - match val { - NamedSelection::Field(alias, name, selection) => vec![new_field( - alias.map(|a| a.name).unwrap_or(name), - selection.map(|s| s.into()), - )], - NamedSelection::Quoted(alias, _name, selection) => { - vec![new_field( - alias.name, - selection.map(GraphQLSelections::from), - )] - } - NamedSelection::Path(alias, path_selection) => { - let graphql_selection: Vec = path_selection.into(); - vec![new_field( - alias.name, - Some(GraphQLSelections::from(graphql_selection)), - )] - } - NamedSelection::Group(alias, sub_selection) => { - vec![new_field(alias.name, Some(sub_selection.into()))] - } - } - } -} - -impl From for Vec { - fn from(val: PathSelection) -> Vec { - match val { - PathSelection::Var(_, _) => { - // Variable references do not correspond to GraphQL fields. - vec![] - } - PathSelection::Key(_, tail) => { - let tail = *tail; - tail.into() - } - PathSelection::Selection(selection) => { - GraphQLSelections::from(selection).valid_selections() - } - PathSelection::Empty => vec![], - } - } -} - -impl From for GraphQLSelections { - // give as much as we can, yield errors for star selection without alias. - fn from(val: SubSelection) -> GraphQLSelections { - let mut selections = val - .selections - .into_iter() - .flat_map(|named_selection| { - let selections: Vec = named_selection.into(); - GraphQLSelections::from(selections).0 - }) - .collect::>>(); - - if let Some(StarSelection(alias, sub_selection)) = val.star { - if let Some(alias) = alias { - let star = new_field( - alias.name, - sub_selection.map(|s| GraphQLSelections::from(*s)), - ); - selections.push(Ok(star)); - } else { - selections.push(Err( - "star selection without alias cannot be converted to GraphQL".to_string(), - )); - } - } - GraphQLSelections(selections) - } -} - -#[cfg(test)] -mod tests { - use apollo_compiler::ast::Selection as GraphQLSelection; - - use crate::selection; - - fn print_set(set: &[apollo_compiler::ast::Selection]) -> String { - set.iter() - .map(|s| s.serialize().to_string()) - .collect::>() - .join(" ") - } - - #[test] - fn into_selection_set() { - let selection = selection!("f"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "f"); - - let selection = selection!("f f2 f3"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "f f2 f3"); - - let selection = selection!("f { f2 f3 }"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "f {\n f2\n f3\n}"); - - let selection = selection!("a: f { b: f2 }"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "a {\n b\n}"); - - let selection = selection!(".a { b c }"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "b c"); - - let selection = selection!(".a.b { c: .d e }"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "c e"); - - let selection = selection!("a: { b c }"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "a {\n b\n c\n}"); - - let selection = selection!("a: 'quoted'"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "a"); - - let selection = selection!("a b: *"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "a b"); - - let selection = selection!("a *"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "a"); - } -} diff --git a/apollo-federation/src/sources/connect/json_selection/helpers.rs b/apollo-federation/src/sources/connect/json_selection/helpers.rs deleted file mode 100644 index e811188a82..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/helpers.rs +++ /dev/null @@ -1,151 +0,0 @@ -use nom::character::complete::multispace0; -use nom::IResult; -use serde_json_bytes::Value as JSON; - -// This macro is handy for tests, but it absolutely should never be used with -// dynamic input at runtime, since it panics if the selection string fails to -// parse for any reason. -#[cfg(test)] -#[macro_export] -macro_rules! selection { - ($input:expr) => { - if let Ok((remainder, parsed)) = - $crate::sources::connect::json_selection::JSONSelection::parse($input) - { - assert_eq!(remainder, ""); - parsed - } else { - panic!("invalid selection: {:?}", $input); - } - }; -} - -// Consumes any amount of whitespace and/or comments starting with # until the -// end of the line. -pub fn spaces_or_comments(input: &str) -> IResult<&str, &str> { - let mut suffix = input; - loop { - (suffix, _) = multispace0(suffix)?; - let mut chars = suffix.chars(); - if let Some('#') = chars.next() { - for c in chars.by_ref() { - if c == '\n' { - break; - } - } - suffix = chars.as_str(); - } else { - return Ok((suffix, &input[0..input.len() - suffix.len()])); - } - } -} - -pub fn json_type_name(v: &JSON) -> &str { - match v { - JSON::Array(_) => "array", - JSON::Object(_) => "object", - JSON::String(_) => "string", - JSON::Number(_) => "number", - JSON::Bool(_) => "boolean", - JSON::Null => "null", - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_spaces_or_comments() { - assert_eq!(spaces_or_comments(""), Ok(("", ""))); - assert_eq!(spaces_or_comments(" "), Ok(("", " "))); - assert_eq!(spaces_or_comments(" "), Ok(("", " "))); - - assert_eq!(spaces_or_comments("#"), Ok(("", "#"))); - assert_eq!(spaces_or_comments("# "), Ok(("", "# "))); - assert_eq!(spaces_or_comments(" # "), Ok(("", " # "))); - assert_eq!(spaces_or_comments(" #"), Ok(("", " #"))); - - assert_eq!(spaces_or_comments("#\n"), Ok(("", "#\n"))); - assert_eq!(spaces_or_comments("# \n"), Ok(("", "# \n"))); - assert_eq!(spaces_or_comments(" # \n"), Ok(("", " # \n"))); - assert_eq!(spaces_or_comments(" #\n"), Ok(("", " #\n"))); - assert_eq!(spaces_or_comments(" # \n "), Ok(("", " # \n "))); - - assert_eq!(spaces_or_comments("hello"), Ok(("hello", ""))); - assert_eq!(spaces_or_comments(" hello"), Ok(("hello", " "))); - assert_eq!(spaces_or_comments("hello "), Ok(("hello ", ""))); - assert_eq!(spaces_or_comments("hello#"), Ok(("hello#", ""))); - assert_eq!(spaces_or_comments("hello #"), Ok(("hello #", ""))); - assert_eq!(spaces_or_comments("hello # "), Ok(("hello # ", ""))); - assert_eq!(spaces_or_comments(" hello # "), Ok(("hello # ", " "))); - assert_eq!( - spaces_or_comments(" hello # world "), - Ok(("hello # world ", " ")) - ); - - assert_eq!(spaces_or_comments("#comment"), Ok(("", "#comment"))); - assert_eq!(spaces_or_comments(" #comment"), Ok(("", " #comment"))); - assert_eq!(spaces_or_comments("#comment "), Ok(("", "#comment "))); - assert_eq!(spaces_or_comments("#comment#"), Ok(("", "#comment#"))); - assert_eq!(spaces_or_comments("#comment #"), Ok(("", "#comment #"))); - assert_eq!(spaces_or_comments("#comment # "), Ok(("", "#comment # "))); - assert_eq!( - spaces_or_comments(" #comment # world "), - Ok(("", " #comment # world ")) - ); - assert_eq!( - spaces_or_comments(" # comment # world "), - Ok(("", " # comment # world ")) - ); - - assert_eq!( - spaces_or_comments(" # comment\nnot a comment"), - Ok(("not a comment", " # comment\n")) - ); - assert_eq!( - spaces_or_comments(" # comment\nnot a comment\n"), - Ok(("not a comment\n", " # comment\n")) - ); - assert_eq!( - spaces_or_comments("not a comment\n # comment\nasdf"), - Ok(("not a comment\n # comment\nasdf", "")) - ); - - #[rustfmt::skip] - assert_eq!(spaces_or_comments(" - # This is a comment - # And so is this - not a comment - "), - Ok(("not a comment - ", " - # This is a comment - # And so is this - "))); - - #[rustfmt::skip] - assert_eq!(spaces_or_comments(" - # This is a comment - not a comment - # Another comment - "), - Ok(("not a comment - # Another comment - ", " - # This is a comment - "))); - - #[rustfmt::skip] - assert_eq!(spaces_or_comments(" - not a comment - # This is a comment - # Another comment - "), - Ok(("not a comment - # This is a comment - # Another comment - ", " - "))); - } -} diff --git a/apollo-federation/src/sources/connect/json_selection/mod.rs b/apollo-federation/src/sources/connect/json_selection/mod.rs deleted file mode 100644 index 0abd543130..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod apply_to; -mod graphql; -mod helpers; -mod parser; -mod pretty; - -pub use apply_to::*; -pub use parser::*; -// Pretty code is currently only used in tests, so this cfg is to suppress the -// unused lint warning. If pretty code is needed in not test code, feel free to -// remove the `#[cfg(test)]`. -#[cfg(test)] -pub use pretty::*; diff --git a/apollo-federation/src/sources/connect/json_selection/parser.rs b/apollo-federation/src/sources/connect/json_selection/parser.rs deleted file mode 100644 index f73762f223..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/parser.rs +++ /dev/null @@ -1,1603 +0,0 @@ -use std::fmt::Display; - -use nom::branch::alt; -use nom::character::complete::char; -use nom::character::complete::one_of; -use nom::combinator::all_consuming; -use nom::combinator::map; -use nom::combinator::opt; -use nom::combinator::recognize; -use nom::multi::many0; -use nom::sequence::delimited; -use nom::sequence::pair; -use nom::sequence::preceded; -use nom::sequence::tuple; -use nom::IResult; -use serde::Serialize; -use serde_json_bytes::Value as JSON; - -use super::helpers::spaces_or_comments; - -// JSONSelection ::= NakedSubSelection | PathSelection -// NakedSubSelection ::= NamedSelection* StarSelection? - -#[derive(Debug, PartialEq, Clone, Serialize)] -pub enum JSONSelection { - // Although we reuse the SubSelection type for the JSONSelection::Named - // case, we parse it as a sequence of NamedSelection items without the - // {...} curly braces that SubSelection::parse expects. - Named(SubSelection), - Path(PathSelection), -} - -impl JSONSelection { - pub fn empty() -> Self { - JSONSelection::Named(SubSelection { - selections: vec![], - star: None, - }) - } - - pub fn parse(input: &str) -> IResult<&str, Self> { - alt(( - all_consuming(map( - tuple(( - many0(NamedSelection::parse), - // When a * selection is used, it must be the last selection - // in the sequence, since it is not a NamedSelection. - opt(StarSelection::parse), - // In case there were no named selections and no * selection, we - // still want to consume any space before the end of the input. - spaces_or_comments, - )), - |(selections, star, _)| Self::Named(SubSelection { selections, star }), - )), - all_consuming(map(PathSelection::parse, Self::Path)), - ))(input) - } - - pub(crate) fn next_subselection(&self) -> Option<&SubSelection> { - match self { - JSONSelection::Named(subselect) => Some(subselect), - JSONSelection::Path(path) => path.next_subselection(), - } - } - - pub(crate) fn next_mut_subselection(&mut self) -> Option<&mut SubSelection> { - match self { - JSONSelection::Named(subselect) => Some(subselect), - JSONSelection::Path(path) => path.next_mut_subselection(), - } - } -} - -// NamedSelection ::= NamedPathSelection | NamedFieldSelection | NamedQuotedSelection | NamedGroupSelection -// NamedPathSelection ::= Alias PathSelection -// NamedFieldSelection ::= Alias? Identifier SubSelection? -// NamedQuotedSelection ::= Alias StringLiteral SubSelection? -// NamedGroupSelection ::= Alias SubSelection - -#[derive(Debug, PartialEq, Clone, Serialize)] -pub enum NamedSelection { - Field(Option, String, Option), - Quoted(Alias, String, Option), - Path(Alias, PathSelection), - Group(Alias, SubSelection), -} - -impl NamedSelection { - pub(crate) fn parse(input: &str) -> IResult<&str, Self> { - alt(( - // We must try parsing NamedPathSelection before NamedFieldSelection - // and NamedQuotedSelection because a NamedPathSelection without a - // leading `.`, such as `alias: some.nested.path` has a prefix that - // can be parsed as a NamedFieldSelection: `alias: some`. Parsing - // then fails when it finds the remaining `.nested.path` text. Some - // parsers would solve this by forbidding `.` in the "lookahead" for - // Named{Field,Quoted}Selection, but negative lookahead is tricky in - // nom, so instead we greedily parse NamedPathSelection first. - Self::parse_path, - Self::parse_field, - Self::parse_quoted, - Self::parse_group, - ))(input) - } - - fn parse_field(input: &str) -> IResult<&str, Self> { - tuple(( - opt(Alias::parse), - delimited(spaces_or_comments, parse_identifier, spaces_or_comments), - opt(SubSelection::parse), - ))(input) - .map(|(input, (alias, name, selection))| (input, Self::Field(alias, name, selection))) - } - - fn parse_quoted(input: &str) -> IResult<&str, Self> { - tuple(( - Alias::parse, - delimited(spaces_or_comments, parse_string_literal, spaces_or_comments), - opt(SubSelection::parse), - ))(input) - .map(|(input, (alias, name, selection))| (input, Self::Quoted(alias, name, selection))) - } - - fn parse_path(input: &str) -> IResult<&str, Self> { - tuple((Alias::parse, PathSelection::parse))(input) - .map(|(input, (alias, path))| (input, Self::Path(alias, path))) - } - - fn parse_group(input: &str) -> IResult<&str, Self> { - tuple((Alias::parse, SubSelection::parse))(input) - .map(|(input, (alias, group))| (input, Self::Group(alias, group))) - } - - #[allow(dead_code)] - pub(crate) fn name(&self) -> &str { - match self { - Self::Field(alias, name, _) => { - if let Some(alias) = alias { - alias.name.as_str() - } else { - name.as_str() - } - } - Self::Quoted(alias, _, _) => alias.name.as_str(), - Self::Path(alias, _) => alias.name.as_str(), - Self::Group(alias, _) => alias.name.as_str(), - } - } - - /// Extracts the property path for a given named selection - /// - // TODO: Expand on what this means once I have a better understanding - pub(crate) fn property_path(&self) -> Vec { - match self { - NamedSelection::Field(_, name, _) => vec![Key::Field(name.to_string())], - NamedSelection::Quoted(_, _, Some(_)) => todo!(), - NamedSelection::Quoted(_, name, None) => vec![Key::Quoted(name.to_string())], - NamedSelection::Path(_, path) => path.collect_paths(), - NamedSelection::Group(alias, _) => vec![Key::Field(alias.name.to_string())], - } - } - - /// Find the next subselection, if present - pub(crate) fn next_subselection(&self) -> Option<&SubSelection> { - match self { - // Paths are complicated because they can have a subselection deeply nested - NamedSelection::Path(_, path) => path.next_subselection(), - - // The other options have it at the root - NamedSelection::Field(_, _, Some(sub)) - | NamedSelection::Quoted(_, _, Some(sub)) - | NamedSelection::Group(_, sub) => Some(sub), - - // Every other option does not have a subselection - _ => None, - } - } - - pub(crate) fn next_mut_subselection(&mut self) -> Option<&mut SubSelection> { - match self { - // Paths are complicated because they can have a subselection deeply nested - NamedSelection::Path(_, path) => path.next_mut_subselection(), - - // The other options have it at the root - NamedSelection::Field(_, _, Some(sub)) - | NamedSelection::Quoted(_, _, Some(sub)) - | NamedSelection::Group(_, sub) => Some(sub), - - // Every other option does not have a subselection - _ => None, - } - } -} - -// PathSelection ::= (VarPath | KeyPath) SubSelection? -// VarPath ::= "$" (NO_SPACE Identifier)? PathStep* -// KeyPath ::= Key PathStep+ -// PathStep ::= "." Key | "->" Identifier MethodArgs? - -#[derive(Debug, PartialEq, Clone, Serialize)] -pub enum PathSelection { - // We use a recursive structure here instead of a Vec to make applying - // the selection to a JSON value easier. - Var(String, Box), - Key(Key, Box), - Selection(SubSelection), - Empty, -} - -impl PathSelection { - pub(crate) fn parse(input: &str) -> IResult<&str, Self> { - match Self::parse_with_depth(input, 0) { - Ok((remainder, Self::Empty)) => Err(nom::Err::Error(nom::error::Error::new( - remainder, - nom::error::ErrorKind::IsNot, - ))), - otherwise => otherwise, - } - } - - fn parse_with_depth(input: &str, depth: usize) -> IResult<&str, Self> { - let (input, _spaces) = spaces_or_comments(input)?; - - // Variable references and key references without a leading . are - // accepted only at depth 0, or at the beginning of the PathSelection. - if depth == 0 { - if let Ok((suffix, opt_var)) = delimited( - tuple((spaces_or_comments, char('$'))), - opt(parse_identifier), - spaces_or_comments, - )(input) - { - let (input, rest) = Self::parse_with_depth(suffix, depth + 1)?; - // Note the $ prefix is included in the variable name. - let dollar_var = format!("${}", opt_var.unwrap_or("".to_string())); - return Ok((input, Self::Var(dollar_var, Box::new(rest)))); - } - - if let Ok((suffix, key)) = Key::parse(input) { - let (input, rest) = Self::parse_with_depth(suffix, depth + 1)?; - return match rest { - Self::Empty | Self::Selection(_) => Err(nom::Err::Error( - nom::error::Error::new(input, nom::error::ErrorKind::IsNot), - )), - rest => Ok((input, Self::Key(key, Box::new(rest)))), - }; - } - } - - // The .key case is applicable at any depth. If it comes first in the - // path selection, $.key is implied, but the distinction is preserved - // (using Self::Path rather than Self::Var) for accurate reprintability. - if let Ok((suffix, key)) = preceded( - tuple((spaces_or_comments, char('.'), spaces_or_comments)), - Key::parse, - )(input) - { - // tuple((char('.'), Key::parse))(input) { - let (input, rest) = Self::parse_with_depth(suffix, depth + 1)?; - return Ok((input, Self::Key(key, Box::new(rest)))); - } - - if depth == 0 { - // If the PathSelection does not start with a $var, a key., or a - // .key, it is not a valid PathSelection. - return Err(nom::Err::Error(nom::error::Error::new( - input, - nom::error::ErrorKind::IsNot, - ))); - } - - // If the PathSelection has a SubSelection, it must appear at the end of - // a non-empty path. - if let Ok((suffix, selection)) = SubSelection::parse(input) { - return Ok((suffix, Self::Selection(selection))); - } - - // The Self::Empty enum case is used to indicate the end of a - // PathSelection that has no SubSelection. - Ok((input, Self::Empty)) - } - - pub(crate) fn from_slice(properties: &[Key], selection: Option) -> Self { - match properties { - [] => selection.map_or(Self::Empty, Self::Selection), - [head, tail @ ..] => { - Self::Key(head.clone(), Box::new(Self::from_slice(tail, selection))) - } - } - } - - /// Collect all nested paths - /// - /// This method attempts to collect as many paths as possible, shorting out once - /// a non path selection is encountered. - pub(crate) fn collect_paths(&self) -> Vec { - let mut results = Vec::new(); - - // Collect as many as possible - let mut current = self; - while let Self::Key(key, rest) = current { - results.push(key.clone()); - - current = rest; - } - - results - } - - /// Find the next subselection, traversing nested chains if needed - pub(crate) fn next_subselection(&self) -> Option<&SubSelection> { - match self { - PathSelection::Var(_, path) => path.next_subselection(), - PathSelection::Key(_, path) => path.next_subselection(), - PathSelection::Selection(sub) => Some(sub), - PathSelection::Empty => None, - } - } - - /// Find the next subselection, traversing nested chains if needed. Returns a mutable reference - pub(crate) fn next_mut_subselection(&mut self) -> Option<&mut SubSelection> { - match self { - PathSelection::Var(_, path) => path.next_mut_subselection(), - PathSelection::Key(_, path) => path.next_mut_subselection(), - PathSelection::Selection(sub) => Some(sub), - PathSelection::Empty => None, - } - } -} - -// SubSelection ::= "{" NakedSubSelection "}" - -#[derive(Debug, PartialEq, Clone, Serialize, Default)] -pub struct SubSelection { - pub(super) selections: Vec, - pub(super) star: Option, -} - -impl SubSelection { - pub(crate) fn parse(input: &str) -> IResult<&str, Self> { - tuple(( - spaces_or_comments, - char('{'), - many0(NamedSelection::parse), - // Note that when a * selection is used, it must be the last - // selection in the SubSelection, since it does not count as a - // NamedSelection, and is stored as a separate field from the - // selections vector. - opt(StarSelection::parse), - spaces_or_comments, - char('}'), - spaces_or_comments, - ))(input) - .map(|(input, (_, _, selections, star, _, _, _))| (input, Self { selections, star })) - } - - pub fn selections_iter(&self) -> impl Iterator { - self.selections.iter() - } - - pub fn has_star(&self) -> bool { - self.star.is_some() - } - - pub fn set_star(&mut self, star: Option) { - self.star = star; - } - - pub fn append_selection(&mut self, selection: NamedSelection) { - self.selections.push(selection); - } - - pub fn last_selection_mut(&mut self) -> Option<&mut NamedSelection> { - self.selections.last_mut() - } - - // Since we enforce that new selections may only be appended to - // self.selections, we can provide an index-based search method that returns - // an unforgeable NamedSelectionIndex, which can later be used to access the - // selection using either get_at_index or get_at_index_mut. - // TODO In the future, this method could make use of an internal lookup - // table to avoid linear search. - pub fn index_of_named_selection(&self, name: &str) -> Option { - self.selections - .iter() - .position(|selection| selection.name() == name) - .map(|pos| NamedSelectionIndex { pos }) - } - - pub fn get_at_index(&self, index: &NamedSelectionIndex) -> &NamedSelection { - self.selections - .get(index.pos) - .expect("NamedSelectionIndex out of bounds") - } - - pub fn get_at_index_mut(&mut self, index: &NamedSelectionIndex) -> &mut NamedSelection { - self.selections - .get_mut(index.pos) - .expect("NamedSelectionIndex out of bounds") - } -} - -pub struct NamedSelectionIndex { - // Intentionally private so NamedSelectionIndex cannot be forged. - pos: usize, -} - -// StarSelection ::= Alias? "*" SubSelection? - -#[derive(Debug, PartialEq, Clone, Serialize)] -pub struct StarSelection( - pub(super) Option, - pub(super) Option>, -); - -impl StarSelection { - pub(crate) fn new(alias: Option, sub: Option) -> Self { - Self(alias, sub.map(Box::new)) - } - - pub(crate) fn parse(input: &str) -> IResult<&str, Self> { - tuple(( - // The spaces_or_comments separators are necessary here because - // Alias::parse and SubSelection::parse only consume surrounding - // spaces when they match, and they are both optional here. - opt(Alias::parse), - spaces_or_comments, - char('*'), - spaces_or_comments, - opt(SubSelection::parse), - ))(input) - .map(|(remainder, (alias, _, _, _, selection))| { - (remainder, Self(alias, selection.map(Box::new))) - }) - } -} - -// Alias ::= Identifier ":" - -#[derive(Debug, PartialEq, Clone, Serialize)] -pub struct Alias { - pub(super) name: String, -} - -impl Alias { - pub fn new(name: &str) -> Self { - Self { - name: name.to_string(), - } - } - - fn parse(input: &str) -> IResult<&str, Self> { - tuple(( - spaces_or_comments, - parse_identifier, - spaces_or_comments, - char(':'), - spaces_or_comments, - ))(input) - .map(|(input, (_, name, _, _, _))| (input, Self { name })) - } - - pub fn name(&self) -> &str { - self.name.as_str() - } -} - -// Key ::= Identifier | StringLiteral - -#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize)] -pub enum Key { - Field(String), - Quoted(String), - Index(usize), -} - -impl Key { - fn parse(input: &str) -> IResult<&str, Self> { - alt(( - map(parse_identifier, Self::Field), - map(parse_string_literal, Self::Quoted), - ))(input) - } - - pub fn to_json(&self) -> JSON { - match self { - Key::Field(name) => JSON::String(name.clone().into()), - Key::Quoted(name) => JSON::String(name.clone().into()), - Key::Index(index) => JSON::Number((*index).into()), - } - } - - // This method returns the field/property name as a String, and is - // appropriate for accessing JSON properties, in contrast to the dotted - // method below. - pub fn as_string(&self) -> String { - match self { - Key::Field(name) => name.clone(), - Key::Quoted(name) => name.clone(), - Key::Index(n) => n.to_string(), - } - } - - // This method is used to implement the Display trait for Key, and includes - // a leading '.' character for string keys, as well as proper quoting for - // Key::Quoted values. However, these additions make key.dotted() unsafe to - // use for accessing JSON properties. - pub fn dotted(&self) -> String { - match self { - Key::Field(field) => format!(".{field}"), - Key::Quoted(field) => { - // JSON encoding is a reliable way to ensure a string that may - // contain special characters (such as '"' characters) is - // properly escaped and double-quoted. - let quoted = serde_json_bytes::Value::String(field.clone().into()).to_string(); - format!(".{quoted}") - } - Key::Index(index) => format!(".{index}"), - } - } -} - -impl Display for Key { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let dotted = self.dotted(); - write!(f, "{dotted}") - } -} - -// Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]* - -fn parse_identifier(input: &str) -> IResult<&str, String> { - delimited( - spaces_or_comments, - recognize(pair( - one_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"), - many0(one_of( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789", - )), - )), - spaces_or_comments, - )(input) - .map(|(input, name)| (input, name.to_string())) -} - -// StringLiteral ::= -// | "'" ("\\'" | [^'])* "'" -// | '"' ('\\"' | [^"])* '"' - -fn parse_string_literal(input: &str) -> IResult<&str, String> { - let input = spaces_or_comments(input).map(|(input, _)| input)?; - let mut input_char_indices = input.char_indices(); - - match input_char_indices.next() { - Some((0, quote @ '\'')) | Some((0, quote @ '"')) => { - let mut escape_next = false; - let mut chars: Vec = vec![]; - let mut remainder: Option<&str> = None; - - for (i, c) in input_char_indices { - if escape_next { - match c { - 'n' => chars.push('\n'), - _ => chars.push(c), - } - escape_next = false; - continue; - } - if c == '\\' { - escape_next = true; - continue; - } - if c == quote { - remainder = Some(spaces_or_comments(&input[i + 1..])?.0); - break; - } - chars.push(c); - } - - if let Some(remainder) = remainder { - Ok((remainder, chars.iter().collect::())) - } else { - Err(nom::Err::Error(nom::error::Error::new( - input, - nom::error::ErrorKind::Eof, - ))) - } - } - - _ => Err(nom::Err::Error(nom::error::Error::new( - input, - nom::error::ErrorKind::IsNot, - ))), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::selection; - - #[test] - fn test_identifier() { - assert_eq!(parse_identifier("hello"), Ok(("", "hello".to_string())),); - - assert_eq!( - parse_identifier("hello_world"), - Ok(("", "hello_world".to_string())), - ); - - assert_eq!( - parse_identifier("hello_world_123"), - Ok(("", "hello_world_123".to_string())), - ); - - assert_eq!(parse_identifier(" hello "), Ok(("", "hello".to_string())),); - } - - #[test] - fn test_string_literal() { - assert_eq!( - parse_string_literal("'hello world'"), - Ok(("", "hello world".to_string())), - ); - assert_eq!( - parse_string_literal("\"hello world\""), - Ok(("", "hello world".to_string())), - ); - assert_eq!( - parse_string_literal("'hello \"world\"'"), - Ok(("", "hello \"world\"".to_string())), - ); - assert_eq!( - parse_string_literal("\"hello \\\"world\\\"\""), - Ok(("", "hello \"world\"".to_string())), - ); - assert_eq!( - parse_string_literal("'hello \\'world\\''"), - Ok(("", "hello 'world'".to_string())), - ); - } - #[test] - fn test_key() { - assert_eq!( - Key::parse("hello"), - Ok(("", Key::Field("hello".to_string()))), - ); - - assert_eq!( - Key::parse("'hello'"), - Ok(("", Key::Quoted("hello".to_string()))), - ); - } - - #[test] - fn test_alias() { - assert_eq!( - Alias::parse("hello:"), - Ok(( - "", - Alias { - name: "hello".to_string(), - }, - )), - ); - - assert_eq!( - Alias::parse("hello :"), - Ok(( - "", - Alias { - name: "hello".to_string(), - }, - )), - ); - - assert_eq!( - Alias::parse("hello : "), - Ok(( - "", - Alias { - name: "hello".to_string(), - }, - )), - ); - - assert_eq!( - Alias::parse(" hello :"), - Ok(( - "", - Alias { - name: "hello".to_string(), - }, - )), - ); - - assert_eq!( - Alias::parse("hello: "), - Ok(( - "", - Alias { - name: "hello".to_string(), - }, - )), - ); - } - - #[test] - fn test_named_selection() { - fn assert_result_and_name(input: &str, expected: NamedSelection, name: &str) { - let actual = NamedSelection::parse(input); - assert_eq!(actual, Ok(("", expected.clone()))); - assert_eq!(actual.unwrap().1.name(), name); - assert_eq!( - selection!(input), - JSONSelection::Named(SubSelection { - selections: vec![expected], - star: None, - }), - ); - } - - assert_result_and_name( - "hello", - NamedSelection::Field(None, "hello".to_string(), None), - "hello", - ); - - assert_result_and_name( - "hello { world }", - NamedSelection::Field( - None, - "hello".to_string(), - Some(SubSelection { - selections: vec![NamedSelection::Field(None, "world".to_string(), None)], - star: None, - }), - ), - "hello", - ); - - assert_result_and_name( - "hi: hello", - NamedSelection::Field( - Some(Alias { - name: "hi".to_string(), - }), - "hello".to_string(), - None, - ), - "hi", - ); - - assert_result_and_name( - "hi: 'hello world'", - NamedSelection::Quoted( - Alias { - name: "hi".to_string(), - }, - "hello world".to_string(), - None, - ), - "hi", - ); - - assert_result_and_name( - "hi: hello { world }", - NamedSelection::Field( - Some(Alias { - name: "hi".to_string(), - }), - "hello".to_string(), - Some(SubSelection { - selections: vec![NamedSelection::Field(None, "world".to_string(), None)], - star: None, - }), - ), - "hi", - ); - - assert_result_and_name( - "hey: hello { world again }", - NamedSelection::Field( - Some(Alias { - name: "hey".to_string(), - }), - "hello".to_string(), - Some(SubSelection { - selections: vec![ - NamedSelection::Field(None, "world".to_string(), None), - NamedSelection::Field(None, "again".to_string(), None), - ], - star: None, - }), - ), - "hey", - ); - - assert_result_and_name( - "hey: 'hello world' { again }", - NamedSelection::Quoted( - Alias { - name: "hey".to_string(), - }, - "hello world".to_string(), - Some(SubSelection { - selections: vec![NamedSelection::Field(None, "again".to_string(), None)], - star: None, - }), - ), - "hey", - ); - - assert_result_and_name( - "leggo: 'my ego'", - NamedSelection::Quoted( - Alias { - name: "leggo".to_string(), - }, - "my ego".to_string(), - None, - ), - "leggo", - ); - } - - #[test] - fn test_selection() { - assert_eq!( - selection!(""), - JSONSelection::Named(SubSelection { - selections: vec![], - star: None, - }), - ); - - assert_eq!( - selection!(" "), - JSONSelection::Named(SubSelection { - selections: vec![], - star: None, - }), - ); - - assert_eq!( - selection!("hello"), - JSONSelection::Named(SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], - star: None, - }), - ); - - assert_eq!( - selection!(".hello"), - JSONSelection::Path(PathSelection::from_slice( - &[Key::Field("hello".to_string()),], - None - )), - ); - - { - let expected = JSONSelection::Named(SubSelection { - selections: vec![NamedSelection::Path( - Alias { - name: "hi".to_string(), - }, - PathSelection::from_slice( - &[ - Key::Field("hello".to_string()), - Key::Field("world".to_string()), - ], - None, - ), - )], - star: None, - }); - - assert_eq!(selection!("hi: .hello.world"), expected); - assert_eq!(selection!("hi: .hello .world"), expected); - assert_eq!(selection!("hi: . hello. world"), expected); - assert_eq!(selection!("hi: .hello . world"), expected); - assert_eq!(selection!("hi: hello.world"), expected); - assert_eq!(selection!("hi: hello. world"), expected); - assert_eq!(selection!("hi: hello .world"), expected); - assert_eq!(selection!("hi: hello . world"), expected); - } - - { - let expected = JSONSelection::Named(SubSelection { - selections: vec![ - NamedSelection::Field(None, "before".to_string(), None), - NamedSelection::Path( - Alias { - name: "hi".to_string(), - }, - PathSelection::from_slice( - &[ - Key::Field("hello".to_string()), - Key::Field("world".to_string()), - ], - None, - ), - ), - NamedSelection::Field(None, "after".to_string(), None), - ], - star: None, - }); - - assert_eq!(selection!("before hi: .hello.world after"), expected); - assert_eq!(selection!("before hi: .hello .world after"), expected); - assert_eq!(selection!("before hi: .hello. world after"), expected); - assert_eq!(selection!("before hi: .hello . world after"), expected); - assert_eq!(selection!("before hi: . hello.world after"), expected); - assert_eq!(selection!("before hi: . hello .world after"), expected); - assert_eq!(selection!("before hi: . hello. world after"), expected); - assert_eq!(selection!("before hi: . hello . world after"), expected); - assert_eq!(selection!("before hi: hello.world after"), expected); - assert_eq!(selection!("before hi: hello .world after"), expected); - assert_eq!(selection!("before hi: hello. world after"), expected); - assert_eq!(selection!("before hi: hello . world after"), expected); - } - - { - let expected = JSONSelection::Named(SubSelection { - selections: vec![ - NamedSelection::Field(None, "before".to_string(), None), - NamedSelection::Path( - Alias { - name: "hi".to_string(), - }, - PathSelection::from_slice( - &[ - Key::Field("hello".to_string()), - Key::Field("world".to_string()), - ], - Some(SubSelection { - selections: vec![ - NamedSelection::Field(None, "nested".to_string(), None), - NamedSelection::Field(None, "names".to_string(), None), - ], - star: None, - }), - ), - ), - NamedSelection::Field(None, "after".to_string(), None), - ], - star: None, - }); - - assert_eq!( - selection!("before hi: .hello.world { nested names } after"), - expected - ); - assert_eq!( - selection!("before hi:.hello.world{nested names}after"), - expected - ); - assert_eq!( - selection!("before hi: hello.world { nested names } after"), - expected - ); - assert_eq!( - selection!("before hi:hello.world{nested names}after"), - expected - ); - } - - assert_eq!( - selection!( - " - # Comments are supported because we parse them as whitespace - topLevelAlias: topLevelField { - # Non-identifier properties must be aliased as an identifier - nonIdentifier: 'property name with spaces' - - # This extracts the value located at the given path and applies a - # selection set to it before renaming the result to pathSelection - pathSelection: .some.nested.path { - still: yet - more - properties - } - - # An aliased SubSelection of fields nests the fields together - # under the given alias - siblingGroup: { brother sister } - }" - ), - JSONSelection::Named(SubSelection { - selections: vec![NamedSelection::Field( - Some(Alias { - name: "topLevelAlias".to_string(), - }), - "topLevelField".to_string(), - Some(SubSelection { - selections: vec![ - NamedSelection::Quoted( - Alias { - name: "nonIdentifier".to_string(), - }, - "property name with spaces".to_string(), - None, - ), - NamedSelection::Path( - Alias { - name: "pathSelection".to_string(), - }, - PathSelection::from_slice( - &[ - Key::Field("some".to_string()), - Key::Field("nested".to_string()), - Key::Field("path".to_string()), - ], - Some(SubSelection { - selections: vec![ - NamedSelection::Field( - Some(Alias { - name: "still".to_string(), - }), - "yet".to_string(), - None, - ), - NamedSelection::Field(None, "more".to_string(), None,), - NamedSelection::Field( - None, - "properties".to_string(), - None, - ), - ], - star: None, - }) - ), - ), - NamedSelection::Group( - Alias { - name: "siblingGroup".to_string(), - }, - SubSelection { - selections: vec![ - NamedSelection::Field(None, "brother".to_string(), None,), - NamedSelection::Field(None, "sister".to_string(), None,), - ], - star: None, - }, - ), - ], - star: None, - }), - )], - star: None, - }), - ); - } - - fn check_path_selection(input: &str, expected: PathSelection) { - assert_eq!(PathSelection::parse(input), Ok(("", expected.clone()))); - assert_eq!(selection!(input), JSONSelection::Path(expected.clone())); - } - - #[test] - fn test_path_selection() { - check_path_selection( - ".hello", - PathSelection::from_slice(&[Key::Field("hello".to_string())], None), - ); - - { - let expected = PathSelection::from_slice( - &[ - Key::Field("hello".to_string()), - Key::Field("world".to_string()), - ], - None, - ); - check_path_selection(".hello.world", expected.clone()); - check_path_selection(".hello .world", expected.clone()); - check_path_selection(".hello. world", expected.clone()); - check_path_selection(".hello . world", expected.clone()); - check_path_selection("hello.world", expected.clone()); - check_path_selection("hello .world", expected.clone()); - check_path_selection("hello. world", expected.clone()); - check_path_selection("hello . world", expected.clone()); - } - - { - let expected = PathSelection::from_slice( - &[ - Key::Field("hello".to_string()), - Key::Field("world".to_string()), - ], - Some(SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None)], - star: None, - }), - ); - check_path_selection(".hello.world { hello }", expected.clone()); - check_path_selection(".hello .world { hello }", expected.clone()); - check_path_selection(".hello. world { hello }", expected.clone()); - check_path_selection(".hello . world { hello }", expected.clone()); - check_path_selection(". hello.world { hello }", expected.clone()); - check_path_selection(". hello .world { hello }", expected.clone()); - check_path_selection(". hello. world { hello }", expected.clone()); - check_path_selection(". hello . world { hello }", expected.clone()); - check_path_selection("hello.world { hello }", expected.clone()); - check_path_selection("hello .world { hello }", expected.clone()); - check_path_selection("hello. world { hello }", expected.clone()); - check_path_selection("hello . world { hello }", expected.clone()); - } - - { - let expected = PathSelection::from_slice( - &[ - Key::Field("nested".to_string()), - Key::Quoted("string literal".to_string()), - Key::Quoted("property".to_string()), - Key::Field("name".to_string()), - ], - None, - ); - check_path_selection( - ".nested.'string literal'.\"property\".name", - expected.clone(), - ); - check_path_selection( - "nested.'string literal'.\"property\".name", - expected.clone(), - ); - check_path_selection( - "nested. 'string literal'.\"property\".name", - expected.clone(), - ); - check_path_selection( - "nested.'string literal'. \"property\".name", - expected.clone(), - ); - check_path_selection( - "nested.'string literal'.\"property\" .name", - expected.clone(), - ); - check_path_selection( - "nested.'string literal'.\"property\". name", - expected.clone(), - ); - } - - { - let expected = PathSelection::from_slice( - &[ - Key::Field("nested".to_string()), - Key::Quoted("string literal".to_string()), - ], - Some(SubSelection { - selections: vec![NamedSelection::Quoted( - Alias { - name: "leggo".to_string(), - }, - "my ego".to_string(), - None, - )], - star: None, - }), - ); - - check_path_selection( - ".nested.'string literal' { leggo: 'my ego' }", - expected.clone(), - ); - - check_path_selection( - "nested.'string literal' { leggo: 'my ego' }", - expected.clone(), - ); - - check_path_selection( - "nested. 'string literal' { leggo: 'my ego' }", - expected.clone(), - ); - - check_path_selection( - "nested . 'string literal' { leggo: 'my ego' }", - expected.clone(), - ); - } - } - - #[test] - fn test_path_selection_vars() { - check_path_selection( - "$var", - PathSelection::Var("$var".to_string(), Box::new(PathSelection::Empty)), - ); - - check_path_selection( - "$", - PathSelection::Var("$".to_string(), Box::new(PathSelection::Empty)), - ); - - check_path_selection( - "$var { hello }", - PathSelection::Var( - "$var".to_string(), - Box::new(PathSelection::Selection(SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None)], - star: None, - })), - ), - ); - - check_path_selection( - "$ { hello }", - PathSelection::Var( - "$".to_string(), - Box::new(PathSelection::Selection(SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None)], - star: None, - })), - ), - ); - - check_path_selection( - "$var { before alias: $args.arg after }", - PathSelection::Var( - "$var".to_string(), - Box::new(PathSelection::Selection(SubSelection { - selections: vec![ - NamedSelection::Field(None, "before".to_string(), None), - NamedSelection::Path( - Alias { - name: "alias".to_string(), - }, - PathSelection::Var( - "$args".to_string(), - Box::new(PathSelection::Key( - Key::Field("arg".to_string()), - Box::new(PathSelection::Empty), - )), - ), - ), - NamedSelection::Field(None, "after".to_string(), None), - ], - star: None, - })), - ), - ); - - check_path_selection( - "$.nested { key injected: $args.arg }", - PathSelection::Var( - "$".to_string(), - Box::new(PathSelection::Key( - Key::Field("nested".to_string()), - Box::new(PathSelection::Selection(SubSelection { - selections: vec![ - NamedSelection::Field(None, "key".to_string(), None), - NamedSelection::Path( - Alias { - name: "injected".to_string(), - }, - PathSelection::Var( - "$args".to_string(), - Box::new(PathSelection::Key( - Key::Field("arg".to_string()), - Box::new(PathSelection::Empty), - )), - ), - ), - ], - star: None, - })), - )), - ), - ); - - check_path_selection( - "$root.a.b.c", - PathSelection::Var( - "$root".to_string(), - Box::new(PathSelection::from_slice( - &[ - Key::Field("a".to_string()), - Key::Field("b".to_string()), - Key::Field("c".to_string()), - ], - None, - )), - ), - ); - - check_path_selection( - "undotted.x.y.z", - PathSelection::from_slice( - &[ - Key::Field("undotted".to_string()), - Key::Field("x".to_string()), - Key::Field("y".to_string()), - Key::Field("z".to_string()), - ], - None, - ), - ); - - check_path_selection( - ".dotted.x.y.z", - PathSelection::from_slice( - &[ - Key::Field("dotted".to_string()), - Key::Field("x".to_string()), - Key::Field("y".to_string()), - Key::Field("z".to_string()), - ], - None, - ), - ); - - check_path_selection( - "$.data", - PathSelection::Var( - "$".to_string(), - Box::new(PathSelection::Key( - Key::Field("data".to_string()), - Box::new(PathSelection::Empty), - )), - ), - ); - - check_path_selection( - "$.data.'quoted property'.nested", - PathSelection::Var( - "$".to_string(), - Box::new(PathSelection::Key( - Key::Field("data".to_string()), - Box::new(PathSelection::Key( - Key::Quoted("quoted property".to_string()), - Box::new(PathSelection::Key( - Key::Field("nested".to_string()), - Box::new(PathSelection::Empty), - )), - )), - )), - ), - ); - - assert_eq!( - PathSelection::parse("naked"), - Err(nom::Err::Error(nom::error::Error::new( - "", - nom::error::ErrorKind::IsNot, - ))), - ); - - assert_eq!( - PathSelection::parse("naked { hi }"), - Err(nom::Err::Error(nom::error::Error::new( - "", - nom::error::ErrorKind::IsNot, - ))), - ); - - assert_eq!( - PathSelection::parse("valid.$invalid"), - Err(nom::Err::Error(nom::error::Error::new( - ".$invalid", - nom::error::ErrorKind::IsNot, - ))), - ); - - assert_eq!( - selection!("$"), - JSONSelection::Path(PathSelection::Var( - "$".to_string(), - Box::new(PathSelection::Empty), - )), - ); - - assert_eq!( - selection!("$this"), - JSONSelection::Path(PathSelection::Var( - "$this".to_string(), - Box::new(PathSelection::Empty), - )), - ); - } - - #[test] - fn test_subselection() { - assert_eq!( - SubSelection::parse(" { \n } "), - Ok(( - "", - SubSelection { - selections: vec![], - star: None, - }, - )), - ); - - assert_eq!( - SubSelection::parse("{hello}"), - Ok(( - "", - SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], - star: None, - }, - )), - ); - - assert_eq!( - SubSelection::parse("{ hello }"), - Ok(( - "", - SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], - star: None, - }, - )), - ); - - assert_eq!( - SubSelection::parse(" { padded } "), - Ok(( - "", - SubSelection { - selections: vec![NamedSelection::Field(None, "padded".to_string(), None),], - star: None, - }, - )), - ); - - assert_eq!( - SubSelection::parse("{ hello world }"), - Ok(( - "", - SubSelection { - selections: vec![ - NamedSelection::Field(None, "hello".to_string(), None), - NamedSelection::Field(None, "world".to_string(), None), - ], - star: None, - }, - )), - ); - - assert_eq!( - SubSelection::parse("{ hello { world } }"), - Ok(( - "", - SubSelection { - selections: vec![NamedSelection::Field( - None, - "hello".to_string(), - Some(SubSelection { - selections: vec![NamedSelection::Field( - None, - "world".to_string(), - None - ),], - star: None, - }) - ),], - star: None, - }, - )), - ); - } - - #[test] - fn test_star_selection() { - assert_eq!( - StarSelection::parse("rest: *"), - Ok(( - "", - StarSelection( - Some(Alias { - name: "rest".to_string(), - }), - None - ), - )), - ); - - assert_eq!( - StarSelection::parse("*"), - Ok(("", StarSelection(None, None),)), - ); - - assert_eq!( - StarSelection::parse(" * "), - Ok(("", StarSelection(None, None),)), - ); - - assert_eq!( - StarSelection::parse(" * { hello } "), - Ok(( - "", - StarSelection( - None, - Some(Box::new(SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], - star: None, - })) - ), - )), - ); - - assert_eq!( - StarSelection::parse("hi: * { hello }"), - Ok(( - "", - StarSelection( - Some(Alias { - name: "hi".to_string(), - }), - Some(Box::new(SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], - star: None, - })) - ), - )), - ); - - assert_eq!( - StarSelection::parse("alias: * { x y z rest: * }"), - Ok(( - "", - StarSelection( - Some(Alias { - name: "alias".to_string() - }), - Some(Box::new(SubSelection { - selections: vec![ - NamedSelection::Field(None, "x".to_string(), None), - NamedSelection::Field(None, "y".to_string(), None), - NamedSelection::Field(None, "z".to_string(), None), - ], - star: Some(StarSelection( - Some(Alias { - name: "rest".to_string(), - }), - None - )), - })), - ), - )), - ); - - assert_eq!( - selection!(" before alias: * { * { a b c } } "), - JSONSelection::Named(SubSelection { - selections: vec![NamedSelection::Field(None, "before".to_string(), None),], - star: Some(StarSelection( - Some(Alias { - name: "alias".to_string() - }), - Some(Box::new(SubSelection { - selections: vec![], - star: Some(StarSelection( - None, - Some(Box::new(SubSelection { - selections: vec![ - NamedSelection::Field(None, "a".to_string(), None), - NamedSelection::Field(None, "b".to_string(), None), - NamedSelection::Field(None, "c".to_string(), None), - ], - star: None, - })) - )), - })), - )), - }), - ); - - assert_eq!( - selection!(" before group: { * { a b c } } after "), - JSONSelection::Named(SubSelection { - selections: vec![ - NamedSelection::Field(None, "before".to_string(), None), - NamedSelection::Group( - Alias { - name: "group".to_string(), - }, - SubSelection { - selections: vec![], - star: Some(StarSelection( - None, - Some(Box::new(SubSelection { - selections: vec![ - NamedSelection::Field(None, "a".to_string(), None), - NamedSelection::Field(None, "b".to_string(), None), - NamedSelection::Field(None, "c".to_string(), None), - ], - star: None, - })) - )), - }, - ), - NamedSelection::Field(None, "after".to_string(), None), - ], - star: None, - }), - ); - } -} diff --git a/apollo-federation/src/sources/connect/json_selection/pretty.rs b/apollo-federation/src/sources/connect/json_selection/pretty.rs deleted file mode 100644 index f6890d635f..0000000000 --- a/apollo-federation/src/sources/connect/json_selection/pretty.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! Pretty printing utility methods -//! -//! Working with raw JSONSelections when doing snapshot testing is difficult to -//! read and makes the snapshots themselves quite large. This module adds a new -//! pretty printing trait which is then implemented on the various sub types -//! of the JSONSelection tree. - -use crate::sources::connect::json_selection::JSONSelection; -use crate::sources::connect::json_selection::NamedSelection; -use crate::sources::connect::json_selection::PathSelection; -use crate::sources::connect::json_selection::StarSelection; -use crate::sources::connect::json_selection::SubSelection; - -/// Pretty print trait -/// -/// This trait marks a type as supporting pretty printing itself outside of a -/// Display implementation, which might be more useful for snapshots. -pub trait PrettyPrintable { - /// Pretty print the struct - fn pretty_print(&self) -> String { - self.pretty_print_with_indentation(true, 0) - } - - /// Pretty print the struct, with indentation - /// - /// Each indentation level is marked with 2 spaces, with `inline` signifying - /// that the first line should be not indented. - fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String; -} - -/// Helper method to generate indentation -fn indent_chars(indent: usize) -> String { - " ".repeat(indent) -} - -impl PrettyPrintable for JSONSelection { - fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { - let mut result = String::new(); - - match self { - JSONSelection::Named(named) => { - let named = named.pretty_print_with_indentation(inline, indentation); - result.push_str(named.as_str()); - } - JSONSelection::Path(path) => { - let path = path.pretty_print_with_indentation(inline, indentation); - result.push_str(path.as_str()); - } - }; - - result - } -} - -impl PrettyPrintable for SubSelection { - fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { - let mut result = String::new(); - let indent = indent_chars(indentation); - - if !inline { - result.push_str(indent.as_str()); - } - - result.push_str("{\n"); - - for selection in &self.selections { - let selection = selection.pretty_print_with_indentation(false, indentation + 1); - result.push_str(selection.as_str()); - result.push('\n'); - } - - if let Some(star) = self.star.as_ref() { - let star = star.pretty_print_with_indentation(false, indentation + 1); - result.push_str(star.as_str()); - result.push('\n'); - } - - result.push_str(indent.as_str()); - result.push('}'); - - result - } -} - -impl PrettyPrintable for PathSelection { - fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { - let mut result = String::new(); - - if !inline { - result.push_str(indent_chars(indentation).as_str()); - } - - match self { - PathSelection::Var(var, path) => { - let rest = path.pretty_print_with_indentation(true, indentation); - result.push_str(var.as_str()); - result.push_str(rest.as_str()); - } - PathSelection::Key(key, path) => { - let rest = path.pretty_print_with_indentation(true, indentation); - result.push_str(key.dotted().as_str()); - result.push_str(rest.as_str()); - } - PathSelection::Selection(sub) => { - let sub = sub.pretty_print_with_indentation(true, indentation); - result.push(' '); - result.push_str(sub.as_str()); - } - PathSelection::Empty => {} - } - - result - } -} - -impl PrettyPrintable for NamedSelection { - fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { - let mut result = String::new(); - - if !inline { - result.push_str(indent_chars(indentation).as_str()); - } - - match self { - NamedSelection::Field(alias, field_name, sub) => { - if let Some(alias) = alias { - result.push_str(alias.name.as_str()); - result.push_str(": "); - } - - result.push_str(field_name.as_str()); - - if let Some(sub) = sub { - let sub = sub.pretty_print_with_indentation(true, indentation); - result.push(' '); - result.push_str(sub.as_str()); - } - } - NamedSelection::Quoted(alias, literal, sub) => { - result.push_str(alias.name.as_str()); - result.push_str(": "); - - let safely_quoted = - serde_json_bytes::Value::String(literal.clone().into()).to_string(); - result.push_str(safely_quoted.as_str()); - - if let Some(sub) = sub { - let sub = sub.pretty_print_with_indentation(true, indentation); - result.push(' '); - result.push_str(sub.as_str()); - } - } - NamedSelection::Path(alias, path) => { - result.push_str(alias.name.as_str()); - result.push_str(": "); - - let path = path.pretty_print_with_indentation(true, indentation); - result.push_str(path.trim_start()); - } - NamedSelection::Group(alias, sub) => { - result.push_str(alias.name.as_str()); - result.push_str(": "); - - let sub = sub.pretty_print_with_indentation(true, indentation); - result.push_str(sub.as_str()); - } - }; - - result - } -} - -impl PrettyPrintable for StarSelection { - fn pretty_print_with_indentation(&self, inline: bool, indentation: usize) -> String { - let mut result = String::new(); - - if !inline { - result.push_str(indent_chars(indentation).as_str()); - } - - if let Some(alias) = self.0.as_ref() { - result.push_str(alias.name.as_str()); - result.push_str(": "); - } - - result.push('*'); - - if let Some(sub) = self.1.as_ref() { - let sub = sub.pretty_print_with_indentation(true, indentation); - result.push(' '); - result.push_str(sub.as_str()); - } - - result - } -} - -#[cfg(test)] -mod tests { - use crate::sources::connect::json_selection::pretty::indent_chars; - use crate::sources::connect::json_selection::NamedSelection; - use crate::sources::connect::json_selection::PrettyPrintable; - use crate::sources::connect::json_selection::StarSelection; - use crate::sources::connect::PathSelection; - use crate::sources::connect::SubSelection; - - // Test all valid pretty print permutations - fn test_permutations(selection: impl PrettyPrintable, expected: &str) { - let indentation = 4; - let expected_indented = expected - .lines() - .map(|line| format!("{}{line}", indent_chars(indentation))) - .collect::>() - .join("\n"); - - let prettified = selection.pretty_print(); - assert_eq!( - prettified, expected, - "pretty printing did not match: {prettified} != {expected}" - ); - - let prettified_inline = selection.pretty_print_with_indentation(true, indentation); - assert_eq!( - prettified_inline, - expected_indented.trim_start(), - "pretty printing inline did not match: {prettified_inline} != {}", - expected_indented.trim_start() - ); - - let prettified_indented = selection.pretty_print_with_indentation(false, indentation); - assert_eq!( - prettified_indented, expected_indented, - "pretty printing indented did not match: {prettified_indented} != {expected_indented}" - ); - } - - #[test] - fn it_prints_a_star_selection() { - let (unmatched, star_selection) = StarSelection::parse("rest: *").unwrap(); - assert!(unmatched.is_empty()); - - test_permutations(star_selection, "rest: *"); - } - - #[test] - fn it_prints_a_star_selection_with_subselection() { - let (unmatched, star_selection) = StarSelection::parse("rest: * { a b }").unwrap(); - assert!(unmatched.is_empty()); - - test_permutations(star_selection, "rest: * {\n a\n b\n}"); - } - - #[test] - fn it_prints_a_named_selection() { - let selections = [ - // Field - "cool", - "cool: beans", - "cool: beans {\n whoa\n}", - // Path - "cool: .one.two.three", - // Quoted - r#"cool: "b e a n s""#, - "cool: \"b e a n s\" {\n a\n b\n}", - // Group - "cool: {\n a\n b\n}", - ]; - for selection in selections { - let (unmatched, named_selection) = NamedSelection::parse(selection).unwrap(); - assert!( - unmatched.is_empty(), - "static named selection was not fully parsed: '{selection}' ({named_selection:?}) had unmatched '{unmatched}'" - ); - - test_permutations(named_selection, selection); - } - } - - #[test] - fn it_prints_a_path_selection() { - let paths = [ - // Var - "$.one.two.three", - "$this.a.b", - "$id.first {\n username\n}", - // Key - ".first", - ".a.b.c.d.e", - ".one.two.three {\n a\n b\n}", - ]; - for path in paths { - let (unmatched, path_selection) = PathSelection::parse(path).unwrap(); - assert!( - unmatched.is_empty(), - "static path was not fully parsed: '{path}' ({path_selection:?}) had unmatched '{unmatched}'" - ); - - test_permutations(path_selection, path); - } - } - - #[test] - fn it_prints_a_sub_selection() { - let sub = "{\n a\n b\n}"; - let (unmatched, sub_selection) = SubSelection::parse(sub).unwrap(); - assert!( - unmatched.is_empty(), - "static path was not fully parsed: '{sub}' ({sub_selection:?}) had unmatched '{unmatched}'" - ); - - test_permutations(sub_selection, sub); - } - - #[test] - fn it_prints_a_nested_sub_selection() { - let sub = "{ - a { - b { - c - } - } - }"; - let sub_indented = "{\n a {\n b {\n c\n }\n }\n}"; - let sub_super_indented = " {\n a {\n b {\n c\n }\n }\n }"; - - let (unmatched, sub_selection) = SubSelection::parse(sub).unwrap(); - assert!( - unmatched.is_empty(), - "static nested sub was not fully parsed: '{sub}' ({sub_selection:?}) had unmatched '{unmatched}'" - ); - - let pretty = sub_selection.pretty_print(); - assert_eq!( - pretty, sub_indented, - "nested sub pretty printing did not match: {pretty} != {sub_indented}" - ); - - let pretty = sub_selection.pretty_print_with_indentation(true, 4); - assert_eq!( - pretty, - sub_super_indented.trim_start(), - "nested inline sub pretty printing did not match: {pretty} != {}", - sub_super_indented.trim_start() - ); - - let pretty = sub_selection.pretty_print_with_indentation(false, 4); - assert_eq!( - pretty, sub_super_indented, - "nested inline sub pretty printing did not match: {pretty} != {sub_super_indented}", - ); - } -} diff --git a/apollo-federation/src/sources/connect/mod.rs b/apollo-federation/src/sources/connect/mod.rs deleted file mode 100644 index 80a48a74a8..0000000000 --- a/apollo-federation/src/sources/connect/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -#![allow(unused_imports)] - -mod json_selection; -mod url_path_template; - -pub use json_selection::ApplyTo; -pub use json_selection::ApplyToError; -pub use json_selection::JSONSelection; -pub use json_selection::Key; -pub use json_selection::PathSelection; -pub use json_selection::SubSelection; -pub use url_path_template::URLPathTemplate; diff --git a/apollo-federation/src/sources/connect/url_path_template.rs b/apollo-federation/src/sources/connect/url_path_template.rs deleted file mode 100644 index 83977d3d45..0000000000 --- a/apollo-federation/src/sources/connect/url_path_template.rs +++ /dev/null @@ -1,1808 +0,0 @@ -use std::fmt::Display; - -use apollo_compiler::collections::IndexMap; -use itertools::Itertools; -use nom::branch::alt; -use nom::bytes::complete::tag; -use nom::character::complete::char; -use nom::character::complete::one_of; -use nom::combinator::opt; -use nom::combinator::recognize; -use nom::multi::many0; -use nom::sequence::pair; -use nom::sequence::preceded; -use nom::sequence::tuple; -use nom::IResult; -use serde::Serialize; -use serde_json_bytes::ByteString; -use serde_json_bytes::Map; -use serde_json_bytes::Value as JSON; - -/// A parser accepting URLPathTemplate syntax, which is useful both for -/// generating new URL paths from provided variables and for extracting variable -/// values from concrete URL paths. - -#[derive(Debug, PartialEq, Clone, Default)] -pub struct URLPathTemplate { - path: Vec, - query: IndexMap, -} - -#[derive(Debug, PartialEq, Clone)] -pub struct ParameterValue { - // The ParameterValue struct represents both path parameter values and query - // parameter values, allowing zero or more variable expressions separated by - // nonempty constant text. - parts: Vec, -} - -#[derive(Debug, PartialEq, Clone)] -pub enum ValuePart { - Text(String), - Var(VariableExpression), -} - -#[derive(Debug, PartialEq, Clone, Default)] -pub struct VariableExpression { - // Variable paths are often a single identifier, but may also consist of a - // sequence of identifiers joined with the . character. We represent dotted - // paths as a single string, rather than a Vec, and these dotted - // path strings are expected for the input keys of generate_path and the - // output keys of extract_vars, rather than a nested JSON object. - var_path: String, - - // When Some, the batch_separator option indicates the variable is a batch - // variable, so the value of the variable is expected to be a JSON array, - // and the separator string separates the batched variable values in the - // parsed/generated URL path. - batch_separator: Option, - - // Variables in the URL path are required by default, whereas variables in - // the query parameter list are optional by default, but can be made - // mandatory by adding a trailing ! to the variable path. - required: bool, -} - -impl URLPathTemplate { - // Top-level parsing entry point for URLPathTemplate syntax. - pub fn parse(input: &str) -> Result { - let mut prefix_suffix = input.splitn(2, '?'); - let path_prefix = prefix_suffix.next(); - let query_suffix = prefix_suffix.next(); - let mut path = vec![]; - - if let Some(path_prefix) = path_prefix { - for path_part in path_prefix.split('/') { - if !path_part.is_empty() { - path.push(ParameterValue::parse(path_part, true)?); - } - } - } - - let mut query = IndexMap::default(); - - if let Some(query_suffix) = query_suffix { - for query_part in query_suffix.split('&') { - if let Some((key, value)) = query_part.split_once('=') { - query.insert(key.to_string(), ParameterValue::parse(value, false)?); - } - } - } - - Ok(URLPathTemplate { path, query }) - } - - // Given a URLPathTemplate and an IndexMap of variables to be interpolated - // into its {...} expressions, generate a new URL path String. - // Guaranteed to return a "/"-prefixed string to make appending to the - // base url easier. - pub fn generate_path(&self, vars: &JSON) -> Result { - let mut path = String::new(); - if let Some(var_map) = vars.as_object() { - for (path_position, param_value) in self.path.iter().enumerate() { - path.push('/'); - - if let Some(value) = param_value.interpolate(var_map)? { - path.push_str(value.as_str()); - } else { - return Err(format!( - "Incomplete path parameter {} at position {} with variables {}", - param_value, - path_position, - JSON::Object(var_map.clone()), - )); - } - } - - let mut params = vec![]; - for (key, param_value) in &self.query { - if let Some(value) = param_value.interpolate(var_map)? { - params.push(format!("{}={}", key, value)); - } - } - if !params.is_empty() { - path.push('?'); - path.push_str(¶ms.join("&")); - } - } else { - return Err(format!("Expected object, got {}", vars)); - } - - if path.is_empty() { - Ok("/".to_string()) - } else if path.starts_with('/') { - Ok(path) - } else { - Ok(format!("/{}", path)) - } - } - - // Given a URLPathTemplate and a concrete URL path, extract any named/nested - // variables from the path and return them as a JSON object. - #[allow(dead_code)] - fn extract_vars(&self, path: &str) -> Result { - let concrete_template = URLPathTemplate::parse(path)?; - - if concrete_template.path.len() != self.path.len() { - return Err(format!( - "Path length {} does not match concrete path length {}", - self.path.len(), - concrete_template.path.len() - )); - } - - let mut var_map = Map::new(); - - for (i, path_value) in self.path.iter().enumerate() { - for (var_path, value) in path_value.extract_vars(&concrete_template.path[i])? { - var_map.insert(var_path, value); - } - } - - // For each query parameter, extract the corresponding variable(s) from - // the concrete template text. - for (key, query_value) in self.query.iter() { - if let Some(concrete_value) = concrete_template.query.get(key) { - for (var_path, value) in query_value.extract_vars(concrete_value)? { - var_map.insert(var_path, value); - } - } else { - // If there is no corresponding query parameter in the concrete - // URL path, we can't extract variables, which is only a problem - // if any of the expected variables are required. - for part in &query_value.parts { - if let ValuePart::Var(var) = part { - if var.required { - return Err(format!( - "Missing required query parameter {}={}", - key, query_value - )); - } - } - } - } - } - - Ok(JSON::Object(var_map)) - } - - pub fn required_parameters(&self) -> Vec { - let mut parameters = IndexSet::default(); - for param_value in &self.path { - parameters.extend(param_value.required_parameters()); - } - for param_value in self.query.values() { - parameters.extend(param_value.required_parameters()); - } - // sorted for a stable SDL - parameters.into_iter().sorted().collect() - } -} - -impl Display for URLPathTemplate { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for param_value in &self.path { - f.write_str("/")?; - param_value.fmt(f)?; - } - - if !self.query.is_empty() { - f.write_str("?")?; - let mut first = true; - for (key, param_value) in &self.query { - if first { - first = false; - } else { - f.write_str("&")?; - } - f.write_str(key)?; - f.write_str("=")?; - param_value.fmt(f)?; - } - } - - Ok(()) - } -} - -impl Serialize for URLPathTemplate { - fn serialize(&self, serializer: S) -> Result { - serializer.collect_str(self) - } -} - -impl ParameterValue { - fn parse(input: &str, required_by_default: bool) -> Result { - // Split the text around any {...} variable expressions, which must be - // separated by nonempty text. - let mut parts = vec![]; - let mut remaining = input; - - while let Some((prefix, suffix)) = remaining.split_once('{') { - if !prefix.is_empty() { - parts.push(ValuePart::Text(prefix.to_string())); - } - remaining = suffix; - - if let Some((var, suffix)) = remaining.split_once('}') { - parts.push(ValuePart::Var(VariableExpression::parse( - var, - required_by_default, - )?)); - remaining = suffix; - } else { - return Err(format!( - "Missing closing brace in URL suffix {} of path {}", - remaining, input - )); - } - } - - if !remaining.is_empty() { - parts.push(ValuePart::Text(remaining.to_string())); - } - - // Enforce that variable expressions must be separated by nonempty text - // delimiters, though the parameter value may start or end with variable - // expressions without preceding/following text. - let mut prev_part_was_var = false; - for part in &parts { - if let ValuePart::Var(_) = part { - if prev_part_was_var { - return Err(format!( - "Ambiguous adjacent variable expressions in {}", - input, - )); - } - prev_part_was_var = true; - } else { - prev_part_was_var = false; - } - } - - Ok(ParameterValue { parts }) - } - - fn interpolate(&self, vars: &Map) -> Result, String> { - let mut value = String::new(); - let mut missing_vars = vec![]; - let mut some_vars_required = false; - - for part in &self.parts { - match part { - ValuePart::Text(text) => { - value.push_str(text); - } - ValuePart::Var(var) => { - if let Some(var_value) = var.interpolate(vars)? { - value.push_str(&var_value); - } else { - missing_vars.push(var); - } - if var.required { - some_vars_required = true; - } - } - } - } - - // If any variable fails to interpolate, the whole ParameterValue fails - // to interpolate. This can be harmless if none of the variables are - // required, but if any of the variables are required (not just the - // variables that failed to interpolate), then the whole ParameterValue - // is required, so any missing variable becomes an error. - if let Some(missing) = missing_vars.into_iter().next() { - if some_vars_required { - return Err(format!( - "Missing variable {} for required parameter {} given variables {}", - missing.var_path, - self, - JSON::Object(vars.clone()), - )); - } else { - return Ok(None); - } - } - - Ok(Some(value)) - } - - fn extract_vars( - &self, - concrete_value: &ParameterValue, - ) -> Result, String> { - let mut concrete_text = String::new(); - for part in &concrete_value.parts { - concrete_text.push_str(match part { - ValuePart::Text(text) => text, - ValuePart::Var(var) => { - return Err(format!("Unexpected variable expression {{{}}}", var)); - } - }); - } - - let mut concrete_suffix = concrete_text.as_str(); - let mut pending_var: Option<&VariableExpression> = None; - let mut output = Map::new(); - - fn add_var_value( - var: &VariableExpression, - value: &str, - output: &mut Map, - ) { - let key = ByteString::from(var.var_path.as_str()); - if let Some(separator) = &var.batch_separator { - let mut values = vec![]; - for value in value.split(separator) { - if !value.is_empty() { - values.push(JSON::String(ByteString::from(value))); - } - } - output.insert(key, JSON::Array(values)); - } else if !value.is_empty() { - output.insert(key, JSON::String(ByteString::from(value))); - } - } - - for part in &self.parts { - match part { - ValuePart::Text(text) => { - if let Some(var) = pending_var { - if let Some(start) = concrete_suffix.find(text) { - add_var_value(var, &concrete_suffix[..start], &mut output); - concrete_suffix = &concrete_suffix[start..]; - } else { - add_var_value(var, concrete_suffix, &mut output); - concrete_suffix = ""; - } - pending_var = None; - } - - if concrete_suffix.starts_with(text) { - concrete_suffix = &concrete_suffix[text.len()..]; - } else { - return Err(format!( - "Constant text {} not found in {}", - text, concrete_text - )); - } - } - ValuePart::Var(var) => { - if let Some(pending) = pending_var { - return Err(format!( - "Ambiguous adjacent variable expressions {} and {} in parameter value {}", - pending, var, concrete_text - )); - } else { - // This variable's value will be extracted from the - // concrete URL by the ValuePart::Text branch above, on - // the next iteration of the for loop. - pending_var = Some(var); - } - } - } - } - - if let Some(var) = pending_var { - add_var_value(var, concrete_suffix, &mut output); - } - - Ok(output) - } - - fn required_parameters(&self) -> Vec { - let mut parameters = vec![]; - for part in &self.parts { - match part { - ValuePart::Text(_) => {} - ValuePart::Var(var) => { - if var.required { - parameters.push(var.var_path.clone()); - } - } - } - } - parameters - } -} - -impl Display for ParameterValue { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for part in &self.parts { - part.fmt(f)?; - } - Ok(()) - } -} - -impl Serialize for ValuePart { - fn serialize(&self, serializer: S) -> Result { - serializer.collect_str(self) - } -} - -impl Display for ValuePart { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ValuePart::Text(text) => { - f.write_str(text)?; - } - ValuePart::Var(var) => { - f.write_str("{")?; - var.fmt(f)?; - f.write_str("}")?; - } - } - Ok(()) - } -} - -impl Serialize for ParameterValue { - fn serialize(&self, serializer: S) -> Result { - serializer.collect_str(self) - } -} - -impl VariableExpression { - // TODO Figure out if this required parameter is really the best way to - // handle ! variables. - fn parse(input: &str, required: bool) -> Result { - tuple(( - nom_parse_identifier_path, - opt(char('!')), - opt(pair(one_of(",;|+ "), tag("..."))), - ))(input) - .map_err(|err| format!("Error parsing variable expression {}: {}", input, err)) - .and_then( - |(remaining, (var_path, exclamation_point, batch_separator))| { - if remaining.is_empty() { - Ok(VariableExpression { - var_path, - required: exclamation_point.is_some() || required, - batch_separator: batch_separator - .map(|(separator, _)| separator.to_string()), - }) - } else { - Err(format!( - "Unexpected trailing characters {} in variable expression {}", - remaining, input - )) - } - }, - ) - } - - fn interpolate(&self, vars: &Map) -> Result, String> { - let var_path_bytes = ByteString::from(self.var_path.as_str()); - if let Some(child_value) = vars.get(&var_path_bytes) { - if let Some(separator) = &self.batch_separator { - if let JSON::Array(array) = child_value { - let mut value_strings = vec![]; - for value in array { - value_strings.push(self.value_as_string(value)); - } - if value_strings.is_empty() { - return Ok(None); - } else { - return Ok(Some(value_strings.join(separator))); - } - } - // Fall through to handle non-array values as single batch inputs. - } - Ok(Some(self.value_as_string(child_value))) - } else if self.required { - return Err(format!( - "Missing required variable {} in {}", - self.var_path, - JSON::Object(vars.clone()), - )); - } else { - return Ok(None); - } - } - - fn value_as_string(&self, value: &JSON) -> String { - // Need to remove quotes from string values, since the quotes don't - // belong in the URL. - if let JSON::String(string) = value { - string.as_str().to_string() - } else { - value.to_string() - } - } -} - -impl Display for VariableExpression { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.var_path)?; - if self.required { - f.write_str("!")?; - } - if let Some(separator) = &self.batch_separator { - f.write_str(separator)?; - f.write_str("...")?; - } - Ok(()) - } -} - -fn nom_parse_identifier_possible_namespace(input: &str) -> IResult<&str, &str> { - recognize(alt((tag("$this"), nom_parse_identifier)))(input) -} - -fn nom_parse_identifier(input: &str) -> IResult<&str, &str> { - recognize(pair( - one_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"), - many0(one_of( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789", - )), - ))(input) -} - -fn nom_parse_identifier_path(input: &str) -> IResult<&str, String> { - let (input, first) = nom_parse_identifier_possible_namespace(input)?; - let (input, mut rest) = many0(preceded(char('.'), nom_parse_identifier))(input)?; - let mut identifier_path = vec![first]; - identifier_path.append(&mut rest); - Ok((input, identifier_path.join("."))) -} - -#[cfg(test)] -mod tests { - use serde_json_bytes::json; - - use super::*; - - #[test] - fn test_parse_identifier() { - assert_eq!(nom_parse_identifier("abc"), Ok(("", "abc"))); - assert_eq!(nom_parse_identifier("abc123"), Ok(("", "abc123"))); - assert_eq!(nom_parse_identifier("abc_123"), Ok(("", "abc_123"))); - assert_eq!(nom_parse_identifier("abc-123"), Ok(("-123", "abc"))); - } - - #[test] - fn test_parse_identifier_path() { - assert_eq!( - nom_parse_identifier_path("abc"), - Ok(("", "abc".to_string())), - ); - assert_eq!( - nom_parse_identifier_path("abc.def"), - Ok(("", "abc.def".to_string())), - ); - assert_eq!( - nom_parse_identifier_path("abc.def.ghi"), - Ok(("", "abc.def.ghi".to_string())), - ); - assert_eq!( - nom_parse_identifier_path("$this.def.ghi"), - Ok(("", "$this.def.ghi".to_string())), - ); - - assert!(nom_parse_identifier_path("$anything.def.ghi").is_err()); - assert_eq!( - nom_parse_identifier_path("abc.$this.ghi"), - Ok((".$this.ghi", "abc".to_string())), - ); - } - - #[test] - fn test_path_list() { - assert_eq!( - URLPathTemplate::parse("/abc"), - Ok(URLPathTemplate { - path: vec![ParameterValue { - parts: vec![ValuePart::Text("abc".to_string())], - },], - ..Default::default() - }), - ); - - assert_eq!( - URLPathTemplate::parse("/abc/def"), - Ok(URLPathTemplate { - path: vec![ - ParameterValue { - parts: vec![ValuePart::Text("abc".to_string())], - }, - ParameterValue { - parts: vec![ValuePart::Text("def".to_string())], - }, - ], - ..Default::default() - }), - ); - - assert_eq!( - URLPathTemplate::parse("/abc/{def}"), - Ok(URLPathTemplate { - path: vec![ - ParameterValue { - parts: vec![ValuePart::Text("abc".to_string())], - }, - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "def".to_string(), - required: true, - ..Default::default() - })], - }, - ], - ..Default::default() - }), - ); - - assert_eq!( - URLPathTemplate::parse("/abc/{def}/ghi"), - Ok(URLPathTemplate { - path: vec![ - ParameterValue { - parts: vec![ValuePart::Text("abc".to_string())], - }, - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "def".to_string(), - required: true, - ..Default::default() - })], - }, - ParameterValue { - parts: vec![ValuePart::Text("ghi".to_string())], - }, - ], - ..Default::default() - }), - ); - } - - #[test] - fn test_url_path_template_parse() { - assert_eq!( - URLPathTemplate::parse("/users/{user_id}?a=b"), - Ok(URLPathTemplate { - path: vec![ - ParameterValue { - parts: vec![ValuePart::Text("users".to_string())], - }, - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "user_id".to_string(), - required: true, - ..Default::default() - })], - }, - ], - query: IndexMap::from([( - "a".to_string(), - ParameterValue { - parts: vec![ValuePart::Text("b".to_string())], - } - )]), - }), - ); - - assert_eq!( - URLPathTemplate::parse("/users/{user_id}?a={b}&c={d!}&e={f.g}"), - Ok(URLPathTemplate { - path: vec![ - ParameterValue { - parts: vec![ValuePart::Text("users".to_string())], - }, - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "user_id".to_string(), - required: true, - ..Default::default() - })], - }, - ], - query: IndexMap::from([ - ( - "e".to_string(), - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "f.g".to_string(), - ..Default::default() - })], - }, - ), - ( - "a".to_string(), - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "b".to_string(), - ..Default::default() - })], - }, - ), - ( - "c".to_string(), - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "d".to_string(), - required: true, - ..Default::default() - })], - }, - ), - ]), - }), - ); - - assert_eq!( - URLPathTemplate::parse("/users/{id}?a={b}#junk"), - Ok(URLPathTemplate { - path: vec![ - ParameterValue { - parts: vec![ValuePart::Text("users".to_string())], - }, - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "id".to_string(), - required: true, - ..Default::default() - })], - }, - ], - query: IndexMap::from([( - "a".to_string(), - ParameterValue { - parts: vec![ - ValuePart::Var(VariableExpression { - var_path: "b".to_string(), - ..Default::default() - }), - ValuePart::Text("#junk".to_string()), - ], - }, - )]), - }), - ); - - assert_eq!( - URLPathTemplate::parse("/location/{lat},{lon}"), - Ok(URLPathTemplate { - path: vec![ - ParameterValue { - parts: vec![ValuePart::Text("location".to_string())], - }, - ParameterValue { - parts: vec![ - ValuePart::Var(VariableExpression { - var_path: "lat".to_string(), - required: true, - ..Default::default() - }), - ValuePart::Text(",".to_string()), - ValuePart::Var(VariableExpression { - var_path: "lon".to_string(), - required: true, - ..Default::default() - }), - ], - }, - ], - ..Default::default() - }), - ); - - assert_eq!( - URLPathTemplate::parse("/point3/{x},{y},{z}?a={b}"), - Ok(URLPathTemplate { - path: vec![ - ParameterValue { - parts: vec![ValuePart::Text("point3".to_string())], - }, - ParameterValue { - parts: vec![ - ValuePart::Var(VariableExpression { - var_path: "x".to_string(), - required: true, - ..Default::default() - }), - ValuePart::Text(",".to_string()), - ValuePart::Var(VariableExpression { - var_path: "y".to_string(), - required: true, - ..Default::default() - }), - ValuePart::Text(",".to_string()), - ValuePart::Var(VariableExpression { - var_path: "z".to_string(), - required: true, - ..Default::default() - }), - ], - }, - ], - query: IndexMap::from([( - "a".to_string(), - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "b".to_string(), - ..Default::default() - })], - }, - )]), - }), - ); - } - - #[test] - fn test_generate_path() { - let template = URLPathTemplate::parse("/users/{user_id}?a={b}&c={d!}&e={f.g}").unwrap(); - - assert_eq!( - template.generate_path(&json!("not an object")), - Err(r#"Expected object, got "not an object""#.to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - // A variables object without any properties - })), - Err("Missing required variable user_id in {}".to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "user_id": 123, - "b": "456", - "d": 789, - "f.g": "abc", - })), - Ok("/users/123?a=456&c=789&e=abc".to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "user_id": 123, - "d": 789, - "f": "not an object", - })), - Ok("/users/123?c=789".to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "b": "456", - "f.g": "abc", - "user_id": "123", - })), - Err( - r#"Missing required variable d in {"b":"456","f.g":"abc","user_id":"123"}"# - .to_string() - ), - ); - - assert_eq!( - template.generate_path(&json!({ - // The order of the variables should not matter. - "d": "789", - "b": "456", - "user_id": "123", - })), - Ok("/users/123?a=456&c=789".to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "user_id": "123", - "b": "a", - "d": "c", - "f.g": "e", - // Extra variables should be ignored. - "extra": "ignored", - })), - Ok("/users/123?a=a&c=c&e=e".to_string()), - ); - - let template_with_nested_required_var = - URLPathTemplate::parse("/repositories/{user.login}/{repo.name}?testing={a.b.c!}") - .unwrap(); - - assert_eq!( - template_with_nested_required_var.generate_path(&json!({ - "repo.name": "repo", - "user.login": "user", - })), - Err( - r#"Missing required variable a.b.c in {"repo.name":"repo","user.login":"user"}"# - .to_string() - ), - ); - - assert_eq!( - template_with_nested_required_var.generate_path(&json!({ - "user.login": "user", - "repo.name": "repo", - "a.b.c": "value", - })), - Ok("/repositories/user/repo?testing=value".to_string()), - ); - } - - #[test] - fn test_generate_path_empty() { - assert_eq!( - URLPathTemplate::parse("") - .unwrap() - .generate_path(&json!({})) - .unwrap(), - "/".to_string() - ); - - assert_eq!( - URLPathTemplate::parse("/") - .unwrap() - .generate_path(&json!({})) - .unwrap(), - "/".to_string() - ); - - assert_eq!( - URLPathTemplate::parse("?foo=bar") - .unwrap() - .generate_path(&json!({})) - .unwrap(), - "/?foo=bar".to_string() - ); - } - - #[test] - fn test_batch_expressions() { - assert_eq!( - URLPathTemplate::parse("/users?ids={id,...}"), - Ok(URLPathTemplate { - path: vec![ParameterValue { - parts: vec![ValuePart::Text("users".to_string())], - }], - query: IndexMap::from([( - "ids".to_string(), - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "id".to_string(), - batch_separator: Some(",".to_string()), - ..Default::default() - })], - }, - )]), - }), - ); - - assert_eq!( - URLPathTemplate::parse("/v1/products?ids={id ...}&names={name|...}"), - Ok(URLPathTemplate { - path: vec![ - ParameterValue { - parts: vec![ValuePart::Text("v1".to_string())] - }, - ParameterValue { - parts: vec![ValuePart::Text("products".to_string())] - }, - ], - query: IndexMap::from([ - ( - "ids".to_string(), - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "id".to_string(), - batch_separator: Some(" ".to_string()), - ..Default::default() - })], - }, - ), - ( - "names".to_string(), - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "name".to_string(), - batch_separator: Some("|".to_string()), - ..Default::default() - })], - }, - ), - ]), - }), - ); - - assert_eq!( - URLPathTemplate::parse("/people?ids={person.id,...}"), - Ok(URLPathTemplate { - path: vec![ParameterValue { - parts: vec![ValuePart::Text("people".to_string())], - }], - query: IndexMap::from([( - "ids".to_string(), - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "person.id".to_string(), - batch_separator: Some(",".to_string()), - ..Default::default() - })], - }, - )]), - }), - ); - - assert_eq!( - URLPathTemplate::parse("/people/{uid}/notes?ids={note_id;...}"), - Ok(URLPathTemplate { - path: vec![ - ParameterValue { - parts: vec![ValuePart::Text("people".to_string())], - }, - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "uid".to_string(), - required: true, - ..Default::default() - })], - }, - ParameterValue { - parts: vec![ValuePart::Text("notes".to_string())], - }, - ], - query: IndexMap::from([( - "ids".to_string(), - ParameterValue { - parts: vec![ValuePart::Var(VariableExpression { - var_path: "note_id".to_string(), - batch_separator: Some(";".to_string()), - ..Default::default() - })], - }, - )]), - }), - ); - - assert_eq!( - URLPathTemplate::parse("/people/by_uid:{uid}/notes?ids=[{note_id;...}]"), - Ok(URLPathTemplate { - path: vec![ - ParameterValue { - parts: vec![ValuePart::Text("people".to_string())], - }, - ParameterValue { - parts: vec![ - ValuePart::Text("by_uid:".to_string()), - ValuePart::Var(VariableExpression { - var_path: "uid".to_string(), - required: true, - ..Default::default() - }), - ], - }, - ParameterValue { - parts: vec![ValuePart::Text("notes".to_string())], - }, - ], - - query: IndexMap::from([( - "ids".to_string(), - ParameterValue { - parts: vec![ - ValuePart::Text("[".to_string()), - ValuePart::Var(VariableExpression { - var_path: "note_id".to_string(), - batch_separator: Some(";".to_string()), - ..Default::default() - }), - ValuePart::Text("]".to_string()), - ], - }, - )]), - }), - ); - } - - #[test] - fn test_batch_generation() { - let template = URLPathTemplate::parse("/users?ids={id,...}").unwrap(); - - assert_eq!( - template.generate_path(&json!({ - "id": [1, 2, 3], - })), - Ok("/users?ids=1,2,3".to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "id": [1], - })), - Ok("/users?ids=1".to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "id": [], - })), - Ok("/users".to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "id": [1, 2, 3], - "extra": "ignored", - })), - Ok("/users?ids=1,2,3".to_string()), - ); - - let template = URLPathTemplate::parse("/users?ids={id;...}&names={name|...}").unwrap(); - - assert_eq!( - template.generate_path(&json!({ - "id": [1, 2, 3], - "name": ["a", "b", "c"], - })), - Ok("/users?ids=1;2;3&names=a|b|c".to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "id": 123, - "name": "456", - })), - Ok("/users?ids=123&names=456".to_string()), - ); - } - - #[test] - fn test_extract_vars_from_url_path() { - let repo_template = URLPathTemplate::parse("/repository/{user.login}/{repo.name}").unwrap(); - - assert_eq!( - repo_template.extract_vars("/repository/user/repo"), - Ok(json!({ - "user.login": "user", - "repo.name": "repo", - })), - ); - - let template_with_query_params = URLPathTemplate::parse( - "/contacts/{cid}/notes/{nid}?testing={a.b.c!}&testing2={a.b.d}&type={type}", - ) - .unwrap(); - - assert_eq!( - template_with_query_params - .extract_vars("/contacts/123/notes/456?testing=abc&testing2=def&type=ghi"), - Ok(json!({ - "cid": "123", - "nid": "456", - "a.b.c": "abc", - "a.b.d": "def", - "type": "ghi", - })), - ); - - assert_eq!( - template_with_query_params - .extract_vars("/contacts/123/notes/456?testing2=def&type=ghi"), - Err("Missing required query parameter testing={a.b.c!}".to_string()), - ); - - assert_eq!( - template_with_query_params.extract_vars("/contacts/123/notes/456?testing=789"), - Ok(json!({ - "cid": "123", - "nid": "456", - "a.b.c": "789", - })), - ); - - assert_eq!( - template_with_query_params.extract_vars("/contacts/123/notes/{nid}?testing=abc"), - Err("Unexpected variable expression {nid!}".to_string()), - ); - - assert_eq!( - template_with_query_params.extract_vars("/contacts/123/notes/456?testing={wrong}"), - Err("Unexpected variable expression {wrong}".to_string()), - ); - - assert_eq!( - template_with_query_params.extract_vars("/wrong/123/notes/456?testing=abc"), - Err("Constant text contacts not found in wrong".to_string()), - ); - - assert_eq!( - template_with_query_params.extract_vars("/contacts/123/wrong/456?testing=abc"), - Err("Constant text notes not found in wrong".to_string()), - ); - - let template_with_constant_query_param = - URLPathTemplate::parse("/contacts/{cid}?constant=asdf&required={a!}&optional={b}") - .unwrap(); - - assert_eq!( - template_with_constant_query_param - .extract_vars("/contacts/123?required=456&optional=789"), - // Since constant-valued query parameters do not affect the - // extracted variables, we don't need to fail when they are missing - // from a given URL. - Ok(json!({ - "cid": "123", - "a": "456", - "b": "789", - })), - ); - - assert_eq!( - template_with_constant_query_param.generate_path(&json!({ - "cid": "123", - "a": "456", - })), - Ok("/contacts/123?constant=asdf&required=456".to_string()), - ); - - assert_eq!( - template_with_constant_query_param - .extract_vars("/contacts/123?required=456&constant=asdf"), - Ok(json!({ - "cid": "123", - "a": "456", - })), - ); - - assert_eq!( - template_with_constant_query_param - .extract_vars("/contacts/123?optional=789&required=456&constant=asdf"), - Ok(json!({ - "cid": "123", - "a": "456", - "b": "789", - })), - ); - - let template_with_constant_path_part = - URLPathTemplate::parse("/users/123/notes/{nid}").unwrap(); - - assert_eq!( - template_with_constant_path_part.extract_vars("/users/123/notes/456"), - Ok(json!({ - "nid": "456", - })), - ); - - assert_eq!( - template_with_constant_path_part.extract_vars("/users/123/notes/456?ignored=true"), - Ok(json!({ - "nid": "456", - })), - ); - - assert_eq!( - template_with_constant_path_part.extract_vars("/users/abc/notes/456"), - Err("Constant text 123 not found in abc".to_string()), - ); - } - - #[test] - fn test_multi_variable_parameter_values() { - let template = URLPathTemplate::parse( - "/locations/xyz({x},{y},{z})?required={b},{c};{d!}&optional=[{e},{f}]", - ) - .unwrap(); - - assert_eq!( - template.generate_path(&json!({ - "x": 1, - "y": 2, - "z": 3, - "b": 4, - "c": 5, - "d": 6, - "e": 7, - "f": 8, - })), - Ok("/locations/xyz(1,2,3)?required=4,5;6&optional=[7,8]".to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "x": 1, - "y": 2, - "z": 3, - "b": 4, - "c": 5, - "d": 6, - "e": 7, - // "f": 8, - })), - Ok("/locations/xyz(1,2,3)?required=4,5;6".to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "x": 1, - "y": 2, - "z": 3, - "b": 4, - "c": 5, - "d": 6, - // "e": 7, - "f": 8, - })), - Ok("/locations/xyz(1,2,3)?required=4,5;6".to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "x": 1, - "y": 2, - "z": 3, - "b": 4, - "c": 5, - "d": 6, - })), - Ok("/locations/xyz(1,2,3)?required=4,5;6".to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - // "x": 1, - "y": 2, - "z": 3, - })), - Err(r#"Missing required variable x in {"y":2,"z":3}"#.to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "x": 1, - "y": 2, - // "z": 3, - })), - Err(r#"Missing required variable z in {"x":1,"y":2}"#.to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "b": 4, - "c": 5, - "x": 1, - "y": 2, - "z": 3, - // "d": 6, - })), - Err(r#"Missing required variable d in {"b":4,"c":5,"x":1,"y":2,"z":3}"#.to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - "b": 4, - // "c": 5, - "d": 6, - "x": 1, - "y": 2, - "z": 3, - })), - Err(r#"Missing variable c for required parameter {b},{c};{d!} given variables {"b":4,"d":6,"x":1,"y":2,"z":3}"#.to_string()), - ); - - assert_eq!( - template.generate_path(&json!({ - // "b": 4, - // "c": 5, - "d": 6, - "x": 1, - "y": 2, - "z": 3, - })), - Err(r#"Missing variable b for required parameter {b},{c};{d!} given variables {"d":6,"x":1,"y":2,"z":3}"#.to_string()), - ); - - assert_eq!( - URLPathTemplate::parse( - "/locations/xyz({x}{y}{z})?required={b},{c};{d!}&optional=[{e}{f},{g}]" - ), - Err("Ambiguous adjacent variable expressions in xyz({x}{y}{z})".to_string()), - ); - - assert_eq!( - URLPathTemplate::parse( - "/locations/xyz({x},{y},{z})?required={b}{c};{d!}&optional=[{e}{f},{g}]" - ), - Err("Ambiguous adjacent variable expressions in {b}{c};{d!}".to_string()), - ); - - assert_eq!( - URLPathTemplate::parse( - "/locations/xyz({x},{y},{z})?required={b},{c};{d!}&optional=[{e};{f}{g}]" - ), - Err("Ambiguous adjacent variable expressions in [{e};{f}{g}]".to_string()), - ); - - assert_eq!( - template.extract_vars("/locations/xyz(1,2,3)?required=4,5;6&optional=[7,8]"), - Ok(json!({ - "x": "1", - "y": "2", - "z": "3", - "b": "4", - "c": "5", - "d": "6", - "e": "7", - "f": "8", - })), - ); - - assert_eq!( - template.extract_vars("/locations/xyz(3,2,1)?required=-5,10.1;2"), - Ok(json!({ - "x": "3", - "y": "2", - "z": "1", - "b": "-5", - "c": "10.1", - "d": "2", - })), - ); - - assert_eq!( - template.extract_vars("/locations/xyz(3,2,1)?optional=[-5,10.1;2]&required=6,7;8"), - Ok(json!({ - "x": "3", - "y": "2", - "z": "1", - "b": "6", - "c": "7", - "d": "8", - "e": "-5", - "f": "10.1;2", - })), - ); - - assert_eq!( - template.extract_vars("/locations/xyz(3,2,1?required=4,5;6)"), - Err("Constant text ) not found in xyz(3,2,1".to_string()), - ); - - assert_eq!( - template.extract_vars("/locations/xyz(3,2,1)?required=4,5,6"), - Err("Constant text ; not found in 4,5,6".to_string()), - ); - - assert_eq!( - template.extract_vars("/locations/xyz(3,2,1)?optional=[p,q]&required=4,5;6"), - Ok(json!({ - "x": "3", - "y": "2", - "z": "1", - "b": "4", - "c": "5", - "d": "6", - "e": "p", - "f": "q", - })), - ); - - assert_eq!( - template.extract_vars("/locations/xyz(3,2,1)?optional=(r,s)&required=4,5;6"), - Err("Constant text [ not found in (r,s)".to_string()), - ); - - assert_eq!( - template.extract_vars("/locations/xyz(3,2,1)?optional=[r,s)&required=4,5;6"), - Err("Constant text ] not found in [r,s)".to_string()), - ); - - assert_eq!( - template.extract_vars("/locations/xyz(1.25,2,3.5)?required=(4,5.1;6.6,7)"), - Ok(json!({ - "x": "1.25", - "y": "2", - "z": "3.5", - "b": "(4", - "c": "5.1", - "d": "6.6,7)", - })), - ); - - let line_template = - URLPathTemplate::parse("/line/{p1.x},{p1.y},{p1.z}/{p2.x},{p2.y},{p2.z}").unwrap(); - - assert_eq!( - line_template.generate_path(&json!({ - "p1.x": 1, - "p1.y": 2, - "p1.z": 3, - "p2.x": 4, - "p2.y": 5, - "p2.z": 6, - })), - Ok("/line/1,2,3/4,5,6".to_string()), - ); - - assert_eq!( - line_template.generate_path(&json!({ - "p1.x": 1, - "p1.y": 2, - "p1.z": 3, - "p2.x": 4, - "p2.y": 5, - // "p2.z": 6, - })), - Err(r#"Missing required variable p2.z in {"p1.x":1,"p1.y":2,"p1.z":3,"p2.x":4,"p2.y":5}"#.to_string()), - ); - - assert_eq!( - line_template.generate_path(&json!({ - "p1.x": 1, - // "p1.y": 2, - "p1.z": 3, - "p2.x": 4, - "p2.y": 5, - "p2.z": 6, - })), - Err(r#"Missing required variable p1.y in {"p1.x":1,"p1.z":3,"p2.x":4,"p2.y":5,"p2.z":6}"#.to_string()), - ); - - assert_eq!( - line_template.extract_vars("/line/6.6,5.5,4.4/3.3,2.2,1.1"), - Ok(json!({ - "p1.x": "6.6", - "p1.y": "5.5", - "p1.z": "4.4", - "p2.x": "3.3", - "p2.y": "2.2", - "p2.z": "1.1", - })), - ); - - assert_eq!( - line_template.extract_vars("/line/(6,5,4)/[3,2,1]"), - Ok(json!({ - "p1.x": "(6", - "p1.y": "5", - "p1.z": "4)", - "p2.x": "[3", - "p2.y": "2", - "p2.z": "1]", - })), - ); - - assert_eq!( - line_template.extract_vars("/line/6.6,5.5,4.4/3.3,2.2"), - Err("Constant text , not found in 3.3,2.2".to_string()), - ); - } - - #[test] - fn test_extract_batch_vars() { - let template_comma = URLPathTemplate::parse("/users?ids=[{id,...}]").unwrap(); - - assert_eq!( - template_comma.extract_vars("/users?ids=[1,2,3]"), - Ok(json!({ - "id": ["1", "2", "3"], - })), - ); - - assert_eq!( - template_comma.extract_vars("/users?ids=[]"), - Ok(json!({ - "id": [], - })), - ); - - assert_eq!( - template_comma.extract_vars("/users?ids=[123]&extra=ignored"), - Ok(json!({ - "id": ["123"], - })), - ); - - let template_semicolon = URLPathTemplate::parse("/columns/{a,...};{b,...}").unwrap(); - - assert_eq!( - template_semicolon.extract_vars("/columns/1;2"), - Ok(json!({ - "a": ["1"], - "b": ["2"], - })), - ); - - assert_eq!( - template_semicolon.extract_vars("/columns/1,2;3"), - Ok(json!({ - "a": ["1", "2"], - "b": ["3"], - })), - ); - - assert_eq!( - template_semicolon.extract_vars("/columns/1;2,3"), - Ok(json!({ - "a": ["1"], - "b": ["2", "3"], - })), - ); - - assert_eq!( - template_semicolon.extract_vars("/columns/1;2;3"), - Ok(json!({ - "a": ["1"], - "b": ["2;3"], - })), - ); - - assert_eq!( - template_semicolon.extract_vars("/columns/;3,2,1?extra=ignored"), - Ok(json!({ - "a": [], - "b": ["3", "2", "1"], - })), - ); - - assert_eq!( - template_semicolon.extract_vars("/columns/1,2,3;"), - Ok(json!({ - "a": ["1", "2", "3"], - "b": [], - })), - ); - - assert_eq!( - template_semicolon.extract_vars("/columns/1,2,3;9,8,7,6"), - Ok(json!({ - "a": ["1", "2", "3"], - "b": ["9", "8", "7", "6"], - })), - ); - - assert_eq!( - template_semicolon.extract_vars("/columns/;?extra=ignored"), - Ok(json!({ - "a": [], - "b": [], - })), - ); - } - - #[test] - fn test_display_trait() { - assert_eq!( - format!( - "{}", - URLPathTemplate::parse("/users/{user_id}?a={b}&c={d!}&e={f.g}").unwrap() - ), - "/users/{user_id!}?a={b}&c={d!}&e={f.g}".to_string(), - ); - - assert_eq!( - format!( - "{}", - URLPathTemplate::parse("/users/{user_id}?a={b}&c={d!}&e={f.g}").unwrap() - ), - "/users/{user_id!}?a={b}&c={d!}&e={f.g}".to_string(), - ); - - assert_eq!( - format!( - "{}", - URLPathTemplate::parse("/users/{user_id}?a={b}&c={d!}&e={f.g}").unwrap() - ), - "/users/{user_id!}?a={b}&c={d!}&e={f.g}".to_string(), - ); - - assert_eq!( - format!( - "{}", - URLPathTemplate::parse("/users?ids={id,...}&names={name|...}").unwrap() - ), - "/users?ids={id,...}&names={name|...}".to_string(), - ); - - assert_eq!( - format!( - "{}", - URLPathTemplate::parse("/users?ids={id!,...}&names={user.name|...}").unwrap() - ), - "/users?ids={id!,...}&names={user.name|...}".to_string(), - ); - - assert_eq!( - format!("{}", URLPathTemplate::parse("/position/{x},{y}").unwrap(),), - "/position/{x!},{y!}".to_string(), - ); - - assert_eq!( - format!( - "{}", - URLPathTemplate::parse("/position/xyz({x},{y},{z})").unwrap(), - ), - "/position/xyz({x!},{y!},{z!})".to_string(), - ); - - assert_eq!( - format!( - "{}", - URLPathTemplate::parse("/position?xyz=({x},{y},{z})").unwrap(), - ), - "/position?xyz=({x},{y},{z})".to_string(), - ); - } - - #[test] - fn test_required_parameters() { - assert_eq!( - URLPathTemplate::parse("/users/{user_id}?a={b}&c={d.e!}&e={f.g}") - .unwrap() - .required_parameters(), - vec!["d.e", "user_id"], - ); - - assert_eq!( - URLPathTemplate::parse("/users?ids={id,...}&names={name|...}") - .unwrap() - .required_parameters(), - Vec::::new(), - ); - - assert_eq!( - URLPathTemplate::parse("/users?ids={id!,...}&names={user.name|...}") - .unwrap() - .required_parameters(), - vec!["id"], - ); - - assert_eq!( - URLPathTemplate::parse("/position/{x},{y}") - .unwrap() - .required_parameters(), - vec!["x", "y"], - ); - - assert_eq!( - URLPathTemplate::parse("/position/xyz({x},{y},{z})") - .unwrap() - .required_parameters(), - vec!["x", "y", "z"], - ); - - assert_eq!( - URLPathTemplate::parse("/position?xyz=({x!},{y},{z!})") - .unwrap() - .required_parameters(), - vec!["x", "z"], - ); - - assert_eq!( - URLPathTemplate::parse("/users/{id}?user_id={id}") - .unwrap() - .required_parameters(), - vec!["id"], - ); - - assert_eq!( - URLPathTemplate::parse("/users/{$this.id}?foo={$this.bar!}") - .unwrap() - .required_parameters(), - vec!["$this.bar", "$this.id"], - ); - } -} diff --git a/apollo-federation/src/sources/mod.rs b/apollo-federation/src/sources/mod.rs deleted file mode 100644 index eb7f65c88d..0000000000 --- a/apollo-federation/src/sources/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod connect; diff --git a/apollo-federation/src/subgraph/database.rs b/apollo-federation/src/subgraph/database.rs deleted file mode 100644 index 141e57d5c8..0000000000 --- a/apollo-federation/src/subgraph/database.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Valid federation 2 subgraphs. -//! -//! Note: technically, federation 1 subgraphs are still accepted as input of -//! composition. However, there is some pre-composition steps that "massage" -//! the input schema to transform them in fully valid federation 2 subgraphs, -//! so the subgraphs seen by composition and query planning are always fully -//! valid federation 2 ones, and this is what this database handles. -//! Note2: This does assumes that whichever way an implementation of this -//! trait is created, some validation that the underlying schema is a valid -//! federation subgraph (so valid graphql, link to the federation spec, and -//! pass additional federation validations). If this is not the case, most -//! of the methods here will panic. - -use std::sync::Arc; - -use apollo_compiler::executable::SelectionSet; - -// TODO: we should define this as part as some more generic "FederationSpec" definition, but need -// to define the ground work for that in `apollo-at-link` first. -#[cfg(test)] -pub(crate) fn federation_link_identity() -> crate::link::spec::Identity { - crate::link::spec::Identity { - domain: crate::link::spec::APOLLO_SPEC_DOMAIN.to_string(), - name: apollo_compiler::name!("federation"), - } -} - -#[derive(Eq, PartialEq, Debug, Clone)] -pub(crate) struct Key { - pub(crate) type_name: apollo_compiler::Name, - // TODO: this should _not_ be an Option below; but we don't know how to build the SelectionSet, - // so until we have a solution, we use None to have code that compiles. - selections: Option>, -} - -impl Key { - // TODO: same remark as above: not meant to be `Option` - // TODO remove suppression OR use method in final version - #[allow(dead_code)] - pub(crate) fn selections(&self) -> Option> { - self.selections.clone() - } - - #[cfg(test)] - pub(crate) fn from_directive_application( - type_name: &apollo_compiler::Name, - directive: &apollo_compiler::executable::Directive, - ) -> Option { - directive - .arguments - .iter() - .find(|arg| arg.name == "fields") - .and_then(|arg| arg.value.as_str()) - .map(|_value| Key { - type_name: type_name.clone(), - // TODO: obviously not what we want. - selections: None, - }) - } -} - -#[cfg(test)] -pub(crate) fn federation_link(schema: &apollo_compiler::Schema) -> Arc { - crate::link::database::links_metadata(schema) - // TODO: error handling? - .unwrap_or_default() - .unwrap_or_default() - .for_identity(&federation_link_identity()) - .expect("The presence of the federation link should have been validated on construction") -} - -/// The name of the @key directive in this subgraph. -/// This will either return 'federation__key' if the `@key` directive is not imported, -/// or whatever never it is imported under otherwise. Commonly, this would just be `key`. -#[cfg(test)] -pub(crate) fn key_directive_name(schema: &apollo_compiler::Schema) -> apollo_compiler::Name { - federation_link(schema).directive_name_in_schema(&apollo_compiler::name!("key")) -} - -#[cfg(test)] -pub(crate) fn keys( - schema: &apollo_compiler::Schema, - type_name: &apollo_compiler::Name, -) -> Vec { - let key_name = key_directive_name(schema); - if let Some(type_def) = schema.types.get(type_name) { - type_def - .directives() - .get_all(&key_name) - .filter_map(|directive| Key::from_directive_application(type_name, directive)) - .collect() - } else { - vec![] - } -} diff --git a/apollo-federation/src/subgraph/mod.rs b/apollo-federation/src/subgraph/mod.rs index 4ba138ba8f..021c10ad66 100644 --- a/apollo-federation/src/subgraph/mod.rs +++ b/apollo-federation/src/subgraph/mod.rs @@ -1,37 +1,42 @@ -use std::collections::BTreeMap; +use std::fmt::Display; use std::fmt::Formatter; -use std::sync::Arc; +use std::ops::Range; +use apollo_compiler::Node; +use apollo_compiler::Schema; use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use apollo_compiler::name; +use apollo_compiler::parser::LineColumn; use apollo_compiler::schema::ComponentName; use apollo_compiler::schema::ExtendedType; use apollo_compiler::schema::ObjectType; +use apollo_compiler::validation::DiagnosticList; use apollo_compiler::validation::Valid; -use apollo_compiler::Node; -use apollo_compiler::Schema; use indexmap::map::Entry; +use crate::ValidFederationSubgraph; use crate::error::FederationError; -use crate::link::spec::Identity; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::link::DEFAULT_LINK_NAME; use crate::link::Link; use crate::link::LinkError; -use crate::link::DEFAULT_LINK_NAME; -use crate::subgraph::spec::AppliedFederationLink; -use crate::subgraph::spec::FederationSpecDefinitions; -use crate::subgraph::spec::LinkSpecDefinitions; +use crate::link::spec::Identity; use crate::subgraph::spec::ANY_SCALAR_NAME; +use crate::subgraph::spec::AppliedFederationLink; +use crate::subgraph::spec::CONTEXTFIELDVALUE_SCALAR_NAME; use crate::subgraph::spec::ENTITIES_QUERY; use crate::subgraph::spec::ENTITY_UNION_NAME; use crate::subgraph::spec::FEDERATION_V2_DIRECTIVE_NAMES; +use crate::subgraph::spec::FederationSpecDefinitions; use crate::subgraph::spec::KEY_DIRECTIVE_NAME; +use crate::subgraph::spec::LinkSpecDefinitions; use crate::subgraph::spec::SERVICE_SDL_QUERY; use crate::subgraph::spec::SERVICE_TYPE; -use crate::ValidFederationSubgraph; -mod database; pub mod spec; +pub mod typestate; // TODO: Move here to overwrite Subgraph after API is reasonable pub struct Subgraph { pub name: String, @@ -118,7 +123,7 @@ impl Subgraph { .schema_definition .make_mut() .directives - .push(defaults.applied_link_directive().into()); + .push(defaults.applied_link_directive()); defaults } }; @@ -135,7 +140,7 @@ impl Subgraph { .schema_definition .make_mut() .directives - .push(defaults.applied_link_directive().into()); + .push(defaults.applied_link_directive()); defaults } }; @@ -175,6 +180,7 @@ impl Subgraph { schema: &mut Schema, fed_definitions: &FederationSpecDefinitions, ) -> Result<(), FederationError> { + // scalar FieldSet let fieldset_scalar_name = &fed_definitions.fieldset_scalar_name; schema .types @@ -185,6 +191,19 @@ impl Subgraph { .into() }); + // scalar ContextFieldValue + let namespaced_contextfieldvalue_scalar_name = + fed_definitions.namespaced_type_name(&CONTEXTFIELDVALUE_SCALAR_NAME, false); + if let Entry::Vacant(entry) = schema + .types + .entry(namespaced_contextfieldvalue_scalar_name.clone()) + { + let type_definition = fed_definitions.contextfieldvalue_scalar_definition(&Some( + namespaced_contextfieldvalue_scalar_name, + )); + entry.insert(type_definition.into()); + } + for directive_name in &FEDERATION_V2_DIRECTIVE_NAMES { let namespaced_directive_name = fed_definitions.namespaced_type_name(directive_name, true); @@ -290,32 +309,6 @@ impl std::fmt::Debug for Subgraph { } } -pub struct Subgraphs { - subgraphs: BTreeMap>, -} - -#[allow(clippy::new_without_default)] -impl Subgraphs { - pub fn new() -> Self { - Subgraphs { - subgraphs: BTreeMap::new(), - } - } - - pub fn add(&mut self, subgraph: Subgraph) -> Result<(), String> { - if self.subgraphs.contains_key(&subgraph.name) { - return Err(format!("A subgraph named {} already exists", subgraph.name)); - } - self.subgraphs - .insert(subgraph.name.clone(), Arc::new(subgraph)); - Ok(()) - } - - pub fn get(&self, name: &str) -> Option> { - self.subgraphs.get(name).cloned() - } -} - pub struct ValidSubgraph { pub name: String, pub url: String, @@ -338,48 +331,450 @@ impl From for ValidSubgraph { } } +#[derive(Clone, Debug)] +pub(crate) struct SingleSubgraphError { + pub(crate) error: SingleFederationError, + pub(crate) locations: Vec>, +} + +/// Currently, this is making up for the fact that we don't have an equivalent of `addSubgraphToErrors`. +/// In JS, that manipulates the underlying `GraphQLError` message to prepend the subgraph name. In Rust, +/// it's idiomatic to have strongly typed errors which defer conversion to strings via `thiserror`, so +/// for now we wrap the underlying error until we figure out a longer-term replacement that accounts +/// for missing error codes and the like. +#[derive(Clone, Debug)] +pub struct SubgraphError { + pub(crate) subgraph: String, + pub(crate) errors: Vec, +} + +impl SubgraphError { + // Legacy constructor without locations info. + pub(crate) fn new_without_locations( + subgraph: impl Into, + error: impl Into, + ) -> Self { + let subgraph = subgraph.into(); + let error: FederationError = error.into(); + SubgraphError { + subgraph, + errors: error + .errors() + .into_iter() + .map(|e| SingleSubgraphError { + error: e.clone(), + locations: Vec::new(), + }) + .collect(), + } + } + + /// Construct from a FederationError. + /// + /// Note: FederationError may hold multiple errors. In that case, all individual errors in the + /// FederationError will share the same locations. + #[allow(dead_code)] + pub(crate) fn from_federation_error( + subgraph: impl Into, + error: impl Into, + locations: Vec>, + ) -> Self { + let error: FederationError = error.into(); + let errors = error + .errors() + .into_iter() + .map(|e| SingleSubgraphError { + error: e.clone(), + locations: locations.clone(), + }) + .collect(); + SubgraphError { + subgraph: subgraph.into(), + errors, + } + } + + /// Constructing from GraphQL errors. + pub(crate) fn from_diagnostic_list( + subgraph: impl Into, + errors: DiagnosticList, + ) -> Self { + let subgraph = subgraph.into(); + SubgraphError { + subgraph, + errors: errors + .iter() + .map(|d| SingleSubgraphError { + error: SingleFederationError::InvalidGraphQL { + message: d.to_string(), + }, + locations: d.line_column_range().iter().cloned().collect(), + }) + .collect(), + } + } + + /// Convert SubgraphError to FederationError. + /// * WARNING: This is a lossy conversion, losing location information. + pub(crate) fn into_federation_error(self) -> FederationError { + MultipleFederationErrors::from_iter(self.errors.into_iter().map(|e| e.error)).into() + } + + // Format subgraph errors in the same way as `Rover` does. + // And return them as a vector of (error_code, error_message) tuples + // - Gather associated errors from the validation error. + // - Split each error into its code and message. + // - Add the subgraph name prefix to CompositionError message. + // + // This is mainly for internal testing. Consider using `to_composition_errors` method instead. + pub fn format_errors(&self) -> Vec<(String, String)> { + self.errors + .iter() + .map(|e| { + let error = &e.error; + ( + error.code_string(), + format!("[{subgraph}] {error}", subgraph = self.subgraph), + ) + }) + .collect() + } +} + +impl Display for SubgraphError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let subgraph = &self.subgraph; + for (code, message) in self.format_errors() { + writeln!(f, "{code} [{subgraph}] {message}")?; + } + Ok(()) + } +} + +pub mod test_utils { + use super::SubgraphError; + use super::typestate::Expanded; + use super::typestate::Subgraph; + use super::typestate::Validated; + + pub enum BuildOption { + AsIs, + AsFed2, + } + + pub fn build_inner( + schema_str: &str, + build_option: BuildOption, + ) -> Result, SubgraphError> { + let name = "S"; + let subgraph = + Subgraph::parse(name, &format!("http://{name}"), schema_str).expect("valid schema"); + let subgraph = if matches!(build_option, BuildOption::AsFed2) { + subgraph.into_fed2_test_subgraph(true)? + } else { + subgraph + }; + let mut subgraph = subgraph.expand_links()?.assume_upgraded(); + subgraph.normalize_root_types()?; + subgraph.validate() + } + + pub fn build_inner_expanded( + schema_str: &str, + build_option: BuildOption, + ) -> Result, SubgraphError> { + let name = "S"; + let subgraph = + Subgraph::parse(name, &format!("http://{name}"), schema_str).expect("valid schema"); + let subgraph = if matches!(build_option, BuildOption::AsFed2) { + subgraph.into_fed2_test_subgraph(true)? + } else { + subgraph + }; + subgraph.expand_links() + } + + pub fn build_and_validate(schema_str: &str) -> Subgraph { + build_inner(schema_str, BuildOption::AsIs).expect("expanded subgraph to be valid") + } + + pub fn build_and_expand(schema_str: &str) -> Subgraph { + build_inner_expanded(schema_str, BuildOption::AsIs).expect("expanded subgraph to be valid") + } + + pub fn build_for_errors_with_option( + schema: &str, + build_option: BuildOption, + ) -> Vec<(String, String)> { + build_inner(schema, build_option) + .expect_err("subgraph error was expected") + .format_errors() + } + + /// Build subgraph expecting errors, assuming fed 2. + pub fn build_for_errors(schema: &str) -> Vec<(String, String)> { + build_for_errors_with_option(schema, BuildOption::AsFed2) + } + + pub fn remove_indentation(s: &str) -> String { + // count the last lines that are space-only + let first_empty_lines = s.lines().take_while(|line| line.trim().is_empty()).count(); + let last_empty_lines = s + .lines() + .rev() + .take_while(|line| line.trim().is_empty()) + .count(); + + // lines without the space-only first/last lines + let lines = s + .lines() + .skip(first_empty_lines) + .take(s.lines().count() - first_empty_lines - last_empty_lines); + + // compute the indentation + let indentation = lines + .clone() + .map(|line| line.chars().take_while(|c| *c == ' ').count()) + .min() + .unwrap_or(0); + + // remove the indentation + lines + .map(|line| { + line.trim_end() + .chars() + .skip(indentation) + .collect::() + }) + .collect::>() + .join("\n") + } + + /// True if a and b contain the same error messages + pub fn check_errors(a: &[(String, String)], b: &[(&str, &str)]) -> Result<(), String> { + if a.len() != b.len() { + return Err(format!( + "Mismatched error counts: {} != {}\n\nexpected:\n{}\n\nactual:\n{}", + b.len(), + a.len(), + b.iter() + .map(|(code, msg)| { format!("- {code}: {msg}") }) + .collect::>() + .join("\n"), + a.iter() + .map(|(code, msg)| { format!("+ {code}: {msg}") }) + .collect::>() + .join("\n"), + )); + } + + // remove indentations from messages to ignore indentation differences + let b_iter = b + .iter() + .map(|(code, message)| (*code, remove_indentation(message))); + let diff: Vec<_> = a + .iter() + .map(|(code, message)| (code.as_str(), remove_indentation(message))) + .zip(b_iter) + .filter(|(a_i, b_i)| a_i.0 != b_i.0 || a_i.1 != b_i.1) + .collect(); + if diff.is_empty() { + Ok(()) + } else { + Err(format!( + "Mismatched errors:\n{}\n", + diff.iter() + .map(|(a_i, b_i)| { format!("- {}: {}\n+ {}: {}", b_i.0, b_i.1, a_i.0, a_i.1) }) + .collect::>() + .join("\n") + )) + } + } + + #[macro_export] + macro_rules! assert_errors { + ($a:expr, $b:expr) => { + match apollo_federation::subgraph::test_utils::check_errors(&$a, &$b) { + Ok(()) => { + // Success + } + Err(e) => { + panic!("{e}") + } + } + }; + } +} + +// INTERNAL: For use by Language Server Protocol (LSP) team +// WARNING: Any changes to this function signature will result in breakages in the dependency chain +// Generates a diff string containing directives and types not included in initial schema string +pub fn schema_diff_expanded_from_initial(schema_str: String) -> Result { + // Parse schema string as Schema + let initial_schema = Schema::parse(schema_str, "")?; + + // Initialize and expand subgraph, without validation + let initial_subgraph = typestate::Subgraph::new("S", "http://S", initial_schema.clone()); + let expanded_subgraph = initial_subgraph + .expand_links() + .map_err(|e| e.into_federation_error())?; + + // Build string of missing directives and types from initial to expanded + let mut diff = String::new(); + + // Push newly added directives onto diff + for (dir_name, dir_def) in &expanded_subgraph.schema().schema().directive_definitions { + if !initial_schema.directive_definitions.contains_key(dir_name) { + diff.push_str(&dir_def.to_string()); + diff.push('\n'); + } + } + + // Push newly added types onto diff + for (named_ty, extended_ty) in &expanded_subgraph.schema().schema().types { + if !initial_schema.types.contains_key(named_ty) { + diff.push_str(&extended_ty.to_string()); + } + } + + Ok(diff) +} + #[cfg(test)] mod tests { - use super::*; - use crate::subgraph::database::keys; + use crate::subgraph::schema_diff_expanded_from_initial; #[test] - fn can_inspect_a_type_key() { - // TODO: no schema expansion currently, so need to having the `@link` to `link` and the - // @link directive definition for @link-bootstrapping to work. Also, we should - // theoretically have the @key directive definition added too (but validation is not - // wired up yet, so we get away without). Point being, this is just some toy code at - // the moment. - - let schema = r#" - extend schema - @link(url: "https://specs.apollo.dev/link/v1.0", import: ["Import"]) - @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) - - type Query { - t: T - } - - type T @key(fields: "id") { - id: ID! - x: Int - } - - enum link__Purpose { - SECURITY - EXECUTION - } - - scalar Import - - directive @link(url: String, as: String, import: [Import], for: link__Purpose) repeatable on SCHEMA - "#; - - let subgraph = Subgraph::new("S1", "http://s1", schema).unwrap(); - let keys = keys(&subgraph.schema, &name!("T")); - assert_eq!(keys.len(), 1); - assert_eq!(keys.first().unwrap().type_name, name!("T")); - - // TODO: no accessible selection yet. + fn returns_correct_schema_diff_for_fed_2_0() { + let schema_string = r#" + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0") + + type Query { + s: String + }"# + .to_string(); + + let diff = schema_diff_expanded_from_initial(schema_string); + + insta::assert_snapshot!(diff.unwrap_or_default(), @r#"directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA +directive @federation__key(fields: federation__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @federation__requires(fields: federation__FieldSet!) on FIELD_DEFINITION +directive @federation__provides(fields: federation__FieldSet!) on FIELD_DEFINITION +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION +directive @federation__tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION +directive @federation__extends on OBJECT | INTERFACE +directive @federation__shareable on OBJECT | FIELD_DEFINITION +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION +directive @federation__override(from: String!) on FIELD_DEFINITION +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} +scalar link__Import +scalar federation__FieldSet +scalar _Any +type _Service { + sdl: String +}"#); + } + + #[test] + fn returns_correct_schema_diff_for_fed_2_4() { + let schema_string = r#" + extend schema @link(url: "https://specs.apollo.dev/federation/v2.4") + + type Query { + s: String + }"# + .to_string(); + + let diff = schema_diff_expanded_from_initial(schema_string); + + insta::assert_snapshot!(diff.unwrap_or_default(), @r#"directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA +directive @federation__key(fields: federation__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @federation__requires(fields: federation__FieldSet!) on FIELD_DEFINITION +directive @federation__provides(fields: federation__FieldSet!) on FIELD_DEFINITION +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION +directive @federation__tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA +directive @federation__extends on OBJECT | INTERFACE +directive @federation__shareable repeatable on OBJECT | FIELD_DEFINITION +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION +directive @federation__override(from: String!) on FIELD_DEFINITION +directive @federation__composeDirective(name: String) repeatable on SCHEMA +directive @federation__interfaceObject on OBJECT +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} +scalar link__Import +scalar federation__FieldSet +scalar _Any +type _Service { + sdl: String +}"#); + } + + #[test] + fn returns_correct_schema_diff_for_fed_2_9() { + let schema_string = r#" + extend schema @link(url: "https://specs.apollo.dev/federation/v2.9") + + type Query { + s: String + }"# + .to_string(); + + let diff = schema_diff_expanded_from_initial(schema_string); + + insta::assert_snapshot!(diff.unwrap_or_default(), @r#"directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA +directive @federation__key(fields: federation__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @federation__requires(fields: federation__FieldSet!) on FIELD_DEFINITION +directive @federation__provides(fields: federation__FieldSet!) on FIELD_DEFINITION +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION +directive @federation__tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA +directive @federation__extends on OBJECT | INTERFACE +directive @federation__shareable repeatable on OBJECT | FIELD_DEFINITION +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION +directive @federation__override(from: String!, label: String) on FIELD_DEFINITION +directive @federation__composeDirective(name: String) repeatable on SCHEMA +directive @federation__interfaceObject on OBJECT +directive @federation__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @federation__requiresScopes(scopes: [[federation__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @federation__policy(policies: [[federation__Policy!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION +directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR +directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} +scalar link__Import +scalar federation__FieldSet +scalar federation__Scope +scalar federation__Policy +scalar federation__ContextFieldValue +scalar _Any +type _Service { + sdl: String +}"#); } } diff --git a/apollo-federation/src/subgraph/spec.rs b/apollo-federation/src/subgraph/spec.rs index 44ba278309..205412e0eb 100644 --- a/apollo-federation/src/subgraph/spec.rs +++ b/apollo-federation/src/subgraph/spec.rs @@ -1,5 +1,9 @@ use std::sync::Arc; +use std::sync::LazyLock; +use apollo_compiler::InvalidNameError; +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::ast::Argument; use apollo_compiler::ast::Directive; use apollo_compiler::ast::DirectiveDefinition; @@ -20,27 +24,25 @@ use apollo_compiler::schema::ObjectType; use apollo_compiler::schema::ScalarType; use apollo_compiler::schema::UnionType; use apollo_compiler::ty; -use apollo_compiler::InvalidNameError; -use apollo_compiler::Name; -use apollo_compiler::Node; -use lazy_static::lazy_static; use thiserror::Error; -use crate::link::spec::Identity; -use crate::link::spec::Url; -use crate::link::spec::Version; -use crate::link::Import; -use crate::link::Link; use crate::link::DEFAULT_IMPORT_SCALAR_NAME; use crate::link::DEFAULT_LINK_NAME; use crate::link::DEFAULT_PURPOSE_ENUM_NAME; +use crate::link::Import; +use crate::link::Link; +use crate::link::spec::Identity; +use crate::link::spec::Url; +use crate::link::spec::Version; use crate::subgraph::spec::FederationSpecError::UnsupportedFederationDirective; use crate::subgraph::spec::FederationSpecError::UnsupportedVersionError; pub const COMPOSE_DIRECTIVE_NAME: Name = name!("composeDirective"); +pub const CONTEXT_DIRECTIVE_NAME: Name = name!("context"); pub const KEY_DIRECTIVE_NAME: Name = name!("key"); pub const EXTENDS_DIRECTIVE_NAME: Name = name!("extends"); pub const EXTERNAL_DIRECTIVE_NAME: Name = name!("external"); +pub const FROM_CONTEXT_DIRECTIVE_NAME: Name = name!("fromContext"); pub const INACCESSIBLE_DIRECTIVE_NAME: Name = name!("inaccessible"); pub const INTF_OBJECT_DIRECTIVE_NAME: Name = name!("interfaceObject"); pub const OVERRIDE_DIRECTIVE_NAME: Name = name!("override"); @@ -48,9 +50,8 @@ pub const PROVIDES_DIRECTIVE_NAME: Name = name!("provides"); pub const REQUIRES_DIRECTIVE_NAME: Name = name!("requires"); pub const SHAREABLE_DIRECTIVE_NAME: Name = name!("shareable"); pub const TAG_DIRECTIVE_NAME: Name = name!("tag"); -pub const CONTEXT_DIRECTIVE_NAME: Name = name!("context"); -pub const FROM_CONTEXT_DIRECTIVE_NAME: Name = name!("fromContext"); pub const FIELDSET_SCALAR_NAME: Name = name!("FieldSet"); +pub const CONTEXTFIELDVALUE_SCALAR_NAME: Name = name!("ContextFieldValue"); // federated types pub const ANY_SCALAR_NAME: Name = name!("_Any"); @@ -68,11 +69,13 @@ pub const FEDERATION_V1_DIRECTIVE_NAMES: [Name; 5] = [ REQUIRES_DIRECTIVE_NAME, ]; -pub const FEDERATION_V2_DIRECTIVE_NAMES: [Name; 11] = [ +pub const FEDERATION_V2_DIRECTIVE_NAMES: [Name; 13] = [ COMPOSE_DIRECTIVE_NAME, + CONTEXT_DIRECTIVE_NAME, KEY_DIRECTIVE_NAME, EXTENDS_DIRECTIVE_NAME, EXTERNAL_DIRECTIVE_NAME, + FROM_CONTEXT_DIRECTIVE_NAME, INACCESSIBLE_DIRECTIVE_NAME, INTF_OBJECT_DIRECTIVE_NAME, OVERRIDE_DIRECTIVE_NAME, @@ -82,13 +85,19 @@ pub const FEDERATION_V2_DIRECTIVE_NAMES: [Name; 11] = [ TAG_DIRECTIVE_NAME, ]; +#[allow(dead_code)] +pub(crate) const FEDERATION_V2_ELEMENT_NAMES: [Name; 2] = + [FIELDSET_SCALAR_NAME, CONTEXTFIELDVALUE_SCALAR_NAME]; + // This type and the subsequent IndexMap exist purely so we can use match with Names; see comment // in FederationSpecDefinitions.directive_definition() for more information. enum FederationDirectiveName { Compose, + Context, Key, Extends, External, + FromContext, Inaccessible, IntfObject, Override, @@ -98,13 +107,18 @@ enum FederationDirectiveName { Tag, } -lazy_static! { - static ref FEDERATION_DIRECTIVE_NAMES_TO_ENUM: IndexMap = { +static FEDERATION_DIRECTIVE_NAMES_TO_ENUM: LazyLock> = + LazyLock::new(|| { IndexMap::from_iter([ (COMPOSE_DIRECTIVE_NAME, FederationDirectiveName::Compose), + (CONTEXT_DIRECTIVE_NAME, FederationDirectiveName::Context), (KEY_DIRECTIVE_NAME, FederationDirectiveName::Key), (EXTENDS_DIRECTIVE_NAME, FederationDirectiveName::Extends), (EXTERNAL_DIRECTIVE_NAME, FederationDirectiveName::External), + ( + FROM_CONTEXT_DIRECTIVE_NAME, + FederationDirectiveName::FromContext, + ), ( INACCESSIBLE_DIRECTIVE_NAME, FederationDirectiveName::Inaccessible, @@ -119,8 +133,7 @@ lazy_static! { (SHAREABLE_DIRECTIVE_NAME, FederationDirectiveName::Shareable), (TAG_DIRECTIVE_NAME, FederationDirectiveName::Tag), ]) - }; -} + }); const MIN_FEDERATION_VERSION: Version = Version { major: 2, minor: 0 }; const MAX_FEDERATION_VERSION: Version = Version { major: 2, minor: 5 }; @@ -285,9 +298,11 @@ impl FederationSpecDefinitions { }; Ok(match enum_name { FederationDirectiveName::Compose => self.compose_directive_definition(alias), + FederationDirectiveName::Context => self.context_directive_definition(alias), FederationDirectiveName::Key => self.key_directive_definition(alias)?, FederationDirectiveName::Extends => self.extends_directive_definition(alias), FederationDirectiveName::External => self.external_directive_definition(alias), + FederationDirectiveName::FromContext => self.from_context_directive_definition(alias), FederationDirectiveName::Inaccessible => self.inaccessible_directive_definition(alias), FederationDirectiveName::IntfObject => { self.interface_object_directive_definition(alias) @@ -309,6 +324,15 @@ impl FederationSpecDefinitions { } } + /// scalar ContextFieldValue + pub fn contextfieldvalue_scalar_definition(&self, alias: &Option) -> ScalarType { + ScalarType { + description: None, + name: alias.clone().unwrap_or(CONTEXTFIELDVALUE_SCALAR_NAME), + directives: Default::default(), + } + } + fn fields_argument_definition(&self) -> Result { Ok(InputValueDefinition { description: None, @@ -326,19 +350,45 @@ impl FederationSpecDefinitions { DirectiveDefinition { description: None, name: alias.clone().unwrap_or(COMPOSE_DIRECTIVE_NAME), - arguments: vec![InputValueDefinition { - description: None, - name: name!("name"), - ty: ty!(String!).into(), - default_value: None, - directives: Default::default(), - } - .into()], + arguments: vec![ + InputValueDefinition { + description: None, + name: name!("name"), + ty: ty!(String).into(), + default_value: None, + directives: Default::default(), + } + .into(), + ], repeatable: true, locations: vec![DirectiveLocation::Schema], } } + /// directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + fn context_directive_definition(&self, alias: &Option) -> DirectiveDefinition { + DirectiveDefinition { + description: None, + name: alias.clone().unwrap_or(CONTEXT_DIRECTIVE_NAME), + arguments: vec![ + InputValueDefinition { + description: None, + name: name!("name"), + ty: ty!(String!).into(), + default_value: None, + directives: Default::default(), + } + .into(), + ], + repeatable: true, + locations: vec![ + DirectiveLocation::Interface, + DirectiveLocation::Object, + DirectiveLocation::Union, + ], + } + } + /// directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE fn key_directive_definition( &self, @@ -388,6 +438,34 @@ impl FederationSpecDefinitions { } } + // The directive is named `@fromContext`. This is confusing for clippy, as + // `from` is a conventional prefix used in conversion methods, which do not + // take `self` as an argument. This function does **not** perform + // conversion, but extracts `@fromContext` directive definition. + /// directive @fromContext(field: ContextFieldValue) on ARGUMENT_DEFINITION + #[allow(clippy::wrong_self_convention)] + fn from_context_directive_definition(&self, alias: &Option) -> DirectiveDefinition { + DirectiveDefinition { + description: None, + name: alias.clone().unwrap_or(FROM_CONTEXT_DIRECTIVE_NAME), + arguments: vec![ + InputValueDefinition { + description: None, + name: name!("field"), + ty: Type::Named( + self.namespaced_type_name(&CONTEXTFIELDVALUE_SCALAR_NAME, false), + ) + .into(), + default_value: None, + directives: Default::default(), + } + .into(), + ], + repeatable: false, + locations: vec![DirectiveLocation::ArgumentDefinition], + } + } + /// directive @inaccessible on /// | ARGUMENT_DEFINITION /// | ENUM @@ -436,14 +514,16 @@ impl FederationSpecDefinitions { DirectiveDefinition { description: None, name: alias.clone().unwrap_or(OVERRIDE_DIRECTIVE_NAME), - arguments: vec![InputValueDefinition { - description: None, - name: name!("from"), - ty: ty!(String!).into(), - default_value: None, - directives: Default::default(), - } - .into()], + arguments: vec![ + InputValueDefinition { + description: None, + name: name!("from"), + ty: ty!(String!).into(), + default_value: None, + directives: Default::default(), + } + .into(), + ], repeatable: false, locations: vec![DirectiveLocation::FieldDefinition], } @@ -506,14 +586,16 @@ impl FederationSpecDefinitions { DirectiveDefinition { description: None, name: alias.clone().unwrap_or(TAG_DIRECTIVE_NAME), - arguments: vec![InputValueDefinition { - description: None, - name: name!("name"), - ty: ty!(String!).into(), - default_value: None, - directives: Default::default(), - } - .into()], + arguments: vec![ + InputValueDefinition { + description: None, + name: name!("name"), + ty: ty!(String!).into(), + default_value: None, + directives: Default::default(), + } + .into(), + ], repeatable: true, locations: vec![ DirectiveLocation::ArgumentDefinition, @@ -722,8 +804,8 @@ impl Default for LinkSpecDefinitions { #[cfg(test)] mod tests { use super::*; - use crate::link::spec::Identity; use crate::link::spec::APOLLO_SPEC_DOMAIN; + use crate::link::spec::Identity; // TODO: we should define this as part as some more generic "FederationSpec" definition, but need // to define the ground work for that in `apollo-at-link` first. diff --git a/apollo-federation/src/subgraph/typestate.rs b/apollo-federation/src/subgraph/typestate.rs new file mode 100644 index 0000000000..873b71ae04 --- /dev/null +++ b/apollo-federation/src/subgraph/typestate.rs @@ -0,0 +1,1471 @@ +use std::collections::HashMap; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::Schema; +use apollo_compiler::ast; +use apollo_compiler::ast::OperationType; +use apollo_compiler::collections::IndexSet; +use apollo_compiler::schema::Component; +use apollo_compiler::schema::ComponentName; +use apollo_compiler::schema::Directive; +use apollo_compiler::schema::Type; + +use crate::LinkSpecDefinition; +use crate::ValidFederationSchema; +use crate::bail; +use crate::ensure; +use crate::error::FederationError; +use crate::error::MultipleFederationErrors; +use crate::error::SingleFederationError; +use crate::internal_error; +use crate::link::DEFAULT_LINK_NAME; +use crate::link::federation_spec_definition::FED_1; +use crate::link::federation_spec_definition::FEDERATION_EXTENDS_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_FROM_CONTEXT_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_KEY_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_OVERRIDE_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC; +use crate::link::federation_spec_definition::FEDERATION_VERSIONS; +use crate::link::federation_spec_definition::FederationSpecDefinition; +use crate::link::link_spec_definition::LINK_DIRECTIVE_IMPORT_ARGUMENT_NAME; +use crate::link::link_spec_definition::LINK_DIRECTIVE_URL_ARGUMENT_NAME; +use crate::link::spec::Identity; +use crate::link::spec::Version; +use crate::link::spec_definition::SpecDefinition; +use crate::schema::FederationSchema; +use crate::schema::SchemaElement; +use crate::schema::blueprint::FederationBlueprint; +use crate::schema::compute_subgraph_metadata; +use crate::schema::position::ObjectFieldDefinitionPosition; +use crate::schema::position::ObjectOrInterfaceTypeDefinitionPosition; +use crate::schema::position::SchemaRootDefinitionKind; +use crate::schema::position::SchemaRootDefinitionPosition; +use crate::schema::position::TypeDefinitionPosition; +use crate::schema::subgraph_metadata::SubgraphMetadata; +use crate::schema::type_and_directive_specification::FieldSpecification; +use crate::schema::type_and_directive_specification::ResolvedArgumentSpecification; +use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; +use crate::schema::type_and_directive_specification::UnionTypeSpecification; +use crate::subgraph::SubgraphError; +use crate::supergraph::ANY_TYPE_SPEC; +use crate::supergraph::EMPTY_QUERY_TYPE_SPEC; +use crate::supergraph::FEDERATION_ANY_TYPE_NAME; +use crate::supergraph::FEDERATION_ENTITIES_FIELD_NAME; +use crate::supergraph::FEDERATION_ENTITY_TYPE_NAME; +use crate::supergraph::FEDERATION_REPRESENTATIONS_ARGUMENTS_NAME; +use crate::supergraph::FEDERATION_SERVICE_FIELD_NAME; +use crate::supergraph::GRAPHQL_MUTATION_TYPE_NAME; +use crate::supergraph::GRAPHQL_QUERY_TYPE_NAME; +use crate::supergraph::GRAPHQL_SUBSCRIPTION_TYPE_NAME; +use crate::supergraph::SERVICE_TYPE_SPEC; + +#[derive(Clone, Debug)] +pub struct Initial { + schema: Schema, +} + +#[derive(Clone, Debug)] +pub struct Expanded { + schema: FederationSchema, + metadata: SubgraphMetadata, +} + +#[derive(Clone, Debug)] +pub struct Upgraded { + schema: FederationSchema, + metadata: SubgraphMetadata, +} + +#[derive(Clone, Debug)] +pub struct Validated { + schema: ValidFederationSchema, + metadata: SubgraphMetadata, +} + +pub(crate) trait HasMetadata { + fn metadata(&self) -> &SubgraphMetadata; + fn schema(&self) -> &FederationSchema; +} + +impl HasMetadata for Expanded { + fn metadata(&self) -> &SubgraphMetadata { + &self.metadata + } + + fn schema(&self) -> &FederationSchema { + &self.schema + } +} + +impl HasMetadata for Upgraded { + fn metadata(&self) -> &SubgraphMetadata { + &self.metadata + } + + fn schema(&self) -> &FederationSchema { + &self.schema + } +} + +impl HasMetadata for Validated { + fn metadata(&self) -> &SubgraphMetadata { + &self.metadata + } + + fn schema(&self) -> &FederationSchema { + &self.schema + } +} + +/// A subgraph represents a schema and its associated metadata. Subgraphs are updated through the +/// composition pipeline, such as when links are expanded or when fed 1 subgraphs are upgraded to fed 2. +/// We aim to encode these state transitions using the [typestate pattern](https://cliffle.com/blog/rust-typestate). +/// +/// ```text +/// (expand) (validate) +/// Initial ──► Expanded ──► Upgraded ──► Validated +/// ▲ │ +/// └────────────┘ +/// (mutate/invalidate) +/// ``` +/// +/// Subgraph states and their invariants: +/// - `Initial`: The initial state, containing original schema. This provides no guarantees about the schema, +/// other than that it can be parsed. +/// - `Expanded`: The schema's links have been expanded to include missing directive definitions and subgraph +/// metadata has been computed. +/// - `Upgraded`: The schema has been upgraded to Federation v2 format (if starting with Fed v2 schema then this is no-op). +/// - `Validated`: The schema has been validated according to Federation rules. Iterators over directives are +/// infallible at this stage. +#[derive(Clone, Debug)] +pub struct Subgraph { + pub name: String, + pub url: String, + pub state: S, +} + +impl Subgraph { + pub fn new(name: &str, url: &str, schema: Schema) -> Subgraph { + Subgraph { + name: name.to_string(), + url: url.to_string(), + state: Initial { schema }, + } + } + + pub fn parse( + name: &str, + url: &str, + schema_str: &str, + ) -> Result, SubgraphError> { + let schema = Schema::builder() + .adopt_orphan_extensions() + .parse(schema_str, name) + .build() + .map_err(|e| SubgraphError::from_diagnostic_list(name, e.errors))?; + + Ok(Self::new(name, url, schema)) + } + + /// Converts the schema to a fed2 schema. + /// - It is assumed to have no `@link` to the federation spec. + /// - Returns an equivalent subgraph with a `@link` to the auto expanded federation spec. + /// - This is mainly for testing and not optimized. + // PORT_NOTE: Corresponds to `asFed2SubgraphDocument` function in JS, but simplified. + pub fn into_fed2_test_subgraph(self, use_latest: bool) -> Result { + let mut schema = self.state.schema; + let federation_spec = if use_latest { + FederationSpecDefinition::latest() + } else { + FederationSpecDefinition::auto_expanded_federation_spec() + }; + add_federation_link_to_schema(&mut schema, federation_spec.version()) + .map_err(|e| SubgraphError::new_without_locations(self.name.clone(), e))?; + Ok(Self::new(&self.name, &self.url, schema)) + } + + /// Converts the schema to a fed2 schema. + /// - It is assumed to have no federation spec link. + /// - Returns an equivalent subgraph with a `@link` to the auto expanded federation spec. + /// - Similar to `into_fed2_test_subgraph`, but more robust. + pub fn into_fed2_subgraph(self) -> Result { + let schema = new_federation_subgraph_schema(self.state.schema)?; + let inner_schema = schema_as_fed2_subgraph(schema, false)?; + Ok(Self::new(&self.name, &self.url, inner_schema)) + } + + pub fn assume_expanded(self) -> Result, SubgraphError> { + let schema = FederationSchema::new(self.state.schema) + .map_err(|e| SubgraphError::new_without_locations(self.name.clone(), e))?; + let metadata = compute_subgraph_metadata(&schema) + .and_then(|m| { + m.ok_or_else(|| { + internal_error!( + "Unable to detect federation version used in subgraph '{}'", + self.name + ) + }) + }) + .map_err(|e| SubgraphError::new_without_locations(self.name.clone(), e))?; + + Ok(Subgraph { + name: self.name, + url: self.url, + state: Expanded { schema, metadata }, + }) + } + + pub fn expand_links(self) -> Result, SubgraphError> { + tracing::debug!("expand_links: expand subgraph `{}`", self.name); + let subgraph_name = self.name.clone(); + self.expand_links_internal() + .map_err(|e| SubgraphError::new_without_locations(subgraph_name, e)) + } + + fn expand_links_internal(self) -> Result, FederationError> { + let schema = expand_schema(self.state.schema)?; + tracing::debug!("expand_links: compute_subgraph_metadata"); + let metadata = compute_subgraph_metadata(&schema)?.ok_or_else(|| { + internal_error!( + "Unable to detect federation version used in subgraph '{}'", + self.name + ) + })?; + + tracing::debug!("expand_links: finished"); + Ok(Subgraph { + name: self.name, + url: self.url, + state: Expanded { schema, metadata }, + }) + } +} + +impl Subgraph { + pub fn assume_upgraded(self) -> Subgraph { + Subgraph { + name: self.name, + url: self.url, + state: Upgraded { + schema: self.state.schema, + metadata: self.state.metadata, + }, + } + } +} + +impl Subgraph { + pub fn assume_validated(self) -> Result, SubgraphError> { + let valid_federation_schema = ValidFederationSchema::new_assume_valid(self.state.schema) + .map_err(|(_schema, error)| { + SubgraphError::new_without_locations(self.name.clone(), error) + })?; + Ok(Subgraph { + name: self.name, + url: self.url, + state: Validated { + schema: valid_federation_schema, + metadata: self.state.metadata, + }, + }) + } + + pub fn validate(self) -> Result, SubgraphError> { + let schema = self + .state + .schema + .validate_or_return_self() + .map_err(|(schema, err)| { + // Specialize GraphQL validation errors. + let iter = err.into_errors().into_iter().map(|err| match err { + SingleFederationError::InvalidGraphQL { message } => { + FederationBlueprint::on_invalid_graphql_error(&schema, message) + } + _ => err, + }); + SubgraphError::new_without_locations( + self.name.clone(), + MultipleFederationErrors::from_iter(iter), + ) + })?; + + FederationBlueprint::on_validation(&schema, &self.state.metadata) + .map_err(|e| SubgraphError::new_without_locations(self.name.clone(), e))?; + + Ok(Subgraph { + name: self.name, + url: self.url, + state: Validated { + schema, + metadata: self.state.metadata, + }, + }) + } + + pub fn normalize_root_types(&mut self) -> Result<(), SubgraphError> { + let mut operation_types_to_rename = HashMap::new(); + for (op_type, op_name) in self + .state + .schema + .schema() + .schema_definition + .iter_root_operations() + { + let default_name = default_operation_name(&op_type); + if op_name.name != default_name { + operation_types_to_rename.insert(op_name.name.clone(), default_name.clone()); + if self + .state + .schema + .try_get_type(default_name.clone()) + .is_some() + { + // TODO: Add locations + return Err(SubgraphError::new_without_locations( + self.name.clone(), + SingleFederationError::root_already_used( + op_type, + default_name, + op_name.name.clone(), + ), + )); + } + } + } + for (current_name, new_name) in operation_types_to_rename { + self.state + .schema + .get_type(current_name) + .map_err(|e| SubgraphError::new_without_locations(self.name.clone(), e))? + .rename(&mut self.state.schema, new_name) + .map_err(|e| SubgraphError::new_without_locations(self.name.clone(), e))?; + } + + Ok(()) + } +} + +fn default_operation_name(op_type: &OperationType) -> Name { + match op_type { + OperationType::Query => GRAPHQL_QUERY_TYPE_NAME, + OperationType::Mutation => GRAPHQL_MUTATION_TYPE_NAME, + OperationType::Subscription => GRAPHQL_SUBSCRIPTION_TYPE_NAME, + } +} + +impl Subgraph { + pub fn validated_schema(&self) -> &ValidFederationSchema { + &self.state.schema + } + + pub fn invalidate(self) -> Subgraph { + // PORT_NOTE: In JS, the metadata gets invalidated by calling + // `federationMetadata.onInvalidate` (via `FederationBlueprint.onValidation`). But, it + // doesn't seem necessary in Rust, since the metadata is computed eagerly. + Subgraph { + name: self.name, + url: self.url, + state: Upgraded { + // Other holders may still need the data in the `Arc`, so we clone the contents to allow mutation later + schema: (*self.state.schema).clone(), + metadata: self.state.metadata, + }, + } + } +} + +#[allow(private_bounds)] +impl Subgraph { + pub(crate) fn metadata(&self) -> &SubgraphMetadata { + self.state.metadata() + } + + pub(crate) fn schema(&self) -> &FederationSchema { + self.state.schema() + } + + /// Returns the schema as a string. Mainly for testing purposes. + pub fn schema_string(&self) -> String { + self.schema().schema().to_string() + } + + pub(crate) fn extends_directive_name(&self) -> Result, FederationError> { + self.metadata() + .federation_spec_definition() + .directive_name_in_schema(self.schema(), &FEDERATION_EXTENDS_DIRECTIVE_NAME_IN_SPEC) + } + + #[allow(clippy::wrong_self_convention)] + pub(crate) fn from_context_directive_name(&self) -> Result, FederationError> { + self.metadata() + .federation_spec_definition() + .directive_name_in_schema( + self.schema(), + &FEDERATION_FROM_CONTEXT_DIRECTIVE_NAME_IN_SPEC, + ) + } + + pub(crate) fn key_directive_name(&self) -> Result, FederationError> { + self.metadata() + .federation_spec_definition() + .directive_name_in_schema(self.schema(), &FEDERATION_KEY_DIRECTIVE_NAME_IN_SPEC) + } + + pub(crate) fn override_directive_name(&self) -> Result, FederationError> { + self.metadata() + .federation_spec_definition() + .directive_name_in_schema(self.schema(), &FEDERATION_OVERRIDE_DIRECTIVE_NAME_IN_SPEC) + } + + pub(crate) fn provides_directive_name(&self) -> Result, FederationError> { + self.metadata() + .federation_spec_definition() + .directive_name_in_schema(self.schema(), &FEDERATION_PROVIDES_DIRECTIVE_NAME_IN_SPEC) + } + + pub(crate) fn requires_directive_name(&self) -> Result, FederationError> { + self.metadata() + .federation_spec_definition() + .directive_name_in_schema(self.schema(), &FEDERATION_REQUIRES_DIRECTIVE_NAME_IN_SPEC) + } + + pub(crate) fn is_interface_object_type(&self, type_: &TypeDefinitionPosition) -> bool { + let Ok(Some(interface_object)) = self + .metadata() + .federation_spec_definition() + .interface_object_directive_definition(self.schema()) + else { + return false; + }; + if let TypeDefinitionPosition::Object(obj) = type_ { + let interface_object_referencers = self + .schema() + .referencers() + .get_directive(&interface_object.name); + return interface_object_referencers.is_ok_and(|refs| refs.object_types.contains(obj)); + } + false + } +} + +/// Adds a federation (v2 or above) link directive to the schema. +/// - Similar to `add_fed1_link_to_schema` & `schema_as_fed2_subgraph`, but the link can be added +/// before collecting metadata. +/// - This is mainly for testing. +pub(crate) fn add_federation_link_to_schema( + schema: &mut Schema, + federation_version: &Version, +) -> Result<(), FederationError> { + let federation_spec = FEDERATION_VERSIONS + .find(federation_version) + .ok_or_else(|| internal_error!( + "Subgraph unexpectedly does not use a supported federation spec version. Requested version: {}", + federation_version, + ))?; + + // Insert `@link(url: "http://specs.apollo.dev/federation/vX.Y", import: ...)`. + // - auto import all directives. + let imports: Vec<_> = federation_spec + .directive_specs() + .iter() + .map(|d| format!("@{}", d.name()).into()) + .collect(); + + schema + .schema_definition + .make_mut() + .directives + .push(Component::new(Directive { + name: Identity::link_identity().name, + arguments: vec![ + Node::new(ast::Argument { + name: LINK_DIRECTIVE_URL_ARGUMENT_NAME, + value: federation_spec.url().to_string().into(), + }), + Node::new(ast::Argument { + name: LINK_DIRECTIVE_IMPORT_ARGUMENT_NAME, + value: Node::new(ast::Value::List(imports)), + }), + ], + })); + Ok(()) +} + +/// Turns a schema without a federation spec link into a federation 1 subgraph schema. +/// - Adds a fed 1 spec link directive to the schema. +pub(crate) fn add_fed1_link_to_schema( + schema: &mut FederationSchema, + link_spec: &LinkSpecDefinition, + link_name_in_schema: Name, +) -> Result<(), FederationError> { + // Insert `@core(feature: "http://specs.apollo.dev/federation/v1.0")` directive (or a `@link` + // directive, if applicable) to the schema definition. + // We can't use `import` argument here since fed1 @core does not support `import`. + // We will add imports later (see `fed1_link_imports`). + let directive = Directive { + name: link_name_in_schema, + arguments: vec![Node::new(ast::Argument { + name: link_spec.url_arg_name(), + value: FED_1.url().to_string().into(), + })], + }; + let origin = schema.schema().schema_definition.origin_to_use(); + crate::schema::position::SchemaDefinitionPosition.insert_directive_at( + schema, + Component { + origin, + node: directive.into(), + }, + 0, // @link to link spec should be first + ) +} + +/// Turns a schema without a federation spec link into a federation 2 subgraph schema. +/// - It may have a link spec, but it must not have a federation spec. +/// - This is related to `add_federation_link_to_schema` but for real subgraphs. +// PORT_NOTE: This corresponds to the `setSchemaAsFed2Subgraph` function in JS. +// The inner Schema is not exposed as mutable at the moment. So, this function consumes +// the input and returns the updated inner Schema. +fn schema_as_fed2_subgraph( + mut schema: FederationSchema, + use_latest: bool, +) -> Result { + let (link_spec, metadata) = if let Some(metadata) = schema.metadata() { + let spec = metadata.link_spec_definition()?; + // We don't accept pre-1.0 @core: this avoid having to care about what the name + // of the argument below is, and why would be bother? + ensure!( + spec.url() + .version + .satisfies(LinkSpecDefinition::latest().version()), + "Fed2 schema must use @link with version >= 1.0, but schema uses {spec_url}", + spec_url = spec.url() + ); + (spec, metadata) + } else { + let default_link_name = &LinkSpecDefinition::latest().identity().name; + let alias = find_unused_name_for_directive(&schema, default_link_name)?; + LinkSpecDefinition::latest().add_to_schema(&mut schema, alias)?; + schema.collect_links_metadata()?; + let Some(metadata) = schema.metadata() else { + bail!("Schema should now be a core schema") + }; + (LinkSpecDefinition::latest(), metadata) + }; + + let Some(link) = link_spec.link_in_schema(&schema)? else { + bail!("Core schema is missing @link directive"); + }; + + let fed_spec = if use_latest { + FederationSpecDefinition::latest() + } else { + FederationSpecDefinition::auto_expanded_federation_spec() + }; + ensure!( + metadata.for_identity(fed_spec.identity()).is_none(), + "Schema already set as a federation subgraph" + ); + + // Insert `@link(url: "http://specs.apollo.dev/federation/vX.Y", import: ...)`. + // - auto import certain directives. + // Note that there is a mismatch between url and directives that are imported. This is because + // we want to maintain backward compatibility for those who have already upgraded and we had + // been upgrading the url to latest, but we never automatically import directives that exist + // past 2.4 + let imports: Vec<_> = FederationSpecDefinition::auto_expanded_federation_spec() + .directive_specs() + .iter() + .map(|d| format!("@{}", d.name()).into()) + .collect(); + + // PORT_NOTE: We are adding the fed spec link to the schema definition unconditionally, not + // considering extensions. This seems consistent with the JS version. But, it's + // not consistent with the `add_to_schema`'s behavior. We may change to use the + // `schema_definition.origin_to_use()` method in the future. + let mut inner_schema = schema.into_inner(); + inner_schema + .schema_definition + .make_mut() + .directives + .push(Component::new(Directive { + name: link.spec_name_in_schema().clone(), + arguments: vec![ + Node::new(ast::Argument { + name: LINK_DIRECTIVE_URL_ARGUMENT_NAME, + value: fed_spec.url().to_string().into(), + }), + Node::new(ast::Argument { + name: LINK_DIRECTIVE_IMPORT_ARGUMENT_NAME, + value: Node::new(ast::Value::List(imports)), + }), + ], + })); + Ok(inner_schema) +} + +/// Returns a suitable alias for a named directive. +/// - Returns Some with an unused directive name. +/// - Returns None, if aliasing is unnecessary. +// PORT_NOTE: This corresponds to the `findUnusedNamedForLinkDirective` function in JS. +fn find_unused_name_for_directive( + schema: &FederationSchema, + directive_name: &Name, +) -> Result, FederationError> { + if schema.get_directive_definition(directive_name).is_none() { + return Ok(None); + } + + // The schema already defines a directive named `@link` so we need to use an alias. To keep it + // simple, we add a number in the end (so we try `@link1`, and if that's taken `@link2`, ...) + for i in 1..=1000 { + let candidate = Name::try_from(format!("{directive_name}{i}"))?; + if schema.get_directive_definition(&candidate).is_none() { + return Ok(Some(candidate)); + } + } + // We couldn't find one that is not used. + Err(internal_error!( + "Unable to find a name for the link directive", + )) +} + +// PORT_NOTE: This corresponds to the Schema's constructor in JS. +fn new_federation_subgraph_schema( + inner_schema: Schema, +) -> Result { + let mut schema = FederationSchema::new_uninitialized(inner_schema)?; + + // First, copy types over from the underlying schema AST to make sure we have built-ins that directives may reference + tracing::debug!("new_federation_subgraph_schema: collect_shallow_references"); + schema.collect_shallow_references(); + + // Backfill missing directive definitions. This is primarily making sure we have a definition for `@link`. + // Note: Unlike `@core`, `@link` doesn't have to be defined in the schema. + tracing::debug!("new_federation_subgraph_schema: missing directive definitions"); + for directive in &schema.schema().schema_definition.directives.clone() { + if schema.get_directive_definition(&directive.name).is_none() { + FederationBlueprint::on_missing_directive_definition(&mut schema, directive)?; + } + } + + // Now that we have the definition for `@link`, the bootstrap directive detection should work. + tracing::debug!("new_federation_subgraph_schema: collect_links_metadata"); + schema.collect_links_metadata()?; + + Ok(schema) +} + +// PORT_NOTE: This corresponds to the `newEmptyFederation2Schema` function in JS. +#[allow(unused)] +pub(crate) fn new_empty_federation_2_subgraph_schema() -> Result { + let mut schema = new_federation_subgraph_schema(Schema::new())?; + schema_as_fed2_subgraph(schema, true) +} + +/// Expands schema with all imported federation definitions. +pub(crate) fn expand_schema(schema: Schema) -> Result { + let mut schema = new_federation_subgraph_schema(schema)?; + + // If there's a use of `@link` and we successfully added its definition, add the bootstrap directive + tracing::debug!("expand_links: bootstrap_spec_links"); + bootstrap_spec_links(&mut schema)?; + + tracing::debug!("expand_links: on_directive_definition_and_schema_parsed"); + FederationBlueprint::on_directive_definition_and_schema_parsed(&mut schema)?; + + // Also, the backfilled definitions mean we can collect deep references. + // Ignore the error case, which means the schema has invalid references. It will be + // reported later in the validation phase. + tracing::debug!("expand_links: collect_deep_references"); + _ = schema.collect_deep_references(); + + // TODO: Remove this and use metadata from this Subgraph instead of FederationSchema + tracing::debug!("expand_links: on_constructed"); + FederationBlueprint::on_constructed(&mut schema)?; + + // PORT_NOTE: JS version calls `addFederationOperations` in the `validate` method. + // It seems to make sense for it to be a part of expansion stage. We can create + // a separate stage for it between `Expanded` and `Validated` if we need a stage + // that is expanded, but federation operations are not added. + tracing::debug!("expand_links: add_federation_operations"); + schema.add_federation_operations()?; + Ok(schema) +} + +/// Bootstrap link spec and federation spec links. +/// - Make sure the schema has a link spec definition & link. +/// - Make sure the schema has a federation spec link. +// PORT_NOTE: This partially corresponds to the `completeSubgraphSchema` function in JS. +fn bootstrap_spec_links(schema: &mut FederationSchema) -> Result<(), FederationError> { + // PORT_NOTE: JS version calls `completeFed1SubgraphSchema` and `completeFed2SubgraphSchema` + // here. In Rust, we don't have them, since + // `on_directive_definition_and_schema_parsed` method handles it. Also, while JS + // version doesn't actually add implicit fed1 spec links to the schema, Rust version + // add it, so that fed 1 and fed 2 can be processed the same way in the method. + + #[allow(clippy::collapsible_else_if)] + if let Some(metadata) = schema.metadata() { + // The schema has a @core or @link spec directive. + if schema.is_fed_2() { + tracing::debug!("bootstrap_spec_links: metadata indicates fed2"); + } else { + // This must be a Fed 1 schema. + tracing::debug!("bootstrap_spec_links: metadata indicates fed1"); + if metadata + .for_identity(&Identity::federation_identity()) + .is_none() + { + // Federation spec is not present. Implicitly add the fed 1 spec. + let link_spec = metadata.link_spec_definition()?; + let link_name_in_schema = metadata + .for_identity(link_spec.identity()) + .map(|link| link.spec_name_in_schema().clone()) + .unwrap_or_else(|| link_spec.identity().name.clone()); + add_fed1_link_to_schema(schema, link_spec, link_name_in_schema)?; + } + } + } else { + // The schemas has no link metadata. + if has_federation_spec_link(schema.schema()) { + // Has a federation spec link, but no link spec itself. Add the latest link spec. + // Since `@link` directive is present, this must be a fed 2 schema. + tracing::debug!( + "bootstrap_spec_links: has a federation spec without a link spec itself" + ); + LinkSpecDefinition::latest().add_to_schema(schema, /*alias*/ None)?; + } else { + // This must be a Fed 1 schema with no link/federation spec. + // Implicitly add the link spec and federation spec to the schema. + tracing::debug!("bootstrap_spec_links: has no link/federation spec"); + let link_spec = LinkSpecDefinition::fed1_latest(); + // PORT_NOTE: JS version doesn't add link specs here, (maybe) due to a potential name + // conflict. We generate an alias to avoid conflicts, if necessary. + let link_spec_name = &link_spec.identity().name; + let alias = find_unused_name_for_directive(schema, link_spec_name)?; + let link_name_in_schema = alias.clone().unwrap_or_else(|| link_spec_name.clone()); + link_spec.add_to_schema(schema, alias)?; + add_fed1_link_to_schema(schema, link_spec, link_name_in_schema)?; + } + } + Ok(()) +} + +fn has_federation_spec_link(schema: &Schema) -> bool { + schema + .schema_definition + .directives + .iter() + .any(|d| is_fed_spec_link_directive(schema, d)) +} + +fn is_fed_spec_link_directive(schema: &Schema, directive: &Directive) -> bool { + if directive.name != DEFAULT_LINK_NAME { + return false; + } + let Ok(url_arg) = directive.argument_by_name(&LINK_DIRECTIVE_URL_ARGUMENT_NAME, schema) else { + return false; + }; + url_arg + .as_str() + .is_some_and(|url| url.starts_with(&Identity::federation_identity().domain)) +} + +impl FederationSchema { + fn add_federation_operations(&mut self) -> Result<(), FederationError> { + // Add federation operation types + ANY_TYPE_SPEC.check_or_add(self, None)?; + SERVICE_TYPE_SPEC.check_or_add(self, None)?; + self.entity_type_spec()?.check_or_add(self, None)?; + + // Add the root `Query` Type (if not already present) and get the actual name in the schema. + let query_root_pos = SchemaRootDefinitionPosition { + root_kind: SchemaRootDefinitionKind::Query, + }; + let query_root_type_name = if query_root_pos.try_get(self.schema()).is_none() { + // If not present, add the default Query type with empty fields. + EMPTY_QUERY_TYPE_SPEC.check_or_add(self, None)?; + query_root_pos.insert(self, ComponentName::from(EMPTY_QUERY_TYPE_SPEC.name))?; + EMPTY_QUERY_TYPE_SPEC.name + } else { + query_root_pos.get(self.schema())?.name.clone() + }; + + // Add or remove `Query._entities` (if applicable) + let entity_field_pos = ObjectFieldDefinitionPosition { + type_name: query_root_type_name.clone(), + field_name: FEDERATION_ENTITIES_FIELD_NAME, + }; + if let Some(_entity_type) = self.entity_type()? { + if entity_field_pos.try_get(self.schema()).is_none() { + entity_field_pos + .insert(self, Component::new(self.entities_field_spec()?.into()))?; + } + // PORT_NOTE: JS version checks if the entity field definition's type is null when the + // definition is found, but the `type` field is not nullable in Rust. + } else { + // Remove the `_entities` field if it is present + // PORT_NOTE: It's unclear why this is necessary. Maybe it's to avoid schema confusion? + entity_field_pos.remove(self)?; + } + + // Add `Query._service` (if not already present) + let service_field_pos = ObjectFieldDefinitionPosition { + type_name: query_root_type_name, + field_name: FEDERATION_SERVICE_FIELD_NAME, + }; + if service_field_pos.try_get(self.schema()).is_none() { + service_field_pos.insert(self, Component::new(self.service_field_spec()?.into()))?; + } + + Ok(()) + } + + // Constructs the `_Entity` type spec for the subgraph schema. + // PORT_NOTE: Corresponds to the `entityTypeSpec` constant definition. + fn entity_type_spec(&self) -> Result { + // Please note that `_Entity` cannot use "interface entities" since interface types cannot + // be in unions. It is ok in practice because _Entity is only use as return type for + // `_entities`, and even when interfaces are involve, the result of an `_entities` call + // will always be an object type anyway, and since we force all implementations of an + // interface entity to be entity themselves in a subgraph, we're fine. + let mut entity_members = IndexSet::default(); + for key_directive_app in self.key_directive_applications()?.into_iter() { + let key_directive_app = key_directive_app?; + let target = key_directive_app.target(); + if let ObjectOrInterfaceTypeDefinitionPosition::Object(obj_ty) = target { + entity_members.insert(ComponentName::from(&obj_ty.type_name)); + } + } + + Ok(UnionTypeSpecification { + name: FEDERATION_ENTITY_TYPE_NAME, + members: Box::new(move |_| entity_members.clone()), + }) + } + + fn representations_arguments_field_spec() -> ResolvedArgumentSpecification { + ResolvedArgumentSpecification { + name: FEDERATION_REPRESENTATIONS_ARGUMENTS_NAME, + ty: Type::NonNullList(Box::new(Type::NonNullNamed(FEDERATION_ANY_TYPE_NAME))), + default_value: None, + } + } + + fn entities_field_spec(&self) -> Result { + let Some(entity_type) = self.entity_type()? else { + bail!("The federation entity type is expected to be defined, but not found") + }; + Ok(FieldSpecification { + name: FEDERATION_ENTITIES_FIELD_NAME, + ty: Type::NonNullList(Box::new(Type::Named(entity_type.type_name))), + arguments: vec![Self::representations_arguments_field_spec()], + }) + } + + fn service_field_spec(&self) -> Result { + Ok(FieldSpecification { + name: FEDERATION_SERVICE_FIELD_NAME, + ty: Type::NonNullNamed(self.service_type()?.type_name), + arguments: vec![], + }) + } +} + +#[cfg(test)] +mod tests { + use apollo_compiler::ast::OperationType; + use apollo_compiler::name; + + use super::*; + use crate::subgraph::test_utils::build_and_validate; + use crate::subgraph::test_utils::build_for_errors; + + #[test] + fn detects_federation_1_subgraphs_correctly() { + let subgraph = Subgraph::parse( + "S", + "", + r#" + type Query { + s: String + }"#, + ) + .expect("valid schema") + .expand_links() + .expect("expands subgraph"); + + assert!(!subgraph.state.metadata.is_fed_2_schema()); + } + + #[test] + fn detects_federation_2_subgraphs_correctly() { + let schema = Subgraph::parse( + "S", + "", + r#" + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0") + + type Query { + s: String + }"#, + ) + .expect("valid schema") + .expand_links() + .expect("expands subgraph"); + + assert!(schema.state.metadata.is_fed_2_schema()); + } + + #[test] + fn injects_missing_directive_definitions_fed_1_0() { + let subgraph = Subgraph::parse( + "S", + "", + r#" + type Query { + s: String + }"#, + ) + .expect("valid schema") + .expand_links() + .expect("expands subgraph"); + + let mut defined_directive_names = subgraph + .state + .schema + .schema() + .directive_definitions + .keys() + .cloned() + .collect::>(); + defined_directive_names.sort(); + + assert_eq!( + defined_directive_names, + vec![ + name!("core"), + name!("deprecated"), + name!("extends"), + name!("external"), + name!("include"), + name!("key"), + name!("provides"), + name!("requires"), + name!("skip"), + name!("specifiedBy"), + name!("tag"), + ] + ); + } + + #[test] + fn injects_missing_directive_definitions_fed_2_0() { + let subgraph = Subgraph::parse( + "S", + "", + r#" + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0") + + type Query { + s: String + }"#, + ) + .expect("valid schema") + .expand_links() + .expect("expands subgraph"); + + let mut defined_directive_names = subgraph + .schema() + .schema() + .directive_definitions + .keys() + .cloned() + .collect::>(); + defined_directive_names.sort(); + + assert_eq!( + defined_directive_names, + vec![ + name!("deprecated"), + name!("federation__extends"), + name!("federation__external"), + name!("federation__inaccessible"), + name!("federation__key"), + name!("federation__override"), + name!("federation__provides"), + name!("federation__requires"), + name!("federation__shareable"), + name!("federation__tag"), + name!("include"), + name!("link"), + name!("skip"), + name!("specifiedBy"), + ] + ); + } + + #[test] + fn injects_missing_directive_definitions_fed_2_1() { + let subgraph = Subgraph::parse( + "S", + "", + r#" + extend schema @link(url: "https://specs.apollo.dev/federation/v2.1") + + type Query { + s: String + }"#, + ) + .expect("valid schema") + .expand_links() + .expect("expands subgraph"); + + let mut defined_directive_names = subgraph + .schema() + .schema() + .directive_definitions + .keys() + .cloned() + .collect::>(); + defined_directive_names.sort(); + + assert_eq!( + defined_directive_names, + vec![ + name!("deprecated"), + name!("federation__composeDirective"), + name!("federation__extends"), + name!("federation__external"), + name!("federation__inaccessible"), + name!("federation__key"), + name!("federation__override"), + name!("federation__provides"), + name!("federation__requires"), + name!("federation__shareable"), + name!("federation__tag"), + name!("include"), + name!("link"), + name!("skip"), + name!("specifiedBy"), + ] + ); + } + + #[test] + fn injects_missing_directive_definitions_fed_2_12() { + let subgraph = Subgraph::parse( + "S", + "", + r#" + extend schema @link(url: "https://specs.apollo.dev/federation/v2.12") + + type Query { + s: String + }"#, + ) + .expect("valid schema") + .expand_links() + .expect("expands subgraph"); + + let mut defined_directive_names = subgraph + .schema() + .schema() + .directive_definitions + .keys() + .cloned() + .collect::>(); + defined_directive_names.sort(); + + assert_eq!( + defined_directive_names, + vec![ + name!("deprecated"), + name!("federation__authenticated"), + name!("federation__cacheTag"), + name!("federation__composeDirective"), + name!("federation__context"), + name!("federation__cost"), + name!("federation__extends"), + name!("federation__external"), + name!("federation__fromContext"), + name!("federation__inaccessible"), + name!("federation__interfaceObject"), + name!("federation__key"), + name!("federation__listSize"), + name!("federation__override"), + name!("federation__policy"), + name!("federation__provides"), + name!("federation__requires"), + name!("federation__requiresScopes"), + name!("federation__shareable"), + name!("federation__tag"), + name!("include"), + name!("link"), + name!("skip"), + name!("specifiedBy") + ] + ); + } + + #[test] + fn injects_missing_directive_definitions_connect_v0_1() { + let subgraph = Subgraph::parse( + "S", + "", + r#" + extend schema @link(url: "https://specs.apollo.dev/federation/v2.10") @link(url: "https://specs.apollo.dev/connect/v0.1") + + type Query { + s: String + }"#, + ) + .expect("valid schema") + .expand_links() + .expect("expands subgraph"); + + let mut defined_directive_names = subgraph + .schema() + .schema() + .directive_definitions + .keys() + .cloned() + .collect::>(); + defined_directive_names.sort(); + + assert_eq!( + defined_directive_names, + vec![ + name!("connect"), + name!("connect__source"), + name!("deprecated"), + name!("federation__authenticated"), + name!("federation__composeDirective"), + name!("federation__context"), + name!("federation__cost"), + name!("federation__extends"), + name!("federation__external"), + name!("federation__fromContext"), + name!("federation__inaccessible"), + name!("federation__interfaceObject"), + name!("federation__key"), + name!("federation__listSize"), + name!("federation__override"), + name!("federation__policy"), + name!("federation__provides"), + name!("federation__requires"), + name!("federation__requiresScopes"), + name!("federation__shareable"), + name!("federation__tag"), + name!("include"), + name!("link"), + name!("skip"), + name!("specifiedBy"), + ] + ); + + let mut defined_type_names = subgraph + .schema() + .schema() + .types + .keys() + .cloned() + .collect::>(); + defined_type_names.sort(); + + assert_eq!( + defined_type_names, + vec![ + name!("Boolean"), + name!("Float"), + name!("ID"), + name!("Int"), + name!("Query"), + name!("String"), + name!("_Any"), + name!("_Service"), + name!("__Directive"), + name!("__DirectiveLocation"), + name!("__EnumValue"), + name!("__Field"), + name!("__InputValue"), + name!("__Schema"), + name!("__Type"), + name!("__TypeKind"), + name!("connect__ConnectBatch"), + name!("connect__ConnectHTTP"), + name!("connect__ConnectorErrors"), + name!("connect__HTTPHeaderMapping"), + name!("connect__JSONSelection"), + name!("connect__SourceHTTP"), + name!("connect__URLTemplate"), + name!("federation__ContextFieldValue"), + name!("federation__FieldSet"), + name!("federation__Policy"), + name!("federation__Scope"), + name!("link__Import"), + name!("link__Purpose"), + ] + ); + } + + #[test] + fn replaces_known_bad_definitions_from_fed1() { + let subgraph = Subgraph::parse( + "S", + "", + r#" + directive @key(fields: String) repeatable on OBJECT | INTERFACE + directive @provides(fields: _FieldSet) repeatable on FIELD_DEFINITION + directive @requires(fields: FieldSet) repeatable on FIELD_DEFINITION + + scalar _FieldSet + scalar FieldSet + + type Query { + s: String + }"#, + ) + .expect("valid schema") + .expand_links() + .expect("expands subgraph"); + + let key_definition = subgraph + .schema() + .schema() + .directive_definitions + .get(&name!("key")) + .unwrap(); + assert_eq!(key_definition.arguments.len(), 2); + assert_eq!( + key_definition + .argument_by_name(&name!("fields")) + .unwrap() + .ty + .inner_named_type(), + "_FieldSet" + ); + assert!( + key_definition + .argument_by_name(&name!("resolvable")) + .is_some() + ); + + let provides_definition = subgraph + .schema() + .schema() + .directive_definitions + .get(&name!("provides")) + .unwrap(); + assert_eq!(provides_definition.arguments.len(), 1); + assert_eq!( + provides_definition + .argument_by_name(&name!("fields")) + .unwrap() + .ty + .inner_named_type(), + "_FieldSet" + ); + + let requires_definition = subgraph + .schema() + .schema() + .directive_definitions + .get(&name!("requires")) + .unwrap(); + assert_eq!(requires_definition.arguments.len(), 1); + assert_eq!( + requires_definition + .argument_by_name(&name!("fields")) + .unwrap() + .ty + .inner_named_type(), + "_FieldSet" + ); + } + + #[test] + fn rejects_non_root_use_of_default_query_name() { + let errors = build_for_errors( + r#" + schema { + query: MyQuery + } + + type MyQuery { + f: Int + } + + type Query { + g: Int + } + "#, + ); + + assert_eq!(errors.len(), 1); + assert_eq!( + errors[0].1, + r#"[S] The schema has a type named "Query" but it is not set as the query root type ("MyQuery" is instead): this is not supported by federation. If a root type does not use its default name, there should be no other type with that default name."# + ); + } + + #[test] + fn rejects_non_root_use_of_default_mutation_name() { + let errors = build_for_errors( + r#" + schema { + mutation: MyMutation + } + + type MyMutation { + f: Int + } + + type Mutation { + g: Int + } + "#, + ); + + assert_eq!(errors.len(), 1); + assert_eq!( + errors[0].1, + r#"[S] The schema has a type named "Mutation" but it is not set as the mutation root type ("MyMutation" is instead): this is not supported by federation. If a root type does not use its default name, there should be no other type with that default name."#, + ); + } + + #[test] + fn rejects_non_root_use_of_default_subscription_name() { + let errors = build_for_errors( + r#" + schema { + subscription: MySubscription + } + + type MySubscription { + f: Int + } + + type Subscription { + g: Int + } + "#, + ); + + assert_eq!(errors.len(), 1); + assert_eq!( + errors[0].1, + r#"[S] The schema has a type named "Subscription" but it is not set as the subscription root type ("MySubscription" is instead): this is not supported by federation. If a root type does not use its default name, there should be no other type with that default name."#, + ); + } + + #[test] + fn renames_root_operations_to_default_names() { + let subgraph = build_and_validate( + r#" + schema { + query: MyQuery + mutation: MyMutation + subscription: MySubscription + } + + type MyQuery { + f: Int + } + + type MyMutation { + g: Int + } + + type MySubscription { + h: Int + } + "#, + ); + + assert_eq!( + subgraph + .state + .schema + .schema() + .root_operation(OperationType::Query), + Some(name!("Query")).as_ref() + ); + assert_eq!( + subgraph + .state + .schema + .schema() + .root_operation(OperationType::Mutation), + Some(name!("Mutation")).as_ref() + ); + assert_eq!( + subgraph + .state + .schema + .schema() + .root_operation(OperationType::Subscription), + Some(name!("Subscription")).as_ref() + ); + } + + #[test] + fn does_not_rename_root_operations_when_disabled() { + let subgraph = Subgraph::parse( + "S", + "", + r#" + schema { + query: MyQuery + mutation: MyMutation + subscription: MySubscription + } + + type MyQuery { + f: Int + } + + type MyMutation { + g: Int + } + + type MySubscription { + h: Int + } + "#, + ) + .expect("parses schema") + .expand_links() + .expect("expands links") + .assume_upgraded() + .validate() + .expect("is valid"); + + assert_eq!( + subgraph + .state + .schema + .schema() + .root_operation(OperationType::Query), + Some(name!("MyQuery")).as_ref() + ); + assert_eq!( + subgraph + .state + .schema + .schema() + .root_operation(OperationType::Mutation), + Some(name!("MyMutation")).as_ref() + ); + assert_eq!( + subgraph + .state + .schema + .schema() + .root_operation(OperationType::Subscription), + Some(name!("MySubscription")).as_ref() + ); + } +} diff --git a/apollo-federation/src/supergraph/join_directive.rs b/apollo-federation/src/supergraph/join_directive.rs new file mode 100644 index 0000000000..d949a711e6 --- /dev/null +++ b/apollo-federation/src/supergraph/join_directive.rs @@ -0,0 +1,313 @@ +//! @join__directive extraction +use std::sync::Arc; + +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast::Argument; +use apollo_compiler::ast::Directive; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::schema::Component; +use itertools::Itertools; + +use super::get_subgraph; +use super::subgraph::FederationSubgraphs; +use crate::connectors::spec::ConnectSpecDefinition; +use crate::error::FederationError; +use crate::link::DEFAULT_LINK_NAME; +use crate::link::spec_definition::SpecDefinition; +use crate::schema::FederationSchema; +use crate::schema::position::ObjectFieldDefinitionPosition; +use crate::schema::position::ObjectTypeDefinitionPosition; +use crate::schema::position::TypeDefinitionPosition; + +static JOIN_DIRECTIVE: &str = "join__directive"; + +/// Converts `@join__directive(graphs: [A], name: "foo")` to `@foo` in the A subgraph. +/// If the directive is a link directive on the schema definition, we also need +/// to update the metadata and add the imported definitions. +pub(super) fn extract( + supergraph_schema: &FederationSchema, + subgraphs: &mut FederationSubgraphs, + graph_enum_value_name_to_subgraph_name: &IndexMap>, +) -> Result<(), FederationError> { + let join_directives = match supergraph_schema + .referencers() + .get_directive(JOIN_DIRECTIVE) + { + Ok(directives) => directives, + Err(_) => { + // No join directives found, nothing to do. + return Ok(()); + } + }; + + if let Some(schema_def_pos) = &join_directives.schema { + let schema_def = schema_def_pos.get(supergraph_schema.schema()); + let directives = schema_def + .directives + .iter() + .filter_map(|d| { + if d.name == JOIN_DIRECTIVE { + Some(to_real_directive(d)) + } else { + None + } + }) + .collect_vec(); + + // TODO: Do we need to handle the link directive being renamed? + let (links, others) = directives + .into_iter() + .partition::, _>(|(d, _)| d.name == DEFAULT_LINK_NAME); + + // After adding links, we'll check the link against a safelist of + // specs and check_or_add the spec definitions if necessary. + for (link_directive, subgraph_enum_values) in links { + for subgraph_enum_value in subgraph_enum_values { + let subgraph = get_subgraph( + subgraphs, + graph_enum_value_name_to_subgraph_name, + &subgraph_enum_value, + )?; + + schema_def_pos.insert_directive( + &mut subgraph.schema, + Component::new(link_directive.clone()), + )?; + + if let Some(spec) = ConnectSpecDefinition::from_directive(&link_directive)? { + spec.add_elements_to_schema(&mut subgraph.schema)?; + } + } + } + + // Other directives are added normally. + for (directive, subgraph_enum_values) in others { + for subgraph_enum_value in subgraph_enum_values { + let subgraph = get_subgraph( + subgraphs, + graph_enum_value_name_to_subgraph_name, + &subgraph_enum_value, + )?; + + schema_def_pos + .insert_directive(&mut subgraph.schema, Component::new(directive.clone()))?; + } + } + } + + for object_field_pos in &join_directives.object_fields { + let object_field = object_field_pos.get(supergraph_schema.schema())?; + let directives = object_field + .directives + .iter() + .filter_map(|d| { + if d.name == JOIN_DIRECTIVE { + Some(to_real_directive(d)) + } else { + None + } + }) + .collect_vec(); + + for (directive, subgraph_enum_values) in directives { + for subgraph_enum_value in subgraph_enum_values { + let subgraph = get_subgraph( + subgraphs, + graph_enum_value_name_to_subgraph_name, + &subgraph_enum_value, + )?; + + object_field_pos + .insert_directive(&mut subgraph.schema, Node::new(directive.clone()))?; + } + } + } + + for intf_pos in &join_directives.interface_types { + let intf = intf_pos.get(supergraph_schema.schema())?; + let directives = intf + .directives + .iter() + .filter_map(|d| { + if d.name == JOIN_DIRECTIVE { + Some(to_real_directive(d)) + } else { + None + } + }) + .collect_vec(); + + for (directive, subgraph_enum_values) in directives { + for subgraph_enum_value in subgraph_enum_values { + let subgraph = get_subgraph( + subgraphs, + graph_enum_value_name_to_subgraph_name, + &subgraph_enum_value, + )?; + + if subgraph + .schema + .try_get_type(intf_pos.type_name.clone()) + .map(|t| matches!(t, TypeDefinitionPosition::Interface(_))) + .unwrap_or_default() + { + intf_pos.insert_directive( + &mut subgraph.schema, + Component::new(directive.clone()), + )?; + } else { + // In the subgraph it's defined as an object with @interfaceObject + let object_field_pos = ObjectTypeDefinitionPosition { + type_name: intf_pos.type_name.clone(), + }; + object_field_pos.insert_directive( + &mut subgraph.schema, + Component::new(directive.clone()), + )?; + } + } + } + } + + for intf_field_pos in &join_directives.interface_fields { + let intf_field = intf_field_pos.get(supergraph_schema.schema())?; + let directives = intf_field + .directives + .iter() + .filter_map(|d| { + if d.name == JOIN_DIRECTIVE { + Some(to_real_directive(d)) + } else { + None + } + }) + .collect_vec(); + + for (directive, subgraph_enum_values) in directives { + for subgraph_enum_value in subgraph_enum_values { + let subgraph = get_subgraph( + subgraphs, + graph_enum_value_name_to_subgraph_name, + &subgraph_enum_value, + )?; + + if subgraph + .schema + .try_get_type(intf_field_pos.type_name.clone()) + .map(|t| matches!(t, TypeDefinitionPosition::Interface(_))) + .unwrap_or_default() + { + intf_field_pos + .insert_directive(&mut subgraph.schema, Node::new(directive.clone()))?; + } else { + // In the subgraph it's defined as an object with @interfaceObject + let object_field_pos = ObjectFieldDefinitionPosition { + type_name: intf_field_pos.type_name.clone(), + field_name: intf_field_pos.field_name.clone(), + }; + object_field_pos + .insert_directive(&mut subgraph.schema, Node::new(directive.clone()))?; + } + } + } + } + + for obj_pos in &join_directives.object_types { + let ty = obj_pos.get(supergraph_schema.schema())?; + let directives = ty + .directives + .iter() + .filter_map(|d| { + if d.name == JOIN_DIRECTIVE { + Some(to_real_directive(d)) + } else { + None + } + }) + .collect_vec(); + + for (directive, subgraph_enum_values) in directives { + for subgraph_enum_value in subgraph_enum_values { + let subgraph = get_subgraph( + subgraphs, + graph_enum_value_name_to_subgraph_name, + &subgraph_enum_value, + )?; + + if subgraph + .schema + .try_get_type(obj_pos.type_name.clone()) + .map(|t| matches!(t, TypeDefinitionPosition::Object(_))) + .unwrap_or_default() + { + obj_pos.insert_directive( + &mut subgraph.schema, + Node::new(directive.clone()).into(), + )?; + } + } + } + } + + // TODO + // - join_directives.directive_arguments + // - join_directives.enum_types + // - join_directives.enum_values + // - join_directives.input_object_fields + // - join_directives.input_object_types + // - join_directives.interface_field_arguments + // - join_directives.object_field_arguments + // - join_directives.scalar_types + // - join_directives.union_types + + Ok(()) +} + +fn to_real_directive(directive: &Node) -> (Directive, Vec) { + let subgraph_enum_values = directive + .specified_argument_by_name("graphs") + .and_then(|arg| arg.as_list()) + .map(|list| { + list.iter() + .map(|node| { + Name::new( + node.as_enum() + .expect("join__directive(graphs:) value is an enum") + .as_str(), + ) + .expect("join__directive(graphs:) value is a valid name") + }) + .collect() + }) + .expect("join__directive(graphs:) missing"); + + let name = directive + .specified_argument_by_name("name") + .expect("join__directive(name:) is present") + .as_str() + .expect("join__directive(name:) is a string"); + + let arguments = directive + .specified_argument_by_name("args") + .and_then(|a| a.as_object()) + .map(|args| { + args.iter() + .map(|(k, v)| { + Argument { + name: k.clone(), + value: v.clone(), + } + .into() + }) + .collect() + }) + .unwrap_or_default(); + + let directive = Directive { + name: Name::new(name).expect("join__directive(name:) is a valid name"), + arguments, + }; + + (directive, subgraph_enum_values) +} diff --git a/apollo-federation/src/supergraph/mod.rs b/apollo-federation/src/supergraph/mod.rs index bc8893f3d5..670b9d8df2 100644 --- a/apollo-federation/src/supergraph/mod.rs +++ b/apollo-federation/src/supergraph/mod.rs @@ -1,3 +1,4 @@ +mod join_directive; mod schema; mod subgraph; @@ -5,13 +6,16 @@ use std::fmt::Write; use std::ops::Deref; use std::ops::Not; use std::sync::Arc; +use std::sync::LazyLock; -use apollo_compiler::ast::Argument; -use apollo_compiler::ast::Directive; +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::Schema; use apollo_compiler::ast::FieldDefinition; use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use apollo_compiler::executable; +use apollo_compiler::executable::FieldSet; use apollo_compiler::name; use apollo_compiler::schema::Component; use apollo_compiler::schema::ComponentName; @@ -32,34 +36,35 @@ use apollo_compiler::schema::ScalarType; use apollo_compiler::schema::Type; use apollo_compiler::schema::UnionType; use apollo_compiler::validation::Valid; -use apollo_compiler::Name; -use apollo_compiler::Node; use itertools::Itertools; -use lazy_static::lazy_static; use time::OffsetDateTime; -use self::schema::get_apollo_directive_names; pub(crate) use self::schema::new_empty_fed_2_subgraph_schema; use self::subgraph::FederationSubgraph; use self::subgraph::FederationSubgraphs; pub use self::subgraph::ValidFederationSubgraph; pub use self::subgraph::ValidFederationSubgraphs; +use crate::ApiSchemaOptions; +use crate::api_schema; use crate::error::FederationError; +use crate::error::Locations; use crate::error::MultipleFederationErrors; use crate::error::SingleFederationError; +use crate::link::context_spec_definition::ContextSpecDefinition; use crate::link::cost_spec_definition::CostSpecDefinition; -use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; -use crate::link::federation_spec_definition::FederationSpecDefinition; use crate::link::federation_spec_definition::FEDERATION_VERSIONS; +use crate::link::federation_spec_definition::FederationSpecDefinition; +use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; +use crate::link::join_spec_definition::ContextArgument; use crate::link::join_spec_definition::FieldDirectiveArguments; use crate::link::join_spec_definition::JoinSpecDefinition; use crate::link::join_spec_definition::TypeDirectiveArguments; use crate::link::spec::Identity; use crate::link::spec::Version; use crate::link::spec_definition::SpecDefinition; -use crate::link::DEFAULT_LINK_NAME; +use crate::schema::FederationSchema; +use crate::schema::ValidFederationSchema; use crate::schema::field_set::parse_field_set_without_normalization; -use crate::schema::position::is_graphql_reserved_name; use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::DirectiveDefinitionPosition; use crate::schema::position::EnumTypeDefinitionPosition; @@ -75,14 +80,171 @@ use crate::schema::position::SchemaRootDefinitionKind; use crate::schema::position::SchemaRootDefinitionPosition; use crate::schema::position::TypeDefinitionPosition; use crate::schema::position::UnionTypeDefinitionPosition; +use crate::schema::position::is_graphql_reserved_name; use crate::schema::type_and_directive_specification::FieldSpecification; use crate::schema::type_and_directive_specification::ObjectTypeSpecification; use crate::schema::type_and_directive_specification::ScalarTypeSpecification; use crate::schema::type_and_directive_specification::TypeAndDirectiveSpecification; use crate::schema::type_and_directive_specification::UnionTypeSpecification; -use crate::schema::FederationSchema; use crate::utils::FallibleIterator; +#[derive(Debug)] +#[allow(unused)] +pub struct Supergraph { + pub state: S, +} + +impl Supergraph { + pub fn new(schema: Valid) -> Self { + Self { + state: Merged { + schema, + hints: vec![], + }, + } + } + + pub fn with_hints(schema: Valid, hints: Vec) -> Self { + Self { + state: Merged { schema, hints }, + } + } + + pub fn parse(schema_str: &str) -> Result { + let schema = Schema::parse_and_validate(schema_str, "schema.graphql")?; + Ok(Self::new(schema)) + } + + pub fn assume_satisfiable(self) -> Supergraph { + todo!("unimplemented") + } + + pub fn schema(&self) -> &Valid { + &self.state.schema + } + + pub fn hints(&self) -> &Vec { + &self.state.hints + } + + #[allow(unused)] + pub(crate) fn subgraph_name_to_graph_enum_value( + &self, + ) -> Result, FederationError> { + // TODO: We can avoid this clone if the `Merged` struct contains a `FederationSchema`. + let supergraph_schema = FederationSchema::new(self.schema().clone().into_inner())?; + // PORT_NOTE: The JS version calls the `extractSubgraphsFromSupergraph` function, which + // returns the subgraph name to graph enum value mapping, but the corresponding + // `extract_subgraphs_from_supergraph` function in Rust does not need it and + // does not return it. Therefore, a small part of + // `extract_subgraphs_from_supergraph` function is reused here to compute the + // mapping, instead of modifying the function itself. + let (_link_spec_definition, join_spec_definition, _context_spec_definition) = + crate::validate_supergraph_for_query_planning(&supergraph_schema)?; + let (_subgraphs, _federation_spec_definitions, graph_enum_value_name_to_subgraph_name) = + collect_empty_subgraphs(&supergraph_schema, join_spec_definition)?; + Ok(graph_enum_value_name_to_subgraph_name + .into_iter() + .map(|(enum_value_name, subgraph_name)| { + (subgraph_name.to_string(), enum_value_name.clone()) + }) + .collect()) + } +} + +impl Supergraph { + pub fn new(schema: ValidFederationSchema, hints: Vec) -> Self { + Supergraph { + state: Satisfiable { + schema, + // TODO: These fields aren't computed by satisfiability (and instead by query + // planning). Not sure why they're here, but we should check if we need them, and + // if we do, compute them. + metadata: SupergraphMetadata { + interface_types_with_interface_objects: Default::default(), + abstract_types_with_inconsistent_runtime_types: Default::default(), + }, + hints, + }, + } + } + + /// Generates an API Schema from this supergraph schema. The API Schema represents the combined + /// API of the supergraph that's visible to end users. + pub fn to_api_schema( + &self, + options: ApiSchemaOptions, + ) -> Result { + api_schema::to_api_schema(self.state.schema.clone(), options) + } + + pub fn schema(&self) -> &ValidFederationSchema { + &self.state.schema + } + + pub fn metadata(&self) -> &SupergraphMetadata { + &self.state.metadata + } + + pub fn hints(&self) -> &Vec { + &self.state.hints + } +} + +#[derive(Clone, Debug)] +#[allow(unused)] +pub struct Merged { + schema: Valid, + hints: Vec, +} + +impl Merged { + pub fn schema(&self) -> &Valid { + &self.schema + } +} + +#[derive(Clone, Debug)] +#[allow(unused)] +pub struct Satisfiable { + schema: ValidFederationSchema, + metadata: SupergraphMetadata, + hints: Vec, +} + +#[derive(Clone, Debug)] +#[allow(unused)] +#[allow(unreachable_pub)] +pub struct SupergraphMetadata { + /// A set of the names of interface types for which at least one subgraph use an + /// @interfaceObject to abstract that interface. + interface_types_with_interface_objects: IndexSet, + /// A set of the names of interface or union types that have inconsistent "runtime types" across + /// subgraphs. + abstract_types_with_inconsistent_runtime_types: IndexSet, +} + +// TODO this should be expanded as needed +// @see apollo-federation-types BuildMessage for what is currently used by rover +#[derive(Clone, Debug)] +#[allow(unused)] +#[allow(unreachable_pub)] +pub struct CompositionHint { + pub message: String, + pub code: String, + pub locations: Locations, +} + +impl CompositionHint { + pub fn code(&self) -> &str { + &self.code + } + + pub fn message(&self) -> &str { + &self.message + } +} + /// Assumes the given schema has been validated. /// /// TODO: A lot of common data gets passed around in the functions called by this one, considering @@ -92,7 +254,7 @@ pub(crate) fn extract_subgraphs_from_supergraph( validate_extracted_subgraphs: Option, ) -> Result { let validate_extracted_subgraphs = validate_extracted_subgraphs.unwrap_or(true); - let (link_spec_definition, join_spec_definition) = + let (link_spec_definition, join_spec_definition, context_spec_definition) = crate::validate_supergraph_for_query_planning(supergraph_schema)?; let is_fed_1 = *join_spec_definition.version() == Version { major: 0, minor: 1 }; let (mut subgraphs, federation_spec_definitions, graph_enum_value_name_to_subgraph_name) = @@ -112,10 +274,11 @@ pub(crate) fn extract_subgraphs_from_supergraph( }) .try_collect()?; if is_fed_1 { - let unsupported = - SingleFederationError::UnsupportedFederationVersion { - message: String::from("Supergraphs composed with federation version 1 are not supported. Please recompose your supergraph with federation version 2 or greater") - }; + let unsupported = SingleFederationError::UnsupportedFederationVersion { + message: String::from( + "Supergraphs composed with federation version 1 are not supported. Please recompose your supergraph with federation version 2 or greater", + ), + }; return Err(unsupported.into()); } else { extract_subgraphs_from_fed_2_supergraph( @@ -124,6 +287,7 @@ pub(crate) fn extract_subgraphs_from_supergraph( &graph_enum_value_name_to_subgraph_name, &federation_spec_definitions, join_spec_definition, + context_spec_definition, &filtered_types, )?; } @@ -150,17 +314,18 @@ pub(crate) fn extract_subgraphs_from_supergraph( Err((schema, error)) => { subgraph.schema = schema; if is_fed_1 { - let message = - String::from("Supergraphs composed with federation version 1 are not supported. Please recompose your supergraph with federation version 2 or greater"); + let message = String::from( + "Supergraphs composed with federation version 1 are not supported. Please recompose your supergraph with federation version 2 or greater", + ); return Err(SingleFederationError::UnsupportedFederationVersion { message, } .into()); } else { let mut message = format!( - "Unexpected error extracting {} from the supergraph: this is either a bug, or the supergraph has been corrupted.\n\nDetails:\n{error}", - subgraph.name, - ); + "Unexpected error extracting {} from the supergraph: this is either a bug, or the supergraph has been corrupted.\n\nDetails:\n{error}", + subgraph.name, + ); maybe_dump_subgraph_schema(subgraph, &mut message); return Err( SingleFederationError::InvalidFederationSupergraph { message }.into(), @@ -202,8 +367,7 @@ fn collect_empty_subgraphs( .get(&graph_directive_definition.name) .ok_or_else(|| SingleFederationError::InvalidFederationSupergraph { message: format!( - "Value \"{}\" of join__Graph enum has no @join__graph directive", - enum_value_name + "Value \"{enum_value_name}\" of join__Graph enum has no @join__graph directive" ), })?; let graph_arguments = join_spec_definition.graph_directive_arguments(graph_application)?; @@ -211,6 +375,7 @@ fn collect_empty_subgraphs( name: graph_arguments.name.to_owned(), url: graph_arguments.url.to_owned(), schema: new_empty_fed_2_subgraph_schema()?, + graph_enum_value: enum_value_name.clone(), }; let federation_link = &subgraph .schema @@ -258,10 +423,9 @@ fn extract_subgraphs_from_fed_2_supergraph( graph_enum_value_name_to_subgraph_name: &IndexMap>, federation_spec_definitions: &IndexMap, join_spec_definition: &'static JoinSpecDefinition, + context_spec_definition: Option<&'static ContextSpecDefinition>, filtered_types: &Vec, ) -> Result<(), FederationError> { - let original_directive_names = get_apollo_directive_names(supergraph_schema)?; - let TypeInfos { object_types, interface_types, @@ -274,8 +438,8 @@ fn extract_subgraphs_from_fed_2_supergraph( graph_enum_value_name_to_subgraph_name, federation_spec_definitions, join_spec_definition, + context_spec_definition, filtered_types, - &original_directive_names, )?; extract_object_type_content( @@ -285,7 +449,6 @@ fn extract_subgraphs_from_fed_2_supergraph( federation_spec_definitions, join_spec_definition, &object_types, - &original_directive_names, )?; extract_interface_type_content( supergraph_schema, @@ -294,7 +457,6 @@ fn extract_subgraphs_from_fed_2_supergraph( federation_spec_definitions, join_spec_definition, &interface_types, - &original_directive_names, )?; extract_union_type_content( supergraph_schema, @@ -307,22 +469,18 @@ fn extract_subgraphs_from_fed_2_supergraph( supergraph_schema, subgraphs, graph_enum_value_name_to_subgraph_name, - federation_spec_definitions, join_spec_definition, &enum_types, - &original_directive_names, )?; extract_input_object_type_content( supergraph_schema, subgraphs, graph_enum_value_name_to_subgraph_name, - federation_spec_definitions, join_spec_definition, &input_object_types, - &original_directive_names, )?; - extract_join_directives( + join_directive::extract( supergraph_schema, subgraphs, graph_enum_value_name_to_subgraph_name, @@ -339,6 +497,7 @@ fn extract_subgraphs_from_fed_2_supergraph( .schema() .directive_definitions .values() + .filter(|directive| !directive.is_built_in()) .filter_map(|directive_definition| { let executable_locations = directive_definition .locations @@ -388,14 +547,15 @@ fn extract_subgraphs_from_fed_2_supergraph( Ok(()) } +#[allow(clippy::too_many_arguments)] fn add_all_empty_subgraph_types( supergraph_schema: &FederationSchema, subgraphs: &mut FederationSubgraphs, graph_enum_value_name_to_subgraph_name: &IndexMap>, federation_spec_definitions: &IndexMap, join_spec_definition: &'static JoinSpecDefinition, + context_spec_definition: Option<&'static ContextSpecDefinition>, filtered_types: &Vec, - original_directive_names: &IndexMap, ) -> Result { let type_directive_definition = join_spec_definition.type_directive_definition(supergraph_schema)?; @@ -425,12 +585,6 @@ fn add_all_empty_subgraph_types( graph_enum_value_name_to_subgraph_name, &type_directive_application.graph, )?; - let federation_spec_definition = federation_spec_definitions - .get(&type_directive_application.graph) - .ok_or_else(|| SingleFederationError::InvalidFederationSupergraph { - message: "Subgraph unexpectedly does not use federation spec" - .to_owned(), - })?; pos.pre_insert(&mut subgraph.schema)?; pos.insert( @@ -442,16 +596,11 @@ fn add_all_empty_subgraph_types( }), )?; - if let Some(cost_spec_definition) = - federation_spec_definition.get_cost_spec_definition(&subgraph.schema) - { - cost_spec_definition.propagate_demand_control_directives_for_scalar( - &mut subgraph.schema, - pos.get(supergraph_schema.schema())?, - pos, - original_directive_names, - )?; - } + CostSpecDefinition::propagate_demand_control_directives_for_scalar( + supergraph_schema, + &mut subgraph.schema, + pos, + )?; } None } @@ -465,9 +614,12 @@ fn add_all_empty_subgraph_types( types_mut.push(add_empty_type( type_definition_position.clone(), &type_directive_applications, + type_.directives(), + supergraph_schema, subgraphs, graph_enum_value_name_to_subgraph_name, federation_spec_definitions, + context_spec_definition, )?); } } @@ -481,17 +633,21 @@ fn add_all_empty_subgraph_types( }) } +#[allow(clippy::too_many_arguments)] fn add_empty_type( type_definition_position: TypeDefinitionPosition, type_directive_applications: &Vec, + directives: &DirectiveList, + supergraph_schema: &FederationSchema, subgraphs: &mut FederationSubgraphs, graph_enum_value_name_to_subgraph_name: &IndexMap>, federation_spec_definitions: &IndexMap, + context_spec_definition: Option<&'static ContextSpecDefinition>, ) -> Result { // In fed2, we always mark all types with `@join__type` but making sure. if type_directive_applications.is_empty() { return Err(SingleFederationError::InvalidFederationSupergraph { - message: format!("Missing @join__type on \"{}\"", type_definition_position), + message: format!("Missing @join__type on \"{type_definition_position}\""), } .into()); } @@ -668,25 +824,52 @@ fn add_empty_type( } .into()); } - TypeDefinitionPosition::Object(pos) => { - pos.insert_directive(&mut subgraph.schema, key_directive)?; - } - TypeDefinitionPosition::Interface(pos) => { - pos.insert_directive(&mut subgraph.schema, key_directive)?; - } - TypeDefinitionPosition::Union(pos) => { - pos.insert_directive(&mut subgraph.schema, key_directive)?; - } - TypeDefinitionPosition::Enum(pos) => { - pos.insert_directive(&mut subgraph.schema, key_directive)?; - } - TypeDefinitionPosition::InputObject(pos) => { - pos.insert_directive(&mut subgraph.schema, key_directive)?; + _ => { + subgraph_type_definition_position + .insert_directive(&mut subgraph.schema, key_directive)?; } }; } } + if let Some(context_spec_definition) = context_spec_definition { + let context_directive_definition = + context_spec_definition.context_directive_definition(supergraph_schema)?; + for directive in directives.get_all(&context_directive_definition.name) { + let context_directive_application = + context_spec_definition.context_directive_arguments(directive)?; + let (subgraph_name, context_name) = context_directive_application + .name + .rsplit_once("__") + .ok_or_else(|| SingleFederationError::InvalidFederationSupergraph { + message: format!( + "Invalid context \"{}\" in supergraph schema", + context_directive_application.name + ), + })?; + let subgraph = subgraphs.get_mut(subgraph_name).ok_or_else(|| { + SingleFederationError::Internal { + message: + "All subgraphs should have been created by \"collect_empty_subgraphs()\"" + .to_owned(), + } + })?; + let federation_spec_definition = federation_spec_definitions + .get(&subgraph.graph_enum_value) + .ok_or_else(|| SingleFederationError::InvalidFederationSupergraph { + message: "Subgraph unexpectedly does not use federation spec".to_owned(), + })?; + let context_directive = federation_spec_definition + .context_directive(&subgraph.schema, context_name.to_owned())?; + let subgraph_type_definition_position: CompositeTypeDefinitionPosition = subgraph + .schema + .get_type(type_definition_position.type_name().clone())? + .try_into()?; + subgraph_type_definition_position + .insert_directive(&mut subgraph.schema, Component::new(context_directive))?; + } + } + Ok(type_info) } @@ -697,7 +880,6 @@ fn extract_object_type_content( federation_spec_definitions: &IndexMap, join_spec_definition: &JoinSpecDefinition, info: &[TypeInfo], - original_directive_names: &IndexMap, ) -> Result<(), FederationError> { let field_directive_definition = join_spec_definition.field_directive_definition(supergraph_schema)?; @@ -753,21 +935,12 @@ fn extract_object_type_content( graph_enum_value_name_to_subgraph_name, graph_enum_value, )?; - let federation_spec_definition = federation_spec_definitions - .get(graph_enum_value) - .ok_or_else(|| SingleFederationError::InvalidFederationSupergraph { - message: "Subgraph unexpectedly does not use federation spec".to_owned(), - })?; - if let Some(cost_spec_definition) = - federation_spec_definition.get_cost_spec_definition(&subgraph.schema) - { - cost_spec_definition.propagate_demand_control_directives_for_object( - &mut subgraph.schema, - type_, - &pos, - original_directive_names, - )?; - } + + CostSpecDefinition::propagate_demand_control_directives_for_object( + supergraph_schema, + &mut subgraph.schema, + &pos, + )?; } for (field_name, field) in type_.fields.iter() { @@ -793,17 +966,14 @@ fn extract_object_type_content( message: "Subgraph unexpectedly does not use federation spec" .to_owned(), })?; - let cost_spec_definition = - federation_spec_definition.get_cost_spec_definition(&subgraph.schema); add_subgraph_field( field_pos.clone().into(), field, + supergraph_schema, subgraph, federation_spec_definition, is_shareable, None, - cost_spec_definition, - original_directive_names, )?; } } else { @@ -834,16 +1004,11 @@ fn extract_object_type_content( message: "Subgraph unexpectedly does not use federation spec" .to_owned(), })?; - let cost_spec_definition = - federation_spec_definition.get_cost_spec_definition(&subgraph.schema); if !subgraph_info.contains_key(graph_enum_value) { return Err( SingleFederationError::InvalidFederationSupergraph { message: format!( - "@join__field cannot exist on {}.{} for subgraph {} without type-level @join__type", - type_name, - field_name, - graph_enum_value, + "@join__field cannot exist on {type_name}.{field_name} for subgraph {graph_enum_value} without type-level @join__type", ), }.into() ); @@ -851,12 +1016,11 @@ fn extract_object_type_content( add_subgraph_field( field_pos.clone().into(), field, + supergraph_schema, subgraph, federation_spec_definition, is_shareable, Some(field_directive_application), - cost_spec_definition, - original_directive_names, )?; } } @@ -873,7 +1037,6 @@ fn extract_interface_type_content( federation_spec_definitions: &IndexMap, join_spec_definition: &JoinSpecDefinition, info: &[TypeInfo], - original_directive_names: &IndexMap, ) -> Result<(), FederationError> { let field_directive_definition = join_spec_definition.field_directive_definition(supergraph_schema)?; @@ -890,10 +1053,10 @@ fn extract_interface_type_content( subgraph_info, } in info.iter() { - let type_ = InterfaceTypeDefinitionPosition { + let pos = InterfaceTypeDefinitionPosition { type_name: (*type_name).clone(), - } - .get(supergraph_schema.schema())?; + }; + let type_ = pos.get(supergraph_schema.schema())?; fn get_pos( subgraph: &FederationSubgraph, subgraph_info: &IndexMap, @@ -903,13 +1066,11 @@ fn extract_interface_type_content( let is_interface_object = *subgraph_info.get(graph_enum_value).ok_or_else(|| { SingleFederationError::InvalidFederationSupergraph { message: format!( - "@join__implements cannot exist on {} for subgraph {} without type-level @join__type", - type_name, - graph_enum_value, + "@join__implements cannot exist on {type_name} for subgraph {graph_enum_value} without type-level @join__type", ), } })?; - Ok(match subgraph.schema.get_type(type_name.clone())? { + Ok(match subgraph.schema.get_type(type_name)? { TypeDefinitionPosition::Object(pos) => { if !is_interface_object { return Err( @@ -996,17 +1157,14 @@ fn extract_interface_type_content( message: "Subgraph unexpectedly does not use federation spec" .to_owned(), })?; - let cost_spec_definition = - federation_spec_definition.get_cost_spec_definition(&subgraph.schema); add_subgraph_field( pos.field(field_name.clone()), field, + supergraph_schema, subgraph, federation_spec_definition, false, None, - cost_spec_definition, - original_directive_names, )?; } } else { @@ -1030,16 +1188,11 @@ fn extract_interface_type_content( message: "Subgraph unexpectedly does not use federation spec" .to_owned(), })?; - let cost_spec_definition = - federation_spec_definition.get_cost_spec_definition(&subgraph.schema); if !subgraph_info.contains_key(graph_enum_value) { return Err( SingleFederationError::InvalidFederationSupergraph { message: format!( - "@join__field cannot exist on {}.{} for subgraph {} without type-level @join__type", - type_name, - field_name, - graph_enum_value, + "@join__field cannot exist on {type_name}.{field_name} for subgraph {graph_enum_value} without type-level @join__type", ), }.into() ); @@ -1047,12 +1200,11 @@ fn extract_interface_type_content( add_subgraph_field( pos.field(field_name.clone()), field, + supergraph_schema, subgraph, federation_spec_definition, false, Some(field_directive_application), - cost_spec_definition, - original_directive_names, )?; } } @@ -1158,10 +1310,8 @@ fn extract_enum_type_content( supergraph_schema: &FederationSchema, subgraphs: &mut FederationSubgraphs, graph_enum_value_name_to_subgraph_name: &IndexMap>, - federation_spec_definitions: &IndexMap, join_spec_definition: &JoinSpecDefinition, info: &[TypeInfo], - original_directive_names: &IndexMap, ) -> Result<(), FederationError> { // This was added in join 0.3, so it can genuinely be None. let enum_value_directive_definition = @@ -1183,21 +1333,12 @@ fn extract_enum_type_content( graph_enum_value_name_to_subgraph_name, graph_enum_value, )?; - let federation_spec_definition = federation_spec_definitions - .get(graph_enum_value) - .ok_or_else(|| SingleFederationError::InvalidFederationSupergraph { - message: "Subgraph unexpectedly does not use federation spec".to_owned(), - })?; - if let Some(cost_spec_definition) = - federation_spec_definition.get_cost_spec_definition(&subgraph.schema) - { - cost_spec_definition.propagate_demand_control_directives_for_enum( - &mut subgraph.schema, - type_, - &pos, - original_directive_names, - )?; - } + + CostSpecDefinition::propagate_demand_control_directives_for_enum( + supergraph_schema, + &mut subgraph.schema, + &pos, + )?; } for (value_name, value) in type_.values.iter() { @@ -1267,10 +1408,8 @@ fn extract_input_object_type_content( supergraph_schema: &FederationSchema, subgraphs: &mut FederationSubgraphs, graph_enum_value_name_to_subgraph_name: &IndexMap>, - federation_spec_definitions: &IndexMap, join_spec_definition: &JoinSpecDefinition, info: &[TypeInfo], - original_directive_names: &IndexMap, ) -> Result<(), FederationError> { let field_directive_definition = join_spec_definition.field_directive_definition(supergraph_schema)?; @@ -1302,21 +1441,12 @@ fn extract_input_object_type_content( graph_enum_value_name_to_subgraph_name, graph_enum_value, )?; - let federation_spec_definition = federation_spec_definitions - .get(graph_enum_value) - .ok_or_else(|| SingleFederationError::InvalidFederationSupergraph { - message: "Subgraph unexpectedly does not use federation spec" - .to_owned(), - })?; - let cost_spec_definition = - federation_spec_definition.get_cost_spec_definition(&subgraph.schema); add_subgraph_input_field( input_field_pos.clone(), input_field, + supergraph_schema, subgraph, None, - cost_spec_definition, - original_directive_names, )?; } } else { @@ -1332,22 +1462,11 @@ fn extract_input_object_type_content( graph_enum_value_name_to_subgraph_name, graph_enum_value, )?; - let federation_spec_definition = federation_spec_definitions - .get(graph_enum_value) - .ok_or_else(|| SingleFederationError::InvalidFederationSupergraph { - message: "Subgraph unexpectedly does not use federation spec" - .to_owned(), - })?; - let cost_spec_definition = - federation_spec_definition.get_cost_spec_definition(&subgraph.schema); if !subgraph_info.contains_key(graph_enum_value) { return Err( SingleFederationError::InvalidFederationSupergraph { message: format!( - "@join__field cannot exist on {}.{} for subgraph {} without type-level @join__type", - type_name, - input_field_name, - graph_enum_value, + "@join__field cannot exist on {type_name}.{input_field_name} for subgraph {graph_enum_value} without type-level @join__type", ), }.into() ); @@ -1355,10 +1474,9 @@ fn extract_input_object_type_content( add_subgraph_input_field( input_field_pos.clone(), input_field, + supergraph_schema, subgraph, Some(field_directive_application), - cost_spec_definition, - original_directive_names, )?; } } @@ -1372,12 +1490,11 @@ fn extract_input_object_type_content( fn add_subgraph_field( object_or_interface_field_definition_position: ObjectOrInterfaceFieldDefinitionPosition, field: &FieldDefinition, + supergraph_schema: &FederationSchema, subgraph: &mut FederationSubgraph, federation_spec_definition: &'static FederationSpecDefinition, is_shareable: bool, field_directive_application: Option<&FieldDirectiveArguments>, - cost_spec_definition: Option<&'static CostSpecDefinition>, - original_directive_names: &IndexMap, ) -> Result<(), FederationError> { let field_directive_application = field_directive_application.unwrap_or_else(|| &FieldDirectiveArguments { @@ -1389,6 +1506,7 @@ fn add_subgraph_field( override_: None, override_label: None, user_overridden: None, + context_arguments: None, }); let subgraph_field_type = match &field_directive_application.type_ { Some(t) => decode_type(t)?, @@ -1412,14 +1530,13 @@ fn add_subgraph_field( default_value: argument.default_value.clone(), directives: Default::default(), }; - if let Some(cost_spec_definition) = cost_spec_definition { - cost_spec_definition.propagate_demand_control_directives( - &subgraph.schema, - &argument.directives, - &mut destination_argument.directives, - original_directive_names, - )?; - } + + CostSpecDefinition::propagate_demand_control_directives( + supergraph_schema, + &argument.directives, + &subgraph.schema, + &mut destination_argument.directives, + )?; subgraph_field .arguments @@ -1444,7 +1561,7 @@ fn add_subgraph_field( )); } let user_overridden = field_directive_application.user_overridden.unwrap_or(false); - if user_overridden { + if user_overridden && field_directive_application.override_label.is_none() { subgraph_field.directives.push(Node::new( federation_spec_definition .external_directive(&subgraph.schema, Some("[overridden]".to_string()))?, @@ -1465,13 +1582,41 @@ fn add_subgraph_field( )); } - if let Some(cost_spec_definition) = cost_spec_definition { - cost_spec_definition.propagate_demand_control_directives( - &subgraph.schema, - &field.directives, - &mut subgraph_field.directives, - original_directive_names, - )?; + CostSpecDefinition::propagate_demand_control_directives( + supergraph_schema, + &field.directives, + &subgraph.schema, + &mut subgraph_field.directives, + )?; + + if let Some(context_arguments) = &field_directive_application.context_arguments { + for args in context_arguments { + let ContextArgument { + name, + type_, + context, + selection, + } = args; + let (_, context_name_in_subgraph) = context.rsplit_once("__").ok_or_else(|| { + SingleFederationError::InvalidFederationSupergraph { + message: format!(r#"Invalid context "{context}" in supergraph schema"#), + } + })?; + + let arg = format!("${context_name_in_subgraph} {selection}"); + let from_context_directive = + federation_spec_definition.from_context_directive(&subgraph.schema, arg)?; + let directives = std::iter::once(from_context_directive).collect(); + let ty = decode_type(type_)?; + let node = Node::new(InputValueDefinition { + name: Name::new(name)?, + ty: ty.into(), + directives, + default_value: None, + description: None, + }); + subgraph_field.arguments.push(node); + } } match object_or_interface_field_definition_position { @@ -1489,10 +1634,9 @@ fn add_subgraph_field( fn add_subgraph_input_field( input_object_field_definition_position: InputObjectFieldDefinitionPosition, input_field: &InputValueDefinition, + supergraph_schema: &FederationSchema, subgraph: &mut FederationSubgraph, field_directive_application: Option<&FieldDirectiveArguments>, - cost_spec_definition: Option<&'static CostSpecDefinition>, - original_directive_names: &IndexMap, ) -> Result<(), FederationError> { let field_directive_application = field_directive_application.unwrap_or_else(|| &FieldDirectiveArguments { @@ -1504,6 +1648,7 @@ fn add_subgraph_input_field( override_: None, override_label: None, user_overridden: None, + context_arguments: None, }); let subgraph_input_field_type = match &field_directive_application.type_ { Some(t) => Node::new(decode_type(t)?), @@ -1517,14 +1662,12 @@ fn add_subgraph_input_field( directives: Default::default(), }; - if let Some(cost_spec_definition) = cost_spec_definition { - cost_spec_definition.propagate_demand_control_directives( - &subgraph.schema, - &input_field.directives, - &mut subgraph_input_field.directives, - original_directive_names, - )?; - } + CostSpecDefinition::propagate_demand_control_directives( + supergraph_schema, + &input_field.directives, + &subgraph.schema, + &mut subgraph_input_field.directives, + )?; input_object_field_definition_position .insert(&mut subgraph.schema, Component::from(subgraph_input_field))?; @@ -1547,8 +1690,7 @@ fn get_subgraph<'subgraph>( .ok_or_else(|| { SingleFederationError::Internal { message: format!( - "Invalid graph enum_value \"{}\": does not match an enum value defined in the @join__Graph enum", - graph_enum_value, + "Invalid graph enum_value \"{graph_enum_value}\": does not match an enum value defined in the @join__Graph enum", ), } })?; @@ -1561,8 +1703,8 @@ fn get_subgraph<'subgraph>( }) } -lazy_static! { - static ref EXECUTABLE_DIRECTIVE_LOCATIONS: IndexSet = { +static EXECUTABLE_DIRECTIVE_LOCATIONS: LazyLock> = + LazyLock::new(|| { [ DirectiveLocation::Query, DirectiveLocation::Mutation, @@ -1575,8 +1717,7 @@ lazy_static! { ] .into_iter() .collect() - }; -} + }); fn remove_unused_types_from_subgraph(schema: &mut FederationSchema) -> Result<(), FederationError> { // We now do an additional path on all types because we sometimes added types to subgraphs @@ -1660,23 +1801,25 @@ fn remove_unused_types_from_subgraph(schema: &mut FederationSchema) -> Result<() Ok(()) } -const FEDERATION_ANY_TYPE_NAME: Name = name!("_Any"); +pub(crate) const FEDERATION_ANY_TYPE_NAME: Name = name!("_Any"); const FEDERATION_SERVICE_TYPE_NAME: Name = name!("_Service"); const FEDERATION_SDL_FIELD_NAME: Name = name!("sdl"); -const FEDERATION_ENTITY_TYPE_NAME: Name = name!("_Entity"); -const FEDERATION_SERVICE_FIELD_NAME: Name = name!("_service"); -const FEDERATION_ENTITIES_FIELD_NAME: Name = name!("_entities"); +pub(crate) const FEDERATION_ENTITY_TYPE_NAME: Name = name!("_Entity"); +pub(crate) const FEDERATION_SERVICE_FIELD_NAME: Name = name!("_service"); +pub(crate) const FEDERATION_ENTITIES_FIELD_NAME: Name = name!("_entities"); pub(crate) const FEDERATION_REPRESENTATIONS_ARGUMENTS_NAME: Name = name!("representations"); pub(crate) const FEDERATION_REPRESENTATIONS_VAR_NAME: Name = name!("representations"); -const GRAPHQL_STRING_TYPE_NAME: Name = name!("String"); -const GRAPHQL_QUERY_TYPE_NAME: Name = name!("Query"); +pub(crate) const GRAPHQL_STRING_TYPE_NAME: Name = name!("String"); +pub(crate) const GRAPHQL_QUERY_TYPE_NAME: Name = name!("Query"); +pub(crate) const GRAPHQL_MUTATION_TYPE_NAME: Name = name!("Mutation"); +pub(crate) const GRAPHQL_SUBSCRIPTION_TYPE_NAME: Name = name!("Subscription"); -const ANY_TYPE_SPEC: ScalarTypeSpecification = ScalarTypeSpecification { +pub(crate) const ANY_TYPE_SPEC: ScalarTypeSpecification = ScalarTypeSpecification { name: FEDERATION_ANY_TYPE_NAME, }; -const SERVICE_TYPE_SPEC: ObjectTypeSpecification = ObjectTypeSpecification { +pub(crate) const SERVICE_TYPE_SPEC: ObjectTypeSpecification = ObjectTypeSpecification { name: FEDERATION_SERVICE_TYPE_NAME, fields: |_schema| { [FieldSpecification { @@ -1688,7 +1831,7 @@ const SERVICE_TYPE_SPEC: ObjectTypeSpecification = ObjectTypeSpecification { }, }; -const QUERY_TYPE_SPEC: ObjectTypeSpecification = ObjectTypeSpecification { +pub(crate) const EMPTY_QUERY_TYPE_SPEC: ObjectTypeSpecification = ObjectTypeSpecification { name: GRAPHQL_QUERY_TYPE_NAME, fields: |_schema| Default::default(), // empty Query (fields should be added later) }; @@ -1720,8 +1863,8 @@ fn add_federation_operations( federation_spec_definition: &'static FederationSpecDefinition, ) -> Result<(), FederationError> { // the `_Any` and `_Service` Type - ANY_TYPE_SPEC.check_or_add(&mut subgraph.schema)?; - SERVICE_TYPE_SPEC.check_or_add(&mut subgraph.schema)?; + ANY_TYPE_SPEC.check_or_add(&mut subgraph.schema, None)?; + SERVICE_TYPE_SPEC.check_or_add(&mut subgraph.schema, None)?; // the `_Entity` Type let key_directive_definition = @@ -1731,9 +1874,9 @@ fn add_federation_operations( if has_entity_type { UnionTypeSpecification { name: FEDERATION_ENTITY_TYPE_NAME, - members: |_| entity_members.clone(), + members: Box::new(move |_| entity_members.clone()), } - .check_or_add(&mut subgraph.schema)?; + .check_or_add(&mut subgraph.schema, None)?; } // the `Query` Type @@ -1741,10 +1884,10 @@ fn add_federation_operations( root_kind: SchemaRootDefinitionKind::Query, }; if query_root_pos.try_get(subgraph.schema.schema()).is_none() { - QUERY_TYPE_SPEC.check_or_add(&mut subgraph.schema)?; + EMPTY_QUERY_TYPE_SPEC.check_or_add(&mut subgraph.schema, None)?; query_root_pos.insert( &mut subgraph.schema, - ComponentName::from(QUERY_TYPE_SPEC.name), + ComponentName::from(EMPTY_QUERY_TYPE_SPEC.name), )?; } @@ -1779,7 +1922,7 @@ fn add_federation_operations( // `Query._service` ObjectFieldDefinitionPosition { - type_name: query_root_type_name.clone(), + type_name: query_root_type_name, field_name: FEDERATION_SERVICE_FIELD_NAME, } .insert( @@ -1805,7 +1948,7 @@ fn add_federation_operations( /// impact on later query planning, because it sometimes make us try type-exploding some interfaces /// unnecessarily. Besides, if a usage adds something useless, there is a chance it hasn't fully /// understood something, and warning about that fact through an error is more helpful. -fn remove_inactive_requires_and_provides_from_subgraph( +pub(crate) fn remove_inactive_requires_and_provides_from_subgraph( supergraph_schema: &FederationSchema, schema: &mut FederationSchema, ) -> Result<(), FederationError> { @@ -1829,8 +1972,7 @@ fn remove_inactive_requires_and_provides_from_subgraph( } // Ignore non-object/interface types. - let Ok(type_pos): Result = type_pos.try_into() - else { + let Ok(type_pos) = ObjectOrInterfaceTypeDefinitionPosition::try_from(type_pos) else { continue; }; @@ -1930,17 +2072,28 @@ fn remove_inactive_applications( // directives instead of returning error here, as it pollutes the list of error messages // during composition (another site in composition will properly check for field set // validity and give better error messaging). - let mut fields = parse_field_set_without_normalization( + let (mut fields, mut is_modified) = parse_field_set_without_normalization( valid_schema, parent_type_pos.type_name().clone(), fields, + true, )?; - let is_modified = remove_non_external_leaf_fields(schema, &mut fields)?; + + if remove_non_external_leaf_fields(schema, &mut fields)? { + is_modified = true; + } if is_modified { let replacement_directive = if fields.selections.is_empty() { None } else { - let fields = fields.serialize().no_indent().to_string(); + let fields = FieldSet { + sources: Default::default(), + selection_set: fields, + } + .serialize() + .no_indent() + .to_string(); + Some(Node::new(match directive_kind { FieldSetDirectiveKind::Provides => { federation_spec_definition.provides_directive(schema, fields)? @@ -2112,190 +2265,19 @@ fn maybe_dump_subgraph_schema(subgraph: FederationSubgraph, message: &mut String } _ => write!( message, - "Re-run with environment variable '{}' set to 'true' to extract the invalid subgraph", - DEBUG_SUBGRAPHS_ENV_VARIABLE_NAME + "Re-run with environment variable '{DEBUG_SUBGRAPHS_ENV_VARIABLE_NAME}' set to 'true' to extract the invalid subgraph" ), }; } -//////////////////////////////////////////////////////////////////////////////// -/// @join__directive extraction - -static JOIN_DIRECTIVE: &str = "join__directive"; - -/// Converts `@join__directive(graphs: [A], name: "foo")` to `@foo` in the A subgraph. -/// If the directive is a link directive on the schema definition, we also need -/// to update the metadata and add the imported definitions. -fn extract_join_directives( - supergraph_schema: &FederationSchema, - subgraphs: &mut FederationSubgraphs, - graph_enum_value_name_to_subgraph_name: &IndexMap>, -) -> Result<(), FederationError> { - let join_directives = match supergraph_schema - .referencers() - .get_directive(JOIN_DIRECTIVE) - { - Ok(directives) => directives, - Err(_) => { - // No join directives found, nothing to do. - return Ok(()); - } - }; - - if let Some(schema_def_pos) = &join_directives.schema { - let schema_def = schema_def_pos.get(supergraph_schema.schema()); - let directives = schema_def - .directives - .iter() - .filter_map(|d| { - if d.name == JOIN_DIRECTIVE { - Some(join_directive_to_real_directive(d)) - } else { - None - } - }) - .collect_vec(); - - // TODO: Do we need to handle the link directive being renamed? - let (links, others) = directives - .into_iter() - .partition::, _>(|(d, _)| d.name == DEFAULT_LINK_NAME); - - // After adding links, we'll check the link against a safelist of - // specs and check_or_add the spec definitions if necessary. - for (link_directive, subgraph_enum_values) in links { - for subgraph_enum_value in subgraph_enum_values { - let subgraph = get_subgraph( - subgraphs, - graph_enum_value_name_to_subgraph_name, - &subgraph_enum_value, - )?; - - schema_def_pos.insert_directive( - &mut subgraph.schema, - Component::new(link_directive.clone()), - )?; - - // TODO: add imported definitions from relevant specs - } - } - - // Other directives are added normally. - for (directive, subgraph_enum_values) in others { - for subgraph_enum_value in subgraph_enum_values { - let subgraph = get_subgraph( - subgraphs, - graph_enum_value_name_to_subgraph_name, - &subgraph_enum_value, - )?; - - schema_def_pos - .insert_directive(&mut subgraph.schema, Component::new(directive.clone()))?; - } - } - } - - for object_field_pos in &join_directives.object_fields { - let object_field = object_field_pos.get(supergraph_schema.schema())?; - let directives = object_field - .directives - .iter() - .filter_map(|d| { - if d.name == JOIN_DIRECTIVE { - Some(join_directive_to_real_directive(d)) - } else { - None - } - }) - .collect_vec(); - - for (directive, subgraph_enum_values) in directives { - for subgraph_enum_value in subgraph_enum_values { - let subgraph = get_subgraph( - subgraphs, - graph_enum_value_name_to_subgraph_name, - &subgraph_enum_value, - )?; - - object_field_pos - .insert_directive(&mut subgraph.schema, Node::new(directive.clone()))?; - } - } - } - - // TODO - // - join_directives.directive_arguments - // - join_directives.enum_types - // - join_directives.enum_values - // - join_directives.input_object_fields - // - join_directives.input_object_types - // - join_directives.interface_field_arguments - // - join_directives.interface_fields - // - join_directives.interface_types - // - join_directives.object_field_arguments - // - join_directives.object_types - // - join_directives.scalar_types - // - join_directives.union_types - - Ok(()) -} - -fn join_directive_to_real_directive(directive: &Node) -> (Directive, Vec) { - let subgraph_enum_values = directive - .specified_argument_by_name("graphs") - .and_then(|arg| arg.as_list()) - .map(|list| { - list.iter() - .map(|node| { - Name::new( - node.as_enum() - .expect("join__directive(graphs:) value is an enum") - .as_str(), - ) - .expect("join__directive(graphs:) value is a valid name") - }) - .collect() - }) - .expect("join__directive(graphs:) missing"); - - let name = directive - .specified_argument_by_name("name") - .expect("join__directive(name:) is present") - .as_str() - .expect("join__directive(name:) is a string"); - - let arguments = directive - .specified_argument_by_name("args") - .and_then(|a| a.as_object()) - .map(|args| { - args.iter() - .map(|(k, v)| { - Argument { - name: k.clone(), - value: v.clone(), - } - .into() - }) - .collect() - }) - .unwrap_or_default(); - - let directive = Directive { - name: Name::new(name).expect("join__directive(name:) is a valid name"), - arguments, - }; - - (directive, subgraph_enum_values) -} - #[cfg(test)] mod tests { - use apollo_compiler::name; use apollo_compiler::Schema; + use apollo_compiler::name; use insta::assert_snapshot; - use crate::schema::FederationSchema; use crate::ValidFederationSubgraphs; + use crate::schema::FederationSchema; // JS PORT NOTE: these tests were ported from // https://github.com/apollographql/federation/blob/3e2c845c74407a136b9e0066e44c1ad1467d3013/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts @@ -2632,7 +2614,7 @@ mod tests { d: String } - * This tests is similar to the other test with unions, but because its members are enties, the + * This tests is similar to the other test with unions, but because its members are entries, the * members themself with have a join__owner, and that means the removal will hit a different * code path (technically, the union A will be "removed" directly by `extractSubgraphsFromSupergraph` * instead of being removed indirectly through the removal of its members). @@ -2910,7 +2892,7 @@ mod tests { let supergraph = r###"schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) - @join__directive(graphs: [SUBGRAPH], name: "link", args: {url: "https://specs.apollo.dev/hello/v0.1", import: ["@hello"]}) + @join__directive(graphs: [SUBGRAPH], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"]}) { query: Query } @@ -2946,6 +2928,7 @@ mod tests { enum join__Graph { SUBGRAPH @join__graph(name: "subgraph", url: "none") + SUBGRAPH2 @join__graph(name: "subgraph2", url: "none") } scalar link__Import @@ -2964,6 +2947,39 @@ mod tests { type Query @join__type(graph: SUBGRAPH) + @join__type(graph: SUBGRAPH2) + { + f: String + @join__field(graph: SUBGRAPH) + @join__directive(graphs: [SUBGRAPH], name: "connect", args: {http: {GET: "http://localhost/"}, selection: "$"}) + i: I + @join__field(graph: SUBGRAPH2) + } + + type T + @join__type(graph: SUBGRAPH) + @join__directive(graphs: [SUBGRAPH], name: "connect", args: {http: {GET: "http://localhost/{$batch.id}"}, selection: "$"}) + { + id: ID! + f: String + } + + interface I + @join__type(graph: SUBGRAPH2, key: "f") + @join__type(graph: SUBGRAPH, isInterfaceObject: true) + @join__directive(graphs: [SUBGRAPH], name: "connect", args: {http: {GET: "http://localhost/{$this.id}"}, selection: "f"}) + { + f: String + } + + type A implements I + @join__type(graph: SUBGRAPH2) + { + f: String + } + + type B implements I + @join__type(graph: SUBGRAPH2) { f: String } @@ -2977,6 +2993,9 @@ mod tests { .unwrap(); let subgraph = subgraphs.get("subgraph").unwrap(); - assert_snapshot!(subgraph.schema.schema().schema_definition.directives, @r###" @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.9") @link(url: "https://specs.apollo.dev/hello/v0.1", import: ["@hello"])"###); + assert_snapshot!(subgraph.schema.schema().schema_definition.directives, @r#" @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@connect"])"#); + assert_snapshot!(subgraph.schema.schema().type_field("Query", "f").unwrap().directives, @r#" @connect(http: {GET: "http://localhost/"}, selection: "$")"#); + assert_snapshot!(subgraph.schema.schema().get_object("T").unwrap().directives, @r#" @connect(http: {GET: "http://localhost/{$batch.id}"}, selection: "$")"#); + assert_snapshot!(subgraph.schema.schema().get_object("I").unwrap().directives, @r###" @federation__interfaceObject @connect(http: {GET: "http://localhost/{$this.id}"}, selection: "f")"###); } } diff --git a/apollo-federation/src/supergraph/schema.rs b/apollo-federation/src/supergraph/schema.rs index 589131f633..9a05cd8cfc 100644 --- a/apollo-federation/src/supergraph/schema.rs +++ b/apollo-federation/src/supergraph/schema.rs @@ -1,49 +1,8 @@ -use apollo_compiler::collections::IndexMap; use apollo_compiler::schema::SchemaBuilder; -use apollo_compiler::Name; use crate::error::FederationError; -use crate::link::spec::APOLLO_SPEC_DOMAIN; -use crate::link::Link; use crate::schema::FederationSchema; -/// Builds a map of original name to new name for Apollo feature directives. This is -/// used to handle cases where a directive is renamed via an import statement. For -/// example, importing a directive with a custom name like -/// ```graphql -/// @link(url: "https://specs.apollo.dev/cost/v0.1", import: [{ name: "@cost", as: "@renamedCost" }]) -/// ``` -/// results in a map entry of `cost -> renamedCost` with the `@` prefix removed. -/// -/// If the directive is imported under its default name, that also results in an entry. So, -/// ```graphql -/// @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) -/// ``` -/// results in a map entry of `cost -> cost`. This duals as a way to check if a directive -/// is included in the supergraph schema. -/// -/// **Important:** This map does _not_ include directives imported from identities other -/// than `specs.apollo.dev`. This helps us avoid extracting directives to subgraphs -/// when a custom directive's name conflicts with that of a default one. -pub(super) fn get_apollo_directive_names( - supergraph_schema: &FederationSchema, -) -> Result, FederationError> { - let mut hm: IndexMap = IndexMap::default(); - for directive in &supergraph_schema.schema().schema_definition.directives { - if directive.name.as_str() == "link" { - if let Ok(link) = Link::from_directive_application(directive) { - if link.url.identity.domain != APOLLO_SPEC_DOMAIN { - continue; - } - for import in link.imports { - hm.insert(import.element.clone(), import.imported_name().clone()); - } - } - } - } - Ok(hm) -} - /// TODO: Use the JS/programmatic approach instead of hard-coding definitions. pub(crate) fn new_empty_fed_2_subgraph_schema() -> Result { let builder = SchemaBuilder::new().adopt_orphan_extensions(); @@ -51,7 +10,7 @@ pub(crate) fn new_empty_fed_2_subgraph_schema() -> Result Result, ValidFederationSubgraph>, + pub subgraphs: BTreeMap, ValidFederationSubgraph>, } impl fmt::Debug for ValidFederationSubgraphs { diff --git a/apollo-federation/src/utils/fallible_iterator.rs b/apollo-federation/src/utils/fallible_iterator.rs index 0a26b9c7fd..978af7ebab 100644 --- a/apollo-federation/src/utils/fallible_iterator.rs +++ b/apollo-federation/src/utils/fallible_iterator.rs @@ -2,25 +2,6 @@ use itertools::Itertools; -/// A common use for iteator is to collect into a container and grow that container. This trait -/// extends the standard library's `Extend` trait to work for containers that can be extended with -/// `T`s to also be extendable with `Result`. If an `Err` is encountered, that `Err` is -/// returned. Notably, this means the container will contain all prior `Ok` values. -pub(crate) trait FallibleExtend: Extend { - fn fallible_extend(&mut self, iter: I) -> Result<(), E> - where - I: IntoIterator>, - { - iter.into_iter() - .process_results(|results| self.extend(results)) - } - - // NOTE: The standard extend trait provides `extend_one` and `extend_reserve` methods. These - // have not been added and can be if a use arises. -} - -impl FallibleExtend for T where T: Extend {} - /// An extension trait for `Iterator`, similar to `Itertools`, that seeks to improve the ergonomics /// around fallible operations. /// @@ -28,16 +9,16 @@ impl FallibleExtend for T where T: Extend {} /// `filter`) might yield a `Result` containing the value you actually want/need, but fallible can /// also refer to the stream of items that you're iterating over (or both!). As much as possible, I /// will use the following naming scheme in order to keep these ideas consistent: -/// - If the iterator yeilds an arbitary `T` and the operation that you wish to apply is of the -/// form `T -> Result`, then it will named `fallible_*`. -/// - If the iterator yields `Result` and the operation is of the form `T -> U` (for arbitary -/// `U`), then it will named `*_ok`. +/// - If the iterator yields an arbitrary `T` and the operation that you wish to apply is of the +/// form `T -> Result`, then it will named `fallible_*`. +/// - If the iterator yields `Result` and the operation is of the form `T -> U` (for arbitrary +/// `U`), then it will named `*_ok`. /// - If both iterator and operation yield `Result`, then it will named `and_then_*` (more on that -/// fewer down). +/// fewer down). /// /// The first category mostly describes combinators that take closures that need specific types, /// such as `filter` and things in the `any`/`all`/`find`/`fold` family. There are several -/// expirement features in `std` that offer similar functionalities. +/// experiment features in `std` that offer similar functionalities. /// /// The second category is mostly taken care of by `Itertools`. While they are not currently /// implemented here (or in `Itertools`), this category would also contain methods like `*_err` @@ -67,7 +48,7 @@ impl FallibleExtend for T where T: Extend {} /// Lastly, if you come across something that fits what this trait is trying to do and you have a /// usecase for but that is not served by already, feel free to expand the functionalities! // TODO: In std, methods like `all` and `any` are actually just specializations of `try_fold` using -// bools and `FlowControl`. When initially writting this, I, @TylerBloom, didn't take the time to +// bools and `FlowControl`. When initially writing this, I, @TylerBloom, didn't take the time to // write equalivalent folding methods. Should they be implemented in the future, we should rework // existing methods to use them. pub(crate) trait FallibleIterator: Sized + Itertools { @@ -104,7 +85,7 @@ pub(crate) trait FallibleIterator: Sized + Itertools { // might be useful at some point. /// This method functions similarly to `Iterator::filter` but where the existing iterator - /// yeilds `Result`s and the given predicate also returns `Result`s. + /// yields `Result`s and the given predicate also returns `Result`s. /// /// The predicate is only called if the existing iterator yields `Ok`. `Err`s are ignored. /// Should the predicate return an `Err`, the `Ok` value was replaced with the `Err`. This @@ -138,7 +119,7 @@ pub(crate) trait FallibleIterator: Sized + Itertools { /// This method functions similarly to `Iterator::all` but where the given predicate returns /// `Result`s. /// - /// Like `Iterator::all`, this function short-curcuits but will short-curcuit if the predicate + /// Like `Iterator::all`, this function short-circuits but will short-circuit if the predicate /// returns anything other than `Ok(true)`. If the first item that is not `Ok(true)` is /// `Ok(false)`, the returned value will be `Ok(false)`. If that item is `Err`, than that `Err` /// is returned. @@ -173,86 +154,10 @@ pub(crate) trait FallibleIterator: Sized + Itertools { Ok(digest) } - /// This method functions similarly to `FallibleIterator::fallible_all` but inverted. The - /// existing iterator yields `Result`s but the predicate is not fallible. - /// - /// Like `FallibleIterator::fallible_all`, this function short-curcuits but will short-curcuit - /// if it encounters an `Err` or `false`. If the existing iterator yields an `Err`, this - /// function short-curcuits, does not call the predicate, and returns that `Err`. If the value - /// is `Ok`, it is given to the predicate. If the predicate returns `false`, this method - /// returns `Ok(false)`. - /// - /// ```ignore - /// let first_values = vec![]; - /// let second_values = vec![Ok(1), Err(())]; - /// let third_values = vec![Ok(0), Ok(1), Ok(2)]; - /// let fourth_values = vec![Err(()), Ok(0)]; - /// - /// assert_eq!(Ok(true), first_values.into_iter().ok_and_all(is_even)); - /// assert_eq!(Ok(false), second_values.into_iter().ok_and_all(is_even)); - /// assert_eq!(Ok(false), third_values.into_iter().ok_and_all(is_even)); - /// assert_eq!(Err(()), fourth_values.into_iter().ok_and_all(is_even)); - /// ``` - fn ok_and_all(&mut self, predicate: F) -> Result - where - Self: Iterator>, - F: FnMut(T) -> bool, - { - self.process_results(|mut results| results.all(predicate)) - } - - /// This method functions similarly to `FallibleIterator::fallible_all` but both the - /// existing iterator and predicate yield `Result`s. - /// - /// Like `FallibleIterator::fallible_all`, this function short-curcuits but will short-curcuit - /// if it encounters an `Err` or `Ok(false)`. If the existing iterator yields an `Err`, this - /// function returns that `Err`. If the value is `Ok`, it is given to the predicate. If the - /// predicate returns `Err`, that `Err` is returned. If the predicate returns `Ok(false)`, - /// `Ok(false)` is returned. By default, this function returned `Ok(true)`. - /// - /// ```ignore - /// // A totally accurate prime checker - /// fn is_prime(i: usize) -> Result { - /// match i { - /// 0 | 1 => Err(()), // 0 and 1 are neither prime or composite - /// 2 | 3 => Ok(true), - /// _ => Ok(false), // Every other number is composite, I guess - /// } - /// } - /// - /// let first_values = vec![]; - /// let second_values = vec![Ok(0), Err(())]; - /// let third_values = vec![Ok(2), Ok(3)]; - /// let fourth_values = vec![Err(()), Ok(2)]; - /// let fifth_values = vec![Ok(2), Err(())]; - /// let sixth_values = vec![Ok(4), Ok(3)]; - /// - /// assert_eq!(Ok(true), first_values.into_iter().and_then_all(is_prime)); - /// assert_eq!(Err(()), second_values.into_iter().and_then_all(is_prime)); - /// assert_eq!(Ok(true), third_values.into_iter().and_then_all(is_prime)); - /// assert_eq!(Err(()), fourth_values.into_iter().and_then_all(is_prime)); - /// assert_eq!(Err(()), fifth_values.into_iter().and_then_all(is_prime)); - /// assert_eq!(Ok(false), sixth_values.into_iter().and_then_all(is_prime)); - /// ``` - fn and_then_all(&mut self, mut predicate: F) -> Result - where - Self: Iterator>, - F: FnMut(T) -> Result, - { - let mut digest = true; - for val in self.by_ref() { - digest &= val.and_then(&mut predicate)?; - if !digest { - break; - } - } - Ok(digest) - } - /// This method functions similarly to `Iterator::any` but where the given predicate returns /// `Result`s. /// - /// Like `Iterator::any`, this function short-curcuits but will short-curcuit if the predicate + /// Like `Iterator::any`, this function short-circuits but will short-circuit if the predicate /// returns anything other than `Ok(false)`. If the first item that is not `Ok(false)` is /// `Ok(true)`, the returned value will be `Ok(true)`. If that item is `Err`, than that `Err` /// is returned. @@ -290,9 +195,9 @@ pub(crate) trait FallibleIterator: Sized + Itertools { /// This method functions similarly to `FallibleIterator::fallible_any` but inverted. The /// existing iterator yields `Result`s but the predicate is not fallible. /// - /// Like `FallibleIterator::fallible_any`, this function short-curcuits but will short-curcuit + /// Like `FallibleIterator::fallible_any`, this function short-circuits but will short-circuit /// if it encounters an `Err` or `true`. If the existing iterator yields an `Err`, this - /// function short-curcuits, does not call the predicate, and returns that `Err`. If the value + /// function short-circuits, does not call the predicate, and returns that `Err`. If the value /// is `Ok`, it is given to the predicate. If the predicate returns `true`, this method returns /// `Ok(true)`. /// @@ -315,54 +220,6 @@ pub(crate) trait FallibleIterator: Sized + Itertools { self.process_results(|mut results| results.any(predicate)) } - /// This method functions similarly to `FallibleIterator::fallible_any` but both the - /// existing iterator and predicate yield `Result`s. - /// - /// Like `FallibleIterator::fallible_any`, this function short-curcuits but will short-curcuit - /// if it encounters an `Err` or `Ok(true)`. If the existing iterator yields an `Err`, this - /// function returns that `Err`. If the value is `Ok`, it is given to the predicate. If the - /// predicate returns `Err`, that `Err` is returned. If the predicate returns `Ok(true)`, - /// `Ok(true)` is returned. By default, this function returned `Ok(false)`. - /// - /// ```ignore - /// // A totally accurate prime checker - /// fn is_prime(i: usize) -> Result { - /// match i { - /// 0 | 1 => Err(()), // 0 and 1 are neither prime or composite - /// 2 | 3 => Ok(true), - /// _ => Ok(false), // Every other number is composite, I guess - /// } - /// } - /// - /// let first_values = vec![]; - /// let second_values = vec![Ok(0), Err(())]; - /// let third_values = vec![Ok(3), Ok(4)]; - /// let fourth_values = vec![Err(()), Ok(2)]; - /// let fifth_values = vec![Ok(2), Err(())]; - /// let sixth_values = vec![Ok(4), Ok(5)]; - /// - /// assert_eq!(Ok(false), first_values.into_iter().and_then_any(is_prime)); - /// assert_eq!(Err(()), second_values.into_iter().and_then_any(is_prime)); - /// assert_eq!(Ok(true), third_values.into_iter().and_then_any(is_prime)); - /// assert_eq!(Err(()), fourth_values.into_iter().and_then_any(is_prime)); - /// assert_eq!(Ok(true), fifth_values.into_iter().and_then_any(is_prime)); - /// assert_eq!(Ok(false), sixth_values.into_iter().and_then_any(is_prime)); - /// ``` - fn and_then_any(&mut self, mut predicate: F) -> Result - where - Self: Iterator>, - F: FnMut(T) -> Result, - { - let mut digest = false; - for val in self { - digest |= val.and_then(&mut predicate)?; - if digest { - break; - } - } - Ok(digest) - } - /// A convenience method that is equivalent to calling `.map(|result| /// result.and_then(fallible_fn))`. fn and_then(self, map: F) -> AndThen @@ -373,26 +230,6 @@ pub(crate) trait FallibleIterator: Sized + Itertools { AndThen { iter: self, map } } - /// A convenience method that is equivalent to calling `.map(|result| - /// result.or_else(fallible_fn))`. - fn or_else(self, map: F) -> OrElse - where - Self: Iterator>, - F: FnMut(E) -> Result, - { - OrElse { iter: self, map } - } - - /// A convenience method for applying a fallible operation to an iterator of `Result`s and - /// returning the first `Err` if one occurs. - fn and_then_for_each(self, inner: F) -> Result<(), E> - where - Self: Iterator>, - F: FnMut(T) -> Result<(), E>, - { - self.and_then(inner).collect() - } - /// Tries to find the first `Ok` value that matches the predicate. If an `Err` is found before /// the finding a match, the `Err` is returned. // NOTE: This is a nightly feature on `Iterator`. To avoid name collisions, this method is @@ -405,6 +242,17 @@ pub(crate) trait FallibleIterator: Sized + Itertools { { self.process_results(|mut results| results.find(predicate)) } + + fn fallible_fold(&mut self, mut init: O, mut mapper: F) -> Result + where + Self: Iterator, + F: FnMut(O, T) -> Result, + { + for item in self { + init = mapper(init, item)?; + } + Ok(init) + } } impl FallibleIterator for I {} @@ -479,23 +327,6 @@ where } } -pub(crate) struct OrElse { - iter: I, - map: F, -} - -impl Iterator for OrElse -where - I: Iterator>, - F: FnMut(E) -> Result, -{ - type Item = Result; - - fn next(&mut self) -> Option { - self.iter.next().map(|res| res.or_else(&mut self.map)) - } -} - // Ideally, these would be doc tests, but gating access to the `utils` module is messy. #[cfg(test)] mod tests { @@ -538,36 +369,6 @@ mod tests { assert_eq!(Err(()), (1..5).fallible_all(is_prime)); } - #[test] - fn test_ok_and_all() { - let first_values: Vec = vec![]; - let second_values: Vec = vec![Ok(1), Err(())]; - let third_values: Vec = vec![Ok(0), Ok(1), Ok(2)]; - let fourth_values: Vec = vec![Err(()), Ok(0)]; - - assert_eq!(Ok(true), first_values.into_iter().ok_and_all(is_even)); - assert_eq!(Ok(false), second_values.into_iter().ok_and_all(is_even)); - assert_eq!(Ok(false), third_values.into_iter().ok_and_all(is_even)); - assert_eq!(Err(()), fourth_values.into_iter().ok_and_all(is_even)); - } - - #[test] - fn test_and_then_all() { - let first_values: Vec = vec![]; - let second_values: Vec = vec![Ok(0), Err(())]; - let third_values: Vec = vec![Ok(2), Ok(3)]; - let fourth_values: Vec = vec![Err(()), Ok(2)]; - let fifth_values: Vec = vec![Ok(2), Err(())]; - let sixth_values: Vec = vec![Ok(4), Ok(3)]; - - assert_eq!(Ok(true), first_values.into_iter().and_then_all(is_prime)); - assert_eq!(Err(()), second_values.into_iter().and_then_all(is_prime)); - assert_eq!(Ok(true), third_values.into_iter().and_then_all(is_prime)); - assert_eq!(Err(()), fourth_values.into_iter().and_then_all(is_prime)); - assert_eq!(Err(()), fifth_values.into_iter().and_then_all(is_prime)); - assert_eq!(Ok(false), sixth_values.into_iter().and_then_all(is_prime)); - } - #[test] fn test_fallible_any() { assert_eq!(Ok(false), [].into_iter().fallible_any(is_prime)); @@ -589,21 +390,4 @@ mod tests { assert_eq!(Ok(false), third_values.into_iter().ok_and_any(is_even)); assert_eq!(Err(()), fourth_values.into_iter().ok_and_any(is_even)); } - - #[test] - fn test_and_then_any() { - let first_values: Vec = vec![]; - let second_values: Vec = vec![Ok(0), Err(())]; - let third_values: Vec = vec![Ok(3), Ok(4)]; - let fourth_values: Vec = vec![Err(()), Ok(2)]; - let fifth_values: Vec = vec![Ok(2), Err(())]; - let sixth_values: Vec = vec![Ok(4), Ok(5)]; - - assert_eq!(Ok(false), first_values.into_iter().and_then_any(is_prime)); - assert_eq!(Err(()), second_values.into_iter().and_then_any(is_prime)); - assert_eq!(Ok(true), third_values.into_iter().and_then_any(is_prime)); - assert_eq!(Err(()), fourth_values.into_iter().and_then_any(is_prime)); - assert_eq!(Ok(true), fifth_values.into_iter().and_then_any(is_prime)); - assert_eq!(Ok(false), sixth_values.into_iter().and_then_any(is_prime)); - } } diff --git a/apollo-federation/src/utils/human_readable.rs b/apollo-federation/src/utils/human_readable.rs new file mode 100644 index 0000000000..d7d60963e7 --- /dev/null +++ b/apollo-federation/src/utils/human_readable.rs @@ -0,0 +1,182 @@ +pub(crate) struct JoinStringsOptions<'a> { + pub(crate) separator: &'a str, + pub(crate) first_separator: Option<&'a str>, + pub(crate) last_separator: Option<&'a str>, + /// When displaying a list of something in a human-readable form, after what size (in number of + /// characters) we start displaying only a subset of the list. Note this only counts characters + /// in list elements, and ignores separators. + pub(crate) output_length_limit: Option, +} + +impl Default for JoinStringsOptions<'_> { + fn default() -> Self { + Self { + separator: ", ", + first_separator: None, + last_separator: Some(" and "), + output_length_limit: None, + } + } +} + +/// Joins an iterator of strings, but with the ability to use a specific different separator for the +/// first and/or last occurrence (if both are given and the list is size two, the first separator is +/// used). Optionally, if the resulting list to print is "too long", it can display a subset of the +/// elements and uses an ellipsis (...) for the rest. +/// +/// The goal is to make the reading flow slightly better. For instance, if you have a vector of +/// subgraphs `s = ["A", "B", "C"]`, then `join_strings(s.iter(), Default::default())` will yield +/// "A, B and C". +pub(crate) fn join_strings( + mut iter: impl Iterator>, + options: JoinStringsOptions, +) -> String { + let mut output = String::new(); + let Some(first) = iter.next() else { + return output; + }; + output.push_str(first.as_ref()); + let Some(second) = iter.next() else { + return output; + }; + // PORT_NOTE: The analogous JS code in `printHumanReadableList()` was only tracking the length + // of elements getting added to the list and ignored separators, so we do the same here. + let mut element_length = first.as_ref().chars().count(); + // Returns true if push would exceed limit, and instead pushes default separator and "...". + let mut push_sep_and_element = |sep: &str, element: &str| { + if let Some(output_length_limit) = options.output_length_limit { + // PORT_NOTE: The analogous JS code in `printHumanReadableList()` has a bug where it + // doesn't early exit when the length would be too long, and later small elements in the + // list may erroneously extend the printed subset. That bug is fixed here. + let new_element_length = element_length + element.chars().count(); + return if new_element_length <= output_length_limit { + element_length = new_element_length; + output.push_str(sep); + output.push_str(element); + false + } else { + output.push_str(options.separator); + output.push_str("..."); + true + }; + } + output.push_str(sep); + output.push_str(element); + false + }; + let last_sep = options.last_separator.unwrap_or(options.separator); + let Some(mut current) = iter.next() else { + push_sep_and_element(options.first_separator.unwrap_or(last_sep), second.as_ref()); + return output; + }; + if push_sep_and_element( + options.first_separator.unwrap_or(options.separator), + second.as_ref(), + ) { + return output; + } + for next in iter { + if push_sep_and_element(options.separator, current.as_ref()) { + return output; + } + current = next; + } + push_sep_and_element(last_sep, current.as_ref()); + output +} + +pub(crate) struct HumanReadableListOptions<'a> { + pub(crate) prefix: Option>, + pub(crate) last_separator: Option<&'a str>, + /// When displaying a list of something in a human-readable form, after what size (in number of + /// characters) we start displaying only a subset of the list. + pub(crate) output_length_limit: usize, + /// If there are no elements, this string will be used instead. + pub(crate) empty_output: &'a str, +} + +pub(crate) struct HumanReadableListPrefix<'a> { + pub(crate) singular: &'a str, + pub(crate) plural: &'a str, +} + +impl Default for HumanReadableListOptions<'_> { + fn default() -> Self { + Self { + prefix: None, + last_separator: Some(" and "), + output_length_limit: 100, + empty_output: "", + } + } +} + +// PORT_NOTE: Named `printHumanReadableList` in the JS codebase, but "print" in Rust has the +// implication it prints to stdout/stderr, so we remove it here. Also, the "emptyValue" option is +// never used, so it's not ported. +/// Like [join_strings], joins an iterator of strings, but with a few differences, namely: +/// - It allows prefixing the whole list, and to use a different prefix if there's only a single +/// element in the list. +/// - It forces the use of ", " as separator, but allows a different last separator. +/// - It forces an output length limit to be specified. In other words, this function assumes it's +/// more useful to avoid flooding the output than printing everything when the list is too long. +pub(crate) fn human_readable_list( + mut iter: impl Iterator>, + options: HumanReadableListOptions, +) -> String { + let Some(first) = iter.next() else { + return options.empty_output.to_owned(); + }; + let Some(second) = iter.next() else { + return if let Some(prefix) = options.prefix { + format!("{} {}", prefix.singular, first.as_ref()) + } else { + first.as_ref().to_owned() + }; + }; + let joined_strings = join_strings( + [first, second].into_iter().chain(iter), + JoinStringsOptions { + last_separator: options.last_separator, + output_length_limit: Some(options.output_length_limit), + ..Default::default() + }, + ); + if let Some(prefix) = options.prefix { + format!("{} {}", prefix.plural, joined_strings) + } else { + joined_strings + } +} + +// PORT_NOTE: Named `printSubgraphNames` in the JS codebase, but "print" in Rust has the implication +// it prints to stdout/stderr, so we've renamed it here to `human_readable_subgraph_names` +pub(crate) fn human_readable_subgraph_names( + subgraph_names: impl Iterator>, +) -> String { + human_readable_list( + subgraph_names.map(|name| format!("\"{}\"", name.as_ref())), + HumanReadableListOptions { + prefix: Some(HumanReadableListPrefix { + singular: "subgraph", + plural: "subgraphs", + }), + ..Default::default() + }, + ) +} + +// PORT_NOTE: Named `printTypes` in the JS codebase, but "print" in Rust has the implication +// it prints to stdout/stderr, so we've renamed it here to `human_readable_types` +pub(crate) fn human_readable_types(types: impl Iterator>) -> String { + human_readable_list( + types.map(|t| format!("\"{}\"", t.as_ref())), + HumanReadableListOptions { + prefix: Some(HumanReadableListPrefix { + singular: "type", + plural: "types", + }), + ..Default::default() + }, + ) +} diff --git a/apollo-federation/src/utils/logging.rs b/apollo-federation/src/utils/logging.rs index 0b247c1698..532abc144f 100644 --- a/apollo-federation/src/utils/logging.rs +++ b/apollo-federation/src/utils/logging.rs @@ -1,16 +1,16 @@ #![allow(dead_code)] use crate::operation::Selection; -use crate::query_graph::graph_path::ClosedBranch; -use crate::query_graph::graph_path::SimultaneousPathsWithLazyIndirectPaths; -use crate::query_plan::query_planning_traversal::OpenBranchAndSelections; +use crate::query_graph::graph_path::operation::ClosedBranch; +use crate::query_graph::graph_path::operation::OpenBranchAndSelections; +use crate::query_graph::graph_path::operation::SimultaneousPathsWithLazyIndirectPaths; /// This macro is a wrapper around `tracing::trace!` and should not be confused with our snapshot /// testing. This primary goal of this macro is to add the necessary context to logging statements /// so that external tools (like the snapshot log visualizer) can show how various key data /// structures evolve over the course of planning a query. /// -/// There are two ways of creating a snapshot. The easiest is by passing the macro a indentifier +/// There are two ways of creating a snapshot. The easiest is by passing the macro a identifier /// for the value you'd like to take a snapshot of. This will tag the snapshot type with the type /// name of the value, create data that is JSON string using serde_json, and add the message /// literal that you pass in. EX: @@ -57,7 +57,7 @@ pub(crate) fn make_string( writer: fn(&mut std::fmt::Formatter<'_>, &T) -> std::fmt::Result, } - impl<'a, T: ?Sized> std::fmt::Display for Stringify<'a, T> { + impl std::fmt::Display for Stringify<'_, T> { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { (self.writer)(f, self.data) } diff --git a/apollo-federation/src/utils/mod.rs b/apollo-federation/src/utils/mod.rs index 8d62d08a04..f26dbfa997 100644 --- a/apollo-federation/src/utils/mod.rs +++ b/apollo-federation/src/utils/mod.rs @@ -1,6 +1,21 @@ //! This module contains various tools that help the ergonomics of this crate. mod fallible_iterator; +pub(crate) mod human_readable; pub(crate) mod logging; +pub(crate) mod multi_index_map; +pub(crate) mod serde_bridge; +// Re-exports pub(crate) use fallible_iterator::*; +pub(crate) use multi_index_map::MultiIndexMap; + +/// If the `iter` yields a single element, return it. Else return `None`. +pub(crate) fn iter_into_single_item(mut iter: impl Iterator) -> Option { + let item = iter.next()?; + if iter.next().is_none() { + Some(item) + } else { + None + } +} diff --git a/apollo-federation/src/utils/multi_index_map.rs b/apollo-federation/src/utils/multi_index_map.rs new file mode 100644 index 0000000000..7d88f55904 --- /dev/null +++ b/apollo-federation/src/utils/multi_index_map.rs @@ -0,0 +1,35 @@ +use std::hash::Hash; +use std::ops::Deref; + +use apollo_compiler::collections::IndexMap; + +/// A simple MultiMap implementation using IndexMap with Vec as its value type. +/// - Preserves the insertion order of keys and values. +pub(crate) struct MultiIndexMap(IndexMap>); + +impl Deref for MultiIndexMap { + type Target = IndexMap>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl MultiIndexMap +where + K: Eq + Hash, +{ + pub(crate) fn new() -> Self { + Self(IndexMap::default()) + } + + pub(crate) fn insert(&mut self, key: K, value: V) { + self.0.entry(key).or_default().push(value); + } + + pub(crate) fn extend>(&mut self, iterable: I) { + for (key, value) in iterable { + self.insert(key, value); + } + } +} diff --git a/apollo-federation/src/utils/serde_bridge.rs b/apollo-federation/src/utils/serde_bridge.rs new file mode 100644 index 0000000000..5d577a71fa --- /dev/null +++ b/apollo-federation/src/utils/serde_bridge.rs @@ -0,0 +1,77 @@ +//! This module contains functions used to bridge the apollo compiler serialization methods with +//! serialization with serde. + +use apollo_compiler::Node; +use apollo_compiler::executable; +use serde::Serializer; +use serde::ser::SerializeSeq; + +pub(crate) fn serialize_optional_slice_of_exe_argument_nodes< + S: Serializer, + Args: AsRef<[Node]>, +>( + args: &Option, + ser: S, +) -> Result { + let Some(args) = args else { + return ser.serialize_none(); + }; + let args = args.as_ref(); + let mut ser = ser.serialize_seq(Some(args.len()))?; + args.iter().try_for_each(|arg| { + ser.serialize_element(&format!( + "{}: {}", + arg.name, + arg.value.serialize().no_indent() + )) + })?; + ser.end() +} + +pub(crate) fn serialize_exe_directive_list( + list: &executable::DirectiveList, + ser: S, +) -> Result { + ser.serialize_str(&list.serialize().no_indent().to_string()) +} + +pub(crate) mod operation_type { + use apollo_compiler::executable; + use serde::Deserialize; + use serde::Deserializer; + use serde::Serialize; + use serde::Serializer; + + pub(crate) fn serialize( + ty: &executable::OperationType, + ser: S, + ) -> Result { + match ty { + executable::OperationType::Query => OperationType::Query, + executable::OperationType::Mutation => OperationType::Mutation, + executable::OperationType::Subscription => OperationType::Subscription, + } + .serialize(ser) + } + + pub(crate) fn deserialize<'de, D>( + deserializier: D, + ) -> Result + where + D: Deserializer<'de>, + { + Ok(match OperationType::deserialize(deserializier)? { + OperationType::Query => executable::OperationType::Query, + OperationType::Mutation => executable::OperationType::Mutation, + OperationType::Subscription => executable::OperationType::Subscription, + }) + } + + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "lowercase")] + enum OperationType { + Query, + Mutation, + Subscription, + } +} diff --git a/apollo-federation/tests/api_schema.rs b/apollo-federation/tests/api_schema.rs index 9b039ef3f8..58d4b47e90 100644 --- a/apollo-federation/tests/api_schema.rs +++ b/apollo-federation/tests/api_schema.rs @@ -1,10 +1,10 @@ +use apollo_compiler::Schema; use apollo_compiler::coord; use apollo_compiler::schema::ExtendedType; use apollo_compiler::validation::Valid; -use apollo_compiler::Schema; -use apollo_federation::error::FederationError; use apollo_federation::ApiSchemaOptions; use apollo_federation::Supergraph; +use apollo_federation::error::FederationError; // TODO(@goto-bus-stop): inaccessible is in theory a standalone spec, // but is only tested here as part of API schema, unlike in the JS implementation. @@ -150,14 +150,18 @@ fn removes_inaccessible_object_types() { assert!(!api_schema.types.contains_key("Subscription")); assert!(!api_schema.types.contains_key("Object")); assert!(coord!(Referencer1.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer1.privatefield) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer1.privatefield) + .lookup(&api_schema) + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer2")); assert!(coord!(Referencer3.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer3.privatefield) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer3.privatefield) + .lookup(&api_schema) + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer4")); let ExtendedType::Union(union_) = api_schema.types.get("Referencer5").unwrap() else { @@ -262,14 +266,18 @@ fn removes_inaccessible_interface_types() { assert!(!api_schema.types.contains_key("Interface")); assert!(!api_schema.types.contains_key("Object")); assert!(api_schema.type_field("Referencer1", "someField").is_ok()); - assert!(api_schema - .type_field("Referencer1", "privatefield") - .is_err()); + assert!( + api_schema + .type_field("Referencer1", "privatefield") + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer2")); assert!(api_schema.type_field("Referencer3", "someField").is_ok()); - assert!(api_schema - .type_field("Referencer3", "privatefield") - .is_err()); + assert!( + api_schema + .type_field("Referencer3", "privatefield") + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer4")); let ExtendedType::Object(object) = api_schema.types.get("Referencer5").unwrap() else { @@ -363,14 +371,18 @@ fn removes_inaccessible_union_types() { assert!(!api_schema.types.contains_key("Union")); assert!(!api_schema.types.contains_key("Object")); assert!(api_schema.type_field("Referencer1", "someField").is_ok()); - assert!(api_schema - .type_field("Referencer1", "privatefield") - .is_err()); + assert!( + api_schema + .type_field("Referencer1", "privatefield") + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer2")); assert!(api_schema.type_field("Referencer3", "someField").is_ok()); - assert!(api_schema - .type_field("Referencer3", "privatefield") - .is_err()); + assert!( + api_schema + .type_field("Referencer3", "privatefield") + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer4")); } @@ -500,26 +512,36 @@ fn removes_inaccessible_input_object_types() { assert!(api_schema.types.contains_key("VisibleInputObject")); assert!(!api_schema.types.contains_key("InputObject")); assert!(coord!(Referencer1.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer1.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer1.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer2.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer2.privateField) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer2.privateField) + .lookup(&api_schema) + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer3")); assert!(coord!(Referencer4.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer4.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer4.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer4.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer4.privatefield) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer4.privatefield) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer5.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer5.privateField) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer5.privateField) + .lookup(&api_schema) + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer6")); let ExtendedType::InputObject(input_object) = &api_schema.types["Referencer7"] else { panic!("expected input object"); @@ -528,9 +550,11 @@ fn removes_inaccessible_input_object_types() { assert!(!input_object.fields.contains_key("privatefield")); assert!(!api_schema.types.contains_key("Referencer8")); assert!(coord!(@referencer9).lookup(&api_schema).is_ok()); - assert!(coord!(@referencer9(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(@referencer9(privateArg:)) + .lookup(&api_schema) + .is_err() + ); } #[test] @@ -694,34 +718,48 @@ fn removes_inaccessible_enum_types() { assert!(api_schema.types.contains_key("VisibleEnum")); assert!(!api_schema.types.contains_key("Enum")); assert!(coord!(Referencer1.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer1.privatefield) - .lookup(&api_schema) - .is_err()); - assert!(coord!(Referencer1.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer1.privatefield) + .lookup(&api_schema) + .is_err() + ); + assert!( + coord!(Referencer1.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer2")); assert!(coord!(Referencer3.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer3.privatefield) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer3.privatefield) + .lookup(&api_schema) + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer4")); assert!(coord!(Referencer5.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer5.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer5.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer6.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer6.privateField) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer6.privateField) + .lookup(&api_schema) + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer7")); assert!(coord!(Referencer8.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer8.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); - assert!(coord!(Referencer9.privateField) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer8.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); + assert!( + coord!(Referencer9.privateField) + .lookup(&api_schema) + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer10")); let ExtendedType::InputObject(input_object) = &api_schema.types["Referencer11"] else { panic!("expected input object"); @@ -729,12 +767,16 @@ fn removes_inaccessible_enum_types() { assert!(input_object.fields.contains_key("someField")); assert!(!input_object.fields.contains_key("privatefield")); assert!(!api_schema.types.contains_key("Referencer12")); - assert!(api_schema - .directive_definitions - .contains_key("referencer13")); - assert!(coord!(@referencer13(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + api_schema + .directive_definitions + .contains_key("referencer13") + ); + assert!( + coord!(@referencer13(privateArg:)) + .lookup(&api_schema) + .is_err() + ); } #[test] @@ -893,43 +935,59 @@ fn removes_inaccessible_scalar_types() { assert!(coord!(VisibleScalar).lookup(&api_schema).is_ok()); assert!(coord!(Scalar).lookup(&api_schema).is_err()); assert!(coord!(Referencer1.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer1.privatefield) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer1.privatefield) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer2).lookup(&api_schema).is_err()); assert!(coord!(Referencer3.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer3.privatefield) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer3.privatefield) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer4).lookup(&api_schema).is_err()); assert!(coord!(Referencer5.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer5.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer5.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer6.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer6.privateField) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer6.privateField) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer7).lookup(&api_schema).is_err()); assert!(coord!(Referencer8.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer8.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer8.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer9.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer9.privateField) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer9.privateField) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer10).lookup(&api_schema).is_err()); assert!(coord!(Referencer11).lookup(&api_schema).is_ok()); assert!(coord!(Referencer11.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer11.privatefield) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer11.privatefield) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer12).lookup(&api_schema).is_err()); assert!(coord!(@referencer13).lookup(&api_schema).is_ok()); - assert!(coord!(@referencer13(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(@referencer13(privateArg:)) + .lookup(&api_schema) + .is_err() + ); } #[test] @@ -1054,9 +1112,11 @@ fn removes_inaccessible_object_fields() { assert!(coord!(Mutation.someField).lookup(&api_schema).is_ok()); assert!(coord!(Mutation.privateField).lookup(&api_schema).is_err()); assert!(coord!(Subscription.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Subscription.privateField) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Subscription.privateField) + .lookup(&api_schema) + .is_err() + ); let ExtendedType::Object(object_type) = &api_schema.types["Object"] else { panic!("should be object"); }; @@ -1065,9 +1125,11 @@ fn removes_inaccessible_object_fields() { assert!(coord!(Object.someField).lookup(&api_schema).is_ok()); assert!(coord!(Object.privateField).lookup(&api_schema).is_err()); assert!(coord!(Referencer1.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer1.privatefield) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer1.privatefield) + .lookup(&api_schema) + .is_err() + ); assert!(api_schema.types.get("Referencer2").is_none()); assert!(api_schema.types.get("Referencer3").is_none()); } @@ -1153,9 +1215,11 @@ fn removes_inaccessible_interface_fields() { assert!(coord!(Interface.someField).lookup(&api_schema).is_ok()); assert!(coord!(Interface.privateField).lookup(&api_schema).is_err()); assert!(coord!(Referencer1.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer1.privatefield) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer1.privatefield) + .lookup(&api_schema) + .is_err() + ); assert!(api_schema.types.get("Referencer2").is_none()); assert!(api_schema.types.get("Referencer3").is_none()); } @@ -1259,40 +1323,54 @@ fn removes_inaccessible_object_field_arguments() { .expect("should succeed"); assert!(coord!(Query.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Query.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Query.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Mutation.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Mutation.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Mutation.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Subscription.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Subscription.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Subscription.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); let ExtendedType::Object(object_type) = &api_schema.types["Object"] else { panic!("expected object"); }; assert!(object_type.implements_interfaces.contains("Referencer1")); assert!(object_type.implements_interfaces.contains("Referencer2")); assert!(!object_type.implements_interfaces.contains("Referencer3")); - assert!(coord!(Object.someField(someArg:)) - .lookup(&api_schema) - .is_ok()); - assert!(coord!(Object.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Object.someField(someArg:)) + .lookup(&api_schema) + .is_ok() + ); + assert!( + coord!(Object.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer1.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer1.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer1.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer2).lookup(&api_schema).is_ok()); assert!(coord!(Referencer2.someField).lookup(&api_schema).is_err()); assert!(!api_schema.types.contains_key("Referencer3")); assert!(coord!(ObjectDefault.someField).lookup(&api_schema).is_ok()); - assert!(coord!(ObjectDefault.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(ObjectDefault.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); } #[test] @@ -1426,22 +1504,30 @@ fn removes_inaccessible_interface_field_arguments() { assert!(interface_type.implements_interfaces.contains("Referencer1")); assert!(interface_type.implements_interfaces.contains("Referencer2")); assert!(!interface_type.implements_interfaces.contains("Referencer3")); - assert!(coord!(Interface.someField(someArg:)) - .lookup(&api_schema) - .is_ok()); - assert!(coord!(Interface.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Interface.someField(someArg:)) + .lookup(&api_schema) + .is_ok() + ); + assert!( + coord!(Interface.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer1.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer1.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer1.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(api_schema.types.contains_key("Referencer2")); assert!(coord!(Referencer2.someField).lookup(&api_schema).is_err()); assert!(!api_schema.types.contains_key("Referencer3")); - assert!(coord!(Interface.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Interface.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); let object_argument = coord!(Referencer4.someField(privateArg:)) .lookup(&api_schema) .unwrap(); @@ -1620,49 +1706,67 @@ fn removes_inaccessible_input_object_fields() { ) .expect("should succeed"); - assert!(coord!(InputObject.someField) - .lookup_input_field(&schema) - .is_ok()); - assert!(coord!(InputObject.privateField) - .lookup_input_field(&schema) - .is_err()); + assert!( + coord!(InputObject.someField) + .lookup_input_field(&schema) + .is_ok() + ); + assert!( + coord!(InputObject.privateField) + .lookup_input_field(&schema) + .is_err() + ); assert!(coord!(Referencer1.someField).lookup(&schema).is_ok()); - assert!(coord!(Referencer1.someField(privateArg:)) - .lookup(&schema) - .is_err()); + assert!( + coord!(Referencer1.someField(privateArg:)) + .lookup(&schema) + .is_err() + ); assert!(coord!(Referencer2.someField).lookup(&schema).is_ok()); assert!(coord!(Referencer2.privateField).lookup(&schema).is_err()); assert!(!schema.types.contains_key("Referencer3")); assert!(coord!(Referencer4.someField).lookup(&schema).is_ok()); - assert!(coord!(Referencer4.someField(privateArg:)) - .lookup(&schema) - .is_err()); + assert!( + coord!(Referencer4.someField(privateArg:)) + .lookup(&schema) + .is_err() + ); assert!(coord!(Referencer5.someField).lookup(&schema).is_ok()); assert!(coord!(Referencer5.privateField).lookup(&schema).is_err()); assert!(!schema.types.contains_key("Referencer6")); - assert!(schema - .get_input_object("Referencer7") - .unwrap() - .fields - .contains_key("someField")); - assert!(!schema - .get_input_object("Referencer7") - .unwrap() - .fields - .contains_key("privatefield")); + assert!( + schema + .get_input_object("Referencer7") + .unwrap() + .fields + .contains_key("someField") + ); + assert!( + !schema + .get_input_object("Referencer7") + .unwrap() + .fields + .contains_key("privatefield") + ); assert!(!schema.types.contains_key("Referencer8")); assert!(schema.directive_definitions.contains_key("referencer9")); assert!(coord!(@referencer9(privateArg:)).lookup(&schema).is_err()); - assert!(coord!(Referencer10.someField(privateArg:)) - .lookup(&schema) - .is_ok()); + assert!( + coord!(Referencer10.someField(privateArg:)) + .lookup(&schema) + .is_ok() + ); assert!(!schema.types.contains_key("Referencer11")); - assert!(coord!(InputObjectDefault.someField) - .lookup_input_field(&schema) - .is_ok()); - assert!(coord!(InputObjectDefault.privatefield) - .lookup_input_field(&schema) - .is_err()); + assert!( + coord!(InputObjectDefault.someField) + .lookup_input_field(&schema) + .is_ok() + ); + assert!( + coord!(InputObjectDefault.privatefield) + .lookup_input_field(&schema) + .is_err() + ); } #[test] @@ -1816,44 +1920,64 @@ fn removes_inaccessible_enum_values() { ) .expect("should succeed"); - assert!(coord!(Enum.SOME_VALUE) - .lookup_enum_value(&api_schema) - .is_ok()); - assert!(coord!(Enum.PRIVATE_VALUE) - .lookup_enum_value(&api_schema) - .is_err()); + assert!( + coord!(Enum.SOME_VALUE) + .lookup_enum_value(&api_schema) + .is_ok() + ); + assert!( + coord!(Enum.PRIVATE_VALUE) + .lookup_enum_value(&api_schema) + .is_err() + ); assert!(coord!(Referencer1.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer1.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer1.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer2.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer2.privateField) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer2.privateField) + .lookup(&api_schema) + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer3")); assert!(coord!(Referencer4.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer4.someField(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer4.someField(privateArg:)) + .lookup(&api_schema) + .is_err() + ); assert!(coord!(Referencer5.someField).lookup(&api_schema).is_ok()); - assert!(coord!(Referencer5.privateField) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(Referencer5.privateField) + .lookup(&api_schema) + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer6")); - assert!(coord!(Referencer7.someField) - .lookup_input_field(&api_schema) - .is_ok()); - assert!(coord!(Referencer7.privatefield) - .lookup_input_field(&api_schema) - .is_err()); + assert!( + coord!(Referencer7.someField) + .lookup_input_field(&api_schema) + .is_ok() + ); + assert!( + coord!(Referencer7.privatefield) + .lookup_input_field(&api_schema) + .is_err() + ); assert!(!api_schema.types.contains_key("Referencer8")); assert!(coord!(@referencer9).lookup(&api_schema).is_ok()); - assert!(coord!(@referencer9(privateArg:)) - .lookup(&api_schema) - .is_err()); - assert!(coord!(Referencer10.someField(privateArg:)) - .lookup(&api_schema) - .is_ok()); + assert!( + coord!(@referencer9(privateArg:)) + .lookup(&api_schema) + .is_err() + ); + assert!( + coord!(Referencer10.someField(privateArg:)) + .lookup(&api_schema) + .is_ok() + ); assert!(!api_schema.types.contains_key("Referencer11")); } @@ -1987,12 +2111,16 @@ fn removes_inaccessible_directive_arguments() { assert!(coord!(@directive(someArg:)).lookup(&api_schema).is_ok()); assert!(coord!(@directive(privateArg:)).lookup(&api_schema).is_err()); - assert!(coord!(@directiveDefault(someArg:)) - .lookup(&api_schema) - .is_ok()); - assert!(coord!(@directiveDefault(privateArg:)) - .lookup(&api_schema) - .is_err()); + assert!( + coord!(@directiveDefault(someArg:)) + .lookup(&api_schema) + .is_ok() + ); + assert!( + coord!(@directiveDefault(privateArg:)) + .lookup(&api_schema) + .is_err() + ); } #[test] @@ -2039,7 +2167,7 @@ fn inaccessible_on_builtins() { // Note this is different from the JS implementation insta::assert_snapshot!(errors, @r###" Error: built-in scalar definitions must be omitted - ╭─[schema.graphql:26:7] + ╭─[ schema.graphql:26:7 ] │ 26 │ scalar String @inaccessible │ ─────────────┬───────────── diff --git a/apollo-federation/tests/composition/compose_directive.rs b/apollo-federation/tests/composition/compose_directive.rs new file mode 100644 index 0000000000..b78a6463f4 --- /dev/null +++ b/apollo-federation/tests/composition/compose_directive.rs @@ -0,0 +1,1006 @@ +use apollo_federation::supergraph::Satisfiable; +use apollo_federation::supergraph::Supergraph; +use rstest::rstest; + +use apollo_federation::composition::compose; +use apollo_federation::subgraph::typestate::Initial; +use apollo_federation::subgraph::typestate::Subgraph; + +mod simple_cases { + use super::*; + + #[test] + fn simple_success_case() { + let subgraph_a = generate_subgraph( + "subgraphA", + r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])"#, + r#"@composeDirective(name: "@foo")"#, + "directive @foo(name: String!) on FIELD_DEFINITION", + r#"@foo(name: "a")"#, + ); + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + assert_has_directive_definition( + &result, + "directive @foo(name: String!) on FIELD_DEFINITION", + ); + + let schema = result.schema().schema().to_string(); + assert!( + schema.contains(r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])"#) + ); + assert!(schema.contains(r#"subgraphA: String @foo(name: "a")"#)); + } + + #[test] + fn simple_success_case_no_import() { + let subgraph_a = generate_subgraph( + "subgraphA", + r#"@link(url: "https://specs.apollo.dev/foo/v1.0")"#, + r#"@composeDirective(name: "@foo__bar")"#, + "directive @foo__bar(name: String!) on FIELD_DEFINITION", + r#"@foo__bar(name: "a")"#, + ); + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + assert_has_directive_definition( + &result, + "directive @foo__bar(name: String!) on FIELD_DEFINITION", + ); + + let schema = result.schema().schema().to_string(); + assert!(schema.contains(r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: [{ name: "@bar", as: "@foo_bar" }])"#)); + assert!(schema.contains(r#"subgraphA: String @foo__bar(name: "a")"#)); + } + + #[test] + fn simple_success_case_renamed_compose_directive() { + let subgraph_a = Subgraph::parse("subgraphA", "", r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.1", import: ["@key", { name: "@composeDirective", as: "@apolloCompose" }]) + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"]) + @apolloCompose(name: "@foo") + + directive @foo(name: String!) on FIELD_DEFINITION + type Query { + a: User + } + type User @key(fields: "id") { + id: Int + a: String @foo(name: "a") + } + "#) + .unwrap() + .into_fed2_test_subgraph(true) + .unwrap(); + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + assert_has_directive_definition( + &result, + "directive @foo(name: String!) on FIELD_DEFINITION", + ); + + let schema = result.schema().schema().to_string(); + assert!( + schema.contains(r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])"#) + ); + assert!(schema.contains(r#"subgraphA: String @foo(name: "a")"#)); + } +} + +mod federation_directives { + use super::*; + + #[rstest] + #[case("@tag")] + #[case("@inaccessible")] + #[case("@authenticated")] + #[case("@requiresScopes")] + fn hints_for_default_composed_federation_directives(#[case] directive: &str) { + let subgraph_a = generate_subgraph( + "subgraphA", + &format!("@composeDirective(name: \"{directive}\")"), + "", + "", + "", + ); + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + assert_eq!(result.hints().len(), 1); + let hint = result.hints().first().unwrap(); + assert_eq!(hint.code, "DIRECTIVE_COMPOSITION_INFO"); + assert_eq!( + hint.message, + format!( + "Directive \"{directive}\" should not be explicitly composed since it is a federation directive composed by default" + ) + ); + } + + #[rstest] + #[case("@tag")] + #[case("@inaccessible")] + #[case("@authenticated")] + #[case("@requiresScopes")] + fn hints_for_renamed_default_composed_federation_directives(#[case] directive: &str) { + let subgraph_a = Subgraph::parse("subgraphA", "", r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.5", import: [{ name: "@key" }, { name: "@composeDirective" } , { name: "", as: "@apolloDirective" }]) + @link(url: "https://specs.apollo.dev/link/v1.0") + @composeDirective(name: "@apolloDirective") + + type Query { + a: User + } + type User @key(fields: "id") { + id: Int + a: String + } + "# + .replace("", directive) + .as_str()) + .unwrap(); + + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + assert_eq!(result.hints().len(), 1); + let hint = result.hints().first().unwrap(); + assert_eq!(hint.code, "DIRECTIVE_COMPOSITION_INFO"); + assert_eq!( + hint.message, + format!( + "Directive \"@apolloDirective\" should not be explicitly composed since it is a federation directive composed by default" + ) + ); + } + + #[rstest] + #[case("@key")] + #[case("@requires")] + #[case("@provides")] + #[case("@external")] + #[case("@extends")] + #[case("@shareable")] + #[case("@override")] + #[case("@composeDirective")] + fn errors_for_federation_directives_with_nontrivial_compositions(#[case] directive: &str) { + let subgraph_a = generate_subgraph( + "subgraphA", + &format!("@composeDirective(name: \"{directive}\")"), + "", + "", + "", + ); + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + format!( + "Composing federation directive \"{directive}\" in subgraph \"subgraphA\" is not supported" + ) + ); + } + + #[rstest] + #[case("@key")] + #[case("@requires")] + #[case("@provides")] + #[case("@external")] + #[case("@extends")] + #[case("@shareable")] + #[case("@override")] + #[case("@composeDirective")] + fn errors_for_renamed_federation_directives_with_nontrivial_compositions( + #[case] directive: &str, + ) { + let subgraph_a = Subgraph::parse("subgraphA", "", r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.1", import: [{ name: "@key" }, { name: "@composeDirective" } , { name: "", as: "@apolloDirective" }]) + @link(url: "https://specs.apollo.dev/link/v1.0") + @composeDirective(name: "@apolloDirective") + + type Query { + a: User + } + type User @key(fields: "id") { + id: Int + a: String + } + "# + .replace("", directive) + .as_str()) + .unwrap(); + + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + format!( + "Composing federation directive \"@apolloDirective\" in subgraph \"subgraphA\" is not supported" + ) + ); + } + + #[rstest] + #[case("@join__field")] + #[case("@join__graph")] + #[case("@join__implements")] + #[case("@join__type")] + #[case("@join__unionMember")] + #[case("@join__enumValue")] + fn errors_for_join_spec_directives(#[case] directive: &str) { + let subgraph_a = generate_subgraph( + "subgraphA", + r#"@link(url: "https://specs.apollo.dev/join/v0.2", for: EXECUTION)"#, + &format!("@composeDirective(name: \"{directive}\")"), + r#" + directive @join__field(graph: join__Graph!, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + + scalar join__FieldSet + + enum join__Graph { + WORLD @join__graph(name: "world", url: "https://world.api.com.invalid") + } + "#, + "", + ); + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + format!( + "Composing federation directive \"{directive}\" in subgraph \"subgraphA\" is not supported" + ) + ); + } +} + +mod inconsistent_feature_versions { + use super::*; + + #[test] + fn hints_when_mismatched_versions_are_not_composed() { + let subgraph_a = generate_subgraph( + r#"subgraphA"#, + r#"@link(url: "https://specs.apollo.dev/foo/v5.0", import: ["@foo"])"#, + "", + r#"directive @foo(String!) on FIELD_DEFINITION"#, + r#"@foo("a")"#, + ); + let subgraph_b = generate_subgraph( + r#"subgraphB"#, + r#"@link(url: "https://specs.apollo.dev/foo/v2.0", import: ["@foo"])"#, + "", + r#"directive @foo(String!) on FIELD_DEFINITION"#, + r#"@foo("b")"#, + ); + let subgraph_c = generate_subgraph( + r#"subgraphC"#, + r#"@link(url: "https://specs.apollo.dev/foo/v3.0", import: ["@foo"])"#, + "", + r#"directive @foo(String!) on FIELD_DEFINITION"#, + r#"@foo("")"#, + ); + let subgraph_d = generate_subgraph( + r#"subgraphD"#, + r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])"#, + "", + r#"directive @foo(String!) on FIELD_DEFINITION"#, + r#"@foo("b")"#, + ); + + let result = compose(vec![subgraph_a, subgraph_b, subgraph_c, subgraph_d]).unwrap(); + assert_eq!(result.hints().len(), 1); + let hint = result.hints().first().unwrap(); + assert_eq!(hint.code, "DIRECTIVE_COMPOSITION_INFO"); + assert_eq!( + hint.message, + r#"Non-composed core feature "https://specs.apollo.dev/foo" has major version mismatch across subgraphs"# + ); + } + + #[rstest] + #[case(r#"@link(url: "https://specs.apollo.dev/foo/v2.0", import: ["@foo"])"#)] + #[case(r#"@link(url: "https://specs.apollo.dev/foo/v2.0", import: ["@bar"])"#)] + fn errors_when_mismatched_major_versions_are_composed(#[case] link_text: &str) { + let subgraph_a = generate_subgraph( + "subgraphA", + r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])"#, + r#"@composeDirective(name: "@foo")"#, + "directive @foo(name: String!) on FIELD_DEFINITION", + r#"@foo(name: "a")"#, + ); + let subgraph_b = generate_subgraph( + "subgraphB", + link_text, + r#"@composeDirective(name: "@foo")"#, + "directive @foo(name: String!) on FIELD_DEFINITION", + r#"@foo(name: "b")"#, + ); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + r#"Core feature "https://specs.apollo.dev/foo" requested to be merged has major version mismatch across subgraphs"# + ); + } + + #[rstest] + #[case( + r#"composeDirective(name: "foo")"#, + "https://specs.apollo.dev/foo/v1.4", + "directive @foo(name: String!) on FIELD_DEFINITION | OBJECT" + )] + #[case( + "", + "https://specs.apollo.dev/foo/v1.0", + "directive @foo(name: String!) on FIELD_DEFINITION" + )] + fn composes_mismatched_versions_with_latest_used_definition( + #[case] compose_text_newer_link: &str, + #[case] expected_link: &str, + #[case] expected_definition: &str, + ) { + let subgraph_a = generate_subgraph( + "subgraphA", + r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])"#, + r#"composeDirective(name: "foo")"#, + "directive @foo(name: String!) on FIELD_DEFINITION", + r#"@foo(name: "a")"#, + ); + let subgraph_b = generate_subgraph( + "subgraphB", + r#"@link(url: "https://specs.apollo.dev/foo/v1.4", import: ["@foo"])"#, + compose_text_newer_link, + "directive @foo(name: String!) on FIELD_DEFINITION | OBJECT", + r#"@foo(name: "b")"#, + ); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + assert_eq!(result.hints().len(), 0); + + assert!(result.schema().schema().to_string().contains(expected_link)); + assert_has_directive_definition(&result, expected_definition); + } +} + +mod inconsistent_imports { + use super::*; + + #[rstest] + #[case( + r#" + directive @foo(name: String!) on FIELD_DEFINITION + directive @bar(name: String!, address: String) on FIELD_DEFINITION | OBJECT + "# + )] + #[case( + r#" + directive @foo(name: String!) on FIELD_DEFINITION + directive @foo_bar(name: String!, address: String) on FIELD_DEFINITION | OBJECT + "# + )] + fn composes_mismatched_imports_with_unqualified_name(#[case] directive_text: &str) { + let subgraph_a = generate_subgraph( + "subgraphA", + r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])"#, + r#"@composeDirective(name: "@foo")"#, + directive_text, + r#"@foo(name: "a")"#, + ); + let subgraph_b = generate_subgraph( + "subgraphB", + r#"@link(url: "https://specs.apollo.dev/foo/v1.1", import: ["@bar"])"#, + r#"@composeDirective(name: "@bar")"#, + r#" + directive @foo(name: String!) on FIELD_DEFINITION + directive @bar(name: String!, address: String) on FIELD_DEFINITION | OBJECT + "#, + r#"@bar(name: "b")"#, + ); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + assert_has_directive_definition( + &result, + "directive @foo(name: String!) on FIELD_DEFINITION", + ); + assert_has_directive_definition( + &result, + "directive @bar(name: String!, address: String) on FIELD_DEFINITION | OBJECT", + ); + + let schema = result.schema().schema().to_string(); + assert!(schema.contains(r#"subgraphA: String @foo(name: "a")"#)); + assert!(schema.contains(r#"subgraphB: String @bar(name: "b")"#)); + } + + #[test] + fn hints_when_imported_with_mismatched_name_but_not_exported() { + let subgraph_a = generate_subgraph( + "subgraphA", + r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo", { name: "@bar", as: "@baz" }])"#, + r#"@composeDirective(name: "@foo")"#, + r#" + directive @foo(name: String!) on FIELD_DEFINITION + directive @baz(name: String!, address: String) on FIELD_DEFINITION | OBJECT + "#, + r#"@foo(name: "a")"#, + ); + let subgraph_b = generate_subgraph( + "subgraphB", + r#"@link(url: "https://specs.apollo.dev/foo/v1.1", import: ["@bar"])"#, + r#"@composeDirective(name: "@bar")"#, + r#" + directive @foo(name: String!) on FIELD_DEFINITION + directive @bar(name: String!, address: String) on FIELD_DEFINITION | OBJECT + "#, + r#"@bar(name: "b")"#, + ); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + + assert_eq!(result.hints().len(), 1); + let hint = result.hints().first().unwrap(); + assert_eq!(hint.code, "DIRECTIVE_COMPOSITION_WARN"); + assert_eq!( + hint.message, + r#"Composed directive "@bar" is named differently in a subgraph that doesn't export it. Consistent naming will be required to export it."# + ); + + assert_has_directive_definition( + &result, + "directive @foo(name: String!) on FIELD_DEFINITION", + ); + assert_has_directive_definition( + &result, + "directive @bar(name: String!, address: String) on FIELD_DEFINITION | OBJECT", + ); + + let schema = result.schema().schema().to_string(); + assert!(schema.contains(r#"subgraphA: String @foo(name: "a")"#)); + assert!(schema.contains(r#"subgraphB: String @bar(name: "b")"#)); + } + + #[test] + fn errors_when_exported_but_undefined() { + let subgraph_a = generate_subgraph( + "subgraphA", + r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])"#, + r#"@composeDirective(name: "@foo")"#, + "directive @foo(name: String!) on FIELD_DEFINITION", + r#"@foo(name: "a")"#, + ); + let subgraph_b = generate_subgraph( + "subgraphB", + r#"@link(url: "https://specs.apollo.dev/foo/v1.1", import: ["@bar"])"#, + r#"@composeDirective(name: "@bar")"#, + r#" + directive @foo(name: String!) on FIELD_DEFINITION + directive @bar(name: String!, address: String) on FIELD_DEFINITION | OBJECT + "#, + r#"@bar(name: "b")"#, + ); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + r#"Core feature "https://specs.apollo.dev/foo" in subgraph "subgraphA" does not have a directive definition for "@bar""#, + ); + } + + #[test] + fn errors_when_exported_but_not_imported() { + let subgraph_a = generate_subgraph( + "subgraphA", + "", + r#"@composeDirective(name: "@foo")"#, + "directive @foo(name: String!) on FIELD_DEFINITION", + r#"@foo(name: "a")"#, + ); + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + r#"Directive "@foo" in subgraph "subgraphA" cannot be composed because it is not a member of a core feature"# + ); + } + + #[test] + fn errors_when_exported_with_mismatched_names() { + let subgraph_a = generate_subgraph( + "subgraphA", + r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])"#, + r#"@composeDirective(name: "@foo")"#, + "directive @foo(name: String!) on FIELD_DEFINITION", + r#"@foo(name: "a")"#, + ); + let subgraph_b = generate_subgraph( + "subgraphB", + r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: [{ name: "@foo", as: "@bar" }])"#, + r#"@composeDirective(name: "@bar")"#, + "directive @bar(name: String!) on FIELD_DEFINITION", + r#"@bar(name: "b")"#, + ); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + r#"Composed directive is not named consistently in all subgraphs but "@foo" in subgraph "subgraphA" and "@bar" in subgraph "subgraphB""#, + ); + } + + #[test] + fn errors_when_exported_directive_is_imported_from_different_specs() { + let subgraph_a = generate_subgraph( + "subgraphA", + r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])"#, + r#"@composeDirective(name: "@foo")"#, + "directive @foo(name: String!) on FIELD_DEFINITION", + r#"@foo(name: "a")"#, + ); + let subgraph_b = generate_subgraph( + "subgraphB", + r#"@link(url: "https://specs.apollo.dev/bar/v1.0", import: ["@foo"])"#, + r#"@composeDirective(name: "@foo")"#, + "directive @foo(name: String!) on FIELD_DEFINITION", + r#"@foo(name: "a")"#, + ); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + r#"Composed directive "@foo" is not linked by the same core feature in every subgraph"# + ); + } + + #[test] + fn errors_when_different_exported_directives_have_the_same_name() { + let subgraph_a = generate_subgraph( + "subgraphA", + r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])"#, + r#"@composeDirective(name: "@foo")"#, + "directive @foo(name: String!) on FIELD_DEFINITION", + r#"@foo(name: "a")"#, + ); + let subgraph_b = generate_subgraph( + "subgraphA", + r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: [{ name: "@bar", as: "@foo" }])"#, + r#"@composeDirective(name: "@foo")"#, + "directive @foo(name: String!) on FIELD_DEFINITION", + r#"@foo(name: "a")"#, + ); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + r#"Composed directive "@foo" does not refer to the same directive in every subgraph"# + ); + } + + #[test] + fn errors_when_exported_directives_conflict_with_federation_directives() { + let subgraph_a = Subgraph::parse("subgraphA", "", r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.1", import: ["@key", "@composeDirective"]) + @link(url: "https://specs.apollo.dev/foo/v1.0", import: [{ name: "@foo", as: "@inaccessible" }]) + @composeDirective(name: "@inaccessible") + + directive @inaccessible(name: String!) on FIELD_DEFINITION + type Query { + a: User + } + type User @key(fields: "id") { + id: Int + a: String @inaccessible(name: "a") + } + "#).unwrap(); + let subgraph_b = Subgraph::parse("subgraphB", "", r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.1", import: ["@key", "@composeDirective", "@inaccessible"]) + @link(url: "https://specs.apollo.dev/link/v1.0") + + type Query { + b: User + } + type User @key(fields: "id") { + id: Int + b: String @inaccessible + } + "#).unwrap(); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + r#"Directive "@inaccessible" in subgraph "subgraphA" cannot be composed because it conflicts with automatically composed federation directive "@inaccessible". Conflict exists in subgraph(s): (subgraphB)"# + ); + } + + #[rstest] + #[case("@join__field")] + #[case("@join__graph")] + #[case("@join__implements")] + #[case("@join__type")] + #[case("@join__unionMember")] + #[case("@join__enumValue")] + fn errors_when_exported_directives_conflict_with_join_spec_directives(#[case] directive: &str) { + let subgraph_a = generate_subgraph( + "subgraphA", + &r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: [{ name: "@foo", as: "" }])"#.replace("", directive), + &r#"@composeDirective(name: "")"#.replace("", directive), + &r#"directive (name: String!) on FIELD_DEFINITION"#.replace("", directive), + &r#"(name: "a")"#.replace("", directive), + ); + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + format!( + "Directive \"{directive}\" in subgraph \"subgraphA\" cannot be composed because it is not a member of a core feature" + ) + ); + } +} + +mod validation { + use super::*; + + #[rstest] + #[case("@composeDirective")] + #[case("@composeDirective(name: null)")] + #[case(r#"@composeDirective(name: "")"#)] + fn errors_when_name_argument_is_null_or_empty(#[case] compose_text: &str) { + let subgraph_a = generate_subgraph("subgraphA", "", compose_text, "", ""); + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + r#"Argument to @composeDirective in subgraph "subgraphA" cannot be NULL or an empty String"# + ); + } + + #[test] + fn errors_when_name_argument_is_missing_at_symbol() { + let subgraph_a = generate_subgraph( + "subgraphA", + "", + r#"@composeDirective(name: "foo")"#, + "directive @foo(name: String!) on FIELD_DEFINITION", + r#"@foo(name: "a")"#, + ); + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + r#"Argument to @composeDirective in subgraph "subgraphA" must have a leading "@""# + ); + } + + #[rstest] + #[case("@foo", "@foo", "@fooz", r#"Did you mean "@foo" or "@cost"?"#)] + #[case( + r#"{ name: "@foo", as "@bar" }"#, + "@bar", + "@barz", + r#"Did you mean "@bar" or "@tag"?"# + )] + fn errors_when_directive_does_not_exist( + #[case] import: &str, + #[case] name: &str, + #[case] usage: &str, + #[case] suggestion: &str, + ) { + let subgraph_a = generate_subgraph( + "subgraphA", + &r#"@link(url: "https://specs.apollo.dev/foo/v1.0", import: [])"# + .replace("", import), + &r#"@composeDirective(name: "")"#.replace("", name), + &r#"directive (name: String!) on FIELD_DEFINITION"#.replace("", name), + &r#"(name: "a")"#.replace("", usage), + ); + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap_err(); + assert_eq!(result.len(), 1); + let error = result.first().unwrap(); + assert_eq!( + error.code().definition().code().to_string(), + "DIRECTIVE_COMPOSITION_ERROR" + ); + assert_eq!( + error.to_string(), + r#"Could not find matching directive definition for argument to @composeDirective "" in subgraph "subgraphA". "# + .replace("", name) + .replace("", suggestion) + ); + } +} + +mod composition { + use super::*; + + #[test] + fn composes_custom_tag_directive_when_renamed() { + let subgraph_a = Subgraph::parse("subgraphA", "", r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.1", import: ["@key", "@composeDirective", "@tag"]) + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://custom.dev/tag/v1.0", import: [{ name: "@tag", as: "@mytag"}]) + @composeDirective(name: "@mytag") + + directive @mytag(name: String!, prop: String!) on FIELD_DEFINITION | OBJECT + type Query { + a: User + } + type User @key(fields: "id") { + id: Int + a: String @mytag(name: "a", prop: "b") + b: String @tag(name: "c") + } + "#).unwrap(); + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + assert_has_directive_definition( + &result, + "directive @tag(name: String!) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA", + ); + assert_has_directive_definition( + &result, + "directive @mytag(name: String!, prop: String!) on FIELD_DEFINITION | OBJECT", + ); + + let schema = result.schema().schema().to_string(); + assert!(schema.contains(r#"a: String @mytag(name: "a", prop: "b")"#)); + assert!(schema.contains(r#"b: String @tag(name: "c")"#)); + assert!(schema.contains( + r#"@link(url: "https://custom.dev/tag/v1.0", import: [{ name: "@tag", as: "@mytag"}])"# + )); + } + + #[test] + fn composes_custom_tag_when_federation_tag_is_renamed() { + let subgraph_a = Subgraph::parse("subgraphA", "", r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.1", import: ["@key", "@composeDirective", {name: "@tag", as: "@mytag"}]) + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://custom.dev/tag/v1.0", import: ["@tag"]) + @composeDirective(name: "@tag") + + directive @tag(name: String!, prop: String!) on FIELD_DEFINITION | OBJECT + type Query { + a: User + } + type User @key(fields: "id") { + id: Int + a: String @tag(name: "a", prop: "b") + b: String @mytag(name: "c") + } + "#).unwrap(); + let subgraph_b = generate_subgraph("subgraphB", "", "", "", ""); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + assert_has_directive_definition( + &result, + "directive @mytag(name: String!) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA", + ); + assert_has_directive_definition( + &result, + "directive @tag(name: String!, prop: String!) on FIELD_DEFINITION | OBJECT", + ); + + let schema = result.schema().schema().to_string(); + assert!(schema.contains(r#"a: String @tag(name: "a", prop: "b")"#)); + assert!(schema.contains(r#"b: String @mytag(name: "c")"#)); + assert!(schema.contains(r#"@link(url: "https://custom.dev/tag/v1.0", import: ["@tag"])"#)); + } + + #[test] + fn composes_repeatable_custom_directives() { + let subgraph_a = Subgraph::parse("subgraphA", "", r#" + extend schema @composeDirective(name: "@auth") + @link(url: "https://specs.apollo.dev/federation/v2.1", import: ["@key", "@composeDirective", "@shareable"]) + @link(url: "https://custom.dev/auth/v1.0", import: ["@auth"]) + directive @auth(scope: String!) repeatable on FIELD_DEFINITION + + type Query { + shared: String @shareable @auth(scope: "VIEWER") + } + "#).unwrap(); + let subgraph_b = Subgraph::parse("subgraphB", "", r#" + extend schema @composeDirective(name: "@auth") + @link(url: "https://specs.apollo.dev/federation/v2.1", import: ["@key", "@composeDirective", "@shareable"]) + @link(url: "https://custom.dev/auth/v1.0", import: ["@auth"]) + directive @auth(scope: String!) repeatable on FIELD_DEFINITION + + type Query { + shared: String @shareable @auth(scope: "ADMIN") + } + "#).unwrap(); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + let schema = result.schema().schema().to_string(); + assert!( + schema.contains( + r#"shared: String @shareable @auth(scope: "VIEWER") @auth(scope: "ADMIN")"# + ) + ) + } + + #[test] + fn composes_custom_directive_with_nullable_array_arguments() { + let subgraph_a = Subgraph::parse("subgraphA", "", r#" + extend schema @composeDirective(name: "@auth") + @link(url: "https://specs.apollo.dev/federation/v2.1", import: ["@key", "@composeDirective", "@shareable"]) + @link(url: "https://custom.dev/auth/v1.0", import: ["@auth"]) + directive @auth(scope: [String!]) repeatable on FIELD_DEFINITION + + type Query { + shared: String @shareable @auth(scope: "VIEWER") + } + "#).unwrap(); + let subgraph_b = Subgraph::parse("subgraphB", "", r#" + extend schema @composeDirective(name: "@auth") + @link(url: "https://specs.apollo.dev/federation/v2.1", import: ["@key", "@composeDirective", "@shareable"]) + @link(url: "https://custom.dev/auth/v1.0", import: ["@auth"]) + directive @auth(scope: [String!]) repeatable on FIELD_DEFINITION + + type Query { + shared: String @shareable @auth + } + "#).unwrap(); + + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + let schema = result.schema().schema().to_string(); + assert!(schema.contains(r#"shared: String @shareable @auth(scope: ["VIEWER"]) @auth"#)); + } +} + +fn generate_subgraph( + name: &str, + link_text: &str, + compose_text: &str, + directive_text: &str, + usage: &str, +) -> Subgraph { + let schema = r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key", "@composeDirective"]) + + + + + type Query { + : User + } + + type User @key(fields: "id") { + id: Int + : String + } + "# + .replace("", link_text) + .replace("", compose_text) + .replace("", directive_text) + .replace("", name) + .replace("", usage); + + Subgraph::parse(name, "", schema.as_str()).unwrap() +} + +fn assert_has_directive_definition( + supergraph: &Supergraph, + expected_definition: &str, +) { + let directive_name = expected_definition + .chars() + .skip_while(|x| *x != '@') + .skip(1) + .take_while(|x| *x != '(' && !x.is_whitespace()) + .collect::(); + let directive_name = apollo_compiler::Name::new_unchecked(directive_name.as_str()); + let definition = supergraph + .schema() + .schema() + .directive_definitions + .get(&directive_name) + .unwrap() + .to_string(); + assert_eq!(definition, expected_definition) +} diff --git a/apollo-federation/tests/composition/demand_control.rs b/apollo-federation/tests/composition/demand_control.rs new file mode 100644 index 0000000000..cfb4732694 --- /dev/null +++ b/apollo-federation/tests/composition/demand_control.rs @@ -0,0 +1,440 @@ +use apollo_federation::composition::compose; +use apollo_federation::error::ErrorCode; +use apollo_federation::subgraph::typestate::Initial; +use apollo_federation::subgraph::typestate::Subgraph; + +fn subgraph_with_cost() -> Subgraph { + Subgraph::parse( + "subgraphWithCost", + "", + r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) + + enum AorB @cost(weight: 15) { + A + B + } + + input InputTypeWithCost { + somethingWithCost: Int @cost(weight: 20) + } + + scalar ExpensiveInt @cost(weight: 30) + + type ExpensiveObject @cost(weight: 40) { + id: ID + } + + type Query { + fieldWithCost: Int @cost(weight: 5) + argWithCost(arg: Int @cost(weight: 10)): Int + enumWithCost: AorB + inputWithCost(someInput: InputTypeWithCost): Int + scalarWithCost: ExpensiveInt + objectWithCost: ExpensiveObject + } +"#, + ) + .unwrap() +} + +fn subgraph_with_listsize() -> Subgraph { + Subgraph::parse("subgraphWithListSize", "", r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) + + type HasInts { + ints: [Int!] + } + + type Query { + fieldWithListSize: [String!] @listSize(assumedSize: 2000, requireOneSlicingArgument: false) + fieldWithDynamicListSize(first: Int!): HasInts @listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true) + } + "#).unwrap() +} + +fn subgraph_with_renamed_cost() -> Subgraph { + Subgraph::parse("subgraphWithCost", "", r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/cost/v0.1", import: [{ name: "@cost", as: "@renamedCost" }]) + + enum AorB @renamedCost(weight: 15) { + A + B + } + + input InputTypeWithCost { + somethingWithCost: Int @renamedCost(weight: 20) + } + + scalar ExpensiveInt @renamedCost(weight: 30) + + type ExpensiveObject @renamedCost(weight: 40) { + id: ID + } + + type Query { + fieldWithCost: Int @renamedCost(weight: 5) + argWithCost(arg: Int @renamedCost(weight: 10)): Int + enumWithCost: AorB + inputWithCost(someInput: InputTypeWithCost): Int + scalarWithCost: ExpensiveInt + objectWithCost: ExpensiveObject + } + "#).unwrap().into_fed2_subgraph().unwrap() +} + +fn subgraph_with_renamed_listsize() -> Subgraph { + Subgraph::parse("subgraphWithListSize", "", r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/cost/v0.1", import: [{ name: "@listSize", as: "@renamedListSize" }]) + + type HasInts { + ints: [Int!] @shareable + } + + type Query { + fieldWithListSize: [String!] @renamedListSize(assumedSize: 2000, requireOneSlicingArgument: false) + fieldWithDynamicListSize(first: Int!): HasInts @renamedListSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true) + } + "#).unwrap().into_fed2_subgraph().unwrap() +} + +fn subgraph_with_cost_from_federation_spec() -> Subgraph { + Subgraph::parse( + "subgraphWithCost", + "", + r#" + enum AorB @cost(weight: 15) { + A + B + } + + input InputTypeWithCost { + somethingWithCost: Int @cost(weight: 20) + } + + scalar ExpensiveInt @cost(weight: 30) + + type ExpensiveObject @cost(weight: 40) { + id: ID + } + + type Query { + fieldWithCost: Int @cost(weight: 5) + argWithCost(arg: Int @cost(weight: 10)): Int + enumWithCost: AorB + inputWithCost(someInput: InputTypeWithCost): Int + scalarWithCost: ExpensiveInt + objectWithCost: ExpensiveObject + } + "#, + ) + .unwrap() + .into_fed2_test_subgraph(true) + .unwrap() +} + +fn subgraph_with_listsize_from_federation_spec() -> Subgraph { + Subgraph::parse("subgraphWithListSize", "", r#" + type HasInts { + ints: [Int!] + } + + type Query { + fieldWithListSize: [String!] @listSize(assumedSize: 2000, requireOneSlicingArgument: false) + fieldWithDynamicListSize(first: Int!): HasInts @listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true) + } + "#).unwrap().into_fed2_test_subgraph(true).unwrap() +} + +fn subgraph_with_renamed_cost_from_federation_spec() -> Subgraph { + Subgraph::parse("subgraphWithCost", "", r#" + extend schema @link(url: "https://specs.apollo.dev/federation/v2.9", import: [{ name: "@cost", as: "@renamedCost" }]) + + enum AorB @renamedCost(weight: 15) { + A + B + } + + input InputTypeWithCost { + somethingWithCost: Int @renamedCost(weight: 20) + } + + scalar ExpensiveInt @renamedCost(weight: 30) + + type ExpensiveObject @renamedCost(weight: 40) { + id: ID + } + + type Query { + fieldWithCost: Int @renamedCost(weight: 5) + argWithCost(arg: Int @renamedCost(weight: 10)): Int + enumWithCost: AorB + inputWithCost(someInput: InputTypeWithCost): Int + scalarWithCost: ExpensiveInt + objectWithCost: ExpensiveObject + } + "#).unwrap() +} + +fn subgraph_with_renamed_listsize_from_federation_spec() -> Subgraph { + Subgraph::parse("subgraphWithListSize", "", r#" + extend schema @link(url: "https://specs.apollo.dev/federation/v2.9", import: [{ name: "@listSize", as: "@renamedListSize" }]) + + type HasInts { + ints: [Int!] + } + + type Query { + fieldWithListSize: [String!] @renamedListSize(assumedSize: 2000, requireOneSlicingArgument: false) + fieldWithDynamicListSize(first: Int!): HasInts @renamedListSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true) + } + "#).unwrap() +} + +fn subgraph_with_unimported_cost() -> Subgraph { + Subgraph::parse( + "subgraphWithCost", + "", + r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.9") + + enum AorB @federation__cost(weight: 15) { + A + B + } + + input InputTypeWithCost { + somethingWithCost: Int @federation__cost(weight: 20) + } + + scalar ExpensiveInt @federation__cost(weight: 30) + + type ExpensiveObject @federation__cost(weight: 40) { + id: ID + } + + type Query { + fieldWithCost: Int @federation__cost(weight: 5) + argWithCost(arg: Int @federation__cost(weight: 10)): Int + enumWithCost: AorB + inputWithCost(someInput: InputTypeWithCost): Int + scalarWithCost: ExpensiveInt + objectWithCost: ExpensiveObject + } + "#, + ) + .unwrap() +} + +fn subgraph_with_unimported_listsize() -> Subgraph { + Subgraph::parse("subgraphWithListSize", "", r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.9") + + type HasInts { + ints: [Int!] + } + + type Query { + fieldWithListSize: [String!] @federation__listSize(assumedSize: 2000, requireOneSlicingArgument: false) + fieldWithDynamicListSize(first: Int!): HasInts @federation__listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true) + } + "#).unwrap() +} + +#[ignore = "until merge implementation completed"] +#[test] +fn composes_directives_imported_from_cost_spec() { + let result = compose(vec![subgraph_with_cost(), subgraph_with_listsize()]).unwrap(); + + assert!(result.hints().is_empty()); + insta::assert_snapshot!(result.schema().schema()); +} + +#[ignore = "until merge implementation completed"] +#[test] +fn composes_directives_imported_from_federation_spec() { + let result = compose(vec![ + subgraph_with_cost_from_federation_spec(), + subgraph_with_listsize_from_federation_spec(), + ]) + .unwrap(); + + assert!(result.hints().is_empty()); + insta::assert_snapshot!(result.schema().schema()); +} + +#[ignore = "until merge implementation completed"] +#[test] +fn composes_renamed_directives_imported_from_cost_spec() { + let result = compose(vec![ + subgraph_with_renamed_cost(), + subgraph_with_renamed_listsize(), + ]) + .unwrap(); + + assert!(result.hints().is_empty()); + insta::assert_snapshot!(result.schema().schema()); +} + +#[ignore = "until merge implementation completed"] +#[test] +fn composes_renamed_directives_imported_from_federation_spec() { + let result = compose(vec![ + subgraph_with_renamed_cost_from_federation_spec(), + subgraph_with_renamed_listsize_from_federation_spec(), + ]) + .unwrap(); + + assert!(result.hints().is_empty()); + insta::assert_snapshot!(result.schema().schema()); +} + +#[ignore = "until merge implementation completed"] +#[test] +fn composes_fully_qualified_directive_names() { + let result = compose(vec![ + subgraph_with_unimported_cost(), + subgraph_with_unimported_listsize(), + ]) + .unwrap(); + + assert!(result.hints().is_empty()); + insta::assert_snapshot!(result.schema().schema()); +} + +#[ignore = "until merge implementation completed"] +#[test] +fn errors_when_subgraphs_use_different_names() { + let subgraph_with_default_name = Subgraph::parse( + "subgraphWithDefaultName", + "", + r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) + + type Query { + field1: Int @cost(weight: 5) + } + "#, + ) + .unwrap(); + let subgraph_with_different_name = Subgraph::parse("subgraphWithDifferentName", "", r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/cost/v0.1", import: [{ name: "@cost", as: "@renamedCost" }]) + + type Query { + field2: Int @renamedCost(weight: 10) + } + "#).unwrap(); + let errors = compose(vec![ + subgraph_with_default_name, + subgraph_with_different_name, + ]) + .unwrap_err(); + + assert_eq!(errors.len(), 1); + let error = errors.first().unwrap(); + assert_eq!(error.code(), ErrorCode::LinkImportNameMismatch); + assert_eq!( + error.to_string(), + r#"The "@cost" directive (from https://specs.apollo.dev/cost/v0.1) is imported with mismatched name between subgraphs: it is imported as "@renamedCost" in subgraph "subgraphWithDifferentName" but "@cost" in subgraph "subgraphWithDefaultName""# + ) +} + +#[ignore = "until merge implementation completed"] +#[test] +fn hints_when_merging_cost_arguments() { + let subgraph_a = Subgraph::parse( + "subgraph-a", + "", + r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) + + type Query { + sharedWithCost: Int @shareable @cost(weight: 5) + } + "#, + ) + .unwrap(); + let subgraph_b = Subgraph::parse( + "subgraph-b", + "", + r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) + + type Query { + sharedWithCost: Int @shareable @cost(weight: 10) + } + "#, + ) + .unwrap(); + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + + assert_eq!(result.hints().len(), 1); + let hint = result.hints().first().unwrap(); + assert_eq!(hint.code(), "MERGED_NON_REPEATABLE_DIRECTIVE_ARGUMENTS"); + assert_eq!( + hint.message(), + r#"Directive @cost is applied to "Query.sharedWithCost" in multiple subgraphs with different arguments. Merging strategies used by arguments: { "weight": MAX }""# + ); +} + +#[ignore = "until merge implementation completed"] +#[test] +fn hints_when_merging_listsize_arguments() { + let subgraph_a = Subgraph::parse( + "subgraph-a", + "", + r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) + + type Query { + sharedWithListSize: [Int] @shareable @listSize(assumedSize: 10) + } + "#, + ) + .unwrap(); + let subgraph_b = Subgraph::parse( + "subgraph-b", + "", + r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) + + type Query { + sharedWithListSize: [Int] @shareable @listSize(assumedSize: 20) + } + "#, + ) + .unwrap(); + let result = compose(vec![subgraph_a, subgraph_b]).unwrap(); + + assert_eq!(result.hints().len(), 1); + let hint = result.hints().first().unwrap(); + assert_eq!(hint.code(), "MERGED_NON_REPEATABLE_DIRECTIVE_ARGUMENTS"); + assert_eq!( + hint.message(), + r#"Directive @listSize is applied to "Query.sharedWithListSize" in multiple subgraphs with different arguments. Merging strategies used by arguments: { "assumedSize": NULLABLE_MAX, "slicingArguments": NULLABLE_UNION, "sizedFields": NULLABLE_UNION, "requireOneSlicingArgument": NULLABLE_AND }"# + ) +} diff --git a/apollo-federation/tests/composition/mod.rs b/apollo-federation/tests/composition/mod.rs new file mode 100644 index 0000000000..1c0a23cecf --- /dev/null +++ b/apollo-federation/tests/composition/mod.rs @@ -0,0 +1,62 @@ +// TODO: Enable this test module when @composeDirective logic is implemented in FED-645 +// mod compose_directive; +mod demand_control; +mod validation_errors; + +pub(crate) mod test_helpers { + use apollo_federation::composition::compose; + use apollo_federation::error::CompositionError; + use apollo_federation::subgraph::typestate::Subgraph; + use apollo_federation::supergraph::Satisfiable; + use apollo_federation::supergraph::Supergraph; + + pub(crate) struct ServiceDefinition<'a> { + pub(crate) name: &'a str, + pub(crate) type_defs: &'a str, + } + + /// Composes a set of subgraphs as if they had the latest federation 2 spec link in them. + /// Also, all federation directives are automatically imported. + // PORT_NOTE: This function corresponds to `composeAsFed2Subgraphs` in JS implementation. + pub(crate) fn compose_as_fed2_subgraphs( + service_list: &[ServiceDefinition<'_>], + ) -> Result, Vec> { + let mut subgraphs = Vec::new(); + let mut errors = Vec::new(); + for service in service_list { + let result = Subgraph::parse( + service.name, + &format!("http://{}", service.name), + service.type_defs, + ); + match result { + Ok(subgraph) => { + subgraphs.push(subgraph); + } + Err(err) => { + errors.extend(err.to_composition_errors()); + } + } + } + if !errors.is_empty() { + return Err(errors); + } + + // PORT_NOTE: This statement corresponds to `asFed2Service` function in JS. + let mut fed2_subgraphs = Vec::new(); + for subgraph in subgraphs { + match subgraph.into_fed2_test_subgraph(true) { + Ok(subgraph) => fed2_subgraphs.push(subgraph), + Err(err) => errors.extend(err.to_composition_errors()), + } + } + if !errors.is_empty() { + return Err(errors); + } + + compose(fed2_subgraphs) + } +} + +pub(crate) use test_helpers::ServiceDefinition; +pub(crate) use test_helpers::compose_as_fed2_subgraphs; diff --git a/apollo-federation/tests/composition/validation_errors.rs b/apollo-federation/tests/composition/validation_errors.rs new file mode 100644 index 0000000000..2c1e097554 --- /dev/null +++ b/apollo-federation/tests/composition/validation_errors.rs @@ -0,0 +1,558 @@ +use apollo_federation::error::CompositionError; +use apollo_federation::supergraph::Supergraph; + +use super::ServiceDefinition; +use super::compose_as_fed2_subgraphs; + +fn error_messages(result: &Result, Vec>) -> Vec { + match result { + Ok(_) => panic!("Expected an error, but got a successful composition"), + Err(err) => err.iter().map(|e| e.to_string()).collect(), + } +} +mod requires_tests { + use super::*; + + #[test] + // TODO + #[should_panic( + expected = "not yet implemented: Implement compose directive manager validation" + )] + fn fails_if_it_cannot_satisfy_a_requires() { + let subgraph_a = ServiceDefinition { + name: "A", + type_defs: r#" + type Query { + a: A + } + + type A @key(fields: "id") { + id: ID! + x: Int + } + "#, + }; + + let subgraph_b = ServiceDefinition { + name: "B", + type_defs: r#" + type A @key(fields: "id") { + id: ID! @external + x: Int @external + y: Int @requires(fields: "x") + z: Int @requires(fields: "x") + } + "#, + }; + + let result = compose_as_fed2_subgraphs(&[subgraph_a, subgraph_b]); + let messages = error_messages(&result); + assert_eq!( + messages, + [ + r#" + The following supergraph API query: + { + a { + y + } + } + cannot be satisfied by the subgraphs because: + - from subgraph "A": cannot find field "A.y". + - from subgraph "B": cannot satisfy @require conditions on field "A.y" (please ensure that this is not due to key field "id" being accidentally marked @external). + "#, + r#" + The following supergraph API query: + { + a { + z + } + } + cannot be satisfied by the subgraphs because: + - from subgraph "A": cannot find field "A.z". + - from subgraph "B": cannot satisfy @require conditions on field "A.z" (please ensure that this is not due to key field "id" being accidentally marked @external). + "#, + ] + ); + } + + #[test] + // TODO + #[should_panic( + expected = "not yet implemented: Implement compose directive manager validation" + )] + fn fails_if_no_usable_post_requires_keys() { + let subgraph_a = ServiceDefinition { + name: "A", + type_defs: r#" + type T1 @key(fields: "id") { + id: Int! + f1: String + } + "#, + }; + + let subgraph_b = ServiceDefinition { + name: "B", + type_defs: r#" + type Query { + getT1s: [T1] + } + + type T1 { + id: Int! @shareable + f1: String @external + f2: T2! @requires(fields: "f1") + } + + type T2 { + a: String + } + "#, + }; + + let result = compose_as_fed2_subgraphs(&[subgraph_a, subgraph_b]); + let messages = error_messages(&result); + assert_eq!( + messages, + [r#" + The following supergraph API query: + { + getT1s { + f2 { + ... + } + } + } + cannot be satisfied by the subgraphs because: + - from subgraph "B": @require condition on field "T1.f2" can be satisfied but missing usable key on "T1" in subgraph "B" to resume query. + - from subgraph "A": cannot find field "T1.f2". + "#] + ); + } +} + +mod non_resolvable_keys_tests { + use super::*; + + #[test] + // TODO + #[should_panic( + expected = "not yet implemented: Implement compose directive manager validation" + )] + fn fails_if_key_is_declared_non_resolvable_but_would_be_needed() { + let subgraph_a = ServiceDefinition { + name: "A", + type_defs: r#" + type T @key(fields: "id", resolvable: false) { + id: ID! + f: String + } + "#, + }; + + let subgraph_b = ServiceDefinition { + name: "B", + type_defs: r#" + type Query { + getTs: [T] + } + + type T @key(fields: "id") { + id: ID! + } + "#, + }; + + let result = compose_as_fed2_subgraphs(&[subgraph_a, subgraph_b]); + let messages = error_messages(&result); + assert_eq!( + messages, + [r#" + The following supergraph API query: + { + getTs { + f + } + } + cannot be satisfied by the subgraphs because: + - from subgraph "B": + - cannot find field "T.f". + - cannot move to subgraph "A", which has field "T.f", because none of the @key defined on type "T" in subgraph "A" are resolvable (they are all declared with their "resolvable" argument set to false). + "#] + ); + } +} + +mod interface_object_tests { + use super::*; + + #[test] + // TODO + #[should_panic( + expected = "not yet implemented: Implement compose directive manager validation" + )] + fn fails_on_interface_object_usage_with_missing_key_on_interface() { + let subgraph_a = ServiceDefinition { + name: "subgraphA", + type_defs: r#" + interface I { + id: ID! + x: Int + } + + type A implements I @key(fields: "id") { + id: ID! + x: Int + } + + type B implements I @key(fields: "id") { + id: ID! + x: Int + } + "#, + }; + + let subgraph_b = ServiceDefinition { + name: "subgraphB", + type_defs: r#" + type Query { + iFromB: I + } + + type I @interfaceObject @key(fields: "id") { + id: ID! + y: Int + } + "#, + }; + + let result = compose_as_fed2_subgraphs(&[subgraph_a, subgraph_b]); + let messages = error_messages(&result); + assert_eq!( + messages, + [ + r#" + The following supergraph API query: + { + iFromB { + ... on A { + ... + } + } + } + cannot be satisfied by the subgraphs because: + - from subgraph "subgraphB": no subgraph can be reached to resolve the implementation type of @interfaceObject type "I". + "#, + r#" + The following supergraph API query: + { + iFromB { + ... on B { + ... + } + } + } + cannot be satisfied by the subgraphs because: + - from subgraph "subgraphB": no subgraph can be reached to resolve the implementation type of @interfaceObject type "I". + "# + ] + ); + } + + #[test] + // TODO + #[should_panic( + expected = "not yet implemented: Implement compose directive manager validation" + )] + fn fails_on_interface_object_with_some_unreachable_implementation() { + let subgraph_a = ServiceDefinition { + name: "subgraphA", + type_defs: r#" + interface I @key(fields: "id") { + id: ID! + x: Int + } + + type A implements I @key(fields: "id") { + id: ID! + x: Int + } + + type B implements I @key(fields: "id") { + id: ID! + x: Int + } + "#, + }; + + let subgraph_b = ServiceDefinition { + name: "subgraphB", + type_defs: r#" + type Query { + iFromB: I + } + + type I @interfaceObject @key(fields: "id") { + id: ID! + y: Int + } + "#, + }; + + let subgraph_c = ServiceDefinition { + name: "subgraphC", + type_defs: r#" + type A { + z: Int + } + "#, + }; + + let result = compose_as_fed2_subgraphs(&[subgraph_a, subgraph_b, subgraph_c]); + let messages = error_messages(&result); + assert_eq!( + messages, + [r#" + The following supergraph API query: + { + iFromB { + ... on A { + z + } + } + } + cannot be satisfied by the subgraphs because: + - from subgraph "subgraphB": + - cannot find implementation type "A" (supergraph interface "I" is declared with @interfaceObject in "subgraphB"). + - cannot move to subgraph "subgraphC", which has field "A.z", because interface "I" is not defined in this subgraph (to jump to "subgraphC", it would need to both define interface "I" and have a @key on it). + - from subgraph "subgraphA": + - cannot find field "A.z". + - cannot move to subgraph "subgraphC", which has field "A.z", because type "A" has no @key defined in subgraph "subgraphC". + "#] + ); + } +} + +// when shared field has non-intersecting runtime types in different subgraphs +mod shared_field_runtime_types_tests { + use super::*; + + #[test] + // TODO + #[should_panic( + expected = "not yet implemented: Implement compose directive manager validation" + )] + fn errors_for_interfaces() { + let subgraph_a = ServiceDefinition { + name: "A", + type_defs: r#" + type Query { + a: A @shareable + } + + interface A { + x: Int + } + + type I1 implements A { + x: Int + i1: Int + } + "#, + }; + + let subgraph_b = ServiceDefinition { + name: "B", + type_defs: r#" + type Query { + a: A @shareable + } + + interface A { + x: Int + } + + type I2 implements A { + x: Int + i2: Int + } + "#, + }; + + let result = compose_as_fed2_subgraphs(&[subgraph_a, subgraph_b]); + let messages = error_messages(&result); + assert_eq!( + messages, + [r#" + For the following supergraph API query: + { + a { + ... + } + } + Shared field "Query.a" return type "A" has a non-intersecting set of possible runtime types across subgraphs. Runtime types in subgraphs are: + - in subgraph "A", type "I1"; + - in subgraph "B", type "I2". + This is not allowed as shared fields must resolve the same way in all subgraphs, and that imply at least some common runtime types between the subgraphs. + "#] + ); + } + + #[test] + // TODO + #[should_panic( + expected = "not yet implemented: Implement compose directive manager validation" + )] + fn errors_for_unions() { + let subgraph_a = ServiceDefinition { + name: "A", + type_defs: r#" + type Query { + e: E! @shareable + } + + type E @key(fields: "id") { + id: ID! + s: U! @shareable + } + + union U = A | B + + type A { + a: Int + } + + type B { + b: Int + } + "#, + }; + + let subgraph_b = ServiceDefinition { + name: "B", + type_defs: r#" + type E @key(fields: "id") { + id: ID! + s: U! @shareable + } + + union U = C | D + + type C { + c: Int + } + + type D { + d: Int + } + "#, + }; + + let result = compose_as_fed2_subgraphs(&[subgraph_a, subgraph_b]); + let messages = error_messages(&result); + assert_eq!( + messages, + [r#" + For the following supergraph API query: + { + e { + s { + ... + } + } + } + Shared field "E.s" return type "U!" has a non-intersecting set of possible runtime types across subgraphs. Runtime types in subgraphs are: + - in subgraph "A", types "A" and "B"; + - in subgraph "B", types "C" and "D". + This is not allowed as shared fields must resolve the same way in all subgraphs, and that imply at least some common runtime types between the subgraphs. + "#] + ); + } +} + +mod other_validation_errors_tests { + use super::*; + + #[test] + // TODO + #[should_panic( + expected = "not yet implemented: Implement compose directive manager validation" + )] + fn errors_when_max_validation_subgraph_paths_is_exceeded() { + let subgraph_a = ServiceDefinition { + name: "A", + type_defs: r#" + type Query { + a: A + } + + type A @key(fields: "id") { + id: ID! + b: B + c: C + d: D + } + + type B @key(fields: "id") { + id: ID! + a: A @shareable + b: Int @shareable + c: C @shareable + d: D @shareable + } + + type C @key(fields: "id") { + id: ID! + a: A @shareable + b: B @shareable + c: Int @shareable + d: D @shareable + } + + type D @key(fields: "id") { + id: ID! + a: A @shareable + b: B @shareable + c: C @shareable + d: Int @shareable + } + "#, + }; + + let subgraph_b = ServiceDefinition { + name: "B", + type_defs: r#" + type B @key(fields: "id") { + id: ID! + b: Int @shareable + c: C @shareable + d: D @shareable + } + + type C @key(fields: "id") { + id: ID! + b: B @shareable + c: Int @shareable + d: D @shareable + } + + type D @key(fields: "id") { + id: ID! + b: B @shareable + c: C @shareable + d: Int @shareable + } + "#, + }; + + let result = compose_as_fed2_subgraphs(&[subgraph_a, subgraph_b]); + let messages = error_messages(&result); + assert_eq!( + messages, + [r#" + Maximum number of validation subgraph paths exceeded: 12 + "#] + ); + } +} diff --git a/apollo-federation/tests/composition_tests.rs b/apollo-federation/tests/composition_tests.rs index 44a234c2e5..60b27c9fb2 100644 --- a/apollo-federation/tests/composition_tests.rs +++ b/apollo-federation/tests/composition_tests.rs @@ -1,6 +1,6 @@ use apollo_compiler::Schema; -use apollo_federation::subgraph::Subgraph; use apollo_federation::Supergraph; +use apollo_federation::subgraph::Subgraph; fn print_sdl(schema: &Schema) -> String { let mut schema = schema.clone(); diff --git a/apollo-federation/tests/core_test.rs b/apollo-federation/tests/core_test.rs new file mode 100644 index 0000000000..51e8752974 --- /dev/null +++ b/apollo-federation/tests/core_test.rs @@ -0,0 +1,422 @@ +// PORT_NOTE: This file ports `gateway-js/src/core/__tests__/core.test.ts`. +use apollo_federation::Supergraph; + +mod core_v0_1 { + use super::*; + + #[test] + fn throws_no_errors_when_using_a_valid_core_v0_1_document() { + let sdl = r#" + schema + @core(feature: "https://specs.apollo.dev/core/v0.1") + @core(feature: "https://specs.apollo.dev/join/v0.1") { + query: Query + } + + directive @core(feature: String!) repeatable on SCHEMA + + directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + ) on FIELD_DEFINITION + + directive @join__type( + graph: join__Graph! + key: join__FieldSet + ) repeatable on OBJECT | INTERFACE + + directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @tag( + name: String! + ) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + enum CacheControlScope { + PRIVATE + PUBLIC + } + + scalar join__FieldSet + + enum join__Graph { + WORLD @join__graph(name: "world", url: "https://world.api.com.invalid") + } + + type Query { + hello: String! @join__field(graph: WORLD) + } + "#; + Supergraph::new(sdl).expect("parse and validate"); + } + + #[test] + fn throws_error_when_for_argument_is_used_in_core_v0_1_document() { + let sdl = r#" + schema + @core(feature: "https://specs.apollo.dev/core/v0.1") + @core(feature: "https://specs.apollo.dev/join/v0.1", for: EXECUTION) + @core( + feature: "https://specs.apollo.dev/something-unsupported/v0.1" + for: SECURITY + ) { + query: Query + } + + directive @core(feature: String!) repeatable on SCHEMA + + directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + ) on FIELD_DEFINITION + + directive @join__type( + graph: join__Graph! + key: join__FieldSet + ) repeatable on OBJECT | INTERFACE + + directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @tag( + name: String! + ) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + enum CacheControlScope { + PRIVATE + PUBLIC + } + + scalar join__FieldSet + + enum join__Graph { + WORLD @join__graph(name: "world", url: "https://world.api.com.invalid") + } + + type Query { + hello: String! @join__field(graph: WORLD) + } + "#; + + let err = Supergraph::new(sdl).expect_err("parsing schema with unsupported feature"); + // PORT_NOTE: The JS version delays schema validation so `checkFeatureSupport` function can + // specialize the error for core v0.1. However, in Rust version, the schema + // validation happens eagerly and generates the error below without + // specialization. + insta::assert_snapshot!( + err.to_string(), + @r###" + The following errors occurred: + - Error: the argument `for` is not supported by `@core` + ╭─[ schema.graphql:4:70 ] + │ + 4 │ @core(feature: "https://specs.apollo.dev/join/v0.1", for: EXECUTION) + │ ──────────────────────────────────┬─────────────────────────┬─────── + │ ╰─────────────────────────────────── @core defined here + │ │ + │ ╰───────── argument by this name not found + ───╯ + + - Error: the argument `for` is not supported by `@core` + ╭─[ schema.graphql:7:21 ] + │ + 5 │ ╭─▶ @core( + ┆ ┆ + 7 │ │ for: SECURITY + │ │ ──────┬────── + │ │ ╰──────── argument by this name not found + 8 │ ├─▶ ) { + │ │ + │ ╰───────────────────────── @core defined here + ───╯ + "###, + ); + } +} + +mod core_v0_2 { + use super::*; + + #[test] + fn does_not_throw_errors_when_using_supported_features() { + let sdl = r#" + schema + @core(feature: "https://specs.apollo.dev/core/v0.2") + @core(feature: "https://specs.apollo.dev/join/v0.1", for: EXECUTION) + @core(feature: "https://specs.apollo.dev/tag/v0.2") { + query: Query + } + + directive @core( + feature: String! + as: String + for: core__Purpose + ) repeatable on SCHEMA + + directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + ) on FIELD_DEFINITION + + directive @join__type( + graph: join__Graph! + key: join__FieldSet + ) repeatable on OBJECT | INTERFACE + + directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @tag( + name: String! + ) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + enum CacheControlScope { + PRIVATE + PUBLIC + } + + enum core__Purpose { + """ + `EXECUTION` features provide metadata necessary to for operation execution. + """ + EXECUTION + + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + } + + scalar join__FieldSet + + enum join__Graph { + WORLD @join__graph(name: "world", url: "https://world.api.com.invalid") + } + + type Query { + hello: String! @join__field(graph: WORLD) + } + "#; + + Supergraph::new(sdl).expect("parse and validate"); + } + + #[test] + fn does_not_throw_errors_when_using_unsupported_features_with_no_for_argument() { + let sdl = r#" + schema + @core(feature: "https://specs.apollo.dev/core/v0.2") + @core(feature: "https://specs.apollo.dev/join/v0.1", for: EXECUTION) + @core(feature: "https://specs.apollo.dev/tag/v0.2") + @core(feature: "https://specs.apollo.dev/unsupported-feature/v0.1") { + query: Query + } + + directive @core( + feature: String! + as: String + for: core__Purpose + ) repeatable on SCHEMA + + directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + ) on FIELD_DEFINITION + + directive @join__type( + graph: join__Graph! + key: join__FieldSet + ) repeatable on OBJECT | INTERFACE + + directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @tag( + name: String! + ) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + enum CacheControlScope { + PRIVATE + PUBLIC + } + + enum core__Purpose { + """ + `EXECUTION` features provide metadata necessary to for operation execution. + """ + EXECUTION + + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + } + + scalar join__FieldSet + + enum join__Graph { + WORLD @join__graph(name: "world", url: "https://world.api.com.invalid") + } + + type Query { + hello: String! @join__field(graph: WORLD) + } + "#; + + Supergraph::new(sdl).expect("parse and validate"); + } + + #[test] + fn throws_errors_when_using_unsupported_features_for_execution() { + let sdl = r#" + schema + @core(feature: "https://specs.apollo.dev/core/v0.2") + @core(feature: "https://specs.apollo.dev/join/v0.1", for: EXECUTION) + @core( + feature: "https://specs.apollo.dev/unsupported-feature/v0.1" + for: EXECUTION + ) { + query: Query + } + + directive @core( + feature: String! + as: String + for: core__Purpose + ) repeatable on SCHEMA + + directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + ) on FIELD_DEFINITION + + directive @join__type( + graph: join__Graph! + key: join__FieldSet + ) repeatable on OBJECT | INTERFACE + + directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @tag( + name: String! + ) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + enum CacheControlScope { + PRIVATE + PUBLIC + } + + enum core__Purpose { + """ + `EXECUTION` features provide metadata necessary to for operation execution. + """ + EXECUTION + + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + } + + scalar join__FieldSet + + enum join__Graph { + WORLD @join__graph(name: "world", url: "https://world.api.com.invalid") + } + + type Query { + hello: String! @join__field(graph: WORLD) + } + "#; + + let err = Supergraph::new(sdl).expect_err("should error on validation"); + assert_eq!( + err.to_string(), + "feature https://specs.apollo.dev/unsupported-feature/v0.1 is for: EXECUTION but is unsupported", + ); + } + + #[test] + fn throws_errors_when_using_unsupported_features_for_security() { + let sdl = r#" + schema + @core(feature: "https://specs.apollo.dev/core/v0.2") + @core(feature: "https://specs.apollo.dev/join/v0.1", for: EXECUTION) + @core( + feature: "https://specs.apollo.dev/unsupported-feature/v0.1" + for: SECURITY + ) { + query: Query + } + + directive @core( + feature: String! + as: String + for: core__Purpose + ) repeatable on SCHEMA + + directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + ) on FIELD_DEFINITION + + directive @join__type( + graph: join__Graph! + key: join__FieldSet + ) repeatable on OBJECT | INTERFACE + + directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @tag( + name: String! + ) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + enum CacheControlScope { + PRIVATE + PUBLIC + } + + enum core__Purpose { + """ + `EXECUTION` features provide metadata necessary to for operation execution. + """ + EXECUTION + + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + } + + scalar join__FieldSet + + enum join__Graph { + WORLD @join__graph(name: "world", url: "https://world.api.com.invalid") + } + + type Query { + hello: String! @join__field(graph: WORLD) + } + "#; + + let err = Supergraph::new(sdl).expect_err("should error on validation"); + assert_eq!( + err.to_string(), + "feature https://specs.apollo.dev/unsupported-feature/v0.1 is for: SECURITY but is unsupported", + ); + } +} diff --git a/apollo-federation/tests/dhat_profiling/connectors_validation.rs b/apollo-federation/tests/dhat_profiling/connectors_validation.rs new file mode 100644 index 0000000000..eab51ebe16 --- /dev/null +++ b/apollo-federation/tests/dhat_profiling/connectors_validation.rs @@ -0,0 +1,24 @@ +#[global_allocator] +pub(crate) static ALLOC: dhat::Alloc = dhat::Alloc; + +// Failure of the test can be diagnosed using the dhat-heap.json file. + +// These values should be kept slightly larger (~10%) than the current heap usage to catch +// significant increases. +#[test] +fn valid_large_body() { + const SCHEMA: &str = "src/connectors/validation/test_data/valid_large_body.graphql"; + + const MAX_BYTES: usize = 204_800; // 200 KiB + const MAX_ALLOCATIONS: u64 = 22_500; + + let schema = std::fs::read_to_string(SCHEMA).unwrap(); + + let _profiler = dhat::Profiler::builder().testing().build(); + + apollo_federation::connectors::validation::validate(schema, SCHEMA); + + let stats = dhat::HeapStats::get(); + dhat::assert!(stats.max_bytes < MAX_BYTES); + dhat::assert!(stats.total_blocks < MAX_ALLOCATIONS); +} diff --git a/apollo-federation/tests/dhat_profiling/query_plan.rs b/apollo-federation/tests/dhat_profiling/query_plan.rs new file mode 100644 index 0000000000..d87f0cee8d --- /dev/null +++ b/apollo-federation/tests/dhat_profiling/query_plan.rs @@ -0,0 +1,97 @@ +#[global_allocator] +pub(crate) static ALLOC: dhat::Alloc = dhat::Alloc; + +// Failure of the test can be diagnosed using the dhat-heap.json file. + +// The figures have a 5% buffer from the actual profiling stats. This +// should help us keep an eye on allocation increases, (hopefully) without +// too much flakiness. +#[test] +fn valid_query_plan() { + const SCHEMA: &str = "../examples/graphql/supergraph.graphql"; + const OPERATION: &str = "query fetchUser { + me { + id + name + username + reviews { + ...reviews + } + } + recommendedProducts { + ...products + } + topProducts { + ...products + } + } + fragment products on Product { + upc + weight + price + shippingEstimate + reviews { + ...reviews + } + } + fragment reviews on Review { + id + author { + id + name + } + } + "; + + // Number of bytes when the heap size reached its global maximum with a 5% buffer. + // Actual number: 744_494. + const MAX_BYTES_QUERY_PLANNER: usize = 781_718; // ~763 KiB + + // Total number of allocations with a 5% buffer. + // Actual number: 15_403. + const MAX_ALLOCATIONS_QUERY_PLANNER: u64 = 16_173; + + // Number of bytes when the heap size reached its global maximum with a 5% buffer. + // Actual number: 864_783. + // + // Planning adds 120_289 bytes to heap max (864_783-744_494=120_289). + const MAX_BYTES_QUERY_PLAN: usize = 908_022; // ~886 KiB + + // Total number of allocations with a 5% buffer. + // Actual number: 22_937. + // + // Planning adds 7_534 allocations (22_937-15_403=7_534). + const MAX_ALLOCATIONS_QUERY_PLAN: u64 = 24_083; + + let schema = std::fs::read_to_string(SCHEMA).unwrap(); + + let _profiler = dhat::Profiler::builder().testing().build(); + + let supergraph = + apollo_federation::Supergraph::new(&schema).expect("supergraph should be valid"); + let api_options = apollo_federation::ApiSchemaOptions::default(); + let api_schema = supergraph + .to_api_schema(api_options) + .expect("api schema should be valid"); + let qp_config = apollo_federation::query_plan::query_planner::QueryPlannerConfig::default(); + let planner = + apollo_federation::query_plan::query_planner::QueryPlanner::new(&supergraph, qp_config) + .expect("query planner should be created"); + let stats = dhat::HeapStats::get(); + dhat::assert!(stats.max_bytes < MAX_BYTES_QUERY_PLANNER); + dhat::assert!(stats.total_blocks < MAX_ALLOCATIONS_QUERY_PLANNER); + + let document = apollo_compiler::ExecutableDocument::parse_and_validate( + api_schema.schema(), + OPERATION, + "operation.graphql", + ) + .expect("operation should be valid"); + let qp_options = apollo_federation::query_plan::query_planner::QueryPlanOptions::default(); + planner + .build_query_plan(&document, None, qp_options) + .expect("valid query plan"); + let stats = dhat::HeapStats::get(); + dhat::assert!(stats.max_bytes < MAX_BYTES_QUERY_PLAN); + dhat::assert!(stats.total_blocks < MAX_ALLOCATIONS_QUERY_PLAN); +} diff --git a/apollo-federation/tests/dhat_profiling/supergraph.rs b/apollo-federation/tests/dhat_profiling/supergraph.rs new file mode 100644 index 0000000000..04a78267e9 --- /dev/null +++ b/apollo-federation/tests/dhat_profiling/supergraph.rs @@ -0,0 +1,70 @@ +#[global_allocator] +pub(crate) static ALLOC: dhat::Alloc = dhat::Alloc; + +// Failure of the test can be diagnosed using the dhat-heap.json file. + +// The figures have a 5% buffer from the actual profiling stats. This +// should help us keep an eye on allocation increases, (hopefully) without +// too much flakiness. +#[test] +fn valid_supergraph_schema() { + const SCHEMA: &str = "../examples/graphql/supergraph.graphql"; + + // Number of bytes when the heap size reached its global maximum with a 5% buffer. + // Actual number: 128_605. + const MAX_BYTES_SUPERGRAPH: usize = 135_050; // ~135 KiB. actual number: 128605 + + // Total number of allocations with a 5% buffer. + // Actual number: 4929. + const MAX_ALLOCATIONS_SUPERGRAPH: u64 = 5_150; // number of allocations. + + // Number of bytes when the heap size reached its global maximum with a 5% buffer. + // Actual number: 188_420. + // + // API schema generation allocates additional 59_635 bytes (188_420-128_605=59_635). + const MAX_BYTES_API_SCHEMA: usize = 197_900; // ~200 KiB + + // Total number of allocations with a 5% buffer. + // Actual number: 5584. + // + // API schema has an additional 655 allocations (= 5584 - 4929). + const MAX_ALLOCATIONS_API_SCHEMA: u64 = 5863; + + // Number of bytes when the heap size reached its global maximum with a 5% buffer. + // Actual number: 552_781. + // + // Extract subgraphs allocates additional 364_361 bytes (552_781-188_420=364_361). + const MAX_BYTES_SUBGRAPHS: usize = 580_420; // ~600 KiB + + // Total number of allocations with a 5% buffer. + // Actual number: 13205. + // + // Extract subgraphs from supergraph has an additional 7621 allocations (= 13205 - 5584). + const MAX_ALLOCATIONS_SUBGRAPHS: u64 = 13865; + + let schema = std::fs::read_to_string(SCHEMA).unwrap(); + + let _profiler = dhat::Profiler::builder().testing().build(); + + let supergraph = + apollo_federation::Supergraph::new(&schema).expect("supergraph should be valid"); + let stats = dhat::HeapStats::get(); + println!("Supergraph::new: {stats:?}"); + dhat::assert!(stats.max_bytes < MAX_BYTES_SUPERGRAPH); + dhat::assert!(stats.total_blocks < MAX_ALLOCATIONS_SUPERGRAPH); + + let api_options = apollo_federation::ApiSchemaOptions::default(); + let _api_schema = supergraph.to_api_schema(api_options); + let stats = dhat::HeapStats::get(); + println!("supergraph.to_api_schema: {stats:?}"); + dhat::assert!(stats.max_bytes < MAX_BYTES_API_SCHEMA); + dhat::assert!(stats.total_blocks < MAX_ALLOCATIONS_API_SCHEMA); + + let _subgraphs = supergraph + .extract_subgraphs() + .expect("subgraphs should be extracted"); + let stats = dhat::HeapStats::get(); + println!("supergraph.extract_subgraphs: {stats:?}"); + dhat::assert!(stats.max_bytes < MAX_BYTES_SUBGRAPHS); + dhat::assert!(stats.total_blocks < MAX_ALLOCATIONS_SUBGRAPHS); +} diff --git a/apollo-federation/tests/extract_subgraphs.rs b/apollo-federation/tests/extract_subgraphs.rs index 2148185184..bf92493188 100644 --- a/apollo-federation/tests/extract_subgraphs.rs +++ b/apollo-federation/tests/extract_subgraphs.rs @@ -1,6 +1,6 @@ +use apollo_compiler::Node; use apollo_compiler::coord; use apollo_compiler::schema::Value; -use apollo_compiler::Node; use apollo_federation::Supergraph; #[test] @@ -697,3 +697,197 @@ fn does_not_extract_renamed_demand_control_directive_name_conflicts() { } insta::assert_snapshot!(snapshot); } + +#[test] +fn extracts_set_context_directives() { + let subgraphs = Supergraph::new(r#" + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1") + { + query: Query + } + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + + directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + + directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + + directive @context__fromContext(field: String) on ARGUMENT_DEFINITION + + enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION + } + + scalar link__Import + + enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "") + } + + scalar join__FieldSet + + scalar join__DirectiveArguments + + scalar join__FieldValue + + input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! + } + + scalar context__context + + type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) + { + t: T! @join__field(graph: SUBGRAPH1) + a: Int! @join__field(graph: SUBGRAPH2) + } + + type T + @join__type(graph: SUBGRAPH1, key: "id") + @context(name: "Subgraph1__context") + { + id: ID! + u: U! + prop: String! + } + + type U + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") + { + id: ID! + field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "Subgraph1__context", name: "a", type: "String", selection: "{ prop }"}]) + } + "#) + .expect("is supergraph") + .extract_subgraphs() + .expect("extracts subgraphs"); + + let mut snapshot = String::new(); + for (_name, subgraph) in subgraphs { + use std::fmt::Write; + + _ = writeln!( + &mut snapshot, + "{}\n---\n{}", + subgraph.name, + subgraph.schema.schema() + ); + } + insta::assert_snapshot!(snapshot); +} + +#[test] +fn extracts_string_enum_values() { + let subgraphs = Supergraph::new(r#" + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + { + query: Query + } + + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + type Entity + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id") + { + id: ID! + localField: String! @join__field(graph: A, requires: "requiredField(arg: \"ENUM_VALUE\")") + requiredField(arg: Enum): String! @join__field(graph: A, external: true) @join__field(graph: B) + } + + enum Enum + @join__type(graph: A) + @join__type(graph: B) + { + ENUM_VALUE @join__enumValue(graph: A) @join__enumValue(graph: B) + } + + scalar join__FieldSet + + enum join__Graph { + A @join__graph(name: "A", url: "http://localhost:4002/") + B @join__graph(name: "B", url: "http://localhost:4003/") + } + + scalar link__Import + + enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION + } + + type Query + @join__type(graph: A) + @join__type(graph: B) + { + entity: Entity @join__field(graph: A) + } + "#) + .expect("is supergraph") + .extract_subgraphs() + .expect("extracts subgraphs"); + + let mut snapshot = String::new(); + for (_name, subgraph) in subgraphs { + use std::fmt::Write; + + _ = writeln!( + &mut snapshot, + "{}\n---\n{}", + subgraph.name, + subgraph.schema.schema() + ); + } + insta::assert_snapshot!(snapshot); +} diff --git a/apollo-federation/tests/main.rs b/apollo-federation/tests/main.rs index 9d6a919307..9175b8529a 100644 --- a/apollo-federation/tests/main.rs +++ b/apollo-federation/tests/main.rs @@ -1,5 +1,7 @@ mod api_schema; +mod composition; mod composition_tests; +mod core_test; mod extract_subgraphs; mod query_plan; mod subgraph; diff --git a/apollo-federation/tests/query_plan/build_query_plan_support.rs b/apollo-federation/tests/query_plan/build_query_plan_support.rs index b7e02865c6..abb218f1cf 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_support.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_support.rs @@ -3,13 +3,12 @@ use std::sync::Mutex; use std::sync::OnceLock; use apollo_compiler::collections::IndexSet; -use apollo_federation::query_plan::query_planner::QueryPlanner; -use apollo_federation::query_plan::query_planner::QueryPlannerConfig; use apollo_federation::query_plan::FetchNode; use apollo_federation::query_plan::PlanNode; use apollo_federation::query_plan::QueryPlan; use apollo_federation::query_plan::TopLevelPlanNode; -use apollo_federation::schema::ValidFederationSchema; +use apollo_federation::query_plan::query_planner::QueryPlanner; +use apollo_federation::query_plan::query_planner::QueryPlannerConfig; use sha1::Digest; const ROVER_FEDERATION_VERSION: &str = "2.9.0"; @@ -32,7 +31,7 @@ macro_rules! planner { $( $subgraph_name: tt: $subgraph_schema: expr),+ $(,)? ) => {{ - $crate::query_plan::build_query_plan_support::api_schema_and_planner( + $crate::query_plan::build_query_plan_support::test_planner( insta::_function_name!(), $config, &[ $( (subgraph_name!($subgraph_name), $subgraph_schema) ),+ ], @@ -59,47 +58,43 @@ macro_rules! subgraph_name { /// formatted query plan string. /// Run `cargo insta review` to diff and accept changes to the generated query plan. macro_rules! assert_plan { - ($api_schema_and_planner: expr, $operation: expr, $options: expr, @$expected: literal) => {{ - let (api_schema, planner) = $api_schema_and_planner; + (validate_correctness = $validate_correctness: expr, $api_schema_and_planner: expr, $operation: expr, @$expected: literal) => {{ + assert_plan!($api_schema_and_planner, $operation, Default::default(), $validate_correctness, @$expected) + }}; + ($planner: expr, $operation: expr, $options: expr, $validate_correctness: expr, @$expected: literal) => {{ + let api_schema = $planner.api_schema(); let document = apollo_compiler::ExecutableDocument::parse_and_validate( api_schema.schema(), $operation, "operation.graphql", ) - .unwrap(); - let plan = planner.build_query_plan(&document, None, $options).unwrap(); + .expect("valid graphql document"); + let plan = $planner.build_query_plan(&document, None, $options).expect("query plan generated"); insta::assert_snapshot!(plan, @$expected); + // temporary workaround for correctness errors such as FED-515 + if $validate_correctness { + apollo_federation::correctness::check_plan($planner.api_schema(), $planner.supergraph_schema(), $planner.subgraph_schemas(), &document, &plan).expect("generated correct plan"); + } plan }}; + ($api_schema_and_planner: expr, $operation: expr, $options: expr, @$expected: literal) => {{ + assert_plan!($api_schema_and_planner, $operation, $options, true, @$expected) + }}; ($api_schema_and_planner: expr, $operation: expr, @$expected: literal) => {{ - let (api_schema, planner) = $api_schema_and_planner; - let document = apollo_compiler::ExecutableDocument::parse_and_validate( - api_schema.schema(), - $operation, - "operation.graphql", - ) - .unwrap(); - let plan = planner.build_query_plan(&document, None, Default::default()).unwrap(); - insta::assert_snapshot!(plan, @$expected); - plan + assert_plan!($api_schema_and_planner, $operation, Default::default(), true, @$expected) }}; } #[track_caller] -pub(crate) fn api_schema_and_planner( +pub(crate) fn test_planner( function_path: &'static str, config: QueryPlannerConfig, subgraph_names_and_schemas: &[(&str, &str)], -) -> (ValidFederationSchema, QueryPlanner) { +) -> QueryPlanner { let supergraph = compose(function_path, subgraph_names_and_schemas); - let supergraph = apollo_federation::Supergraph::new(&supergraph).unwrap(); - let planner = QueryPlanner::new(&supergraph, config).unwrap(); - let api_schema_config = apollo_federation::ApiSchemaOptions { - include_defer: true, - include_stream: false, - }; - let api_schema = supergraph.to_api_schema(api_schema_config).unwrap(); - (api_schema, planner) + let supergraph = apollo_federation::Supergraph::new_with_router_specs(&supergraph) + .expect("valid supergraph"); + QueryPlanner::new(&supergraph, config).expect("can create query planner") } #[track_caller] @@ -121,7 +116,7 @@ pub(crate) fn compose( .map(|(name, schema)| { ( *name, - format!("extend schema {DEFAULT_LINK_DIRECTIVE}\n\n{}", schema,), + format!("extend schema {DEFAULT_LINK_DIRECTIVE}\n\n{schema}",), ) }) .collect(); diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests.rs b/apollo-federation/tests/query_plan/build_query_plan_tests.rs index 13ca9e52bd..5188034d62 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_tests.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_tests.rs @@ -31,8 +31,12 @@ fn some_name() { } */ +mod cancel; +mod context; mod debug_max_evaluated_plans_configuration; mod defer; +mod disable_subgraphs; +mod entities; mod fetch_operation_names; mod field_merging_with_skip_and_include; mod fragment_autogeneration; @@ -43,8 +47,7 @@ mod interface_type_explosion; mod introspection_typename_handling; mod merged_abstract_types_handling; mod mutations; -mod named_fragments; -mod named_fragments_preservation; +mod named_fragments_expansion; mod overrides; mod provides; mod requires; @@ -613,113 +616,6 @@ fn key_where_at_external_is_not_at_top_level_of_selection_of_requires() { ); } -// TODO(@TylerBloom): As part of the private preview, we strip out all uses of the @defer -// directive. Once handling that feature is implemented, this test will start failing and should be -// updated to use a config for the planner to strip out the defer directive. -#[test] -fn defer_gets_stripped_out() { - let planner = planner!( - Subgraph1: r#" - type Query { - t: T - } - - type T @key(fields: "id") { - id: ID! - } - "#, - Subgraph2: r#" - type T @key(fields: "id") { - id: ID! - data: String - } - "#, - ); - let plan_one = assert_plan!( - &planner, - r#" - { - t { - id - data - } - } - "#, - @r###" - QueryPlan { - Sequence { - Fetch(service: "Subgraph1") { - { - t { - __typename - id - } - } - }, - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - data - } - } - }, - }, - }, - } - "### - ); - let plan_two = assert_plan!( - &planner, - r#" - { - t { - id - ... @defer { - data - } - } - } - "#, - @r###" - QueryPlan { - Sequence { - Fetch(service: "Subgraph1") { - { - t { - __typename - id - } - } - }, - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - data - } - } - }, - }, - }, - } - "### - ); - assert_eq!(plan_one, plan_two) -} - #[test] fn test_merging_fetches_do_not_create_cycle_in_fetch_dependency_graph() { // This is a test for ROUTER-546 (the second part). @@ -1317,13 +1213,15 @@ fn condition_order_router799() { } } "#, + // Note: `on Mutation` is suppressed in the query plan. But, the operation is still a + // mutation. @r###" QueryPlan { Include(if: $var1) { Skip(if: $var0) { Fetch(service: "books") { { - ... on Mutation { + ... { field0: __typename } } @@ -1344,13 +1242,15 @@ fn condition_order_router799() { } } "#, + // Note: `on Mutation` is suppressed in the query plan. But, the operation is still a + // mutation. @r###" QueryPlan { Include(if: $var1) { Skip(if: $var0) { Fetch(service: "books") { { - ... on Mutation { + ... { field0: __typename } } diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/cancel.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/cancel.rs new file mode 100644 index 0000000000..b86ed10e72 --- /dev/null +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/cancel.rs @@ -0,0 +1,116 @@ +//! Cooperative cancellation + +use std::cell::Cell; +use std::ops::ControlFlow; + +use apollo_compiler::ExecutableDocument; +use apollo_federation::error::FederationError; +use apollo_federation::error::SingleFederationError; +use apollo_federation::query_plan::QueryPlan; +use apollo_federation::query_plan::query_planner::QueryPlanOptions; + +macro_rules! plan_with_check { + ($check_for_cooperative_cancellation:expr) => { + run_planner_with_check( + $check_for_cooperative_cancellation, + planner!( + Subgraph1: r#" + type Query { + t: T + } + + type T @key(fields: "id") { + id: ID! + x: Int + } + "# + ) + ) + }; +} + +fn run_planner_with_check( + check_for_cooperative_cancellation: &dyn Fn() -> ControlFlow<()>, + planner: apollo_federation::query_plan::query_planner::QueryPlanner, +) -> Result { + let api_schema = planner.api_schema(); + let doc = r#" + query { + t { + __typename + x + } + } + "#; + let doc = ExecutableDocument::parse_and_validate(api_schema.schema(), doc, "").unwrap(); + planner.build_query_plan( + &doc, + None, + QueryPlanOptions { + check_for_cooperative_cancellation: Some(check_for_cooperative_cancellation), + ..Default::default() + }, + ) +} + +#[track_caller] +fn assert_cancelled(result: Result) { + match result { + Err(FederationError::SingleFederationError(SingleFederationError::PlanningCancelled)) => {} + Err(e) => panic!("expected PlanningCancelled error, got {e}"), + Ok(_) => panic!("expected PlanningCancelled, got a successful query plan"), + } +} + +#[test] +fn test_callback_is_called() { + let counter = Cell::new(0); + let result = plan_with_check!(&|| { + counter.set(counter.get() + 1); + ControlFlow::Continue(()) + }); + assert!(result.is_ok()); + // The actual count was 9 when this test was first written. + // We don’t assert an exact number because it changing as the planner evolves is fine. + assert!(counter.get() > 5); +} + +#[test] +fn test_cancel_as_soon_as_possible() { + let counter = Cell::new(0); + let result = plan_with_check!(&|| { + counter.set(counter.get() + 1); + ControlFlow::Break(()) + }); + assert_cancelled(result); + assert_eq!(counter.get(), 1); +} + +#[test] +fn test_cancel_near_the_middle() { + let counter = Cell::new(0); + let result = plan_with_check!(&|| { + counter.set(counter.get() + 1); + if counter.get() == 5 { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }); + assert_cancelled(result); + assert_eq!(counter.get(), 5); +} + +#[test] +fn test_cancel_late_enough_that_planning_finishes() { + let counter = Cell::new(0); + let result = plan_with_check!(&|| { + counter.set(counter.get() + 1); + if counter.get() >= 1_000 { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }); + assert!(result.is_ok()); +} diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/context.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/context.rs new file mode 100644 index 0000000000..1b692e1423 --- /dev/null +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/context.rs @@ -0,0 +1,1306 @@ +// PORT_NOTE: The context tests in the JS code had more involved setup compared to the other tests. +// Here is a snippet from the JS context test leading up to the creation of the planner: +// ```js +// const asFed2Service = (service: ServiceDefinition) => { +// return { +// ...service, +// typeDefs: asFed2SubgraphDocument(service.typeDefs, { +// includeAllImports: true, +// }), +// }; +// }; +// +// const composeAsFed2Subgraphs = (services: ServiceDefinition[]) => { +// return composeServices(services.map((s) => asFed2Service(s))); +// }; +// +// const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); +// const [api, queryPlanner] = [ +// result.schema!.toAPISchema(), +// new QueryPlanner(Supergraph.buildForTests(result.supergraphSdl!)), +// ]; +// ``` +// For all other tests, the set up was a single line: +// ```js +// const [api, queryPlanner] = composeAndCreatePlanner(subgraph1, subgraph2); +// ``` +// +// How this needs to be ported remains to be seen... + +use std::sync::Arc; + +use apollo_compiler::Name; +use apollo_federation::query_plan::FetchDataKeyRenamer; +use apollo_federation::query_plan::FetchDataPathElement; +use apollo_federation::query_plan::FetchDataRewrite; +use apollo_federation::query_plan::PlanNode; +use apollo_federation::query_plan::TopLevelPlanNode; + +fn parse_fetch_data_path_element(value: &str) -> FetchDataPathElement { + if value == ".." { + FetchDataPathElement::Parent + } else if let Some(("", ty)) = value.split_once("... on ") { + FetchDataPathElement::TypenameEquals(Name::new(ty).unwrap()) + } else { + FetchDataPathElement::Key(Name::new(value).unwrap(), Default::default()) + } +} + +macro_rules! node_assert { + ($plan: ident, $index: literal, $($rename_key_to: literal, $path: expr),+$(,)?) => { + let Some(TopLevelPlanNode::Sequence(node)) = $plan.node else { + panic!("failed to get sequence node"); + }; + let Some(PlanNode::Flatten(node)) = node.nodes.get($index) else { + panic!("failed to get fetch node"); + }; + let PlanNode::Fetch(node) = &*node.node else { + panic!("failed to get flatten node"); + }; + let expected_rewrites = &[ $( $rename_key_to ),+ ]; + let expected_paths = &[ $( $path.into_iter().map(parse_fetch_data_path_element).collect::>() ),+ ]; + assert_eq!(expected_rewrites.len(), expected_paths.len()); + assert_eq!(node.context_rewrites.len(), expected_rewrites.len()); + node + .context_rewrites + .iter() + .map(|rewriter| { + let FetchDataRewrite::KeyRenamer(renamer) = &**rewriter else { + panic!("Expected KeyRenamer"); + }; + renamer + }) + .zip(expected_rewrites.iter().zip(expected_paths)) + .for_each(|(actual, (rename_key_to, path))|{ + assert_eq!(&actual.rename_key_to.as_str(), rename_key_to); + assert_eq!(&actual.path, path); + }); + }; +} + +#[test] +fn set_context_test_variable_is_from_same_subgraph() { + let planner = planner!( + Subgraph1: r#" + type Query { + t: T! + } + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + type U @key(fields: "id") { + id: ID! + b: String! + field(a: String @fromContext(field: "$context { prop }")): Int! + } + "#, + Subgraph2: r#" + type Query { + a: Int! + } + type U @key(fields: "id") { + id: ID! + } + "#, + ); + + let plan = assert_plan!(planner, + r#" + { + t { + u { + b + field + } + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + prop + u { + __typename + id + b + } + } + } + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + field(a: $contextualArgument_1_0) + } + } + }, + }, + }, + } + "### + ); + node_assert!( + plan, + 1, + "contextualArgument_1_0", + ["..", "... on T", "prop"] + ); +} + +#[test] +fn set_context_test_variable_is_from_different_subgraph() { + let planner = planner!( + Subgraph1: r#" + type Query { + t: T! + } + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! @external + } + type U @key(fields: "id") { + id: ID! + field(a: String @fromContext(field: "$context { prop }")): Int! + } + "#, + Subgraph2: r#" + type Query { + a: Int! + } + type T @key(fields: "id") { + id: ID! + prop: String! + } + type U @key(fields: "id") { + id: ID! + } + "#, + ); + let plan = assert_plan!( + planner, + r#" + { + t { + u { + id + field + } + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + id + u { + __typename + id + } + } + } + }, + Flatten(path: "t") { + Fetch(service: "Subgraph2") { + { + ... on T { + __typename + id + } + } => + { + ... on T { + prop + } + } + }, + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + field(a: $contextualArgument_1_0) + } + } + }, + }, + }, + } + "###); + + node_assert!( + plan, + 2, + "contextualArgument_1_0", + ["..", "... on T", "prop"] + ); +} + +#[test] +fn set_context_test_variable_is_already_in_a_different_fetch_group() { + let planner = planner!( + Subgraph1: r#" + type Query { + t: T! + } + type T @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + type U @key(fields: "id") { + id: ID! + } + "#, + Subgraph2: r#" + type Query { + a: Int! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + prop: String! @external + } + + type U @key(fields: "id") { + id: ID! + field(a: String @fromContext(field: "$context { prop }")): Int! + } + "#, + ); + + let plan = assert_plan!(planner, + r#" + { + t { + u { + id + field + } + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + prop + u { + __typename + id + } + } + } + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph2") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + field(a: $contextualArgument_2_0) + } + } + }, + }, + }, + } + "### + ); + match plan.node { + Some(TopLevelPlanNode::Sequence(node)) => match node.nodes.get(1) { + Some(PlanNode::Flatten(node)) => match &*node.node { + PlanNode::Fetch(node) => { + assert_eq!( + node.context_rewrites, + vec![Arc::new(FetchDataRewrite::KeyRenamer( + FetchDataKeyRenamer { + rename_key_to: Name::new("contextualArgument_2_0").unwrap(), + path: vec![ + FetchDataPathElement::Parent, + FetchDataPathElement::TypenameEquals(Name::new("T").unwrap()), + FetchDataPathElement::Key( + Name::new("prop").unwrap(), + Default::default() + ), + ], + } + )),] + ); + } + _ => panic!("failed to get fetch node"), + }, + _ => panic!("failed to get flatten node"), + }, + _ => panic!("failed to get sequence node"), + } +} + +#[test] +fn set_context_test_variable_is_a_list() { + let planner = planner!( + Subgraph1: r#" + type Query { + t: T! + } + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: [String]! + } + type U @key(fields: "id") { + id: ID! + field(a: [String] @fromContext(field: "$context { prop }")): Int! + } + "#, + Subgraph2: r#" + type Query { + a: Int! + } + type U @key(fields: "id") { + id: ID! + } + "# + ); + + let plan = assert_plan!(planner, + r#" + { + t { + u { + field + } + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + prop + u { + __typename + id + } + } + } + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + field(a: $contextualArgument_1_0) + } + } + }, + }, + }, + } + "### + ); + match plan.node { + Some(TopLevelPlanNode::Sequence(node)) => match node.nodes.get(1) { + Some(PlanNode::Flatten(node)) => match &*node.node { + PlanNode::Fetch(node) => { + assert_eq!( + node.context_rewrites, + vec![Arc::new(FetchDataRewrite::KeyRenamer( + FetchDataKeyRenamer { + rename_key_to: Name::new("contextualArgument_1_0").unwrap(), + path: vec![ + FetchDataPathElement::Parent, + FetchDataPathElement::TypenameEquals(Name::new("T").unwrap()), + FetchDataPathElement::Key( + Name::new("prop").unwrap(), + Default::default() + ), + ], + } + )),] + ); + } + _ => panic!("failed to get fetch node"), + }, + _ => panic!("failed to get flatten node"), + }, + _ => panic!("failed to get sequence node"), + } +} + +#[test] +fn set_context_test_fetched_as_a_list() { + let planner = planner!( + Subgraph1: r#" + type Query { + t: [T]! + } + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + type U @key(fields: "id") { + id: ID! + b: String! + field(a: String @fromContext(field: "$context { prop }")): Int! + } + "#, + Subgraph2: r#" + type Query { + a: Int! + } + type U @key(fields: "id") { + id: ID! + } + "#, + ); + + let plan = assert_plan!(planner, + r#" + { + t { + u { + b + field + } + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + prop + u { + __typename + id + b + } + } + } + }, + Flatten(path: "t.@.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + field(a: $contextualArgument_1_0) + } + } + }, + }, + }, + } + "### + ); + + node_assert!( + plan, + 1, + "contextualArgument_1_0", + ["..", "... on T", "prop"] + ); +} + +#[test] +fn set_context_test_impacts_on_query_planning() { + let planner = planner!( + Subgraph1: r#" + type Query { + t: I! + } + + interface I @context(name: "context") @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + + type A implements I @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + + type B implements I @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + b: String! + field(a: String @fromContext(field: "$context { prop }")): Int! + } + "#, + Subgraph2: r#" + type Query { + a: Int! + } + type U @key(fields: "id") { + id: ID! + } + "#, + ); + + let plan = assert_plan!(planner, + r#" + { + t { + u { + b + field + } + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + prop + u { + __typename + id + b + } + } + } + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + field(a: $contextualArgument_1_0) + } + } + }, + }, + }, + } + "### + ); + + node_assert!( + plan, + 1, + "contextualArgument_1_0", + ["..", "... on A", "prop"], + "contextualArgument_1_0", + ["..", "... on B", "prop"] + ); +} + +#[test] +fn set_context_test_with_type_conditions_for_union() { + let planner = planner!( + Subgraph1: r#" + type Query { + t: T! + } + + union T @context(name: "context") = A | B + + type A @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + + type B @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + b: String! + field( + a: String + @fromContext( + field: "$context ... on A { prop } ... on B { prop }" + ) + ): Int! + } + "#, + Subgraph2: r#" + type Query { + a: Int! + } + type U @key(fields: "id") { + id: ID! + } + "#, + ); + + let plan = assert_plan!(planner, + r#" + { + t { + ... on A { + u { + b + field + } + } + ... on B { + u { + b + field + } + } + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + ... on A { + __typename + prop + u { + __typename + id + b + } + } + ... on B { + __typename + prop + u { + __typename + id + b + } + } + } + } + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + field(a: $contextualArgument_1_0) + } + } + }, + }, + }, + } + "### + ); + + node_assert!( + plan, + 1, + "contextualArgument_1_0", + ["..", "... on A", "prop"], + "contextualArgument_1_0", + ["..", "... on B", "prop"] + ); +} + +#[test] +fn set_context_test_accesses_a_different_top_level_query() { + let planner = planner!( + Subgraph1: r#" + type Query @context(name: "topLevelQuery") { + me: User! + product: Product + } + + type User @key(fields: "id") { + id: ID! + locale: String! + } + + type Product @key(fields: "id") { + id: ID! + price( + locale: String + @fromContext(field: "$topLevelQuery { me { locale } }") + ): Int! + } + "#, + Subgraph2: r#" + type Query { + randomId: ID! + } + + type Product @key(fields: "id") { + id: ID! + } + "#, + ); + + let plan = assert_plan!(planner, + r#" + { + product { + price + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + __typename + me { + locale + } + product { + __typename + id + } + } + }, + Flatten(path: "product") { + Fetch(service: "Subgraph1") { + { + ... on Product { + __typename + id + } + } => + { + ... on Product { + price(locale: $contextualArgument_1_0) + } + } + }, + }, + }, + } + "### + ); + + node_assert!(plan, 1, "contextualArgument_1_0", ["..", "me", "locale"]); +} + +#[test] +fn set_context_one_subgraph() { + let planner = planner!( + Subgraph1: r#" + type Query { + t: T! + } + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + type U @key(fields: "id") { + id: ID! + b: String! + field(a: String @fromContext(field: "$context { prop }")): Int! + } + "#, + Subgraph2: r#" + type Query { + randomId: ID! + } + "#, + ); + + let plan = assert_plan!(planner, + r#" + { + t { + u { + field + } + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + prop + u { + __typename + id + } + } + } + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + field(a: $contextualArgument_1_0) + } + } + }, + }, + }, + } + "### + ); + + node_assert!( + plan, + 1, + "contextualArgument_1_0", + ["..", "... on T", "prop"] + ); +} + +#[test] +fn set_context_required_field_is_several_levels_deep_going_back_and_forth_between_subgraphs() { + let planner = planner!( + Subgraph1: r#" + type Query { + t: T! + } + + type A @key(fields: "id") { + id: ID! + b: B! @external + } + + type B @key(fields: "id") { + id: ID! + c: C! + } + + type C @key(fields: "id") { + id: ID! + prop: String! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + a: A! + } + type U @key(fields: "id") { + id: ID! + b: String! + field( + a: String @fromContext(field: "$context { a { b { c { prop }}} }") + ): Int! + } + "#, + Subgraph2: r#" + type Query { + randomId: ID! + } + + type A @key(fields: "id") { + id: ID! + b: B! + } + + type B @key(fields: "id") { + id: ID! + } + "#, + ); + + let plan = assert_plan!(planner, + r#" + { + t { + u { + field + } + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + a { + __typename + id + } + u { + __typename + id + } + } + } + }, + Flatten(path: "t.a") { + Fetch(service: "Subgraph2") { + { + ... on A { + __typename + id + } + } => + { + ... on A { + b { + __typename + id + } + } + } + }, + }, + Flatten(path: "t.a.b") { + Fetch(service: "Subgraph1") { + { + ... on B { + __typename + id + } + } => + { + ... on B { + c { + prop + } + } + } + }, + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + field(a: $contextualArgument_1_0) + } + } + }, + }, + }, + } + "### + ); + + node_assert!( + plan, + 3, + "contextualArgument_1_0", + ["..", "... on T", "a", "b", "c", "prop"] + ); +} + +#[test] +fn set_context_test_before_key_resolution_transition() { + let planner = planner!( + Subgraph1: r#" + type Query { + customer: Customer! + } + + type Identifiers @key(fields: "id") { + id: ID! + legacyUserId: ID! + } + + type Customer @key(fields: "id") { + id: ID! + child: Child! + identifiers: Identifiers! + } + + type Child @key(fields: "id") { + id: ID! + } + "#, + Subgraph2: r#" + type Customer @key(fields: "id") @context(name: "ctx") { + id: ID! + identifiers: Identifiers! @external + } + + type Identifiers @key(fields: "id") { + id: ID! + legacyUserId: ID! @external + } + + type Child @key(fields: "id") { + id: ID! + prop( + legacyUserId: ID + @fromContext(field: "$ctx { identifiers { legacyUserId } }") + ): String + } + "#, + ); + + assert_plan!(planner, + r#" + query { + customer { + child { + id + prop + } + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + customer { + __typename + identifiers { + legacyUserId + } + child { + __typename + id + } + } + } + }, + Flatten(path: "customer.child") { + Fetch(service: "Subgraph2") { + { + ... on Child { + __typename + id + } + } => + { + ... on Child { + prop(legacyUserId: $contextualArgument_2_0) + } + } + }, + }, + }, + } + "### + ); +} + +#[test] +fn set_context_test_efficiently_merge_fetch_groups() { + let planner = planner!( + Subgraph1: r#" + type Identifiers @key(fields: "id") { + id: ID! + id2: ID @external + id3: ID @external + wid: ID @requires(fields: "id2 id3") + } + "#, + Subgraph2: r#" + type Query { + customer: Customer + } + + type Customer @key(fields: "id") { + id: ID! + identifiers: Identifiers + mid: ID + } + + type Identifiers @key(fields: "id") { + id: ID! + id2: ID + id3: ID + id5: ID + } + "#, + Subgraph3: r#" + type Customer @key(fields: "id") @context(name: "retailCtx") { + accounts: Accounts @shareable + id: ID! + mid: ID @external + identifiers: Identifiers @external + } + + type Identifiers @key(fields: "id") { + id: ID! + id5: ID @external + } + type Accounts @key(fields: "id") { + foo( + randomInput: String + ctx_id5: ID + @fromContext(field: "$retailCtx { identifiers { id5 } }") + ctx_mid: ID @fromContext(field: "$retailCtx { mid }") + ): Foo + id: ID! + } + + type Foo { + id: ID + } + "#, + Subgraph4: r#" + type Customer + @key(fields: "id", resolvable: false) + @context(name: "widCtx") { + accounts: Accounts @shareable + id: ID! + identifiers: Identifiers @external + } + + type Identifiers @key(fields: "id", resolvable: false) { + id: ID! + wid: ID @external # @requires(fields: "id2 id3") + } + + type Accounts @key(fields: "id") { + bar( + ctx_wid: ID @fromContext(field: "$widCtx { identifiers { wid } }") + ): Bar + + id: ID! + } + + type Bar { + id: ID + } + "#, + ); + + let plan = assert_plan!(planner, + r#" + query { + customer { + accounts { + foo { + id + } + } + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph2") { + { + customer { + __typename + id + identifiers { + id5 + } + mid + } + } + }, + Flatten(path: "customer") { + Fetch(service: "Subgraph3") { + { + ... on Customer { + __typename + id + } + } => + { + ... on Customer { + accounts { + foo(ctx_id5: $contextualArgument_3_0, ctx_mid: $contextualArgument_3_1) { + id + } + } + } + } + }, + }, + }, + } + "### + ); + + node_assert!( + plan, + 1, + "contextualArgument_3_0", + ["identifiers", "id5"], + "contextualArgument_3_1", + ["mid"] + ); +} diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/defer.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/defer.rs index 2dd9f28cdd..e11ef2b35d 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_tests/defer.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/defer.rs @@ -6,71 +6,6 @@ fn config_with_defer() -> QueryPlannerConfig { config } -#[test] -fn defer_test_handles_simple_defer_without_defer_enabled() { - let planner = planner!( - Subgraph1: r#" - type Query { - t: T - } - - type T @key(fields: "id") { - id: ID! - } - "#, - Subgraph2: r#" - type T @key(fields: "id") { - id: ID! - v1: Int - v2: Int - } - "#, - ); - // without defer-support enabled - assert_plan!(planner, - r#" - { - t { - v1 - ... @defer { - v2 - } - } - } - "#, - @r###" - QueryPlan { - Sequence { - Fetch(service: "Subgraph1") { - { - t { - __typename - id - } - } - }, - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v1 - v2 - } - } - }, - }, - }, - } - "### - ); -} - #[test] fn defer_test_normalizes_if_false() { let planner = planner!( @@ -172,11 +107,7 @@ fn defer_test_normalizes_if_true() { QueryPlan { Defer { Primary { - { - t { - v1 - } - }: + { t { v1 } }: Sequence { Fetch(service: "Subgraph1", id: 0) { { @@ -204,9 +135,7 @@ fn defer_test_normalizes_if_true() { }, }, [ Deferred(depends: [0], path: "t") { - { - v2 - }: + { v2 }: Flatten(path: "t") { Fetch(service: "Subgraph2") { { @@ -266,11 +195,7 @@ fn defer_test_handles_simple_defer_with_defer_enabled() { QueryPlan { Defer { Primary { - { - t { - v1 - } - }: + { t { v1 } }: Sequence { Fetch(service: "Subgraph1", id: 0) { { @@ -298,9 +223,7 @@ fn defer_test_handles_simple_defer_with_defer_enabled() { }, }, [ Deferred(depends: [0], path: "t") { - { - v2 - }: + { v2 }: Flatten(path: "t") { Fetch(service: "Subgraph2") { { @@ -368,13 +291,7 @@ fn defer_test_non_router_based_defer_case_one() { QueryPlan { Defer { Primary { - { - t { - v { - a - } - } - }: + { t { v { a } } }: Sequence { Fetch(service: "Subgraph1") { { @@ -405,9 +322,7 @@ fn defer_test_non_router_based_defer_case_one() { }, }, [ Deferred(depends: [], path: "t/v") { - { - b - }: + { b }: }, ] }, @@ -420,7 +335,7 @@ fn defer_test_non_router_based_defer_case_one() { fn defer_test_non_router_based_defer_case_two() { // @defer on entity but with no @key // While the @defer in the operation is on an entity, the @key in the first subgraph - // is explicitely marked as non-resovable, so we cannot use it to actually defer the + // is explicitly marked as non-resovable, so we cannot use it to actually defer the // fetch to `v1`. Note that example still compose because, defer excluded, `v1` can // still be fetched for all queries (which is only `t` here). let planner = planner!( @@ -455,50 +370,44 @@ fn defer_test_non_router_based_defer_case_two() { } "#, @r###" - QueryPlan { - Defer { - Primary { + QueryPlan { + Defer { + Primary { + { t { v2 } }: + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + id + v1 + } + } + }, + Flatten(path: "t") { + Fetch(service: "Subgraph2") { { - t { + ... on T { + __typename + id + } + } => + { + ... on T { v2 } - }: - Sequence { - Fetch(service: "Subgraph1") { - { - t { - __typename - id - v1 - } - } - }, - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v2 - } - } - }, - }, - }, - }, [ - Deferred(depends: [], path: "t") { - { - v1 - }: - }, - ] + } + }, }, - } - "### + }, + }, [ + Deferred(depends: [], path: "t") { + { v1 }: + }, + ] + }, + } + "### ); } @@ -555,74 +464,64 @@ fn defer_test_non_router_based_defer_case_three() { } "#, @r###" - QueryPlan { - Defer { - Primary { + QueryPlan { + Defer { + Primary { + { t { v { a } } }: + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + id + } + } + }, + Flatten(path: "t") { + Fetch(service: "Subgraph2", id: 0) { { - t { + ... on T { + __typename + id + } + } => + { + ... on T { v { a - } - } - }: - Sequence { - Fetch(service: "Subgraph1") { - { - t { + u { __typename id } } - }, - Flatten(path: "t") { - Fetch(service: "Subgraph2", id: 0) { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v { - a - u { - __typename - id - } - } - } - } - }, - }, - }, - }, [ - Deferred(depends: [0], path: "t/v") { - { - u { - x - } - }: - Flatten(path: "t.v.u") { - Fetch(service: "Subgraph1") { - { - ... on U { - __typename - id - } - } => - { - ... on U { - x - } - } - }, - }, - }, - ] + } + } + }, }, - } - "### + }, + }, [ + Deferred(depends: [0], path: "t/v") { + { u { x } }: + Flatten(path: "t.v.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + x + } + } + }, + }, + }, + ] + }, + } + "### ); } @@ -658,11 +557,7 @@ fn defer_test_defer_resuming_in_the_same_subgraph() { QueryPlan { Defer { Primary { - { - t { - v0 - } - }: + { t { v0 } }: Fetch(service: "Subgraph1", id: 0) { { t { @@ -674,9 +569,7 @@ fn defer_test_defer_resuming_in_the_same_subgraph() { }, }, [ Deferred(depends: [0], path: "t") { - { - v1 - }: + { v1 }: Flatten(path: "t") { Fetch(service: "Subgraph1") { { @@ -744,82 +637,74 @@ fn defer_test_defer_multiple_fields_in_different_subgraphs() { } "#, @r###" - QueryPlan { - Defer { - Primary { - { - t { - v0 - } - }: - Fetch(service: "Subgraph1", id: 0) { - { - t { - __typename - v0 - id + QueryPlan { + Defer { + Primary { + { t { v0 } }: + Fetch(service: "Subgraph1", id: 0) { + { + t { + __typename + v0 + id + } + } + }, + }, [ + Deferred(depends: [0], path: "t") { + { v1 v2 v3 }: + Parallel { + Flatten(path: "t") { + Fetch(service: "Subgraph1") { + { + ... on T { + __typename + id + } + } => + { + ... on T { + v1 + } } - } + }, }, - }, [ - Deferred(depends: [0], path: "t") { - { - v1 - v2 - v3 - }: - Parallel { - Flatten(path: "t") { - Fetch(service: "Subgraph1") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v1 - } - } - }, - }, - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v2 - } - } - }, - }, - Flatten(path: "t") { - Fetch(service: "Subgraph3") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v3 - } - } - }, - }, + Flatten(path: "t") { + Fetch(service: "Subgraph2") { + { + ... on T { + __typename + id + } + } => + { + ... on T { + v2 + } + } }, }, - ] + Flatten(path: "t") { + Fetch(service: "Subgraph3") { + { + ... on T { + __typename + id + } + } => + { + ... on T { + v3 + } + } + }, + }, + }, }, - } - "### + ] + }, + } + "### ); } @@ -882,14 +767,7 @@ fn defer_test_multiple_non_nested_defer_plus_label_handling() { QueryPlan { Defer { Primary { - { - t { - v0 - v3 { - x - } - } - }: + { t { v0 v3 { x } } }: Sequence { Fetch(service: "Subgraph1", id: 0) { { @@ -936,9 +814,7 @@ fn defer_test_multiple_non_nested_defer_plus_label_handling() { }, }, [ Deferred(depends: [0], path: "t") { - { - v2 - }: + { v2 }: Flatten(path: "t") { Fetch(service: "Subgraph2") { { @@ -956,9 +832,7 @@ fn defer_test_multiple_non_nested_defer_plus_label_handling() { }, }, Deferred(depends: [0], path: "t", label: "defer_v1") { - { - v1 - }: + { v1 }: Flatten(path: "t") { Fetch(service: "Subgraph1") { { @@ -976,9 +850,7 @@ fn defer_test_multiple_non_nested_defer_plus_label_handling() { }, }, Deferred(depends: [1], path: "t/v3", label: "defer_in_v3") { - { - y - }: + { y }: Flatten(path: "t.v3") { Fetch(service: "Subgraph3") { { @@ -1055,11 +927,7 @@ fn defer_test_nested_defer_on_entities() { QueryPlan { Defer { Primary { - { - me { - name - } - }: + { me { name } }: Fetch(service: "Subgraph1", id: 0) { { me { @@ -1073,16 +941,7 @@ fn defer_test_nested_defer_on_entities() { Deferred(depends: [0], path: "me") { Defer { Primary { - { - ... on User { - messages { - body - author { - name - } - } - } - }: + { ... on User { messages { body author { name } } } }: Sequence { Flatten(path: "me") { Fetch(service: "Subgraph2", id: 1) { @@ -1123,11 +982,7 @@ fn defer_test_nested_defer_on_entities() { }, }, [ Deferred(depends: [1], path: "me/messages/author") { - { - messages { - body - } - }: + { messages { body } }: Flatten(path: "me.messages.@.author") { Fetch(service: "Subgraph2") { { @@ -1241,11 +1096,7 @@ fn defer_test_defer_on_value_types() { }, }, [ Deferred(depends: [], path: "me/messages") { - { - body { - lines - } - }: + { body { lines } }: }, ] }, @@ -1298,11 +1149,7 @@ fn defer_test_direct_nesting_on_entity() { QueryPlan { Defer { Primary { - { - me { - name - } - }: + { me { name } }: Fetch(service: "Subgraph1", id: 0) { { me { @@ -1316,9 +1163,7 @@ fn defer_test_direct_nesting_on_entity() { Deferred(depends: [0], path: "me") { Defer { Primary { - { - age - }: + { age }: Flatten(path: "me") { Fetch(service: "Subgraph2") { { @@ -1336,9 +1181,7 @@ fn defer_test_direct_nesting_on_entity() { }, }, [ Deferred(depends: [0], path: "me") { - { - address - }: + { address }: Flatten(path: "me") { Fetch(service: "Subgraph2") { { @@ -1401,11 +1244,7 @@ fn defer_test_direct_nesting_on_value_type() { QueryPlan { Defer { Primary { - { - me { - name - } - }: + { me { name } }: Fetch(service: "Subgraph1") { { me { @@ -1419,14 +1258,10 @@ fn defer_test_direct_nesting_on_value_type() { Deferred(depends: [], path: "me") { Defer { Primary { - { - age - } + { age } }, [ Deferred(depends: [], path: "me") { - { - address - }: + { address }: }, ] }, @@ -1489,14 +1324,10 @@ fn defer_test_defer_on_enity_but_with_unuseful_key() { Deferred(depends: [], path: "t") { Defer { Primary { - { - a - } + { a } }, [ Deferred(depends: [], path: "t") { - { - b - }: + { b }: }, ] }, @@ -1560,14 +1391,7 @@ fn defer_test_defer_on_mutation_in_same_subgraph() { QueryPlan { Defer { Primary { - { - update1 { - v0 - } - update2 { - v1 - } - }: + { update1 { v0 } update2 { v1 } }: Fetch(service: "Subgraph1", id: 2) { { update1 { @@ -1584,9 +1408,7 @@ fn defer_test_defer_on_mutation_in_same_subgraph() { }, }, [ Deferred(depends: [2], path: "update1") { - { - v1 - }: + { v1 }: Flatten(path: "update1") { Fetch(service: "Subgraph1") { { @@ -1604,10 +1426,7 @@ fn defer_test_defer_on_mutation_in_same_subgraph() { }, }, Deferred(depends: [2], path: "update2") { - { - v0 - v2 - }: + { v0 v2 }: Parallel { Flatten(path: "update2") { Fetch(service: "Subgraph1") { @@ -1700,114 +1519,102 @@ fn defer_test_defer_on_mutation_on_different_subgraphs() { } "#, @r###" - QueryPlan { - Defer { - Primary { + QueryPlan { + Defer { + Primary { + { update1 { v0 } update2 { v1 } }: + Sequence { + Fetch(service: "Subgraph1", id: 0) { + { + update1 { + __typename + v0 + id + } + } + }, + Fetch(service: "Subgraph2", id: 1) { + { + update2 { + __typename + id + } + } + }, + Flatten(path: "update2") { + Fetch(service: "Subgraph1") { + { + ... on T { + __typename + id + } + } => + { + ... on T { + v1 + } + } + }, + }, + }, + }, [ + Deferred(depends: [0], path: "update1") { + { v1 }: + Flatten(path: "update1") { + Fetch(service: "Subgraph1") { { - update1 { - v0 + ... on T { + __typename + id } - update2 { + } => + { + ... on T { v1 } - }: - Sequence { - Fetch(service: "Subgraph1", id: 0) { - { - update1 { - __typename - v0 - id - } + } + }, + }, + }, + Deferred(depends: [1], path: "update2") { + { v0 v2 }: + Parallel { + Flatten(path: "update2") { + Fetch(service: "Subgraph1") { + { + ... on T { + __typename + id } - }, - Fetch(service: "Subgraph2", id: 1) { - { - update2 { - __typename - id - } + } => + { + ... on T { + v0 } - }, - Flatten(path: "update2") { - Fetch(service: "Subgraph1") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v1 - } - } - }, - }, + } }, - }, [ - Deferred(depends: [0], path: "update1") { - { - v1 - }: - Flatten(path: "update1") { - Fetch(service: "Subgraph1") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v1 - } - } - }, - }, - }, - Deferred(depends: [1], path: "update2") { + }, + Flatten(path: "update2") { + Fetch(service: "Subgraph2") { { - v0 - v2 - }: - Parallel { - Flatten(path: "update2") { - Fetch(service: "Subgraph1") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v0 - } - } - }, - }, - Flatten(path: "update2") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v2 - } - } - }, - }, - }, + ... on T { + __typename + id + } + } => + { + ... on T { + v2 + } + } }, - ] + }, }, - } - "### + }, + ] + }, + } + "### ); } @@ -1865,13 +1672,7 @@ fn defer_test_defer_on_multi_dependency_deferred_section() { QueryPlan { Defer { Primary { - { - t { - v1 - v2 - v3 - } - }: + { t { v1 v2 v3 } }: Sequence { Fetch(service: "Subgraph1") { { @@ -1919,9 +1720,7 @@ fn defer_test_defer_on_multi_dependency_deferred_section() { }, }, [ Deferred(depends: [0, 1], path: "t") { - { - v4 - }: + { v4 }: Flatten(path: "t") { Fetch(service: "Subgraph4") { { @@ -1945,11 +1744,11 @@ fn defer_test_defer_on_multi_dependency_deferred_section() { "### ); - // TODO: the following plan is admittedly not as effecient as it could be, as the 2 queries to + // TODO: the following plan is admittedly not as efficient as it could be, as the 2 queries to // subgraph 2 and 3 are done in the "primary" section, but all they do is handle transitive // key dependencies for the deferred block, so it would make more sense to defer those fetches // as well. It is however tricky to both improve this here _and_ maintain the plan generate - // just above (which is admittedly optimial). More precisely, what the code currently does is + // just above (which is admittedly optimal). More precisely, what the code currently does is // that when it gets to a defer, then it defers the fetch that gets the deferred fields (the // fetch to subgraph 4 here), but it puts the "condition" resolution for the key of that fetch // in the non-deferred section. Here, resolving that fetch conditions is what creates the @@ -1982,11 +1781,7 @@ fn defer_test_defer_on_multi_dependency_deferred_section() { QueryPlan { Defer { Primary { - { - t { - v1 - } - }: + { t { v1 } }: Sequence { Fetch(service: "Subgraph1") { { @@ -2032,9 +1827,7 @@ fn defer_test_defer_on_multi_dependency_deferred_section() { }, }, [ Deferred(depends: [0, 1], path: "t") { - { - v4 - }: + { v4 }: Flatten(path: "t") { Fetch(service: "Subgraph4") { { @@ -2103,11 +1896,7 @@ fn defer_test_requirements_of_deferred_fields_are_deferred() { QueryPlan { Defer { Primary { - { - t { - v1 - } - }: + { t { v1 } }: Fetch(service: "Subgraph1", id: 0) { { t { @@ -2119,9 +1908,7 @@ fn defer_test_requirements_of_deferred_fields_are_deferred() { }, }, [ Deferred(depends: [0], path: "t") { - { - v2 - }: + { v2 }: Sequence { Flatten(path: "t") { Fetch(service: "Subgraph3") { @@ -2210,11 +1997,7 @@ fn defer_test_provides_are_ignored_for_deferred_fields() { QueryPlan { Defer { Primary { - { - t { - v1 - } - }: + { t { v1 } }: Fetch(service: "Subgraph1", id: 0) { { t { @@ -2226,9 +2009,7 @@ fn defer_test_provides_are_ignored_for_deferred_fields() { }, }, [ Deferred(depends: [0], path: "t") { - { - v2 - }: + { v2 }: Flatten(path: "t") { Fetch(service: "Subgraph2") { { @@ -2296,15 +2077,7 @@ fn defer_test_defer_on_query_root_type() { QueryPlan { Defer { Primary { - { - op2 { - x - y - next { - op3 - } - } - }: + { op2 { x y next { op3 } } }: Sequence { Fetch(service: "Subgraph1", id: 0) { { @@ -2320,7 +2093,7 @@ fn defer_test_defer_on_query_root_type() { Flatten(path: "op2.next") { Fetch(service: "Subgraph2") { { - ... on Query { + ... { op3 } } @@ -2329,15 +2102,12 @@ fn defer_test_defer_on_query_root_type() { }, }, [ Deferred(depends: [0], path: "op2/next") { - { - op1 - op4 - }: + { op1 op4 }: Parallel { Flatten(path: "op2.next") { Fetch(service: "Subgraph1") { { - ... on Query { + ... { op1 } } @@ -2346,7 +2116,7 @@ fn defer_test_defer_on_query_root_type() { Flatten(path: "op2.next") { Fetch(service: "Subgraph2") { { - ... on Query { + ... { op4 } } @@ -2399,17 +2169,12 @@ fn defer_test_defer_on_everything_queried() { Defer { Primary {}, [ Deferred(depends: [], path: "") { - { - t { - x - y - } - }: + { t { x y } }: Sequence { Flatten(path: "") { Fetch(service: "Subgraph1") { { - ... on Query { + ... { t { __typename id @@ -2490,10 +2255,7 @@ fn defer_test_defer_everything_within_entity() { }, }, [ Deferred(depends: [0], path: "t") { - { - x - y - }: + { x y }: Parallel { Flatten(path: "t") { Fetch(service: "Subgraph1") { @@ -2573,11 +2335,7 @@ fn defer_test_defer_with_conditions_and_labels() { Then { Defer { Primary { - { - t { - x - } - }: + { t { x } }: Fetch(service: "Subgraph1", id: 0) { { t { @@ -2589,9 +2347,7 @@ fn defer_test_defer_with_conditions_and_labels() { }, }, [ Deferred(depends: [0], path: "t") { - { - y - }: + { y }: Flatten(path: "t") { Fetch(service: "Subgraph2") { { @@ -2655,79 +2411,73 @@ fn defer_test_defer_with_conditions_and_labels() { } "#, @r###" - QueryPlan { - Condition(if: $cond) { - Then { - Defer { - Primary { - { - t { - x - } - }: - Fetch(service: "Subgraph1", id: 0) { - { - t { - __typename - x - id - } - } - }, - }, [ - Deferred(depends: [0], path: "t", label: "testLabel") { - { - y - }: - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - y - } - } - }, - }, - }, - ] - }, - } Else { - Sequence { - Fetch(service: "Subgraph1") { + QueryPlan { + Condition(if: $cond) { + Then { + Defer { + Primary { + { t { x } }: + Fetch(service: "Subgraph1", id: 0) { + { + t { + __typename + x + id + } + } + }, + }, [ + Deferred(depends: [0], path: "t", label: "testLabel") { + { y }: + Flatten(path: "t") { + Fetch(service: "Subgraph2") { { - t { + ... on T { __typename id - x } - } - }, - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - y - } + } => + { + ... on T { + y } - }, + } }, }, }, + ] + }, + } Else { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + id + x + } + } }, - } - "### + Flatten(path: "t") { + Fetch(service: "Subgraph2") { + { + ... on T { + __typename + id + } + } => + { + ... on T { + y + } + } + }, + }, + }, + }, + }, + } + "### ); } @@ -2768,11 +2518,7 @@ fn defer_test_defer_with_condition_on_single_subgraph() { Then { Defer { Primary { - { - t { - x - } - }: + { t { x } }: Fetch(service: "Subgraph1", id: 0) { { t { @@ -2784,9 +2530,7 @@ fn defer_test_defer_with_condition_on_single_subgraph() { }, }, [ Deferred(depends: [0], path: "t") { - { - y - }: + { y }: Flatten(path: "t") { Fetch(service: "Subgraph1") { { @@ -2873,151 +2617,50 @@ fn defer_test_defer_with_mutliple_conditions_and_labels() { } "#, @r###" - QueryPlan { - Condition(if: $cond1) { - Then { - Condition(if: $cond2) { - Then { - Defer { - Primary { - { - t { - x - } - }: - Fetch(service: "Subgraph1", id: 0) { - { - t { - __typename - x - id - } - } - }, - }, [ - Deferred(depends: [0], path: "t", label: "bar") { - Defer { - Primary { - { - u { - a - } - }: - Flatten(path: "t") { - Fetch(service: "Subgraph1", id: 1) { - { - ... on T { - __typename - id - } - } => - { - ... on T { - u { - __typename - a - id - } - } - } - }, - }, - }, [ - Deferred(depends: [1], path: "t/u") { - { - b - }: - Flatten(path: "t.u") { - Fetch(service: "Subgraph3") { - { - ... on U { - __typename - id - } - } => - { - ... on U { - b - } - } - }, - }, - }, - ] - }, - }, - Deferred(depends: [0], path: "t", label: "foo") { - { - y - }: - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - y - } - } - }, - }, - }, - ] - }, - } Else { + QueryPlan { + Condition(if: $cond1) { + Then { + Condition(if: $cond2) { + Then { + Defer { + Primary { + { t { x } }: + Fetch(service: "Subgraph1", id: 0) { + { + t { + __typename + x + id + } + } + }, + }, [ + Deferred(depends: [0], path: "t", label: "bar") { Defer { Primary { - { - t { - x - u { - a - } - } - }: - Fetch(service: "Subgraph1", id: 2) { - { - t { - __typename - x - id - u { + { u { a } }: + Flatten(path: "t") { + Fetch(service: "Subgraph1", id: 1) { + { + ... on T { __typename - a id } - } - } - }, - }, [ - Deferred(depends: [2], path: "t", label: "foo") { - { - y - }: - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { + } => + { + ... on T { + u { __typename + a id } - } => - { - ... on T { - y - } } - }, + } }, }, - Deferred(depends: [2], path: "t/u") { - { - b - }: + }, [ + Deferred(depends: [1], path: "t/u") { + { b }: Flatten(path: "t.u") { Fetch(service: "Subgraph3") { { @@ -3036,147 +2679,215 @@ fn defer_test_defer_with_mutliple_conditions_and_labels() { }, ] }, - }, - }, - } Else { - Condition(if: $cond2) { - Then { - Defer { - Primary { + }, + Deferred(depends: [0], path: "t", label: "foo") { + { y }: + Flatten(path: "t") { + Fetch(service: "Subgraph2") { + { + ... on T { + __typename + id + } + } => + { + ... on T { + y + } + } + }, + }, + }, + ] + }, + } Else { + Defer { + Primary { + { t { x u { a } } }: + Fetch(service: "Subgraph1", id: 2) { + { + t { + __typename + x + id + u { + __typename + a + id + } + } + } + }, + }, [ + Deferred(depends: [2], path: "t", label: "foo") { + { y }: + Flatten(path: "t") { + Fetch(service: "Subgraph2") { + { + ... on T { + __typename + id + } + } => + { + ... on T { + y + } + } + }, + }, + }, + Deferred(depends: [2], path: "t/u") { + { b }: + Flatten(path: "t.u") { + Fetch(service: "Subgraph3") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + b + } + } + }, + }, + }, + ] + }, + }, + }, + } Else { + Condition(if: $cond2) { + Then { + Defer { + Primary { + { t { x y } }: + Sequence { + Fetch(service: "Subgraph1", id: 3) { + { + t { + __typename + id + x + } + } + }, + Flatten(path: "t") { + Fetch(service: "Subgraph2") { + { + ... on T { + __typename + id + } + } => { - t { - x + ... on T { y } - }: - Sequence { - Fetch(service: "Subgraph1", id: 3) { - { - t { - __typename - id - x - } - } - }, - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - y - } - } - }, - }, - }, - }, [ - Deferred(depends: [3], path: "t", label: "bar") { - { - u { - a - b - } - }: - Sequence { - Flatten(path: "t") { - Fetch(service: "Subgraph1") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - u { - __typename - id - a - } - } - } - }, - }, - Flatten(path: "t.u") { - Fetch(service: "Subgraph3") { - { - ... on U { - __typename - id - } - } => - { - ... on U { - b - } - } - }, - }, - }, - }, - ] + } + }, }, - } Else { + }, + }, [ + Deferred(depends: [3], path: "t", label: "bar") { + { u { a b } }: Sequence { - Fetch(service: "Subgraph1") { - { - t { - __typename - id - x - u { + Flatten(path: "t") { + Fetch(service: "Subgraph1") { + { + ... on T { __typename id - a } - } - } - }, - Parallel { - Flatten(path: "t.u") { - Fetch(service: "Subgraph3") { - { - ... on U { + } => + { + ... on T { + u { __typename id - } - } => - { - ... on U { - b + a } } - }, + } }, - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - y - } + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph3") { + { + ... on U { + __typename + id } - }, + } => + { + ... on U { + b + } + } }, }, }, }, + ] + }, + } Else { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + id + x + u { + __typename + id + a + } + } + } + }, + Parallel { + Flatten(path: "t.u") { + Fetch(service: "Subgraph3") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + b + } + } + }, + }, + Flatten(path: "t") { + Fetch(service: "Subgraph2") { + { + ... on T { + __typename + id + } + } => + { + ... on T { + y + } + } + }, + }, }, }, }, - } - "### + }, + }, + }, + } + "### ); } @@ -3236,14 +2947,7 @@ fn defer_test_interface_has_different_definitions_between_subgraphs() { QueryPlan { Defer { Primary { - { - i { - a - ... on T { - b - } - } - }: + { i { a ... on T { b } } }: Sequence { Fetch(service: "Subgraph1") { { @@ -3278,9 +2982,7 @@ fn defer_test_interface_has_different_definitions_between_subgraphs() { }, }, [ Deferred(depends: [], path: "i") { - { - c - }: + { c }: }, ] }, @@ -3338,12 +3040,7 @@ fn defer_test_named_fragments_simple() { }, }, [ Deferred(depends: [0], path: "t") { - { - ... on T { - x - y - } - }: + { ... on T { x y } }: Flatten(path: "t") { Fetch(service: "Subgraph2") { { @@ -3414,12 +3111,7 @@ fn defer_test_fragments_expand_into_same_field_regardless_of_defer() { QueryPlan { Defer { Primary { - { - t { - x - y - } - }: + { t { x y } }: Sequence { Fetch(service: "Subgraph1", id: 0) { { @@ -3448,12 +3140,7 @@ fn defer_test_fragments_expand_into_same_field_regardless_of_defer() { }, }, [ Deferred(depends: [0], path: "t") { - { - ... on T { - y - z - } - }: + { ... on T { y z } }: Flatten(path: "t") { Fetch(service: "Subgraph2") { { @@ -3482,7 +3169,7 @@ fn defer_test_fragments_expand_into_same_field_regardless_of_defer() { fn defer_test_can_request_typename_in_fragment() { // NOTE: There is nothing super special about __typename in theory, but because it's a field // that is always available in all subghraph (for a type the subgraph has), it tends to create - // multiple options for the query planner, and so excercises some code-paths that triggered an + // multiple options for the query planner, and so exercises some code-paths that triggered an // early bug in the handling of `@defer` // (https://github.com/apollographql/federation/issues/2128). let planner = planner!( @@ -3523,11 +3210,7 @@ fn defer_test_can_request_typename_in_fragment() { QueryPlan { Defer { Primary { - { - t { - x - } - }: + { t { x } }: Fetch(service: "Subgraph1", id: 0) { { t { @@ -3539,12 +3222,7 @@ fn defer_test_can_request_typename_in_fragment() { }, }, [ Deferred(depends: [0], path: "t") { - { - ... on T { - __typename - y - } - }: + { ... on T { __typename y } }: Flatten(path: "t") { Fetch(service: "Subgraph2") { { @@ -3610,11 +3288,7 @@ fn defer_test_do_not_merge_query_branches_with_defer() { QueryPlan { Defer { Primary { - { - t { - a - } - }: + { t { a } }: Fetch(service: "Subgraph1", id: 0) { { t { @@ -3626,9 +3300,7 @@ fn defer_test_do_not_merge_query_branches_with_defer() { }, }, [ Deferred(depends: [0], path: "t") { - { - c - }: + { c }: Flatten(path: "t") { Fetch(service: "Subgraph2") { { @@ -3646,9 +3318,7 @@ fn defer_test_do_not_merge_query_branches_with_defer() { }, }, Deferred(depends: [0], path: "t") { - { - b - }: + { b }: Flatten(path: "t") { Fetch(service: "Subgraph1") { { @@ -3700,32 +3370,26 @@ fn defer_test_defer_only_the_key_of_an_entity() { } "#, @r###" - QueryPlan { - Defer { - Primary { - { - t { - v0 - } - }: - Fetch(service: "Subgraph1") { - { - t { - v0 - id - } - } - }, - }, [ - Deferred(depends: [], path: "t") { - { - id - }: - }, - ] + QueryPlan { + Defer { + Primary { + { t { v0 } }: + Fetch(service: "Subgraph1") { + { + t { + v0 + id + } + } }, - } - "### + }, [ + Deferred(depends: [], path: "t") { + { id }: + }, + ] + }, + } + "### ); } @@ -3774,15 +3438,7 @@ fn defer_test_the_path_in_defer_includes_traversed_fragments() { QueryPlan { Defer { Primary { - { - i { - ... on A { - t { - v1 - } - } - } - }: + { i { ... on A { t { v1 } } } }: Fetch(service: "Subgraph1", id: 0) { { i { @@ -3799,9 +3455,7 @@ fn defer_test_the_path_in_defer_includes_traversed_fragments() { }, }, [ Deferred(depends: [0], path: "i/... on A/t") { - { - v2 - }: + { v2 }: Flatten(path: "i.t") { Fetch(service: "Subgraph1") { { @@ -3824,3 +3478,53 @@ fn defer_test_the_path_in_defer_includes_traversed_fragments() { "### ); } + +#[test] +fn defer_on_renamed_root_type() { + let planner = planner!( + config = config_with_defer(), + Subgraph1: r#" + type MyQuery { + thing: Thing + } + + type Thing { + i: Int + } + + schema { query: MyQuery } + "#, + ); + assert_plan!( + &planner, + r#" + { + ... @defer { + thing { i } + } + } + "#, + @r###" + QueryPlan { + Defer { + Primary {}, [ + Deferred(depends: [], path: "") { + { thing { i } }: + Flatten(path: "") { + Fetch(service: "Subgraph1") { + { + ... { + thing { + i + } + } + } + }, + }, + }, + ] + }, + } + "### + ); +} diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/disable_subgraphs.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/disable_subgraphs.rs new file mode 100644 index 0000000000..7579255d5b --- /dev/null +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/disable_subgraphs.rs @@ -0,0 +1,161 @@ +use apollo_federation::error::FederationError; +use apollo_federation::error::SingleFederationError; +use apollo_federation::query_plan::query_planner::QueryPlanOptions; + +const SUBGRAPH_A: &str = r#" + type Query { + foo: Foo + } + + type Foo { + idA: ID! @shareable + idB: ID! @shareable + } +"#; + +const SUBGRAPH_B: &str = r#" + type Foo @key(fields: "idA idB") { + idA: ID! + idB: ID! + bar: String! @shareable + } +"#; + +const SUBGRAPH_C: &str = r#" + type Foo @key(fields: "idA") { + idA: ID! + bar: String! @shareable + } +"#; + +const OPERATION: &str = r#" + query { + foo { + bar + } + } +"#; + +#[test] +fn test_if_less_expensive_subgraph_jump_is_used() { + let planner = planner!( + subgraphA: SUBGRAPH_A, + subgraphB: SUBGRAPH_B, + subgraphC: SUBGRAPH_C, + ); + assert_plan!( + &planner, + OPERATION, + @r###" + QueryPlan { + Sequence { + Fetch(service: "subgraphA") { + { + foo { + __typename + idA + } + } + }, + Flatten(path: "foo") { + Fetch(service: "subgraphC") { + { + ... on Foo { + __typename + idA + } + } => + { + ... on Foo { + bar + } + } + }, + }, + }, + } + "### + ); +} + +#[test] +fn test_if_disabling_less_expensive_subgraph_jump_causes_other_to_be_used() { + // setup_tracing_subscriber().expect("Failed to setup tracing"); + let planner = planner!( + subgraphA: SUBGRAPH_A, + subgraphB: SUBGRAPH_B, + subgraphC: SUBGRAPH_C, + ); + assert_plan!( + &planner, + OPERATION, + QueryPlanOptions { + disabled_subgraph_names: vec!["subgraphC".to_string()].into_iter().collect(), + ..Default::default() + }, + @r###" + QueryPlan { + Sequence { + Fetch(service: "subgraphA") { + { + foo { + __typename + idA + idB + } + } + }, + Flatten(path: "foo") { + Fetch(service: "subgraphB") { + { + ... on Foo { + __typename + idA + idB + } + } => + { + ... on Foo { + bar + } + } + }, + }, + }, + } + "### + ); +} + +#[test] +fn test_if_disabling_all_subgraph_jumps_causes_error() { + let planner = planner!( + subgraphA: SUBGRAPH_A, + subgraphB: SUBGRAPH_B, + subgraphC: SUBGRAPH_C, + ); + let api_schema = planner.api_schema(); + let document = apollo_compiler::ExecutableDocument::parse_and_validate( + api_schema.schema(), + OPERATION, + "operation.graphql", + ) + .expect("valid graphql document"); + assert!(matches!( + planner + .build_query_plan( + &document, + None, + QueryPlanOptions { + disabled_subgraph_names: vec!["subgraphB".to_string(), "subgraphC".to_string()] + .into_iter() + .collect(), + ..Default::default() + }, + ) + .err(), + Some(FederationError::SingleFederationError( + SingleFederationError::NoPlanFoundWithDisabledSubgraphs + )) + )) +} diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/entities.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/entities.rs new file mode 100644 index 0000000000..53f50aad38 --- /dev/null +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/entities.rs @@ -0,0 +1,142 @@ +// TODO this test shows inefficient QP where we make multiple parallel +// fetches of the same entity from the same subgraph but for different paths +#[test] +fn inefficient_entity_fetches_to_same_subgraph() { + let planner = planner!( + Subgraph1: r#" + type V @shareable { + x: Int + } + + interface I { + v: V + } + + type Outer implements I @key(fields: "id") { + id: ID! + v: V + } + "#, + Subgraph2: r#" + type Query { + outer1: Outer + outer2: Outer + } + + type V @shareable { + x: Int + } + + interface I { + v: V + w: Int + } + + type Inner implements I { + v: V + w: Int + } + + type Outer @key(fields: "id") { + id: ID! + inner: Inner + w: Int + } + "#, + ); + assert_plan!( + &planner, + r#" + query { + outer1 { + ...OuterFrag + } + outer2 { + ...OuterFrag + } + } + + fragment OuterFrag on Outer { + ...IFrag + inner { + ...IFrag + } + } + + fragment IFrag on I { + v { + x + } + w + } + "#, + @r#" + QueryPlan { + Sequence { + Fetch(service: "Subgraph2") { + { + outer1 { + __typename + id + w + inner { + v { + x + } + w + } + } + outer2 { + __typename + id + w + inner { + v { + x + } + w + } + } + } + }, + Parallel { + Flatten(path: "outer2") { + Fetch(service: "Subgraph1") { + { + ... on Outer { + __typename + id + } + } => + { + ... on Outer { + v { + x + } + } + } + }, + }, + Flatten(path: "outer1") { + Fetch(service: "Subgraph1") { + { + ... on Outer { + __typename + id + } + } => + { + ... on Outer { + v { + x + } + } + } + }, + }, + }, + }, + } + "# + ); +} diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/fetch_operation_names.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/fetch_operation_names.rs index 884c10e7e1..161a376dc9 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_tests/fetch_operation_names.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/fetch_operation_names.rs @@ -1,9 +1,9 @@ use std::fmt::Write; -use apollo_federation::query_plan::query_planner::QueryPlannerDebugConfig; use apollo_federation::query_plan::PlanNode; use apollo_federation::query_plan::QueryPlan; use apollo_federation::query_plan::TopLevelPlanNode; +use apollo_federation::query_plan::query_planner::QueryPlannerDebugConfig; fn second_operation(plan: &QueryPlan) -> String { let Some(TopLevelPlanNode::Sequence(node)) = &plan.node else { @@ -243,10 +243,11 @@ fn correctly_handle_case_where_there_is_too_many_plans_to_consider() { schema.push_str("\n }\n"); operation.push_str("\n }\n}\n"); - let (api_schema, planner) = planner!( + let planner = planner!( S1: &schema, S2: &schema, ); + let api_schema = planner.api_schema(); let document = apollo_compiler::ExecutableDocument::parse_and_validate( api_schema.schema(), operation, @@ -265,9 +266,10 @@ fn correctly_handle_case_where_there_is_too_many_plans_to_consider() { panic!() }; assert_eq!(fetch.subgraph_name.as_ref(), "S1"); - assert!(fetch.requires.is_none()); - assert!(fetch.operation_document.fragments.is_empty()); - let mut operations = fetch.operation_document.operations.iter(); + assert!(fetch.requires.is_empty()); + let doc = fetch.operation_document.as_parsed().unwrap(); + assert!(doc.fragments.is_empty()); + let mut operations = doc.operations.iter(); let operation = operations.next().unwrap(); assert!(operations.next().is_none()); // operation is essentially: diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/fragment_autogeneration.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/fragment_autogeneration.rs index 03b43c6245..96307a1316 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_tests/fragment_autogeneration.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/fragment_autogeneration.rs @@ -1,5 +1,12 @@ use apollo_federation::query_plan::query_planner::QueryPlannerConfig; +fn generate_fragments_config() -> QueryPlannerConfig { + QueryPlannerConfig { + generate_query_fragments: true, + ..Default::default() + } +} + const SUBGRAPH: &str = r#" directive @custom on INLINE_FRAGMENT | FRAGMENT_SPREAD @@ -22,153 +29,194 @@ const SUBGRAPH: &str = r#" } "#; +// TODO this test shows a worse plan than reused fragments when generated fragments +// target concrete types whereas hand-crafted ones reference abstract types #[test] -fn it_respects_generate_query_fragments_option() { +fn it_handles_nested_fragment_generation_from_operation_with_fragments() { let planner = planner!( - config = QueryPlannerConfig { generate_query_fragments: true, reuse_query_fragments: false, ..Default::default() }, - Subgraph1: SUBGRAPH, - ); - assert_plan!( - &planner, - r#" - query { - t { - ... on A { - x - y - } - ... on B { - z - } + config = generate_fragments_config(), + Subgraph1: r#" + type Query { + a: Anything } - } - "#, + union Anything = A1 | A2 | A3 + interface Foo { + foo: String + child: Foo + child2: Foo + } - // Note: `... on B {}` won't be replaced, since it has only one field. - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - t { - __typename - ..._generated_onA2_0 - ... on B { - z - } + type A1 implements Foo { + foo: String + child: Foo + child2: Foo + } + + type A2 implements Foo { + foo: String + child: Foo + child2: Foo + } + + type A3 implements Foo { + foo: String + child: Foo + child2: Foo + } + "#, + ); + let operation = r#" + query { + a { + ... on A1 { + ...FooSelect + } + ... on A2 { + ...FooSelect + } + ... on A3 { + ...FooSelect } } + } - fragment _generated_onA2_0 on A { - x - y + fragment FooSelect on Foo { + __typename + foo + child { + ...FooChildSelect } - }, - } - "### - ); -} + child2 { + ...FooChildSelect + } + } -#[test] -fn it_handles_nested_fragment_generation() { - let planner = planner!( - config = QueryPlannerConfig { generate_query_fragments: true, reuse_query_fragments: false, ..Default::default() }, - Subgraph1: SUBGRAPH, - ); - assert_plan!( - &planner, - r#" - query { - t { - ... on A { - x - y - t { - ... on A { - x - y - } - ... on B { - z - } - } + fragment FooChildSelect on Foo { + __typename + foo + child { + child { + child { + foo } } } - "#, + } + "#; + assert_plan!( + &planner, + operation, - // Note: `... on B {}` won't be replaced, since it has only one field. + // This is a test case that shows worse result + // QueryPlan { + // Fetch(service: "Subgraph1") { + // { + // a { + // __typename + // ... on A1 { + // ...FooSelect + // } + // ... on A2 { + // ...FooSelect + // } + // ... on A3 { + // ...FooSelect + // } + // } + // } + // + // fragment FooChildSelect on Foo { + // __typename + // foo + // child { + // __typename + // child { + // __typename + // child { + // __typename + // foo + // } + // } + // } + // } + // + // fragment FooSelect on Foo { + // __typename + // foo + // child { + // ...FooChildSelect + // } + // child2 { + // ...FooChildSelect + // } + // } + // }, + // } @r###" QueryPlan { Fetch(service: "Subgraph1") { { - t { + a { __typename - ..._generated_onA3_0 + ... on A1 { + __typename + foo + child { + ...d + } + child2 { + ...d + } + } + ... on A2 { + __typename + foo + child { + ...d + } + child2 { + ...d + } + } + ... on A3 { + __typename + foo + child { + ...d + } + child2 { + ...d + } + } } } - fragment _generated_onA2_0 on A { - x - y + fragment a on Foo { + __typename + foo } - fragment _generated_onA3_0 on A { - x - y - t { - __typename - ..._generated_onA2_0 - ... on B { - z - } + fragment b on Foo { + __typename + child { + ...a } } - }, - } - "### - ); -} - -#[test] -fn it_handles_fragments_with_one_non_leaf_field() { - let planner = planner!( - config = QueryPlannerConfig { generate_query_fragments: true, reuse_query_fragments: false, ..Default::default() }, - Subgraph1: SUBGRAPH, - ); - assert_plan!( - &planner, - r#" - query { - t { - ... on A { - t { - ... on B { - z - } - } - } - } - } - "#, - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - t { - __typename - ..._generated_onA1_0 + fragment c on Foo { + __typename + child { + ...b } } - fragment _generated_onA1_0 on A { - t { - __typename - ... on B { - z - } + fragment d on Foo { + __typename + foo + child { + ...c } } }, @@ -177,13 +225,10 @@ fn it_handles_fragments_with_one_non_leaf_field() { ); } -/// XXX(@goto-bus-stop): this test is meant to check that fragments with @skip and @include *are* -/// migrated. But we are currently matching JS behavior, where they are not. This test should be -/// updated when we remove JS compatibility. #[test] fn it_migrates_skip_include() { let planner = planner!( - config = QueryPlannerConfig { generate_query_fragments: true, reuse_query_fragments: false, ..Default::default() }, + config = generate_fragments_config(), Subgraph1: SUBGRAPH, ); assert_plan!( @@ -215,80 +260,29 @@ fn it_migrates_skip_include() { // Note: `... on A @custom {}` won't be replaced, since it has a custom directive. Even // though it also supports being used on a named fragment spread, we cannot assume that - // the behaviour is exactly the same. + // the behaviour is exactly the same. We will replace its subselection though. @r###" QueryPlan { Fetch(service: "Subgraph1") { { t { __typename - ..._generated_onA3_0 - } - } - - fragment _generated_onA3_0 on A { - x - y - t { - __typename - ... on A @include(if: $var) { - x - y - } - ... on A @skip(if: $var) { - x - y - } - ... on A @custom { - x - y - } - } - } - }, - } - "### - ); -} -#[test] -fn it_identifies_and_reuses_equivalent_fragments_that_arent_identical() { - let planner = planner!( - config = QueryPlannerConfig { generate_query_fragments: true, reuse_query_fragments: false, ..Default::default() }, - Subgraph1: SUBGRAPH, - ); - assert_plan!( - &planner, - r#" - query { - t { ... on A { x y - } - } - t2 { - ... on A { - y - x + t { + __typename + ...a @include(if: $var) + ...a @skip(if: $var) + ... on A @custom { + ...a + } + } } } } - "#, - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - t { - __typename - ..._generated_onA2_0 - } - t2 { - __typename - ..._generated_onA2_0 - } - } - fragment _generated_onA2_0 on A { + fragment a on A { x y } @@ -299,9 +293,9 @@ fn it_identifies_and_reuses_equivalent_fragments_that_arent_identical() { } #[test] -fn fragments_that_share_a_hash_but_are_not_identical_generate_their_own_fragment_definitions() { +fn it_identifies_and_reuses_equivalent_fragments_that_arent_identical() { let planner = planner!( - config = QueryPlannerConfig { generate_query_fragments: true, reuse_query_fragments: false, ..Default::default() }, + config = generate_fragments_config(), Subgraph1: SUBGRAPH, ); assert_plan!( @@ -317,7 +311,7 @@ fn fragments_that_share_a_hash_but_are_not_identical_generate_their_own_fragment t2 { ... on A { y - z + x } } } @@ -327,23 +321,21 @@ fn fragments_that_share_a_hash_but_are_not_identical_generate_their_own_fragment Fetch(service: "Subgraph1") { { t { - __typename - ..._generated_onA2_0 + ...b } t2 { - __typename - ..._generated_onA2_1 + ...b } } - fragment _generated_onA2_0 on A { + fragment a on A { x y } - fragment _generated_onA2_1 on A { - y - z + fragment b on T { + __typename + ...a } }, } @@ -354,7 +346,7 @@ fn fragments_that_share_a_hash_but_are_not_identical_generate_their_own_fragment #[test] fn same_as_js_router798() { let planner = planner!( - config = QueryPlannerConfig { generate_query_fragments: true, reuse_query_fragments: false, ..Default::default() }, + config = generate_fragments_config(), Subgraph1: r#" interface Interface { a: Int } type Y implements Interface { a: Int b: Int } @@ -398,6 +390,7 @@ fn same_as_js_router798() { #[test] fn works_with_key_chains() { let planner = planner!( + config = generate_fragments_config(), Subgraph1: r#" type Query { t: T @@ -411,6 +404,13 @@ fn works_with_key_chains() { type T @key(fields: "id1") @key(fields: "id2") { id1: ID! id2: ID! + u1: U + u2: U + } + + type U { + a: String + b: Int } "#, Subgraph3: r#" @@ -433,6 +433,14 @@ fn works_with_key_chains() { id2 x y + u1 { + a + b + } + u2 { + a + b + } } } "#, @@ -458,8 +466,19 @@ fn works_with_key_chains() { { ... on T { id2 + u1 { + ...a + } + u2 { + ...a + } } } + + fragment a on U { + a + b + } }, }, Flatten(path: "t") { @@ -483,3 +502,182 @@ fn works_with_key_chains() { "### ); } + +// TODO this test shows redundant inline fragment in the "normalized" query +// - ... on T2 inline fragment should be dropped during normalization +#[test] +fn another_mix_of_fragments_indirection_and_unions() { + // This tests that the issue reported on https://github.com/apollographql/router/issues/3172 is resolved. + let planner = planner!( + config = generate_fragments_config(), + Subgraph1: r#" + type Query { + owner: Owner! + } + + interface OItf { + id: ID! + v0: String! + } + + type Owner implements OItf { + id: ID! + v0: String! + u: [U] + } + + union U = T1 | T2 + + interface I { + id1: ID! + id2: ID! + } + + type T1 implements I { + id1: ID! + id2: ID! + owner: Owner! + } + + type T2 implements I { + id1: ID! + id2: ID! + } + "#, + ); + assert_plan!( + &planner, + r#" + { + owner { + u { + ... on I { + id1 + id2 + } + ...Fragment1 + ...Fragment2 + } + } + } + + fragment Fragment1 on T1 { + owner { + ... on Owner { + ...Fragment3 + } + } + } + + fragment Fragment2 on T2 { + ...Fragment4 + id1 + } + + fragment Fragment3 on OItf { + v0 + } + + fragment Fragment4 on I { + id1 + id2 + __typename + } + "#, + @r###" + QueryPlan { + Fetch(service: "Subgraph1") { + { + owner { + u { + __typename + ... on I { + __typename + id1 + id2 + } + ... on T1 { + owner { + v0 + } + } + ... on T2 { + __typename + id1 + id2 + } + } + } + } + }, + } + "### + ); + + assert_plan!( + &planner, + r#" + { + owner { + u { + ... on I { + id1 + id2 + } + ...Fragment1 + ...Fragment2 + } + } + } + + fragment Fragment1 on T1 { + owner { + ... on Owner { + ...Fragment3 + } + } + } + + fragment Fragment2 on T2 { + ...Fragment4 + id1 + } + + fragment Fragment3 on OItf { + v0 + } + + fragment Fragment4 on I { + id1 + id2 + } + "#, + @r###" + QueryPlan { + Fetch(service: "Subgraph1") { + { + owner { + u { + __typename + ... on I { + __typename + id1 + id2 + } + ... on T1 { + owner { + v0 + } + } + ... on T2 { + id1 + id2 + } + } + } + } + }, + } + "### + ); +} diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/handles_operations_with_directives.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/handles_operations_with_directives.rs index 23508b7d02..03cf08aee5 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_tests/handles_operations_with_directives.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/handles_operations_with_directives.rs @@ -112,7 +112,7 @@ fn test_if_directives_at_the_operation_level_are_passed_down_to_subgraph_queries let a_fetch_nodes = find_fetch_nodes_for_subgraph("subgraphA", &plan); assert_eq!(a_fetch_nodes.len(), 1); // Note: The query is expected to carry the `@operation` directive. - insta::assert_snapshot!(a_fetch_nodes[0].operation_document, @r#" + insta::assert_snapshot!(a_fetch_nodes[0].operation_document.as_parsed().unwrap(), @r#" query Operation__subgraphA__0 @operation { foo @field { __typename @@ -129,7 +129,7 @@ fn test_if_directives_at_the_operation_level_are_passed_down_to_subgraph_queries let b_fetch_nodes = find_fetch_nodes_for_subgraph("subgraphB", &plan); assert_eq!(b_fetch_nodes.len(), 2); // Note: The query is expected to carry the `@operation` directive. - insta::assert_snapshot!(b_fetch_nodes[0].operation_document, @r#" + insta::assert_snapshot!(b_fetch_nodes[0].operation_document.as_parsed().unwrap(), @r#" query Operation__subgraphB__1($representations: [_Any!]!) @operation { _entities(representations: $representations) { ... on T { @@ -139,7 +139,7 @@ fn test_if_directives_at_the_operation_level_are_passed_down_to_subgraph_queries } "#); // Note: The query is expected to carry the `@operation` directive. - insta::assert_snapshot!(b_fetch_nodes[1].operation_document, @r#" + insta::assert_snapshot!(b_fetch_nodes[1].operation_document.as_parsed().unwrap(), @r#" query Operation__subgraphB__2($representations: [_Any!]!) @operation { _entities(representations: $representations) { ... on Foo { @@ -186,7 +186,7 @@ fn test_if_directives_on_mutations_are_passed_down_to_subgraph_queries() { let fetch_nodes = find_fetch_nodes_for_subgraph("subgraphA", &plan); assert_eq!(fetch_nodes.len(), 1); // Note: The query is expected to carry the `@operation` directive. - insta::assert_snapshot!(fetch_nodes[0].operation_document, @r#" + insta::assert_snapshot!(fetch_nodes[0].operation_document.as_parsed().unwrap(), @r#" mutation TestMutation__subgraphA__0 @operation { updateFoo(bar: "something") @field { id @field @@ -233,7 +233,7 @@ fn test_if_directives_with_arguments_applied_on_queries_are_ok() { let fetch_nodes = find_fetch_nodes_for_subgraph("Subgraph1", &plan); assert_eq!(fetch_nodes.len(), 1); // Note: The query is expected to carry the `@noArgs` and `@withArgs` directive. - insta::assert_snapshot!(fetch_nodes[0].operation_document, @r#" + insta::assert_snapshot!(fetch_nodes[0].operation_document.as_parsed().unwrap(), @r#" query @noArgs @withArgs(arg1: "hi") { test } @@ -276,7 +276,7 @@ fn subgraph_query_retains_the_query_variables_used_in_the_directives_applied_to_ let fetch_nodes = find_fetch_nodes_for_subgraph("Subgraph1", &plan); assert_eq!(fetch_nodes.len(), 1); // Note: `($some_var: String!)` used to be missing. - insta::assert_snapshot!(fetch_nodes[0].operation_document, @r#" + insta::assert_snapshot!(fetch_nodes[0].operation_document.as_parsed().unwrap(), @r#" query testQuery__Subgraph1__0($some_var: String!) @withArgs(arg1: $some_var) { test } diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/interface_object.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/interface_object.rs index 2f8ec2a798..77d8211ff2 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_tests/interface_object.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/interface_object.rs @@ -2,6 +2,7 @@ use std::ops::Deref; use apollo_federation::query_plan::FetchDataPathElement; use apollo_federation::query_plan::FetchDataRewrite; +use apollo_federation::query_plan::query_planner::QueryPlannerConfig; use crate::query_plan::build_query_plan_support::find_fetch_nodes_for_subgraph; @@ -368,8 +369,8 @@ fn can_use_a_key_on_an_interface_object_type_even_for_a_concrete_implementation( } #[test] -fn handles_query_of_an_interface_field_for_a_specific_implementation_when_query_starts_with_interface_object( -) { +fn handles_query_of_an_interface_field_for_a_specific_implementation_when_query_starts_with_interface_object() + { let planner = planner!( S1: SUBGRAPH1, S2: SUBGRAPH2, @@ -956,3 +957,103 @@ fn test_interface_object_advance_with_non_collecting_and_type_preserving_transit "### ); } + +#[test] +fn test_type_conditioned_fetching_with_interface_object_does_not_crash() { + let planner = planner!( + config = QueryPlannerConfig { + type_conditioned_fetching: true, + ..Default::default() + }, + S1: r#" + type I @interfaceObject @key(fields: "id") { + id: ID! + t: T + } + + type T { + relatedIs: [I] + } + "#, + S2: r#" + type Query { + i: I + } + + interface I @key(fields: "id") { + id: ID! + a: Int + } + + type A implements I @key(fields: "id") { + id: ID! + a: Int + } + "#, + ); + assert_plan!( + &planner, + r#" + { + i { + t { + relatedIs { + a + } + } + } + } + "#, + + @r###" + QueryPlan { + Sequence { + Fetch(service: "S2") { + { + i { + __typename + id + } + } + }, + Flatten(path: "i") { + Fetch(service: "S1") { + { + ... on I { + __typename + id + } + } => + { + ... on I { + t { + relatedIs { + __typename + id + } + } + } + } + }, + }, + Flatten(path: "i.t.relatedIs.@") { + Fetch(service: "S2") { + { + ... on I { + __typename + id + } + } => + { + ... on I { + __typename + a + } + } + }, + }, + }, + } + "### + ); +} diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/introspection_typename_handling.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/introspection_typename_handling.rs index 73d6567f98..9fead2c626 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_tests/introspection_typename_handling.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/introspection_typename_handling.rs @@ -398,7 +398,8 @@ fn test_indirect_branch_merging_with_typename_sibling() { // This operation has two `f` selection instances: One with __typename sibling and one without. // It creates multiple identical branches in the form of `... on A { f }` with different `f`. // The query plan must chose one over the other, which is implementation specific. - // Currently, the last one is chosen. + // This test is to make sure we choose the one with a typename sibling attached, thus + // we won't lose the requested `__typename` selection. assert_plan!( &planner, r#" @@ -444,6 +445,7 @@ fn test_indirect_branch_merging_with_typename_sibling() { } => { ... on A { + __typename f } ... on B { diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/merged_abstract_types_handling.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/merged_abstract_types_handling.rs index 82eade721c..6267bb685b 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_tests/merged_abstract_types_handling.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/merged_abstract_types_handling.rs @@ -801,7 +801,10 @@ fn handles_types_with_no_common_supertype_at_the_same_merge_at() { } "#, ); + // Note: This plan is ambiguous without type-conditioned fetching (FED-515). + // So, correctness check is disabled. assert_plan!( + validate_correctness = false, &planner, r#" { diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/named_fragments.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/named_fragments.rs deleted file mode 100644 index 959069588c..0000000000 --- a/apollo-federation/tests/query_plan/build_query_plan_tests/named_fragments.rs +++ /dev/null @@ -1,563 +0,0 @@ -use apollo_federation::query_plan::query_planner::QueryPlannerConfig; - -fn reuse_fragments_config() -> QueryPlannerConfig { - QueryPlannerConfig { - reuse_query_fragments: true, - ..Default::default() - } -} - -#[test] -fn handles_mix_of_fragments_indirection_and_unions() { - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type Query { - parent: Parent - } - - union CatOrPerson = Cat | Parent | Child - - type Parent { - childs: [Child] - } - - type Child { - id: ID! - } - - type Cat { - name: String - } - "#, - ); - assert_plan!( - &planner, - r#" - query { - parent { - ...F_indirection1_parent - } - } - - fragment F_indirection1_parent on Parent { - ...F_indirection2_catOrPerson - } - - fragment F_indirection2_catOrPerson on CatOrPerson { - ...F_catOrPerson - } - - fragment F_catOrPerson on CatOrPerson { - __typename - ... on Cat { - name - } - ... on Parent { - childs { - __typename - id - } - } - } - "#, - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - parent { - __typename - childs { - __typename - id - } - } - } - }, - } - "### - ); -} - -#[test] -fn another_mix_of_fragments_indirection_and_unions() { - // This tests that the issue reported on https://github.com/apollographql/router/issues/3172 is resolved. - - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type Query { - owner: Owner! - } - - interface OItf { - id: ID! - v0: String! - } - - type Owner implements OItf { - id: ID! - v0: String! - u: [U] - } - - union U = T1 | T2 - - interface I { - id1: ID! - id2: ID! - } - - type T1 implements I { - id1: ID! - id2: ID! - owner: Owner! - } - - type T2 implements I { - id1: ID! - id2: ID! - } - "#, - ); - assert_plan!( - &planner, - r#" - { - owner { - u { - ... on I { - id1 - id2 - } - ...Fragment1 - ...Fragment2 - } - } - } - - fragment Fragment1 on T1 { - owner { - ... on Owner { - ...Fragment3 - } - } - } - - fragment Fragment2 on T2 { - ...Fragment4 - id1 - } - - fragment Fragment3 on OItf { - v0 - } - - fragment Fragment4 on I { - id1 - id2 - __typename - } - "#, - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - owner { - u { - __typename - ...Fragment4 - ... on T1 { - owner { - v0 - } - } - ... on T2 { - ...Fragment4 - } - } - } - } - - fragment Fragment4 on I { - __typename - id1 - id2 - } - }, - } - "### - ); - - assert_plan!( - &planner, - r#" - { - owner { - u { - ... on I { - id1 - id2 - } - ...Fragment1 - ...Fragment2 - } - } - } - - fragment Fragment1 on T1 { - owner { - ... on Owner { - ...Fragment3 - } - } - } - - fragment Fragment2 on T2 { - ...Fragment4 - id1 - } - - fragment Fragment3 on OItf { - v0 - } - - fragment Fragment4 on I { - id1 - id2 - } - "#, - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - owner { - u { - __typename - ... on I { - __typename - ...Fragment4 - } - ... on T1 { - owner { - v0 - } - } - ... on T2 { - ...Fragment4 - } - } - } - } - - fragment Fragment4 on I { - id1 - id2 - } - }, - } - "### - ); -} - -#[test] -fn handles_fragments_with_interface_field_subtyping() { - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type Query { - t1: T1! - } - - interface I { - id: ID! - other: I! - } - - type T1 implements I { - id: ID! - other: T1! - } - - type T2 implements I { - id: ID! - other: T2! - } - "#, - ); - assert_plan!( - &planner, - r#" - { - t1 { - ...Fragment1 - } - } - - fragment Fragment1 on I { - other { - ... on T1 { - id - } - ... on T2 { - id - } - } - } - "#, - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - t1 { - other { - __typename - id - } - } - } - }, - } - "### - ); -} - -#[test] -fn can_reuse_fragments_in_subgraph_where_they_only_partially_apply_in_root_fetch() { - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type Query { - t1: T - t2: T - } - - type T @key(fields: "id") { - id: ID! - v0: Int - v1: Int - v2: Int - } - "#, - Subgraph2: r#" - type T @key(fields: "id") { - id: ID! - v3: Int - } - "#, - ); - assert_plan!( - &planner, - r#" - { - t1 { - ...allTFields - } - t2 { - ...allTFields - } - } - - fragment allTFields on T { - v0 - v1 - v2 - v3 - } - "#, - @r###" - QueryPlan { - Sequence { - Fetch(service: "Subgraph1") { - { - t1 { - __typename - ...allTFields - id - } - t2 { - __typename - ...allTFields - id - } - } - - fragment allTFields on T { - v0 - v1 - v2 - } - }, - Parallel { - Flatten(path: "t2") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v3 - } - } - }, - }, - Flatten(path: "t1") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v3 - } - } - }, - }, - }, - }, - } - "### - ); -} - -#[test] -fn can_reuse_fragments_in_subgraph_where_they_only_partially_apply_in_entity_fetch() { - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type Query { - t: T - } - - type T @key(fields: "id") { - id: ID! - } - "#, - Subgraph2: r#" - type T @key(fields: "id") { - id: ID! - u1: U - u2: U - } - - type U @key(fields: "id") { - id: ID! - v0: Int - v1: Int - } - "#, - Subgraph3: r#" - type U @key(fields: "id") { - id: ID! - v2: Int - v3: Int - } - "#, - ); - - assert_plan!( - &planner, - r#" - { - t { - u1 { - ...allUFields - } - u2 { - ...allUFields - } - } - } - - fragment allUFields on U { - v0 - v1 - v2 - v3 - } - "#, - @r###" - QueryPlan { - Sequence { - Fetch(service: "Subgraph1") { - { - t { - __typename - id - } - } - }, - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - u1 { - __typename - ...allUFields - id - } - u2 { - __typename - ...allUFields - id - } - } - } - - fragment allUFields on U { - v0 - v1 - } - }, - }, - Parallel { - Flatten(path: "t.u2") { - Fetch(service: "Subgraph3") { - { - ... on U { - __typename - id - } - } => - { - ... on U { - v2 - v3 - } - } - }, - }, - Flatten(path: "t.u1") { - Fetch(service: "Subgraph3") { - { - ... on U { - __typename - id - } - } => - { - ... on U { - v2 - v3 - } - } - }, - }, - }, - }, - } - "### - ); -} diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/named_fragments_expansion.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/named_fragments_expansion.rs new file mode 100644 index 0000000000..5b68d3e059 --- /dev/null +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/named_fragments_expansion.rs @@ -0,0 +1,369 @@ +#[test] +fn handles_mix_of_fragments_indirection_and_unions() { + let planner = planner!( + Subgraph1: r#" + type Query { + parent: Parent + } + + union CatOrPerson = Cat | Parent | Child + + type Parent { + childs: [Child] + } + + type Child { + id: ID! + } + + type Cat { + name: String + } + "#, + ); + assert_plan!( + &planner, + r#" + query { + parent { + ...F_indirection1_parent + } + } + + fragment F_indirection1_parent on Parent { + ...F_indirection2_catOrPerson + } + + fragment F_indirection2_catOrPerson on CatOrPerson { + ...F_catOrPerson + } + + fragment F_catOrPerson on CatOrPerson { + __typename + ... on Cat { + name + } + ... on Parent { + childs { + __typename + id + } + } + } + "#, + @r###" + QueryPlan { + Fetch(service: "Subgraph1") { + { + parent { + __typename + childs { + __typename + id + } + } + } + }, + } + "### + ); +} + +#[test] +fn handles_fragments_with_interface_field_subtyping() { + let planner = planner!( + Subgraph1: r#" + type Query { + t1: T1! + } + + interface I { + id: ID! + other: I! + } + + type T1 implements I { + id: ID! + other: T1! + } + + type T2 implements I { + id: ID! + other: T2! + } + "#, + ); + + assert_plan!( + &planner, + r#" + { + t1 { + ...Fragment1 + } + } + + fragment Fragment1 on I { + other { + ... on T1 { + id + } + ... on T2 { + id + } + } + } + "#, + @r###" + QueryPlan { + Fetch(service: "Subgraph1") { + { + t1 { + other { + id + } + } + } + }, + } + "### + ); +} + +#[test] +fn it_preserves_directives() { + // (because used only once) + let planner = planner!( + Subgraph1: r#" + type Query { + t: T + } + + type T @key(fields: "id") { + id: ID! + a: Int + b: Int + } + "#, + ); + assert_plan!( + &planner, + r#" + query test($if: Boolean!) { + t { + id + ...OnT @include(if: $if) + } + } + + fragment OnT on T { + a + b + } + "#, + @r###" + QueryPlan { + Fetch(service: "Subgraph1") { + { + t { + id + ... on T @include(if: $if) { + a + b + } + } + } + }, + } + "### + ); +} + +#[test] +fn it_preserves_directives_when_fragment_is_reused() { + let planner = planner!( + Subgraph1: r#" + type Query { + t: T + } + + type T @key(fields: "id") { + id: ID! + a: Int + b: Int + } + "#, + ); + assert_plan!( + &planner, + r#" + query test($test1: Boolean!, $test2: Boolean!) { + t { + id + ...OnT @include(if: $test1) + ...OnT @include(if: $test2) + } + } + + fragment OnT on T { + a + b + } + "#, + @r###" + QueryPlan { + Fetch(service: "Subgraph1") { + { + t { + id + ... on T @include(if: $test1) { + a + b + } + ... on T @include(if: $test2) { + a + b + } + } + } + }, + } + "### + ); +} + +#[test] +fn it_preserves_directives_on_collapsed_fragments() { + let planner = planner!( + Subgraph1: r#" + type Query { + t: T + } + + type T { + id: ID! + t1: V + t2: V + } + + type V { + v1: Int + v2: Int + } + "#, + ); + assert_plan!( + &planner, + r#" + query($test: Boolean!) { + t { + ...OnT + } + } + + fragment OnT on T { + id + ...OnTInner @include(if: $test) + } + + fragment OnTInner on T { + t1 { + ...OnV + } + t2 { + ...OnV + } + } + + fragment OnV on V { + v1 + v2 + } + "#, + @r###" + QueryPlan { + Fetch(service: "Subgraph1") { + { + t { + id + ... on T @include(if: $test) { + t1 { + v1 + v2 + } + t2 { + v1 + v2 + } + } + } + } + }, + } + "### + ); +} + +#[test] +fn it_expands_nested_fragments() { + let planner = planner!( + Subgraph1: r#" + type Query { + t: T + } + + type T @key(fields: "id") { + id: ID! + a: V + b: V + } + + type V { + v1: Int + v2: Int + } + "#, + ); + assert_plan!( + &planner, + r#" + { + t { + ...OnT + } + } + + fragment OnT on T { + a { + ...OnV + } + b { + ...OnV + } + } + + fragment OnV on V { + v1 + v2 + } + "#, + @r###" + QueryPlan { + Fetch(service: "Subgraph1") { + { + t { + a { + v1 + v2 + } + b { + v1 + v2 + } + } + } + }, + } + "### + ); +} diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/named_fragments_preservation.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/named_fragments_preservation.rs deleted file mode 100644 index da90c3edb8..0000000000 --- a/apollo-federation/tests/query_plan/build_query_plan_tests/named_fragments_preservation.rs +++ /dev/null @@ -1,1384 +0,0 @@ -use apollo_federation::query_plan::query_planner::QueryPlannerConfig; - -fn reuse_fragments_config() -> QueryPlannerConfig { - QueryPlannerConfig { - reuse_query_fragments: true, - ..Default::default() - } -} - -#[test] -fn it_works_with_nested_fragments_1() { - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type Query { - a: Anything - } - - union Anything = A1 | A2 | A3 - - interface Foo { - foo: String - child: Foo - child2: Foo - } - - type A1 implements Foo { - foo: String - child: Foo - child2: Foo - } - - type A2 implements Foo { - foo: String - child: Foo - child2: Foo - } - - type A3 implements Foo { - foo: String - child: Foo - child2: Foo - } - "#, - ); - assert_plan!( - &planner, - r#" - query { - a { - ... on A1 { - ...FooSelect - } - ... on A2 { - ...FooSelect - } - ... on A3 { - ...FooSelect - } - } - } - - fragment FooSelect on Foo { - __typename - foo - child { - ...FooChildSelect - } - child2 { - ...FooChildSelect - } - } - - fragment FooChildSelect on Foo { - __typename - foo - child { - child { - child { - foo - } - } - } - } - "#, - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - a { - __typename - ... on A1 { - ...FooSelect - } - ... on A2 { - ...FooSelect - } - ... on A3 { - ...FooSelect - } - } - } - - fragment FooChildSelect on Foo { - __typename - foo - child { - __typename - child { - __typename - child { - __typename - foo - } - } - } - } - - fragment FooSelect on Foo { - __typename - foo - child { - ...FooChildSelect - } - child2 { - ...FooChildSelect - } - } - }, - } - "### - ); -} - -#[test] -fn it_avoid_fragments_usable_only_once() { - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type Query { - t: T - } - - type T @key(fields: "id") { - id: ID! - v1: V - } - - type V @shareable { - a: Int - b: Int - c: Int - } - "#, - Subgraph2: r#" - type T @key(fields: "id") { - id: ID! - v2: V - v3: V - } - - type V @shareable { - a: Int - b: Int - c: Int - } - "#, - ); - - // We use a fragment which does save some on the original query, but as each - // field gets to a different subgraph, the fragment would only be used one - // on each sub-fetch and we make sure the fragment is not used in that case. - assert_plan!( - &planner, - r#" - query { - t { - v1 { - ...OnV - } - v2 { - ...OnV - } - } - } - - fragment OnV on V { - a - b - c - } - "#, - @r###" - QueryPlan { - Sequence { - Fetch(service: "Subgraph1") { - { - t { - __typename - id - v1 { - a - b - c - } - } - } - }, - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v2 { - a - b - c - } - } - } - }, - }, - }, - } - "### - ); - - // But double-check that if we query 2 fields from the same subgraph, then - // the fragment gets used now. - assert_plan!( - &planner, - r#" - query { - t { - v2 { - ...OnV - } - v3 { - ...OnV - } - } - } - - fragment OnV on V { - a - b - c - } - "#, - @r###" - QueryPlan { - Sequence { - Fetch(service: "Subgraph1") { - { - t { - __typename - id - } - } - }, - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { - __typename - id - } - } => - { - ... on T { - v2 { - ...OnV - } - v3 { - ...OnV - } - } - } - - fragment OnV on V { - a - b - c - } - }, - }, - }, - } - "### - ); -} - -mod respects_query_planner_option_reuse_query_fragments { - use super::*; - - const SUBGRAPH1: &str = r#" - type Query { - t: T - } - - type T { - a1: A - a2: A - } - - type A { - x: Int - y: Int - } - "#; - const QUERY: &str = r#" - query { - t { - a1 { - ...Selection - } - a2 { - ...Selection - } - } - } - - fragment Selection on A { - x - y - } - "#; - - #[test] - fn respects_query_planner_option_reuse_query_fragments_true() { - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: SUBGRAPH1, - ); - let query = QUERY; - - assert_plan!( - &planner, - query, - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - t { - a1 { - ...Selection - } - a2 { - ...Selection - } - } - } - - fragment Selection on A { - x - y - } - }, - } - "### - ); - } - - #[test] - fn respects_query_planner_option_reuse_query_fragments_false() { - let reuse_query_fragments = false; - let planner = planner!( - config = QueryPlannerConfig {reuse_query_fragments, ..Default::default()}, - Subgraph1: SUBGRAPH1, - ); - let query = QUERY; - - assert_plan!( - &planner, - query, - @r#" - QueryPlan { - Fetch(service: "Subgraph1") { - { - t { - a1 { - x - y - } - a2 { - x - y - } - } - } - }, - } - "# - ); - } -} - -#[test] -fn it_works_with_nested_fragments_when_only_the_nested_fragment_gets_preserved() { - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type Query { - t: T - } - - type T @key(fields: "id") { - id: ID! - a: V - b: V - } - - type V { - v1: Int - v2: Int - } - "#, - ); - assert_plan!( - &planner, - r#" - { - t { - ...OnT - } - } - - fragment OnT on T { - a { - ...OnV - } - b { - ...OnV - } - } - - fragment OnV on V { - v1 - v2 - } - "#, - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - t { - a { - ...OnV - } - b { - ...OnV - } - } - } - - fragment OnV on V { - v1 - v2 - } - }, - } - "### - ); -} - -#[test] -fn it_preserves_directives_when_fragment_not_used() { - // (because used only once) - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type Query { - t: T - } - - type T @key(fields: "id") { - id: ID! - a: Int - b: Int - } - "#, - ); - assert_plan!( - &planner, - r#" - query test($if: Boolean!) { - t { - id - ...OnT @include(if: $if) - } - } - - fragment OnT on T { - a - b - } - "#, - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - t { - id - ... on T @include(if: $if) { - a - b - } - } - } - }, - } - "### - ); -} - -#[test] -fn it_preserves_directives_when_fragment_is_reused() { - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type Query { - t: T - } - - type T @key(fields: "id") { - id: ID! - a: Int - b: Int - } - "#, - ); - assert_plan!( - &planner, - r#" - query test($test1: Boolean!, $test2: Boolean!) { - t { - id - ...OnT @include(if: $test1) - ...OnT @include(if: $test2) - } - } - - fragment OnT on T { - a - b - } - "#, - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - t { - id - ...OnT @include(if: $test1) - ...OnT @include(if: $test2) - } - } - - fragment OnT on T { - a - b - } - }, - } - "### - ); -} - -#[test] -fn it_does_not_try_to_apply_fragments_that_are_not_valid_for_the_subgraph() { - // Slightly artificial example for simplicity, but this highlight the problem. - // In that example, the only queried subgraph is the first one (there is in fact - // no way to ever reach the 2nd one), so the plan should mostly simply forward - // the query to the 1st subgraph, but a subtlety is that the named fragment used - // in the query is *not* valid for Subgraph1, because it queries `b` on `I`, but - // there is no `I.b` in Subgraph1. - // So including the named fragment in the fetch would be erroneous: the subgraph - // server would reject it when validating the query, and we must make sure it - // is not reused. - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type Query { - i1: I - i2: I - } - - interface I { - a: Int - } - - type T implements I { - a: Int - b: Int - } - "#, - Subgraph2: r#" - interface I { - a: Int - b: Int - } - "#, - ); - assert_plan!( - &planner, - r#" - query { - i1 { - ... on T { - ...Frag - } - } - i2 { - ... on T { - ...Frag - } - } - } - - fragment Frag on I { - b - } - "#, - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - i1 { - __typename - ... on T { - b - } - } - i2 { - __typename - ... on T { - b - } - } - } - }, - } - "### - ); -} - -#[test] -fn it_handles_fragment_rebasing_in_a_subgraph_where_some_subtyping_relation_differs() { - // This test is designed such that type `Outer` implements the interface `I` in `Subgraph1` - // but not in `Subgraph2`, yet `I` exists in `Subgraph2` (but only `Inner` implements it - // there). Further, the operations we test have a fragment on I (`IFrag` below) that is - // used "in the context of `Outer`" (at the top-level of fragment `OuterFrag`). - // - // What this all means is that `IFrag` can be rebased in `Subgraph2` "as is" because `I` - // exists there with all its fields, but as we rebase `OuterFrag` on `Subgraph2`, we - // cannot use `...IFrag` inside it (at the top-level), because `I` and `Outer` do - // no intersect in `Subgraph2` and this would be an invalid selection. - // - // Previous versions of the code were not handling this case and were error out by - // creating the invalid selection (#2721), and this test ensures this is fixed. - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type V @shareable { - x: Int - } - - interface I { - v: V - } - - type Outer implements I @key(fields: "id") { - id: ID! - v: V - } - "#, - Subgraph2: r#" - type Query { - outer1: Outer - outer2: Outer - } - - type V @shareable { - x: Int - } - - interface I { - v: V - w: Int - } - - type Inner implements I { - v: V - w: Int - } - - type Outer @key(fields: "id") { - id: ID! - inner: Inner - w: Int - } - "#, - ); - assert_plan!( - &planner, - r#" - query { - outer1 { - ...OuterFrag - } - outer2 { - ...OuterFrag - } - } - - fragment OuterFrag on Outer { - ...IFrag - inner { - ...IFrag - } - } - - fragment IFrag on I { - v { - x - } - } - "#, - @r#" - QueryPlan { - Sequence { - Fetch(service: "Subgraph2") { - { - outer1 { - __typename - ...OuterFrag - id - } - outer2 { - __typename - ...OuterFrag - id - } - } - - fragment OuterFrag on Outer { - inner { - v { - x - } - } - } - }, - Parallel { - Flatten(path: "outer2") { - Fetch(service: "Subgraph1") { - { - ... on Outer { - __typename - id - } - } => - { - ... on Outer { - v { - x - } - } - } - }, - }, - Flatten(path: "outer1") { - Fetch(service: "Subgraph1") { - { - ... on Outer { - __typename - id - } - } => - { - ... on Outer { - v { - x - } - } - } - }, - }, - }, - }, - } - "# - ); - - // We very slighly modify the operation to add an artificial indirection within `IFrag`. - // This does not really change the query, and should result in the same plan, but - // ensure the code handle correctly such indirection. - assert_plan!( - &planner, - r#" - query { - outer1 { - ...OuterFrag - } - outer2 { - ...OuterFrag - } - } - - fragment OuterFrag on Outer { - ...IFrag - inner { - ...IFrag - } - } - - fragment IFrag on I { - ...IFragDelegate - } - - fragment IFragDelegate on I { - v { - x - } - } - "#, - @r#" - QueryPlan { - Sequence { - Fetch(service: "Subgraph2") { - { - outer1 { - __typename - ...OuterFrag - id - } - outer2 { - __typename - ...OuterFrag - id - } - } - - fragment OuterFrag on Outer { - inner { - v { - x - } - } - } - }, - Parallel { - Flatten(path: "outer2") { - Fetch(service: "Subgraph1") { - { - ... on Outer { - __typename - id - } - } => - { - ... on Outer { - v { - x - } - } - } - }, - }, - Flatten(path: "outer1") { - Fetch(service: "Subgraph1") { - { - ... on Outer { - __typename - id - } - } => - { - ... on Outer { - v { - x - } - } - } - }, - }, - }, - }, - } - "# - ); - - // The previous cases tests the cases where nothing in the `...IFrag` spread at the - // top-level of `OuterFrag` applied at all: it all gets eliminated in the plan. But - // in the schema of `Subgraph2`, while `Outer` does not implement `I` (and does not - // have `v` in particular), it does contains field `w` that `I` also have, so we - // add that field to `IFrag` and make sure we still correctly query that field. - - assert_plan!( - &planner, - r#" - query { - outer1 { - ...OuterFrag - } - outer2 { - ...OuterFrag - } - } - - fragment OuterFrag on Outer { - ...IFrag - inner { - ...IFrag - } - } - - fragment IFrag on I { - v { - x - } - w - } - "#, - @r#" - QueryPlan { - Sequence { - Fetch(service: "Subgraph2") { - { - outer1 { - __typename - ...OuterFrag - id - } - outer2 { - __typename - ...OuterFrag - id - } - } - - fragment OuterFrag on Outer { - w - inner { - v { - x - } - w - } - } - }, - Parallel { - Flatten(path: "outer2") { - Fetch(service: "Subgraph1") { - { - ... on Outer { - __typename - id - } - } => - { - ... on Outer { - v { - x - } - } - } - }, - }, - Flatten(path: "outer1") { - Fetch(service: "Subgraph1") { - { - ... on Outer { - __typename - id - } - } => - { - ... on Outer { - v { - x - } - } - } - }, - }, - }, - }, - } - "# - ); -} - -#[test] -fn it_handles_fragment_rebasing_in_a_subgraph_where_some_union_membership_relation_differs() { - // This test is similar to the subtyping case (it tests the same problems), but test the case - // of unions instead of interfaces. - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type V @shareable { - x: Int - } - - union U = Outer - - type Outer @key(fields: "id") { - id: ID! - v: Int - } - "#, - Subgraph2: r#" - type Query { - outer1: Outer - outer2: Outer - } - - union U = Inner - - type Inner { - v: Int - w: Int - } - - type Outer @key(fields: "id") { - id: ID! - inner: Inner - w: Int - } - "#, - ); - assert_plan!( - &planner, - r#" - query { - outer1 { - ...OuterFrag - } - outer2 { - ...OuterFrag - } - } - - fragment OuterFrag on Outer { - ...UFrag - inner { - ...UFrag - } - } - - fragment UFrag on U { - ... on Outer { - v - } - ... on Inner { - v - } - } - "#, - @r#" - QueryPlan { - Sequence { - Fetch(service: "Subgraph2") { - { - outer1 { - __typename - ...OuterFrag - id - } - outer2 { - __typename - ...OuterFrag - id - } - } - - fragment OuterFrag on Outer { - inner { - v - } - } - }, - Parallel { - Flatten(path: "outer2") { - Fetch(service: "Subgraph1") { - { - ... on Outer { - __typename - id - } - } => - { - ... on Outer { - v - } - } - }, - }, - Flatten(path: "outer1") { - Fetch(service: "Subgraph1") { - { - ... on Outer { - __typename - id - } - } => - { - ... on Outer { - v - } - } - }, - }, - }, - }, - } - "# - ); - - // We very slighly modify the operation to add an artificial indirection within `IFrag`. - // This does not really change the query, and should result in the same plan, but - // ensure the code handle correctly such indirection. - assert_plan!( - &planner, - r#" - query { - outer1 { - ...OuterFrag - } - outer2 { - ...OuterFrag - } - } - - fragment OuterFrag on Outer { - ...UFrag - inner { - ...UFrag - } - } - - fragment UFrag on U { - ...UFragDelegate - } - - fragment UFragDelegate on U { - ... on Outer { - v - } - ... on Inner { - v - } - } - "#, - @r#" - QueryPlan { - Sequence { - Fetch(service: "Subgraph2") { - { - outer1 { - __typename - ...OuterFrag - id - } - outer2 { - __typename - ...OuterFrag - id - } - } - - fragment OuterFrag on Outer { - inner { - v - } - } - }, - Parallel { - Flatten(path: "outer2") { - Fetch(service: "Subgraph1") { - { - ... on Outer { - __typename - id - } - } => - { - ... on Outer { - v - } - } - }, - }, - Flatten(path: "outer1") { - Fetch(service: "Subgraph1") { - { - ... on Outer { - __typename - id - } - } => - { - ... on Outer { - v - } - } - }, - }, - }, - }, - } - "# - ); - - // The previous cases tests the cases where nothing in the `...IFrag` spread at the - // top-level of `OuterFrag` applied at all: it all gets eliminated in the plan. But - // in the schema of `Subgraph2`, while `Outer` does not implement `I` (and does not - // have `v` in particular), it does contains field `w` that `I` also have, so we - // add that field to `IFrag` and make sure we still correctly query that field. - assert_plan!( - &planner, - r#" - query { - outer1 { - ...OuterFrag - } - outer2 { - ...OuterFrag - } - } - - fragment OuterFrag on Outer { - ...UFrag - inner { - ...UFrag - } - } - - fragment UFrag on U { - ... on Outer { - v - w - } - ... on Inner { - v - } - } - "#, - @r#" - QueryPlan { - Sequence { - Fetch(service: "Subgraph2") { - { - outer1 { - __typename - ...OuterFrag - id - } - outer2 { - __typename - ...OuterFrag - id - } - } - - fragment OuterFrag on Outer { - w - inner { - v - } - } - }, - Parallel { - Flatten(path: "outer2") { - Fetch(service: "Subgraph1") { - { - ... on Outer { - __typename - id - } - } => - { - ... on Outer { - v - } - } - }, - }, - Flatten(path: "outer1") { - Fetch(service: "Subgraph1") { - { - ... on Outer { - __typename - id - } - } => - { - ... on Outer { - v - } - } - }, - }, - }, - }, - } - "# - ); -} - -#[test] -fn it_preserves_nested_fragments_when_outer_one_has_directives_and_is_eliminated() { - let planner = planner!( - config = reuse_fragments_config(), - Subgraph1: r#" - type Query { - t: T - } - - type T { - id: ID! - t1: V - t2: V - } - - type V { - v1: Int - v2: Int - } - "#, - ); - assert_plan!( - &planner, - r#" - query($test: Boolean!) { - t { - ...OnT @include(if: $test) - } - } - - fragment OnT on T { - t1 { - ...OnV - } - t2 { - ...OnV - } - } - - fragment OnV on V { - v1 - v2 - } - "#, - @r###" - QueryPlan { - Fetch(service: "Subgraph1") { - { - t { - ... on T @include(if: $test) { - t1 { - ...OnV - } - t2 { - ...OnV - } - } - } - } - - fragment OnV on V { - v1 - v2 - } - }, - } - "### - ); -} diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/overrides.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/overrides.rs index c0f1b91a31..c3348021ed 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_tests/overrides.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/overrides.rs @@ -24,7 +24,8 @@ fn it_handles_progressive_override_on_root_fields() { } "#, QueryPlanOptions { - override_conditions: vec!["test".to_string()] + override_conditions: vec!["test".to_string()], + ..Default::default() }, @r###" QueryPlan { @@ -117,7 +118,8 @@ fn it_handles_progressive_override_on_entity_fields() { } "#, QueryPlanOptions { - override_conditions: vec!["test".to_string()] + override_conditions: vec!["test".to_string()], + ..Default::default() }, @r###" QueryPlan { @@ -276,7 +278,8 @@ fn it_handles_progressive_override_on_nested_entity_fields() { } "#, QueryPlanOptions { - override_conditions: vec!["test".to_string()] + override_conditions: vec!["test".to_string()], + ..Default::default() }, @r###" QueryPlan { @@ -373,3 +376,88 @@ fn it_does_not_override_unset_labels_on_nested_entity_fields() { "### ); } + +#[test] +fn override_a_field_from_an_interface() { + let planner = planner!( + subgraphA: r#" + interface IImage { + id: ID! + absoluteUri: String! + } + type Image implements IImage @key(fields: "id") { + id: ID! + absoluteUri: String! + } + extend type AssetMetadata @key(fields: "id") { + id: ID! + image: Image + } + "#, + subgraphB: r#" + type Image @key(fields: "id") { + id: ID! + absoluteUri: String! @override(from: "subgraphA", label: "percent(1)") + } + type AssetMetadata @key(fields: "id") { + id: ID! + image: Image @override(from: "subgraphA", label: "percent(1)") + } + "#, + subgraphC: r#" + type Query { + assetMetadata(id: ID!): AssetMetadata + } + type AssetMetadata @key(fields: "id") { + id: ID! + name: String! + } + "#, + ); + + assert_plan!( + &planner, + r#" + query TestQuery($id: ID!) { + assetMetadata(id: $id) { + __typename + image { + absoluteUri + } + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "subgraphC") { + { + assetMetadata(id: $id) { + __typename + id + } + } + }, + Flatten(path: "assetMetadata") { + Fetch(service: "subgraphA") { + { + ... on AssetMetadata { + __typename + id + } + } => + { + ... on AssetMetadata { + __typename + image { + absoluteUri + } + } + } + }, + }, + }, + } + "### + ); +} diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/overrides/shareable.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/overrides/shareable.rs index 103017912e..98c3cf9e74 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_tests/overrides/shareable.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/overrides/shareable.rs @@ -45,7 +45,8 @@ fn it_overrides_to_s2_when_label_is_provided() { } "#, QueryPlanOptions { - override_conditions: vec!["test".to_string()] + override_conditions: vec!["test".to_string()], + ..Default::default() }, @r###" QueryPlan { @@ -157,7 +158,8 @@ fn it_overrides_f1_to_s3_when_label_is_provided() { } "#, QueryPlanOptions { - override_conditions: vec!["test".to_string()] + override_conditions: vec!["test".to_string()], + ..Default::default() }, @r###" QueryPlan { diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/requires.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/requires.rs index bcaeac067d..a5bd26ec12 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_tests/requires.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/requires.rs @@ -122,7 +122,7 @@ fn it_handles_multiple_requires_within_the_same_entity_fetch() { "#, - // The main goal of this test is to show that the 2 @requires for `f` gets handled seemlessly + // The main goal of this test is to show that the 2 @requires for `f` gets handled seamlessly // into the same fetch group. But note that because the type for `f` differs, the 2nd instance // gets aliased (or the fetch would be invalid graphQL). @r###" @@ -227,7 +227,7 @@ fn handles_multiple_requires_involving_different_nestedness() { "#, - // The main goal of this test is to show that the 2 @requires for `f` gets handled seemlessly + // The main goal of this test is to show that the 2 @requires for `f` gets handled seamlessly // into the same fetch group. @r###" QueryPlan { @@ -444,7 +444,7 @@ fn it_handles_simple_require_chain() { #[test] fn it_handles_require_chain_not_ending_in_original_group() { - // This is somewhat simiar to the 'simple require chain' case, but the chain does not + // This is somewhat similar to the 'simple require chain' case, but the chain does not // end in the group in which the query start let planner = planner!( Subgraph1: r#" @@ -1364,7 +1364,7 @@ fn it_require_of_multiple_field_when_one_is_also_a_key_to_reach_another() { // and `req2`, but `req1` is also a key to get `req2`. This dependency was // confusing a previous version of the code (which, when gathering the // "createdGroups" for `T.v` @requires, was using the group for `req1` twice - // separatly (instead of recognizing it was the same group), and this was + // separately (instead of recognizing it was the same group), and this was // confusing the rest of the code was wasn't expecting it. let planner = planner!( A: r#" @@ -1839,3 +1839,93 @@ fn handles_requires_from_supergraph() { "### ); } + +#[test] +fn allows_post_requires_input_with_typename_on_interface_object_type() { + // This used to panic with an `InternalRebaseError(InterfaceObjectTypename)` error. + let planner = planner!( + A: r#" + type I @key(fields: "id") @interfaceObject { + id: ID! + } + + extend type Query { + start: I! + } + "#, + B: r#" + interface I @key(fields: "id") { + id: ID! + required: String + } + + type P implements I @key(fields: "id") { + id: ID! + required: String + } + "#, + C: r#" + type I @key(fields: "id") @interfaceObject { + id: ID! + required: String @external + data: String! @requires(fields: "required") + } + "#, + ); + assert_plan!( + &planner, + r#" + { + start { + data + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "A") { + { + start { + __typename + id + } + } + }, + Flatten(path: "start") { + Fetch(service: "B") { + { + ... on I { + __typename + id + } + } => + { + ... on I { + __typename + required + } + } + }, + }, + Flatten(path: "start") { + Fetch(service: "C") { + { + ... on I { + __typename + required + id + } + } => + { + ... on I { + data + } + } + }, + }, + }, + } + "### + ); +} diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests/subscriptions.rs b/apollo-federation/tests/query_plan/build_query_plan_tests/subscriptions.rs index b2bd3ed6fb..064f1d6067 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_tests/subscriptions.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_tests/subscriptions.rs @@ -1,5 +1,6 @@ -use apollo_compiler::name; use apollo_compiler::ExecutableDocument; +use apollo_compiler::name; +use apollo_compiler::validation::Valid; use apollo_federation::query_plan::query_planner::QueryPlanIncrementalDeliveryConfig; use apollo_federation::query_plan::query_planner::QueryPlannerConfig; @@ -138,12 +139,12 @@ fn basic_subscription_with_single_subgraph() { } #[test] -fn trying_to_use_defer_with_a_subcription_results_in_an_error() { +fn trying_to_use_defer_with_a_subscription_results_in_an_error() { let config = QueryPlannerConfig { incremental_delivery: QueryPlanIncrementalDeliveryConfig { enable_defer: true }, ..Default::default() }; - let (api_schema, planner) = planner!( + let planner = planner!( config = config, SubgraphA: r#" type Query { @@ -171,7 +172,7 @@ fn trying_to_use_defer_with_a_subcription_results_in_an_error() { "#); let document = ExecutableDocument::parse_and_validate( - api_schema.schema(), + planner.api_schema().schema(), r#" subscription MySubscription { onNewUser { @@ -191,3 +192,99 @@ fn trying_to_use_defer_with_a_subcription_results_in_an_error() { .build_query_plan(&document, Some(name!(MySubscription)), Default::default()) .expect_err("should return an error"); } + +#[test] +fn trying_to_use_skip_with_a_subscription_results_in_an_error() { + let planner = planner!( + SubgraphA: r#" + type Query { + me: User! + } + + type Subscription { + onNewUser: User! + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + "#, + SubgraphB: r#" + type Query { + foo: Int + } + + type User @key(fields: "id") { + id: ID! + address: String! + } + "#, + ); + + // This is invalid per https://github.com/graphql/graphql-spec/pull/860 + let document = Valid::assume_valid( + ExecutableDocument::parse( + planner.api_schema().schema(), + r#" + subscription MySubscription($v: Boolean!) { + onNewUser @skip(if: $v) { id name } + } + "#, + "trying_to_use_skip_with_a_subcription_results_in_an_error.graphql", + ) + .unwrap(), + ); + + planner + .build_query_plan(&document, Some(name!(MySubscription)), Default::default()) + .expect_err("should return an error"); +} + +#[test] +fn trying_to_use_include_with_a_subscription_results_in_an_error() { + let planner = planner!( + SubgraphA: r#" + type Query { + me: User! + } + + type Subscription { + onNewUser: User! + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + "#, + SubgraphB: r#" + type Query { + foo: Int + } + + type User @key(fields: "id") { + id: ID! + address: String! + } + "#, + ); + + // This is invalid per https://github.com/graphql/graphql-spec/pull/860 + let document = Valid::assume_valid( + ExecutableDocument::parse( + planner.api_schema().schema(), + r#" + subscription MySubscription($v: Boolean!) { + onNewUser @include(if: $v) { id name } + } + "#, + "trying_to_use_include_with_a_subcription_results_in_an_error.graphql", + ) + .unwrap(), + ); + + planner + .build_query_plan(&document, Some(name!(MySubscription)), Default::default()) + .expect_err("should return an error"); +} diff --git a/apollo-federation/tests/query_plan/mod.rs b/apollo-federation/tests/query_plan/mod.rs index 749216903a..c2e1258610 100644 --- a/apollo-federation/tests/query_plan/mod.rs +++ b/apollo-federation/tests/query_plan/mod.rs @@ -1,5 +1,3 @@ #[macro_use] mod build_query_plan_support; mod build_query_plan_tests; -// TODO: port query-planner-js/src/__tests__/buildPlan.*.test.ts as new modules here -mod operation_validations_tests; diff --git a/apollo-federation/tests/query_plan/operation_validations_tests.rs b/apollo-federation/tests/query_plan/operation_validations_tests.rs deleted file mode 100644 index 32b610d337..0000000000 --- a/apollo-federation/tests/query_plan/operation_validations_tests.rs +++ /dev/null @@ -1,1013 +0,0 @@ -/// -/// validations -/// - -#[test] -fn reject_defer_on_mutation() { - //test.each([ - // { directive: '@defer', rootKind: 'mutation' }, - // { directive: '@defer', rootKind: 'subscription' }, - // { directive: '@stream', rootKind: 'mutation' }, - // { directive: '@stream', rootKind: 'subscription' }, - // ])('reject $directive on $rootKind type', ({ directive, rootKind }) => { - // const schema = parseSchema(` - // type Query { - // x: String - // } - // - // type Mutation { - // x: String - // } - // - // type Subscription { - // x: String - // } - // `); - // - // expect(() => { - // parseOperation(schema, ` - // ${rootKind} { - // ... ${directive} { - // x - // } - // } - // `) - // }).toThrowError(new GraphQLError(`The @defer and @stream directives cannot be used on ${rootKind} root type "${defaultRootName(rootKind as SchemaRootKind)}"`)); - // }); -} - -#[test] -fn reject_defer_on_subscription() { - // see reject_defer_on_mutation -} - -#[test] -fn reject_stream_on_mutation() { - // see reject_defer_on_mutation -} - -#[test] -fn reject_stream_on_subscription() { - //// see reject_defer_on_mutation -} - -/// -/// conflicts -/// - -#[test] -fn conflict_between_selection_and_reused_fragment() { - //test('due to conflict between selection and reused fragment', () => { - // const schema = parseSchema(` - // type Query { - // t1: T1 - // i: I - // } - // - // interface I { - // id: ID! - // } - // - // interface WithF { - // f(arg: Int): Int - // } - // - // type T1 implements I { - // id: ID! - // f(arg: Int): Int - // } - // - // type T2 implements I & WithF { - // id: ID! - // f(arg: Int): Int - // } - // `); - // const gqlSchema = schema.toGraphQLJSSchema(); - // - // const operation = parseOperation(schema, ` - // query { - // t1 { - // id - // f(arg: 0) - // } - // i { - // ...F1 - // } - // } - // - // fragment F1 on I { - // id - // ... on WithF { - // f(arg: 1) - // } - // } - // `); - // expect(validate(gqlSchema, parse(operation.toString()))).toStrictEqual([]); - // - // const withoutFragments = operation.expandAllFragments(); - // expect(withoutFragments.toString()).toMatchString(` - // { - // t1 { - // id - // f(arg: 0) - // } - // i { - // id - // ... on WithF { - // f(arg: 1) - // } - // } - // } - // `); - // - // // Note that technically, `t1` has return type `T1` which is a `I`, so `F1` can be spread - // // within `t1`, and `t1 { ...F1 }` is just `t1 { id }` (because `T!` does not implement `WithF`), - // // so that it would appear that it could be valid to optimize this query into: - // // { - // // t1 { - // // ...F1 // Notice the use of F1 here, which does expand to `id` in this context - // // f(arg: 0) - // // } - // // i { - // // ...F1 - // // } - // // } - // // And while doing this may look "dumb" in that toy example (we're replacing `id` with `...F1` - // // which is longer so less optimal really), it's easy to expand this to example where re-using - // // `F1` this way _does_ make things smaller. - // // - // // But the query above is actually invalid. And it is invalid because the validation of graphQL - // // does not take into account the fact that the `... on WithF` part of `F1` is basically dead - // // code within `t1`. And so it finds a conflict between `f(arg: 0)` and the `f(arg: 1)` in `F1` - // // (even though, again, the later is statically known to never apply, but graphQL does not - // // include such static analysis in its validation). - // // - // // And so this test does make sure we do not generate the query above (do not use `F1` in `t1`). - // const optimized = withoutFragments.optimize(operation.fragments!, 1); - // expect(validate(gqlSchema, parse(optimized.toString()))).toStrictEqual([]); - // - // expect(optimized.toString()).toMatchString(` - // fragment F1 on I { - // id - // ... on WithF { - // f(arg: 1) - // } - // } - // - // { - // t1 { - // id - // f(arg: 0) - // } - // i { - // ...F1 - // } - // } - // `); - // }); -} - -#[test] -fn conflict_between_reused_fragment_and_another_trimmed_fragment() { - //test('due to conflict between the active selection of a reused fragment and the trimmed part of another fragments', () => { - // const schema = parseSchema(` - // type Query { - // t1: T1 - // i: I - // } - // - // interface I { - // id: ID! - // } - // - // interface WithF { - // f(arg: Int): Int - // } - // - // type T1 implements I { - // id: ID! - // f(arg: Int): Int - // } - // - // type T2 implements I & WithF { - // id: ID! - // f(arg: Int): Int - // } - // `); - // const gqlSchema = schema.toGraphQLJSSchema(); - // - // const operation = parseOperation(schema, ` - // query { - // t1 { - // id - // ...F1 - // } - // i { - // ...F2 - // } - // } - // - // fragment F1 on T1 { - // f(arg: 0) - // } - // - // fragment F2 on I { - // id - // ... on WithF { - // f(arg: 1) - // } - // } - // - // `); - // expect(validate(gqlSchema, parse(operation.toString()))).toStrictEqual([]); - // - // const withoutFragments = operation.expandAllFragments(); - // expect(withoutFragments.toString()).toMatchString(` - // { - // t1 { - // id - // f(arg: 0) - // } - // i { - // id - // ... on WithF { - // f(arg: 1) - // } - // } - // } - // `); - // - // // See the comments on the previous test. The only different here is that `F1` is applied - // // first, and then we need to make sure we do not apply `F2` even though it's restriction - // // inside `t1` matches its selection set. - // const optimized = withoutFragments.optimize(operation.fragments!, 1); - // expect(validate(gqlSchema, parse(optimized.toString()))).toStrictEqual([]); - // - // expect(optimized.toString()).toMatchString(` - // fragment F1 on T1 { - // f(arg: 0) - // } - // - // fragment F2 on I { - // id - // ... on WithF { - // f(arg: 1) - // } - // } - // - // { - // t1 { - // ...F1 - // id - // } - // i { - // ...F2 - // } - // } - // `); - // }); -} - -#[test] -fn conflict_between_trimmed_parts_of_two_fragments() { - //test('due to conflict between the trimmed parts of 2 fragments', () => { - // const schema = parseSchema(` - // type Query { - // t1: T1 - // i1: I - // i2: I - // } - // - // interface I { - // id: ID! - // a: Int - // b: Int - // } - // - // interface WithF { - // f(arg: Int): Int - // } - // - // type T1 implements I { - // id: ID! - // a: Int - // b: Int - // f(arg: Int): Int - // } - // - // type T2 implements I & WithF { - // id: ID! - // a: Int - // b: Int - // f(arg: Int): Int - // } - // `); - // const gqlSchema = schema.toGraphQLJSSchema(); - // - // const operation = parseOperation(schema, ` - // query { - // t1 { - // id - // a - // b - // } - // i1 { - // ...F1 - // } - // i2 { - // ...F2 - // } - // } - // - // fragment F1 on I { - // id - // a - // ... on WithF { - // f(arg: 0) - // } - // } - // - // fragment F2 on I { - // id - // b - // ... on WithF { - // f(arg: 1) - // } - // } - // - // `); - // expect(validate(gqlSchema, parse(operation.toString()))).toStrictEqual([]); - // - // const withoutFragments = operation.expandAllFragments(); - // expect(withoutFragments.toString()).toMatchString(` - // { - // t1 { - // id - // a - // b - // } - // i1 { - // id - // a - // ... on WithF { - // f(arg: 0) - // } - // } - // i2 { - // id - // b - // ... on WithF { - // f(arg: 1) - // } - // } - // } - // `); - // - // // Here, `F1` in `T1` reduces to `{ id a }` and F2 reduces to `{ id b }`, so theoretically both could be used - // // within the first `T1` branch. But they can't both be used because their `... on WithF` part conflict, - // // and even though that part is dead in `T1`, this would still be illegal graphQL. - // const optimized = withoutFragments.optimize(operation.fragments!, 1); - // expect(validate(gqlSchema, parse(optimized.toString()))).toStrictEqual([]); - // - // expect(optimized.toString()).toMatchString(` - // fragment F1 on I { - // id - // a - // ... on WithF { - // f(arg: 0) - // } - // } - // - // fragment F2 on I { - // id - // b - // ... on WithF { - // f(arg: 1) - // } - // } - // - // { - // t1 { - // ...F1 - // b - // } - // i1 { - // ...F1 - // } - // i2 { - // ...F2 - // } - // } - // `); - // }); -} - -#[test] -fn conflict_between_selection_and_reused_fragment_at_different_level() { - // test('due to conflict between selection and reused fragment at different levels', () => { - // const schema = parseSchema(` - // type Query { - // t1: SomeV - // t2: SomeV - // } - // - // union SomeV = V1 | V2 | V3 - // - // type V1 { - // x: String - // } - // - // type V2 { - // y: String! - // } - // - // type V3 { - // x: Int - // } - // `); - // const gqlSchema = schema.toGraphQLJSSchema(); - // - // const operation = parseOperation(schema, ` - // fragment onV1V2 on SomeV { - // ... on V1 { - // x - // } - // ... on V2 { - // y - // } - // } - // - // query { - // t1 { - // ...onV1V2 - // } - // t2 { - // ... on V2 { - // y - // } - // ... on V3 { - // x - // } - // } - // } - // `); - // expect(validate(gqlSchema, parse(operation.toString()))).toStrictEqual([]); - // - // const withoutFragments = operation.expandAllFragments(); - // expect(withoutFragments.toString()).toMatchString(` - // { - // t1 { - // ... on V1 { - // x - // } - // ... on V2 { - // y - // } - // } - // t2 { - // ... on V2 { - // y - // } - // ... on V3 { - // x - // } - // } - // } - // `); - // - // const optimized = withoutFragments.optimize(operation.fragments!, 1); - // expect(validate(gqlSchema, parse(optimized.toString()))).toStrictEqual([]); - // - // expect(optimized.toString()).toMatchString(` - // fragment onV1V2 on SomeV { - // ... on V1 { - // x - // } - // ... on V2 { - // y - // } - // } - // - // { - // t1 { - // ...onV1V2 - // } - // t2 { - // ... on V2 { - // y - // } - // ... on V3 { - // x - // } - // } - // } - // `); - // }); -} - -#[test] -fn conflict_between_fragments_at_different_levels() { - //test('due to conflict between the trimmed parts of 2 fragments at different levels', () => { - // const schema = parseSchema(` - // type Query { - // t1: SomeV - // t2: SomeV - // t3: OtherV - // } - // - // union SomeV = V1 | V2 | V3 - // union OtherV = V3 - // - // type V1 { - // x: String - // } - // - // type V2 { - // x: Int - // } - // - // type V3 { - // y: String! - // z: String! - // } - // `); - // const gqlSchema = schema.toGraphQLJSSchema(); - // - // const operation = parseOperation(schema, ` - // fragment onV1V3 on SomeV { - // ... on V1 { - // x - // } - // ... on V3 { - // y - // } - // } - // - // fragment onV2V3 on SomeV { - // ... on V2 { - // x - // } - // ... on V3 { - // z - // } - // } - // - // query { - // t1 { - // ...onV1V3 - // } - // t2 { - // ...onV2V3 - // } - // t3 { - // ... on V3 { - // y - // z - // } - // } - // } - // `); - // expect(validate(gqlSchema, parse(operation.toString()))).toStrictEqual([]); - // - // const withoutFragments = operation.expandAllFragments(); - // expect(withoutFragments.toString()).toMatchString(` - // { - // t1 { - // ... on V1 { - // x - // } - // ... on V3 { - // y - // } - // } - // t2 { - // ... on V2 { - // x - // } - // ... on V3 { - // z - // } - // } - // t3 { - // ... on V3 { - // y - // z - // } - // } - // } - // `); - // - // const optimized = withoutFragments.optimize(operation.fragments!, 1); - // expect(validate(gqlSchema, parse(optimized.toString()))).toStrictEqual([]); - // - // expect(optimized.toString()).toMatchString(` - // fragment onV1V3 on SomeV { - // ... on V1 { - // x - // } - // ... on V3 { - // y - // } - // } - // - // fragment onV2V3 on SomeV { - // ... on V2 { - // x - // } - // ... on V3 { - // z - // } - // } - // - // { - // t1 { - // ...onV1V3 - // } - // t2 { - // ...onV2V3 - // } - // t3 { - // ...onV1V3 - // ... on V3 { - // z - // } - // } - // } - // `); - // }); -} - -#[test] -fn conflict_between_two_sibling_branches() { - // test('due to conflict between 2 sibling branches', () => { - // const schema = parseSchema(` - // type Query { - // t1: SomeV - // i: I - // } - // - // interface I { - // id: ID! - // } - // - // type T1 implements I { - // id: ID! - // t2: SomeV - // } - // - // type T2 implements I { - // id: ID! - // t2: SomeV - // } - // - // union SomeV = V1 | V2 | V3 - // - // type V1 { - // x: String - // } - // - // type V2 { - // y: String! - // } - // - // type V3 { - // x: Int - // } - // `); - // const gqlSchema = schema.toGraphQLJSSchema(); - // - // const operation = parseOperation(schema, ` - // fragment onV1V2 on SomeV { - // ... on V1 { - // x - // } - // ... on V2 { - // y - // } - // } - // - // query { - // t1 { - // ...onV1V2 - // } - // i { - // ... on T1 { - // t2 { - // ... on V2 { - // y - // } - // } - // } - // ... on T2 { - // t2 { - // ... on V3 { - // x - // } - // } - // } - // } - // } - // `); - // expect(validate(gqlSchema, parse(operation.toString()))).toStrictEqual([]); - // - // const withoutFragments = operation.expandAllFragments(); - // expect(withoutFragments.toString()).toMatchString(` - // { - // t1 { - // ... on V1 { - // x - // } - // ... on V2 { - // y - // } - // } - // i { - // ... on T1 { - // t2 { - // ... on V2 { - // y - // } - // } - // } - // ... on T2 { - // t2 { - // ... on V3 { - // x - // } - // } - // } - // } - // } - // `); - // - // const optimized = withoutFragments.optimize(operation.fragments!, 1); - // expect(validate(gqlSchema, parse(optimized.toString()))).toStrictEqual([]); - // - // expect(optimized.toString()).toMatchString(` - // fragment onV1V2 on SomeV { - // ... on V1 { - // x - // } - // ... on V2 { - // y - // } - // } - // - // { - // t1 { - // ...onV1V2 - // } - // i { - // ... on T1 { - // t2 { - // ... on V2 { - // y - // } - // } - // } - // ... on T2 { - // t2 { - // ... on V3 { - // x - // } - // } - // } - // } - // } - // `); - // }); -} - -#[test] -fn conflict_when_inline_fragment_should_be_normalized() { - // test('when a spread inside an expanded fragment should be "normalized away"', () => { - // const schema = parseSchema(` - // type Query { - // t1: T1 - // i: I - // } - // - // interface I { - // id: ID! - // } - // - // type T1 implements I { - // id: ID! - // a: Int - // } - // - // type T2 implements I { - // id: ID! - // b: Int - // c: Int - // } - // `); - // const gqlSchema = schema.toGraphQLJSSchema(); - // - // const operation = parseOperation(schema, ` - // { - // t1 { - // ...GetAll - // } - // i { - // ...GetT2 - // } - // } - // - // fragment GetAll on I { - // ... on T1 { - // a - // } - // ...GetT2 - // ... on T2 { - // c - // } - // } - // - // fragment GetT2 on T2 { - // b - // } - // `); - // expect(validate(gqlSchema, parse(operation.toString()))).toStrictEqual([]); - // - // const withoutFragments = operation.expandAllFragments(); - // expect(withoutFragments.toString()).toMatchString(` - // { - // t1 { - // a - // } - // i { - // ... on T2 { - // b - // } - // } - // } - // `); - // - // // As we re-optimize, we will initially generated the initial query. But - // // as we ask to only optimize fragments used more than once, the `GetAll` - // // fragment will be re-expanded (`GetT2` will not because the code will say - // // that it is used both in the expanded `GetAll` but also inside `i`). - // // But because `GetAll` is within `t1: T1`, that expansion should actually - // // get rid of anything `T2`-related. - // // This test exists because a previous version of the code was not correctly - // // "getting rid" of the `...GetT2` spread, keeping in the query, which is - // // invalid (we cannot have `...GetT2` inside `t1`). - // const optimized = withoutFragments.optimize(operation.fragments!, 2); - // expect(validate(gqlSchema, parse(optimized.toString()))).toStrictEqual([]); - // - // expect(optimized.toString()).toMatchString(` - // fragment GetT2 on T2 { - // b - // } - // - // { - // t1 { - // a - // } - // i { - // ...GetT2 - // } - // } - // `); - // }); -} - -#[test] -fn conflict_due_to_trimmed_selections_of_nested_fragments() { - //test('due to the trimmed selection of nested fragments', () => { - // const schema = parseSchema(` - // type Query { - // u1: U - // u2: U - // u3: U - // } - // - // union U = S | T - // - // type T { - // id: ID! - // vt: Int - // } - // - // interface I { - // vs: Int - // } - // - // type S implements I { - // vs: Int! - // } - // `); - // const gqlSchema = schema.toGraphQLJSSchema(); - // - // const operation = parseOperation(schema, ` - // { - // u1 { - // ...F1 - // } - // u2 { - // ...F3 - // } - // u3 { - // ...F3 - // } - // } - // - // fragment F1 on U { - // ... on S { - // __typename - // vs - // } - // ... on T { - // __typename - // vt - // } - // } - // - // fragment F2 on T { - // __typename - // vt - // } - // - // fragment F3 on U { - // ... on I { - // vs - // } - // ...F2 - // } - // `); - // expect(validate(gqlSchema, parse(operation.toString()))).toStrictEqual([]); - // - // const withoutFragments = operation.expandAllFragments(); - // expect(withoutFragments.toString()).toMatchString(` - // { - // u1 { - // ... on S { - // __typename - // vs - // } - // ... on T { - // __typename - // vt - // } - // } - // u2 { - // ... on I { - // vs - // } - // ... on T { - // __typename - // vt - // } - // } - // u3 { - // ... on I { - // vs - // } - // ... on T { - // __typename - // vt - // } - // } - // } - // `); - // - // // We use `mapToExpandedSelectionSets` with a no-op mapper because this will still expand the selections - // // and re-optimize them, which 1) happens to match what happens in the query planner and 2) is necessary - // // for reproducing a bug that this test was initially added to cover. - // const newFragments = operation.fragments!.mapToExpandedSelectionSets((s) => s); - // const optimized = withoutFragments.optimize(newFragments, 2); - // expect(validate(gqlSchema, parse(optimized.toString()))).toStrictEqual([]); - // - // expect(optimized.toString()).toMatchString(` - // fragment F3 on U { - // ... on I { - // vs - // } - // ... on T { - // __typename - // vt - // } - // } - // - // { - // u1 { - // ... on S { - // __typename - // vs - // } - // ... on T { - // __typename - // vt - // } - // } - // u2 { - // ...F3 - // } - // u3 { - // ...F3 - // } - // } - // `); - // }); -} diff --git a/apollo-federation/tests/query_plan/supergraphs/allows_post_requires_input_with_typename_on_interface_object_type.graphql b/apollo-federation/tests/query_plan/supergraphs/allows_post_requires_input_with_typename_on_interface_object_type.graphql new file mode 100644 index 0000000000..7a82707af5 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/allows_post_requires_input_with_typename_on_interface_object_type.graphql @@ -0,0 +1,83 @@ +# Composed from subgraphs with hash: 6a52228f357eb403fba553216a31a338bab93088 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +interface I + @join__type(graph: A, key: "id", isInterfaceObject: true) + @join__type(graph: B, key: "id") + @join__type(graph: C, key: "id", isInterfaceObject: true) +{ + id: ID! + required: String @join__field(graph: B) @join__field(graph: C, external: true) + data: String! @join__field(graph: C, requires: "required") +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + A @join__graph(name: "A", url: "none") + B @join__graph(name: "B", url: "none") + C @join__graph(name: "C", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type P implements I + @join__implements(graph: B, interface: "I") + @join__type(graph: B, key: "id") +{ + id: ID! + required: String + data: String! @join__field +} + +type Query + @join__type(graph: A) + @join__type(graph: B) + @join__type(graph: C) +{ + start: I! @join__field(graph: A) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/defer_on_renamed_root_type.graphql b/apollo-federation/tests/query_plan/supergraphs/defer_on_renamed_root_type.graphql new file mode 100644 index 0000000000..c3da7ab6e4 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/defer_on_renamed_root_type.graphql @@ -0,0 +1,66 @@ +# Composed from subgraphs with hash: 342e57c5d2c95caa99ea85f70fdd3114d65d6ca7 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) +{ + thing: Thing +} + +type Thing + @join__type(graph: SUBGRAPH1) +{ + i: Int +} diff --git a/apollo-federation/tests/query_plan/supergraphs/inefficient_entity_fetches_to_same_subgraph.graphql b/apollo-federation/tests/query_plan/supergraphs/inefficient_entity_fetches_to_same_subgraph.graphql new file mode 100644 index 0000000000..8aaa3f274f --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/inefficient_entity_fetches_to_same_subgraph.graphql @@ -0,0 +1,97 @@ +# Composed from subgraphs with hash: b2221050efb89f6e4df71823675d2ea1fbe66a31 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +interface I + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + v: V + w: Int @join__field(graph: SUBGRAPH2) +} + +type Inner implements I + @join__implements(graph: SUBGRAPH2, interface: "I") + @join__type(graph: SUBGRAPH2) +{ + v: V + w: Int +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Outer implements I + @join__implements(graph: SUBGRAPH1, interface: "I") + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + v: V @join__field(graph: SUBGRAPH1) + inner: Inner @join__field(graph: SUBGRAPH2) + w: Int @join__field(graph: SUBGRAPH2) +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + outer1: Outer @join__field(graph: SUBGRAPH2) + outer2: Outer @join__field(graph: SUBGRAPH2) +} + +type V + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + x: Int +} diff --git a/apollo-federation/tests/query_plan/supergraphs/it_expands_nested_fragments.graphql b/apollo-federation/tests/query_plan/supergraphs/it_expands_nested_fragments.graphql new file mode 100644 index 0000000000..0d1594dcca --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/it_expands_nested_fragments.graphql @@ -0,0 +1,75 @@ +# Composed from subgraphs with hash: af8642bd2cc335a2823e7c95f48ce005d3c809f0 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) +{ + t: T +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + a: V + b: V +} + +type V + @join__type(graph: SUBGRAPH1) +{ + v1: Int + v2: Int +} diff --git a/apollo-federation/tests/query_plan/supergraphs/it_handles_nested_fragment_generation_from_operation_with_fragments.graphql b/apollo-federation/tests/query_plan/supergraphs/it_handles_nested_fragment_generation_from_operation_with_fragments.graphql new file mode 100644 index 0000000000..bf45161fb0 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/it_handles_nested_fragment_generation_from_operation_with_fragments.graphql @@ -0,0 +1,102 @@ +# Composed from subgraphs with hash: 7cb80bbad99a03ca0bb30082bd6f9eb6f7c1beff +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type A1 implements Foo + @join__implements(graph: SUBGRAPH1, interface: "Foo") + @join__type(graph: SUBGRAPH1) +{ + foo: String + child: Foo + child2: Foo +} + +type A2 implements Foo + @join__implements(graph: SUBGRAPH1, interface: "Foo") + @join__type(graph: SUBGRAPH1) +{ + foo: String + child: Foo + child2: Foo +} + +type A3 implements Foo + @join__implements(graph: SUBGRAPH1, interface: "Foo") + @join__type(graph: SUBGRAPH1) +{ + foo: String + child: Foo + child2: Foo +} + +union Anything + @join__type(graph: SUBGRAPH1) + @join__unionMember(graph: SUBGRAPH1, member: "A1") + @join__unionMember(graph: SUBGRAPH1, member: "A2") + @join__unionMember(graph: SUBGRAPH1, member: "A3") + = A1 | A2 | A3 + +interface Foo + @join__type(graph: SUBGRAPH1) +{ + foo: String + child: Foo + child2: Foo +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) +{ + a: Anything +} diff --git a/apollo-federation/tests/query_plan/supergraphs/it_preserves_directives.graphql b/apollo-federation/tests/query_plan/supergraphs/it_preserves_directives.graphql new file mode 100644 index 0000000000..95316d4353 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/it_preserves_directives.graphql @@ -0,0 +1,68 @@ +# Composed from subgraphs with hash: 136ac120ab3c0a9b8ea4cb22cb440886a1b4a961 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) +{ + t: T +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + a: Int + b: Int +} diff --git a/apollo-federation/tests/query_plan/supergraphs/it_preserves_directives_on_collapsed_fragments.graphql b/apollo-federation/tests/query_plan/supergraphs/it_preserves_directives_on_collapsed_fragments.graphql new file mode 100644 index 0000000000..7b9af26713 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/it_preserves_directives_on_collapsed_fragments.graphql @@ -0,0 +1,75 @@ +# Composed from subgraphs with hash: fd162a5fc982fc2cd0a8d33e271831822b681137 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) +{ + t: T +} + +type T + @join__type(graph: SUBGRAPH1) +{ + id: ID! + t1: V + t2: V +} + +type V + @join__type(graph: SUBGRAPH1) +{ + v1: Int + v2: Int +} diff --git a/apollo-federation/tests/query_plan/supergraphs/override_a_field_from_an_interface.graphql b/apollo-federation/tests/query_plan/supergraphs/override_a_field_from_an_interface.graphql new file mode 100644 index 0000000000..8dc2111502 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/override_a_field_from_an_interface.graphql @@ -0,0 +1,90 @@ +# Composed from subgraphs with hash: 11b8b04d27652c8709c30bd23bf203a9f1cf2879 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type AssetMetadata + @join__type(graph: SUBGRAPHA, key: "id", extension: true) + @join__type(graph: SUBGRAPHB, key: "id") + @join__type(graph: SUBGRAPHC, key: "id") +{ + id: ID! + image: Image @join__field(graph: SUBGRAPHA, overrideLabel: "percent(1)") @join__field(graph: SUBGRAPHB, override: "subgraphA", overrideLabel: "percent(1)") + name: String! @join__field(graph: SUBGRAPHC) +} + +interface IImage + @join__type(graph: SUBGRAPHA) +{ + id: ID! + absoluteUri: String! +} + +type Image implements IImage + @join__implements(graph: SUBGRAPHA, interface: "IImage") + @join__type(graph: SUBGRAPHA, key: "id") + @join__type(graph: SUBGRAPHB, key: "id") +{ + id: ID! + absoluteUri: String! @join__field(graph: SUBGRAPHA, usedOverridden: true, overrideLabel: "percent(1)") @join__field(graph: SUBGRAPHB, override: "subgraphA", overrideLabel: "percent(1)") +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPHA @join__graph(name: "subgraphA", url: "none") + SUBGRAPHB @join__graph(name: "subgraphB", url: "none") + SUBGRAPHC @join__graph(name: "subgraphC", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPHA) + @join__type(graph: SUBGRAPHB) + @join__type(graph: SUBGRAPHC) +{ + assetMetadata(id: ID!): AssetMetadata @join__field(graph: SUBGRAPHC) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/plan_with_check.graphql b/apollo-federation/tests/query_plan/supergraphs/plan_with_check.graphql new file mode 100644 index 0000000000..1304bec2a3 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/plan_with_check.graphql @@ -0,0 +1,67 @@ +# Composed from subgraphs with hash: 79e3960a11730ec6e1274425847a6393316221c2 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) +{ + t: T +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + x: Int +} diff --git a/apollo-federation/tests/query_plan/supergraphs/set_context_one_subgraph.graphql b/apollo-federation/tests/query_plan/supergraphs/set_context_one_subgraph.graphql new file mode 100644 index 0000000000..5a7c7ab74c --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/set_context_one_subgraph.graphql @@ -0,0 +1,87 @@ +# Composed from subgraphs with hash: 493d78ea411e0726dbfcd63da1534851ed24438d +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", import: ["@context"], for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar context__ContextFieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + t: T! @join__field(graph: SUBGRAPH1) + randomId: ID! @join__field(graph: SUBGRAPH2) +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") + @context(name: "Subgraph1__context") +{ + id: ID! + u: U! + prop: String! +} + +type U + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + b: String! + field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "Subgraph1__context", name: "a", type: "String", selection: " { prop }"}]) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/set_context_required_field_is_several_levels_deep_going_back_and_forth_between_subgraphs.graphql b/apollo-federation/tests/query_plan/supergraphs/set_context_required_field_is_several_levels_deep_going_back_and_forth_between_subgraphs.graphql new file mode 100644 index 0000000000..9fe87e18b6 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/set_context_required_field_is_several_levels_deep_going_back_and_forth_between_subgraphs.graphql @@ -0,0 +1,110 @@ +# Composed from subgraphs with hash: f1dd07e720398727750d4546a6c36b1ee83e38d0 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", import: ["@context"], for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type A + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + b: B! @join__field(graph: SUBGRAPH1, external: true) @join__field(graph: SUBGRAPH2) +} + +type B + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + c: C! @join__field(graph: SUBGRAPH1) +} + +type C + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + prop: String! +} + +scalar context__ContextFieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + t: T! @join__field(graph: SUBGRAPH1) + randomId: ID! @join__field(graph: SUBGRAPH2) +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") + @context(name: "Subgraph1__context") +{ + id: ID! + u: U! + a: A! +} + +type U + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + b: String! + field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "Subgraph1__context", name: "a", type: "String", selection: " { a { b { c { prop }}} }"}]) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/set_context_test_accesses_a_different_top_level_query.graphql b/apollo-federation/tests/query_plan/supergraphs/set_context_test_accesses_a_different_top_level_query.graphql new file mode 100644 index 0000000000..c911771068 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/set_context_test_accesses_a_different_top_level_query.graphql @@ -0,0 +1,87 @@ +# Composed from subgraphs with hash: ccebd26247c9c39723f64c9ed99609619a002604 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", import: ["@context"], for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar context__ContextFieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Product + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + price: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "Subgraph1__topLevelQuery", name: "locale", type: "String", selection: " { me { locale } }"}]) +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) + @context(name: "Subgraph1__topLevelQuery") +{ + me: User! @join__field(graph: SUBGRAPH1) + product: Product @join__field(graph: SUBGRAPH1) + randomId: ID! @join__field(graph: SUBGRAPH2) +} + +type User + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + locale: String! +} diff --git a/apollo-federation/tests/query_plan/supergraphs/set_context_test_before_key_resolution_transition.graphql b/apollo-federation/tests/query_plan/supergraphs/set_context_test_before_key_resolution_transition.graphql new file mode 100644 index 0000000000..496cc9fd8c --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/set_context_test_before_key_resolution_transition.graphql @@ -0,0 +1,95 @@ +# Composed from subgraphs with hash: 70f79d57f189ed5847e652ef9fa7c60603f2d639 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", import: ["@context"], for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type Child + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + prop: String @join__field(graph: SUBGRAPH2, contextArguments: [{context: "Subgraph2__ctx", name: "legacyUserId", type: "ID", selection: " { identifiers { legacyUserId } }"}]) +} + +scalar context__ContextFieldValue + +type Customer + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") + @context(name: "Subgraph2__ctx") +{ + id: ID! + child: Child! @join__field(graph: SUBGRAPH1) + identifiers: Identifiers! @join__field(graph: SUBGRAPH1) @join__field(graph: SUBGRAPH2, external: true) +} + +type Identifiers + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + legacyUserId: ID! @join__field(graph: SUBGRAPH1) @join__field(graph: SUBGRAPH2, external: true) +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + customer: Customer! @join__field(graph: SUBGRAPH1) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/set_context_test_efficiently_merge_fetch_groups.graphql b/apollo-federation/tests/query_plan/supergraphs/set_context_test_efficiently_merge_fetch_groups.graphql new file mode 100644 index 0000000000..d0e79d42e3 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/set_context_test_efficiently_merge_fetch_groups.graphql @@ -0,0 +1,120 @@ +# Composed from subgraphs with hash: 753d6866862484ee27a265b4f2f2f9d4e0c97b03 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", import: ["@context"], for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type Accounts + @join__type(graph: SUBGRAPH3, key: "id") + @join__type(graph: SUBGRAPH4, key: "id") +{ + foo(randomInput: String): Foo @join__field(graph: SUBGRAPH3, contextArguments: [{context: "Subgraph3__retailCtx", name: "ctx_id5", type: "ID", selection: " { identifiers { id5 } }"}, {context: "Subgraph3__retailCtx", name: "ctx_mid", type: "ID", selection: " { mid }"}]) + id: ID! + bar: Bar @join__field(graph: SUBGRAPH4, contextArguments: [{context: "Subgraph4__widCtx", name: "ctx_wid", type: "ID", selection: " { identifiers { wid } }"}]) +} + +type Bar + @join__type(graph: SUBGRAPH4) +{ + id: ID +} + +scalar context__ContextFieldValue + +type Customer + @join__type(graph: SUBGRAPH2, key: "id") + @join__type(graph: SUBGRAPH3, key: "id") + @join__type(graph: SUBGRAPH4, key: "id", resolvable: false) + @context(name: "Subgraph3__retailCtx") + @context(name: "Subgraph4__widCtx") +{ + id: ID! + identifiers: Identifiers @join__field(graph: SUBGRAPH2) @join__field(graph: SUBGRAPH3, external: true) @join__field(graph: SUBGRAPH4, external: true) + mid: ID @join__field(graph: SUBGRAPH2) @join__field(graph: SUBGRAPH3, external: true) + accounts: Accounts @join__field(graph: SUBGRAPH3) @join__field(graph: SUBGRAPH4) +} + +type Foo + @join__type(graph: SUBGRAPH3) +{ + id: ID +} + +type Identifiers + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") + @join__type(graph: SUBGRAPH3, key: "id") + @join__type(graph: SUBGRAPH4, key: "id", resolvable: false) +{ + id: ID! + id2: ID @join__field(graph: SUBGRAPH1, external: true) @join__field(graph: SUBGRAPH2) + id3: ID @join__field(graph: SUBGRAPH1, external: true) @join__field(graph: SUBGRAPH2) + wid: ID @join__field(graph: SUBGRAPH1, requires: "id2 id3") @join__field(graph: SUBGRAPH4, external: true) + id5: ID @join__field(graph: SUBGRAPH2) @join__field(graph: SUBGRAPH3, external: true) +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") + SUBGRAPH3 @join__graph(name: "Subgraph3", url: "none") + SUBGRAPH4 @join__graph(name: "Subgraph4", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) + @join__type(graph: SUBGRAPH3) + @join__type(graph: SUBGRAPH4) +{ + customer: Customer @join__field(graph: SUBGRAPH2) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/set_context_test_fetched_as_a_list.graphql b/apollo-federation/tests/query_plan/supergraphs/set_context_test_fetched_as_a_list.graphql new file mode 100644 index 0000000000..354ae88c1c --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/set_context_test_fetched_as_a_list.graphql @@ -0,0 +1,88 @@ +# Composed from subgraphs with hash: 8b81d5086b71ff9fb618f2d250eec4b47f7a1d04 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", import: ["@context"], for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar context__ContextFieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + t: [T]! @join__field(graph: SUBGRAPH1) + a: Int! @join__field(graph: SUBGRAPH2) +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") + @context(name: "Subgraph1__context") +{ + id: ID! + u: U! + prop: String! +} + +type U + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + b: String! @join__field(graph: SUBGRAPH1) + field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "Subgraph1__context", name: "a", type: "String", selection: " { prop }"}]) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/set_context_test_impacts_on_query_planning.graphql b/apollo-federation/tests/query_plan/supergraphs/set_context_test_impacts_on_query_planning.graphql new file mode 100644 index 0000000000..596c21edad --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/set_context_test_impacts_on_query_planning.graphql @@ -0,0 +1,106 @@ +# Composed from subgraphs with hash: 173623d59b042e5f8dcf81f5c08880ad2d2d3ccb +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", import: ["@context"], for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type A implements I + @join__implements(graph: SUBGRAPH1, interface: "I") + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + u: U! + prop: String! +} + +type B implements I + @join__implements(graph: SUBGRAPH1, interface: "I") + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + u: U! + prop: String! +} + +scalar context__ContextFieldValue + +interface I + @join__type(graph: SUBGRAPH1, key: "id") + @context(name: "Subgraph1__context") +{ + id: ID! + u: U! + prop: String! +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + t: I! @join__field(graph: SUBGRAPH1) + a: Int! @join__field(graph: SUBGRAPH2) +} + +type U + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + b: String! @join__field(graph: SUBGRAPH1) + field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "Subgraph1__context", name: "a", type: "String", selection: " { prop }"}]) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_a_list.graphql b/apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_a_list.graphql new file mode 100644 index 0000000000..d502c7d4b1 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_a_list.graphql @@ -0,0 +1,87 @@ +# Composed from subgraphs with hash: 1a8895fe791cc7cd69ace02dc95527034d7d864f +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", import: ["@context"], for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar context__ContextFieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + t: T! @join__field(graph: SUBGRAPH1) + a: Int! @join__field(graph: SUBGRAPH2) +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") + @context(name: "Subgraph1__context") +{ + id: ID! + u: U! + prop: [String]! +} + +type U + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "Subgraph1__context", name: "a", type: "[String]", selection: " { prop }"}]) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_already_in_a_different_fetch_group.graphql b/apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_already_in_a_different_fetch_group.graphql new file mode 100644 index 0000000000..55fbffc7dd --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_already_in_a_different_fetch_group.graphql @@ -0,0 +1,88 @@ +# Composed from subgraphs with hash: 295947c45a4fbf129c3112e24611d04c40619c86 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", import: ["@context"], for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar context__ContextFieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + t: T! @join__field(graph: SUBGRAPH1) + a: Int! @join__field(graph: SUBGRAPH2) +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") + @context(name: "Subgraph2__context") +{ + id: ID! + u: U! @join__field(graph: SUBGRAPH1) + prop: String! @join__field(graph: SUBGRAPH1) @join__field(graph: SUBGRAPH2, external: true) +} + +type U + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + field: Int! @join__field(graph: SUBGRAPH2, contextArguments: [{context: "Subgraph2__context", name: "a", type: "String", selection: " { prop }"}]) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_from_different_subgraph.graphql b/apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_from_different_subgraph.graphql new file mode 100644 index 0000000000..f71c83f1a3 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_from_different_subgraph.graphql @@ -0,0 +1,88 @@ +# Composed from subgraphs with hash: a1eeaafd2a79733109742acf5e08df586d1358b0 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", import: ["@context"], for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar context__ContextFieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + t: T! @join__field(graph: SUBGRAPH1) + a: Int! @join__field(graph: SUBGRAPH2) +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") + @context(name: "Subgraph1__context") +{ + id: ID! + u: U! @join__field(graph: SUBGRAPH1) + prop: String! @join__field(graph: SUBGRAPH1, external: true) @join__field(graph: SUBGRAPH2) +} + +type U + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "Subgraph1__context", name: "a", type: "String", selection: " { prop }"}]) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_from_same_subgraph.graphql b/apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_from_same_subgraph.graphql new file mode 100644 index 0000000000..24d777888d --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/set_context_test_variable_is_from_same_subgraph.graphql @@ -0,0 +1,88 @@ +# Composed from subgraphs with hash: 2de3c6cc9b36392a362af0d19e03688085e2119b +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", import: ["@context"], for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar context__ContextFieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + t: T! @join__field(graph: SUBGRAPH1) + a: Int! @join__field(graph: SUBGRAPH2) +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") + @context(name: "Subgraph1__context") +{ + id: ID! + u: U! + prop: String! +} + +type U + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + b: String! @join__field(graph: SUBGRAPH1) + field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "Subgraph1__context", name: "a", type: "String", selection: " { prop }"}]) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/set_context_test_with_type_conditions_for_union.graphql b/apollo-federation/tests/query_plan/supergraphs/set_context_test_with_type_conditions_for_union.graphql new file mode 100644 index 0000000000..ae73fa590c --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/set_context_test_with_type_conditions_for_union.graphql @@ -0,0 +1,102 @@ +# Composed from subgraphs with hash: 0981934ba0944ccff6a8c554bef807ca905ad13a +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1", import: ["@context"], for: SECURITY) +{ + query: Query +} + +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @context__fromContext(field: context__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type A + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + u: U! + prop: String! +} + +type B + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + u: U! + prop: String! +} + +scalar context__ContextFieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + t: T! @join__field(graph: SUBGRAPH1) + a: Int! @join__field(graph: SUBGRAPH2) +} + +union T + @join__type(graph: SUBGRAPH1) + @join__unionMember(graph: SUBGRAPH1, member: "A") + @join__unionMember(graph: SUBGRAPH1, member: "B") + @context(name: "Subgraph1__context") + = A | B + +type U + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + b: String! @join__field(graph: SUBGRAPH1) + field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "Subgraph1__context", name: "a", type: "String", selection: " ... on A { prop } ... on B { prop }"}]) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/test_callback_is_called.graphql b/apollo-federation/tests/query_plan/supergraphs/test_callback_is_called.graphql new file mode 100644 index 0000000000..0b79b80e1f --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/test_callback_is_called.graphql @@ -0,0 +1,67 @@ +# Composed from subgraphs with hash: 3738ebf8ae9ce8817262ee0d2a64c1d1e52cf6e6 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) +{ + t: T +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + x: Int +} diff --git a/apollo-federation/tests/query_plan/supergraphs/test_cancel_as_soon_as_possible.graphql b/apollo-federation/tests/query_plan/supergraphs/test_cancel_as_soon_as_possible.graphql new file mode 100644 index 0000000000..0b79b80e1f --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/test_cancel_as_soon_as_possible.graphql @@ -0,0 +1,67 @@ +# Composed from subgraphs with hash: 3738ebf8ae9ce8817262ee0d2a64c1d1e52cf6e6 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) +{ + t: T +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + x: Int +} diff --git a/apollo-federation/tests/query_plan/supergraphs/test_cancel_late_enough_that_planning_finishes.graphql b/apollo-federation/tests/query_plan/supergraphs/test_cancel_late_enough_that_planning_finishes.graphql new file mode 100644 index 0000000000..0b79b80e1f --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/test_cancel_late_enough_that_planning_finishes.graphql @@ -0,0 +1,67 @@ +# Composed from subgraphs with hash: 3738ebf8ae9ce8817262ee0d2a64c1d1e52cf6e6 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) +{ + t: T +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + x: Int +} diff --git a/apollo-federation/tests/query_plan/supergraphs/test_cancel_near_the_middle.graphql b/apollo-federation/tests/query_plan/supergraphs/test_cancel_near_the_middle.graphql new file mode 100644 index 0000000000..0b79b80e1f --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/test_cancel_near_the_middle.graphql @@ -0,0 +1,67 @@ +# Composed from subgraphs with hash: 3738ebf8ae9ce8817262ee0d2a64c1d1e52cf6e6 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) +{ + t: T +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") +{ + id: ID! + x: Int +} diff --git a/apollo-federation/tests/query_plan/supergraphs/test_if_disabling_all_subgraph_jumps_causes_error.graphql b/apollo-federation/tests/query_plan/supergraphs/test_if_disabling_all_subgraph_jumps_causes_error.graphql new file mode 100644 index 0000000000..85b66cd293 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/test_if_disabling_all_subgraph_jumps_causes_error.graphql @@ -0,0 +1,74 @@ +# Composed from subgraphs with hash: 440d3c4bdff8acf74c9ca72961ae2f7bbb9f2223 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type Foo + @join__type(graph: SUBGRAPHA) + @join__type(graph: SUBGRAPHB, key: "idA idB") + @join__type(graph: SUBGRAPHC, key: "idA") +{ + idA: ID! + idB: ID! @join__field(graph: SUBGRAPHA) @join__field(graph: SUBGRAPHB) + bar: String! @join__field(graph: SUBGRAPHB) @join__field(graph: SUBGRAPHC) +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPHA @join__graph(name: "subgraphA", url: "none") + SUBGRAPHB @join__graph(name: "subgraphB", url: "none") + SUBGRAPHC @join__graph(name: "subgraphC", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPHA) + @join__type(graph: SUBGRAPHB) + @join__type(graph: SUBGRAPHC) +{ + foo: Foo @join__field(graph: SUBGRAPHA) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/test_if_disabling_less_expensive_subgraph_jump_causes_other_to_be_used.graphql b/apollo-federation/tests/query_plan/supergraphs/test_if_disabling_less_expensive_subgraph_jump_causes_other_to_be_used.graphql new file mode 100644 index 0000000000..85b66cd293 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/test_if_disabling_less_expensive_subgraph_jump_causes_other_to_be_used.graphql @@ -0,0 +1,74 @@ +# Composed from subgraphs with hash: 440d3c4bdff8acf74c9ca72961ae2f7bbb9f2223 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type Foo + @join__type(graph: SUBGRAPHA) + @join__type(graph: SUBGRAPHB, key: "idA idB") + @join__type(graph: SUBGRAPHC, key: "idA") +{ + idA: ID! + idB: ID! @join__field(graph: SUBGRAPHA) @join__field(graph: SUBGRAPHB) + bar: String! @join__field(graph: SUBGRAPHB) @join__field(graph: SUBGRAPHC) +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPHA @join__graph(name: "subgraphA", url: "none") + SUBGRAPHB @join__graph(name: "subgraphB", url: "none") + SUBGRAPHC @join__graph(name: "subgraphC", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPHA) + @join__type(graph: SUBGRAPHB) + @join__type(graph: SUBGRAPHC) +{ + foo: Foo @join__field(graph: SUBGRAPHA) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/test_if_less_expensive_subgraph_jump_is_used.graphql b/apollo-federation/tests/query_plan/supergraphs/test_if_less_expensive_subgraph_jump_is_used.graphql new file mode 100644 index 0000000000..85b66cd293 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/test_if_less_expensive_subgraph_jump_is_used.graphql @@ -0,0 +1,74 @@ +# Composed from subgraphs with hash: 440d3c4bdff8acf74c9ca72961ae2f7bbb9f2223 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type Foo + @join__type(graph: SUBGRAPHA) + @join__type(graph: SUBGRAPHB, key: "idA idB") + @join__type(graph: SUBGRAPHC, key: "idA") +{ + idA: ID! + idB: ID! @join__field(graph: SUBGRAPHA) @join__field(graph: SUBGRAPHB) + bar: String! @join__field(graph: SUBGRAPHB) @join__field(graph: SUBGRAPHC) +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPHA @join__graph(name: "subgraphA", url: "none") + SUBGRAPHB @join__graph(name: "subgraphB", url: "none") + SUBGRAPHC @join__graph(name: "subgraphC", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPHA) + @join__type(graph: SUBGRAPHB) + @join__type(graph: SUBGRAPHC) +{ + foo: Foo @join__field(graph: SUBGRAPHA) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/test_type_conditioned_fetching_with_interface_object_does_not_crash.graphql b/apollo-federation/tests/query_plan/supergraphs/test_type_conditioned_fetching_with_interface_object_does_not_crash.graphql new file mode 100644 index 0000000000..4a21fe7ba1 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/test_type_conditioned_fetching_with_interface_object_does_not_crash.graphql @@ -0,0 +1,86 @@ +# Composed from subgraphs with hash: 161c48cab8f2c97bc5fef235b557994f82dc7e51 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type A implements I + @join__implements(graph: S2, interface: "I") + @join__type(graph: S2, key: "id") +{ + id: ID! + a: Int + t: T @join__field +} + +interface I + @join__type(graph: S1, key: "id", isInterfaceObject: true) + @join__type(graph: S2, key: "id") +{ + id: ID! + t: T @join__field(graph: S1) + a: Int @join__field(graph: S2) +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + S1 @join__graph(name: "S1", url: "none") + S2 @join__graph(name: "S2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: S1) + @join__type(graph: S2) +{ + i: I @join__field(graph: S2) +} + +type T + @join__type(graph: S1) +{ + relatedIs: [I] +} diff --git a/apollo-federation/tests/query_plan/supergraphs/trying_to_use_defer_with_a_subcription_results_in_an_error.graphql b/apollo-federation/tests/query_plan/supergraphs/trying_to_use_defer_with_a_subscription_results_in_an_error.graphql similarity index 100% rename from apollo-federation/tests/query_plan/supergraphs/trying_to_use_defer_with_a_subcription_results_in_an_error.graphql rename to apollo-federation/tests/query_plan/supergraphs/trying_to_use_defer_with_a_subscription_results_in_an_error.graphql diff --git a/apollo-federation/tests/query_plan/supergraphs/trying_to_use_include_with_a_subscription_results_in_an_error.graphql b/apollo-federation/tests/query_plan/supergraphs/trying_to_use_include_with_a_subscription_results_in_an_error.graphql new file mode 100644 index 0000000000..075d5bd978 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/trying_to_use_include_with_a_subscription_results_in_an_error.graphql @@ -0,0 +1,79 @@ +# Composed from subgraphs with hash: c8a41e00d374bc7c77184e0ffa9fae7d65868f14 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query + subscription: Subscription +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPHA @join__graph(name: "SubgraphA", url: "none") + SUBGRAPHB @join__graph(name: "SubgraphB", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPHA) + @join__type(graph: SUBGRAPHB) +{ + me: User! @join__field(graph: SUBGRAPHA) + foo: Int @join__field(graph: SUBGRAPHB) +} + +type Subscription + @join__type(graph: SUBGRAPHA) +{ + onNewUser: User! +} + +type User + @join__type(graph: SUBGRAPHA, key: "id") + @join__type(graph: SUBGRAPHB, key: "id") +{ + id: ID! + name: String! @join__field(graph: SUBGRAPHA) + address: String! @join__field(graph: SUBGRAPHB) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/trying_to_use_skip_with_a_subscription_results_in_an_error.graphql b/apollo-federation/tests/query_plan/supergraphs/trying_to_use_skip_with_a_subscription_results_in_an_error.graphql new file mode 100644 index 0000000000..075d5bd978 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/trying_to_use_skip_with_a_subscription_results_in_an_error.graphql @@ -0,0 +1,79 @@ +# Composed from subgraphs with hash: c8a41e00d374bc7c77184e0ffa9fae7d65868f14 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query + subscription: Subscription +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPHA @join__graph(name: "SubgraphA", url: "none") + SUBGRAPHB @join__graph(name: "SubgraphB", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPHA) + @join__type(graph: SUBGRAPHB) +{ + me: User! @join__field(graph: SUBGRAPHA) + foo: Int @join__field(graph: SUBGRAPHB) +} + +type Subscription + @join__type(graph: SUBGRAPHA) +{ + onNewUser: User! +} + +type User + @join__type(graph: SUBGRAPHA, key: "id") + @join__type(graph: SUBGRAPHB, key: "id") +{ + id: ID! + name: String! @join__field(graph: SUBGRAPHA) + address: String! @join__field(graph: SUBGRAPHB) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/works_with_key_chains.graphql b/apollo-federation/tests/query_plan/supergraphs/works_with_key_chains.graphql index 3b8b99fd6d..73787da61d 100644 --- a/apollo-federation/tests/query_plan/supergraphs/works_with_key_chains.graphql +++ b/apollo-federation/tests/query_plan/supergraphs/works_with_key_chains.graphql @@ -1,4 +1,4 @@ -# Composed from subgraphs with hash: 2a34e202493c546249d10e9e361038512dd0e213 +# Composed from subgraphs with hash: e824c9fb889f790eb6a3dbe8aadf3e57840376a5 schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @@ -71,6 +71,15 @@ type T { id1: ID! @join__field(graph: SUBGRAPH1) @join__field(graph: SUBGRAPH2) id2: ID! @join__field(graph: SUBGRAPH2) @join__field(graph: SUBGRAPH3) + u1: U @join__field(graph: SUBGRAPH2) + u2: U @join__field(graph: SUBGRAPH2) x: Int @join__field(graph: SUBGRAPH3) y: Int @join__field(graph: SUBGRAPH3) } + +type U + @join__type(graph: SUBGRAPH2) +{ + a: String + b: Int +} diff --git a/apollo-federation/tests/snapshots/main__composition_tests__can_compose_supergraph.snap b/apollo-federation/tests/snapshots/main__composition_tests__can_compose_supergraph.snap index e4dcf8530b..79c5a91adf 100644 --- a/apollo-federation/tests/snapshots/main__composition_tests__can_compose_supergraph.snap +++ b/apollo-federation/tests/snapshots/main__composition_tests__can_compose_supergraph.snap @@ -2,13 +2,15 @@ source: apollo-federation/tests/composition_tests.rs expression: print_sdl(supergraph.schema.schema()) --- -schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) { query: Query } +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE -directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION directive @join__graph(name: String!, url: String!) on ENUM_VALUE @@ -28,23 +30,34 @@ enum E @join__type(graph: SUBGRAPH2) { scalar Import @join__type(graph: SUBGRAPH1) @join__type(graph: SUBGRAPH2) type Query @join__type(graph: SUBGRAPH1) @join__type(graph: SUBGRAPH2) { - t: T @join__field(graph: SUBGRAPH1) + t: T @join__field(graph: SUBGRAPH1, type: "T") } type S @join__type(graph: SUBGRAPH1) { - x: Int @join__field(graph: SUBGRAPH1) + x: Int @join__field(graph: SUBGRAPH1, type: "Int") } type T @join__type(graph: SUBGRAPH1, key: "k") @join__type(graph: SUBGRAPH2, key: "k") { - k: ID @join__field(graph: SUBGRAPH1) @join__field(graph: SUBGRAPH2) - a: Int @join__field(graph: SUBGRAPH2) - b: String @join__field(graph: SUBGRAPH2) + k: ID @join__field(graph: SUBGRAPH1, type: "ID") @join__field(graph: SUBGRAPH2, type: "ID") + a: Int @join__field(graph: SUBGRAPH2, type: "Int") + b: String @join__field(graph: SUBGRAPH2, type: "String") } union U @join__type(graph: SUBGRAPH1) @join__unionMember(graph: SUBGRAPH1, member: "S") @join__unionMember(graph: SUBGRAPH1, member: "T") = S | T +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + scalar join__FieldSet +scalar join__FieldValue + enum join__Graph { SUBGRAPH1 @join__graph(name: "Subgraph1", url: "https://subgraph1") SUBGRAPH2 @join__graph(name: "Subgraph2", url: "https://subgraph2") diff --git a/apollo-federation/tests/snapshots/main__composition_tests__can_compose_types_from_different_subgraphs.snap b/apollo-federation/tests/snapshots/main__composition_tests__can_compose_types_from_different_subgraphs.snap index dfe41eb6ab..cd534b94f0 100644 --- a/apollo-federation/tests/snapshots/main__composition_tests__can_compose_types_from_different_subgraphs.snap +++ b/apollo-federation/tests/snapshots/main__composition_tests__can_compose_types_from_different_subgraphs.snap @@ -2,13 +2,15 @@ source: apollo-federation/tests/composition_tests.rs expression: print_sdl(supergraph.schema.schema()) --- -schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) { query: Query } +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE -directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION directive @join__graph(name: String!, url: String!) on ENUM_VALUE @@ -23,21 +25,32 @@ directive @link(url: String, as: String, for: link__Purpose, import: [link__Impo scalar Import @join__type(graph: SUBGRAPHA) @join__type(graph: SUBGRAPHB) type Product @join__type(graph: SUBGRAPHA) { - sku: String! @join__field(graph: SUBGRAPHA) - name: String! @join__field(graph: SUBGRAPHA) + sku: String! @join__field(graph: SUBGRAPHA, type: "String!") + name: String! @join__field(graph: SUBGRAPHA, type: "String!") } type Query @join__type(graph: SUBGRAPHA) @join__type(graph: SUBGRAPHB) { - products: [Product!] @join__field(graph: SUBGRAPHA) + products: [Product!] @join__field(graph: SUBGRAPHA, type: "[Product!]") } type User @join__type(graph: SUBGRAPHB) { - name: String @join__field(graph: SUBGRAPHB) - email: String! @join__field(graph: SUBGRAPHB) + name: String @join__field(graph: SUBGRAPHB, type: "String") + email: String! @join__field(graph: SUBGRAPHB, type: "String!") +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! } +scalar join__DirectiveArguments + scalar join__FieldSet +scalar join__FieldValue + enum join__Graph { SUBGRAPHA @join__graph(name: "SubgraphA", url: "https://subgraphA") SUBGRAPHB @join__graph(name: "SubgraphB", url: "https://subgraphB") diff --git a/apollo-federation/tests/snapshots/main__composition_tests__can_compose_with_descriptions.snap b/apollo-federation/tests/snapshots/main__composition_tests__can_compose_with_descriptions.snap index 59ae3be771..3a4c8f9a45 100644 --- a/apollo-federation/tests/snapshots/main__composition_tests__can_compose_with_descriptions.snap +++ b/apollo-federation/tests/snapshots/main__composition_tests__can_compose_with_descriptions.snap @@ -3,16 +3,18 @@ source: apollo-federation/tests/composition_tests.rs expression: print_sdl(supergraph.schema.schema()) --- """A cool schema""" -schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) { query: Query } """The foo directive description""" directive @foo(url: String) on FIELD +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE -directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION directive @join__graph(name: String!, url: String!) on ENUM_VALUE @@ -43,11 +45,22 @@ type Query @join__type(graph: SUBGRAPH1) @join__type(graph: SUBGRAPH2) { t( """An argument that is very important""" x: String!, - ): String @join__field(graph: SUBGRAPH1) + ): String @join__field(graph: SUBGRAPH1, type: "String") +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! } +scalar join__DirectiveArguments + scalar join__FieldSet +scalar join__FieldValue + enum join__Graph { SUBGRAPH1 @join__graph(name: "Subgraph1", url: "https://subgraph1") SUBGRAPH2 @join__graph(name: "Subgraph2", url: "https://subgraph2") diff --git a/apollo-federation/tests/snapshots/main__composition_tests__compose_removes_federation_directives.snap b/apollo-federation/tests/snapshots/main__composition_tests__compose_removes_federation_directives.snap index 443ee630a4..e78cfc9371 100644 --- a/apollo-federation/tests/snapshots/main__composition_tests__compose_removes_federation_directives.snap +++ b/apollo-federation/tests/snapshots/main__composition_tests__compose_removes_federation_directives.snap @@ -2,13 +2,15 @@ source: apollo-federation/tests/composition_tests.rs expression: print_sdl(supergraph.schema.schema()) --- -schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) { query: Query } +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE -directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION directive @join__graph(name: String!, url: String!) on ENUM_VALUE @@ -23,16 +25,27 @@ directive @link(url: String, as: String, for: link__Purpose, import: [link__Impo scalar Import @join__type(graph: SUBGRAPHA) @join__type(graph: SUBGRAPHB) type Product @join__type(graph: SUBGRAPHA, key: "sku") @join__type(graph: SUBGRAPHB, key: "sku") { - sku: String! @join__field(graph: SUBGRAPHA) @join__field(graph: SUBGRAPHB) - name: String! @join__field(graph: SUBGRAPHA, external: true) @join__field(graph: SUBGRAPHB) + sku: String! @join__field(graph: SUBGRAPHA, type: "String!") @join__field(graph: SUBGRAPHB, type: "String!") + name: String! @join__field(graph: SUBGRAPHA, external: true, type: "String!") @join__field(graph: SUBGRAPHB, type: "String!") } type Query @join__type(graph: SUBGRAPHA) @join__type(graph: SUBGRAPHB) { - products: [Product!] @join__field(graph: SUBGRAPHA, provides: "name") + products: [Product!] @join__field(graph: SUBGRAPHA, provides: "name", type: "[Product!]") +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! } +scalar join__DirectiveArguments + scalar join__FieldSet +scalar join__FieldValue + enum join__Graph { SUBGRAPHA @join__graph(name: "SubgraphA", url: "https://subgraphA") SUBGRAPHB @join__graph(name: "SubgraphB", url: "https://subgraphB") diff --git a/apollo-federation/tests/snapshots/main__extract_subgraphs__can_extract_subgraph.snap b/apollo-federation/tests/snapshots/main__extract_subgraphs__can_extract_subgraph.snap index 324709bd56..4b68a496cd 100644 --- a/apollo-federation/tests/snapshots/main__extract_subgraphs__can_extract_subgraph.snap +++ b/apollo-federation/tests/snapshots/main__extract_subgraphs__can_extract_subgraph.snap @@ -8,7 +8,7 @@ schema { query: Query } -extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.9") +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA @@ -42,6 +42,12 @@ directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_ directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + scalar link__Import enum link__Purpose { @@ -55,6 +61,8 @@ enum link__Purpose { EXECUTION } +scalar federation__ContextFieldValue + scalar federation__FieldSet scalar federation__Scope @@ -89,7 +97,7 @@ schema { query: Query } -extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.9") +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA @@ -123,6 +131,12 @@ directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_ directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + scalar link__Import enum link__Purpose { @@ -136,6 +150,8 @@ enum link__Purpose { EXECUTION } +scalar federation__ContextFieldValue + scalar federation__FieldSet scalar federation__Scope diff --git a/apollo-federation/tests/snapshots/main__extract_subgraphs__does_not_extract_demand_control_directive_name_conflicts.snap b/apollo-federation/tests/snapshots/main__extract_subgraphs__does_not_extract_demand_control_directive_name_conflicts.snap index f86e759fca..650d15ca63 100644 --- a/apollo-federation/tests/snapshots/main__extract_subgraphs__does_not_extract_demand_control_directive_name_conflicts.snap +++ b/apollo-federation/tests/snapshots/main__extract_subgraphs__does_not_extract_demand_control_directive_name_conflicts.snap @@ -8,7 +8,7 @@ schema { query: Query } -extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.9") +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA @@ -42,6 +42,12 @@ directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_ directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + scalar link__Import enum link__Purpose { @@ -55,6 +61,8 @@ enum link__Purpose { EXECUTION } +scalar federation__ContextFieldValue + scalar federation__FieldSet scalar federation__Scope @@ -78,7 +86,7 @@ schema { query: Query } -extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.9") +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA @@ -112,6 +120,12 @@ directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_ directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + scalar link__Import enum link__Purpose { @@ -125,6 +139,8 @@ enum link__Purpose { EXECUTION } +scalar federation__ContextFieldValue + scalar federation__FieldSet scalar federation__Scope diff --git a/apollo-federation/tests/snapshots/main__extract_subgraphs__does_not_extract_renamed_demand_control_directive_name_conflicts.snap b/apollo-federation/tests/snapshots/main__extract_subgraphs__does_not_extract_renamed_demand_control_directive_name_conflicts.snap index f86e759fca..650d15ca63 100644 --- a/apollo-federation/tests/snapshots/main__extract_subgraphs__does_not_extract_renamed_demand_control_directive_name_conflicts.snap +++ b/apollo-federation/tests/snapshots/main__extract_subgraphs__does_not_extract_renamed_demand_control_directive_name_conflicts.snap @@ -8,7 +8,7 @@ schema { query: Query } -extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.9") +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA @@ -42,6 +42,12 @@ directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_ directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + scalar link__Import enum link__Purpose { @@ -55,6 +61,8 @@ enum link__Purpose { EXECUTION } +scalar federation__ContextFieldValue + scalar federation__FieldSet scalar federation__Scope @@ -78,7 +86,7 @@ schema { query: Query } -extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.9") +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA @@ -112,6 +120,12 @@ directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_ directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + scalar link__Import enum link__Purpose { @@ -125,6 +139,8 @@ enum link__Purpose { EXECUTION } +scalar federation__ContextFieldValue + scalar federation__FieldSet scalar federation__Scope diff --git a/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_demand_control_directives.snap b/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_demand_control_directives.snap index 319b91d908..e1e7ce36ca 100644 --- a/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_demand_control_directives.snap +++ b/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_demand_control_directives.snap @@ -8,7 +8,7 @@ schema { query: Query } -extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.9") +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA @@ -42,6 +42,12 @@ directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_ directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + scalar link__Import enum link__Purpose { @@ -55,6 +61,8 @@ enum link__Purpose { EXECUTION } +scalar federation__ContextFieldValue + scalar federation__FieldSet scalar federation__Scope @@ -98,7 +106,7 @@ schema { query: Query } -extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.9") +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA @@ -132,6 +140,12 @@ directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_ directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + scalar link__Import enum link__Purpose { @@ -145,6 +159,8 @@ enum link__Purpose { EXECUTION } +scalar federation__ContextFieldValue + scalar federation__FieldSet scalar federation__Scope diff --git a/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_renamed_demand_control_directives.snap b/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_renamed_demand_control_directives.snap index 319b91d908..e1e7ce36ca 100644 --- a/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_renamed_demand_control_directives.snap +++ b/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_renamed_demand_control_directives.snap @@ -8,7 +8,7 @@ schema { query: Query } -extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.9") +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA @@ -42,6 +42,12 @@ directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_ directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + scalar link__Import enum link__Purpose { @@ -55,6 +61,8 @@ enum link__Purpose { EXECUTION } +scalar federation__ContextFieldValue + scalar federation__FieldSet scalar federation__Scope @@ -98,7 +106,7 @@ schema { query: Query } -extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.9") +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA @@ -132,6 +140,12 @@ directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_ directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + scalar link__Import enum link__Purpose { @@ -145,6 +159,8 @@ enum link__Purpose { EXECUTION } +scalar federation__ContextFieldValue + scalar federation__FieldSet scalar federation__Scope diff --git a/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_set_context_directives.snap b/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_set_context_directives.snap new file mode 100644 index 0000000000..38a2996949 --- /dev/null +++ b/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_set_context_directives.snap @@ -0,0 +1,178 @@ +--- +source: apollo-federation/tests/extract_subgraphs.rs +expression: snapshot +--- +Subgraph1 +--- +schema { + query: Query +} + +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @federation__key(fields: federation__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + +directive @federation__requires(fields: federation__FieldSet!) on FIELD_DEFINITION + +directive @federation__provides(fields: federation__FieldSet!) on FIELD_DEFINITION + +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION + +directive @federation__tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @federation__extends on OBJECT | INTERFACE + +directive @federation__shareable on OBJECT | FIELD_DEFINITION + +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @federation__override(from: String!, label: String) on FIELD_DEFINITION + +directive @federation__composeDirective(name: String) repeatable on SCHEMA + +directive @federation__interfaceObject on OBJECT + +directive @federation__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__requiresScopes(scopes: [[federation__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION + +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + +scalar link__Import + +enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +scalar federation__ContextFieldValue + +scalar federation__FieldSet + +scalar federation__Scope + +type Query { + t: T! + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type T @federation__key(fields: "id", resolvable: true) @federation__context(name: "context") { + id: ID! + u: U! + prop: String! +} + +type U @federation__key(fields: "id", resolvable: true) { + id: ID! @federation__shareable + field( + a: String @federation__fromContext(field: "$context { prop }"), + ): Int! +} + +scalar _Any + +type _Service { + sdl: String +} + +union _Entity = T | U + +Subgraph2 +--- +schema { + query: Query +} + +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @federation__key(fields: federation__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + +directive @federation__requires(fields: federation__FieldSet!) on FIELD_DEFINITION + +directive @federation__provides(fields: federation__FieldSet!) on FIELD_DEFINITION + +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION + +directive @federation__tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @federation__extends on OBJECT | INTERFACE + +directive @federation__shareable on OBJECT | FIELD_DEFINITION + +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @federation__override(from: String!, label: String) on FIELD_DEFINITION + +directive @federation__composeDirective(name: String) repeatable on SCHEMA + +directive @federation__interfaceObject on OBJECT + +directive @federation__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__requiresScopes(scopes: [[federation__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION + +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + +scalar link__Import + +enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +scalar federation__ContextFieldValue + +scalar federation__FieldSet + +scalar federation__Scope + +type Query { + a: Int! + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type U @federation__key(fields: "id", resolvable: true) { + id: ID! @federation__shareable +} + +scalar _Any + +type _Service { + sdl: String +} + +union _Entity = U diff --git a/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_string_enum_values.snap b/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_string_enum_values.snap new file mode 100644 index 0000000000..19cfd456b5 --- /dev/null +++ b/apollo-federation/tests/snapshots/main__extract_subgraphs__extracts_string_enum_values.snap @@ -0,0 +1,179 @@ +--- +source: apollo-federation/tests/extract_subgraphs.rs +expression: snapshot +--- +A +--- +schema { + query: Query +} + +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @federation__key(fields: federation__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + +directive @federation__requires(fields: federation__FieldSet!) on FIELD_DEFINITION + +directive @federation__provides(fields: federation__FieldSet!) on FIELD_DEFINITION + +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION + +directive @federation__tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @federation__extends on OBJECT | INTERFACE + +directive @federation__shareable on OBJECT | FIELD_DEFINITION + +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @federation__override(from: String!, label: String) on FIELD_DEFINITION + +directive @federation__composeDirective(name: String) repeatable on SCHEMA + +directive @federation__interfaceObject on OBJECT + +directive @federation__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__requiresScopes(scopes: [[federation__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION + +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + +scalar link__Import + +enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +scalar federation__ContextFieldValue + +scalar federation__FieldSet + +scalar federation__Scope + +type Entity @federation__key(fields: "id", resolvable: true) { + id: ID! @federation__shareable + localField: String! @federation__requires(fields: "requiredField(arg: ENUM_VALUE)") + requiredField(arg: Enum): String! @federation__external +} + +enum Enum { + ENUM_VALUE +} + +type Query { + entity: Entity + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +scalar _Any + +type _Service { + sdl: String +} + +union _Entity = Entity + +B +--- +schema { + query: Query +} + +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.12") + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @federation__key(fields: federation__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + +directive @federation__requires(fields: federation__FieldSet!) on FIELD_DEFINITION + +directive @federation__provides(fields: federation__FieldSet!) on FIELD_DEFINITION + +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION + +directive @federation__tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @federation__extends on OBJECT | INTERFACE + +directive @federation__shareable on OBJECT | FIELD_DEFINITION + +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @federation__override(from: String!, label: String) on FIELD_DEFINITION + +directive @federation__composeDirective(name: String) repeatable on SCHEMA + +directive @federation__interfaceObject on OBJECT + +directive @federation__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__requiresScopes(scopes: [[federation__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +directive @federation__cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @federation__listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION + +directive @federation__fromContext(field: federation__ContextFieldValue) on ARGUMENT_DEFINITION + +directive @federation__context(name: String!) repeatable on INTERFACE | OBJECT | UNION + +directive @federation__cacheTag(format: String!) repeatable on INTERFACE | OBJECT | FIELD_DEFINITION + +scalar link__Import + +enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +scalar federation__ContextFieldValue + +scalar federation__FieldSet + +scalar federation__Scope + +type Entity @federation__key(fields: "id", resolvable: true) { + id: ID! @federation__shareable + requiredField(arg: Enum): String! +} + +enum Enum { + ENUM_VALUE +} + +scalar _Any + +type _Service { + sdl: String +} + +union _Entity = Entity + +type Query { + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} diff --git a/apollo-federation/tests/subgraph/fixtures/tag_validation_template.graphqls b/apollo-federation/tests/subgraph/fixtures/tag_validation_template.graphqls new file mode 100644 index 0000000000..9f3d911fc0 --- /dev/null +++ b/apollo-federation/tests/subgraph/fixtures/tag_validation_template.graphqls @@ -0,0 +1,37 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@tag"]) + @tag(name: "test{symbol}") + +directive @custom(arg: String @tag(name: "test{symbol}")) on FIELD_DEFINITION + +type Query { + foo: Foo @tag(name: "{symbol}test") + bar: Bar @tag(name: "test{symbol}") + baz: Baz @tag(name: "test{symbol}test") @custom(arg: "something") +} +interface Foo @tag(name: "test{symbol}") { + name: String + foo1: String @tag(name: "test{symbol}") + foo2(arg: String @tag(name: "test{symbol}")): String +} +type Bar implements Foo @tag(name: "test{symbol}") { + name: String + foo1: String + foo2(arg: String): String + bar1: String @tag(name: "test{symbol}") + bar2(arg: String @tag(name: "test{symbol}")): String +} +union Baz @tag(name: "test{symbol}") = Bar + +enum TestEnum @tag(name: "test{symbol}") { + VALUE1 @tag(name: "test{symbol}") + VALUE2 +} + +input TestInput @tag(name: "test{symbol}") { + inputField1: String @tag(name: "test{symbol}") + inputField2: Int + inputField3: Boolean @tag(name: "{symbol}test") +} + +scalar CustomScalar @tag(name: "test{symbol}") diff --git a/apollo-federation/tests/subgraph/mod.rs b/apollo-federation/tests/subgraph/mod.rs index 7610f5925b..3b4f779a79 100644 --- a/apollo-federation/tests/subgraph/mod.rs +++ b/apollo-federation/tests/subgraph/mod.rs @@ -1 +1,2 @@ mod parse_expand_tests; +mod subgraph_validation_tests; diff --git a/apollo-federation/tests/subgraph/parse_expand_tests.rs b/apollo-federation/tests/subgraph/parse_expand_tests.rs index 6467cbfb0f..dc143ed53e 100644 --- a/apollo-federation/tests/subgraph/parse_expand_tests.rs +++ b/apollo-federation/tests/subgraph/parse_expand_tests.rs @@ -17,15 +17,17 @@ fn can_parse_and_expand() -> Result<(), String> { "#; let subgraph = Subgraph::parse_and_expand("S1", "http://s1", schema).map_err(|e| { - println!("{}", e); + println!("{e}"); String::from("failed to parse and expand the subgraph, see errors above for details") })?; assert!(subgraph.schema.types.contains_key("T")); assert!(subgraph.schema.directive_definitions.contains_key("key")); - assert!(subgraph - .schema - .directive_definitions - .contains_key("federation__requires")); + assert!( + subgraph + .schema + .directive_definitions + .contains_key("federation__requires") + ); Ok(()) } @@ -46,14 +48,16 @@ fn can_parse_and_expand_with_renames() -> Result<(), String> { "#; let subgraph = Subgraph::parse_and_expand("S1", "http://s1", schema).map_err(|e| { - println!("{}", e); + println!("{e}"); String::from("failed to parse and expand the subgraph, see errors above for details") })?; assert!(subgraph.schema.directive_definitions.contains_key("myKey")); - assert!(subgraph - .schema - .directive_definitions - .contains_key("provides")); + assert!( + subgraph + .schema + .directive_definitions + .contains_key("provides") + ); Ok(()) } @@ -74,14 +78,16 @@ fn can_parse_and_expand_with_namespace() -> Result<(), String> { "#; let subgraph = Subgraph::parse_and_expand("S1", "http://s1", schema).map_err(|e| { - println!("{}", e); + println!("{e}"); String::from("failed to parse and expand the subgraph, see errors above for details") })?; assert!(subgraph.schema.directive_definitions.contains_key("key")); - assert!(subgraph - .schema - .directive_definitions - .contains_key("fed__requires")); + assert!( + subgraph + .schema + .directive_definitions + .contains_key("fed__requires") + ); Ok(()) } @@ -112,7 +118,7 @@ fn can_parse_and_expand_preserves_user_definitions() -> Result<(), String> { "#; let subgraph = Subgraph::parse_and_expand("S1", "http://s1", schema).map_err(|e| { - println!("{}", e); + println!("{e}"); String::from("failed to parse and expand the subgraph, see errors above for details") })?; assert!(subgraph.schema.types.contains_key("Purpose")); @@ -133,7 +139,7 @@ fn can_parse_and_expand_works_with_fed_v1() -> Result<(), String> { "#; let subgraph = Subgraph::parse_and_expand("S1", "http://s1", schema).map_err(|e| { - println!("{}", e); + println!("{e}"); String::from("failed to parse and expand the subgraph, see errors above for details") })?; assert!(subgraph.schema.types.contains_key("T")); @@ -160,5 +166,8 @@ fn can_parse_and_expand_will_fail_when_importing_same_spec_twice() { let result = Subgraph::parse_and_expand("S1", "http://s1", schema) .expect_err("importing same specification twice should fail"); - assert_eq!("Invalid use of @link in schema: invalid graphql schema - multiple @link imports for the federation specification are not supported", result.to_string()); + assert_eq!( + "Invalid use of @link in schema: invalid graphql schema - multiple @link imports for the federation specification are not supported", + result.to_string() + ); } diff --git a/apollo-federation/tests/subgraph/subgraph_validation_tests.rs b/apollo-federation/tests/subgraph/subgraph_validation_tests.rs new file mode 100644 index 0000000000..e8eeafb080 --- /dev/null +++ b/apollo-federation/tests/subgraph/subgraph_validation_tests.rs @@ -0,0 +1,2307 @@ +use apollo_federation::assert_errors; +use apollo_federation::subgraph::test_utils::BuildOption; +use apollo_federation::subgraph::test_utils::build_and_validate; +use apollo_federation::subgraph::test_utils::build_for_errors; +use apollo_federation::subgraph::test_utils::build_for_errors_with_option; + +mod fieldset_based_directives { + use super::*; + + #[test] + fn rejects_field_defined_with_arguments_in_key() { + let schema_str = r#" + type Query { + t: T + } + type T @key(fields: "f") { + f(x: Int): Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "KEY_FIELDS_HAS_ARGS", + r#"[S] On type "T", for @key(fields: "f"): field T.f cannot be included because it has arguments (fields with argument are not allowed in @key)"#, + )] + ); + } + + #[test] + fn rejects_field_defined_with_arguments_in_provides() { + let schema_str = r#" + type Query { + t: T @provides(fields: "f") + } + + type T { + f(x: Int): Int @external + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "PROVIDES_FIELDS_HAS_ARGS", + r#"[S] On field "Query.t", for @provides(fields: "f"): field T.f cannot be included because it has arguments (fields with argument are not allowed in @provides)"#, + )] + ); + } + + #[test] + fn rejects_provides_on_non_external_fields() { + let schema_str = r#" + type Query { + t: T @provides(fields: "f") + } + + type T { + f: Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "PROVIDES_FIELDS_MISSING_EXTERNAL", + r#"[S] On field "Query.t", for @provides(fields: "f"): field "T.f" should not be part of a @provides since it is already provided by this subgraph (it is not marked @external)"#, + )] + ); + } + + #[test] + fn rejects_requires_on_non_external_fields() { + let schema_str = r#" + type Query { + t: T + } + + type T { + f: Int + g: Int @requires(fields: "f") + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "REQUIRES_FIELDS_MISSING_EXTERNAL", + r#"[S] On field "T.g", for @requires(fields: "f"): field "T.f" should not be part of a @requires since it is already provided by this subgraph (it is not marked @external)"#, + )] + ); + } + + #[test] + fn rejects_key_on_interfaces_in_all_specs() { + for version in ["2.0", "2.1", "2.2"] { + let schema_str = format!( + r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v{version}", import: ["@key"]) + + type Query {{ + t: T + }} + + interface T @key(fields: "f") {{ + f: Int + }} + "# + ); + let err = build_for_errors_with_option(&schema_str, BuildOption::AsIs); + + assert_errors!( + err, + [( + "KEY_UNSUPPORTED_ON_INTERFACE", + r#"[S] Cannot use @key on interface "T": @key is not yet supported on interfaces"#, + )] + ); + } + } + + #[test] + fn rejects_provides_on_interfaces() { + let schema_str = r#" + type Query { + t: T + } + + interface T { + f: U @provides(fields: "g") + } + + type U { + g: Int @external + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "PROVIDES_UNSUPPORTED_ON_INTERFACE", + r#"[S] Cannot use @provides on field "T.f" of parent type "T": @provides is not yet supported within interfaces"#, + )] + ); + } + + #[test] + fn rejects_requires_on_interfaces() { + let schema_str = r#" + type Query { + t: T + } + + interface T { + f: Int @external + g: Int @requires(fields: "f") + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [ + ( + "REQUIRES_UNSUPPORTED_ON_INTERFACE", + r#"[S] Cannot use @requires on field "T.g" of parent type "T": @requires is not yet supported within interfaces"#, + ), + ( + "EXTERNAL_ON_INTERFACE", + r#"[S] Interface type field "T.f" is marked @external but @external is not allowed on interface fields."#, + ), + ] + ); + } + + #[test] + fn rejects_unused_external() { + let schema_str = r#" + type Query { + t: T + } + + type T { + f: Int @external + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "EXTERNAL_UNUSED", + r#"[S] Field "T.f" is marked @external but is not used in any federation directive (@key, @provides, @requires) or to satisfy an interface; the field declaration has no use and should be removed (or the field should not be @external)."#, + )] + ); + } + + #[test] + fn rejects_provides_on_non_object_fields() { + let schema_str = r#" + type Query { + t: Int @provides(fields: "f") + } + + type T { + f: Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "PROVIDES_ON_NON_OBJECT_FIELD", + r#"[S] Invalid @provides directive on field "Query.t": field has type "Int" which is not a Composite Type"#, + )] + ); + } + + #[test] + fn rejects_non_string_argument_to_key() { + let schema_str = r#" + type Query { + t: T + } + + type T @key(fields: ["f"]) { + f: Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "KEY_INVALID_FIELDS_TYPE", + r#"[S] On type "T", for @key(fields: ["f"]): Invalid value for argument "fields": must be a string."#, + )] + ); + } + + #[test] + fn rejects_non_string_argument_to_provides() { + let schema_str = r#" + type Query { + t: T @provides(fields: ["f"]) + } + + type T { + f: Int @external + } + "#; + let err = build_for_errors(schema_str); + + // Note: since the error here is that we cannot parse the key `fields`, this also means that @external on + // `f` will appear unused and we get an error for it. It's kind of hard to avoid cleanly and hopefully + // not a big deal (having errors dependencies is not exactly unheard of). + assert_errors!( + err, + [ + ( + "PROVIDES_INVALID_FIELDS_TYPE", + r#"[S] On field "Query.t", for @provides(fields: ["f"]): Invalid value for argument "fields": must be a string."#, + ), + ( + "EXTERNAL_UNUSED", + r#"[S] Field "T.f" is marked @external but is not used in any federation directive (@key, @provides, @requires) or to satisfy an interface; the field declaration has no use and should be removed (or the field should not be @external)."#, + ), + ] + ); + } + + #[test] + fn rejects_non_string_argument_to_requires() { + let schema_str = r#" + type Query { + t: T + } + + type T { + f: Int @external + g: Int @requires(fields: ["f"]) + } + "#; + let err = build_for_errors(schema_str); + + // Note: since the error here is that we cannot parse the key `fields`, this also means that @external on + // `f` will appear unused and we get an error for it. It's kind of hard to avoid cleanly and hopefully + // not a big deal (having errors dependencies is not exactly unheard of). + assert_errors!( + err, + [ + ( + "REQUIRES_INVALID_FIELDS_TYPE", + r#"[S] On field "T.g", for @requires(fields: ["f"]): Invalid value for argument "fields": must be a string."#, + ), + ( + "EXTERNAL_UNUSED", + r#"[S] Field "T.f" is marked @external but is not used in any federation directive (@key, @provides, @requires) or to satisfy an interface; the field declaration has no use and should be removed (or the field should not be @external)."#, + ), + ] + ); + } + + #[test] + // Special case of non-string argument, specialized because it hits a different + // code-path due to enum values being parsed as string and requiring special care. + fn rejects_enum_like_argument_to_key() { + let schema_str = r#" + type Query { + t: T + } + + type T @key(fields: f) { + f: Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "KEY_INVALID_FIELDS_TYPE", + r#"[S] On type "T", for @key(fields: f): Invalid value for argument "fields": must be a string."#, + )] + ); + } + + #[test] + // Special case of non-string argument, specialized because it hits a different + // code-path due to enum values being parsed as string and requiring special care. + fn rejects_enum_like_argument_to_provides() { + let schema_str = r#" + type Query { + t: T @provides(fields: f) + } + + type T { + f: Int @external + } + "#; + let err = build_for_errors(schema_str); + + // Note: since the error here is that we cannot parse the key `fields`, this also mean that @external on + // `f` will appear unused and we get an error for it. It's kind of hard to avoid cleanly and hopefully + // not a big deal (having errors dependencies is not exactly unheard of). + assert_errors!( + err, + [ + ( + "PROVIDES_INVALID_FIELDS_TYPE", + r#"[S] On field "Query.t", for @provides(fields: f): Invalid value for argument "fields": must be a string."#, + ), + ( + "EXTERNAL_UNUSED", + r#"[S] Field "T.f" is marked @external but is not used in any federation directive (@key, @provides, @requires) or to satisfy an interface; the field declaration has no use and should be removed (or the field should not be @external)."#, + ), + ] + ); + } + + #[test] + // Special case of non-string argument, specialized because it hits a different + // code-path due to enum values being parsed as string and requiring special care. + fn rejects_enum_like_argument_to_requires() { + let schema_str = r#" + type Query { + t: T + } + + type T { + f: Int @external + g: Int @requires(fields: f) + } + "#; + let err = build_for_errors(schema_str); + + // Note: since the error here is that we cannot parse the key `fields`, this also mean that @external on + // `f` will appear unused and we get an error for it. It's kind of hard to avoid cleanly and hopefully + // not a big deal (having errors dependencies is not exactly unheard of). + assert_errors!( + err, + [ + ( + "REQUIRES_INVALID_FIELDS_TYPE", + r#"[S] On field "T.g", for @requires(fields: f): Invalid value for argument "fields": must be a string."#, + ), + ( + "EXTERNAL_UNUSED", + r#"[S] Field "T.f" is marked @external but is not used in any federation directive (@key, @provides, @requires) or to satisfy an interface; the field declaration has no use and should be removed (or the field should not be @external)."#, + ), + ] + ); + } + + #[test] + fn rejects_invalid_fields_argument_to_key() { + let schema_str = r#" + type Query { + t: T + } + + type T @key(fields: ":f") { + f: Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "KEY_INVALID_FIELDS", + r#"[S] On type "T", for @key(fields: ":f"): Syntax error: expected at least one Selection in Selection Set"#, + )] + ); + } + + #[test] + fn rejects_invalid_fields_argument_to_provides() { + let schema_str = r#" + type Query { + t: T @provides(fields: "{{f}}") + } + + type T { + f: Int @external + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [ + ( + "PROVIDES_INVALID_FIELDS", + r#"[S] On field "Query.t", for @provides(fields: "{{f}}"): Syntax error: expected at least one Selection in Selection Set"#, + ), + ( + "PROVIDES_INVALID_FIELDS", + r#"[S] On field "Query.t", for @provides(fields: "{{f}}"): Syntax error: expected R_CURLY, got {"# + ), + ( + "EXTERNAL_UNUSED", + r#"[S] Field "T.f" is marked @external but is not used in any federation directive (@key, @provides, @requires) or to satisfy an interface; the field declaration has no use and should be removed (or the field should not be @external)."#, + ), + ] + ); + } + + #[test] + fn rejects_invalid_fields_argument_to_requires() { + let schema_str = r#" + type Query { + t: T + } + + type T { + f: Int @external + g: Int @requires(fields: "f b") + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "REQUIRES_INVALID_FIELDS", + r#"[S] On field "T.g", for @requires(fields: "f b"): Cannot query field "b" on type "T". If the field is defined in another subgraph, you need to add it to this subgraph with @external."#, + )] + ); + } + + #[test] + fn rejects_key_on_interface_field() { + let schema_str = r#" + type Query { + t: T + } + + type T @key(fields: "f") { + f: I + } + + interface I { + i: Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "KEY_FIELDS_SELECT_INVALID_TYPE", + r#"[S] On type "T", for @key(fields: "f"): field "T.f" is an Interface type which is not allowed in @key"#, + )] + ); + } + + #[test] + fn rejects_key_on_union_field() { + let schema_str = r#" + type Query { + t: T + } + + type T @key(fields: "f") { + f: U + } + + union U = Query | T + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "KEY_FIELDS_SELECT_INVALID_TYPE", + r#"[S] On type "T", for @key(fields: "f"): field "T.f" is a Union type which is not allowed in @key"#, + )] + ); + } + + #[test] + fn rejects_directive_applications_in_key() { + let schema_str = r#" + type Query { + t: T + } + + type T @key(fields: "v { x ... @include(if: false) { y }}") { + v: V + } + + type V { + x: Int + y: Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "KEY_DIRECTIVE_IN_FIELDS_ARG", + r#"[S] On type "T", for @key(fields: "v { x ... @include(if: false) { y }}"): cannot have directive applications in the @key(fields:) argument but found @include(if: false)."#, + )] + ); + } + + #[test] + fn rejects_directive_applications_in_provides() { + let schema_str = r#" + type Query { + t: T @provides(fields: "v { ... on V @skip(if: true) { x y } }") + } + + type T @key(fields: "id") { + id: ID + v: V @external + } + + type V { + x: Int + y: Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "PROVIDES_DIRECTIVE_IN_FIELDS_ARG", + r#"[S] On field "Query.t", for @provides(fields: "v { ... on V @skip(if: true) { x y } }"): cannot have directive applications in the @provides(fields:) argument but found @skip(if: true)."#, + )] + ); + } + + #[test] + fn rejects_directive_applications_in_requires() { + let schema_str = r#" + type Query { + t: T + } + + type T @key(fields: "id") { + id: ID + a: Int @requires(fields: "... @skip(if: false) { b }") + b: Int @external + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "REQUIRES_DIRECTIVE_IN_FIELDS_ARG", + r#"[S] On field "T.a", for @requires(fields: "... @skip(if: false) { b }"): cannot have directive applications in the @requires(fields:) argument but found @skip(if: false)."#, + )] + ); + } + + #[test] + fn can_collect_multiple_errors_in_a_single_fields_argument() { + let schema_str = r#" + type Query { + t: T @provides(fields: "f(x: 3)") + } + + type T @key(fields: "id") { + id: ID + f(x: Int): Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [ + ( + "PROVIDES_FIELDS_HAS_ARGS", + r#"[S] On field "Query.t", for @provides(fields: "f(x: 3)"): field T.f cannot be included because it has arguments (fields with argument are not allowed in @provides)"#, + ), + ( + "PROVIDES_FIELDS_MISSING_EXTERNAL", + r#"[S] On field "Query.t", for @provides(fields: "f(x: 3)"): field "T.f" should not be part of a @provides since it is already provided by this subgraph (it is not marked @external)"#, + ), + ] + ); + } + + #[test] + fn rejects_aliases_in_key() { + let schema_str = r#" + type Query { + t: T + } + + type T @key(fields: "foo: id") { + id: ID! + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "KEY_INVALID_FIELDS", + r#"[S] On type "T", for @key(fields: "foo: id"): Cannot use alias "foo" in "foo: id": aliases are not currently supported in @key"#, + )] + ); + } + + #[test] + fn rejects_aliases_in_provides() { + let schema_str = r#" + type Query { + t: T @provides(fields: "bar: x") + } + + type T @key(fields: "id") { + id: ID! + x: Int @external + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "PROVIDES_INVALID_FIELDS", + r#"[S] On field "Query.t", for @provides(fields: "bar: x"): Cannot use alias "bar" in "bar: x": aliases are not currently supported in @provides"#, + )] + ); + } + + #[test] + fn rejects_aliases_in_requires() { + let schema_str = r#" + type Query { + t: T + } + + type T { + x: X @external + y: Int @external + g: Int @requires(fields: "foo: y") + h: Int @requires(fields: "x { m: a n: b }") + } + + type X { + a: Int + b: Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [ + ( + "REQUIRES_INVALID_FIELDS", + r#"[S] On field "T.g", for @requires(fields: "foo: y"): Cannot use alias "foo" in "foo: y": aliases are not currently supported in @requires"#, + ), + ( + "REQUIRES_INVALID_FIELDS", + r#"[S] On field "T.h", for @requires(fields: "x { m: a n: b }"): Cannot use alias "m" in "m: a": aliases are not currently supported in @requires"#, + ), + // PORT NOTE: JS didn't include this last message, but we should report the other alias if we're making the effort to collect all the errors + ( + "REQUIRES_INVALID_FIELDS", + r#"[S] On field "T.h", for @requires(fields: "x { m: a n: b }"): Cannot use alias "n" in "n: b": aliases are not currently supported in @requires"#, + ), + ] + ); + } +} + +mod root_types { + use super::*; + + #[test] + fn rejects_using_query_as_type_name_if_not_the_query_root() { + let schema_str = r#" + schema { + query: MyQuery + } + + type MyQuery { + f: Int + } + + type Query { + g: Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "ROOT_QUERY_USED", + r#"[S] The schema has a type named "Query" but it is not set as the query root type ("MyQuery" is instead): this is not supported by federation. If a root type does not use its default name, there should be no other type with that default name."#, + )] + ); + } + + #[test] + fn rejects_using_mutation_as_type_name_if_not_the_mutation_root() { + let schema_str = r#" + schema { + mutation: MyMutation + } + + type MyMutation { + f: Int + } + + type Mutation { + g: Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "ROOT_MUTATION_USED", + r#"[S] The schema has a type named "Mutation" but it is not set as the mutation root type ("MyMutation" is instead): this is not supported by federation. If a root type does not use its default name, there should be no other type with that default name."#, + )] + ); + } + + #[test] + fn rejects_using_subscription_as_type_name_if_not_the_subscription_root() { + let schema_str = r#" + schema { + subscription: MySubscription + } + + type MySubscription { + f: Int + } + + type Subscription { + g: Int + } + "#; + let err = build_for_errors(schema_str); + + assert_errors!( + err, + [( + "ROOT_SUBSCRIPTION_USED", + r#"[S] The schema has a type named "Subscription" but it is not set as the subscription root type ("MySubscription" is instead): this is not supported by federation. If a root type does not use its default name, there should be no other type with that default name."#, + )] + ); + } +} + +mod custom_error_message_for_misnamed_directives { + use super::*; + + struct FedVersionSchemaParams { + build_option: BuildOption, + extra_msg: &'static str, + } + + #[test] + fn has_suggestions_if_a_federation_directive_is_misspelled_in_all_schema_versions() { + let schema_versions = [ + FedVersionSchemaParams { + // fed1 + build_option: BuildOption::AsIs, + extra_msg: " If so, note that it is a federation 2 directive but this schema is a federation 1 one. To be a federation 2 schema, it needs to @link to the federation specification v2.", + }, + FedVersionSchemaParams { + // fed2 + build_option: BuildOption::AsFed2, + extra_msg: "", + }, + ]; + for fed_ver in schema_versions { + let schema_str = r#" + type T @keys(fields: "id") { + id: Int @foo + foo: String @sharable + } + "#; + let err = build_for_errors_with_option(schema_str, fed_ver.build_option); + + assert_errors!( + err, + [ + ( + "INVALID_GRAPHQL", + r#"[S] Error: cannot find directive `@keys` in this document + ╭─[ S:2:28 ] + │ + 2 │ type T @keys(fields: "id") { + │ ─────────┬───────── + │ ╰─────────── directive not defined +───╯ +Did you mean "@key"?"#, + ), + ( + "INVALID_GRAPHQL", + r#"[S] Error: cannot find directive `@foo` in this document + ╭─[ S:3:33 ] + │ + 3 │ id: Int @foo + │ ──┬─ + │ ╰─── directive not defined +───╯"#, + ), + ( + "INVALID_GRAPHQL", + &format!( + r#"[S] Error: cannot find directive `@sharable` in this document + ╭─[ S:4:37 ] + │ + 4 │ foo: String @sharable + │ ────┬──── + │ ╰────── directive not defined +───╯ +Did you mean "@shareable"?{}"#, + fed_ver.extra_msg + ), + ), + ] + ); + } + } + + #[test] + fn has_suggestions_if_a_fed2_directive_is_used_in_fed1() { + let schema_str = r#" + type T @key(fields: "id") { + id: Int + foo: String @shareable + } + "#; + let err = build_for_errors_with_option(schema_str, BuildOption::AsIs); + + assert_errors!( + err, + [( + "INVALID_GRAPHQL", + r#"[S] Error: cannot find directive `@shareable` in this document + ╭─[ S:4:29 ] + │ + 4 │ foo: String @shareable + │ ─────┬──── + │ ╰────── directive not defined +───╯ + If you meant the "@shareable" federation 2 directive, note that this schema is a federation 1 schema. To be a federation 2 schema, it needs to @link to the federation specification v2."#, + )] + ); + } + + #[test] + fn has_suggestions_if_a_fed2_directive_is_used_under_wrong_name_for_the_schema() { + let schema_str = r#" + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.0" + import: [{ name: "@key", as: "@myKey" }] + ) + + type T @key(fields: "id") { + id: Int + foo: String @shareable + } + "#; + let err = build_for_errors_with_option(schema_str, BuildOption::AsIs); + + assert_errors!( + err, + [ + ( + "INVALID_GRAPHQL", + r#"[S] Error: cannot find directive `@key` in this document + ╭─[ S:8:20 ] + │ + 8 │ type T @key(fields: "id") { + │ ─────────┬──────── + │ ╰────────── directive not defined +───╯ + If you meant the "@key" federation directive, you should use "@myKey" as it is imported under that name in the @link to the federation specification of this schema."#, + ), + ( + "INVALID_GRAPHQL", + r#"[S] Error: cannot find directive `@shareable` in this document + ╭─[ S:10:29 ] + │ + 10 │ foo: String @shareable + │ ─────┬──── + │ ╰────── directive not defined +────╯ + If you meant the "@shareable" federation directive, you should use fully-qualified name "@federation__shareable" or add "@shareable" to the \`import\` argument of the @link to the federation specification."#, + ), + ] + ); + } +} + +// PORT_NOTE: Corresponds to '@core/@link handling' tests in JS +#[cfg(test)] +mod link_handling_tests { + use similar::TextDiff; + + use super::*; + + // There are a few whitespace differences between this and the JS version, but the more important difference is that + // the links are added as a new extension instead of being attached to the top-level schema definition. We may need + // to revisit that later if we're doing strict comparisons of SDLs between versions. + const EXPECTED_FULL_SCHEMA: &str = r#"schema { + query: Query +} + +extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @key(fields: federation__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + +directive @federation__requires(fields: federation__FieldSet!) on FIELD_DEFINITION + +directive @federation__provides(fields: federation__FieldSet!) on FIELD_DEFINITION + +directive @federation__external(reason: String) on OBJECT | FIELD_DEFINITION + +directive @federation__tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @federation__extends on OBJECT | INTERFACE + +directive @federation__shareable on OBJECT | FIELD_DEFINITION + +directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @federation__override(from: String!) on FIELD_DEFINITION + +type T @key(fields: "k") { + k: ID! +} + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +scalar link__Import + +scalar federation__FieldSet + +scalar _Any + +type _Service { + sdl: String +} + +union _Entity = T + +type Query { + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} +"#; + + #[test] + fn expands_everything_if_only_the_federation_spec_is_linked() { + let subgraph = build_and_validate( + r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) + + type T @key(fields: "k") { + k: ID! + } + "#, + ); + + assert_eq!( + subgraph.schema_string(), + EXPECTED_FULL_SCHEMA, + "{}", + TextDiff::from_lines(EXPECTED_FULL_SCHEMA, subgraph.schema_string().as_str()) + .unified_diff() + ); + } + + #[test] + fn expands_definitions_if_both_the_federation_spec_and_link_spec_are_linked() { + let subgraph = build_and_validate( + r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) + + type T @key(fields: "k") { + k: ID! + } + "#, + ); + + assert_eq!( + subgraph.schema_string(), + EXPECTED_FULL_SCHEMA, + "{}", + TextDiff::from_lines(EXPECTED_FULL_SCHEMA, subgraph.schema_string().as_str()) + .unified_diff() + ); + } + + #[test] + fn is_valid_if_a_schema_is_complete_from_the_get_go() { + let subgraph = build_and_validate(EXPECTED_FULL_SCHEMA); + assert_eq!( + subgraph.schema_string(), + EXPECTED_FULL_SCHEMA, + "{}", + TextDiff::from_lines(EXPECTED_FULL_SCHEMA, subgraph.schema_string().as_str()) + .unified_diff() + ); + } + + #[test] + fn expands_missing_definitions_when_some_are_partially_provided() { + let docs = [ + r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link( + url: "https://specs.apollo.dev/federation/v2.0" + import: ["@key"] + ) + + type T @key(fields: "k") { + k: ID! + } + + directive @key( + fields: federation__FieldSet! + resolvable: Boolean = true + ) repeatable on OBJECT | INTERFACE + + scalar federation__FieldSet + + scalar link__Import + "#, + r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link( + url: "https://specs.apollo.dev/federation/v2.0" + import: ["@key"] + ) + + type T @key(fields: "k") { + k: ID! + } + + scalar link__Import + "#, + r#" + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.0" + import: ["@key"] + ) + + type T @key(fields: "k") { + k: ID! + } + + scalar link__Import + "#, + r#" + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.0" + import: ["@key"] + ) + + type T @key(fields: "k") { + k: ID! + } + + directive @federation__external( + reason: String + ) on OBJECT | FIELD_DEFINITION + "#, + r#" + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0") + + type T { + k: ID! + } + + enum link__Purpose { + EXECUTION + SECURITY + } + "#, + ]; + + // Note that we cannot use `validateFullSchema` as-is for those examples because the order + // or directive is going to be different. But that's ok, we mostly care that the validation + // doesn't fail, so we can be somewhat sure that if something necessary wasn't expanded + // properly, we would have an issue. The main reason we did validate the full schema in + // prior tests is so we had at least one full example of a subgraph expansion in the tests. + docs.iter().for_each(|doc| { + _ = build_and_validate(doc); + }); + } + + #[test] + fn allows_directive_redefinition_without_optional_argument() { + // @key has a `resolvable` argument in its full definition, but it is optional. + let _ = build_and_validate( + r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link( + url: "https://specs.apollo.dev/federation/v2.0" + import: ["@key"] + ) + + type T @key(fields: "k") { + k: ID! + } + + directive @key( + fields: federation__FieldSet! + ) repeatable on OBJECT | INTERFACE + + scalar federation__FieldSet + "#, + ); + } + + #[test] + fn allows_directive_redefinition_with_subset_of_locations() { + // @inaccessible can be put in a bunch of locations, but you're welcome to restrict + // yourself to just fields. + let _ = build_and_validate( + r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link( + url: "https://specs.apollo.dev/federation/v2.0" + import: ["@inaccessible"] + ) + + type T { + k: ID! @inaccessible + } + + directive @inaccessible on FIELD_DEFINITION + "#, + ); + } + + #[test] + fn allows_directive_redefinition_without_repeatable() { + // @key is repeatable, but you're welcome to restrict yourself to never repeating it. + let _ = build_and_validate( + r#" + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.0" + import: ["@key"] + ) + + type T @key(fields: "k") { + k: ID! + } + + directive @key( + fields: federation__FieldSet! + resolvable: Boolean = true + ) on OBJECT | INTERFACE + + scalar federation__FieldSet + "#, + ); + } + + #[test] + fn allows_directive_redefinition_changing_optional_argument_to_required() { + let docs = [ + // @key `resolvable` argument is optional, but you're welcome to force users to always + // provide it. + r#" + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.0" + import: ["@key"] + ) + + type T @key(fields: "k", resolvable: true) { + k: ID! + } + + directive @key( + fields: federation__FieldSet! + resolvable: Boolean! + ) repeatable on OBJECT | INTERFACE + + scalar federation__FieldSet + "#, + // @link `url` argument is allowed to be `null` now, but it used not too, so making + // sure we still accept definition where it's mandatory. + r#" + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link( + url: "https://specs.apollo.dev/federation/v2.0" + import: ["@key"] + ) + + type T @key(fields: "k") { + k: ID! + } + + directive @link( + url: String! + as: String + for: link__Purpose + import: [link__Import] + ) repeatable on SCHEMA + + scalar link__Import + scalar link__Purpose + "#, + ]; + + // Like above, we really only care that the examples validate. + docs.iter().for_each(|doc| { + _ = build_and_validate(doc); + }); + } + + #[test] + fn errors_on_invalid_known_directive_location() { + // @external is not allowed on 'schema' and likely never will. + let doc = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) + + type T @key(fields: "k") { + k: ID! + } + + directive @federation__external( + reason: String + ) on OBJECT | FIELD_DEFINITION | SCHEMA + "#; + assert_errors!( + build_for_errors_with_option(doc, BuildOption::AsIs), + [( + "DIRECTIVE_DEFINITION_INVALID", + r#"[S] Invalid definition for directive "@federation__external": "@federation__external" should have locations OBJECT, FIELD_DEFINITION, but found (non-subset) OBJECT, FIELD_DEFINITION, SCHEMA"#, + )] + ); + } + + #[test] + fn errors_on_invalid_non_repeatable_directive_marked_repeatable() { + let doc = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0" import: ["@key"]) + + type T @key(fields: "k") { + k: ID! + } + + directive @federation__external repeatable on OBJECT | FIELD_DEFINITION + "#; + assert_errors!( + build_for_errors_with_option(doc, BuildOption::AsIs), + [( + "DIRECTIVE_DEFINITION_INVALID", + r#"[S] Invalid definition for directive "@federation__external": "@federation__external" should not be repeatable"#, + )] + ); + } + + #[test] + fn errors_on_unknown_argument_of_known_directive() { + let doc = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) + + type T @key(fields: "k") { + k: ID! + } + + directive @federation__external(foo: Int) on OBJECT | FIELD_DEFINITION + "#; + assert_errors!( + build_for_errors_with_option(doc, BuildOption::AsIs), + [( + "DIRECTIVE_DEFINITION_INVALID", + r#"[S] Invalid definition for directive "@federation__external": unknown/unsupported argument "foo""#, + )] + ); + } + + #[test] + fn errors_on_invalid_type_for_a_known_argument() { + let doc = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) + + type T @key(fields: "k") { + k: ID! + } + + directive @key( + fields: String! + resolvable: String + ) repeatable on OBJECT | INTERFACE + "#; + assert_errors!( + build_for_errors_with_option(doc, BuildOption::AsIs), + [( + "DIRECTIVE_DEFINITION_INVALID", + r#"[S] Invalid definition for directive "@key": argument "resolvable" should have type "Boolean" but found type "String""#, + )] + ); + } + + #[test] + fn errors_on_a_required_argument_defined_as_optional() { + let doc = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) + + type T @key(fields: "k") { + k: ID! + } + + directive @key( + fields: federation__FieldSet + resolvable: Boolean = true + ) repeatable on OBJECT | INTERFACE + + scalar federation__FieldSet + "#; + assert_errors!( + build_for_errors_with_option(doc, BuildOption::AsIs), + [( + "DIRECTIVE_DEFINITION_INVALID", + r#"[S] Invalid definition for directive "@key": argument "fields" should have type "federation__FieldSet!" but found type "federation__FieldSet""#, + )] + ); + } + + #[test] + fn errors_on_invalid_definition_for_link_purpose() { + let doc = r#" + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0") + + type T { + k: ID! + } + + enum link__Purpose { + EXECUTION + RANDOM + } + "#; + assert_errors!( + build_for_errors_with_option(doc, BuildOption::AsIs), + [( + "TYPE_DEFINITION_INVALID", + r#"[S] Invalid definition for type "Purpose": expected values [EXECUTION, SECURITY] but found [EXECUTION, RANDOM]."#, + )] + ); + } + + #[test] + fn allows_any_non_scalar_type_in_redefinition_when_expected_type_is_a_scalar() { + // Just making sure this doesn't error out. + build_and_validate( + r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) + + type T @key(fields: "k") { + k: ID! + } + + # 'fields' should be of type 'federation_FieldSet!', but ensure we allow 'String!' alternatively. + directive @key( + fields: String! + resolvable: Boolean = true + ) repeatable on OBJECT | INTERFACE + "#, + ); + } + + #[test] + fn allows_defining_a_repeatable_directive_as_non_repeatable_but_validates_usages() { + let doc = r#" + type T @key(fields: "k1") @key(fields: "k2") { + k1: ID! + k2: ID! + } + + directive @key(fields: String!) on OBJECT + "#; + + // Test for fed2 (with @key being @link-ed) + assert_errors!( + build_for_errors(doc), + [( + "INVALID_GRAPHQL", + r###" + [S] Error: non-repeatable directive key can only be used once per location + ╭─[ S:2:39 ] + │ + 2 │ type T @key(fields: "k1") @key(fields: "k2") { + │ ──┬─ ─────────┬──────── + │ ╰──────────────────────────────────── directive `@key` first called here + │ │ + │ ╰────────── directive `@key` called again here + ───╯ + "### + )] + ); + + // Test for fed1 + assert_errors!( + build_for_errors_with_option(doc, BuildOption::AsIs), + [( + "INVALID_GRAPHQL", + r###" + [S] Error: non-repeatable directive key can only be used once per location + ╭─[ S:2:39 ] + │ + 2 │ type T @key(fields: "k1") @key(fields: "k2") { + │ ──┬─ ─────────┬──────── + │ ╰──────────────────────────────────── directive `@key` first called here + │ │ + │ ╰────────── directive `@key` called again here + ───╯ + "### + )] + ); + } +} + +mod federation_1_schema_tests { + use super::*; + + #[test] + fn accepts_federation_directive_definitions_without_arguments() { + let doc = r#" + type Query { + a: Int + } + + directive @key on OBJECT | INTERFACE + directive @requires on FIELD_DEFINITION + "#; + build_and_validate(doc); + } + + #[test] + fn accepts_federation_directive_definitions_with_nullable_arguments() { + let doc = r#" + type Query { + a: Int + } + + type T @key(fields: "id") { + id: ID! @requires(fields: "x") + x: Int @external + } + + # Tests with the _FieldSet argument non-nullable + scalar _FieldSet + directive @key(fields: _FieldSet) on OBJECT | INTERFACE + + # Tests with the argument as String and non-nullable + directive @requires(fields: String) on FIELD_DEFINITION + "#; + build_and_validate(doc); + } + + #[test] + fn accepts_federation_directive_definitions_with_fieldset_type_instead_of_underscore_fieldset() + { + // accepts federation directive definitions with "FieldSet" type instead of "_FieldSet" + let doc = r#" + type Query { + a: Int + } + + type T @key(fields: "id") { + id: ID! + } + + scalar FieldSet + directive @key(fields: FieldSet) on OBJECT | INTERFACE + "#; + build_and_validate(doc); + } + + #[test] + fn rejects_federation_directive_definition_with_unknown_arguments() { + let doc = r#" + type Query { + a: Int + } + + type T @key(fields: "id", unknown: 42) { + id: ID! + } + + scalar _FieldSet + directive @key(fields: _FieldSet!, unknown: Int) on OBJECT | INTERFACE + "#; + assert_errors!( + build_for_errors_with_option(doc, BuildOption::AsIs), + [( + "DIRECTIVE_DEFINITION_INVALID", + r#"[S] Invalid definition for directive "@key": unknown/unsupported argument "unknown""# + )] + ); + } +} + +mod shareable_tests { + use apollo_federation::subgraph::test_utils::build_inner; + + use super::*; + + #[test] + fn can_only_be_applied_to_fields_of_object_types() { + let doc = r#" + interface I { + a: Int @shareable + } + "#; + assert_errors!( + build_for_errors(doc), + [( + "INVALID_SHAREABLE_USAGE", + r#"[S] Invalid use of @shareable on field "I.a": only object type fields can be marked with @shareable"# + )] + ); + } + + #[test] + fn rejects_duplicate_shareable_on_the_same_definition_declaration() { + let doc = r#" + type E @shareable @key(fields: "id") @shareable { + id: ID! + a: Int + } + "#; + assert_errors!( + build_for_errors(doc), + [( + "INVALID_SHAREABLE_USAGE", + r#"[S] Invalid duplicate application of @shareable on the same type declaration of "E": @shareable is only repeatable on types so it can be used simultaneously on a type definition and its extensions, but it should not be duplicated on the same definition/extension declaration"# + )] + ); + } + + #[test] + fn rejects_duplicate_shareable_on_the_same_extension_declaration() { + let doc = r#" + type E @shareable { + id: ID! + a: Int + } + + extend type E @shareable @shareable { + b: Int + } + "#; + assert_errors!( + build_for_errors(doc), + [( + "INVALID_SHAREABLE_USAGE", + r#"[S] Invalid duplicate application of @shareable on the same type declaration of "E": @shareable is only repeatable on types so it can be used simultaneously on a type definition and its extensions, but it should not be duplicated on the same definition/extension declaration"# + )] + ); + } + + #[test] + fn rejects_duplicate_shareable_on_a_field() { + let doc = r#" + type E { + a: Int @shareable @shareable + } + "#; + assert_errors!( + build_for_errors(doc), + [( + "INVALID_SHAREABLE_USAGE", + r#"[S] Invalid duplicate application of @shareable on field "E.a": @shareable is only repeatable on types so it can be used simultaneously on a type definition and its extensions, but it should not be duplicated on the same definition/extension declaration"# + )] + ); + } + + #[test] + fn allows_shareable_on_declaration_and_extension_of_same_type() { + let doc = r#" + type E @shareable { + id: ID! + a: Int + } + + extend type E @shareable { + b: Int + } + "#; + assert!(build_inner(doc, BuildOption::AsFed2).is_ok()); + } +} + +mod interface_object_and_key_on_interfaces_validation_tests { + use super::*; + + #[test] + fn key_on_interfaces_require_key_on_all_implementations() { + let doc = r#" + interface I @key(fields: "id1") @key(fields: "id2") { + id1: ID! + id2: ID! + } + + type A implements I @key(fields: "id2") { + id1: ID! + id2: ID! + a: Int + } + + type B implements I @key(fields: "id1") @key(fields: "id2") { + id1: ID! + id2: ID! + b: Int + } + + type C implements I @key(fields: "id2") { + id1: ID! + id2: ID! + c: Int + } + "#; + assert_errors!( + build_for_errors(doc), + [( + "INTERFACE_KEY_NOT_ON_IMPLEMENTATION", + r#"[S] Key @key(fields: "id1") on interface type "I" is missing on implementation types "A" and "C"."# + )] + ); + } + + #[test] + fn key_on_interfaces_with_key_on_some_implementation_non_resolvable() { + let doc = r#" + interface I @key(fields: "id1") { + id1: ID! + } + + type A implements I @key(fields: "id1") { + id1: ID! + a: Int + } + + type B implements I @key(fields: "id1") { + id1: ID! + b: Int + } + + type C implements I @key(fields: "id1", resolvable: false) { + id1: ID! + c: Int + } + "#; + + assert_errors!( + build_for_errors(doc), + [( + "INTERFACE_KEY_NOT_ON_IMPLEMENTATION", + r#"[S] Key @key(fields: "id1") on interface type "I" should be resolvable on all implementation types, but is declared with argument "@key(resolvable:)" set to false in type "C"."# + )] + ); + } + + #[test] + fn ensures_order_of_fields_in_key_does_not_matter() { + let doc = r#" + interface I @key(fields: "a b c") { + a: Int + b: Int + c: Int + } + + type A implements I @key(fields: "c b a") { + a: Int + b: Int + c: Int + } + + type B implements I @key(fields: "a c b") { + a: Int + b: Int + c: Int + } + + type C implements I @key(fields: "a b c") { + a: Int + b: Int + c: Int + } + "#; + + // Ensure no errors are returned + build_and_validate(doc); + } + + #[test] + fn only_allow_interface_object_on_entity_types() { + // There is no meaningful way to make @interfaceObject work on a value type at the moment, + // because if you have an @interfaceObject, some other subgraph needs to be able to resolve + // the concrete type, and that imply that you have key to go to that other subgraph. To be + // clear, the @key on the @interfaceObject technically don't need to be "resolvable", and + // the difference between no key and a non-resolvable key is arguably more of a convention + // than a genuine mechanical difference at the moment, but still a good idea to rely on + // that convention to help catching obvious mistakes early. + let doc = r#" + # This one shouldn't raise an error + type A @key(fields: "id", resolvable: false) @interfaceObject { + id: ID! + } + + # This one should + type B @interfaceObject { + x: Int + } + "#; + assert_errors!( + build_for_errors(doc), + [( + "INTERFACE_OBJECT_USAGE_ERROR", + r#"[S] The @interfaceObject directive can only be applied to entity types but type "B" has no @key in this subgraph."# + )] + ); + } +} + +mod cost_tests { + use super::*; + + #[test] + fn rejects_cost_applications_on_interfaces() { + let doc = r#" + type Query { + a: A + } + + interface A { + x: Int @cost(weight: 10) + } + "#; + + assert_errors!( + build_for_errors(doc), + [( + "COST_APPLIED_TO_INTERFACE_FIELD", + r#"[S] @cost cannot be applied to interface "A.x""# + )] + ); + } +} + +mod list_size_tests { + use super::*; + + #[test] + fn rejects_applications_on_non_lists_unless_it_uses_sized_fields() { + let doc = r#" + type Query { + a1: A @listSize(assumedSize: 5) + a2: A @listSize(assumedSize: 10, sizedFields: ["ints"]) + } + + type A { + ints: [Int] + } + "#; + + assert_errors!( + build_for_errors(doc), + [( + "LIST_SIZE_APPLIED_TO_NON_LIST", + r#"[S] "Query.a1" is not a list"# + )] + ); + } + + #[test] + fn rejects_negative_assumed_size() { + let doc = r#" + type Query { + a: [Int] @listSize(assumedSize: -5) + b: [Int] @listSize(assumedSize: 0) + } + "#; + + assert_errors!( + build_for_errors(doc), + [( + "LIST_SIZE_INVALID_ASSUMED_SIZE", + r#"[S] Assumed size of "Query.a" cannot be negative"# + )] + ); + } + + #[test] + fn rejects_slicing_arguments_not_in_field_arguments() { + let doc = r#" + type Query { + myField(something: Int): [String] + @listSize(slicingArguments: ["missing1", "missing2"]) + myOtherField(somethingElse: String): [Int] + @listSize(slicingArguments: ["alsoMissing"]) + } + "#; + + assert_errors!( + build_for_errors(doc), + [ + ( + "LIST_SIZE_INVALID_SLICING_ARGUMENT", + r#"[S] Slicing argument "missing1" is not an argument of "Query.myField""# + ), + ( + "LIST_SIZE_INVALID_SLICING_ARGUMENT", + r#"[S] Slicing argument "missing2" is not an argument of "Query.myField""# + ), + ( + "LIST_SIZE_INVALID_SLICING_ARGUMENT", + r#"[S] Slicing argument "alsoMissing" is not an argument of "Query.myOtherField""# + ) + ] + ); + } + + #[test] + fn rejects_slicing_arguments_not_int_or_int_non_null() { + let doc = r#" + type Query { + sliced( + first: String + second: Int + third: Int! + fourth: [Int] + fifth: [Int]! + ): [String] + @listSize( + slicingArguments: ["first", "second", "third", "fourth", "fifth"] + ) + } + "#; + + assert_errors!( + build_for_errors(doc), + [ + ( + "LIST_SIZE_INVALID_SLICING_ARGUMENT", + r#"[S] Slicing argument "Query.sliced(first:)" must be Int or Int!"# + ), + ( + "LIST_SIZE_INVALID_SLICING_ARGUMENT", + r#"[S] Slicing argument "Query.sliced(fourth:)" must be Int or Int!"# + ), + ( + "LIST_SIZE_INVALID_SLICING_ARGUMENT", + r#"[S] Slicing argument "Query.sliced(fifth:)" must be Int or Int!"# + ) + ] + ); + } + + #[test] + fn rejects_sized_fields_when_output_type_is_not_object() { + let doc = r#" + type Query { + notObject: Int @listSize(assumedSize: 1, sizedFields: ["anything"]) + a: A @listSize(assumedSize: 5, sizedFields: ["ints"]) + b: B @listSize(assumedSize: 10, sizedFields: ["ints"]) + } + + type A { + ints: [Int] + } + + interface B { + ints: [Int] + } + "#; + + assert_errors!( + build_for_errors(doc), + [( + "LIST_SIZE_INVALID_SIZED_FIELD", + r#"[S] Sized fields cannot be used because "Int" is not a composite type"# + )] + ); + } + + #[test] + fn rejects_sized_fields_not_in_output_type() { + let doc = r#" + type Query { + a: A @listSize(assumedSize: 5, sizedFields: ["notOnA"]) + } + + type A { + ints: [Int] + } + "#; + + assert_errors!( + build_for_errors(doc), + [( + "LIST_SIZE_INVALID_SIZED_FIELD", + r#"[S] Sized field "notOnA" is not a field on type "A""# + )] + ); + } + + #[test] + fn rejects_sized_fields_not_lists() { + let doc = r#" + type Query { + a: A + @listSize( + assumedSize: 5 + sizedFields: ["list", "nonNullList", "notList"] + ) + } + + type A { + list: [String] + nonNullList: [String]! + notList: String + } + "#; + + assert_errors!( + build_for_errors(doc), + [( + "LIST_SIZE_APPLIED_TO_NON_LIST", + r#"[S] Sized field "A.notList" is not a list"# + )] + ); + } +} + +mod tag_tests { + use super::*; + + #[test] + fn errors_on_tag_missing_required_argument() { + let doc = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag"]) + + directive @tag on FIELD_DEFINITION + "#; + assert_errors!( + build_for_errors_with_option(doc, BuildOption::AsIs), + [( + "DIRECTIVE_DEFINITION_INVALID", + r#"[S] Invalid definition for directive "@tag": Missing required argument "name""# + )] + ); + } + + #[test] + fn errors_on_tag_with_unknown_argument() { + let doc = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag"]) + + directive @tag(name: String!, foo: Int) repeatable on FIELD_DEFINITION | OBJECT + "#; + assert_errors!( + build_for_errors_with_option(doc, BuildOption::AsIs), + [( + "DIRECTIVE_DEFINITION_INVALID", + r#"[S] Invalid definition for directive "@tag": unknown/unsupported argument "foo""# + )] + ); + } + + #[test] + fn errors_on_tag_with_wrong_argument_type() { + let doc = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag"]) + + directive @tag(name: Int!) repeatable on FIELD_DEFINITION | OBJECT + "#; + assert_errors!( + build_for_errors_with_option(doc, BuildOption::AsIs), + [( + "DIRECTIVE_DEFINITION_INVALID", + r#"[S] Invalid definition for directive "@tag": argument "name" should have type "String!" but found type "Int!""# + )] + ); + } + + #[test] + fn errors_on_tag_with_wrong_locations() { + let doc = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag"]) + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | SCHEMA + "#; + assert_errors!( + build_for_errors_with_option(doc, BuildOption::AsIs), + [( + "DIRECTIVE_DEFINITION_INVALID", + r#"[S] Invalid definition for directive "@tag": "@tag" should have locations FIELD_DEFINITION, OBJECT, INTERFACE, UNION, ARGUMENT_DEFINITION, SCALAR, ENUM, ENUM_VALUE, INPUT_OBJECT, INPUT_FIELD_DEFINITION, but found (non-subset) FIELD_DEFINITION, OBJECT, SCHEMA"# + )] + ); + } + + #[test] + fn allows_tag_with_valid_subset_of_locations() { + let doc = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag"]) + + type T @tag(name: "foo") { x: Int } + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE + "#; + let _ = build_and_validate(doc); + } + + #[test] + fn errors_on_invalid_symbols_in_tag_name() { + let disallowed_symbols = [ + ' ', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '{', '}', '[', ']', '|', ';', + ':', '+', '=', '.', ',', '?', '`', '~', + ]; + for symbol in disallowed_symbols { + let doc = include_str!("fixtures/tag_validation_template.graphqls") + .replace("{symbol}", &symbol.to_string()); + let err = build_for_errors_with_option(&doc, BuildOption::AsIs); + + assert_errors!( + err, + [ + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema root has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element Foo has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element Foo.foo1 has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element Foo.foo2(arg:) has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element Bar has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element Query.foo has invalid @tag directive value '{symbol}test' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element Query.bar has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element Query.baz has invalid @tag directive value 'test{symbol}test' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element Bar.bar1 has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element Bar.bar2(arg:) has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element Baz has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element CustomScalar has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element TestEnum has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element TestEnum.VALUE1 has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element TestInput has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element TestInput.inputField1 has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element TestInput.inputField3 has invalid @tag directive value '{symbol}test' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element @custom(arg:) has invalid @tag directive value 'test{symbol}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ] + ); + } + } + + #[test] + fn errors_on_invalid_symbols_at_start_of_tag_name() { + let disallowed_symbols = ['-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; + for symbol in disallowed_symbols { + let doc = include_str!("fixtures/tag_validation_template.graphqls") + .replace("{symbol}", &symbol.to_string()); + let err = build_for_errors_with_option(&doc, BuildOption::AsIs); + + assert_errors!( + err, + [ + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element Query.foo has invalid @tag directive value '{symbol}test' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element TestInput.inputField3 has invalid @tag directive value '{symbol}test' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + ), + ] + ); + } + } + + #[test] + fn allows_valid_symbols_in_tag_name() { + let allowed_symbols = [ + '_', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', + 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + ]; + for symbol in allowed_symbols { + let doc = include_str!("fixtures/tag_validation_template.graphqls") + .replace("{symbol}", &symbol.to_string()); + // Build should succeed without errors + let _ = build_and_validate(&doc); + } + } + + #[test] + fn errors_when_tag_name_exceeds_length_limit() { + // 128 chars is valid + let valid = "a".repeat(128); + let doc_valid = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag"]) + + type T @tag(name: "VALID_TAG") { x: Int } + "# + .replace("VALID_TAG", &valid); + // Build should succeed without errors + let _ = build_and_validate(&doc_valid); + + // 129 chars is invalid + let invalid = "a".repeat(129); + let doc_invalid = r#" + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag"]) + + type T @tag(name: "INVALID_TAG") { x: Int } + "# + .replace("INVALID_TAG", &invalid); + let err = build_for_errors_with_option(&doc_invalid, BuildOption::AsIs); + assert_errors!( + err, + [( + "INVALID_TAG_NAME", + &format!( + "[S] Schema element T has invalid @tag directive value '{invalid}' for argument \"name\". Values must start with an alphanumeric character or underscore and contain only slashes, hyphens, or underscores." + ) + )] + ); + } +} diff --git a/apollo-router-benchmarks/Cargo.toml b/apollo-router-benchmarks/Cargo.toml index d26748c696..54a4a8de28 100644 --- a/apollo-router-benchmarks/Cargo.toml +++ b/apollo-router-benchmarks/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "apollo-router-benchmarks" -version = "1.57.1" +version = "2.6.0" authors = ["Apollo Graph, Inc. "] edition = "2021" license = "Elastic-2.0" @@ -8,7 +8,7 @@ publish = false [dev-dependencies] apollo-router = { path = "../apollo-router" } -criterion = { version = "0.5", features = ["async_tokio", "async_futures"] } +criterion = { version = "0.7", features = ["async_tokio", "async_futures"] } memory-stats = "1.1.0" once_cell.workspace = true serde_json.workspace = true diff --git a/apollo-router-benchmarks/src/shared.rs b/apollo-router-benchmarks/src/shared.rs index e0576c603e..a1a729feb7 100644 --- a/apollo-router-benchmarks/src/shared.rs +++ b/apollo-router-benchmarks/src/shared.rs @@ -76,8 +76,7 @@ pub fn setup() -> TestHarness<'static> { }}).build(); let review_service = MockSubgraph::builder().with_json(json!{{ - "query": "query TopProducts__reviews__1($representations:[_Any!]!){_entities(representations:$representations){..._generated_onProduct1_0}}fragment _generated_onProduct1_0 on Product{reviews{id product{__typename upc}author{__typename id}}}", - "operationName": "TopProducts__reviews__1", + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviews { id product { __typename upc } author { __typename id } } } } }", "variables": { "representations":[ { diff --git a/apollo-router-scaffold/Cargo.toml b/apollo-router-scaffold/Cargo.toml deleted file mode 100644 index 63bf887d7f..0000000000 --- a/apollo-router-scaffold/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "apollo-router-scaffold" -version = "1.57.1" -authors = ["Apollo Graph, Inc. "] -edition = "2021" -license = "Elastic-2.0" -publish = false - -[package.metadata.cargo-machete] -ignored = [ - # usage not found because the crate name is `inflector` without `str_` - "str_inflector", -] - -[dependencies] -anyhow = "1.0.80" -clap = { version = "4.5.1", features = ["derive"] } -cargo-scaffold = { version = "0.14.0", default-features = false } -regex = "1" -str_inflector = "0.12.0" -toml = "0.8.10" -[dev-dependencies] -tempfile = "3.10.0" -copy_dir = "0.1.3" -dircmp = "0.2.0" -similar = "2.5.0" diff --git a/apollo-router-scaffold/scaffold-test/.cargo/config b/apollo-router-scaffold/scaffold-test/.cargo/config deleted file mode 100644 index 24a9882b48..0000000000 --- a/apollo-router-scaffold/scaffold-test/.cargo/config +++ /dev/null @@ -1,3 +0,0 @@ -[alias] -xtask = "run --package xtask --" -router = "run --package xtask -- router" diff --git a/apollo-router-scaffold/scaffold-test/.dockerignore b/apollo-router-scaffold/scaffold-test/.dockerignore deleted file mode 100644 index c2c4a5aa95..0000000000 --- a/apollo-router-scaffold/scaffold-test/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -target/** \ No newline at end of file diff --git a/apollo-router-scaffold/scaffold-test/.gitignore b/apollo-router-scaffold/scaffold-test/.gitignore deleted file mode 100644 index bba7b53950..0000000000 --- a/apollo-router-scaffold/scaffold-test/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target/ -/.idea/ diff --git a/apollo-router-scaffold/scaffold-test/Cargo.toml b/apollo-router-scaffold/scaffold-test/Cargo.toml deleted file mode 100644 index 692dc3d08c..0000000000 --- a/apollo-router-scaffold/scaffold-test/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -[package] -name = "apollo-router-scaffold-test" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "apollo-router-scaffold-test" -path = "src/main.rs" - -[dependencies] -anyhow = "1.0.58" -apollo-router = { path = "../../apollo-router" } -async-trait = "0.1.52" -schemars = "0.8.10" -serde = "1.0.149" -serde_json = "1.0.79" -tokio = { version = "1.17.0", features = ["full"] } -tower = { version = "0.4.0", features = ["full"] } -tracing = "0.1.37" - -# this makes build scripts and proc macros faster to compile -[profile.dev.build-override] -strip = "debuginfo" -incremental = false diff --git a/apollo-router-scaffold/scaffold-test/Dockerfile b/apollo-router-scaffold/scaffold-test/Dockerfile deleted file mode 100644 index 28e91cc9a6..0000000000 --- a/apollo-router-scaffold/scaffold-test/Dockerfile +++ /dev/null @@ -1,54 +0,0 @@ -# Use the rust build image from docker as our base -# renovate-automation: rustc version -FROM rust:1.76.0 as build - -# Set our working directory for the build -WORKDIR /usr/src/router - -# Update our build image and install required packages -RUN apt-get update -RUN apt-get -y install \ - npm \ - protobuf-compiler - -# Add rustfmt since build requires it -RUN rustup component add rustfmt - -# Copy the router source to our build environment -COPY . . - -# Build and install the custom binary -RUN cargo build --release - -# Make directories for config and schema -RUN mkdir -p /dist/config && \ - mkdir /dist/schema && \ - mv target/release/router /dist - -# Copy configuration for docker image -COPY router.yaml /dist/config.yaml - -FROM debian:bullseye-slim - -RUN apt-get update -RUN apt-get -y install \ - ca-certificates - -# Set labels for our image -LABEL org.opencontainers.image.authors="Apollo Graph, Inc. https://github.com/apollographql/router" -LABEL org.opencontainers.image.source="https://github.com/apollographql/router" - -# Copy in the required files from our build image -COPY --from=build --chown=root:root /dist /dist - -WORKDIR /dist - -ENV APOLLO_ROUTER_CONFIG_PATH="/dist/config.yaml" - -# Make sure we can run the router -RUN chmod 755 /dist/router - -USER router - -# Default executable is the router -ENTRYPOINT ["/dist/router"] diff --git a/apollo-router-scaffold/scaffold-test/README.md b/apollo-router-scaffold/scaffold-test/README.md deleted file mode 100644 index 98ec70eb24..0000000000 --- a/apollo-router-scaffold/scaffold-test/README.md +++ /dev/null @@ -1,120 +0,0 @@ -# Apollo Router project - -This generated project is set up to create a custom Apollo Router binary that may include plugins that you have written. - -> Note: The Apollo Router is made available under the Elastic License v2.0 (ELv2). -> Read [our licensing page](https://www.apollographql.com/docs/resources/elastic-license-v2-faq/) for more details. - -# Compile the router - -To create a debug build use the following command. -```bash -cargo build -``` -Your debug binary is now located in `target/debug/router` - -For production, you will want to create a release build. -```bash -cargo build --release -``` -Your release binary is now located in `target/release/router` - -# Run the Apollo Router - -1. Download the example schema - - ```bash - curl -sSL https://supergraph.demo.starstuff.dev/ > supergraph-schema.graphql - ``` - -2. Run the Apollo Router - - During development it is convenient to use `cargo run` to run the Apollo Router as it will - ```bash - cargo run -- --hot-reload --config router.yaml --supergraph supergraph-schema.graphql - ``` - -> If you are using managed federation you can set APOLLO_KEY and APOLLO_GRAPH_REF environment variables instead of specifying the supergraph as a file. - -# Create a plugin - -1. From within your project directory scaffold a new plugin - ```bash - cargo router plugin create hello_world - ``` -2. Select the type of plugin you want to scaffold: - ```bash - Select a plugin template: - > "basic" - "auth" - "tracing" - ``` - - The different templates are: - * basic - a barebones plugin. - * auth - a basic authentication plugin that could make an external call. - * tracing - a plugin that adds a custom span and a log message. - - Choose `basic`. - -4. Add the plugin to the `router.yaml` - ```yaml - plugins: - starstuff.hello_world: - message: "Starting my plugin" - ``` - -5. Run the Apollo Router and see your plugin start up - ```bash - cargo run -- --hot-reload --config router.yaml --supergraph supergraph-schema.graphql - ``` - - In your output you should see something like: - ```bash - 2022-05-21T09:16:33.160288Z INFO router::plugins::hello_world: Starting my plugin - ``` - -# Remove a plugin - -1. From within your project run the following command. It makes a best effort to remove the plugin, but your mileage may vary. - ```bash - cargo router plugin remove hello_world - ``` - -# Docker - -You can use the provided Dockerfile to build a release container. - -Make sure your router is configured to listen to `0.0.0.0` so you can query it from outside the container: - -```yml - supergraph: - listen: 0.0.0.0:4000 -``` - -Use your `APOLLO_KEY` and `APOLLO_GRAPH_REF` environment variables to run the router in managed federation. - - ```bash - docker build -t my_custom_router . - docker run -e APOLLO_KEY="your apollo key" -e APOLLO_GRAPH_REF="your apollo graph ref" -p 4000:4000 my_custom_router - ``` - -Otherwise add a `COPY` step to the Dockerfile, and edit the entrypoint: - -```Dockerfile -# Copy configuration for docker image -COPY router.yaml /dist/config.yaml -# Copy supergraph for docker image -COPY my_supergraph.graphql /dist/supergraph.graphql - -# [...] and change the entrypoint - -# Default executable is the router -ENTRYPOINT ["/dist/router", "-s", "/dist/supergraph.graphql"] -``` - -You can now build and run your custom router: - ```bash - docker build -t my_custom_router . - docker run -p 4000:4000 my_custom_router - ``` diff --git a/apollo-router-scaffold/scaffold-test/router.yaml b/apollo-router-scaffold/scaffold-test/router.yaml deleted file mode 100644 index 8411f80a8b..0000000000 --- a/apollo-router-scaffold/scaffold-test/router.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# uncomment this section if you plan to use the Dockerfile -# supergraph: -# listen: 0.0.0.0:4000 -plugins: - # Add plugin configuration here diff --git a/apollo-router-scaffold/scaffold-test/src/main.rs b/apollo-router-scaffold/scaffold-test/src/main.rs deleted file mode 100644 index ca6699afe9..0000000000 --- a/apollo-router-scaffold/scaffold-test/src/main.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod plugins; - -use anyhow::Result; - -fn main() -> Result<()> { - apollo_router::main() -} diff --git a/apollo-router-scaffold/scaffold-test/src/plugins/auth.rs b/apollo-router-scaffold/scaffold-test/src/plugins/auth.rs deleted file mode 100644 index b0da3b45c7..0000000000 --- a/apollo-router-scaffold/scaffold-test/src/plugins/auth.rs +++ /dev/null @@ -1,99 +0,0 @@ -use std::ops::ControlFlow; - -use apollo_router::layers::ServiceBuilderExt; -use apollo_router::plugin::Plugin; -use apollo_router::plugin::PluginInit; -use apollo_router::register_plugin; -use apollo_router::services::supergraph; -use schemars::JsonSchema; -use serde::Deserialize; -use tower::BoxError; -use tower::ServiceBuilder; -use tower::ServiceExt; - -#[derive(Debug)] -struct Auth { - #[allow(dead_code)] - configuration: Conf, -} - -#[derive(Debug, Default, Deserialize, JsonSchema)] -struct Conf { - // Put your plugin configuration here. It will automatically be deserialized from JSON. - // Always put some sort of config here, even if it is just a bool to say that the plugin is enabled, - // otherwise the yaml to enable the plugin will be confusing. - message: String, -} -// This plugin is a skeleton for doing authentication that requires a remote call. -#[async_trait::async_trait] -impl Plugin for Auth { - type Config = Conf; - - async fn new(init: PluginInit) -> Result { - tracing::info!("{}", init.config.message); - Ok(Auth { - configuration: init.config, - }) - } - - fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { - ServiceBuilder::new() - .oneshot_checkpoint_async(|request: supergraph::Request| async { - // Do some async call here to auth, and decide if to continue or not. - Ok(ControlFlow::Continue(request)) - }) - .service(service) - .boxed() - } -} - -// This macro allows us to use it in our plugin registry! -// register_plugin takes a group name, and a plugin name. -register_plugin!("acme", "auth", Auth); - -#[cfg(test)] -mod tests { - use apollo_router::graphql; - use apollo_router::services::supergraph; - use apollo_router::TestHarness; - use tower::BoxError; - use tower::ServiceExt; - - #[tokio::test] - async fn basic_test() -> Result<(), BoxError> { - let test_harness = TestHarness::builder() - .configuration_json(serde_json::json!({ - "plugins": { - "acme.auth": { - "message" : "Starting my plugin" - } - } - })) - .unwrap() - .build_router() - .await - .unwrap(); - let request = supergraph::Request::canned_builder().build().unwrap(); - let mut streamed_response = test_harness.oneshot(request.try_into()?).await?; - - let first_response: graphql::Response = serde_json::from_slice( - streamed_response - .next_response() - .await - .expect("couldn't get primary response")? - .to_vec() - .as_slice(), - ) - .unwrap(); - - assert!(first_response.data.is_some()); - - println!("first response: {:?}", first_response); - let next = streamed_response.next_response().await; - println!("next response: {:?}", next); - - // You could keep calling .next_response() until it yields None if you're expexting more parts. - assert!(next.is_none()); - Ok(()) - } -} diff --git a/apollo-router-scaffold/scaffold-test/src/plugins/basic.rs b/apollo-router-scaffold/scaffold-test/src/plugins/basic.rs deleted file mode 100644 index 4f83a15401..0000000000 --- a/apollo-router-scaffold/scaffold-test/src/plugins/basic.rs +++ /dev/null @@ -1,123 +0,0 @@ -use apollo_router::plugin::Plugin; -use apollo_router::plugin::PluginInit; -use apollo_router::register_plugin; -use apollo_router::services::execution; -use apollo_router::services::router; -use apollo_router::services::subgraph; -use apollo_router::services::supergraph; -use schemars::JsonSchema; -use serde::Deserialize; -use tower::BoxError; - -#[derive(Debug)] -struct Basic { - #[allow(dead_code)] - configuration: Conf, -} - -#[derive(Debug, Default, Deserialize, JsonSchema)] -struct Conf { - // Put your plugin configuration here. It will automatically be deserialized from JSON. - // Always put some sort of config here, even if it is just a bool to say that the plugin is enabled, - // otherwise the yaml to enable the plugin will be confusing. - message: String, -} -// This is a bare bones plugin that can be duplicated when creating your own. -#[async_trait::async_trait] -impl Plugin for Basic { - type Config = Conf; - - async fn new(init: PluginInit) -> Result { - tracing::info!("{}", init.config.message); - Ok(Basic { - configuration: init.config, - }) - } - - // Delete this function if you are not customizing it. - fn router_service(&self, service: router::BoxService) -> router::BoxService { - // Always use service builder to compose your plugins. - // It provides off the shelf building blocks for your plugin. - // - // ServiceBuilder::new() - // .service(service) - // .boxed() - - // Returning the original service means that we didn't add any extra functionality at this point in the lifecycle. - service - } - - // Delete this function if you are not customizing it. - fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { - // Always use service builder to compose your plugins. - // It provides off the shelf building blocks for your plugin. - // - // ServiceBuilder::new() - // .service(service) - // .boxed() - - // Returning the original service means that we didn't add any extra functionality for at this point in the lifecycle. - service - } - - // Delete this function if you are not customizing it. - fn execution_service(&self, service: execution::BoxService) -> execution::BoxService { - service - } - - // Delete this function if you are not customizing it. - fn subgraph_service(&self, _name: &str, service: subgraph::BoxService) -> subgraph::BoxService { - service - } -} - -// This macro allows us to use it in our plugin registry! -// register_plugin takes a group name, and a plugin name. -register_plugin!("acme", "basic", Basic); - -#[cfg(test)] -mod tests { - use apollo_router::graphql; - use apollo_router::services::supergraph; - use apollo_router::TestHarness; - use tower::BoxError; - use tower::ServiceExt; - - #[tokio::test] - async fn basic_test() -> Result<(), BoxError> { - let test_harness = TestHarness::builder() - .configuration_json(serde_json::json!({ - "plugins": { - "acme.basic": { - "message" : "Starting my plugin" - } - } - })) - .unwrap() - .build_router() - .await - .unwrap(); - let request = supergraph::Request::canned_builder().build().unwrap(); - let mut streamed_response = test_harness.oneshot(request.try_into()?).await?; - - let first_response: graphql::Response = serde_json::from_slice( - streamed_response - .next_response() - .await - .expect("couldn't get primary response")? - .to_vec() - .as_slice(), - ) - .unwrap(); - - assert!(first_response.data.is_some()); - - println!("first response: {:?}", first_response); - let next = streamed_response.next_response().await; - println!("next response: {:?}", next); - - // You could keep calling .next_response() until it yields None if you're expexting more parts. - assert!(next.is_none()); - Ok(()) - } -} diff --git a/apollo-router-scaffold/scaffold-test/src/plugins/mod.rs b/apollo-router-scaffold/scaffold-test/src/plugins/mod.rs deleted file mode 100644 index 738d9a3f7e..0000000000 --- a/apollo-router-scaffold/scaffold-test/src/plugins/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod auth; -mod basic; -mod tracing; diff --git a/apollo-router-scaffold/scaffold-test/src/plugins/tracing.rs b/apollo-router-scaffold/scaffold-test/src/plugins/tracing.rs deleted file mode 100644 index e62ba30df3..0000000000 --- a/apollo-router-scaffold/scaffold-test/src/plugins/tracing.rs +++ /dev/null @@ -1,103 +0,0 @@ -use apollo_router::layers::ServiceBuilderExt; -use apollo_router::plugin::Plugin; -use apollo_router::plugin::PluginInit; -use apollo_router::register_plugin; -use apollo_router::services::supergraph; -use schemars::JsonSchema; -use serde::Deserialize; -use tower::BoxError; -use tower::ServiceBuilder; -use tower::ServiceExt; - -#[derive(Debug)] -struct Tracing { - #[allow(dead_code)] - configuration: Conf, -} - -#[derive(Debug, Default, Deserialize, JsonSchema)] -struct Conf { - // Put your plugin configuration here. It will automatically be deserialized from JSON. - // Always put some sort of config here, even if it is just a bool to say that the plugin is enabled, - // otherwise the yaml to enable the plugin will be confusing. - message: String, -} -// This plugin adds a span and an error to the logs. -#[async_trait::async_trait] -impl Plugin for Tracing { - type Config = Conf; - - async fn new(init: PluginInit) -> Result { - tracing::info!("{}", init.config.message); - Ok(Tracing { - configuration: init.config, - }) - } - - fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { - ServiceBuilder::new() - .instrument(|_request| { - // Optionally take information from the request and insert it into the span as attributes - // See https://docs.rs/tracing/latest/tracing/ for more information - tracing::info_span!("my_custom_span") - }) - .map_request(|request| { - // Add a log message, this will appear within the context of the current span - tracing::error!("error detected"); - request - }) - .service(service) - .boxed() - } -} - -// This macro allows us to use it in our plugin registry! -// register_plugin takes a group name, and a plugin name. -register_plugin!("acme", "tracing", Tracing); - -#[cfg(test)] -mod tests { - use apollo_router::graphql; - use apollo_router::services::supergraph; - use apollo_router::TestHarness; - use tower::BoxError; - use tower::ServiceExt; - - #[tokio::test] - async fn basic_test() -> Result<(), BoxError> { - let test_harness = TestHarness::builder() - .configuration_json(serde_json::json!({ - "plugins": { - "acme.tracing": { - "message" : "Starting my plugin" - } - } - })) - .unwrap() - .build_router() - .await - .unwrap(); - let request = supergraph::Request::canned_builder().build().unwrap(); - let mut streamed_response = test_harness.oneshot(request.try_into()?).await?; - - let first_response: graphql::Response = serde_json::from_slice( - streamed_response - .next_response() - .await - .expect("couldn't get primary response")? - .to_vec() - .as_slice(), - ) - .unwrap(); - - assert!(first_response.data.is_some()); - - println!("first response: {:?}", first_response); - let next = streamed_response.next_response().await; - println!("next response: {:?}", next); - - // You could keep calling .next_response() until it yields None if you're expexting more parts. - assert!(next.is_none()); - Ok(()) - } -} diff --git a/apollo-router-scaffold/scaffold-test/xtask/Cargo.toml b/apollo-router-scaffold/scaffold-test/xtask/Cargo.toml deleted file mode 100644 index c96b9d0e98..0000000000 --- a/apollo-router-scaffold/scaffold-test/xtask/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "xtask" -edition = "2021" -publish = false -version = "0.1.0" - -[dependencies] -# This dependency should stay in line with your router version - -apollo-router-scaffold = { git = "https://github.com/apollographql/router.git", tag = "v1.46.0" } -anyhow = "1.0.58" -clap = "4.0.32" diff --git a/apollo-router-scaffold/scaffold-test/xtask/src/main.rs b/apollo-router-scaffold/scaffold-test/xtask/src/main.rs deleted file mode 100644 index 211ded5255..0000000000 --- a/apollo-router-scaffold/scaffold-test/xtask/src/main.rs +++ /dev/null @@ -1,38 +0,0 @@ -use anyhow::Result; -use apollo_router_scaffold::RouterAction; -use clap::Parser; -use clap::Subcommand; - -#[derive(Parser, Debug)] -struct Args { - #[clap(subcommand)] - action: Action, -} - -impl Args { - fn execute(&self) -> Result<()> { - self.action.execute() - } -} - -#[derive(Subcommand, Debug)] -enum Action { - /// Forward to router action - Router { - #[clap(subcommand)] - action: RouterAction, - }, -} - -impl Action { - fn execute(&self) -> Result<()> { - match self { - Action::Router { action } => action.execute(), - } - } -} - -fn main() -> Result<()> { - let args = Args::parse(); - args.execute() -} diff --git a/apollo-router-scaffold/src/lib.rs b/apollo-router-scaffold/src/lib.rs deleted file mode 100644 index dbc76bc877..0000000000 --- a/apollo-router-scaffold/src/lib.rs +++ /dev/null @@ -1,204 +0,0 @@ -mod plugin; - -use anyhow::Result; -use clap::Subcommand; - -use crate::plugin::PluginAction; - -#[derive(Subcommand, Debug)] -pub enum RouterAction { - /// Manage plugins - Plugin { - #[clap(subcommand)] - action: PluginAction, - }, -} - -impl RouterAction { - pub fn execute(&self) -> Result<()> { - match self { - RouterAction::Plugin { action } => action.execute(), - } - } -} - -#[cfg(test)] -mod test { - use std::collections::BTreeMap; - use std::env; - use std::path::Path; - use std::path::PathBuf; - use std::path::MAIN_SEPARATOR; - - use anyhow::Result; - use cargo_scaffold::Opts; - use cargo_scaffold::ScaffoldDescription; - use dircmp::Comparison; - use inflector::Inflector; - use similar::ChangeTag; - use similar::TextDiff; - - #[test] - // this test takes a while, I hope the above test name - // let users know they should not worry and wait a bit. - // Hang in there! - // Note that we configure nextest to use all threads for this test as invoking rustc will use all available CPU and cause timing tests to fail. - fn test_scaffold() { - let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); - let repo_root = manifest_dir.parent().unwrap(); - let target_dir = repo_root.join("target"); - assert!(target_dir.exists()); - let temp_dir = tempfile::Builder::new() - .prefix("router_scaffold") - .tempdir() - .unwrap(); - let temp_dir_path = temp_dir.path(); - - let current_dir = env::current_dir().unwrap(); - // Scaffold the main project - let opts = Opts::builder(PathBuf::from("templates").join("base")) - .project_name("temp") - .target_dir(temp_dir_path) - .force(true); - ScaffoldDescription::new(opts) - .unwrap() - .scaffold_with_parameters(BTreeMap::from([( - "integration_test".to_string(), - toml::Value::String( - format!( - "{}{}", - current_dir - .parent() - .expect("current dir cannot be the root") - .to_str() - .expect("current dir must be convertable to string"), - // add / or \ depending on windows or unix - MAIN_SEPARATOR, - ) - // we need to double \ so they don't get interpreted as escape characters in TOML - .replace('\\', "\\\\"), - ), - )])) - .unwrap(); - - // Scaffold one of each type of plugin - scaffold_plugin(¤t_dir, temp_dir_path, "basic").unwrap(); - scaffold_plugin(¤t_dir, temp_dir_path, "auth").unwrap(); - scaffold_plugin(¤t_dir, temp_dir_path, "tracing").unwrap(); - std::fs::write( - temp_dir.path().join("src").join("plugins").join("mod.rs"), - "mod auth;\nmod basic;\nmod tracing;\n", - ) - .unwrap(); - - #[cfg(target_os = "windows")] - let left = ".\\scaffold-test\\"; - #[cfg(not(target_os = "windows"))] - let left = "./scaffold-test/"; - - let cmp = Comparison::default(); - let diff = cmp - .compare(left, temp_dir_path.to_str().unwrap()) - .expect("should compare"); - - let mut found = false; - if !diff.is_empty() { - println!("generated scaffolding project has changed:\n{:#?}", diff); - for file in diff.changed { - println!("file: {file:?}"); - let file = PathBuf::from(file.to_str().unwrap().strip_prefix(left).unwrap()); - - // we do not check the Cargo.toml files because they have differences due to import paths and workspace usage - if file == PathBuf::from("Cargo.toml") || file == PathBuf::from("xtask/Cargo.toml") - { - println!("skipping {}", file.to_str().unwrap()); - continue; - } - // we are not dealing with windows line endings - if file == PathBuf::from("src\\plugins\\mod.rs") { - println!("skipping {}", file.to_str().unwrap()); - continue; - } - - found = true; - diff_file(&PathBuf::from("./scaffold-test"), temp_dir_path, &file); - } - if found { - panic!(); - } - } - } - - fn scaffold_plugin(current_dir: &Path, dir_path: &Path, plugin_type: &str) -> Result<()> { - let opts = Opts::builder(PathBuf::from("templates").join("plugin")) - .project_name(plugin_type) - .target_dir(dir_path) - .append(true); - ScaffoldDescription::new(opts)?.scaffold_with_parameters(BTreeMap::from([ - ( - format!("type_{plugin_type}"), - toml::Value::String(plugin_type.to_string()), - ), - ( - "snake_name".to_string(), - toml::Value::String(plugin_type.to_snake_case()), - ), - ( - "pascal_name".to_string(), - toml::Value::String(plugin_type.to_pascal_case()), - ), - ( - "project_name".to_string(), - toml::Value::String("acme".to_string()), - ), - ( - "integration_test".to_string(), - toml::Value::String( - format!( - "{}{}", - current_dir - .parent() - .expect("current dir cannot be the root") - .to_str() - .expect("current dir must be convertable to string"), - // add / or \ depending on windows or unix - MAIN_SEPARATOR, - ) - // we need to double \ so they don't get interpreted as escape characters in TOML - .replace('\\', "\\\\"), - ), - ), - ]))?; - Ok(()) - } - - fn diff_file(left_folder: &Path, right_folder: &Path, file: &Path) { - println!("file changed: {}\n", file.to_str().unwrap()); - let left = std::fs::read_to_string(left_folder.join(file)).unwrap(); - let right = std::fs::read_to_string(right_folder.join(file)).unwrap(); - - let diff = TextDiff::from_lines(&left, &right); - - for change in diff.iter_all_changes() { - let sign = match change.tag() { - ChangeTag::Delete => "-", - ChangeTag::Insert => "+", - ChangeTag::Equal => " ", - }; - print!( - "{} {}|\t{}{}", - change - .old_index() - .map(|s| s.to_string()) - .unwrap_or("-".to_string()), - change - .new_index() - .map(|s| s.to_string()) - .unwrap_or("-".to_string()), - sign, - change - ); - } - println!("\n\n"); - } -} diff --git a/apollo-router-scaffold/src/plugin.rs b/apollo-router-scaffold/src/plugin.rs deleted file mode 100644 index 911e2a86c0..0000000000 --- a/apollo-router-scaffold/src/plugin.rs +++ /dev/null @@ -1,177 +0,0 @@ -use std::fs; -use std::path::Path; -use std::path::PathBuf; - -use anyhow::Result; -use cargo_scaffold::ScaffoldDescription; -use clap::Subcommand; -use inflector::Inflector; -use regex::Regex; -use toml::Value; - -#[derive(Subcommand, Debug)] -pub enum PluginAction { - /// Add a plugin. - Create { - /// The name of the plugin you want to add. - name: String, - - /// Optional override of the scaffold template path. - #[clap(long)] - template_override: Option, - }, - - /// Remove a plugin. - Remove { - /// The name of the plugin you want to remove. - name: String, - }, -} - -impl PluginAction { - pub fn execute(&self) -> Result<()> { - match self { - PluginAction::Create { - name, - template_override, - } => create_plugin(name, template_override), - PluginAction::Remove { name } => remove_plugin(name), - } - } -} - -fn create_plugin(name: &str, template_path: &Option) -> Result<()> { - let plugin_path = plugin_path(name); - if plugin_path.exists() { - return Err(anyhow::anyhow!("plugin '{}' already exists", name)); - } - - let cargo_toml = fs::read_to_string("Cargo.toml")?.parse::()?; - let project_name = cargo_toml - .get("package") - .unwrap_or(&toml::Value::String("default".to_string())) - .get("name") - .map(|n| n.to_string().to_snake_case()) - .unwrap_or_else(|| "default".to_string()); - - let version = get_router_version(cargo_toml); - - let opts = cargo_scaffold::Opts::builder(template_path.as_ref().unwrap_or(&PathBuf::from( - "https://github.com/apollographql/router.git", - ))) - .git_ref(version) - .repository_template_path( - PathBuf::from("apollo-router-scaffold") - .join("templates") - .join("plugin"), - ) - .target_dir(".") - .project_name(name) - .parameters(vec![format!("name={name}")]) - .append(true); - let desc = ScaffoldDescription::new(opts)?; - let mut params = desc.fetch_parameters_value()?; - params.insert( - "pascal_name".to_string(), - Value::String(name.to_pascal_case()), - ); - params.insert( - "snake_name".to_string(), - Value::String(name.to_snake_case()), - ); - params.insert( - "project_name".to_string(), - Value::String(project_name.to_snake_case()), - ); - - params.insert( - format!( - "type_{}", - params - .get("type") - .expect("type must have been set") - .as_str() - .expect("type must be a string") - ), - Value::Boolean(true), - ); - - desc.scaffold_with_parameters(params)?; - - let mod_path = mod_path(); - let mut mod_rs = if mod_path.exists() { - std::fs::read_to_string(&mod_path)? - } else { - "".to_string() - }; - - let snake_name = name.to_snake_case(); - let re = Regex::new(&format!(r"(?m)^mod {snake_name};$")).unwrap(); - if re.find(&mod_rs).is_none() { - mod_rs = format!("mod {snake_name};\n{mod_rs}"); - } - - std::fs::write(mod_path, mod_rs)?; - - println!( - "Plugin created at '{}'.\nRemember to add the plugin to your router.yaml to activate it.", - plugin_path.display() - ); - Ok(()) -} - -fn get_router_version(cargo_toml: Value) -> String { - match cargo_toml - .get("dependencies") - .cloned() - .unwrap_or_else(|| Value::Table(toml::value::Table::default())) - .get("apollo-router") - { - Some(Value::String(version)) => format!("v{version}"), - Some(Value::Table(table)) => { - if let Some(Value::String(branch)) = table.get("branch") { - format!("origin/{}", branch.clone()) - } else if let Some(Value::String(tag)) = table.get("tag") { - tag.clone() - } else if let Some(Value::String(rev)) = table.get("rev") { - rev.clone() - } else { - format!("v{}", std::env!("CARGO_PKG_VERSION")) - } - } - _ => format!("v{}", std::env!("CARGO_PKG_VERSION")), - } -} - -fn remove_plugin(name: &str) -> Result<()> { - let plugin_path = plugin_path(name); - let snake_name = name.to_snake_case(); - - std::fs::remove_file(&plugin_path)?; - - // Remove the mod; - let mod_path = mod_path(); - if Path::new(&mod_path).exists() { - let mut mod_rs = std::fs::read_to_string(&mod_path)?; - let re = Regex::new(&format!(r"(?m)^mod {snake_name};$")).unwrap(); - mod_rs = re.replace(&mod_rs, "").to_string(); - - std::fs::write(mod_path, mod_rs)?; - } - - println!( - "Plugin removed at '{}'. This is a best effort, and you may need to edit some files manually.", - plugin_path.display() - ); - Ok(()) -} - -fn mod_path() -> PathBuf { - PathBuf::from("src").join("plugins").join("mod.rs") -} - -fn plugin_path(name: &str) -> PathBuf { - PathBuf::from("src") - .join("plugins") - .join(format!("{}.rs", name.to_snake_case())) -} diff --git a/apollo-router-scaffold/templates/base/.cargo/config b/apollo-router-scaffold/templates/base/.cargo/config deleted file mode 100644 index 24a9882b48..0000000000 --- a/apollo-router-scaffold/templates/base/.cargo/config +++ /dev/null @@ -1,3 +0,0 @@ -[alias] -xtask = "run --package xtask --" -router = "run --package xtask -- router" diff --git a/apollo-router-scaffold/templates/base/.dockerignore b/apollo-router-scaffold/templates/base/.dockerignore deleted file mode 100644 index c2c4a5aa95..0000000000 --- a/apollo-router-scaffold/templates/base/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -target/** \ No newline at end of file diff --git a/apollo-router-scaffold/templates/base/.gitignore b/apollo-router-scaffold/templates/base/.gitignore deleted file mode 100644 index bba7b53950..0000000000 --- a/apollo-router-scaffold/templates/base/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target/ -/.idea/ diff --git a/apollo-router-scaffold/templates/base/.scaffold.toml b/apollo-router-scaffold/templates/base/.scaffold.toml deleted file mode 100644 index 02fd1e20d9..0000000000 --- a/apollo-router-scaffold/templates/base/.scaffold.toml +++ /dev/null @@ -1,32 +0,0 @@ -[template] -name = "apollo-router" -author = "Apollo" -version = "0.1.0" - -exclude = [ - "./target" -] - -disable_templating = [ - "./scaffold/**/*" -] - -notes = """ -Created new Apollo Router project '{{name}}'. - -> Note: The Apollo Router is made available under the Elastic License v2.0 (ELv2). -> Read [our licensing page](https://www.apollographql.com/docs/resources/elastic-license-v2-faq/) for more details. -""" - -[hooks] -post = [ - "mv Cargo.template.toml Cargo.toml", - "mv xtask/Cargo.template.toml xtask/Cargo.toml", -] - -[parameters] -[parameters.name] -type = "string" -message = "What is the name of your new router project?" -required = true - diff --git a/apollo-router-scaffold/templates/base/Cargo.template.toml b/apollo-router-scaffold/templates/base/Cargo.template.toml deleted file mode 100644 index 6b9873bf75..0000000000 --- a/apollo-router-scaffold/templates/base/Cargo.template.toml +++ /dev/null @@ -1,39 +0,0 @@ -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -[package] -name = "{{name}}" -version = "0.1.0" -edition = "2021" - -[workspace] -members = [ - "xtask", -] - -[[bin]] -name = "router" -path = "src/main.rs" - -[dependencies] -anyhow = "1.0.58" -{{#if integration_test}} -apollo-router = { path ="{{integration_test}}apollo-router" } -{{else}} -{{#if branch}} -apollo-router = { git="https://github.com/apollographql/router.git", branch="{{branch}}" } -{{else}} -# Note if you update these dependencies then also update xtask/Cargo.toml -apollo-router = "1.57.1" -{{/if}} -{{/if}} -async-trait = "0.1.52" -schemars = "0.8.10" -serde = "1.0.149" -serde_json = "1.0.79" -tokio = { version = "1.17.0", features = ["full"] } -tower = { version = "0.4.12", features = ["full"] } -tracing = "0.1.37" - -# this makes build scripts and proc macros faster to compile -[profile.dev.build-override] -strip = "debuginfo" -incremental = false diff --git a/apollo-router-scaffold/templates/base/Dockerfile b/apollo-router-scaffold/templates/base/Dockerfile deleted file mode 100644 index 28e91cc9a6..0000000000 --- a/apollo-router-scaffold/templates/base/Dockerfile +++ /dev/null @@ -1,54 +0,0 @@ -# Use the rust build image from docker as our base -# renovate-automation: rustc version -FROM rust:1.76.0 as build - -# Set our working directory for the build -WORKDIR /usr/src/router - -# Update our build image and install required packages -RUN apt-get update -RUN apt-get -y install \ - npm \ - protobuf-compiler - -# Add rustfmt since build requires it -RUN rustup component add rustfmt - -# Copy the router source to our build environment -COPY . . - -# Build and install the custom binary -RUN cargo build --release - -# Make directories for config and schema -RUN mkdir -p /dist/config && \ - mkdir /dist/schema && \ - mv target/release/router /dist - -# Copy configuration for docker image -COPY router.yaml /dist/config.yaml - -FROM debian:bullseye-slim - -RUN apt-get update -RUN apt-get -y install \ - ca-certificates - -# Set labels for our image -LABEL org.opencontainers.image.authors="Apollo Graph, Inc. https://github.com/apollographql/router" -LABEL org.opencontainers.image.source="https://github.com/apollographql/router" - -# Copy in the required files from our build image -COPY --from=build --chown=root:root /dist /dist - -WORKDIR /dist - -ENV APOLLO_ROUTER_CONFIG_PATH="/dist/config.yaml" - -# Make sure we can run the router -RUN chmod 755 /dist/router - -USER router - -# Default executable is the router -ENTRYPOINT ["/dist/router"] diff --git a/apollo-router-scaffold/templates/base/README.md b/apollo-router-scaffold/templates/base/README.md deleted file mode 100644 index 98ec70eb24..0000000000 --- a/apollo-router-scaffold/templates/base/README.md +++ /dev/null @@ -1,120 +0,0 @@ -# Apollo Router project - -This generated project is set up to create a custom Apollo Router binary that may include plugins that you have written. - -> Note: The Apollo Router is made available under the Elastic License v2.0 (ELv2). -> Read [our licensing page](https://www.apollographql.com/docs/resources/elastic-license-v2-faq/) for more details. - -# Compile the router - -To create a debug build use the following command. -```bash -cargo build -``` -Your debug binary is now located in `target/debug/router` - -For production, you will want to create a release build. -```bash -cargo build --release -``` -Your release binary is now located in `target/release/router` - -# Run the Apollo Router - -1. Download the example schema - - ```bash - curl -sSL https://supergraph.demo.starstuff.dev/ > supergraph-schema.graphql - ``` - -2. Run the Apollo Router - - During development it is convenient to use `cargo run` to run the Apollo Router as it will - ```bash - cargo run -- --hot-reload --config router.yaml --supergraph supergraph-schema.graphql - ``` - -> If you are using managed federation you can set APOLLO_KEY and APOLLO_GRAPH_REF environment variables instead of specifying the supergraph as a file. - -# Create a plugin - -1. From within your project directory scaffold a new plugin - ```bash - cargo router plugin create hello_world - ``` -2. Select the type of plugin you want to scaffold: - ```bash - Select a plugin template: - > "basic" - "auth" - "tracing" - ``` - - The different templates are: - * basic - a barebones plugin. - * auth - a basic authentication plugin that could make an external call. - * tracing - a plugin that adds a custom span and a log message. - - Choose `basic`. - -4. Add the plugin to the `router.yaml` - ```yaml - plugins: - starstuff.hello_world: - message: "Starting my plugin" - ``` - -5. Run the Apollo Router and see your plugin start up - ```bash - cargo run -- --hot-reload --config router.yaml --supergraph supergraph-schema.graphql - ``` - - In your output you should see something like: - ```bash - 2022-05-21T09:16:33.160288Z INFO router::plugins::hello_world: Starting my plugin - ``` - -# Remove a plugin - -1. From within your project run the following command. It makes a best effort to remove the plugin, but your mileage may vary. - ```bash - cargo router plugin remove hello_world - ``` - -# Docker - -You can use the provided Dockerfile to build a release container. - -Make sure your router is configured to listen to `0.0.0.0` so you can query it from outside the container: - -```yml - supergraph: - listen: 0.0.0.0:4000 -``` - -Use your `APOLLO_KEY` and `APOLLO_GRAPH_REF` environment variables to run the router in managed federation. - - ```bash - docker build -t my_custom_router . - docker run -e APOLLO_KEY="your apollo key" -e APOLLO_GRAPH_REF="your apollo graph ref" -p 4000:4000 my_custom_router - ``` - -Otherwise add a `COPY` step to the Dockerfile, and edit the entrypoint: - -```Dockerfile -# Copy configuration for docker image -COPY router.yaml /dist/config.yaml -# Copy supergraph for docker image -COPY my_supergraph.graphql /dist/supergraph.graphql - -# [...] and change the entrypoint - -# Default executable is the router -ENTRYPOINT ["/dist/router", "-s", "/dist/supergraph.graphql"] -``` - -You can now build and run your custom router: - ```bash - docker build -t my_custom_router . - docker run -p 4000:4000 my_custom_router - ``` diff --git a/apollo-router-scaffold/templates/base/router.yaml b/apollo-router-scaffold/templates/base/router.yaml deleted file mode 100644 index 8411f80a8b..0000000000 --- a/apollo-router-scaffold/templates/base/router.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# uncomment this section if you plan to use the Dockerfile -# supergraph: -# listen: 0.0.0.0:4000 -plugins: - # Add plugin configuration here diff --git a/apollo-router-scaffold/templates/base/rust-toolchain.toml b/apollo-router-scaffold/templates/base/rust-toolchain.toml deleted file mode 100644 index 65542fa528..0000000000 --- a/apollo-router-scaffold/templates/base/rust-toolchain.toml +++ /dev/null @@ -1,6 +0,0 @@ -# Note that the contents should be same as https://github.com/apollographql/router/blob/main/rust-toolchain.toml - -[toolchain] -# renovate-automation: rustc version -channel = "1.76.0" -components = [ "rustfmt", "clippy" ] diff --git a/apollo-router-scaffold/templates/base/src/main.rs b/apollo-router-scaffold/templates/base/src/main.rs deleted file mode 100644 index ca6699afe9..0000000000 --- a/apollo-router-scaffold/templates/base/src/main.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod plugins; - -use anyhow::Result; - -fn main() -> Result<()> { - apollo_router::main() -} diff --git a/apollo-router-scaffold/templates/base/xtask/Cargo.template.toml b/apollo-router-scaffold/templates/base/xtask/Cargo.template.toml deleted file mode 100644 index f58bd86237..0000000000 --- a/apollo-router-scaffold/templates/base/xtask/Cargo.template.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "xtask" -edition = "2021" -publish = false -version = "0.1.0" - -[dependencies] -# This dependency should stay in line with your router version - -{{#if integration_test}} -apollo-router-scaffold = { path ="{{integration_test}}apollo-router-scaffold" } -{{else}} -{{#if branch}} -apollo-router-scaffold = { git="https://github.com/apollographql/router.git", branch="{{branch}}" } -{{else}} -apollo-router-scaffold = { git = "https://github.com/apollographql/router.git", tag = "v1.57.1" } -{{/if}} -{{/if}} -anyhow = "1.0.58" -clap = "4.0.32" diff --git a/apollo-router-scaffold/templates/base/xtask/src/main.rs b/apollo-router-scaffold/templates/base/xtask/src/main.rs deleted file mode 100644 index 211ded5255..0000000000 --- a/apollo-router-scaffold/templates/base/xtask/src/main.rs +++ /dev/null @@ -1,38 +0,0 @@ -use anyhow::Result; -use apollo_router_scaffold::RouterAction; -use clap::Parser; -use clap::Subcommand; - -#[derive(Parser, Debug)] -struct Args { - #[clap(subcommand)] - action: Action, -} - -impl Args { - fn execute(&self) -> Result<()> { - self.action.execute() - } -} - -#[derive(Subcommand, Debug)] -enum Action { - /// Forward to router action - Router { - #[clap(subcommand)] - action: RouterAction, - }, -} - -impl Action { - fn execute(&self) -> Result<()> { - match self { - Action::Router { action } => action.execute(), - } - } -} - -fn main() -> Result<()> { - let args = Args::parse(); - args.execute() -} diff --git a/apollo-router-scaffold/templates/plugin/.scaffold.toml b/apollo-router-scaffold/templates/plugin/.scaffold.toml deleted file mode 100644 index 09a3e558d6..0000000000 --- a/apollo-router-scaffold/templates/plugin/.scaffold.toml +++ /dev/null @@ -1,25 +0,0 @@ -[template] -name = "apollo-router-plugins" -author = "Apollo" -version = "0.1.0" - -exclude = [ - "./target" -] - -notes = """ -Created new plugin {{project_name}}.{{snake_name}} -Source: src/plugins/{{snake_name}}.rs. - -To use the plugin add it to router.yaml: - -plugins: - {{project_name}}.{{snake_name}}: - # Plugin configuration -""" - -[parameters] -[parameters.type] -type = "select" -message = "Select a plugin template" -values = ["basic", "auth", "tracing"] diff --git a/apollo-router-scaffold/templates/plugin/src/plugins/mod.rs b/apollo-router-scaffold/templates/plugin/src/plugins/mod.rs deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/apollo-router-scaffold/templates/plugin/src/plugins/{{snake_name}}.rs b/apollo-router-scaffold/templates/plugin/src/plugins/{{snake_name}}.rs deleted file mode 100644 index 2208f6eb3f..0000000000 --- a/apollo-router-scaffold/templates/plugin/src/plugins/{{snake_name}}.rs +++ /dev/null @@ -1,199 +0,0 @@ -{{#if type_auth}} -use std::ops::ControlFlow; - -use apollo_router::layers::ServiceBuilderExt; -{{/if}} -{{#if type_tracing}} -use apollo_router::layers::ServiceBuilderExt; -{{/if}} -use apollo_router::plugin::Plugin; -use apollo_router::plugin::PluginInit; -use apollo_router::register_plugin; -{{#if type_basic}} -use apollo_router::services::execution; -{{/if}} -{{#if type_basic}} -use apollo_router::services::router; -use apollo_router::services::subgraph; -{{/if}} -use apollo_router::services::supergraph; -use schemars::JsonSchema; -use serde::Deserialize; -use tower::BoxError; -{{#if type_auth}} -use tower::ServiceBuilder; -use tower::ServiceExt; -{{/if}} -{{#if type_tracing}} -use tower::ServiceBuilder; -use tower::ServiceExt; -{{/if}} - -#[derive(Debug)] -struct {{pascal_name}} { - #[allow(dead_code)] - configuration: Conf, -} - -#[derive(Debug, Default, Deserialize, JsonSchema)] -struct Conf { - // Put your plugin configuration here. It will automatically be deserialized from JSON. - // Always put some sort of config here, even if it is just a bool to say that the plugin is enabled, - // otherwise the yaml to enable the plugin will be confusing. - message: String, -} -{{#if type_basic}} -// This is a bare bones plugin that can be duplicated when creating your own. -#[async_trait::async_trait] -impl Plugin for {{pascal_name}} { - type Config = Conf; - - async fn new(init: PluginInit) -> Result { - tracing::info!("{}", init.config.message); - Ok({{pascal_name}} { - configuration: init.config, - }) - } - - // Delete this function if you are not customizing it. - fn router_service(&self, service: router::BoxService) -> router::BoxService { - // Always use service builder to compose your plugins. - // It provides off the shelf building blocks for your plugin. - // - // ServiceBuilder::new() - // .service(service) - // .boxed() - - // Returning the original service means that we didn't add any extra functionality at this point in the lifecycle. - service - } - - // Delete this function if you are not customizing it. - fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { - // Always use service builder to compose your plugins. - // It provides off the shelf building blocks for your plugin. - // - // ServiceBuilder::new() - // .service(service) - // .boxed() - - // Returning the original service means that we didn't add any extra functionality for at this point in the lifecycle. - service - } - - // Delete this function if you are not customizing it. - fn execution_service(&self, service: execution::BoxService) -> execution::BoxService { - service - } - - // Delete this function if you are not customizing it. - fn subgraph_service(&self, _name: &str, service: subgraph::BoxService) -> subgraph::BoxService { - service - } -} -{{/if}} -{{#if type_auth}} -// This plugin is a skeleton for doing authentication that requires a remote call. -#[async_trait::async_trait] -impl Plugin for {{pascal_name}} { - type Config = Conf; - - async fn new(init: PluginInit) -> Result { - tracing::info!("{}", init.config.message); - Ok({{pascal_name}} { - configuration: init.config, - }) - } - - fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { - ServiceBuilder::new() - .oneshot_checkpoint_async(|request: supergraph::Request| async { - // Do some async call here to auth, and decide if to continue or not. - Ok(ControlFlow::Continue(request)) - }) - .service(service) - .boxed() - } -} -{{/if}} -{{#if type_tracing}} -// This plugin adds a span and an error to the logs. -#[async_trait::async_trait] -impl Plugin for {{pascal_name}} { - type Config = Conf; - - async fn new(init: PluginInit) -> Result { - tracing::info!("{}", init.config.message); - Ok({{pascal_name}} { - configuration: init.config, - }) - } - - fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { - ServiceBuilder::new() - .instrument(|_request| { - // Optionally take information from the request and insert it into the span as attributes - // See https://docs.rs/tracing/latest/tracing/ for more information - tracing::info_span!("my_custom_span") - }) - .map_request(|request| { - // Add a log message, this will appear within the context of the current span - tracing::error!("error detected"); - request - }) - .service(service) - .boxed() - } -} -{{/if}} - -// This macro allows us to use it in our plugin registry! -// register_plugin takes a group name, and a plugin name. -register_plugin!("{{project_name}}", "{{snake_name}}", {{pascal_name}}); - -#[cfg(test)] -mod tests { - use apollo_router::graphql; - use apollo_router::services::supergraph; - use apollo_router::TestHarness; - use tower::BoxError; - use tower::ServiceExt; - - #[tokio::test] - async fn basic_test() -> Result<(), BoxError> { - let test_harness = TestHarness::builder() - .configuration_json(serde_json::json!({ - "plugins": { - "{{project_name}}.{{snake_name}}": { - "message" : "Starting my plugin" - } - } - })) - .unwrap() - .build_router() - .await - .unwrap(); - let request = supergraph::Request::canned_builder().build().unwrap(); - let mut streamed_response = test_harness.oneshot(request.try_into()?).await?; - - let first_response: graphql::Response = serde_json::from_slice( - streamed_response - .next_response() - .await - .expect("couldn't get primary response")? - .to_vec() - .as_slice(), - ) - .unwrap(); - - assert!(first_response.data.is_some()); - - println!("first response: {:?}", first_response); - let next = streamed_response.next_response().await; - println!("next response: {:?}", next); - - // You could keep calling .next_response() until it yields None if you're expexting more parts. - assert!(next.is_none()); - Ok(()) - } -} diff --git a/apollo-router/.sqlx/query-119ea89f7b98079bd3d2ec81596cd04a19fd394c234940976dd32bc507984fca.json b/apollo-router/.sqlx/query-119ea89f7b98079bd3d2ec81596cd04a19fd394c234940976dd32bc507984fca.json new file mode 100644 index 0000000000..09edf782c6 --- /dev/null +++ b/apollo-router/.sqlx/query-119ea89f7b98079bd3d2ec81596cd04a19fd394c234940976dd32bc507984fca.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO invalidation_key (cache_key_id, invalidation_key, subgraph_name)\n SELECT * FROM UNNEST(\n $1::BIGINT[],\n $2::VARCHAR(255)[],\n $3::VARCHAR(255)[]\n ) ON CONFLICT (cache_key_id, invalidation_key, subgraph_name) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "VarcharArray", + "VarcharArray" + ] + }, + "nullable": [] + }, + "hash": "119ea89f7b98079bd3d2ec81596cd04a19fd394c234940976dd32bc507984fca" +} diff --git a/apollo-router/.sqlx/query-5070a632fa2c6fb2766a16d305ca71988b1ef61a8a4dd38e4ac71bcb968d489e.json b/apollo-router/.sqlx/query-5070a632fa2c6fb2766a16d305ca71988b1ef61a8a4dd38e4ac71bcb968d489e.json new file mode 100644 index 0000000000..431653ab5f --- /dev/null +++ b/apollo-router/.sqlx/query-5070a632fa2c6fb2766a16d305ca71988b1ef61a8a4dd38e4ac71bcb968d489e.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(id) AS count FROM cache WHERE expires_at <= NOW()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "5070a632fa2c6fb2766a16d305ca71988b1ef61a8a4dd38e4ac71bcb968d489e" +} diff --git a/apollo-router/.sqlx/query-602e9f11a9cf461010a1523d8e8e261b56416439675a38b316017478797a56ab.json b/apollo-router/.sqlx/query-602e9f11a9cf461010a1523d8e8e261b56416439675a38b316017478797a56ab.json new file mode 100644 index 0000000000..2fbd8f6806 --- /dev/null +++ b/apollo-router/.sqlx/query-602e9f11a9cf461010a1523d8e8e261b56416439675a38b316017478797a56ab.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO cache\n ( cache_key, data, expires_at, control ) SELECT * FROM UNNEST(\n $1::VARCHAR(1024)[],\n $2::TEXT[],\n $3::TIMESTAMP WITH TIME ZONE[],\n $4::TEXT[]\n ) ON CONFLICT (cache_key) DO UPDATE SET data = excluded.data, control = excluded.control, expires_at = excluded.expires_at\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "VarcharArray", + "TextArray", + "TimestamptzArray", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "602e9f11a9cf461010a1523d8e8e261b56416439675a38b316017478797a56ab" +} diff --git a/apollo-router/.sqlx/query-6245ac300b03e217aa5ed15489acf4fdc174de48a1d7fd64c088a15d02c2c690.json b/apollo-router/.sqlx/query-6245ac300b03e217aa5ed15489acf4fdc174de48a1d7fd64c088a15d02c2c690.json new file mode 100644 index 0000000000..5147f2336a --- /dev/null +++ b/apollo-router/.sqlx/query-6245ac300b03e217aa5ed15489acf4fdc174de48a1d7fd64c088a15d02c2c690.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(id) AS count FROM cache WHERE starts_with(cache_key, $1) AND expires_at <= NOW()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "6245ac300b03e217aa5ed15489acf4fdc174de48a1d7fd64c088a15d02c2c690" +} diff --git a/apollo-router/.sqlx/query-6cb3643cda13c340a17084366d2b27163e1b83fe86adf6e6a906a11547a1d05f.json b/apollo-router/.sqlx/query-6cb3643cda13c340a17084366d2b27163e1b83fe86adf6e6a906a11547a1d05f.json new file mode 100644 index 0000000000..8d0119ef9c --- /dev/null +++ b/apollo-router/.sqlx/query-6cb3643cda13c340a17084366d2b27163e1b83fe86adf6e6a906a11547a1d05f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT cron.alter_job((SELECT jobid FROM cron.job WHERE jobname = 'delete-old-cache-entries'), $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "alter_job", + "type_info": "Void" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "6cb3643cda13c340a17084366d2b27163e1b83fe86adf6e6a906a11547a1d05f" +} diff --git a/apollo-router/.sqlx/query-6e77a91b9716475f81f81e188b13813f86f0b8072bbd241ed1090b5c37b932c8.json b/apollo-router/.sqlx/query-6e77a91b9716475f81f81e188b13813f86f0b8072bbd241ed1090b5c37b932c8.json new file mode 100644 index 0000000000..7b03c87a7b --- /dev/null +++ b/apollo-router/.sqlx/query-6e77a91b9716475f81f81e188b13813f86f0b8072bbd241ed1090b5c37b932c8.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO cache ( cache_key, data, control, expires_at )\n VALUES ( $1, $2, $3, $4 )\n ON CONFLICT (cache_key) DO UPDATE SET data = $2, control = $3, expires_at = $4\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "6e77a91b9716475f81f81e188b13813f86f0b8072bbd241ed1090b5c37b932c8" +} diff --git a/apollo-router/.sqlx/query-92256ab4f44ce1b6a034cbb1bfac848ed7767b3aa0e1b3eaeb612f4840f4765e.json b/apollo-router/.sqlx/query-92256ab4f44ce1b6a034cbb1bfac848ed7767b3aa0e1b3eaeb612f4840f4765e.json new file mode 100644 index 0000000000..ca1a34f7cb --- /dev/null +++ b/apollo-router/.sqlx/query-92256ab4f44ce1b6a034cbb1bfac848ed7767b3aa0e1b3eaeb612f4840f4765e.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM cache WHERE cache.cache_key = $1 AND expires_at >= NOW()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "cache_key", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "data", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "control", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "92256ab4f44ce1b6a034cbb1bfac848ed7767b3aa0e1b3eaeb612f4840f4765e" +} diff --git a/apollo-router/.sqlx/query-9cab35054c927272ec5fb820c6122ec531e1dc6f695c56268bb3546b1f14beab.json b/apollo-router/.sqlx/query-9cab35054c927272ec5fb820c6122ec531e1dc6f695c56268bb3546b1f14beab.json new file mode 100644 index 0000000000..3737f26782 --- /dev/null +++ b/apollo-router/.sqlx/query-9cab35054c927272ec5fb820c6122ec531e1dc6f695c56268bb3546b1f14beab.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT data FROM cache WHERE cache_key = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "data", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9cab35054c927272ec5fb820c6122ec531e1dc6f695c56268bb3546b1f14beab" +} diff --git a/apollo-router/.sqlx/query-a14112a24ef08c184e9062837d553deffa2e7a2c8570ef7089701ebab9f02665.json b/apollo-router/.sqlx/query-a14112a24ef08c184e9062837d553deffa2e7a2c8570ef7089701ebab9f02665.json new file mode 100644 index 0000000000..0483e1b425 --- /dev/null +++ b/apollo-router/.sqlx/query-a14112a24ef08c184e9062837d553deffa2e7a2c8570ef7089701ebab9f02665.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM cache WHERE cache.cache_key = ANY($1::VARCHAR(1024)[]) AND expires_at >= NOW()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "cache_key", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "data", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "control", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "VarcharArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "a14112a24ef08c184e9062837d553deffa2e7a2c8570ef7089701ebab9f02665" +} diff --git a/apollo-router/.sqlx/query-a8ff75a83927cd264969cecc40449b47ba93ab750c54cecec6ccf636f1145178.json b/apollo-router/.sqlx/query-a8ff75a83927cd264969cecc40449b47ba93ab750c54cecec6ccf636f1145178.json new file mode 100644 index 0000000000..fdd920a80a --- /dev/null +++ b/apollo-router/.sqlx/query-a8ff75a83927cd264969cecc40449b47ba93ab750c54cecec6ccf636f1145178.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT into invalidation_key (cache_key_id, invalidation_key, subgraph_name) VALUES ($1, $2, $3) ON CONFLICT (cache_key_id, invalidation_key, subgraph_name) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "a8ff75a83927cd264969cecc40449b47ba93ab750c54cecec6ccf636f1145178" +} diff --git a/apollo-router/.sqlx/query-b1b1778f3d3f1ca73b5037d6d6e9cd980c07f640be2fea4bc9d76dfc451d437d.json b/apollo-router/.sqlx/query-b1b1778f3d3f1ca73b5037d6d6e9cd980c07f640be2fea4bc9d76dfc451d437d.json new file mode 100644 index 0000000000..24194a9aec --- /dev/null +++ b/apollo-router/.sqlx/query-b1b1778f3d3f1ca73b5037d6d6e9cd980c07f640be2fea4bc9d76dfc451d437d.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH deleted AS\n (DELETE\n FROM cache\n USING invalidation_key\n WHERE invalidation_key.cache_key_id = cache.id AND invalidation_key.subgraph_name = ANY($1::text[]) RETURNING cache.cache_key, cache.expires_at\n )\n SELECT COUNT(*) AS count FROM deleted WHERE deleted.expires_at >= NOW()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b1b1778f3d3f1ca73b5037d6d6e9cd980c07f640be2fea4bc9d76dfc451d437d" +} diff --git a/apollo-router/.sqlx/query-b7f6dac1056d65e173a6f4f3d81f57f456f3a15c9dfd1388e0b32a0978a08ae0.json b/apollo-router/.sqlx/query-b7f6dac1056d65e173a6f4f3d81f57f456f3a15c9dfd1388e0b32a0978a08ae0.json new file mode 100644 index 0000000000..ab6ce0062d --- /dev/null +++ b/apollo-router/.sqlx/query-b7f6dac1056d65e173a6f4f3d81f57f456f3a15c9dfd1388e0b32a0978a08ae0.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM cache WHERE starts_with(cache_key, $1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "b7f6dac1056d65e173a6f4f3d81f57f456f3a15c9dfd1388e0b32a0978a08ae0" +} diff --git a/apollo-router/.sqlx/query-c2b5393c5c63e03e12e9d480e82a0c610380ed3411e0e5d3cae03e94347410a3.json b/apollo-router/.sqlx/query-c2b5393c5c63e03e12e9d480e82a0c610380ed3411e0e5d3cae03e94347410a3.json new file mode 100644 index 0000000000..939f23161e --- /dev/null +++ b/apollo-router/.sqlx/query-c2b5393c5c63e03e12e9d480e82a0c610380ed3411e0e5d3cae03e94347410a3.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH deleted AS\n (DELETE\n FROM cache\n USING invalidation_key\n WHERE invalidation_key.invalidation_key = ANY($1::text[])\n AND invalidation_key.cache_key_id = cache.id AND invalidation_key.subgraph_name = ANY($2::text[]) RETURNING cache.cache_key, cache.expires_at, invalidation_key.subgraph_name\n )\n SELECT subgraph_name, COUNT(deleted.cache_key) AS count FROM deleted WHERE deleted.expires_at >= NOW() GROUP BY deleted.subgraph_name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "subgraph_name", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "c2b5393c5c63e03e12e9d480e82a0c610380ed3411e0e5d3cae03e94347410a3" +} diff --git a/apollo-router/.sqlx/query-cb60ba408aa989631c6257955d9cf21671f600ee645ed77e5a300b6bbb9590c3.json b/apollo-router/.sqlx/query-cb60ba408aa989631c6257955d9cf21671f600ee645ed77e5a300b6bbb9590c3.json new file mode 100644 index 0000000000..abca13915f --- /dev/null +++ b/apollo-router/.sqlx/query-cb60ba408aa989631c6257955d9cf21671f600ee645ed77e5a300b6bbb9590c3.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT schedule FROM cron.job WHERE jobname = 'delete-old-cache-entries'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "schedule", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "cb60ba408aa989631c6257955d9cf21671f600ee645ed77e5a300b6bbb9590c3" +} diff --git a/apollo-router/Cargo.toml b/apollo-router/Cargo.toml index 6c7e06a6d0..be6310048c 100644 --- a/apollo-router/Cargo.toml +++ b/apollo-router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "apollo-router" -version = "1.57.1" +version = "2.6.0" authors = ["Apollo Graph, Inc. "] repository = "https://github.com/apollographql/router/" documentation = "https://docs.rs/apollo-router" @@ -8,9 +8,10 @@ description = "A configurable, high-performance routing runtime for Apollo Feder license = "Elastic-2.0" # renovate-automation: rustc version -rust-version = "1.76.0" -edition = "2021" +rust-version = "1.89.0" +edition = "2024" build = "build/main.rs" +default-run = "router" [[bin]] name = "router" @@ -33,7 +34,7 @@ default = ["global-allocator"] # [dependencies] # apollo-router = {version = "1.20", default-features = false} # ``` -global-allocator = [] +global-allocator = ["tikv-jemallocator/profiling", "tikv-jemalloc-ctl/stats", "tikv-jemalloc-ctl/profiling"] # if you are doing heap profiling dhat-heap = ["dhat"] @@ -44,31 +45,20 @@ dhat-ad-hoc = ["dhat"] # alerted early when something is wrong instead of receiving an invalid result. failfast = [] -# "fake" feature to disable V8 usage when building on docs.rs -# See https://github.com/apollographql/federation-rs/pull/185 -docs_rs = ["router-bridge/docs_rs"] - # Enables the use of new telemetry features that are under development # and not yet ready for production use. telemetry_next = [] -# Allow Router to use feature from custom fork of Hyper until it is merged: -# https://github.com/hyperium/hyper/pull/3523 -hyper_header_limits = [] - # is set when ci builds take place. It allows us to disable some tests when CI is running on certain platforms. ci = [] -[package.metadata.docs.rs] -features = ["docs_rs"] +# Enables the HTTP snapshot server for testing +snapshot = ["axum-server"] [dependencies] -access-json = "0.1.0" anyhow = "1.0.86" apollo-compiler.workspace = true -apollo-federation = { path = "../apollo-federation", version = "=1.57.1" } -arc-swap = "1.6.0" -async-channel = "1.9.0" +apollo-federation = { path = "../apollo-federation", version = "=2.6.0" } async-compression = { version = "0.4.6", features = [ "tokio", "brotli", @@ -76,10 +66,12 @@ async-compression = { version = "0.4.6", features = [ "deflate", ] } async-trait.workspace = true -axum = { version = "0.6.20", features = ["headers", "json", "original-uri"] } +axum = { version = "0.8.1", features = ["http2"] } +axum-extra = { version = "0.10.0", features = ["typed-header"] } +axum-server = { version = "0.7.1", optional = true } base64 = "0.22.0" -bloomfilter = "1.0.13" -buildstructor = "0.5.4" +bloomfilter = "3.0.0" +buildstructor = "0.6.0" bytes = "1.6.0" clap = { version = "4.5.8", default-features = false, features = [ "env", @@ -88,11 +80,12 @@ clap = { version = "4.5.8", default-features = false, features = [ "help", ] } cookie = { version = "0.18.0", default-features = false } -crossbeam-channel = "0.5" +crossbeam-channel = "0.5.15" ci_info = { version = "0.14.14", features = ["serde-1"] } -dashmap = { version = "5.5.3", features = ["serde"] } +dashmap = { version = "6.0.0", features = ["serde"] } +docker_credential = "1.3.1" derivative = "2.2.0" -derive_more = { version = "0.99.17", default-features = false, features = [ +derive_more = { version = "2.0.0", default-features = false, features = [ "from", "display", ] } @@ -100,39 +93,50 @@ dhat = { version = "0.3.3", optional = true } diff = "0.1.13" displaydoc = "0.2" flate2 = "1.0.30" -fred = { version = "7.1.2", features = ["enable-rustls"] } +fred = { version = "10.1.0", features = [ + "enable-rustls-ring", + "i-cluster", + "tcp-user-timeouts", + "metrics", +] } futures = { version = "0.3.30", features = ["thread-pool"] } graphql_client = "0.14.0" hex.workspace = true http.workspace = true -http-body = "0.4.6" +http-body = "1.0.1" +http-body-util = { version = "0.1.2" } heck = "0.5.0" humantime = "2.1.0" humantime-serde = "1.1.1" -hyper = { version = "0.14.31", features = ["server", "client", "stream"] } -hyper-rustls = { version = "0.24.2", features = ["http1", "http2"] } +hyper = { version = "1.5.1", features = ["full"] } +# XXX(@goto-bus-stop): Pinned because of undiagnosed tracing failures in 0.1.11 and up: https://github.com/apollographql/router/pull/7159 +hyper-util = { version = "=0.1.10", features = ["full"] } +hyper-rustls = { version = "0.27.3", default-features = false, features = [ + "http1", + "http2", + "rustls-native-certs", +] } indexmap = { version = "2.2.6", features = ["serde"] } -itertools = "0.13.0" +itertools = "0.14.0" jsonpath_lib = "0.3.0" jsonpath-rust = "0.3.5" jsonschema = { version = "0.17.1", default-features = false } jsonwebtoken = "9.3.0" -lazy_static = "1.4.0" libc = "0.2.155" linkme = "0.3.27" -lru = "0.12.3" -maplit = "1.0.2" -mediatype = "0.19.18" +lru = "0.16.0" +mediatype = "0.20.0" mockall = "0.13.0" mime = "0.3.17" -multer = "2.1.0" +multer = "3.1.0" multimap = "0.9.1" # Warning: part of the public API # To avoid tokio issues -notify = { version = "6.1.1", default-features = false, features = [ +notify = { version = "8.0.0", default-features = false, features = [ "macos_kqueue", ] } nu-ansi-term = "0.50" num-traits = "0.2.19" +oci-client = { version = "0.15.0", default-features = false, features = ["rustls-tls", "rustls-tls-native-roots", "trust-dns"] } once_cell = "1.19.0" # Any package that starts with `opentelemetry` needs to be updated with care @@ -146,26 +150,20 @@ once_cell = "1.19.0" # groups `^tracing` and `^opentelemetry*` dependencies together as of # https://github.com/apollographql/router/pull/1509. A comment which exists # there (and on `tracing` packages below) should be updated should this change. -opentelemetry = { version = "0.20.0", features = ["trace", "metrics"] } -opentelemetry_sdk = { version = "0.20.0", default-features = false, features = [ +opentelemetry = { version = "0.24.0", features = ["trace", "metrics"] } +opentelemetry_sdk = { version = "0.24.1", default-features = false, features = [ + "rt-tokio", "trace", ] } -opentelemetry_api = "0.20.0" -opentelemetry-aws = "0.8.0" +opentelemetry-aws = "0.12.0" # START TEMP DATADOG Temporarily remove until we upgrade otel to the latest version # This means including the rmp library -# opentelemetry-datadog = { version = "0.8.0", features = ["reqwest-client"] } +# opentelemetry-datadog = { version = "0.12.0", features = ["reqwest-client"] } rmp = "0.8" # END TEMP DATADOG -# Pin rowan until update to rust 1.77 -rowan = "=0.15.15" -opentelemetry-http = "0.9.0" -opentelemetry-jaeger = { version = "0.19.0", features = [ - "collector_client", - "reqwest_collector_client", - "rt-tokio", -] } -opentelemetry-otlp = { version = "0.13.0", default-features = false, features = [ +opentelemetry-http = "0.13.0" +opentelemetry-jaeger-propagator = "0.3.0" +opentelemetry-otlp = { version = "0.17.0", default-features = false, features = [ "grpc-tonic", "gzip-tonic", "tonic", @@ -175,30 +173,34 @@ opentelemetry-otlp = { version = "0.13.0", default-features = false, features = "reqwest-client", "trace", ] } -opentelemetry-semantic-conventions = "0.12.0" -opentelemetry-zipkin = { version = "0.18.0", default-features = false, features = [ +opentelemetry-semantic-conventions = "0.16.0" +opentelemetry-zipkin = { version = "0.22.0", default-features = false, features = [ "reqwest-client", "reqwest-rustls", ] } -opentelemetry-prometheus = "0.13.0" +opentelemetry-prometheus = "0.17.0" paste = "1.0.15" pin-project-lite = "0.2.14" prometheus = "0.13" -prost = "0.12.6" -prost-types = "0.12.6" +prost = "0.13.0" +prost-types = "0.13.0" proteus = "0.5.0" -rand = "0.8.5" -rhai = { version = "1.19.0", features = ["sync", "serde", "internals"] } +rand = "0.9.0" +# Pinned due to https://github.com/apollographql/router/pull/7679 +rhai = { version = "=1.21.0", features = ["sync", "serde", "internals"] } regex = "1.10.5" -reqwest.workspace = true - -# note: this dependency should _always_ be pinned, prefix the version with an `=` -router-bridge = "=0.6.4+v2.9.3" +reqwest = { workspace = true, default-features = false, features = [ + "rustls-tls", + "rustls-tls-native-roots", + "gzip", + "json", + "stream", +] } rust-embed = { version = "8.4.0", features = ["include-exclude"] } -rustls = "0.21.12" -rustls-native-certs = "0.6.3" -rustls-pemfile = "1.0.4" +rustls = { version = "0.23.19", default-features = false } +rustls-native-certs = "0.8.1" +rustls-pemfile = "2.2.0" schemars.workspace = true shellexpand = "3.1.0" sha2 = "0.10.8" @@ -207,97 +209,104 @@ serde.workspace = true serde_derive_default = "0.1" serde_json_bytes.workspace = true serde_json.workspace = true +serde_regex = { version = "1.1.0" } serde_urlencoded = "0.7.1" serde_yaml = "0.8.26" static_assertions = "1.1.0" -strum_macros = "0.26.0" +strum = "0.27.0" +strum_macros = "0.27.0" +sqlx = { version = "0.8", features = [ + "postgres", + "runtime-tokio", + "tls-rustls-ring-native-roots", + "chrono", +] } sys-info = "0.9.1" -thiserror = "1.0.61" +sysinfo = { version = "0.37.0", features = [ + "system", + "windows", +], default-features = false } +thiserror = "2.0.0" tokio.workspace = true tokio-stream = { version = "0.1.15", features = ["sync", "net"] } tokio-util = { version = "0.7.11", features = ["net", "codec", "time"] } -tonic = { version = "0.9.2", features = [ +tonic = { version = "0.12.3", features = [ "transport", "tls", "tls-roots", "gzip", ] } tower.workspace = true -tower-http = { version = "0.4.0", features = [ - "add-extension", - "trace", - "cors", - "compression-br", - "compression-deflate", - "compression-gzip", - "decompression-br", - "decompression-deflate", - "decompression-gzip", - "timeout", -] } +tower-http = { version = "0.6.2", features = ["full"] } tower-service = "0.3.2" tracing = "0.1.40" tracing-core = "0.1.32" tracing-futures = { version = "0.2.5", features = ["futures-03"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] } -url = { version = "2.5.2", features = ["serde"] } +url = { version = "2.5.4", features = ["serde"] } urlencoding = "2.1.3" uuid = { version = "1.9.1", features = ["serde", "v4"] } yaml-rust = "0.4.5" -wiremock = "0.5.22" +wiremock = "0.6" wsl = "0.1.0" -tokio-tungstenite = { version = "0.20.1", features = [ +tokio-tungstenite = { version = "0.27.0", features = [ "rustls-tls-native-roots", ] } -tokio-rustls = "0.24.1" -hickory-resolver = "0.24.1" -http-serde = "1.1.3" +tokio-rustls = { version = "0.26.0", default-features = false } +hickory-resolver = "0.25.0" +http-serde = "2.1.1" hmac = "0.12.1" parking_lot = { version = "0.12.3", features = ["serde"] } memchr = "2.7.4" -brotli = "3.5.0" +brotli = "8.0.0" zstd = "0.13.1" zstd-safe = "7.1.0" # note: AWS dependencies should always use the same version -aws-sigv4 = "1.1.6" -aws-credential-types = "1.1.6" -aws-config = "1.1.6" -aws-types = "1.1.6" -aws-smithy-runtime-api = { version = "1.1.6", features = ["client"] } -aws-sdk-sso = "=1.39.0" # TODO: unpin when on Rust 1.78+ -aws-sdk-ssooidc = "=1.40.0" # TODO: unpin when on Rust 1.78+ -aws-sdk-sts = "=1.39.0" # TODO: unpin when on Rust 1.78+ +# note: hyper 1.0 update seems to mean this isn't true... +aws-sigv4 = "1.2.6" +aws-credential-types = "1.2.1" # XXX: This is the latest version +aws-config = { version = "1.5.5", default-features = false } +aws-types = "1.3.3" +aws-smithy-async = { version = "1.2.5", features = ["rt-tokio"] } +aws-smithy-http-client = { version = "1.0.1", default-features = false, features = [ + "default-client", + "rustls-ring", +] } +aws-smithy-runtime-api = { version = "1.7.3", features = ["client"] } sha1.workspace = true -tracing-serde = "0.1.3" +tracing-serde = "0.2.0" time = { version = "0.3.36", features = ["serde"] } -similar = { version = "2.5.0", features = ["inline"] } -console = "0.15.8" +similar.workspace = true +console = "0.16.0" bytesize = { version = "1.3.0", features = ["serde"] } ahash = "0.8.11" itoa = "1.0.9" ryu = "1.0.15" - -[target.'cfg(macos)'.dependencies] -uname = "0.1.1" +apollo-environment-detector = "0.1.0" +log = "0.4.22" +scopeguard = "1.2.0" +chrono = "0.4.41" [target.'cfg(unix)'.dependencies] uname = "0.1.1" -hyperlocal = { version = "0.8.0", default-features = false, features = [ +hyperlocal = { version = "0.9.1", default-features = false, features = [ "client", ] } - -[target.'cfg(target_os = "linux")'.dependencies] -tikv-jemallocator = "0.6.0" +tikv-jemallocator = { version = "0.6.0", features = ["profiling"], optional = true } +tikv-jemalloc-ctl = { version = "0.6.0", features = ["stats", "profiling"], optional = true } [dev-dependencies] -axum = { version = "0.6.20", features = [ - "headers", - "json", - "original-uri", - "ws", -] } +axum = { version = "0.8.1", features = ["http2", "ws"] } +axum-server = "0.7.1" +ctor = "0.5.0" ecdsa = { version = "0.16.9", features = ["signing", "pem", "pkcs8"] } -fred = { version = "7.1.2", features = ["enable-rustls", "mocks"] } +encoding_rs.workspace = true +fred = { version = "10.1.0", features = [ + "enable-rustls-ring", + "mocks", + "i-cluster", + "tcp-user-timeouts", +] } futures-test = "0.3.30" insta.workspace = true maplit = "1.0.2" @@ -305,35 +314,36 @@ memchr = { version = "2.7.4", default-features = false } mockall = "0.13.0" num-traits = "0.2.19" once_cell.workspace = true -opentelemetry-stdout = { version = "0.1.0", features = ["trace"] } -opentelemetry = { version = "0.20.0", features = ["testing"] } -opentelemetry-proto = { version = "0.5.0", features = [ +opentelemetry-stdout = { version = "0.5.0", features = ["trace"] } +opentelemetry = { version = "0.24.0", features = ["testing"] } +opentelemetry_sdk = { version = "0.24.1", features = ["testing"] } +opentelemetry-proto = { version = "0.7.0", features = [ "metrics", "trace", "gen-tonic-messages", "with-serde", ] } -opentelemetry-datadog = { version = "0.8.0", features = ["reqwest-client"] } +opentelemetry-datadog = { version = "0.12.0", features = ["reqwest-client"] } p256 = "0.13.2" -rand_core = "0.6.4" -reqwest = { version = "0.11.0", default-features = false, features = [ +pretty_assertions = "1.4.1" +reqwest = { version = "0.12.9", default-features = false, features = [ "json", "multipart", "stream", ] } -rhai = { version = "1.17.1", features = [ +# Pinned due to https://github.com/apollographql/router/pull/7679 +rhai = { version = "=1.21.0", features = [ "sync", "serde", "internals", "testing-environ", ] } -serial_test = { version = "3.1.1" } tempfile.workspace = true test-log = { version = "0.2.16", default-features = false, features = [ "trace", ] } -basic-toml = "0.1.9" tower-test = "0.4.0" +multer = { version = "3.1.0", features = ["json"] } # See note above in this file about `^tracing` packages which also applies to # these dev dependencies. @@ -341,33 +351,30 @@ tracing-subscriber = { version = "0.3.18", default-features = false, features = "env-filter", "fmt", ] } -tracing-opentelemetry = "0.21.0" -tracing-test = "0.2.5" +tracing-opentelemetry = "0.25.0" +tracing-test = "=0.2.5" +tracing-mock = "0.1.0-beta.1" walkdir = "2.5.0" -wiremock = "0.5.22" -libtest-mimic = "0.7.3" +wiremock = "0.6" +libtest-mimic = "0.8.0" +rstest = "0.26.0" [target.'cfg(target_os = "linux")'.dev-dependencies] rstack = { version = "0.3.3", features = ["dw"], default-features = false } [target.'cfg(unix)'.dev-dependencies] -hyperlocal = { version = "0.8.0", default-features = false, features = [ +hyperlocal = { version = "0.9.1", default-features = false, features = [ "client", "server", ] } [build-dependencies] -tonic-build = "0.9.2" -basic-toml = "0.1.9" +tonic-build = "0.12.3" serde_json.workspace = true [package.metadata.cargo-machete] ignored = [ - # Pinned to versions pre-MSRV bump. Remove when we update our rust-toolchain. - "rowan", - "aws-sdk-sso", - "aws-sdk-ssooidc", - "aws-sdk-sts", + "serde_regex", # Referenced only as a string in a macro ] [[test]] @@ -379,6 +386,23 @@ name = "samples" path = "tests/samples_tests.rs" harness = false +[[test]] +# This test is separated because it needs to run in a dedicated process. +# nextest does this by default, but using a separate [[test]] also makes it work with `cargo test`. +name = "compute_backpressure" +path = "tests/compute_backpressure.rs" + +[[test]] +name = "telemetry_resources" +path = "tests/telemetry_resource_tests.rs" +harness = false + +[[bin]] +name = "snapshot" +path = "src/test_harness/http_snapshot_main.rs" +test = false +required-features = ["snapshot"] + [[bench]] name = "huge_requests" harness = false @@ -389,3 +413,20 @@ harness = false [[example]] name = "planner" + +[lints.rust] + +# Compatibility with the 2024 edition, remove after switching to it: +boxed-slice-into-iter = "warn" +dependency-on-unit-never-type-fallback = "warn" +deprecated-safe-2024 = "warn" +impl-trait-overcaptures = "warn" +keyword-idents-2024 = "warn" +missing-unsafe-on-extern = "warn" +never-type-fallback-flowing-into-unsafe = "warn" +rust-2024-guarded-string-incompatible-syntax = "warn" +rust-2024-incompatible-pat = "warn" +rust-2024-prelude-collisions = "warn" +static-mut-refs = "warn" +unsafe-attr-outside-unsafe = "warn" +unsafe-op-in-unsafe-fn = "warn" diff --git a/apollo-router/README.md b/apollo-router/README.md index 96a32e23f1..caf30ae106 100644 --- a/apollo-router/README.md +++ b/apollo-router/README.md @@ -27,4 +27,4 @@ Most Apollo Router Core features can be defined using our [YAML configuration](h If you prefer to write customizations in Rust or need more advanced customizations, see our section on [native customizations](https://www.apollographql.com/docs/router/customizations/native) for information on how to use `apollo-router` as a Rust library. We also publish Rust-specific documentation on our [`apollo-router` crate docs](https://docs.rs/crate/apollo-router). -The minimum supported Rust version (MSRV) for this version of `apollo-router` is **1.76.0**. +The minimum supported Rust version (MSRV) for this version of `apollo-router` is **1.89.0**. diff --git a/apollo-router/benches/deeply_nested.rs b/apollo-router/benches/deeply_nested.rs index c99f2ce99d..f758717f2c 100644 --- a/apollo-router/benches/deeply_nested.rs +++ b/apollo-router/benches/deeply_nested.rs @@ -6,8 +6,12 @@ #![allow(clippy::single_char_add_str)] // don’t care use std::fmt::Write; +use std::time::Duration; -use futures::stream::StreamExt; +use apollo_router::services::router::body::RouterBody; +use http_body_util::BodyExt; +use http_body_util::Full; +use hyper_util::rt::TokioExecutor; use serde_json_bytes::Value; use tokio::io::AsyncBufReadExt; use tokio::process::Command; @@ -48,7 +52,7 @@ async fn main() { }}; } - let _subgraph = spawn_subgraph(); + let _subgraph = spawn_subgraph().await; let graphql_recursion_limit = 5_000; let _router = spawn_router(graphql_recursion_limit).await; @@ -61,25 +65,31 @@ async fn main() { assert!(request!(125).is_ok()); // JSON parser recursion limit in serde_json::Deserializier - assert!(request!(126) - .unwrap_err() - .contains("service 'subgraph_1' response was malformed: recursion limit exceeded")); + assert!( + request!(126) + .unwrap_err() + .contains("service 'subgraph_1' response was malformed: recursion limit exceeded") + ); // Stack overflow: the router process aborts before it can send a response // // As of commit 6e426ddf2fe9480210dfa74c85040db498c780a2 (Router 1.33.2+), // with Rust 1.72.0 on aarch64-apple-darwin, this happens starting at ~2400 nesting levels. - assert!(request!(3000) - .unwrap_err() - .contains("connection closed before message completed")); + assert!( + request!(3000) + .unwrap_err() + .contains("connection closed before message completed") + ); let graphql_recursion_limit = 500; let _router = spawn_router(graphql_recursion_limit).await; // GraphQL parser recursion limit in apollo-parser - assert!(request!(500) - .unwrap_err() - .contains("Error: parser recursion limit reached")); + assert!( + request!(500) + .unwrap_err() + .contains("Error: parser recursion limit reached") + ); } async fn spawn_router(graphql_recursion_limit: usize) -> tokio::process::Child { @@ -106,14 +116,14 @@ async fn spawn_router(graphql_recursion_limit: usize) -> tokio::process::Child { tokio::spawn(async move { let mut tx = Some(tx); while let Some(line) = router_stdout.next_line().await.unwrap() { - if line.contains("GraphQL endpoint exposed") { - if let Some(tx) = tx.take() { - let _ = tx.send(()); - // Don’t stop here, keep consuming output so the pipe doesn’t block on a full buffer - } + if line.contains("GraphQL endpoint exposed") + && let Some(tx) = tx.take() + { + let _ = tx.send(()); + // Don’t stop here, keep consuming output so the pipe doesn’t block on a full buffer } if VERBOSE { - println!("{}", line); + println!("{line}"); } } }); @@ -133,45 +143,74 @@ async fn graphql_client(nesting_level: usize) -> Result { let request = http::Request::post(format!("http://127.0.0.1:{SUPERGRAPH_PORT}")) .header("content-type", "application/json") .header("fibonacci-iterations", nesting_level) - .body(json.into()) + .body(json) .unwrap(); - let client = hyper::Client::new(); + let client = hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build_http(); let mut response = client.request(request).await.map_err(|e| e.to_string())?; - let body = hyper::body::to_bytes(response.body_mut()) + let body = response + .body_mut() + .collect() .await .map_err(|e| e.to_string())?; - let json = serde_json::from_slice::(&body).map_err(|e| e.to_string())?; - if let Some(errors) = json.get("errors") { - if !errors.is_null() { - return Err(errors.to_string()); - } + let json = serde_json::from_slice::(&body.to_bytes()).map_err(|e| e.to_string())?; + if let Some(errors) = json.get("errors") + && !errors.is_null() + { + return Err(errors.to_string()); } Ok(json.get("data").cloned().unwrap_or(Value::Null)) } -fn spawn_subgraph() -> ShutdownOnDrop { - let (tx, rx) = tokio::sync::oneshot::channel::<()>(); +async fn spawn_subgraph() -> ShutdownOnDrop { + let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(2); let shutdown_on_drop = ShutdownOnDrop(Some(tx)); - let service = hyper::service::make_service_fn(|_| async { - Ok::<_, hyper::Error>(hyper::service::service_fn(subgraph)) - }); - let server = hyper::Server::bind(&([127, 0, 0, 1], SUBGRAPH_PORT).into()) - .serve(service) - .with_graceful_shutdown(async { - let _ = rx.await; - }); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", SUBGRAPH_PORT)) + .await + .unwrap(); + let server = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()); + let graceful = hyper_util::server::graceful::GracefulShutdown::new(); + tokio::spawn(async move { - if let Err(e) = server.await { - eprintln!("server error: {}", e); + loop { + tokio::select! { + conn = listener.accept() => { + let (stream, peer_addr) = conn.unwrap(); + let stream = hyper_util::rt::TokioIo::new(Box::pin(stream)); + let conn = server + .serve_connection_with_upgrades(stream, hyper::service::service_fn(subgraph)); + let conn = graceful.watch(conn.into_owned()); + + tokio::spawn(async move { + if let Err(err) = conn.await { + eprintln!("connection error: {err}"); + } + eprintln!("connection dropped: {peer_addr}"); + }); + } + _ = rx.recv() => { + drop(listener); + break; + } + } + } + + tokio::select! { + _ = graceful.shutdown() => { + eprintln!("Gracefully shutdown!"); + }, + _ = tokio::time::sleep(Duration::from_secs(5)) => { + eprintln!("Waited 10 seconds for graceful shutdown, aborting..."); + } } }); + shutdown_on_drop } async fn subgraph( - request: http::Request, -) -> Result, hyper::Error> { + request: http::Request, +) -> Result, hyper::Error> { let nesting_level = request .headers() .get("fibonacci-iterations") @@ -180,14 +219,15 @@ async fn subgraph( .unwrap() .parse::() .unwrap(); - // Read the request body and prompty ignore it + // Read the request body and promptly ignore it request .into_body() - .for_each(|chunk| { - let _: &[u8] = &chunk.unwrap(); - async {} - }) - .await; + .collect() + .await + .unwrap() + .to_bytes() + .into_iter() + .for_each(|_chunk| {}); // Assume we got a GraphQL request with that many nested selection sets let mut json = String::from(r#"{"data":{"value":0"#); let mut a = 1; @@ -203,7 +243,11 @@ async fn subgraph( json.push_str("}"); } json.push_str("}}"); - let mut response = http::Response::new(hyper::Body::from(json)); + let mut response = http::Response::new( + Full::new(json.into()) + .map_err(|never| match never {}) + .boxed_unsync(), + ); let application_json = hyper::header::HeaderValue::from_static("application/json"); response .headers_mut() @@ -211,12 +255,12 @@ async fn subgraph( Ok(response) } -struct ShutdownOnDrop(Option>); +struct ShutdownOnDrop(Option>); impl Drop for ShutdownOnDrop { fn drop(&mut self) { if let Some(tx) = self.0.take() { - let _ = tx.send(()); + drop(tx.send(())); } } } diff --git a/apollo-router/benches/huge_requests.rs b/apollo-router/benches/huge_requests.rs index 0a5a4b23c0..6887dea740 100644 --- a/apollo-router/benches/huge_requests.rs +++ b/apollo-router/benches/huge_requests.rs @@ -1,6 +1,9 @@ use std::time::Duration; -use futures::stream::StreamExt; +use apollo_router::services::router::body::RouterBody; +use http_body_util::BodyExt; +use http_body_util::Full; +use hyper_util::rt::TokioExecutor; use tokio::io::AsyncBufReadExt; use tokio::process::Command; @@ -66,14 +69,14 @@ async fn one_request(string_variable_bytes: usize) { tokio::spawn(async move { let mut tx = Some(tx); while let Some(line) = router_stdout.next_line().await.unwrap() { - if line.contains("GraphQL endpoint exposed") { - if let Some(tx) = tx.take() { - let _ = tx.send(()); - // Don’t stop here, keep consuming output so the pipe doesn’t block on a full buffer - } + if line.contains("GraphQL endpoint exposed") + && let Some(tx) = tx.take() + { + let _ = tx.send(()); + // Don’t stop here, keep consuming output so the pipe doesn’t block on a full buffer } if VERBOSE { - println!("{}", line); + println!("{line}"); } } }); @@ -87,15 +90,17 @@ async fn one_request(string_variable_bytes: usize) { // Trigger graceful shutdown by signaling the router process, // which is a child of the heaptrack process. - assert!(Command::new("pkill") - .arg("-P") - .arg(child.id().unwrap().to_string()) - .arg("-f") - .arg(router_exe) - .status() - .await - .unwrap() - .success()); + assert!( + Command::new("pkill") + .arg("-P") + .arg(child.id().unwrap().to_string()) + .arg("-f") + .arg(router_exe) + .status() + .await + .unwrap() + .success() + ); assert!(child.wait().await.unwrap().success()); let output = Command::new("heaptrack_print") @@ -119,14 +124,15 @@ async fn graphql_client(string_variable_bytes: usize) -> Duration { }); let request = http::Request::post(format!("http://127.0.0.1:{SUPERGRAPH_PORT}")) .header("content-type", "application/json") - .body(serde_json::to_string(&graphql_request).unwrap().into()) + .body(serde_json::to_string(&graphql_request).unwrap()) .unwrap(); - let client = hyper::Client::new(); + let client = hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build_http(); let start_time = std::time::Instant::now(); let result = client.request(request).await; let latency = start_time.elapsed(); let mut response = result.unwrap(); - let body = hyper::body::to_bytes(response.body_mut()).await.unwrap(); + let body = response.body_mut().collect().await.unwrap(); + let body = body.to_bytes(); assert_eq!( String::from_utf8_lossy(&body), r#"{"data":{"upload":true}}"# @@ -139,47 +145,78 @@ async fn graphql_client(string_variable_bytes: usize) -> Duration { } async fn spawn_subgraph() -> ShutdownOnDrop { - let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(2); let shutdown_on_drop = ShutdownOnDrop(Some(tx)); - let service = hyper::service::make_service_fn(|_| async { - Ok::<_, hyper::Error>(hyper::service::service_fn(subgraph)) - }); - let server = hyper::Server::bind(&([127, 0, 0, 1], SUBGRAPH_PORT).into()) - .serve(service) - .with_graceful_shutdown(async { - let _ = rx.await; - }); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", SUBGRAPH_PORT)) + .await + .unwrap(); + let server = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()); + let graceful = hyper_util::server::graceful::GracefulShutdown::new(); + tokio::spawn(async move { - if let Err(e) = server.await { - eprintln!("server error: {}", e); + loop { + tokio::select! { + conn = listener.accept() => { + let (stream, peer_addr) = conn.unwrap(); + let stream = hyper_util::rt::TokioIo::new(Box::pin(stream)); + let conn = server + .serve_connection_with_upgrades(stream, hyper::service::service_fn(subgraph)); + let conn = graceful.watch(conn.into_owned()); + + tokio::spawn(async move { + if let Err(err) = conn.await { + eprintln!("connection error: {err}"); + } + eprintln!("connection dropped: {peer_addr}"); + }); + } + _ = rx.recv() => { + drop(listener); + break; + } + } + } + + tokio::select! { + _ = graceful.shutdown() => { + eprintln!("Gracefully shutdown!"); + }, + _ = tokio::time::sleep(Duration::from_secs(5)) => { + eprintln!("Waited 10 seconds for graceful shutdown, aborting..."); + } } }); + shutdown_on_drop } async fn subgraph( - request: http::Request, -) -> Result, hyper::Error> { - // Read the request body and prompty ignore it + request: http::Request, +) -> Result, hyper::Error> { + // Read the request body and promptly ignore it request .into_body() - .for_each(|chunk| { - let _: &[u8] = &chunk.unwrap(); - async {} - }) - .await; + .collect() + .await? + .to_bytes() + .iter() + .for_each(|_chunk| {}); // Assume we got a GraphQL request with `mutation Mutation { upload($some_string) }` let graphql_response = r#"{"data":{"upload":true}}"#; - Ok(http::Response::new(hyper::Body::from(graphql_response))) + Ok::<_, hyper::Error>(http::Response::new( + Full::new(graphql_response.as_bytes().into()) + .map_err(|never| match never {}) + .boxed_unsync(), + )) } -struct ShutdownOnDrop(Option>); +struct ShutdownOnDrop(Option>); impl Drop for ShutdownOnDrop { fn drop(&mut self) { if let Some(tx) = self.0.take() { - let _ = tx.send(()); + drop(tx.send(())); } } } diff --git a/apollo-router/build/main.rs b/apollo-router/build/main.rs index 763d894df0..b323c668bb 100644 --- a/apollo-router/build/main.rs +++ b/apollo-router/build/main.rs @@ -1,37 +1,5 @@ -use std::fs; -use std::path::PathBuf; - mod studio; fn main() -> Result<(), Box> { - let cargo_manifest: serde_json::Value = basic_toml::from_str( - &fs::read_to_string(PathBuf::from(&env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) - .expect("could not read Cargo.toml"), - ) - .expect("could not parse Cargo.toml"); - - let router_bridge = cargo_manifest - .get("dependencies") - .expect("Cargo.toml does not contain dependencies") - .as_object() - .expect("Cargo.toml dependencies key is not an object") - .get("router-bridge") - .expect("Cargo.toml dependencies does not have an entry for router-bridge"); - let router_bridge_version = router_bridge - .as_str() - .or_else(|| { - router_bridge - .as_object() - .and_then(|o| o.get("version")) - .and_then(|version| version.as_str()) - }) - .expect("router-bridge does not have a version"); - - let mut it = router_bridge_version.split('+'); - let _ = it.next(); - let fed_version = it.next().expect("invalid router-bridge version format"); - - println!("cargo:rustc-env=FEDERATION_VERSION={fed_version}"); - studio::main() } diff --git a/apollo-router/build/studio.rs b/apollo-router/build/studio.rs index 2975f439a4..a5ef7b9fc5 100644 --- a/apollo-router/build/studio.rs +++ b/apollo-router/build/studio.rs @@ -48,9 +48,10 @@ pub fn main() -> Result<(), Box> { "#[serde(serialize_with = \"crate::plugins::telemetry::apollo_exporter::serialize_timestamp\")]", ) .type_attribute(".", "#[derive(serde::Serialize)]") + .type_attribute(".", "#[allow(dead_code)]") .type_attribute("StatsContext", "#[derive(Eq, Hash)]") .emit_rerun_if_changed(false) - .compile(&[reports_out], &[&out_dir])?; + .compile_protos(&[reports_out], &[&out_dir])?; Ok(()) } diff --git a/apollo-router/examples/planner.rs b/apollo-router/examples/planner.rs index 43e53e9261..60fbb5eadc 100644 --- a/apollo-router/examples/planner.rs +++ b/apollo-router/examples/planner.rs @@ -32,7 +32,7 @@ impl Plugin for DoNotExecute { .map_request(|mut req: supergraph::Request| { let body = req.supergraph_request.body_mut(); body.query = body.query.as_ref().map(|query| { - let query_name = format!("query Query{} ", rand::random::()); + let query_name = format!("query Query{} ", rand::random::()); query.replacen("query ", query_name.as_str(), 1) }); req diff --git a/apollo-router/examples/router.yaml b/apollo-router/examples/router.yaml index 6936d57e32..b544aab718 100644 --- a/apollo-router/examples/router.yaml +++ b/apollo-router/examples/router.yaml @@ -1,8 +1,6 @@ supergraph: listen: 0.0.0.0:4100 introspection: true - query_planning: - experimental_parallelism: auto # or any number plugins: experimental.expose_query_plan: true apollo-test.do_not_execute: true diff --git a/apollo-router/feature_discussions.json b/apollo-router/feature_discussions.json index 446162650a..f0dea9a7f4 100644 --- a/apollo-router/feature_discussions.json +++ b/apollo-router/feature_discussions.json @@ -1,8 +1,6 @@ { "experimental": { - "experimental_retry": "https://github.com/apollographql/router/discussions/2241", - "experimental_response_trace_id": "https://github.com/apollographql/router/discussions/2147", - "experimental_when_header": "https://github.com/apollographql/router/discussions/1961" + "experimental_response_trace_id": "https://github.com/apollographql/router/discussions/2147" }, "preview": { "preview_entity_cache": "https://github.com/apollographql/router/discussions/4592" diff --git a/apollo-router/migrations/20250516144204_creation.down.sql b/apollo-router/migrations/20250516144204_creation.down.sql new file mode 100644 index 0000000000..baf9f6cff9 --- /dev/null +++ b/apollo-router/migrations/20250516144204_creation.down.sql @@ -0,0 +1,10 @@ +-- Add down migration script here +-- +SELECT + cron.unschedule ('delete-old-cache-entries'); + +DROP EXTENSION IF EXISTS pg_cron; + +DROP TABLE "invalidation_key"; + +DROP TABLE "cache"; diff --git a/apollo-router/migrations/20250516144204_creation.up.sql b/apollo-router/migrations/20250516144204_creation.up.sql new file mode 100644 index 0000000000..328eb297fb --- /dev/null +++ b/apollo-router/migrations/20250516144204_creation.up.sql @@ -0,0 +1,66 @@ +-- Add up migration script here +-- Add migration script here +CREATE EXTENSION IF NOT EXISTS pg_cron; +CREATE OR REPLACE FUNCTION create_index(table_name text, index_name text, column_name text) RETURNS void AS $$ +declare + l_count integer; +begin + select count(*) + into l_count + from pg_indexes + where schemaname = 'public' + and tablename = lower(table_name) + and indexname = lower(index_name); + + if l_count = 0 then + execute 'create index ' || index_name || ' on "' || table_name || '"(' || column_name || ')'; + end if; +end; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION create_unique_index(table_name text, index_name text, column_names text) RETURNS void AS $$ +declare + l_count integer; +begin + select count(*) + into l_count + from pg_indexes + where schemaname = 'public' + and tablename = lower(table_name) + and indexname = lower(index_name); + + if l_count = 0 then + execute 'create unique index ' || index_name || ' on "' || table_name || '"(' || array_to_string(string_to_array(column_names, ',') , ',') || ')'; + end if; +end; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION create_foreign_key(fk_name text, table_name_child text, table_name_parent text, column_name_child text, column_name_parent text) RETURNS void AS $$ +declare + l_count integer; +begin + select count(*) + into l_count + from information_schema.table_constraints as tc + where constraint_type = 'FOREIGN KEY' + and tc.table_name = lower(table_name_child) + and tc.constraint_name = lower(fk_name); + + if l_count = 0 then + execute 'alter table "' || table_name_child || '" ADD CONSTRAINT ' || fk_name || ' FOREIGN KEY(' || column_name_child || ') REFERENCES "' || table_name_parent || '"(' || column_name_parent || ')'; + end if; +end; +$$ LANGUAGE plpgsql; + + +CREATE UNLOGGED TABLE IF NOT EXISTS "invalidation_key" (cache_key_id BIGSERIAL NOT NULL, invalidation_key VARCHAR(255) NOT NULL, subgraph_name VARCHAR(255) NOT NULL, PRIMARY KEY(cache_key_id, invalidation_key, subgraph_name)); +CREATE UNLOGGED TABLE IF NOT EXISTS "cache" (id BIGSERIAL PRIMARY KEY, cache_key VARCHAR(1024) NOT NULL, data TEXT NOT NULL, control TEXT NOT NULL, expires_at TIMESTAMP WITH TIME ZONE NOT NULL); + +ALTER TABLE invalidation_key ADD CONSTRAINT FK_INVALIDATION_KEY_CACHE FOREIGN KEY (cache_key_id) references cache (id) ON delete cascade; +SELECT create_unique_index('cache', 'cache_key_idx', 'cache_key'); + +-- Remove expired data every hour +SELECT cron.schedule('delete-old-cache-entries', '0 * * * *', $$ + DELETE FROM cache + WHERE expires_at < NOW() +$$); diff --git a/apollo-router/rustfmt.toml b/apollo-router/rustfmt.toml new file mode 100644 index 0000000000..3501136812 --- /dev/null +++ b/apollo-router/rustfmt.toml @@ -0,0 +1 @@ +style_edition = "2024" diff --git a/apollo-router/src/ageing_priority_queue.rs b/apollo-router/src/ageing_priority_queue.rs new file mode 100644 index 0000000000..06f1b0fc5f --- /dev/null +++ b/apollo-router/src/ageing_priority_queue.rs @@ -0,0 +1,174 @@ +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +/// Items with higher priority value get handled sooner +#[allow(unused)] +#[derive(strum_macros::IntoStaticStr)] +pub(crate) enum Priority { + P1 = 1, + P2, + P3, + P4, + P5, + P6, + P7, + P8, +} + +#[derive(Debug, Clone)] +pub(crate) enum SendError { + QueueIsFull, + Disconnected, +} + +const INNER_QUEUES_COUNT: usize = Priority::P8 as usize - Priority::P1 as usize + 1; + +/// Indices start at 0 for highest priority +const fn index_from_priority(priority: Priority) -> usize { + Priority::P8 as usize - priority as usize +} + +/// Indices start at 0 for highest priority +const fn priority_from_index(idx: usize) -> Priority { + match idx { + 0 => Priority::P8, + 1 => Priority::P7, + 2 => Priority::P6, + 3 => Priority::P5, + 4 => Priority::P4, + 5 => Priority::P3, + 6 => Priority::P2, + 7 => Priority::P1, + _ => { + panic!("invalid index") + } + } +} + +const _: () = { + assert!(index_from_priority(Priority::P1) == 7); + assert!(index_from_priority(Priority::P8) == 0); + assert!(index_from_priority(priority_from_index(7)) == 7); + assert!(index_from_priority(priority_from_index(0)) == 0); +}; + +pub(crate) struct AgeingPriorityQueue +where + T: Send + 'static, +{ + /// Items in **lower** indices queues are handled sooner + inner_queues: + [(crossbeam_channel::Sender, crossbeam_channel::Receiver); INNER_QUEUES_COUNT], + pub(crate) queued_count: AtomicUsize, + capacity: usize, +} + +pub(crate) struct Receiver<'a, T> +where + T: Send + 'static, +{ + shared: &'a AgeingPriorityQueue, + select: crossbeam_channel::Select<'a>, +} + +impl AgeingPriorityQueue +where + T: Send + 'static, +{ + pub(crate) fn bounded(capacity: usize) -> Self { + Self { + // Using unbounded channels: callers must use `is_full` to implement backpressure + inner_queues: std::array::from_fn(|_| crossbeam_channel::unbounded()), + queued_count: AtomicUsize::new(0), + capacity, + } + } + + pub(crate) fn queued_count(&self) -> usize { + self.queued_count.load(Ordering::Relaxed) + } + + /// Panics if `priority` is not in `AVAILABLE_PRIORITIES` + pub(crate) fn send(&self, priority: Priority, message: T) -> Result<(), SendError> { + self.queued_count + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |previous_count| { + (previous_count < self.capacity).then_some(previous_count + 1) + }) + .map_err(|_| SendError::QueueIsFull)?; + let (inner_sender, _) = &self.inner_queues[index_from_priority(priority)]; + inner_sender + .send(message) + .map_err(|crossbeam_channel::SendError(_)| SendError::Disconnected) + } + + pub(crate) fn receiver(&self) -> Receiver<'_, T> { + let mut select = crossbeam_channel::Select::new_biased(); + for (_, inner_receiver) in &self.inner_queues { + select.recv(inner_receiver); + } + Receiver { + shared: self, + select, + } + } +} + +impl Receiver<'_, T> +where + T: Send + 'static, +{ + pub(crate) fn blocking_recv(&mut self) -> (T, Priority) { + // Because we used `Select::new_biased` above, + // `select()` will not shuffle receivers as it would with `Select::new` (for fairness) + // but instead will try each one in priority order. + let selected = self.select.select(); + let index = selected.index(); + let (_tx, rx) = &self.shared.inner_queues[index]; + // This `expect` can never panic because this channel can never be disconnected + // because its sender is right here in `_tx`. + let item = selected.recv(rx).expect("disconnected channel"); + self.shared.queued_count.fetch_sub(1, Ordering::Relaxed); + self.age(index); + (item, priority_from_index(index)) + } + + // Promote some messages from priorities lower (higher indices) than `message_consumed_at_index` + fn age(&self, message_consumed_at_index: usize) { + for window in self.shared.inner_queues[message_consumed_at_index..].windows(2) { + let [higher_priority, lower_priority] = window else { + panic!("expected windows of length 2") + }; + let (higher_priority_sender, _higher_priority_receiver) = higher_priority; + let (_, lower_priority_receiver) = lower_priority; + if let Ok(message) = lower_priority_receiver.try_recv() { + // This `expect` can never panic because this channel can never be disconnected + // because its sender is right here in `_higher_priority_receiver`. + higher_priority_sender + .send(message) + .expect("disconnected channel") + } + } + } +} + +#[test] +fn test_priorities() { + let queue = AgeingPriorityQueue::bounded(4); + assert_eq!(queue.queued_count(), 0); + queue.send(Priority::P1, "p1").unwrap(); + assert_eq!(queue.queued_count(), 1); + queue.send(Priority::P2, "p2").unwrap(); + queue.send(Priority::P3, "p3").unwrap(); + queue.send(Priority::P2, "p2 again").unwrap(); + assert_eq!(queue.queued_count(), 4); + // The queue is full now, this send() fails: + queue.send(Priority::P3, "p5").unwrap_err(); + assert_eq!(queue.queued_count(), 4); + + let mut receiver = queue.receiver(); + assert_eq!(receiver.blocking_recv().0, "p3"); + assert_eq!(receiver.blocking_recv().0, "p2"); + assert_eq!(receiver.blocking_recv().0, "p2 again"); + assert_eq!(receiver.blocking_recv().0, "p1"); + assert_eq!(queue.queued_count(), 0); +} diff --git a/apollo-router/src/apollo_studio_interop/mod.rs b/apollo-router/src/apollo_studio_interop/mod.rs index 4499a9b557..33d07e390d 100644 --- a/apollo-router/src/apollo_studio_interop/mod.rs +++ b/apollo-router/src/apollo_studio_interop/mod.rs @@ -1,13 +1,17 @@ //! Generation of usage reporting fields use std::cmp::Ordering; -use std::collections::hash_map::Entry; use std::collections::HashMap; use std::collections::HashSet; +use std::collections::hash_map::Entry; use std::fmt; use std::fmt::Write; use std::ops::AddAssign; use std::sync::Arc; +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::Schema; use apollo_compiler::ast::Argument; use apollo_compiler::ast::DirectiveList; use apollo_compiler::ast::OperationType; @@ -22,16 +26,13 @@ use apollo_compiler::executable::Selection; use apollo_compiler::executable::SelectionSet; use apollo_compiler::schema::ExtendedType; use apollo_compiler::validation::Valid; -use apollo_compiler::ExecutableDocument; -use apollo_compiler::Name; -use apollo_compiler::Node; -use apollo_compiler::Schema; -use router_bridge::planner::ReferencedFieldsForType; -use router_bridge::planner::UsageReporting; +use serde::Deserialize; use serde::Serialize; +use sha2::Digest; use crate::json_ext::Object; use crate::json_ext::Value as JsonValue; +use crate::plugins::telemetry::apollo_exporter::proto::reports::QueryMetadata; use crate::plugins::telemetry::config::ApolloSignatureNormalizationAlgorithm; use crate::spec::Fragments; use crate::spec::Query; @@ -162,23 +163,183 @@ impl AddAssign for AggregatedExtendedReferenceStats { } } -/// The result of the generate_usage_reporting function which contains a UsageReporting struct and -/// functions that allow comparison with another ComparableUsageReporting or UsageReporting object. -pub(crate) struct ComparableUsageReporting { - /// The UsageReporting fields - pub(crate) result: UsageReporting, +#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageReportingOperationDetails { + /// The operation name, or None if there is no operation name + operation_name: Option, + /// The normalized operation signature, or None if there is no valid signature + operation_signature: Option, + /// a list of all types and fields referenced in the query + #[serde(default)] + referenced_fields_by_type: HashMap, +} + +impl UsageReportingOperationDetails { + fn operation_name_or_default(&self) -> String { + self.operation_name.as_deref().unwrap_or("").to_string() + } + + fn operation_sig_or_default(&self) -> String { + self.operation_signature + .as_deref() + .unwrap_or("") + .to_string() + } + + fn get_signature_and_operation(&self) -> String { + let op_name = self.operation_name.as_deref().unwrap_or("-").to_string(); + let op_sig = self + .operation_signature + .as_deref() + .unwrap_or("") + .to_string(); + format!("# {op_name}\n{op_sig}") + } } -/// Generate a ComparableUsageReporting containing the stats_report_key (a normalized version of the operation signature) -/// and referenced fields of an operation. The document used to generate the signature and for the references can be -/// different to handle cases where the operation has been filtered, but we want to keep the same signature. +/// UsageReporting fields, that will be used to send stats to uplink/studio +#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) enum UsageReporting { + Operation(UsageReportingOperationDetails), + PersistedQuery { + operation_details: UsageReportingOperationDetails, + persisted_query_id: String, + }, + Error(String), +} + +impl UsageReporting { + pub(crate) fn with_pq_id(&self, persisted_query_id: String) -> UsageReporting { + match self { + UsageReporting::Operation(operation_details) + | UsageReporting::PersistedQuery { + operation_details, .. + } => UsageReporting::PersistedQuery { + operation_details: operation_details.clone(), + persisted_query_id, + }, + // PQ ID has no effect on errors + UsageReporting::Error { .. } => self.clone(), + } + } + + /// The `stats_report_key` is a unique identifier derived from schema and query. + /// Metric data sent to Studio must be aggregated via grouped key of + /// (`client_name`, `client_version`, `stats_report_key`). + /// For errors, the report key is of the form "## \n". + /// For operations not requested by PQ, the report key is of the form "# \n". + /// For operations requested by PQ, the report key is of the form "pq# ", where the + /// unique hash is a string that is consistent for the same PQ and operation, but unique if either + /// is different. The actual PQ ID, operation name, and operation signature is passed as metadata. + /// We need to do this so that we can group stats for each combination of PQ and operation. + /// Note that the combination of signature and operation name is sometimes referred to in code as + /// the "operation signature". + pub(crate) fn get_stats_report_key(&self) -> String { + match self { + UsageReporting::Operation(operation_details) => { + operation_details.get_signature_and_operation() + } + UsageReporting::Error(error_key) => { + format!("## {error_key}\n") + } + UsageReporting::PersistedQuery { + operation_details, + persisted_query_id, + .. + } => { + let string_to_hash = format!( + "{}\n{}\n{}", + persisted_query_id, + operation_details.operation_name_or_default(), + operation_details.operation_sig_or_default() + ); + format!("pq# {}", Self::hash_string(&string_to_hash)) + } + } + } + + pub(crate) fn get_operation_id(&self) -> String { + let string_to_hash = match self { + UsageReporting::Operation(operation_details) + | UsageReporting::PersistedQuery { + operation_details, .. + } => operation_details.get_signature_and_operation(), + UsageReporting::Error(error_key) => { + format!("# # {error_key}\n") + } + }; + Self::hash_string(&string_to_hash) + } + + pub(crate) fn get_operation_name(&self) -> String { + match self { + UsageReporting::Operation(operation_details) + | UsageReporting::PersistedQuery { + operation_details, .. + } => operation_details.operation_name_or_default(), + UsageReporting::Error(error_key) => format!("# {error_key}"), + } + } + + pub(crate) fn get_referenced_fields(&self) -> HashMap { + match self { + UsageReporting::Operation(operation_details) + | UsageReporting::PersistedQuery { + operation_details, .. + } => operation_details.referenced_fields_by_type.clone(), + UsageReporting::Error { .. } => HashMap::default(), + } + } + + pub(crate) fn get_query_metadata(&self) -> Option { + match self { + UsageReporting::PersistedQuery { + operation_details, + persisted_query_id, + .. + } => Some(QueryMetadata { + name: operation_details.operation_name_or_default(), + signature: operation_details.operation_sig_or_default(), + pq_id: persisted_query_id.clone(), + }), + // For now we only want to populate query metadata for PQ operations + UsageReporting::Operation { .. } | UsageReporting::Error { .. } => None, + } + } + + fn hash_string(string_to_hash: &String) -> String { + let mut hasher = sha1::Sha1::new(); + hasher.update(string_to_hash.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) + } +} + +/// A list of fields that will be resolved for a given type +#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReferencedFieldsForType { + /// names of the fields queried + #[serde(default)] + pub(crate) field_names: Vec, + /// whether the field is an interface + #[serde(default)] + pub(crate) is_interface: bool, +} + +/// Generate a UsageReporting containing the data required to generate a stats_report_key (either a normalized version of +/// the operation signature or an error key or a PQ ID) and referenced fields of an operation. The document used to +/// generate the signature and for the references can be different to handle cases where the operation has been filtered, +/// but we want to keep the same signature. pub(crate) fn generate_usage_reporting( signature_doc: &ExecutableDocument, references_doc: &ExecutableDocument, operation_name: &Option, schema: &Valid, normalization_algorithm: &ApolloSignatureNormalizationAlgorithm, -) -> ComparableUsageReporting { +) -> UsageReporting { let mut generator = UsageGenerator { signature_doc, references_doc, @@ -290,16 +451,16 @@ fn extract_enums_from_selection_set( add_enum_value_to_map(&enum_type.name, field_value, result_set); } // Otherwise if the response value is an object, add any enums from the field's selection set - else if let JsonValue::Object(value_object) = field_value { - if let Some(selection_set) = selection_set { - extract_enums_from_selection_set( - selection_set, - fragments, - schema, - value_object, - result_set, - ); - } + else if let JsonValue::Object(value_object) = field_value + && let Some(selection_set) = selection_set + { + extract_enums_from_selection_set( + selection_set, + fragments, + schema, + value_object, + result_set, + ); } } } @@ -343,16 +504,23 @@ struct UsageGenerator<'a> { } impl UsageGenerator<'_> { - fn generate_usage_reporting(&mut self) -> ComparableUsageReporting { - ComparableUsageReporting { - result: UsageReporting { - stats_report_key: self.generate_stats_report_key(), - referenced_fields_by_type: self.generate_apollo_reporting_refs(), - }, - } + fn generate_usage_reporting(&mut self) -> UsageReporting { + UsageReporting::Operation(UsageReportingOperationDetails { + operation_name: self.get_operation_name(), + operation_signature: self.generate_normalized_signature(), + referenced_fields_by_type: self.generate_apollo_reporting_refs(), + }) } - fn generate_stats_report_key(&mut self) -> String { + fn get_operation_name(&self) -> Option { + self.signature_doc + .operations + .get(self.operation_name.as_deref()) + .ok() + .and_then(|operation| operation.name.as_ref().map(|node| node.to_string())) + } + + fn generate_normalized_signature(&mut self) -> Option { self.fragments_map.clear(); match self @@ -361,10 +529,10 @@ impl UsageGenerator<'_> { .get(self.operation_name.as_deref()) .ok() { - None => "".to_string(), + None => None, Some(operation) => { self.extract_signature_fragments(&operation.selection_set); - self.format_operation_for_report(operation) + Some(self.format_operation_signature_for_report(operation)) } } } @@ -380,30 +548,24 @@ impl UsageGenerator<'_> { } Selection::FragmentSpread(fragment_node) => { let fragment_name = fragment_node.fragment_name.to_string(); - if let Entry::Vacant(e) = self.fragments_map.entry(fragment_name) { - if let Some(fragment) = self + if let Entry::Vacant(e) = self.fragments_map.entry(fragment_name) + && let Some(fragment) = self .signature_doc .fragments .get(&fragment_node.fragment_name) - { - e.insert(fragment.clone()); - self.extract_signature_fragments(&fragment.selection_set); - } + { + e.insert(fragment.clone()); + self.extract_signature_fragments(&fragment.selection_set); } } } } } - fn format_operation_for_report(&self, operation: &Node) -> String { - // The result in the name of the operation - let op_name = match &operation.name { - None => "-".into(), - Some(node) => node.to_string(), - }; - let mut result = format!("# {}\n", op_name); + fn format_operation_signature_for_report(&self, operation: &Node) -> String { + let mut result = String::new(); - // Followed by a sorted list of fragments + // The signature starts with a sorted list of fragments let mut sorted_fragments: Vec<_> = self.fragments_map.iter().collect(); sorted_fragments.sort_by_key(|&(k, _)| k); @@ -599,6 +761,7 @@ impl UsageGenerator<'_> { self.process_extended_refs_for_value(type_name.to_string(), &arg.value); } } + self.process_extended_refs_for_selection_set(&field.selection_set); } Selection::InlineFragment(fragment) => { self.process_extended_refs_for_selection_set(&fragment.selection_set); @@ -748,7 +911,7 @@ struct SignatureFormatterWithAlgorithm<'a> { normalization_algorithm: &'a ApolloSignatureNormalizationAlgorithm, } -impl<'a> fmt::Display for SignatureFormatterWithAlgorithm<'a> { +impl fmt::Display for SignatureFormatterWithAlgorithm<'_> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self.formatter { ApolloReportingSignatureFormatter::Operation(operation) => { @@ -787,7 +950,7 @@ fn format_operation( if !shorthand { f.write_str(operation.operation_type.name())?; if let Some(name) = &operation.name { - write!(f, " {}", name)?; + write!(f, " {name}")?; } // print variables sorted by name @@ -870,14 +1033,14 @@ fn format_selection_set( formatter: &ApolloReportingSignatureFormatter::Field(field), normalization_algorithm, }; - let field_str = format!("{}", formatter); + let field_str = format!("{formatter}"); f.write_str(&field_str)?; // We need to insert a space if this is not the last field and it ends in an alphanumeric character. let use_separator = field_str .chars() .last() - .map_or(false, |c| c.is_alphanumeric() || c == '_'); + .is_some_and(|c| c.is_alphanumeric() || c == '_'); if i < fields.len() - 1 && use_separator { f.write_str(" ")?; } @@ -926,10 +1089,10 @@ fn format_field( normalization_algorithm: &ApolloSignatureNormalizationAlgorithm, f: &mut fmt::Formatter, ) -> fmt::Result { - if is_enhanced(normalization_algorithm) { - if let Some(alias) = &field.alias { - write!(f, "{alias}:")?; - } + if is_enhanced(normalization_algorithm) + && let Some(alias) = &field.alias + { + write!(f, "{alias}:")?; } f.write_str(&field.name)?; @@ -947,7 +1110,7 @@ fn format_field( formatter: &ApolloReportingSignatureFormatter::Argument(a), normalization_algorithm, }; - format!("{}", formatter) + format!("{formatter}") }) .collect(); @@ -963,9 +1126,9 @@ fn format_field( || arg_string .chars() .last() - .map_or(true, |c| c.is_alphanumeric() || c == '_')) + .is_none_or(|c| c.is_alphanumeric() || c == '_')) { - write!(f, "{}", separator)?; + write!(f, "{separator}")?; } } f.write_str(")")?; @@ -982,7 +1145,7 @@ fn format_inline_fragment( f: &mut fmt::Formatter, ) -> fmt::Result { if let Some(type_name) = &inline_fragment.type_condition { - write!(f, "...on {}", type_name)?; + write!(f, "...on {type_name}")?; } else { f.write_str("...")?; } @@ -1041,7 +1204,7 @@ fn format_directives( formatter: &ApolloReportingSignatureFormatter::Argument(argument), normalization_algorithm, }; - write!(f, "{}", formatter)?; + write!(f, "{formatter}")?; } f.write_str(")")?; @@ -1066,7 +1229,7 @@ fn format_value( if index != 0 { f.write_str(",")?; } - write!(f, "{}:", name)?; + write!(f, "{name}:")?; format_value(val, normalization_algorithm, f)?; } f.write_str("}") @@ -1107,11 +1270,7 @@ fn get_arg_separator( + arg_strings.iter().map(|s| s.len()).sum::() + arg_strings.len() + ((arg_strings.len() - 1) * 2); - if original_line_length > 80 { - ' ' - } else { - ',' - } + if original_line_length > 80 { ' ' } else { ',' } } fn format_fragment_spread( diff --git a/apollo-router/src/apollo_studio_interop/snapshots/apollo_router__apollo_studio_interop__tests__enums_with_nested_query.snap b/apollo-router/src/apollo_studio_interop/snapshots/apollo_router__apollo_studio_interop__tests__enums_with_nested_query.snap new file mode 100644 index 0000000000..dbb48863d1 --- /dev/null +++ b/apollo-router/src/apollo_studio_interop/snapshots/apollo_router__apollo_studio_interop__tests__enums_with_nested_query.snap @@ -0,0 +1,8 @@ +--- +source: apollo-router/src/apollo_studio_interop/tests.rs +expression: "&generated" +--- +referenced_input_fields: {} +referenced_enums: + SomeEnum: + - SOME_VALUE_1 diff --git a/apollo-router/src/apollo_studio_interop/snapshots/apollo_router__apollo_studio_interop__tests__enums_with_nested_query_fragment.snap b/apollo-router/src/apollo_studio_interop/snapshots/apollo_router__apollo_studio_interop__tests__enums_with_nested_query_fragment.snap new file mode 100644 index 0000000000..dbb48863d1 --- /dev/null +++ b/apollo-router/src/apollo_studio_interop/snapshots/apollo_router__apollo_studio_interop__tests__enums_with_nested_query_fragment.snap @@ -0,0 +1,8 @@ +--- +source: apollo-router/src/apollo_studio_interop/tests.rs +expression: "&generated" +--- +referenced_input_fields: {} +referenced_enums: + SomeEnum: + - SOME_VALUE_1 diff --git a/apollo-router/src/apollo_studio_interop/snapshots/apollo_router__apollo_studio_interop__tests__extended_references_nested_query.snap b/apollo-router/src/apollo_studio_interop/snapshots/apollo_router__apollo_studio_interop__tests__extended_references_nested_query.snap new file mode 100644 index 0000000000..4154c1ac42 --- /dev/null +++ b/apollo-router/src/apollo_studio_interop/snapshots/apollo_router__apollo_studio_interop__tests__extended_references_nested_query.snap @@ -0,0 +1,56 @@ +--- +source: apollo-router/src/apollo_studio_interop/tests.rs +expression: "&generated" +--- +referenced_input_fields: + EnumInputType: + enumInput: + referenced: true + null_reference: false + undefined_reference: false + enumListInput: + referenced: true + null_reference: false + undefined_reference: false + enumListOfListInput: + referenced: true + null_reference: true + undefined_reference: true + nestedEnumType: + referenced: true + null_reference: false + undefined_reference: true + nullableEnumInput: + referenced: false + null_reference: false + undefined_reference: true + NestedEnumInputType: + someEnum: + referenced: true + null_reference: false + undefined_reference: false + someEnumList: + referenced: false + null_reference: false + undefined_reference: true + someEnumListOfList: + referenced: false + null_reference: false + undefined_reference: true + someNullableEnum: + referenced: true + null_reference: true + undefined_reference: false +referenced_enums: + SomeEnum: + - SOME_VALUE_1 + - SOME_VALUE_10 + - SOME_VALUE_11 + - SOME_VALUE_19 + - SOME_VALUE_20 + - SOME_VALUE_21 + - SOME_VALUE_31 + - SOME_VALUE_32 + - SOME_VALUE_33 + - SOME_VALUE_34 + - SOME_VALUE_8 diff --git a/apollo-router/src/apollo_studio_interop/testdata/enums_from_response_with_nested_query.graphql b/apollo-router/src/apollo_studio_interop/testdata/enums_from_response_with_nested_query.graphql new file mode 100644 index 0000000000..e30c389276 --- /dev/null +++ b/apollo-router/src/apollo_studio_interop/testdata/enums_from_response_with_nested_query.graphql @@ -0,0 +1,7 @@ +query QueryWithNestedQuery { + enumNestedQuery { + nestedQuery(input: SOME_VALUE_1) { + str + } + } +} \ No newline at end of file diff --git a/apollo-router/src/apollo_studio_interop/testdata/enums_from_response_with_nested_query_fragment.graphql b/apollo-router/src/apollo_studio_interop/testdata/enums_from_response_with_nested_query_fragment.graphql new file mode 100644 index 0000000000..b7303148aa --- /dev/null +++ b/apollo-router/src/apollo_studio_interop/testdata/enums_from_response_with_nested_query_fragment.graphql @@ -0,0 +1,11 @@ +fragment EnumNestedQueryFragment on Query { + enumNestedQuery { + nestedQuery(input: SOME_VALUE_1) { + str + } + } +} + +query QueryWithNestedQuery { + ...EnumNestedQueryFragment +} \ No newline at end of file diff --git a/apollo-router/src/apollo_studio_interop/testdata/schema_interop.graphql b/apollo-router/src/apollo_studio_interop/testdata/schema_interop.graphql index faafec9977..91a7911342 100644 --- a/apollo-router/src/apollo_studio_interop/testdata/schema_interop.graphql +++ b/apollo-router/src/apollo_studio_interop/testdata/schema_interop.graphql @@ -195,6 +195,18 @@ type EnumResponse nestedObject: NestedEnumResponse } +type EnumResponseWithNestedQuery + @join__type(graph: MAIN) +{ + nestedQuery(input: SomeEnum): StringResponse +} + +type StringResponse + @join__type(graph: MAIN) +{ + str: String +} + type Query @join__type(graph: MAIN) { @@ -222,6 +234,7 @@ type Query anotherEnumList: [AnotherEnum], ): BasicResponse enumResponseQuery: EnumResponse, + enumNestedQuery: EnumResponseWithNestedQuery } enum SomeEnum diff --git a/apollo-router/src/apollo_studio_interop/tests.rs b/apollo-router/src/apollo_studio_interop/tests.rs index 754951983c..08da7e1280 100644 --- a/apollo-router/src/apollo_studio_interop/tests.rs +++ b/apollo-router/src/apollo_studio_interop/tests.rs @@ -1,54 +1,11 @@ use apollo_compiler::Schema; -use router_bridge::planner::PlanOptions; -use router_bridge::planner::Planner; -use router_bridge::planner::QueryPlannerConfig; use test_log::test; use super::*; use crate::Configuration; -macro_rules! assert_generated_report { - ($actual:expr) => { - // Field names need sorting - let mut result = $actual.result; - for ty in result.referenced_fields_by_type.values_mut() { - ty.field_names.sort(); - } - - insta::with_settings!({sort_maps => true, snapshot_suffix => "report"}, { - insta::assert_yaml_snapshot!(result); - }); - }; -} - -// Generate the signature and referenced fields using router-bridge to confirm that the expected value we used is correct. -// We can remove this when we no longer use the bridge but should keep the rust implementation verifications. -macro_rules! assert_bridge_results { - ($schema_str:expr, $query_str:expr) => { - let planner = Planner::::new( - $schema_str.to_string(), - QueryPlannerConfig::default(), - ) - .await - .unwrap(); - let mut plan = planner - .plan($query_str.to_string(), None, PlanOptions::default()) - .await - .unwrap(); - - // Field names need sorting - for ty in plan.usage_reporting.referenced_fields_by_type.values_mut() { - ty.field_names.sort(); - } - - insta::with_settings!({sort_maps => true, snapshot_suffix => "bridge"}, { - insta::assert_yaml_snapshot!(plan.usage_reporting); - }); - }; -} - -fn assert_expected_signature(actual: &ComparableUsageReporting, expected_sig: &str) { - assert_eq!(actual.result.stats_report_key, expected_sig); +fn assert_expected_signature(actual: &UsageReporting, expected_sig: &str) { + assert_eq!(actual.get_stats_report_key(), expected_sig); } macro_rules! assert_extended_references { @@ -73,27 +30,12 @@ macro_rules! assert_enums_from_response { }; } -// Generate usage reporting with the same signature and refs doc, and with legacy normalization algorithm -fn generate_legacy( - doc: &ExecutableDocument, - operation_name: &Option, - schema: &Valid, -) -> ComparableUsageReporting { - generate_usage_reporting( - doc, - doc, - operation_name, - schema, - &ApolloSignatureNormalizationAlgorithm::Legacy, - ) -} - // Generate usage reporting with the same signature and refs doc, and with enhanced normalization algorithm fn generate_enhanced( doc: &ExecutableDocument, operation_name: &Option, schema: &Valid, -) -> ComparableUsageReporting { +) -> UsageReporting { generate_usage_reporting( doc, doc, @@ -140,413 +82,6 @@ fn enums_from_response( result } -#[test(tokio::test)] -async fn test_complex_query() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/complex_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("TransformedQuery".into()), &schema); - - assert_generated_report!(generated); - - // the router-bridge planner will throw errors on unused fragments/queries so we remove them here - let sanitised_query_str = include_str!("testdata/complex_query_sanitized.graphql"); - - assert_bridge_results!(schema_str, sanitised_query_str); -} - -#[test(tokio::test)] -async fn test_complex_references() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/complex_references_query.graphql"); - - let schema: Valid = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("Query".into()), &schema); - - assert_generated_report!(generated); - - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_basic_whitespace() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/named_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("MyQuery".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_anonymous_query() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/anonymous_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &None, &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_anonymous_mutation() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/anonymous_mutation.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &None, &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_anonymous_subscription() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str: &str = include_str!("testdata/subscription_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &None, &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_ordered_fields_and_variables() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/ordered_fields_and_variables_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("VariableScalarInputQuery".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_fragments() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/fragments_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("FragmentQuery".into()), &schema); - - assert_generated_report!(generated); - - // the router-bridge planner will throw errors on unused fragments/queries so we remove them here - let sanitised_query_str = r#"query FragmentQuery { - noInputQuery { - listOfBools - interfaceResponse { - sharedField - ... on InterfaceImplementation2 { - implementation2Field - } - ...bbbInterfaceFragment - ...aaaInterfaceFragment - ... { - ... on InterfaceImplementation1 { - implementation1Field - } - } - ... on InterfaceImplementation1 { - implementation1Field - } - } - unionResponse { - ... on UnionType2 { - unionType2Field - } - ... on UnionType1 { - unionType1Field - } - } - ...zzzFragment - ...aaaFragment - ...ZZZFragment - } - } - - fragment zzzFragment on EverythingResponse { - listOfInterfaces { - sharedField - } - } - - fragment ZZZFragment on EverythingResponse { - listOfInterfaces { - sharedField - } - } - - fragment aaaFragment on EverythingResponse { - listOfInterfaces { - sharedField - } - } - - fragment bbbInterfaceFragment on InterfaceImplementation2 { - sharedField - implementation2Field - } - - fragment aaaInterfaceFragment on InterfaceImplementation1 { - sharedField - }"#; - assert_bridge_results!(schema_str, sanitised_query_str); -} - -#[test(tokio::test)] -async fn test_directives() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/directives_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("DirectiveQuery".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_aliases() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/aliases_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("AliasQuery".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_inline_values() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/inline_values_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("InlineInputTypeQuery".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_root_type_fragment() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/root_type_fragment_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &None, &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_directive_arg_spacing() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/directive_arg_spacing_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &None, &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_operation_with_single_variable() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/operation_with_single_variable_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("QueryWithVar".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_operation_with_multiple_variables() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/operation_with_multiple_variables_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("QueryWithVars".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_field_arg_comma_or_space() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/field_arg_comma_or_space_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("QueryArgLength".into()), &schema); - - // enumInputQuery has a variable line length of 81, so it should be separated by spaces (which are converted from newlines - // in the original implementation). - // enumInputQuery has a variable line length of 80, so it should be separated by commas. - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_operation_arg_always_commas() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/operation_arg_always_commas_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("QueryArgLength".into()), &schema); - - // operation variables shouldn't ever be converted to spaces, since the line length check is only on field variables - // in the original implementation - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_comma_separator_always() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/comma_separator_always_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("QueryCommaEdgeCase".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_nested_fragments() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/nested_fragments_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("NestedFragmentQuery".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_mutation_space() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/mutation_space_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("Test_Mutation_Space".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_mutation_comma() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/mutation_comma_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("Test_Mutation_Comma".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_comma_lower_bound() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/comma_lower_bound_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("TestCommaLowerBound".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_comma_upper_bound() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/comma_upper_bound_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("TestCommaUpperBound".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - -#[test(tokio::test)] -async fn test_underscore() { - let schema_str = include_str!("testdata/schema_interop.graphql"); - let query_str = include_str!("testdata/underscore_query.graphql"); - - let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); - let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); - - let generated = generate_legacy(&doc, &Some("UnderscoreQuery".into()), &schema); - - assert_generated_report!(generated); - assert_bridge_results!(schema_str, query_str); -} - #[test(tokio::test)] async fn test_enhanced_uses_comma_always() { let schema_str = include_str!("testdata/schema_interop.graphql"); @@ -595,6 +130,7 @@ async fn test_enhanced_inline_input_object() { let doc = ExecutableDocument::parse(&schema, query_str, "query.graphql").unwrap(); let generated = generate_enhanced(&doc, &Some("InputObjectTypeQuery".into()), &schema); + #[allow(clippy::literal_string_with_formatting_args)] let expected_sig = "# InputObjectTypeQuery\nquery InputObjectTypeQuery{inputTypeQuery(input:{inputString:\"\",inputInt:0,inputBoolean:null,nestedType:{someFloat:0},enumInput:SOME_VALUE_1,nestedTypeList:[],listInput:[]}){enumResponse}}"; assert_expected_signature(&generated, expected_sig); } @@ -742,6 +278,25 @@ async fn test_extended_references_var_nested_type() { assert_extended_references!(&generated); } +#[test(tokio::test)] +async fn test_extended_references_nested_query() { + let schema_str = include_str!("testdata/schema_interop.graphql"); + let query_str = include_str!("testdata/extended_references_var_nested_type.graphql"); + let query_vars_str = include_str!("testdata/extended_references_var_nested_type.json"); + + let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); + let doc = ExecutableDocument::parse_and_validate(&schema, query_str, "query.graphql").unwrap(); + let vars: Object = serde_json::from_str(query_vars_str).unwrap(); + + let generated = generate_extended_refs( + &doc, + Some("NestedInputTypeVarsQuery".into()), + &schema, + Some(&vars), + ); + assert_extended_references!(&generated); +} + #[test(tokio::test)] async fn test_enums_from_response_complex_response_type() { let schema_str = include_str!("testdata/schema_interop.graphql"); @@ -764,3 +319,249 @@ async fn test_enums_from_response_fragments() { let generated = enums_from_response(query_str, op_name, schema_str, response_str); assert_enums_from_response!(&generated); } + +#[test] +fn apollo_operation_id_hash() { + let usage_reporting = UsageReporting::Operation(UsageReportingOperationDetails { + operation_name: Some("IgnitionMeQuery".to_string()), + operation_signature: Some("query IgnitionMeQuery{me{id}}".to_string()), + referenced_fields_by_type: HashMap::new(), + }); + + assert_eq!( + "d1554552698157b05c2a462827fb4367a4548ee5", + usage_reporting.get_operation_id() + ); +} + +// The Apollo operation ID hash for these errors is based on a slightly different string. E.g. instead of hashing +// "## GraphQLValidationFailure\n" we should hash "# # GraphQLValidationFailure". +#[test] +fn apollo_error_operation_id_hash() { + assert_eq!( + "ea4f152696abedca148b016d72df48842b713697", + UsageReporting::Error("GraphQLValidationFailure".into()).get_operation_id() + ); + assert_eq!( + "3f410834f13153f401ffe73f7e454aa500d10bf7", + UsageReporting::Error("GraphQLParseFailure".into()).get_operation_id() + ); + assert_eq!( + "7486043da2085fed407d942508a572ef88dc8120", + UsageReporting::Error("GraphQLUnknownOperationName".into()).get_operation_id() + ); +} + +#[test] +fn test_get_stats_report_key_and_metadata() { + let usage_reporting_for_errors = UsageReporting::Error("GraphQLParseFailure".into()); + assert_eq!( + "## GraphQLParseFailure\n", + usage_reporting_for_errors.get_stats_report_key() + ); + assert_eq!(None, usage_reporting_for_errors.get_query_metadata()); + + let usage_reporting_for_pq = UsageReporting::PersistedQuery { + operation_details: UsageReportingOperationDetails { + operation_name: Some("SomeQuery".into()), + operation_signature: Some("query SomeQuery{thing{id}}".into()), + referenced_fields_by_type: HashMap::new(), + }, + persisted_query_id: "SomePqId".into(), + }; + assert_eq!( + "pq# ", + usage_reporting_for_pq + .get_stats_report_key() + .chars() + .take(4) + .collect::() + ); + assert_eq!( + Some(QueryMetadata { + name: "SomeQuery".into(), + signature: "query SomeQuery{thing{id}}".into(), + pq_id: "SomePqId".into() + }), + usage_reporting_for_pq.get_query_metadata() + ); + + let usage_reporting_for_named_operation = + UsageReporting::Operation(UsageReportingOperationDetails { + operation_name: Some("SomeQuery".into()), + operation_signature: Some("query SomeQuery{thing{id}}".into()), + referenced_fields_by_type: HashMap::new(), + }); + assert_eq!( + "# SomeQuery\nquery SomeQuery{thing{id}}", + usage_reporting_for_named_operation.get_stats_report_key() + ); + assert_eq!( + None, + usage_reporting_for_named_operation.get_query_metadata() + ); + + let usage_reporting_for_unnamed_operation = + UsageReporting::Operation(UsageReportingOperationDetails { + operation_name: None, + operation_signature: Some("query{thing{id}}".into()), + referenced_fields_by_type: HashMap::new(), + }); + assert_eq!( + "# -\nquery{thing{id}}", + usage_reporting_for_unnamed_operation.get_stats_report_key() + ); + assert_eq!( + None, + usage_reporting_for_unnamed_operation.get_query_metadata() + ); +} + +// The stats report key should be distinct per combination of operation name/signature and PQ ID. All of these +// details are stored in metadata, so it's not important what the actual stats report key is, it's only important +// that they are distinct for each combination, but identical for the same operation name/signature and PQ ID. +#[test] +fn test_get_stats_report_key_uses_distinct_keys_for_pq_operations() { + let usage_reporting_op_1_pq_1 = UsageReporting::PersistedQuery { + operation_details: UsageReportingOperationDetails { + operation_name: Some("SomeQuery1".into()), + operation_signature: Some("query SomeQuery1{thing{id}}".into()), + referenced_fields_by_type: HashMap::new(), + }, + persisted_query_id: "SomePqId1".into(), + }; + let usage_reporting_op_1_pq_1_again = UsageReporting::PersistedQuery { + operation_details: UsageReportingOperationDetails { + operation_name: Some("SomeQuery1".into()), + operation_signature: Some("query SomeQuery1{thing{id}}".into()), + referenced_fields_by_type: HashMap::new(), + }, + persisted_query_id: "SomePqId1".into(), + }; + assert_eq!( + usage_reporting_op_1_pq_1.get_stats_report_key(), + usage_reporting_op_1_pq_1_again.get_stats_report_key() + ); + + let usage_reporting_op_1_pq_1_different_name = UsageReporting::PersistedQuery { + operation_details: UsageReportingOperationDetails { + operation_name: Some("DifferentName".into()), + operation_signature: Some("query SomeQuery1{thing{id}}".into()), + referenced_fields_by_type: HashMap::new(), + }, + persisted_query_id: "SomePqId1".into(), + }; + let usage_reporting_op_2_pq_1 = UsageReporting::PersistedQuery { + operation_details: UsageReportingOperationDetails { + operation_name: Some("SomeQuery2".into()), + operation_signature: Some("query SomeQuery2{thing{id}}".into()), + referenced_fields_by_type: HashMap::new(), + }, + persisted_query_id: "SomePqId1".into(), + }; + let usage_reporting_op_1_pq_2 = UsageReporting::PersistedQuery { + operation_details: UsageReportingOperationDetails { + operation_name: Some("SomeQuery1".into()), + operation_signature: Some("query SomeQuery1{thing{id}}".into()), + referenced_fields_by_type: HashMap::new(), + }, + persisted_query_id: "SomePqId2".into(), + }; + let usage_reporting_op_2_pq_2 = UsageReporting::PersistedQuery { + operation_details: UsageReportingOperationDetails { + operation_name: Some("SomeQuery2".into()), + operation_signature: Some("query SomeQuery2{thing{id}}".into()), + referenced_fields_by_type: HashMap::new(), + }, + persisted_query_id: "SomePqId2".into(), + }; + let usage_reporting_op_1_no_pq = UsageReporting::Operation(UsageReportingOperationDetails { + operation_name: Some("SomeQuery1".into()), + operation_signature: Some("query SomeQuery1{thing{id}}".into()), + referenced_fields_by_type: HashMap::new(), + }); + let usage_reporting_op_2_no_pq = UsageReporting::Operation(UsageReportingOperationDetails { + operation_name: Some("SomeQuery2".into()), + operation_signature: Some("query SomeQuery2{thing{id}}".into()), + referenced_fields_by_type: HashMap::new(), + }); + + let stats_report_keys = [ + usage_reporting_op_1_pq_1, + usage_reporting_op_1_pq_1_different_name, + usage_reporting_op_2_pq_1, + usage_reporting_op_1_pq_2, + usage_reporting_op_2_pq_2, + usage_reporting_op_1_no_pq, + usage_reporting_op_2_no_pq, + ] + .map(|x| x.get_stats_report_key()); + + // Check that all the stats report keys are distinct + for i in 0..stats_report_keys.len() { + for j in (i + 1)..stats_report_keys.len() { + assert_ne!( + stats_report_keys[i], stats_report_keys[j], + "Stats report keys should be distinct: {} == {}", + stats_report_keys[i], stats_report_keys[j] + ); + } + } +} + +#[test] +fn test_get_operation_name() { + let usage_reporting_for_errors = UsageReporting::Error("GraphQLParseFailure".into()); + assert_eq!( + "# GraphQLParseFailure", + usage_reporting_for_errors.get_operation_name() + ); + + let usage_reporting_for_named_operation = + UsageReporting::Operation(UsageReportingOperationDetails { + operation_name: Some("SomeQuery".into()), + operation_signature: Some("query SomeQuery{thing{id}}".into()), + referenced_fields_by_type: HashMap::new(), + }); + assert_eq!( + "SomeQuery", + usage_reporting_for_named_operation.get_operation_name() + ); + + let usage_reporting_for_unnamed_operation = + UsageReporting::Operation(UsageReportingOperationDetails { + operation_name: None, + operation_signature: Some("query{thing{id}}".into()), + referenced_fields_by_type: HashMap::new(), + }); + assert_eq!( + "", + usage_reporting_for_unnamed_operation.get_operation_name() + ); +} + +#[test(tokio::test)] +async fn test_enums_with_nested_query_fragment() { + let schema_str = include_str!("testdata/schema_interop.graphql"); + let query_str = include_str!("testdata/enums_from_response_with_nested_query_fragment.graphql"); + + let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); + let doc = ExecutableDocument::parse_and_validate(&schema, query_str, "query.graphql").unwrap(); + + let generated = + generate_extended_refs(&doc, Some("QueryWithNestedQuery".into()), &schema, None); + assert_extended_references!(&generated); +} + +#[test(tokio::test)] +async fn test_enums_with_nested_query() { + let schema_str = include_str!("testdata/schema_interop.graphql"); + let query_str = include_str!("testdata/enums_from_response_with_nested_query.graphql"); + + let schema = Schema::parse_and_validate(schema_str, "schema.graphql").unwrap(); + let doc = ExecutableDocument::parse_and_validate(&schema, query_str, "query.graphql").unwrap(); + + let generated = + generate_extended_refs(&doc, Some("QueryWithNestedQuery".into()), &schema, None); + assert_extended_references!(&generated); +} diff --git a/apollo-router/src/axum_factory/axum_http_server_factory.rs b/apollo-router/src/axum_factory/axum_http_server_factory.rs index 391424f0b1..7dc5ecccff 100644 --- a/apollo-router/src/axum_factory/axum_http_server_factory.rs +++ b/apollo-router/src/axum_factory/axum_http_server_factory.rs @@ -1,14 +1,12 @@ //! Axum http server factory. Axum provides routing capability on top of Hyper HTTP. use std::fmt::Display; use std::pin::Pin; -use std::sync::atomic::AtomicBool; +use std::sync::Arc; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; -use std::sync::Arc; -use std::time::Duration; use std::time::Instant; -use axum::error_handling::HandleErrorLayer; +use axum::Router; use axum::extract::Extension; use axum::extract::State; use axum::http::StatusCode; @@ -16,41 +14,38 @@ use axum::middleware; use axum::middleware::Next; use axum::response::*; use axum::routing::get; -use axum::Router; use futures::channel::oneshot; use futures::future::join_all; use futures::prelude::*; -use http::header::ACCEPT_ENCODING; -use http::header::CONTENT_ENCODING; use http::HeaderValue; use http::Request; -use http_body::combinators::UnsyncBoxBody; -use hyper::server::conn::Http; -use hyper::Body; +use http::header::ACCEPT_ENCODING; +use http::header::CONTENT_ENCODING; use itertools::Itertools; use multimap::MultiMap; -use serde::Serialize; +use once_cell::sync::Lazy; +use opentelemetry::metrics::MeterProvider as _; +use opentelemetry::metrics::ObservableGauge; +use regex::Regex; use serde_json::json; #[cfg(unix)] use tokio::net::UnixListener; use tokio::sync::mpsc; use tokio_rustls::TlsAcceptor; -use tower::service_fn; -use tower::BoxError; -use tower::ServiceBuilder; use tower::ServiceExt; -use tower_http::decompression::DecompressionBody; +use tower::layer::layer_fn; use tower_http::trace::TraceLayer; -use tracing::instrument::WithSubscriber; use tracing::Instrument; +use tracing::instrument::WithSubscriber; +use super::ENDPOINT_CALLBACK; +use super::ListenAddrAndRouter; +use super::listeners::ListenersAndRouters; use super::listeners::ensure_endpoints_consistency; use super::listeners::ensure_listenaddrs_consistency; use super::listeners::extra_endpoints; -use super::listeners::ListenersAndRouters; use super::utils::PropagatingMakeSpan; -use super::ListenAddrAndRouter; -use super::ENDPOINT_CALLBACK; +use crate::Context; use crate::axum_factory::compression::Compressor; use crate::axum_factory::listeners::get_extra_listeners; use crate::axum_factory::listeners::serve_router_on_listen_addr; @@ -60,140 +55,86 @@ use crate::graphql; use crate::http_server_factory::HttpServerFactory; use crate::http_server_factory::HttpServerHandle; use crate::http_server_factory::Listener; +use crate::metrics::meter_provider; use crate::plugins::telemetry::SpanMode; use crate::router::ApolloRouterError; use crate::router_factory::Endpoint; use crate::router_factory::RouterFactory; -use crate::services::http::service::BodyStream; use crate::services::router; -use crate::uplink::license_enforcement::LicenseState; use crate::uplink::license_enforcement::APOLLO_ROUTER_LICENSE_EXPIRED; use crate::uplink::license_enforcement::LICENSE_EXPIRED_SHORT_MESSAGE; -use crate::Context; +use crate::uplink::license_enforcement::LicenseState; static ACTIVE_SESSION_COUNT: AtomicU64 = AtomicU64::new(0); +static BARE_WILDCARD_PATH_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"^/\{\*[^/]+\}$").expect("this regex to check wildcard paths is valid") +}); + +fn session_count_instrument() -> ObservableGauge { + let meter = meter_provider().meter("apollo/router"); + meter + .u64_observable_gauge("apollo.router.session.count.active") + .with_description("Amount of in-flight sessions") + .with_callback(|gauge| { + gauge.observe(ACTIVE_SESSION_COUNT.load(Ordering::Relaxed), &[]); + }) + .init() +} -struct SessionCountGuard; +#[cfg(all(feature = "global-allocator", not(feature = "dhat-heap"), unix))] +fn jemalloc_metrics_instruments() -> (tokio::task::JoinHandle<()>, Vec>) { + use crate::axum_factory::metrics::jemalloc; + + ( + jemalloc::start_epoch_advance_loop(), + vec![ + jemalloc::create_active_gauge(), + jemalloc::create_allocated_gauge(), + jemalloc::create_metadata_gauge(), + jemalloc::create_mapped_gauge(), + jemalloc::create_resident_gauge(), + jemalloc::create_retained_gauge(), + ], + ) +} -impl SessionCountGuard { +struct ActiveSessionCountGuard; + +impl ActiveSessionCountGuard { fn start() -> Self { - let session_count = ACTIVE_SESSION_COUNT.fetch_add(1, Ordering::Acquire) + 1; - tracing::info!(value.apollo_router_session_count_active = session_count,); + ACTIVE_SESSION_COUNT.fetch_add(1, Ordering::Acquire); Self } } -impl Drop for SessionCountGuard { +impl Drop for ActiveSessionCountGuard { fn drop(&mut self) { - let session_count = ACTIVE_SESSION_COUNT.fetch_sub(1, Ordering::Acquire) - 1; - tracing::info!(value.apollo_router_session_count_active = session_count,); + ACTIVE_SESSION_COUNT.fetch_sub(1, Ordering::Acquire); } } /// A basic http server using Axum. /// Uses streaming as primary method of response. #[derive(Debug, Default)] -pub(crate) struct AxumHttpServerFactory { - live: Arc, - ready: Arc, -} +pub(crate) struct AxumHttpServerFactory {} impl AxumHttpServerFactory { pub(crate) fn new() -> Self { - Self { - ..Default::default() - } + Self {} } } -#[derive(Debug, Serialize)] -#[serde(rename_all = "UPPERCASE")] -#[allow(dead_code)] -enum HealthStatus { - Up, - Down, -} - -#[derive(Debug, Serialize)] -struct Health { - status: HealthStatus, -} - pub(crate) fn make_axum_router( - live: Arc, - ready: Arc, service_factory: RF, configuration: &Configuration, mut endpoints: MultiMap, - license: LicenseState, + license: Arc, ) -> Result where RF: RouterFactory, { ensure_listenaddrs_consistency(configuration, &endpoints)?; - if configuration.health_check.enabled { - tracing::info!( - "Health check exposed at {}{}", - configuration.health_check.listen, - configuration.health_check.path - ); - endpoints.insert( - configuration.health_check.listen.clone(), - Endpoint::from_router_service( - configuration.health_check.path.clone(), - service_fn(move |req: router::Request| { - let mut status_code = StatusCode::OK; - let health = if let Some(query) = req.router_request.uri().query() { - let query_upper = query.to_ascii_uppercase(); - // Could be more precise, but sloppy match is fine for this use case - if query_upper.starts_with("READY") { - let status = if ready.load(Ordering::SeqCst) { - HealthStatus::Up - } else { - // It's hard to get k8s to parse payloads. Especially since we - // can't install curl or jq into our docker images because of CVEs. - // So, compromise, k8s will interpret this as probe fail. - status_code = StatusCode::SERVICE_UNAVAILABLE; - HealthStatus::Down - }; - Health { status } - } else if query_upper.starts_with("LIVE") { - let status = if live.load(Ordering::SeqCst) { - HealthStatus::Up - } else { - // It's hard to get k8s to parse payloads. Especially since we - // can't install curl or jq into our docker images because of CVEs. - // So, compromise, k8s will interpret this as probe fail. - status_code = StatusCode::SERVICE_UNAVAILABLE; - HealthStatus::Down - }; - Health { status } - } else { - Health { - status: HealthStatus::Up, - } - } - } else { - Health { - status: HealthStatus::Up, - } - }; - tracing::trace!(?health, request = ?req.router_request, "health check"); - async move { - Ok(router::Response { - response: http::Response::builder().status(status_code).body::( - serde_json::to_vec(&health).map_err(BoxError::from)?.into(), - )?, - context: req.context, - }) - } - }) - .boxed(), - ), - ); - } - ensure_endpoints_consistency(configuration, &endpoints)?; let mut main_endpoint = main_endpoint( @@ -229,23 +170,16 @@ impl HttpServerFactory for AxumHttpServerFactory { mut main_listener: Option, previous_listeners: Vec<(ListenAddr, Listener)>, extra_endpoints: MultiMap, - license: LicenseState, + license: Arc, all_connections_stopped_sender: mpsc::Sender<()>, ) -> Self::Future where RF: RouterFactory, { - let live = self.live.clone(); - let ready = self.ready.clone(); Box::pin(async move { - let all_routers = make_axum_router( - live.clone(), - ready.clone(), - service_factory, - &configuration, - extra_endpoints, - license, - )?; + let pipeline_ref = service_factory.pipeline_ref().clone(); + let all_routers = + make_axum_router(service_factory, &configuration, extra_endpoints, license)?; // serve main router @@ -300,25 +234,16 @@ impl HttpServerFactory for AxumHttpServerFactory { let actual_main_listen_address = main_listener .local_addr() .map_err(ApolloRouterError::ServerCreationError)?; - let mut http_config = Http::new(); - http_config.http1_keep_alive(true); - http_config.http1_header_read_timeout(Duration::from_secs(10)); - - #[cfg(feature = "hyper_header_limits")] - if let Some(max_headers) = configuration.limits.http1_max_request_headers { - http_config.http1_max_headers(max_headers); - } - - if let Some(max_buf_size) = configuration.limits.http1_max_request_buf_size { - http_config.max_buf_size(max_buf_size.as_u64() as usize); - } let (main_server, main_shutdown_sender) = serve_router_on_listen_addr( - main_listener, + pipeline_ref.clone(), actual_main_listen_address.clone(), + main_listener, + configuration.supergraph.connection_shutdown_timeout, all_routers.main.1, - true, - http_config.clone(), + configuration.limits.http1_max_request_headers, + configuration.limits.http1_max_request_buf_size, + configuration.server.http.header_read_timeout, all_connections_stopped_sender.clone(), ); @@ -354,11 +279,14 @@ impl HttpServerFactory for AxumHttpServerFactory { .into_iter() .map(|((listen_addr, listener), router)| { let (server, shutdown_sender) = serve_router_on_listen_addr( - listener, + pipeline_ref.clone(), listen_addr.clone(), + listener, + configuration.supergraph.connection_shutdown_timeout, router, - false, - http_config.clone(), + configuration.limits.http1_max_request_headers, + configuration.limits.http1_max_request_buf_size, + configuration.server.http.header_read_timeout, all_connections_stopped_sender.clone(), ); ( @@ -419,14 +347,6 @@ impl HttpServerFactory for AxumHttpServerFactory { )) }) } - - fn live(&self, live: bool) { - self.live.store(live, Ordering::SeqCst); - } - - fn ready(&self, ready: bool) { - self.ready.store(ready, Ordering::SeqCst); - } } // This function can be removed once https://github.com/apollographql/router/issues/4083 is done. @@ -445,15 +365,11 @@ pub(crate) fn span_mode(configuration: &Configuration) -> SpanMode { .unwrap_or_default() } -async fn decompression_error(_error: BoxError) -> axum::response::Response { - (StatusCode::BAD_REQUEST, "cannot decompress request body").into_response() -} - fn main_endpoint( service_factory: RF, configuration: &Configuration, endpoints_on_main_listener: Vec, - license: LicenseState, + license: Arc, ) -> Result where RF: RouterFactory, @@ -463,18 +379,19 @@ where })?; let span_mode = span_mode(configuration); - let decompression = ServiceBuilder::new() - .layer(HandleErrorLayer::<_, ()>::new(decompression_error)) - .layer( - tower_http::decompression::RequestDecompressionLayer::new() - .br(true) - .gzip(true) - .deflate(true), - ); + // XXX(@goto-bus-stop): in hyper 0.x, we required a HandleErrorLayer around this, + // to turn errors from decompression into an axum error response. Now, + // `RequestDecompressionLayer` appears to preserve(?) the error type from the inner service? + // So maybe we don't need this anymore? But I don't understand what happens to an error *caused + // by decompression* (such as an invalid compressed data stream). + let decompression = tower_http::decompression::RequestDecompressionLayer::new() + .br(true) + .gzip(true) + .deflate(true); let mut main_route = main_router::(configuration) .layer(decompression) .layer(middleware::from_fn_with_state( - (license, Instant::now(), Arc::new(AtomicU64::new(0))), + (license.clone(), Instant::now(), Arc::new(AtomicU64::new(0))), license_handler, )) .layer(Extension(service_factory)) @@ -505,7 +422,7 @@ where Ok(ListenAddrAndRouter(listener, route)) } -async fn metrics_handler(request: Request, next: Next) -> Response { +async fn metrics_handler(request: Request, next: Next) -> Response { let resp = next.run(request).await; u64_counter!( "apollo.router.operations", @@ -516,25 +433,18 @@ async fn metrics_handler(request: Request, next: Next) -> Response { resp } -async fn license_handler( - State((license, start, delta)): State<(LicenseState, Instant, Arc)>, - request: Request, - next: Next, +async fn license_handler( + State((license, start, delta)): State<(Arc, Instant, Arc)>, + request: Request, + next: Next, ) -> Response { if matches!( - license, - LicenseState::LicensedHalt | LicenseState::LicensedWarn + &*license, + LicenseState::LicensedHalt { limits: _ } | LicenseState::LicensedWarn { limits: _ } ) { - u64_counter!( - "apollo_router_http_requests_total", - "Total number of HTTP requests made.", - 1, - status = StatusCode::INTERNAL_SERVER_ERROR.as_u16() as i64, - error = LICENSE_EXPIRED_SHORT_MESSAGE - ); // This will rate limit logs about license to 1 a second. // The way it works is storing the delta in seconds from a starting instant. - // If the delta is over one second from the last time we logged then try and do a compare_exchange and if successfull log. + // If the delta is over one second from the last time we logged then try and do a compare_exchange and if successful log. // If not successful some other thread will have logged. let last_elapsed_seconds = delta.load(Ordering::SeqCst); let elapsed_seconds = start.elapsed().as_secs(); @@ -555,90 +465,66 @@ async fn license_handler( } } - if matches!(license, LicenseState::LicensedHalt) { + if matches!(&*license, LicenseState::LicensedHalt { limits: _ }) { http::Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(UnsyncBoxBody::default()) + .body(axum::body::Body::default()) .expect("canned response must be valid") } else { next.run(request).await } } -pub(super) fn main_router( - configuration: &Configuration, -) -> axum::Router<(), DecompressionBody> +#[derive(Clone)] +struct HandlerOptions { + early_cancel: bool, + experimental_log_on_broken_pipe: bool, +} + +pub(super) fn main_router(configuration: &Configuration) -> axum::Router<()> where RF: RouterFactory, { - let early_cancel = configuration.supergraph.early_cancel; - let experimental_log_on_broken_pipe = configuration.supergraph.experimental_log_on_broken_pipe; let mut router = Router::new().route( &configuration.supergraph.sanitized_path(), - get({ - move |Extension(service): Extension, request: Request>| { - handle_graphql( - service.create().boxed(), - early_cancel, - experimental_log_on_broken_pipe, - request, - ) - } - }) - .post({ - move |Extension(service): Extension, request: Request>| { - handle_graphql( - service.create().boxed(), - early_cancel, - experimental_log_on_broken_pipe, - request, - ) - } - }), + get(handle_graphql::).post(handle_graphql::), ); - if configuration.supergraph.path == "/*" { - router = router.route( - "/", - get({ - move |Extension(service): Extension, - request: Request>| { - handle_graphql( - service.create().boxed(), - early_cancel, - experimental_log_on_broken_pipe, - request, - ) - } - }) - .post({ - move |Extension(service): Extension, - request: Request>| { - handle_graphql( - service.create().boxed(), - early_cancel, - experimental_log_on_broken_pipe, - request, - ) - } - }), - ); + if BARE_WILDCARD_PATH_REGEX.is_match(configuration.supergraph.path.as_str()) { + router = router.route("/", get(handle_graphql::).post(handle_graphql::)); } + router = router.route_layer(Extension(HandlerOptions { + early_cancel: configuration.supergraph.early_cancel, + experimental_log_on_broken_pipe: configuration.supergraph.experimental_log_on_broken_pipe, + })); + let session_count_instrument = session_count_instrument(); + #[cfg(all(feature = "global-allocator", not(feature = "dhat-heap"), unix))] + let (_epoch_advance_loop, jemalloc_instrument) = jemalloc_metrics_instruments(); + // Tie the lifetime of the various instruments to the lifetime of the router + // by referencing them in a no-op layer. + router = router.layer(layer_fn(move |service| { + let _session_count_instrument = &session_count_instrument; + #[cfg(all(feature = "global-allocator", not(feature = "dhat-heap"), unix))] + let _jemalloc_instrument = &jemalloc_instrument; + service + })); + router } -async fn handle_graphql( - service: router::BoxService, - early_cancel: bool, - experimental_log_on_broken_pipe: bool, - http_request: Request>, +async fn handle_graphql( + Extension(options): Extension, + Extension(service_factory): Extension, + http_request: Request, ) -> impl IntoResponse { - let _guard = SessionCountGuard::start(); + let _guard = ActiveSessionCountGuard::start(); - let (parts, body) = http_request.into_parts(); - - let http_request = http::Request::from_parts(parts, Body::wrap_stream(BodyStream::new(body))); + let HandlerOptions { + early_cancel, + experimental_log_on_broken_pipe, + } = options; + let service = service_factory.create(); let request: router::Request = http_request.into(); let context = request.context.clone(); @@ -669,15 +555,6 @@ async fn handle_graphql( res }; - let dur = context.busy_time(); - let processing_seconds = dur.as_secs_f64(); - - f64_histogram!( - "apollo.router.processing.time", - "Time spent by the router actually working on the request, not waiting for its network calls or other queries being processed", - processing_seconds - ); - match res { Err(err) => internal_server_error(err), Ok(response) => { @@ -694,7 +571,7 @@ async fn handle_graphql( CONTENT_ENCODING, HeaderValue::from_static(compressor.content_encoding()), ); - Body::wrap_stream(compressor.process(body.into())) + router::body::from_result_stream(compressor.process(body)) } }; @@ -746,7 +623,7 @@ impl<'a> CancelHandler<'a> { } } -impl<'a> Drop for CancelHandler<'a> { +impl Drop for CancelHandler<'_> { fn drop(&mut self) { if !self.got_first_response { if self.experimental_log_on_broken_pipe { @@ -755,7 +632,7 @@ impl<'a> Drop for CancelHandler<'a> { } self.context .extensions() - .with_lock(|mut lock| lock.insert(CanceledRequest)); + .with_lock(|lock| lock.insert(CanceledRequest)); } } } @@ -778,7 +655,7 @@ mod tests { Configuration::from_str(include_str!("testdata/span_mode_default.router.yaml")) .unwrap(); let mode = span_mode(&config); - assert_eq!(mode, SpanMode::Deprecated); + assert_eq!(mode, SpanMode::SpecCompliant); } #[test] @@ -822,7 +699,9 @@ mod tests { .uri("/") .header(ACCEPT, "application/json") .header(CONTENT_TYPE, "application/json") - .body(hyper::Body::from(r#"{"query":"query { me { name }}"}"#)) + .body(router::body::from_bytes( + r#"{"query":"query { me { name }}"}"#, + )) .unwrap(), ), ) @@ -856,7 +735,9 @@ mod tests { .uri("/") .header(ACCEPT, "application/json") .header(CONTENT_TYPE, "application/json") - .body(hyper::Body::from(r#"{"query":"query { me { name }}"}"#)) + .body(router::body::from_bytes( + r#"{"query":"query { me { name }}"}"#, + )) .unwrap(), ), ) diff --git a/apollo-router/src/axum_factory/compression/codec/brotli/encoder.rs b/apollo-router/src/axum_factory/compression/codec/brotli/encoder.rs index ef877335ac..e1d22ede12 100644 --- a/apollo-router/src/axum_factory/compression/codec/brotli/encoder.rs +++ b/apollo-router/src/axum_factory/compression/codec/brotli/encoder.rs @@ -2,17 +2,12 @@ // it will be removed when we find a long lasting solution to https://github.com/Nemo157/async-compression/issues/154 use std::fmt; use std::io::Error; -use std::io::ErrorKind; use std::io::Result; +use brotli::enc::StandardAlloc; use brotli::enc::backward_references::BrotliEncoderParams; -use brotli::enc::encode::BrotliEncoderCompressStream; -use brotli::enc::encode::BrotliEncoderCreateInstance; -use brotli::enc::encode::BrotliEncoderHasMoreOutput; -use brotli::enc::encode::BrotliEncoderIsFinished; use brotli::enc::encode::BrotliEncoderOperation; use brotli::enc::encode::BrotliEncoderStateStruct; -use brotli::enc::StandardAlloc; use crate::axum_factory::compression::codec::Encode; use crate::axum_factory::compression::util::PartialBuffer; @@ -23,7 +18,7 @@ pub(crate) struct BrotliEncoder { impl BrotliEncoder { pub(crate) fn new(params: BrotliEncoderParams) -> Self { - let mut state = BrotliEncoderCreateInstance(StandardAlloc::default()); + let mut state = BrotliEncoderStateStruct::new(StandardAlloc::default()); state.params = params; Self { state } } @@ -40,8 +35,7 @@ impl BrotliEncoder { let mut input_len = 0; let mut output_len = 0; - if BrotliEncoderCompressStream( - &mut self.state, + if !self.state.compress_stream( op, &mut in_buf.len(), in_buf, @@ -51,9 +45,8 @@ impl BrotliEncoder { &mut output_len, &mut None, &mut |_, _, _, _| (), - ) <= 0 - { - return Err(Error::new(ErrorKind::Other, "brotli error")); + ) { + return Err(Error::other("brotli error")); } input.advance(input_len); @@ -86,7 +79,7 @@ impl Encode for BrotliEncoder { BrotliEncoderOperation::BROTLI_OPERATION_FLUSH, )?; - Ok(BrotliEncoderHasMoreOutput(&self.state) == 0) + Ok(!self.state.has_more_output()) } fn finish( @@ -99,7 +92,7 @@ impl Encode for BrotliEncoder { BrotliEncoderOperation::BROTLI_OPERATION_FINISH, )?; - Ok(BrotliEncoderIsFinished(&self.state) == 1) + Ok(self.state.is_finished()) } } diff --git a/apollo-router/src/axum_factory/compression/codec/flate/encoder.rs b/apollo-router/src/axum_factory/compression/codec/flate/encoder.rs index e264b874ff..ddef210478 100644 --- a/apollo-router/src/axum_factory/compression/codec/flate/encoder.rs +++ b/apollo-router/src/axum_factory/compression/codec/flate/encoder.rs @@ -1,7 +1,6 @@ // All code from this module is extracted from https://github.com/Nemo157/async-compression and is under MIT or Apache-2 licence // it will be removed when we find a long lasting solution to https://github.com/Nemo157/async-compression/issues/154 use std::io::Error; -use std::io::ErrorKind; use std::io::Result; use flate2::Compress; @@ -56,7 +55,7 @@ impl Encode for FlateEncoder { match self.encode(input, output, FlushCompress::None)? { Status::Ok => Ok(()), Status::StreamEnd => unreachable!(), - Status::BufError => Err(Error::new(ErrorKind::Other, "unexpected BufError")), + Status::BufError => Err(Error::other("unexpected BufError")), } } @@ -104,7 +103,7 @@ impl Encode for FlateEncoder { )? { Status::Ok => Ok(false), Status::StreamEnd => Ok(true), - Status::BufError => Err(Error::new(ErrorKind::Other, "unexpected BufError")), + Status::BufError => Err(Error::other("unexpected BufError")), } } } diff --git a/apollo-router/src/axum_factory/compression/codec/mod.rs b/apollo-router/src/axum_factory/compression/codec/mod.rs index 71e801b313..18d2f6ead2 100644 --- a/apollo-router/src/axum_factory/compression/codec/mod.rs +++ b/apollo-router/src/axum_factory/compression/codec/mod.rs @@ -26,7 +26,7 @@ pub(crate) trait Encode { /// Returns whether the internal buffers are flushed fn flush(&mut self, output: &mut PartialBuffer + AsMut<[u8]>>) - -> Result; + -> Result; /// Returns whether the internal buffers are flushed and the end of the stream is written fn finish( diff --git a/apollo-router/src/axum_factory/compression/mod.rs b/apollo-router/src/axum_factory/compression/mod.rs index feef4f5a0a..8fcd9706b7 100644 --- a/apollo-router/src/axum_factory/compression/mod.rs +++ b/apollo-router/src/axum_factory/compression/mod.rs @@ -42,16 +42,20 @@ impl Compressor { "deflate" => { return Some(Compressor::Deflate( DeflateEncoder::new(Compression::fast()), - )) + )); } - // FIXME: find the "fast" brotli encoder params "br" => { return Some(Compressor::Brotli(Box::new(BrotliEncoder::new( - BrotliEncoderParams::default(), - )))) + BrotliEncoderParams { + // '4' is a reasonable setting for 'fast' + // https://github.com/dropbox/rust-brotli/issues/93 + quality: 4, + ..BrotliEncoderParams::default() + }, + )))); } "zstd" => { - return Some(Compressor::Zstd(ZstdEncoder::new(zstd_safe::min_c_level()))) + return Some(Compressor::Zstd(ZstdEncoder::new(zstd_safe::min_c_level()))); } _ => {} } @@ -70,11 +74,12 @@ impl Compressor { pub(crate) fn process( mut self, - mut stream: RouterBody, + body: RouterBody, ) -> impl Stream> where { let (tx, rx) = mpsc::channel(10); + let mut stream = http_body_util::BodyDataStream::new(body); tokio::task::spawn( async move { while let Some(data) = stream.next().await { @@ -214,17 +219,20 @@ mod tests { use tokio::io::AsyncWriteExt; use super::*; + use crate::services::router; + use crate::services::router::body::{self}; #[tokio::test] async fn finish() { let compressor = Compressor::new(["gzip"].into_iter()).unwrap(); - let mut rng = rand::thread_rng(); - let body: RouterBody = std::iter::repeat(()) - .map(|_| rng.gen_range(0u8..3)) - .take(5000) - .collect::>() - .into(); + let mut rng = rand::rng(); + let body: RouterBody = body::from_bytes( + std::iter::repeat(()) + .map(|_| rng.random_range(0u8..3)) + .take(5000) + .collect::>(), + ); let mut stream = compressor.process(body); let mut decoder = GzipDecoder::new(Vec::new()); @@ -244,7 +252,7 @@ mod tests { async fn small_input() { let compressor = Compressor::new(["gzip"].into_iter()).unwrap(); - let body: RouterBody = vec![0u8, 1, 2, 3].into(); + let body: RouterBody = body::from_bytes(vec![0u8, 1, 2, 3]); let mut stream = compressor.process(body); let mut decoder = GzipDecoder::new(Vec::new()); @@ -264,7 +272,8 @@ mod tests { #[tokio::test] async fn gzip_header_writing() { let compressor = Compressor::new(["gzip"].into_iter()).unwrap(); - let body: RouterBody = r#"{"data":{"me":{"id":"1","name":"Ada Lovelace"}}}"#.into(); + let body: RouterBody = + body::from_bytes(r#"{"data":{"me":{"id":"1","name":"Ada Lovelace"}}}"#); let mut stream = compressor.process(body); let _ = stream.next().await.unwrap().unwrap(); @@ -279,15 +288,15 @@ content-type: application/json {"data":{"allProducts":[{"sku":"federation","id":"apollo-federation"},{"sku":"studio","id":"apollo-studio"},{"sku":"client","id":"apollo-client"}]},"hasNext":true} --graphql "#; + let deferred_response = r#"content-type: application/json {"hasNext":false,"incremental":[{"data":{"dimensions":{"size":"1"},"variation":{"id":"OSS","name":"platform"}},"path":["allProducts",0]},{"data":{"dimensions":{"size":"1"},"variation":{"id":"platform","name":"platform-name"}},"path":["allProducts",1]},{"data":{"dimensions":{"size":"1"},"variation":{"id":"OSS","name":"client"}},"path":["allProducts",2]}]} --graphql-- "#; - let compressor = Compressor::new(["gzip"].into_iter()).unwrap(); - let body: RouterBody = RouterBody::wrap_stream(stream::iter(vec![ + let body: RouterBody = router::body::from_result_stream(stream::iter(vec![ Ok::<_, BoxError>(Bytes::from(primary_response)), Ok(Bytes::from(deferred_response)), ])); diff --git a/apollo-router/src/axum_factory/connection_handle.rs b/apollo-router/src/axum_factory/connection_handle.rs new file mode 100644 index 0000000000..25ecd98c6b --- /dev/null +++ b/apollo-router/src/axum_factory/connection_handle.rs @@ -0,0 +1,89 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::OnceLock; + +use parking_lot::Mutex; +use parking_lot::MutexGuard; + +use crate::ListenAddr; +use crate::services::router::pipeline_handle::PipelineRef; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum ConnectionState { + Active, + Terminating, +} + +/// A ConnectionRef is used to keep track of how many connections we have active. It's associated with an instance of RouterCreator +/// Pipeline ref represents a unique pipeline +#[derive(Clone, Hash, Eq, PartialEq, Debug)] +pub(crate) struct ConnectionRef { + pub(crate) pipeline_ref: Arc, + pub(crate) address: ListenAddr, + /// The state of this connection. When we are trying to shut it down, for instance on reload, it will switch to terminating. + pub(crate) state: ConnectionState, +} + +/// A connection handle does the actual tracking of connections +/// Creating a new connection handle will insert a ConnectionRef into a static map. +/// Dropping all connection handles associated with the internal ref will remove the ConnectionRef +/// Clone MUST NOT be implemented for this type. Cloning will make extra copies that when dropped will throw off the global count. +pub(crate) struct ConnectionHandle { + pub(crate) connection_ref: ConnectionRef, +} + +static CONNECTION_COUNTS: OnceLock>> = OnceLock::new(); +pub(crate) fn connection_counts() -> MutexGuard<'static, HashMap> { + CONNECTION_COUNTS.get_or_init(Default::default).lock() +} + +impl ConnectionHandle { + pub(crate) fn new(pipeline_ref: Arc, address: ListenAddr) -> Self { + let connection_ref = ConnectionRef { + pipeline_ref, + address, + state: ConnectionState::Active, + }; + Self::increment(&mut connection_counts(), &connection_ref); + ConnectionHandle { connection_ref } + } + + pub(crate) fn shutdown(&mut self) { + // We obtain the guard across decrement and increment so that telemetry sees this as atomic + let mut connections = connection_counts(); + Self::decrement(&mut connections, &self.connection_ref); + self.connection_ref.state = ConnectionState::Terminating; + Self::increment(&mut connections, &self.connection_ref); + } + + fn increment( + connections: &mut MutexGuard>, + connection_ref: &ConnectionRef, + ) { + connections + .entry(connection_ref.clone()) + .and_modify(|p| *p += 1) + .or_insert(1); + } + + fn decrement( + connections: &mut MutexGuard>, + connection_ref: &ConnectionRef, + ) { + let value = connections + .get_mut(connection_ref) + .expect("connection_ref MUST be greater than zero"); + *value -= 1; + if *value == 0 { + connections.remove(connection_ref); + } + } +} + +impl Drop for ConnectionHandle { + fn drop(&mut self) { + Self::decrement(&mut connection_counts(), &self.connection_ref); + } +} + +pub(crate) const OPEN_CONNECTIONS_METRIC: &str = "apollo.router.open_connections"; diff --git a/apollo-router/src/axum_factory/listeners.rs b/apollo-router/src/axum_factory/listeners.rs index 52ea352979..f21a35d8f1 100644 --- a/apollo-router/src/axum_factory/listeners.rs +++ b/apollo-router/src/axum_factory/listeners.rs @@ -2,35 +2,40 @@ use std::collections::HashMap; use std::collections::HashSet; +use std::sync::Arc; use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; -use std::sync::Arc; use std::time::Duration; -use axum::response::*; use axum::Router; +use axum::response::*; +use bytesize::ByteSize; use futures::channel::oneshot; use futures::prelude::*; -use hyper::server::conn::Http; +use hyper_util::rt::TokioExecutor; +use hyper_util::rt::TokioIo; +use hyper_util::rt::TokioTimer; +use hyper_util::server::conn::auto::Builder; use multimap::MultiMap; #[cfg(unix)] use tokio::net::UnixListener; -use tokio::sync::mpsc; use tokio::sync::Notify; +use tokio::sync::mpsc; +use tokio_util::time::FutureExt; use tower_service::Service; +use crate::ListenAddr; +use crate::axum_factory::ENDPOINT_CALLBACK; +use crate::axum_factory::connection_handle::ConnectionHandle; use crate::axum_factory::utils::ConnectionInfo; use crate::axum_factory::utils::InjectConnectionInfo; -use crate::axum_factory::ENDPOINT_CALLBACK; use crate::configuration::Configuration; use crate::http_server_factory::Listener; use crate::http_server_factory::NetworkStream; use crate::router::ApolloRouterError; use crate::router_factory::Endpoint; -use crate::ListenAddr; +use crate::services::router::pipeline_handle::PipelineRef; -static SESSION_COUNT: AtomicU64 = AtomicU64::new(0); static MAX_FILE_HANDLES_WARN: AtomicBool = AtomicBool::new(false); #[derive(Clone, Debug)] @@ -51,33 +56,31 @@ pub(super) fn ensure_endpoints_consistency( endpoints: &MultiMap, ) -> Result<(), ApolloRouterError> { // check the main endpoint - if let Some(supergraph_listen_endpoint) = endpoints.get_vec(&configuration.supergraph.listen) { - if supergraph_listen_endpoint + if let Some(supergraph_listen_endpoint) = endpoints.get_vec(&configuration.supergraph.listen) + && supergraph_listen_endpoint .iter() .any(|e| e.path == configuration.supergraph.path) - { - if let Some((ip, port)) = configuration.supergraph.listen.ip_and_port() { - return Err(ApolloRouterError::SameRouteUsedTwice( - ip, - port, - configuration.supergraph.path.clone(), - )); - } - } + && let Some((ip, port)) = configuration.supergraph.listen.ip_and_port() + { + return Err(ApolloRouterError::SameRouteUsedTwice( + ip, + port, + configuration.supergraph.path.clone(), + )); } // check the extra endpoints let mut listen_addrs_and_paths = HashSet::new(); for (listen, endpoints) in endpoints.iter_all() { for endpoint in endpoints { - if let Some((ip, port)) = listen.ip_and_port() { - if !listen_addrs_and_paths.insert((ip, port, endpoint.path.clone())) { - return Err(ApolloRouterError::SameRouteUsedTwice( - ip, - port, - endpoint.path.clone(), - )); - } + if let Some((ip, port)) = listen.ip_and_port() + && !listen_addrs_and_paths.insert((ip, port, endpoint.path.clone())) + { + return Err(ApolloRouterError::SameRouteUsedTwice( + ip, + port, + endpoint.path.clone(), + )); } } } @@ -125,31 +128,28 @@ pub(super) fn ensure_listenaddrs_consistency( all_ports.insert(main_port, main_ip); } - if configuration.health_check.enabled { - if let Some((ip, port)) = configuration.health_check.listen.ip_and_port() { - if let Some(previous_ip) = all_ports.insert(port, ip) { - if ip != previous_ip { - return Err(ApolloRouterError::DifferentListenAddrsOnSamePort( - previous_ip, - ip, - port, - )); - } - } - } + if configuration.health_check.enabled + && let Some((ip, port)) = configuration.health_check.listen.ip_and_port() + && let Some(previous_ip) = all_ports.insert(port, ip) + && ip != previous_ip + { + return Err(ApolloRouterError::DifferentListenAddrsOnSamePort( + previous_ip, + ip, + port, + )); } for addr in endpoints.keys() { - if let Some((ip, port)) = addr.ip_and_port() { - if let Some(previous_ip) = all_ports.insert(port, ip) { - if ip != previous_ip { - return Err(ApolloRouterError::DifferentListenAddrsOnSamePort( - previous_ip, - ip, - port, - )); - } - } + if let Some((ip, port)) = addr.ip_and_port() + && let Some(previous_ip) = all_ports.insert(port, ip) + && ip != previous_ip + { + return Err(ApolloRouterError::DifferentListenAddrsOnSamePort( + previous_ip, + ip, + port, + )); } } @@ -197,12 +197,125 @@ pub(super) async fn get_extra_listeners( Ok(listeners_and_routers) } +// This macro unifies the logic tht deals with connections. +// Ideally this would be a function, but the generics proved too difficult to figure out. +macro_rules! handle_connection { + ($connection:expr, $connection_handle:expr, $connection_shutdown:expr, $connection_shutdown_timeout:expr, $received_first_request:expr) => { + let connection = $connection; + let mut connection_handle = $connection_handle; + let connection_shutdown = $connection_shutdown; + let connection_shutdown_timeout = $connection_shutdown_timeout; + let received_first_request = $received_first_request; + tokio::pin!(connection); + tokio::select! { + // the connection finished first + _res = &mut connection => { + } + // the shutdown receiver was triggered first, + // so we tell the connection to do a graceful shutdown + // on the next request, then we wait for it to finish + _ = connection_shutdown.notified() => { + connection_handle.shutdown(); + connection.as_mut().graceful_shutdown(); + // Only wait for the connection to close gracfully if we recieved a request. + // On hyper 0.x awaiting the connection would potentially hang forever if no request was recieved. + if received_first_request.load(Ordering::Relaxed) { + // The connection may still not shutdown so we apply a timeout from the configuration + // Connections stuck terminating will keep the pipeline and everything related to that pipeline + // in memory. + + if let Err(_) = connection.timeout(connection_shutdown_timeout).await { + tracing::warn!( + timeout = connection_shutdown_timeout.as_secs(), + server.address = connection_handle.connection_ref.address.to_string(), + schema.id = connection_handle.connection_ref.pipeline_ref.schema_id, + config.hash = connection_handle.connection_ref.pipeline_ref.config_hash, + launch.id = connection_handle.connection_ref.pipeline_ref.launch_id, + "connection shutdown exceeded, forcing close", + ); + } + } + } + } + }; +} + +#[allow(clippy::too_many_arguments)] +async fn process_error(io_error: std::io::Error) { + match io_error.kind() { + // this is already handled by mio and tokio + //std::io::ErrorKind::WouldBlock => todo!(), + + // should be treated as EAGAIN + // https://man7.org/linux/man-pages/man2/accept.2.html + // Linux accept() (and accept4()) passes already-pending network + // errors on the new socket as an error code from accept(). This + // behavior differs from other BSD socket implementations. For + // reliable operation the application should detect the network + // errors defined for the protocol after accept() and treat them + // like EAGAIN by retrying. In the case of TCP/IP, these are + // ENETDOWN, EPROTO, ENOPROTOOPT, EHOSTDOWN, ENONET, EHOSTUNREACH, + // EOPNOTSUPP, and ENETUNREACH. + // + // those errors are not supported though: needs the unstable io_error_more feature + // std::io::ErrorKind::NetworkDown => todo!(), + // std::io::ErrorKind::HostUnreachable => todo!(), + // std::io::ErrorKind::NetworkUnreachable => todo!(), + + //ECONNABORTED + std::io::ErrorKind::ConnectionAborted| + //EINTR + std::io::ErrorKind::Interrupted| + // EINVAL + std::io::ErrorKind::InvalidInput| + std::io::ErrorKind::PermissionDenied | + std::io::ErrorKind::TimedOut | + std::io::ErrorKind::ConnectionReset| + std::io::ErrorKind::NotConnected => { + // the socket was invalid (maybe timedout waiting in accept queue, or was closed) + // we should ignore that and get to the next one + } + + // ignored errors, these should not happen with accept() + std::io::ErrorKind::NotFound | + std::io::ErrorKind::AddrInUse | + std::io::ErrorKind::AddrNotAvailable | + std::io::ErrorKind::BrokenPipe| + std::io::ErrorKind::AlreadyExists | + std::io::ErrorKind::InvalidData | + std::io::ErrorKind::WriteZero | + std::io::ErrorKind::Unsupported | + std::io::ErrorKind::UnexpectedEof | + std::io::ErrorKind::OutOfMemory => { + } + + // EPROTO, EOPNOTSUPP, EBADF, EFAULT, EMFILE, ENOBUFS, ENOMEM, ENOTSOCK + // We match on _ because max open file errors fall under ErrorKind::Uncategorized + _ => { + match io_error.raw_os_error() { + Some(libc::EMFILE) | Some(libc::ENFILE) => { + tracing::error!( + "reached the max open file limit, cannot accept any new connection" + ); + MAX_FILE_HANDLES_WARN.store(true, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(1)).await; + } + _ => {} + } + } + } +} + +#[allow(clippy::too_many_arguments)] pub(super) fn serve_router_on_listen_addr( - mut listener: Listener, + pipeline_ref: Arc, address: ListenAddr, + mut listener: Listener, + connection_shutdown_timeout: Duration, router: axum::Router, - main_graphql_port: bool, - http_config: Http, + opt_max_headers: Option, + opt_max_buf_size: Option, + header_read_timeout: Duration, all_connections_stopped_sender: mpsc::Sender<()>, ) -> (impl Future, oneshot::Sender<()>) { let (shutdown_sender, shutdown_receiver) = oneshot::channel::<()>(); @@ -216,8 +329,6 @@ pub(super) fn serve_router_on_listen_addr( let connection_shutdown = Arc::new(Notify::new()); - let address = address.to_string(); - loop { tokio::select! { _ = &mut shutdown_receiver => { @@ -227,6 +338,8 @@ pub(super) fn serve_router_on_listen_addr( let app = router.clone(); let connection_shutdown = connection_shutdown.clone(); let connection_stop_signal = all_connections_stopped_sender.clone(); + let address = address.clone(); + let pipeline_ref = pipeline_ref.clone(); match res { Ok(res) => { @@ -234,20 +347,11 @@ pub(super) fn serve_router_on_listen_addr( tracing::info!("can accept connections again"); MAX_FILE_HANDLES_WARN.store(false, Ordering::SeqCst); } - // We only want to recognise sessions if we are the main graphql port. - if main_graphql_port { - let session_count = SESSION_COUNT.fetch_add(1, Ordering::Acquire)+1; - tracing::info!( - value.apollo_router_session_count_total = session_count, - listener = &address - ); - } - let address = address.clone(); - let mut http_config = http_config.clone(); tokio::task::spawn(async move { // this sender must be moved into the session to track that it is still running let _connection_stop_signal = connection_stop_signal; + let connection_handle = ConnectionHandle::new(pipeline_ref, address); match res { NetworkStream::Tcp(stream) => { @@ -263,55 +367,50 @@ pub(super) fn serve_router_on_listen_addr( .expect( "this should not fail unless the socket is invalid", ); + let tokio_stream = TokioIo::new(stream); + let hyper_service = hyper::service::service_fn(move |request| { + app.clone().call(request) + }); + + let mut builder = Builder::new(TokioExecutor::new()); + let mut http_connection = builder.http1(); + let http_config = http_connection + .keep_alive(true) + .timer(TokioTimer::new()) + .header_read_timeout(header_read_timeout); + if let Some(max_headers) = opt_max_headers { + http_config.max_headers(max_headers); + } - let connection = http_config.serve_connection(stream, app); - tokio::pin!(connection); - tokio::select! { - // the connection finished first - _res = &mut connection => { - } - // the shutdown receiver was triggered first, - // so we tell the connection to do a graceful shutdown - // on the next request, then we wait for it to finish - _ = connection_shutdown.notified() => { - let c = connection.as_mut(); - c.graceful_shutdown(); - - // if the connection was idle and we never received the first request, - // hyper's graceful shutdown would wait indefinitely, so instead we - // close the connection right away - if received_first_request.load(Ordering::Relaxed) { - let _= connection.await; - } - } + if let Some(max_buf_size) = opt_max_buf_size { + http_config.max_buf_size(max_buf_size.as_u64() as usize); } + let connection = http_config.serve_connection_with_upgrades(tokio_stream, hyper_service); + handle_connection!(connection, connection_handle, connection_shutdown, connection_shutdown_timeout, received_first_request); } #[cfg(unix)] NetworkStream::Unix(stream) => { let received_first_request = Arc::new(AtomicBool::new(false)); let app = IdleConnectionChecker::new(received_first_request.clone(), app); - let connection = http_config.serve_connection(stream, app); - - tokio::pin!(connection); - tokio::select! { - // the connection finished first - _res = &mut connection => { - } - // the shutdown receiver was triggered first, - // so we tell the connection to do a graceful shutdown - // on the next request, then we wait for it to finish - _ = connection_shutdown.notified() => { - let c = connection.as_mut(); - c.graceful_shutdown(); - - // if the connection was idle and we never received the first request, - // hyper's graceful shutdown would wait indefinitely, so instead we - // close the connection right away - if received_first_request.load(Ordering::Relaxed) { - let _= connection.await; - } - } + let tokio_stream = TokioIo::new(stream); + let hyper_service = hyper::service::service_fn(move |request| { + app.clone().call(request) + }); + let mut builder = Builder::new(TokioExecutor::new()); + let mut http_connection = builder.http1(); + let http_config = http_connection + .keep_alive(true) + .timer(TokioTimer::new()) + .header_read_timeout(header_read_timeout); + if let Some(max_headers) = opt_max_headers { + http_config.max_headers(max_headers); } + + if let Some(max_buf_size) = opt_max_buf_size { + http_config.max_buf_size(max_buf_size.as_u64() as usize); + } + let connection = http_config.serve_connection_with_upgrades(tokio_stream, hyper_service); + handle_connection!(connection, connection_handle, connection_shutdown, connection_shutdown_timeout, received_first_request); }, NetworkStream::Tls(stream) => { let received_first_request = Arc::new(AtomicBool::new(false)); @@ -323,113 +422,36 @@ pub(super) fn serve_router_on_listen_addr( "this should not fail unless the socket is invalid", ); - let protocol = stream.get_ref().1.alpn_protocol(); - let http2 = protocol == Some(&b"h2"[..]); - - let connection = http_config - .http2_only(http2) - .serve_connection(stream, app); - - tokio::pin!(connection); - tokio::select! { - // the connection finished first - _res = &mut connection => { - } - // the shutdown receiver was triggered first, - // so we tell the connection to do a graceful shutdown - // on the next request, then we wait for it to finish - _ = connection_shutdown.notified() => { - let c = connection.as_mut(); - c.graceful_shutdown(); - - // if the connection was idle and we never received the first request, - // hyper's graceful shutdown would wait indefinitely, so instead we - // close the connection right away - if received_first_request.load(Ordering::Relaxed) { - let _= connection.await; - } - } + let mut builder = Builder::new(TokioExecutor::new()); + if stream.get_ref().1.alpn_protocol() == Some(&b"h2"[..]) { + builder = builder.http2_only(); } - } - } - - // We only want to recognise sessions if we are the main graphql port. - if main_graphql_port { - let session_count = SESSION_COUNT.fetch_sub(1, Ordering::Acquire)-1; - tracing::info!( - value.apollo_router_session_count_total = session_count, - listener = &address - ); - } - }); - } - Err(e) => match e.kind() { - // this is already handled by moi and tokio - //std::io::ErrorKind::WouldBlock => todo!(), - - // should be treated as EAGAIN - // https://man7.org/linux/man-pages/man2/accept.2.html - // Linux accept() (and accept4()) passes already-pending network - // errors on the new socket as an error code from accept(). This - // behavior differs from other BSD socket implementations. For - // reliable operation the application should detect the network - // errors defined for the protocol after accept() and treat them - // like EAGAIN by retrying. In the case of TCP/IP, these are - // ENETDOWN, EPROTO, ENOPROTOOPT, EHOSTDOWN, ENONET, EHOSTUNREACH, - // EOPNOTSUPP, and ENETUNREACH. - // - // those errors are not supported though: needs the unstable io_error_more feature - // std::io::ErrorKind::NetworkDown => todo!(), - // std::io::ErrorKind::HostUnreachable => todo!(), - // std::io::ErrorKind::NetworkUnreachable => todo!(), - - //ECONNABORTED - std::io::ErrorKind::ConnectionAborted| - //EINTR - std::io::ErrorKind::Interrupted| - // EINVAL - std::io::ErrorKind::InvalidInput| - std::io::ErrorKind::PermissionDenied | - std::io::ErrorKind::TimedOut | - std::io::ErrorKind::ConnectionReset| - std::io::ErrorKind::NotConnected => { - // the socket was invalid (maybe timedout waiting in accept queue, or was closed) - // we should ignore that and get to the next one - continue; - } + let tokio_stream = TokioIo::new(stream); + let hyper_service = hyper::service::service_fn(move |request| { + app.clone().call(request) + }); + let mut http_connection = builder.http1(); + let http_config = http_connection + .keep_alive(true) + .timer(TokioTimer::new()) + .header_read_timeout(header_read_timeout); + if let Some(max_headers) = opt_max_headers { + http_config.max_headers(max_headers); + } - // ignored errors, these should not happen with accept() - std::io::ErrorKind::NotFound | - std::io::ErrorKind::AddrInUse | - std::io::ErrorKind::AddrNotAvailable | - std::io::ErrorKind::BrokenPipe| - std::io::ErrorKind::AlreadyExists | - std::io::ErrorKind::InvalidData | - std::io::ErrorKind::WriteZero | - std::io::ErrorKind::Unsupported | - std::io::ErrorKind::UnexpectedEof | - std::io::ErrorKind::OutOfMemory => { - continue; - } + if let Some(max_buf_size) = opt_max_buf_size { + http_config.max_buf_size(max_buf_size.as_u64() as usize); + } + let connection = http_config + .serve_connection_with_upgrades(tokio_stream, hyper_service); + handle_connection!(connection, connection_handle, connection_shutdown, connection_shutdown_timeout, received_first_request); - // EPROTO, EOPNOTSUPP, EBADF, EFAULT, EMFILE, ENOBUFS, ENOMEM, ENOTSOCK - // We match on _ because max open file errors fall under ErrorKind::Uncategorized - _ => { - match e.raw_os_error() { - Some(libc::EMFILE) | Some(libc::ENFILE) => { - tracing::error!( - "reached the max open file limit, cannot accept any new connection" - ); - MAX_FILE_HANDLES_WARN.store(true, Ordering::SeqCst); - tokio::time::sleep(Duration::from_millis(1)).await; } - _ => {} } - continue; - } - + }); } + Err(e) => process_error(e).await } } } @@ -444,12 +466,13 @@ pub(super) fn serve_router_on_listen_addr( (server, shutdown_sender) } +#[derive(Clone)] struct IdleConnectionChecker { received_request: Arc, inner: S, } -impl IdleConnectionChecker { +impl IdleConnectionChecker { fn new(b: Arc, service: S) -> Self { IdleConnectionChecker { received_request: b, @@ -486,14 +509,15 @@ mod tests { use std::str::FromStr; use axum::BoxError; - use tower::service_fn; use tower::ServiceExt; + use tower::service_fn; use super::*; use crate::axum_factory::tests::init_with_config; use crate::configuration::Sandbox; use crate::configuration::Supergraph; use crate::services::router; + use crate::services::router::body; #[tokio::test] async fn it_makes_sure_same_listenaddrs_are_accepted() { @@ -521,12 +545,17 @@ mod tests { .unwrap(); let endpoint = service_fn(|req: router::Request| async move { - Ok::<_, BoxError>(router::Response { - response: http::Response::builder() - .body::("this is a test".to_string().into()) + Ok::<_, BoxError>( + router::Response::http_response_builder() + .response( + http::Response::builder().body::( + body::from_bytes("this is a test".to_string()), + )?, + ) + .context(req.context) + .build() .unwrap(), - context: req.context, - }) + ) }) .boxed(); @@ -560,12 +589,14 @@ mod tests { .build() .unwrap(); let endpoint = service_fn(|req: router::Request| async move { - Ok::<_, BoxError>(router::Response { - response: http::Response::builder() - .body::("this is a test".to_string().into()) - .unwrap(), - context: req.context, - }) + router::Response::http_response_builder() + .response( + http::Response::builder().body::( + body::from_bytes("this is a test".to_string()), + )?, + ) + .context(req.context) + .build() }) .boxed(); diff --git a/apollo-router/src/axum_factory/metrics.rs b/apollo-router/src/axum_factory/metrics.rs new file mode 100644 index 0000000000..9f2d3ced3e --- /dev/null +++ b/apollo-router/src/axum_factory/metrics.rs @@ -0,0 +1,62 @@ +#[cfg(all(feature = "global-allocator", not(feature = "dhat-heap"), unix))] +pub(crate) mod jemalloc { + use std::time::Duration; + + use opentelemetry::metrics::MeterProvider; + use opentelemetry::metrics::ObservableGauge; + + use crate::metrics::meter_provider; + + pub(crate) fn start_epoch_advance_loop() -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + loop { + if let Err(e) = tikv_jemalloc_ctl::epoch::advance() { + tracing::warn!("Failed to advance jemalloc epoch: {}", e); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }) + } + + macro_rules! create_jemalloc_gauge { + ($name:ident, $description:expr) => { + meter_provider() + .meter("apollo/router") + .u64_observable_gauge(concat!("apollo.router.jemalloc.", stringify!($name))) + .with_description($description) + .with_unit("bytes") + .with_callback(|gauge| { + if let Ok(value) = tikv_jemalloc_ctl::stats::$name::read() { + gauge.observe(value as u64, &[]); + } else { + tracing::warn!("Failed to read jemalloc {} stats", stringify!($name)); + } + }) + .init() + }; + } + + pub(crate) fn create_active_gauge() -> ObservableGauge { + create_jemalloc_gauge!(active, "Total active bytes in jemalloc") + } + + pub(crate) fn create_allocated_gauge() -> ObservableGauge { + create_jemalloc_gauge!(allocated, "Total bytes allocated by jemalloc") + } + + pub(crate) fn create_metadata_gauge() -> ObservableGauge { + create_jemalloc_gauge!(metadata, "Total metadata bytes in jemalloc") + } + + pub(crate) fn create_mapped_gauge() -> ObservableGauge { + create_jemalloc_gauge!(mapped, "Total mapped bytes in jemalloc") + } + + pub(crate) fn create_resident_gauge() -> ObservableGauge { + create_jemalloc_gauge!(resident, "Total resident bytes in jemalloc") + } + + pub(crate) fn create_retained_gauge() -> ObservableGauge { + create_jemalloc_gauge!(retained, "Total retained bytes in jemalloc") + } +} diff --git a/apollo-router/src/axum_factory/mod.rs b/apollo-router/src/axum_factory/mod.rs index 929da384d7..d7ccb9d270 100644 --- a/apollo-router/src/axum_factory/mod.rs +++ b/apollo-router/src/axum_factory/mod.rs @@ -1,7 +1,9 @@ //! axum factory is useful to create an [`AxumHttpServerFactory`] which implements [`crate::http_server_factory::HttpServerFactory`] -mod axum_http_server_factory; +pub(crate) mod axum_http_server_factory; pub(crate) mod compression; +pub(crate) mod connection_handle; mod listeners; +pub(crate) mod metrics; #[cfg(test)] pub(crate) mod tests; pub(crate) mod utils; @@ -10,20 +12,9 @@ use std::sync::Arc; use std::sync::OnceLock; use axum::Router; -pub(crate) use axum_http_server_factory::span_mode; pub(crate) use axum_http_server_factory::AxumHttpServerFactory; pub(crate) use axum_http_server_factory::CanceledRequest; +pub(crate) use axum_http_server_factory::span_mode; pub(crate) use listeners::ListenAddrAndRouter; static ENDPOINT_CALLBACK: OnceLock Router + Send + Sync>> = OnceLock::new(); - -/// Set a callback that may wrap or mutate `axum::Router` as they are added to the main router. -/// Although part of the public API, this is not intended for use by end users, and may change at any time. -#[doc(hidden)] -pub fn unsupported_set_axum_router_callback( - callback: impl Fn(Router) -> Router + Send + Sync + 'static, -) -> axum::response::Result<(), &'static str> { - ENDPOINT_CALLBACK - .set(Arc::new(callback)) - .map_err(|_| "endpoint decorator was already set") -} diff --git a/apollo-router/src/axum_factory/snapshots/apollo_router__axum_factory__tests__defer_is_not_buffered.snap b/apollo-router/src/axum_factory/snapshots/apollo_router__axum_factory__tests__defer_is_not_buffered.snap index ece5fd3c9f..aa5a895461 100644 --- a/apollo-router/src/axum_factory/snapshots/apollo_router__axum_factory__tests__defer_is_not_buffered.snap +++ b/apollo-router/src/axum_factory/snapshots/apollo_router__axum_factory__tests__defer_is_not_buffered.snap @@ -1,6 +1,7 @@ --- source: apollo-router/src/axum_factory/tests.rs expression: parts +snapshot_kind: text --- [ { @@ -9,30 +10,82 @@ expression: parts { "upc": "1", "name": "Table", - "reviews": null + "reviews": [ + { + "id": "1", + "product": { + "name": "Table" + } + }, + { + "id": "4", + "product": { + "name": "Table" + } + } + ] }, { "upc": "2", "name": "Couch", - "reviews": null + "reviews": [ + { + "id": "2", + "product": { + "name": "Couch" + } + } + ] } ] }, - "errors": [ + "hasNext": true + }, + { + "hasNext": false, + "incremental": [ { - "message": "couldn't find mock for query {\"query\":\"query($representations: [_Any!]!) { _entities(representations: $representations) { ..._generated_onProduct1_0 } } fragment _generated_onProduct1_0 on Product { reviews { __typename id product { __typename upc } } }\",\"variables\":{\"representations\":[{\"__typename\":\"Product\",\"upc\":\"1\"},{\"__typename\":\"Product\",\"upc\":\"2\"}]}}", + "data": { + "author": { + "id": "1", + "name": "Ada Lovelace" + } + }, "path": [ "topProducts", - "@" - ], - "extensions": { - "code": "FETCH_ERROR" - } + 0, + "reviews", + 0 + ] + }, + { + "data": { + "author": { + "id": "2", + "name": "Alan Turing" + } + }, + "path": [ + "topProducts", + 0, + "reviews", + 1 + ] + }, + { + "data": { + "author": { + "id": "1", + "name": "Ada Lovelace" + } + }, + "path": [ + "topProducts", + 1, + "reviews", + 0 + ] } - ], - "hasNext": true - }, - { - "hasNext": false + ] } ] diff --git a/apollo-router/src/axum_factory/tests.rs b/apollo-router/src/axum_factory/tests.rs index efcf7e3ab3..479ebfda13 100644 --- a/apollo-router/src/axum_factory/tests.rs +++ b/apollo-router/src/axum_factory/tests.rs @@ -1,32 +1,40 @@ -use std::collections::HashMap; use std::io; use std::net::SocketAddr; -use std::num::NonZeroUsize; use std::pin::Pin; use std::str::FromStr; +use std::sync::Arc; use std::sync::atomic::AtomicU32; use std::sync::atomic::Ordering; -use std::sync::Arc; +use std::task::Poll; use std::time::Duration; use async_compression::tokio::write::GzipDecoder; use async_compression::tokio::write::GzipEncoder; -use axum::body::BoxBody; +use futures::Future; +use futures::StreamExt; use futures::future::BoxFuture; use futures::stream; use futures::stream::poll_fn; -use futures::Future; -use futures::StreamExt; +use http::HeaderMap; +use http::HeaderValue; use http::header::ACCEPT_ENCODING; use http::header::CONTENT_ENCODING; use http::header::CONTENT_TYPE; use http::header::{self}; -use http::HeaderMap; -use http::HeaderValue; -use http_body::Body; +#[cfg(unix)] +use http_body_util::BodyExt; +#[cfg(unix)] +use hyper::rt::ReadBufCursor; +#[cfg(unix)] +use hyper_util::rt::TokioIo; use mime::APPLICATION_JSON; use mockall::mock; use multimap::MultiMap; +#[cfg(unix)] +use pin_project_lite::pin_project; +use reqwest::Client; +use reqwest::Method; +use reqwest::StatusCode; use reqwest::header::ACCEPT; use reqwest::header::ACCESS_CONTROL_ALLOW_HEADERS; use reqwest::header::ACCESS_CONTROL_ALLOW_METHODS; @@ -35,63 +43,55 @@ use reqwest::header::ACCESS_CONTROL_MAX_AGE; use reqwest::header::ACCESS_CONTROL_REQUEST_HEADERS; use reqwest::header::ACCESS_CONTROL_REQUEST_METHOD; use reqwest::header::ORIGIN; -use reqwest::redirect::Policy; -use reqwest::Client; -use reqwest::Method; -use reqwest::StatusCode; use serde_json::json; use test_log::test; use tokio::io::AsyncRead; use tokio::io::AsyncReadExt; +#[cfg(unix)] +use tokio::io::AsyncWrite; use tokio::io::AsyncWriteExt; +#[cfg(unix)] +use tokio::io::ReadBuf; use tokio::sync::mpsc; use tokio_util::io::StreamReader; -use tower::service_fn; use tower::BoxError; use tower::Service; use tower::ServiceExt; +use tower::service_fn; -pub(crate) use super::axum_http_server_factory::make_axum_router; use super::*; -use crate::configuration::cors::Cors; -use crate::configuration::HealthCheck; +use crate::ApolloRouterError; +use crate::Configuration; +use crate::ListenAddr; +use crate::TestHarness; +use crate::assert_response_eq_ignoring_error_id; +use crate::axum_factory::connection_handle::connection_counts; use crate::configuration::Homepage; use crate::configuration::Sandbox; use crate::configuration::Supergraph; +use crate::configuration::cors::Cors; +use crate::configuration::cors::Policy; use crate::graphql; use crate::http_server_factory::HttpServerFactory; use crate::http_server_factory::HttpServerHandle; use crate::json_ext::Path; -use crate::plugin::test::MockSubgraph; -use crate::query_planner::BridgeQueryPlannerPool; -use crate::router_factory::create_plugins; +use crate::metrics::FutureMetricsExt; +use crate::plugins::healthcheck::Config as HealthCheck; use crate::router_factory::Endpoint; use crate::router_factory::RouterFactory; -use crate::services::execution; -use crate::services::layers::persisted_queries::PersistedQueryLayer; -use crate::services::layers::query_analysis::QueryAnalysisLayer; +use crate::services::MULTIPART_DEFER_ACCEPT; +use crate::services::MULTIPART_DEFER_CONTENT_TYPE; +use crate::services::RouterRequest; +use crate::services::RouterResponse; +use crate::services::SupergraphResponse; use crate::services::layers::static_page::home_page_content; use crate::services::layers::static_page::sandbox_page_content; use crate::services::new_service::ServiceFactory; use crate::services::router; -use crate::services::router::service::RouterCreator; -use crate::services::supergraph; -use crate::services::HasSchema; -use crate::services::PluggableSupergraphServiceBuilder; -use crate::services::RouterRequest; -use crate::services::RouterResponse; -use crate::services::SupergraphResponse; -use crate::services::MULTIPART_DEFER_ACCEPT; -use crate::services::MULTIPART_DEFER_CONTENT_TYPE; -use crate::spec::Schema; +use crate::services::router::pipeline_handle::PipelineRef; use crate::test_harness::http_client; use crate::test_harness::http_client::MaybeMultipart; use crate::uplink::license_enforcement::LicenseState; -use crate::ApolloRouterError; -use crate::Configuration; -use crate::Context; -use crate::ListenAddr; -use crate::TestHarness; macro_rules! assert_header { ($response:expr, $header:expr, $expected:expr $(, $msg:expr)?) => { @@ -164,16 +164,24 @@ impl RouterFactory for TestRouterFactory { fn web_endpoints(&self) -> MultiMap { MultiMap::new() } + + fn pipeline_ref(&self) -> Arc { + Arc::new(PipelineRef { + schema_id: "dummy".to_string(), + launch_id: None, + config_hash: "dummy".to_string(), + }) + } } async fn init( mut mock: impl Service< - router::Request, - Response = router::Response, - Error = BoxError, - Future = BoxFuture<'static, router::ServiceResult>, - > + Send - + 'static, + router::Request, + Response = router::Response, + Error = BoxError, + Future = BoxFuture<'static, router::ServiceResult>, + > + Send + + 'static, ) -> (HttpServerHandle, Client) { let server_factory = AxumHttpServerFactory::new(); let (service, mut handle) = tower_test::mock::spawn(); @@ -218,7 +226,7 @@ async fn init( None, vec![], MultiMap::new(), - LicenseState::Unlicensed, + Arc::new(LicenseState::Unlicensed), all_connections_stopped_sender, ) .await @@ -236,7 +244,7 @@ async fn init( let client = reqwest::Client::builder() .no_gzip() .default_headers(default_headers) - .redirect(Policy::none()) + .redirect(reqwest::redirect::Policy::none()) .build() .unwrap(); (server, client) @@ -244,12 +252,12 @@ async fn init( pub(super) async fn init_with_config( mut router_service: impl Service< - router::Request, - Response = router::Response, - Error = BoxError, - Future = BoxFuture<'static, router::ServiceResult>, - > + Send - + 'static, + router::Request, + Response = router::Response, + Error = BoxError, + Future = BoxFuture<'static, router::ServiceResult>, + > + Send + + 'static, conf: Arc, web_endpoints: MultiMap, ) -> Result<(HttpServerHandle, Client), ApolloRouterError> { @@ -276,7 +284,7 @@ pub(super) async fn init_with_config( None, vec![], web_endpoints, - LicenseState::Unlicensed, + Arc::new(LicenseState::Unlicensed), all_connections_stopped_sender, ) .await?; @@ -293,7 +301,7 @@ pub(super) async fn init_with_config( let client = reqwest::Client::builder() .no_gzip() .default_headers(default_headers) - .redirect(Policy::none()) + .redirect(reqwest::redirect::Policy::none()) .build() .unwrap(); Ok((server, client)) @@ -302,12 +310,12 @@ pub(super) async fn init_with_config( #[cfg(unix)] async fn init_unix( mut mock: impl Service< - router::Request, - Response = router::Response, - Error = BoxError, - Future = BoxFuture<'static, router::ServiceResult>, - > + Send - + 'static, + router::Request, + Response = router::Response, + Error = BoxError, + Future = BoxFuture<'static, router::ServiceResult>, + > + Send + + 'static, temp_dir: &tempfile::TempDir, ) -> HttpServerHandle { let server_factory = AxumHttpServerFactory::new(); @@ -343,7 +351,7 @@ async fn init_unix( None, vec![], MultiMap::new(), - LicenseState::Unlicensed, + Arc::new(LicenseState::Unlicensed), all_connections_stopped_sender, ) .await @@ -513,16 +521,20 @@ async fn it_compress_response_body() -> Result<(), ApolloRouterError> { Ok(()) } -#[tokio::test] -async fn it_decompress_request_body() -> Result<(), ApolloRouterError> { - let original_body = json!({ "query": "query { me { name } }" }); +async fn gzip(json: serde_json::Value) -> Vec { let mut encoder = GzipEncoder::new(Vec::new()); encoder - .write_all(original_body.to_string().as_bytes()) + .write_all(json.to_string().as_bytes()) .await .unwrap(); encoder.shutdown().await.unwrap(); - let compressed_body = encoder.into_inner(); + encoder.into_inner() +} + +#[tokio::test] +async fn it_decompress_request_body() -> Result<(), ApolloRouterError> { + let original_body = json!({ "query": "query { me { name } }" }); + let compressed_body = gzip(original_body).await; let expected_response = graphql::Response::builder() .data(json!({"response": "yayyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"})) // Body must be bigger than 32 to be compressed .build(); @@ -562,6 +574,54 @@ async fn it_decompress_request_body() -> Result<(), ApolloRouterError> { Ok(()) } +#[tokio::test] +async fn unsupported_compression() -> Result<(), ApolloRouterError> { + let original_body = json!({ "query": "query { me { name } }" }); + let compressed_body = gzip(original_body).await; + + let router_service = router::service::empty().await; + let (server, client) = init(router_service).await; + let url = format!("{}/", server.graphql_listen_address().as_ref().unwrap()); + + let response = client + .post(url.as_str()) + // Telling the router we used a compression algorithm it can't decompress + .header(CONTENT_ENCODING, HeaderValue::from_static("unsupported")) + .body(compressed_body.clone()) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn mismatched_compression_header() -> Result<(), ApolloRouterError> { + let original_body = json!({ "query": "query { me { name } }" }); + let compressed_body = gzip(original_body).await; + + let router_service = router::service::empty().await; + let (server, client) = init(router_service).await; + let url = format!("{}/", server.graphql_listen_address().as_ref().unwrap()); + + let response = client + .post(url.as_str()) + // Telling the router we used a different (valid) compression algorithm than the one we actually used + .header(CONTENT_ENCODING, HeaderValue::from_static("br")) + .body(compressed_body.clone()) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + + server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn malformed_request() -> Result<(), ApolloRouterError> { let (server, client) = init(router::service::empty().await).await; @@ -708,7 +768,7 @@ async fn response_with_root_wildcard() -> Result<(), ApolloRouterError> { let conf = Configuration::fake_builder() .supergraph( crate::configuration::Supergraph::fake_builder() - .path(String::from("/*")) + .path(String::from("/{*rest}")) .build(), ) .build() @@ -858,7 +918,7 @@ async fn response_with_custom_prefix_endpoint() -> Result<(), ApolloRouterError> let conf = Configuration::fake_builder() .supergraph( crate::configuration::Supergraph::fake_builder() - .path(String::from("/:my_prefix/graphql")) + .path(String::from("/{my_prefix}/graphql")) .build(), ) .build() @@ -923,7 +983,7 @@ async fn response_with_custom_endpoint_wildcard() -> Result<(), ApolloRouterErro let conf = Configuration::fake_builder() .supergraph( crate::configuration::Supergraph::fake_builder() - .path(String::from("/graphql/*")) + .path(String::from("/graphql/{*rest}")) .build(), ) .build() @@ -1012,7 +1072,7 @@ async fn response_failure() -> Result<(), ApolloRouterError> { .await .unwrap(); - assert_eq!( + assert_response_eq_ignoring_error_id!( response, crate::error::FetchError::SubrequestHttpError { status_code: Some(200), @@ -1055,7 +1115,7 @@ async fn cors_preflight() -> Result<(), ApolloRouterError> { .header(ACCESS_CONTROL_REQUEST_METHOD, "POST") .header( ACCESS_CONTROL_REQUEST_HEADERS, - "Content-type, x-an-other-test-header, apollo-require-preflight", + "content-type, x-an-other-test-header, apollo-require-preflight", ) .send() .await @@ -1071,7 +1131,11 @@ async fn cors_preflight() -> Result<(), ApolloRouterError> { assert_header_contains!( &response, ACCESS_CONTROL_ALLOW_HEADERS, - &["Content-type, x-an-other-test-header, apollo-require-preflight"], + &[ + "content-type", + "x-an-other-test-header", + "apollo-require-preflight" + ], "Incorrect access control allow header header {headers:?}" ); assert_header_contains!( @@ -1342,9 +1406,9 @@ async fn it_refuses_to_start_if_homepage_and_sandbox_are_enabled() { .unwrap_err(); assert_eq!( - "sandbox and homepage cannot be enabled at the same time: disable the homepage if you want to enable sandbox", - error.to_string() - ) + "sandbox and homepage cannot be enabled at the same time: disable the homepage if you want to enable sandbox", + error.to_string() + ) } #[test(tokio::test)] @@ -1365,9 +1429,9 @@ async fn it_refuses_to_start_if_sandbox_is_enabled_and_introspection_is_not() { .unwrap_err(); assert_eq!( - "sandbox and homepage cannot be enabled at the same time: disable the homepage if you want to enable sandbox", - error.to_string() - ) + "sandbox and homepage cannot be enabled at the same time: disable the homepage if you want to enable sandbox", + error.to_string() + ) } #[test(tokio::test)] @@ -1419,7 +1483,7 @@ async fn cors_origin_default() -> Result<(), ApolloRouterError> { let response = request_cors_with_origin(&client, url.as_str(), "https://studio.apollographql.com").await; - assert_cors_origin(response, "https://studio.apollographql.com"); + assert_cors_origin(response, "https://studio.apollographql.com").await; let response = request_cors_with_origin(&client, url.as_str(), "https://this.wont.work.com").await; @@ -1462,7 +1526,7 @@ async fn cors_allow_any_origin() -> Result<(), ApolloRouterError> { let url = format!("{}/", server.graphql_listen_address().as_ref().unwrap()); let response = request_cors_with_origin(&client, url.as_str(), "https://thisisatest.com").await; - assert_cors_origin(response, "*"); + assert_cors_origin(response, "*").await; Ok(()) } @@ -1474,7 +1538,11 @@ async fn cors_origin_list() -> Result<(), ApolloRouterError> { let conf = Configuration::fake_builder() .cors( Cors::builder() - .origins(vec![valid_origin.to_string()]) + .policies(vec![ + Policy::builder() + .origins(vec![valid_origin.to_string()]) + .build(), + ]) .build(), ) .build() @@ -1488,7 +1556,7 @@ async fn cors_origin_list() -> Result<(), ApolloRouterError> { let url = format!("{}/", server.graphql_listen_address().as_ref().unwrap()); let response = request_cors_with_origin(&client, url.as_str(), valid_origin).await; - assert_cors_origin(response, valid_origin); + assert_cors_origin(response, valid_origin).await; let response = request_cors_with_origin(&client, url.as_str(), "https://thisoriginisinvalid").await; @@ -1504,8 +1572,17 @@ async fn cors_origin_regex() -> Result<(), ApolloRouterError> { let conf = Configuration::fake_builder() .cors( Cors::builder() - .origins(vec!["https://anexactmatchorigin.com".to_string()]) - .match_origins(vec![apollo_subdomains.to_string()]) + .policies(vec![ + Policy::builder() + .origins(vec!["https://anexactmatchorigin.com".to_string()]) + .match_origins(vec![regex::Regex::new(apollo_subdomains).unwrap()]) + .allow_headers(vec![ + "content-type".into(), + "x-an-other-test-header".into(), + "apollo-require-preflight".into(), + ]) + .build(), + ]) .build(), ) .build() @@ -1521,10 +1598,10 @@ async fn cors_origin_regex() -> Result<(), ApolloRouterError> { // regex tests let response = request_cors_with_origin(&client, url.as_str(), "https://www.apollographql.com").await; - assert_cors_origin(response, "https://www.apollographql.com"); + assert_cors_origin(response, "https://www.apollographql.com").await; let response = request_cors_with_origin(&client, url.as_str(), "https://staging.apollographql.com").await; - assert_cors_origin(response, "https://staging.apollographql.com"); + assert_cors_origin(response, "https://staging.apollographql.com").await; let response = request_cors_with_origin(&client, url.as_str(), "https://thisshouldnotwork.com").await; assert_not_cors_origin(response, "https://thisshouldnotwork.com"); @@ -1532,7 +1609,7 @@ async fn cors_origin_regex() -> Result<(), ApolloRouterError> { // exact match tests let response = request_cors_with_origin(&client, url.as_str(), "https://anexactmatchorigin.com").await; - assert_cors_origin(response, "https://anexactmatchorigin.com"); + assert_cors_origin(response, "https://anexactmatchorigin.com").await; // won't match let response = @@ -1553,8 +1630,19 @@ async fn request_cors_with_origin(client: &Client, url: &str, origin: &str) -> r .unwrap() } -fn assert_cors_origin(response: reqwest::Response, origin: &str) { - assert!(response.status().is_success()); +async fn assert_cors_origin(response: reqwest::Response, origin: &str) { + if !response.status().is_success() { + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Failed to get response body".to_string()); + println!("Response status: {status}"); + println!("Response headers: {headers:?}"); + println!("Response body: {body}"); + panic!("Response status is not success: {status}"); + } let headers = response.headers(); assert_headers_valid(&response); assert!(origin_valid(headers, origin)); @@ -1641,12 +1729,14 @@ async fn deferred_response_shape() -> Result<(), ApolloRouterError> { .has_next(true) .build(), graphql::Response::builder() - .incremental(vec![graphql::IncrementalResponse::builder() - .data(json!({ - "name": "Ada" - })) - .path(Path::from("me")) - .build()]) + .incremental(vec![ + graphql::IncrementalResponse::builder() + .data(json!({ + "name": "Ada" + })) + .path(Path::from("me")) + .build(), + ]) .has_next(true) .build(), graphql::Response::builder().has_next(false).build(), @@ -1680,15 +1770,15 @@ async fn deferred_response_shape() -> Result<(), ApolloRouterError> { let first = response.chunk().await.unwrap().unwrap(); assert_eq!( - std::str::from_utf8(&first).unwrap(), - "\r\n--graphql\r\ncontent-type: application/json\r\n\r\n{\"data\":{\"me\":\"id\"},\"hasNext\":true}\r\n--graphql" - ); + std::str::from_utf8(&first).unwrap(), + "\r\n--graphql\r\ncontent-type: application/json\r\n\r\n{\"data\":{\"me\":\"id\"},\"hasNext\":true}\r\n--graphql" + ); let second = response.chunk().await.unwrap().unwrap(); assert_eq!( - std::str::from_utf8(&second).unwrap(), + std::str::from_utf8(&second).unwrap(), "\r\ncontent-type: application/json\r\n\r\n{\"hasNext\":true,\"incremental\":[{\"data\":{\"name\":\"Ada\"},\"path\":[\"me\"]}]}\r\n--graphql" - ); + ); let third = response.chunk().await.unwrap().unwrap(); assert_eq!( @@ -1702,12 +1792,14 @@ async fn deferred_response_shape() -> Result<(), ApolloRouterError> { #[test(tokio::test)] async fn multipart_response_shape_with_one_chunk() -> Result<(), ApolloRouterError> { let router_service = router::service::from_supergraph_mock_callback(move |req| { - let body = stream::iter(vec![graphql::Response::builder() - .data(json!({ - "me": "name", - })) - .has_next(false) - .build()]) + let body = stream::iter(vec![ + graphql::Response::builder() + .data(json!({ + "me": "name", + })) + .has_next(false) + .build(), + ]) .boxed(); Ok(SupergraphResponse::new_from_response( @@ -1738,9 +1830,9 @@ async fn multipart_response_shape_with_one_chunk() -> Result<(), ApolloRouterErr let first = response.chunk().await.unwrap().unwrap(); assert_eq!( - std::str::from_utf8(&first).unwrap(), - "\r\n--graphql\r\ncontent-type: application/json\r\n\r\n{\"data\":{\"me\":\"name\"},\"hasNext\":false}\r\n--graphql--\r\n" - ); + std::str::from_utf8(&first).unwrap(), + "\r\n--graphql\r\ncontent-type: application/json\r\n\r\n{\"data\":{\"me\":\"name\"},\"hasNext\":false}\r\n--graphql--\r\n" + ); server.shutdown().await } @@ -1774,7 +1866,7 @@ async fn it_supports_server_restart() { None, vec![], MultiMap::new(), - LicenseState::default(), + Arc::new(LicenseState::default()), all_connections_stopped_sender, ) .await @@ -1803,7 +1895,7 @@ async fn it_supports_server_restart() { supergraph_service_factory, new_configuration, MultiMap::new(), - LicenseState::default(), + Arc::new(LicenseState::default()), ) .await .unwrap(); @@ -1843,10 +1935,8 @@ async fn http_compressed_service() -> impl Service< let counter = GraphQLResponseCounter::default(); let service = TestHarness::builder() .configuration_json(json!({ - "plugins": { - "apollo.include_subgraph_errors": { - "all": true - } + "include_subgraph_errors": { + "all": true }, })) .unwrap() @@ -1895,10 +1985,8 @@ async fn http_deferred_service() -> impl Service< let counter = GraphQLResponseCounter::default(); let service = TestHarness::builder() .configuration_json(json!({ - "plugins": { - "apollo.include_subgraph_errors": { - "all": true - } + "include_subgraph_errors": { + "all": true } })) .unwrap() @@ -1918,12 +2006,12 @@ async fn http_deferred_service() -> impl Service< .await .unwrap() .map_err(Into::into) - .map_response(|response: http::Response| { + .map_response(|response: http::Response| { let response = response.map(|body| { - // Convert from axum’s BoxBody to AsyncBufRead - let mut body = Box::pin(body); - let stream = poll_fn(move |ctx| body.as_mut().poll_data(ctx)) - .map(|result| result.map_err(|e| io::Error::new(io::ErrorKind::Other, e))); + // Convert from axum's BoxBody to AsyncBufRead + let mut body = body.into_data_stream(); + let stream = poll_fn(move |ctx| body.poll_next_unpin(ctx)) + .map(|result| result.map_err(io::Error::other)); StreamReader::new(stream) }); response.map(|body| Box::pin(body) as _) @@ -2036,8 +2124,8 @@ async fn test_defer_is_not_buffered() { // `counts` is `[2, 2]` since both parts have to be generated on the server side // before the first one reaches the client. // - // Conversly, observing the value `1` after receiving the first part - // means the didn’t wait for all parts to be in the compression buffer + // Conversely, observing the value `1` after receiving the first part + // means the didn't wait for all parts to be in the compression buffer // before sending any. assert_eq!(counts, [1, 2]); } @@ -2089,24 +2177,125 @@ async fn listening_to_unix_socket() { server.shutdown().await.unwrap(); } +#[cfg(unix)] +pin_project! { + /// Wrapper around [`tokio::net::UnixStream`]. + #[derive(Debug)] + struct UnixStream { + #[pin] + unix_stream: tokio::net::UnixStream, + } +} + +#[cfg(unix)] +impl UnixStream { + async fn connect(path: impl AsRef) -> io::Result { + let unix_stream = tokio::net::UnixStream::connect(path).await?; + Ok(Self { unix_stream }) + } +} + +#[cfg(unix)] +impl AsyncWrite for UnixStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> Poll> { + self.project().unix_stream.poll_write(cx, buf) + } + + fn poll_flush( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + self.project().unix_stream.poll_flush(cx) + } + + fn poll_shutdown( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + self.project().unix_stream.poll_shutdown(cx) + } + + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + bufs: &[io::IoSlice<'_>], + ) -> Poll> { + self.project().unix_stream.poll_write_vectored(cx, bufs) + } + + fn is_write_vectored(&self) -> bool { + self.unix_stream.is_write_vectored() + } +} + +#[cfg(unix)] +impl hyper::rt::Write for UnixStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> Poll> { + self.project().unix_stream.poll_write(cx, buf) + } + + fn poll_flush( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + self.project().unix_stream.poll_flush(cx) + } + + fn poll_shutdown( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + self.project().unix_stream.poll_shutdown(cx) + } +} + +#[cfg(unix)] +impl AsyncRead for UnixStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + self.project().unix_stream.poll_read(cx, buf) + } +} + +#[cfg(unix)] +impl hyper::rt::Read for UnixStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: ReadBufCursor<'_>, + ) -> Poll> { + let mut t = TokioIo::new(self.project().unix_stream); + Pin::new(&mut t).poll_read(cx, buf) + } +} + #[cfg(unix)] async fn send_to_unix_socket(addr: &ListenAddr, method: Method, body: &str) -> String { - use tokio::net::UnixStream; let stream = UnixStream::connect(addr.to_string()).await.unwrap(); - let (mut sender, conn) = hyper::client::conn::handshake(stream).await.unwrap(); + let (mut sender, conn) = hyper::client::conn::http1::handshake(stream).await.unwrap(); tokio::task::spawn(async move { if let Err(err) = conn.await { - println!("Connection failed: {:?}", err); + println!("Connection failed: {err:?}"); } }); - let http_body = hyper::Body::from(body.to_string()); let mut request = http::Request::builder() .method(method.clone()) .header("Host", "localhost:4100") .header("Content-Type", "application/json") .header("Accept", "application/json") - .body(http_body) + .body(body.to_string()) .unwrap(); if method == Method::GET { *request.uri_mut() = body.parse().unwrap(); @@ -2117,66 +2306,11 @@ async fn send_to_unix_socket(addr: &ListenAddr, method: Method, body: &str) -> S String::from_utf8(body.to_vec()).unwrap() } -#[tokio::test] -async fn test_health_check() { - let router_service = router::service::from_supergraph_mock_callback(|_| { - Ok(supergraph::Response::builder() - .data(json!({ "__typename": "Query"})) - .context(Context::new()) - .build() - .unwrap()) - }) - .await; - - let (server, client) = init(router_service).await; - let url = format!( - "{}/health", - server.graphql_listen_address().as_ref().unwrap() - ); - - let response = client.get(url).send().await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - json!({"status": "UP" }), - response.json::().await.unwrap() - ) -} - -#[tokio::test] -async fn test_health_check_custom_listener() { - let conf = Configuration::fake_builder() - .health_check( - HealthCheck::fake_builder() - .listen(ListenAddr::SocketAddr("127.0.0.1:4012".parse().unwrap())) - .enabled(true) - .build(), - ) - .build() - .unwrap(); - - // keep the server handle around otherwise it will immediately shutdown - let (_server, client) = init_with_config( - router::service::empty().await, - Arc::new(conf), - MultiMap::new(), - ) - .await - .unwrap(); - let url = "http://localhost:4012/health"; - - let response = client.get(url).send().await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - json!({"status": "UP" }), - response.json::().await.unwrap() - ) -} - #[tokio::test] async fn test_sneaky_supergraph_and_health_check_configuration() { let conf = Configuration::fake_builder() .health_check( - HealthCheck::fake_builder() + HealthCheck::builder() .listen(ListenAddr::SocketAddr("127.0.0.1:0".parse().unwrap())) .enabled(true) .build(), @@ -2184,10 +2318,33 @@ async fn test_sneaky_supergraph_and_health_check_configuration() { .supergraph(Supergraph::fake_builder().path("/health").build()) // here be dragons .build() .unwrap(); + + // Manually add the endpoints, since they are only created if the health-check plugin is + // enabled and that won't happen in init_with_config() + let endpoint = service_fn(|req: router::Request| async move { + Ok::<_, BoxError>( + http::Response::builder() + .status(StatusCode::OK) + .body(format!( + "{} + {}", + req.router_request.method(), + req.router_request.uri().path() + )) + .unwrap() + .into(), + ) + }) + .boxed_clone(); + let mut web_endpoints = MultiMap::new(); + web_endpoints.insert( + ListenAddr::SocketAddr("127.0.0.1:0".parse().unwrap()), + Endpoint::from_router_service("/health".to_string(), endpoint.boxed()), + ); + let error = init_with_config( router::service::empty().await, Arc::new(conf), - MultiMap::new(), + web_endpoints, ) .await .unwrap_err(); @@ -2202,7 +2359,7 @@ async fn test_sneaky_supergraph_and_health_check_configuration() { async fn test_sneaky_supergraph_and_disabled_health_check_configuration() { let conf = Configuration::fake_builder() .health_check( - HealthCheck::fake_builder() + HealthCheck::builder() .listen(ListenAddr::SocketAddr("127.0.0.1:0".parse().unwrap())) .enabled(false) .build(), @@ -2223,7 +2380,7 @@ async fn test_sneaky_supergraph_and_disabled_health_check_configuration() { async fn test_supergraph_and_health_check_same_port_different_listener() { let conf = Configuration::fake_builder() .health_check( - HealthCheck::fake_builder() + HealthCheck::builder() .listen(ListenAddr::SocketAddr("127.0.0.1:4013".parse().unwrap())) .enabled(true) .build(), @@ -2249,111 +2406,74 @@ async fn test_supergraph_and_health_check_same_port_different_listener() { ); } +/// This tests that the apollo.router.open_connections metric is keeps track of connections +/// It's a replacement for the session count total metric that is more in line with otel conventions +/// It also has pipeline information attached to it. #[tokio::test] -async fn test_supergraph_timeout() { - let config = serde_json::json!({ - "supergraph": { - "listen": "127.0.0.1:0", - "defer_support": false, - }, - "traffic_shaping": { - "router": { - "timeout": "1ns" - } - }, - }); - - let conf: Arc = Arc::new(serde_json::from_value(config).unwrap()); - - let schema = include_str!("..//testdata/minimal_supergraph.graphql"); - let schema = Arc::new(Schema::parse(schema, &conf).unwrap()); - let planner = BridgeQueryPlannerPool::new( - Vec::new(), - schema.clone(), - conf.clone(), - NonZeroUsize::new(1).unwrap(), - ) - .await - .unwrap(); +async fn it_reports_open_connections_metric() { + let configuration = Configuration::fake_builder().build().unwrap(); - // we do the entire supergraph rebuilding instead of using `from_supergraph_mock_callback_and_configuration` - // because we need the plugins to apply on the supergraph - let mut plugins = create_plugins(&conf, &schema, planner.subgraph_schemas(), None, None) + async { + let (server, _client) = init_with_config( + router::service::empty().await, + Arc::new(configuration), + MultiMap::new(), + ) .await .unwrap(); - plugins.insert("delay".into(), Box::new(Delay)); - - struct Delay; + let url = format!( + "{}/graphql", + server + .graphql_listen_address() + .as_ref() + .expect("listen address") + ); - #[async_trait::async_trait] - impl crate::plugin::Plugin for Delay { - type Config = (); + let client = reqwest::Client::builder() + .pool_max_idle_per_host(1) + .build() + .unwrap(); - async fn new(_: crate::plugin::PluginInit<()>) -> Result { - Ok(Self) - } + let second_client = reqwest::Client::builder() + .pool_max_idle_per_host(1) + .build() + .unwrap(); - fn execution_service(&self, service: execution::BoxService) -> execution::BoxService { - service - .map_future(|fut| async { - tokio::time::sleep(Duration::from_millis(10)).await; - fut.await - }) - .boxed() - } - } + // Create a second client that does not reuse the same connection pool. + let _first_response = client + .post(url.clone()) + .body(r#"{ "query": "{ me }" }"#) + .send() + .await + .unwrap(); - let builder = PluggableSupergraphServiceBuilder::new(planner) - .with_configuration(conf.clone()) - .with_subgraph_service("accounts", MockSubgraph::new(HashMap::new())); + assert_eq!(*connection_counts().iter().next().unwrap().1, 1); - let supergraph_creator = builder - .with_plugins(Arc::new(plugins)) - .build() - .await - .unwrap(); + let _second_response = second_client + .post(url.clone()) + .body(r#"{ "query": "{ me }" }"#) + .send() + .await + .unwrap(); - let service = RouterCreator::new( - QueryAnalysisLayer::new(supergraph_creator.schema(), Arc::clone(&conf)).await, - Arc::new(PersistedQueryLayer::new(&conf).await.unwrap()), - Arc::new(supergraph_creator), - conf.clone(), - ) - .await - .unwrap() - .make(); + // Both requests are in-flight + assert_eq!(*connection_counts().iter().next().unwrap().1, 2); - // keep the server handle around otherwise it will immediately shutdown - let (server, client) = init_with_config(service, conf.clone(), MultiMap::new()) - .await - .unwrap(); - let url = server - .graphql_listen_address() - .as_ref() - .unwrap() - .to_string(); + // Connection is still open in the pool even though the request is complete. + assert_eq!(*connection_counts().iter().next().unwrap().1, 2); - let response = client - .post(url) - .body(r#"{ "query": "{ me }" }"#) - .send() - .await - .unwrap(); + drop(client); + drop(second_client); - assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + // XXX(@bryncooke): Not ideal, but we would probably have to drop down to very + // low-level hyper primitives to control the shutdown of connections to the required + // extent. 100ms is a long time so I hope it's not flaky. + tokio::time::sleep(Duration::from_millis(100)).await; - let body = response.bytes().await.unwrap(); - let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!( - body, - json!({ - "errors": [{ - "message": "Request timed out", - "extensions": { - "code": "REQUEST_TIMEOUT" - } - }] - }) - ); + // All connections are closed + assert_eq!(connection_counts().iter().count(), 0); + } + .with_metrics() + .await; } diff --git a/apollo-router/src/axum_factory/utils.rs b/apollo-router/src/axum_factory/utils.rs index 1e208fdb00..2a5b0accf3 100644 --- a/apollo-router/src/axum_factory/utils.rs +++ b/apollo-router/src/axum_factory/utils.rs @@ -1,6 +1,7 @@ //! Utilities used for [`super::AxumHttpServerFactory`] use std::net::SocketAddr; +use std::sync::Arc; use opentelemetry::global; use opentelemetry::trace::TraceContextExt; @@ -8,15 +9,15 @@ use tower_http::trace::MakeSpan; use tower_service::Service; use tracing::Span; +use crate::plugins::telemetry::SpanMode; use crate::plugins::telemetry::consts::OTEL_STATUS_CODE; use crate::plugins::telemetry::consts::OTEL_STATUS_CODE_ERROR; -use crate::plugins::telemetry::SpanMode; -use crate::uplink::license_enforcement::LicenseState; use crate::uplink::license_enforcement::LICENSE_EXPIRED_SHORT_MESSAGE; +use crate::uplink::license_enforcement::LicenseState; #[derive(Clone, Default)] pub(crate) struct PropagatingMakeSpan { - pub(crate) license: LicenseState, + pub(crate) license: Arc, pub(crate) span_mode: SpanMode, } @@ -26,7 +27,7 @@ impl MakeSpan for PropagatingMakeSpan { // Before we make the span we need to attach span info that may have come in from the request. let context = global::get_text_map_propagator(|propagator| { - propagator.extract(&opentelemetry_http::HeaderExtractor(request.headers())) + propagator.extract(&crate::otel_compat::HeaderExtractor(request.headers())) }); let use_legacy_request_span = matches!(self.span_mode, SpanMode::Deprecated); @@ -38,21 +39,21 @@ impl MakeSpan for PropagatingMakeSpan { // We have a valid remote span, attach it to the current thread before creating the root span. let _context_guard = context.attach(); if use_legacy_request_span { - self.span_mode.create_request(request, self.license) + self.span_mode.create_request(request, &self.license) } else { self.span_mode.create_router(request) } } else { // No remote span, we can go ahead and create the span without context. if use_legacy_request_span { - self.span_mode.create_request(request, self.license) + self.span_mode.create_request(request, &self.license) } else { self.span_mode.create_router(request) } }; if matches!( - self.license, - LicenseState::LicensedWarn | LicenseState::LicensedHalt + &*self.license, + LicenseState::LicensedWarn { limits: _ } | LicenseState::LicensedHalt { limits: _ } ) { span.record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); span.record("apollo_router.license", LICENSE_EXPIRED_SHORT_MESSAGE); @@ -62,6 +63,7 @@ impl MakeSpan for PropagatingMakeSpan { } } +#[derive(Clone)] pub(crate) struct InjectConnectionInfo { inner: S, connection_info: ConnectionInfo, diff --git a/apollo-router/src/batching.rs b/apollo-router/src/batching.rs index a66aca8d87..961c21b247 100644 --- a/apollo-router/src/batching.rs +++ b/apollo-router/src/batching.rs @@ -4,34 +4,34 @@ use std::collections::HashMap; use std::collections::HashSet; use std::fmt; +use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; -use std::sync::Arc; -use opentelemetry::trace::TraceContextExt; use opentelemetry::Context as otelContext; +use opentelemetry::trace::TraceContextExt; use parking_lot::Mutex as PMutex; +use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::oneshot; -use tokio::sync::Mutex; use tokio::task::JoinHandle; use tower::BoxError; use tracing::Instrument; use tracing::Span; +use crate::Context; use crate::error::FetchError; use crate::error::SubgraphBatchingError; use crate::graphql; use crate::plugins::telemetry::otel::span_ext::OpenTelemetrySpanExt; -use crate::query_planner::fetch::QueryHash; +use crate::services::SubgraphRequest; +use crate::services::SubgraphResponse; use crate::services::http::HttpClientServiceFactory; use crate::services::process_batches; -use crate::services::router::body::get_body_bytes; +use crate::services::router; use crate::services::router::body::RouterBody; use crate::services::subgraph::SubgraphRequestId; -use crate::services::SubgraphRequest; -use crate::services::SubgraphResponse; -use crate::Context; +use crate::spec::QueryHash; /// A query that is part of a batch. /// Note: It's ok to make transient clones of this struct, but *do not* store clones anywhere apart @@ -110,14 +110,16 @@ impl BatchQuery { .await .as_ref() .ok_or(SubgraphBatchingError::SenderUnavailable)? - .send(BatchHandlerMessage::Progress { - index: self.index, - client_factory, - request, - gql_request, - response_sender: tx, - span_context: Span::current().context(), - }) + .send(BatchHandlerMessage::Progress(Box::new( + BatchHandlerMessageProgress { + index: self.index, + client_factory, + request, + gql_request, + response_sender: tx, + span_context: Span::current().context(), + }, + ))) .await?; if !self.finished() { @@ -163,18 +165,13 @@ impl BatchQuery { // #[derive(Debug)] enum BatchHandlerMessage { /// Cancel one of the batch items - Cancel { index: usize, reason: String }, - - /// A query has reached the subgraph service and we should update its state - Progress { + Cancel { index: usize, - client_factory: HttpClientServiceFactory, - request: SubgraphRequest, - gql_request: graphql::Request, - response_sender: oneshot::Sender>, - span_context: otelContext, + reason: String, }, + Progress(Box), + /// A query has passed query planning and knows how many fetches are needed /// to complete. Begin { @@ -183,6 +180,16 @@ enum BatchHandlerMessage { }, } +/// A query has reached the subgraph service and we should update its state +struct BatchHandlerMessageProgress { + index: usize, + client_factory: HttpClientServiceFactory, + request: SubgraphRequest, + gql_request: graphql::Request, + response_sender: oneshot::Sender>, + span_context: otelContext, +} + /// Collection of info needed to resolve a batch query pub(crate) struct BatchQueryInfo { /// The owning subgraph request @@ -274,7 +281,7 @@ impl Batch { request, sender, .. } in cancelled_requests { - let subgraph_name = request.subgraph_name.ok_or(SubgraphBatchingError::MissingSubgraphName)?; + let subgraph_name = request.subgraph_name; if let Err(log_error) = sender.send(Err(Box::new(FetchError::SubrequestBatchingError { service: subgraph_name.clone(), reason: format!("request cancelled: {reason}"), @@ -306,15 +313,16 @@ impl Batch { ); } - BatchHandlerMessage::Progress { - index, - client_factory, - request, - gql_request, - response_sender, - span_context, - } => { + BatchHandlerMessage::Progress(progress) => { // Progress the index + let BatchHandlerMessageProgress { + index, + client_factory, + request, + gql_request, + response_sender, + span_context, + } = *progress; tracing::debug!("Progress index: {index}"); @@ -357,7 +365,7 @@ impl Batch { sender: tx, } in all_in_one { - let subgraph_name = sg_request.subgraph_name.clone().ok_or(SubgraphBatchingError::MissingSubgraphName)?; + let subgraph_name = sg_request.subgraph_name.clone(); let value = svc_map .entry( subgraph_name, @@ -441,7 +449,7 @@ pub(crate) async fn assemble_batch( let (requests, gql_requests): (Vec<_>, Vec<_>) = request_pairs.into_iter().unzip(); // Construct the actual byte body of the batched request - let bytes = get_body_bytes(serde_json::to_string(&gql_requests)?).await?; + let bytes = router::body::into_bytes(serde_json::to_string(&gql_requests)?).await?; // Retain the various contexts for later use let contexts = requests @@ -462,7 +470,7 @@ pub(crate) async fn assemble_batch( let (parts, _) = first_request.into_parts(); // Generate the final request and pass it up - let request = http::Request::from_parts(parts, RouterBody::from(bytes)); + let request = http::Request::from_parts(parts, router::body::from_bytes(bytes)); Ok((operation_name, contexts, request, txs)) } @@ -475,26 +483,27 @@ mod tests { use http::header::CONTENT_TYPE; use tokio::sync::oneshot; use tower::ServiceExt; - use wiremock::matchers; use wiremock::MockServer; use wiremock::ResponseTemplate; + use wiremock::matchers; - use super::assemble_batch; use super::Batch; use super::BatchQueryInfo; + use super::assemble_batch; + use crate::Configuration; + use crate::Context; + use crate::TestHarness; use crate::graphql; use crate::graphql::Request; use crate::layers::ServiceExt as LayerExt; - use crate::query_planner::fetch::QueryHash; + use crate::services::SubgraphRequest; + use crate::services::SubgraphResponse; use crate::services::http::HttpClientServiceFactory; use crate::services::router; + use crate::services::router::body; use crate::services::subgraph; use crate::services::subgraph::SubgraphRequestId; - use crate::services::SubgraphRequest; - use crate::services::SubgraphResponse; - use crate::Configuration; - use crate::Context; - use crate::TestHarness; + use crate::spec::QueryHash; #[tokio::test(flavor = "multi_thread")] async fn it_assembles_batch() { @@ -545,7 +554,8 @@ mod tests { // We should see the aggregation of all of the requests let actual: Vec = serde_json::from_str( - std::str::from_utf8(&request.into_body().to_bytes().await.unwrap()).unwrap(), + std::str::from_utf8(&router::body::into_bytes(request.into_body()).await.unwrap()) + .unwrap(), ) .unwrap(); @@ -573,7 +583,7 @@ mod tests { .body(graphql::Response::builder().data(data.clone()).build()) .unwrap(), context: Context::new(), - subgraph_name: None, + subgraph_name: String::default(), id: SubgraphRequestId(String::new()), }; @@ -611,17 +621,19 @@ mod tests { let bq = Batch::query_for_index(batch.clone(), 0).expect("its a valid index"); - assert!(bq - .set_query_hashes(vec![Arc::new(QueryHash::default())]) - .await - .is_ok()); + assert!( + bq.set_query_hashes(vec![Arc::new(QueryHash::default())]) + .await + .is_ok() + ); assert!(!bq.finished()); assert!(bq.signal_cancelled("why not?".to_string()).await.is_ok()); assert!(bq.finished()); - assert!(bq - .signal_cancelled("only once though".to_string()) - .await - .is_err()); + assert!( + bq.signal_cancelled("only once though".to_string()) + .await + .is_err() + ); } #[tokio::test(flavor = "multi_thread")] @@ -643,24 +655,27 @@ mod tests { ) .subgraph_name("whatever".to_string()) .build(); - assert!(bq - .set_query_hashes(vec![Arc::new(QueryHash::default())]) - .await - .is_ok()); + assert!( + bq.set_query_hashes(vec![Arc::new(QueryHash::default())]) + .await + .is_ok() + ); assert!(!bq.finished()); - assert!(bq - .signal_progress( + assert!( + bq.signal_progress( factory.clone(), request.clone(), graphql::Request::default() ) .await - .is_ok()); + .is_ok() + ); assert!(bq.finished()); - assert!(bq - .signal_progress(factory, request, graphql::Request::default()) - .await - .is_err()); + assert!( + bq.signal_progress(factory, request, graphql::Request::default()) + .await + .is_err() + ); } #[tokio::test(flavor = "multi_thread")] @@ -682,20 +697,23 @@ mod tests { ) .subgraph_name("whatever".to_string()) .build(); - assert!(bq - .set_query_hashes(vec![Arc::new(QueryHash::default())]) - .await - .is_ok()); + assert!( + bq.set_query_hashes(vec![Arc::new(QueryHash::default())]) + .await + .is_ok() + ); assert!(!bq.finished()); - assert!(bq - .signal_progress(factory, request, graphql::Request::default()) - .await - .is_ok()); + assert!( + bq.signal_progress(factory, request, graphql::Request::default()) + .await + .is_ok() + ); assert!(bq.finished()); - assert!(bq - .signal_cancelled("only once though".to_string()) - .await - .is_err()); + assert!( + bq.signal_cancelled("only once though".to_string()) + .await + .is_err() + ); } #[tokio::test(flavor = "multi_thread")] @@ -720,20 +738,23 @@ mod tests { let qh = Arc::new(QueryHash::default()); assert!(bq.set_query_hashes(vec![qh.clone(), qh]).await.is_ok()); assert!(!bq.finished()); - assert!(bq - .signal_progress(factory, request, graphql::Request::default()) - .await - .is_ok()); + assert!( + bq.signal_progress(factory, request, graphql::Request::default()) + .await + .is_ok() + ); assert!(!bq.finished()); - assert!(bq - .signal_cancelled("only twice though".to_string()) - .await - .is_ok()); + assert!( + bq.signal_cancelled("only twice though".to_string()) + .await + .is_ok() + ); assert!(bq.finished()); - assert!(bq - .signal_cancelled("only twice though".to_string()) - .await - .is_err()); + assert!( + bq.signal_cancelled("only twice though".to_string()) + .await + .is_err() + ); } fn expect_batch(request: &wiremock::Request) -> ResponseTemplate { @@ -741,7 +762,7 @@ mod tests { // Extract info about this operation let (subgraph, count): (String, usize) = { - let re = regex::Regex::new(r"entry([AB])\(count:([0-9]+)\)").unwrap(); + let re = regex::Regex::new(r"entry([AB])\(count: ?([0-9]+)\)").unwrap(); let captures = re.captures(requests[0].query.as_ref().unwrap()).unwrap(); (captures[1].to_string(), captures[2].parse().unwrap()) @@ -757,7 +778,7 @@ mod tests { assert_eq!( request.query, Some(format!( - "query op{index}__{}__0{{entry{}(count:{count}){{index}}}}", + "query op{index}__{}__0 {{ entry{}(count: {count}) {{ index }} }}", subgraph.to_lowercase(), subgraph )) @@ -798,6 +819,9 @@ mod tests { "include_subgraph_errors": { "all": true }, + "include_subgraph_errors": { + "all": true + }, "batching": { "enabled": true, "mode": "batch_http_link", @@ -845,7 +869,7 @@ mod tests { .method("POST") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(serde_json::to_vec(&request).unwrap().into()) + .body(body::from_bytes(serde_json::to_vec(&request).unwrap())) .unwrap(), }; diff --git a/apollo-router/src/cache/metrics.rs b/apollo-router/src/cache/metrics.rs new file mode 100644 index 0000000000..c128d43f5c --- /dev/null +++ b/apollo-router/src/cache/metrics.rs @@ -0,0 +1,533 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use fred::interfaces::MetricsInterface; +use fred::prelude::Pool as RedisPool; +use opentelemetry::KeyValue; +use opentelemetry::metrics::MeterProvider; +use opentelemetry::metrics::ObservableGauge; +use tokio::task::AbortHandle; + +use crate::metrics::meter_provider; + +/// Collection of Redis metrics gauges +pub(crate) struct RedisMetricsGauges { + pub(crate) _queue_length: ObservableGauge, + pub(crate) _network_latency: ObservableGauge, + pub(crate) _latency: ObservableGauge, + pub(crate) _request_size: ObservableGauge, + pub(crate) _response_size: ObservableGauge, +} + +/// Weighted sum data for calculating averages +#[derive(Default, Clone)] +struct WeightedSum { + weighted_sum: u64, + total_samples: u64, +} + +/// Configuration for metrics collection +struct MetricsConfig { + pool: Arc, + caller: &'static str, + metrics_interval: Duration, + queue_length: Arc, + network_latency_metric: WeightedAverageMetric, + latency_metric: WeightedAverageMetric, + request_size_metric: WeightedAverageMetric, + response_size_metric: WeightedAverageMetric, +} + +/// Configuration for a weighted average metric +#[derive(Clone)] +struct WeightedAverageMetric { + weighted_sum: Arc, + sample_count: Arc, + name: &'static str, + description: &'static str, + unit: &'static str, + unit_conversion: f64, // e.g., 1000.0 for ms->μs conversion +} + +/// Aggregated metrics collected from Redis clients +#[derive(Default)] +struct ClientMetrics { + total_redelivery_count: u64, + total_queue_len: u64, + total_commands_executed: u64, + network_latency: WeightedSum, + latency: WeightedSum, + request_size: WeightedSum, + response_size: WeightedSum, +} + +/// Redis metrics collection functionality +pub(crate) struct RedisMetricsCollector { + // Task handle and gauges + abort_handle: AbortHandle, + _gauges: RedisMetricsGauges, +} + +impl WeightedAverageMetric { + /// Create a new weighted average metric + fn new( + name: &'static str, + description: &'static str, + unit: &'static str, + unit_conversion: f64, + ) -> Self { + Self { + weighted_sum: Arc::new(AtomicU64::new(0)), + sample_count: Arc::new(AtomicU64::new(0)), + name, + description, + unit, + unit_conversion, + } + } + + /// Update the atomic counters with new weighted sum data + fn update(&self, weighted_sum: &WeightedSum) { + self.weighted_sum + .store(weighted_sum.weighted_sum, Ordering::Relaxed); + self.sample_count + .store(weighted_sum.total_samples, Ordering::Relaxed); + } +} + +impl Drop for RedisMetricsCollector { + fn drop(&mut self) { + self.abort_handle.abort(); + } +} + +impl RedisMetricsCollector { + /// Create a new metrics collector and start the collection task + pub(crate) fn new( + pool: Arc, + caller: &'static str, + metrics_interval: Duration, + ) -> Self { + // Create atomic counters for metrics + let queue_length = Arc::new(AtomicU64::new(0)); + + let network_latency_metric = WeightedAverageMetric::new( + "experimental.apollo.router.cache.redis.network_latency_avg", + "Average Redis network latency", + "s", + 1000.0, // Fred returns milliseconds, convert to seconds for display + ); + let latency_metric = WeightedAverageMetric::new( + "experimental.apollo.router.cache.redis.latency_avg", + "Average Redis command latency", + "s", + 1000.0, // Fred returns milliseconds, convert to seconds for display + ); + let request_size_metric = WeightedAverageMetric::new( + "experimental.apollo.router.cache.redis.request_size_avg", + "Average Redis request size", + "bytes", + 1.0, + ); + let response_size_metric = WeightedAverageMetric::new( + "experimental.apollo.router.cache.redis.response_size_avg", + "Average Redis response size", + "bytes", + 1.0, + ); + + let config = MetricsConfig { + pool: pool.clone(), + caller, + metrics_interval, + queue_length: queue_length.clone(), + network_latency_metric, + latency_metric, + request_size_metric, + response_size_metric, + }; + + let (abort_handle, gauges) = Self::start_collection_task_for_metrics(config); + + Self { + abort_handle, + _gauges: gauges, + } + } + + /// Start the metrics collection task and create gauges + fn start_collection_task_for_metrics( + config: MetricsConfig, + ) -> (AbortHandle, RedisMetricsGauges) { + let queue_length_gauge = + Self::create_queue_length_gauge(config.queue_length.clone(), config.caller); + let network_latency_gauge = + Self::create_weighted_average_gauge(&config.network_latency_metric, config.caller); + let latency_gauge = + Self::create_weighted_average_gauge(&config.latency_metric, config.caller); + let request_size_gauge = + Self::create_weighted_average_gauge(&config.request_size_metric, config.caller); + let response_size_gauge = + Self::create_weighted_average_gauge(&config.response_size_metric, config.caller); + let metrics_handle = Self::spawn_metrics_collection_task(config); + + let gauges = RedisMetricsGauges { + _queue_length: queue_length_gauge, + _network_latency: network_latency_gauge, + _latency: latency_gauge, + _request_size: request_size_gauge, + _response_size: response_size_gauge, + }; + + (metrics_handle.abort_handle(), gauges) + } + + /// Create the queue length observable gauge + fn create_queue_length_gauge( + queue_length: Arc, + caller: &'static str, + ) -> ObservableGauge { + let meter = meter_provider().meter("apollo/router"); + let queue_length_for_gauge = queue_length; + + meter + .u64_observable_gauge("apollo.router.cache.redis.command_queue_length") + .with_description("Number of Redis commands buffered and not yet sent") + .with_unit("{command}") + .with_callback(move |gauge| { + gauge.observe( + queue_length_for_gauge.load(Ordering::Relaxed), + &[KeyValue::new("kind", caller)], + ); + }) + .init() + } + + /// Generic method to create a weighted average gauge + fn create_weighted_average_gauge( + metric: &WeightedAverageMetric, + caller: &'static str, + ) -> ObservableGauge { + let meter = meter_provider().meter("apollo/router"); + let weighted_sum_for_gauge = metric.weighted_sum.clone(); + let sample_count_for_gauge = metric.sample_count.clone(); + let unit_conversion = metric.unit_conversion; + + meter + .f64_observable_gauge(metric.name) + .with_description(metric.description) + .with_unit(metric.unit) + .with_callback(move |gauge| { + let total_samples = sample_count_for_gauge.load(Ordering::Relaxed); + let weighted_sum = weighted_sum_for_gauge.load(Ordering::Relaxed); + + let average = if total_samples > 0 { + // Convert from milliseconds to seconds for display + (weighted_sum as f64) / (total_samples as f64) / unit_conversion + } else { + // Emit 0 to show the gauge exists even when no samples are available at scrape time + 0.0 + }; + + gauge.observe(average, &[KeyValue::new("kind", caller)]); + }) + .init() + } + + /// Spawn the metrics collection task + fn spawn_metrics_collection_task(config: MetricsConfig) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut interval = tokio::time::interval(config.metrics_interval); + loop { + interval.tick().await; + + let metrics = Self::collect_client_metrics(&config.pool); + + // Update atomic counters for gauges + config + .queue_length + .store(metrics.total_queue_len, Ordering::Relaxed); + config + .network_latency_metric + .update(&metrics.network_latency); + config.latency_metric.update(&metrics.latency); + config.request_size_metric.update(&metrics.request_size); + config.response_size_metric.update(&metrics.response_size); + + // Emit counters + Self::emit_counter_metrics(&metrics, config.caller); + } + }) + } + + /// Collect metrics from all Redis clients + fn collect_client_metrics(pool: &Arc) -> ClientMetrics { + let mut metrics = ClientMetrics::default(); + + for client in pool.clients() { + // Basic metrics always available + let redelivery_count = client.take_redelivery_count(); + metrics.total_redelivery_count += redelivery_count as u64; + + let queue_len = client.command_queue_len(); + metrics.total_queue_len += queue_len as u64; + + // Collect weighted average metrics directly + Self::update_average_weighted_metric( + client.take_network_latency_metrics(), + &mut metrics.network_latency, + 1.0, // Fred returns milliseconds, store as-is for precision + ); + + Self::update_average_weighted_metric( + client.take_latency_metrics(), + &mut metrics.latency, + 1.0, // Fred returns milliseconds, store as-is for precision + ); + + Self::update_average_weighted_metric( + client.take_req_size_metrics(), + &mut metrics.request_size, + 1.0, + ); + + Self::update_average_weighted_metric( + client.take_res_size_metrics(), + &mut metrics.response_size, + 1.0, + ); + + // Get commands executed from latency stats (already collected above) + // Note: We use latency samples as a proxy for total commands executed + // since latency stats track all commands that were executed + } + + // Set total commands executed based on latency samples + metrics.total_commands_executed = metrics.latency.total_samples; + + metrics + } + + /// Generic method to collect weighted metrics + fn update_average_weighted_metric( + stats: fred::types::Stats, + weighted_sum: &mut WeightedSum, + unit_conversion: f64, + ) { + if stats.samples > 0 { + // Apply unit conversion (Fred returns milliseconds) + let converted_avg = (stats.avg * unit_conversion) as u64; + weighted_sum.weighted_sum += converted_avg * stats.samples; + weighted_sum.total_samples += stats.samples; + } + } + + /// Emit counter metrics + fn emit_counter_metrics(metrics: &ClientMetrics, caller: &'static str) { + u64_counter_with_unit!( + "apollo.router.cache.redis.redelivery_count", + "Number of Redis command redeliveries due to connection issues", + "{redelivery}", + metrics.total_redelivery_count, + kind = caller + ); + + u64_counter_with_unit!( + "apollo.router.cache.redis.commands_executed", + "Number of Redis commands executed", + "{command}", + metrics.total_commands_executed, + kind = caller + ); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use fred::mocks::SimpleMap; + + use crate::cache::redis::RedisCacheStorage; + use crate::cache::redis::RedisKey; + use crate::cache::redis::RedisValue; + use crate::cache::storage::ValueType; + use crate::metrics::FutureMetricsExt; + + #[test] + fn test_weighted_sum_calculation() { + let mut weighted_sum = super::WeightedSum::default(); + + // Test adding first stats + super::RedisMetricsCollector::update_average_weighted_metric( + fred::types::Stats { + avg: 10.0, // 10ms (Fred returns milliseconds) + samples: 5, + max: 15, + min: 5, + stddev: 2.0, + sum: 50, + }, + &mut weighted_sum, + 1.0, // Store milliseconds as-is + ); + + assert_eq!(weighted_sum.total_samples, 5); + assert_eq!(weighted_sum.weighted_sum, 50); // 10.0 * 1.0 * 5 = 50 milliseconds + + // Test adding more stats + super::RedisMetricsCollector::update_average_weighted_metric( + fred::types::Stats { + avg: 20.0, // 20ms (Fred returns milliseconds) + samples: 3, + max: 25, + min: 15, + stddev: 3.0, + sum: 60, + }, + &mut weighted_sum, + 1.0, + ); + + assert_eq!(weighted_sum.total_samples, 8); // 5 + 3 + assert_eq!(weighted_sum.weighted_sum, 110); // 50 + 60 milliseconds + } + + #[test] + fn test_weighted_sum_with_zero_samples() { + let mut weighted_sum = super::WeightedSum::default(); + + // Test that zero samples don't affect the weighted sum + super::RedisMetricsCollector::update_average_weighted_metric( + fred::types::Stats { + avg: 0.010, // 10ms in seconds + samples: 0, + max: 0, + min: 0, + stddev: 0.0, + sum: 0, + }, + &mut weighted_sum, + 1000000.0, + ); + + assert_eq!(weighted_sum.total_samples, 0); + assert_eq!(weighted_sum.weighted_sum, 0); + } + + #[test] + fn test_weighted_sum_with_unit_conversion() { + let mut weighted_sum = super::WeightedSum::default(); + + // Test with different unit conversions (bytes - no conversion) + super::RedisMetricsCollector::update_average_weighted_metric( + fred::types::Stats { + avg: 100.0, + samples: 2, + max: 120, + min: 80, + stddev: 20.0, + sum: 200, + }, + &mut weighted_sum, + 1.0, // No conversion (bytes) + ); + + assert_eq!(weighted_sum.total_samples, 2); + assert_eq!(weighted_sum.weighted_sum, 200); // 100.0 * 1.0 * 2 + } + + #[test] + fn test_latency_metric_conversion_to_seconds() { + // This test demonstrates that latency metrics are correctly converted to seconds + let mut weighted_sum = super::WeightedSum::default(); + + // Simulate Redis latency stats (Fred returns milliseconds) + super::RedisMetricsCollector::update_average_weighted_metric( + fred::types::Stats { + avg: 5.0, // 5ms (Fred returns milliseconds) + samples: 10, + max: 8, + min: 2, + stddev: 1.5, + sum: 50, + }, + &mut weighted_sum, + 1.0, // Store milliseconds as-is + ); + + assert_eq!(weighted_sum.total_samples, 10); + assert_eq!(weighted_sum.weighted_sum, 50); // 5.0 * 1.0 * 10 = 50 milliseconds + + // Verify conversion to seconds for gauge emission + let final_average_seconds = + (weighted_sum.weighted_sum as f64) / (weighted_sum.total_samples as f64) / 1000.0; + assert_eq!(final_average_seconds, 0.005); // Should be 0.005 seconds (5ms converted from ms) + } + + #[tokio::test] + async fn test_redis_storage_with_mocks() { + async { + let simple_map = Arc::new(SimpleMap::new()); + let storage = RedisCacheStorage::from_mocks(simple_map.clone()) + .await + .expect("Failed to create Redis storage with mocks"); + + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] + struct TestValue { + data: String, + } + + impl ValueType for TestValue { + fn estimated_size(&self) -> Option { + Some(self.data.len()) + } + } + + let test_key = RedisKey("test_key".to_string()); + let test_value = RedisValue(TestValue { + data: "test_value".to_string(), + }); + + // Perform Redis operations + storage + .insert(test_key.clone(), test_value.clone(), None) + .await; + let retrieved: Option> = storage.get(test_key.clone()).await; + + // Verify the mock actually worked + assert!(retrieved.is_some(), "Should have retrieved value from mock"); + assert_eq!(retrieved.unwrap().0.data, "test_value"); + + // Verify Redis connection metrics are emitted + assert_counter!("apollo.router.cache.redis.connections", 1, kind = "test"); + + // Pause to ensure that queue length is zero + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Verify Redis gauge metrics are available (observables are created immediately) + assert_gauge!( + "apollo.router.cache.redis.command_queue_length", + 0.0, + kind = "test" + ); + + // Verify Redis average metrics are available (may be 0 initially) + assert_gauge!( + "experimental.apollo.router.cache.redis.latency_avg", + 0.0, + kind = "test" + ); + + assert_gauge!( + "experimental.apollo.router.cache.redis.network_latency_avg", + 0.0, + kind = "test" + ); + } + .with_metrics() + .await; + } +} diff --git a/apollo-router/src/cache/mod.rs b/apollo-router/src/cache/mod.rs index 04b578a437..d4e7a486ea 100644 --- a/apollo-router/src/cache/mod.rs +++ b/apollo-router/src/cache/mod.rs @@ -2,9 +2,9 @@ use std::collections::HashMap; use std::num::NonZeroUsize; use std::sync::Arc; +use tokio::sync::Mutex; use tokio::sync::broadcast; use tokio::sync::oneshot; -use tokio::sync::Mutex; use tower::BoxError; use self::storage::CacheStorage; @@ -13,12 +13,16 @@ use self::storage::KeyType; use self::storage::ValueType; use crate::configuration::RedisCache; +mod metrics; pub(crate) mod redis; mod size_estimation; pub(crate) mod storage; +use std::convert::Infallible; + pub(crate) use size_estimation::estimate_size; -type WaitMap = Arc>>>; +type WaitMap = + Arc>>>>; pub(crate) const DEFAULT_CACHE_CAPACITY: NonZeroUsize = match NonZeroUsize::new(512) { Some(v) => v, None => unreachable!(), @@ -26,15 +30,20 @@ pub(crate) const DEFAULT_CACHE_CAPACITY: NonZeroUsize = match NonZeroUsize::new( /// Cache implementation with query deduplication #[derive(Clone)] -pub(crate) struct DeduplicatingCache { - wait_map: WaitMap, +pub(crate) struct DeduplicatingCache +where + K: KeyType, + V: ValueType, +{ + wait_map: WaitMap, storage: CacheStorage, } -impl DeduplicatingCache +impl DeduplicatingCache where K: KeyType + 'static, V: ValueType + 'static, + UncachedError: Clone + Send + 'static, { pub(crate) async fn with_capacity( capacity: NonZeroUsize, @@ -60,7 +69,7 @@ where &self, key: &K, init_from_redis: impl FnMut(&mut V) -> Result<(), String>, - ) -> Entry { + ) -> Entry { // waiting on a value from the cache is a potentially long(millisecond scale) task that // can involve a network call to an external database. To reduce the waiting time, we // go through a wait map to register interest in data associated with a key. @@ -99,7 +108,7 @@ where drop(locked_wait_map); if let Some(value) = self.storage.get(key, init_from_redis).await { - self.send(sender, key, value.clone()).await; + self.send(sender, key, Ok(value.clone())).await; return Entry { inner: EntryInner::Value(value), @@ -126,7 +135,12 @@ where self.storage.insert_in_memory(key, value).await; } - async fn send(&self, sender: broadcast::Sender, key: &K, value: V) { + async fn send( + &self, + sender: broadcast::Sender>, + key: &K, + value: Result, + ) { // Lock the wait map to prevent more subscribers racing with our send // notification let mut locked_wait_map = self.wait_map.lock().await; @@ -141,46 +155,56 @@ where pub(crate) fn activate(&self) { self.storage.activate() } + + #[cfg(test)] + pub(crate) async fn len(&self) -> usize { + self.storage.len().await + } } -pub(crate) struct Entry { - inner: EntryInner, +pub(crate) struct Entry { + inner: EntryInner, } -enum EntryInner { +enum EntryInner { First { key: K, - sender: broadcast::Sender, - cache: DeduplicatingCache, + sender: broadcast::Sender>, + cache: DeduplicatingCache, _drop_signal: oneshot::Sender<()>, }, Receiver { - receiver: broadcast::Receiver, + receiver: broadcast::Receiver>, }, Value(V), } #[derive(Debug)] -pub(crate) enum EntryError { - Closed, +pub(crate) enum EntryError { IsFirst, + RecvError, + UncachedError(UncachedError), } -impl Entry +impl Entry where K: KeyType + 'static, V: ValueType + 'static, + UncachedError: Clone + Send + 'static, { pub(crate) fn is_first(&self) -> bool { matches!(self.inner, EntryInner::First { .. }) } - pub(crate) async fn get(self) -> Result { + pub(crate) async fn get(self) -> Result> { match self.inner { // there was already a value in cache EntryInner::Value(v) => Ok(v), - EntryInner::Receiver { mut receiver } => { - receiver.recv().await.map_err(|_| EntryError::Closed) - } + EntryInner::Receiver { mut receiver } => match receiver.recv().await { + Ok(Ok(value)) => Ok(value), + Ok(Err(e)) => Err(EntryError::UncachedError(e)), + Err(broadcast::error::RecvError::Closed) + | Err(broadcast::error::RecvError::Lagged(_)) => Err(EntryError::RecvError), + }, _ => Err(EntryError::IsFirst), } } @@ -194,13 +218,13 @@ where } = self.inner { cache.insert(key.clone(), value.clone()).await; - cache.send(sender, &key, value).await; + cache.send(sender, &key, Ok(value)).await; } } /// sends the value without storing it into the cache #[allow(unused)] - pub(crate) async fn send(self, value: V) { + pub(crate) async fn send(self, value: Result) { if let EntryInner::First { sender, cache, key, .. } = self.inner @@ -224,9 +248,10 @@ mod tests { #[tokio::test] async fn example_cache_usage() { let k = "key".to_string(); - let cache = DeduplicatingCache::with_capacity(NonZeroUsize::new(1).unwrap(), None, "test") - .await - .unwrap(); + let cache: DeduplicatingCache = + DeduplicatingCache::with_capacity(NonZeroUsize::new(1).unwrap(), None, "test") + .await + .unwrap(); let entry = cache.get(&k, |_| Ok(())).await; diff --git a/apollo-router/src/cache/redis.rs b/apollo-router/src/cache/redis.rs index c8d7b10d07..645f866e0a 100644 --- a/apollo-router/src/cache/redis.rs +++ b/apollo-router/src/cache/redis.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::fmt; +use std::ops::Deref; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; @@ -7,28 +8,34 @@ use std::time::Duration; use fred::interfaces::EventInterface; #[cfg(test)] use fred::mocks::Mocks; +use fred::prelude::Client as RedisClient; use fred::prelude::ClientLike; +use fred::prelude::Error as RedisError; +use fred::prelude::ErrorKind as RedisErrorKind; +use fred::prelude::HeartbeatInterface; use fred::prelude::KeysInterface; -use fred::prelude::RedisClient; -use fred::prelude::RedisError; -use fred::prelude::RedisErrorKind; -use fred::prelude::RedisPool; -use fred::types::ClusterRouting; +use fred::prelude::Pool as RedisPool; +use fred::prelude::TcpConfig; +use fred::types::Builder; use fred::types::Expiration; -use fred::types::FromRedis; -use fred::types::PerformanceConfig; -use fred::types::ReconnectPolicy; -use fred::types::RedisConfig; -use fred::types::ScanResult; -use fred::types::TlsConfig; -use fred::types::TlsHostMapping; +use fred::types::FromValue; +use fred::types::cluster::ClusterRouting; +use fred::types::config::Config as RedisConfig; +use fred::types::config::ReconnectPolicy; +use fred::types::config::TlsConfig; +use fred::types::config::TlsHostMapping; +use fred::types::config::UnresponsiveConfig; +use fred::types::scan::ScanResult; use futures::FutureExt; use futures::Stream; +use tokio::sync::broadcast::error::RecvError; +use tokio::task::AbortHandle; use tower::BoxError; use url::Url; use super::KeyType; use super::ValueType; +use super::metrics::RedisMetricsCollector; use crate::configuration::RedisCache; use crate::services::generate_tls_client_config; @@ -41,6 +48,54 @@ const SUPPORTED_REDIS_SCHEMES: [&str; 6] = [ "rediss-sentinel", ]; +/// Timeout applied to internal Redis operations, such as TCP connection initialization, TLS handshakes, AUTH or HELLO, cluster health checks, etc. +const DEFAULT_INTERNAL_REDIS_TIMEOUT: Duration = Duration::from_secs(5); +/// Interval on which we send PING commands to the Redis servers. +const REDIS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10); + +/// Record a Redis error as a metric, independent of having an active connection +fn record_redis_error(error: &RedisError, caller: &'static str) { + // Don't track NotFound errors as they're expected for cache misses + + let error_type = match error.kind() { + RedisErrorKind::Config => "config", + RedisErrorKind::Auth => "auth", + RedisErrorKind::Routing => "routing", + RedisErrorKind::IO => "io", + RedisErrorKind::InvalidCommand => "invalid_command", + RedisErrorKind::InvalidArgument => "invalid_argument", + RedisErrorKind::Url => "url", + RedisErrorKind::Protocol => "protocol", + RedisErrorKind::Tls => "tls", + RedisErrorKind::Canceled => "canceled", + RedisErrorKind::Unknown => "unknown", + RedisErrorKind::Timeout => "timeout", + RedisErrorKind::Cluster => "cluster", + RedisErrorKind::Parse => "parse", + RedisErrorKind::Sentinel => "sentinel", + RedisErrorKind::NotFound => "not_found", + RedisErrorKind::Backpressure => "backpressure", + }; + + u64_counter_with_unit!( + "apollo.router.cache.redis.errors", + "Number of Redis errors by type", + "{error}", + 1, + kind = caller, + error_type = error_type + ); + + if !error.is_not_found() && !error.is_canceled() { + tracing::error!( + error_type = error_type, + caller = caller, + error = ?error, + "Redis error occurred" + ); + } +} + #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub(crate) struct RedisKey(pub(crate) K) where @@ -51,13 +106,52 @@ pub(crate) struct RedisValue(pub(crate) V) where V: ValueType; +/// `DropSafeRedisPool` is a wrapper for `fred::prelude::RedisPool` which closes the pool's Redis +/// connections when it is dropped. +// +// Dev notes: +// * the inner `RedisPool` must be wrapped in an `Arc` because closing the connections happens +// in a spawned async task. +// * why not just implement this within `Drop` for `RedisCacheStorage`? Because `RedisCacheStorage` +// is cloned frequently throughout the router, and we don't want to close the connections +// when each clone is dropped, only when the last instance is dropped. +struct DropSafeRedisPool { + pool: Arc, + heartbeat_abort_handle: AbortHandle, + // Metrics collector handles its own abort and gauges + _metrics_collector: RedisMetricsCollector, +} + +impl Deref for DropSafeRedisPool { + type Target = RedisPool; + + fn deref(&self) -> &Self::Target { + &self.pool + } +} + +impl Drop for DropSafeRedisPool { + fn drop(&mut self) { + let inner = self.pool.clone(); + tokio::spawn(async move { + let result = inner.quit().await; + if let Err(err) = result { + tracing::warn!("Caught error while closing unused Redis connections: {err:?}"); + } + }); + self.heartbeat_abort_handle.abort(); + // Metrics collector will be dropped automatically and its Drop impl will abort the task + } +} + #[derive(Clone)] pub(crate) struct RedisCacheStorage { - inner: Arc, + inner: Arc, namespace: Option>, pub(crate) ttl: Option, is_cluster: bool, reset_ttl: bool, + caller: &'static str, } fn get_type_of(_: &T) -> &'static str { @@ -73,7 +167,7 @@ where } } -impl From> for fred::types::RedisKey +impl From> for fred::types::Key where K: KeyType, { @@ -91,13 +185,13 @@ where } } -impl FromRedis for RedisValue +impl FromValue for RedisValue where V: ValueType, { - fn from_value(value: fred::types::RedisValue) -> Result { + fn from_value(value: fred::types::Value) -> Result { match value { - fred::types::RedisValue::Bytes(data) => { + fred::types::Value::Bytes(data) => { serde_json::from_slice(&data).map(RedisValue).map_err(|e| { RedisError::new( RedisErrorKind::Parse, @@ -105,7 +199,7 @@ where ) }) } - fred::types::RedisValue::String(s) => { + fred::types::Value::String(s) => { serde_json::from_str(&s).map(RedisValue).map_err(|e| { RedisError::new( RedisErrorKind::Parse, @@ -113,9 +207,7 @@ where ) }) } - fred::types::RedisValue::Null => { - Err(RedisError::new(RedisErrorKind::NotFound, "not found")) - } + fred::types::Value::Null => Err(RedisError::new(RedisErrorKind::NotFound, "not found")), _res => Err(RedisError::new( RedisErrorKind::Parse, "the data is the wrong type", @@ -124,27 +216,27 @@ where } } -impl TryInto for RedisValue +impl TryInto for RedisValue where V: ValueType, { type Error = RedisError; - fn try_into(self) -> Result { + fn try_into(self) -> Result { let v = serde_json::to_vec(&self.0).map_err(|e| { tracing::error!("couldn't serialize value to redis {}. This is a bug in the router, please file an issue: https://github.com/apollographql/router/issues/new", e); RedisError::new( RedisErrorKind::Parse, - format!("couldn't serialize value to redis {}", e), + format!("couldn't serialize value to redis {e}"), ) })?; - Ok(fred::types::RedisValue::Bytes(v.into())) + Ok(fred::types::Value::Bytes(v.into())) } } impl RedisCacheStorage { - pub(crate) async fn new(config: RedisCache) -> Result { + pub(crate) async fn new(config: RedisCache, caller: &'static str) -> Result { let url = Self::preprocess_urls(config.urls)?; let mut client_config = RedisConfig::from_url(url.as_str())?; let is_cluster = url.scheme() == "redis-cluster" || url.scheme() == "rediss-cluster"; @@ -160,11 +252,14 @@ impl RedisCacheStorage { if let Some(tls) = config.tls.as_ref() { let tls_cert_store = tls.create_certificate_store().transpose()?; let client_cert_config = tls.client_authentication.as_ref(); - let tls_client_config = generate_tls_client_config(tls_cert_store, client_cert_config)?; + let tls_client_config = generate_tls_client_config( + tls_cert_store, + client_cert_config.map(|arc| arc.as_ref()), + )?; let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_client_config)); client_config.tls = Some(TlsConfig { - connector: fred::types::TlsConnector::Rustls(connector), + connector: fred::types::config::TlsConnector::Rustls(connector), hostnames: TlsHostMapping::None, }); } @@ -177,6 +272,8 @@ impl RedisCacheStorage { config.ttl, config.reset_ttl, is_cluster, + caller, + config.metrics_interval, ) .await } @@ -196,10 +293,13 @@ impl RedisCacheStorage { None, false, false, + "test", + Duration::from_millis(100), ) .await } + #[allow(clippy::too_many_arguments)] async fn create_client( client_config: RedisConfig, timeout: Duration, @@ -208,50 +308,107 @@ impl RedisCacheStorage { ttl: Option, reset_ttl: bool, is_cluster: bool, + caller: &'static str, + metrics_interval: Duration, ) -> Result { - let pooled_client = RedisPool::new( - client_config, - Some(PerformanceConfig { - default_command_timeout: timeout, - ..Default::default() - }), - None, - Some(ReconnectPolicy::new_exponential(0, 1, 2000, 5)), - pool_size, - )?; - let _handle = pooled_client.connect(); + let pooled_client = Builder::from_config(client_config) + .with_connection_config(|config| { + config.internal_command_timeout = DEFAULT_INTERNAL_REDIS_TIMEOUT; + config.reconnect_on_auth_error = true; + config.tcp = TcpConfig { + #[cfg(target_os = "linux")] + user_timeout: Some(timeout), + ..Default::default() + }; + config.unresponsive = UnresponsiveConfig { + max_timeout: Some(DEFAULT_INTERNAL_REDIS_TIMEOUT), + interval: Duration::from_secs(3), + }; + }) + .with_performance_config(|config| { + config.default_command_timeout = timeout; + }) + .set_policy(ReconnectPolicy::new_exponential(0, 1, 2000, 5)) + .build_pool(pool_size)?; for client in pooled_client.clients() { // spawn tasks that listen for connection close or reconnect events let mut error_rx = client.error_rx(); let mut reconnect_rx = client.reconnect_rx(); + i64_up_down_counter_with_unit!( + "apollo.router.cache.redis.connections", + "Number of Redis connections", + "{connection}", + 1, + kind = caller + ); + tokio::spawn(async move { - while let Ok(error) = error_rx.recv().await { - tracing::error!("Client disconnected with error: {:?}", error); + loop { + match error_rx.recv().await { + Ok((error, Some(server))) => { + tracing::error!( + "Redis client disconnected from {server:?} with error: {error:?}", + ) + } + Ok((error, None)) => { + tracing::error!("Redis client disconnected with error: {error:?}",) + } + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => break, + } } }); tokio::spawn(async move { - while reconnect_rx.recv().await.is_ok() { - tracing::info!("Redis client reconnected."); + loop { + match reconnect_rx.recv().await { + Ok(server) => tracing::info!("Redis client connected to {server:?}"), + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => break, + } } + + // NB: closing the Redis client connection will also close the error, pubsub, and + // reconnection event streams, so the above while loop will only terminate when the + // connection closes. + i64_up_down_counter_with_unit!( + "apollo.router.cache.redis.connections", + "Number of Redis connections", + "{connection}", + -1, + kind = caller + ); }); } - // a TLS connection to a TCP Redis could hang, so we add a timeout - tokio::time::timeout(Duration::from_secs(5), pooled_client.wait_for_connect()) - .await - .map_err(|_| { - RedisError::new(RedisErrorKind::Timeout, "timeout connecting to Redis") - })??; + let _handle = pooled_client.init().await.inspect_err(|e| { + // Record connection failure as metrics even when initial setup fails + record_redis_error(e, caller); + })?; + let heartbeat_clients = pooled_client.clone(); + let heartbeat_handle = tokio::spawn(async move { + heartbeat_clients + .enable_heartbeat(REDIS_HEARTBEAT_INTERVAL, false) + .await + }); + + let pooled_client_arc = Arc::new(pooled_client); + let metrics_collector = + RedisMetricsCollector::new(pooled_client_arc.clone(), caller, metrics_interval); tracing::trace!("redis connection established"); Ok(Self { - inner: Arc::new(pooled_client), + inner: Arc::new(DropSafeRedisPool { + pool: pooled_client_arc, + heartbeat_abort_handle: heartbeat_handle.abort_handle(), + _metrics_collector: metrics_collector, + }), namespace: namespace.map(Arc::new), ttl, is_cluster, reset_ttl, + caller, }) } @@ -259,6 +416,11 @@ impl RedisCacheStorage { self.ttl } + /// Helper method to record Redis errors for metrics + fn record_error(&self, error: &RedisError) { + record_redis_error(error, self.caller); + } + fn preprocess_urls(urls: Vec) -> Result { let url_len = urls.len(); let mut urls_iter = urls.into_iter(); @@ -286,8 +448,7 @@ impl RedisCacheStorage { return Err(RedisError::new( RedisErrorKind::Config, format!( - "invalid Redis URL scheme, expected a scheme from {SUPPORTED_REDIS_SCHEMES:?}, got: {}", - scheme + "invalid Redis URL scheme, expected a scheme from {SUPPORTED_REDIS_SCHEMES:?}, got: {scheme}" ), )); } @@ -363,65 +524,42 @@ impl RedisCacheStorage { &self, key: RedisKey, ) -> Option> { - if self.reset_ttl && self.ttl.is_some() { - let pipeline: fred::clients::Pipeline = self.inner.next().pipeline(); - let key = self.make_key(key); - let res = pipeline - .get::(&key) - .await - .map_err(|e| { - if !e.is_not_found() { - tracing::error!(error = %e, "redis get error"); - } - e - }) - .ok()?; - if !res.is_queued() { - tracing::error!("could not queue GET command"); - return None; - } - let res: fred::types::RedisValue = pipeline - .expire( - &key, - self.ttl - .expect("we already checked the presence of ttl") - .as_secs() as i64, - ) - .await - .map_err(|e| { - if !e.is_not_found() { - tracing::error!(error = %e, "redis get error"); - } - e - }) - .ok()?; - if !res.is_queued() { - tracing::error!("could not queue EXPIRE command"); - return None; - } + match self.ttl { + Some(ttl) if self.reset_ttl => { + let pipeline: fred::clients::Pipeline = self.inner.next().pipeline(); + let key = self.make_key(key); + let res = pipeline + .get::(&key) + .await + .inspect_err(|e| self.record_error(e)) + .ok()?; + if !res.is_queued() { + tracing::error!("could not queue GET command"); + return None; + } + let res: fred::types::Value = pipeline + .expire(&key, ttl.as_secs() as i64, None) + .await + .inspect_err(|e| self.record_error(e)) + .ok()?; + if !res.is_queued() { + tracing::error!("could not queue EXPIRE command"); + return None; + } - let (first, _): (Option>, bool) = pipeline - .all() - .await - .map_err(|e| { - if !e.is_not_found() { - tracing::error!(error = %e, "redis get error"); - } - e - }) - .ok()?; - first - } else { - self.inner + let (first, _): (Option>, bool) = pipeline + .all() + .await + .inspect_err(|e| self.record_error(e)) + .ok()?; + first + } + _ => self + .inner .get::, _>(self.make_key(key)) .await - .map_err(|e| { - if !e.is_not_found() { - tracing::error!(error = %e, "redis get error"); - } - e - }) - .ok() + .inspect_err(|e| self.record_error(e)) + .ok(), } } @@ -436,19 +574,14 @@ impl RedisCacheStorage { .inner .get::, _>(self.make_key(keys.remove(0))) .await - .map_err(|e| { - if !e.is_not_found() { - tracing::error!("get error: {}", e); - } - e - }) + .inspect_err(|e| self.record_error(e)) .ok(); Some(vec![res]) } else if self.is_cluster { // when using a cluster of redis nodes, the keys are hashed, and the hash number indicates which // node will store it. So first we have to group the keys by hash, because we cannot do a MGET - // across multipe nodes (error: "ERR CROSSSLOT Keys in request don't hash to the same slot") + // across multiple nodes (error: "ERR CROSSSLOT Keys in request don't hash to the same slot") let len = keys.len(); let mut h: HashMap, Vec)> = HashMap::new(); for (index, key) in keys.into_iter().enumerate() { @@ -473,7 +606,7 @@ impl RedisCacheStorage { for (indexes, result) in results.into_iter() { match result { Err(e) => { - tracing::error!("mget error: {}", e); + self.record_error(&e); return None; } Ok(values) => { @@ -493,13 +626,7 @@ impl RedisCacheStorage { .collect::>(), ) .await - .map_err(|e| { - if !e.is_not_found() { - tracing::error!("mget error: {}", e); - } - - e - }) + .inspect_err(|e| self.record_error(e)) .ok() } } @@ -555,10 +682,11 @@ impl RedisCacheStorage { tracing::trace!("insert result {:?}", r); } - pub(crate) async fn delete(&self, keys: Vec>) -> Option { - let mut h: HashMap> = HashMap::new(); + /// Delete keys *without* adding the `namespace` prefix because `keys` is from + /// `scan_with_namespaced_results` and already includes it. + pub(crate) async fn delete_from_scan_result(&self, keys: Vec) -> Option { + let mut h: HashMap> = HashMap::new(); for key in keys.into_iter() { - let key = self.make_key(key); let hash = ClusterRouting::hash_key(key.as_bytes()); let entry = h.entry(hash).or_default(); entry.push(key); @@ -573,7 +701,7 @@ impl RedisCacheStorage { match res { Ok(res) => total += res, Err(e) => { - tracing::error!(error = %e, "redis del error"); + self.record_error(&e); } } } @@ -581,11 +709,13 @@ impl RedisCacheStorage { Some(total) } - pub(crate) fn scan( + /// The keys returned in `ScanResult` do include the prefix from `namespace` configuration. + pub(crate) fn scan_with_namespaced_results( &self, pattern: String, count: Option, ) -> Pin> + Send>> { + let pattern = self.make_key(RedisKey(pattern)); if self.is_cluster { Box::pin(self.inner.next().scan_cluster(pattern, count, None)) } else { @@ -619,7 +749,7 @@ mod test { time: std::time::UNIX_EPOCH - std::time::Duration::new(1, 0), }); - let as_value: Result = invalid_json_payload.try_into(); + let as_value: Result = invalid_json_payload.try_into(); assert!(as_value.is_err()); } @@ -628,17 +758,15 @@ mod test { fn it_preprocesses_redis_schemas_correctly() { // Base Format for scheme in ["redis", "rediss"] { - let url_s = format!("{}://username:password@host:6666/database", scheme); + let url_s = format!("{scheme}://username:password@host:6666/database"); let url = Url::parse(&url_s).expect("it's a valid url"); let urls = vec![url.clone(), url]; assert!(super::RedisCacheStorage::preprocess_urls(urls).is_ok()); } // Cluster Format for scheme in ["redis-cluster", "rediss-cluster"] { - let url_s = format!( - "{}://username:password@host:6666?node=host1:6667&node=host2:6668", - scheme - ); + let url_s = + format!("{scheme}://username:password@host:6666?node=host1:6667&node=host2:6668"); let url = Url::parse(&url_s).expect("it's a valid url"); let urls = vec![url.clone(), url]; assert!(super::RedisCacheStorage::preprocess_urls(urls).is_ok()); @@ -646,8 +774,7 @@ mod test { // Sentinel Format for scheme in ["redis-sentinel", "rediss-sentinel"] { let url_s = format!( - "{}://username:password@host:6666?node=host1:6667&node=host2:6668&sentinelServiceName=myservice&sentinelUserName=username2&sentinelPassword=password2", - scheme + "{scheme}://username:password@host:6666?node=host1:6667&node=host2:6668&sentinelServiceName=myservice&sentinelUserName=username2&sentinelPassword=password2" ); let url = Url::parse(&url_s).expect("it's a valid url"); let urls = vec![url.clone(), url]; @@ -655,7 +782,7 @@ mod test { } // Make sure it fails on sample invalid schemes for scheme in ["wrong", "something"] { - let url_s = format!("{}://username:password@host:6666/database", scheme); + let url_s = format!("{scheme}://username:password@host:6666/database"); let url = Url::parse(&url_s).expect("it's a valid url"); let urls = vec![url.clone(), url]; assert!(super::RedisCacheStorage::preprocess_urls(urls).is_err()); diff --git a/apollo-router/src/cache/size_estimation.rs b/apollo-router/src/cache/size_estimation.rs index 885e8d5c13..2cf72b2648 100644 --- a/apollo-router/src/cache/size_estimation.rs +++ b/apollo-router/src/cache/size_estimation.rs @@ -2,6 +2,7 @@ use std::fmt::Debug; use std::fmt::Display; use std::fmt::Formatter; +use serde::Serialize; use serde::ser; use serde::ser::SerializeMap; use serde::ser::SerializeSeq; @@ -10,7 +11,6 @@ use serde::ser::SerializeStructVariant; use serde::ser::SerializeTuple; use serde::ser::SerializeTupleStruct; use serde::ser::SerializeTupleVariant; -use serde::Serialize; pub(crate) fn estimate_size(s: &T) -> usize { let ser = s diff --git a/apollo-router/src/cache/storage.rs b/apollo-router/src/cache/storage.rs index 15f452ed28..c4582cf425 100644 --- a/apollo-router/src/cache/storage.rs +++ b/apollo-router/src/cache/storage.rs @@ -2,17 +2,16 @@ use std::fmt::Display; use std::fmt::{self}; use std::hash::Hash; use std::num::NonZeroUsize; +use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; -use std::sync::Arc; use lru::LruCache; +use opentelemetry::KeyValue; use opentelemetry::metrics::MeterProvider; -use opentelemetry_api::metrics::ObservableGauge; -use opentelemetry_api::metrics::Unit; -use opentelemetry_api::KeyValue; -use serde::de::DeserializeOwned; +use opentelemetry::metrics::ObservableGauge; use serde::Serialize; +use serde::de::DeserializeOwned; use tokio::sync::Mutex; use tokio::time::Instant; use tower::BoxError; @@ -58,8 +57,8 @@ pub(crate) struct CacheStorage { cache_size: Arc, cache_estimated_storage: Arc, // It's OK for these to be mutexes as they are only initialized once - cache_size_gauge: Arc>>>, - cache_estimated_storage_gauge: Arc>>>, + cache_size_gauge: Arc>>>, + cache_estimated_storage_gauge: Arc>>>, } impl CacheStorage @@ -81,7 +80,7 @@ where inner: Arc::new(Mutex::new(LruCache::new(max_capacity))), redis: if let Some(config) = config { let required_to_start = config.required_to_start; - match RedisCacheStorage::new(config).await { + match RedisCacheStorage::new(config, caller).await { Err(e) => { tracing::error!( cache = caller, @@ -118,8 +117,7 @@ where let current_cache_size_for_gauge = self.cache_size.clone(); let caller = self.caller; meter - // TODO move to dot naming convention - .i64_observable_gauge("apollo_router_cache_size") + .i64_observable_gauge("apollo.router.cache.size") .with_description("Cache size") .with_callback(move |i| { i.observe( @@ -137,10 +135,11 @@ where let meter: opentelemetry::metrics::Meter = metrics::meter_provider().meter(METER_NAME); let cache_estimated_storage_for_gauge = self.cache_estimated_storage.clone(); let caller = self.caller; - let cache_estimated_storage_gauge = meter + + meter .i64_observable_gauge("apollo.router.cache.storage.estimated_size") .with_description("Estimated cache storage") - .with_unit(Unit::new("bytes")) + .with_unit("bytes") .with_callback(move |i| { // If there's no storage then don't bother updating the gauge let value = cache_estimated_storage_for_gauge.load(Ordering::SeqCst); @@ -154,8 +153,7 @@ where ) } }) - .init(); - cache_estimated_storage_gauge + .init() } /// `init_from_redis` is called with values newly deserialized from Redis cache @@ -170,30 +168,24 @@ where match res { Some(v) => { - tracing::info!( - monotonic_counter.apollo_router_cache_hit_count = 1u64, - kind = %self.caller, - storage = &tracing::field::display(CacheStorageName::Memory), - ); - let duration = instant_memory.elapsed().as_secs_f64(); - tracing::info!( - histogram.apollo_router_cache_hit_time = duration, - kind = %self.caller, - storage = &tracing::field::display(CacheStorageName::Memory), + let duration = instant_memory.elapsed(); + f64_histogram!( + "apollo.router.cache.hit.time", + "Time to get a value from the cache in seconds", + duration.as_secs_f64(), + kind = self.caller, + storage = CacheStorageName::Memory.to_string() ); Some(v) } None => { - let duration = instant_memory.elapsed().as_secs_f64(); - tracing::info!( - histogram.apollo_router_cache_miss_time = duration, - kind = %self.caller, - storage = &tracing::field::display(CacheStorageName::Memory), - ); - tracing::info!( - monotonic_counter.apollo_router_cache_miss_count = 1u64, - kind = %self.caller, - storage = &tracing::field::display(CacheStorageName::Memory), + let duration = instant_memory.elapsed(); + f64_histogram!( + "apollo.router.cache.miss.time", + "Time to check the cache for an uncached value in seconds", + duration.as_secs_f64(), + kind = self.caller, + storage = CacheStorageName::Memory.to_string() ); let instant_redis = Instant::now(); @@ -214,30 +206,24 @@ where Some(v) => { self.insert_in_memory(key.clone(), v.0.clone()).await; - tracing::info!( - monotonic_counter.apollo_router_cache_hit_count = 1u64, - kind = %self.caller, - storage = &tracing::field::display(CacheStorageName::Redis), - ); - let duration = instant_redis.elapsed().as_secs_f64(); - tracing::info!( - histogram.apollo_router_cache_hit_time = duration, - kind = %self.caller, - storage = &tracing::field::display(CacheStorageName::Redis), + let duration = instant_redis.elapsed(); + f64_histogram!( + "apollo.router.cache.hit.time", + "Time to get a value from the cache in seconds", + duration.as_secs_f64(), + kind = self.caller, + storage = CacheStorageName::Redis.to_string() ); Some(v.0) } None => { - tracing::info!( - monotonic_counter.apollo_router_cache_miss_count = 1u64, - kind = %self.caller, - storage = &tracing::field::display(CacheStorageName::Redis), - ); - let duration = instant_redis.elapsed().as_secs_f64(); - tracing::info!( - histogram.apollo_router_cache_miss_time = duration, - kind = %self.caller, - storage = &tracing::field::display(CacheStorageName::Redis), + let duration = instant_redis.elapsed(); + f64_histogram!( + "apollo.router.cache.miss.time", + "Time to check the cache for an uncached value in seconds", + duration.as_secs_f64(), + kind = self.caller, + storage = CacheStorageName::Redis.to_string() ); None } @@ -297,12 +283,9 @@ where pub(crate) fn activate(&self) { // Gauges MUST be created after the meter provider is initialized // This means that on reload we need a non-fallible way to recreate the gauges, hence this function. - *self.cache_size_gauge.lock().expect("lock poisoned") = - Some(self.create_cache_size_gauge()); - *self - .cache_estimated_storage_gauge - .lock() - .expect("lock poisoned") = Some(self.create_cache_estimated_storage_size_gauge()); + *self.cache_size_gauge.lock() = Some(self.create_cache_size_gauge()); + *self.cache_estimated_storage_gauge.lock() = + Some(self.create_cache_estimated_storage_size_gauge()); } } @@ -372,7 +355,7 @@ mod test { "type" = "memory" ); assert_gauge!( - "apollo_router_cache_size", + "apollo.router.cache.size", 1, "kind" = "test", "type" = "memory" @@ -403,7 +386,7 @@ mod test { cache.insert("test".to_string(), Stuff {}).await; // This metric won't exist assert_gauge!( - "apollo_router_cache_size", + "apollo.router.cache.size", 0, "kind" = "test", "type" = "memory" @@ -449,7 +432,7 @@ mod test { "type" = "memory" ); assert_gauge!( - "apollo_router_cache_size", + "apollo.router.cache.size", 1, "kind" = "test", "type" = "memory" @@ -471,7 +454,7 @@ mod test { "type" = "memory" ); assert_gauge!( - "apollo_router_cache_size", + "apollo.router.cache.size", 1, "kind" = "test", "type" = "memory" @@ -493,7 +476,7 @@ mod test { "type" = "memory" ); assert_gauge!( - "apollo_router_cache_size", + "apollo.router.cache.size", 1, "kind" = "test", "type" = "memory" diff --git a/apollo-router/src/compute_job/metrics.rs b/apollo-router/src/compute_job/metrics.rs new file mode 100644 index 0000000000..3d30cb87f2 --- /dev/null +++ b/apollo-router/src/compute_job/metrics.rs @@ -0,0 +1,192 @@ +use std::time::Duration; +use std::time::Instant; + +use tracing::Span; + +use crate::compute_job::ComputeJobType; +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE; +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE_ERROR; +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE_OK; + +#[derive(Copy, Clone, strum_macros::IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub(super) enum Outcome { + ExecutedOk, + ExecutedError, + ChannelError, + RejectedQueueFull, + Abandoned, +} + +impl From for opentelemetry::Value { + fn from(outcome: Outcome) -> Self { + let s: &'static str = outcome.into(); + s.into() + } +} + +pub(super) struct JobWatcher { + span: Span, + queue_start: Instant, + compute_job_type: ComputeJobType, + pub(super) outcome: Outcome, +} + +impl JobWatcher { + pub(super) fn new(compute_job_type: ComputeJobType) -> Self { + Self { + span: Span::current(), + queue_start: Instant::now(), + outcome: Outcome::Abandoned, + compute_job_type, + } + } +} + +impl Drop for JobWatcher { + fn drop(&mut self) { + let outcome: &'static str = self.outcome.into(); + self.span.record("job.outcome", outcome); + + match &self.outcome { + Outcome::ExecutedOk => { + self.span.record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_OK); + } + Outcome::ExecutedError | Outcome::ChannelError | Outcome::RejectedQueueFull => { + self.span.record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + } + _ => {} + } + let full_duration = self.queue_start.elapsed(); + f64_histogram_with_unit!( + "apollo.router.compute_jobs.duration", + "Total job processing time", + "s", + full_duration.as_secs_f64(), + "job.type" = self.compute_job_type, + "job.outcome" = outcome + ); + } +} + +pub(super) struct ActiveComputeMetric { + compute_job_type: ComputeJobType, +} + +impl ActiveComputeMetric { + // create metric (auto-increments and decrements) + pub(super) fn register(compute_job_type: ComputeJobType) -> Self { + let s = Self { compute_job_type }; + s.incr(1); + s + } + + fn incr(&self, value: i64) { + i64_up_down_counter_with_unit!( + "apollo.router.compute_jobs.active_jobs", + "Number of computation jobs in progress", + "{job}", + value, + job.type = self.compute_job_type + ); + } +} + +impl Drop for ActiveComputeMetric { + fn drop(&mut self) { + self.incr(-1); + } +} + +pub(super) fn observe_queue_wait_duration( + compute_job_type: ComputeJobType, + queue_duration: Duration, +) { + f64_histogram_with_unit!( + "apollo.router.compute_jobs.queue.wait.duration", + "Time spent by the job in the compute queue", + "s", + queue_duration.as_secs_f64(), + "job.type" = compute_job_type + ); +} + +pub(super) fn observe_compute_duration(compute_job_type: ComputeJobType, job_duration: Duration) { + f64_histogram_with_unit!( + "apollo.router.compute_jobs.execution.duration", + "Time to execute the job, after it has been pulled from the queue", + "s", + job_duration.as_secs_f64(), + "job.type" = compute_job_type + ); +} + +#[cfg(test)] +mod tests { + use crate::compute_job::ComputeJobType; + use crate::compute_job::metrics::ActiveComputeMetric; + use crate::compute_job::metrics::JobWatcher; + use crate::compute_job::metrics::Outcome; + + #[test] + fn test_job_watcher() { + let check_histogram_count = + |count: u64, job_type: &'static str, job_outcome: &'static str| { + assert_histogram_count!( + "apollo.router.compute_jobs.duration", + count, + "job.type" = job_type, + "job.outcome" = job_outcome + ); + }; + + { + let _job_watcher = JobWatcher::new(ComputeJobType::Introspection); + } + check_histogram_count(1, "introspection", "abandoned"); + + { + let mut job_watcher = JobWatcher::new(ComputeJobType::QueryParsing); + job_watcher.outcome = Outcome::ExecutedOk; + } + check_histogram_count(1, "query_parsing", "executed_ok"); + + for count in 1..5 { + { + let mut job_watcher = JobWatcher::new(ComputeJobType::QueryPlanning); + job_watcher.outcome = Outcome::RejectedQueueFull; + } + check_histogram_count(count, "query_planning", "rejected_queue_full"); + } + } + + #[test] + fn test_active_compute_metric() { + let check_count = |count: i64, job_type: &'static str| { + assert_up_down_counter!( + "apollo.router.compute_jobs.active_jobs", + count, + "job.type" = job_type + ); + }; + + { + let _introspection_1 = ActiveComputeMetric::register(ComputeJobType::Introspection); + let _introspection_2 = ActiveComputeMetric::register(ComputeJobType::Introspection); + let introspection_3 = ActiveComputeMetric::register(ComputeJobType::Introspection); + check_count(3, "introspection"); + + let _planning_1 = ActiveComputeMetric::register(ComputeJobType::QueryPlanning); + check_count(3, "introspection"); + check_count(1, "query_planning"); + + drop(introspection_3); + check_count(2, "introspection"); + check_count(1, "query_planning"); + } + + // block ended, so should have no ongoing computation + check_count(0, "introspection"); + check_count(0, "query_planning"); + } +} diff --git a/apollo-router/src/compute_job/mod.rs b/apollo-router/src/compute_job/mod.rs new file mode 100644 index 0000000000..a391ec1482 --- /dev/null +++ b/apollo-router/src/compute_job/mod.rs @@ -0,0 +1,415 @@ +mod metrics; + +use std::future::Future; +use std::ops::ControlFlow; +use std::sync::OnceLock; +use std::time::Instant; + +use opentelemetry::metrics::MeterProvider as _; +use opentelemetry::metrics::ObservableGauge; +use tokio::sync::oneshot; +use tracing::Instrument; +use tracing::Span; +use tracing::info_span; +use tracing_core::Dispatch; +use tracing_subscriber::util::SubscriberInitExt; + +use self::metrics::ActiveComputeMetric; +use self::metrics::JobWatcher; +use self::metrics::Outcome; +use self::metrics::observe_compute_duration; +use self::metrics::observe_queue_wait_duration; +use crate::ageing_priority_queue::AgeingPriorityQueue; +use crate::ageing_priority_queue::Priority; +use crate::ageing_priority_queue::SendError; +use crate::metrics::meter_provider; +use crate::plugins::telemetry::consts::COMPUTE_JOB_EXECUTION_SPAN_NAME; +use crate::plugins::telemetry::consts::COMPUTE_JOB_SPAN_NAME; + +/// We generate backpressure in tower `poll_ready` when the number of queued jobs +/// reaches `QUEUE_SOFT_CAPACITY_PER_THREAD * thread_pool_size()` +/// +/// This number is somewhat arbitrary and subject to change. Most compute jobs +/// don't take a long time, so by making the queue quite big, it's capable of eating +/// a sizable backlog during spikes. +const QUEUE_SOFT_CAPACITY_PER_THREAD: usize = 1_000; + +/// By default, let this thread pool use all available resources if it can. +/// In the worst case, we’ll have moderate context switching cost +/// as the kernel’s scheduler distributes time to it or Tokio or other threads. +fn thread_pool_size() -> usize { + // This environment variable is intentionally undocumented. + if let Some(threads) = std::env::var("APOLLO_ROUTER_COMPUTE_THREADS") + .ok() + .and_then(|value| value.parse::().ok()) + { + threads + } else { + std::thread::available_parallelism() + .expect("available_parallelism() failed") + .get() + } +} + +pub(crate) struct JobStatus<'a, T> { + result_sender: &'a oneshot::Sender>, +} + +impl JobStatus<'_, T> { + /// Checks whether the oneshot receiver for the result of the job was dropped, + /// which means nothing is expecting the result anymore. + /// + /// This can happen if the Tokio task owning it is cancelled, + /// such as if a supergraph client disconnects or if a request times out. + /// + /// In this case, a long-running job should try to cancel itself + /// to avoid needless resource consumption. + pub(crate) fn check_for_cooperative_cancellation(&self) -> ControlFlow<()> { + if self.result_sender.is_closed() { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } +} + +/// Compute job queue is full +#[derive(thiserror::Error, Debug, displaydoc::Display, Clone)] +pub(crate) struct ComputeBackPressureError; + +#[derive(Debug)] +pub(crate) enum MaybeBackPressureError { + /// Doing the same request again later would result in the same error (e.g. invalid query). + /// + /// This error can be cached. + PermanentError(E), + + /// Doing the same request again later might work. + /// + /// This error must not be cached. + TemporaryError(ComputeBackPressureError), +} + +impl From for MaybeBackPressureError { + fn from(error: E) -> Self { + Self::PermanentError(error) + } +} + +impl ComputeBackPressureError { + pub(crate) fn to_graphql_error(&self) -> crate::graphql::Error { + crate::graphql::Error::builder() + .message("Your request has been concurrency limited during query processing") + .extension_code("REQUEST_CONCURRENCY_LIMITED") + .build() + } +} + +impl crate::graphql::IntoGraphQLErrors for ComputeBackPressureError { + fn into_graphql_errors(self) -> Result, Self> { + Ok(vec![self.to_graphql_error()]) + } +} + +#[derive(Copy, Clone, Hash, Eq, PartialEq, Debug, strum_macros::IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub(crate) enum ComputeJobType { + QueryParsing, + QueryPlanning, + Introspection, + QueryParsingWarmup, + QueryPlanningWarmup, +} + +impl From for Priority { + fn from(job_type: ComputeJobType) -> Self { + match job_type { + ComputeJobType::QueryPlanning => Self::P8, // high + ComputeJobType::QueryParsing => Self::P4, // medium + ComputeJobType::Introspection => Self::P3, // low + ComputeJobType::QueryParsingWarmup => Self::P1, // low + ComputeJobType::QueryPlanningWarmup => Self::P2, // low + } + } +} + +impl From for opentelemetry::Value { + fn from(compute_job_type: ComputeJobType) -> Self { + let s: &'static str = compute_job_type.into(); + s.into() + } +} + +pub(crate) struct Job { + subscriber: Dispatch, + parent_span: Span, + ty: ComputeJobType, + queue_start: Instant, + job_fn: Box, +} + +pub(crate) fn queue() -> &'static AgeingPriorityQueue { + static QUEUE: OnceLock> = OnceLock::new(); + QUEUE.get_or_init(|| { + let pool_size = thread_pool_size(); + for _ in 0..pool_size { + std::thread::spawn(|| { + // This looks like we need the queue before creating the queue, + // but it happens in a child thread where OnceLock will block + // until `get_or_init` in the parent thread is finished + // and the parent is *not* blocked on the child thread making progress. + let queue = queue(); + + let mut receiver = queue.receiver(); + loop { + let (job, age) = receiver.blocking_recv(); + let job_type: &'static str = job.ty.into(); + let age: &'static str = age.into(); + let _subscriber = job.subscriber.set_default(); + job.parent_span.in_scope(|| { + let span = info_span!( + COMPUTE_JOB_EXECUTION_SPAN_NAME, + "job.type" = job_type, + "job.age" = age + ); + span.in_scope(|| { + observe_queue_wait_duration(job.ty, job.queue_start.elapsed()); + + let _active_metric = ActiveComputeMetric::register(job.ty); + let job_start = Instant::now(); + (job.job_fn)(); + observe_compute_duration(job.ty, job_start.elapsed()); + }) + }) + } + }); + } + tracing::info!( + threads = pool_size, + queue_capacity = QUEUE_SOFT_CAPACITY_PER_THREAD * pool_size, + "compute job thread pool created", + ); + AgeingPriorityQueue::bounded(QUEUE_SOFT_CAPACITY_PER_THREAD * pool_size) + }) +} + +/// Returns a future that resolves to a `Result` that is `Ok` if `f` returned or `Err` if it panicked. +pub(crate) fn execute( + compute_job_type: ComputeJobType, + job: F, +) -> Result, ComputeBackPressureError> +where + F: FnOnce(JobStatus<'_, T>) -> T + Send + 'static, + T: Send + 'static, +{ + let compute_job_type_str: &'static str = compute_job_type.into(); + let span = info_span!( + COMPUTE_JOB_SPAN_NAME, + "job.type" = compute_job_type_str, + "job.outcome" = tracing::field::Empty + ); + span.in_scope(|| { + let mut job_watcher = JobWatcher::new(compute_job_type); + let (tx, rx) = oneshot::channel(); + let wrapped_job_fn = Box::new(move || { + let status = JobStatus { result_sender: &tx }; + // `AssertUnwindSafe` here is correct because this `catch_unwind` + // is paired with `resume_unwind` below, so the overall effect on unwind safety + // is the same as if the caller had executed `job` directly without a thread pool. + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || job(status))); + match tx.send(result) { + Ok(()) => {} + Err(_) => { + // `rx` was dropped: `result` is no longer needed and we can safely drop it + } + } + }); + + let queue = queue(); + let job = Job { + subscriber: Dispatch::default(), + parent_span: Span::current(), + ty: compute_job_type, + job_fn: wrapped_job_fn, + queue_start: Instant::now(), + }; + + queue + .send(Priority::from(compute_job_type), job) + .map_err(|e| match e { + SendError::QueueIsFull => { + u64_counter!( + "apollo.router.compute_jobs.queue_is_full", + "Number of requests rejected because the queue for compute jobs is full", + 1u64 + ); + job_watcher.outcome = Outcome::RejectedQueueFull; + ComputeBackPressureError + } + SendError::Disconnected => { + // This never panics because this channel can never be disconnect: + // the receiver is owned by `queue` which we can access here: + let _proof_of_life: &'static AgeingPriorityQueue<_> = queue; + unreachable!("compute thread pool queue is disconnected") + } + })?; + + Ok(async move { + let result = rx.await; + + // This local variable MUST exist. Otherwise, only the field from the JobWatcher struct is moved and drop will occur before the outcome is set. + // This is predicated on all the fields in the struct being Copy!!! + let mut local_job_watcher = job_watcher; + local_job_watcher.outcome = match &result { + Ok(Ok(_)) => Outcome::ExecutedOk, + // We don't know what the cardinality of errors are so we just say there was a response error + Ok(Err(_)) => Outcome::ExecutedError, + // We got an error reading the response from the channel + Err(_) => Outcome::ChannelError, + }; + + match result { + Ok(Ok(value)) => value, + Ok(Err(panic_payload)) => { + // The `job` callback panicked. + // + // We try to to avoid this (by returning errors instead) and consider this a bug. + // But if it does happen, propagating the panic to the caller from here + // has the same effect as if they had executed `job` directly + // without a thread pool. + // + // Additionally we have a panic handler in `apollo-router/src/executable.rs` + // that exits the process, + // so in practice a Router thread should never start unwinding + // an this code path should be unreachable. + std::panic::resume_unwind(panic_payload) + } + Err(e) => { + let _: tokio::sync::oneshot::error::RecvError = e; + // This should never happen because this oneshot channel can never be disconnect: + // the sender is owned by `job` which, if we reach here, + // was successfully sent to the queue. + // The queue or thread pool never drop a job without executing it. + // When executing, `catch_unwind` ensures that + // the sender cannot be dropped without sending. + unreachable!("compute result oneshot channel is disconnected") + } + } + } + .in_current_span()) + }) +} + +pub(crate) fn create_queue_size_gauge() -> ObservableGauge { + meter_provider() + .meter("apollo/router") + .u64_observable_gauge("apollo.router.compute_jobs.queued") + .with_description( + "Number of computation jobs (parsing, planning, …) waiting to be scheduled", + ) + .with_callback(move |m| m.observe(queue().queued_count() as u64, &[])) + .init() +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + use std::time::Instant; + + use tracing_futures::WithSubscriber; + + use super::*; + use crate::assert_snapshot_subscriber; + + /// Send a request to the compute queue to make sure it is initialized. + /// + /// The queue is (a) wrapped in a `OnceLock`, so it is shared between tests, and (b) only + /// initialized after receiving and processing a request. + /// These two properties can lead to inconsistent behavior. + async fn ensure_queue_is_initialized() { + execute(ComputeJobType::Introspection, |_| {}) + .unwrap() + .await; + } + + #[tokio::test] + async fn test_observability() { + // make sure that the queue has been initialized - if this step is skipped, the + // queue will _sometimes_ be initialized in the step below, which causes an + // additional log line and a snapshot mismatch. + ensure_queue_is_initialized().await; + + async { + let span = info_span!("test_observability"); + let job = span.in_scope(|| { + tracing::info!("Outer"); + execute(ComputeJobType::QueryParsing, |_| { + tracing::info!("Inner"); + 1 + }) + .unwrap() + }); + let result = job.await; + assert_eq!(result, 1); + } + .with_subscriber(assert_snapshot_subscriber!()) + .await; + } + + #[tokio::test] + async fn test_executes_on_different_thread() { + let test_thread = std::thread::current().id(); + let job_thread = execute(ComputeJobType::QueryParsing, |_| { + std::thread::current().id() + }) + .unwrap() + .await; + assert_ne!(job_thread, test_thread) + } + + #[tokio::test] + async fn test_parallelism() { + if thread_pool_size() < 2 { + return; + } + let start = Instant::now(); + let one = execute(ComputeJobType::QueryPlanning, |_| { + std::thread::sleep(Duration::from_millis(1_000)); + 1 + }) + .unwrap(); + let two = execute(ComputeJobType::QueryPlanning, |_| { + std::thread::sleep(Duration::from_millis(1_000)); + 1 + 1 + }) + .unwrap(); + tokio::time::sleep(Duration::from_millis(500)).await; + assert_eq!(one.await, 1); + assert_eq!(two.await, 2); + // Evidence of fearless parallel sleep: + assert!(start.elapsed() < Duration::from_millis(1_400)); + } + + #[tokio::test] + async fn test_cancel() { + let (side_channel_sender, side_channel_receiver) = oneshot::channel(); + let queue_receiver = execute(ComputeJobType::Introspection, move |status| { + // We expect the first iteration to succeed, + // but let’s add lots of margin for CI machines with super-busy CPU cores + for _ in 0..1_000 { + std::thread::sleep(Duration::from_millis(10)); + if status.check_for_cooperative_cancellation().is_break() { + side_channel_sender.send(Ok(())).unwrap(); + return; + } + } + side_channel_sender.send(Err(())).unwrap(); + }); + drop(queue_receiver); + match side_channel_receiver.await { + Ok(Ok(())) => {} + e => panic!("job did not cancel as expected: {e:?}"), + }; + } +} diff --git a/apollo-router/src/compute_job/snapshots/apollo_router__compute_job__tests__observability@logs.snap b/apollo-router/src/compute_job/snapshots/apollo_router__compute_job__tests__observability@logs.snap new file mode 100644 index 0000000000..cf3b122079 --- /dev/null +++ b/apollo-router/src/compute_job/snapshots/apollo_router__compute_job__tests__observability@logs.snap @@ -0,0 +1,25 @@ +--- +source: apollo-router/src/compute_job/mod.rs +expression: yaml +--- +- fields: {} + level: INFO + message: Outer + span: + name: test_observability + spans: + - name: test_observability +- fields: {} + level: INFO + message: Inner + span: + job.age: P4 + job.type: query_parsing + name: compute_job.execution + spans: + - name: test_observability + - job.type: query_parsing + name: compute_job + - job.age: P4 + job.type: query_parsing + name: compute_job.execution diff --git a/apollo-router/src/configuration/connector.rs b/apollo-router/src/configuration/connector.rs new file mode 100644 index 0000000000..6491d8bbcf --- /dev/null +++ b/apollo-router/src/configuration/connector.rs @@ -0,0 +1,19 @@ +use std::collections::HashMap; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)] +pub(crate) struct ConnectorConfiguration +where + T: Default + Serialize + JsonSchema, +{ + /// Map of subgraph_name.connector_source_name to configuration + #[serde(default)] + pub(crate) sources: HashMap, + + /// Options applying to all sources + #[serde(default)] + pub(crate) all: T, +} diff --git a/apollo-router/src/configuration/cooperative_cancellation.rs b/apollo-router/src/configuration/cooperative_cancellation.rs new file mode 100644 index 0000000000..d4f28fc57e --- /dev/null +++ b/apollo-router/src/configuration/cooperative_cancellation.rs @@ -0,0 +1,84 @@ +use std::time::Duration; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +use crate::configuration::mode::Mode; + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct CooperativeCancellation { + /// When true, cooperative cancellation is enabled. + enabled: bool, + /// When enabled, this sets whether the router will cancel query planning or + /// merely emit a metric when it would have happened. + mode: Mode, + #[serde(deserialize_with = "humantime_serde::deserialize")] + #[serde(serialize_with = "humantime_serde::serialize")] + #[schemars(with = "Option")] + /// Enable timeout for query planning. + timeout: Option, +} + +impl Default for CooperativeCancellation { + fn default() -> Self { + Self { + enabled: true, + mode: Mode::Measure, + timeout: None, + } + } +} + +impl CooperativeCancellation { + /// Returns the timeout, if configured. + pub(crate) fn timeout(&self) -> Option { + self.timeout + } + + #[cfg(test)] + /// Create a new `CooperativeCancellation` config in enforcement mode. + pub(crate) fn enabled() -> Self { + Self { + enabled: true, + mode: Mode::Enforce, + timeout: None, + } + } + + /// Returns true if cooperative cancellation is enabled. + pub(crate) fn is_enabled(&self) -> bool { + self.enabled + } + + /// Returns true if this config is in measure mode. + pub(crate) fn is_measure_mode(&self) -> bool { + self.mode.is_measure_mode() + } + + /// Returns true if this config is in enforce mode. + pub(crate) fn is_enforce_mode(&self) -> bool { + self.mode.is_enforce_mode() + } + + #[cfg(test)] + /// Create a new `CooperativeCancellation` config in enforcement mode with a timeout. + pub(crate) fn enabled_with_timeout(timeout: Duration) -> Self { + Self { + enabled: true, + mode: Mode::Enforce, + timeout: Some(timeout), + } + } + + #[cfg(test)] + /// Create a new `CooperativeCancellation` config in measure mode with a timeout. + pub(crate) fn measure_with_timeout(timeout: Duration) -> Self { + Self { + enabled: true, + mode: Mode::Measure, + timeout: Some(timeout), + } + } +} diff --git a/apollo-router/src/configuration/cors.rs b/apollo-router/src/configuration/cors.rs index 313d863214..44634fcfb7 100644 --- a/apollo-router/src/configuration/cors.rs +++ b/apollo-router/src/configuration/cors.rs @@ -1,26 +1,159 @@ //! Cross Origin Resource Sharing (CORS configuration) +//! +//! This module provides configuration structures for CORS (Cross-Origin Resource Sharing) settings. +//! +//! # Default Behavior +//! +//! When the `policies` field is omitted from the CORS config, the router uses a default policy: +//! - **Origins:** `["https://studio.apollographql.com"]` +//! - **Methods:** `["GET", "POST", "OPTIONS"]` +//! - **Allow credentials:** `false` +//! - **Allow any origin:** `false` +//! +//! # Policy Configuration +//! +//! When specifying individual policies within the `policies` array: +//! - **Origins:** Defaults to `["https://studio.apollographql.com"]` when no policies are specified at all. +//! If you specify any policies, you must explicitly set origins for each policy. +//! - **Match origins:** Defaults to an empty list (no regex matching) unless explicitly set +//! - **Allow credentials:** Has three possible states: +//! - not specified: Use the global default allow_credentials +//! - `true`: Enable credentials for this policy +//! - `false`: Disable credentials for this policy +//! - **Allow headers:** Inherits global headers if empty, otherwise uses policy-specific headers +//! - **Expose headers:** Inherits global headers if empty, otherwise uses policy-specific headers +//! - **Methods:** Has three possible states: +//! - not specified: Use the global default methods +//! - `[]` (empty array): No methods allowed for this policy +//! - `[values]` (with values): Use these specific methods +//! - **Max age:** Has three possible states: +//! - not specified: Use the global default max_age +//! - `"0s"` or other duration: Set specific max age for this policy +//! +//! # Origin Matching +//! +//! The router matches request origins against policies in order: +//! 1. **Exact match**: First checks if the origin exactly matches any origin in the policy's `origins` list +//! 2. **Regex match**: If no exact match is found, checks if the origin matches any pattern in the policy's `match_origins` list +//! 3. **No match**: If no policy matches, the request is rejected (no CORS headers are set) +//! # Examples +//! +//! ```yaml +//! # Use global default (Apollo Studio only) +//! cors: {} +//! +//! # Disable all CORS +//! cors: +//! policies: [] +//! +//! # Global methods with policy-specific overrides +//! cors: +//! methods: [POST] # Global default +//! policies: +//! - origins: [https://app1.com] +//! # methods not specified - uses global default [POST] +//! - origins: [https://app2.com] +//! methods: [] # Explicitly disable all methods +//! - origins: [https://app3.com] +//! methods: [GET, DELETE] # Use specific methods +//! - origins: [https://api.example.com] +//! match_origins: ["^https://.*\\.example\\.com$"] # Regex pattern for subdomains +//! allow_headers: [content-type, authorization] +//! # Uses global methods [POST] +//! ``` -use std::str::FromStr; use std::time::Duration; -use http::request::Parts; -use http::HeaderValue; use regex::Regex; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; -use tower_http::cors; -use tower_http::cors::CorsLayer; + +/// Configuration for a specific set of origins +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[serde(default)] +pub(crate) struct Policy { + /// Set to true to add the `Access-Control-Allow-Credentials` header for these origins + pub(crate) allow_credentials: Option, + + /// The headers to allow for these origins + pub(crate) allow_headers: Vec, + + /// Which response headers should be made available to scripts running in the browser + pub(crate) expose_headers: Vec, + + /// Regex patterns to match origins against. + #[serde(with = "serde_regex")] + #[schemars(with = "Vec")] + pub(crate) match_origins: Vec, + + /// The `Access-Control-Max-Age` header value in time units + #[serde(deserialize_with = "humantime_serde::deserialize", default)] + #[schemars(with = "String", default)] + pub(crate) max_age: Option, + + /// Allowed request methods for these origins. + pub(crate) methods: Option>, + + /// The origins to allow requests from. + pub(crate) origins: Vec, +} + +impl Default for Policy { + fn default() -> Self { + Self { + allow_credentials: None, + allow_headers: Vec::new(), + expose_headers: Vec::new(), + match_origins: Vec::new(), + max_age: None, + methods: None, + origins: default_origins(), + } + } +} + +fn default_origins() -> Vec { + vec!["https://studio.apollographql.com".into()] +} + +fn default_cors_methods() -> Vec { + vec!["GET".into(), "POST".into(), "OPTIONS".into()] +} + +// Currently, this is only used for testing. +#[cfg(test)] +#[buildstructor::buildstructor] +impl Policy { + #[builder] + pub(crate) fn new( + allow_credentials: Option, + allow_headers: Vec, + expose_headers: Vec, + match_origins: Vec, + max_age: Option, + methods: Option>, + origins: Vec, + ) -> Self { + Self { + allow_credentials, + allow_headers, + expose_headers, + match_origins, + max_age, + methods, + origins, + } + } +} /// Cross origin request configuration. #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] #[serde(default)] pub(crate) struct Cors { - /// Set to true to allow any origin. - /// - /// Defaults to false - /// Having this set to true is the only way to allow Origin: null. + /// Set to true to allow any origin. Defaults to false. This is the only way to allow Origin: null. pub(crate) allow_any_origin: bool, /// Set to true to add the `Access-Control-Allow-Credentials` header. @@ -42,22 +175,17 @@ pub(crate) struct Cors { /// in response to a cross-origin request. pub(crate) expose_headers: Option>, - /// The origin(s) to allow requests from. - /// Defaults to `https://studio.apollographql.com/` for Apollo Studio. - pub(crate) origins: Vec, - - /// `Regex`es you want to match the origins against to determine if they're allowed. - /// Defaults to an empty list. - /// Note that `origins` will be evaluated before `match_origins` - pub(crate) match_origins: Option>, - - /// Allowed request methods. Defaults to GET, POST, OPTIONS. + /// Allowed request methods. See module documentation for default behavior. pub(crate) methods: Vec, /// The `Access-Control-Max-Age` header value in time units #[serde(deserialize_with = "humantime_serde::deserialize", default)] #[schemars(with = "String", default)] pub(crate) max_age: Option, + + /// The origin(s) to allow requests from. The router matches request origins against policies + /// in order, first by exact match, then by regex. See module documentation for default behavior. + pub(crate) policies: Option>, } impl Default for Cors { @@ -66,151 +194,918 @@ impl Default for Cors { } } -fn default_origins() -> Vec { - vec!["https://studio.apollographql.com".into()] -} - -fn default_cors_methods() -> Vec { - vec!["GET".into(), "POST".into(), "OPTIONS".into()] -} - #[buildstructor::buildstructor] impl Cors { #[builder] - #[allow(clippy::too_many_arguments)] pub(crate) fn new( allow_any_origin: Option, allow_credentials: Option, allow_headers: Option>, expose_headers: Option>, - origins: Option>, - match_origins: Option>, - methods: Option>, max_age: Option, + methods: Option>, + policies: Option>, ) -> Self { + let global_methods = methods.unwrap_or_else(default_cors_methods); + let policies = policies.or_else(|| { + let default_policy = Policy { + methods: Some(global_methods.clone()), + ..Default::default() + }; + Some(vec![default_policy]) + }); + Self { - expose_headers, - match_origins, - max_age, - origins: origins.unwrap_or_else(default_origins), - methods: methods.unwrap_or_else(default_cors_methods), allow_any_origin: allow_any_origin.unwrap_or_default(), allow_credentials: allow_credentials.unwrap_or_default(), allow_headers: allow_headers.unwrap_or_default(), + expose_headers, + max_age, + methods: global_methods, + policies, } } } impl Cors { - pub(crate) fn into_layer(self) -> Result { - // Ensure configuration is valid before creating CorsLayer - self.ensure_usable_cors_rules()?; - - let allow_headers = if self.allow_headers.is_empty() { - cors::AllowHeaders::mirror_request() - } else { - cors::AllowHeaders::list(self.allow_headers.iter().filter_map(|header| { - header - .parse() - .map_err(|_| tracing::error!("header name '{header}' is not valid")) - .ok() - })) - }; - let cors = CorsLayer::new() - .vary([]) - .allow_credentials(self.allow_credentials) - .allow_headers(allow_headers) - .expose_headers(cors::ExposeHeaders::list( - self.expose_headers - .unwrap_or_default() - .iter() - .filter_map(|header| { - header - .parse() - .map_err(|_| tracing::error!("header name '{header}' is not valid")) - .ok() - }), - )) - .allow_methods(cors::AllowMethods::list(self.methods.iter().filter_map( - |method| { - method - .parse() - .map_err(|_| tracing::error!("method '{method}' is not valid")) - .ok() - }, - ))); - let cors = if let Some(max_age) = self.max_age { - cors.max_age(max_age) - } else { - cors - }; - - if self.allow_any_origin { - Ok(cors.allow_origin(cors::Any)) - } else if let Some(match_origins) = self.match_origins { - let regexes = match_origins - .into_iter() - .filter_map(|regex| { - Regex::from_str(regex.as_str()) - .map_err(|_| tracing::error!("origin regex '{regex}' is not valid")) - .ok() - }) - .collect::>(); - - Ok(cors.allow_origin(cors::AllowOrigin::predicate( - move |origin: &HeaderValue, _: &Parts| { - origin - .to_str() - .map(|o| { - self.origins.iter().any(|origin| origin.as_str() == o) - || regexes.iter().any(|regex| regex.is_match(o)) - }) - .unwrap_or_default() - }, - ))) - } else { - Ok(cors.allow_origin(cors::AllowOrigin::list( - self.origins.into_iter().filter_map(|origin| { - origin - .parse() - .map_err(|_| tracing::error!("origin '{origin}' is not valid")) - .ok() - }), - ))) - } + pub(crate) fn into_layer(self) -> Result { + crate::plugins::cors::CorsLayer::new(self) } // This is cribbed from the similarly named function in tower-http. The version there // asserts that CORS rules are useable, which results in a panic if they aren't. We // don't want the router to panic in such cases, so this function returns an error // with a message describing what the problem is. - fn ensure_usable_cors_rules(&self) -> Result<(), &'static str> { - if self.origins.iter().any(|x| x == "*") { - return Err("Invalid CORS configuration: use `allow_any_origin: true` to set `Access-Control-Allow-Origin: *`"); + // + // This function validates CORS configuration according to the CORS specification: + // https://fetch.spec.whatwg.org/#cors-protocol-and-credentials + // + // CORS Specification Table (from https://fetch.spec.whatwg.org/#cors-protocol-and-credentials): + // + // Request's credentials mode | Access-Control-Allow-Origin | Access-Control-Allow-Credentials | Shared? | Notes + // ---------------------------|------------------------------|-----------------------------------|---------|------ + // "omit" | `*` | Omitted | ✅ | — + // "omit" | `*` | `true` | ✅ | If credentials mode is not "include", then `Access-Control-Allow-Credentials` is ignored. + // "omit" | `https://rabbit.invalid/` | Omitted | ❌ | A serialized origin has no trailing slash. + // "omit" | `https://rabbit.invalid` | Omitted | ✅ | — + // "include" | `*` | `true` | ❌ | If credentials mode is "include", then `Access-Control-Allow-Origin` cannot be `*`. + // "include" | `https://rabbit.invalid` | `true` | ✅ | — + // "include" | `https://rabbit.invalid` | `True` | ❌ | `true` is (byte) case-sensitive. + // + // Similarly, `Access-Control-Expose-Headers`, `Access-Control-Allow-Methods`, and `Access-Control-Allow-Headers` + // response headers can only use `*` as value when request's credentials mode is not "include". + pub(crate) fn ensure_usable_cors_rules(&self) -> Result<(), &'static str> { + // Check for wildcard origins in any Policy + if let Some(policies) = &self.policies { + for policy in policies { + if policy.origins.iter().any(|x| x == "*") { + return Err( + "Invalid CORS configuration: use `allow_any_origin: true` to set `Access-Control-Allow-Origin: *`", + ); + } + + // Validate that origins don't have trailing slashes (per CORS spec) + for origin in &policy.origins { + if origin.ends_with('/') && origin != "/" { + return Err( + "Invalid CORS configuration: origins cannot have trailing slashes (a serialized origin has no trailing slash)", + ); + } + } + } } + if self.allow_credentials { + // Check global fields for wildcards if self.allow_headers.iter().any(|x| x == "*") { - return Err("Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ - with `Access-Control-Allow-Headers: *`"); + return Err( + "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ + with `Access-Control-Allow-Headers: *`", + ); } if self.methods.iter().any(|x| x == "*") { - return Err("Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ - with `Access-Control-Allow-Methods: *`"); + return Err( + "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ + with `Access-Control-Allow-Methods: *`", + ); } if self.allow_any_origin { - return Err("Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ - with `allow_any_origin: true`"); + return Err( + "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ + with `allow_any_origin: true`", + ); + } + + if let Some(headers) = &self.expose_headers + && headers.iter().any(|x| x == "*") + { + return Err( + "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ + with `Access-Control-Expose-Headers: *`", + ); } + } + + // Check per-policy fields for wildcards when policy-level credentials are enabled + if let Some(policies) = &self.policies { + for policy in policies { + // Check if policy-level credentials override is enabled + let policy_credentials = policy.allow_credentials.unwrap_or(self.allow_credentials); + + if policy_credentials { + if policy.allow_headers.iter().any(|x| x == "*") { + return Err( + "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ + with `Access-Control-Allow-Headers: *` in policy", + ); + } - if let Some(headers) = &self.expose_headers { - if headers.iter().any(|x| x == "*") { - return Err("Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ - with `Access-Control-Expose-Headers: *`"); + if let Some(methods) = &policy.methods + && methods.iter().any(|x| x == "*") + { + return Err( + "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ + with `Access-Control-Allow-Methods: *` in policy", + ); + } + + if policy.expose_headers.iter().any(|x| x == "*") { + return Err( + "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ + with `Access-Control-Expose-Headers: *` in policy", + ); + } } } } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bad_allow_headers_cors_configuration() { + let cors = Cors::builder() + .allow_headers(vec![String::from("bad\nname")]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + + assert_eq!( + layer.unwrap_err(), + String::from("allow header name 'bad\nname' is not valid: invalid HTTP header name") + ); + } + + #[test] + fn test_bad_allow_methods_cors_configuration() { + let cors = Cors::builder() + .methods(vec![String::from("bad\nmethod")]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + + assert_eq!( + layer.unwrap_err(), + String::from("method 'bad\nmethod' is not valid: invalid HTTP method") + ); + } + + #[test] + fn test_bad_origins_cors_configuration() { + let cors = Cors::builder() + .policies(vec![ + Policy::builder() + .origins(vec![String::from("bad\norigin")]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + + assert_eq!( + layer.unwrap_err(), + String::from("origin 'bad\norigin' is not valid: failed to parse header value") + ); + } + + #[test] + fn test_bad_match_origins_cors_configuration() { + let yaml = r#" +allow_any_origin: false +allow_credentials: false +allow_headers: [] +expose_headers: [] +methods: ["GET", "POST", "OPTIONS"] +policies: + - origins: ["https://studio.apollographql.com"] + allow_credentials: false + allow_headers: [] + expose_headers: [] + match_origins: ["["] + methods: ["GET", "POST", "OPTIONS"] +"#; + let cors: Result = serde_yaml::from_str(yaml); + assert!(cors.is_err()); + let err = format!("{}", cors.unwrap_err()); + assert!(err.contains("regex parse error")); + assert!(err.contains("unclosed character class")); + } + + #[test] + fn test_good_cors_configuration() { + let cors = Cors::builder() + .allow_headers(vec![String::from("good-name")]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_ok()); + } + + // Test that multiple Policy entries have correct precedence (exact match > regex) + // This ensures the matching logic is deterministic and follows the documented behavior + #[test] + fn test_multiple_origin_config_precedence() { + let cors = Cors::builder() + .policies(vec![ + // This should match by regex but be lower priority + Policy::builder() + .origins(vec![]) + .match_origins(vec![ + regex::Regex::new(r"https://.*\.example\.com").unwrap(), + ]) + .allow_headers(vec!["regex-header".into()]) + .build(), + // This should match by exact match and be higher priority + Policy::builder() + .origins(vec!["https://api.example.com".into()]) + .allow_headers(vec!["exact-header".into()]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_ok()); + } + + // Test regex matching edge cases to ensure regexes are not too permissive or restrictive + // This prevents security issues where unintended origins might be allowed + #[test] + fn test_regex_matching_edge_cases() { + let cors = Cors::builder() + .policies(vec![ + Policy::builder() + .origins(vec![]) + .match_origins(vec![ + regex::Regex::new(r"https://[a-z]+\.example\.com").unwrap(), + ]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_ok()); + } + + // Test that wildcard origins in Policy are rejected + // This ensures users must use allow_any_origin: true for wildcard behavior + #[test] + fn test_wildcard_origin_in_origin_config_rejected() { + let cors = Cors::builder() + .policies(vec![Policy::builder().origins(vec!["*".into()]).build()]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + assert!(layer.unwrap_err().contains("use `allow_any_origin: true`")); + } + + // Test that allow_any_origin with credentials is rejected + // This is forbidden by the CORS spec and prevents security issues + #[test] + fn test_allow_any_origin_with_credentials_rejected() { + let cors = Cors::builder() + .allow_any_origin(true) + .allow_credentials(true) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + assert!( + layer + .unwrap_err() + .contains("Cannot combine `Access-Control-Allow-Credentials: true`") + ); + } + + // Test that wildcard headers with credentials are rejected + // This prevents security issues where credentials could be sent with any header + #[test] + fn test_wildcard_headers_with_credentials_rejected() { + let cors = Cors::builder() + .allow_credentials(true) + .allow_headers(vec!["*".into()]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + assert!( + layer + .unwrap_err() + .contains("Cannot combine `Access-Control-Allow-Credentials: true`") + ); + } + + // Test that wildcard methods with credentials are rejected + // This prevents security issues where credentials could be sent with any method + #[test] + fn test_wildcard_methods_with_credentials_rejected() { + let cors = Cors::builder() + .allow_credentials(true) + .methods(vec!["*".into()]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + assert!( + layer + .unwrap_err() + .contains("Cannot combine `Access-Control-Allow-Credentials: true`") + ); + } + + // Test that wildcard expose headers with credentials are rejected + // This prevents security issues where any header could be exposed with credentials + #[test] + fn test_wildcard_expose_headers_with_credentials_rejected() { + let cors = Cors::builder() + .allow_credentials(true) + .expose_headers(vec!["*".into()]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + assert!( + layer + .unwrap_err() + .contains("Cannot combine `Access-Control-Allow-Credentials: true`") + ); + } + + // Test that per-policy wildcard headers with credentials are rejected + // This prevents security issues where credentials could be sent with any header in a policy + #[test] + fn test_per_policy_wildcard_headers_with_credentials_rejected() { + let cors = Cors::builder() + .allow_credentials(true) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .allow_headers(vec!["*".into()]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + let error_msg = layer.unwrap_err(); + assert!(error_msg.contains("Cannot combine `Access-Control-Allow-Credentials: true`")); + assert!(error_msg.contains("in policy")); + } + + // Test that per-policy wildcard methods with credentials are rejected + // This prevents security issues where credentials could be sent with any method in a policy + #[test] + fn test_per_policy_wildcard_methods_with_credentials_rejected() { + let cors = Cors::builder() + .allow_credentials(true) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .methods(vec!["*".into()]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + let error_msg = layer.unwrap_err(); + assert!(error_msg.contains("Cannot combine `Access-Control-Allow-Credentials: true`")); + assert!(error_msg.contains("in policy")); + } + + // Test that per-policy wildcard expose headers with credentials are rejected + // This prevents security issues where any header could be exposed with credentials in a policy + #[test] + fn test_per_policy_wildcard_expose_headers_with_credentials_rejected() { + let cors = Cors::builder() + .allow_credentials(true) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .expose_headers(vec!["*".into()]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + let error_msg = layer.unwrap_err(); + assert!(error_msg.contains("Cannot combine `Access-Control-Allow-Credentials: true`")); + assert!(error_msg.contains("in policy")); + } + + // Test that per-policy wildcard validation works with multiple policies + // This ensures that validation checks all policies, not just the first one + #[test] + fn test_per_policy_wildcard_validation_with_multiple_policies() { + let cors = Cors::builder() + .allow_credentials(true) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .allow_headers(vec!["content-type".into()]) + .build(), + Policy::builder() + .origins(vec!["https://another.com".into()]) + .allow_headers(vec!["*".into()]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + let error_msg = layer.unwrap_err(); + assert!(error_msg.contains("Cannot combine `Access-Control-Allow-Credentials: true`")); + assert!(error_msg.contains("in policy")); + } + + // Test that per-policy wildcard validation is skipped when credentials are disabled + // This ensures that wildcards are allowed when credentials are not enabled + #[test] + fn test_per_policy_wildcard_allowed_when_credentials_disabled() { + let cors = Cors::builder() + .allow_credentials(false) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .allow_headers(vec!["*".into()]) + .methods(vec!["*".into()]) + .expose_headers(vec!["*".into()]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_ok()); + } + + // Test that Origin: null is only allowed with allow_any_origin: true + // This ensures compliance with the CORS spec which only allows null origin in this case + #[test] + fn test_origin_null_only_allowed_with_allow_any_origin() { + let cors = Cors::builder().allow_any_origin(true).build(); + let layer = cors.into_layer(); + assert!(layer.is_ok()); + + let cors_without_allow_any = Cors::builder().allow_any_origin(false).build(); + let layer = cors_without_allow_any.into_layer(); + assert!(layer.is_ok()); // This should be valid config, but null origin requests should be rejected + } + + // Test that max_age is properly validated and handled + // This ensures preflight caching works correctly and prevents invalid configurations + #[test] + fn test_max_age_validation() { + // Valid max_age + let cors = Cors::builder().max_age(Duration::from_secs(3600)).build(); + let layer = cors.into_layer(); + assert!(layer.is_ok()); + + // Zero max_age should be valid + let cors_zero = Cors::builder().max_age(Duration::from_secs(0)).build(); + let layer_zero = cors_zero.into_layer(); + assert!(layer_zero.is_ok()); + } + + // Test that expose_headers are properly validated + // This ensures that only valid header names can be exposed to the browser + #[test] + fn test_expose_headers_validation() { + // Valid expose headers + let cors = Cors::builder() + .expose_headers(vec!["content-type".into(), "x-custom-header".into()]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_ok()); + + // Invalid expose header + let cors_invalid = Cors::builder() + .expose_headers(vec!["invalid\nheader".into()]) + .build(); + let layer_invalid = cors_invalid.into_layer(); + assert!(layer_invalid.is_err()); + assert!(layer_invalid.unwrap_err().contains("expose header name")); + } + + // Test that origin-specific expose_headers are properly validated + // This ensures per-origin configurations are validated correctly + #[test] + fn test_origin_specific_expose_headers_validation() { + let cors = Cors::builder() + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .expose_headers(vec!["invalid\nheader".into()]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + assert!(layer.unwrap_err().contains("expose header name")); + } + + // Test that origin-specific methods are properly validated + // This ensures per-origin method configurations are validated correctly + #[test] + fn test_origin_specific_methods_validation() { + let cors = Cors::builder() + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .methods(vec!["INVALID\nMETHOD".into()]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + assert!(layer.unwrap_err().contains("method")); + } + + // Test that origin-specific allow_headers are properly validated + // This ensures per-origin header configurations are validated correctly + #[test] + fn test_origin_specific_allow_headers_validation() { + let cors = Cors::builder() + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .allow_headers(vec!["invalid\nheader".into()]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_err()); + assert!(layer.unwrap_err().contains("allow header name")); + } + + // Test that empty origins list is valid + // This ensures the configuration can be used for deny-all scenarios + #[test] + fn test_empty_origins_list_valid() { + let cors = Cors::builder().policies(vec![]).build(); + let layer = cors.into_layer(); + assert!(layer.is_ok()); + } + + // Test that empty methods list falls back to defaults + // This ensures backward compatibility when methods are not specified + #[test] + fn test_empty_methods_falls_back_to_defaults() { + let cors = Cors::builder().methods(vec![]).build(); + let layer = cors.into_layer(); + assert!(layer.is_ok()); + } + + // Test that empty allow_headers list is valid + // This ensures the mirroring behavior works when no headers are configured + #[test] + fn test_empty_allow_headers_valid() { + let cors = Cors::builder().allow_headers(vec![]).build(); + let layer = cors.into_layer(); + assert!(layer.is_ok()); + } + + // Test that complex regex patterns are handled correctly + // This ensures advanced regex matching works for complex origin patterns + #[test] + fn test_complex_regex_patterns() { + let cors = Cors::builder() + .policies(vec![ + Policy::builder() + .origins(vec![]) + .match_origins(vec![ + regex::Regex::new(r"https://(?:www\.)?example\.com").unwrap(), + regex::Regex::new(r"https://api-[0-9]+\.example\.com").unwrap(), + ]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_ok()); + } + + // Test that multiple regex patterns in a single Policy work + // This ensures that multiple regex patterns can be used for the same origin configuration + #[test] + fn test_multiple_regex_patterns_in_single_origin_config() { + let cors = Cors::builder() + .policies(vec![ + Policy::builder() + .origins(vec![]) + .match_origins(vec![ + regex::Regex::new(r"https://api\.example\.com").unwrap(), + regex::Regex::new(r"https://staging\.example\.com").unwrap(), + ]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_ok()); + } + + // Test that case-sensitive origin matching works correctly + // This ensures that origin matching follows the CORS spec which requires case-sensitive matching + #[test] + fn test_case_sensitive_origin_matching() { + let cors = Cors::builder() + .policies(vec![ + Policy::builder() + .origins(vec!["https://Example.com".into()]) + .build(), + ]) + .build(); + let layer = cors.into_layer(); + assert!(layer.is_ok()); + } + + // Test that policies without specified methods fall back to global methods + // This ensures that the global methods are used when policies don't specify methods + #[test] + fn test_policy_falls_back_to_global_methods() { + let cors = Cors::builder() + .methods(vec!["POST".into()]) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .build(), + ]) + .build(); + let layer = cors.clone().into_layer(); + assert!(layer.is_ok()); + + // Verify that the policy has None methods (will fall back to global) + let policies = cors.policies.unwrap(); + assert!(policies[0].methods.is_none()); + + // Verify that the global methods are set correctly + assert_eq!(cors.methods, vec!["POST"]); + } + + // Test that policy with Some([]) methods overrides global defaults + // This ensures that explicitly setting empty methods disables all methods for that policy + #[test] + fn test_policy_empty_methods_override_global() { + let cors = Cors::builder() + .methods(vec!["POST".into(), "PUT".into()]) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .methods(vec![]) + .build(), + ]) + .build(); + let layer = cors.clone().into_layer(); + assert!(layer.is_ok()); + + // Verify that the policy has Some([]) methods (overrides global) + let policies = cors.policies.unwrap(); + assert_eq!(policies[0].methods, Some(vec![])); + + // Verify that the global methods are set correctly + assert_eq!(cors.methods, vec!["POST", "PUT"]); + } + + // Test that policy with Some([value]) methods uses those specific values + // This ensures that explicitly setting methods uses those exact methods + #[test] + fn test_policy_specific_methods_used() { + let cors = Cors::builder() + .methods(vec!["POST".into()]) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .methods(vec!["GET".into(), "DELETE".into()]) + .build(), + ]) + .build(); + let layer = cors.clone().into_layer(); + assert!(layer.is_ok()); + + // Verify that the policy has specific methods + let policies = cors.policies.unwrap(); + assert_eq!( + policies[0].methods, + Some(vec!["GET".into(), "DELETE".into()]) + ); + + // Verify that the global methods are set correctly + assert_eq!(cors.methods, vec!["POST"]); + } + + // Tests based on CORS specification table for credentials mode and Access-Control-Allow-Origin combinations + // https://fetch.spec.whatwg.org/#cors-protocol + + // Test: credentials "omit" + Access-Control-Allow-Origin "*" + Access-Control-Allow-Credentials omitted = ✅ + #[test] + fn test_cors_spec_omit_credentials_wildcard_origin_no_credentials_header() { + let cors = Cors::builder() + .allow_any_origin(true) + .allow_credentials(false) + .build(); + let result = cors.ensure_usable_cors_rules(); + assert!(result.is_ok()); + } + + // Test: credentials "omit" + Access-Control-Allow-Origin "*" + Access-Control-Allow-Credentials "true" = ✅ + // Note: This is allowed because when credentials mode is not "include", Access-Control-Allow-Credentials is ignored + #[test] + fn test_cors_spec_omit_credentials_wildcard_origin_with_credentials_header() { + let cors = Cors::builder() + .allow_any_origin(true) + .allow_credentials(true) + .build(); + let result = cors.ensure_usable_cors_rules(); + // This should fail in our implementation because we enforce the stricter rule + assert!(result.is_err()); + assert!(result.unwrap_err().contains( + "Cannot combine `Access-Control-Allow-Credentials: true` with `allow_any_origin: true`" + )); + } + + // Test: credentials "omit" + Access-Control-Allow-Origin "https://rabbit.invalid/" + Access-Control-Allow-Credentials omitted = ❌ + // A serialized origin has no trailing slash + #[test] + fn test_cors_spec_origin_with_trailing_slash_rejected() { + let cors = Cors::builder() + .policies(vec![ + Policy::builder() + .origins(vec!["https://rabbit.invalid/".into()]) + .build(), + ]) + .build(); + let result = cors.ensure_usable_cors_rules(); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .contains("origins cannot have trailing slashes") + ); + } + + // Test: credentials "omit" + Access-Control-Allow-Origin "https://rabbit.invalid" + Access-Control-Allow-Credentials omitted = ✅ + #[test] + fn test_cors_spec_origin_without_trailing_slash_accepted() { + let cors = Cors::builder() + .policies(vec![ + Policy::builder() + .origins(vec!["https://rabbit.invalid".into()]) + .build(), + ]) + .build(); + let result = cors.ensure_usable_cors_rules(); + assert!(result.is_ok()); + } + + // Test: credentials "include" + Access-Control-Allow-Origin "https://rabbit.invalid" + Access-Control-Allow-Credentials "true" = ✅ + #[test] + fn test_cors_spec_include_credentials_specific_origin_accepted() { + let cors = Cors::builder() + .allow_any_origin(false) + .allow_credentials(true) + .policies(vec![ + Policy::builder() + .origins(vec!["https://rabbit.invalid".into()]) + .build(), + ]) + .build(); + let result = cors.ensure_usable_cors_rules(); + assert!(result.is_ok()); + } + + // Test: credentials "include" + Access-Control-Allow-Origin "https://rabbit.invalid" + Access-Control-Allow-Credentials "True" = ❌ + // "true" is (byte) case-sensitive - but this is handled by serde deserialization + // This test verifies our validation doesn't accidentally allow mixed case + #[test] + fn test_cors_spec_credentials_case_sensitivity_handled_by_serde() { + // Since we use bool in our config, case sensitivity is handled by serde + // This test ensures our validation doesn't break this behavior + let cors = Cors::builder() + .allow_credentials(true) + .policies(vec![ + Policy::builder() + .origins(vec!["https://rabbit.invalid".into()]) + .build(), + ]) + .build(); + let result = cors.ensure_usable_cors_rules(); + assert!(result.is_ok()); + } + + // Test policy-level credentials override behavior + #[test] + fn test_cors_spec_policy_level_credentials_override() { + // Global credentials disabled, but policy-level credentials enabled should still validate + let cors = Cors::builder() + .allow_any_origin(false) + .allow_credentials(false) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .allow_credentials(true) + .allow_headers(vec!["*".into()]) // This should be rejected with policy-level credentials + .build(), + ]) + .build(); + + let result = cors.ensure_usable_cors_rules(); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Cannot combine `Access-Control-Allow-Credentials: true` with `Access-Control-Allow-Headers: *` in policy")); + } + + // Test policy-level credentials disabled should allow wildcards even with global credentials enabled + #[test] + fn test_cors_spec_policy_level_credentials_disabled_allows_wildcards() { + let cors = Cors::builder() + .allow_credentials(true) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .allow_credentials(false) + .allow_headers(vec!["*".into()]) // This should be allowed with policy-level credentials disabled + .build(), + ]) + .build(); + let result = cors.ensure_usable_cors_rules(); + assert!(result.is_ok()); + } + + // Test root path "/" as origin (special case) + #[test] + fn test_cors_spec_root_path_origin_allowed() { + let cors = Cors::builder() + .policies(vec![Policy::builder().origins(vec!["/".into()]).build()]) + .build(); + let result = cors.ensure_usable_cors_rules(); + assert!(result.is_ok()); + } + + // Test multiple trailing slash violations + #[test] + fn test_cors_spec_multiple_trailing_slash_violations() { + let cors = Cors::builder() + .policies(vec![ + Policy::builder() + .origins(vec![ + "https://example.com".into(), // Valid + "https://api.example.com/".into(), // Invalid + "https://app.example.com".into(), // Valid + ]) + .build(), + ]) + .build(); + let result = cors.ensure_usable_cors_rules(); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .contains("origins cannot have trailing slashes") + ); + } + + // Test edge case: empty string origin + #[test] + fn test_cors_spec_empty_origin_handling() { + let cors = Cors::builder() + .policies(vec![Policy::builder().origins(vec!["".into()]).build()]) + .build(); + let result = cors.ensure_usable_cors_rules(); + // Empty string should be handled by header validation, not by our trailing slash check + assert!(result.is_ok()); + } + + // Test comprehensive wildcard validation with all headers/methods/expose combinations + #[test] + fn test_cors_spec_comprehensive_wildcard_validation() { + let cors = Cors::builder() + .allow_credentials(true) + .allow_headers(vec!["*".into()]) + .methods(vec!["*".into()]) + .expose_headers(vec!["*".into()]) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .allow_headers(vec!["*".into()]) + .methods(vec!["*".into()]) + .expose_headers(vec!["*".into()]) + .build(), + ]) + .build(); + let result = cors.ensure_usable_cors_rules(); + assert!(result.is_err()); + // Should fail on the first wildcard check (global allow_headers) + assert!(result.unwrap_err().contains("Cannot combine `Access-Control-Allow-Credentials: true` with `Access-Control-Allow-Headers: *`")); + } +} diff --git a/apollo-router/src/configuration/expansion.rs b/apollo-router/src/configuration/expansion.rs index c865b0d878..3c079451a9 100644 --- a/apollo-router/src/configuration/expansion.rs +++ b/apollo-router/src/configuration/expansion.rs @@ -1,22 +1,25 @@ //! Environment variable expansion in the configuration file +use std::collections::HashMap; use std::env; use std::env::VarError; use std::fs; use std::str::FromStr; +use std::sync::atomic::Ordering; use proteus::Parser; use proteus::TransformBuilder; use serde_json::Value; use super::ConfigurationError; -use crate::executable::APOLLO_ROUTER_DEV_ENV; #[derive(buildstructor::Builder, Clone)] pub(crate) struct Expansion { prefix: Option, supported_modes: Vec, override_configs: Vec, + #[cfg(test)] + mocked_env_vars: HashMap, } #[derive(buildstructor::Builder, Clone)] @@ -29,6 +32,8 @@ pub(crate) struct Override { value: Option, /// The type of the value, used to coerce env variables. value_type: ValueType, + #[cfg(test)] + mocked_env_vars: HashMap, } #[derive(Clone)] @@ -42,15 +47,18 @@ pub(crate) enum ValueType { impl Override { fn value(&self) -> Option { // Order of precedence is: - // 1. If the env variable is set, use that - // 2. If the override is set, use that - // 3. Don't change the config - match ( - self.env_name - .as_ref() - .and_then(|name| std::env::var(name).ok()), - self.value.clone(), - ) { + // 1. In tests only, if the mocked env variable is set, use that + // 2. If the env variable is set, use that + // 3. If the override is set, use that + // 4. Don't change the config + let env_value = self.env_name.as_ref().and_then(|name| { + #[cfg(test)] + if let Some(value) = self.mocked_env_vars.get(name) { + return Some(value.clone()); + } + std::env::var(name).ok() + }); + match (env_value, self.value.clone()) { (Some(value), _) => { // Coerce the env variable into the correct format, otherwise let it through as a string let parsed = Value::from_str(&value); @@ -67,8 +75,16 @@ impl Override { } } +#[buildstructor::buildstructor] impl Expansion { pub(crate) fn default() -> Result { + Self::default_builder().build() + } + + #[builder] + pub(crate) fn default_new( + #[cfg_attr(not(test), allow(unused))] mocked_env_vars: HashMap, + ) -> Result { let prefix = Expansion::prefix_from_env()?; let supported_expansion_modes = match env::var("APOLLO_ROUTER_CONFIG_SUPPORTED_MODES") { @@ -81,16 +97,29 @@ impl Expansion { .map(|mode| mode.trim().to_string()) .collect::>(); - let dev_mode_defaults = if std::env::var(APOLLO_ROUTER_DEV_ENV).ok().as_deref() - == Some("true") + let dev_mode_defaults = if crate::executable::APOLLO_ROUTER_DEV_MODE.load(Ordering::Relaxed) { - tracing::info!("Running with *development* mode settings which facilitate development experience (e.g., introspection enabled)"); + tracing::info!( + "Running with *development* mode settings which facilitate development experience (e.g., introspection enabled)" + ); dev_mode_defaults() } else { Vec::new() }; - Ok(Expansion::builder() + let builder = Expansion::builder(); + #[cfg(test)] + let builder = builder.mocked_env_vars(mocked_env_vars); + let listen_override = Override::builder() + .config_path("supergraph.listen") + .value_type(ValueType::String); + let listen = *crate::executable::APOLLO_ROUTER_LISTEN_ADDRESS.lock(); + let listen_override = if let Some(listen) = listen { + listen_override.value(listen.to_string()).build() + } else { + listen_override.build() + }; + Ok(builder .and_prefix(prefix) .supported_modes(supported_modes) .override_config( @@ -108,13 +137,7 @@ impl Expansion { .value_type(ValueType::String) .build(), ) - .override_config( - Override::builder() - .config_path("supergraph.listen") - .env_name("APOLLO_ROUTER_LISTEN_ADDRESS") - .value_type(ValueType::String) - .build(), - ) + .override_config(listen_override) .override_configs(dev_mode_defaults) .build()) } @@ -168,6 +191,11 @@ fn dev_mode_defaults() -> Vec { .value(false) .value_type(ValueType::Bool) .build(), + Override::builder() + .config_path("connectors.debug_extensions") + .value(true) + .value_type(ValueType::Bool) + .build(), ] } @@ -206,8 +234,8 @@ impl Expansion { pub(crate) fn expand_env(&self, key: &str) -> Result, ConfigurationError> { match self.prefix.as_ref() { - None => env::var(key), - Some(prefix) => env::var(format!("{prefix}_{key}")), + None => self.get_env(key), + Some(prefix) => self.get_env(&format!("{prefix}_{key}")), } .map(Some) .map_err(|cause| ConfigurationError::CannotExpandVariable { @@ -216,6 +244,14 @@ impl Expansion { }) } + fn get_env(&self, name: &str) -> Result { + #[cfg(test)] + if let Some(value) = self.mocked_env_vars.get(name) { + return Ok(value.clone()); + } + env::var(name) + } + pub(crate) fn expand( &self, configuration: &serde_json::Value, @@ -230,19 +266,17 @@ impl Expansion { // Anything that needs expanding via env variable should be placed here. Don't pollute the codebase with calls to std::env. // For testing we have the one fixed expansion. We don't actually want to expand env variables during tests let mut transformer_builder = TransformBuilder::default(); - transformer_builder = - transformer_builder.add_action(Parser::parse("", "").expect("migration must be valid")); + transformer_builder = transformer_builder.add_action(Parser::parse("", "")?); for override_config in &self.override_configs { if let Some(value) = override_config.value() { - transformer_builder = transformer_builder.add_action( - Parser::parse(&format!("const({value})"), &override_config.config_path) - .expect("migration must be valid"), - ); + transformer_builder = transformer_builder.add_action(Parser::parse( + &format!("const({value})"), + &override_config.config_path, + )?); } } *config = transformer_builder - .build() - .expect("failed to build config default transformer") + .build()? .apply(config) .map_err(|e| ConfigurationError::InvalidConfiguration { message: "could not set configuration defaults as the source configuration had an invalid structure", @@ -293,20 +327,20 @@ pub(crate) fn coerce(expanded: &str) -> Value { #[cfg(test)] mod test { use insta::assert_yaml_snapshot; - use serde_json::json; use serde_json::Value; + use serde_json::json; - use crate::configuration::expansion::dev_mode_defaults; + use crate::configuration::Expansion; use crate::configuration::expansion::Override; use crate::configuration::expansion::ValueType; - use crate::configuration::Expansion; + use crate::configuration::expansion::dev_mode_defaults; #[test] fn test_override_precedence() { - std::env::set_var("TEST_OVERRIDE", "env_override"); assert_eq!( None, Override::builder() + .mocked_env_var("TEST_OVERRIDE", "env_override") .config_path("") .value_type(ValueType::String) .build() @@ -315,6 +349,7 @@ mod test { assert_eq!( None, Override::builder() + .mocked_env_var("TEST_OVERRIDE", "env_override") .config_path("") .env_name("NON_EXISTENT") .value_type(ValueType::String) @@ -324,6 +359,7 @@ mod test { assert_eq!( Some(Value::String("override".to_string())), Override::builder() + .mocked_env_var("TEST_OVERRIDE", "env_override") .config_path("") .env_name("NON_EXISTENT") .value("override") @@ -334,6 +370,7 @@ mod test { assert_eq!( Some(Value::String("override".to_string())), Override::builder() + .mocked_env_var("TEST_OVERRIDE", "env_override") .config_path("") .value("override") .value_type(ValueType::String) @@ -343,6 +380,7 @@ mod test { assert_eq!( Some(Value::String("env_override".to_string())), Override::builder() + .mocked_env_var("TEST_OVERRIDE", "env_override") .config_path("") .env_name("TEST_OVERRIDE") .value("override") @@ -354,14 +392,10 @@ mod test { #[test] fn test_type_coercion() { - std::env::set_var("TEST_DEFAULTED_STRING_VAR", "overridden_string"); - std::env::set_var("TEST_DEFAULTED_NUMERIC_VAR", "1"); - std::env::set_var("TEST_DEFAULTED_BOOL_VAR", "true"); - std::env::set_var("TEST_DEFAULTED_INCORRECT_TYPE", "true"); - assert_eq!( Some(Value::String("overridden_string".to_string())), Override::builder() + .mocked_env_var("TEST_DEFAULTED_STRING_VAR", "overridden_string") .config_path("") .env_name("TEST_DEFAULTED_STRING_VAR") .value_type(ValueType::String) @@ -371,6 +405,7 @@ mod test { assert_eq!( Some(Value::Number(1.into())), Override::builder() + .mocked_env_var("TEST_DEFAULTED_NUMERIC_VAR", "1") .config_path("") .env_name("TEST_DEFAULTED_NUMERIC_VAR") .value_type(ValueType::Number) @@ -380,6 +415,7 @@ mod test { assert_eq!( Some(Value::Bool(true)), Override::builder() + .mocked_env_var("TEST_DEFAULTED_BOOL_VAR", "true") .config_path("") .env_name("TEST_DEFAULTED_BOOL_VAR") .value_type(ValueType::Bool) @@ -389,6 +425,7 @@ mod test { assert_eq!( Some(Value::String("true".to_string())), Override::builder() + .mocked_env_var("TEST_DEFAULTED_INCORRECT_TYPE", "true") .config_path("") .env_name("TEST_DEFAULTED_INCORRECT_TYPE") .value_type(ValueType::Number) @@ -399,13 +436,14 @@ mod test { #[test] fn test_unprefixed() { - std::env::set_var("TEST_EXPANSION_VAR", "expanded"); - std::env::set_var("TEST_OVERRIDDEN_VAR", "overridden"); - let expansion = Expansion::builder() + .mocked_env_var("TEST_EXPANSION_VAR", "expanded") + .mocked_env_var("TEST_OVERRIDDEN_VAR", "overridden") .supported_mode("env") .override_config( Override::builder() + .mocked_env_var("TEST_EXPANSION_VAR", "expanded") + .mocked_env_var("TEST_OVERRIDDEN_VAR", "overridden") .config_path("defaulted") .env_name("TEST_DEFAULTED_VAR") .value("defaulted") @@ -414,6 +452,8 @@ mod test { ) .override_config( Override::builder() + .mocked_env_var("TEST_EXPANSION_VAR", "expanded") + .mocked_env_var("TEST_OVERRIDDEN_VAR", "overridden") .config_path("no_env") .env_name("NON_EXISTENT") .value("defaulted") @@ -422,6 +462,8 @@ mod test { ) .override_config( Override::builder() + .mocked_env_var("TEST_EXPANSION_VAR", "expanded") + .mocked_env_var("TEST_OVERRIDDEN_VAR", "overridden") .config_path("overridden") .env_name("TEST_OVERRIDDEN_VAR") .value("defaulted") @@ -439,14 +481,15 @@ mod test { #[test] fn test_prefixed() { - std::env::set_var("TEST_PREFIX_TEST_EXPANSION_VAR", "expanded"); - std::env::set_var("TEST_OVERRIDDEN_VAR", "overridden"); - let expansion = Expansion::builder() + .mocked_env_var("TEST_PREFIX_TEST_EXPANSION_VAR", "expanded") + .mocked_env_var("TEST_OVERRIDDEN_VAR", "overridden") .prefix("TEST_PREFIX") .supported_mode("env") .override_config( Override::builder() + .mocked_env_var("TEST_PREFIX_TEST_EXPANSION_VAR", "expanded") + .mocked_env_var("TEST_OVERRIDDEN_VAR", "overridden") .config_path("defaulted") .env_name("TEST_DEFAULTED_VAR") .value("defaulted") @@ -455,6 +498,8 @@ mod test { ) .override_config( Override::builder() + .mocked_env_var("TEST_PREFIX_TEST_EXPANSION_VAR", "expanded") + .mocked_env_var("TEST_OVERRIDDEN_VAR", "overridden") .config_path("no_env") .env_name("NON_EXISTENT") .value("defaulted") @@ -463,6 +508,8 @@ mod test { ) .override_config( Override::builder() + .mocked_env_var("TEST_PREFIX_TEST_EXPANSION_VAR", "expanded") + .mocked_env_var("TEST_OVERRIDDEN_VAR", "overridden") .config_path("overridden") .env_name("TEST_OVERRIDDEN_VAR") .value("defaulted") diff --git a/apollo-router/src/configuration/metrics.rs b/apollo-router/src/configuration/metrics.rs index 52ec64b384..08086a0125 100644 --- a/apollo-router/src/configuration/metrics.rs +++ b/apollo-router/src/configuration/metrics.rs @@ -1,17 +1,19 @@ use std::collections::HashMap; use std::str::FromStr; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use jsonpath_rust::JsonPathInst; +use opentelemetry::KeyValue; +use opentelemetry::metrics::Meter; use opentelemetry::metrics::MeterProvider; -use opentelemetry_api::metrics::Meter; -use opentelemetry_api::KeyValue; +use parking_lot::Mutex; use paste::paste; use serde_json::Value; -use super::AvailableParallelism; +use crate::Configuration; use crate::metrics::meter_provider; use crate::uplink::license_enforcement::LicenseState; -use crate::Configuration; type InstrumentMap = HashMap)>; @@ -38,7 +40,7 @@ impl Metrics { let mut data = InstrumentData::default(); // Env variables and unit tests don't mix. - data.populate_env_instrument(); + data.populate_cli_instrument(); data.populate_config_instruments( configuration .validated_yaml @@ -47,9 +49,6 @@ impl Metrics { ); data.populate_license_instrument(license_state); data.populate_user_plugins_instrument(configuration); - data.populate_query_planner_experimental_parallelism(configuration); - data.populate_deno_or_rust_mode_instruments(configuration); - data.populate_legacy_fragment_usage(configuration); data.into() } @@ -64,10 +63,10 @@ impl InstrumentData { ) { if let Ok(json_path) = JsonPathInst::from_str(path) { let value_at_path = json_path.find_slice(value).into_iter().next(); - if let Some(Value::Object(children)) = value_at_path.as_deref() { - if let Some(first_key) = children.keys().next() { - attributes.insert(attr_name.to_string(), first_key.clone().into()); - } + if let Some(Value::Object(children)) = value_at_path.as_deref() + && let Some(first_key) = children.keys().next() + { + attributes.insert(attr_name.to_string(), first_key.clone().into()); } } } @@ -124,19 +123,20 @@ impl InstrumentData { } pub(crate) fn populate_config_instruments(&mut self, yaml: &serde_json::Value) { - // This macro will query the config json for a primary metric and optionally metric attributes. - - // The reason we use jsonpath_rust is that jsonpath_lib has correctness issues and looks abandoned. - // We should consider converting the rest of the codebase to use jsonpath_rust. - - // Example usage: - // populate_usage_instrument!( - // value.apollo.router.config.authorization, // The metric name - // "$.authorization", // The path into the config - // opt.require_authentication, // The name of the attribute - // "$[?(@.require_authentication == true)]" // The path for the attribute relative to the metric - // ); - + /// This macro will query the config json for a primary metric and optionally metric attributes. + /// + /// The reason we use jsonpath_rust is that jsonpath_lib has correctness issues and looks abandoned. + /// We should consider converting the rest of the codebase to use jsonpath_rust. + /// + /// Example usage: + /// ```rust,ignore + /// populate_config_instrument!( + /// apollo.router.config.authorization, // The metric name + /// "$.authorization", // The path into the config + /// opt.require_authentication, // The name of the attribute + /// "$[?(@.require_authentication == true)]" // The path for the attribute relative to the metric + /// ); + /// ``` macro_rules! populate_config_instrument { ($($metric:ident).+, $path:literal) => { let instrument_name = stringify!($($metric).+).to_string(); @@ -303,9 +303,7 @@ impl InstrumentData { opt.subgraph.compression, "$[?(@.all.compression || @.subgraphs..compression)]", opt.subgraph.deduplicate_query, - "$[?(@.all.deduplicate_query == true || @.subgraphs..deduplicate_query == true)]", - opt.subgraph.retry, - "$[?(@.all.experimental_retry || @.subgraphs..experimental_retry)]" + "$[?(@.all.deduplicate_query == true || @.subgraphs..deduplicate_query == true)]" ); populate_config_instrument!( @@ -318,7 +316,30 @@ impl InstrumentData { opt.subgraph.enabled, "$[?(@.subgraph.subgraphs..enabled)]", opt.subgraph.ttl, - "$[?(@.subgraph.all.ttl || @.subgraph.subgraphs..ttl)]" + "$[?(@.subgraph.all.ttl || @.subgraph.subgraphs..ttl)]", + opt.subgraph.invalidation.enabled, + "$[?(@.subgraph.all.invalidation.enabled || @.subgraph.subgraphs..invalidation.enabled)]" + ); + + populate_config_instrument!( + apollo.router.config.response_cache, + "$.experimental_response_cache", + opt.enabled, + "$[?(@.enabled)]", + opt.debug, + "$[?(@.debug)]", + opt.subgraph.enabled, + "$[?(@.subgraph.all.enabled)]", + opt.subgraph.postgres.required_to_start, + "$[?(@.subgraph.all.postgres.required_to_start || @.subgraph.subgraphs..postgres.required_to_start)]", + opt.subgraph.postgres.cleanup_interval, + "$[?(@.subgraph.all.postgres.cleanup_interval || @.subgraph.subgraphs..postgres.cleanup_interval)]", + opt.subgraph.enabled, + "$[?(@.subgraph.subgraphs..enabled)]", + opt.subgraph.ttl, + "$[?(@.subgraph.all.ttl || @.subgraph.subgraphs..ttl)]", + opt.subgraph.invalidation.enabled, + "$[?(@.subgraph.all.invalidation.enabled || @.subgraph.subgraphs..invalidation.enabled)]" ); populate_config_instrument!( apollo.router.config.telemetry, @@ -331,8 +352,6 @@ impl InstrumentData { "$..tracing.otlp[?(@.enabled==true)]", opt.tracing.datadog, "$..tracing.datadog[?(@.enabled==true)]", - opt.tracing.jaeger, - "$..tracing.jaeger[?(@.enabled==true)]", opt.tracing.zipkin, "$..tracing.zipkin[?(@.enabled==true)]", opt.events, @@ -343,6 +362,8 @@ impl InstrumentData { "$..events.supergraph", opt.events.subgraph, "$..events.subgraph", + opt.events.connector, + "$..events.connector", opt.instruments, "$..instruments", opt.instruments.router, @@ -351,6 +372,8 @@ impl InstrumentData { "$..instruments.supergraph", opt.instruments.subgraph, "$..instruments.subgraph", + opt.instruments.connector, + "$..instruments.connector", opt.instruments.graphql, "$..instruments.graphql", opt.instruments.default_attribute_requirement_level, @@ -366,9 +389,7 @@ impl InstrumentData { opt.spans.subgraph, "$..spans.subgraph", opt.spans.supergraph, - "$..spans.supergraph", - opt.logging.experimental_when_header, - "$..logging.experimental_when_header" + "$..spans.supergraph" ); populate_config_instrument!( @@ -400,7 +421,26 @@ impl InstrumentData { opt.signature_normalization_algorithm, "$.signature_normalization_algorithm", opt.metrics_reference_mode, - "$.metrics_reference_mode" + "$.metrics_reference_mode", + opt.errors.preview_extended_error_metrics, + "$.errors.preview_extended_error_metrics" + ); + + populate_config_instrument!( + apollo.router.config.connectors, + "$.connectors", + opt.debug_extensions, + "$[?(@.debug_extensions == true)]", + opt.expose_sources_in_context, + "$[?(@.expose_sources_in_context == true)]", + opt.max_requests_per_operation_per_source, + "$[?(@.max_requests_per_operation_per_source)]", + opt.subgraph.config, + "$[?(@.subgraphs..['$config'])]", + opt.source.override_url, + "$[?(@.subgraphs..sources..override_url)]", + opt.source.max_requests_per_operation, + "$[?(@.subgraphs..sources..max_requests_per_operation)]" ); // We need to update the entry we just made because the selected strategy is a named object in the config. @@ -417,45 +457,58 @@ impl InstrumentData { } } - fn populate_env_instrument(&mut self) { - #[cfg(not(test))] - fn env_var_exists(env_name: &str) -> opentelemetry::Value { - std::env::var(env_name) - .map(|_| true) - .unwrap_or(false) - .into() + fn populate_cli_instrument(&mut self) { + fn mutex_is_some(mutex: &Mutex>) -> opentelemetry::Value { + if cfg!(test) { + true.into() + } else { + mutex.lock().is_some().into() + } } - #[cfg(test)] - fn env_var_exists(_env_name: &str) -> opentelemetry::Value { - true.into() + fn atomic_is_true(atomic: &AtomicBool) -> opentelemetry::Value { + if cfg!(test) { + true.into() + } else { + atomic.load(Ordering::Relaxed).into() + } } - let mut attributes = HashMap::new(); - attributes.insert("opt.apollo.key".to_string(), env_var_exists("APOLLO_KEY")); + attributes.insert( + "opt.apollo.key".to_string(), + mutex_is_some(&crate::services::APOLLO_KEY), + ); attributes.insert( "opt.apollo.graph_ref".to_string(), - env_var_exists("APOLLO_GRAPH_REF"), + mutex_is_some(&crate::services::APOLLO_GRAPH_REF), ); attributes.insert( "opt.apollo.license".to_string(), - env_var_exists("APOLLO_ROUTER_LICENSE"), + atomic_is_true(&crate::executable::APOLLO_ROUTER_LICENCE_IS_SET), ); attributes.insert( "opt.apollo.license.path".to_string(), - env_var_exists("APOLLO_ROUTER_LICENSE_PATH"), + atomic_is_true(&crate::executable::APOLLO_ROUTER_LICENCE_PATH_IS_SET), ); attributes.insert( "opt.apollo.supergraph.urls".to_string(), - env_var_exists("APOLLO_ROUTER_SUPERGRAPH_URLS"), + atomic_is_true(&crate::executable::APOLLO_ROUTER_SUPERGRAPH_URLS_IS_SET), ); attributes.insert( "opt.apollo.supergraph.path".to_string(), - env_var_exists("APOLLO_ROUTER_SUPERGRAPH_PATH"), + atomic_is_true(&crate::executable::APOLLO_ROUTER_SUPERGRAPH_PATH_IS_SET), ); - attributes.insert( "opt.apollo.dev".to_string(), - env_var_exists("APOLLO_ROUTER_DEV_ENV"), + atomic_is_true(&crate::executable::APOLLO_ROUTER_DEV_MODE), + ); + attributes.insert( + "opt.security.recursive_selections".to_string(), + crate::services::layers::query_analysis::recursive_selections_check_enabled().into(), + ); + attributes.insert( + "opt.security.non_local_selections".to_string(), + crate::query_planner::query_planner_service::non_local_selections_check_enabled() + .into(), ); self.data @@ -495,84 +548,6 @@ impl InstrumentData { ), ); } - - pub(crate) fn populate_legacy_fragment_usage(&mut self, configuration: &Configuration) { - // Fragment generation takes precedence over fragment reuse. Only report when fragment reuse is *actually active*. - if configuration.supergraph.reuse_query_fragments == Some(true) - && !configuration.supergraph.generate_query_fragments - { - self.data.insert( - "apollo.router.config.reuse_query_fragments".to_string(), - (1, HashMap::new()), - ); - } - } - - pub(crate) fn populate_query_planner_experimental_parallelism( - &mut self, - configuration: &Configuration, - ) { - let query_planner_parallelism_config = configuration - .supergraph - .query_planning - .experimental_parallelism; - - if query_planner_parallelism_config != Default::default() { - let mut attributes = HashMap::new(); - attributes.insert( - "mode".to_string(), - if let AvailableParallelism::Auto(_) = query_planner_parallelism_config { - "auto" - } else { - "static" - } - .into(), - ); - self.data.insert( - "apollo.router.config.query_planning.parallelism".to_string(), - ( - configuration - .supergraph - .query_planning - .experimental_query_planner_parallelism() - .map(|n| { - #[cfg(test)] - { - // Set to a fixed number for snapshot tests - if let AvailableParallelism::Auto(_) = - query_planner_parallelism_config - { - return 8; - } - } - let as_usize: usize = n.into(); - let as_u64: u64 = as_usize.try_into().unwrap_or_default(); - as_u64 - }) - .unwrap_or_default(), - attributes, - ), - ); - } - } - - /// Populate metrics on the rollout of experimental Rust replacements of JavaScript code. - pub(crate) fn populate_deno_or_rust_mode_instruments(&mut self, configuration: &Configuration) { - let experimental_query_planner_mode = match configuration.experimental_query_planner_mode { - super::QueryPlannerMode::Legacy => "legacy", - super::QueryPlannerMode::Both => "both", - super::QueryPlannerMode::BothBestEffort => "both_best_effort", - super::QueryPlannerMode::New => "new", - }; - - self.data.insert( - "apollo.router.config.experimental_query_planner_mode".to_string(), - ( - 1, - HashMap::from_iter([("mode".to_string(), experimental_query_planner_mode.into())]), - ), - ); - } } impl From for Metrics { @@ -605,9 +580,8 @@ mod test { use crate::configuration::metrics::InstrumentData; use crate::configuration::metrics::Metrics; - use crate::configuration::QueryPlannerMode; + use crate::uplink::license_enforcement::LicenseLimits; use crate::uplink::license_enforcement::LicenseState; - use crate::Configuration; #[derive(RustEmbed)] #[folder = "src/configuration/testdata/metrics"] @@ -625,8 +599,6 @@ mod test { let mut data = InstrumentData::default(); data.populate_config_instruments(yaml); - let configuration: Configuration = input.parse().unwrap(); - data.populate_query_planner_experimental_parallelism(&configuration); let _metrics: Metrics = data.into(); assert_non_zero_metrics_snapshot!(file_name); } @@ -635,7 +607,7 @@ mod test { #[test] fn test_env_metrics() { let mut data = InstrumentData::default(); - data.populate_env_instrument(); + data.populate_cli_instrument(); let _metrics: Metrics = data.into(); assert_non_zero_metrics_snapshot!(); } @@ -643,7 +615,9 @@ mod test { #[test] fn test_license_warn() { let mut data = InstrumentData::default(); - data.populate_license_instrument(&LicenseState::LicensedWarn); + data.populate_license_instrument(&LicenseState::LicensedWarn { + limits: Some(LicenseLimits::default()), + }); let _metrics: Metrics = data.into(); assert_non_zero_metrics_snapshot!(); } @@ -651,7 +625,9 @@ mod test { #[test] fn test_license_halt() { let mut data = InstrumentData::default(); - data.populate_license_instrument(&LicenseState::LicensedHalt); + data.populate_license_instrument(&LicenseState::LicensedHalt { + limits: Some(LicenseLimits::default()), + }); let _metrics: Metrics = data.into(); assert_non_zero_metrics_snapshot!(); } @@ -680,35 +656,4 @@ mod test { let _metrics: Metrics = data.into(); assert_non_zero_metrics_snapshot!(); } - - #[test] - fn test_experimental_mode_metrics() { - let mut data = InstrumentData::default(); - data.populate_deno_or_rust_mode_instruments(&Configuration { - experimental_query_planner_mode: QueryPlannerMode::Both, - ..Default::default() - }); - let _metrics: Metrics = data.into(); - assert_non_zero_metrics_snapshot!(); - } - - #[test] - fn test_experimental_mode_metrics_2() { - let mut data = InstrumentData::default(); - // Default query planner value should still be reported - data.populate_deno_or_rust_mode_instruments(&Configuration::default()); - let _metrics: Metrics = data.into(); - assert_non_zero_metrics_snapshot!(); - } - - #[test] - fn test_experimental_mode_metrics_3() { - let mut data = InstrumentData::default(); - data.populate_deno_or_rust_mode_instruments(&Configuration { - experimental_query_planner_mode: QueryPlannerMode::New, - ..Default::default() - }); - let _metrics: Metrics = data.into(); - assert_non_zero_metrics_snapshot!(); - } } diff --git a/apollo-router/src/configuration/migrations/0031-experimental_reuse_query_fragments.yaml b/apollo-router/src/configuration/migrations/0031-experimental_reuse_query_fragments.yaml new file mode 100644 index 0000000000..74eeeec00e --- /dev/null +++ b/apollo-router/src/configuration/migrations/0031-experimental_reuse_query_fragments.yaml @@ -0,0 +1,4 @@ +description: supergraph.experimental_reuse_query_fragments fragment optimization config is no longer supported and superseded by supergraph.generate_query_fragments +actions: + - type: delete + path: supergraph.experimental_reuse_query_fragments diff --git a/apollo-router/src/configuration/migrations/0032-experimental_query_planner_mode.yaml b/apollo-router/src/configuration/migrations/0032-experimental_query_planner_mode.yaml new file mode 100644 index 0000000000..ef6525e9cf --- /dev/null +++ b/apollo-router/src/configuration/migrations/0032-experimental_query_planner_mode.yaml @@ -0,0 +1,4 @@ +description: experimental_query_planner_mode config is no longer supported as the legacy query planner was removed +actions: + - type: delete + path: experimental_query_planner_mode diff --git a/apollo-router/src/configuration/migrations/0033-experimental_retry.yaml b/apollo-router/src/configuration/migrations/0033-experimental_retry.yaml new file mode 100644 index 0000000000..cbfad5e1a0 --- /dev/null +++ b/apollo-router/src/configuration/migrations/0033-experimental_retry.yaml @@ -0,0 +1,6 @@ +description: the experimental_retry feature has been removed because it was unused and badly implemented +actions: + - type: delete + path: traffic_shaping.all.experimental_retry + - type: delete + path: traffic_shaping.subgraphs.*.experimental_retry \ No newline at end of file diff --git a/apollo-router/src/configuration/migrations/0034-experimental_parallelism.yaml b/apollo-router/src/configuration/migrations/0034-experimental_parallelism.yaml new file mode 100644 index 0000000000..1623dc80bb --- /dev/null +++ b/apollo-router/src/configuration/migrations/0034-experimental_parallelism.yaml @@ -0,0 +1,4 @@ +description: supergraph.query_planning.experimental_parallelism config is no longer supported as a result of the legacy query planner removal +actions: + - type: delete + path: supergraph.query_planning.experimental_parallelism diff --git a/apollo-router/src/configuration/migrations/0035-preview_connectors.yaml b/apollo-router/src/configuration/migrations/0035-preview_connectors.yaml new file mode 100644 index 0000000000..8605149777 --- /dev/null +++ b/apollo-router/src/configuration/migrations/0035-preview_connectors.yaml @@ -0,0 +1,5 @@ +description: Apollo Connectors is no longer in preview +actions: + - type: move + from: preview_connectors + to: connectors diff --git a/apollo-router/src/configuration/migrations/0036-preview_connectors_subgraphs.yaml b/apollo-router/src/configuration/migrations/0036-preview_connectors_subgraphs.yaml new file mode 100644 index 0000000000..abba7372b0 --- /dev/null +++ b/apollo-router/src/configuration/migrations/0036-preview_connectors_subgraphs.yaml @@ -0,0 +1,6 @@ +description: Apollo Connectors GA has replaced `subgraphs` configuration with `sources` +actions: + - type: log + level: warn + path: connectors.subgraphs + log: "In the General Availability (GA) release of Apollo Connectors, `subgraphs` has been replaced by `sources`. Please update your configuration." diff --git a/apollo-router/src/configuration/migrations/0037-preview_otlp_error_metrics.yaml b/apollo-router/src/configuration/migrations/0037-preview_otlp_error_metrics.yaml new file mode 100644 index 0000000000..1d10a484a1 --- /dev/null +++ b/apollo-router/src/configuration/migrations/0037-preview_otlp_error_metrics.yaml @@ -0,0 +1,5 @@ +description: The OTLP error metrics option is now in the preview release stage +actions: + - type: move + from: telemetry.apollo.errors.experimental_otlp_error_metrics + to: telemetry.apollo.errors.preview_extended_error_metrics diff --git a/apollo-router/src/configuration/migrations/2000-jwt-issuer-becomes-issuers.yaml b/apollo-router/src/configuration/migrations/2000-jwt-issuer-becomes-issuers.yaml new file mode 100644 index 0000000000..d0cc8b4624 --- /dev/null +++ b/apollo-router/src/configuration/migrations/2000-jwt-issuer-becomes-issuers.yaml @@ -0,0 +1,68 @@ +description: JWK issuer becomes array of issuers in authentication +# I can't figure out how to do this for all elements of an array, so did +# it for 20 instead. +actions: + - type: log + level: info + path: authentication.router.jwt.jwks[].issuer + log: "JWK `issuer` is now an array (`issuers`). The first 20 JWKs in your configuration will be migrated automatically." + - type: move + from: authentication.router.jwt.jwks[0].issuer + to: authentication.router.jwt.jwks[0].issuers[] + - type: move + from: authentication.router.jwt.jwks[1].issuer + to: authentication.router.jwt.jwks[1].issuers[] + - type: move + from: authentication.router.jwt.jwks[2].issuer + to: authentication.router.jwt.jwks[2].issuers[] + - type: move + from: authentication.router.jwt.jwks[3].issuer + to: authentication.router.jwt.jwks[3].issuers[] + - type: move + from: authentication.router.jwt.jwks[4].issuer + to: authentication.router.jwt.jwks[4].issuers[] + - type: move + from: authentication.router.jwt.jwks[5].issuer + to: authentication.router.jwt.jwks[5].issuers[] + - type: move + from: authentication.router.jwt.jwks[6].issuer + to: authentication.router.jwt.jwks[6].issuers[] + - type: move + from: authentication.router.jwt.jwks[7].issuer + to: authentication.router.jwt.jwks[7].issuers[] + - type: move + from: authentication.router.jwt.jwks[8].issuer + to: authentication.router.jwt.jwks[8].issuers[] + - type: move + from: authentication.router.jwt.jwks[9].issuer + to: authentication.router.jwt.jwks[9].issuers[] + - type: move + from: authentication.router.jwt.jwks[10].issuer + to: authentication.router.jwt.jwks[10].issuers[] + - type: move + from: authentication.router.jwt.jwks[11].issuer + to: authentication.router.jwt.jwks[11].issuers[] + - type: move + from: authentication.router.jwt.jwks[12].issuer + to: authentication.router.jwt.jwks[12].issuers[] + - type: move + from: authentication.router.jwt.jwks[13].issuer + to: authentication.router.jwt.jwks[13].issuers[] + - type: move + from: authentication.router.jwt.jwks[14].issuer + to: authentication.router.jwt.jwks[14].issuers[] + - type: move + from: authentication.router.jwt.jwks[15].issuer + to: authentication.router.jwt.jwks[15].issuers[] + - type: move + from: authentication.router.jwt.jwks[16].issuer + to: authentication.router.jwt.jwks[16].issuers[] + - type: move + from: authentication.router.jwt.jwks[17].issuer + to: authentication.router.jwt.jwks[17].issuers[] + - type: move + from: authentication.router.jwt.jwks[18].issuer + to: authentication.router.jwt.jwks[18].issuers[] + - type: move + from: authentication.router.jwt.jwks[19].issuer + to: authentication.router.jwt.jwks[19].issuers[] diff --git a/apollo-router/src/configuration/migrations/2038-ignored-headers-subs-dedup.yaml b/apollo-router/src/configuration/migrations/2038-ignored-headers-subs-dedup.yaml new file mode 100644 index 0000000000..79631b4a12 --- /dev/null +++ b/apollo-router/src/configuration/migrations/2038-ignored-headers-subs-dedup.yaml @@ -0,0 +1,5 @@ +description: "`subscription.enable_deduplication` was a true/false before but now has child properties `enabled` and `ignored_headers`" +actions: + - type: move + from: subscription.enable_deduplication + to: subscription.deduplication.enabled diff --git a/apollo-router/src/configuration/migrations/2039-cors-origins-to-policies.yaml b/apollo-router/src/configuration/migrations/2039-cors-origins-to-policies.yaml new file mode 100644 index 0000000000..05ced03041 --- /dev/null +++ b/apollo-router/src/configuration/migrations/2039-cors-origins-to-policies.yaml @@ -0,0 +1,18 @@ +description: Migrate CORS origins array to policy-based structure +actions: + # Log a message to inform users about the migration + - type: log + level: info + path: cors.origins + log: "CORS configuration has been migrated from 'origins' array to policy-based structure. Your origins have been moved to the first policy in 'cors.policies[0].origins'. You can now configure per-policy settings like allow_credentials, methods, allow_headers, and expose_headers for more granular control." + + # If legacy fields exists, we need to migrate them to the new policy-based structure + # In order to support the unlikely case of a user having _both_ the legacy config _and_ the new config + # at the same time, we append both names separately to the `policies` array. This prevents overwriting + # user configuration in a potentially security-critical way. + - type: move + from: cors.origins + to: cors.policies[].origins + - type: move + from: cors.match_origins + to: cors.policies[].match_origins diff --git a/apollo-router/src/configuration/migrations/README.md b/apollo-router/src/configuration/migrations/README.md index 7a061a105c..0508c448e7 100644 --- a/apollo-router/src/configuration/migrations/README.md +++ b/apollo-router/src/configuration/migrations/README.md @@ -6,7 +6,7 @@ It uses [proteus](https://github.com/rust-playground/proteus) under the hood, wh A migration has the following format: The filename should begin with a 4 digit numerical prefix. This allows us to apply migrations in a deterministic order. -`Filename: 0001-name.yaml` +`Filename: 0001-name.yaml`. It must start with the current major version of the router. For example for router 2.x it should start with `2001-name.yaml`. If it doesn'start with the right version then it would be considered as a real breaking change and won't be automatically migrated when the router starts. The yaml consists of a description and a number of actions: ```yaml diff --git a/apollo-router/src/configuration/mod.rs b/apollo-router/src/configuration/mod.rs index 2df5ad8c62..e932a325d0 100644 --- a/apollo-router/src/configuration/mod.rs +++ b/apollo-router/src/configuration/mod.rs @@ -1,17 +1,21 @@ //! Logic for loading configuration in to an object model use std::fmt; +use std::hash::Hash; use std::io; use std::io::BufReader; use std::iter; use std::net::IpAddr; use std::net::SocketAddr; +use std::num::NonZeroU32; use std::num::NonZeroUsize; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; +use connector::ConnectorConfiguration; use derivative::Derivative; use displaydoc::Display; +use itertools::Either; use itertools::Itertools; use once_cell::sync::Lazy; pub(crate) use persisted_queries::PersistedQueries; @@ -19,22 +23,20 @@ pub(crate) use persisted_queries::PersistedQueriesPrewarmQueryPlanCache; #[cfg(test)] pub(crate) use persisted_queries::PersistedQueriesSafelist; use regex::Regex; -use rustls::Certificate; -use rustls::PrivateKey; use rustls::ServerConfig; -use rustls_pemfile::certs; -use rustls_pemfile::read_one; -use rustls_pemfile::Item; -use schemars::gen::SchemaGenerator; +use rustls::pki_types::CertificateDer; +use rustls::pki_types::PrivateKeyDer; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; use schemars::schema::ObjectValidation; use schemars::schema::Schema; use schemars::schema::SchemaObject; -use schemars::JsonSchema; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use serde_json::Map; use serde_json::Value; +use sha2::Digest; use thiserror::Error; use self::cors::Cors; @@ -42,25 +44,36 @@ use self::expansion::Expansion; pub(crate) use self::experimental::Discussed; pub(crate) use self::schema::generate_config_schema; pub(crate) use self::schema::generate_upgrade; +pub(crate) use self::schema::validate_yaml_configuration; +use self::server::Server; use self::subgraph::SubgraphConfiguration; +use crate::ApolloRouterError; use crate::cache::DEFAULT_CACHE_CAPACITY; -use crate::configuration::schema::Mode; +use crate::configuration::cooperative_cancellation::CooperativeCancellation; use crate::graphql; use crate::notification::Notify; use crate::plugin::plugins; +use crate::plugins::chaos; +use crate::plugins::chaos::Config; +use crate::plugins::healthcheck::Config as HealthCheck; +#[cfg(test)] +use crate::plugins::healthcheck::test_listen; use crate::plugins::limits; -use crate::plugins::subscription::SubscriptionConfig; use crate::plugins::subscription::APOLLO_SUBSCRIPTION_PLUGIN; use crate::plugins::subscription::APOLLO_SUBSCRIPTION_PLUGIN_NAME; +use crate::plugins::subscription::SubscriptionConfig; use crate::uplink::UplinkConfig; -use crate::ApolloRouterError; +pub(crate) mod connector; +pub(crate) mod cooperative_cancellation; pub(crate) mod cors; pub(crate) mod expansion; mod experimental; pub(crate) mod metrics; +pub(crate) mod mode; mod persisted_queries; -mod schema; +pub(crate) mod schema; +pub(crate) mod server; pub(crate) mod shared; pub(crate) mod subgraph; #[cfg(test)] @@ -109,6 +122,22 @@ pub enum ConfigurationError { CertificateAuthorities { error: String }, } +impl From for ConfigurationError { + fn from(error: proteus::Error) -> Self { + Self::MigrationFailure { + error: error.to_string(), + } + } +} + +impl From for ConfigurationError { + fn from(error: proteus::parser::Error) -> Self { + Self::MigrationFailure { + error: error.to_string(), + } + } +} + /// The configuration for the router. /// /// Can be created through `serde::Deserialize` from various formats, @@ -117,7 +146,7 @@ pub enum ConfigurationError { #[derivative(Debug)] // We can't put a global #[serde(default)] here because of the Default implementation using `from_str` which use deserialize pub struct Configuration { - /// The raw configuration string. + /// The raw configuration value. #[serde(skip)] pub(crate) validated_yaml: Option, @@ -133,6 +162,10 @@ pub struct Configuration { #[serde(default)] pub(crate) homepage: Homepage, + /// Configuration for the server + #[serde(default)] + pub(crate) server: Server, + /// Configuration for the supergraph #[serde(default)] pub(crate) supergraph: Supergraph, @@ -159,11 +192,7 @@ pub struct Configuration { /// Configuration for chaos testing, trying to reproduce bugs that require uncommon conditions. /// You probably don’t want this in production! #[serde(default)] - pub(crate) experimental_chaos: Chaos, - - /// Set the query planner implementation to use. - #[serde(default)] - pub(crate) experimental_query_planner_mode: QueryPlannerMode, + pub(crate) experimental_chaos: Config, /// Plugin configuration #[serde(default)] @@ -196,37 +225,6 @@ impl PartialEq for Configuration { } } -/// Query planner modes. -#[derive(Clone, PartialEq, Eq, Default, Derivative, Serialize, Deserialize, JsonSchema)] -#[derivative(Debug)] -#[serde(rename_all = "snake_case")] -pub(crate) enum QueryPlannerMode { - /// Use the new Rust-based implementation. - /// - /// Raises an error at Router startup if the the new planner does not support the schema - /// (such as using legacy Apollo Federation 1) - New, - /// Use the old JavaScript-based implementation. - Legacy, - /// Use primarily the Javascript-based implementation, - /// but also schedule background jobs to run the Rust implementation and compare results, - /// logging warnings if the implementations disagree. - /// - /// Raises an error at Router startup if the the new planner does not support the schema - /// (such as using legacy Apollo Federation 1) - Both, - /// Use primarily the Javascript-based implementation, - /// but also schedule on a best-effort basis background jobs - /// to run the Rust implementation and compare results, - /// logging warnings if the implementations disagree. - /// - /// Falls back to `legacy` with a warning - /// if the the new planner does not support the schema - /// (such as using legacy Apollo Federation 1) - #[default] - BothBestEffort, -} - impl<'de> serde::Deserialize<'de> for Configuration { fn deserialize(deserializer: D) -> Result where @@ -240,6 +238,7 @@ impl<'de> serde::Deserialize<'de> for Configuration { health_check: HealthCheck, sandbox: Sandbox, homepage: Homepage, + server: Server, supergraph: Supergraph, cors: Cors, plugins: UserPlugins, @@ -249,10 +248,9 @@ impl<'de> serde::Deserialize<'de> for Configuration { apq: Apq, persisted_queries: PersistedQueries, limits: limits::Config, - experimental_chaos: Chaos, + experimental_chaos: chaos::Config, batching: Batching, experimental_type_conditioned_fetching: bool, - experimental_query_planner_mode: QueryPlannerMode, } let mut ad_hoc: AdHocConfiguration = serde::Deserialize::deserialize(deserializer)?; @@ -265,12 +263,17 @@ impl<'de> serde::Deserialize<'de> for Configuration { "limits".to_string(), serde_json::to_value(&ad_hoc.limits).unwrap(), ); + ad_hoc.apollo_plugins.plugins.insert( + "health_check".to_string(), + serde_json::to_value(&ad_hoc.health_check).unwrap(), + ); // Use a struct literal instead of a builder to ensure this is exhaustive Configuration { health_check: ad_hoc.health_check, sandbox: ad_hoc.sandbox, homepage: ad_hoc.homepage, + server: ad_hoc.server, supergraph: ad_hoc.supergraph, cors: ad_hoc.cors, tls: ad_hoc.tls, @@ -279,7 +282,6 @@ impl<'de> serde::Deserialize<'de> for Configuration { limits: ad_hoc.limits, experimental_chaos: ad_hoc.experimental_chaos, experimental_type_conditioned_fetching: ad_hoc.experimental_type_conditioned_fetching, - experimental_query_planner_mode: ad_hoc.experimental_query_planner_mode, plugins: ad_hoc.plugins, apollo_plugins: ad_hoc.apollo_plugins, batching: ad_hoc.batching, @@ -300,11 +302,6 @@ fn default_graphql_listen() -> ListenAddr { SocketAddr::from_str("127.0.0.1:4000").unwrap().into() } -#[cfg(test)] -fn test_listen() -> ListenAddr { - SocketAddr::from_str("127.0.0.1:0").unwrap().into() -} - #[cfg(test)] #[buildstructor::buildstructor] impl Configuration { @@ -321,17 +318,18 @@ impl Configuration { apq: Option, persisted_query: Option, operation_limits: Option, - chaos: Option, + chaos: Option, uplink: Option, experimental_type_conditioned_fetching: Option, batching: Option, - experimental_query_planner_mode: Option, + server: Option, ) -> Result { let notify = Self::notify(&apollo_plugins)?; let conf = Self { validated_yaml: Default::default(), supergraph: supergraph.unwrap_or_default(), + server: server.unwrap_or_default(), health_check: health_check.unwrap_or_default(), sandbox: sandbox.unwrap_or_default(), homepage: homepage.unwrap_or_default(), @@ -340,7 +338,6 @@ impl Configuration { persisted_queries: persisted_query.unwrap_or_default(), limits: operation_limits.unwrap_or_default(), experimental_chaos: chaos.unwrap_or_default(), - experimental_query_planner_mode: experimental_query_planner_mode.unwrap_or_default(), plugins: UserPlugins { plugins: Some(plugins), }, @@ -360,6 +357,18 @@ impl Configuration { } impl Configuration { + pub(crate) fn hash(&self) -> String { + let mut hasher = sha2::Sha256::new(); + let defaulted_raw = self + .validated_yaml + .as_ref() + .map(|s| serde_yaml::to_string(s).expect("config was not serializable")) + .unwrap_or_default(); + hasher.update(defaulted_raw); + let hash: String = format!("{:x}", hasher.finalize()); + hash + } + fn notify( apollo_plugins: &Map, ) -> Result, ConfigurationError> { @@ -392,40 +401,32 @@ impl Configuration { ).build()) } - pub(crate) fn js_query_planner_config(&self) -> router_bridge::planner::QueryPlannerConfig { - router_bridge::planner::QueryPlannerConfig { - reuse_query_fragments: self.supergraph.reuse_query_fragments, - generate_query_fragments: Some(self.supergraph.generate_query_fragments), - incremental_delivery: Some(router_bridge::planner::IncrementalDeliverySupport { - enable_defer: Some(self.supergraph.defer_support), - }), - graphql_validation: false, - debug: Some(router_bridge::planner::QueryPlannerDebugConfig { - bypass_planner_for_single_subgraph: None, - max_evaluated_plans: self - .supergraph - .query_planning - .experimental_plans_limit - .or(Some(10000)), - paths_limit: self.supergraph.query_planning.experimental_paths_limit, - }), - type_conditioned_fetching: self.experimental_type_conditioned_fetching, - } - } - pub(crate) fn rust_query_planner_config( &self, ) -> apollo_federation::query_plan::query_planner::QueryPlannerConfig { - apollo_federation::query_plan::query_planner::QueryPlannerConfig { - reuse_query_fragments: self.supergraph.reuse_query_fragments.unwrap_or(true), + use apollo_federation::query_plan::query_planner::QueryPlanIncrementalDeliveryConfig; + use apollo_federation::query_plan::query_planner::QueryPlannerConfig; + use apollo_federation::query_plan::query_planner::QueryPlannerDebugConfig; + + let max_evaluated_plans = self + .supergraph + .query_planning + .experimental_plans_limit + // Fails if experimental_plans_limit is zero; use our default. + .and_then(NonZeroU32::new) + .unwrap_or(NonZeroU32::new(10_000).expect("it is not zero")); + + QueryPlannerConfig { subgraph_graphql_validation: false, generate_query_fragments: self.supergraph.generate_query_fragments, - incremental_delivery: - apollo_federation::query_plan::query_planner::QueryPlanIncrementalDeliveryConfig { - enable_defer: self.supergraph.defer_support, - }, + incremental_delivery: QueryPlanIncrementalDeliveryConfig { + enable_defer: self.supergraph.defer_support, + }, type_conditioned_fetching: self.experimental_type_conditioned_fetching, - debug: Default::default(), + debug: QueryPlannerDebugConfig { + max_evaluated_plans, + paths_limit: self.supergraph.query_planning.experimental_paths_limit, + }, } } } @@ -454,22 +455,22 @@ impl Configuration { apq: Option, persisted_query: Option, operation_limits: Option, - chaos: Option, + chaos: Option, uplink: Option, batching: Option, experimental_type_conditioned_fetching: Option, - experimental_query_planner_mode: Option, + server: Option, ) -> Result { let configuration = Self { validated_yaml: Default::default(), + server: server.unwrap_or_default(), supergraph: supergraph.unwrap_or_else(|| Supergraph::fake_builder().build()), - health_check: health_check.unwrap_or_else(|| HealthCheck::fake_builder().build()), + health_check: health_check.unwrap_or_else(|| HealthCheck::builder().build()), sandbox: sandbox.unwrap_or_else(|| Sandbox::fake_builder().build()), homepage: homepage.unwrap_or_else(|| Homepage::fake_builder().build()), cors: cors.unwrap_or_default(), limits: operation_limits.unwrap_or_default(), experimental_chaos: chaos.unwrap_or_default(), - experimental_query_planner_mode: experimental_query_planner_mode.unwrap_or_default(), plugins: UserPlugins { plugins: Some(plugins), }, @@ -492,14 +493,6 @@ impl Configuration { impl Configuration { pub(crate) fn validate(self) -> Result { - #[cfg(not(feature = "hyper_header_limits"))] - if self.limits.http1_max_request_headers.is_some() { - return Err(ConfigurationError::InvalidConfiguration { - message: "'limits.http1_max_request_headers' requires 'hyper_header_limits' feature", - error: "enable 'hyper_header_limits' feature in order to use 'limits.http1_max_request_headers'".to_string(), - }); - } - // Sandbox and Homepage cannot be both enabled if self.sandbox.enabled && self.homepage.enabled { return Err(ConfigurationError::InvalidConfiguration { @@ -516,13 +509,12 @@ impl Configuration { } if !self.supergraph.path.starts_with('/') { return Err(ConfigurationError::InvalidConfiguration { - message: "invalid 'server.graphql_path' configuration", - error: format!( - "'{}' is invalid, it must be an absolute path and start with '/', you should try with '/{}'", - self.supergraph.path, - self.supergraph.path - ), - }); + message: "invalid 'server.graphql_path' configuration", + error: format!( + "'{}' is invalid, it must be an absolute path and start with '/', you should try with '/{}'", + self.supergraph.path, self.supergraph.path + ), + }); } if self.supergraph.path.ends_with('*') && !self.supergraph.path.ends_with("/*") @@ -537,15 +529,13 @@ impl Configuration { }); } if self.supergraph.path.contains("/*/") { - return Err( - ConfigurationError::InvalidConfiguration { - message: "invalid 'server.graphql_path' configuration", - error: format!( - "'{}' is invalid, if you need to set a path like '/*/graphql' then specify it as a path parameter with a name, for example '/:my_project_key/graphql'", - self.supergraph.path - ), - }, - ); + return Err(ConfigurationError::InvalidConfiguration { + message: "invalid 'server.graphql_path' configuration", + error: format!( + "'{}' is invalid, if you need to set a path like '/*/graphql' then specify it as a path parameter with a name, for example '/:my_project_key/graphql'", + self.supergraph.path + ), + }); } // PQs. @@ -587,15 +577,25 @@ impl FromStr for Configuration { type Err = ConfigurationError; fn from_str(s: &str) -> Result { - schema::validate_yaml_configuration(s, Expansion::default()?, Mode::Upgrade)?.validate() + schema::validate_yaml_configuration(s, Expansion::default()?, schema::Mode::Upgrade)? + .validate() } } -fn gen_schema(plugins: schemars::Map) -> Schema { +fn gen_schema( + plugins: schemars::Map, + hidden_plugins: Option>, +) -> Schema { let plugins_object = SchemaObject { object: Some(Box::new(ObjectValidation { properties: plugins, additional_properties: Option::Some(Box::new(Schema::Bool(false))), + pattern_properties: hidden_plugins + .unwrap_or_default() + .into_iter() + // Wrap plugin name with regex start/end to enforce exact match + .map(|(k, v)| (format!("^{k}$"), v)) + .collect(), ..Default::default() })), ..Default::default() @@ -620,21 +620,27 @@ impl JsonSchema for ApolloPlugins { stringify!(Plugins).to_string() } - fn json_schema(gen: &mut SchemaGenerator) -> Schema { + fn json_schema(generator: &mut SchemaGenerator) -> Schema { // This is a manual implementation of Plugins schema to allow plugins that have been registered at // compile time to be picked up. - let plugins = crate::plugin::plugins() + let (plugin_entries, hidden_plugin_entries): (Vec<_>, Vec<_>) = crate::plugin::plugins() .sorted_by_key(|factory| factory.name.clone()) .filter(|factory| factory.name.starts_with(APOLLO_PLUGIN_PREFIX)) - .map(|factory| { - ( - factory.name[APOLLO_PLUGIN_PREFIX.len()..].to_string(), - factory.create_schema(gen), - ) - }) - .collect::>(); - gen_schema(plugins) + .partition_map(|factory| { + let key = factory.name[APOLLO_PLUGIN_PREFIX.len()..].to_string(); + let schema = factory.create_schema(generator); + // Separate any plugins we're hiding + if factory.hidden_from_config_json_schema { + Either::Right((key, schema)) + } else { + Either::Left((key, schema)) + } + }); + gen_schema( + plugin_entries.into_iter().collect(), + Some(hidden_plugin_entries.into_iter().collect()), + ) } } @@ -653,16 +659,16 @@ impl JsonSchema for UserPlugins { stringify!(Plugins).to_string() } - fn json_schema(gen: &mut SchemaGenerator) -> Schema { + fn json_schema(generator: &mut SchemaGenerator) -> Schema { // This is a manual implementation of Plugins schema to allow plugins that have been registered at // compile time to be picked up. let plugins = crate::plugin::plugins() .sorted_by_key(|factory| factory.name.clone()) .filter(|factory| !factory.name.starts_with(APOLLO_PLUGIN_PREFIX)) - .map(|factory| (factory.name.to_string(), factory.create_schema(gen))) + .map(|factory| (factory.name.to_string(), factory.create_schema(generator))) .collect::>(); - gen_schema(plugins) + gen_schema(plugins, None) } } @@ -675,6 +681,11 @@ pub(crate) struct Supergraph { /// Defaults to 127.0.0.1:4000 pub(crate) listen: ListenAddr, + /// The timeout for shutting down connections during a router shutdown or a schema reload. + #[serde(deserialize_with = "humantime_serde::deserialize")] + #[schemars(with = "String", default = "default_connection_shutdown_timeout")] + pub(crate) connection_shutdown_timeout: Duration, + /// The HTTP path on which GraphQL requests will be served. /// default: "/" pub(crate) path: String, @@ -683,11 +694,6 @@ pub(crate) struct Supergraph { /// Default: false pub(crate) introspection: bool, - /// Enable reuse of query fragments - /// Default: depends on the federation version - #[serde(rename = "experimental_reuse_query_fragments")] - pub(crate) reuse_query_fragments: Option, - /// Enable QP generation of fragments for subgraph requests /// Default: true pub(crate) generate_query_fragments: bool, @@ -713,25 +719,12 @@ const fn default_generate_query_fragments() -> bool { true } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case", untagged)] -pub(crate) enum AvailableParallelism { - Auto(Auto), - Fixed(NonZeroUsize), -} - #[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub(crate) enum Auto { Auto, } -impl Default for AvailableParallelism { - fn default() -> Self { - Self::Fixed(NonZeroUsize::new(1).expect("cannot fail")) - } -} - fn default_defer_support() -> bool { true } @@ -742,10 +735,10 @@ impl Supergraph { pub(crate) fn new( listen: Option, path: Option, + connection_shutdown_timeout: Option, introspection: Option, defer_support: Option, query_planning: Option, - reuse_query_fragments: Option, generate_query_fragments: Option, early_cancel: Option, experimental_log_on_broken_pipe: Option, @@ -753,19 +746,13 @@ impl Supergraph { Self { listen: listen.unwrap_or_else(default_graphql_listen), path: path.unwrap_or_else(default_graphql_path), + connection_shutdown_timeout: connection_shutdown_timeout + .unwrap_or_else(default_connection_shutdown_timeout), introspection: introspection.unwrap_or_else(default_graphql_introspection), defer_support: defer_support.unwrap_or_else(default_defer_support), query_planning: query_planning.unwrap_or_default(), - reuse_query_fragments: generate_query_fragments.and_then(|v| - if v { - if reuse_query_fragments.is_some_and(|v| v) { - // warn the user that both are enabled and it's overridden - tracing::warn!("Both 'generate_query_fragments' and 'experimental_reuse_query_fragments' are explicitly enabled, 'experimental_reuse_query_fragments' will be overridden to false"); - } - Some(false) - } else { reuse_query_fragments } - ), - generate_query_fragments: generate_query_fragments.unwrap_or_else(default_generate_query_fragments), + generate_query_fragments: generate_query_fragments + .unwrap_or_else(default_generate_query_fragments), early_cancel: early_cancel.unwrap_or_default(), experimental_log_on_broken_pipe: experimental_log_on_broken_pipe.unwrap_or_default(), } @@ -779,10 +766,10 @@ impl Supergraph { pub(crate) fn fake_new( listen: Option, path: Option, + connection_shutdown_timeout: Option, introspection: Option, defer_support: Option, query_planning: Option, - reuse_query_fragments: Option, generate_query_fragments: Option, early_cancel: Option, experimental_log_on_broken_pipe: Option, @@ -790,19 +777,13 @@ impl Supergraph { Self { listen: listen.unwrap_or_else(test_listen), path: path.unwrap_or_else(default_graphql_path), + connection_shutdown_timeout: connection_shutdown_timeout + .unwrap_or_else(default_connection_shutdown_timeout), introspection: introspection.unwrap_or_else(default_graphql_introspection), defer_support: defer_support.unwrap_or_else(default_defer_support), query_planning: query_planning.unwrap_or_default(), - reuse_query_fragments: generate_query_fragments.and_then(|v| - if v { - if reuse_query_fragments.is_some_and(|v| v) { - // warn the user that both are enabled and it's overridden - tracing::warn!("Both 'generate_query_fragments' and 'experimental_reuse_query_fragments' are explicitly enabled, 'experimental_reuse_query_fragments' will be overridden to false"); - } - Some(false) - } else { reuse_query_fragments } - ), - generate_query_fragments: generate_query_fragments.unwrap_or_else(default_generate_query_fragments), + generate_query_fragments: generate_query_fragments + .unwrap_or_else(default_generate_query_fragments), early_cancel: early_cancel.unwrap_or_default(), experimental_log_on_broken_pipe: experimental_log_on_broken_pipe.unwrap_or_default(), } @@ -824,7 +805,7 @@ impl Supergraph { path = format!("{}router_extra_path", self.path); } else if SUPERGRAPH_ENDPOINT_REGEX.is_match(&self.path) { let new_path = SUPERGRAPH_ENDPOINT_REGEX - .replace(&self.path, "${first_path}${sub_path}:supergraph_route"); + .replace(&self.path, "${first_path}${sub_path}{supergraph_route}"); path = new_path.to_string(); } @@ -925,16 +906,32 @@ pub(crate) struct QueryPlanning { /// the old schema, if it determines that the schema update does not affect the corresponding query pub(crate) experimental_reuse_query_plans: bool, - /// Set the size of a pool of workers to enable query planning parallelism. - /// Default: 1. - pub(crate) experimental_parallelism: AvailableParallelism, + /// Configures cooperative cancellation of query planning + /// + /// See [`CooperativeCancellation`] for more details. + pub(crate) experimental_cooperative_cancellation: CooperativeCancellation, } +#[buildstructor::buildstructor] impl QueryPlanning { - pub(crate) fn experimental_query_planner_parallelism(&self) -> io::Result { - match self.experimental_parallelism { - AvailableParallelism::Auto(Auto::Auto) => std::thread::available_parallelism(), - AvailableParallelism::Fixed(n) => Ok(n), + #[builder] + #[allow(dead_code)] + pub(crate) fn new( + cache: Option, + warmed_up_queries: Option, + experimental_plans_limit: Option, + experimental_paths_limit: Option, + experimental_reuse_query_plans: Option, + experimental_cooperative_cancellation: Option, + ) -> Self { + Self { + cache: cache.unwrap_or_default(), + warmed_up_queries, + experimental_plans_limit, + experimental_paths_limit, + experimental_reuse_query_plans: experimental_reuse_query_plans.unwrap_or_default(), + experimental_cooperative_cancellation: experimental_cooperative_cancellation + .unwrap_or_default(), } } } @@ -1078,6 +1075,13 @@ pub(crate) struct RedisCache { #[serde(default = "default_pool_size")] /// The size of the Redis connection pool pub(crate) pool_size: u32, + #[serde( + deserialize_with = "humantime_serde::deserialize", + default = "default_metrics_interval" + )] + #[schemars(with = "Option", default)] + /// Interval for collecting Redis metrics (default: 1s) + pub(crate) metrics_interval: Duration, } fn default_required_to_start() -> bool { @@ -1088,6 +1092,10 @@ fn default_pool_size() -> u32 { 1 } +pub(crate) fn default_metrics_interval() -> Duration { + Duration::from_secs(1) +} + impl From for RedisCache { fn from(value: QueryPlanRedisCache) -> Self { RedisCache { @@ -1101,6 +1109,7 @@ impl From for RedisCache { required_to_start: value.required_to_start, reset_ttl: value.reset_ttl, pool_size: value.pool_size, + metrics_interval: default_metrics_interval(), } } } @@ -1117,26 +1126,27 @@ pub(crate) struct Tls { /// TLS server configuration /// /// this will affect the GraphQL endpoint and any other endpoint targeting the same listen address - pub(crate) supergraph: Option, + pub(crate) supergraph: Option>, pub(crate) subgraph: SubgraphConfiguration, + pub(crate) connector: ConnectorConfiguration, } /// Configuration options pertaining to the supergraph server component. -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct TlsSupergraph { /// server certificate in PEM format #[serde(deserialize_with = "deserialize_certificate", skip_serializing)] #[schemars(with = "String")] - pub(crate) certificate: Certificate, + pub(crate) certificate: CertificateDer<'static>, /// server key in PEM format #[serde(deserialize_with = "deserialize_key", skip_serializing)] #[schemars(with = "String")] - pub(crate) key: PrivateKey, + pub(crate) key: PrivateKeyDer<'static>, /// list of certificate authorities in PEM format #[serde(deserialize_with = "deserialize_certificate_chain", skip_serializing)] #[schemars(with = "String")] - pub(crate) certificate_chain: Vec, + pub(crate) certificate_chain: Vec>, } impl TlsSupergraph { @@ -1145,9 +1155,8 @@ impl TlsSupergraph { certificates.extend(self.certificate_chain.iter().cloned()); let mut config = ServerConfig::builder() - .with_safe_defaults() .with_no_client_auth() - .with_single_cert(certificates, self.key.clone()) + .with_single_cert(certificates, self.key.clone_key()) .map_err(ApolloRouterError::Rustls)?; config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; @@ -1155,7 +1164,7 @@ impl TlsSupergraph { } } -fn deserialize_certificate<'de, D>(deserializer: D) -> Result +fn deserialize_certificate<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { @@ -1174,7 +1183,9 @@ where }) } -fn deserialize_certificate_chain<'de, D>(deserializer: D) -> Result, D::Error> +fn deserialize_certificate_chain<'de, D>( + deserializer: D, +) -> Result>, D::Error> where D: Deserializer<'de>, { @@ -1183,7 +1194,7 @@ where load_certs(&data).map_err(serde::de::Error::custom) } -fn deserialize_key<'de, D>(deserializer: D) -> Result +fn deserialize_key<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { @@ -1192,37 +1203,41 @@ where load_key(&data).map_err(serde::de::Error::custom) } -pub(crate) fn load_certs(data: &str) -> io::Result> { - certs(&mut BufReader::new(data.as_bytes())) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid cert")) - .map(|mut certs| certs.drain(..).map(Certificate).collect()) +#[derive(thiserror::Error, Debug)] +#[error("could not load TLS certificate: {0}")] +struct LoadCertError(std::io::Error); + +pub(crate) fn load_certs(data: &str) -> io::Result>> { + rustls_pemfile::certs(&mut BufReader::new(data.as_bytes())) + .collect::, _>>() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, LoadCertError(error))) } -pub(crate) fn load_key(data: &str) -> io::Result { +pub(crate) fn load_key(data: &str) -> io::Result> { let mut reader = BufReader::new(data.as_bytes()); - let mut key_iterator = iter::from_fn(|| read_one(&mut reader).transpose()); + let mut key_iterator = iter::from_fn(|| rustls_pemfile::read_one(&mut reader).transpose()); let private_key = match key_iterator.next() { - Some(Ok(Item::RSAKey(key))) => PrivateKey(key), - Some(Ok(Item::PKCS8Key(key))) => PrivateKey(key), - Some(Ok(Item::ECKey(key))) => PrivateKey(key), + Some(Ok(rustls_pemfile::Item::Pkcs1Key(key))) => PrivateKeyDer::from(key), + Some(Ok(rustls_pemfile::Item::Pkcs8Key(key))) => PrivateKeyDer::from(key), + Some(Ok(rustls_pemfile::Item::Sec1Key(key))) => PrivateKeyDer::from(key), Some(Err(e)) => { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!("could not parse the key: {e}"), - )) + )); } Some(_) => { return Err(io::Error::new( io::ErrorKind::InvalidInput, "expected a private key", - )) + )); } None => { return Err(io::Error::new( io::ErrorKind::InvalidInput, "could not find a private key", - )) + )); } }; @@ -1243,7 +1258,7 @@ pub(crate) struct TlsClient { /// list of certificate authorities in PEM format pub(crate) certificate_authorities: Option, /// client certificate authentication - pub(crate) client_authentication: Option, + pub(crate) client_authentication: Option>, } #[buildstructor::buildstructor] @@ -1251,7 +1266,7 @@ impl TlsClient { #[builder] pub(crate) fn new( certificate_authorities: Option, - client_authentication: Option, + client_authentication: Option>, ) -> Self { Self { certificate_authorities, @@ -1267,17 +1282,17 @@ impl Default for TlsClient { } /// TLS client authentication -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct TlsClientAuth { /// list of certificates in PEM format #[serde(deserialize_with = "deserialize_certificate_chain", skip_serializing)] #[schemars(with = "String")] - pub(crate) certificate_chain: Vec, + pub(crate) certificate_chain: Vec>, /// key in PEM format #[serde(deserialize_with = "deserialize_key", skip_serializing)] #[schemars(with = "String")] - pub(crate) key: PrivateKey, + pub(crate) key: PrivateKeyDer<'static>, } /// Configuration options pertaining to the sandbox page. @@ -1365,97 +1380,6 @@ impl Default for Homepage { } } -/// Configuration options pertaining to the http server component. -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -#[serde(default)] -pub(crate) struct HealthCheck { - /// The socket address and port to listen on - /// Defaults to 127.0.0.1:8088 - pub(crate) listen: ListenAddr, - - /// Set to false to disable the health check - pub(crate) enabled: bool, - - /// Optionally set a custom healthcheck path - /// Defaults to /health - pub(crate) path: String, -} - -fn default_health_check_listen() -> ListenAddr { - SocketAddr::from_str("127.0.0.1:8088").unwrap().into() -} - -fn default_health_check_enabled() -> bool { - true -} - -fn default_health_check_path() -> String { - "/health".to_string() -} - -#[buildstructor::buildstructor] -impl HealthCheck { - #[builder] - pub(crate) fn new( - listen: Option, - enabled: Option, - path: Option, - ) -> Self { - let mut path = path.unwrap_or_else(default_health_check_path); - if !path.starts_with('/') { - path = format!("/{path}").to_string(); - } - - Self { - listen: listen.unwrap_or_else(default_health_check_listen), - enabled: enabled.unwrap_or_else(default_health_check_enabled), - path, - } - } -} - -#[cfg(test)] -#[buildstructor::buildstructor] -impl HealthCheck { - #[builder] - pub(crate) fn fake_new( - listen: Option, - enabled: Option, - path: Option, - ) -> Self { - let mut path = path.unwrap_or_else(default_health_check_path); - if !path.starts_with('/') { - path = format!("/{path}"); - } - - Self { - listen: listen.unwrap_or_else(test_listen), - enabled: enabled.unwrap_or_else(default_health_check_enabled), - path, - } - } -} - -impl Default for HealthCheck { - fn default() -> Self { - Self::builder().build() - } -} - -/// Configuration for chaos testing, trying to reproduce bugs that require uncommon conditions. -/// You probably don’t want this in production! -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -#[serde(default)] -pub(crate) struct Chaos { - /// Force a hot reload of the Router (as if the schema or configuration had changed) - /// at a regular time interval. - #[serde(with = "humantime_serde")] - #[schemars(with = "Option")] - pub(crate) force_reload: Option, -} - /// Listening address. #[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)] #[serde(untagged)] @@ -1538,6 +1462,10 @@ fn default_graphql_introspection() -> bool { false } +fn default_connection_shutdown_timeout() -> Duration { + Duration::from_secs(60) +} + #[derive(Clone, Debug, Default, Error, Display, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields, rename_all = "snake_case")] pub(crate) enum BatchingMode { @@ -1559,6 +1487,10 @@ pub(crate) struct Batching { /// Subgraph options for batching pub(crate) subgraph: Option>, + + /// Maximum size for a batch + #[serde(default)] + pub(crate) maximum_size: Option, } /// Common options for configuring subgraph batching @@ -1581,7 +1513,7 @@ impl Batching { subgraph_batching_config .subgraphs .get(service_name) - .map_or(true, |x| x.enabled) + .is_none_or(|x| x.enabled) } else { // If it isn't, require: // - an enabled subgraph entry @@ -1594,4 +1526,11 @@ impl Batching { None => false, } } + + pub(crate) fn exceeds_batch_size(&self, batch: &[T]) -> bool { + match self.maximum_size { + Some(maximum_size) => batch.len() > maximum_size, + None => false, + } + } } diff --git a/apollo-router/src/configuration/mode.rs b/apollo-router/src/configuration/mode.rs new file mode 100644 index 0000000000..1257b07cb2 --- /dev/null +++ b/apollo-router/src/configuration/mode.rs @@ -0,0 +1,24 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +// Don't add a default here. Instead, Default should be implemented for +// individual cases of Mode. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Mode { + Measure, + Enforce, +} + +impl Mode { + /// Returns true if this config is in measure mode. + pub(crate) fn is_measure_mode(&self) -> bool { + matches!(self, Mode::Measure) + } + + /// Returns true if this config is in enforce mode. + pub(crate) fn is_enforce_mode(&self) -> bool { + matches!(self, Mode::Enforce) + } +} diff --git a/apollo-router/src/configuration/persisted_queries.rs b/apollo-router/src/configuration/persisted_queries.rs index 2b395b79ff..1be3aacb3c 100644 --- a/apollo-router/src/configuration/persisted_queries.rs +++ b/apollo-router/src/configuration/persisted_queries.rs @@ -19,7 +19,11 @@ pub struct PersistedQueries { pub experimental_prewarm_query_plan_cache: PersistedQueriesPrewarmQueryPlanCache, /// Enables using a local copy of the persisted query manifest to safelist operations - pub experimental_local_manifests: Option>, + #[serde(alias = "experimental_local_manifests")] + pub local_manifests: Option>, + + /// Enables hot reloading of the local persisted query manifests + pub hot_reload: bool, } #[cfg(test)] @@ -30,16 +34,18 @@ impl PersistedQueries { enabled: Option, log_unknown: Option, safelist: Option, + local_manifests: Option>, + hot_reload: Option, experimental_prewarm_query_plan_cache: Option, - experimental_local_manifests: Option>, ) -> Self { Self { enabled: enabled.unwrap_or_else(default_pq), safelist: safelist.unwrap_or_default(), log_unknown: log_unknown.unwrap_or_else(default_log_unknown), + local_manifests, experimental_prewarm_query_plan_cache: experimental_prewarm_query_plan_cache .unwrap_or_default(), - experimental_local_manifests, + hot_reload: hot_reload.unwrap_or_default(), } } } @@ -84,8 +90,9 @@ impl Default for PersistedQueries { enabled: default_pq(), safelist: PersistedQueriesSafelist::default(), log_unknown: default_log_unknown(), + local_manifests: None, + hot_reload: false, experimental_prewarm_query_plan_cache: PersistedQueriesPrewarmQueryPlanCache::default(), - experimental_local_manifests: None, } } } diff --git a/apollo-router/src/configuration/schema.rs b/apollo-router/src/configuration/schema.rs index 4d05b786ef..3e80da1494 100644 --- a/apollo-router/src/configuration/schema.rs +++ b/apollo-router/src/configuration/schema.rs @@ -7,27 +7,28 @@ use std::mem; use std::sync::OnceLock; use itertools::Itertools; -use jsonschema::error::ValidationErrorKind; use jsonschema::Draft; use jsonschema::JSONSchema; -use schemars::gen::SchemaSettings; +use jsonschema::error::ValidationErrorKind; +use schemars::r#gen::SchemaSettings; use schemars::schema::Metadata; use schemars::schema::RootSchema; use schemars::schema::SchemaObject; +use schemars::visit::Visitor; use schemars::visit::visit_root_schema; use schemars::visit::visit_schema_object; -use schemars::visit::Visitor; use yaml_rust::scanner::Marker; -use super::expansion::coerce; +use super::APOLLO_PLUGIN_PREFIX; +use super::Configuration; +use super::ConfigurationError; use super::expansion::Expansion; +use super::expansion::coerce; use super::plugins; use super::yaml; -use super::Configuration; -use super::ConfigurationError; -use super::APOLLO_PLUGIN_PREFIX; +use crate::configuration::upgrade::UpgradeMode; pub(crate) use crate::configuration::upgrade::generate_upgrade; -pub(crate) use crate::configuration::upgrade::upgrade_configuration; +use crate::configuration::upgrade::upgrade_configuration; const NUMBER_OF_PREVIOUS_LINES_TO_DISPLAY: usize = 5; @@ -69,8 +70,8 @@ pub(crate) fn generate_config_schema() -> RootSchema { // Manually patch up the schema // We don't want to allow unknown fields, but serde doesn't work if we put the annotation on Configuration as the struct has a flattened type. // It's fine to just add it here. - let gen = settings.into_generator(); - let mut schema = gen.into_root_schema_for::(); + let generator = settings.into_generator(); + let mut schema = generator.into_root_schema_for::(); let root = schema.schema.object.as_mut().expect("schema not generated"); root.additional_properties = Some(Box::new(schemars::schema::Schema::Bool(false))); schema @@ -79,9 +80,6 @@ pub(crate) fn generate_config_schema() -> RootSchema { #[derive(Eq, PartialEq)] pub(crate) enum Mode { Upgrade, - - // This is used only in testing to ensure that we don't allow old config in our tests. - #[cfg(test)] NoUpgrade, } @@ -128,18 +126,20 @@ pub(crate) fn validate_yaml_configuration( match result { Ok(schema) => schema, Err(e) => { - panic!("failed to compile configuration schema: {}", e) + panic!("failed to compile configuration schema: {e}") } } }); if migration == Mode::Upgrade { - let upgraded = upgrade_configuration(&yaml, true)?; + let upgraded = upgrade_configuration(&yaml, true, UpgradeMode::Minor)?; let expanded_yaml = expansion.expand(&upgraded)?; if schema.validate(&expanded_yaml).is_ok() { yaml = upgraded; } else { - tracing::warn!("configuration could not be upgraded automatically as it had errors") + tracing::warn!( + "Configuration could not be upgraded automatically as it had errors. If you previously used this configuration with Router 1.x, please refer to the migration guide: https://www.apollographql.com/docs/graphos/reference/migration/from-router-v1" + ) } } @@ -265,6 +265,9 @@ pub(crate) fn validate_yaml_configuration( } if !errors.is_empty() { + tracing::warn!( + "Configuration had errors. It may be possible to update your configuration automatically. Execute 'router config upgrade --help' for more details. If you previously used this configuration with Router 1.x, please refer to the upgrade guide: https://www.apollographql.com/docs/graphos/reference/upgrade/from-router-v1" + ); return Err(ConfigurationError::InvalidConfiguration { message: "configuration had errors", error: format!("\n{errors}"), @@ -296,6 +299,9 @@ pub(crate) fn validate_yaml_configuration( // It might mean you forgot to update // `impl<'de> serde::Deserialize<'de> for Configuration // In `/apollo-router/src/configuration/mod.rs` + tracing::warn!( + "Configuration had errors. It may be possible to update your configuration automatically. Execute 'router config upgrade --help' for more details. If you previously used this configuration with Router 1.x, please refer to the upgrade guide: https://www.apollographql.com/docs/graphos/reference/upgrade/from-router-v1" + ); return Err(ConfigurationError::InvalidConfiguration { message: "unknown fields", error: format!( diff --git a/apollo-router/src/configuration/server.rs b/apollo-router/src/configuration/server.rs new file mode 100644 index 0000000000..a2ed812671 --- /dev/null +++ b/apollo-router/src/configuration/server.rs @@ -0,0 +1,118 @@ +use std::time::Duration; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +const DEFAULT_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(10); + +fn default_header_read_timeout() -> Duration { + DEFAULT_HEADER_READ_TIMEOUT +} + +/// Configuration for HTTP +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct ServerHttpConfig { + /// Header read timeout in human-readable format; defaults to 10s + #[serde( + deserialize_with = "humantime_serde::deserialize", + default = "default_header_read_timeout" + )] + #[schemars(with = "String", default = "default_header_read_timeout")] + pub(crate) header_read_timeout: Duration, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct Server { + /// The server http configuration + pub(crate) http: ServerHttpConfig, +} + +impl Default for ServerHttpConfig { + fn default() -> Self { + Self { + header_read_timeout: Duration::from_secs(10), + } + } +} + +#[buildstructor::buildstructor] +impl Server { + #[builder] + pub(crate) fn new(http: Option) -> Self { + Self { + http: http.unwrap_or_default(), + } + } +} + +impl Default for Server { + fn default() -> Self { + Self::builder().build() + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn it_builds_default_server_configuration() { + let default_duration_seconds = Duration::from_secs(10); + let server_config = Server::builder().build(); + assert_eq!( + server_config.http.header_read_timeout, + default_duration_seconds + ); + } + + #[test] + fn it_json_parses_default_header_read_timeout_when_server_http_config_omitted() { + let json_server = json!({}); + + let config: Server = serde_json::from_value(json_server).unwrap(); + + assert_eq!(config.http.header_read_timeout, Duration::from_secs(10)); + } + + #[test] + fn it_json_parses_default_header_read_timeout_when_omitted() { + let json_config = json!({ + "http": {} + }); + + let config: Server = serde_json::from_value(json_config).unwrap(); + + assert_eq!(config.http.header_read_timeout, Duration::from_secs(10)); + } + + #[test] + fn it_json_parses_specified_server_config_seconds_correctly() { + let json_config = json!({ + "http": { + "header_read_timeout": "30s" + } + }); + + let config: Server = serde_json::from_value(json_config).unwrap(); + + assert_eq!(config.http.header_read_timeout, Duration::from_secs(30)); + } + + #[test] + fn it_json_parses_specified_server_config_minutes_correctly() { + let json_config = json!({ + "http": { + "header_read_timeout": "1m" + } + }); + + let config: Server = serde_json::from_value(json_config).unwrap(); + + assert_eq!(config.http.header_read_timeout, Duration::from_secs(60)); + } +} diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__expansion__test__dev_mode.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__expansion__test__dev_mode.snap index 6839dcb274..8f7ba2ae23 100644 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__expansion__test__dev_mode.snap +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__expansion__test__dev_mode.snap @@ -2,6 +2,8 @@ source: apollo-router/src/configuration/expansion.rs expression: value --- +connectors: + debug_extensions: true homepage: enabled: false some_other_config: should remain @@ -18,4 +20,3 @@ telemetry: tracing: experimental_response_trace_id: enabled: true - diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__env_metrics.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__env_metrics.snap index ff27bcc591..fb5c119324 100644 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__env_metrics.snap +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__env_metrics.snap @@ -1,6 +1,7 @@ --- source: apollo-router/src/configuration/metrics.rs -expression: "&metrics.non_zero()" +expression: "& metrics.non_zero()" +snapshot_kind: text --- - name: apollo.router.config.env data: @@ -14,4 +15,5 @@ expression: "&metrics.non_zero()" opt.apollo.license.path: true opt.apollo.supergraph.path: true opt.apollo.supergraph.urls: true - + opt.security.non_local_selections: true + opt.security.recursive_selections: true diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__experimental_mode_metrics.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__experimental_mode_metrics.snap deleted file mode 100644 index 513298fd51..0000000000 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__experimental_mode_metrics.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: apollo-router/src/configuration/metrics.rs -expression: "&metrics.non_zero()" ---- -- name: apollo.router.config.experimental_query_planner_mode - data: - datapoints: - - value: 1 - attributes: - mode: both diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__experimental_mode_metrics_2.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__experimental_mode_metrics_2.snap deleted file mode 100644 index 43cb1b8568..0000000000 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__experimental_mode_metrics_2.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: apollo-router/src/configuration/metrics.rs -expression: "&metrics.non_zero()" ---- -- name: apollo.router.config.experimental_query_planner_mode - data: - datapoints: - - value: 1 - attributes: - mode: both_best_effort diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__experimental_mode_metrics_3.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__experimental_mode_metrics_3.snap deleted file mode 100644 index 34d0b7fe07..0000000000 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__experimental_mode_metrics_3.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: apollo-router/src/configuration/metrics.rs -expression: "&metrics.non_zero()" ---- -- name: apollo.router.config.experimental_query_planner_mode - data: - datapoints: - - value: 1 - attributes: - mode: new diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@connectors.router.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@connectors.router.yaml.snap new file mode 100644 index 0000000000..9b24638487 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@connectors.router.yaml.snap @@ -0,0 +1,15 @@ +--- +source: apollo-router/src/configuration/metrics.rs +expression: "&metrics.non_zero()" +--- +- name: apollo.router.config.connectors + data: + datapoints: + - value: 1 + attributes: + opt.debug_extensions: true + opt.expose_sources_in_context: true + opt.max_requests_per_operation_per_source: true + opt.source.max_requests_per_operation: true + opt.source.override_url: true + opt.subgraph.config: true diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@entities.router.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@entities.router.yaml.snap index 9b586a2b76..1285a6de53 100644 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@entities.router.yaml.snap +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@entities.router.yaml.snap @@ -9,4 +9,5 @@ expression: "&metrics.non_zero()" attributes: opt.enabled: true opt.subgraph.enabled: true + opt.subgraph.invalidation.enabled: true opt.subgraph.ttl: true diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@response_cache.router.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@response_cache.router.yaml.snap new file mode 100644 index 0000000000..4a4deb211c --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@response_cache.router.yaml.snap @@ -0,0 +1,16 @@ +--- +source: apollo-router/src/configuration/metrics.rs +expression: "& metrics.non_zero()" +--- +- name: apollo.router.config.response_cache + data: + datapoints: + - value: 1 + attributes: + opt.debug: true + opt.enabled: true + opt.subgraph.enabled: true + opt.subgraph.invalidation.enabled: true + opt.subgraph.postgres.cleanup_interval: true + opt.subgraph.postgres.required_to_start: true + opt.subgraph.ttl: true diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@telemetry.router.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@telemetry.router.yaml.snap index 50a0d132d5..8318bb9894 100644 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@telemetry.router.yaml.snap +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@telemetry.router.yaml.snap @@ -1,6 +1,6 @@ --- source: apollo-router/src/configuration/metrics.rs -expression: "&metrics.non_zero()" +expression: "& metrics.non_zero()" --- - name: apollo.router.config.telemetry data: @@ -8,16 +8,17 @@ expression: "&metrics.non_zero()" - value: 1 attributes: opt.events: true + opt.events.connector: true opt.events.router: true opt.events.subgraph: true opt.events.supergraph: true opt.instruments: true + opt.instruments.connector: true opt.instruments.default_attribute_requirement_level: false opt.instruments.graphql: true opt.instruments.router: true opt.instruments.subgraph: true opt.instruments.supergraph: true - opt.logging.experimental_when_header: true opt.metrics.otlp: true opt.metrics.prometheus: true opt.spans: true @@ -27,6 +28,5 @@ expression: "&metrics.non_zero()" opt.spans.subgraph: true opt.spans.supergraph: true opt.tracing.datadog: true - opt.tracing.jaeger: true opt.tracing.otlp: true opt.tracing.zipkin: true diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@traffic_shaping.router.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@traffic_shaping.router.yaml.snap index 53cca422bf..d4f09abedc 100644 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@traffic_shaping.router.yaml.snap +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__metrics__test__metrics@traffic_shaping.router.yaml.snap @@ -1,6 +1,6 @@ --- source: apollo-router/src/configuration/metrics.rs -expression: "&metrics.non_zero()" +expression: "& metrics.non_zero()" --- - name: apollo.router.config.traffic_shaping data: @@ -13,5 +13,4 @@ expression: "&metrics.non_zero()" opt.subgraph.deduplicate_query: true opt.subgraph.http2: true opt.subgraph.rate_limit: true - opt.subgraph.retry: true opt.subgraph.timeout: true diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap index 3616c90414..5aa80ac60c 100644 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap @@ -98,16 +98,6 @@ snapshot_kind: text }, "type": "object" }, - "AgentConfig": { - "additionalProperties": false, - "properties": { - "endpoint": { - "$ref": "#/definitions/SocketEndpoint", - "description": "#/definitions/SocketEndpoint" - } - }, - "type": "object" - }, "All": { "enum": [ "all" @@ -256,41 +246,6 @@ snapshot_kind: text } ] }, - "AttributesForwardConf": { - "additionalProperties": false, - "description": "Configuration to add custom attributes/labels on metrics to subgraphs", - "properties": { - "context": { - "description": "Configuration to forward values from the context to custom attributes/labels in metrics", - "items": { - "$ref": "#/definitions/ContextForward", - "description": "#/definitions/ContextForward" - }, - "type": "array" - }, - "errors": { - "$ref": "#/definitions/ErrorsForward", - "description": "#/definitions/ErrorsForward" - }, - "request": { - "$ref": "#/definitions/Forward", - "description": "#/definitions/Forward" - }, - "response": { - "$ref": "#/definitions/Forward", - "description": "#/definitions/Forward" - }, - "static": { - "description": "Configuration to insert custom attributes/labels in metrics", - "items": { - "$ref": "#/definitions/Insert2", - "description": "#/definitions/Insert2" - }, - "type": "array" - } - }, - "type": "object" - }, "AuthConfig": { "oneOf": [ { @@ -308,25 +263,6 @@ snapshot_kind: text } ] }, - "Auto": { - "enum": [ - "auto" - ], - "type": "string" - }, - "AvailableParallelism": { - "anyOf": [ - { - "$ref": "#/definitions/Auto", - "description": "#/definitions/Auto" - }, - { - "format": "uint", - "minimum": 1.0, - "type": "integer" - } - ] - }, "BatchProcessorConfig": { "description": "Batch processor configuration", "properties": { @@ -379,6 +315,14 @@ snapshot_kind: text "description": "Activates Batching (disabled by default)", "type": "boolean" }, + "maximum_size": { + "default": null, + "description": "Maximum size for a batch", + "format": "uint", + "minimum": 0.0, + "nullable": true, + "type": "integer" + }, "mode": { "$ref": "#/definitions/BatchingMode", "description": "#/definitions/BatchingMode" @@ -405,33 +349,9 @@ snapshot_kind: text } ] }, - "BodyForward": { - "additionalProperties": false, - "description": "Configuration to forward body values in metric attributes/labels", - "properties": { - "default": { - "$ref": "#/definitions/AttributeValue", - "description": "#/definitions/AttributeValue", - "nullable": true - }, - "name": { - "description": "The name of the attribute", - "type": "string" - }, - "path": { - "description": "The path in the body", - "type": "string" - } - }, - "required": [ - "name", - "path" - ], - "type": "object" - }, "CSRFConfig": { "additionalProperties": false, - "description": "CSRF Configuration.", + "description": "CSRF protection configuration.\n\nSee for an explanation on CSRF attacks.", "properties": { "required_headers": { "default": [ @@ -446,7 +366,7 @@ snapshot_kind: text }, "unsafe_disabled": { "default": false, - "description": "The CSRF plugin is enabled by default; set unsafe_disabled = true to disable the plugin behavior Note that setting this to true is deemed unsafe. See .", + "description": "The CSRF plugin is enabled by default.\n\nSetting `unsafe_disabled: true` *disables* CSRF protection.", "type": "boolean" } }, @@ -483,8 +403,8 @@ snapshot_kind: text "additionalProperties": false, "properties": { "apollo.router.operations.entity.cache": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" } }, "type": "object" @@ -534,19 +454,6 @@ snapshot_kind: text ], "type": "object" }, - "Chaos": { - "additionalProperties": false, - "description": "Configuration for chaos testing, trying to reproduce bugs that require uncommon conditions. You probably don’t want this in production!", - "properties": { - "force_reload": { - "default": null, - "description": "Force a hot reload of the Router (as if the schema or configuration had changed) at a regular time interval.", - "nullable": true, - "type": "string" - } - }, - "type": "object" - }, "Client": { "additionalProperties": false, "properties": { @@ -563,28 +470,6 @@ snapshot_kind: text }, "type": "object" }, - "CollectorConfig": { - "additionalProperties": false, - "properties": { - "endpoint": { - "$ref": "#/definitions/UriEndpoint", - "description": "#/definitions/UriEndpoint" - }, - "password": { - "default": null, - "description": "The optional password", - "nullable": true, - "type": "string" - }, - "username": { - "default": null, - "description": "The optional username", - "nullable": true, - "type": "string" - } - }, - "type": "object" - }, "CommonBatchingConfig": { "description": "Common options for configuring subgraph batching", "properties": { @@ -630,6 +515,143 @@ snapshot_kind: text } ] }, + "Condition_for_ConnectorSelector": { + "oneOf": [ + { + "additionalProperties": false, + "description": "A condition to check a selection against a value.", + "properties": { + "eq": { + "items": { + "$ref": "#/definitions/SelectorOrValue_for_ConnectorSelector", + "description": "#/definitions/SelectorOrValue_for_ConnectorSelector" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + } + }, + "required": [ + "eq" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "The first selection must be greater than the second selection.", + "properties": { + "gt": { + "items": { + "$ref": "#/definitions/SelectorOrValue_for_ConnectorSelector", + "description": "#/definitions/SelectorOrValue_for_ConnectorSelector" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + } + }, + "required": [ + "gt" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "The first selection must be less than the second selection.", + "properties": { + "lt": { + "items": { + "$ref": "#/definitions/SelectorOrValue_for_ConnectorSelector", + "description": "#/definitions/SelectorOrValue_for_ConnectorSelector" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + } + }, + "required": [ + "lt" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "A condition to check a selection against a selector.", + "properties": { + "exists": { + "$ref": "#/definitions/ConnectorSelector", + "description": "#/definitions/ConnectorSelector" + } + }, + "required": [ + "exists" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "All sub-conditions must be true.", + "properties": { + "all": { + "items": { + "$ref": "#/definitions/Condition_for_ConnectorSelector", + "description": "#/definitions/Condition_for_ConnectorSelector" + }, + "type": "array" + } + }, + "required": [ + "all" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "At least one sub-conditions must be true.", + "properties": { + "any": { + "items": { + "$ref": "#/definitions/Condition_for_ConnectorSelector", + "description": "#/definitions/Condition_for_ConnectorSelector" + }, + "type": "array" + } + }, + "required": [ + "any" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "The sub-condition must not be true", + "properties": { + "not": { + "$ref": "#/definitions/Condition_for_ConnectorSelector", + "description": "#/definitions/Condition_for_ConnectorSelector" + } + }, + "required": [ + "not" + ], + "type": "object" + }, + { + "description": "Static true condition", + "enum": [ + "true" + ], + "type": "string" + }, + { + "description": "Static false condition", + "enum": [ + "false" + ], + "type": "string" + } + ] + }, "Condition_for_GraphQLSelector": { "oneOf": [ { @@ -1195,14 +1217,19 @@ snapshot_kind: text "additionalProperties": false, "description": "Authentication", "properties": { + "connector": { + "$ref": "#/definitions/Config7", + "description": "#/definitions/Config7", + "nullable": true + }, "router": { "$ref": "#/definitions/RouterConf", "description": "#/definitions/RouterConf", "nullable": true }, "subgraph": { - "$ref": "#/definitions/Config4", - "description": "#/definitions/Config4", + "$ref": "#/definitions/Config6", + "description": "#/definitions/Config6", "nullable": true } }, @@ -1236,6 +1263,11 @@ snapshot_kind: text "$ref": "#/definitions/ExecutionStage", "description": "#/definitions/ExecutionStage" }, + "response_validation": { + "default": true, + "description": "Response validation defaults to true", + "type": "boolean" + }, "router": { "$ref": "#/definitions/RouterStage", "description": "#/definitions/RouterStage" @@ -1267,6 +1299,9 @@ snapshot_kind: text "type": "object" }, "Conf5": { + "type": "object" + }, + "Conf6": { "anyOf": [ { "additionalProperties": { @@ -1278,7 +1313,7 @@ snapshot_kind: text ], "description": "Subgraph URL mappings" }, - "Conf6": { + "Conf7": { "additionalProperties": false, "description": "Configuration for the Rhai Plugin", "properties": { @@ -1295,13 +1330,13 @@ snapshot_kind: text }, "type": "object" }, - "Conf7": { + "Conf8": { "additionalProperties": false, "description": "Telemetry configuration", "properties": { "apollo": { - "$ref": "#/definitions/Config9", - "description": "#/definitions/Config9" + "$ref": "#/definitions/Config14", + "description": "#/definitions/Config14" }, "exporters": { "$ref": "#/definitions/Exporters", @@ -1316,84 +1351,196 @@ snapshot_kind: text }, "Config": { "additionalProperties": false, - "description": "Configuration for operation limits, parser limits, HTTP limits, etc.", + "description": "Configuration options pertaining to the health component.", "properties": { - "http1_max_request_buf_size": { - "default": null, - "description": "Limit the maximum buffer size for the HTTP1 connection.\n\nDefault is ~400kib.", - "nullable": true, - "type": "string" + "enabled": { + "default": true, + "description": "Set to false to disable the health check", + "type": "boolean" }, - "http1_max_request_headers": { - "default": null, - "description": "Limit the maximum number of headers of incoming HTTP1 requests. Default is 100.\n\nIf router receives more headers than the buffer size, it responds to the client with \"431 Request Header Fields Too Large\".", - "format": "uint", - "minimum": 0.0, - "nullable": true, - "type": "integer" + "listen": { + "$ref": "#/definitions/ListenAddr", + "description": "#/definitions/ListenAddr" }, - "http_max_request_bytes": { - "default": 2000000, - "description": "Limit the size of incoming HTTP requests read from the network, to protect against running out of memory. Default: 2000000 (2 MB)", - "format": "uint", - "minimum": 0.0, - "type": "integer" + "path": { + "default": "/health", + "description": "Optionally set a custom healthcheck path Defaults to /health", + "type": "string" }, - "max_aliases": { - "default": null, - "description": "If set, requests with operations with more aliases than this maximum are rejected with a HTTP 400 Bad Request response and GraphQL error with `\"extensions\": {\"code\": \"MAX_ALIASES_LIMIT\"}`", - "format": "uint32", - "minimum": 0.0, - "nullable": true, - "type": "integer" + "readiness": { + "$ref": "#/definitions/ReadinessConfig", + "description": "#/definitions/ReadinessConfig" + } + }, + "type": "object" + }, + "Config10": { + "additionalProperties": false, + "description": "Configuration for header propagation", + "properties": { + "all": { + "$ref": "#/definitions/HeadersLocation", + "description": "#/definitions/HeadersLocation", + "nullable": true }, - "max_depth": { - "default": null, - "description": "If set, requests with operations deeper than this maximum are rejected with a HTTP 400 Bad Request response and GraphQL error with `\"extensions\": {\"code\": \"MAX_DEPTH_LIMIT\"}`\n\nCounts depth of an operation, looking at its selection sets,˛ including fields in fragments and inline fragments. The following example has a depth of 3.\n\n```graphql query getProduct { book { # 1 ...bookDetails } }\n\nfragment bookDetails on Book { details { # 2 ... on ProductDetailsBook { country # 3 } } } ```", - "format": "uint32", - "minimum": 0.0, - "nullable": true, + "connector": { + "$ref": "#/definitions/ConnectorHeadersConfiguration", + "description": "#/definitions/ConnectorHeadersConfiguration" + }, + "subgraphs": { + "additionalProperties": { + "$ref": "#/definitions/HeadersLocation", + "description": "#/definitions/HeadersLocation" + }, + "description": "Rules to specific subgraphs", + "type": "object" + } + }, + "type": "object" + }, + "Config11": { + "additionalProperties": false, + "description": "Configuration for exposing errors that originate from subgraphs", + "properties": { + "all": { + "$ref": "#/definitions/ErrorMode", + "description": "#/definitions/ErrorMode" + }, + "subgraphs": { + "additionalProperties": { + "$ref": "#/definitions/SubgraphConfig2", + "description": "#/definitions/SubgraphConfig2" + }, + "default": {}, + "description": "Overrides global configuration on a per-subgraph basis", + "type": "object" + } + }, + "type": "object" + }, + "Config12": { + "additionalProperties": false, + "description": "Configuration for entity caching", + "properties": { + "enabled": { + "default": false, + "description": "Enable or disable the entity caching feature", + "type": "boolean" + }, + "expose_keys_in_context": { + "default": false, + "description": "Expose cache keys in context", + "type": "boolean" + }, + "invalidation": { + "$ref": "#/definitions/InvalidationEndpointConfig2", + "description": "#/definitions/InvalidationEndpointConfig2", + "nullable": true + }, + "metrics": { + "$ref": "#/definitions/Metrics2", + "description": "#/definitions/Metrics2" + }, + "subgraph": { + "$ref": "#/definitions/SubgraphConfiguration_for_Subgraph2", + "description": "#/definitions/SubgraphConfiguration_for_Subgraph2" + } + }, + "required": [ + "subgraph" + ], + "type": "object" + }, + "Config13": { + "description": "Configuration for the progressive override plugin", + "type": "object" + }, + "Config14": { + "additionalProperties": false, + "properties": { + "batch_processor": { + "$ref": "#/definitions/BatchProcessorConfig", + "description": "#/definitions/BatchProcessorConfig" + }, + "buffer_size": { + "default": 10000, + "description": "The buffer size for sending traces to Apollo. Increase this if you are experiencing lost traces.", + "format": "uint", + "minimum": 1.0, "type": "integer" }, - "max_height": { - "default": null, - "description": "If set, requests with operations higher than this maximum are rejected with a HTTP 400 Bad Request response and GraphQL error with `\"extensions\": {\"code\": \"MAX_DEPTH_LIMIT\"}`\n\nHeight is based on simple merging of fields using the same name or alias, but only within the same selection set. For example `name` here is only counted once and the query has height 3, not 4:\n\n```graphql query { name { first } name { last } } ```\n\nThis may change in a future version of Apollo Router to do [full field merging across fragments][merging] instead.\n\n[merging]: https://spec.graphql.org/October2021/#sec-Field-Selection-Merging]", - "format": "uint32", - "minimum": 0.0, + "client_name_header": { + "default": "apollographql-client-name", + "description": "The name of the header to extract from requests when populating 'client name' for traces and metrics in Apollo Studio.", "nullable": true, - "type": "integer" + "type": "string" }, - "max_root_fields": { - "default": null, - "description": "If set, requests with operations with more root fields than this maximum are rejected with a HTTP 400 Bad Request response and GraphQL error with `\"extensions\": {\"code\": \"MAX_ROOT_FIELDS_LIMIT\"}`\n\nThis limit counts only the top level fields in a selection set, including fragments and inline fragments.", - "format": "uint32", - "minimum": 0.0, + "client_version_header": { + "default": "apollographql-client-version", + "description": "The name of the header to extract from requests when populating 'client version' for traces and metrics in Apollo Studio.", "nullable": true, - "type": "integer" + "type": "string" }, - "parser_max_recursion": { - "default": 500, - "description": "Limit recursion in the GraphQL parser to protect against stack overflow. default: 500", - "format": "uint", - "minimum": 0.0, - "type": "integer" + "endpoint": { + "default": "https://usage-reporting.api.apollographql.com/api/ingress/traces", + "description": "The Apollo Studio endpoint for exporting traces and metrics.", + "type": "string" }, - "parser_max_tokens": { - "default": 15000, - "description": "Limit the number of tokens the GraphQL parser processes before aborting.", - "format": "uint", - "minimum": 0.0, - "type": "integer" + "errors": { + "$ref": "#/definitions/ErrorsConfiguration", + "description": "#/definitions/ErrorsConfiguration" }, - "warn_only": { + "experimental_local_field_metrics": { "default": false, - "description": "If set to true (which is the default is dev mode), requests that exceed a `max_*` limit are *not* rejected. Instead they are executed normally, and a warning is logged.", + "description": "Enable field metrics that are generated without FTV1 to be sent to Apollo Studio.", + "type": "boolean" + }, + "experimental_otlp_endpoint": { + "default": "https://usage-reporting.api.apollographql.com/", + "description": "The Apollo Studio endpoint for exporting traces and metrics.", + "type": "string" + }, + "experimental_otlp_metrics_protocol": { + "$ref": "#/definitions/Protocol", + "description": "#/definitions/Protocol" + }, + "experimental_otlp_tracing_protocol": { + "$ref": "#/definitions/Protocol", + "description": "#/definitions/Protocol" + }, + "experimental_subgraph_metrics": { + "default": false, + "description": "Enable sending additional subgraph metrics to Apollo Studio via OTLP", "type": "boolean" + }, + "field_level_instrumentation_sampler": { + "$ref": "#/definitions/SamplerOption", + "description": "#/definitions/SamplerOption" + }, + "metrics_reference_mode": { + "$ref": "#/definitions/ApolloMetricsReferenceMode", + "description": "#/definitions/ApolloMetricsReferenceMode" + }, + "otlp_tracing_sampler": { + "$ref": "#/definitions/SamplerOption", + "description": "#/definitions/SamplerOption" + }, + "send_headers": { + "$ref": "#/definitions/ForwardHeaders", + "description": "#/definitions/ForwardHeaders" + }, + "send_variable_values": { + "$ref": "#/definitions/ForwardValues", + "description": "#/definitions/ForwardValues" + }, + "signature_normalization_algorithm": { + "$ref": "#/definitions/ApolloSignatureNormalizationAlgorithm", + "description": "#/definitions/ApolloSignatureNormalizationAlgorithm" } }, "type": "object" }, - "Config10": { + "Config15": { "additionalProperties": false, "properties": { "batch_processor": { @@ -1405,8 +1552,10 @@ snapshot_kind: text "type": "boolean" }, "endpoint": { - "$ref": "#/definitions/UriEndpoint", - "description": "#/definitions/UriEndpoint" + "default": null, + "description": "The endpoint to send data to", + "nullable": true, + "type": "string" }, "grpc": { "$ref": "#/definitions/GrpcExporter", @@ -1430,7 +1579,7 @@ snapshot_kind: text ], "type": "object" }, - "Config11": { + "Config16": { "additionalProperties": false, "description": "Prometheus configuration", "properties": { @@ -1447,57 +1596,15 @@ snapshot_kind: text "default": "/metrics", "description": "The path where prometheus will be exposed", "type": "string" + }, + "resource_selector": { + "$ref": "#/definitions/ResourceSelectorConfig", + "description": "#/definitions/ResourceSelectorConfig" } }, "type": "object" }, - "Config12": { - "anyOf": [ - { - "additionalProperties": false, - "properties": { - "agent": { - "$ref": "#/definitions/AgentConfig", - "description": "#/definitions/AgentConfig" - }, - "batch_processor": { - "$ref": "#/definitions/BatchProcessorConfig", - "description": "#/definitions/BatchProcessorConfig" - }, - "enabled": { - "description": "Enable Jaeger", - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "batch_processor": { - "$ref": "#/definitions/BatchProcessorConfig", - "description": "#/definitions/BatchProcessorConfig" - }, - "collector": { - "$ref": "#/definitions/CollectorConfig", - "description": "#/definitions/CollectorConfig" - }, - "enabled": { - "description": "Enable Jaeger", - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - } - ] - }, - "Config13": { + "Config17": { "additionalProperties": false, "properties": { "batch_processor": { @@ -1518,7 +1625,7 @@ snapshot_kind: text ], "type": "object" }, - "Config14": { + "Config18": { "additionalProperties": false, "properties": { "batch_processor": { @@ -1556,6 +1663,8 @@ snapshot_kind: text "type": "boolean" }, "default": { + "connect": true, + "connect_request": true, "execution": true, "http_request": true, "parse_query": true, @@ -1566,7 +1675,7 @@ snapshot_kind: text "subgraph_request": true, "supergraph": true }, - "description": "Which spans will be eligible for span stats to be collected for viewing in the APM view. Defaults to true for `request`, `router`, `query_parsing`, `supergraph`, `execution`, `query_planning`, `subgraph`, `subgraph_request` and `http_request`.", + "description": "Which spans will be eligible for span stats to be collected for viewing in the APM view. Defaults to true for `request`, `router`, `query_parsing`, `supergraph`, `execution`, `query_planning`, `subgraph`, `subgraph_request`, `connect`, `connect_request` and `http_request`.", "type": "object" } }, @@ -1575,7 +1684,7 @@ snapshot_kind: text ], "type": "object" }, - "Config15": { + "Config19": { "additionalProperties": false, "description": "Configuration for the experimental traffic shaping plugin", "properties": { @@ -1584,6 +1693,10 @@ snapshot_kind: text "description": "#/definitions/SubgraphShaping", "nullable": true }, + "connector": { + "$ref": "#/definitions/ConnectorsShapingConfig", + "description": "#/definitions/ConnectorsShapingConfig" + }, "deduplicate_variables": { "default": null, "description": "DEPRECATED, now always enabled: Enable variable deduplication optimization when sending requests to subgraphs (https://github.com/apollographql/router/issues/87)", @@ -1607,221 +1720,703 @@ snapshot_kind: text "type": "object" }, "Config2": { - "description": "This is a broken plugin for testing purposes only.", - "properties": { - "enabled": { - "description": "Enable the broken plugin.", - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, - "Config3": { - "description": "Restricted plugin (for testing purposes only)", - "properties": { - "enabled": { - "description": "Enable the restricted plugin (for testing purposes only)", - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, - "Config4": { "additionalProperties": false, - "description": "Configure subgraph authentication", + "description": "Configuration for operation limits, parser limits, HTTP limits, etc.", "properties": { - "all": { - "$ref": "#/definitions/AuthConfig", - "description": "#/definitions/AuthConfig", - "nullable": true - }, - "subgraphs": { + "http1_max_request_buf_size": { + "default": null, + "description": "Limit the maximum buffer size for the HTTP1 connection.\n\nDefault is ~400kib.", + "nullable": true, + "type": "string" + }, + "http1_max_request_headers": { + "default": null, + "description": "Limit the maximum number of headers of incoming HTTP1 requests. Default is 100.\n\nIf router receives more headers than the buffer size, it responds to the client with \"431 Request Header Fields Too Large\".", + "format": "uint", + "minimum": 0.0, + "nullable": true, + "type": "integer" + }, + "http_max_request_bytes": { + "default": 2000000, + "description": "Limit the size of incoming HTTP requests read from the network, to protect against running out of memory. Default: 2000000 (2 MB)", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "introspection_max_depth": { + "default": true, + "description": "Limit the depth of nested list fields in introspection queries to protect avoid generating huge responses. Returns a GraphQL error with `{ message: \"Maximum introspection depth exceeded\" }` when nested fields exceed the limit. Default: true", + "type": "boolean" + }, + "max_aliases": { + "default": null, + "description": "If set, requests with operations with more aliases than this maximum are rejected with a HTTP 400 Bad Request response and GraphQL error with `\"extensions\": {\"code\": \"MAX_ALIASES_LIMIT\"}`", + "format": "uint32", + "minimum": 0.0, + "nullable": true, + "type": "integer" + }, + "max_depth": { + "default": null, + "description": "If set, requests with operations deeper than this maximum are rejected with a HTTP 400 Bad Request response and GraphQL error with `\"extensions\": {\"code\": \"MAX_DEPTH_LIMIT\"}`\n\nCounts depth of an operation, looking at its selection sets,˛ including fields in fragments and inline fragments. The following example has a depth of 3.\n\n```graphql query getProduct { book { # 1 ...bookDetails } }\n\nfragment bookDetails on Book { details { # 2 ... on ProductDetailsBook { country # 3 } } } ```", + "format": "uint32", + "minimum": 0.0, + "nullable": true, + "type": "integer" + }, + "max_height": { + "default": null, + "description": "If set, requests with operations higher than this maximum are rejected with a HTTP 400 Bad Request response and GraphQL error with `\"extensions\": {\"code\": \"MAX_DEPTH_LIMIT\"}`\n\nHeight is based on simple merging of fields using the same name or alias, but only within the same selection set. For example `name` here is only counted once and the query has height 3, not 4:\n\n```graphql query { name { first } name { last } } ```\n\nThis may change in a future version of Apollo Router to do [full field merging across fragments][merging] instead.\n\n[merging]: https://spec.graphql.org/October2021/#sec-Field-Selection-Merging]", + "format": "uint32", + "minimum": 0.0, + "nullable": true, + "type": "integer" + }, + "max_root_fields": { + "default": null, + "description": "If set, requests with operations with more root fields than this maximum are rejected with a HTTP 400 Bad Request response and GraphQL error with `\"extensions\": {\"code\": \"MAX_ROOT_FIELDS_LIMIT\"}`\n\nThis limit counts only the top level fields in a selection set, including fragments and inline fragments.", + "format": "uint32", + "minimum": 0.0, + "nullable": true, + "type": "integer" + }, + "parser_max_recursion": { + "default": 500, + "description": "Limit recursion in the GraphQL parser to protect against stack overflow. default: 500", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "parser_max_tokens": { + "default": 15000, + "description": "Limit the number of tokens the GraphQL parser processes before aborting.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "warn_only": { + "default": false, + "description": "If set to true (which is the default is dev mode), requests that exceed a `max_*` limit are *not* rejected. Instead they are executed normally, and a warning is logged.", + "type": "boolean" + } + }, + "type": "object" + }, + "Config3": { + "additionalProperties": false, + "description": "Configuration for chaos testing, trying to reproduce bugs that require uncommon conditions. You probably don't want this in production!\n\n## How Chaos Reloading Works\n\nThe chaos system automatically captures and replays the last known schema and configuration events to force hot reloads even when the underlying content hasn't actually changed. This is particularly useful for memory leak detection during hot reload scenarios. If configured, it will activate upon the first config event that is encountered.\n\n### Schema Reloading (`force_schema_reload`) When enabled, the router will periodically replay the last schema event with a timestamp comment injected into the SDL (e.g., `# Chaos reload timestamp: 1234567890`). This ensures the schema is seen as \"different\" and triggers a full hot reload, even though the functional schema content is identical.\n\n### Configuration Reloading (`force_config_reload`) When enabled, the router will periodically replay the last configuration event. The configuration is cloned and re-emitted, which triggers the router's configuration change detection and reload logic.\n\n### Example Usage ```yaml experimental_chaos: force_schema_reload: \"30s\" # Trigger schema reload every 30 seconds force_config_reload: \"2m\" # Trigger config reload every 2 minutes ```", + "properties": { + "force_config_reload": { + "default": null, + "description": "Force a hot reload of the configuration at regular intervals by replaying the last configuration event. This triggers the router's configuration change detection even when the configuration content hasn't actually changed.\n\nThe system automatically captures the last configuration event and replays it to force configuration reload processing.", + "nullable": true, + "type": "string" + }, + "force_schema_reload": { + "default": null, + "description": "Force a hot reload of the schema at regular intervals by injecting a timestamp comment into the SDL. This ensures schema reloads occur even when the functional schema content hasn't changed, which is useful for testing memory leaks during schema hot reloads.\n\nThe system automatically captures the last schema event and replays it with a timestamp comment added to make it appear \"different\" to the reload detection logic.", + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "Config4": { + "description": "This is a broken plugin for testing purposes only.", + "properties": { + "enabled": { + "description": "Enable the broken plugin.", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "Config5": { + "description": "Restricted plugin (for testing purposes only)", + "properties": { + "enabled": { + "description": "Enable the restricted plugin (for testing purposes only)", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "Config6": { + "additionalProperties": false, + "description": "Configure subgraph authentication", + "properties": { + "all": { + "$ref": "#/definitions/AuthConfig", + "description": "#/definitions/AuthConfig", + "nullable": true + }, + "subgraphs": { "additionalProperties": { "$ref": "#/definitions/AuthConfig", "description": "#/definitions/AuthConfig" }, - "description": "Create a configuration that will apply only to a specific subgraph.", + "default": {}, + "description": "Create a configuration that will apply only to a specific subgraph.", + "type": "object" + } + }, + "type": "object" + }, + "Config7": { + "additionalProperties": false, + "description": "Configure connector authentication", + "properties": { + "sources": { + "additionalProperties": { + "$ref": "#/definitions/AuthConfig", + "description": "#/definitions/AuthConfig" + }, + "default": {}, + "description": "Create a configuration that will apply only to a specific source.", + "type": "object" + } + }, + "type": "object" + }, + "Config8": { + "type": "object" + }, + "Config9": { + "additionalProperties": false, + "description": "Configuration for response caching", + "properties": { + "debug": { + "default": false, + "description": "Enable debug mode for the debugger", + "type": "boolean" + }, + "enabled": { + "default": false, + "description": "Enable or disable the response caching feature", + "type": "boolean" + }, + "invalidation": { + "$ref": "#/definitions/InvalidationEndpointConfig", + "description": "#/definitions/InvalidationEndpointConfig", + "nullable": true + }, + "metrics": { + "$ref": "#/definitions/Metrics", + "description": "#/definitions/Metrics" + }, + "private_queries_buffer_size": { + "default": 2048, + "description": "Buffer size for known private queries (default: 2048)", + "format": "uint", + "minimum": 1.0, + "type": "integer" + }, + "subgraph": { + "$ref": "#/definitions/SubgraphConfiguration_for_Subgraph", + "description": "#/definitions/SubgraphConfiguration_for_Subgraph" + } + }, + "required": [ + "subgraph" + ], + "type": "object" + }, + "ConnectorAttributes": { + "additionalProperties": false, + "properties": { + "connector.http.method": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "connector.source.name": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "connector.url.template": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "subgraph.name": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + } + }, + "type": "object" + }, + "ConnectorConfiguration_for_TlsClient": { + "properties": { + "all": { + "$ref": "#/definitions/TlsClient", + "description": "#/definitions/TlsClient" + }, + "sources": { + "additionalProperties": { + "$ref": "#/definitions/TlsClient", + "description": "#/definitions/TlsClient" + }, + "default": {}, + "description": "Map of subgraph_name.connector_source_name to configuration", + "type": "object" + } + }, + "type": "object" + }, + "ConnectorEventsConfig": { + "additionalProperties": false, + "properties": { + "error": { + "$ref": "#/definitions/StandardEventConfig_for_ConnectorSelector", + "description": "#/definitions/StandardEventConfig_for_ConnectorSelector" + }, + "request": { + "$ref": "#/definitions/StandardEventConfig_for_ConnectorSelector", + "description": "#/definitions/StandardEventConfig_for_ConnectorSelector" + }, + "response": { + "$ref": "#/definitions/StandardEventConfig_for_ConnectorSelector", + "description": "#/definitions/StandardEventConfig_for_ConnectorSelector" + } + }, + "type": "object" + }, + "ConnectorHeadersConfiguration": { + "additionalProperties": false, + "properties": { + "all": { + "$ref": "#/definitions/HeadersLocation", + "description": "#/definitions/HeadersLocation", + "nullable": true + }, + "sources": { + "additionalProperties": { + "$ref": "#/definitions/HeadersLocation", + "description": "#/definitions/HeadersLocation" + }, + "description": "Map of subgraph_name.connector_source_name to configuration", + "type": "object" + } + }, + "type": "object" + }, + "ConnectorInstrumentsConfig": { + "additionalProperties": false, + "properties": { + "http.client.request.body.size": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector" + }, + "http.client.request.duration": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector" + }, + "http.client.response.body.size": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector" + } + }, + "type": "object" + }, + "ConnectorSelector": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "subgraph_name": { + "description": "The subgraph name", + "type": "boolean" + } + }, + "required": [ + "subgraph_name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "connector_source": { + "$ref": "#/definitions/ConnectorSource", + "description": "#/definitions/ConnectorSource" + } + }, + "required": [ + "connector_source" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "connector_http_request_header": { + "description": "The name of a connector HTTP request header.", + "type": "string" + }, + "default": { + "description": "Optional default value.", + "nullable": true, + "type": "string" + } + }, + "required": [ + "connector_http_request_header" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "connector_http_response_header": { + "description": "The name of a connector HTTP response header.", + "type": "string" + }, + "default": { + "description": "Optional default value.", + "nullable": true, + "type": "string" + } + }, + "required": [ + "connector_http_response_header" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "connector_http_response_status": { + "$ref": "#/definitions/ResponseStatus", + "description": "#/definitions/ResponseStatus" + } + }, + "required": [ + "connector_http_response_status" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "connector_http_method": { + "description": "The connector HTTP method.", + "type": "boolean" + } + }, + "required": [ + "connector_http_method" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "connector_url_template": { + "description": "The connector URL template.", + "type": "boolean" + } + }, + "required": [ + "connector_url_template" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "static": { + "$ref": "#/definitions/AttributeValue", + "description": "#/definitions/AttributeValue" + } + }, + "required": [ + "static" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "error": { + "$ref": "#/definitions/ErrorRepr", + "description": "#/definitions/ErrorRepr" + } + }, + "required": [ + "error" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "connector_request_mapping_problems": { + "$ref": "#/definitions/MappingProblems", + "description": "#/definitions/MappingProblems" + } + }, + "required": [ + "connector_request_mapping_problems" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "connector_response_mapping_problems": { + "$ref": "#/definitions/MappingProblems", + "description": "#/definitions/MappingProblems" + } + }, + "required": [ + "connector_response_mapping_problems" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "default": { + "$ref": "#/definitions/AttributeValue", + "description": "#/definitions/AttributeValue", + "nullable": true + }, + "request_context": { + "description": "The request context key.", + "type": "string" + } + }, + "required": [ + "request_context" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "default": { + "description": "Optional default value.", + "nullable": true, + "type": "string" + }, + "supergraph_operation_name": { + "$ref": "#/definitions/OperationName", + "description": "#/definitions/OperationName" + } + }, + "required": [ + "supergraph_operation_name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "supergraph_operation_kind": { + "$ref": "#/definitions/OperationKind", + "description": "#/definitions/OperationKind" + } + }, + "required": [ + "supergraph_operation_kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "connector_on_response_error": { + "description": "Boolean set to true if the response's `is_successful` condition is false. If this is not set, returns true when the response contains a non-200 status code", + "type": "boolean" + } + }, + "required": [ + "connector_on_response_error" + ], + "type": "object" + } + ] + }, + "ConnectorShaping": { + "additionalProperties": false, + "properties": { + "compression": { + "$ref": "#/definitions/Compression", + "description": "#/definitions/Compression", + "nullable": true + }, + "dns_resolution_strategy": { + "$ref": "#/definitions/DnsResolutionStrategy", + "description": "#/definitions/DnsResolutionStrategy", + "nullable": true + }, + "experimental_http2": { + "$ref": "#/definitions/Http2Config", + "description": "#/definitions/Http2Config", + "nullable": true + }, + "global_rate_limit": { + "$ref": "#/definitions/RateLimitConf", + "description": "#/definitions/RateLimitConf", + "nullable": true + }, + "timeout": { + "default": null, + "description": "Enable timeout for connectors requests", + "type": "string" + } + }, + "type": "object" + }, + "ConnectorSource": { + "oneOf": [ + { + "description": "The name of the connector source.", + "enum": [ + "name" + ], + "type": "string" + } + ] + }, + "ConnectorSpans": { + "additionalProperties": false, + "properties": { + "attributes": { + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional" + } + }, + "type": "object" + }, + "ConnectorValue": { + "anyOf": [ + { + "$ref": "#/definitions/Standard", + "description": "#/definitions/Standard" + }, + { + "$ref": "#/definitions/ConnectorSelector", + "description": "#/definitions/ConnectorSelector" + } + ] + }, + "ConnectorsConfig": { + "additionalProperties": false, + "properties": { + "debug_extensions": { + "default": false, + "description": "Enables connector debugging information on response extensions if the feature is enabled", + "type": "boolean" + }, + "expose_sources_in_context": { + "default": false, + "description": "When enabled, adds an entry to the context for use in coprocessors ```json { \"context\": { \"entries\": { \"apollo_connectors::sources_in_query_plan\": [ { \"subgraph_name\": \"subgraph\", \"source_name\": \"source\" } ] } } } ```", + "type": "boolean" + }, + "max_requests_per_operation_per_source": { + "default": null, + "description": "The maximum number of requests for a connector source", + "format": "uint", + "minimum": 0.0, + "nullable": true, + "type": "integer" + }, + "preview_connect_v0_2": { + "default": null, + "deprecated": true, + "description": "Enables Connect spec v0.2 during the preview.", + "nullable": true, + "type": "boolean" + }, + "preview_connect_v0_3": { + "default": null, + "description": "Feature gate for Connect spec v0.3. Set to `true` to enable the using the v0.3 spec during the preview phase.", + "nullable": true, + "type": "boolean" + }, + "sources": { + "additionalProperties": { + "$ref": "#/definitions/SourceConfiguration", + "description": "#/definitions/SourceConfiguration" + }, + "default": {}, + "description": "Map of subgraph_name.connector_source_name to source configuration", "type": "object" - } - }, - "type": "object" - }, - "Config5": { - "additionalProperties": false, - "description": "Configuration for header propagation", - "properties": { - "all": { - "$ref": "#/definitions/HeadersLocation", - "description": "#/definitions/HeadersLocation", - "nullable": true }, "subgraphs": { "additionalProperties": { - "$ref": "#/definitions/HeadersLocation", - "description": "#/definitions/HeadersLocation" + "$ref": "#/definitions/SubgraphConnectorConfiguration", + "description": "#/definitions/SubgraphConnectorConfiguration" }, - "description": "Rules to specific subgraphs", + "default": {}, + "deprecated": true, + "description": "A map of subgraph name to connectors config for that subgraph", "type": "object" } }, "type": "object" }, - "Config6": { + "ConnectorsShapingConfig": { "additionalProperties": false, - "description": "Configuration for exposing errors that originate from subgraphs", "properties": { "all": { - "default": false, - "description": "Include errors from all subgraphs", - "type": "boolean" + "$ref": "#/definitions/ConnectorShaping", + "description": "#/definitions/ConnectorShaping", + "nullable": true }, - "subgraphs": { + "sources": { "additionalProperties": { - "type": "boolean" + "$ref": "#/definitions/ConnectorShaping", + "description": "#/definitions/ConnectorShaping" }, - "default": {}, - "description": "Include errors from specific subgraphs", + "description": "Applied on specific connector sources", "type": "object" } }, "type": "object" }, - "Config7": { - "additionalProperties": false, - "description": "Configuration for entity caching", - "properties": { - "enabled": { - "default": false, - "description": "Enable or disable the entity caching feature", + "ContextConf": { + "anyOf": [ + { + "description": "Deprecated configuration using a boolean", "type": "boolean" }, - "invalidation": { - "$ref": "#/definitions/InvalidationEndpointConfig", - "description": "#/definitions/InvalidationEndpointConfig", - "nullable": true - }, - "metrics": { - "$ref": "#/definitions/Metrics", - "description": "#/definitions/Metrics" - }, - "subgraph": { - "$ref": "#/definitions/SubgraphConfiguration_for_Subgraph", - "description": "#/definitions/SubgraphConfiguration_for_Subgraph" + { + "$ref": "#/definitions/NewContextConf", + "description": "#/definitions/NewContextConf" } - }, - "required": [ - "subgraph" ], - "type": "object" - }, - "Config8": { - "description": "Configuration for the progressive override plugin", - "type": "object" + "description": "Configures the context" }, - "Config9": { + "CooperativeCancellation": { "additionalProperties": false, "properties": { - "batch_processor": { - "$ref": "#/definitions/BatchProcessorConfig", - "description": "#/definitions/BatchProcessorConfig" - }, - "buffer_size": { - "default": 10000, - "description": "The buffer size for sending traces to Apollo. Increase this if you are experiencing lost traces.", - "format": "uint", - "minimum": 1.0, - "type": "integer" - }, - "client_name_header": { - "default": "apollographql-client-name", - "description": "The name of the header to extract from requests when populating 'client nane' for traces and metrics in Apollo Studio.", - "nullable": true, - "type": "string" - }, - "client_version_header": { - "default": "apollographql-client-version", - "description": "The name of the header to extract from requests when populating 'client version' for traces and metrics in Apollo Studio.", - "nullable": true, - "type": "string" - }, - "endpoint": { - "default": "https://usage-reporting.api.apollographql.com/api/ingress/traces", - "description": "The Apollo Studio endpoint for exporting traces and metrics.", - "type": "string" - }, - "errors": { - "$ref": "#/definitions/ErrorsConfiguration", - "description": "#/definitions/ErrorsConfiguration" - }, - "experimental_local_field_metrics": { - "default": false, - "description": "Enable field metrics that are generated without FTV1 to be sent to Apollo Studio.", + "enabled": { + "default": true, + "description": "When true, cooperative cancellation is enabled.", "type": "boolean" }, - "experimental_otlp_endpoint": { - "default": "https://usage-reporting.api.apollographql.com/", - "description": "The Apollo Studio endpoint for exporting traces and metrics.", - "type": "string" - }, - "experimental_otlp_tracing_protocol": { - "$ref": "#/definitions/Protocol", - "description": "#/definitions/Protocol" - }, - "experimental_otlp_tracing_sampler": { - "$ref": "#/definitions/SamplerOption", - "description": "#/definitions/SamplerOption" - }, - "field_level_instrumentation_sampler": { - "$ref": "#/definitions/SamplerOption", - "description": "#/definitions/SamplerOption" - }, - "metrics_reference_mode": { - "$ref": "#/definitions/ApolloMetricsReferenceMode", - "description": "#/definitions/ApolloMetricsReferenceMode" - }, - "send_headers": { - "$ref": "#/definitions/ForwardHeaders", - "description": "#/definitions/ForwardHeaders" - }, - "send_variable_values": { - "$ref": "#/definitions/ForwardValues", - "description": "#/definitions/ForwardValues" - }, - "signature_normalization_algorithm": { - "$ref": "#/definitions/ApolloSignatureNormalizationAlgorithm", - "description": "#/definitions/ApolloSignatureNormalizationAlgorithm" - } - }, - "type": "object" - }, - "ContextForward": { - "additionalProperties": false, - "description": "Configuration to forward context values in metric attributes/labels", - "properties": { - "default": { - "$ref": "#/definitions/AttributeValue", - "description": "#/definitions/AttributeValue", - "nullable": true - }, - "named": { - "description": "The name of the value in the context", - "type": "string" + "mode": { + "$ref": "#/definitions/Mode", + "description": "#/definitions/Mode" }, - "rename": { - "description": "The optional output name", + "timeout": { + "default": null, + "description": "Enable timeout for query planning.", "nullable": true, "type": "string" } }, - "required": [ - "named" - ], "type": "object" }, "Cors": { @@ -1830,7 +2425,7 @@ snapshot_kind: text "properties": { "allow_any_origin": { "default": false, - "description": "Set to true to allow any origin.\n\nDefaults to false Having this set to true is the only way to allow Origin: null.", + "description": "Set to true to allow any origin. Defaults to false. This is the only way to allow Origin: null.", "type": "boolean" }, "allow_credentials": { @@ -1855,15 +2450,6 @@ snapshot_kind: text "nullable": true, "type": "array" }, - "match_origins": { - "default": null, - "description": "`Regex`es you want to match the origins against to determine if they're allowed. Defaults to an empty list. Note that `origins` will be evaluated before `match_origins`", - "items": { - "type": "string" - }, - "nullable": true, - "type": "array" - }, "max_age": { "default": null, "description": "The `Access-Control-Max-Age` header value in time units", @@ -1875,20 +2461,36 @@ snapshot_kind: text "POST", "OPTIONS" ], - "description": "Allowed request methods. Defaults to GET, POST, OPTIONS.", + "description": "Allowed request methods. See module documentation for default behavior.", "items": { "type": "string" }, "type": "array" }, - "origins": { + "policies": { "default": [ - "https://studio.apollographql.com" + { + "allow_credentials": null, + "allow_headers": [], + "expose_headers": [], + "match_origins": [], + "max_age": null, + "methods": [ + "GET", + "POST", + "OPTIONS" + ], + "origins": [ + "https://studio.apollographql.com" + ] + } ], - "description": "The origin(s) to allow requests from. Defaults to `https://studio.apollographql.com/` for Apollo Studio.", + "description": "The origin(s) to allow requests from. The router matches request origins against policies in order, first by exact match, then by regex. See module documentation for default behavior.", "items": { - "type": "string" + "$ref": "#/definitions/Policy", + "description": "#/definitions/Policy" }, + "nullable": true, "type": "array" } }, @@ -1926,6 +2528,27 @@ snapshot_kind: text } ] }, + "DeduplicationConfig": { + "additionalProperties": false, + "description": "Subscription deduplication configuration", + "properties": { + "enabled": { + "default": true, + "description": "Enable subgraph subscription deduplication. When enabled, multiple identical requests to the same subgraph will share one WebSocket connection in passthrough mode. (default: true)", + "type": "boolean" + }, + "ignored_headers": { + "default": [], + "description": "List of headers to ignore for deduplication. Even if these headers are different, the subscription request is considered identical. For example, if you forward the \"User-Agent\" header, but the subgraph doesn't depend on the value of that header, adding it to this list will let the router dedupe subgraph subscriptions even if the header value is different.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + } + }, + "type": "object" + }, "DefaultAttributeRequirementLevel": { "oneOf": [ { @@ -2003,7 +2626,7 @@ snapshot_kind: text } ] }, - "DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector": { + "DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector": { "anyOf": [ { "type": "null" @@ -2015,8 +2638,8 @@ snapshot_kind: text "additionalProperties": false, "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" } }, "required": [ @@ -2026,7 +2649,7 @@ snapshot_kind: text } ] }, - "DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector": { + "DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector": { "anyOf": [ { "type": "null" @@ -2038,8 +2661,8 @@ snapshot_kind: text "additionalProperties": false, "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector" } }, "required": [ @@ -2049,7 +2672,7 @@ snapshot_kind: text } ] }, - "DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector": { + "DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector": { "anyOf": [ { "type": "null" @@ -2061,8 +2684,8 @@ snapshot_kind: text "additionalProperties": false, "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector" } }, "required": [ @@ -2072,7 +2695,7 @@ snapshot_kind: text } ] }, - "DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector": { + "DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector": { "anyOf": [ { "type": "null" @@ -2084,8 +2707,8 @@ snapshot_kind: text "additionalProperties": false, "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector" } }, "required": [ @@ -2095,7 +2718,7 @@ snapshot_kind: text } ] }, - "DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector": { + "DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector": { "anyOf": [ { "type": "null" @@ -2107,8 +2730,31 @@ snapshot_kind: text "additionalProperties": false, "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" + } + }, + "required": [ + "attributes" + ], + "type": "object" + } + ] + }, + "DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "additionalProperties": false, + "properties": { + "attributes": { + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector" } }, "required": [ @@ -2127,8 +2773,8 @@ snapshot_kind: text "type": "boolean" }, "mode": { - "$ref": "#/definitions/Mode", - "description": "#/definitions/Mode" + "$ref": "#/definitions/Mode2", + "description": "#/definitions/Mode2" }, "strategy": { "$ref": "#/definitions/StrategyConfig", @@ -2261,34 +2907,107 @@ snapshot_kind: text "description": "Redact subgraph errors to Apollo Studio", "type": "boolean" }, + "redaction_policy": { + "$ref": "#/definitions/ErrorRedactionPolicy", + "description": "#/definitions/ErrorRedactionPolicy" + }, "send": { "default": true, "description": "Send subgraph errors to Apollo Studio", "type": "boolean" } - }, - "type": "object" + }, + "type": "object" + }, + "ErrorLocation": { + "oneOf": [ + { + "description": "store authorization errors in the response errors", + "enum": [ + "errors" + ], + "type": "string" + }, + { + "description": "store authorization errors in the response extensions", + "enum": [ + "extensions" + ], + "type": "string" + }, + { + "description": "do not add the authorization errors to the GraphQL response", + "enum": [ + "disabled" + ], + "type": "string" + } + ] + }, + "ErrorMode": { + "anyOf": [ + { + "description": "When `true`, Propagate the original error as is. Otherwise, redact it.", + "type": "boolean" + }, + { + "description": "Allow specific extension keys with required redact_message", + "properties": { + "allow_extensions_keys": { + "description": "Allow specific extension keys", + "items": { + "type": "string" + }, + "type": "array" + }, + "redact_message": { + "description": "redact error messages for all subgraphs", + "type": "boolean" + } + }, + "required": [ + "allow_extensions_keys", + "redact_message" + ], + "type": "object" + }, + { + "description": "Deny specific extension keys with required redact_message", + "properties": { + "deny_extensions_keys": { + "description": "Deny specific extension keys", + "items": { + "type": "string" + }, + "type": "array" + }, + "redact_message": { + "description": "redact error messages for all subgraphs", + "type": "boolean" + } + }, + "required": [ + "deny_extensions_keys", + "redact_message" + ], + "type": "object" + } + ] }, - "ErrorLocation": { + "ErrorRedactionPolicy": { + "description": "Allow some error fields to be send to Apollo Studio even when `redact` is true.", "oneOf": [ { - "description": "store authorization errors in the response errors", - "enum": [ - "errors" - ], - "type": "string" - }, - { - "description": "store authorization errors in the response extensions", + "description": "Applies redaction to all error details.", "enum": [ - "extensions" + "strict" ], "type": "string" }, { - "description": "do not add the authorization errors to the GraphQL response", + "description": "Modifies the `redact` setting by excluding the `extensions.code` field in errors from redaction.", "enum": [ - "disabled" + "extended" ], "type": "string" } @@ -2308,6 +3027,10 @@ snapshot_kind: text "ErrorsConfiguration": { "additionalProperties": false, "properties": { + "preview_extended_error_metrics": { + "$ref": "#/definitions/ExtendedErrorMetricsMode", + "description": "#/definitions/ExtendedErrorMetricsMode" + }, "subgraph": { "$ref": "#/definitions/SubgraphErrorConfig", "description": "#/definitions/SubgraphErrorConfig" @@ -2315,27 +3038,7 @@ snapshot_kind: text }, "type": "object" }, - "ErrorsForward": { - "additionalProperties": false, - "properties": { - "extensions": { - "description": "Forward extensions values as custom attributes/labels in metrics", - "items": { - "$ref": "#/definitions/BodyForward", - "description": "#/definitions/BodyForward" - }, - "type": "array" - }, - "include_messages": { - "default": null, - "description": "Will include the error message in a \"message\" attribute", - "nullable": true, - "type": "boolean" - } - }, - "type": "object" - }, - "EventLevel": { + "EventLevelConfig": { "enum": [ "info", "warn", @@ -2377,20 +3080,51 @@ snapshot_kind: text } ] }, + "Event_for_ConnectorAttributes_and_ConnectorSelector": { + "description": "An event that can be logged as part of a trace. The event has an implicit `type` attribute that matches the name of the event in the yaml and a message that can be used to provide additional information.", + "properties": { + "attributes": { + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector" + }, + "condition": { + "$ref": "#/definitions/Condition_for_ConnectorSelector", + "description": "#/definitions/Condition_for_ConnectorSelector" + }, + "level": { + "$ref": "#/definitions/EventLevelConfig", + "description": "#/definitions/EventLevelConfig" + }, + "message": { + "description": "The event message.", + "type": "string" + }, + "on": { + "$ref": "#/definitions/EventOn", + "description": "#/definitions/EventOn" + } + }, + "required": [ + "level", + "message", + "on" + ], + "type": "object" + }, "Event_for_RouterAttributes_and_RouterSelector": { "description": "An event that can be logged as part of a trace. The event has an implicit `type` attribute that matches the name of the event in the yaml and a message that can be used to provide additional information.", "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector" }, "condition": { "$ref": "#/definitions/Condition_for_RouterSelector", "description": "#/definitions/Condition_for_RouterSelector" }, "level": { - "$ref": "#/definitions/EventLevel", - "description": "#/definitions/EventLevel" + "$ref": "#/definitions/EventLevelConfig", + "description": "#/definitions/EventLevelConfig" }, "message": { "description": "The event message.", @@ -2412,16 +3146,16 @@ snapshot_kind: text "description": "An event that can be logged as part of a trace. The event has an implicit `type` attribute that matches the name of the event in the yaml and a message that can be used to provide additional information.", "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" }, "condition": { "$ref": "#/definitions/Condition_for_SubgraphSelector", "description": "#/definitions/Condition_for_SubgraphSelector" }, "level": { - "$ref": "#/definitions/EventLevel", - "description": "#/definitions/EventLevel" + "$ref": "#/definitions/EventLevelConfig", + "description": "#/definitions/EventLevelConfig" }, "message": { "description": "The event message.", @@ -2443,16 +3177,16 @@ snapshot_kind: text "description": "An event that can be logged as part of a trace. The event has an implicit `type` attribute that matches the name of the event in the yaml and a message that can be used to provide additional information.", "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector" }, "condition": { "$ref": "#/definitions/Condition_for_SupergraphSelector", "description": "#/definitions/Condition_for_SupergraphSelector" }, "level": { - "$ref": "#/definitions/EventLevel", - "description": "#/definitions/EventLevel" + "$ref": "#/definitions/EventLevelConfig", + "description": "#/definitions/EventLevelConfig" }, "message": { "description": "The event message.", @@ -2506,17 +3240,21 @@ snapshot_kind: text "additionalProperties": false, "description": "Events are", "properties": { + "connector": { + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::events::ConnectorEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::events::ConnectorEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event" + }, "router": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::events::RouterEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::events::RouterEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::router::events::RouterEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::router::events::RouterEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event" }, "subgraph": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::events::SubgraphEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::events::SubgraphEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::events::SubgraphEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::events::SubgraphEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event" }, "supergraph": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::events::SupergraphEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::events::SupergraphEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::events::SupergraphEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::events::SupergraphEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event" } }, "type": "object" @@ -2531,9 +3269,8 @@ snapshot_kind: text "type": "boolean" }, "context": { - "default": false, - "description": "Send the context", - "type": "boolean" + "$ref": "#/definitions/ContextConf", + "description": "#/definitions/ContextConf" }, "headers": { "default": false, @@ -2568,9 +3305,8 @@ snapshot_kind: text "type": "boolean" }, "context": { - "default": false, - "description": "Send the context", - "type": "boolean" + "$ref": "#/definitions/ContextConf", + "description": "#/definitions/ContextConf" }, "headers": { "default": false, @@ -2612,8 +3348,8 @@ snapshot_kind: text "description": "#/definitions/Logging" }, "metrics": { - "$ref": "#/definitions/Metrics2", - "description": "#/definitions/Metrics2" + "$ref": "#/definitions/Metrics3", + "description": "#/definitions/Metrics3" }, "tracing": { "$ref": "#/definitions/Tracing", @@ -2646,6 +3382,25 @@ snapshot_kind: text }, "type": "object" }, + "ExtendedErrorMetricsMode": { + "description": "Extended Open Telemetry error metrics mode", + "oneOf": [ + { + "description": "Do not send extended OTLP error metrics", + "enum": [ + "disabled" + ], + "type": "string" + }, + { + "description": "Send extended OTLP error metrics to Apollo Studio with additional dimensions [`extensions.service`, `extensions.code`]. If enabled, it's also recommended to enable `redaction_policy: extended` on subgraphs to send the `extensions.code` for subgraph errors.", + "enum": [ + "enabled" + ], + "type": "string" + } + ] + }, "FieldName": { "oneOf": [ { @@ -2712,29 +3467,6 @@ snapshot_kind: text "description": "Forbid mutations configuration", "type": "boolean" }, - "Forward": { - "additionalProperties": false, - "description": "Configuration to forward from headers/body", - "properties": { - "body": { - "description": "Forward body values as custom attributes/labels in metrics", - "items": { - "$ref": "#/definitions/BodyForward", - "description": "#/definitions/BodyForward" - }, - "type": "array" - }, - "header": { - "description": "Forward header values as custom attributes/labels in metrics", - "items": { - "$ref": "#/definitions/HeaderForward", - "description": "#/definitions/HeaderForward" - }, - "type": "array" - } - }, - "type": "object" - }, "ForwardHeaders": { "description": "Forward headers", "oneOf": [ @@ -3044,111 +3776,6 @@ snapshot_kind: text ], "type": "object" }, - "HeaderForward": { - "anyOf": [ - { - "additionalProperties": false, - "description": "Match via header name", - "properties": { - "default": { - "$ref": "#/definitions/AttributeValue", - "description": "#/definitions/AttributeValue", - "nullable": true - }, - "named": { - "description": "The name of the header", - "type": "string" - }, - "rename": { - "description": "The optional output name", - "nullable": true, - "type": "string" - } - }, - "required": [ - "named" - ], - "type": "object" - }, - { - "additionalProperties": false, - "description": "Match via rgex", - "properties": { - "matching": { - "description": "Using a regex on the header name", - "type": "string" - } - }, - "required": [ - "matching" - ], - "type": "object" - } - ], - "description": "Configuration to forward header values in metric labels" - }, - "HeaderLoggingCondition": { - "anyOf": [ - { - "additionalProperties": false, - "description": "Match header value given a regex to display logs", - "properties": { - "body": { - "default": false, - "description": "Display request/response body (default: false)", - "type": "boolean" - }, - "headers": { - "default": false, - "description": "Display request/response headers (default: false)", - "type": "boolean" - }, - "match": { - "description": "Regex to match the header value", - "type": "string" - }, - "name": { - "description": "Header name", - "type": "string" - } - }, - "required": [ - "match", - "name" - ], - "type": "object" - }, - { - "additionalProperties": false, - "description": "Match header value given a value to display logs", - "properties": { - "body": { - "default": false, - "description": "Display request/response body (default: false)", - "type": "boolean" - }, - "headers": { - "default": false, - "description": "Display request/response headers (default: false)", - "type": "boolean" - }, - "name": { - "description": "Header name", - "type": "string" - }, - "value": { - "description": "Header value", - "type": "string" - } - }, - "required": [ - "name", - "value" - ], - "type": "object" - } - ] - }, "HeadersLocation": { "additionalProperties": false, "properties": { @@ -3166,27 +3793,6 @@ snapshot_kind: text ], "type": "object" }, - "HealthCheck": { - "additionalProperties": false, - "description": "Configuration options pertaining to the http server component.", - "properties": { - "enabled": { - "default": true, - "description": "Set to false to disable the health check", - "type": "boolean" - }, - "listen": { - "$ref": "#/definitions/ListenAddr", - "description": "#/definitions/ListenAddr" - }, - "path": { - "default": "/health", - "description": "Optionally set a custom healthcheck path Defaults to /health", - "type": "string" - } - }, - "type": "object" - }, "HeartbeatInterval": { "anyOf": [ { @@ -3293,25 +3899,6 @@ snapshot_kind: text ], "description": "Insert header" }, - "Insert2": { - "additionalProperties": false, - "description": "Configuration to insert custom attributes/labels in metrics", - "properties": { - "name": { - "description": "The name of the attribute to insert", - "type": "string" - }, - "value": { - "$ref": "#/definitions/AttributeValue", - "description": "#/definitions/AttributeValue" - } - }, - "required": [ - "name", - "value" - ], - "type": "object" - }, "InsertFromBody": { "additionalProperties": false, "description": "Insert header with a value coming from body", @@ -3396,8 +3983,8 @@ snapshot_kind: text "additionalProperties": false, "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" }, "condition": { "$ref": "#/definitions/Condition_for_SubgraphSelector", @@ -3428,6 +4015,42 @@ snapshot_kind: text ], "type": "object" }, + "Instrument_for_ConnectorAttributes_and_ConnectorSelector_and_ConnectorValue": { + "additionalProperties": false, + "properties": { + "attributes": { + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector" + }, + "condition": { + "$ref": "#/definitions/Condition_for_ConnectorSelector", + "description": "#/definitions/Condition_for_ConnectorSelector" + }, + "description": { + "description": "The description of the instrument.", + "type": "string" + }, + "type": { + "$ref": "#/definitions/InstrumentType", + "description": "#/definitions/InstrumentType" + }, + "unit": { + "description": "The units of the instrument, e.g. \"ms\", \"bytes\", \"requests\".", + "type": "string" + }, + "value": { + "$ref": "#/definitions/ConnectorValue", + "description": "#/definitions/ConnectorValue" + } + }, + "required": [ + "description", + "type", + "unit", + "value" + ], + "type": "object" + }, "Instrument_for_GraphQLAttributes_and_GraphQLSelector_and_GraphQLValue": { "additionalProperties": false, "properties": { @@ -3468,8 +4091,8 @@ snapshot_kind: text "additionalProperties": false, "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector" }, "condition": { "$ref": "#/definitions/Condition_for_RouterSelector", @@ -3504,8 +4127,8 @@ snapshot_kind: text "additionalProperties": false, "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" }, "condition": { "$ref": "#/definitions/Condition_for_SubgraphSelector", @@ -3540,8 +4163,8 @@ snapshot_kind: text "additionalProperties": false, "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector" }, "condition": { "$ref": "#/definitions/Condition_for_SupergraphSelector", @@ -3595,8 +4218,12 @@ snapshot_kind: text "additionalProperties": false, "properties": { "cache": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::CacheInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::CacheInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::CacheInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::CacheInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument" + }, + "connector": { + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::instruments::ConnectorInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::instruments::ConnectorInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument" }, "default_requirement_level": { "$ref": "#/definitions/DefaultAttributeRequirementLevel", @@ -3607,21 +4234,39 @@ snapshot_kind: text "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::GraphQLInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument" }, "router": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::instruments::RouterInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::instruments::RouterInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::router::instruments::RouterInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::router::instruments::RouterInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument" }, "subgraph": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::instruments::SubgraphInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::instruments::SubgraphInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::instruments::SubgraphInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::instruments::SubgraphInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument" }, "supergraph": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::instruments::SupergraphInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::instruments::SupergraphInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::instruments::SupergraphInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::instruments::SupergraphInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument" + } + }, + "type": "object" + }, + "InvalidationEndpointConfig": { + "additionalProperties": false, + "properties": { + "listen": { + "$ref": "#/definitions/ListenAddr", + "description": "#/definitions/ListenAddr" + }, + "path": { + "description": "Specify on which path you want to listen for invalidation endpoint.", + "type": "string" } }, + "required": [ + "listen", + "path" + ], "type": "object" }, - "InvalidationEndpointConfig": { + "InvalidationEndpointConfig2": { "additionalProperties": false, "properties": { "concurrent_requests": { @@ -3679,6 +4324,10 @@ snapshot_kind: text }, "type": "array" }, + "on_error": { + "$ref": "#/definitions/OnError", + "description": "#/definitions/OnError" + }, "sources": { "description": "Alternative sources to extract the JWT", "items": { @@ -3705,6 +4354,15 @@ snapshot_kind: text "nullable": true, "type": "array" }, + "audiences": { + "description": "Expected audiences for tokens verified by that JWKS\n\nIf not specified, the audience will not be checked.", + "items": { + "type": "string" + }, + "nullable": true, + "type": "array", + "uniqueItems": true + }, "headers": { "description": "List of headers to add to the JWKS request", "items": { @@ -3713,10 +4371,14 @@ snapshot_kind: text }, "type": "array" }, - "issuer": { - "description": "Expected issuer for tokens verified by that JWKS", + "issuers": { + "description": "Expected issuers for tokens verified by that JWKS\n\nIf not specified, the issuer will not be checked.", + "items": { + "type": "string" + }, "nullable": true, - "type": "string" + "type": "array", + "uniqueItems": true }, "poll_interval": { "default": { @@ -3736,6 +4398,9 @@ snapshot_kind: text ], "type": "object" }, + "LicenseEnforcementConfig": { + "type": "object" + }, "ListLength": { "oneOf": [ { @@ -3768,14 +4433,6 @@ snapshot_kind: text "$ref": "#/definitions/LoggingCommon", "description": "#/definitions/LoggingCommon" }, - "experimental_when_header": { - "description": "Log configuration to log request and response for subgraphs and supergraph Note that this will be removed when events are implemented.", - "items": { - "$ref": "#/definitions/HeaderLoggingCondition", - "description": "#/definitions/HeaderLoggingCondition" - }, - "type": "array" - }, "stdout": { "$ref": "#/definitions/StdOut", "description": "#/definitions/StdOut" @@ -3810,6 +4467,31 @@ snapshot_kind: text }, "type": "object" }, + "MappingProblems": { + "oneOf": [ + { + "description": "String representation of all problems", + "enum": [ + "problems" + ], + "type": "string" + }, + { + "description": "The count of mapping problems", + "enum": [ + "count" + ], + "type": "string" + }, + { + "description": "Whether there are any mapping problems", + "enum": [ + "boolean" + ], + "type": "string" + } + ] + }, "MetricAggregation": { "oneOf": [ { @@ -3886,11 +4568,11 @@ snapshot_kind: text }, "Metrics": { "additionalProperties": false, - "description": "Per subgraph configuration for entity caching", + "description": "Per subgraph configuration for response caching", "properties": { "enabled": { "default": false, - "description": "enables metrics evaluating the benefits of entity caching", + "description": "enables metrics evaluating the benefits of response caching", "type": "boolean" }, "separate_per_type": { @@ -3908,34 +4590,41 @@ snapshot_kind: text }, "Metrics2": { "additionalProperties": false, - "description": "Metrics configuration", + "description": "Per subgraph configuration for entity caching", "properties": { - "common": { - "$ref": "#/definitions/MetricsCommon", - "description": "#/definitions/MetricsCommon" + "enabled": { + "default": false, + "description": "enables metrics evaluating the benefits of entity caching", + "type": "boolean" }, - "otlp": { - "$ref": "#/definitions/Config10", - "description": "#/definitions/Config10" + "separate_per_type": { + "default": false, + "description": "Adds the entity type name to attributes. This can greatly increase the cardinality", + "type": "boolean" }, - "prometheus": { - "$ref": "#/definitions/Config11", - "description": "#/definitions/Config11" + "ttl": { + "$ref": "#/definitions/Ttl2", + "description": "#/definitions/Ttl2", + "nullable": true } }, "type": "object" }, - "MetricsAttributesConf": { + "Metrics3": { "additionalProperties": false, - "description": "Configuration to add custom attributes/labels on metrics", + "description": "Metrics configuration", "properties": { - "subgraph": { - "$ref": "#/definitions/SubgraphAttributesConf", - "description": "#/definitions/SubgraphAttributesConf" + "common": { + "$ref": "#/definitions/MetricsCommon", + "description": "#/definitions/MetricsCommon" }, - "supergraph": { - "$ref": "#/definitions/AttributesForwardConf", - "description": "#/definitions/AttributesForwardConf" + "otlp": { + "$ref": "#/definitions/Config15", + "description": "#/definitions/Config15" + }, + "prometheus": { + "$ref": "#/definitions/Config16", + "description": "#/definitions/Config16" } }, "type": "object" @@ -3943,10 +4632,6 @@ snapshot_kind: text "MetricsCommon": { "additionalProperties": false, "properties": { - "attributes": { - "$ref": "#/definitions/MetricsAttributesConf", - "description": "#/definitions/MetricsAttributesConf" - }, "buckets": { "default": [ 0.001, @@ -4008,6 +4693,13 @@ snapshot_kind: text ], "type": "string" }, + "Mode2": { + "enum": [ + "measure", + "enforce" + ], + "type": "string" + }, "MultipartRequest": { "additionalProperties": false, "description": "Configuration for a multipart request for file uploads.\n\nThis protocol conforms to [jaydenseric's multipart spec](https://github.com/jaydenseric/graphql-multipart-request-spec)", @@ -4061,6 +4753,53 @@ snapshot_kind: text } ] }, + "MyTestPluginConfig": { + "description": "Config for the test plugin", + "type": "object" + }, + "NewContextConf": { + "description": "Configures the context", + "oneOf": [ + { + "description": "Send all context keys to coprocessor", + "enum": [ + "all" + ], + "type": "string" + }, + { + "description": "Send all context keys using deprecated names (from router 1.x) to coprocessor", + "enum": [ + "deprecated" + ], + "type": "string" + }, + { + "additionalProperties": false, + "description": "Only send the list of context keys to coprocessor", + "properties": { + "selective": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "selective" + ], + "type": "object" + } + ] + }, + "OnError": { + "enum": [ + "Continue", + "Error" + ], + "type": "string" + }, "Operation": { "oneOf": [ { @@ -4142,7 +4881,16 @@ snapshot_kind: text "description": "Activates Persisted Queries (disabled by default)", "type": "boolean" }, - "experimental_local_manifests": { + "experimental_prewarm_query_plan_cache": { + "$ref": "#/definitions/PersistedQueriesPrewarmQueryPlanCache", + "description": "#/definitions/PersistedQueriesPrewarmQueryPlanCache" + }, + "hot_reload": { + "default": false, + "description": "Enables hot reloading of the local persisted query manifests", + "type": "boolean" + }, + "local_manifests": { "default": null, "description": "Enables using a local copy of the persisted query manifest to safelist operations", "items": { @@ -4151,10 +4899,6 @@ snapshot_kind: text "nullable": true, "type": "array" }, - "experimental_prewarm_query_plan_cache": { - "$ref": "#/definitions/PersistedQueriesPrewarmQueryPlanCache", - "description": "#/definitions/PersistedQueriesPrewarmQueryPlanCache" - }, "log_unknown": { "default": false, "description": "Enabling this field configures the router to log any freeform GraphQL request that is not in the persisted query list", @@ -4204,9 +4948,13 @@ snapshot_kind: text "Plugins": { "additionalProperties": false, "properties": { + "apollo_testing.my_test_plugin": { + "$ref": "#/definitions/MyTestPluginConfig", + "description": "#/definitions/MyTestPluginConfig" + }, "experimental.broken": { - "$ref": "#/definitions/Config2", - "description": "#/definitions/Config2" + "$ref": "#/definitions/Config4", + "description": "#/definitions/Config4" }, "experimental.expose_query_plan": { "$ref": "#/definitions/ExposeQueryPlanConfig", @@ -4217,8 +4965,8 @@ snapshot_kind: text "description": "#/definitions/RecordConfig" }, "experimental.restricted": { - "$ref": "#/definitions/Config3", - "description": "#/definitions/Config3" + "$ref": "#/definitions/Config5", + "description": "#/definitions/Config5" }, "test.always_fails_to_start": { "$ref": "#/definitions/Conf", @@ -4230,6 +4978,145 @@ snapshot_kind: text } } }, + "Policy": { + "additionalProperties": false, + "description": "Configuration for a specific set of origins", + "properties": { + "allow_credentials": { + "default": null, + "description": "Set to true to add the `Access-Control-Allow-Credentials` header for these origins", + "nullable": true, + "type": "boolean" + }, + "allow_headers": { + "default": [], + "description": "The headers to allow for these origins", + "items": { + "type": "string" + }, + "type": "array" + }, + "expose_headers": { + "default": [], + "description": "Which response headers should be made available to scripts running in the browser", + "items": { + "type": "string" + }, + "type": "array" + }, + "match_origins": { + "default": [], + "description": "Regex patterns to match origins against.", + "items": { + "type": "string" + }, + "type": "array" + }, + "max_age": { + "default": null, + "description": "The `Access-Control-Max-Age` header value in time units", + "type": "string" + }, + "methods": { + "default": null, + "description": "Allowed request methods for these origins.", + "items": { + "type": "string" + }, + "nullable": true, + "type": "array" + }, + "origins": { + "default": [ + "https://studio.apollographql.com" + ], + "description": "The origins to allow requests from.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "PostgresCacheConfig": { + "additionalProperties": false, + "description": "Postgres cache configuration", + "properties": { + "acquire_timeout": { + "default": { + "nanos": 50000000, + "secs": 0 + }, + "description": "PostgreSQL the maximum amount of time to spend waiting for a connection (default: 50ms)", + "type": "string" + }, + "batch_size": { + "default": 100, + "description": "The size of batch when inserting cache entries in PG (default: 100)", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "cleanup_interval": { + "default": { + "nanos": 0, + "secs": 3600 + }, + "description": "Specifies the interval between cache cleanup operations (e.g., \"2 hours\", \"30min\"). Default: 1 hour", + "type": "string" + }, + "idle_timeout": { + "default": { + "nanos": 0, + "secs": 60 + }, + "description": "PostgreSQL maximum idle duration for individual connection (default: 1min)", + "type": "string" + }, + "namespace": { + "default": null, + "description": "Useful when running tests in parallel to avoid conflicts", + "nullable": true, + "type": "string" + }, + "password": { + "description": "PostgreSQL password if not provided in the URLs. This field takes precedence over the password in the URL", + "nullable": true, + "type": "string" + }, + "pool_size": { + "default": 5, + "description": "The size of the PostgreSQL connection pool", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "required_to_start": { + "default": false, + "description": "Prevents the router from starting if it cannot connect to PostgreSQL", + "type": "boolean" + }, + "tls": { + "$ref": "#/definitions/TlsConfig", + "description": "#/definitions/TlsConfig" + }, + "url": { + "description": "List of URL to Postgres", + "format": "uri", + "type": "string" + }, + "username": { + "description": "PostgreSQL username if not provided in the URLs. This field takes precedence over the username in the URL", + "nullable": true, + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, "Propagate": { "anyOf": [ { @@ -4440,44 +5327,11 @@ snapshot_kind: text "nullable": true, "type": "string" } - }, - "required": [ - "urls" - ], - "type": "object" - }, - "QueryPlannerMode": { - "description": "Query planner modes.", - "oneOf": [ - { - "description": "Use the new Rust-based implementation.\n\nRaises an error at Router startup if the the new planner does not support the schema (such as using legacy Apollo Federation 1)", - "enum": [ - "new" - ], - "type": "string" - }, - { - "description": "Use the old JavaScript-based implementation.", - "enum": [ - "legacy" - ], - "type": "string" - }, - { - "description": "Use primarily the Javascript-based implementation, but also schedule background jobs to run the Rust implementation and compare results, logging warnings if the implementations disagree.\n\nRaises an error at Router startup if the the new planner does not support the schema (such as using legacy Apollo Federation 1)", - "enum": [ - "both" - ], - "type": "string" - }, - { - "description": "Use primarily the Javascript-based implementation, but also schedule on a best-effort basis background jobs to run the Rust implementation and compare results, logging warnings if the implementations disagree.\n\nFalls back to `legacy` with a warning if the the new planner does not support the schema (such as using legacy Apollo Federation 1)", - "enum": [ - "both_best_effort" - ], - "type": "string" - } - ] + }, + "required": [ + "urls" + ], + "type": "object" }, "QueryPlanning": { "additionalProperties": false, @@ -4487,9 +5341,9 @@ snapshot_kind: text "$ref": "#/definitions/QueryPlanCache", "description": "#/definitions/QueryPlanCache" }, - "experimental_parallelism": { - "$ref": "#/definitions/AvailableParallelism", - "description": "#/definitions/AvailableParallelism" + "experimental_cooperative_cancellation": { + "$ref": "#/definitions/CooperativeCancellation", + "description": "#/definitions/CooperativeCancellation" }, "experimental_paths_limit": { "default": null, @@ -4569,6 +5423,43 @@ snapshot_kind: text ], "type": "object" }, + "ReadinessConfig": { + "additionalProperties": false, + "description": "Configuration options pertaining to the readiness health sub-component.", + "properties": { + "allowed": { + "default": 100, + "description": "How many rejections are allowed in an interval (default: 100) If this number is exceeded, the router will start to report unready.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "interval": { + "$ref": "#/definitions/ReadinessIntervalConfig", + "description": "#/definitions/ReadinessIntervalConfig" + } + }, + "type": "object" + }, + "ReadinessIntervalConfig": { + "additionalProperties": false, + "description": "Configuration options pertaining to the readiness health interval sub-component.", + "properties": { + "sampling": { + "default": "0s", + "description": "The sampling interval (default: 5s)", + "nullable": true, + "type": "string" + }, + "unready": { + "default": null, + "description": "The unready interval (default: 2 * sampling interval)", + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, "RecordConfig": { "additionalProperties": false, "description": "Request recording configuration.", @@ -4592,6 +5483,15 @@ snapshot_kind: text "additionalProperties": false, "description": "Redis cache configuration", "properties": { + "metrics_interval": { + "default": { + "nanos": 0, + "secs": 0 + }, + "description": "Interval for collecting Redis metrics (default: 1s)", + "nullable": true, + "type": "string" + }, "namespace": { "description": "namespace used to prefix Redis keys", "nullable": true, @@ -4705,6 +5605,24 @@ snapshot_kind: text ], "type": "object" }, + "ResourceSelectorConfig": { + "oneOf": [ + { + "description": "Export all resource attributes with every metrics.", + "enum": [ + "all" + ], + "type": "string" + }, + { + "description": "Do not export any resource attributes with every metrics.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, "ResponseStatus": { "oneOf": [ { @@ -4723,36 +5641,6 @@ snapshot_kind: text } ] }, - "RetryConfig": { - "additionalProperties": false, - "description": "Retry configuration", - "properties": { - "min_per_sec": { - "description": "minimum rate of retries allowed to accomodate clients that have just started issuing requests, or clients that do not issue many requests per window. The default value is 10", - "format": "uint32", - "minimum": 0.0, - "nullable": true, - "type": "integer" - }, - "retry_mutations": { - "description": "allows request retries on mutations. This should only be activated if mutations are idempotent. Disabled by default", - "nullable": true, - "type": "boolean" - }, - "retry_percent": { - "description": "percentage of calls to deposit that can be retried. This is in addition to any retries allowed for via min_per_sec. Must be between 0 and 1000, default value is 0.2", - "format": "float", - "nullable": true, - "type": "number" - }, - "ttl": { - "default": null, - "description": "how long a single deposit should be considered. Must be between 1 and 60 seconds, default value is 10 seconds", - "type": "string" - } - }, - "type": "object" - }, "Router": { "additionalProperties": false, "description": "Router level (APQ) configuration", @@ -4926,16 +5814,16 @@ snapshot_kind: text "description": "#/definitions/DefaultedStandardInstrument_for_ActiveRequestsAttributes" }, "http.server.request.body.size": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector" + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector" }, "http.server.request.duration": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector" + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector" }, "http.server.response.body.size": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector" + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector" } }, "type": "object" @@ -4955,9 +5843,8 @@ snapshot_kind: text "nullable": true }, "context": { - "default": false, - "description": "Send the context", - "type": "boolean" + "$ref": "#/definitions/ContextConf", + "description": "#/definitions/ContextConf" }, "headers": { "default": false, @@ -4993,13 +5880,11 @@ snapshot_kind: text }, "condition": { "$ref": "#/definitions/Condition_for_RouterSelector", - "description": "#/definitions/Condition_for_RouterSelector", - "nullable": true + "description": "#/definitions/Condition_for_RouterSelector" }, "context": { - "default": false, - "description": "Send the context", - "type": "boolean" + "$ref": "#/definitions/ContextConf", + "description": "#/definitions/ContextConf" }, "headers": { "default": false, @@ -5023,190 +5908,203 @@ snapshot_kind: text "anyOf": [ { "additionalProperties": false, - "description": "A header from the request", + "description": "A value from baggage.", "properties": { + "baggage": { + "description": "The name of the baggage item.", + "type": "string" + }, "default": { "$ref": "#/definitions/AttributeValue", "description": "#/definitions/AttributeValue", "nullable": true + } + }, + "required": [ + "baggage" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "A value from an environment variable.", + "properties": { + "default": { + "description": "Optional default value.", + "nullable": true, + "type": "string" }, - "request_header": { - "description": "The name of the request header.", + "env": { + "description": "The name of the environment variable", "type": "string" } }, "required": [ - "request_header" + "env" ], "type": "object" }, { "additionalProperties": false, - "description": "The request method.", + "description": "Critical error if it happens", "properties": { - "request_method": { - "description": "The request method enabled or not", + "error": { + "$ref": "#/definitions/ErrorRepr", + "description": "#/definitions/ErrorRepr" + } + }, + "required": [ + "error" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Boolean set to true if the response body contains graphql error", + "properties": { + "on_graphql_error": { "type": "boolean" } }, "required": [ - "request_method" + "on_graphql_error" ], "type": "object" }, { "additionalProperties": false, - "description": "A value from context.", + "description": "The operation name from the query.", "properties": { "default": { - "$ref": "#/definitions/AttributeValue", - "description": "#/definitions/AttributeValue", - "nullable": true - }, - "request_context": { - "description": "The request context key.", + "description": "Optional default value.", + "nullable": true, "type": "string" + }, + "operation_name": { + "$ref": "#/definitions/OperationName", + "description": "#/definitions/OperationName" } }, "required": [ - "request_context" + "operation_name" ], "type": "object" }, { "additionalProperties": false, - "description": "A header from the response", + "description": "A header from the request", "properties": { "default": { "$ref": "#/definitions/AttributeValue", "description": "#/definitions/AttributeValue", "nullable": true }, - "response_header": { + "request_header": { "description": "The name of the request header.", "type": "string" } }, "required": [ - "response_header" + "request_header" ], "type": "object" }, { "additionalProperties": false, - "description": "A status from the response", + "description": "A value from context.", "properties": { - "response_status": { - "$ref": "#/definitions/ResponseStatus", - "description": "#/definitions/ResponseStatus" + "default": { + "$ref": "#/definitions/AttributeValue", + "description": "#/definitions/AttributeValue", + "nullable": true + }, + "request_context": { + "description": "The request context key.", + "type": "string" } }, "required": [ - "response_status" + "request_context" ], "type": "object" }, { "additionalProperties": false, - "description": "The trace ID of the request.", + "description": "The request method.", "properties": { - "trace_id": { - "$ref": "#/definitions/TraceIdFormat", - "description": "#/definitions/TraceIdFormat" + "request_method": { + "description": "The request method enabled or not", + "type": "boolean" } }, "required": [ - "trace_id" + "request_method" ], "type": "object" }, { "additionalProperties": false, - "description": "Apollo Studio operation id", + "description": "The body of the response", "properties": { - "studio_operation_id": { - "description": "Apollo Studio operation id", + "response_body": { + "description": "The response body enabled or not", "type": "boolean" } }, "required": [ - "studio_operation_id" + "response_body" ], "type": "object" }, { "additionalProperties": false, - "description": "A value from context.", + "description": "A header from the response", "properties": { "default": { "$ref": "#/definitions/AttributeValue", "description": "#/definitions/AttributeValue", "nullable": true }, - "response_context": { - "description": "The response context key.", - "type": "string" - } - }, - "required": [ - "response_context" - ], - "type": "object" - }, - { - "additionalProperties": false, - "description": "The operation name from the query.", - "properties": { - "default": { - "description": "Optional default value.", - "nullable": true, + "response_header": { + "description": "The name of the request header.", "type": "string" - }, - "operation_name": { - "$ref": "#/definitions/OperationName", - "description": "#/definitions/OperationName" } }, "required": [ - "operation_name" + "response_header" ], "type": "object" }, { "additionalProperties": false, - "description": "A value from baggage.", + "description": "A value from context.", "properties": { - "baggage": { - "description": "The name of the baggage item.", - "type": "string" - }, "default": { "$ref": "#/definitions/AttributeValue", "description": "#/definitions/AttributeValue", "nullable": true + }, + "response_context": { + "description": "The response context key.", + "type": "string" } }, "required": [ - "baggage" + "response_context" ], "type": "object" }, { "additionalProperties": false, - "description": "A value from an environment variable.", + "description": "A status from the response", "properties": { - "default": { - "description": "Optional default value.", - "nullable": true, - "type": "string" - }, - "env": { - "description": "The name of the environment variable", - "type": "string" + "response_status": { + "$ref": "#/definitions/ResponseStatus", + "description": "#/definitions/ResponseStatus" } }, "required": [ - "env" + "response_status" ], "type": "object" }, @@ -5229,27 +6127,29 @@ snapshot_kind: text }, { "additionalProperties": false, + "description": "Apollo Studio operation id", "properties": { - "on_graphql_error": { - "description": "Boolean set to true if the response body contains graphql error", + "studio_operation_id": { + "description": "Apollo Studio operation id", "type": "boolean" } }, "required": [ - "on_graphql_error" + "studio_operation_id" ], "type": "object" }, { "additionalProperties": false, + "description": "The trace ID of the request.", "properties": { - "error": { - "$ref": "#/definitions/ErrorRepr", - "description": "#/definitions/ErrorRepr" + "trace_id": { + "$ref": "#/definitions/TraceIdFormat", + "description": "#/definitions/TraceIdFormat" } }, "required": [ - "error" + "trace_id" ], "type": "object" } @@ -5258,6 +6158,13 @@ snapshot_kind: text "RouterShaping": { "additionalProperties": false, "properties": { + "concurrency_limit": { + "description": "The global concurrency limit", + "format": "uint", + "minimum": 0.0, + "nullable": true, + "type": "integer" + }, "global_rate_limit": { "$ref": "#/definitions/RateLimitConf", "description": "#/definitions/RateLimitConf", @@ -5275,8 +6182,8 @@ snapshot_kind: text "additionalProperties": false, "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional" } }, "type": "object" @@ -5346,8 +6253,20 @@ snapshot_kind: text "description": "Set to true to enable sandbox", "type": "boolean" } - }, - "type": "object" + }, + "type": "object" + }, + "SelectorOrValue_for_ConnectorSelector": { + "anyOf": [ + { + "$ref": "#/definitions/AttributeValue", + "description": "#/definitions/AttributeValue" + }, + { + "$ref": "#/definitions/ConnectorSelector", + "description": "#/definitions/ConnectorSelector" + } + ] }, "SelectorOrValue_for_GraphQLSelector": { "anyOf": [ @@ -5397,8 +6316,30 @@ snapshot_kind: text } ] }, - "SocketEndpoint": { - "type": "string" + "Server": { + "additionalProperties": false, + "properties": { + "http": { + "$ref": "#/definitions/ServerHttpConfig", + "description": "#/definitions/ServerHttpConfig" + } + }, + "type": "object" + }, + "ServerHttpConfig": { + "additionalProperties": false, + "description": "Configuration for HTTP", + "properties": { + "header_read_timeout": { + "default": { + "nanos": 0, + "secs": 10 + }, + "description": "Header read timeout in human-readable format; defaults to 10s", + "type": "string" + } + }, + "type": "object" }, "Source": { "oneOf": [ @@ -5449,6 +6390,34 @@ snapshot_kind: text } ] }, + "SourceConfiguration": { + "additionalProperties": false, + "description": "Configuration for a `@source` directive", + "properties": { + "$config": { + "additionalProperties": true, + "default": {}, + "description": "Other values that can be used by connectors via `{$config.}`", + "type": "object" + }, + "max_requests_per_operation": { + "default": null, + "description": "The maximum number of requests for this source", + "format": "uint", + "minimum": 0.0, + "nullable": true, + "type": "integer" + }, + "override_url": { + "default": null, + "description": "Override the `@source(http: {baseURL:})`", + "format": "uri", + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, "SpanMode": { "description": "Span mode to create new or deprecated spans", "oneOf": [ @@ -5471,6 +6440,10 @@ snapshot_kind: text "Spans": { "additionalProperties": false, "properties": { + "connector": { + "$ref": "#/definitions/ConnectorSpans", + "description": "#/definitions/ConnectorSpans" + }, "default_attribute_requirement_level": { "$ref": "#/definitions/DefaultAttributeRequirementLevel", "description": "#/definitions/DefaultAttributeRequirementLevel" @@ -5520,11 +6493,36 @@ snapshot_kind: text } ] }, + "StandardEventConfig_for_ConnectorSelector": { + "anyOf": [ + { + "$ref": "#/definitions/EventLevelConfig", + "description": "#/definitions/EventLevelConfig" + }, + { + "properties": { + "condition": { + "$ref": "#/definitions/Condition_for_ConnectorSelector", + "description": "#/definitions/Condition_for_ConnectorSelector" + }, + "level": { + "$ref": "#/definitions/EventLevelConfig", + "description": "#/definitions/EventLevelConfig" + } + }, + "required": [ + "condition", + "level" + ], + "type": "object" + } + ] + }, "StandardEventConfig_for_RouterSelector": { "anyOf": [ { - "$ref": "#/definitions/EventLevel", - "description": "#/definitions/EventLevel" + "$ref": "#/definitions/EventLevelConfig", + "description": "#/definitions/EventLevelConfig" }, { "properties": { @@ -5533,8 +6531,8 @@ snapshot_kind: text "description": "#/definitions/Condition_for_RouterSelector" }, "level": { - "$ref": "#/definitions/EventLevel", - "description": "#/definitions/EventLevel" + "$ref": "#/definitions/EventLevelConfig", + "description": "#/definitions/EventLevelConfig" } }, "required": [ @@ -5548,8 +6546,8 @@ snapshot_kind: text "StandardEventConfig_for_SubgraphSelector": { "anyOf": [ { - "$ref": "#/definitions/EventLevel", - "description": "#/definitions/EventLevel" + "$ref": "#/definitions/EventLevelConfig", + "description": "#/definitions/EventLevelConfig" }, { "properties": { @@ -5558,8 +6556,8 @@ snapshot_kind: text "description": "#/definitions/Condition_for_SubgraphSelector" }, "level": { - "$ref": "#/definitions/EventLevel", - "description": "#/definitions/EventLevel" + "$ref": "#/definitions/EventLevelConfig", + "description": "#/definitions/EventLevelConfig" } }, "required": [ @@ -5573,8 +6571,8 @@ snapshot_kind: text "StandardEventConfig_for_SupergraphSelector": { "anyOf": [ { - "$ref": "#/definitions/EventLevel", - "description": "#/definitions/EventLevel" + "$ref": "#/definitions/EventLevelConfig", + "description": "#/definitions/EventLevelConfig" }, { "properties": { @@ -5583,8 +6581,8 @@ snapshot_kind: text "description": "#/definitions/Condition_for_SupergraphSelector" }, "level": { - "$ref": "#/definitions/EventLevel", - "description": "#/definitions/EventLevel" + "$ref": "#/definitions/EventLevelConfig", + "description": "#/definitions/EventLevelConfig" } }, "required": [ @@ -5690,11 +6688,12 @@ snapshot_kind: text }, "Subgraph": { "additionalProperties": false, - "description": "Per subgraph configuration for entity caching", + "description": "Per subgraph configuration for response caching", "properties": { "enabled": { "default": true, "description": "activates caching for this subgraph, overrides the global configuration", + "nullable": true, "type": "boolean" }, "invalidation": { @@ -5702,6 +6701,40 @@ snapshot_kind: text "description": "#/definitions/SubgraphInvalidationConfig", "nullable": true }, + "postgres": { + "$ref": "#/definitions/PostgresCacheConfig", + "description": "#/definitions/PostgresCacheConfig", + "nullable": true + }, + "private_id": { + "default": null, + "description": "Context key used to separate cache sections per user", + "nullable": true, + "type": "string" + }, + "ttl": { + "$ref": "#/definitions/Ttl", + "description": "#/definitions/Ttl", + "nullable": true + } + }, + "type": "object" + }, + "Subgraph2": { + "additionalProperties": false, + "description": "Per subgraph configuration for entity caching", + "properties": { + "enabled": { + "default": true, + "description": "activates caching for this subgraph, overrides the global configuration", + "nullable": true, + "type": "boolean" + }, + "invalidation": { + "$ref": "#/definitions/SubgraphInvalidationConfig2", + "description": "#/definitions/SubgraphInvalidationConfig2", + "nullable": true + }, "private_id": { "default": null, "description": "Context key used to separate cache sections per user", @@ -5714,8 +6747,8 @@ snapshot_kind: text "nullable": true }, "ttl": { - "$ref": "#/definitions/Ttl", - "description": "#/definitions/Ttl", + "$ref": "#/definitions/Ttl2", + "description": "#/definitions/Ttl2", "nullable": true } }, @@ -5736,6 +6769,11 @@ snapshot_kind: text "SubgraphAttributes": { "additionalProperties": false, "properties": { + "http.request.resend_count": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, "subgraph.graphql.document": { "$ref": "#/definitions/StandardAttribute", "description": "#/definitions/StandardAttribute", @@ -5759,25 +6797,126 @@ snapshot_kind: text }, "type": "object" }, - "SubgraphAttributesConf": { - "additionalProperties": false, - "description": "Configuration to add custom attributes/labels on metrics to subgraphs", + "SubgraphConfig": { + "description": "Configuration for one subgraph for the `mock_subgraphs` plugin", "properties": { - "all": { - "$ref": "#/definitions/AttributesForwardConf", - "description": "#/definitions/AttributesForwardConf" + "entities": { + "default": [], + "description": "Entities that can be queried through Federation’s special `_entities` field\n\nIn maps directly in the top-level `Vec` (but not in other maps nested deeper), the `__cacheTags` key is special. Instead of representing a field that can be selected, when its parent entity is selected its contents are added to the `response.extensions[\"apolloEntityCacheTags\"]` array.", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, - "subgraphs": { + "headers": { "additionalProperties": { - "$ref": "#/definitions/AttributesForwardConf", - "description": "#/definitions/AttributesForwardConf" + "type": "string" }, - "description": "Attributes per subgraph", + "description": "HTTP headers for the subgraph response", + "type": "object" + }, + "mutation": { + "additionalProperties": true, + "default": null, + "description": "Data for `mutation` operations", + "nullable": true, + "type": "object" + }, + "query": { + "additionalProperties": true, + "default": {}, + "description": "Data for `query` operations (excluding the special `_entities` field)\n\nIn maps nested in this one (but not at the top level), the `__cacheTags` key is special. Instead of representing a field that can be selected, when its parent field is selected its value is expected to be an array which is appended to the `response.extensions[\"apolloCacheTags\"]` array.", "type": "object" } }, "type": "object" }, + "SubgraphConfig2": { + "anyOf": [ + { + "description": "Enable or disable error redaction for a subgraph", + "type": "boolean" + }, + { + "description": "Allow specific error extension keys for a subgraph", + "properties": { + "allow_extensions_keys": { + "description": "Allow specific extension keys for a subgraph. Will extending global allow list or override a global deny list", + "items": { + "type": "string" + }, + "type": "array" + }, + "exclude_global_keys": { + "default": [], + "description": "Exclude specific extension keys from global allow/deny list", + "items": { + "type": "string" + }, + "type": "array" + }, + "redact_message": { + "description": "Redact error messages for a subgraph", + "nullable": true, + "type": "boolean" + } + }, + "required": [ + "allow_extensions_keys" + ], + "type": "object" + }, + { + "description": "Deny specific error extension keys for a subgraph", + "properties": { + "deny_extensions_keys": { + "description": "Allow specific extension keys for a subgraph. Will extending global deny list or override a global allow list", + "items": { + "type": "string" + }, + "type": "array" + }, + "exclude_global_keys": { + "default": [], + "description": "Exclude specific extension keys from global allow/deny list", + "items": { + "type": "string" + }, + "type": "array" + }, + "redact_message": { + "description": "Redact error messages for a subgraph", + "nullable": true, + "type": "boolean" + } + }, + "required": [ + "deny_extensions_keys" + ], + "type": "object" + }, + { + "description": "Override global configuration, but don't allow or deny any new keys explicitly", + "properties": { + "exclude_global_keys": { + "default": [], + "description": "Exclude specific extension keys from global allow/deny list", + "items": { + "type": "string" + }, + "type": "array" + }, + "redact_message": { + "description": "Redact error messages for a subgraph", + "nullable": true, + "type": "boolean" + } + }, + "type": "object" + } + ] + }, "SubgraphConfiguration_for_CommonBatchingConfig": { "description": "Configuration options pertaining to the subgraph server component.", "properties": { @@ -5816,6 +6955,25 @@ snapshot_kind: text }, "type": "object" }, + "SubgraphConfiguration_for_Subgraph2": { + "description": "Configuration options pertaining to the subgraph server component.", + "properties": { + "all": { + "$ref": "#/definitions/Subgraph2", + "description": "#/definitions/Subgraph2" + }, + "subgraphs": { + "additionalProperties": { + "$ref": "#/definitions/Subgraph2", + "description": "#/definitions/Subgraph2" + }, + "default": {}, + "description": "per subgraph options", + "type": "object" + } + }, + "type": "object" + }, "SubgraphConfiguration_for_SubgraphApq": { "description": "Configuration options pertaining to the subgraph server component.", "properties": { @@ -5854,6 +7012,28 @@ snapshot_kind: text }, "type": "object" }, + "SubgraphConnectorConfiguration": { + "additionalProperties": false, + "description": "Configuration for a connector subgraph", + "properties": { + "$config": { + "additionalProperties": true, + "default": {}, + "description": "Other values that can be used by connectors via `{$config.}`", + "type": "object" + }, + "sources": { + "additionalProperties": { + "$ref": "#/definitions/SourceConfiguration", + "description": "#/definitions/SourceConfiguration" + }, + "default": {}, + "description": "A map of `@source(name:)` to configuration for that source", + "type": "object" + } + }, + "type": "object" + }, "SubgraphErrorConfig": { "additionalProperties": false, "properties": { @@ -5894,16 +7074,16 @@ snapshot_kind: text "additionalProperties": false, "properties": { "http.client.request.body.size": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" }, "http.client.request.duration": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" }, "http.client.response.body.size": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" } }, "type": "object" @@ -5924,6 +7104,22 @@ snapshot_kind: text }, "type": "object" }, + "SubgraphInvalidationConfig2": { + "additionalProperties": false, + "properties": { + "enabled": { + "default": false, + "description": "Enable the invalidation", + "type": "boolean" + }, + "shared_key": { + "default": "", + "description": "Shared key needed to request the invalidation endpoint", + "type": "string" + } + }, + "type": "object" + }, "SubgraphPassthroughMode": { "additionalProperties": false, "properties": { @@ -5966,13 +7162,11 @@ snapshot_kind: text }, "condition": { "$ref": "#/definitions/Condition_for_SubgraphSelector", - "description": "#/definitions/Condition_for_SubgraphSelector", - "nullable": true + "description": "#/definitions/Condition_for_SubgraphSelector" }, "context": { - "default": false, - "description": "Send the context", - "type": "boolean" + "$ref": "#/definitions/ContextConf", + "description": "#/definitions/ContextConf" }, "headers": { "default": false, @@ -6013,13 +7207,11 @@ snapshot_kind: text }, "condition": { "$ref": "#/definitions/Condition_for_SubgraphSelector", - "description": "#/definitions/Condition_for_SubgraphSelector", - "nullable": true + "description": "#/definitions/Condition_for_SubgraphSelector" }, "context": { - "default": false, - "description": "Send the context", - "type": "boolean" + "$ref": "#/definitions/ContextConf", + "description": "#/definitions/ContextConf" }, "headers": { "default": false, @@ -6126,25 +7318,6 @@ snapshot_kind: text ], "type": "object" }, - { - "additionalProperties": false, - "description": "Deprecated, use SubgraphResponseData and SubgraphResponseError instead", - "properties": { - "default": { - "$ref": "#/definitions/AttributeValue", - "description": "#/definitions/AttributeValue", - "nullable": true - }, - "subgraph_response_body": { - "description": "The subgraph response body json path.", - "type": "string" - } - }, - "required": [ - "subgraph_response_body" - ], - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -6226,7 +7399,25 @@ snapshot_kind: text } }, "required": [ - "subgraph_response_status" + "subgraph_response_status" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "default": { + "$ref": "#/definitions/AttributeValue", + "description": "#/definitions/AttributeValue", + "nullable": true + }, + "subgraph_resend_count": { + "description": "The subgraph http resend count", + "type": "boolean" + } + }, + "required": [ + "subgraph_resend_count" ], "type": "object" }, @@ -6474,11 +7665,6 @@ snapshot_kind: text "description": "#/definitions/Http2Config", "nullable": true }, - "experimental_retry": { - "$ref": "#/definitions/RetryConfig", - "description": "#/definitions/RetryConfig", - "nullable": true - }, "global_rate_limit": { "$ref": "#/definitions/RateLimitConf", "description": "#/definitions/RateLimitConf", @@ -6496,8 +7682,8 @@ snapshot_kind: text "additionalProperties": false, "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional" } }, "type": "object" @@ -6544,10 +7730,9 @@ snapshot_kind: text "additionalProperties": false, "description": "Subscriptions configuration", "properties": { - "enable_deduplication": { - "default": true, - "description": "Enable the deduplication of subscription (for example if we detect the exact same request to subgraph we won't open a new websocket to the subgraph in passthrough mode) (default: true)", - "type": "boolean" + "deduplication": { + "$ref": "#/definitions/DeduplicationConfig", + "description": "#/definitions/DeduplicationConfig" }, "enabled": { "default": true, @@ -6597,6 +7782,14 @@ snapshot_kind: text "additionalProperties": false, "description": "Configuration options pertaining to the supergraph server component.", "properties": { + "connection_shutdown_timeout": { + "default": { + "nanos": 0, + "secs": 60 + }, + "description": "The timeout for shutting down connections during a router shutdown or a schema reload.", + "type": "string" + }, "defer_support": { "default": true, "description": "Set to false to disable defer support", @@ -6612,12 +7805,6 @@ snapshot_kind: text "description": "Log a message if the client closes the connection before the response is sent. Default: false.", "type": "boolean" }, - "experimental_reuse_query_fragments": { - "default": null, - "description": "Enable reuse of query fragments Default: depends on the federation version", - "nullable": true, - "type": "boolean" - }, "generate_query_fragments": { "default": true, "description": "Enable QP generation of fragments for subgraph requests Default: true", @@ -6708,16 +7895,16 @@ snapshot_kind: text "additionalProperties": false, "properties": { "cost.actual": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector" + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector" }, "cost.delta": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector" + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector" }, "cost.estimated": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector" + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector" } }, "type": "object" @@ -6733,13 +7920,11 @@ snapshot_kind: text }, "condition": { "$ref": "#/definitions/Condition_for_SupergraphSelector", - "description": "#/definitions/Condition_for_SupergraphSelector", - "nullable": true + "description": "#/definitions/Condition_for_SupergraphSelector" }, "context": { - "default": false, - "description": "Send the context", - "type": "boolean" + "$ref": "#/definitions/ContextConf", + "description": "#/definitions/ContextConf" }, "headers": { "default": false, @@ -6770,13 +7955,11 @@ snapshot_kind: text }, "condition": { "$ref": "#/definitions/Condition_for_SupergraphSelector", - "description": "#/definitions/Condition_for_SupergraphSelector", - "nullable": true + "description": "#/definitions/Condition_for_SupergraphSelector" }, "context": { - "default": false, - "description": "Send the context", - "type": "boolean" + "$ref": "#/definitions/ContextConf", + "description": "#/definitions/ContextConf" }, "headers": { "default": false, @@ -7100,8 +8283,8 @@ snapshot_kind: text "additionalProperties": false, "properties": { "attributes": { - "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional", - "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional" + "$ref": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional", + "description": "#/definitions/extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional" } }, "type": "object" @@ -7173,6 +8356,10 @@ snapshot_kind: text "additionalProperties": false, "description": "TLS related configuration options.", "properties": { + "connector": { + "$ref": "#/definitions/ConnectorConfiguration_for_TlsClient", + "description": "#/definitions/ConnectorConfiguration_for_TlsClient" + }, "subgraph": { "$ref": "#/definitions/SubgraphConfiguration_for_TlsClient", "description": "#/definitions/SubgraphConfiguration_for_TlsClient" @@ -7224,6 +8411,44 @@ snapshot_kind: text ], "type": "object" }, + "TlsClientAuth2": { + "additionalProperties": false, + "description": "TLS client authentication", + "properties": { + "certificate": { + "description": "Sets the SSL client certificate as a PEM", + "type": "string" + }, + "key": { + "description": "key in PEM format", + "type": "string" + } + }, + "required": [ + "certificate", + "key" + ], + "type": "object" + }, + "TlsConfig": { + "additionalProperties": false, + "description": "Postgres TLS client configuration", + "properties": { + "certificate_authorities": { + "description": "list of certificate authorities in PEM format", + "type": "string" + }, + "client_authentication": { + "$ref": "#/definitions/TlsClientAuth2", + "description": "#/definitions/TlsClientAuth2", + "nullable": true + } + }, + "required": [ + "certificate_authorities" + ], + "type": "object" + }, "TlsSupergraph": { "additionalProperties": false, "description": "Configuration options pertaining to the supergraph server component.", @@ -7299,28 +8524,24 @@ snapshot_kind: text "description": "#/definitions/TracingCommon" }, "datadog": { - "$ref": "#/definitions/Config14", - "description": "#/definitions/Config14" + "$ref": "#/definitions/Config18", + "description": "#/definitions/Config18" }, "experimental_response_trace_id": { "$ref": "#/definitions/ExposeTraceId", "description": "#/definitions/ExposeTraceId" }, - "jaeger": { - "$ref": "#/definitions/Config12", - "description": "#/definitions/Config12" - }, "otlp": { - "$ref": "#/definitions/Config10", - "description": "#/definitions/Config10" + "$ref": "#/definitions/Config15", + "description": "#/definitions/Config15" }, "propagation": { "$ref": "#/definitions/Propagation", "description": "#/definitions/Propagation" }, "zipkin": { - "$ref": "#/definitions/Config13", - "description": "#/definitions/Config13" + "$ref": "#/definitions/Config17", + "description": "#/definitions/Config17" } }, "type": "object" @@ -7368,6 +8589,12 @@ snapshot_kind: text "description": "Whether to use parent based sampling", "type": "boolean" }, + "preview_datadog_agent_sampling": { + "default": null, + "description": "Use datadog agent sampling. This means that all spans will be sent to the Datadog agent and the `sampling.priority` attribute will be used to control if the span will then be sent to Datadog", + "nullable": true, + "type": "boolean" + }, "resource": { "additionalProperties": { "$ref": "#/definitions/AttributeValue", @@ -7397,6 +8624,10 @@ snapshot_kind: text "type": "object" }, "Ttl": { + "description": "Per subgraph configuration for response caching", + "type": "string" + }, + "Ttl2": { "description": "Per subgraph configuration for entity caching", "type": "string" }, @@ -7442,7 +8673,23 @@ snapshot_kind: text ], "type": "string" }, - "conditional_attribute_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector": { + "conditional_attribute_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector": { + "anyOf": [ + { + "$ref": "#/definitions/ConnectorSelector", + "description": "#/definitions/ConnectorSelector" + }, + { + "properties": { + "condition": { + "$ref": "#/definitions/Condition_for_ConnectorSelector", + "description": "#/definitions/Condition_for_ConnectorSelector" + } + } + } + ] + }, + "conditional_attribute_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector": { "anyOf": [ { "$ref": "#/definitions/RouterSelector", @@ -7458,7 +8705,7 @@ snapshot_kind: text } ] }, - "conditional_attribute_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector": { + "conditional_attribute_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector": { "anyOf": [ { "$ref": "#/definitions/SubgraphSelector", @@ -7474,7 +8721,7 @@ snapshot_kind: text } ] }, - "conditional_attribute_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector": { + "conditional_attribute_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector": { "anyOf": [ { "$ref": "#/definitions/SupergraphSelector", @@ -7490,10 +8737,188 @@ snapshot_kind: text } ] }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional": { + "extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::CacheInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument": { + "additionalProperties": { + "$ref": "#/definitions/Instrument_for_CacheAttributes_and_SubgraphSelector_and_SubgraphValue", + "description": "#/definitions/Instrument_for_CacheAttributes_and_SubgraphSelector_and_SubgraphValue" + }, + "properties": { + "apollo.router.operations.entity.cache": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" + } + }, + "type": "object" + }, + "extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector": { + "additionalProperties": { + "$ref": "#/definitions/SubgraphSelector", + "description": "#/definitions/SubgraphSelector" + }, + "properties": { + "graphql.type.name": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + } + }, + "type": "object" + }, + "extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional": { + "additionalProperties": { + "$ref": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector", + "description": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector" + }, + "properties": { + "connector.http.method": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "connector.source.name": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "connector.url.template": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "subgraph.name": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + } + }, + "type": "object" + }, + "extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector": { + "additionalProperties": { + "$ref": "#/definitions/ConnectorSelector", + "description": "#/definitions/ConnectorSelector" + }, + "properties": { + "connector.http.method": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "connector.source.name": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "connector.url.template": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "subgraph.name": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + } + }, + "type": "object" + }, + "extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::events::ConnectorEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event": { + "additionalProperties": { + "$ref": "#/definitions/Event_for_ConnectorAttributes_and_ConnectorSelector", + "description": "#/definitions/Event_for_ConnectorAttributes_and_ConnectorSelector" + }, + "properties": { + "error": { + "$ref": "#/definitions/StandardEventConfig_for_ConnectorSelector", + "description": "#/definitions/StandardEventConfig_for_ConnectorSelector" + }, + "request": { + "$ref": "#/definitions/StandardEventConfig_for_ConnectorSelector", + "description": "#/definitions/StandardEventConfig_for_ConnectorSelector" + }, + "response": { + "$ref": "#/definitions/StandardEventConfig_for_ConnectorSelector", + "description": "#/definitions/StandardEventConfig_for_ConnectorSelector" + } + }, + "type": "object" + }, + "extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::instruments::ConnectorInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument": { + "additionalProperties": { + "$ref": "#/definitions/Instrument_for_ConnectorAttributes_and_ConnectorSelector_and_ConnectorValue", + "description": "#/definitions/Instrument_for_ConnectorAttributes_and_ConnectorSelector_and_ConnectorValue" + }, + "properties": { + "http.client.request.body.size": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector" + }, + "http.client.request.duration": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector" + }, + "http.client.response.body.size": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes_apollo_router::plugins::telemetry::config_new::connector::selectors::ConnectorSelector" + } + }, + "type": "object" + }, + "extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::GraphQLInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument": { + "additionalProperties": { + "$ref": "#/definitions/Instrument_for_GraphQLAttributes_and_GraphQLSelector_and_GraphQLValue", + "description": "#/definitions/Instrument_for_GraphQLAttributes_and_GraphQLSelector_and_GraphQLValue" + }, + "properties": { + "field.execution": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector" + }, + "list.length": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector" + } + }, + "type": "object" + }, + "extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector": { + "additionalProperties": { + "$ref": "#/definitions/GraphQLSelector", + "description": "#/definitions/GraphQLSelector" + }, + "properties": { + "graphql.field.name": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "graphql.field.type": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "graphql.list.length": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "graphql.operation.name": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "graphql.type.name": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + } + }, + "type": "object" + }, + "extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional": { "additionalProperties": { - "$ref": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector", - "description": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector" + "$ref": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector", + "description": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector" }, "description": "Common attributes for http server and client. See https://opentelemetry.io/docs/specs/semconv/http/http-spans/#common-attributes", "properties": { @@ -7616,7 +9041,7 @@ snapshot_kind: text }, "type": "object" }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector": { + "extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector": { "additionalProperties": { "$ref": "#/definitions/RouterSelector", "description": "#/definitions/RouterSelector" @@ -7719,35 +9144,86 @@ snapshot_kind: text "description": "#/definitions/StandardAttribute", "nullable": true }, - "url.path": { - "$ref": "#/definitions/StandardAttribute", - "description": "#/definitions/StandardAttribute", - "nullable": true + "url.path": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "url.query": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "url.scheme": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, + "user_agent.original": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + } + }, + "type": "object" + }, + "extendable_attribute_apollo_router::plugins::telemetry::config_new::router::events::RouterEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event": { + "additionalProperties": { + "$ref": "#/definitions/Event_for_RouterAttributes_and_RouterSelector", + "description": "#/definitions/Event_for_RouterAttributes_and_RouterSelector" + }, + "properties": { + "error": { + "$ref": "#/definitions/StandardEventConfig_for_RouterSelector", + "description": "#/definitions/StandardEventConfig_for_RouterSelector" + }, + "request": { + "$ref": "#/definitions/StandardEventConfig_for_RouterSelector", + "description": "#/definitions/StandardEventConfig_for_RouterSelector" + }, + "response": { + "$ref": "#/definitions/StandardEventConfig_for_RouterSelector", + "description": "#/definitions/StandardEventConfig_for_RouterSelector" + } + }, + "type": "object" + }, + "extendable_attribute_apollo_router::plugins::telemetry::config_new::router::instruments::RouterInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument": { + "additionalProperties": { + "$ref": "#/definitions/Instrument_for_RouterAttributes_and_RouterSelector_and_RouterValue", + "description": "#/definitions/Instrument_for_RouterAttributes_and_RouterSelector_and_RouterValue" + }, + "properties": { + "http.server.active_requests": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_ActiveRequestsAttributes", + "description": "#/definitions/DefaultedStandardInstrument_for_ActiveRequestsAttributes" }, - "url.query": { - "$ref": "#/definitions/StandardAttribute", - "description": "#/definitions/StandardAttribute", - "nullable": true + "http.server.request.body.size": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector" }, - "url.scheme": { - "$ref": "#/definitions/StandardAttribute", - "description": "#/definitions/StandardAttribute", - "nullable": true + "http.server.request.duration": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector" }, - "user_agent.original": { - "$ref": "#/definitions/StandardAttribute", - "description": "#/definitions/StandardAttribute", - "nullable": true + "http.server.response.body.size": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::router::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::router::selectors::RouterSelector" } }, "type": "object" }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional": { + "extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional": { "additionalProperties": { - "$ref": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" + "$ref": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" }, "properties": { + "http.request.resend_count": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, "subgraph.graphql.document": { "$ref": "#/definitions/StandardAttribute", "description": "#/definitions/StandardAttribute", @@ -7771,12 +9247,17 @@ snapshot_kind: text }, "type": "object" }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector": { + "extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector": { "additionalProperties": { "$ref": "#/definitions/SubgraphSelector", "description": "#/definitions/SubgraphSelector" }, "properties": { + "http.request.resend_count": { + "$ref": "#/definitions/StandardAttribute", + "description": "#/definitions/StandardAttribute", + "nullable": true + }, "subgraph.graphql.document": { "$ref": "#/definitions/StandardAttribute", "description": "#/definitions/StandardAttribute", @@ -7800,10 +9281,52 @@ snapshot_kind: text }, "type": "object" }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional": { + "extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::events::SubgraphEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event": { + "additionalProperties": { + "$ref": "#/definitions/Event_for_SubgraphAttributes_and_SubgraphSelector", + "description": "#/definitions/Event_for_SubgraphAttributes_and_SubgraphSelector" + }, + "properties": { + "error": { + "$ref": "#/definitions/StandardEventConfig_for_SubgraphSelector", + "description": "#/definitions/StandardEventConfig_for_SubgraphSelector" + }, + "request": { + "$ref": "#/definitions/StandardEventConfig_for_SubgraphSelector", + "description": "#/definitions/StandardEventConfig_for_SubgraphSelector" + }, + "response": { + "$ref": "#/definitions/StandardEventConfig_for_SubgraphSelector", + "description": "#/definitions/StandardEventConfig_for_SubgraphSelector" + } + }, + "type": "object" + }, + "extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::instruments::SubgraphInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument": { "additionalProperties": { - "$ref": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector", - "description": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector" + "$ref": "#/definitions/Instrument_for_SubgraphAttributes_and_SubgraphSelector_and_SubgraphValue", + "description": "#/definitions/Instrument_for_SubgraphAttributes_and_SubgraphSelector_and_SubgraphValue" + }, + "properties": { + "http.client.request.body.size": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" + }, + "http.client.request.duration": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" + }, + "http.client.response.body.size": { + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector" + } + }, + "type": "object" + }, + "extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::conditional::Conditional": { + "additionalProperties": { + "$ref": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector", + "description": "#/definitions/conditional_attribute_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector" }, "description": "Attributes for Cost", "properties": { @@ -7845,7 +9368,7 @@ snapshot_kind: text }, "type": "object" }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector": { + "extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector": { "additionalProperties": { "$ref": "#/definitions/SupergraphSelector", "description": "#/definitions/SupergraphSelector" @@ -7890,76 +9413,7 @@ snapshot_kind: text }, "type": "object" }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::CacheInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument": { - "additionalProperties": { - "$ref": "#/definitions/Instrument_for_CacheAttributes_and_SubgraphSelector_and_SubgraphValue", - "description": "#/definitions/Instrument_for_CacheAttributes_and_SubgraphSelector_and_SubgraphValue" - }, - "properties": { - "apollo.router.operations.entity.cache": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" - } - }, - "type": "object" - }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::cache::attributes::CacheAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector": { - "additionalProperties": { - "$ref": "#/definitions/SubgraphSelector", - "description": "#/definitions/SubgraphSelector" - }, - "properties": { - "graphql.type.name": { - "$ref": "#/definitions/StandardAttribute", - "description": "#/definitions/StandardAttribute", - "nullable": true - } - }, - "type": "object" - }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::events::RouterEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event": { - "additionalProperties": { - "$ref": "#/definitions/Event_for_RouterAttributes_and_RouterSelector", - "description": "#/definitions/Event_for_RouterAttributes_and_RouterSelector" - }, - "properties": { - "error": { - "$ref": "#/definitions/StandardEventConfig_for_RouterSelector", - "description": "#/definitions/StandardEventConfig_for_RouterSelector" - }, - "request": { - "$ref": "#/definitions/StandardEventConfig_for_RouterSelector", - "description": "#/definitions/StandardEventConfig_for_RouterSelector" - }, - "response": { - "$ref": "#/definitions/StandardEventConfig_for_RouterSelector", - "description": "#/definitions/StandardEventConfig_for_RouterSelector" - } - }, - "type": "object" - }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::events::SubgraphEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event": { - "additionalProperties": { - "$ref": "#/definitions/Event_for_SubgraphAttributes_and_SubgraphSelector", - "description": "#/definitions/Event_for_SubgraphAttributes_and_SubgraphSelector" - }, - "properties": { - "error": { - "$ref": "#/definitions/StandardEventConfig_for_SubgraphSelector", - "description": "#/definitions/StandardEventConfig_for_SubgraphSelector" - }, - "request": { - "$ref": "#/definitions/StandardEventConfig_for_SubgraphSelector", - "description": "#/definitions/StandardEventConfig_for_SubgraphSelector" - }, - "response": { - "$ref": "#/definitions/StandardEventConfig_for_SubgraphSelector", - "description": "#/definitions/StandardEventConfig_for_SubgraphSelector" - } - }, - "type": "object" - }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::events::SupergraphEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event": { + "extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::events::SupergraphEventsConfig_apollo_router::plugins::telemetry::config_new::events::Event": { "additionalProperties": { "$ref": "#/definitions/Event_for_SupergraphAttributes_and_SupergraphSelector", "description": "#/definitions/Event_for_SupergraphAttributes_and_SupergraphSelector" @@ -7980,120 +9434,23 @@ snapshot_kind: text }, "type": "object" }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::GraphQLInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument": { - "additionalProperties": { - "$ref": "#/definitions/Instrument_for_GraphQLAttributes_and_GraphQLSelector_and_GraphQLValue", - "description": "#/definitions/Instrument_for_GraphQLAttributes_and_GraphQLSelector_and_GraphQLValue" - }, - "properties": { - "field.execution": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector" - }, - "list.length": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector" - } - }, - "type": "object" - }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes_apollo_router::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector": { - "additionalProperties": { - "$ref": "#/definitions/GraphQLSelector", - "description": "#/definitions/GraphQLSelector" - }, - "properties": { - "graphql.field.name": { - "$ref": "#/definitions/StandardAttribute", - "description": "#/definitions/StandardAttribute", - "nullable": true - }, - "graphql.field.type": { - "$ref": "#/definitions/StandardAttribute", - "description": "#/definitions/StandardAttribute", - "nullable": true - }, - "graphql.list.length": { - "$ref": "#/definitions/StandardAttribute", - "description": "#/definitions/StandardAttribute", - "nullable": true - }, - "graphql.operation.name": { - "$ref": "#/definitions/StandardAttribute", - "description": "#/definitions/StandardAttribute", - "nullable": true - }, - "graphql.type.name": { - "$ref": "#/definitions/StandardAttribute", - "description": "#/definitions/StandardAttribute", - "nullable": true - } - }, - "type": "object" - }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::instruments::RouterInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument": { - "additionalProperties": { - "$ref": "#/definitions/Instrument_for_RouterAttributes_and_RouterSelector_and_RouterValue", - "description": "#/definitions/Instrument_for_RouterAttributes_and_RouterSelector_and_RouterValue" - }, - "properties": { - "http.server.active_requests": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_ActiveRequestsAttributes", - "description": "#/definitions/DefaultedStandardInstrument_for_ActiveRequestsAttributes" - }, - "http.server.request.body.size": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector" - }, - "http.server.request.duration": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector" - }, - "http.server.response.body.size": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::RouterAttributes_apollo_router::plugins::telemetry::config_new::selectors::RouterSelector" - } - }, - "type": "object" - }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::instruments::SubgraphInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument": { - "additionalProperties": { - "$ref": "#/definitions/Instrument_for_SubgraphAttributes_and_SubgraphSelector_and_SubgraphValue", - "description": "#/definitions/Instrument_for_SubgraphAttributes_and_SubgraphSelector_and_SubgraphValue" - }, - "properties": { - "http.client.request.body.size": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" - }, - "http.client.request.duration": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" - }, - "http.client.response.body.size": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SubgraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SubgraphSelector" - } - }, - "type": "object" - }, - "extendable_attribute_apollo_router::plugins::telemetry::config_new::instruments::SupergraphInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument": { + "extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::instruments::SupergraphInstrumentsConfig_apollo_router::plugins::telemetry::config_new::instruments::Instrument": { "additionalProperties": { "$ref": "#/definitions/Instrument_for_SupergraphAttributes_and_SupergraphSelector_and_SupergraphValue", "description": "#/definitions/Instrument_for_SupergraphAttributes_and_SupergraphSelector_and_SupergraphValue" }, "properties": { "cost.actual": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector" + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector" }, "cost.delta": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector" + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector" }, "cost.estimated": { - "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector", - "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::selectors::SupergraphSelector" + "$ref": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector", + "description": "#/definitions/DefaultedStandardInstrument_for_extendable_attribute_apollo_router::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes_apollo_router::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector" } }, "type": "object" @@ -8292,6 +9649,19 @@ snapshot_kind: text } }, "description": "The configuration for the router.\n\nCan be created through `serde::Deserialize` from various formats, or inline in Rust code with `serde_json::json!` and `serde_json::from_value`.", + "patternProperties": { + "^experimental_mock_subgraphs$": { + "additionalProperties": { + "$ref": "#/definitions/SubgraphConfig", + "description": "#/definitions/SubgraphConfig" + }, + "type": "object" + }, + "^experimental_response_cache$": { + "$ref": "#/definitions/Config9", + "description": "#/definitions/Config9" + } + }, "properties": { "apq": { "$ref": "#/definitions/Apq", @@ -8309,6 +9679,10 @@ snapshot_kind: text "$ref": "#/definitions/Batching", "description": "#/definitions/Batching" }, + "connectors": { + "$ref": "#/definitions/ConnectorsConfig", + "description": "#/definitions/ConnectorsConfig" + }, "coprocessor": { "$ref": "#/definitions/Conf4", "description": "#/definitions/Conf4" @@ -8325,46 +9699,54 @@ snapshot_kind: text "$ref": "#/definitions/DemandControlConfig", "description": "#/definitions/DemandControlConfig" }, - "experimental_chaos": { - "$ref": "#/definitions/Chaos", - "description": "#/definitions/Chaos" + "enhanced_client_awareness": { + "$ref": "#/definitions/Config8", + "description": "#/definitions/Config8" }, - "experimental_query_planner_mode": { - "$ref": "#/definitions/QueryPlannerMode", - "description": "#/definitions/QueryPlannerMode" + "experimental_chaos": { + "$ref": "#/definitions/Config3", + "description": "#/definitions/Config3" }, "experimental_type_conditioned_fetching": { "default": false, "description": "Type conditioned fetching configuration.", "type": "boolean" }, + "fleet_detector": { + "$ref": "#/definitions/Conf5", + "description": "#/definitions/Conf5" + }, "forbid_mutations": { "$ref": "#/definitions/ForbidMutationsConfig", "description": "#/definitions/ForbidMutationsConfig" }, "headers": { - "$ref": "#/definitions/Config5", - "description": "#/definitions/Config5" + "$ref": "#/definitions/Config10", + "description": "#/definitions/Config10" }, "health_check": { - "$ref": "#/definitions/HealthCheck", - "description": "#/definitions/HealthCheck" + "$ref": "#/definitions/Config", + "description": "#/definitions/Config" }, "homepage": { "$ref": "#/definitions/Homepage", "description": "#/definitions/Homepage" }, "include_subgraph_errors": { - "$ref": "#/definitions/Config6", - "description": "#/definitions/Config6" + "$ref": "#/definitions/Config11", + "description": "#/definitions/Config11" + }, + "license_enforcement": { + "$ref": "#/definitions/LicenseEnforcementConfig", + "description": "#/definitions/LicenseEnforcementConfig" }, "limits": { - "$ref": "#/definitions/Config", - "description": "#/definitions/Config" + "$ref": "#/definitions/Config2", + "description": "#/definitions/Config2" }, "override_subgraph_url": { - "$ref": "#/definitions/Conf5", - "description": "#/definitions/Conf5" + "$ref": "#/definitions/Conf6", + "description": "#/definitions/Conf6" }, "persisted_queries": { "$ref": "#/definitions/PersistedQueries", @@ -8375,25 +9757,29 @@ snapshot_kind: text "description": "#/definitions/Plugins" }, "preview_entity_cache": { - "$ref": "#/definitions/Config7", - "description": "#/definitions/Config7" + "$ref": "#/definitions/Config12", + "description": "#/definitions/Config12" }, "preview_file_uploads": { "$ref": "#/definitions/FileUploadsConfig", "description": "#/definitions/FileUploadsConfig" }, "progressive_override": { - "$ref": "#/definitions/Config8", - "description": "#/definitions/Config8" + "$ref": "#/definitions/Config13", + "description": "#/definitions/Config13" }, "rhai": { - "$ref": "#/definitions/Conf6", - "description": "#/definitions/Conf6" + "$ref": "#/definitions/Conf7", + "description": "#/definitions/Conf7" }, "sandbox": { "$ref": "#/definitions/Sandbox", "description": "#/definitions/Sandbox" }, + "server": { + "$ref": "#/definitions/Server", + "description": "#/definitions/Server" + }, "subscription": { "$ref": "#/definitions/SubscriptionConfig", "description": "#/definitions/SubscriptionConfig" @@ -8403,16 +9789,16 @@ snapshot_kind: text "description": "#/definitions/Supergraph" }, "telemetry": { - "$ref": "#/definitions/Conf7", - "description": "#/definitions/Conf7" + "$ref": "#/definitions/Conf8", + "description": "#/definitions/Conf8" }, "tls": { "$ref": "#/definitions/Tls", "description": "#/definitions/Tls" }, "traffic_shaping": { - "$ref": "#/definitions/Config15", - "description": "#/definitions/Config15" + "$ref": "#/definitions/Config19", + "description": "#/definitions/Config19" } }, "title": "Configuration", diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@apollo_extended_errors.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@apollo_extended_errors.yaml.snap new file mode 100644 index 0000000000..fce7119e0a --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@apollo_extended_errors.yaml.snap @@ -0,0 +1,9 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +--- +--- +telemetry: + apollo: + errors: + preview_extended_error_metrics: enabled diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@connectors_preview.router.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@connectors_preview.router.yaml.snap new file mode 100644 index 0000000000..819284088b --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@connectors_preview.router.yaml.snap @@ -0,0 +1,14 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +--- +--- +connectors: + debug_extensions: true + expose_sources_in_context: true + max_requests_per_operation_per_source: 50 + subgraphs: + subgraph_name: + sources: + source_name: + override_url: "http://localhost:5280" diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@jaeger_enabled.router.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@jaeger_enabled.router.yaml.snap index 93d6a3f228..198fdbfcb7 100644 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@jaeger_enabled.router.yaml.snap +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@jaeger_enabled.router.yaml.snap @@ -6,8 +6,6 @@ expression: new_config telemetry: exporters: tracing: - jaeger: - agent: - endpoint: default + otlp: + endpoint: default enabled: true - diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@jwt_issuer_to_issuers.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@jwt_issuer_to_issuers.yaml.snap new file mode 100644 index 0000000000..b6d6478cdb --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@jwt_issuer_to_issuers.yaml.snap @@ -0,0 +1,23 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +--- +--- +authentication: + router: + jwt: + jwks: + - url: "https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json" + poll_interval: "" + headers: + - name: User-Agent + value: router + issuers: + - "https://issuer.one" + - url: "https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json" + poll_interval: "" + headers: + - name: User-Agent + value: router + issuers: + - "https://issuer.two" diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@legacy_qp.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@legacy_qp.yaml.snap new file mode 100644 index 0000000000..44ea1678e6 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@legacy_qp.yaml.snap @@ -0,0 +1,7 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +--- +--- +supergraph: + query_planning: {} diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_both_origins.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_both_origins.yaml.snap new file mode 100644 index 0000000000..a4f9c8720a --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_both_origins.yaml.snap @@ -0,0 +1,20 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +snapshot_kind: text +--- +--- +cors: + allow_credentials: true + methods: + - GET + - POST + - OPTIONS + allow_headers: + - content-type + - authorization + policies: + - origins: + - "https://studio.apollographql.com" + - match_origins: + - "^https://(a|b|c)\\.myapp\\.com$" diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_conflicting_config.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_conflicting_config.yaml.snap new file mode 100644 index 0000000000..de6576271d --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_conflicting_config.yaml.snap @@ -0,0 +1,12 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +snapshot_kind: text +--- +--- +cors: + policies: + - origins: + - "https://example.com" + - origins: + - "https://studio.apollographql.com" diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_match_origins.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_match_origins.yaml.snap new file mode 100644 index 0000000000..96a126da88 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_match_origins.yaml.snap @@ -0,0 +1,19 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +snapshot_kind: text +--- +--- +cors: + allow_credentials: true + methods: + - GET + - POST + - OPTIONS + allow_headers: + - content-type + - authorization + policies: + - match_origins: + - "^https://studio\\.apollographql\\.com$" + - "^https://(a|b|c)\\.myapp\\.com$" diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_no_origins.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_no_origins.yaml.snap new file mode 100644 index 0000000000..a561077fa9 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_no_origins.yaml.snap @@ -0,0 +1,11 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +snapshot_kind: text +--- +--- +cors: + allow_credentials: true + methods: + - GET + - POST diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_origins_empty.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_origins_empty.yaml.snap new file mode 100644 index 0000000000..5ac9701ca4 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_origins_empty.yaml.snap @@ -0,0 +1,10 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +snapshot_kind: text +--- +--- +cors: + allow_credentials: false + policies: + - origins: [] diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_origins_simple.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_origins_simple.yaml.snap new file mode 100644 index 0000000000..ed4dd04799 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_origins_simple.yaml.snap @@ -0,0 +1,11 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +snapshot_kind: text +--- +--- +cors: + policies: + - origins: + - "https://studio.apollographql.com" + - "https://example.com" diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_origins_with_settings.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_origins_with_settings.yaml.snap new file mode 100644 index 0000000000..f5c539d7d6 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__cors_origins_with_settings.yaml.snap @@ -0,0 +1,23 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +snapshot_kind: text +--- +--- +cors: + allow_credentials: true + methods: + - GET + - POST + - OPTIONS + allow_headers: + - content-type + - authorization + expose_headers: + - x-custom-header + max_age: 3600s + policies: + - origins: + - "https://studio.apollographql.com" + - "https://myapp.com" + - "https://api.example.com" diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__health_check.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__health_check.yaml.snap new file mode 100644 index 0000000000..a742c41d58 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__health_check.yaml.snap @@ -0,0 +1,7 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +--- +--- +health_check: + enabled: true diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__subscription_dedup.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__subscription_dedup.yaml.snap new file mode 100644 index 0000000000..eda2b1d22b --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@minor__subscription_dedup.yaml.snap @@ -0,0 +1,11 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +--- +--- +subscription: + enabled: true + queue_capacity: 100000 + max_opened_subscriptions: 100 + deduplication: + enabled: true diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_major_configuration@apollo_extended_errors.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_major_configuration@apollo_extended_errors.yaml.snap new file mode 100644 index 0000000000..fce7119e0a --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_major_configuration@apollo_extended_errors.yaml.snap @@ -0,0 +1,9 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +--- +--- +telemetry: + apollo: + errors: + preview_extended_error_metrics: enabled diff --git a/apollo-router/src/configuration/subgraph.rs b/apollo-router/src/configuration/subgraph.rs index 1c3ea1d7db..8dd7475046 100644 --- a/apollo-router/src/configuration/subgraph.rs +++ b/apollo-router/src/configuration/subgraph.rs @@ -6,12 +6,12 @@ use std::fmt::Debug; use std::marker::PhantomData; use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; use serde::de; use serde::de::DeserializeOwned; use serde::de::MapAccess; use serde::de::Visitor; -use serde::Deserialize; -use serde::Serialize; // In various parts of the configuration, we need to provide a global configuration for subgraphs, // with a per subgraph override. This cannot be handled easily with `Default` implementations, @@ -70,7 +70,7 @@ use serde::Serialize; // This `SubgraphConfiguration` type handles overrides through a custom deserializer that works in three steps: // - deserialize `all` and `subgraphs` fields to `serde_yaml::Mapping` // - for each specific subgraph configuration, start from the `all` configuration (or default implementation), -// and replace the overriden fields +// and replace the overridden fields // - deserialize to the plugin configuration /// Configuration options pertaining to the subgraph server component. @@ -225,7 +225,7 @@ impl<'de> Deserialize<'de> for Field { struct FieldVisitor; -impl<'de> Visitor<'de> for FieldVisitor { +impl Visitor<'_> for FieldVisitor { type Value = Field; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { diff --git a/apollo-router/src/configuration/testdata/config_full.router.yaml b/apollo-router/src/configuration/testdata/config_full.router.yaml index 6d658bfaae..41e34100d9 100644 --- a/apollo-router/src/configuration/testdata/config_full.router.yaml +++ b/apollo-router/src/configuration/testdata/config_full.router.yaml @@ -1,5 +1,6 @@ supergraph: listen: 1.2.3.4:5 cors: - origins: [foo, bar, baz] + policies: + - origins: [foo, bar, baz] methods: [foo, bar] diff --git a/apollo-router/src/configuration/testdata/metrics/connectors.router.yaml b/apollo-router/src/configuration/testdata/metrics/connectors.router.yaml new file mode 100644 index 0000000000..eab44f922c --- /dev/null +++ b/apollo-router/src/configuration/testdata/metrics/connectors.router.yaml @@ -0,0 +1,12 @@ +connectors: + debug_extensions: true + expose_sources_in_context: true + max_requests_per_operation_per_source: 100 + subgraphs: + subgraph_name: + $config: + name_of_the_variable: variable_value + sources: + source_name: + max_requests_per_operation: 50 + override_url: 'http://localhost' diff --git a/apollo-router/src/configuration/testdata/metrics/entities.router.yaml b/apollo-router/src/configuration/testdata/metrics/entities.router.yaml index e2cbd0ee04..2382453c33 100644 --- a/apollo-router/src/configuration/testdata/metrics/entities.router.yaml +++ b/apollo-router/src/configuration/testdata/metrics/entities.router.yaml @@ -9,10 +9,15 @@ preview_entity_cache: urls: [ "redis://localhost:6379" ] timeout: 5ms ttl: 60s - enabled: true + invalidation: + enabled: true + shared_key: "invalidate" subgraphs: accounts: enabled: false products: - ttl: 120s \ No newline at end of file + ttl: 120s + invalidation: + enabled: true + shared_key: "invalidate" \ No newline at end of file diff --git a/apollo-router/src/configuration/testdata/metrics/query_planner_parallelism_auto.router.yaml b/apollo-router/src/configuration/testdata/metrics/query_planner_parallelism_auto.router.yaml deleted file mode 100644 index e29357f06d..0000000000 --- a/apollo-router/src/configuration/testdata/metrics/query_planner_parallelism_auto.router.yaml +++ /dev/null @@ -1,3 +0,0 @@ -supergraph: - query_planning: - experimental_parallelism: auto diff --git a/apollo-router/src/configuration/testdata/metrics/query_planner_parallelism_static.router.yaml b/apollo-router/src/configuration/testdata/metrics/query_planner_parallelism_static.router.yaml deleted file mode 100644 index 8861ab2777..0000000000 --- a/apollo-router/src/configuration/testdata/metrics/query_planner_parallelism_static.router.yaml +++ /dev/null @@ -1,3 +0,0 @@ -supergraph: - query_planning: - experimental_parallelism: 10 diff --git a/apollo-router/src/configuration/testdata/metrics/response_cache.router.yaml b/apollo-router/src/configuration/testdata/metrics/response_cache.router.yaml new file mode 100644 index 0000000000..6eb71deea6 --- /dev/null +++ b/apollo-router/src/configuration/testdata/metrics/response_cache.router.yaml @@ -0,0 +1,25 @@ +experimental_response_cache: + enabled: true + debug: true + invalidation: + listen: 127.0.0.1:4000 + path: /invalidation + subgraph: + all: + postgres: + url: "postgres://test" + acquire_timeout: 5ms + required_to_start: true + cleanup_interval: 10mins + enabled: true + invalidation: + enabled: true + shared_key: "invalidate" + subgraphs: + accounts: + enabled: false + products: + ttl: 120s + invalidation: + enabled: true + shared_key: "invalidate" diff --git a/apollo-router/src/configuration/testdata/metrics/subscriptions.router.yaml b/apollo-router/src/configuration/testdata/metrics/subscriptions.router.yaml index c87fe252ed..4179e99b4e 100644 --- a/apollo-router/src/configuration/testdata/metrics/subscriptions.router.yaml +++ b/apollo-router/src/configuration/testdata/metrics/subscriptions.router.yaml @@ -7,7 +7,7 @@ subscription: callback: path: /graphql public_url: https://example.com/graphql - enable_deduplication: false + deduplication: + enabled: false queue_capacity: 2 max_opened_subscriptions: 3 - diff --git a/apollo-router/src/configuration/testdata/metrics/telemetry.router.yaml b/apollo-router/src/configuration/testdata/metrics/telemetry.router.yaml index b4f9a19dd7..bf83e0ae37 100644 --- a/apollo-router/src/configuration/testdata/metrics/telemetry.router.yaml +++ b/apollo-router/src/configuration/testdata/metrics/telemetry.router.yaml @@ -16,20 +16,6 @@ telemetry: datadog: enabled: true endpoint: default - jaeger: - enabled: true - agent: - endpoint: default - logging: - experimental_when_header: - - name: apollo-router-log-request - value: test - headers: true # default: false - body: true # default: false - # log request for all requests coming from Iphones - - name: custom-header - match: ^foo.* - headers: true instrumentation: spans: mode: spec_compliant @@ -99,6 +85,20 @@ telemetry: subgraph_response_data: "$.products[*].price1" attributes: subgraph.name: true + connector: + acme.user.not.found: + value: unit + type: counter + unit: count + description: "Count of 404 responses from the user API" + condition: + all: + - eq: + - 404 + - connector_http_response_status: code + - eq: + - "user_api" + - connector_source: name graphql: list.length: true field.execution: true @@ -174,4 +174,20 @@ telemetry: attributes: subgraph.name: true response_status: - subgraph_response_status: code \ No newline at end of file + subgraph_response_status: code + connector: + # Standard events + request: off + response: info + error: error + + # Custom events + connector.response: + message: "Connector response" + level: info + on: response + attributes: + connector.http.method: true + connector.url.template: true + response_status: + connector_http_response_status: code \ No newline at end of file diff --git a/apollo-router/src/configuration/testdata/metrics/traffic_shaping.router.yaml b/apollo-router/src/configuration/testdata/metrics/traffic_shaping.router.yaml index ad221ae92d..a5e0d6014a 100644 --- a/apollo-router/src/configuration/testdata/metrics/traffic_shaping.router.yaml +++ b/apollo-router/src/configuration/testdata/metrics/traffic_shaping.router.yaml @@ -12,9 +12,4 @@ traffic_shaping: capacity: 100 interval: 1s experimental_http2: enable - experimental_retry: - ttl: 1s - min_per_sec: 2 - retry_mutations: true - retry_percent: 2 diff --git a/apollo-router/src/configuration/testdata/migrations/apollo_extended_errors.yaml b/apollo-router/src/configuration/testdata/migrations/apollo_extended_errors.yaml new file mode 100644 index 0000000000..df2772fb41 --- /dev/null +++ b/apollo-router/src/configuration/testdata/migrations/apollo_extended_errors.yaml @@ -0,0 +1,4 @@ +telemetry: + apollo: + errors: + experimental_otlp_error_metrics: enabled diff --git a/apollo-router/src/configuration/testdata/migrations/connectors_preview.router.yaml b/apollo-router/src/configuration/testdata/migrations/connectors_preview.router.yaml new file mode 100644 index 0000000000..7b379bf2ac --- /dev/null +++ b/apollo-router/src/configuration/testdata/migrations/connectors_preview.router.yaml @@ -0,0 +1,10 @@ +preview_connectors: + debug_extensions: true + expose_sources_in_context: true + max_requests_per_operation_per_source: 50 + # If support for subgraphs is eventually removed, the following will need to be removed + subgraphs: + subgraph_name: + sources: + source_name: + override_url: http://localhost:5280 \ No newline at end of file diff --git a/apollo-router/src/configuration/testdata/migrations/defer_support_ga.router.yaml b/apollo-router/src/configuration/testdata/migrations/defer_support_ga.router.yaml deleted file mode 100644 index 3bce6e29fb..0000000000 --- a/apollo-router/src/configuration/testdata/migrations/defer_support_ga.router.yaml +++ /dev/null @@ -1,2 +0,0 @@ -supergraph: - defer_support: true diff --git a/apollo-router/src/configuration/testdata/migrations/jaeger_enabled.router.yaml b/apollo-router/src/configuration/testdata/migrations/jaeger_enabled.router.yaml deleted file mode 100644 index a38bb8d5e6..0000000000 --- a/apollo-router/src/configuration/testdata/migrations/jaeger_enabled.router.yaml +++ /dev/null @@ -1,5 +0,0 @@ -telemetry: - tracing: - jaeger: - agent: - endpoint: default diff --git a/apollo-router/src/configuration/testdata/migrations/jaeger_scheduled_delay.router.yaml b/apollo-router/src/configuration/testdata/migrations/jaeger_scheduled_delay.router.yaml deleted file mode 100644 index 65426f4792..0000000000 --- a/apollo-router/src/configuration/testdata/migrations/jaeger_scheduled_delay.router.yaml +++ /dev/null @@ -1,6 +0,0 @@ -telemetry: - tracing: - jaeger: - scheduled_delay: 100ms - agent: - endpoint: default diff --git a/apollo-router/src/configuration/testdata/migrations/jwt_issuer_to_issuers.yaml b/apollo-router/src/configuration/testdata/migrations/jwt_issuer_to_issuers.yaml new file mode 100644 index 0000000000..cc01ab20b0 --- /dev/null +++ b/apollo-router/src/configuration/testdata/migrations/jwt_issuer_to_issuers.yaml @@ -0,0 +1,16 @@ +authentication: + router: + jwt: + jwks: + - url: https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json + issuer: https://issuer.one + poll_interval: + headers: + - name: User-Agent + value: router + - url: https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json + issuer: https://issuer.two + poll_interval: + headers: + - name: User-Agent + value: router \ No newline at end of file diff --git a/apollo-router/src/configuration/testdata/migrations/legacy_qp.yaml b/apollo-router/src/configuration/testdata/migrations/legacy_qp.yaml new file mode 100644 index 0000000000..4408d941ec --- /dev/null +++ b/apollo-router/src/configuration/testdata/migrations/legacy_qp.yaml @@ -0,0 +1,5 @@ +supergraph: + query_planning: + experimental_parallelism: 2 + experimental_reuse_query_fragments: true +experimental_query_planner_mode: legacy diff --git a/apollo-router/src/configuration/testdata/migrations/minor/cors_both_origins.yaml b/apollo-router/src/configuration/testdata/migrations/minor/cors_both_origins.yaml new file mode 100644 index 0000000000..ba0bddaf9a --- /dev/null +++ b/apollo-router/src/configuration/testdata/migrations/minor/cors_both_origins.yaml @@ -0,0 +1,13 @@ +cors: + origins: + - "https://studio.apollographql.com" + match_origins: + - "^https://(a|b|c)\\.myapp\\.com$" + allow_credentials: true + methods: + - "GET" + - "POST" + - "OPTIONS" + allow_headers: + - "content-type" + - "authorization" diff --git a/apollo-router/src/configuration/testdata/migrations/minor/cors_conflicting_config.yaml b/apollo-router/src/configuration/testdata/migrations/minor/cors_conflicting_config.yaml new file mode 100644 index 0000000000..779c554681 --- /dev/null +++ b/apollo-router/src/configuration/testdata/migrations/minor/cors_conflicting_config.yaml @@ -0,0 +1,7 @@ +cors: + origins: + - "https://studio.apollographql.com" + policies: + - origins: + - "https://example.com" + diff --git a/apollo-router/src/configuration/testdata/migrations/minor/cors_match_origins.yaml b/apollo-router/src/configuration/testdata/migrations/minor/cors_match_origins.yaml new file mode 100644 index 0000000000..89f46a8dbc --- /dev/null +++ b/apollo-router/src/configuration/testdata/migrations/minor/cors_match_origins.yaml @@ -0,0 +1,12 @@ +cors: + match_origins: + - "^https://studio\\.apollographql\\.com$" + - "^https://(a|b|c)\\.myapp\\.com$" + allow_credentials: true + methods: + - "GET" + - "POST" + - "OPTIONS" + allow_headers: + - "content-type" + - "authorization" diff --git a/apollo-router/src/configuration/testdata/migrations/minor/cors_no_origins.yaml b/apollo-router/src/configuration/testdata/migrations/minor/cors_no_origins.yaml new file mode 100644 index 0000000000..10f2cdc065 --- /dev/null +++ b/apollo-router/src/configuration/testdata/migrations/minor/cors_no_origins.yaml @@ -0,0 +1,5 @@ +cors: + allow_credentials: true + methods: + - "GET" + - "POST" \ No newline at end of file diff --git a/apollo-router/src/configuration/testdata/migrations/minor/cors_origins_empty.yaml b/apollo-router/src/configuration/testdata/migrations/minor/cors_origins_empty.yaml new file mode 100644 index 0000000000..8073ac67b9 --- /dev/null +++ b/apollo-router/src/configuration/testdata/migrations/minor/cors_origins_empty.yaml @@ -0,0 +1,3 @@ +cors: + origins: [] + allow_credentials: false \ No newline at end of file diff --git a/apollo-router/src/configuration/testdata/migrations/minor/cors_origins_simple.yaml b/apollo-router/src/configuration/testdata/migrations/minor/cors_origins_simple.yaml new file mode 100644 index 0000000000..4852622073 --- /dev/null +++ b/apollo-router/src/configuration/testdata/migrations/minor/cors_origins_simple.yaml @@ -0,0 +1,4 @@ +cors: + origins: + - "https://studio.apollographql.com" + - "https://example.com" \ No newline at end of file diff --git a/apollo-router/src/configuration/testdata/migrations/minor/cors_origins_with_settings.yaml b/apollo-router/src/configuration/testdata/migrations/minor/cors_origins_with_settings.yaml new file mode 100644 index 0000000000..f63ecb0498 --- /dev/null +++ b/apollo-router/src/configuration/testdata/migrations/minor/cors_origins_with_settings.yaml @@ -0,0 +1,16 @@ +cors: + origins: + - "https://studio.apollographql.com" + - "https://myapp.com" + - "https://api.example.com" + allow_credentials: true + methods: + - "GET" + - "POST" + - "OPTIONS" + allow_headers: + - "content-type" + - "authorization" + expose_headers: + - "x-custom-header" + max_age: "3600s" \ No newline at end of file diff --git a/apollo-router/src/configuration/testdata/migrations/minor/subscription_dedup.yaml b/apollo-router/src/configuration/testdata/migrations/minor/subscription_dedup.yaml new file mode 100644 index 0000000000..f0ddbcb257 --- /dev/null +++ b/apollo-router/src/configuration/testdata/migrations/minor/subscription_dedup.yaml @@ -0,0 +1,5 @@ +subscription: + enabled: true + queue_capacity: 100000 + max_opened_subscriptions: 100 + enable_deduplication: true diff --git a/apollo-router/src/configuration/testdata/migrations/telemetry_router_to_supergraph.router.yaml b/apollo-router/src/configuration/testdata/migrations/telemetry_router_to_supergraph.router.yaml deleted file mode 100644 index 0e44d606a6..0000000000 --- a/apollo-router/src/configuration/testdata/migrations/telemetry_router_to_supergraph.router.yaml +++ /dev/null @@ -1,8 +0,0 @@ -telemetry: - metrics: - common: - attributes: - router: - request: - header: - - named: "fd" diff --git a/apollo-router/src/configuration/testdata/supergraph_config.router.yaml b/apollo-router/src/configuration/testdata/supergraph_config.router.yaml index a8bcd9692f..39c8424587 100644 --- a/apollo-router/src/configuration/testdata/supergraph_config.router.yaml +++ b/apollo-router/src/configuration/testdata/supergraph_config.router.yaml @@ -1,8 +1,9 @@ supergraph: listen: "127.0.0.1:4001" cors: - origins: - - studio.apollographql.com + policies: + - origins: + - https://studio.apollographql.com methods: - GET - PUT diff --git a/apollo-router/src/configuration/testdata/tracing_jaeger_agent.router.yaml b/apollo-router/src/configuration/testdata/tracing_jaeger_agent.router.yaml deleted file mode 100644 index 9a123bb97f..0000000000 --- a/apollo-router/src/configuration/testdata/tracing_jaeger_agent.router.yaml +++ /dev/null @@ -1,9 +0,0 @@ -supergraph: - listen: 1.2.3.4:5 -telemetry: - exporters: - tracing: - jaeger: - enabled: true - agent: - endpoint: default diff --git a/apollo-router/src/configuration/testdata/tracing_jaeger_collector.router.yaml b/apollo-router/src/configuration/testdata/tracing_jaeger_collector.router.yaml deleted file mode 100644 index aede8230b5..0000000000 --- a/apollo-router/src/configuration/testdata/tracing_jaeger_collector.router.yaml +++ /dev/null @@ -1,9 +0,0 @@ -supergraph: - listen: 1.2.3.4:5 -telemetry: - exporters: - tracing: - jaeger: - enabled: true - collector: - endpoint: http://example.com diff --git a/apollo-router/src/configuration/testdata/tracing_jaeger_collector_env.router.yaml b/apollo-router/src/configuration/testdata/tracing_jaeger_collector_env.router.yaml deleted file mode 100644 index 44277ff80d..0000000000 --- a/apollo-router/src/configuration/testdata/tracing_jaeger_collector_env.router.yaml +++ /dev/null @@ -1,9 +0,0 @@ -supergraph: - listen: 1.2.3.4:5 -telemetry: - exporters: - tracing: - jaeger: - enabled: true - collector: - endpoint: ${env.TEST_CONFIG_COLLECTOR_ENDPOINT} diff --git a/apollo-router/src/configuration/testdata/tracing_jaeger_full.router.yaml b/apollo-router/src/configuration/testdata/tracing_jaeger_full.router.yaml deleted file mode 100644 index f54ded8450..0000000000 --- a/apollo-router/src/configuration/testdata/tracing_jaeger_full.router.yaml +++ /dev/null @@ -1,11 +0,0 @@ -supergraph: - listen: 1.2.3.4:5 -telemetry: - exporters: - tracing: - jaeger: - enabled: true - collector: - endpoint: "http://foo" - password: "" - username: "" diff --git a/apollo-router/src/configuration/tests.rs b/apollo-router/src/configuration/tests.rs index 21cb5fdb50..8e45e5fdbf 100644 --- a/apollo-router/src/configuration/tests.rs +++ b/apollo-router/src/configuration/tests.rs @@ -8,14 +8,16 @@ use http::Uri; use insta::assert_json_snapshot; use regex::Regex; use rust_embed::RustEmbed; -use schemars::gen::SchemaSettings; +use schemars::r#gen::SchemaSettings; use serde_json::json; use walkdir::DirEntry; use walkdir::WalkDir; +use super::schema::Mode; use super::schema::validate_yaml_configuration; use super::subgraph::SubgraphConfiguration; use super::*; +use crate::configuration::cors::Policy; use crate::error::SchemaError; #[cfg(unix)] @@ -81,21 +83,39 @@ fn missing_subgraph_url() { #[test] fn cors_defaults() { let cors = Cors::builder().build(); - - assert_eq!( - ["https://studio.apollographql.com"], - cors.origins.as_slice() - ); + let policies = cors.policies.unwrap(); + assert_eq!(policies.len(), 1); + assert_eq!(policies[0].origins, ["https://studio.apollographql.com"]); assert!( !cors.allow_any_origin, "Allow any origin should be disabled by default" ); - assert!(cors.allow_headers.is_empty()); + assert_eq!(cors.methods, ["GET", "POST", "OPTIONS"]); + assert!(cors.max_age.is_none()); +} - assert!( - cors.match_origins.is_none(), - "No origin regex list should be present by default" - ); +#[test] +fn cors_single_origin_config() { + let cors = Cors::builder() + .max_age(std::time::Duration::from_secs(3600)) + .policies(vec![ + Policy::builder() + .origins(vec!["https://trusted.com".into()]) + .allow_credentials(true) + .allow_headers(vec!["content-type".into(), "authorization".into()]) + .expose_headers(vec!["x-custom-header".into()]) + .methods(vec!["GET".into(), "POST".into()]) + .build(), + ]) + .build(); + let policies = cors.policies.unwrap(); + assert_eq!(policies.len(), 1); + let oc = &policies[0]; + assert_eq!(oc.origins, ["https://trusted.com"]); + assert!(oc.allow_credentials.unwrap()); + assert_eq!(oc.allow_headers, ["content-type", "authorization"]); + assert_eq!(oc.expose_headers, ["x-custom-header"]); + assert_eq!(oc.methods, Some(vec!["GET".into(), "POST".into()])); } #[test] @@ -104,7 +124,12 @@ fn bad_graphql_path_configuration_without_slash() { .supergraph(Supergraph::fake_builder().path("test").build()) .build() .unwrap_err(); - assert_eq!(error.to_string(), String::from("invalid 'server.graphql_path' configuration: 'test' is invalid, it must be an absolute path and start with '/', you should try with '/test'")); + assert_eq!( + error.to_string(), + String::from( + "invalid 'server.graphql_path' configuration: 'test' is invalid, it must be an absolute path and start with '/', you should try with '/test'" + ) + ); } #[test] @@ -114,7 +139,12 @@ fn bad_graphql_path_configuration_with_wildcard_as_prefix() { .build() .unwrap_err(); - assert_eq!(error.to_string(), String::from("invalid 'server.graphql_path' configuration: '/*/test' is invalid, if you need to set a path like '/*/graphql' then specify it as a path parameter with a name, for example '/:my_project_key/graphql'")); + assert_eq!( + error.to_string(), + String::from( + "invalid 'server.graphql_path' configuration: '/*/test' is invalid, if you need to set a path like '/*/graphql' then specify it as a path parameter with a name, for example '/:my_project_key/graphql'" + ) + ); } #[test] @@ -292,7 +322,10 @@ cors: .cors .into_layer() .expect_err("should have resulted in an error"); - assert_eq!(error, "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` with `Access-Control-Allow-Headers: *`"); + assert_eq!( + error, + "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` with `Access-Control-Allow-Headers: *`" + ); } #[test] @@ -311,11 +344,14 @@ cors: .cors .into_layer() .expect_err("should have resulted in an error"); - assert_eq!(error, "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` with `Access-Control-Allow-Methods: *`"); + assert_eq!( + error, + "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` with `Access-Control-Allow-Methods: *`" + ); } #[test] -fn it_does_not_allow_invalid_cors_origins() { +fn cors_does_not_allow_invalid_cors_origins() { let cfg = validate_yaml_configuration( r#" cors: @@ -330,16 +366,19 @@ cors: .cors .into_layer() .expect_err("should have resulted in an error"); - assert_eq!(error, "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` with `allow_any_origin: true`"); + assert_eq!( + error, + "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` with `allow_any_origin: true`" + ); } #[test] -fn it_doesnt_allow_origins_wildcard() { +fn cors_doesnt_allow_origins_wildcard() { let cfg = validate_yaml_configuration( r#" cors: - origins: - - "*" + policies: + - origins: ["*"] "#, Expansion::default().unwrap(), Mode::NoUpgrade, @@ -349,20 +388,14 @@ cors: .cors .into_layer() .expect_err("should have resulted in an error"); - assert_eq!(error, "Invalid CORS configuration: use `allow_any_origin: true` to set `Access-Control-Allow-Origin: *`"); + assert_eq!( + error, + "Invalid CORS configuration: use `allow_any_origin: true` to set `Access-Control-Allow-Origin: *`" + ); } #[test] fn validate_project_config_files() { - std::env::set_var("DATADOG_AGENT_HOST", "http://example.com"); - std::env::set_var("JAEGER_HOST", "http://example.com"); - std::env::set_var("JAEGER_USERNAME", "username"); - std::env::set_var("JAEGER_PASSWORD", "pass"); - std::env::set_var("ZIPKIN_HOST", "http://example.com"); - std::env::set_var("TEST_CONFIG_ENDPOINT", "http://example.com"); - std::env::set_var("TEST_CONFIG_COLLECTOR_ENDPOINT", "http://example.com"); - std::env::set_var("PARSER_MAX_RECURSION", "500"); - #[cfg(not(unix))] let filename_matcher = Regex::from_str("((.+[.])?router\\.yaml)|(.+\\.mdx)").unwrap(); #[cfg(unix)] @@ -374,7 +407,7 @@ fn validate_project_config_files() { let embedded_yaml_matcher = Regex::from_str(r#"(?ms)```yaml title="router(_unix)?.yaml"(.+?)```"#).unwrap(); - fn it(path: &str) -> impl Iterator { + fn it(path: &str) -> impl Iterator + use<> { WalkDir::new(path).into_iter().filter_map(|e| e.ok()) } @@ -413,16 +446,20 @@ fn validate_project_config_files() { }; for yaml in yamls { - #[cfg(not(feature = "hyper_header_limits"))] - if yaml.contains("http1_max_request_headers") { - continue; - } - - if let Err(e) = validate_yaml_configuration( - &yaml, - Expansion::default().unwrap(), - Mode::NoUpgrade, - ) { + let expansion = Expansion::default_builder() + .mocked_env_var("DATADOG_AGENT_HOST", "http://example.com") + .mocked_env_var("JAEGER_HOST", "http://example.com") + .mocked_env_var("JAEGER_USERNAME", "username") + .mocked_env_var("JAEGER_PASSWORD", "pass") + .mocked_env_var("ZIPKIN_HOST", "http://example.com") + .mocked_env_var("TEST_CONFIG_ENDPOINT", "http://example.com") + .mocked_env_var("TEST_CONFIG_COLLECTOR_ENDPOINT", "http://example.com") + .mocked_env_var("PARSER_MAX_RECURSION", "500") + .mocked_env_var("AWS_ROLE_ARN", "arn:aws:iam::12345678:role/SomeRole") + .build() + .unwrap(); + + if let Err(e) = validate_yaml_configuration(&yaml, expansion, Mode::NoUpgrade) { panic!( "{} configuration error: \n{}", entry.path().to_string_lossy(), @@ -436,13 +473,16 @@ fn validate_project_config_files() { #[test] fn it_does_not_leak_env_variable_values() { - std::env::set_var("TEST_CONFIG_NUMERIC_ENV_UNIQUE", "5"); + let expansion = Expansion::default_builder() + .mocked_env_var("TEST_CONFIG_NUMERIC_ENV_UNIQUE", "5") + .build() + .unwrap(); let error = validate_yaml_configuration( r#" supergraph: introspection: ${env.TEST_CONFIG_NUMERIC_ENV_UNIQUE:-true} "#, - Expansion::default().unwrap(), + expansion, Mode::NoUpgrade, ) .expect_err("Must have an error because we expect a boolean"); @@ -451,7 +491,10 @@ supergraph: #[test] fn line_precise_config_errors_with_inline_sequence_env_expansion() { - std::env::set_var("TEST_CONFIG_NUMERIC_ENV_UNIQUE", "5"); + let expansion = Expansion::default_builder() + .mocked_env_var("TEST_CONFIG_NUMERIC_ENV_UNIQUE", "5") + .build() + .unwrap(); let error = validate_yaml_configuration( r#" supergraph: @@ -461,7 +504,7 @@ supergraph: cors: allow_headers: [ Content-Type, "${env.TEST_CONFIG_NUMERIC_ENV_UNIQUE}" ] "#, - Expansion::default().unwrap(), + expansion, Mode::NoUpgrade, ) .expect_err("should have resulted in an error"); @@ -470,7 +513,10 @@ cors: #[test] fn line_precise_config_errors_with_sequence_env_expansion() { - std::env::set_var("env.TEST_CONFIG_NUMERIC_ENV_UNIQUE", "5"); + let expansion = Expansion::default_builder() + .mocked_env_var("env.TEST_CONFIG_NUMERIC_ENV_UNIQUE", "5") + .build() + .unwrap(); let error = validate_yaml_configuration( r#" @@ -483,7 +529,7 @@ cors: - Content-Type - "${env.TEST_CONFIG_NUMERIC_ENV_UNIQUE:-true}" "#, - Expansion::default().unwrap(), + expansion, Mode::NoUpgrade, ) .expect_err("should have resulted in an error"); @@ -492,6 +538,7 @@ cors: #[test] fn line_precise_config_errors_with_errors_after_first_field_env_expansion() { + #[allow(clippy::literal_string_with_formatting_args)] let error = validate_yaml_configuration( r#" supergraph: @@ -541,13 +588,13 @@ supergraph: #[test] fn expansion_prefixing() { - std::env::set_var("TEST_CONFIG_NEEDS_PREFIX", "true"); validate_yaml_configuration( r#" supergraph: introspection: ${env.NEEDS_PREFIX} "#, Expansion::builder() + .mocked_env_var("TEST_CONFIG_NEEDS_PREFIX", "true") .prefix("TEST_CONFIG") .supported_mode("env") .build(), @@ -594,6 +641,7 @@ fn upgrade_old_configuration() { let new_config = crate::configuration::upgrade::upgrade_configuration( &serde_yaml::from_str(&input).expect("config must be valid yaml"), true, + upgrade::UpgradeMode::Major, ) .expect("configuration could not be updated"); let new_config = @@ -612,13 +660,49 @@ fn upgrade_old_configuration() { }); } Err(e) => { - panic!("migrated configuration had validation errors:\n{e}\n\noriginal configuration:\n{input}\n\nmigrated configuration:\n{new_config}") + panic!( + "migrated configuration had validation errors:\n{e}\n\noriginal configuration:\n{input}\n\nmigrated configuration:\n{new_config}" + ) } } } } } +#[derive(RustEmbed)] +#[folder = "src/configuration/testdata/migrations/minor"] +struct AssetMinor; + +#[test] +fn upgrade_old_minor_configuration() { + for file_name in AssetMinor::iter() { + if file_name.ends_with(".yaml") { + let source = AssetMinor::get(&file_name).expect("test file must exist"); + let input = std::str::from_utf8(&source.data) + .expect("expected utf8") + .to_string(); + let new_config = crate::configuration::upgrade::upgrade_configuration( + &serde_yaml::from_str(&input).expect("config must be valid yaml"), + true, + upgrade::UpgradeMode::Minor, + ) + .expect("configuration could not be updated"); + let new_config = + serde_yaml::to_string(&new_config).expect("must be able to serialize config"); + + let result = validate_yaml_configuration( + &new_config, + Expansion::builder().build(), + Mode::NoUpgrade, + ); + + if let Err(err) = result { + panic!("minor upgrade should not raise errors, but it did for {file_name}: {err:?}") + } + } + } +} + #[test] fn all_properties_are_documented() { let schema = serde_json::to_value(generate_config_schema()) @@ -682,7 +766,7 @@ fn test_configuration_validate_and_sanitize() { .unwrap() .validate() .unwrap(); - assert_eq!(&conf.supergraph.sanitized_path(), "/g:supergraph_route"); + assert_eq!(&conf.supergraph.sanitized_path(), "/g{supergraph_route}"); let conf = Configuration::builder() .supergraph(Supergraph::builder().path("/graphql/g*").build()) @@ -692,16 +776,16 @@ fn test_configuration_validate_and_sanitize() { .unwrap(); assert_eq!( &conf.supergraph.sanitized_path(), - "/graphql/g:supergraph_route" + "/graphql/g{supergraph_route}" ); let conf = Configuration::builder() - .supergraph(Supergraph::builder().path("/*").build()) + .supergraph(Supergraph::builder().path("/{*rest}").build()) .build() .unwrap() .validate() .unwrap(); - assert_eq!(&conf.supergraph.sanitized_path(), "/*router_extra_path"); + assert_eq!(&conf.supergraph.sanitized_path(), "/{*rest}"); let conf = Configuration::builder() .supergraph(Supergraph::builder().path("/test").build()) @@ -711,10 +795,12 @@ fn test_configuration_validate_and_sanitize() { .unwrap(); assert_eq!(&conf.supergraph.sanitized_path(), "/test"); - assert!(Configuration::builder() - .supergraph(Supergraph::builder().path("/*/whatever").build()) - .build() - .is_err()); + assert!( + Configuration::builder() + .supergraph(Supergraph::builder().path("/*/whatever").build()) + .build() + .is_err() + ); } #[test] @@ -776,8 +862,8 @@ fn test_subgraph_override() { s.option_add_null_type = false; s.inline_subschemas = true; }); - let gen = settings.into_generator(); - let schema = gen.into_root_schema_for::(); + let generator = settings.into_generator(); + let schema = generator.into_root_schema_for::(); insta::assert_json_snapshot!(schema); } @@ -855,7 +941,7 @@ fn test_deserialize_derive_default() { // Walk every source file and check that #[derive(Default)] is not used. let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); path.push("src"); - fn it(path: &Path) -> impl Iterator { + fn it(path: &Path) -> impl Iterator + use<> { WalkDir::new(path).into_iter().filter_map(|e| e.ok()) } @@ -874,7 +960,7 @@ fn test_deserialize_derive_default() { if deserialize_regex.is_match(line) { // Get the struct name if let Some(struct_name) = find_struct_name(&lines, line_number) { - let manual_implementation = format!("impl Default for {} ", struct_name); + let manual_implementation = format!("impl Default for {struct_name} "); let has_field_level_defaults = has_field_level_serde_defaults(&lines, line_number); @@ -920,7 +1006,12 @@ fn it_defaults_health_check_configuration() { #[test] fn it_sets_custom_health_check_path() { let conf = Configuration::builder() - .health_check(HealthCheck::new(None, None, Some("/healthz".to_string()))) + .health_check(HealthCheck::new( + None, + None, + Some("/healthz".to_string()), + Default::default(), + )) .build() .unwrap(); @@ -931,7 +1022,12 @@ fn it_sets_custom_health_check_path() { fn it_adds_slash_to_custom_health_check_path_if_missing() { let conf = Configuration::builder() // NB the missing `/` - .health_check(HealthCheck::new(None, None, Some("healthz".to_string()))) + .health_check(HealthCheck::new( + None, + None, + Some("healthz".to_string()), + Default::default(), + )) .build() .unwrap(); @@ -1064,6 +1160,61 @@ fn it_processes_batching_subgraph_accounts_override_enabled_correctly() { assert!(config.batch_include("accounts")); } +#[test] +fn it_processes_unspecified_maximum_batch_limit_correctly() { + let json_config = json!({ + "enabled": true, + "mode": "batch_http_link", + }); + + let config: Batching = serde_json::from_value(json_config).unwrap(); + + assert_eq!(config.maximum_size, None); +} + +#[test] +fn it_processes_specified_maximum_batch_limit_correctly() { + let json_config = json!({ + "enabled": true, + "mode": "batch_http_link", + "maximum_size": 10 + }); + + let config: Batching = serde_json::from_value(json_config).unwrap(); + + assert_eq!(config.maximum_size, Some(10)); +} + +#[test] +fn it_includes_default_header_read_timeout_when_server_config_omitted() { + let json_config = json!({}); + + let config: Configuration = serde_json::from_value(json_config).unwrap(); + + assert_eq!( + config.server.http.header_read_timeout, + Duration::from_secs(10) + ); +} + +#[test] +fn it_processes_specified_server_config_correctly() { + let json_config = json!({ + "server": { + "http": { + "header_read_timeout": "30s" + } + } + }); + + let config: Configuration = serde_json::from_value(json_config).unwrap(); + + assert_eq!( + config.server.http.header_read_timeout, + Duration::from_secs(30) + ); +} + fn has_field_level_serde_defaults(lines: &[&str], line_number: usize) -> bool { let serde_field_default = Regex::new( r#"^\s*#[\s\n]*\[serde\s*\((.*,)?\s*default\s*=\s*"[a-zA-Z0-9_:]+"\s*(,.*)?\)\s*\]\s*$"#, @@ -1096,19 +1247,3 @@ fn find_struct_name(lines: &[&str], line_number: usize) -> Option { }) .next() } - -#[test] -fn it_prevents_reuse_and_generate_query_fragments_simultaneously() { - let conf = Configuration::builder() - .supergraph( - Supergraph::builder() - .generate_query_fragments(true) - .reuse_query_fragments(true) - .build(), - ) - .build() - .unwrap(); - - assert!(conf.supergraph.generate_query_fragments); - assert_eq!(conf.supergraph.reuse_query_fragments, Some(false)); -} diff --git a/apollo-router/src/configuration/upgrade.rs b/apollo-router/src/configuration/upgrade.rs index d925ed7354..f116eb8f34 100644 --- a/apollo-router/src/configuration/upgrade.rs +++ b/apollo-router/src/configuration/upgrade.rs @@ -57,17 +57,39 @@ enum Action { const REMOVAL_VALUE: &str = "__PLEASE_DELETE_ME"; const REMOVAL_EXPRESSION: &str = r#"const("__PLEASE_DELETE_ME")"#; +#[derive(Debug, Clone, Copy)] +pub(crate) enum UpgradeMode { + /// Upgrade using migrations for major version (eg: from router 1.x to router 2.x) + Major, + /// Upgrade using migrations for minor version (eg: from router 2.x to router 2.y) + Minor, +} + pub(crate) fn upgrade_configuration( config: &serde_json::Value, log_warnings: bool, + upgrade_mode: UpgradeMode, ) -> Result { + const CURRENT_MAJOR_VERSION: &str = env!("CARGO_PKG_VERSION_MAJOR"); // Transformers are loaded from a file and applied in order - let migrations: Vec = Asset::iter() - .sorted() - .filter(|filename| filename.ends_with(".yaml")) - .map(|filename| Asset::get(&filename).expect("migration must exist").data) - .map(|data| serde_yaml::from_slice(&data).expect("migration must be valid")) - .collect(); + let mut migrations: Vec = Vec::new(); + let files = Asset::iter().sorted().filter(|f| { + if matches!(upgrade_mode, UpgradeMode::Major) { + f.ends_with(".yaml") + } else { + f.ends_with(".yaml") && f.starts_with(CURRENT_MAJOR_VERSION) + } + }); + for filename in files { + if let Some(migration) = Asset::get(&filename) { + let parsed_migration = serde_yaml::from_slice(&migration.data).map_err(|error| { + ConfigurationError::MigrationFailure { + error: format!("Failed to parse migration {filename}: {error}"), + } + })?; + migrations.push(parsed_migration); + } + } let mut config = config.clone(); @@ -84,7 +106,14 @@ pub(crate) fn upgrade_configuration( config = new_config; } if !effective_migrations.is_empty() && log_warnings { - tracing::warn!("router configuration contains deprecated options: \n\n{}\n\nThese will become errors in the future. Run `router config upgrade ` to see a suggested upgraded configuration.", effective_migrations.iter().enumerate().map(|(idx, m)|format!(" {}. {}", idx + 1, m.description)).join("\n\n")); + tracing::error!( + "router configuration contains unsupported options and needs to be upgraded to run the router: \n\n{}\n\n", + effective_migrations + .iter() + .enumerate() + .map(|(idx, m)| format!(" {}. {}", idx + 1, m.description)) + .join("\n\n") + ); } Ok(config) } @@ -92,8 +121,7 @@ pub(crate) fn upgrade_configuration( fn apply_migration(config: &Value, migration: &Migration) -> Result { let mut transformer_builder = TransformBuilder::default(); //We always copy the entire doc to the destination first - transformer_builder = - transformer_builder.add_action(Parser::parse("", "").expect("migration must be valid")); + transformer_builder = transformer_builder.add_action(Parser::parse("", "")?); for action in &migration.actions { match action { Action::Add { path, name, value } => { @@ -104,10 +132,10 @@ fn apply_migration(config: &Value, migration: &Migration) -> Result { @@ -116,9 +144,8 @@ fn apply_migration(config: &Value, migration: &Migration) -> Result { @@ -126,8 +153,7 @@ fn apply_migration(config: &Value, migration: &Migration) -> Result { @@ -135,12 +161,10 @@ fn apply_migration(config: &Value, migration: &Migration) -> Result { @@ -148,14 +172,12 @@ fn apply_migration(config: &Value, migration: &Migration) -> Result { - let level = Level::from_str(level).expect("unknown level for log migration"); + let level = Level::from_str(level).map_err(migration_failure_error)?; if !jsonpath_lib::select(config, &format!("$.{path}")) .unwrap_or_default() @@ -172,15 +194,8 @@ fn apply_migration(config: &Value, migration: &Migration) -> Result Result Result { let parsed_config = - serde_yaml::from_str(config).map_err(|e| ConfigurationError::MigrationFailure { - error: e.to_string(), + serde_yaml::from_str(config).map_err(|error| ConfigurationError::MigrationFailure { + error: format!("Failed to parse config: {error}"), })?; - let upgraded_config = upgrade_configuration(&parsed_config, true).map_err(|e| { + let upgraded_config = upgrade_configuration(&parsed_config, true, UpgradeMode::Major)?; + let upgraded_config = serde_yaml::to_string(&upgraded_config).map_err(|error| { ConfigurationError::MigrationFailure { - error: e.to_string(), - } - })?; - let upgraded_config = serde_yaml::to_string(&upgraded_config).map_err(|e| { - ConfigurationError::MigrationFailure { - error: e.to_string(), + error: format!("Failed to serialize upgraded config: {error}"), } })?; generate_upgrade_output(config, &upgraded_config, diff) @@ -224,28 +236,28 @@ pub(crate) fn generate_upgrade_output( let trimmed = l.trim(); if !trimmed.starts_with('#') && !trimmed.is_empty() { if diff { - writeln!(output, "-{l}").expect("write will never fail"); + writeln!(output, "-{l}").map_err(migration_failure_error)?; } } else if diff { - writeln!(output, " {l}").expect("write will never fail"); + writeln!(output, " {l}").map_err(migration_failure_error)?; } else { - writeln!(output, "{l}").expect("write will never fail"); + writeln!(output, "{l}").map_err(migration_failure_error)?; } } diff::Result::Both(l, _) => { if diff { - writeln!(output, " {l}").expect("write will never fail"); + writeln!(output, " {l}").map_err(migration_failure_error)?; } else { - writeln!(output, "{l}").expect("write will never fail"); + writeln!(output, "{l}").map_err(migration_failure_error)?; } } diff::Result::Right(r) => { let trimmed = r.trim(); if trimmed != "---" && !trimmed.is_empty() { if diff { - writeln!(output, "+{r}").expect("write will never fail"); + writeln!(output, "+{r}").map_err(migration_failure_error)?; } else { - writeln!(output, "{r}").expect("write will never fail"); + writeln!(output, "{r}").map_err(migration_failure_error)?; } } } @@ -275,15 +287,21 @@ fn cleanup(value: &mut Value) { } } +fn migration_failure_error(error: T) -> ConfigurationError { + ConfigurationError::MigrationFailure { + error: error.to_string(), + } +} + #[cfg(test)] mod test { - use serde_json::json; use serde_json::Value; + use serde_json::json; - use crate::configuration::upgrade::apply_migration; - use crate::configuration::upgrade::generate_upgrade_output; use crate::configuration::upgrade::Action; use crate::configuration::upgrade::Migration; + use crate::configuration::upgrade::apply_migration; + use crate::configuration::upgrade::generate_upgrade_output; fn source_doc() -> Value { json!( { @@ -300,184 +318,210 @@ mod test { #[test] fn delete_field() { - insta::assert_json_snapshot!(apply_migration( - &source_doc(), - &Migration::builder() - .action(Action::Delete { - path: "obj.field1".to_string() - }) - .description("delete field1") - .build(), - ) - .expect("expected successful migration")); + insta::assert_json_snapshot!( + apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Delete { + path: "obj.field1".to_string() + }) + .description("delete field1") + .build(), + ) + .expect("expected successful migration") + ); } #[test] fn delete_array_element() { - insta::assert_json_snapshot!(apply_migration( - &source_doc(), - &Migration::builder() - .action(Action::Delete { - path: "arr[0]".to_string() - }) - .description("delete arr[0]") - .build(), - ) - .expect("expected successful migration")); + insta::assert_json_snapshot!( + apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Delete { + path: "arr[0]".to_string() + }) + .description("delete arr[0]") + .build(), + ) + .expect("expected successful migration") + ); } #[test] fn move_field() { - insta::assert_json_snapshot!(apply_migration( - &source_doc(), - &Migration::builder() - .action(Action::Move { - from: "obj.field1".to_string(), - to: "new.obj.field1".to_string() - }) - .description("move field1") - .build(), - ) - .expect("expected successful migration")); + insta::assert_json_snapshot!( + apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Move { + from: "obj.field1".to_string(), + to: "new.obj.field1".to_string() + }) + .description("move field1") + .build(), + ) + .expect("expected successful migration") + ); } #[test] fn add_field() { // This one won't add the field because `obj.field1` already exists - insta::assert_json_snapshot!(apply_migration( - &source_doc(), - &Migration::builder() - .action(Action::Add { - path: "obj".to_string(), - name: "field1".to_string(), - value: 25.into() - }) - .description("add field1") - .build(), - ) - .expect("expected successful migration")); - - insta::assert_json_snapshot!(apply_migration( - &source_doc(), - &Migration::builder() - .action(Action::Add { - path: "obj".to_string(), - name: "field3".to_string(), - value: 42.into() - }) - .description("add field3") - .build(), - ) - .expect("expected successful migration")); + insta::assert_json_snapshot!( + apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Add { + path: "obj".to_string(), + name: "field1".to_string(), + value: 25.into() + }) + .description("add field1") + .build(), + ) + .expect("expected successful migration") + ); + + insta::assert_json_snapshot!( + apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Add { + path: "obj".to_string(), + name: "field3".to_string(), + value: 42.into() + }) + .description("add field3") + .build(), + ) + .expect("expected successful migration") + ); // This one won't add the field because `unexistent` doesn't exist, we don't add parent structure - insta::assert_json_snapshot!(apply_migration( - &source_doc(), - &Migration::builder() - .action(Action::Add { - path: "unexistent".to_string(), - name: "field".to_string(), - value: 1.into() - }) - .description("add field3") - .build(), - ) - .expect("expected successful migration")); + insta::assert_json_snapshot!( + apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Add { + path: "unexistent".to_string(), + name: "field".to_string(), + value: 1.into() + }) + .description("add field3") + .build(), + ) + .expect("expected successful migration") + ); } #[test] fn move_non_existent_field() { - insta::assert_json_snapshot!(apply_migration( - &json!({"should": "stay"}), - &Migration::builder() - .action(Action::Move { - from: "obj.field1".to_string(), - to: "new.obj.field1".to_string() - }) - .description("move field1") - .build(), - ) - .expect("expected successful migration")); + insta::assert_json_snapshot!( + apply_migration( + &json!({"should": "stay"}), + &Migration::builder() + .action(Action::Move { + from: "obj.field1".to_string(), + to: "new.obj.field1".to_string() + }) + .description("move field1") + .build(), + ) + .expect("expected successful migration") + ); } #[test] fn move_array_element() { - insta::assert_json_snapshot!(apply_migration( - &source_doc(), - &Migration::builder() - .action(Action::Move { - from: "arr[0]".to_string(), - to: "new.arr[0]".to_string() - }) - .description("move arr[0]") - .build(), - ) - .expect("expected successful migration")); + insta::assert_json_snapshot!( + apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Move { + from: "arr[0]".to_string(), + to: "new.arr[0]".to_string() + }) + .description("move arr[0]") + .build(), + ) + .expect("expected successful migration") + ); } #[test] fn copy_field() { - insta::assert_json_snapshot!(apply_migration( - &source_doc(), - &Migration::builder() - .action(Action::Copy { - from: "obj.field1".to_string(), - to: "new.obj.field1".to_string() - }) - .description("copy field1") - .build(), - ) - .expect("expected successful migration")); + insta::assert_json_snapshot!( + apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Copy { + from: "obj.field1".to_string(), + to: "new.obj.field1".to_string() + }) + .description("copy field1") + .build(), + ) + .expect("expected successful migration") + ); } #[test] fn copy_array_element() { - insta::assert_json_snapshot!(apply_migration( - &source_doc(), - &Migration::builder() - .action(Action::Copy { - from: "arr[0]".to_string(), - to: "new.arr[0]".to_string() - }) - .description("copy arr[0]") - .build(), - ) - .expect("expected successful migration")); + insta::assert_json_snapshot!( + apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Copy { + from: "arr[0]".to_string(), + to: "new.arr[0]".to_string() + }) + .description("copy arr[0]") + .build(), + ) + .expect("expected successful migration") + ); } #[test] fn diff_upgrade_output() { - insta::assert_snapshot!(generate_upgrade_output( - "changed: bar\nstable: 1.0\ndeleted: gone", - "changed: bif\nstable: 1.0\nadded: new", - true - ) - .expect("expected successful migration")); + insta::assert_snapshot!( + generate_upgrade_output( + "changed: bar\nstable: 1.0\ndeleted: gone", + "changed: bif\nstable: 1.0\nadded: new", + true + ) + .expect("expected successful migration") + ); } #[test] fn upgrade_output() { - insta::assert_snapshot!(generate_upgrade_output( - "changed: bar\nstable: 1.0\ndeleted: gone", - "changed: bif\nstable: 1.0\nadded: new", - false - ) - .expect("expected successful migration")); + insta::assert_snapshot!( + generate_upgrade_output( + "changed: bar\nstable: 1.0\ndeleted: gone", + "changed: bif\nstable: 1.0\nadded: new", + false + ) + .expect("expected successful migration") + ); } #[test] fn change_field() { - insta::assert_json_snapshot!(apply_migration( - &source_doc(), - &Migration::builder() - .action(Action::Change { - path: "obj.field1".to_string(), - from: Value::Number(1u64.into()), - to: Value::String("a".into()), - }) - .description("change field1") - .build(), - ) - .expect("expected successful migration")); + insta::assert_json_snapshot!( + apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Change { + path: "obj.field1".to_string(), + from: Value::Number(1u64.into()), + to: Value::String("a".into()), + }) + .description("change field1") + .build(), + ) + .expect("expected successful migration") + ); } } diff --git a/apollo-router/src/configuration/yaml.rs b/apollo-router/src/configuration/yaml.rs index cf165daa03..5050e84cda 100644 --- a/apollo-router/src/configuration/yaml.rs +++ b/apollo-router/src/configuration/yaml.rs @@ -5,10 +5,10 @@ use derivative::Derivative; use indexmap::IndexMap; use jsonschema::paths::JSONPointer; use jsonschema::paths::PathChunk; +use yaml_rust::Event; use yaml_rust::parser::MarkedEventReceiver; use yaml_rust::parser::Parser; use yaml_rust::scanner::Marker; -use yaml_rust::Event; use crate::configuration::ConfigurationError; @@ -29,7 +29,7 @@ impl From for Label { #[derive(Clone, Debug)] pub(crate) enum Value { // These types are not currently used. - // In theory if we want to parse the yaml properly then we need them, but we're only interrested + // In theory if we want to parse the YAML properly then we need them, but we're only interested // in the markers, so maybe we don't need them? // Null(Marker), // Bool(bool, Marker), diff --git a/apollo-router/src/context/deprecated.rs b/apollo-router/src/context/deprecated.rs new file mode 100644 index 0000000000..17711d1f74 --- /dev/null +++ b/apollo-router/src/context/deprecated.rs @@ -0,0 +1,122 @@ +//! Support 1.x context key names in 2.x. + +use crate::context::OPERATION_KIND; +use crate::context::OPERATION_NAME; +use crate::plugins::authentication::APOLLO_AUTHENTICATION_JWT_CLAIMS; +use crate::plugins::authorization::AUTHENTICATION_REQUIRED_KEY; +use crate::plugins::authorization::REQUIRED_POLICIES_KEY; +use crate::plugins::authorization::REQUIRED_SCOPES_KEY; +use crate::plugins::demand_control::COST_ACTUAL_KEY; +use crate::plugins::demand_control::COST_ESTIMATED_KEY; +use crate::plugins::demand_control::COST_RESULT_KEY; +use crate::plugins::demand_control::COST_STRATEGY_KEY; +use crate::plugins::expose_query_plan::ENABLED_CONTEXT_KEY; +use crate::plugins::expose_query_plan::FORMATTED_QUERY_PLAN_CONTEXT_KEY; +use crate::plugins::expose_query_plan::QUERY_PLAN_CONTEXT_KEY; +use crate::plugins::progressive_override::LABELS_TO_OVERRIDE_KEY; +use crate::plugins::progressive_override::UNRESOLVED_LABELS_KEY; +use crate::plugins::telemetry::CLIENT_NAME; +use crate::plugins::telemetry::CLIENT_VERSION; +use crate::plugins::telemetry::STUDIO_EXCLUDE; +use crate::plugins::telemetry::SUBGRAPH_FTV1; +use crate::query_planner::APOLLO_OPERATION_ID; +use crate::services::FIRST_EVENT_CONTEXT_KEY; +use crate::services::layers::apq::PERSISTED_QUERY_CACHE_HIT; +use crate::services::layers::apq::PERSISTED_QUERY_REGISTERED; + +// From crate::context +pub(crate) const DEPRECATED_OPERATION_NAME: &str = "operation_name"; +pub(crate) const DEPRECATED_OPERATION_KIND: &str = "operation_kind"; + +// crate::plugins::authentication +pub(crate) const DEPRECATED_APOLLO_AUTHENTICATION_JWT_CLAIMS: &str = + "apollo_authentication::JWT::claims"; + +// crate::plugins::authorization +pub(crate) const DEPRECATED_AUTHENTICATION_REQUIRED_KEY: &str = + "apollo_authorization::authenticated::required"; +pub(crate) const DEPRECATED_REQUIRED_SCOPES_KEY: &str = "apollo_authorization::scopes::required"; +pub(crate) const DEPRECATED_REQUIRED_POLICIES_KEY: &str = + "apollo_authorization::policies::required"; + +// crate::plugins::demand_control +pub(crate) const DEPRECATED_COST_ESTIMATED_KEY: &str = "cost.estimated"; +pub(crate) const DEPRECATED_COST_ACTUAL_KEY: &str = "cost.actual"; +pub(crate) const DEPRECATED_COST_RESULT_KEY: &str = "cost.result"; +pub(crate) const DEPRECATED_COST_STRATEGY_KEY: &str = "cost.strategy"; + +// crate::plugins::expose_query_plan +pub(crate) const DEPRECATED_QUERY_PLAN_CONTEXT_KEY: &str = "experimental::expose_query_plan.plan"; +pub(crate) const DEPRECATED_FORMATTED_QUERY_PLAN_CONTEXT_KEY: &str = + "experimental::expose_query_plan.formatted_plan"; +pub(crate) const DEPRECATED_ENABLED_CONTEXT_KEY: &str = "experimental::expose_query_plan.enabled"; + +// crate::plugins::progressive_override +pub(crate) const DEPRECATED_UNRESOLVED_LABELS_KEY: &str = "apollo_override::unresolved_labels"; +pub(crate) const DEPRECATED_LABELS_TO_OVERRIDE_KEY: &str = "apollo_override::labels_to_override"; + +// crate::plugins::telemetry +pub(crate) const DEPRECATED_CLIENT_NAME: &str = "apollo_telemetry::client_name"; +pub(crate) const DEPRECATED_CLIENT_VERSION: &str = "apollo_telemetry::client_version"; +pub(crate) const DEPRECATED_SUBGRAPH_FTV1: &str = "apollo_telemetry::subgraph_ftv1"; +pub(crate) const DEPRECATED_STUDIO_EXCLUDE: &str = "apollo_telemetry::studio::exclude"; + +// crate::query_planner::caching_query_planner +pub(crate) const DEPRECATED_APOLLO_OPERATION_ID: &str = "apollo_operation_id"; + +// crate::services::supergraph::service +pub(crate) const DEPRECATED_FIRST_EVENT_CONTEXT_KEY: &str = + "apollo_router::supergraph::first_event"; + +// crate::services::layers::apq +pub(crate) const DEPRECATED_PERSISTED_QUERY_CACHE_HIT: &str = "persisted_query_hit"; +pub(crate) const DEPRECATED_PERSISTED_QUERY_REGISTERED: &str = "persisted_query_register"; + +/// Generate the function pair with a macro to be sure that they handle all the same keys. +macro_rules! make_deprecated_key_conversions { + ( $( $new:ident => $deprecated:ident, )* ) => { + /// Convert context key to the deprecated context key (mainly useful for coprocessor/rhai) + /// If the context key is not part of a deprecated one it just returns the original one because it doesn't have to be renamed + pub(crate) fn context_key_to_deprecated(key: String) -> String { + match key.as_str() { + $( $new => $deprecated.to_string(), )* + _ => key, + } + } + + /// Convert context key from deprecated to new one (mainly useful for coprocessor/rhai) + /// If the context key is not part of a deprecated one it just returns the original one because it doesn't have to be renamed + pub(crate) fn context_key_from_deprecated(key: String) -> String { + match key.as_str() { + $( $deprecated => $new.to_string(), )* + _ => key, + } + } + }; +} + +make_deprecated_key_conversions!( + OPERATION_NAME => DEPRECATED_OPERATION_NAME, + OPERATION_KIND => DEPRECATED_OPERATION_KIND, + APOLLO_AUTHENTICATION_JWT_CLAIMS => DEPRECATED_APOLLO_AUTHENTICATION_JWT_CLAIMS, + AUTHENTICATION_REQUIRED_KEY => DEPRECATED_AUTHENTICATION_REQUIRED_KEY, + REQUIRED_SCOPES_KEY => DEPRECATED_REQUIRED_SCOPES_KEY, + REQUIRED_POLICIES_KEY => DEPRECATED_REQUIRED_POLICIES_KEY, + APOLLO_OPERATION_ID => DEPRECATED_APOLLO_OPERATION_ID, + UNRESOLVED_LABELS_KEY => DEPRECATED_UNRESOLVED_LABELS_KEY, + LABELS_TO_OVERRIDE_KEY => DEPRECATED_LABELS_TO_OVERRIDE_KEY, + FIRST_EVENT_CONTEXT_KEY => DEPRECATED_FIRST_EVENT_CONTEXT_KEY, + CLIENT_NAME => DEPRECATED_CLIENT_NAME, + CLIENT_VERSION => DEPRECATED_CLIENT_VERSION, + STUDIO_EXCLUDE => DEPRECATED_STUDIO_EXCLUDE, + SUBGRAPH_FTV1 => DEPRECATED_SUBGRAPH_FTV1, + COST_ESTIMATED_KEY => DEPRECATED_COST_ESTIMATED_KEY, + COST_ACTUAL_KEY => DEPRECATED_COST_ACTUAL_KEY, + COST_RESULT_KEY => DEPRECATED_COST_RESULT_KEY, + COST_STRATEGY_KEY => DEPRECATED_COST_STRATEGY_KEY, + ENABLED_CONTEXT_KEY => DEPRECATED_ENABLED_CONTEXT_KEY, + FORMATTED_QUERY_PLAN_CONTEXT_KEY => DEPRECATED_FORMATTED_QUERY_PLAN_CONTEXT_KEY, + QUERY_PLAN_CONTEXT_KEY => DEPRECATED_QUERY_PLAN_CONTEXT_KEY, + PERSISTED_QUERY_CACHE_HIT => DEPRECATED_PERSISTED_QUERY_CACHE_HIT, + PERSISTED_QUERY_REGISTERED => DEPRECATED_PERSISTED_QUERY_REGISTERED, +); diff --git a/apollo-router/src/context/extensions/mod.rs b/apollo-router/src/context/extensions/mod.rs index 84274eeccd..06584a3667 100644 --- a/apollo-router/src/context/extensions/mod.rs +++ b/apollo-router/src/context/extensions/mod.rs @@ -131,10 +131,10 @@ impl Extensions { /// Check whether the extension set is empty or not. #[inline] pub fn is_empty(&self) -> bool { - self.map.as_ref().map_or(true, |map| map.is_empty()) + self.map.as_ref().is_none_or(|map| map.is_empty()) } - /// Get the numer of extensions available. + /// Get the number of extensions available. #[inline] pub fn len(&self) -> usize { self.map.as_ref().map_or(0, |map| map.len()) diff --git a/apollo-router/src/context/extensions/sync.rs b/apollo-router/src/context/extensions/sync.rs index 59c11c2b7a..de6e0d3127 100644 --- a/apollo-router/src/context/extensions/sync.rs +++ b/apollo-router/src/context/extensions/sync.rs @@ -1,10 +1,4 @@ -use std::ops::Deref; -use std::ops::DerefMut; use std::sync::Arc; -#[cfg(debug_assertions)] -use std::time::Duration; -#[cfg(debug_assertions)] -use std::time::Instant; /// You can use `Extensions` to pass data between plugins that is not serializable. Such data is not accessible from Rhai or co-processoers. /// @@ -19,71 +13,11 @@ pub struct ExtensionsMutex { } impl ExtensionsMutex { - /// Locks the extensions for mutation. - /// - /// It is CRITICAL to avoid holding on to the mutex guard for too long, particularly across async calls. - /// Doing so may cause performance degradation or even deadlocks. - /// - /// DEPRECATED: prefer with_lock() - /// - /// See related clippy lint for examples: - #[deprecated] - pub fn lock(&self) -> ExtensionsGuard { - ExtensionsGuard::new(&self.extensions) - } - /// Locks the extensions for interaction. /// /// The lock will be dropped once the closure completes. - pub fn with_lock<'a, T, F: FnOnce(ExtensionsGuard<'a>) -> T>(&'a self, func: F) -> T { - let locked = ExtensionsGuard::new(&self.extensions); - func(locked) - } -} - -pub struct ExtensionsGuard<'a> { - #[cfg(debug_assertions)] - start: Instant, - guard: parking_lot::MutexGuard<'a, super::Extensions>, -} -impl<'a> ExtensionsGuard<'a> { - fn new(guard: &'a parking_lot::Mutex) -> Self { - // IMPORTANT: Rust fields are constructed in the order that in which you write the fields in the initializer - // The guard MUST be initialized first otherwise time waiting for a lock is included in this time. - Self { - guard: guard.lock(), - #[cfg(debug_assertions)] - start: Instant::now(), - } - } -} - -impl<'a> Deref for ExtensionsGuard<'a> { - type Target = super::Extensions; - - fn deref(&self) -> &super::Extensions { - &self.guard - } -} - -impl DerefMut for ExtensionsGuard<'_> { - fn deref_mut(&mut self) -> &mut super::Extensions { - &mut self.guard - } -} - -#[cfg(debug_assertions)] -impl Drop for ExtensionsGuard<'_> { - fn drop(&mut self) { - // In debug builds we check that extensions is never held for too long. - // We only check if the current runtime is multi-threaded, because a bunch of unit tests fail the assertion and these need to be investigated separately. - if let Ok(runtime) = tokio::runtime::Handle::try_current() { - if runtime.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread { - let elapsed = self.start.elapsed(); - if elapsed > Duration::from_millis(10) { - panic!("ExtensionsGuard held for {}ms. This is probably a bug that will stall the Router and cause performance problems. Run with `RUST_BACKTRACE=1` environment variable to display a backtrace", elapsed.as_millis()); - } - } - } + pub fn with_lock T>(&self, func: F) -> T { + let mut locked = self.extensions.lock(); + func(&mut locked) } } diff --git a/apollo-router/src/context/mod.rs b/apollo-router/src/context/mod.rs index daade4ea5a..5b33b66146 100644 --- a/apollo-router/src/context/mod.rs +++ b/apollo-router/src/context/mod.rs @@ -4,17 +4,15 @@ //! allows additional data to be passed back and forth along the request invocation pipeline. use std::sync::Arc; -use std::time::Duration; use std::time::Instant; -use apollo_compiler::validation::Valid; use apollo_compiler::ExecutableDocument; +use apollo_compiler::validation::Valid; +use dashmap::DashMap; use dashmap::mapref::multiple::RefMulti; use dashmap::mapref::multiple::RefMutMulti; -use dashmap::DashMap; use derivative::Derivative; use extensions::sync::ExtensionsMutex; -use parking_lot::Mutex; use serde::Deserialize; use serde::Serialize; use tower::BoxError; @@ -22,14 +20,25 @@ use tower::BoxError; use crate::json_ext::Value; use crate::services::layers::query_analysis::ParsedDocument; +pub(crate) mod deprecated; pub(crate) mod extensions; -/// The key of the resolved operation name. This is subject to change and should not be relied on. -pub(crate) const OPERATION_NAME: &str = "operation_name"; -/// The key of the resolved operation kind. This is subject to change and should not be relied on. -pub(crate) const OPERATION_KIND: &str = "operation_kind"; +/// Context key for the operation name. +pub(crate) const OPERATION_NAME: &str = "apollo::supergraph::operation_name"; +/// Context key for the operation kind. +pub(crate) const OPERATION_KIND: &str = "apollo::supergraph::operation_kind"; /// The key to know if the response body contains at least 1 GraphQL error pub(crate) const CONTAINS_GRAPHQL_ERROR: &str = "apollo::telemetry::contains_graphql_error"; +/// The key to a map of errors that were already counted in a previous layer. This is subject to +/// change and is NOT supported for user access. +pub(crate) const COUNTED_ERRORS: &str = "apollo::telemetry::counted_errors"; +/// The key for the full list of errors in the router response. This allows us to pull the value in +/// plugins without having to deserialize the router response. This is subject to change and is NOT +/// supported for user access. +pub(crate) const ROUTER_RESPONSE_ERRORS: &str = "apollo::router::response_errors"; + +pub(crate) use deprecated::context_key_from_deprecated; +pub(crate) use deprecated::context_key_to_deprecated; /// Holds [`Context`] entries. pub(crate) type Entries = Arc>; @@ -59,10 +68,6 @@ pub struct Context { #[serde(skip)] pub(crate) created_at: Instant, - #[serde(skip)] - #[derivative(Debug = "ignore")] - busy_timer: Arc>, - #[serde(skip)] pub(crate) id: String, } @@ -78,12 +83,22 @@ impl Context { entries: Default::default(), extensions: ExtensionsMutex::default(), created_at: Instant::now(), - busy_timer: Arc::new(Mutex::new(BusyTimer::new())), id, } } } +impl FromIterator<(String, Value)> for Context { + fn from_iter>(iter: T) -> Self { + Self { + entries: Arc::new(DashMap::from_iter(iter)), + extensions: ExtensionsMutex::default(), + created_at: Instant::now(), + id: String::new(), + } + } +} + impl Context { /// Returns extensions of the context. /// @@ -234,124 +249,31 @@ impl Context { self.entries.iter_mut() } - /// Notify the busy timer that we're waiting on a network request - /// - /// When a plugin makes a network call that would block request handling, this - /// indicates to the processing time counter that it should stop measuring while - /// we wait for the call to finish. When the value returned by this method is - /// dropped, the router will start measuring again, unless we are still covered - /// by another active request (ex: parallel subgraph calls) - pub fn enter_active_request(&self) -> BusyTimerGuard { - self.busy_timer.lock().increment_active_requests(); - BusyTimerGuard { - busy_timer: self.busy_timer.clone(), - } - } - - /// Time actually spent working on this request - /// - /// This is the request duration without the time spent waiting for external calls - /// (coprocessor and subgraph requests). This metric is an approximation of - /// the time spent, because in the case of parallel subgraph calls, some - /// router processing time could happen during a network call (and so would - /// not be accounted for) and make another task late. - /// This is reported under the `apollo_router_processing_time` metric - pub fn busy_time(&self) -> Duration { - self.busy_timer.lock().current() - } - pub(crate) fn extend(&self, other: &Context) { for kv in other.entries.iter() { self.entries.insert(kv.key().clone(), kv.value().clone()); } } - /// Read only access to the executable document. This is UNSTABLE and may be changed or removed in future router releases. - /// In addition, ExecutableDocument is UNSTABLE, and may be changed or removed in future apollo-rs releases. - #[doc(hidden)] - pub fn unsupported_executable_document(&self) -> Option>> { + /// Read only access to the executable document for internal router plugins. + pub(crate) fn executable_document(&self) -> Option>> { self.extensions() .with_lock(|lock| lock.get::().map(|d| d.executable.clone())) } } -pub struct BusyTimerGuard { - busy_timer: Arc>, -} - -impl Drop for BusyTimerGuard { - fn drop(&mut self) { - self.busy_timer.lock().decrement_active_requests() - } -} - impl Default for Context { fn default() -> Self { Self::new() } } -/// Measures the total overhead of the router -/// -/// This works by measuring the time spent executing when there is no active subgraph request. -/// This is still not a perfect solution, there are cases where preprocessing a subgraph request -/// happens while another one is running and still shifts the end of the span, but for now this -/// should serve as a reasonable solution without complex post processing of spans -pub(crate) struct BusyTimer { - active_requests: u32, - busy_ns: Duration, - start: Option, -} - -impl BusyTimer { - fn new() -> Self { - BusyTimer::default() - } - - fn increment_active_requests(&mut self) { - if self.active_requests == 0 { - if let Some(start) = self.start.take() { - self.busy_ns += start.elapsed(); - } - self.start = None; - } - - self.active_requests += 1; - } - - fn decrement_active_requests(&mut self) { - self.active_requests -= 1; - - if self.active_requests == 0 { - self.start = Some(Instant::now()); - } - } - - fn current(&mut self) -> Duration { - if let Some(start) = self.start { - self.busy_ns + start.elapsed() - } else { - self.busy_ns - } - } -} - -impl Default for BusyTimer { - fn default() -> Self { - Self { - active_requests: 0, - busy_ns: Duration::new(0, 0), - start: Some(Instant::now()), - } - } -} - #[cfg(test)] mod test { - use crate::spec::Query; - use crate::spec::Schema; use crate::Configuration; use crate::Context; + use crate::spec::Query; + use crate::spec::Schema; #[test] fn test_context_insert() { @@ -419,7 +341,7 @@ mod test { fn context_extensions() { // This is mostly tested in the extensions module. let c = Context::new(); - c.extensions().with_lock(|mut lock| lock.insert(1usize)); + c.extensions().with_lock(|lock| lock.insert(1usize)); let v = c .extensions() .with_lock(|lock| lock.get::().cloned()); @@ -433,8 +355,8 @@ mod test { let schema = Schema::parse(schema, &Default::default()).unwrap(); let document = Query::parse_document("{ me }", None, &schema, &Configuration::default()).unwrap(); - assert!(c.unsupported_executable_document().is_none()); - c.extensions().with_lock(|mut lock| lock.insert(document)); - assert!(c.unsupported_executable_document().is_some()); + assert!(c.executable_document().is_none()); + c.extensions().with_lock(|lock| lock.insert(document)); + assert!(c.executable_document().is_some()); } } diff --git a/apollo-router/src/error.rs b/apollo-router/src/error.rs index d78cd86728..93676b3c6a 100644 --- a/apollo-router/src/error.rs +++ b/apollo-router/src/error.rs @@ -1,21 +1,18 @@ //! Router errors. -use std::collections::HashMap; +use std::ops::Deref; use std::sync::Arc; use apollo_compiler::validation::DiagnosticList; use apollo_compiler::validation::WithErrors; use apollo_federation::error::FederationError; use displaydoc::Display; -use lazy_static::__Deref; -use router_bridge::introspect::IntrospectionError; -use router_bridge::planner::PlannerError; -use router_bridge::planner::UsageReporting; use serde::Deserialize; use serde::Serialize; use thiserror::Error; use tokio::task::JoinError; use tower::BoxError; +use crate::apollo_studio_interop::UsageReporting; pub(crate) use crate::configuration::ConfigurationError; pub(crate) use crate::graphql::Error; use crate::graphql::ErrorExtension; @@ -24,8 +21,8 @@ use crate::graphql::Location as ErrorLocation; use crate::graphql::Response; use crate::json_ext::Path; use crate::json_ext::Value; -use crate::spec::operation_limits::OperationLimits; use crate::spec::SpecError; +use crate::spec::operation_limits::OperationLimits; /// Return up to this many GraphQL parsing or validation errors. /// @@ -42,10 +39,10 @@ const MAX_VALIDATION_ERRORS: usize = 100; #[non_exhaustive] #[allow(missing_docs)] // FIXME pub(crate) enum FetchError { - /// invalid type for variable: '{name}' + /// {message} ValidationInvalidTypeVariable { - /// Name of the variable. - name: String, + name: serde_json_bytes::ByteString, + message: crate::spec::InvalidInputValue, }, /// query could not be planned: {reason} @@ -120,6 +117,8 @@ pub(crate) enum FetchError { impl FetchError { /// Convert the fetch error to a GraphQL error. pub(crate) fn to_graphql_error(&self, path: Option) -> Error { + // FIXME(SimonSapin): this causes every Rust field to be included in `extensions`, + // do we really want that? let mut value: Value = serde_json_bytes::to_value(self).unwrap_or_default(); if let Some(extensions) = value.as_object_mut() { extensions @@ -148,21 +147,22 @@ impl FetchError { .entry("service") .or_insert_with(|| service.clone().into()); } - FetchError::ValidationInvalidTypeVariable { name } => { + FetchError::ValidationInvalidTypeVariable { name, .. } => { + extensions.remove("message"); extensions .entry("name") - .or_insert_with(|| name.clone().into()); + .or_insert_with(|| Value::String(name.clone())); } _ => (), } } - Error { - message: self.to_string(), - locations: Default::default(), - path, - extensions: value.as_object().unwrap().to_owned(), - } + Error::builder() + .message(self.to_string()) + .locations(Vec::default()) + .and_path(path) + .extensions(value.as_object().unwrap().to_owned()) + .build() } /// Convert the error to an appropriate response. @@ -203,10 +203,12 @@ impl From for FetchError { } /// Error types for CacheResolver -#[derive(Error, Debug, Display, Clone, Serialize, Deserialize)] +#[derive(Error, Debug, Display, Clone)] pub(crate) enum CacheResolverError { /// value retrieval failed: {0} RetrievalError(Arc), + /// {0} + Backpressure(crate::compute_job::ComputeBackPressureError), /// batch processing failed: {0} BatchingError(String), } @@ -219,10 +221,13 @@ impl IntoGraphQLErrors for CacheResolverError { .clone() .into_graphql_errors() .map_err(|_err| CacheResolverError::RetrievalError(retrieval_error)), - CacheResolverError::BatchingError(msg) => Ok(vec![Error::builder() - .message(msg) - .extension_code("BATCH_PROCESSING_FAILED") - .build()]), + CacheResolverError::Backpressure(e) => Ok(vec![e.to_graphql_error()]), + CacheResolverError::BatchingError(msg) => Ok(vec![ + Error::builder() + .message(msg) + .extension_code("BATCH_PROCESSING_FAILED") + .build(), + ]), } } } @@ -236,9 +241,6 @@ impl From for CacheResolverError { /// Error types for service building. #[derive(Error, Debug, Display)] pub(crate) enum ServiceBuildError { - /// couldn't build Query Planner Service: {0} - QueryPlannerError(QueryPlannerError), - /// failed to initialize the query planner: {0} QpInitError(FederationError), @@ -255,18 +257,6 @@ impl From for ServiceBuildError { } } -impl From> for ServiceBuildError { - fn from(errors: Vec) -> Self { - ServiceBuildError::QueryPlannerError(errors.into()) - } -} - -impl From for ServiceBuildError { - fn from(error: router_bridge::error::Error) -> Self { - ServiceBuildError::QueryPlannerError(error.into()) - } -} - impl From for ServiceBuildError { fn from(err: BoxError) -> Self { ServiceBuildError::ServiceError(err) @@ -274,53 +264,102 @@ impl From for ServiceBuildError { } /// Error types for QueryPlanner +/// +/// This error may be cached so no temporary errors may be defined here. #[derive(Error, Debug, Display, Clone, Serialize, Deserialize)] pub(crate) enum QueryPlannerError { - /// couldn't instantiate query planner; invalid schema: {0} - SchemaValidationErrors(PlannerErrors), - /// invalid query: {0} OperationValidationErrors(ValidationErrors), - /// couldn't plan query: {0} - PlanningErrors(PlanErrors), - /// query planning panicked: {0} JoinError(String), - /// Cache resolution failed: {0} - CacheResolverError(Arc), - /// empty query plan. This behavior is unexpected and we suggest opening an issue to apollographql/router with a reproduction. - EmptyPlan(UsageReporting), // usage_reporting_signature + EmptyPlan(String), // usage_reporting stats_report_key /// unhandled planner result UnhandledPlannerResult, - /// router bridge error: {0} - RouterBridgeError(router_bridge::error::Error), - /// spec error: {0} SpecError(SpecError), - /// introspection error: {0} - Introspection(IntrospectionError), - /// complexity limit exceeded LimitExceeded(OperationLimits), + // Safe to cache because user scopes and policies are included in the cache key. /// Unauthorized field or type Unauthorized(Vec), - /// Query planner pool error: {0} - PoolProcessing(String), - /// Federation error: {0} - // TODO: make `FederationError` serializable and store it as-is? - FederationError(String), + FederationError(FederationErrorBridge), + + /// Query planning timed out: {0} + Timeout(String), +} + +impl From for QueryPlannerError { + fn from(value: FederationErrorBridge) -> Self { + Self::FederationError(value) + } } -impl IntoGraphQLErrors for Vec { +/// A temporary error type used to extract a few variants from `apollo-federation`'s +/// `FederationError`. For backwards compatibility, these other variant need to be extracted so +/// that the correct status code (GRAPHQL_VALIDATION_ERROR) can be added to the response. For +/// router 2.0, apollo-federation should split its error type into internal and external types. +/// When this happens, this temp type should be replaced with that type. +// TODO(@TylerBloom): See the comment above +#[derive(Error, Debug, Display, Clone, Serialize, Deserialize)] +pub(crate) enum FederationErrorBridge { + /// {0} + UnknownOperation(String), + /// {0} + OperationNameNotProvided(String), + /// {0} + Other(String), + /// {0} + Cancellation(String), +} + +impl From for FederationErrorBridge { + fn from(value: FederationError) -> Self { + match &value { + err @ FederationError::SingleFederationError( + apollo_federation::error::SingleFederationError::UnknownOperation, + ) => Self::UnknownOperation(err.to_string()), + err @ FederationError::SingleFederationError( + apollo_federation::error::SingleFederationError::OperationNameNotProvided, + ) => Self::OperationNameNotProvided(err.to_string()), + err @ FederationError::SingleFederationError( + apollo_federation::error::SingleFederationError::PlanningCancelled, + ) => Self::Cancellation(err.to_string()), + err => Self::Other(err.to_string()), + } + } +} + +impl IntoGraphQLErrors for FederationErrorBridge { + fn into_graphql_errors(self) -> Result, Self> { + match self { + FederationErrorBridge::UnknownOperation(msg) => Ok(vec![ + Error::builder() + .message(msg) + .extension_code("GRAPHQL_VALIDATION_FAILED") + .build(), + ]), + FederationErrorBridge::OperationNameNotProvided(msg) => Ok(vec![ + Error::builder() + .message(msg) + .extension_code("GRAPHQL_VALIDATION_FAILED") + .build(), + ]), + // All other errors will be pushed on and be treated as internal server errors + err => Err(err), + } + } +} + +impl IntoGraphQLErrors for Vec { fn into_graphql_errors(self) -> Result, Self> { Ok(self .into_iter() @@ -350,25 +389,11 @@ impl IntoGraphQLErrors for QueryPlannerError { QueryPlannerError::SpecError(err) => err .into_graphql_errors() .map_err(QueryPlannerError::SpecError), - QueryPlannerError::SchemaValidationErrors(errs) => errs - .into_graphql_errors() - .map_err(QueryPlannerError::SchemaValidationErrors), + QueryPlannerError::OperationValidationErrors(errs) => errs .into_graphql_errors() .map_err(QueryPlannerError::OperationValidationErrors), - QueryPlannerError::PlanningErrors(planning_errors) => Ok(planning_errors - .errors - .iter() - .map(|p_err| Error::from(p_err.clone())) - .collect()), - QueryPlannerError::Introspection(introspection_error) => Ok(vec![Error::builder() - .message( - introspection_error - .message - .unwrap_or_else(|| "introspection error".to_string()), - ) - .extension_code("INTROSPECTION_ERROR") - .build()]), + QueryPlannerError::LimitExceeded(OperationLimits { depth, height, @@ -408,6 +433,9 @@ impl IntoGraphQLErrors for QueryPlannerError { ); Ok(errors) } + QueryPlannerError::FederationError(err) => err + .into_graphql_errors() + .map_err(QueryPlannerError::FederationError), err => Err(err), } } @@ -416,71 +444,20 @@ impl IntoGraphQLErrors for QueryPlannerError { impl QueryPlannerError { pub(crate) fn usage_reporting(&self) -> Option { match self { - QueryPlannerError::PlanningErrors(pe) => Some(pe.usage_reporting.clone()), - QueryPlannerError::SpecError(e) => Some(UsageReporting { - stats_report_key: e.get_error_key().to_string(), - referenced_fields_by_type: HashMap::new(), - }), + QueryPlannerError::SpecError(e) => { + Some(UsageReporting::Error(e.get_error_key().to_string())) + } _ => None, } } } -#[derive(Clone, Debug, Error, Serialize, Deserialize)] -/// Container for planner setup errors -pub(crate) struct PlannerErrors(Arc>); - -impl IntoGraphQLErrors for PlannerErrors { - fn into_graphql_errors(self) -> Result, Self> { - let errors = self.0.iter().map(|e| Error::from(e.clone())).collect(); - - Ok(errors) - } -} - -impl std::fmt::Display for PlannerErrors { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_fmt(format_args!( - "schema validation errors: {}", - self.0 - .iter() - .map(std::string::ToString::to_string) - .collect::>() - .join(", ") - )) - } -} - -impl From> for QueryPlannerError { - fn from(errors: Vec) -> Self { - QueryPlannerError::SchemaValidationErrors(PlannerErrors(Arc::new(errors))) - } -} - -impl From for QueryPlannerError { - fn from(errors: router_bridge::planner::PlanErrors) -> Self { - QueryPlannerError::PlanningErrors(errors.into()) - } -} - -impl From for QueryPlannerError { - fn from(errors: PlanErrors) -> Self { - QueryPlannerError::PlanningErrors(errors) - } -} - impl From for QueryPlannerError { fn from(err: JoinError) -> Self { QueryPlannerError::JoinError(err.to_string()) } } -impl From for QueryPlannerError { - fn from(err: CacheResolverError) -> Self { - QueryPlannerError::CacheResolverError(Arc::new(err)) - } -} - impl From for QueryPlannerError { fn from(err: SpecError) -> Self { match err { @@ -497,12 +474,6 @@ impl From for QueryPlannerError { QueryPlannerError::OperationValidationErrors(ValidationErrors { errors: err.errors }) } } - -impl From for QueryPlannerError { - fn from(error: router_bridge::error::Error) -> Self { - QueryPlannerError::RouterBridgeError(error) - } -} impl From> for QueryPlannerError { fn from(error: OperationLimits) -> Self { QueryPlannerError::LimitExceeded(error) @@ -515,51 +486,6 @@ impl From for Response { } } -/// The payload if the plan_worker invocation failed -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct PlanErrors { - /// The errors the plan_worker invocation failed with - pub(crate) errors: Arc>, - /// Usage reporting related data such as the - /// operation signature and referenced fields - pub(crate) usage_reporting: UsageReporting, -} - -impl From for PlanErrors { - fn from( - router_bridge::planner::PlanErrors { - errors, - usage_reporting, - }: router_bridge::planner::PlanErrors, - ) -> Self { - PlanErrors { - errors, - usage_reporting, - } - } -} - -impl std::fmt::Display for PlanErrors { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_fmt(format_args!( - "query validation errors: {}", - format_bridge_errors(&self.errors) - )) - } -} - -pub(crate) fn format_bridge_errors(errors: &[router_bridge::planner::PlanError]) -> String { - errors - .iter() - .map(|e| { - e.message - .clone() - .unwrap_or_else(|| "UNKNWON ERROR".to_string()) - }) - .collect::>() - .join(", ") -} - /// Error in the schema. #[derive(Debug, Error, Display, derive_more::From)] #[non_exhaustive] @@ -574,10 +500,14 @@ pub(crate) enum SchemaError { /// GraphQL validation error: {0} Validate(ValidationErrors), /// Federation error: {0} - FederationError(apollo_federation::error::FederationError), + FederationError(FederationError), /// Api error(s): {0} #[from(ignore)] Api(String), + + /// Connector error(s): {0} + #[from(ignore)] + Connector(FederationError), } /// Collection of schema validation errors. @@ -593,7 +523,7 @@ impl std::fmt::Display for ParseErrors { if i > 0 { f.write_str("\n")?; } - write!(f, "{}", error)?; + write!(f, "{error}")?; } let remaining = errors.count(); if remaining > 0 { @@ -633,7 +563,7 @@ impl IntoGraphQLErrors for ParseErrors { /// Collection of schema validation errors. #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct ValidationErrors { - pub(crate) errors: Vec, + pub(crate) errors: Vec, } impl ValidationErrors { @@ -709,8 +639,6 @@ impl std::fmt::Display for ValidationErrors { pub(crate) enum SubgraphBatchingError { /// Sender unavailable SenderUnavailable, - /// Request does not have a subgraph name - MissingSubgraphName, /// Requests is empty RequestsIsEmpty, /// Batch processing failed: {0} @@ -720,6 +648,7 @@ pub(crate) enum SubgraphBatchingError { #[cfg(test)] mod tests { use super::*; + use crate::assert_error_eq_ignoring_id; use crate::graphql; #[test] @@ -740,40 +669,6 @@ mod tests { ) .build(); - assert_eq!(expected_gql_error, error.to_graphql_error(None)); - } - - #[test] - fn test_into_graphql_error_introspection_with_message_handled_correctly() { - let expected_message = "no can introspect".to_string(); - let ie = IntrospectionError { - message: Some(expected_message.clone()), - }; - let error = QueryPlannerError::Introspection(ie); - let mut graphql_errors = error.into_graphql_errors().expect("vec of graphql errors"); - assert_eq!(graphql_errors.len(), 1); - let first_error = graphql_errors.pop().expect("has to be one error"); - assert_eq!(first_error.message, expected_message); - assert_eq!(first_error.extensions.len(), 1); - assert_eq!( - first_error.extensions.get("code").expect("has code"), - "INTROSPECTION_ERROR" - ); - } - - #[test] - fn test_into_graphql_error_introspection_without_message_handled_correctly() { - let expected_message = "introspection error".to_string(); - let ie = IntrospectionError { message: None }; - let error = QueryPlannerError::Introspection(ie); - let mut graphql_errors = error.into_graphql_errors().expect("vec of graphql errors"); - assert_eq!(graphql_errors.len(), 1); - let first_error = graphql_errors.pop().expect("has to be one error"); - assert_eq!(first_error.message, expected_message); - assert_eq!(first_error.extensions.len(), 1); - assert_eq!( - first_error.extensions.get("code").expect("has code"), - "INTROSPECTION_ERROR" - ); + assert_error_eq_ignoring_id!(expected_gql_error, error.to_graphql_error(None)); } } diff --git a/apollo-router/src/executable.rs b/apollo-router/src/executable.rs index 86bdee162f..0921084d2a 100644 --- a/apollo-router/src/executable.rs +++ b/apollo-router/src/executable.rs @@ -1,7 +1,5 @@ //! Main entry point for CLI command to start server. -use std::cell::Cell; -use std::env; use std::fmt::Debug; use std::net::SocketAddr; #[cfg(unix)] @@ -11,40 +9,38 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; -use anyhow::anyhow; use anyhow::Result; -use clap::builder::FalseyValueParser; +use anyhow::anyhow; use clap::ArgAction; use clap::Args; -use clap::CommandFactory; use clap::Parser; use clap::Subcommand; -#[cfg(any(feature = "dhat-heap", feature = "dhat-ad-hoc"))] -use once_cell::sync::OnceCell; +use clap::builder::FalseyValueParser; +use parking_lot::Mutex; use regex::Captures; use regex::Regex; use url::ParseError; use url::Url; +use crate::LicenseSource; +use crate::configuration::Discussed; +use crate::configuration::expansion::Expansion; use crate::configuration::generate_config_schema; use crate::configuration::generate_upgrade; -use crate::configuration::Discussed; -use crate::metrics::meter_provider; +use crate::configuration::schema::Mode; +use crate::configuration::validate_yaml_configuration; +use crate::metrics::meter_provider_internal; use crate::plugin::plugins; use crate::plugins::telemetry::reload::init_telemetry; +use crate::registry::OciConfig; use crate::router::ConfigurationSource; use crate::router::RouterHttpServer; use crate::router::SchemaSource; use crate::router::ShutdownSource; use crate::uplink::Endpoints; use crate::uplink::UplinkConfig; -use crate::LicenseSource; -#[cfg(all( - feature = "global-allocator", - not(feature = "dhat-heap"), - target_os = "linux" -))] +#[cfg(all(feature = "global-allocator", not(feature = "dhat-heap"), unix))] #[global_allocator] static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; @@ -56,58 +52,50 @@ static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; pub(crate) static ALLOC: dhat::Alloc = dhat::Alloc; #[cfg(feature = "dhat-heap")] -pub(crate) static mut DHAT_HEAP_PROFILER: OnceCell = OnceCell::new(); +pub(crate) static DHAT_HEAP_PROFILER: Mutex> = Mutex::new(None); #[cfg(feature = "dhat-ad-hoc")] -pub(crate) static mut DHAT_AD_HOC_PROFILER: OnceCell = OnceCell::new(); +pub(crate) static DHAT_AD_HOC_PROFILER: Mutex> = Mutex::new(None); -pub(crate) const APOLLO_ROUTER_DEV_ENV: &str = "APOLLO_ROUTER_DEV"; +pub(crate) static APOLLO_ROUTER_DEV_MODE: AtomicBool = AtomicBool::new(false); +pub(crate) static APOLLO_ROUTER_SUPERGRAPH_PATH_IS_SET: AtomicBool = AtomicBool::new(false); +pub(crate) static APOLLO_ROUTER_SUPERGRAPH_URLS_IS_SET: AtomicBool = AtomicBool::new(false); +pub(crate) static APOLLO_ROUTER_LICENCE_IS_SET: AtomicBool = AtomicBool::new(false); +pub(crate) static APOLLO_ROUTER_LICENCE_PATH_IS_SET: AtomicBool = AtomicBool::new(false); +pub(crate) static APOLLO_TELEMETRY_DISABLED: AtomicBool = AtomicBool::new(false); +pub(crate) static APOLLO_ROUTER_LISTEN_ADDRESS: Mutex> = Mutex::new(None); + +const INITIAL_UPLINK_POLL_INTERVAL: Duration = Duration::from_secs(10); // Note: Constructor/Destructor functions may not play nicely with tracing, since they run after // main completes, so don't use tracing, use println!() and eprintln!().. #[cfg(feature = "dhat-heap")] fn create_heap_profiler() { - unsafe { - match DHAT_HEAP_PROFILER.set(dhat::Profiler::new_heap()) { - Ok(p) => { - println!("heap profiler installed: {:?}", p); - libc::atexit(drop_heap_profiler); - } - Err(e) => eprintln!("heap profiler install failed: {:?}", e), - } - } + *DHAT_HEAP_PROFILER.lock() = Some(dhat::Profiler::new_heap()); + println!("heap profiler installed"); + unsafe { libc::atexit(drop_heap_profiler) }; } #[cfg(feature = "dhat-heap")] -#[no_mangle] +#[unsafe(no_mangle)] extern "C" fn drop_heap_profiler() { - unsafe { - if let Some(p) = DHAT_HEAP_PROFILER.take() { - drop(p); - } + if let Some(p) = DHAT_HEAP_PROFILER.lock().take() { + drop(p); } } #[cfg(feature = "dhat-ad-hoc")] fn create_ad_hoc_profiler() { - unsafe { - match DHAT_AD_HOC_PROFILER.set(dhat::Profiler::new_ad_hoc()) { - Ok(p) => { - println!("ad-hoc profiler installed: {:?}", p); - libc::atexit(drop_ad_hoc_profiler); - } - Err(e) => eprintln!("ad-hoc profiler install failed: {:?}", e), - } - } + *DHAT_AD_HOC_PROFILER.lock() = Some(dhat::Profiler::new_heap()); + println!("ad-hoc profiler installed"); + unsafe { libc::atexit(drop_ad_hoc_profiler) }; } #[cfg(feature = "dhat-ad-hoc")] -#[no_mangle] +#[unsafe(no_mangle)] extern "C" fn drop_ad_hoc_profiler() { - unsafe { - if let Some(p) = DHAT_AD_HOC_PROFILER.take() { - drop(p); - } + if let Some(p) = DHAT_AD_HOC_PROFILER.lock().take() { + drop(p); } } @@ -140,6 +128,12 @@ enum ConfigSubcommand { #[clap(action = ArgAction::SetTrue, long)] diff: bool, }, + /// Validate existing Router configuration file + Validate { + /// The location of the config to validate. + #[clap(value_parser, env = "APOLLO_ROUTER_CONFIG_PATH")] + config_path: PathBuf, + }, /// List all the available experimental configurations with related GitHub discussion Experimental, /// List all the available preview configurations with related GitHub discussion @@ -181,11 +175,7 @@ pub struct Opt { config_path: Option, /// Enable development mode. - #[clap( - env = APOLLO_ROUTER_DEV_ENV, - long = "dev", - action(ArgAction::SetTrue) - )] + #[clap(env = "APOLLO_ROUTER_DEV", long = "dev", action(ArgAction::SetTrue))] dev: bool, /// Schema location relative to the project directory. @@ -201,10 +191,6 @@ pub struct Opt { #[clap(env = "APOLLO_ROUTER_SUPERGRAPH_URLS", value_delimiter = ',')] supergraph_urls: Option>, - /// Prints the configuration schema. - #[clap(long, action(ArgAction::SetTrue), hide(true))] - schema: bool, - /// Subcommands #[clap(subcommand)] command: Option, @@ -235,9 +221,10 @@ pub struct Opt { // Should be a Vec when https://github.com/clap-rs/clap/discussions/3796 is solved apollo_uplink_endpoints: Option, - /// The time between polls to Apollo uplink. Minimum 10s. - #[clap(long, default_value = "10s", value_parser = humantime::parse_duration, env)] - apollo_uplink_poll_interval: Duration, + /// An OCI reference to an image that contains the supergraph schema for the router. + #[clap(long, env, action = ArgAction::Append)] + // TODO: Update name to be final public name + graph_artifact_reference: Option, /// Disable sending anonymous usage information to Apollo. #[clap(long, env = "APOLLO_TELEMETRY_DISABLED", value_parser = FalseyValueParser::new())] @@ -294,13 +281,22 @@ impl Opt { .as_ref() .map(|endpoints| Self::parse_endpoints(endpoints)) .transpose()?, - poll_interval: self.apollo_uplink_poll_interval, + poll_interval: INITIAL_UPLINK_POLL_INTERVAL, timeout: self.apollo_uplink_timeout, }) } - pub(crate) fn is_telemetry_disabled(&self) -> bool { - self.anonymous_telemetry_disabled + pub(crate) fn oci_config(&self) -> Result { + Ok(OciConfig { + apollo_key: self + .apollo_key + .clone() + .ok_or(Self::err_require_opt("APOLLO_KEY"))?, + reference: self + .graph_artifact_reference + .clone() + .ok_or(Self::err_require_opt(" GRAPH_ARTIFACT_REFERENCE"))?, + }) } fn parse_endpoints(endpoints: &str) -> std::result::Result { @@ -333,12 +329,16 @@ pub fn main() -> Result<()> { let mut builder = tokio::runtime::Builder::new_multi_thread(); builder.enable_all(); - if let Some(nb) = std::env::var("APOLLO_ROUTER_NUM_CORES") + + // This environment variable is intentionally undocumented. + // See also APOLLO_ROUTER_COMPUTE_THREADS in apollo-router/src/compute_job.rs + if let Some(nb) = std::env::var("APOLLO_ROUTER_IO_THREADS") .ok() .and_then(|value| value.parse::().ok()) { builder.worker_threads(nb); } + let runtime = builder.build()?; runtime.block_on(Executable::builder().start()) } @@ -405,7 +405,18 @@ impl Executable { return Ok(()); } - copy_args_to_env(); + *crate::services::APOLLO_KEY.lock() = opt.apollo_key.clone(); + *crate::services::APOLLO_GRAPH_REF.lock() = opt.apollo_graph_ref.clone(); + *APOLLO_ROUTER_LISTEN_ADDRESS.lock() = opt.listen_address; + APOLLO_ROUTER_DEV_MODE.store(opt.dev, Ordering::Relaxed); + APOLLO_ROUTER_SUPERGRAPH_PATH_IS_SET + .store(opt.supergraph_path.is_some(), Ordering::Relaxed); + APOLLO_ROUTER_SUPERGRAPH_URLS_IS_SET + .store(opt.supergraph_urls.is_some(), Ordering::Relaxed); + APOLLO_ROUTER_LICENCE_IS_SET.store(opt.apollo_router_license.is_some(), Ordering::Relaxed); + APOLLO_ROUTER_LICENCE_PATH_IS_SET + .store(opt.apollo_router_license_path.is_some(), Ordering::Relaxed); + APOLLO_TELEMETRY_DISABLED.store(opt.anonymous_telemetry_disabled, Ordering::Relaxed); let apollo_telemetry_initialized = if graph_os() { init_telemetry(&opt.log_level)?; @@ -417,13 +428,6 @@ impl Executable { setup_panic_handler(); - if opt.schema { - eprintln!("`router --schema` is deprecated. Use `router config schema`"); - let schema = generate_config_schema(); - println!("{}", serde_json::to_string_pretty(&schema)?); - return Ok(()); - } - let result = match opt.command.as_ref() { Some(Commands::Config(ConfigSubcommandArgs { command: ConfigSubcommand::Schema, @@ -432,6 +436,21 @@ impl Executable { println!("{}", serde_json::to_string_pretty(&schema)?); Ok(()) } + Some(Commands::Config(ConfigSubcommandArgs { + command: ConfigSubcommand::Validate { config_path }, + })) => { + let config_string = std::fs::read_to_string(config_path)?; + validate_yaml_configuration( + &config_string, + Expansion::default()?, + Mode::NoUpgrade, + )? + .validate()?; + + println!("Configuration at path {config_path:?} is valid!"); + + Ok(()) + } Some(Commands::Config(ConfigSubcommandArgs { command: ConfigSubcommand::Upgrade { config_path, diff }, })) => { @@ -459,7 +478,7 @@ impl Executable { // We should be good to shutdown OpenTelemetry now as the router should have finished everything. tokio::task::spawn_blocking(move || { opentelemetry::global::shutdown_tracer_provider(); - meter_provider().shutdown(); + meter_provider_internal().shutdown(); }) .await?; } @@ -473,9 +492,6 @@ impl Executable { license: Option, mut opt: Opt, ) -> Result<()> { - if opt.apollo_uplink_poll_interval < Duration::from_secs(10) { - return Err(anyhow!("apollo-uplink-poll-interval must be at least 10s")); - } let current_directory = std::env::current_dir()?; // Enable hot reload when dev mode is enabled opt.hot_reload = opt.hot_reload || opt.dev; @@ -501,7 +517,6 @@ impl Executable { ConfigurationSource::File { path, watch: opt.hot_reload, - delay: None, } }) .unwrap_or_default(), @@ -513,25 +528,35 @@ impl Executable { "Anonymous usage data is gathered to inform Apollo product development. See https://go.apollo.dev/o/privacy for details.".to_string() }; - let apollo_router_msg = format!("Apollo Router v{} // (c) Apollo Graph, Inc. // Licensed as ELv2 (https://go.apollo.dev/elv2)", std::env!("CARGO_PKG_VERSION")); + let apollo_router_msg = format!( + "Apollo Router v{} // (c) Apollo Graph, Inc. // Licensed as ELv2 (https://go.apollo.dev/elv2)", + std::env!("CARGO_PKG_VERSION") + ); // Schema source will be in order of precedence: // 1. Cli --supergraph // 2. Env APOLLO_ROUTER_SUPERGRAPH_PATH // 3. Env APOLLO_ROUTER_SUPERGRAPH_URLS - // 4. Env APOLLO_KEY and APOLLO_GRAPH_REF + // 4. Env APOLLO_KEY and GRAPH_ARTIFACT_REFERENCE + // 5. Env APOLLO_KEY and APOLLO_GRAPH_REF #[cfg(unix)] let akp = &opt.apollo_key_path; #[cfg(not(unix))] let akp: &Option = &None; - let schema_source = match (schema, &opt.supergraph_path, &opt.supergraph_urls, &opt.apollo_key, akp) { + let schema_source = match ( + schema, + &opt.supergraph_path, + &opt.supergraph_urls, + &opt.apollo_key, + akp, + ) { (Some(_), Some(_), _, _, _) | (Some(_), _, Some(_), _, _) => { return Err(anyhow!( "--supergraph and APOLLO_ROUTER_SUPERGRAPH_PATH cannot be used when a custom schema source is in use" - )) + )); } - (Some(source), None, None,_,_) => source, + (Some(source), None, None, _, _) => source, (_, Some(supergraph_path), _, _, _) => { tracing::info!("{apollo_router_msg}"); tracing::info!("{apollo_telemetry_msg}"); @@ -544,17 +569,20 @@ impl Executable { SchemaSource::File { path: supergraph_path, watch: opt.hot_reload, - delay: None, } } (_, _, Some(supergraph_urls), _, _) => { tracing::info!("{apollo_router_msg}"); tracing::info!("{apollo_telemetry_msg}"); + if opt.hot_reload { + tracing::warn!( + "Schema hot reloading is disabled for --supergraph-urls / APOLLO_ROUTER_SUPERGRAPH_URLS." + ); + } + SchemaSource::URLs { urls: supergraph_urls.clone(), - watch: opt.hot_reload, - period: opt.apollo_uplink_poll_interval } } (_, None, None, _, Some(apollo_key_path)) => { @@ -579,27 +607,23 @@ impl Executable { // Note: We could, in future, add support for Windows. #[cfg(unix)] { - let meta = std::fs::metadata(apollo_key_path.clone()).map_err(|err| - anyhow!( - "Failed to read Apollo key file: {}", - err - ))?; + let meta = std::fs::metadata(apollo_key_path.clone()) + .map_err(|err| anyhow!("Failed to read Apollo key file: {}", err))?; let mode = meta.mode(); // If our mode isn't "safe", fail... // safe == none of the "group" or "other" bits set. if mode & 0o077 != 0 { - return Err( - anyhow!( - "Apollo key file permissions ({:#o}) are too permissive", mode & 0o000777 - )); + return Err(anyhow!( + "Apollo key file permissions ({:#o}) are too permissive", + mode & 0o000777 + )); } let euid = unsafe { libc::geteuid() }; let owner = meta.uid(); if euid != owner { - return Err( - anyhow!( - "Apollo key file owner id ({owner}) does not match effective user id ({euid})" - )); + return Err(anyhow!( + "Apollo key file owner id ({owner}) does not match effective user id ({euid})" + )); } } //The key file exists try and load it @@ -608,19 +632,22 @@ impl Executable { opt.apollo_key = Some(apollo_key.trim().to_string()); } Err(err) => { - return Err(anyhow!( - "Failed to read Apollo key file: {}", - err - )); + return Err(anyhow!("Failed to read Apollo key file: {}", err)); } - }; - SchemaSource::Registry(opt.uplink_config()?) - } + }; + match opt.graph_artifact_reference { + None => SchemaSource::Registry(opt.uplink_config()?), + Some(_) => SchemaSource::OCI(opt.oci_config()?), + } + } } (_, None, None, Some(_apollo_key), None) => { tracing::info!("{apollo_router_msg}"); tracing::info!("{apollo_telemetry_msg}"); - SchemaSource::Registry(opt.uplink_config()?) + match opt.graph_artifact_reference { + None => SchemaSource::Registry(opt.uplink_config()?), + Some(_) => SchemaSource::OCI(opt.oci_config()?), + } } _ => { return Err(anyhow!( @@ -699,7 +726,9 @@ impl Executable { && !rust_log_set && ["trace", "debug", "warn", "error", "info"].contains(&apollo_router_log.as_str()) { - tracing::info!("Custom plugins are present. To see log messages from your plugins you must configure `RUST_LOG` or `APOLLO_ROUTER_LOG` environment variables. See the Router logging documentation for more details"); + tracing::info!( + "Custom plugins are present. To see log messages from your plugins you must configure `RUST_LOG` or `APOLLO_ROUTER_LOG` environment variables. See the Router logging documentation for more details" + ); } let uplink_config = opt.uplink_config().ok(); @@ -711,11 +740,13 @@ impl Executable { .url_count() == 1 { - tracing::warn!("Only a single uplink endpoint is configured. We recommend specifying at least two endpoints so that a fallback exists."); + tracing::warn!( + "Only a single uplink endpoint is configured. We recommend specifying at least two endpoints so that a fallback exists." + ); } let router = RouterHttpServer::builder() - .is_telemetry_disabled(opt.is_telemetry_disabled()) + .is_telemetry_disabled(opt.anonymous_telemetry_disabled) .configuration(configuration) .and_uplink(uplink_config) .schema(schema_source) @@ -732,7 +763,8 @@ impl Executable { } fn graph_os() -> bool { - std::env::var("APOLLO_KEY").is_ok() && std::env::var("APOLLO_GRAPH_REF").is_ok() + crate::services::APOLLO_KEY.lock().is_some() + && crate::services::APOLLO_GRAPH_REF.lock().is_some() } fn setup_panic_handler() { @@ -741,7 +773,10 @@ fn setup_panic_handler() { let show_backtraces = backtrace_env.as_deref() == Ok("1") || backtrace_env.as_deref() == Ok("full"); if show_backtraces { - tracing::warn!("RUST_BACKTRACE={} detected. This is useful for diagnostics but will have a performance impact and may leak sensitive information", backtrace_env.as_ref().unwrap()); + tracing::warn!( + "RUST_BACKTRACE={} detected. This is useful for diagnostics but will have a performance impact and may leak sensitive information", + backtrace_env.as_ref().unwrap() + ); } std::panic::set_hook(Box::new(move |e| { if show_backtraces { @@ -750,42 +785,11 @@ fn setup_panic_handler() { } else { tracing::error!("{}", e) } - if !USING_CATCH_UNWIND.get() { - // Once we've panic'ed the behaviour of the router is non-deterministic - // We've logged out the panic details. Terminate with an error code - std::process::exit(1); - } - })); -} - -// TODO: once the Rust query planner does not use `todo!()` anymore, -// remove this and the use of `catch_unwind` to call it. -thread_local! { - pub(crate) static USING_CATCH_UNWIND: Cell = const { Cell::new(false) }; -} - -static COPIED: AtomicBool = AtomicBool::new(false); -fn copy_args_to_env() { - if Ok(false) != COPIED.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) { - panic!("`copy_args_to_env` was called twice: That means `Executable::start` was called twice in the same process, which should not happen"); - } - // Copy all the args to env. - // This way, Clap is still responsible for the definitive view of what the current options are. - // But if we have code that relies on env variable then it will still work. - // Env variables should disappear over time as we move to plugins. - let matches = Opt::command().get_matches(); - Opt::command().get_arguments().for_each(|a| { - if let Some(env) = a.get_env() { - if let Some(raw) = matches - .get_raw(a.get_id().as_str()) - .unwrap_or_default() - .next() - { - env::set_var(env, raw); - } - } - }); + // Once we've panic'ed the behaviour of the router is non-deterministic + // We've logged out the panic details. Terminate with an error code + std::process::exit(1); + })); } #[cfg(test)] diff --git a/apollo-router/src/files.rs b/apollo-router/src/files.rs index 452f02aaec..f7cfc54429 100644 --- a/apollo-router/src/files.rs +++ b/apollo-router/src/files.rs @@ -3,14 +3,14 @@ use std::path::PathBuf; use std::time::Duration; use futures::prelude::*; -use notify::event::DataChange; -use notify::event::MetadataKind; -use notify::event::ModifyKind; use notify::Config; use notify::EventKind; use notify::PollWatcher; use notify::RecursiveMode; use notify::Watcher; +use notify::event::DataChange; +use notify::event::MetadataKind; +use notify::event::ModifyKind; use tokio::sync::mpsc; use tokio::sync::mpsc::error::TrySendError; @@ -29,11 +29,11 @@ const DEFAULT_WATCH_DURATION: Duration = Duration::from_millis(100); /// /// returns: impl Stream /// -pub(crate) fn watch(path: &Path) -> impl Stream { +pub(crate) fn watch(path: &Path) -> impl Stream + use<> { watch_with_duration(path, DEFAULT_WATCH_DURATION) } -fn watch_with_duration(path: &Path, duration: Duration) -> impl Stream { +fn watch_with_duration(path: &Path, duration: Duration) -> impl Stream + use<> { // Due to the vagaries of file watching across multiple platforms, instead of watching the // supplied path (file), we are going to watch the parent (directory) of the path. let config_file_path = PathBuf::from(path); @@ -101,6 +101,101 @@ fn watch_with_duration(path: &Path, duration: Duration) -> impl Stream +/// +pub(crate) fn watch_rhai(path: &Path) -> impl Stream + use<> { + watch_rhai_with_duration(path, DEFAULT_WATCH_DURATION) +} + +// We need different watcher configuration for Rhai source. +fn watch_rhai_with_duration(path: &Path, duration: Duration) -> impl Stream + use<> { + // Due to the vagaries of file watching across multiple platforms, instead of watching the + // supplied path (file), we are going to watch the parent (directory) of the path. + let rhai_source_path = PathBuf::from(path); + + let (watch_sender, watch_receiver) = mpsc::channel(1); + let watch_receiver_stream = tokio_stream::wrappers::ReceiverStream::new(watch_receiver); + // We can't use the recommended watcher, because there's just too much variation across + // platforms and file systems. We use the Poll Watcher, which is implemented consistently + // across all platforms. Less reactive than other mechanisms, but at least it's predictable + // across all environments. We compare contents as well, which reduces false positives with + // some additional processing burden. + let config = Config::default() + .with_poll_interval(duration) + .with_compare_contents(true); + let mut watcher = PollWatcher::new( + move |res: Result| { + match res { + Ok(event) => { + // Let's limit the events we are interested in to: + // - Modified files + // - Created/Remove files + // - with suffix "rhai" + if matches!( + event.kind, + EventKind::Modify(ModifyKind::Metadata(MetadataKind::WriteTime)) + | EventKind::Modify(ModifyKind::Data(DataChange::Any)) + | EventKind::Create(_) + | EventKind::Remove(_) + ) { + let mut proceed = false; + for path in &event.paths { + if path.extension().is_some_and(|ext| ext == "rhai") { + proceed = true; + break; + } + } + + if proceed { + loop { + match watch_sender.try_send(()) { + Ok(_) => break, + Err(err) => { + tracing::warn!( + "could not process file watch notification. {}", + err.to_string() + ); + if matches!(err, TrySendError::Full(_)) { + std::thread::sleep(Duration::from_millis(50)); + } else { + panic!("event channel failed: {err}"); + } + } + } + } + } + } + } + Err(e) => tracing::error!("rhai watching event error: {:?}", e), + } + }, + config, + ) + .unwrap_or_else(|_| panic!("could not create watch on: {rhai_source_path:?}")); + watcher + .watch(&rhai_source_path, RecursiveMode::Recursive) + .unwrap_or_else(|_| panic!("could not watch: {rhai_source_path:?}")); + // Tell watchers once they should read the file once, + // then listen to fs events. + stream::once(future::ready(())) + .chain(watch_receiver_stream) + .chain(stream::once(async move { + // This exists to give the stream ownership of the hotwatcher. + // Without it hotwatch will get dropped and the stream will terminate. + // This code never actually gets run. + // The ideal would be that hotwatch implements a stream and + // therefore we don't need this hackery. + drop(watcher); + })) + .boxed() +} #[cfg(test)] pub(crate) mod tests { use std::env::temp_dir; diff --git a/apollo-router/src/graphql/mod.rs b/apollo-router/src/graphql/mod.rs index 7ec24d7dd7..1e96cbda67 100644 --- a/apollo-router/src/graphql/mod.rs +++ b/apollo-router/src/graphql/mod.rs @@ -6,33 +6,29 @@ mod visitor; use std::fmt; use std::pin::Pin; +use std::str::FromStr; -use apollo_compiler::execution::GraphQLError as CompilerExecutionError; -use apollo_compiler::execution::ResponseDataPathElement; +use apollo_compiler::response::GraphQLError as CompilerExecutionError; +use apollo_compiler::response::ResponseDataPathSegment; use futures::Stream; use heck::ToShoutySnakeCase; pub use request::Request; pub use response::IncrementalResponse; +use response::MalformedResponseError; pub use response::Response; -pub use router_bridge::planner::Location; -use router_bridge::planner::PlanError; -use router_bridge::planner::PlanErrorExtensions; -use router_bridge::planner::PlannerError; -use router_bridge::planner::WorkerError; -use router_bridge::planner::WorkerGraphQLError; use serde::Deserialize; use serde::Serialize; -use serde_json_bytes::json; use serde_json_bytes::ByteString; use serde_json_bytes::Map as JsonMap; use serde_json_bytes::Value; +use uuid::Uuid; pub(crate) use visitor::ResponseVisitor; -use crate::error::FetchError; use crate::json_ext::Object; use crate::json_ext::Path; pub use crate::json_ext::Path as JsonPath; pub use crate::json_ext::PathElement as JsonPathElement; +use crate::spec::query::ERROR_CODE_RESPONSE_VALIDATION; /// An asynchronous [`Stream`] of GraphQL [`Response`]s. /// @@ -44,19 +40,29 @@ pub use crate::json_ext::PathElement as JsonPathElement; /// even if that stream happens to only contain one item. pub type ResponseStream = Pin + Send>>; +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +#[serde(rename_all = "camelCase")] +/// The error location +pub struct Location { + /// The line number + pub line: u32, + /// The column number + pub column: u32, +} + /// A [GraphQL error](https://spec.graphql.org/October2021/#sec-Errors) /// as may be found in the `errors` field of a GraphQL [`Response`]. /// /// Converted to (or from) JSON with serde. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] #[non_exhaustive] pub struct Error { /// The error message. pub message: String, /// The locations of the error in the GraphQL document of the originating request. - #[serde(skip_serializing_if = "Vec::is_empty", default)] + #[serde(skip_serializing_if = "Vec::is_empty")] pub locations: Vec, /// If this is a field error, the JSON path to that field in [`Response::data`] @@ -64,9 +70,26 @@ pub struct Error { pub path: Option, /// The optional GraphQL extensions for this error. - #[serde(default, skip_serializing_if = "Object::is_empty")] + #[serde(skip_serializing_if = "Object::is_empty")] pub extensions: Object, + + /// A unique identifier for this error + #[serde(skip_serializing)] + apollo_id: Uuid, +} + +impl Default for Error { + fn default() -> Self { + Self { + message: String::new(), + locations: Vec::new(), + path: None, + extensions: Object::new(), + apollo_id: generate_uuid(), + } + } } + // Implement getter and getter_mut to not use pub field directly #[buildstructor::buildstructor] @@ -99,79 +122,170 @@ impl Error { /// Optional, may be called multiple times. /// Adds one item to the [`Error::extensions`] map. /// + /// * `.extension_code(impl Into<`[`String`]`>)` + /// Optional. + /// Sets the "code" in the extension map. Will be ignored if extension already has this key + /// set. + /// + /// * `.apollo_id(impl Into<`[`Uuid`]`>)` + /// Optional. + /// Sets the unique identifier for this Error. This should only be used in cases of + /// deserialization or testing. If not given, the ID will be auto-generated. + /// /// * `.build()` /// Finishes the builder and returns a GraphQL [`Error`]. #[builder(visibility = "pub")] - fn new>( + fn new( message: String, locations: Vec, path: Option, - extension_code: T, - // Skip the `Object` type alias in order to use buildstructor’s map special-casing + extension_code: Option, + // Skip the `Object` type alias in order to use buildstructor's map special-casing mut extensions: JsonMap, + apollo_id: Option, ) -> Self { - extensions - .entry("code") - .or_insert_with(|| extension_code.into().into()); + if let Some(code) = extension_code { + extensions + .entry("code") + .or_insert(Value::String(ByteString::from(code))); + } Self { message, locations, path, extensions, + apollo_id: apollo_id.unwrap_or_else(Uuid::new_v4), } } - pub(crate) fn from_value(service_name: &str, value: Value) -> Result { - let mut object = - ensure_object!(value).map_err(|error| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), - reason: format!("invalid error within `errors`: {}", error), - })?; + pub(crate) fn from_value(value: Value) -> Result { + let mut object = ensure_object!(value).map_err(|error| MalformedResponseError { + reason: format!("invalid error within `errors`: {error}"), + })?; let extensions = extract_key_value_from_object!(object, "extensions", Value::Object(o) => o) - .map_err(|err| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), - reason: format!("invalid `extensions` within error: {}", err), + .map_err(|err| MalformedResponseError { + reason: format!("invalid `extensions` within error: {err}"), })? .unwrap_or_default(); - let message = extract_key_value_from_object!(object, "message", Value::String(s) => s) - .map_err(|err| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), - reason: format!("invalid `message` within error: {}", err), - })? - .map(|s| s.as_str().to_string()) - .unwrap_or_default(); + let message = match extract_key_value_from_object!(object, "message", Value::String(s) => s) + { + Ok(Some(s)) => Ok(s.as_str().to_string()), + Ok(None) => Err(MalformedResponseError { + reason: "missing required `message` property within error".to_owned(), + }), + Err(err) => Err(MalformedResponseError { + reason: format!("invalid `message` within error: {err}"), + }), + }?; let locations = extract_key_value_from_object!(object, "locations") .map(skip_invalid_locations) .map(serde_json_bytes::from_value) .transpose() - .map_err(|err| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), - reason: format!("invalid `locations` within error: {}", err), + .map_err(|err| MalformedResponseError { + reason: format!("invalid `locations` within error: {err}"), })? .unwrap_or_default(); let path = extract_key_value_from_object!(object, "path") .map(serde_json_bytes::from_value) .transpose() - .map_err(|err| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), - reason: format!("invalid `path` within error: {}", err), + .map_err(|err| MalformedResponseError { + reason: format!("invalid `path` within error: {err}"), })?; + let apollo_id: Option = extract_key_value_from_object!( + object, + "apolloId", + Value::String(s) => s + ) + .map_err(|err| MalformedResponseError { + reason: format!("invalid `apolloId` within error: {err}"), + })? + .map(|s| { + Uuid::from_str(s.as_str()).map_err(|err| MalformedResponseError { + reason: format!("invalid `apolloId` within error: {err}"), + }) + }) + .transpose()?; - Ok(Error { - message, - locations, - path, - extensions, + Ok(Self::new( + message, locations, path, None, extensions, apollo_id, + )) + } + + pub(crate) fn from_value_completion_value(value: &Value) -> Option { + let value_completion = ensure_object!(value).ok()?; + let mut extensions = value_completion + .get("extensions") + .and_then(|e: &Value| -> Option { + serde_json_bytes::from_value(e.clone()).ok() + }) + .unwrap_or_default(); + extensions.insert("code", ERROR_CODE_RESPONSE_VALIDATION.into()); + extensions.insert("severity", tracing::Level::WARN.as_str().into()); + + let message = value_completion + .get("message") + .and_then(|m| m.as_str()) + .map(|m| m.to_string()) + .unwrap_or_default(); + let locations = value_completion + .get("locations") + .map(|l: &Value| skip_invalid_locations(l.clone())) + .map(|l: Value| serde_json_bytes::from_value(l).unwrap_or_default()) + .unwrap_or_default(); + let path = + value_completion + .get("path") + .and_then(|p: &serde_json_bytes::Value| -> Option { + serde_json_bytes::from_value(p.clone()).ok() + }); + + Some(Self::new( + message, locations, path, None, extensions, + None, // apollo_id is not serialized, so it will never exist in a serialized vc error + )) + } + + /// Extract the error code from [`Error::extensions`] as a String if it is set. + pub fn extension_code(&self) -> Option { + self.extensions.get("code").and_then(|c| match c { + Value::String(s) => Some(s.as_str().to_owned()), + Value::Number(n) => Some(n.to_string()), + Value::Null | Value::Array(_) | Value::Object(_) | Value::Bool(_) => None, }) } + + /// Retrieve the internal Apollo unique ID for this error + pub fn apollo_id(&self) -> Uuid { + self.apollo_id + } + + /// Returns a duplicate of the error where [`self.apollo_id`] is now the given ID + pub fn with_apollo_id(&self, id: Uuid) -> Self { + let mut new_err = self.clone(); + new_err.apollo_id = id; + new_err + } + + #[cfg(test)] + /// Returns a duplicate of the error where [`self.apollo_id`] is `Uuid::nil()`. Used for + /// comparing errors in tests where you cannot control the randomly generated Uuid + pub fn with_null_id(&self) -> Self { + self.with_apollo_id(Uuid::nil()) + } +} + +/// Generate a random Uuid. For use in generating a default [`Error::apollo_id`] when not supplied +/// during deserialization. +fn generate_uuid() -> Uuid { + Uuid::new_v4() } /// GraphQL spec require that both "line" and "column" are positive numbers. /// However GraphQL Java and GraphQL Kotlin return `{ "line": -1, "column": -1 }` /// if they can't determine error location inside query. -/// This function removes such locations from suplied value. +/// This function removes such locations from supplied value. fn skip_invalid_locations(mut value: Value) -> Value { if let Some(array) = value.as_array_mut() { array.retain(|location| { @@ -211,73 +325,6 @@ where } } -impl ErrorExtension for PlanError {} - -impl From for Error { - fn from(err: PlanError) -> Self { - let extension_code = err.extension_code(); - let extensions = err - .extensions - .map(convert_extensions_to_map) - .unwrap_or_else(move || { - let mut object = Object::new(); - object.insert("code", extension_code.into()); - object - }); - Self { - message: err.message.unwrap_or_else(|| String::from("plan error")), - extensions, - ..Default::default() - } - } -} - -impl ErrorExtension for PlannerError { - fn extension_code(&self) -> String { - match self { - PlannerError::WorkerGraphQLError(worker_graphql_error) => worker_graphql_error - .extensions - .as_ref() - .map(|ext| ext.code.clone()) - .unwrap_or_else(|| worker_graphql_error.extension_code()), - PlannerError::WorkerError(worker_error) => worker_error - .extensions - .as_ref() - .map(|ext| ext.code.clone()) - .unwrap_or_else(|| worker_error.extension_code()), - } - } -} - -impl From for Error { - fn from(err: PlannerError) -> Self { - match err { - PlannerError::WorkerGraphQLError(err) => err.into(), - PlannerError::WorkerError(err) => err.into(), - } - } -} - -impl ErrorExtension for WorkerError {} - -impl From for Error { - fn from(err: WorkerError) -> Self { - let extension_code = err.extension_code(); - let mut extensions = err - .extensions - .map(convert_extensions_to_map) - .unwrap_or_default(); - extensions.insert("code", extension_code.into()); - - Self { - message: err.message.unwrap_or_else(|| String::from("worker error")), - locations: err.locations.into_iter().map(Location::from).collect(), - extensions, - ..Default::default() - } - } -} - impl From for Error { fn from(error: CompilerExecutionError) -> Self { let CompilerExecutionError { @@ -297,10 +344,10 @@ impl From for Error { let elements = path .into_iter() .map(|element| match element { - ResponseDataPathElement::Field(name) => { + ResponseDataPathSegment::Field(name) => { JsonPathElement::Key(name.as_str().to_owned(), None) } - ResponseDataPathElement::ListIndex(i) => JsonPathElement::Index(i), + ResponseDataPathSegment::ListIndex(i) => JsonPathElement::Index(i), }) .collect(); Some(Path(elements)) @@ -312,40 +359,44 @@ impl From for Error { locations, path, extensions, + apollo_id: Uuid::new_v4(), } } } -impl ErrorExtension for WorkerGraphQLError {} +/// Assert that the expected and actual [`Error`] are equal when ignoring their +/// [`Error::apollo_id`]. +#[macro_export] +macro_rules! assert_error_eq_ignoring_id { + ($expected:expr, $actual:expr) => { + assert_eq!($expected.with_null_id(), $actual.with_null_id()); + }; +} -impl From for Error { - fn from(err: WorkerGraphQLError) -> Self { - let extension_code = err.extension_code(); - let mut extensions = err - .extensions - .map(convert_extensions_to_map) - .unwrap_or_default(); - extensions.insert("code", extension_code.into()); - Self { - message: err.message, - locations: err.locations.into_iter().map(Location::from).collect(), - extensions, - ..Default::default() - } - } +/// Assert that the expected and actual lists of [`Error`] are equal when ignoring their +/// [`Error::apollo_id`]. +#[macro_export] +macro_rules! assert_errors_eq_ignoring_id { + ($expected:expr, $actual:expr) => {{ + let normalize = + |v: &[graphql::Error]| v.iter().map(|e| e.with_null_id()).collect::>(); + + assert_eq!(normalize(&$expected), normalize(&$actual)); + }}; } -fn convert_extensions_to_map(ext: PlanErrorExtensions) -> Object { - let mut extensions = Object::new(); - extensions.insert("code", ext.code.into()); - if let Some(exception) = ext.exception { - extensions.insert( - "exception", - json!({ - "stacktrace": serde_json_bytes::Value::from(exception.stacktrace) - }), - ); - } +/// Assert that the expected and actual [`Response`] are equal when ignoring the +/// [`Error::apollo_id`] on any [`Error`] in their [`Response::errors`]. +#[macro_export] +macro_rules! assert_response_eq_ignoring_error_id { + ($expected:expr, $actual:expr) => {{ + let normalize = + |v: &[graphql::Error]| v.iter().map(|e| e.with_null_id()).collect::>(); + let mut expected_response: graphql::Response = $expected.clone(); + let mut actual_response: graphql::Response = $actual.clone(); + expected_response.errors = normalize(&expected_response.errors); + actual_response.errors = normalize(&actual_response.errors); - extensions + assert_eq!(expected_response, actual_response); + }}; } diff --git a/apollo-router/src/graphql/request.rs b/apollo-router/src/graphql/request.rs index 1e51262dbf..2ec3c99342 100644 --- a/apollo-router/src/graphql/request.rs +++ b/apollo-router/src/graphql/request.rs @@ -1,15 +1,15 @@ use bytes::Bytes; use derivative::Derivative; -use serde::de::DeserializeSeed; -use serde::de::Error; use serde::Deserialize; use serde::Serialize; +use serde::de::DeserializeSeed; +use serde::de::Error; use serde_json_bytes::ByteString; use serde_json_bytes::Map as JsonMap; -use serde_json_bytes::Value; use crate::configuration::BatchingMode; use crate::json_ext::Object; +use crate::json_ext::Value; /// A GraphQL `Request` used to represent both supergraph and subgraph requests. #[derive(Clone, Derivative, Serialize, Deserialize, Default)] @@ -137,7 +137,7 @@ impl Request { /// of some parameters to be specified that would be otherwise required /// for a real request. It's usually enough for most testing purposes, /// especially when a fully constructed `Request` is difficult to construct. - /// While today, its paramters have the same optionality as its `new` + /// While today, its parameters have the same optionality as its `new` /// counterpart, that may change in future versions. fn fake_new( query: Option, @@ -161,118 +161,73 @@ impl Request { seed.deserialize(&mut de) } - /// Convert encoded URL query string parameters (also known as "search - /// params") into a GraphQL [`Request`]. - /// - /// An error will be produced in the event that the query string parameters - /// cannot be turned into a valid GraphQL `Request`. - pub(crate) fn batch_from_urlencoded_query( - url_encoded_query: String, - ) -> Result, serde_json::Error> { - let value: serde_json::Value = serde_urlencoded::from_bytes(url_encoded_query.as_bytes()) - .map_err(serde_json::Error::custom)?; - - Request::process_query_values(&value) - } - /// Convert Bytes into a GraphQL [`Request`]. /// /// An error will be produced in the event that the bytes array cannot be /// turned into a valid GraphQL `Request`. pub(crate) fn batch_from_bytes(bytes: &[u8]) -> Result, serde_json::Error> { - let value: serde_json::Value = - serde_json::from_slice(bytes).map_err(serde_json::Error::custom)?; + let value: Value = serde_json::from_slice(bytes).map_err(serde_json::Error::custom)?; - Request::process_batch_values(&value) + Request::process_batch_values(value) } - fn allocate_result_array(value: &serde_json::Value) -> Vec { + fn allocate_result_array(value: &Value) -> Vec { match value.as_array() { Some(array) => Vec::with_capacity(array.len()), None => Vec::with_capacity(1), } } - fn process_batch_values(value: &serde_json::Value) -> Result, serde_json::Error> { - let mut result = Request::allocate_result_array(value); + fn process_batch_values(value: Value) -> Result, serde_json::Error> { + let mut result = Request::allocate_result_array(&value); - if value.is_array() { - tracing::info!( - histogram.apollo.router.operations.batching.size = result.len() as f64, - mode = %BatchingMode::BatchHttpLink // Only supported mode right now + if let Value::Array(entries) = value { + u64_histogram!( + "apollo.router.operations.batching.size", + "Number of queries contained within each query batch", + entries.len() as u64, + mode = BatchingMode::BatchHttpLink.to_string() // Only supported mode right now ); - tracing::info!( - monotonic_counter.apollo.router.operations.batching = 1u64, - mode = %BatchingMode::BatchHttpLink // Only supported mode right now + u64_counter!( + "apollo.router.operations.batching", + "Total requests with batched operations", + 1, + mode = BatchingMode::BatchHttpLink.to_string() // Only supported mode right now ); - for entry in value - .as_array() - .expect("We already checked that it was an array") - { - let bytes = serde_json::to_vec(entry)?; + for entry in entries { + let bytes = serde_json::to_vec(&entry)?; result.push(Request::deserialize_from_bytes(&bytes.into())?); } } else { - let bytes = serde_json::to_vec(value)?; + let bytes = serde_json::to_vec(&value)?; result.push(Request::deserialize_from_bytes(&bytes.into())?); } Ok(result) } - fn process_query_values(value: &serde_json::Value) -> Result, serde_json::Error> { - let mut result = Request::allocate_result_array(value); - - if value.is_array() { - tracing::info!( - histogram.apollo.router.operations.batching.size = result.len() as f64, - mode = "batch_http_link" // Only supported mode right now - ); - - tracing::info!( - monotonic_counter.apollo.router.operations.batching = 1u64, - mode = "batch_http_link" // Only supported mode right now - ); - for entry in value - .as_array() - .expect("We already checked that it was an array") - { - result.push(Request::process_value(entry)?); - } - } else { - result.push(Request::process_value(value)?) - } - Ok(result) - } - - fn process_value(value: &serde_json::Value) -> Result { - let operation_name = - if let Some(serde_json::Value::String(operation_name)) = value.get("operationName") { - Some(operation_name.clone()) - } else { - None - }; - - let query = if let Some(serde_json::Value::String(query)) = value.get("query") { - Some(query.as_str()) - } else { - None - }; - let variables: Object = get_from_urlencoded_value(value, "variables")?.unwrap_or_default(); - let extensions: Object = - get_from_urlencoded_value(value, "extensions")?.unwrap_or_default(); - - let request_builder = Self::builder() + fn process_value(value: &Value) -> Result { + let operation_name = value.get("operationName").and_then(Value::as_str); + let query = value.get("query").and_then(Value::as_str).map(String::from); + let variables: Object = value + .get("variables") + .and_then(Value::as_str) + .map(serde_json::from_str) + .transpose()? + .unwrap_or_default(); + let extensions: Object = value + .get("extensions") + .and_then(Value::as_str) + .map(serde_json::from_str) + .transpose()? + .unwrap_or_default(); + + let request = Self::builder() + .and_query(query) .variables(variables) .and_operation_name(operation_name) - .extensions(extensions); - - let request = if let Some(query_str) = query { - request_builder.query(query_str).build() - } else { - request_builder.build() - }; - + .extensions(extensions) + .build(); Ok(request) } @@ -282,28 +237,16 @@ impl Request { /// An error will be produced in the event that the query string parameters /// cannot be turned into a valid GraphQL `Request`. pub fn from_urlencoded_query(url_encoded_query: String) -> Result { - let urldecoded: serde_json::Value = - serde_urlencoded::from_bytes(url_encoded_query.as_bytes()) - .map_err(serde_json::Error::custom)?; + let urldecoded: Value = serde_urlencoded::from_bytes(url_encoded_query.as_bytes()) + .map_err(serde_json::Error::custom)?; Request::process_value(&urldecoded) } } -fn get_from_urlencoded_value<'a, T: Deserialize<'a>>( - object: &'a serde_json::Value, - key: &str, -) -> Result, serde_json::Error> { - if let Some(serde_json::Value::String(byte_string)) = object.get(key) { - Some(serde_json::from_str(byte_string.as_str())).transpose() - } else { - Ok(None) - } -} - struct RequestFromBytesSeed<'data>(&'data Bytes); -impl<'data, 'de> DeserializeSeed<'de> for RequestFromBytesSeed<'data> { +impl<'de> DeserializeSeed<'de> for RequestFromBytesSeed<'_> { type Value = Request; fn deserialize(self, deserializer: D) -> Result @@ -325,7 +268,7 @@ impl<'data, 'de> DeserializeSeed<'de> for RequestFromBytesSeed<'data> { struct RequestVisitor<'data>(&'data Bytes); - impl<'data, 'de> serde::de::Visitor<'de> for RequestVisitor<'data> { + impl<'de> serde::de::Visitor<'de> for RequestVisitor<'_> { type Value = Request; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { diff --git a/apollo-router/src/graphql/response.rs b/apollo-router/src/graphql/response.rs index 6b44f1d2cc..b4bcdd7868 100644 --- a/apollo-router/src/graphql/response.rs +++ b/apollo-router/src/graphql/response.rs @@ -1,19 +1,27 @@ #![allow(missing_docs)] // FIXME use std::time::Instant; +use apollo_compiler::response::ExecutionResponse; use bytes::Bytes; +use displaydoc::Display; use serde::Deserialize; use serde::Serialize; use serde_json_bytes::ByteString; use serde_json_bytes::Map; use crate::error::Error; -use crate::error::FetchError; use crate::graphql::IntoGraphQLErrors; use crate::json_ext::Object; use crate::json_ext::Path; use crate::json_ext::Value; +#[derive(thiserror::Error, Display, Debug, Eq, PartialEq)] +#[error("GraphQL response was malformed: {reason}")] +pub(crate) struct MalformedResponseError { + /// The reason the deserialization failed. + pub(crate) reason: String, +} + /// A graphql primary response. /// Used for federated and subgraph queries. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Default)] @@ -58,7 +66,6 @@ pub struct Response { impl Response { /// Constructor #[builder(visibility = "pub")] - #[allow(clippy::too_many_arguments)] fn new( label: Option, data: Option, @@ -97,63 +104,50 @@ impl Response { /// Create a [`Response`] from the supplied [`Bytes`]. /// /// This will return an error (identifying the faulty service) if the input is invalid. - pub(crate) fn from_bytes(service_name: &str, b: Bytes) -> Result { - let value = - Value::from_bytes(b).map_err(|error| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), - reason: error.to_string(), - })?; - let object = - ensure_object!(value).map_err(|error| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), - reason: error.to_string(), - })?; - Response::from_object(service_name, object) + pub(crate) fn from_bytes(b: Bytes) -> Result { + let value = Value::from_bytes(b).map_err(|error| MalformedResponseError { + reason: error.to_string(), + })?; + Response::from_value(value) } - pub(crate) fn from_object( - service_name: &str, - mut object: Object, - ) -> Result { + pub(crate) fn from_value(value: Value) -> Result { + let mut object = ensure_object!(value).map_err(|error| MalformedResponseError { + reason: error.to_string(), + })?; let data = object.remove("data"); let errors = extract_key_value_from_object!(object, "errors", Value::Array(v) => v) - .map_err(|err| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), + .map_err(|err| MalformedResponseError { reason: err.to_string(), })? .into_iter() .flatten() - .map(|v| Error::from_value(service_name, v)) - .collect::, FetchError>>()?; + .map(Error::from_value) + .collect::, MalformedResponseError>>()?; let extensions = extract_key_value_from_object!(object, "extensions", Value::Object(o) => o) - .map_err(|err| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), + .map_err(|err| MalformedResponseError { reason: err.to_string(), })? .unwrap_or_default(); let label = extract_key_value_from_object!(object, "label", Value::String(s) => s) - .map_err(|err| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), + .map_err(|err| MalformedResponseError { reason: err.to_string(), })? .map(|s| s.as_str().to_string()); let path = extract_key_value_from_object!(object, "path") .map(serde_json_bytes::from_value) .transpose() - .map_err(|err| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), + .map_err(|err| MalformedResponseError { reason: err.to_string(), })?; let has_next = extract_key_value_from_object!(object, "hasNext", Value::Bool(b) => b) - .map_err(|err| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), + .map_err(|err| MalformedResponseError { reason: err.to_string(), })?; let incremental = extract_key_value_from_object!(object, "incremental", Value::Array(a) => a).map_err( - |err| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), + |err| MalformedResponseError { reason: err.to_string(), }, )?; @@ -162,8 +156,7 @@ impl Response { .into_iter() .map(serde_json_bytes::from_value) .collect::, _>>() - .map_err(|err| FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), + .map_err(|err| MalformedResponseError { reason: err.to_string(), })?, None => vec![], @@ -172,8 +165,7 @@ impl Response { // If the data entry in the response is not present, the errors entry in the response must not be empty. // It must contain at least one error. The errors it contains should indicate why no data was able to be returned. if data.is_none() && errors.is_empty() { - return Err(FetchError::SubrequestMalformedResponse { - service: service_name.to_string(), + return Err(MalformedResponseError { reason: "graphql response without data must contain at least one error".to_string(), }); } @@ -245,25 +237,13 @@ impl IncrementalResponse { } } -impl From for Response { - fn from(response: apollo_compiler::execution::Response) -> Response { - let apollo_compiler::execution::Response { - errors, - data, - extensions, - } = response; +impl From for Response { + fn from(response: ExecutionResponse) -> Response { + let ExecutionResponse { errors, data } = response; Self { errors: errors.into_graphql_errors().unwrap(), - data: match data { - apollo_compiler::execution::ResponseData::Object(map) => { - Some(serde_json_bytes::Value::Object(map)) - } - apollo_compiler::execution::ResponseData::Null => { - Some(serde_json_bytes::Value::Null) - } - apollo_compiler::execution::ResponseData::Absent => None, - }, - extensions, + data: data.map(serde_json_bytes::Value::Object), + extensions: Default::default(), label: None, path: None, has_next: None, @@ -276,36 +256,43 @@ impl From for Response { #[cfg(test)] mod tests { - use router_bridge::planner::Location; use serde_json::json; use serde_json_bytes::json as bjson; + use uuid::Uuid; use super::*; + use crate::assert_response_eq_ignoring_error_id; + use crate::graphql; + use crate::graphql::Error; + use crate::graphql::Location; + use crate::graphql::Response; #[test] fn test_append_errors_path_fallback_and_override() { + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); let expected_errors = vec![ - Error { - message: "Something terrible happened!".to_string(), - path: Some(Path::from("here")), - ..Default::default() - }, - Error { - message: "I mean for real".to_string(), - ..Default::default() - }, + Error::builder() + .message("Something terrible happened!") + .path(Path::from("here")) + .apollo_id(uuid1) + .build(), + Error::builder() + .message("I mean for real") + .apollo_id(uuid2) + .build(), ]; let mut errors_to_append = vec![ - Error { - message: "Something terrible happened!".to_string(), - path: Some(Path::from("here")), - ..Default::default() - }, - Error { - message: "I mean for real".to_string(), - ..Default::default() - }, + Error::builder() + .message("Something terrible happened!") + .path(Path::from("here")) + .apollo_id(uuid1) + .build(), + Error::builder() + .message("I mean for real") + .apollo_id(uuid2) + .build(), ]; let mut response = Response::builder().build(); @@ -354,8 +341,9 @@ mod tests { .to_string() .as_str(), ); - assert_eq!( - result.unwrap(), + let response = result.unwrap(); + assert_response_eq_ignoring_error_id!( + response, Response::builder() .data(json!({ "hero": { @@ -376,17 +364,19 @@ mod tests { ] } })) - .errors(vec![Error { - message: "Name for character with ID 1002 could not be fetched.".into(), - locations: vec!(Location { line: 6, column: 7 }), - path: Some(Path::from("hero/heroFriends/1/name")), - extensions: bjson!({ - "error-extension": 5, - }) - .as_object() - .cloned() - .unwrap() - }]) + .errors(vec![ + Error::builder() + .message("Name for character with ID 1002 could not be fetched.") + .locations(vec!(Location { line: 6, column: 7 })) + .path(Path::from("hero/heroFriends/1/name")) + .extensions( + bjson!({ "error-extension": 5, }) + .as_object() + .cloned() + .unwrap() + ) + .build() + ]) .extensions( bjson!({ "response-extension": 3, @@ -443,8 +433,9 @@ mod tests { .to_string() .as_str(), ); - assert_eq!( - result.unwrap(), + let response = result.unwrap(); + assert_response_eq_ignoring_error_id!( + response, Response::builder() .label("part".to_owned()) .data(json!({ @@ -467,17 +458,19 @@ mod tests { } })) .path(Path::from("hero/heroFriends/1/name")) - .errors(vec![Error { - message: "Name for character with ID 1002 could not be fetched.".into(), - locations: vec!(Location { line: 6, column: 7 }), - path: Some(Path::from("hero/heroFriends/1/name")), - extensions: bjson!({ - "error-extension": 5, - }) - .as_object() - .cloned() - .unwrap() - }]) + .errors(vec![ + Error::builder() + .message("Name for character with ID 1002 could not be fetched.") + .locations(vec!(Location { line: 6, column: 7 })) + .path(Path::from("hero/heroFriends/1/name")) + .extensions( + bjson!({ "error-extension": 5, }) + .as_object() + .cloned() + .unwrap() + ) + .build() + ]) .extensions( bjson!({ "response-extension": 3, @@ -493,13 +486,21 @@ mod tests { #[test] fn test_no_data_and_no_errors() { - let response = Response::from_bytes("test", "{\"errors\":null}".into()); + let response = Response::from_bytes("{\"errors\":null}".into()); assert_eq!( response.expect_err("no data and no errors"), - FetchError::SubrequestMalformedResponse { - service: "test".to_string(), + MalformedResponseError { reason: "graphql response without data must contain at least one error".to_string(), } ); } + + #[test] + fn test_data_null() { + let response = Response::from_bytes("{\"data\":null}".into()).unwrap(); + assert_eq!( + response, + Response::builder().data(Some(Value::Null)).build(), + ); + } } diff --git a/apollo-router/src/graphql/visitor.rs b/apollo-router/src/graphql/visitor.rs index aa901508db..0f25af1030 100644 --- a/apollo-router/src/graphql/visitor.rs +++ b/apollo-router/src/graphql/visitor.rs @@ -120,9 +120,9 @@ mod tests { use apollo_compiler::Schema; use bytes::Bytes; use insta::assert_yaml_snapshot; - use serde::ser::SerializeMap; use serde::Serialize; use serde::Serializer; + use serde::ser::SerializeMap; use super::*; use crate::graphql::Response; @@ -135,7 +135,7 @@ mod tests { let schema = Schema::parse_and_validate(schema_str, "").unwrap(); let request = ExecutableDocument::parse(&schema, query_str, "").unwrap(); - let response = Response::from_bytes("test", Bytes::from_static(response_bytes)).unwrap(); + let response = Response::from_bytes(Bytes::from_static(response_bytes)).unwrap(); let mut visitor = FieldCounter::new(); visitor.visit(&request, &response, &Default::default()); @@ -150,7 +150,7 @@ mod tests { let schema = Schema::parse_and_validate(schema_str, "").unwrap(); let request = ExecutableDocument::parse(&schema, query_str, "").unwrap(); - let response = Response::from_bytes("test", Bytes::from_static(response_bytes)).unwrap(); + let response = Response::from_bytes(Bytes::from_static(response_bytes)).unwrap(); let mut visitor = FieldCounter::new(); visitor.visit(&request, &response, &Default::default()); @@ -165,7 +165,7 @@ mod tests { let schema = Schema::parse_and_validate(schema_str, "").unwrap(); let request = ExecutableDocument::parse(&schema, query_str, "").unwrap(); - let response = Response::from_bytes("test", Bytes::from_static(response_bytes)).unwrap(); + let response = Response::from_bytes(Bytes::from_static(response_bytes)).unwrap(); let mut visitor = FieldCounter::new(); visitor.visit(&request, &response, &Default::default()); @@ -180,7 +180,7 @@ mod tests { let schema = Schema::parse_and_validate(schema_str, "").unwrap(); let request = ExecutableDocument::parse(&schema, query_str, "").unwrap(); - let response = Response::from_bytes("test", Bytes::from_static(response_bytes)).unwrap(); + let response = Response::from_bytes(Bytes::from_static(response_bytes)).unwrap(); let mut visitor = FieldCounter::new(); visitor.visit(&request, &response, &Default::default()); diff --git a/apollo-router/src/http_ext.rs b/apollo-router/src/http_ext.rs index b30982ec1c..c7a89027da 100644 --- a/apollo-router/src/http_ext.rs +++ b/apollo-router/src/http_ext.rs @@ -7,12 +7,11 @@ use std::hash::Hash; use std::ops::Deref; use std::ops::DerefMut; -use axum::body::boxed; use axum::response::IntoResponse; use bytes::Bytes; +use http::HeaderValue; use http::header; use http::header::HeaderName; -use http::HeaderValue; use multimap::MultiMap; use crate::graphql; @@ -20,7 +19,7 @@ use crate::services::APPLICATION_JSON_HEADER_VALUE; /// Delayed-fallibility wrapper for conversion to [`http::header::HeaderName`]. /// -/// `buildstructor` builders allow doing implict conversions for convenience, +/// `buildstructor` builders allow doing implicit conversions for convenience, /// but only infallible ones. /// `HeaderName` can be converted from various types but the conversions is often fallible, /// with `TryFrom` or `TryInto` instead of `From` or `Into`. @@ -47,7 +46,7 @@ pub struct TryIntoHeaderName { /// Delayed-fallibility wrapper for conversion to [`http::header::HeaderValue`]. /// -/// `buildstructor` builders allow doing implict conversions for convenience, +/// `buildstructor` builders allow doing implicit conversions for convenience, /// but only infallible ones. /// `HeaderValue` can be converted from various types but the conversions is often fallible, /// with `TryFrom` or `TryInto` instead of `From` or `Into`. @@ -194,9 +193,8 @@ impl PartialEq for TryIntoHeaderName { impl Hash for TryIntoHeaderName { fn hash(&self, state: &mut H) { - match &self.result { - Ok(value) => value.hash(state), - Err(_) => {} + if let Ok(value) = &self.result { + value.hash(state) } } } @@ -218,27 +216,22 @@ pub(crate) fn header_map( Ok(http) } -/// Ignores `http::Extensions` +// In an earlier version of the http crate, `Request` didn't implement `Clone`, so we +// implemented partial support (excluding Extensions) for `Clone`. `Request` does now implement +// `Clone`, so this function does now clone Requests fully. +// - https://github.com/hyperium/http/pull/634 +// - https://github.com/hyperium/http/releases/tag/v1.0.0 pub(crate) fn clone_http_request(request: &http::Request) -> http::Request { - let mut new = http::Request::builder() - .method(request.method().clone()) - .uri(request.uri().clone()) - .version(request.version()) - .body(request.body().clone()) - .unwrap(); - *new.headers_mut() = request.headers().clone(); - new + request.clone() } -/// Ignores `http::Extensions` +// In an earlier version of the http crate, `Response` didn't implement `Clone`, so we +// implemented partial support (excluding Extensions) for `Clone`. `Response` does now implement +// `Clone`, so this function does now clone Responses fully. +// - https://github.com/hyperium/http/pull/634 +// - https://github.com/hyperium/http/releases/tag/v1.0.0 pub(crate) fn clone_http_response(response: &http::Response) -> http::Response { - let mut new = http::Response::builder() - .status(response.status()) - .version(response.version()) - .body(response.body().clone()) - .unwrap(); - *new.headers_mut() = response.headers().clone(); - new + response.clone() } /// Wrap an http Request. @@ -445,7 +438,7 @@ impl IntoResponse for Response { .headers .insert(header::CONTENT_TYPE, APPLICATION_JSON_HEADER_VALUE.clone()); - axum::response::Response::from_parts(parts, boxed(http_body::Full::new(json_body_bytes))) + axum::response::Response::from_parts(parts, axum::body::Body::from(json_body_bytes)) } } @@ -454,7 +447,7 @@ impl IntoResponse for Response { // todo: chunks? let (parts, body) = http::Response::from(self).into_parts(); - axum::response::Response::from_parts(parts, boxed(http_body::Full::new(body))) + axum::response::Response::from_parts(parts, axum::body::Body::from(body)) } } @@ -464,6 +457,8 @@ mod test { use http::Method; use http::Uri; + use super::clone_http_request; + use super::clone_http_response; use crate::http_ext::Request; #[test] @@ -488,4 +483,34 @@ mod test { assert_eq!(request.method(), Method::POST); assert_eq!(request.body(), &"test"); } + + #[test] + fn cloning_an_http_request_includes_extensions() { + let mut request = http::Request::new("body".to_string()); + request.extensions_mut().insert::(6); + let cloned_request = clone_http_request(&request); + + assert_eq!( + *cloned_request + .extensions() + .get::() + .expect("it has the usize extension"), + 6 + ); + } + + #[test] + fn cloning_an_http_response_includes_extensions() { + let mut response = http::Response::new("body".to_string()); + response.extensions_mut().insert::(6); + let cloned_response = clone_http_response(&response); + + assert_eq!( + *cloned_response + .extensions() + .get::() + .expect("it has the usize extension"), + 6 + ); + } } diff --git a/apollo-router/src/http_server_factory.rs b/apollo-router/src/http_server_factory.rs index c66c9da0f0..f3a6d3a6ef 100644 --- a/apollo-router/src/http_server_factory.rs +++ b/apollo-router/src/http_server_factory.rs @@ -31,13 +31,11 @@ pub(crate) trait HttpServerFactory { main_listener: Option, previous_listeners: ExtraListeners, extra_endpoints: MultiMap, - license: LicenseState, + license: Arc, all_connections_stopped_sender: mpsc::Sender<()>, ) -> Self::Future where RF: RouterFactory; - fn live(&self, live: bool); - fn ready(&self, ready: bool); } type ExtraListeners = Vec<(ListenAddr, Listener)>; @@ -101,13 +99,12 @@ impl HttpServerHandle { } } + #[cfg(unix)] pub(crate) async fn shutdown(mut self) -> Result<(), ApolloRouterError> { - #[cfg(unix)] let listen_addresses = std::mem::take(&mut self.listen_addresses); let (_main_listener, _extra_listener) = self.wait_for_servers().await?; - #[cfg(unix)] // listen_addresses includes the main graphql_address for listen_address in listen_addresses { if let ListenAddr::UnixSocket(path) = listen_address { @@ -117,13 +114,20 @@ impl HttpServerHandle { Ok(()) } + #[cfg(not(unix))] + pub(crate) async fn shutdown(self) -> Result<(), ApolloRouterError> { + let (_main_listener, _extra_listener) = self.wait_for_servers().await?; + + Ok(()) + } + pub(crate) async fn restart( self, factory: &SF, router: RF, configuration: Arc, web_endpoints: MultiMap, - license: LicenseState, + license: Arc, ) -> Result where SF: HttpServerFactory, @@ -195,6 +199,9 @@ pub(crate) enum Listener { }, } +// Though there is a large difference in variant sizes, only a few instances of this type will +// exist ever, so it's not a big deal. +#[allow(clippy::large_enum_variant)] pub(crate) enum NetworkStream { Tcp(tokio::net::TcpStream), #[cfg(unix)] diff --git a/apollo-router/src/introspection.rs b/apollo-router/src/introspection.rs index a0a539895d..90298b7714 100644 --- a/apollo-router/src/introspection.rs +++ b/apollo-router/src/introspection.rs @@ -5,24 +5,36 @@ use std::sync::Arc; use apollo_compiler::executable::Selection; use serde_json_bytes::json; +use crate::Configuration; use crate::cache::storage::CacheStorage; +use crate::compute_job; +use crate::compute_job::ComputeBackPressureError; +use crate::compute_job::ComputeJobType; use crate::graphql; use crate::query_planner::QueryKey; use crate::services::layers::query_analysis::ParsedDocument; use crate::spec; -use crate::Configuration; -const DEFAULT_INTROSPECTION_CACHE_CAPACITY: NonZeroUsize = - unsafe { NonZeroUsize::new_unchecked(5) }; +const DEFAULT_INTROSPECTION_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(5).unwrap(); #[derive(Clone)] -pub(crate) enum IntrospectionCache { +pub(crate) struct IntrospectionCache(Mode); + +#[derive(Clone)] +enum Mode { Disabled, Enabled { storage: Arc>, + max_depth: MaxDepth, }, } +#[derive(Copy, Clone)] +enum MaxDepth { + Check, + Ignore, +} + impl IntrospectionCache { pub(crate) fn new(configuration: &Configuration) -> Self { if configuration.supergraph.introspection { @@ -31,16 +43,23 @@ impl IntrospectionCache { "introspection", )); storage.activate(); - Self::Enabled { storage } + Self(Mode::Enabled { + storage, + max_depth: if configuration.limits.introspection_max_depth { + MaxDepth::Check + } else { + MaxDepth::Ignore + }, + }) } else { - Self::Disabled + Self(Mode::Disabled) } } pub(crate) fn activate(&self) { - match self { - IntrospectionCache::Disabled => {} - IntrospectionCache::Enabled { storage } => storage.activate(), + match &self.0 { + Mode::Disabled => {} + Mode::Enabled { storage, .. } => storage.activate(), } } @@ -51,13 +70,23 @@ impl IntrospectionCache { schema: &Arc, key: &QueryKey, doc: &ParsedDocument, - ) -> ControlFlow { + ) -> ControlFlow, ()> { Self::maybe_lone_root_typename(schema, doc)?; if doc.operation.is_query() { - if doc.has_explicit_root_fields && doc.has_schema_introspection { - ControlFlow::Break(Self::mixed_fields_error())?; + if doc.has_schema_introspection { + if doc.has_explicit_root_fields { + ControlFlow::Break(Ok(Self::mixed_fields_error()))?; + } else { + ControlFlow::Break(self.cached_introspection(schema, key, doc).await)? + } } else if !doc.has_explicit_root_fields { - ControlFlow::Break(self.cached_introspection(schema, key, doc).await)? + // Root __typename only + + // No list field so depth is already known to be zero: + let max_depth = MaxDepth::Ignore; + + // Probably a small query, execute it without caching: + ControlFlow::Break(Ok(Self::execute_introspection(max_depth, schema, doc)))? } } ControlFlow::Continue(()) @@ -71,22 +100,22 @@ impl IntrospectionCache { fn maybe_lone_root_typename( schema: &Arc, doc: &ParsedDocument, - ) -> ControlFlow { - if doc.operation.selection_set.selections.len() == 1 { - if let Selection::Field(field) = &doc.operation.selection_set.selections[0] { - if field.name == "__typename" && field.directives.is_empty() { - // `{ alias: __typename }` is much less common so handling it here is not essential - // but easier than a conditional to reject it - let key = field.response_key().as_str(); - let object_type_name = schema - .api_schema() - .root_operation(doc.operation.operation_type) - .expect("validation should have caught undefined root operation") - .as_str(); - let data = json!({key: object_type_name}); - ControlFlow::Break(graphql::Response::builder().data(data).build())? - } - } + ) -> ControlFlow, ()> { + if doc.operation.selection_set.selections.len() == 1 + && let Selection::Field(field) = &doc.operation.selection_set.selections[0] + && field.name == "__typename" + && field.directives.is_empty() + { + // `{ alias: __typename }` is much less common so handling it here is not essential + // but easier than a conditional to reject it + let key = field.response_key().as_str(); + let object_type_name = schema + .api_schema() + .root_operation(doc.operation.operation_type) + .expect("validation should have caught undefined root operation") + .as_str(); + let data = json!({key: object_type_name}); + ControlFlow::Break(Ok(graphql::Response::builder().data(data).build()))? } ControlFlow::Continue(()) } @@ -109,15 +138,15 @@ impl IntrospectionCache { schema: &Arc, key: &QueryKey, doc: &ParsedDocument, - ) -> graphql::Response { - let storage = match self { - IntrospectionCache::Enabled { storage } => storage, - IntrospectionCache::Disabled => { + ) -> Result { + let (storage, max_depth) = match &self.0 { + Mode::Enabled { storage, max_depth } => (storage, *max_depth), + Mode::Disabled => { let error = graphql::Error::builder() .message(String::from("introspection has been disabled")) .extension_code("INTROSPECTION_DISABLED") .build(); - return graphql::Response::builder().error(error).build(); + return Ok(graphql::Response::builder().error(error).build()); } }; let query = key.filtered_query.clone(); @@ -126,36 +155,58 @@ impl IntrospectionCache { // https://github.com/apollographql/router/issues/3831 let cache_key = query; if let Some(response) = storage.get(&cache_key, |_| unreachable!()).await { - return response; + return Ok(response); } let schema = schema.clone(); let doc = doc.clone(); - let response = - tokio::task::spawn_blocking(move || Self::execute_introspection(&schema, &doc)) - .await - .expect("Introspection panicked"); + let response = compute_job::execute(ComputeJobType::Introspection, move |_| { + Self::execute_introspection(max_depth, &schema, &doc) + })? + // `expect()` propagates any panic that potentially happens in the closure, but: + // + // * We try to avoid such panics in the first place and consider them bugs + // * The panic handler in `apollo-router/src/executable.rs` exits the process + // so this error case should never be reached. + .await; storage.insert(cache_key, response.clone()).await; - response + Ok(response) } - fn execute_introspection(schema: &spec::Schema, doc: &ParsedDocument) -> graphql::Response { - let schema = schema.api_schema(); + fn execute_introspection( + max_depth: MaxDepth, + schema: &spec::Schema, + doc: &ParsedDocument, + ) -> graphql::Response { + let api_schema = schema.api_schema(); let operation = &doc.operation; let variable_values = Default::default(); - match apollo_compiler::execution::coerce_variable_values( - schema, - operation, - &variable_values, - ) { - Ok(variable_values) => apollo_compiler::execution::execute_introspection_only_query( - schema, - &doc.executable, - operation, - &variable_values, - ) - .into(), + let max_depth_result = match max_depth { + MaxDepth::Check => { + apollo_compiler::introspection::check_max_depth(&doc.executable, operation) + } + MaxDepth::Ignore => Ok(()), + }; + let result = max_depth_result + .and_then(|()| { + apollo_compiler::request::coerce_variable_values( + api_schema, + operation, + &variable_values, + ) + }) + .and_then(|variable_values| { + apollo_compiler::introspection::partial_execute( + api_schema, + &schema.implementers_map, + &doc.executable, + operation, + &variable_values, + ) + }); + match result { + Ok(response) => response.into(), Err(e) => { - let error = e.into_graphql_error(&doc.executable.sources); + let error = e.to_graphql_error(&doc.executable.sources); graphql::Response::builder().error(error).build() } } diff --git a/apollo-router/src/json_ext.rs b/apollo-router/src/json_ext.rs index 9bc94833d0..a2d42fbc93 100644 --- a/apollo-router/src/json_ext.rs +++ b/apollo-router/src/json_ext.rs @@ -26,10 +26,26 @@ pub(crate) type Object = Map; const FRAGMENT_PREFIX: &str = "... on "; static TYPE_CONDITIONS_REGEX: Lazy = Lazy::new(|| { - Regex::new(r"(?:\|\[)(?.+?)(?:,\s*|)(?:\])") + Regex::new(r"\|\[(?.+?)?\]") .expect("this regex to check for type conditions is valid") }); +/// Extract the condition list from the regex captures. +fn extract_matched_conditions(caps: &Captures) -> TypeConditions { + caps.name("condition") + .map(|c| c.as_str().split(',').map(|s| s.to_string()).collect()) + .unwrap_or_default() +} + +fn split_path_element_and_type_conditions(s: &str) -> (String, Option) { + let mut type_conditions = None; + let path_element = TYPE_CONDITIONS_REGEX.replace(s, |caps: &Captures| { + type_conditions = Some(extract_matched_conditions(caps)); + "" + }); + (path_element.to_string(), type_conditions) +} + macro_rules! extract_key_value_from_object { ($object:expr, $key:literal, $pattern:pat => $var:ident) => {{ match $object.remove($key) { @@ -152,12 +168,6 @@ pub(crate) trait ValueExt { #[track_caller] fn is_object_of_type(&self, schema: &Schema, maybe_type: &str) -> bool; - /// value type - fn json_type_name(&self) -> &'static str; - - /// Convert this value to an instance of `apollo_compiler::ast::Value` - fn to_ast(&self) -> apollo_compiler::ast::Value; - fn as_i32(&self) -> Option; } @@ -259,7 +269,7 @@ impl ValueExt for Value { (Some((field_a, value_a)), Some((field_b, value_b))) if field_a == field_b && ValueExt::eq_and_ordered(value_a, value_b) => { - continue + continue; } (Some(_), Some(_)) => break false, } @@ -276,7 +286,7 @@ impl ValueExt for Value { (Some(value_a), Some(value_b)) if ValueExt::eq_and_ordered(value_a, value_b) => { - continue + continue; } (Some(_), Some(_)) => break false, } @@ -415,7 +425,7 @@ impl ValueExt for Value { _other => { return Err(FetchError::ExecutionPathNotFound { reason: "expected an array".to_string(), - }) + }); } }, PathElement::Key(k, type_conditions) => { @@ -440,7 +450,7 @@ impl ValueExt for Value { _other => { return Err(FetchError::ExecutionPathNotFound { reason: "expected an object".to_string(), - }) + }); } } } @@ -526,53 +536,11 @@ impl ValueExt for Value { && self .get(TYPENAME) .and_then(|v| v.as_str()) - .map_or(true, |typename| { + .is_none_or(|typename| { typename == maybe_type || schema.is_subtype(maybe_type, typename) }) } - fn json_type_name(&self) -> &'static str { - match self { - Value::Array(_) => "array", - Value::Null => "null", - Value::Bool(_) => "boolean", - Value::Number(_) => "number", - Value::String(_) => "string", - Value::Object(_) => "object", - } - } - - fn to_ast(&self) -> apollo_compiler::ast::Value { - match self { - Value::Null => apollo_compiler::ast::Value::Null, - Value::Bool(b) => apollo_compiler::ast::Value::Boolean(*b), - Value::Number(n) if n.is_f64() => { - apollo_compiler::ast::Value::Float(n.as_f64().expect("is float").into()) - } - Value::Number(n) => { - apollo_compiler::ast::Value::Int((n.as_i64().expect("is int") as i32).into()) - } - Value::String(s) => apollo_compiler::ast::Value::String(s.as_str().to_string()), - Value::Array(inner_vars) => apollo_compiler::ast::Value::List( - inner_vars - .iter() - .map(|v| apollo_compiler::Node::new(v.to_ast())) - .collect(), - ), - Value::Object(inner_vars) => apollo_compiler::ast::Value::Object( - inner_vars - .iter() - .map(|(k, v)| { - ( - apollo_compiler::Name::new(k.as_str()).expect("is valid name"), - apollo_compiler::Node::new(v.to_ast()), - ) - }) - .collect(), - ), - } - } - fn as_i32(&self) -> Option { self.as_i64()?.to_i32() } @@ -582,10 +550,10 @@ fn filter_type_conditions(value: Value, type_conditions: &Option if let Some(tc) = type_conditions { match value { Value::Object(ref o) => { - if let Some(Value::String(type_name)) = &o.get("__typename") { - if !tc.iter().any(|tc| tc.as_str() == type_name.as_str()) { - return Value::Null; - } + if let Some(Value::String(type_name)) = &o.get("__typename") + && !tc.iter().any(|tc| tc.as_str() == type_name.as_str()) + { + return Value::Null; } } Value::Array(v) => { @@ -617,28 +585,24 @@ fn iterate_path<'a, F>( for (i, value) in array.iter().enumerate() { if let Some(tc) = type_conditions { if !tc.is_empty() { - if let Value::Object(o) = value { - if let Some(Value::String(type_name)) = o.get("__typename") { - if tc.iter().any(|tc| tc.as_str() == type_name.as_str()) { - parent.push(PathElement::Index(i)); - iterate_path(schema, parent, &path[1..], value, f); - parent.pop(); - } - } + if let Value::Object(o) = value + && let Some(Value::String(type_name)) = o.get("__typename") + && tc.iter().any(|tc| tc.as_str() == type_name.as_str()) + { + parent.push(PathElement::Index(i)); + iterate_path(schema, parent, &path[1..], value, f); + parent.pop(); } if let Value::Array(array) = value { for (i, value) in array.iter().enumerate() { - if let Value::Object(o) = value { - if let Some(Value::String(type_name)) = o.get("__typename") - { - if tc.iter().any(|tc| tc.as_str() == type_name.as_str()) - { - parent.push(PathElement::Index(i)); - iterate_path(schema, parent, &path[1..], value, f); - parent.pop(); - } - } + if let Value::Object(o) = value + && let Some(Value::String(type_name)) = o.get("__typename") + && tc.iter().any(|tc| tc.as_str() == type_name.as_str()) + { + parent.push(PathElement::Index(i)); + iterate_path(schema, parent, &path[1..], value, f); + parent.pop(); } } } @@ -652,37 +616,35 @@ fn iterate_path<'a, F>( } } Some(PathElement::Index(i)) => { - if let Value::Array(a) = data { - if let Some(value) = a.get(*i) { - parent.push(PathElement::Index(*i)); - iterate_path(schema, parent, &path[1..], value, f); - parent.pop(); - } + if let Value::Array(a) = data + && let Some(value) = a.get(*i) + { + parent.push(PathElement::Index(*i)); + iterate_path(schema, parent, &path[1..], value, f); + parent.pop(); } } Some(PathElement::Key(k, type_conditions)) => { if let Some(tc) = type_conditions { if !tc.is_empty() { if let Value::Object(o) = data { - if let Some(value) = o.get(k.as_str()) { - if let Some(Value::String(type_name)) = value.get("__typename") { - if tc.iter().any(|tc| tc.as_str() == type_name.as_str()) { - parent.push(PathElement::Key(k.to_string(), None)); - iterate_path(schema, parent, &path[1..], value, f); - parent.pop(); - } - } + if let Some(value) = o.get(k.as_str()) + && let Some(Value::String(type_name)) = value.get("__typename") + && tc.iter().any(|tc| tc.as_str() == type_name.as_str()) + { + parent.push(PathElement::Key(k.to_string(), None)); + iterate_path(schema, parent, &path[1..], value, f); + parent.pop(); } } else if let Value::Array(array) = data { for (i, value) in array.iter().enumerate() { - if let Value::Object(o) = value { - if let Some(Value::String(type_name)) = o.get("__typename") { - if tc.iter().any(|tc| tc.as_str() == type_name.as_str()) { - parent.push(PathElement::Index(i)); - iterate_path(schema, parent, path, value, f); - parent.pop(); - } - } + if let Value::Object(o) = value + && let Some(Value::String(type_name)) = o.get("__typename") + && tc.iter().any(|tc| tc.as_str() == type_name.as_str()) + { + parent.push(PathElement::Index(i)); + iterate_path(schema, parent, path, value, f); + parent.pop(); } } } @@ -735,16 +697,14 @@ fn iterate_path_mut<'a, F>( if let Some(array) = data.as_array_mut() { for (i, value) in array.iter_mut().enumerate() { if let Some(tc) = type_conditions { - if !tc.is_empty() { - if let Value::Object(o) = value { - if let Some(Value::String(type_name)) = o.get("__typename") { - if tc.iter().any(|tc| tc.as_str() == type_name.as_str()) { - parent.push(PathElement::Index(i)); - iterate_path_mut(schema, parent, &path[1..], value, f); - parent.pop(); - } - } - } + if !tc.is_empty() + && let Value::Object(o) = value + && let Some(Value::String(type_name)) = o.get("__typename") + && tc.iter().any(|tc| tc.as_str() == type_name.as_str()) + { + parent.push(PathElement::Index(i)); + iterate_path_mut(schema, parent, &path[1..], value, f); + parent.pop(); } } else { parent.push(PathElement::Index(i)); @@ -755,37 +715,35 @@ fn iterate_path_mut<'a, F>( } } Some(PathElement::Index(i)) => { - if let Value::Array(a) = data { - if let Some(value) = a.get_mut(*i) { - parent.push(PathElement::Index(*i)); - iterate_path_mut(schema, parent, &path[1..], value, f); - parent.pop(); - } + if let Value::Array(a) = data + && let Some(value) = a.get_mut(*i) + { + parent.push(PathElement::Index(*i)); + iterate_path_mut(schema, parent, &path[1..], value, f); + parent.pop(); } } Some(PathElement::Key(k, type_conditions)) => { if let Some(tc) = type_conditions { if !tc.is_empty() { if let Value::Object(o) = data { - if let Some(value) = o.get_mut(k.as_str()) { - if let Some(Value::String(type_name)) = value.get("__typename") { - if tc.iter().any(|tc| tc.as_str() == type_name.as_str()) { - parent.push(PathElement::Key(k.to_string(), None)); - iterate_path_mut(schema, parent, &path[1..], value, f); - parent.pop(); - } - } + if let Some(value) = o.get_mut(k.as_str()) + && let Some(Value::String(type_name)) = value.get("__typename") + && tc.iter().any(|tc| tc.as_str() == type_name.as_str()) + { + parent.push(PathElement::Key(k.to_string(), None)); + iterate_path_mut(schema, parent, &path[1..], value, f); + parent.pop(); } } else if let Value::Array(array) = data { for (i, value) in array.iter_mut().enumerate() { - if let Value::Object(o) = value { - if let Some(Value::String(type_name)) = o.get("__typename") { - if tc.iter().any(|tc| tc.as_str() == type_name.as_str()) { - parent.push(PathElement::Index(i)); - iterate_path_mut(schema, parent, path, value, f); - parent.pop(); - } - } + if let Value::Object(o) = value + && let Some(Value::String(type_name)) = o.get("__typename") + && tc.iter().any(|tc| tc.as_str() == type_name.as_str()) + { + parent.push(PathElement::Index(i)); + iterate_path_mut(schema, parent, path, value, f); + parent.pop(); } } } @@ -870,13 +828,13 @@ where struct FlattenVisitor; -impl<'de> serde::de::Visitor<'de> for FlattenVisitor { +impl serde::de::Visitor<'_> for FlattenVisitor { type Value = Option; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { write!( formatter, - "a string that is '@', potentially preceded of followed by type conditions" + "a string that is '@', potentially followed by type conditions" ) } @@ -884,23 +842,9 @@ impl<'de> serde::de::Visitor<'de> for FlattenVisitor { where E: serde::de::Error, { - let mut type_conditions: Vec = Vec::new(); - let path = TYPE_CONDITIONS_REGEX.replace(s, |caps: &Captures| { - type_conditions.extend( - caps.name("condition") - .map(|c| { - c.as_str() - .split(',') - .map(|s| s.to_string()) - .collect::>() - }) - .unwrap_or_default(), - ); - "" - }); - - if path == "@" { - Ok((!type_conditions.is_empty()).then_some(type_conditions)) + let (path_element, type_conditions) = split_path_element_and_type_conditions(s); + if path_element == "@" { + Ok(type_conditions) } else { Err(serde::de::Error::invalid_value( serde::de::Unexpected::Str(s), @@ -918,15 +862,11 @@ where S: serde::Serializer, { let tc_string = if let Some(c) = type_conditions { - if !c.is_empty() { - format!("|[{}]", c.join(",")) - } else { - "".to_string() - } + format!("|[{}]", c.join(",")) } else { "".to_string() }; - let res = format!("@{}", tc_string); + let res = format!("@{tc_string}"); serializer.serialize_str(res.as_str()) } @@ -939,13 +879,13 @@ where struct KeyVisitor; -impl<'de> serde::de::Visitor<'de> for KeyVisitor { +impl serde::de::Visitor<'_> for KeyVisitor { type Value = (String, Option); fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { write!( formatter, - "a string, potentially preceded of followed by type conditions" + "a string, potentially followed by type conditions" ) } @@ -953,21 +893,7 @@ impl<'de> serde::de::Visitor<'de> for KeyVisitor { where E: serde::de::Error, { - let mut type_conditions = Vec::new(); - let key = TYPE_CONDITIONS_REGEX.replace(s, |caps: &Captures| { - type_conditions.extend( - caps.extract::<1>() - .1 - .map(|s| s.split(',').map(|s| s.to_string())) - .into_iter() - .flatten(), - ); - "" - }); - Ok(( - key.to_string(), - (!type_conditions.is_empty()).then_some(type_conditions), - )) + Ok(split_path_element_and_type_conditions(s)) } } @@ -980,15 +906,11 @@ where S: serde::Serializer, { let tc_string = if let Some(c) = type_conditions { - if !c.is_empty() { - format!("|[{}]", c.join(",")) - } else { - "".to_string() - } + format!("|[{}]", c.join(",")) } else { "".to_string() }; - let res = format!("{}{}", key, tc_string); + let res = format!("{key}{tc_string}"); serializer.serialize_str(res.as_str()) } @@ -1001,7 +923,7 @@ where struct FragmentVisitor; -impl<'de> serde::de::Visitor<'de> for FragmentVisitor { +impl serde::de::Visitor<'_> for FragmentVisitor { type Value = String; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { @@ -1026,43 +948,16 @@ where } fn flatten_from_str(s: &str) -> Result { - let mut type_conditions = Vec::new(); - let path = TYPE_CONDITIONS_REGEX.replace(s, |caps: &Captures| { - type_conditions.extend( - caps.extract::<1>() - .1 - .map(|s| s.split(',').map(|s| s.to_string())) - .into_iter() - .flatten(), - ); - "" - }); - - if path != "@" { + let (path_element, type_conditions) = split_path_element_and_type_conditions(s); + if path_element != "@" { return Err("invalid flatten".to_string()); } - Ok(PathElement::Flatten( - (!type_conditions.is_empty()).then_some(type_conditions), - )) + Ok(PathElement::Flatten(type_conditions)) } fn key_from_str(s: &str) -> Result { - let mut type_conditions = Vec::new(); - let key = TYPE_CONDITIONS_REGEX.replace(s, |caps: &Captures| { - type_conditions.extend( - caps.extract::<1>() - .1 - .map(|s| s.split(',').map(|s| s.to_string())) - .into_iter() - .flatten(), - ); - "" - }); - - Ok(PathElement::Key( - key.to_string(), - (!type_conditions.is_empty()).then_some(type_conditions), - )) + let (key, type_conditions) = split_path_element_and_type_conditions(s); + Ok(PathElement::Key(key, type_conditions)) } /// A path into the result document. @@ -1153,11 +1048,9 @@ impl Path { PathElement::Key(key, type_conditions) => { let mut tc = String::new(); if let Some(c) = type_conditions { - if !c.is_empty() { - tc = format!("|[{}]", c.join(",")); - } + tc = format!("|[{}]", c.join(",")); }; - Some(format!("{}{}", key, tc)) + Some(format!("{key}{tc}")) } _ => None, }) @@ -1169,14 +1062,36 @@ impl Path { // Removes the empty key if at root (used for TypedConditions) pub fn remove_empty_key_root(&self) -> Self { - if let Some(PathElement::Key(k, type_conditions)) = self.0.first() { - if k.is_empty() && type_conditions.is_none() { - return Path(self.iter().skip(1).cloned().collect()); - } + if let Some(PathElement::Key(k, type_conditions)) = self.0.first() + && k.is_empty() + && type_conditions.is_none() + { + return Path(self.iter().skip(1).cloned().collect()); } self.clone() } + + // Checks whether self and other are equal if PathElement::Flatten and PathElement::Index are + // treated as equal + pub fn equal_if_flattened(&self, other: &Path) -> bool { + if self.len() != other.len() { + return false; + } + + for (elem1, elem2) in self.iter().zip(other.iter()) { + let equal_elements = match (elem1, elem2) { + (PathElement::Index(_), PathElement::Flatten(_)) => true, + (PathElement::Flatten(_), PathElement::Index(_)) => true, + (elem1, elem2) => elem1 == elem2, + }; + if !equal_elements { + return false; + } + } + + true + } } impl FromIterator for Path { @@ -1225,17 +1140,13 @@ impl fmt::Display for Path { PathElement::Key(key, type_conditions) => { write!(f, "{key}")?; if let Some(c) = type_conditions { - if !c.is_empty() { - write!(f, "|[{}]", c.join(","))?; - } + write!(f, "|[{}]", c.join(","))?; }; } PathElement::Flatten(type_conditions) => { write!(f, "@")?; if let Some(c) = type_conditions { - if !c.is_empty() { - write!(f, "|[{}]", c.join(","))?; - } + write!(f, "|[{}]", c.join(","))?; }; } PathElement::Fragment(name) => { @@ -1493,7 +1404,9 @@ mod tests { // test objects nested assert!(json!({"baz":{"foo":1,"bar":2}}).eq_and_ordered(&json!({"baz":{"foo":1,"bar":2}}))); - assert!(!json!({"baz":{"bar":2,"foo":1}}).eq_and_ordered(&json!({"baz":{"foo":1,"bar":2}}))); + assert!( + !json!({"baz":{"bar":2,"foo":1}}).eq_and_ordered(&json!({"baz":{"foo":1,"bar":2}})) + ); assert!(!json!([1,{"bar":2,"foo":1},2]).eq_and_ordered(&json!([1,{"foo":1,"bar":2},2]))); } diff --git a/apollo-router/src/layers/async_checkpoint.rs b/apollo-router/src/layers/async_checkpoint.rs index 5cd45e1713..f2cda0a9d9 100644 --- a/apollo-router/src/layers/async_checkpoint.rs +++ b/apollo-router/src/layers/async_checkpoint.rs @@ -13,14 +13,12 @@ use std::marker::PhantomData; use std::ops::ControlFlow; use std::pin::Pin; use std::sync::Arc; -use std::task::Poll; -use futures::future::BoxFuture; use futures::Future; +use futures::future::BoxFuture; use tower::BoxError; use tower::Layer; use tower::Service; -use tower::ServiceExt; /// [`Layer`] for Asynchronous Checkpoints. See [`ServiceBuilderExt::checkpoint_async()`](crate::layers::ServiceBuilderExt::checkpoint_async()). #[allow(clippy::type_complexity)] @@ -63,86 +61,11 @@ where fn layer(&self, service: S) -> Self::Service { AsyncCheckpointService { checkpoint_fn: Arc::clone(&self.checkpoint_fn), - inner: service, - } - } -} - -/// [`Service`] for OneShot (single use) Asynchronous Checkpoints. See [`ServiceBuilderExt::oneshot_checkpoint_async()`](crate::layers::ServiceBuilderExt::oneshot_checkpoint_async()). -#[allow(clippy::type_complexity)] -pub struct OneShotAsyncCheckpointService -where - Request: Send + 'static, - S: Service + Send + 'static, - >::Response: Send + 'static, - >::Future: Send + 'static, - Fut: Future>::Response, Request>, BoxError>>, -{ - inner: Option, - checkpoint_fn: Arc Fut + Send + Sync + 'static>>>, -} - -impl OneShotAsyncCheckpointService -where - Request: Send + 'static, - S: Service + Send + 'static, - >::Response: Send + 'static, - >::Future: Send + 'static, - Fut: Future>::Response, Request>, BoxError>>, -{ - /// Create an `OneShotAsyncCheckpointLayer` from a function that takes a Service Request and returns a `ControlFlow` - pub fn new(checkpoint_fn: F, service: S) -> Self - where - F: Fn(Request) -> Fut + Send + Sync + 'static, - { - Self { - checkpoint_fn: Arc::new(Box::pin(checkpoint_fn)), - inner: Some(service), + service, } } } -impl Service for OneShotAsyncCheckpointService -where - Request: Send + 'static, - S: Service + Send + 'static, - >::Response: Send + 'static, - >::Future: Send + 'static, - Fut: Future>::Response, Request>, BoxError>> - + Send - + 'static, -{ - type Response = >::Response; - - type Error = BoxError; - - type Future = BoxFuture<'static, Result>; - - fn poll_ready( - &mut self, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - // Return an error if we no longer have an inner service - match self.inner.as_mut() { - Some(inner) => inner.poll_ready(cx), - None => Poll::Ready(Err("One shot must only be called once".into())), - } - } - - fn call(&mut self, req: Request) -> Self::Future { - let checkpoint_fn = Arc::clone(&self.checkpoint_fn); - let inner = self.inner.take(); - Box::pin(async move { - let inner = inner.ok_or("One shot must only be called once")?; - match (checkpoint_fn)(req).await { - Ok(ControlFlow::Break(response)) => Ok(response), - Ok(ControlFlow::Continue(request)) => inner.oneshot(request).await, - Err(error) => Err(error), - } - }) - } -} - /// [`Service`] for Asynchronous Checkpoints. See [`ServiceBuilderExt::checkpoint_async()`](crate::layers::ServiceBuilderExt::checkpoint_async()). #[allow(clippy::type_complexity)] pub struct AsyncCheckpointService @@ -153,7 +76,7 @@ where >::Future: Send + 'static, Fut: Future>::Response, Request>, BoxError>>, { - inner: S, + service: S, checkpoint_fn: Arc Fut + Send + Sync + 'static>>>, } @@ -172,7 +95,7 @@ where { Self { checkpoint_fn: Arc::new(Box::pin(checkpoint_fn)), - inner: service, + service, } } } @@ -197,68 +120,24 @@ where &mut self, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { - self.inner.poll_ready(cx) + self.service.poll_ready(cx) } fn call(&mut self, req: Request) -> Self::Future { let checkpoint_fn = Arc::clone(&self.checkpoint_fn); - let inner = self.inner.clone(); + let service = self.service.clone(); + let mut inner = std::mem::replace(&mut self.service, service); + Box::pin(async move { match (checkpoint_fn)(req).await { Ok(ControlFlow::Break(response)) => Ok(response), - Ok(ControlFlow::Continue(request)) => inner.oneshot(request).await, + Ok(ControlFlow::Continue(request)) => inner.call(request).await, Err(error) => Err(error), } }) } } -/// [`Layer`] for OneShot (single use) Asynchronous Checkpoints. See [`ServiceBuilderExt::oneshot_checkpoint_async()`](crate::layers::ServiceBuilderExt::oneshot_checkpoint_async()). -#[allow(clippy::type_complexity)] -pub struct OneShotAsyncCheckpointLayer -where - S: Service + Send + 'static, - Fut: Future>::Response, Request>, BoxError>>, -{ - checkpoint_fn: Arc Fut + Send + Sync + 'static>>>, - phantom: PhantomData, // We use PhantomData because the compiler can't detect that S is used in the Future. -} - -impl OneShotAsyncCheckpointLayer -where - S: Service + Send + 'static, - Fut: Future>::Response, Request>, BoxError>>, -{ - /// Create an `OneShotAsyncCheckpointLayer` from a function that takes a Service Request and returns a `ControlFlow` - pub fn new(checkpoint_fn: F) -> Self - where - F: Fn(Request) -> Fut + Send + Sync + 'static, - { - Self { - checkpoint_fn: Arc::new(Box::pin(checkpoint_fn)), - phantom: PhantomData, - } - } -} - -impl Layer for OneShotAsyncCheckpointLayer -where - S: Service + Send + 'static, - >::Future: Send, - Request: Send + 'static, - >::Response: Send + 'static, - Fut: Future>::Response, Request>, BoxError>>, -{ - type Service = OneShotAsyncCheckpointService; - - fn layer(&self, service: S) -> Self::Service { - OneShotAsyncCheckpointService { - checkpoint_fn: Arc::clone(&self.checkpoint_fn), - inner: Some(service), - } - } -} - #[cfg(test)] mod async_checkpoint_tests { use tower::BoxError; @@ -277,21 +156,21 @@ mod async_checkpoint_tests { let expected_label = "from_mock_service"; let mut execution_service = MockExecutionService::new(); - execution_service.expect_clone().return_once(move || { - let mut execution_service = MockExecutionService::new(); - execution_service - .expect_call() - .times(1) - .returning(move |req: ExecutionRequest| { - Ok(ExecutionResponse::fake_builder() - .label(expected_label.to_string()) - .context(req.context) - .build() - .unwrap()) - }); - - execution_service - }); + + execution_service + .expect_clone() + .return_once(MockExecutionService::new); + + execution_service + .expect_call() + .times(1) + .returning(move |req| { + Ok(ExecutionResponse::fake_builder() + .label(expected_label.to_string()) + .context(req.context) + .build() + .unwrap()) + }); let service_stack = ServiceBuilder::new() .checkpoint_async(|req: ExecutionRequest| async { Ok(ControlFlow::Continue(req)) }) @@ -317,20 +196,19 @@ mod async_checkpoint_tests { let expected_label = "from_mock_service"; let mut router_service = MockExecutionService::new(); - router_service.expect_clone().return_once(move || { - let mut router_service = MockExecutionService::new(); - router_service - .expect_call() - .times(1) - .returning(move |_req| { - Ok(ExecutionResponse::fake_builder() - .label(expected_label.to_string()) - .build() - .unwrap()) - }); - router_service - }); + router_service + .expect_clone() + .return_once(MockExecutionService::new); + router_service + .expect_call() + .times(1) + .returning(move |_req| { + Ok(ExecutionResponse::fake_builder() + .label(expected_label.to_string()) + .build() + .unwrap()) + }); let service_stack = AsyncCheckpointLayer::new(|req| async { Ok(ControlFlow::Continue(req)) }) .layer(router_service); @@ -425,10 +303,12 @@ mod async_checkpoint_tests { .unwrap()) }); + execution_service + .expect_clone() + .returning(MockExecutionService::new); + let service_stack = ServiceBuilder::new() - .oneshot_checkpoint_async(|req: ExecutionRequest| async { - Ok(ControlFlow::Continue(req)) - }) + .checkpoint_async(|req: ExecutionRequest| async { Ok(ControlFlow::Continue(req)) }) .service(execution_service); let request = ExecutionRequest::fake_builder().build(); @@ -464,9 +344,7 @@ mod async_checkpoint_tests { }); let mut service_stack = ServiceBuilder::new() - .oneshot_checkpoint_async(|req: ExecutionRequest| async { - Ok(ControlFlow::Continue(req)) - }) + .checkpoint_async(|req: ExecutionRequest| async { Ok(ControlFlow::Continue(req)) }) .buffered() .service(execution_service); @@ -479,95 +357,14 @@ mod async_checkpoint_tests { } #[tokio::test] - async fn test_continue_oneshot() { - let expected_label = "from_mock_service"; + async fn test_double_ready_doesnt_panic() { let mut router_service = MockExecutionService::new(); - router_service - .expect_call() - .times(1) - .returning(move |_req| { - Ok(ExecutionResponse::fake_builder() - .label(expected_label.to_string()) - .build() - .unwrap()) - }); - - let service_stack = - OneShotAsyncCheckpointLayer::new(|req| async { Ok(ControlFlow::Continue(req)) }) - .layer(router_service); - - let request = ExecutionRequest::fake_builder().build(); - - let actual_label = service_stack - .oneshot(request) - .await - .unwrap() - .next_response() - .await - .unwrap() - .label - .unwrap(); - - assert_eq!(actual_label, expected_label) - } - - #[tokio::test] - async fn test_return_oneshot() { - let expected_label = "returned_before_mock_service"; - let router_service = MockExecutionService::new(); - let service_stack = OneShotAsyncCheckpointLayer::new(|_req| async { - Ok(ControlFlow::Break( - ExecutionResponse::fake_builder() - .label("returned_before_mock_service".to_string()) - .build() - .unwrap(), - )) - }) - .layer(router_service); - - let request = ExecutionRequest::fake_builder().build(); - - let actual_label = service_stack - .oneshot(request) - .await - .unwrap() - .next_response() - .await - .unwrap() - .label - .unwrap(); - - assert_eq!(actual_label, expected_label) - } - - #[tokio::test] - async fn test_error_oneshot() { - let expected_error = "checkpoint_error"; - let router_service = MockExecutionService::new(); - - let service_stack = OneShotAsyncCheckpointLayer::new(move |_req| async move { - Err(BoxError::from(expected_error)) - }) - .layer(router_service); - - let request = ExecutionRequest::fake_builder().build(); - - let actual_error = service_stack - .oneshot(request) - .await - .map(|_| unreachable!()) - .unwrap_err() - .to_string(); - - assert_eq!(actual_error, expected_error) - } - - #[tokio::test] - async fn test_double_ready_doesnt_panic() { - let router_service = MockExecutionService::new(); + router_service + .expect_clone() + .returning(MockExecutionService::new); - let mut service_stack = OneShotAsyncCheckpointLayer::new(|_req| async { + let mut service_stack = AsyncCheckpointLayer::new(|_req| async { Ok(ControlFlow::Break( ExecutionResponse::fake_builder() .label("returned_before_mock_service".to_string()) @@ -583,14 +380,20 @@ mod async_checkpoint_tests { .await .unwrap(); - assert!(service_stack.ready().await.is_err()); + assert!(service_stack.ready().await.is_ok()); } #[tokio::test] async fn test_double_call_doesnt_panic() { - let router_service = MockExecutionService::new(); + let mut router_service = MockExecutionService::new(); - let mut service_stack = OneShotAsyncCheckpointLayer::new(|_req| async { + router_service.expect_clone().returning(|| { + let mut mes = MockExecutionService::new(); + mes.expect_clone().returning(MockExecutionService::new); + mes + }); + + let mut service_stack = AsyncCheckpointLayer::new(|_req| async { Ok(ControlFlow::Break( ExecutionResponse::fake_builder() .label("returned_before_mock_service".to_string()) @@ -607,9 +410,13 @@ mod async_checkpoint_tests { .await .unwrap(); - assert!(service_stack - .call(ExecutionRequest::fake_builder().build()) - .await - .is_err()); + service_stack.ready().await.unwrap(); + + assert!( + service_stack + .call(ExecutionRequest::fake_builder().build()) + .await + .is_ok() + ); } } diff --git a/apollo-router/src/layers/map_first_graphql_response.rs b/apollo-router/src/layers/map_first_graphql_response.rs index 1f3328c6b4..98b826f04e 100644 --- a/apollo-router/src/layers/map_first_graphql_response.rs +++ b/apollo-router/src/layers/map_first_graphql_response.rs @@ -5,16 +5,16 @@ use std::future::ready; use std::task::Poll; -use futures::future::BoxFuture; -use futures::stream::once; use futures::FutureExt; use futures::StreamExt; +use futures::future::BoxFuture; +use futures::stream::once; use tower::Layer; use tower::Service; +use crate::Context; use crate::graphql; use crate::services::supergraph; -use crate::Context; /// [`Layer`] for mapping first graphql responses. See [`ServiceBuilderExt::map_first_graphql_response()`](crate::layers::ServiceBuilderExt::map_first_graphql_response()). pub struct MapFirstGraphqlResponseLayer { diff --git a/apollo-router/src/layers/mod.rs b/apollo-router/src/layers/mod.rs index 8a175639c9..2b11243395 100644 --- a/apollo-router/src/layers/mod.rs +++ b/apollo-router/src/layers/mod.rs @@ -3,24 +3,23 @@ use std::future::Future; use std::ops::ControlFlow; -use tower::buffer::BufferLayer; -use tower::layer::util::Stack; use tower::BoxError; use tower::ServiceBuilder; +use tower::buffer::BufferLayer; +use tower::layer::util::Stack; use tower_service::Service; use tracing::Span; use self::map_first_graphql_response::MapFirstGraphqlResponseLayer; use self::map_first_graphql_response::MapFirstGraphqlResponseService; +use crate::Context; use crate::graphql; use crate::layers::async_checkpoint::AsyncCheckpointLayer; -use crate::layers::async_checkpoint::OneShotAsyncCheckpointLayer; use crate::layers::instrument::InstrumentLayer; use crate::layers::map_future_with_request_data::MapFutureWithRequestDataLayer; use crate::layers::map_future_with_request_data::MapFutureWithRequestDataService; use crate::layers::sync_checkpoint::CheckpointLayer; use crate::services::supergraph; -use crate::Context; pub mod async_checkpoint; pub mod instrument; @@ -28,7 +27,15 @@ pub mod map_first_graphql_response; pub mod map_future_with_request_data; pub mod sync_checkpoint; -pub(crate) const DEFAULT_BUFFER_SIZE: usize = 20_000; +// Note: We use Buffer in many places throughout the router. 50_000 represents +// the "maximal number of requests that can be queued for the buffered +// service before backpressure is applied to callers". We set this to be +// so high, 50_000, because we anticipate that many users will want to +// +// Think of this as a backstop for when there are no other backpressure +// enforcing limits configured in a router. In future we may tweak this +// value higher or lower or expose it as a configurable. +pub(crate) const DEFAULT_BUFFER_SIZE: usize = 50_000; /// Extension to the [`ServiceBuilder`] trait to make it easy to add router specific capabilities /// (e.g.: checkpoints) to a [`Service`]. @@ -73,13 +80,13 @@ pub trait ServiceBuilderExt: Sized { fn checkpoint( self, checkpoint_fn: impl Fn( - Request, - ) -> Result< - ControlFlow<>::Response, Request>, - >::Error, - > + Send - + Sync - + 'static, + Request, + ) -> Result< + ControlFlow<>::Response, Request>, + >::Error, + > + Send + + Sync + + 'static, ) -> ServiceBuilder, L>> where S: Service + Send + 'static, @@ -148,63 +155,6 @@ pub trait ServiceBuilderExt: Sized { self.layer(AsyncCheckpointLayer::new(async_checkpoint_fn)) } - /// Decide if processing should continue or not, and if not allow returning of a response. - /// Unlike checkpoint it is possible to perform async operations in the callback. Unlike - /// checkpoint_async, this does not require that the service is `Clone` and avoids the - /// requiremnent to buffer services. - /// - /// This is useful for things like authentication where you need to make an external call to - /// check if a request should proceed or not. - /// - /// # Arguments - /// - /// * `async_checkpoint_fn`: The asynchronous callback to decide if processing should continue or not. - /// - /// returns: ServiceBuilder, L>> - /// - /// # Examples - /// - /// ```rust - /// # use std::ops::ControlFlow; - /// use futures::FutureExt; - /// # use http::Method; - /// # use tower::ServiceBuilder; - /// # use tower_service::Service; - /// # use tracing::info_span; - /// # use apollo_router::services::supergraph; - /// # use apollo_router::layers::ServiceBuilderExt; - /// # fn test(service: supergraph::BoxService) { - /// let _ = ServiceBuilder::new() - /// .oneshot_checkpoint_async(|req: supergraph::Request| - /// async { - /// if req.supergraph_request.method() == Method::GET { - /// Ok(ControlFlow::Break(supergraph::Response::builder() - /// .data("Only get requests allowed") - /// .context(req.context) - /// .build()?)) - /// } else { - /// Ok(ControlFlow::Continue(req)) - /// } - /// } - /// .boxed() - /// ) - /// .service(service); - /// # } - /// ``` - fn oneshot_checkpoint_async( - self, - async_checkpoint_fn: F, - ) -> ServiceBuilder, L>> - where - S: Service + Send + 'static, - Fut: Future< - Output = Result>::Response, Request>, BoxError>, - >, - F: Fn(Request) -> Fut + Send + Sync + 'static, - { - self.layer(OneShotAsyncCheckpointLayer::new(async_checkpoint_fn)) - } - /// Adds a buffer to the service stack with a default size. /// /// This is useful for making services `Clone` and `Send` diff --git a/apollo-router/src/layers/sync_checkpoint.rs b/apollo-router/src/layers/sync_checkpoint.rs index 655aa2cf12..b5df946a08 100644 --- a/apollo-router/src/layers/sync_checkpoint.rs +++ b/apollo-router/src/layers/sync_checkpoint.rs @@ -51,13 +51,13 @@ where /// Create a `CheckpointLayer` from a function that takes a Service Request and returns a `ControlFlow` pub fn new( checkpoint_fn: impl Fn( - Request, - ) -> Result< - ControlFlow<>::Response, Request>, - >::Error, - > + Send - + Sync - + 'static, + Request, + ) -> Result< + ControlFlow<>::Response, Request>, + >::Error, + > + Send + + Sync + + 'static, ) -> Self { Self { checkpoint_fn: Arc::new(checkpoint_fn), @@ -119,13 +119,13 @@ where /// Create a `CheckpointLayer` from a function that takes a Service Request and returns a `ControlFlow` pub fn new( checkpoint_fn: impl Fn( - Request, - ) -> Result< - ControlFlow<>::Response, Request>, - >::Error, - > + Send - + Sync - + 'static, + Request, + ) -> Result< + ControlFlow<>::Response, Request>, + >::Error, + > + Send + + Sync + + 'static, inner: S, ) -> Self { Self { diff --git a/apollo-router/src/lib.rs b/apollo-router/src/lib.rs index 9ac39c9e23..35443dec19 100644 --- a/apollo-router/src/lib.rs +++ b/apollo-router/src/lib.rs @@ -19,8 +19,6 @@ #![cfg_attr(feature = "failfast", allow(unreachable_code))] #![warn(unreachable_pub)] #![warn(missing_docs)] -// TODO: silence false positives (apollo_compiler::Name) and investigate the rest -#![allow(clippy::mutable_key_type)] macro_rules! failfast_debug { ($($tokens:tt)+) => {{ @@ -50,12 +48,14 @@ mod json_ext; pub mod plugin; #[macro_use] -pub(crate) mod metrics; +pub mod metrics; +mod ageing_priority_queue; mod apollo_studio_interop; pub(crate) mod axum_factory; mod batching; mod cache; +mod compute_job; mod configuration; mod context; mod error; @@ -81,14 +81,17 @@ pub mod test_harness; pub mod tracer; mod uplink; -pub use crate::axum_factory::unsupported_set_axum_router_callback; +#[doc(hidden)] +pub mod otel_compat; +mod registry; + pub use crate::configuration::Configuration; pub use crate::configuration::ListenAddr; -pub use crate::context::extensions::sync::ExtensionsMutex; -pub use crate::context::extensions::Extensions; pub use crate::context::Context; -pub use crate::executable::main; +pub use crate::context::extensions::Extensions; +pub use crate::context::extensions::sync::ExtensionsMutex; pub use crate::executable::Executable; +pub use crate::executable::main; pub use crate::notification::Notify; pub use crate::router::ApolloRouterError; pub use crate::router::ConfigurationSource; @@ -97,10 +100,15 @@ pub use crate::router::RouterHttpServer; pub use crate::router::SchemaSource; pub use crate::router::ShutdownSource; pub use crate::router_factory::Endpoint; -pub use crate::test_harness::make_fake_batch; pub use crate::test_harness::MockedSubgraphs; pub use crate::test_harness::TestHarness; +#[cfg(any(test, feature = "snapshot"))] +pub use crate::test_harness::http_snapshot::SnapshotServer; +#[cfg(any(test, feature = "snapshot"))] +pub use crate::test_harness::http_snapshot::standalone::main as snapshot_server; +pub use crate::test_harness::make_fake_batch; pub use crate::uplink::UplinkConfig; +pub use crate::uplink::license_enforcement::AllowedFeature; /// Not part of the public API #[doc(hidden)] @@ -108,16 +116,21 @@ pub mod _private { // Reexports for macros pub use linkme; pub use once_cell; - pub use router_bridge; pub use serde_json; - pub use crate::plugin::PluginFactory; pub use crate::plugin::PLUGINS; - // For comparison/fuzzing - pub use crate::query_planner::bridge_query_planner::render_diff; - pub use crate::query_planner::bridge_query_planner::QueryPlanResult; - pub use crate::query_planner::dual_query_planner::diff_plan; - pub use crate::query_planner::dual_query_planner::plan_matches; + pub use crate::plugin::PluginFactory; // For tests + pub use crate::plugins::mock_subgraphs::testing_subgraph_call as mock_subgraphs_subgraph_call; pub use crate::router_factory::create_test_service_factory_from_yaml; + pub use crate::services::APOLLO_GRAPH_REF; + pub use crate::services::APOLLO_KEY; + + pub fn compute_job_queued_count() -> &'static std::sync::atomic::AtomicUsize { + &crate::compute_job::queue().queued_count + } + pub mod telemetry { + pub use crate::plugins::telemetry::config::AttributeValue; + pub use crate::plugins::telemetry::resource::ConfigResource; + } } diff --git a/apollo-router/src/metrics/aggregation.rs b/apollo-router/src/metrics/aggregation.rs index d136f82e5a..72706a7763 100644 --- a/apollo-router/src/metrics/aggregation.rs +++ b/apollo-router/src/metrics/aggregation.rs @@ -3,12 +3,15 @@ use std::borrow::Cow; use std::collections::HashMap; use std::ops::DerefMut; use std::sync::Arc; -use std::sync::Mutex; use derive_more::From; use itertools::Itertools; +use opentelemetry::KeyValue; +use opentelemetry::metrics::AsyncInstrument; use opentelemetry::metrics::Callback; +use opentelemetry::metrics::CallbackRegistration; use opentelemetry::metrics::Counter; +use opentelemetry::metrics::Gauge; use opentelemetry::metrics::Histogram; use opentelemetry::metrics::InstrumentProvider; use opentelemetry::metrics::Meter; @@ -16,15 +19,13 @@ use opentelemetry::metrics::MeterProvider; use opentelemetry::metrics::ObservableCounter; use opentelemetry::metrics::ObservableGauge; use opentelemetry::metrics::ObservableUpDownCounter; +use opentelemetry::metrics::Observer; use opentelemetry::metrics::SyncCounter; +use opentelemetry::metrics::SyncGauge; use opentelemetry::metrics::SyncHistogram; use opentelemetry::metrics::SyncUpDownCounter; -use opentelemetry::metrics::Unit; use opentelemetry::metrics::UpDownCounter; -use opentelemetry::KeyValue; -use opentelemetry_api::metrics::AsyncInstrument; -use opentelemetry_api::metrics::CallbackRegistration; -use opentelemetry_api::metrics::Observer; +use parking_lot::Mutex; use crate::metrics::filter::FilterMeterProvider; @@ -39,6 +40,7 @@ use crate::metrics::filter::FilterMeterProvider; pub(crate) enum MeterProviderType { PublicPrometheus, Apollo, + ApolloRealtime, Public, OtelDefault, } @@ -60,7 +62,7 @@ impl Default for AggregateMeterProvider { meter_provider.set( MeterProviderType::OtelDefault, Some(FilterMeterProvider::public( - opentelemetry_api::global::meter_provider(), + opentelemetry::global::meter_provider(), )), ); @@ -119,13 +121,15 @@ impl AggregateMeterProvider { meter_provider_type: MeterProviderType, meter_provider: Option, ) -> Option { - let mut inner = self.inner.lock().expect("lock poisoned"); + let mut inner = self.inner.lock(); // As we are changing a meter provider we need to invalidate any registered instruments. // Clearing these allows any weak references at callsites to be invalidated. + // This must be done BEFORE the old provider is dropped to ensure that metrics are not lost. + // Once invalidated all metrics callsites will try to obtain new instruments, but will be blocked on the mutex. inner.registered_instruments.clear(); //Now update the meter provider - if let Some(meter_provider) = meter_provider { + let old = if let Some(meter_provider) = meter_provider { inner .providers .insert( @@ -135,13 +139,29 @@ impl AggregateMeterProvider { .map(|(old_provider, _)| old_provider) } else { None - } + }; + // Important! The mutex MUST be dropped before the old meter provider is dropped to avoid deadlocks in the case that the export function has metrics. + // This implicitly happens by returning the old meter provider. + // However, to avoid a potential footgun where someone removes the return value of this function I will explicitly drop the mutex guard. + drop(inner); + + // Important! Now it is safe to drop the old meter provider, we return it, so we should be OK. If someone removes the return value of this function then + // this must instead be converted to a drop call. + old } /// Shutdown MUST be called from a blocking thread. pub(crate) fn shutdown(&self) { - let inner = self.inner.lock().expect("lock poisoned"); - for (meter_provider_type, (meter_provider, _)) in &inner.providers { + // Make sure that we don't deadlock by dropping the mutex guard before actual shutdown happens + // This means that if we have any misbehaving code that tries to access the meter provider during shutdown, e.g. for export metrics + // then we don't get stuck on the mutex. + let mut inner = self.inner.lock(); + let mut swap = Inner::default(); + std::mem::swap(&mut *inner, &mut swap); + drop(inner); + + // Now that we have dropped the mutex guard we can safely shutdown the meter providers + for (meter_provider_type, (meter_provider, _)) in &swap.providers { if let Err(e) = meter_provider.shutdown() { ::tracing::error!(error = %e, meter_provider_type = ?meter_provider_type, "failed to shutdown meter provider") } @@ -156,7 +176,7 @@ impl AggregateMeterProvider { where Arc: Into, { - let mut guard = self.inner.lock().expect("lock poisoned"); + let mut guard = self.inner.lock(); let instrument = Arc::new((create_fn)(guard.deref_mut())); guard.registered_instruments.push(instrument.clone().into()); instrument @@ -164,11 +184,7 @@ impl AggregateMeterProvider { #[cfg(test)] pub(crate) fn registered_instruments(&self) -> usize { - self.inner - .lock() - .expect("lock poisoned") - .registered_instruments - .len() + self.inner.lock().registered_instruments.len() } } @@ -225,7 +241,7 @@ impl MeterProvider for AggregateMeterProvider { schema_url: Option>>, attributes: Option>, ) -> Meter { - let mut inner = self.inner.lock().expect("lock poisoned"); + let mut inner = self.inner.lock(); inner.versioned_meter(name, version, schema_url, attributes) } } @@ -302,6 +318,18 @@ impl AsyncInstrument for AggregateObservableUpDownCounter { } } +pub(crate) struct AggregateGauge { + delegates: Vec>, +} + +impl SyncGauge for AggregateGauge { + fn record(&self, value: T, attributes: &[KeyValue]) { + for gauge in &self.delegates { + gauge.record(value, attributes) + } + } +} + pub(crate) struct AggregateObservableGauge { delegates: Vec<(ObservableGauge, Option)>, } @@ -324,7 +352,7 @@ macro_rules! aggregate_observable_instrument_fn { &self, name: Cow<'static, str>, description: Option>, - unit: Option, + unit: Option>, callback: Vec>, ) -> opentelemetry::metrics::Result<$wrapper<$ty>> { let callback: Vec>> = @@ -342,7 +370,7 @@ macro_rules! aggregate_observable_instrument_fn { } // We must not set callback in the builder as it will leak memory. // Instead we use callback registration on the meter provider as it allows unregistration - // Also we need to filter out no-op instruments as passing these to the meter provider as these will fail witha crptic message about different implementation. + // Also we need to filter out no-op instruments as passing these to the meter provider as these will fail with a cryptic message about different implementations. // Confusingly the implementation of as_any() on an instrument will return 'other stuff'. In particular no-ops return Arc<()>. This is why we need to check for this. let delegate: $wrapper<$ty> = builder.try_init()?; let registration = if delegate.clone().as_any().downcast_ref::<()>().is_some() { @@ -377,7 +405,7 @@ macro_rules! aggregate_instrument_fn { &self, name: Cow<'static, str>, description: Option>, - unit: Option, + unit: Option>, ) -> opentelemetry::metrics::Result<$wrapper<$ty>> { let delegates = self .meters @@ -424,7 +452,6 @@ impl InstrumentProvider for AggregateInstrumentProvider { aggregate_instrument_fn!(u64_histogram, u64, Histogram, AggregateHistogram); aggregate_instrument_fn!(f64_histogram, f64, Histogram, AggregateHistogram); - aggregate_instrument_fn!(i64_histogram, i64, Histogram, AggregateHistogram); aggregate_instrument_fn!( i64_up_down_counter, @@ -438,6 +465,9 @@ impl InstrumentProvider for AggregateInstrumentProvider { UpDownCounter, AggregateUpDownCounter ); + aggregate_instrument_fn!(u64_gauge, u64, Gauge, AggregateGauge); + aggregate_instrument_fn!(i64_gauge, i64, Gauge, AggregateGauge); + aggregate_instrument_fn!(f64_gauge, f64, Gauge, AggregateGauge); aggregate_observable_instrument_fn!( i64_observable_up_down_counter, @@ -475,7 +505,7 @@ impl InstrumentProvider for AggregateInstrumentProvider { &self, _instruments: &[Arc], _callbacks: Box, - ) -> opentelemetry_api::metrics::Result> { + ) -> opentelemetry::metrics::Result> { // We may implement this in future, but for now we don't need it and it's a pain to implement because we need to unwrap the aggregate instruments and pass them to the meter provider that owns them. unimplemented!("register_callback is not supported on AggregateInstrumentProvider"); } @@ -483,26 +513,30 @@ impl InstrumentProvider for AggregateInstrumentProvider { #[cfg(test)] mod test { - use std::sync::atomic::AtomicI64; use std::sync::Arc; use std::sync::Weak; - - use opentelemetry::sdk::metrics::data::Gauge; - use opentelemetry::sdk::metrics::data::ResourceMetrics; - use opentelemetry::sdk::metrics::data::Temporality; - use opentelemetry::sdk::metrics::reader::AggregationSelector; - use opentelemetry::sdk::metrics::reader::MetricProducer; - use opentelemetry::sdk::metrics::reader::MetricReader; - use opentelemetry::sdk::metrics::reader::TemporalitySelector; - use opentelemetry::sdk::metrics::Aggregation; - use opentelemetry::sdk::metrics::InstrumentKind; - use opentelemetry::sdk::metrics::ManualReader; - use opentelemetry::sdk::metrics::MeterProviderBuilder; - use opentelemetry::sdk::metrics::Pipeline; - use opentelemetry_api::global::GlobalMeterProvider; - use opentelemetry_api::metrics::MeterProvider; - use opentelemetry_api::metrics::Result; - use opentelemetry_api::Context; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::AtomicI64; + use std::time::Duration; + + use async_trait::async_trait; + use opentelemetry::global::GlobalMeterProvider; + use opentelemetry::metrics::MeterProvider; + use opentelemetry::metrics::Result; + use opentelemetry_sdk::metrics::Aggregation; + use opentelemetry_sdk::metrics::InstrumentKind; + use opentelemetry_sdk::metrics::ManualReader; + use opentelemetry_sdk::metrics::MeterProviderBuilder; + use opentelemetry_sdk::metrics::PeriodicReader; + use opentelemetry_sdk::metrics::Pipeline; + use opentelemetry_sdk::metrics::data::Gauge; + use opentelemetry_sdk::metrics::data::ResourceMetrics; + use opentelemetry_sdk::metrics::data::Temporality; + use opentelemetry_sdk::metrics::exporter::PushMetricsExporter; + use opentelemetry_sdk::metrics::reader::AggregationSelector; + use opentelemetry_sdk::metrics::reader::MetricReader; + use opentelemetry_sdk::metrics::reader::TemporalitySelector; + use opentelemetry_sdk::runtime; use crate::metrics::aggregation::AggregateMeterProvider; use crate::metrics::aggregation::MeterProviderType; @@ -528,16 +562,12 @@ mod test { self.0.register_pipeline(pipeline) } - fn register_producer(&self, producer: Box) { - self.0.register_producer(producer) - } - fn collect(&self, rm: &mut ResourceMetrics) -> Result<()> { self.0.collect(rm) } - fn force_flush(&self, cx: &Context) -> Result<()> { - self.0.force_flush(cx) + fn force_flush(&self) -> Result<()> { + self.0.force_flush() } fn shutdown(&self) -> Result<()> { @@ -721,4 +751,136 @@ mod test { reader.collect(&mut resource_metrics).unwrap(); assert_eq!(1, resource_metrics.scope_metrics.len()); } + + struct TestExporter { + meter_provider: AggregateMeterProvider, + shutdown: Arc, + } + + impl AggregationSelector for TestExporter { + fn aggregation(&self, _kind: InstrumentKind) -> Aggregation { + Aggregation::Default + } + } + + impl TemporalitySelector for TestExporter { + fn temporality(&self, _kind: InstrumentKind) -> Temporality { + Temporality::Cumulative + } + } + + #[async_trait] + impl PushMetricsExporter for TestExporter { + async fn export(&self, _metrics: &mut ResourceMetrics) -> Result<()> { + self.count(); + Ok(()) + } + + async fn force_flush(&self) -> Result<()> { + self.count(); + Ok(()) + } + + fn shutdown(&self) -> Result<()> { + self.count(); + self.shutdown + .store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + } + + impl TestExporter { + fn count(&self) { + let counter = self + .meter_provider + .versioned_meter("test", None::, None::, None) + .u64_counter("test.counter") + .init(); + counter.add(1, &[]); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_shutdown_exporter_metrics() { + // See the `shutdown` method implementation as to why this is tricky. + // This test calls the meter provider from within the exporter to ensure there is no deadlock possible. + let meter_provider = AggregateMeterProvider::default(); + let shutdown = Arc::new(AtomicBool::new(false)); + let periodic_reader = reader(&meter_provider, &shutdown); + + let delegate = MeterProviderBuilder::default() + .with_reader(periodic_reader) + .build(); + + meter_provider.set( + MeterProviderType::OtelDefault, + Some(FilterMeterProvider::public(GlobalMeterProvider::new( + delegate, + ))), + ); + + tokio::time::sleep(Duration::from_millis(20)).await; + meter_provider.shutdown(); + + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(shutdown.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_reload_exporter_metrics() { + // When exporters that interact with the meter provider are being refreshed we want to ensure that they don't deadlock. + // I don't think that this could have ever happened, but best to be safe and add a test. + let meter_provider = AggregateMeterProvider::default(); + let shutdown1 = Arc::new(AtomicBool::new(false)); + let periodic_reader = reader(&meter_provider, &shutdown1); + + let delegate = MeterProviderBuilder::default() + .with_reader(periodic_reader) + .build(); + + meter_provider.set( + MeterProviderType::OtelDefault, + Some(FilterMeterProvider::public(GlobalMeterProvider::new( + delegate, + ))), + ); + + tokio::time::sleep(Duration::from_millis(20)).await; + let shutdown2 = Arc::new(AtomicBool::new(false)); + let periodic_reader = reader(&meter_provider, &shutdown2); + + let delegate = MeterProviderBuilder::default() + .with_reader(periodic_reader) + .build(); + + // Setting the meter provider should not deadlock. + meter_provider.set( + MeterProviderType::OtelDefault, + Some(FilterMeterProvider::public(GlobalMeterProvider::new( + delegate, + ))), + ); + + tokio::time::sleep(Duration::from_millis(20)).await; + + // The first meter provider should be shut down and the second is still active + assert!(shutdown1.load(std::sync::atomic::Ordering::SeqCst)); + assert!(!shutdown2.load(std::sync::atomic::Ordering::SeqCst)); + } + + fn reader( + meter_provider: &AggregateMeterProvider, + shutdown: &Arc, + ) -> PeriodicReader { + PeriodicReader::builder( + TestExporter { + meter_provider: meter_provider.clone(), + shutdown: shutdown.clone(), + }, + runtime::Tokio, + ) + .with_interval(Duration::from_millis(10)) + .with_timeout(Duration::from_millis(10)) + .build() + } } diff --git a/apollo-router/src/metrics/filter.rs b/apollo-router/src/metrics/filter.rs index fa6dad38df..dd9115648e 100644 --- a/apollo-router/src/metrics/filter.rs +++ b/apollo-router/src/metrics/filter.rs @@ -3,9 +3,11 @@ use std::borrow::Cow; use std::sync::Arc; use buildstructor::buildstructor; -use opentelemetry::metrics::noop::NoopMeterProvider; +use opentelemetry::KeyValue; use opentelemetry::metrics::Callback; +use opentelemetry::metrics::CallbackRegistration; use opentelemetry::metrics::Counter; +use opentelemetry::metrics::Gauge; use opentelemetry::metrics::Histogram; use opentelemetry::metrics::InstrumentProvider; use opentelemetry::metrics::Meter; @@ -13,17 +15,14 @@ use opentelemetry::metrics::MeterProvider as OtelMeterProvider; use opentelemetry::metrics::ObservableCounter; use opentelemetry::metrics::ObservableGauge; use opentelemetry::metrics::ObservableUpDownCounter; -use opentelemetry::metrics::Unit; +use opentelemetry::metrics::Observer; use opentelemetry::metrics::UpDownCounter; -use opentelemetry_api::metrics::CallbackRegistration; -use opentelemetry_api::metrics::Observer; -use opentelemetry_api::Context; -use opentelemetry_api::KeyValue; +use opentelemetry::metrics::noop::NoopMeterProvider; use regex::Regex; #[derive(Clone)] pub(crate) enum MeterProvider { - Regular(opentelemetry::sdk::metrics::MeterProvider), + Regular(opentelemetry_sdk::metrics::SdkMeterProvider), Global(opentelemetry::global::GlobalMeterProvider), } @@ -51,16 +50,16 @@ impl MeterProvider { } } - fn force_flush(&self, cx: &Context) -> opentelemetry::metrics::Result<()> { + fn force_flush(&self) -> opentelemetry::metrics::Result<()> { match self { - MeterProvider::Regular(provider) => provider.force_flush(cx), + MeterProvider::Regular(provider) => provider.force_flush(), MeterProvider::Global(_provider) => Ok(()), } } } -impl From for MeterProvider { - fn from(provider: opentelemetry::sdk::metrics::MeterProvider) -> Self { +impl From for MeterProvider { + fn from(provider: opentelemetry_sdk::metrics::SdkMeterProvider) -> Self { MeterProvider::Regular(provider) } } @@ -89,15 +88,28 @@ impl FilterMeterProvider { } } + fn get_private_realtime_regex() -> Regex { + Regex::new(r"apollo\.router\.operations\.(?:error|fetch\.duration)") + .expect("regex should have been valid") + } + + pub(crate) fn private_realtime>(delegate: T) -> Self { + FilterMeterProvider::builder() + .delegate(delegate) + .allow(Self::get_private_realtime_regex().clone()) + .build() + } + pub(crate) fn private>(delegate: T) -> Self { FilterMeterProvider::builder() .delegate(delegate) .allow( Regex::new( - r"apollo\.(graphos\.cloud|router\.(operations?|lifecycle|config|schema|query|query_planning|telemetry))(\..*|$)|apollo_router_uplink_fetch_count_total|apollo_router_uplink_fetch_duration_seconds", + r"apollo\.(graphos\.cloud|router\.(operations?|lifecycle|config|schema|query|query_planning|telemetry|instance|graphql_error))(\..*|$)|apollo_router_uplink_fetch_count_total|apollo_router_uplink_fetch_duration_seconds", ) .expect("regex should have been valid"), ) + .deny(Self::get_private_realtime_regex().clone()) .build() } @@ -105,7 +117,7 @@ impl FilterMeterProvider { FilterMeterProvider::builder() .delegate(delegate) .deny( - Regex::new(r"apollo\.router\.(config|entities)(\..*|$)") + Regex::new(r"apollo\.router\.(config|entities|instance|operations\.(connectors|fetch|request_size|response_size|error)|schema\.connectors)(\..*|$)") .expect("regex should have been valid"), ) .build() @@ -121,8 +133,8 @@ impl FilterMeterProvider { } #[allow(dead_code)] - pub(crate) fn force_flush(&self, cx: &Context) -> opentelemetry::metrics::Result<()> { - self.delegate.force_flush(cx) + pub(crate) fn force_flush(&self) -> opentelemetry::metrics::Result<()> { + self.delegate.force_flush() } } @@ -139,14 +151,12 @@ macro_rules! filter_instrument_fn { &self, name: Cow<'static, str>, description: Option>, - unit: Option, + unit: Option>, ) -> opentelemetry::metrics::Result<$wrapper<$ty>> { let mut builder = match (&self.deny, &self.allow) { - (Some(deny), Some(allow)) if deny.is_match(&name) && !allow.is_match(&name) => { - self.noop.$name(name) - } - (Some(deny), None) if deny.is_match(&name) => self.noop.$name(name), - (None, Some(allow)) if !allow.is_match(&name) => self.noop.$name(name), + // Deny match takes precedence over allow match + (Some(deny), _) if deny.is_match(&name) => self.noop.$name(name), + (_, Some(allow)) if !allow.is_match(&name) => self.noop.$name(name), (_, _) => self.delegate.$name(name), }; if let Some(description) = &description { @@ -166,15 +176,13 @@ macro_rules! filter_observable_instrument_fn { &self, name: Cow<'static, str>, description: Option>, - unit: Option, + unit: Option>, callback: Vec>, ) -> opentelemetry::metrics::Result<$wrapper<$ty>> { let mut builder = match (&self.deny, &self.allow) { - (Some(deny), Some(allow)) if deny.is_match(&name) && !allow.is_match(&name) => { - self.noop.$name(name) - } - (Some(deny), None) if deny.is_match(&name) => self.noop.$name(name), - (None, Some(allow)) if !allow.is_match(&name) => self.noop.$name(name), + // Deny match takes precedence over allow match + (Some(deny), _) if deny.is_match(&name) => self.noop.$name(name), + (_, Some(allow)) if !allow.is_match(&name) => self.noop.$name(name), (_, _) => self.delegate.$name(name), }; if let Some(description) = &description { @@ -197,12 +205,15 @@ impl InstrumentProvider for FilteredInstrumentProvider { filter_instrument_fn!(u64_counter, u64, Counter); filter_instrument_fn!(f64_counter, f64, Counter); + filter_instrument_fn!(u64_gauge, u64, Gauge); + filter_instrument_fn!(i64_gauge, i64, Gauge); + filter_instrument_fn!(f64_gauge, f64, Gauge); + filter_observable_instrument_fn!(f64_observable_counter, f64, ObservableCounter); filter_observable_instrument_fn!(u64_observable_counter, u64, ObservableCounter); filter_instrument_fn!(u64_histogram, u64, Histogram); filter_instrument_fn!(f64_histogram, f64, Histogram); - filter_instrument_fn!(i64_histogram, i64, Histogram); filter_instrument_fn!(i64_up_down_counter, i64, UpDownCounter); filter_instrument_fn!(f64_up_down_counter, f64, UpDownCounter); @@ -244,15 +255,12 @@ impl opentelemetry::metrics::MeterProvider for FilterMeterProvider { #[cfg(test)] mod test { - + use opentelemetry::global::GlobalMeterProvider; use opentelemetry::metrics::MeterProvider; - use opentelemetry::metrics::Unit; - use opentelemetry::runtime; - use opentelemetry::sdk::metrics::MeterProviderBuilder; - use opentelemetry::sdk::metrics::PeriodicReader; - use opentelemetry::testing::metrics::InMemoryMetricsExporter; - use opentelemetry_api::global::GlobalMeterProvider; - use opentelemetry_api::Context; + use opentelemetry_sdk::metrics::MeterProviderBuilder; + use opentelemetry_sdk::metrics::PeriodicReader; + use opentelemetry_sdk::runtime; + use opentelemetry_sdk::testing::metrics::InMemoryMetricsExporter; use crate::metrics::filter::FilterMeterProvider; @@ -264,8 +272,8 @@ mod test { .with_reader(PeriodicReader::builder(exporter.clone(), runtime::Tokio).build()) .build(), ); - let cx = Context::default(); let filtered = meter_provider.versioned_meter("filtered", "".into(), "".into(), None); + // Matches allow filtered .u64_counter("apollo.router.operations") .init() @@ -279,18 +287,35 @@ mod test { .init() .add(1, &[]); filtered - .u64_counter("apollo.router.unknown.test") + .u64_counter("apollo.router.query_planning.test") .init() .add(1, &[]); filtered - .u64_counter("apollo.router.query_planning.test") + .u64_counter("apollo.router.lifecycle.api_schema") .init() .add(1, &[]); filtered - .u64_counter("apollo.router.lifecycle.api_schema") + .u64_counter("apollo.router.operations.connectors") + .init() + .add(1, &[]); + filtered + .u64_observable_gauge("apollo.router.schema.connectors") + .with_callback(move |observer| observer.observe(1, &[])) + .init(); + + // Mismatches allow + filtered + .u64_counter("apollo.router.unknown.test") .init() .add(1, &[]); - meter_provider.force_flush(&cx).unwrap(); + + // Matches deny + filtered + .u64_counter("apollo.router.operations.error") + .init() + .add(1, &[]); + + meter_provider.force_flush().unwrap(); let metrics: Vec<_> = exporter .get_finished_metrics() @@ -299,23 +324,52 @@ mod test { .flat_map(|m| m.scope_metrics.into_iter()) .flat_map(|m| m.metrics) .collect(); - assert!(metrics - .iter() - .any(|m| m.name == "apollo.router.operations.test")); + + // Matches allow + assert!( + metrics + .iter() + .any(|m| m.name == "apollo.router.operations.test") + ); assert!(metrics.iter().any(|m| m.name == "apollo.router.operations")); - assert!(metrics - .iter() - .any(|m| m.name == "apollo.graphos.cloud.test")); + assert!( + metrics + .iter() + .any(|m| m.name == "apollo.graphos.cloud.test") + ); + + assert!( + metrics + .iter() + .any(|m| m.name == "apollo.router.lifecycle.api_schema") + ); - assert!(!metrics - .iter() - .any(|m| m.name == "apollo.router.unknown.test")); + assert!( + metrics + .iter() + .any(|m| m.name == "apollo.router.operations.connectors") + ); + assert!( + metrics + .iter() + .any(|m| m.name == "apollo.router.schema.connectors") + ); + + // Mismatches allow + assert!( + !metrics + .iter() + .any(|m| m.name == "apollo.router.unknown.test") + ); - assert!(metrics - .iter() - .any(|m| m.name == "apollo.router.lifecycle.api_schema")); + // Matches deny + assert!( + !metrics + .iter() + .any(|m| m.name == "apollo.router.operations.error") + ); } #[tokio::test(flavor = "multi_thread")] @@ -326,15 +380,14 @@ mod test { .with_reader(PeriodicReader::builder(exporter.clone(), runtime::Tokio).build()) .build(), ); - let cx = Context::default(); let filtered = meter_provider.versioned_meter("filtered", "".into(), "".into(), None); filtered .u64_counter("apollo.router.operations") .with_description("desc") - .with_unit(Unit::new("ms")) + .with_unit("ms") .init() .add(1, &[]); - meter_provider.force_flush(&cx).unwrap(); + meter_provider.force_flush().unwrap(); let metrics: Vec<_> = exporter .get_finished_metrics() @@ -345,7 +398,7 @@ mod test { .collect(); assert!(metrics.iter().any(|m| m.name == "apollo.router.operations" && m.description == "desc" - && m.unit == Unit::new("ms"))); + && m.unit == "ms")); } #[tokio::test(flavor = "multi_thread")] @@ -379,7 +432,6 @@ mod test { meter_provider: T, ) { let meter_provider = FilterMeterProvider::public(meter_provider); - let cx = Context::default(); let filtered = meter_provider.versioned_meter("filtered", "".into(), "".into(), None); filtered .u64_counter("apollo.router.config") @@ -397,7 +449,15 @@ mod test { .u64_counter("apollo.router.entities.test") .init() .add(1, &[]); - meter_provider.force_flush(&cx).unwrap(); + filtered + .u64_counter("apollo.router.operations.connectors") + .init() + .add(1, &[]); + filtered + .u64_observable_gauge("apollo.router.schema.connectors") + .with_callback(move |observer| observer.observe(1, &[])) + .init(); + meter_provider.force_flush().unwrap(); let metrics: Vec<_> = exporter .get_finished_metrics() @@ -408,12 +468,67 @@ mod test { .collect(); assert!(!metrics.iter().any(|m| m.name == "apollo.router.config")); - assert!(!metrics - .iter() - .any(|m| m.name == "apollo.router.config.test")); + assert!( + !metrics + .iter() + .any(|m| m.name == "apollo.router.config.test") + ); assert!(!metrics.iter().any(|m| m.name == "apollo.router.entities")); - assert!(!metrics - .iter() - .any(|m| m.name == "apollo.router.entities.test")); + assert!( + !metrics + .iter() + .any(|m| m.name == "apollo.router.entities.test") + ); + assert!( + !metrics + .iter() + .any(|m| m.name == "apollo.router.operations.connectors") + ); + assert!( + !metrics + .iter() + .any(|m| m.name == "apollo.router.schema.connectors") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_private_realtime_metrics() { + let exporter = InMemoryMetricsExporter::default(); + let meter_provider = FilterMeterProvider::private_realtime( + MeterProviderBuilder::default() + .with_reader(PeriodicReader::builder(exporter.clone(), runtime::Tokio).build()) + .build(), + ); + let filtered = meter_provider.versioned_meter("filtered", "".into(), "".into(), None); + filtered + .u64_counter("apollo.router.operations.error") + .init() + .add(1, &[]); + filtered + .u64_counter("apollo.router.operations.mismatch") + .init() + .add(1, &[]); + meter_provider.force_flush().unwrap(); + + let metrics: Vec<_> = exporter + .get_finished_metrics() + .unwrap() + .into_iter() + .flat_map(|m| m.scope_metrics.into_iter()) + .flat_map(|m| m.metrics) + .collect(); + // Matches + assert!( + metrics + .iter() + .any(|m| m.name == "apollo.router.operations.error") + ); + + // Mismatches + assert!( + !metrics + .iter() + .any(|m| m.name == "apollo.router.operations.mismatch") + ); } } diff --git a/apollo-router/src/metrics/layer.rs b/apollo-router/src/metrics/layer.rs deleted file mode 100644 index 8502e1da0d..0000000000 --- a/apollo-router/src/metrics/layer.rs +++ /dev/null @@ -1,480 +0,0 @@ -use std::collections::HashMap; -use std::fmt; -use std::sync::Arc; -use std::sync::RwLock; - -use opentelemetry::metrics::Counter; -use opentelemetry::metrics::Histogram; -use opentelemetry::metrics::Meter; -use opentelemetry::metrics::MeterProvider; -use opentelemetry::metrics::ObservableGauge; -use opentelemetry::metrics::UpDownCounter; -use opentelemetry::Key; -use opentelemetry::KeyValue; -use opentelemetry::Value; -use tracing::field::Visit; -use tracing::Subscriber; -use tracing_core::Field; -use tracing_subscriber::layer::Context; -use tracing_subscriber::registry::LookupSpan; -use tracing_subscriber::Layer; - -use crate::metrics::aggregation::AggregateMeterProvider; - -pub(crate) const METRIC_PREFIX_MONOTONIC_COUNTER: &str = "monotonic_counter."; -pub(crate) const METRIC_PREFIX_COUNTER: &str = "counter."; -pub(crate) const METRIC_PREFIX_HISTOGRAM: &str = "histogram."; -pub(crate) const METRIC_PREFIX_VALUE: &str = "value."; - -macro_rules! log_and_panic_in_debug_build { - ($($tokens:tt)+) => {{ - tracing::error!($($tokens)+); - #[cfg(debug_assertions)] - panic!("metric type error, see DEBUG log for details. Release builds will not panic but will still emit a debug log message"); - }}; -} - -#[derive(Default)] -pub(crate) struct Instruments { - u64_counter: MetricsMap>, - f64_counter: MetricsMap>, - i64_up_down_counter: MetricsMap>, - f64_up_down_counter: MetricsMap>, - u64_histogram: MetricsMap>, - i64_histogram: MetricsMap>, - f64_histogram: MetricsMap>, - u64_gauge: MetricsMap>, -} - -type MetricsMap = RwLock>; - -#[derive(Copy, Clone, Debug)] -pub(crate) enum InstrumentType { - CounterU64(u64), - CounterF64(f64), - UpDownCounterI64(i64), - UpDownCounterF64(f64), - HistogramU64(u64), - HistogramI64(i64), - HistogramF64(f64), - GaugeU64(u64), -} - -impl Instruments { - pub(crate) fn update_metric( - &self, - meter: &Meter, - instrument_type: InstrumentType, - metric_name: &'static str, - custom_attributes: &[KeyValue], - ) { - fn update_or_insert( - map: &MetricsMap, - name: &'static str, - insert: impl FnOnce() -> T, - update: impl FnOnce(&T), - ) { - { - let lock = map.read().unwrap(); - if let Some(metric) = lock.get(name) { - update(metric); - return; - } - } - - // that metric did not already exist, so we have to acquire a write lock to - // create it. - let mut lock = map.write().unwrap(); - - // handle the case where the entry was created while we were waiting to - // acquire the write lock - let metric = lock.entry(name).or_insert_with(insert); - update(metric) - } - - match instrument_type { - InstrumentType::CounterU64(value) => { - update_or_insert( - &self.u64_counter, - metric_name, - || meter.u64_counter(metric_name).init(), - |ctr| ctr.add(value, custom_attributes), - ); - } - InstrumentType::CounterF64(value) => { - update_or_insert( - &self.f64_counter, - metric_name, - || meter.f64_counter(metric_name).init(), - |ctr| ctr.add(value, custom_attributes), - ); - } - InstrumentType::UpDownCounterI64(value) => { - update_or_insert( - &self.i64_up_down_counter, - metric_name, - || meter.i64_up_down_counter(metric_name).init(), - |ctr| ctr.add(value, custom_attributes), - ); - } - InstrumentType::UpDownCounterF64(value) => { - update_or_insert( - &self.f64_up_down_counter, - metric_name, - || meter.f64_up_down_counter(metric_name).init(), - |ctr| ctr.add(value, custom_attributes), - ); - } - InstrumentType::HistogramU64(value) => { - update_or_insert( - &self.u64_histogram, - metric_name, - || meter.u64_histogram(metric_name).init(), - |rec| rec.record(value, custom_attributes), - ); - } - InstrumentType::HistogramI64(value) => { - update_or_insert( - &self.i64_histogram, - metric_name, - || meter.i64_histogram(metric_name).init(), - |rec| rec.record(value, custom_attributes), - ); - } - InstrumentType::HistogramF64(value) => { - update_or_insert( - &self.f64_histogram, - metric_name, - || meter.f64_histogram(metric_name).init(), - |rec| rec.record(value, custom_attributes), - ); - } - InstrumentType::GaugeU64(value) => { - update_or_insert( - &self.u64_gauge, - metric_name, - || meter.u64_observable_gauge(metric_name).init(), - |gauge| gauge.observe(value, custom_attributes), - ); - } - }; - } -} - -pub(crate) struct MetricVisitor<'a> { - pub(crate) meter: &'a Meter, - pub(crate) instruments: &'a Instruments, - pub(crate) metric: Option<(&'static str, InstrumentType)>, - pub(crate) custom_attributes: Vec, - attributes_ignored: bool, -} - -impl<'a> MetricVisitor<'a> { - fn set_metric(&mut self, name: &'static str, instrument_type: InstrumentType) { - self.metric = Some((name, instrument_type)); - if self.attributes_ignored { - log_and_panic_in_debug_build!( - metric_name = name, - "metric attributes must be declared after the metric value. Some attributes have been ignored" - ); - } - } -} - -impl<'a> Visit for MetricVisitor<'a> { - fn record_f64(&mut self, field: &Field, value: f64) { - if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_MONOTONIC_COUNTER) { - self.set_metric(metric_name, InstrumentType::CounterF64(value)); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_COUNTER) { - self.set_metric(metric_name, InstrumentType::UpDownCounterF64(value)); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_HISTOGRAM) { - self.set_metric(metric_name, InstrumentType::HistogramF64(value)); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_VALUE) { - log_and_panic_in_debug_build!( - metric_name, - "gauge must be u64. This metric will be ignored" - ); - } else if self.metric.is_some() { - self.custom_attributes.push(KeyValue::new( - Key::from_static_str(field.name()), - Value::from(value), - )); - } else { - self.attributes_ignored = true - } - } - - fn record_i64(&mut self, field: &Field, value: i64) { - if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_MONOTONIC_COUNTER) { - if value < 0 { - log_and_panic_in_debug_build!( - metric_name, - "monotonic counter must be u64 or f64. This metric will be ignored" - ); - } else { - self.set_metric(metric_name, InstrumentType::CounterU64(value as u64)); - } - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_COUNTER) { - self.set_metric(metric_name, InstrumentType::UpDownCounterI64(value)); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_HISTOGRAM) { - self.set_metric(metric_name, InstrumentType::HistogramI64(value)); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_VALUE) { - if value < 0 { - log_and_panic_in_debug_build!( - metric_name, - "gauge must be u64. This metric will be ignored" - ); - } else { - self.set_metric(metric_name, InstrumentType::GaugeU64(value as u64)); - } - } else if self.metric.is_some() { - self.custom_attributes.push(KeyValue::new( - Key::from_static_str(field.name()), - Value::from(value), - )); - } else { - self.attributes_ignored = true - } - } - - fn record_u64(&mut self, field: &Field, value: u64) { - if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_MONOTONIC_COUNTER) { - self.set_metric(metric_name, InstrumentType::CounterU64(value)); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_COUNTER) { - self.set_metric(metric_name, InstrumentType::UpDownCounterI64(value as i64)); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_HISTOGRAM) { - self.set_metric(metric_name, InstrumentType::HistogramU64(value)); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_VALUE) { - self.set_metric(metric_name, InstrumentType::GaugeU64(value)); - } else if self.metric.is_some() { - log_and_panic_in_debug_build!( - name = field.name(), - "metric attribute must be i64, f64, string or bool. This attribute will be ignored" - ); - } else { - self.attributes_ignored = true - } - } - - fn record_i128(&mut self, field: &Field, _value: i128) { - if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_MONOTONIC_COUNTER) { - log_and_panic_in_debug_build!( - metric_name, - "monotonic counter must be u64 or f64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_COUNTER) { - log_and_panic_in_debug_build!( - metric_name, - "counter must be i64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_HISTOGRAM) { - log_and_panic_in_debug_build!( - metric_name, - "histogram must be u64, i64 or f64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_VALUE) { - log_and_panic_in_debug_build!( - metric_name, - "gauge must be u64. This metric will be ignored" - ); - } else if self.metric.is_some() { - log_and_panic_in_debug_build!( - name = field.name(), - "metric attribute must be i64, f64, string or bool. This attribute will be ignored" - ); - } else { - self.attributes_ignored = true - } - } - - fn record_u128(&mut self, field: &Field, _value: u128) { - if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_MONOTONIC_COUNTER) { - log_and_panic_in_debug_build!( - metric_name, - "monotonic counter must be u64 or f64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_COUNTER) { - log_and_panic_in_debug_build!( - metric_name, - "counter must be i64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_HISTOGRAM) { - log_and_panic_in_debug_build!( - metric_name, - "histogram must be u64, i64 or f64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_VALUE) { - log_and_panic_in_debug_build!( - metric_name, - "gauge must be u64. This metric will be ignored" - ); - } else if self.metric.is_some() { - log_and_panic_in_debug_build!( - name = field.name(), - "metric attribute must be i64, f64, string or bool. This attribute will be ignored" - ); - } else { - self.attributes_ignored = true - } - } - - fn record_bool(&mut self, field: &Field, value: bool) { - if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_MONOTONIC_COUNTER) { - log_and_panic_in_debug_build!( - metric_name, - "monotonic counter must be u64 or f64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_COUNTER) { - log_and_panic_in_debug_build!( - metric_name, - "counter must be i64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_HISTOGRAM) { - log_and_panic_in_debug_build!( - metric_name, - "histogram must be u64, i64 or f64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_VALUE) { - log_and_panic_in_debug_build!( - metric_name, - "gauge must be u64. This metric will be ignored" - ); - } else if self.metric.is_some() { - self.custom_attributes.push(KeyValue::new( - Key::from_static_str(field.name()), - Value::from(value), - )); - } else { - self.attributes_ignored = true - } - } - - fn record_str(&mut self, field: &Field, value: &str) { - if field.name() != "message" { - if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_MONOTONIC_COUNTER) { - log_and_panic_in_debug_build!( - metric_name, - "monotonic counter must be u64 or f64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_COUNTER) { - log_and_panic_in_debug_build!( - metric_name, - "counter must be i64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_HISTOGRAM) { - log_and_panic_in_debug_build!( - metric_name, - "histogram must be u64, i64 or f64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_VALUE) { - log_and_panic_in_debug_build!( - metric_name, - "gauge must be u64. This metric will be ignored" - ); - } else if self.metric.is_some() { - self.custom_attributes.push(KeyValue::new( - Key::from_static_str(field.name()), - Value::from(value.to_string()), - )); - } else { - self.attributes_ignored = true - } - } - } - - fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { - if field.name() != "message" { - if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_MONOTONIC_COUNTER) { - log_and_panic_in_debug_build!( - metric_name, - "monotonic counter must be u64 or f64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_COUNTER) { - log_and_panic_in_debug_build!( - metric_name, - "counter must be i64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_HISTOGRAM) { - log_and_panic_in_debug_build!( - metric_name, - "histogram must be u64, i64 or f64. This metric will be ignored" - ); - } else if let Some(metric_name) = field.name().strip_prefix(METRIC_PREFIX_VALUE) { - log_and_panic_in_debug_build!( - metric_name, - "gauge must be u64. This metric will be ignored" - ); - } else if self.metric.is_some() { - self.custom_attributes.push(KeyValue::new( - Key::from_static_str(field.name()), - Value::from(format!("{value:?}")), - )); - } else { - self.attributes_ignored = true - } - } - } -} - -impl<'a> MetricVisitor<'a> { - fn finish(self) { - if let Some((metric_name, instrument_type)) = self.metric { - self.instruments.update_metric( - self.meter, - instrument_type, - metric_name, - &self.custom_attributes, - ); - } - } -} - -#[derive(Clone)] -pub(crate) struct MetricsLayer { - meter_provider: AggregateMeterProvider, - inner: Arc>, -} - -struct MetricsLayerInner { - meter: Meter, - instruments: Instruments, -} - -impl MetricsLayer { - pub(crate) fn new(meter_provider: AggregateMeterProvider) -> Self { - Self { - inner: Arc::new(RwLock::new(Self::new_inner(&meter_provider))), - meter_provider, - } - } - - fn new_inner(meter_provider: &AggregateMeterProvider) -> MetricsLayerInner { - MetricsLayerInner { - meter: meter_provider.meter("apollo/router"), - instruments: Default::default(), - } - } - /// Remove all the instruments from the metrics layer. These will be obtained again from the meter provider upon next use. - pub(crate) fn clear(&self) { - let mut inner = self.inner.write().expect("lock poisoned"); - *inner = Self::new_inner(&self.meter_provider); - } -} - -impl Layer for MetricsLayer -where - S: Subscriber + for<'span> LookupSpan<'span>, -{ - fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { - let inner = self.inner.read().expect("lock poisoned"); - let mut metric_visitor = MetricVisitor { - meter: &inner.meter, - instruments: &inner.instruments, - metric: None, - custom_attributes: Vec::new(), - attributes_ignored: false, - }; - event.record(&mut metric_visitor); - metric_visitor.finish(); - } -} diff --git a/apollo-router/src/metrics/mod.rs b/apollo-router/src/metrics/mod.rs index e24317cd06..58ac54c5ee 100644 --- a/apollo-router/src/metrics/mod.rs +++ b/apollo-router/src/metrics/mod.rs @@ -1,3 +1,72 @@ +//! APIs for integrating with the router's metrics. +//! +//! The macros contained here are a replacement for the telemetry crate's `MetricsLayer`. We will +//! eventually convert all metrics to use these macros and deprecate the `MetricsLayer`. +//! The reason for this is that the `MetricsLayer` has: +//! +//! * No support for dynamic attributes +//! * No support dynamic metrics. +//! * Imperfect mapping to metrics API that can only be checked at runtime. +//! +//! New metrics should be added using these macros. +//! +//! Prefer using `_with_unit` types for all new macros. Units should conform to the +//! [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/#units), +//! some of which has been copied here for reference: +//! * Instruments that measure a count of something should only use annotations with curly braces to +//! give additional meaning. For example, use `{packet}`, `{error}`, `{fault}`, etc., not `packet`, +//! `error`, `fault`, etc. +//! * Other instrument units should be specified using the UCUM case sensitive (c/s) variant. For +//! example, Cel for the unit with full name degree Celsius. +//! * When instruments are measuring durations, seconds (i.e. s) should be used. +//! * Instruments should use non-prefixed units (i.e. By instead of MiBy) unless there is good +//! technical reason to not do so. +//! +//! NB: we have not yet modified the existing metrics because some metric exporters (notably +//! Prometheus) include the unit in the metric name, and changing the metric name will be a breaking +//! change for customers. +//! +//! ## Compatibility +//! This module uses types from the [opentelemetry] crates. Since OpenTelemetry for Rust is not yet +//! API-stable, we may update it in a minor version, which may require code changes to plugins. +//! +//! +//! # Examples +//! ```ignore +//! // Count a thing: +//! u64_counter!( +//! "apollo.router.operations.frobbles", +//! "The amount of frobbles we've operated on", +//! 1 +//! ); +//! // Count a thing with attributes: +//! u64_counter!( +//! "apollo.router.operations.frobbles", +//! "The amount of frobbles we've operated on", +//! 1, +//! frobbles.color = "blue" +//! ); +//! // Count a thing with dynamic attributes: +//! let attributes = vec![]; +//! if (frobbled) { +//! attributes.push(opentelemetry::KeyValue::new("frobbles.color".to_string(), "blue".into())); +//! } +//! u64_counter!( +//! "apollo.router.operations.frobbles", +//! "The amount of frobbles we've operated on", +//! 1, +//! attributes +//! ); +//! // Measure a thing with units: +//! f64_histogram_with_unit!( +//! "apollo.router.operation.frobbles.time", +//! "Duration to operate on frobbles", +//! "s", +//! 1.0, +//! frobbles.color = "red" +//! ); +//! ``` + #[cfg(test)] use std::future::Future; #[cfg(test)] @@ -11,9 +80,6 @@ use crate::metrics::aggregation::AggregateMeterProvider; pub(crate) mod aggregation; pub(crate) mod filter; -pub(crate) mod layer; - -// During tests this is a task local so that we can test metrics without having to worry about other tests interfering. #[cfg(test)] pub(crate) mod test_utils { @@ -28,28 +94,27 @@ pub(crate) mod test_utils { use itertools::Itertools; use num_traits::NumCast; use num_traits::ToPrimitive; - use opentelemetry::sdk::metrics::data::DataPoint; - use opentelemetry::sdk::metrics::data::Gauge; - use opentelemetry::sdk::metrics::data::Histogram; - use opentelemetry::sdk::metrics::data::HistogramDataPoint; - use opentelemetry::sdk::metrics::data::Metric; - use opentelemetry::sdk::metrics::data::ResourceMetrics; - use opentelemetry::sdk::metrics::data::Sum; - use opentelemetry::sdk::metrics::data::Temporality; - use opentelemetry::sdk::metrics::reader::AggregationSelector; - use opentelemetry::sdk::metrics::reader::MetricProducer; - use opentelemetry::sdk::metrics::reader::MetricReader; - use opentelemetry::sdk::metrics::reader::TemporalitySelector; - use opentelemetry::sdk::metrics::Aggregation; - use opentelemetry::sdk::metrics::InstrumentKind; - use opentelemetry::sdk::metrics::ManualReader; - use opentelemetry::sdk::metrics::MeterProviderBuilder; - use opentelemetry::sdk::metrics::Pipeline; - use opentelemetry::sdk::AttributeSet; use opentelemetry::Array; use opentelemetry::KeyValue; + use opentelemetry::StringValue; use opentelemetry::Value; - use opentelemetry_api::Context; + use opentelemetry_sdk::metrics::Aggregation; + use opentelemetry_sdk::metrics::AttributeSet; + use opentelemetry_sdk::metrics::InstrumentKind; + use opentelemetry_sdk::metrics::ManualReader; + use opentelemetry_sdk::metrics::MeterProviderBuilder; + use opentelemetry_sdk::metrics::Pipeline; + use opentelemetry_sdk::metrics::data::DataPoint; + use opentelemetry_sdk::metrics::data::Gauge; + use opentelemetry_sdk::metrics::data::Histogram; + use opentelemetry_sdk::metrics::data::HistogramDataPoint; + use opentelemetry_sdk::metrics::data::Metric; + use opentelemetry_sdk::metrics::data::ResourceMetrics; + use opentelemetry_sdk::metrics::data::Sum; + use opentelemetry_sdk::metrics::data::Temporality; + use opentelemetry_sdk::metrics::reader::AggregationSelector; + use opentelemetry_sdk::metrics::reader::MetricReader; + use opentelemetry_sdk::metrics::reader::TemporalitySelector; use serde::Serialize; use tokio::task_local; @@ -84,19 +149,15 @@ pub(crate) mod test_utils { self.reader.register_pipeline(pipeline) } - fn register_producer(&self, producer: Box) { - self.reader.register_producer(producer) - } - fn collect(&self, rm: &mut ResourceMetrics) -> opentelemetry::metrics::Result<()> { self.reader.collect(rm) } - fn force_flush(&self, cx: &Context) -> opentelemetry_api::metrics::Result<()> { - self.reader.force_flush(cx) + fn force_flush(&self) -> opentelemetry::metrics::Result<()> { + self.reader.force_flush() } - fn shutdown(&self) -> opentelemetry_api::metrics::Result<()> { + fn shutdown(&self) -> opentelemetry::metrics::Result<()> { self.reader.shutdown() } } @@ -149,15 +210,14 @@ pub(crate) mod test_utils { pub(crate) fn collect_metrics() -> Metrics { let mut metrics = Metrics::default(); let (_, reader) = meter_provider_and_readers(); - reader.collect(&mut metrics.resource_metrics).unwrap(); + reader + .collect(&mut metrics.resource_metrics) + .expect("Failed to collect metrics. Did you forget to use `async{}.with_metrics()`? See dev-docs/metrics.md"); metrics } impl Metrics { - pub(crate) fn find( - &self, - name: &str, - ) -> Option<&opentelemetry::sdk::metrics::data::Metric> { + pub(crate) fn find(&self, name: &str) -> Option<&opentelemetry_sdk::metrics::data::Metric> { self.resource_metrics .scope_metrics .iter() @@ -175,25 +235,27 @@ pub(crate) mod test_utils { name: &str, ty: MetricType, value: T, + // Useful for histogram to check the count and not the sum + count: bool, attributes: &[KeyValue], ) -> bool { let attributes = AttributeSet::from(attributes); - if let Some(value) = value.to_u64() { - if self.metric_matches(name, &ty, value, &attributes) { - return true; - } + if let Some(value) = value.to_u64() + && self.metric_matches(name, &ty, value, count, &attributes) + { + return true; } - if let Some(value) = value.to_i64() { - if self.metric_matches(name, &ty, value, &attributes) { - return true; - } + if let Some(value) = value.to_i64() + && self.metric_matches(name, &ty, value, count, &attributes) + { + return true; } - if let Some(value) = value.to_f64() { - if self.metric_matches(name, &ty, value, &attributes) { - return true; - } + if let Some(value) = value.to_f64() + && self.metric_matches(name, &ty, value, count, &attributes) + { + return true; } false @@ -204,6 +266,7 @@ pub(crate) mod test_utils { name: &str, ty: &MetricType, value: T, + count: bool, attributes: &AttributeSet, ) -> bool { if let Some(metric) = self.find(name) { @@ -212,21 +275,30 @@ pub(crate) mod test_utils { // Find the datapoint with the correct attributes. if matches!(ty, MetricType::Gauge) { return gauge.data_points.iter().any(|datapoint| { - datapoint.attributes == *attributes && datapoint.value == value + datapoint.value == value + && Self::equal_attributes(attributes, &datapoint.attributes) }); } } else if let Some(sum) = metric.data.as_any().downcast_ref::>() { // Note that we can't actually tell if the sum is monotonic or not, so we just check if it's a sum. if matches!(ty, MetricType::Counter | MetricType::UpDownCounter) { return sum.data_points.iter().any(|datapoint| { - datapoint.attributes == *attributes && datapoint.value == value + datapoint.value == value + && Self::equal_attributes(attributes, &datapoint.attributes) }); } } else if let Some(histogram) = metric.data.as_any().downcast_ref::>() + && matches!(ty, MetricType::Histogram) { - if matches!(ty, MetricType::Histogram) { + if count { + return histogram.data_points.iter().any(|datapoint| { + datapoint.count == value.to_u64().unwrap() + && Self::equal_attributes(attributes, &datapoint.attributes) + }); + } else { return histogram.data_points.iter().any(|datapoint| { - datapoint.attributes == *attributes && datapoint.sum == value + datapoint.sum == value + && Self::equal_attributes(attributes, &datapoint.attributes) }); } } @@ -246,27 +318,23 @@ pub(crate) mod test_utils { if let Some(gauge) = metric.data.as_any().downcast_ref::>() { // Find the datapoint with the correct attributes. if matches!(ty, MetricType::Gauge) { - return gauge - .data_points - .iter() - .any(|datapoint| datapoint.attributes == attributes); + return gauge.data_points.iter().any(|datapoint| { + Self::equal_attributes(&attributes, &datapoint.attributes) + }); } } else if let Some(sum) = metric.data.as_any().downcast_ref::>() { // Note that we can't actually tell if the sum is monotonic or not, so we just check if it's a sum. if matches!(ty, MetricType::Counter | MetricType::UpDownCounter) { - return sum - .data_points - .iter() - .any(|datapoint| datapoint.attributes == attributes); + return sum.data_points.iter().any(|datapoint| { + Self::equal_attributes(&attributes, &datapoint.attributes) + }); } } else if let Some(histogram) = metric.data.as_any().downcast_ref::>() + && matches!(ty, MetricType::Histogram) { - if matches!(ty, MetricType::Histogram) { - return histogram - .data_points - .iter() - .any(|datapoint| datapoint.attributes == attributes); - } + return histogram.data_points.iter().any(|datapoint| { + Self::equal_attributes(&attributes, &datapoint.attributes) + }); } } false @@ -305,6 +373,20 @@ pub(crate) mod test_utils { }) .collect() } + + fn equal_attributes(expected: &AttributeSet, actual: &[KeyValue]) -> bool { + // If lengths are different, we can short circuit. This also accounts for a bug where + // an empty attributes list would always be considered "equal" due to zip capping at + // the shortest iter's length + if expected.iter().count() != actual.len() { + return false; + } + // This works because the attributes are always sorted + expected.iter().zip(actual.iter()).all(|((k, v), kv)| { + kv.key == *k + && (kv.value == *v || kv.value == Value::String(StringValue::from(""))) + }) + } } #[derive(Serialize, Eq, PartialEq)] @@ -340,6 +422,8 @@ pub(crate) mod test_utils { pub(crate) value: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) sum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) count: Option, pub(crate) attributes: BTreeMap, } @@ -361,7 +445,7 @@ pub(crate) mod test_utils { impl SerdeMetricData { fn extract_datapoints + Clone + 'static>( metric_data: &mut SerdeMetricData, - value: &dyn opentelemetry::sdk::metrics::data::Aggregation, + value: &dyn opentelemetry_sdk::metrics::data::Aggregation, ) { if let Some(gauge) = value.as_any().downcast_ref::>() { gauge.data_points.iter().for_each(|datapoint| { @@ -386,7 +470,7 @@ pub(crate) mod test_utils { let mut serde_metric = SerdeMetric { name: value.name.into_owned(), description: value.description.into_owned(), - unit: value.unit.as_str().to_string(), + unit: value.unit.to_string(), data: value.data.into(), }; // Sort the datapoints so that we can compare them @@ -399,10 +483,10 @@ pub(crate) mod test_utils { .datapoints .iter_mut() .for_each(|datapoint| { - if let Some(sum) = &datapoint.sum { - if sum.as_f64().unwrap_or_default() > 0.0 { - datapoint.sum = Some(0.1.into()); - } + if let Some(sum) = &datapoint.sum + && sum.as_f64().unwrap_or_default() > 0.0 + { + datapoint.sum = Some(0.1.into()); } }); } @@ -418,10 +502,11 @@ pub(crate) mod test_utils { SerdeMetricDataPoint { value: Some(value.value.clone().into()), sum: None, + count: None, attributes: value .attributes .iter() - .map(|(k, v)| (k.as_str().to_string(), Self::convert(v))) + .map(|kv| (kv.key.to_string(), Self::convert(&kv.value))) .collect(), } } @@ -452,17 +537,18 @@ pub(crate) mod test_utils { SerdeMetricDataPoint { sum: Some(value.sum.clone().into()), value: None, + count: Some(value.count), attributes: value .attributes .iter() - .map(|(k, v)| (k.as_str().to_string(), Self::convert(v))) + .map(|kv| (kv.key.to_string(), Self::convert(&kv.value))) .collect(), } } } - impl From> for SerdeMetricData { - fn from(value: Box) -> Self { + impl From> for SerdeMetricData { + fn from(value: Box) -> Self { let mut metric_data = SerdeMetricData::default(); Self::extract_datapoints::(&mut metric_data, value.as_ref()); Self::extract_datapoints::(&mut metric_data, value.as_ref()); @@ -478,8 +564,12 @@ pub(crate) mod test_utils { Gauge, } } + +/// Returns a MeterProvider, as a concrete type so we can use our own extensions. +/// +/// During tests this is a task local so that we can test metrics without having to worry about other tests interfering. #[cfg(test)] -pub(crate) fn meter_provider() -> AggregateMeterProvider { +pub(crate) fn meter_provider_internal() -> AggregateMeterProvider { test_utils::meter_provider_and_readers().0 } @@ -488,48 +578,48 @@ pub(crate) use test_utils::collect_metrics; #[cfg(not(test))] static AGGREGATE_METER_PROVIDER: OnceLock = OnceLock::new(); + +/// Returns the currently configured global MeterProvider, as a concrete type +/// so we can use our own extensions. #[cfg(not(test))] -pub(crate) fn meter_provider() -> AggregateMeterProvider { +pub(crate) fn meter_provider_internal() -> AggregateMeterProvider { AGGREGATE_METER_PROVIDER .get_or_init(Default::default) .clone() } -#[macro_export] -/// Get or create a u64 monotonic counter metric and add a value to it +/// Returns the currently configured global [`MeterProvider`]. /// -/// This macro is a replacement for the telemetry crate's MetricsLayer. We will eventually convert all metrics to use these macros and deprecate the MetricsLayer. -/// The reason for this is that the MetricsLayer has: +/// See the [module-level documentation] for important details on the semver-compatibility guarantees of this API. /// -/// * No support for dynamic attributes -/// * No support dynamic metrics. -/// * Imperfect mapping to metrics API that can only be checked at runtime. +/// [`MeterProvider`]: opentelemetry::metrics::MeterProvider +/// [module-level documentation]: crate::metrics +pub fn meter_provider() -> impl opentelemetry::metrics::MeterProvider { + meter_provider_internal() +} + +/// Parse key/value attributes into `opentelemetry::KeyValue` structs. Should only be used within +/// this module, as a helper for the various metric macros (ie `u64_counter!`). +macro_rules! parse_attributes { + ($($attr_key:literal = $attr_value:expr),+) => {[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]}; + ($($($attr_key:ident).+ = $attr_value:expr),+) => {[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]}; + ($attrs:expr) => {$attrs}; +} + +/// Get or create a `u64` monotonic counter metric and add a value to it. +/// The metric must include a description. /// -/// New metrics should be added using these macros. +/// See the [module-level documentation](crate::metrics) for examples and details on the reasoning +/// behind this API. #[allow(unused_macros)] +#[deprecated(since = "TBD", note = "use `u64_counter_with_unit` instead")] macro_rules! u64_counter { - ($($name:ident).+, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(u64, counter, add, stringify!($($name).+), $description, $value, attributes); - }; - - ($($name:ident).+, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(u64, counter, add, stringify!($($name).+), $description, $value, attributes); + ($($name:ident).+, $description:literal, $value: expr, $($attrs:tt)*) => { + metric!(u64, counter, add, stringify!($($name).+), $description, $value, parse_attributes!($($attrs)*)); }; - ($name:literal, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(u64, counter, add, $name, $description, $value, attributes); - }; - - ($name:literal, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(u64, counter, add, $name, $description, $value, attributes); - }; - - ($name:literal, $description:literal, $value: expr, $attrs: expr) => { - metric!(u64, counter, add, $name, $description, $value, $attrs); + ($name:literal, $description:literal, $value: expr, $($attrs:tt)*) => { + metric!(u64, counter, add, $name, $description, $value, parse_attributes!($($attrs)*)); }; ($name:literal, $description:literal, $value: expr) => { @@ -537,39 +627,42 @@ macro_rules! u64_counter { } } -/// Get or create a f64 monotonic counter metric and add a value to it -/// -/// This macro is a replacement for the telemetry crate's MetricsLayer. We will eventually convert all metrics to use these macros and deprecate the MetricsLayer. -/// The reason for this is that the MetricsLayer has: +/// Get or create a u64 monotonic counter metric and add a value to it. +/// The metric must include a description and a unit. /// -/// * No support for dynamic attributes -/// * No support dynamic metrics. -/// * Imperfect mapping to metrics API that can only be checked at runtime. +/// The units should conform to the [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/#units). /// -/// New metrics should be added using these macros. +/// See the [module-level documentation](crate::metrics) for examples and details on the reasoning +/// behind this API. #[allow(unused_macros)] -macro_rules! f64_counter { - ($($name:ident).+, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(f64, counter, add, stringify!($($name).+), $description, $value, attributes); +macro_rules! u64_counter_with_unit { + ($($name:ident).+, $description:literal, $unit:literal, $value: expr, $($attrs:tt)*) => { + metric!(u64, counter, add, stringify!($($name).+), $description, $unit, $value, parse_attributes!($($attrs)*)); }; - ($($name:ident).+, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(f64, counter, add, stringify!($($name).+), $description, $value, attributes); + ($name:literal, $description:literal, $unit:literal, $value: expr, $($attrs:tt)*) => { + metric!(u64, counter, add, $name, $description, $unit, $value, parse_attributes!($($attrs)*)); }; - ($name:literal, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(f64, counter, add, $name, $description, $value, attributes); - }; + ($name:literal, $description:literal, $unit:literal, $value: expr) => { + metric!(u64, counter, add, $name, $description, $unit, $value, []); + } +} - ($name:literal, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(f64, counter, add, $name, $description, $value, attributes); +/// Get or create a f64 monotonic counter metric and add a value to it. +/// The metric must include a description. +/// +/// See the [module-level documentation](crate::metrics) for examples and details on the reasoning +/// behind this API. +#[allow(unused_macros)] +#[deprecated(since = "TBD", note = "use `f64_counter_with_unit` instead")] +macro_rules! f64_counter { + ($($name:ident).+, $description:literal, $value: expr, $($attrs:tt)*) => { + metric!(f64, counter, add, stringify!($($name).+), $description, $value, parse_attributes!($($attrs)*)); }; - ($name:literal, $description:literal, $value: expr, $attrs: expr) => { - metric!(f64, counter, add, $name, $description, $value, $attrs); + + ($name:literal, $description:literal, $value: expr, $($attrs:tt)*) => { + metric!(f64, counter, add, $name, $description, $value, parse_attributes!($($attrs)*)); }; ($name:literal, $description:literal, $value: expr) => { @@ -577,41 +670,42 @@ macro_rules! f64_counter { } } -/// Get or create an i64 up down counter metric and add a value to it +/// Get or create an f64 monotonic counter metric and add a value to it. +/// The metric must include a description and a unit. /// -/// This macro is a replacement for the telemetry crate's MetricsLayer. We will eventually convert all metrics to use these macros and deprecate the MetricsLayer. -/// The reason for this is that the MetricsLayer has: +/// The units should conform to the [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/#units). /// -/// * No support for dynamic attributes -/// * No support dynamic metrics. -/// * Imperfect mapping to metrics API that can only be checked at runtime. -/// -/// New metrics should be added using these macros. - +/// See the [module-level documentation](crate::metrics) for examples and details on the reasoning +/// behind this API. #[allow(unused_macros)] -macro_rules! i64_up_down_counter { - ($($name:ident).+, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(i64, up_down_counter, add, stringify!($($name).+), $description, $value, attributes); +macro_rules! f64_counter_with_unit { + ($($name:ident).+, $description:literal, $unit:literal, $value: expr, $($attrs:tt)*) => { + metric!(f64, counter, add, stringify!($($name).+), $description, $unit, $value, parse_attributes!($($attrs)*)); }; - ($($name:ident).+, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(i64, up_down_counter, add, stringify!($($name).+), $description, $value, attributes); + ($name:literal, $description:literal, $unit:literal, $value: expr, $($attrs:tt)*) => { + metric!(f64, counter, add, $name, $description, $unit, $value, parse_attributes!($($attrs)*)); }; - ($name:literal, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(i64, up_down_counter, add, $name, $description, $value, attributes); - }; + ($name:literal, $description:literal, $unit:literal, $value: expr) => { + metric!(f64, counter, add, $name, $description, $unit, $value, []); + } +} - ($name:literal, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(i64, up_down_counter, add, $name, $description, $value, attributes); +/// Get or create an i64 up down counter metric and add a value to it. +/// The metric must include a description. +/// +/// See the [module-level documentation](crate::metrics) for examples and details on the reasoning +/// behind this API. +#[allow(unused_macros)] +#[deprecated(since = "TBD", note = "use `i64_up_down_counter_with_unit` instead")] +macro_rules! i64_up_down_counter { + ($($name:ident).+, $description:literal, $value: expr, $($attrs:tt)*) => { + metric!(i64, up_down_counter, add, stringify!($($name).+), $description, $value, parse_attributes!($($attrs)*)); }; - ($name:literal, $description:literal, $value: expr, $attrs: expr) => { - metric!(i64, up_down_counter, add, $name, $description, $value, $attrs); + ($name:literal, $description:literal, $value: expr, $($attrs:tt)*) => { + metric!(i64, up_down_counter, add, $name, $description, $value, parse_attributes!($($attrs)*)); }; ($name:literal, $description:literal, $value: expr) => { @@ -619,40 +713,42 @@ macro_rules! i64_up_down_counter { }; } -/// Get or create an f64 up down counter metric and add a value to it +/// Get or create an i64 up down counter metric and add a value to it. +/// The metric must include a description and a unit. /// -/// This macro is a replacement for the telemetry crate's MetricsLayer. We will eventually convert all metrics to use these macros and deprecate the MetricsLayer. -/// The reason for this is that the MetricsLayer has: +/// The units should conform to the [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/#units). /// -/// * No support for dynamic attributes -/// * No support dynamic metrics. -/// * Imperfect mapping to metrics API that can only be checked at runtime. -/// -/// New metrics should be added using these macros. +/// See the [module-level documentation](crate::metrics) for examples and details on the reasoning +/// behind this API. #[allow(unused_macros)] -macro_rules! f64_up_down_counter { - ($($name:ident).+, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(f64, up_down_counter, add, stringify!($($name).+), $description, $value, attributes); +macro_rules! i64_up_down_counter_with_unit { + ($($name:ident).+, $description:literal, $unit:literal, $value: expr, $($attrs:tt)*) => { + metric!(i64, up_down_counter, add, stringify!($($name).+), $description, $unit, $value, parse_attributes!($($attrs)*)); }; - ($($name:ident).+, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(f64, up_down_counter, add, stringify!($($name).+), $description, $value, attributes); + ($name:literal, $description:literal, $unit:literal, $value: expr, $($attrs:tt)*) => { + metric!(i64, up_down_counter, add, $name, $description, $unit, $value, parse_attributes!($($attrs)*)); }; - ($name:literal, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(f64, up_down_counter, add, $name, $description, $value, attributes); - }; + ($name:literal, $description:literal, $unit:literal, $value: expr) => { + metric!(i64, up_down_counter, add, $name, $description, $unit, $value, []); + } +} - ($name:literal, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(f64, up_down_counter, add, $name, $description, $value, attributes); +/// Get or create an f64 up down counter metric and add a value to it. +/// The metric must include a description. +/// +/// See the [module-level documentation](crate::metrics) for examples and details on the reasoning +/// behind this API. +#[allow(unused_macros)] +#[deprecated(since = "TBD", note = "use `f64_up_down_counter_with_unit` instead")] +macro_rules! f64_up_down_counter { + ($($name:ident).+, $description:literal, $value: expr, $($attrs:tt)*) => { + metric!(f64, up_down_counter, add, stringify!($($name).+), $description, $value, parse_attributes!($($attrs)*)); }; - ($name:literal, $description:literal, $value: expr, $attrs: expr) => { - metric!(f64, up_down_counter, add, $name, $description, $value, $attrs); + ($name:literal, $description:literal, $value: expr, $($attrs:tt)*) => { + metric!(f64, up_down_counter, add, $name, $description, $value, parse_attributes!($($attrs)*)); }; ($name:literal, $description:literal, $value: expr) => { @@ -660,40 +756,42 @@ macro_rules! f64_up_down_counter { }; } -/// Get or create an f64 histogram metric and add a value to it -/// -/// This macro is a replacement for the telemetry crate's MetricsLayer. We will eventually convert all metrics to use these macros and deprecate the MetricsLayer. -/// The reason for this is that the MetricsLayer has: +/// Get or create an f64 up down counter metric and add a value to it. +/// The metric must include a description and a unit. /// -/// * No support for dynamic attributes -/// * No support dynamic metrics. -/// * Imperfect mapping to metrics API that can only be checked at runtime. +/// The units should conform to the [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/#units). /// -/// New metrics should be added using these macros. +/// See the [module-level documentation](crate::metrics) for examples and details on the reasoning +/// behind this API. #[allow(unused_macros)] -macro_rules! f64_histogram { - ($($name:ident).+, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(f64, histogram, record, stringify!($($name).+), $description, $value, attributes); +macro_rules! f64_up_down_counter_with_unit { + ($($name:ident).+, $description:literal, $unit:literal, $value: expr, $($attrs:tt)*) => { + metric!(f64, up_down_counter, add, stringify!($($name).+), $description, $unit, $value, parse_attributes!($($attrs)*)); }; - ($($name:ident).+, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(f64, histogram, record, stringify!($($name).+), $description, $value, attributes); + ($name:literal, $description:literal, $unit:literal, $value: expr, $($attrs:tt)*) => { + metric!(f64, up_down_counter, add, $name, $description, $unit, $value, parse_attributes!($($attrs)*)); }; - ($name:literal, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(f64, histogram, record, $name, $description, $value, attributes); - }; + ($name:literal, $description:literal, $unit:literal, $value: expr) => { + metric!(f64, up_down_counter, add, $name, $description, $unit, $value, []); + } +} - ($name:literal, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(f64, histogram, record, $name, $description, $value, attributes); +/// Get or create an f64 histogram metric and add a value to it. +/// The metric must include a description. +/// +/// See the [module-level documentation](crate::metrics) for examples and details on the reasoning +/// behind this API. +#[allow(unused_macros)] +#[deprecated(since = "TBD", note = "use `f64_histogram_with_unit` instead")] +macro_rules! f64_histogram { + ($($name:ident).+, $description:literal, $value: expr, $($attrs:tt)*) => { + metric!(f64, histogram, record, stringify!($($name).+), $description, $value, parse_attributes!($($attrs)*)); }; - ($name:literal, $description:literal, $value: expr, $attrs: expr) => { - metric!(f64, histogram, record, $name, $description, $value, $attrs); + ($name:literal, $description:literal, $value: expr, $($attrs:tt)*) => { + metric!(f64, histogram, record, $name, $description, $value, parse_attributes!($($attrs)*)); }; ($name:literal, $description:literal, $value: expr) => { @@ -701,40 +799,55 @@ macro_rules! f64_histogram { }; } -/// Get or create an u64 histogram metric and add a value to it +/// Get or create an f64 histogram metric and add a value to it. +/// The metric must include a description and a unit. +/// +/// The units should conform to the [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/#units). +/// +/// See the [module-level documentation](crate::metrics) for examples and details on the reasoning +/// behind this API. /// -/// This macro is a replacement for the telemetry crate's MetricsLayer. We will eventually convert all metrics to use these macros and deprecate the MetricsLayer. -/// The reason for this is that the MetricsLayer has: +/// ## Caveat /// -/// * No support for dynamic attributes -/// * No support dynamic metrics. -/// * Imperfect mapping to metrics API that can only be checked at runtime. +/// Two metrics with the same name but different descriptions and/or units will be created as +/// _separate_ metrics. /// -/// New metrics should be added using these macros. +/// ```ignore +/// f64_histogram_with_unit!("test", "test description", "s", 1.0, "attr" = "val"); +/// assert_histogram_sum!("test", 1, "attr" = "val"); +/// +/// f64_histogram_with_unit!("test", "test description", "Hz", 1.0, "attr" = "val"); +/// assert_histogram_sum!("test", 1, "attr" = "val"); +/// ``` #[allow(unused_macros)] -macro_rules! u64_histogram { - ($($name:ident).+, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(u64, histogram, record, stringify!($($name).+), $description, $value, attributes); +macro_rules! f64_histogram_with_unit { + ($($name:ident).+, $description:literal, $unit:literal, $value: expr, $($attrs:tt)*) => { + metric!(f64, histogram, record, stringify!($($name).+), $description, $unit, $value, parse_attributes!($($attrs)*)); }; - ($($name:ident).+, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(u64, histogram, record, stringify!($($name).+), $description, $value, attributes); + ($name:literal, $description:literal, $unit:literal, $value: expr, $($attrs:tt)*) => { + metric!(f64, histogram, record, $name, $description, $unit, $value, parse_attributes!($($attrs)*)); }; - ($name:literal, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(u64, histogram, record, $name, $description, $value, attributes); + ($name:literal, $description:literal, $unit:literal, $value: expr) => { + metric!(f64, histogram, record, $name, $description, $unit, $value, []); }; +} - ($name:literal, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(u64, histogram, record, $name, $description, $value, attributes); +/// Get or create a u64 histogram metric and add a value to it. +/// The metric must include a description. +/// +/// See the [module-level documentation](crate::metrics) for examples and details on the reasoning +/// behind this API. +#[allow(unused_macros)] +#[deprecated(since = "TBD", note = "use `u64_histogram_with_unit` instead")] +macro_rules! u64_histogram { + ($($name:ident).+, $description:literal, $value: expr, $($attrs:tt)*) => { + metric!(u64, histogram, record, stringify!($($name).+), $description, $value, parse_attributes!($($attrs)*)); }; - ($name:literal, $description:literal, $value: expr, $attrs: expr) => { - metric!(u64, histogram, record, $name, $description, $value, $attrs); + ($name:literal, $description:literal, $value: expr, $($attrs:tt)*) => { + metric!(u64, histogram, record, $name, $description, $value, parse_attributes!($($attrs)*)); }; ($name:literal, $description:literal, $value: expr) => { @@ -742,44 +855,25 @@ macro_rules! u64_histogram { }; } -/// Get or create an i64 histogram metric and add a value to it -/// -/// This macro is a replacement for the telemetry crate's MetricsLayer. We will eventually convert all metrics to use these macros and deprecate the MetricsLayer. -/// The reason for this is that the MetricsLayer has: +/// Get or create a u64 histogram metric and add a value to it. +/// The metric must include a description and a unit. /// -/// * No support for dynamic attributes -/// * No support dynamic metrics. -/// * Imperfect mapping to metrics API that can only be checked at runtime. +/// The units should conform to the [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/#units). /// -/// New metrics should be added using these macros. +/// See the [module-level documentation](crate::metrics) for examples and details on the reasoning +/// behind this API. #[allow(unused_macros)] -macro_rules! i64_histogram { - ($($name:ident).+, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(i64, histogram, record, stringify!($($name).+), $description, $value, attributes); +macro_rules! u64_histogram_with_unit { + ($($name:ident).+, $description:literal, $unit:literal, $value: expr, $($attrs:tt)*) => { + metric!(u64, histogram, record, stringify!($($name).+), $description, $unit, $value, parse_attributes!($($attrs)*)); }; - ($($name:ident).+, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(i64, histogram, record, stringify!($($name).+), $description, $value, attributes); + ($name:literal, $description:literal, $unit:literal, $value: expr, $($attrs:tt)*) => { + metric!(u64, histogram, record, $name, $description, $unit, $value, parse_attributes!($($attrs)*)); }; - ($name:literal, $description:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - metric!(i64, histogram, record, $name, $description, $value, attributes); - }; - - ($name:literal, $description:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { - let attributes = [$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - metric!(i64, histogram, record, $name, $description, $value, attributes); - }; - - ($name:literal, $description:literal, $value: expr, $attrs: expr) => { - metric!(i64, histogram, record, $name, $description, $value, $attrs); - }; - - ($name:literal, $description:literal, $value: expr) => { - metric!(i64, histogram, record, $name, $description, $value, []); + ($name:literal, $description:literal, $unit:literal, $value: expr) => { + metric!(u64, histogram, record, $name, $description, $unit, $value, []); }; } @@ -789,8 +883,7 @@ thread_local! { pub(crate) static CACHE_CALLSITE: std::sync::atomic::AtomicBool = const {std::sync::atomic::AtomicBool::new(false)}; } macro_rules! metric { - ($ty:ident, $instrument:ident, $mutation:ident, $name:expr, $description:literal, $value: expr, $attrs: expr) => { - + ($ty:ident, $instrument:ident, $mutation:ident, $name:expr, $description:literal, $unit:literal, $value:expr, $attrs:expr) => { // The way this works is that we have a static at each call site that holds a weak reference to the instrument. // We make a call we try to upgrade the weak reference. If it succeeds we use the instrument. // Otherwise we create a new instrument and update the static. @@ -809,25 +902,35 @@ macro_rules! metric { #[cfg(not(test))] let cache_callsite = true; + let create_instrument_fn = |meter: opentelemetry::metrics::Meter| { + let mut builder = meter.[<$ty _ $instrument>]($name); + builder = builder.with_description($description); + + if !$unit.is_empty() { + builder = builder.with_unit($unit); + } + + builder.init() + }; + if cache_callsite { - static INSTRUMENT_CACHE: std::sync::OnceLock]<$ty>>>> = std::sync::OnceLock::new(); + static INSTRUMENT_CACHE: std::sync::OnceLock]<$ty>>>> = std::sync::OnceLock::new(); let mut instrument_guard = INSTRUMENT_CACHE .get_or_init(|| { - let meter_provider = crate::metrics::meter_provider(); - let instrument_ref = meter_provider.create_registered_instrument(|p| p.meter("apollo/router").[<$ty _ $instrument>]($name).with_description($description).init()); - std::sync::Mutex::new(std::sync::Arc::downgrade(&instrument_ref)) + let meter_provider = crate::metrics::meter_provider_internal(); + let instrument_ref = meter_provider.create_registered_instrument(|p| create_instrument_fn(p.meter("apollo/router"))); + parking_lot::Mutex::new(std::sync::Arc::downgrade(&instrument_ref)) }) - .lock() - .expect("lock poisoned"); + .lock(); let instrument = if let Some(instrument) = instrument_guard.upgrade() { // Fast path, we got the instrument, drop the mutex guard immediately. drop(instrument_guard); instrument } else { // Slow path, we need to obtain the instrument again. - let meter_provider = crate::metrics::meter_provider(); - let instrument_ref = meter_provider.create_registered_instrument(|p| p.meter("apollo/router").[<$ty _ $instrument>]($name).with_description($description).init()); + let meter_provider = crate::metrics::meter_provider_internal(); + let instrument_ref = meter_provider.create_registered_instrument(|p| create_instrument_fn(p.meter("apollo/router"))); *instrument_guard = std::sync::Arc::downgrade(&instrument_ref); // We've updated the instrument and got a strong reference to it. We can drop the mutex guard now. drop(instrument_guard); @@ -838,17 +941,20 @@ macro_rules! metric { else { let meter_provider = crate::metrics::meter_provider(); let meter = opentelemetry::metrics::MeterProvider::meter(&meter_provider, "apollo/router"); - let instrument = meter.[<$ty _ $instrument>]($name).with_description($description).init(); - instrument.$mutation($value, &$attrs); + create_instrument_fn(meter).$mutation($value, &$attrs); } } } }; + + ($ty:ident, $instrument:ident, $mutation:ident, $name:expr, $description:literal, $value: expr, $attrs: expr) => { + metric!($ty, $instrument, $mutation, $name, $description, "", $value, $attrs); + } } #[cfg(test)] macro_rules! assert_metric { - ($result:expr, $name:expr, $value:expr, $sum:expr, $attrs:expr) => { + ($result:expr, $name:expr, $value:expr, $sum:expr, $count:expr, $attrs:expr) => { if !$result { let metric = crate::metrics::test_utils::SerdeMetric { name: $name.to_string(), @@ -858,6 +964,7 @@ macro_rules! assert_metric { datapoints: [crate::metrics::test_utils::SerdeMetricDataPoint { value: $value, sum: $sum, + count: $count, attributes: $attrs .iter() .map(|kv: &opentelemetry::KeyValue| { @@ -882,205 +989,354 @@ macro_rules! assert_metric { }; } +#[cfg(test)] +macro_rules! assert_no_metric { + ($result:expr, $name:expr, $value:expr, $sum:expr, $count:expr, $attrs:expr) => { + if $result { + let metric = crate::metrics::test_utils::SerdeMetric { + name: $name.to_string(), + description: "".to_string(), + unit: "".to_string(), + data: crate::metrics::test_utils::SerdeMetricData { + datapoints: [crate::metrics::test_utils::SerdeMetricDataPoint { + value: $value, + sum: $sum, + count: $count, + attributes: $attrs + .iter() + .map(|kv: &opentelemetry::KeyValue| { + ( + kv.key.to_string(), + crate::metrics::test_utils::SerdeMetricDataPoint::convert( + &kv.value, + ), + ) + }) + .collect::>(), + }] + .to_vec(), + }, + }; + panic!( + "unexpected metric found:\n{}\nmetrics present:\n{}", + serde_yaml::to_string(&metric).unwrap(), + serde_yaml::to_string(&crate::metrics::collect_metrics().all()).unwrap() + ) + } + }; +} + +/// Assert the value of a counter metric that has the given name and attributes. +/// +/// In asynchronous tests, you must use [`FutureMetricsExt::with_metrics`]. See dev-docs/metrics.md +/// for details: #[cfg(test)] macro_rules! assert_counter { ($($name:ident).+, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { let name = stringify!($($name).+); let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert(name, crate::metrics::test_utils::MetricType::Counter, $value, attributes); - assert_metric!(result, name, Some($value.into()), None, &attributes); + let result = crate::metrics::collect_metrics().assert(name, crate::metrics::test_utils::MetricType::Counter, $value, false, attributes); + assert_metric!(result, name, Some($value.into()), None, None, &attributes); }; ($($name:ident).+, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { let name = stringify!($($name).+); let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert(name, crate::metrics::test_utils::MetricType::Counter, $value, attributes); - assert_metric!(result, name, Some($value.into()), None, &attributes); + let result = crate::metrics::collect_metrics().assert(name, crate::metrics::test_utils::MetricType::Counter, $value, false, attributes); + assert_metric!(result, name, Some($value.into()), None, None, &attributes); }; ($name:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Counter, $value, attributes); - assert_metric!(result, $name, Some($value.into()), None, &attributes); + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Counter, $value, false, attributes); + assert_metric!(result, $name, Some($value.into()), None, None, &attributes); }; ($name:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Counter, $value, attributes); - assert_metric!(result, $name, Some($value.into()), None, &attributes); + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Counter, $value, false, attributes); + assert_metric!(result, $name, Some($value.into()), None, None, &attributes); + }; + + ($name:literal, $value: expr, $attributes: expr) => { + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Counter, $value, false, $attributes); + assert_metric!(result, $name, Some($value.into()), None, None, &$attributes); }; ($name:literal, $value: expr) => { - let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Counter, $value, &[]); - assert_metric!(result, $name, Some($value.into()), None, &[]); + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Counter, $value, false, &[]); + assert_metric!(result, $name, Some($value.into()), None, None, &[]); }; } +/// Assert that a counter metric does not exist with the given name and attributes. +/// +/// In asynchronous tests, you must use [`FutureMetricsExt::with_metrics`]. See dev-docs/metrics.md +/// for details: +#[cfg(test)] +macro_rules! assert_counter_not_exists { + + ($($name:ident).+, $value: ty, $($attr_key:literal = $attr_value:expr),+) => { + let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; + let result = crate::metrics::collect_metrics().metric_exists::<$value>(stringify!($($name).+), crate::metrics::test_utils::MetricType::Counter, attributes); + assert_no_metric!(result, $name, None, None, None, attributes); + }; + + ($($name:ident).+, $value: ty, $($($attr_key:ident).+ = $attr_value:expr),+) => { + let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; + let result = crate::metrics::collect_metrics().metric_exists::<$value>(stringify!($($name).+), crate::metrics::test_utils::MetricType::Counter, attributes); + assert_no_metric!(result, $name, None, None, None, attributes); + }; + + ($name:literal, $value: ty, $($attr_key:literal = $attr_value:expr),+) => { + let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; + let result = crate::metrics::collect_metrics().metric_exists::<$value>($name, crate::metrics::test_utils::MetricType::Counter, attributes); + assert_no_metric!(result, $name, None, None, None, attributes); + }; + + ($name:literal, $value: ty, $($($attr_key:ident).+ = $attr_value:expr),+) => { + let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; + let result = crate::metrics::collect_metrics().metric_exists::<$value>($name, crate::metrics::test_utils::MetricType::Counter, attributes); + assert_no_metric!(result, $name, None, None, None, attributes); + }; + + + ($name:literal, $value: ty, $attributes: expr) => { + let result = crate::metrics::collect_metrics().metric_exists::<$value>($name, crate::metrics::test_utils::MetricType::Counter, $attributes); + assert_no_metric!(result, $name, None, None, None, &$attributes); + }; + + ($name:literal, $value: ty) => { + let result = crate::metrics::collect_metrics().metric_exists::<$value>($name, crate::metrics::test_utils::MetricType::Counter, &[]); + assert_no_metric!(result, $name, None, None, None, &[]); + }; +} + +/// Assert the value of a counter metric that has the given name and attributes. +/// +/// In asynchronous tests, you must use [`FutureMetricsExt::with_metrics`]. See dev-docs/metrics.md +/// for details: #[cfg(test)] macro_rules! assert_up_down_counter { ($($name:ident).+, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::UpDownCounter, $value, attributes); - assert_metric!(result, $name, Some($value.into()), None, attributes); + let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::UpDownCounter, $value, false, attributes); + assert_metric!(result, $name, Some($value.into()), None, None, attributes); }; ($($name:ident).+, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::UpDownCounter, $value, attributes); - assert_metric!(result, $name, Some($value.into()), None, attributes); + let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::UpDownCounter, $value, false, attributes); + assert_metric!(result, $name, Some($value.into()), None, None, attributes); }; ($name:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::UpDownCounter, $value, attributes); - assert_metric!(result, $name, Some($value.into()), None, attributes); + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::UpDownCounter, $value, false, attributes); + assert_metric!(result, $name, Some($value.into()), None, None, attributes); }; ($name:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::UpDownCounter, $value, attributes); - assert_metric!(result, $name, Some($value.into()), None, attributes); + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::UpDownCounter, $value, false, attributes); + assert_metric!(result, $name, Some($value.into()), None, None, attributes); }; ($name:literal, $value: expr) => { - let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::UpDownCounter, $value, &[]); - assert_metric!(result, $name, Some($value.into()), None, &[]); + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::UpDownCounter, $value, false, &[]); + assert_metric!(result, $name, Some($value.into()), None, None, &[]); }; } +/// Assert the value of a gauge metric that has the given name and attributes. +/// +/// In asynchronous tests, you must use [`FutureMetricsExt::with_metrics`]. See dev-docs/metrics.md +/// for details: #[cfg(test)] macro_rules! assert_gauge { ($($name:ident).+, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::Gauge, $value, attributes); - assert_metric!(result, $name, Some($value.into()), None, attributes); + let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::Gauge, $value, false, attributes); + assert_metric!(result, $name, Some($value.into()), None, None, attributes); }; ($($name:ident).+, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::Gauge, $value, attributes); - assert_metric!(result, $name, Some($value.into()), None, attributes); + let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::Gauge, $value, false, attributes); + assert_metric!(result, $name, Some($value.into()), None, None, attributes); }; ($name:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Gauge, $value, attributes); - assert_metric!(result, $name, Some($value.into()), None, attributes); + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Gauge, $value, false, attributes); + assert_metric!(result, $name, Some($value.into()), None, None, attributes); }; ($name:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Gauge, $value, attributes); - assert_metric!(result, $name, Some($value.into()), None, attributes); + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Gauge, $value, false, attributes); + assert_metric!(result, $name, Some($value.into()), None, None, attributes); }; ($name:literal, $value: expr) => { - let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Gauge, $value, &[]); - assert_metric!(result, $name, Some($value.into()), None, &[]); + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Gauge, $value, false, &[]); + assert_metric!(result, $name, Some($value.into()), None, None, &[]); }; } #[cfg(test)] -macro_rules! assert_histogram_sum { +macro_rules! assert_histogram_count { ($($name:ident).+, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::Histogram, $value, attributes); - assert_metric!(result, $name, None, Some($value.into()), attributes); + let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::Histogram, $value, true, attributes); + assert_metric!(result, $name, None, Some($value.into()), Some(num_traits::ToPrimitive::to_u64(&$value).expect("count should be convertible to u64")), attributes); }; ($($name:ident).+, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::Histogram, $value, attributes); - assert_metric!(result, $name, None, Some($value.into()), attributes); + let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::Histogram, $value, true, attributes); + assert_metric!(result, $name, None, Some($value.into()), Some(num_traits::ToPrimitive::to_u64(&$value).expect("count should be convertible to u64")), attributes); }; ($name:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; - let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Histogram, $value, attributes); - assert_metric!(result, $name, None, Some($value.into()), attributes); + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Histogram, $value, true, attributes); + assert_metric!(result, $name, None, Some($value.into()), Some(num_traits::ToPrimitive::to_u64(&$value).expect("count should be convertible to u64")), attributes); }; ($name:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Histogram, $value, attributes); - assert_metric!(result, $name, None, Some($value.into()), attributes); + assert_metric!(result, $name, None, Some($value.into()), Some(num_traits::ToPrimitive::to_u64(&$value).expect("count should be convertible to u64")), attributes); + }; + + ($name:literal, $value: expr) => { + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Histogram, $value, true, &[]); + assert_metric!(result, $name, None, Some($value.into()), Some(num_traits::ToPrimitive::to_u64(&$value).expect("count should be convertible to u64")), &[]); + }; +} + +/// Assert the sum value of a histogram metric with the given name and attributes. +/// +/// In asynchronous tests, you must use [`FutureMetricsExt::with_metrics`]. See dev-docs/metrics.md +/// for details: +#[cfg(test)] +macro_rules! assert_histogram_sum { + + ($($name:ident).+, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { + let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; + let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::Histogram, $value, false, attributes); + assert_metric!(result, $name, None, Some($value.into()), None, attributes); + }; + + ($($name:ident).+, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { + let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; + let result = crate::metrics::collect_metrics().assert(stringify!($($name).+), crate::metrics::test_utils::MetricType::Histogram, $value, false, attributes); + assert_metric!(result, $name, None, Some($value.into()), None, attributes); + }; + + ($name:literal, $value: expr, $($attr_key:literal = $attr_value:expr),+) => { + let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Histogram, $value, false, attributes); + assert_metric!(result, $name, None, Some($value.into()), None, attributes); + }; + + ($name:literal, $value: expr, $($($attr_key:ident).+ = $attr_value:expr),+) => { + let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Histogram, $value, false, attributes); + assert_metric!(result, $name, None, Some($value.into()), None, attributes); }; ($name:literal, $value: expr) => { - let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Histogram, $value, &[]); - assert_metric!(result, $name, None, Some($value.into()), &[]); + let result = crate::metrics::collect_metrics().assert($name, crate::metrics::test_utils::MetricType::Histogram, $value, false, &[]); + assert_metric!(result, $name, None, Some($value.into()), None, &[]); }; } +/// Assert that a histogram metric exists with the given name and attributes. +/// +/// In asynchronous tests, you must use [`FutureMetricsExt::with_metrics`]. See dev-docs/metrics.md +/// for details: #[cfg(test)] macro_rules! assert_histogram_exists { ($($name:ident).+, $value: ty, $($attr_key:literal = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; let result = crate::metrics::collect_metrics().metric_exists::<$value>(stringify!($($name).+), crate::metrics::test_utils::MetricType::Histogram, attributes); - assert_metric!(result, $name, None, None, attributes); + assert_metric!(result, $name, None, None, None, attributes); }; ($($name:ident).+, $value: ty, $($($attr_key:ident).+ = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; let result = crate::metrics::collect_metrics().metric_exists::<$value>(stringify!($($name).+), crate::metrics::test_utils::MetricType::Histogram, attributes); - assert_metric!(result, $name, None, None, attributes); + assert_metric!(result, $name, None, None, None, attributes); }; ($name:literal, $value: ty, $($attr_key:literal = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; let result = crate::metrics::collect_metrics().metric_exists::<$value>($name, crate::metrics::test_utils::MetricType::Histogram, attributes); - assert_metric!(result, $name, None, None, attributes); + assert_metric!(result, $name, None, None, None, attributes); }; ($name:literal, $value: ty, $($($attr_key:ident).+ = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; let result = crate::metrics::collect_metrics().metric_exists::<$value>($name, crate::metrics::test_utils::MetricType::Histogram, attributes); - assert_metric!(result, $name, None, None, attributes); + assert_metric!(result, $name, None, None, None, attributes); }; ($name:literal, $value: ty) => { let result = crate::metrics::collect_metrics().metric_exists::<$value>($name, crate::metrics::test_utils::MetricType::Histogram, &[]); - assert_metric!(result, $name, None, None, &[]); + assert_metric!(result, $name, None, None, None, &[]); }; } +/// Assert that a histogram metric does not exist with the given name and attributes. +/// +/// In asynchronous tests, you must use [`FutureMetricsExt::with_metrics`]. See dev-docs/metrics.md +/// for details: #[cfg(test)] macro_rules! assert_histogram_not_exists { ($($name:ident).+, $value: ty, $($attr_key:literal = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; let result = crate::metrics::collect_metrics().metric_exists::<$value>(stringify!($($name).+), crate::metrics::test_utils::MetricType::Histogram, attributes); - assert_metric!(!result, $name, None, None, attributes); + assert_no_metric!(result, $name, None, None, None, attributes); }; ($($name:ident).+, $value: ty, $($($attr_key:ident).+ = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; let result = crate::metrics::collect_metrics().metric_exists::<$value>(stringify!($($name).+), crate::metrics::test_utils::MetricType::Histogram, attributes); - assert_metric!(!result, $name, None, None, attributes); + assert_no_metric!(result, $name, None, None, None, attributes); }; ($name:literal, $value: ty, $($attr_key:literal = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new($attr_key, $attr_value)),+]; let result = crate::metrics::collect_metrics().metric_exists::<$value>($name, crate::metrics::test_utils::MetricType::Histogram, attributes); - assert_metric!(!result, $name, None, None, attributes); + assert_no_metric!(result, $name, None, None, None, attributes); }; ($name:literal, $value: ty, $($($attr_key:ident).+ = $attr_value:expr),+) => { let attributes = &[$(opentelemetry::KeyValue::new(stringify!($($attr_key).+), $attr_value)),+]; let result = crate::metrics::collect_metrics().metric_exists::<$value>($name, crate::metrics::test_utils::MetricType::Histogram, attributes); - assert_metric!(!result, $name, None, None, attributes); + assert_no_metric!(result, $name, None, None, None, attributes); }; ($name:literal, $value: ty) => { let result = crate::metrics::collect_metrics().metric_exists::<$value>($name, crate::metrics::test_utils::MetricType::Histogram, &[]); - assert_metric!(!result, $name, None, None, &[]); + assert_no_metric!(result, $name, None, None, None, &[]); }; } +/// Assert that all metrics match an [insta] snapshot. +/// +/// Consider using [assert_non_zero_metrics_snapshot] to produce more grokkable snapshots if +/// zero-valued metrics are not relevant to your test. +/// +/// In asynchronous tests, you must use [`FutureMetricsExt::with_metrics`]. See dev-docs/metrics.md +/// for details: #[cfg(test)] #[allow(unused_macros)] macro_rules! assert_metrics_snapshot { @@ -1099,6 +1355,10 @@ macro_rules! assert_metrics_snapshot { }; } +/// Assert that all metrics with a non-zero value match an [insta] snapshot. +/// +/// In asynchronous tests, you must use [`FutureMetricsExt::with_metrics`]. See dev-docs/metrics.md +/// for details: #[cfg(test)] #[allow(unused_macros)] macro_rules! assert_non_zero_metrics_snapshot { @@ -1117,10 +1377,29 @@ macro_rules! assert_non_zero_metrics_snapshot { } #[cfg(test)] -pub(crate) type MetricFuture = Pin::Output> + Send>>; +pub(crate) type MetricFuture = Pin::Output>>>; -#[cfg(test)] +/// Extension trait for Futures that wish to test metrics. pub(crate) trait FutureMetricsExt { + /// Wraps a Future with metrics collection capabilities. + /// + /// This method creates a new Future that will: + /// 1. Initialize the meter provider before executing the Future + /// 2. Execute the original Future + /// 3. Shutdown the meter provider after completion + /// + /// This is useful for testing scenarios where you need to ensure metrics are properly + /// collected throughout the entire Future's execution. + /// + /// # Example + /// ```rust + /// # use apollo_router::metrics::FutureMetricsExt; + /// # async fn example() { + /// let future = async { /* your async code that produces metrics */ }; + /// let result = future.with_metrics().await; + /// # } + /// ``` + #[cfg(test)] fn with_metrics( self, ) -> tokio::task::futures::TaskLocalFuture< @@ -1128,35 +1407,88 @@ pub(crate) trait FutureMetricsExt { MetricFuture, > where - Self: Sized + Future + Send + 'static, - ::Output: Send + 'static, + Self: Sized + Future + 'static, + ::Output: 'static, { test_utils::AGGREGATE_METER_PROVIDER_ASYNC.scope( Default::default(), async move { + // We want to eagerly create the meter provider, the reason is that this will be shared among subtasks that use `with_current_meter_provider`. + let _ = meter_provider_internal(); let result = self.await; let _ = tokio::task::spawn_blocking(|| { - meter_provider().shutdown(); + meter_provider_internal().shutdown(); }) .await; result } - .boxed(), + .boxed_local(), ) } + + /// Propagates the current meter provider to child tasks during test execution. + /// + /// This method ensures that the meter provider is properly shared across tasks + /// during test scenarios. In non-test contexts, it returns the original Future + /// unchanged. + /// + /// # Example + /// ```rust + /// # use apollo_router::metrics::FutureMetricsExt; + /// # async fn example() { + /// let result = tokio::task::spawn(async { /* your async code that produces metrics */ }.with_current_meter_provider()).await; + /// # } + /// ``` + #[cfg(test)] + fn with_current_meter_provider( + self, + ) -> tokio::task::futures::TaskLocalFuture< + OnceLock<(AggregateMeterProvider, test_utils::ClonableManualReader)>, + Self, + > + where + Self: Sized + Future + 'static, + ::Output: 'static, + { + // We need to determine if the meter was set. If not then we can use default provider which is empty + let meter_provider_set = test_utils::AGGREGATE_METER_PROVIDER_ASYNC + .try_with(|_| {}) + .is_ok(); + if meter_provider_set { + test_utils::AGGREGATE_METER_PROVIDER_ASYNC + .scope(test_utils::AGGREGATE_METER_PROVIDER_ASYNC.get(), self) + } else { + test_utils::AGGREGATE_METER_PROVIDER_ASYNC.scope(Default::default(), self) + } + } + + #[cfg(not(test))] + fn with_current_meter_provider(self) -> Self + where + Self: Sized + Future + 'static, + { + // This is intentionally a noop. In the real world meter provider is a global variable. + self + } } -#[cfg(test)] impl FutureMetricsExt for T where T: Future {} #[cfg(test)] mod test { - use opentelemetry_api::metrics::MeterProvider; - use opentelemetry_api::KeyValue; + use opentelemetry::KeyValue; + use opentelemetry::metrics::MeterProvider; + use crate::metrics::FutureMetricsExt; use crate::metrics::aggregation::MeterProviderType; use crate::metrics::meter_provider; - use crate::metrics::FutureMetricsExt; + use crate::metrics::meter_provider_internal; + + fn assert_unit(name: &str, unit: &str) { + let collected_metrics = crate::metrics::collect_metrics(); + let metric = collected_metrics.find(name).unwrap(); + assert_eq!(metric.unit, unit); + } #[test] fn test_gauge() { @@ -1169,6 +1501,13 @@ mod test { assert_gauge!("test", 5); } + #[test] + fn test_gauge_record() { + let gauge = meter_provider().meter("test").u64_gauge("test").init(); + gauge.record(5, &[]); + assert_gauge!("test", 5); + } + #[test] fn test_no_attributes() { u64_counter!("test", "test description", 1); @@ -1180,6 +1519,7 @@ mod test { let attributes = vec![KeyValue::new("attr", "val")]; u64_counter!("test", "test description", 1, attributes); assert_counter!("test", 1, "attr" = "val"); + assert_counter!("test", 1, &attributes); } #[test] @@ -1292,16 +1632,6 @@ mod test { .await; } - #[tokio::test] - async fn test_i64_histogram() { - async { - i64_histogram!("test", "test description", 1, "attr" = "val"); - assert_histogram_sum!("test", 1, "attr" = "val"); - } - .with_metrics() - .await; - } - #[tokio::test] async fn test_f64_histogram() { async { @@ -1360,6 +1690,21 @@ mod test { .await; } + #[test] + fn parse_attributes_should_handle_multiple_input_types() { + let variable = 123; + let parsed_idents = parse_attributes!(hello = "world", my.variable = variable); + let parsed_literals = parse_attributes!("hello" = "world", "my.variable" = variable); + let parsed_provided = parse_attributes!(vec![ + KeyValue::new("hello", "world"), + KeyValue::new("my.variable", variable) + ]); + + assert_eq!(parsed_idents, parsed_literals); + assert_eq!(parsed_idents.as_slice(), parsed_provided.as_slice()); + assert_eq!(parsed_literals.as_slice(), parsed_provided.as_slice()); + } + #[test] fn test_callsite_caching() { // Creating instruments may be slow due to multiple levels of locking that needs to happen through the various metrics layers. @@ -1372,28 +1717,125 @@ mod test { } // Callsite hasn't been used yet, so there should be no metrics - assert_eq!(meter_provider().registered_instruments(), 0); + assert_eq!(meter_provider_internal().registered_instruments(), 0); // Call the metrics, it will be registered test(); assert_counter!("test", 1, "attr" = "val"); - assert_eq!(meter_provider().registered_instruments(), 1); + assert_eq!(meter_provider_internal().registered_instruments(), 1); // Call the metrics again, but the second call will not register a new metric because it will have be retrieved from the static test(); assert_counter!("test", 2, "attr" = "val"); - assert_eq!(meter_provider().registered_instruments(), 1); + assert_eq!(meter_provider_internal().registered_instruments(), 1); // Force invalidation of instruments - meter_provider().set(MeterProviderType::PublicPrometheus, None); - assert_eq!(meter_provider().registered_instruments(), 0); + meter_provider_internal().set(MeterProviderType::PublicPrometheus, None); + assert_eq!(meter_provider_internal().registered_instruments(), 0); // Slow path test(); - assert_eq!(meter_provider().registered_instruments(), 1); + assert_eq!(meter_provider_internal().registered_instruments(), 1); // Fast path test(); - assert_eq!(meter_provider().registered_instruments(), 1); + assert_eq!(meter_provider_internal().registered_instruments(), 1); + } + + #[tokio::test] + async fn test_f64_histogram_with_unit() { + async { + f64_histogram_with_unit!("test", "test description", "m/s", 1.0, "attr" = "val"); + assert_histogram_sum!("test", 1, "attr" = "val"); + assert_unit("test", "m/s"); + } + .with_metrics() + .await; + } + + #[tokio::test] + async fn test_u64_counter_with_unit() { + async { + u64_counter_with_unit!("test", "test description", "Hz", 1, attr = "val"); + assert_counter!("test", 1, "attr" = "val"); + assert_unit("test", "Hz"); + } + .with_metrics() + .await; + } + + #[tokio::test] + async fn test_i64_up_down_counter_with_unit() { + async { + i64_up_down_counter_with_unit!( + "test", + "test description", + "{request}", + 1, + attr = "val" + ); + assert_up_down_counter!("test", 1, "attr" = "val"); + assert_unit("test", "{request}"); + } + .with_metrics() + .await; + } + + #[tokio::test] + async fn test_f64_up_down_counter_with_unit() { + async { + f64_up_down_counter_with_unit!("test", "test description", "kg", 1.5, "attr" = "val"); + assert_up_down_counter!("test", 1.5, "attr" = "val"); + assert_unit("test", "kg"); + } + .with_metrics() + .await; + } + + #[tokio::test] + async fn test_u64_histogram_with_unit() { + async { + u64_histogram_with_unit!("test", "test description", "{packet}", 1, "attr" = "val"); + assert_histogram_sum!("test", 1, "attr" = "val"); + assert_unit("test", "{packet}"); + } + .with_metrics() + .await; + } + + #[tokio::test] + async fn test_f64_counter_with_unit() { + async { + f64_counter_with_unit!("test", "test description", "s", 1.5, "attr" = "val"); + assert_counter!("test", 1.5, "attr" = "val"); + assert_unit("test", "s"); + } + .with_metrics() + .await; + } + + #[tokio::test] + async fn test_metrics_across_tasks() { + async { + // Initial metric in the main task + u64_counter!("apollo.router.test", "metric", 1); + assert_counter!("apollo.router.test", 1); + + // Spawn a task that also records metrics + let handle = tokio::spawn( + async move { + u64_counter!("apollo.router.test", "metric", 2); + } + .with_current_meter_provider(), + ); + + // Wait for the spawned task to complete + handle.await.unwrap(); + + // The metric should now be 3 since both tasks contributed + assert_counter!("apollo.router.test", 3); + } + .with_metrics() + .await; } } diff --git a/apollo-router/src/notification.rs b/apollo-router/src/notification.rs index 7cfba87e7a..a167e93fe3 100644 --- a/apollo-router/src/notification.rs +++ b/apollo-router/src/notification.rs @@ -21,14 +21,15 @@ use tokio::sync::mpsc::error::SendError; use tokio::sync::mpsc::error::TrySendError; use tokio::sync::oneshot; use tokio::sync::oneshot::error::RecvError; -use tokio_stream::wrappers::errors::BroadcastStreamRecvError; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::IntervalStream; use tokio_stream::wrappers::ReceiverStream; +use tokio_stream::wrappers::errors::BroadcastStreamRecvError; +use crate::Configuration; use crate::graphql; +use crate::metrics::FutureMetricsExt; use crate::spec::Schema; -use crate::Configuration; static NOTIFY_CHANNEL_SIZE: usize = 1024; static DEFAULT_MSG_CHANNEL_SIZE: usize = 128; @@ -66,6 +67,8 @@ pub(crate) enum Notification { // To know if it has been created or re-used response_sender: ResponseSenderWithCreated, heartbeat_enabled: bool, + // Useful for the metric we create + operation_name: Option, }, Subscribe { topic: K, @@ -153,7 +156,9 @@ where ) -> Notify { let (sender, receiver) = mpsc::channel(NOTIFY_CHANNEL_SIZE); let receiver_stream: ReceiverStream> = ReceiverStream::new(receiver); - tokio::task::spawn(task(receiver_stream, ttl, heartbeat_error_message)); + tokio::task::spawn( + task(receiver_stream, ttl, heartbeat_error_message).with_current_meter_provider(), + ); Notify { sender, queue_size, @@ -179,7 +184,9 @@ impl Notify { self.router_broadcasts.configuration.0.send(configuration).expect("cannot send the configuration update to the static channel. Should not happen because the receiver will always live in this struct; qed"); } /// Receive the new configuration everytime we have a new router configuration - pub(crate) fn subscribe_configuration(&self) -> impl Stream> { + pub(crate) fn subscribe_configuration( + &self, + ) -> impl Stream> + use { self.router_broadcasts.subscribe_configuration() } /// Receive the new schema everytime we have a new schema @@ -187,7 +194,7 @@ impl Notify { self.router_broadcasts.schema.0.send(schema).expect("cannot send the schema update to the static channel. Should not happen because the receiver will always live in this struct; qed"); } /// Receive the new schema everytime we have a new schema - pub(crate) fn subscribe_schema(&self) -> impl Stream> { + pub(crate) fn subscribe_schema(&self) -> impl Stream> + use { self.router_broadcasts.subscribe_schema() } } @@ -210,6 +217,7 @@ where &mut self, topic: K, heartbeat_enabled: bool, + operation_name: Option, ) -> Result<(Handle, bool), NotifyError> { let (sender, _receiver) = broadcast::channel(self.queue_size.unwrap_or(DEFAULT_MSG_CHANNEL_SIZE)); @@ -221,6 +229,7 @@ where msg_sender: sender, response_sender: tx, heartbeat_enabled, + operation_name, }) .await?; @@ -510,7 +519,11 @@ where match Pin::new(&mut this.msg_receiver).poll_next(cx) { Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(_)))) => { - tracing::info!(monotonic_counter.apollo_router_skipped_event_count = 1u64,); + u64_counter!( + "apollo.router.skipped.event.count", + "Amount of events dropped from the internal message queue", + 1u64 + ); self.poll_next(cx) } Poll::Ready(None) => Poll::Ready(None), @@ -611,8 +624,8 @@ async fn task( match message { Notification::Unsubscribe { topic } => pubsub.unsubscribe(topic), Notification::ForceDelete { topic } => pubsub.force_delete(topic), - Notification::CreateOrSubscribe { topic, msg_sender, response_sender, heartbeat_enabled } => { - pubsub.subscribe_or_create(topic, msg_sender, response_sender, heartbeat_enabled); + Notification::CreateOrSubscribe { topic, msg_sender, response_sender, heartbeat_enabled, operation_name } => { + pubsub.subscribe_or_create(topic, msg_sender, response_sender, heartbeat_enabled, operation_name); } Notification::Subscribe { topic, @@ -691,14 +704,20 @@ struct Subscription { msg_sender: broadcast::Sender>, heartbeat_enabled: bool, updated_at: Instant, + operation_name: Option, } impl Subscription { - fn new(msg_sender: broadcast::Sender>, heartbeat_enabled: bool) -> Self { + fn new( + msg_sender: broadcast::Sender>, + heartbeat_enabled: bool, + operation_name: Option, + ) -> Self { Self { msg_sender, heartbeat_enabled, updated_at: Instant::now(), + operation_name, } } // Update the updated_at value @@ -745,17 +764,21 @@ where topic: K, sender: broadcast::Sender>, heartbeat_enabled: bool, + operation_name: Option, ) { let existed = self .subscriptions - .insert(topic, Subscription::new(sender, heartbeat_enabled)) + .insert( + topic, + Subscription::new(sender, heartbeat_enabled, operation_name.clone()), + ) .is_some(); if !existed { - // TODO: deprecated name, should use our new convention apollo.router. for router next i64_up_down_counter!( - "apollo_router_opened_subscriptions", + "apollo.router.opened.subscriptions", "Number of opened subscriptions", - 1 + 1, + graphql.operation.name = operation_name.unwrap_or_default() ); } } @@ -780,6 +803,7 @@ where msg_sender: broadcast::Sender>, sender: ResponseSenderWithCreated, heartbeat_enabled: bool, + operation_name: Option, ) { match self.subscriptions.get(&topic) { Some(subscription) => { @@ -790,7 +814,7 @@ where )); } None => { - self.create_topic(topic, msg_sender.clone(), heartbeat_enabled); + self.create_topic(topic, msg_sender.clone(), heartbeat_enabled, operation_name); let _ = sender.send((msg_sender.clone(), msg_sender.subscribe(), true)); } @@ -808,11 +832,12 @@ where #[allow(clippy::collapsible_if)] if topic_to_delete { tracing::trace!("deleting subscription from unsubscribe"); - if self.subscriptions.remove(&topic).is_some() { + if let Some(sub) = self.subscriptions.remove(&topic) { i64_up_down_counter!( - "apollo_router_opened_subscriptions", + "apollo.router.opened.subscriptions", "Number of opened subscriptions", - -1 + -1, + graphql.operation.name = sub.operation_name.unwrap_or_default() ); } }; @@ -883,9 +908,10 @@ where for (_subscriber_id, subscription) in closed_subs { tracing::trace!("deleting subscription from kill_dead_topics"); i64_up_down_counter!( - "apollo_router_opened_subscriptions", + "apollo.router.opened.subscriptions", "Number of opened subscriptions", - -1 + -1, + graphql.operation.name = subscription.operation_name.unwrap_or_default() ); if let Some(heartbeat_error_message) = &heartbeat_error_message { let _ = subscription @@ -899,10 +925,10 @@ where #[cfg(test)] fn try_delete(&mut self, topic: K) { - if let Some(sub) = self.subscriptions.get(&topic) { - if sub.msg_sender.receiver_count() > 1 { - return; - } + if let Some(sub) = self.subscriptions.get(&topic) + && sub.msg_sender.receiver_count() > 1 + { + return; } self.force_delete(topic); @@ -913,9 +939,10 @@ where let sub = self.subscriptions.remove(&topic); if let Some(sub) = sub { i64_up_down_counter!( - "apollo_router_opened_subscriptions", + "apollo.router.opened.subscriptions", "Number of opened subscriptions", - -1 + -1, + graphql.operation.name = sub.operation_name.unwrap_or_default() ); let _ = sub.msg_sender.send(None); } @@ -960,17 +987,20 @@ pub(crate) struct RouterBroadcasts { impl RouterBroadcasts { pub(crate) fn new() -> Self { Self { - configuration: broadcast::channel(1), - schema: broadcast::channel(1), + // Set to 2 to avoid potential deadlock when triggering a config/schema change mutiple times in a row + configuration: broadcast::channel(2), + schema: broadcast::channel(2), } } - pub(crate) fn subscribe_configuration(&self) -> impl Stream> { + pub(crate) fn subscribe_configuration( + &self, + ) -> impl Stream> + use<> { BroadcastStream::new(self.configuration.0.subscribe()) .filter_map(|cfg| futures::future::ready(cfg.ok())) } - pub(crate) fn subscribe_schema(&self) -> impl Stream> { + pub(crate) fn subscribe_schema(&self) -> impl Stream> + use<> { BroadcastStream::new(self.schema.0.subscribe()) .filter_map(|schema| futures::future::ready(schema.ok())) } @@ -984,6 +1014,7 @@ mod tests { use uuid::Uuid; use super::*; + use crate::metrics::FutureMetricsExt; #[tokio::test] async fn subscribe() { @@ -991,9 +1022,15 @@ mod tests { let topic_1 = Uuid::new_v4(); let topic_2 = Uuid::new_v4(); - let (handle1, created) = notify.create_or_subscribe(topic_1, false).await.unwrap(); + let (handle1, created) = notify + .create_or_subscribe(topic_1, false, None) + .await + .unwrap(); assert!(created); - let (_handle2, created) = notify.create_or_subscribe(topic_2, false).await.unwrap(); + let (_handle2, created) = notify + .create_or_subscribe(topic_2, false, None) + .await + .unwrap(); assert!(created); let handle_1_bis = notify.subscribe(topic_1).await.unwrap(); @@ -1030,9 +1067,15 @@ mod tests { let topic_1 = Uuid::new_v4(); let topic_2 = Uuid::new_v4(); - let (handle1, created) = notify.create_or_subscribe(topic_1, true).await.unwrap(); + let (handle1, created) = notify + .create_or_subscribe(topic_1, true, None) + .await + .unwrap(); assert!(created); - let (_handle2, created) = notify.create_or_subscribe(topic_2, true).await.unwrap(); + let (_handle2, created) = notify + .create_or_subscribe(topic_2, true, None) + .await + .unwrap(); assert!(created); let mut _handle_1_bis = notify.subscribe(topic_1).await.unwrap(); @@ -1067,6 +1110,99 @@ mod tests { assert_eq!(subscriptions_nb, 0); } + #[tokio::test] + async fn it_subscribe_and_delete_metrics() { + async { + let mut notify = Notify::builder().build(); + let topic_1 = Uuid::new_v4(); + let topic_2 = Uuid::new_v4(); + + let (handle1, created) = notify + .create_or_subscribe(topic_1, true, Some("TestSubscription".to_string())) + .await + .unwrap(); + assert!(created); + let (_handle2, created) = notify + .create_or_subscribe(topic_2, true, Some("TestSubscriptionBis".to_string())) + .await + .unwrap(); + assert!(created); + assert_up_down_counter!( + "apollo.router.opened.subscriptions", + 1i64, + "graphql.operation.name" = "TestSubscription" + ); + assert_up_down_counter!( + "apollo.router.opened.subscriptions", + 1i64, + "graphql.operation.name" = "TestSubscriptionBis" + ); + + let mut _handle_1_bis = notify.subscribe(topic_1).await.unwrap(); + let mut _handle_1_other = notify.subscribe(topic_1).await.unwrap(); + let mut cloned_notify = notify.clone(); + let mut handle = cloned_notify.subscribe(topic_1).await.unwrap().into_sink(); + handle + .send_sync(serde_json_bytes::json!({"test": "ok"})) + .unwrap(); + drop(handle); + assert!(notify.exist(topic_1).await.unwrap()); + drop(_handle_1_bis); + drop(_handle_1_other); + + notify.try_delete(topic_1).unwrap(); + assert_up_down_counter!( + "apollo.router.opened.subscriptions", + 1i64, + "graphql.operation.name" = "TestSubscription" + ); + assert_up_down_counter!( + "apollo.router.opened.subscriptions", + 1i64, + "graphql.operation.name" = "TestSubscriptionBis" + ); + + let subscriptions_nb = notify.debug().await.unwrap(); + assert_eq!(subscriptions_nb, 1); + + assert!(!notify.exist(topic_1).await.unwrap()); + + notify.force_delete(topic_1).await.unwrap(); + assert_up_down_counter!( + "apollo.router.opened.subscriptions", + 0i64, + "graphql.operation.name" = "TestSubscription" + ); + assert_up_down_counter!( + "apollo.router.opened.subscriptions", + 1i64, + "graphql.operation.name" = "TestSubscriptionBis" + ); + + let mut handle1 = handle1.into_stream(); + let new_msg = handle1.next().await.unwrap(); + assert_eq!(new_msg, serde_json_bytes::json!({"test": "ok"})); + assert!(handle1.next().await.is_none()); + assert!(notify.exist(topic_2).await.unwrap()); + notify.try_delete(topic_2).unwrap(); + + let subscriptions_nb = notify.debug().await.unwrap(); + assert_eq!(subscriptions_nb, 0); + assert_up_down_counter!( + "apollo.router.opened.subscriptions", + 0i64, + "graphql.operation.name" = "TestSubscription" + ); + assert_up_down_counter!( + "apollo.router.opened.subscriptions", + 0i64, + "graphql.operation.name" = "TestSubscriptionBis" + ); + } + .with_metrics() + .await; + } + #[tokio::test] async fn it_test_ttl() { let mut notify = Notify::builder() @@ -1076,9 +1212,15 @@ mod tests { let topic_1 = Uuid::new_v4(); let topic_2 = Uuid::new_v4(); - let (handle1, created) = notify.create_or_subscribe(topic_1, true).await.unwrap(); + let (handle1, created) = notify + .create_or_subscribe(topic_1, true, None) + .await + .unwrap(); assert!(created); - let (_handle2, created) = notify.create_or_subscribe(topic_2, true).await.unwrap(); + let (_handle2, created) = notify + .create_or_subscribe(topic_2, true, None) + .await + .unwrap(); assert!(created); let handle_1_bis = notify.subscribe(topic_1).await.unwrap(); diff --git a/apollo-router/src/orbiter/mod.rs b/apollo-router/src/orbiter/mod.rs index a326e15d48..d631ff91cd 100644 --- a/apollo-router/src/orbiter/mod.rs +++ b/apollo-router/src/orbiter/mod.rs @@ -17,15 +17,16 @@ use serde_json::Value; use tower::BoxError; use uuid::Uuid; +use crate::Configuration; use crate::configuration::generate_config_schema; use crate::executable::Opt; use crate::plugin::DynPlugin; use crate::router_factory::RouterSuperServiceFactory; use crate::router_factory::YamlRouterFactory; -use crate::services::router::service::RouterCreator; use crate::services::HasSchema; +use crate::services::router::service::RouterCreator; use crate::spec::Schema; -use crate::Configuration; +use crate::uplink::license_enforcement::LicenseState; /// This session id is created once when the router starts. It persists between config reloads and supergraph schema changes. static SESSION_ID: OnceCell = OnceCell::new(); @@ -100,6 +101,7 @@ impl RouterSuperServiceFactory for OrbiterRouterSuperServiceFactory { schema: Arc, previous_router: Option<&'a Self::RouterFactory>, extra_plugins: Option)>>, + license: Arc, ) -> Result { self.delegate .create( @@ -108,6 +110,7 @@ impl RouterSuperServiceFactory for OrbiterRouterSuperServiceFactory { schema.clone(), previous_router, extra_plugins, + license, ) .await .inspect(|factory| { @@ -310,13 +313,14 @@ mod test { use std::sync::Arc; use insta::assert_yaml_snapshot; - use serde_json::json; use serde_json::Value; + use serde_json::json; + use crate::Configuration; + use crate::configuration::ConfigurationError; use crate::orbiter::create_report; use crate::orbiter::visit_args; use crate::orbiter::visit_config; - use crate::Configuration; #[test] fn test_visit_args() { @@ -359,20 +363,13 @@ mod test { #[test] fn test_visit_config_that_needed_upgrade() { - let config: Configuration = + let result: ConfigurationError = Configuration::from_str("supergraph:\n preview_defer_support: true") - .expect("config must be valid"); - let mut usage = HashMap::new(); - visit_config( - &mut usage, - config - .validated_yaml - .as_ref() - .expect("config should have had validated_yaml"), - ); - insta::with_settings!({sort_maps => true}, { - assert_yaml_snapshot!(usage); - }); + .expect_err("expected an error"); + // Note: Can't implement PartialEq on ConfigurationError, so... + let err_message = "configuration had errors"; + let err_error = "\n1. at line 2\n\n supergraph:\n┌ preview_defer_support: true\n└-----> Additional properties are not allowed ('preview_defer_support' was unexpected)\n\n".to_string(); + matches!(result, ConfigurationError::InvalidConfiguration {message, error} if err_message == message && err_error == error); } #[test] diff --git a/apollo-router/src/otel_compat.rs b/apollo-router/src/otel_compat.rs new file mode 100644 index 0000000000..4394a392c7 --- /dev/null +++ b/apollo-router/src/otel_compat.rs @@ -0,0 +1,38 @@ +//! Facilities for using our old version of opentelemetry with our new version of http/hyper. + +/// A header extractor that works on http 1.x types. +/// +/// The implementation is a straight copy from [opentelemetry_http::HeaderExtractor]. +/// This can be removed after we update otel. +pub struct HeaderExtractor<'a>(pub &'a http::HeaderMap); +impl opentelemetry::propagation::Extractor for HeaderExtractor<'_> { + /// Get a value for a key from the HeaderMap. If the value is not valid ASCII, returns None. + fn get(&self, key: &str) -> Option<&str> { + self.0.get(key).and_then(|value| value.to_str().ok()) + } + + /// Collect all the keys from the HeaderMap. + fn keys(&self) -> Vec<&str> { + self.0 + .keys() + .map(|value| value.as_str()) + .collect::>() + } +} + +/// A header injector that works on http 1.x types. +/// +/// The implementation is a straight copy from [opentelemetry_http::HeaderInjector]. +/// This can be removed after we update otel. +pub struct HeaderInjector<'a>(pub &'a mut http::HeaderMap); + +impl opentelemetry::propagation::Injector for HeaderInjector<'_> { + /// Set a key and value in the HeaderMap. Does nothing if the key or value are not valid inputs. + fn set(&mut self, key: &str, value: String) { + if let Ok(name) = http::header::HeaderName::from_bytes(key.as_bytes()) + && let Ok(val) = http::header::HeaderValue::from_str(&value) + { + self.0.insert(name, val); + } + } +} diff --git a/apollo-router/src/plugin/mod.rs b/apollo-router/src/plugin/mod.rs index 3354868491..6314f9f17a 100644 --- a/apollo-router/src/plugin/mod.rs +++ b/apollo-router/src/plugin/mod.rs @@ -21,37 +21,39 @@ pub mod test; use std::any::TypeId; use std::collections::HashMap; use std::fmt; +#[cfg(test)] use std::path::PathBuf; use std::sync::Arc; use std::task::Context; use std::task::Poll; -use ::serde::de::DeserializeOwned; use ::serde::Deserialize; -use apollo_compiler::validation::Valid; +use ::serde::de::DeserializeOwned; use apollo_compiler::Schema; +use apollo_compiler::validation::Valid; use async_trait::async_trait; use futures::future::BoxFuture; use multimap::MultiMap; use once_cell::sync::Lazy; -use schemars::gen::SchemaGenerator; use schemars::JsonSchema; -use tower::buffer::future::ResponseFuture; -use tower::buffer::Buffer; +use schemars::r#gen::SchemaGenerator; +use serde_json::Value; use tower::BoxError; use tower::Service; use tower::ServiceBuilder; +use tower::buffer::Buffer; +use tower::buffer::future::ResponseFuture; +use crate::ListenAddr; use crate::graphql; use crate::layers::ServiceBuilderExt; use crate::notification::Notify; -use crate::query_planner::fetch::SubgraphSchemas; use crate::router_factory::Endpoint; use crate::services::execution; use crate::services::router; use crate::services::subgraph; use crate::services::supergraph; -use crate::ListenAddr; +use crate::uplink::license_enforcement::LicenseState; type InstanceFactory = fn(PluginInit) -> BoxFuture<'static, Result, BoxError>>; @@ -75,54 +77,26 @@ pub struct PluginInit { pub(crate) supergraph_schema: Arc>, /// The parsed subgraph schemas from the query planner, keyed by subgraph name - pub(crate) subgraph_schemas: Arc, + pub(crate) subgraph_schemas: Arc>>>, + + /// Launch ID + pub(crate) launch_id: Option>, pub(crate) notify: Notify, + + /// User's license's state, including any limits of use + pub(crate) license: Arc, + + /// The full router configuration json for use by the telemetry plugin ONLY. + /// NEVER use this in any other plugin. Plugins should only ever access their pre-defined + /// configuration subset. + pub(crate) full_config: Option, } impl PluginInit where T: for<'de> Deserialize<'de>, { - #[deprecated = "use PluginInit::builder() instead"] - /// Create a new PluginInit for the supplied config and SDL. - pub fn new(config: T, supergraph_sdl: Arc) -> Self { - Self::builder() - .config(config) - .supergraph_schema(Arc::new( - Schema::parse_and_validate(supergraph_sdl.to_string(), PathBuf::from("synthetic")) - .expect("failed to parse supergraph schema"), - )) - .supergraph_schema_id(crate::spec::Schema::schema_id(&supergraph_sdl).into()) - .supergraph_sdl(supergraph_sdl) - .notify(Notify::builder().build()) - .build() - } - - /// Try to create a new PluginInit for the supplied JSON and SDL. - /// - /// This will fail if the supplied JSON cannot be deserialized into the configuration - /// struct. - #[deprecated = "use PluginInit::try_builder() instead"] - pub fn try_new( - config: serde_json::Value, - supergraph_sdl: Arc, - ) -> Result { - Self::try_builder() - .config(config) - .supergraph_schema(Arc::new( - Schema::parse_and_validate(supergraph_sdl.to_string(), PathBuf::from("synthetic")) - .map_err(|e| { - // This method is deprecated so we're not going to do anything fancy with the error - BoxError::from(e.errors.to_string()) - })?, - )) - .supergraph_schema_id(crate::spec::Schema::schema_id(&supergraph_sdl).into()) - .supergraph_sdl(supergraph_sdl) - .notify(Notify::builder().build()) - .build() - } - #[cfg(test)] pub(crate) fn fake_new(config: T, supergraph_sdl: Arc) -> Self { let supergraph_schema = Arc::new(if !supergraph_sdl.is_empty() { @@ -134,27 +108,14 @@ where PluginInit::fake_builder() .config(config) - .supergraph_schema_id(crate::spec::Schema::schema_id(&supergraph_sdl).into()) + .supergraph_schema_id(crate::spec::Schema::schema_id(&supergraph_sdl).into_inner()) .supergraph_sdl(supergraph_sdl) .supergraph_schema(supergraph_schema) + .launch_id(Arc::new("launch_id".to_string())) .notify(Notify::for_tests()) + .license(Arc::new(LicenseState::default())) .build() } - - /// Returns the parsed Schema. This is unstable and may be changed or removed in future router releases. - /// In addition, Schema is not stable, and may be changed or removed in future apollo-rs releases. - #[doc(hidden)] - pub fn unsupported_supergraph_schema(&self) -> Arc> { - self.supergraph_schema.clone() - } - - /// Returns a mapping of subgraph to parsed schema. This is unstable and may be changed or removed in - /// future router releases. In addition, Schema is not stable, and may be changed or removed in future - /// apollo-rs releases. - #[doc(hidden)] - pub fn unsupported_subgraph_schemas(&self) -> Arc>>> { - self.subgraph_schemas.clone() - } } #[buildstructor::buildstructor] @@ -172,8 +133,11 @@ where supergraph_sdl: Arc, supergraph_schema_id: Arc, supergraph_schema: Arc>, - subgraph_schemas: Option>, + subgraph_schemas: Option>>>>, + launch_id: Option>>, notify: Notify, + license: Arc, + full_config: Option, ) -> Self { PluginInit { config, @@ -181,7 +145,10 @@ where supergraph_schema_id, supergraph_schema, subgraph_schemas: subgraph_schemas.unwrap_or_default(), + launch_id: launch_id.flatten(), notify, + license, + full_config, } } @@ -195,8 +162,11 @@ where supergraph_sdl: Arc, supergraph_schema_id: Arc, supergraph_schema: Arc>, - subgraph_schemas: Option>, + subgraph_schemas: Option>>>>, + launch_id: Option>, notify: Notify, + license: Arc, + full_config: Option, ) -> Result { let config: T = serde_json::from_value(config)?; Ok(PluginInit { @@ -205,7 +175,10 @@ where supergraph_schema, supergraph_schema_id, subgraph_schemas: subgraph_schemas.unwrap_or_default(), + launch_id, notify, + license, + full_config, }) } @@ -216,8 +189,11 @@ where supergraph_sdl: Option>, supergraph_schema_id: Option>, supergraph_schema: Option>>, - subgraph_schemas: Option>, + subgraph_schemas: Option>>>>, + launch_id: Option>, notify: Option>, + license: Option>, + full_config: Option, ) -> Self { PluginInit { config, @@ -226,7 +202,10 @@ where supergraph_schema: supergraph_schema .unwrap_or_else(|| Arc::new(Valid::assume_valid(Schema::new()))), subgraph_schemas: subgraph_schemas.unwrap_or_default(), + launch_id, notify: notify.unwrap_or_else(Notify::for_tests), + license: license.unwrap_or_default(), + full_config, } } } @@ -244,6 +223,8 @@ impl PluginInit { .supergraph_sdl(self.supergraph_sdl) .subgraph_schemas(self.subgraph_schemas) .notify(self.notify.clone()) + .license(self.license) + .and_full_config(self.full_config) .build() } } @@ -252,6 +233,7 @@ impl PluginInit { #[derive(Clone)] pub struct PluginFactory { pub(crate) name: String, + pub(crate) hidden_from_config_json_schema: bool, instance_factory: InstanceFactory, schema_factory: SchemaFactory, pub(crate) type_id: TypeId, @@ -281,6 +263,7 @@ impl PluginFactory { tracing::debug!(%plugin_factory_name, "creating plugin factory"); PluginFactory { name: plugin_factory_name, + hidden_from_config_json_schema: false, instance_factory: |init| { Box::pin(async move { let init = init.with_deserialized_config()?; @@ -288,13 +271,12 @@ impl PluginFactory { Ok(Box::new(plugin) as Box) }) }, - schema_factory: |gen| gen.subschema_for::<

::Config>(), + schema_factory: |generator| generator.subschema_for::<

::Config>(), type_id: TypeId::of::

(), } } /// Create a plugin factory. - #[allow(dead_code)] pub(crate) fn new_private(group: &str, name: &str) -> PluginFactory { let plugin_factory_name = if group.is_empty() { name.to_string() @@ -304,6 +286,7 @@ impl PluginFactory { tracing::debug!(%plugin_factory_name, "creating plugin factory"); PluginFactory { name: plugin_factory_name, + hidden_from_config_json_schema: P::HIDDEN_FROM_CONFIG_JSON_SCHEMA, instance_factory: |init| { Box::pin(async move { let init = init.with_deserialized_config()?; @@ -311,7 +294,7 @@ impl PluginFactory { Ok(Box::new(plugin) as Box) }) }, - schema_factory: |gen| gen.subschema_for::<

::Config>(), + schema_factory: |generator| generator.subschema_for::<

::Config>(), type_id: TypeId::of::

(), } } @@ -336,8 +319,11 @@ impl PluginFactory { .await } - pub(crate) fn create_schema(&self, gen: &mut SchemaGenerator) -> schemars::schema::Schema { - (self.schema_factory)(gen) + pub(crate) fn create_schema( + &self, + generator: &mut SchemaGenerator, + ) -> schemars::schema::Schema { + (self.schema_factory)(generator) } } @@ -374,7 +360,7 @@ pub trait Plugin: Send + Sync + 'static { /// /// This service runs at the very beginning and very end of the request lifecycle. /// It's the entrypoint of every requests and also the last hook before sending the response. - /// Define supergraph_service if your customization needs to interact at the earliest or latest point possible. + /// Define `router_service` if your customization needs to interact at the earliest or latest point possible. /// For example, this is a good opportunity to perform JWT verification before allowing a request to proceed further. fn router_service(&self, service: router::BoxService) -> router::BoxService { service @@ -382,7 +368,7 @@ pub trait Plugin: Send + Sync + 'static { /// This service runs after the HTTP request payload has been deserialized into a GraphQL request, /// and before the GraphQL response payload is serialized into a raw HTTP response. - /// Define supergraph_service if your customization needs to interact at the earliest or latest point possible, yet operates on GraphQL payloads. + /// Define `supergraph_service` if your customization needs to interact at the earliest or latest point possible, yet operates on GraphQL payloads. fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { service } @@ -567,6 +553,8 @@ pub(crate) trait PluginPrivate: Send + Sync + 'static { /// and passed to [`Plugin::new`] as part of [`PluginInit`]. type Config: JsonSchema + DeserializeOwned + Send; + const HIDDEN_FROM_CONFIG_JSON_SCHEMA: bool = false; + /// This is invoked once after the router starts and compiled-in /// plugins are registered. async fn new(init: PluginInit) -> Result @@ -616,6 +604,15 @@ pub(crate) trait PluginPrivate: Send + Sync + 'static { service } + /// This service handles individual requests to Apollo Connectors + fn connector_request_service( + &self, + service: crate::services::connector::request_service::BoxService, + _source_name: String, + ) -> crate::services::connector::request_service::BoxService { + service + } + /// Return the name of the plugin. fn name(&self) -> &'static str where @@ -630,6 +627,9 @@ pub(crate) trait PluginPrivate: Send + Sync + 'static { fn web_endpoints(&self) -> MultiMap { MultiMap::new() } + + /// The point of no return this plugin is about to go live + fn activate(&self) {} } #[async_trait] @@ -677,6 +677,8 @@ where fn web_endpoints(&self) -> MultiMap { PluginUnstable::web_endpoints(self) } + + fn activate(&self) {} } fn get_type_of(_: &T) -> &'static str { @@ -721,6 +723,13 @@ pub(crate) trait DynPlugin: Send + Sync + 'static { service: crate::services::http::BoxService, ) -> crate::services::http::BoxService; + /// This service handles individual requests to Apollo Connectors + fn connector_request_service( + &self, + service: crate::services::connector::request_service::BoxService, + source_name: String, + ) -> crate::services::connector::request_service::BoxService; + /// Return the name of the plugin. fn name(&self) -> &'static str; @@ -732,7 +741,11 @@ pub(crate) trait DynPlugin: Send + Sync + 'static { /// Support downcasting #[cfg(test)] + #[allow(dead_code)] fn as_any_mut(&mut self) -> &mut dyn std::any::Any; + + /// The point of no return, this plugin is about to go live + fn activate(&self) {} } #[async_trait] @@ -766,6 +779,14 @@ where self.http_client_service(name, service) } + fn connector_request_service( + &self, + service: crate::services::connector::request_service::BoxService, + source_name: String, + ) -> crate::services::connector::request_service::BoxService { + self.connector_request_service(service, source_name) + } + fn name(&self) -> &'static str { self.name() } @@ -783,6 +804,19 @@ where fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self } + + fn activate(&self) { + self.activate() + } +} + +impl From for Box +where + T: PluginPrivate, +{ + fn from(value: T) -> Self { + Box::new(value) + } } /// Register a plugin with a group and a name @@ -793,9 +827,9 @@ macro_rules! register_plugin { ($group: literal, $name: literal, $plugin_type: ident < $generic: ident >) => { // Artificial scope to avoid naming collisions const _: () = { - use $crate::_private::once_cell::sync::Lazy; - use $crate::_private::PluginFactory; use $crate::_private::PLUGINS; + use $crate::_private::PluginFactory; + use $crate::_private::once_cell::sync::Lazy; #[$crate::_private::linkme::distributed_slice(PLUGINS)] #[linkme(crate = $crate::_private::linkme)] @@ -805,12 +839,12 @@ macro_rules! register_plugin { }; }; - ($group: literal, $name: literal, $plugin_type: ident) => { + ($group: literal, $name: expr, $plugin_type: ident) => { // Artificial scope to avoid naming collisions const _: () = { - use $crate::_private::once_cell::sync::Lazy; - use $crate::_private::PluginFactory; use $crate::_private::PLUGINS; + use $crate::_private::PluginFactory; + use $crate::_private::once_cell::sync::Lazy; #[$crate::_private::linkme::distributed_slice(PLUGINS)] #[linkme(crate = $crate::_private::linkme)] @@ -828,9 +862,9 @@ macro_rules! register_private_plugin { ($group: literal, $name: literal, $plugin_type: ident < $generic: ident >) => { // Artificial scope to avoid naming collisions const _: () = { - use $crate::_private::once_cell::sync::Lazy; - use $crate::_private::PluginFactory; use $crate::_private::PLUGINS; + use $crate::_private::PluginFactory; + use $crate::_private::once_cell::sync::Lazy; #[$crate::_private::linkme::distributed_slice(PLUGINS)] #[linkme(crate = $crate::_private::linkme)] @@ -843,9 +877,9 @@ macro_rules! register_private_plugin { ($group: literal, $name: literal, $plugin_type: ident) => { // Artificial scope to avoid naming collisions const _: () = { - use $crate::_private::once_cell::sync::Lazy; - use $crate::_private::PluginFactory; use $crate::_private::PLUGINS; + use $crate::_private::PluginFactory; + use $crate::_private::once_cell::sync::Lazy; #[$crate::_private::linkme::distributed_slice(PLUGINS)] #[linkme(crate = $crate::_private::linkme)] @@ -859,7 +893,7 @@ macro_rules! register_private_plugin { /// Handler represents a [`Plugin`] endpoint. #[derive(Clone)] pub(crate) struct Handler { - service: Buffer, + service: Buffer>::Future>, } impl Handler { diff --git a/apollo-router/src/plugin/serde.rs b/apollo-router/src/plugin/serde.rs index 390b871e54..dddf456db4 100644 --- a/apollo-router/src/plugin/serde.rs +++ b/apollo-router/src/plugin/serde.rs @@ -3,15 +3,14 @@ use std::fmt::Formatter; use std::str::FromStr; -use access_json::JSONQuery; -use http::header::HeaderName; use http::HeaderValue; +use http::header::HeaderName; use regex::Regex; +use serde::Deserializer; use serde::de; use serde::de::Error; use serde::de::SeqAccess; use serde::de::Visitor; -use serde::Deserializer; /// De-serialize an optional [`HeaderName`]. pub fn deserialize_option_header_name<'de, D>( @@ -112,7 +111,7 @@ where #[derive(Default)] struct HeaderNameVisitor; -impl<'de> Visitor<'de> for HeaderNameVisitor { +impl Visitor<'_> for HeaderNameVisitor { type Value = HeaderName; fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { @@ -135,35 +134,9 @@ where deserializer.deserialize_str(HeaderNameVisitor) } -struct JSONQueryVisitor; - -impl<'de> Visitor<'de> for JSONQueryVisitor { - type Value = JSONQuery; - - fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { - formatter.write_str("struct JSONQuery") - } - - fn visit_str(self, v: &str) -> Result - where - E: Error, - { - JSONQuery::parse(v) - .map_err(|e| de::Error::custom(format!("Invalid JSON query path for '{v}' {e}"))) - } -} - -/// De-serialize a [`JSONQuery`]. -pub fn deserialize_json_query<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - deserializer.deserialize_str(JSONQueryVisitor) -} - struct HeaderValueVisitor; -impl<'de> Visitor<'de> for HeaderValueVisitor { +impl Visitor<'_> for HeaderValueVisitor { type Value = HeaderValue; fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { @@ -193,7 +166,7 @@ where { struct RegexVisitor; - impl<'de> Visitor<'de> for RegexVisitor { + impl Visitor<'_> for RegexVisitor { type Value = Regex; fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { @@ -221,7 +194,7 @@ where struct JSONPathVisitor; -impl<'de> serde::de::Visitor<'de> for JSONPathVisitor { +impl serde::de::Visitor<'_> for JSONPathVisitor { type Value = serde_json_bytes::path::JsonPathInst; fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { diff --git a/apollo-router/src/plugin/test/mock/canned.rs b/apollo-router/src/plugin/test/mock/canned.rs index 369715444e..86a31f66cd 100644 --- a/apollo-router/src/plugin/test/mock/canned.rs +++ b/apollo-router/src/plugin/test/mock/canned.rs @@ -1,192 +1,77 @@ -//! Canned data for use with MockSugbraph. -//! Eventually we may replace this with a real subgraph. +//! Canned data for use with the canned schema (`/testing_schema.graphl`). use serde_json::json; -use crate::plugin::test::MockSubgraph; - -/// Canned responses for accounts_subgraphs. -pub(crate) fn accounts_subgraph() -> MockSubgraph { - let account_mocks = vec![ - ( - json! {{ - "query": "query TopProducts__accounts__3($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}", - "operationName": "TopProducts__accounts__3", - "variables": { - "representations": [ - { - "__typename": "User", - "id": "1" - }, - { - "__typename": "User", - "id": "2" - }, - ] - } - }}, - json! {{ - "data": { - "_entities": [ - { - "name": "Ada Lovelace" - }, - { - "name": "Alan Turing" - }, - ] - } - }} - ), - ( - json! {{ - "query": "subscription{userWasCreated{name}}", - }}, - json! {{}} - ) - ].into_iter().map(|(query, response)| (serde_json::from_value(query).unwrap(), serde_json::from_value(response).unwrap())).collect(); - MockSubgraph::new(account_mocks) -} - -/// Canned responses for reviews_subgraphs. -pub(crate) fn reviews_subgraph() -> MockSubgraph { - let review_mocks = vec![ - ( - json! {{ - "query": "query TopProducts__reviews__1($representations:[_Any!]!){_entities(representations:$representations){..._generated_onProduct1_0}}fragment _generated_onProduct1_0 on Product{reviews{id product{__typename upc}author{__typename id}}}", - "operationName": "TopProducts__reviews__1", - "variables": { - "representations":[ - { - "__typename": "Product", - "upc":"1" - }, - { - "__typename": "Product", - "upc": "2" - } - ] - } - }}, - json! {{ - "data": { - "_entities": [ - { - "reviews": [ - { - "id": "1", - "product": { - "__typename": "Product", - "upc": "1" - }, - "author": { - "__typename": "User", - "id": "1" - } - }, - { - "id": "4", - "product": { - "__typename": "Product", - "upc": "1" - }, - "author": { - "__typename": "User", - "id": "2" - } - } - ] - }, - { - "reviews": [ - { - "id": "2", - "product": { - "__typename": "Product", - "upc": "2" - }, - "author": { - "__typename": "User", - "id": "1" - } - } - ] - } - ] - } - }} - ), - ( - json! {{ - "query": "subscription{reviewAdded{body}}", - }}, - json! {{ - "errors": [{ - "message": "subscription is not enabled" - }] - }} - ) - ].into_iter().map(|(query, response)| (serde_json::from_value(query).unwrap(), serde_json::from_value(response).unwrap())).collect(); - MockSubgraph::new(review_mocks) -} - -/// Canned responses for products_subgraphs. -pub(crate) fn products_subgraph() -> MockSubgraph { - let product_mocks = vec![ - ( - json!{{ - "query": "query TopProducts__products__0($first:Int){topProducts(first:$first){__typename upc name}}", - "operationName": "TopProducts__products__0", - "variables":{ - "first":2u8 - }, - }}, - json!{{ - "data": { - "topProducts": [ - { - "__typename": "Product", - "upc": "1", - "name":"Table" - }, - { - "__typename": "Product", - "upc": "2", - "name": "Couch" - } - ] - } - }} - ), - ( - json!{{ - "query": "query TopProducts__products__2($representations:[_Any!]!){_entities(representations:$representations){...on Product{name}}}", - "operationName": "TopProducts__products__2", - "variables": { - "representations": [ - { - "__typename": "Product", - "upc": "1" - }, - { - "__typename": "Product", - "upc": "2" - } - ] - } - }}, - json!{{ - "data": { - "_entities": [ - { - "name": "Table" - }, - { - "name": "Couch" - } - ] - } - }} - ) - ].into_iter().map(|(query, response)| (serde_json::from_value(query).unwrap(), serde_json::from_value(response).unwrap())).collect(); - MockSubgraph::new(product_mocks) +pub(crate) fn mock_subgraphs() -> serde_json::Value { + json!({ + "accounts": { + "entities": [ + { "__typename": "User", "id": "1", "name": "Ada Lovelace" }, + { "__typename": "User", "id": "2", "name": "Alan Turing" }, + ], + }, + "products": { + "query": { + "topProducts": [ + { "__typename": "Product", "upc": "1", "name": "Table" }, + { "__typename": "Product", "upc": "2", "name": "Couch" }, + ], + }, + "entities": [ + { "__typename": "Product", "upc": "1", "name": "Table" }, + { "__typename": "Product", "upc": "2", "name": "Couch" }, + ], + }, + "reviews": { + "entities": [ + { + "__typename": "Product", + "upc": "1", + "reviews": [ + { + "__typename": "Review", + "id": "1", + "product": { "__typename": "Product", "upc": "1" }, + "author": { "__typename": "User", "id": "1" }, + }, + { + "__typename": "Review", + "id": "4", + "product": { "__typename": "Product", "upc": "1" }, + "author": { "__typename": "User", "id": "2" }, + }, + ], + }, + { + "__typename": "Product", + "upc": "2", + "reviews": [ + { + "__typename": "Review", + "id": "2", + "product": { "__typename": "Product", "upc": "2" }, + "author": { "__typename": "User", "id": "1" }, + }, + ], + }, + { + "__typename": "Review", + "id": "1", + "product": { "__typename": "Product", "upc": "1" }, + "author": { "__typename": "User", "id": "1" }, + }, + { + "__typename": "Review", + "id": "2", + "product": { "__typename": "Product", "upc": "2" }, + "author": { "__typename": "User", "id": "1" }, + }, + { + "__typename": "Review", + "id": "4", + "product": { "__typename": "Product", "upc": "1" }, + "author": { "__typename": "User", "id": "2" }, + }, + ], + }, + }) } diff --git a/apollo-router/src/plugin/test/mock/connector.rs b/apollo-router/src/plugin/test/mock/connector.rs new file mode 100644 index 0000000000..e3f58de8e7 --- /dev/null +++ b/apollo-router/src/plugin/test/mock/connector.rs @@ -0,0 +1,129 @@ +//! Mock connector implementation + +#![allow(missing_docs)] // FIXME + +use std::collections::HashMap; +use std::sync::Arc; +use std::task::Poll; + +use apollo_federation::connectors::runtime::http_json_transport::TransportRequest; +use futures::future; +use http::HeaderMap; +use http::HeaderName; +use http::HeaderValue; +use serde_json_bytes::json; +use tower::BoxError; +use tower::Service; + +use crate::json_ext::Object; +use crate::services::connector::request_service::Request as ConnectorRequest; +use crate::services::connector::request_service::Response as ConnectorResponse; + +type MockResponses = HashMap; + +#[derive(Default, Clone)] +pub struct MockConnector { + // using an arc to improve efficiency when service is cloned + mocks: Arc, + extensions: Option, + map_request_fn: + Option ConnectorRequest) + Send + Sync + 'static>>, + headers: HeaderMap, +} + +impl MockConnector { + pub fn new(mocks: MockResponses) -> Self { + Self { + mocks: Arc::new(mocks.into_iter().collect()), + extensions: None, + map_request_fn: None, + headers: HeaderMap::new(), + } + } + + pub fn builder() -> MockConnectorBuilder { + MockConnectorBuilder::default() + } + + pub fn with_extensions(mut self, extensions: Object) -> Self { + self.extensions = Some(extensions); + self + } +} + +/// Builder for `MockConnector` +#[derive(Default, Clone)] +pub struct MockConnectorBuilder { + mocks: MockResponses, + extensions: Option, + headers: HeaderMap, +} +impl MockConnectorBuilder { + pub fn with_extensions(mut self, extensions: Object) -> Self { + self.extensions = Some(extensions); + self + } + + /// adds a mocked response for a request + /// + /// the arguments must deserialize to `crate::graphql::Request` and `crate::graphql::Response` + pub fn with_json(mut self, request: serde_json::Value, response: serde_json::Value) -> Self { + let request = serde_json::from_value(request).unwrap(); + self.mocks + .insert(request, serde_json::from_value(response).unwrap()); + self + } + + pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self { + self.headers.insert(name, value); + self + } + + pub fn build(self) -> MockConnector { + MockConnector { + mocks: Arc::new(self.mocks), + extensions: self.extensions, + map_request_fn: None, + headers: self.headers, + } + } +} + +impl Service for MockConnector { + type Response = ConnectorResponse; + + type Error = BoxError; + + type Future = future::Ready>; + + fn poll_ready(&mut self, _cx: &mut std::task::Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, mut req: ConnectorRequest) -> Self::Future { + if let Some(map_request_fn) = &self.map_request_fn { + req = map_request_fn.clone()(req); + } + let TransportRequest::Http(http) = req.transport_request; + let body = http.inner.body(); + + let response = if let Some(response) = self.mocks.get(body) { + let response_key = req.key; + let data = json!(response); + let headers = self.headers.clone(); + + ConnectorResponse::test_new(response_key, Default::default(), data, Some(headers)) + } else { + let error_message = format!( + "couldn't find mock for query {}", + serde_json::to_string(&body).unwrap() + ); + let response_key = req.key; + let data = json!(error_message); + let headers = self.headers.clone(); + + ConnectorResponse::test_new(response_key, Default::default(), data, Some(headers)) + }; + future::ok(response) + } +} diff --git a/apollo-router/src/plugin/test/mock/mod.rs b/apollo-router/src/plugin/test/mock/mod.rs index 963d0df1a7..67f58b5abd 100644 --- a/apollo-router/src/plugin/test/mock/mod.rs +++ b/apollo-router/src/plugin/test/mock/mod.rs @@ -1,2 +1,4 @@ pub(crate) mod canned; +#[cfg(test)] +pub(super) mod connector; pub(super) mod subgraph; diff --git a/apollo-router/src/plugin/test/mock/subgraph.rs b/apollo-router/src/plugin/test/mock/subgraph.rs index ee38234606..0a96c941c1 100644 --- a/apollo-router/src/plugin/test/mock/subgraph.rs +++ b/apollo-router/src/plugin/test/mock/subgraph.rs @@ -138,7 +138,7 @@ fn normalize(request: &mut Request) { if let Some(q) = &request.query { let mut doc = Document::parse(q.clone(), "request").unwrap(); - if let Some(Definition::OperationDefinition(ref mut op)) = doc.definitions.first_mut() { + if let Some(Definition::OperationDefinition(op)) = doc.definitions.first_mut() { let o = op.make_mut(); o.name.take(); }; @@ -165,11 +165,11 @@ impl Service for MockSubgraph { } let body = req.subgraph_request.body_mut(); + let subscription_stream = self.subscription_stream.clone(); if let Some(sub_stream) = &mut req.subscription_stream { sub_stream .try_send(Box::pin( - self.subscription_stream - .take() + subscription_stream .expect("must have a subscription stream set") .into_stream(), )) @@ -199,7 +199,6 @@ impl Service for MockSubgraph { } normalize(body); - let response = if let Some(response) = self.mocks.get(body) { // Build an http Response let mut http_response_builder = http::Response::builder().status(StatusCode::OK); @@ -227,6 +226,7 @@ impl Service for MockSubgraph { SubgraphResponse::fake_builder() .error(error) .context(req.context) + .subgraph_name(req.subgraph_name.clone()) .id(req.id) .build() }; diff --git a/apollo-router/src/plugin/test/mod.rs b/apollo-router/src/plugin/test/mod.rs index 5b1fc7c63c..11c3b13c06 100644 --- a/apollo-router/src/plugin/test/mod.rs +++ b/apollo-router/src/plugin/test/mod.rs @@ -6,7 +6,10 @@ mod service; mod broken; mod restricted; +#[cfg(test)] +pub use mock::connector::MockConnector; pub use mock::subgraph::MockSubgraph; +pub use service::MockConnectorService; pub use service::MockExecutionService; pub use service::MockHttpClientService; #[cfg(test)] diff --git a/apollo-router/src/plugin/test/service.rs b/apollo-router/src/plugin/test/service.rs index fa1388ff1b..9095ff486a 100644 --- a/apollo-router/src/plugin/test/service.rs +++ b/apollo-router/src/plugin/test/service.rs @@ -8,7 +8,6 @@ use futures::Future; use http::Request as HyperRequest; use http::Response as HyperResponse; -use crate::services::router::Body; use crate::services::ExecutionRequest; use crate::services::ExecutionResponse; #[cfg(test)] @@ -19,6 +18,8 @@ use crate::services::SubgraphRequest; use crate::services::SubgraphResponse; use crate::services::SupergraphRequest; use crate::services::SupergraphResponse; +use crate::services::connector; +use crate::services::router::Body; #[cfg(test)] use crate::spec::Schema; @@ -108,6 +109,11 @@ mock_service!(Router, RouterRequest, RouterResponse); mock_service!(Supergraph, SupergraphRequest, SupergraphResponse); mock_service!(Execution, ExecutionRequest, ExecutionResponse); mock_service!(Subgraph, SubgraphRequest, SubgraphResponse); +mock_service!( + Connector, + connector::request_service::Request, + connector::request_service::Response +); mock_async_service!(HttpClient, HyperRequest, HyperResponse); // This type is introduced to update internal uses of mocked http services, because the HttpClientService diff --git a/apollo-router/src/plugins/authentication/connector.rs b/apollo-router/src/plugins/authentication/connector.rs new file mode 100644 index 0000000000..4f7a145b26 --- /dev/null +++ b/apollo-router/src/plugins/authentication/connector.rs @@ -0,0 +1,52 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use schemars::JsonSchema; +use serde::Deserialize; +use tower::ServiceBuilder; +use tower::ServiceExt; + +use super::subgraph::AuthConfig; +use crate::plugins::authentication::subgraph::SigningParamsConfig; +use crate::services::connector; +use crate::services::connector_service::ConnectorSourceRef; + +pub(super) struct ConnectorAuth { + pub(super) signing_params: Arc>>, +} + +impl ConnectorAuth { + pub(super) fn connector_request_service( + &self, + service: connector::request_service::BoxService, + ) -> connector::request_service::BoxService { + let signing_params = self.signing_params.clone(); + ServiceBuilder::new() + .map_request(move |req: connector::request_service::Request| { + if let Some(ref source_name) = req.connector.id.source_name + && let Some(signing_params) = signing_params + .get(&ConnectorSourceRef::new( + req.connector.id.subgraph_name.clone(), + source_name.clone(), + )) + .cloned() + { + req.context + .extensions() + .with_lock(|lock| lock.insert(signing_params)); + } + req + }) + .service(service) + .boxed() + } +} + +/// Configure connector authentication +#[derive(Clone, Debug, Default, JsonSchema, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub(crate) struct Config { + #[serde(default)] + /// Create a configuration that will apply only to a specific source. + pub(crate) sources: HashMap, +} diff --git a/apollo-router/src/plugins/authentication/error.rs b/apollo-router/src/plugins/authentication/error.rs new file mode 100644 index 0000000000..b78c7427b2 --- /dev/null +++ b/apollo-router/src/plugins/authentication/error.rs @@ -0,0 +1,127 @@ +use displaydoc::Display; +use jsonwebtoken::Algorithm; +use jsonwebtoken::errors::Error as JWTError; +use jsonwebtoken::errors::ErrorKind; +use jsonwebtoken::jwk::KeyAlgorithm; +use serde::Deserialize; +use serde::Serialize; +use thiserror::Error; +use tower::BoxError; + +#[derive(Debug, Display, Error)] +pub(crate) enum AuthenticationError { + /// Configured header is not convertible to a string + CannotConvertToString, + + /// Value of '{0}' JWT header should be prefixed with '{1}' + InvalidJWTPrefix(String, String), + + /// Value of '{0}' JWT header has only '{1}' prefix but no JWT token + MissingJWTToken(String, String), + + /// '{0}' is not a valid JWT header: {1} + InvalidHeader(String, JWTError), + + /// Cannot create decoding key: {0} + CannotCreateDecodingKey(JWTError), + + /// JWK does not contain an algorithm + JWKHasNoAlgorithm, + + /// Cannot decode JWT: {0} + CannotDecodeJWT(JWTError), + + /// Cannot insert claims into context: {0} + CannotInsertClaimsIntoContext(BoxError), + + /// Cannot find kid: '{0:?}' in JWKS list + CannotFindKID(String), + + /// Cannot find a suitable key for: alg: '{0:?}', kid: '{1:?}' in JWKS list + CannotFindSuitableKey(Algorithm, Option), + + /// Invalid issuer: the token's `iss` was '{token}', but signed with a key from JWKS configured to only accept from '{expected}' + InvalidIssuer { expected: String, token: String }, + + /// Invalid audience: the token's `aud` was '{actual}', but '{expected}' was expected + InvalidAudience { actual: String, expected: String }, + + /// Unsupported key algorithm: {0} + UnsupportedKeyAlgorithm(KeyAlgorithm), +} + +fn jwt_error_to_reason(jwt_err: &JWTError) -> &'static str { + let kind = jwt_err.kind(); + match kind { + ErrorKind::InvalidToken => "INVALID_TOKEN", + ErrorKind::InvalidSignature => "INVALID_SIGNATURE", + ErrorKind::InvalidEcdsaKey => "INVALID_ECDSA_KEY", + ErrorKind::InvalidRsaKey(_) => "INVALID_RSA_KEY", + ErrorKind::RsaFailedSigning => "RSA_FAILED_SIGNING", + ErrorKind::InvalidAlgorithmName => "INVALID_ALGORITHM_NAME", + ErrorKind::InvalidKeyFormat => "INVALID_KEY_FORMAT", + ErrorKind::MissingRequiredClaim(_) => "MISSING_REQUIRED_CLAIM", + ErrorKind::ExpiredSignature => "EXPIRED_SIGNATURE", + ErrorKind::InvalidIssuer => "INVALID_ISSUER", + ErrorKind::InvalidAudience => "INVALID_AUDIENCE", + ErrorKind::InvalidSubject => "INVALID_SUBJECT", + ErrorKind::ImmatureSignature => "IMMATURE_SIGNATURE", + ErrorKind::InvalidAlgorithm => "INVALID_ALGORITHM", + ErrorKind::MissingAlgorithm => "MISSING_ALGORITHM", + ErrorKind::Base64(_) => "BASE64_ERROR", + ErrorKind::Json(_) => "JSON_ERROR", + ErrorKind::Utf8(_) => "UTF8_ERROR", + ErrorKind::Crypto(_) => "CRYPTO_ERROR", + // ErrorKind is non-exhaustive + _ => "UNKNOWN_ERROR", + } +} + +impl AuthenticationError { + pub(crate) fn as_context_object(&self) -> ErrorContext { + let (code, reason) = match self { + AuthenticationError::CannotConvertToString => ("CANNOT_CONVERT_TO_STRING", None), + AuthenticationError::InvalidJWTPrefix(_, _) => ("INVALID_PREFIX", None), + AuthenticationError::MissingJWTToken(_, _) => ("MISSING_JWT", None), + AuthenticationError::InvalidHeader(_, jwt_err) => { + ("INVALID_HEADER", Some(jwt_error_to_reason(jwt_err).into())) + } + AuthenticationError::CannotCreateDecodingKey(jwt_err) => ( + "CANNOT_CREATE_DECODING_KEY", + Some(jwt_error_to_reason(jwt_err).into()), + ), + AuthenticationError::JWKHasNoAlgorithm => ("JWK_HAS_NO_ALGORITHM", None), + AuthenticationError::CannotDecodeJWT(jwt_err) => ( + "CANNOT_DECODE_JWT", + Some(jwt_error_to_reason(jwt_err).into()), + ), + AuthenticationError::CannotInsertClaimsIntoContext(_) => { + ("CANNOT_INSERT_CLAIMS_INTO_CONTEXT", None) + } + AuthenticationError::CannotFindKID(_) => ("CANNOT_FIND_KID", None), + AuthenticationError::CannotFindSuitableKey(_, _) => ("CANNOT_FIND_SUITABLE_KEY", None), + AuthenticationError::InvalidIssuer { .. } => ("INVALID_ISSUER", None), + AuthenticationError::InvalidAudience { .. } => ("INVALID_AUDIENCE", None), + AuthenticationError::UnsupportedKeyAlgorithm(_) => ("UNSUPPORTED_KEY_ALGORITHM", None), + }; + + ErrorContext { + message: self.to_string(), + code: code.into(), + reason, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct ErrorContext { + pub(super) message: String, + pub(super) code: String, + pub(super) reason: Option, +} + +#[derive(Error, Debug)] +pub(crate) enum Error { + #[error("header_value_prefix must not contain whitespace")] + BadHeaderValuePrefix, +} diff --git a/apollo-router/src/plugins/authentication/jwks.rs b/apollo-router/src/plugins/authentication/jwks.rs index 9da1a2ac74..da3c1eee4d 100644 --- a/apollo-router/src/plugins/authentication/jwks.rs +++ b/apollo-router/src/plugins/authentication/jwks.rs @@ -2,30 +2,46 @@ use std::collections::HashMap; use std::collections::HashSet; use std::mem; use std::sync::Arc; -use std::sync::RwLock; use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; +use futures::future::Either; use futures::future::join_all; use futures::future::select; -use futures::future::Either; use futures::pin_mut; use futures::stream::repeat; use futures::stream::select_all; +use http::HeaderMap; +use http::StatusCode; use http::header::ACCEPT; +use jsonwebtoken::Algorithm; +use jsonwebtoken::DecodingKey; +use jsonwebtoken::TokenData; +use jsonwebtoken::Validation; +use jsonwebtoken::decode; +use jsonwebtoken::jwk::AlgorithmParameters; +use jsonwebtoken::jwk::EllipticCurve; use jsonwebtoken::jwk::Jwk; use jsonwebtoken::jwk::JwkSet; -use jsonwebtoken::Algorithm; +use jsonwebtoken::jwk::KeyAlgorithm; +use jsonwebtoken::jwk::KeyOperations; +use jsonwebtoken::jwk::PublicKeyUse; use mime::APPLICATION_JSON; -use serde_json::Value; +use parking_lot::RwLock; use tokio::fs::read_to_string; use tokio::sync::oneshot; use tower::BoxError; use tracing_futures::Instrument; use url::Url; -use super::Header; +use super::APOLLO_AUTHENTICATION_JWT_CLAIMS; use super::CLIENT; use super::DEFAULT_AUTHENTICATION_NETWORK_TIMEOUT; +use super::Header; +use super::Source; +use crate::Context; +use crate::plugins::authentication::error::AuthenticationError; #[derive(Clone)] pub(super) struct JwksManager { @@ -34,10 +50,14 @@ pub(super) struct JwksManager { _drop_signal: Arc>, } +pub(super) type Issuers = HashSet; +pub(super) type Audiences = HashSet; + #[derive(Clone)] pub(super) struct JwksConfig { pub(super) url: Url, - pub(super) issuer: Option, + pub(super) issuers: Option, + pub(super) audiences: Option, pub(super) algorithms: Option>, pub(super) poll_interval: Duration, pub(super) headers: Vec
, @@ -46,7 +66,8 @@ pub(super) struct JwksConfig { #[derive(Clone)] pub(super) struct JwkSetInfo { pub(super) jwks: JwkSet, - pub(super) issuer: Option, + pub(super) issuers: Option, + pub(super) audiences: Option, pub(super) algorithms: Option>, } @@ -90,7 +111,7 @@ impl JwksManager { } } - pub(super) fn iter_jwks(&self) -> Iter { + pub(super) fn iter_jwks(&self) -> Iter<'_> { Iter { list: self.list.clone(), manager: self, @@ -112,9 +133,7 @@ async fn poll( tokio::time::sleep(config.poll_interval).await; if let Some(jwks) = get_jwks(config.url.clone(), config.headers.clone()).await { - if let Ok(mut map) = jwks_map.write() { - map.insert(config.url, jwks); - } + jwks_map.write().insert(config.url, jwks); } }), ) @@ -139,7 +158,7 @@ async fn poll( } // This function is expected to return an Optional value, but we'd like to let -// users know the various failure conditions. Hence the various clumsy map_err() +// users know the various failure conditions. Hence, the various clumsy map_err() // scattered through the processing. pub(super) async fn get_jwks(url: Url, headers: Vec
) -> Option { let data = if url.scheme() == "file" { @@ -203,7 +222,7 @@ pub(crate) fn parse_jwks(data: &str) -> Option { // jsonwebtoken and exclude them tracing::debug!(data, "parsing JWKS"); - let mut raw_json: Value = serde_json::from_str(data) + let mut raw_json: serde_json::Value = serde_json::from_str(data) .map_err(|e| { tracing::error!(%e, "could not create JSON Value from url content, enable debug logs to see content"); e @@ -237,7 +256,7 @@ pub(super) struct Iter<'a> { list: Vec, } -impl<'a> Iterator for Iter<'a> { +impl Iterator for Iter<'_> { type Item = JwkSetInfo; fn next(&mut self) -> Option { @@ -245,19 +264,534 @@ impl<'a> Iterator for Iter<'a> { match self.list.pop() { None => return None, Some(config) => { - if let Ok(map) = self.manager.jwks_map.read() { - if let Some(jwks) = map.get(&config.url) { - return Some(JwkSetInfo { - jwks: jwks.clone(), - issuer: config.issuer.clone(), - algorithms: config.algorithms.clone(), - }); + let map = self.manager.jwks_map.read(); + if let Some(jwks) = map.get(&config.url) { + return Some(JwkSetInfo { + jwks: jwks.clone(), + issuers: config.issuers.clone(), + audiences: config.audiences.clone(), + algorithms: config.algorithms.clone(), + }); + } + } + } + } + } +} + +#[derive(Debug, Default)] +pub(super) struct JWTCriteria { + pub(super) alg: Algorithm, + pub(super) kid: Option, +} + +pub(super) type SearchResult = (Option, Option, Jwk); + +/// Search the list of JWKS to find a key we can use to decode a JWT. +/// +/// The search criteria allow us to match a variety of keys depending on which criteria are provided +/// by the JWT header. The only mandatory parameter is "alg". +/// Note: "none" is not implemented by jsonwebtoken, so it can't be part of the [`Algorithm`] enum. +pub(super) fn search_jwks( + jwks_manager: &JwksManager, + criteria: &JWTCriteria, +) -> Option> { + const HIGHEST_SCORE: usize = 2; + let mut candidates = vec![]; + let mut found_highest_score = false; + for JwkSetInfo { + jwks, + issuers, + audiences, + algorithms, + } in jwks_manager.iter_jwks() + { + // filter accepted algorithms + if let Some(algs) = algorithms + && !algs.contains(&criteria.alg) + { + continue; + } + + // Try to figure out if our jwks contains a candidate key (i.e.: a key which matches our + // criteria) + for mut key in jwks.keys.into_iter().filter(|key| { + // We are only interested in keys which are used for signature verification + match (&key.common.public_key_use, &key.common.key_operations) { + // "use" https://datatracker.ietf.org/doc/html/rfc7517#section-4.2 and + // "key_ops" https://datatracker.ietf.org/doc/html/rfc7517#section-4.3 are both optional + (None, None) => true, + (None, Some(purpose)) => purpose.contains(&KeyOperations::Verify), + (Some(key_use), None) => key_use == &PublicKeyUse::Signature, + // The "use" and "key_ops" JWK members SHOULD NOT be used together; + // however, if both are used, the information they convey MUST be + // consistent + (Some(key_use), Some(purpose)) => { + key_use == &PublicKeyUse::Signature && purpose.contains(&KeyOperations::Verify) + } + } + }) { + let mut key_score = 0; + + // Let's see if we have a specified kid and if they match + if criteria.kid.is_some() && key.common.key_id == criteria.kid { + key_score += 1; + } + + // Furthermore, we would like our algorithms to match, or at least the kty + // If we have an algorithm that matches, boost the score + match key.common.key_algorithm { + Some(algorithm) => { + if convert_key_algorithm(algorithm) != Some(criteria.alg) { + continue; + } + key_score += 1; + } + // If a key doesn't have an algorithm, then we match the "alg" specified in the + // search criteria against all of the algorithms that we support. If the + // key.algorithm parameters match the type of parameters for the "family" of the + // criteria "alg", then we update the key to use the value of "alg" provided in + // the search criteria. + // If not, then this is not a usable key for this JWT + // Note: Matching algorithm parameters may seem unusual, but the appropriate + // algorithm details are not structured for easy consumption in jsonwebtoken and + // this is the simplest way to determine algorithm family. + None => match (criteria.alg, &key.algorithm) { + ( + Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512, + AlgorithmParameters::OctetKey(_), + ) => { + key.common.key_algorithm = Some(convert_algorithm(criteria.alg)); + } + ( + Algorithm::RS256 + | Algorithm::RS384 + | Algorithm::RS512 + | Algorithm::PS256 + | Algorithm::PS384 + | Algorithm::PS512, + AlgorithmParameters::RSA(_), + ) => { + key.common.key_algorithm = Some(convert_algorithm(criteria.alg)); + } + (Algorithm::ES256, AlgorithmParameters::EllipticCurve(params)) => { + if params.curve == EllipticCurve::P256 { + key.common.key_algorithm = Some(convert_algorithm(criteria.alg)); + } + } + (Algorithm::ES384, AlgorithmParameters::EllipticCurve(params)) => { + if params.curve == EllipticCurve::P384 { + key.common.key_algorithm = Some(convert_algorithm(criteria.alg)); } - } else { - return None; } + (Algorithm::EdDSA, AlgorithmParameters::EllipticCurve(params)) => { + if params.curve == EllipticCurve::Ed25519 { + key.common.key_algorithm = Some(convert_algorithm(criteria.alg)); + } + } + _ => { + // We'll ignore combinations we don't recognise + continue; + } + }, + }; + + // If we get here we have a key that: + // - may be used for signature verification + // - has a matching algorithm, or if JWT has no algorithm, a matching key type + // It may have a matching kid if the JWT has a kid and it matches the key kid + // + // Multiple keys may meet the matching criteria, but they have a score. They get 1 + // point for having an explicitly matching algorithm and 1 point for an explicitly + // matching kid. We will sort our candidates and pick the key with the highest score. + + // If we find a key with a HIGHEST_SCORE, we will filter the list to only keep those + // with that score + if key_score == HIGHEST_SCORE { + found_highest_score = true; + } + + candidates.push((key_score, (issuers.clone(), audiences.clone(), key))); + } + } + + tracing::debug!( + "jwk candidates: {:?}", + candidates + .iter() + .map(|(score, (_, _, candidate))| ( + score, + &candidate.common.key_id, + candidate.common.key_algorithm + )) + .collect::, Option)>>() + ); + + if candidates.is_empty() { + None + } else { + // Only sort if we need to + if candidates.len() > 1 { + candidates.sort_by(|a, b| a.0.cmp(&b.0)); + } + + if found_highest_score { + Some( + candidates + .into_iter() + .filter_map(|(score, candidate)| { + if score == HIGHEST_SCORE { + Some(candidate) + } else { + None + } + }) + .collect(), + ) + } else { + Some( + candidates + .into_iter() + .map(|(_score, candidate)| candidate) + .collect(), + ) + } + } +} + +pub(super) fn extract_jwt<'a, 'b: 'a>( + source: &'a Source, + ignore_other_prefixes: bool, + headers: &'b HeaderMap, +) -> Option> { + match source { + Source::Header { name, value_prefix } => { + // The http_request is stored in a `Router::Request` context. + // We are going to check the headers for the presence of the configured header + let jwt_value_result = headers + .get(name)? + .to_str() + .map_err(|_err| AuthenticationError::CannotConvertToString); + + // If we find the header, but can't convert it to a string, let the client know + let jwt_value_untrimmed = match jwt_value_result { + Ok(value) => value, + Err(err) => { + return Some(Err(err)); } + }; + + // Let's trim out leading and trailing whitespace to be accommodating + let jwt_value = jwt_value_untrimmed.trim(); + + // Make sure the format of our message matches our expectations + // Technically, the spec is case-sensitive, but let's accept + // case variations + let prefix_len = value_prefix.len(); + if jwt_value.len() < prefix_len + || !&jwt_value[..prefix_len].eq_ignore_ascii_case(value_prefix) + { + return if ignore_other_prefixes { + None + } else { + Some(Err(AuthenticationError::InvalidJWTPrefix( + name.to_owned(), + value_prefix.to_owned(), + ))) + }; } + // If there's no header prefix, we avoid splitting the header + let jwt = if value_prefix.is_empty() { + // check for whitespace — we've already trimmed, so this means the request has a + // prefix that shouldn't exist + if jwt_value.contains(' ') { + return Some(Err(AuthenticationError::InvalidJWTPrefix( + name.to_owned(), + value_prefix.to_owned(), + ))); + } + + // we can simply assign the jwt to the jwt_value; we'll validate down below + jwt_value + } else { + // Otherwise, we need to split our string in (at most 2) sections. + let jwt_parts: Vec<&str> = jwt_value.splitn(2, ' ').collect(); + if jwt_parts.len() != 2 { + return Some(Err(AuthenticationError::MissingJWTToken( + name.to_owned(), + value_prefix.to_owned(), + ))); + } + + // We have our jwt + jwt_parts[1] + }; + Some(Ok(jwt)) } + Source::Cookie { name } => { + for header in headers.get_all("cookie") { + let value = match header.to_str() { + Ok(value) => value, + Err(_not_a_string_error) => { + return Some(Err(AuthenticationError::CannotConvertToString)); + } + }; + for cookie in cookie::Cookie::split_parse(value) { + match cookie { + Err(_) => continue, + Ok(cookie) => { + if cookie.name() == name + && let Some(value) = cookie.value_raw() + { + return Some(Ok(value)); + } + } + } + } + } + + None + } + } +} + +pub(super) type DecodedClaims = ( + Option, + Option, + TokenData, +); + +pub(super) fn decode_jwt( + jwt: &str, + keys: Vec, + criteria: JWTCriteria, +) -> Result { + let mut error = None; + for (issuers, audiences, jwk) in keys.into_iter() { + let decoding_key = match DecodingKey::from_jwk(&jwk) { + Ok(k) => k, + Err(e) => { + error = Some(( + AuthenticationError::CannotCreateDecodingKey(e), + StatusCode::INTERNAL_SERVER_ERROR, + )); + continue; + } + }; + + let key_algorithm = match jwk.common.key_algorithm { + Some(a) => a, + None => { + error = Some(( + AuthenticationError::JWKHasNoAlgorithm, + StatusCode::INTERNAL_SERVER_ERROR, + )); + continue; + } + }; + + let algorithm = match convert_key_algorithm(key_algorithm) { + Some(a) => a, + None => { + error = Some(( + AuthenticationError::UnsupportedKeyAlgorithm(key_algorithm), + StatusCode::INTERNAL_SERVER_ERROR, + )); + continue; + } + }; + + let mut validation = Validation::new(algorithm); + validation.validate_nbf = true; + // if set to true, it will reject tokens containing an `aud` claim if the validation does not specify an audience + // we don't validate audience yet, so this is deactivated + validation.validate_aud = false; + + match decode::(jwt, &decoding_key, &validation) { + Ok(v) => return Ok((issuers, audiences, v)), + Err(e) => { + tracing::trace!("JWT decoding failed with error `{e}`"); + error = Some(( + AuthenticationError::CannotDecodeJWT(e), + StatusCode::UNAUTHORIZED, + )); + } + }; + } + + match error { + Some(e) => Err(e), + None => { + // We can't find a key to process this JWT. + Err(( + criteria.kid.map_or_else( + || AuthenticationError::CannotFindSuitableKey(criteria.alg, None), + AuthenticationError::CannotFindKID, + ), + StatusCode::UNAUTHORIZED, + )) + } + } +} + +pub(crate) fn jwt_expires_in(context: &Context) -> Duration { + context + .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) + .unwrap_or_else(|err| { + tracing::error!("could not read JWT claims: {err}"); + None + }) + .flatten() + .and_then(|claims_value: Option| { + let claims_obj = claims_value.as_ref()?.as_object(); + // Extract the expiry claim from the JWT + let exp = match claims_obj { + Some(exp) => exp.get("exp"), + None => { + tracing::error!("expected JWT claims to be an object"); + None + } + }; + // Ensure the expiry claim is an integer + match exp.and_then(|it| it.as_i64()) { + Some(ts) => Some(ts), + None => { + tracing::error!("expected JWT 'exp' (expiry) claim to be an integer"); + None + } + } + }) + .map(|exp| { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("no time travel allowed") + .as_secs() as i64; + if now < exp { + Duration::from_secs((exp - now) as u64) + } else { + Duration::ZERO + } + }) + .unwrap_or(Duration::MAX) +} + +// Apparently the `jsonwebtoken` crate now has 2 different enums for algorithms +pub(crate) fn convert_key_algorithm(algorithm: KeyAlgorithm) -> Option { + Some(match algorithm { + KeyAlgorithm::HS256 => Algorithm::HS256, + KeyAlgorithm::HS384 => Algorithm::HS384, + KeyAlgorithm::HS512 => Algorithm::HS512, + KeyAlgorithm::ES256 => Algorithm::ES256, + KeyAlgorithm::ES384 => Algorithm::ES384, + KeyAlgorithm::RS256 => Algorithm::RS256, + KeyAlgorithm::RS384 => Algorithm::RS384, + KeyAlgorithm::RS512 => Algorithm::RS512, + KeyAlgorithm::PS256 => Algorithm::PS256, + KeyAlgorithm::PS384 => Algorithm::PS384, + KeyAlgorithm::PS512 => Algorithm::PS512, + KeyAlgorithm::EdDSA => Algorithm::EdDSA, + // We don't use these encryption algorithms + KeyAlgorithm::RSA1_5 | KeyAlgorithm::RSA_OAEP | KeyAlgorithm::RSA_OAEP_256 => return None, + }) +} + +fn convert_algorithm(algorithm: Algorithm) -> KeyAlgorithm { + match algorithm { + Algorithm::HS256 => KeyAlgorithm::HS256, + Algorithm::HS384 => KeyAlgorithm::HS384, + Algorithm::HS512 => KeyAlgorithm::HS512, + Algorithm::ES256 => KeyAlgorithm::ES256, + Algorithm::ES384 => KeyAlgorithm::ES384, + Algorithm::RS256 => KeyAlgorithm::RS256, + Algorithm::RS384 => KeyAlgorithm::RS384, + Algorithm::RS512 => KeyAlgorithm::RS512, + Algorithm::PS256 => KeyAlgorithm::PS256, + Algorithm::PS384 => KeyAlgorithm::PS384, + Algorithm::PS512 => KeyAlgorithm::PS512, + Algorithm::EdDSA => KeyAlgorithm::EdDSA, + } +} + +#[cfg(test)] +mod test { + use std::time::Duration; + use std::time::UNIX_EPOCH; + + use serde_json_bytes::json; + + use super::APOLLO_AUTHENTICATION_JWT_CLAIMS; + use super::Context; + use super::jwt_expires_in; + use crate::test_harness::tracing_test; + + #[test] + fn test_exp_defaults_to_max_when_no_jwt_claims_present() { + let context = Context::new(); + let expiry = jwt_expires_in(&context); + assert_eq!(expiry, Duration::MAX); + } + + #[test] + fn test_jwt_claims_not_object() { + let _guard = tracing_test::dispatcher_guard(); + + let context = Context::new(); + context.insert_json_value(APOLLO_AUTHENTICATION_JWT_CLAIMS, json!("not an object")); + + let expiry = jwt_expires_in(&context); + assert_eq!(expiry, Duration::MAX); + + assert!(tracing_test::logs_contain( + "expected JWT claims to be an object" + )); + } + + #[test] + fn test_expiry_claim_not_integer() { + let _guard = tracing_test::dispatcher_guard(); + + let context = Context::new(); + context.insert_json_value( + APOLLO_AUTHENTICATION_JWT_CLAIMS, + json!({ + "exp": "\"not an integer\"" + }), + ); + + let expiry = jwt_expires_in(&context); + assert_eq!(expiry, Duration::MAX); + + assert!(tracing_test::logs_contain( + "expected JWT 'exp' (expiry) claim to be an integer" + )); + } + + #[test] + fn test_expiry_claim_is_valid_but_expired() { + let context = Context::new(); + context.insert_json_value( + APOLLO_AUTHENTICATION_JWT_CLAIMS, + json!({ + "exp": 0 + }), + ); + + let expiry = jwt_expires_in(&context); + assert_eq!(expiry, Duration::ZERO); + } + + #[test] + fn test_expiry_claim_is_valid() { + let context = Context::new(); + let exp = UNIX_EPOCH.elapsed().unwrap().as_secs() + 3600; + context.insert_json_value( + APOLLO_AUTHENTICATION_JWT_CLAIMS, + json!({ + "exp": exp + }), + ); + + let expiry = jwt_expires_in(&context); + assert_eq!(expiry, Duration::from_secs(3600)); } } diff --git a/apollo-router/src/plugins/authentication/mod.rs b/apollo-router/src/plugins/authentication/mod.rs index 9f9abc8040..d965f07778 100644 --- a/apollo-router/src/plugins/authentication/mod.rs +++ b/apollo-router/src/plugins/authentication/mod.rs @@ -5,34 +5,20 @@ use std::ops::ControlFlow; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; -use displaydoc::Display; -use http::header; -use http::HeaderMap; +use error::AuthenticationError; +use error::Error; use http::HeaderName; use http::HeaderValue; use http::StatusCode; -use jsonwebtoken::decode; -use jsonwebtoken::decode_header; -use jsonwebtoken::errors::Error as JWTError; -use jsonwebtoken::jwk::AlgorithmParameters; -use jsonwebtoken::jwk::EllipticCurve; -use jsonwebtoken::jwk::Jwk; -use jsonwebtoken::jwk::KeyAlgorithm; -use jsonwebtoken::jwk::KeyOperations; -use jsonwebtoken::jwk::PublicKeyUse; +use http::header; use jsonwebtoken::Algorithm; -use jsonwebtoken::DecodingKey; -use jsonwebtoken::TokenData; -use jsonwebtoken::Validation; +use jsonwebtoken::decode_header; use once_cell::sync::Lazy; use reqwest::Client; use schemars::JsonSchema; use serde::Deserialize; -use serde_json::Value; -use thiserror::Error; +use serde::Serialize; use tower::BoxError; use tower::ServiceBuilder; use tower::ServiceExt; @@ -44,77 +30,39 @@ use self::subgraph::SigningParamsConfig; use self::subgraph::SubgraphAuth; use crate::graphql; use crate::layers::ServiceBuilderExt; +use crate::plugin::PluginInit; +use crate::plugin::PluginPrivate; use crate::plugin::serde::deserialize_header_name; use crate::plugin::serde::deserialize_header_value; -use crate::plugin::Plugin; -use crate::plugin::PluginInit; -use crate::plugins::authentication::jwks::JwkSetInfo; +use crate::plugins::authentication::connector::ConnectorAuth; +use crate::plugins::authentication::error::ErrorContext; +use crate::plugins::authentication::jwks::Audiences; +use crate::plugins::authentication::jwks::Issuers; use crate::plugins::authentication::jwks::JwksConfig; -use crate::register_plugin; -use crate::services::router; +use crate::plugins::authentication::subgraph::make_signing_params; use crate::services::APPLICATION_JSON_HEADER_VALUE; -use crate::Context; +use crate::services::connector_service::ConnectorSourceRef; +use crate::services::router; + +pub(crate) mod jwks; + +pub(crate) mod connector; -mod jwks; pub(crate) mod subgraph; +mod error; #[cfg(test)] mod tests; pub(crate) const AUTHENTICATION_SPAN_NAME: &str = "authentication_plugin"; -pub(crate) const APOLLO_AUTHENTICATION_JWT_CLAIMS: &str = "apollo_authentication::JWT::claims"; +pub(crate) const APOLLO_AUTHENTICATION_JWT_CLAIMS: &str = "apollo::authentication::jwt_claims"; const HEADER_TOKEN_TRUNCATED: &str = "(truncated)"; -#[derive(Debug, Display, Error)] -pub(crate) enum AuthenticationError<'a> { - /// Configured header is not convertible to a string - CannotConvertToString, - - /// Header Value: '{0}' is not correctly formatted. prefix should be '{1}' - InvalidPrefix(&'a str, &'a str), - - /// Header Value: '{0}' is not correctly formatted. Missing JWT - MissingJWT(&'a str), - - /// '{0}' is not a valid JWT header: {1} - InvalidHeader(&'a str, JWTError), - - /// Cannot create decoding key: {0} - CannotCreateDecodingKey(JWTError), - - /// JWK does not contain an algorithm - JWKHasNoAlgorithm, - - /// Cannot decode JWT: {0} - CannotDecodeJWT(JWTError), - - /// Cannot insert claims into context: {0} - CannotInsertClaimsIntoContext(BoxError), - - /// Cannot find kid: '{0:?}' in JWKS list - CannotFindKID(Option), - - /// Cannot find a suitable key for: alg: '{0:?}', kid: '{1:?}' in JWKS list - CannotFindSuitableKey(Algorithm, Option), - - /// Invalid issuer: the token's `iss` was '{token}', but signed with a key from '{expected}' - InvalidIssuer { expected: String, token: String }, - - /// Unsupported key algorithm: {0} - UnsupportedKeyAlgorithm(KeyAlgorithm), -} - const DEFAULT_AUTHENTICATION_NETWORK_TIMEOUT: Duration = Duration::from_secs(15); const DEFAULT_AUTHENTICATION_DOWNLOAD_INTERVAL: Duration = Duration::from_secs(60); static CLIENT: Lazy> = Lazy::new(|| Ok(Client::new())); -#[derive(Error, Debug)] -pub(crate) enum Error { - #[error("header_value_prefix must not contain whitespace")] - BadHeaderValuePrefix, -} - struct Router { configuration: JWTConf, jwks_manager: JwksManager, @@ -123,6 +71,19 @@ struct Router { struct AuthenticationPlugin { router: Option, subgraph: Option, + connector: Option, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)] +enum OnError { + Continue, + Error, +} + +impl Default for OnError { + fn default() -> Self { + Self::Error + } } #[derive(Clone, Debug, Deserialize, JsonSchema, serde_derive_default::Default)] @@ -142,6 +103,13 @@ struct JWTConf { /// Alternative sources to extract the JWT #[serde(default)] sources: Vec, + /// Control the behavior when an error occurs during the authentication process. + /// + /// Defaults to `Error`. When set to `Continue`, requests that fail JWT authentication will + /// continue to be processed by the router, but without the JWT claims in the context. When set + /// to `Error`, requests that fail JWT authentication will be rejected with a HTTP 403 error. + #[serde(default)] + on_error: OnError, } #[derive(Clone, Debug, Deserialize, JsonSchema)] @@ -156,8 +124,14 @@ struct JwksConf { )] #[schemars(with = "String", default = "default_poll_interval")] poll_interval: Duration, - /// Expected issuer for tokens verified by that JWKS - issuer: Option, + /// Expected issuers for tokens verified by that JWKS + /// + /// If not specified, the issuer will not be checked. + issuers: Option, + /// Expected audiences for tokens verified by that JWKS + /// + /// If not specified, the audience will not be checked. + audiences: Option, /// List of accepted algorithms. Possible values are `HS256`, `HS384`, `HS512`, `ES256`, `ES384`, `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `EdDSA` #[schemars(with = "Option>", default)] #[serde(default)] @@ -207,6 +181,8 @@ struct Conf { router: Option, /// Subgraph configuration subgraph: Option, + /// Connector configuration + connector: Option, } // We may support additional authentication mechanisms in future, so all @@ -220,7 +196,7 @@ struct RouterConf { } fn default_header_name() -> String { - http::header::AUTHORIZATION.to_string() + header::AUTHORIZATION.to_string() } fn default_header_value_prefix() -> String { @@ -231,367 +207,319 @@ fn default_poll_interval() -> Duration { DEFAULT_AUTHENTICATION_DOWNLOAD_INTERVAL } -#[derive(Debug, Default)] -struct JWTCriteria { - alg: Algorithm, - kid: Option, -} +#[async_trait::async_trait] +impl PluginPrivate for AuthenticationPlugin { + type Config = Conf; -/// Search the list of JWKS to find a key we can use to decode a JWT. -/// -/// The search criteria allow us to match a variety of keys depending on which criteria are provided -/// by the JWT header. The only mandatory parameter is "alg". -/// Note: "none" is not implemented by jsonwebtoken, so it can't be part of the [`Algorithm`] enum. -fn search_jwks( - jwks_manager: &JwksManager, - criteria: &JWTCriteria, -) -> Option, Jwk)>> { - const HIGHEST_SCORE: usize = 2; - let mut candidates = vec![]; - let mut found_highest_score = false; - for JwkSetInfo { - jwks, - issuer, - algorithms, - } in jwks_manager.iter_jwks() - { - // filter accepted algorithms - if let Some(algs) = algorithms { - if !algs.contains(&criteria.alg) { - continue; - } - } + async fn new(init: PluginInit) -> Result { + let subgraph = Self::init_subgraph(&init).await?; + let router = Self::init_router(&init).await?; + let connector = Self::init_connector(init).await?; + + Ok(Self { + router, + subgraph, + connector, + }) + } - // Try to figure out if our jwks contains a candidate key (i.e.: a key which matches our - // criteria) - for mut key in jwks.keys.into_iter().filter(|key| { - // We are only interested in keys which are used for signature verification - match (&key.common.public_key_use, &key.common.key_operations) { - // "use" https://datatracker.ietf.org/doc/html/rfc7517#section-4.2 and - // "key_ops" https://datatracker.ietf.org/doc/html/rfc7517#section-4.3 are both optional - (None, None) => true, - (None, Some(purpose)) => purpose.contains(&KeyOperations::Verify), - (Some(key_use), None) => key_use == &PublicKeyUse::Signature, - // The "use" and "key_ops" JWK members SHOULD NOT be used together; - // however, if both are used, the information they convey MUST be - // consistent - (Some(key_use), Some(purpose)) => { - key_use == &PublicKeyUse::Signature && purpose.contains(&KeyOperations::Verify) - } - } - }) { - let mut key_score = 0; + fn router_service(&self, service: router::BoxService) -> router::BoxService { + // Return without layering if no router config was defined + let Some(router_config) = &self.router else { + return service; + }; - // Let's see if we have a specified kid and if they match - if criteria.kid.is_some() && key.common.key_id == criteria.kid { - key_score += 1; + fn authentication_service_span() -> impl Fn(&router::Request) -> tracing::Span + Clone { + move |_request: &router::Request| { + tracing::info_span!( + AUTHENTICATION_SPAN_NAME, + "authentication service" = stringify!(router::Request), + "otel.kind" = "INTERNAL" + ) } + } - // Furthermore, we would like our algorithms to match, or at least the kty - // If we have an algorithm that matches, boost the score - match key.common.key_algorithm { - Some(algorithm) => { - if convert_key_algorithm(algorithm) != Some(criteria.alg) { - continue; - } - key_score += 1; - } - // If a key doesn't have an algorithm, then we match the "alg" specified in the - // search criteria against all of the algorithms that we support. If the - // key.algorithm parameters match the type of parameters for the "family" of the - // criteria "alg", then we update the key to use the value of "alg" provided in - // the search criteria. - // If not, then this is not a usable key for this JWT - // Note: Matching algorithm parameters may seem unusual, but the appropriate - // algorithm details are not structured for easy consumption in jsonwebtoken and - // this is the simplest way to determine algorithm family. - None => match (criteria.alg, &key.algorithm) { - ( - Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512, - AlgorithmParameters::OctetKey(_), - ) => { - key.common.key_algorithm = Some(convert_algorithm(criteria.alg)); - } - ( - Algorithm::RS256 - | Algorithm::RS384 - | Algorithm::RS512 - | Algorithm::PS256 - | Algorithm::PS384 - | Algorithm::PS512, - AlgorithmParameters::RSA(_), - ) => { - key.common.key_algorithm = Some(convert_algorithm(criteria.alg)); - } - (Algorithm::ES256, AlgorithmParameters::EllipticCurve(params)) => { - if params.curve == EllipticCurve::P256 { - key.common.key_algorithm = Some(convert_algorithm(criteria.alg)); - } - } - (Algorithm::ES384, AlgorithmParameters::EllipticCurve(params)) => { - if params.curve == EllipticCurve::P384 { - key.common.key_algorithm = Some(convert_algorithm(criteria.alg)); - } - } - (Algorithm::EdDSA, AlgorithmParameters::EllipticCurve(params)) => { - if params.curve == EllipticCurve::Ed25519 { - key.common.key_algorithm = Some(convert_algorithm(criteria.alg)); - } - } - _ => { - // We'll ignore combinations we don't recognise - continue; - } - }, - }; - - // If we get here we have a key that: - // - may be used for signature verification - // - has a matching algorithm, or if JWT has no algorithm, a matching key type - // It may have a matching kid if the JWT has a kid and it matches the key kid - // - // Multiple keys may meet the matching criteria, but they have a score. They get 1 - // point for having an explicitly matching algorithm and 1 point for an explicitly - // matching kid. We will sort our candidates and pick the key with the highest score. - - // If we find a key with a HIGHEST_SCORE, we will filter the list to only keep those - // with that score - if key_score == HIGHEST_SCORE { - found_highest_score = true; - } + let jwks_manager = router_config.jwks_manager.clone(); + let configuration = router_config.configuration.clone(); - candidates.push((key_score, (issuer.clone(), key))); - } + ServiceBuilder::new() + .instrument(authentication_service_span()) + .checkpoint(move |request: router::Request| { + Ok(authenticate(&configuration, &jwks_manager, request)) + }) + .service(service) + .boxed() } - tracing::debug!( - "jwk candidates: {:?}", - candidates - .iter() - .map(|(score, (_, candidate))| ( - score, - &candidate.common.key_id, - candidate.common.key_algorithm - )) - .collect::, Option)>>() - ); - - if candidates.is_empty() { - None - } else { - // Only sort if we need to - if candidates.len() > 1 { - candidates.sort_by(|a, b| a.0.cmp(&b.0)); - } + fn subgraph_service( + &self, + name: &str, + service: crate::services::subgraph::BoxService, + ) -> crate::services::subgraph::BoxService { + // Return without layering if no subgraph config was defined + let Some(subgraph) = &self.subgraph else { + return service; + }; - if found_highest_score { - Some( - candidates - .into_iter() - .filter_map(|(score, candidate)| { - if score == HIGHEST_SCORE { - Some(candidate) - } else { - None - } - }) - .collect(), - ) - } else { - Some( - candidates - .into_iter() - .map(|(_score, candidate)| candidate) - .collect(), - ) - } + subgraph.subgraph_service(name, service) } -} -#[async_trait::async_trait] -impl Plugin for AuthenticationPlugin { - type Config = Conf; + fn connector_request_service( + &self, + service: crate::services::connector::request_service::BoxService, + _: String, + ) -> crate::services::connector::request_service::BoxService { + // Return without layering if no connector config was defined + let Some(connector_auth) = &self.connector else { + return service; + }; - async fn new(init: PluginInit) -> Result { - let subgraph = if let Some(config) = init.config.subgraph { - let all = if let Some(config) = &config.all { - Some(Arc::new( - subgraph::make_signing_params(config, "all").await?, - )) - } else { - None - }; + connector_auth.connector_request_service(service) + } +} - let mut subgraphs: HashMap> = Default::default(); - for (subgraph_name, config) in &config.subgraphs { - subgraphs.insert( - subgraph_name.clone(), - Arc::new(subgraph::make_signing_params(config, subgraph_name.as_str()).await?), - ); - } +impl AuthenticationPlugin { + async fn init_subgraph(init: &PluginInit) -> Result, BoxError> { + // if no subgraph config was defined, then return early + let Some(subgraph_conf) = init.config.subgraph.clone() else { + return Ok(None); + }; - Some(SubgraphAuth { - signing_params: Arc::new(SigningParams { all, subgraphs }), - }) + let all = if let Some(config) = &subgraph_conf.all { + Some(Arc::new(make_signing_params(config, "all").await?)) } else { None }; - let router = if let Some(mut router_conf) = init.config.router { - if router_conf - .jwt - .header_value_prefix - .as_bytes() - .iter() - .any(u8::is_ascii_whitespace) + let mut subgraphs: HashMap> = Default::default(); + for (subgraph_name, config) in &subgraph_conf.subgraphs { + subgraphs.insert( + subgraph_name.clone(), + Arc::new(make_signing_params(config, subgraph_name.as_str()).await?), + ); + } + + Ok(Some(SubgraphAuth { + signing_params: Arc::new(SigningParams { all, subgraphs }), + })) + } + + async fn init_router(init: &PluginInit) -> Result, BoxError> { + // if no router config was defined, then return early + let Some(mut router_conf) = init.config.router.clone() else { + return Ok(None); + }; + + if router_conf + .jwt + .header_value_prefix + .as_bytes() + .iter() + .any(u8::is_ascii_whitespace) + { + return Err(Error::BadHeaderValuePrefix.into()); + } + + for source in &router_conf.jwt.sources { + if let Source::Header { value_prefix, .. } = source + && value_prefix.as_bytes().iter().any(u8::is_ascii_whitespace) { return Err(Error::BadHeaderValuePrefix.into()); } + } - for source in &router_conf.jwt.sources { - if let Source::Header { value_prefix, .. } = source { - if value_prefix.as_bytes().iter().any(u8::is_ascii_whitespace) { - return Err(Error::BadHeaderValuePrefix.into()); - } - } - } - - router_conf.jwt.sources.insert( - 0, - Source::Header { - name: router_conf.jwt.header_name.clone(), - value_prefix: router_conf.jwt.header_value_prefix.clone(), - }, - ); + router_conf.jwt.sources.insert( + 0, + Source::Header { + name: router_conf.jwt.header_name.clone(), + value_prefix: router_conf.jwt.header_value_prefix.clone(), + }, + ); - let mut list = vec![]; - for jwks_conf in &router_conf.jwt.jwks { - let url: Url = Url::from_str(jwks_conf.url.as_str())?; - list.push(JwksConfig { - url, - issuer: jwks_conf.issuer.clone(), - algorithms: jwks_conf - .algorithms - .as_ref() - .map(|algs| algs.iter().cloned().collect()), - poll_interval: jwks_conf.poll_interval, - headers: jwks_conf.headers.clone(), - }); - } + let mut list = vec![]; + for jwks_conf in &router_conf.jwt.jwks { + let url: Url = Url::from_str(jwks_conf.url.as_str())?; + list.push(JwksConfig { + url, + issuers: jwks_conf.issuers.clone(), + audiences: jwks_conf.audiences.clone(), + algorithms: jwks_conf + .algorithms + .as_ref() + .map(|algs| algs.iter().cloned().collect()), + poll_interval: jwks_conf.poll_interval, + headers: jwks_conf.headers.clone(), + }); + } - tracing::info!(jwks=?router_conf.jwt.jwks, "JWT authentication using JWKSets from"); + let jwks_manager = JwksManager::new(list).await?; - let jwks_manager = JwksManager::new(list).await?; + Ok(Some(Router { + configuration: router_conf.jwt, + jwks_manager, + })) + } - Some(Router { - configuration: router_conf.jwt, - jwks_manager, - }) - } else { - None + async fn init_connector(init: PluginInit) -> Result, BoxError> { + // if no connector config was defined, then return early + let Some(connector_conf) = init.config.connector.clone() else { + return Ok(None); }; - Ok(Self { router, subgraph }) + let mut signing_params: HashMap> = + Default::default(); + for (s, source_config) in connector_conf.sources { + let source_ref: ConnectorSourceRef = s.parse()?; + signing_params.insert( + source_ref.clone(), + make_signing_params(&source_config, &source_ref.subgraph_name) + .await + .map(Arc::new)?, + ); + } + + Ok(Some(ConnectorAuth { + signing_params: Arc::new(signing_params), + })) } +} - fn router_service(&self, service: router::BoxService) -> router::BoxService { - if let Some(config) = &self.router { - let jwks_manager = config.jwks_manager.clone(); - let configuration = config.configuration.clone(); - - fn authentication_service_span() -> impl Fn(&router::Request) -> tracing::Span + Clone { - move |_request: &router::Request| { - tracing::info_span!( - AUTHENTICATION_SPAN_NAME, - "authentication service" = stringify!(router::Request), - "otel.kind" = "INTERNAL" - ) - } - } +#[derive(Debug, Serialize, Deserialize)] +enum JwtStatus { + Failure { + r#type: String, + name: String, + error: ErrorContext, + }, + Success { + r#type: String, + name: String, + }, +} - ServiceBuilder::new() - .instrument(authentication_service_span()) - .checkpoint(move |request: router::Request| { - Ok(authenticate(&configuration, &jwks_manager, request)) - }) - .service(service) - .boxed() - } else { - service +impl JwtStatus { + fn new_failure(source: Option<&Source>, error_context: ErrorContext) -> Self { + let (r#type, name) = match source { + Some(Source::Header { name, .. }) => ("header", name.as_str()), + Some(Source::Cookie { name }) => ("cookie", name.as_str()), + None => ("unknown", "unknown"), + }; + + Self::Failure { + r#type: r#type.into(), + name: name.into(), + error: error_context, } } - fn subgraph_service( - &self, - name: &str, - service: crate::services::subgraph::BoxService, - ) -> crate::services::subgraph::BoxService { - if let Some(auth) = &self.subgraph { - auth.subgraph_service(name, service) - } else { - service + fn new_success(source: Option<&Source>) -> Self { + match source { + Some(Source::Header { name, .. }) => Self::Success { + r#type: "header".into(), + name: name.into(), + }, + Some(Source::Cookie { name }) => Self::Success { + r#type: "cookie".into(), + name: name.into(), + }, + None => Self::Success { + r#type: "unknown".into(), + name: "unknown".into(), + }, + } + } + + #[cfg(test)] + /// Returns the error context if the status is a failure; Otherwise, returns None. + fn error(&self) -> Option<&ErrorContext> { + match self { + Self::Failure { error, .. } => Some(error), + _ => None, } } } +const JWT_CONTEXT_KEY: &str = "apollo::authentication::jwt_status"; + fn authenticate( config: &JWTConf, jwks_manager: &JwksManager, request: router::Request, ) -> ControlFlow { - const AUTHENTICATION_KIND: &str = "JWT"; - // We are going to do a lot of similar checking so let's define a local function // to help reduce repetition fn failure_message( - context: Context, + request: router::Request, + config: &JWTConf, error: AuthenticationError, status: StatusCode, + source: Option<&Source>, ) -> ControlFlow { // This is a metric and will not appear in the logs - tracing::info!( - monotonic_counter.apollo_authentication_failure_count = 1u64, - kind = %AUTHENTICATION_KIND + let failed = true; + increment_jwt_counter_metric(failed); + + tracing::info!(message = %error, "jwt authentication failure"); + + let _ = request.context.insert_json_value( + JWT_CONTEXT_KEY, + serde_json_bytes::json!(JwtStatus::new_failure(source, error.as_context_object())), ); - tracing::info!( - monotonic_counter - .apollo - .router - .operations - .authentication - .jwt = 1, - authentication.jwt.failed = true + + if config.on_error == OnError::Error { + let response = router::Response::infallible_builder() + .error( + graphql::Error::builder() + .message(error.to_string()) + .extension_code("AUTH_ERROR") + .build(), + ) + .status_code(status) + .header(header::CONTENT_TYPE, APPLICATION_JSON_HEADER_VALUE.clone()) + .context(request.context) + .build(); + + ControlFlow::Break(response) + } else { + ControlFlow::Continue(request) + } + } + + /// This is the documented metric + fn increment_jwt_counter_metric(failed: bool) { + u64_counter!( + "apollo.router.operations.authentication.jwt", + "Number of requests with JWT authentication", + 1, + authentication.jwt.failed = failed ); - tracing::info!(message = %error, "jwt authentication failure"); - let response = router::Response::infallible_builder() - .error( - graphql::Error::builder() - .message(error.to_string()) - .extension_code("AUTH_ERROR") - .build(), - ) - .status_code(status) - .header(header::CONTENT_TYPE, APPLICATION_JSON_HEADER_VALUE.clone()) - .context(context) - .build(); - ControlFlow::Break(response) } let mut jwt = None; + let mut source_of_extracted_jwt = None; for source in &config.sources { - match extract_jwt( + let extracted_jwt = jwks::extract_jwt( source, config.ignore_other_prefixes, request.router_request.headers(), - ) { + ); + + match extracted_jwt { None => continue, - Some(Err(error)) => { - return failure_message(request.context, error, StatusCode::BAD_REQUEST) - } Some(Ok(extracted_jwt)) => { + source_of_extracted_jwt = Some(source); jwt = Some(extracted_jwt); break; } + Some(Err(error)) => { + return failure_message( + request, + config, + error, + StatusCode::BAD_REQUEST, + Some(source), + ); + } } } @@ -607,15 +535,17 @@ fn authenticate( // Don't reflect the jwt on error, just reply with a fixed // error message. return failure_message( - request.context, - AuthenticationError::InvalidHeader(HEADER_TOKEN_TRUNCATED, e), + request, + config, + AuthenticationError::InvalidHeader(HEADER_TOKEN_TRUNCATED.to_owned(), e), StatusCode::BAD_REQUEST, + source_of_extracted_jwt, ); } }; // Extract our search criteria from our jwt - let criteria = JWTCriteria { + let criteria = jwks::JWTCriteria { kid: jwt_header.kid, alg: jwt_header.alg, }; @@ -623,310 +553,159 @@ fn authenticate( // Search our list of JWKS to find the kid and process it // Note: This will search through JWKS in the order in which they are defined // in configuration. - if let Some(keys) = search_jwks(jwks_manager, &criteria) { - let (issuer, token_data) = match decode_jwt(jwt, keys, criteria) { + if let Some(keys) = jwks::search_jwks(jwks_manager, &criteria) { + let (issuers, audiences, token_data) = match jwks::decode_jwt(jwt, keys, criteria) { Ok(data) => data, Err((auth_error, status_code)) => { - return failure_message(request.context, auth_error, status_code); + return failure_message( + request, + config, + auth_error, + status_code, + source_of_extracted_jwt, + ); } }; - if let Some(configured_issuer) = issuer { - if let Some(token_issuer) = token_data + if let Some(configured_issuers) = issuers + && let Some(token_issuer) = token_data .claims .as_object() .and_then(|o| o.get("iss")) .and_then(|value| value.as_str()) - { - if configured_issuer != token_issuer { + && !configured_issuers.contains(token_issuer) + { + let mut issuers_for_error: Vec = configured_issuers.into_iter().collect(); + issuers_for_error.sort(); // done to maintain consistent ordering in error message + return failure_message( + request, + config, + AuthenticationError::InvalidIssuer { + expected: issuers_for_error + .iter() + .map(|issuer| issuer.to_string()) + .collect::>() + .join(", "), + token: token_issuer.to_string(), + }, + StatusCode::INTERNAL_SERVER_ERROR, + source_of_extracted_jwt, + ); + } + + if let Some(configured_audiences) = audiences { + let maybe_token_audiences = token_data.claims.as_object().and_then(|o| o.get("aud")); + let Some(maybe_token_audiences) = maybe_token_audiences else { + let mut audiences_for_error: Vec = + configured_audiences.into_iter().collect(); + audiences_for_error.sort(); // done to maintain consistent ordering in error message + return failure_message( + request, + config, + AuthenticationError::InvalidAudience { + expected: audiences_for_error + .iter() + .map(|audience| audience.to_string()) + .collect::>() + .join(", "), + actual: "".to_string(), + }, + StatusCode::UNAUTHORIZED, + source_of_extracted_jwt, + ); + }; + + if let Some(token_audience) = maybe_token_audiences.as_str() { + if !configured_audiences.contains(token_audience) { + let mut audiences_for_error: Vec = + configured_audiences.into_iter().collect(); + audiences_for_error.sort(); // done to maintain consistent ordering in error message return failure_message( - request.context, - AuthenticationError::InvalidIssuer { - expected: configured_issuer, - token: token_issuer.to_string(), + request, + config, + AuthenticationError::InvalidAudience { + expected: audiences_for_error + .iter() + .map(|audience| audience.to_string()) + .collect::>() + .join(", "), + actual: token_audience.to_string(), }, - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::UNAUTHORIZED, + source_of_extracted_jwt, ); } + } else { + // If the token has incorrectly configured audiences, we cannot validate it against + // the configured audiences. + let mut audiences_for_error: Vec = + configured_audiences.into_iter().collect(); + audiences_for_error.sort(); // done to maintain consistent ordering in error message + return failure_message( + request, + config, + AuthenticationError::InvalidAudience { + expected: audiences_for_error + .iter() + .map(|audience| audience.to_string()) + .collect::>() + .join(", "), + actual: maybe_token_audiences.to_string(), + }, + StatusCode::UNAUTHORIZED, + source_of_extracted_jwt, + ); } } if let Err(e) = request .context - .insert(APOLLO_AUTHENTICATION_JWT_CLAIMS, token_data.claims) + .insert(APOLLO_AUTHENTICATION_JWT_CLAIMS, token_data.claims.clone()) { return failure_message( - request.context, + request, + config, AuthenticationError::CannotInsertClaimsIntoContext(e), StatusCode::INTERNAL_SERVER_ERROR, + source_of_extracted_jwt, ); } // This is a metric and will not appear in the logs - tracing::info!( - monotonic_counter.apollo_authentication_success_count = 1u64, - kind = %AUTHENTICATION_KIND + // + // Apparently intended to be `apollo.router.operations.authentication.jwt` like above, + // but has existed for two years with a buggy name. Keep it for now. + u64_counter!( + "apollo.router.operations.jwt", + "Number of requests with JWT successful authentication (deprecated, \ + use `apollo.router.operations.authentication.jwt` \ + with `authentication.jwt.failed = false` instead)", + 1 ); - tracing::info!(monotonic_counter.apollo.router.operations.jwt = 1u64); - return ControlFlow::Continue(request); - } - - // We can't find a key to process this JWT. - if criteria.kid.is_some() { - failure_message( - request.context, - AuthenticationError::CannotFindKID(criteria.kid), - StatusCode::UNAUTHORIZED, - ) - } else { - failure_message( - request.context, - AuthenticationError::CannotFindSuitableKey(criteria.alg, criteria.kid), - StatusCode::UNAUTHORIZED, - ) - } -} - -fn extract_jwt<'a, 'b: 'a>( - source: &'a Source, - ignore_other_prefixes: bool, - headers: &'b HeaderMap, -) -> Option>> { - match source { - Source::Header { name, value_prefix } => { - // The http_request is stored in a `Router::Request` context. - // We are going to check the headers for the presence of the configured header - let jwt_value_result = match headers.get(name) { - Some(value) => value.to_str(), - None => return None, - }; + // Use the fixed name too: + let failed = false; + increment_jwt_counter_metric(failed); - // If we find the header, but can't convert it to a string, let the client know - let jwt_value_untrimmed = match jwt_value_result { - Ok(value) => value, - Err(_not_a_string_error) => { - return Some(Err(AuthenticationError::CannotConvertToString)); - } - }; - - // Let's trim out leading and trailing whitespace to be accommodating - let jwt_value = jwt_value_untrimmed.trim(); - - // Make sure the format of our message matches our expectations - // Technically, the spec is case sensitive, but let's accept - // case variations - // - let prefix_len = value_prefix.len(); - if jwt_value.len() < prefix_len - || !&jwt_value[..prefix_len].eq_ignore_ascii_case(value_prefix) - { - if ignore_other_prefixes { - return None; - } else { - return Some(Err(AuthenticationError::InvalidPrefix( - jwt_value_untrimmed, - value_prefix, - ))); - } - } - // If there's no header prefix, we need to avoid splitting the header - let jwt = if value_prefix.is_empty() { - // check for whitespace- we've already trimmed, so this means the request has a prefix that shouldn't exist - if jwt_value.contains(' ') { - return Some(Err(AuthenticationError::InvalidPrefix( - jwt_value_untrimmed, - value_prefix, - ))); - } - - // we can simply assign the jwt to the jwt_value; we'll validate down below - jwt_value - } else { - // Otherwise, we need to split our string in (at most 2) sections. - let jwt_parts: Vec<&str> = jwt_value.splitn(2, ' ').collect(); - if jwt_parts.len() != 2 { - return Some(Err(AuthenticationError::MissingJWT(jwt_value))); - } - - // We have our jwt - jwt_parts[1] - }; - Some(Ok(jwt)) - } - Source::Cookie { name } => { - for header in headers.get_all("cookie") { - let value = match header.to_str() { - Ok(value) => value, - Err(_not_a_string_error) => { - return Some(Err(AuthenticationError::CannotConvertToString)); - } - }; - for cookie in cookie::Cookie::split_parse(value) { - match cookie { - Err(_) => continue, - Ok(cookie) => { - if cookie.name() == name { - if let Some(value) = cookie.value_raw() { - return Some(Ok(value)); - } - } - } - } - } - } - - None - } - } -} - -fn decode_jwt( - jwt: &str, - keys: Vec<(Option, Jwk)>, - criteria: JWTCriteria, -) -> Result<(Option, TokenData), (AuthenticationError, StatusCode)> { - let mut error = None; - for (issuer, jwk) in keys.into_iter() { - let decoding_key = match DecodingKey::from_jwk(&jwk) { - Ok(k) => k, - Err(e) => { - error = Some(( - AuthenticationError::CannotCreateDecodingKey(e), - StatusCode::INTERNAL_SERVER_ERROR, - )); - continue; - } - }; - - let key_algorithm = match jwk.common.key_algorithm { - Some(a) => a, - None => { - error = Some(( - AuthenticationError::JWKHasNoAlgorithm, - StatusCode::INTERNAL_SERVER_ERROR, - )); - continue; - } - }; - - let algorithm = match convert_key_algorithm(key_algorithm) { - Some(a) => a, - None => { - error = Some(( - AuthenticationError::UnsupportedKeyAlgorithm(key_algorithm), - StatusCode::INTERNAL_SERVER_ERROR, - )); - continue; - } - }; - - let mut validation = Validation::new(algorithm); - validation.validate_nbf = true; - // if set to true, it will reject tokens containing an `aud` claim if the validation does not specify an audience - // we don't validate audience yet, so this is deactivated - validation.validate_aud = false; - - match decode::(jwt, &decoding_key, &validation) { - Ok(v) => return Ok((issuer, v)), - Err(e) => { - error = Some(( - AuthenticationError::CannotDecodeJWT(e), - StatusCode::UNAUTHORIZED, - )); - } - }; - } - - match error { - Some(e) => Err(e), - None => { - // We can't find a key to process this JWT. - if criteria.kid.is_some() { - Err(( - AuthenticationError::CannotFindKID(criteria.kid), - StatusCode::UNAUTHORIZED, - )) - } else { - Err(( - AuthenticationError::CannotFindSuitableKey(criteria.alg, criteria.kid), - StatusCode::UNAUTHORIZED, - )) - } - } - } -} + let _ = request.context.insert_json_value( + JWT_CONTEXT_KEY, + serde_json_bytes::json!(JwtStatus::new_success(source_of_extracted_jwt)), + ); -pub(crate) fn jwt_expires_in(context: &Context) -> Duration { - let claims = context - .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) - .map_err(|err| tracing::error!("could not read JWT claims: {err}")) - .ok() - .flatten(); - let ts_opt = claims.as_ref().and_then(|x: &Value| { - if !x.is_object() { - tracing::error!("JWT claims should be an object"); - return None; - } - let claims = x.as_object().expect("claims should be an object"); - let exp = claims.get("exp")?; - if !exp.is_number() { - tracing::error!("JWT 'exp' (expiry) claim should be a number"); - return None; - } - exp.as_i64() - }); - match ts_opt { - Some(ts) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("we should not run before EPOCH") - .as_secs() as i64; - if now < ts { - Duration::from_secs((ts - now) as u64) - } else { - Duration::ZERO - } - } - None => Duration::MAX, + return ControlFlow::Continue(request); } -} -//Apparently the jsonwebtoken crate now has 2 different enums for algorithms -pub(crate) fn convert_key_algorithm(algorithm: KeyAlgorithm) -> Option { - Some(match algorithm { - jsonwebtoken::jwk::KeyAlgorithm::HS256 => jsonwebtoken::Algorithm::HS256, - jsonwebtoken::jwk::KeyAlgorithm::HS384 => jsonwebtoken::Algorithm::HS384, - jsonwebtoken::jwk::KeyAlgorithm::HS512 => jsonwebtoken::Algorithm::HS512, - jsonwebtoken::jwk::KeyAlgorithm::ES256 => jsonwebtoken::Algorithm::ES256, - jsonwebtoken::jwk::KeyAlgorithm::ES384 => jsonwebtoken::Algorithm::ES384, - jsonwebtoken::jwk::KeyAlgorithm::RS256 => jsonwebtoken::Algorithm::RS256, - jsonwebtoken::jwk::KeyAlgorithm::RS384 => jsonwebtoken::Algorithm::RS384, - jsonwebtoken::jwk::KeyAlgorithm::RS512 => jsonwebtoken::Algorithm::RS512, - jsonwebtoken::jwk::KeyAlgorithm::PS256 => jsonwebtoken::Algorithm::PS256, - jsonwebtoken::jwk::KeyAlgorithm::PS384 => jsonwebtoken::Algorithm::PS384, - jsonwebtoken::jwk::KeyAlgorithm::PS512 => jsonwebtoken::Algorithm::PS512, - jsonwebtoken::jwk::KeyAlgorithm::EdDSA => jsonwebtoken::Algorithm::EdDSA, - // we do not use the encryption algorithms - jsonwebtoken::jwk::KeyAlgorithm::RSA1_5 - | jsonwebtoken::jwk::KeyAlgorithm::RSA_OAEP - | jsonwebtoken::jwk::KeyAlgorithm::RSA_OAEP_256 => return None, - }) -} + // We can't find a key to process this JWT. + let err = criteria.kid.map_or_else( + || AuthenticationError::CannotFindSuitableKey(criteria.alg, None), + AuthenticationError::CannotFindKID, + ); -pub(crate) fn convert_algorithm(algorithm: Algorithm) -> KeyAlgorithm { - match algorithm { - jsonwebtoken::Algorithm::HS256 => jsonwebtoken::jwk::KeyAlgorithm::HS256, - jsonwebtoken::Algorithm::HS384 => jsonwebtoken::jwk::KeyAlgorithm::HS384, - jsonwebtoken::Algorithm::HS512 => jsonwebtoken::jwk::KeyAlgorithm::HS512, - jsonwebtoken::Algorithm::ES256 => jsonwebtoken::jwk::KeyAlgorithm::ES256, - jsonwebtoken::Algorithm::ES384 => jsonwebtoken::jwk::KeyAlgorithm::ES384, - jsonwebtoken::Algorithm::RS256 => jsonwebtoken::jwk::KeyAlgorithm::RS256, - jsonwebtoken::Algorithm::RS384 => jsonwebtoken::jwk::KeyAlgorithm::RS384, - jsonwebtoken::Algorithm::RS512 => jsonwebtoken::jwk::KeyAlgorithm::RS512, - jsonwebtoken::Algorithm::PS256 => jsonwebtoken::jwk::KeyAlgorithm::PS256, - jsonwebtoken::Algorithm::PS384 => jsonwebtoken::jwk::KeyAlgorithm::PS384, - jsonwebtoken::Algorithm::PS512 => jsonwebtoken::jwk::KeyAlgorithm::PS512, - jsonwebtoken::Algorithm::EdDSA => jsonwebtoken::jwk::KeyAlgorithm::EdDSA, - } + failure_message( + request, + config, + err, + StatusCode::UNAUTHORIZED, + source_of_extracted_jwt, + ) } // This macro allows us to use it in our plugin registry! @@ -934,4 +713,4 @@ pub(crate) fn convert_algorithm(algorithm: Algorithm) -> KeyAlgorithm { // // In order to keep the plugin names consistent, // we use using the `Reverse domain name notation` -register_plugin!("apollo", "authentication", AuthenticationPlugin); +register_private_plugin!("apollo", "authentication", AuthenticationPlugin); diff --git a/apollo-router/src/plugins/authentication/snapshots/apollo_router__plugins__authentication__tests__parse_failure_logs.snap b/apollo-router/src/plugins/authentication/snapshots/apollo_router__plugins__authentication__tests__parse_failure_logs.snap index 125add8b16..f7f7359ccf 100644 --- a/apollo-router/src/plugins/authentication/snapshots/apollo_router__plugins__authentication__tests__parse_failure_logs.snap +++ b/apollo-router/src/plugins/authentication/snapshots/apollo_router__plugins__authentication__tests__parse_failure_logs.snap @@ -1,5 +1,6 @@ --- source: apollo-router/src/plugins/authentication/tests.rs +assertion_line: 1432 expression: jwks --- keys: @@ -26,4 +27,3 @@ keys: crv: P-256 x: opFUViwCYVZLmsbG2cJTA9uPvOF5Gg8W7uNhrcorGhI y: bPxvCFKmlqTdEFc34OekvpviUUyelGrbi020dlgIsqo - diff --git a/apollo-router/src/plugins/authentication/snapshots/apollo_router__plugins__authentication__tests__parse_failure_logs@logs.snap b/apollo-router/src/plugins/authentication/snapshots/apollo_router__plugins__authentication__tests__parse_failure_logs@logs.snap index f46ba8763c..9cb0c86153 100644 --- a/apollo-router/src/plugins/authentication/snapshots/apollo_router__plugins__authentication__tests__parse_failure_logs@logs.snap +++ b/apollo-router/src/plugins/authentication/snapshots/apollo_router__plugins__authentication__tests__parse_failure_logs@logs.snap @@ -1,5 +1,6 @@ --- source: apollo-router/src/plugins/authentication/tests.rs +assertion_line: 1430 expression: yaml --- - fields: @@ -20,4 +21,3 @@ expression: yaml index: 5 level: WARN message: "ignoring a key since it is not valid, enable debug logs to full content" - diff --git a/apollo-router/src/plugins/authentication/subgraph.rs b/apollo-router/src/plugins/authentication/subgraph.rs index 568aa9c8ac..3cb1123a30 100644 --- a/apollo-router/src/plugins/authentication/subgraph.rs +++ b/apollo-router/src/plugins/authentication/subgraph.rs @@ -1,37 +1,45 @@ use std::collections::HashMap; use std::sync::Arc; -use std::sync::RwLock; use std::time::Duration; use std::time::SystemTime; -use aws_credential_types::provider::error::CredentialsError; -use aws_credential_types::provider::ProvideCredentials; +use aws_config::provider_config::ProviderConfig; use aws_credential_types::Credentials; -use aws_sigv4::http_request::sign; +use aws_credential_types::provider::ProvideCredentials; +use aws_credential_types::provider::error::CredentialsError; use aws_sigv4::http_request::PayloadChecksumKind; use aws_sigv4::http_request::SignableBody; use aws_sigv4::http_request::SignableRequest; use aws_sigv4::http_request::SigningSettings; +use aws_sigv4::http_request::sign; +use aws_smithy_async::rt::sleep::TokioSleep; +use aws_smithy_async::time::SystemTimeSource; +use aws_smithy_http_client::tls::Provider; +use aws_smithy_http_client::tls::rustls_provider::CryptoMode; +use aws_smithy_runtime_api::client::behavior_version::BehaviorVersion; use aws_smithy_runtime_api::client::identity::Identity; +use aws_types::SdkConfig; use aws_types::region::Region; use aws_types::sdk_config::SharedCredentialsProvider; use http::HeaderMap; use http::Request; +use parking_lot::RwLock; use schemars::JsonSchema; use serde::Deserialize; +use serde::Serialize; use tokio::sync::mpsc::Sender; use tokio::task::JoinHandle; use tower::BoxError; use tower::ServiceBuilder; use tower::ServiceExt; -use crate::services::router::body::get_body_bytes; -use crate::services::router::body::RouterBody; use crate::services::SubgraphRequest; +use crate::services::router; +use crate::services::router::body::RouterBody; /// Hardcoded Config using access_key and secret. /// Prefer using DefaultChain instead. -#[derive(Clone, JsonSchema, Deserialize, Debug)] +#[derive(Clone, JsonSchema, Deserialize, Serialize, Debug)] #[serde(rename_all = "snake_case", deny_unknown_fields)] pub(crate) struct AWSSigV4HardcodedConfig { /// The ID for this access key. @@ -64,7 +72,7 @@ impl ProvideCredentials for AWSSigV4HardcodedConfig { } /// Configuration of the DefaultChainProvider -#[derive(Clone, JsonSchema, Deserialize, Debug)] +#[derive(Clone, JsonSchema, Deserialize, Serialize, Debug)] #[serde(deny_unknown_fields)] pub(crate) struct DefaultChainConfig { /// The AWS region this chain applies to. @@ -78,7 +86,7 @@ pub(crate) struct DefaultChainConfig { } /// Specify assumed role configuration. -#[derive(Clone, JsonSchema, Deserialize, Debug)] +#[derive(Clone, JsonSchema, Deserialize, Serialize, Debug)] #[serde(deny_unknown_fields)] pub(crate) struct AssumeRoleProvider { /// Amazon Resource Name (ARN) @@ -91,7 +99,7 @@ pub(crate) struct AssumeRoleProvider { } /// Configure AWS sigv4 auth. -#[derive(Clone, JsonSchema, Deserialize, Debug)] +#[derive(Clone, JsonSchema, Deserialize, Serialize, Debug)] #[serde(rename_all = "snake_case")] pub(crate) enum AWSSigV4Config { Hardcoded(AWSSigV4HardcodedConfig), @@ -105,6 +113,18 @@ impl AWSSigV4Config { let role_provider_builder = self.assume_role().map(|assume_role_provider| { let rp = aws_config::sts::AssumeRoleProvider::builder(assume_role_provider.role_arn.clone()) + .configure( + &SdkConfig::builder() + .http_client( + aws_smithy_http_client::Builder::new() + .tls_provider(Provider::Rustls(CryptoMode::Ring)) + .build_https(), + ) + .sleep_impl(TokioSleep::new()) + .time_source(SystemTimeSource::new()) + .behavior_version(BehaviorVersion::latest()) + .build(), + ) .session_name(assume_role_provider.session_name.clone()) .region(region.clone()); if let Some(external_id) = &assume_role_provider.external_id { @@ -116,9 +136,7 @@ impl AWSSigV4Config { match self { Self::DefaultChain(config) => { - let aws_config = - aws_config::default_provider::credentials::DefaultCredentialsChain::builder() - .region(region.clone()); + let aws_config = credentials_chain_builder().region(region.clone()); let aws_config = if let Some(profile_name) = &config.profile_name { aws_config.profile_name(profile_name.as_str()) @@ -134,10 +152,7 @@ impl AWSSigV4Config { } } Self::Hardcoded(config) => { - let chain = - aws_config::default_provider::credentials::DefaultCredentialsChain::builder() - .build() - .await; + let chain = credentials_chain_builder().build().await; if let Some(assume_role_provider) = role_provider_builder { Arc::new(assume_role_provider.build_from_provider(chain).await) } else { @@ -170,7 +185,20 @@ impl AWSSigV4Config { } } -#[derive(Clone, Debug, JsonSchema, Deserialize)] +fn credentials_chain_builder() -> aws_config::default_provider::credentials::Builder { + aws_config::default_provider::credentials::DefaultCredentialsChain::builder().configure( + ProviderConfig::default() + .with_http_client( + aws_smithy_http_client::Builder::new() + .tls_provider(Provider::Rustls(CryptoMode::Ring)) + .build_https(), + ) + .with_sleep_impl(TokioSleep::new()) + .with_time_source(SystemTimeSource::new()), + ) +} + +#[derive(Clone, Debug, JsonSchema, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub(crate) enum AuthConfig { #[serde(rename = "aws_sig_v4")] @@ -263,9 +291,7 @@ async fn refresh_credentials( ) -> Option { match credentials_provider.provide_credentials().await { Ok(new_credentials) => { - let mut credentials = credentials - .write() - .expect("authentication: credentials RwLock poisoned"); + let mut credentials = credentials.write(); *credentials = new_credentials; next_refresh_timer(&credentials) } @@ -296,7 +322,6 @@ impl ProvideCredentials for CredentialsProvider { aws_credential_types::provider::future::ProvideCredentials::ready(Ok(self .credentials .read() - .expect("authentication: credentials RwLock poisoned") .clone())) } } @@ -314,7 +339,7 @@ impl SigningParamsConfig { // We'll go with default signed headers let headers = HeaderMap::<&'static str>::default(); // UnsignedPayload only applies to lattice - let body_bytes = get_body_bytes(body).await?.to_vec(); + let body_bytes = router::body::into_bytes(body).await?.to_vec(); let signable_request = SignableRequest::new( parts.method.as_str(), parts.uri.to_string(), @@ -330,13 +355,13 @@ impl SigningParamsConfig { let (signing_instructions, _signature) = sign(signable_request, &signing_params.into()) .map_err(|err| { increment_failure_counter(subgraph_name); - let error = format!("failed to sign GraphQL body for AWS SigV4: {}", err); + let error = format!("failed to sign GraphQL body for AWS SigV4: {err}"); tracing::error!("{}", error); error })? .into_parts(); - req = Request::::from_parts(parts, body_bytes.into()); - signing_instructions.apply_to_request_http0x(&mut req); + req = Request::::from_parts(parts, router::body::from_bytes(body_bytes)); + signing_instructions.apply_to_request_http1x(&mut req); increment_success_counter(subgraph_name); Ok(req) } @@ -369,13 +394,13 @@ impl SigningParamsConfig { let (signing_instructions, _signature) = sign(signable_request, &signing_params.into()) .map_err(|err| { increment_failure_counter(subgraph_name); - let error = format!("failed to sign GraphQL body for AWS SigV4: {}", err); + let error = format!("failed to sign GraphQL body for AWS SigV4: {err}"); tracing::error!("{}", error); error })? .into_parts(); req = Request::<()>::from_parts(parts, ()); - signing_instructions.apply_to_request_http0x(&mut req); + signing_instructions.apply_to_request_http1x(&mut req); increment_success_counter(subgraph_name); Ok(req) } @@ -400,7 +425,7 @@ impl SigningParamsConfig { .await .map_err(|err| { increment_failure_counter(self.subgraph_name.as_str()); - let error = format!("failed to get credentials for AWS SigV4 signing: {}", err); + let error = format!("failed to get credentials for AWS SigV4 signing: {err}"); tracing::error!("{}", error); error.into() }) @@ -409,17 +434,21 @@ impl SigningParamsConfig { } fn increment_success_counter(subgraph_name: &str) { - tracing::info!( - monotonic_counter.apollo.router.operations.authentication.aws.sigv4 = 1u64, + u64_counter!( + "apollo.router.operations.authentication.aws.sigv4", + "Number of subgraph requests signed with AWS SigV4", + 1, authentication.aws.sigv4.failed = false, - subgraph.service.name = %subgraph_name, + subgraph.service.name = subgraph_name.to_string() ); } fn increment_failure_counter(subgraph_name: &str) { - tracing::info!( - monotonic_counter.apollo.router.operations.authentication.aws.sigv4 = 1u64, + u64_counter!( + "apollo.router.operations.authentication.aws.sigv4", + "Number of subgraph requests signed with AWS SigV4", + 1, authentication.aws.sigv4.failed = true, - subgraph.service.name = %subgraph_name, + subgraph.service.name = subgraph_name.to_string() ); } @@ -471,7 +500,7 @@ impl SubgraphAuth { let signing_params = signing_params.clone(); req.context .extensions() - .with_lock(|mut lock| lock.insert(signing_params)); + .with_lock(|lock| lock.insert(signing_params)); req }) .service(service) @@ -494,9 +523,9 @@ impl SubgraphAuth { #[cfg(test)] mod test { + use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; - use std::sync::Arc; use http::header::CONTENT_LENGTH; use http::header::CONTENT_TYPE; @@ -505,13 +534,13 @@ mod test { use tower::Service; use super::*; + use crate::Context; use crate::graphql::Request; use crate::plugin::test::MockSubgraphService; use crate::query_planner::fetch::OperationKind; - use crate::services::subgraph::SubgraphRequestId; use crate::services::SubgraphRequest; use crate::services::SubgraphResponse; - use crate::Context; + use crate::services::subgraph::SubgraphRequestId; async fn test_signing_settings(service_name: &str) -> SigningSettings { let params: SigningParamsConfig = make_signing_params( @@ -806,7 +835,7 @@ mod test { Ok(SubgraphResponse::new_from_response( http::Response::default(), Context::new(), - req.subgraph_name.unwrap_or_else(|| String::from("test")), + req.subgraph_name, SubgraphRequestId(String::new()), )) } @@ -837,6 +866,7 @@ mod test { ) .operation_kind(OperationKind::Query) .context(Context::new()) + .subgraph_name(String::default()) .build() } @@ -853,7 +883,7 @@ mod test { let http_request = request .clone() .subgraph_request - .map(|body| RouterBody::from(serde_json::to_string(&body).unwrap())); + .map(|body| router::body::from_bytes(serde_json::to_string(&body).unwrap())); std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); diff --git a/apollo-router/src/plugins/authentication/tests.rs b/apollo-router/src/plugins/authentication/tests.rs index 258c324da7..17a0b05dac 100644 --- a/apollo-router/src/plugins/authentication/tests.rs +++ b/apollo-router/src/plugins/authentication/tests.rs @@ -1,43 +1,71 @@ use std::collections::HashMap; use std::collections::HashSet; -use std::io; +use std::ops::ControlFlow; use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use std::sync::Arc; +use std::time::Duration; -use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use axum::handler::HandlerWithoutStateExt; use base64::Engine as _; -use http::header::CONTENT_TYPE; -use hyper::server::conn::AddrIncoming; -use hyper::service::make_service_fn; -use hyper::service::service_fn; -use hyper::Server; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use http::HeaderMap; +use http::HeaderName; +use http::HeaderValue; +use http::StatusCode; +use http_body_util::BodyExt; use insta::assert_yaml_snapshot; +use jsonwebtoken::Algorithm; +use jsonwebtoken::EncodingKey; use jsonwebtoken::encode; use jsonwebtoken::get_current_timestamp; +use jsonwebtoken::jwk::AlgorithmParameters; use jsonwebtoken::jwk::CommonParameters; +use jsonwebtoken::jwk::EllipticCurve; use jsonwebtoken::jwk::EllipticCurveKeyParameters; use jsonwebtoken::jwk::EllipticCurveKeyType; +use jsonwebtoken::jwk::Jwk; use jsonwebtoken::jwk::JwkSet; -use jsonwebtoken::EncodingKey; +use jsonwebtoken::jwk::KeyAlgorithm; +use jsonwebtoken::jwk::KeyOperations; +use jsonwebtoken::jwk::PublicKeyUse; use mime::APPLICATION_JSON; use p256::ecdsa::SigningKey; +use p256::ecdsa::signature::rand_core::OsRng; use p256::pkcs8::EncodePrivateKey; -use rand_core::OsRng; +use serde::Deserialize; use serde::Serialize; -use serde_json::Value; +use tower::ServiceExt; use tracing::subscriber; +use url::Url; +use super::APOLLO_AUTHENTICATION_JWT_CLAIMS; +use super::HEADER_TOKEN_TRUNCATED; use super::Header; -use super::*; +use super::JWT_CONTEXT_KEY; +use super::JWTConf; +use super::JwtStatus; +use super::Source; +use super::authenticate; +use crate::assert_errors_eq_ignoring_id; +use crate::assert_response_eq_ignoring_error_id; use crate::assert_snapshot_subscriber; +use crate::graphql; use crate::plugin::test; +use crate::plugins::authentication::Issuers; +use crate::plugins::authentication::jwks::Audiences; +use crate::plugins::authentication::jwks::JWTCriteria; +use crate::plugins::authentication::jwks::JwksConfig; +use crate::plugins::authentication::jwks::JwksManager; use crate::plugins::authentication::jwks::parse_jwks; -use crate::services::router::body::get_body_bytes; +use crate::plugins::authentication::jwks::search_jwks; +use crate::services::router; +use crate::services::router::body::RouterBody; use crate::services::supergraph; -fn create_an_url(filename: &str) -> String { +pub(crate) fn create_an_url(filename: &str) -> String { let jwks_base = Path::new("tests"); let jwks_path = jwks_base.join("fixtures").join(filename); @@ -48,7 +76,7 @@ fn create_an_url(filename: &str) -> String { } async fn build_a_default_test_harness() -> router::BoxCloneService { - build_a_test_harness(None, None, false, false).await + build_a_test_harness(None, None, false, false, false).await } async fn build_a_test_harness( @@ -56,6 +84,7 @@ async fn build_a_test_harness( header_value_prefix: Option, multiple_jwks: bool, ignore_other_prefixes: bool, + continue_on_error: bool, ) -> router::BoxCloneService { // create a mock service we will use to test our plugin let mut mock_service = test::MockSupergraphService::new(); @@ -126,13 +155,21 @@ async fn build_a_test_harness( config["authentication"]["router"]["jwt"]["ignore_other_prefixes"] = serde_json::Value::Bool(ignore_other_prefixes); - crate::TestHarness::builder() + if continue_on_error { + config["authentication"]["router"]["jwt"]["on_error"] = + serde_json::Value::String("Continue".to_string()); + } + + match crate::TestHarness::builder() .configuration_json(config) .unwrap() .supergraph_hook(move |_| mock_service.clone().boxed()) .build_router() .await - .unwrap() + { + Ok(test_harness) => test_harness, + Err(e) => panic!("Failed to build test harness: {e}"), + } } #[tokio::test] @@ -200,7 +237,7 @@ async fn it_rejects_when_there_is_no_auth_header() { .extension_code("AUTH_ERROR") .build(); - assert_eq!(response.errors, vec![expected_error]); + assert_errors_eq_ignoring_id!(response.errors, [expected_error]); assert_eq!(StatusCode::UNAUTHORIZED, service_response.response.status()); } @@ -232,17 +269,20 @@ async fn it_rejects_when_auth_prefix_is_missing() { .unwrap(); let expected_error = graphql::Error::builder() - .message("Header Value: 'invalid' is not correctly formatted. prefix should be 'Bearer'") + .message(format!( + "Value of '{0}' JWT header should be prefixed with 'Bearer'", + http::header::AUTHORIZATION, + )) .extension_code("AUTH_ERROR") .build(); - assert_eq!(response.errors, vec![expected_error]); + assert_errors_eq_ignoring_id!(response.errors, [expected_error]); assert_eq!(StatusCode::BAD_REQUEST, service_response.response.status()); } #[tokio::test] -async fn it_rejects_when_auth_prefix_has_no_jwt() { +async fn it_rejects_when_auth_prefix_has_no_jwt_token() { let test_harness = build_a_default_test_harness().await; // Let's create a request with our operation name @@ -268,11 +308,14 @@ async fn it_rejects_when_auth_prefix_has_no_jwt() { .unwrap(); let expected_error = graphql::Error::builder() - .message("Header Value: 'Bearer' is not correctly formatted. Missing JWT") + .message(format!( + "Value of '{0}' JWT header has only 'Bearer' prefix but no JWT token", + http::header::AUTHORIZATION, + )) .extension_code("AUTH_ERROR") .build(); - assert_eq!(response.errors, vec![expected_error]); + assert_errors_eq_ignoring_id!(response.errors, [expected_error]); assert_eq!(StatusCode::BAD_REQUEST, service_response.response.status()); } @@ -310,7 +353,7 @@ async fn it_rejects_when_auth_prefix_has_invalid_format_jwt() { .extension_code("AUTH_ERROR") .build(); - assert_eq!(response.errors, vec![expected_error]); + assert_errors_eq_ignoring_id!(response.errors, [expected_error]); assert_eq!(StatusCode::BAD_REQUEST, service_response.response.status()); } @@ -345,11 +388,11 @@ async fn it_rejects_when_auth_prefix_has_correct_format_but_invalid_jwt() { .unwrap(); let expected_error = graphql::Error::builder() - .message(format!("'{HEADER_TOKEN_TRUNCATED}' is not a valid JWT header: Base64 error: Invalid last symbol 114, offset 5.")) - .extension_code("AUTH_ERROR") - .build(); + .message(format!("'{HEADER_TOKEN_TRUNCATED}' is not a valid JWT header: Base64 error: Invalid last symbol 114, offset 5.")) + .extension_code("AUTH_ERROR") + .build(); - assert_eq!(response.errors, vec![expected_error]); + assert_errors_eq_ignoring_id!(response.errors, [expected_error]); assert_eq!(StatusCode::BAD_REQUEST, service_response.response.status()); } @@ -388,7 +431,7 @@ async fn it_rejects_when_auth_prefix_has_correct_format_and_invalid_jwt() { .extension_code("AUTH_ERROR") .build(); - assert_eq!(response.errors, vec![expected_error]); + assert_errors_eq_ignoring_id!(response.errors, [expected_error]); assert_eq!(StatusCode::UNAUTHORIZED, service_response.response.status()); } @@ -433,7 +476,7 @@ async fn it_accepts_when_auth_prefix_has_correct_format_and_valid_jwt() { #[tokio::test] async fn it_accepts_when_auth_prefix_does_not_match_config_and_is_ignored() { - let test_harness = build_a_test_harness(None, None, false, true).await; + let test_harness = build_a_test_harness(None, None, false, true, false).await; // Let's create a request with our operation name let request_with_appropriate_name = supergraph::Request::canned_builder() .header(http::header::AUTHORIZATION, "Basic dXNlcjpwYXNzd29yZA==") @@ -467,7 +510,7 @@ async fn it_accepts_when_auth_prefix_does_not_match_config_and_is_ignored() { #[tokio::test] async fn it_accepts_when_auth_prefix_has_correct_format_multiple_jwks_and_valid_jwt() { - let test_harness = build_a_test_harness(None, None, true, false).await; + let test_harness = build_a_test_harness(None, None, true, false, false).await; // Let's create a request with our operation name let request_with_appropriate_name = supergraph::Request::canned_builder() @@ -506,7 +549,7 @@ async fn it_accepts_when_auth_prefix_has_correct_format_multiple_jwks_and_valid_ #[tokio::test] async fn it_accepts_when_auth_prefix_has_correct_format_and_valid_jwt_custom_auth() { let test_harness = - build_a_test_harness(Some("SOMETHING".to_string()), None, false, false).await; + build_a_test_harness(Some("SOMETHING".to_string()), None, false, false, false).await; // Let's create a request with our operation name let request_with_appropriate_name = supergraph::Request::canned_builder() @@ -545,7 +588,7 @@ async fn it_accepts_when_auth_prefix_has_correct_format_and_valid_jwt_custom_aut #[tokio::test] async fn it_accepts_when_auth_prefix_has_correct_format_and_valid_jwt_custom_prefix() { let test_harness = - build_a_test_harness(None, Some("SOMETHING".to_string()), false, false).await; + build_a_test_harness(None, Some("SOMETHING".to_string()), false, false, false).await; // Let's create a request with our operation name let request_with_appropriate_name = supergraph::Request::canned_builder() @@ -583,7 +626,7 @@ async fn it_accepts_when_auth_prefix_has_correct_format_and_valid_jwt_custom_pre #[tokio::test] async fn it_accepts_when_no_auth_prefix_and_valid_jwt_custom_prefix() { - let test_harness = build_a_test_harness(None, Some("".to_string()), false, false).await; + let test_harness = build_a_test_harness(None, Some("".to_string()), false, false, false).await; // Let's create a request with our operation name let request_with_appropriate_name = supergraph::Request::canned_builder() @@ -619,18 +662,211 @@ async fn it_accepts_when_no_auth_prefix_and_valid_jwt_custom_prefix() { assert_eq!(expected_mock_response_data, response.data.as_ref().unwrap()); } +#[tokio::test] +async fn it_inserts_success_jwt_status_into_context() { + let test_harness = build_a_test_harness(None, None, false, false, false).await; + + // Let's create a request with our operation name + let request_with_appropriate_name = supergraph::Request::canned_builder() + .header( + http::header::AUTHORIZATION, + "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6ImtleTEifQ.eyJleHAiOjEwMDAwMDAwMDAwLCJhbm90aGVyIGNsYWltIjoidGhpcyBpcyBhbm90aGVyIGNsYWltIn0.4GrmfxuUST96cs0YUC0DfLAG218m7vn8fO_ENfXnu5A", + ) + .build() + .unwrap(); + + // ...And call our service stack with it + let mut service_response = test_harness + .oneshot(request_with_appropriate_name.try_into().unwrap()) + .await + .unwrap(); + + let jwt_context = service_response + .context + .get::<_, JwtStatus>(JWT_CONTEXT_KEY) + .expect("deserialization succeeds") + .expect("a context value was set"); + + match jwt_context { + JwtStatus::Success { r#type, name } => { + assert_eq!(r#type, "header"); + assert!(name.eq_ignore_ascii_case("Authorization")); + } + JwtStatus::Failure { .. } => panic!("expected a success but got {jwt_context:?}"), + } + + let response: graphql::Response = serde_json::from_slice( + service_response + .next_response() + .await + .unwrap() + .unwrap() + .to_vec() + .as_slice(), + ) + .unwrap(); + + assert_eq!(response.errors, vec![]); + + assert_eq!(StatusCode::OK, service_response.response.status()); + + let expected_mock_response_data = "response created within the mock"; + // with the expected message + assert_eq!(expected_mock_response_data, response.data.as_ref().unwrap()); + + let jwt_claims = service_response + .context + .get::<_, serde_json::Value>(APOLLO_AUTHENTICATION_JWT_CLAIMS) + .expect("deserialization succeeds") + .expect("a context value was set"); + + assert_eq!( + jwt_claims, + serde_json::json!({ + "exp": 10_000_000_000i64, + "another claim": "this is another claim" + }) + ); +} + +#[tokio::test] +async fn it_inserts_failure_jwt_status_into_context() { + let test_harness = build_a_test_harness(None, None, false, false, false).await; + + // Let's create a request with our operation name + let request_with_appropriate_name = supergraph::Request::canned_builder() + .header( + http::header::AUTHORIZATION, + "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6ImtleTEifQ.eyJleHAiOjEwMDAwMDAwMDAwLCJhbm90aGVyIGNsYWltIjoidGhpcyBpcyBhbm90aGVyIGNsYWltIn0.4GrmfxuUST96cs0YUC0DfLAG218m7vn8fO_ENfXnu5B", + ) + .build() + .unwrap(); + + // ...And call our service stack with it + let mut service_response = test_harness + .oneshot(request_with_appropriate_name.try_into().unwrap()) + .await + .unwrap(); + + let jwt_context = service_response + .context + .get::<_, JwtStatus>(JWT_CONTEXT_KEY) + .expect("deserialization succeeds") + .expect("a context value was set"); + + let error = jwt_context.error(); + match error { + Some(err) => { + assert_eq!(err.code, "CANNOT_DECODE_JWT"); + assert_eq!(err.message, "Cannot decode JWT: InvalidSignature"); + } + None => panic!("expected an error"), + } + + let response: graphql::Response = serde_json::from_slice( + service_response + .next_response() + .await + .unwrap() + .unwrap() + .to_vec() + .as_slice(), + ) + .unwrap(); + + let expected_error = graphql::Error::builder() + .message("Cannot decode JWT: InvalidSignature") + .extension_code("AUTH_ERROR") + .build(); + + assert_errors_eq_ignoring_id!(response.errors, [expected_error]); + + assert_eq!(StatusCode::UNAUTHORIZED, service_response.response.status()); + + let jwt_claims = service_response + .context + .get::<_, serde_json::Value>(APOLLO_AUTHENTICATION_JWT_CLAIMS) + .expect("deserialization succeeds"); + + assert!( + jwt_claims.is_none(), + "because the JWT was invalid, no claims should be set" + ); +} + +#[tokio::test] +async fn it_moves_on_after_jwt_errors_when_configured() { + let test_harness = build_a_test_harness(None, None, false, false, true).await; + + // Let's create a request with our operation name + let request_with_appropriate_name = supergraph::Request::canned_builder() + .header( + http::header::AUTHORIZATION, + "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6ImtleTEifQ.eyJleHAiOjEwMDAwMDAwMDAwLCJhbm90aGVyIGNsYWltIjoidGhpcyBpcyBhbm90aGVyIGNsYWltIn0.4GrmfxuUST96cs0YUC0DfLAG218m7vn8fO_ENfXnu5B", + ) + .build() + .unwrap(); + + // ...And call our service stack with it + let mut service_response = test_harness + .oneshot(request_with_appropriate_name.try_into().unwrap()) + .await + .unwrap(); + + let jwt_context = service_response + .context + .get::<_, JwtStatus>(JWT_CONTEXT_KEY) + .expect("deserialization succeeds") + .expect("a context value was set"); + + let error = jwt_context.error(); + match error { + Some(err) => { + assert_eq!(err.code, "CANNOT_DECODE_JWT"); + assert_eq!(err.message, "Cannot decode JWT: InvalidSignature"); + } + None => panic!("expected an error"), + } + + let response: graphql::Response = serde_json::from_slice( + service_response + .next_response() + .await + .unwrap() + .unwrap() + .to_vec() + .as_slice(), + ) + .unwrap(); + + // JWT decode failure should be ignored + assert_eq!(response.errors, vec![]); + + assert_eq!(StatusCode::OK, service_response.response.status()); + + let jwt_claims = service_response + .context + .get::<_, serde_json::Value>(APOLLO_AUTHENTICATION_JWT_CLAIMS) + .expect("deserialization succeeds"); + + assert!( + jwt_claims.is_none(), + "because the JWT was invalid, no claims should be set" + ); +} + #[tokio::test] #[should_panic] async fn it_panics_when_auth_prefix_has_correct_format_but_contains_whitespace() { let _test_harness = - build_a_test_harness(None, Some("SOMET HING".to_string()), false, false).await; + build_a_test_harness(None, Some("SOMET HING".to_string()), false, false, false).await; } #[tokio::test] #[should_panic] async fn it_panics_when_auth_prefix_has_correct_format_but_contains_trailing_whitespace() { let _test_harness = - build_a_test_harness(None, Some("SOMETHING ".to_string()), false, false).await; + build_a_test_harness(None, Some("SOMETHING ".to_string()), false, false, false).await; } #[tokio::test] @@ -825,7 +1061,8 @@ async fn build_jwks_search_components() -> JwksManager { let url: Url = Url::from_str(s_url).expect("created a valid url"); urls.push(JwksConfig { url, - issuer: None, + issuers: None, + audiences: None, algorithms: None, poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -844,7 +1081,7 @@ async fn it_finds_key_with_criteria_kid_and_algorithm() { alg: Algorithm::HS256, }; - let (_issuer, key) = search_jwks(&jwks_manager, &criteria) + let (_issuer, _audience, key) = search_jwks(&jwks_manager, &criteria) .expect("found a key") .pop() .expect("list isn't empty"); @@ -861,7 +1098,7 @@ async fn it_finds_best_matching_key_with_criteria_algorithm() { alg: Algorithm::HS256, }; - let (_issuer, key) = search_jwks(&jwks_manager, &criteria) + let (_issuer, _audience, key) = search_jwks(&jwks_manager, &criteria) .expect("found a key") .pop() .expect("list isn't empty"); @@ -890,7 +1127,7 @@ async fn it_finds_key_with_criteria_algorithm_ec() { alg: Algorithm::ES256, }; - let (_issuer, key) = search_jwks(&jwks_manager, &criteria) + let (_issuer, _audience, key) = search_jwks(&jwks_manager, &criteria) .expect("found a key") .pop() .expect("list isn't empty"); @@ -910,7 +1147,7 @@ async fn it_finds_key_with_criteria_algorithm_rsa() { alg: Algorithm::RS256, }; - let (_issuer, key) = search_jwks(&jwks_manager, &criteria) + let (_issuer, _audience, key) = search_jwks(&jwks_manager, &criteria) .expect("found a key") .pop() .expect("list isn't empty"); @@ -926,9 +1163,10 @@ struct Claims { sub: String, exp: u64, iss: Option, + aud: Option, } -fn make_manager(jwk: &Jwk, issuer: Option) -> JwksManager { +fn make_manager(jwk: &Jwk, issuers: Option, audiences: Option) -> JwksManager { let jwks = JwkSet { keys: vec![jwk.clone()], }; @@ -936,7 +1174,8 @@ fn make_manager(jwk: &Jwk, issuer: Option) -> JwksManager { let url = Url::from_str("file:///jwks.json").unwrap(); let list = vec![JwksConfig { url: url.clone(), - issuer, + issuers, + audiences, algorithms: None, poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -970,7 +1209,11 @@ async fn issuer_check() { }), }; - let manager = make_manager(&jwk, Some("hello".to_string())); + let manager = make_manager( + &jwk, + Some(HashSet::from(["hello".to_string(), "goodbye".to_string()])), + None, + ); // No issuer let token = encode( @@ -979,6 +1222,7 @@ async fn issuer_check() { sub: "test".to_string(), exp: get_current_timestamp(), iss: None, + aud: None, }, &encoding_key, ) @@ -1000,7 +1244,7 @@ async fn issuer_check() { } ControlFlow::Continue(req) => { println!("got req with issuer check"); - let claims: Value = req + let claims: serde_json::Value = req .context .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) .unwrap() @@ -1016,6 +1260,7 @@ async fn issuer_check() { sub: "test".to_string(), exp: get_current_timestamp(), iss: Some("hello".to_string()), + aud: None, }, &encoding_key, ) @@ -1028,15 +1273,22 @@ async fn issuer_check() { match authenticate(&config, &manager, request.try_into().unwrap()) { ControlFlow::Break(res) => { - let response: graphql::Response = - serde_json::from_slice(&get_body_bytes(res.response.into_body()).await.unwrap()) - .unwrap(); - assert_eq!(response, graphql::Response::builder() - .errors(vec![graphql::Error::builder().extension_code("AUTH_ERROR").message("Invalid issuer: the token's `iss` was 'hallo', but signed with a key from 'hello'").build()]).build()); + let response: graphql::Response = serde_json::from_slice( + &router::body::into_bytes(res.response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + assert_response_eq_ignoring_error_id!(response, graphql::Response::builder() + .errors(vec![graphql::Error::builder() + .extension_code("AUTH_ERROR") + .message("Invalid issuer: the token's `iss` was 'hallo', but signed with a key from JWKS configured to only accept from 'hello'") + .build() + ]).build()); } ControlFlow::Continue(req) => { println!("got req with issuer check"); - let claims: Value = req + let claims: serde_json::Value = req .context .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) .unwrap() @@ -1052,6 +1304,7 @@ async fn issuer_check() { sub: "test".to_string(), exp: get_current_timestamp(), iss: Some("AAAA".to_string()), + aud: None, }, &encoding_key, ) @@ -1064,11 +1317,17 @@ async fn issuer_check() { match authenticate(&config, &manager, request.try_into().unwrap()) { ControlFlow::Break(res) => { - let response: graphql::Response = - serde_json::from_slice(&get_body_bytes(res.response.into_body()).await.unwrap()) - .unwrap(); - assert_eq!(response, graphql::Response::builder() - .errors(vec![graphql::Error::builder().extension_code("AUTH_ERROR").message("Invalid issuer: the token's `iss` was 'AAAA', but signed with a key from 'hello'").build()]).build()); + let response: graphql::Response = serde_json::from_slice( + &router::body::into_bytes(res.response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + assert_response_eq_ignoring_error_id!(response, graphql::Response::builder() + .errors(vec![graphql::Error::builder() + .extension_code("AUTH_ERROR") + .message("Invalid issuer: the token's `iss` was 'AAAA', but signed with a key from JWKS configured to only accept from 'goodbye, hello'") + .build()]).build()); } ControlFlow::Continue(_) => { panic!("issuer check should have failed") @@ -1076,13 +1335,14 @@ async fn issuer_check() { } // no issuer check - let manager = make_manager(&jwk, None); + let manager = make_manager(&jwk, None, None); let token = encode( &jsonwebtoken::Header::new(Algorithm::ES256), &Claims { sub: "test".to_string(), exp: get_current_timestamp(), iss: Some("hello".to_string()), + aud: None, }, &encoding_key, ) @@ -1095,15 +1355,18 @@ async fn issuer_check() { match authenticate(&config, &manager, request.try_into().unwrap()) { ControlFlow::Break(res) => { - let response: graphql::Response = - serde_json::from_slice(&get_body_bytes(res.response.into_body()).await.unwrap()) - .unwrap(); + let response: graphql::Response = serde_json::from_slice( + &router::body::into_bytes(res.response.into_body()) + .await + .unwrap(), + ) + .unwrap(); assert_eq!(response, graphql::Response::builder() - .errors(vec![graphql::Error::builder().extension_code("AUTH_ERROR").message("Invalid issuer: the token's `iss` was 'AAAA', but signed with a key from 'hello'").build()]).build()); + .errors(vec![graphql::Error::builder().extension_code("AUTH_ERROR").message("Invalid issuer: the token's `iss` was 'AAAA', but signed with a key from JWKS configured to only accept from 'hello'").build()]).build()); } ControlFlow::Continue(req) => { println!("got req with issuer check"); - let claims: Value = req + let claims: serde_json::Value = req .context .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) .unwrap() @@ -1113,6 +1376,187 @@ async fn issuer_check() { } } +#[tokio::test] +async fn audience_check() { + let signing_key = SigningKey::random(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + + let encoding_key = EncodingKey::from_ec_der(&signing_key.to_pkcs8_der().unwrap().to_bytes()); + + let jwk = Jwk { + common: CommonParameters { + public_key_use: Some(PublicKeyUse::Signature), + key_operations: Some(vec![KeyOperations::Verify]), + key_algorithm: Some(KeyAlgorithm::ES256), + key_id: Some("hello".to_string()), + ..Default::default() + }, + algorithm: AlgorithmParameters::EllipticCurve(EllipticCurveKeyParameters { + key_type: EllipticCurveKeyType::EC, + curve: EllipticCurve::P256, + x: BASE64_URL_SAFE_NO_PAD.encode(point.x().unwrap()), + y: BASE64_URL_SAFE_NO_PAD.encode(point.y().unwrap()), + }), + }; + + let manager = make_manager( + &jwk, + None, + Some(HashSet::from(["hello".to_string(), "goodbye".to_string()])), + ); + + // No audience + let token = encode( + &jsonwebtoken::Header::new(Algorithm::ES256), + &Claims { + sub: "test".to_string(), + exp: get_current_timestamp(), + aud: None, + iss: None, + }, + &encoding_key, + ) + .unwrap(); + + let request = supergraph::Request::canned_builder() + .header(http::header::AUTHORIZATION, format!("Bearer {token}")) + .build() + .unwrap(); + + let mut config = JWTConf::default(); + config.sources.push(Source::Header { + name: super::default_header_name(), + value_prefix: super::default_header_value_prefix(), + }); + match authenticate(&config, &manager, request.try_into().unwrap()) { + ControlFlow::Break(res) => { + assert_eq!(res.response.status(), StatusCode::UNAUTHORIZED); + let body = res.response.into_body().collect().await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body.to_bytes()).unwrap(); + let expected_body = serde_json::json!({ + "errors": [ + { + "message": "Invalid audience: the token's `aud` was 'null', but 'goodbye, hello' was expected", + "extensions": { + "code": "AUTH_ERROR" + } + } + ] + }); + assert_eq!(body, expected_body); + } + ControlFlow::Continue(_req) => { + panic!("expected a rejection for a lack of audience"); + } + } + + // Valid audience + let token = encode( + &jsonwebtoken::Header::new(Algorithm::ES256), + &Claims { + sub: "test".to_string(), + exp: get_current_timestamp(), + aud: Some("hello".to_string()), + iss: None, + }, + &encoding_key, + ) + .unwrap(); + + let request = supergraph::Request::canned_builder() + .header(http::header::AUTHORIZATION, format!("Bearer {token}")) + .build() + .unwrap(); + + match authenticate(&config, &manager, request.try_into().unwrap()) { + ControlFlow::Break(_res) => { + panic!("expected audience to be valid"); + } + ControlFlow::Continue(req) => { + let claims: serde_json::Value = req + .context + .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) + .unwrap() + .unwrap(); + + assert_eq!(claims["aud"], "hello"); + } + } + + // Invalid audience + let token = encode( + &jsonwebtoken::Header::new(Algorithm::ES256), + &Claims { + sub: "test".to_string(), + exp: get_current_timestamp(), + aud: Some("AAAA".to_string()), + iss: None, + }, + &encoding_key, + ) + .unwrap(); + + let request = supergraph::Request::canned_builder() + .header(http::header::AUTHORIZATION, format!("Bearer {token}")) + .build() + .unwrap(); + + match authenticate(&config, &manager, request.try_into().unwrap()) { + ControlFlow::Break(res) => { + let response: graphql::Response = serde_json::from_slice( + &router::body::into_bytes(res.response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + assert_response_eq_ignoring_error_id!(response, graphql::Response::builder() + .errors(vec![ + graphql::Error::builder() + .extension_code("AUTH_ERROR") + .message("Invalid audience: the token's `aud` was 'AAAA', but 'goodbye, hello' was expected") + .build() + ]).build()); + } + ControlFlow::Continue(_) => { + panic!("audience check should have failed") + } + } + + // no audience check + let manager = make_manager(&jwk, None, None); + let token = encode( + &jsonwebtoken::Header::new(Algorithm::ES256), + &Claims { + sub: "test".to_string(), + exp: get_current_timestamp(), + aud: Some("hello".to_string()), + iss: None, + }, + &encoding_key, + ) + .unwrap(); + + let request = supergraph::Request::canned_builder() + .header(http::header::AUTHORIZATION, format!("Bearer {token}")) + .build() + .unwrap(); + + match authenticate(&config, &manager, request.try_into().unwrap()) { + ControlFlow::Break(_res) => { + panic!("expected audience to be valid"); + } + ControlFlow::Continue(req) => { + let claims: serde_json::Value = req + .context + .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) + .unwrap() + .unwrap(); + assert_eq!(claims["aud"], "hello"); + } + } +} + #[tokio::test] async fn it_rejects_key_with_restricted_algorithm() { let mut sets = vec![]; @@ -1126,7 +1570,8 @@ async fn it_rejects_key_with_restricted_algorithm() { let url: Url = Url::from_str(s_url).expect("created a valid url"); urls.push(JwksConfig { url, - issuer: None, + issuers: None, + audiences: None, algorithms: Some(HashSet::from([Algorithm::RS256])), poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -1158,7 +1603,8 @@ async fn it_rejects_and_accepts_keys_with_restricted_algorithms_and_unknown_jwks let url: Url = Url::from_str(s_url).expect("created a valid url"); urls.push(JwksConfig { url, - issuer: None, + issuers: None, + audiences: None, algorithms: Some(HashSet::from([Algorithm::RS256])), poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -1167,7 +1613,7 @@ async fn it_rejects_and_accepts_keys_with_restricted_algorithms_and_unknown_jwks let jwks_manager = JwksManager::new(urls).await.unwrap(); - // the JWT contains a HMAC key but we configured a restriction to RSA signing + // the JWT contains a HMAC key, but we configured a restriction to RSA signing let criteria = JWTCriteria { kid: None, alg: Algorithm::HS256, @@ -1197,7 +1643,8 @@ async fn it_accepts_key_without_use_or_keyops() { let url: Url = Url::from_str(s_url).expect("created a valid url"); urls.push(JwksConfig { url, - issuer: None, + issuers: None, + audiences: None, algorithms: None, poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -1228,7 +1675,8 @@ async fn it_accepts_elliptic_curve_key_without_alg() { let url: Url = Url::from_str(s_url).expect("created a valid url"); urls.push(JwksConfig { url, - issuer: None, + issuers: None, + audiences: None, algorithms: None, poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -1259,7 +1707,8 @@ async fn it_accepts_rsa_key_without_alg() { let url: Url = Url::from_str(s_url).expect("created a valid url"); urls.push(JwksConfig { url, - issuer: None, + issuers: None, + audiences: None, algorithms: None, poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -1292,44 +1741,30 @@ async fn jwks_send_headers() { let got_header = Arc::new(AtomicBool::new(false)); let gh = got_header.clone(); - let service = make_service_fn(move |_| { - let gh = gh.clone(); + let service = move |headers: HeaderMap| { + println!("got re: {headers:?}"); + let gh: Arc = gh.clone(); async move { - //let gh1 = gh.clone(); - Ok::<_, io::Error>(service_fn(move |req| { - println!("got re: {:?}", req.headers()); - let gh: Arc = gh.clone(); - async move { - if req - .headers() - .get("jwks-authz") - .and_then(|v| v.to_str().ok()) - == Some("user1") - { - gh.store(true, Ordering::Release); - } - Ok::<_, io::Error>( - http::Response::builder() - .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) - .status(StatusCode::OK) - .version(http::Version::HTTP_11) - .body::( - include_str!("testdata/jwks.json").into(), - ) - .unwrap(), - ) - } - })) + if headers.get("jwks-authz").and_then(|v| v.to_str().ok()) == Some("user1") { + gh.store(true, Ordering::Release); + } + http::Response::builder() + .header(http::header::CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .status(StatusCode::OK) + .version(http::Version::HTTP_11) + .body::(router::body::from_bytes(include_str!("testdata/jwks.json"))) + .unwrap() } - }); - let server = Server::builder(AddrIncoming::from_listener(listener).unwrap()).serve(service); - tokio::task::spawn(server); + }; + let server = axum::serve(listener, service.into_make_service()); + tokio::task::spawn(async { server.await.unwrap() }); let url = Url::parse(&format!("http://{socket_addr}/")).unwrap(); let _jwks_manager = JwksManager::new(vec![JwksConfig { url, - issuer: None, + issuers: None, + audiences: None, algorithms: Some(HashSet::from([Algorithm::RS256])), poll_interval: Duration::from_secs(60), headers: vec![Header { diff --git a/apollo-router/src/plugins/authorization/authenticated.rs b/apollo-router/src/plugins/authorization/authenticated.rs index bfcb28c51a..3e7e866d22 100644 --- a/apollo-router/src/plugins/authorization/authenticated.rs +++ b/apollo-router/src/plugins/authorization/authenticated.rs @@ -2,21 +2,21 @@ use std::collections::HashMap; +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::ast; use apollo_compiler::executable; use apollo_compiler::schema; use apollo_compiler::schema::Implementers; -use apollo_compiler::Name; -use apollo_compiler::Node; use tower::BoxError; use crate::json_ext::Path; use crate::json_ext::PathElement; +use crate::spec::Schema; +use crate::spec::TYPENAME; use crate::spec::query::transform; use crate::spec::query::transform::TransformState; use crate::spec::query::traverse; -use crate::spec::Schema; -use crate::spec::TYPENAME; pub(crate) const AUTHENTICATED_DIRECTIVE_NAME: &str = "authenticated"; pub(crate) const AUTHENTICATED_SPEC_BASE_URL: &str = "https://specs.apollo.dev/authenticated"; @@ -96,7 +96,7 @@ impl<'a> AuthenticatedCheckVisitor<'a> { } } -impl<'a> traverse::Visitor for AuthenticatedCheckVisitor<'a> { +impl traverse::Visitor for AuthenticatedCheckVisitor<'_> { fn operation(&mut self, root_type: &str, node: &executable::Operation) -> Result<(), BoxError> { if !self.entity_query { traverse::operation(self, root_type, node) @@ -154,16 +154,15 @@ impl<'a> traverse::Visitor for AuthenticatedCheckVisitor<'a> { parent_type: &str, node: &executable::InlineFragment, ) -> Result<(), BoxError> { - if let Some(name) = &node.type_condition { - if self + if let Some(name) = &node.type_condition + && self .schema .types .get(name) .is_some_and(|type_definition| self.is_type_authenticated(type_definition)) - { - self.found = true; - return Ok(()); - } + { + self.found = true; + return Ok(()); } traverse::inline_fragment(self, parent_type, node) @@ -225,7 +224,7 @@ impl<'a> AuthenticatedVisitor<'a> { t.directives().has(&self.authenticated_directive_name) } - fn implementors(&self, type_name: &str) -> impl Iterator { + fn implementors<'s>(&'s self, type_name: &str) -> impl Iterator + use<'s> { self.implementers_map .get(type_name) .map(|implementers| implementers.iter()) @@ -253,10 +252,10 @@ impl<'a> AuthenticatedVisitor<'a> { } let type_name = field_def.ty.inner_named_type(); - if let Some(type_definition) = self.schema.types.get(type_name) { - if self.implementors_with_different_type_requirements(type_name, type_definition) { - return true; - } + if let Some(type_definition) = self.schema.types.get(type_name) + && self.implementors_with_different_type_requirements(type_name, type_definition) + { + return true; } false } @@ -293,24 +292,24 @@ impl<'a> AuthenticatedVisitor<'a> { parent_type: &str, field: &ast::Field, ) -> bool { - if let Some(t) = self.schema.types.get(parent_type) { - if t.is_interface() { - let mut is_authenticated: Option = None; - - for ty in self.implementors(parent_type) { - if let Ok(f) = self.schema.type_field(ty, &field.name) { - let field_is_authenticated = - f.directives.has(&self.authenticated_directive_name); - match is_authenticated { - Some(other) => { - if field_is_authenticated != other { - return true; - } - } - _ => { - is_authenticated = Some(field_is_authenticated); + if let Some(t) = self.schema.types.get(parent_type) + && t.is_interface() + { + let mut is_authenticated: Option = None; + + for ty in self.implementors(parent_type) { + if let Ok(f) = self.schema.type_field(ty, &field.name) { + let field_is_authenticated = + f.directives.has(&self.authenticated_directive_name); + match is_authenticated { + Some(other) => { + if field_is_authenticated != other { + return true; } } + _ => { + is_authenticated = Some(field_is_authenticated); + } } } } @@ -319,7 +318,7 @@ impl<'a> AuthenticatedVisitor<'a> { } } -impl<'a> transform::Visitor for AuthenticatedVisitor<'a> { +impl transform::Visitor for AuthenticatedVisitor<'_> { fn operation( &mut self, root_type: &str, @@ -523,23 +522,24 @@ impl<'a> transform::Visitor for AuthenticatedVisitor<'a> { #[cfg(test)] mod tests { - use apollo_compiler::ast; use apollo_compiler::Schema; + use apollo_compiler::ast; use multimap::MultiMap; use serde_json_bytes::json; use tower::ServiceExt; + use crate::Context; + use crate::MockedSubgraphs; + use crate::TestHarness; use crate::http_ext::TryIntoHeaderName; use crate::http_ext::TryIntoHeaderValue; use crate::json_ext::Path; use crate::plugin::test::MockSubgraph; + use crate::plugins::authorization::APOLLO_AUTHENTICATION_JWT_CLAIMS; use crate::plugins::authorization::authenticated::AuthenticatedVisitor; use crate::services::router::ClientRequestAccepts; use crate::services::supergraph; use crate::spec::query::transform; - use crate::Context; - use crate::MockedSubgraphs; - use crate::TestHarness; static BASIC_SCHEMA: &str = r#" @@ -627,7 +627,7 @@ mod tests { paths: Vec, } - impl<'a> std::fmt::Display for TestResult<'a> { + impl std::fmt::Display for TestResult<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, @@ -1502,10 +1502,7 @@ mod tests { let context = Context::new(); context - .insert( - "apollo_authentication::JWT::claims", - "placeholder".to_string(), - ) + .insert(APOLLO_AUTHENTICATION_JWT_CLAIMS, "placeholder".to_string()) .unwrap(); let request = supergraph::Request::fake_builder() .query("query { orga(id: 1) { id creatorUser { id name phone } } }") @@ -1584,7 +1581,7 @@ mod tests { let context = Context::new(); /*context .insert( - "apollo_authentication::JWT::claims", + APOLLO_AUTHENTICATION_JWT_CLAIMS, "placeholder".to_string(), ) .unwrap();*/ @@ -1659,13 +1656,13 @@ mod tests { let context = Context::new(); /*context .insert( - "apollo_authentication::JWT::claims", + APOLLO_AUTHENTICATION_JWT_CLAIMS, "placeholder".to_string(), ) .unwrap();*/ let mut headers: MultiMap = MultiMap::new(); headers.insert("Accept".into(), "multipart/mixed;deferSpec=20220824".into()); - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { lock.insert(ClientRequestAccepts { multipart_defer: true, multipart_subscription: true, @@ -1945,7 +1942,7 @@ mod tests { #[tokio::test] async fn introspection_mixed_with_authenticated_fields() { // Note: in https://github.com/apollographql/router/pull/5952/ we moved introspection handling - // before authorization filtering in bridge_query_planner.rs, relying on the fact that queries + // before authorization filtering in query_planner_service.rs, relying on the fact that queries // mixing introspection and concrete fields are not supported, so introspection answers right // away. If this ever changes, we should make sure that unauthorized fields are still properly // filtered out diff --git a/apollo-router/src/plugins/authorization/mod.rs b/apollo-router/src/plugins/authorization/mod.rs index 331641a726..2db06d11a2 100644 --- a/apollo-router/src/plugins/authorization/mod.rs +++ b/apollo-router/src/plugins/authorization/mod.rs @@ -4,8 +4,8 @@ use std::collections::HashMap; use std::collections::HashSet; use std::ops::ControlFlow; -use apollo_compiler::ast; use apollo_compiler::ExecutableDocument; +use apollo_compiler::ast; use http::StatusCode; use schemars::JsonSchema; use serde::Deserialize; @@ -15,18 +15,20 @@ use tower::BoxError; use tower::ServiceBuilder; use tower::ServiceExt; -use self::authenticated::AuthenticatedCheckVisitor; -use self::authenticated::AuthenticatedVisitor; use self::authenticated::AUTHENTICATED_SPEC_BASE_URL; use self::authenticated::AUTHENTICATED_SPEC_VERSION_RANGE; -use self::policy::PolicyExtractionVisitor; -use self::policy::PolicyFilteringVisitor; +use self::authenticated::AuthenticatedCheckVisitor; +use self::authenticated::AuthenticatedVisitor; use self::policy::POLICY_SPEC_BASE_URL; use self::policy::POLICY_SPEC_VERSION_RANGE; -use self::scopes::ScopeExtractionVisitor; -use self::scopes::ScopeFilteringVisitor; +use self::policy::PolicyExtractionVisitor; +use self::policy::PolicyFilteringVisitor; use self::scopes::REQUIRES_SCOPES_SPEC_BASE_URL; use self::scopes::REQUIRES_SCOPES_SPEC_VERSION_RANGE; +use self::scopes::ScopeExtractionVisitor; +use self::scopes::ScopeFilteringVisitor; +use crate::Configuration; +use crate::Context; use crate::error::QueryPlannerError; use crate::error::ServiceBuildError; use crate::graphql; @@ -41,20 +43,19 @@ use crate::register_plugin; use crate::services::execution; use crate::services::layers::query_analysis::ParsedDocumentInner; use crate::services::supergraph; -use crate::spec::query::transform; -use crate::spec::query::traverse; use crate::spec::Schema; use crate::spec::SpecError; -use crate::Configuration; -use crate::Context; +use crate::spec::query::transform; +use crate::spec::query::traverse; pub(crate) mod authenticated; pub(crate) mod policy; pub(crate) mod scopes; -const AUTHENTICATED_KEY: &str = "apollo_authorization::authenticated::required"; -const REQUIRED_SCOPES_KEY: &str = "apollo_authorization::scopes::required"; -const REQUIRED_POLICIES_KEY: &str = "apollo_authorization::policies::required"; +pub(crate) const AUTHENTICATION_REQUIRED_KEY: &str = + "apollo::authorization::authentication_required"; +pub(crate) const REQUIRED_SCOPES_KEY: &str = "apollo::authorization::required_scopes"; +pub(crate) const REQUIRED_POLICIES_KEY: &str = "apollo::authorization::required_policies"; #[derive(Clone, Debug, Default, Hash, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct CacheKeyMetadata { @@ -191,7 +192,7 @@ impl AuthorizationPlugin { false, ); if is_authenticated { - context.insert(AUTHENTICATED_KEY, true).unwrap(); + context.insert(AUTHENTICATION_REQUIRED_KEY, true).unwrap(); } if !scopes.is_empty() { @@ -291,7 +292,7 @@ impl AuthorizationPlugin { .unwrap_or_default(); policies.sort(); - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { lock.insert(CacheKeyMetadata { is_authenticated, scopes, @@ -445,14 +446,19 @@ impl AuthorizationPlugin { if visitor.query_requires_authentication { if is_authenticated { - tracing::debug!("the query contains @authenticated, the request is authenticated, keeping the query"); + tracing::debug!( + "the query contains @authenticated, the request is authenticated, keeping the query" + ); Ok(None) } else { - tracing::debug!("the query contains @authenticated, modified query:\n{modified_query}\nunauthorized paths: {:?}", visitor - .unauthorized_paths - .iter() - .map(|path| path.to_string()) - .collect::>()); + tracing::debug!( + "the query contains @authenticated, modified query:\n{modified_query}\nunauthorized paths: {:?}", + visitor + .unauthorized_paths + .iter() + .map(|path| path.to_string()) + .collect::>() + ); Ok(Some((modified_query, visitor.unauthorized_paths))) } @@ -481,13 +487,14 @@ impl AuthorizationPlugin { let modified_query = transform::document(&mut visitor, doc) .map_err(|e| SpecError::TransformError(e.to_string()))?; if visitor.query_requires_scopes { - tracing::debug!("the query required scopes, the requests present scopes: {scopes:?}, modified query:\n{modified_query}\nunauthorized paths: {:?}", - visitor - .unauthorized_paths - .iter() - .map(|path| path.to_string()) - .collect::>() - ); + tracing::debug!( + "the query required scopes, the requests present scopes: {scopes:?}, modified query:\n{modified_query}\nunauthorized paths: {:?}", + visitor + .unauthorized_paths + .iter() + .map(|path| path.to_string()) + .collect::>() + ); Ok(Some((modified_query, visitor.unauthorized_paths))) } else { tracing::debug!("the query does not require scopes"); @@ -516,13 +523,14 @@ impl AuthorizationPlugin { .map_err(|e| SpecError::TransformError(e.to_string()))?; if visitor.query_requires_policies { - tracing::debug!("the query required policies, the requests present policies: {policies:?}, modified query:\n{modified_query}\nunauthorized paths: {:?}", - visitor - .unauthorized_paths - .iter() - .map(|path| path.to_string()) - .collect::>() - ); + tracing::debug!( + "the query required policies, the requests present policies: {policies:?}, modified query:\n{modified_query}\nunauthorized paths: {:?}", + visitor + .unauthorized_paths + .iter() + .map(|path| path.to_string()) + .collect::>() + ); Ok(Some((modified_query, visitor.unauthorized_paths))) } else { tracing::debug!("the query does not require policies"); @@ -549,16 +557,14 @@ impl Plugin for AuthorizationPlugin { if self.require_authentication { ServiceBuilder::new() .checkpoint(move |request: supergraph::Request| { + // XXX(@goto-bus-stop): Why are we doing this here, as opposed to the + // authentication plugin, which manages this context value? if request .context .contains_key(APOLLO_AUTHENTICATION_JWT_CLAIMS) { Ok(ControlFlow::Continue(request)) } else { - // This is a metric and will not appear in the logs - tracing::info!( - monotonic_counter.apollo_require_authentication_failure_count = 1u64, - ); tracing::error!("rejecting unauthenticated request"); let response = supergraph::Response::error_builder() .error( @@ -584,15 +590,17 @@ impl Plugin for AuthorizationPlugin { ServiceBuilder::new() .map_request(|request: execution::Request| { let filtered = !request.query_plan.query.unauthorized.paths.is_empty(); - let needs_authenticated = request.context.contains_key(AUTHENTICATED_KEY); + let needs_authenticated = request.context.contains_key(AUTHENTICATION_REQUIRED_KEY); let needs_requires_scopes = request.context.contains_key(REQUIRED_SCOPES_KEY); if needs_authenticated || needs_requires_scopes { - tracing::info!( - monotonic_counter.apollo.router.operations.authorization = 1u64, + u64_counter!( + "apollo.router.operations.authorization", + "Number of subgraph requests requiring authorization", + 1, authorization.filtered = filtered, authorization.needs_authenticated = needs_authenticated, - authorization.needs_requires_scopes = needs_requires_scopes, + authorization.needs_requires_scopes = needs_requires_scopes ); } diff --git a/apollo-router/src/plugins/authorization/policy.rs b/apollo-router/src/plugins/authorization/policy.rs index e317b7eb97..b7a0fd4398 100644 --- a/apollo-router/src/plugins/authorization/policy.rs +++ b/apollo-router/src/plugins/authorization/policy.rs @@ -9,21 +9,21 @@ use std::collections::HashMap; use std::collections::HashSet; +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::ast; use apollo_compiler::executable; use apollo_compiler::schema; use apollo_compiler::schema::Implementers; -use apollo_compiler::Name; -use apollo_compiler::Node; use tower::BoxError; use crate::json_ext::Path; use crate::json_ext::PathElement; +use crate::spec::Schema; +use crate::spec::TYPENAME; use crate::spec::query::transform; use crate::spec::query::transform::TransformState; use crate::spec::query::traverse; -use crate::spec::Schema; -use crate::spec::TYPENAME; pub(crate) struct PolicyExtractionVisitor<'a> { schema: &'a schema::Schema, @@ -122,7 +122,7 @@ fn policy_argument( .filter_map(|v| v.as_str().map(str::to_owned)) } -impl<'a> traverse::Visitor for PolicyExtractionVisitor<'a> { +impl traverse::Visitor for PolicyExtractionVisitor<'_> { fn operation(&mut self, root_type: &str, node: &executable::Operation) -> Result<(), BoxError> { if let Some(ty) = self.schema.types.get(root_type) { self.extracted_policies.extend(policy_argument( @@ -173,10 +173,10 @@ impl<'a> traverse::Visitor for PolicyExtractionVisitor<'a> { parent_type: &str, node: &executable::InlineFragment, ) -> Result<(), BoxError> { - if let Some(type_condition) = &node.type_condition { - if let Some(ty) = self.schema.types.get(type_condition) { - self.get_policies_from_type(ty); - } + if let Some(type_condition) = &node.type_condition + && let Some(ty) = self.schema.types.get(type_condition) + { + self.get_policies_from_type(ty); } traverse::inline_fragment(self, parent_type, node) } @@ -290,7 +290,7 @@ impl<'a> PolicyFilteringVisitor<'a> { } } - fn implementors(&self, type_name: &str) -> impl Iterator { + fn implementors<'s>(&'s self, type_name: &str) -> impl Iterator + use<'s> { self.implementers_map .get(type_name) .map(|implementers| implementers.iter()) @@ -318,10 +318,10 @@ impl<'a> PolicyFilteringVisitor<'a> { } let type_name = field_def.ty.inner_named_type(); - if let Some(type_definition) = self.schema.types.get(type_name) { - if self.implementors_with_different_type_requirements(type_name, type_definition) { - return true; - } + if let Some(type_definition) = self.schema.types.get(type_name) + && self.implementors_with_different_type_requirements(type_name, type_definition) + { + return true; } false } @@ -376,37 +376,37 @@ impl<'a> PolicyFilteringVisitor<'a> { parent_type: &str, field: &ast::Field, ) -> bool { - if let Some(t) = self.schema.types.get(parent_type) { - if t.is_interface() { - let mut policies_sets: Option>> = None; - - for ty in self.implementors(parent_type) { - if let Ok(f) = self.schema.type_field(ty, &field.name) { - // aggregate the list of policies sets - // we transform to a common representation of sorted vectors because the element order - // of hashsets is not stable - let field_policies = f - .directives - .get(&self.policy_directive_name) - .map(|directive| { - let mut v = policies_sets_argument(directive) - .map(|h| { - let mut v = h.into_iter().collect::>(); - v.sort(); - v - }) - .collect::>(); - v.sort(); - v - }) - .unwrap_or_default(); + if let Some(t) = self.schema.types.get(parent_type) + && t.is_interface() + { + let mut policies_sets: Option>> = None; - match &policies_sets { - None => policies_sets = Some(field_policies), - Some(other_policies) => { - if field_policies != *other_policies { - return true; - } + for ty in self.implementors(parent_type) { + if let Ok(f) = self.schema.type_field(ty, &field.name) { + // aggregate the list of policies sets + // we transform to a common representation of sorted vectors because the element order + // of hashsets is not stable + let field_policies = f + .directives + .get(&self.policy_directive_name) + .map(|directive| { + let mut v = policies_sets_argument(directive) + .map(|h| { + let mut v = h.into_iter().collect::>(); + v.sort(); + v + }) + .collect::>(); + v.sort(); + v + }) + .unwrap_or_default(); + + match &policies_sets { + None => policies_sets = Some(field_policies), + Some(other_policies) => { + if field_policies != *other_policies { + return true; } } } @@ -417,7 +417,7 @@ impl<'a> PolicyFilteringVisitor<'a> { } } -impl<'a> transform::Visitor for PolicyFilteringVisitor<'a> { +impl transform::Visitor for PolicyFilteringVisitor<'_> { fn operation( &mut self, root_type: &str, @@ -642,10 +642,10 @@ mod tests { use std::collections::BTreeSet; use std::collections::HashSet; - use apollo_compiler::ast; - use apollo_compiler::ast::Document; use apollo_compiler::ExecutableDocument; use apollo_compiler::Schema; + use apollo_compiler::ast; + use apollo_compiler::ast::Document; use crate::json_ext::Path; use crate::plugins::authorization::policy::PolicyExtractionVisitor; @@ -763,7 +763,7 @@ mod tests { paths: Vec, } - impl<'a> std::fmt::Display for TestResult<'a> { + impl std::fmt::Display for TestResult<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, diff --git a/apollo-router/src/plugins/authorization/scopes.rs b/apollo-router/src/plugins/authorization/scopes.rs index 6dcccfc0a0..c3c883430e 100644 --- a/apollo-router/src/plugins/authorization/scopes.rs +++ b/apollo-router/src/plugins/authorization/scopes.rs @@ -9,21 +9,21 @@ use std::collections::HashMap; use std::collections::HashSet; +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::ast; use apollo_compiler::executable; use apollo_compiler::schema; use apollo_compiler::schema::Implementers; -use apollo_compiler::Name; -use apollo_compiler::Node; use tower::BoxError; use crate::json_ext::Path; use crate::json_ext::PathElement; +use crate::spec::Schema; +use crate::spec::TYPENAME; use crate::spec::query::transform; use crate::spec::query::transform::TransformState; use crate::spec::query::traverse; -use crate::spec::Schema; -use crate::spec::TYPENAME; pub(crate) struct ScopeExtractionVisitor<'a> { schema: &'a schema::Schema, @@ -122,7 +122,7 @@ fn scopes_argument( .filter_map(|value| value.as_str().map(str::to_owned)) } -impl<'a> traverse::Visitor for ScopeExtractionVisitor<'a> { +impl traverse::Visitor for ScopeExtractionVisitor<'_> { fn operation(&mut self, root_type: &str, node: &executable::Operation) -> Result<(), BoxError> { if let Some(ty) = self.schema.types.get(root_type) { self.extracted_scopes.extend(scopes_argument( @@ -173,10 +173,10 @@ impl<'a> traverse::Visitor for ScopeExtractionVisitor<'a> { parent_type: &str, node: &executable::InlineFragment, ) -> Result<(), BoxError> { - if let Some(type_condition) = &node.type_condition { - if let Some(ty) = self.schema.types.get(type_condition) { - self.scopes_from_type(ty); - } + if let Some(type_condition) = &node.type_condition + && let Some(ty) = self.schema.types.get(type_condition) + { + self.scopes_from_type(ty); } traverse::inline_fragment(self, parent_type, node) } @@ -288,7 +288,7 @@ impl<'a> ScopeFilteringVisitor<'a> { } } - fn implementors(&self, type_name: &str) -> impl Iterator { + fn implementors<'s>(&'s self, type_name: &str) -> impl Iterator + use<'s> { self.implementers_map .get(type_name) .map(|implementers| implementers.iter()) @@ -317,10 +317,10 @@ impl<'a> ScopeFilteringVisitor<'a> { } let field_type = field_def.ty.inner_named_type(); - if let Some(type_definition) = self.schema.types.get(field_type) { - if self.implementors_with_different_type_requirements(field_def, type_definition) { - return true; - } + if let Some(type_definition) = self.schema.types.get(field_type) + && self.implementors_with_different_type_requirements(field_def, type_definition) + { + return true; } false } @@ -376,37 +376,37 @@ impl<'a> ScopeFilteringVisitor<'a> { parent_type: &str, field: &ast::Field, ) -> bool { - if let Some(t) = self.schema.types.get(parent_type) { - if t.is_interface() { - let mut scope_sets = None; - - for ty in self.implementors(parent_type) { - if let Ok(f) = self.schema.type_field(ty, &field.name) { - // aggregate the list of scope sets - // we transform to a common representation of sorted vectors because the element order - // of hashsets is not stable - let field_scope_sets = f - .directives - .get(&self.requires_scopes_directive_name) - .map(|directive| { - let mut v = scopes_sets_argument(directive) - .map(|h| { - let mut v = h.into_iter().collect::>(); - v.sort(); - v - }) - .collect::>(); - v.sort(); - v - }) - .unwrap_or_default(); + if let Some(t) = self.schema.types.get(parent_type) + && t.is_interface() + { + let mut scope_sets = None; - match &scope_sets { - None => scope_sets = Some(field_scope_sets), - Some(other_scope_sets) => { - if field_scope_sets != *other_scope_sets { - return true; - } + for ty in self.implementors(parent_type) { + if let Ok(f) = self.schema.type_field(ty, &field.name) { + // aggregate the list of scope sets + // we transform to a common representation of sorted vectors because the element order + // of hashsets is not stable + let field_scope_sets = f + .directives + .get(&self.requires_scopes_directive_name) + .map(|directive| { + let mut v = scopes_sets_argument(directive) + .map(|h| { + let mut v = h.into_iter().collect::>(); + v.sort(); + v + }) + .collect::>(); + v.sort(); + v + }) + .unwrap_or_default(); + + match &scope_sets { + None => scope_sets = Some(field_scope_sets), + Some(other_scope_sets) => { + if field_scope_sets != *other_scope_sets { + return true; } } } @@ -418,7 +418,7 @@ impl<'a> ScopeFilteringVisitor<'a> { } } -impl<'a> transform::Visitor for ScopeFilteringVisitor<'a> { +impl transform::Visitor for ScopeFilteringVisitor<'_> { fn operation( &mut self, root_type: &str, @@ -644,8 +644,8 @@ mod tests { use std::collections::BTreeSet; use std::collections::HashSet; - use apollo_compiler::ast::Document; use apollo_compiler::Schema; + use apollo_compiler::ast::Document; use crate::json_ext::Path; use crate::plugins::authorization::scopes::ScopeExtractionVisitor; @@ -767,7 +767,7 @@ mod tests { paths: Vec, } - impl<'a> std::fmt::Display for TestResult<'a> { + impl std::fmt::Display for TestResult<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, diff --git a/apollo-router/src/plugins/authorization/snapshots/apollo_router__plugins__authorization__authenticated__tests__unauthenticated_request_defer.snap b/apollo-router/src/plugins/authorization/snapshots/apollo_router__plugins__authorization__authenticated__tests__unauthenticated_request_defer.snap index 834762190d..561696a5f7 100644 --- a/apollo-router/src/plugins/authorization/snapshots/apollo_router__plugins__authorization__authenticated__tests__unauthenticated_request_defer.snap +++ b/apollo-router/src/plugins/authorization/snapshots/apollo_router__plugins__authorization__authenticated__tests__unauthenticated_request_defer.snap @@ -1,6 +1,7 @@ --- source: apollo-router/src/plugins/authorization/authenticated.rs expression: first_response +snapshot_kind: text --- { "data": { @@ -10,5 +11,16 @@ expression: first_response "id": 0 } } - } + }, + "errors": [ + { + "message": "Unauthorized field or type", + "path": [ + "orga" + ], + "extensions": { + "code": "UNAUTHORIZED_FIELD_OR_TYPE" + } + } + ] } diff --git a/apollo-router/src/plugins/authorization/tests.rs b/apollo-router/src/plugins/authorization/tests.rs index b8b1b8052b..94fea960b8 100644 --- a/apollo-router/src/plugins/authorization/tests.rs +++ b/apollo-router/src/plugins/authorization/tests.rs @@ -4,16 +4,18 @@ use http::header::CONTENT_TYPE; use serde_json_bytes::json; use tower::ServiceExt; +use crate::Context; +use crate::MockedSubgraphs; +use crate::TestHarness; use crate::graphql; use crate::plugin::test::MockSubgraph; use crate::plugin::test::MockSubgraphService; +use crate::plugins::authorization::APOLLO_AUTHENTICATION_JWT_CLAIMS; use crate::plugins::authorization::CacheKeyMetadata; use crate::services::router; +use crate::services::router::body; use crate::services::subgraph; use crate::services::supergraph; -use crate::Context; -use crate::MockedSubgraphs; -use crate::TestHarness; const SCHEMA: &str = include_str!("../../testdata/orga_supergraph.graphql"); @@ -22,7 +24,7 @@ async fn authenticated_request() { let subgraphs = MockedSubgraphs([ ("user", MockSubgraph::builder().with_json( serde_json::json!{{ - "query": "query($representations:[_Any!]!){_entities(representations:$representations){..._generated_onUser2_0}}fragment _generated_onUser2_0 on User{name phone}", + "query": "query($representations:[_Any!]!){_entities(representations:$representations){... on User{name phone}}}", "variables": { "representations": [ { "__typename": "User", "id":0 } @@ -63,10 +65,7 @@ async fn authenticated_request() { let context = Context::new(); context - .insert( - "apollo_authentication::JWT::claims", - "placeholder".to_string(), - ) + .insert(APOLLO_AUTHENTICATION_JWT_CLAIMS, "placeholder".to_string()) .unwrap(); let request = supergraph::Request::fake_builder() .query("query { orga(id: 1) { id creatorUser { id name phone } } }") @@ -282,7 +281,7 @@ async fn authenticated_directive() { .method("POST") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(serde_json::to_vec(&req).unwrap().into()) + .body(body::from_bytes(serde_json::to_vec(&req).unwrap())) .unwrap(), }; @@ -303,7 +302,7 @@ async fn authenticated_directive() { let context = Context::new(); context .insert( - "apollo_authentication::JWT::claims", + APOLLO_AUTHENTICATION_JWT_CLAIMS, json! {{ "scope": "user:read" }}, ) .unwrap(); @@ -313,7 +312,7 @@ async fn authenticated_directive() { .method("POST") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(serde_json::to_vec(&req).unwrap().into()) + .body(body::from_bytes(serde_json::to_vec(&req).unwrap())) .unwrap(), }; @@ -400,7 +399,7 @@ async fn authenticated_directive_reject_unauthorized() { .method("POST") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(serde_json::to_vec(&req).unwrap().into()) + .body(body::from_bytes(serde_json::to_vec(&req).unwrap())) .unwrap(), }; @@ -485,7 +484,7 @@ async fn authenticated_directive_dry_run() { .method("POST") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(serde_json::to_vec(&req).unwrap().into()) + .body(body::from_bytes(serde_json::to_vec(&req).unwrap())) .unwrap(), }; @@ -626,7 +625,7 @@ async fn scopes_directive() { .method("POST") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(serde_json::to_vec(&req).unwrap().into()) + .body(body::from_bytes(serde_json::to_vec(&req).unwrap())) .unwrap(), }; @@ -647,7 +646,7 @@ async fn scopes_directive() { let context = Context::new(); context .insert( - "apollo_authentication::JWT::claims", + APOLLO_AUTHENTICATION_JWT_CLAIMS, json! {{ "scope": "user:read" }}, ) .unwrap(); @@ -657,7 +656,7 @@ async fn scopes_directive() { .method("POST") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(serde_json::to_vec(&req).unwrap().into()) + .body(body::from_bytes(serde_json::to_vec(&req).unwrap())) .unwrap(), }; @@ -678,7 +677,7 @@ async fn scopes_directive() { let context = Context::new(); context .insert( - "apollo_authentication::JWT::claims", + APOLLO_AUTHENTICATION_JWT_CLAIMS, json! {{ "scope": "user:read pii" }}, ) .unwrap(); @@ -688,7 +687,7 @@ async fn scopes_directive() { .method("POST") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(serde_json::to_vec(&req).unwrap().into()) + .body(body::from_bytes(serde_json::to_vec(&req).unwrap())) .unwrap(), }; @@ -709,7 +708,7 @@ async fn scopes_directive() { let context = Context::new(); context .insert( - "apollo_authentication::JWT::claims", + APOLLO_AUTHENTICATION_JWT_CLAIMS, json! {{ "scope": "admin" }}, ) .unwrap(); @@ -719,7 +718,7 @@ async fn scopes_directive() { .method("POST") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(serde_json::to_vec(&req).unwrap().into()) + .body(body::from_bytes(serde_json::to_vec(&req).unwrap())) .unwrap(), }; @@ -798,7 +797,7 @@ async fn scopes_directive_reject_unauthorized() { .method("POST") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(serde_json::to_vec(&req).unwrap().into()) + .body(body::from_bytes(serde_json::to_vec(&req).unwrap())) .unwrap(), }; @@ -878,7 +877,7 @@ async fn scopes_directive_dry_run() { .method("POST") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(serde_json::to_vec(&req).unwrap().into()) + .body(body::from_bytes(serde_json::to_vec(&req).unwrap())) .unwrap(), }; @@ -960,7 +959,7 @@ async fn errors_in_extensions() { .method("POST") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(serde_json::to_vec(&req).unwrap().into()) + .body(body::from_bytes(serde_json::to_vec(&req).unwrap())) .unwrap(), }; @@ -1098,7 +1097,7 @@ async fn cache_key_metadata() { let context = Context::new(); context .insert( - "apollo_authentication::JWT::claims", + APOLLO_AUTHENTICATION_JWT_CLAIMS, json! {{ "scope": "id test" }}, ) .unwrap(); diff --git a/apollo-router/src/plugins/cache/cache_control.rs b/apollo-router/src/plugins/cache/cache_control.rs index dd7d1a598e..f3fe708aed 100644 --- a/apollo-router/src/plugins/cache/cache_control.rs +++ b/apollo-router/src/plugins/cache/cache_control.rs @@ -3,10 +3,10 @@ use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; -use http::header::AGE; -use http::header::CACHE_CONTROL; use http::HeaderMap; use http::HeaderValue; +use http::header::AGE; +use http::header::CACHE_CONTROL; use serde::Deserialize; use serde::Serialize; use tower::BoxError; @@ -157,10 +157,36 @@ impl CacheControl { Ok(result) } + /// Fill the header map with cache-control header and age header pub(crate) fn to_headers(&self, headers: &mut HeaderMap) -> Result<(), BoxError> { + headers.insert( + CACHE_CONTROL, + HeaderValue::from_str(&self.to_cache_control_header()?)?, + ); + + if let Some(age) = self.age + && age != 0 + { + headers.insert(AGE, age.into()); + } + + Ok(()) + } + + /// Only for cache control header and not age + pub(crate) fn to_cache_control_header(&self) -> Result { let mut s = String::new(); let mut prev = false; let now = now_epoch_seconds(); + if self.no_store { + write!(&mut s, "no-store")?; + // Early return to avoid conflicts https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#preventing_storing + return Ok(s); + } + if self.no_cache { + write!(&mut s, "{}no-cache", if prev { "," } else { "" },)?; + prev = true; + } if let Some(max_age) = self.max_age { //FIXME: write no-store if max_age = 0? write!( @@ -189,10 +215,6 @@ impl CacheControl { )?; prev = true; } - if self.no_cache { - write!(&mut s, "{}no_cache", if prev { "," } else { "" },)?; - prev = true; - } if self.must_revalidate { write!(&mut s, "{}must-revalidate", if prev { "," } else { "" },)?; prev = true; @@ -201,10 +223,6 @@ impl CacheControl { write!(&mut s, "{}proxy-revalidate", if prev { "," } else { "" },)?; prev = true; } - if self.no_store { - write!(&mut s, "{}no-store", if prev { "," } else { "" },)?; - prev = true; - } if self.private { write!(&mut s, "{}private", if prev { "," } else { "" },)?; prev = true; @@ -228,15 +246,8 @@ impl CacheControl { if self.stale_if_error { write!(&mut s, "{}stale-if-error", if prev { "," } else { "" },)?; } - headers.insert(CACHE_CONTROL, HeaderValue::from_str(&s)?); - if let Some(age) = self.age { - if age != 0 { - headers.insert(AGE, age.into()); - } - } - - Ok(()) + Ok(s) } pub(super) fn no_store() -> Self { @@ -248,11 +259,7 @@ impl CacheControl { fn update_ttl(&self, ttl: u32, now: u64) -> u32 { let elapsed = self.elapsed_inner(now); - if elapsed >= ttl { - 0 - } else { - ttl - elapsed - } + ttl.saturating_sub(elapsed) } pub(crate) fn merge(&self, other: &CacheControl) -> CacheControl { @@ -260,6 +267,13 @@ impl CacheControl { } fn merge_inner(&self, other: &CacheControl, now: u64) -> CacheControl { + // Early return to avoid conflicts https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#preventing_storing + if self.no_store || other.no_store { + return CacheControl { + no_store: true, + ..Default::default() + }; + } CacheControl { created: now, max_age: match (self.ttl(), other.ttl()) { @@ -366,11 +380,7 @@ impl CacheControl { pub(crate) fn remaining_time(&self, now: u64) -> Option { self.ttl().map(|ttl| { let elapsed = self.elapsed_inner(now); - if ttl > elapsed { - ttl - elapsed - } else { - 0 - } + ttl.saturating_sub(elapsed) }) } } @@ -427,10 +437,27 @@ mod tests { let merged = first.merge_inner(&second, now); assert!(merged.no_store); - assert!(merged.public); + assert!(!merged.public); assert!(!merged.can_use()); } + #[test] + fn remove_conflicts() { + let now = now_epoch_seconds(); + + let first = CacheControl { + created: now, + max_age: Some(40), + no_store: true, + must_revalidate: true, + no_cache: true, + private: true, + ..Default::default() + }; + let cache_control_header = first.to_cache_control_header().unwrap(); + assert_eq!(cache_control_header, "no-store".to_string()); + } + #[test] fn merge_public_private() { let now = now_epoch_seconds(); diff --git a/apollo-router/src/plugins/cache/entity.rs b/apollo-router/src/plugins/cache/entity.rs index b0abd0178e..c024c20242 100644 --- a/apollo-router/src/plugins/cache/entity.rs +++ b/apollo-router/src/plugins/cache/entity.rs @@ -5,15 +5,20 @@ use std::ops::ControlFlow; use std::sync::Arc; use std::time::Duration; +use apollo_compiler::Schema; +use apollo_compiler::ast::NamedType; +use apollo_compiler::parser::Parser; +use apollo_compiler::validation::Valid; use http::header; use http::header::CACHE_CONTROL; +use itertools::Itertools; use multimap::MultiMap; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; -use serde_json_bytes::from_value; use serde_json_bytes::ByteString; use serde_json_bytes::Value; +use serde_json_bytes::from_value; use sha2::Digest; use sha2::Sha256; use tokio::sync::RwLock; @@ -32,36 +37,40 @@ use super::invalidation_endpoint::InvalidationService; use super::invalidation_endpoint::SubgraphInvalidationConfig; use super::metrics::CacheMetricContextKey; use super::metrics::CacheMetricsService; +use crate::Context; +use crate::Endpoint; +use crate::ListenAddr; use crate::batching::BatchQuery; use crate::cache::redis::RedisCacheStorage; use crate::cache::redis::RedisKey; use crate::cache::redis::RedisValue; use crate::cache::storage::ValueType; -use crate::configuration::subgraph::SubgraphConfiguration; use crate::configuration::RedisCache; +use crate::configuration::subgraph::SubgraphConfiguration; use crate::error::FetchError; use crate::graphql; use crate::graphql::Error; use crate::json_ext::Object; use crate::json_ext::Path; use crate::json_ext::PathElement; +use crate::layers::ServiceBuilderExt; use crate::plugin::Plugin; use crate::plugin::PluginInit; use crate::plugins::authorization::CacheKeyMetadata; -use crate::query_planner::fetch::QueryHash; use crate::query_planner::OperationKind; use crate::services::subgraph; +use crate::services::subgraph::SubgraphRequestId; use crate::services::supergraph; +use crate::spec::QueryHash; use crate::spec::TYPENAME; -use crate::Context; -use crate::Endpoint; -use crate::ListenAddr; /// Change this key if you introduce a breaking change in entity caching algorithm to make sure it won't take the previous entries pub(crate) const ENTITY_CACHE_VERSION: &str = "1.0"; pub(crate) const ENTITIES: &str = "_entities"; pub(crate) const REPRESENTATIONS: &str = "representations"; pub(crate) const CONTEXT_CACHE_KEY: &str = "apollo_entity_cache::key"; +/// Context key to enable support of surrogate cache key +pub(crate) const CONTEXT_CACHE_KEYS: &str = "apollo::entity_cache::cached_keys_status"; register_plugin!("apollo", "preview_entity_cache", EntityCache); @@ -73,8 +82,12 @@ pub(crate) struct EntityCache { entity_type: Option, enabled: bool, metrics: Metrics, + expose_keys_in_context: bool, private_queries: Arc>>, pub(crate) invalidation: Invalidation, + supergraph_schema: Arc>, + /// map containing the enum GRAPH + subgraph_enums: Arc>, } pub(crate) struct Storage { @@ -94,10 +107,14 @@ impl Storage { pub(crate) struct Config { /// Enable or disable the entity caching feature #[serde(default)] - enabled: bool, + pub(crate) enabled: bool, + + #[serde(default)] + /// Expose cache keys in context + expose_keys_in_context: bool, /// Configure invalidation per subgraph - subgraph: SubgraphConfiguration, + pub(crate) subgraph: SubgraphConfiguration, /// Global invalidation configuration invalidation: Option, @@ -114,11 +131,11 @@ pub(crate) struct Subgraph { /// Redis configuration pub(crate) redis: Option, - /// expiration for all keys for this subgraph, unless overriden by the `Cache-Control` header in subgraph responses + /// expiration for all keys for this subgraph, unless overridden by the `Cache-Control` header in subgraph responses pub(crate) ttl: Option, /// activates caching for this subgraph, overrides the global configuration - pub(crate) enabled: bool, + pub(crate) enabled: Option, /// Context key used to separate cache sections per user pub(crate) private_id: Option, @@ -131,7 +148,7 @@ impl Default for Subgraph { fn default() -> Self { Self { redis: None, - enabled: true, + enabled: Some(true), ttl: Default::default(), private_id: Default::default(), invalidation: Default::default(), @@ -193,9 +210,9 @@ impl Plugin for EntityCache { if let Some(redis) = &init.config.subgraph.all.redis { let mut redis_config = redis.clone(); let required_to_start = redis_config.required_to_start; - // we need to explicitely disable TTL reset because it is managed directly by this plugin + // we need to explicitly disable TTL reset because it is managed directly by this plugin redis_config.reset_ttl = false; - all = match RedisCacheStorage::new(redis_config).await { + all = match RedisCacheStorage::new(redis_config, "entity").await { Ok(storage) => Some(storage), Err(e) => { tracing::error!( @@ -214,10 +231,10 @@ impl Plugin for EntityCache { for (subgraph, config) in &init.config.subgraph.subgraphs { if let Some(redis) = &config.redis { let required_to_start = redis.required_to_start; - // we need to explicitely disable TTL reset because it is managed directly by this plugin + // we need to explicitly disable TTL reset because it is managed directly by this plugin let mut redis_config = redis.clone(); redis_config.reset_ttl = false; - let storage = match RedisCacheStorage::new(redis_config).await { + let storage = match RedisCacheStorage::new(redis_config, "entity").await { Ok(storage) => Some(storage), Err(e) => { tracing::error!( @@ -297,11 +314,14 @@ impl Plugin for EntityCache { storage, entity_type, enabled: init.config.enabled, + expose_keys_in_context: init.config.expose_keys_in_context, endpoint_config: init.config.invalidation.clone().map(Arc::new), subgraphs: Arc::new(init.config.subgraph), metrics: init.config.metrics, private_queries: Arc::new(RwLock::new(HashSet::new())), invalidation, + subgraph_enums: Arc::new(get_subgraph_enums(&init.supergraph_schema)), + supergraph_schema: init.supergraph_schema, }) } @@ -346,15 +366,8 @@ impl Plugin for EntityCache { } }; - let subgraph_ttl = self - .subgraphs - .get(name) - .ttl - .clone() - .map(|t| t.0) - .or_else(|| storage.ttl()); - let subgraph_enabled = - self.enabled && (self.subgraphs.all.enabled || self.subgraphs.get(name).enabled); + let subgraph_ttl = self.subgraph_ttl(name, &storage); + let subgraph_enabled = self.subgraph_enabled(name); let private_id = self.subgraphs.get(name).private_id.clone(); let name = name.to_string(); @@ -381,8 +394,11 @@ impl Plugin for EntityCache { response }) - .service(CacheService(Some(InnerCacheService { - service, + .service(CacheService { + service: ServiceBuilder::new() + .buffered() + .service(service) + .boxed_clone(), entity_type: self.entity_type.clone(), name: name.to_string(), storage, @@ -390,7 +406,10 @@ impl Plugin for EntityCache { private_queries, private_id, invalidation: self.invalidation.clone(), - }))); + expose_keys_in_context: self.expose_keys_in_context, + supergraph_schema: self.supergraph_schema.clone(), + subgraph_enums: self.subgraph_enums.clone(), + }); tower::util::BoxService::new(inner) } else { ServiceBuilder::new() @@ -435,7 +454,9 @@ impl Plugin for EntityCache { map.insert(endpoint_config.listen.clone(), endpoint); } None => { - tracing::warn!("Cannot start entity caching invalidation endpoint because the listen address and endpoint is not configured"); + tracing::warn!( + "Cannot start entity caching invalidation endpoint because the listen address and endpoint is not configured" + ); } } } @@ -444,11 +465,14 @@ impl Plugin for EntityCache { } } +#[cfg(test)] +pub(super) const INVALIDATION_SHARED_KEY: &str = "supersecret"; impl EntityCache { #[cfg(test)] pub(crate) async fn with_mocks( storage: RedisCacheStorage, subgraphs: HashMap, + supergraph_schema: Arc>, ) -> Result where Self: Sized, @@ -467,8 +491,15 @@ impl EntityCache { storage, entity_type: None, enabled: true, + expose_keys_in_context: true, subgraphs: Arc::new(SubgraphConfiguration { - all: Subgraph::default(), + all: Subgraph { + invalidation: Some(SubgraphInvalidationConfig { + enabled: true, + shared_key: INVALIDATION_SHARED_KEY.to_string(), + }), + ..Default::default() + }, subgraphs, }), metrics: Metrics::default(), @@ -483,20 +514,74 @@ impl EntityCache { concurrent_requests: 10, })), invalidation, + subgraph_enums: Arc::new(get_subgraph_enums(&supergraph_schema)), + supergraph_schema, }) } + + // Returns boolean to know if cache is enabled for this subgraph + fn subgraph_enabled(&self, subgraph_name: &str) -> bool { + if !self.enabled { + return false; + } + match ( + self.subgraphs.all.enabled, + self.subgraphs.get(subgraph_name).enabled, + ) { + (_, Some(x)) => x, // explicit per-subgraph setting overrides the `all` default + (Some(true) | None, None) => true, // unset defaults to true + (Some(false), None) => false, + } + } + + // Returns the configured ttl for this subgraph + fn subgraph_ttl(&self, subgraph_name: &str, storage: &RedisCacheStorage) -> Option { + self.subgraphs + .get(subgraph_name) + .ttl + .clone() + .map(|t| t.0) + .or_else(|| match self.subgraphs.all.ttl.clone() { + Some(ttl) => Some(ttl.0), + None => storage.ttl(), + }) + } +} + +/// Get the map of subgraph enum variant mapped with subgraph name +fn get_subgraph_enums(supergraph_schema: &Valid) -> HashMap { + let mut subgraph_enums = HashMap::new(); + if let Some(graph_enum) = supergraph_schema.get_enum("join__Graph") { + subgraph_enums.extend(graph_enum.values.iter().filter_map( + |(enum_name, enum_value_def)| { + let subgraph_name = enum_value_def + .directives + .get("join__graph")? + .specified_argument_by_name("name")? + .as_str()? + .to_string(); + + Some((enum_name.to_string(), subgraph_name)) + }, + )); + } + + subgraph_enums } -struct CacheService(Option); -struct InnerCacheService { - service: subgraph::BoxService, +#[derive(Clone)] +struct CacheService { + service: subgraph::BoxCloneService, name: String, entity_type: Option, storage: RedisCacheStorage, subgraph_ttl: Option, private_queries: Arc>>, private_id: Option, + expose_keys_in_context: bool, invalidation: Invalidation, + supergraph_schema: Arc>, + subgraph_enums: Arc>, } impl Service for CacheService { @@ -508,21 +593,18 @@ impl Service for CacheService { &mut self, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { - match &mut self.0 { - Some(s) => s.service.poll_ready(cx), - None => panic!("service should have been called only once"), - } + self.service.poll_ready(cx) } fn call(&mut self, request: subgraph::Request) -> Self::Future { - match self.0.take() { - None => panic!("service should have been called only once"), - Some(s) => Box::pin(s.call_inner(request)), - } + let clone = self.clone(); + let inner = std::mem::replace(self, clone); + + Box::pin(inner.call_inner(request)) } } -impl InnerCacheService { +impl CacheService { async fn call_inner( mut self, request: subgraph::Request, @@ -567,6 +649,7 @@ impl InnerCacheService { self.storage.clone(), is_known_private, private_id.as_deref(), + self.expose_keys_in_context, request, ) .instrument(tracing::info_span!("cache.entity.lookup")) @@ -575,9 +658,7 @@ impl InnerCacheService { ControlFlow::Break(response) => { cache_hit.insert("Query".to_string(), CacheHitMiss { hit: 1, miss: 0 }); let _ = response.context.insert( - CacheMetricContextKey::new( - response.subgraph_name.clone().unwrap_or_default(), - ), + CacheMetricContextKey::new(response.subgraph_name.clone()), CacheSubgraph(cache_hit), ); Ok(response) @@ -585,14 +666,11 @@ impl InnerCacheService { ControlFlow::Continue((request, mut root_cache_key)) => { cache_hit.insert("Query".to_string(), CacheHitMiss { hit: 0, miss: 1 }); let _ = request.context.insert( - CacheMetricContextKey::new( - request.subgraph_name.clone().unwrap_or_default(), - ), + CacheMetricContextKey::new(request.subgraph_name.clone()), CacheSubgraph(cache_hit), ); let mut response = self.service.call(request).await?; - let cache_control = if response.response.headers().contains_key(CACHE_CONTROL) { CacheControl::new(response.response.headers(), self.storage.ttl)? @@ -614,6 +692,7 @@ impl InnerCacheService { if private_id.is_none() { // the response has a private scope but we don't have a way to differentiate users, so we do not store the response in cache + // We don't need to fill the context with this cache key as it will never be cached return Ok(response); } } @@ -638,6 +717,7 @@ impl InnerCacheService { &response, cache_control, root_cache_key, + self.expose_keys_in_context, ) .await?; } @@ -663,12 +743,16 @@ impl InnerCacheService { Ok(response) } } else { + let request_id = request.id.clone(); match cache_lookup_entities( self.name.clone(), + self.supergraph_schema.clone(), + &self.subgraph_enums, self.storage.clone(), is_known_private, private_id.as_deref(), request, + self.expose_keys_in_context, ) .instrument(tracing::info_span!("cache.entity.lookup")) .await? @@ -701,6 +785,20 @@ impl InnerCacheService { &[graphql_error], &mut cache_result.0, ); + if self.expose_keys_in_context { + // Update cache keys needed for surrogate cache key because new data has not been fetched + context.upsert::<_, CacheKeysContext>( + CONTEXT_CACHE_KEYS, + |mut value| { + if let Some(cache_keys) = value.get_mut(&request_id) { + cache_keys.retain(|cache_key| { + matches!(cache_key.status, CacheKeyStatus::Cached) + }); + } + value + }, + )?; + } let mut data = Object::default(); data.insert(ENTITIES, new_entities.into()); @@ -709,6 +807,7 @@ impl InnerCacheService { .context(context) .data(Value::Object(data)) .errors(new_errors) + .subgraph_name(self.name) .extensions(Object::new()) .build(); CacheControl::no_store().to_headers(response.response.headers_mut())?; @@ -727,6 +826,25 @@ impl InnerCacheService { if let Some(control_from_cached) = cache_result.1 { cache_control = cache_control.merge(&control_from_cached); } + if self.expose_keys_in_context { + // Update cache keys needed for surrogate cache key when it's new data and not data from the cache + let response_id = response.id.clone(); + let cache_control_str = cache_control.to_cache_control_header()?; + response.context.upsert::<_, CacheKeysContext>( + CONTEXT_CACHE_KEYS, + |mut value| { + if let Some(cache_keys) = value.get_mut(&response_id) { + for cache_key in cache_keys + .iter_mut() + .filter(|c| matches!(c.status, CacheKeyStatus::New)) + { + cache_key.cache_control = cache_control_str.clone(); + } + } + value + }, + )?; + } if !is_known_private && cache_control.private() { self.private_queries.write().await.insert(query.to_string()); @@ -781,12 +899,12 @@ impl InnerCacheService { origin: InvalidationOrigin, invalidation_extensions: Value, ) { - if let Ok(requests) = from_value(invalidation_extensions) { - if let Err(e) = self.invalidation.invalidate(origin, requests).await { - tracing::error!(error = %e, - message = "could not invalidate entity cache entries", - ); - } + if let Ok(requests) = from_value(invalidation_extensions) + && let Err(e) = self.invalidation.invalidate(origin, requests).await + { + tracing::error!(error = %e, + message = "could not invalidate entity cache entries", + ); } } } @@ -797,6 +915,7 @@ async fn cache_lookup_root( cache: RedisCacheStorage, is_known_private: bool, private_id: Option<&str>, + expose_keys_in_context: bool, mut request: subgraph::Request, ) -> Result, BoxError> { let body = request.subgraph_request.body_mut(); @@ -818,16 +937,43 @@ async fn cache_lookup_root( Some(value) => { if value.0.control.can_use() { let control = value.0.control.clone(); - request - .context - .extensions() - .with_lock(|mut lock| lock.insert(control)); + update_cache_control(&request.context, &control); + if expose_keys_in_context { + let request_id = request.id.clone(); + let cache_control_header = value.0.control.to_cache_control_header()?; + request.context.upsert::<_, CacheKeysContext>( + CONTEXT_CACHE_KEYS, + |mut val| { + match val.get_mut(&request_id) { + Some(v) => { + v.push(CacheKeyContext { + key: key.clone(), + status: CacheKeyStatus::Cached, + cache_control: cache_control_header, + }); + } + None => { + val.insert( + request_id, + vec![CacheKeyContext { + key: key.clone(), + status: CacheKeyStatus::Cached, + cache_control: cache_control_header, + }], + ); + } + } + + val + }, + )?; + } let mut response = subgraph::Response::builder() .data(value.0.data) .extensions(Object::new()) .context(request.context) - .and_subgraph_name(request.subgraph_name.clone()) + .subgraph_name(request.subgraph_name.clone()) .build(); value @@ -845,17 +991,22 @@ async fn cache_lookup_root( struct EntityCacheResults(Vec, Option); +#[allow(clippy::too_many_arguments)] async fn cache_lookup_entities( name: String, + supergraph_schema: Arc>, + subgraph_enums: &HashMap, cache: RedisCacheStorage, is_known_private: bool, private_id: Option<&str>, mut request: subgraph::Request, + expose_keys_in_context: bool, ) -> Result, BoxError> { let body = request.subgraph_request.body_mut(); - let keys = extract_cache_keys( &name, + supergraph_schema, + subgraph_enums, &request.query_hash, body, &request.context, @@ -882,7 +1033,7 @@ async fn cache_lookup_entities( }) .collect() }) - .unwrap_or_else(|| std::iter::repeat(None).take(keys.len()).collect()); + .unwrap_or_else(|| vec![None; keys.len()]); let representations = body .variables @@ -893,6 +1044,46 @@ async fn cache_lookup_entities( let (new_representations, cache_result, cache_control) = filter_representations(&name, representations, keys, cache_result, &request.context)?; + if expose_keys_in_context { + let mut cache_entries = Vec::with_capacity(cache_result.len()); + for intermediate_result in &cache_result { + match &intermediate_result.cache_entry { + Some(cache_entry) => { + cache_entries.push(CacheKeyContext { + key: intermediate_result.key.clone(), + status: CacheKeyStatus::Cached, + cache_control: cache_entry.control.to_cache_control_header()?, + }); + } + None => { + cache_entries.push(CacheKeyContext { + key: intermediate_result.key.clone(), + status: CacheKeyStatus::New, + cache_control: match &cache_control { + Some(cc) => cc.to_cache_control_header()?, + None => CacheControl::default().to_cache_control_header()?, + }, + }); + } + } + } + let request_id = request.id.clone(); + request + .context + .upsert::<_, CacheKeysContext>(CONTEXT_CACHE_KEYS, |mut v| { + match v.get_mut(&request_id) { + Some(cache_keys) => { + cache_keys.append(&mut cache_entries); + } + None => { + v.insert(request_id, cache_entries); + } + } + + v + })?; + } + if !new_representations.is_empty() { body.variables .insert(REPRESENTATIONS, new_representations.into()); @@ -913,7 +1104,7 @@ async fn cache_lookup_entities( let mut response = subgraph::Response::builder() .data(data) .extensions(Object::new()) - .and_subgraph_name(request.subgraph_name) + .subgraph_name(request.subgraph_name) .context(request.context) .build(); @@ -926,12 +1117,14 @@ async fn cache_lookup_entities( } fn update_cache_control(context: &Context, cache_control: &CacheControl) { - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { if let Some(c) = lock.get_mut::() { *c = c.merge(cache_control); } else { - //FIXME: race condition. We need an Entry API for private entries - lock.insert(cache_control.clone()); + // Go through the "merge" algorithm even with a single value + // in order to keep single-fetch queries consistent between cache hit and miss, + // and with multi-fetch queries. + lock.insert(cache_control.merge(cache_control)); } }) } @@ -954,6 +1147,7 @@ async fn cache_store_root_from_response( response: &subgraph::Response, cache_control: CacheControl, cache_key: String, + expose_keys_in_context: bool, ) -> Result<(), BoxError> { if let Some(data) = response.response.body().data.as_ref() { let ttl: Option = cache_control @@ -964,6 +1158,37 @@ async fn cache_store_root_from_response( if response.response.body().errors.is_empty() && cache_control.should_store() { let span = tracing::info_span!("cache.entity.store"); let data = data.clone(); + if expose_keys_in_context { + let response_id = response.id.clone(); + let cache_control_header = cache_control.to_cache_control_header()?; + + response + .context + .upsert::<_, CacheKeysContext>(CONTEXT_CACHE_KEYS, |mut val| { + match val.get_mut(&response_id) { + Some(v) => { + v.push(CacheKeyContext { + key: cache_key.clone(), + status: CacheKeyStatus::New, + cache_control: cache_control_header, + }); + } + None => { + val.insert( + response_id, + vec![CacheKeyContext { + key: cache_key.clone(), + status: CacheKeyStatus::New, + cache_control: cache_control_header, + }], + ); + } + } + + val + })?; + } + tokio::spawn(async move { cache .insert( @@ -1066,9 +1291,11 @@ pub(crate) fn hash_vary_headers(headers: &http::HeaderMap) -> String { hex::encode(digest.finalize().as_slice()) } +// XXX(@goto-bus-stop): this doesn't make much sense: QueryHash already includes the operation name. +// This function can be removed outright later at the cost of invalidating all entity caches. pub(crate) fn hash_query(query_hash: &QueryHash, body: &graphql::Request) -> String { let mut digest = Sha256::new(); - digest.update(&query_hash.0); + digest.update(query_hash.as_bytes()); digest.update(&[0u8; 1][..]); digest.update(body.operation_name.as_deref().unwrap_or("-").as_bytes()); digest.update(&[0u8; 1][..]); @@ -1086,6 +1313,7 @@ pub(crate) fn hash_additional_data( let repr_key = ByteString::from(REPRESENTATIONS); // Removing the representations variable because it's already part of the cache key let representations = body.variables.remove(&repr_key); + body.variables.sort_keys(); digest.update(serde_json::to_vec(&body.variables).unwrap()); if let Some(representations) = representations { body.variables.insert(repr_key, representations); @@ -1140,17 +1368,18 @@ fn extract_cache_key_root( "version:{ENTITY_CACHE_VERSION}:subgraph:{subgraph_name}:type:{entity_type}:hash:{query_hash}:data:{additional_data_hash}" ); - if is_known_private { - if let Some(id) = private_id { - let _ = write!(&mut key, ":{id}"); - } + if is_known_private && let Some(id) = private_id { + let _ = write!(&mut key, ":{id}"); } key } // build a list of keys to get from the cache in one query +#[allow(clippy::too_many_arguments)] fn extract_cache_keys( subgraph_name: &str, + supergraph_schema: Arc>, + subgraph_enums: &HashMap, query_hash: &QueryHash, body: &mut graphql::Request, context: &Context, @@ -1169,18 +1398,44 @@ fn extract_cache_keys( .and_then(|value| value.as_array_mut()) .expect("we already checked that representations exist"); + // Get entity key to only get the right fields in representations + let mut res = Vec::new(); for representation in representations { - let opt_type = representation - .as_object_mut() - .and_then(|o| o.remove(TYPENAME)) + let representation = + representation + .as_object_mut() + .ok_or_else(|| FetchError::MalformedRequest { + reason: "representation variable should be an array of object".to_string(), + })?; + let typename_value = + representation + .remove(TYPENAME) + .ok_or_else(|| FetchError::MalformedRequest { + reason: "missing __typename in representation".to_string(), + })?; + + let typename = typename_value + .as_str() .ok_or_else(|| FetchError::MalformedRequest { - reason: "missing __typename in representation".to_string(), + reason: "__typename in representation is not a string".to_string(), })?; - let typename = opt_type.as_str().unwrap_or("-"); + // Split `representation` into two parts: the entity key part and the rest. + let representation_entity_key = take_matching_key_field_set( + representation, + typename, + subgraph_name, + &supergraph_schema, + subgraph_enums, + )?; - let hashed_entity_key = hash_entity_key(representation); + let hashed_representation = if representation.is_empty() { + String::new() + } else { + hash_other_representation(representation) + }; + let hashed_entity_key = hash_entity_key(&representation_entity_key); // the cache key is written to easily find keys matching a prefix for deletion: // - entity cache version: current version of the hash @@ -1189,29 +1444,219 @@ fn extract_cache_keys( // - entity key: invalidate a specific entity // - query hash: invalidate the entry for a specific query and operation name // - additional data: separate cache entries depending on info like authorization status - let mut key = String::new(); - let _ = write!(&mut key, "version:{ENTITY_CACHE_VERSION}:subgraph:{subgraph_name}:type:{typename}:entity:{hashed_entity_key}:hash:{query_hash}:data:{additional_data_hash}"); - if is_known_private { - if let Some(id) = private_id { - let _ = write!(&mut key, ":{id}"); - } + let mut key = format!( + "version:{ENTITY_CACHE_VERSION}:subgraph:{subgraph_name}:type:{typename}:entity:{hashed_entity_key}:representation:{hashed_representation}:hash:{query_hash}:data:{additional_data_hash}" + ); + if is_known_private && let Some(id) = private_id { + let _ = write!(&mut key, ":{id}"); } - representation - .as_object_mut() - .map(|o| o.insert(TYPENAME, opt_type)); + // Restore the `representation` back whole again + representation.insert(TYPENAME, typename_value); + merge_representation(representation, representation_entity_key); + res.push(key); } Ok(res) } -pub(crate) fn hash_entity_key(representation: &Value) -> String { - // We have to hash the representation because it can contains PII +fn take_matching_key_field_set( + representation: &mut serde_json_bytes::Map, + typename: &str, + subgraph_name: &str, + supergraph_schema: &Valid, + subgraph_enums: &HashMap, +) -> Result, FetchError> { + // find an entry in the `key_field_sets` that matches the `representation`. + let matched_key_field_set = + collect_key_field_sets(typename, subgraph_name, supergraph_schema, subgraph_enums)? + .find(|field_set| { + matches_selection_set(representation, &field_set.selection_set) + }) + .ok_or_else(|| { + tracing::trace!("representation does not match any key field set for typename {typename} in subgraph {subgraph_name}"); + FetchError::MalformedRequest { + reason: format!("unexpected critical internal error for typename {typename} in subgraph {subgraph_name}"), + } + })?; + take_selection_set(representation, &matched_key_field_set.selection_set).ok_or_else(|| { + FetchError::MalformedRequest { + reason: format!("representation does not match the field set {matched_key_field_set}"), + } + }) +} + +// Collect `@key` field sets on a `typename` in a `subgraph_name`. +// - Returns a Vec of FieldSet, since there may be more than one @key directives in the subgraph. +fn collect_key_field_sets( + typename: &str, + subgraph_name: &str, + supergraph_schema: &Valid, + subgraph_enums: &HashMap, +) -> Result, FetchError> { + Ok(supergraph_schema + .types + .get(typename) + .ok_or_else(|| FetchError::MalformedRequest { + reason: format!("unknown typename {typename:?} in representations"), + })? + .directives() + .get_all("join__type") + .filter_map(move |directive| { + let schema_subgraph_name = directive + .specified_argument_by_name("graph") + .and_then(|arg| arg.as_enum()) + .and_then(|arg| subgraph_enums.get(arg.as_str()))?; + + if schema_subgraph_name == subgraph_name { + let mut parser = Parser::new(); + directive + .specified_argument_by_name("key") + .and_then(|arg| arg.as_str()) + .and_then(|arg| { + parser + .parse_field_set( + supergraph_schema, + NamedType::new(typename).ok()?, + arg, + "entity_caching.graphql", + ) + .ok() + }) + } else { + None + } + })) +} + +// Does the shape of `representation` match the `selection_set`? +fn matches_selection_set( + representation: &serde_json_bytes::Map, + selection_set: &apollo_compiler::executable::SelectionSet, +) -> bool { + for field in selection_set.root_fields(&Default::default()) { + // Note: field sets can't have aliases. + let Some(value) = representation.get(field.name.as_str()) else { + return false; + }; + + if field.selection_set.is_empty() { + // `value` must be a scalar. + if matches!(value, Value::Object(_)) { + return false; + } + continue; + } + + // Check the sub-selection set. + let Value::Object(sub_value) = value else { + return false; + }; + if !matches_selection_set(sub_value, &field.selection_set) { + return false; + } + } + true +} + +// Removes the selection set from `representation` and returns the value corresponding to it. +// - Returns None if the representation doesn't match the selection set. +fn take_selection_set( + representation: &mut serde_json_bytes::Map, + selection_set: &apollo_compiler::executable::SelectionSet, +) -> Option> { + let mut result = serde_json_bytes::Map::new(); + for field in selection_set.root_fields(&Default::default()) { + // Note: field sets can't have aliases. + if field.selection_set.is_empty() { + let value = representation.remove(field.name.as_str())?; + // `value` must be a scalar. + if matches!(value, Value::Object(_)) { + return None; + } + // Move the scalar field to the `result`. + result.insert(ByteString::from(field.name.as_str()), value); + continue; + } else { + let value = representation.get_mut(field.name.as_str())?; + // Update the sub-selection set. + let Value::Object(sub_value) = value else { + return None; + }; + let removed = take_selection_set(sub_value, &field.selection_set)?; + result.insert( + ByteString::from(field.name.as_str()), + Value::Object(removed), + ); + } + } + Some(result) +} + +// The inverse of `take_selection_set`. +fn merge_representation( + dest: &mut serde_json_bytes::Map, + source: serde_json_bytes::Map, +) { + source.into_iter().for_each(|(key, src_value)| { + // Note: field sets can't have aliases. + let Some(dest_value) = dest.get_mut(&key) else { + dest.insert(key, src_value); + return; + }; + + // Overlapping fields must be objects. + if let (Value::Object(dest_sub_value), Value::Object(src_sub_value)) = + (dest_value, src_value) + { + // Merge sub-values + merge_representation(dest_sub_value, src_sub_value); + } + }); +} + +// Order-insensitive structural hash of the representation value +pub(crate) fn hash_representation( + representation: &serde_json_bytes::Map, +) -> String { let mut digest = Sha256::new(); - digest.update(serde_json::to_string(&representation).unwrap().as_bytes()); + fn hash(state: &mut Sha256, fields: &serde_json_bytes::Map) { + fields + .iter() + .sorted_by(|a, b| a.0.cmp(b.0)) + .for_each(|(k, v)| { + state.update(serde_json::to_string(k).unwrap().as_bytes()); + state.update(":".as_bytes()); + match v { + serde_json_bytes::Value::Object(obj) => { + state.update("{".as_bytes()); + hash(state, obj); + state.update("}".as_bytes()); + } + _ => state.update(serde_json::to_string(v).unwrap().as_bytes()), + } + }); + } + hash(&mut digest, representation); hex::encode(digest.finalize().as_slice()) } +// Only hash the list of entity keys +pub(crate) fn hash_entity_key( + entity_keys: &serde_json_bytes::Map, +) -> String { + tracing::trace!("entity keys: {entity_keys:?}"); + // We have to hash the representation because it can contains PII + hash_representation(entity_keys) +} + +// Hash other representation variables except __typename and entity keys +fn hash_other_representation( + representation: &mut serde_json_bytes::Map, +) -> String { + hash_representation(representation) +} + /// represents the result of a cache lookup for an entity type and key struct IntermediateResult { key: String, @@ -1419,3 +1864,165 @@ fn assemble_response_from_errors( } (new_entities, new_errors) } + +pub(crate) type CacheKeysContext = HashMap>; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(test, derive(PartialEq, Eq, Hash, PartialOrd, Ord))] +pub(crate) struct CacheKeyContext { + pub(super) key: String, + pub(super) status: CacheKeyStatus, + pub(super) cache_control: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(test, derive(PartialEq, Eq, Hash))] +#[serde(rename_all = "snake_case")] +pub(crate) enum CacheKeyStatus { + /// New cache key inserted in the cache + New, + /// Key that was already in the cache + Cached, +} + +#[cfg(test)] +impl PartialOrd for CacheKeyStatus { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[cfg(test)] +impl Ord for CacheKeyStatus { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match (self, other) { + (CacheKeyStatus::New, CacheKeyStatus::New) => std::cmp::Ordering::Equal, + (CacheKeyStatus::New, CacheKeyStatus::Cached) => std::cmp::Ordering::Greater, + (CacheKeyStatus::Cached, CacheKeyStatus::New) => std::cmp::Ordering::Less, + (CacheKeyStatus::Cached, CacheKeyStatus::Cached) => std::cmp::Ordering::Equal, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugins::cache::tests::MockStore; + use crate::plugins::cache::tests::SCHEMA; + + #[tokio::test] + async fn test_subgraph_enabled() { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + let redis_cache = RedisCacheStorage::from_mocks(Arc::new(MockStore::new())) + .await + .unwrap(); + let map = serde_json::json!({ + "user": { + "private_id": "sub" + }, + "orga": { + "private_id": "sub", + "enabled": true + }, + "archive": { + "private_id": "sub", + "enabled": false + } + }); + + let mut entity_cache = EntityCache::with_mocks( + redis_cache.clone(), + serde_json::from_value(map).unwrap(), + valid_schema.clone(), + ) + .await + .unwrap(); + + assert!(entity_cache.subgraph_enabled("user")); + assert!(!entity_cache.subgraph_enabled("archive")); + let subgraph_config = serde_json::json!({ + "all": { + "enabled": false + }, + "subgraphs": entity_cache.subgraphs.subgraphs.clone() + }); + entity_cache.subgraphs = Arc::new(serde_json::from_value(subgraph_config).unwrap()); + assert!(!entity_cache.subgraph_enabled("archive")); + assert!(entity_cache.subgraph_enabled("user")); + assert!(entity_cache.subgraph_enabled("orga")); + } + + #[tokio::test] + async fn test_subgraph_ttl() { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + let mut redis_cache = RedisCacheStorage::from_mocks(Arc::new(MockStore::new())) + .await + .unwrap(); + let map = serde_json::json!({ + "user": { + "private_id": "sub", + "ttl": "2s" + }, + "orga": { + "private_id": "sub", + "enabled": true + }, + "archive": { + "private_id": "sub", + "enabled": false, + "ttl": "5000ms" + } + }); + + let mut entity_cache = EntityCache::with_mocks( + redis_cache.clone(), + serde_json::from_value(map).unwrap(), + valid_schema.clone(), + ) + .await + .unwrap(); + + assert_eq!( + entity_cache.subgraph_ttl("user", &redis_cache), + Some(Duration::from_secs(2)) + ); + assert!(entity_cache.subgraph_ttl("orga", &redis_cache).is_none()); + assert_eq!( + entity_cache.subgraph_ttl("archive", &redis_cache), + Some(Duration::from_millis(5000)) + ); + // update global storage TTL + redis_cache.ttl = Some(Duration::from_secs(25)); + assert_eq!( + entity_cache.subgraph_ttl("user", &redis_cache), + Some(Duration::from_secs(2)) + ); + assert_eq!( + entity_cache.subgraph_ttl("orga", &redis_cache), + Some(Duration::from_secs(25)) + ); + assert_eq!( + entity_cache.subgraph_ttl("archive", &redis_cache), + Some(Duration::from_millis(5000)) + ); + entity_cache.subgraphs = Arc::new(SubgraphConfiguration { + all: Subgraph { + ttl: Some(Ttl(Duration::from_secs(42))), + ..Default::default() + }, + subgraphs: entity_cache.subgraphs.subgraphs.clone(), + }); + assert_eq!( + entity_cache.subgraph_ttl("user", &redis_cache), + Some(Duration::from_secs(2)) + ); + assert_eq!( + entity_cache.subgraph_ttl("orga", &redis_cache), + Some(Duration::from_secs(42)) + ); + assert_eq!( + entity_cache.subgraph_ttl("archive", &redis_cache), + Some(Duration::from_millis(5000)) + ); + } +} diff --git a/apollo-router/src/plugins/cache/invalidation.rs b/apollo-router/src/plugins/cache/invalidation.rs index 45d623299c..1c7c014234 100644 --- a/apollo-router/src/plugins/cache/invalidation.rs +++ b/apollo-router/src/plugins/cache/invalidation.rs @@ -1,13 +1,14 @@ use std::sync::Arc; use std::time::Instant; -use fred::error::RedisError; -use fred::types::Scanner; -use futures::stream; +use fred::error::Error as RedisError; +use fred::types::scan::Scanner; use futures::StreamExt; +use futures::stream; use itertools::Itertools; use serde::Deserialize; use serde::Serialize; +use serde_json_bytes::ByteString; use serde_json_bytes::Value; use thiserror::Error; use tokio::sync::Semaphore; @@ -16,9 +17,8 @@ use tracing::Instrument; use super::entity::Storage as EntityStorage; use crate::cache::redis::RedisCacheStorage; -use crate::cache::redis::RedisKey; -use crate::plugins::cache::entity::hash_entity_key; use crate::plugins::cache::entity::ENTITY_CACHE_VERSION; +use crate::plugins::cache::entity::hash_entity_key; #[derive(Clone)] pub(crate) struct Invalidation { @@ -50,9 +50,6 @@ impl std::fmt::Display for InvalidationErrors { impl std::error::Error for InvalidationErrors {} -#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] -pub(crate) struct InvalidationTopic; - #[derive(Clone, Debug, PartialEq)] pub(crate) enum InvalidationOrigin { Endpoint, @@ -101,7 +98,7 @@ impl Invalidation { &self, redis_storage: &RedisCacheStorage, origin: &'static str, - request: &InvalidationRequest, + request: &mut InvalidationRequest, ) -> Result { let key_prefix = request.key_prefix(); let subgraph = request.subgraph_name(); @@ -110,7 +107,8 @@ impl Invalidation { key_prefix ); - let mut stream = redis_storage.scan(key_prefix.clone(), Some(self.scan_count)); + let mut stream = + redis_storage.scan_with_namespaced_results(key_prefix.clone(), Some(self.scan_count)); let mut count = 0u64; let mut error = None; @@ -125,19 +123,17 @@ impl Invalidation { error = Some(e); break; } - Ok(scan_res) => { - if let Some(keys) = scan_res.results() { - let keys = keys - .iter() - .filter_map(|k| k.as_str()) - .map(|k| RedisKey(k.to_string())) - .collect::>(); - if !keys.is_empty() { - let deleted = redis_storage.delete(keys).await.unwrap_or(0) as u64; - count += deleted; - } + Ok(mut scan_res) => { + if let Some(keys) = scan_res.take_results() + && !keys.is_empty() + { + let deleted = redis_storage + .delete_from_scan_result(keys) + .await + .unwrap_or(0) as u64; + count += deleted; } - scan_res.next()?; + scan_res.next(); } } } @@ -170,7 +166,7 @@ impl Invalidation { let mut count = 0; let mut errors = Vec::new(); let mut futures = Vec::new(); - for request in requests { + for mut request in requests { let redis_storage = match self.storage.get(request.subgraph_name()) { Some(s) => s, None => continue, @@ -184,13 +180,13 @@ impl Invalidation { let start = Instant::now(); let res = self - .handle_request(redis_storage, origin, &request) + .handle_request(redis_storage, origin, &mut request) .instrument(tracing::info_span!("cache.invalidation.request")) .await; f64_histogram!( "apollo.router.cache.invalidation.duration", - "Duration of the invalidation event execution.", + "Duration of the invalidation event execution, in seconds.", start.elapsed().as_secs_f64() ); res @@ -228,12 +224,13 @@ pub(crate) enum InvalidationRequest { Entity { subgraph: String, r#type: String, - key: Value, + key: serde_json_bytes::Map, }, } impl InvalidationRequest { - fn key_prefix(&self) -> String { + /// Compute a cache key prefix. For entity keys, this destructively sorts all objects. + fn key_prefix(&mut self) -> String { match self { InvalidationRequest::Subgraph { subgraph } => { format!("version:{ENTITY_CACHE_VERSION}:subgraph:{subgraph}:*",) @@ -247,7 +244,9 @@ impl InvalidationRequest { key, } => { let entity_key = hash_entity_key(key); - format!("version:{ENTITY_CACHE_VERSION}:subgraph:{subgraph}:type:{type}:entity:{entity_key}:*") + format!( + "version:{ENTITY_CACHE_VERSION}:subgraph:{subgraph}:type:{type}:entity:{entity_key}:*" + ) } } } @@ -259,4 +258,12 @@ impl InvalidationRequest { | InvalidationRequest::Entity { subgraph, .. } => subgraph, } } + + pub(super) fn kind(&self) -> &'static str { + match self { + InvalidationRequest::Subgraph { .. } => "subgraph", + InvalidationRequest::Type { .. } => "type", + InvalidationRequest::Entity { .. } => "entity", + } + } } diff --git a/apollo-router/src/plugins/cache/invalidation_endpoint.rs b/apollo-router/src/plugins/cache/invalidation_endpoint.rs index 0b1af7a9b3..d08f5dcaa0 100644 --- a/apollo-router/src/plugins/cache/invalidation_endpoint.rs +++ b/apollo-router/src/plugins/cache/invalidation_endpoint.rs @@ -3,25 +3,31 @@ use std::task::Poll; use bytes::Buf; use futures::future::BoxFuture; -use http::header::AUTHORIZATION; use http::Method; use http::StatusCode; +use http::header::AUTHORIZATION; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use serde_json_bytes::json; use tower::BoxError; use tower::Service; +use tracing::Span; use tracing_futures::Instrument; use super::entity::Subgraph; use super::invalidation::Invalidation; use super::invalidation::InvalidationOrigin; +use crate::ListenAddr; use crate::configuration::subgraph::SubgraphConfiguration; +use crate::graphql; use crate::plugins::cache::invalidation::InvalidationRequest; +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE; +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE_ERROR; +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE_OK; use crate::services::router; -use crate::services::router::body::RouterBody; -use crate::ListenAddr; + +pub(crate) const INVALIDATION_ENDPOINT_SPAN_NAME: &str = "invalidation_endpoint"; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)] #[serde(rename_all = "snake_case", deny_unknown_fields, default)] @@ -102,18 +108,22 @@ impl Service for InvalidationService { async move { let (parts, body) = req.router_request.into_parts(); if !parts.headers.contains_key(AUTHORIZATION) { - return Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body("Missing authorization header".into()) - .map_err(BoxError::from)?, - context: req.context, - }); + Span::current().record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + return router::Response::error_builder() + .status_code(StatusCode::UNAUTHORIZED) + .error( + graphql::Error::builder() + .message(String::from("Missing authorization header")) + .extension_code(StatusCode::UNAUTHORIZED.to_string()) + .build(), + ) + .context(req.context) + .build(); } match parts.method { Method::POST => { - let body = Into::::into(body) - .to_bytes() + let body = router::body::into_bytes(body) + .instrument(tracing::info_span!("into_bytes")) .await .map_err(|e| format!("failed to get the request body: {e}")) .and_then(|bytes| { @@ -130,66 +140,110 @@ impl Service for InvalidationService { .headers .get(AUTHORIZATION) .ok_or("cannot find authorization header")? - .to_str()?; + .to_str() + .inspect_err(|_err| { + Span::current().record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + })?; match body { Ok(body) => { + Span::current().record( + "invalidation.request.kinds", + body.iter() + .map(|i| i.kind()) + .collect::>() + .join(", "), + ); let valid_shared_key = body.iter().map(|b| b.subgraph_name()).any(|subgraph_name| { valid_shared_key(&config, shared_key, subgraph_name) }); if !valid_shared_key { - return Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body("Invalid authorization header".into()) - .map_err(BoxError::from)?, - context: req.context, - }); + Span::current() + .record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + return router::Response::error_builder() + .status_code(StatusCode::UNAUTHORIZED) + .error( + graphql::Error::builder() + .message("Invalid authorization header") + .extension_code( + StatusCode::UNAUTHORIZED.to_string(), + ) + .build(), + ) + .context(req.context) + .build(); } match invalidation .invalidate(InvalidationOrigin::Endpoint, body) + .instrument(tracing::info_span!("invalidate")) .await { - Ok(count) => Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::ACCEPTED) - .body( - serde_json::to_string(&json!({ - "count": count - }))? - .into(), + Ok(count) => router::Response::http_response_builder() + .response( + http::Response::builder() + .status(StatusCode::ACCEPTED) + .body(router::body::from_bytes( + serde_json::to_string(&json!({ + "count": count + }))?, + )) + .map_err(BoxError::from)?, + ) + .context(req.context) + .build(), + Err(err) => { + Span::current() + .record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + router::Response::error_builder() + .status_code(StatusCode::BAD_REQUEST) + .error( + graphql::Error::builder() + .message(err.to_string()) + .extension_code( + StatusCode::BAD_REQUEST.to_string(), + ) + .build(), ) - .map_err(BoxError::from)?, - context: req.context, - }), - Err(err) => Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(err.to_string().into()) - .map_err(BoxError::from)?, - context: req.context, - }), + .context(req.context) + .build() + } } } - Err(err) => Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(err.into()) - .map_err(BoxError::from)?, - context: req.context, - }), + Err(err) => { + Span::current().record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + router::Response::error_builder() + .status_code(StatusCode::BAD_REQUEST) + .error( + graphql::Error::builder() + .message(err) + .extension_code(StatusCode::BAD_REQUEST.to_string()) + .build(), + ) + .context(req.context) + .build() + } } } - _ => Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::METHOD_NOT_ALLOWED) - .body("".into()) - .map_err(BoxError::from)?, - context: req.context, - }), + _ => { + Span::current().record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + router::Response::error_builder() + .status_code(StatusCode::METHOD_NOT_ALLOWED) + .error( + graphql::Error::builder() + .message("".to_string()) + .extension_code(StatusCode::METHOD_NOT_ALLOWED.to_string()) + .build(), + ) + .context(req.context) + .build() + } } } - .instrument(tracing::info_span!("invalidation_endpoint")), + .instrument(tracing::info_span!( + INVALIDATION_ENDPOINT_SPAN_NAME, + "invalidation.request.kinds" = ::tracing::field::Empty, + "otel.status_code" = OTEL_STATUS_CODE_OK, + )), ) } } @@ -238,7 +292,7 @@ mod tests { let config = Arc::new(SubgraphConfiguration { all: Subgraph { ttl: None, - enabled: true, + enabled: Some(true), redis: None, private_id: None, invalidation: Some(SubgraphInvalidationConfig { @@ -284,7 +338,7 @@ mod tests { let config = Arc::new(SubgraphConfiguration { all: Subgraph { ttl: None, - enabled: true, + enabled: Some(true), redis: None, private_id: None, invalidation: Some(SubgraphInvalidationConfig { @@ -296,7 +350,7 @@ mod tests { String::from("test"), Subgraph { ttl: None, - enabled: true, + enabled: Some(true), redis: None, private_id: None, invalidation: Some(SubgraphInvalidationConfig { diff --git a/apollo-router/src/plugins/cache/metrics.rs b/apollo-router/src/plugins/cache/metrics.rs index 86e802c093..0e3197c218 100644 --- a/apollo-router/src/plugins/cache/metrics.rs +++ b/apollo-router/src/plugins/cache/metrics.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; +use std::task::Poll; use std::time::Duration; use std::time::Instant; @@ -8,18 +9,20 @@ use http::header; use parking_lot::Mutex; use serde_json_bytes::Value; use tower::BoxError; +use tower::ServiceBuilder; +use tower::ServiceExt; use tower_service::Service; +use super::entity::REPRESENTATIONS; +use super::entity::Ttl; use super::entity::hash_query; use super::entity::hash_vary_headers; -use super::entity::Ttl; -use super::entity::REPRESENTATIONS; +use crate::layers::ServiceBuilderExt; use crate::services::subgraph; use crate::spec::TYPENAME; pub(crate) const CACHE_INFO_SUBGRAPH_CONTEXT_KEY: &str = "apollo::router::entity_cache_info_subgraph"; -pub(crate) struct CacheMetricsService(Option); impl CacheMetricsService { pub(crate) fn create( @@ -28,19 +31,23 @@ impl CacheMetricsService { ttl: Option<&Ttl>, separate_per_type: bool, ) -> subgraph::BoxService { - tower::util::BoxService::new(CacheMetricsService(Some(InnerCacheMetricsService { - service, + tower::util::BoxService::new(CacheMetricsService { + service: ServiceBuilder::new() + .buffered() + .service(service) + .boxed_clone(), name: Arc::new(name), counter: Some(Arc::new(Mutex::new(CacheCounter::new( ttl.map(|t| t.0).unwrap_or_else(|| Duration::from_secs(60)), separate_per_type, )))), - }))) + }) } } -pub(crate) struct InnerCacheMetricsService { - service: subgraph::BoxService, +#[derive(Clone)] +pub(crate) struct CacheMetricsService { + service: subgraph::BoxCloneService, name: Arc, counter: Option>>, } @@ -52,35 +59,32 @@ impl Service for CacheMetricsService { fn poll_ready( &mut self, - cx: &mut std::task::Context<'_>, + _cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { - match &mut self.0 { - Some(s) => s.service.poll_ready(cx), - None => panic!("service should have been called only once"), - } + Poll::Ready(Ok(())) } fn call(&mut self, request: subgraph::Request) -> Self::Future { - match self.0.take() { - None => panic!("service should have been called only once"), - Some(s) => Box::pin(s.call_inner(request)), - } + let clone = self.clone(); + let inner = std::mem::replace(self, clone); + + Box::pin(inner.call_inner(request)) } } -impl InnerCacheMetricsService { +impl CacheMetricsService { async fn call_inner( mut self, mut request: subgraph::Request, ) -> Result { let cache_attributes = Self::get_cache_attributes(&mut request); - let response = self.service.call(request).await?; + let response = self.service.ready().await?.call(request).await?; - if let Some(cache_attributes) = cache_attributes { - if let Some(counter) = &self.counter { - Self::update_cache_metrics(&self.name, counter, &response, cache_attributes) - } + if let Some(cache_attributes) = cache_attributes + && let Some(counter) = &self.counter + { + Self::update_cache_metrics(&self.name, counter, &response, cache_attributes) } Ok(response) @@ -197,7 +201,7 @@ impl CacheCounter { fn make_filter() -> Bloom { // the filter is around 4kB in size (can be calculated with `Bloom::compute_bitmap_size`) - Bloom::new_for_fp_rate(10000, 0.2) + Bloom::new_for_fp_rate(10000, 0.2).expect("no OS source of randomness, that's a bit much") } pub(crate) fn record( @@ -244,15 +248,21 @@ impl CacheCounter { for (typename, (cache_hit, total_entities)) in seen.into_iter() { if separate_metrics_per_type { - ::tracing::info!( - histogram.apollo.router.operations.entity.cache_hit = (cache_hit as f64 / total_entities as f64) * 100f64, - entity_type = %typename, - subgraph = %subgraph_name, + f64_histogram!( + "apollo.router.operations.entity.cache_hit", + "Hit rate percentage of cached entities", + (cache_hit as f64 / total_entities as f64) * 100f64, + // Can't just `Arc::clone` these because they're `Arc`, + // while opentelemetry supports `Arc` + entity_type = typename.to_string(), + subgraph = subgraph_name.to_string() ); } else { - ::tracing::info!( - histogram.apollo.router.operations.entity.cache_hit = (cache_hit as f64 / total_entities as f64) * 100f64, - subgraph = %subgraph_name, + f64_histogram!( + "apollo.router.operations.entity.cache_hit", + "Hit rate percentage of cached entities", + (cache_hit as f64 / total_entities as f64) * 100f64, + subgraph = subgraph_name.to_string() ); } } diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-2.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-2.snap index e3d6799c33..7d3bb156a2 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-2.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-2.snap @@ -1,17 +1,7 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response +expression: response.response.headers().get(CACHE_CONTROL) --- -{ - "data": { - "currentUser": { - "activeOrganization": { - "id": "1", - "creatorUser": { - "__typename": "User", - "id": 2 - } - } - } - } -} +Some( + "public", +) diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-3.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-3.snap index 7d3bb156a2..e3d6799c33 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-3.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-3.snap @@ -1,7 +1,17 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response.response.headers().get(CACHE_CONTROL) +expression: response --- -Some( - "public", -) +{ + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-4.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-4.snap index e3d6799c33..7d3bb156a2 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-4.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-4.snap @@ -1,17 +1,7 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response +expression: response.response.headers().get(CACHE_CONTROL) --- -{ - "data": { - "currentUser": { - "activeOrganization": { - "id": "1", - "creatorUser": { - "__typename": "User", - "id": 2 - } - } - } - } -} +Some( + "public", +) diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-5.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-5.snap new file mode 100644 index 0000000000..06460ab44e --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-5.snap @@ -0,0 +1,16 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:24c28e0fbf1bd14093d77e5fa35abb9b6311a81b6331e894829ebd3188272315:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "public" + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:c6e5b190c1212fef780ab4f8edfd7fa0aeafda5b6246cea17816796fa4d4a545:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "public" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-6.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-6.snap new file mode 100644 index 0000000000..e3d6799c33 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-6.snap @@ -0,0 +1,17 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert.snap index 7d3bb156a2..6df385b501 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert.snap @@ -1,7 +1,16 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response.response.headers().get(CACHE_CONTROL) +expression: cache_keys --- -Some( - "public", -) +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:24c28e0fbf1bd14093d77e5fa35abb9b6311a81b6331e894829ebd3188272315:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "public" + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:c6e5b190c1212fef780ab4f8edfd7fa0aeafda5b6246cea17816796fa4d4a545:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "public" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-2.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-2.snap new file mode 100644 index 0000000000..7d3bb156a2 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-2.snap @@ -0,0 +1,7 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response.response.headers().get(CACHE_CONTROL) +--- +Some( + "public", +) diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-3.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-3.snap new file mode 100644 index 0000000000..3f2316f7a1 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-3.snap @@ -0,0 +1,19 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "allProducts": [ + { + "name": "Test", + "createdBy": { + "name": "test", + "country": { + "a": "France" + } + } + } + ] + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-4.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-4.snap new file mode 100644 index 0000000000..7d3bb156a2 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-4.snap @@ -0,0 +1,7 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response.response.headers().get(CACHE_CONTROL) +--- +Some( + "public", +) diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-5.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-5.snap new file mode 100644 index 0000000000..448bc31e3e --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-5.snap @@ -0,0 +1,16 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:products:type:Query:hash:6173063a04125ecfdaf77111980dc68921dded7813208fdf1d7d38dfbb959627:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "public" + }, + { + "key": "version:1.0:subgraph:users:type:User:entity:210e26346d676046faa9fb55d459273a43e5b5397a1a056f179a3521dc5643aa:representation:7cd02a08f4ea96f0affa123d5d3f56abca20e6014e060fe5594d210c00f64b27:hash:2820563c632c1ab498e06030084acf95c97e62afba71a3d4b7c5e81a11cb4d13:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "public" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-6.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-6.snap new file mode 100644 index 0000000000..3f2316f7a1 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set-6.snap @@ -0,0 +1,19 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "allProducts": [ + { + "name": "Test", + "createdBy": { + "name": "test", + "country": { + "a": "France" + } + } + } + ] + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set.snap new file mode 100644 index 0000000000..30df450e76 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_nested_field_set.snap @@ -0,0 +1,16 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:products:type:Query:hash:6173063a04125ecfdaf77111980dc68921dded7813208fdf1d7d38dfbb959627:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "public" + }, + { + "key": "version:1.0:subgraph:users:type:User:entity:210e26346d676046faa9fb55d459273a43e5b5397a1a056f179a3521dc5643aa:representation:7cd02a08f4ea96f0affa123d5d3f56abca20e6014e060fe5594d210c00f64b27:hash:2820563c632c1ab498e06030084acf95c97e62afba71a3d4b7c5e81a11cb4d13:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "public" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-2.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-2.snap new file mode 100644 index 0000000000..7d3bb156a2 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-2.snap @@ -0,0 +1,7 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response.response.headers().get(CACHE_CONTROL) +--- +Some( + "public", +) diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-3.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-3.snap new file mode 100644 index 0000000000..af7d59f620 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-3.snap @@ -0,0 +1,15 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "Test", + "shippingEstimate": 15, + "price": 150 + } + ] + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-4.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-4.snap new file mode 100644 index 0000000000..7d3bb156a2 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-4.snap @@ -0,0 +1,7 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response.response.headers().get(CACHE_CONTROL) +--- +Some( + "public", +) diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-5.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-5.snap new file mode 100644 index 0000000000..40ab651f2a --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-5.snap @@ -0,0 +1,16 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:inventory:type:Product:entity:72bafad9ffe61307806863b13856470e429e0cf332c99e5b735224fb0b1436f7:representation:7a26bc9b6cfab31cd9278921710167eaf9cde6c17d5f86102c898a04a06aeb6c:hash:900c10078145fb50a6556e6c44a88e8ddf8e64a1d9f1f1cc013885f5bca5741b:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "public" + }, + { + "key": "version:1.0:subgraph:products:type:Query:hash:479d4e6883a932c8690e028c0ac0f8580365e7782aecdc30f94477f1e995afbc:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "public" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-6.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-6.snap new file mode 100644 index 0000000000..af7d59f620 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires-6.snap @@ -0,0 +1,15 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "Test", + "shippingEstimate": 15, + "price": 150 + } + ] + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires.snap new file mode 100644 index 0000000000..1f0f4eadc5 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert_with_requires.snap @@ -0,0 +1,16 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:inventory:type:Product:entity:72bafad9ffe61307806863b13856470e429e0cf332c99e5b735224fb0b1436f7:representation:7a26bc9b6cfab31cd9278921710167eaf9cde6c17d5f86102c898a04a06aeb6c:hash:900c10078145fb50a6556e6c44a88e8ddf8e64a1d9f1f1cc013885f5bca5741b:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "public" + }, + { + "key": "version:1.0:subgraph:products:type:Query:hash:479d4e6883a932c8690e028c0ac0f8580365e7782aecdc30f94477f1e995afbc:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "public" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-2.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-2.snap new file mode 100644 index 0000000000..e3d6799c33 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-2.snap @@ -0,0 +1,17 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-3.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-3.snap new file mode 100644 index 0000000000..7d3bb156a2 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-3.snap @@ -0,0 +1,7 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response.response.headers().get(CACHE_CONTROL) +--- +Some( + "public", +) diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-4.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-4.snap new file mode 100644 index 0000000000..35b3843001 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-4.snap @@ -0,0 +1,16 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:4db0b6e27a228df592ab8ae4e6c776bb048cb4d82d191a9eb49c182705781e78:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "public" + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:3e689b15c9cee1f2812c319a66ddbe8bfc452c8ab6759566ed1987cae95178cd:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "public" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-5.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-5.snap new file mode 100644 index 0000000000..e3d6799c33 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity-5.snap @@ -0,0 +1,17 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity.snap new file mode 100644 index 0000000000..9fe98a8b50 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__invalidate_entity.snap @@ -0,0 +1,16 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:4db0b6e27a228df592ab8ae4e6c776bb048cb4d82d191a9eb49c182705781e78:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "public" + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:3e689b15c9cee1f2812c319a66ddbe8bfc452c8ab6759566ed1987cae95178cd:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "public" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__missing_entities-2.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__missing_entities-2.snap index 9798af179e..0944c313df 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__missing_entities-2.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__missing_entities-2.snap @@ -28,7 +28,10 @@ expression: response "currentUser", "allOrganizations", 2 - ] + ], + "extensions": { + "service": "orga" + } } ] } diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_cache_control-4.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_cache_control-4.snap index 3c363a2b4d..674cc44083 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_cache_control-4.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_cache_control-4.snap @@ -5,5 +5,15 @@ expression: response { "data": { "currentUser": null - } + }, + "errors": [ + { + "message": "subgraph mock not configured", + "path": [], + "extensions": { + "code": "SUBGRAPH_MOCK_NOT_CONFIGURED", + "service": "user" + } + } + ] } diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-2.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-2.snap index 6e58a2d437..b9832aaeaa 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-2.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-2.snap @@ -10,30 +10,11 @@ expression: response "id": "1", "name": "Organization 1" }, - { - "id": "2", - "name": null - }, { "id": "3", "name": "Organization 3" } ] } - }, - "errors": [ - { - "message": "HTTP fetch failed from 'orga': orga not found", - "path": [ - "currentUser", - "allOrganizations", - 1 - ], - "extensions": { - "code": "SUBREQUEST_HTTP_ERROR", - "service": "orga", - "reason": "orga not found" - } - } - ] + } } diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-3.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-3.snap new file mode 100644 index 0000000000..60a81e2be7 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-3.snap @@ -0,0 +1,16 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:2a66208010218056832ffcb8e3e26c636cb2a57e71fc62b424909e2ab2246145:representation::hash:c51b7c3366074b6bbeb4022520a2eddb75fb7c18fb2f88b7fe36bbe2a5402ef2:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "[REDACTED]" + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:c51b7c3366074b6bbeb4022520a2eddb75fb7c18fb2f88b7fe36bbe2a5402ef2:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "[REDACTED]" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-4.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-4.snap new file mode 100644 index 0000000000..6e58a2d437 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-4.snap @@ -0,0 +1,39 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "currentUser": { + "allOrganizations": [ + { + "id": "1", + "name": "Organization 1" + }, + { + "id": "2", + "name": null + }, + { + "id": "3", + "name": "Organization 3" + } + ] + } + }, + "errors": [ + { + "message": "HTTP fetch failed from 'orga': orga not found", + "path": [ + "currentUser", + "allOrganizations", + 1 + ], + "extensions": { + "code": "SUBREQUEST_HTTP_ERROR", + "service": "orga", + "reason": "orga not found" + } + } + ] +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data.snap index b9832aaeaa..6ed0ee494f 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data.snap @@ -1,20 +1,16 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response +expression: cache_keys --- -{ - "data": { - "currentUser": { - "allOrganizations": [ - { - "id": "1", - "name": "Organization 1" - }, - { - "id": "3", - "name": "Organization 3" - } - ] - } +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:2a66208010218056832ffcb8e3e26c636cb2a57e71fc62b424909e2ab2246145:representation::hash:c51b7c3366074b6bbeb4022520a2eddb75fb7c18fb2f88b7fe36bbe2a5402ef2:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "[REDACTED]" + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:c51b7c3366074b6bbeb4022520a2eddb75fb7c18fb2f88b7fe36bbe2a5402ef2:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "[REDACTED]" } -} +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-3.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-3.snap index 3c363a2b4d..4403d0d3cc 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-3.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-3.snap @@ -1,9 +1,16 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response +expression: cache_keys --- -{ - "data": { - "currentUser": null +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:24c28e0fbf1bd14093d77e5fa35abb9b6311a81b6331e894829ebd3188272315:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "status": "cached", + "cache_control": "private" + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:c6e5b190c1212fef780ab4f8edfd7fa0aeafda5b6246cea17816796fa4d4a545:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "status": "cached", + "cache_control": "private" } -} +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-4.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-4.snap new file mode 100644 index 0000000000..e3d6799c33 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-4.snap @@ -0,0 +1,17 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-5.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-5.snap new file mode 100644 index 0000000000..4403d0d3cc --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-5.snap @@ -0,0 +1,16 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:24c28e0fbf1bd14093d77e5fa35abb9b6311a81b6331e894829ebd3188272315:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "status": "cached", + "cache_control": "private" + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:c6e5b190c1212fef780ab4f8edfd7fa0aeafda5b6246cea17816796fa4d4a545:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "status": "cached", + "cache_control": "private" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-6.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-6.snap new file mode 100644 index 0000000000..674cc44083 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-6.snap @@ -0,0 +1,19 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "currentUser": null + }, + "errors": [ + { + "message": "subgraph mock not configured", + "path": [], + "extensions": { + "code": "SUBGRAPH_MOCK_NOT_CONFIGURED", + "service": "user" + } + } + ] +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private.snap index e3d6799c33..98007e00e5 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private.snap @@ -1,17 +1,16 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response +expression: cache_keys --- -{ - "data": { - "currentUser": { - "activeOrganization": { - "id": "1", - "creatorUser": { - "__typename": "User", - "id": 2 - } - } - } +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:24c28e0fbf1bd14093d77e5fa35abb9b6311a81b6331e894829ebd3188272315:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "private" + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:c6e5b190c1212fef780ab4f8edfd7fa0aeafda5b6246cea17816796fa4d4a545:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "status": "new", + "cache_control": "private" } -} +] diff --git a/apollo-router/src/plugins/cache/tests.rs b/apollo-router/src/plugins/cache/tests.rs index 24dc71d3ab..a172d4a8d7 100644 --- a/apollo-router/src/plugins/cache/tests.rs +++ b/apollo-router/src/plugins/cache/tests.rs @@ -1,29 +1,38 @@ use std::collections::HashMap; use std::sync::Arc; +use apollo_compiler::Schema; use bytes::Bytes; -use fred::error::RedisErrorKind; +use fred::error::ErrorKind as RedisErrorKind; use fred::mocks::MockCommand; use fred::mocks::Mocks; -use fred::prelude::RedisError; -use fred::prelude::RedisValue; -use http::header::CACHE_CONTROL; +use fred::prelude::Error as RedisError; +use fred::prelude::Value as RedisValue; use http::HeaderValue; +use http::header::CACHE_CONTROL; use parking_lot::Mutex; +use serde_json_bytes::ByteString; +use tower::Service; use tower::ServiceExt; use super::entity::EntityCache; +use crate::Context; +use crate::MockedSubgraphs; +use crate::TestHarness; use crate::cache::redis::RedisCacheStorage; use crate::plugin::test::MockSubgraph; use crate::plugin::test::MockSubgraphService; +use crate::plugins::cache::entity::CONTEXT_CACHE_KEYS; +use crate::plugins::cache::entity::CacheKeyContext; +use crate::plugins::cache::entity::CacheKeysContext; use crate::plugins::cache::entity::Subgraph; +use crate::plugins::cache::entity::hash_representation; use crate::services::subgraph; use crate::services::supergraph; -use crate::Context; -use crate::MockedSubgraphs; -use crate::TestHarness; -const SCHEMA: &str = include_str!("../../testdata/orga_supergraph.graphql"); +pub(super) const SCHEMA: &str = include_str!("../../testdata/orga_supergraph.graphql"); +const SCHEMA_REQUIRES: &str = include_str!("../../testdata/supergraph.graphql"); +const SCHEMA_NESTED_KEYS: &str = include_str!("../../testdata/supergraph_nested_fields.graphql"); #[derive(Debug)] pub(crate) struct MockStore { map: Arc>>, @@ -43,11 +52,11 @@ impl Mocks for MockStore { match &*command.cmd { "GET" => { - if let Some(RedisValue::Bytes(b)) = command.args.first() { - if let Some(bytes) = self.map.lock().get(b) { - println!("-> returning {:?}", std::str::from_utf8(bytes)); - return Ok(RedisValue::Bytes(bytes.clone())); - } + if let Some(RedisValue::Bytes(b)) = command.args.first() + && let Some(bytes) = self.map.lock().get(b) + { + println!("-> returning {:?}", std::str::from_utf8(bytes)); + return Ok(RedisValue::Bytes(bytes.clone())); } } "MGET" => { @@ -148,33 +157,174 @@ impl Mocks for MockStore { #[tokio::test] async fn insert() { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }"; - let subgraphs = MockedSubgraphs([ - ("user", MockSubgraph::builder().with_json( - serde_json::json!{{"query":"{currentUser{activeOrganization{__typename id}}}"}}, - serde_json::json!{{"data": {"currentUser": { "activeOrganization": { - "__typename": "Organization", - "id": "1" - } }}}} - ).with_header(CACHE_CONTROL, HeaderValue::from_static("public")).build()), - ("orga", MockSubgraph::builder().with_json( - serde_json::json!{{ - "query": "query($representations:[_Any!]!){_entities(representations:$representations){..._generated_onOrganization1_0}}fragment _generated_onOrganization1_0 on Organization{creatorUser{__typename id}}", - "variables": { - "representations": [ - { - "id": "1", + let subgraphs = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { "__typename": "Organization", + "id": "1", } - ] - }}}, - serde_json::json!{{"data": { - "_entities": [{ + } + }, + "headers": {"cache-control": "public"}, + }, + "orga": { + "entities": [ + { + "__typename": "Organization", + "id": "1", "creatorUser": { "__typename": "User", "id": 2 } + } + ], + "headers": {"cache-control": "public"}, + }, + }); + + let redis_cache = RedisCacheStorage::from_mocks(Arc::new(MockStore::new())) + .await + .unwrap(); + let map = [ + ( + "user".to_string(), + Subgraph { + redis: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "orga".to_string(), + Subgraph { + redis: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let entity_cache = EntityCache::with_mocks(redis_cache.clone(), map, valid_schema.clone()) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({ + "include_subgraph_errors": { "all": true }, + "experimental_mock_subgraphs": subgraphs, + })) + .unwrap() + .schema(SCHEMA) + .extra_plugin(entity_cache) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys); + let mut entity_key = serde_json_bytes::Map::new(); + entity_key.insert( + ByteString::from("id"), + serde_json_bytes::Value::String(ByteString::from("1")), + ); + let hashed_entity_key = hash_representation(&entity_key); + let prefix_key = + format!("version:1.0:subgraph:orga:type:Organization:entity:{hashed_entity_key}"); + assert!( + cache_keys + .iter() + .any(|cache_key| cache_key.key.starts_with(&prefix_key)) + ); + + insta::assert_debug_snapshot!(response.response.headers().get(CACHE_CONTROL)); + let response = response.next_response().await.unwrap(); + + insta::assert_json_snapshot!(response); + + // Now testing without any mock subgraphs, all the data should come from the cache + let entity_cache = + EntityCache::with_mocks(redis_cache.clone(), HashMap::new(), valid_schema.clone()) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(SCHEMA) + .extra_plugin(entity_cache) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + + insta::assert_debug_snapshot!(response.response.headers().get(CACHE_CONTROL)); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys); + + let response = response.next_response().await.unwrap(); + + insta::assert_json_snapshot!(response); +} + +#[tokio::test] +async fn insert_with_requires() { + let valid_schema = + Arc::new(Schema::parse_and_validate(SCHEMA_REQUIRES, "test.graphql").unwrap()); + let query = "query { topProducts { name shippingEstimate price } }"; + + let subgraphs = MockedSubgraphs([ + ("products", MockSubgraph::builder().with_json( + serde_json::json!{{"query":"{ topProducts { __typename upc name price weight } }"}}, + serde_json::json!{{"data": {"topProducts": [{ + "__typename": "Product", + "upc": "1", + "name": "Test", + "price": 150, + "weight": 5 + }]}}} + ).with_header(CACHE_CONTROL, HeaderValue::from_static("public")).build()), + ("inventory", MockSubgraph::builder().with_json( + serde_json::json!{{ + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { shippingEstimate } } }", + "variables": { + "representations": [ + { + "weight": 5, + "price": 150, + "upc": "1", + "__typename": "Product" + } + ] + }}}, + serde_json::json!{{"data": { + "_entities": [{ + "shippingEstimate": 15 }] }}} ).with_header(CACHE_CONTROL, HeaderValue::from_static("public")).build()) @@ -185,21 +335,21 @@ async fn insert() { .unwrap(); let map = [ ( - "user".to_string(), + "products".to_string(), Subgraph { redis: None, private_id: Some("sub".to_string()), - enabled: true, + enabled: true.into(), ttl: None, ..Default::default() }, ), ( - "orga".to_string(), + "inventory".to_string(), Subgraph { redis: None, private_id: Some("sub".to_string()), - enabled: true, + enabled: true.into(), ttl: None, ..Default::default() }, @@ -207,14 +357,14 @@ async fn insert() { ] .into_iter() .collect(); - let entity_cache = EntityCache::with_mocks(redis_cache.clone(), map) + let entity_cache = EntityCache::with_mocks(redis_cache.clone(), map, valid_schema.clone()) .await .unwrap(); let service = TestHarness::builder() .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) .unwrap() - .schema(SCHEMA) + .schema(SCHEMA_REQUIRES) .extra_plugin(entity_cache) .extra_plugin(subgraphs) .build_supergraph() @@ -227,6 +377,23 @@ async fn insert() { .build() .unwrap(); let mut response = service.oneshot(request).await.unwrap(); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + let mut entity_key = serde_json_bytes::Map::new(); + entity_key.insert( + ByteString::from("upc"), + serde_json_bytes::Value::String(ByteString::from("1")), + ); + let hashed_entity_key = hash_representation(&entity_key); + let prefix_key = + format!("version:1.0:subgraph:inventory:type:Product:entity:{hashed_entity_key}"); + assert!( + cache_keys + .iter() + .any(|cache_key| cache_key.key.starts_with(&prefix_key)) + ); + insta::assert_json_snapshot!(cache_keys); insta::assert_debug_snapshot!(response.response.headers().get(CACHE_CONTROL)); let response = response.next_response().await.unwrap(); @@ -234,14 +401,152 @@ async fn insert() { insta::assert_json_snapshot!(response); // Now testing without any mock subgraphs, all the data should come from the cache - let entity_cache = EntityCache::with_mocks(redis_cache.clone(), HashMap::new()) + let entity_cache = + EntityCache::with_mocks(redis_cache.clone(), HashMap::new(), valid_schema.clone()) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(SCHEMA_REQUIRES) + .extra_plugin(entity_cache) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + + insta::assert_debug_snapshot!(response.response.headers().get(CACHE_CONTROL)); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys); + + let response = response.next_response().await.unwrap(); + + insta::assert_json_snapshot!(response); +} + +#[tokio::test] +async fn insert_with_nested_field_set() { + let valid_schema = + Arc::new(Schema::parse_and_validate(SCHEMA_NESTED_KEYS, "test.graphql").unwrap()); + let query = "query { allProducts { name createdBy { name country { a } } } }"; + + let subgraphs = serde_json::json!({ + "products": { + "query": {"allProducts": [{ + "id": "1", + "name": "Test", + "sku": "150", + "createdBy": { "__typename": "User", "email": "test@test.com", "country": {"a": "France"} } + }]}, + "headers": {"cache-control": "public"}, + }, + "users": { + "entities": [{ + "__typename": "User", + "email": "test@test.com", + "name": "test", + "country": { + "a": "France" + } + }], + "headers": {"cache-control": "public"}, + } + }); + + let redis_cache = RedisCacheStorage::from_mocks(Arc::new(MockStore::new())) + .await + .unwrap(); + let map = [ + ( + "products".to_string(), + Subgraph { + redis: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "users".to_string(), + Subgraph { + redis: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let entity_cache = EntityCache::with_mocks(redis_cache.clone(), map, valid_schema.clone()) .await .unwrap(); let service = TestHarness::builder() - .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) .unwrap() - .schema(SCHEMA) + .schema(SCHEMA_NESTED_KEYS) + .extra_plugin(entity_cache) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + let mut entity_key = serde_json_bytes::Map::new(); + entity_key.insert( + ByteString::from("email"), + serde_json_bytes::Value::String(ByteString::from("test@test.com")), + ); + entity_key.insert( + ByteString::from("country"), + serde_json_bytes::json!({"a": "France"}), + ); + + let hashed_entity_key = hash_representation(&entity_key); + let prefix_key = format!("version:1.0:subgraph:users:type:User:entity:{hashed_entity_key}"); + assert!( + cache_keys + .iter() + .any(|cache_key| cache_key.key.starts_with(&prefix_key)) + ); + + insta::assert_json_snapshot!(cache_keys); + + insta::assert_debug_snapshot!(response.response.headers().get(CACHE_CONTROL)); + + let response = response.next_response().await.unwrap(); + + insta::assert_json_snapshot!(response); + + // Now testing without any mock subgraphs, all the data should come from the cache + let entity_cache = + EntityCache::with_mocks(redis_cache.clone(), HashMap::new(), valid_schema.clone()) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA_NESTED_KEYS) .extra_plugin(entity_cache) .build_supergraph() .await @@ -255,6 +560,11 @@ async fn insert() { let mut response = service.oneshot(request).await.unwrap(); insta::assert_debug_snapshot!(response.response.headers().get(CACHE_CONTROL)); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys); + let response = response.next_response().await.unwrap(); insta::assert_json_snapshot!(response); @@ -262,6 +572,7 @@ async fn insert() { #[tokio::test] async fn no_cache_control() { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }"; let subgraphs = MockedSubgraphs([ @@ -274,7 +585,7 @@ async fn no_cache_control() { ).build()), ("orga", MockSubgraph::builder().with_json( serde_json::json!{{ - "query": "query($representations:[_Any!]!){_entities(representations:$representations){..._generated_onOrganization1_0}}fragment _generated_onOrganization1_0 on Organization{creatorUser{__typename id}}", + "query": "query($representations:[_Any!]!){_entities(representations:$representations){... on Organization{creatorUser{__typename id}}}}", "variables": { "representations": [ { @@ -297,9 +608,10 @@ async fn no_cache_control() { let redis_cache = RedisCacheStorage::from_mocks(Arc::new(MockStore::new())) .await .unwrap(); - let entity_cache = EntityCache::with_mocks(redis_cache.clone(), HashMap::new()) - .await - .unwrap(); + let entity_cache = + EntityCache::with_mocks(redis_cache.clone(), HashMap::new(), valid_schema.clone()) + .await + .unwrap(); let service = TestHarness::builder() .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) @@ -324,9 +636,10 @@ async fn no_cache_control() { insta::assert_json_snapshot!(response); // Now testing without any mock subgraphs, all the data should come from the cache - let entity_cache = EntityCache::with_mocks(redis_cache.clone(), HashMap::new()) - .await - .unwrap(); + let entity_cache = + EntityCache::with_mocks(redis_cache.clone(), HashMap::new(), valid_schema.clone()) + .await + .unwrap(); let service = TestHarness::builder() .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) @@ -353,6 +666,7 @@ async fn no_cache_control() { #[tokio::test] async fn private() { let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }"; + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); let subgraphs = MockedSubgraphs([ ("user", MockSubgraph::builder().with_json( @@ -365,7 +679,7 @@ async fn private() { .build()), ("orga", MockSubgraph::builder().with_json( serde_json::json!{{ - "query": "query($representations:[_Any!]!){_entities(representations:$representations){..._generated_onOrganization1_0}}fragment _generated_onOrganization1_0 on Organization{creatorUser{__typename id}}", + "query": "query($representations:[_Any!]!){_entities(representations:$representations){... on Organization{creatorUser{__typename id}}}}", "variables": { "representations": [ { @@ -394,7 +708,7 @@ async fn private() { Subgraph { redis: None, private_id: Some("sub".to_string()), - enabled: true, + enabled: true.into(), ttl: None, ..Default::default() }, @@ -404,7 +718,7 @@ async fn private() { Subgraph { redis: None, private_id: Some("sub".to_string()), - enabled: true, + enabled: true.into(), ttl: None, ..Default::default() }, @@ -412,11 +726,11 @@ async fn private() { ] .into_iter() .collect(); - let entity_cache = EntityCache::with_mocks(redis_cache.clone(), map) + let entity_cache = EntityCache::with_mocks(redis_cache.clone(), map, valid_schema.clone()) .await .unwrap(); - let service = TestHarness::builder() + let mut service = TestHarness::builder() .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) .unwrap() .schema(SCHEMA) @@ -434,19 +748,18 @@ async fn private() { .context(context) .build() .unwrap(); - let response = service - .oneshot(request) - .await - .unwrap() - .next_response() - .await - .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys); + let response = response.next_response().await.unwrap(); insta::assert_json_snapshot!(response); println!("\nNOW WITHOUT SUBGRAPHS\n"); // Now testing without any mock subgraphs, all the data should come from the cache - let service = TestHarness::builder() + let mut service = TestHarness::builder() .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) .unwrap() .schema(SCHEMA) @@ -463,14 +776,13 @@ async fn private() { .context(context) .build() .unwrap(); - let response = service - .clone() - .oneshot(request) - .await - .unwrap() - .next_response() - .await - .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys); + + let response = response.next_response().await.unwrap(); insta::assert_json_snapshot!(response); @@ -483,21 +795,25 @@ async fn private() { .context(context) .build() .unwrap(); - let response = service - .clone() - .oneshot(request) - .await - .unwrap() - .next_response() - .await - .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + assert!( + response + .context + .get::<_, CacheKeysContext>(CONTEXT_CACHE_KEYS) + .ok() + .flatten() + .is_none() + ); + insta::assert_json_snapshot!(cache_keys); + let response = response.next_response().await.unwrap(); insta::assert_json_snapshot!(response); } #[tokio::test] async fn no_data() { let query = "query { currentUser { allOrganizations { id name } } }"; + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); let subgraphs = MockedSubgraphs([ ("user", MockSubgraph::builder().with_json( @@ -550,7 +866,7 @@ async fn no_data() { Subgraph { redis: None, private_id: Some("sub".to_string()), - enabled: true, + enabled: true.into(), ttl: None, ..Default::default() }, @@ -560,7 +876,7 @@ async fn no_data() { Subgraph { redis: None, private_id: Some("sub".to_string()), - enabled: true, + enabled: true.into(), ttl: None, ..Default::default() }, @@ -568,7 +884,7 @@ async fn no_data() { ] .into_iter() .collect(); - let entity_cache = EntityCache::with_mocks(redis_cache.clone(), map) + let entity_cache = EntityCache::with_mocks(redis_cache.clone(), map, valid_schema.clone()) .await .unwrap(); @@ -589,12 +905,25 @@ async fn no_data() { .unwrap(); let mut response = service.oneshot(request).await.unwrap(); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys, { + "[].cache_control" => insta::dynamic_redaction(|value, _path| { + let cache_control = value.as_str().unwrap().to_string(); + assert!(cache_control.contains("max-age=")); + assert!(cache_control.contains("public")); + "[REDACTED]" + }) + }); + let response = response.next_response().await.unwrap(); insta::assert_json_snapshot!(response); - let entity_cache = EntityCache::with_mocks(redis_cache.clone(), HashMap::new()) - .await - .unwrap(); + let entity_cache = + EntityCache::with_mocks(redis_cache.clone(), HashMap::new(), valid_schema.clone()) + .await + .unwrap(); let subgraphs = MockedSubgraphs( [( @@ -652,6 +981,18 @@ async fn no_data() { .build() .unwrap(); let mut response = service.oneshot(request).await.unwrap(); + + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys, { + "[].cache_control" => insta::dynamic_redaction(|value, _path| { + let cache_control = value.as_str().unwrap().to_string(); + assert!(cache_control.contains("max-age=")); + assert!(cache_control.contains("public")); + "[REDACTED]" + }) + }); let response = response.next_response().await.unwrap(); insta::assert_json_snapshot!(response); @@ -660,7 +1001,7 @@ async fn no_data() { #[tokio::test] async fn missing_entities() { let query = "query { currentUser { allOrganizations { id name } } }"; - + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); let subgraphs = MockedSubgraphs([ ("user", MockSubgraph::builder().with_json( serde_json::json!{{"query":"{currentUser{allOrganizations{__typename id}}}"}}, @@ -714,7 +1055,7 @@ async fn missing_entities() { Subgraph { redis: None, private_id: Some("sub".to_string()), - enabled: true, + enabled: true.into(), ttl: None, ..Default::default() }, @@ -724,7 +1065,7 @@ async fn missing_entities() { Subgraph { redis: None, private_id: Some("sub".to_string()), - enabled: true, + enabled: true.into(), ttl: None, ..Default::default() }, @@ -732,7 +1073,7 @@ async fn missing_entities() { ] .into_iter() .collect(); - let entity_cache = EntityCache::with_mocks(redis_cache.clone(), map) + let entity_cache = EntityCache::with_mocks(redis_cache.clone(), map, valid_schema.clone()) .await .unwrap(); @@ -755,9 +1096,10 @@ async fn missing_entities() { let response = response.next_response().await.unwrap(); insta::assert_json_snapshot!(response); - let entity_cache = EntityCache::with_mocks(redis_cache.clone(), HashMap::new()) - .await - .unwrap(); + let entity_cache = + EntityCache::with_mocks(redis_cache.clone(), HashMap::new(), valid_schema.clone()) + .await + .unwrap(); let subgraphs = MockedSubgraphs([ ("user", MockSubgraph::builder().with_json( diff --git a/apollo-router/src/plugins/chaos/mod.rs b/apollo-router/src/plugins/chaos/mod.rs new file mode 100644 index 0000000000..cbd587d77d --- /dev/null +++ b/apollo-router/src/plugins/chaos/mod.rs @@ -0,0 +1,127 @@ +//! Chaos testing plugin for the Apollo Router. +//! +//! This plugin provides chaos testing capabilities to help reproduce bugs that require uncommon conditions. +//! You probably don't want this in production! + +use std::time::Duration; + +use futures::Stream; +use futures::StreamExt; +use futures::stream; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +pub(crate) mod reload; + +// Re-export reload functionality +pub(crate) use reload::ReloadState; + +use crate::router::Event; + +/// Configuration for chaos testing, trying to reproduce bugs that require uncommon conditions. +/// You probably don't want this in production! +/// +/// ## How Chaos Reloading Works +/// +/// The chaos system automatically captures and replays the last known schema and configuration +/// events to force hot reloads even when the underlying content hasn't actually changed. This +/// is particularly useful for memory leak detection during hot reload scenarios. +/// If configured, it will activate upon the first config event that is encountered. +/// +/// ### Schema Reloading (`force_schema_reload`) +/// When enabled, the router will periodically replay the last schema event with a timestamp +/// comment injected into the SDL (e.g., `# Chaos reload timestamp: 1234567890`). This ensures +/// the schema is seen as "different" and triggers a full hot reload, even though the functional +/// schema content is identical. +/// +/// ### Configuration Reloading (`force_config_reload`) +/// When enabled, the router will periodically replay the last configuration event. The +/// configuration is cloned and re-emitted, which triggers the router's configuration change +/// detection and reload logic. +/// +/// ### Example Usage +/// ```yaml +/// experimental_chaos: +/// force_schema_reload: "30s" # Trigger schema reload every 30 seconds +/// force_config_reload: "2m" # Trigger config reload every 2 minutes +/// ``` +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[serde(default)] +pub(crate) struct Config { + /// Force a hot reload of the schema at regular intervals by injecting a timestamp comment + /// into the SDL. This ensures schema reloads occur even when the functional schema content + /// hasn't changed, which is useful for testing memory leaks during schema hot reloads. + /// + /// The system automatically captures the last schema event and replays it with a timestamp + /// comment added to make it appear "different" to the reload detection logic. + #[serde(with = "humantime_serde")] + #[schemars(with = "Option")] + pub(crate) force_schema_reload: Option, + + /// Force a hot reload of the configuration at regular intervals by replaying the last + /// configuration event. This triggers the router's configuration change detection even + /// when the configuration content hasn't actually changed. + /// + /// The system automatically captures the last configuration event and replays it to + /// force configuration reload processing. + #[serde(with = "humantime_serde")] + #[schemars(with = "Option")] + pub(crate) force_config_reload: Option, +} + +impl Config { + #[cfg(test)] + fn new(force_schema_reload: Option, force_config_reload: Option) -> Self { + Self { + force_schema_reload, + force_config_reload, + } + } +} + +/// Extension trait to add chaos reload functionality to event streams. +pub(crate) trait ChaosEventStream: Stream + Sized { + /// Add chaos reload functionality to an event stream. + /// + /// This method wraps the event stream to automatically capture schema and configuration + /// events for later replay during chaos testing. The reload source will emit modified + /// versions of these events at configured intervals to force hot reloads. + /// + /// The chaos reload timers are automatically configured when configuration events + /// flow through the stream. + fn with_chaos_reload_state(self, reload_source: ReloadState) -> impl Stream { + let reload_source_for_events = reload_source.clone(); + let watched_upstream = self.map(move |event| { + match &event { + Event::UpdateSchema(schema_state) => { + reload_source_for_events.update_last_schema(schema_state); + } + Event::UpdateConfiguration(config) => { + // Update the reload source with the latest configuration + reload_source_for_events.set_periods(&config.experimental_chaos); + reload_source_for_events.update_last_configuration(config); + } + _ => {} + } + event + }); + + // Combine upstream events with reload source events + stream::select(watched_upstream, reload_source.into_stream()) + } + /// Add chaos reload functionality to an event stream. + /// + /// This method wraps the event stream to automatically capture schema and configuration + /// events for later replay during chaos testing. The reload source will emit modified + /// versions of these events at configured intervals to force hot reloads. + /// + /// The chaos reload timers are automatically configured when configuration events + /// flow through the stream - no manual setup is required. + fn with_chaos_reload(self) -> impl Stream { + self.with_chaos_reload_state(ReloadState::default()) + } +} + +impl ChaosEventStream for S where S: Stream {} diff --git a/apollo-router/src/plugins/chaos/reload.rs b/apollo-router/src/plugins/chaos/reload.rs new file mode 100644 index 0000000000..03e1e746a0 --- /dev/null +++ b/apollo-router/src/plugins/chaos/reload.rs @@ -0,0 +1,514 @@ +//! Chaos reload source for schema and configuration events. +//! +//! This module provides the ReloadSource for chaos testing, which automatically captures +//! and replays schema/configuration events to force hot reloads at configurable intervals. + +use std::sync::Arc; +use std::task::Poll; +use std::time::Duration; + +use futures::prelude::*; +use parking_lot::Mutex; +use tokio_util::time::DelayQueue; + +use super::Config; +use crate::configuration::Configuration; +use crate::router::Event; +use crate::uplink::schema::SchemaState; + +#[derive(Clone)] +enum ChaosEvent { + Schema, + Configuration, +} + +#[derive(Default)] +struct ReloadStateInner { + queue: DelayQueue, + force_schema_reload_period: Option, + force_config_reload_period: Option, + last_schema: Option, + last_configuration: Option>, +} + +/// Chaos testing event source that automatically captures and replays schema/configuration events +/// to force hot reloads at configurable intervals. +/// +/// This is used for memory leak detection during hot reload scenarios. The ReloadSource: +/// 1. Automatically captures the last schema and configuration events as they flow through the system +/// 2. Replays these events at configured intervals with modifications to ensure they're seen as "different" +/// 3. For schema events: injects a timestamp comment into the SDL +/// 4. For configuration events: clones and re-emits the configuration +/// +/// The ReloadSource requires no manual setup - it automatically configures itself when the first +/// configuration event (containing chaos settings) flows through the event stream. +#[derive(Clone, Default)] +pub(crate) struct ReloadState { + inner: Arc>, +} + +impl ReloadState { + /// Configure chaos reload periods from the router configuration. + /// This is called automatically when configuration events flow through the system. + pub(crate) fn set_periods(&self, config: &Config) { + let mut inner = self.inner.lock(); + // Clear the queue before setting the periods + inner.queue.clear(); + + inner.force_schema_reload_period = config.force_schema_reload; + inner.force_config_reload_period = config.force_config_reload; + + if let Some(period) = config.force_schema_reload { + inner.queue.insert(ChaosEvent::Schema, period); + } + if let Some(period) = config.force_config_reload { + inner.queue.insert(ChaosEvent::Configuration, period); + } + } + + /// Store the most recent schema event for later replay during chaos testing. + /// This is called automatically when schema events flow through the system. + pub(crate) fn update_last_schema(&self, schema: &SchemaState) { + let mut inner = self.inner.lock(); + inner.last_schema = Some(schema.clone()); + } + + /// Store the most recent configuration event for later replay during chaos testing. + /// This is called automatically when configuration events flow through the system. + pub(crate) fn update_last_configuration(&self, config: &Arc) { + let mut inner = self.inner.lock(); + inner.last_configuration = Some(config.clone()); + } + + pub(crate) fn into_stream(self) -> impl Stream { + futures::stream::poll_fn(move |cx| { + let mut inner = self.inner.lock(); + match inner.queue.poll_expired(cx) { + Poll::Ready(Some(expired)) => { + let event_type = expired.into_inner(); + + // Re-schedule the event + match &event_type { + ChaosEvent::Schema => { + if let Some(period) = inner.force_schema_reload_period { + inner.queue.insert(ChaosEvent::Schema, period); + } + } + ChaosEvent::Configuration => { + if let Some(period) = inner.force_config_reload_period { + inner.queue.insert(ChaosEvent::Configuration, period); + } + } + } + + // Generate the appropriate event + let event = match event_type { + ChaosEvent::Schema => { + if let Some(mut schema) = inner.last_schema.clone() { + // Inject a timestamp comment into the schema SDL to make it appear "different" + // This ensures the router's change detection will trigger a hot reload even + // though the functional schema content is identical + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + schema.sdl = format!( + "# Chaos reload timestamp: {}\n{}", + timestamp, schema.sdl + ); + Some(Event::UpdateSchema(schema)) + } else { + // No schema available yet - skip this chaos event + None + } + } + ChaosEvent::Configuration => { + if let Some(config) = inner.last_configuration.clone() { + // Clone and re-emit the configuration to trigger reload processing + // The router's change detection will process this as a new configuration event + // even though the content is identical, forcing configuration reload logic + let config_clone = (*config).clone(); + Some(Event::UpdateConfiguration(Arc::new(config_clone))) + } else { + // No configuration available yet - skip this chaos event + None + } + } + }; + + match event { + Some(event) => Poll::Ready(Some(event)), + None => Poll::Pending, // Skip this event and wait for the next one + } + } + // We must return pending even if the queue is empty, otherwise the stream will never be polled again + // The waker will still be used, so this won't end up in a hot loop. + Poll::Ready(None) => Poll::Pending, + Poll::Pending => Poll::Pending, + } + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use futures::StreamExt; + use futures::pin_mut; + + use super::*; + use crate::configuration::Configuration; + use crate::plugins::chaos::ChaosEventStream; + + fn create_test_schema() -> SchemaState { + SchemaState { + sdl: "type Query { hello: String }".to_string(), + launch_id: Some("test-launch".to_string()), + } + } + + fn create_test_config() -> Arc { + Arc::new(Configuration::default()) + } + + #[test] + fn test_reload_source_default() { + let source = ReloadState::default(); + let inner = source.inner.lock(); + assert!(inner.force_schema_reload_period.is_none()); + assert!(inner.force_config_reload_period.is_none()); + assert!(inner.last_schema.is_none()); + assert!(inner.last_configuration.is_none()); + } + + #[tokio::test] + async fn test_set_periods_configures_reload_intervals() { + let source = ReloadState::default(); + let schema_period = Duration::from_secs(5); + let config_period = Duration::from_secs(10); + + let chaos_config = Config::new(Some(schema_period), Some(config_period)); + source.set_periods(&chaos_config); + + let inner = source.inner.lock(); + assert_eq!(inner.force_schema_reload_period, Some(schema_period)); + assert_eq!(inner.force_config_reload_period, Some(config_period)); + } + + #[tokio::test] + async fn test_set_periods_clears_existing_queue() { + let source = ReloadState::default(); + + // Set initial periods + let chaos_config1 = Config::new(Some(Duration::from_secs(5)), None); + source.set_periods(&chaos_config1); + + // Verify queue has an entry + assert_eq!(source.inner.lock().queue.len(), 1); + // Set different periods - should clear queue + let chaos_config2 = Config::new(None, Some(Duration::from_secs(10))); + source.set_periods(&chaos_config2); + + let inner = source.inner.lock(); + assert_eq!(inner.force_schema_reload_period, None); + assert_eq!( + inner.force_config_reload_period, + Some(Duration::from_secs(10)) + ); + assert_eq!(inner.queue.len(), 1); + } + + #[test] + fn test_set_periods_with_none_values() { + let source = ReloadState::default(); + let chaos_config = Config::new(None, None); + + source.set_periods(&chaos_config); + + let inner = source.inner.lock(); + assert!(inner.force_schema_reload_period.is_none()); + assert!(inner.force_config_reload_period.is_none()); + assert!(inner.queue.is_empty()); + } + + #[test] + fn test_update_last_schema() { + let source = ReloadState::default(); + let schema = create_test_schema(); + + // Initially no schema stored + { + let inner = source.inner.lock(); + assert!(inner.last_schema.is_none()); + } + + // Update with schema + source.update_last_schema(&schema); + + // Verify schema is stored + let inner = source.inner.lock(); + let stored_schema = inner.last_schema.as_ref().unwrap(); + assert_eq!(stored_schema.sdl, schema.sdl); + assert_eq!(stored_schema.launch_id, schema.launch_id); + } + + #[test] + fn test_update_last_configuration() { + let source = ReloadState::default(); + let config = create_test_config(); + + // Initially no configuration stored + { + let inner = source.inner.lock(); + assert!(inner.last_configuration.is_none()); + } + + // Update with configuration + source.update_last_configuration(&config); + + // Verify configuration is stored + let inner = source.inner.lock(); + let stored_config = inner.last_configuration.as_ref().unwrap(); + assert!(Arc::ptr_eq(stored_config, &config)); + } + + #[test] + fn test_update_methods_replace_previous_values() { + let source = ReloadState::default(); + + // Set initial schema and config + let schema1 = create_test_schema(); + let config1 = create_test_config(); + source.update_last_schema(&schema1); + source.update_last_configuration(&config1); + + // Create new schema and config + let mut schema2 = create_test_schema(); + schema2.sdl = "type Query { goodbye: String }".to_string(); + schema2.launch_id = Some("new-launch".to_string()); + let config2 = Arc::new(Configuration::default()); + + // Update with new values + source.update_last_schema(&schema2); + source.update_last_configuration(&config2); + + // Verify new values are stored + let inner = source.inner.lock(); + let stored_schema = inner.last_schema.as_ref().unwrap(); + assert_eq!(stored_schema.sdl, schema2.sdl); + assert_eq!(stored_schema.launch_id, schema2.launch_id); + + let stored_config = inner.last_configuration.as_ref().unwrap(); + assert!(Arc::ptr_eq(stored_config, &config2)); + assert!(!Arc::ptr_eq(stored_config, &config1)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_stream_with_no_chaos_periods_is_empty() { + let source = ReloadState::default(); + let mut stream = source.into_stream(); + + // Stream should not produce events without periods configured + let result = tokio::time::timeout(Duration::from_millis(50), stream.next()).await; + assert!(result.is_err()); // Timeout expected + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_stream_skips_events_when_no_data_available() { + let source = ReloadState::default(); + let chaos_config = Config::new(Some(Duration::from_millis(10)), None); + source.set_periods(&chaos_config); + + let mut stream = source.into_stream(); + + // Stream should not produce events since no schema is stored + let result = tokio::time::timeout(Duration::from_millis(50), stream.next()).await; + assert!(result.is_err()); // Timeout expected + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_schema_reload_stream_generates_events() { + let source = ReloadState::default(); + let schema = create_test_schema(); + source.update_last_schema(&schema); + + let chaos_config = Config::new(Some(Duration::from_millis(10)), None); + source.set_periods(&chaos_config); + + let mut stream = source.into_stream(); + + // Should get a schema reload event + let event = tokio::time::timeout(Duration::from_millis(100), stream.next()) + .await + .expect("Should receive event within timeout") + .expect("Stream should produce an event"); + + match event { + Event::UpdateSchema(reloaded_schema) => { + // Should contain timestamp comment + assert!(reloaded_schema.sdl.contains("# Chaos reload timestamp:")); + assert!(reloaded_schema.sdl.contains(&schema.sdl)); + assert_eq!(reloaded_schema.launch_id, schema.launch_id); + } + _ => panic!("Expected UpdateSchema event, got {event:?}"), + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_config_reload_stream_generates_events() { + let source = ReloadState::default(); + let config = create_test_config(); + source.update_last_configuration(&config); + + let chaos_config = Config::new(None, Some(Duration::from_millis(10))); + source.set_periods(&chaos_config); + + let mut stream = source.into_stream(); + + // Should get a config reload event + let event = tokio::time::timeout(Duration::from_millis(100), stream.next()) + .await + .expect("Should receive event within timeout") + .expect("Stream should produce an event"); + + match event { + Event::UpdateConfiguration(reloaded_config) => { + // Should be a clone of the original config (different Arc but same contents) + assert!(!Arc::ptr_eq(&reloaded_config, &config)); + } + _ => panic!("Expected UpdateConfiguration event, got {event:?}"), + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_reloadable_event_stream_captures_schema_events() { + let reload_source = ReloadState::default(); + let schema = create_test_schema(); + let schema_event = Event::UpdateSchema(schema.clone()); + + let upstream = futures::stream::once(async move { schema_event }); + let stream = upstream.with_chaos_reload_state(reload_source.clone()); + pin_mut!(stream); + + // Get the upstream event + let event = stream.next().await.unwrap(); + match event { + Event::UpdateSchema(received_schema) => { + assert_eq!(received_schema.sdl, schema.sdl); + assert_eq!(received_schema.launch_id, schema.launch_id); + } + _ => panic!("Expected UpdateSchema event"), + } + + // Verify the schema was captured by reload source + let inner = reload_source.inner.lock(); + let stored_schema = inner.last_schema.as_ref().unwrap(); + assert_eq!(stored_schema.sdl, schema.sdl); + assert_eq!(stored_schema.launch_id, schema.launch_id); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_reloadable_event_stream_configures_and_captures_config_events() { + let reload_source = ReloadState::default(); + let config = Configuration { + experimental_chaos: Config::new(Some(Duration::from_secs(30)), None), + ..Default::default() + }; + let config_arc = Arc::new(config); + let config_event = Event::UpdateConfiguration(config_arc.clone()); + + let upstream = futures::stream::once(async move { config_event }); + let stream = upstream.with_chaos_reload_state(reload_source.clone()); + pin_mut!(stream); + + // Get the upstream event + let event = stream.next().await.unwrap(); + match event { + Event::UpdateConfiguration(received_config) => { + assert!(Arc::ptr_eq(&received_config, &config_arc)); + } + _ => panic!("Expected UpdateConfiguration event"), + } + + // Verify the configuration was captured and periods were set + let inner = reload_source.inner.lock(); + let stored_config = inner.last_configuration.as_ref().unwrap(); + assert!(Arc::ptr_eq(stored_config, &config_arc)); + assert_eq!( + inner.force_schema_reload_period, + Some(Duration::from_secs(30)) + ); + assert!(inner.force_config_reload_period.is_none()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_reloadable_event_stream_ignores_other_events() { + let reload_source = ReloadState::default(); + let other_event = Event::NoMoreSchema; + + let upstream = futures::stream::once(async move { other_event }); + let stream = upstream.with_chaos_reload_state(reload_source.clone()); + pin_mut!(stream); + + // Get the upstream event + let event = stream.next().await.unwrap(); + match event { + Event::NoMoreSchema => { + // Expected - this should pass through unchanged + } + _ => panic!("Expected NoMoreSchema event"), + } + + // Verify no data was captured + let inner = reload_source.inner.lock(); + assert!(inner.last_schema.is_none()); + assert!(inner.last_configuration.is_none()); + assert!(inner.force_schema_reload_period.is_none()); + assert!(inner.force_config_reload_period.is_none()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_reloadable_event_stream_merges_upstream_and_chaos_events() { + let reload_source = ReloadState::default(); + let schema = create_test_schema(); + reload_source.update_last_schema(&schema); + + let chaos_config = Config::new(Some(Duration::from_millis(10)), None); + reload_source.set_periods(&chaos_config); + + // Create upstream with a single event, then end + let schema_event = Event::UpdateSchema(schema.clone()); + let upstream = futures::stream::once(async move { schema_event }); + + let stream = upstream.with_chaos_reload_state(reload_source); + pin_mut!(stream); + + // Should get the original upstream event first + let first_event = stream.next().await.unwrap(); + match first_event { + Event::UpdateSchema(received_schema) => { + // This should be the original schema without timestamp + assert_eq!(received_schema.sdl, schema.sdl); + assert!(!received_schema.sdl.contains("# Chaos reload timestamp:")); + } + _ => panic!("Expected UpdateSchema event"), + } + + // Should then get chaos reload events + let second_event = tokio::time::timeout(Duration::from_millis(100), stream.next()) + .await + .expect("Should receive chaos event within timeout") + .expect("Stream should produce an event"); + + match second_event { + Event::UpdateSchema(chaos_schema) => { + // This should contain the timestamp comment + assert!(chaos_schema.sdl.contains("# Chaos reload timestamp:")); + assert!(chaos_schema.sdl.contains(&schema.sdl)); + } + _ => panic!("Expected UpdateSchema chaos event"), + } + } +} diff --git a/apollo-router/src/plugins/connectors/configuration.rs b/apollo-router/src/plugins/connectors/configuration.rs new file mode 100644 index 0000000000..b754a08893 --- /dev/null +++ b/apollo-router/src/plugins/connectors/configuration.rs @@ -0,0 +1,163 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use apollo_federation::connectors::CustomConfiguration; +use apollo_federation::connectors::SourceName; +use apollo_federation::connectors::expand::Connectors; +use http::Uri; +use schemars::JsonSchema; +use schemars::schema::InstanceType; +use schemars::schema::SchemaObject; +use serde::Deserialize; +use serde::Serialize; + +use super::incompatible::warn_incompatible_plugins; +use crate::Configuration; +use crate::plugins::connectors::plugin::PLUGIN_NAME; +use crate::services::connector_service::ConnectorSourceRef; + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct ConnectorsConfig { + /// A map of subgraph name to connectors config for that subgraph + #[serde(default)] + #[deprecated(note = "use `sources`")] + pub(crate) subgraphs: HashMap, + + /// Map of subgraph_name.connector_source_name to source configuration + #[serde(default)] + pub(crate) sources: HashMap, + + /// Enables connector debugging information on response extensions if the feature is enabled + #[serde(default)] + pub(crate) debug_extensions: bool, + + /// The maximum number of requests for a connector source + #[serde(default)] + pub(crate) max_requests_per_operation_per_source: Option, + + /// When enabled, adds an entry to the context for use in coprocessors + /// ```json + /// { + /// "context": { + /// "entries": { + /// "apollo_connectors::sources_in_query_plan": [ + /// { "subgraph_name": "subgraph", "source_name": "source" } + /// ] + /// } + /// } + /// } + /// ``` + #[serde(default)] + pub(crate) expose_sources_in_context: bool, + + /// Enables Connect spec v0.2 during the preview. + #[serde(default)] + #[deprecated(note = "Connect spec v0.2 is now available.")] + pub(crate) preview_connect_v0_2: Option, + + /// Feature gate for Connect spec v0.3. Set to `true` to enable the using + /// the v0.3 spec during the preview phase. + #[serde(default)] + pub(crate) preview_connect_v0_3: Option, +} + +// TODO: remove this after deprecation period +/// Configuration for a connector subgraph +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct SubgraphConnectorConfiguration { + /// A map of `@source(name:)` to configuration for that source + pub(crate) sources: HashMap, + + /// Other values that can be used by connectors via `{$config.}` + #[serde(rename = "$config")] + pub(crate) custom: CustomConfiguration, +} + +/// Configuration for a `@source` directive +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct SourceConfiguration { + /// Override the `@source(http: {baseURL:})` + #[serde(default, with = "http_serde::option::uri")] + #[schemars(schema_with = "uri_schema")] + pub(crate) override_url: Option, + + /// The maximum number of requests for this source + pub(crate) max_requests_per_operation: Option, + + /// Other values that can be used by connectors via `{$config.}` + #[serde(rename = "$config")] + pub(crate) custom: CustomConfiguration, +} + +fn uri_schema(_generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + SchemaObject { + instance_type: Some(InstanceType::String.into()), + format: Some("uri".to_owned()), + extensions: { + let mut map = schemars::Map::new(); + map.insert("nullable".to_owned(), serde_json::json!(true)); + map + }, + ..Default::default() + } + .into() +} + +/// Modifies connectors with values from the configuration +pub(crate) fn apply_config( + router_config: &Configuration, + mut connectors: Connectors, +) -> Connectors { + // Enabling connectors might end up interfering with other router features, so we insert warnings + // into the logs for any incompatibilities found. + warn_incompatible_plugins(router_config, &connectors); + + let Some(config) = router_config.apollo_plugins.plugins.get(PLUGIN_NAME) else { + return connectors; + }; + let Ok(config) = serde_json::from_value::(config.clone()) else { + return connectors; + }; + + for connector in Arc::make_mut(&mut connectors.by_service_name).values_mut() { + if let Ok(source_ref) = ConnectorSourceRef::try_from(&mut *connector) + && let Some(source_config) = config.sources.get(&source_ref.to_string()) + { + if let Some(uri) = source_config.override_url.as_ref() { + // Discards potential StringTemplate parsing error as URI should + // always be a valid template string. + connector.transport.source_template = uri.to_string().parse().ok(); + } + if let Some(max_requests) = source_config.max_requests_per_operation { + connector.max_requests = Some(max_requests); + } + connector.config = Some(source_config.custom.clone()); + } + + // TODO: remove this after deprecation period + #[allow(deprecated)] + let Some(subgraph_config) = config.subgraphs.get(&connector.id.subgraph_name) else { + continue; + }; + if let Some(source_config) = connector + .id + .source_name + .as_ref() + .and_then(|source_name| subgraph_config.sources.get(source_name)) + { + if let Some(uri) = source_config.override_url.as_ref() { + // Discards potential StringTemplate parsing error as + // URI should always be a valid template string. + connector.transport.source_template = uri.to_string().parse().ok(); + } + if let Some(max_requests) = source_config.max_requests_per_operation { + connector.max_requests = Some(max_requests); + } + } + connector.config = Some(subgraph_config.custom.clone()); + } + connectors +} diff --git a/apollo-router/src/plugins/connectors/handle_responses.rs b/apollo-router/src/plugins/connectors/handle_responses.rs new file mode 100644 index 0000000000..a1f9df186d --- /dev/null +++ b/apollo-router/src/plugins/connectors/handle_responses.rs @@ -0,0 +1,1290 @@ +use std::sync::Arc; + +use apollo_federation::connectors::Connector; +use apollo_federation::connectors::runtime::debug::ConnectorContext; +use apollo_federation::connectors::runtime::debug::DebugRequest; +use apollo_federation::connectors::runtime::debug::SelectionData; +use apollo_federation::connectors::runtime::errors::Error; +use apollo_federation::connectors::runtime::errors::RuntimeError; +use apollo_federation::connectors::runtime::http_json_transport::HttpResponse; +use apollo_federation::connectors::runtime::http_json_transport::TransportResponse; +use apollo_federation::connectors::runtime::key::ResponseKey; +use apollo_federation::connectors::runtime::mapping::Problem; +use apollo_federation::connectors::runtime::responses::HandleResponseError; +use apollo_federation::connectors::runtime::responses::MappedResponse; +use apollo_federation::connectors::runtime::responses::deserialize_response; +use apollo_federation::connectors::runtime::responses::handle_raw_response; +use axum::body::HttpBody; +use http::response::Parts; +use http_body_util::BodyExt; +use opentelemetry::KeyValue; +use parking_lot::Mutex; +use serde_json_bytes::Map; +use serde_json_bytes::Value; +use tracing::Span; + +use crate::Context; +use crate::graphql; +use crate::json_ext::Path; +use crate::plugins::telemetry::config_new::attributes::HTTP_RESPONSE_BODY; +use crate::plugins::telemetry::config_new::attributes::HTTP_RESPONSE_HEADERS; +use crate::plugins::telemetry::config_new::attributes::HTTP_RESPONSE_STATUS; +use crate::plugins::telemetry::config_new::attributes::HTTP_RESPONSE_VERSION; +use crate::plugins::telemetry::config_new::connector::events::ConnectorEventResponse; +use crate::plugins::telemetry::config_new::events::log_event; +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE; +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE_ERROR; +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE_OK; +use crate::plugins::telemetry::tracing::apollo_telemetry::emit_error_event; +use crate::services::connect::Response; +use crate::services::connector; +use crate::services::fetch::AddSubgraphNameExt; + +// --- ERRORS ------------------------------------------------------------------ + +impl From for graphql::Error { + fn from(error: RuntimeError) -> Self { + let path: Path = (&error.path).into(); + + let err = graphql::Error::builder() + .message(&error.message) + .extensions(error.extensions()) + .extension_code(error.code()) + .path(path) + .build(); + + if let Some(subgraph_name) = &error.subgraph_name { + err.with_subgraph_name(subgraph_name) + } else { + err + } + } +} + +// --- handle_responses -------------------------------------------------------- + +pub(crate) async fn process_response( + result: Result, Error>, + response_key: ResponseKey, + connector: Arc, + context: &Context, + debug_request: DebugRequest, + debug_context: Option<&Arc>>, + supergraph_request: Arc>, +) -> connector::request_service::Response { + let (mapped_response, result) = match result { + // This occurs when we short-circuit the request when over the limit + Err(error) => { + Span::current().record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + ( + MappedResponse::Error { + error: error.to_runtime_error(&connector, &response_key), + key: response_key, + problems: Vec::new(), + }, + Err(error), + ) + } + Ok(response) => { + let (parts, body) = response.into_parts(); + + let result = Ok(TransportResponse::Http(HttpResponse { + inner: parts.clone(), + })); + + let make_err = || { + let mut err = RuntimeError::new( + "The server returned data in an unexpected format.".to_string(), + &response_key, + ); + err.subgraph_name = Some(connector.id.subgraph_name.clone()); + err = err.with_code("CONNECTOR_RESPONSE_INVALID"); + err.coordinate = Some(connector.id.coordinate()); + err = err.extension( + "http", + Value::Object(Map::from_iter([( + "status".into(), + Value::Number(parts.status.as_u16().into()), + )])), + ); + err + }; + + let deserialized_body = body + .collect() + .await + .map_err(|_| ()) + .and_then(|body| { + let body = body.to_bytes(); + let raw = deserialize_response(&body, &parts.headers).map_err(|_| { + if let Some(debug_context) = debug_context { + debug_context.lock().push_invalid_response( + debug_request.0.clone(), + &parts, + &body, + &connector.error_settings, + debug_request.1.clone(), + ); + } + }); + log_connectors_event(context, &body, &parts, response_key.clone(), &connector); + raw + }) + .map_err(|()| make_err()); + + // If this errors, it will write to the debug context because it + // has access to the raw bytes, so we can't write to it again + // in any RawResponse::Error branches. + let mapped = match &deserialized_body { + Err(error) => MappedResponse::Error { + error: error.clone(), + key: response_key, + problems: Vec::new(), + }, + Ok(data) => handle_raw_response( + data, + &parts, + response_key, + &connector, + context, + supergraph_request.headers(), + ), + }; + + if let Some(debug) = debug_context { + let mut debug_problems: Vec = mapped.problems().to_vec(); + debug_problems.extend(debug_request.1); + + let selection_data = if let MappedResponse::Data { key, data, .. } = &mapped { + Some(SelectionData { + source: connector.selection.to_string(), + transformed: key.selection().to_string(), + result: Some(data.clone()), + }) + } else { + None + }; + + debug.lock().push_response( + debug_request.0, + &parts, + deserialized_body.ok().as_ref().unwrap_or(&Value::Null), + selection_data, + &connector.error_settings, + debug_problems, + ); + } + if matches!(mapped, MappedResponse::Data { .. }) { + Span::current().record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_OK); + } else { + Span::current().record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + } + + (mapped, result) + } + }; + + if let MappedResponse::Error { ref error, .. } = mapped_response { + emit_error_event(error.code(), &error.message, Some((*error.path).into())); + } + + connector::request_service::Response { + transport_result: result, + mapped_response, + } +} + +pub(crate) fn aggregate_responses( + responses: Vec, +) -> Result { + let mut data = serde_json_bytes::Map::new(); + let mut errors = Vec::new(); + let count = responses.len(); + + for mapped in responses { + mapped.add_to_data(&mut data, &mut errors, count)?; + } + + let data = if data.is_empty() { + Value::Null + } else { + Value::Object(data) + }; + + Span::current().record( + OTEL_STATUS_CODE, + if errors.is_empty() { + OTEL_STATUS_CODE_OK + } else { + OTEL_STATUS_CODE_ERROR + }, + ); + + Ok(Response { + response: http::Response::builder() + .body( + graphql::Response::builder() + .data(data) + .errors(errors.into_iter().map(|e| e.into()).collect()) + .build(), + ) + .unwrap(), + }) +} + +fn log_connectors_event( + context: &Context, + body: &[u8], + parts: &Parts, + response_key: ResponseKey, + connector: &Connector, +) { + let log_response_level = context + .extensions() + .with_lock(|lock| lock.get::().cloned()) + .and_then(|event| { + // TODO: evaluate if this is still needed now that we're cloning the body anyway + // Create a temporary response here so we can evaluate the condition. This response + // is missing any information about the mapped response, because we don't have that + // yet. This means that we cannot correctly evaluate any condition that relies on + // the mapped response data or mapping problems. But we can't wait until we do have + // that information, because this is the only place we have the body bytes (without + // making an expensive clone of the body). So we either need to not expose any + // selector which can be used as a condition that requires mapping information, or + // we must document that such selectors cannot be used as conditions on standard + // connectors events. + + let response = connector::request_service::Response { + transport_result: Ok(TransportResponse::Http(HttpResponse { + inner: parts.clone(), + })), + mapped_response: MappedResponse::Data { + data: Value::Null, + key: response_key, + problems: vec![], + }, + }; + if event.condition.evaluate_response(&response) { + Some(event.level) + } else { + None + } + }); + + if let Some(level) = log_response_level { + let mut attrs = Vec::with_capacity(4); + #[cfg(test)] + let headers = { + let mut headers: indexmap::IndexMap = parts + .headers + .iter() + .map(|(name, val)| (name.to_string(), val.clone())) + .collect(); + headers.sort_keys(); + headers + }; + #[cfg(not(test))] + let headers = &parts.headers; + + attrs.push(KeyValue::new( + HTTP_RESPONSE_HEADERS, + opentelemetry::Value::String(format!("{headers:?}").into()), + )); + attrs.push(KeyValue::new( + HTTP_RESPONSE_STATUS, + opentelemetry::Value::String(format!("{}", parts.status).into()), + )); + attrs.push(KeyValue::new( + HTTP_RESPONSE_VERSION, + opentelemetry::Value::String(format!("{:?}", parts.version).into()), + )); + attrs.push(KeyValue::new( + HTTP_RESPONSE_BODY, + opentelemetry::Value::String(String::from_utf8_lossy(body).into_owned().into()), + )); + + log_event( + level, + "connector.response", + attrs, + &format!("Response from connector {label:?}", label = connector.label), + ); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use apollo_compiler::Schema; + use apollo_compiler::collections::IndexMap; + use apollo_compiler::name; + use apollo_compiler::response::JsonValue; + use apollo_federation::connectors::ConnectId; + use apollo_federation::connectors::ConnectSpec; + use apollo_federation::connectors::Connector; + use apollo_federation::connectors::ConnectorErrorsSettings; + use apollo_federation::connectors::EntityResolver; + use apollo_federation::connectors::HTTPMethod; + use apollo_federation::connectors::HttpJsonTransport; + use apollo_federation::connectors::JSONSelection; + use apollo_federation::connectors::Label; + use apollo_federation::connectors::Namespace; + use apollo_federation::connectors::runtime::inputs::RequestInputs; + use apollo_federation::connectors::runtime::key::ResponseKey; + use insta::assert_debug_snapshot; + use itertools::Itertools; + use serde_json_bytes::json; + + use crate::Context; + use crate::graphql; + use crate::plugins::connectors::handle_responses::process_response; + use crate::services::router; + use crate::services::router::body::RouterBody; + + #[tokio::test] + async fn test_handle_responses_root_fields() { + let connector = Arc::new(Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(hello), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("$.data").unwrap(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }); + + let response1: http::Response = http::Response::builder() + .body(router::body::from_bytes(r#"{"data":"world"}"#)) + .unwrap(); + let response_key1 = ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + + let response2 = http::Response::builder() + .body(router::body::from_bytes(r#"{"data":"world"}"#)) + .unwrap(); + let response_key2 = ResponseKey::RootField { + name: "hello2".to_string(), + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + + let supergraph_request = Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + ); + + let res = super::aggregate_responses(vec![ + process_response( + Ok(response1), + response_key1, + connector.clone(), + &Context::default(), + (None, Default::default()), + None, + supergraph_request.clone(), + ) + .await + .mapped_response, + process_response( + Ok(response2), + response_key2, + connector, + &Context::default(), + (None, Default::default()), + None, + supergraph_request, + ) + .await + .mapped_response, + ]) + .unwrap(); + + assert_debug_snapshot!(res, @r###" + Response { + response: Response { + status: 200, + version: HTTP/1.1, + headers: {}, + body: Response { + label: None, + data: Some( + Object({ + "hello": String( + "world", + ), + "hello2": String( + "world", + ), + }), + ), + path: None, + errors: [], + extensions: {}, + has_next: None, + subscribed: None, + created_at: None, + incremental: [], + }, + }, + } + "###); + } + + #[tokio::test] + async fn test_handle_responses_entities() { + let connector = Arc::new(Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(user), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("$.data { id }").unwrap(), + entity_resolver: Some(EntityResolver::Explicit), + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }); + + let response1: http::Response = http::Response::builder() + .body(router::body::from_bytes(r#"{"data":{"id": "1"}}"#)) + .unwrap(); + let response_key1 = ResponseKey::Entity { + index: 0, + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + + let response2 = http::Response::builder() + .body(router::body::from_bytes(r#"{"data":{"id": "2"}}"#)) + .unwrap(); + let response_key2 = ResponseKey::Entity { + index: 1, + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + + let supergraph_request = Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + ); + + let res = super::aggregate_responses(vec![ + process_response( + Ok(response1), + response_key1, + connector.clone(), + &Context::default(), + (None, Default::default()), + None, + supergraph_request.clone(), + ) + .await + .mapped_response, + process_response( + Ok(response2), + response_key2, + connector, + &Context::default(), + (None, Default::default()), + None, + supergraph_request, + ) + .await + .mapped_response, + ]) + .unwrap(); + + assert_debug_snapshot!(res, @r###" + Response { + response: Response { + status: 200, + version: HTTP/1.1, + headers: {}, + body: Response { + label: None, + data: Some( + Object({ + "_entities": Array([ + Object({ + "id": String( + "1", + ), + }), + Object({ + "id": String( + "2", + ), + }), + ]), + }), + ), + path: None, + errors: [], + extensions: {}, + has_next: None, + subscribed: None, + created_at: None, + incremental: [], + }, + }, + } + "###); + } + + #[tokio::test] + async fn test_handle_responses_batch() { + let connector = Arc::new(Connector { + spec: ConnectSpec::V0_2, + id: ConnectId::new_on_object("subgraph_name".into(), None, name!(User), None, 0), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + method: HTTPMethod::Post, + body: Some(JSONSelection::parse("ids: $batch.id").unwrap()), + ..Default::default() + }, + selection: JSONSelection::parse("$.data { id name }").unwrap(), + entity_resolver: Some(EntityResolver::TypeBatch), + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }); + + let keys = connector + .resolvable_key( + &Schema::parse_and_validate("type Query { _: ID } type User { id: ID! }", "") + .unwrap(), + ) + .unwrap() + .unwrap(); + + let response1: http::Response = http::Response::builder() + // different order from the request inputs + .body(router::body::from_bytes( + r#"{"data":[{"id": "2","name":"B"},{"id": "1","name":"A"}]}"#, + )) + .unwrap(); + + let mut inputs: RequestInputs = RequestInputs::default(); + let representations = serde_json_bytes::json!([{"__typename": "User", "id": "1"}, {"__typename": "User", "id": "2"}]); + inputs.batch = representations + .as_array() + .unwrap() + .iter() + .cloned() + .map(|v| v.as_object().unwrap().clone()) + .collect_vec(); + + let response_key1 = ResponseKey::BatchEntity { + selection: Arc::new(JSONSelection::parse("$.data { id name }").unwrap()), + keys, + inputs, + }; + + let supergraph_request = Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + ); + + let res = super::aggregate_responses(vec![ + process_response( + Ok(response1), + response_key1, + connector.clone(), + &Context::default(), + (None, Default::default()), + None, + supergraph_request, + ) + .await + .mapped_response, + ]) + .unwrap(); + + assert_debug_snapshot!(res, @r#" + Response { + response: Response { + status: 200, + version: HTTP/1.1, + headers: {}, + body: Response { + label: None, + data: Some( + Object({ + "_entities": Array([ + Object({ + "id": String( + "1", + ), + "name": String( + "A", + ), + }), + Object({ + "id": String( + "2", + ), + "name": String( + "B", + ), + }), + ]), + }), + ), + path: None, + errors: [], + extensions: {}, + has_next: None, + subscribed: None, + created_at: None, + incremental: [], + }, + }, + } + "#); + } + + #[tokio::test] + async fn test_handle_responses_entity_field() { + let connector = Arc::new(Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(User), + name!(field), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("$.data").unwrap(), + entity_resolver: Some(EntityResolver::Implicit), + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }); + + let response1: http::Response = http::Response::builder() + .body(router::body::from_bytes(r#"{"data":"value1"}"#)) + .unwrap(); + let response_key1 = ResponseKey::EntityField { + index: 0, + inputs: Default::default(), + field_name: "field".to_string(), + typename: Some(name!("User")), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + + let response2 = http::Response::builder() + .body(router::body::from_bytes(r#"{"data":"value2"}"#)) + .unwrap(); + let response_key2 = ResponseKey::EntityField { + index: 1, + inputs: Default::default(), + field_name: "field".to_string(), + typename: Some(name!("User")), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + + let supergraph_request = Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + ); + + let res = super::aggregate_responses(vec![ + process_response( + Ok(response1), + response_key1, + connector.clone(), + &Context::default(), + (None, Default::default()), + None, + supergraph_request.clone(), + ) + .await + .mapped_response, + process_response( + Ok(response2), + response_key2, + connector, + &Context::default(), + (None, Default::default()), + None, + supergraph_request, + ) + .await + .mapped_response, + ]) + .unwrap(); + + assert_debug_snapshot!(res, @r###" + Response { + response: Response { + status: 200, + version: HTTP/1.1, + headers: {}, + body: Response { + label: None, + data: Some( + Object({ + "_entities": Array([ + Object({ + "__typename": String( + "User", + ), + "field": String( + "value1", + ), + }), + Object({ + "__typename": String( + "User", + ), + "field": String( + "value2", + ), + }), + ]), + }), + ), + path: None, + errors: [], + extensions: {}, + has_next: None, + subscribed: None, + created_at: None, + incremental: [], + }, + }, + } + "###); + } + + #[tokio::test] + async fn test_handle_responses_errors() { + let connector = Arc::new(Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(user), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("$.data").unwrap(), + entity_resolver: Some(EntityResolver::Explicit), + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }); + + let response_plaintext: http::Response = http::Response::builder() + .body(router::body::from_bytes(r#"plain text"#)) + .unwrap(); + let response_key_plaintext = ResponseKey::Entity { + index: 0, + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + + let response1: http::Response = http::Response::builder() + .status(404) + .body(router::body::from_bytes(r#"{"error":"not found"}"#)) + .unwrap(); + let response_key1 = ResponseKey::Entity { + index: 1, + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + + let response2 = http::Response::builder() + .body(router::body::from_bytes(r#"{"data":{"id":"2"}}"#)) + .unwrap(); + let response_key2 = ResponseKey::Entity { + index: 2, + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + + let response3: http::Response = http::Response::builder() + .status(500) + .body(router::body::from_bytes(r#"{"error":"whoops"}"#)) + .unwrap(); + let response_key3 = ResponseKey::Entity { + index: 3, + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + + let supergraph_request = Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + ); + + let mut res = super::aggregate_responses(vec![ + process_response( + Ok(response_plaintext), + response_key_plaintext, + connector.clone(), + &Context::default(), + (None, Default::default()), + None, + supergraph_request.clone(), + ) + .await + .mapped_response, + process_response( + Ok(response1), + response_key1, + connector.clone(), + &Context::default(), + (None, Default::default()), + None, + supergraph_request.clone(), + ) + .await + .mapped_response, + process_response( + Ok(response2), + response_key2, + connector.clone(), + &Context::default(), + (None, Default::default()), + None, + supergraph_request.clone(), + ) + .await + .mapped_response, + process_response( + Ok(response3), + response_key3, + connector, + &Context::default(), + (None, Default::default()), + None, + supergraph_request, + ) + .await + .mapped_response, + ]) + .unwrap(); + + // Overwrite error IDs to avoid random Uuid mismatch. + // Since assert_debug_snapshot does not support redactions (which would be useful for error IDs), + // we have to do it manually. + let body = res.response.body_mut(); + body.errors = body.errors.iter_mut().map(|e| e.with_null_id()).collect(); + + assert_debug_snapshot!(res, @r#" + Response { + response: Response { + status: 200, + version: HTTP/1.1, + headers: {}, + body: Response { + label: None, + data: Some( + Object({ + "_entities": Array([ + Null, + Null, + Object({ + "id": String( + "2", + ), + }), + Null, + ]), + }), + ), + path: None, + errors: [ + Error { + message: "The server returned data in an unexpected format.", + locations: [], + path: Some( + Path( + [ + Key( + "_entities", + None, + ), + Index( + 0, + ), + ], + ), + ), + extensions: { + "code": String( + "CONNECTOR_RESPONSE_INVALID", + ), + "service": String( + "subgraph_name", + ), + "connector": Object({ + "coordinate": String( + "subgraph_name:Query.user[0]", + ), + }), + "http": Object({ + "status": Number(200), + }), + "apollo.private.subgraph.name": String( + "subgraph_name", + ), + }, + apollo_id: 00000000-0000-0000-0000-000000000000, + }, + Error { + message: "Request failed", + locations: [], + path: Some( + Path( + [ + Key( + "_entities", + None, + ), + Index( + 1, + ), + ], + ), + ), + extensions: { + "code": String( + "CONNECTOR_FETCH", + ), + "service": String( + "subgraph_name", + ), + "connector": Object({ + "coordinate": String( + "subgraph_name:Query.user[0]", + ), + }), + "http": Object({ + "status": Number(404), + }), + "apollo.private.subgraph.name": String( + "subgraph_name", + ), + }, + apollo_id: 00000000-0000-0000-0000-000000000000, + }, + Error { + message: "Request failed", + locations: [], + path: Some( + Path( + [ + Key( + "_entities", + None, + ), + Index( + 3, + ), + ], + ), + ), + extensions: { + "code": String( + "CONNECTOR_FETCH", + ), + "service": String( + "subgraph_name", + ), + "connector": Object({ + "coordinate": String( + "subgraph_name:Query.user[0]", + ), + }), + "http": Object({ + "status": Number(500), + }), + "apollo.private.subgraph.name": String( + "subgraph_name", + ), + }, + apollo_id: 00000000-0000-0000-0000-000000000000, + }, + ], + extensions: {}, + has_next: None, + subscribed: None, + created_at: None, + incremental: [], + }, + }, + } + "#); + } + + #[tokio::test] + async fn test_handle_responses_status() { + let selection = JSONSelection::parse("$status").unwrap(); + let connector = Arc::new(Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(hello), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: selection.clone(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: IndexMap::from_iter([(Namespace::Status, Default::default())]), + error_settings: Default::default(), + label: "test label".into(), + }); + + let response1: http::Response = http::Response::builder() + .status(201) + .body(router::body::from_bytes(r#"{}"#)) + .unwrap(); + let response_key1 = ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$status").unwrap()), + }; + + let supergraph_request = Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + ); + + let res = super::aggregate_responses(vec![ + process_response( + Ok(response1), + response_key1, + connector, + &Context::default(), + (None, Default::default()), + None, + supergraph_request, + ) + .await + .mapped_response, + ]) + .unwrap(); + + assert_debug_snapshot!(res, @r###" + Response { + response: Response { + status: 200, + version: HTTP/1.1, + headers: {}, + body: Response { + label: None, + data: Some( + Object({ + "hello": Number(201), + }), + ), + path: None, + errors: [], + extensions: {}, + has_next: None, + subscribed: None, + created_at: None, + incremental: [], + }, + }, + } + "###); + } + + #[tokio::test] + async fn test_handle_response_with_is_success() { + let is_success = JSONSelection::parse("$status ->eq(400)").unwrap(); + let selection = JSONSelection::parse("$status").unwrap(); + let error_settings: ConnectorErrorsSettings = ConnectorErrorsSettings { + message: Default::default(), + source_extensions: Default::default(), + connect_extensions: Default::default(), + connect_is_success: Some(is_success.clone()), + }; + let connector = Arc::new(Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(hello), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: selection.clone(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: IndexMap::from_iter([(Namespace::Status, Default::default())]), + error_settings, + label: Label::from("test label"), + }); + + // First request should be marked as error as status is NOT 400 + let response_fail: http::Response = http::Response::builder() + .status(201) + .body(router::body::from_bytes(r#"{}"#)) + .unwrap(); + let response_fail_key = ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$status").unwrap()), + }; + + // Second response should be marked as a success as the status is 400! + let response_succeed: http::Response = http::Response::builder() + .status(400) + .body(router::body::from_bytes(r#"{}"#)) + .unwrap(); + let response_succeed_key = ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$status").unwrap()), + }; + + let supergraph_request = Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + ); + + // Make failing request + let res_expect_fail = super::aggregate_responses(vec![ + process_response( + Ok(response_fail), + response_fail_key, + connector.clone(), + &Context::default(), + (None, Default::default()), + None, + supergraph_request.clone(), + ) + .await + .mapped_response, + ]) + .unwrap() + .response; + assert_eq!(res_expect_fail.body().data, Some(JsonValue::Null)); + assert_eq!(res_expect_fail.body().errors.len(), 1); + + // Make succeeding request + let res_expect_success = super::aggregate_responses(vec![ + process_response( + Ok(response_succeed), + response_succeed_key, + connector.clone(), + &Context::default(), + (None, Default::default()), + None, + supergraph_request.clone(), + ) + .await + .mapped_response, + ]) + .unwrap() + .response; + assert!(res_expect_success.body().errors.is_empty()); + assert_eq!( + &res_expect_success.body().data, + &Some(json!({"hello": json!(400)})) + ); + } +} diff --git a/apollo-router/src/plugins/connectors/incompatible.rs b/apollo-router/src/plugins/connectors/incompatible.rs new file mode 100644 index 0000000000..3c08f48490 --- /dev/null +++ b/apollo-router/src/plugins/connectors/incompatible.rs @@ -0,0 +1,132 @@ +use std::collections::HashSet; + +use apollo_federation::connectors::expand::Connectors; +use apq::APQIncompatPlugin; +use authentication::AuthIncompatPlugin; +use batching::BatchingIncompatPlugin; +use coprocessor::CoprocessorIncompatPlugin; +use entity_cache::EntityCacheIncompatPlugin; +use headers::HeadersIncompatPlugin; +use rhai::RhaiIncompatPlugin; +use telemetry::TelemetryIncompatPlugin; +use tls::TlsIncompatPlugin; +use traffic_shaping::TrafficShapingIncompatPlugin; +use url_override::UrlOverrideIncompatPlugin; + +use crate::Configuration; + +mod apq; +mod authentication; +mod batching; +mod coprocessor; +mod entity_cache; +mod headers; +mod rhai; +mod telemetry; +mod tls; +mod traffic_shaping; +mod url_override; + +/// Pair of explicitly configured subgraphs for a plugin +#[derive(Default)] +struct ConfiguredSubgraphs<'a> { + /// Subgraphs which are explicitly enabled + enabled: HashSet<&'a String>, + + /// Subgraphs that are explicitly disabled + /// Note: Not all plugins allow for explicitly disabling a subgraph + disabled: HashSet<&'a String>, +} + +/// Trait describing a connector-enabled subgraph incompatible plugin +/// +/// Certain features of the router are not currently compatible with subgraphs +/// which use connectors, so those plugins can mark themselves as incompatible +/// with this trait. +/// +/// Note: Care should be taken to not spam the end-user with warnings that +/// either cannot be resolved or are not applicable in all circumstances. +trait IncompatiblePlugin { + /// Whether the plugin is currently configured to apply to all subgraphs + fn is_applied_to_all(&self) -> bool; + + /// Get all explicitly configured subgraphs for this plugin + fn configured_subgraphs(&self) -> ConfiguredSubgraphs<'_>; + + /// Inform the user of incompatibilities with provided subgraphs + fn inform_incompatibilities(&self, subgraphs: HashSet<&String>, connectors: &Connectors); +} + +/// Warn about possible incompatibilities with other router features / plugins. +/// +/// Connectors do not currently work with some of the existing router +/// features, so we need to inform the user when those features are +/// detected as being enabled. +pub(crate) fn warn_incompatible_plugins(config: &Configuration, connectors: &Connectors) { + let connector_enabled_subgraphs: HashSet<&String> = connectors + .by_service_name + .values() + .map(|v| &v.id.subgraph_name) + .collect(); + + // If we don't have any connector-enabled subgraphs, then no need to warn + if connector_enabled_subgraphs.is_empty() { + return; + } + + // Specify all of the incompatible plugin handlers that should warn + // + // Note: Plugin configuration is only populated if the user has specified it, + // so we can skip any that are missing. + macro_rules! boxify { + () => { + |a| { + let boxed: Box = Box::new(a); + boxed + } + }; + } + let incompatible_plugins: Vec> = vec![ + APQIncompatPlugin::from_config(config).map(boxify!()), + AuthIncompatPlugin::from_config(config).map(boxify!()), + BatchingIncompatPlugin::from_config(config).map(boxify!()), + CoprocessorIncompatPlugin::from_config(config).map(boxify!()), + EntityCacheIncompatPlugin::from_config(config).map(boxify!()), + HeadersIncompatPlugin::from_config(config).map(boxify!()), + RhaiIncompatPlugin::from_config(config).map(boxify!()), + TelemetryIncompatPlugin::from_config(config).map(boxify!()), + TlsIncompatPlugin::from_config(config).map(boxify!()), + TrafficShapingIncompatPlugin::from_config(config).map(boxify!()), + UrlOverrideIncompatPlugin::from_config(config).map(boxify!()), + ] + .into_iter() + .flatten() + .collect(); + + for plugin in incompatible_plugins { + // Grab all of the configured subgraphs for this plugin + let ConfiguredSubgraphs { enabled, disabled } = plugin.configured_subgraphs(); + + // Now actually calculate which are incompatible + // Note: We need to collect here because we need to know if the iterator + // is empty or not when printing the warning message. + let incompatible = if plugin.is_applied_to_all() { + // If all are enabled, then we can subtract out those which are disabled explicitly + connector_enabled_subgraphs + .difference(&disabled) + .copied() + .collect::>() + } else { + // Otherwise, then we only care about those explicitly enabled + enabled + .intersection(&connector_enabled_subgraphs) + .copied() + .collect::>() + }; + + // Now warn for each subgraph that is targeted by the incompatible plugin + if !incompatible.is_empty() { + plugin.inform_incompatibilities(incompatible, connectors); + } + } +} diff --git a/apollo-router/src/plugins/connectors/incompatible/apq.rs b/apollo-router/src/plugins/connectors/incompatible/apq.rs new file mode 100644 index 0000000000..dce9a57d24 --- /dev/null +++ b/apollo-router/src/plugins/connectors/incompatible/apq.rs @@ -0,0 +1,67 @@ +use itertools::Either; +use itertools::Itertools as _; + +use super::ConfiguredSubgraphs; +use super::IncompatiblePlugin; +use crate::Configuration; +use crate::configuration::Apq; + +pub(super) struct APQIncompatPlugin { + config: Apq, +} + +impl APQIncompatPlugin { + pub(super) fn from_config(config: &Configuration) -> Option { + // Apq is always default initialized, but can be explicitly + // disabled by the config, so we init this plugin only if enabled. + config.apq.enabled.then_some(Self { + config: config.apq.clone(), + }) + } +} + +impl IncompatiblePlugin for APQIncompatPlugin { + fn is_applied_to_all(&self) -> bool { + // Aqp allows for explicitly disabling it for all subgraphs, + // with overrides optionally set at the subgraph level + self.config.subgraph.all.enabled + } + + fn configured_subgraphs(&self) -> super::ConfiguredSubgraphs<'_> { + // Subgraphs can expliciltly enable / disable aqp, so we partition + // here for those cases + let (enabled, disabled) = + self.config + .subgraph + .subgraphs + .iter() + .partition_map(|(name, conf)| match conf.enabled { + true => Either::Left(name), + false => Either::Right(name), + }); + + ConfiguredSubgraphs { enabled, disabled } + } + + fn inform_incompatibilities( + &self, + subgraphs: std::collections::HashSet<&String>, + _connectors: &apollo_federation::connectors::expand::Connectors, + ) { + for subgraph in subgraphs { + if self.config.subgraph.subgraphs.contains_key(subgraph) { + tracing::warn!( + subgraph = subgraph, + message = "plugin `apq` is explicitly configured for connector-enabled subgraph, which is not supported.", + see = "https://go.apollo.dev/connectors/incompat", + ); + } else { + tracing::info!( + subgraph = subgraph, + message = "plugin `apq` indirectly targets a connector-enabled subgraph, which is not supported.", + see = "https://go.apollo.dev/connectors/incompat", + ); + } + } + } +} diff --git a/apollo-router/src/plugins/connectors/incompatible/authentication.rs b/apollo-router/src/plugins/connectors/incompatible/authentication.rs new file mode 100644 index 0000000000..f31d6ad805 --- /dev/null +++ b/apollo-router/src/plugins/connectors/incompatible/authentication.rs @@ -0,0 +1,114 @@ +use std::collections::HashSet; + +use apollo_federation::connectors::expand::Connectors; +use itertools::Itertools; + +use super::ConfiguredSubgraphs; +use super::IncompatiblePlugin; +use crate::Configuration; +use crate::plugins::authentication; +use crate::plugins::authentication::connector; + +/// Incompatibility handler for the built-in authentication plugin +pub(super) struct AuthIncompatPlugin { + // Auth configuration per subgraph + subgraph: authentication::subgraph::Config, + + /// The auth configuration per connector source + /// Note: We don't necessarily care about how each source is configured, + /// only that it has an entry in the config. + connector_sources: Option, +} + +impl AuthIncompatPlugin { + pub(super) fn from_config(config: &Configuration) -> Option { + let plugin_config = config.apollo_plugins.plugins.get("authentication"); + let subgraph_config = plugin_config + .and_then(|plugin| plugin.get("subgraph")) + .and_then(|subgraph_config| serde_json::from_value(subgraph_config.clone()).ok()); + let connector_sources = plugin_config + .and_then(|plugin| plugin.get("connector")) + .and_then(|sources| serde_json::from_value(sources.clone()).ok()); + + subgraph_config.map(|subgraph| AuthIncompatPlugin { + subgraph, + connector_sources, + }) + } +} + +impl IncompatiblePlugin for AuthIncompatPlugin { + fn configured_subgraphs(&self) -> ConfiguredSubgraphs<'_> { + // Authentication does not support manually marking subgraphs as + // disabled, so any subgraph listed is enabled. + ConfiguredSubgraphs { + enabled: self.subgraph.subgraphs.keys().collect(), + disabled: HashSet::with_hasher(Default::default()), + } + } + + fn is_applied_to_all(&self) -> bool { + self.subgraph.all.is_some() + } + + fn inform_incompatibilities(&self, subgraphs: HashSet<&String>, connectors: &Connectors) { + // If the user has not configured any connector-related options on authentication, + // then all passed subgraphs are misconfigured. + let Some(connector_sources) = self.connector_sources.as_ref() else { + return tracing::warn!( + subgraphs = subgraphs.iter().join(","), + message = "plugin `authentication` is enabled for connector-enabled subgraphs, which requires a different configuration to work properly", + see = "https://www.apollographql.com/docs/graphos/schema-design/connectors/router#authentication", + ); + }; + + // Authentication is technically compatible with connectors, but it must be configured + // at the connector source level rather than subgraph level. Here we collect + // all subgraphs and their set of sources. + // + // Note: Named sources are optional for connectors, so any connector that does not have + // one is misconfigured by default for authentication. + let sources = connectors + .by_service_name + .values() + .filter(|connector| { + subgraphs.contains(&connector.id.subgraph_name) + && !connector + .id + .source_name + .as_ref() + .map(|src| { + connector_sources + .sources + .contains_key(&format!("{}.{src}", connector.id.subgraph_name)) + }) + .unwrap_or(false) + }) + .map(|connector| { + ( + connector.id.subgraph_name.as_str(), + connector + .id + .source_name + .as_ref() + .map(|name| name.to_string()) + .unwrap_or(format!( + "", + connector.label.as_ref() + )), + ) + }) + .into_grouping_map() + .collect::>(); + + // Verify that for every affected subgraph that its sources have been configured separately + for (subgraph, srcs) in sources { + tracing::warn!( + subgraph = subgraph, + sources = srcs.into_iter().join(","), + message = "plugin `authentication` is enabled for a connector-enabled subgraph, which requires a different configuration to work properly", + see = "https://www.apollographql.com/docs/graphos/schema-design/connectors/router#authentication", + ); + } + } +} diff --git a/apollo-router/src/plugins/connectors/incompatible/batching.rs b/apollo-router/src/plugins/connectors/incompatible/batching.rs new file mode 100644 index 0000000000..76d4de93d5 --- /dev/null +++ b/apollo-router/src/plugins/connectors/incompatible/batching.rs @@ -0,0 +1,79 @@ +use itertools::Either; +use itertools::Itertools; + +use super::ConfiguredSubgraphs; +use super::IncompatiblePlugin; +use crate::Configuration; +use crate::configuration::Batching; + +pub(super) struct BatchingIncompatPlugin { + config: Batching, +} + +impl BatchingIncompatPlugin { + pub(super) fn from_config(config: &Configuration) -> Option { + // Batching is always default initialized, but can be explicitly + // disabled by the config, so we init this plugin only if enabled. + config.batching.enabled.then_some(Self { + config: config.batching.clone(), + }) + } +} + +impl IncompatiblePlugin for BatchingIncompatPlugin { + fn is_applied_to_all(&self) -> bool { + // Batching allows for explicitly disabling it for all subgraphs, + // with overrides optionally set at the subgraph level + self.config + .subgraph + .as_ref() + .map(|conf| conf.all.enabled) + .unwrap_or_default() + } + + fn configured_subgraphs(&self) -> super::ConfiguredSubgraphs<'_> { + // Subgraphs can expliciltly enable / disable batching, so we partition + // here for those cases + self.config + .subgraph + .as_ref() + .map(|conf| { + conf.subgraphs + .iter() + .partition_map(|(name, batch)| match batch.enabled { + true => Either::Left(name), + false => Either::Right(name), + }) + }) + .map(|(enabled, disabled)| ConfiguredSubgraphs { enabled, disabled }) + .unwrap_or_default() + } + + fn inform_incompatibilities( + &self, + subgraphs: std::collections::HashSet<&String>, + _connectors: &apollo_federation::connectors::expand::Connectors, + ) { + for subgraph in subgraphs { + if self + .config + .subgraph + .as_ref() + .map(|conf| conf.subgraphs.contains_key(subgraph)) + .unwrap_or_default() + { + tracing::warn!( + subgraph = subgraph, + message = "plugin `batching` is explicitly configured for connector-enabled subgraph, which is not supported.", + see = "https://go.apollo.dev/connectors/incompat", + ); + } else { + tracing::info!( + subgraph = subgraph, + message = "plugin `batching` indirectly targets a connector-enabled subgraph, which is not supported.", + see = "https://go.apollo.dev/connectors/incompat", + ); + } + } + } +} diff --git a/apollo-router/src/plugins/connectors/incompatible/coprocessor.rs b/apollo-router/src/plugins/connectors/incompatible/coprocessor.rs new file mode 100644 index 0000000000..95e681d1c4 --- /dev/null +++ b/apollo-router/src/plugins/connectors/incompatible/coprocessor.rs @@ -0,0 +1,45 @@ +use itertools::Itertools as _; + +use super::IncompatiblePlugin; +use crate::Configuration; +use crate::plugins::coprocessor; + +pub(super) struct CoprocessorIncompatPlugin; + +impl CoprocessorIncompatPlugin { + pub(super) fn from_config(config: &Configuration) -> Option { + config + .apollo_plugins + .plugins + .get("coprocessor") + .and_then(|val| val.get("subgraph")) + .and_then(|val| val.get("all")) + .and_then(|raw| serde_json::from_value(raw.clone()).ok()) + .map(|_: coprocessor::SubgraphStage| CoprocessorIncompatPlugin) + } +} + +impl IncompatiblePlugin for CoprocessorIncompatPlugin { + fn is_applied_to_all(&self) -> bool { + // If the coprocessor is configured with a subgraph setting, then it is + // for sure applied to all as there is no other configuration available + true + } + + fn configured_subgraphs(&self) -> super::ConfiguredSubgraphs<'_> { + // Coprocessors cannot be configured at the subgraph level + Default::default() + } + + fn inform_incompatibilities( + &self, + subgraphs: std::collections::HashSet<&String>, + _connectors: &apollo_federation::connectors::expand::Connectors, + ) { + tracing::info!( + subgraphs = subgraphs.into_iter().join(","), + message = "coprocessors which hook into `subgraph_request` or `subgraph_response` won't be used by connector-enabled subgraphs", + see = "https://go.apollo.dev/connectors/incompat", + ); + } +} diff --git a/apollo-router/src/plugins/connectors/incompatible/entity_cache.rs b/apollo-router/src/plugins/connectors/incompatible/entity_cache.rs new file mode 100644 index 0000000000..92f5906067 --- /dev/null +++ b/apollo-router/src/plugins/connectors/incompatible/entity_cache.rs @@ -0,0 +1,69 @@ +use itertools::Either; +use itertools::Itertools as _; + +use super::IncompatiblePlugin; +use crate::Configuration; +use crate::plugins::cache::entity; + +pub(super) struct EntityCacheIncompatPlugin { + config: entity::Config, +} + +impl EntityCacheIncompatPlugin { + pub(super) fn from_config(config: &Configuration) -> Option { + config + .apollo_plugins + .plugins + .get("preview_entity_cache") + .and_then(|raw| serde_json::from_value(raw.clone()).ok()) + .and_then(|config: entity::Config| { + config + .enabled + .then_some(EntityCacheIncompatPlugin { config }) + }) + } +} + +impl IncompatiblePlugin for EntityCacheIncompatPlugin { + fn is_applied_to_all(&self) -> bool { + self.config.subgraph.all.enabled.unwrap_or_default() + } + + fn configured_subgraphs(&self) -> super::ConfiguredSubgraphs<'_> { + let (enabled, disabled) = + self.config + .subgraph + .subgraphs + .iter() + .partition_map(|(name, sub)| match sub.enabled { + Some(true) => Either::Left(name), + Some(false) => Either::Right(name), + // Because default value of sub.enabled is true, we can assume that None means true + None => Either::Left(name), + }); + + super::ConfiguredSubgraphs { enabled, disabled } + } + + fn inform_incompatibilities( + &self, + subgraphs: std::collections::HashSet<&String>, + _connectors: &apollo_federation::connectors::expand::Connectors, + ) { + for subgraph in subgraphs { + if self.config.subgraph.subgraphs.contains_key(subgraph) { + tracing::warn!( + subgraph = subgraph, + message = "plugin `preview_entity_cache` is explicitly configured for connector-enabled subgraph, which is not supported.", + see = "https://go.apollo.dev/connectors/incompat", + ); + } else { + tracing::info!( + subgraph = subgraph, + message = "plugin `preview_entity_cache` indirectly targets a connector-enabled subgraph, which is not supported.", + see = "https://go.apollo.dev/connectors/incompat", + ); + } + } + } +} diff --git a/apollo-router/src/plugins/connectors/incompatible/headers.rs b/apollo-router/src/plugins/connectors/incompatible/headers.rs new file mode 100644 index 0000000000..9f826b6d54 --- /dev/null +++ b/apollo-router/src/plugins/connectors/incompatible/headers.rs @@ -0,0 +1,59 @@ +use std::collections::HashSet; + +use super::ConfiguredSubgraphs; +use super::IncompatiblePlugin; +use crate::Configuration; +use crate::configuration::subgraph::SubgraphConfiguration; + +pub(super) struct HeadersIncompatPlugin { + /// Configured subgraphs for header propagation + config: SubgraphConfiguration>, +} + +impl HeadersIncompatPlugin { + pub(super) fn from_config(config: &Configuration) -> Option { + config + .apollo_plugins + .plugins + .get("headers") + .and_then(|headers| serde_json::from_value(headers.clone()).ok()) + .map(|subgraphs| HeadersIncompatPlugin { config: subgraphs }) + } +} + +impl IncompatiblePlugin for HeadersIncompatPlugin { + fn is_applied_to_all(&self) -> bool { + self.config.all.is_some() + } + + fn configured_subgraphs(&self) -> ConfiguredSubgraphs<'_> { + // Headers does not support manually marking subgraphs as + // disabled, so any subgraph listed is enabled. + ConfiguredSubgraphs { + enabled: self.config.subgraphs.keys().collect(), + disabled: HashSet::with_hasher(Default::default()), + } + } + + fn inform_incompatibilities( + &self, + subgraphs: std::collections::HashSet<&String>, + _connectors: &apollo_federation::connectors::expand::Connectors, + ) { + for subgraph in subgraphs { + if self.config.subgraphs.contains_key(subgraph) { + tracing::warn!( + subgraph = subgraph, + message = "plugin `headers` is explicitly configured for connector-enabled subgraph, which is not supported. Headers will not be applied", + see = "https://go.apollo.dev/connectors/incompat", + ); + } else { + tracing::info!( + subgraph = subgraph, + message = "plugin `headers` indirectly targets a connector-enabled subgraph, which is not supported. Headers will not be applied", + see = "https://go.apollo.dev/connectors/incompat", + ); + } + } + } +} diff --git a/apollo-router/src/plugins/connectors/incompatible/rhai.rs b/apollo-router/src/plugins/connectors/incompatible/rhai.rs new file mode 100644 index 0000000000..ffe264c8bf --- /dev/null +++ b/apollo-router/src/plugins/connectors/incompatible/rhai.rs @@ -0,0 +1,41 @@ +use itertools::Itertools as _; + +use super::IncompatiblePlugin; +use crate::Configuration; + +pub(super) struct RhaiIncompatPlugin; + +impl RhaiIncompatPlugin { + pub(super) fn from_config(config: &Configuration) -> Option { + config + .apollo_plugins + .plugins + .get("rhai") + .map(|_| RhaiIncompatPlugin) + } +} + +impl IncompatiblePlugin for RhaiIncompatPlugin { + fn is_applied_to_all(&self) -> bool { + // Rhai is always applied to all subgraphs since it modifies + // the lifecycle of each router request + true + } + + fn configured_subgraphs(&self) -> super::ConfiguredSubgraphs<'_> { + // Rhai cannot be configured at the subgraph level + Default::default() + } + + fn inform_incompatibilities( + &self, + subgraphs: std::collections::HashSet<&String>, + _connectors: &apollo_federation::connectors::expand::Connectors, + ) { + tracing::info!( + subgraphs = subgraphs.into_iter().join(","), + message = "rhai scripts which hook into `subgraph_request` or `subgraph_response` won't be used by connector-enabled subgraphs", + see = "https://go.apollo.dev/connectors/incompat", + ); + } +} diff --git a/apollo-router/src/plugins/connectors/incompatible/telemetry.rs b/apollo-router/src/plugins/connectors/incompatible/telemetry.rs new file mode 100644 index 0000000000..3d42ee4420 --- /dev/null +++ b/apollo-router/src/plugins/connectors/incompatible/telemetry.rs @@ -0,0 +1,76 @@ +use itertools::Either; +use itertools::Itertools as _; + +use super::IncompatiblePlugin; +use crate::Configuration; +use crate::plugins::telemetry::apollo; +use crate::plugins::telemetry::config::Conf; + +pub(super) struct TelemetryIncompatPlugin { + config: apollo::ErrorsConfiguration, +} + +impl TelemetryIncompatPlugin { + pub(super) fn from_config(config: &Configuration) -> Option { + Some(TelemetryIncompatPlugin { + config: Conf::apollo(config).errors, + }) + } +} + +impl IncompatiblePlugin for TelemetryIncompatPlugin { + fn is_applied_to_all(&self) -> bool { + self.config.subgraph.all.send + // When ExtendedErrorMetricsMode is enabled, this plugin supports reporting connector errors + && !matches!( + self.config.preview_extended_error_metrics, + apollo::ExtendedErrorMetricsMode::Enabled + ) + } + + fn configured_subgraphs(&self) -> super::ConfiguredSubgraphs<'_> { + // While you can't necessarily disable telemetry errors per subgraph, + // you can technically disable doing anything with it. + let (enabled, disabled) = + self.config + .subgraph + .subgraphs + .iter() + .partition_map(|(name, sub)| { + if sub.send + && !matches!( + self.config.preview_extended_error_metrics, + apollo::ExtendedErrorMetricsMode::Enabled + ) + { + Either::Left(name) + } else { + Either::Right(name) + } + }); + + super::ConfiguredSubgraphs { enabled, disabled } + } + + fn inform_incompatibilities( + &self, + subgraphs: std::collections::HashSet<&String>, + _connectors: &apollo_federation::connectors::expand::Connectors, + ) { + for subgraph in subgraphs { + if self.config.subgraph.subgraphs.contains_key(subgraph) { + tracing::warn!( + subgraph = subgraph, + message = "plugin `telemetry` is explicitly configured to send errors to Apollo studio for connector-enabled subgraph, which is only supported when `preview_extended_error_metrics` is enabled", + see = "https://go.apollo.dev/connectors/incompat", + ); + } else { + tracing::info!( + subgraph = subgraph, + message = "plugin `telemetry` is indirectly configured to send errors to Apollo studio for a connector-enabled subgraph, which is only supported when `preview_extended_error_metrics` is enabled", + see = "https://go.apollo.dev/connectors/incompat", + ); + } + } + } +} diff --git a/apollo-router/src/plugins/connectors/incompatible/tls.rs b/apollo-router/src/plugins/connectors/incompatible/tls.rs new file mode 100644 index 0000000000..7142270efc --- /dev/null +++ b/apollo-router/src/plugins/connectors/incompatible/tls.rs @@ -0,0 +1,54 @@ +use std::collections::HashSet; + +use super::ConfiguredSubgraphs; +use super::IncompatiblePlugin; +use crate::Configuration; +use crate::configuration::Tls; + +pub(super) struct TlsIncompatPlugin { + config: Tls, +} + +impl TlsIncompatPlugin { + pub(super) fn from_config(config: &Configuration) -> Option { + // TLS is always enaabled and gets default initialized + Some(Self { + config: config.tls.clone(), + }) + } +} + +impl IncompatiblePlugin for TlsIncompatPlugin { + fn is_applied_to_all(&self) -> bool { + let all = &self.config.subgraph.all; + + // Since everything gets default initialized, we need to manually check + // that every field is not set :( + all.certificate_authorities.is_some() || all.client_authentication.is_some() + } + + fn configured_subgraphs(&self) -> super::ConfiguredSubgraphs<'_> { + // TLS cannot be manually disabled per subgraph, so all configured are + // enabled. + ConfiguredSubgraphs { + enabled: self.config.subgraph.subgraphs.keys().collect(), + disabled: HashSet::with_hasher(Default::default()), + } + } + + fn inform_incompatibilities( + &self, + subgraphs: std::collections::HashSet<&String>, + _connectors: &apollo_federation::connectors::expand::Connectors, + ) { + for subgraph in subgraphs { + if self.config.subgraph.subgraphs.contains_key(subgraph) { + tracing::warn!( + subgraph = subgraph, + message = "The `tls` plugin is explicitly configured for a subgraph containing connectors, which is not supported. Instead, configure the connector sources directly using `tls.connector.sources..`.", + see = "https://www.apollographql.com/docs/graphos/schema-design/connectors/router#tls", + ); + } + } + } +} diff --git a/apollo-router/src/plugins/connectors/incompatible/traffic_shaping.rs b/apollo-router/src/plugins/connectors/incompatible/traffic_shaping.rs new file mode 100644 index 0000000000..a468ad6c26 --- /dev/null +++ b/apollo-router/src/plugins/connectors/incompatible/traffic_shaping.rs @@ -0,0 +1,72 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +use serde::Deserialize; +use serde_json::Value; + +use super::ConfiguredSubgraphs; +use super::IncompatiblePlugin; +use crate::Configuration; + +#[derive(Debug, Deserialize)] +struct Config { + all: Option, + subgraphs: Option>, +} + +pub(super) struct TrafficShapingIncompatPlugin { + config: Config, +} + +impl TrafficShapingIncompatPlugin { + pub(super) fn from_config(config: &Configuration) -> Option { + config + .apollo_plugins + .plugins + .get("traffic_shaping") + .and_then(|raw| serde_json::from_value(raw.clone()).ok()) + .map(|config| Self { config }) + } +} + +impl IncompatiblePlugin for TrafficShapingIncompatPlugin { + fn is_applied_to_all(&self) -> bool { + self.config.all.is_some() + } + + fn configured_subgraphs(&self) -> super::ConfiguredSubgraphs<'_> { + // Apq does not support manually marking subgraphs as + // disabled, so any subgraph listed is enabled. + ConfiguredSubgraphs { + enabled: self + .config + .subgraphs + .as_ref() + .map(|subs| subs.keys().collect()) + .unwrap_or_default(), + disabled: HashSet::with_hasher(Default::default()), + } + } + + fn inform_incompatibilities( + &self, + subgraphs: std::collections::HashSet<&String>, + _connectors: &apollo_federation::connectors::expand::Connectors, + ) { + for subgraph in subgraphs { + if self + .config + .subgraphs + .as_ref() + .map(|subs| subs.contains_key(subgraph)) + .unwrap_or_default() + { + tracing::warn!( + subgraph = subgraph, + message = "The `traffic_shaping` plugin is explicitly configured for a subgraph containing connectors, which is not supported. Instead, configure the connector sources directly using `traffic_shaping.connector.sources..`.", + see = "https://www.apollographql.com/docs/graphos/schema-design/connectors/router#traffic-shaping", + ); + } + } + } +} diff --git a/apollo-router/src/plugins/connectors/incompatible/url_override.rs b/apollo-router/src/plugins/connectors/incompatible/url_override.rs new file mode 100644 index 0000000000..5a0111ae5c --- /dev/null +++ b/apollo-router/src/plugins/connectors/incompatible/url_override.rs @@ -0,0 +1,53 @@ +use std::collections::HashSet; + +use super::ConfiguredSubgraphs; +use super::IncompatiblePlugin; +use crate::Configuration; + +pub(super) struct UrlOverrideIncompatPlugin { + configured: HashSet, +} + +impl UrlOverrideIncompatPlugin { + pub(super) fn from_config(config: &Configuration) -> Option { + config + .apollo_plugins + .plugins + .get("override_subgraph_url") + .and_then(serde_json::Value::as_object) + .map(|configured| UrlOverrideIncompatPlugin { + configured: configured.keys().cloned().collect(), + }) + } +} + +impl IncompatiblePlugin for UrlOverrideIncompatPlugin { + fn is_applied_to_all(&self) -> bool { + // Overrides are per subgraph, so it can never target all + false + } + + fn configured_subgraphs(&self) -> super::ConfiguredSubgraphs<'_> { + // Overrides cannot be explicitly disabled, so all present overrides + // are always enabled + ConfiguredSubgraphs { + enabled: self.configured.iter().by_ref().collect(), + disabled: HashSet::with_hasher(Default::default()), + } + } + + fn inform_incompatibilities( + &self, + subgraphs: HashSet<&String>, + _connectors: &apollo_federation::connectors::expand::Connectors, + ) { + for subgraph in subgraphs { + tracing::warn!( + subgraph = subgraph, + message = + "overriding a subgraph URL for a connectors-enabled subgraph is not supported", + see = "https://go.apollo.dev/connectors/incompat", + ); + } + } +} diff --git a/apollo-router/src/plugins/connectors/make_requests.rs b/apollo-router/src/plugins/connectors/make_requests.rs new file mode 100644 index 0000000000..4e18ccb3c5 --- /dev/null +++ b/apollo-router/src/plugins/connectors/make_requests.rs @@ -0,0 +1,2181 @@ +use std::sync::Arc; + +use apollo_compiler::executable::Selection; +use apollo_federation::connectors::Connector; +use apollo_federation::connectors::EntityResolver; +use apollo_federation::connectors::runtime::debug::ConnectorContext; +use apollo_federation::connectors::runtime::http_json_transport::HttpJsonTransportError; +use apollo_federation::connectors::runtime::http_json_transport::make_request; +use apollo_federation::connectors::runtime::inputs::RequestInputs; +use apollo_federation::connectors::runtime::key::ResponseKey; +use parking_lot::Mutex; + +use crate::Context; +use crate::services::connect; +use crate::services::connector::request_service::Request; + +const REPRESENTATIONS_VAR: &str = "representations"; +const ENTITIES: &str = "_entities"; +const TYPENAME: &str = "__typename"; + +pub(crate) fn make_requests( + request: connect::Request, + context: &Context, + connector: Arc, + debug: &Option>>, +) -> Result, MakeRequestError> { + let request_params = match connector.entity_resolver { + Some(EntityResolver::Explicit) | Some(EntityResolver::TypeSingle) => { + entities_from_request(connector.clone(), &request) + } + Some(EntityResolver::Implicit) => { + entities_with_fields_from_request(connector.clone(), &request) + } + Some(EntityResolver::TypeBatch) => batch_entities_from_request(connector.clone(), &request), + None => root_fields(connector.clone(), &request), + }?; + + request_params_to_requests(context, connector, request_params, request, debug) +} + +fn request_params_to_requests( + context: &Context, + connector: Arc, + request_params: Vec, + original_request: connect::Request, + debug: &Option>>, +) -> Result, MakeRequestError> { + let mut results = vec![]; + for response_key in request_params { + let connector = connector.clone(); + let (transport_request, mapping_problems) = make_request( + &connector.transport, + response_key + .inputs() + .clone() + .merger(&connector.request_variable_keys) + .config(connector.config.as_ref()) + .context(&original_request.context) + .request( + &connector.request_headers, + original_request.supergraph_request.headers(), + ) + .merge(), + original_request.supergraph_request.headers(), + debug, + )?; + + results.push(Request { + context: context.clone(), + connector, + transport_request, + key: response_key, + mapping_problems, + supergraph_request: original_request.supergraph_request.clone(), + }); + } + + Ok(results) +} + +// --- ERRORS ------------------------------------------------------------------ + +#[derive(Debug, thiserror::Error, displaydoc::Display)] +pub(crate) enum MakeRequestError { + /// Invalid request operation: {0} + InvalidOperation(String), + + /// Unsupported request operation: {0} + UnsupportedOperation(String), + + /// Invalid request arguments: {0} + InvalidArguments(String), + + /// Invalid entity representation: {0} + InvalidRepresentations(String), + + /// Cannot create HTTP request: {0} + TransportError(#[from] HttpJsonTransportError), +} + +// --- ROOT FIELDS ------------------------------------------------------------- + +/// Given a query, find the root fields and return a list of requests. +/// The connector subgraph must have only a single root field, but it could be +/// used multiple times with aliases. +/// +/// Root fields exist in the supergraph schema so we can parse the operation +/// using the schema. (This isn't true for _entities operations.) +/// +/// Example: +/// ```graphql +/// type Query { +/// foo(bar: String): Foo @connect(...) +/// } +/// ``` +/// ```graphql +/// { +/// a: foo(bar: "a") # one request +/// b: foo(bar: "b") # another request +/// } +/// ``` +fn root_fields( + connector: Arc, + request: &connect::Request, +) -> Result, MakeRequestError> { + use MakeRequestError::*; + + let op = request + .operation + .operations + .get(None) + .map_err(|_| InvalidOperation("no operation document".into()))?; + + op.selection_set + .selections + .iter() + .map(|s| match s { + Selection::Field(field) => { + let response_name = field + .alias + .as_ref() + .unwrap_or_else(|| &field.name) + .to_string(); + + let args = graphql_utils::field_arguments_map(field, &request.variables.variables) + .map_err(|err| { + InvalidArguments(format!("cannot get inputs from field arguments: {err}")) + })?; + + let request_inputs = RequestInputs { + args, + ..Default::default() + }; + + let response_key = ResponseKey::RootField { + name: response_name, + selection: Arc::new(connector.selection.apply_selection_set( + &request.operation, + &field.selection_set, + None, + )), + inputs: request_inputs, + }; + + Ok(response_key) + } + + // The query planner removes fragments at the root so we don't have + // to worry these branches + Selection::FragmentSpread(_) | Selection::InlineFragment(_) => { + Err(UnsupportedOperation( + "top-level fragments in query planner nodes should not happen".into(), + )) + } + }) + .collect::, MakeRequestError>>() +} + +// --- ENTITIES ---------------------------------------------------------------- + +/// Connectors marked with `entity: true` can be used as entity resolvers, +/// (resolving `_entities` queries) or regular root fields. For now we'll check +/// the existence of the `representations` variable to determine which use case +/// is relevant here. +/// +/// If it's an entity resolver, we create separate requests for each item in the +/// representations array. +/// +/// ```json +/// { +/// "variables": { +/// "representations": [{ "__typename": "User", "id": "1" }] +/// } +/// } +/// ``` +/// +/// Returns a list of request inputs and the response key (index in the array). +fn entities_from_request( + connector: Arc, + request: &connect::Request, +) -> Result, MakeRequestError> { + use MakeRequestError::*; + + let Some(representations) = request.variables.variables.get(REPRESENTATIONS_VAR) else { + return root_fields(connector, request); + }; + + let op = request + .operation + .operations + .get(None) + .map_err(|_| InvalidOperation("no operation document".into()))?; + + let (entities_field, _) = graphql_utils::get_entity_fields(&request.operation, op)?; + + let selection = Arc::new(connector.selection.apply_selection_set( + &request.operation, + &entities_field.selection_set, + None, + )); + + representations + .as_array() + .ok_or_else(|| InvalidRepresentations("representations is not an array".into()))? + .iter() + .enumerate() + .map(|(i, rep)| { + let request_inputs = match connector.entity_resolver { + Some(EntityResolver::Explicit) => RequestInputs { + args: rep + .as_object() + .ok_or_else(|| { + InvalidRepresentations("representation is not an object".into()) + })? + .clone(), + ..Default::default() + }, + Some(EntityResolver::TypeSingle) => RequestInputs { + this: rep + .as_object() + .ok_or_else(|| { + InvalidRepresentations("representation is not an object".into()) + })? + .clone(), + ..Default::default() + }, + _ => { + return Err(InvalidRepresentations( + "entity resolver not supported for this connector".into(), + )); + } + }; + + Ok(ResponseKey::Entity { + index: i, + selection: selection.clone(), + inputs: request_inputs, + }) + }) + .collect::, _>>() +} + +// --- ENTITY FIELDS ----------------------------------------------------------- + +/// This is effectively the combination of the other two functions: +/// +/// * It makes a request for each item in the `representations` array. +/// * If the connector field is aliased, it makes a request for each alias. +/// +/// So it can return N (representations) x M (aliases) requests. +/// +/// ```json +/// { +/// "query": "{ _entities(representations: $representations) { ... on User { name } } }", +/// "variables": { "representations": [{ "__typename": "User", "id": "1" }] } +/// } +/// ``` +/// +/// Return a list of request inputs with the response key (index in list and +/// name/alias of field) for each. +fn entities_with_fields_from_request( + connector: Arc, + request: &connect::Request, +) -> Result, MakeRequestError> { + use MakeRequestError::*; + + let op = request + .operation + .operations + .get(None) + .map_err(|_| InvalidOperation("no operation document".into()))?; + + let (entities_field, typename_requested) = + graphql_utils::get_entity_fields(&request.operation, op)?; + + let types_and_fields = entities_field + .selection_set + .selections + .iter() + .map(|selection| match selection { + Selection::Field(_) => Ok::<_, MakeRequestError>(vec![]), + + Selection::FragmentSpread(f) => { + let Some(frag) = f.fragment_def(&request.operation) else { + return Err(InvalidOperation(format!( + "invalid operation: fragment `{}` missing", + f.fragment_name + ))); + }; + let typename = frag.type_condition(); + Ok(frag + .selection_set + .selections + .iter() + .filter_map(|sel| { + let field = match sel { + Selection::Field(f) => { + if f.name == TYPENAME { + None + } else { + Some(f) + } + } + Selection::FragmentSpread(_) | Selection::InlineFragment(_) => { + return Some(Err(InvalidOperation( + "handling fragments inside entity selections not implemented" + .into(), + ))); + } + }; + field.map(|f| Ok((typename, f))) + }) + .collect::, _>>()?) + } + + Selection::InlineFragment(frag) => { + let typename = frag + .type_condition + .as_ref() + .ok_or_else(|| InvalidOperation("missing type condition".into()))?; + Ok(frag + .selection_set + .selections + .iter() + .filter_map(|sel| { + let field = match sel { + Selection::Field(f) => { + if f.name == TYPENAME { + None + } else { + Some(f) + } + } + Selection::FragmentSpread(_) | Selection::InlineFragment(_) => { + return Some(Err(InvalidOperation( + "handling fragments inside entity selections not implemented" + .into(), + ))); + } + }; + field.map(|f| Ok((typename, f))) + }) + .collect::, _>>()?) + } + }) + .collect::, _>>()?; + + let representations = request + .variables + .variables + .get(REPRESENTATIONS_VAR) + .ok_or_else(|| InvalidRepresentations("missing representations variable".into()))? + .as_array() + .ok_or_else(|| InvalidRepresentations("representations is not an array".into()))? + .iter() + .enumerate() + .collect::>(); + + // if we have multiple fields (because of aliases, we'll flatten that list) + // and generate requests for each field/representation pair + types_and_fields + .into_iter() + .flatten() + .flat_map(|(typename, field)| { + let selection = Arc::new(connector.selection.apply_selection_set( + &request.operation, + &field.selection_set, + None, + )); + + representations.iter().map(move |(i, representation)| { + let args = graphql_utils::field_arguments_map(field, &request.variables.variables) + .map_err(|err| { + InvalidArguments(format!("cannot get inputs from field arguments: {err}")) + })?; + + let response_name = field + .alias + .as_ref() + .unwrap_or_else(|| &field.name) + .to_string(); + + let request_inputs = RequestInputs { + args, + this: representation + .as_object() + .ok_or_else(|| { + InvalidRepresentations("representation is not an object".into()) + })? + .clone(), + ..Default::default() + }; + Ok::<_, MakeRequestError>(ResponseKey::EntityField { + index: *i, + field_name: response_name.to_string(), + // if the fetch node operation doesn't include __typename, then + // we're assuming this is for an interface object and we don't want + // to include a __typename in the response. + // + // TODO: is this fragile? should we just check the output + // type of the field and omit the typename if it's abstract? + typename: typename_requested.then_some(typename.clone()), + selection: selection.clone(), + inputs: request_inputs, + }) + }) + }) + .collect::, _>>() +} + +// --- BATCH ENTITIES ---------------------------------------------------------------- + +/// Connectors on types can make a single batch request for multiple entities +/// using the `$batch` variable. +/// +/// The key (pun intended) to batching is that we have to return entities in an +/// order than matches the `representations` variable. We use the "key" fields +/// to construct a HashMap key for each representation and response object, +/// which allows us to match them up and return them in the correct order. +fn batch_entities_from_request( + connector: Arc, + request: &connect::Request, +) -> Result, MakeRequestError> { + use MakeRequestError::*; + + let Some(keys) = &request.keys else { + return Err(InvalidOperation("TODO better error type".into())); + }; + + let Some(representations) = request.variables.variables.get(REPRESENTATIONS_VAR) else { + return Err(InvalidRepresentations( + "batch_entities_from_request called without representations".into(), + )); + }; + + let op = request + .operation + .operations + .get(None) + .map_err(|_| InvalidOperation("no operation document".into()))?; + + let (entities_field, _) = graphql_utils::get_entity_fields(&request.operation, op)?; + + let selection = Arc::new(connector.selection.apply_selection_set( + &request.operation, + &entities_field.selection_set, + Some(keys), + )); + + // First, let's grab all the representations into a single batch + let batch = representations + .as_array() + .ok_or_else(|| InvalidRepresentations("representations is not an array".into()))? + .iter() + .map(|rep| { + let obj = rep + .as_object() + .ok_or_else(|| InvalidRepresentations("representation is not an object".into()))? + .clone(); + Ok::<_, MakeRequestError>(obj) + }) + .collect::, _>>()?; + + // If we've got a max_size set, chunk the batch into smaller batches. Otherwise, we'll default to just a single batch. + let max_size = connector.batch_settings.as_ref().and_then(|bs| bs.max_size); + let batches = if let Some(size) = max_size { + batch.chunks(size).map(|chunk| chunk.to_vec()).collect() + } else { + vec![batch] + }; + + // Finally, map the batches to BatchEntity. Each one of these final BatchEntity's ends up being a outgoing request + let batch_entities = batches + .iter() + .map(|batch| { + let inputs = RequestInputs { + batch: batch.to_vec(), + ..Default::default() + }; + + ResponseKey::BatchEntity { + selection: selection.clone(), + inputs, + keys: keys.clone(), + } + }) + .collect(); + + Ok(batch_entities) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use apollo_compiler::ExecutableDocument; + use apollo_compiler::Schema; + use apollo_compiler::executable::FieldSet; + use apollo_compiler::name; + use apollo_federation::connectors::ConnectBatchArguments; + use apollo_federation::connectors::ConnectId; + use apollo_federation::connectors::ConnectSpec; + use apollo_federation::connectors::Connector; + use apollo_federation::connectors::HttpJsonTransport; + use apollo_federation::connectors::JSONSelection; + use apollo_federation::connectors::StringTemplate; + use apollo_federation::connectors::runtime::http_json_transport::TransportRequest; + use insta::assert_debug_snapshot; + + use crate::Context; + use crate::graphql; + use crate::query_planner::fetch::Variables; + + const DEFAULT_CONNECT_SPEC: ConnectSpec = ConnectSpec::V0_2; + + #[test] + fn test_root_fields_simple() { + let schema = Arc::new( + Schema::parse_and_validate("type Query { a: A } type A { f: String }", "./").unwrap(), + ); + + let req = crate::services::connect::Request::builder() + .service_name("subgraph_Query_a_0".into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &schema, + "query { a { f } a2: a { f2: f } }".to_string(), + "./", + ) + .unwrap(), + )) + .variables(Variables { + variables: Default::default(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .build(); + + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(a), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("f").unwrap(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + assert_debug_snapshot!(super::root_fields(Arc::new(connector), &req), @r#" + Ok( + [ + RootField { + name: "a", + selection: "f", + inputs: RequestInputs { + args: {}, + this: {}, + batch: [] + }, + }, + RootField { + name: "a2", + selection: "f2: f", + inputs: RequestInputs { + args: {}, + this: {}, + batch: [] + }, + }, + ], + ) + "#); + } + + #[test] + fn test_root_fields_inputs() { + let schema = Arc::new( + Schema::parse_and_validate("type Query {b(var: String): String}", "./").unwrap(), + ); + + let req = crate::services::connect::Request::builder() + .service_name("subgraph_Query_b_0".into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &schema, + "query($var: String) { b(var: \"inline\") b2: b(var: $var) }".to_string(), + "./", + ) + .unwrap(), + )) + .variables(Variables { + variables: serde_json_bytes::json!({ "var": "variable" }) + .as_object() + .unwrap() + .clone(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .build(); + + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(b), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("$").unwrap(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + assert_debug_snapshot!(super::root_fields(Arc::new(connector), &req), @r#" + Ok( + [ + RootField { + name: "b", + selection: "$", + inputs: RequestInputs { + args: {"var":"inline"}, + this: {}, + batch: [] + }, + }, + RootField { + name: "b2", + selection: "$", + inputs: RequestInputs { + args: {"var":"variable"}, + this: {}, + batch: [] + }, + }, + ], + ) + "#); + } + + #[test] + fn test_root_fields_input_types() { + let schema = Arc::new(Schema::parse_and_validate( + r#" + scalar JSON + type Query { + c(var1: Int, var2: Boolean, var3: Float, var4: ID, var5: JSON, var6: [String], var7: String): String + } + "#, + "./", + ).unwrap()); + + let req = crate::services::connect::Request::builder() + .service_name("subgraph_Query_c_0".into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &schema, + r#" + query( + $var1: Int, $var2: Boolean, $var3: Float, $var4: ID, $var5: JSON, $var6: [String], $var7: String + ) { + c(var1: $var1, var2: $var2, var3: $var3, var4: $var4, var5: $var5, var6: $var6, var7: $var7) + c2: c( + var1: 1, + var2: true, + var3: 0.9, + var4: "123", + var5: { a: 42 }, + var6: ["item"], + var7: null + ) + } + "#.to_string(), + "./", + ) + .unwrap(), + ) + ) + .variables(Variables { + variables: serde_json_bytes::json!({ + "var1": 1, "var2": true, "var3": 0.9, + "var4": "123", "var5": { "a": 42 }, "var6": ["item"], + "var7": null + }) + .as_object() + .unwrap() + .clone(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .build(); + + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(c), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("$.data").unwrap(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + assert_debug_snapshot!(super::root_fields(Arc::new(connector), &req), @r#" + Ok( + [ + RootField { + name: "c", + selection: "$.data", + inputs: RequestInputs { + args: {"var1":1,"var2":true,"var3":0.9,"var4":"123","var5":{"a":42},"var6":["item"],"var7":null}, + this: {}, + batch: [] + }, + }, + RootField { + name: "c2", + selection: "$.data", + inputs: RequestInputs { + args: {"var1":1,"var2":true,"var3":0.9,"var4":"123","var5":{"a":42},"var6":["item"],"var7":null}, + this: {}, + batch: [] + }, + }, + ], + ) + "#); + } + + #[test] + fn entities_from_request_entity() { + let partial_sdl = r#" + type Query { + entity(id: ID!): Entity + } + + type Entity { + field: String + } + "#; + + let subgraph_schema = Arc::new( + Schema::parse_and_validate( + format!( + r#"{partial_sdl} + extend type Query {{ + _entities(representations: [_Any!]!): _Entity + }} + scalar _Any + union _Entity = Entity + "# + ), + "./", + ) + .unwrap(), + ); + + let req = crate::services::connect::Request::builder() + .service_name("subgraph_Query_entity_0".into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &subgraph_schema, + r#" + query($representations: [_Any!]!) { + _entities(representations: $representations) { + __typename + ... on Entity { + field + alias: field + } + } + } + "# + .to_string(), + "./", + ) + .unwrap(), + )) + .variables(Variables { + variables: serde_json_bytes::json!({ + "representations": [ + { "__typename": "Entity", "id": "1" }, + { "__typename": "Entity", "id": "2" }, + ] + }) + .as_object() + .unwrap() + .clone(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .build(); + + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(entity), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("field").unwrap(), + entity_resolver: Some(super::EntityResolver::Explicit), + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + assert_debug_snapshot!(super::entities_from_request(Arc::new(connector), &req).unwrap(), @r#" + [ + Entity { + index: 0, + selection: "field\nalias: field", + inputs: RequestInputs { + args: {"__typename":"Entity","id":"1"}, + this: {}, + batch: [] + }, + }, + Entity { + index: 1, + selection: "field\nalias: field", + inputs: RequestInputs { + args: {"__typename":"Entity","id":"2"}, + this: {}, + batch: [] + }, + }, + ] + "#); + } + + #[test] + fn entities_from_request_entity_with_fragment() { + let partial_sdl = r#" + type Query { + entity(id: ID!): Entity + } + + type Entity { + field: String + } + "#; + + let subgraph_schema = Arc::new( + Schema::parse_and_validate( + format!( + r#"{partial_sdl} + extend type Query {{ + _entities(representations: [_Any!]!): _Entity + }} + scalar _Any + union _Entity = Entity + "# + ), + "./", + ) + .unwrap(), + ); + + let req = crate::services::connect::Request::builder() + .service_name("subgraph_Query_entity_0".into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &subgraph_schema, + r#" + query($representations: [_Any!]!) { + _entities(representations: $representations) { + ... _generated_Entity + } + } + fragment _generated_Entity on Entity { + __typename + field + alias: field + } + "# + .to_string(), + "./", + ) + .unwrap(), + )) + .variables(Variables { + variables: serde_json_bytes::json!({ + "representations": [ + { "__typename": "Entity", "id": "1" }, + { "__typename": "Entity", "id": "2" }, + ] + }) + .as_object() + .unwrap() + .clone(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .build(); + + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(entity), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("field").unwrap(), + entity_resolver: Some(super::EntityResolver::Explicit), + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + assert_debug_snapshot!(super::entities_from_request(Arc::new(connector), &req).unwrap(), @r#" + [ + Entity { + index: 0, + selection: "field\nalias: field", + inputs: RequestInputs { + args: {"__typename":"Entity","id":"1"}, + this: {}, + batch: [] + }, + }, + Entity { + index: 1, + selection: "field\nalias: field", + inputs: RequestInputs { + args: {"__typename":"Entity","id":"2"}, + this: {}, + batch: [] + }, + }, + ] + "#); + } + + #[test] + fn entities_from_request_root_field() { + let partial_sdl = r#" + type Query { + entity(id: ID!): Entity + } + + type Entity { + field: T + } + + type T { + field: String + } + "#; + let schema = Arc::new(Schema::parse_and_validate(partial_sdl, "./").unwrap()); + + let req = crate::services::connect::Request::builder() + .service_name("subgraph_Query_entity_0".into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &schema, + r#" + query($a: ID!, $b: ID!) { + a: entity(id: $a) { field { field } } + b: entity(id: $b) { field { alias: field } } + } + "# + .to_string(), + "./", + ) + .unwrap(), + )) + .variables(Variables { + variables: serde_json_bytes::json!({ + "a": "1", + "b": "2" + }) + .as_object() + .unwrap() + .clone(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .build(); + + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(entity), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("field { field }").unwrap(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + assert_debug_snapshot!(super::entities_from_request(Arc::new(connector), &req).unwrap(), @r#" + [ + RootField { + name: "a", + selection: "field {\n field\n}", + inputs: RequestInputs { + args: {"id":"1"}, + this: {}, + batch: [] + }, + }, + RootField { + name: "b", + selection: "field {\n alias: field\n}", + inputs: RequestInputs { + args: {"id":"2"}, + this: {}, + batch: [] + }, + }, + ] + "#); + } + + #[test] + fn entities_with_fields_from_request() { + let partial_sdl = r#" + type Query { _: String } # just to make it valid + + type Entity { # @key(fields: "id") + id: ID! + field(foo: String): T + } + + type T { + selected: String + } + "#; + + let subgraph_schema = Arc::new( + Schema::parse_and_validate( + format!( + r#"{partial_sdl} + extend type Query {{ + _entities(representations: [_Any!]!): _Entity + }} + scalar _Any + union _Entity = Entity + "# + ), + "./", + ) + .unwrap(), + ); + + let req = crate::services::connect::Request::builder() + .service_name("subgraph_Entity_field_0".into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &subgraph_schema, + r#" + query($representations: [_Any!]!, $bye: String) { + _entities(representations: $representations) { + __typename + ... on Entity { + field(foo: "hi") { selected } + alias: field(foo: $bye) { selected } + } + } + } + "# + .to_string(), + "./", + ) + .unwrap(), + )) + .variables(Variables { + variables: serde_json_bytes::json!({ + "representations": [ + { "__typename": "Entity", "id": "1" }, + { "__typename": "Entity", "id": "2" }, + ], + "bye": "bye" + }) + .as_object() + .unwrap() + .clone(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .build(); + + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Entity), + name!(field), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("selected").unwrap(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + assert_debug_snapshot!(super::entities_with_fields_from_request(Arc::new(connector), &req).unwrap(), @r#" + [ + EntityField { + index: 0, + field_name: "field", + typename: Some( + "Entity", + ), + selection: "selected", + inputs: RequestInputs { + args: {"foo":"hi"}, + this: {"__typename":"Entity","id":"1"}, + batch: [] + }, + }, + EntityField { + index: 1, + field_name: "field", + typename: Some( + "Entity", + ), + selection: "selected", + inputs: RequestInputs { + args: {"foo":"hi"}, + this: {"__typename":"Entity","id":"2"}, + batch: [] + }, + }, + EntityField { + index: 0, + field_name: "alias", + typename: Some( + "Entity", + ), + selection: "selected", + inputs: RequestInputs { + args: {"foo":"bye"}, + this: {"__typename":"Entity","id":"1"}, + batch: [] + }, + }, + EntityField { + index: 1, + field_name: "alias", + typename: Some( + "Entity", + ), + selection: "selected", + inputs: RequestInputs { + args: {"foo":"bye"}, + this: {"__typename":"Entity","id":"2"}, + batch: [] + }, + }, + ] + "#); + } + + #[test] + fn entities_with_fields_from_request_with_fragment() { + let partial_sdl = r#" + type Query { _: String } # just to make it valid + + type Entity { # @key(fields: "id") + id: ID! + field(foo: String): T + } + + type T { + selected: String + } + "#; + + let subgraph_schema = Arc::new( + Schema::parse_and_validate( + format!( + r#"{partial_sdl} + extend type Query {{ + _entities(representations: [_Any!]!): _Entity + }} + scalar _Any + union _Entity = Entity + "# + ), + "./", + ) + .unwrap(), + ); + + let req = crate::services::connect::Request::builder() + .service_name("subgraph_Entity_field_0".into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &subgraph_schema, + r#" + query($representations: [_Any!]!, $bye: String) { + _entities(representations: $representations) { + ... _generated_Entity + } + } + fragment _generated_Entity on Entity { + __typename + field(foo: "hi") { selected } + alias: field(foo: $bye) { selected } + } + "# + .to_string(), + "./", + ) + .unwrap(), + )) + .variables(Variables { + variables: serde_json_bytes::json!({ + "representations": [ + { "__typename": "Entity", "id": "1" }, + { "__typename": "Entity", "id": "2" }, + ], + "bye": "bye" + }) + .as_object() + .unwrap() + .clone(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .build(); + + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Entity), + name!(field), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("selected").unwrap(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + assert_debug_snapshot!(super::entities_with_fields_from_request(Arc::new(connector), &req).unwrap(), @r#" + [ + EntityField { + index: 0, + field_name: "field", + typename: Some( + "Entity", + ), + selection: "selected", + inputs: RequestInputs { + args: {"foo":"hi"}, + this: {"__typename":"Entity","id":"1"}, + batch: [] + }, + }, + EntityField { + index: 1, + field_name: "field", + typename: Some( + "Entity", + ), + selection: "selected", + inputs: RequestInputs { + args: {"foo":"hi"}, + this: {"__typename":"Entity","id":"2"}, + batch: [] + }, + }, + EntityField { + index: 0, + field_name: "alias", + typename: Some( + "Entity", + ), + selection: "selected", + inputs: RequestInputs { + args: {"foo":"bye"}, + this: {"__typename":"Entity","id":"1"}, + batch: [] + }, + }, + EntityField { + index: 1, + field_name: "alias", + typename: Some( + "Entity", + ), + selection: "selected", + inputs: RequestInputs { + args: {"foo":"bye"}, + this: {"__typename":"Entity","id":"2"}, + batch: [] + }, + }, + ] + "#); + } + + #[test] + fn entities_with_fields_from_request_interface_object() { + let partial_sdl = r#" + type Query { _: String } # just to make it valid + + type Entity { # @interfaceObject @key(fields: "id") + id: ID! + field(foo: String): T + } + + type T { + selected: String + } + "#; + + let subgraph_schema = Arc::new( + Schema::parse_and_validate( + format!( + r#"{partial_sdl} + extend type Query {{ + _entities(representations: [_Any!]!): _Entity + }} + scalar _Any + union _Entity = Entity + "# + ), + "./", + ) + .unwrap(), + ); + + let req = crate::services::connect::Request::builder() + .service_name("subgraph_Entity_field_0".into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &subgraph_schema, + r#" + query($representations: [_Any!]!, $foo: String) { + _entities(representations: $representations) { + ... on Entity { + field(foo: $foo) { selected } + } + } + } + "# + .to_string(), + "./", + ) + .unwrap(), + )) + .variables(Variables { + variables: serde_json_bytes::json!({ + "representations": [ + { "__typename": "Entity", "id": "1" }, + { "__typename": "Entity", "id": "2" }, + ], + "foo": "bar" + }) + .as_object() + .unwrap() + .clone(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .build(); + + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Entity), + name!(field), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("selected").unwrap(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + assert_debug_snapshot!(super::entities_with_fields_from_request(Arc::new(connector), &req).unwrap(), @r#" + [ + EntityField { + index: 0, + field_name: "field", + typename: None, + selection: "selected", + inputs: RequestInputs { + args: {"foo":"bar"}, + this: {"__typename":"Entity","id":"1"}, + batch: [] + }, + }, + EntityField { + index: 1, + field_name: "field", + typename: None, + selection: "selected", + inputs: RequestInputs { + args: {"foo":"bar"}, + this: {"__typename":"Entity","id":"2"}, + batch: [] + }, + }, + ] + "#); + } + + #[test] + fn batch_entities_from_request() { + let partial_sdl = r#" + type Query { + entity(id: ID!): Entity + } + + type Entity { + id: ID! + field: String + } + "#; + + let subgraph_schema = Arc::new( + Schema::parse_and_validate( + format!( + r#"{partial_sdl} + extend type Query {{ + _entities(representations: [_Any!]!): _Entity + }} + scalar _Any + union _Entity = Entity + "# + ), + "./", + ) + .unwrap(), + ); + + let keys = FieldSet::parse_and_validate(&subgraph_schema, name!(Entity), "id", "").unwrap(); + + let req = crate::services::connect::Request::builder() + .service_name("subgraph_Entity_0".into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &subgraph_schema, + r#" + query($representations: [_Any!]!) { + _entities(representations: $representations) { + __typename + ... on Entity { + field + alias: field + } + } + } + "# + .to_string(), + "./", + ) + .unwrap(), + )) + .variables(Variables { + variables: serde_json_bytes::json!({ + "representations": [ + { "__typename": "Entity", "id": "1" }, + { "__typename": "Entity", "id": "2" }, + ] + }) + .as_object() + .unwrap() + .clone(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .and_keys(Some(keys)) + .build(); + + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new_on_object("subgraph_name".into(), None, name!(Entity), None, 0), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("id field").unwrap(), + entity_resolver: Some(super::EntityResolver::TypeBatch), + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + assert_debug_snapshot!(super::batch_entities_from_request(Arc::new(connector), &req).unwrap(), @r###" + [ + BatchEntity { + selection: "id\nfield\nalias: field", + key: "id", + inputs: RequestInputs { + args: {}, + this: {}, + batch: [{"__typename":"Entity","id":"1"},{"__typename":"Entity","id":"2"}] + }, + }, + ] + "###); + } + + #[test] + fn batch_entities_from_request_within_max_size() { + let partial_sdl = r#" + type Query { + entity(id: ID!): Entity + } + + type Entity { + id: ID! + field: String + } + "#; + + let subgraph_schema = Arc::new( + Schema::parse_and_validate( + format!( + r#"{partial_sdl} + extend type Query {{ + _entities(representations: [_Any!]!): _Entity + }} + scalar _Any + union _Entity = Entity + "# + ), + "./", + ) + .unwrap(), + ); + + let keys = FieldSet::parse_and_validate(&subgraph_schema, name!(Entity), "id", "").unwrap(); + + let req = crate::services::connect::Request::builder() + .service_name("subgraph_Entity_0".into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &subgraph_schema, + r#" + query($representations: [_Any!]!) { + _entities(representations: $representations) { + __typename + ... on Entity { + field + alias: field + } + } + } + "# + .to_string(), + "./", + ) + .unwrap(), + )) + .variables(Variables { + variables: serde_json_bytes::json!({ + "representations": [ + { "__typename": "Entity", "id": "1" }, + { "__typename": "Entity", "id": "2" }, + ] + }) + .as_object() + .unwrap() + .clone(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .and_keys(Some(keys)) + .build(); + + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new_on_object("subgraph_name".into(), None, name!(Entity), None, 0), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("id field").unwrap(), + entity_resolver: Some(super::EntityResolver::TypeBatch), + config: Default::default(), + max_requests: None, + batch_settings: Some(ConnectBatchArguments { max_size: Some(10) }), + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + assert_debug_snapshot!(super::batch_entities_from_request(Arc::new(connector), &req).unwrap(), @r###" + [ + BatchEntity { + selection: "id\nfield\nalias: field", + key: "id", + inputs: RequestInputs { + args: {}, + this: {}, + batch: [{"__typename":"Entity","id":"1"},{"__typename":"Entity","id":"2"}] + }, + }, + ] + "###); + } + + #[test] + fn batch_entities_from_request_above_max_size() { + let partial_sdl = r#" + type Query { + entity(id: ID!): Entity + } + + type Entity { + id: ID! + field: String + } + "#; + + let subgraph_schema = Arc::new( + Schema::parse_and_validate( + format!( + r#"{partial_sdl} + extend type Query {{ + _entities(representations: [_Any!]!): _Entity + }} + scalar _Any + union _Entity = Entity + "# + ), + "./", + ) + .unwrap(), + ); + + let keys = FieldSet::parse_and_validate(&subgraph_schema, name!(Entity), "id", "").unwrap(); + + let req = crate::services::connect::Request::builder() + .service_name("subgraph_Entity_0".into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &subgraph_schema, + r#" + query($representations: [_Any!]!) { + _entities(representations: $representations) { + __typename + ... on Entity { + field + alias: field + } + } + } + "# + .to_string(), + "./", + ) + .unwrap(), + )) + .variables(Variables { + variables: serde_json_bytes::json!({ + "representations": [ + { "__typename": "Entity", "id": "1" }, + { "__typename": "Entity", "id": "2" }, + { "__typename": "Entity", "id": "3" }, + { "__typename": "Entity", "id": "4" }, + { "__typename": "Entity", "id": "5" }, + { "__typename": "Entity", "id": "6" }, + { "__typename": "Entity", "id": "7" }, + ] + }) + .as_object() + .unwrap() + .clone(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .and_keys(Some(keys)) + .build(); + + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new_on_object("subgraph_name".into(), None, name!(Entity), None, 0), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("id field").unwrap(), + entity_resolver: Some(super::EntityResolver::TypeBatch), + config: Default::default(), + max_requests: None, + batch_settings: Some(ConnectBatchArguments { max_size: Some(5) }), + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + assert_debug_snapshot!(super::batch_entities_from_request(Arc::new(connector), &req).unwrap(), @r###" + [ + BatchEntity { + selection: "id\nfield\nalias: field", + key: "id", + inputs: RequestInputs { + args: {}, + this: {}, + batch: [{"__typename":"Entity","id":"1"},{"__typename":"Entity","id":"2"},{"__typename":"Entity","id":"3"},{"__typename":"Entity","id":"4"},{"__typename":"Entity","id":"5"}] + }, + }, + BatchEntity { + selection: "id\nfield\nalias: field", + key: "id", + inputs: RequestInputs { + args: {}, + this: {}, + batch: [{"__typename":"Entity","id":"6"},{"__typename":"Entity","id":"7"}] + }, + }, + ] + "###); + } + + #[test] + fn entities_from_request_on_type() { + let partial_sdl = r#" + type Query { + entity(id: ID!): Entity + } + + type Entity { + id: ID! + field: String + } + "#; + + let subgraph_schema = Arc::new( + Schema::parse_and_validate( + format!( + r#"{partial_sdl} + extend type Query {{ + _entities(representations: [_Any!]!): _Entity + }} + scalar _Any + union _Entity = Entity + "# + ), + "./", + ) + .unwrap(), + ); + + let keys = FieldSet::parse_and_validate(&subgraph_schema, name!(Entity), "id", "").unwrap(); + + let req = crate::services::connect::Request::builder() + .service_name("subgraph_Entity_0".into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &subgraph_schema, + r#" + query($representations: [_Any!]!) { + _entities(representations: $representations) { + __typename + ... on Entity { + field + alias: field + } + } + } + "# + .to_string(), + "./", + ) + .unwrap(), + )) + .variables(Variables { + variables: serde_json_bytes::json!({ + "representations": [ + { "__typename": "Entity", "id": "1" }, + { "__typename": "Entity", "id": "2" }, + ] + }) + .as_object() + .unwrap() + .clone(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .and_keys(Some(keys)) + .build(); + + let connector = Connector { + spec: DEFAULT_CONNECT_SPEC, + id: ConnectId::new_on_object("subgraph_name".into(), None, name!(Entity), None, 0), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: StringTemplate::parse_with_spec( + "/path?id={$this.id}", + DEFAULT_CONNECT_SPEC, + ) + .unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse_with_spec("id field", DEFAULT_CONNECT_SPEC).unwrap(), + entity_resolver: Some(super::EntityResolver::TypeSingle), + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + assert_debug_snapshot!(super::entities_from_request(Arc::new(connector), &req).unwrap(), @r#" + [ + Entity { + index: 0, + selection: "field\nalias: field", + inputs: RequestInputs { + args: {}, + this: {"__typename":"Entity","id":"1"}, + batch: [] + }, + }, + Entity { + index: 1, + selection: "field\nalias: field", + inputs: RequestInputs { + args: {}, + this: {"__typename":"Entity","id":"2"}, + batch: [] + }, + }, + ] + "#); + } + + #[test] + fn make_requests() { + let schema = Schema::parse_and_validate("type Query { hello: String }", "./").unwrap(); + let service_name = String::from("subgraph_Query_a_0"); + let req = crate::services::connect::Request::builder() + .service_name(service_name.clone().into()) + .context(Context::default()) + .operation(Arc::new( + ExecutableDocument::parse_and_validate( + &schema, + "query { a: hello }".to_string(), + "./", + ) + .unwrap(), + )) + .variables(Variables { + variables: Default::default(), + inverted_paths: Default::default(), + contextual_arguments: Default::default(), + }) + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap(), + )) + .build(); + + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(users), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("$.data").unwrap(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + + let requests: Vec<_> = + super::make_requests(req, &Context::default(), Arc::new(connector), &None) + .unwrap() + .into_iter() + .map(|req| { + let TransportRequest::Http(http_request) = req.transport_request; + let (parts, _body) = http_request.inner.into_parts(); + let new_req = http::Request::from_parts( + parts, + http_body_util::Empty::::new(), + ); + (new_req, req.key, http_request.debug) + }) + .collect(); + + assert_debug_snapshot!(requests, @r#" + [ + ( + Request { + method: GET, + uri: http://localhost/api/path, + version: HTTP/1.1, + headers: {}, + body: Empty, + }, + RootField { + name: "a", + selection: "$.data", + inputs: RequestInputs { + args: {}, + this: {}, + batch: [] + }, + }, + ( + None, + [], + ), + ), + ] + "#); + } +} + +mod graphql_utils; diff --git a/apollo-router/src/plugins/connectors/make_requests/graphql_utils.rs b/apollo-router/src/plugins/connectors/make_requests/graphql_utils.rs new file mode 100644 index 0000000000..432d31689a --- /dev/null +++ b/apollo-router/src/plugins/connectors/make_requests/graphql_utils.rs @@ -0,0 +1,145 @@ +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Node; +use apollo_compiler::executable::Field; +use apollo_compiler::executable::Operation; +use apollo_compiler::executable::Selection; +use apollo_compiler::schema::Value; +use serde_json::Number; +use serde_json_bytes::ByteString; +use serde_json_bytes::Map; +use serde_json_bytes::Value as JSONValue; +use tower::BoxError; + +use super::ENTITIES; +use super::MakeRequestError; + +pub(super) fn field_arguments_map( + field: &Node, + variables: &Map, +) -> Result, BoxError> { + let mut arguments = Map::new(); + + for argument in field.arguments.iter() { + arguments.insert( + argument.name.as_str(), + argument_value_to_json(&argument.value, variables)?, + ); + } + + for argument_def in field.definition.arguments.iter() { + if let Some(value) = argument_def.default_value.as_ref() + && !arguments.contains_key(argument_def.name.as_str()) + { + arguments.insert( + argument_def.name.as_str(), + argument_value_to_json(value, variables).map_err(|err| { + format!( + "failed to convert default value on {}({}:) to json: {}", + field.definition.name, argument_def.name, err + ) + })?, + ); + } + } + + Ok(arguments) +} + +pub(super) fn argument_value_to_json( + value: &apollo_compiler::ast::Value, + variables: &Map, +) -> Result { + match value { + Value::Null => Ok(JSONValue::Null), + Value::Enum(e) => Ok(JSONValue::String(e.as_str().into())), + Value::Variable(name) => variables.get(name.as_str()).cloned().ok_or_else(|| { + BoxError::from(format!( + "variable {name} used in operation but not defined in variables" + )) + }), + Value::String(s) => Ok(JSONValue::String(s.as_str().into())), + Value::Float(f) => Ok(JSONValue::Number( + Number::from_f64( + f.try_to_f64() + .map_err(|_| BoxError::from("Failed to parse float"))?, + ) + .ok_or_else(|| BoxError::from("Failed to parse float"))?, + )), + Value::Int(i) => Ok(JSONValue::Number(Number::from( + i.try_to_i32().map_err(|_| "Failed to parse int")?, + ))), + Value::Boolean(b) => Ok(JSONValue::Bool(*b)), + Value::List(l) => Ok(JSONValue::Array( + l.iter() + .map(|v| argument_value_to_json(v, variables)) + .collect::, _>>()?, + )), + Value::Object(o) => Ok(JSONValue::Object( + o.iter() + .map(|(k, v)| argument_value_to_json(v, variables).map(|v| (k.as_str().into(), v))) + .collect::, _>>()?, + )), + } +} + +/// Looks for _entities near the root of the operation. Also looks for +/// __typename within the _entities selection — if it was selected, then we +/// don't have a interfaceObject query. +pub(super) fn get_entity_fields<'a>( + document: &'a ExecutableDocument, + op: &'a Node, +) -> Result<(&'a Node, bool), MakeRequestError> { + use MakeRequestError::*; + + let root_field = op + .selection_set + .selections + .iter() + .find_map(|s| match s { + Selection::Field(f) if f.name == ENTITIES => Some(f), + _ => None, + }) + .ok_or_else(|| InvalidOperation("missing entities root field".into()))?; + + let mut typename_requested = false; + + for selection in root_field.selection_set.selections.iter() { + match selection { + Selection::Field(f) => { + if f.name == "__typename" { + typename_requested = true; + } + } + Selection::FragmentSpread(f) => { + let fragment = document + .fragments + .get(f.fragment_name.as_str()) + .ok_or_else(|| InvalidOperation("missing fragment".into()))?; + for selection in fragment.selection_set.selections.iter() { + match selection { + Selection::Field(f) => { + if f.name == "__typename" { + typename_requested = true; + } + } + Selection::FragmentSpread(_) | Selection::InlineFragment(_) => {} + } + } + } + Selection::InlineFragment(f) => { + for selection in f.selection_set.selections.iter() { + match selection { + Selection::Field(f) => { + if f.name == "__typename" { + typename_requested = true; + } + } + Selection::FragmentSpread(_) | Selection::InlineFragment(_) => {} + } + } + } + } + } + + Ok((root_field, typename_requested)) +} diff --git a/apollo-router/src/plugins/connectors/mod.rs b/apollo-router/src/plugins/connectors/mod.rs new file mode 100644 index 0000000000..d1c928a1bf --- /dev/null +++ b/apollo-router/src/plugins/connectors/mod.rs @@ -0,0 +1,22 @@ +pub(crate) mod configuration; +pub(crate) mod handle_responses; +pub(crate) mod incompatible; +pub(crate) mod make_requests; +pub(crate) mod plugin; +pub(crate) mod query_plans; +pub(crate) mod request_limit; +pub(crate) mod tracing; + +#[cfg(test)] +pub(crate) mod tests; + +use apollo_federation::connectors::runtime::inputs::ContextReader; + +impl ContextReader for &crate::Context { + fn get_key(&self, key: &str) -> Option { + match self.get::<&str, serde_json_bytes::Value>(key) { + Ok(Some(value)) => Some(value.clone()), + _ => None, + } + } +} diff --git a/apollo-router/src/plugins/connectors/plugin.rs b/apollo-router/src/plugins/connectors/plugin.rs new file mode 100644 index 0000000000..74e8e6de50 --- /dev/null +++ b/apollo-router/src/plugins/connectors/plugin.rs @@ -0,0 +1,181 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +use apollo_federation::connectors::runtime::debug::ConnectorContext; +use futures::StreamExt; +use http::HeaderValue; +use itertools::Itertools; +use parking_lot::Mutex; +use serde_json_bytes::json; +use tower::BoxError; +use tower::ServiceBuilder; +use tower::ServiceExt as TowerServiceExt; + +use super::query_plans::get_connectors; +use crate::layers::ServiceExt; +use crate::plugin::Plugin; +use crate::plugin::PluginInit; +use crate::plugins::connectors::configuration::ConnectorsConfig; +use crate::plugins::connectors::request_limit::RequestLimits; +use crate::register_plugin; +use crate::services::connector_service::ConnectorSourceRef; +use crate::services::execution; +use crate::services::supergraph; + +const CONNECTORS_DEBUG_HEADER_NAME: &str = "Apollo-Connectors-Debugging"; +const CONNECTORS_DEBUG_ENV: &str = "APOLLO_CONNECTORS_DEBUGGING"; +const CONNECTORS_DEBUG_KEY: &str = "apolloConnectorsDebugging"; +const CONNECTORS_MAX_REQUESTS_ENV: &str = "APOLLO_CONNECTORS_MAX_REQUESTS_PER_OPERATION"; +const CONNECTOR_SOURCES_IN_QUERY_PLAN: &str = "apollo_connectors::sources_in_query_plan"; + +static LAST_DEBUG_ENABLED_VALUE: AtomicBool = AtomicBool::new(false); + +#[derive(Debug, Clone)] +struct Connectors { + debug_extensions: bool, + max_requests: Option, + expose_sources_in_context: bool, +} + +#[async_trait::async_trait] +impl Plugin for Connectors { + type Config = ConnectorsConfig; + + async fn new(init: PluginInit) -> Result { + let debug_extensions = init.config.debug_extensions + || std::env::var(CONNECTORS_DEBUG_ENV).as_deref() == Ok("true"); + + let last_value = LAST_DEBUG_ENABLED_VALUE.load(Ordering::Relaxed); + let swap_result = LAST_DEBUG_ENABLED_VALUE.compare_exchange( + last_value, + debug_extensions, + Ordering::Relaxed, + Ordering::Relaxed, + ); + // Ok means we swapped value, inner value is old value. Ok(false) means we went false -> true + if matches!(swap_result, Ok(false)) { + tracing::warn!( + "Connector debugging is enabled, this may expose sensitive information." + ); + } + + let max_requests = init + .config + .max_requests_per_operation_per_source + .or(std::env::var(CONNECTORS_MAX_REQUESTS_ENV) + .ok() + .and_then(|v| v.parse().ok())); + + Ok(Connectors { + debug_extensions, + max_requests, + expose_sources_in_context: init.config.expose_sources_in_context, + }) + } + + fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { + let conf_enabled = self.debug_extensions; + let max_requests = self.max_requests; + service + .map_future_with_request_data( + move |req: &supergraph::Request| { + let is_debug_enabled = conf_enabled + && req + .supergraph_request + .headers() + .get(CONNECTORS_DEBUG_HEADER_NAME) + == Some(&HeaderValue::from_static("true")); + + req.context.extensions().with_lock(|lock| { + lock.insert::>(Arc::new(RequestLimits::new( + max_requests, + ))); + if is_debug_enabled { + lock.insert::>>(Arc::new(Mutex::new( + ConnectorContext::default(), + ))); + } + }); + + is_debug_enabled + }, + move |is_debug_enabled: bool, f| async move { + let mut res: supergraph::ServiceResult = f.await; + + res = match res { + Ok(mut res) => { + res.context.extensions().with_lock(|lock| { + if let Some(limits) = lock.remove::>() { + limits.log(); + } + }); + if is_debug_enabled + && let Some(debug) = res.context.extensions().with_lock(|lock| { + lock.get::>>().cloned() + }) + { + let (parts, stream) = res.response.into_parts(); + + let stream = stream.map(move |mut chunk| { + let serialized = { &debug.lock().clone().serialize() }; + chunk.extensions.insert( + CONNECTORS_DEBUG_KEY, + json!({"version": "2", "data": serialized }), + ); + chunk + }); + + res.response = http::Response::from_parts(parts, Box::pin(stream)); + } + + Ok(res) + } + Err(err) => Err(err), + }; + + res + }, + ) + .boxed() + } + + fn execution_service(&self, service: execution::BoxService) -> execution::BoxService { + if !self.expose_sources_in_context { + return service; + } + + ServiceBuilder::new() + .map_request(|req: execution::Request| { + let Some(connectors) = get_connectors(&req.context) else { + return req; + }; + + // add [{"subgraph_name": "", "source_name": ""}] to the context + // for connectors with sources in the query plan. + let list = req + .query_plan + .root + .service_usage_set() + .into_iter() + .flat_map(|service_name| { + connectors + .get(service_name) + .map(|connector| ConnectorSourceRef::try_from(connector).ok()) + }) + .unique() + .collect_vec(); + + req.context + .insert(CONNECTOR_SOURCES_IN_QUERY_PLAN, list) + .unwrap(); + req + }) + .service(service) + .boxed() + } +} + +pub(crate) const PLUGIN_NAME: &str = "connectors"; + +register_plugin!("apollo", PLUGIN_NAME, Connectors); diff --git a/apollo-router/src/plugins/connectors/query_plans.rs b/apollo-router/src/plugins/connectors/query_plans.rs new file mode 100644 index 0000000000..a1d721c0f2 --- /dev/null +++ b/apollo-router/src/plugins/connectors/query_plans.rs @@ -0,0 +1,120 @@ +use std::sync::Arc; + +use apollo_federation::connectors::Connector; +use indexmap::IndexMap; + +use crate::Context; +use crate::query_planner::PlanNode; + +type ConnectorsByServiceName = Arc, Connector>>; + +pub(crate) fn store_connectors( + context: &Context, + connectors_by_service_name: Arc, Connector>>, +) { + context + .extensions() + .with_lock(|lock| lock.insert::(connectors_by_service_name)); +} + +pub(crate) fn get_connectors(context: &Context) -> Option { + context + .extensions() + .with_lock(|lock| lock.get::().cloned()) +} + +type ConnectorLabels = Arc, String>>; + +pub(crate) fn store_connectors_labels(context: &Context, labels_by_service_name: ConnectorLabels) { + context + .extensions() + .with_lock(|lock| lock.insert(labels_by_service_name)); +} + +pub(crate) fn replace_connector_service_names_text( + text: Option>, + context: &Context, +) -> Option> { + let replacements = context + .extensions() + .with_lock(|lock| lock.get::().cloned()); + if let Some(replacements) = replacements { + text.as_ref().map(|text| { + let mut text = text.to_string(); + for (service_name, label) in replacements.iter() { + text = text.replace(&**service_name, label.as_ref()); + } + Arc::new(text) + }) + } else { + text + } +} + +pub(crate) fn replace_connector_service_names( + plan: Arc, + context: &Context, +) -> Arc { + let replacements = context + .extensions() + .with_lock(|lock| lock.get::().cloned()); + + return if let Some(replacements) = replacements { + let mut plan = plan.clone(); + recurse(Arc::make_mut(&mut plan), &replacements); + plan + } else { + plan + }; + + fn recurse(plan: &mut PlanNode, replacements: &IndexMap, String>) { + match plan { + PlanNode::Sequence { nodes } => { + for node in nodes { + recurse(node, replacements); + } + } + PlanNode::Parallel { nodes } => { + for node in nodes { + recurse(node, replacements); + } + } + PlanNode::Fetch(node) => { + if let Some(service_name) = replacements.get(&node.service_name) { + node.service_name = service_name.clone().into(); + } + } + PlanNode::Flatten(flatten) => { + recurse(&mut flatten.node, replacements); + } + PlanNode::Defer { primary, deferred } => { + if let Some(primary) = primary.node.as_mut() { + recurse(primary, replacements); + } + for deferred in deferred { + if let Some(node) = &mut deferred.node { + recurse(Arc::make_mut(node), replacements); + } + } + } + PlanNode::Subscription { primary: _, rest } => { + // ignoring subscriptions because connectors are not supported + if let Some(node) = rest { + recurse(node, replacements); + } + } + PlanNode::Condition { + if_clause, + else_clause, + .. + } => { + if let Some(if_clause) = if_clause.as_mut() { + recurse(if_clause, replacements); + } + if let Some(else_clause) = else_clause.as_mut() { + recurse(else_clause, replacements); + } + } + } + } +} diff --git a/apollo-router/src/plugins/connectors/request_limit.rs b/apollo-router/src/plugins/connectors/request_limit.rs new file mode 100644 index 0000000000..3a2c62139c --- /dev/null +++ b/apollo-router/src/plugins/connectors/request_limit.rs @@ -0,0 +1,116 @@ +//! Limits on Connectors requests + +use std::collections::HashMap; +use std::fmt::Display; +use std::fmt::Formatter; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use apollo_federation::connectors::Connector; +use apollo_federation::connectors::SourceName; +use parking_lot::Mutex; + +/// Key to access request limits for a connector +#[derive(Eq, Hash, PartialEq)] +pub(crate) enum RequestLimitKey { + /// A key to access the request limit for a connector referencing a source directive + SourceName(SourceName), + + /// A key to access the request limit for a connector without a corresponding source directive + ConnectorLabel(String), +} + +impl Display for RequestLimitKey { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + RequestLimitKey::SourceName(source_name) => { + write!(f, "connector source {source_name}") + } + RequestLimitKey::ConnectorLabel(connector_label) => { + write!(f, "connector {connector_label}") + } + } + } +} + +impl From<&Connector> for RequestLimitKey { + fn from(value: &Connector) -> Self { + value + .id + .source_name + .as_ref() + .map(|source_name| RequestLimitKey::SourceName(source_name.clone())) + .unwrap_or(RequestLimitKey::ConnectorLabel(value.label.0.clone())) + } +} + +/// Tracks a request limit for a connector +pub(crate) struct RequestLimit { + max_requests: usize, + total_requests: AtomicUsize, +} + +impl RequestLimit { + pub(crate) fn new(max_requests: usize) -> Self { + Self { + max_requests, + total_requests: AtomicUsize::new(0), + } + } + + pub(crate) fn allow(&self) -> bool { + self.total_requests.fetch_add(1, Ordering::Relaxed) < self.max_requests + } +} + +/// Tracks the request limits for an operation +pub(crate) struct RequestLimits { + default_max_requests: Option, + limits: Mutex>>, +} + +impl RequestLimits { + pub(crate) fn new(default_max_requests: Option) -> Self { + Self { + default_max_requests, + limits: Mutex::new(HashMap::new()), + } + } + + #[allow(clippy::unwrap_used)] // Unwrap checked by invariant + pub(crate) fn get( + &self, + key: RequestLimitKey, + limit: Option, + ) -> Option> { + if limit.is_none() && self.default_max_requests.is_none() { + return None; + } + Some( + self.limits + .lock() + .entry(key) + .or_insert_with(|| { + Arc::new(RequestLimit::new( + limit.or(self.default_max_requests).unwrap(), + )) + }) // unwrap ok, invariant checked above + .clone(), + ) + } + + pub(crate) fn log(&self) { + self.limits.lock().iter().for_each(|(key, limit)| { + let total = limit.total_requests.load(Ordering::Relaxed); + if total > limit.max_requests { + tracing::warn!( + "Request limit exceeded for {}: max: {}, attempted: {}", + key, + limit.max_requests, + total, + ); + } + }); + } +} diff --git a/apollo-router/src/plugins/connectors/testdata/README.md b/apollo-router/src/plugins/connectors/testdata/README.md new file mode 100644 index 0000000000..bd6b8e20f3 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/README.md @@ -0,0 +1,22 @@ +# Connectors runtime tests + +Each schema in this directory is used to test the runtime behavior of connectors in the sibling `test` directory. + +The runtime test require an already composed "supergraph SDL", which is the ouput of `rover supergraph compose`. Each +schema is defined using a supergraph config `.yaml` file in this directory. + +## Regenerating + +The `regenerate.sh` script will convert each of these `.yaml` files into a composed `.graphql` file which can be +executed. + +### Options: + +- Pass a specific `.yaml` file as an argument to regenerate only that file. +- Set the `FEDERATION_VERSION` environment variable to specify the federation version to use. + +> [!TIP] +> If you need to compose with an unreleased version of composition, you can add any `supergraph` binary to +> `~/.rover/bin` and use the suffix of that binary as a version. For example, if you have `supergraph-v2.10.0-blah` in +> that +> bin folder, you can set `FEDERATION_VERSION="2.10.0-blah"` to use that version. diff --git a/apollo-router/src/plugins/connectors/testdata/batch-max-size.graphql b/apollo-router/src/plugins/connectors/testdata/batch-max-size.graphql new file mode 100644 index 0000000000..6c6d8929b0 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/batch-max-size.graphql @@ -0,0 +1,109 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive( + graphs: [CONNECTORS] + name: "link" + args: { url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"] } + ) + @join__directive( + graphs: [CONNECTORS] + name: "source" + args: { name: "json", http: { baseURL: "http://localhost:4001/api" } } + ) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String + contextArguments: [join__ContextArgument!] +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query @join__type(graph: CONNECTORS) { + users: [User!]! + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { source: "json", http: { GET: "/users" }, selection: "id" } + ) +} + +type User + @join__type(graph: CONNECTORS) + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "json" + http: { POST: "/users-batch", body: "ids: $batch.id" } + batch: { maxSize: 5 } + selection: "id name username" + } + ) { + id: ID! + name: String + username: String +} diff --git a/apollo-router/src/plugins/connectors/testdata/batch-max-size.yaml b/apollo-router/src/plugins/connectors/testdata/batch-max-size.yaml new file mode 100644 index 0000000000..2c4ece3a97 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/batch-max-size.yaml @@ -0,0 +1,26 @@ +subgraphs: + connectors: + routing_url: http://none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11") + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "json", http: { baseURL: "http://localhost:4001/api" }) + + type Query { + users: [User!]! + @connect(source: "json", http: { GET: "/users" }, selection: "id name username") + } + + type User + @connect(source: "json" + http: { POST: "/users-batch", body: "ids: $$batch.id" } + batch: { maxSize: 5 } + selection: "id name username" + ) + { + id: ID! + name: String + username: String + } diff --git a/apollo-router/src/plugins/connectors/testdata/batch-query.graphql b/apollo-router/src/plugins/connectors/testdata/batch-query.graphql new file mode 100644 index 0000000000..f160ab5046 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/batch-query.graphql @@ -0,0 +1,71 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "http://localhost:4001/api"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + users: [User!]! @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users"}, selection: "id"}) +} + +type User + @join__type(graph: CONNECTORS) + @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/user-details?ids={$batch.id->joinNotNull(',')}"}, selection: "id name username"}) +{ + id: ID! + name: String + username: String +} diff --git a/apollo-router/src/plugins/connectors/testdata/batch-query.yaml b/apollo-router/src/plugins/connectors/testdata/batch-query.yaml new file mode 100644 index 0000000000..21772a0ada --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/batch-query.yaml @@ -0,0 +1,25 @@ +subgraphs: + connectors: + routing_url: http://none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11") + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "json", http: { baseURL: "http://localhost:4001/api" }) + + type Query { + users: [User!]! + @connect(source: "json", http: { GET: "/users" }, selection: "id") + } + + type User + @connect(source: "json" + http: { GET: "/users-details/?ids={$$batch.id->joinNotNull(',')}" } + selection: "id name username" + ) + { + id: ID! + name: String + username: String + } diff --git a/apollo-router/src/plugins/connectors/testdata/batch.graphql b/apollo-router/src/plugins/connectors/testdata/batch.graphql new file mode 100644 index 0000000000..c740647db7 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/batch.graphql @@ -0,0 +1,71 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "http://localhost:4001/api"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + users: [User!]! @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users"}, selection: "id"}) +} + +type User + @join__type(graph: CONNECTORS) + @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {POST: "/users-batch", body: "ids: $batch.id"}, selection: "id name username"}) +{ + id: ID! + name: String + username: String +} diff --git a/apollo-router/src/plugins/connectors/testdata/batch.yaml b/apollo-router/src/plugins/connectors/testdata/batch.yaml new file mode 100644 index 0000000000..b4c8098fdf --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/batch.yaml @@ -0,0 +1,25 @@ +subgraphs: + connectors: + routing_url: http://none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11") + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "json", http: { baseURL: "http://localhost:4001/api" }) + + type Query { + users: [User!]! + @connect(source: "json", http: { GET: "/users" }, selection: "id name username") + } + + type User + @connect(source: "json" + http: { POST: "/users-batch", body: "ids: $$batch.id" } + selection: "id name username" + ) + { + id: ID! + name: String + username: String + } diff --git a/apollo-router/src/plugins/connectors/testdata/connect-on-interface-object.graphql b/apollo-router/src/plugins/connectors/testdata/connect-on-interface-object.graphql new file mode 100644 index 0000000000..72a8b8bccf --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/connect-on-interface-object.graphql @@ -0,0 +1,91 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "http://localhost:4001/api"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type Customer implements User + @join__implements(graph: GRAPHQL, interface: "User") + @join__type(graph: GRAPHQL, key: "id") +{ + id: ID! + name: String + favoriteColor: String @join__field +} + +type Employee implements User + @join__implements(graph: GRAPHQL, interface: "User") + @join__type(graph: GRAPHQL, key: "id") +{ + id: ID! + name: String + favoriteColor: String @join__field +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://none") + GRAPHQL @join__graph(name: "graphql", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) + @join__type(graph: GRAPHQL) +{ + users: [User!]! @join__field(graph: GRAPHQL) +} + +interface User + @join__type(graph: CONNECTORS, key: "id", isInterfaceObject: true) + @join__type(graph: GRAPHQL, key: "id") + @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users/{$this.id}"}, selection: "id favoriteColor"}) +{ + id: ID! + favoriteColor: String @join__field(graph: CONNECTORS) +} diff --git a/apollo-router/src/plugins/connectors/testdata/connect-on-interface-object.yaml b/apollo-router/src/plugins/connectors/testdata/connect-on-interface-object.yaml new file mode 100644 index 0000000000..d232b76c80 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/connect-on-interface-object.yaml @@ -0,0 +1,45 @@ +subgraphs: + graphql: + routing_url: http://none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) + + type Query { + users: [User!]! + } + + interface User @key(fields: "id") { + id: ID! + } + + type Employee implements User @key(fields: "id") { + id: ID! + name: String + } + + type Customer implements User @key(fields: "id") { + id: ID! + name: String + } + + connectors: + routing_url: http://none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key", "@interfaceObject"]) + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "json", http: { baseURL: "http://localhost:4001/api" }) + + type User + @connect(source: "json" + http: { GET: "/users/{$$this.id}" } + selection: "id favoriteColor" + ) + @interfaceObject @key(fields: "id") + { + id: ID! + favoriteColor: String + } diff --git a/apollo-router/src/plugins/connectors/testdata/connect-on-type.graphql b/apollo-router/src/plugins/connectors/testdata/connect-on-type.graphql new file mode 100644 index 0000000000..47eef9f96f --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/connect-on-type.graphql @@ -0,0 +1,71 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "http://localhost:4001/api"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + users: [User!]! @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users"}, selection: "id"}) +} + +type User + @join__type(graph: CONNECTORS) + @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users/{$this.id}"}, selection: "id name username"}) +{ + id: ID! + name: String + username: String +} diff --git a/apollo-router/src/plugins/connectors/testdata/connect-on-type.yaml b/apollo-router/src/plugins/connectors/testdata/connect-on-type.yaml new file mode 100644 index 0000000000..8dcbd252c9 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/connect-on-type.yaml @@ -0,0 +1,25 @@ +subgraphs: + connectors: + routing_url: http://none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11") + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "json", http: { baseURL: "http://localhost:4001/api" }) + + type Query { + users: [User!]! + @connect(source: "json", http: { GET: "/users" }, selection: "id name username") + } + + type User + @connect(source: "json" + http: { GET: "/users/{$$this.id}" } + selection: "id name username" + ) + { + id: ID! + name: String + username: String + } diff --git a/apollo-router/src/plugins/connectors/testdata/connector-without-source.graphql b/apollo-router/src/plugins/connectors/testdata/connector-without-source.graphql new file mode 100644 index 0000000000..18e9e7d267 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/connector-without-source.graphql @@ -0,0 +1,68 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + user(id: ID!): User @join__directive(graphs: [CONNECTORS], name: "connect", args: {http: {GET: "http://localhost/\nusers/\n{$args.id}"}, selection: "id name"}) +} + +type User + @join__type(graph: CONNECTORS) +{ + id: ID! + name: String +} diff --git a/apollo-router/src/plugins/connectors/testdata/connector-without-source.yaml b/apollo-router/src/plugins/connectors/testdata/connector-without-source.yaml new file mode 100644 index 0000000000..063eda2e38 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/connector-without-source.yaml @@ -0,0 +1,28 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10") + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + + type User { + id: ID! + name: String + } + + type Query { + user(id: ID!): User + @connect(http: { + GET: """ + http://localhost/ + users/ + {$$args.id} + """ + }, + selection: "id name") + } diff --git a/apollo-router/src/plugins/connectors/testdata/content-type.graphql b/apollo-router/src/plugins/connectors/testdata/content-type.graphql new file mode 100644 index 0000000000..ab623d011d --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/content-type.graphql @@ -0,0 +1,122 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive( + graphs: [CONNECTORS] + name: "link" + args: { url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"] } + ) + @join__directive( + graphs: [CONNECTORS] + name: "source" + args: { name: "json", http: { baseURL: "http://localhost:4001/api" } } + ) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String + contextArguments: [join__ContextArgument!] +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query @join__type(graph: CONNECTORS) { + literal: String + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "json" + http: { GET: "/literal" } + selection: """ + $("literal test") + """ + } + ) + raw: String + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "json" + http: { GET: "/raw" } + selection: """ + $ + """ + } + ) + users: [User!]! + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { source: "json", http: { GET: "/users" }, selection: "id name username" } + ) +} + +type User @join__type(graph: CONNECTORS) { + id: ID! + name: String + username: String +} diff --git a/apollo-router/src/plugins/connectors/testdata/content-type.yaml b/apollo-router/src/plugins/connectors/testdata/content-type.yaml new file mode 100644 index 0000000000..c8071fa094 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/content-type.yaml @@ -0,0 +1,25 @@ +subgraphs: + connectors: + routing_url: http://none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11") + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "json", http: { baseURL: "http://localhost:4001/api" }) + + type Query { + literal: String + @connect(source: "json", http: { GET: "/literal" }, selection: """$("literal test")""") + raw: String + @connect(source: "json", http: { GET: "/raw" }, selection: """$""") + users: [User!]! + @connect(source: "json", http: { GET: "/users" }, selection: "id name username") + } + + type User + { + id: ID! + name: String + username: String + } diff --git a/apollo-router/src/plugins/connectors/testdata/env-var.graphql b/apollo-router/src/plugins/connectors/testdata/env-var.graphql new file mode 100644 index 0000000000..13be377ed0 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/env-var.graphql @@ -0,0 +1,69 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "http://localhost"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + f: T @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/"}, selection: "greeting: $ fromEnv: $env.CONNECTORS_TESTS_VARIABLES_TEST_ENV_VAR"}) +} + +type T + @join__type(graph: CONNECTORS) +{ + greeting: String! + fromEnv: String! +} diff --git a/apollo-router/src/plugins/connectors/testdata/errors.graphql b/apollo-router/src/plugins/connectors/testdata/errors.graphql new file mode 100644 index 0000000000..c8f36b5bb4 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/errors.graphql @@ -0,0 +1,149 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive( + graphs: [CONNECTORS] + name: "link" + args: { url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"] } + ) + @join__directive( + graphs: [CONNECTORS] + name: "source" + args: { + name: "withconfig" + http: { baseURL: "http://localhost:4001/api" } + errors: { message: "error.message", extensions: "code: error.code\n status: $status\n fromSource: $('a')" } + } + ) + @join__directive( + graphs: [CONNECTORS] + name: "source" + args: { name: "withoutconfig", http: { baseURL: "http://localhost:4001/api" } } + ) + @join__directive( + graphs: [CONNECTORS] + name: "source" + args: { + name: "withpartialconfig" + http: { baseURL: "http://localhost:4001/api" } + errors: { extensions: "code: error.code\n status: $status\n fromSource: $('a')" } + } + ) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String + contextArguments: [join__ContextArgument!] +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query @join__type(graph: CONNECTORS) { + only_source: [User!]! + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { source: "withconfig", http: { GET: "/users" }, selection: "id name username" } + ) + only_connect: [User!]! + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "withoutconfig" + http: { GET: "/users" } + selection: "id name username" + errors: { message: "error.message", extensions: "code: error.code\n status: $status" } + } + ) + both_source_and_connect: [User!]! + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "withconfig" + http: { GET: "/users" } + selection: "id name username" + errors: { message: "error.message", extensions: "code: error.code\n fromConnect: $('b')" } + } + ) + partial_source_and_partial_connect: [User!]! + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "withpartialconfig" + http: { GET: "/users" } + selection: "id name username" + errors: { message: "error.message" } + } + ) +} + +type User @join__type(graph: CONNECTORS) { + id: ID! + name: String + username: String +} diff --git a/apollo-router/src/plugins/connectors/testdata/errors.yaml b/apollo-router/src/plugins/connectors/testdata/errors.yaml new file mode 100644 index 0000000000..96e8fdc467 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/errors.yaml @@ -0,0 +1,29 @@ +subgraphs: + connectors: + routing_url: http://none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11") + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source(name: "withconfig", http: { baseURL: "http://localhost:4001/api" }, errors: { message: "error.message", extensions: "code: error.code status: $status fromSource: $('a')" } ) + @source(name: "withoutconfig", http: { baseURL: "http://localhost:4001/api" }) + @source(name: "withpartialconfig", http: { baseURL: "http://localhost:4001/api" }, errors: { extensions: "code: error.code status: $status fromSource: $('a')" } ) + + type Query { + only_source: [User!]! + @connect(source: "withconfig", http: { GET: "/users" }, selection: "id name username") + only_connect: [User!]! + @connect(source: "withoutconfig", http: { GET: "/users" }, errors: { message: "error.message", extensions: "code: error.code status: $status" }, selection: "id name username") + both_source_and_connect: [User!]! + @connect(source: "withconfig", http: { GET: "/users" }, errors: { message: "error.message", extensions: "code: error.code fromConnect: $('b')" }, selection: "id name username") + partial_source_and_partial_connect: [User!]! + @connect(source: "withpartialconfig", http: { GET: "/users" }, errors: { message: "error.message" }, selection: "id name username") + } + + type User + { + id: ID! + name: String + username: String + } diff --git a/apollo-router/src/plugins/connectors/testdata/form-encoding.graphql b/apollo-router/src/plugins/connectors/testdata/form-encoding.graphql new file mode 100644 index 0000000000..f139fcf2b9 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/form-encoding.graphql @@ -0,0 +1,107 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "http://localhost", headers: [{name: "Content-Type", value: "application/x-www-form-urlencoded"}]}}) +{ + query: Query + mutation: Mutation +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Mutation + @join__type(graph: CONNECTORS) +{ + post(input: PostInput!): Post @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {POST: "/posts", body: "$args.input {\n int\n str\n bool\n enum\n id\n\n intArr\n strArr\n boolArr\n # enumArr\n idArr\n\n obj {\n a\n b\n c\n nested {\n d\n e\n f\n }\n }\n objArr {\n a\n b\n c\n nested {\n d\n e\n f\n }\n }\n}"}, selection: "id"}) +} + +type Post + @join__type(graph: CONNECTORS) +{ + id: ID +} + +input PostChildInput + @join__type(graph: CONNECTORS) +{ + a: Int + b: String + c: Boolean + nested: PostNestedInput +} + +input PostInput + @join__type(graph: CONNECTORS) +{ + int: Int + str: String + bool: Boolean + id: ID + intArr: [Int] + strArr: [String] + boolArr: [Boolean] + idArr: [ID] + obj: PostChildInput + objArr: [PostChildInput] +} + +input PostNestedInput + @join__type(graph: CONNECTORS) +{ + d: Int + e: String + f: Boolean +} + +type Query + @join__type(graph: CONNECTORS) +{ + hello: String @join__directive(graphs: [CONNECTORS], name: "connect", args: {http: {GET: "http://localhost/hello"}, selection: "$"}) +} diff --git a/apollo-router/src/plugins/connectors/testdata/form-encoding.yaml b/apollo-router/src/plugins/connectors/testdata/form-encoding.yaml new file mode 100644 index 0000000000..ccb0168d38 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/form-encoding.yaml @@ -0,0 +1,114 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.10" + import: ["@key", "@external", "@requires", "@shareable"] + ) + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source( + name: "json" + http: { + baseURL: "http://localhost" + headers: [ + { name: "Content-Type" value: "application/x-www-form-urlencoded" } + ] + } + ) + + type Query { + hello: String @connect(http: { GET: "http://localhost/hello" }, selection: "$") + } + + type Mutation { + post(input: PostInput!): Post + @connect( + source: "json" + http: { + POST: "/posts" + body: """ + $$args.input { + int + str + bool + enum + id + + intArr + strArr + boolArr + # enumArr + idArr + + obj { + a + b + c + nested { + d + e + f + } + } + objArr { + a + b + c + nested { + d + e + f + } + } + } + """ + } + selection: "id" + ) + } + + input PostInput { + int: Int + str: String + bool: Boolean + # enum: PostEnum + id: ID + + intArr: [Int] + strArr: [String] + boolArr: [Boolean] + # enumArr: [PostEnum] + idArr: [ID] + + obj: PostChildInput + objArr: [PostChildInput] + } + + input PostChildInput { + a: Int + b: String + c: Boolean + nested: PostNestedInput + } + + input PostNestedInput { + d: Int + e: String + f: Boolean + } + + # enum PostEnum { + # A + # B + # C + # } + + type Post { + id: ID + } \ No newline at end of file diff --git a/apollo-router/src/plugins/connectors/testdata/interface-object.graphql b/apollo-router/src/plugins/connectors/testdata/interface-object.graphql new file mode 100644 index 0000000000..a7364124c6 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/interface-object.graphql @@ -0,0 +1,97 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "http://localhost:4001"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +interface Itf + @join__type(graph: CONNECTORS, key: "id", isInterfaceObject: true) + @join__type(graph: GRAPHQL, key: "id") +{ + id: ID! + c: Int! @join__field(graph: CONNECTORS) + d: Int! @join__field(graph: CONNECTORS) + e: String @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/itfs/{$this.id}/e"}, selection: "$"}) +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") + GRAPHQL @join__graph(name: "graphql", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) + @join__type(graph: GRAPHQL) +{ + itfs: [Itf] @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/itfs"}, selection: "id c"}) + itf(id: ID!): Itf @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/itfs/{$args.id}"}, selection: "id c d", entity: true}) +} + +type T1 implements Itf + @join__implements(graph: GRAPHQL, interface: "Itf") + @join__type(graph: GRAPHQL, key: "id") +{ + id: ID! + a: String + c: Int! @join__field + d: Int! @join__field + e: String @join__field +} + +type T2 implements Itf + @join__implements(graph: GRAPHQL, interface: "Itf") + @join__type(graph: GRAPHQL, key: "id") +{ + id: ID! + b: String + c: Int! @join__field + d: Int! @join__field + e: String @join__field +} diff --git a/apollo-router/src/plugins/connectors/testdata/interface-object.yaml b/apollo-router/src/plugins/connectors/testdata/interface-object.yaml new file mode 100644 index 0000000000..54a17a093a --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/interface-object.yaml @@ -0,0 +1,58 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key", "@interfaceObject"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source(name: "json", http: { baseURL: "http://localhost:4001" }) + + type Query { + itfs: [Itf] + @connect( + source: "json" + http: { GET: "/itfs" } + selection: "id c" + ) + + itf(id: ID!): Itf + @connect( + source: "json" + http: { GET: "/itfs/{$$args.id}" } + selection: "id c d" + entity: true + ) + } + + type Itf @key(fields: "id") @interfaceObject { + id: ID! + c: Int! + d: Int! + e: String + @connect( + source: "json" + http: { GET: "/itfs/{$$this.id}/e" } + selection: "$" + ) + } + graphql: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + + interface Itf @key(fields: "id") { + id: ID! + } + + type T1 implements Itf @key(fields: "id") { + id: ID! + a: String + } + + type T2 implements Itf @key(fields: "id") { + id: ID! + b: String + } diff --git a/apollo-router/src/plugins/connectors/testdata/mutation.graphql b/apollo-router/src/plugins/connectors/testdata/mutation.graphql new file mode 100644 index 0000000000..0bc0e39265 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/mutation.graphql @@ -0,0 +1,85 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "https://jsonplaceholder.typicode.com/"}}) +{ + query: Query + mutation: Mutation +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type CreateUserPayload + @join__type(graph: CONNECTORS) +{ + success: Boolean! + user: User! +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Mutation + @join__type(graph: CONNECTORS) +{ + createUser(name: String!): CreateUserPayload! @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {POST: "/user", body: "username: $args.name"}, selection: "success: $(true)\nuser: {\n id\n name: username\n}"}) +} + +type Query + @join__type(graph: CONNECTORS) +{ + users: [User] @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users"}, selection: "id name"}) + user(id: ID!): User @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users/{$args.id}"}, selection: "id name email", entity: true}) +} + +type User + @join__type(graph: CONNECTORS) +{ + id: ID! + name: String + email: String +} diff --git a/apollo-router/src/plugins/connectors/testdata/mutation.yaml b/apollo-router/src/plugins/connectors/testdata/mutation.yaml new file mode 100644 index 0000000000..ec77b1a698 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/mutation.yaml @@ -0,0 +1,60 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10") + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source( + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + } + ) + + type User { + id: ID! + name: String + email: String + } + + type Query { + users: [User] + @connect(source: "json", http: { GET: "/users" }, selection: "id name") + user(id: ID!): User + @connect( + source: "json" + http: { GET: "/users/{$$args.id}" } + selection: "id name email" + entity: true + ) + } + + type Mutation { + createUser(name: String!): CreateUserPayload! + @connect( + source: "json" + http: { + POST: "/user" + body: """ + username: $$args.name + """ + } + selection: """ + success: $(true) + user: { + id + name: username + } + """ + ) + } + + type CreateUserPayload { + success: Boolean! + user: User! + } diff --git a/apollo-router/src/plugins/connectors/testdata/nullability.graphql b/apollo-router/src/plugins/connectors/testdata/nullability.graphql new file mode 100644 index 0000000000..b70e9ffbb2 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/nullability.graphql @@ -0,0 +1,87 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "https://jsonplaceholder.typicode.com/"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type Address + @join__type(graph: CONNECTORS) +{ + street: String + zip: String +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Pet + @join__type(graph: CONNECTORS) +{ + name: String + species: String +} + +type Query + @join__type(graph: CONNECTORS) +{ + user(id: ID!): User @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users/{$args.id}"}, selection: "id\nname\noccupation: job\naddress {\n street\n zip\n}\npet {\n name\n species\n}", entity: true}) + defaultArgs(str: String = "default", int: Int = 42, float: Float = 1.23, bool: Boolean = true, arr: [String] = ["default"]): String @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {POST: "/default-args", body: "str: $args.str int: $args.int float: $args.float bool: $args.bool arr: $args.arr"}, selection: "$"}) +} + +type User + @join__type(graph: CONNECTORS) +{ + id: ID! + name: String + occupation: String + address: Address + pet: Pet +} diff --git a/apollo-router/src/plugins/connectors/testdata/nullability.yaml b/apollo-router/src/plugins/connectors/testdata/nullability.yaml new file mode 100644 index 0000000000..1fab24d5cc --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/nullability.yaml @@ -0,0 +1,71 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10") + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source( + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + } + ) + + type User { + id: ID! + name: String + occupation: String + address: Address + pet: Pet + } + + type Address { + street: String + zip: String + } + + type Pet { + name: String + species: String + } + + type Query { + user(id: ID!): User + @connect( + source: "json" + http: { GET: "/users/{$$args.id}" } + selection: """ + id + name + occupation: job + address { + street + zip + } + pet { + name + species + } + """ + entity: true + ) + + defaultArgs( + str: String = "default" + int: Int = 42 + float: Float = 1.23 + bool: Boolean = true + # TODO: input enums will be supported after 2.10.0-alpha.2 + arr: [String] = ["default"] + ): String + @connect( + source: "json" + http: { POST: "/default-args", body: "str: $$args.str int: $$args.int float: $$args.float bool: $$args.bool arr: $$args.arr" } + selection: "$" + ) + } diff --git a/apollo-router/src/plugins/connectors/testdata/progressive-override.graphql b/apollo-router/src/plugins/connectors/testdata/progressive-override.graphql new file mode 100644 index 0000000000..3c8bbdc52d --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/progressive-override.graphql @@ -0,0 +1,72 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "https://jsonplaceholder.typicode.com/"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") + GRAPHQL @join__graph(name: "graphql", url: "https://localhost:4001") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) + @join__type(graph: GRAPHQL) +{ + users: [User] @join__field(graph: CONNECTORS, override: "graphql", overrideLabel: "percent(100)") @join__field(graph: GRAPHQL, overrideLabel: "percent(100)") @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users"}, selection: "id name"}) +} + +type User + @join__type(graph: CONNECTORS) + @join__type(graph: GRAPHQL) +{ + id: ID! + name: String +} diff --git a/apollo-router/src/plugins/connectors/testdata/progressive-override.yaml b/apollo-router/src/plugins/connectors/testdata/progressive-override.yaml new file mode 100644 index 0000000000..a4cc02737b --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/progressive-override.yaml @@ -0,0 +1,56 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.10" + import: ["@shareable", "@override"] + ) + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source( + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + } + ) + + type Query { + users: [User] + @override(from: "graphql", label: "percent(100)") + @connect( + source: "json" + http: { + GET: "/users" + } + selection: "id name" + ) + } + + type User @shareable { + id: ID! + name: String + } + + graphql: + routing_url: https://localhost:4001 + schema: + sdl: | + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.7" + import: ["@shareable"] + ) + + type Query { + users: [User] + } + + type User @shareable { + id: ID! + name: String + } diff --git a/apollo-router/src/plugins/connectors/testdata/quickstart.graphql b/apollo-router/src/plugins/connectors/testdata/quickstart.graphql new file mode 100644 index 0000000000..11cf26c992 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/quickstart.graphql @@ -0,0 +1,82 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "jsonPlaceholder", http: {baseURL: "https://jsonplaceholder.typicode.com/"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Post + @join__type(graph: CONNECTORS) +{ + id: ID! + body: String + title: String + author: User +} + +type Query + @join__type(graph: CONNECTORS) +{ + posts: [Post] @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "jsonPlaceholder", http: {GET: "/posts"}, selection: "id\ntitle\nbody\nauthor: { id: userId }"}) + post(id: ID!): Post @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "jsonPlaceholder", http: {GET: "/posts/{$args.id}"}, selection: "id\ntitle\nbody\nauthor: { id: userId }", entity: true}) + user(id: ID!): User @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "jsonPlaceholder", http: {GET: "/users/{$args.id}"}, selection: "id\nname\nusername", entity: true}) +} + +type User + @join__type(graph: CONNECTORS) +{ + id: ID! + name: String + username: String + posts: [Post] @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "jsonPlaceholder", http: {GET: "/users/{$this.id}/posts"}, selection: "id\ntitle\nbody"}) +} diff --git a/apollo-router/src/plugins/connectors/testdata/quickstart.yaml b/apollo-router/src/plugins/connectors/testdata/quickstart.yaml new file mode 100644 index 0000000000..856705fe9e --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/quickstart.yaml @@ -0,0 +1,75 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.8") + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source( + name: "jsonPlaceholder" + http: { baseURL: "https://jsonplaceholder.typicode.com/" } + ) + + type Post { + id: ID! + body: String + title: String + author: User + } + + type User { + id: ID! + name: String + username: String + posts: [Post] + @connect( + source: "jsonPlaceholder" + http: { GET: "/users/{$$this.id}/posts" } + selection: """ + id + title + body + """ + ) + } + + type Query { + posts: [Post] + @connect( + source: "jsonPlaceholder" + http: { GET: "/posts" } + selection: """ + id + title + body + author: { id: userId } + """ + ) + post(id: ID!): Post + @connect( + source: "jsonPlaceholder" + http: { GET: "/posts/{$$args.id}" } + selection: """ + id + title + body + author: { id: userId } + """ + entity: true + ) + user(id: ID!): User + @connect( + source: "jsonPlaceholder" + http: { GET: "/users/{$$args.id}" } + selection: """ + id + name + username + """ + entity: true + ) + } \ No newline at end of file diff --git a/apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_1.json b/apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_1.json new file mode 100644 index 0000000000..5faa5ca17b --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_1.json @@ -0,0 +1,31 @@ +[ + { + "request": { + "method": "GET", + "path": "posts", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": [ + { + "userId": 1, + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + }, + { + "userId": 1, + "id": 2, + "title": "qui est esse", + "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla" + } + ] + } + } +] \ No newline at end of file diff --git a/apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_2.json b/apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_2.json new file mode 100644 index 0000000000..f0a408eb2d --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_2.json @@ -0,0 +1,23 @@ +[ + { + "request": { + "method": "GET", + "path": "posts/1", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "userId": 1, + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + } + } + } +] \ No newline at end of file diff --git a/apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_3.json b/apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_3.json new file mode 100644 index 0000000000..2373dbdc5b --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_3.json @@ -0,0 +1,61 @@ +[ + { + "request": { + "method": "GET", + "path": "posts/1", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "userId": 1, + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + } + } + }, + { + "request": { + "method": "GET", + "path": "users/1", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "id": 1, + "name": "Leanne Graham", + "username": "Bret", + "email": "Sincere@april.biz", + "address": { + "street": "Kulas Light", + "suite": "Apt. 556", + "city": "Gwenborough", + "zipcode": "92998-3874", + "geo": { + "lat": "-37.3159", + "lng": "81.1496" + } + }, + "phone": "1-770-736-8031 x56442", + "website": "hildegard.org", + "company": { + "name": "Romaguera-Crona", + "catchPhrase": "Multi-layered client-server neural-net", + "bs": "harness real-time e-markets" + } + } + } + } +] \ No newline at end of file diff --git a/apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_4.json b/apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_4.json new file mode 100644 index 0000000000..b26761e481 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/quickstart_api_snapshots/query_4.json @@ -0,0 +1,279 @@ +[ + { + "request": { + "method": "GET", + "path": "posts/1", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "userId": 1, + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + } + } + }, + { + "request": { + "method": "GET", + "path": "posts/10", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "userId": 1, + "id": 10, + "title": "optio molestias id quia eum", + "body": "quo et expedita modi cum officia vel magni\ndoloribus qui repudiandae\nvero nisi sit\nquos veniam quod sed accusamus veritatis error" + } + } + }, + { + "request": { + "method": "GET", + "path": "posts/2", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "userId": 1, + "id": 2, + "title": "qui est esse", + "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla" + } + } + }, + { + "request": { + "method": "GET", + "path": "posts/3", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "userId": 1, + "id": 3, + "title": "ea molestias quasi exercitationem repellat qui ipsa sit aut", + "body": "et iusto sed quo iure\nvoluptatem occaecati omnis eligendi aut ad\nvoluptatem doloribus vel accusantium quis pariatur\nmolestiae porro eius odio et labore et velit aut" + } + } + }, + { + "request": { + "method": "GET", + "path": "posts/4", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "userId": 1, + "id": 4, + "title": "eum et est occaecati", + "body": "ullam et saepe reiciendis voluptatem adipisci\nsit amet autem assumenda provident rerum culpa\nquis hic commodi nesciunt rem tenetur doloremque ipsam iure\nquis sunt voluptatem rerum illo velit" + } + } + }, + { + "request": { + "method": "GET", + "path": "posts/5", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "userId": 1, + "id": 5, + "title": "nesciunt quas odio", + "body": "repudiandae veniam quaerat sunt sed\nalias aut fugiat sit autem sed est\nvoluptatem omnis possimus esse voluptatibus quis\nest aut tenetur dolor neque" + } + } + }, + { + "request": { + "method": "GET", + "path": "posts/6", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "userId": 1, + "id": 6, + "title": "dolorem eum magni eos aperiam quia", + "body": "ut aspernatur corporis harum nihil quis provident sequi\nmollitia nobis aliquid molestiae\nperspiciatis et ea nemo ab reprehenderit accusantium quas\nvoluptate dolores velit et doloremque molestiae" + } + } + }, + { + "request": { + "method": "GET", + "path": "posts/7", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "userId": 1, + "id": 7, + "title": "magnam facilis autem", + "body": "dolore placeat quibusdam ea quo vitae\nmagni quis enim qui quis quo nemo aut saepe\nquidem repellat excepturi ut quia\nsunt ut sequi eos ea sed quas" + } + } + }, + { + "request": { + "method": "GET", + "path": "posts/8", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "userId": 1, + "id": 8, + "title": "dolorem dolore est ipsam", + "body": "dignissimos aperiam dolorem qui eum\nfacilis quibusdam animi sint suscipit qui sint possimus cum\nquaerat magni maiores excepturi\nipsam ut commodi dolor voluptatum modi aut vitae" + } + } + }, + { + "request": { + "method": "GET", + "path": "posts/9", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "userId": 1, + "id": 9, + "title": "nesciunt iure omnis dolorem tempora et accusantium", + "body": "consectetur animi nesciunt iure dolore\nenim quia ad\nveniam autem ut quam aut nobis\net est aut quod aut provident voluptas autem voluptas" + } + } + }, + { + "request": { + "method": "GET", + "path": "users/1", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "id": 1, + "name": "Leanne Graham", + "username": "Bret", + "email": "Sincere@april.biz", + "address": { + "street": "Kulas Light", + "suite": "Apt. 556", + "city": "Gwenborough", + "zipcode": "92998-3874", + "geo": { + "lat": "-37.3159", + "lng": "81.1496" + } + }, + "phone": "1-770-736-8031 x56442", + "website": "hildegard.org", + "company": { + "name": "Romaguera-Crona", + "catchPhrase": "Multi-layered client-server neural-net", + "bs": "harness real-time e-markets" + } + } + } + }, + { + "request": { + "method": "GET", + "path": "users/1/posts", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": [ + { + "userId": 1, + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + }, + { + "userId": 1, + "id": 2, + "title": "qui est esse", + "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla" + } + ] + } + } +] \ No newline at end of file diff --git a/apollo-router/src/plugins/connectors/testdata/regenerate.sh b/apollo-router/src/plugins/connectors/testdata/regenerate.sh new file mode 100755 index 0000000000..053bd0b5ef --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/regenerate.sh @@ -0,0 +1,23 @@ +set -euo pipefail + +if [ -z "${FEDERATION_VERSION:-}" ]; then + FEDERATION_VERSION="2.11.0" +fi + +regenerate_graphql() { + local supergraph_config=$1 + local test_name + test_name=$(basename "$supergraph_config" .yaml) + local dir_name + dir_name=$(dirname "$supergraph_config") + echo "Regenerating $dir_name/$test_name.graphql" + rover supergraph compose --federation-version "=$FEDERATION_VERSION" --config "$supergraph_config" > "$dir_name/$test_name.graphql" +} + +if [ -z "${1:-}" ]; then + for supergraph_config in **/*.yaml; do + regenerate_graphql "$supergraph_config" + done +else + regenerate_graphql "$1" +fi \ No newline at end of file diff --git a/apollo-router/src/plugins/connectors/testdata/selection.graphql b/apollo-router/src/plugins/connectors/testdata/selection.graphql new file mode 100644 index 0000000000..8c0333eb2e --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/selection.graphql @@ -0,0 +1,83 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "https://jsonplaceholder.typicode.com/"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type Commit + @join__type(graph: CONNECTORS) +{ + commit: CommitDetail +} + +type CommitAuthor + @join__type(graph: CONNECTORS) +{ + name: String + email: String + owner: String +} + +type CommitDetail + @join__type(graph: CONNECTORS) +{ + name_from_path: String + by: CommitAuthor +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + commits(owner: String!, repo: String!): [Commit] @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/repos/{$args.owner}/{$args.repo}/commits", headers: [{name: "x-multiline", value: "multi\n line\n header"}]}, selection: "commit {\n name_from_path: author.name\n by: {\n name: author.name\n email: author.email\n owner: $args.owner\n }\n}"}) +} diff --git a/apollo-router/src/plugins/connectors/testdata/selection.yaml b/apollo-router/src/plugins/connectors/testdata/selection.yaml new file mode 100644 index 0000000000..910649a917 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/selection.yaml @@ -0,0 +1,55 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7") + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source( + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + } + ) + + type Commit { + commit: CommitDetail + } + + type CommitDetail { + name_from_path: String + by: CommitAuthor + } + + type CommitAuthor { + name: String + email: String + owner: String + } + + type Query { + commits(owner: String!, repo: String!): [Commit] + @connect( + source: "json" + http: { + GET: "/repos/{$$args.owner}/{$$args.repo}/commits" + headers: [ + { name: "x-multiline", value: "multi\n line\n header" } + ] + } + selection: """ + commit { + name_from_path: author.name + by: { + name: author.name + email: author.email + owner: $$args.owner + } + } + """ + ) + } diff --git a/apollo-router/src/plugins/connectors/testdata/steelthread.graphql b/apollo-router/src/plugins/connectors/testdata/steelthread.graphql new file mode 100644 index 0000000000..3cb4212f0d --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/steelthread.graphql @@ -0,0 +1,90 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "https://jsonplaceholder.typicode.com/", headers: [{name: "x-new-name", from: "x-rename-source"}, {name: "x-forward", from: "x-forward"}, {name: "x-insert", value: "inserted"}, {name: "x-config-variable-source", value: "before {$config.source.val} after"}, {name: "x-context-value-source", value: "before {$context.val} after"}]}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") + GRAPHQL @join__graph(name: "graphql", url: "https://localhost:4001") +} + +scalar JSON + @join__type(graph: CONNECTORS) + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Post + @join__type(graph: CONNECTORS) +{ + id: ID! + title: String + user: User +} + +type Query + @join__type(graph: CONNECTORS) + @join__type(graph: GRAPHQL) +{ + users: [User] @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users", headers: [{name: "x-new-name", from: "x-rename-connect"}, {name: "x-insert-multi-value", value: "first,second"}, {name: "x-config-variable-connect", value: "before {$config.connect.val} after"}, {name: "x-context-value-connect", value: "before {$context.val} after"}]}, selection: "id name"}) + me: User @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users/{$config.id}"}, selection: "id\nname\nusername"}) + user(id: ID!): User @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users/{$args.id}", headers: [{name: "x-from-args", value: "before {$args.id} after"}]}, selection: "id\nname\nusername", entity: true}) + posts: [Post] @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/posts"}, selection: "id title user: { id: userId }"}) +} + +type User + @join__type(graph: CONNECTORS, key: "id") + @join__type(graph: GRAPHQL, key: "id") +{ + id: ID! + name: String @join__field(graph: CONNECTORS) + username: String @join__field(graph: CONNECTORS) + nickname: String @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users/{$this.id}/nicknames", headers: [{name: "x-from-this", value: "before {$this.id} after"}]}, selection: "$.first"}) + c: String @join__field(graph: CONNECTORS, external: true) @join__field(graph: GRAPHQL) + d: String @join__field(graph: CONNECTORS, requires: "c") @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users/{$this.c}"}, selection: "$.phone"}) +} diff --git a/apollo-router/src/plugins/connectors/testdata/steelthread.yaml b/apollo-router/src/plugins/connectors/testdata/steelthread.yaml new file mode 100644 index 0000000000..1da18ef5b3 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/steelthread.yaml @@ -0,0 +1,126 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.10" + import: ["@key", "@external", "@requires", "@shareable"] + ) + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source( + name: "json" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [ + { name: "x-new-name" from: "x-rename-source" } + { name: "x-forward" from: "x-forward" } + { name: "x-insert" value: "inserted" } + { name: "x-config-variable-source" value: "before {$$config.source.val} after" } + { name: "x-context-value-source", value: "before {$$context.val} after" } + ] + } + ) + + type Query { + users: [User] + @connect( + source: "json" + http: { + GET: "/users" + headers: [ + {name: "x-new-name", from: "x-rename-connect"} + {name: "x-insert-multi-value", value: "first,second"} + {name: "x-config-variable-connect" value: "before {$$config.connect.val} after"} + {name: "x-context-value-connect", value: "before {$$context.val} after"} + ] + } + selection: "id name" + ) + + me: User @connect( + source: "json" + http: { GET: "/users/{$$config.id}" } + selection: """ + id + name + username + """ + ) + + user(id: ID!): User + @connect( + source: "json" + http: { + GET: "/users/{$$args.id}" + headers: [ + {name: "x-from-args" value: "before {$$args.id} after"} + ] + } + selection: """ + id + name + username + """ + entity: true + ) + + posts: [Post] + @connect( + source: "json" + http: { GET: "/posts" } + selection: "id title user: { id: userId }" + ) + } + + type User @key(fields: "id") { + id: ID! + name: String + username: String + nickname: String + @connect( + source: "json" + http: { + GET: "/users/{$$this.id}/nicknames" + headers: [ + {name: "x-from-this" value: "before {$$this.id} after"} + ] + } + selection: "$.first" + ) + c: String @external + d: String + @requires(fields: "c") + @connect( + source: "json" + http: { GET: "/users/{$$this.c}" } + selection: "$.phone" + ) + } + + type Post { + id: ID! + title: String + user: User + } + + scalar JSON + + graphql: + routing_url: https://localhost:4001 + schema: + sdl: | + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.7" + import: ["@key"] + ) + + type User @key(fields: "id") { + id: ID! + c: String + } diff --git a/apollo-router/src/plugins/connectors/testdata/url-properties.graphql b/apollo-router/src/plugins/connectors/testdata/url-properties.graphql new file mode 100644 index 0000000000..1678ad6bf5 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/url-properties.graphql @@ -0,0 +1,62 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "http://localhost", path: "$(['api', 'v1'])", queryParams: "q: $(1)"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + f(req: String!, opt: String, repeated: [Int]): String @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users", path: "$([$args.req, $args.opt])", queryParams: "repeated: $args.repeated"}, selection: "$"}) +} diff --git a/apollo-router/src/plugins/connectors/testdata/url-properties.yaml b/apollo-router/src/plugins/connectors/testdata/url-properties.yaml new file mode 100644 index 0000000000..5914756653 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/url-properties.yaml @@ -0,0 +1,29 @@ +subgraphs: + connectors: + routing_url: http://none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.11") + @link(url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]) + @source( + name: "json" + http: { + baseURL: "http://localhost" + path: "$(['api', 'v1'])" + queryParams: "q: $(1)" + } + ) + + type Query { + f(req: String!, opt: String, repeated: [Int]): String + @connect( + source: "json" + http: { + GET: "/users" + path: "$([$$args.req, $$args.opt])" + queryParams: "repeated: $$args.repeated" + } + selection: "$" + ) + } diff --git a/apollo-router/src/plugins/connectors/testdata/variables-subgraph.graphql b/apollo-router/src/plugins/connectors/testdata/variables-subgraph.graphql new file mode 100644 index 0000000000..4b45ff8ac2 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/variables-subgraph.graphql @@ -0,0 +1,104 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10") + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source( + name: "v1" + http: { + baseURL: "http://localhost:4001/" + headers: [ + { name: "x-source-context", value: "{$context.value}" } + { name: "x-source-config", value: "{$config.value}" } + ] + } + ) + +type Query { + f(arg: String!): T + @connect( + source: "v1" + http: { + POST: "/f?arg={$args.arg->slice(1)}&context={$context.value}&config={$config.value}&header={$request.headers.value->first}" + headers: [ + { name: "x-connect-context", value: "{$context.value}" } + { name: "x-connect-config", value: "{$config.value}" } + { name: "x-connect-arg", value: "{$args.arg->last}" } + ] + body: """ + arg: $args.arg + context: $context.value + config: $config.value + request: $request.headers.value->first + """ + } + selection: """ + arg: $args.arg + context: $context.value + config: $config.value + status: $status + sibling: $("D") + extra: $->echo({ arg: $args.arg, context: $context.value, config: $config.value, status: $status }) + request: $request.headers.value->first + response: $response.headers.value->first + """ + ) + complexInputType(filters: I): String + @connect( + source: "v1" + http: { GET: "/complexInputType?inSpace={$args.filters.inSpace}&search={$args.filters.search}" } + selection: """ + $ + """ + ) +} + +input I { + inSpace: Boolean + search: String +} + +type T { + arg: String + context: String + config: String + status: Int + sibling: String + extra: JSON + request: String + response: String + f(arg: String): U + @connect( + source: "v1" + http: { + POST: "/f?arg={$args.arg->slice(2)}&context={$context.value}&config={$config.value}&sibling={$this.sibling}" + headers: [ + { name: "x-connect-context", value: "{$context.value}" } + { name: "x-connect-config", value: "{$config.value}" } + { name: "x-connect-arg", value: "{$args.arg->first}" } + { name: "x-connect-sibling", value: "{$this.sibling}" } + ] + body: """ + arg: $args.arg + context: $context.value + config: $config.value + sibling: $this.sibling + """ + } + selection: """ + arg: $args.arg + context: $context.value + config: $config.value + sibling: $this.sibling + status: $status + """ + ) +} + +type U { + arg: String + context: String + config: String + status: Int + sibling: String +} + +scalar JSON diff --git a/apollo-router/src/plugins/connectors/testdata/variables.graphql b/apollo-router/src/plugins/connectors/testdata/variables.graphql new file mode 100644 index 0000000000..1c594768a2 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/variables.graphql @@ -0,0 +1,97 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "v1", http: {baseURL: "http://localhost:4001/", headers: [{name: "x-source-context", value: "{$context.value}"}, {name: "x-source-config", value: "{$config.value}"}]}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input I + @join__type(graph: CONNECTORS) +{ + inSpace: Boolean + search: String +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar JSON + @join__type(graph: CONNECTORS) + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + f(arg: String!): T @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "v1", http: {POST: "/f?arg={$args.arg->slice(1)}&context={$context.value}&config={$config.value}&header={$request.headers.value->first}", headers: [{name: "x-connect-context", value: "{$context.value}"}, {name: "x-connect-config", value: "{$config.value}"}, {name: "x-connect-arg", value: "{$args.arg->last}"}], body: "arg: $args.arg\ncontext: $context.value\nconfig: $config.value\nrequest: $request.headers.value->first"}, selection: "arg: $args.arg\ncontext: $context.value\nconfig: $config.value\nstatus: $status\nsibling: $(\"D\")\nextra: $->echo({ arg: $args.arg, context: $context.value, config: $config.value, status: $status })\nrequest: $request.headers.value->first\nresponse: $response.headers.value->first"}) + complexInputType(filters: I): String @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "v1", http: {GET: "/complexInputType?inSpace={$args.filters.inSpace}&search={$args.filters.search}"}, selection: "$"}) +} + +type T + @join__type(graph: CONNECTORS) +{ + arg: String + context: String + config: String + status: Int + sibling: String + extra: JSON + request: String + response: String + f(arg: String): U @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "v1", http: {POST: "/f?arg={$args.arg->slice(2)}&context={$context.value}&config={$config.value}&sibling={$this.sibling}", headers: [{name: "x-connect-context", value: "{$context.value}"}, {name: "x-connect-config", value: "{$config.value}"}, {name: "x-connect-arg", value: "{$args.arg->first}"}, {name: "x-connect-sibling", value: "{$this.sibling}"}], body: "arg: $args.arg\ncontext: $context.value\nconfig: $config.value\nsibling: $this.sibling"}, selection: "arg: $args.arg\ncontext: $context.value\nconfig: $config.value\nsibling: $this.sibling\nstatus: $status"}) +} + +type U + @join__type(graph: CONNECTORS) +{ + arg: String + context: String + config: String + status: Int + sibling: String +} diff --git a/apollo-router/src/plugins/connectors/testdata/variables.yaml b/apollo-router/src/plugins/connectors/testdata/variables.yaml new file mode 100644 index 0000000000..c186dfe709 --- /dev/null +++ b/apollo-router/src/plugins/connectors/testdata/variables.yaml @@ -0,0 +1,6 @@ +federation_version: =2.10.0-dylan +subgraphs: + connectors: + routing_url: none + schema: + file: variables-subgraph.graphql \ No newline at end of file diff --git a/apollo-router/src/plugins/connectors/tests/connect_on_type.rs b/apollo-router/src/plugins/connectors/tests/connect_on_type.rs new file mode 100644 index 0000000000..8c7685308b --- /dev/null +++ b/apollo-router/src/plugins/connectors/tests/connect_on_type.rs @@ -0,0 +1,658 @@ +use http::header::CONTENT_TYPE; +use mime::APPLICATION_JSON; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_json; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use super::req_asserts::Matcher; +use super::req_asserts::Plan; + +#[tokio::test] +async fn basic_batch() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "id": 3 }, + { "id": 1 }, + { "id": 2 }]))) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/users-batch")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }]))) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/batch.graphql"), + &mock_server.uri(), + "query { users { id name username } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": [ + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }, + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + } + ] + } + } + "#); + + super::req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/users"), + Matcher::new() + .method("POST") + .path("/users-batch") + .body(serde_json::json!({ "ids": [3,1,2] })), + ], + ); +} + +#[tokio::test] +async fn basic_batch_query_params() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "id": 3 }, + { "id": 1 }, + { "id": 2 }]))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/user-details")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }]))) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/batch-query.graphql"), + &mock_server.uri(), + "query { users { id name username } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": [ + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }, + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + } + ] + } + } + "#); + + super::req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/users"), + Matcher::new() + .method("GET") + .path("/user-details") + .query("ids=3%2C1%2C2"), + ], + ); +} + +#[tokio::test] +async fn batch_missing_items() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { "id": 3 }, + { "id": 1 }, + { "id": 2 }, + { "id": 4 }, + ]))) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/users-batch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + // 1 & 4 are not returned, so the extra fields should just null out (not be an error) + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }]))) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/batch.graphql"), + &mock_server.uri(), + "query { users { id name username } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": [ + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }, + { + "id": 1, + "name": null, + "username": null + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 4, + "name": null, + "username": null + } + ] + } + } + "#); + + super::req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/users"), + Matcher::new() + .method("POST") + .path("/users-batch") + .body(json!({ "ids": [3,1,2,4] })), + ], + ); +} + +#[tokio::test] +async fn connect_on_type() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "id": 3 }, + { "id": 1 }, + { "id": 2 }]))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!( + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/users/2")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!( + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/users/3")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!( + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }))) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/connect-on-type.graphql"), + &mock_server.uri(), + "query { users { id name username } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": [ + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }, + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + } + ] + } + } + "#); + + Plan::Sequence(vec![ + Plan::Fetch(Matcher::new().method("GET").path("/users")), + Plan::Parallel(vec![ + Matcher::new().method("GET").path("/users/1"), + Matcher::new().method("GET").path("/users/2"), + Matcher::new().method("GET").path("/users/3"), + ]), + ]) + .assert_matches(&mock_server.received_requests().await.unwrap()); +} + +#[tokio::test] +async fn connect_on_interface_object() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(json!({"query": "{ users { __typename id ... on Employee { name } ... on Customer { name } } }"}))) + .respond_with( + ResponseTemplate::new(200) + .insert_header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .set_body_json(json!({ + "data": { + "users": [{ + "__typename": "Employee", + "id": "1", + "name": "Alice", + }, { + "__typename": "Customer", + "id": "2", + "name": "Bob" + }, { + "__typename": "Customer", + "id": "3", + "name": "Charlie" + }] + } + })), + ).mount(&mock_server).await; + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!( + { + "id": "1", + "favoriteColor": "red" + }))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/users/2")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!( + { + "id": "2", + "favoriteColor": "green" + }))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/users/3")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!( + { + "id": "3", + "favoriteColor": "blue" + }))) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/connect-on-interface-object.graphql"), + &mock_server.uri(), + "{ users { id favoriteColor ... on Employee { name } ... on Customer { name } } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "users": [ + { + "id": "1", + "favoriteColor": "red", + "name": "Alice" + }, + { + "id": "2", + "favoriteColor": "green", + "name": "Bob" + }, + { + "id": "3", + "favoriteColor": "blue", + "name": "Charlie" + } + ] + } + } + "###); + + Plan::Sequence(vec![ + Plan::Fetch(Matcher::new().method("POST").path("/graphql")), + Plan::Parallel(vec![ + Matcher::new().method("GET").path("/users/1"), + Matcher::new().method("GET").path("/users/2"), + Matcher::new().method("GET").path("/users/3"), + ]), + ]) + .assert_matches(&mock_server.received_requests().await.unwrap()); +} + +#[tokio::test] +async fn batch_with_max_size_under_batch_size() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "id": 3 }, + { "id": 1 }, + { "id": 2 }]))) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/users-batch")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }]))) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/batch-max-size.graphql"), + &mock_server.uri(), + "query { users { id name username } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": [ + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }, + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + } + ] + } + } + "#); + + super::req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/users"), + Matcher::new() + .method("POST") + .path("/users-batch") + .body(serde_json::json!({ "ids": [3,1,2] })), + ], + ); +} + +#[tokio::test] +async fn batch_with_max_size_over_batch_size() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "id": 3 }, + { "id": 1 }, + { "id": 2 }, + { "id": 4 }, + { "id": 5 }, + { "id": 6 }, + { "id": 7 }, + ]))) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/users-batch")) + .and(body_json(json!({ "ids": [3,1,2,4,5] }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }, + { + "id": 4, + "name": "John Doe", + "username": "jdoe" + }, + { + "id": 5, + "name": "John Wick", + "username": "jwick" + }, + ]))) + .mount(&mock_server) + .await; + + Mock::given(method("POST")) + .and(path("/users-batch")) + .and(body_json(json!({ "ids": [6,7] }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 6, + "name": "Jack Reacher", + "username": "reacher" + }, + { + "id": 7, + "name": "James Bond", + "username": "jbond" + } + ]))) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/batch-max-size.graphql"), + &mock_server.uri(), + "query { users { id name username } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": [ + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }, + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 4, + "name": "John Doe", + "username": "jdoe" + }, + { + "id": 5, + "name": "John Wick", + "username": "jwick" + }, + { + "id": 6, + "name": "Jack Reacher", + "username": "reacher" + }, + { + "id": 7, + "name": "James Bond", + "username": "jbond" + } + ] + } + } + "#); + + super::req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/users"), + Matcher::new() + .method("POST") + .path("/users-batch") + .body(serde_json::json!({ "ids": [3,1,2,4,5] })), + Matcher::new() + .method("POST") + .path("/users-batch") + .body(serde_json::json!({ "ids": [6,7] })), + ], + ); +} diff --git a/apollo-router/src/plugins/connectors/tests/content_type.rs b/apollo-router/src/plugins/connectors/tests/content_type.rs new file mode 100644 index 0000000000..5c31b711b5 --- /dev/null +++ b/apollo-router/src/plugins/connectors/tests/content_type.rs @@ -0,0 +1,507 @@ +use encoding_rs::WINDOWS_1252; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test] +async fn blank_body_maps_literal() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/literal")) + .respond_with(ResponseTemplate::new(200).insert_header("content-type", "text/plain")) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/content-type.graphql"), + &mock_server.uri(), + "query { literal }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "literal": "literal test" + } + } + "#); +} + +#[tokio::test] +async fn blank_body_raw_value_is_null() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/raw")) + .respond_with(ResponseTemplate::new(200).insert_header("content-type", "text/plain")) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/content-type.graphql"), + &mock_server.uri(), + "query { raw }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "raw": null + } + } + "#); +} + +#[tokio::test] +async fn text_body_maps_literal() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/literal")) + .respond_with(ResponseTemplate::new(200).set_body_raw("test from server", "text/plain")) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/content-type.graphql"), + &mock_server.uri(), + "query { literal }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "literal": "literal test" + } + } + "#); +} + +#[tokio::test] +async fn text_body_maps_raw_value() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/raw")) + .respond_with(ResponseTemplate::new(200).set_body_raw("test from server", "text/plain")) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/content-type.graphql"), + &mock_server.uri(), + "query { raw }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "raw": "test from server" + } + } + "#); +} + +#[tokio::test] +async fn text_body_maps_with_non_utf8_charset() { + let (body, ..) = WINDOWS_1252.encode("test from server"); + let body = body.into_owned(); + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/raw")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(body, "text/plain; charset=windows-1252"), + ) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/content-type.graphql"), + &mock_server.uri(), + "query { raw }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "raw": "test from server" + } + } + "#); +} + +#[tokio::test] +async fn text_body_maps_with_non_utf8_charset_using_invalid_utf8_bytes() { + let bytes = [0x80]; // valid in Windows-1252 (e.g., €), invalid in UTF-8 + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/raw")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(bytes, "text/plain; charset=windows-1252"), + ) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/content-type.graphql"), + &mock_server.uri(), + "query { raw }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "raw": "€" + } + } + "#); +} + +#[tokio::test] +async fn text_body_errors_on_invalid_chars_in_charset() { + let bytes = [0xC0, 0xAF]; // invalid UTF-8 sequence + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/raw")) + .respond_with(ResponseTemplate::new(200).set_body_raw(bytes, "text/plain; charset=utf-8")) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/content-type.graphql"), + &mock_server.uri(), + "query { raw }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": null, + "errors": [ + { + "message": "The server returned data in an unexpected format.", + "path": [ + "raw" + ], + "extensions": { + "code": "CONNECTOR_RESPONSE_INVALID", + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.raw[0]" + }, + "http": { + "status": 200 + } + } + } + ] + } + "#); +} + +#[tokio::test] +async fn other_content_type_maps_literal() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/literal")) + .respond_with(ResponseTemplate::new(200).set_body_raw("hello", "text/html")) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/content-type.graphql"), + &mock_server.uri(), + "query { literal }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "literal": "literal test" + } + } + "#); +} + +#[tokio::test] +async fn other_content_type_maps_raw_value_as_null() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/raw")) + .respond_with(ResponseTemplate::new(200).set_body_raw("hello", "text/html")) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/content-type.graphql"), + &mock_server.uri(), + "query { raw }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "raw": null + } + } + "#); +} + +#[tokio::test] +async fn should_map_json_content_type() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }]))) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/content-type.graphql"), + &mock_server.uri(), + "query { users {id name username} }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": [ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + } + ] + } + } + "#); +} + +#[tokio::test] +async fn should_error_on_invalid_with_json_content_type() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_raw("{_...]", "application/json")) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/content-type.graphql"), + &mock_server.uri(), + "query { users {id name username} }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": null, + "errors": [ + { + "message": "The server returned data in an unexpected format.", + "path": [ + "users" + ], + "extensions": { + "code": "CONNECTOR_RESPONSE_INVALID", + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.users[0]" + }, + "http": { + "status": 200 + } + } + } + ] + } + "#); +} + +#[tokio::test] +async fn should_map_json_like_content_type() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with( + ResponseTemplate::new(200).set_body_raw( + serde_json::json!([ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }]) + .to_string(), + "application/vnd.foo+json", + ), + ) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/content-type.graphql"), + &mock_server.uri(), + "query { users {id name username} }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": [ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + } + ] + } + } + "#); +} + +#[tokio::test] +async fn should_error_on_invalid_with_json_like_content_type() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_raw("{_...]", "application/vnd.foo+json")) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/content-type.graphql"), + &mock_server.uri(), + "query { users {id name username} }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": null, + "errors": [ + { + "message": "The server returned data in an unexpected format.", + "path": [ + "users" + ], + "extensions": { + "code": "CONNECTOR_RESPONSE_INVALID", + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.users[0]" + }, + "http": { + "status": 200 + } + } + } + ] + } + "#); +} diff --git a/apollo-router/src/plugins/connectors/tests/error_handling.rs b/apollo-router/src/plugins/connectors/tests/error_handling.rs new file mode 100644 index 0000000000..364a7ff914 --- /dev/null +++ b/apollo-router/src/plugins/connectors/tests/error_handling.rs @@ -0,0 +1,627 @@ +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test] +async fn only_source_no_error() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }]))) + .mount(&mock_server) + .await; + + let connector_uri = format!("{}/", &mock_server.uri()); + let override_config = json!({ + "connectors": { + "sources": { + "connectors.withconfig": { + "override_url": connector_uri + }, + "connectors.withoutconfig": { + "override_url": connector_uri + } + } + } + }); + + let response = super::execute( + include_str!("../testdata/errors.graphql"), + &mock_server.uri(), + "query { only_source { id name username } }", + Default::default(), + Some(override_config.into()), + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "only_source": [ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + } + ] + } + } + "#); +} + +#[tokio::test] +async fn only_source_with_error() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ + "error": { + "message": "Something blew up!", + "code": "BIG_BOOM" + } + }))) + .mount(&mock_server) + .await; + + let connector_uri = format!("{}/", &mock_server.uri()); + let override_config = json!({ + "connectors": { + "sources": { + "connectors.withconfig": { + "override_url": connector_uri + }, + "connectors.withoutconfig": { + "override_url": connector_uri + } + } + } + }); + + let response = super::execute( + include_str!("../testdata/errors.graphql"), + &mock_server.uri(), + "query { only_source { id name username } }", + Default::default(), + Some(override_config.into()), + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": null, + "errors": [ + { + "message": "Something blew up!", + "path": [ + "only_source" + ], + "extensions": { + "code": "BIG_BOOM", + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.only_source[0]" + }, + "http": { + "status": 500 + }, + "status": 500, + "fromSource": "a" + } + } + ] + } + "#); +} + +#[tokio::test] +async fn only_connect_no_error() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }]))) + .mount(&mock_server) + .await; + + let connector_uri = format!("{}/", &mock_server.uri()); + let override_config = json!({ + "connectors": { + "sources": { + "connectors.withconfig": { + "override_url": connector_uri + }, + "connectors.withoutconfig": { + "override_url": connector_uri + } + } + } + }); + + let response = super::execute( + include_str!("../testdata/errors.graphql"), + &mock_server.uri(), + "query { only_connect { id name username } }", + Default::default(), + Some(override_config.into()), + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "only_connect": [ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + } + ] + } + } + "#); +} + +#[tokio::test] +async fn only_connect_with_error() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ + "error": { + "message": "Something blew up!", + "code": "BIG_BOOM" + } + }))) + .mount(&mock_server) + .await; + + let connector_uri = format!("{}/", &mock_server.uri()); + let override_config = json!({ + "connectors": { + "sources": { + "connectors.withconfig": { + "override_url": connector_uri + }, + "connectors.withoutconfig": { + "override_url": connector_uri + } + } + } + }); + + let response = super::execute( + include_str!("../testdata/errors.graphql"), + &mock_server.uri(), + "query { only_connect { id name username } }", + Default::default(), + Some(override_config.into()), + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": null, + "errors": [ + { + "message": "Something blew up!", + "path": [ + "only_connect" + ], + "extensions": { + "code": "BIG_BOOM", + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.only_connect[0]" + }, + "http": { + "status": 500 + }, + "status": 500 + } + } + ] + } + "#); +} + +#[tokio::test] +async fn both_source_and_connect_no_error() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }]))) + .mount(&mock_server) + .await; + + let connector_uri = format!("{}/", &mock_server.uri()); + let override_config = json!({ + "connectors": { + "sources": { + "connectors.withconfig": { + "override_url": connector_uri + }, + "connectors.withoutconfig": { + "override_url": connector_uri + } + } + } + }); + + let response = super::execute( + include_str!("../testdata/errors.graphql"), + &mock_server.uri(), + "query { both_source_and_connect { id name username } }", + Default::default(), + Some(override_config.into()), + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "both_source_and_connect": [ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + } + ] + } + } + "#); +} + +#[tokio::test] +async fn both_source_and_connect_with_error() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ + "error": { + "message": "Something blew up!", + "code": "BIG_BOOM" + } + }))) + .mount(&mock_server) + .await; + + let connector_uri = format!("{}/", &mock_server.uri()); + let override_config = json!({ + "connectors": { + "sources": { + "connectors.withconfig": { + "override_url": connector_uri + }, + "connectors.withoutconfig": { + "override_url": connector_uri + } + } + } + }); + + let response = super::execute( + include_str!("../testdata/errors.graphql"), + &mock_server.uri(), + "query { both_source_and_connect { id name username } }", + Default::default(), + Some(override_config.into()), + |_| {}, + None, + ) + .await; + + // Note that status 500 is NOT included in extensions because connect is overriding source + insta::assert_json_snapshot!(response, @r#" + { + "data": null, + "errors": [ + { + "message": "Something blew up!", + "path": [ + "both_source_and_connect" + ], + "extensions": { + "code": "BIG_BOOM", + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.both_source_and_connect[0]" + }, + "http": { + "status": 500 + }, + "status": 500, + "fromSource": "a", + "fromConnect": "b" + } + } + ] + } + "#); +} + +#[tokio::test] +async fn partial_source_and_partial_connect() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ + "error": { + "message": "Something blew up!", + "code": "BIG_BOOM" + } + }))) + .mount(&mock_server) + .await; + + let connector_uri = format!("{}/", &mock_server.uri()); + let override_config = json!({ + "connectors": { + "sources": { + "connectors.withconfig": { + "override_url": connector_uri + }, + "connectors.withoutconfig": { + "override_url": connector_uri + }, + "connectors.withpartialconfig": { + "override_url": connector_uri + } + } + } + }); + + let response = super::execute( + include_str!("../testdata/errors.graphql"), + &mock_server.uri(), + "query { partial_source_and_partial_connect { id name username } }", + Default::default(), + Some(override_config.into()), + |_| {}, + None, + ) + .await; + + // Note that status 500 is NOT included in extensions because connect is overriding source + insta::assert_json_snapshot!(response, @r#" + { + "data": null, + "errors": [ + { + "message": "Something blew up!", + "path": [ + "partial_source_and_partial_connect" + ], + "extensions": { + "code": "BIG_BOOM", + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.partial_source_and_partial_connect[0]" + }, + "http": { + "status": 500 + }, + "status": 500, + "fromSource": "a" + } + } + ] + } + "#); +} + +#[tokio::test] +async fn redact_errors_when_include_subgraph_errors_disabled() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ + "error": { + "message": "Something blew up!", + "code": "BIG_BOOM" + } + }))) + .mount(&mock_server) + .await; + + let connector_uri = format!("{}/", &mock_server.uri()); + let override_config = json!({ + "connectors": { + "sources": { + "connectors.withconfig": { + "override_url": connector_uri + }, + "connectors.withoutconfig": { + "override_url": connector_uri + } + } + }, + "include_subgraph_errors": { + "subgraphs": { + "connectors": false + } + } + }); + + let response = super::execute( + include_str!("../testdata/errors.graphql"), + &mock_server.uri(), + "query { only_source { id name username } }", + Default::default(), + Some(override_config.into()), + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": null, + "errors": [ + { + "message": "Subgraph errors redacted", + "path": [ + "only_source" + ] + } + ] + } + "#); +} + +#[tokio::test] +async fn does_not_redact_errors_when_include_subgraph_errors_enabled() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ + "error": { + "message": "Something blew up!", + "code": "BIG_BOOM" + } + }))) + .mount(&mock_server) + .await; + + let connector_uri = format!("{}/", &mock_server.uri()); + let override_config = json!({ + "connectors": { + "sources": { + "connectors.withconfig": { + "override_url": connector_uri + }, + "connectors.withoutconfig": { + "override_url": connector_uri + } + } + }, + "include_subgraph_errors": { + "subgraphs": { + "connectors": true + } + } + }); + + let response = super::execute( + include_str!("../testdata/errors.graphql"), + &mock_server.uri(), + "query { only_source { id name username } }", + Default::default(), + Some(override_config.into()), + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": null, + "errors": [ + { + "message": "Something blew up!", + "path": [ + "only_source" + ], + "extensions": { + "code": "BIG_BOOM", + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.only_source[0]" + }, + "http": { + "status": 500 + }, + "status": 500, + "fromSource": "a" + } + } + ] + } + "#); +} diff --git a/apollo-router/src/plugins/connectors/tests/mock_api.rs b/apollo-router/src/plugins/connectors/tests/mock_api.rs new file mode 100644 index 0000000000..167d130413 --- /dev/null +++ b/apollo-router/src/plugins/connectors/tests/mock_api.rs @@ -0,0 +1,150 @@ +struct PathTemplate(String); + +impl wiremock::Match for PathTemplate { + fn matches(&self, request: &wiremock::Request) -> bool { + let path = request.url.path(); + let path = path.split('/'); + let template = self.0.split('/'); + + for pair in path.zip_longest(template) { + match pair { + EitherOrBoth::Both(p, t) => { + if t.starts_with('{') && t.ends_with('}') { + continue; + } + + if p != t { + return false; + } + } + _ => return false, + } + } + true + } +} + +#[allow(dead_code)] +fn path_template(template: &str) -> PathTemplate { + PathTemplate(template.to_string()) +} + +use super::*; + +pub(crate) fn users() -> Mock { + Mock::given(method("GET")).and(path("/users")).respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 1, + "name": "Leanne Graham" + }, + { + "id": 2, + "name": "Ervin Howell", + } + ])), + ) +} + +pub(crate) fn user_2_nicknames() -> Mock { + Mock::given(method("GET")) + .and(path("/users/2/nicknames")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(["cat"]))) +} + +pub(crate) fn users_error() -> Mock { + Mock::given(method("GET")).and(path("/users")).respond_with( + ResponseTemplate::new(404).set_body_json(serde_json::json!([ + { + "kind": "json", + "content": {}, + "selection": null + } + ])), + ) +} + +pub(crate) fn user_1() -> Mock { + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 1, + "name": "Leanne Graham", + "username": "Bret", + "phone": "1-770-736-8031 x56442", + "email": "Sincere@april.biz", + "website": "hildegard.org" + }))) +} + +pub(crate) fn user_2() -> Mock { + Mock::given(method("GET")) + .and(path("/users/2")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 2, + "name": "Ervin Howell", + "username": "Antonette", + "phone": "1-770-736-8031 x56442", + "email": "Shanna@melissa.tv", + "website": "anastasia.net" + }))) +} + +pub(crate) fn create_user() -> Mock { + Mock::given(method("POST")).and(path("/user")).respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!( + { + "id": 3, + "username": "New User" + } + )), + ) +} + +pub(crate) fn user_1_with_pet() -> Mock { + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 1, + "name": "Leanne Graham", + "pet": { + "name": "Spot" + } + }))) +} + +pub(crate) fn commits() -> Mock { + Mock::given(method("GET")) + .and(path("/repos/foo/bar/commits")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!( + [ + { + "sha": "abcdef", + "commit": { + "author": { + "name": "Foo Bar", + "email": "noone@nowhere", + "date": "2024-07-09T01:22:33Z" + }, + "message": "commit message", + }, + }] + ))) +} + +pub(crate) fn posts() -> Mock { + Mock::given(method("GET")).and(path("/posts")).respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 1, + "title": "Post 1", + "userId": 1 + }, + { + "id": 2, + "title": "Post 2", + "userId": 2 + } + ])), + ) +} diff --git a/apollo-router/src/plugins/connectors/tests/mod.rs b/apollo-router/src/plugins/connectors/tests/mod.rs new file mode 100644 index 0000000000..5f125c1c9b --- /dev/null +++ b/apollo-router/src/plugins/connectors/tests/mod.rs @@ -0,0 +1,2350 @@ +use std::str::FromStr; +use std::sync::Arc; + +use apollo_compiler::response::JsonMap; +use http::header::CONTENT_TYPE; +use itertools::EitherOrBoth; +use itertools::Itertools; +use mime::APPLICATION_JSON; +use mockall::mock; +use mockall::predicate::eq; +use req_asserts::Matcher; +use serde_json::Value; +use serde_json_bytes::json; +use tower::ServiceExt; +use tracing_core::Event; +use tracing_core::Metadata; +use tracing_core::span::Attributes; +use tracing_core::span::Id; +use tracing_core::span::Record; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::http::HeaderName; +use wiremock::http::HeaderValue; +use wiremock::matchers::body_json; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use crate::Configuration; +use crate::json_ext::ValueExt; +use crate::metrics::FutureMetricsExt; +use crate::plugins::connectors::tests::req_asserts::Plan; +use crate::plugins::telemetry::consts::CONNECT_SPAN_NAME; +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE; +use crate::router_factory::RouterSuperServiceFactory; +use crate::router_factory::YamlRouterFactory; +use crate::services::new_service::ServiceFactory; +use crate::services::router::Request; +use crate::services::supergraph; +use crate::uplink::license_enforcement::LicenseState; + +mod connect_on_type; +mod content_type; +mod error_handling; +mod mock_api; +mod progressive_override; +mod query_plan; +mod quickstart; +mod req_asserts; +mod url_properties; +mod variables; + +const STEEL_THREAD_SCHEMA: &str = include_str!("../testdata/steelthread.graphql"); +const MUTATION_SCHEMA: &str = include_str!("../testdata/mutation.graphql"); +const NULLABILITY_SCHEMA: &str = include_str!("../testdata/nullability.graphql"); +const SELECTION_SCHEMA: &str = include_str!("../testdata/selection.graphql"); +const NO_SOURCES_SCHEMA: &str = include_str!("../testdata/connector-without-source.graphql"); +const QUICKSTART_SCHEMA: &str = include_str!("../testdata/quickstart.graphql"); +const INTERFACE_OBJECT_SCHEMA: &str = include_str!("../testdata/interface-object.graphql"); +const VARIABLES_SCHEMA: &str = include_str!("../testdata/variables.graphql"); + +#[tokio::test] +async fn value_from_config() { + let mock_server = MockServer::start().await; + mock_api::user_1().mount(&mock_server).await; + + let response = execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { me { id name username} }", + Default::default(), + Some(json!({ + "connectors": { + "sources": { + "connectors.json": { + "$config": { + "id": 1, + } + } + } + } + })), + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "me": { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + } + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![Matcher::new().method("GET").path("/users/1")], + ); +} + +#[tokio::test] +async fn max_requests() { + let mock_server = MockServer::start().await; + mock_api::users().mount(&mock_server).await; + mock_api::user_1().mount(&mock_server).await; + mock_api::user_2().mount(&mock_server).await; + + let response = execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { users { id name username } }", + Default::default(), + Some(json!({ + "connectors": { + "max_requests_per_operation_per_source": 2 + } + })), + |_| {}, + Some(LicenseState::Licensed { + limits: Default::default(), + }), + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": [ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": null + } + ] + }, + "errors": [ + { + "message": "Request limit exceeded", + "path": [ + "users", + 1 + ], + "extensions": { + "code": "REQUEST_LIMIT_EXCEEDED", + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.user[0]" + } + } + } + ] + } + "#); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/users"), + Matcher::new().method("GET").path("/users/1"), + ], + ); +} + +#[tokio::test] +async fn source_max_requests() { + let mock_server = MockServer::start().await; + mock_api::users().mount(&mock_server).await; + mock_api::user_1().mount(&mock_server).await; + mock_api::user_2().mount(&mock_server).await; + + let response = execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { users { id name username } }", + Default::default(), + Some(json!({ + "connectors": { + "subgraphs": { + "connectors": { + "sources": { + "json": { + "max_requests_per_operation": 2, + } + } + } + } + } + })), + |_| {}, + Some(LicenseState::Licensed { + limits: Default::default(), + }), + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": [ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": null + } + ] + }, + "errors": [ + { + "message": "Request limit exceeded", + "path": [ + "users", + 1 + ], + "extensions": { + "code": "REQUEST_LIMIT_EXCEEDED", + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.user[0]" + } + } + } + ] + } + "#); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/users"), + Matcher::new().method("GET").path("/users/1"), + ], + ); +} + +#[tokio::test] +async fn test_root_field_plus_entity() { + let mock_server = MockServer::start().await; + mock_api::users().mount(&mock_server).await; + mock_api::user_1().mount(&mock_server).await; + mock_api::user_2().mount(&mock_server).await; + + let response = execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { users { __typename id name username } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "users": [ + { + "__typename": "User", + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "__typename": "User", + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + } + ] + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/users"), + Matcher::new().method("GET").path("/users/1"), + Matcher::new().method("GET").path("/users/2"), + ], + ); +} + +#[tokio::test] +async fn test_root_field_plus_entity_plus_requires() { + let mock_server = MockServer::start().await; + mock_api::users().mount(&mock_server).await; + mock_api::user_1().mount(&mock_server).await; + mock_api::user_2().mount(&mock_server).await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(json!({ + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { c } } }", + "variables": {"representations":[{"__typename":"User","id":1},{"__typename":"User","id":2}]} + }))) + .respond_with( + ResponseTemplate::new(200) + .insert_header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .set_body_json(json!({ + "data": { + "_entities": [{ + "__typename": "User", + "c": "1", + }, { + "__typename": "User", + "c": "2", + }] + } + })), + ).mount(&mock_server).await; + + let response = execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { users { __typename id name username d } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "users": [ + { + "__typename": "User", + "id": 1, + "name": "Leanne Graham", + "username": "Bret", + "d": "1-770-736-8031 x56442" + }, + { + "__typename": "User", + "id": 2, + "name": "Ervin Howell", + "username": "Antonette", + "d": "1-770-736-8031 x56442" + } + ] + } + } + "###); + + let plan = Plan::Sequence(vec![ + Plan::Fetch(Matcher::new().method("GET").path("/users")), + Plan::Parallel(vec![ + Matcher::new().method("GET").path("/users/1"), + Matcher::new().method("GET").path("/users/2"), + Matcher::new().method("POST").path("/graphql"), + ]), + Plan::Parallel(vec![ + Matcher::new().method("GET").path("/users/1"), + Matcher::new().method("GET").path("/users/2"), + ]), + ]); + + plan.assert_matches(&mock_server.received_requests().await.unwrap()) +} + +/// Tests that a connector can vend an entity reference like `user: { id: userId }` +#[tokio::test] +async fn test_entity_references() { + let mock_server = MockServer::start().await; + mock_api::posts().mount(&mock_server).await; + mock_api::user_1().mount(&mock_server).await; + mock_api::user_2().mount(&mock_server).await; + + let response = execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { posts { title user { name } } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "posts": [ + { + "title": "Post 1", + "user": { + "name": "Leanne Graham" + } + }, + { + "title": "Post 2", + "user": { + "name": "Ervin Howell" + } + } + ] + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/posts"), + Matcher::new().method("GET").path("/users/1"), + Matcher::new().method("GET").path("/users/2"), + ], + ); +} + +#[tokio::test] +async fn basic_errors() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "error": "not found" + }))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/posts")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!([{ "id": "1", "userId": "1" }])), + ) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({"error": "bad"}))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/users/1/nicknames")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({"error": "bad"}))) + .mount(&mock_server) + .await; + + let response = execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "{ users { id } posts { id user { name nickname } } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": null, + "posts": [ + { + "id": "1", + "user": { + "name": null, + "nickname": null + } + } + ] + }, + "errors": [ + { + "message": "Request failed", + "path": [ + "users" + ], + "extensions": { + "code": "CONNECTOR_FETCH", + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.users[0]" + }, + "http": { + "status": 404 + } + } + }, + { + "message": "Request failed", + "path": [ + "posts", + 0, + "user" + ], + "extensions": { + "code": "CONNECTOR_FETCH", + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.user[0]" + }, + "http": { + "status": 400 + } + } + }, + { + "message": "Request failed", + "path": [ + "posts", + 0, + "user", + "nickname" + ], + "extensions": { + "code": "CONNECTOR_FETCH", + "service": "connectors", + "connector": { + "coordinate": "connectors:User.nickname[0]" + }, + "http": { + "status": 400 + } + } + } + ] + } + "#); +} + +#[tokio::test] +async fn basic_connection_errors() { + let response = execute( + STEEL_THREAD_SCHEMA, + "http://localhost:9999", + "{ users { id } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + assert_eq!(response.get("data").unwrap(), &Value::Null); + assert_eq!(response.get("errors").unwrap().as_array().unwrap().len(), 1); + let err = response + .get("errors") + .unwrap() + .as_array() + .unwrap() + .first() + .unwrap(); + // Different OSes have different codes at the end of the message so we have to assert on the parts separately + let msg = err.get("message").unwrap().as_str().unwrap(); + assert!( + msg.starts_with( + "Connector error: HTTP fetch failed from 'connectors.json': tcp connect error:" // *nix: Connection refused, Windows: No connection could be made + ), + "got message: {msg}" + ); + assert_eq!(err.get("path").unwrap(), &serde_json::json!(["users"])); + assert_eq!( + err.get("extensions").unwrap(), + &serde_json::json!({ + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.users[0]" + }, + "code": "HTTP_CLIENT_ERROR" + }) + ); +} + +#[tokio::test] +async fn test_headers() { + let mock_server = MockServer::start().await; + mock_api::users().mount(&mock_server).await; + + execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { users { id } }", + Default::default(), + Some(json!({ + "connectors": { + "subgraphs": { + "connectors": { + "$config": { + "source": { + "val": "val-from-config-source" + }, + "connect": { + "val": "val-from-config-connect" + }, + } + } + } + } + })), + |request| { + let headers = request.router_request.headers_mut(); + headers.insert("x-rename-source", "renamed-by-source".parse().unwrap()); + headers.insert("x-rename-connect", "renamed-by-connect".parse().unwrap()); + headers.insert("x-forward", "forwarded".parse().unwrap()); + headers.append("x-forward", "forwarded-again".parse().unwrap()); + request + .context + .insert("val", String::from("val-from-request-context")) + .unwrap(); + }, + None, + ) + .await; + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new() + .method("GET") + .header( + HeaderName::from_str("x-forward").unwrap(), + HeaderValue::from_str("forwarded").unwrap(), + ) + .header( + HeaderName::from_str("x-forward").unwrap(), + HeaderValue::from_str("forwarded-again").unwrap(), + ) + .header( + HeaderName::from_str("x-new-name").unwrap(), + HeaderValue::from_str("renamed-by-connect").unwrap(), + ) + .header( + HeaderName::from_str("x-insert").unwrap(), + HeaderValue::from_str("inserted").unwrap(), + ) + .header( + HeaderName::from_str("x-insert-multi-value").unwrap(), + HeaderValue::from_str("first,second").unwrap(), + ) + .header( + HeaderName::from_str("x-config-variable-source").unwrap(), + HeaderValue::from_str("before val-from-config-source after").unwrap(), + ) + .header( + HeaderName::from_str("x-config-variable-connect").unwrap(), + HeaderValue::from_str("before val-from-config-connect after").unwrap(), + ) + .header( + HeaderName::from_str("x-context-value-source").unwrap(), + HeaderValue::from_str("before val-from-request-context after").unwrap(), + ) + .header( + HeaderName::from_str("x-context-value-connect").unwrap(), + HeaderValue::from_str("before val-from-request-context after").unwrap(), + ) + .path("/users"), + ], + ); +} + +#[tokio::test] +async fn test_override_headers_with_config() { + let mock_server = MockServer::start().await; + mock_api::users().mount(&mock_server).await; + + execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { users { id } }", + Default::default(), + Some(json!({ + "connectors": { + "subgraphs": { + "connectors": { + "$config": { + "source": { + "val": "val-from-config-source" + }, + "connect": { + "val": "val-from-config-connect" + }, + } + } + } + }, + "headers": { + "connector": { + "all": { + "request": [ + // This is additive to the existing forwarding rule + { + "propagate": { + "named": "x-forward-2", + "rename": "x-forward" + } + }, + // This is an override + { + "insert": { + "name": "x-insert", + "value": "inserted-by-config" + } + }, + // This is an override + { + "insert": { + "name": "x-insert-multi-value", + "value": "third,fourth" + } + } + ] + } + } + } + })), + |request| { + let headers = request.router_request.headers_mut(); + headers.insert("x-rename-source", "renamed-by-source".parse().unwrap()); + headers.insert("x-rename-connect", "renamed-by-connect".parse().unwrap()); + headers.insert("x-forward", "forwarded".parse().unwrap()); + headers.insert("x-forward-2", "forwarded-by-config".parse().unwrap()); + headers.append("x-forward", "forwarded-again".parse().unwrap()); + request + .context + .insert("val", String::from("val-from-request-context")) + .unwrap(); + }, + None, + ) + .await; + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new() + .method("GET") + .header( + HeaderName::from_str("x-forward").unwrap(), + HeaderValue::from_str("forwarded-by-config").unwrap(), + ) + .header( + HeaderName::from_str("x-insert").unwrap(), + HeaderValue::from_str("inserted-by-config").unwrap(), + ) + .header( + HeaderName::from_str("x-insert-multi-value").unwrap(), + HeaderValue::from_str("third,fourth").unwrap(), + ) + .path("/users"), + ], + ); +} + +#[tokio::test] +async fn should_only_send_named_header_once_when_both_config_and_schema_propagate_header() { + let mock_server = MockServer::start().await; + mock_api::users().mount(&mock_server).await; + + execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { users { id } }", + Default::default(), + Some(json!({ + "connectors": { + "subgraphs": { + "connectors": { + "$config": { + "source": { + "val": "val-from-config-source" + }, + "connect": { + "val": "val-from-config-connect" + }, + } + } + } + }, + "headers": { + "connector": { + "all": { + "request": [ + { + "propagate": { + "named": "x-forward", + } + }, + ] + } + } + } + })), + |request| { + let headers = request.router_request.headers_mut(); + headers.append("x-forward", "forwarded".parse().unwrap()); + request + .context + .insert("val", String::from("val-from-request-context")) + .unwrap(); + }, + None, + ) + .await; + + let received_requests = &mock_server.received_requests().await.unwrap(); + + assert!( + !received_requests + .iter() + .any(|r| r.headers.get_all("x-forward").iter().count() > 1), + "There should only be one instance of x-forward since the yaml config is overriding the sdl" + ); + req_asserts::matches( + received_requests, + vec![ + Matcher::new() + .method("GET") + .header( + HeaderName::from_str("x-forward").unwrap(), + HeaderValue::from_str("forwarded").unwrap(), + ) + .header( + HeaderName::from_str("x-insert").unwrap(), + HeaderValue::from_str("inserted").unwrap(), + ) + .header( + HeaderName::from_str("x-insert-multi-value").unwrap(), + HeaderValue::from_str("first,second").unwrap(), + ) + .path("/users"), + ], + ); +} + +#[tokio::test] +async fn should_only_send_matching_header_once_when_both_config_and_schema_propagate_header() { + let mock_server = MockServer::start().await; + mock_api::users().mount(&mock_server).await; + + execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { users { id } }", + Default::default(), + Some(json!({ + "connectors": { + "subgraphs": { + "connectors": { + "$config": { + "source": { + "val": "val-from-config-source" + }, + "connect": { + "val": "val-from-config-connect" + }, + } + } + } + }, + "headers": { + "connector": { + "all": { + "request": [ + { + "propagate": { + "matching": ".+?forward", + } + }, + ] + } + } + } + })), + |request| { + let headers = request.router_request.headers_mut(); + headers.append("x-forward", "forwarded".parse().unwrap()); + headers.append("y-forward", "also-forwarded".parse().unwrap()); + request + .context + .insert("val", String::from("val-from-request-context")) + .unwrap(); + }, + None, + ) + .await; + + let received_requests = &mock_server.received_requests().await.unwrap(); + + assert!( + !received_requests + .iter() + .any(|r| r.headers.get_all("x-forward").iter().count() > 1), + "There should only be one instance of x-forward since the yaml config is overriding the sdl" + ); + req_asserts::matches( + received_requests, + vec![ + Matcher::new() + .method("GET") + .header( + HeaderName::from_str("x-forward").unwrap(), + HeaderValue::from_str("forwarded").unwrap(), + ) + .header( + HeaderName::from_str("y-forward").unwrap(), + HeaderValue::from_str("also-forwarded").unwrap(), + ) + .header( + HeaderName::from_str("x-insert").unwrap(), + HeaderValue::from_str("inserted").unwrap(), + ) + .header( + HeaderName::from_str("x-insert-multi-value").unwrap(), + HeaderValue::from_str("first,second").unwrap(), + ) + .path("/users"), + ], + ); +} + +#[tokio::test] +async fn should_remove_header_when_sdl_has_insert_and_yaml_has_remove() { + let mock_server = MockServer::start().await; + mock_api::users().mount(&mock_server).await; + + execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { users { id } }", + Default::default(), + Some(json!({ + "connectors": { + "subgraphs": { + "connectors": { + "$config": { + "source": { + "val": "val-from-config-source" + }, + "connect": { + "val": "val-from-config-connect" + }, + } + } + } + }, + "headers": { + "connector": { + "all": { + "request": [ + { + "remove": { + "named": "x-insert", + } + }, + ] + } + } + } + })), + |request| { + request + .context + .insert("val", String::from("val-from-request-context")) + .unwrap(); + }, + None, + ) + .await; + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new() + .method("GET") + .header( + HeaderName::from_str("x-insert-multi-value").unwrap(), + HeaderValue::from_str("first,second").unwrap(), + ) + .path("/users"), + ], + ); +} + +#[tokio::test] +async fn test_args_and_this_in_header() { + let mock_server = MockServer::start().await; + mock_api::user_2().mount(&mock_server).await; + mock_api::user_2_nicknames().mount(&mock_server).await; + + execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { user(id: 2){ id nickname } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new() + .method("GET") + .header( + HeaderName::from_str("x-from-args").unwrap(), + HeaderValue::from_str("before 2 after").unwrap(), + ) + .path("/users/2"), + Matcher::new() + .method("GET") + .header( + HeaderName::from_str("x-from-this").unwrap(), + HeaderValue::from_str("before 2 after").unwrap(), + ) + .path("/users/2/nicknames"), + ], + ); +} + +mock! { + Subscriber {} + impl tracing_core::Subscriber for Subscriber { + fn enabled<'a>(&self, metadata: &Metadata<'a>) -> bool; + fn new_span<'a>(&self, span: &Attributes<'a>) -> Id; + fn record<'a>(&self, span: &Id, values: &Record<'a>); + fn record_follows_from(&self, span: &Id, follows: &Id); + fn event_enabled<'a>(&self, event: &Event<'a>) -> bool; + fn event<'a>(&self, event: &Event<'a>); + fn enter(&self, span: &Id); + fn exit(&self, span: &Id); + } +} + +#[tokio::test] +async fn test_tracing_connect_span() { + let mut mock_subscriber = MockSubscriber::new(); + mock_subscriber.expect_event_enabled().returning(|_| false); + mock_subscriber.expect_record().returning(|_, _| {}); + mock_subscriber + .expect_enabled() + .returning(|metadata| metadata.name() == CONNECT_SPAN_NAME); + mock_subscriber.expect_new_span().returning(|attributes| { + if attributes.metadata().name() == CONNECT_SPAN_NAME { + assert!(attributes.fields().field("apollo.connector.type").is_some()); + assert!( + attributes + .fields() + .field("apollo.connector.detail") + .is_some() + ); + assert!( + attributes + .fields() + .field("apollo.connector.coordinate") + .is_some() + ); + assert!( + attributes + .fields() + .field("apollo.connector.selection") + .is_some() + ); + assert!( + attributes + .fields() + .field("apollo.connector.source.name") + .is_some() + ); + assert!( + attributes + .fields() + .field("apollo.connector.source.detail") + .is_some() + ); + assert!(attributes.fields().field(OTEL_STATUS_CODE).is_some()); + Id::from_u64(1) + } else { + panic!("unexpected span: {}", attributes.metadata().name()); + } + }); + mock_subscriber + .expect_enter() + .with(eq(Id::from_u64(1))) + .returning(|_| {}); + mock_subscriber + .expect_exit() + .with(eq(Id::from_u64(1))) + .returning(|_| {}); + let _guard = tracing::subscriber::set_default(mock_subscriber); + + let mock_server = MockServer::start().await; + mock_api::users().mount(&mock_server).await; + + execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { users { id } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; +} + +#[tokio::test] +async fn test_operation_counter() { + async { + let mock_server = MockServer::start().await; + mock_api::users().mount(&mock_server).await; + execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { users { id name username } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/users"), + Matcher::new().method("GET").path("/users/1"), + Matcher::new().method("GET").path("/users/2"), + ], + ); + assert_counter!( + "apollo.router.operations.connectors", + 3, + connector.type = "http", + subgraph.name = "connectors" + ); + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_mutation() { + let mock_server = MockServer::start().await; + mock_api::create_user().mount(&mock_server).await; + + let response = execute( + MUTATION_SCHEMA, + &mock_server.uri(), + "mutation CreateUser($name: String!) { + createUser(name: $name) { + success + user { + id + name + } + } + }", + serde_json_bytes::json!({ "name": "New User" }) + .as_object() + .unwrap() + .clone(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "createUser": { + "success": true, + "user": { + "id": 3, + "name": "New User" + } + } + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new() + .method("POST") + .body(serde_json::json!({ "username": "New User" })) + .path("/user"), + ], + ); +} + +#[tokio::test] +async fn test_mutation_empty_body() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/user")) + .respond_with(ResponseTemplate::new(200)) + .mount(&mock_server) + .await; + + let response = execute( + MUTATION_SCHEMA, + &mock_server.uri(), + "mutation CreateUser($name: String!) { + createUser(name: $name) { + success + } + }", + serde_json_bytes::json!({ "name": "New User" }) + .as_object() + .unwrap() + .clone(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "createUser": { + "success": true + } + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new() + .method("POST") + .body(serde_json::json!({ "username": "New User" })) + .path("/user"), + ], + ); +} + +#[tokio::test] +async fn test_selection_set() { + let mock_server = MockServer::start().await; + mock_api::commits().mount(&mock_server).await; + + let response = execute( + SELECTION_SCHEMA, + &mock_server.uri(), + "query Commits($owner: String!, $repo: String!, $skipInlineFragment: Boolean!, + $skipNamedFragment: Boolean!, $skipField: Boolean!) { + commits(owner: $owner, repo: $repo) { + __typename + commit { + __typename + from_path_alias: name_from_path + ...CommitDetails @skip(if: $skipNamedFragment) + } + } + } + + fragment CommitDetails on CommitDetail { + by { + __typename + user: name @skip(if: $skipField) + name + ...on CommitAuthor @skip(if: $skipInlineFragment) { + address: email + owner + } + owner_not_fragment: owner + } + }", + serde_json_bytes::json!({ + "owner": "foo", + "repo": "bar", + "skipField": false, + "skipInlineFragment": false, + "skipNamedFragment": false + }) + .as_object() + .unwrap() + .clone(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "commits": [ + { + "__typename": "Commit", + "commit": { + "__typename": "CommitDetail", + "from_path_alias": "Foo Bar", + "by": { + "__typename": "CommitAuthor", + "user": "Foo Bar", + "name": "Foo Bar", + "address": "noone@nowhere", + "owner": "foo", + "owner_not_fragment": "foo" + } + } + } + ] + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new() + // Testing multiline headers + .header( + HeaderName::from_static("x-multiline"), + HeaderValue::from_static("multi line header"), + ) + .method("GET") + .path("/repos/foo/bar/commits"), + ], + ); +} + +#[tokio::test] +async fn test_nullability() { + let mock_server = MockServer::start().await; + mock_api::user_1_with_pet().mount(&mock_server).await; + + let response = execute( + NULLABILITY_SCHEMA, + &mock_server.uri(), + "query { user(id: 1) { id name occupation address { zip } pet { species } } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "user": { + "id": 1, + "name": "Leanne Graham", + "occupation": null, + "address": null, + "pet": { + "species": null + } + } + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![Matcher::new().method("GET").path("/users/1")], + ); +} + +#[tokio::test] +async fn test_default_argument_values() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/default-args")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!("hello"))) + .mount(&mock_server) + .await; + + let response = execute( + NULLABILITY_SCHEMA, + &mock_server.uri(), + "query { defaultArgs }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "defaultArgs": "hello" + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new() + .method("POST") + .path("/default-args") + .body(serde_json::json!({ + "str": "default", + "int": 42, + "float": 1.23, + "bool": true, + "arr": ["default"], + })), + ], + ); +} + +#[tokio::test] +async fn test_default_argument_overrides() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/default-args")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!("hello"))) + .mount(&mock_server) + .await; + + let response = execute( + NULLABILITY_SCHEMA, + &mock_server.uri(), + "query { defaultArgs(str: \"hi\" int: 108 float: 9.87 bool: false arr: [\"hi again\"]) }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "defaultArgs": "hello" + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new() + .method("POST") + .path("/default-args") + .body(serde_json::json!({ + "str": "hi", + "int": 108, + "float": 9.87, + "bool": false, + "arr": ["hi again"], + })), + ], + ); +} + +#[tokio::test] +async fn test_form_encoding() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/posts")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 }))) + .mount(&mock_server) + .await; + let uri = mock_server.uri(); + + let response = execute( + include_str!("../testdata/form-encoding.graphql"), + &uri, + "mutation { + post( + input: { + int: 1 + str: \"s\" + bool: true + id: \"id\" + + intArr: [1, 2] + strArr: [\"a\", \"b\"] + boolArr: [true, false] + idArr: [\"id1\", \"id2\"] + + obj: { + a: 1 + b: \"b\" + c: true + nested: { + d: 1 + e: \"e\" + f: true + } + } + objArr: [ + { + a: 1 + b: \"b\" + c: true + nested: { + d: 1 + e: \"e\" + f: true + } + }, + { + a: 2 + b: \"bb\" + c: false + nested: { + d: 1 + e: \"e\" + f: true + } + } + ] + } + ) + { id } + }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "post": { + "id": 1 + } + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![Matcher::new().method("POST").path("/posts")], + ); + + let reqs = mock_server.received_requests().await.unwrap(); + let body = String::from_utf8_lossy(&reqs[0].body).to_string(); + assert_eq!( + body, + "int=1&str=s&bool=true&id=id&intArr%5B0%5D=1&intArr%5B1%5D=2&strArr%5B0%5D=a&strArr%5B1%5D=b&boolArr%5B0%5D=true&boolArr%5B1%5D=false&idArr%5B0%5D=id1&idArr%5B1%5D=id2&obj%5Ba%5D=1&obj%5Bb%5D=b&obj%5Bc%5D=true&obj%5Bnested%5D%5Bd%5D=1&obj%5Bnested%5D%5Be%5D=e&obj%5Bnested%5D%5Bf%5D=true&objArr%5B0%5D%5Ba%5D=1&objArr%5B0%5D%5Bb%5D=b&objArr%5B0%5D%5Bc%5D=true&objArr%5B0%5D%5Bnested%5D%5Bd%5D=1&objArr%5B0%5D%5Bnested%5D%5Be%5D=e&objArr%5B0%5D%5Bnested%5D%5Bf%5D=true&objArr%5B1%5D%5Ba%5D=2&objArr%5B1%5D%5Bb%5D=bb&objArr%5B1%5D%5Bc%5D=false&objArr%5B1%5D%5Bnested%5D%5Bd%5D=1&objArr%5B1%5D%5Bnested%5D%5Be%5D=e&objArr%5B1%5D%5Bnested%5D%5Bf%5D=true" + ); +} + +#[tokio::test] +async fn test_no_source() { + let mock_server = MockServer::start().await; + mock_api::user_1().mount(&mock_server).await; + let uri = mock_server.uri(); + + let response = execute( + &NO_SOURCES_SCHEMA.replace("http://localhost", &uri), + &uri, + "query { user(id: 1) { id name }}", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "user": { + "id": 1, + "name": "Leanne Graham" + } + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![Matcher::new().method("GET").path("/users/1")], + ); +} + +#[tokio::test] +async fn error_not_redacted() { + let mock_server = MockServer::start().await; + mock_api::users_error().mount(&mock_server).await; + + let response = execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { users { id name username } }", + Default::default(), + Some(json!({ + "include_subgraph_errors": { + "subgraphs": { + "connectors": true + } + } + })), + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": null + }, + "errors": [ + { + "message": "Request failed", + "path": [ + "users" + ], + "extensions": { + "code": "CONNECTOR_FETCH", + "service": "connectors", + "connector": { + "coordinate": "connectors:Query.users[0]" + }, + "http": { + "status": 404 + } + } + } + ] + } + "#); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![Matcher::new().method("GET").path("/users")], + ); +} + +#[tokio::test] +async fn error_redacted() { + let mock_server = MockServer::start().await; + mock_api::users_error().mount(&mock_server).await; + + let response = execute( + STEEL_THREAD_SCHEMA, + &mock_server.uri(), + "query { users { id name username } }", + Default::default(), + Some(json!({ + "include_subgraph_errors": { + "subgraphs": { + "connectors": false + } + } + })), + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "users": null + }, + "errors": [ + { + "message": "Subgraph errors redacted", + "path": [ + "users" + ] + } + ] + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![Matcher::new().method("GET").path("/users")], + ); +} + +#[tokio::test] +async fn test_interface_object() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/itfs")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!([{ "id": 1, "c": 10 }, { "id": 2, "c": 11 }])), + ) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/itfs/1")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "id": 1, "c": 10, "d": 20 })), + ) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/itfs/2")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "id": 1, "c": 11, "d": 21 })), + ) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/itfs/1/e")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!("e1"))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/itfs/2/e")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!("e2"))) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "_entities": [{ + "__typename": "T1", + "a": "a" + }, { + "__typename": "T2", + "b": "b" + }] + } + }))) + .mount(&mock_server) + .await; + + let response = execute( + INTERFACE_OBJECT_SCHEMA, + &mock_server.uri(), + "query { itfs { __typename id c d e ... on T1 { a } ... on T2 { b } } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "itfs": [ + { + "__typename": "T1", + "id": 1, + "c": 10, + "d": 20, + "e": "e1", + "a": "a" + }, + { + "__typename": "T2", + "id": 2, + "c": 11, + "d": 21, + "e": "e2", + "b": "b" + } + ] + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/itfs"), + Matcher::new().method("GET").path("/itfs/1/e"), + Matcher::new().method("GET").path("/itfs/2/e"), + Matcher::new().method("GET").path("/itfs/1"), + Matcher::new().method("GET").path("/itfs/2"), + Matcher::new() + .method("POST") + .path("/graphql") + .body(serde_json::json!({ + "query": r#"query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Itf { __typename ... on T1 { a } ... on T2 { b } } } }"#, + "variables": { + "representations": [ + { "__typename": "Itf", "id": 1 }, + { "__typename": "Itf", "id": 2 } + ] + } + })), + ], + ); +} + +#[tokio::test] +async fn test_sources_in_context() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/coprocessor")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "control": "continue", + "version": 1, + "stage": "ExecutionRequest" + }))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/posts")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "userId": 1, "id": 1, "title": "title", "body": "body" }, + { "userId": 1, "id": 2, "title": "title", "body": "body" }] + ))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }))) + .mount(&mock_server) + .await; + let uri = mock_server.uri(); + + let _ = execute( + &QUICKSTART_SCHEMA.replace("https://jsonplaceholder.typicode.com", &mock_server.uri()), + &uri, + "query Posts { posts { id body title author { name username } } }", + Default::default(), + Some(json!({ + "connectors": { + "expose_sources_in_context": true + }, + "coprocessor": { + "url": format!("{}/coprocessor", mock_server.uri()), + "execution": { + "request": { + "context": true + } + } + } + })), + |_| {}, + Some(LicenseState::Licensed { + limits: Default::default(), + }), + ) + .await; + + let requests = &mock_server.received_requests().await.unwrap(); + let coprocessor_request = requests.first().unwrap(); + let body = coprocessor_request + .body_json::() + .unwrap(); + pretty_assertions::assert_eq!( + body.get("context") + .unwrap() + .as_object() + .unwrap() + .get("entries") + .unwrap() + .as_object() + .unwrap() + .get("apollo_connectors::sources_in_query_plan") + .unwrap(), + &serde_json_bytes::json!([ + { "subgraph_name": "connectors", "source_name": "jsonPlaceholder" } + ]) + ); +} + +#[tokio::test] +async fn test_variables() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/coprocessor")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "control": "continue", + "version": 1, + "stage": "SupergraphRequest", + "context": { + "entries": { + "value": "B" + } + } + }))) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/f")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({})) + .insert_header("value", "myothercoolheader"), + ) + .mount(&mock_server) + .await; + let uri = mock_server.uri(); + + let response = execute( + &VARIABLES_SCHEMA.replace("http://localhost:4001/", &mock_server.uri()), + &uri, + "{ f(arg: \"arg\") { arg context config sibling status extra request response f(arg: \"arg\") { arg context config sibling status } } }", + Default::default(), + Some(json!({ + "connectors": { + "subgraphs": { + "connectors": { + "$config": { + "value": "C" + } + } + } + }, + "coprocessor": { + "url": format!("{}/coprocessor", mock_server.uri()), + "supergraph": { + "request": { + "context": true + } + } + } + })), + |request| { + let headers = request.router_request.headers_mut(); + headers.insert("value", "coolheader".parse().unwrap()); + }, + Some(LicenseState::Licensed { + limits: Default::default(), + }), + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "f": { + "arg": "arg", + "context": "B", + "config": "C", + "sibling": "D", + "status": 200, + "extra": { + "arg": "arg", + "context": "B", + "config": "C", + "status": 200 + }, + "request": "coolheader", + "response": "myothercoolheader", + "f": { + "arg": "arg", + "context": "B", + "config": "C", + "sibling": "D", + "status": 200 + } + } + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("POST").path("/coprocessor"), + Matcher::new() + .method("POST") + .path("/f") + .query("arg=rg&context=B&config=C&header=coolheader") + .header(HeaderName::from_static("x-source-context"), "B".try_into().unwrap()) + .header(HeaderName::from_static("x-source-config"), "C".try_into().unwrap()) + .header(HeaderName::from_static("x-connect-arg"), "g".try_into().unwrap()) + .header(HeaderName::from_static("x-connect-context"), "B".try_into().unwrap()) + .header(HeaderName::from_static("x-connect-config"), "C".try_into().unwrap()) + .body(serde_json::json!({ "arg": "arg", "context": "B", "config": "C", "request": "coolheader" })) + , + Matcher::new() + .method("POST") + .path("/f") + .query("arg=g&context=B&config=C&sibling=D") + .header(HeaderName::from_static("x-source-context"), "B".try_into().unwrap()) + .header(HeaderName::from_static("x-source-config"), "C".try_into().unwrap()) + .header(HeaderName::from_static("x-connect-arg"), "a".try_into().unwrap()) + .header(HeaderName::from_static("x-connect-context"), "B".try_into().unwrap()) + .header(HeaderName::from_static("x-connect-config"), "C".try_into().unwrap()) + .header(HeaderName::from_static("x-connect-sibling"), "D".try_into().unwrap()) + .body(serde_json::json!({ "arg": "arg", "context": "B", "config": "C", "sibling": "D" })) + , + ], + ); +} + +#[tokio::test] +async fn should_support_using_variable_in_nested_input_argument() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/complexInputType")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!("Hello world!"))) + .mount(&mock_server) + .await; + let uri = mock_server.uri(); + let mut variables: JsonMap = serde_json_bytes::Map::new(); + variables.insert( + serde_json_bytes::ByteString::from("query"), + serde_json_bytes::Value::from("kim"), + ); + + let response = execute( + &VARIABLES_SCHEMA.replace("http://localhost:4001/", &mock_server.uri()), + &uri, + "query Query ($query: String){ complexInputType(filters: { inSpace: true, search: $query }) }", + variables, + None, + |_|{}, + None + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "complexInputType": "Hello world!" + } + } + "###); + + req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new() + .method("GET") + .path("/complexInputType") + .query("inSpace=true&search=kim"), + ], + ); +} + +#[tokio::test] +async fn should_error_when_using_arguments_that_has_not_been_defined() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/complexInputType")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!("Hello world!"))) + .mount(&mock_server) + .await; + let uri = mock_server.uri(); + let mut variables: JsonMap = serde_json_bytes::Map::new(); + variables.insert( + serde_json_bytes::ByteString::from("query_not_named_right"), + serde_json_bytes::Value::from("kim"), + ); + + let response = execute( + &VARIABLES_SCHEMA.replace("http://localhost:4001/", &mock_server.uri()), + &uri, + "query Query ($query: String){ complexInputType(filters: { inSpace: true, search: $query }) }", + variables, + None, + |_|{}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": null, + "errors": [ + { + "message": "HTTP fetch failed from 'connectors': Invalid request arguments: cannot get inputs from field arguments: variable query used in operation but not defined in variables", + "path": [], + "extensions": { + "code": "SUBREQUEST_HTTP_ERROR", + "service": "connectors", + "reason": "Invalid request arguments: cannot get inputs from field arguments: variable query used in operation but not defined in variables" + } + } + ] + } + "#); +} + +mod quickstart_tests { + use http::Uri; + + use super::*; + use crate::test_harness::http_snapshot::SnapshotServer; + + const SNAPSHOT_DIR: &str = "./src/plugins/connectors/testdata/quickstart_api_snapshots/"; + + macro_rules! map { + ($($tt:tt)*) => { + serde_json_bytes::json!($($tt)*).as_object().unwrap().clone() + }; + } + + async fn execute( + query: &str, + variables: JsonMap, + snapshot_file_name: &str, + ) -> serde_json::Value { + let snapshot_path = [SNAPSHOT_DIR, snapshot_file_name, ".json"].concat(); + + let server = SnapshotServer::spawn( + snapshot_path, + Uri::from_str("https://jsonPlaceholder.typicode.com/").unwrap(), + true, + false, + Some(vec![CONTENT_TYPE.to_string()]), + None, + ) + .await; + + super::execute( + &QUICKSTART_SCHEMA.replace("https://jsonplaceholder.typicode.com", &server.uri()), + &server.uri(), + query, + variables, + None, + |_| {}, + None, + ) + .await + } + #[tokio::test] + async fn query_1() { + let query = r#" + query Posts { + posts { + id + body + title + } + } + "#; + + let response = execute(query, Default::default(), "query_1").await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "posts": [ + { + "id": 1, + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto", + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + }, + { + "id": 2, + "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla", + "title": "qui est esse" + } + ] + } + } + "###); + } + + #[tokio::test] + async fn query_2() { + let query = r#" + query Post($postId: ID!) { + post(id: $postId) { + id + title + body + } + } + "#; + + let response = execute(query, map!({ "postId": "1" }), "query_2").await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "post": { + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + } + } + } + "###); + } + + #[tokio::test] + async fn query_3() { + let query = r#" + query PostWithAuthor($postId: ID!) { + post(id: $postId) { + id + title + body + author { + id + name + } + } + } + "#; + + let response = execute(query, map!({ "postId": "1" }), "query_3").await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "post": { + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto", + "author": { + "id": 1, + "name": "Leanne Graham" + } + } + } + } + "###); + } + + #[tokio::test] + async fn query_4() { + let query = r#" + query PostsForUser($userId: ID!) { + user(id: $userId) { + id + name + posts { + id + title + author { + id + name + } + } + } + } + "#; + + let response = execute(query, map!({ "userId": "1" }), "query_4").await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "user": { + "id": 1, + "name": "Leanne Graham", + "posts": [ + { + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "author": { + "id": 1, + "name": "Leanne Graham" + } + }, + { + "id": 2, + "title": "qui est esse", + "author": { + "id": 1, + "name": "Leanne Graham" + } + } + ] + } + } + } + "###); + } +} + +async fn execute( + schema: &str, + uri: &str, + query: &str, + variables: JsonMap, + config: Option, + mut request_mutator: impl FnMut(&mut Request), + license: Option, +) -> serde_json::Value { + let connector_uri = format!("{uri}/"); + let subgraph_uri = format!("{uri}/graphql"); + + // we cannot use Testharness because the subgraph connectors are actually extracted in YamlRouterFactory + let mut factory = YamlRouterFactory; + + let common_config = json!({ + "include_subgraph_errors": { "all": true }, + "override_subgraph_url": {"graphql": subgraph_uri}, + "connectors": { + "sources": { + "connectors.json": { + "override_url": connector_uri + } + } + } + }); + let config = if let Some(mut config) = config { + config.deep_merge(common_config); + config + } else { + common_config + }; + let config: Configuration = serde_json_bytes::from_value(config).unwrap(); + + let router_creator = factory + .create( + false, + Arc::new(config.clone()), + Arc::new(crate::spec::Schema::parse(schema, &config).unwrap()), + None, + None, + Arc::new(license.unwrap_or_default()), + ) + .await + .unwrap(); + let service = router_creator.create(); + + let mut request = supergraph::Request::fake_builder() + .query(query) + .variables(variables) + .header("x-client-header", "client-header-value") + .build() + .unwrap() + .try_into() + .unwrap(); + + request_mutator(&mut request); + + let response = service + .oneshot(request) + .await + .unwrap() + .next_response() + .await + .unwrap() + .unwrap(); + + serde_json::from_slice(&response).unwrap() +} diff --git a/apollo-router/src/plugins/connectors/tests/progressive_override.rs b/apollo-router/src/plugins/connectors/tests/progressive_override.rs new file mode 100644 index 0000000000..654bd72af3 --- /dev/null +++ b/apollo-router/src/plugins/connectors/tests/progressive_override.rs @@ -0,0 +1,57 @@ +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use super::req_asserts::Matcher; + +#[tokio::test] +async fn progressive_override() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "id": 3, "name": "Clementine Bauch" }, + { "id": 1, "name": "Leanne Graham" }, + { "id": 2, "name": "Ervin Howell" }]))) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/progressive-override.graphql"), + &mock_server.uri(), + "query { users { id name } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": [ + { + "id": 3, + "name": "Clementine Bauch" + }, + { + "id": 1, + "name": "Leanne Graham" + }, + { + "id": 2, + "name": "Ervin Howell" + } + ] + } + } + "#); + + super::req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![Matcher::new().method("GET").path("/users")], + ); +} diff --git a/apollo-router/src/plugins/connectors/tests/query_plan.rs b/apollo-router/src/plugins/connectors/tests/query_plan.rs new file mode 100644 index 0000000000..868ea7de2f --- /dev/null +++ b/apollo-router/src/plugins/connectors/tests/query_plan.rs @@ -0,0 +1,255 @@ +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use super::req_asserts::Matcher; +use super::req_asserts::Plan; + +#[tokio::test] +async fn basic_batch() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "id": 3 }, + { "id": 1 }, + { "id": 2 }]))) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/users-batch")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }, + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }]))) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/batch.graphql"), + &mock_server.uri(), + "query { users { id name username } }", + Default::default(), + Some(serde_json_bytes::json!({ + "plugins": { + "experimental.expose_query_plan": true + } + })), + |req| { + req.router_request + .headers_mut() + .append("Apollo-Expose-Query-Plan", "true".parse().unwrap()); + }, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "users": [ + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }, + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + } + ] + }, + "extensions": { + "apolloQueryPlan": { + "object": { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "connectors.json http: GET /users", + "variableUsages": [], + "operation": "{ users { __typename id } }", + "operationName": null, + "operationKind": "query", + "id": null, + "inputRewrites": null, + "outputRewrites": null, + "contextRewrites": null, + "schemaAwareHash": "55f6e99ca6971bdc3e7540bf4504f4d0deffccc59de18697edeffffd2ba2f9d8", + "authorization": { + "is_authenticated": false, + "scopes": [], + "policies": [] + } + }, + { + "kind": "Flatten", + "path": [ + "users", + "@" + ], + "node": { + "kind": "Fetch", + "serviceName": "[BATCH] connectors.json http: POST /users-batch", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ] + } + ], + "variableUsages": [], + "operation": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { name username } } }", + "operationName": null, + "operationKind": "query", + "id": null, + "inputRewrites": null, + "outputRewrites": null, + "contextRewrites": null, + "schemaAwareHash": "959ef545ae98f0ac39aef5ea1548bbf86bbccd2455793e9209df1b1021ab511a", + "authorization": { + "is_authenticated": false, + "scopes": [], + "policies": [] + } + } + } + ] + } + }, + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"connectors.json http: GET /users\") {\n {\n users {\n __typename\n id\n }\n }\n },\n Flatten(path: \"users.@\") {\n Fetch(service: \"[BATCH] connectors.json http: POST /users-batch\") {\n {\n ... on User {\n __typename\n id\n }\n } =>\n {\n ... on User {\n name\n username\n }\n }\n },\n },\n },\n}" + } + } + } + "###); + + super::req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/users"), + Matcher::new() + .method("POST") + .path("/users-batch") + .body(serde_json::json!({ "ids": [3,1,2] })), + ], + ); +} + +#[tokio::test] +async fn connect_on_type() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "id": 3 }, + { "id": 1 }, + { "id": 2 }]))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!( + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/users/2")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!( + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + }))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/users/3")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!( + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }))) + .mount(&mock_server) + .await; + + let response = super::execute( + include_str!("../testdata/connect-on-type.graphql"), + &mock_server.uri(), + "query { users { id name username } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "users": [ + { + "id": 3, + "name": "Clementine Bauch", + "username": "Samantha" + }, + { + "id": 1, + "name": "Leanne Graham", + "username": "Bret" + }, + { + "id": 2, + "name": "Ervin Howell", + "username": "Antonette" + } + ] + } + } + "#); + + Plan::Sequence(vec![ + Plan::Fetch(Matcher::new().method("GET").path("/users")), + Plan::Parallel(vec![ + Matcher::new().method("GET").path("/users/1"), + Matcher::new().method("GET").path("/users/2"), + Matcher::new().method("GET").path("/users/3"), + ]), + ]) + .assert_matches(&mock_server.received_requests().await.unwrap()); +} diff --git a/apollo-router/src/plugins/connectors/tests/quickstart.rs b/apollo-router/src/plugins/connectors/tests/quickstart.rs new file mode 100644 index 0000000000..b9139d5ac9 --- /dev/null +++ b/apollo-router/src/plugins/connectors/tests/quickstart.rs @@ -0,0 +1,278 @@ +use super::*; + +macro_rules! map { + ($($tt:tt)*) => { + serde_json_bytes::json!($($tt)*).as_object().unwrap().clone() + }; + } + +async fn execute(query: &str, variables: JsonMap) -> (serde_json::Value, MockServer) { + let mock_server = MockServer::start().await; + Mock::given(method("GET")).and(path("/posts")).respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "userId": 1, + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + }, + { + "userId": 1, + "id": 2, + "title": "qui est esse", + "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla" + }] + )), + ).mount(&mock_server).await; + Mock::given(method("GET")).and(path("/posts/1")).respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!( + { + "userId": 1, + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + } + )), + ).mount(&mock_server).await; + Mock::given(method("GET")).and(path("/posts/2")).respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "userId": 1, + "id": 2, + "title": "qui est esse", + "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla" + } + )), + ).mount(&mock_server).await; + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 1, + "name": "Leanne Graham", + "username": "Bret", + "email": "Sincere@april.biz", + "address": { + "street": "Kulas Light", + "suite": "Apt. 556", + "city": "Gwenborough", + "zipcode": "92998-3874", + "geo": { + "lat": "-37.3159", + "lng": "81.1496" + } + }, + "phone": "1-770-736-8031 x56442", + "website": "hildegard.org", + "company": { + "name": "Romaguera-Crona", + "catchPhrase": "Multi-layered client-server neural-net", + "bs": "harness real-time e-markets" + } + }))) + .mount(&mock_server) + .await; + Mock::given(method("GET")).and(path("/users/1/posts")).respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "userId": 1, + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + }, + { + "userId": 1, + "id": 2, + "title": "qui est esse", + "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla" + }] + )), + ).mount(&mock_server).await; + + let res = super::execute( + &QUICKSTART_SCHEMA.replace("https://jsonplaceholder.typicode.com", &mock_server.uri()), + &mock_server.uri(), + query, + variables, + None, + |_| {}, + None, + ) + .await; + + (res, mock_server) +} + +#[tokio::test] +async fn query_1() { + let query = r#" + query Posts { + posts { + id + body + title + } + } + "#; + + let (response, server) = execute(query, Default::default()).await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "posts": [ + { + "id": 1, + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto", + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + }, + { + "id": 2, + "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla", + "title": "qui est esse" + } + ] + } + } + "###); + + req_asserts::matches( + &server.received_requests().await.unwrap(), + vec![Matcher::new().method("GET").path("/posts")], + ); +} + +#[tokio::test] +async fn query_2() { + let query = r#" + query Post($postId: ID!) { + post(id: $postId) { + id + title + body + } + } + "#; + + let (response, server) = execute(query, map!({ "postId": "1" })).await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "post": { + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + } + } + } + "###); + + req_asserts::matches( + &server.received_requests().await.unwrap(), + vec![Matcher::new().method("GET").path("/posts/1")], + ); +} + +#[tokio::test] +async fn query_3() { + let query = r#" + query PostWithAuthor($postId: ID!) { + post(id: $postId) { + id + title + body + author { + id + name + } + } + } + "#; + + let (response, server) = execute(query, map!({ "postId": "1" })).await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "post": { + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto", + "author": { + "id": 1, + "name": "Leanne Graham" + } + } + } + } + "###); + + req_asserts::matches( + &server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/posts/1"), + Matcher::new().method("GET").path("/users/1"), + ], + ); +} + +#[tokio::test] +async fn query_4() { + let query = r#" + query PostsForUser($userId: ID!) { + user(id: $userId) { + id + name + posts { + id + title + author { + id + name + } + } + } + } + "#; + + let (response, server) = execute(query, map!({ "userId": "1" })).await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "user": { + "id": 1, + "name": "Leanne Graham", + "posts": [ + { + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "author": { + "id": 1, + "name": "Leanne Graham" + } + }, + { + "id": 2, + "title": "qui est esse", + "author": { + "id": 1, + "name": "Leanne Graham" + } + } + ] + } + } + } + "###); + + req_asserts::matches( + &server.received_requests().await.unwrap(), + vec![ + Matcher::new().method("GET").path("/users/1"), + Matcher::new().method("GET").path("/users/1/posts"), + Matcher::new().method("GET").path("/posts/1"), + Matcher::new().method("GET").path("/posts/2"), + Matcher::new().method("GET").path("/users/1"), + ], + ); +} diff --git a/apollo-router/src/plugins/connectors/tests/req_asserts.rs b/apollo-router/src/plugins/connectors/tests/req_asserts.rs new file mode 100644 index 0000000000..a92781e1eb --- /dev/null +++ b/apollo-router/src/plugins/connectors/tests/req_asserts.rs @@ -0,0 +1,198 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +use itertools::Itertools; +use wiremock::http::HeaderName; +use wiremock::http::HeaderValue; + +#[derive(Clone)] +pub(crate) struct Matcher { + method: Option, + path: Option, + query: Option, + body: Option, + headers: HashMap>, +} + +impl Matcher { + pub(crate) fn new() -> Self { + Self { + method: None, + path: None, + query: None, + body: None, + headers: Default::default(), + } + } + + pub(crate) fn method(mut self, method: &str) -> Self { + self.method = Some(method.to_string()); + self + } + + pub(crate) fn path(mut self, path: &str) -> Self { + self.path = Some(path.to_string()); + self + } + + pub(crate) fn query(mut self, query: &str) -> Self { + self.query = Some(query.to_string()); + self + } + + pub(crate) fn body(mut self, body: serde_json::Value) -> Self { + self.body = Some(body); + self + } + + pub(crate) fn header(mut self, name: HeaderName, value: HeaderValue) -> Self { + let values = self.headers.entry(name).or_default(); + values.push(value); + self + } + + fn matches(&self, request: &wiremock::Request, index: usize) -> Result<(), String> { + if let Some(method) = self.method.as_ref() + && method != &request.method.to_string() + { + return Err(format!( + "[Request {index}]: Expected method {method}, got {}", + request.method + )); + } + + if let Some(path) = self.path.as_ref() + && path != request.url.path() + { + return Err(format!( + "[Request {index}]: Expected path {path}, got {}", + request.url.path() + )); + } + + if let Some(query) = self.query.as_ref() + && query != request.url.query().unwrap_or_default() + { + return Err(format!( + "[Request {index}]: Expected query {query}, got {}", + request.url.query().unwrap_or_default() + )); + } + + if let Some(body) = self.body.as_ref() + && body != &request.body_json::().unwrap() + { + return Err(format!("[Request {index}]: incorrect body")); + } + + for (name, expected) in self.headers.iter() { + let actual: HashSet = request + .headers + .get_all(name) + .iter() + .map(|v| { + v.to_str() + .expect("non-UTF-8 header value in tests") + .to_owned() + }) + .collect(); + if actual.is_empty() { + return Err(format!( + "[Request {index}]: expected header {name}, was missing" + )); + } else { + let expected: HashSet = expected + .iter() + .map(|v| { + v.to_str() + .expect("non-UTF-8 header value in tests") + .to_owned() + }) + .collect(); + if expected != actual { + return Err(format!( + "[Request {index}]: expected header {name} to be [{}], was [{}]", + expected.iter().join(", "), + actual.iter().join(", ") + )); + } + } + } + Ok(()) + } +} + +pub(crate) fn matches(received: &[wiremock::Request], matchers: Vec) { + assert_eq!( + received.len(), + matchers.len(), + "Expected {} requests, recorded {}", + matchers.len(), + received.len() + ); + for (i, (request, matcher)) in received.iter().zip(matchers.iter()).enumerate() { + matcher.matches(request, i).unwrap(); + } +} + +/// Basically a [`crate::query_planner::PlanNode`], but specialized for testing connectors. +pub(crate) enum Plan { + Fetch(Matcher), + Sequence(Vec), + /// Fetches that can run in any order. + /// TODO: support nesting plans if we need it some day + Parallel(Vec), +} + +impl Plan { + fn len(&self) -> usize { + match self { + Plan::Fetch(_) => 1, + Plan::Sequence(plans) => plans.iter().map(Plan::len).sum(), + Plan::Parallel(matchers) => matchers.len(), + } + } + + pub(crate) fn assert_matches(self, received: &[wiremock::Request]) { + assert_eq!( + received.len(), + self.len(), + "Expected {} requests, recorded {}", + self.len(), + received.len() + ); + self.matches(received, 0); + } + + fn matches(self, received: &[wiremock::Request], index_offset: usize) { + match self { + Plan::Fetch(matcher) => { + matcher.matches(&received[0], index_offset).unwrap(); + } + Plan::Sequence(plans) => { + let mut index = 0; + for plan in plans { + let len = plan.len(); + plan.matches(&received[index..index + len], index_offset + index); + index += len; + } + } + Plan::Parallel(mut matchers) => { + // These can be received in any order, so we need to make sure _one_ of the matchers + // matches each request. + 'requests: for (request_index, request) in received.iter().enumerate() { + for (matcher_index, matcher) in matchers.iter().enumerate() { + if matcher + .matches(request, request_index + index_offset) + .is_ok() + { + matchers.remove(matcher_index); + continue 'requests; + } + } + panic!("No plan matched request {request:?}"); + } + } + } + } +} diff --git a/apollo-router/src/plugins/connectors/tests/url_properties.rs b/apollo-router/src/plugins/connectors/tests/url_properties.rs new file mode 100644 index 0000000000..a5e83b9631 --- /dev/null +++ b/apollo-router/src/plugins/connectors/tests/url_properties.rs @@ -0,0 +1,47 @@ +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use super::req_asserts::Matcher; + +#[tokio::test] +async fn url_properties() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/users/required/")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!("hi"))) + .mount(&mock_server) + .await; + + let response = super::execute( + &include_str!("../testdata/url-properties.graphql") + .replace("http://localhost", &mock_server.uri()), + &mock_server.uri(), + "query { f(req: \"required\", repeated: [1,2,3]) }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + super::req_asserts::matches( + &mock_server.received_requests().await.unwrap(), + vec![ + Matcher::new() + .method("GET") + .path("/api/v1/users/required/") + .query("q=1&repeated=1&repeated=2&repeated=3"), + ], + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "f": "hi" + } + } + "#); +} diff --git a/apollo-router/src/plugins/connectors/tests/variables.rs b/apollo-router/src/plugins/connectors/tests/variables.rs new file mode 100644 index 0000000000..6fcb8d361b --- /dev/null +++ b/apollo-router/src/plugins/connectors/tests/variables.rs @@ -0,0 +1,47 @@ +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test] +async fn test_env_var() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!("hi"))) + .mount(&mock_server) + .await; + + unsafe { + std::env::set_var( + "CONNECTORS_TESTS_VARIABLES_TEST_ENV_VAR", // unique to this test + "environment variable value", + ) + }; + + let response = super::execute( + &include_str!("../testdata/env-var.graphql") + .replace("http://localhost", &mock_server.uri()), + &mock_server.uri(), + "query { f { greeting fromEnv } }", + Default::default(), + None, + |_| {}, + None, + ) + .await; + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "f": { + "greeting": "hi", + "fromEnv": "environment variable value" + } + } + } + "###); + + unsafe { std::env::remove_var("CONNECTORS_TESTS_VARIABLES_TEST_ENV_VAR") }; +} diff --git a/apollo-router/src/plugins/connectors/tracing.rs b/apollo-router/src/plugins/connectors/tracing.rs new file mode 100644 index 0000000000..b4e45cea5a --- /dev/null +++ b/apollo-router/src/plugins/connectors/tracing.rs @@ -0,0 +1,130 @@ +use std::collections::HashMap; + +use apollo_federation::connectors::expand::Connectors; +use opentelemetry::KeyValue; +use opentelemetry::metrics::MeterProvider as _; +use opentelemetry::metrics::ObservableGauge; + +use crate::metrics::meter_provider; + +pub(crate) const CONNECTOR_TYPE_HTTP: &str = "http"; + +/// Create a gauge instrument for the number of connectors and their spec versions +pub(crate) fn connect_spec_version_instrument( + connectors: Option<&Connectors>, +) -> Option> { + connectors.map(|connectors| { + let spec_counts = connect_spec_counts(connectors); + meter_provider() + .meter("apollo/router") + .u64_observable_gauge("apollo.router.schema.connectors") + .with_description("Number connect directives in the supergraph") + .with_callback(move |observer| { + spec_counts.iter().for_each(|(spec, &count)| { + observer.observe( + count, + &[KeyValue::new("connect.spec.version", spec.clone())], + ) + }) + }) + .init() + }) +} + +/// Map from connect spec version to the number of connectors with that version +fn connect_spec_counts(connectors: &Connectors) -> HashMap { + connectors + .by_service_name + .values() + .map(|connector| connector.spec.as_str().to_string()) + .fold(HashMap::new(), |mut acc, spec| { + *acc.entry(spec).or_insert(0u64) += 1u64; + acc + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use apollo_compiler::name; + use apollo_federation::connectors::ConnectId; + use apollo_federation::connectors::ConnectSpec; + use apollo_federation::connectors::Connector; + use apollo_federation::connectors::HttpJsonTransport; + use apollo_federation::connectors::JSONSelection; + use apollo_federation::connectors::expand::Connectors; + + use crate::metrics::FutureMetricsExt as _; + use crate::plugins::connectors::tracing::connect_spec_counts; + use crate::services::connector_service::ConnectorServiceFactory; + use crate::spec::Schema; + + #[test] + fn test_connect_spec_counts() { + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(users), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("$.data").unwrap(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "label".into(), + }; + + let connectors = Connectors { + by_service_name: Arc::new( + [ + ("service_name_1".into(), connector.clone()), + ("service_name_2".into(), connector.clone()), + ("service_name_3".into(), connector), + ] + .into(), + ), + labels_by_service_name: Default::default(), + source_config_keys: Default::default(), + }; + + assert_eq!( + connect_spec_counts(&connectors), + [(ConnectSpec::V0_1.to_string(), 3u64)].into() + ); + } + + const STEEL_THREAD_SCHEMA: &str = include_str!("./testdata/steelthread.graphql"); + + #[tokio::test] + async fn test_connect_spec_version_instrument() { + async { + let config = Arc::default(); + let schema = Schema::parse(STEEL_THREAD_SCHEMA, &config).unwrap(); + let _factory = ConnectorServiceFactory::empty(Arc::from(schema)); + + assert_gauge!( + "apollo.router.schema.connectors", + 6, + connect.spec.version = "0.1" + ); + } + .with_metrics() + .await; + } +} diff --git a/apollo-router/src/plugins/coprocessor/execution.rs b/apollo-router/src/plugins/coprocessor/execution.rs index c5342db5bd..c8c640f908 100644 --- a/apollo-router/src/plugins/coprocessor/execution.rs +++ b/apollo-router/src/plugins/coprocessor/execution.rs @@ -5,27 +5,26 @@ use futures::future; use futures::stream; use schemars::JsonSchema; use serde::Deserialize; -use serde::Serialize; use tower::BoxError; use tower::ServiceBuilder; use tower_service::Service; -use super::externalize_header_map; use super::*; use crate::graphql; -use crate::layers::async_checkpoint::OneShotAsyncCheckpointLayer; +use crate::json_ext::Value; use crate::layers::ServiceBuilderExt; +use crate::layers::async_checkpoint::AsyncCheckpointLayer; use crate::plugins::coprocessor::EXTERNAL_SPAN_NAME; use crate::services::execution; /// What information is passed to a router request/response stage -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, JsonSchema)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(super) struct ExecutionRequestConf { /// Send the headers pub(super) headers: bool, /// Send the context - pub(super) context: bool, + pub(super) context: ContextConf, /// Send the body pub(super) body: bool, /// Send the SDL @@ -37,13 +36,13 @@ pub(super) struct ExecutionRequestConf { } /// What information is passed to a router request/response stage -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, JsonSchema)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(super) struct ExecutionResponseConf { /// Send the headers pub(super) headers: bool, /// Send the context - pub(super) context: bool, + pub(super) context: ContextConf, /// Send the body pub(super) body: bool, /// Send the SDL @@ -52,7 +51,7 @@ pub(super) struct ExecutionResponseConf { pub(super) status_code: bool, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, JsonSchema)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] #[serde(default)] pub(super) struct ExecutionStage { /// The request configuration @@ -68,6 +67,7 @@ impl ExecutionStage { service: execution::BoxService, coprocessor_url: String, sdl: Arc, + response_validation: bool, ) -> execution::BoxService where C: Service< @@ -86,7 +86,7 @@ impl ExecutionStage { let http_client = http_client.clone(); let sdl = sdl.clone(); - OneShotAsyncCheckpointLayer::new(move |request: execution::Request| { + AsyncCheckpointLayer::new(move |request: execution::Request| { let request_config = request_config.clone(); let coprocessor_url = coprocessor_url.clone(); let http_client = http_client.clone(); @@ -100,13 +100,12 @@ impl ExecutionStage { sdl, request, request_config, + response_validation, ) .await .map_err(|error| { succeeded = false; - tracing::error!( - "external extensibility: execution request stage error: {error}" - ); + tracing::error!("coprocessor: execution request stage error: {error}"); error }); @@ -141,13 +140,12 @@ impl ExecutionStage { sdl, response, response_config, + response_validation, ) .await .map_err(|error| { succeeded = false; - tracing::error!( - "external extensibility: execution response stage error: {error}" - ); + tracing::error!("coprocessor: execution response stage error: {error}"); error }); @@ -177,6 +175,7 @@ impl ExecutionStage { .instrument(external_service_span()) .option_layer(request_layer) .option_layer(response_layer) + .buffered() // XXX: Added during backpressure fixing .service(service) .boxed() } @@ -188,6 +187,7 @@ async fn process_execution_request_stage( sdl: Arc, mut request: execution::Request, request_config: ExecutionRequestConf, + response_validation: bool, ) -> Result, BoxError> where C: Service, Response = http::Response, Error = BoxError> @@ -210,9 +210,9 @@ where let body_to_send = request_config .body - .then(|| serde_json::from_slice::(&bytes)) + .then(|| serde_json::from_slice::(&bytes)) .transpose()?; - let context_to_send = request_config.context.then(|| request.context.clone()); + let context_to_send = request_config.context.get_context(&request.context); let sdl_to_send = request_config.sdl.then(|| sdl.clone().to_string()); let method = request_config.method.then(|| parts.method.to_string()); let query_plan = request_config @@ -232,15 +232,10 @@ where .build(); tracing::debug!(?payload, "externalized output"); - let guard = request.context.enter_active_request(); let start = Instant::now(); let co_processor_result = payload.call(http_client, &coprocessor_url).await; - let duration = start.elapsed().as_secs_f64(); - drop(guard); - tracing::info!( - histogram.apollo.router.operations.coprocessor.duration = duration, - coprocessor.stage = %PipelineStep::ExecutionRequest, - ); + let duration = start.elapsed(); + record_coprocessor_duration(PipelineStep::ExecutionRequest, duration); tracing::debug!(?co_processor_result, "co-processor returned"); let co_processor_output = co_processor_result?; @@ -256,18 +251,10 @@ where let code = control.get_http_status()?; let res = { - let graphql_response: crate::graphql::Response = - serde_json::from_value(co_processor_output.body.unwrap_or(serde_json::Value::Null)) - .unwrap_or_else(|error| { - crate::graphql::Response::builder() - .errors(vec![Error::builder() - .message(format!( - "couldn't deserialize coprocessor output body: {error}" - )) - .extension_code("EXTERNAL_DESERIALIZATION_ERROR") - .build()]) - .build() - }); + let graphql_response = { + let body_value = co_processor_output.body.unwrap_or(Value::Null); + deserialize_coprocessor_response(body_value, response_validation) + }; let mut http_response = http::Response::builder() .status(code) @@ -282,7 +269,12 @@ where }; if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = + &request_config.context + { + key = context_key_from_deprecated(key); + } execution_response .context .upsert_json_value(key, move |_current| value); @@ -297,16 +289,19 @@ where // Finally, process our reply and act on the contents. Our processing logic is // that we replace "bits" of our incoming request with the updated bits if they // are present in our co_processor_output. - - let new_body: crate::graphql::Request = match co_processor_output.body { - Some(value) => serde_json::from_value(value)?, + let new_body: graphql::Request = match co_processor_output.body { + Some(value) => serde_json_bytes::from_value(value)?, None => body, }; request.supergraph_request = http::Request::from_parts(parts, new_body); if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = &request_config.context + { + key = context_key_from_deprecated(key); + } request .context .upsert_json_value(key, move |_current| value); @@ -330,6 +325,7 @@ async fn process_execution_response_stage( sdl: Arc, response: execution::Response, response_config: ExecutionResponseConf, + response_validation: bool, ) -> Result where C: Service, Response = http::Response, Error = BoxError> @@ -345,7 +341,7 @@ where // we split the body (which is a stream) into first response + rest of responses, // for which we will implement mapping later let (first, rest): (Option, graphql::ResponseStream) = - body.into_future().await; + StreamExt::into_future(body).await; // If first is None, we return an error let first = first.ok_or_else(|| { @@ -360,9 +356,9 @@ where .transpose()?; let body_to_send = response_config .body - .then(|| serde_json::to_value(&first).expect("serialization will not fail")); + .then(|| serde_json_bytes::to_value(&first).expect("serialization will not fail")); let status_to_send = response_config.status_code.then(|| parts.status.as_u16()); - let context_to_send = response_config.context.then(|| response.context.clone()); + let context_to_send = response_config.context.get_context(&response.context); let sdl_to_send = response_config.sdl.then(|| sdl.clone().to_string()); let payload = Externalizable::execution_builder() @@ -378,33 +374,42 @@ where // Second, call our co-processor and get a reply. tracing::debug!(?payload, "externalized output"); - let guard = response.context.enter_active_request(); let start = Instant::now(); let co_processor_result = payload.call(http_client.clone(), &coprocessor_url).await; - let duration = start.elapsed().as_secs_f64(); - drop(guard); - tracing::info!( - histogram.apollo.router.operations.coprocessor.duration = duration, - coprocessor.stage = %PipelineStep::ExecutionResponse, - ); + let duration = start.elapsed(); + record_coprocessor_duration(PipelineStep::ExecutionResponse, duration); tracing::debug!(?co_processor_result, "co-processor returned"); let co_processor_output = co_processor_result?; validate_coprocessor_output(&co_processor_output, PipelineStep::ExecutionResponse)?; + // Check if the incoming GraphQL response was valid according to GraphQL spec + let incoming_payload_was_valid = + crate::plugins::coprocessor::was_incoming_payload_valid(&first, response_config.body); + // Third, process our reply and act on the contents. Our processing logic is // that we replace "bits" of our incoming response with the updated bits if they // are present in our co_processor_output. If they aren't present, just use the // bits that we sent to the co_processor. - let new_body: graphql::Response = handle_graphql_response(first, co_processor_output.body)?; + let new_body = handle_graphql_response( + first, + co_processor_output.body, + response_validation, + incoming_payload_was_valid, + )?; if let Some(control) = co_processor_output.control { parts.status = control.get_http_status()? } if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = + &response_config.context + { + key = context_key_from_deprecated(key); + } response .context .upsert_json_value(key, move |_current| value); @@ -427,14 +432,14 @@ where let generator_map_context = map_context.clone(); let generator_sdl_to_send = sdl_to_send.clone(); let generator_id = map_context.id.clone(); + let response_config_context = response_config.context.clone(); async move { let body_to_send = response_config.body.then(|| { - serde_json::to_value(&deferred_response).expect("serialization will not fail") + serde_json_bytes::to_value(&deferred_response) + .expect("serialization will not fail") }); - let context_to_send = response_config - .context - .then(|| generator_map_context.clone()); + let context_to_send = response_config_context.get_context(&generator_map_context); // Note: We deliberately DO NOT send headers or status_code even if the user has // requested them. That's because they are meaningless on a deferred response and @@ -450,31 +455,45 @@ where // Second, call our co-processor and get a reply. tracing::debug!(?payload, "externalized output"); - let guard = generator_map_context.enter_active_request(); let co_processor_result = payload .call(generator_client, &generator_coprocessor_url) .await; - drop(guard); tracing::debug!(?co_processor_result, "co-processor returned"); let co_processor_output = co_processor_result?; validate_coprocessor_output(&co_processor_output, PipelineStep::ExecutionResponse)?; + // Check if the incoming deferred GraphQL response was valid according to GraphQL spec + let incoming_payload_was_valid = + crate::plugins::coprocessor::was_incoming_payload_valid( + &deferred_response, + response_config.body, + ); + // Third, process our reply and act on the contents. Our processing logic is // that we replace "bits" of our incoming response with the updated bits if they // are present in our co_processor_output. If they aren't present, just use the // bits that we sent to the co_processor. - let new_deferred_response: graphql::Response = - handle_graphql_response(deferred_response, co_processor_output.body)?; + let new_deferred_response = handle_graphql_response( + deferred_response, + co_processor_output.body, + response_validation, + incoming_payload_was_valid, + )?; if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = + &response_config_context + { + key = context_key_from_deprecated(key); + } generator_map_context.upsert_json_value(key, move |_current| value); } } // We return the deferred_response into our stream of response chunks - Ok(new_deferred_response) + Ok::<_, BoxError>(new_deferred_response) } }) .map(|res: Result| match res { @@ -509,16 +528,17 @@ mod tests { use futures::future::BoxFuture; use http::StatusCode; - use serde_json::json; + use serde_json_bytes::json; use tower::BoxError; use tower::ServiceExt; use super::super::*; use super::*; + use crate::json_ext::Object; use crate::plugin::test::MockExecutionService; use crate::plugin::test::MockInternalHttpClientService; use crate::services::execution; - use crate::services::router::body::get_body_bytes; + use crate::services::router; use crate::services::router::body::RouterBody; #[allow(clippy::type_complexity)] @@ -571,7 +591,7 @@ mod tests { let execution_stage = ExecutionStage { request: ExecutionRequestConf { headers: false, - context: false, + context: ContextConf::Deprecated(false), body: true, sdl: false, method: false, @@ -615,7 +635,7 @@ mod tests { Ok(execution::Response::builder() .data(json!({ "test": 1234_u32 })) .errors(Vec::new()) - .extensions(crate::json_ext::Object::new()) + .extensions(Object::new()) .context(req.context) .build() .unwrap()) @@ -624,7 +644,7 @@ mod tests { let mock_http_client = mock_with_callback(move |_: http::Request| { Box::pin(async { Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( r#"{ "version": 1, "stage": "ExecutionRequest", @@ -680,12 +700,13 @@ mod tests { mock_execution_service.boxed(), "http://test".to_string(), Arc::new("".to_string()), + true, ); let request = execution::Request::fake_builder().build(); assert_eq!( - serde_json_bytes::json!({ "test": 1234_u32 }), + json!({ "test": 1234_u32 }), service .oneshot(request) .await @@ -705,7 +726,7 @@ mod tests { let execution_stage = ExecutionStage { request: ExecutionRequestConf { headers: false, - context: false, + context: ContextConf::Deprecated(false), body: true, sdl: false, method: false, @@ -720,7 +741,7 @@ mod tests { let mock_http_client = mock_with_callback(move |_: http::Request| { Box::pin(async { Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( r#"{ "version": 1, "stage": "ExecutionRequest", @@ -749,6 +770,7 @@ mod tests { mock_execution_service.boxed(), "http://test".to_string(), Arc::new("".to_string()), + true, ); let request = execution::Request::fake_builder().build(); @@ -777,7 +799,7 @@ mod tests { let execution_stage = ExecutionStage { response: ExecutionResponseConf { headers: true, - context: true, + context: ContextConf::NewContextConf(NewContextConf::All), body: true, sdl: true, status_code: false, @@ -793,7 +815,7 @@ mod tests { Ok(execution::Response::builder() .data(json!({ "test": 1234_u32 })) .errors(Vec::new()) - .extensions(crate::json_ext::Object::new()) + .extensions(Object::new()) .context(req.context) .build() .unwrap()) @@ -802,9 +824,10 @@ mod tests { let mock_http_client = mock_with_deferred_callback(move |res: http::Request| { Box::pin(async { - let deserialized_response: Externalizable = - serde_json::from_slice(&get_body_bytes(res.into_body()).await.unwrap()) - .unwrap(); + let deserialized_response: Externalizable = serde_json::from_slice( + &router::body::into_bytes(res.into_body()).await.unwrap(), + ) + .unwrap(); assert_eq!(EXTERNALIZABLE_VERSION, deserialized_response.version); assert_eq!( @@ -865,7 +888,9 @@ mod tests { "sdl": "the sdl shouldn't change" }); Ok(http::Response::builder() - .body(RouterBody::from(serde_json::to_string(&input).unwrap())) + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) .unwrap()) }) }); @@ -875,6 +900,7 @@ mod tests { mock_execution_service.boxed(), "http://test".to_string(), Arc::new("".to_string()), + true, ); let request = execution::Request::fake_builder().build(); @@ -899,7 +925,7 @@ mod tests { let body = res.response.body_mut().next().await.unwrap(); // the body should have changed: assert_eq!( - serde_json::to_value(&body).unwrap(), + serde_json_bytes::to_value(&body).unwrap(), json!({ "data": { "test": 42_u32 } }), ); } @@ -909,7 +935,7 @@ mod tests { let execution_stage = ExecutionStage { response: ExecutionResponseConf { headers: true, - context: true, + context: ContextConf::NewContextConf(NewContextConf::All), body: true, sdl: true, status_code: false, @@ -949,9 +975,10 @@ mod tests { let mock_http_client = mock_with_deferred_callback(move |res: http::Request| { Box::pin(async { - let mut deserialized_response: Externalizable = - serde_json::from_slice(&get_body_bytes(res.into_body()).await.unwrap()) - .unwrap(); + let mut deserialized_response: Externalizable = serde_json::from_slice( + &router::body::into_bytes(res.into_body()).await.unwrap(), + ) + .unwrap(); assert_eq!(EXTERNALIZABLE_VERSION, deserialized_response.version); assert_eq!( PipelineStep::ExecutionResponse.to_string(), @@ -971,13 +998,11 @@ mod tests { .unwrap() .insert( "has_next".to_string(), - serde_json::Value::from( - deserialized_response.has_next.unwrap_or_default(), - ), + Value::from(deserialized_response.has_next.unwrap_or_default()), ); Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( serde_json::to_string(&deserialized_response).unwrap_or_default(), )) .unwrap()) @@ -989,6 +1014,7 @@ mod tests { mock_execution_service.boxed(), "http://test".to_string(), Arc::new("".to_string()), + true, ); let request = execution::Request::fake_builder() @@ -999,18 +1025,437 @@ mod tests { let body = res.response.body_mut().next().await.unwrap(); assert_eq!( - serde_json::to_value(&body).unwrap(), + serde_json_bytes::to_value(&body).unwrap(), json!({ "data": { "test": 1, "has_next": true }, "hasNext": true }), ); let body = res.response.body_mut().next().await.unwrap(); assert_eq!( - serde_json::to_value(&body).unwrap(), + serde_json_bytes::to_value(&body).unwrap(), json!({ "data": { "test": 2, "has_next": true }, "hasNext": true }), ); let body = res.response.body_mut().next().await.unwrap(); assert_eq!( - serde_json::to_value(&body).unwrap(), + serde_json_bytes::to_value(&body).unwrap(), json!({ "data": { "test": 3, "has_next": false }, "hasNext": false }), ); } + + // Helper function to create execution stage for validation tests + fn create_execution_stage_for_response_validation_test() -> ExecutionStage { + ExecutionStage { + request: Default::default(), + response: ExecutionResponseConf { + headers: true, + context: ContextConf::NewContextConf(NewContextConf::All), + body: true, + sdl: true, + status_code: false, + }, + } + } + + // Helper function to create mock execution service + fn create_mock_execution_service() -> MockExecutionService { + let mut mock_execution_service = MockExecutionService::new(); + mock_execution_service + .expect_call() + .returning(|req: execution::Request| { + Ok(execution::Response::builder() + .data(json!({ "test": 1234_u32 })) + .errors(Vec::new()) + .extensions(Object::new()) + .context(req.context) + .build() + .unwrap()) + }); + mock_execution_service + } + + // Helper functions for execution request validation tests + fn create_execution_stage_for_request_validation_test() -> ExecutionStage { + ExecutionStage { + request: ExecutionRequestConf { + headers: true, + context: ContextConf::NewContextConf(NewContextConf::All), + body: true, + sdl: true, + method: true, + query_plan: true, + }, + response: Default::default(), + } + } + + // Helper function to create mock http client that returns valid GraphQL break response + fn create_mock_http_client_execution_request_valid_response() -> MockInternalHttpClientService { + mock_with_callback(move |_: http::Request| { + Box::pin(async { + let response = json!({ + "version": 1, + "stage": "ExecutionRequest", + "control": { + "break": 400 + }, + "body": { + "data": {"test": "valid_response"} + } + }); + Ok(http::Response::builder() + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) + .unwrap()) + }) + }) + } + + // Helper function to create mock http client that returns empty GraphQL break response + fn create_mock_http_client_execution_request_empty_response() -> MockInternalHttpClientService { + mock_with_callback(move |_: http::Request| { + Box::pin(async { + let response = json!({ + "version": 1, + "stage": "ExecutionRequest", + "control": { + "break": 400 + }, + "body": {} + }); + Ok(http::Response::builder() + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) + .unwrap()) + }) + }) + } + + // Helper function to create mock http client that returns invalid GraphQL break response + fn create_mock_http_client_execution_request_invalid_response() -> MockInternalHttpClientService + { + mock_with_callback(move |_: http::Request| { + Box::pin(async { + let response = json!({ + "version": 1, + "stage": "ExecutionRequest", + "control": { + "break": 400 + }, + "body": { + "errors": "this should be an array not a string" + } + }); + Ok(http::Response::builder() + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) + .unwrap()) + }) + }) + } + + // Helper function to create mock http client that returns valid GraphQL response + fn create_mock_http_client_execution_response_valid_response() -> MockInternalHttpClientService + { + mock_with_deferred_callback(move |_: http::Request| { + Box::pin(async { + let input = json!({ + "version": 1, + "stage": "ExecutionResponse", + "control": "continue", + "body": { + "data": {"test": "valid_response"} + } + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }) + } + + // Helper function to create mock http client that returns invalid GraphQL response + fn create_mock_http_client_invalid_response() -> MockInternalHttpClientService { + mock_with_deferred_callback(move |_: http::Request| { + Box::pin(async { + let input = json!({ + "version": 1, + "stage": "ExecutionResponse", + "control": "continue", + "body": { + "errors": "this should be an array not a string" + } + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }) + } + + // Helper function to create mock http client that returns empty response + fn create_mock_http_client_empty_response() -> MockInternalHttpClientService { + mock_with_deferred_callback(move |_: http::Request| { + Box::pin(async { + let input = json!({ + "version": 1, + "stage": "ExecutionResponse", + "control": "continue", + "body": {} + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }) + } + + #[tokio::test] + async fn external_plugin_execution_response_validation_disabled_invalid() { + let service = create_execution_stage_for_response_validation_test().as_service( + create_mock_http_client_invalid_response(), + create_mock_execution_service().boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + false, // Validation disabled + ); + + let request = execution::Request::fake_builder().build(); + let mut res = service.oneshot(request).await.unwrap(); + + // With validation disabled, uses permissive serde deserialization instead of strict GraphQL validation + // Falls back to original response when serde deserialization fails (string can't deserialize to Vec) + let body = res.response.body_mut().next().await.unwrap(); + assert_eq!(json!({ "test": 1234_u32 }), body.data.unwrap()); + } + + #[tokio::test] + async fn external_plugin_execution_response_validation_disabled_empty() { + let service = create_execution_stage_for_response_validation_test().as_service( + create_mock_http_client_empty_response(), + create_mock_execution_service().boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + false, // Validation disabled + ); + + let request = execution::Request::fake_builder().build(); + let mut res = service.oneshot(request).await.unwrap(); + + // With validation disabled, empty response deserializes successfully via serde + // (all fields are optional with defaults), resulting in a response with no data/errors + let body = res.response.body_mut().next().await.unwrap(); + assert_eq!(body.data, None); + assert_eq!(body.errors.len(), 0); + } + + // ===== EXECUTION REQUEST VALIDATION TESTS ===== + + #[tokio::test] + async fn external_plugin_execution_request_validation_enabled_valid() { + let service = create_execution_stage_for_request_validation_test().as_service( + create_mock_http_client_execution_request_valid_response(), + create_mock_execution_service().boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + true, // Validation enabled + ); + + let request = execution::Request::fake_builder().build(); + let mut res = service.oneshot(request).await.unwrap(); + + // Should return 400 due to break with valid GraphQL response + assert_eq!(res.response.status(), 400); + let body = res.response.body_mut().next().await.unwrap(); + assert_eq!(body.data.unwrap()["test"], "valid_response"); + } + + #[tokio::test] + async fn external_plugin_execution_request_validation_enabled_empty() { + let service = create_execution_stage_for_request_validation_test().as_service( + create_mock_http_client_execution_request_empty_response(), + create_mock_execution_service().boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + true, // Validation enabled + ); + + let request = execution::Request::fake_builder().build(); + let mut res = service.oneshot(request).await.unwrap(); + + // Should return 400 with validation error since empty response violates GraphQL spec + assert_eq!(res.response.status(), 400); + let body = res.response.body_mut().next().await.unwrap(); + assert!(!body.errors.is_empty()); + assert!( + body.errors[0] + .message + .contains("couldn't deserialize coprocessor output body") + ); + } + + #[tokio::test] + async fn external_plugin_execution_request_validation_enabled_invalid() { + let service = create_execution_stage_for_request_validation_test().as_service( + create_mock_http_client_execution_request_invalid_response(), + create_mock_execution_service().boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + true, // Validation enabled + ); + + let request = execution::Request::fake_builder().build(); + let mut res = service.oneshot(request).await.unwrap(); + + // Should return 400 with validation error since errors should be array not string + assert_eq!(res.response.status(), 400); + let body = res.response.body_mut().next().await.unwrap(); + assert!(!body.errors.is_empty()); + assert!( + body.errors[0] + .message + .contains("couldn't deserialize coprocessor output body") + ); + } + + #[tokio::test] + async fn external_plugin_execution_request_validation_disabled_valid() { + let service = create_execution_stage_for_request_validation_test().as_service( + create_mock_http_client_execution_request_valid_response(), + create_mock_execution_service().boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + false, // Validation disabled + ); + + let request = execution::Request::fake_builder().build(); + let mut res = service.oneshot(request).await.unwrap(); + + // Should return 400 due to break with valid response preserved via permissive deserialization + assert_eq!(res.response.status(), 400); + let body = res.response.body_mut().next().await.unwrap(); + assert_eq!(body.data.unwrap()["test"], "valid_response"); + } + + #[tokio::test] + async fn external_plugin_execution_request_validation_disabled_empty() { + let service = create_execution_stage_for_request_validation_test().as_service( + create_mock_http_client_execution_request_empty_response(), + create_mock_execution_service().boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + false, // Validation disabled + ); + + let request = execution::Request::fake_builder().build(); + let mut res = service.oneshot(request).await.unwrap(); + + // Should return 400 with empty response preserved via permissive deserialization + assert_eq!(res.response.status(), 400); + let body = res.response.body_mut().next().await.unwrap(); + // Empty object deserializes to GraphQL response with no data/errors + assert_eq!(body.data, None); + assert_eq!(body.errors.len(), 0); + } + + #[tokio::test] + async fn external_plugin_execution_request_validation_disabled_invalid() { + let service = create_execution_stage_for_request_validation_test().as_service( + create_mock_http_client_execution_request_invalid_response(), + create_mock_execution_service().boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + false, // Validation disabled + ); + + let request = execution::Request::fake_builder().build(); + let mut res = service.oneshot(request).await.unwrap(); + + // Should return 400 with fallback to original response since invalid structure can't deserialize + assert_eq!(res.response.status(), 400); + let body = res.response.body_mut().next().await.unwrap(); + // Falls back to original response since permissive deserialization fails too + assert!(body.data.is_some() || !body.errors.is_empty()); + } + + // ===== EXECUTION RESPONSE VALIDATION TESTS ===== + + #[tokio::test] + async fn external_plugin_execution_response_validation_enabled_valid() { + let service = create_execution_stage_for_response_validation_test().as_service( + create_mock_http_client_execution_response_valid_response(), + create_mock_execution_service().boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + true, // Validation enabled + ); + + let request = execution::Request::fake_builder().build(); + let mut res = service.oneshot(request).await.unwrap(); + + // With validation enabled, valid GraphQL response should be processed normally + let body = res.response.body_mut().next().await.unwrap(); + assert_eq!(body.data.unwrap()["test"], "valid_response"); + } + + #[tokio::test] + async fn external_plugin_execution_response_validation_enabled_empty() { + let service = create_execution_stage_for_response_validation_test().as_service( + create_mock_http_client_empty_response(), + create_mock_execution_service().boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + true, // Validation enabled + ); + + let request = execution::Request::fake_builder().build(); + + // With validation enabled, empty response should cause service call to fail due to GraphQL validation + let result = service.oneshot(request).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn external_plugin_execution_response_validation_enabled_invalid() { + let service = create_execution_stage_for_response_validation_test().as_service( + create_mock_http_client_invalid_response(), + create_mock_execution_service().boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + true, // Validation enabled + ); + + let request = execution::Request::fake_builder().build(); + + // With validation enabled, invalid GraphQL response should cause service call to fail + let result = service.oneshot(request).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn external_plugin_execution_response_validation_disabled_valid() { + let service = create_execution_stage_for_response_validation_test().as_service( + create_mock_http_client_execution_response_valid_response(), + create_mock_execution_service().boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + false, // Validation disabled + ); + + let request = execution::Request::fake_builder().build(); + let mut res = service.oneshot(request).await.unwrap(); + + // With validation disabled, valid response processed via permissive deserialization + let body = res.response.body_mut().next().await.unwrap(); + assert_eq!(body.data.unwrap()["test"], "valid_response"); + } } diff --git a/apollo-router/src/plugins/coprocessor/mod.rs b/apollo-router/src/plugins/coprocessor/mod.rs index cbc5b88213..5ca93f3358 100644 --- a/apollo-router/src/plugins/coprocessor/mod.rs +++ b/apollo-router/src/plugins/coprocessor/mod.rs @@ -1,6 +1,7 @@ //! Externalization plugin use std::collections::HashMap; +use std::collections::HashSet; use std::ops::ControlFlow; use std::str::FromStr; use std::sync::Arc; @@ -8,52 +9,55 @@ use std::time::Duration; use std::time::Instant; use bytes::Bytes; -use futures::future::ready; -use futures::stream::once; use futures::StreamExt; use futures::TryStreamExt; -use http::header; +use futures::future::ready; +use futures::stream::once; use http::HeaderMap; use http::HeaderName; use http::HeaderValue; -use hyper::client::HttpConnector; +use http::header; +use http_body_util::BodyExt; use hyper_rustls::ConfigBuilderExt; use hyper_rustls::HttpsConnector; +use hyper_util::client::legacy::connect::HttpConnector; +use hyper_util::rt::TokioExecutor; use schemars::JsonSchema; use serde::Deserialize; -use serde::Serialize; -use tower::timeout::TimeoutLayer; -use tower::util::MapFutureLayer; use tower::BoxError; use tower::Service; use tower::ServiceBuilder; use tower::ServiceExt; +use tower::timeout::TimeoutLayer; +use tower::util::MapFutureLayer; +use crate::Context; use crate::configuration::shared::Client; +use crate::context::context_key_from_deprecated; +use crate::context::context_key_to_deprecated; use crate::error::Error; use crate::graphql; -use crate::layers::async_checkpoint::OneShotAsyncCheckpointLayer; +use crate::json_ext::Value; use crate::layers::ServiceBuilderExt; +use crate::layers::async_checkpoint::AsyncCheckpointLayer; use crate::plugin::Plugin; use crate::plugin::PluginInit; use crate::plugins::telemetry::config_new::conditions::Condition; -use crate::plugins::telemetry::config_new::selectors::RouterSelector; -use crate::plugins::telemetry::config_new::selectors::SubgraphSelector; +use crate::plugins::telemetry::config_new::router::selectors::RouterSelector; +use crate::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector; use crate::plugins::traffic_shaping::Http2Config; use crate::register_plugin; use crate::services; -use crate::services::external::externalize_header_map; use crate::services::external::Control; -use crate::services::external::Externalizable; -use crate::services::external::PipelineStep; use crate::services::external::DEFAULT_EXTERNALIZATION_TIMEOUT; use crate::services::external::EXTERNALIZABLE_VERSION; -use crate::services::hickory_dns_connector::new_async_http_connector; +use crate::services::external::Externalizable; +use crate::services::external::PipelineStep; +use crate::services::external::externalize_header_map; use crate::services::hickory_dns_connector::AsyncHyperResolver; +use crate::services::hickory_dns_connector::new_async_http_connector; use crate::services::router; -use crate::services::router::body::get_body_bytes; use crate::services::router::body::RouterBody; -use crate::services::router::body::RouterBodyConverter; use crate::services::subgraph; #[cfg(test)] @@ -67,10 +71,16 @@ const POOL_IDLE_TIMEOUT_DURATION: Option = Some(Duration::from_secs(5) const COPROCESSOR_ERROR_EXTENSION: &str = "ERROR"; const COPROCESSOR_DESERIALIZATION_ERROR_EXTENSION: &str = "EXTERNAL_DESERIALIZATION_ERROR"; -type HTTPClientService = RouterBodyConverter< +type MapFn = fn(http::Response) -> http::Response; + +type HTTPClientService = tower::util::MapResponse< tower::timeout::Timeout< - hyper::Client>, RouterBody>, + hyper_util::client::legacy::Client< + HttpsConnector>, + RouterBody, + >, >, + MapFn, >; #[async_trait::async_trait] @@ -86,8 +96,7 @@ impl Plugin for CoprocessorPlugin { http_connector.enforce_http(false); let tls_config = rustls::ClientConfig::builder() - .with_safe_defaults() - .with_native_roots() + .with_native_roots()? .with_no_client_auth(); let builder = hyper_rustls::HttpsConnectorBuilder::new() @@ -102,17 +111,85 @@ impl Plugin for CoprocessorPlugin { builder.wrap_connector(http_connector) }; - let http_client = RouterBodyConverter { - inner: ServiceBuilder::new() - .layer(TimeoutLayer::new(init.config.timeout)) - .service( - hyper::Client::builder() - .http2_only(experimental_http2 == Http2Config::Http2Only) - .pool_idle_timeout(POOL_IDLE_TIMEOUT_DURATION) - .build(connector), - ), - }; + if matches!( + init.config.router.request.context, + ContextConf::Deprecated(true) + ) { + tracing::warn!( + "Configuration `coprocessor.router.request.context: true` is deprecated. See https://go.apollo.dev/o/coprocessor-context" + ); + } + if matches!( + init.config.router.response.context, + ContextConf::Deprecated(true) + ) { + tracing::warn!( + "Configuration `coprocessor.router.response.context: true` is deprecated. See https://go.apollo.dev/o/coprocessor-context" + ); + } + if matches!( + init.config.supergraph.request.context, + ContextConf::Deprecated(true) + ) { + tracing::warn!( + "Configuration `coprocessor.supergraph.request.context: true` is deprecated. See https://go.apollo.dev/o/coprocessor-context" + ); + } + if matches!( + init.config.supergraph.response.context, + ContextConf::Deprecated(true) + ) { + tracing::warn!( + "Configuration `coprocessor.supergraph.response.context: true` is deprecated. See https://go.apollo.dev/o/coprocessor-context" + ); + } + if matches!( + init.config.execution.request.context, + ContextConf::Deprecated(true) + ) { + tracing::warn!( + "Configuration `coprocessor.execution.request.context: true` is deprecated. See https://go.apollo.dev/o/coprocessor-context" + ); + } + if matches!( + init.config.execution.response.context, + ContextConf::Deprecated(true) + ) { + tracing::warn!( + "Configuration `coprocessor.execution.response.context: true` is deprecated. See https://go.apollo.dev/o/coprocessor-context" + ); + } + if matches!( + init.config.subgraph.all.request.context, + ContextConf::Deprecated(true) + ) { + tracing::warn!( + "Configuration `coprocessor.subgraph.all.request.context: true` is deprecated. See https://go.apollo.dev/o/coprocessor-context" + ); + } + if matches!( + init.config.subgraph.all.response.context, + ContextConf::Deprecated(true) + ) { + tracing::warn!( + "Configuration `coprocessor.subgraph.all.response.context: true` is deprecated. See https://go.apollo.dev/o/coprocessor-context" + ); + } + let http_client = ServiceBuilder::new() + .map_response( + |http_response: http::Response| -> http::Response { + let (parts, body) = http_response.into_parts(); + http::Response::from_parts(parts, body.map_err(axum::Error::new).boxed_unsync()) + } as MapFn, + ) + .layer(TimeoutLayer::new(init.config.timeout)) + .service( + hyper_util::client::legacy::Client::builder(TokioExecutor::new()) + .http2_only(experimental_http2 == Http2Config::Http2Only) + .pool_idle_timeout(POOL_IDLE_TIMEOUT_DURATION) + .build(connector), + ); CoprocessorPlugin::new(http_client, init.config, init.supergraph_sdl) } @@ -194,6 +271,7 @@ where service, self.configuration.url.clone(), self.sdl.clone(), + self.configuration.response_validation, ) } @@ -206,6 +284,7 @@ where service, self.configuration.url.clone(), self.sdl.clone(), + self.configuration.response_validation, ) } @@ -218,6 +297,7 @@ where service, self.configuration.url.clone(), self.sdl.clone(), + self.configuration.response_validation, ) } @@ -227,20 +307,20 @@ where service, self.configuration.url.clone(), name.to_string(), + self.configuration.response_validation, ) } } /// What information is passed to a router request/response stage -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, JsonSchema)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(super) struct RouterRequestConf { /// Condition to trigger this stage - #[serde(skip_serializing)] pub(super) condition: Option>, /// Send the headers pub(super) headers: bool, /// Send the context - pub(super) context: bool, + pub(super) context: ContextConf, /// Send the body pub(super) body: bool, /// Send the SDL @@ -252,16 +332,15 @@ pub(super) struct RouterRequestConf { } /// What information is passed to a router request/response stage -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, JsonSchema)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(super) struct RouterResponseConf { /// Condition to trigger this stage - #[serde(skip_serializing)] - pub(super) condition: Option>, + pub(super) condition: Condition, /// Send the headers pub(super) headers: bool, /// Send the context - pub(super) context: bool, + pub(super) context: ContextConf, /// Send the body pub(super) body: bool, /// Send the SDL @@ -270,16 +349,15 @@ pub(super) struct RouterResponseConf { pub(super) status_code: bool, } /// What information is passed to a subgraph request/response stage -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, JsonSchema)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(super) struct SubgraphRequestConf { /// Condition to trigger this stage - #[serde(skip_serializing)] - pub(super) condition: Option>, + pub(super) condition: Condition, /// Send the headers pub(super) headers: bool, /// Send the context - pub(super) context: bool, + pub(super) context: ContextConf, /// Send the body pub(super) body: bool, /// Send the subgraph URI @@ -293,16 +371,15 @@ pub(super) struct SubgraphRequestConf { } /// What information is passed to a subgraph request/response stage -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, JsonSchema)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(super) struct SubgraphResponseConf { /// Condition to trigger this stage - #[serde(skip_serializing)] - pub(super) condition: Option>, + pub(super) condition: Condition, /// Send the headers pub(super) headers: bool, /// Send the context - pub(super) context: bool, + pub(super) context: ContextConf, /// Send the body pub(super) body: bool, /// Send the service name @@ -325,6 +402,9 @@ struct Conf { #[schemars(with = "String", default = "default_timeout")] #[serde(default = "default_timeout")] timeout: Duration, + /// Response validation defaults to true + #[serde(default = "default_response_validation")] + response_validation: bool, /// The router stage request/response configuration #[serde(default)] router: RouterStage, @@ -339,11 +419,83 @@ struct Conf { subgraph: SubgraphStages, } +/// Configures the context +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields, untagged)] +pub(super) enum ContextConf { + /// Deprecated configuration using a boolean + Deprecated(bool), + NewContextConf(NewContextConf), +} + +impl Default for ContextConf { + fn default() -> Self { + Self::Deprecated(false) + } +} + +/// Configures the context +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub(super) enum NewContextConf { + /// Send all context keys to coprocessor + All, + /// Send all context keys using deprecated names (from router 1.x) to coprocessor + Deprecated, + /// Only send the list of context keys to coprocessor + Selective(Arc>), +} + +impl ContextConf { + pub(crate) fn get_context(&self, ctx: &Context) -> Option { + match self { + Self::NewContextConf(NewContextConf::All) => Some(ctx.clone()), + Self::NewContextConf(NewContextConf::Deprecated) | Self::Deprecated(true) => { + let mut new_ctx = Context::from_iter(ctx.iter().map(|elt| { + ( + context_key_to_deprecated(elt.key().clone()), + elt.value().clone(), + ) + })); + new_ctx.id = ctx.id.clone(); + + Some(new_ctx) + } + Self::NewContextConf(NewContextConf::Selective(context_keys)) => { + let mut new_ctx = Context::from_iter(ctx.iter().filter_map(|elt| { + if context_keys.contains(elt.key()) { + Some((elt.key().clone(), elt.value().clone())) + } else { + None + } + })); + new_ctx.id = ctx.id.clone(); + + Some(new_ctx) + } + Self::Deprecated(false) => None, + } + } +} + fn default_timeout() -> Duration { DEFAULT_EXTERNALIZATION_TIMEOUT } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, JsonSchema)] +fn default_response_validation() -> bool { + true +} + +fn record_coprocessor_duration(stage: PipelineStep, duration: Duration) { + f64_histogram!( + "apollo.router.operations.coprocessor.duration", + "Time spent waiting for the coprocessor to answer, in seconds", + duration.as_secs_f64(), + coprocessor.stage = stage.to_string() + ); +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] #[serde(default)] pub(super) struct RouterStage { /// The request configuration @@ -359,6 +511,7 @@ impl RouterStage { service: router::BoxService, coprocessor_url: String, sdl: Arc, + response_validation: bool, ) -> router::BoxService where C: Service< @@ -377,7 +530,7 @@ impl RouterStage { let http_client = http_client.clone(); let sdl = sdl.clone(); - OneShotAsyncCheckpointLayer::new(move |request: router::Request| { + AsyncCheckpointLayer::new(move |request: router::Request| { let request_config = request_config.clone(); let coprocessor_url = coprocessor_url.clone(); let http_client = http_client.clone(); @@ -391,13 +544,12 @@ impl RouterStage { sdl, request, request_config, + response_validation, ) .await .map_err(|error| { succeeded = false; - tracing::error!( - "external extensibility: router request stage error: {error}" - ); + tracing::error!("coprocessor: router request stage error: {error}"); error }); u64_counter!( @@ -430,13 +582,12 @@ impl RouterStage { sdl, response, response_config, + response_validation, ) .await .map_err(|error| { succeeded = false; - tracing::error!( - "external extensibility: router response stage error: {error}" - ); + tracing::error!("coprocessor: router response stage error: {error}"); error }); u64_counter!( @@ -465,6 +616,7 @@ impl RouterStage { .instrument(external_service_span()) .option_layer(request_layer) .option_layer(response_layer) + .buffered() // XXX: Added during backpressure fixing .service(service) .boxed() } @@ -473,7 +625,7 @@ impl RouterStage { // ----------------------------------------------------------------------------------------- /// What information is passed to a subgraph request/response stage -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, JsonSchema)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(super) struct SubgraphStages { #[serde(default)] @@ -481,7 +633,7 @@ pub(super) struct SubgraphStages { } /// What information is passed to a subgraph request/response stage -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, JsonSchema)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(super) struct SubgraphStage { #[serde(default)] @@ -497,6 +649,7 @@ impl SubgraphStage { service: subgraph::BoxService, coprocessor_url: String, service_name: String, + response_validation: bool, ) -> subgraph::BoxService where C: Service< @@ -514,7 +667,7 @@ impl SubgraphStage { let http_client = http_client.clone(); let coprocessor_url = coprocessor_url.clone(); let service_name = service_name.clone(); - OneShotAsyncCheckpointLayer::new(move |request: subgraph::Request| { + AsyncCheckpointLayer::new(move |request: subgraph::Request| { let http_client = http_client.clone(); let coprocessor_url = coprocessor_url.clone(); let service_name = service_name.clone(); @@ -528,13 +681,12 @@ impl SubgraphStage { service_name, request, request_config, + response_validation, ) .await .map_err(|error| { succeeded = false; - tracing::error!( - "external extensibility: subgraph request stage error: {error}" - ); + tracing::error!("coprocessor: subgraph request stage error: {error}"); error }); u64_counter!( @@ -568,13 +720,12 @@ impl SubgraphStage { service_name, response, response_config, + response_validation, ) .await .map_err(|error| { succeeded = false; - tracing::error!( - "external extensibility: subgraph response stage error: {error}" - ); + tracing::error!("coprocessor: subgraph response stage error: {error}"); error }); u64_counter!( @@ -603,6 +754,7 @@ impl SubgraphStage { .instrument(external_service_span()) .option_layer(request_layer) .option_layer(response_layer) + .buffered() // XXX: Added during backpressure fixing .service(service) .boxed() } @@ -615,6 +767,7 @@ async fn process_router_request_stage( sdl: Arc, mut request: router::Request, mut request_config: RouterRequestConf, + response_validation: bool, ) -> Result, BoxError> where C: Service, Response = http::Response, Error = BoxError> @@ -636,7 +789,7 @@ where // First, extract the data we need from our request and prepare our // external call. Use our configuration to figure out which data to send. let (parts, body) = request.router_request.into_parts(); - let bytes = get_body_bytes(body).await?; + let bytes = router::body::into_bytes(body).await?; let headers_to_send = request_config .headers @@ -652,7 +805,7 @@ where let path_to_send = request_config.path.then(|| parts.uri.to_string()); - let context_to_send = request_config.context.then(|| request.context.clone()); + let context_to_send = request_config.context.get_context(&request.context); let sdl_to_send = request_config.sdl.then(|| sdl.clone().to_string()); let payload = Externalizable::router_builder() @@ -668,15 +821,10 @@ where .build(); tracing::debug!(?payload, "externalized output"); - let guard = request.context.enter_active_request(); let start = Instant::now(); let co_processor_result = payload.call(http_client, &coprocessor_url).await; - let duration = start.elapsed().as_secs_f64(); - drop(guard); - tracing::info!( - histogram.apollo.router.operations.coprocessor.duration = duration, - coprocessor.stage = %PipelineStep::RouterRequest, - ); + let duration = start.elapsed(); + record_coprocessor_duration(PipelineStep::RouterRequest, duration); tracing::debug!(?co_processor_result, "co-processor returned"); let mut co_processor_output = co_processor_result?; @@ -697,26 +845,19 @@ where .body .as_ref() .and_then(|b| serde_json::from_str(b).ok()) - .unwrap_or(serde_json::Value::Null); + .unwrap_or(Value::Null); // Now we have some JSON, let's see if it's the right "shape" to create a graphql_response. // If it isn't, we create a graphql error response - let graphql_response: crate::graphql::Response = match body_as_value { - serde_json::Value::Null => crate::graphql::Response::builder() - .errors(vec![Error::builder() - .message(co_processor_output.body.take().unwrap_or_default()) - .extension_code(COPROCESSOR_ERROR_EXTENSION) - .build()]) + let graphql_response = match body_as_value { + Value::Null => graphql::Response::builder() + .errors(vec![ + Error::builder() + .message(co_processor_output.body.take().unwrap_or_default()) + .extension_code(COPROCESSOR_ERROR_EXTENSION) + .build(), + ]) .build(), - _ => serde_json::from_value(body_as_value).unwrap_or_else(|error| { - crate::graphql::Response::builder() - .errors(vec![Error::builder() - .message(format!( - "couldn't deserialize coprocessor output body: {error}" - )) - .extension_code(COPROCESSOR_DESERIALIZATION_ERROR_EXTENSION) - .build()]) - .build() - }), + _ => deserialize_coprocessor_response(body_as_value, response_validation), }; let res = router::Response::builder() @@ -736,7 +877,12 @@ where } if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = + &request_config.context + { + key = context_key_from_deprecated(key); + } res.context.upsert_json_value(key, move |_current| value); } } @@ -749,14 +895,18 @@ where // are present in our co_processor_output. let new_body = match co_processor_output.body { - Some(bytes) => RouterBody::from(bytes), - None => RouterBody::from(bytes), + Some(bytes) => router::body::from_bytes(bytes), + None => router::body::from_bytes(bytes), }; - request.router_request = http::Request::from_parts(parts, new_body.into_inner()); + request.router_request = http::Request::from_parts(parts, new_body); if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = &request_config.context + { + key = context_key_from_deprecated(key); + } request .context .upsert_json_value(key, move |_current| value); @@ -776,6 +926,7 @@ async fn process_router_response_stage( sdl: Arc, mut response: router::Response, response_config: RouterResponseConf, + _response_validation: bool, // Router responses don't implement GraphQL validation - streaming responses bypass handle_graphql_response ) -> Result where C: Service, Response = http::Response, Error = BoxError> @@ -785,12 +936,7 @@ where + 'static, >>::Future: Send + 'static, { - let should_be_executed = response_config - .condition - .as_ref() - .map(|c| c.evaluate_response(&response)) - .unwrap_or(true); - if !should_be_executed { + if !response_config.condition.evaluate_response(&response) { return Ok(response); } // split the response into parts + body @@ -798,14 +944,12 @@ where // we split the body (which is a stream) into first response + rest of responses, // for which we will implement mapping later - let (first, rest): ( - Option>, - crate::services::router::Body, - ) = body.into_future().await; + let mut stream = body.into_data_stream(); + let first = stream.next().await.transpose()?; + let rest = stream; // If first is None, or contains an error we return an error - let opt_first: Option = first.and_then(|f| f.ok()); - let bytes = match opt_first { + let bytes = match first { Some(b) => b, None => { tracing::error!( @@ -828,7 +972,7 @@ where .then(|| std::str::from_utf8(&bytes).map(|s| s.to_string())) .transpose()?; let status_to_send = response_config.status_code.then(|| parts.status.as_u16()); - let context_to_send = response_config.context.then(|| response.context.clone()); + let context_to_send = response_config.context.get_context(&response.context); let sdl_to_send = response_config.sdl.then(|| sdl.clone().to_string()); let payload = Externalizable::router_builder() @@ -843,15 +987,10 @@ where // Second, call our co-processor and get a reply. tracing::debug!(?payload, "externalized output"); - let guard = response.context.enter_active_request(); let start = Instant::now(); let co_processor_result = payload.call(http_client.clone(), &coprocessor_url).await; - let duration = start.elapsed().as_secs_f64(); - drop(guard); - tracing::info!( - histogram.apollo.router.operations.coprocessor.duration = duration, - coprocessor.stage = %PipelineStep::RouterResponse, - ); + let duration = start.elapsed(); + record_coprocessor_duration(PipelineStep::RouterResponse, duration); tracing::debug!(?co_processor_result, "co-processor returned"); let co_processor_output = co_processor_result?; @@ -864,18 +1003,23 @@ where // bits that we sent to the co_processor. let new_body = match co_processor_output.body { - Some(bytes) => RouterBody::from(bytes), - None => RouterBody::from(bytes), + Some(bytes) => router::body::from_bytes(bytes), + None => router::body::from_bytes(bytes), }; - response.response = http::Response::from_parts(parts, new_body.into_inner()); + response.response = http::Response::from_parts(parts, new_body); if let Some(control) = co_processor_output.control { *response.response.status_mut() = control.get_http_status()? } if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = + &response_config.context + { + key = context_key_from_deprecated(key); + } response .context .upsert_json_value(key, move |_current| value); @@ -902,6 +1046,7 @@ where let generator_map_context = map_context.clone(); let generator_sdl_to_send = sdl_to_send.clone(); let generator_id = map_context.id.clone(); + let context_conf = response_config.context.clone(); async move { let bytes = deferred_response.to_vec(); @@ -909,9 +1054,8 @@ where .body .then(|| String::from_utf8(bytes.clone())) .transpose()?; - let context_to_send = response_config - .context - .then(|| generator_map_context.clone()); + let generator_map_context = generator_map_context.clone(); + let context_to_send = context_conf.get_context(&generator_map_context); // Note: We deliberately DO NOT send headers or status_code even if the user has // requested them. That's because they are meaningless on a deferred response and @@ -926,11 +1070,9 @@ where // Second, call our co-processor and get a reply. tracing::debug!(?payload, "externalized output"); - let guard = generator_map_context.enter_active_request(); let co_processor_result = payload .call(generator_client, &generator_coprocessor_url) .await; - drop(guard); tracing::debug!(?co_processor_result, "co-processor returned"); let co_processor_output = co_processor_result?; @@ -946,7 +1088,12 @@ where }; if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = + &context_conf + { + key = context_key_from_deprecated(key); + } generator_map_context.upsert_json_value(key, move |_current| value); } } @@ -958,17 +1105,18 @@ where // Create our response stream which consists of the bytes from our first body chained with the // rest of the responses in our mapped stream. - let bytes = get_body_bytes(body).await.map_err(BoxError::from); - let final_stream = once(ready(bytes)).chain(mapped_stream).boxed(); - - // Finally, return a response which has a Body that wraps our stream of response chunks. - Ok(router::Response { - context, - response: http::Response::from_parts( - parts, - RouterBody::wrap_stream(final_stream).into_inner(), - ), - }) + let bytes = router::body::into_bytes(body).await.map_err(BoxError::from); + let final_stream = RouterBody::new(http_body_util::StreamBody::new( + once(ready(bytes)) + .chain(mapped_stream) + .map(|b| b.map(http_body::Frame::data).map_err(axum::Error::new)), + )); + + // Finally, return a response which has a Body that wraps our stream of response chunks + router::Response::http_response_builder() + .context(context) + .response(http::Response::from_parts(parts, final_stream)) + .build() } // ----------------------------------------------------------------------------------------------------- @@ -978,6 +1126,7 @@ async fn process_subgraph_request_stage( service_name: String, mut request: subgraph::Request, mut request_config: SubgraphRequestConf, + response_validation: bool, ) -> Result, BoxError> where C: Service, Response = http::Response, Error = BoxError> @@ -987,12 +1136,7 @@ where + 'static, >>::Future: Send + 'static, { - let should_be_executed = request_config - .condition - .as_mut() - .map(|c| c.evaluate_request(&request) == Some(true)) - .unwrap_or(true); - if !should_be_executed { + if request_config.condition.evaluate_request(&request) != Some(true) { return Ok(ControlFlow::Continue(request)); } // Call into our out of process processor with a body of our body @@ -1007,9 +1151,9 @@ where let body_to_send = request_config .body - .then(|| serde_json::to_value(&body)) + .then(|| serde_json_bytes::to_value(&body)) .transpose()?; - let context_to_send = request_config.context.then(|| request.context.clone()); + let context_to_send = request_config.context.get_context(&request.context); let uri = request_config.uri.then(|| parts.uri.to_string()); let subgraph_name = service_name.clone(); let service_name = request_config.service_name.then_some(service_name); @@ -1031,15 +1175,10 @@ where .build(); tracing::debug!(?payload, "externalized output"); - let guard = request.context.enter_active_request(); let start = Instant::now(); let co_processor_result = payload.call(http_client, &coprocessor_url).await; - let duration = start.elapsed().as_secs_f64(); - drop(guard); - tracing::info!( - histogram.apollo.router.operations.coprocessor.duration = duration, - coprocessor.stage = %PipelineStep::SubgraphRequest, - ); + let duration = start.elapsed(); + record_coprocessor_duration(PipelineStep::SubgraphRequest, duration); tracing::debug!(?co_processor_result, "co-processor returned"); let co_processor_output = co_processor_result?; @@ -1055,25 +1194,17 @@ where let code = control.get_http_status()?; let res = { - let graphql_response: crate::graphql::Response = - match co_processor_output.body.unwrap_or(serde_json::Value::Null) { - serde_json::Value::String(s) => crate::graphql::Response::builder() - .errors(vec![Error::builder() - .message(s) + let graphql_response = match co_processor_output.body.unwrap_or(Value::Null) { + Value::String(s) => graphql::Response::builder() + .errors(vec![ + Error::builder() + .message(s.as_str().to_owned()) .extension_code(COPROCESSOR_ERROR_EXTENSION) - .build()]) - .build(), - value => serde_json::from_value(value).unwrap_or_else(|error| { - crate::graphql::Response::builder() - .errors(vec![Error::builder() - .message(format!( - "couldn't deserialize coprocessor output body: {error}" - )) - .extension_code(COPROCESSOR_DESERIALIZATION_ERROR_EXTENSION) - .build()]) - .build() - }), - }; + .build(), + ]) + .build(), + value => deserialize_coprocessor_response(value, response_validation), + }; let mut http_response = http::Response::builder() .status(code) @@ -1085,12 +1216,17 @@ where let subgraph_response = subgraph::Response { response: http_response, context: request.context, - subgraph_name: Some(subgraph_name), + subgraph_name, id: request.id, }; if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = + &request_config.context + { + key = context_key_from_deprecated(key); + } subgraph_response .context .upsert_json_value(key, move |_current| value); @@ -1105,16 +1241,19 @@ where // Finally, process our reply and act on the contents. Our processing logic is // that we replace "bits" of our incoming request with the updated bits if they // are present in our co_processor_output. - - let new_body: crate::graphql::Request = match co_processor_output.body { - Some(value) => serde_json::from_value(value)?, + let new_body: graphql::Request = match co_processor_output.body { + Some(value) => serde_json_bytes::from_value(value)?, None => body, }; request.subgraph_request = http::Request::from_parts(parts, new_body); if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = &request_config.context + { + key = context_key_from_deprecated(key); + } request .context .upsert_json_value(key, move |_current| value); @@ -1138,6 +1277,7 @@ async fn process_subgraph_response_stage( service_name: String, mut response: subgraph::Response, response_config: SubgraphResponseConf, + response_validation: bool, ) -> Result where C: Service, Response = http::Response, Error = BoxError> @@ -1147,12 +1287,7 @@ where + 'static, >>::Future: Send + 'static, { - let should_be_executed = response_config - .condition - .as_ref() - .map(|c| c.evaluate_response(&response)) - .unwrap_or(true); - if !should_be_executed { + if !response_config.condition.evaluate_response(&response) { return Ok(response); } // Call into our out of process processor with a body of our body @@ -1170,9 +1305,9 @@ where let body_to_send = response_config .body - .then(|| serde_json::to_value(&body)) + .then(|| serde_json_bytes::to_value(&body)) .transpose()?; - let context_to_send = response_config.context.then(|| response.context.clone()); + let context_to_send = response_config.context.get_context(&response.context); let service_name = response_config.service_name.then_some(service_name); let subgraph_request_id = response_config .subgraph_request_id @@ -1190,28 +1325,30 @@ where .build(); tracing::debug!(?payload, "externalized output"); - let guard = response.context.enter_active_request(); let start = Instant::now(); let co_processor_result = payload.call(http_client, &coprocessor_url).await; - let duration = start.elapsed().as_secs_f64(); - drop(guard); - tracing::info!( - histogram.apollo.router.operations.coprocessor.duration = duration, - coprocessor.stage = %PipelineStep::SubgraphResponse, - ); + let duration = start.elapsed(); + record_coprocessor_duration(PipelineStep::SubgraphResponse, duration); tracing::debug!(?co_processor_result, "co-processor returned"); let co_processor_output = co_processor_result?; validate_coprocessor_output(&co_processor_output, PipelineStep::SubgraphResponse)?; + // Check if the incoming GraphQL response was valid according to GraphQL spec + let incoming_payload_was_valid = was_incoming_payload_valid(&body, response_config.body); + // Third, process our reply and act on the contents. Our processing logic is // that we replace "bits" of our incoming response with the updated bits if they // are present in our co_processor_output. If they aren't present, just use the // bits that we sent to the co_processor. - let new_body: crate::graphql::Response = - handle_graphql_response(body, co_processor_output.body)?; + let new_body = handle_graphql_response( + body, + co_processor_output.body, + response_validation, + incoming_payload_was_valid, + )?; response.response = http::Response::from_parts(parts, new_body); @@ -1220,7 +1357,12 @@ where } if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = + &response_config.context + { + key = context_key_from_deprecated(key); + } response .context .upsert_json_value(key, move |_current| value); @@ -1280,29 +1422,114 @@ pub(super) fn internalize_header_map( Ok(output) } +// Helper function to apply common post-processing to deserialized GraphQL responses +fn apply_response_post_processing( + mut new_body: graphql::Response, + original_response_body: &graphql::Response, +) -> graphql::Response { + // Needs to take back these 2 fields because it's skipped by serde + new_body.subscribed = original_response_body.subscribed; + new_body.created_at = original_response_body.created_at; + // Required because for subscription if data is Some(Null) it won't cut the subscription + // And in some languages they don't have any differences between Some(Null) and Null + if original_response_body.data == Some(Value::Null) + && new_body.data.is_none() + && new_body.subscribed == Some(true) + { + new_body.data = Some(Value::Null); + } + new_body +} + +/// Check if a GraphQL response is minimally valid according to the GraphQL spec. +/// A response is invalid if it has no data AND no errors. +pub(super) fn is_graphql_response_minimally_valid(response: &graphql::Response) -> bool { + // According to GraphQL spec, a response without data must contain at least one error + response.data.is_some() || !response.errors.is_empty() +} + +/// Check if the incoming payload was valid for conditional validation purposes. +/// Returns true if body was not sent to coprocessor OR if the response is minimally valid. +pub(super) fn was_incoming_payload_valid(response: &graphql::Response, body_sent: bool) -> bool { + if body_sent { + // If we sent the body to the coprocessor, check if it was minimally valid + is_graphql_response_minimally_valid(response) + } else { + // If we didn't send the body, assume it was valid + true + } +} + +/// Deserializes a GraphQL response from a Value with optional validation +pub(super) fn deserialize_coprocessor_response( + body_as_value: Value, + response_validation: bool, +) -> graphql::Response { + if response_validation { + graphql::Response::from_value(body_as_value).unwrap_or_else(|error| { + graphql::Response::builder() + .errors(vec![ + Error::builder() + .message(format!( + "couldn't deserialize coprocessor output body: {error}" + )) + .extension_code(COPROCESSOR_DESERIALIZATION_ERROR_EXTENSION) + .build(), + ]) + .build() + }) + } else { + // When validation is disabled, use the old behavior - just deserialize without GraphQL validation + serde_json_bytes::from_value(body_as_value).unwrap_or_else(|error| { + graphql::Response::builder() + .errors(vec![ + Error::builder() + .message(format!( + "couldn't deserialize coprocessor output body: {error}" + )) + .extension_code(COPROCESSOR_DESERIALIZATION_ERROR_EXTENSION) + .build(), + ]) + .build() + }) + } +} + pub(super) fn handle_graphql_response( original_response_body: graphql::Response, - copro_response_body: Option, + copro_response_body: Option, + response_validation: bool, + incoming_payload_was_valid: bool, ) -> Result { - let new_body: graphql::Response = match copro_response_body { + // Enable conditional validation: only validate coprocessor responses when the incoming payload was valid. + // This prevents validation failures for responses that were already invalid before being sent to the coprocessor. + // Set to false to restore the previous behavior of always validating coprocessor responses when response_validation is true. + const ENABLE_CONDITIONAL_VALIDATION: bool = true; + + // Only apply validation if response_validation is enabled AND either: + // 1. Conditional validation is disabled, OR + // 2. The incoming payload to the coprocessor was valid + let should_validate = + response_validation && (!ENABLE_CONDITIONAL_VALIDATION || incoming_payload_was_valid); + + Ok(match copro_response_body { Some(value) => { - let mut new_body: graphql::Response = serde_json::from_value(value)?; - // Needs to take back these 2 fields because it's skipped by serde - new_body.subscribed = original_response_body.subscribed; - new_body.created_at = original_response_body.created_at; - // Required because for subscription if data is Some(Null) it won't cut the subscription - // And in some languages they don't have any differences between Some(Null) and Null - if original_response_body.data == Some(serde_json_bytes::Value::Null) - && new_body.data.is_none() - && new_body.subscribed == Some(true) - { - new_body.data = Some(serde_json_bytes::Value::Null); + if should_validate { + let new_body = graphql::Response::from_value(value)?; + apply_response_post_processing(new_body, &original_response_body) + } else { + // When validation is disabled, use the old behavior - just deserialize without GraphQL validation + match serde_json_bytes::from_value::(value) { + Ok(new_body) => { + apply_response_post_processing(new_body, &original_response_body) + } + Err(_) => { + // If deserialization fails completely, return original response + original_response_body + } + } } - - new_body } None => original_response_body, - }; - - Ok(new_body) + }) } diff --git a/apollo-router/src/plugins/coprocessor/supergraph.rs b/apollo-router/src/plugins/coprocessor/supergraph.rs index d5aff80b7e..ace6d2d694 100644 --- a/apollo-router/src/plugins/coprocessor/supergraph.rs +++ b/apollo-router/src/plugins/coprocessor/supergraph.rs @@ -5,32 +5,30 @@ use futures::future; use futures::stream; use schemars::JsonSchema; use serde::Deserialize; -use serde::Serialize; use tower::BoxError; use tower::ServiceBuilder; use tower_service::Service; -use super::externalize_header_map; use super::*; use crate::graphql; -use crate::layers::async_checkpoint::OneShotAsyncCheckpointLayer; +use crate::json_ext::Value; use crate::layers::ServiceBuilderExt; +use crate::layers::async_checkpoint::AsyncCheckpointLayer; use crate::plugins::coprocessor::EXTERNAL_SPAN_NAME; use crate::plugins::telemetry::config_new::conditions::Condition; -use crate::plugins::telemetry::config_new::selectors::SupergraphSelector; +use crate::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector; use crate::services::supergraph; /// What information is passed to a router request/response stage -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, JsonSchema)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(super) struct SupergraphRequestConf { /// Condition to trigger this stage - #[serde(skip_serializing)] - pub(super) condition: Option>, + pub(super) condition: Condition, /// Send the headers pub(super) headers: bool, /// Send the context - pub(super) context: bool, + pub(super) context: ContextConf, /// Send the body pub(super) body: bool, /// Send the SDL @@ -40,16 +38,15 @@ pub(super) struct SupergraphRequestConf { } /// What information is passed to a router request/response stage -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, JsonSchema)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(super) struct SupergraphResponseConf { /// Condition to trigger this stage - #[serde(skip_serializing)] - pub(super) condition: Option>, + pub(super) condition: Condition, /// Send the headers pub(super) headers: bool, /// Send the context - pub(super) context: bool, + pub(super) context: ContextConf, /// Send the body pub(super) body: bool, /// Send the SDL @@ -58,7 +55,7 @@ pub(super) struct SupergraphResponseConf { pub(super) status_code: bool, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, JsonSchema)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] #[serde(default)] pub(super) struct SupergraphStage { /// The request configuration @@ -74,6 +71,7 @@ impl SupergraphStage { service: supergraph::BoxService, coprocessor_url: String, sdl: Arc, + response_validation: bool, ) -> supergraph::BoxService where C: Service< @@ -92,7 +90,7 @@ impl SupergraphStage { let http_client = http_client.clone(); let sdl = sdl.clone(); - OneShotAsyncCheckpointLayer::new(move |request: supergraph::Request| { + AsyncCheckpointLayer::new(move |request: supergraph::Request| { let request_config = request_config.clone(); let coprocessor_url = coprocessor_url.clone(); let http_client = http_client.clone(); @@ -106,13 +104,12 @@ impl SupergraphStage { sdl, request, request_config, + response_validation, ) .await .map_err(|error| { succeeded = false; - tracing::error!( - "external extensibility: supergraph request stage error: {error}" - ); + tracing::error!("coprocessor: supergraph request stage error: {error}"); error }); u64_counter!( @@ -146,13 +143,12 @@ impl SupergraphStage { sdl, response, response_config, + response_validation, ) .await .map_err(|error| { succeeded = false; - tracing::error!( - "external extensibility: supergraph response stage error: {error}" - ); + tracing::error!("coprocessor: supergraph response stage error: {error}"); error }); u64_counter!( @@ -181,6 +177,7 @@ impl SupergraphStage { .instrument(external_service_span()) .option_layer(request_layer) .option_layer(response_layer) + .buffered() // XXX: Added during backpressure fixing .service(service) .boxed() } @@ -192,6 +189,7 @@ async fn process_supergraph_request_stage( sdl: Arc, mut request: supergraph::Request, mut request_config: SupergraphRequestConf, + response_validation: bool, ) -> Result, BoxError> where C: Service, Response = http::Response, Error = BoxError> @@ -201,12 +199,7 @@ where + 'static, >>::Future: Send + 'static, { - let should_be_executed = request_config - .condition - .as_mut() - .map(|c| c.evaluate_request(&request) == Some(true)) - .unwrap_or(true); - if !should_be_executed { + if request_config.condition.evaluate_request(&request) != Some(true) { return Ok(ControlFlow::Continue(request)); } // Call into our out of process processor with a body of our body @@ -222,9 +215,9 @@ where let body_to_send = request_config .body - .then(|| serde_json::from_slice::(&bytes)) + .then(|| serde_json::from_slice::(&bytes)) .transpose()?; - let context_to_send = request_config.context.then(|| request.context.clone()); + let context_to_send = request_config.context.get_context(&request.context); let sdl_to_send = request_config.sdl.then(|| sdl.clone().to_string()); let method = request_config.method.then(|| parts.method.to_string()); @@ -240,15 +233,10 @@ where .build(); tracing::debug!(?payload, "externalized output"); - let guard = request.context.enter_active_request(); let start = Instant::now(); let co_processor_result = payload.call(http_client, &coprocessor_url).await; - let duration = start.elapsed().as_secs_f64(); - drop(guard); - tracing::info!( - histogram.apollo.router.operations.coprocessor.duration = duration, - coprocessor.stage = %PipelineStep::SupergraphRequest, - ); + let duration = start.elapsed(); + record_coprocessor_duration(PipelineStep::SupergraphRequest, duration); tracing::debug!(?co_processor_result, "co-processor returned"); let co_processor_output = co_processor_result?; @@ -264,18 +252,10 @@ where let code = control.get_http_status()?; let res = { - let graphql_response: crate::graphql::Response = - serde_json::from_value(co_processor_output.body.unwrap_or(serde_json::Value::Null)) - .unwrap_or_else(|error| { - crate::graphql::Response::builder() - .errors(vec![Error::builder() - .message(format!( - "couldn't deserialize coprocessor output body: {error}" - )) - .extension_code("EXTERNAL_DESERIALIZATION_ERROR") - .build()]) - .build() - }); + let graphql_response = { + let body_value = co_processor_output.body.unwrap_or(Value::Null); + deserialize_coprocessor_response(body_value, response_validation) + }; let mut http_response = http::Response::builder() .status(code) @@ -290,7 +270,12 @@ where }; if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = + &request_config.context + { + key = context_key_from_deprecated(key); + } supergraph_response .context .upsert_json_value(key, move |_current| value); @@ -305,16 +290,19 @@ where // Finally, process our reply and act on the contents. Our processing logic is // that we replace "bits" of our incoming request with the updated bits if they // are present in our co_processor_output. - - let new_body: crate::graphql::Request = match co_processor_output.body { - Some(value) => serde_json::from_value(value)?, + let new_body: graphql::Request = match co_processor_output.body { + Some(value) => serde_json_bytes::from_value(value)?, None => body, }; request.supergraph_request = http::Request::from_parts(parts, new_body); if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = &request_config.context + { + key = context_key_from_deprecated(key); + } request .context .upsert_json_value(key, move |_current| value); @@ -338,6 +326,7 @@ async fn process_supergraph_response_stage( sdl: Arc, response: supergraph::Response, response_config: SupergraphResponseConf, + response_validation: bool, ) -> Result where C: Service, Response = http::Response, Error = BoxError> @@ -347,12 +336,7 @@ where + 'static, >>::Future: Send + 'static, { - let should_be_executed = response_config - .condition - .as_ref() - .map(|c| c.evaluate_response(&response)) - .unwrap_or(true); - if !should_be_executed { + if !response_config.condition.evaluate_response(&response) { return Ok(response); } // split the response into parts + body @@ -361,7 +345,7 @@ where // we split the body (which is a stream) into first response + rest of responses, // for which we will implement mapping later let (first, rest): (Option, graphql::ResponseStream) = - body.into_future().await; + StreamExt::into_future(body).await; // If first is None, we return an error let first = first.ok_or_else(|| { @@ -376,9 +360,9 @@ where .transpose()?; let body_to_send = response_config .body - .then(|| serde_json::to_value(&first).expect("serialization will not fail")); + .then(|| serde_json_bytes::to_value(&first).expect("serialization will not fail")); let status_to_send = response_config.status_code.then(|| parts.status.as_u16()); - let context_to_send = response_config.context.then(|| response.context.clone()); + let context_to_send = response_config.context.get_context(&response.context); let sdl_to_send = response_config.sdl.then(|| sdl.clone().to_string()); let payload = Externalizable::supergraph_builder() @@ -394,33 +378,42 @@ where // Second, call our co-processor and get a reply. tracing::debug!(?payload, "externalized output"); - let guard = response.context.enter_active_request(); let start = Instant::now(); let co_processor_result = payload.call(http_client.clone(), &coprocessor_url).await; - let duration = start.elapsed().as_secs_f64(); - drop(guard); - tracing::info!( - histogram.apollo.router.operations.coprocessor.duration = duration, - coprocessor.stage = %PipelineStep::SupergraphResponse, - ); + let duration = start.elapsed(); + record_coprocessor_duration(PipelineStep::SupergraphResponse, duration); tracing::debug!(?co_processor_result, "co-processor returned"); let co_processor_output = co_processor_result?; validate_coprocessor_output(&co_processor_output, PipelineStep::SupergraphResponse)?; + // Check if the incoming GraphQL response was valid according to GraphQL spec + let incoming_payload_was_valid = + crate::plugins::coprocessor::was_incoming_payload_valid(&first, response_config.body); + // Third, process our reply and act on the contents. Our processing logic is // that we replace "bits" of our incoming response with the updated bits if they // are present in our co_processor_output. If they aren't present, just use the // bits that we sent to the co_processor. - let new_body: graphql::Response = handle_graphql_response(first, co_processor_output.body)?; + let new_body = handle_graphql_response( + first, + co_processor_output.body, + response_validation, + incoming_payload_was_valid, + )?; if let Some(control) = co_processor_output.control { parts.status = control.get_http_status()? } if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = + &response_config.context + { + key = context_key_from_deprecated(key); + } response .context .upsert_json_value(key, move |_current| value); @@ -445,20 +438,17 @@ where let generator_id = map_context.id.clone(); let should_be_executed = response_config .condition - .as_ref() - .map(|c| c.evaluate_event_response(&deferred_response, &map_context)) - .unwrap_or(true); - + .evaluate_event_response(&deferred_response, &map_context); + let response_config_context = response_config.context.clone(); async move { if !should_be_executed { return Ok(deferred_response); } let body_to_send = response_config.body.then(|| { - serde_json::to_value(&deferred_response).expect("serialization will not fail") + serde_json_bytes::to_value(&deferred_response) + .expect("serialization will not fail") }); - let context_to_send = response_config - .context - .then(|| generator_map_context.clone()); + let context_to_send = response_config_context.get_context(&generator_map_context); // Note: We deliberately DO NOT send headers or status_code even if the user has // requested them. That's because they are meaningless on a deferred response and @@ -474,11 +464,9 @@ where // Second, call our co-processor and get a reply. tracing::debug!(?payload, "externalized output"); - let guard = generator_map_context.enter_active_request(); let co_processor_result = payload .call(generator_client, &generator_coprocessor_url) .await; - drop(guard); tracing::debug!(?co_processor_result, "co-processor returned"); let co_processor_output = co_processor_result?; @@ -487,15 +475,31 @@ where PipelineStep::SupergraphResponse, )?; + // Check if the incoming deferred GraphQL response was valid according to GraphQL spec + let incoming_payload_was_valid = + crate::plugins::coprocessor::was_incoming_payload_valid( + &deferred_response, + response_config.body, + ); + // Third, process our reply and act on the contents. Our processing logic is // that we replace "bits" of our incoming response with the updated bits if they // are present in our co_processor_output. If they aren't present, just use the // bits that we sent to the co_processor. - let new_deferred_response: graphql::Response = - handle_graphql_response(deferred_response, co_processor_output.body)?; + let new_deferred_response = handle_graphql_response( + deferred_response, + co_processor_output.body, + response_validation, + incoming_payload_was_valid, + )?; if let Some(context) = co_processor_output.context { - for (key, value) in context.try_into_iter()? { + for (mut key, value) in context.try_into_iter()? { + if let ContextConf::NewContextConf(NewContextConf::Deprecated) = + &response_config_context + { + key = context_key_from_deprecated(key); + } generator_map_context.upsert_json_value(key, move |_current| value); } } @@ -536,16 +540,17 @@ mod tests { use futures::future::BoxFuture; use http::StatusCode; - use serde_json::json; + use serde_json_bytes::json; use tower::BoxError; use tower::ServiceExt; use super::super::*; use super::*; + use crate::json_ext::Object; use crate::plugin::test::MockInternalHttpClientService; use crate::plugin::test::MockSupergraphService; use crate::plugins::telemetry::config_new::conditions::SelectorOrValue; - use crate::services::router::body::get_body_bytes; + use crate::services::router; use crate::services::supergraph; #[allow(clippy::type_complexity)] @@ -599,7 +604,7 @@ mod tests { request: SupergraphRequestConf { condition: Default::default(), headers: false, - context: false, + context: ContextConf::Deprecated(false), body: true, sdl: false, method: false, @@ -642,7 +647,7 @@ mod tests { Ok(supergraph::Response::builder() .data(json!({ "test": 1234_u32 })) .errors(Vec::new()) - .extensions(crate::json_ext::Object::new()) + .extensions(Object::new()) .context(req.context) .build() .unwrap()) @@ -651,7 +656,7 @@ mod tests { let mock_http_client = mock_with_callback(move |_: http::Request| { Box::pin(async { Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( r#"{ "version": 1, "stage": "SupergraphRequest", @@ -707,12 +712,13 @@ mod tests { mock_supergraph_service.boxed(), "http://test".to_string(), Arc::new("".to_string()), + true, ); let request = supergraph::Request::fake_builder().build().unwrap(); assert_eq!( - serde_json_bytes::json!({ "test": 1234_u32 }), + json!({ "test": 1234_u32 }), service .oneshot(request) .await @@ -738,10 +744,9 @@ mod tests { default: None, }), SelectorOrValue::Value("value".to_string().into()), - ]) - .into(), + ]), headers: false, - context: false, + context: ContextConf::Deprecated(false), body: true, sdl: false, method: false, @@ -755,7 +760,7 @@ mod tests { let mock_http_client = mock_with_callback(move |_: http::Request| { Box::pin(async { Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( r#"{ "version": 1, "stage": "SupergraphRequest", @@ -785,6 +790,7 @@ mod tests { mock_supergraph_service.boxed(), "http://test".to_string(), Arc::new("".to_string()), + true, ); let request = supergraph::Request::fake_builder() @@ -819,7 +825,7 @@ mod tests { Ok(supergraph::Response::builder() .data(json!({ "test": 1234_u32 })) .errors(Vec::new()) - .extensions(crate::json_ext::Object::new()) + .extensions(Object::new()) .context(req.context) .build() .unwrap()) @@ -832,7 +838,7 @@ mod tests { let mock_http_client = mock_with_callback(move |_: http::Request| { Box::pin(async { Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( r#"{ "version": 1, "stage": "SupergraphRequest", @@ -862,6 +868,7 @@ mod tests { mock_supergraph_service.boxed(), "http://test".to_string(), Arc::new("".to_string()), + true, ); let crate::services::supergraph::Response { context, .. } = @@ -876,7 +883,7 @@ mod tests { response: SupergraphResponseConf { condition: Default::default(), headers: true, - context: true, + context: ContextConf::NewContextConf(NewContextConf::All), body: true, sdl: true, status_code: false, @@ -892,7 +899,7 @@ mod tests { Ok(supergraph::Response::builder() .data(json!({ "test": 1234_u32 })) .errors(Vec::new()) - .extensions(crate::json_ext::Object::new()) + .extensions(Object::new()) .context(req.context) .build() .unwrap()) @@ -901,8 +908,9 @@ mod tests { let mock_http_client = mock_with_deferred_callback(move |mut res: http::Request| { Box::pin(async move { - let deserialized_response: Externalizable = - serde_json::from_slice(&get_body_bytes(&mut res).await.unwrap()).unwrap(); + let deserialized_response: Externalizable = + serde_json::from_slice(&router::body::into_bytes(&mut res).await.unwrap()) + .unwrap(); assert_eq!(EXTERNALIZABLE_VERSION, deserialized_response.version); assert_eq!( @@ -963,7 +971,9 @@ mod tests { "sdl": "the sdl shouldn't change" }); Ok(http::Response::builder() - .body(RouterBody::from(serde_json::to_string(&input).unwrap())) + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) .unwrap()) }) }); @@ -973,6 +983,7 @@ mod tests { mock_supergraph_service.boxed(), "http://test".to_string(), Arc::new("".to_string()), + true, ); let request = supergraph::Request::canned_builder().build().unwrap(); @@ -997,7 +1008,7 @@ mod tests { let body = res.response.body_mut().next().await.unwrap(); // the body should have changed: assert_eq!( - serde_json::to_value(&body).unwrap(), + serde_json_bytes::to_value(&body).unwrap(), json!({ "data": { "test": 42_u32 } }), ); } @@ -1008,7 +1019,7 @@ mod tests { response: SupergraphResponseConf { condition: Default::default(), headers: true, - context: true, + context: ContextConf::NewContextConf(NewContextConf::All), body: true, sdl: true, status_code: false, @@ -1048,9 +1059,10 @@ mod tests { let mock_http_client = mock_with_deferred_callback(move |res: http::Request| { Box::pin(async { - let mut deserialized_response: Externalizable = - serde_json::from_slice(&get_body_bytes(res.into_body()).await.unwrap()) - .unwrap(); + let mut deserialized_response: Externalizable = serde_json::from_slice( + &router::body::into_bytes(res.into_body()).await.unwrap(), + ) + .unwrap(); assert_eq!(EXTERNALIZABLE_VERSION, deserialized_response.version); assert_eq!( PipelineStep::SupergraphResponse.to_string(), @@ -1070,13 +1082,11 @@ mod tests { .unwrap() .insert( "has_next".to_string(), - serde_json::Value::from( - deserialized_response.has_next.unwrap_or_default(), - ), + Value::from(deserialized_response.has_next.unwrap_or_default()), ); Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( serde_json::to_string(&deserialized_response).unwrap_or_default(), )) .unwrap()) @@ -1088,6 +1098,7 @@ mod tests { mock_supergraph_service.boxed(), "http://test".to_string(), Arc::new("".to_string()), + true, ); let request = supergraph::Request::canned_builder() @@ -1099,17 +1110,17 @@ mod tests { let body = res.response.body_mut().next().await.unwrap(); assert_eq!( - serde_json::to_value(&body).unwrap(), + serde_json_bytes::to_value(&body).unwrap(), json!({ "data": { "test": 1, "has_next": true }, "hasNext": true }), ); let body = res.response.body_mut().next().await.unwrap(); assert_eq!( - serde_json::to_value(&body).unwrap(), + serde_json_bytes::to_value(&body).unwrap(), json!({ "data": { "test": 2, "has_next": true }, "hasNext": true }), ); let body = res.response.body_mut().next().await.unwrap(); assert_eq!( - serde_json::to_value(&body).unwrap(), + serde_json_bytes::to_value(&body).unwrap(), json!({ "data": { "test": 3, "has_next": false }, "hasNext": false }), ); } @@ -1123,10 +1134,9 @@ mod tests { is_primary_response: true, }), SelectorOrValue::Value(true.into()), - ]) - .into(), + ]), headers: true, - context: true, + context: ContextConf::NewContextConf(NewContextConf::All), body: true, sdl: true, status_code: false, @@ -1166,9 +1176,10 @@ mod tests { let mock_http_client = mock_with_deferred_callback(move |res: http::Request| { Box::pin(async { - let mut deserialized_response: Externalizable = - serde_json::from_slice(&get_body_bytes(res.into_body()).await.unwrap()) - .unwrap(); + let mut deserialized_response: Externalizable = serde_json::from_slice( + &router::body::into_bytes(res.into_body()).await.unwrap(), + ) + .unwrap(); assert_eq!(EXTERNALIZABLE_VERSION, deserialized_response.version); assert_eq!( PipelineStep::SupergraphResponse.to_string(), @@ -1188,13 +1199,11 @@ mod tests { .unwrap() .insert( "has_next".to_string(), - serde_json::Value::from( - deserialized_response.has_next.unwrap_or_default(), - ), + Value::from(deserialized_response.has_next.unwrap_or_default()), ); Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( serde_json::to_string(&deserialized_response).unwrap_or_default(), )) .unwrap()) @@ -1206,6 +1215,7 @@ mod tests { mock_supergraph_service.boxed(), "http://test".to_string(), Arc::new("".to_string()), + true, ); let request = supergraph::Request::canned_builder() @@ -1217,18 +1227,438 @@ mod tests { let body = res.response.body_mut().next().await.unwrap(); assert_eq!( - serde_json::to_value(&body).unwrap(), + serde_json_bytes::to_value(&body).unwrap(), json!({ "data": { "test": 1, "has_next": true }, "hasNext": true }), ); let body = res.response.body_mut().next().await.unwrap(); assert_eq!( - serde_json::to_value(&body).unwrap(), + serde_json_bytes::to_value(&body).unwrap(), json!({ "data": { "test": 2 }, "hasNext": true }), ); let body = res.response.body_mut().next().await.unwrap(); assert_eq!( - serde_json::to_value(&body).unwrap(), + serde_json_bytes::to_value(&body).unwrap(), json!({ "data": { "test": 3 }, "hasNext": false }), ); } + + // Helper function to create supergraph stage for validation tests + fn create_supergraph_stage_for_response_validation_test() -> SupergraphStage { + SupergraphStage { + request: Default::default(), + response: SupergraphResponseConf { + condition: Condition::True, + headers: true, + context: ContextConf::NewContextConf(NewContextConf::All), + body: true, + sdl: true, + status_code: false, + }, + } + } + + // Helper function to create mock supergraph service + fn create_mock_supergraph_service() -> MockSupergraphService { + let mut mock_supergraph_service = MockSupergraphService::new(); + mock_supergraph_service + .expect_call() + .returning(|req: supergraph::Request| { + Ok(supergraph::Response::builder() + .data(json!({ "test": 1234_u32 })) + .context(req.context) + .build() + .unwrap()) + }); + mock_supergraph_service + } + + // Helper functions for supergraph request validation tests + fn create_supergraph_stage_for_request_validation_test() -> SupergraphStage { + SupergraphStage { + request: SupergraphRequestConf { + condition: Condition::True, + headers: true, + context: ContextConf::NewContextConf(NewContextConf::All), + body: true, + sdl: true, + method: true, + }, + response: Default::default(), + } + } + + // Helper function to create mock http client that returns valid GraphQL break response + fn create_mock_http_client_supergraph_request_valid_response() -> MockInternalHttpClientService + { + mock_with_callback(move |_: http::Request| { + Box::pin(async { + let response = json!({ + "version": 1, + "stage": "SupergraphRequest", + "control": { + "break": 400 + }, + "body": { + "data": {"test": "valid_response"} + } + }); + Ok(http::Response::builder() + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) + .unwrap()) + }) + }) + } + + // Helper function to create mock http client that returns empty GraphQL break response + fn create_mock_http_client_supergraph_request_empty_response() -> MockInternalHttpClientService + { + mock_with_callback(move |_: http::Request| { + Box::pin(async { + let response = json!({ + "version": 1, + "stage": "SupergraphRequest", + "control": { + "break": 400 + }, + "body": {} + }); + Ok(http::Response::builder() + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) + .unwrap()) + }) + }) + } + + // Helper function to create mock http client that returns invalid GraphQL break response + fn create_mock_http_client_supergraph_request_invalid_response() -> MockInternalHttpClientService + { + mock_with_callback(move |_: http::Request| { + Box::pin(async { + let response = json!({ + "version": 1, + "stage": "SupergraphRequest", + "control": { + "break": 400 + }, + "body": { + "errors": "this should be an array not a string" + } + }); + Ok(http::Response::builder() + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) + .unwrap()) + }) + }) + } + + // Helper function to create mock http client that returns valid GraphQL response + fn create_mock_http_client_supergraph_response_valid_response() -> MockInternalHttpClientService + { + mock_with_deferred_callback(move |_: http::Request| { + Box::pin(async { + let input = json!({ + "version": 1, + "stage": "SupergraphResponse", + "control": "continue", + "body": { + "data": {"test": "valid_response"} + } + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }) + } + + // Helper function to create mock http client that returns invalid GraphQL response + fn create_mock_http_client_invalid_response() -> MockInternalHttpClientService { + mock_with_deferred_callback(move |_: http::Request| { + Box::pin(async { + let input = json!({ + "version": 1, + "stage": "SupergraphResponse", + "control": "continue", + "body": { + "errors": "this should be an array not a string" + } + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }) + } + + // Helper function to create mock http client that returns empty response + fn create_mock_http_client_empty_response() -> MockInternalHttpClientService { + mock_with_deferred_callback(move |_: http::Request| { + Box::pin(async { + let input = json!({ + "version": 1, + "stage": "SupergraphResponse", + "control": "continue", + "body": {} + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }) + } + + #[tokio::test] + async fn external_plugin_supergraph_response_validation_disabled_invalid() { + let service = create_supergraph_stage_for_response_validation_test().as_service( + create_mock_http_client_invalid_response(), + create_mock_supergraph_service().boxed(), + "http://test".to_string(), + Arc::default(), + false, // Validation disabled + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + let mut res = service.oneshot(request).await.unwrap(); + + // With validation disabled, uses permissive serde deserialization instead of strict GraphQL validation + // Falls back to original response when serde deserialization fails (string can't deserialize to Vec) + let body = res.response.body_mut().next().await.unwrap(); + assert_eq!(json!({ "test": 1234_u32 }), body.data.unwrap()); + } + + #[tokio::test] + async fn external_plugin_supergraph_response_validation_disabled_empty() { + let service = create_supergraph_stage_for_response_validation_test().as_service( + create_mock_http_client_empty_response(), + create_mock_supergraph_service().boxed(), + "http://test".to_string(), + Arc::default(), + false, // Validation disabled + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + let mut res = service.oneshot(request).await.unwrap(); + + // With validation disabled, empty response deserializes successfully via serde + // (all fields are optional with defaults), resulting in a response with no data/errors + let body = res.response.body_mut().next().await.unwrap(); + assert_eq!(body.data, None); + assert_eq!(body.errors.len(), 0); + } + + // ===== SUPERGRAPH REQUEST VALIDATION TESTS ===== + + #[tokio::test] + async fn external_plugin_supergraph_request_validation_enabled_valid() { + let service = create_supergraph_stage_for_request_validation_test().as_service( + create_mock_http_client_supergraph_request_valid_response(), + create_mock_supergraph_service().boxed(), + "http://test".to_string(), + Arc::default(), + true, // Validation enabled + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + let mut res = service.oneshot(request).await.unwrap(); + + // Should return 400 due to break with valid GraphQL response + assert_eq!(res.response.status(), 400); + let body = res.response.body_mut().next().await.unwrap(); + assert_eq!(body.data.unwrap()["test"], "valid_response"); + } + + #[tokio::test] + async fn external_plugin_supergraph_request_validation_enabled_empty() { + let service = create_supergraph_stage_for_request_validation_test().as_service( + create_mock_http_client_supergraph_request_empty_response(), + create_mock_supergraph_service().boxed(), + "http://test".to_string(), + Arc::default(), + true, // Validation enabled + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + let mut res = service.oneshot(request).await.unwrap(); + + // Should return 400 with validation error since empty response violates GraphQL spec + assert_eq!(res.response.status(), 400); + let body = res.response.body_mut().next().await.unwrap(); + assert!(!body.errors.is_empty()); + assert!( + body.errors[0] + .message + .contains("couldn't deserialize coprocessor output body") + ); + } + + #[tokio::test] + async fn external_plugin_supergraph_request_validation_enabled_invalid() { + let service = create_supergraph_stage_for_request_validation_test().as_service( + create_mock_http_client_supergraph_request_invalid_response(), + create_mock_supergraph_service().boxed(), + "http://test".to_string(), + Arc::default(), + true, // Validation enabled + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + let mut res = service.oneshot(request).await.unwrap(); + + // Should return 400 with validation error since errors should be array not string + assert_eq!(res.response.status(), 400); + let body = res.response.body_mut().next().await.unwrap(); + assert!(!body.errors.is_empty()); + assert!( + body.errors[0] + .message + .contains("couldn't deserialize coprocessor output body") + ); + } + + #[tokio::test] + async fn external_plugin_supergraph_request_validation_disabled_valid() { + let service = create_supergraph_stage_for_request_validation_test().as_service( + create_mock_http_client_supergraph_request_valid_response(), + create_mock_supergraph_service().boxed(), + "http://test".to_string(), + Arc::default(), + false, // Validation disabled + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + let mut res = service.oneshot(request).await.unwrap(); + + // Should return 400 due to break with valid response preserved via permissive deserialization + assert_eq!(res.response.status(), 400); + let body = res.response.body_mut().next().await.unwrap(); + assert_eq!(body.data.unwrap()["test"], "valid_response"); + } + + #[tokio::test] + async fn external_plugin_supergraph_request_validation_disabled_empty() { + let service = create_supergraph_stage_for_request_validation_test().as_service( + create_mock_http_client_supergraph_request_empty_response(), + create_mock_supergraph_service().boxed(), + "http://test".to_string(), + Arc::default(), + false, // Validation disabled + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + let mut res = service.oneshot(request).await.unwrap(); + + // Should return 400 with empty response preserved via permissive deserialization + assert_eq!(res.response.status(), 400); + let body = res.response.body_mut().next().await.unwrap(); + // Empty object deserializes to GraphQL response with no data/errors + assert_eq!(body.data, None); + assert_eq!(body.errors.len(), 0); + } + + #[tokio::test] + async fn external_plugin_supergraph_request_validation_disabled_invalid() { + let service = create_supergraph_stage_for_request_validation_test().as_service( + create_mock_http_client_supergraph_request_invalid_response(), + create_mock_supergraph_service().boxed(), + "http://test".to_string(), + Arc::default(), + false, // Validation disabled + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + let mut res = service.oneshot(request).await.unwrap(); + + // Should return 400 with fallback to original response since invalid structure can't deserialize + assert_eq!(res.response.status(), 400); + let body = res.response.body_mut().next().await.unwrap(); + // Falls back to original response since permissive deserialization fails too + assert!(body.data.is_some() || !body.errors.is_empty()); + } + + // ===== SUPERGRAPH RESPONSE VALIDATION TESTS ===== + + #[tokio::test] + async fn external_plugin_supergraph_response_validation_enabled_valid() { + let service = create_supergraph_stage_for_response_validation_test().as_service( + create_mock_http_client_supergraph_response_valid_response(), + create_mock_supergraph_service().boxed(), + "http://test".to_string(), + Arc::default(), + true, // Validation enabled + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + let mut res = service.oneshot(request).await.unwrap(); + + // With validation enabled, valid GraphQL response should be processed normally + let body = res.response.body_mut().next().await.unwrap(); + assert_eq!(body.data.unwrap()["test"], "valid_response"); + } + + #[tokio::test] + async fn external_plugin_supergraph_response_validation_enabled_empty() { + let service = create_supergraph_stage_for_response_validation_test().as_service( + create_mock_http_client_empty_response(), + create_mock_supergraph_service().boxed(), + "http://test".to_string(), + Arc::default(), + true, // Validation enabled + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + + // With validation enabled, empty response should cause service call to fail due to GraphQL validation + let result = service.oneshot(request).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn external_plugin_supergraph_response_validation_enabled_invalid() { + let service = create_supergraph_stage_for_response_validation_test().as_service( + create_mock_http_client_invalid_response(), + create_mock_supergraph_service().boxed(), + "http://test".to_string(), + Arc::default(), + true, // Validation enabled + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + + // With validation enabled, invalid GraphQL response should cause service call to fail + let result = service.oneshot(request).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn external_plugin_supergraph_response_validation_disabled_valid() { + let service = create_supergraph_stage_for_response_validation_test().as_service( + create_mock_http_client_supergraph_response_valid_response(), + create_mock_supergraph_service().boxed(), + "http://test".to_string(), + Arc::default(), + false, // Validation disabled + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + let mut res = service.oneshot(request).await.unwrap(); + + // With validation disabled, valid response processed via permissive deserialization + let body = res.response.body_mut().next().await.unwrap(); + assert_eq!(body.data.unwrap()["test"], "valid_response"); + } } diff --git a/apollo-router/src/plugins/coprocessor/test.rs b/apollo-router/src/plugins/coprocessor/test.rs index a2b9374fb9..6f6fff0129 100644 --- a/apollo-router/src/plugins/coprocessor/test.rs +++ b/apollo-router/src/plugins/coprocessor/test.rs @@ -4,39 +4,45 @@ mod tests { use std::sync::Arc; use futures::future::BoxFuture; - use http::header::ACCEPT; - use http::header::CONTENT_TYPE; use http::HeaderMap; use http::HeaderValue; use http::Method; use http::StatusCode; + use http::header::ACCEPT; + use http::header::CONTENT_TYPE; use mime::APPLICATION_JSON; use mime::TEXT_HTML; use router::body::RouterBody; - use serde_json::json; - use serde_json_bytes::Value; + use serde_json_bytes::json; use services::subgraph::SubgraphRequestId; use tower::BoxError; use tower::ServiceExt; use super::super::*; + use crate::assert_response_eq_ignoring_error_id; + use crate::graphql::Response; + use crate::json_ext::Object; + use crate::json_ext::Value; use crate::plugin::test::MockInternalHttpClientService; use crate::plugin::test::MockRouterService; use crate::plugin::test::MockSubgraphService; use crate::plugin::test::MockSupergraphService; + use crate::plugins::coprocessor::handle_graphql_response; + use crate::plugins::coprocessor::is_graphql_response_minimally_valid; use crate::plugins::coprocessor::supergraph::SupergraphResponseConf; use crate::plugins::coprocessor::supergraph::SupergraphStage; + use crate::plugins::coprocessor::was_incoming_payload_valid; use crate::plugins::telemetry::config_new::conditions::SelectorOrValue; + use crate::services::external::EXTERNALIZABLE_VERSION; use crate::services::external::Externalizable; use crate::services::external::PipelineStep; - use crate::services::external::EXTERNALIZABLE_VERSION; - use crate::services::router::body::get_body_bytes; + use crate::services::router; use crate::services::subgraph; use crate::services::supergraph; #[tokio::test] async fn load_plugin() { - let config = json!({ + let config = serde_json::json!({ "coprocessor": { "url": "http://127.0.0.1:8081" } @@ -54,46 +60,20 @@ mod tests { #[tokio::test] async fn unknown_fields_are_denied() { - let config = json!({ + let config = serde_json::json!({ "coprocessor": { "url": "http://127.0.0.1:8081", "thisFieldDoesntExist": true } }); // Build a test harness. Usually we'd use this and send requests to - // it, but in this case it's enough to build the harness to see our - // output when our service registers. - assert!(crate::TestHarness::builder() - .configuration_json(config) - .unwrap() - .build_router() - .await - .is_err()); - } - - #[tokio::test] - async fn external_plugin_with_stages_wont_load_without_graph_ref() { - let config = json!({ - "coprocessor": { - "url": "http://127.0.0.1:8081", - "stages": { - "subgraph": { - "request": { - "uri": true - } - } - }, - } - }); - // Build a test harness. Usually we'd use this and send requests to - // it, but in this case it's enough to build the harness to see our - // output when our service registers. - assert!(crate::TestHarness::builder() - .configuration_json(config) - .unwrap() - .build_router() - .await - .is_err()); + // it, but in this case it's enough to start building the harness and + // ensure building the Configuration fails. + assert!( + crate::TestHarness::builder() + .configuration_json(config) + .is_err() + ); } #[tokio::test] @@ -102,7 +82,7 @@ mod tests { request: RouterRequestConf { condition: Default::default(), headers: true, - context: true, + context: ContextConf::NewContextConf(NewContextConf::All), body: true, sdl: true, path: false, @@ -132,7 +112,9 @@ mod tests { "sdl": "the sdl shouldnt change" }); Ok(http::Response::builder() - .body(RouterBody::from(serde_json::to_string(&input).unwrap())) + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) .unwrap()) }) }); @@ -142,6 +124,7 @@ mod tests { mock_router_service.boxed(), "http://test".to_string(), Arc::new("".to_string()), + true, ); let request = supergraph::Request::canned_builder().build().unwrap(); @@ -162,7 +145,7 @@ mod tests { request: RouterRequestConf { condition: Default::default(), headers: true, - context: true, + context: ContextConf::NewContextConf(NewContextConf::All), body: true, sdl: true, path: false, @@ -192,7 +175,9 @@ mod tests { "sdl": "the sdl shouldnt change" }); Ok(http::Response::builder() - .body(RouterBody::from(serde_json::to_string(&input).unwrap())) + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) .unwrap()) }) }); @@ -202,6 +187,7 @@ mod tests { mock_router_service.boxed(), "http://test".to_string(), Arc::new("".to_string()), + true, ); let request = supergraph::Request::canned_builder().build().unwrap(); @@ -222,7 +208,7 @@ mod tests { request: RouterRequestConf { condition: Default::default(), headers: true, - context: true, + context: ContextConf::NewContextConf(NewContextConf::All), body: true, sdl: true, path: false, @@ -251,7 +237,9 @@ mod tests { "sdl": "the sdl shouldnt change" }); Ok(http::Response::builder() - .body(RouterBody::from(serde_json::to_string(&input).unwrap())) + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) .unwrap()) }) }); @@ -261,6 +249,7 @@ mod tests { mock_router_service.boxed(), "http://test".to_string(), Arc::new("".to_string()), + true, ); let request = supergraph::Request::canned_builder().build().unwrap(); @@ -292,7 +281,7 @@ mod tests { let mock_http_client = mock_with_callback(move |_: http::Request| { Box::pin(async { Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( r#"{ "version": 1, "stage": "SubgraphRequest", @@ -316,12 +305,13 @@ mod tests { mock_subgraph_service.boxed(), "http://test".to_string(), "my_subgraph_service_name".to_string(), + true, ); let request = subgraph::Request::fake_builder().build(); assert_eq!( - "couldn't deserialize coprocessor output body: missing field `message`", + "couldn't deserialize coprocessor output body: GraphQL response was malformed: missing required `message` property within error", service .oneshot(request) .await @@ -384,23 +374,25 @@ mod tests { Ok(subgraph::Response::builder() .data(json!({ "test": 1234_u32 })) .errors(Vec::new()) - .extensions(crate::json_ext::Object::new()) + .extensions(Object::new()) .context(req.context) .id(req.id) + .subgraph_name(String::default()) .build()) }); let mock_http_client = mock_with_callback(move |req: http::Request| { Box::pin(async { - let deserialized_request: Externalizable = - serde_json::from_slice(&hyper::body::to_bytes(req.into_body()).await.unwrap()) - .unwrap(); + let deserialized_request: Externalizable = serde_json::from_slice( + &router::body::into_bytes(req.into_body()).await.unwrap(), + ) + .unwrap(); assert_eq!( deserialized_request.subgraph_request_id.as_deref(), Some("5678") ); Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( r#"{ "version": 1, "stage": "SubgraphRequest", @@ -456,6 +448,7 @@ mod tests { mock_subgraph_service.boxed(), "http://test".to_string(), "my_subgraph_service_name".to_string(), + true, ); let mut request = subgraph::Request::fake_builder().build(); @@ -465,25 +458,21 @@ mod tests { assert_eq!("5678", &*response.id); assert_eq!( - serde_json_bytes::json!({ "test": 1234_u32 }), + json!({ "test": 1234_u32 }), response.response.into_body().data.unwrap() ); } #[tokio::test] - async fn external_plugin_subgraph_request_with_condition() { + async fn external_plugin_subgraph_request_with_selective_context() { let subgraph_stage = SubgraphStage { request: SubgraphRequestConf { - condition: Condition::Eq([ - SelectorOrValue::Selector(SubgraphSelector::SubgraphRequestHeader { - subgraph_request_header: String::from("another_header"), - redact: None, - default: None, - }), - SelectorOrValue::Value("value".to_string().into()), - ]) - .into(), + condition: Default::default(), body: true, + subgraph_request_id: true, + context: ContextConf::NewContextConf(NewContextConf::Selective(Arc::new( + ["this-is-a-test-context".to_string()].into(), + ))), ..Default::default() }, response: Default::default(), @@ -495,31 +484,112 @@ mod tests { mock_subgraph_service .expect_call() .returning(|req: subgraph::Request| { - assert_eq!("/", req.subgraph_request.uri().to_string()); + // Let's assert that the subgraph request has been transformed as it should have. + assert_eq!( + req.subgraph_request.headers().get("cookie").unwrap(), + "tasty_cookie=strawberry" + ); + assert_eq!( + req.context + .get::<&str, u8>("this-is-a-test-context") + .unwrap() + .unwrap(), + 42 + ); + + // The subgraph uri should have changed + assert_eq!( + "http://thisurihaschanged/", + req.subgraph_request.uri().to_string() + ); + + // The query should have changed + assert_eq!( + "query Long {\n me {\n name\n}\n}", + req.subgraph_request.into_body().query.unwrap() + ); + + // this should be the same as the initial request id + assert_eq!(&*req.id, "5678"); Ok(subgraph::Response::builder() .data(json!({ "test": 1234_u32 })) .errors(Vec::new()) - .extensions(crate::json_ext::Object::new()) + .extensions(Object::new()) .context(req.context) + .id(req.id) + .subgraph_name(String::default()) .build()) }); - let mock_http_client = mock_with_callback(move |_: http::Request| { + let mock_http_client = mock_with_callback(move |req: http::Request| { Box::pin(async { + let deserialized_request: Externalizable = serde_json::from_slice( + &router::body::into_bytes(req.into_body()).await.unwrap(), + ) + .unwrap(); + assert_eq!( + deserialized_request.subgraph_request_id.as_deref(), + Some("5678") + ); + let context = deserialized_request.context.unwrap_or_default(); + assert_eq!( + context + .get::<&str, u8>("this-is-a-test-context") + .expect("context key should be there") + .expect("context key should have the right format"), + 42 + ); + assert!( + context + .get::<&str, String>("not_passed") + .ok() + .flatten() + .is_none() + ); Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( r#"{ "version": 1, "stage": "SubgraphRequest", "control": "continue", + "headers": { + "cookie": [ + "tasty_cookie=strawberry" + ], + "content-type": [ + "application/json" + ], + "host": [ + "127.0.0.1:4000" + ], + "apollo-federation-include-trace": [ + "ftv1" + ], + "apollographql-client-name": [ + "manual" + ], + "accept": [ + "*/*" + ], + "user-agent": [ + "curl/7.79.1" + ], + "content-length": [ + "46" + ] + }, "body": { "query": "query Long {\n me {\n name\n}\n}" }, "context": { + "entries": { + "this-is-a-test-context": 42 + } }, "serviceName": "service name shouldn't change", - "uri": "http://thisurihaschanged" + "uri": "http://thisurihaschanged", + "subgraphRequestId": "9abc" }"#, )) .unwrap()) @@ -531,58 +601,162 @@ mod tests { mock_subgraph_service.boxed(), "http://test".to_string(), "my_subgraph_service_name".to_string(), + true, ); - let request = subgraph::Request::fake_builder().build(); + let mut request = subgraph::Request::fake_builder().build(); + request.id = SubgraphRequestId("5678".to_string()); + request + .context + .insert("not_passed", "OK".to_string()) + .unwrap(); + request + .context + .insert("this-is-a-test-context", 42) + .unwrap(); + + let response = service.oneshot(request).await.unwrap(); + assert_eq!("5678", &*response.id); assert_eq!( - serde_json_bytes::json!({ "test": 1234_u32 }), - service - .oneshot(request) - .await - .unwrap() - .response - .into_body() - .data - .unwrap() + json!({ "test": 1234_u32 }), + response.response.into_body().data.unwrap() ); } #[tokio::test] - async fn external_plugin_subgraph_request_controlflow_break() { + async fn external_plugin_subgraph_request_with_deprecated_context() { let subgraph_stage = SubgraphStage { request: SubgraphRequestConf { condition: Default::default(), body: true, + subgraph_request_id: true, + context: ContextConf::NewContextConf(NewContextConf::Deprecated), ..Default::default() }, response: Default::default(), }; // This will never be called because we will fail at the coprocessor. - let mock_subgraph_service = MockSubgraphService::new(); + let mut mock_subgraph_service = MockSubgraphService::new(); - let mock_http_client = mock_with_callback(move |_: http::Request| { + mock_subgraph_service + .expect_call() + .returning(|req: subgraph::Request| { + // Let's assert that the subgraph request has been transformed as it should have. + assert_eq!( + req.subgraph_request.headers().get("cookie").unwrap(), + "tasty_cookie=strawberry" + ); + assert_eq!( + req.context + .get::<&str, u8>("this-is-a-test-context") + .unwrap() + .unwrap(), + 42 + ); + assert_eq!( + req.context + .get::<&str, String>("apollo::supergraph::operation_name") + .expect("context key should be there") + .expect("context key should have the right format"), + "New".to_string() + ); + + // The subgraph uri should have changed + assert_eq!( + "http://thisurihaschanged/", + req.subgraph_request.uri().to_string() + ); + + // The query should have changed + assert_eq!( + "query Long {\n me {\n name\n}\n}", + req.subgraph_request.into_body().query.unwrap() + ); + + // this should be the same as the initial request id + assert_eq!(&*req.id, "5678"); + + Ok(subgraph::Response::builder() + .data(json!({ "test": 1234_u32 })) + .errors(Vec::new()) + .extensions(Object::new()) + .context(req.context) + .id(req.id) + .subgraph_name(String::default()) + .build()) + }); + + let mock_http_client = mock_with_callback(move |req: http::Request| { Box::pin(async { + let deserialized_request: Externalizable = serde_json::from_slice( + &router::body::into_bytes(req.into_body()).await.unwrap(), + ) + .unwrap(); + assert_eq!( + deserialized_request.subgraph_request_id.as_deref(), + Some("5678") + ); + let context = deserialized_request.context.unwrap_or_default(); + assert_eq!( + context + .get::<&str, u8>("this-is-a-test-context") + .expect("context key should be there") + .expect("context key should have the right format"), + 42 + ); + assert_eq!( + context + .get::<&str, String>("operation_name") + .expect("context key should be there") + .expect("context key should have the right format"), + "Test".to_string() + ); Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( r#"{ "version": 1, "stage": "SubgraphRequest", - "control": { - "break": 200 - }, - "body": { - "errors": [{ "message": "my error message" }] - }, - "context": { + "control": "continue", + "headers": { + "cookie": [ + "tasty_cookie=strawberry" + ], + "content-type": [ + "application/json" + ], + "host": [ + "127.0.0.1:4000" + ], + "apollo-federation-include-trace": [ + "ftv1" + ], + "apollographql-client-name": [ + "manual" + ], + "accept": [ + "*/*" + ], + "user-agent": [ + "curl/7.79.1" + ], + "content-length": [ + "46" + ] + }, + "body": { + "query": "query Long {\n me {\n name\n}\n}" + }, + "context": { "entries": { - "testKey": true + "this-is-a-test-context": 42, + "operation_name": "New" } - }, - "headers": { - "aheader": ["a value"] - } + }, + "serviceName": "service name shouldn't change", + "uri": "http://thisurihaschanged", + "subgraphRequestId": "9abc" }"#, )) .unwrap()) @@ -594,23 +768,172 @@ mod tests { mock_subgraph_service.boxed(), "http://test".to_string(), "my_subgraph_service_name".to_string(), + true, ); - let request = subgraph::Request::fake_builder().build(); - - let crate::services::subgraph::Response { - response, context, .. - } = service.oneshot(request).await.unwrap(); - - assert!(context.get::<_, bool>("testKey").unwrap().unwrap()); - - let value = response.headers().get("aheader").unwrap(); + let mut request = subgraph::Request::fake_builder().build(); + request.id = SubgraphRequestId("5678".to_string()); + request + .context + .insert("apollo::supergraph::operation_name", "Test".to_string()) + .unwrap(); + request + .context + .insert("this-is-a-test-context", 42) + .unwrap(); - assert_eq!("a value", value); + let response = service.oneshot(request).await.unwrap(); + assert_eq!("5678", &*response.id); assert_eq!( - "my error message", - response.into_body().errors[0].message.as_str() + json!({ "test": 1234_u32 }), + response.response.into_body().data.unwrap() + ); + } + + #[tokio::test] + async fn external_plugin_subgraph_request_with_condition() { + let subgraph_stage = SubgraphStage { + request: SubgraphRequestConf { + condition: Condition::Eq([ + SelectorOrValue::Selector(SubgraphSelector::SubgraphRequestHeader { + subgraph_request_header: String::from("another_header"), + redact: None, + default: None, + }), + SelectorOrValue::Value("value".to_string().into()), + ]), + body: true, + ..Default::default() + }, + response: Default::default(), + }; + + // This will never be called because we will fail at the coprocessor. + let mut mock_subgraph_service = MockSubgraphService::new(); + + mock_subgraph_service + .expect_call() + .returning(|req: subgraph::Request| { + assert_eq!("/", req.subgraph_request.uri().to_string()); + + Ok(subgraph::Response::builder() + .data(json!({ "test": 1234_u32 })) + .errors(Vec::new()) + .extensions(Object::new()) + .context(req.context) + .subgraph_name(String::default()) + .build()) + }); + + let mock_http_client = mock_with_callback(move |_: http::Request| { + Box::pin(async { + Ok(http::Response::builder() + .body(router::body::from_bytes( + r#"{ + "version": 1, + "stage": "SubgraphRequest", + "control": "continue", + "body": { + "query": "query Long {\n me {\n name\n}\n}" + }, + "context": { + }, + "serviceName": "service name shouldn't change", + "uri": "http://thisurihaschanged" + }"#, + )) + .unwrap()) + }) + }); + + let service = subgraph_stage.as_service( + mock_http_client, + mock_subgraph_service.boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + true, + ); + + let request = subgraph::Request::fake_builder().build(); + + assert_eq!( + json!({ "test": 1234_u32 }), + service + .oneshot(request) + .await + .unwrap() + .response + .into_body() + .data + .unwrap() + ); + } + + #[tokio::test] + async fn external_plugin_subgraph_request_controlflow_break() { + let subgraph_stage = SubgraphStage { + request: SubgraphRequestConf { + condition: Default::default(), + body: true, + ..Default::default() + }, + response: Default::default(), + }; + + // This will never be called because we will fail at the coprocessor. + let mock_subgraph_service = MockSubgraphService::new(); + + let mock_http_client = mock_with_callback(move |_: http::Request| { + Box::pin(async { + Ok(http::Response::builder() + .body(router::body::from_bytes( + r#"{ + "version": 1, + "stage": "SubgraphRequest", + "control": { + "break": 200 + }, + "body": { + "errors": [{ "message": "my error message" }] + }, + "context": { + "entries": { + "testKey": true + } + }, + "headers": { + "aheader": ["a value"] + } + }"#, + )) + .unwrap()) + }) + }); + + let service = subgraph_stage.as_service( + mock_http_client, + mock_subgraph_service.boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + true, + ); + + let request = subgraph::Request::fake_builder().build(); + + let crate::services::subgraph::Response { + response, context, .. + } = service.oneshot(request).await.unwrap(); + + assert!(context.get::<_, bool>("testKey").unwrap().unwrap()); + + let value = response.headers().get("aheader").unwrap(); + + assert_eq!("a value", value); + + assert_eq!( + "my error message", + response.into_body().errors[0].message.as_str() ); } @@ -631,7 +954,7 @@ mod tests { let mock_http_client = mock_with_callback(move |_: http::Request| { Box::pin(async { Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( r#"{ "version": 1, "stage": "SubgraphRequest", @@ -650,6 +973,7 @@ mod tests { mock_subgraph_service.boxed(), "http://test".to_string(), "my_subgraph_service_name".to_string(), + true, ); let request = subgraph::Request::fake_builder().build(); @@ -660,9 +984,9 @@ mod tests { let actual_response = response.into_body(); - assert_eq!( + assert_response_eq_ignoring_error_id!( actual_response, - serde_json::from_value(json!({ + serde_json_bytes::from_value::(json!({ "errors": [{ "message": "my error message", "extensions": { @@ -670,7 +994,7 @@ mod tests { } }] })) - .unwrap(), + .unwrap() ); } @@ -696,21 +1020,23 @@ mod tests { Ok(subgraph::Response::builder() .data(json!({ "test": 1234_u32 })) .errors(Vec::new()) - .extensions(crate::json_ext::Object::new()) + .extensions(Object::new()) .context(req.context) .id(req.id) + .subgraph_name(String::default()) .build()) }); let mock_http_client = mock_with_callback(move |r: http::Request| { Box::pin(async move { let (_, body) = r.into_parts(); - let body: Value = serde_json::from_slice(&body.to_bytes().await.unwrap()).unwrap(); + let body: Value = + serde_json::from_slice(&router::body::into_bytes(body).await.unwrap()).unwrap(); let subgraph_id = body.get("subgraphRequestId").unwrap(); assert_eq!(subgraph_id.as_str(), Some("5678")); Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( r#"{ "version": 1, "stage": "SubgraphResponse", @@ -765,6 +1091,7 @@ mod tests { mock_subgraph_service.boxed(), "http://test".to_string(), "my_subgraph_service_name".to_string(), + true, ); let mut request = subgraph::Request::fake_builder().build(); @@ -789,24 +1116,19 @@ mod tests { ); assert_eq!( - serde_json_bytes::json!({ "test": 5678_u32 }), + json!({ "test": 5678_u32 }), response.response.into_body().data.unwrap() ); } #[tokio::test] - async fn external_plugin_subgraph_response_with_condition() { + async fn external_plugin_subgraph_response_with_null_data() { let subgraph_stage = SubgraphStage { request: Default::default(), response: SubgraphResponseConf { - // Will be satisfied - condition: Condition::Exists(SubgraphSelector::ResponseContext { - response_context: String::from("context_value"), - redact: None, - default: None, - }) - .into(), + condition: Default::default(), body: true, + subgraph_request_id: true, ..Default::default() }, }; @@ -817,28 +1139,30 @@ mod tests { mock_subgraph_service .expect_call() .returning(|req: subgraph::Request| { - req.context - .insert("context_value", "content".to_string()) - .unwrap(); + assert_eq!(&*req.id, "5678"); Ok(subgraph::Response::builder() - .data(json!({ "test": 1234_u32 })) - .errors(Vec::new()) - .extensions(crate::json_ext::Object::new()) + .data(serde_json_bytes::Value::Null) + .extensions(Object::new()) .context(req.context) + .id(req.id) + .subgraph_name(String::default()) .build()) }); - let mock_http_client = mock_with_callback(move |_: http::Request| { - Box::pin(async { + let mock_http_client = mock_with_callback(move |r: http::Request| { + Box::pin(async move { + let (_, body) = r.into_parts(); + let body: Value = + serde_json::from_slice(&router::body::into_bytes(body).await.unwrap()).unwrap(); + let subgraph_id = body.get("subgraphRequestId").unwrap(); + assert_eq!(subgraph_id.as_str(), Some("5678")); + Ok(http::Response::builder() - .body(RouterBody::from( + .body(router::body::from_bytes( r#"{ "version": 1, "stage": "SubgraphResponse", "headers": { - "cookie": [ - "tasty_cookie=strawberry" - ], "content-type": [ "application/json" ], @@ -862,18 +1186,16 @@ mod tests { ] }, "body": { - "data": { - "test": 5678 - } + "data": null }, "context": { "entries": { "accepts-json": false, "accepts-wildcard": true, - "accepts-multipart": false, - "this-is-a-test-context": 42 + "accepts-multipart": false } - } + }, + "subgraphRequestId": "9abc" }"#, )) .unwrap()) @@ -885,821 +1207,2550 @@ mod tests { mock_subgraph_service.boxed(), "http://test".to_string(), "my_subgraph_service_name".to_string(), + true, ); - let request = subgraph::Request::fake_builder().build(); + let mut request = subgraph::Request::fake_builder().build(); + request.id = SubgraphRequestId("5678".to_string()); let response = service.oneshot(request).await.unwrap(); // Let's assert that the subgraph response has been transformed as it should have. + assert_eq!(&*response.id, "5678"); assert_eq!( - response.response.headers().get("cookie").unwrap(), - "tasty_cookie=strawberry" - ); - - assert_eq!( - response - .context - .get::<&str, u8>("this-is-a-test-context") - .unwrap() - .unwrap(), - 42 - ); - - assert_eq!( - serde_json_bytes::json!({ "test": 5678_u32 }), + serde_json_bytes::Value::Null, response.response.into_body().data.unwrap() ); } #[tokio::test] - async fn external_plugin_supergraph_response() { - let supergraph_stage = SupergraphStage { + async fn external_plugin_subgraph_response_with_selective_context() { + let subgraph_stage = SubgraphStage { request: Default::default(), - response: SupergraphResponseConf { + response: SubgraphResponseConf { condition: Default::default(), - headers: false, - context: false, body: true, - status_code: false, - sdl: false, + subgraph_request_id: true, + context: ContextConf::NewContextConf(NewContextConf::Selective(Arc::new( + ["this-is-a-test-context".to_string()].into(), + ))), + ..Default::default() }, }; // This will never be called because we will fail at the coprocessor. - let mut mock_supergraph_service = MockSupergraphService::new(); + let mut mock_subgraph_service = MockSubgraphService::new(); - mock_supergraph_service + mock_subgraph_service .expect_call() - .returning(|req: supergraph::Request| { - Ok(supergraph::Response::new_from_graphql_response( - graphql::Response::builder() - .data(Value::Null) - .subscribed(true) - .build(), - req.context, - )) + .returning(|req: subgraph::Request| { + assert_eq!(&*req.id, "5678"); + Ok(subgraph::Response::builder() + .data(json!({ "test": 1234_u32 })) + .errors(Vec::new()) + .extensions(Object::new()) + .context(req.context) + .id(req.id) + .subgraph_name(String::default()) + .build()) }); - let mock_http_client = mock_with_deferred_callback(move |_: http::Request| { - Box::pin(async { - Ok(http::Response::builder() - .body(RouterBody::from( - r#"{ - "version": 1, - "stage": "SupergraphResponse", - "body": { - "data": null - } - }"#, - )) - .unwrap()) - }) - }); + let mock_http_client = mock_with_callback(move |r: http::Request| { + Box::pin(async move { + let (_, body) = r.into_parts(); + let deserialized_response: Externalizable = + serde_json::from_slice(&router::body::into_bytes(body).await.unwrap()).unwrap(); - let service = supergraph_stage.as_service( - mock_http_client, - mock_supergraph_service.boxed(), - "http://test".to_string(), - Arc::default(), - ); + assert_eq!( + deserialized_response.subgraph_request_id, + Some(SubgraphRequestId("5678".to_string())) + ); - let request = supergraph::Request::fake_builder().build().unwrap(); + let context = deserialized_response.context.unwrap_or_default(); + assert_eq!( + context + .get::<&str, u8>("this-is-a-test-context") + .expect("context key should be there") + .expect("context key should have the right format"), + 55 + ); + assert!( + context + .get::<&str, String>("not_passed") + .ok() + .flatten() + .is_none() + ); + + Ok(http::Response::builder() + .body(router::body::from_bytes( + r#"{ + "version": 1, + "stage": "SubgraphResponse", + "headers": { + "cookie": [ + "tasty_cookie=strawberry" + ], + "content-type": [ + "application/json" + ], + "host": [ + "127.0.0.1:4000" + ], + "apollo-federation-include-trace": [ + "ftv1" + ], + "apollographql-client-name": [ + "manual" + ], + "accept": [ + "*/*" + ], + "user-agent": [ + "curl/7.79.1" + ], + "content-length": [ + "46" + ] + }, + "body": { + "data": { + "test": 5678 + } + }, + "context": { + "entries": { + "this-is-a-test-context": 42 + } + }, + "subgraphRequestId": "9abc" + }"#, + )) + .unwrap()) + }) + }); + + let service = subgraph_stage.as_service( + mock_http_client, + mock_subgraph_service.boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + true, + ); + + let mut request = subgraph::Request::fake_builder().build(); + request.id = SubgraphRequestId("5678".to_string()); + request + .context + .insert("not_passed", "OK".to_string()) + .unwrap(); + request + .context + .insert("this-is-a-test-context", 55) + .unwrap(); + + let response = service.oneshot(request).await.unwrap(); + + // Let's assert that the subgraph response has been transformed as it should have. + assert_eq!( + response.response.headers().get("cookie").unwrap(), + "tasty_cookie=strawberry" + ); + assert_eq!(&*response.id, "5678"); + + assert_eq!( + response + .context + .get::<&str, u8>("this-is-a-test-context") + .unwrap() + .unwrap(), + 42 + ); + + assert_eq!( + json!({ "test": 5678_u32 }), + response.response.into_body().data.unwrap() + ); + } + + #[tokio::test] + async fn external_plugin_subgraph_response_with_deprecated_context() { + let subgraph_stage = SubgraphStage { + request: Default::default(), + response: SubgraphResponseConf { + condition: Default::default(), + body: true, + subgraph_request_id: true, + context: ContextConf::NewContextConf(NewContextConf::Deprecated), + ..Default::default() + }, + }; + + // This will never be called because we will fail at the coprocessor. + let mut mock_subgraph_service = MockSubgraphService::new(); + + mock_subgraph_service + .expect_call() + .returning(|req: subgraph::Request| { + assert_eq!(&*req.id, "5678"); + Ok(subgraph::Response::builder() + .data(json!({ "test": 1234_u32 })) + .errors(Vec::new()) + .extensions(Object::new()) + .context(req.context) + .id(req.id) + .subgraph_name(String::default()) + .build()) + }); + + let mock_http_client = mock_with_callback(move |r: http::Request| { + Box::pin(async move { + let (_, body) = r.into_parts(); + let deserialized_response: Externalizable = + serde_json::from_slice(&router::body::into_bytes(body).await.unwrap()).unwrap(); + + assert_eq!( + deserialized_response.subgraph_request_id, + Some(SubgraphRequestId("5678".to_string())) + ); + + let context = deserialized_response.context.unwrap_or_default(); + assert_eq!( + context + .get::<&str, u8>("this-is-a-test-context") + .expect("context key should be there") + .expect("context key should have the right format"), + 55 + ); + assert_eq!( + context + .get::<&str, String>("operation_name") + .expect("context key should be there") + .expect("context key should have the right format"), + "Test".to_string() + ); + + Ok(http::Response::builder() + .body(router::body::from_bytes( + r#"{ + "version": 1, + "stage": "SubgraphResponse", + "headers": { + "cookie": [ + "tasty_cookie=strawberry" + ], + "content-type": [ + "application/json" + ], + "host": [ + "127.0.0.1:4000" + ], + "apollo-federation-include-trace": [ + "ftv1" + ], + "apollographql-client-name": [ + "manual" + ], + "accept": [ + "*/*" + ], + "user-agent": [ + "curl/7.79.1" + ], + "content-length": [ + "46" + ] + }, + "body": { + "data": { + "test": 5678 + } + }, + "context": { + "entries": { + "this-is-a-test-context": 42, + "operation_name": "New" + } + }, + "subgraphRequestId": "9abc" + }"#, + )) + .unwrap()) + }) + }); + + let service = subgraph_stage.as_service( + mock_http_client, + mock_subgraph_service.boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + true, + ); + + let mut request = subgraph::Request::fake_builder().build(); + request.id = SubgraphRequestId("5678".to_string()); + request + .context + .insert("apollo::supergraph::operation_name", "Test".to_string()) + .unwrap(); + request + .context + .insert("this-is-a-test-context", 55) + .unwrap(); + + let response = service.oneshot(request).await.unwrap(); + + // Let's assert that the subgraph response has been transformed as it should have. + assert_eq!( + response.response.headers().get("cookie").unwrap(), + "tasty_cookie=strawberry" + ); + assert_eq!(&*response.id, "5678"); + + assert_eq!( + response + .context + .get::<&str, u8>("this-is-a-test-context") + .unwrap() + .unwrap(), + 42 + ); + assert_eq!( + response + .context + .get::<&str, String>("apollo::supergraph::operation_name") + .unwrap() + .unwrap(), + "New".to_string() + ); + + assert_eq!( + json!({ "test": 5678_u32 }), + response.response.into_body().data.unwrap() + ); + } + + #[tokio::test] + async fn external_plugin_subgraph_response_with_condition() { + let subgraph_stage = SubgraphStage { + request: Default::default(), + response: SubgraphResponseConf { + // Will be satisfied + condition: Condition::Exists(SubgraphSelector::ResponseContext { + response_context: String::from("context_value"), + redact: None, + default: None, + }), + body: true, + ..Default::default() + }, + }; + + // This will never be called because we will fail at the coprocessor. + let mut mock_subgraph_service = MockSubgraphService::new(); + + mock_subgraph_service + .expect_call() + .returning(|req: subgraph::Request| { + req.context + .insert("context_value", "content".to_string()) + .unwrap(); + Ok(subgraph::Response::builder() + .data(json!({ "test": 1234_u32 })) + .errors(Vec::new()) + .extensions(Object::new()) + .context(req.context) + .subgraph_name(String::default()) + .build()) + }); + + let mock_http_client = mock_with_callback(move |_: http::Request| { + Box::pin(async { + Ok(http::Response::builder() + .body(router::body::from_bytes( + r#"{ + "version": 1, + "stage": "SubgraphResponse", + "headers": { + "cookie": [ + "tasty_cookie=strawberry" + ], + "content-type": [ + "application/json" + ], + "host": [ + "127.0.0.1:4000" + ], + "apollo-federation-include-trace": [ + "ftv1" + ], + "apollographql-client-name": [ + "manual" + ], + "accept": [ + "*/*" + ], + "user-agent": [ + "curl/7.79.1" + ], + "content-length": [ + "46" + ] + }, + "body": { + "data": { + "test": 5678 + } + }, + "context": { + "entries": { + "accepts-json": false, + "accepts-wildcard": true, + "accepts-multipart": false, + "this-is-a-test-context": 42 + } + } + }"#, + )) + .unwrap()) + }) + }); + + let service = subgraph_stage.as_service( + mock_http_client, + mock_subgraph_service.boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + true, + ); + + let request = subgraph::Request::fake_builder().build(); + + let response = service.oneshot(request).await.unwrap(); + + // Let's assert that the subgraph response has been transformed as it should have. + assert_eq!( + response.response.headers().get("cookie").unwrap(), + "tasty_cookie=strawberry" + ); + + assert_eq!( + response + .context + .get::<&str, u8>("this-is-a-test-context") + .unwrap() + .unwrap(), + 42 + ); + + assert_eq!( + json!({ "test": 5678_u32 }), + response.response.into_body().data.unwrap() + ); + } + + #[tokio::test] + async fn external_plugin_supergraph_response() { + let supergraph_stage = SupergraphStage { + request: Default::default(), + response: SupergraphResponseConf { + condition: Default::default(), + headers: false, + context: ContextConf::Deprecated(false), + body: true, + status_code: false, + sdl: false, + }, + }; + + // This will never be called because we will fail at the coprocessor. + let mut mock_supergraph_service = MockSupergraphService::new(); + + mock_supergraph_service + .expect_call() + .returning(|req: supergraph::Request| { + Ok(supergraph::Response::new_from_graphql_response( + graphql::Response::builder() + .data(Value::Null) + .subscribed(true) + .build(), + req.context, + )) + }); + + let mock_http_client = mock_with_deferred_callback(move |_: http::Request| { + Box::pin(async { + Ok(http::Response::builder() + .body(router::body::from_bytes( + r#"{ + "version": 1, + "stage": "SupergraphResponse", + "body": { + "data": null + } + }"#, + )) + .unwrap()) + }) + }); + + let service = supergraph_stage.as_service( + mock_http_client, + mock_supergraph_service.boxed(), + "http://test".to_string(), + Arc::default(), + true, + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + + let mut response = service.oneshot(request).await.unwrap(); + + let gql_response = response.response.body_mut().next().await.unwrap(); + // Let's assert that the supergraph response has been transformed as it should have. + assert_eq!(gql_response.subscribed, Some(true)); + assert_eq!(gql_response.data, Some(Value::Null)); + } + + #[tokio::test] + async fn external_plugin_supergraph_response_with_selective_context() { + let supergraph_stage = SupergraphStage { + request: Default::default(), + response: SupergraphResponseConf { + condition: Default::default(), + headers: false, + context: ContextConf::NewContextConf(NewContextConf::Selective(Arc::new( + ["this-is-a-test-context".to_string()].into(), + ))), + body: true, + status_code: false, + sdl: false, + }, + }; + + // This will never be called because we will fail at the coprocessor. + let mut mock_supergraph_service = MockSupergraphService::new(); + + mock_supergraph_service + .expect_call() + .returning(|req: supergraph::Request| { + Ok(supergraph::Response::new_from_graphql_response( + graphql::Response::builder() + .data(Value::Null) + .subscribed(true) + .build(), + req.context, + )) + }); + + let mock_http_client = + mock_with_deferred_callback(move |req: http::Request| { + Box::pin(async { + let (_, body) = req.into_parts(); + let deserialized_response: Externalizable = + serde_json::from_slice(&router::body::into_bytes(body).await.unwrap()) + .unwrap(); + let context = deserialized_response.context.unwrap_or_default(); + assert_eq!( + context + .get::<&str, u8>("this-is-a-test-context") + .expect("context key should be there") + .expect("context key should have the right format"), + 42 + ); + assert!( + context + .get::<&str, String>("not_passed") + .ok() + .flatten() + .is_none() + ); + Ok(http::Response::builder() + .body(router::body::from_bytes( + r#"{ + "version": 1, + "stage": "SupergraphResponse", + "context": { + "entries": { + "this-is-a-test-context": 25 + } + }, + "body": { + "data": null + } + }"#, + )) + .unwrap()) + }) + }); + + let service = supergraph_stage.as_service( + mock_http_client, + mock_supergraph_service.boxed(), + "http://test".to_string(), + Arc::default(), + true, + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + request + .context + .insert("not_passed", "OK".to_string()) + .unwrap(); + request + .context + .insert("this-is-a-test-context", 42) + .unwrap(); + + let mut response = service.oneshot(request).await.unwrap(); + + assert_eq!( + response + .context + .get::<&str, u8>("this-is-a-test-context") + .unwrap() + .unwrap(), + 25 + ); + + let gql_response = response.response.body_mut().next().await.unwrap(); + // Let's assert that the supergraph response has been transformed as it should have. + assert_eq!(gql_response.subscribed, Some(true)); + assert_eq!(gql_response.data, Some(Value::Null)); + } + + #[tokio::test] + async fn external_plugin_supergraph_response_with_deprecated_context() { + let supergraph_stage = SupergraphStage { + request: Default::default(), + response: SupergraphResponseConf { + condition: Default::default(), + headers: false, + context: ContextConf::NewContextConf(NewContextConf::Deprecated), + body: true, + status_code: false, + sdl: false, + }, + }; + + // This will never be called because we will fail at the coprocessor. + let mut mock_supergraph_service = MockSupergraphService::new(); + + mock_supergraph_service + .expect_call() + .returning(|req: supergraph::Request| { + Ok(supergraph::Response::new_from_graphql_response( + graphql::Response::builder() + .data(Value::Null) + .subscribed(true) + .build(), + req.context, + )) + }); + + let mock_http_client = + mock_with_deferred_callback(move |req: http::Request| { + Box::pin(async { + let (_, body) = req.into_parts(); + let deserialized_response: Externalizable = + serde_json::from_slice(&router::body::into_bytes(body).await.unwrap()) + .unwrap(); + let context = deserialized_response.context.unwrap_or_default(); + assert_eq!( + context + .get::<&str, String>("operation_name") + .expect("context key should be there") + .expect("context key should have the right format"), + "Test".to_string() + ); + Ok(http::Response::builder() + .body(router::body::from_bytes( + r#"{ + "version": 1, + "stage": "SupergraphResponse", + "context": { + "entries": { + "operation_name": "New" + } + }, + "body": { + "data": null + } + }"#, + )) + .unwrap()) + }) + }); + + let service = supergraph_stage.as_service( + mock_http_client, + mock_supergraph_service.boxed(), + "http://test".to_string(), + Arc::default(), + true, + ); + + let request = supergraph::Request::fake_builder().build().unwrap(); + request + .context + .insert("apollo::supergraph::operation_name", "Test".to_string()) + .unwrap(); let mut response = service.oneshot(request).await.unwrap(); - let gql_response = response.response.body_mut().next().await.unwrap(); - // Let's assert that the supergraph response has been transformed as it should have. - assert_eq!(gql_response.subscribed, Some(true)); - assert_eq!(gql_response.data, Some(Value::Null)); + assert_eq!( + response + .context + .get::<&str, String>("apollo::supergraph::operation_name") + .unwrap() + .unwrap(), + "New".to_string() + ); + assert!( + response + .context + .get::<&str, String>("operation_name") + .ok() + .flatten() + .is_none() + ); + + let gql_response = response.response.body_mut().next().await.unwrap(); + // Let's assert that the supergraph response has been transformed as it should have. + assert_eq!(gql_response.subscribed, Some(true)); + assert_eq!(gql_response.data, Some(Value::Null)); + } + + #[tokio::test] + async fn external_plugin_router_request() { + let router_stage = RouterStage { + request: RouterRequestConf { + condition: Default::default(), + headers: true, + context: ContextConf::NewContextConf(NewContextConf::All), + body: true, + sdl: true, + path: true, + method: true, + }, + response: Default::default(), + }; + + let mock_router_service = router::service::from_supergraph_mock_callback(move |req| { + // Let's assert that the router request has been transformed as it should have. + assert_eq!( + req.supergraph_request.headers().get("cookie").unwrap(), + "tasty_cookie=strawberry" + ); + + assert_eq!( + req.context + .get::<&str, u8>("this-is-a-test-context") + .unwrap() + .unwrap(), + 42 + ); + + // The query should have changed + assert_eq!( + "query Long {\n me {\n name\n}\n}", + req.supergraph_request.into_body().query.unwrap() + ); + + Ok(supergraph::Response::builder() + .data(json!({ "test": 1234_u32 })) + .context(req.context) + .build() + .unwrap()) + }) + .await; + + let mock_http_client = mock_with_callback(move |req: http::Request| { + Box::pin(async { + let deserialized_request: Externalizable = serde_json::from_slice( + &router::body::into_bytes(req.into_body()).await.unwrap(), + ) + .unwrap(); + + assert_eq!(EXTERNALIZABLE_VERSION, deserialized_request.version); + assert_eq!( + PipelineStep::RouterRequest.to_string(), + deserialized_request.stage + ); + + let input = json!( + { + "version": 1, + "stage": "RouterRequest", + "control": "continue", + "id": "1b19c05fdafc521016df33148ad63c1b", + "headers": { + "cookie": [ + "tasty_cookie=strawberry" + ], + "content-type": [ + "application/json" + ], + "host": [ + "127.0.0.1:4000" + ], + "apollo-federation-include-trace": [ + "ftv1" + ], + "apollographql-client-name": [ + "manual" + ], + "accept": [ + "*/*" + ], + "user-agent": [ + "curl/7.79.1" + ], + "content-length": [ + "46" + ] + }, + "body": "{ + \"query\": \"query Long {\n me {\n name\n}\n}\" + }", + "context": { + "entries": { + "accepts-json": false, + "accepts-wildcard": true, + "accepts-multipart": false, + "this-is-a-test-context": 42 + } + }, + "sdl": "the sdl shouldnt change" + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }); + + let service = router_stage.as_service( + mock_http_client, + mock_router_service.boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + true, + ); + + let request = supergraph::Request::canned_builder().build().unwrap(); + + service.oneshot(request.try_into().unwrap()).await.unwrap(); + } + + #[tokio::test] + async fn external_plugin_router_request_with_selective_context() { + let router_stage = RouterStage { + request: RouterRequestConf { + condition: Default::default(), + headers: true, + context: ContextConf::NewContextConf(NewContextConf::Selective(Arc::new( + ["this-is-a-test-context".to_string()].into(), + ))), + body: true, + sdl: true, + path: true, + method: true, + }, + response: Default::default(), + }; + + let mock_router_service = router::service::from_supergraph_mock_callback(move |req| { + // Let's assert that the router request has been transformed as it should have. + assert_eq!( + req.supergraph_request.headers().get("cookie").unwrap(), + "tasty_cookie=strawberry" + ); + + assert_eq!( + req.context + .get::<&str, u8>("this-is-a-test-context") + .unwrap() + .unwrap(), + 42 + ); + + // The query should have changed + assert_eq!( + "query Long {\n me {\n name\n}\n}", + req.supergraph_request.into_body().query.unwrap() + ); + + Ok(supergraph::Response::builder() + .data(json!({ "test": 1234_u32 })) + .context(req.context) + .build() + .unwrap()) + }) + .await; + + let mock_http_client = mock_with_callback(move |req: http::Request| { + Box::pin(async { + let deserialized_request: Externalizable = serde_json::from_slice( + &router::body::into_bytes(req.into_body()).await.unwrap(), + ) + .unwrap(); + + assert_eq!( + deserialized_request + .context + .as_ref() + .unwrap() + .get::<&str, u8>("this-is-a-test-context") + .unwrap() + .unwrap(), + 42 + ); + + assert!( + deserialized_request + .context + .as_ref() + .unwrap() + .get::<&str, String>("not_passed") + .ok() + .flatten() + .is_none() + ); + + assert_eq!(EXTERNALIZABLE_VERSION, deserialized_request.version); + assert_eq!( + PipelineStep::RouterRequest.to_string(), + deserialized_request.stage + ); + + let input = json!( + { + "version": 1, + "stage": "RouterRequest", + "control": "continue", + "id": "1b19c05fdafc521016df33148ad63c1b", + "headers": { + "cookie": [ + "tasty_cookie=strawberry" + ], + "content-type": [ + "application/json" + ], + "host": [ + "127.0.0.1:4000" + ], + "apollo-federation-include-trace": [ + "ftv1" + ], + "apollographql-client-name": [ + "manual" + ], + "accept": [ + "*/*" + ], + "user-agent": [ + "curl/7.79.1" + ], + "content-length": [ + "46" + ] + }, + "body": "{ + \"query\": \"query Long {\n me {\n name\n}\n}\" + }", + "context": { + "entries": { + "accepts-json": false, + "accepts-wildcard": true, + "accepts-multipart": false, + "this-is-a-test-context": 42 + } + }, + "sdl": "the sdl shouldnt change" + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }); + + let service = router_stage.as_service( + mock_http_client, + mock_router_service.boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + true, + ); + + let request = supergraph::Request::canned_builder().build().unwrap(); + request + .context + .insert("not_passed", "OK".to_string()) + .unwrap(); + request + .context + .insert("this-is-a-test-context", 42) + .unwrap(); + + let res = service.oneshot(request.try_into().unwrap()).await.unwrap(); + + assert!( + res.context + .get::<&str, String>("not_passed") + .ok() + .flatten() + .is_some() + ); + } + + #[tokio::test] + async fn external_plugin_router_request_with_condition() { + let router_stage = RouterStage { + request: RouterRequestConf { + // Won't be satisfied + condition: Condition::Eq([ + SelectorOrValue::Selector(RouterSelector::RequestMethod { + request_method: true, + }), + SelectorOrValue::Value("GET".to_string().into()), + ]) + .into(), + headers: true, + context: ContextConf::NewContextConf(NewContextConf::All), + body: true, + sdl: true, + path: true, + method: true, + }, + response: Default::default(), + }; + + let mock_router_service = router::service::from_supergraph_mock_callback(move |req| { + assert!( + req.context + .get::<&str, u8>("this-is-a-test-context") + .ok() + .flatten() + .is_none() + ); + Ok(supergraph::Response::builder() + .data(json!({ "test": 1234_u32 })) + .context(req.context) + .build() + .unwrap()) + }) + .await; + + let mock_http_client = mock_with_callback(move |req: http::Request| { + Box::pin(async { + let deserialized_request: Externalizable = serde_json::from_slice( + &router::body::into_bytes(req.into_body()).await.unwrap(), + ) + .unwrap(); + + assert_eq!(EXTERNALIZABLE_VERSION, deserialized_request.version); + assert_eq!( + PipelineStep::RouterRequest.to_string(), + deserialized_request.stage + ); + + let input = json!( + { + "version": 1, + "stage": "RouterRequest", + "control": "continue", + "id": "1b19c05fdafc521016df33148ad63c1b", + "headers": { + "cookie": [ + "tasty_cookie=strawberry" + ], + "content-type": [ + "application/json" + ], + "host": [ + "127.0.0.1:4000" + ], + "apollo-federation-include-trace": [ + "ftv1" + ], + "apollographql-client-name": [ + "manual" + ], + "accept": [ + "*/*" + ], + "user-agent": [ + "curl/7.79.1" + ], + "content-length": [ + "46" + ] + }, + "body": "{ + \"query\": \"query Long {\n me {\n name\n}\n}\" + }", + "context": { + "entries": { + "accepts-json": false, + "accepts-wildcard": true, + "accepts-multipart": false, + "this-is-a-test-context": 42 + } + }, + "sdl": "the sdl shouldnt change" + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }); + + let service = router_stage.as_service( + mock_http_client, + mock_router_service.boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + true, + ); + + let request = supergraph::Request::canned_builder().build().unwrap(); + + service.oneshot(request.try_into().unwrap()).await.unwrap(); + } + + #[tokio::test] + async fn external_plugin_router_request_http_get() { + let router_stage = RouterStage { + request: RouterRequestConf { + condition: Default::default(), + headers: true, + context: ContextConf::NewContextConf(NewContextConf::All), + body: true, + sdl: true, + path: true, + method: true, + }, + response: Default::default(), + }; + + let mock_router_service = router::service::from_supergraph_mock_callback(move |req| { + // Let's assert that the router request has been transformed as it should have. + assert_eq!( + req.supergraph_request.headers().get("cookie").unwrap(), + "tasty_cookie=strawberry" + ); + + // the method shouldn't have changed + assert_eq!(req.supergraph_request.method(), Method::GET); + // the uri shouldn't have changed + assert_eq!(req.supergraph_request.uri(), "/"); + + assert_eq!( + req.context + .get::<&str, u8>("this-is-a-test-context") + .unwrap() + .unwrap(), + 42 + ); + + // The query should have changed + assert_eq!( + "query Long {\n me {\n name\n}\n}", + req.supergraph_request.into_body().query.unwrap() + ); + + Ok(supergraph::Response::builder() + .data(json!({ "test": 1234_u32 })) + .context(req.context) + .build() + .unwrap()) + }) + .await; + + let mock_http_client = mock_with_callback(move |req: http::Request| { + Box::pin(async { + let deserialized_request: Externalizable = serde_json::from_slice( + &router::body::into_bytes(req.into_body()).await.unwrap(), + ) + .unwrap(); + + assert_eq!(EXTERNALIZABLE_VERSION, deserialized_request.version); + assert_eq!( + PipelineStep::RouterRequest.to_string(), + deserialized_request.stage + ); + + let input = json!( + { + "version": 1, + "stage": "RouterRequest", + "control": "continue", + "id": "1b19c05fdafc521016df33148ad63c1b", + "uri": "/this/is/a/new/uri", + "method": "POST", + "headers": { + "cookie": [ + "tasty_cookie=strawberry" + ], + "content-type": [ + "application/json" + ], + "host": [ + "127.0.0.1:4000" + ], + "apollo-federation-include-trace": [ + "ftv1" + ], + "apollographql-client-name": [ + "manual" + ], + "accept": [ + "*/*" + ], + "user-agent": [ + "curl/7.79.1" + ], + "content-length": [ + "46" + ] + }, + "body": "{ + \"query\": \"query Long {\n me {\n name\n}\n}\" + }", + "context": { + "entries": { + "accepts-json": false, + "accepts-wildcard": true, + "accepts-multipart": false, + "this-is-a-test-context": 42 + } + }, + "sdl": "the sdl shouldnt change" + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }); + + let service = router_stage.as_service( + mock_http_client, + mock_router_service.boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + true, + ); + + let request = supergraph::Request::fake_builder() + .method(Method::GET) + .build() + .unwrap(); + + service.oneshot(request.try_into().unwrap()).await.unwrap(); + } + + #[tokio::test] + async fn external_plugin_router_request_controlflow_break() { + let router_stage = RouterStage { + request: RouterRequestConf { + condition: Default::default(), + headers: true, + context: ContextConf::NewContextConf(NewContextConf::All), + body: true, + sdl: true, + path: true, + method: true, + }, + response: Default::default(), + }; + + let mock_router_service = MockRouterService::new(); + + let mock_http_client = mock_with_callback(move |req: http::Request| { + Box::pin(async { + let deserialized_request: Externalizable = serde_json::from_slice( + &router::body::into_bytes(req.into_body()).await.unwrap(), + ) + .unwrap(); + + assert_eq!(EXTERNALIZABLE_VERSION, deserialized_request.version); + assert_eq!( + PipelineStep::RouterRequest.to_string(), + deserialized_request.stage + ); + + let input = json!( + { + "version": 1, + "stage": "RouterRequest", + "control": { + "break": 200 + }, + "id": "1b19c05fdafc521016df33148ad63c1b", + "body": "{ + \"errors\": [{ \"message\": \"my error message\" }] + }", + "context": { + "entries": { + "testKey": true + } + }, + "headers": { + "aheader": ["a value"] + } + } + ); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }); + + let service = router_stage.as_service( + mock_http_client, + mock_router_service.boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + true, + ); + + let request = supergraph::Request::canned_builder().build().unwrap(); + + let crate::services::router::Response { response, context } = + service.oneshot(request.try_into().unwrap()).await.unwrap(); + + assert!(context.get::<_, bool>("testKey").unwrap().unwrap()); + + let value = response.headers().get("aheader").unwrap(); + + assert_eq!("a value", value); + + let actual_response = serde_json::from_slice::( + &router::body::into_bytes(response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + + assert_eq!( + json!({ + "errors": [{ + "message": "my error message" + }] + }), + actual_response + ); + } + + #[tokio::test] + async fn external_plugin_router_request_controlflow_break_with_message_string() { + let router_stage = RouterStage { + request: RouterRequestConf { + condition: Default::default(), + headers: true, + context: ContextConf::NewContextConf(NewContextConf::All), + body: true, + sdl: true, + path: true, + method: true, + }, + response: Default::default(), + }; + + let mock_router_service = MockRouterService::new(); + + let mock_http_client = mock_with_callback(move |req: http::Request| { + Box::pin(async { + let deserialized_request: Externalizable = serde_json::from_slice( + &router::body::into_bytes(req.into_body()).await.unwrap(), + ) + .unwrap(); + + assert_eq!(EXTERNALIZABLE_VERSION, deserialized_request.version); + assert_eq!( + PipelineStep::RouterRequest.to_string(), + deserialized_request.stage + ); + + let input = json!( + { + "version": 1, + "stage": "RouterRequest", + "control": { + "break": 401 + }, + "id": "1b19c05fdafc521016df33148ad63c1b", + "body": "this is a test error", + } + ); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }); + + let service = router_stage.as_service( + mock_http_client, + mock_router_service.boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + true, + ); + + let request = supergraph::Request::canned_builder().build().unwrap(); + + let response = service + .oneshot(request.try_into().unwrap()) + .await + .unwrap() + .response; + + assert_eq!(response.status(), http::StatusCode::UNAUTHORIZED); + let actual_response = serde_json::from_slice::( + &router::body::into_bytes(response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + + assert_eq!( + json!({ + "errors": [{ + "message": "this is a test error", + "extensions": { + "code": "ERROR" + } + }] + }), + actual_response + ); + } + + #[tokio::test] + async fn external_plugin_router_response() { + let router_stage = RouterStage { + response: RouterResponseConf { + condition: Default::default(), + headers: true, + context: ContextConf::NewContextConf(NewContextConf::All), + body: true, + sdl: true, + status_code: false, + }, + request: Default::default(), + }; + + let mock_router_service = router::service::from_supergraph_mock_callback(move |req| { + Ok(supergraph::Response::builder() + .data(json!("{ \"test\": 1234_u32 }")) + .context(req.context) + .build() + .unwrap()) + }) + .await; + + let mock_http_client = + mock_with_deferred_callback(move |res: http::Request| { + Box::pin(async { + let deserialized_response: Externalizable = serde_json::from_slice( + &router::body::into_bytes(res.into_body()).await.unwrap(), + ) + .unwrap(); + + assert_eq!(EXTERNALIZABLE_VERSION, deserialized_response.version); + assert_eq!( + PipelineStep::RouterResponse.to_string(), + deserialized_response.stage + ); + + assert_eq!( + json!("{\"data\":\"{ \\\"test\\\": 1234_u32 }\"}"), + deserialized_response.body.unwrap() + ); + + let input = json!( + { + "version": 1, + "stage": "RouterResponse", + "control": { + "break": 400 + }, + "id": "1b19c05fdafc521016df33148ad63c1b", + "headers": { + "cookie": [ + "tasty_cookie=strawberry" + ], + "content-type": [ + "application/json" + ], + "host": [ + "127.0.0.1:4000" + ], + "apollo-federation-include-trace": [ + "ftv1" + ], + "apollographql-client-name": [ + "manual" + ], + "accept": [ + "*/*" + ], + "user-agent": [ + "curl/7.79.1" + ], + "content-length": [ + "46" + ] + }, + "body": "{ + \"data\": { \"test\": 42 } + }", + "context": { + "entries": { + "accepts-json": false, + "accepts-wildcard": true, + "accepts-multipart": false, + "this-is-a-test-context": 42 + } + }, + "sdl": "the sdl shouldnt change" + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }); + + let service = router_stage.as_service( + mock_http_client, + mock_router_service.boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + true, + ); + + let request = supergraph::Request::canned_builder().build().unwrap(); + + let res = service.oneshot(request.try_into().unwrap()).await.unwrap(); + + // Let's assert that the router request has been transformed as it should have. + assert_eq!(res.response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + res.response.headers().get("cookie").unwrap(), + "tasty_cookie=strawberry" + ); + + assert_eq!( + res.context + .get::<&str, u8>("this-is-a-test-context") + .unwrap() + .unwrap(), + 42 + ); + + // the body should have changed: + assert_eq!( + json!({ "data": { "test": 42_u32 } }), + serde_json::from_slice::( + &router::body::into_bytes(res.response.into_body()) + .await + .unwrap() + ) + .unwrap() + ); } #[tokio::test] - async fn external_plugin_router_request() { + async fn external_plugin_router_response_validation_disabled_custom() { + // Router stage doesn't actually implement response validation - it always uses + // permissive deserialization since it handles streaming responses differently let router_stage = RouterStage { - request: RouterRequestConf { - condition: Default::default(), - headers: true, - context: true, + response: RouterResponseConf { body: true, - sdl: true, - path: true, - method: true, + ..Default::default() }, - response: Default::default(), + ..Default::default() }; let mock_router_service = router::service::from_supergraph_mock_callback(move |req| { - // Let's assert that the router request has been transformed as it should have. - assert_eq!( - req.supergraph_request.headers().get("cookie").unwrap(), - "tasty_cookie=strawberry" - ); - - assert_eq!( - req.context - .get::<&str, u8>("this-is-a-test-context") - .unwrap() - .unwrap(), - 42 - ); - - // The query should have changed - assert_eq!( - "query Long {\n me {\n name\n}\n}", - req.supergraph_request.into_body().query.unwrap() - ); - Ok(supergraph::Response::builder() - .data(json!({ "test": 1234_u32 })) + .data(json!({"test": 42})) .context(req.context) .build() .unwrap()) }) .await; - let mock_http_client = mock_with_callback(move |req: http::Request| { + let mock_http_client = mock_with_deferred_callback(move |_: http::Request| { Box::pin(async { - let deserialized_request: Externalizable = - serde_json::from_slice(&hyper::body::to_bytes(req.into_body()).await.unwrap()) - .unwrap(); - - assert_eq!(EXTERNALIZABLE_VERSION, deserialized_request.version); - assert_eq!( - PipelineStep::RouterRequest.to_string(), - deserialized_request.stage - ); - - let input = json!( - { - "version": 1, - "stage": "RouterRequest", - "control": "continue", - "id": "1b19c05fdafc521016df33148ad63c1b", - "headers": { - "cookie": [ - "tasty_cookie=strawberry" - ], - "content-type": [ - "application/json" - ], - "host": [ - "127.0.0.1:4000" - ], - "apollo-federation-include-trace": [ - "ftv1" - ], - "apollographql-client-name": [ - "manual" - ], - "accept": [ - "*/*" - ], - "user-agent": [ - "curl/7.79.1" - ], - "content-length": [ - "46" - ] - }, - "body": "{ - \"query\": \"query Long {\n me {\n name\n}\n}\" - }", - "context": { - "entries": { - "accepts-json": false, - "accepts-wildcard": true, - "accepts-multipart": false, - "this-is-a-test-context": 42 - } - }, - "sdl": "the sdl shouldnt change" + // Return response that modifies the body - this demonstrates router stage processes + // coprocessor responses without GraphQL validation (unlike other stages) + let response = json!({ + "version": 1, + "stage": "RouterResponse", + "control": "continue", + "body": "{\"data\": {\"test\": \"modified_by_coprocessor\"}}" }); + Ok(http::Response::builder() - .body(RouterBody::from(serde_json::to_string(&input).unwrap())) + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) .unwrap()) }) }); - let service = router_stage.as_service( - mock_http_client, - mock_router_service.boxed(), - "http://test".to_string(), - Arc::new("".to_string()), - ); + let service_stack = router_stage + .as_service( + mock_http_client, + mock_router_service.boxed(), + "http://test".to_string(), + Arc::new("".to_string()), + false, // response_validation - doesn't matter for router stage + ) + .boxed(); - let request = supergraph::Request::canned_builder().build().unwrap(); + let request = router::Request::fake_builder().build().unwrap(); - service.oneshot(request.try_into().unwrap()).await.unwrap(); + let res = service_stack.oneshot(request).await.unwrap(); + + // Response should be processed normally since router stage doesn't validate + assert_eq!(res.response.status(), 200); + + // Router stage should accept the coprocessor response without validation + let body_bytes = router::body::into_bytes(res.response.into_body()) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body["data"]["test"], "modified_by_coprocessor"); } + // ===== ROUTER RESPONSE VALIDATION TESTS ===== + // Note: Router response stage doesn't implement GraphQL validation - it always uses permissive + // deserialization since it handles streaming responses differently than other stages + #[tokio::test] - async fn external_plugin_router_request_with_condition() { - let router_stage = RouterStage { - request: RouterRequestConf { - // Won't be satisfied - condition: Condition::Eq([ - SelectorOrValue::Selector(RouterSelector::RequestMethod { - request_method: true, - }), - SelectorOrValue::Value("GET".to_string().into()), - ]) - .into(), - headers: true, - context: true, + async fn external_plugin_router_response_validation_enabled_valid() { + let service_stack = create_router_stage_for_response_validation_test() + .as_service( + create_mock_http_client_router_response_valid_response(), + create_mock_router_service_for_validation_test().await, + "http://test".to_string(), + Arc::new("".to_string()), + true, // response_validation enabled - but router response ignores this + ) + .boxed(); + + let request = router::Request::fake_builder().build().unwrap(); + let res = service_stack.oneshot(request).await.unwrap(); + + // Router response stage processes all responses without validation regardless of setting + assert_eq!(res.response.status(), 200); + let body_bytes = router::body::into_bytes(res.response.into_body()) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body["data"]["test"], "valid_response"); + } + + #[tokio::test] + async fn external_plugin_router_response_validation_enabled_empty() { + let service_stack = create_router_stage_for_response_validation_test() + .as_service( + create_mock_http_client_router_response_empty_response(), + create_mock_router_service_for_validation_test().await, + "http://test".to_string(), + Arc::new("".to_string()), + true, // response_validation enabled - but router response ignores this + ) + .boxed(); + + let request = router::Request::fake_builder().build().unwrap(); + let res = service_stack.oneshot(request).await.unwrap(); + + // Router response stage accepts empty responses without validation + assert_eq!(res.response.status(), 200); + let body_bytes = router::body::into_bytes(res.response.into_body()) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); + // Empty object passes through unchanged since router response doesn't validate + assert!(body.as_object().unwrap().is_empty()); + } + + #[tokio::test] + async fn external_plugin_router_response_validation_enabled_invalid() { + let service_stack = create_router_stage_for_response_validation_test() + .as_service( + create_mock_http_client_router_response_invalid_response(), + create_mock_router_service_for_validation_test().await, + "http://test".to_string(), + Arc::new("".to_string()), + true, // response_validation enabled - but router response ignores this + ) + .boxed(); + + let request = router::Request::fake_builder().build().unwrap(); + let res = service_stack.oneshot(request).await.unwrap(); + + // Router response stage accepts invalid responses without validation + assert_eq!(res.response.status(), 200); + let body_bytes = router::body::into_bytes(res.response.into_body()) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); + // Invalid response passes through unchanged since router response doesn't validate + assert_eq!(body["errors"], "this should be an array not a string"); + } + + #[tokio::test] + async fn external_plugin_router_response_validation_disabled_valid() { + let service_stack = create_router_stage_for_response_validation_test() + .as_service( + create_mock_http_client_router_response_valid_response(), + create_mock_router_service_for_validation_test().await, + "http://test".to_string(), + Arc::new("".to_string()), + false, // response_validation disabled - same behavior as enabled for router response + ) + .boxed(); + + let request = router::Request::fake_builder().build().unwrap(); + let res = service_stack.oneshot(request).await.unwrap(); + + // Router response stage processes all responses identically regardless of validation setting + assert_eq!(res.response.status(), 200); + let body_bytes = router::body::into_bytes(res.response.into_body()) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body["data"]["test"], "valid_response"); + } + + #[tokio::test] + async fn external_plugin_router_response_validation_disabled_empty() { + let service_stack = create_router_stage_for_response_validation_test() + .as_service( + create_mock_http_client_router_response_empty_response(), + create_mock_router_service_for_validation_test().await, + "http://test".to_string(), + Arc::new("".to_string()), + false, // response_validation disabled - same behavior as enabled for router response + ) + .boxed(); + + let request = router::Request::fake_builder().build().unwrap(); + let res = service_stack.oneshot(request).await.unwrap(); + + // Router response stage behavior is identical whether validation is enabled or disabled + assert_eq!(res.response.status(), 200); + let body_bytes = router::body::into_bytes(res.response.into_body()) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); + // Empty object passes through unchanged + assert!(body.as_object().unwrap().is_empty()); + } + + #[tokio::test] + async fn external_plugin_router_response_validation_disabled_invalid() { + let service_stack = create_router_stage_for_response_validation_test() + .as_service( + create_mock_http_client_router_response_invalid_response(), + create_mock_router_service_for_validation_test().await, + "http://test".to_string(), + Arc::new("".to_string()), + false, // response_validation disabled - same behavior as enabled for router response + ) + .boxed(); + + let request = router::Request::fake_builder().build().unwrap(); + let res = service_stack.oneshot(request).await.unwrap(); + + // Router response stage behavior is identical whether validation is enabled or disabled + assert_eq!(res.response.status(), 200); + let body_bytes = router::body::into_bytes(res.response.into_body()) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); + // Invalid response passes through unchanged + assert_eq!(body["errors"], "this should be an array not a string"); + } + + // Helper functions for router request validation tests + fn create_router_stage_for_validation_test() -> RouterStage { + RouterStage { + request: RouterRequestConf { body: true, - sdl: true, - path: true, - method: true, + ..Default::default() }, - response: Default::default(), - }; + ..Default::default() + } + } - let mock_router_service = router::service::from_supergraph_mock_callback(move |req| { - assert!(req - .context - .get::<&str, u8>("this-is-a-test-context") - .ok() - .flatten() - .is_none()); + async fn create_mock_router_service_for_validation_test() -> router::BoxService { + router::service::from_supergraph_mock_callback(move |req| { Ok(supergraph::Response::builder() - .data(json!({ "test": 1234_u32 })) + .data(json!({"test": 42})) .context(req.context) .build() .unwrap()) }) - .await; + .await + .boxed() + } - let mock_http_client = mock_with_callback(move |req: http::Request| { + fn create_mock_http_client_empty_router_response() -> MockInternalHttpClientService { + mock_with_callback(move |_: http::Request| { Box::pin(async { - let deserialized_request: Externalizable = - serde_json::from_slice(&hyper::body::to_bytes(req.into_body()).await.unwrap()) - .unwrap(); - - assert_eq!(EXTERNALIZABLE_VERSION, deserialized_request.version); - assert_eq!( - PipelineStep::RouterRequest.to_string(), - deserialized_request.stage - ); - - let input = json!( - { - "version": 1, - "stage": "RouterRequest", - "control": "continue", - "id": "1b19c05fdafc521016df33148ad63c1b", - "headers": { - "cookie": [ - "tasty_cookie=strawberry" - ], - "content-type": [ - "application/json" - ], - "host": [ - "127.0.0.1:4000" - ], - "apollo-federation-include-trace": [ - "ftv1" - ], - "apollographql-client-name": [ - "manual" - ], - "accept": [ - "*/*" - ], - "user-agent": [ - "curl/7.79.1" - ], - "content-length": [ - "46" - ] - }, - "body": "{ - \"query\": \"query Long {\n me {\n name\n}\n}\" - }", - "context": { - "entries": { - "accepts-json": false, - "accepts-wildcard": true, - "accepts-multipart": false, - "this-is-a-test-context": 42 - } - }, - "sdl": "the sdl shouldnt change" + // Return empty GraphQL break response - passes serde but fails GraphQL validation + let response = json!({ + "version": 1, + "stage": "RouterRequest", + "control": { + "break": 400 + }, + "body": "{}" }); + Ok(http::Response::builder() - .body(RouterBody::from(serde_json::to_string(&input).unwrap())) + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) .unwrap()) }) - }); - - let service = router_stage.as_service( - mock_http_client, - mock_router_service.boxed(), - "http://test".to_string(), - Arc::new("".to_string()), - ); - - let request = supergraph::Request::canned_builder().build().unwrap(); - - service.oneshot(request.try_into().unwrap()).await.unwrap(); + }) } - #[tokio::test] - async fn external_plugin_router_request_http_get() { - let router_stage = RouterStage { - request: RouterRequestConf { + // Helper functions for router response validation tests + fn create_router_stage_for_response_validation_test() -> RouterStage { + RouterStage { + request: Default::default(), + response: RouterResponseConf { condition: Default::default(), headers: true, - context: true, + context: ContextConf::NewContextConf(NewContextConf::All), body: true, sdl: true, - path: true, - method: true, + status_code: false, }, - response: Default::default(), - }; - - let mock_router_service = router::service::from_supergraph_mock_callback(move |req| { - // Let's assert that the router request has been transformed as it should have. - assert_eq!( - req.supergraph_request.headers().get("cookie").unwrap(), - "tasty_cookie=strawberry" - ); - - // the method shouldn't have changed - assert_eq!(req.supergraph_request.method(), Method::GET); - // the uri shouldn't have changed - assert_eq!(req.supergraph_request.uri(), "/"); - - assert_eq!( - req.context - .get::<&str, u8>("this-is-a-test-context") - .unwrap() - .unwrap(), - 42 - ); - - // The query should have changed - assert_eq!( - "query Long {\n me {\n name\n}\n}", - req.supergraph_request.into_body().query.unwrap() - ); + } + } - Ok(supergraph::Response::builder() - .data(json!({ "test": 1234_u32 })) - .context(req.context) - .build() - .unwrap()) + // Helper function to create mock http client that returns valid GraphQL response + fn create_mock_http_client_router_response_valid_response() -> MockInternalHttpClientService { + mock_with_deferred_callback(move |_: http::Request| { + Box::pin(async { + let response = json!({ + "version": 1, + "stage": "RouterResponse", + "control": "continue", + "body": "{\"data\": {\"test\": \"valid_response\"}}" + }); + Ok(http::Response::builder() + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) + .unwrap()) + }) }) - .await; + } - let mock_http_client = mock_with_callback(move |req: http::Request| { + // Helper function to create mock http client that returns empty GraphQL response + fn create_mock_http_client_router_response_empty_response() -> MockInternalHttpClientService { + mock_with_deferred_callback(move |_: http::Request| { Box::pin(async { - let deserialized_request: Externalizable = - serde_json::from_slice(&hyper::body::to_bytes(req.into_body()).await.unwrap()) - .unwrap(); - - assert_eq!(EXTERNALIZABLE_VERSION, deserialized_request.version); - assert_eq!( - PipelineStep::RouterRequest.to_string(), - deserialized_request.stage - ); + let response = json!({ + "version": 1, + "stage": "RouterResponse", + "control": "continue", + "body": "{}" + }); + Ok(http::Response::builder() + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) + .unwrap()) + }) + }) + } - let input = json!( - { - "version": 1, - "stage": "RouterRequest", - "control": "continue", - "id": "1b19c05fdafc521016df33148ad63c1b", - "uri": "/this/is/a/new/uri", - "method": "POST", - "headers": { - "cookie": [ - "tasty_cookie=strawberry" - ], - "content-type": [ - "application/json" - ], - "host": [ - "127.0.0.1:4000" - ], - "apollo-federation-include-trace": [ - "ftv1" - ], - "apollographql-client-name": [ - "manual" - ], - "accept": [ - "*/*" - ], - "user-agent": [ - "curl/7.79.1" - ], - "content-length": [ - "46" - ] - }, - "body": "{ - \"query\": \"query Long {\n me {\n name\n}\n}\" - }", - "context": { - "entries": { - "accepts-json": false, - "accepts-wildcard": true, - "accepts-multipart": false, - "this-is-a-test-context": 42 - } - }, - "sdl": "the sdl shouldnt change" + // Helper function to create mock http client that returns invalid GraphQL response + fn create_mock_http_client_router_response_invalid_response() -> MockInternalHttpClientService { + mock_with_deferred_callback(move |_: http::Request| { + Box::pin(async { + let response = json!({ + "version": 1, + "stage": "RouterResponse", + "control": "continue", + "body": "{\"errors\": \"this should be an array not a string\"}" }); Ok(http::Response::builder() - .body(RouterBody::from(serde_json::to_string(&input).unwrap())) + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) .unwrap()) }) - }); + }) + } + + #[tokio::test] + async fn external_plugin_router_request_validation_disabled_empty() { + let service_stack = create_router_stage_for_validation_test() + .as_service( + create_mock_http_client_empty_router_response(), + create_mock_router_service_for_validation_test().await, + "http://test".to_string(), + Arc::new("".to_string()), + false, // response_validation disabled + ) + .boxed(); + + let request = router::Request::fake_builder().build().unwrap(); + let res = service_stack.oneshot(request).await.unwrap(); + + // Should return 400 due to break, but with permissive deserialization + assert_eq!(res.response.status(), 400); + + // Body should contain the empty response that passed serde but failed GraphQL validation + let body_bytes = router::body::into_bytes(res.response.into_body()) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); + // With validation disabled, should get empty object as response + assert!( + body.as_object().unwrap().is_empty() + || body.get("data").is_some() + || body.get("errors").is_some() + ); + } + + #[tokio::test] + async fn external_plugin_router_request_validation_enabled_empty() { + let service_stack = create_router_stage_for_validation_test() + .as_service( + create_mock_http_client_empty_router_response(), + create_mock_router_service_for_validation_test().await, + "http://test".to_string(), + Arc::new("".to_string()), + true, // response_validation enabled + ) + .boxed(); + + let request = router::Request::fake_builder().build().unwrap(); + let res = service_stack.oneshot(request).await.unwrap(); + + // Should return 400 due to break + assert_eq!(res.response.status(), 400); + + // Body should contain validation error from GraphQL validation failure + let body_bytes = router::body::into_bytes(res.response.into_body()) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); + // Should contain GraphQL errors from validation failure, not the original empty object + assert!(body.get("errors").is_some()); + // Verify it's a deserialization error (validation failed) + let errors = body["errors"].as_array().unwrap(); + assert!( + errors[0]["message"] + .as_str() + .unwrap() + .contains("couldn't deserialize coprocessor output body") + ); + } + + #[test] + fn it_externalizes_headers() { + // Build our expected HashMap + let mut expected = HashMap::new(); + + expected.insert( + "content-type".to_string(), + vec![APPLICATION_JSON.essence_str().to_string()], + ); + + expected.insert( + "accept".to_string(), + vec![ + APPLICATION_JSON.essence_str().to_string(), + TEXT_HTML.essence_str().to_string(), + ], + ); + + let mut external_form = HeaderMap::new(); - let service = router_stage.as_service( - mock_http_client, - mock_router_service.boxed(), - "http://test".to_string(), - Arc::new("".to_string()), + external_form.insert( + CONTENT_TYPE, + HeaderValue::from_static(APPLICATION_JSON.essence_str()), ); - let request = supergraph::Request::fake_builder() - .method(Method::GET) - .build() - .unwrap(); + external_form.insert( + ACCEPT, + HeaderValue::from_static(APPLICATION_JSON.essence_str()), + ); - service.oneshot(request.try_into().unwrap()).await.unwrap(); + external_form.append(ACCEPT, HeaderValue::from_static(TEXT_HTML.essence_str())); + + let actual = externalize_header_map(&external_form).expect("externalized header map"); + + assert_eq!(expected, actual); } - #[tokio::test] - async fn external_plugin_router_request_controlflow_break() { - let router_stage = RouterStage { - request: RouterRequestConf { - condition: Default::default(), - headers: true, - context: true, - body: true, - sdl: true, - path: true, - method: true, - }, - response: Default::default(), - }; + #[test] + fn it_internalizes_headers() { + // Build our expected HeaderMap + let mut expected = HeaderMap::new(); - let mock_router_service = MockRouterService::new(); + expected.insert( + ACCEPT, + HeaderValue::from_static(APPLICATION_JSON.essence_str()), + ); - let mock_http_client = mock_with_callback(move |req: http::Request| { - Box::pin(async { - let deserialized_request: Externalizable = - serde_json::from_slice(&get_body_bytes(req.into_body()).await.unwrap()) - .unwrap(); + expected.append(ACCEPT, HeaderValue::from_static(TEXT_HTML.essence_str())); - assert_eq!(EXTERNALIZABLE_VERSION, deserialized_request.version); - assert_eq!( - PipelineStep::RouterRequest.to_string(), - deserialized_request.stage - ); + let mut external_form = HashMap::new(); - let input = json!( - { - "version": 1, - "stage": "RouterRequest", - "control": { - "break": 200 - }, - "id": "1b19c05fdafc521016df33148ad63c1b", - "body": "{ - \"errors\": [{ \"message\": \"my error message\" }] - }", - "context": { - "entries": { - "testKey": true - } - }, - "headers": { - "aheader": ["a value"] - } - } - ); - Ok(http::Response::builder() - .body(RouterBody::from(serde_json::to_string(&input).unwrap())) - .unwrap()) - }) + external_form.insert( + "accept".to_string(), + vec![ + APPLICATION_JSON.essence_str().to_string(), + TEXT_HTML.essence_str().to_string(), + ], + ); + + // This header should be stripped + external_form.insert("content-length".to_string(), vec!["1024".to_string()]); + + let actual = internalize_header_map(external_form).expect("internalized header map"); + + assert_eq!(expected, actual); + } + + #[test] + fn test_handle_graphql_response_validation_enabled() { + let original = graphql::Response::builder() + .data(json!({"test": "original"})) + .build(); + + // Valid GraphQL response should work + let valid_response = json!({ + "data": {"test": "modified"} }); + let result = + handle_graphql_response(original.clone(), Some(valid_response), true, true).unwrap(); + assert_eq!(result.data, Some(json!({"test": "modified"}))); - let service = router_stage.as_service( - mock_http_client, - mock_router_service.boxed(), - "http://test".to_string(), - Arc::new("".to_string()), - ); + // Invalid GraphQL response should return error when validation enabled + let invalid_response = json!({ + "invalid": "structure" + }); + let result = handle_graphql_response(original.clone(), Some(invalid_response), true, true); + assert!(result.is_err()); + } - let request = supergraph::Request::canned_builder().build().unwrap(); + #[test] + fn test_handle_graphql_response_validation_disabled() { + let original = graphql::Response::builder() + .data(json!({"test": "original"})) + .build(); + + // Valid GraphQL response should work + let valid_response = json!({ + "data": {"test": "modified"} + }); + let result = + handle_graphql_response(original.clone(), Some(valid_response), false, true).unwrap(); + assert_eq!(result.data, Some(json!({"test": "modified"}))); + + // Invalid GraphQL response should return original when validation disabled + // Use a structure that will actually fail deserialization (wrong type for errors field) + let invalid_response = json!({ + "errors": "this should be an array not a string" + }); + let result = + handle_graphql_response(original.clone(), Some(invalid_response), false, true).unwrap(); + // With validation disabled, uses permissive serde deserialization instead of strict GraphQL validation + // Falls back to original response when serde deserialization fails (string can't deserialize to Vec) + assert_eq!(result.data, Some(json!({"test": "original"}))); + } - let crate::services::router::Response { response, context } = - service.oneshot(request.try_into().unwrap()).await.unwrap(); + #[test] + fn test_handle_graphql_response_validation_disabled_empty_response() { + let original = graphql::Response::builder() + .data(json!({"test": "original"})) + .build(); + + // Empty response violates GraphQL spec (must have data or errors) but should pass serde deserialization + let empty_response = json!({}); + let result = + handle_graphql_response(original.clone(), Some(empty_response), false, true).unwrap(); + + // With validation disabled, empty response deserializes successfully via serde + // (all fields are optional with defaults), resulting in a response with no data/errors + assert_eq!(result.data, None); + assert_eq!(result.errors.len(), 0); + } - assert!(context.get::<_, bool>("testKey").unwrap().unwrap()); + #[test] + fn test_handle_graphql_response_validation_enabled_empty_response() { + let original = graphql::Response::builder() + .data(json!({"test": "original"})) + .build(); - let value = response.headers().get("aheader").unwrap(); + // Empty response should fail strict GraphQL validation + let empty_response = json!({}); + let result = handle_graphql_response(original.clone(), Some(empty_response), true, true); - assert_eq!("a value", value); + // With validation enabled, should return error due to invalid GraphQL response structure + assert!(result.is_err()); + } - let actual_response = serde_json::from_slice::( - &hyper::body::to_bytes(response.into_body()).await.unwrap(), - ) - .unwrap(); + // Helper function to create subgraph stage for validation tests + fn create_subgraph_stage_for_validation_test() -> SubgraphStage { + SubgraphStage { + request: Default::default(), + response: SubgraphResponseConf { + condition: Condition::True, + headers: true, + context: ContextConf::NewContextConf(NewContextConf::All), + body: true, + service_name: false, + status_code: false, + subgraph_request_id: false, + }, + } + } - assert_eq!( - json!({ - "errors": [{ - "message": "my error message" - }] - }), - actual_response - ); + // Helper function to create mock subgraph service + fn create_mock_subgraph_service() -> MockSubgraphService { + let mut mock_subgraph_service = MockSubgraphService::new(); + mock_subgraph_service + .expect_call() + .returning(|req: subgraph::Request| { + Ok(subgraph::Response::builder() + .data(json!({ "test": 1234_u32 })) + .errors(Vec::new()) + .extensions(Object::new()) + .subgraph_name("coprocessorMockSubgraph") + .context(req.context) + .id(req.id) + .build()) + }); + mock_subgraph_service } - #[tokio::test] - async fn external_plugin_router_request_controlflow_break_with_message_string() { - let router_stage = RouterStage { - request: RouterRequestConf { - condition: Default::default(), + // Helper functions for subgraph request validation tests + fn create_subgraph_stage_for_request_validation_test() -> SubgraphStage { + SubgraphStage { + request: SubgraphRequestConf { + condition: Condition::True, headers: true, - context: true, + context: ContextConf::NewContextConf(NewContextConf::All), body: true, - sdl: true, - path: true, + uri: true, method: true, + service_name: true, + subgraph_request_id: true, }, response: Default::default(), - }; - - let mock_router_service = MockRouterService::new(); + } + } - let mock_http_client = mock_with_callback(move |req: http::Request| { + // Helper function to create mock http client that returns valid GraphQL break response + fn create_mock_http_client_subgraph_request_valid_response() -> MockInternalHttpClientService { + mock_with_callback(move |_: http::Request| { Box::pin(async { - let deserialized_request: Externalizable = - serde_json::from_slice(&hyper::body::to_bytes(req.into_body()).await.unwrap()) - .unwrap(); + let response = json!({ + "version": 1, + "stage": "SubgraphRequest", + "control": { + "break": 400 + }, + "body": { + "data": {"test": "valid_response"} + } + }); + Ok(http::Response::builder() + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) + .unwrap()) + }) + }) + } - assert_eq!(EXTERNALIZABLE_VERSION, deserialized_request.version); - assert_eq!( - PipelineStep::RouterRequest.to_string(), - deserialized_request.stage - ); + // Helper function to create mock http client that returns empty GraphQL break response + fn create_mock_http_client_subgraph_request_empty_response() -> MockInternalHttpClientService { + mock_with_callback(move |_: http::Request| { + Box::pin(async { + let response = json!({ + "version": 1, + "stage": "SubgraphRequest", + "control": { + "break": 400 + }, + "body": {} + }); + Ok(http::Response::builder() + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) + .unwrap()) + }) + }) + } - let input = json!( - { + // Helper function to create mock http client that returns invalid GraphQL break response + fn create_mock_http_client_subgraph_request_invalid_response() -> MockInternalHttpClientService + { + mock_with_callback(move |_: http::Request| { + Box::pin(async { + let response = json!({ "version": 1, - "stage": "RouterRequest", + "stage": "SubgraphRequest", "control": { - "break": 401 + "break": 400 }, - "id": "1b19c05fdafc521016df33148ad63c1b", - "body": "this is a test error", - } - ); + "body": { + "errors": "this should be an array not a string" + } + }); Ok(http::Response::builder() - .body(RouterBody::from(serde_json::to_string(&input).unwrap())) + .status(200) + .body(router::body::from_bytes( + serde_json::to_string(&response).unwrap(), + )) .unwrap()) }) - }); + }) + } - let service = router_stage.as_service( - mock_http_client, - mock_router_service.boxed(), + // Helper function to create mock http client that returns valid GraphQL response + fn create_mock_http_client_subgraph_response_valid_response() -> MockInternalHttpClientService { + mock_with_callback(move |_: http::Request| { + Box::pin(async { + let input = json!({ + "version": 1, + "stage": "SubgraphResponse", + "control": "continue", + "body": { + "data": {"test": "valid_response"} + } + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }) + } + + // Helper function to create mock http client that returns empty GraphQL response + fn create_mock_http_client_subgraph_response_empty_response() -> MockInternalHttpClientService { + mock_with_callback(move |_: http::Request| { + Box::pin(async { + let input = json!({ + "version": 1, + "stage": "SubgraphResponse", + "control": "continue", + "body": {} + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }) + } + + // Helper function to create mock http client that returns invalid GraphQL response + fn create_mock_http_client_invalid_subgraph_response() -> MockInternalHttpClientService { + mock_with_callback(move |_: http::Request| { + Box::pin(async { + let input = json!({ + "version": 1, + "stage": "SubgraphResponse", + "control": "continue", + "body": { + "errors": "this should be an array not a string" + } + }); + Ok(http::Response::builder() + .body(router::body::from_bytes( + serde_json::to_string(&input).unwrap(), + )) + .unwrap()) + }) + }) + } + + #[tokio::test] + async fn external_plugin_subgraph_response_validation_disabled_invalid() { + let service = create_subgraph_stage_for_validation_test().as_service( + create_mock_http_client_invalid_subgraph_response(), + create_mock_subgraph_service().boxed(), "http://test".to_string(), - Arc::new("".to_string()), + "my_subgraph_service_name".to_string(), + false, // Validation disabled ); - let request = supergraph::Request::canned_builder().build().unwrap(); + let request = subgraph::Request::fake_builder().build(); + let res = service.oneshot(request).await.unwrap(); - let response = service - .oneshot(request.try_into().unwrap()) - .await - .unwrap() - .response; + // With validation disabled, uses permissive serde deserialization instead of strict GraphQL validation + // Falls back to original response when serde deserialization fails (string can't deserialize to Vec) + assert_eq!( + &json!({ "test": 1234_u32 }), + res.response.body().data.as_ref().unwrap() + ); + } + + // ===== SUBGRAPH REQUEST VALIDATION TESTS ===== + + #[tokio::test] + async fn external_plugin_subgraph_request_validation_enabled_valid() { + let service = create_subgraph_stage_for_request_validation_test().as_service( + create_mock_http_client_subgraph_request_valid_response(), + create_mock_subgraph_service().boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + true, // Validation enabled + ); - assert_eq!(response.status(), http::StatusCode::UNAUTHORIZED); - let actual_response = serde_json::from_slice::( - &hyper::body::to_bytes(response.into_body()).await.unwrap(), - ) - .unwrap(); + let request = subgraph::Request::fake_builder().build(); + let res = service.oneshot(request).await.unwrap(); + // Should return 400 due to break with valid GraphQL response + assert_eq!(res.response.status(), 400); assert_eq!( - json!({ - "errors": [{ - "message": "this is a test error", - "extensions": { - "code": "ERROR" - } - }] - }), - actual_response + &json!({"test": "valid_response"}), + res.response.body().data.as_ref().unwrap() ); } #[tokio::test] - async fn external_plugin_router_response() { - let router_stage = RouterStage { - response: RouterResponseConf { - condition: Default::default(), - headers: true, - context: true, - body: true, - sdl: true, - status_code: false, - }, - request: Default::default(), - }; + async fn external_plugin_subgraph_request_validation_enabled_empty() { + let service = create_subgraph_stage_for_request_validation_test().as_service( + create_mock_http_client_subgraph_request_empty_response(), + create_mock_subgraph_service().boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + true, // Validation enabled + ); - let mock_router_service = router::service::from_supergraph_mock_callback(move |req| { - Ok(supergraph::Response::builder() - .data(json!("{ \"test\": 1234_u32 }")) - .context(req.context) - .build() - .unwrap()) - }) - .await; + let request = subgraph::Request::fake_builder().build(); + let res = service.oneshot(request).await.unwrap(); - let mock_http_client = - mock_with_deferred_callback(move |res: http::Request| { - Box::pin(async { - let deserialized_response: Externalizable = - serde_json::from_slice( - &hyper::body::to_bytes(res.into_body()).await.unwrap(), - ) - .unwrap(); + // Should return 400 with validation error since empty response violates GraphQL spec + assert_eq!(res.response.status(), 400); + assert!(!res.response.body().errors.is_empty()); + assert!( + res.response.body().errors[0] + .message + .contains("couldn't deserialize coprocessor output body") + ); + } - assert_eq!(EXTERNALIZABLE_VERSION, deserialized_response.version); - assert_eq!( - PipelineStep::RouterResponse.to_string(), - deserialized_response.stage - ); + #[tokio::test] + async fn external_plugin_subgraph_request_validation_enabled_invalid() { + let service = create_subgraph_stage_for_request_validation_test().as_service( + create_mock_http_client_subgraph_request_invalid_response(), + create_mock_subgraph_service().boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + true, // Validation enabled + ); - assert_eq!( - json!("{\"data\":\"{ \\\"test\\\": 1234_u32 }\"}"), - deserialized_response.body.unwrap() - ); + let request = subgraph::Request::fake_builder().build(); + let res = service.oneshot(request).await.unwrap(); - let input = json!( - { - "version": 1, - "stage": "RouterResponse", - "control": { - "break": 400 - }, - "id": "1b19c05fdafc521016df33148ad63c1b", - "headers": { - "cookie": [ - "tasty_cookie=strawberry" - ], - "content-type": [ - "application/json" - ], - "host": [ - "127.0.0.1:4000" - ], - "apollo-federation-include-trace": [ - "ftv1" - ], - "apollographql-client-name": [ - "manual" - ], - "accept": [ - "*/*" - ], - "user-agent": [ - "curl/7.79.1" - ], - "content-length": [ - "46" - ] - }, - "body": "{ - \"data\": { \"test\": 42 } - }", - "context": { - "entries": { - "accepts-json": false, - "accepts-wildcard": true, - "accepts-multipart": false, - "this-is-a-test-context": 42 - } - }, - "sdl": "the sdl shouldnt change" - }); - Ok(http::Response::builder() - .body(RouterBody::from(serde_json::to_string(&input).unwrap())) - .unwrap()) - }) - }); + // Should return 400 with validation error since errors should be array not string + assert_eq!(res.response.status(), 400); + assert!(!res.response.body().errors.is_empty()); + assert!( + res.response.body().errors[0] + .message + .contains("couldn't deserialize coprocessor output body") + ); + } - let service = router_stage.as_service( - mock_http_client, - mock_router_service.boxed(), + #[tokio::test] + async fn external_plugin_subgraph_request_validation_disabled_valid() { + let service = create_subgraph_stage_for_request_validation_test().as_service( + create_mock_http_client_subgraph_request_valid_response(), + create_mock_subgraph_service().boxed(), "http://test".to_string(), - Arc::new("".to_string()), + "my_subgraph_service_name".to_string(), + false, // Validation disabled ); - let request = supergraph::Request::canned_builder().build().unwrap(); - - let res = service.oneshot(request.try_into().unwrap()).await.unwrap(); + let request = subgraph::Request::fake_builder().build(); + let res = service.oneshot(request).await.unwrap(); - // Let's assert that the router request has been transformed as it should have. - assert_eq!(res.response.status(), StatusCode::BAD_REQUEST); + // Should return 400 due to break with valid response preserved via permissive deserialization + assert_eq!(res.response.status(), 400); assert_eq!( - res.response.headers().get("cookie").unwrap(), - "tasty_cookie=strawberry" + &json!({"test": "valid_response"}), + res.response.body().data.as_ref().unwrap() ); + } - assert_eq!( - res.context - .get::<&str, u8>("this-is-a-test-context") - .unwrap() - .unwrap(), - 42 + #[tokio::test] + async fn external_plugin_subgraph_request_validation_disabled_empty() { + let service = create_subgraph_stage_for_request_validation_test().as_service( + create_mock_http_client_subgraph_request_empty_response(), + create_mock_subgraph_service().boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + false, // Validation disabled ); - // the body should have changed: - assert_eq!( - json!({ "data": { "test": 42_u32 } }), - serde_json::from_slice::( - &get_body_bytes(res.response.into_body()).await.unwrap() - ) - .unwrap() - ); - } + let request = subgraph::Request::fake_builder().build(); + let res = service.oneshot(request).await.unwrap(); - #[test] - fn it_externalizes_headers() { - // Build our expected HashMap - let mut expected = HashMap::new(); + // Should return 400 with empty response preserved via permissive deserialization + assert_eq!(res.response.status(), 400); + // Empty object deserializes to GraphQL response with no data/errors + assert_eq!(res.response.body().data, None); + assert_eq!(res.response.body().errors.len(), 0); + } - expected.insert( - "content-type".to_string(), - vec![APPLICATION_JSON.essence_str().to_string()], + #[tokio::test] + async fn external_plugin_subgraph_request_validation_disabled_invalid() { + let service = create_subgraph_stage_for_request_validation_test().as_service( + create_mock_http_client_subgraph_request_invalid_response(), + create_mock_subgraph_service().boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + false, // Validation disabled ); - expected.insert( - "accept".to_string(), - vec![ - APPLICATION_JSON.essence_str().to_string(), - TEXT_HTML.essence_str().to_string(), - ], - ); + let request = subgraph::Request::fake_builder().build(); + let res = service.oneshot(request).await.unwrap(); - let mut external_form = HeaderMap::new(); + // Should return 400 with fallback to original response since invalid structure can't deserialize + assert_eq!(res.response.status(), 400); + // Falls back to original response since permissive deserialization fails too + assert!(res.response.body().data.is_some() || !res.response.body().errors.is_empty()); + } - external_form.insert( - CONTENT_TYPE, - HeaderValue::from_static(APPLICATION_JSON.essence_str()), + // ===== SUBGRAPH RESPONSE VALIDATION TESTS ===== + + #[tokio::test] + async fn external_plugin_subgraph_response_validation_enabled_valid() { + let service = create_subgraph_stage_for_validation_test().as_service( + create_mock_http_client_subgraph_response_valid_response(), + create_mock_subgraph_service().boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + true, // Validation enabled ); - external_form.insert( - ACCEPT, - HeaderValue::from_static(APPLICATION_JSON.essence_str()), + let request = subgraph::Request::fake_builder().build(); + let res = service.oneshot(request).await.unwrap(); + + // With validation enabled, valid GraphQL response should be processed normally + assert_eq!( + &json!({"test": "valid_response"}), + res.response.body().data.as_ref().unwrap() ); + } - external_form.append(ACCEPT, HeaderValue::from_static(TEXT_HTML.essence_str())); + #[tokio::test] + async fn external_plugin_subgraph_response_validation_enabled_empty() { + let service = create_subgraph_stage_for_validation_test().as_service( + create_mock_http_client_subgraph_response_empty_response(), + create_mock_subgraph_service().boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + true, // Validation enabled + ); - let actual = externalize_header_map(&external_form).expect("externalized header map"); + let request = subgraph::Request::fake_builder().build(); - assert_eq!(expected, actual); + // With validation enabled, empty response should cause service call to fail due to GraphQL validation + let result = service.oneshot(request).await; + assert!(result.is_err()); } - #[test] - fn it_internalizes_headers() { - // Build our expected HeaderMap - let mut expected = HeaderMap::new(); - - expected.insert( - ACCEPT, - HeaderValue::from_static(APPLICATION_JSON.essence_str()), + #[tokio::test] + async fn external_plugin_subgraph_response_validation_enabled_invalid() { + let service = create_subgraph_stage_for_validation_test().as_service( + create_mock_http_client_invalid_subgraph_response(), + create_mock_subgraph_service().boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + true, // Validation enabled ); - expected.append(ACCEPT, HeaderValue::from_static(TEXT_HTML.essence_str())); + let request = subgraph::Request::fake_builder().build(); - let mut external_form = HashMap::new(); + // With validation enabled, invalid GraphQL response should cause service call to fail + let result = service.oneshot(request).await; + assert!(result.is_err()); + } - external_form.insert( - "accept".to_string(), - vec![ - APPLICATION_JSON.essence_str().to_string(), - TEXT_HTML.essence_str().to_string(), - ], + #[tokio::test] + async fn external_plugin_subgraph_response_validation_disabled_valid() { + let service = create_subgraph_stage_for_validation_test().as_service( + create_mock_http_client_subgraph_response_valid_response(), + create_mock_subgraph_service().boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + false, // Validation disabled ); - // This header should be stripped - external_form.insert("content-length".to_string(), vec!["1024".to_string()]); + let request = subgraph::Request::fake_builder().build(); + let res = service.oneshot(request).await.unwrap(); - let actual = internalize_header_map(external_form).expect("internalized header map"); + // With validation disabled, valid response processed via permissive deserialization + assert_eq!( + &json!({"test": "valid_response"}), + res.response.body().data.as_ref().unwrap() + ); + } - assert_eq!(expected, actual); + #[tokio::test] + async fn external_plugin_subgraph_response_validation_disabled_empty() { + let service = create_subgraph_stage_for_validation_test().as_service( + create_mock_http_client_subgraph_response_empty_response(), + create_mock_subgraph_service().boxed(), + "http://test".to_string(), + "my_subgraph_service_name".to_string(), + false, // Validation disabled + ); + + let request = subgraph::Request::fake_builder().build(); + let res = service.oneshot(request).await.unwrap(); + + // With validation disabled, empty response deserializes successfully via serde + // (all fields are optional with defaults), resulting in a response with no data/errors + assert_eq!(res.response.body().data, None); + assert_eq!(res.response.body().errors.len(), 0); } #[allow(clippy::type_complexity)] @@ -1745,4 +3796,83 @@ mod tests { mock_http_client } + + // Tests for conditional validation based on incoming payload validity + + // Helper functions for readable tests + fn valid_response() -> crate::graphql::Response { + crate::graphql::Response::builder() + .data(json!({"field": "value"})) + .build() + } + + fn valid_response_with_errors() -> crate::graphql::Response { + use crate::graphql::Error; + crate::graphql::Response::builder() + .errors(vec![ + Error::builder() + .message("error") + .extension_code("TEST") + .build(), + ]) + .build() + } + + fn invalid_response() -> crate::graphql::Response { + crate::graphql::Response::builder().build() // No data, no errors + } + + fn valid_copro_body() -> Value { + json!({"data": {"field": "new_value"}}) + } + + fn invalid_copro_body() -> Value { + json!({}) // No data, no errors + } + + #[test] + fn test_minimal_graphql_validation() { + assert!(is_graphql_response_minimally_valid(&valid_response())); + assert!(is_graphql_response_minimally_valid( + &valid_response_with_errors() + )); + assert!(!is_graphql_response_minimally_valid(&invalid_response())); + } + + #[test] + fn test_was_incoming_payload_valid() { + // When body is not sent, always return true + assert!(was_incoming_payload_valid(&valid_response(), false)); + assert!(was_incoming_payload_valid(&invalid_response(), false)); + + // When body is sent, check validity + assert!(was_incoming_payload_valid(&valid_response(), true)); + assert!(!was_incoming_payload_valid(&invalid_response(), true)); + } + + #[test] + fn test_conditional_validation_logic() { + // Invalid incoming + validation enabled = validation bypassed (succeeds with invalid copro response) + assert!( + handle_graphql_response(invalid_response(), Some(invalid_copro_body()), true, false) + .is_ok() + ); + + // Valid incoming + validation enabled + invalid copro response = validation applied (fails) + assert!( + handle_graphql_response(valid_response(), Some(invalid_copro_body()), true, true) + .is_err() + ); + + // Valid incoming + validation enabled + valid copro response = validation applied (succeeds) + assert!( + handle_graphql_response(valid_response(), Some(valid_copro_body()), true, true).is_ok() + ); + + // Validation disabled = always bypassed (succeeds regardless) + assert!( + handle_graphql_response(valid_response(), Some(invalid_copro_body()), false, true) + .is_ok() + ); + } } diff --git a/apollo-router/src/plugins/cors.rs b/apollo-router/src/plugins/cors.rs new file mode 100644 index 0000000000..0c2b8b8f29 --- /dev/null +++ b/apollo-router/src/plugins/cors.rs @@ -0,0 +1,1122 @@ +//! Cross Origin Resource Sharing (CORS) plugin + +use std::future::Future; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; + +use http::Request; +use http::Response; +use http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS; +use http::header::ACCESS_CONTROL_ALLOW_HEADERS; +use http::header::ACCESS_CONTROL_ALLOW_METHODS; +use http::header::ACCESS_CONTROL_ALLOW_ORIGIN; +use http::header::ACCESS_CONTROL_EXPOSE_HEADERS; +use http::header::ACCESS_CONTROL_MAX_AGE; +use http::header::ACCESS_CONTROL_REQUEST_HEADERS; +use http::header::ORIGIN; +use http::header::VARY; +use tower::Layer; +use tower::Service; + +use crate::configuration::cors::Cors; +use crate::configuration::cors::Policy; + +/// Our custom CORS layer that supports per-origin configuration +#[derive(Clone, Debug)] +pub(crate) struct CorsLayer { + config: Cors, +} + +impl CorsLayer { + pub(crate) fn new(config: Cors) -> Result { + // Ensure configuration is valid before creating CorsLayer + config.ensure_usable_cors_rules()?; + + // Validate global headers + if !config.allow_headers.is_empty() { + parse_values::(&config.allow_headers, "allow header name")?; + } + + // Validate global methods + parse_values::(&config.methods, "method")?; + + // Validate global expose headers + if let Some(headers) = &config.expose_headers { + parse_values::(headers, "expose header name")?; + } + + // Validate origin configurations + if let Some(policies) = &config.policies { + for policy in policies { + // Validate origin URLs + for origin in &policy.origins { + http::HeaderValue::from_str(origin).map_err(|_| { + format!("origin '{origin}' is not valid: failed to parse header value") + })?; + } + + // Validate origin-specific headers + if !policy.allow_headers.is_empty() { + parse_values::(&policy.allow_headers, "allow header name")?; + } + + // Validate origin-specific methods + if let Some(methods) = &policy.methods + && !methods.is_empty() + { + parse_values::(methods, "method")?; + } + + // Validate origin-specific expose headers + if !policy.expose_headers.is_empty() { + parse_values::(&policy.expose_headers, "expose header name")?; + } + } + } + + Ok(Self { config }) + } +} + +impl Layer for CorsLayer { + type Service = CorsService; + + fn layer(&self, service: S) -> Self::Service { + CorsService { + inner: service, + config: self.config.clone(), + } + } +} + +/// Our custom CORS service that handles per-origin configuration +#[derive(Clone, Debug)] +pub(crate) struct CorsService { + inner: S, + config: Cors, +} + +impl Service> for CorsService +where + S: Service, Response = Response> + Send + 'static, + S::Future: Send + 'static, + S::Error: Send + 'static, + ReqBody: Send + 'static, + ResBody: Send + 'static + Default, +{ + type Response = S::Response; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let request_origin = req.headers().get(ORIGIN).cloned(); + let is_preflight = req.method() == http::Method::OPTIONS; + let config = self.config.clone(); + let request_headers = req.headers().get(ACCESS_CONTROL_REQUEST_HEADERS).cloned(); + + // Intercept OPTIONS requests and return preflight response directly + if is_preflight { + let mut response = Response::builder() + .status(http::StatusCode::OK) + .body(ResBody::default()) + .unwrap(); + // Find matching origin configuration + let policy = Self::find_matching_policy(&config, &request_origin); + // Add CORS headers for preflight + Self::add_cors_headers( + &mut response, + &config, + &policy, + &request_origin, + true, + request_headers, + ); + return Box::pin(async move { Ok(response) }); + } + + let fut = self.inner.call(req); + Box::pin(async move { + let mut response = fut.await?; + // Find matching origin configuration + let policy = Self::find_matching_policy(&config, &request_origin); + // Add CORS headers for non-preflight + Self::add_cors_headers( + &mut response, + &config, + &policy, + &request_origin, + false, + request_headers, + ); + Ok(response) + }) + } +} + +impl CorsService { + /// Find the matching policy for a given origin + fn find_matching_policy<'a>( + config: &'a Cors, + origin: &'a Option, + ) -> Option<&'a Policy> { + let origin_str = origin.as_ref()?.to_str().ok()?; + + // Security guard: null origins are only allowed when allow_any_origin is true + if origin_str == "null" && !config.allow_any_origin { + return None; + } + + if let Some(policies) = &config.policies { + for policy in policies.iter() { + for url in &policy.origins { + if url == origin_str { + return Some(policy); + } + } + + if !policy.match_origins.is_empty() { + for regex in &policy.match_origins { + if regex.is_match(origin_str) { + return Some(policy); + } + } + } + } + } + + None + } + + /// Add CORS headers to the response + fn add_cors_headers( + response: &mut Response, + config: &Cors, + policy: &Option<&Policy>, + request_origin: &Option, + is_preflight: bool, + request_headers: Option, + ) { + let allow_credentials = policy + .and_then(|p| p.allow_credentials) + .unwrap_or(config.allow_credentials); + + let allow_headers = policy + .and_then(|p| { + if p.allow_headers.is_empty() { + None + } else { + Some(&p.allow_headers) + } + }) + .unwrap_or(&config.allow_headers); + + // Distinguish between None, Some([]), and Some([item, ...]) for expose_headers + let expose_headers = if let Some(policy) = policy { + if policy.expose_headers.is_empty() { + config.expose_headers.as_ref() + } else { + Some(&policy.expose_headers) + } + } else { + config.expose_headers.as_ref() + }; + + // Distinguish between None, Some([]), and Some([item, ...]) for methods + let methods = if let Some(policy) = policy { + match &policy.methods { + None => &config.methods, + Some(methods) => methods, + } + } else { + &config.methods + }; + + let max_age = policy.and_then(|p| p.max_age).or(config.max_age); + + // Set Access-Control-Allow-Origin + if let Some(origin) = request_origin { + if config.allow_any_origin { + response.headers_mut().insert( + ACCESS_CONTROL_ALLOW_ORIGIN, + http::HeaderValue::from_static("*"), + ); + } else if policy.is_some() { + // Only set the header if we found a matching origin configuration + response + .headers_mut() + .insert(ACCESS_CONTROL_ALLOW_ORIGIN, origin.clone()); + } + // If no matching origin config found, don't set the header (origin will be rejected) + } + + // Set Access-Control-Allow-Credentials + if allow_credentials { + response.headers_mut().insert( + ACCESS_CONTROL_ALLOW_CREDENTIALS, + http::HeaderValue::from_static("true"), + ); + } + + // Set Access-Control-Allow-Headers (only for preflight requests) + if is_preflight { + if !allow_headers.is_empty() { + // Join the headers with commas for a single header value + let header_value = allow_headers.join(", "); + response.headers_mut().insert( + ACCESS_CONTROL_ALLOW_HEADERS, + http::HeaderValue::from_str(&header_value) + .unwrap_or_else(|_| http::HeaderValue::from_static("")), + ); + } else { + // If no headers are configured, mirror the client's Access-Control-Request-Headers + if let Some(request_headers) = request_headers + && let Ok(headers_str) = request_headers.to_str() + { + response.headers_mut().insert( + ACCESS_CONTROL_ALLOW_HEADERS, + http::HeaderValue::from_str(headers_str) + .unwrap_or_else(|_| http::HeaderValue::from_static("")), + ); + } + } + } + + // Set Access-Control-Expose-Headers (only for non-preflight requests) + if !is_preflight && let Some(headers) = expose_headers { + // Join the headers with commas for a single header value + let header_value = headers.join(", "); + response.headers_mut().insert( + ACCESS_CONTROL_EXPOSE_HEADERS, + http::HeaderValue::from_str(&header_value) + .unwrap_or_else(|_| http::HeaderValue::from_static("")), + ); + } + + // Set Access-Control-Allow-Methods (for preflight requests) + // The CORS protocol specifies an Access-Control-Request-Method header on requests, + // but no matter its value, we would reply with the same Access-Control-Allow-Methods + // header, so we don't need to look at it. The browser will enforce the right thing here. + if is_preflight { + // Join the methods with commas for a single header value + let method_value = methods.join(", "); + response.headers_mut().insert( + ACCESS_CONTROL_ALLOW_METHODS, + http::HeaderValue::from_str(&method_value) + .unwrap_or_else(|_| http::HeaderValue::from_static("")), + ); + } + + // Set Access-Control-Max-Age (only for preflight requests) + if is_preflight && let Some(max_age) = max_age { + let max_age_secs = max_age.as_secs(); + response.headers_mut().insert( + ACCESS_CONTROL_MAX_AGE, + http::HeaderValue::from_str(&max_age_secs.to_string()) + .unwrap_or_else(|_| http::HeaderValue::from_static("")), + ); + } + + // Set Vary header - append to existing values instead of overwriting + Self::append_vary_header(response, ORIGIN); + + // For preflight requests, also vary on Access-Control-Request-Headers + // since the presence/content of this header affects the response + if is_preflight { + Self::append_vary_header(response, ACCESS_CONTROL_REQUEST_HEADERS); + } + } + + /// Append a value to the Vary header, preserving existing values + fn append_vary_header(response: &mut Response, value: http::HeaderName) { + let headers = response.headers_mut(); + + if let Some(existing_vary) = headers.get(VARY) { + // Get existing value and append new value + if let Ok(existing_str) = existing_vary.to_str() { + // Check if the value is already present to avoid duplicates + let mut existing_values = existing_str.split(',').map(|v| v.trim()); + + if !existing_values.any(|existing| existing.eq_ignore_ascii_case(value.as_str())) { + let new_vary = format!("{existing_str}, {value}"); + let new_header_value = http::HeaderValue::from_str(&new_vary) + .expect("combining pre-existing header + hardcoded valid value can not produce an invalid result"); + headers.insert(VARY, new_header_value); + } + } else { + let lossy_str = String::from_utf8_lossy(existing_vary.as_bytes()); + tracing::error!( + "could not append Vary header, because the existing value is not UTF-8: {lossy_str}" + ); + } + } else { + // No existing Vary header, set it to the new value + headers.insert(VARY, http::HeaderValue::from(value)); + } + } +} + +fn parse_values(values_to_parse: &[String], error_description: &str) -> Result, String> +where + T: std::str::FromStr, + ::Err: std::fmt::Display, +{ + let mut errors = Vec::new(); + let mut values = Vec::new(); + for val in values_to_parse { + match val + .parse::() + .map_err(|err| format!("{error_description} '{val}' is not valid: {err}")) + { + Ok(val) => values.push(val), + Err(err) => errors.push(err), + } + } + + if errors.is_empty() { + Ok(values) + } else { + Err(errors.join(", ")) + } +} + +#[cfg(test)] +mod tests { + use std::future::Future; + use std::pin::Pin; + use std::task::Context; + use std::task::Poll; + + use http::Request; + use http::Response; + use http::StatusCode; + use http::header::ACCESS_CONTROL_ALLOW_ORIGIN; + use http::header::ACCESS_CONTROL_EXPOSE_HEADERS; + use http::header::ORIGIN; + use tower::Service; + + use super::*; + use crate::configuration::cors::Cors; + use crate::configuration::cors::Policy; + + struct DummyService; + impl Service> for DummyService { + type Response = Response<&'static str>; + type Error = (); + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: Request<()>) -> Self::Future { + Box::pin(async { + Ok(Response::builder() + .status(StatusCode::OK) + .body("ok") + .unwrap()) + }) + } + } + + #[test] + fn test_bad_allow_headers_cors_configuration() { + let cors = Cors::builder() + .allow_headers(vec![String::from("bad\nname")]) + .build(); + let layer = CorsLayer::new(cors); + assert!(layer.is_err()); + + assert_eq!( + layer.unwrap_err(), + String::from("allow header name 'bad\nname' is not valid: invalid HTTP header name") + ); + } + + #[test] + fn test_bad_allow_methods_cors_configuration() { + let cors = Cors::builder() + .methods(vec![String::from("bad\nmethod")]) + .build(); + let layer = CorsLayer::new(cors); + assert!(layer.is_err()); + + assert_eq!( + layer.unwrap_err(), + String::from("method 'bad\nmethod' is not valid: invalid HTTP method") + ); + } + + #[test] + fn test_bad_origins_cors_configuration() { + let cors = Cors::builder() + .policies(vec![ + Policy::builder() + .origins(vec![String::from("bad\norigin")]) + .build(), + ]) + .build(); + let layer = CorsLayer::new(cors); + assert!(layer.is_err()); + + assert_eq!( + layer.unwrap_err(), + String::from("origin 'bad\norigin' is not valid: failed to parse header value") + ); + } + + #[test] + fn test_good_cors_configuration() { + let cors = Cors::builder() + .allow_headers(vec![String::from("good-name")]) + .build(); + let layer = CorsLayer::new(cors); + assert!(layer.is_ok()); + } + + #[test] + fn test_non_preflight_cors_headers() { + let cors = Cors::builder() + .policies(vec![ + Policy::builder() + .origins(vec!["https://trusted.com".into()]) + .expose_headers(vec!["x-custom-header".into()]) + .build(), + ]) + .build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + let req = Request::get("/") + .header(ORIGIN, "https://trusted.com") + .body(()) + .unwrap(); + let fut = service.call(req); + let resp = futures::executor::block_on(fut).unwrap(); + let headers = resp.headers(); + assert_eq!( + headers.get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), + "https://trusted.com" + ); + assert_eq!( + headers.get(ACCESS_CONTROL_EXPOSE_HEADERS).unwrap(), + "x-custom-header" + ); + } + + #[test] + fn test_expose_headers_non_preflight_set() { + let cors = Cors::builder() + .expose_headers(vec!["x-foo".into(), "x-bar".into()]) + .build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + let req = Request::get("/") + .header(ORIGIN, "https://studio.apollographql.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + assert_eq!( + headers.get(ACCESS_CONTROL_EXPOSE_HEADERS).unwrap(), + "x-foo, x-bar" + ); + } + + #[test] + fn test_expose_headers_non_preflight_not_set() { + let cors = Cors::builder().build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + let req = Request::get("/") + .header(ORIGIN, "https://studio.apollographql.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + assert!(headers.get(ACCESS_CONTROL_EXPOSE_HEADERS).is_none()); + } + + #[test] + fn test_mirror_request_headers_preflight() { + let cors = Cors::builder().allow_headers(vec![]).build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + let req = Request::builder() + .method("OPTIONS") + .uri("/") + .header(ORIGIN, "https://studio.apollographql.com") + .header(ACCESS_CONTROL_REQUEST_HEADERS, "x-foo, x-bar") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + let allow_headers = headers.get(ACCESS_CONTROL_ALLOW_HEADERS).unwrap(); + assert_eq!(allow_headers, "x-foo, x-bar"); + } + + #[test] + fn test_no_mirror_request_headers_non_preflight() { + let cors = Cors::builder().allow_headers(vec![]).build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + let req = Request::get("/") + .header(ORIGIN, "https://studio.apollographql.com") + .header(ACCESS_CONTROL_REQUEST_HEADERS, "x-foo, x-bar") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + // Should not set ACCESS_CONTROL_ALLOW_HEADERS for non-preflight + assert!(headers.get(ACCESS_CONTROL_ALLOW_HEADERS).is_none()); + } + + #[test] + fn test_cors_headers_comma_separated_format() { + // Test that Access-Control-Allow-Headers uses comma-separated format + let cors = Cors::builder() + .allow_headers(vec![ + "content-type".into(), + "authorization".into(), + "x-custom".into(), + ]) + .build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + let req = Request::builder() + .method("OPTIONS") + .uri("/") + .header(ORIGIN, "https://studio.apollographql.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + + // Should have a single header with comma-separated values + let allow_headers = headers.get(ACCESS_CONTROL_ALLOW_HEADERS).unwrap(); + assert_eq!(allow_headers, "content-type, authorization, x-custom"); + + // Should not have multiple separate headers + let all_headers = headers.get_all(ACCESS_CONTROL_ALLOW_HEADERS); + assert_eq!(all_headers.iter().count(), 1); + } + + #[test] + fn test_cors_methods_comma_separated_format() { + // Test that Access-Control-Allow-Methods uses comma-separated format + let cors = Cors { + allow_any_origin: false, + allow_credentials: false, + allow_headers: vec![], + expose_headers: None, + methods: vec!["GET".into(), "POST".into(), "PUT".into()], + max_age: None, + policies: None, + }; + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + let req = Request::builder() + .method("OPTIONS") + .uri("/") + .header(ORIGIN, "https://studio.apollographql.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + + // Should have a single header with comma-separated values + let allow_methods = headers.get(ACCESS_CONTROL_ALLOW_METHODS).unwrap(); + assert_eq!(allow_methods, "GET, POST, PUT"); + + // Should not have multiple separate headers + let all_methods = headers.get_all(ACCESS_CONTROL_ALLOW_METHODS); + assert_eq!(all_methods.iter().count(), 1); + } + + #[test] + fn test_policy_methods_fallback_to_global() { + // Test that when a policy doesn't specify methods, it falls back to global methods + let cors = Cors::builder() + .methods(vec!["POST".into()]) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .build(), + ]) + .build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + + // Test preflight request from the policy origin + let req = Request::builder() + .method("OPTIONS") + .uri("/") + .header(ORIGIN, "https://example.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + + // Should use the global methods (POST) instead of default methods + let allow_methods = headers.get(ACCESS_CONTROL_ALLOW_METHODS).unwrap(); + assert_eq!(allow_methods, "POST"); + } + + #[test] + fn test_policy_empty_methods_runtime() { + // Test that a policy with empty methods ([]) overrides global methods + let cors = Cors::builder() + .methods(vec!["POST".into(), "PUT".into()]) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .methods(vec![]) + .build(), + ]) + .build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + + // Test preflight request from the policy origin + let req = Request::builder() + .method("OPTIONS") + .uri("/") + .header(ORIGIN, "https://example.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + + // Should use empty methods (no methods allowed) + let allow_methods = headers.get(ACCESS_CONTROL_ALLOW_METHODS).unwrap(); + assert_eq!(allow_methods, ""); + } + + #[test] + fn test_policy_specific_methods_runtime() { + // Test that a policy with specific methods uses those methods + let cors = Cors::builder() + .methods(vec!["POST".into()]) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .methods(vec!["GET".into(), "DELETE".into()]) + .build(), + ]) + .build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + + // Test preflight request from the policy origin + let req = Request::builder() + .method("OPTIONS") + .uri("/") + .header(ORIGIN, "https://example.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + + // Should use the specific methods (GET, DELETE) + let allow_methods = headers.get(ACCESS_CONTROL_ALLOW_METHODS).unwrap(); + assert_eq!(allow_methods, "GET, DELETE"); + } + + #[test] + fn test_null_origin_rejected_with_catch_all_regex() { + // Test that null origins are rejected even when there's a catch-all regex pattern + let cors = Cors::builder() + .allow_any_origin(false) + .policies(vec![ + Policy::builder() + .origins(vec![]) + .match_origins(vec![regex::Regex::new(".*").unwrap()]) + .allow_credentials(false) + .build(), + ]) + .build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + + // Test that null origin is rejected (no ACCESS_CONTROL_ALLOW_ORIGIN header) + let req = Request::get("/").header(ORIGIN, "null").body(()).unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + assert!(headers.get(ACCESS_CONTROL_ALLOW_ORIGIN).is_none()); + } + + #[test] + fn test_null_origin_rejected_with_specific_regex() { + // Test that null origins are rejected even with a regex that matches "null" + let cors = Cors::builder() + .allow_any_origin(false) + .policies(vec![ + Policy::builder() + .origins(vec![]) + .match_origins(vec![regex::Regex::new("n.ll").unwrap()]) + .allow_credentials(false) + .build(), + ]) + .build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + + // Test that null origin is rejected despite matching the regex + let req = Request::get("/").header(ORIGIN, "null").body(()).unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + assert!(headers.get(ACCESS_CONTROL_ALLOW_ORIGIN).is_none()); + } + + #[test] + fn test_null_origin_allowed_with_allow_any_origin() { + // Test that null origins are allowed when allow_any_origin is true + let cors = Cors::builder().allow_any_origin(true).build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + + // Test that null origin is allowed (ACCESS_CONTROL_ALLOW_ORIGIN should be *) + let req = Request::get("/").header(ORIGIN, "null").body(()).unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + assert_eq!(headers.get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), "*"); + } + + #[test] + fn test_regular_origins_still_work_with_null_guard() { + // Test that regular origins still work normally after adding null guard + let cors = Cors::builder() + .allow_any_origin(false) + .policies(vec![ + Policy::builder() + .origins(vec!["https://example.com".into()]) + .build(), + Policy::builder() + .origins(vec![]) + .match_origins(vec![regex::Regex::new("https://.*\\.test\\.com").unwrap()]) + .build(), + ]) + .build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + + // Test exact match still works + let req = Request::get("/") + .header(ORIGIN, "https://example.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + assert_eq!( + headers.get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), + "https://example.com" + ); + + // Test regex match still works + let req2 = Request::get("/") + .header(ORIGIN, "https://api.test.com") + .body(()) + .unwrap(); + let resp2 = futures::executor::block_on(service.call(req2)).unwrap(); + let headers2 = resp2.headers(); + assert_eq!( + headers2.get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), + "https://api.test.com" + ); + + // Test that unmatched origin is still rejected + let req3 = Request::get("/") + .header(ORIGIN, "https://malicious.com") + .body(()) + .unwrap(); + let resp3 = futures::executor::block_on(service.call(req3)).unwrap(); + let headers3 = resp3.headers(); + assert!(headers3.get(ACCESS_CONTROL_ALLOW_ORIGIN).is_none()); + } + + #[test] + fn test_null_origin_preflight_request_rejected() { + // Test that null origins are rejected in preflight requests too + let cors = Cors::builder() + .allow_any_origin(false) + .policies(vec![ + Policy::builder() + .origins(vec![]) + .match_origins(vec![regex::Regex::new(".*").unwrap()]) + .allow_credentials(false) + .build(), + ]) + .build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + + // Test that null origin preflight request is rejected + let req = Request::builder() + .method("OPTIONS") + .uri("/") + .header(ORIGIN, "null") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + assert!(headers.get(ACCESS_CONTROL_ALLOW_ORIGIN).is_none()); + } + + #[test] + fn test_null_origin_preflight_allowed_with_allow_any_origin() { + // Test that null origins are allowed in preflight requests when allow_any_origin is true + let cors = Cors::builder().allow_any_origin(true).build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + + // Test that null origin preflight request is allowed + let req = Request::builder() + .method("OPTIONS") + .uri("/") + .header(ORIGIN, "null") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + assert_eq!(headers.get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), "*"); + } + + #[test] + fn test_vary_header_set_for_cors_requests() { + // Test that Vary header is properly set to "Origin" for CORS requests + let cors = Cors::builder().build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + + let req = Request::get("/") + .header(ORIGIN, "https://studio.apollographql.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + assert_eq!(headers.get(VARY).unwrap(), "origin"); + } + + #[test] + fn test_vary_header_preserves_existing_values() { + // Test that existing Vary header values are preserved when adding Origin + struct VaryService; + impl Service> for VaryService { + type Response = Response<&'static str>; + type Error = (); + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: Request<()>) -> Self::Future { + Box::pin(async { + Ok(Response::builder() + .status(StatusCode::OK) + .header(VARY, "Accept-Encoding, User-Agent") + .body("ok") + .unwrap()) + }) + } + } + + let cors = Cors::builder().build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(VaryService); + + let req = Request::get("/") + .header(ORIGIN, "https://studio.apollographql.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + assert_eq!( + headers.get(VARY).unwrap(), + "Accept-Encoding, User-Agent, origin" + ); + } + + #[test] + fn test_vary_header_no_duplicates() { + // Test that duplicate values are not added to Vary header + struct VaryWithOriginService; + impl Service> for VaryWithOriginService { + type Response = Response<&'static str>; + type Error = (); + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: Request<()>) -> Self::Future { + Box::pin(async { + Ok(Response::builder() + .status(StatusCode::OK) + .header(VARY, "accept-encoding, origin, user-agent") + .body("ok") + .unwrap()) + }) + } + } + + let cors = Cors::builder().build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(VaryWithOriginService); + + let req = Request::get("/") + .header(ORIGIN, "https://studio.apollographql.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + // Should not duplicate Origin + assert_eq!( + headers.get(VARY).unwrap(), + "accept-encoding, origin, user-agent" + ); + } + + #[test] + fn test_vary_header_no_duplicates_case_insensitive() { + // Test that duplicate values are not added to Vary header + struct VaryWithOriginService; + impl Service> for VaryWithOriginService { + type Response = Response<&'static str>; + type Error = (); + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: Request<()>) -> Self::Future { + Box::pin(async { + Ok(Response::builder() + .status(StatusCode::OK) + .header(VARY, "Accept-Encoding, Origin, User-Agent") + .body("ok") + .unwrap()) + }) + } + } + + let cors = Cors::builder().build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(VaryWithOriginService); + + let req = Request::get("/") + .header(ORIGIN, "https://studio.apollographql.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + // Should not duplicate Origin + assert_eq!( + headers.get(VARY).unwrap(), + "Accept-Encoding, Origin, User-Agent" + ); + } + + #[test] + fn test_vary_header_preflight_includes_request_headers() { + // Test that preflight requests include both Origin and Access-Control-Request-Headers in Vary + let cors = Cors::builder().build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + + let req = Request::builder() + .method("OPTIONS") + .uri("/") + .header(ORIGIN, "https://studio.apollographql.com") + .header(ACCESS_CONTROL_REQUEST_HEADERS, "content-type") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + let vary_header = headers.get(VARY).unwrap().to_str().unwrap(); + assert!(vary_header.contains("origin")); + assert!(vary_header.contains("access-control-request-headers")); + } + + #[test] + fn test_vary_header_non_preflight_only_origin() { + // Test that non-preflight requests only include Origin in Vary (not Access-Control-Request-Headers) + let cors = Cors::builder().build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + + let req = Request::get("/") + .header(ORIGIN, "https://studio.apollographql.com") + .header(ACCESS_CONTROL_REQUEST_HEADERS, "content-type") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + let vary_header = headers.get(VARY).unwrap().to_str().unwrap(); + assert_eq!(vary_header, "origin"); + } + + #[test] + fn test_vary_header_preserves_complex_existing_values_non_preflight() { + // Test complex scenario with existing Vary header and non-preflight request + // Note: preflight requests create new responses so don't preserve underlying service headers + struct ComplexVaryService; + impl Service> for ComplexVaryService { + type Response = Response<&'static str>; + type Error = (); + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: Request<()>) -> Self::Future { + Box::pin(async { + Ok(Response::builder() + .status(StatusCode::OK) + .header(VARY, "Accept-Language, Accept-Encoding") + .body("ok") + .unwrap()) + }) + } + } + + let cors = Cors::builder().build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(ComplexVaryService); + + let req = Request::get("/") + .header(ORIGIN, "https://studio.apollographql.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + let vary_header = headers.get(VARY).unwrap().to_str().unwrap(); + assert_eq!(vary_header, "Accept-Language, Accept-Encoding, origin"); + } + + #[test] + fn test_vary_header_preflight_only_cors_headers() { + // Test that preflight requests only contain CORS-related Vary headers + // (no headers from underlying service since it's never called) + let cors = Cors::builder().build(); + let layer = CorsLayer::new(cors).unwrap(); + let mut service = layer.layer(DummyService); + + let req = Request::builder() + .method("OPTIONS") + .uri("/") + .header(ORIGIN, "https://studio.apollographql.com") + .body(()) + .unwrap(); + let resp = futures::executor::block_on(service.call(req)).unwrap(); + let headers = resp.headers(); + let vary_header = headers.get(VARY).unwrap().to_str().unwrap(); + assert_eq!(vary_header, "origin, access-control-request-headers"); + } +} diff --git a/apollo-router/src/plugins/csrf/fixtures/default.router.yaml b/apollo-router/src/plugins/csrf/fixtures/default.router.yaml new file mode 100644 index 0000000000..941c3f6298 --- /dev/null +++ b/apollo-router/src/plugins/csrf/fixtures/default.router.yaml @@ -0,0 +1,2 @@ +csrf: + unsafe_disabled: false diff --git a/apollo-router/src/plugins/csrf/fixtures/required_headers.router.yaml b/apollo-router/src/plugins/csrf/fixtures/required_headers.router.yaml new file mode 100644 index 0000000000..fb9c4f8b6f --- /dev/null +++ b/apollo-router/src/plugins/csrf/fixtures/required_headers.router.yaml @@ -0,0 +1,3 @@ +csrf: + required_headers: + - X-MY-CSRF-Token diff --git a/apollo-router/src/plugins/csrf/fixtures/unsafe_disabled.router.yaml b/apollo-router/src/plugins/csrf/fixtures/unsafe_disabled.router.yaml new file mode 100644 index 0000000000..c1d00005e7 --- /dev/null +++ b/apollo-router/src/plugins/csrf/fixtures/unsafe_disabled.router.yaml @@ -0,0 +1,2 @@ +csrf: + unsafe_disabled: true diff --git a/apollo-router/src/plugins/csrf.rs b/apollo-router/src/plugins/csrf/mod.rs similarity index 68% rename from apollo-router/src/plugins/csrf.rs rename to apollo-router/src/plugins/csrf/mod.rs index 5c76c57116..7bdb80c5fd 100644 --- a/apollo-router/src/plugins/csrf.rs +++ b/apollo-router/src/plugins/csrf/mod.rs @@ -2,9 +2,9 @@ use std::ops::ControlFlow; use std::sync::Arc; -use http::header; use http::HeaderMap; use http::StatusCode; +use http::header; use schemars::JsonSchema; use serde::Deserialize; use tower::BoxError; @@ -15,18 +15,19 @@ use crate::layers::ServiceBuilderExt; use crate::plugin::Plugin; use crate::plugin::PluginInit; use crate::register_plugin; -use crate::services::supergraph; -use crate::services::SupergraphResponse; +use crate::services::router; -/// CSRF Configuration. +/// CSRF protection configuration. +/// +/// See for an explanation on CSRF attacks. #[derive(Deserialize, Debug, Clone, JsonSchema)] #[serde(deny_unknown_fields)] #[serde(default)] pub(crate) struct CSRFConfig { - /// The CSRF plugin is enabled by default; - /// set unsafe_disabled = true to disable the plugin behavior - /// Note that setting this to true is deemed unsafe. - /// See . + /// The CSRF plugin is enabled by default. + /// + /// Setting `unsafe_disabled: true` *disables* CSRF protection. + // TODO rename this to enabled. This is in line with the other plugins and will be less confusing. unsafe_disabled: bool, /// Override the headers to check for by setting /// custom_headers @@ -100,11 +101,11 @@ impl Plugin for Csrf { }) } - fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { + fn router_service(&self, service: router::BoxService) -> router::BoxService { if !self.config.unsafe_disabled { let required_headers = self.config.required_headers.clone(); ServiceBuilder::new() - .checkpoint(move |req: supergraph::Request| { + .checkpoint(move |req: router::Request| { if is_preflighted(&req, required_headers.as_slice()) { tracing::trace!("request is preflighted"); Ok(ControlFlow::Continue(req)) @@ -120,7 +121,7 @@ impl Plugin for Csrf { )) .extension_code("CSRF_ERROR") .build(); - let res = SupergraphResponse::infallible_builder() + let res = router::Response::infallible_builder() .error(error) .status_code(StatusCode::BAD_REQUEST) .context(req.context) @@ -144,8 +145,8 @@ impl Plugin for Csrf { // - The only headers added by javascript code are part of the cors safelisted request headers (Accept,Accept-Language,Content-Language,Content-Type, and simple Range // // Given the first step is covered in our web browser, we'll take care of the two other steps below: -fn is_preflighted(req: &supergraph::Request, required_headers: &[String]) -> bool { - let headers = req.supergraph_request.headers(); +fn is_preflighted(req: &router::Request, required_headers: &[String]) -> bool { + let headers = req.router_request.headers(); content_type_requires_preflight(headers) || recommended_header_is_provided(headers, required_headers) } @@ -210,7 +211,6 @@ register_plugin!("apollo", "csrf", Csrf); #[cfg(test)] mod csrf_tests { - use crate::plugin::PluginInit; #[tokio::test] async fn plugin_registered() { crate::plugin::plugins() @@ -229,116 +229,175 @@ mod csrf_tests { } use http::header::CONTENT_TYPE; + use http_body_util::BodyExt; use mime::APPLICATION_JSON; - use serde_json_bytes::json; - use tower::ServiceExt; use super::*; - use crate::plugin::test::MockSupergraphService; + use crate::graphql; + use crate::plugins::test::PluginTestHarness; #[tokio::test] async fn it_lets_preflighted_request_pass_through() { - let config = CSRFConfig::default(); - let with_preflight_content_type = supergraph::Request::fake_builder() + let with_preflight_content_type = router::Request::fake_builder() .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) .build() .unwrap(); - assert_accepted(config.clone(), with_preflight_content_type).await; + assert_accepted( + include_str!("fixtures/default.router.yaml"), + with_preflight_content_type, + ) + .await; - let with_preflight_header = supergraph::Request::fake_builder() + let with_preflight_header = router::Request::fake_builder() .header("apollo-require-preflight", "this-is-a-test") .build() .unwrap(); - assert_accepted(config, with_preflight_header).await; + assert_accepted( + include_str!("fixtures/default.router.yaml"), + with_preflight_header, + ) + .await; + } + + #[tokio::test] + async fn it_rejects_preflighted_multipart_form_data() { + let with_preflight_content_type = router::Request::fake_builder() + .header(CONTENT_TYPE, "multipart/form-data; boundary=842705fe5c26bcc3-e1302903b7efd762-d3aeccc8154e83c9-2ac7e6d91c6a7fdc") + .build() + .unwrap(); + assert_rejected( + include_str!("fixtures/default.router.yaml"), + with_preflight_content_type, + ) + .await; } #[tokio::test] async fn it_rejects_non_preflighted_headers_request() { - let config = CSRFConfig::default(); - let mut non_preflighted_request = supergraph::Request::fake_builder().build().unwrap(); + let mut non_preflighted_request = router::Request::fake_builder().build().unwrap(); // fake_builder defaults to `Content-Type: application/json`, // specifically to avoid the case we’re testing here. non_preflighted_request - .supergraph_request + .router_request .headers_mut() .remove("content-type"); - assert_rejected(config, non_preflighted_request).await + assert_rejected( + include_str!("fixtures/default.router.yaml"), + non_preflighted_request, + ) + .await } #[tokio::test] async fn it_rejects_non_preflighted_content_type_request() { - let config = CSRFConfig::default(); - let non_preflighted_request = supergraph::Request::fake_builder() + let non_preflighted_request = router::Request::fake_builder() .header(CONTENT_TYPE, "text/plain") .build() .unwrap(); - assert_rejected(config.clone(), non_preflighted_request).await; + assert_rejected( + include_str!("fixtures/default.router.yaml"), + non_preflighted_request, + ) + .await; - let non_preflighted_request = supergraph::Request::fake_builder() + let non_preflighted_request = router::Request::fake_builder() .header(CONTENT_TYPE, "text/plain; charset=utf8") .build() .unwrap(); - assert_rejected(config, non_preflighted_request).await; + assert_rejected( + include_str!("fixtures/default.router.yaml"), + non_preflighted_request, + ) + .await; } #[tokio::test] async fn it_accepts_non_preflighted_headers_request_when_plugin_is_disabled() { - let config = CSRFConfig { - unsafe_disabled: true, - ..Default::default() - }; - let non_preflighted_request = supergraph::Request::fake_builder().build().unwrap(); - assert_accepted(config, non_preflighted_request).await + let non_preflighted_request = router::Request::fake_builder().build().unwrap(); + assert_accepted( + include_str!("fixtures/unsafe_disabled.router.yaml"), + non_preflighted_request, + ) + .await } - async fn assert_accepted(config: CSRFConfig, request: supergraph::Request) { - let mut mock_service = MockSupergraphService::new(); - mock_service.expect_call().times(1).returning(move |_| { - Ok(SupergraphResponse::fake_builder() - .data(json!({ "test": 1234_u32 })) - .build() - .unwrap()) - }); + #[tokio::test] + async fn it_rejects_non_preflighted_headers_request_when_required_headers_are_not_present() { + let non_preflighted_request = router::Request::fake_builder().build().unwrap(); + assert_rejected( + include_str!("fixtures/required_headers.router.yaml"), + non_preflighted_request, + ) + .await + } - let service_stack = Csrf::new(PluginInit::fake_new(config, Default::default())) + // Check that when the headers are present, the request is accepted + #[tokio::test] + async fn it_accepts_non_preflighted_headers_request_when_required_headers_are_present() { + let non_preflighted_request = router::Request::fake_builder() + .header("X-MY-CSRF-Token", "this-is-a-test") + .build() + .unwrap(); + assert_accepted( + include_str!("fixtures/required_headers.router.yaml"), + non_preflighted_request, + ) + .await + } + + async fn assert_accepted(config: &'static str, request: router::Request) { + let plugin = PluginTestHarness::::builder() + .config(config) + .build() .await - .unwrap() - .supergraph_service(mock_service.boxed()); - let res = service_stack - .oneshot(request) + .expect("test harness"); + let router_service = + plugin.router_service(|_r| async { router::Response::fake_builder().build() }); + let mut resp = router_service + .call(request) .await - .unwrap() - .next_response() + .expect("expected response"); + + let body = resp + .response + .body_mut() + .collect() .await - .unwrap(); + .expect("expected body"); - assert_eq!(res.errors, []); - assert_eq!(res.data.unwrap(), json!({ "test": 1234_u32 })); + let response: graphql::Response = serde_json::from_slice(&body.to_bytes()).unwrap(); + assert_eq!(response.errors.len(), 0); } - async fn assert_rejected(config: CSRFConfig, request: supergraph::Request) { - let service_stack = Csrf::new(PluginInit::fake_new(config, Default::default())) + async fn assert_rejected(config: &'static str, request: router::Request) { + let plugin = PluginTestHarness::::builder() + .config(config) + .build() .await - .unwrap() - .supergraph_service(MockSupergraphService::new().boxed()); - let res = service_stack - .oneshot(request) + .expect("test harness"); + let router_service = + plugin.router_service(|_r| async { router::Response::fake_builder().build() }); + let mut resp = router_service + .call(request) .await - .unwrap() - .next_response() + .expect("expected response"); + + let body = resp + .response + .body_mut() + .collect() .await - .unwrap(); + .expect("expected body"); + let response: graphql::Response = serde_json::from_slice(&body.to_bytes()).unwrap(); + assert_eq!(response.errors.len(), 1); assert_eq!( - 1, - res.errors.len(), - "expected one(1) error in the SupergraphResponse, found {}\n{:?}", - res.errors.len(), - res.errors + response.errors[0] + .extensions + .get("code") + .expect("error code") + .as_str(), + Some("CSRF_ERROR") ); - assert_eq!(res.errors[0].message, "This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). \ - Please either specify a 'content-type' header \ - (with a mime-type that is not one of application/x-www-form-urlencoded, multipart/form-data, text/plain) \ - or provide one of the following headers: x-apollo-operation-name, apollo-require-preflight"); } } diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs b/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs index cf819478e1..ce4f183ff4 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs +++ b/apollo-router/src/plugins/demand_control/cost_calculator/directives.rs @@ -1,114 +1,20 @@ use ahash::HashMap; use ahash::HashMapExt; use ahash::HashSet; -use apollo_compiler::ast::DirectiveList; +use apollo_compiler::Schema; use apollo_compiler::ast::FieldDefinition; -use apollo_compiler::ast::InputValueDefinition; use apollo_compiler::ast::NamedType; use apollo_compiler::executable::Field; use apollo_compiler::executable::SelectionSet; -use apollo_compiler::name; use apollo_compiler::parser::Parser; -use apollo_compiler::schema::ExtendedType; use apollo_compiler::validation::Valid; -use apollo_compiler::Name; -use apollo_compiler::Schema; -use apollo_federation::link::spec::APOLLO_SPEC_DOMAIN; -use apollo_federation::link::Link; +use apollo_federation::link::cost_spec_definition::ListSizeDirective as ParsedListSizeDirective; use tower::BoxError; use crate::json_ext::Object; use crate::json_ext::ValueExt; use crate::plugins::demand_control::DemandControlError; -const COST_DIRECTIVE_NAME: Name = name!("cost"); -const COST_DIRECTIVE_DEFAULT_NAME: Name = name!("federation__cost"); -const COST_DIRECTIVE_WEIGHT_ARGUMENT_NAME: Name = name!("weight"); - -const LIST_SIZE_DIRECTIVE_NAME: Name = name!("listSize"); -const LIST_SIZE_DIRECTIVE_DEFAULT_NAME: Name = name!("federation__listSize"); -const LIST_SIZE_DIRECTIVE_ASSUMED_SIZE_ARGUMENT_NAME: Name = name!("assumedSize"); -const LIST_SIZE_DIRECTIVE_SLICING_ARGUMENTS_ARGUMENT_NAME: Name = name!("slicingArguments"); -const LIST_SIZE_DIRECTIVE_SIZED_FIELDS_ARGUMENT_NAME: Name = name!("sizedFields"); -const LIST_SIZE_DIRECTIVE_REQUIRE_ONE_SLICING_ARGUMENT_ARGUMENT_NAME: Name = - name!("requireOneSlicingArgument"); - -pub(in crate::plugins::demand_control) fn get_apollo_directive_names( - schema: &Schema, -) -> HashMap { - let mut hm: HashMap = HashMap::new(); - for directive in &schema.schema_definition.directives { - if directive.name.as_str() == "link" { - if let Ok(link) = Link::from_directive_application(directive) { - if link.url.identity.domain != APOLLO_SPEC_DOMAIN { - continue; - } - for import in link.imports { - hm.insert(import.element.clone(), import.imported_name().clone()); - } - } - } - } - hm -} - -pub(in crate::plugins::demand_control) struct CostDirective { - weight: i32, -} - -impl CostDirective { - pub(in crate::plugins::demand_control) fn weight(&self) -> f64 { - self.weight as f64 - } - - pub(in crate::plugins::demand_control) fn from_argument( - directive_name_map: &HashMap, - argument: &InputValueDefinition, - ) -> Option { - Self::from_directives(directive_name_map, &argument.directives) - } - - pub(in crate::plugins::demand_control) fn from_field( - directive_name_map: &HashMap, - field: &FieldDefinition, - ) -> Option { - Self::from_directives(directive_name_map, &field.directives) - } - - pub(in crate::plugins::demand_control) fn from_type( - directive_name_map: &HashMap, - ty: &ExtendedType, - ) -> Option { - Self::from_schema_directives(directive_name_map, ty.directives()) - } - - fn from_directives( - directive_name_map: &HashMap, - directives: &DirectiveList, - ) -> Option { - directive_name_map - .get(&COST_DIRECTIVE_NAME) - .and_then(|name| directives.get(name)) - .or(directives.get(&COST_DIRECTIVE_DEFAULT_NAME)) - .and_then(|cost| cost.specified_argument_by_name(&COST_DIRECTIVE_WEIGHT_ARGUMENT_NAME)) - .and_then(|weight| weight.to_i32()) - .map(|weight| Self { weight }) - } - - pub(in crate::plugins::demand_control) fn from_schema_directives( - directive_name_map: &HashMap, - directives: &apollo_compiler::schema::DirectiveList, - ) -> Option { - directive_name_map - .get(&COST_DIRECTIVE_NAME) - .and_then(|name| directives.get(name)) - .or(directives.get(&COST_DIRECTIVE_DEFAULT_NAME)) - .and_then(|cost| cost.specified_argument_by_name(&COST_DIRECTIVE_WEIGHT_ARGUMENT_NAME)) - .and_then(|weight| weight.to_i32()) - .map(|weight| Self { weight }) - } -} - pub(in crate::plugins::demand_control) struct IncludeDirective { pub(in crate::plugins::demand_control) is_included: bool, } @@ -134,94 +40,20 @@ pub(in crate::plugins::demand_control) struct ListSizeDirective<'schema> { } impl<'schema> ListSizeDirective<'schema> { - pub(in crate::plugins::demand_control) fn size_of(&self, field: &Field) -> Option { - if self - .sized_fields - .as_ref() - .is_some_and(|sf| sf.contains(field.name.as_str())) - { - self.expected_size - } else { - None - } - } -} - -/// The `@listSize` directive from a field definition, which can be converted to -/// `ListSizeDirective` with a concrete field from a request. -pub(in crate::plugins::demand_control) struct DefinitionListSizeDirective { - assumed_size: Option, - slicing_argument_names: Option>, - sized_fields: Option>, - require_one_slicing_argument: bool, -} - -impl DefinitionListSizeDirective { - pub(in crate::plugins::demand_control) fn from_field_definition( - directive_name_map: &HashMap, - definition: &FieldDefinition, - ) -> Result, DemandControlError> { - let directive = directive_name_map - .get(&LIST_SIZE_DIRECTIVE_NAME) - .and_then(|name| definition.directives.get(name)) - .or(definition.directives.get(&LIST_SIZE_DIRECTIVE_DEFAULT_NAME)); - if let Some(directive) = directive { - let assumed_size = directive - .specified_argument_by_name(&LIST_SIZE_DIRECTIVE_ASSUMED_SIZE_ARGUMENT_NAME) - .and_then(|arg| arg.to_i32()); - let slicing_argument_names = directive - .specified_argument_by_name(&LIST_SIZE_DIRECTIVE_SLICING_ARGUMENTS_ARGUMENT_NAME) - .and_then(|arg| arg.as_list()) - .map(|arg_list| { - arg_list - .iter() - .flat_map(|arg| arg.as_str()) - .map(String::from) - .collect() - }); - let sized_fields = directive - .specified_argument_by_name(&LIST_SIZE_DIRECTIVE_SIZED_FIELDS_ARGUMENT_NAME) - .and_then(|arg| arg.as_list()) - .map(|arg_list| { - arg_list - .iter() - .flat_map(|arg| arg.as_str()) - .map(String::from) - .collect() - }); - let require_one_slicing_argument = directive - .specified_argument_by_name( - &LIST_SIZE_DIRECTIVE_REQUIRE_ONE_SLICING_ARGUMENT_ARGUMENT_NAME, - ) - .and_then(|arg| arg.to_bool()) - .unwrap_or(true); - - Ok(Some(Self { - assumed_size, - slicing_argument_names, - sized_fields, - require_one_slicing_argument, - })) - } else { - Ok(None) - } - } - - pub(in crate::plugins::demand_control) fn with_field_and_variables( - &self, + pub(in crate::plugins::demand_control) fn new( + parsed: &'schema ParsedListSizeDirective, field: &Field, variables: &Object, - ) -> Result { + ) -> Result { let mut slicing_arguments: HashMap<&str, i32> = HashMap::new(); - if let Some(slicing_argument_names) = self.slicing_argument_names.as_ref() { + if let Some(slicing_argument_names) = parsed.slicing_argument_names.as_ref() { // First, collect the default values for each argument for argument in &field.definition.arguments { - if slicing_argument_names.contains(argument.name.as_str()) { - if let Some(numeric_value) = + if slicing_argument_names.contains(argument.name.as_str()) + && let Some(numeric_value) = argument.default_value.as_ref().and_then(|v| v.to_i32()) - { - slicing_arguments.insert(&argument.name, numeric_value); - } + { + slicing_arguments.insert(&argument.name, numeric_value); } } // Then, overwrite any default values with the actual values passed in the query @@ -240,7 +72,7 @@ impl DefinitionListSizeDirective { } } - if self.require_one_slicing_argument && slicing_arguments.len() != 1 { + if parsed.require_one_slicing_argument && slicing_arguments.len() != 1 { return Err(DemandControlError::QueryParseFailure(format!( "Exactly one slicing argument is required, but found {}", slicing_arguments.len() @@ -252,16 +84,28 @@ impl DefinitionListSizeDirective { .values() .max() .cloned() - .or(self.assumed_size); + .or(parsed.assumed_size); - Ok(ListSizeDirective { + Ok(Self { expected_size, - sized_fields: self + sized_fields: parsed .sized_fields .as_ref() .map(|set| set.iter().map(|s| s.as_str()).collect()), }) } + + pub(in crate::plugins::demand_control) fn size_of(&self, field: &Field) -> Option { + if self + .sized_fields + .as_ref() + .is_some_and(|sf| sf.contains(field.name.as_str())) + { + self.expected_size + } else { + None + } + } } pub(in crate::plugins::demand_control) struct RequiresDirective { diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/arbitrary_json_schema.graphql b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/arbitrary_json_schema.graphql new file mode 100644 index 0000000000..2b4d7565f3 --- /dev/null +++ b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/arbitrary_json_schema.graphql @@ -0,0 +1,94 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String + contextArguments: [join__ContextArgument!] +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar ArbitraryJson @join__type(graph: SUBGRAPHWITHCUSTOMSCALAR) + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPHWITHCUSTOMSCALAR + @join__graph(name: "subgraphWithCustomScalar", url: "http://localhost:4001") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +input MyInput @join__type(graph: SUBGRAPHWITHCUSTOMSCALAR) { + json: ArbitraryJson +} + +type Query @join__type(graph: SUBGRAPHWITHCUSTOMSCALAR) { + fetch(args: MyInput): String +} diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql index d966512be1..02184164a9 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql +++ b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/custom_cost_schema.graphql @@ -1,10 +1,7 @@ schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) - @link( - url: "https://specs.apollo.dev/cost/v0.1" - import: ["@cost", "@listSize"] - ) { + @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) { query: Query } @@ -12,13 +9,6 @@ directive @cost( weight: Int! ) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR -directive @cost__listSize( - assumedSize: Int - slicingArguments: [String!] - sizedFields: [String!] - requireOneSlicingArgument: Boolean = true -) on FIELD_DEFINITION - directive @join__directive( graphs: [join__Graph!] name: String! diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/federated_ships_typename_query.graphql b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/federated_ships_typename_query.graphql new file mode 100644 index 0000000000..69dc3f7824 --- /dev/null +++ b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/federated_ships_typename_query.graphql @@ -0,0 +1,7 @@ +query NamedQuery { + users { + __typename + licenseNumber + name + } +} \ No newline at end of file diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/federated_ships_typename_response.json b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/federated_ships_typename_response.json new file mode 100644 index 0000000000..00fc1e2fa6 --- /dev/null +++ b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/federated_ships_typename_response.json @@ -0,0 +1,16 @@ +{ + "data": { + "users": [ + { + "__typename": "User", + "licenseNumber": 1, + "name": "Kate Chopin" + }, + { + "__typename": "User", + "licenseNumber": 2, + "name": "Paul Auster" + } + ] + } +} \ No newline at end of file diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/subscription_query.graphql b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/subscription_query.graphql new file mode 100644 index 0000000000..6429125907 --- /dev/null +++ b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/subscription_query.graphql @@ -0,0 +1,6 @@ +subscription MessageSubscription { + messages { + subject + content + } +} diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/subscription_schema.graphql b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/subscription_schema.graphql new file mode 100644 index 0000000000..c04c47c742 --- /dev/null +++ b/apollo-router/src/plugins/demand_control/cost_calculator/fixtures/subscription_schema.graphql @@ -0,0 +1,92 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) { + query: Query + mutation: Mutation + subscription: Subscription +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar join__DirectiveArguments + +scalar join__FieldSet + +enum join__Graph { + SUBGRAPH @join__graph(name: "subgraph", url: "http://localhost:4001") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Message @join__type(graph: SUBGRAPH) { + subject: String + content: String +} + +type Mutation @join__type(graph: SUBGRAPH) { + addMessage(subject: String, content: String): Message +} + +type Query @join__type(graph: SUBGRAPH) { + allMessages: [Message] +} + +type Subscription @join__type(graph: SUBGRAPH) { + messages: Message +} diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/schema.rs b/apollo-router/src/plugins/demand_control/cost_calculator/schema.rs index 6a46ee9fe9..eb53b7c473 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/schema.rs +++ b/apollo-router/src/plugins/demand_control/cost_calculator/schema.rs @@ -3,116 +3,196 @@ use std::sync::Arc; use ahash::HashMap; use ahash::HashMapExt; -use apollo_compiler::schema::ExtendedType; -use apollo_compiler::validation::Valid; use apollo_compiler::Name; use apollo_compiler::Schema; +use apollo_compiler::ast::InputValueDefinition; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::validation::Valid; +use apollo_federation::link::cost_spec_definition::CostDirective; +use apollo_federation::link::cost_spec_definition::CostSpecDefinition; +use apollo_federation::link::cost_spec_definition::ListSizeDirective; +use apollo_federation::schema::ValidFederationSchema; -use super::directives::get_apollo_directive_names; -use super::directives::CostDirective; -use super::directives::DefinitionListSizeDirective as ListSizeDirective; -use super::directives::RequiresDirective; use crate::plugins::demand_control::DemandControlError; +use crate::plugins::demand_control::cost_calculator::directives::RequiresDirective; + +pub(in crate::plugins::demand_control) struct InputDefinition { + name: Name, + ty: ExtendedType, + cost_directive: Option, +} + +impl InputDefinition { + fn new( + schema: &ValidFederationSchema, + field_definition: &InputValueDefinition, + ) -> Result { + let field_type = schema + .schema() + .types + .get(field_definition.ty.inner_named_type()) + .ok_or_else(|| { + DemandControlError::QueryParseFailure(format!( + "Field {} was found in query, but its type is missing from the schema.", + field_definition.name + )) + })?; + let processed_inputs = InputDefinition { + name: field_definition.name.clone(), + ty: field_type.clone(), + cost_directive: CostSpecDefinition::cost_directive_from_argument( + schema, + field_definition, + field_type, + )?, + }; + + Ok(processed_inputs) + } + + pub(in crate::plugins::demand_control) fn name(&self) -> &Name { + &self.name + } + + pub(in crate::plugins::demand_control) fn ty(&self) -> &ExtendedType { + &self.ty + } + + pub(in crate::plugins::demand_control) fn cost_directive(&self) -> Option<&CostDirective> { + self.cost_directive.as_ref() + } +} + +pub(in crate::plugins::demand_control) struct FieldDefinition { + ty: ExtendedType, + cost_directive: Option, + list_size_directive: Option, + requires_directive: Option, + arguments: HashMap, +} + +impl FieldDefinition { + fn new( + schema: &ValidFederationSchema, + parent_type_name: &Name, + field_definition: &apollo_compiler::ast::FieldDefinition, + ) -> Result { + let field_type = schema + .schema() + .types + .get(field_definition.ty.inner_named_type()) + .ok_or_else(|| { + DemandControlError::QueryParseFailure(format!( + "Field {} was found in query, but its type is missing from the schema.", + field_definition.name, + )) + })?; + let mut processed_field_definition = Self { + ty: field_type.clone(), + cost_directive: None, + list_size_directive: None, + requires_directive: None, + arguments: HashMap::new(), + }; + + processed_field_definition.cost_directive = + CostSpecDefinition::cost_directive_from_field(schema, field_definition, field_type)?; + processed_field_definition.list_size_directive = + CostSpecDefinition::list_size_directive_from_field_definition( + schema, + field_definition, + )?; + processed_field_definition.requires_directive = RequiresDirective::from_field_definition( + field_definition, + parent_type_name, + schema.schema(), + )?; + + for argument in &field_definition.arguments { + processed_field_definition.arguments.insert( + argument.name.clone(), + InputDefinition::new(schema, argument)?, + ); + } + + Ok(processed_field_definition) + } + + pub(in crate::plugins::demand_control) fn ty(&self) -> &ExtendedType { + &self.ty + } + + pub(in crate::plugins::demand_control) fn cost_directive(&self) -> Option<&CostDirective> { + self.cost_directive.as_ref() + } + + pub(in crate::plugins::demand_control) fn list_size_directive( + &self, + ) -> Option<&ListSizeDirective> { + self.list_size_directive.as_ref() + } + + pub(in crate::plugins::demand_control) fn requires_directive( + &self, + ) -> Option<&RequiresDirective> { + self.requires_directive.as_ref() + } + + pub(in crate::plugins::demand_control) fn argument_by_name( + &self, + argument_name: &str, + ) -> Option<&InputDefinition> { + self.arguments.get(argument_name) + } +} pub(crate) struct DemandControlledSchema { - directive_name_map: HashMap, - inner: Arc>, - type_field_cost_directives: HashMap>, - type_field_list_size_directives: HashMap>, - type_field_requires_directives: HashMap>, + inner: ValidFederationSchema, + input_field_definitions: HashMap>, + output_field_definitions: HashMap>, } impl DemandControlledSchema { pub(crate) fn new(schema: Arc>) -> Result { - let directive_name_map = get_apollo_directive_names(&schema); - - let mut type_field_cost_directives: HashMap> = - HashMap::new(); - let mut type_field_list_size_directives: HashMap> = - HashMap::new(); - let mut type_field_requires_directives: HashMap> = - HashMap::new(); + let fed_schema = ValidFederationSchema::new((*schema).clone())?; + let mut input_field_definitions: HashMap> = + HashMap::with_capacity(schema.types.len()); + let mut output_field_definitions: HashMap> = + HashMap::with_capacity(schema.types.len()); for (type_name, type_) in &schema.types { - let field_cost_directives = type_field_cost_directives - .entry(type_name.clone()) - .or_default(); - let field_list_size_directives = type_field_list_size_directives - .entry(type_name.clone()) - .or_default(); - let field_requires_directives = type_field_requires_directives - .entry(type_name.clone()) - .or_default(); - match type_ { ExtendedType::Interface(ty) => { - for field_name in ty.fields.keys() { - let field_definition = schema.type_field(type_name, field_name)?; - let field_type = schema.types.get(field_definition.ty.inner_named_type()).ok_or_else(|| { - DemandControlError::QueryParseFailure(format!( - "Field {} was found in query, but its type is missing from the schema.", - field_name - )) - })?; - - if let Some(cost_directive) = - CostDirective::from_field(&directive_name_map, field_definition) - .or(CostDirective::from_type(&directive_name_map, field_type)) - { - field_cost_directives.insert(field_name.clone(), cost_directive); - } - - if let Some(list_size_directive) = ListSizeDirective::from_field_definition( - &directive_name_map, - field_definition, - )? { - field_list_size_directives - .insert(field_name.clone(), list_size_directive); - } - - if let Some(requires_directive) = RequiresDirective::from_field_definition( - field_definition, - type_name, - &schema, - )? { - field_requires_directives - .insert(field_name.clone(), requires_directive); - } + let type_fields = output_field_definitions + .entry(type_name.clone()) + .or_insert_with(|| HashMap::with_capacity(ty.fields.len())); + for (field_name, field_definition) in &ty.fields { + type_fields.insert( + field_name.clone(), + FieldDefinition::new(&fed_schema, type_name, field_definition)?, + ); } } ExtendedType::Object(ty) => { - for field_name in ty.fields.keys() { - let field_definition = schema.type_field(type_name, field_name)?; - let field_type = schema.types.get(field_definition.ty.inner_named_type()).ok_or_else(|| { - DemandControlError::QueryParseFailure(format!( - "Field {} was found in query, but its type is missing from the schema.", - field_name - )) - })?; - - if let Some(cost_directive) = - CostDirective::from_field(&directive_name_map, field_definition) - .or(CostDirective::from_type(&directive_name_map, field_type)) - { - field_cost_directives.insert(field_name.clone(), cost_directive); - } - - if let Some(list_size_directive) = ListSizeDirective::from_field_definition( - &directive_name_map, - field_definition, - )? { - field_list_size_directives - .insert(field_name.clone(), list_size_directive); - } - - if let Some(requires_directive) = RequiresDirective::from_field_definition( - field_definition, - type_name, - &schema, - )? { - field_requires_directives - .insert(field_name.clone(), requires_directive); - } + let type_fields = output_field_definitions + .entry(type_name.clone()) + .or_insert_with(|| HashMap::with_capacity(ty.fields.len())); + for (field_name, field_definition) in &ty.fields { + type_fields.insert( + field_name.clone(), + FieldDefinition::new(&fed_schema, type_name, field_definition)?, + ); + } + } + ExtendedType::InputObject(ty) => { + let type_fields = input_field_definitions + .entry(type_name.clone()) + .or_insert_with(|| HashMap::with_capacity(ty.fields.len())); + for (field_name, field_definition) in &ty.fields { + type_fields.insert( + field_name.clone(), + InputDefinition::new(&fed_schema, field_definition)?, + ); } } _ => { @@ -121,45 +201,39 @@ impl DemandControlledSchema { } } + input_field_definitions.shrink_to_fit(); + output_field_definitions.shrink_to_fit(); + Ok(Self { - directive_name_map, - inner: schema, - type_field_cost_directives, - type_field_list_size_directives, - type_field_requires_directives, + inner: fed_schema, + input_field_definitions, + output_field_definitions, }) } - pub(in crate::plugins::demand_control) fn directive_name_map(&self) -> &HashMap { - &self.directive_name_map - } - - pub(in crate::plugins::demand_control) fn type_field_cost_directive( - &self, - type_name: &str, - field_name: &str, - ) -> Option<&CostDirective> { - self.type_field_cost_directives - .get(type_name)? - .get(field_name) + pub(crate) fn empty(schema: Arc>) -> Result { + let fed_schema = ValidFederationSchema::new((*schema).clone())?; + Ok(Self { + inner: fed_schema, + input_field_definitions: Default::default(), + output_field_definitions: Default::default(), + }) } - pub(in crate::plugins::demand_control) fn type_field_list_size_directive( + pub(in crate::plugins::demand_control) fn input_field_definition( &self, type_name: &str, field_name: &str, - ) -> Option<&ListSizeDirective> { - self.type_field_list_size_directives - .get(type_name)? - .get(field_name) + ) -> Option<&InputDefinition> { + self.input_field_definitions.get(type_name)?.get(field_name) } - pub(in crate::plugins::demand_control) fn type_field_requires_directive( + pub(in crate::plugins::demand_control) fn output_field_definition( &self, type_name: &str, field_name: &str, - ) -> Option<&RequiresDirective> { - self.type_field_requires_directives + ) -> Option<&FieldDefinition> { + self.output_field_definitions .get(type_name)? .get(field_name) } @@ -167,7 +241,7 @@ impl DemandControlledSchema { impl AsRef> for DemandControlledSchema { fn as_ref(&self) -> &Valid { - &self.inner + self.inner.schema() } } @@ -175,6 +249,6 @@ impl Deref for DemandControlledSchema { type Target = Schema; fn deref(&self) -> &Self::Target { - &self.inner + self.inner.schema() } } diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/snapshots/apollo_router__plugins__demand_control__cost_calculator__static_cost__tests__federated_query_with_typenames@logs.snap b/apollo-router/src/plugins/demand_control/cost_calculator/snapshots/apollo_router__plugins__demand_control__cost_calculator__static_cost__tests__federated_query_with_typenames@logs.snap new file mode 100644 index 0000000000..d39a85b259 --- /dev/null +++ b/apollo-router/src/plugins/demand_control/cost_calculator/snapshots/apollo_router__plugins__demand_control__cost_calculator__static_cost__tests__federated_query_with_typenames@logs.snap @@ -0,0 +1,5 @@ +--- +source: apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs +expression: yaml +--- +[] diff --git a/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs b/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs index 3c0d973ebb..70595ab172 100644 --- a/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs +++ b/apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use ahash::HashMap; use apollo_compiler::ast; -use apollo_compiler::ast::InputValueDefinition; use apollo_compiler::ast::NamedType; use apollo_compiler::executable::ExecutableDocument; use apollo_compiler::executable::Field; @@ -12,24 +11,23 @@ use apollo_compiler::executable::Operation; use apollo_compiler::executable::Selection; use apollo_compiler::executable::SelectionSet; use apollo_compiler::schema::ExtendedType; -use apollo_compiler::Node; +use apollo_federation::query_plan::serializable_document::SerializableDocument; use serde_json_bytes::Value; +use super::DemandControlError; use super::directives::IncludeDirective; use super::directives::SkipDirective; use super::schema::DemandControlledSchema; -use super::DemandControlError; +use super::schema::InputDefinition; use crate::graphql::Response; use crate::graphql::ResponseVisitor; use crate::json_ext::Object; -use crate::json_ext::ValueExt; -use crate::plugins::demand_control::cost_calculator::directives::CostDirective; use crate::plugins::demand_control::cost_calculator::directives::ListSizeDirective; -use crate::query_planner::fetch::SubgraphOperation; use crate::query_planner::DeferredNode; use crate::query_planner::PlanNode; use crate::query_planner::Primary; use crate::query_planner::QueryPlan; +use crate::spec::TYPENAME; pub(crate) struct StaticCostCalculator { list_size: u32, @@ -46,50 +44,39 @@ struct ScoringContext<'a> { fn score_argument( argument: &apollo_compiler::ast::Value, - argument_definition: &Node, + argument_definition: &InputDefinition, schema: &DemandControlledSchema, variables: &Object, ) -> Result { - let cost_directive = - CostDirective::from_argument(schema.directive_name_map(), argument_definition); - let ty = schema - .types - .get(argument_definition.ty.inner_named_type()) - .ok_or_else(|| { - DemandControlError::QueryParseFailure(format!( - "Argument {} was found in query, but its type ({}) was not found in the schema", - argument_definition.name, - argument_definition.ty.inner_named_type() - )) - })?; - - match (argument, ty) { + match (argument, argument_definition.ty()) { (_, ExtendedType::Interface(_)) | (_, ExtendedType::Object(_)) - | (_, ExtendedType::Union(_)) => Err(DemandControlError::QueryParseFailure( - format!( - "Argument {} has type {}, but objects, interfaces, and unions are disallowed in this position", - argument_definition.name, - argument_definition.ty.inner_named_type() - ) - )), - - (ast::Value::Object(inner_args), ExtendedType::InputObject(inner_arg_defs)) => { - let mut cost = cost_directive.map_or(1.0, |cost| cost.weight()); + | (_, ExtendedType::Union(_)) => Err(DemandControlError::QueryParseFailure(format!( + "Argument {} has type {}, but objects, interfaces, and unions are disallowed in this position", + argument_definition.name(), + argument_definition.ty().name() + ))), + + (ast::Value::Object(inner_args), ExtendedType::InputObject(_)) => { + let mut cost = argument_definition + .cost_directive() + .map_or(1.0, |cost| cost.weight()); for (arg_name, arg_val) in inner_args { - let arg_def = inner_arg_defs.fields.get(arg_name).ok_or_else(|| { + let arg_def = schema.input_field_definition(argument_definition.ty().name(), arg_name).ok_or_else(|| { DemandControlError::QueryParseFailure(format!( "Argument {} was found in query, but its type ({}) was not found in the schema", - argument_definition.name, - argument_definition.ty.inner_named_type() + arg_name, + argument_definition.ty().name() )) })?; - cost += score_argument(arg_val, arg_def, schema, variables,)?; + cost += score_argument(arg_val, arg_def, schema, variables)?; } Ok(cost) } (ast::Value::List(inner_args), _) => { - let mut cost = cost_directive.map_or(0.0, |cost| cost.weight()); + let mut cost = argument_definition + .cost_directive() + .map_or(0.0, |cost| cost.weight()); for arg_val in inner_args { cost += score_argument(arg_val, argument_definition, schema, variables)?; } @@ -99,13 +86,61 @@ fn score_argument( // We make a best effort attempt to score the variable, but some of these may not exist in the variables // sent on the supergraph request, such as `$representations`. if let Some(variable) = variables.get(name.as_str()) { - score_argument(&variable.to_ast(), argument_definition, schema, variables) + score_variable(variable, argument_definition, schema) } else { Ok(0.0) } } (ast::Value::Null, _) => Ok(0.0), - _ => Ok(cost_directive.map_or(0.0, |cost| cost.weight())) + _ => Ok(argument_definition + .cost_directive() + .map_or(0.0, |cost| cost.weight())), + } +} + +fn score_variable( + variable: &Value, + argument_definition: &InputDefinition, + schema: &DemandControlledSchema, +) -> Result { + match (variable, argument_definition.ty()) { + (_, ExtendedType::Interface(_)) + | (_, ExtendedType::Object(_)) + | (_, ExtendedType::Union(_)) => Err(DemandControlError::QueryParseFailure(format!( + "Argument {} has type {}, but objects, interfaces, and unions are disallowed in this position", + argument_definition.name(), + argument_definition.ty().name() + ))), + + (Value::Object(inner_args), ExtendedType::InputObject(_)) => { + let mut cost = argument_definition + .cost_directive() + .map_or(1.0, |cost| cost.weight()); + for (arg_name, arg_val) in inner_args { + let arg_def = schema.input_field_definition(argument_definition.ty().name(), arg_name.as_str()).ok_or_else(|| { + DemandControlError::QueryParseFailure(format!( + "Argument {} was found in query, but its type ({}) was not found in the schema", + argument_definition.name(), + argument_definition.ty().name() + )) + })?; + cost += score_variable(arg_val, arg_def, schema)?; + } + Ok(cost) + } + (Value::Array(inner_args), _) => { + let mut cost = argument_definition + .cost_directive() + .map_or(0.0, |cost| cost.weight()); + for arg_val in inner_args { + cost += score_variable(arg_val, argument_definition, schema)?; + } + Ok(cost) + } + (Value::Null, _) => Ok(0.0), + _ => Ok(argument_definition + .cost_directive() + .map_or(0.0, |cost| cost.weight())), } } @@ -147,25 +182,25 @@ impl StaticCostCalculator { parent_type: &NamedType, list_size_from_upstream: Option, ) -> Result { + // When we pre-process the schema, __typename isn't included. So, we short-circuit here to avoid failed lookups. + if field.name == TYPENAME { + return Ok(0.0); + } if StaticCostCalculator::skipped_by_directives(field) { return Ok(0.0); } - // We need to look up the `FieldDefinition` from the supergraph schema instead of using `field.definition` - // because `field.definition` was generated from the API schema, which strips off the directives we need. - let definition = ctx.schema.type_field(parent_type, &field.name)?; - let ty = field.inner_type_def(ctx.schema).ok_or_else(|| { - DemandControlError::QueryParseFailure(format!( - "Field {} was found in query, but its type is missing from the schema.", - field.name - )) - })?; - - let list_size_directive = match ctx + let definition = ctx .schema - .type_field_list_size_directive(parent_type, &field.name) - { - Some(dir) => dir.with_field_and_variables(field, ctx.variables).map(Some), + .output_field_definition(parent_type, &field.name) + .ok_or_else(|| { + DemandControlError::QueryParseFailure(format!( + "Field {} was found in query, but its type is missing from the schema.", + field.name + )) + })?; + let list_size_directive = match definition.list_size_directive() { + Some(dir) => ListSizeDirective::new(dir, field, ctx.variables).map(Some), None => Ok(None), }?; let instance_count = if !field.ty().is_list() { @@ -184,12 +219,12 @@ impl StaticCostCalculator { // Determine the cost for this particular field. Scalars are free, non-scalars are not. // For fields with selections, add in the cost of the selections as well. - let mut type_cost = if let Some(cost_directive) = ctx - .schema - .type_field_cost_directive(parent_type, &field.name) - { + let mut type_cost = if let Some(cost_directive) = definition.cost_directive() { cost_directive.weight() - } else if ty.is_interface() || ty.is_object() || ty.is_union() { + } else if definition.ty().is_interface() + || definition.ty().is_object() + || definition.ty().is_union() + { 1.0 } else { 0.0 @@ -223,10 +258,7 @@ impl StaticCostCalculator { // If the field is marked with `@requires`, the required selection may not be included // in the query's selection. Adding that requirement's cost to the field ensures it's // accounted for. - let requirements = ctx - .schema - .type_field_requires_directive(parent_type, &field.name) - .map(|d| &d.fields); + let requirements = definition.requires_directive().map(|d| &d.fields); if let Some(selection_set) = requirements { requirements_cost = self.score_selection_set( ctx, @@ -390,15 +422,14 @@ impl StaticCostCalculator { fn estimated_cost_of_operation( &self, subgraph: &str, - operation: &SubgraphOperation, + operation: &SerializableDocument, variables: &Object, ) -> Result { tracing::debug!("On subgraph {}, scoring operation: {}", subgraph, operation); let schema = self.subgraph_schemas.get(subgraph).ok_or_else(|| { DemandControlError::QueryParseFailure(format!( - "Query planner did not provide a schema for service {}", - subgraph + "Query planner did not provide a schema for service {subgraph}" )) })?; @@ -508,39 +539,82 @@ impl<'schema> ResponseCostCalculator<'schema> { pub(crate) fn new(schema: &'schema DemandControlledSchema) -> Self { Self { cost: 0.0, schema } } -} -impl<'schema> ResponseVisitor for ResponseCostCalculator<'schema> { - fn visit_field( + fn score_response_field( &mut self, request: &ExecutableDocument, variables: &Object, parent_ty: &NamedType, field: &Field, value: &Value, + include_argument_score: bool, ) { - self.visit_list_item(request, variables, parent_ty, field, value); + // When we pre-process the schema, __typename isn't included. So, we short-circuit here to avoid failed lookups. + if field.name == TYPENAME { + return; + } + if let Some(definition) = self.schema.output_field_definition(parent_ty, &field.name) { + match value { + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + self.cost += definition + .cost_directive() + .map_or(0.0, |cost| cost.weight()); + } + Value::Array(items) => { + for item in items { + self.visit_list_item(request, variables, parent_ty, field, item); + } + } + Value::Object(children) => { + self.cost += definition + .cost_directive() + .map_or(1.0, |cost| cost.weight()); + self.visit_selections(request, variables, &field.selection_set, children); + } + } - let definition = self.schema.type_field(parent_ty, &field.name); - for argument in &field.arguments { - if let Ok(Some(argument_definition)) = definition - .as_ref() - .map(|def| def.argument_by_name(&argument.name)) - { - if let Ok(score) = - score_argument(&argument.value, argument_definition, self.schema, variables) - { - self.cost += score; + if include_argument_score { + for argument in &field.arguments { + if let Some(argument_definition) = definition.argument_by_name(&argument.name) { + if let Ok(score) = score_argument( + &argument.value, + argument_definition, + self.schema, + variables, + ) { + self.cost += score; + } + } else { + tracing::debug!( + "Failed to get schema definition for argument {}.{}({}:). The resulting response cost will be a partial result.", + parent_ty, + field.name, + argument.name, + ) + } } - } else { - tracing::warn!( - "Failed to get schema definition for argument {} of field {}. The resulting actual cost will be a partial result.", - argument.name, - field.name - ) } + } else { + tracing::debug!( + "Failed to get schema definition for field {}.{}. The resulting response cost will be a partial result.", + parent_ty, + field.name, + ) } } +} + +impl ResponseVisitor for ResponseCostCalculator<'_> { + fn visit_field( + &mut self, + request: &ExecutableDocument, + variables: &Object, + parent_ty: &NamedType, + field: &Field, + value: &Value, + ) { + self.score_response_field(request, variables, parent_ty, field, value, true); + } fn visit_list_item( &mut self, @@ -550,24 +624,7 @@ impl<'schema> ResponseVisitor for ResponseCostCalculator<'schema> { field: &apollo_compiler::executable::Field, value: &Value, ) { - let cost_directive = self - .schema - .type_field_cost_directive(parent_ty, &field.name); - - match value { - Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { - self.cost += cost_directive.map_or(0.0, |cost| cost.weight()); - } - Value::Array(items) => { - for item in items { - self.visit_list_item(request, variables, parent_ty, field, item); - } - } - Value::Object(children) => { - self.cost += cost_directive.map_or(1.0, |cost| cost.weight()); - self.visit_selections(request, variables, &field.selection_set, children); - } - } + self.score_response_field(request, variables, parent_ty, field, value, false); } } @@ -578,21 +635,23 @@ mod tests { use ahash::HashMapExt; use apollo_federation::query_plan::query_planner::QueryPlanner; use bytes::Bytes; - use router_bridge::planner::PlanOptions; use test_log::test; use tower::Service; + use tracing::instrument::WithSubscriber; use super::*; - use crate::introspection::IntrospectionCache; + use crate::Configuration; + use crate::Context; + use crate::assert_snapshot_subscriber; + use crate::compute_job::ComputeJobType; use crate::plugins::authorization::CacheKeyMetadata; - use crate::query_planner::BridgeQueryPlanner; - use crate::services::layers::query_analysis::ParsedDocument; + use crate::query_planner::QueryPlannerService; use crate::services::QueryPlannerContent; use crate::services::QueryPlannerRequest; + use crate::services::layers::query_analysis::ParsedDocument; + use crate::services::query_planner::PlanOptions; use crate::spec; use crate::spec::Query; - use crate::Configuration; - use crate::Context; impl StaticCostCalculator { fn rust_planned( @@ -671,19 +730,13 @@ mod tests { .unwrap_or_default(); let supergraph_schema = schema.supergraph_schema().clone(); - let mut planner = BridgeQueryPlanner::new( - schema.into(), - config.clone(), - None, - None, - Arc::new(IntrospectionCache::new(&config)), - ) - .await - .unwrap(); + let mut planner = QueryPlannerService::new(schema.into(), config.clone()) + .await + .unwrap(); let ctx = Context::new(); ctx.extensions() - .with_lock(|mut lock| lock.insert::(query.clone())); + .with_lock(|lock| lock.insert::(query.clone())); let planner_res = planner .call(QueryPlannerRequest::new( @@ -692,6 +745,7 @@ mod tests { query, CacheKeyMetadata::default(), PlanOptions::default(), + ComputeJobType::QueryPlanning, )) .await .unwrap(); @@ -704,7 +758,7 @@ mod tests { let mut demand_controlled_subgraph_schemas = HashMap::new(); for (subgraph_name, subgraph_schema) in planner.subgraph_schemas().iter() { let demand_controlled_subgraph_schema = - DemandControlledSchema::new(subgraph_schema.clone()).unwrap(); + DemandControlledSchema::new(subgraph_schema.schema.clone()).unwrap(); demand_controlled_subgraph_schemas .insert(subgraph_name.to_string(), demand_controlled_subgraph_schema); } @@ -766,7 +820,7 @@ mod tests { .as_object() .cloned() .unwrap_or_default(); - let response = Response::from_bytes("test", Bytes::from(response_bytes)).unwrap(); + let response = Response::from_bytes(Bytes::from(response_bytes)).unwrap(); let schema = DemandControlledSchema::new(Arc::new(schema.supergraph_schema().clone())).unwrap(); StaticCostCalculator::new(Arc::new(schema), Default::default(), 100) @@ -794,7 +848,7 @@ mod tests { .as_object() .cloned() .unwrap_or_default(); - let response = Response::from_bytes("test", Bytes::from(response_bytes)).unwrap(); + let response = Response::from_bytes(Bytes::from(response_bytes)).unwrap(); let schema = DemandControlledSchema::new(Arc::new(schema)).unwrap(); StaticCostCalculator::new(Arc::new(schema), Default::default(), 100) @@ -1020,6 +1074,22 @@ mod tests { assert_eq!(narrow_estimate, 35.0); } + #[test(tokio::test)] + async fn federated_query_with_typenames() { + let schema = include_str!("./fixtures/federated_ships_schema.graphql"); + let query = include_str!("./fixtures/federated_ships_typename_query.graphql"); + let variables = "{}"; + let response = include_bytes!("./fixtures/federated_ships_typename_response.json"); + + async { + assert_eq!(actual_cost(schema, query, variables, response), 2.0); + } + // This was previously logging a warning for every __typename in the response. At the time of writing, + // this should not produce logs. Generally, it should not produce undue noise for valid requests. + .with_subscriber(assert_snapshot_subscriber!()) + .await + } + #[test(tokio::test)] async fn custom_cost_query() { let schema = include_str!("./fixtures/custom_cost_schema.graphql"); @@ -1073,4 +1143,36 @@ mod tests { assert_eq!(planned_cost_rust(schema, query, variables), 127.0); assert_eq!(actual_cost(schema, query, variables, response), 125.0); } + + #[test] + fn arbitrary_json_as_custom_scalar_in_variables() { + let schema = include_str!("./fixtures/arbitrary_json_schema.graphql"); + let query = r#" + query FetchData($myJsonValue: ArbitraryJson) { + fetch(args: { + json: $myJsonValue + }) + } + "#; + let variables = r#" + { + "myJsonValue": { + "field.with.dots": 1 + } + } + "#; + + assert_eq!(estimated_cost(schema, query, variables), 1.0); + } + + #[test(tokio::test)] + async fn subscription_request() { + let schema = include_str!("./fixtures/subscription_schema.graphql"); + let query = include_str!("./fixtures/subscription_query.graphql"); + let variables = "{}"; + + assert_eq!(estimated_cost(schema, query, variables), 1.0); + assert_eq!(planned_cost_js(schema, query, variables).await, 1.0); + assert_eq!(planned_cost_rust(schema, query, variables), 1.0); + } } diff --git a/apollo-router/src/plugins/demand_control/mod.rs b/apollo-router/src/plugins/demand_control/mod.rs index 5e3ba587f8..3e784551bb 100644 --- a/apollo-router/src/plugins/demand_control/mod.rs +++ b/apollo-router/src/plugins/demand_control/mod.rs @@ -7,22 +7,26 @@ use std::sync::Arc; use ahash::HashMap; use ahash::HashMapExt; +use apollo_compiler::ExecutableDocument; use apollo_compiler::schema::FieldLookupError; use apollo_compiler::validation::Valid; use apollo_compiler::validation::WithErrors; -use apollo_compiler::ExecutableDocument; +use apollo_federation::error::FederationError; +use apollo_federation::query_plan::serializable_document::SerializableDocumentNotInitialized; use displaydoc::Display; +use futures::StreamExt; use futures::future::Either; use futures::stream; -use futures::StreamExt; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use serde_json_bytes::Value; use thiserror::Error; use tower::BoxError; use tower::ServiceBuilder; use tower::ServiceExt; +use crate::Context; use crate::error::Error; use crate::graphql; use crate::graphql::IntoGraphQLErrors; @@ -33,20 +37,19 @@ use crate::plugin::PluginInit; use crate::plugins::demand_control::cost_calculator::schema::DemandControlledSchema; use crate::plugins::demand_control::strategy::Strategy; use crate::plugins::demand_control::strategy::StrategyFactory; +use crate::plugins::telemetry::tracing::apollo_telemetry::emit_error_event; use crate::register_plugin; use crate::services::execution; use crate::services::execution::BoxService; use crate::services::subgraph; -use crate::Context; pub(crate) mod cost_calculator; pub(crate) mod strategy; -pub(crate) static COST_ESTIMATED_KEY: &str = "cost.estimated"; -pub(crate) static COST_ACTUAL_KEY: &str = "cost.actual"; -pub(crate) static COST_DELTA_KEY: &str = "cost.delta"; -pub(crate) static COST_RESULT_KEY: &str = "cost.result"; -pub(crate) static COST_STRATEGY_KEY: &str = "cost.strategy"; +pub(crate) const COST_ESTIMATED_KEY: &str = "apollo::demand_control::estimated_cost"; +pub(crate) const COST_ACTUAL_KEY: &str = "apollo::demand_control::actual_cost"; +pub(crate) const COST_RESULT_KEY: &str = "apollo::demand_control::result"; +pub(crate) const COST_STRATEGY_KEY: &str = "apollo::demand_control::strategy"; /// Algorithm for calculating the cost of an incoming query. #[derive(Clone, Debug, Deserialize, JsonSchema)] @@ -120,9 +123,11 @@ pub(crate) enum DemandControlError { /// Query could not be parsed: {0} QueryParseFailure(String), /// {0} - SubgraphOperationNotInitialized(crate::query_planner::fetch::SubgraphOperationNotInitialized), + SubgraphOperationNotInitialized(SerializableDocumentNotInitialized), /// {0} ContextSerializationError(String), + /// {0} + FederationError(FederationError), } impl IntoGraphQLErrors for DemandControlError { @@ -135,11 +140,13 @@ impl IntoGraphQLErrors for DemandControlError { let mut extensions = Object::new(); extensions.insert("cost.estimated", estimated_cost.into()); extensions.insert("cost.max", max_cost.into()); - Ok(vec![graphql::Error::builder() - .extension_code(self.code()) - .extensions(extensions) - .message(self.to_string()) - .build()]) + Ok(vec![ + graphql::Error::builder() + .extension_code(self.code()) + .extensions(extensions) + .message(self.to_string()) + .build(), + ]) } DemandControlError::ActualCostTooExpensive { actual_cost, @@ -148,21 +155,38 @@ impl IntoGraphQLErrors for DemandControlError { let mut extensions = Object::new(); extensions.insert("cost.actual", actual_cost.into()); extensions.insert("cost.max", max_cost.into()); - Ok(vec![graphql::Error::builder() + Ok(vec![ + graphql::Error::builder() + .extension_code(self.code()) + .extensions(extensions) + .message(self.to_string()) + .build(), + ]) + } + DemandControlError::QueryParseFailure(_) => Ok(vec![ + graphql::Error::builder() .extension_code(self.code()) - .extensions(extensions) .message(self.to_string()) - .build()]) - } - DemandControlError::QueryParseFailure(_) => Ok(vec![graphql::Error::builder() - .extension_code(self.code()) - .message(self.to_string()) - .build()]), - DemandControlError::SubgraphOperationNotInitialized(e) => Ok(e.into_graphql_errors()), - DemandControlError::ContextSerializationError(_) => Ok(vec![graphql::Error::builder() - .extension_code(self.code()) - .message(self.to_string()) - .build()]), + .build(), + ]), + DemandControlError::SubgraphOperationNotInitialized(_) => Ok(vec![ + graphql::Error::builder() + .extension_code(self.code()) + .message(self.to_string()) + .build(), + ]), + DemandControlError::ContextSerializationError(_) => Ok(vec![ + graphql::Error::builder() + .extension_code(self.code()) + .message(self.to_string()) + .build(), + ]), + DemandControlError::FederationError(_) => Ok(vec![ + graphql::Error::builder() + .extension_code(self.code()) + .message(self.to_string()) + .build(), + ]), } } } @@ -173,19 +197,22 @@ impl DemandControlError { DemandControlError::EstimatedCostTooExpensive { .. } => "COST_ESTIMATED_TOO_EXPENSIVE", DemandControlError::ActualCostTooExpensive { .. } => "COST_ACTUAL_TOO_EXPENSIVE", DemandControlError::QueryParseFailure(_) => "COST_QUERY_PARSE_FAILURE", - DemandControlError::SubgraphOperationNotInitialized(e) => e.code(), + DemandControlError::SubgraphOperationNotInitialized(_) => { + "SUBGRAPH_OPERATION_NOT_INITIALIZED" + } DemandControlError::ContextSerializationError(_) => "COST_CONTEXT_SERIALIZATION_ERROR", + DemandControlError::FederationError(_) => "FEDERATION_ERROR", } } } impl From> for DemandControlError { fn from(value: WithErrors) -> Self { - DemandControlError::QueryParseFailure(format!("{}", value)) + DemandControlError::QueryParseFailure(format!("{value}")) } } -impl<'a> From> for DemandControlError { +impl From> for DemandControlError { fn from(value: FieldLookupError) -> Self { match value { FieldLookupError::NoSuchType => DemandControlError::QueryParseFailure( @@ -193,14 +220,19 @@ impl<'a> From> for DemandControlError { ), FieldLookupError::NoSuchField(type_name, _) => { DemandControlError::QueryParseFailure(format!( - "Attempted to look up a field on type {}, but the field does not exist", - type_name + "Attempted to look up a field on type {type_name}, but the field does not exist" )) } } } } +impl From for DemandControlError { + fn from(value: FederationError) -> Self { + DemandControlError::FederationError(value) + } +} + #[derive(Clone)] pub(crate) struct DemandControlContext { pub(crate) strategy: Strategy, @@ -259,7 +291,7 @@ impl Context { } pub(crate) fn insert_demand_control_context(&self, ctx: DemandControlContext) { - self.extensions().with_lock(|mut lock| lock.insert(ctx)); + self.extensions().with_lock(|lock| lock.insert(ctx)); } pub(crate) fn get_demand_control_context(&self) -> Option { @@ -293,6 +325,19 @@ impl Plugin for DemandControl { type Config = DemandControlConfig; async fn new(init: PluginInit) -> Result { + if !init.config.enabled { + return Ok(DemandControl { + strategy_factory: StrategyFactory::new( + init.config.clone(), + Arc::new(DemandControlledSchema::empty( + init.supergraph_schema.clone(), + )?), + Arc::new(HashMap::new()), + ), + config: init.config, + }); + } + let demand_controlled_supergraph_schema = DemandControlledSchema::new(init.supergraph_schema.clone())?; let mut demand_controlled_subgraph_schemas = HashMap::new(); @@ -329,22 +374,35 @@ impl Plugin for DemandControl { // On the request path we need to check for estimates, checkpoint is used to do this, short-circuiting the request if it's too expensive. Ok(match strategy.on_execution_request(&req) { Ok(_) => ControlFlow::Continue(req), - Err(err) => ControlFlow::Break( - execution::Response::builder() - .errors( - err.into_graphql_errors() - .expect("must be able to convert to graphql error"), - ) - .context(req.context.clone()) - .build() - .expect("Must be able to build response"), - ), + Err(err) => { + let graphql_errors = err + .into_graphql_errors() + .expect("must be able to convert to graphql error"); + graphql_errors.iter().for_each(|mapped_error| { + if let Some(Value::String(error_code)) = + mapped_error.extensions.get("code") + { + emit_error_event( + error_code.as_str(), + &mapped_error.message, + mapped_error.path.clone(), + ); + } + }); + ControlFlow::Break( + execution::Response::builder() + .errors(graphql_errors) + .context(req.context.clone()) + .build() + .expect("Must be able to build response"), + ) + } }) }) .map_response(|mut resp: execution::Response| { let req = resp .context - .unsupported_executable_document() + .executable_document() .expect("must have document"); let strategy = resp .context @@ -468,14 +526,15 @@ register_plugin!("apollo", "demand_control", DemandControl); mod test { use std::sync::Arc; - use apollo_compiler::ast; - use apollo_compiler::validation::Valid; use apollo_compiler::ExecutableDocument; use apollo_compiler::Schema; + use apollo_compiler::ast; + use apollo_compiler::validation::Valid; use futures::StreamExt; use schemars::JsonSchema; use serde::Deserialize; + use crate::Context; use crate::graphql; use crate::graphql::Response; use crate::metrics::FutureMetricsExt; @@ -487,7 +546,6 @@ mod test { use crate::services::layers::query_analysis::ParsedDocument; use crate::services::layers::query_analysis::ParsedDocumentInner; use crate::services::subgraph; - use crate::Context; #[tokio::test] async fn test_measure_on_execution_request() { @@ -607,20 +665,17 @@ mod test { let plugin = PluginTestHarness::::builder() .config(config) .build() - .await; - + .await + .expect("test harness"); let ctx = context(); - let resp = plugin - .call_execution( - execution::Request::fake_builder().context(ctx).build(), - |req| { - execution::Response::fake_builder() - .context(req.context) - .build() - .unwrap() - }, - ) + .execution_service(|req| async { + Ok(execution::Response::fake_builder() + .context(req.context) + .build() + .unwrap()) + }) + .call(execution::Request::fake_builder().context(ctx).build()) .await .unwrap(); @@ -634,7 +689,8 @@ mod test { let plugin = PluginTestHarness::::builder() .config(config) .build() - .await; + .await + .expect("test harness"); let strategy = plugin.strategy_factory.create(); let ctx = context(); @@ -648,11 +704,12 @@ mod test { .build(); req.executable_document = Some(Arc::new(Valid::assume_valid(ExecutableDocument::new()))); let resp = plugin - .call_subgraph(req, |req| { - subgraph::Response::fake_builder() + .subgraph_service("test", |req| async { + Ok(subgraph::Response::fake_builder() .context(req.context) - .build() + .build()) }) + .call(req) .await .unwrap(); @@ -667,7 +724,7 @@ mod test { ParsedDocumentInner::new(ast, doc.into(), None, Default::default()).unwrap(); let ctx = Context::new(); ctx.extensions() - .with_lock(|mut lock| lock.insert::(parsed_document)); + .with_lock(|lock| lock.insert::(parsed_document)); ctx } diff --git a/apollo-router/src/plugins/demand_control/strategy/mod.rs b/apollo-router/src/plugins/demand_control/strategy/mod.rs index 6bae126694..738b935b12 100644 --- a/apollo-router/src/plugins/demand_control/strategy/mod.rs +++ b/apollo-router/src/plugins/demand_control/strategy/mod.rs @@ -3,17 +3,17 @@ use std::sync::Arc; use ahash::HashMap; use apollo_compiler::ExecutableDocument; +use crate::Context; use crate::graphql; -use crate::plugins::demand_control::cost_calculator::schema::DemandControlledSchema; -use crate::plugins::demand_control::cost_calculator::static_cost::StaticCostCalculator; -use crate::plugins::demand_control::strategy::static_estimated::StaticEstimated; use crate::plugins::demand_control::DemandControlConfig; use crate::plugins::demand_control::DemandControlError; use crate::plugins::demand_control::Mode; use crate::plugins::demand_control::StrategyConfig; +use crate::plugins::demand_control::cost_calculator::schema::DemandControlledSchema; +use crate::plugins::demand_control::cost_calculator::static_cost::StaticCostCalculator; +use crate::plugins::demand_control::strategy::static_estimated::StaticEstimated; use crate::services::execution; use crate::services::subgraph; -use crate::Context; mod static_estimated; #[cfg(test)] diff --git a/apollo-router/src/plugins/demand_control/strategy/static_estimated.rs b/apollo-router/src/plugins/demand_control/strategy/static_estimated.rs index f3a114e8fb..ea442b3a69 100644 --- a/apollo-router/src/plugins/demand_control/strategy/static_estimated.rs +++ b/apollo-router/src/plugins/demand_control/strategy/static_estimated.rs @@ -1,9 +1,9 @@ use apollo_compiler::ExecutableDocument; use crate::graphql; +use crate::plugins::demand_control::DemandControlError; use crate::plugins::demand_control::cost_calculator::static_cost::StaticCostCalculator; use crate::plugins::demand_control::strategy::StrategyImpl; -use crate::plugins::demand_control::DemandControlError; use crate::services::execution; use crate::services::subgraph; diff --git a/apollo-router/src/plugins/demand_control/strategy/test.rs b/apollo-router/src/plugins/demand_control/strategy/test.rs index 265613472d..3755b8a5d2 100644 --- a/apollo-router/src/plugins/demand_control/strategy/test.rs +++ b/apollo-router/src/plugins/demand_control/strategy/test.rs @@ -1,9 +1,9 @@ use apollo_compiler::ExecutableDocument; +use crate::plugins::demand_control::DemandControlError; use crate::plugins::demand_control::strategy::StrategyImpl; use crate::plugins::demand_control::test::TestError; use crate::plugins::demand_control::test::TestStage; -use crate::plugins::demand_control::DemandControlError; use crate::services::execution::Request; use crate::services::subgraph::Response; diff --git a/apollo-router/src/plugins/enhanced_client_awareness/mod.rs b/apollo-router/src/plugins/enhanced_client_awareness/mod.rs new file mode 100644 index 0000000000..3c860a4eaa --- /dev/null +++ b/apollo-router/src/plugins/enhanced_client_awareness/mod.rs @@ -0,0 +1,74 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; +use tower::ServiceBuilder; +use tower::ServiceExt; + +use crate::plugin::Plugin; +use crate::plugin::PluginInit; +use crate::plugins::telemetry::CLIENT_LIBRARY_NAME; +use crate::plugins::telemetry::CLIENT_LIBRARY_VERSION; +use crate::services::supergraph; + +const CLIENT_LIBRARY_KEY: &str = "clientLibrary"; +const CLIENT_LIBRARY_NAME_KEY: &str = "name"; +const CLIENT_LIBRARY_VERSION_KEY: &str = "version"; + +#[derive(Debug, Deserialize, JsonSchema)] +struct Config {} + +struct EnhancedClientAwareness {} + +#[async_trait::async_trait] +impl Plugin for EnhancedClientAwareness { + type Config = Config; + + // This is invoked once after the router starts and compiled-in + // plugins are registered + async fn new(_init: PluginInit) -> Result { + Ok(EnhancedClientAwareness {}) + } + + fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { + ServiceBuilder::new() + .map_request(move |request: supergraph::Request| { + if let Some(client_library_metadata) = request + .supergraph_request + .body() + .extensions + .get(CLIENT_LIBRARY_KEY) + { + if let Some(client_library_name) = client_library_metadata + .get(CLIENT_LIBRARY_NAME_KEY) + .and_then(|value| value.as_str()) + { + let _ = request + .context + .insert(CLIENT_LIBRARY_NAME, client_library_name.to_string()); + }; + + if let Some(client_library_version) = client_library_metadata + .get(CLIENT_LIBRARY_VERSION_KEY) + .and_then(|value| value.as_str()) + { + let _ = request + .context + .insert(CLIENT_LIBRARY_VERSION, client_library_version.to_string()); + }; + }; + + request + }) + .service(service) + .boxed() + } +} + +register_plugin!( + "apollo", + "enhanced_client_awareness", + EnhancedClientAwareness +); + +#[cfg(test)] +mod tests; diff --git a/apollo-router/src/plugins/enhanced_client_awareness/tests.rs b/apollo-router/src/plugins/enhanced_client_awareness/tests.rs new file mode 100644 index 0000000000..6c7d6f6ebf --- /dev/null +++ b/apollo-router/src/plugins/enhanced_client_awareness/tests.rs @@ -0,0 +1,101 @@ +use tower::ServiceExt; + +use super::EnhancedClientAwareness; +use crate::Context; +use crate::plugin::Plugin; +use crate::plugin::PluginInit; +use crate::plugin::test::MockSupergraphService; +use crate::plugins::enhanced_client_awareness::CLIENT_LIBRARY_KEY; +use crate::plugins::enhanced_client_awareness::CLIENT_LIBRARY_NAME_KEY; +use crate::plugins::enhanced_client_awareness::CLIENT_LIBRARY_VERSION_KEY; +use crate::plugins::enhanced_client_awareness::Config; +use crate::plugins::telemetry::CLIENT_LIBRARY_NAME; +use crate::plugins::telemetry::CLIENT_LIBRARY_VERSION; +use crate::services::SupergraphResponse; +use crate::services::supergraph; + +#[tokio::test] +async fn given_client_library_metadata_adds_values_to_context() { + let mut mock_service = MockSupergraphService::new(); + + mock_service.expect_call().returning(move |request| { + // then + assert!( + request.context.contains_key(CLIENT_LIBRARY_NAME), + "Missing CLIENT_LIBRARY_NAME key/value" + ); + let client_library_name: String = request + .context + .get(CLIENT_LIBRARY_NAME) + .unwrap_or_default() + .unwrap_or_default(); + assert_eq!(client_library_name, "apollo-general-client-library"); + + assert!( + request.context.contains_key(CLIENT_LIBRARY_VERSION), + "Missing CLIENT_LIBRARY_VERSION key/value" + ); + let client_library_version: String = request + .context + .get(CLIENT_LIBRARY_VERSION) + .unwrap_or_default() + .unwrap_or_default(); + assert_eq!(client_library_version, "0.1.0"); + + SupergraphResponse::fake_builder().build() + }); + + let service_stack = + EnhancedClientAwareness::new(PluginInit::fake_new(Config {}, Default::default())) + .await + .unwrap() + .supergraph_service(mock_service.boxed()); + + // given + let mut clients_map = serde_json_bytes::map::Map::new(); + clients_map.insert( + CLIENT_LIBRARY_NAME_KEY, + "apollo-general-client-library".into(), + ); + clients_map.insert(CLIENT_LIBRARY_VERSION_KEY, "0.1.0".into()); + let mut extensions_map = serde_json_bytes::map::Map::new(); + extensions_map.insert(CLIENT_LIBRARY_KEY, clients_map.into()); + + // when + let request = supergraph::Request::fake_builder() + .context(Context::default()) + .query("{query:{ foo { bar } }}") + .extensions(extensions_map) + .build() + .unwrap(); + + let _ = service_stack.oneshot(request).await; +} + +#[tokio::test] +async fn without_client_library_metadata_does_not_add_values_to_context() { + let mut mock_service = MockSupergraphService::new(); + + mock_service.expect_call().returning(move |request| { + // then + assert!(!request.context.contains_key(CLIENT_LIBRARY_NAME)); + assert!(!request.context.contains_key(CLIENT_LIBRARY_VERSION)); + + SupergraphResponse::fake_builder().build() + }); + + let service_stack = + EnhancedClientAwareness::new(PluginInit::fake_new(Config {}, Default::default())) + .await + .unwrap() + .supergraph_service(mock_service.boxed()); + + // when + let request = supergraph::Request::fake_builder() + .context(Context::default()) + .query("{query:{ foo { bar } }}") + .build() + .unwrap(); + + let _ = service_stack.oneshot(request).await; +} diff --git a/apollo-router/src/plugins/expose_query_plan.rs b/apollo-router/src/plugins/expose_query_plan.rs index 657969e27c..55c3b09aaa 100644 --- a/apollo-router/src/plugins/expose_query_plan.rs +++ b/apollo-router/src/plugins/expose_query_plan.rs @@ -1,14 +1,20 @@ +use std::ops::ControlFlow; + +use futures::StreamExt; use futures::future::ready; use futures::stream::once; -use futures::StreamExt; use http::HeaderValue; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use serde_json_bytes::json; use tower::BoxError; +use tower::ServiceBuilder; use tower::ServiceExt as TowerServiceExt; +use super::connectors::query_plans::replace_connector_service_names; +use super::connectors::query_plans::replace_connector_service_names_text; +use crate::layers::ServiceBuilderExt; use crate::layers::ServiceExt; use crate::plugin::Plugin; use crate::plugin::PluginInit; @@ -18,9 +24,10 @@ use crate::services::supergraph; const EXPOSE_QUERY_PLAN_HEADER_NAME: &str = "Apollo-Expose-Query-Plan"; const ENABLE_EXPOSE_QUERY_PLAN_ENV: &str = "APOLLO_EXPOSE_QUERY_PLAN"; -const QUERY_PLAN_CONTEXT_KEY: &str = "experimental::expose_query_plan.plan"; -const FORMATTED_QUERY_PLAN_CONTEXT_KEY: &str = "experimental::expose_query_plan.formatted_plan"; -const ENABLED_CONTEXT_KEY: &str = "experimental::expose_query_plan.enabled"; +pub(crate) const QUERY_PLAN_CONTEXT_KEY: &str = "apollo::expose_query_plan::plan"; +pub(crate) const FORMATTED_QUERY_PLAN_CONTEXT_KEY: &str = + "apollo::expose_query_plan::formatted_plan"; +pub(crate) const ENABLED_CONTEXT_KEY: &str = "apollo::expose_query_plan::enabled"; #[derive(Debug, Clone)] struct ExposeQueryPlan { @@ -35,6 +42,13 @@ struct ExposeQueryPlanConfig( bool, ); +#[derive(Clone, serde::Deserialize, serde::Serialize)] +enum Setting { + Enabled, + DryRun, + Disabled, +} + #[async_trait::async_trait] impl Plugin for ExposeQueryPlan { type Config = ExposeQueryPlanConfig; @@ -47,28 +61,40 @@ impl Plugin for ExposeQueryPlan { } fn execution_service(&self, service: execution::BoxService) -> execution::BoxService { - service - .map_request(move |req: execution::Request| { - if req + ServiceBuilder::new() + .checkpoint(|req: execution::Request| { + let setting = req .context - .get::<_, bool>(ENABLED_CONTEXT_KEY) + .get::<_, Setting>(ENABLED_CONTEXT_KEY) .ok() .flatten() - .is_some() - { - req.context - .insert(QUERY_PLAN_CONTEXT_KEY, req.query_plan.root.clone()) - .unwrap(); + .unwrap_or(Setting::Disabled); + + if !matches!(setting, Setting::Disabled) { + let plan = + replace_connector_service_names(req.query_plan.root.clone(), &req.context); + let text = replace_connector_service_names_text( + req.query_plan.formatted_query_plan.clone(), + &req.context, + ); + + req.context.insert(QUERY_PLAN_CONTEXT_KEY, plan).unwrap(); req.context - .insert( - FORMATTED_QUERY_PLAN_CONTEXT_KEY, - req.query_plan.formatted_query_plan.clone(), - ) + .insert(FORMATTED_QUERY_PLAN_CONTEXT_KEY, text) .unwrap(); } - - req + if matches!(setting, Setting::DryRun) { + Ok(ControlFlow::Break( + execution::Response::error_builder() + .errors(vec![]) + .context(req.context) + .build()?, + )) + } else { + Ok(ControlFlow::Continue(req)) + } }) + .service(service) .boxed() } @@ -76,30 +102,41 @@ impl Plugin for ExposeQueryPlan { let conf_enabled = self.enabled; service .map_future_with_request_data(move |req: &supergraph::Request| { - let is_enabled = conf_enabled && req.supergraph_request.headers().get(EXPOSE_QUERY_PLAN_HEADER_NAME) == Some(&HeaderValue::from_static("true")); - if is_enabled { - req.context.insert(ENABLED_CONTEXT_KEY, true).unwrap(); + let setting = if conf_enabled { + let header = req.supergraph_request.headers().get(EXPOSE_QUERY_PLAN_HEADER_NAME); + if header == Some(&HeaderValue::from_static("true")) { + Setting::Enabled + } else if header == Some(&HeaderValue::from_static("dry-run")) { + Setting::DryRun + } else { + Setting::Disabled + } + } else { + Setting::Disabled + }; + + if !matches!(setting, Setting::Disabled) { + req.context.insert(ENABLED_CONTEXT_KEY, setting.clone()).unwrap(); } - is_enabled - }, move | is_enabled: bool, f| async move { + setting + }, move | setting: Setting, f| async move { let mut res: supergraph::ServiceResult = f.await; res = match res { Ok(mut res) => { - if is_enabled { + if !matches!(setting, Setting::Disabled) { let (parts, stream) = res.response.into_parts(); - let (mut first, rest) = stream.into_future().await; + let (mut first, rest) = StreamExt::into_future(stream).await; - if let Some(first) = &mut first { - if let Some(plan) = + if let Some(first) = &mut first + && let Some(plan) = res.context.get_json_value(QUERY_PLAN_CONTEXT_KEY) { first .extensions .insert("apolloQueryPlan", json!({ "object": { "kind": "QueryPlan", "node": plan }, "text": res.context.get_json_value(FORMATTED_QUERY_PLAN_CONTEXT_KEY) })); } - } res.response = http::Response::from_parts( parts, once(ready(first.unwrap_or_default())).chain(rest).boxed(), @@ -126,10 +163,10 @@ mod tests { use tower::Service; use super::*; + use crate::MockedSubgraphs; use crate::graphql::Response; use crate::json_ext::Object; use crate::plugin::test::MockSubgraph; - use crate::MockedSubgraphs; static VALID_QUERY: &str = r#"query TopProducts($first: Int) { topProducts(first: $first) { upc name reviews { id product { name } author { id name } } } }"#; @@ -211,6 +248,53 @@ mod tests { .unwrap() } + async fn execute_supergraph_test_dry_run( + query: &str, + mut supergraph_service: supergraph::BoxCloneService, + ) -> Response { + let request = supergraph::Request::fake_builder() + .query(query.to_string()) + .variable("first", 2usize) + .header(EXPOSE_QUERY_PLAN_HEADER_NAME, "dry-run") + .build() + .expect("expecting valid request"); + + supergraph_service + .ready() + .await + .unwrap() + .call(request) + .await + .unwrap() + .next_response() + .await + .unwrap() + } + + #[tokio::test] + async fn it_doesnt_expose_query_plan() { + let supergraph = build_mock_supergraph(serde_json::json! {{ + "plugins": { + "experimental.expose_query_plan": false + } + }}) + .await; + + // Since we're not exposing the query plan, we expect the extensions (where the query plan + // would be) to be empty + // + // That the extension is empty is important. This lets us assume that when the extension is + // populated (like in the following tests when we're testing that the query plan is + // output), it's populated with _only_ the query plan (meaning we won't be experiencing + // false positives) + assert!( + execute_supergraph_test(VALID_QUERY, supergraph) + .await + .extensions + .is_empty() + ) + } + #[tokio::test] async fn it_expose_query_plan() { let response = execute_supergraph_test( @@ -218,50 +302,40 @@ mod tests { build_mock_supergraph(serde_json::json! {{ "plugins": { "experimental.expose_query_plan": true - }, - "supergraph": { - // TODO(@goto-bus-stop): need to update the mocks and remove this, #6013 - "generate_query_fragments": false, } }}) .await, ) .await; - insta::assert_json_snapshot!(serde_json::to_value(response).unwrap()); - // let's try that again - let response = execute_supergraph_test( + // Since we're exposing the query plan, the extensions better not be empty! See the test + // for not exposing query plans to know why the assumption that a non-empty extension means + // we have a query plan + assert!(!response.extensions.is_empty()); + + // Since this is a full-run (ie, not a dry-run), we should have data + assert!(response.data.is_some()); + } + + #[tokio::test] + async fn it_expose_query_plan_without_executing() { + let response = execute_supergraph_test_dry_run( VALID_QUERY, build_mock_supergraph(serde_json::json! {{ "plugins": { "experimental.expose_query_plan": true - }, - "supergraph": { - // TODO(@goto-bus-stop): need to update the mocks and remove this, #6013 - "generate_query_fragments": false, } }}) .await, ) .await; - insta::assert_json_snapshot!(serde_json::to_value(response).unwrap()); - } - - #[tokio::test] - async fn it_doesnt_expose_query_plan() { - let supergraph = build_mock_supergraph(serde_json::json! {{ - "plugins": { - "experimental.expose_query_plan": false - }, - "supergraph": { - // TODO(@goto-bus-stop): need to update the mocks and remove this, #6013 - "generate_query_fragments": false, - } - }}) - .await; - let response = execute_supergraph_test(VALID_QUERY, supergraph).await; + // Since we're exposing the query plan, the extensions better not be empty! See the test + // for not exposing query plans to know why the assumption that a non-empty extension means + // we have a query plan + assert!(!response.extensions.is_empty()); - insta::assert_json_snapshot!(serde_json::to_value(response).unwrap()); + // Since this is a dry-run, we shouldn't have any data + assert!(response.data.is_none()); } } diff --git a/apollo-router/src/plugins/file_uploads/error.rs b/apollo-router/src/plugins/file_uploads/error.rs index e11d2f8ba6..871f07b8c5 100644 --- a/apollo-router/src/plugins/file_uploads/error.rs +++ b/apollo-router/src/plugins/file_uploads/error.rs @@ -16,7 +16,7 @@ pub(super) enum FileUploadError { #[error("Missing multipart field 'map', it should be a second field in request body.")] MissingMapField, - #[error("Invalid JSON in the ‘map’ multipart field: {0}")] + #[error("Invalid JSON in the 'map' multipart field: {0}")] InvalidJsonInMapField(serde_json::Error), #[error("Batched requests are not supported for file uploads.")] @@ -28,7 +28,9 @@ pub(super) enum FileUploadError { #[error("Invalid path '{0}' found inside 'map' field, missing name of variable.")] MissingVariableNameInsideMapField(String), - #[error("Invalid path '{0}' found inside 'map' field, it does not point to a valid value inside 'operations' field.")] + #[error( + "Invalid path '{0}' found inside 'map' field, it does not point to a valid value inside 'operations' field." + )] InputValueNotFound(String), #[error("Missing files in the request: {0}.")] @@ -40,7 +42,9 @@ pub(super) enum FileUploadError { #[error("Variables containing files are forbidden inside subscription: {0}.")] VariablesForbiddenInsideSubscription(String), - #[error("References to variables containing files are ordered in the way that prevent streaming of files.")] + #[error( + "References to variables containing files are ordered in the way that prevent streaming of files." + )] MisorderedVariables, #[error("Variables use mutiple time in the way that prevent streaming of files: {0}.")] @@ -54,6 +58,9 @@ pub(super) enum FileUploadError { #[error("{0}")] HyperBodyErrorWrapper(#[from] hyper::Error), + + #[error("{0}")] + AxumError(#[from] axum::Error), } impl From for graphql::Error { diff --git a/apollo-router/src/plugins/file_uploads/map_field.rs b/apollo-router/src/plugins/file_uploads/map_field.rs index 3e1d141c31..274ca59f93 100644 --- a/apollo-router/src/plugins/file_uploads/map_field.rs +++ b/apollo-router/src/plugins/file_uploads/map_field.rs @@ -4,8 +4,8 @@ use indexmap::IndexMap; use indexmap::IndexSet; use serde_json_bytes::ByteString; -use super::error::FileUploadError; use super::Result as UploadResult; +use super::error::FileUploadError; type MapPerVariable = HashMap; type MapPerFile = HashMap>>; diff --git a/apollo-router/src/plugins/file_uploads/mod.rs b/apollo-router/src/plugins/file_uploads/mod.rs index fb44aa6b25..227b19c35b 100644 --- a/apollo-router/src/plugins/file_uploads/mod.rs +++ b/apollo-router/src/plugins/file_uploads/mod.rs @@ -2,15 +2,15 @@ use std::ops::ControlFlow; use std::sync::Arc; use futures::FutureExt; -use http::header::CONTENT_LENGTH; -use http::header::CONTENT_TYPE; use http::HeaderName; use http::HeaderValue; +use http::header::CONTENT_LENGTH; +use http::header::CONTENT_TYPE; +use mediatype::MediaType; +use mediatype::ReadParams; use mediatype::names::BOUNDARY; use mediatype::names::FORM_DATA; use mediatype::names::MULTIPART; -use mediatype::MediaType; -use mediatype::ReadParams; use tower::BoxError; use tower::ServiceBuilder; use tower::ServiceExt; @@ -42,8 +42,6 @@ mod rearrange_query_plan; type Result = std::result::Result; -// FIXME: check if we need to hide docs -#[doc(hidden)] // Only public for integration tests struct FileUploadsPlugin { enabled: bool, limits: MultipartRequestLimits, @@ -68,7 +66,7 @@ impl PluginPrivate for FileUploadsPlugin { } let limits = self.limits; ServiceBuilder::new() - .oneshot_checkpoint_async(move |req: router::Request| { + .checkpoint_async(move |req: router::Request| { async move { let context = req.context.clone(); Ok(match router_layer(req, limits).await { @@ -83,6 +81,7 @@ impl PluginPrivate for FileUploadsPlugin { } .boxed() }) + .buffered() .service(service) .boxed() } @@ -92,7 +91,7 @@ impl PluginPrivate for FileUploadsPlugin { return service; } ServiceBuilder::new() - .oneshot_checkpoint_async(move |req: supergraph::Request| { + .checkpoint_async(move |req: supergraph::Request| { async move { let context = req.context.clone(); Ok(match supergraph_layer(req).await { @@ -107,6 +106,7 @@ impl PluginPrivate for FileUploadsPlugin { } .boxed() }) + .buffered() .service(service) .boxed() } @@ -141,18 +141,19 @@ impl PluginPrivate for FileUploadsPlugin { return service; } ServiceBuilder::new() - .oneshot_checkpoint_async(|req: subgraph::Request| { + .checkpoint_async(|req: subgraph::Request| { subgraph_layer(req) .boxed() .map(|req| Ok(ControlFlow::Continue(req))) .boxed() }) + .buffered() .service(service) .boxed() } } -fn get_multipart_mime(req: &router::Request) -> Option { +fn get_multipart_mime(req: &router::Request) -> Option> { req.router_request .headers() .get(CONTENT_TYPE) @@ -162,6 +163,11 @@ fn get_multipart_mime(req: &router::Request) -> Option { .filter(|mime| mime.ty == MULTIPART && mime.subty == FORM_DATA) } +/// Takes in multipart request bodies, and turns them into serialized JSON bodies that the rest of the router +/// pipeline can understand. +/// +/// # Context +/// Adds a [`MultipartRequest`] value to context. async fn router_layer( req: router::Request, limits: MultipartRequestLimits, @@ -174,12 +180,12 @@ async fn router_layer( let (mut request_parts, request_body) = req.router_request.into_parts(); - let mut multipart = MultipartRequest::new(request_body.into(), boundary, limits); + let mut multipart = MultipartRequest::new(request_body, boundary, limits); let operations_stream = multipart.operations_field().await?; req.context .extensions() - .with_lock(|mut lock| lock.insert(multipart)); + .with_lock(|lock| lock.insert(multipart)); let content_type = operations_stream .headers() @@ -191,9 +197,9 @@ async fn router_layer( request_parts.headers.insert(CONTENT_TYPE, content_type); request_parts.headers.remove(CONTENT_LENGTH); - let request_body = RouterBody::wrap_stream(operations_stream); + let request_body = router::body::from_result_stream(operations_stream); return Ok(router::Request::from(( - http::Request::from_parts(request_parts, request_body.into_inner()), + http::Request::from_parts(request_parts, request_body), req.context, ))); } @@ -201,6 +207,14 @@ async fn router_layer( Ok(req) } +/// Patch up the variable values in file upload requests. +/// +/// File uploads do something funky: They use *required* GraphQL field arguments (`file: Upload!`), +/// but then pass `null` as the variable value. This is invalid GraphQL, but it is how the file +/// uploads spec works. +/// +/// To make all this work in the router, we stick some placeholder value in the variables used for +/// file uploads, and then remove them before we pass on the files to subgraphs. async fn supergraph_layer(mut req: supergraph::Request) -> Result { let multipart = req .context @@ -219,7 +233,7 @@ async fn supergraph_layer(mut req: supergraph::Request) -> Result", filename).into(), + format!("").into(), ), ) .map_err(|path| FileUploadError::InputValueNotFound(path.join(".")))?; @@ -227,7 +241,7 @@ async fn supergraph_layer(mut req: supergraph::Request) -> Result( // Removes value at path. fn remove_value_at_path<'a>(variables: &'a mut json_ext::Object, path: &'a [String]) { - let _ = get_value_at_path(variables, path).take(); + if let Some(v) = get_value_at_path(variables, path) { + *v = serde_json_bytes::Value::Null; + } } fn get_value_at_path<'a>( @@ -359,8 +375,9 @@ pub(crate) async fn http_request_wrapper( request_parts .headers .insert(CONTENT_TYPE, form.content_type()); - let body = RouterBody::wrap_stream(form.into_stream(operations).await); - return http::Request::from_parts(request_parts, body); + let request_body = router::body::from_result_stream(form.into_stream(operations).await); + + return http::Request::from_parts(request_parts, request_body); } req } diff --git a/apollo-router/src/plugins/file_uploads/multipart_form_data.rs b/apollo-router/src/plugins/file_uploads/multipart_form_data.rs index 59b3a1d54a..6868eb1d72 100644 --- a/apollo-router/src/plugins/file_uploads/multipart_form_data.rs +++ b/apollo-router/src/plugins/file_uploads/multipart_form_data.rs @@ -2,20 +2,22 @@ use std::sync::Arc; use bytes::Bytes; use bytes::BytesMut; +use futures::Stream; use futures::stream::StreamExt; use futures::stream::TryStreamExt; -use futures::Stream; use http::HeaderMap; use http::HeaderValue; +use http_body_util::BodyExt; +use mediatype::MediaType; use mediatype::names::BOUNDARY; use mediatype::names::FORM_DATA; use mediatype::names::MULTIPART; -use mediatype::MediaType; use rand::RngCore; -use super::map_field::MapFieldRaw; use super::MultipartRequest; use super::Result as UploadResult; +use super::error::FileUploadError; +use super::map_field::MapFieldRaw; use crate::services::router::body::RouterBody; #[derive(Clone, Debug)] @@ -27,7 +29,7 @@ pub(super) struct MultipartFormData { impl MultipartFormData { pub(super) fn new(map: MapFieldRaw, multipart: MultipartRequest) -> Self { - let boundary = format!("{:016x}", rand::thread_rng().next_u64()); + let boundary = format!("{:016x}", rand::rng().next_u64()); Self { boundary, map: Arc::new(map), @@ -57,9 +59,8 @@ impl MultipartFormData { self.boundary, name ) }; - let static_part = tokio_stream::once(Ok(Bytes::from(field_prefix("operations")))) - .chain(operations.into_inner().map_err(Into::into)) + .chain(operations.into_data_stream().map_err(FileUploadError::from)) .chain(tokio_stream::once(Ok(Bytes::from(format!( "\r\n{}{}\r\n", field_prefix("map"), diff --git a/apollo-router/src/plugins/file_uploads/multipart_request.rs b/apollo-router/src/plugins/file_uploads/multipart_request.rs index c91095138c..aadfbdee7e 100644 --- a/apollo-router/src/plugins/file_uploads/multipart_request.rs +++ b/apollo-router/src/plugins/file_uploads/multipart_request.rs @@ -8,6 +8,7 @@ use std::task::Poll; use bytes::Bytes; use futures::Stream; use http::HeaderMap; +use http_body_util::BodyExt; use itertools::Itertools; use multer::Constraints; use multer::Multipart; @@ -16,11 +17,11 @@ use pin_project_lite::pin_project; use tokio::sync::Mutex; use tokio::sync::OwnedMutexGuard; +use super::Result as UploadResult; use super::config::MultipartRequestLimits; use super::error::FileUploadError; use super::map_field::MapField; use super::map_field::MapFieldRaw; -use super::Result as UploadResult; use crate::services::router::body::RouterBody; // The limit to set for the map field in the multipart request. @@ -74,7 +75,7 @@ impl MultipartRequest { limits: MultipartRequestLimits, ) -> Self { let multer = Multipart::with_constraints( - request_body, + request_body.into_data_stream(), boundary, Constraints::new().size_limit(SizeLimit::new().for_field("map", MAP_SIZE_LIMIT)), ); @@ -173,7 +174,7 @@ where let filename = field .file_name() .or_else(|| field.name()) - .map(|name| format!("'{}'", name)) + .map(|name| format!("'{name}'")) .unwrap_or_else(|| "unknown".to_owned()); let field = Pin::new(field); @@ -225,10 +226,7 @@ where let files = mem::take(&mut self.file_names); return Poll::Ready(Some(Err(FileUploadError::MissingFiles( - files - .into_iter() - .map(|file| format!("'{}'", file)) - .join(", "), + files.into_iter().map(|file| format!("'{file}'")).join(", "), )))); } Poll::Ready(Ok(Some(field))) => { @@ -241,12 +239,12 @@ where } else { self.state.read_files_counter += 1; - if let Some(name) = field.name() { - if self.file_names.remove(name) { - let prefix = (self.file_prefix_fn)(field.headers()); - self.current_field = Some(field); - return Poll::Ready(Some(Ok(prefix))); - } + if let Some(name) = field.name() + && self.file_names.remove(name) + { + let prefix = (self.file_prefix_fn)(field.headers()); + self.current_field = Some(field); + return Poll::Ready(Some(Ok(prefix))); } // The file is extraneous, but the rest can still be processed. diff --git a/apollo-router/src/plugins/file_uploads/rearrange_query_plan.rs b/apollo-router/src/plugins/file_uploads/rearrange_query_plan.rs index 22bcf3fdb6..a6378c89c0 100644 --- a/apollo-router/src/plugins/file_uploads/rearrange_query_plan.rs +++ b/apollo-router/src/plugins/file_uploads/rearrange_query_plan.rs @@ -7,9 +7,9 @@ use indexmap::IndexMap; use indexmap::IndexSet; use itertools::Itertools; -use super::error::FileUploadError; use super::MapField; use super::Result as UploadResult; +use super::error::FileUploadError; use crate::query_planner::DeferredNode; use crate::query_planner::FlattenNode; use crate::query_planner::PlanNode; @@ -109,7 +109,7 @@ fn rearrange_plan_node<'a>( return Err(FileUploadError::VariablesForbiddenInsideSubscription( rest_variables .into_keys() - .map(|name| format!("${}", name)) + .map(|name| format!("${name}")) .join(", "), )); } @@ -146,7 +146,7 @@ fn rearrange_plan_node<'a>( return Err(FileUploadError::VariablesForbiddenInsideDefer( deferred_variables .into_keys() - .map(|name| format!("${}", name)) + .map(|name| format!("${name}")) .join(", "), )); } @@ -193,7 +193,7 @@ fn rearrange_plan_node<'a>( return Err(FileUploadError::DuplicateVariableUsages( duplicate_variables .iter() - .map(|name| format!("${}", name)) + .map(|name| format!("${name}")) .join(", "), )); } @@ -241,7 +241,7 @@ fn rearrange_plan_node<'a>( return Err(FileUploadError::DuplicateVariableUsages( duplicate_variables .iter() - .map(|name| format!("${}", name)) + .map(|name| format!("${name}")) .join(", "), )); } @@ -280,8 +280,8 @@ mod tests { use serde_json::json; use super::*; - use crate::query_planner::subscription::SubscriptionNode; use crate::query_planner::Primary; + use crate::query_planner::subscription::SubscriptionNode; use crate::services::execution::QueryPlan; // Custom `assert_matches` due to its current nightly-only status, see diff --git a/apollo-router/src/plugins/fleet_detector.rs b/apollo-router/src/plugins/fleet_detector.rs new file mode 100644 index 0000000000..77a5ed9d45 --- /dev/null +++ b/apollo-router/src/plugins/fleet_detector.rs @@ -0,0 +1,820 @@ +use std::env::consts::ARCH; +use std::env::consts::OS; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use futures::StreamExt; +use http_body::Body as _; +use http_body_util::BodyExt as _; +use opentelemetry::KeyValue; +use opentelemetry::metrics::MeterProvider; +use opentelemetry::metrics::ObservableGauge; +use parking_lot::Mutex; +use schemars::JsonSchema; +use serde::Deserialize; +use sysinfo::System; +use tower::BoxError; +use tower::ServiceExt as _; +use tower::util::BoxService; +use tracing::debug; + +use crate::metrics::meter_provider; +use crate::plugin::PluginInit; +use crate::plugin::PluginPrivate; +use crate::services::http::HttpRequest; +use crate::services::http::HttpResponse; +use crate::services::router; + +const REFRESH_INTERVAL: Duration = Duration::from_secs(60); +const COMPUTE_DETECTOR_THRESHOLD: u16 = 24576; +const OFFICIAL_HELM_CHART_VAR: &str = "APOLLO_ROUTER_OFFICIAL_HELM_CHART"; +const DEPLOYMENT_TYPE_VAR: &str = "APOLLO_ROUTER_DEPLOYMENT_TYPE"; + +#[derive(Debug, Default, Deserialize, JsonSchema)] +struct Conf {} + +#[derive(Debug)] +struct SystemGetter { + system: System, + start: Instant, +} + +impl SystemGetter { + fn new() -> Self { + let mut system = System::new(); + system.refresh_cpu_all(); + system.refresh_memory(); + Self { + system, + start: Instant::now(), + } + } + + fn get_system(&mut self) -> &System { + if self.start.elapsed() >= REFRESH_INTERVAL { + self.start = Instant::now(); + self.system.refresh_cpu_all(); + self.system.refresh_memory(); + } + &self.system + } +} + +#[derive(Default)] +enum GaugeStore { + #[default] + Disabled, + Pending, + // This `Vec` is not used explicitly but is to be kept alive until the enum is dropped + Active(#[allow(unused)] Vec>), +} + +impl GaugeStore { + fn active(opts: &GaugeOptions) -> GaugeStore { + let system_getter = Arc::new(Mutex::new(SystemGetter::new())); + let meter = meter_provider().meter("apollo/router"); + + let mut gauges = Vec::new(); + // apollo.router.instance + { + let mut attributes = Vec::new(); + // CPU architecture + attributes.push(KeyValue::new("host.arch", get_otel_arch())); + // Operating System + attributes.push(KeyValue::new("os.type", get_otel_os())); + if OS == "linux" { + attributes.push(KeyValue::new( + "linux.distribution", + System::distribution_id(), + )); + } + // Compute Environment + if let Some(env) = apollo_environment_detector::detect_one(COMPUTE_DETECTOR_THRESHOLD) { + attributes.push(KeyValue::new("cloud.platform", env.platform_code())); + if let Some(cloud_provider) = env.cloud_provider() { + attributes.push(KeyValue::new("cloud.provider", cloud_provider.code())); + } + } + // Deployment type + attributes.push(KeyValue::new( + "deployment.type", + opts.deployment_type.clone(), + )); + gauges.push( + meter + .u64_observable_gauge("apollo.router.instance") + .with_description("The number of instances the router is running on") + .with_callback(move |i| { + i.observe(1, &attributes); + }) + .init(), + ); + } + // apollo.router.instance.cpu_freq + { + let system_getter = system_getter.clone(); + gauges.push( + meter + .u64_observable_gauge("apollo.router.instance.cpu_freq") + .with_description( + "The CPU frequency of the underlying instance the router is deployed to", + ) + .with_unit("Mhz") + .with_callback(move |gauge| { + let local_system_getter = system_getter.clone(); + let mut system_getter = local_system_getter.lock(); + let system = system_getter.get_system(); + let cpus = system.cpus(); + let cpu_freq = + cpus.iter().map(|cpu| cpu.frequency()).sum::() / cpus.len() as u64; + gauge.observe(cpu_freq, &[]) + }) + .init(), + ); + } + // apollo.router.instance.cpu_count + { + let system_getter = system_getter.clone(); + gauges.push( + meter + .u64_observable_gauge("apollo.router.instance.cpu_count") + .with_description( + "The number of CPUs reported by the instance the router is running on", + ) + .with_callback(move |gauge| { + let local_system_getter = system_getter.clone(); + let mut system_getter = local_system_getter.lock(); + let system = system_getter.get_system(); + let (detection_method, cpu_count) = detect_cpu_count(system); + gauge.observe( + cpu_count, + &[ + KeyValue::new("host.arch", get_otel_arch()), + KeyValue::new("detection_method", detection_method), + ], + ) + }) + .init(), + ); + } + // apollo.router.instance.total_memory + { + let system_getter = system_getter.clone(); + gauges.push( + meter + .u64_observable_gauge("apollo.router.instance.total_memory") + .with_description( + "The amount of memory reported by the instance the router is running on", + ) + .with_callback(move |gauge| { + let local_system_getter = system_getter.clone(); + let mut system_getter = local_system_getter.lock(); + let system = system_getter.get_system(); + gauge.observe( + system.total_memory(), + &[KeyValue::new("host.arch", get_otel_arch())], + ) + }) + .with_unit("bytes") + .init(), + ); + } + { + let opts = opts.clone(); + gauges.push( + meter + .u64_observable_gauge("apollo.router.instance.schema") + .with_description("Details about the current in-use schema") + .with_callback(move |gauge| { + // NOTE: this is a fixed gauge. We only care about observing the included + // attributes. + let mut attributes: Vec = vec![KeyValue::new( + "schema_hash", + opts.supergraph_schema_hash.clone(), + )]; + if let Some(launch_id) = opts.launch_id.as_ref() { + attributes.push(KeyValue::new("launch_id", launch_id.to_string())); + } + gauge.observe(1, attributes.as_slice()) + }) + .init(), + ) + } + GaugeStore::Active(gauges) + } +} + +#[derive(Clone, Default)] +struct GaugeOptions { + supergraph_schema_hash: String, + launch_id: Option, + deployment_type: String, +} + +#[derive(Default)] +struct FleetDetector { + gauge_store: Mutex, + + // Options passed to the gauge_store during activation. + gauge_options: GaugeOptions, +} + +#[async_trait::async_trait] +impl PluginPrivate for FleetDetector { + type Config = Conf; + + async fn new(plugin: PluginInit) -> Result { + debug!("initialising fleet detection plugin"); + + let deployment_type = get_deployment_type( + std::env::var_os(OFFICIAL_HELM_CHART_VAR) + .is_some() + .then_some("true"), + std::env::var(DEPLOYMENT_TYPE_VAR).ok().as_deref(), + ); + + let gauge_options = GaugeOptions { + supergraph_schema_hash: plugin.supergraph_schema_id.to_string(), + launch_id: plugin.launch_id.map(|s| s.to_string()), + deployment_type, + }; + + Ok(FleetDetector { + gauge_store: Mutex::new(GaugeStore::Pending), + gauge_options, + }) + } + + fn activate(&self) { + let mut store = self.gauge_store.lock(); + if matches!(*store, GaugeStore::Pending) { + *store = GaugeStore::active(&self.gauge_options); + } + } + + fn router_service(&self, service: router::BoxService) -> router::BoxService { + service + // Count the number of request bytes from clients to the router + .map_request(move |req: router::Request| router::Request { + router_request: req.router_request.map(move |body| { + router::body::from_result_stream(body.into_data_stream().inspect(|res| { + if let Ok(bytes) = res { + u64_counter!( + "apollo.router.operations.request_size", + "Total number of request bytes from clients", + bytes.len() as u64 + ); + } + })) + }), + context: req.context, + }) + // Count the number of response bytes from the router to clients + .map_response(move |res: router::Response| { + router::Response::http_response_builder() + .response(res.response.map(move |body| { + router::body::from_result_stream(body.into_data_stream().inspect(|res| { + if let Ok(bytes) = res { + u64_counter!( + "apollo.router.operations.response_size", + "Total number of response bytes to clients", + bytes.len() as u64 + ); + } + })) + })) + .context(res.context) + .build() + .unwrap() + }) + .boxed() + } + + fn http_client_service( + &self, + subgraph_name: &str, + service: BoxService, + ) -> BoxService { + let sn_req = Arc::new(subgraph_name.to_string()); + let sn_res = sn_req.clone(); + service + // Count the number of bytes per subgraph fetch request + .map_request(move |req: HttpRequest| { + let sn = sn_req.clone(); + HttpRequest { + http_request: req.http_request.map(move |body| { + let sn = sn.clone(); + let size_hint = body.size_hint(); + + // Short-circuit for complete bodies + // + // If the `SizeHint` gives us an exact value, we can use this for the + // metric and return without wrapping the request Body into a stream. + if let Some(size) = size_hint.exact() { + let sn = sn.clone(); + u64_counter!( + "apollo.router.operations.fetch.request_size", + "Total number of request bytes for subgraph fetches", + size, + subgraph.name = sn.to_string() + ); + return body; + } + + // For streaming bodies, we need to wrap the stream and count bytes as we go + router::body::from_result_stream(body.into_data_stream().inspect( + move |res| { + if let Ok(bytes) = res { + let sn = sn.clone(); + u64_counter!( + "apollo.router.operations.fetch.request_size", + "Total number of request bytes for subgraph fetches", + bytes.len() as u64, + subgraph.name = sn.to_string() + ); + } + }, + )) + }), + context: req.context, + } + }) + // Count the number of fetches, and the number of bytes per subgraph fetch response + .map_result(move |res| { + let sn = sn_res.clone(); + match res { + Ok(res) => { + u64_counter!( + "apollo.router.operations.fetch", + "Number of subgraph fetches", + 1u64, + subgraph.name = sn.to_string(), + client_error = false, + http.response.status_code = res.http_response.status().as_u16() as i64 + ); + let sn = sn_res.clone(); + Ok(HttpResponse { + http_response: res.http_response.map(move |body| { + let sn = sn.clone(); + router::body::from_result_stream(body.into_data_stream().inspect( + move |res| { + if let Ok(bytes) = res { + let sn = sn.clone(); + u64_counter!( + "apollo.router.operations.fetch.response_size", + "Total number of response bytes for subgraph fetches", + bytes.len() as u64, + subgraph.name = sn.to_string() + ); + } + }, + )) + }), + context: res.context, + }) + } + Err(err) => { + u64_counter!( + "apollo.router.operations.fetch", + "Number of subgraph fetches", + 1u64, + subgraph.name = sn.to_string(), + client_error = true + ); + Err(err) + } + } + }) + .boxed() + } +} + +#[cfg(not(target_os = "linux"))] +fn detect_cpu_count(system: &System) -> (&'static str, u64) { + ("system", system.cpus().len() as u64) +} + +// Because Linux provides CGroups as a way of controlling the proportion of CPU time each +// process gets we can perform slightly more introspection here than simply appealing to the +// raw number of processors. Hence, the extra logic including below. +#[cfg(target_os = "linux")] +fn detect_cpu_count(system: &System) -> (&'static str, u64) { + use std::fs; + + let system_cpus = system.cpus().len() as u64; + // Grab the contents of /proc/filesystems + match fs::read_to_string("/proc/filesystems").map(|fs| detect_cgroup_version(&fs)) { + Ok(CGroupVersion::CGroup2) => { + // If we're looking at cgroup2 then we need to look in `cpu.max` + match fs::read_to_string("/sys/fs/cgroup/cpu.max") { + Ok(readings) => { + // The format of the file lists the quota first, followed by the period, + // but the quota could also be max which would mean there are no restrictions. + if readings.starts_with("max") { + ("system", system_cpus) + } else { + // If it's not max then divide the two to get an integer answer + match readings.split_once(' ') { + None => ("system", system_cpus), + Some((quota, period)) => ( + "cgroup2", + calculate_cpu_count_with_default(system_cpus, quota, period), + ), + } + } + } + Err(_) => ("system", system_cpus), + } + } + Ok(CGroupVersion::CGroup) => { + // If we're in cgroup v1 then we need to read from two separate files + let quota = fs::read_to_string("/sys/fs/cgroup/cpu/cpu.cfs_quota_us") + .map(|s| String::from(s.trim())) + .ok(); + let period = fs::read_to_string("/sys/fs/cgroup/cpu/cpu.cfs_period_us") + .map(|s| String::from(s.trim())) + .ok(); + match (quota, period) { + (Some(quota), Some(period)) => { + // In v1 quota being -1 indicates no restrictions so return the maximum (all + // system CPUs) otherwise divide the two. + if quota == "-1" { + ("system", system_cpus) + } else { + ( + "cgroup", + calculate_cpu_count_with_default(system_cpus, "a, &period), + ) + } + } + _ => ("system", system_cpus), + } + } + // Error reading the file or no cgroup support + _ => ("system", system_cpus), + } +} + +/// Detect the cgroup version supported in Linux based on the content of the `/proc/filesystems` +/// file +#[allow(unused)] +fn detect_cgroup_version(filesystems: &str) -> CGroupVersion { + use std::collections::HashSet; + let versions: HashSet<_> = filesystems + .lines() + .flat_map(|line: &str| line.split_whitespace()) + .filter(|x| x.contains("cgroup")) + .collect(); + + if versions.contains("cgroup2") { + CGroupVersion::CGroup2 + } else if versions.contains("cgroup") { + CGroupVersion::CGroup + } else { + CGroupVersion::None + } +} + +#[allow(unused)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum CGroupVersion { + CGroup2, + CGroup, + #[default] + None, +} + +#[cfg(target_os = "linux")] +fn calculate_cpu_count_with_default(default: u64, quota: &str, period: &str) -> u64 { + if let (Ok(q), Ok(p)) = (quota.parse::(), period.parse::()) { + q / p + } else { + default + } +} + +fn get_otel_arch() -> &'static str { + match ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + "arm" => "arm32", + "powerpc" => "ppc32", + "powerpc64" => "ppc64", + a => a, + } +} + +fn get_otel_os() -> &'static str { + match OS { + "apple" => "darwin", + "dragonfly" => "dragonflybsd", + "macos" => "darwin", + "ios" => "darwin", + a => a, + } +} + +fn get_deployment_type(official_helm_chart: Option<&str>, deployment_type: Option<&str>) -> String { + // Official Apollo helm chart + if official_helm_chart.is_some() { + return "official_helm_chart".to_string(); + } + + // Check for a custom deployment type via APOLLO_ROUTER_DEPLOYMENT_TYPE + if let Some(val) = deployment_type + && !val.is_empty() + { + return val.to_string(); + } + + "unknown".to_string() +} + +register_private_plugin!("apollo", "fleet_detector", FleetDetector); + +#[cfg(test)] +mod tests { + use std::convert::Infallible; + + use http::StatusCode; + use tower::Service as _; + + use super::*; + use crate::metrics::FutureMetricsExt as _; + use crate::plugin::test::MockHttpClientService; + use crate::plugin::test::MockRouterService; + use crate::services::router::Body; + + #[tokio::test] + async fn test_enabled_router_service() { + async { + let plugin = FleetDetector::default(); + + // GIVEN a router service request + let mut mock_bad_request_service = MockRouterService::new(); + mock_bad_request_service + .expect_call() + .times(1) + .returning(|req: router::Request| { + router::Response::http_response_builder() + .context(req.context) + .response( + http::Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "application/json") + // making sure the request body is consumed + .body(req.router_request.into_body()) + .unwrap(), + ) + .build() + }); + let mut bad_request_router_service = + plugin.router_service(mock_bad_request_service.boxed()); + let router_req = router::Request::fake_builder() + .body(router::body::from_bytes("request")) + .build() + .unwrap(); + let _router_response = bad_request_router_service + .ready() + .await + .unwrap() + .call(router_req) + .await + .unwrap() + .next_response() + .await + .unwrap(); + + // THEN operation size metrics should exist + assert_counter!("apollo.router.operations.request_size", 7, &[]); + assert_counter!("apollo.router.operations.response_size", 7, &[]); + } + .with_metrics() + .await; + } + + #[tokio::test] + async fn test_enabled_http_client_service_full() { + async { + let plugin = FleetDetector::default(); + + // GIVEN an http client service request with a complete body + let mut mock_bad_request_service = MockHttpClientService::new(); + mock_bad_request_service + .expect_call() + .times(1) + .returning(|req| { + Box::pin(async { + Ok(http::Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "application/json") + // making sure the request body is consumed + .body(req.into_body()) + .unwrap()) + }) + }); + let mut bad_request_http_client_service = plugin.http_client_service( + "subgraph", + mock_bad_request_service + .map_request(|req: HttpRequest| req.http_request) + .map_response(|res| HttpResponse { + http_response: res, + context: Default::default(), + }) + .boxed(), + ); + let http_client_req = HttpRequest { + http_request: http::Request::builder() + .body(router::body::from_bytes("request")) + .unwrap(), + context: Default::default(), + }; + let http_client_response = bad_request_http_client_service + .ready() + .await + .unwrap() + .call(http_client_req) + .await + .unwrap(); + + // making sure the response body is consumed + let _data = http_client_response + .http_response + .into_body() + .collect() + .await + .unwrap(); + + // THEN fetch metrics should exist + assert_counter!( + "apollo.router.operations.fetch", + 1, + &[ + KeyValue::new("subgraph.name", "subgraph"), + KeyValue::new("http.response.status_code", 400), + KeyValue::new("client_error", false) + ] + ); + assert_counter!( + "apollo.router.operations.fetch.request_size", + 7, + &[KeyValue::new("subgraph.name", "subgraph"),] + ); + assert_counter!( + "apollo.router.operations.fetch.response_size", + 7, + &[KeyValue::new("subgraph.name", "subgraph"),] + ); + } + .with_metrics() + .await; + } + + #[tokio::test] + async fn test_enabled_http_client_service_stream() { + async { + let plugin = FleetDetector::default(); + + // GIVEN an http client service request with a streaming body + let mut mock_bad_request_service = MockHttpClientService::new(); + mock_bad_request_service.expect_call().times(1).returning( + |req: http::Request| { + Box::pin(async { + // making sure the request body is consumed + let data = router::body::into_bytes(req.into_body()).await?; + Ok(http::Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "application/json") + .body(router::body::from_bytes(data)) + .unwrap()) + }) + }, + ); + let mut bad_request_http_client_service = plugin.http_client_service( + "subgraph", + mock_bad_request_service + .map_request(|req: HttpRequest| req.http_request) + .map_response(|res: http::Response| HttpResponse { + http_response: res.map(Body::from), + context: Default::default(), + }) + .boxed(), + ); + let http_client_req = HttpRequest { + http_request: http::Request::builder() + .body(router::body::from_result_stream(futures::stream::once( + async { Ok::<_, Infallible>(bytes::Bytes::from("request")) }, + ))) + .unwrap(), + context: Default::default(), + }; + let http_client_response = bad_request_http_client_service + .ready() + .await + .unwrap() + .call(http_client_req) + .await + .unwrap(); + + // making sure the response body is consumed + let _data = router::body::into_bytes(http_client_response.http_response.into_body()) + .await + .unwrap(); + + // THEN fetch metrics should exist + assert_counter!( + "apollo.router.operations.fetch", + 1, + &[ + KeyValue::new("subgraph.name", "subgraph"), + KeyValue::new("http.response.status_code", 400), + KeyValue::new("client_error", false) + ] + ); + assert_counter!( + "apollo.router.operations.fetch.request_size", + 7, + &[KeyValue::new("subgraph.name", "subgraph"),] + ); + assert_counter!( + "apollo.router.operations.fetch.response_size", + 7, + &[KeyValue::new("subgraph.name", "subgraph"),] + ); + } + .with_metrics() + .await; + } + + #[test] + fn test_detect_cgroup_version_2() { + const PROC_FILESYSTEMS_CGROUP2: &str = "nodev proc +nodev cgroup +nodev cgroup2 + ext3 + ext2 + ext4"; + + let res = detect_cgroup_version(PROC_FILESYSTEMS_CGROUP2); + assert_eq!(res, CGroupVersion::CGroup2) + } + + #[test] + fn test_detect_cgroup_version_1() { + const PROC_FILESYSTEMS_CGROUP2: &str = "nodev proc +nodev cgroup + ext3 + ext2 + ext4"; + + let res = detect_cgroup_version(PROC_FILESYSTEMS_CGROUP2); + assert_eq!(res, CGroupVersion::CGroup) + } + + #[test] + fn test_detect_cgroup_version_none() { + const PROC_FILESYSTEMS_CGROUP2: &str = "nodev proc + ext3 + ext2 + ext4"; + + let res = detect_cgroup_version(PROC_FILESYSTEMS_CGROUP2); + assert_eq!(res, CGroupVersion::None) + } + + #[test] + fn test_get_deployment_type_official_helm_chart() { + assert_eq!( + get_deployment_type(Some("true"), None), + "official_helm_chart" + ); + } + + #[test] + fn test_get_deployment_type_custom() { + assert_eq!( + get_deployment_type(None, Some("custom_deployment")), + "custom_deployment" + ); + } + + #[test] + fn test_get_deployment_type_custom_empty() { + assert_eq!(get_deployment_type(None, Some("")), "unknown"); + } + + #[test] + fn test_get_deployment_type_default() { + assert_eq!(get_deployment_type(None, None), "unknown"); + } + + #[test] + fn test_get_deployment_type_priority() { + // Set both environment variables - official helm chart should take priority + assert_eq!( + get_deployment_type(Some("true"), Some("custom_deployment")), + "official_helm_chart" + ); + } +} diff --git a/apollo-router/src/plugins/forbid_mutations.rs b/apollo-router/src/plugins/forbid_mutations.rs index 136b8de3ba..730c87dcc6 100644 --- a/apollo-router/src/plugins/forbid_mutations.rs +++ b/apollo-router/src/plugins/forbid_mutations.rs @@ -13,9 +13,9 @@ use crate::layers::ServiceBuilderExt; use crate::plugin::Plugin; use crate::plugin::PluginInit; use crate::register_plugin; -use crate::services::execution; use crate::services::ExecutionRequest; use crate::services::ExecutionResponse; +use crate::services::execution; #[derive(Debug, Clone)] struct ForbidMutations { @@ -75,14 +75,14 @@ mod forbid_http_get_mutations_tests { use tower::ServiceExt; use super::*; + use crate::assert_error_eq_ignoring_id; use crate::graphql; - use crate::graphql::Response; use crate::http_ext::Request; - use crate::plugin::test::MockExecutionService; use crate::plugin::PluginInit; - use crate::query_planner::fetch::OperationKind; + use crate::plugin::test::MockExecutionService; use crate::query_planner::PlanNode; use crate::query_planner::QueryPlan; + use crate::query_planner::fetch::OperationKind; #[tokio::test] async fn it_lets_queries_pass_through() { @@ -129,10 +129,11 @@ mod forbid_http_get_mutations_tests { .execution_service(MockExecutionService::new().boxed()); let request = create_request(Method::GET, OperationKind::Mutation); - let mut actual_error = service_stack.oneshot(request).await.unwrap(); + let mut response = service_stack.oneshot(request).await.unwrap(); + let actual_error = &response.next_response().await.unwrap().errors[0]; - assert_eq!(expected_status, actual_error.response.status()); - assert_error_matches(&expected_error, actual_error.next_response().await.unwrap()); + assert_eq!(expected_status, response.response.status()); + assert_error_eq_ignoring_id!(actual_error, expected_error); } #[tokio::test] @@ -163,10 +164,6 @@ mod forbid_http_get_mutations_tests { .unwrap(); } - fn assert_error_matches(expected_error: &Error, response: Response) { - assert_eq!(&response.errors[0], expected_error); - } - fn create_request(method: Method, operation_kind: OperationKind) -> ExecutionRequest { let root: PlanNode = if operation_kind == OperationKind::Mutation { serde_json::from_value(json!({ diff --git a/apollo-router/src/plugins/headers.rs b/apollo-router/src/plugins/headers.rs deleted file mode 100644 index c6e0082cfd..0000000000 --- a/apollo-router/src/plugins/headers.rs +++ /dev/null @@ -1,1026 +0,0 @@ -use std::collections::HashMap; -use std::collections::HashSet; -use std::sync::Arc; -use std::task::Context; -use std::task::Poll; - -use access_json::JSONQuery; -use http::header::HeaderName; -use http::header::ACCEPT; -use http::header::ACCEPT_ENCODING; -use http::header::CONNECTION; -use http::header::CONTENT_ENCODING; -use http::header::CONTENT_LENGTH; -use http::header::CONTENT_TYPE; -use http::header::HOST; -use http::header::PROXY_AUTHENTICATE; -use http::header::PROXY_AUTHORIZATION; -use http::header::TE; -use http::header::TRAILER; -use http::header::TRANSFER_ENCODING; -use http::header::UPGRADE; -use http::HeaderValue; -use regex::Regex; -use schemars::JsonSchema; -use serde::Deserialize; -use serde_json::Value; -use tower::BoxError; -use tower::Layer; -use tower::ServiceBuilder; -use tower::ServiceExt; -use tower_service::Service; - -use crate::plugin::serde::deserialize_header_name; -use crate::plugin::serde::deserialize_header_value; -use crate::plugin::serde::deserialize_json_query; -use crate::plugin::serde::deserialize_option_header_name; -use crate::plugin::serde::deserialize_option_header_value; -use crate::plugin::serde::deserialize_regex; -use crate::plugin::Plugin; -use crate::plugin::PluginInit; -use crate::register_plugin; -use crate::services::subgraph; -use crate::services::SubgraphRequest; - -register_plugin!("apollo", "headers", Headers); - -#[derive(Clone, JsonSchema, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -struct HeadersLocation { - /// Propagate/Insert/Remove headers from request - request: Vec, - // Propagate/Insert/Remove headers from response - // response: Option -} - -#[derive(Clone, JsonSchema, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -enum Operation { - Insert(Insert), - Remove(Remove), - Propagate(Propagate), -} - -schemar_fn!(remove_named, String, "Remove a header given a header name"); -schemar_fn!( - remove_matching, - String, - "Remove a header given a regex matching against the header name" -); - -#[derive(Clone, JsonSchema, Deserialize)] -#[serde(rename_all = "snake_case")] -/// Remove header -enum Remove { - #[schemars(schema_with = "remove_named")] - #[serde(deserialize_with = "deserialize_header_name")] - /// Remove a header given a header name - Named(HeaderName), - - #[schemars(schema_with = "remove_matching")] - #[serde(deserialize_with = "deserialize_regex")] - /// Remove a header given a regex matching header name - Matching(Regex), -} - -#[derive(Clone, JsonSchema, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -#[serde(untagged)] -/// Insert header -enum Insert { - /// Insert static header - Static(InsertStatic), - /// Insert header with a value coming from context key (works only for a string in the context) - FromContext(InsertFromContext), - /// Insert header with a value coming from body - FromBody(InsertFromBody), -} - -#[derive(Clone, JsonSchema, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -/// Insert static header -struct InsertStatic { - /// The name of the header - #[schemars(with = "String")] - #[serde(deserialize_with = "deserialize_header_name")] - name: HeaderName, - - /// The value for the header - #[schemars(with = "String")] - #[serde(deserialize_with = "deserialize_header_value")] - value: HeaderValue, -} - -#[derive(Clone, JsonSchema, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -/// Insert header with a value coming from context key -struct InsertFromContext { - #[schemars(with = "String")] - #[serde(deserialize_with = "deserialize_header_name")] - /// Specify header name - name: HeaderName, - /// Specify context key to fetch value - from_context: String, -} - -#[derive(Clone, JsonSchema, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -/// Insert header with a value coming from body -struct InsertFromBody { - /// The target header name - #[schemars(with = "String")] - #[serde(deserialize_with = "deserialize_header_name")] - name: HeaderName, - - /// The path in the request body - #[schemars(with = "String")] - #[serde(deserialize_with = "deserialize_json_query")] - path: JSONQuery, - - /// The default if the path in the body did not resolve to an element - #[schemars(with = "Option", default)] - #[serde(deserialize_with = "deserialize_option_header_value")] - default: Option, -} - -schemar_fn!( - propagate_matching, - String, - "Remove a header given a regex matching header name" -); - -#[derive(Clone, JsonSchema, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -#[serde(untagged)] -/// Propagate header -enum Propagate { - /// Propagate header given a header name - Named { - /// The source header name - #[schemars(with = "String")] - #[serde(deserialize_with = "deserialize_header_name")] - named: HeaderName, - - /// An optional target header name - #[schemars(with = "Option", default)] - #[serde(deserialize_with = "deserialize_option_header_name", default)] - rename: Option, - - /// Default value for the header. - #[schemars(with = "Option", default)] - #[serde(deserialize_with = "deserialize_option_header_value", default)] - default: Option, - }, - /// Propagate header given a regex to match header name - Matching { - /// The regex on header name - #[schemars(schema_with = "propagate_matching")] - #[serde(deserialize_with = "deserialize_regex")] - matching: Regex, - }, -} - -/// Configuration for header propagation -#[derive(Clone, JsonSchema, Default, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields, default)] -struct Config { - /// Rules to apply to all subgraphs - all: Option, - /// Rules to specific subgraphs - subgraphs: HashMap, -} - -struct Headers { - all_operations: Arc>, - subgraph_operations: HashMap>>, - reserved_headers: Arc>, -} - -#[async_trait::async_trait] -impl Plugin for Headers { - type Config = Config; - - async fn new(init: PluginInit) -> Result { - let operations: Vec = init - .config - .all - .as_ref() - .map(|a| a.request.clone()) - .unwrap_or_default(); - let subgraph_operations = init - .config - .subgraphs - .iter() - .map(|(subgraph_name, op)| { - let mut operations = operations.clone(); - operations.append(&mut op.request.clone()); - (subgraph_name.clone(), Arc::new(operations)) - }) - .collect(); - - Ok(Headers { - all_operations: Arc::new(operations), - subgraph_operations, - reserved_headers: Arc::new(RESERVED_HEADERS.iter().collect()), - }) - } - - fn subgraph_service(&self, name: &str, service: subgraph::BoxService) -> subgraph::BoxService { - ServiceBuilder::new() - .layer(HeadersLayer::new( - self.subgraph_operations - .get(name) - .cloned() - .unwrap_or_else(|| self.all_operations.clone()), - self.reserved_headers.clone(), - )) - .service(service) - .boxed() - } -} - -struct HeadersLayer { - operations: Arc>, - reserved_headers: Arc>, -} - -impl HeadersLayer { - fn new( - operations: Arc>, - reserved_headers: Arc>, - ) -> Self { - Self { - operations, - reserved_headers, - } - } -} - -impl Layer for HeadersLayer { - type Service = HeadersService; - - fn layer(&self, inner: S) -> Self::Service { - HeadersService { - inner, - operations: self.operations.clone(), - reserved_headers: self.reserved_headers.clone(), - } - } -} -struct HeadersService { - inner: S, - operations: Arc>, - reserved_headers: Arc>, -} - -// Headers from https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1 -// These are not propagated by default using a regex match as they will not make sense for the -// second hop. -// In addition because our requests are not regular proxy requests content-type, content-length -// and host are also in the exclude list. -static RESERVED_HEADERS: [HeaderName; 14] = [ - CONNECTION, - PROXY_AUTHENTICATE, - PROXY_AUTHORIZATION, - TE, - TRAILER, - TRANSFER_ENCODING, - UPGRADE, - CONTENT_LENGTH, - CONTENT_TYPE, - CONTENT_ENCODING, - HOST, - ACCEPT, - ACCEPT_ENCODING, - HeaderName::from_static("keep-alive"), -]; - -impl Service for HeadersService -where - S: Service, -{ - type Response = S::Response; - type Error = S::Error; - type Future = S::Future; - - fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - self.inner.poll_ready(cx) - } - - fn call(&mut self, mut req: SubgraphRequest) -> Self::Future { - self.modify_request(&mut req); - self.inner.call(req) - } -} - -impl HeadersService { - fn modify_request(&self, req: &mut SubgraphRequest) { - let mut already_propagated: HashSet<&str> = HashSet::new(); - - for operation in &*self.operations { - match operation { - Operation::Insert(insert_config) => match insert_config { - Insert::Static(static_insert) => { - req.subgraph_request - .headers_mut() - .insert(&static_insert.name, static_insert.value.clone()); - } - Insert::FromContext(insert_from_context) => { - if let Some(val) = req - .context - .get::<_, String>(&insert_from_context.from_context) - .ok() - .flatten() - { - match HeaderValue::from_str(&val) { - Ok(header_value) => { - req.subgraph_request - .headers_mut() - .insert(&insert_from_context.name, header_value); - } - Err(err) => { - tracing::error!("cannot convert from the context into a header value for header name '{}': {:?}", insert_from_context.name, err); - } - } - } - } - Insert::FromBody(from_body) => { - let output = from_body - .path - .execute(req.supergraph_request.body()) - .ok() - .flatten(); - if let Some(val) = output { - let header_value = if let Value::String(val_str) = val { - val_str - } else { - val.to_string() - }; - match HeaderValue::from_str(&header_value) { - Ok(header_value) => { - req.subgraph_request - .headers_mut() - .insert(&from_body.name, header_value); - } - Err(err) => { - tracing::error!("cannot convert from the body into a header value for header name '{}': {:?}", from_body.name, err); - } - } - } else if let Some(default_val) = &from_body.default { - req.subgraph_request - .headers_mut() - .insert(&from_body.name, default_val.clone()); - } - } - }, - Operation::Remove(Remove::Named(name)) => { - req.subgraph_request.headers_mut().remove(name); - } - Operation::Remove(Remove::Matching(matching)) => { - let headers = req.subgraph_request.headers_mut(); - let new_headers = headers - .drain() - .filter_map(|(name, value)| { - name.and_then(|name| { - (self.reserved_headers.contains(&name) - || !matching.is_match(name.as_str())) - .then_some((name, value)) - }) - }) - .collect(); - - let _ = std::mem::replace(headers, new_headers); - } - Operation::Propagate(Propagate::Named { - named, - rename, - default, - }) => { - if !already_propagated.contains(named.as_str()) { - let headers = req.subgraph_request.headers_mut(); - let values = req.supergraph_request.headers().get_all(named); - if values.iter().count() == 0 { - if let Some(default) = default { - headers.append(rename.as_ref().unwrap_or(named), default.clone()); - } - } else { - for value in values { - headers.append(rename.as_ref().unwrap_or(named), value.clone()); - } - } - already_propagated.insert(named.as_str()); - } - } - Operation::Propagate(Propagate::Matching { matching }) => { - let mut previous_name = None; - let headers = req.subgraph_request.headers_mut(); - req.supergraph_request - .headers() - .iter() - .filter(|(name, _)| { - !self.reserved_headers.contains(*name) - && matching.is_match(name.as_str()) - }) - .for_each(|(name, value)| { - if !already_propagated.contains(name.as_str()) { - headers.append(name, value.clone()); - - // we have to this because don't want to propagate headers that are accounted for in the - // `already_propagated` set, but in the iteration here we might go through the same header - // multiple times - match previous_name { - None => previous_name = Some(name), - Some(previous) => { - if previous != name { - already_propagated.insert(previous.as_str()); - previous_name = Some(name); - } - } - } - } - }); - if let Some(name) = previous_name { - already_propagated.insert(name.as_str()); - } - } - } - } - } -} - -#[cfg(test)] -mod test { - use std::collections::HashSet; - use std::str::FromStr; - use std::sync::Arc; - - use subgraph::SubgraphRequestId; - use tower::BoxError; - - use super::*; - use crate::graphql::Request; - use crate::plugin::test::MockSubgraphService; - use crate::plugins::headers::Config; - use crate::plugins::headers::HeadersLayer; - use crate::query_planner::fetch::OperationKind; - use crate::services::SubgraphRequest; - use crate::services::SubgraphResponse; - use crate::Context; - - #[test] - fn test_subgraph_config() { - serde_yaml::from_str::( - r#" - subgraphs: - products: - request: - - insert: - name: "test" - value: "test" - "#, - ) - .unwrap(); - } - - #[test] - fn test_insert_config() { - serde_yaml::from_str::( - r#" - all: - request: - - insert: - name: "test" - value: "test" - "#, - ) - .unwrap(); - } - - #[test] - fn test_remove_config() { - serde_yaml::from_str::( - r#" - all: - request: - - remove: - named: "test" - "#, - ) - .unwrap(); - - serde_yaml::from_str::( - r#" - all: - request: - - remove: - matching: "d.*" - "#, - ) - .unwrap(); - - assert!(serde_yaml::from_str::( - r#" - all: - request: - - remove: - matching: "d.*[" - "#, - ) - .is_err()); - } - - #[test] - fn test_propagate_config() { - serde_yaml::from_str::( - r#" - all: - request: - - propagate: - named: "test" - "#, - ) - .unwrap(); - - serde_yaml::from_str::( - r#" - all: - request: - - propagate: - named: "test" - rename: "bif" - "#, - ) - .unwrap(); - - serde_yaml::from_str::( - r#" - all: - request: - - propagate: - named: "test" - rename: "bif" - default: "bof" - "#, - ) - .unwrap(); - - serde_yaml::from_str::( - r#" - all: - request: - - propagate: - matching: "d.*" - "#, - ) - .unwrap(); - } - - #[tokio::test] - async fn test_insert_static() -> Result<(), BoxError> { - let mut mock = MockSubgraphService::new(); - mock.expect_call() - .times(1) - .withf(|request| { - request.assert_headers(vec![ - ("aa", "vaa"), - ("ab", "vab"), - ("ac", "vac"), - ("c", "d"), - ]) - }) - .returning(example_response); - - let mut service = HeadersLayer::new( - Arc::new(vec![Operation::Insert(Insert::Static(InsertStatic { - name: "c".try_into()?, - value: "d".try_into()?, - }))]), - Arc::new(RESERVED_HEADERS.iter().collect()), - ) - .layer(mock); - - service.ready().await?.call(example_request()).await?; - Ok(()) - } - - #[tokio::test] - async fn test_insert_from_context() -> Result<(), BoxError> { - let mut mock = MockSubgraphService::new(); - mock.expect_call() - .times(1) - .withf(|request| { - request.assert_headers(vec![ - ("aa", "vaa"), - ("ab", "vab"), - ("ac", "vac"), - ("header_from_context", "my_value_from_context"), - ]) - }) - .returning(example_response); - - let mut service = HeadersLayer::new( - Arc::new(vec![Operation::Insert(Insert::FromContext( - InsertFromContext { - name: "header_from_context".try_into()?, - from_context: "my_key".to_string(), - }, - ))]), - Arc::new(RESERVED_HEADERS.iter().collect()), - ) - .layer(mock); - - service.ready().await?.call(example_request()).await?; - Ok(()) - } - - #[tokio::test] - async fn test_insert_from_request_body() -> Result<(), BoxError> { - let mut mock = MockSubgraphService::new(); - mock.expect_call() - .times(1) - .withf(|request| { - request.assert_headers(vec![ - ("aa", "vaa"), - ("ab", "vab"), - ("ac", "vac"), - ("header_from_request", "my_operation_name"), - ]) - }) - .returning(example_response); - - let mut service = HeadersLayer::new( - Arc::new(vec![Operation::Insert(Insert::FromBody(InsertFromBody { - name: "header_from_request".try_into()?, - path: JSONQuery::parse(".operationName")?, - default: None, - }))]), - Arc::new(RESERVED_HEADERS.iter().collect()), - ) - .layer(mock); - - service.ready().await?.call(example_request()).await?; - Ok(()) - } - - #[tokio::test] - async fn test_remove_exact() -> Result<(), BoxError> { - let mut mock = MockSubgraphService::new(); - mock.expect_call() - .times(1) - .withf(|request| request.assert_headers(vec![("ac", "vac"), ("ab", "vab")])) - .returning(example_response); - - let mut service = HeadersLayer::new( - Arc::new(vec![Operation::Remove(Remove::Named("aa".try_into()?))]), - Arc::new(RESERVED_HEADERS.iter().collect()), - ) - .layer(mock); - - service.ready().await?.call(example_request()).await?; - Ok(()) - } - - #[tokio::test] - async fn test_remove_matching() -> Result<(), BoxError> { - let mut mock = MockSubgraphService::new(); - mock.expect_call() - .times(1) - .withf(|request| request.assert_headers(vec![("ac", "vac")])) - .returning(example_response); - - let mut service = HeadersLayer::new( - Arc::new(vec![Operation::Remove(Remove::Matching(Regex::from_str( - "a[ab]", - )?))]), - Arc::new(RESERVED_HEADERS.iter().collect()), - ) - .layer(mock); - - service.ready().await?.call(example_request()).await?; - Ok(()) - } - - #[tokio::test] - async fn test_propagate_matching() -> Result<(), BoxError> { - let mut mock = MockSubgraphService::new(); - mock.expect_call() - .times(1) - .withf(|request| { - request.assert_headers(vec![ - ("aa", "vaa"), - ("ab", "vab"), - ("ac", "vac"), - ("da", "vda"), - ("db", "vdb"), - ("db", "vdb2"), - ]) - }) - .returning(example_response); - - let mut service = HeadersLayer::new( - Arc::new(vec![Operation::Propagate(Propagate::Matching { - matching: Regex::from_str("d[ab]")?, - })]), - Arc::new(RESERVED_HEADERS.iter().collect()), - ) - .layer(mock); - - service.ready().await?.call(example_request()).await?; - Ok(()) - } - - #[tokio::test] - async fn test_propagate_exact() -> Result<(), BoxError> { - let mut mock = MockSubgraphService::new(); - mock.expect_call() - .times(1) - .withf(|request| { - request.assert_headers(vec![ - ("aa", "vaa"), - ("ab", "vab"), - ("ac", "vac"), - ("da", "vda"), - ]) - }) - .returning(example_response); - - let mut service = HeadersLayer::new( - Arc::new(vec![Operation::Propagate(Propagate::Named { - named: "da".try_into()?, - rename: None, - default: None, - })]), - Arc::new(RESERVED_HEADERS.iter().collect()), - ) - .layer(mock); - - service.ready().await?.call(example_request()).await?; - Ok(()) - } - - #[tokio::test] - async fn test_propagate_exact_rename() -> Result<(), BoxError> { - let mut mock = MockSubgraphService::new(); - mock.expect_call() - .times(1) - .withf(|request| { - request.assert_headers(vec![ - ("aa", "vaa"), - ("ab", "vab"), - ("ac", "vac"), - ("ea", "vda"), - ]) - }) - .returning(example_response); - - let mut service = HeadersLayer::new( - Arc::new(vec![Operation::Propagate(Propagate::Named { - named: "da".try_into()?, - rename: Some("ea".try_into()?), - default: None, - })]), - Arc::new(RESERVED_HEADERS.iter().collect()), - ) - .layer(mock); - - service.ready().await?.call(example_request()).await?; - Ok(()) - } - - #[tokio::test] - async fn test_propagate_exact_default() -> Result<(), BoxError> { - let mut mock = MockSubgraphService::new(); - mock.expect_call() - .times(1) - .withf(|request| { - request.assert_headers(vec![ - ("aa", "vaa"), - ("ab", "vab"), - ("ac", "vac"), - ("ea", "defaulted"), - ]) - }) - .returning(example_response); - - let mut service = HeadersLayer::new( - Arc::new(vec![Operation::Propagate(Propagate::Named { - named: "ea".try_into()?, - rename: None, - default: Some("defaulted".try_into()?), - })]), - Arc::new(RESERVED_HEADERS.iter().collect()), - ) - .layer(mock); - - service.ready().await?.call(example_request()).await?; - Ok(()) - } - - #[tokio::test] - async fn test_propagate_reserved() -> Result<(), BoxError> { - let service = HeadersService { - inner: MockSubgraphService::new(), - operations: Arc::new(vec![Operation::Propagate(Propagate::Matching { - matching: Regex::from_str(".*")?, - })]), - reserved_headers: Arc::new(RESERVED_HEADERS.iter().collect()), - }; - - let mut request = SubgraphRequest { - supergraph_request: Arc::new( - http::Request::builder() - .header("da", "vda") - .header("db", "vdb") - .header("db", "vdb") - .header("db", "vdb2") - .header(HOST, "host") - .header(CONTENT_LENGTH, "2") - .header(CONTENT_TYPE, "graphql") - .header(CONTENT_ENCODING, "identity") - .header(ACCEPT, "application/json") - .header(ACCEPT_ENCODING, "gzip") - .body( - Request::builder() - .query("query") - .operation_name("my_operation_name") - .build(), - ) - .expect("expecting valid request"), - ), - subgraph_request: http::Request::builder() - .header("aa", "vaa") - .header("ab", "vab") - .header("ac", "vac") - .header(HOST, "rhost") - .header(CONTENT_LENGTH, "22") - .header(CONTENT_TYPE, "graphql") - .body(Request::builder().query("query").build()) - .expect("expecting valid request"), - operation_kind: OperationKind::Query, - context: Context::new(), - subgraph_name: String::from("test").into(), - subscription_stream: None, - connection_closed_signal: None, - query_hash: Default::default(), - authorization: Default::default(), - executable_document: None, - id: SubgraphRequestId(String::new()), - }; - service.modify_request(&mut request); - let headers = request - .subgraph_request - .headers() - .iter() - .map(|(name, value)| (name.as_str(), value.to_str().unwrap())) - .collect::>(); - assert_eq!( - headers, - vec![ - ("aa", "vaa"), - ("ab", "vab"), - ("ac", "vac"), - ("host", "rhost"), - ("content-length", "22"), - ("content-type", "graphql"), - ("da", "vda"), - ("db", "vdb"), - ("db", "vdb"), - ("db", "vdb2"), - ] - ); - - Ok(()) - } - - #[tokio::test] - async fn test_propagate_multiple_matching_rules() -> Result<(), BoxError> { - let service = HeadersService { - inner: MockSubgraphService::new(), - operations: Arc::new(vec![ - Operation::Propagate(Propagate::Named { - named: HeaderName::from_static("dc"), - rename: None, - default: None, - }), - Operation::Propagate(Propagate::Matching { - matching: Regex::from_str("dc")?, - }), - ]), - reserved_headers: Arc::new(RESERVED_HEADERS.iter().collect()), - }; - - let mut request = SubgraphRequest { - supergraph_request: Arc::new( - http::Request::builder() - .header("da", "vda") - .header("db", "vdb") - .header("dc", "vdb2") - .body( - Request::builder() - .query("query") - .operation_name("my_operation_name") - .build(), - ) - .expect("expecting valid request"), - ), - subgraph_request: http::Request::builder() - .header("aa", "vaa") - .header("ab", "vab") - .header("ac", "vac") - .body(Request::builder().query("query").build()) - .expect("expecting valid request"), - operation_kind: OperationKind::Query, - context: Context::new(), - subgraph_name: String::from("test").into(), - subscription_stream: None, - connection_closed_signal: None, - query_hash: Default::default(), - authorization: Default::default(), - executable_document: None, - id: SubgraphRequestId(String::new()), - }; - service.modify_request(&mut request); - let headers = request - .subgraph_request - .headers() - .iter() - .map(|(name, value)| (name.as_str(), value.to_str().unwrap())) - .collect::>(); - assert_eq!( - headers, - vec![("aa", "vaa"), ("ab", "vab"), ("ac", "vac"), ("dc", "vdb2"),] - ); - - Ok(()) - } - - fn example_response(req: SubgraphRequest) -> Result { - Ok(SubgraphResponse::new_from_response( - http::Response::default(), - Context::new(), - req.subgraph_name.unwrap_or_default(), - SubgraphRequestId(String::new()), - )) - } - - fn example_request() -> SubgraphRequest { - let ctx = Context::new(); - ctx.insert("my_key", "my_value_from_context".to_string()) - .unwrap(); - SubgraphRequest { - supergraph_request: Arc::new( - http::Request::builder() - .header("da", "vda") - .header("db", "vdb") - .header("db", "vdb") - .header("db", "vdb2") - .header(HOST, "host") - .header(CONTENT_LENGTH, "2") - .header(CONTENT_TYPE, "graphql") - .body( - Request::builder() - .query("query") - .operation_name("my_operation_name") - .build(), - ) - .expect("expecting valid request"), - ), - subgraph_request: http::Request::builder() - .header("aa", "vaa") - .header("ab", "vab") - .header("ac", "vac") - .header(HOST, "rhost") - .header(CONTENT_LENGTH, "22") - .header(CONTENT_TYPE, "graphql") - .body(Request::builder().query("query").build()) - .expect("expecting valid request"), - operation_kind: OperationKind::Query, - context: ctx, - subgraph_name: String::from("test").into(), - subscription_stream: None, - connection_closed_signal: None, - query_hash: Default::default(), - authorization: Default::default(), - executable_document: None, - id: SubgraphRequestId(String::new()), - } - } - - impl SubgraphRequest { - pub fn assert_headers(&self, headers: Vec<(&'static str, &'static str)>) -> bool { - let mut headers = headers.clone(); - headers.push((HOST.as_str(), "rhost")); - headers.push((CONTENT_LENGTH.as_str(), "22")); - headers.push((CONTENT_TYPE.as_str(), "graphql")); - let actual_headers = self - .subgraph_request - .headers() - .iter() - .map(|(name, value)| (name.as_str(), value.to_str().unwrap())) - .collect::>(); - assert_eq!(actual_headers, headers.into_iter().collect::>()); - - true - } - } -} diff --git a/apollo-router/src/plugins/headers/fixtures/propagate_passthrough.router.yaml b/apollo-router/src/plugins/headers/fixtures/propagate_passthrough.router.yaml new file mode 100644 index 0000000000..6bf7850d41 --- /dev/null +++ b/apollo-router/src/plugins/headers/fixtures/propagate_passthrough.router.yaml @@ -0,0 +1,10 @@ +headers: + all: + request: + - propagate: + matching: .* + - propagate: + named: a + rename: b + - propagate: + named: b diff --git a/apollo-router/src/plugins/headers/fixtures/propagate_passthrough_defaulted.router.yaml b/apollo-router/src/plugins/headers/fixtures/propagate_passthrough_defaulted.router.yaml new file mode 100644 index 0000000000..438626ae2a --- /dev/null +++ b/apollo-router/src/plugins/headers/fixtures/propagate_passthrough_defaulted.router.yaml @@ -0,0 +1,9 @@ +headers: + all: + request: + - propagate: + named: a + rename: b + - propagate: + named: b + default: defaulted diff --git a/apollo-router/src/plugins/headers/mod.rs b/apollo-router/src/plugins/headers/mod.rs new file mode 100644 index 0000000000..d75a841199 --- /dev/null +++ b/apollo-router/src/plugins/headers/mod.rs @@ -0,0 +1,1843 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; +use std::task::Context; +use std::task::Poll; + +use apollo_federation::connectors::runtime::http_json_transport::TransportRequest; +use http::HeaderMap; +use http::HeaderValue; +use http::header::ACCEPT; +use http::header::ACCEPT_ENCODING; +use http::header::CONNECTION; +use http::header::CONTENT_ENCODING; +use http::header::CONTENT_LENGTH; +use http::header::CONTENT_TYPE; +use http::header::HOST; +use http::header::HeaderName; +use http::header::PROXY_AUTHENTICATE; +use http::header::PROXY_AUTHORIZATION; +use http::header::TE; +use http::header::TRAILER; +use http::header::TRANSFER_ENCODING; +use http::header::UPGRADE; +use itertools::Itertools; +use regex::Regex; +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json_bytes::Value; +use serde_json_bytes::path::JsonPathInst; +use tower::BoxError; +use tower::Layer; +use tower::ServiceBuilder; +use tower::ServiceExt; +use tower_service::Service; + +use crate::plugin::PluginInit; +use crate::plugin::PluginPrivate; +use crate::plugin::serde::deserialize_header_name; +use crate::plugin::serde::deserialize_header_value; +use crate::plugin::serde::deserialize_jsonpath; +use crate::plugin::serde::deserialize_option_header_name; +use crate::plugin::serde::deserialize_option_header_value; +use crate::plugin::serde::deserialize_regex; +use crate::services::SubgraphRequest; +use crate::services::connector; +use crate::services::subgraph; + +register_private_plugin!("apollo", "headers", Headers); + +#[derive(Clone, JsonSchema, Deserialize, Default)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +struct HeadersLocation { + /// Propagate/Insert/Remove headers from request + request: Vec, + // Propagate/Insert/Remove headers from response + // response: Option +} + +#[derive(Clone, JsonSchema, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +enum Operation { + Insert(Insert), + Remove(Remove), + Propagate(Propagate), +} + +schemar_fn!(remove_named, String, "Remove a header given a header name"); +schemar_fn!( + remove_matching, + String, + "Remove a header given a regex matching against the header name" +); + +#[derive(Clone, JsonSchema, Deserialize)] +#[serde(rename_all = "snake_case")] +/// Remove header +enum Remove { + #[schemars(schema_with = "remove_named")] + #[serde(deserialize_with = "deserialize_header_name")] + /// Remove a header given a header name + Named(HeaderName), + + #[schemars(schema_with = "remove_matching")] + #[serde(deserialize_with = "deserialize_regex")] + /// Remove a header given a regex matching header name + Matching(Regex), +} + +#[derive(Clone, JsonSchema, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +#[serde(untagged)] +/// Insert header +enum Insert { + /// Insert static header + Static(InsertStatic), + /// Insert header with a value coming from context key (works only for a string in the context) + FromContext(InsertFromContext), + /// Insert header with a value coming from body + FromBody(InsertFromBody), +} + +#[derive(Clone, JsonSchema, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +/// Insert static header +struct InsertStatic { + /// The name of the header + #[schemars(with = "String")] + #[serde(deserialize_with = "deserialize_header_name")] + name: HeaderName, + + /// The value for the header + #[schemars(with = "String")] + #[serde(deserialize_with = "deserialize_header_value")] + value: HeaderValue, +} + +#[derive(Clone, JsonSchema, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +/// Insert header with a value coming from context key +struct InsertFromContext { + #[schemars(with = "String")] + #[serde(deserialize_with = "deserialize_header_name")] + /// Specify header name + name: HeaderName, + /// Specify context key to fetch value + from_context: String, +} + +#[derive(Clone, JsonSchema, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +/// Insert header with a value coming from body +struct InsertFromBody { + /// The target header name + #[schemars(with = "String")] + #[serde(deserialize_with = "deserialize_header_name")] + name: HeaderName, + + /// The path in the request body + #[schemars(with = "String")] + #[serde(deserialize_with = "deserialize_jsonpath")] + path: JsonPathInst, + + /// The default if the path in the body did not resolve to an element + #[schemars(with = "Option", default)] + #[serde(deserialize_with = "deserialize_option_header_value", default)] + default: Option, +} + +schemar_fn!( + propagate_matching, + String, + "Remove a header given a regex matching header name" +); + +#[derive(Clone, JsonSchema, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +#[serde(untagged)] +/// Propagate header +enum Propagate { + /// Propagate header given a header name + Named { + /// The source header name + #[schemars(with = "String")] + #[serde(deserialize_with = "deserialize_header_name")] + named: HeaderName, + + /// An optional target header name + #[schemars(with = "Option", default)] + #[serde(deserialize_with = "deserialize_option_header_name", default)] + rename: Option, + + /// Default value for the header. + #[schemars(with = "Option", default)] + #[serde(deserialize_with = "deserialize_option_header_value", default)] + default: Option, + }, + /// Propagate header given a regex to match header name + Matching { + /// The regex on header name + #[schemars(schema_with = "propagate_matching")] + #[serde(deserialize_with = "deserialize_regex")] + matching: Regex, + }, +} + +#[derive(Clone, JsonSchema, Default, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields, default)] +struct ConnectorHeadersConfiguration { + /// Map of subgraph_name.connector_source_name to configuration + #[serde(default)] + sources: HashMap, + + /// Options applying to all sources across all subgraphs + #[serde(default)] + all: Option, +} + +/// Configuration for header propagation +#[derive(Clone, JsonSchema, Default, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields, default)] +struct Config { + /// Rules to apply to all subgraphs + all: Option, + /// Rules to specific subgraphs + subgraphs: HashMap, + /// Rules for connectors + connector: ConnectorHeadersConfiguration, +} + +struct Headers { + all_operations: Arc>, + subgraph_operations: HashMap>>, + all_connector_operations: Arc>, + connector_source_operations: HashMap>>, +} + +#[async_trait::async_trait] +impl PluginPrivate for Headers { + type Config = Config; + + async fn new(init: PluginInit) -> Result { + let operations: Vec = init + .config + .all + .as_ref() + .map(|a| a.request.clone()) + .unwrap_or_default(); + let subgraph_operations = init + .config + .subgraphs + .iter() + .map(|(subgraph_name, op)| { + let mut operations = operations.clone(); + operations.append(&mut op.request.clone()); + (subgraph_name.clone(), Arc::new(operations)) + }) + .collect(); + let all_connector_operations: Vec = init + .config + .connector + .all + .as_ref() + .map(|a| a.request.clone()) + .unwrap_or_default(); + let connector_source_operations = init + .config + .connector + .sources + .iter() + .map(|(subgraph_name, op)| { + let mut operations = operations.clone(); + operations.append(&mut op.request.clone()); + (subgraph_name.clone(), Arc::new(operations)) + }) + .collect(); + + Ok(Headers { + all_operations: Arc::new(operations), + all_connector_operations: Arc::new(all_connector_operations), + subgraph_operations, + connector_source_operations, + }) + } + + fn subgraph_service(&self, name: &str, service: subgraph::BoxService) -> subgraph::BoxService { + ServiceBuilder::new() + .layer(HeadersLayer::new( + self.subgraph_operations + .get(name) + .cloned() + .unwrap_or_else(|| self.all_operations.clone()), + )) + .service(service) + .boxed() + } + + fn connector_request_service( + &self, + service: crate::services::connector::request_service::BoxService, + source_name: String, + ) -> crate::services::connector::request_service::BoxService { + ServiceBuilder::new() + .layer(HeadersLayer::new( + self.connector_source_operations + .get(&source_name) + .cloned() + .unwrap_or_else(|| self.all_connector_operations.clone()), + )) + .service(service) + .boxed() + } +} + +struct HeadersLayer { + operations: Arc>, +} + +impl HeadersLayer { + fn new(operations: Arc>) -> Self { + Self { operations } + } +} + +impl Layer for HeadersLayer { + type Service = HeadersService; + + fn layer(&self, inner: S) -> Self::Service { + HeadersService { + inner, + operations: self.operations.clone(), + } + } +} +struct HeadersService { + inner: S, + operations: Arc>, +} + +// Headers from https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1 +// These are not propagated by default using a regex match as they will not make sense for the +// second hop. +// In addition because our requests are not regular proxy requests content-type, content-length +// and host are also in the exclude list. +static RESERVED_HEADERS: [HeaderName; 14] = [ + CONNECTION, + PROXY_AUTHENTICATE, + PROXY_AUTHORIZATION, + TE, + TRAILER, + TRANSFER_ENCODING, + UPGRADE, + CONTENT_LENGTH, + CONTENT_TYPE, + CONTENT_ENCODING, + HOST, + ACCEPT, + ACCEPT_ENCODING, + HeaderName::from_static("keep-alive"), +]; + +impl Service for HeadersService +where + S: Service, +{ + type Response = S::Response; + type Error = S::Error; + type Future = S::Future; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: SubgraphRequest) -> Self::Future { + self.modify_subgraph_request(&mut req); + self.inner.call(req) + } +} + +impl Service for HeadersService +where + S: Service, +{ + type Response = S::Response; + type Error = S::Error; + type Future = S::Future; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: connector::request_service::Request) -> Self::Future { + self.modify_connector_request(&mut req); + self.inner.call(req) + } +} + +impl HeadersService { + fn modify_subgraph_request(&self, req: &mut SubgraphRequest) { + let mut already_propagated: HashSet = HashSet::new(); + + let body_to_value = serde_json_bytes::value::to_value(req.supergraph_request.body()).ok(); + let supergraph_headers = req.supergraph_request.headers(); + let context = &req.context; + let headers_mut = req.subgraph_request.headers_mut(); + + for operation in &*self.operations { + operation.process_header_rules( + &mut already_propagated, + supergraph_headers, + &body_to_value, + context, + headers_mut, + None, + ); + } + } + + fn modify_connector_request(&self, req: &mut connector::request_service::Request) { + let mut already_propagated: HashSet = HashSet::new(); + + let TransportRequest::Http(ref mut http_request) = req.transport_request; + let body_to_value = serde_json::from_str(http_request.inner.body()).ok(); + let supergraph_headers = req.supergraph_request.headers(); + let context = &req.context; + // We need to know what headers were added prior to this processing to that we can properly override as needed + let existing_headers = http_request.inner.headers().clone(); + let headers_mut = http_request.inner.headers_mut(); + + for operation in &*self.operations { + operation.process_header_rules( + &mut already_propagated, + supergraph_headers, + &body_to_value, + context, + headers_mut, + Some(&existing_headers), + ); + } + } +} + +impl Operation { + fn process_header_rules( + &self, + already_propagated: &mut HashSet, + supergraph_headers: &HeaderMap, + body_to_value: &Option, + context: &crate::Context, + headers_mut: &mut HeaderMap, + existing_headers: Option<&HeaderMap>, + ) { + match self { + Operation::Insert(insert) => { + insert.process_header_rules(body_to_value, context, headers_mut) + } + Operation::Remove(remove) => remove.process_header_rules(headers_mut), + Operation::Propagate(propagate) => propagate.process_header_rules( + already_propagated, + supergraph_headers, + headers_mut, + existing_headers, + ), + } + } +} + +impl Insert { + fn process_header_rules( + &self, + body_to_value: &Option, + context: &crate::Context, + headers_mut: &mut HeaderMap, + ) { + match self { + Insert::Static(insert_static) => { + headers_mut.insert(&insert_static.name, insert_static.value.clone()); + } + Insert::FromContext(insert_from_context) => { + if let Some(val) = context + .get::<_, String>(&insert_from_context.from_context) + .ok() + .flatten() + { + match HeaderValue::from_str(&val) { + Ok(header_value) => { + headers_mut.insert(&insert_from_context.name, header_value); + } + Err(err) => { + tracing::error!( + "cannot convert from the context into a header value for header name '{}': {:?}", + insert_from_context.name, + err + ); + } + } + } + } + Insert::FromBody(from_body) => { + if let Some(body_to_value) = &body_to_value { + let output = from_body.path.find(body_to_value); + if let serde_json_bytes::Value::Null = output { + if let Some(default_val) = &from_body.default { + headers_mut.insert(&from_body.name, default_val.clone()); + } + } else { + let header_value = if let serde_json_bytes::Value::String(val_str) = output + { + val_str.as_str().to_string() + } else { + output.to_string() + }; + match HeaderValue::from_str(&header_value) { + Ok(header_value) => { + headers_mut.insert(&from_body.name, header_value); + } + Err(err) => { + let header_name = &from_body.name; + tracing::error!(%header_name, ?err, "cannot convert from the body into a header value for header name"); + } + } + } + } else if let Some(default_val) = &from_body.default { + headers_mut.insert(&from_body.name, default_val.clone()); + } + } + } + } +} + +impl Remove { + fn process_header_rules(&self, headers_mut: &mut HeaderMap) { + match self { + Remove::Named(name) => { + headers_mut.remove(name); + } + Remove::Matching(matching) => { + let new_headers = headers_mut + .drain() + .filter_map(|(name, value)| { + name.and_then(|name| { + (RESERVED_HEADERS.contains(&name) || !matching.is_match(name.as_str())) + .then_some((name, value)) + }) + }) + .collect(); + + let _ = std::mem::replace(headers_mut, new_headers); + } + } + } +} + +impl Propagate { + fn process_header_rules( + &self, + already_propagated: &mut HashSet, + supergraph_headers: &HeaderMap, + headers_mut: &mut HeaderMap, + existing_headers: Option<&HeaderMap>, + ) { + let default_headers = Default::default(); + let existing_headers = existing_headers.unwrap_or(&default_headers); + match self { + Propagate::Named { + named, + rename, + default, + } => { + let target_header = rename.as_ref().unwrap_or(named); + if !already_propagated.contains(target_header.as_str()) { + // If the header was already added previously by some other + // method (e.g Connectors), remove it first before propagating + // the value from the client request. This allows us to use + // `.append` instead of `.insert` to handle multiple headers. + // + // Note: Rhai and Coprocessor plugins run after this plugin, + // so this will not remove headers added there. + if existing_headers.contains_key(target_header) { + headers_mut.remove(target_header); + } + + let values = supergraph_headers.get_all(named); + if values.iter().count() == 0 { + if let Some(default) = default { + headers_mut.append(target_header, default.clone()); + already_propagated.insert(target_header.to_string()); + } + } else { + for value in values { + headers_mut.append(target_header, value.clone()); + already_propagated.insert(target_header.to_string()); + } + } + } + } + Propagate::Matching { matching } => { + supergraph_headers + .iter() + .filter(|(name, _)| { + !RESERVED_HEADERS.contains(*name) && matching.is_match(name.as_str()) + }) + .chunk_by(|(name, ..)| name.to_owned()) + .into_iter() + .for_each(|(name, headers)| { + if !already_propagated.contains(name.as_str()) { + // If the header was already added previously by some other + // method (e.g Connectors), remove it first before propagating + // the value from the client request. This allows us to use + // `.append` instead of `.insert` to handle multiple headers. + // + // Note: Rhai and Coprocessor plugins run after this plugin, + // so this will not remove headers added there. + if existing_headers.contains_key(name) { + headers_mut.remove(name); + } + + headers.for_each(|(_, value)| { + headers_mut.append(name, value.clone()); + }); + already_propagated.insert(name.to_string()); + } + }); + } + } + } +} + +#[cfg(test)] +mod test { + use std::collections::HashSet; + use std::str::FromStr; + use std::sync::Arc; + + use apollo_compiler::name; + use apollo_federation::connectors::ConnectId; + use apollo_federation::connectors::ConnectSpec; + use apollo_federation::connectors::Connector; + use apollo_federation::connectors::HttpJsonTransport; + use apollo_federation::connectors::JSONSelection; + use apollo_federation::connectors::runtime::http_json_transport::HttpRequest; + use apollo_federation::connectors::runtime::key::ResponseKey; + use serde_json_bytes::json; + use subgraph::SubgraphRequestId; + use tower::BoxError; + + use super::*; + use crate::Context; + use crate::graphql; + use crate::graphql::Request; + use crate::plugin::test::MockConnectorService; + use crate::plugin::test::MockSubgraphService; + use crate::plugins::test::PluginTestHarness; + use crate::query_planner::fetch::OperationKind; + use crate::services::SubgraphRequest; + use crate::services::SubgraphResponse; + + #[test] + fn test_subgraph_config() { + serde_yaml::from_str::( + r#" + subgraphs: + products: + request: + - insert: + name: "test" + value: "test" + "#, + ) + .unwrap(); + } + + #[test] + fn test_insert_config() { + serde_yaml::from_str::( + r#" + all: + request: + - insert: + name: "test" + value: "test" + "#, + ) + .unwrap(); + } + + #[test] + fn test_remove_config() { + serde_yaml::from_str::( + r#" + all: + request: + - remove: + named: "test" + "#, + ) + .unwrap(); + + serde_yaml::from_str::( + r#" + all: + request: + - remove: + matching: "d.*" + "#, + ) + .unwrap(); + + assert!( + serde_yaml::from_str::( + r#" + all: + request: + - remove: + matching: "d.*[" + "#, + ) + .is_err() + ); + } + + #[test] + fn test_propagate_config() { + serde_yaml::from_str::( + r#" + all: + request: + - propagate: + named: "test" + "#, + ) + .unwrap(); + + serde_yaml::from_str::( + r#" + all: + request: + - propagate: + named: "test" + rename: "bif" + "#, + ) + .unwrap(); + + serde_yaml::from_str::( + r#" + all: + request: + - propagate: + named: "test" + rename: "bif" + default: "bof" + "#, + ) + .unwrap(); + + serde_yaml::from_str::( + r#" + all: + request: + - propagate: + matching: "d.*" + "#, + ) + .unwrap(); + } + + #[tokio::test] + async fn test_insert_static() -> Result<(), BoxError> { + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("c", "d"), + ]) + }) + .returning(example_response); + + let mut service = HeadersLayer::new(Arc::new(vec![Operation::Insert(Insert::Static( + InsertStatic { + name: "c".try_into()?, + value: "d".try_into()?, + }, + ))])) + .layer(mock); + + service.ready().await?.call(example_request()).await?; + Ok(()) + } + + #[tokio::test] + async fn test_connector_insert_static() -> Result<(), BoxError> { + let mut mock = MockConnectorService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("c", "d"), + ]) + }) + .returning(example_connector_response); + + let mut service = HeadersLayer::new(Arc::new(vec![Operation::Insert(Insert::Static( + InsertStatic { + name: "c".try_into()?, + value: "d".try_into()?, + }, + ))])) + .layer(mock); + + service + .ready() + .await? + .call(example_connector_request()) + .await?; + Ok(()) + } + + #[tokio::test] + async fn test_insert_from_context() -> Result<(), BoxError> { + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("header_from_context", "my_value_from_context"), + ]) + }) + .returning(example_response); + + let mut service = HeadersLayer::new(Arc::new(vec![Operation::Insert( + Insert::FromContext(InsertFromContext { + name: "header_from_context".try_into()?, + from_context: "my_key".to_string(), + }), + )])) + .layer(mock); + + service.ready().await?.call(example_request()).await?; + Ok(()) + } + + #[tokio::test] + async fn test_connector_insert_from_context() -> Result<(), BoxError> { + let mut mock = MockConnectorService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("header_from_context", "my_value_from_context"), + ]) + }) + .returning(example_connector_response); + + let mut service = HeadersLayer::new(Arc::new(vec![Operation::Insert( + Insert::FromContext(InsertFromContext { + name: "header_from_context".try_into()?, + from_context: "my_key".to_string(), + }), + )])) + .layer(mock); + + service + .ready() + .await? + .call(example_connector_request()) + .await?; + Ok(()) + } + + #[tokio::test] + async fn test_insert_from_request_body() -> Result<(), BoxError> { + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("header_from_request", "my_operation_name"), + ]) + }) + .returning(example_response); + + let mut service = HeadersLayer::new(Arc::new(vec![Operation::Insert(Insert::FromBody( + InsertFromBody { + name: "header_from_request".try_into()?, + path: JsonPathInst::from_str("$.operationName").unwrap(), + default: None, + }, + ))])) + .layer(mock); + + service.ready().await?.call(example_request()).await?; + Ok(()) + } + + #[tokio::test] + async fn test_connector_insert_from_request_body() -> Result<(), BoxError> { + let mut mock = MockConnectorService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("header_from_request", "myCoolValue"), + ]) + }) + .returning(example_connector_response); + + let mut service = HeadersLayer::new(Arc::new(vec![Operation::Insert(Insert::FromBody( + InsertFromBody { + name: "header_from_request".try_into()?, + path: JsonPathInst::from_str("$.myCoolField").unwrap(), + default: None, + }, + ))])) + .layer(mock); + + service + .ready() + .await? + .call(example_connector_request()) + .await?; + Ok(()) + } + + #[tokio::test] + async fn test_insert_from_request_body_with_old_access_json_notation() -> Result<(), BoxError> { + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("header_from_request", "my_operation_name"), + ]) + }) + .returning(example_response); + + let mut service = HeadersLayer::new(Arc::new(vec![Operation::Insert(Insert::FromBody( + InsertFromBody { + name: "header_from_request".try_into()?, + path: JsonPathInst::from_str(".operationName").unwrap(), + default: None, + }, + ))])) + .layer(mock); + + service.ready().await?.call(example_request()).await?; + Ok(()) + } + + #[tokio::test] + async fn test_connector_insert_from_request_body_with_old_access_json_notation() + -> Result<(), BoxError> { + let mut mock = MockConnectorService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("header_from_request", "myCoolValue"), + ]) + }) + .returning(example_connector_response); + + let mut service = HeadersLayer::new(Arc::new(vec![Operation::Insert(Insert::FromBody( + InsertFromBody { + name: "header_from_request".try_into()?, + path: JsonPathInst::from_str(".myCoolField").unwrap(), + default: None, + }, + ))])) + .layer(mock); + + service + .ready() + .await? + .call(example_connector_request()) + .await?; + Ok(()) + } + + #[tokio::test] + async fn test_remove_exact() -> Result<(), BoxError> { + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| request.assert_headers(vec![("ac", "vac"), ("ab", "vab")])) + .returning(example_response); + + let mut service = HeadersLayer::new(Arc::new(vec![Operation::Remove(Remove::Named( + "aa".try_into()?, + ))])) + .layer(mock); + + service.ready().await?.call(example_request()).await?; + Ok(()) + } + + #[tokio::test] + async fn test_remove_exact_multiple() -> Result<(), BoxError> { + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| request.assert_headers(vec![("ac", "vac"), ("ab", "vab")])) + .returning(example_response); + + let mut service = HeadersLayer::new(Arc::new(vec![Operation::Remove(Remove::Named( + "aa".try_into()?, + ))])) + .layer(mock); + + let ctx = Context::new(); + ctx.insert("my_key", "my_value_from_context".to_string()) + .unwrap(); + let req = SubgraphRequest { + supergraph_request: Arc::new( + http::Request::builder() + .header("da", "vda") + .header("db", "vdb") + .header("db", "vdb") + .header("db", "vdb2") + .header(HOST, "host") + .header(CONTENT_LENGTH, "2") + .header(CONTENT_TYPE, "graphql") + .body( + Request::builder() + .query("query") + .operation_name("my_operation_name") + .build(), + ) + .expect("expecting valid request"), + ), + subgraph_request: http::Request::builder() + .header("aa", "vaa") // will be removed + .header("aa", "vaa") // will be removed + .header("aa", "vaa2") // will be removed + .header("ab", "vab") + .header("ac", "vac") + .header(HOST, "rhost") + .header(CONTENT_LENGTH, "22") + .header(CONTENT_TYPE, "graphql") + .body(Request::builder().query("query").build()) + .expect("expecting valid request"), + operation_kind: OperationKind::Query, + context: ctx, + subgraph_name: String::from("test"), + subscription_stream: None, + connection_closed_signal: None, + query_hash: Default::default(), + authorization: Default::default(), + executable_document: None, + id: SubgraphRequestId(String::new()), + }; + + service.ready().await?.call(req).await?; + Ok(()) + } + + #[tokio::test] + async fn test_connector_remove_exact() -> Result<(), BoxError> { + let mut mock = MockConnectorService::new(); + mock.expect_call() + .times(1) + .withf(|request| request.assert_headers(vec![("ac", "vac"), ("ab", "vab")])) + .returning(example_connector_response); + + let mut service = HeadersLayer::new(Arc::new(vec![Operation::Remove(Remove::Named( + "aa".try_into()?, + ))])) + .layer(mock); + + service + .ready() + .await? + .call(example_connector_request()) + .await?; + Ok(()) + } + + #[tokio::test] + async fn test_remove_matching() -> Result<(), BoxError> { + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| request.assert_headers(vec![("ac", "vac")])) + .returning(example_response); + + let mut service = HeadersLayer::new(Arc::new(vec![Operation::Remove(Remove::Matching( + Regex::from_str("a[ab]")?, + ))])) + .layer(mock); + + service.ready().await?.call(example_request()).await?; + Ok(()) + } + + #[tokio::test] + async fn test_connector_remove_matching() -> Result<(), BoxError> { + let mut mock = MockConnectorService::new(); + mock.expect_call() + .times(1) + .withf(|request| request.assert_headers(vec![("ac", "vac")])) + .returning(example_connector_response); + + let mut service = HeadersLayer::new(Arc::new(vec![Operation::Remove(Remove::Matching( + Regex::from_str("a[ab]")?, + ))])) + .layer(mock); + + service + .ready() + .await? + .call(example_connector_request()) + .await?; + Ok(()) + } + + #[tokio::test] + async fn test_propagate_matching() -> Result<(), BoxError> { + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("da", "vda"), + ("db", "vdb"), + ("db", "vdb2"), + ]) + }) + .returning(example_response); + + let mut service = + HeadersLayer::new(Arc::new(vec![Operation::Propagate(Propagate::Matching { + matching: Regex::from_str("d[ab]")?, + })])) + .layer(mock); + + service.ready().await?.call(example_request()).await?; + Ok(()) + } + + #[tokio::test] + async fn test_connector_propagate_matching() -> Result<(), BoxError> { + let mut mock = MockConnectorService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("da", "vda"), + ("db", "vdb"), + ("db", "vdb2"), + ]) + }) + .returning(example_connector_response); + + let mut service = + HeadersLayer::new(Arc::new(vec![Operation::Propagate(Propagate::Matching { + matching: Regex::from_str("d[ab]")?, + })])) + .layer(mock); + + service + .ready() + .await? + .call(example_connector_request()) + .await?; + Ok(()) + } + + #[tokio::test] + async fn test_propagate_exact() -> Result<(), BoxError> { + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("da", "vda"), + ]) + }) + .returning(example_response); + + let mut service = + HeadersLayer::new(Arc::new(vec![Operation::Propagate(Propagate::Named { + named: "da".try_into()?, + rename: None, + default: None, + })])) + .layer(mock); + + service.ready().await?.call(example_request()).await?; + Ok(()) + } + + #[tokio::test] + async fn test_connector_propagate_exact() -> Result<(), BoxError> { + let mut mock = MockConnectorService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("da", "vda"), + ]) + }) + .returning(example_connector_response); + + let mut service = + HeadersLayer::new(Arc::new(vec![Operation::Propagate(Propagate::Named { + named: "da".try_into()?, + rename: None, + default: None, + })])) + .layer(mock); + + service + .ready() + .await? + .call(example_connector_request()) + .await?; + Ok(()) + } + + #[tokio::test] + async fn test_propagate_exact_rename() -> Result<(), BoxError> { + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("ea", "vda"), + ]) + }) + .returning(example_response); + + let mut service = + HeadersLayer::new(Arc::new(vec![Operation::Propagate(Propagate::Named { + named: "da".try_into()?, + rename: Some("ea".try_into()?), + default: None, + })])) + .layer(mock); + + service.ready().await?.call(example_request()).await?; + Ok(()) + } + + #[tokio::test] + async fn test_connect_propagate_exact_rename() -> Result<(), BoxError> { + let mut mock = MockConnectorService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("ea", "vda"), + ]) + }) + .returning(example_connector_response); + + let mut service = + HeadersLayer::new(Arc::new(vec![Operation::Propagate(Propagate::Named { + named: "da".try_into()?, + rename: Some("ea".try_into()?), + default: None, + })])) + .layer(mock); + + service + .ready() + .await? + .call(example_connector_request()) + .await?; + Ok(()) + } + + #[tokio::test] + async fn test_propagate_multiple() -> Result<(), BoxError> { + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("ra", "vda"), + ("rb", "vda"), + ]) + }) + .returning(example_response); + + let mut service = HeadersLayer::new(Arc::new(vec![ + Operation::Propagate(Propagate::Named { + named: "da".try_into()?, + rename: Some("ra".try_into()?), + default: None, + }), + Operation::Propagate(Propagate::Named { + named: "da".try_into()?, + rename: Some("rb".try_into()?), + default: None, + }), + // This should not take effect as the header is already propagated + Operation::Propagate(Propagate::Named { + named: "db".try_into()?, + rename: Some("ra".try_into()?), + default: None, + }), + ])) + .layer(mock); + + service.ready().await?.call(example_request()).await?; + Ok(()) + } + + #[tokio::test] + async fn test_connector_propagate_multiple() -> Result<(), BoxError> { + let mut mock = MockConnectorService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("ra", "vda"), + ("rb", "vda"), + ]) + }) + .returning(example_connector_response); + + let mut service = HeadersLayer::new(Arc::new(vec![ + Operation::Propagate(Propagate::Named { + named: "da".try_into()?, + rename: Some("ra".try_into()?), + default: None, + }), + Operation::Propagate(Propagate::Named { + named: "da".try_into()?, + rename: Some("rb".try_into()?), + default: None, + }), + // This should not take effect as the header is already propagated + Operation::Propagate(Propagate::Named { + named: "db".try_into()?, + rename: Some("ra".try_into()?), + default: None, + }), + ])) + .layer(mock); + + service + .ready() + .await? + .call(example_connector_request()) + .await?; + Ok(()) + } + + #[tokio::test] + async fn test_propagate_exact_default() -> Result<(), BoxError> { + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("ea", "defaulted"), + ]) + }) + .returning(example_response); + + let mut service = + HeadersLayer::new(Arc::new(vec![Operation::Propagate(Propagate::Named { + named: "ea".try_into()?, + rename: None, + default: Some("defaulted".try_into()?), + })])) + .layer(mock); + + service.ready().await?.call(example_request()).await?; + Ok(()) + } + + #[tokio::test] + async fn test_connector_propagate_exact_default() -> Result<(), BoxError> { + let mut mock = MockConnectorService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + request.assert_headers(vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("ea", "defaulted"), + ]) + }) + .returning(example_connector_response); + + let mut service = + HeadersLayer::new(Arc::new(vec![Operation::Propagate(Propagate::Named { + named: "ea".try_into()?, + rename: None, + default: Some("defaulted".try_into()?), + })])) + .layer(mock); + + service + .ready() + .await? + .call(example_connector_request()) + .await?; + Ok(()) + } + + #[tokio::test] + async fn test_propagate_reserved() -> Result<(), BoxError> { + let service = HeadersService { + inner: MockSubgraphService::new(), + operations: Arc::new(vec![Operation::Propagate(Propagate::Matching { + matching: Regex::from_str(".*")?, + })]), + }; + + let mut request = SubgraphRequest { + supergraph_request: Arc::new( + http::Request::builder() + .header("da", "vda") + .header("db", "vdb") + .header("db", "vdb") + .header("db", "vdb2") + .header(HOST, "host") + .header(CONTENT_LENGTH, "2") + .header(CONTENT_TYPE, "graphql") + .header(CONTENT_ENCODING, "identity") + .header(ACCEPT, "application/json") + .header(ACCEPT_ENCODING, "gzip") + .body( + Request::builder() + .query("query") + .operation_name("my_operation_name") + .build(), + ) + .expect("expecting valid request"), + ), + subgraph_request: http::Request::builder() + .header("aa", "vaa") + .header("ab", "vab") + .header("ac", "vac") + .header(HOST, "rhost") + .header(CONTENT_LENGTH, "22") + .header(CONTENT_TYPE, "graphql") + .body(Request::builder().query("query").build()) + .expect("expecting valid request"), + operation_kind: OperationKind::Query, + context: Context::new(), + subgraph_name: String::from("test"), + subscription_stream: None, + connection_closed_signal: None, + query_hash: Default::default(), + authorization: Default::default(), + executable_document: None, + id: SubgraphRequestId(String::new()), + }; + service.modify_subgraph_request(&mut request); + let headers = request + .subgraph_request + .headers() + .iter() + .map(|(name, value)| (name.as_str(), value.to_str().unwrap())) + .collect::>(); + assert_eq!( + headers, + vec![ + ("aa", "vaa"), + ("ab", "vab"), + ("ac", "vac"), + ("host", "rhost"), + ("content-length", "22"), + ("content-type", "graphql"), + ("da", "vda"), + ("db", "vdb"), + ("db", "vdb"), + ("db", "vdb2"), + ] + ); + + Ok(()) + } + + #[tokio::test] + async fn test_propagate_multiple_matching_rules() -> Result<(), BoxError> { + let service = HeadersService { + inner: MockSubgraphService::new(), + operations: Arc::new(vec![ + Operation::Propagate(Propagate::Named { + named: HeaderName::from_static("dc"), + rename: None, + default: None, + }), + Operation::Propagate(Propagate::Matching { + matching: Regex::from_str("dc")?, + }), + ]), + }; + + let mut request = SubgraphRequest { + supergraph_request: Arc::new( + http::Request::builder() + .header("da", "vda") + .header("db", "vdb") + .header("dc", "vdb2") + .body( + Request::builder() + .query("query") + .operation_name("my_operation_name") + .build(), + ) + .expect("expecting valid request"), + ), + subgraph_request: http::Request::builder() + .header("aa", "vaa") + .header("ab", "vab") + .header("ac", "vac") + .body(Request::builder().query("query").build()) + .expect("expecting valid request"), + operation_kind: OperationKind::Query, + context: Context::new(), + subgraph_name: String::from("test"), + subscription_stream: None, + connection_closed_signal: None, + query_hash: Default::default(), + authorization: Default::default(), + executable_document: None, + id: SubgraphRequestId(String::new()), + }; + service.modify_subgraph_request(&mut request); + let headers = request + .subgraph_request + .headers() + .iter() + .map(|(name, value)| (name.as_str(), value.to_str().unwrap())) + .collect::>(); + assert_eq!( + headers, + vec![("aa", "vaa"), ("ab", "vab"), ("ac", "vac"), ("dc", "vdb2"),] + ); + + Ok(()) + } + + fn example_response(req: SubgraphRequest) -> Result { + Ok(SubgraphResponse::new_from_response( + http::Response::default(), + Context::new(), + req.subgraph_name, + SubgraphRequestId(String::new()), + )) + } + + fn example_connector_response( + _req: connector::request_service::Request, + ) -> Result { + let key = ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + Ok(connector::request_service::Response::test_new( + key, + Vec::new(), + json!(""), + None, + )) + } + + fn example_request() -> SubgraphRequest { + let ctx = Context::new(); + ctx.insert("my_key", "my_value_from_context".to_string()) + .unwrap(); + SubgraphRequest { + supergraph_request: Arc::new( + http::Request::builder() + .header("da", "vda") + .header("db", "vdb") + .header("db", "vdb") + .header("db", "vdb2") + .header(HOST, "host") + .header(CONTENT_LENGTH, "2") + .header(CONTENT_TYPE, "graphql") + .body( + Request::builder() + .query("query") + .operation_name("my_operation_name") + .build(), + ) + .expect("expecting valid request"), + ), + subgraph_request: http::Request::builder() + .header("aa", "vaa") + .header("ab", "vab") + .header("ac", "vac") + .header(HOST, "rhost") + .header(CONTENT_LENGTH, "22") + .header(CONTENT_TYPE, "graphql") + .body(Request::builder().query("query").build()) + .expect("expecting valid request"), + operation_kind: OperationKind::Query, + context: ctx, + subgraph_name: String::from("test"), + subscription_stream: None, + connection_closed_signal: None, + query_hash: Default::default(), + authorization: Default::default(), + executable_document: None, + id: SubgraphRequestId(String::new()), + } + } + + fn example_connector_request() -> connector::request_service::Request { + let ctx = Context::new(); + ctx.insert("my_key", "my_value_from_context".to_string()) + .unwrap(); + let connector = Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "subgraph_name".into(), + None, + name!(Query), + name!(a), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("f").unwrap(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }; + let key = ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + + let request = http::Request::builder() + .header("aa", "vaa") + .header("ab", "vab") + .header("ac", "vac") + .header(HOST, "rhost") + .header(CONTENT_LENGTH, "22") + .header(CONTENT_TYPE, "graphql") + .body( + json!({ + "myCoolField": "myCoolValue" + }) + .to_string(), + ) + .unwrap(); + + let http_request = HttpRequest { + inner: request, + debug: Default::default(), + }; + + connector::request_service::Request { + context: ctx, + connector: Arc::new(connector), + transport_request: http_request.into(), + key, + mapping_problems: Default::default(), + supergraph_request: Arc::new( + http::Request::builder() + .header("da", "vda") + .header("db", "vdb") + .header("db", "vdb") + .header("db", "vdb2") + .header(HOST, "host") + .header(CONTENT_LENGTH, "2") + .header(CONTENT_TYPE, "graphql") + .body( + Request::builder() + .query("query") + .operation_name("my_operation_name") + .build(), + ) + .expect("expecting valid request"), + ), + } + } + + impl SubgraphRequest { + fn assert_headers(&self, headers: Vec<(&'static str, &'static str)>) -> bool { + let mut headers = headers.clone(); + headers.push((HOST.as_str(), "rhost")); + headers.push((CONTENT_LENGTH.as_str(), "22")); + headers.push((CONTENT_TYPE.as_str(), "graphql")); + let actual_headers = self + .subgraph_request + .headers() + .iter() + .map(|(name, value)| (name.as_str(), value.to_str().unwrap())) + .collect::>(); + assert_eq!(actual_headers, headers.into_iter().collect::>()); + + true + } + } + + impl connector::request_service::Request { + fn assert_headers(&self, headers: Vec<(&'static str, &'static str)>) -> bool { + let mut headers = headers.clone(); + headers.push((HOST.as_str(), "rhost")); + headers.push((CONTENT_LENGTH.as_str(), "22")); + headers.push((CONTENT_TYPE.as_str(), "graphql")); + let TransportRequest::Http(ref http_request) = self.transport_request; + let actual_headers = http_request + .inner + .headers() + .iter() + .map(|(name, value)| (name.as_str(), value.to_str().unwrap())) + .collect::>(); + assert_eq!(actual_headers, headers.into_iter().collect::>()); + + true + } + } + + async fn assert_headers( + config: &'static str, + input: Vec<(&'static str, &'static str)>, + output: Vec<(&'static str, &'static str)>, + ) { + let test_harness = PluginTestHarness::::builder() + .config(config) + .build() + .await + .expect("test harness"); + let service = test_harness.subgraph_service("test", move |r| { + let output = output.clone(); + async move { + // Assert the headers here + let headers = r.subgraph_request.headers(); + for (name, value) in output.iter() { + if let Some(header) = headers.get(*name) { + assert_eq!(header.to_str().unwrap(), *value); + } else { + panic!("missing header {name}"); + } + } + Ok(subgraph::Response::fake_builder().build()) + } + }); + + let mut req = http::Request::builder(); + for (name, value) in input.iter() { + req = req.header(*name, *value); + } + + service + .call( + subgraph::Request::fake_builder() + .supergraph_request(Arc::new( + req.body(graphql::Request::default()) + .expect("valid request"), + )) + .build(), + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_propagate_passthrough() { + assert_headers( + include_str!("fixtures/propagate_passthrough.router.yaml"), + vec![("a", "av"), ("c", "cv")], + vec![("a", "av"), ("b", "av"), ("c", "cv")], + ) + .await; + + assert_headers( + include_str!("fixtures/propagate_passthrough.router.yaml"), + vec![("b", "bv"), ("c", "cv")], + vec![("b", "bv"), ("c", "cv")], + ) + .await; + } + + #[tokio::test] + async fn test_propagate_passthrough_defaulted() { + assert_headers( + include_str!("fixtures/propagate_passthrough_defaulted.router.yaml"), + vec![("a", "av")], + vec![("b", "av")], + ) + .await; + + assert_headers( + include_str!("fixtures/propagate_passthrough_defaulted.router.yaml"), + vec![("b", "bv")], + vec![("b", "bv")], + ) + .await; + assert_headers( + include_str!("fixtures/propagate_passthrough_defaulted.router.yaml"), + vec![("c", "cv")], + vec![("b", "defaulted")], + ) + .await; + } +} diff --git a/apollo-router/src/plugins/healthcheck/mod.rs b/apollo-router/src/plugins/healthcheck/mod.rs new file mode 100644 index 0000000000..33a9323bc7 --- /dev/null +++ b/apollo-router/src/plugins/healthcheck/mod.rs @@ -0,0 +1,542 @@ +//! Health Check plugin +//! +//! Provides liveness and readiness checks for the router. +//! +//! This module needs to be executed prior to traffic shaping so that it can capture the responses +//! of requests which have been load shed. +//! + +use std::net::SocketAddr; +use std::str::FromStr; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use http::StatusCode; +use multimap::MultiMap; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use tokio::time::Instant; +use tower::BoxError; +use tower::ServiceBuilder; +use tower::ServiceExt; +use tower::service_fn; + +use crate::Endpoint; +use crate::configuration::ListenAddr; +use crate::plugin::PluginInit; +use crate::plugin::PluginPrivate; +use crate::register_private_plugin; +use crate::services::router; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "UPPERCASE")] +#[allow(dead_code)] +enum HealthStatus { + Up, + Down, +} + +#[derive(Debug, Serialize)] +struct Health { + status: HealthStatus, +} + +/// Configuration options pertaining to the readiness health interval sub-component. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[serde(default)] +pub(crate) struct ReadinessIntervalConfig { + #[serde(deserialize_with = "humantime_serde::deserialize", default)] + #[serde(serialize_with = "humantime_serde::serialize")] + #[schemars(with = "Option", default)] + /// The sampling interval (default: 5s) + pub(crate) sampling: Duration, + + #[serde(deserialize_with = "humantime_serde::deserialize")] + #[serde(serialize_with = "humantime_serde::serialize")] + #[schemars(with = "Option")] + /// The unready interval (default: 2 * sampling interval) + pub(crate) unready: Option, +} + +/// Configuration options pertaining to the readiness health sub-component. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[serde(default)] +pub(crate) struct ReadinessConfig { + /// The readiness interval configuration + pub(crate) interval: ReadinessIntervalConfig, + + /// How many rejections are allowed in an interval (default: 100) + /// If this number is exceeded, the router will start to report unready. + pub(crate) allowed: usize, +} + +impl Default for ReadinessIntervalConfig { + fn default() -> Self { + Self { + sampling: Duration::from_secs(5), + unready: None, + } + } +} + +impl Default for ReadinessConfig { + fn default() -> Self { + Self { + interval: Default::default(), + allowed: 100, + } + } +} + +/// Configuration options pertaining to the health component. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[serde(default)] +pub(crate) struct Config { + /// The socket address and port to listen on + /// Defaults to 127.0.0.1:8088 + pub(crate) listen: ListenAddr, + + /// Set to false to disable the health check + pub(crate) enabled: bool, + + /// Optionally set a custom healthcheck path + /// Defaults to /health + pub(crate) path: String, + + /// Optionally specify readiness configuration + pub(crate) readiness: ReadinessConfig, +} + +#[cfg(test)] +pub(crate) fn test_listen() -> ListenAddr { + SocketAddr::from_str("127.0.0.1:0").unwrap().into() +} + +fn default_health_check_listen() -> ListenAddr { + SocketAddr::from_str("127.0.0.1:8088").unwrap().into() +} + +fn default_health_check_enabled() -> bool { + true +} + +fn default_health_check_path() -> String { + "/health".to_string() +} + +#[buildstructor::buildstructor] +impl Config { + #[builder] + pub(crate) fn new( + listen: Option, + enabled: Option, + path: Option, + readiness: Option, + ) -> Self { + let mut path = path.unwrap_or_else(default_health_check_path); + if !path.starts_with('/') { + path = format!("/{path}"); + } + + Self { + listen: listen.unwrap_or_else(default_health_check_listen), + enabled: enabled.unwrap_or_else(default_health_check_enabled), + path, + readiness: readiness.unwrap_or_default(), + } + } +} + +impl Default for Config { + fn default() -> Self { + Self::builder().build() + } +} + +struct HealthCheck { + config: Config, + live: Arc, + ready: Arc, + rejected: Arc, + ticker: tokio::task::JoinHandle<()>, +} + +#[async_trait::async_trait] +impl PluginPrivate for HealthCheck { + type Config = Config; + + async fn new(init: PluginInit) -> Result { + // We always do the work to track readiness and liveness because we + // need that data to implement our `router_service`. We only log out + // our health tracing message if our health check is enabled. + if init.config.enabled { + tracing::info!( + "Health check exposed at {}{}", + init.config.listen, + init.config.path + ); + } + let live = Arc::new(AtomicBool::new(false)); + let ready = Arc::new(AtomicBool::new(false)); + let rejected = Arc::new(AtomicUsize::new(0)); + + let allowed = init.config.readiness.allowed; + let my_sampling_interval = init.config.readiness.interval.sampling; + let my_recovery_interval = init + .config + .readiness + .interval + .unready + .unwrap_or(2 * my_sampling_interval); + let my_rejected = rejected.clone(); + let my_ready = ready.clone(); + + let ticker = tokio::spawn(async move { + loop { + let start = Instant::now() + my_sampling_interval; + let mut interval = tokio::time::interval_at(start, my_sampling_interval); + loop { + interval.tick().await; + if my_rejected.load(Ordering::Relaxed) > allowed { + my_ready.store(false, Ordering::SeqCst); + tokio::time::sleep(my_recovery_interval).await; + my_rejected.store(0, Ordering::Relaxed); + my_ready.store(true, Ordering::SeqCst); + break; + } + } + } + }); + Ok(Self { + config: init.config, + live, + ready, + rejected, + ticker, + }) + } + + // Track rejected requests due to traffic shaping. + // We always do this; even if the health check is disabled. + fn router_service(&self, service: router::BoxService) -> router::BoxService { + let my_rejected = self.rejected.clone(); + + ServiceBuilder::new() + .map_response(move |res: router::Response| { + if res.response.status() == StatusCode::SERVICE_UNAVAILABLE + || res.response.status() == StatusCode::GATEWAY_TIMEOUT + { + my_rejected.fetch_add(1, Ordering::Relaxed); + } + res + }) + .service(service) + .boxed() + } + + // Support the health-check endpoint for the router, incorporating both live and ready. + fn web_endpoints(&self) -> MultiMap { + let mut map = MultiMap::new(); + + if self.config.enabled { + let my_ready = self.ready.clone(); + let my_live = self.live.clone(); + + let endpoint = Endpoint::from_router_service( + self.config.path.clone(), + service_fn(move |req: router::Request| { + let mut status_code = StatusCode::OK; + let health = if let Some(query) = req.router_request.uri().query() { + let query_upper = query.to_ascii_uppercase(); + // Could be more precise, but sloppy match is fine for this use case + if query_upper.starts_with("READY") { + let status = if my_ready.load(Ordering::SeqCst) { + HealthStatus::Up + } else { + // It's hard to get k8s to parse payloads. Especially since we + // can't install curl or jq into our docker images because of CVEs. + // So, compromise, k8s will interpret this as probe fail. + status_code = StatusCode::SERVICE_UNAVAILABLE; + HealthStatus::Down + }; + Health { status } + } else if query_upper.starts_with("LIVE") { + let status = if my_live.load(Ordering::SeqCst) { + HealthStatus::Up + } else { + // It's hard to get k8s to parse payloads. Especially since we + // can't install curl or jq into our docker images because of CVEs. + // So, compromise, k8s will interpret this as probe fail. + status_code = StatusCode::SERVICE_UNAVAILABLE; + HealthStatus::Down + }; + Health { status } + } else { + Health { + status: HealthStatus::Up, + } + } + } else { + Health { + status: HealthStatus::Up, + } + }; + tracing::trace!(?health, request = ?req.router_request, "health check"); + async move { + router::Response::http_response_builder() + .response(http::Response::builder().status(status_code).body( + router::body::from_bytes( + serde_json::to_vec(&health).map_err(BoxError::from)?, + ), + )?) + .context(req.context) + .build() + } + }) + .boxed(), + ); + + map.insert(self.config.listen.clone(), endpoint); + } + + map + } + + /// The point of no return this plugin is about to go live + fn activate(&self) { + self.live.store(true, Ordering::SeqCst); + self.ready.store(true, Ordering::SeqCst); + } +} + +// When a new configuration is made available we need to drop our old ticker. +impl Drop for HealthCheck { + fn drop(&mut self) { + self.ticker.abort(); + } +} + +register_private_plugin!("apollo", "health_check", HealthCheck); + +#[cfg(test)] +mod test { + use serde_json::json; + use tower::Service; + use tower::ServiceExt; + + use super::*; + use crate::plugins::test::PluginTestHarness; + use crate::plugins::test::ServiceHandle; + + // Create a base for testing. Even though we don't use the test_harness once this function + // completes, we return it because we need to keep it alive to prevent the ticker from being + // dropped. + async fn get_axum_router( + listen_addr: ListenAddr, + config: &'static str, + response_status_code: StatusCode, + ) -> ( + Option, + Option>, + PluginTestHarness, + ) { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(config) + .build() + .await + .expect("test harness"); + + test_harness.activate(); + + // Limitations in the plugin test harness (requires an Fn function) + // mean we need to create our responses here... + let svc = match response_status_code { + StatusCode::OK => test_harness.router_service(|_req| async { + router::Response::fake_builder() + .data(serde_json::json!({"data": {"field": "value"}})) + .header("x-custom-header", "test-value") + .build() + }), + StatusCode::GATEWAY_TIMEOUT => test_harness.router_service(|_req| async { + router::Response::fake_builder() + .data(serde_json::json!({"data": {"field": "value"}})) + .header("x-custom-header", "test-value") + .status_code(StatusCode::GATEWAY_TIMEOUT) + .build() + }), + StatusCode::SERVICE_UNAVAILABLE => test_harness.router_service(|_req| async { + router::Response::fake_builder() + .data(serde_json::json!({"data": {"field": "value"}})) + .header("x-custom-header", "test-value") + .status_code(StatusCode::SERVICE_UNAVAILABLE) + .build() + }), + _ => panic!("unsupported status code"), + }; + + let endpoints = test_harness.web_endpoints(); + + let endpoint = endpoints.get(&listen_addr); + + (endpoint.cloned(), Some(svc), test_harness) + } + + // This could be improved. It makes assumptions about the content of config files regarding how + // many fails are allowed and unready durations. A better test would either parse the config to + // extract those values or (not as good) take extra parameters specifying them. + async fn base_test_health_check( + router_addr: &str, + config: &'static str, + status_string: &str, + response_status_code: StatusCode, + expect_endpoint: bool, + ) { + let listen_addr: ListenAddr = SocketAddr::from_str(router_addr).unwrap().into(); + + let (axum_router_opt, pipeline_svc_opt, _test_harness) = + get_axum_router(listen_addr, config, response_status_code).await; + + let request = http::Request::builder() + .uri(format!("http://{router_addr}/health?ready=")) + .body(http_body_util::Empty::new()) + .expect("valid request"); + + // Make more than 10 requests to trigger our condition + if let Some(pipeline_svc) = pipeline_svc_opt { + for _ in 0..20 { + let _response = pipeline_svc.call_default().await.unwrap(); + } + // Wait for 3 second so that our condition is recognised + tokio::time::sleep(Duration::from_secs(3)).await; + } + + if expect_endpoint { + let mut axum_router = axum_router_opt.expect("it better be there").into_router(); + // This creates our web_endpoint (in this case the health check) so that we can call it + let mut svc = axum_router.as_service(); + let response = svc + .ready() + .await + .expect("readied") + .call(request) + .await + .expect("called it"); + + let expected_code = if status_string == "DOWN" { + StatusCode::SERVICE_UNAVAILABLE + } else { + StatusCode::OK + }; + + assert_eq!(expected_code, response.status()); + + let j: serde_json::Value = serde_json::from_slice( + &crate::services::router::body::into_bytes(response) + .await + .expect("we have a body"), + ) + .expect("some json"); + assert_eq!(json!({"status": status_string }), j) + } else { + assert!(axum_router_opt.is_none()) + } + } + + #[tokio::test] + async fn test_health_check() { + let router_addr = "127.0.0.1:8088"; + base_test_health_check( + router_addr, + include_str!("testdata/default_listener.router.yaml"), + "UP", + StatusCode::OK, + true, + ) + .await; + } + + #[tokio::test] + async fn test_health_check_custom_listener() { + let router_addr = "127.0.0.1:4012"; + base_test_health_check( + router_addr, + include_str!("testdata/custom_listener.router.yaml"), + "UP", + StatusCode::OK, + true, + ) + .await; + } + + #[tokio::test] + async fn test_health_check_timeout_unready() { + let router_addr = "127.0.0.1:8088"; + base_test_health_check( + router_addr, + include_str!("testdata/allowed_ten_per_second.router.yaml"), + "DOWN", + StatusCode::GATEWAY_TIMEOUT, + true, + ) + .await; + } + + #[tokio::test] + async fn test_health_check_unavailable_unready() { + let router_addr = "127.0.0.1:8088"; + base_test_health_check( + router_addr, + include_str!("testdata/allowed_ten_per_second.router.yaml"), + "DOWN", + StatusCode::SERVICE_UNAVAILABLE, + true, + ) + .await; + } + + #[tokio::test] + async fn test_health_check_timeout_ready() { + let router_addr = "127.0.0.1:8088"; + base_test_health_check( + router_addr, + include_str!("testdata/allowed_fifty_per_second.router.yaml"), + "UP", + StatusCode::GATEWAY_TIMEOUT, + true, + ) + .await; + } + + #[tokio::test] + async fn test_health_check_unavailable_ready() { + let router_addr = "127.0.0.1:8088"; + base_test_health_check( + router_addr, + include_str!("testdata/allowed_fifty_per_second.router.yaml"), + "UP", + StatusCode::SERVICE_UNAVAILABLE, + true, + ) + .await; + } + + #[tokio::test] + async fn test_health_check_disabled() { + let router_addr = "127.0.0.1:8088"; + base_test_health_check( + router_addr, + include_str!("testdata/disabled_listener.router.yaml"), + "UP", + StatusCode::SERVICE_UNAVAILABLE, + false, + ) + .await; + } +} diff --git a/apollo-router/src/plugins/healthcheck/testdata/allowed_fifty_per_second.router.yaml b/apollo-router/src/plugins/healthcheck/testdata/allowed_fifty_per_second.router.yaml new file mode 100644 index 0000000000..10f70d59ed --- /dev/null +++ b/apollo-router/src/plugins/healthcheck/testdata/allowed_fifty_per_second.router.yaml @@ -0,0 +1,7 @@ +health_check: + listen: 127.0.0.1:8088 + readiness: + allowed: 50 + interval: + sampling: 1s + unready: 10s diff --git a/apollo-router/src/plugins/healthcheck/testdata/allowed_ten_per_second.router.yaml b/apollo-router/src/plugins/healthcheck/testdata/allowed_ten_per_second.router.yaml new file mode 100644 index 0000000000..8ac50e9716 --- /dev/null +++ b/apollo-router/src/plugins/healthcheck/testdata/allowed_ten_per_second.router.yaml @@ -0,0 +1,7 @@ +health_check: + listen: 127.0.0.1:8088 + readiness: + allowed: 10 + interval: + sampling: 1s + unready: 10s diff --git a/apollo-router/src/plugins/healthcheck/testdata/custom_listener.router.yaml b/apollo-router/src/plugins/healthcheck/testdata/custom_listener.router.yaml new file mode 100644 index 0000000000..0b8f20e9ae --- /dev/null +++ b/apollo-router/src/plugins/healthcheck/testdata/custom_listener.router.yaml @@ -0,0 +1,2 @@ +health_check: + listen: 127.0.0.1:4012 diff --git a/apollo-router/src/plugins/healthcheck/testdata/default_listener.router.yaml b/apollo-router/src/plugins/healthcheck/testdata/default_listener.router.yaml new file mode 100644 index 0000000000..04847bf310 --- /dev/null +++ b/apollo-router/src/plugins/healthcheck/testdata/default_listener.router.yaml @@ -0,0 +1,2 @@ +health_check: + listen: 127.0.0.1:8088 diff --git a/apollo-router/src/plugins/healthcheck/testdata/disabled_listener.router.yaml b/apollo-router/src/plugins/healthcheck/testdata/disabled_listener.router.yaml new file mode 100644 index 0000000000..83228f149e --- /dev/null +++ b/apollo-router/src/plugins/healthcheck/testdata/disabled_listener.router.yaml @@ -0,0 +1,3 @@ +health_check: + listen: 127.0.0.1:8088 + enabled: false diff --git a/apollo-router/src/plugins/include_subgraph_errors.rs b/apollo-router/src/plugins/include_subgraph_errors.rs deleted file mode 100644 index 3b399e0151..0000000000 --- a/apollo-router/src/plugins/include_subgraph_errors.rs +++ /dev/null @@ -1,345 +0,0 @@ -use std::collections::HashMap; - -use schemars::JsonSchema; -use serde::Deserialize; -use tower::BoxError; -use tower::ServiceExt; - -use crate::json_ext::Object; -use crate::plugin::Plugin; -use crate::plugin::PluginInit; -use crate::register_plugin; -use crate::services::subgraph; -use crate::services::SubgraphResponse; - -static REDACTED_ERROR_MESSAGE: &str = "Subgraph errors redacted"; - -register_plugin!("apollo", "include_subgraph_errors", IncludeSubgraphErrors); - -/// Configuration for exposing errors that originate from subgraphs -#[derive(Clone, Debug, JsonSchema, Default, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields, default)] -struct Config { - /// Include errors from all subgraphs - all: bool, - - /// Include errors from specific subgraphs - subgraphs: HashMap, -} - -struct IncludeSubgraphErrors { - config: Config, -} - -#[async_trait::async_trait] -impl Plugin for IncludeSubgraphErrors { - type Config = Config; - - async fn new(init: PluginInit) -> Result { - Ok(IncludeSubgraphErrors { - config: init.config, - }) - } - - fn subgraph_service(&self, name: &str, service: subgraph::BoxService) -> subgraph::BoxService { - // Search for subgraph in our configured subgraph map. - // If we can't find it, use the "all" value - if !*self.config.subgraphs.get(name).unwrap_or(&self.config.all) { - let sub_name_response = name.to_string(); - let sub_name_error = name.to_string(); - return service - .map_response(move |mut response: SubgraphResponse| { - if !response.response.body().errors.is_empty() { - tracing::info!("redacted subgraph({sub_name_response}) errors"); - for error in response.response.body_mut().errors.iter_mut() { - error.message = REDACTED_ERROR_MESSAGE.to_string(); - error.extensions = Object::default(); - } - } - response - }) - // _error to stop clippy complaining about unused assignments... - .map_err(move |mut _error: BoxError| { - // Create a redacted error to replace whatever error we have - tracing::info!("redacted subgraph({sub_name_error}) error"); - _error = Box::new(crate::error::FetchError::SubrequestHttpError { - status_code: None, - service: "redacted".to_string(), - reason: "redacted".to_string(), - }); - _error - }) - .boxed(); - } - service - } -} - -#[cfg(test)] -mod test { - use std::num::NonZeroUsize; - use std::sync::Arc; - - use bytes::Bytes; - use once_cell::sync::Lazy; - use serde_json::Value as jValue; - use serde_json_bytes::ByteString; - use serde_json_bytes::Value; - use tower::Service; - - use super::*; - use crate::json_ext::Object; - use crate::plugin::test::MockSubgraph; - use crate::plugin::DynPlugin; - use crate::query_planner::BridgeQueryPlannerPool; - use crate::router_factory::create_plugins; - use crate::services::layers::persisted_queries::PersistedQueryLayer; - use crate::services::layers::query_analysis::QueryAnalysisLayer; - use crate::services::router; - use crate::services::router::service::RouterCreator; - use crate::services::HasSchema; - use crate::services::PluggableSupergraphServiceBuilder; - use crate::services::SupergraphRequest; - use crate::spec::Schema; - use crate::Configuration; - - static UNREDACTED_PRODUCT_RESPONSE: Lazy = Lazy::new(|| { - Bytes::from_static(r#"{"data":{"topProducts":null},"errors":[{"message":"couldn't find mock for query {\"query\":\"query($first: Int) { topProducts(first: $first) { __typename upc } }\",\"variables\":{\"first\":2}}","path":[],"extensions":{"test":"value","code":"FETCH_ERROR"}}]}"#.as_bytes()) - }); - - static REDACTED_PRODUCT_RESPONSE: Lazy = Lazy::new(|| { - Bytes::from_static( - r#"{"data":{"topProducts":null},"errors":[{"message":"Subgraph errors redacted","path":[]}]}"# - .as_bytes(), - ) - }); - - static REDACTED_ACCOUNT_RESPONSE: Lazy = Lazy::new(|| { - Bytes::from_static( - r#"{"data":null,"errors":[{"message":"Subgraph errors redacted","path":[]}]}"# - .as_bytes(), - ) - }); - - static EXPECTED_RESPONSE: Lazy = Lazy::new(|| { - Bytes::from_static(r#"{"data":{"topProducts":[{"upc":"1","name":"Table","reviews":[{"id":"1","product":{"name":"Table"},"author":{"id":"1","name":"Ada Lovelace"}},{"id":"4","product":{"name":"Table"},"author":{"id":"2","name":"Alan Turing"}}]},{"upc":"2","name":"Couch","reviews":[{"id":"2","product":{"name":"Couch"},"author":{"id":"1","name":"Ada Lovelace"}}]}]}}"#.as_bytes()) - }); - - static VALID_QUERY: &str = r#"query TopProducts($first: Int) { topProducts(first: $first) { upc name reviews { id product { name } author { id name } } } }"#; - - static ERROR_PRODUCT_QUERY: &str = r#"query ErrorTopProducts($first: Int) { topProducts(first: $first) { upc reviews { id product { name } author { id name } } } }"#; - - static ERROR_ACCOUNT_QUERY: &str = r#"query Query { me { name }}"#; - - async fn execute_router_test( - query: &str, - body: &Bytes, - mut router_service: router::BoxService, - ) { - let request = SupergraphRequest::fake_builder() - .query(query.to_string()) - .variable("first", 2usize) - .build() - .expect("expecting valid request") - .try_into() - .unwrap(); - - let response = router_service - .ready() - .await - .unwrap() - .call(request) - .await - .unwrap() - .next_response() - .await - .unwrap() - .unwrap(); - assert_eq!(*body, response); - } - - async fn build_mock_router(plugin: Box) -> router::BoxService { - let mut extensions = Object::new(); - extensions.insert("test", Value::String(ByteString::from("value"))); - - let account_mocks = vec![ - ( - r#"{"query":"query TopProducts__accounts__3($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}","operationName":"TopProducts__accounts__3","variables":{"representations":[{"__typename":"User","id":"1"},{"__typename":"User","id":"2"}]}}"#, - r#"{"data":{"_entities":[{"name":"Ada Lovelace"},{"name":"Alan Turing"}]}}"# - ) - ].into_iter().map(|(query, response)| (serde_json::from_str(query).unwrap(), serde_json::from_str(response).unwrap())).collect(); - let account_service = MockSubgraph::new(account_mocks); - - let review_mocks = vec![ - ( - r#"{"query":"query TopProducts__reviews__1($representations:[_Any!]!){_entities(representations:$representations){...on Product{reviews{id product{__typename upc}author{__typename id}}}}}","operationName":"TopProducts__reviews__1","variables":{"representations":[{"__typename":"Product","upc":"1"},{"__typename":"Product","upc":"2"}]}}"#, - r#"{"data":{"_entities":[{"reviews":[{"id":"1","product":{"__typename":"Product","upc":"1"},"author":{"__typename":"User","id":"1"}},{"id":"4","product":{"__typename":"Product","upc":"1"},"author":{"__typename":"User","id":"2"}}]},{"reviews":[{"id":"2","product":{"__typename":"Product","upc":"2"},"author":{"__typename":"User","id":"1"}}]}]}}"# - ) - ].into_iter().map(|(query, response)| (serde_json::from_str(query).unwrap(), serde_json::from_str(response).unwrap())).collect(); - let review_service = MockSubgraph::new(review_mocks); - - let product_mocks = vec![ - ( - r#"{"query":"query TopProducts__products__0($first:Int){topProducts(first:$first){__typename upc name}}","operationName":"TopProducts__products__0","variables":{"first":2}}"#, - r#"{"data":{"topProducts":[{"__typename":"Product","upc":"1","name":"Table"},{"__typename":"Product","upc":"2","name":"Couch"}]}}"# - ), - ( - r#"{"query":"query TopProducts__products__2($representations:[_Any!]!){_entities(representations:$representations){...on Product{name}}}","operationName":"TopProducts__products__2","variables":{"representations":[{"__typename":"Product","upc":"1"},{"__typename":"Product","upc":"2"}]}}"#, - r#"{"data":{"_entities":[{"name":"Table"},{"name":"Couch"}]}}"# - ) - ].into_iter().map(|(query, response)| (serde_json::from_str(query).unwrap(), serde_json::from_str(response).unwrap())).collect(); - - let product_service = MockSubgraph::new(product_mocks).with_extensions(extensions); - - let mut configuration = Configuration::default(); - // TODO(@goto-bus-stop): need to update the mocks and remove this, #6013 - configuration.supergraph.generate_query_fragments = false; - let configuration = Arc::new(configuration); - - let schema = - include_str!("../../../apollo-router-benchmarks/benches/fixtures/supergraph.graphql"); - let schema = Schema::parse(schema, &configuration).unwrap(); - - let planner = BridgeQueryPlannerPool::new( - Vec::new(), - schema.into(), - Arc::clone(&configuration), - NonZeroUsize::new(1).unwrap(), - ) - .await - .unwrap(); - let schema = planner.schema(); - let subgraph_schemas = planner.subgraph_schemas(); - - let builder = PluggableSupergraphServiceBuilder::new(planner); - - let mut plugins = create_plugins(&configuration, &schema, subgraph_schemas, None, None) - .await - .unwrap(); - - plugins.insert("apollo.include_subgraph_errors".to_string(), plugin); - - let builder = builder - .with_plugins(Arc::new(plugins)) - .with_subgraph_service("accounts", account_service.clone()) - .with_subgraph_service("reviews", review_service.clone()) - .with_subgraph_service("products", product_service.clone()); - - let supergraph_creator = builder.build().await.expect("should build"); - - RouterCreator::new( - QueryAnalysisLayer::new(supergraph_creator.schema(), Arc::clone(&configuration)).await, - Arc::new(PersistedQueryLayer::new(&configuration).await.unwrap()), - Arc::new(supergraph_creator), - configuration, - ) - .await - .unwrap() - .make() - .boxed() - } - - async fn get_redacting_plugin(config: &jValue) -> Box { - // Build a redacting plugin - crate::plugin::plugins() - .find(|factory| factory.name == "apollo.include_subgraph_errors") - .expect("Plugin not found") - .create_instance_without_schema(config) - .await - .expect("Plugin not created") - } - - #[tokio::test] - async fn it_returns_valid_response() { - // Build a redacting plugin - let plugin = get_redacting_plugin(&serde_json::json!({ "all": false })).await; - let router = build_mock_router(plugin).await; - execute_router_test(VALID_QUERY, &EXPECTED_RESPONSE, router).await; - } - - #[tokio::test] - async fn it_redacts_all_subgraphs_explicit_redact() { - // Build a redacting plugin - let plugin = get_redacting_plugin(&serde_json::json!({ "all": false })).await; - let router = build_mock_router(plugin).await; - execute_router_test(ERROR_PRODUCT_QUERY, &REDACTED_PRODUCT_RESPONSE, router).await; - } - - #[tokio::test] - async fn it_redacts_all_subgraphs_implicit_redact() { - // Build a redacting plugin - let plugin = get_redacting_plugin(&serde_json::json!({})).await; - let router = build_mock_router(plugin).await; - execute_router_test(ERROR_PRODUCT_QUERY, &REDACTED_PRODUCT_RESPONSE, router).await; - } - - #[tokio::test] - async fn it_does_not_redact_all_subgraphs_explicit_allow() { - // Build a redacting plugin - let plugin = get_redacting_plugin(&serde_json::json!({ "all": true })).await; - let router = build_mock_router(plugin).await; - execute_router_test(ERROR_PRODUCT_QUERY, &UNREDACTED_PRODUCT_RESPONSE, router).await; - } - - #[tokio::test] - async fn it_does_not_redact_all_implicit_redact_product_explict_allow_for_product_query() { - // Build a redacting plugin - let plugin = - get_redacting_plugin(&serde_json::json!({ "subgraphs": {"products": true }})).await; - let router = build_mock_router(plugin).await; - execute_router_test(ERROR_PRODUCT_QUERY, &UNREDACTED_PRODUCT_RESPONSE, router).await; - } - - #[tokio::test] - async fn it_does_redact_all_implicit_redact_product_explict_allow_for_review_query() { - // Build a redacting plugin - let plugin = - get_redacting_plugin(&serde_json::json!({ "subgraphs": {"reviews": true }})).await; - let router = build_mock_router(plugin).await; - execute_router_test(ERROR_PRODUCT_QUERY, &REDACTED_PRODUCT_RESPONSE, router).await; - } - - #[tokio::test] - async fn it_does_not_redact_all_explicit_allow_review_explict_redact_for_product_query() { - // Build a redacting plugin - let plugin = get_redacting_plugin( - &serde_json::json!({ "all": true, "subgraphs": {"reviews": false }}), - ) - .await; - let router = build_mock_router(plugin).await; - execute_router_test(ERROR_PRODUCT_QUERY, &UNREDACTED_PRODUCT_RESPONSE, router).await; - } - - #[tokio::test] - async fn it_does_redact_all_explicit_allow_product_explict_redact_for_product_query() { - // Build a redacting plugin - let plugin = get_redacting_plugin( - &serde_json::json!({ "all": true, "subgraphs": {"products": false }}), - ) - .await; - let router = build_mock_router(plugin).await; - execute_router_test(ERROR_PRODUCT_QUERY, &REDACTED_PRODUCT_RESPONSE, router).await; - } - - #[tokio::test] - async fn it_does_not_redact_all_explicit_allow_account_explict_redact_for_product_query() { - // Build a redacting plugin - let plugin = get_redacting_plugin( - &serde_json::json!({ "all": true, "subgraphs": {"accounts": false }}), - ) - .await; - let router = build_mock_router(plugin).await; - execute_router_test(ERROR_PRODUCT_QUERY, &UNREDACTED_PRODUCT_RESPONSE, router).await; - } - - #[tokio::test] - async fn it_does_redact_all_explicit_allow_account_explict_redact_for_account_query() { - // Build a redacting plugin - let plugin = get_redacting_plugin( - &serde_json::json!({ "all": true, "subgraphs": {"accounts": false }}), - ) - .await; - let router = build_mock_router(plugin).await; - execute_router_test(ERROR_ACCOUNT_QUERY, &REDACTED_ACCOUNT_RESPONSE, router).await; - } -} diff --git a/apollo-router/src/plugins/include_subgraph_errors/config.rs b/apollo-router/src/plugins/include_subgraph_errors/config.rs new file mode 100644 index 0000000000..fa326201e9 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/config.rs @@ -0,0 +1,227 @@ +use std::collections::HashMap; +use std::fmt; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde::de::Deserializer; +use serde::de::MapAccess; +use serde::de::Visitor; +use serde::de::{self}; + +/// Configuration for exposing errors that originate from subgraphs +#[derive(Clone, Debug, JsonSchema, Default, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields, default)] +pub(crate) struct Config { + /// Global configuration for error redaction. Applies to all subgraphs. + pub(crate) all: ErrorMode, + + /// Overrides global configuration on a per-subgraph basis + pub(crate) subgraphs: HashMap, +} + +#[derive(Clone, Debug, JsonSchema, Serialize)] +#[serde(untagged)] +pub(crate) enum ErrorMode { + /// When `true`, Propagate the original error as is. Otherwise, redact it. + Included(bool), + /// Allow specific extension keys with required redact_message + Allow { + /// Allow specific extension keys + allow_extensions_keys: Vec, + /// redact error messages for all subgraphs + redact_message: bool, + }, + /// Deny specific extension keys with required redact_message + Deny { + /// Deny specific extension keys + deny_extensions_keys: Vec, + /// redact error messages for all subgraphs + redact_message: bool, + }, +} + +impl Default for ErrorMode { + fn default() -> Self { + ErrorMode::Included(false) + } +} + +impl<'de> Deserialize<'de> for ErrorMode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct ErrorModeVisitor; + + impl<'de> Visitor<'de> for ErrorModeVisitor { + type Value = ErrorMode; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter + .write_str("boolean or object with allow_extensions_keys/deny_extensions_keys") + } + + fn visit_bool(self, value: bool) -> Result + where + E: de::Error, + { + Ok(ErrorMode::Included(value)) + } + + fn visit_map(self, map: M) -> Result + where + M: MapAccess<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Helper { + allow_extensions_keys: Option>, + deny_extensions_keys: Option>, + redact_message: bool, + } + + let helper = Helper::deserialize(de::value::MapAccessDeserializer::new(map))?; + + match (helper.allow_extensions_keys, helper.deny_extensions_keys) { + (Some(_), Some(_)) => Err(de::Error::custom( + "Global config cannot have both allow_extensions_keys and deny_extensions_keys", + )), + (Some(allow), None) => Ok(ErrorMode::Allow { + allow_extensions_keys: allow, + redact_message: helper.redact_message, + }), + (None, Some(deny)) => Ok(ErrorMode::Deny { + deny_extensions_keys: deny, + redact_message: helper.redact_message, + }), + // If neither allow nor deny is present, but redact_message is, + // treat it as Included(true) with the specified redaction. + // However, the current logic implies Included(true) means no redaction. + // Let's stick to the original logic: if neither list is present, it's Included(true). + // The `redact_message` field is only relevant for Allow/Deny variants here. + // If the user provides *only* `redact_message: bool`, it might be confusing. + // The original code defaults to Included(true) if neither key is present. + (None, None) => Ok(ErrorMode::Included(true)), + } + } + } + + deserializer.deserialize_any(ErrorModeVisitor) + } +} + +#[derive(Clone, Debug, JsonSchema, Serialize)] +#[serde(untagged)] +pub(crate) enum SubgraphConfig { + /// Enable or disable error redaction for a subgraph + Included(bool), + /// Allow specific error extension keys for a subgraph + Allow { + /// Allow specific extension keys for a subgraph. Will extending global allow list or override a global deny list + allow_extensions_keys: Vec, + /// Redact error messages for a subgraph + #[serde(skip_serializing_if = "Option::is_none")] + redact_message: Option, + /// Exclude specific extension keys from global allow/deny list + #[serde(default)] + exclude_global_keys: Vec, + }, + /// Deny specific error extension keys for a subgraph + Deny { + /// Allow specific extension keys for a subgraph. Will extending global deny list or override a global allow list + deny_extensions_keys: Vec, + /// Redact error messages for a subgraph + #[serde(skip_serializing_if = "Option::is_none")] + redact_message: Option, + /// Exclude specific extension keys from global allow/deny list + #[serde(default)] + exclude_global_keys: Vec, + }, + /// Override global configuration, but don't allow or deny any new keys explicitly + CommonOnly { + /// Redact error messages for a subgraph + #[serde(skip_serializing_if = "Option::is_none")] + redact_message: Option, + /// Exclude specific extension keys from global allow/deny list + #[serde(default)] + exclude_global_keys: Vec, + }, +} + +// Custom deserializer to handle both boolean and object types +impl<'de> Deserialize<'de> for SubgraphConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct SubgraphConfigVisitor; + + impl<'de> Visitor<'de> for SubgraphConfigVisitor { + type Value = SubgraphConfig; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str( + "boolean or object with optional allow_extensions_keys or deny_extensions_keys", + ) + } + + fn visit_bool(self, value: bool) -> Result + where + E: de::Error, + { + Ok(SubgraphConfig::Included(value)) + } + + fn visit_map(self, map: M) -> Result + where + M: MapAccess<'de>, + { + // Intermediate struct to capture all possible fields + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct FullConfig { + allow_extensions_keys: Option>, + deny_extensions_keys: Option>, + redact_message: Option, + #[serde(default)] + exclude_global_keys: Vec, + } + + let config: FullConfig = + Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))?; + + match (config.allow_extensions_keys, config.deny_extensions_keys) { + (Some(_), Some(_)) => Err(de::Error::custom( + "A subgraph config cannot have both allow_extensions_keys and deny_extensions_keys", + )), + (Some(allow), None) => Ok(SubgraphConfig::Allow { + allow_extensions_keys: allow, + redact_message: config.redact_message, + exclude_global_keys: config.exclude_global_keys, + }), + (None, Some(deny)) => Ok(SubgraphConfig::Deny { + deny_extensions_keys: deny, + redact_message: config.redact_message, + exclude_global_keys: config.exclude_global_keys, + }), + (None, None) => { + // If neither allow nor deny keys are present, it's CommonOnly + Ok(SubgraphConfig::CommonOnly { + redact_message: config.redact_message, + exclude_global_keys: config.exclude_global_keys, + }) + } + } + } + } + + deserializer.deserialize_any(SubgraphConfigVisitor) + } +} + +impl Default for SubgraphConfig { + fn default() -> Self { + SubgraphConfig::Included(false) + } +} diff --git a/apollo-router/src/plugins/include_subgraph_errors/effective_config.rs b/apollo-router/src/plugins/include_subgraph_errors/effective_config.rs new file mode 100644 index 0000000000..2f388e121c --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/effective_config.rs @@ -0,0 +1,191 @@ +use std::collections::HashMap; + +use itertools::Itertools; + +use super::config::Config; +use super::config::ErrorMode; +use super::config::SubgraphConfig; +use crate::error::ConfigurationError; + +#[derive(Debug, Default, Clone)] +pub(crate) struct EffectiveConfig { + /// Default effective configuration applied if a subgraph isn't specifically listed. + pub(crate) default: SubgraphEffectiveConfig, + /// Per-subgraph effective configurations. + pub(crate) subgraphs: HashMap, +} + +/// Generates the effective configuration by merging global and per-subgraph settings. +impl TryFrom for EffectiveConfig { + type Error = ConfigurationError; + + fn try_from(config: Config) -> Result { + let mut effective_config = EffectiveConfig { + default: Self::default_config(&config), + ..Default::default() + }; + + // Determine global defaults + let default_config = &effective_config.default; + + // Calculate effective config for each specific subgraph + for (name, subgraph_config) in &config.subgraphs { + // Compute effective configuration by merging global and subgraph settings. + let (effective_include_errors, effective_redact, effective_allow, effective_deny) = + match subgraph_config { + SubgraphConfig::Allow { + allow_extensions_keys: sub_allow, + redact_message: sub_redact, + exclude_global_keys, + } => { + let redact = sub_redact.unwrap_or(default_config.redact_message); + match &default_config.allow_extensions_keys { + Some(global_allow) => { + let mut allow_list = global_allow + .iter() + .filter(|k| !exclude_global_keys.contains(k)) + .cloned() + .collect::>(); + // Add subgraph's allow keys + allow_list.extend(sub_allow.iter().cloned()); + (true, redact, Some(allow_list), None) + } + None => (true, redact, Some(sub_allow.to_vec()), None), + } + } + SubgraphConfig::Deny { + deny_extensions_keys: sub_deny, + redact_message: sub_redact, + exclude_global_keys, + } => { + let redact = sub_redact.unwrap_or(default_config.redact_message); + match &default_config.deny_extensions_keys { + Some(global_deny) => { + let mut deny_list = global_deny + .iter() + .filter(|k| !exclude_global_keys.contains(k)) + .cloned() + .collect::>(); + deny_list.extend(sub_deny.clone()); + (true, redact, None, Some(deny_list)) + } + None => (true, redact, None, Some(sub_deny.to_vec())), + } + } + SubgraphConfig::Included(enabled) => ( + // Discard global allow/deny when subgraph is bool + *enabled, false, None, None, + ), + SubgraphConfig::CommonOnly { + redact_message: sub_redact, + exclude_global_keys, + } => { + let redact = sub_redact.unwrap_or(default_config.redact_message); + // Inherit global allow/deny lists when using CommonOnly + match config.all.clone() { + ErrorMode::Allow { + allow_extensions_keys, + .. + } => ( + true, + redact, + Some( + allow_extensions_keys + .iter() + .filter(|k| !exclude_global_keys.contains(k)) + .cloned() + .collect(), + ), + None, + ), + ErrorMode::Deny { + deny_extensions_keys, + .. + } => ( + true, + redact, + None, + Some( + deny_extensions_keys + .iter() + .filter(|k| !exclude_global_keys.contains(k)) + .cloned() + .collect(), + ), + ), + _ => (true, redact, None, None), + } + } + }; + + effective_config.subgraphs.insert( + name.clone(), + SubgraphEffectiveConfig { + include_errors: effective_include_errors, + redact_message: effective_redact, + allow_extensions_keys: effective_allow, + deny_extensions_keys: effective_deny, + }, + ); + } + + Ok(effective_config) + } +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct SubgraphEffectiveConfig { + /// Whether errors from this subgraph should be included at all. + pub(crate) include_errors: bool, + /// Whether the error message should be redacted. + pub(crate) redact_message: bool, + /// Set of extension keys explicitly allowed. If `None`, all are allowed unless denied. + pub(crate) allow_extensions_keys: Option>, + /// Set of extension keys explicitly denied. Applied *after* allow list filtering. + pub(crate) deny_extensions_keys: Option>, +} + +impl EffectiveConfig { + fn default_config(config: &Config) -> SubgraphEffectiveConfig { + let (global_include_errors, global_redact_message, global_allow_keys, global_deny_keys) = + match &config.all { + ErrorMode::Included(enabled) => (*enabled, !*enabled, None, None), // Redact if not enabled + ErrorMode::Allow { + allow_extensions_keys, + redact_message, + } => ( + true, + *redact_message, + Some( + allow_extensions_keys + .iter() + .sorted() + .cloned() + .collect::>(), + ), + None, + ), + ErrorMode::Deny { + deny_extensions_keys, + redact_message, + } => ( + true, + *redact_message, + None, + Some( + deny_extensions_keys + .iter() + .sorted() + .cloned() + .collect::>(), + ), + ), + }; + SubgraphEffectiveConfig { + include_errors: global_include_errors, + redact_message: global_redact_message, + allow_extensions_keys: global_allow_keys, + deny_extensions_keys: global_deny_keys, + } + } +} diff --git a/apollo-router/src/plugins/include_subgraph_errors/mod.rs b/apollo-router/src/plugins/include_subgraph_errors/mod.rs new file mode 100644 index 0000000000..c9a3e7a7c6 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/mod.rs @@ -0,0 +1,168 @@ +// Declare modules +mod config; +mod effective_config; +#[cfg(test)] +mod tests; + +// Use items from modules +use std::sync::Arc; + +use config::Config; +use config::ErrorMode; +use config::SubgraphConfig; +use effective_config::EffectiveConfig; +use tower::BoxError; +use tower::ServiceExt; + +use crate::error::Error; +use crate::graphql; +use crate::json_ext::Object; +use crate::plugin::Plugin; +use crate::plugin::PluginInit; +use crate::register_plugin; +use crate::services::SupergraphResponse; +use crate::services::fetch::AddSubgraphNameExt; +use crate::services::fetch::SubgraphNameExt; +use crate::services::supergraph::BoxService; + +static REDACTED_ERROR_MESSAGE: &str = "Subgraph errors redacted"; + +register_plugin!("apollo", "include_subgraph_errors", IncludeSubgraphErrors); + +struct IncludeSubgraphErrors { + // Store the calculated effective configuration + config: Arc, +} + +#[async_trait::async_trait] +impl Plugin for IncludeSubgraphErrors { + type Config = Config; // Use Config from the config module + + async fn new(init: PluginInit) -> Result { + // Validate that subgraph configs are boolean only if global config is boolean + if let ErrorMode::Included(_) = &init.config.all { + for (name, config) in &init.config.subgraphs { + if !matches!(config, SubgraphConfig::Included(_)) { + return Err(format!( + "Subgraph '{name}' must use boolean config when global config is boolean", + ) + .into()); + } + } + } + + // Generate and store the effective configuration + let config = Arc::new(init.config.try_into()?); + + Ok(IncludeSubgraphErrors { config }) + } + + fn supergraph_service(&self, service: BoxService) -> BoxService { + let config = Arc::clone(&self.config); + + service + .map_response(move |response: SupergraphResponse| { + response.map_stream(move |mut graphql_response: graphql::Response| { + for error in &mut graphql_response.errors { + Self::process_error(&config, error); + } + for incremental in &mut graphql_response.incremental { + for error in &mut incremental.errors { + Self::process_error(&config, error); + } + } + + graphql_response + }) + }) + .boxed() + } + + fn subgraph_service( + &self, + subgraph_name: &str, + service: crate::services::subgraph::BoxService, + ) -> crate::services::subgraph::BoxService { + // We need to attach the subgraph name to each error so that we can do the filtering in the supergraph service. + // The reason filtering is not done here is that other types of request may also generate errors that need filtering. + // Pushing the error filtering to supergraph will ensure that everything gets filtered. + let subgraph_name = subgraph_name.to_string(); + service + .map_response(move |mut r| { + let body = r.response.body_mut(); + for error in &mut body.errors { + error.add_subgraph_name(&subgraph_name); + } + r + }) + .boxed() + } +} + +impl IncludeSubgraphErrors { + fn process_error(config: &Arc, error: &mut Error) { + if let Some(subgraph_name) = error.subgraph_name() { + // Get the effective config for this specific subgraph, or use default + let effective_config = config + .subgraphs + .get(&subgraph_name) + .unwrap_or(&config.default); + + if !effective_config.include_errors { + tracing::debug!( + "Redacting errors for subgraph '{}' based on config: include_errors=false", + subgraph_name + ); + // Redact fully if errors should not be included + error.message = REDACTED_ERROR_MESSAGE.to_string(); + error.extensions = Object::new(); // Clear all extensions + } else { + tracing::debug!( + "Processing errors for subgraph '{}' based on config: {:?}", + subgraph_name, + effective_config + ); + // Process errors based on the effective config + // 1. Redact message if needed + if effective_config.redact_message { + error.message = REDACTED_ERROR_MESSAGE.to_string(); + } + + // 2. Add 'service' extension (unless denied) + let service_key = "service".to_string(); + let is_service_denied = effective_config + .deny_extensions_keys + .as_ref() + .is_some_and(|deny| deny.contains(&service_key)); + let is_service_allowed = effective_config + .allow_extensions_keys + .as_ref() + .is_none_or(|allow| allow.contains(&service_key)); // Allowed if no allow list or if present in allow list + + if !is_service_denied && is_service_allowed { + error + .extensions + .entry(service_key) + .or_insert(subgraph_name.clone().into()); + } + + // 3. Filter extensions based on allow list + if let Some(allow_keys) = &effective_config.allow_extensions_keys { + let mut original_extensions = std::mem::take(&mut error.extensions); + for key in allow_keys { + if let Some((key, value)) = original_extensions.remove_entry(key.as_str()) { + error.extensions.insert(key, value); + } + } + } + + // 4. Remove extensions based on deny list (applied *after* allow list) + if let Some(deny_keys) = &effective_config.deny_extensions_keys { + for key in deny_keys { + error.extensions.remove(key.as_str()); + } + } + } + } + } +} diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_all_explicit.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_all_explicit.snap new file mode 100644 index 0000000000..3d29bb5ec8 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_all_explicit.snap @@ -0,0 +1,14 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall: true\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Could not query products + path: [] + extensions: + test: value + code: FETCH_ERROR + service: products diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_product_override_implicit_redact.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_product_override_implicit_redact.snap new file mode 100644 index 0000000000..9989973ae7 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_product_override_implicit_redact.snap @@ -0,0 +1,14 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nsubgraphs:\n products: true\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Could not query products + path: [] + extensions: + test: value + code: FETCH_ERROR + service: products diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_product_when_account_redacted.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_product_when_account_redacted.snap new file mode 100644 index 0000000000..9d41fa7dee --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_product_when_account_redacted.snap @@ -0,0 +1,14 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall: true\nsubgraphs:\n accounts: false\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Could not query products + path: [] + extensions: + test: value + code: FETCH_ERROR + service: products diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_product_when_review_redacted.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_product_when_review_redacted.snap new file mode 100644 index 0000000000..510d787b6b --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__allow_product_when_review_redacted.snap @@ -0,0 +1,14 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall: true\nsubgraphs:\n reviews: false\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Could not query products + path: [] + extensions: + test: value + code: FETCH_ERROR + service: products diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__filter_global_allow_keep_msg.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__filter_global_allow_keep_msg.snap new file mode 100644 index 0000000000..8ac4a4fada --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__filter_global_allow_keep_msg.snap @@ -0,0 +1,13 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall:\n redact_message: false\n allow_extensions_keys:\n - code\n - service\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Could not query products + path: [] + extensions: + code: FETCH_ERROR + service: products diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__filter_global_allow_redact_msg.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__filter_global_allow_redact_msg.snap new file mode 100644 index 0000000000..a740a436a8 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__filter_global_allow_redact_msg.snap @@ -0,0 +1,13 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall:\n redact_message: true\n allow_extensions_keys:\n - code\n - service\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Subgraph errors redacted + path: [] + extensions: + code: FETCH_ERROR + service: products diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__incremental_response.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__incremental_response.snap new file mode 100644 index 0000000000..c68f75038a --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__incremental_response.snap @@ -0,0 +1,24 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall: true\n\n\nREQUEST:\n[\n {\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Main errors error\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"MAIN_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n },\n {\n \"incremental\": [\n {\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Incremental error\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"INCREMENTAL_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n }\n ]\n }\n]" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Main errors error + path: [] + extensions: + test: value + code: MAIN_ERROR + service: products +- incremental: + - data: + topProducts: ~ + errors: + - message: Incremental error + path: [] + extensions: + test: value + code: INCREMENTAL_ERROR + service: products diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__non_subgraph_error.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__non_subgraph_error.snap new file mode 100644 index 0000000000..12c23a1761 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__non_subgraph_error.snap @@ -0,0 +1,13 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall: true\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Authentication error\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"AUTH_ERROR\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Authentication error + path: [] + extensions: + test: value + code: AUTH_ERROR diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_account_override_explicit_allow.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_account_override_explicit_allow.snap new file mode 100644 index 0000000000..9fc8347c84 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_account_override_explicit_allow.snap @@ -0,0 +1,8 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall: true\nsubgraphs:\n accounts: false\n\n\nREQUEST:\n{\n \"data\": null,\n \"errors\": [\n {\n \"message\": \"Account service error\",\n \"path\": [],\n \"extensions\": {\n \"code\": \"ACCOUNT_FAIL\",\n \"apollo.subgraph.name\": \"accounts\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- errors: + - message: Subgraph errors redacted + path: [] diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_all_explicit.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_all_explicit.snap new file mode 100644 index 0000000000..0f16a4c104 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_all_explicit.snap @@ -0,0 +1,10 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall: false\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Subgraph errors redacted + path: [] diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_all_implicit.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_all_implicit.snap new file mode 100644 index 0000000000..92ddb16c9f --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_all_implicit.snap @@ -0,0 +1,10 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\n{}\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Subgraph errors redacted + path: [] diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_product_override_explicit_allow.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_product_override_explicit_allow.snap new file mode 100644 index 0000000000..4b0a38fcb2 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_product_override_explicit_allow.snap @@ -0,0 +1,10 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall: true\nsubgraphs:\n products: false\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Subgraph errors redacted + path: [] diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_product_when_review_allowed.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_product_when_review_allowed.snap new file mode 100644 index 0000000000..7760354a9e --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__redact_product_when_review_allowed.snap @@ -0,0 +1,10 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nsubgraphs:\n reviews: true\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Subgraph errors redacted + path: [] diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_allow_extend_global_allow.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_allow_extend_global_allow.snap new file mode 100644 index 0000000000..de58a1c771 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_allow_extend_global_allow.snap @@ -0,0 +1,14 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall:\n redact_message: false\n allow_extensions_keys:\n - test\n - service\nsubgraphs:\n products:\n allow_extensions_keys:\n - code\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Could not query products + path: [] + extensions: + service: products + test: value + code: FETCH_ERROR diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_allow_override_global_deny.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_allow_override_global_deny.snap new file mode 100644 index 0000000000..adf7c95302 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_allow_override_global_deny.snap @@ -0,0 +1,13 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall:\n redact_message: false\n deny_extensions_keys:\n - test\n - service\nsubgraphs:\n products:\n allow_extensions_keys:\n - code\n - test\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Could not query products + path: [] + extensions: + code: FETCH_ERROR + test: value diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_allow_service.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_allow_service.snap new file mode 100644 index 0000000000..fefaf5ee53 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_allow_service.snap @@ -0,0 +1,12 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall:\n redact_message: true\n deny_extensions_keys:\n - code\n - test\nsubgraphs:\n products:\n allow_extensions_keys:\n - service\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Subgraph errors redacted + path: [] + extensions: + service: products diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_bool_false_override_global_allow.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_bool_false_override_global_allow.snap new file mode 100644 index 0000000000..351b6505ba --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_bool_false_override_global_allow.snap @@ -0,0 +1,10 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall:\n redact_message: true\n allow_extensions_keys:\n - code\nsubgraphs:\n products: false\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Subgraph errors redacted + path: [] diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_bool_true_override_global_deny.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_bool_true_override_global_deny.snap new file mode 100644 index 0000000000..5734bd276d --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_bool_true_override_global_deny.snap @@ -0,0 +1,14 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall:\n redact_message: true\n deny_extensions_keys:\n - code\nsubgraphs:\n products: true\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Could not query products + path: [] + extensions: + test: value + code: FETCH_ERROR + service: products diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_deny_extend_global_deny.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_deny_extend_global_deny.snap new file mode 100644 index 0000000000..f7fc055297 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_deny_extend_global_deny.snap @@ -0,0 +1,12 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall:\n redact_message: true\n deny_extensions_keys:\n - test\nsubgraphs:\n products:\n deny_extensions_keys:\n - code\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Subgraph errors redacted + path: [] + extensions: + service: products diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_deny_override_global_allow.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_deny_override_global_allow.snap new file mode 100644 index 0000000000..d1e0239504 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_deny_override_global_allow.snap @@ -0,0 +1,12 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall:\n redact_message: false\n allow_extensions_keys:\n - code\n - test\n - service\nsubgraphs:\n products:\n deny_extensions_keys:\n - test\n - service\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Could not query products + path: [] + extensions: + code: FETCH_ERROR diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_deny_service.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_deny_service.snap new file mode 100644 index 0000000000..e393919381 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_deny_service.snap @@ -0,0 +1,13 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall:\n redact_message: false\n allow_extensions_keys:\n - code\n - test\n - service\nsubgraphs:\n products:\n deny_extensions_keys:\n - service\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Could not query products + path: [] + extensions: + test: value + code: FETCH_ERROR diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_exclude_global_allow.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_exclude_global_allow.snap new file mode 100644 index 0000000000..1c8e469ad2 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_exclude_global_allow.snap @@ -0,0 +1,13 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall:\n redact_message: false\n allow_extensions_keys:\n - code\n - test\n - service\nsubgraphs:\n products:\n exclude_global_keys:\n - test\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Could not query products + path: [] + extensions: + code: FETCH_ERROR + service: products diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_obj_override_redaction.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_obj_override_redaction.snap new file mode 100644 index 0000000000..35a5394392 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__subgraph_obj_override_redaction.snap @@ -0,0 +1,13 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall:\n redact_message: false\n allow_extensions_keys:\n - code\n - service\nsubgraphs:\n products:\n redact_message: true\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": null\n },\n \"errors\": [\n {\n \"message\": \"Could not query products\",\n \"path\": [],\n \"extensions\": {\n \"test\": \"value\",\n \"code\": \"FETCH_ERROR\",\n \"apollo.subgraph.name\": \"products\"\n }\n }\n ]\n}" +expression: actual_responses +--- +- data: + topProducts: ~ + errors: + - message: Subgraph errors redacted + path: [] + extensions: + code: FETCH_ERROR + service: products diff --git a/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__valid_response.snap b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__valid_response.snap new file mode 100644 index 0000000000..c2b7dd7796 --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/snapshots/apollo_router__plugins__include_subgraph_errors__tests__valid_response.snap @@ -0,0 +1,31 @@ +--- +source: apollo-router/src/plugins/include_subgraph_errors/tests.rs +description: "CONFIG:\n---\nall: false\n\n\nREQUEST:\n{\n \"data\": {\n \"topProducts\": [\n {\n \"upc\": \"1\",\n \"name\": \"Table\",\n \"reviews\": [\n {\n \"id\": \"1\",\n \"product\": {\n \"name\": \"Table\"\n },\n \"author\": {\n \"id\": \"1\",\n \"name\": \"Ada Lovelace\"\n }\n },\n {\n \"id\": \"4\",\n \"product\": {\n \"name\": \"Table\"\n },\n \"author\": {\n \"id\": \"2\",\n \"name\": \"Alan Turing\"\n }\n }\n ]\n },\n {\n \"upc\": \"2\",\n \"name\": \"Couch\",\n \"reviews\": [\n {\n \"id\": \"2\",\n \"product\": {\n \"name\": \"Couch\"\n },\n \"author\": {\n \"id\": \"1\",\n \"name\": \"Ada Lovelace\"\n }\n }\n ]\n }\n ]\n }\n}" +expression: actual_responses +--- +- data: + topProducts: + - upc: "1" + name: Table + reviews: + - id: "1" + product: + name: Table + author: + id: "1" + name: Ada Lovelace + - id: "4" + product: + name: Table + author: + id: "2" + name: Alan Turing + - upc: "2" + name: Couch + reviews: + - id: "2" + product: + name: Couch + author: + id: "1" + name: Ada Lovelace diff --git a/apollo-router/src/plugins/include_subgraph_errors/tests.rs b/apollo-router/src/plugins/include_subgraph_errors/tests.rs new file mode 100644 index 0000000000..8ec4d5061f --- /dev/null +++ b/apollo-router/src/plugins/include_subgraph_errors/tests.rs @@ -0,0 +1,505 @@ +use futures::StreamExt; +use insta::with_settings; +use serde_json::Map; +use serde_json::Value; +use serde_json::json; +use tower::BoxError; + +use super::*; // Import items from mod.rs +use crate::graphql; +use crate::plugins::test::PluginTestHarness; +use crate::services::supergraph; // Required for collect + +const PRODUCT_ERROR_RESPONSE: &[&str] = &[ + r#"{"data":{"topProducts":null},"errors":[{"message":"Could not query products","path":[],"extensions":{"test":"value","code":"FETCH_ERROR", "apollo.private.subgraph.name": "products"}}]}"#, +]; +const ACCOUNT_ERROR_RESPONSE: &[&str] = &[ + r#"{"data":null,"errors":[{"message":"Account service error","path":[],"extensions":{"code":"ACCOUNT_FAIL", "apollo.private.subgraph.name": "accounts"}}]}"#, +]; +const VALID_RESPONSE: &[&str] = &[ + r#"{"data":{"topProducts":[{"upc":"1","name":"Table","reviews":[{"id":"1","product":{"name":"Table"},"author":{"id":"1","name":"Ada Lovelace"}},{"id":"4","product":{"name":"Table"},"author":{"id":"2","name":"Alan Turing"}}]},{"upc":"2","name":"Couch","reviews":[{"id":"2","product":{"name":"Couch"},"author":{"id":"1","name":"Ada Lovelace"}}]}]}}"#, +]; +const NON_SUBGRAPH_ERROR: &[&str] = &[ + r#"{"data":{"topProducts":null},"errors":[{"message":"Authentication error","path":[],"extensions":{"test":"value","code":"AUTH_ERROR"}}]}"#, +]; +const INCREMENTAL_RESPONSE: &[&str] = &[ + r#"{"data":{"topProducts":null},"errors":[{"message":"Main errors error","path":[],"extensions":{"test":"value","code":"MAIN_ERROR", "apollo.private.subgraph.name": "products"}}]}"#, + r#"{"incremental":[{"data":{"topProducts":null},"errors":[{"message":"Incremental error","path":[],"extensions":{"test":"value","code":"INCREMENTAL_ERROR", "apollo.private.subgraph.name": "products"}}]}]}"#, +]; + +async fn build_harness( + plugin_config: &Value, +) -> Result, BoxError> { + let mut config = Map::new(); + config.insert("include_subgraph_errors".to_string(), plugin_config.clone()); + let config = serde_yaml::to_string(&config).expect("config to yaml"); + PluginTestHarness::builder().config(&config).build().await +} + +async fn run_test_case( + config: &Value, + mock_responses: &[&str], // The array of responses + snapshot_suffix: &str, // Suffix for the snapshot file name +) { + let harness = build_harness(config).await.expect("plugin should load"); + + let mock_response_elements = mock_responses + .iter() + .map(|response| { + serde_json::from_str(response).expect("Failed to parse mock response bytes") + }) + .collect::>(); + + let service = harness.supergraph_service(move |req| { + let mock_response_elements = mock_response_elements.clone(); + async { + supergraph::Response::fake_stream_builder() + .responses(mock_response_elements) + .context(req.context) + .build() + } + }); + let mut response = service.call_default().await.unwrap(); + + // Collect the actual response body (potentially modified by the plugin) + let actual_responses: Vec = response.response.body_mut().collect().await; + + let config = serde_yaml::to_string(config).expect("config to yaml"); + let parsed_responses = mock_responses + .iter() + .map(|response| serde_json::from_str(response).expect("request")) + .collect::>(); + let request = serde_json::to_string_pretty(&parsed_responses).expect("request to json"); + + let description = format!("CONFIG:\n{config}\n\nREQUEST:\n{request}"); + with_settings!({ + description => description, + }, { + // Assert the collected body against a snapshot + insta::assert_yaml_snapshot!(snapshot_suffix, actual_responses); + }); +} + +#[tokio::test] +async fn it_returns_valid_response() { + run_test_case( + &json!({ "all": false }), + VALID_RESPONSE, // Mock stream input + "valid_response", // Snapshot suffix + ) + .await; +} + +#[tokio::test] +async fn it_redacts_all_subgraphs_explicit_redact() { + run_test_case( + &json!({ "all": false }), + PRODUCT_ERROR_RESPONSE, // Mock original error + "redact_all_explicit", // Snapshot suffix + ) + .await; +} + +#[tokio::test] +async fn it_redacts_all_subgraphs_implicit_redact() { + run_test_case( + &json!({}), // Default is all: false + PRODUCT_ERROR_RESPONSE, + "redact_all_implicit", + ) + .await; +} + +#[tokio::test] +async fn it_does_not_redact_all_subgraphs_explicit_allow() { + run_test_case( + &json!({ "all": true }), + PRODUCT_ERROR_RESPONSE, // Mock original error + "allow_all_explicit", // Snapshot suffix + ) + .await; +} + +#[tokio::test] +async fn it_does_not_redact_all_implicit_redact_product_explicit_allow_for_product_query() { + run_test_case( + &json!({ "subgraphs": {"products": true }}), // Default all: false + PRODUCT_ERROR_RESPONSE, + "allow_product_override_implicit_redact", + ) + .await; +} + +#[tokio::test] +async fn it_does_redact_all_implicit_redact_product_explicit_allow_for_review_query() { + run_test_case( + &json!({ "subgraphs": {"reviews": true }}), // Allows reviews, defaults products to redact + PRODUCT_ERROR_RESPONSE, // Mock original error for products + "redact_product_when_review_allowed", + ) + .await; +} + +#[tokio::test] +async fn it_does_not_redact_all_explicit_allow_review_explicit_redact_for_product_query() { + run_test_case( + &json!({ "all": true, "subgraphs": {"reviews": false }}), // Global allow, reviews redact + PRODUCT_ERROR_RESPONSE, // Mock original + "allow_product_when_review_redacted", + ) + .await; +} + +#[tokio::test] +async fn it_does_redact_all_explicit_allow_product_explicit_redact_for_product_query() { + run_test_case( + &json!({ "all": true, "subgraphs": {"products": false }}), // Global allow, products redact + PRODUCT_ERROR_RESPONSE, // Mock original + "redact_product_override_explicit_allow", + ) + .await; +} + +#[tokio::test] +async fn it_does_not_redact_all_explicit_allow_account_explicit_redact_for_product_query() { + run_test_case( + &json!({ "all": true, "subgraphs": {"accounts": false }}), // Global allow, accounts redact + PRODUCT_ERROR_RESPONSE, // Mock original + "allow_product_when_account_redacted", + ) + .await; +} + +#[tokio::test] +async fn it_does_redact_all_explicit_allow_account_explicit_redact_for_account_query() { + run_test_case( + &json!({ "all": true, "subgraphs": {"accounts": false }}), // Global allow, accounts redact + ACCOUNT_ERROR_RESPONSE, // Mock original account error + "redact_account_override_explicit_allow", + ) + .await; +} + +#[tokio::test] +async fn it_does_not_allow_both_allow_and_deny_list_in_global_config() { + let config_json = json!({ + "all": { + "redact_message": false, + "allow_extensions_keys": [], + "deny_extensions_keys": [] + } + }); + let result = build_harness(&config_json).await; + assert_eq!( + result.expect_err("expected error").to_string(), + "Global config cannot have both allow_extensions_keys and deny_extensions_keys" + ); +} + +#[tokio::test] +async fn it_does_not_allow_both_allow_and_deny_list_in_a_subgraph_config() { + let config_json = json!({ + "all": { // Global must be object type if subgraph is object type + "redact_message": false, + "allow_extensions_keys": [], + }, + "subgraphs": { + "products": { + "redact_message": false, + "allow_extensions_keys": [], + "deny_extensions_keys": [] + } + } + }); + let result = build_harness(&config_json).await; + assert_eq!( + result.expect_err("expected error").to_string(), + "A subgraph config cannot have both allow_extensions_keys and deny_extensions_keys" + ); +} + +#[tokio::test] +async fn it_does_not_allow_subgraph_config_with_object_when_global_is_boolean() { + let config_json = json!({ + "all": false, // Global is boolean + "subgraphs": { + "products": { // Subgraph is object + "redact_message": true + } + } + }); + let result = build_harness(&config_json).await; + assert_eq!( + result.expect_err("expected error").to_string(), + "Subgraph 'products' must use boolean config when global config is boolean" + ); +} + +#[tokio::test] +async fn it_allows_subgraph_config_with_boolean_when_global_is_object() { + let config_json = json!({ + "all": { + "redact_message": true, + "deny_extensions_keys": ["code"] + }, + "subgraphs": { + "products": true // Boolean subgraph config is allowed + } + }); + let result = build_harness(&config_json).await; + assert!(result.is_ok()); // Check plugin creation succeeded +} + +#[tokio::test] +async fn it_allows_any_subgraph_config_type_when_global_is_object() { + let config_json = json!({ + "all": { + "redact_message": true, + "deny_extensions_keys": ["code"] // Global deny list + }, + "subgraphs": { + "products": { + "allow_extensions_keys": ["code"] // Subgraph allow overrides global deny + }, + "reviews": { + "deny_extensions_keys": ["reason"] // Subgraph deny extends global deny + }, + "inventory": { + "exclude_global_keys": ["code"] // CommonOnly inherits global deny, but excludes 'code' + }, + "accounts": true // Boolean overrides global object config + } + }); + let result = build_harness(&config_json).await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn it_filters_extensions_based_on_global_allow_list_and_redacts_message() { + run_test_case( + &json!({ + "all": { + "redact_message": true, + "allow_extensions_keys": ["code", "service"] // Allow 'code' and 'service' + } + }), + PRODUCT_ERROR_RESPONSE, + "filter_global_allow_redact_msg", + ) + .await; +} + +#[tokio::test] +async fn it_filters_extensions_based_on_global_allow_list_keeps_message() { + run_test_case( + &json!({ + "all": { + "redact_message": false, + "allow_extensions_keys": ["code", "service"] // Allow 'code' and 'service' + } + }), + PRODUCT_ERROR_RESPONSE, + "filter_global_allow_keep_msg", + ) + .await; +} + +#[tokio::test] +async fn it_allows_subgraph_bool_override_global_deny_config() { + run_test_case( + &json!({ + "all": { + "redact_message": true, + "deny_extensions_keys": ["code"], + }, + "subgraphs": { "products": true } + }), + PRODUCT_ERROR_RESPONSE, + "subgraph_bool_true_override_global_deny", + ) + .await; +} + +#[tokio::test] +async fn it_allows_subgraph_bool_override_global_allow_config() { + run_test_case( + &json!({ + "all": { + "redact_message": true, + "allow_extensions_keys": ["code"], + }, + "subgraphs": { "products": false } + }), + PRODUCT_ERROR_RESPONSE, + "subgraph_bool_false_override_global_allow", + ) + .await; +} + +#[tokio::test] +async fn it_allows_subgraph_object_to_override_global_redaction() { + run_test_case( + &json!({ + "all": { + "redact_message": false, + "allow_extensions_keys": ["code", "service"], + }, + "subgraphs": { + "products": { "redact_message": true } // Override redaction + } + }), + PRODUCT_ERROR_RESPONSE, + "subgraph_obj_override_redaction", + ) + .await; +} + +#[tokio::test] +async fn it_allows_subgraph_to_exclude_key_from_global_allow_list() { + run_test_case( + &json!({ + "all": { + "redact_message": false, + "allow_extensions_keys": ["code", "test", "service"] + }, + "subgraphs": { + "products": { "exclude_global_keys": ["test"] } // Exclude 'test' + } + }), + PRODUCT_ERROR_RESPONSE, + "subgraph_exclude_global_allow", + ) + .await; +} + +#[tokio::test] +async fn it_allows_subgraph_deny_list_to_override_global_allow_list() { + run_test_case( + &json!({ + "all": { + "redact_message": false, + "allow_extensions_keys": ["code", "test", "service"] + }, + "subgraphs": { + "products": { "deny_extensions_keys": ["test", "service"] } // Deny overrides global allow + } + }), + PRODUCT_ERROR_RESPONSE, + "subgraph_deny_override_global_allow", + ) + .await; +} + +#[tokio::test] +async fn it_allows_subgraph_allow_list_to_override_global_deny_list() { + run_test_case( + &json!({ + "all": { + "redact_message": false, + "deny_extensions_keys": ["test", "service"] + }, + "subgraphs": { + "products": { "allow_extensions_keys": ["code", "test"] } // Allow overrides global deny for 'test' + } + }), + PRODUCT_ERROR_RESPONSE, + "subgraph_allow_override_global_deny", + ) + .await; +} + +#[tokio::test] +async fn it_allows_subgraph_deny_list_to_extend_global_deny_list() { + run_test_case( + &json!({ + "all": { + "redact_message": true, + "deny_extensions_keys": ["test"] + }, + "subgraphs": { + "products": { "deny_extensions_keys": ["code"] } // Extends global deny + } + }), + PRODUCT_ERROR_RESPONSE, + "subgraph_deny_extend_global_deny", + ) + .await; +} + +#[tokio::test] +async fn it_allows_subgraph_allow_list_to_extend_global_allow_list() { + run_test_case( + &json!({ + "all": { + "redact_message": false, + "allow_extensions_keys": ["test", "service"] + }, + "subgraphs": { + "products": { "allow_extensions_keys": ["code"] } // Extends global allow + } + }), + PRODUCT_ERROR_RESPONSE, + "subgraph_allow_extend_global_allow", + ) + .await; +} + +#[tokio::test] +async fn it_redacts_service_extension_if_denied() { + run_test_case( + &json!({ + "all": { + "redact_message": false, + "allow_extensions_keys": ["code", "test", "service"] // Allow globally initially + }, + "subgraphs": { + "products": { "deny_extensions_keys": ["service"] } // Deny service specifically + } + }), + PRODUCT_ERROR_RESPONSE, + "subgraph_deny_service", + ) + .await; +} + +#[tokio::test] +async fn it_includes_service_extension_if_allowed() { + run_test_case( + &json!({ + "all": { + "redact_message": true, + "deny_extensions_keys": ["code", "test"] + }, + "subgraphs": { + "products": { "allow_extensions_keys": ["service"] } // Allow service specifically + } + }), + PRODUCT_ERROR_RESPONSE, + "subgraph_allow_service", + ) + .await; +} + +#[tokio::test] +async fn it_does_not_add_service_extension_for_non_subgraph_errors() { + run_test_case( + &json!({ + "all": true, + }), + NON_SUBGRAPH_ERROR, + "non_subgraph_error", + ) + .await; +} + +#[tokio::test] +async fn it_processes_incremental_responses() { + run_test_case( + &json!({ + "all": true, + }), + INCREMENTAL_RESPONSE, + "incremental_response", + ) + .await; +} diff --git a/apollo-router/src/plugins/license_enforcement/mod.rs b/apollo-router/src/plugins/license_enforcement/mod.rs new file mode 100644 index 0000000000..252bcc3940 --- /dev/null +++ b/apollo-router/src/plugins/license_enforcement/mod.rs @@ -0,0 +1,260 @@ +//! A plugin for enforcing product limitations in the router based on License claims +//! +//! Currently includes: +//! * TPS Rate Limiting: a certain threshold, set via License claim, for how many operations over a certain interval can be serviced + +use std::num::NonZeroU64; +use std::time::Duration; + +use http::StatusCode; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use tower::BoxError; +use tower::ServiceBuilder; +use tower::ServiceExt; +use tower::limit::RateLimitLayer; +use tower::load_shed::error::Overloaded; + +use crate::graphql; +use crate::layers::ServiceBuilderExt; +use crate::plugin::PluginInit; +use crate::plugin::PluginPrivate; +use crate::services::RouterResponse; +use crate::services::router; + +#[derive(PartialEq, Debug, Clone, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +/// The limits placed on a router in virtue of what's in a user's license +pub(crate) struct LicenseEnforcement { + /// Transactions per second allowed based on license tier + pub(crate) tps: Option, +} + +#[derive(PartialEq, Debug, Clone, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +/// Configuration for transactions per second +pub(crate) struct TpsLimitConf { + /// The number of operations allowed during a certain interval + pub(crate) capacity: NonZeroU64, + /// The interval as specified in the user's license; this is in milliseconds + #[serde(deserialize_with = "humantime_serde::deserialize")] + #[schemars(with = "String")] + pub(crate) interval: Duration, +} + +#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)] +pub(crate) struct LicenseEnforcementConfig {} + +#[async_trait::async_trait] +impl PluginPrivate for LicenseEnforcement { + type Config = LicenseEnforcementConfig; + + async fn new(init: PluginInit) -> Result { + let tps = init.license.get_limits().and_then(|limits| { + limits.tps.and_then(|tps| { + NonZeroU64::new(tps.capacity as u64).map(|capacity| TpsLimitConf { + capacity, + interval: tps.interval, + }) + }) + }); + + Ok(Self { tps }) + } + + fn router_service(&self, service: router::BoxService) -> router::BoxService { + ServiceBuilder::new() + .map_future_with_request_data( + |req: &router::Request| req.context.clone(), + move |ctx, future| async { + let response: Result = future.await; + match response { + Ok(ok) => Ok(ok), + Err(err) if err.is::() => { + let error = graphql::Error::builder() + .message("Your request has been rate limited. You've reached the limits for the Free plan. Consider upgrading to a higher plan for increased limits.") + .extension_code("ROUTER_FREE_PLAN_RATE_LIMIT_REACHED") + .build(); + Ok(RouterResponse::error_builder() + .status_code(StatusCode::SERVICE_UNAVAILABLE) + .error(error) + .context(ctx) + .build() + .expect("should build overloaded response")) + } + Err(err) => Err(err), + } + }, + ) + .load_shed() + .option_layer( + self.tps + .as_ref() + .map(|config| RateLimitLayer::new(config.capacity.into(), config.interval)), + ) + .service(service) + .boxed() + } +} + +register_private_plugin!("apollo", "license_enforcement", LicenseEnforcement); + +#[cfg(test)] +mod test { + use std::sync::Arc; + use std::sync::Mutex; + + use super::*; + use crate::metrics::FutureMetricsExt; + use crate::plugins::telemetry::Telemetry; + use crate::plugins::test::PluginTestHarness; + use crate::uplink::license_enforcement::LicenseLimits; + use crate::uplink::license_enforcement::LicenseState; + use crate::uplink::license_enforcement::TpsLimit; + + #[tokio::test(flavor = "multi_thread")] + async fn it_enforces_tps_limit_when_license() { + // GIVEN + // * a license with tps limits set to 1 req per 200ms + // * the router limits plugin + let license = LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: Some(TpsLimit { + capacity: 1, + interval: Duration::from_millis(150), + }), + allowed_features: Default::default(), + }), + }; + + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .license(license) + .build() + .await + .expect("test harness"); + + let service = test_harness.router_service(|_req| async { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + Ok(router::Response::fake_builder() + .data(serde_json::json!({"data": {"field": "value"}})) + .header("x-custom-header", "test-value") + .build() + .unwrap()) + }); + + // WHEN + // * three reqs happen concurrently + // * one delayed enough to be outside of rate limiting interval + let f1 = service.call_default(); + let f2 = service.call_default(); + #[allow(clippy::async_yields_async)] + let f3 = async { + tokio::time::sleep(Duration::from_millis(500)).await; + service.call_default() + }; + + let (r1, r2, r3) = tokio::join!(f1, f2, f3); + + // THEN + // * the first succeeds + // * the second gets rate limited + // * the third, delayed req succeeds + + assert!(r1.is_ok_and(|resp| resp.response.status().is_success())); + assert!(r2.is_ok_and(|resp| resp.response.status() == StatusCode::SERVICE_UNAVAILABLE)); + assert!( + r3.await + .is_ok_and(|resp| resp.response.status().is_success()) + ); + } + + #[tokio::test] + async fn it_emits_metrics_when_tps_enforced() { + async { + // GIVEN + // * a license with tps limits set to 1 req per 200ms + // * the router limits plugin + let license = LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: Some(TpsLimit { + capacity: 1, + interval: Duration::from_millis(150), + }), + allowed_features: Default::default(), + }), + }; + + let license_service = PluginTestHarness::::builder() + .license(license) + .build() + .await + .unwrap() + .router_service(|req| async { + Ok(router::Response::fake_builder() + .data(serde_json::json!({"data": {"field": "value"}})) + .header("x-custom-header", "test-value") + .context(req.context) + .build() + .unwrap()) + }); + + // WHEN + // * two reqs happen + // * and the telemetry plugin receives the second response with errors to count + + let _first_response = license_service.call_default().await; + let license_plugin_error_response = license_service.call_default().await.unwrap(); + + // Put the error response in an arc and mutex so we can share it with telemetry threads + let slot = Arc::new(Mutex::new(Some(license_plugin_error_response))); + // We have to do a weird thing where we take the response from the license plugin and feed + // it as the mock response of the telemetry plugin so that telemetry plugin will count + // the errors. Ideally this would be done using a TestHarness, but using a "full" + // router with the Telemetry plugin will hit reload_metrics() on activation thus + // breaking async(){}.with_metrics() by shutting down its metrics provider. + // Ultimately this is the best way anyone could think of to simulate this scenario. + let _telemetry_service = PluginTestHarness::::builder() + .config( + r#" + telemetry: + apollo: + endpoint: "http://example.com" + client_name_header: "name_header" + client_version_header: "version_header" + buffer_size: 10000 + "#, + ) + .build() + .await + .unwrap() + .router_service(move |_req| { + let slot = Arc::clone(&slot); + async move { + // pull out our one error‐response + let mut guard = slot.lock().unwrap(); + let resp = guard.take().unwrap(); + Ok(resp) + } + }) + .call( + router::Request::fake_builder() + .header("content-type", "application/json") + .build() + .unwrap(), + ) + .await + .unwrap(); + + // THEN + // * we get a metric from the telemetry plugin saying the tps limit was enforced + assert_counter!( + "apollo.router.graphql_error", + 1, + code = "ROUTER_FREE_PLAN_RATE_LIMIT_REACHED" + ); + } + .with_metrics() + .await; + } +} diff --git a/apollo-router/src/plugins/limits/layer.rs b/apollo-router/src/plugins/limits/layer.rs index 680a95c41b..268f8d1c33 100644 --- a/apollo-router/src/plugins/limits/layer.rs +++ b/apollo-router/src/plugins/limits/layer.rs @@ -1,7 +1,7 @@ use std::future::Future; use std::pin::Pin; -use std::sync::atomic::AtomicUsize; use std::sync::Arc; +use std::sync::atomic::AtomicUsize; use std::task::Poll; use displaydoc::Display; @@ -25,6 +25,7 @@ struct BodyLimitControlInner { /// This structure allows the body limit to be updated dynamically. /// It also allows the error message to be updated +/// #[derive(Clone)] pub(crate) struct BodyLimitControl { inner: Arc, @@ -41,7 +42,12 @@ impl BodyLimitControl { } /// To disable the limit check just set this to usize::MAX + #[allow(dead_code)] pub(crate) fn update_limit(&self, limit: usize) { + assert!( + self.limit() < limit, + "new limit must be greater than current limit" + ); self.inner .limit .store(limit, std::sync::atomic::Ordering::SeqCst); @@ -76,13 +82,13 @@ impl BodyLimitControl { /// pub(crate) struct RequestBodyLimitLayer { _phantom: std::marker::PhantomData, - control: BodyLimitControl, + initial_limit: usize, } impl RequestBodyLimitLayer { - pub(crate) fn new(control: BodyLimitControl) -> Self { + pub(crate) fn new(initial_limit: usize) -> Self { Self { _phantom: Default::default(), - control, + initial_limit, } } } @@ -95,14 +101,14 @@ where type Service = RequestBodyLimit; fn layer(&self, inner: S) -> Self::Service { - RequestBodyLimit::new(inner, self.control.clone()) + RequestBodyLimit::new(inner, self.initial_limit) } } pub(crate) struct RequestBodyLimit { _phantom: std::marker::PhantomData, inner: S, - control: BodyLimitControl, + initial_limit: usize, } impl RequestBodyLimit @@ -110,11 +116,11 @@ where S: Service>>, Body: http_body::Body, { - fn new(inner: S, control: BodyLimitControl) -> Self { + fn new(inner: S, initial_limit: usize) -> Self { Self { _phantom: Default::default(), inner, - control, + initial_limit, } } } @@ -122,9 +128,9 @@ where impl Service> for RequestBodyLimit where S: Service< - http::Request>, - Response = http::Response, - >, + http::Request>, + Response = http::Response, + >, ReqBody: http_body::Body, RespBody: http_body::Body, S::Error: From, @@ -137,16 +143,17 @@ where self.inner.poll_ready(cx) } - fn call(&mut self, req: http::Request) -> Self::Future { + fn call(&mut self, mut req: http::Request) -> Self::Future { + let control = BodyLimitControl::new(self.initial_limit); let content_length = req .headers() .get(http::header::CONTENT_LENGTH) .and_then(|value| value.to_str().ok()?.parse::().ok()); let _body_limit = match content_length { - Some(len) if len > self.control.limit() => return ResponseFuture::Reject, - Some(len) => self.control.limit().min(len), - None => self.control.limit(), + Some(len) if len > control.limit() => return ResponseFuture::Reject, + Some(len) => control.limit().min(len), + None => control.limit(), }; // TODO: We can only do this once this layer is moved to the beginning of the router pipeline. @@ -162,10 +169,12 @@ where .try_acquire_owned() .expect("abort lock is new, qed"); - let f = - self.inner.call(req.map(|body| { - super::limited::Limited::new(body, self.control.clone(), owned_permit) - })); + // Add the body limit to the request extensions + req.extensions_mut().insert(control.clone()); + + let f = self + .inner + .call(req.map(|body| super::limited::Limited::new(body, control, owned_permit))); ResponseFuture::Continue { inner: f, @@ -224,26 +233,27 @@ where mod test { use futures::stream::StreamExt; use http::StatusCode; + use http_body_util::BodyStream; use tower::BoxError; use tower::ServiceBuilder; + use tower::ServiceExt; use tower_service::Service; use crate::plugins::limits::layer::BodyLimitControl; use crate::plugins::limits::layer::RequestBodyLimitLayer; - use crate::services; #[tokio::test] async fn test_body_content_length_limit_exceeded() { - let control = BodyLimitControl::new(10); let mut service = ServiceBuilder::new() - .layer(RequestBodyLimitLayer::new(control.clone())) + .layer(RequestBodyLimitLayer::new(10)) .service_fn(|r: http::Request<_>| async move { - services::http::body_stream::BodyStream::new(r.into_body()) - .collect::>() - .await; + BodyStream::new(r.into_body()).collect::>().await; panic!("should have rejected request"); }); let resp: Result, BoxError> = service + .ready() + .await + .unwrap() .call(http::Request::new("This is a test".to_string())) .await; assert!(resp.is_err()); @@ -251,19 +261,21 @@ mod test { #[tokio::test] async fn test_body_content_length_limit_ok() { - let control = BodyLimitControl::new(10); let mut service = ServiceBuilder::new() - .layer(RequestBodyLimitLayer::new(control.clone())) + .layer(RequestBodyLimitLayer::new(10)) .service_fn(|r: http::Request<_>| async move { - services::http::body_stream::BodyStream::new(r.into_body()) - .collect::>() - .await; + BodyStream::new(r.into_body()).collect::>().await; Ok(http::Response::builder() .status(StatusCode::OK) .body("This is a test".to_string()) .unwrap()) }); - let resp: Result<_, BoxError> = service.call(http::Request::new("OK".to_string())).await; + let resp: Result<_, BoxError> = service + .ready() + .await + .unwrap() + .call(http::Request::new("OK".to_string())) + .await; assert!(resp.is_ok()); let resp = resp.unwrap(); @@ -273,16 +285,16 @@ mod test { #[tokio::test] async fn test_header_content_length_limit_exceeded() { - let control = BodyLimitControl::new(10); let mut service = ServiceBuilder::new() - .layer(RequestBodyLimitLayer::new(control.clone())) + .layer(RequestBodyLimitLayer::new(10)) .service_fn(|r: http::Request<_>| async move { - services::http::body_stream::BodyStream::new(r.into_body()) - .collect::>() - .await; + BodyStream::new(r.into_body()).collect::>().await; panic!("should have rejected request"); }); let resp: Result, BoxError> = service + .ready() + .await + .unwrap() .call( http::Request::builder() .header("Content-Length", "100") @@ -295,19 +307,19 @@ mod test { #[tokio::test] async fn test_header_content_length_limit_ok() { - let control = BodyLimitControl::new(10); let mut service = ServiceBuilder::new() - .layer(RequestBodyLimitLayer::new(control.clone())) + .layer(RequestBodyLimitLayer::new(10)) .service_fn(|r: http::Request<_>| async move { - services::http::body_stream::BodyStream::new(r.into_body()) - .collect::>() - .await; + BodyStream::new(r.into_body()).collect::>().await; Ok(http::Response::builder() .status(StatusCode::OK) .body("This is a test".to_string()) .unwrap()) }); let resp: Result<_, BoxError> = service + .ready() + .await + .unwrap() .call( http::Request::builder() .header("Content-Length", "5") @@ -323,16 +335,16 @@ mod test { #[tokio::test] async fn test_limits_dynamic_update() { - let control = BodyLimitControl::new(10); let mut service = ServiceBuilder::new() - .layer(RequestBodyLimitLayer::new(control.clone())) + .layer(RequestBodyLimitLayer::new(10)) .service_fn(move |r: http::Request<_>| { - let control = control.clone(); + //Update the limit before we start reading the stream + r.extensions() + .get::() + .expect("cody limit must have been added to extensions") + .update_limit(100); async move { - services::http::body_stream::BodyStream::new(r.into_body()) - .collect::>() - .await; - control.update_limit(100); + BodyStream::new(r.into_body()).collect::>().await; Ok(http::Response::builder() .status(StatusCode::OK) .body("This is a test".to_string()) @@ -340,26 +352,29 @@ mod test { } }); let resp: Result<_, BoxError> = service + .ready() + .await + .unwrap() .call(http::Request::new("This is a test".to_string())) .await; - assert!(resp.is_err()); + assert!(resp.is_ok()); } #[tokio::test] async fn test_body_length_exceeds_content_length() { - let control = BodyLimitControl::new(10); let mut service = ServiceBuilder::new() - .layer(RequestBodyLimitLayer::new(control.clone())) + .layer(RequestBodyLimitLayer::new(10)) .service_fn(|r: http::Request<_>| async move { - services::http::body_stream::BodyStream::new(r.into_body()) - .collect::>() - .await; + BodyStream::new(r.into_body()).collect::>().await; Ok(http::Response::builder() .status(StatusCode::OK) .body("This is a test".to_string()) .unwrap()) }); let resp: Result<_, BoxError> = service + .ready() + .await + .unwrap() .call( http::Request::builder() .header("Content-Length", "5") @@ -373,4 +388,31 @@ mod test { assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.into_body(), "This is a test"); } + + #[tokio::test] + async fn test_body_content_length_service_reuse() { + let mut service = ServiceBuilder::new() + .layer(RequestBodyLimitLayer::new(10)) + .service_fn(|r: http::Request<_>| async move { + BodyStream::new(r.into_body()).collect::>().await; + Ok(http::Response::builder() + .status(StatusCode::OK) + .body("This is a test".to_string()) + .unwrap()) + }); + + for _ in 0..10 { + let resp: Result<_, BoxError> = service + .ready() + .await + .unwrap() + .call(http::Request::new("OK".to_string())) + .await; + + assert!(resp.is_ok()); + let resp = resp.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.into_body(), "This is a test"); + } + } } diff --git a/apollo-router/src/plugins/limits/limited.rs b/apollo-router/src/plugins/limits/limited.rs index 54a632d93e..ce78a8a94b 100644 --- a/apollo-router/src/plugins/limits/limited.rs +++ b/apollo-router/src/plugins/limits/limited.rs @@ -3,12 +3,11 @@ use std::task::Context; use std::task::Poll; use bytes::Buf; -use http::HeaderMap; use http_body::SizeHint; use pin_project_lite::pin_project; use tokio::sync::OwnedSemaphorePermit; -use crate::plugins::limits::layer::BodyLimitControl; +use super::layer::BodyLimitControl; pin_project! { /// An implementation of http_body::Body that limits the number of bytes read from the inner body. @@ -72,25 +71,28 @@ where type Data = Body::Data; type Error = Body::Error; - fn poll_data( + fn poll_frame( self: Pin<&mut Self>, cx: &mut Context<'_>, - ) -> Poll>> { + ) -> Poll, Self::Error>>> { let mut this = self.project(); - let res = match this.inner.poll_data(cx) { + let res = match this.inner.poll_frame(cx) { Poll::Pending => return Poll::Pending, Poll::Ready(None) => None, - Poll::Ready(Some(Ok(data))) => { + Poll::Ready(Some(Ok(frame))) => { + let Some(data) = frame.data_ref() else { + return Poll::Ready(Some(Ok(frame))); + }; + if data.remaining() > this.control.remaining() { // This is the difference between http_body::Limited and our implementation. // Dropping this mutex allows the containing layer to immediately return an error response // This prevents the need to deal with wrapped errors. - this.control.update_limit(0); this.permit.release(); return Poll::Pending; } else { this.control.increment(data.remaining()); - Some(Ok(data)) + Some(Ok(frame)) } } Poll::Ready(Some(Err(err))) => Some(Err(err)), @@ -99,22 +101,10 @@ where Poll::Ready(res) } - fn poll_trailers( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll, Self::Error>> { - let this = self.project(); - let res = match this.inner.poll_trailers(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Ok(data)) => Ok(data), - Poll::Ready(Err(err)) => Err(err), - }; - - Poll::Ready(res) - } fn is_end_stream(&self) -> bool { self.inner.is_end_stream() } + fn size_hint(&self) -> SizeHint { match u64::try_from(self.control.remaining()) { Ok(n) => { @@ -138,24 +128,32 @@ mod test { use std::pin::Pin; use std::sync::Arc; + use bytes::Bytes; use http_body::Body; use tower::BoxError; use crate::plugins::limits::layer::BodyLimitControl; + use crate::services::router::body; #[test] fn test_completes() { let control = BodyLimitControl::new(100); let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); let lock = semaphore.clone().try_acquire_owned().unwrap(); - let mut limited = super::Limited::new("test".to_string(), control, lock); + let mut limited = super::Limited::new(body::from_bytes("test".to_string()), control, lock); + + match Pin::new(&mut limited).poll_frame(&mut std::task::Context::from_waker( + &futures::task::noop_waker(), + )) { + std::task::Poll::Ready(Some(Ok(data))) => { + let data = data.into_data().unwrap().to_vec(); + let content = String::from_utf8_lossy(data.as_slice()); + assert_eq!(&content, "test"); + } + std::task::Poll::Pending => panic!("it should be ready"), + _ => panic!("the data returned is incorrect"), + } - assert_eq!( - Pin::new(&mut limited).poll_data(&mut std::task::Context::from_waker( - &futures::task::noop_waker() - )), - std::task::Poll::Ready(Some(Ok("test".to_string().into_bytes().into()))) - ); assert!(semaphore.try_acquire().is_err()); // We need to assert that if the stream is dropped the semaphore isn't released. @@ -171,12 +169,12 @@ mod test { let lock = semaphore.clone().try_acquire_owned().unwrap(); let mut limited = super::Limited::new("test".to_string(), control, lock); - assert_eq!( - Pin::new(&mut limited).poll_data(&mut std::task::Context::from_waker( - &futures::task::noop_waker() - )), - std::task::Poll::Pending - ); + match Pin::new(&mut limited).poll_frame(&mut std::task::Context::from_waker( + &futures::task::noop_waker(), + )) { + std::task::Poll::Pending => {} + std::task::Poll::Ready(_) => panic!("it should be pending"), + } assert!(semaphore.try_acquire().is_ok()) } @@ -187,26 +185,28 @@ mod test { let lock = semaphore.clone().try_acquire_owned().unwrap(); let mut limited = super::Limited::new( - hyper::Body::wrap_stream(futures::stream::iter(vec![ - Ok::<&str, BoxError>("hello"), - Ok("world"), + body::from_result_stream(futures::stream::iter(vec![ + Ok::("hello".into()), + Ok("world".into()), ])), control, lock, ); - assert!(matches!( - Pin::new(&mut limited).poll_data(&mut std::task::Context::from_waker( - &futures::task::noop_waker() - )), - std::task::Poll::Ready(Some(Ok(_))) - )); + match Pin::new(&mut limited).poll_frame(&mut std::task::Context::from_waker( + &futures::task::noop_waker(), + )) { + std::task::Poll::Ready(Some(Ok(_))) => {} + _ => panic!("it should be ready with Some(Ok(_)"), + } assert!(semaphore.try_acquire().is_err()); - assert!(matches!( - Pin::new(&mut limited).poll_data(&mut std::task::Context::from_waker( - &futures::task::noop_waker() - )), - std::task::Poll::Pending - )); + if Pin::new(&mut limited) + .poll_frame(&mut std::task::Context::from_waker( + &futures::task::noop_waker(), + )) + .is_ready() + { + panic!("it should be pending"); + } assert!(semaphore.try_acquire().is_ok()); } } diff --git a/apollo-router/src/plugins/limits/mod.rs b/apollo-router/src/plugins/limits/mod.rs index ec041a1c02..fd7af04f56 100644 --- a/apollo-router/src/plugins/limits/mod.rs +++ b/apollo-router/src/plugins/limits/mod.rs @@ -13,16 +13,15 @@ use tower::BoxError; use tower::ServiceBuilder; use tower::ServiceExt; +use crate::Context; use crate::graphql; use crate::layers::ServiceBuilderExt; use crate::plugin::Plugin; use crate::plugin::PluginInit; -use crate::plugins::limits::layer::BodyLimitControl; use crate::plugins::limits::layer::BodyLimitError; use crate::plugins::limits::layer::RequestBodyLimitLayer; use crate::services::router; use crate::services::router::BoxService; -use crate::Context; /// Configuration for operation limits, parser limits, HTTP limits, etc. #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] @@ -115,6 +114,13 @@ pub(crate) struct Config { /// Default is ~400kib. #[schemars(with = "Option", default)] pub(crate) http1_max_request_buf_size: Option, + + /// Limit the depth of nested list fields in introspection queries + /// to protect avoid generating huge responses. Returns a GraphQL + /// error with `{ message: "Maximum introspection depth exceeded" }` + /// when nested fields exceed the limit. + /// Default: true + pub(crate) introspection_max_depth: bool, } impl Default for Config { @@ -135,6 +141,8 @@ impl Default for Config { // but is still very high for "reasonable" queries. // https://github.com/apollographql/apollo-rs/blob/apollo-parser%400.7.3/crates/apollo-parser/src/parser/mod.rs#L93-L104 parser_max_recursion: 500, + + introspection_max_depth: true, } } } @@ -157,16 +165,7 @@ impl Plugin for LimitsPlugin { } fn router_service(&self, service: BoxService) -> BoxService { - let control = BodyLimitControl::new(self.config.http_max_request_bytes); - let control_for_context = control.clone(); ServiceBuilder::new() - .map_request(move |r: router::Request| { - let control_for_context = control_for_context.clone(); - r.context - .extensions() - .with_lock(|mut lock| lock.insert(control_for_context)); - r - }) .map_future_with_request_data( |r: &router::Request| r.context.clone(), |ctx, f| async { Self::map_error_to_graphql(f.await, ctx) }, @@ -174,7 +173,9 @@ impl Plugin for LimitsPlugin { // Here we need to convert to and from the underlying http request types so that we can use existing middleware. .map_request(Into::into) .map_response(Into::into) - .layer(RequestBodyLimitLayer::new(control)) + .layer(RequestBodyLimitLayer::new( + self.config.http_max_request_bytes, + )) .map_request(Into::into) .map_response(Into::into) .service(service) @@ -194,7 +195,6 @@ impl LimitsPlugin { match resp { Ok(r) => { if r.response.status() == StatusCode::PAYLOAD_TOO_LARGE { - Self::increment_legacy_metric(); Ok(BodyLimitError::PayloadTooLarge.into_response(ctx)) } else { Ok(r) @@ -209,26 +209,11 @@ impl LimitsPlugin { match root_cause.downcast_ref::() { None => Err(e), - Some(_) => { - Self::increment_legacy_metric(); - Ok(BodyLimitError::PayloadTooLarge.into_response(ctx)) - } + Some(_) => Ok(BodyLimitError::PayloadTooLarge.into_response(ctx)), } } } } - - fn increment_legacy_metric() { - // Remove this eventually - // This is already handled by the telemetry plugin via the http.server.request metric. - u64_counter!( - "apollo_router_http_requests_total", - "Total number of HTTP requests made.", - 1, - status = StatusCode::PAYLOAD_TOO_LARGE.as_u16() as i64, - error = BodyLimitError::PayloadTooLarge.to_string() - ); - } } impl BodyLimitError { @@ -257,26 +242,25 @@ mod test { use http::StatusCode; use tower::BoxError; - use crate::plugins::limits::layer::BodyLimitControl; use crate::plugins::limits::LimitsPlugin; + use crate::plugins::limits::layer::BodyLimitControl; use crate::plugins::test::PluginTestHarness; use crate::services::router; - use crate::services::router::body::get_body_bytes; #[tokio::test] async fn test_body_content_length_limit_exceeded() { let plugin = plugin().await; let resp = plugin - .call_router( + .router_service(|r| async { + let body = r.router_request.into_body(); + let _ = router::body::into_bytes(body).await?; + panic!("should have failed to read stream") + }) + .call( router::Request::fake_builder() - .body("This is a test") + .body(router::body::from_bytes("This is a test")) .build() .unwrap(), - |r| async { - let body = r.router_request.into_body(); - let _ = get_body_bytes(body).await?; - panic!("should have failed to read stream") - }, ) .await; assert!(resp.is_ok()); @@ -284,7 +268,7 @@ mod test { assert_eq!(resp.response.status(), StatusCode::PAYLOAD_TOO_LARGE); assert_eq!( String::from_utf8( - get_body_bytes(resp.response.into_body()) + router::body::into_bytes(resp.response.into_body()) .await .unwrap() .to_vec() @@ -298,14 +282,17 @@ mod test { async fn test_body_content_length_limit_ok() { let plugin = plugin().await; let resp = plugin - .call_router( - router::Request::fake_builder().body("").build().unwrap(), - |r| async { - let body = r.router_request.into_body(); - let body = get_body_bytes(body).await; - assert!(body.is_ok()); - Ok(router::Response::fake_builder().build().unwrap()) - }, + .router_service(|r| async { + let body = r.router_request.into_body(); + let body = router::body::into_bytes(body).await; + assert!(body.is_ok()); + Ok(router::Response::fake_builder().build().unwrap()) + }) + .call( + router::Request::fake_builder() + .body(router::body::empty()) + .build() + .unwrap(), ) .await; @@ -314,7 +301,7 @@ mod test { assert_eq!(resp.response.status(), StatusCode::OK); assert_eq!( String::from_utf8( - get_body_bytes(resp.response.into_body()) + router::body::into_bytes(resp.response.into_body()) .await .unwrap() .to_vec() @@ -328,13 +315,13 @@ mod test { async fn test_header_content_length_limit_exceeded() { let plugin = plugin().await; let resp = plugin - .call_router( + .router_service(|_| async { panic!("should have rejected request") }) + .call( router::Request::fake_builder() .header("Content-Length", "100") - .body("") + .body(router::body::empty()) .build() .unwrap(), - |_| async { panic!("should have rejected request") }, ) .await; assert!(resp.is_ok()); @@ -342,7 +329,7 @@ mod test { assert_eq!(resp.response.status(), StatusCode::PAYLOAD_TOO_LARGE); assert_eq!( String::from_utf8( - get_body_bytes(resp.response.into_body()) + router::body::into_bytes(resp.response.into_body()) .await .unwrap() .to_vec() @@ -356,13 +343,13 @@ mod test { async fn test_header_content_length_limit_ok() { let plugin = plugin().await; let resp = plugin - .call_router( + .router_service(|_| async { Ok(router::Response::fake_builder().build().unwrap()) }) + .call( router::Request::fake_builder() .header("Content-Length", "5") - .body("") + .body(router::body::empty()) .build() .unwrap(), - |_| async { Ok(router::Response::fake_builder().build().unwrap()) }, ) .await; assert!(resp.is_ok()); @@ -370,7 +357,7 @@ mod test { assert_eq!(resp.response.status(), StatusCode::OK); assert_eq!( String::from_utf8( - get_body_bytes(resp.response.into_body()) + router::body::into_bytes(resp.response.into_body()) .await .unwrap() .to_vec() @@ -385,9 +372,12 @@ mod test { // We should not be translating errors that are not limit errors into graphql errors let plugin = plugin().await; let resp = plugin - .call_router( - router::Request::fake_builder().body("").build().unwrap(), - |_| async { Err(BoxError::from("error")) }, + .router_service(|_| async { Err(BoxError::from("error")) }) + .call( + router::Request::fake_builder() + .body(router::body::empty()) + .build() + .unwrap(), ) .await; assert!(resp.is_err()); @@ -397,31 +387,31 @@ mod test { async fn test_limits_dynamic_update() { let plugin = plugin().await; let resp = plugin - .call_router( + .router_service(|mut r: router::Request| async move { + // Before we go for the body, we'll update the limit + let control = r + .router_request + .extensions_mut() + .get::() + .expect("body limit control must have been set") + .clone(); + + assert_eq!(control.remaining(), 10); + assert_eq!(control.limit(), 10); + control.update_limit(100); + + let body = r.router_request.into_body(); + let _ = router::body::into_bytes(body).await?; + + // Now let's check progress + assert_eq!(control.remaining(), 86); + Ok(router::Response::fake_builder().build().unwrap()) + }) + .call( router::Request::fake_builder() - .body("This is a test") + .body(router::body::from_bytes("This is a test")) .build() .unwrap(), - |r| async move { - // Before we go for the body, we'll update the limit - r.context.extensions().with_lock(|lock| { - let control: &BodyLimitControl = - lock.get().expect("mut have body limit control"); - assert_eq!(control.remaining(), 10); - assert_eq!(control.limit(), 10); - control.update_limit(100); - }); - let body = r.router_request.into_body(); - let _ = get_body_bytes(body).await?; - - // Now let's check progress - r.context.extensions().with_lock(|lock| { - let control: &BodyLimitControl = - lock.get().expect("mut have body limit control"); - assert_eq!(control.remaining(), 86); - }); - Ok(router::Response::fake_builder().build().unwrap()) - }, ) .await; assert!(resp.is_ok()); @@ -429,7 +419,7 @@ mod test { assert_eq!(resp.response.status(), StatusCode::OK); assert_eq!( String::from_utf8( - get_body_bytes(resp.response.into_body()) + router::body::into_bytes(resp.response.into_body()) .await .unwrap() .to_vec() @@ -440,11 +430,11 @@ mod test { } async fn plugin() -> PluginTestHarness { - let plugin: PluginTestHarness = PluginTestHarness::new( - Some(include_str!("fixtures/content_length_limit.router.yaml")), - None, - ) - .await; + let plugin: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!("fixtures/content_length_limit.router.yaml")) + .build() + .await + .expect("test harness"); plugin } } diff --git a/apollo-router/src/plugins/mock_subgraphs/execution/engine.rs b/apollo-router/src/plugins/mock_subgraphs/execution/engine.rs new file mode 100644 index 0000000000..3d3ec2d555 --- /dev/null +++ b/apollo-router/src/plugins/mock_subgraphs/execution/engine.rs @@ -0,0 +1,331 @@ +use std::cell::RefCell; + +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Name; +use apollo_compiler::Schema; +use apollo_compiler::ast::Value; +use apollo_compiler::collections::HashSet; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::executable::Field; +use apollo_compiler::executable::Selection; +use apollo_compiler::parser::SourceMap; +use apollo_compiler::parser::SourceSpan; +use apollo_compiler::response::GraphQLError; +use apollo_compiler::response::JsonMap; +use apollo_compiler::response::JsonValue; +use apollo_compiler::response::ResponseDataPathSegment; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::FieldDefinition; +use apollo_compiler::schema::ObjectType; +use apollo_compiler::schema::Type; +use apollo_compiler::validation::Valid; + +use super::input_coercion::coerce_argument_values; +use super::resolver::ObjectValue; +use super::result_coercion::complete_value; +use super::validation::SuspectedValidationBug; + +/// +#[derive(Debug, Copy, Clone)] +pub(crate) enum ExecutionMode { + /// Allowed to resolve fields in any order, including in parallel + Normal, + /// Top-level fields of a mutation operation must be executed in order + #[allow(unused)] + Sequential, +} + +/// Return in `Err` when a field error occurred at some non-nullable place +/// +/// +pub(crate) struct PropagateNull; + +/// Linked-list version of `Vec`, taking advantage of the call stack +pub(crate) type LinkedPath<'a> = Option<&'a LinkedPathElement<'a>>; + +pub(crate) struct LinkedPathElement<'a> { + pub(crate) element: ResponseDataPathSegment, + pub(crate) next: LinkedPath<'a>, +} + +/// +#[allow(clippy::too_many_arguments)] // yes it’s not a nice API but it’s internal +pub(crate) fn execute_selection_set<'a>( + schema: &Valid, + document: &'a Valid, + variable_values: &Valid, + errors: &mut Vec, + response_extensions: &RefCell, + path: LinkedPath<'_>, + mode: ExecutionMode, + object_type: &ObjectType, + object_value: &ObjectValue<'_>, + selections: impl IntoIterator, +) -> Result { + let mut grouped_field_set = IndexMap::default(); + collect_fields( + schema, + document, + variable_values, + object_type, + object_value, + selections, + &mut HashSet::default(), + &mut grouped_field_set, + ); + + match mode { + ExecutionMode::Normal => {} + ExecutionMode::Sequential => { + // If we want parallelism, use `futures::future::join_all` (async) + // or Rayon’s `par_iter` (sync) here. + } + } + + let mut response_map = JsonMap::with_capacity(grouped_field_set.len()); + for (&response_key, fields) in &grouped_field_set { + // Indexing should not panic: `collect_fields` only creates a `Vec` to push to it + let field_name = &fields[0].name; + let Ok(field_def) = schema.type_field(&object_type.name, field_name) else { + // TODO: Return a `validation_bug`` field error here? + // The spec specifically has a “If fieldType is defined” condition, + // but it being undefined would make the request invalid, right? + continue; + }; + let value = if field_name == "__typename" { + JsonValue::from(object_type.name.as_str()) + } else { + let field_path = LinkedPathElement { + element: ResponseDataPathSegment::Field(response_key.clone()), + next: path, + }; + execute_field( + schema, + document, + variable_values, + errors, + response_extensions, + Some(&field_path), + mode, + object_value, + field_def, + fields, + )? + }; + response_map.insert(response_key.as_str(), value); + } + Ok(response_map) +} + +/// +#[allow(clippy::too_many_arguments)] // yes it’s not a nice API but it’s internal +fn collect_fields<'a>( + schema: &Schema, + document: &'a ExecutableDocument, + variable_values: &Valid, + object_type: &ObjectType, + object_value: &ObjectValue<'_>, + selections: impl IntoIterator, + visited_fragments: &mut HashSet<&'a Name>, + grouped_fields: &mut IndexMap<&'a Name, Vec<&'a Field>>, +) { + for selection in selections { + if eval_if_arg(selection, "skip", variable_values).unwrap_or(false) + || !eval_if_arg(selection, "include", variable_values).unwrap_or(true) + { + continue; + } + match selection { + Selection::Field(field) => { + if !object_value.skip_field(&field.name) { + grouped_fields + .entry(field.response_key()) + .or_default() + .push(field.as_ref()) + } + } + Selection::FragmentSpread(spread) => { + let new = visited_fragments.insert(&spread.fragment_name); + if !new { + continue; + } + let Some(fragment) = document.fragments.get(&spread.fragment_name) else { + continue; + }; + if !does_fragment_type_apply(schema, object_type, fragment.type_condition()) { + continue; + } + collect_fields( + schema, + document, + variable_values, + object_type, + object_value, + &fragment.selection_set.selections, + visited_fragments, + grouped_fields, + ) + } + Selection::InlineFragment(inline) => { + if let Some(condition) = &inline.type_condition + && !does_fragment_type_apply(schema, object_type, condition) + { + continue; + } + collect_fields( + schema, + document, + variable_values, + object_type, + object_value, + &inline.selection_set.selections, + visited_fragments, + grouped_fields, + ) + } + } + } +} + +/// +fn does_fragment_type_apply( + schema: &Schema, + object_type: &ObjectType, + fragment_type: &Name, +) -> bool { + match schema.types.get(fragment_type) { + Some(ExtendedType::Object(_)) => *fragment_type == object_type.name, + Some(ExtendedType::Interface(_)) => { + object_type.implements_interfaces.contains(fragment_type) + } + Some(ExtendedType::Union(def)) => def.members.contains(&object_type.name), + // Undefined or not an output type: validation should have caught this + _ => false, + } +} + +fn eval_if_arg( + selection: &Selection, + directive_name: &str, + variable_values: &Valid, +) -> Option { + match selection + .directives() + .get(directive_name)? + .specified_argument_by_name("if")? + .as_ref() + { + Value::Boolean(value) => Some(*value), + Value::Variable(var) => variable_values.get(var.as_str())?.as_bool(), + _ => None, + } +} + +/// +#[allow(clippy::too_many_arguments)] // yes it’s not a nice API but it’s internal +fn execute_field( + schema: &Valid, + document: &Valid, + variable_values: &Valid, + errors: &mut Vec, + response_extensions: &RefCell, + path: LinkedPath<'_>, + mode: ExecutionMode, + object_value: &ObjectValue<'_>, + field_def: &FieldDefinition, + fields: &[&Field], +) -> Result { + let field = fields[0]; + let argument_values = match coerce_argument_values( + schema, + document, + variable_values, + errors, + path, + field_def, + field, + ) { + Ok(argument_values) => argument_values, + Err(PropagateNull) => return try_nullify(&field_def.ty, Err(PropagateNull)), + }; + let resolved_result = + object_value.resolve_field(response_extensions, &field.name, &argument_values); + let completed_result = match resolved_result { + Ok(resolved) => complete_value( + schema, + document, + variable_values, + errors, + response_extensions, + path, + mode, + field.ty(), + resolved, + fields, + ), + Err(message) => { + errors.push(field_error( + format!("resolver error: {message}"), + path, + field.name.location(), + &document.sources, + )); + Err(PropagateNull) + } + }; + try_nullify(&field_def.ty, completed_result) +} + +/// Try to insert a propagated null if possible, or keep propagating it. +/// +/// +pub(crate) fn try_nullify( + ty: &Type, + result: Result, +) -> Result { + match result { + Ok(json) => Ok(json), + Err(PropagateNull) => { + if ty.is_non_null() { + Err(PropagateNull) + } else { + Ok(JsonValue::Null) + } + } + } +} + +pub(crate) fn path_to_vec(mut link: LinkedPath<'_>) -> Vec { + let mut path = Vec::new(); + while let Some(node) = link { + path.push(node.element.clone()); + link = node.next; + } + path.reverse(); + path +} + +pub(crate) fn field_error( + message: impl Into, + path: LinkedPath<'_>, + location: Option, + sources: &SourceMap, +) -> GraphQLError { + let mut err = GraphQLError::new(message, location, sources); + err.path = path_to_vec(path); + err +} + +impl SuspectedValidationBug { + pub(crate) fn into_field_error( + self, + sources: &SourceMap, + path: LinkedPath<'_>, + ) -> GraphQLError { + let Self { message, location } = self; + let mut err = field_error(message, path, location, sources); + err.extensions + .insert("APOLLO_SUSPECTED_VALIDATION_BUG", true.into()); + err + } +} diff --git a/apollo-router/src/plugins/mock_subgraphs/execution/input_coercion.rs b/apollo-router/src/plugins/mock_subgraphs/execution/input_coercion.rs new file mode 100644 index 0000000000..8833e69d4f --- /dev/null +++ b/apollo-router/src/plugins/mock_subgraphs/execution/input_coercion.rs @@ -0,0 +1,342 @@ +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Node; +use apollo_compiler::Schema; +use apollo_compiler::ast::Type; +use apollo_compiler::ast::Value; +use apollo_compiler::collections::HashMap; +use apollo_compiler::executable::Field; +use apollo_compiler::parser::SourceMap; +use apollo_compiler::parser::SourceSpan; +use apollo_compiler::response::GraphQLError; +use apollo_compiler::response::JsonMap; +use apollo_compiler::response::JsonValue; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::FieldDefinition; +use apollo_compiler::validation::Valid; + +use super::engine::LinkedPath; +use super::engine::PropagateNull; +use super::engine::field_error; +use super::validation::SuspectedValidationBug; + +#[derive(Debug, Clone)] +pub(crate) enum InputCoercionError { + SuspectedValidationBug(SuspectedValidationBug), + // TODO: split into more structured variants? + ValueError { + message: String, + location: Option, + }, +} + +fn graphql_value_to_json( + kind: &str, + parent: &str, + sep: &str, + name: &str, + value: &Node, +) -> Result { + match value.as_ref() { + Value::Null => Ok(JsonValue::Null), + Value::Variable(_) => { + // TODO: separate `ContValue` enum without this variant? + Err(InputCoercionError::SuspectedValidationBug( + SuspectedValidationBug { + message: format!("Variable in default value of {kind} {parent}{sep}{name}."), + location: value.location(), + }, + )) + } + Value::Enum(value) => Ok(value.as_str().into()), + Value::String(value) => Ok(value.as_str().into()), + Value::Boolean(value) => Ok((*value).into()), + // Rely on `serde_json::Number`’s own parser to use whatever precision it supports + Value::Int(i) => Ok(JsonValue::Number(i.as_str().parse().map_err(|_| { + InputCoercionError::ValueError { + message: format!("IntValue overflow in {kind} {parent}{sep}{name}"), + location: value.location(), + } + })?)), + Value::Float(f) => Ok(JsonValue::Number(f.as_str().parse().map_err(|_| { + InputCoercionError::ValueError { + message: format!("FloatValue overflow in {kind} {parent}{sep}{name}"), + location: value.location(), + } + })?)), + Value::List(value) => value + .iter() + .map(|value| graphql_value_to_json(kind, parent, sep, name, value)) + .collect(), + Value::Object(value) => value + .iter() + .map(|(key, value)| { + Ok(( + key.as_str(), + graphql_value_to_json(kind, parent, sep, name, value)?, + )) + }) + .collect(), + } +} + +/// +pub(crate) fn coerce_argument_values( + schema: &Schema, + document: &Valid, + variable_values: &Valid, + errors: &mut Vec, + path: LinkedPath<'_>, + field_def: &FieldDefinition, + field: &Field, +) -> Result { + let mut coerced_values = JsonMap::new(); + for arg_def in &field_def.arguments { + let arg_name = &arg_def.name; + if let Some(arg) = field.arguments.iter().find(|arg| arg.name == *arg_name) { + if let Value::Variable(var_name) = arg.value.as_ref() { + if let Some(var_value) = variable_values.get(var_name.as_str()) { + if var_value.is_null() && arg_def.ty.is_non_null() { + errors.push(field_error( + format!("null value for non-nullable argument {arg_name}"), + path, + arg_def.location(), + &document.sources, + )); + return Err(PropagateNull); + } else { + coerced_values.insert(arg_name.as_str(), var_value.clone()); + continue; + } + } + } else if arg.value.is_null() && arg_def.ty.is_non_null() { + errors.push(field_error( + format!("null value for non-nullable argument {arg_name}"), + path, + arg_def.location(), + &document.sources, + )); + return Err(PropagateNull); + } else { + let coerced_value = coerce_argument_value( + schema, + document, + variable_values, + errors, + path, + "argument", + "", + "", + arg_name, + &arg_def.ty, + &arg.value, + )?; + coerced_values.insert(arg_name.as_str(), coerced_value); + continue; + } + } + if let Some(default) = &arg_def.default_value { + let value = + graphql_value_to_json("argument", "", "", arg_name, default).map_err(|err| { + errors.push(err.into_field_error(path, &document.sources)); + PropagateNull + })?; + coerced_values.insert(arg_def.name.as_str(), value); + continue; + } + if arg_def.ty.is_non_null() { + errors.push(field_error( + format!("missing value for required argument {arg_name}"), + path, + arg_def.location(), + &document.sources, + )); + return Err(PropagateNull); + } + } + Ok(coerced_values) +} + +#[allow(clippy::too_many_arguments)] // yes it’s not a nice API but it’s internal +fn coerce_argument_value( + schema: &Schema, + document: &Valid, + variable_values: &Valid, + errors: &mut Vec, + path: LinkedPath<'_>, + kind: &str, + parent: &str, + sep: &str, + name: &str, + ty: &Type, + value: &Node, +) -> Result { + if value.is_null() { + if ty.is_non_null() { + errors.push(field_error( + format!("null value for non-null {kind} {parent}{sep}{name}"), + path, + value.location(), + &document.sources, + )); + return Err(PropagateNull); + } else { + return Ok(JsonValue::Null); + } + } + if let Some(var_name) = value.as_variable() { + if let Some(var_value) = variable_values.get(var_name.as_str()) { + if var_value.is_null() && ty.is_non_null() { + errors.push(field_error( + format!("null variable value for non-null {kind} {parent}{sep}{name}"), + path, + value.location(), + &document.sources, + )); + return Err(PropagateNull); + } else { + return Ok(var_value.clone()); + } + } else if ty.is_non_null() { + errors.push(field_error( + format!("missing variable for non-null {kind} {parent}{sep}{name}"), + path, + value.location(), + &document.sources, + )); + return Err(PropagateNull); + } else { + return Ok(JsonValue::Null); + } + } + let ty_name = match ty { + Type::List(inner_ty) | Type::NonNullList(inner_ty) => { + // https://spec.graphql.org/October2021/#sec-List.Input-Coercion + return value + .as_list() + // If not an array, treat the value as an array of size one: + .unwrap_or(std::slice::from_ref(value)) + .iter() + .map(|item| { + coerce_argument_value( + schema, + document, + variable_values, + errors, + path, + kind, + parent, + sep, + name, + inner_ty, + item, + ) + }) + .collect(); + } + Type::Named(ty_name) | Type::NonNullNamed(ty_name) => ty_name, + }; + let Some(ty_def) = schema.types.get(ty_name) else { + errors.push( + SuspectedValidationBug { + message: format!("Undefined type {ty_name} for {kind} {parent}{sep}{name}"), + location: value.location(), + } + .into_field_error(&document.sources, path), + ); + return Err(PropagateNull); + }; + match ty_def { + ExtendedType::InputObject(ty_def) => { + // https://spec.graphql.org/October2021/#sec-Input-Objects.Input-Coercion + if let Some(object) = value.as_object() { + if let Some((key, _value)) = object + .iter() + .find(|(key, _value)| !ty_def.fields.contains_key(key)) + { + errors.push(field_error( + format!("Input object has key {key} not in type {ty_name}",), + path, + value.location(), + &document.sources, + )); + return Err(PropagateNull); + } + #[allow(clippy::map_identity)] // `map` converts `&(k, v)` to `(&k, &v)` + let object: HashMap<_, _> = object.iter().map(|(k, v)| (k, v)).collect(); + let mut coerced_object = JsonMap::new(); + for (field_name, field_def) in &ty_def.fields { + if let Some(field_value) = object.get(field_name) { + let coerced_value = coerce_argument_value( + schema, + document, + variable_values, + errors, + path, + "input field", + ty_name, + ".", + field_name, + &field_def.ty, + field_value, + )?; + coerced_object.insert(field_name.as_str(), coerced_value); + } else if let Some(default) = &field_def.default_value { + let default = + graphql_value_to_json("input field", ty_name, ".", field_name, default) + .map_err(|err| { + errors.push(err.into_field_error(path, &document.sources)); + PropagateNull + })?; + coerced_object.insert(field_name.as_str(), default); + } else if field_def.ty.is_non_null() { + errors.push(field_error( + format!( + "Missing value for non-null input object field {ty_name}.{field_name}" + ), + path, + value.location(), + &document.sources, + )); + return Err(PropagateNull); + } else { + // Field not required + } + } + return Ok(coerced_object.into()); + } + } + _ => { + // For scalar and enums, rely and validation and just convert between Rust types + return graphql_value_to_json(kind, parent, sep, name, value).map_err(|err| { + errors.push(err.into_field_error(path, &document.sources)); + PropagateNull + }); + } + } + errors.push(field_error( + format!("Could not coerce {kind} {parent}{sep}{name}: {value} to type {ty_name}"), + path, + value.location(), + &document.sources, + )); + Err(PropagateNull) +} + +impl From for InputCoercionError { + fn from(value: SuspectedValidationBug) -> Self { + Self::SuspectedValidationBug(value) + } +} + +impl InputCoercionError { + pub(crate) fn into_field_error( + self, + path: LinkedPath<'_>, + sources: &SourceMap, + ) -> GraphQLError { + match self { + Self::SuspectedValidationBug(s) => s.into_field_error(sources, path), + Self::ValueError { message, location } => field_error(message, path, location, sources), + } + } +} diff --git a/apollo-router/src/plugins/mock_subgraphs/execution/mod.rs b/apollo-router/src/plugins/mock_subgraphs/execution/mod.rs new file mode 100644 index 0000000000..eed332f447 --- /dev/null +++ b/apollo-router/src/plugins/mock_subgraphs/execution/mod.rs @@ -0,0 +1,20 @@ +//! GraphQL execution engine initially copied from +//! +//! +//! It exists inside apollo-compiler to support introspection but is not exposed in its public API. +//! This may change if we figure out a good public API for execution and resolvers, +//! at which point this duplicated code could be removed. +//! +//! Changes to merge back upstream in that case: +//! +//! * `ResolvedValue::List` contains an iterator of results, +//! in case an error happens during iteration. +//! * `Resolver::type_name` can return a `&str` not necessarily `&'static str`. +//! * Added the `response_extensions` parameter. + +#[macro_use] +pub(crate) mod resolver; +pub(crate) mod engine; +pub(crate) mod input_coercion; +pub(crate) mod result_coercion; +pub(crate) mod validation; diff --git a/apollo-router/src/plugins/mock_subgraphs/execution/resolver.rs b/apollo-router/src/plugins/mock_subgraphs/execution/resolver.rs new file mode 100644 index 0000000000..5912892f36 --- /dev/null +++ b/apollo-router/src/plugins/mock_subgraphs/execution/resolver.rs @@ -0,0 +1,88 @@ +use std::cell::RefCell; + +use apollo_compiler::response::JsonMap; +use serde_json_bytes::Value as JsonValue; + +/// A GraphQL object whose fields can be resolved during execution +pub(crate) type ObjectValue<'a> = dyn Resolver + 'a; + +/// Abstraction for implementing field resolvers. Used through [`ObjectValue`]. +pub(crate) trait Resolver { + /// Returns the name of the concrete object type this resolver represents + /// + /// That name expected to be that of an object type defined in the schema. + /// This is called when the schema indicates an abstract (interface or union) type. + fn type_name(&self) -> &str; + + /// Resolves a field of this object with the given arguments + /// + /// The resolved is expected to match the type of the corresponding field definition + /// in the schema. + fn resolve_field<'a>( + &'a self, + response_extensions: &'a RefCell, + field_name: &'a str, + arguments: &'a JsonMap, + ) -> Result, ResolverError>; + + /// Returns true if this field should be skipped, + /// as if the corresponding selection has `@skip(if: true)`. + /// + /// This is used to exclude root concrete fields in [crate::introspection::partial_execute]. + fn skip_field(&self, _field_name: &str) -> bool { + false + } +} + +pub(crate) type ResolverError = String; + +/// The value of a resolved field +pub(crate) enum ResolvedValue<'a> { + /// * JSON null represents GraphQL null + /// * A GraphQL enum value is represented as a JSON string + /// * GraphQL built-in scalars are coerced according to their respective *Result Coercion* spec + /// * For custom scalars, any JSON value is passed through as-is (including array or object) + Leaf(JsonValue), + + /// Expected where the GraphQL type is an object, interface, or union type + Object(Box>), + + /// Expected for GraphQL list types + List(Box, ResolverError>> + 'a>), +} + +impl<'a> ResolvedValue<'a> { + /// Construct a null leaf resolved value + #[allow(unused)] + pub(crate) fn null() -> Self { + Self::Leaf(JsonValue::Null) + } + + /// Construct a leaf resolved value from something that is convertible to JSON + pub(crate) fn leaf(json: impl Into) -> Self { + Self::Leaf(json.into()) + } + + /// Construct an object resolved value from the resolver for that object + pub(crate) fn object(resolver: impl Resolver + 'a) -> Self { + Self::Object(Box::new(resolver)) + } + + /// Construct an object resolved value or null, from an optional resolver + #[allow(unused)] + pub(crate) fn opt_object(opt_resolver: Option) -> Self { + match opt_resolver { + Some(resolver) => Self::Object(Box::new(resolver)), + None => Self::null(), + } + } + + /// Construct a list resolved value from an iterator + pub(crate) fn list(iter: I) -> Self + where + I: IntoIterator>, + I::IntoIter: 'a, + { + Self::List(Box::new(iter.into_iter())) + } +} diff --git a/apollo-router/src/plugins/mock_subgraphs/execution/result_coercion.rs b/apollo-router/src/plugins/mock_subgraphs/execution/result_coercion.rs new file mode 100644 index 0000000000..890d5ad0af --- /dev/null +++ b/apollo-router/src/plugins/mock_subgraphs/execution/result_coercion.rs @@ -0,0 +1,236 @@ +use std::cell::RefCell; + +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Schema; +use apollo_compiler::executable::Field; +use apollo_compiler::response::GraphQLError; +use apollo_compiler::response::JsonMap; +use apollo_compiler::response::JsonValue; +use apollo_compiler::response::ResponseDataPathSegment; +use apollo_compiler::schema::ExtendedType; +use apollo_compiler::schema::Type; +use apollo_compiler::validation::Valid; + +use super::engine::ExecutionMode; +use super::engine::LinkedPath; +use super::engine::LinkedPathElement; +use super::engine::PropagateNull; +use super::engine::execute_selection_set; +use super::engine::field_error; +use super::engine::try_nullify; +use super::resolver::ResolvedValue; +use super::validation::SuspectedValidationBug; + +/// +/// +/// Returns `Err` for a field error being propagated upwards to find a nullable place +#[allow(clippy::too_many_arguments)] // yes it’s not a nice API but it’s internal +pub(crate) fn complete_value<'a, 'b>( + schema: &'a Valid, + document: &'a Valid, + variable_values: &'a Valid, + errors: &'b mut Vec, + response_extensions: &RefCell, + path: LinkedPath<'b>, + mode: ExecutionMode, + ty: &'a Type, + resolved: ResolvedValue<'a>, + fields: &'a [&'a Field], +) -> Result { + let location = fields[0].name.location(); + macro_rules! field_error { + ($($arg: tt)+) => { + { + errors.push(field_error( + format!($($arg)+), + path, + location, + &document.sources + )); + return Err(PropagateNull); + } + }; + } + if let ResolvedValue::Leaf(JsonValue::Null) = resolved { + if ty.is_non_null() { + field_error!("Non-null type {ty} resolved to null") + } else { + return Ok(JsonValue::Null); + } + } + if let ResolvedValue::List(iter) = resolved { + match ty { + Type::Named(_) | Type::NonNullNamed(_) => { + field_error!("Non-list type {ty} resolved to a list") + } + Type::List(inner_ty) | Type::NonNullList(inner_ty) => { + let mut completed_list = Vec::with_capacity(iter.size_hint().0); + for (index, inner_result) in iter.enumerate() { + let inner_resolved = inner_result.map_err(|message| { + errors.push(field_error( + format!("resolver error: {message}"), + path, + fields[0].name.location(), + &document.sources, + )); + PropagateNull + })?; + let inner_path = LinkedPathElement { + element: ResponseDataPathSegment::ListIndex(index), + next: path, + }; + let inner_result = complete_value( + schema, + document, + variable_values, + errors, + response_extensions, + Some(&inner_path), + mode, + inner_ty, + inner_resolved, + fields, + ); + // On field error, try to nullify that item + match try_nullify(inner_ty, inner_result) { + Ok(inner_value) => completed_list.push(inner_value), + // If the item is non-null, try to nullify the list + Err(PropagateNull) => return try_nullify(ty, Err(PropagateNull)), + } + } + return Ok(completed_list.into()); + } + } + } + let ty_name = match ty { + Type::List(_) | Type::NonNullList(_) => { + field_error!("List type {ty} resolved to an object") + } + Type::Named(name) | Type::NonNullNamed(name) => name, + }; + let Some(ty_def) = schema.types.get(ty_name) else { + errors.push( + SuspectedValidationBug { + message: format!("Undefined type {ty_name}"), + location, + } + .into_field_error(&document.sources, path), + ); + return Err(PropagateNull); + }; + if let ExtendedType::InputObject(_) = ty_def { + errors.push( + SuspectedValidationBug { + message: format!("Field with input object type {ty_name}"), + location, + } + .into_field_error(&document.sources, path), + ); + return Err(PropagateNull); + } + let resolved_obj = match resolved { + ResolvedValue::List(_) => unreachable!(), // early return above + ResolvedValue::Leaf(json_value) => { + match ty_def { + ExtendedType::InputObject(_) => unreachable!(), // early return above + ExtendedType::Object(_) | ExtendedType::Interface(_) | ExtendedType::Union(_) => { + field_error!( + "Resolver returned a leaf value \ + but expected an object for type {ty_name}" + ) + } + ExtendedType::Enum(enum_def) => { + // https://spec.graphql.org/October2021/#sec-Enums.Result-Coercion + if !json_value + .as_str() + .is_some_and(|str| enum_def.values.contains_key(str)) + { + field_error!("Resolver returned {json_value}, expected enum {ty_name}") + } + } + ExtendedType::Scalar(_) => match ty_name.as_str() { + "Int" => { + // https://spec.graphql.org/October2021/#sec-Int.Result-Coercion + // > GraphQL services may coerce non-integer internal values to integers + // > when reasonable without losing information + // + // We choose not to, to keep with Rust’s strong typing + if let Some(int) = json_value.as_i64() { + if i32::try_from(int).is_err() { + field_error!("Resolver returned {json_value} which overflows Int") + } + } else { + field_error!("Resolver returned {json_value}, expected Int") + } + } + "Float" => { + // https://spec.graphql.org/October2021/#sec-Float.Result-Coercion + if !json_value.is_f64() { + field_error!("Resolver returned {json_value}, expected Float") + } + } + "String" => { + // https://spec.graphql.org/October2021/#sec-String.Result-Coercion + if !json_value.is_string() { + field_error!("Resolver returned {json_value}, expected String") + } + } + "Boolean" => { + // https://spec.graphql.org/October2021/#sec-Boolean.Result-Coercion + if !json_value.is_boolean() { + field_error!("Resolver returned {json_value}, expected Boolean") + } + } + "ID" => { + // https://spec.graphql.org/October2021/#sec-ID.Result-Coercion + if !(json_value.is_string() || json_value.is_i64()) { + field_error!("Resolver returned {json_value}, expected ID") + } + } + _ => { + // Custom scalar: accept any JSON value (including an array or object, + // despite this being a "leaf" as far as GraphQL resolution is concerned) + } + }, + }; + return Ok(json_value); + } + ResolvedValue::Object(resolved_obj) => resolved_obj, + }; + let object_type = match ty_def { + ExtendedType::InputObject(_) => unreachable!(), // early return above + ExtendedType::Enum(_) | ExtendedType::Scalar(_) => { + field_error!("Resolver returned a an object, expected {ty_name}",) + } + ExtendedType::Interface(_) | ExtendedType::Union(_) => { + let object_type_name = resolved_obj.type_name(); + if let Some(def) = schema.get_object(object_type_name) { + def + } else { + field_error!( + "Resolver returned an object of type {object_type_name} \ + not defined in the schema" + ) + } + } + ExtendedType::Object(def) => { + // debug_assert_eq!(ty_name, resolved_obj.type_name()); + def + } + }; + execute_selection_set( + schema, + document, + variable_values, + errors, + response_extensions, + path, + mode, + object_type, + &*resolved_obj, + fields + .iter() + .flat_map(|field| &field.selection_set.selections), + ) + .map(JsonValue::Object) +} diff --git a/apollo-router/src/plugins/mock_subgraphs/execution/validation.rs b/apollo-router/src/plugins/mock_subgraphs/execution/validation.rs new file mode 100644 index 0000000000..6716310e1c --- /dev/null +++ b/apollo-router/src/plugins/mock_subgraphs/execution/validation.rs @@ -0,0 +1,16 @@ +use apollo_compiler::parser::SourceSpan; + +/// Returned as an error for situations that should not happen with a valid schema or document. +/// +/// Since the relevant APIs take [`Valid<_>`][crate::validation::Valid] parameters, +/// either apollo-compiler has a validation bug +/// or [`assume_valid`][crate::validation::Valid::assume_valid] was used incorrectly. +/// +/// Can be [converted][std::convert] to [`GraphQLError`], +/// which populates [`extensions`][GraphQLError::extensions] +/// with a `"APOLLO_SUSPECTED_VALIDATION_BUG": true` entry. +#[derive(Debug, Clone)] +pub(crate) struct SuspectedValidationBug { + pub message: String, + pub location: Option, +} diff --git a/apollo-router/src/plugins/mock_subgraphs/mod.rs b/apollo-router/src/plugins/mock_subgraphs/mod.rs new file mode 100644 index 0000000000..b466139f6f --- /dev/null +++ b/apollo-router/src/plugins/mock_subgraphs/mod.rs @@ -0,0 +1,377 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::LazyLock; + +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Schema; +use apollo_compiler::ast::OperationType; +use apollo_compiler::request::coerce_variable_values; +use apollo_compiler::response::GraphQLError; +use apollo_compiler::response::JsonMap; +use apollo_compiler::response::JsonValue; +use apollo_compiler::validation::Valid; +use tower::BoxError; +use tower::ServiceExt; + +use self::execution::resolver::ResolvedValue; +use crate::graphql; +use crate::plugin::PluginInit; +use crate::plugin::PluginPrivate; +use crate::plugins::response_cache::plugin::GRAPHQL_RESPONSE_EXTENSION_ENTITY_CACHE_TAGS; +use crate::plugins::response_cache::plugin::GRAPHQL_RESPONSE_EXTENSION_ROOT_FIELDS_CACHE_TAGS; +use crate::services::subgraph; + +pub(crate) mod execution; + +register_private_plugin!("apollo", "experimental_mock_subgraphs", MockSubgraphsPlugin); + +/// Configuration for the `mock_subgraphs` plugin +/// +/// +/// Example `router.yaml`: +/// +/// ```yaml +/// experimental_mock_subgraphs: +/// subgraph1_name: +/// headers: +/// cache-control: public +/// query: +/// rootField: +/// subField: "value" +/// __cacheTags: ["rootField"] +/// entities: +/// - __typename: Something +/// id: 4 +/// field: [42, 7] +/// __cacheTags: ["something-4"] +/// ``` +// +// If changing this, also update `dev-docs/mock_subgraphs_plugin.md` +type Config = HashMap>; + +/// Configuration for one subgraph for the `mock_subgraphs` plugin +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct SubgraphConfig { + // If changing this struct, also update `dev-docs/mock_subgraphs_plugin.md` + /// HTTP headers for the subgraph response + #[serde(default)] + #[schemars(with = "HashMap")] + headers: HeaderMap, + + /// Data for `query` operations (excluding the special `_entities` field) + /// + /// In maps nested in this one (but not at the top level), the `__cacheTags` key is special. + /// Instead of representing a field that can be selected, when its parent field is selected + /// its value is expected to be an array which is appended + /// to the `response.extensions["apolloCacheTags"]` array. + #[serde(default)] + #[schemars(with = "OtherJsonMap")] + query: JsonMap, + + /// Data for `mutation` operations + #[serde(default)] + #[schemars(with = "Option")] + mutation: Option, + + /// Entities that can be queried through Federation’s special `_entities` field + /// + /// In maps directly in the top-level `Vec` (but not in other maps nested deeper), + /// the `__cacheTags` key is special. + /// Instead of representing a field that can be selected, when its parent entity is selected + /// its contents are added to the `response.extensions["apolloEntityCacheTags"]` array. + #[serde(default)] + #[schemars(with = "Vec")] + entities: Vec, +} + +type OtherJsonMap = serde_json::Map; + +#[derive(Default)] +struct HeaderMap(http::HeaderMap); + +// Exposed this way for the test harness, so the plugin type itself doesn't need to be made pub. +pub(crate) static PLUGIN_NAME: LazyLock<&'static str> = + LazyLock::new(std::any::type_name::); + +struct MockSubgraphsPlugin { + per_subgraph_config: Config, + subgraph_schemas: Arc>>>, +} + +#[async_trait::async_trait] +impl PluginPrivate for MockSubgraphsPlugin { + type Config = Config; + + const HIDDEN_FROM_CONFIG_JSON_SCHEMA: bool = true; + + async fn new(init: PluginInit) -> Result { + Ok(Self { + subgraph_schemas: init.subgraph_schemas.clone(), + per_subgraph_config: init.config, + }) + } + + fn subgraph_service(&self, name: &str, _: subgraph::BoxService) -> subgraph::BoxService { + let config = self.per_subgraph_config.get(name).cloned(); + let subgraph_schema = self.subgraph_schemas[name].clone(); + tower::service_fn(move |request: subgraph::Request| { + let config = config.clone(); + let subgraph_schema = subgraph_schema.clone(); + async move { + let mut response = http::Response::builder(); + let body = if let Some(config) = &config { + *response.headers_mut().unwrap() = config.headers.0.clone(); + subgraph_call(config, &subgraph_schema, request.subgraph_request.body()) + .unwrap_or_else(|e| { + graphql::Response::builder() + .errors(e.into_iter().map(Into::into).collect()) + .build() + }) + } else { + graphql::Response::builder() + .error( + graphql::Error::builder() + .message("subgraph mock not configured") + .extension_code("SUBGRAPH_MOCK_NOT_CONFIGURED") + .build(), + ) + .build() + }; + let response = response.body(body).unwrap(); + Ok(subgraph::Response::new_from_response( + response, + request.context, + request.subgraph_name, + request.id, + )) + } + }) + .boxed() + } +} + +/// Entry point for testing this mock +pub fn testing_subgraph_call( + config: JsonValue, + subgraph_schema: &Valid, + request: &graphql::Request, +) -> Result> { + let config = serde_json_bytes::from_value(config).unwrap(); + subgraph_call(&config, subgraph_schema, request) +} + +fn subgraph_call( + config: &SubgraphConfig, + subgraph_schema: &Valid, + request: &graphql::Request, +) -> Result> { + let query = request.query.as_deref().unwrap_or(""); + let doc = ExecutableDocument::parse_and_validate(subgraph_schema, query, "query") + .map_err(|e| e.errors.iter().map(|e| e.to_json()).collect::>())?; + let operation = doc + .operations + .get(request.operation_name.as_deref()) + .map_err(|e| vec![e.to_graphql_error(&doc.sources)])?; + let variable_values = coerce_variable_values(subgraph_schema, operation, &request.variables) + .map_err(|e| vec![e.to_graphql_error(&doc.sources)])?; + let object_type_name = operation.object_type(); + let plain_error = |message: &str| vec![GraphQLError::new(message, None, &doc.sources)]; + let root_operation_object_type_def = subgraph_schema + .get_object(object_type_name) + .ok_or_else(|| plain_error("undefined root operation object type"))?; + let (mode, root_mocks) = match operation.operation_type { + OperationType::Query => (execution::engine::ExecutionMode::Normal, &config.query), + OperationType::Mutation => ( + execution::engine::ExecutionMode::Sequential, + config + .mutation + .as_ref() + .ok_or_else(|| plain_error("mutation is not supported"))?, + ), + OperationType::Subscription => return Err(plain_error("subscription not supported")), + }; + let initial_value = RootResolver { + root_mocks, + entities: &config.entities, + }; + let mut errors = Vec::new(); + let response_extensions = RefCell::new(JsonMap::new()); + let path = None; + let data = match execution::engine::execute_selection_set( + subgraph_schema, + &doc, + &variable_values, + &mut errors, + &response_extensions, + path, + mode, + root_operation_object_type_def, + &initial_value, + &operation.selection_set.selections, + ) { + Ok(map) => JsonValue::Object(map), + Err(execution::engine::PropagateNull) => JsonValue::Null, + }; + Ok(graphql::Response::builder() + .data(data) + .errors(errors.into_iter().map(Into::into).collect()) + .extensions(response_extensions.into_inner()) + .build()) +} + +struct RootResolver<'a> { + root_mocks: &'a JsonMap, + entities: &'a [JsonMap], +} + +struct MockResolver<'a> { + in_entity: bool, + mocks: &'a JsonMap, +} + +impl<'a> RootResolver<'a> { + fn find_entities(&self, representation: &JsonMap) -> Option<&'a JsonMap> { + self.entities.iter().find(|entity| { + representation + .iter() + .all(|(k, v)| entity.get(k).is_some_and(|value| value == v)) + }) + } +} + +impl execution::resolver::Resolver for RootResolver<'_> { + fn type_name(&self) -> &str { + unreachable!() + } + + fn resolve_field<'a>( + &'a self, + response_extensions: &'a RefCell, + field_name: &'a str, + arguments: &'a JsonMap, + ) -> Result, execution::resolver::ResolverError> { + if field_name != "_entities" { + let in_entity = false; + return resolve_normal_field( + response_extensions, + in_entity, + self.root_mocks, + field_name, + arguments, + ); + } + let entities = arguments["representations"] + .as_array() + .ok_or("expected array `representations`")? + .iter() + .map(move |representation| { + let representation = representation + .as_object() + .ok_or("expected object `representations[n]`")?; + let entity = self.find_entities(representation).ok_or_else(|| { + format!("no mocked entity found for representation {representation:?}") + })?; + if let Some(keys) = entity.get("__cacheTags") { + response_extensions + .borrow_mut() + .entry(GRAPHQL_RESPONSE_EXTENSION_ENTITY_CACHE_TAGS) + .or_insert_with(|| JsonValue::Array(Vec::new())) + .as_array_mut() + .unwrap() + .push(keys.clone()); + } + Ok(ResolvedValue::object(MockResolver { + in_entity: true, + mocks: entity, + })) + }); + Ok(ResolvedValue::list(entities)) + } +} + +impl execution::resolver::Resolver for MockResolver<'_> { + fn type_name(&self) -> &str { + self.mocks + .get("__typename") + .expect("missing `__typename` mock for interface or union type") + .as_str() + .expect("`__typename` is not a string") + } + + fn resolve_field<'a>( + &'a self, + response_extensions: &'a RefCell, + field_name: &'a str, + arguments: &'a JsonMap, + ) -> Result, execution::resolver::ResolverError> { + resolve_normal_field( + response_extensions, + self.in_entity, + self.mocks, + field_name, + arguments, + ) + } +} + +fn resolve_normal_field<'a>( + response_extensions: &'a RefCell, + in_entity: bool, + mocks: &'a JsonMap, + field_name: &'a str, + arguments: &'a JsonMap, +) -> Result, execution::resolver::ResolverError> { + let _ignored = arguments; // TODO: find some way to vary response based on arguments? + let mock = mocks + .get(field_name) + .ok_or_else(|| format!("field '{field_name}' not found in mocked data"))?; + resolve_value(response_extensions, in_entity, mock) +} + +fn resolve_value<'a>( + response_extensions: &'a RefCell, + in_entity: bool, + mock: &'a JsonValue, +) -> Result, String> { + match mock { + JsonValue::Object(map) => { + if !in_entity && let Some(keys) = map.get("__cacheTags") { + response_extensions + .borrow_mut() + .entry(GRAPHQL_RESPONSE_EXTENSION_ROOT_FIELDS_CACHE_TAGS) + .or_insert_with(|| JsonValue::Array(Vec::new())) + .as_array_mut() + .unwrap() + .extend_from_slice(keys.as_array().unwrap()); + }; + Ok(ResolvedValue::object(MockResolver { + in_entity, + mocks: map, + })) + } + JsonValue::Array(values) => { + Ok(ResolvedValue::list(values.iter().map(move |x| { + resolve_value(response_extensions, in_entity, x) + }))) + } + json => Ok(ResolvedValue::leaf(json.clone())), + } +} + +impl<'de> serde::Deserialize<'de> for HeaderMap { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + let mut map = http::HeaderMap::new(); + for (k, v) in >::deserialize(deserializer)? { + map.insert( + http::HeaderName::from_bytes(k.as_bytes()).map_err(D::Error::custom)?, + http::HeaderValue::from_str(&v).map_err(D::Error::custom)?, + ); + } + Ok(Self(map)) + } +} diff --git a/apollo-router/src/plugins/mod.rs b/apollo-router/src/plugins/mod.rs index beac8037b9..a8743b8943 100644 --- a/apollo-router/src/plugins/mod.rs +++ b/apollo-router/src/plugins/mod.rs @@ -8,8 +8,8 @@ macro_rules! schemar_fn { }; ($name:ident, $ty:ty, $default:expr, $description:expr) => { - fn $name(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { - let schema = <$ty>::json_schema(gen); + fn $name(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + let schema = <$ty>::json_schema(generator); let mut schema = schema.into_object(); let mut metadata = schemars::schema::Metadata::default(); metadata.description = Some($description.to_string()); @@ -23,18 +23,27 @@ macro_rules! schemar_fn { pub(crate) mod authentication; pub(crate) mod authorization; pub(crate) mod cache; +pub(crate) mod chaos; +pub(crate) mod connectors; mod coprocessor; +pub(crate) mod cors; pub(crate) mod csrf; -mod demand_control; -mod expose_query_plan; +pub(crate) mod demand_control; +pub(crate) mod enhanced_client_awareness; +pub(crate) mod expose_query_plan; pub(crate) mod file_uploads; +mod fleet_detector; mod forbid_mutations; mod headers; +pub(crate) mod healthcheck; mod include_subgraph_errors; +pub(crate) mod license_enforcement; pub(crate) mod limits; +pub(crate) mod mock_subgraphs; pub(crate) mod override_url; pub(crate) mod progressive_override; mod record_replay; +pub(crate) mod response_cache; pub(crate) mod rhai; pub(crate) mod subscription; pub(crate) mod telemetry; diff --git a/apollo-router/src/plugins/override_url.rs b/apollo-router/src/plugins/override_url.rs index 4922a39f5f..f2dc7d27fa 100644 --- a/apollo-router/src/plugins/override_url.rs +++ b/apollo-router/src/plugins/override_url.rs @@ -13,8 +13,8 @@ use tower::ServiceExt; use crate::plugin::Plugin; use crate::plugin::PluginInit; use crate::register_plugin; -use crate::services::subgraph; use crate::services::SubgraphRequest; +use crate::services::subgraph; #[derive(Debug, Clone)] struct OverrideSubgraphUrl { @@ -85,15 +85,15 @@ mod tests { use http::Uri; use serde_json::Value; - use tower::util::BoxService; use tower::Service; use tower::ServiceExt; + use tower::util::BoxService; - use crate::plugin::test::MockSubgraphService; + use crate::Context; use crate::plugin::DynPlugin; + use crate::plugin::test::MockSubgraphService; use crate::services::SubgraphRequest; use crate::services::SubgraphResponse; - use crate::Context; #[tokio::test] async fn plugin_registered() { diff --git a/apollo-router/src/plugins/progressive_override/mod.rs b/apollo-router/src/plugins/progressive_override/mod.rs index d4e1adb9b5..1afb525ed8 100644 --- a/apollo-router/src/plugins/progressive_override/mod.rs +++ b/apollo-router/src/plugins/progressive_override/mod.rs @@ -2,9 +2,9 @@ use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; +use apollo_compiler::Schema; use apollo_compiler::schema::ExtendedType; use apollo_compiler::validation::Valid; -use apollo_compiler::Schema; use dashmap::DashMap; use schemars::JsonSchema; use serde::Deserialize; @@ -24,8 +24,8 @@ use crate::spec; use crate::spec::query::traverse; pub(crate) mod visitor; -pub(crate) const UNRESOLVED_LABELS_KEY: &str = "apollo_override::unresolved_labels"; -pub(crate) const LABELS_TO_OVERRIDE_KEY: &str = "apollo_override::labels_to_override"; +pub(crate) const UNRESOLVED_LABELS_KEY: &str = "apollo::progressive_override::unresolved_labels"; +pub(crate) const LABELS_TO_OVERRIDE_KEY: &str = "apollo::progressive_override::labels_to_override"; pub(crate) const JOIN_FIELD_DIRECTIVE_NAME: &str = "join__field"; pub(crate) const JOIN_SPEC_BASE_URL: &str = "https://specs.apollo.dev/join"; diff --git a/apollo-router/src/plugins/progressive_override/snapshots/apollo_router__plugins__progressive_override__tests__non_overridden_field_yields_expected_query_plan.snap b/apollo-router/src/plugins/progressive_override/snapshots/apollo_router__plugins__progressive_override__tests__non_overridden_field_yields_expected_query_plan.snap index 01cca77a5b..6318a3beaa 100644 --- a/apollo-router/src/plugins/progressive_override/snapshots/apollo_router__plugins__progressive_override__tests__non_overridden_field_yields_expected_query_plan.snap +++ b/apollo-router/src/plugins/progressive_override/snapshots/apollo_router__plugins__progressive_override__tests__non_overridden_field_yields_expected_query_plan.snap @@ -4,6 +4,16 @@ expression: query_plan --- { "data": null, + "errors": [ + { + "message": "subgraph mock not configured", + "path": [], + "extensions": { + "code": "SUBGRAPH_MOCK_NOT_CONFIGURED", + "service": "Subgraph2" + } + } + ], "extensions": { "apolloQueryPlan": { "object": { @@ -12,14 +22,14 @@ expression: query_plan "kind": "Fetch", "serviceName": "Subgraph2", "variableUsages": [], - "operation": "{percent0{foo}}", + "operation": "{ percent0 { foo } }", "operationName": null, "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "23605b350473485e40bc8b1245f0c5c226a2997a96291bf3ad3412570a5172bb", + "schemaAwareHash": "1e87c57f82d475874fe60b76bf0d1f3dac1c9752248e2874e1f43e45f5b61534", "authorization": { "is_authenticated": false, "scopes": [], diff --git a/apollo-router/src/plugins/progressive_override/snapshots/apollo_router__plugins__progressive_override__tests__overridden_field_yields_expected_query_plan.snap b/apollo-router/src/plugins/progressive_override/snapshots/apollo_router__plugins__progressive_override__tests__overridden_field_yields_expected_query_plan.snap index 455898049f..c9d0bd52d8 100644 --- a/apollo-router/src/plugins/progressive_override/snapshots/apollo_router__plugins__progressive_override__tests__overridden_field_yields_expected_query_plan.snap +++ b/apollo-router/src/plugins/progressive_override/snapshots/apollo_router__plugins__progressive_override__tests__overridden_field_yields_expected_query_plan.snap @@ -6,6 +6,16 @@ expression: query_plan "data": { "percent100": null }, + "errors": [ + { + "message": "subgraph mock not configured", + "path": [], + "extensions": { + "code": "SUBGRAPH_MOCK_NOT_CONFIGURED", + "service": "Subgraph1" + } + } + ], "extensions": { "apolloQueryPlan": { "object": { @@ -17,14 +27,14 @@ expression: query_plan "kind": "Fetch", "serviceName": "Subgraph1", "variableUsages": [], - "operation": "{percent100{__typename id}}", + "operation": "{ percent100 { __typename id } }", "operationName": null, "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "d14f50b039a3b961385f4d2a878c5800dd01141cddd3f8f1874a5499bbe397a9", + "schemaAwareHash": "5ccd469f4d6e284c89147dabcb126f77a4df3d1564022d4e656ab5259afe4d4b", "authorization": { "is_authenticated": false, "scopes": [], @@ -56,14 +66,14 @@ expression: query_plan } ], "variableUsages": [], - "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on T{foo}}}", + "operation": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on T { foo } } }", "operationName": null, "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "caa182daf66e4ffe9b1af8c386092ba830887bbae0d58395066fa480525080ec", + "schemaAwareHash": "06c92f85953526b52325aa2553d5a1eb10263d393ed30316da637b20bd379f7e", "authorization": { "is_authenticated": false, "scopes": [], diff --git a/apollo-router/src/plugins/progressive_override/tests.rs b/apollo-router/src/plugins/progressive_override/tests.rs index 0cead42fd9..b6803f5aa7 100644 --- a/apollo-router/src/plugins/progressive_override/tests.rs +++ b/apollo-router/src/plugins/progressive_override/tests.rs @@ -3,25 +3,25 @@ use std::sync::Arc; use apollo_compiler::Schema; use tower::ServiceExt; +use crate::Context; +use crate::TestHarness; use crate::metrics::FutureMetricsExt; -use crate::plugin::test::MockRouterService; -use crate::plugin::test::MockSupergraphService; use crate::plugin::Plugin; use crate::plugin::PluginInit; +use crate::plugin::test::MockRouterService; +use crate::plugin::test::MockSupergraphService; use crate::plugins::progressive_override::Config; -use crate::plugins::progressive_override::ProgressiveOverridePlugin; use crate::plugins::progressive_override::JOIN_FIELD_DIRECTIVE_NAME; use crate::plugins::progressive_override::JOIN_SPEC_BASE_URL; use crate::plugins::progressive_override::JOIN_SPEC_VERSION_RANGE; use crate::plugins::progressive_override::LABELS_TO_OVERRIDE_KEY; +use crate::plugins::progressive_override::ProgressiveOverridePlugin; use crate::plugins::progressive_override::UNRESOLVED_LABELS_KEY; +use crate::services::RouterResponse; +use crate::services::SupergraphResponse; use crate::services::layers::query_analysis::ParsedDocument; use crate::services::router; use crate::services::supergraph; -use crate::services::RouterResponse; -use crate::services::SupergraphResponse; -use crate::Context; -use crate::TestHarness; const SCHEMA: &str = include_str!("testdata/supergraph.graphql"); const SCHEMA_NO_USAGES: &str = include_str!("testdata/supergraph_no_usages.graphql"); @@ -32,41 +32,46 @@ fn test_progressive_overrides_are_recognised_vor_join_v0_4_and_above() { format!( r#"schema @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/{}", for: EXECUTION) + @link(url: "https://specs.apollo.dev/join/{version}", for: EXECUTION) @link(url: "https://specs.apollo.dev/context/v0.1", for: SECURITY) - directive @join__field repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION"#, - version + directive @join__field repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION"# ) }; let join_v3_schema = Schema::parse(schema_for_version("v0.3"), "test").unwrap(); - assert!(crate::spec::Schema::directive_name( - &join_v3_schema, - JOIN_SPEC_BASE_URL, - JOIN_SPEC_VERSION_RANGE, - JOIN_FIELD_DIRECTIVE_NAME, - ) - .is_none()); + assert!( + crate::spec::Schema::directive_name( + &join_v3_schema, + JOIN_SPEC_BASE_URL, + JOIN_SPEC_VERSION_RANGE, + JOIN_FIELD_DIRECTIVE_NAME, + ) + .is_none() + ); let join_v4_schema = Schema::parse(schema_for_version("v0.4"), "test").unwrap(); - assert!(crate::spec::Schema::directive_name( - &join_v4_schema, - JOIN_SPEC_BASE_URL, - JOIN_SPEC_VERSION_RANGE, - JOIN_FIELD_DIRECTIVE_NAME, - ) - .is_some()); + assert!( + crate::spec::Schema::directive_name( + &join_v4_schema, + JOIN_SPEC_BASE_URL, + JOIN_SPEC_VERSION_RANGE, + JOIN_FIELD_DIRECTIVE_NAME, + ) + .is_some() + ); let join_v5_schema = Schema::parse(schema_for_version("v0.5"), "test").unwrap(); - assert!(crate::spec::Schema::directive_name( - &join_v5_schema, - JOIN_SPEC_BASE_URL, - JOIN_SPEC_VERSION_RANGE, - JOIN_FIELD_DIRECTIVE_NAME, + assert!( + crate::spec::Schema::directive_name( + &join_v5_schema, + JOIN_SPEC_BASE_URL, + JOIN_SPEC_VERSION_RANGE, + JOIN_FIELD_DIRECTIVE_NAME, + ) + .is_some() ) - .is_some()) } #[tokio::test] @@ -111,9 +116,11 @@ async fn plugin_router_service_adds_all_arbitrary_labels_to_context() { assert!(!labels_on_context.contains(&Arc::new("percent(0)".to_string()))); assert!(!labels_on_context.contains(&Arc::new("percent(100)".to_string()))); assert!(labels_on_context.len() == 3); - assert!(vec!["bar", "baz", "foo"] - .into_iter() - .all(|s| labels_on_context.contains(&Arc::new(s.to_string())))); + assert!( + vec!["bar", "baz", "foo"] + .into_iter() + .all(|s| labels_on_context.contains(&Arc::new(s.to_string()))) + ); RouterResponse::fake_builder().build() }); @@ -192,7 +199,7 @@ async fn assert_expected_and_absent_labels_for_supergraph_service( let context = Context::new(); context .extensions() - .with_lock(|mut lock| lock.insert::(parsed_doc)); + .with_lock(|lock| lock.insert::(parsed_doc)); context .insert( @@ -264,7 +271,7 @@ async fn get_json_query_plan(query: &str) -> serde_json::Value { let context: Context = Context::new(); context .extensions() - .with_lock(|mut lock| lock.insert::(parsed_doc)); + .with_lock(|lock| lock.insert::(parsed_doc)); let request = supergraph::Request::fake_builder() .query(query) @@ -275,6 +282,9 @@ async fn get_json_query_plan(query: &str) -> serde_json::Value { let supergraph_service = TestHarness::builder() .configuration_json(serde_json::json! {{ + "include_subgraph_errors": { + "all": true + }, "plugins": { "experimental.expose_query_plan": true } @@ -338,7 +348,7 @@ async fn query_with_labels(query: &str, labels_from_coprocessors: Vec<&str>) { let context = Context::new(); context .extensions() - .with_lock(|mut lock| lock.insert::(parsed_doc)); + .with_lock(|lock| lock.insert::(parsed_doc)); context .insert( diff --git a/apollo-router/src/plugins/progressive_override/visitor.rs b/apollo-router/src/plugins/progressive_override/visitor.rs index d17cd07aec..abc07054e5 100644 --- a/apollo-router/src/plugins/progressive_override/visitor.rs +++ b/apollo-router/src/plugins/progressive_override/visitor.rs @@ -11,8 +11,8 @@ use super::JOIN_FIELD_DIRECTIVE_NAME; use super::JOIN_SPEC_BASE_URL; use super::JOIN_SPEC_VERSION_RANGE; use super::OVERRIDE_LABEL_ARG_NAME; -use crate::spec::query::traverse; use crate::spec::Schema; +use crate::spec::query::traverse; impl<'a> OverrideLabelVisitor<'a> { pub(crate) fn new(schema: &'a schema::Schema) -> Option { @@ -29,7 +29,7 @@ impl<'a> OverrideLabelVisitor<'a> { } } -impl<'a> traverse::Visitor for OverrideLabelVisitor<'a> { +impl traverse::Visitor for OverrideLabelVisitor<'_> { fn schema(&self) -> &apollo_compiler::Schema { self.schema } @@ -77,9 +77,9 @@ pub(crate) struct OverrideLabelVisitor<'a> { mod tests { use std::sync::Arc; - use apollo_compiler::validation::Valid; use apollo_compiler::ExecutableDocument; use apollo_compiler::Schema; + use apollo_compiler::validation::Valid; use crate::plugins::progressive_override::visitor::OverrideLabelVisitor; use crate::spec::query::traverse; diff --git a/apollo-router/src/plugins/record_replay/record.rs b/apollo-router/src/plugins/record_replay/record.rs index d1c0bc9829..dde27a641d 100644 --- a/apollo-router/src/plugins/record_replay/record.rs +++ b/apollo-router/src/plugins/record_replay/record.rs @@ -2,8 +2,9 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; -use futures::stream::once; use futures::StreamExt; +use futures::stream::once; +use http_body_util::BodyExt; use tokio::fs; use tower::BoxError; use tower::ServiceBuilder; @@ -19,7 +20,6 @@ use crate::plugin::PluginInit; use crate::services::execution; use crate::services::external::externalize_header_map; use crate::services::router; -use crate::services::router::body::RouterBody; use crate::services::subgraph; use crate::services::supergraph; @@ -98,7 +98,7 @@ impl Plugin for Record { let after_complete = once(async move { let recording = context .extensions() - .with_lock(|mut lock| lock.remove::()); + .with_lock(|lock| lock.remove::()); if let Some(mut recording) = recording { let res_headers = externalize_header_map(&headers)?; @@ -125,15 +125,15 @@ impl Plugin for Record { }) .filter_map(|a| async move { a.unwrap() }); - let stream = stream.chain(after_complete); + let stream = stream.into_data_stream().chain(after_complete); - Ok(router::Response { - context: res.context, - response: http::Response::from_parts( + router::Response::http_response_builder() + .context(res.context) + .response(http::Response::from_parts( parts, - RouterBody::wrap_stream(stream).into_inner(), - ), - }) + router::body::from_result_stream(stream), + )) + .build() } }) .service(service) @@ -155,7 +155,7 @@ impl Plugin for Record { let recording_enabled = if req.supergraph_request.headers().contains_key(RECORD_HEADER) { - req.context.extensions().with_lock(|mut lock| { + req.context.extensions().with_lock(|lock| { lock.insert(Recording { supergraph_sdl: supergraph_sdl.clone().to_string(), client_request: Default::default(), @@ -178,7 +178,7 @@ impl Plugin for Record { let method = req.supergraph_request.method().to_string(); let uri = req.supergraph_request.uri().to_string(); - req.context.extensions().with_lock(|mut lock| { + req.context.extensions().with_lock(|lock| { if let Some(recording) = lock.get_mut::() { recording.client_request = RequestDetails { query, @@ -196,7 +196,7 @@ impl Plugin for Record { .map_response(|res: supergraph::Response| { let context = res.context.clone(); res.map_stream(move |chunk| { - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { if let Some(recording) = lock.get_mut::() { recording.client_response.chunks.push(chunk.clone()); } @@ -212,7 +212,7 @@ impl Plugin for Record { fn execution_service(&self, service: execution::BoxService) -> execution::BoxService { ServiceBuilder::new() .map_request(|req: execution::Request| { - req.context.extensions().with_lock(|mut lock| { + req.context.extensions().with_lock(|lock| { if let Some(recording) = lock.get_mut::() { recording.formatted_query_plan = req.query_plan.formatted_query_plan.clone(); @@ -250,43 +250,35 @@ impl Plugin for Record { let subgraph_name = subgraph_name.clone(); async move { let res: subgraph::ServiceResult = future.await; + let res = res?; let operation_name = req .operation_name .clone() .unwrap_or_else(|| "UnnamedOperation".to_string()); - let res = match res { - Ok(res) => { - let subgraph = Subgraph { - subgraph_name, - response: ResponseDetails { - headers: externalize_header_map( - &res.response.headers().clone(), - ) - .expect("failed to externalize header map"), - chunks: vec![res.response.body().clone()], - }, - request: req, - }; - - res.context.extensions().with_lock(|mut lock| { - if let Some(recording) = lock.get_mut::() { - if recording.subgraph_fetches.is_none() { - recording.subgraph_fetches = Some(Default::default()); - } - - if let Some(fetches) = &mut recording.subgraph_fetches { - fetches.insert(operation_name, subgraph); - } - } - }); - Ok(res) - } - Err(err) => Err(err), + let subgraph = Subgraph { + subgraph_name, + response: ResponseDetails { + headers: externalize_header_map(&res.response.headers().clone()) + .expect("failed to externalize header map"), + chunks: vec![res.response.body().clone()], + }, + request: req, }; - res + res.context.extensions().with_lock(|lock| { + if let Some(recording) = lock.get_mut::() { + if recording.subgraph_fetches.is_none() { + recording.subgraph_fetches = Some(Default::default()); + } + + if let Some(fetches) = &mut recording.subgraph_fetches { + fetches.insert(operation_name, subgraph); + } + } + }); + Ok(res) } }, ) @@ -304,17 +296,14 @@ async fn write_file(dir: Arc, path: &PathBuf, contents: &[u8]) -> Result<( } fn is_introspection(request: &supergraph::Request) -> bool { - request - .context - .unsupported_executable_document() - .is_some_and(|doc| { - doc.operations - .get(request.supergraph_request.body().operation_name.as_deref()) - .ok() - .is_some_and(|op| { - op.root_fields(&doc).all(|field| { - matches!(field.name.as_str(), "__typename" | "__schema" | "__type") - }) + request.context.executable_document().is_some_and(|doc| { + doc.operations + .get(request.supergraph_request.body().operation_name.as_deref()) + .ok() + .is_some_and(|op| { + op.root_fields(&doc).all(|field| { + matches!(field.name.as_str(), "__typename" | "__schema" | "__type") }) - }) + }) + }) } diff --git a/apollo-router/src/plugins/record_replay/recording.rs b/apollo-router/src/plugins/record_replay/recording.rs index 2286da83fd..00d3af7e76 100644 --- a/apollo-router/src/plugins/record_replay/recording.rs +++ b/apollo-router/src/plugins/record_replay/recording.rs @@ -34,7 +34,7 @@ impl Recording { digest.update(req); let hash = hex::encode(digest.finalize().as_slice()); - PathBuf::from(format!("{}-{}.json", operation_name, hash)) + PathBuf::from(format!("{operation_name}-{hash}.json")) } } diff --git a/apollo-router/src/plugins/record_replay/replay.rs b/apollo-router/src/plugins/record_replay/replay.rs index 814e3e750e..aad4c92c01 100644 --- a/apollo-router/src/plugins/record_replay/replay.rs +++ b/apollo-router/src/plugins/record_replay/replay.rs @@ -3,12 +3,12 @@ use std::collections::HashSet; use std::ops::ControlFlow; use std::path::Path; use std::sync::Arc; -use std::sync::Mutex; use console::style; use http::Method; use http::Uri; use multimap::MultiMap; +use parking_lot::Mutex; use serde_json_bytes::ByteString; use serde_json_bytes::Map; use serde_json_bytes::Value; @@ -22,12 +22,12 @@ use crate::context::Context; use crate::layers::ServiceBuilderExt; use crate::plugin::Plugin; use crate::plugin::PluginInit; +use crate::services::TryIntoHeaderName; +use crate::services::TryIntoHeaderValue; use crate::services::execution; use crate::services::router; use crate::services::subgraph; use crate::services::supergraph; -use crate::services::TryIntoHeaderName; -use crate::services::TryIntoHeaderValue; #[derive(Debug)] pub(crate) struct Replay { @@ -116,7 +116,7 @@ impl Plugin for Replay { recorded_set.difference(&runtime_values).collect::>(); if !missing_values.is_empty() { - report.lock().unwrap().push(ReplayReport::HeaderDifference { + report.lock().push(ReplayReport::HeaderDifference { name: k.clone(), recorded: recorded_values.clone(), runtime: runtime_values.iter().map(|v| v.to_string()).collect(), @@ -148,7 +148,6 @@ impl Plugin for Replay { if recorded_chunk_str != chunk_str { report .lock() - .unwrap() .push(ReplayReport::ClientResponseChunkDifference( i, recorded_chunk_str.clone(), @@ -177,13 +176,10 @@ impl Plugin for Replay { .unwrap_or_default(); if recorded != runtime { - report - .lock() - .unwrap() - .push(ReplayReport::QueryPlanDifference( - recorded.clone(), - runtime.clone(), - )); + report.lock().push(ReplayReport::QueryPlanDifference( + recorded.clone(), + runtime.clone(), + )); } req @@ -226,25 +222,19 @@ impl Plugin for Replay { let recorded_variables = fetch.request.variables.clone(); if runtime_variables != recorded_variables { - report - .lock() - .unwrap() - .push(ReplayReport::VariablesDifference { - name: operation_name.clone(), - runtime: runtime_variables, - recorded: recorded_variables, - }); + report.lock().push(ReplayReport::VariablesDifference { + name: operation_name.clone(), + runtime: runtime_variables, + recorded: recorded_variables, + }); } Ok(ControlFlow::Break(subgraph_response)) } else { - report - .lock() - .unwrap() - .push(ReplayReport::SubgraphRequestMissed( - subgraph_name.clone(), - operation_name.clone(), - )); + report.lock().push(ReplayReport::SubgraphRequestMissed( + subgraph_name.clone(), + operation_name.clone(), + )); // TODO: break with an empty response or error instead? If // the subgraph routing url is accessible this will hit the @@ -357,9 +347,9 @@ impl ReplayReport { print_line(width); if !old.is_empty() { - println!("{}", style(format_args!("-{}", old_hint)).red()); + println!("{}", style(format_args!("-{old_hint}")).red()); } - println!("{}", style(format_args!("+{}", new_hint)).green()); + println!("{}", style(format_args!("+{new_hint}")).green()); println!("────────────┬{:─^1$}", "", width.saturating_sub(13)); let mut has_changes = false; diff --git a/apollo-router/src/plugins/record_replay/replay_tests.rs b/apollo-router/src/plugins/record_replay/replay_tests.rs index 2b2d832f33..902c44eddc 100644 --- a/apollo-router/src/plugins/record_replay/replay_tests.rs +++ b/apollo-router/src/plugins/record_replay/replay_tests.rs @@ -32,7 +32,7 @@ async fn replay_recording() { let mut resp = test_harness.oneshot(req).await.unwrap(); while (resp.next_response().await).is_some() {} - let report = report.lock().unwrap(); + let report = report.lock(); let has_items = report.len(); if has_items == 0 { diff --git a/apollo-router/src/plugins/response_cache/cache_control.rs b/apollo-router/src/plugins/response_cache/cache_control.rs new file mode 100644 index 0000000000..652be32428 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/cache_control.rs @@ -0,0 +1,466 @@ +use std::fmt::Write; +use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use http::HeaderMap; +use http::HeaderValue; +use http::header::AGE; +use http::header::CACHE_CONTROL; +use serde::Deserialize; +use serde::Serialize; +use tower::BoxError; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CacheControl { + pub(super) created: u64, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub(super) max_age: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub(super) age: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub(super) s_max_age: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub(super) stale_while_revalidate: Option, + #[serde(skip_serializing_if = "is_false", default)] + pub(super) no_cache: bool, + #[serde(skip_serializing_if = "is_false", default)] + pub(super) must_revalidate: bool, + #[serde(skip_serializing_if = "is_false", default)] + pub(super) proxy_revalidate: bool, + #[serde(skip_serializing_if = "is_false", default)] + pub(super) no_store: bool, + #[serde(skip_serializing_if = "is_false", default)] + pub(super) private: bool, + #[serde(skip_serializing_if = "is_false", default)] + pub(super) public: bool, + #[serde(skip_serializing_if = "is_false", default)] + pub(super) must_understand: bool, + #[serde(skip_serializing_if = "is_false", default)] + pub(super) no_transform: bool, + #[serde(skip_serializing_if = "is_false", default)] + pub(super) immutable: bool, + #[serde(skip_serializing_if = "is_false", default)] + pub(super) stale_if_error: bool, +} + +fn is_false(b: &bool) -> bool { + !b +} + +fn now_epoch_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("we should not run before EPOCH") + .as_secs() +} + +impl Default for CacheControl { + fn default() -> Self { + Self { + created: now_epoch_seconds(), + max_age: None, + age: None, + s_max_age: None, + stale_while_revalidate: None, + no_cache: false, + must_revalidate: false, + proxy_revalidate: false, + no_store: false, + private: false, + public: false, + must_understand: false, + no_transform: false, + immutable: false, + stale_if_error: false, + } + } +} + +impl CacheControl { + pub(crate) fn new( + headers: &HeaderMap, + default_ttl: Option, + ) -> Result { + let mut result = CacheControl::default(); + if let Some(duration) = default_ttl { + result.max_age = Some(duration.as_secs() as u32); + } + + let mut found = false; + for header_value in headers.get_all(CACHE_CONTROL) { + found = true; + for value in header_value.to_str()?.split(',') { + let mut it = value.trim().split('='); + let (k, v) = (it.next(), it.next()); + if k.is_none() || it.next().is_some() { + return Err("invalid Cache-Control header value".into()); + } + + match (k.expect("the key was checked"), v) { + ("max-age", Some(v)) => { + result.max_age = Some(v.parse()?); + } + ("s-maxage", Some(v)) => { + result.s_max_age = Some(v.parse()?); + } + ("stale-while-revalidate", Some(v)) => { + result.stale_while_revalidate = Some(v.parse()?); + } + ("no-cache", None) => { + result.no_cache = true; + } + ("must-revalidate", None) => { + result.must_revalidate = true; + } + ("proxy-revalidate", None) => { + result.proxy_revalidate = true; + } + ("no-store", None) => { + result.no_store = true; + } + ("private", None) => { + result.private = true; + } + ("public", None) => { + result.public = true; + } + ("must-understand", None) => { + result.must_understand = true; + } + ("no-transform", None) => { + result.no_transform = true; + } + ("immutable", None) => { + result.immutable = true; + } + ("stale-if-error", None) => { + result.stale_if_error = true; + } + _ => { + return Err("invalid Cache-Control header value".into()); + } + } + } + } + + if !found { + result.no_store = true; + } + + if let Some(value) = headers.get("Age") { + result.age = Some(value.to_str()?.trim().parse()?); + } + + //TODO etag + + Ok(result) + } + + /// Fill the header map with cache-control header and age header + pub(crate) fn to_headers(&self, headers: &mut HeaderMap) -> Result<(), BoxError> { + headers.insert( + CACHE_CONTROL, + HeaderValue::from_str(&self.to_cache_control_header()?)?, + ); + + if let Some(age) = self.age + && age != 0 + { + headers.insert(AGE, age.into()); + } + + Ok(()) + } + + /// Only for cache control header and not age + pub(crate) fn to_cache_control_header(&self) -> Result { + let mut s = String::new(); + let mut prev = false; + let now = now_epoch_seconds(); + if self.no_store { + write!(&mut s, "no-store")?; + // Early return to avoid conflicts https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#preventing_storing + return Ok(s); + } + if self.no_cache { + write!(&mut s, "{}no-cache", if prev { "," } else { "" },)?; + prev = true; + } + if let Some(max_age) = self.max_age { + //FIXME: write no-store if max_age = 0? + write!( + &mut s, + "{}max-age={}", + if prev { "," } else { "" }, + self.update_ttl(max_age, now) + )?; + prev = true; + } + if let Some(s_max_age) = self.s_max_age { + write!( + &mut s, + "{}s-maxage={}", + if prev { "," } else { "" }, + self.update_ttl(s_max_age, now) + )?; + prev = true; + } + if let Some(swr) = self.stale_while_revalidate { + write!( + &mut s, + "{}stale-while-revalidate={}", + if prev { "," } else { "" }, + swr + )?; + prev = true; + } + if self.must_revalidate { + write!(&mut s, "{}must-revalidate", if prev { "," } else { "" },)?; + prev = true; + } + if self.proxy_revalidate { + write!(&mut s, "{}proxy-revalidate", if prev { "," } else { "" },)?; + prev = true; + } + if self.private { + write!(&mut s, "{}private", if prev { "," } else { "" },)?; + prev = true; + } + if self.public && !self.private { + write!(&mut s, "{}public", if prev { "," } else { "" },)?; + prev = true; + } + if self.must_understand { + write!(&mut s, "{}must-understand", if prev { "," } else { "" },)?; + prev = true; + } + if self.no_transform { + write!(&mut s, "{}no-transform", if prev { "," } else { "" },)?; + prev = true; + } + if self.immutable { + write!(&mut s, "{}immutable", if prev { "," } else { "" },)?; + prev = true; + } + if self.stale_if_error { + write!(&mut s, "{}stale-if-error", if prev { "," } else { "" },)?; + } + + Ok(s) + } + + pub(super) fn no_store() -> Self { + CacheControl { + no_store: true, + ..Default::default() + } + } + + fn update_ttl(&self, ttl: u32, now: u64) -> u32 { + let elapsed = self.elapsed_inner(now); + ttl.saturating_sub(elapsed) + } + + pub(crate) fn merge(&self, other: &CacheControl) -> CacheControl { + self.merge_inner(other, now_epoch_seconds()) + } + + fn merge_inner(&self, other: &CacheControl, now: u64) -> CacheControl { + // Early return to avoid conflicts https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#preventing_storing + if self.no_store || other.no_store { + return CacheControl { + no_store: true, + ..Default::default() + }; + } + CacheControl { + created: now, + max_age: match (self.ttl(), other.ttl()) { + (None, None) => None, + (None, Some(ttl)) => Some(other.update_ttl(ttl, now)), + (Some(ttl), None) => Some(self.update_ttl(ttl, now)), + (Some(ttl1), Some(ttl2)) => Some(std::cmp::min( + self.update_ttl(ttl1, now), + other.update_ttl(ttl2, now), + )), + }, + age: None, + s_max_age: None, + stale_while_revalidate: match ( + self.stale_while_revalidate, + other.stale_while_revalidate, + ) { + (None, None) => None, + (None, Some(ttl)) => Some(other.update_ttl(ttl, now)), + (Some(ttl), None) => Some(self.update_ttl(ttl, now)), + (Some(ttl1), Some(ttl2)) => Some(std::cmp::min( + self.update_ttl(ttl1, now), + other.update_ttl(ttl2, now), + )), + }, + no_cache: self.no_cache || other.no_cache, + must_revalidate: self.must_revalidate || other.must_revalidate, + proxy_revalidate: self.proxy_revalidate || other.proxy_revalidate, + no_store: self.no_store || other.no_store, + private: self.private || other.private, + // private takes precedence over public + public: if self.private || other.private { + false + } else { + self.public || other.public + }, + must_understand: self.must_understand || other.must_understand, + no_transform: self.no_transform || other.no_transform, + immutable: self.immutable || other.immutable, + stale_if_error: self.stale_if_error || other.stale_if_error, + } + } + + pub(crate) fn elapsed(&self) -> u32 { + self.elapsed_inner(now_epoch_seconds()) + } + + pub(crate) fn elapsed_inner(&self, now: u64) -> u32 { + (now - self.created) as u32 + } + + pub(crate) fn ttl(&self) -> Option { + match ( + self.s_max_age.as_ref().or(self.max_age.as_ref()), + self.age.as_ref(), + ) { + (None, _) => None, + (Some(max_age), None) => Some(*max_age), + (Some(max_age), Some(age)) => Some(max_age - age), + } + } + + pub(crate) fn should_store(&self) -> bool { + // FIXME: should we add support for must-understand? + // public will be the default case + !self.no_store + } + + pub(crate) fn private(&self) -> bool { + self.private + } + + pub(crate) fn can_use(&self) -> bool { + let elapsed = self.elapsed(); + let expired = self.ttl().map(|ttl| ttl < elapsed).unwrap_or(false); + + // FIXME: we don't honor stale-while-revalidate yet + // !expired || self.stale_while_revalidate + !expired && !self.no_store + } + + #[cfg(test)] + pub(crate) fn remaining_time(&self, now: u64) -> Option { + self.ttl().map(|ttl| { + let elapsed = self.elapsed_inner(now); + ttl.saturating_sub(elapsed) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_ttl() { + let now = now_epoch_seconds(); + + let first = CacheControl { + created: now - 10, + max_age: Some(40), + ..Default::default() + }; + + let second = CacheControl { + created: now - 20, + max_age: Some(60), + ..Default::default() + }; + + assert_eq!(first.remaining_time(now), Some(30)); + assert_eq!(second.remaining_time(now), Some(40)); + + let merged = first.merge_inner(&second, now); + assert_eq!(merged.created, now); + + assert_eq!(merged.ttl(), Some(30)); + assert_eq!(merged.remaining_time(now), Some(30)); + assert!(merged.can_use()); + } + + #[test] + fn merge_nostore() { + let now = now_epoch_seconds(); + + let first = CacheControl { + created: now, + max_age: Some(40), + no_store: true, + ..Default::default() + }; + + let second = CacheControl { + created: now, + max_age: Some(60), + no_store: false, + public: true, + ..Default::default() + }; + + let merged = first.merge_inner(&second, now); + assert!(merged.no_store); + assert!(!merged.public); + assert!(!merged.can_use()); + } + + #[test] + fn remove_conflicts() { + let now = now_epoch_seconds(); + + let first = CacheControl { + created: now, + max_age: Some(40), + no_store: true, + must_revalidate: true, + no_cache: true, + private: true, + ..Default::default() + }; + let cache_control_header = first.to_cache_control_header().unwrap(); + assert_eq!(cache_control_header, "no-store".to_string()); + } + + #[test] + fn merge_public_private() { + let now = now_epoch_seconds(); + + let first = CacheControl { + created: now, + max_age: Some(40), + public: true, + private: false, + ..Default::default() + }; + + let second = CacheControl { + created: now, + max_age: Some(60), + public: false, + private: true, + ..Default::default() + }; + + let merged = first.merge_inner(&second, now); + assert!(!merged.public); + assert!(merged.private); + assert!(merged.can_use()); + } +} diff --git a/apollo-router/src/plugins/response_cache/invalidation.rs b/apollo-router/src/plugins/response_cache/invalidation.rs new file mode 100644 index 0000000000..df272f9c10 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/invalidation.rs @@ -0,0 +1,282 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Instant; + +use futures::FutureExt; +use futures::StreamExt; +use futures::stream; +use itertools::Itertools; +use serde::Deserialize; +use serde::Serialize; +use thiserror::Error; +use tower::BoxError; +use tracing::Instrument; + +use super::plugin::Storage; +use super::postgres::PostgresCacheStorage; +use crate::plugins::response_cache::ErrorCode; +use crate::plugins::response_cache::plugin::INTERNAL_CACHE_TAG_PREFIX; +use crate::plugins::response_cache::plugin::RESPONSE_CACHE_VERSION; + +#[derive(Clone)] +pub(crate) struct Invalidation { + pub(crate) storage: Arc, +} + +#[derive(Error, Debug)] +pub(crate) enum InvalidationError { + #[error("error")] + Misc(#[from] anyhow::Error), + #[error("caching database error")] + Postgres(#[from] sqlx::Error), + #[error("several errors")] + Errors(#[from] InvalidationErrors), +} + +impl ErrorCode for InvalidationError { + fn code(&self) -> &'static str { + match &self { + InvalidationError::Misc(_) => "MISC", + InvalidationError::Postgres(error) => error.code(), + InvalidationError::Errors(_) => "INVALIDATION_ERRORS", + } + } +} + +#[derive(Debug)] +pub(crate) struct InvalidationErrors(Vec); + +impl std::fmt::Display for InvalidationErrors { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "invalidation errors: [{}]", + self.0.iter().map(|e| e.to_string()).join("; ") + ) + } +} + +impl std::error::Error for InvalidationErrors {} + +impl Invalidation { + pub(crate) async fn new(storage: Arc) -> Result { + Ok(Self { storage }) + } + + pub(crate) async fn invalidate( + &self, + requests: Vec, + ) -> Result { + u64_counter_with_unit!( + "apollo.router.operations.response_cache.invalidation.event", + "Response cache received a batch of invalidation requests", + "{request}", + 1u64 + ); + + Ok(self + .handle_request_batch(requests) + .instrument(tracing::info_span!("cache.invalidation.batch")) + .await?) + } + + async fn handle_request( + &self, + pg_storage: &PostgresCacheStorage, + request: &mut InvalidationRequest, + ) -> Result { + let invalidation_key = request.invalidation_key(); + tracing::debug!( + "got invalidation request: {request:?}, will invalidate: {}", + invalidation_key + ); + let (count, subgraphs) = match request { + InvalidationRequest::Subgraph { subgraph } => { + let count = pg_storage + .invalidate_by_subgraphs(vec![subgraph.clone()]) + .await?; + u64_counter_with_unit!( + "apollo.router.operations.response_cache.invalidation.entry", + "Response cache counter for invalidated entries", + "{entry}", + count, + "subgraph.name" = subgraph.clone() + ); + (count, vec![subgraph.clone()]) + } + InvalidationRequest::Type { subgraph, .. } => { + let subgraph_counts = pg_storage + .invalidate(vec![invalidation_key], vec![subgraph.clone()]) + .await?; + let mut total_count = 0; + for (subgraph_name, count) in subgraph_counts { + total_count += count; + u64_counter_with_unit!( + "apollo.router.operations.response_cache.invalidation.entry", + "Response cache counter for invalidated entries", + "{entry}", + count, + "subgraph.name" = subgraph_name + ); + } + + (total_count, vec![subgraph.clone()]) + } + InvalidationRequest::CacheTag { + subgraphs, + cache_tag, + } => { + let subgraph_counts = pg_storage + .invalidate( + vec![cache_tag.clone()], + subgraphs.clone().into_iter().collect(), + ) + .await?; + let mut total_count = 0; + for (subgraph_name, count) in subgraph_counts { + total_count += count; + u64_counter_with_unit!( + "apollo.router.operations.response_cache.invalidation.entry", + "Response cache counter for invalidated entries", + "{entry}", + count, + "subgraph.name" = subgraph_name + ); + } + + ( + total_count, + subgraphs.clone().into_iter().collect::>(), + ) + } + }; + + for subgraph in subgraphs { + u64_histogram_with_unit!( + "apollo.router.operations.response_cache.invalidation.request.entry", + "Number of invalidated entries per invalidation request.", + "{entry}", + count, + "subgraph.name" = subgraph + ); + } + + Ok(count) + } + + async fn handle_request_batch( + &self, + requests: Vec, + ) -> Result { + let mut count = 0; + let mut errors = Vec::new(); + let mut futures = Vec::new(); + for request in requests { + let storages = match &request { + InvalidationRequest::Subgraph { subgraph } + | InvalidationRequest::Type { subgraph, .. } => match self.storage.get(subgraph) { + Some(s) => vec![s], + None => continue, + }, + InvalidationRequest::CacheTag { subgraphs, .. } => subgraphs + .iter() + .filter_map(|subgraph| self.storage.get(subgraph)) + .collect(), + }; + + for pg_storage in storages { + let mut request = request.clone(); + let f = async move { + let start = Instant::now(); + + let res = self + .handle_request(pg_storage, &mut request) + .instrument(tracing::info_span!("cache.invalidation.request")) + .await; + + f64_histogram_with_unit!( + "apollo.router.operations.response_cache.invalidation.duration", + "Duration of the invalidation event execution, in seconds.", + "s", + start.elapsed().as_secs_f64() + ); + if let Err(err) = &res { + u64_counter_with_unit!( + "apollo.router.operations.response_cache.invalidation.error", + "Errors when invalidating data in cache", + "{error}", + 1, + "code" = err.code() + ); + } + res + }; + futures.push(f.boxed()); + } + } + let mut stream: stream::FuturesUnordered<_> = futures.into_iter().collect(); + while let Some(res) = stream.next().await { + match res { + Ok(c) => count += c, + Err(err) => { + errors.push(err); + } + } + } + + if !errors.is_empty() { + Err(InvalidationErrors(errors).into()) + } else { + Ok(count) + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum InvalidationRequest { + Subgraph { + subgraph: String, + }, + Type { + subgraph: String, + r#type: String, + }, + CacheTag { + subgraphs: HashSet, + cache_tag: String, + }, +} + +impl InvalidationRequest { + pub(crate) fn subgraph_names(&self) -> Vec { + match self { + InvalidationRequest::Subgraph { subgraph } + | InvalidationRequest::Type { subgraph, .. } => vec![subgraph.clone()], + InvalidationRequest::CacheTag { subgraphs, .. } => { + subgraphs.clone().into_iter().collect() + } + } + } + fn invalidation_key(&self) -> String { + match self { + InvalidationRequest::Subgraph { subgraph } => { + format!("version:{RESPONSE_CACHE_VERSION}:subgraph:{subgraph}",) + } + InvalidationRequest::Type { subgraph, r#type } => { + format!( + "{INTERNAL_CACHE_TAG_PREFIX}version:{RESPONSE_CACHE_VERSION}:subgraph:{subgraph}:type:{type}", + ) + } + InvalidationRequest::CacheTag { cache_tag, .. } => cache_tag.clone(), + } + } + + pub(super) fn kind(&self) -> &'static str { + match self { + InvalidationRequest::Subgraph { .. } => "subgraph", + InvalidationRequest::Type { .. } => "type", + InvalidationRequest::CacheTag { .. } => "cache_tag", + } + } +} diff --git a/apollo-router/src/plugins/response_cache/invalidation_endpoint.rs b/apollo-router/src/plugins/response_cache/invalidation_endpoint.rs new file mode 100644 index 0000000000..eb3cba0104 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/invalidation_endpoint.rs @@ -0,0 +1,420 @@ +use std::sync::Arc; +use std::task::Poll; + +use bytes::Buf; +use futures::future::BoxFuture; +use http::HeaderValue; +use http::Method; +use http::StatusCode; +use http::header::AUTHORIZATION; +use http::header::CONTENT_TYPE; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json_bytes::json; +use tower::BoxError; +use tower::Service; +use tracing::Span; +use tracing_futures::Instrument; + +use super::invalidation::Invalidation; +use super::plugin::Subgraph; +use crate::ListenAddr; +use crate::configuration::subgraph::SubgraphConfiguration; +use crate::graphql; +use crate::plugins::response_cache::invalidation::InvalidationRequest; +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE; +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE_ERROR; +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE_OK; +use crate::services::router; + +pub(crate) const INVALIDATION_ENDPOINT_SPAN_NAME: &str = "invalidation_endpoint"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "snake_case", deny_unknown_fields, default)] +pub(crate) struct SubgraphInvalidationConfig { + /// Enable the invalidation + pub(crate) enabled: bool, + /// Shared key needed to request the invalidation endpoint + pub(crate) shared_key: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub(crate) struct InvalidationEndpointConfig { + /// Specify on which path you want to listen for invalidation endpoint. + pub(crate) path: String, + /// Listen address on which the invalidation endpoint must listen. + pub(crate) listen: ListenAddr, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub(crate) enum InvalidationType { + EntityType, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InvalidationKey { + pub(crate) id: String, + pub(crate) field: String, +} + +#[derive(Clone)] +pub(crate) struct InvalidationService { + config: Arc>, + invalidation: Invalidation, +} + +impl InvalidationService { + pub(crate) fn new( + config: Arc>, + invalidation: Invalidation, + ) -> Self { + Self { + config, + invalidation, + } + } +} + +impl Service for InvalidationService { + type Response = router::Response; + type Error = BoxError; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, _: &mut std::task::Context<'_>) -> Poll> { + Ok(()).into() + } + + fn call(&mut self, req: router::Request) -> Self::Future { + const APPLICATION_JSON_HEADER_VALUE: HeaderValue = + HeaderValue::from_static("application/json"); + let invalidation = self.invalidation.clone(); + let config = self.config.clone(); + Box::pin( + async move { + let (parts, body) = req.router_request.into_parts(); + if !parts.headers.contains_key(AUTHORIZATION) { + Span::current().record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + return router::Response::error_builder() + .status_code(StatusCode::UNAUTHORIZED) + .header(CONTENT_TYPE, APPLICATION_JSON_HEADER_VALUE) + .error( + graphql::Error::builder() + .message(String::from("Missing authorization header")) + .extension_code(StatusCode::UNAUTHORIZED.to_string()) + .build(), + ) + .context(req.context) + .build(); + } + match parts.method { + Method::POST => { + let body = router::body::into_bytes(body) + .instrument(tracing::info_span!("into_bytes")) + .await + .map_err(|e| format!("failed to get the request body: {e}")) + .and_then(|bytes| { + serde_json::from_reader::<_, Vec>( + bytes.reader(), + ) + .map_err(|err| { + format!( + "failed to deserialize the request body into JSON: {err}" + ) + }) + }); + let shared_key = parts + .headers + .get(AUTHORIZATION) + .ok_or("cannot find authorization header")? + .to_str() + .inspect_err(|_err| { + Span::current().record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + })?; + match body { + Ok(body) => { + Span::current().record( + "invalidation.request.kinds", + body.iter() + .map(|i| i.kind()) + .collect::>() + .join(", "), + ); + let shared_key_is_valid = body + .iter() + .flat_map(|b| b.subgraph_names()) + .any(|subgraph_name| { + validate_shared_key(&config, shared_key, &subgraph_name) + }); + if !shared_key_is_valid { + Span::current() + .record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + return router::Response::error_builder() + .status_code(StatusCode::UNAUTHORIZED) + .header(CONTENT_TYPE, APPLICATION_JSON_HEADER_VALUE) + .error( + graphql::Error::builder() + .message(String::from( + "Invalid authorization header", + )) + .extension_code( + StatusCode::UNAUTHORIZED.to_string(), + ) + .build(), + ) + .context(req.context) + .build(); + } + match invalidation + .invalidate(body) + .instrument(tracing::info_span!("invalidate")) + .await + { + Ok(count) => router::Response::http_response_builder() + .response( + http::Response::builder() + .status(StatusCode::ACCEPTED) + .header(CONTENT_TYPE, APPLICATION_JSON_HEADER_VALUE) + .body(router::body::from_bytes( + serde_json::to_string(&json!({ + "count": count + }))?, + )) + .map_err(BoxError::from)?, + ) + .context(req.context) + .build(), + Err(err) => { + Span::current() + .record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + router::Response::error_builder() + .status_code(StatusCode::BAD_REQUEST) + .header(CONTENT_TYPE, APPLICATION_JSON_HEADER_VALUE) + .error( + graphql::Error::builder() + .message(err.to_string()) + .extension_code( + StatusCode::BAD_REQUEST.to_string(), + ) + .build(), + ) + .context(req.context) + .build() + } + } + } + Err(err) => { + Span::current().record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + router::Response::error_builder() + .status_code(StatusCode::BAD_REQUEST) + .header(CONTENT_TYPE, APPLICATION_JSON_HEADER_VALUE) + .error( + graphql::Error::builder() + .message(err) + .extension_code(StatusCode::BAD_REQUEST.to_string()) + .build(), + ) + .context(req.context) + .build() + } + } + } + _ => { + Span::current().record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); + router::Response::error_builder() + .status_code(StatusCode::METHOD_NOT_ALLOWED) + .header(CONTENT_TYPE, APPLICATION_JSON_HEADER_VALUE) + .error( + graphql::Error::builder() + .message("".to_string()) + .extension_code(StatusCode::METHOD_NOT_ALLOWED.to_string()) + .build(), + ) + .context(req.context) + .build() + } + } + } + .instrument(tracing::info_span!( + INVALIDATION_ENDPOINT_SPAN_NAME, + "invalidation.request.kinds" = ::tracing::field::Empty, + "otel.status_code" = OTEL_STATUS_CODE_OK, + )), + ) + } +} + +fn validate_shared_key( + config: &SubgraphConfiguration, + shared_key: &str, + subgraph_name: &str, +) -> bool { + config + .all + .invalidation + .as_ref() + .map(|i| i.shared_key == shared_key) + .unwrap_or_default() + || config + .subgraphs + .get(subgraph_name) + .and_then(|s| s.invalidation.as_ref()) + .map(|i| i.shared_key == shared_key) + .unwrap_or_default() +} + +#[cfg(all( + test, + any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")) +))] +mod tests { + use std::collections::HashMap; + + use tower::ServiceExt; + + use super::*; + use crate::plugins::response_cache::plugin::Storage; + use crate::plugins::response_cache::postgres::PostgresCacheConfig; + use crate::plugins::response_cache::postgres::PostgresCacheStorage; + use crate::plugins::response_cache::postgres::default_batch_size; + use crate::plugins::response_cache::postgres::default_cleanup_interval; + use crate::plugins::response_cache::postgres::default_pool_size; + + #[tokio::test] + async fn test_invalidation_service_bad_shared_key() { + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + tls: Default::default(), + cleanup_interval: default_cleanup_interval(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(500), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("test_invalidation_service_bad_shared_key")), + }) + .await + .unwrap(); + let storage = Arc::new(Storage { + all: Some(Arc::new(pg_cache.into())), + subgraphs: HashMap::new(), + }); + let invalidation = Invalidation::new(storage.clone()).await.unwrap(); + + let config = Arc::new(SubgraphConfiguration { + all: Subgraph { + ttl: None, + enabled: Some(true), + postgres: None, + private_id: None, + invalidation: Some(SubgraphInvalidationConfig { + enabled: true, + shared_key: String::from("test"), + }), + }, + subgraphs: HashMap::new(), + }); + let service = InvalidationService::new(config, invalidation); + let req = router::Request::fake_builder() + .method(http::Method::POST) + .header(AUTHORIZATION, "testttt") + .body( + serde_json::to_vec(&[ + InvalidationRequest::Subgraph { + subgraph: String::from("test"), + }, + InvalidationRequest::Type { + subgraph: String::from("test"), + r#type: String::from("Test"), + }, + ]) + .unwrap(), + ) + .build() + .unwrap(); + let res = service.oneshot(req).await.unwrap(); + assert_eq!( + res.response.headers().get(&CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("application/json") + ); + assert_eq!(res.response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_invalidation_service_bad_shared_key_subgraph() { + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + tls: Default::default(), + cleanup_interval: default_cleanup_interval(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(500), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from( + "test_invalidation_service_bad_shared_key_subgraph", + )), + }) + .await + .unwrap(); + let storage = Arc::new(Storage { + all: Some(Arc::new(pg_cache.into())), + subgraphs: HashMap::new(), + }); + let invalidation = Invalidation::new(storage.clone()).await.unwrap(); + + let config = Arc::new(SubgraphConfiguration { + all: Subgraph { + ttl: None, + enabled: Some(true), + postgres: None, + private_id: None, + invalidation: Some(SubgraphInvalidationConfig { + enabled: true, + shared_key: String::from("test"), + }), + }, + subgraphs: [( + String::from("test"), + Subgraph { + ttl: None, + enabled: Some(true), + postgres: None, + private_id: None, + invalidation: Some(SubgraphInvalidationConfig { + enabled: true, + shared_key: String::from("test_test"), + }), + }, + )] + .into_iter() + .collect(), + }); + // Trying to invalidation with shared_key on subgraph test for a subgraph foo + let service = InvalidationService::new(config, invalidation); + let req = router::Request::fake_builder() + .method(http::Method::POST) + .header(AUTHORIZATION, "test_test") + .body( + serde_json::to_vec(&[InvalidationRequest::Subgraph { + subgraph: String::from("foo"), + }]) + .unwrap(), + ) + .build() + .unwrap(); + let res = service.oneshot(req).await.unwrap(); + assert_eq!( + res.response.headers().get(&CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("application/json") + ); + assert_eq!(res.response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/apollo-router/src/plugins/response_cache/metrics.rs b/apollo-router/src/plugins/response_cache/metrics.rs new file mode 100644 index 0000000000..6719d3aed2 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/metrics.rs @@ -0,0 +1,360 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::task::Poll; +use std::time::Duration; +use std::time::Instant; + +use bloomfilter::Bloom; +use http::header; +use opentelemetry::KeyValue; +use opentelemetry::metrics::MeterProvider; +use parking_lot::Mutex; +use serde_json_bytes::Value; +use tokio::sync::broadcast; +use tokio_stream::StreamExt; +use tokio_stream::wrappers::IntervalStream; +use tower::BoxError; +use tower::ServiceBuilder; +use tower::ServiceExt; +use tower_service::Service; + +use super::plugin::REPRESENTATIONS; +use super::plugin::Ttl; +use super::plugin::hash_query; +use super::plugin::hash_vary_headers; +use crate::layers::ServiceBuilderExt; +use crate::metrics::meter_provider; +use crate::plugins::response_cache::postgres::PostgresCacheStorage; +use crate::services::subgraph; +use crate::spec::TYPENAME; + +pub(crate) const CACHE_INFO_SUBGRAPH_CONTEXT_KEY: &str = + "apollo::router::response_cache::cache_info_subgraph"; + +impl CacheMetricsService { + pub(crate) fn create( + name: String, + service: subgraph::BoxService, + ttl: Option<&Ttl>, + separate_per_type: bool, + ) -> subgraph::BoxService { + tower::util::BoxService::new(CacheMetricsService { + service: ServiceBuilder::new() + .buffered() + .service(service) + .boxed_clone(), + name: Arc::new(name), + counter: Some(Arc::new(Mutex::new(CacheCounter::new( + ttl.map(|t| t.0).unwrap_or_else(|| Duration::from_secs(60)), + separate_per_type, + )))), + }) + } +} + +#[derive(Clone)] +pub(crate) struct CacheMetricsService { + service: subgraph::BoxCloneService, + name: Arc, + counter: Option>>, +} + +impl Service for CacheMetricsService { + type Response = subgraph::Response; + type Error = BoxError; + type Future = >::Future; + + fn poll_ready( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, request: subgraph::Request) -> Self::Future { + let clone = self.clone(); + let inner = std::mem::replace(self, clone); + + Box::pin(inner.call_inner(request)) + } +} + +impl CacheMetricsService { + async fn call_inner( + mut self, + mut request: subgraph::Request, + ) -> Result { + let cache_attributes = Self::get_cache_attributes(&mut request); + + let response = self.service.ready().await?.call(request).await?; + + if let Some(cache_attributes) = cache_attributes + && let Some(counter) = &self.counter + { + Self::update_cache_metrics(&self.name, counter, &response, cache_attributes) + } + + Ok(response) + } + + fn get_cache_attributes(sub_request: &mut subgraph::Request) -> Option { + let body = sub_request.subgraph_request.body_mut(); + let hashed_query = hash_query(&sub_request.query_hash, body); + let representations = body + .variables + .get(REPRESENTATIONS) + .and_then(|value| value.as_array())?; + + let keys = extract_cache_attributes(representations).ok()?; + + Some(CacheAttributes { + headers: sub_request.subgraph_request.headers().clone(), + hashed_query: Arc::new(hashed_query), + representations: keys, + }) + } + + fn update_cache_metrics( + subgraph_name: &Arc, + counter: &Mutex, + sub_response: &subgraph::Response, + cache_attributes: CacheAttributes, + ) { + let mut vary_headers = sub_response + .response + .headers() + .get_all(header::VARY) + .into_iter() + .filter_map(|val| { + val.to_str().ok().map(|v| { + v.split(", ") + .map(|s| s.to_string()) + .collect::>() + }) + }) + .flatten() + .collect::>(); + vary_headers.sort(); + let vary_headers = vary_headers.join(", "); + + let hashed_headers = if vary_headers.is_empty() { + Arc::default() + } else { + Arc::new(hash_vary_headers(&cache_attributes.headers)) + }; + + CacheCounter::record( + counter, + cache_attributes.hashed_query.clone(), + subgraph_name, + hashed_headers, + cache_attributes.representations, + ); + } +} + +#[derive(Debug, Clone)] +pub(crate) struct CacheAttributes { + pub(crate) headers: http::HeaderMap, + pub(crate) hashed_query: Arc, + // Typename + hashed_representation + pub(crate) representations: Vec<(Arc, Value)>, +} + +#[derive(Debug, Hash, Clone)] +pub(crate) struct CacheKey { + pub(crate) representation: Value, + pub(crate) typename: Arc, + pub(crate) query: Arc, + pub(crate) subgraph_name: Arc, + pub(crate) hashed_headers: Arc, +} + +// Get typename and hashed representation for each representations in the subgraph query +pub(crate) fn extract_cache_attributes( + representations: &[Value], +) -> Result, Value)>, BoxError> { + let mut res = Vec::new(); + for representation in representations { + let opt_type = representation + .as_object() + .and_then(|o| o.get(TYPENAME)) + .ok_or("missing __typename in representation")?; + let typename = opt_type.as_str().unwrap_or(""); + + res.push((Arc::new(typename.to_string()), representation.clone())); + } + Ok(res) +} + +pub(crate) struct CacheCounter { + primary: Bloom, + secondary: Bloom, + created_at: Instant, + ttl: Duration, + per_type: bool, +} + +impl CacheCounter { + pub(crate) fn new(ttl: Duration, per_type: bool) -> Self { + Self { + primary: Self::make_filter(), + secondary: Self::make_filter(), + created_at: Instant::now(), + ttl, + per_type, + } + } + + fn make_filter() -> Bloom { + // the filter is around 4kB in size (can be calculated with `Bloom::compute_bitmap_size`) + Bloom::new_for_fp_rate(10000, 0.2).expect("cannot fail") + } + + pub(crate) fn record( + counter: &Mutex, + query: Arc, + subgraph_name: &Arc, + hashed_headers: Arc, + representations: Vec<(Arc, Value)>, + ) { + let separate_metrics_per_type; + { + let mut c = counter.lock(); + if c.created_at.elapsed() >= c.ttl { + c.clear(); + } + separate_metrics_per_type = c.per_type; + } + + // typename -> (nb of cache hits, nb of entities) + let mut seen: HashMap, (usize, usize)> = HashMap::new(); + let mut key = CacheKey { + representation: Value::Null, + typename: Arc::new(String::new()), + query, + subgraph_name: subgraph_name.clone(), + hashed_headers, + }; + for (typename, representation) in representations { + let cache_hit; + key.typename = typename.clone(); + key.representation = representation; + + { + let mut c = counter.lock(); + cache_hit = c.check(&key); + } + + let seen_entry = seen.entry(typename.clone()).or_default(); + if cache_hit { + seen_entry.0 += 1; + } + seen_entry.1 += 1; + } + + for (typename, (cache_hit, total_entities)) in seen.into_iter() { + if separate_metrics_per_type { + f64_histogram_with_unit!( + "apollo.router.operations.response_cache.cache_hit", + "Hit rate percentage of cached entities", + "percent", + (cache_hit as f64 / total_entities as f64) * 100f64, + // Can't just `Arc::clone` these because they're `Arc`, + // while opentelemetry supports `Arc` + entity_type = typename.to_string(), + subgraph = subgraph_name.to_string() + ); + } else { + f64_histogram_with_unit!( + "apollo.router.operations.response_cache.cache_hit", + "Hit rate percentage of cached entities", + "percent", + (cache_hit as f64 / total_entities as f64) * 100f64, + subgraph = subgraph_name.to_string() + ); + } + } + } + + fn check(&mut self, key: &CacheKey) -> bool { + self.primary.check_and_set(key) || self.secondary.check(key) + } + + fn clear(&mut self) { + let secondary = std::mem::replace(&mut self.primary, Self::make_filter()); + self.secondary = secondary; + + self.created_at = Instant::now(); + } +} + +pub(crate) struct CacheMetricContextKey(String); + +impl CacheMetricContextKey { + pub(crate) fn new(subgraph_name: String) -> Self { + Self(subgraph_name) + } +} + +impl From for String { + fn from(val: CacheMetricContextKey) -> Self { + format!("{CACHE_INFO_SUBGRAPH_CONTEXT_KEY}_{}", val.0) + } +} + +/// This task counts all rows in the given Postgres DB that is expired and will be removed when pg_cron will be triggered +/// parameter subgraph_name is optional and is None when the database is the global one, and Some(...) when it's a database configured for a specific subgraph +pub(super) async fn expired_data_task( + pg_cache: PostgresCacheStorage, + mut abort_signal: broadcast::Receiver<()>, + subgraph_name: Option, +) { + let mut interval = IntervalStream::new(tokio::time::interval(std::time::Duration::from_secs( + (pg_cache.cleanup_interval.num_seconds().max(60) / 2) as u64, + ))); + let expired_data_count = Arc::new(AtomicU64::new(0)); + let expired_data_count_clone = expired_data_count.clone(); + let meter = meter_provider().meter("apollo/router"); + let _gauge = meter + .u64_observable_gauge("apollo.router.response_cache.data.expired") + .with_description("Count of expired data entries still in database") + .with_unit("{entry}") + .with_callback(move |gauge| { + let attributes = match subgraph_name.clone() { + Some(subgraph_name) => { + vec![KeyValue::new( + "subgraph.name", + opentelemetry::Value::String(subgraph_name.into()), + )] + } + None => Vec::new(), + }; + gauge.observe( + expired_data_count_clone.load(Ordering::Relaxed), + &attributes, + ); + }) + .init(); + + loop { + tokio::select! { + biased; + _ = abort_signal.recv() => { + break; + } + _ = interval.next() => { + let exp_data = match pg_cache.expired_data_count().await { + Ok(exp_data) => exp_data, + Err(err) => { + ::tracing::error!(error = ?err, "cannot get expired data count"); + continue; + } + }; + expired_data_count.store(exp_data, Ordering::Relaxed); + } + } + } +} diff --git a/apollo-router/src/plugins/response_cache/mod.rs b/apollo-router/src/plugins/response_cache/mod.rs new file mode 100644 index 0000000000..964b3d15cd --- /dev/null +++ b/apollo-router/src/plugins/response_cache/mod.rs @@ -0,0 +1,15 @@ +pub(crate) mod cache_control; +pub(crate) mod invalidation; +pub(crate) mod invalidation_endpoint; +pub(crate) mod metrics; +pub(crate) mod plugin; +pub(crate) mod postgres; +#[cfg(all( + test, + any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")) +))] +pub(crate) mod tests; + +pub(super) trait ErrorCode { + fn code(&self) -> &'static str; +} diff --git a/apollo-router/src/plugins/response_cache/plugin.rs b/apollo-router/src/plugins/response_cache/plugin.rs new file mode 100644 index 0000000000..7e282d5bbe --- /dev/null +++ b/apollo-router/src/plugins/response_cache/plugin.rs @@ -0,0 +1,2933 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::fmt::Write; +use std::num::NonZeroUsize; +use std::ops::ControlFlow; +use std::sync::Arc; +use std::sync::OnceLock; +use std::time::Duration; +use std::time::Instant; + +use apollo_compiler::Schema; +use apollo_compiler::ast::NamedType; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::parser::Parser; +use apollo_compiler::validation::Valid; +use apollo_federation::connectors::StringTemplate; +use http::HeaderValue; +use http::header; +use http::header::CACHE_CONTROL; +use itertools::Itertools; +use lru::LruCache; +use multimap::MultiMap; +use opentelemetry::Key; +use opentelemetry::StringValue; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json_bytes::ByteString; +use serde_json_bytes::Value; +use sha2::Digest; +use sha2::Sha256; +use tokio::sync::RwLock; +use tokio::sync::broadcast; +use tokio::sync::broadcast::Receiver; +use tokio::sync::broadcast::Sender; +use tokio_stream::StreamExt; +use tokio_stream::wrappers::IntervalStream; +use tower::BoxError; +use tower::ServiceBuilder; +use tower::ServiceExt; +use tower_service::Service; +use tracing::Instrument; +use tracing::Level; +use tracing::Span; + +use super::cache_control::CacheControl; +use super::invalidation::Invalidation; +use super::invalidation_endpoint::InvalidationEndpointConfig; +use super::invalidation_endpoint::InvalidationService; +use super::invalidation_endpoint::SubgraphInvalidationConfig; +use super::metrics::CacheMetricContextKey; +use super::metrics::CacheMetricsService; +use super::postgres::BatchDocument; +use super::postgres::CacheEntry; +use super::postgres::PostgresCacheConfig; +use super::postgres::PostgresCacheStorage; +use crate::Context; +use crate::Endpoint; +use crate::ListenAddr; +use crate::batching::BatchQuery; +use crate::configuration::subgraph::SubgraphConfiguration; +use crate::error::FetchError; +use crate::graphql; +use crate::graphql::Error; +use crate::json_ext::Object; +use crate::json_ext::Path; +use crate::json_ext::PathElement; +use crate::layers::ServiceBuilderExt; +use crate::plugin::PluginInit; +use crate::plugin::PluginPrivate; +use crate::plugins::authorization::CacheKeyMetadata; +use crate::plugins::mock_subgraphs::execution::input_coercion::coerce_argument_values; +use crate::plugins::response_cache::ErrorCode; +use crate::plugins::response_cache::metrics; +use crate::plugins::telemetry::LruSizeInstrument; +use crate::plugins::telemetry::dynamic_attribute::SpanDynAttribute; +use crate::plugins::telemetry::span_ext::SpanMarkError; +use crate::query_planner::OperationKind; +use crate::services::subgraph; +use crate::services::supergraph; +use crate::spec::QueryHash; +use crate::spec::TYPENAME; + +/// Change this key if you introduce a breaking change in response caching algorithm to make sure it won't take the previous entries +pub(crate) const RESPONSE_CACHE_VERSION: &str = "1.0"; +pub(crate) const CACHE_TAG_DIRECTIVE_NAME: &str = "federation__cacheTag"; +pub(crate) const ENTITIES: &str = "_entities"; +pub(crate) const REPRESENTATIONS: &str = "representations"; +pub(crate) const CONTEXT_CACHE_KEY: &str = "apollo::response_cache::key"; +/// Context key to enable support of debugger +pub(crate) const CONTEXT_DEBUG_CACHE_KEYS: &str = "apollo::response_cache::debug_cached_keys"; +pub(crate) const CACHE_DEBUG_HEADER_NAME: &str = "apollo-cache-debugging"; +pub(crate) const CACHE_DEBUG_EXTENSIONS_KEY: &str = "apolloCacheDebugging"; +pub(crate) const CACHE_DEBUGGER_VERSION: &str = "1.0"; +pub(crate) const GRAPHQL_RESPONSE_EXTENSION_ROOT_FIELDS_CACHE_TAGS: &str = "apolloCacheTags"; +pub(crate) const GRAPHQL_RESPONSE_EXTENSION_ENTITY_CACHE_TAGS: &str = "apolloEntityCacheTags"; +/// Used to mark cache tags as internal and should not be exported or displayed to our users +pub(crate) const INTERNAL_CACHE_TAG_PREFIX: &str = "__apollo_internal::"; +const DEFAULT_LRU_PRIVATE_QUERIES_SIZE: NonZeroUsize = NonZeroUsize::new(2048).unwrap(); +const LRU_PRIVATE_QUERIES_INSTRUMENT_NAME: &str = + "apollo.router.response_cache.private_queries.lru.size"; + +register_private_plugin!("apollo", "experimental_response_cache", ResponseCache); + +#[derive(Clone)] +pub(crate) struct ResponseCache { + pub(super) storage: Arc, + endpoint_config: Option>, + subgraphs: Arc>, + entity_type: Option, + enabled: bool, + metrics: Metrics, + debug: bool, + private_queries: Arc>>, + pub(crate) invalidation: Invalidation, + supergraph_schema: Arc>, + /// map containing the enum GRAPH + subgraph_enums: Arc>, + /// To close all related tasks + drop_tx: Sender<()>, + lru_size_instrument: LruSizeInstrument, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +struct PrivateQueryKey { + query_hash: String, + has_private_id: bool, +} + +impl Drop for ResponseCache { + fn drop(&mut self) { + let _ = self.drop_tx.send(()); + } +} + +#[derive(Clone)] +pub(crate) struct Storage { + pub(crate) all: Option>>, + pub(crate) subgraphs: HashMap>>, +} + +impl Storage { + pub(crate) fn get(&self, subgraph: &str) -> Option<&PostgresCacheStorage> { + match self.subgraphs.get(subgraph) { + Some(subgraph) => subgraph.get(), + None => self.all.as_ref().and_then(|s| s.get()), + } + } + + pub(crate) async fn migrate(&self) -> anyhow::Result<()> { + if let Some(all) = self.all.as_ref().and_then(|all| all.get()) { + all.migrate().await?; + } + futures::future::try_join_all( + self.subgraphs + .values() + .filter_map(|s| Some(s.get()?.migrate())), + ) + .await?; + + Ok(()) + } + + /// Spawn tokio task to refresh metrics about expired data count + fn expired_data_count_tasks(&self, drop_signal: Receiver<()>) { + if let Some(all) = self.all.as_ref().and_then(|all| all.get()) { + tokio::task::spawn(metrics::expired_data_task( + all.clone(), + drop_signal.resubscribe(), + None, + )); + } + for (subgraph_name, subgraph_cache_storage) in &self.subgraphs { + if let Some(subgraph_cache_storage) = subgraph_cache_storage.get() { + tokio::task::spawn(metrics::expired_data_task( + subgraph_cache_storage.clone(), + drop_signal.resubscribe(), + subgraph_name.clone().into(), + )); + } + } + } + + pub(crate) async fn update_cron(&self) -> anyhow::Result<()> { + if let Some(all) = self.all.as_ref().and_then(|all| all.get()) { + all.update_cron().await?; + } + futures::future::try_join_all( + self.subgraphs + .values() + .filter_map(|s| Some(s.get()?.update_cron())), + ) + .await?; + + Ok(()) + } +} + +/// Configuration for response caching +#[derive(Clone, Debug, JsonSchema, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub(crate) struct Config { + /// Enable or disable the response caching feature + #[serde(default)] + pub(crate) enabled: bool, + + #[serde(default)] + /// Enable debug mode for the debugger + debug: bool, + + /// Configure invalidation per subgraph + pub(crate) subgraph: SubgraphConfiguration, + + /// Global invalidation configuration + invalidation: Option, + + /// Response caching evaluation metrics + #[serde(default)] + metrics: Metrics, + + /// Buffer size for known private queries (default: 2048) + #[serde(default = "default_lru_private_queries_size")] + private_queries_buffer_size: NonZeroUsize, +} + +const fn default_lru_private_queries_size() -> NonZeroUsize { + DEFAULT_LRU_PRIVATE_QUERIES_SIZE +} + +/// Per subgraph configuration for response caching +#[derive(Clone, Debug, JsonSchema, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields, default)] +pub(crate) struct Subgraph { + /// PostgreSQL configuration + pub(crate) postgres: Option, + + /// expiration for all keys for this subgraph, unless overridden by the `Cache-Control` header in subgraph responses + pub(crate) ttl: Option, + + /// activates caching for this subgraph, overrides the global configuration + pub(crate) enabled: Option, + + /// Context key used to separate cache sections per user + pub(crate) private_id: Option, + + /// Invalidation configuration + pub(crate) invalidation: Option, +} + +impl Default for Subgraph { + fn default() -> Self { + Self { + postgres: None, + enabled: Some(true), + ttl: Default::default(), + private_id: Default::default(), + invalidation: Default::default(), + } + } +} + +/// Per subgraph configuration for response caching +#[derive(Clone, Debug, JsonSchema, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub(crate) struct Ttl( + #[serde(deserialize_with = "humantime_serde::deserialize")] + #[schemars(with = "String")] + pub(crate) Duration, +); + +/// Per subgraph configuration for response caching +#[derive(Clone, Debug, Default, JsonSchema, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +struct Metrics { + /// enables metrics evaluating the benefits of response caching + #[serde(default)] + pub(crate) enabled: bool, + /// Metrics counter TTL + pub(crate) ttl: Option, + /// Adds the entity type name to attributes. This can greatly increase the cardinality + #[serde(default)] + pub(crate) separate_per_type: bool, +} + +#[derive(Default, Serialize, Deserialize, Debug)] +#[serde(default)] +pub(crate) struct CacheSubgraph(pub(crate) HashMap); + +#[derive(Default, Serialize, Deserialize, Debug)] +#[serde(default)] +pub(crate) struct CacheHitMiss { + pub(crate) hit: usize, + pub(crate) miss: usize, +} + +#[async_trait::async_trait] +impl PluginPrivate for ResponseCache { + const HIDDEN_FROM_CONFIG_JSON_SCHEMA: bool = true; + type Config = Config; + + async fn new(init: PluginInit) -> Result + where + Self: Sized, + { + let entity_type = init + .supergraph_schema + .schema_definition + .query + .as_ref() + .map(|q| q.name.to_string()); + + let mut all = None; + let (drop_tx, drop_rx) = broadcast::channel(2); + let mut task_aborts = Vec::new(); + if let Some(postgres) = &init.config.subgraph.all.postgres { + let postgres_config = postgres.clone(); + let required_to_start = postgres_config.required_to_start; + all = match PostgresCacheStorage::new(&postgres_config).await { + Ok(storage) => Some(Arc::new(OnceLock::from(storage))), + Err(e) => { + tracing::error!( + cache = "response", + error = %e, + "could not open connection to Postgres for caching", + ); + if required_to_start { + return Err(e.into()); + } else { + let pg_cache_storage = Arc::new(OnceLock::new()); + task_aborts.push( + tokio::spawn(check_pg_connection( + postgres_config, + pg_cache_storage.clone(), + drop_rx, + None, + )) + .abort_handle(), + ); + Some(pg_cache_storage) + } + } + }; + } + let mut subgraph_storages = HashMap::new(); + for (subgraph, config) in &init.config.subgraph.subgraphs { + if let Some(postgres) = &config.postgres { + let required_to_start = postgres.required_to_start; + let storage = match PostgresCacheStorage::new(postgres).await { + Ok(storage) => Arc::new(OnceLock::from(storage)), + Err(e) => { + tracing::error!( + cache = "response", + error = %e, + "could not open connection to Postgres for caching", + ); + if required_to_start { + return Err(e.into()); + } else { + let pg_cache_storage = Arc::new(OnceLock::new()); + task_aborts.push( + tokio::spawn(check_pg_connection( + postgres.clone(), + pg_cache_storage.clone(), + drop_tx.subscribe(), + subgraph.clone().into(), + )) + .abort_handle(), + ); + pg_cache_storage + } + } + }; + subgraph_storages.insert(subgraph.clone(), storage); + } + } + + if init.config.subgraph.all.ttl.is_none() + && init + .config + .subgraph + .subgraphs + .values() + .any(|s| s.ttl.is_none()) + { + return Err("a TTL must be configured for all subgraphs or globally" + .to_string() + .into()); + } + + if init + .config + .subgraph + .all + .invalidation + .as_ref() + .map(|i| i.shared_key.is_empty()) + .unwrap_or_default() + { + return Err( + "you must set a default shared_key invalidation for all subgraphs" + .to_string() + .into(), + ); + } + + let storage = Arc::new(Storage { + all, + subgraphs: subgraph_storages, + }); + storage.migrate().await?; + storage.update_cron().await?; + + let invalidation = Invalidation::new(storage.clone()).await?; + + Ok(Self { + storage, + entity_type, + enabled: init.config.enabled, + debug: init.config.debug, + endpoint_config: init.config.invalidation.clone().map(Arc::new), + subgraphs: Arc::new(init.config.subgraph), + metrics: init.config.metrics, + private_queries: Arc::new(RwLock::new(LruCache::new( + init.config.private_queries_buffer_size, + ))), + invalidation, + subgraph_enums: Arc::new(get_subgraph_enums(&init.supergraph_schema)), + supergraph_schema: init.supergraph_schema, + drop_tx, + lru_size_instrument: LruSizeInstrument::new(LRU_PRIVATE_QUERIES_INSTRUMENT_NAME), + }) + } + + fn activate(&self) { + self.storage + .expired_data_count_tasks(self.drop_tx.subscribe()); + } + + fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { + let debug = self.debug; + ServiceBuilder::new() + .map_response(move |mut response: supergraph::Response| { + if let Some(cache_control) = response + .context + .extensions() + .with_lock(|lock| lock.get::().cloned()) + { + let _ = cache_control.to_headers(response.response.headers_mut()); + } + + if debug + && let Some(debug_data) = + response.context.get_json_value(CONTEXT_DEBUG_CACHE_KEYS) + { + return response.map_stream(move |mut body| { + body.extensions.insert( + CACHE_DEBUG_EXTENSIONS_KEY, + serde_json_bytes::json!({ + "version": CACHE_DEBUGGER_VERSION, + "data": debug_data.clone() + }), + ); + body + }); + } + + response + }) + .service(service) + .boxed() + } + + fn subgraph_service( + &self, + name: &str, + mut service: subgraph::BoxService, + ) -> subgraph::BoxService { + let subgraph_ttl = self + .subgraph_ttl(name) + .unwrap_or_else(|| Duration::from_secs(60 * 60 * 24)); // The unwrap should not happen because it's checked when creating the plugin + let subgraph_enabled = self.subgraph_enabled(name); + let private_id = self.subgraphs.get(name).private_id.clone(); + + let name = name.to_string(); + + if self.metrics.enabled { + service = CacheMetricsService::create( + name.to_string(), + service, + self.metrics.ttl.as_ref(), + self.metrics.separate_per_type, + ); + } + + if subgraph_enabled { + let private_queries = self.private_queries.clone(); + let inner = ServiceBuilder::new() + .map_response(move |response: subgraph::Response| { + update_cache_control( + &response.context, + &CacheControl::new(response.response.headers(), None) + .ok() + .unwrap_or_else(CacheControl::no_store), + ); + + response + }) + .service(CacheService { + service: ServiceBuilder::new() + .buffered() + .service(service) + .boxed_clone(), + entity_type: self.entity_type.clone(), + name: name.to_string(), + storage: self.storage.clone(), + subgraph_ttl, + private_queries, + private_id, + debug: self.debug, + supergraph_schema: self.supergraph_schema.clone(), + subgraph_enums: self.subgraph_enums.clone(), + lru_size_instrument: self.lru_size_instrument.clone(), + }); + tower::util::BoxService::new(inner) + } else { + ServiceBuilder::new() + .map_response(move |response: subgraph::Response| { + update_cache_control( + &response.context, + &CacheControl::new(response.response.headers(), None) + .ok() + .unwrap_or_else(CacheControl::no_store), + ); + + response + }) + .service(service) + .boxed() + } + } + + fn web_endpoints(&self) -> MultiMap { + let mut map = MultiMap::new(); + if self.enabled + && self + .subgraphs + .all + .invalidation + .as_ref() + .map(|i| i.enabled) + .unwrap_or_default() + { + match &self.endpoint_config { + Some(endpoint_config) => { + let endpoint = Endpoint::from_router_service( + endpoint_config.path.clone(), + InvalidationService::new(self.subgraphs.clone(), self.invalidation.clone()) + .boxed(), + ); + tracing::info!( + "Response cache invalidation endpoint listening on: {}{}", + endpoint_config.listen, + endpoint_config.path + ); + map.insert(endpoint_config.listen.clone(), endpoint); + } + None => { + tracing::warn!( + "Cannot start response cache invalidation endpoint because the listen address and endpoint is not configured" + ); + } + } + } + + map + } +} + +#[cfg(all( + test, + any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")) +))] +pub(super) const INVALIDATION_SHARED_KEY: &str = "supersecret"; +impl ResponseCache { + #[cfg(all( + test, + any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")) + ))] + pub(crate) async fn for_test( + storage: PostgresCacheStorage, + subgraphs: HashMap, + supergraph_schema: Arc>, + truncate_namespace: bool, + update_cron: bool, + ) -> Result + where + Self: Sized, + { + use std::net::IpAddr; + use std::net::Ipv4Addr; + use std::net::SocketAddr; + storage.migrate().await?; + if update_cron { + storage.update_cron().await?; + } + if truncate_namespace { + storage.truncate_namespace().await?; + } + + let storage = Arc::new(Storage { + all: Some(Arc::new(storage.into())), + subgraphs: HashMap::new(), + }); + let invalidation = Invalidation::new(storage.clone()).await?; + let (drop_tx, _drop_rx) = broadcast::channel(2); + Ok(Self { + storage, + entity_type: None, + enabled: true, + debug: true, + subgraphs: Arc::new(SubgraphConfiguration { + all: Subgraph { + invalidation: Some(SubgraphInvalidationConfig { + enabled: true, + shared_key: INVALIDATION_SHARED_KEY.to_string(), + }), + ..Default::default() + }, + subgraphs, + }), + metrics: Metrics::default(), + private_queries: Arc::new(RwLock::new(LruCache::new(DEFAULT_LRU_PRIVATE_QUERIES_SIZE))), + endpoint_config: Some(Arc::new(InvalidationEndpointConfig { + path: String::from("/invalidation"), + listen: ListenAddr::SocketAddr(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), + 4000, + )), + })), + invalidation, + subgraph_enums: Arc::new(get_subgraph_enums(&supergraph_schema)), + supergraph_schema, + drop_tx, + lru_size_instrument: LruSizeInstrument::new(LRU_PRIVATE_QUERIES_INSTRUMENT_NAME), + }) + } + #[cfg(all( + test, + any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")) + ))] + /// Use this method when you want to test ResponseCache without database available + pub(crate) async fn without_storage_for_failure_mode( + subgraphs: HashMap, + supergraph_schema: Arc>, + ) -> Result + where + Self: Sized, + { + use std::net::IpAddr; + use std::net::Ipv4Addr; + use std::net::SocketAddr; + + let storage = Arc::new(Storage { + all: Some(Default::default()), + subgraphs: HashMap::new(), + }); + let invalidation = Invalidation::new(storage.clone()).await?; + let (drop_tx, _drop_rx) = broadcast::channel(2); + + Ok(Self { + storage, + entity_type: None, + enabled: true, + debug: true, + subgraphs: Arc::new(SubgraphConfiguration { + all: Subgraph { + invalidation: Some(SubgraphInvalidationConfig { + enabled: true, + shared_key: INVALIDATION_SHARED_KEY.to_string(), + }), + ..Default::default() + }, + subgraphs, + }), + metrics: Metrics::default(), + private_queries: Arc::new(RwLock::new(LruCache::new(DEFAULT_LRU_PRIVATE_QUERIES_SIZE))), + endpoint_config: Some(Arc::new(InvalidationEndpointConfig { + path: String::from("/invalidation"), + listen: ListenAddr::SocketAddr(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), + 4000, + )), + })), + invalidation, + subgraph_enums: Arc::new(get_subgraph_enums(&supergraph_schema)), + supergraph_schema, + drop_tx, + lru_size_instrument: LruSizeInstrument::new(LRU_PRIVATE_QUERIES_INSTRUMENT_NAME), + }) + } + + // Returns boolean to know if cache is enabled for this subgraph + fn subgraph_enabled(&self, subgraph_name: &str) -> bool { + if !self.enabled { + return false; + } + match ( + self.subgraphs.all.enabled, + self.subgraphs.get(subgraph_name).enabled, + ) { + (_, Some(x)) => x, // explicit per-subgraph setting overrides the `all` default + (Some(true) | None, None) => true, // unset defaults to true + (Some(false), None) => false, + } + } + + // Returns the configured ttl for this subgraph + fn subgraph_ttl(&self, subgraph_name: &str) -> Option { + self.subgraphs + .get(subgraph_name) + .ttl + .clone() + .map(|t| t.0) + .or_else(|| self.subgraphs.all.ttl.clone().map(|ttl| ttl.0)) + } +} + +/// Get the map of subgraph enum variant mapped with subgraph name +fn get_subgraph_enums(supergraph_schema: &Valid) -> HashMap { + let mut subgraph_enums = HashMap::new(); + if let Some(graph_enum) = supergraph_schema.get_enum("join__Graph") { + subgraph_enums.extend(graph_enum.values.iter().filter_map( + |(enum_name, enum_value_def)| { + let subgraph_name = enum_value_def + .directives + .get("join__graph")? + .specified_argument_by_name("name")? + .as_str()? + .to_string(); + + Some((enum_name.to_string(), subgraph_name)) + }, + )); + } + + subgraph_enums +} + +#[derive(Clone)] +struct CacheService { + service: subgraph::BoxCloneService, + name: String, + entity_type: Option, + storage: Arc, + subgraph_ttl: Duration, + private_queries: Arc>>, + private_id: Option, + debug: bool, + supergraph_schema: Arc>, + subgraph_enums: Arc>, + lru_size_instrument: LruSizeInstrument, +} + +impl Service for CacheService { + type Response = subgraph::Response; + type Error = BoxError; + type Future = >::Future; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.service.poll_ready(cx) + } + + fn call(&mut self, request: subgraph::Request) -> Self::Future { + let clone = self.clone(); + let inner = std::mem::replace(self, clone); + + Box::pin(inner.call_inner(request)) + } +} + +impl CacheService { + async fn call_inner( + mut self, + request: subgraph::Request, + ) -> Result { + let storage = match self.storage.get(&self.name) { + Some(storage) => storage.clone(), + None => { + u64_counter_with_unit!( + "apollo.router.operations.response_cache.fetch.error", + "Errors when fetching data from cache", + "{error}", + 1, + "subgraph.name" = self.name.clone(), + "code" = "NO_STORAGE" + ); + + return self + .service + .map_response(move |response: subgraph::Response| { + update_cache_control( + &response.context, + &CacheControl::new(response.response.headers(), None) + .ok() + .unwrap_or_else(CacheControl::no_store), + ); + + response + }) + .call(request) + .await; + } + }; + + self.debug = self.debug + && (request + .supergraph_request + .headers() + .get(CACHE_DEBUG_HEADER_NAME) + == Some(&HeaderValue::from_static("true"))); + // Check if the request is part of a batch. If it is, completely bypass response caching since it + // will break any request batches which this request is part of. + // This check is what enables Batching and response caching to work together, so be very careful + // before making any changes to it. + if request + .context + .extensions() + .with_lock(|lock| lock.contains_key::()) + { + return self.service.call(request).await; + } + // Don't use cache at all if no-store is set in cache-control header + if request + .subgraph_request + .headers() + .contains_key(&CACHE_CONTROL) + { + let cache_control = match CacheControl::new(request.subgraph_request.headers(), None) { + Ok(cache_control) => cache_control, + Err(err) => { + return Ok(subgraph::Response::builder() + .subgraph_name(request.subgraph_name) + .context(request.context) + .error( + graphql::Error::builder() + .message(format!("cannot get cache-control header: {err}")) + .extension_code("INVALID_CACHE_CONTROL_HEADER") + .build(), + ) + .extensions(Object::default()) + .build()); + } + }; + if cache_control.no_store { + let mut resp = self.service.call(request).await?; + cache_control.to_headers(resp.response.headers_mut())?; + return Ok(resp); + } + } + let private_id = self.get_private_id(&request.context); + // Knowing if there's a private_id or not will differentiate the hash because for a same query it can be both public and private depending if we have private_id set or not + let private_query_key = PrivateQueryKey { + query_hash: hash_query(&request.query_hash, request.subgraph_request.body()), + has_private_id: private_id.is_some(), + }; + + let is_known_private = { + self.private_queries + .read() + .await + .contains(&private_query_key) + }; + + // the response will have a private scope but we don't have a way to differentiate users, so we know we will not get or store anything in the cache + if is_known_private && private_id.is_none() { + let mut debug_subgraph_request = None; + let mut root_operation_fields = Vec::new(); + if self.debug { + root_operation_fields = request + .executable_document + .as_ref() + .and_then(|executable_document| { + let operation_name = + request.subgraph_request.body().operation_name.as_deref(); + Some( + executable_document + .operations + .get(operation_name) + .ok()? + .root_fields(executable_document) + .map(|f| f.name.to_string()) + .collect(), + ) + }) + .unwrap_or_default(); + debug_subgraph_request = Some(request.subgraph_request.body().clone()); + } + let is_entity = request + .subgraph_request + .body() + .variables + .contains_key(REPRESENTATIONS); + let resp = self.service.call(request).await?; + if self.debug { + let cache_control = CacheControl::new(resp.response.headers(), None)?; + let kind = if is_entity { + CacheEntryKind::Entity { + typename: "".to_string(), + entity_key: Default::default(), + } + } else { + CacheEntryKind::RootFields { + root_fields: root_operation_fields, + } + }; + resp.context.upsert::<_, CacheKeysContext>( + CONTEXT_DEBUG_CACHE_KEYS, + |mut val| { + val.push(CacheKeyContext { + key: "-".to_string(), + invalidation_keys: vec![], + kind, + hashed_private_id: private_id.clone(), + subgraph_name: self.name.clone(), + subgraph_request: debug_subgraph_request.unwrap_or_default(), + source: CacheKeySource::Subgraph, + cache_control, + data: serde_json_bytes::to_value(resp.response.body().clone()) + .unwrap_or_default(), + }); + + val + }, + )?; + } + + return Ok(resp); + } + + if !request + .subgraph_request + .body() + .variables + .contains_key(REPRESENTATIONS) + { + if request.operation_kind == OperationKind::Query { + let mut cache_hit: HashMap = HashMap::new(); + match cache_lookup_root( + self.name.clone(), + self.entity_type.as_deref(), + storage.clone(), + is_known_private, + private_id.as_deref(), + self.debug, + request, + self.supergraph_schema.clone(), + &self.subgraph_enums, + ) + .instrument(tracing::info_span!( + "response_cache.lookup", + kind = "root", + "graphql.type" = self.entity_type.as_deref().unwrap_or_default(), + debug = self.debug, + private = is_known_private, + contains_private_id = private_id.is_some() + )) + .await? + { + ControlFlow::Break(response) => { + cache_hit.insert("Query".to_string(), CacheHitMiss { hit: 1, miss: 0 }); + let _ = response.context.insert( + CacheMetricContextKey::new(response.subgraph_name.clone()), + CacheSubgraph(cache_hit), + ); + Ok(response) + } + ControlFlow::Continue((request, mut root_cache_key, invalidation_keys)) => { + cache_hit.insert("Query".to_string(), CacheHitMiss { hit: 0, miss: 1 }); + let _ = request.context.insert( + CacheMetricContextKey::new(request.subgraph_name.clone()), + CacheSubgraph(cache_hit), + ); + let mut root_operation_fields: Vec = Vec::new(); + let mut debug_subgraph_request = None; + if self.debug { + root_operation_fields = request + .executable_document + .as_ref() + .and_then(|executable_document| { + let operation_name = + request.subgraph_request.body().operation_name.as_deref(); + Some( + executable_document + .operations + .get(operation_name) + .ok()? + .root_fields(executable_document) + .map(|f| f.name.to_string()) + .collect(), + ) + }) + .unwrap_or_default(); + debug_subgraph_request = Some(request.subgraph_request.body().clone()); + } + let response = self.service.call(request).await?; + + let cache_control = + if response.response.headers().contains_key(CACHE_CONTROL) { + CacheControl::new( + response.response.headers(), + self.subgraph_ttl.into(), + )? + } else { + CacheControl { + no_store: true, + ..Default::default() + } + }; + + if cache_control.private() { + // we did not know in advance that this was a query with a private scope, so we update the cache key + if !is_known_private { + let size = { + let mut private_queries = self.private_queries.write().await; + private_queries.put(private_query_key.clone(), ()); + private_queries.len() + }; + self.lru_size_instrument.update(size as u64); + + if let Some(s) = private_id.as_ref() { + root_cache_key = format!("{root_cache_key}:{s}"); + } + } + + if self.debug { + response.context.upsert::<_, CacheKeysContext>( + CONTEXT_DEBUG_CACHE_KEYS, + |mut val| { + val.push(CacheKeyContext { + key: root_cache_key.clone(), + hashed_private_id: private_id.clone(), + invalidation_keys: invalidation_keys + .clone() + .into_iter() + .filter(|k| { + !k.starts_with(INTERNAL_CACHE_TAG_PREFIX) + }) + .collect(), + kind: CacheEntryKind::RootFields { + root_fields: root_operation_fields, + }, + subgraph_name: self.name.clone(), + subgraph_request: debug_subgraph_request + .unwrap_or_default(), + source: CacheKeySource::Subgraph, + cache_control: cache_control.clone(), + data: serde_json_bytes::to_value( + response.response.body().clone(), + ) + .unwrap_or_default(), + }); + + val + }, + )?; + } + + if private_id.is_none() { + // the response has a private scope but we don't have a way to differentiate users, so we do not store the response in cache + // We don't need to fill the context with this cache key as it will never be cached + return Ok(response); + } + } else if self.debug { + response.context.upsert::<_, CacheKeysContext>( + CONTEXT_DEBUG_CACHE_KEYS, + |mut val| { + val.push(CacheKeyContext { + key: root_cache_key.clone(), + hashed_private_id: private_id.clone(), + invalidation_keys: invalidation_keys + .clone() + .into_iter() + .filter(|k| !k.starts_with(INTERNAL_CACHE_TAG_PREFIX)) + .collect(), + kind: CacheEntryKind::RootFields { + root_fields: root_operation_fields, + }, + subgraph_name: self.name.clone(), + subgraph_request: debug_subgraph_request + .unwrap_or_default(), + source: CacheKeySource::Subgraph, + cache_control: cache_control.clone(), + data: serde_json_bytes::to_value( + response.response.body().clone(), + ) + .unwrap_or_default(), + }); + + val + }, + )?; + } + + if cache_control.should_store() { + cache_store_root_from_response( + storage, + self.subgraph_ttl, + &response, + cache_control, + root_cache_key, + invalidation_keys, + self.debug, + ) + .await?; + } + + Ok(response) + } + } + } else { + let response = self.service.call(request).await?; + + Ok(response) + } + } else { + match cache_lookup_entities( + self.name.clone(), + self.supergraph_schema.clone(), + &self.subgraph_enums, + storage.clone(), + is_known_private, + private_id.as_deref(), + request, + self.debug, + ) + .instrument(tracing::info_span!( + "response_cache.lookup", + kind = "entity", + debug = self.debug, + private = is_known_private, + contains_private_id = private_id.is_some() + )) + .await? + { + ControlFlow::Break(response) => Ok(response), + ControlFlow::Continue((request, mut cache_result)) => { + let context = request.context.clone(); + let mut debug_subgraph_request = None; + if self.debug { + debug_subgraph_request = Some(request.subgraph_request.body().clone()); + let debug_cache_keys_ctx = cache_result.0.iter().filter_map(|ir| { + ir.cache_entry.as_ref().map(|cache_entry| CacheKeyContext { + hashed_private_id: private_id.clone(), + key: cache_entry.cache_key.clone(), + invalidation_keys: ir.invalidation_keys.clone().into_iter() + .filter(|k| !k.starts_with(INTERNAL_CACHE_TAG_PREFIX)) + .collect(), + kind: CacheEntryKind::Entity { + typename: ir.typename.clone(), + entity_key: ir.entity_key.clone(), + }, + subgraph_name: self.name.clone(), + subgraph_request: request.subgraph_request.body().clone(), + source: CacheKeySource::Cache, + cache_control: cache_entry.control.clone(), + data: serde_json_bytes::json!({ + "data": serde_json_bytes::to_value(cache_entry.data.clone()).unwrap_or_default() + }), + }) + }); + request.context.upsert::<_, CacheKeysContext>( + CONTEXT_DEBUG_CACHE_KEYS, + |mut val| { + val.extend(debug_cache_keys_ctx); + + val + }, + )?; + } + + let mut response = match self.service.call(request).await { + Ok(response) => response, + Err(e) => { + let e = match e.downcast::() { + Ok(inner) => match *inner { + FetchError::SubrequestHttpError { .. } => *inner, + _ => FetchError::SubrequestHttpError { + status_code: None, + service: self.name.to_string(), + reason: inner.to_string(), + }, + }, + Err(e) => FetchError::SubrequestHttpError { + status_code: None, + service: self.name.to_string(), + reason: e.to_string(), + }, + }; + + let graphql_error = e.to_graphql_error(None); + + let (new_entities, new_errors) = assemble_response_from_errors( + &[graphql_error], + &mut cache_result.0, + ); + + let mut data = Object::default(); + data.insert(ENTITIES, new_entities.into()); + + let mut response = subgraph::Response::builder() + .context(context) + .data(Value::Object(data)) + .errors(new_errors) + .subgraph_name(self.name) + .extensions(Object::new()) + .build(); + CacheControl::no_store().to_headers(response.response.headers_mut())?; + + return Ok(response); + } + }; + + let mut cache_control = if response + .response + .headers() + .contains_key(CACHE_CONTROL) + { + CacheControl::new(response.response.headers(), self.subgraph_ttl.into())? + } else { + CacheControl::no_store() + }; + + if let Some(control_from_cached) = cache_result.1 { + cache_control = cache_control.merge(&control_from_cached); + } + + if !is_known_private && cache_control.private() { + self.private_queries + .write() + .await + .put(private_query_key, ()); + } + + cache_store_entities_from_response( + storage, + self.subgraph_ttl, + &mut response, + cache_control.clone(), + cache_result.0, + is_known_private, + private_id, + debug_subgraph_request, + ) + .await?; + + cache_control.to_headers(response.response.headers_mut())?; + + Ok(response) + } + } + } + } + + fn get_private_id(&self, context: &Context) -> Option { + self.private_id.as_ref().and_then(|key| { + context.get_json_value(key).and_then(|value| { + value.as_str().map(|s| { + let mut digest = Sha256::new(); + digest.update(s); + hex::encode(digest.finalize().as_slice()) + }) + }) + }) + } +} + +#[allow(clippy::too_many_arguments)] +async fn cache_lookup_root( + name: String, + entity_type_opt: Option<&str>, + cache: PostgresCacheStorage, + is_known_private: bool, + private_id: Option<&str>, + debug: bool, + mut request: subgraph::Request, + supergraph_schema: Arc>, + subgraph_enums: &HashMap, +) -> Result)>, BoxError> { + let invalidation_cache_keys = + get_invalidation_root_keys_from_schema(&request, subgraph_enums, supergraph_schema)?; + let body = request.subgraph_request.body_mut(); + + let (key, mut invalidation_keys) = extract_cache_key_root( + &name, + entity_type_opt, + &request.query_hash, + body, + &request.context, + &request.authorization, + is_known_private, + private_id, + ); + invalidation_keys.extend(invalidation_cache_keys); + + let now = Instant::now(); + let cache_result = cache.get(&key).await; + f64_histogram_with_unit!( + "apollo.router.operations.response_cache.fetch", + "Time to fetch data from cache", + "s", + now.elapsed().as_secs_f64(), + "subgraph.name" = request.subgraph_name.clone(), + "kind" = "single" + ); + + match cache_result { + Ok(value) => { + if value.control.can_use() { + let control = value.control.clone(); + update_cache_control(&request.context, &control); + if debug { + let root_operation_fields: Vec = request + .executable_document + .as_ref() + .and_then(|executable_document| { + Some( + executable_document + .operations + .iter() + .next()? + .root_fields(executable_document) + .map(|f| f.name.to_string()) + .collect(), + ) + }) + .unwrap_or_default(); + + request.context.upsert::<_, CacheKeysContext>( + CONTEXT_DEBUG_CACHE_KEYS, + |mut val| { + val.push(CacheKeyContext { + key: value.cache_key.clone(), + hashed_private_id: private_id.map(ToString::to_string), + invalidation_keys: invalidation_keys + .clone() + .into_iter() + .filter(|k| !k.starts_with(INTERNAL_CACHE_TAG_PREFIX)) + .collect(), + kind: CacheEntryKind::RootFields { + root_fields: root_operation_fields, + }, + subgraph_name: request.subgraph_name.clone(), + subgraph_request: request.subgraph_request.body().clone(), + source: CacheKeySource::Cache, + cache_control: value.control.clone(), + data: serde_json_bytes::json!({"data": value.data.clone()}), + }); + + val + }, + )?; + } + + Span::current().set_span_dyn_attribute( + opentelemetry::Key::new("cache.status"), + opentelemetry::Value::String("hit".into()), + ); + let mut response = subgraph::Response::builder() + .data(value.data) + .extensions(Object::new()) + .context(request.context) + .subgraph_name(request.subgraph_name.clone()) + .build(); + + value.control.to_headers(response.response.headers_mut())?; + Ok(ControlFlow::Break(response)) + } else { + Span::current().set_span_dyn_attribute( + opentelemetry::Key::new("cache.status"), + opentelemetry::Value::String("miss".into()), + ); + Ok(ControlFlow::Continue((request, key, invalidation_keys))) + } + } + Err(err) => { + let span = Span::current(); + if !matches!(err, sqlx::Error::RowNotFound) { + span.mark_as_error(format!("cannot get cache entry: {err}")); + + u64_counter_with_unit!( + "apollo.router.operations.response_cache.fetch.error", + "Errors when fetching data from cache", + "{error}", + 1, + "subgraph.name" = name, + "code" = err.code() + ); + } + + span.set_span_dyn_attribute( + opentelemetry::Key::new("cache.status"), + opentelemetry::Value::String("miss".into()), + ); + Ok(ControlFlow::Continue((request, key, invalidation_keys))) + } + } +} + +fn get_invalidation_root_keys_from_schema( + request: &subgraph::Request, + subgraph_enums: &HashMap, + supergraph_schema: Arc>, +) -> Result, anyhow::Error> { + let subgraph_name = &request.subgraph_name; + let executable_document = + request + .executable_document + .as_ref() + .ok_or_else(|| FetchError::MalformedRequest { + reason: "cannot get the executable document for subgraph request".to_string(), + })?; + let root_operation_fields = executable_document + .operations + .get(request.subgraph_request.body().operation_name.as_deref()) + .map_err(|_err| FetchError::MalformedRequest { + reason: "cannot get the operation from executable document for subgraph request" + .to_string(), + })? + .root_fields(executable_document); + let root_query_type = supergraph_schema + .root_operation(apollo_compiler::ast::OperationType::Query) + .ok_or_else(|| FetchError::MalformedRequest { + reason: "cannot get the root operation from supergraph schema".to_string(), + })?; + let query_object_type = supergraph_schema + .get_object(root_query_type.as_str()) + .ok_or_else(|| FetchError::MalformedRequest { + reason: "cannot get the root query type from supergraph schema".to_string(), + })?; + + let cache_keys = root_operation_fields + .map(|field| { + // We don't use field.definition because we need the directive set in supergraph schema not in the executable document + let field_def = query_object_type.fields.get(&field.name).ok_or_else(|| { + FetchError::MalformedRequest { + reason: "cannot get the field definition from supergraph schema".to_string(), + } + })?; + let cache_keys = field_def + .directives + .get_all("join__directive") + .filter_map(|dir| { + let name = dir.argument_by_name("name", &supergraph_schema).ok()?; + if name.as_str()? != CACHE_TAG_DIRECTIVE_NAME { + return None; + } + let is_current_subgraph = + dir.argument_by_name("graphs", &supergraph_schema) + .ok() + .and_then(|f| { + Some(f.as_list()?.iter().filter_map(|graph| graph.as_enum()).any( + |g| { + subgraph_enums.get(g.as_str()).map(|s| s.as_str()) + == Some(subgraph_name) + }, + )) + }) + .unwrap_or_default(); + if !is_current_subgraph { + return None; + } + let mut format = None; + for (field_name, value) in dir + .argument_by_name("args", &supergraph_schema) + .ok()? + .as_object()? + { + if field_name.as_str() == "format" { + format = value + .as_str() + .and_then(|v| v.parse::().ok()) + } + } + format + }); + let mut errors = Vec::new(); + // Query::validate_variables runs before this + let variable_values = + Valid::assume_valid_ref(&request.subgraph_request.body().variables); + let args = coerce_argument_values( + &supergraph_schema, + executable_document, + variable_values, + &mut errors, + Default::default(), + field_def, + field, + ) + .map_err(|_| FetchError::MalformedRequest { + reason: format!("cannot argument values for root fields {:?}", field.name), + })?; + + if !errors.is_empty() { + return Err(FetchError::MalformedRequest { + reason: format!( + "cannot coerce argument values for root fields {:?}, errors: {errors:?}", + field.name, + ), + } + .into()); + } + + let mut vars = IndexMap::default(); + vars.insert("$args".to_string(), Value::Object(args)); + cache_keys + .map(|ck| Ok(ck.interpolate(&vars).map(|(res, _)| res)?)) + .collect::, anyhow::Error>>() + }) + .collect::>, anyhow::Error>>()?; + + let invalidation_cache_keys: HashSet = cache_keys.into_iter().flatten().collect(); + + Ok(invalidation_cache_keys) +} + +struct ResponseCacheResults(Vec, Option); + +#[allow(clippy::too_many_arguments)] +async fn cache_lookup_entities( + name: String, + supergraph_schema: Arc>, + subgraph_enums: &HashMap, + cache: PostgresCacheStorage, + is_known_private: bool, + private_id: Option<&str>, + mut request: subgraph::Request, + debug: bool, +) -> Result, BoxError> { + let cache_metadata = extract_cache_keys( + &name, + supergraph_schema, + subgraph_enums, + &mut request, + is_known_private, + private_id, + )?; + let keys_len = cache_metadata.len(); + + let now = Instant::now(); + let cache_result = cache + .get_multiple( + &cache_metadata + .iter() + .map(|k| k.cache_key.as_str()) + .collect::>(), + ) + .await; + + f64_histogram_with_unit!( + "apollo.router.operations.response_cache.fetch", + "Time to fetch data from cache", + "s", + now.elapsed().as_secs_f64(), + "subgraph.name" = request.subgraph_name.clone(), + "kind" = "batch" + ); + + let cache_result: Vec> = match cache_result { + Ok(res) => { + Span::current().set_span_dyn_attribute( + opentelemetry::Key::new("cache.status"), + opentelemetry::Value::String("hit".into()), + ); + res.into_iter() + .map(|v| match v { + Some(v) if v.control.can_use() => Some(v), + _ => None, + }) + .collect() + } + Err(err) => { + let span = Span::current(); + if !matches!(err, sqlx::Error::RowNotFound) { + span.mark_as_error(format!("cannot get cache entry: {err}")); + + u64_counter_with_unit!( + "apollo.router.operations.response_cache.fetch.error", + "Errors when fetching data from cache", + "{error}", + 1, + "subgraph.name" = name.clone(), + "code" = err.code() + ); + } + span.set_span_dyn_attribute( + opentelemetry::Key::new("cache.status"), + opentelemetry::Value::String("miss".into()), + ); + + std::iter::repeat_n(None, keys_len).collect() + } + }; + let body = request.subgraph_request.body_mut(); + + let representations = body + .variables + .get_mut(REPRESENTATIONS) + .and_then(|value| value.as_array_mut()) + .expect("we already checked that representations exist"); + // remove from representations the entities we already obtained from the cache + let (new_representations, cache_result, cache_control) = filter_representations( + &name, + representations, + cache_metadata, + cache_result, + &request.context, + )?; + + if !new_representations.is_empty() { + body.variables + .insert(REPRESENTATIONS, new_representations.into()); + let cache_status = if cache_result.is_empty() { + opentelemetry::Value::String("miss".into()) + } else { + opentelemetry::Value::String("partial_hit".into()) + }; + Span::current() + .set_span_dyn_attribute(opentelemetry::Key::new("cache.status"), cache_status); + + Ok(ControlFlow::Continue(( + request, + ResponseCacheResults(cache_result, cache_control), + ))) + } else { + if debug { + let debug_cache_keys_ctx = cache_result.iter().filter_map(|ir| { + ir.cache_entry.as_ref().map(|cache_entry| CacheKeyContext { + key: ir.key.clone(), + hashed_private_id: private_id.map(ToString::to_string), + invalidation_keys: ir + .invalidation_keys + .clone() + .into_iter() + .filter(|k| !k.starts_with(INTERNAL_CACHE_TAG_PREFIX)) + .collect(), + kind: CacheEntryKind::Entity { + typename: ir.typename.clone(), + entity_key: ir.entity_key.clone(), + }, + subgraph_name: name.clone(), + subgraph_request: request.subgraph_request.body().clone(), + source: CacheKeySource::Cache, + cache_control: cache_entry.control.clone(), + data: serde_json_bytes::json!({"data": cache_entry.data.clone()}), + }) + }); + request.context.upsert::<_, CacheKeysContext>( + CONTEXT_DEBUG_CACHE_KEYS, + |mut val| { + val.extend(debug_cache_keys_ctx); + + val + }, + )?; + } + Span::current().set_span_dyn_attribute( + opentelemetry::Key::new("cache.status"), + opentelemetry::Value::String("hit".into()), + ); + + let entities = cache_result + .into_iter() + .filter_map(|res| res.cache_entry) + .map(|entry| entry.data) + .collect::>(); + let mut data = Object::default(); + data.insert(ENTITIES, entities.into()); + + let mut response = subgraph::Response::builder() + .data(data) + .extensions(Object::new()) + .subgraph_name(request.subgraph_name) + .context(request.context) + .build(); + + cache_control + .unwrap_or_default() + .to_headers(response.response.headers_mut())?; + + Ok(ControlFlow::Break(response)) + } +} + +fn update_cache_control(context: &Context, cache_control: &CacheControl) { + context.extensions().with_lock(|lock| { + if let Some(c) = lock.get_mut::() { + *c = c.merge(cache_control); + } else { + // Go through the "merge" algorithm even with a single value + // in order to keep single-fetch queries consistent between cache hit and miss, + // and with multi-fetch queries. + lock.insert(cache_control.merge(cache_control)); + } + }) +} + +async fn cache_store_root_from_response( + cache: PostgresCacheStorage, + default_subgraph_ttl: Duration, + response: &subgraph::Response, + cache_control: CacheControl, + cache_key: String, + mut invalidation_keys: Vec, + _debug: bool, +) -> Result<(), BoxError> { + if let Some(data) = response.response.body().data.as_ref() { + let ttl = cache_control + .ttl() + .map(|secs| Duration::from_secs(secs as u64)) + .unwrap_or(default_subgraph_ttl); + + if response.response.body().errors.is_empty() && cache_control.should_store() { + // Support surrogate keys coming from subgraph response extensions + if let Some(Value::Array(cache_tags)) = response + .response + .body() + .extensions + .get(GRAPHQL_RESPONSE_EXTENSION_ROOT_FIELDS_CACHE_TAGS) + { + invalidation_keys.extend( + cache_tags + .iter() + .filter_map(|v| v.as_str()) + .map(|s| s.to_owned()), + ); + } + let data = data.clone(); + + let subgraph_name = response.subgraph_name.clone(); + let span = tracing::info_span!("response_cache.store", "kind" = "root", "subgraph.name" = subgraph_name.clone(), "ttl" = ?ttl); + // Write to cache in a non-awaited task so it’s on in the request’s critical path + tokio::spawn(async move { + let now = Instant::now(); + if let Err(err) = cache + .insert( + &cache_key, + ttl, + invalidation_keys, + data, + cache_control, + &subgraph_name, + ) + .instrument(span) + .await + { + u64_counter_with_unit!( + "apollo.router.operations.response_cache.insert.error", + "Errors when inserting data in cache", + "{error}", + 1, + "subgraph.name" = subgraph_name.clone(), + "code" = err.code() + ); + tracing::debug!(error = %err, "cannot insert data in cache"); + } + f64_histogram_with_unit!( + "apollo.router.operations.response_cache.insert", + "Time to insert new data in cache", + "s", + now.elapsed().as_secs_f64(), + "subgraph.name" = subgraph_name, + "kind" = "single" + ); + }); + } + } + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn cache_store_entities_from_response( + cache: PostgresCacheStorage, + default_subgraph_ttl: Duration, + response: &mut subgraph::Response, + cache_control: CacheControl, + mut result_from_cache: Vec, + is_known_private: bool, + private_id: Option, + // Only Some if debug is enabled + subgraph_request: Option, +) -> Result<(), BoxError> { + let mut data = response.response.body_mut().data.take(); + + if let Some(mut entities) = data + .as_mut() + .and_then(|v| v.as_object_mut()) + .and_then(|o| o.remove(ENTITIES)) + { + // if the scope is private but we do not have a way to differentiate users, do not store anything in the cache + let should_cache_private = !cache_control.private() || private_id.is_some(); + + let update_key_private = if !is_known_private && cache_control.private() { + private_id + } else { + None + }; + + // Support surrogate keys coming from subgraph extensions + let per_entity_surrogate_keys = response + .response + .body() + .extensions + .get(GRAPHQL_RESPONSE_EXTENSION_ENTITY_CACHE_TAGS) + .and_then(|value| value.as_array()) + .map(|vec| vec.as_slice()) + .unwrap_or_default(); + + let (new_entities, new_errors) = insert_entities_in_result( + entities + .as_array_mut() + .ok_or_else(|| FetchError::MalformedResponse { + reason: "expected an array of entities".to_string(), + })?, + &response.response.body().errors, + cache, + default_subgraph_ttl, + cache_control, + &mut result_from_cache, + update_key_private, + should_cache_private, + &response.subgraph_name, + per_entity_surrogate_keys, + response.context.clone(), + subgraph_request, + ) + .await?; + + data.as_mut() + .and_then(|v| v.as_object_mut()) + .map(|o| o.insert(ENTITIES, new_entities.into())); + response.response.body_mut().data = data; + response.response.body_mut().errors = new_errors; + } else { + let (new_entities, new_errors) = + assemble_response_from_errors(&response.response.body().errors, &mut result_from_cache); + + let mut data = Object::default(); + data.insert(ENTITIES, new_entities.into()); + + response.response.body_mut().data = Some(Value::Object(data)); + response.response.body_mut().errors = new_errors; + } + + Ok(()) +} + +pub(crate) fn hash_vary_headers(headers: &http::HeaderMap) -> String { + let mut digest = Sha256::new(); + + for vary_header_value in headers.get_all(header::VARY).into_iter() { + if vary_header_value == "*" { + return String::from("*"); + } else { + let header_names = match vary_header_value.to_str() { + Ok(header_val) => header_val.split(", "), + Err(_) => continue, + }; + header_names.for_each(|header_name| { + if let Some(header_value) = headers.get(header_name).and_then(|h| h.to_str().ok()) { + digest.update(header_value); + digest.update(&[0u8; 1][..]); + } + }); + } + } + + hex::encode(digest.finalize().as_slice()) +} + +// XXX(@goto-bus-stop): this doesn't make much sense: QueryHash already includes the operation name. +// This function can be removed outright later at the cost of changing all hashes. +pub(crate) fn hash_query(query_hash: &QueryHash, body: &graphql::Request) -> String { + let mut digest = Sha256::new(); + digest.update(query_hash.as_bytes()); + digest.update(&[0u8; 1][..]); + digest.update(body.operation_name.as_deref().unwrap_or("-").as_bytes()); + digest.update(&[0u8; 1][..]); + + hex::encode(digest.finalize().as_slice()) +} + +pub(crate) fn hash_additional_data( + body: &mut graphql::Request, + context: &Context, + cache_key: &CacheKeyMetadata, +) -> String { + let mut digest = Sha256::new(); + + let repr_key = ByteString::from(REPRESENTATIONS); + // Removing the representations variable because it's already part of the cache key + let representations = body.variables.remove(&repr_key); + body.variables.sort_keys(); + digest.update(serde_json::to_vec(&body.variables).unwrap()); + if let Some(representations) = representations { + body.variables.insert(repr_key, representations); + } + + digest.update(serde_json::to_vec(cache_key).unwrap()); + + if let Ok(Some(cache_data)) = context.get::<&str, Object>(CONTEXT_CACHE_KEY) { + if let Some(v) = cache_data.get("all") { + digest.update(serde_json::to_vec(v).unwrap()) + } + if let Some(v) = body + .operation_name + .as_ref() + .and_then(|op| cache_data.get(op.as_str())) + { + digest.update(serde_json::to_vec(v).unwrap()) + } + } + + hex::encode(digest.finalize().as_slice()) +} + +// build a cache key for the root operation +#[allow(clippy::too_many_arguments)] +fn extract_cache_key_root( + subgraph_name: &str, + entity_type_opt: Option<&str>, + query_hash: &QueryHash, + body: &mut graphql::Request, + context: &Context, + cache_key: &CacheKeyMetadata, + is_known_private: bool, + private_id: Option<&str>, +) -> (String, Vec) { + // hash the query and operation name + let query_hash = hash_query(query_hash, body); + // hash more data like variables and authorization status + let additional_data_hash = hash_additional_data(body, context, cache_key); + + let entity_type = entity_type_opt.unwrap_or("Query"); + + // the cache key is written to easily find keys matching a prefix for deletion: + // - response cache version: current version of the hash + // - subgraph name: subgraph name + // - entity type: entity type + // - query hash: invalidate the entry for a specific query and operation name + // - additional data: separate cache entries depending on info like authorization status + let mut key = String::new(); + let _ = write!( + &mut key, + "version:{RESPONSE_CACHE_VERSION}:subgraph:{subgraph_name}:type:{entity_type}:hash:{query_hash}:data:{additional_data_hash}" + ); + let invalidation_keys = vec![format!( + "{INTERNAL_CACHE_TAG_PREFIX}version:{RESPONSE_CACHE_VERSION}:subgraph:{subgraph_name}:type:{entity_type}" + )]; + + if is_known_private && let Some(id) = private_id { + let _ = write!(&mut key, ":{id}"); + } + (key, invalidation_keys) +} + +struct CacheMetadata { + cache_key: String, + invalidation_keys: Vec, + entity_key: serde_json_bytes::Map, +} + +// build a list of keys to get from the cache in one query +#[allow(clippy::too_many_arguments)] +fn extract_cache_keys( + subgraph_name: &str, + supergraph_schema: Arc>, + subgraph_enums: &HashMap, + request: &mut subgraph::Request, + is_known_private: bool, + private_id: Option<&str>, +) -> Result, BoxError> { + let context = &request.context; + let authorization = &request.authorization; + // hash the query and operation name + let query_hash = hash_query(&request.query_hash, request.subgraph_request.body()); + // hash more data like variables and authorization status + let additional_data_hash = + hash_additional_data(request.subgraph_request.body_mut(), context, authorization); + + let representations = request + .subgraph_request + .body_mut() + .variables + .get_mut(REPRESENTATIONS) + .and_then(|value| value.as_array_mut()) + .expect("we already checked that representations exist"); + + // Get entity key to only get the right fields in representations + let mut res = Vec::with_capacity(representations.len()); + let mut entities = HashMap::new(); + let mut typenames = HashSet::new(); + for representation in representations { + let representation = + representation + .as_object_mut() + .ok_or_else(|| FetchError::MalformedRequest { + reason: "representation variable should be an array of object".to_string(), + })?; + let typename_value = + representation + .remove(TYPENAME) + .ok_or_else(|| FetchError::MalformedRequest { + reason: "missing __typename in representation".to_string(), + })?; + + let typename = typename_value + .as_str() + .ok_or_else(|| FetchError::MalformedRequest { + reason: "__typename in representation is not a string".to_string(), + })?; + typenames.insert(typename.to_string()); + match entities.get_mut(typename) { + Some(entity_nb) => *entity_nb += 1, + None => { + entities.insert(typename.to_string(), 1u64); + } + } + + // Split `representation` into two parts: the entity key part and the rest. + let representation_entity_key = take_matching_key_field_set( + representation, + typename, + subgraph_name, + &supergraph_schema, + subgraph_enums, + )?; + + let hashed_representation = if representation.is_empty() { + String::new() + } else { + hash_other_representation(representation) + }; + let hashed_entity_key = hash_entity_key(&representation_entity_key); + + // the cache key is written to easily find keys matching a prefix for deletion: + // - response cache version: current version of the hash + // - subgraph name: caching is done per subgraph + // - type: can invalidate all instances of a type + // - entity key: invalidate a specific entity + // - query hash: invalidate the entry for a specific query and operation name + // - additional data: separate cache entries depending on info like authorization status + let mut key = format!( + "version:{RESPONSE_CACHE_VERSION}:subgraph:{subgraph_name}:type:{typename}:entity:{hashed_entity_key}:representation:{hashed_representation}:hash:{query_hash}:data:{additional_data_hash}" + ); + // Used as a surrogate cache key + let mut invalidation_keys = vec![format!( + "{INTERNAL_CACHE_TAG_PREFIX}version:{RESPONSE_CACHE_VERSION}:subgraph:{subgraph_name}:type:{typename}" + )]; + + // get cache keys from directive + let invalidation_cache_keys = get_invalidation_entity_keys_from_schema( + &supergraph_schema, + subgraph_name, + subgraph_enums, + typename, + &representation_entity_key, + )?; + + if is_known_private && let Some(id) = private_id { + let _ = write!(&mut key, ":{id}"); + } + + // Restore the `representation` back whole again + representation.insert(TYPENAME, typename_value); + merge_representation(representation, representation_entity_key.clone()); //FIXME: not always clone, only on debug + invalidation_keys.extend(invalidation_cache_keys); + let cache_key_metadata = CacheMetadata { + cache_key: key, + invalidation_keys, + entity_key: representation_entity_key, + }; + res.push(cache_key_metadata); + } + + Span::current().set_span_dyn_attribute( + Key::from_static_str("graphql.types"), + opentelemetry::Value::Array( + typenames + .into_iter() + .map(StringValue::from) + .collect::>() + .into(), + ), + ); + + for (typename, entity_nb) in entities { + u64_histogram_with_unit!( + "apollo.router.operations.response_cache.fetch.entity", + "Number of entities per subgraph fetch node", + "{entity}", + entity_nb, + "subgraph.name" = subgraph_name.to_string(), + "graphql.type" = typename + ); + } + + Ok(res) +} + +/// Get invalidation keys from @cacheTag directives in supergraph schema for entities +fn get_invalidation_entity_keys_from_schema( + supergraph_schema: &Arc>, + subgraph_name: &str, + subgraph_enums: &HashMap, + typename: &str, + entity_keys: &serde_json_bytes::Map, +) -> Result, anyhow::Error> { + let field_def = + supergraph_schema + .get_object(typename) + .ok_or_else(|| FetchError::MalformedRequest { + reason: "can't find corresponding type for __typename {typename:?}".to_string(), + })?; + let cache_keys = field_def + .directives + .get_all("join__directive") + .filter_map(|dir| { + let name = dir.argument_by_name("name", supergraph_schema).ok()?; + if name.as_str()? != CACHE_TAG_DIRECTIVE_NAME { + return None; + } + let is_current_subgraph = dir + .argument_by_name("graphs", supergraph_schema) + .ok() + .and_then(|f| { + Some( + f.as_list()? + .iter() + .filter_map(|graph| graph.as_enum()) + .any(|g| { + subgraph_enums.get(g.as_str()).map(|s| s.as_str()) + == Some(subgraph_name) + }), + ) + }) + .unwrap_or_default(); + if !is_current_subgraph { + return None; + } + dir.argument_by_name("args", supergraph_schema) + .ok()? + .as_object()? + .iter() + .find_map(|(field_name, value)| { + if field_name.as_str() == "format" { + value.as_str()?.parse::().ok() + } else { + None + } + }) + }); + let mut vars = IndexMap::default(); + vars.insert("$key".to_string(), Value::Object(entity_keys.clone())); + let invalidation_cache_keys = cache_keys + .map(|ck| ck.interpolate(&vars).map(|(res, _)| res)) + .collect::, apollo_federation::connectors::StringTemplateError>>()?; + Ok(invalidation_cache_keys) +} + +fn take_matching_key_field_set( + representation: &mut serde_json_bytes::Map, + typename: &str, + subgraph_name: &str, + supergraph_schema: &Valid, + subgraph_enums: &HashMap, +) -> Result, FetchError> { + // find an entry in the `key_field_sets` that matches the `representation`. + let matched_key_field_set = + collect_key_field_sets(typename, subgraph_name, supergraph_schema, subgraph_enums)? + .find(|field_set| { + matches_selection_set(representation, &field_set.selection_set) + }) + .ok_or_else(|| { + tracing::trace!("representation does not match any key field set for typename {typename} in subgraph {subgraph_name}"); + FetchError::MalformedRequest { + reason: format!("unexpected critical internal error for typename {typename} in subgraph {subgraph_name}"), + } + })?; + take_selection_set(representation, &matched_key_field_set.selection_set).ok_or_else(|| { + FetchError::MalformedRequest { + reason: format!("representation does not match the field set {matched_key_field_set}"), + } + }) +} + +// Collect `@key` field sets on a `typename` in a `subgraph_name`. +// - Returns a Vec of FieldSet, since there may be more than one @key directives in the subgraph. +fn collect_key_field_sets( + typename: &str, + subgraph_name: &str, + supergraph_schema: &Valid, + subgraph_enums: &HashMap, +) -> Result, FetchError> { + Ok(supergraph_schema + .types + .get(typename) + .ok_or_else(|| FetchError::MalformedRequest { + reason: format!("unknown typename {typename:?} in representations"), + })? + .directives() + .get_all("join__type") + .filter_map(move |directive| { + let schema_subgraph_name = directive + .specified_argument_by_name("graph") + .and_then(|arg| arg.as_enum()) + .and_then(|arg| subgraph_enums.get(arg.as_str()))?; + + if schema_subgraph_name == subgraph_name { + let mut parser = Parser::new(); + directive + .specified_argument_by_name("key") + .and_then(|arg| arg.as_str()) + .and_then(|arg| { + parser + .parse_field_set( + supergraph_schema, + NamedType::new(typename).ok()?, + arg, + "response_caching.graphql", + ) + .ok() + }) + } else { + None + } + })) +} + +// Does the shape of `representation` match the `selection_set`? +fn matches_selection_set( + representation: &serde_json_bytes::Map, + selection_set: &apollo_compiler::executable::SelectionSet, +) -> bool { + for field in selection_set.root_fields(&Default::default()) { + // Note: field sets can't have aliases. + let Some(value) = representation.get(field.name.as_str()) else { + return false; + }; + + if field.selection_set.is_empty() { + // `value` must be a scalar. + if matches!(value, Value::Object(_)) { + return false; + } + continue; + } + + // Check the sub-selection set. + let Value::Object(sub_value) = value else { + return false; + }; + if !matches_selection_set(sub_value, &field.selection_set) { + return false; + } + } + true +} + +// Removes the selection set from `representation` and returns the value corresponding to it. +// - Returns None if the representation doesn't match the selection set. +fn take_selection_set( + representation: &mut serde_json_bytes::Map, + selection_set: &apollo_compiler::executable::SelectionSet, +) -> Option> { + let mut result = serde_json_bytes::Map::new(); + for field in selection_set.root_fields(&Default::default()) { + // Note: field sets can't have aliases. + if field.selection_set.is_empty() { + let value = representation.remove(field.name.as_str())?; + // `value` must be a scalar. + if matches!(value, Value::Object(_)) { + return None; + } + // Move the scalar field to the `result`. + result.insert(ByteString::from(field.name.as_str()), value); + continue; + } else { + let value = representation.get_mut(field.name.as_str())?; + // Update the sub-selection set. + let Value::Object(sub_value) = value else { + return None; + }; + let removed = take_selection_set(sub_value, &field.selection_set)?; + result.insert( + ByteString::from(field.name.as_str()), + Value::Object(removed), + ); + } + } + Some(result) +} + +// The inverse of `take_selection_set`. +fn merge_representation( + dest: &mut serde_json_bytes::Map, + source: serde_json_bytes::Map, +) { + source.into_iter().for_each(|(key, src_value)| { + // Note: field sets can't have aliases. + let Some(dest_value) = dest.get_mut(&key) else { + dest.insert(key, src_value); + return; + }; + + // Overlapping fields must be objects. + if let (Value::Object(dest_sub_value), Value::Object(src_sub_value)) = + (dest_value, src_value) + { + // Merge sub-values + merge_representation(dest_sub_value, src_sub_value); + } + }); +} + +// Order-insensitive structural hash of the representation value +pub(crate) fn hash_representation( + representation: &serde_json_bytes::Map, +) -> String { + let mut digest = Sha256::new(); + fn hash(state: &mut Sha256, fields: &serde_json_bytes::Map) { + fields + .iter() + .sorted_by(|a, b| a.0.cmp(b.0)) + .for_each(|(k, v)| { + state.update(serde_json::to_string(k).unwrap().as_bytes()); + state.update(":".as_bytes()); + match v { + serde_json_bytes::Value::Object(obj) => { + state.update("{".as_bytes()); + hash(state, obj); + state.update("}".as_bytes()); + } + _ => state.update(serde_json::to_string(v).unwrap().as_bytes()), + } + }); + } + hash(&mut digest, representation); + hex::encode(digest.finalize().as_slice()) +} + +// Only hash the list of entity keys +pub(crate) fn hash_entity_key( + entity_keys: &serde_json_bytes::Map, +) -> String { + tracing::trace!("entity keys: {entity_keys:?}"); + // We have to hash the representation because it can contains PII + hash_representation(entity_keys) +} + +// Hash other representation variables except __typename and entity keys +fn hash_other_representation( + representation: &mut serde_json_bytes::Map, +) -> String { + hash_representation(representation) +} + +/// represents the result of a cache lookup for an entity type and key +struct IntermediateResult { + key: String, + invalidation_keys: Vec, + typename: String, + entity_key: serde_json_bytes::Map, + cache_entry: Option, +} + +// build a new list of representations without the ones we got from the cache +#[allow(clippy::type_complexity)] +fn filter_representations( + subgraph_name: &str, + representations: &mut Vec, + // keys: Vec<(String, Vec)>, + keys: Vec, + mut cache_result: Vec>, + context: &Context, +) -> Result<(Vec, Vec, Option), BoxError> { + let mut new_representations: Vec = Vec::new(); + let mut result = Vec::new(); + let mut cache_hit: HashMap = HashMap::new(); + let mut cache_control = None; + + for ( + ( + mut representation, + CacheMetadata { + cache_key: key, + invalidation_keys, + entity_key, + .. + }, + ), + mut cache_entry, + ) in representations + .drain(..) + .zip(keys) + .zip(cache_result.drain(..)) + { + let opt_type = representation + .as_object_mut() + .and_then(|o| o.remove(TYPENAME)) + .ok_or_else(|| FetchError::MalformedRequest { + reason: "missing __typename in representation".to_string(), + })?; + + let typename = opt_type.as_str().unwrap_or("-").to_string(); + + // do not use that cache entry if it is stale + if let Some(false) = cache_entry.as_ref().map(|c| c.control.can_use()) { + cache_entry = None; + } + match cache_entry.as_ref() { + None => { + cache_hit.entry(typename.clone()).or_default().miss += 1; + + representation + .as_object_mut() + .map(|o| o.insert(TYPENAME, opt_type)); + new_representations.push(representation); + } + Some(entry) => { + cache_hit.entry(typename.clone()).or_default().hit += 1; + match cache_control.as_mut() { + None => cache_control = Some(entry.control.clone()), + Some(c) => *c = c.merge(&entry.control), + } + } + } + + result.push(IntermediateResult { + key, + invalidation_keys, + typename, + cache_entry, + entity_key, + }); + } + + let _ = context.insert( + CacheMetricContextKey::new(subgraph_name.to_string()), + CacheSubgraph(cache_hit), + ); + + Ok((new_representations, result, cache_control)) +} + +// fill in the entities for the response +#[allow(clippy::too_many_arguments)] +async fn insert_entities_in_result( + entities: &mut Vec, + errors: &[Error], + cache: PostgresCacheStorage, + default_subgraph_ttl: Duration, + cache_control: CacheControl, + result: &mut Vec, + update_key_private: Option, + should_cache_private: bool, + subgraph_name: &str, + per_entity_surrogate_keys: &[Value], + context: Context, + // Only Some if debug is enabled + subgraph_request: Option, +) -> Result<(Vec, Vec), BoxError> { + let ttl = cache_control + .ttl() + .map(|secs| Duration::from_secs(secs as u64)) + .unwrap_or(default_subgraph_ttl); + + let mut new_entities = Vec::new(); + let mut new_errors = Vec::new(); + + let mut inserted_types: HashMap = HashMap::new(); + let mut to_insert: Vec<_> = Vec::new(); + let mut debug_ctx_entries = Vec::new(); + let mut entities_it = entities.drain(..).enumerate(); + let mut per_entity_surrogate_keys_it = per_entity_surrogate_keys.iter(); + + // insert requested entities and cached entities in the same order as + // they were requested + for ( + new_entity_idx, + IntermediateResult { + mut key, + mut invalidation_keys, + typename, + cache_entry, + entity_key, + }, + ) in result.drain(..).enumerate() + { + match cache_entry { + Some(v) => { + new_entities.push(v.data); + } + None => { + let (entity_idx, value) = + entities_it + .next() + .ok_or_else(|| FetchError::MalformedResponse { + reason: "invalid number of entities".to_string(), + })?; + let specific_surrogate_keys = per_entity_surrogate_keys_it.next(); + + *inserted_types.entry(typename.clone()).or_default() += 1; + + if let Some(ref id) = update_key_private { + key = format!("{key}:{id}"); + } + + let mut has_errors = false; + for error in errors.iter().filter(|e| { + e.path + .as_ref() + .map(|path| { + path.starts_with(&Path(vec![ + PathElement::Key(ENTITIES.to_string(), None), + PathElement::Index(entity_idx), + ])) + }) + .unwrap_or(false) + }) { + // update the entity index, because it does not match with the original one + let mut e = error.clone(); + if let Some(path) = e.path.as_mut() { + path.0[1] = PathElement::Index(new_entity_idx); + } + + new_errors.push(e); + has_errors = true; + } + + // Only in debug mode + if let Some(subgraph_request) = &subgraph_request { + debug_ctx_entries.push(CacheKeyContext { + key: key.clone(), + hashed_private_id: update_key_private.clone(), + invalidation_keys: invalidation_keys + .clone() + .into_iter() + .filter(|k| !k.starts_with(INTERNAL_CACHE_TAG_PREFIX)) + .collect(), + kind: CacheEntryKind::Entity { + typename: typename.clone(), + entity_key: entity_key.clone(), + }, + subgraph_name: subgraph_name.to_string(), + subgraph_request: subgraph_request.clone(), + source: CacheKeySource::Subgraph, + cache_control: cache_control.clone(), + data: serde_json_bytes::json!({"data": value.clone()}), + }); + } + if !has_errors && cache_control.should_store() && should_cache_private { + if let Some(Value::Array(keys)) = specific_surrogate_keys { + invalidation_keys + .extend(keys.iter().filter_map(|v| v.as_str()).map(|s| s.to_owned())); + } + to_insert.push(BatchDocument { + control: serde_json::to_string(&cache_control)?, + data: serde_json::to_string(&value)?, + cache_key: key, + invalidation_keys, + expire: ttl, + }); + } + + new_entities.push(value); + } + } + } + + // For debug mode + if !debug_ctx_entries.is_empty() { + context.upsert::<_, CacheKeysContext>(CONTEXT_DEBUG_CACHE_KEYS, |mut val| { + val.extend(debug_ctx_entries); + val + })?; + } + + if !to_insert.is_empty() { + let batch_size = to_insert.len(); + let span = tracing::info_span!("response_cache.store", "kind" = "entity", "subgraph.name" = subgraph_name, "ttl" = ?ttl, "batch.size" = %batch_size); + + let batch_size_str = if batch_size <= 10 { + "1-10" + } else if batch_size <= 20 { + "11-20" + } else if batch_size <= 50 { + "21-50" + } else { + "50+" + }; + + let subgraph_name = subgraph_name.to_string(); + // Write to cache in a non-awaited task so it’s on in the request’s critical path + tokio::spawn(async move { + let now = Instant::now(); + if let Err(err) = cache + .insert_in_batch(to_insert, &subgraph_name) + .instrument(span) + .await + { + u64_counter_with_unit!( + "apollo.router.operations.response_cache.insert.error", + "Errors when inserting data in cache", + "{error}", + 1, + "subgraph.name" = subgraph_name.clone(), + "code" = err.code() + ); + tracing::debug!(error = %err, "cannot insert data in cache"); + } + f64_histogram_with_unit!( + "apollo.router.operations.response_cache.insert", + "Time to insert new data in cache", + "s", + now.elapsed().as_secs_f64(), + "subgraph.name" = subgraph_name, + "kind" = "batch", + "batch.size" = batch_size_str + ); + }); + } + + for (ty, nb) in inserted_types { + tracing::event!(Level::TRACE, entity_type = ty.as_str(), cache_insert = nb,); + } + + Ok((new_entities, new_errors)) +} + +fn assemble_response_from_errors( + graphql_errors: &[Error], + result: &mut Vec, +) -> (Vec, Vec) { + let mut new_entities = Vec::new(); + let mut new_errors = Vec::new(); + + for (new_entity_idx, IntermediateResult { cache_entry, .. }) in result.drain(..).enumerate() { + match cache_entry { + Some(v) => { + new_entities.push(v.data); + } + None => { + new_entities.push(Value::Null); + + for mut error in graphql_errors.iter().cloned() { + error.path = Some(Path(vec![ + PathElement::Key(ENTITIES.to_string(), None), + PathElement::Index(new_entity_idx), + ])); + new_errors.push(error); + } + } + } + } + (new_entities, new_errors) +} + +async fn check_pg_connection( + postgres_config: PostgresCacheConfig, + pg_storage: Arc>, + mut abort_signal: Receiver<()>, + subgraph_name: Option, +) { + let mut interval = + IntervalStream::new(tokio::time::interval(std::time::Duration::from_secs(30))); + let abort_signal_cloned = abort_signal.resubscribe(); + loop { + tokio::select! { + biased; + _ = abort_signal.recv() => { + break; + } + _ = interval.next() => { + u64_counter_with_unit!( + "apollo.router.response_cache.reconnection", + "Response cache counter for invalidated entries", + "{retry}", + 1, + "subgraph.name" = subgraph_name.clone().unwrap_or_default() + ); + if let Ok(storage) = PostgresCacheStorage::new(&postgres_config).await { + if let Err(err) = storage.migrate().await { + tracing::error!(error = %err, "cannot migrate storage"); + } + if let Err(err) = storage.update_cron().await { + tracing::error!(error = %err, "cannot update cron storage"); + } + let _ = pg_storage.set(storage.clone()); + tokio::task::spawn(metrics::expired_data_task(storage, abort_signal_cloned, None)); + break; + } + } + } + } +} + +pub(crate) type CacheKeysContext = Vec; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CacheKeyContext { + pub(super) key: String, + pub(super) invalidation_keys: Vec, + pub(super) kind: CacheEntryKind, + pub(super) subgraph_name: String, + pub(super) subgraph_request: graphql::Request, + pub(super) source: CacheKeySource, + pub(super) cache_control: CacheControl, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) hashed_private_id: Option, + pub(super) data: serde_json_bytes::Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(test, derive(PartialEq, Eq, Hash))] +#[serde(rename_all = "camelCase", untagged)] +pub(crate) enum CacheEntryKind { + Entity { + typename: String, + #[serde(rename = "entityKey")] + entity_key: Object, + }, + RootFields { + #[serde(rename = "rootFields")] + root_fields: Vec, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(test, derive(PartialEq, Eq, Hash))] +#[serde(rename_all = "camelCase")] +pub(crate) enum CacheKeySource { + /// Data fetched from subgraph + Subgraph, + /// Data fetched from cache + Cache, +} + +#[cfg(test)] +impl PartialOrd for CacheKeySource { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[cfg(test)] +impl Ord for CacheKeySource { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match (self, other) { + (CacheKeySource::Subgraph, CacheKeySource::Subgraph) => std::cmp::Ordering::Equal, + (CacheKeySource::Subgraph, CacheKeySource::Cache) => std::cmp::Ordering::Greater, + (CacheKeySource::Cache, CacheKeySource::Subgraph) => std::cmp::Ordering::Less, + (CacheKeySource::Cache, CacheKeySource::Cache) => std::cmp::Ordering::Equal, + } + } +} + +#[cfg(all( + test, + any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")) +))] +mod tests { + use super::*; + use crate::plugins::response_cache::postgres::default_batch_size; + use crate::plugins::response_cache::postgres::default_cleanup_interval; + use crate::plugins::response_cache::postgres::default_pool_size; + + const SCHEMA: &str = include_str!("../../testdata/orga_supergraph_cache_key.graphql"); + + #[tokio::test] + async fn test_subgraph_enabled() { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + tls: Default::default(), + cleanup_interval: default_cleanup_interval(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("test_subgraph_enabled")), + }) + .await + .unwrap(); + let map = serde_json::json!({ + "user": { + "private_id": "sub" + }, + "orga": { + "private_id": "sub", + "enabled": true + }, + "archive": { + "private_id": "sub", + "enabled": false + } + }); + + let mut response_cache = ResponseCache::for_test( + pg_cache.clone(), + serde_json::from_value(map).unwrap(), + valid_schema.clone(), + true, + false, + ) + .await + .unwrap(); + + assert!(response_cache.subgraph_enabled("user")); + assert!(!response_cache.subgraph_enabled("archive")); + let subgraph_config = serde_json::json!({ + "all": { + "enabled": false + }, + "subgraphs": response_cache.subgraphs.subgraphs.clone() + }); + response_cache.subgraphs = Arc::new(serde_json::from_value(subgraph_config).unwrap()); + assert!(!response_cache.subgraph_enabled("archive")); + assert!(response_cache.subgraph_enabled("user")); + assert!(response_cache.subgraph_enabled("orga")); + } + + #[tokio::test] + async fn test_subgraph_ttl() { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + tls: Default::default(), + cleanup_interval: default_cleanup_interval(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("test_subgraph_ttl")), + }) + .await + .unwrap(); + let map = serde_json::json!({ + "user": { + "private_id": "sub", + "ttl": "2s" + }, + "orga": { + "private_id": "sub", + "enabled": true + }, + "archive": { + "private_id": "sub", + "enabled": false, + "ttl": "5000ms" + } + }); + + let mut response_cache = ResponseCache::for_test( + pg_cache.clone(), + serde_json::from_value(map).unwrap(), + valid_schema.clone(), + true, + false, + ) + .await + .unwrap(); + + assert_eq!( + response_cache.subgraph_ttl("user"), + Some(Duration::from_secs(2)) + ); + assert!(response_cache.subgraph_ttl("orga").is_none()); + assert_eq!( + response_cache.subgraph_ttl("archive"), + Some(Duration::from_millis(5000)) + ); + // Update ttl for all + response_cache.subgraphs = Arc::new(SubgraphConfiguration { + all: Subgraph { + ttl: Some(Ttl(Duration::from_secs(25))), + ..Default::default() + }, + subgraphs: response_cache.subgraphs.subgraphs.clone(), + }); + assert_eq!( + response_cache.subgraph_ttl("user"), + Some(Duration::from_secs(2)) + ); + assert_eq!( + response_cache.subgraph_ttl("orga"), + Some(Duration::from_secs(25)) + ); + assert_eq!( + response_cache.subgraph_ttl("archive"), + Some(Duration::from_millis(5000)) + ); + response_cache.subgraphs = Arc::new(SubgraphConfiguration { + all: Subgraph { + ttl: Some(Ttl(Duration::from_secs(42))), + ..Default::default() + }, + subgraphs: response_cache.subgraphs.subgraphs.clone(), + }); + assert_eq!( + response_cache.subgraph_ttl("user"), + Some(Duration::from_secs(2)) + ); + assert_eq!( + response_cache.subgraph_ttl("orga"), + Some(Duration::from_secs(42)) + ); + assert_eq!( + response_cache.subgraph_ttl("archive"), + Some(Duration::from_millis(5000)) + ); + } +} diff --git a/apollo-router/src/plugins/response_cache/postgres.rs b/apollo-router/src/plugins/response_cache/postgres.rs new file mode 100644 index 0000000000..6046ec3ba1 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/postgres.rs @@ -0,0 +1,709 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use chrono::TimeDelta; +use log::LevelFilter; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use sqlx::Acquire; +use sqlx::PgPool; +use sqlx::postgres::PgConnectOptions; +use sqlx::postgres::PgPoolOptions; +use sqlx::types::chrono::DateTime; +use sqlx::types::chrono::Utc; + +use super::cache_control::CacheControl; +use crate::plugins::response_cache::ErrorCode; + +#[derive(sqlx::FromRow, Debug, Clone)] +pub(crate) struct CacheEntryRow { + pub(crate) id: i64, + pub(crate) cache_key: String, + pub(crate) data: String, + pub(crate) expires_at: DateTime, + pub(crate) control: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct CacheEntry { + #[allow(unused)] // Used in the database but not in rust code + pub(crate) id: i64, + pub(crate) cache_key: String, + pub(crate) data: serde_json_bytes::Value, + #[allow(unused)] // Used in the database but not in rust code + pub(crate) expires_at: DateTime, + pub(crate) control: CacheControl, +} + +#[derive(Debug, Clone)] +pub(crate) struct BatchDocument { + pub(crate) cache_key: String, + pub(crate) data: String, + pub(crate) control: String, + pub(crate) invalidation_keys: Vec, + pub(crate) expire: Duration, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +/// Postgres cache configuration +pub(crate) struct PostgresCacheConfig { + /// List of URL to Postgres + pub(crate) url: url::Url, + + /// PostgreSQL username if not provided in the URLs. This field takes precedence over the username in the URL + pub(crate) username: Option, + /// PostgreSQL password if not provided in the URLs. This field takes precedence over the password in the URL + pub(crate) password: Option, + + #[serde( + deserialize_with = "humantime_serde::deserialize", + default = "default_idle_timeout" + )] + #[schemars(with = "String")] + /// PostgreSQL maximum idle duration for individual connection (default: 1min) + pub(crate) idle_timeout: Duration, + + #[serde( + deserialize_with = "humantime_serde::deserialize", + default = "default_acquire_timeout" + )] + #[schemars(with = "String")] + /// PostgreSQL the maximum amount of time to spend waiting for a connection (default: 50ms) + pub(crate) acquire_timeout: Duration, + + #[serde(default = "default_required_to_start")] + /// Prevents the router from starting if it cannot connect to PostgreSQL + pub(crate) required_to_start: bool, + + #[serde(default = "default_pool_size")] + /// The size of the PostgreSQL connection pool + pub(crate) pool_size: u32, + #[serde(default = "default_batch_size")] + /// The size of batch when inserting cache entries in PG (default: 100) + pub(crate) batch_size: usize, + /// Useful when running tests in parallel to avoid conflicts + #[serde(default)] + pub(crate) namespace: Option, + + #[serde( + deserialize_with = "humantime_serde::deserialize", + default = "default_cleanup_interval" + )] + #[schemars(with = "String")] + /// Specifies the interval between cache cleanup operations (e.g., "2 hours", "30min"). Default: 1 hour + pub(crate) cleanup_interval: Duration, + + /// Postgres TLS client configuration + #[serde(default)] + pub(crate) tls: TlsConfig, +} + +pub(super) const fn default_required_to_start() -> bool { + false +} + +pub(super) const fn default_pool_size() -> u32 { + 5 +} + +pub(super) const fn default_cleanup_interval() -> Duration { + Duration::from_secs(60 * 60) +} + +pub(super) const fn default_idle_timeout() -> Duration { + Duration::from_secs(60) +} + +pub(super) const fn default_acquire_timeout() -> Duration { + Duration::from_millis(50) +} + +pub(super) const fn default_batch_size() -> usize { + 100 +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default)] +#[serde(deny_unknown_fields)] +/// Postgres TLS client configuration +pub(crate) struct TlsConfig { + /// list of certificate authorities in PEM format + #[schemars(with = "String")] + pub(crate) certificate_authorities: Option>, + /// client certificate authentication + pub(crate) client_authentication: Option>, +} + +/// TLS client authentication +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub(crate) struct TlsClientAuth { + /// Sets the SSL client certificate as a PEM + #[schemars(with = "String")] + pub(crate) certificate: Vec, + /// key in PEM format + #[schemars(with = "String")] + pub(crate) key: Vec, +} + +impl TryFrom for CacheEntry { + type Error = serde_json::Error; + + fn try_from(value: CacheEntryRow) -> Result { + let data = serde_json::from_str(&value.data)?; + let control = serde_json::from_str(&value.control)?; + Ok(Self { + id: value.id, + cache_key: value.cache_key, + data, + expires_at: value.expires_at, + control, + }) + } +} + +#[derive(Clone)] +pub(crate) struct PostgresCacheStorage { + batch_size: usize, + pg_pool: PgPool, + namespace: Option, + pub(super) cleanup_interval: TimeDelta, +} + +#[derive(thiserror::Error, Debug)] +pub(crate) enum PostgresCacheStorageError { + #[error("postgres error: {0}")] + PgError(#[from] sqlx::Error), + #[error("cleanup_interval configuration is out of range: {0}")] + OutOfRangeError(#[from] chrono::OutOfRangeError), + #[error("cleanup_interval configuration is invalid: {0}")] + InvalidCleanupInterval(String), +} + +impl PostgresCacheStorage { + pub(crate) async fn new(conf: &PostgresCacheConfig) -> Result { + // After 500ms trying to get a connection from PG pool it will return a warning in logs + const ACQUIRE_SLOW_THRESHOLD: std::time::Duration = std::time::Duration::from_millis(500); + let mut pg_connection: PgConnectOptions = conf.url.as_ref().parse()?; + if let Some(user) = &conf.username { + pg_connection = pg_connection.username(user); + } + if let Some(password) = &conf.password { + pg_connection = pg_connection.password(password); + } + if let Some(ca) = &conf.tls.certificate_authorities { + pg_connection = pg_connection.ssl_root_cert_from_pem(ca.clone()); + } + if let Some(tls_client_auth) = &conf.tls.client_authentication { + pg_connection = pg_connection + .ssl_client_cert_from_pem(&tls_client_auth.certificate) + .ssl_client_key_from_pem(&tls_client_auth.key); + } + let pg_pool = PgPoolOptions::new() + .max_connections(conf.pool_size) + .idle_timeout(conf.idle_timeout) + .acquire_timeout(conf.acquire_timeout) + .acquire_slow_threshold(ACQUIRE_SLOW_THRESHOLD) + .acquire_slow_level(LevelFilter::Warn) + .connect_with(pg_connection) + .await?; + + Ok(Self { + pg_pool, + batch_size: conf.batch_size, + namespace: conf.namespace.clone(), + cleanup_interval: TimeDelta::from_std(conf.cleanup_interval)?, + }) + } + + pub(crate) async fn migrate(&self) -> sqlx::Result<()> { + sqlx::migrate!().run(&self.pg_pool).await?; + Ok(()) + } + + #[cfg(all( + test, + any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")) + ))] + pub(crate) async fn truncate_namespace(&self) -> sqlx::Result<()> { + if let Some(ns) = &self.namespace { + sqlx::query!("DELETE FROM cache WHERE starts_with(cache_key, $1)", ns) + .execute(&self.pg_pool) + .await?; + } + + Ok(()) + } + + fn namespaced(&self, key: &str) -> String { + if let Some(ns) = &self.namespace { + format!("{ns}-{key}") + } else { + key.into() + } + } + + pub(crate) async fn insert( + &self, + cache_key: &str, + expire: Duration, + invalidation_keys: Vec, + value: serde_json_bytes::Value, + control: CacheControl, + subgraph_name: &str, + ) -> sqlx::Result<()> { + let mut conn = self.pg_pool.acquire().await?; + let mut transaction = conn.begin().await?; + let tx = &mut transaction; + + let expired_at = Utc::now() + expire; + let value_str = + serde_json::to_string(&value).map_err(|err| sqlx::Error::Encode(Box::new(err)))?; + let control_str = + serde_json::to_string(&control).map_err(|err| sqlx::Error::Encode(Box::new(err)))?; + let cache_key = self.namespaced(cache_key); + let rec = sqlx::query!( + r#" + INSERT INTO cache ( cache_key, data, control, expires_at ) + VALUES ( $1, $2, $3, $4 ) + ON CONFLICT (cache_key) DO UPDATE SET data = $2, control = $3, expires_at = $4 + RETURNING id + "#, + &cache_key, + value_str, + control_str, + expired_at + ) + .fetch_one(&mut **tx) + .await?; + + for invalidation_key in invalidation_keys { + let invalidation_key = self.namespaced(&invalidation_key); + sqlx::query!( + r#"INSERT into invalidation_key (cache_key_id, invalidation_key, subgraph_name) VALUES ($1, $2, $3) ON CONFLICT (cache_key_id, invalidation_key, subgraph_name) DO NOTHING"#, + rec.id, + &invalidation_key, + subgraph_name + ) + .execute(&mut **tx) + .await?; + } + + transaction.commit().await?; + + Ok(()) + } + + pub(crate) async fn insert_in_batch( + &self, + mut batch_docs: Vec, + subgraph_name: &str, + ) -> sqlx::Result<()> { + // order batch_docs to prevent deadlocks! don't need namespaced as we just need to make sure + // that transaction 1 can't lock A and wait for B, and transaction 2 can't lock B and wait for A + batch_docs.sort_by(|a, b| a.cache_key.cmp(&b.cache_key)); + + let mut conn = self.pg_pool.acquire().await?; + let batch_docs = batch_docs.chunks(self.batch_size); + for batch_docs in batch_docs { + let mut transaction = conn.begin().await?; + let tx = &mut transaction; + let cache_keys = batch_docs + .iter() + .map(|b| self.namespaced(&b.cache_key)) + .collect::>(); + + let data = batch_docs + .iter() + .map(|b| b.data.clone()) + .collect::>(); + let controls = batch_docs + .iter() + .map(|b| b.control.clone()) + .collect::>(); + let expires = batch_docs + .iter() + .map(|b| Utc::now() + b.expire) + .collect::>>(); + + let resp = sqlx::query!( + r#" + INSERT INTO cache + ( cache_key, data, expires_at, control ) SELECT * FROM UNNEST( + $1::VARCHAR(1024)[], + $2::TEXT[], + $3::TIMESTAMP WITH TIME ZONE[], + $4::TEXT[] + ) ON CONFLICT (cache_key) DO UPDATE SET data = excluded.data, control = excluded.control, expires_at = excluded.expires_at + RETURNING id + "#, + &cache_keys, + &data, + &expires, + &controls + ) + .fetch_all(&mut **tx) + .await?; + + let invalidation_keys: Vec<(i64, String)> = resp + .iter() + .enumerate() + .flat_map(|(idx, resp)| { + let cache_key_id = resp.id; + batch_docs + .get(idx) + .unwrap() + .invalidation_keys + .iter() + .map(move |k| (cache_key_id, k.clone())) + }) + .collect(); + + let cache_key_ids: Vec = invalidation_keys.iter().map(|(idx, _)| *idx).collect(); + + let subgraph_names: Vec = (0..invalidation_keys.len()) + .map(|_| subgraph_name.to_string()) + .collect(); + let invalidation_keys: Vec = invalidation_keys + .iter() + .map(|(_, invalidation_key)| self.namespaced(invalidation_key)) + .collect(); + sqlx::query!( + r#" + INSERT INTO invalidation_key (cache_key_id, invalidation_key, subgraph_name) + SELECT * FROM UNNEST( + $1::BIGINT[], + $2::VARCHAR(255)[], + $3::VARCHAR(255)[] + ) ON CONFLICT (cache_key_id, invalidation_key, subgraph_name) DO NOTHING + "#, + &cache_key_ids, + &invalidation_keys, + &subgraph_names, + ) + .execute(&mut **tx) + .await?; + + transaction.commit().await?; + } + + Ok(()) + } + + pub(crate) async fn get(&self, cache_key: &str) -> sqlx::Result { + let cache_key = self.namespaced(cache_key); + let resp = sqlx::query_as!( + CacheEntryRow, + "SELECT * FROM cache WHERE cache.cache_key = $1 AND expires_at >= NOW()", + &cache_key + ) + .fetch_one(&self.pg_pool) + .await?; + + let cache_entry_json = resp + .try_into() + .map_err(|err| sqlx::Error::Decode(Box::new(err)))?; + + Ok(cache_entry_json) + } + + pub(crate) async fn get_multiple( + &self, + cache_keys: &[&str], + ) -> sqlx::Result>> { + let cache_keys: Vec<_> = cache_keys.iter().map(|ck| self.namespaced(ck)).collect(); + let resp = sqlx::query_as!( + CacheEntryRow, + "SELECT * FROM cache WHERE cache.cache_key = ANY($1::VARCHAR(1024)[]) AND expires_at >= NOW()", + &cache_keys + ) + .fetch_all(&self.pg_pool) + .await?; + + let cache_key_entries: Result, serde_json::Error> = resp + .into_iter() + .map(|e| { + let entry: CacheEntry = e.try_into()?; + + Ok((entry.cache_key.clone(), entry)) + }) + .collect(); + let mut cache_key_entries = + cache_key_entries.map_err(|err| sqlx::Error::Encode(Box::new(err)))?; + + Ok(cache_keys + .iter() + .map(|ck| cache_key_entries.remove(ck)) + .collect()) + } + + /// Deletes all documents that have one (or more) of the keys + /// Returns the number of deleted documents. + pub(crate) async fn invalidate_by_subgraphs( + &self, + subgraph_names: Vec, + ) -> sqlx::Result { + let rec = sqlx::query!( + r#"WITH deleted AS + (DELETE + FROM cache + USING invalidation_key + WHERE invalidation_key.cache_key_id = cache.id AND invalidation_key.subgraph_name = ANY($1::text[]) RETURNING cache.cache_key, cache.expires_at + ) + SELECT COUNT(*) AS count FROM deleted WHERE deleted.expires_at >= NOW()"#, + &subgraph_names + ) + .fetch_one(&self.pg_pool) + .await?; + + Ok(rec.count.unwrap_or_default() as u64) + } + + /// Deletes all documents that have one (or more) of the keys + /// Returns the number of deleted documents. + pub(crate) async fn invalidate( + &self, + invalidation_keys: Vec, + subgraph_names: Vec, + ) -> sqlx::Result> { + let invalidation_keys: Vec = invalidation_keys + .iter() + .map(|ck| self.namespaced(ck)) + .collect(); + // In this query the 'deleted' view contains the number of data we deleted from 'cache' + // The SELECT on 'deleted' happening at the end is to filter the data to only count for deleted fresh data and get it by subgraph to be able to use it in a metric + let rec = sqlx::query!( + r#"WITH deleted AS + (DELETE + FROM cache + USING invalidation_key + WHERE invalidation_key.invalidation_key = ANY($1::text[]) + AND invalidation_key.cache_key_id = cache.id AND invalidation_key.subgraph_name = ANY($2::text[]) RETURNING cache.cache_key, cache.expires_at, invalidation_key.subgraph_name + ) + SELECT subgraph_name, COUNT(deleted.cache_key) AS count FROM deleted WHERE deleted.expires_at >= NOW() GROUP BY deleted.subgraph_name"#, + &invalidation_keys, + &subgraph_names + ) + .fetch_all(&self.pg_pool) + .await?; + + Ok(rec + .into_iter() + .map(|rec| (rec.subgraph_name, rec.count.unwrap_or_default() as u64)) + .collect()) + } + + pub(crate) async fn expired_data_count(&self) -> anyhow::Result { + match &self.namespace { + Some(ns) => { + let resp = sqlx::query!("SELECT COUNT(id) AS count FROM cache WHERE starts_with(cache_key, $1) AND expires_at <= NOW()", ns) + .fetch_one(&self.pg_pool) + .await?; + + Ok(resp.count.unwrap_or_default() as u64) + } + None => { + let resp = + sqlx::query!("SELECT COUNT(id) AS count FROM cache WHERE expires_at <= NOW()") + .fetch_one(&self.pg_pool) + .await?; + + Ok(resp.count.unwrap_or_default() as u64) + } + } + } + + pub(crate) async fn update_cron(&self) -> sqlx::Result<()> { + let cron = Cron::try_from(&self.cleanup_interval).map_err(|err| { + sqlx::Error::Configuration(Box::new(PostgresCacheStorageError::InvalidCleanupInterval( + err, + ))) + })?; + sqlx::query!("SELECT cron.alter_job((SELECT jobid FROM cron.job WHERE jobname = 'delete-old-cache-entries'), $1)", &cron.0) + .execute(&self.pg_pool) + .await?; + log::trace!( + "Configured `delete-old-cache-entries` cron to have interval = `{}`", + &cron.0 + ); + + Ok(()) + } + + #[cfg(all( + test, + any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")) + ))] + pub(crate) async fn get_cron(&self) -> sqlx::Result { + let rec = sqlx::query!( + "SELECT schedule FROM cron.job WHERE jobname = 'delete-old-cache-entries'" + ) + .fetch_one(&self.pg_pool) + .await?; + + Ok(Cron(rec.schedule)) + } +} + +#[derive(Debug, sqlx::Type)] +#[sqlx(transparent)] +pub(crate) struct Cron(pub(crate) String); + +impl TryFrom<&TimeDelta> for Cron { + type Error = String; + fn try_from(value: &TimeDelta) -> Result { + let num_days = value.num_days(); + let num_hours = value.num_hours(); + let num_mins = value.num_minutes(); + if num_days > 366 { + Err(String::from("interval cannot exceed 1 year")) + } else if num_days > 31 { + // multiple months + let months = (num_days / 30).min(12); + Ok(Cron(format!("0 0 1 */{months} *"))) + } else if num_days > 28 { + // treat as one month + Ok(Cron(String::from("0 0 1 * *"))) + } else if num_days > 0 { + Ok(Cron(format!("0 0 */{num_days} * *"))) + } else if num_hours > 0 { + Ok(Cron(format!("0 */{num_hours} * * *"))) + } else if num_mins > 0 { + Ok(Cron(format!("*/{num_mins} * * * *"))) + } else { + Err(String::from( + "interval lower than 1 minute is not supported", + )) + } + } +} + +impl ErrorCode for sqlx::Error { + fn code(&self) -> &'static str { + match &self { + sqlx::Error::Configuration(_) => "CONFIGURATION", + sqlx::Error::InvalidArgument(_) => "INVALID_ARGUMENT", + sqlx::Error::Database(_) => "DATABASE", + sqlx::Error::Io(_) => "IO", + sqlx::Error::Tls(_) => "TLS", + sqlx::Error::Protocol(_) => "PROTOCOL", + sqlx::Error::RowNotFound => "ROW_NOT_FOUND", + sqlx::Error::TypeNotFound { .. } => "TYPE_NOT_FOUND", + sqlx::Error::ColumnIndexOutOfBounds { .. } => "COLUMN_INDEX_OUT_OF_BOUNDS", + sqlx::Error::ColumnNotFound(_) => "COLUMN_NOT_FOUND", + sqlx::Error::ColumnDecode { .. } => "COLUMN_DECODE", + sqlx::Error::Encode(..) => "ENCODE", + sqlx::Error::Decode(..) => "DECODE", + sqlx::Error::AnyDriverError(..) => "DRIVER_ERROR", + sqlx::Error::PoolTimedOut => "POOL_TIMED_OUT", + sqlx::Error::PoolClosed => "POOL_CLOSED", + sqlx::Error::WorkerCrashed => "WORKER_CRASHED", + sqlx::Error::Migrate(_) => "MIGRATE", + sqlx::Error::InvalidSavePointStatement => "INVALID_SAVE_POINT_STATEMENT", + sqlx::Error::BeginFailed => "BEGIN_FAILED", + _ => "UNKNOWN", + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use chrono::TimeDelta; + + use super::Cron; + + #[rstest::rstest] + #[case(TimeDelta::minutes(1), "*/1 * * * *")] + #[case(TimeDelta::minutes(5), "*/5 * * * *")] + #[case(TimeDelta::minutes(30), "*/30 * * * *")] + #[case(TimeDelta::minutes(59), "*/59 * * * *")] + #[case(TimeDelta::minutes(60), "0 */1 * * *")] + #[case(TimeDelta::hours(1), "0 */1 * * *")] + #[case(TimeDelta::hours(3), "0 */3 * * *")] + #[case(TimeDelta::hours(12), "0 */12 * * *")] + #[case(TimeDelta::hours(23), "0 */23 * * *")] + #[case(TimeDelta::hours(24), "0 0 */1 * *")] + #[case(TimeDelta::days(1), "0 0 */1 * *")] + #[case(TimeDelta::days(7), "0 0 */7 * *")] + #[case(TimeDelta::days(15), "0 0 */15 * *")] + #[case(TimeDelta::days(27), "0 0 */27 * *")] + #[case(TimeDelta::days(28), "0 0 */28 * *")] + #[case::monthly(TimeDelta::days(29), "0 0 1 * *")] + #[case::monthly(TimeDelta::days(30), "0 0 1 * *")] + #[case::monthly(TimeDelta::days(31), "0 0 1 * *")] + #[case::two_months(TimeDelta::days(60), "0 0 1 */2 *")] + #[case::three_months(TimeDelta::days(90), "0 0 1 */3 *")] + #[case::six_months(TimeDelta::days(180), "0 0 1 */6 *")] + #[case::year(TimeDelta::days(360), "0 0 1 */12 *")] + #[case::year(TimeDelta::days(365), "0 0 1 */12 *")] + #[case::year(TimeDelta::days(366), "0 0 1 */12 *")] + #[case::six_weeks_rounds_down(TimeDelta::days(42), "0 0 1 */1 *")] + #[case::complex(TimeDelta::minutes(90), "0 */1 * * *")] + #[case::complex(TimeDelta::hours(36), "0 0 */1 * *")] + fn check_passing_conversion(#[case] interval: TimeDelta, #[case] expected: &str) { + let cron = Cron::try_from(&interval); + assert!(cron.is_ok()); + + let cron_str = cron.unwrap().0; + assert_eq!(cron_str, expected); + } + + #[rstest::rstest] + #[case("1m", "*/1 * * * *")] + #[case("5m", "*/5 * * * *")] + #[case("30m", "*/30 * * * *")] + #[case("59m", "*/59 * * * *")] + #[case("60m", "0 */1 * * *")] + #[case("1h", "0 */1 * * *")] + #[case("3h", "0 */3 * * *")] + #[case("12h", "0 */12 * * *")] + #[case("23h", "0 */23 * * *")] + #[case("24h", "0 0 */1 * *")] + #[case("1d", "0 0 */1 * *")] + #[case("7d", "0 0 */7 * *")] + #[case("1w", "0 0 */7 * *")] + #[case("15d", "0 0 */15 * *")] + #[case("27d", "0 0 */27 * *")] + #[case("28d", "0 0 */28 * *")] + #[case::monthly("29d", "0 0 1 * *")] + #[case::monthly("30d", "0 0 1 * *")] + #[case::monthly("31d", "0 0 1 * *")] + #[case::monthly("1month", "0 0 1 * *")] + #[case::two_months("2months", "0 0 1 */2 *")] + #[case::three_months("3months", "0 0 1 */3 *")] + #[case::six_months("6months", "0 0 1 */6 *")] + #[case::year("365d", "0 0 1 */12 *")] + #[case::year("366d", "0 0 1 */12 *")] + #[case::year("12months", "0 0 1 */12 *")] + #[case::year("1y", "0 0 1 */12 *")] + #[case::six_weeks_rounds_down("6w", "0 0 1 */1 *")] + #[case::complex("90m", "0 */1 * * *")] + #[case::complex("36h", "0 0 */1 * *")] + fn check_passing_conversion_from_humantime(#[case] interval: &str, #[case] expected: &str) { + let interval_dur: Duration = humantime::parse_duration(interval).unwrap(); + let interval = TimeDelta::from_std(interval_dur).unwrap(); + + let cron = Cron::try_from(&interval); + assert!(cron.is_ok()); + + let cron_str = cron.unwrap().0; + assert_eq!(cron_str, expected); + } + + #[rstest::rstest] + #[case::zero(TimeDelta::minutes(0), "interval lower than 1 minute is not supported")] + #[case::negative(TimeDelta::minutes(-1), "interval lower than 1 minute is not supported")] + #[case::too_small(TimeDelta::seconds(1), "interval lower than 1 minute is not supported")] + #[case::too_large(TimeDelta::days(367), "interval cannot exceed 1 year")] + fn check_error_conversion(#[case] interval: TimeDelta, #[case] expected_err: &str) { + let cron = Cron::try_from(&interval); + assert!(cron.is_err()); + + let err_str = cron.unwrap_err(); + assert_eq!(err_str, expected_err); + } +} diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__failure_mode_reconnect-2.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__failure_mode_reconnect-2.snap new file mode 100644 index 0000000000..4685d0ee3d --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__failure_mode_reconnect-2.snap @@ -0,0 +1,77 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +description: "Make sure everything is in status 'new' and we have all the entities and root fields" +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__failure_mode_reconnect-4.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__failure_mode_reconnect-4.snap new file mode 100644 index 0000000000..b43fa9d929 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__failure_mode_reconnect-4.snap @@ -0,0 +1,72 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +description: "Make sure everything is in status 'cached' and we have all the entities and root fields" +expression: cache_keys +--- +[ + { + "key": "failure_mode_reconnect-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert-3.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert-3.snap new file mode 100644 index 0000000000..fccf95442c --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert-3.snap @@ -0,0 +1,72 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +description: "Make sure everything is in status 'cached' and we have all the entities and root fields" +expression: cache_keys +--- +[ + { + "key": "test_insert_simple-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert.snap new file mode 100644 index 0000000000..4685d0ee3d --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert.snap @@ -0,0 +1,77 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +description: "Make sure everything is in status 'new' and we have all the entities and root fields" +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_nested_field_set-3.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_nested_field_set-3.snap new file mode 100644 index 0000000000..3fa2700dc9 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_nested_field_set-3.snap @@ -0,0 +1,77 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +description: "Make sure everything is in status 'cached' and we have all the entities and root fields" +expression: cache_keys +--- +[ + { + "key": "test_insert_with_nested_field_set-version:1.0:subgraph:products:type:Query:hash:1d6a4bed1b509c596aa33b20897cfb1639570bcd9a0e25e7b103ca887b8290bb:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "allProducts" + ], + "kind": { + "rootFields": [ + "allProducts" + ] + }, + "subgraphName": "products", + "subgraphRequest": { + "query": "{ allProducts { name createdBy { __typename email country { a } } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "allProducts": [ + { + "name": "Test", + "createdBy": { + "__typename": "User", + "email": "test@test.com", + "country": { + "a": "France" + } + } + } + ] + } + } + }, + { + "key": "version:1.0:subgraph:users:type:User:entity:210e26346d676046faa9fb55d459273a43e5b5397a1a056f179a3521dc5643aa:representation:7cd02a08f4ea96f0affa123d5d3f56abca20e6014e060fe5594d210c00f64b27:hash:d62875543fbc76830c980c1ccc0fc838c1cfa5dbb61ee82a6299aeb1a9250dc0:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "user-email:test@test.com-country-a:France" + ], + "kind": { + "typename": "User", + "entityKey": { + "email": "test@test.com", + "country": { + "a": "France" + } + } + }, + "subgraphName": "users", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { name } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "name": "test" + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_nested_field_set.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_nested_field_set.snap new file mode 100644 index 0000000000..69ca29bdac --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_nested_field_set.snap @@ -0,0 +1,85 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +description: "Make sure everything is in status 'new' and we have all the entities and root fields" +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:products:type:Query:hash:1d6a4bed1b509c596aa33b20897cfb1639570bcd9a0e25e7b103ca887b8290bb:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "allProducts" + ], + "kind": { + "rootFields": [ + "allProducts" + ] + }, + "subgraphName": "products", + "subgraphRequest": { + "query": "{ allProducts { name createdBy { __typename email country { a } } } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "allProducts": [ + { + "name": "Test", + "createdBy": { + "__typename": "User", + "email": "test@test.com", + "country": { + "a": "France" + } + } + } + ] + } + } + }, + { + "key": "version:1.0:subgraph:users:type:User:entity:210e26346d676046faa9fb55d459273a43e5b5397a1a056f179a3521dc5643aa:representation:7cd02a08f4ea96f0affa123d5d3f56abca20e6014e060fe5594d210c00f64b27:hash:d62875543fbc76830c980c1ccc0fc838c1cfa5dbb61ee82a6299aeb1a9250dc0:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "user-email:test@test.com-country-a:France" + ], + "kind": { + "typename": "User", + "entityKey": { + "email": "test@test.com", + "country": { + "a": "France" + } + } + }, + "subgraphName": "users", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { name } } }", + "variables": { + "representations": [ + { + "country": { + "a": "France" + }, + "email": "test@test.com", + "__typename": "User" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "name": "test" + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_requires-3.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_requires-3.snap new file mode 100644 index 0000000000..b0955b911a --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_requires-3.snap @@ -0,0 +1,73 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +description: "Make sure everything is in status 'cached' and we have all the entities and root fields" +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:inventory:type:Product:entity:72bafad9ffe61307806863b13856470e429e0cf332c99e5b735224fb0b1436f7:representation:7a26bc9b6cfab31cd9278921710167eaf9cde6c17d5f86102c898a04a06aeb6c:hash:ea89086f7f644d0d328416ab58e8fdd3adbbd9209ad59f9ce79ef762bb4c3546:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "product", + "product-1" + ], + "kind": { + "typename": "Product", + "entityKey": { + "upc": "1" + } + }, + "subgraphName": "inventory", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { shippingEstimate } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "shippingEstimate": 15 + } + } + }, + { + "key": "test_insert_with_requires-version:1.0:subgraph:products:type:Query:hash:cb5a89f1c324570b58144094abb4d100350bbf988e178a06584018fdd08a55db:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "topProducts", + "topProducts-5" + ], + "kind": { + "rootFields": [ + "topProducts" + ] + }, + "subgraphName": "products", + "subgraphRequest": { + "query": "{ topProducts { __typename upc name price weight } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "topProducts": [ + { + "__typename": "Product", + "upc": "1", + "name": "Test", + "price": 150, + "weight": 5 + } + ] + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_requires.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_requires.snap new file mode 100644 index 0000000000..c77b8be991 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__insert_with_requires.snap @@ -0,0 +1,80 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +description: "Make sure everything is in status 'new' and we have all the entities and root fields" +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:inventory:type:Product:entity:72bafad9ffe61307806863b13856470e429e0cf332c99e5b735224fb0b1436f7:representation:7a26bc9b6cfab31cd9278921710167eaf9cde6c17d5f86102c898a04a06aeb6c:hash:ea89086f7f644d0d328416ab58e8fdd3adbbd9209ad59f9ce79ef762bb4c3546:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "product", + "product-1" + ], + "kind": { + "typename": "Product", + "entityKey": { + "upc": "1" + } + }, + "subgraphName": "test", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { shippingEstimate } } }", + "variables": { + "representations": [ + { + "weight": 5, + "price": 150, + "upc": "1", + "__typename": "Product" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "shippingEstimate": 15 + } + } + }, + { + "key": "version:1.0:subgraph:products:type:Query:hash:cb5a89f1c324570b58144094abb4d100350bbf988e178a06584018fdd08a55db:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "topProducts", + "topProducts-5" + ], + "kind": { + "rootFields": [ + "topProducts" + ] + }, + "subgraphName": "products", + "subgraphRequest": { + "query": "{ topProducts { __typename upc name price weight } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "topProducts": [ + { + "__typename": "Product", + "upc": "1", + "name": "Test", + "price": 150, + "weight": 5 + } + ] + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_cache_tag-3.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_cache_tag-3.snap new file mode 100644 index 0000000000..972c44b446 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_cache_tag-3.snap @@ -0,0 +1,71 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "test_invalidate_by_cache_tag-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_cache_tag-5.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_cache_tag-5.snap new file mode 100644 index 0000000000..8592644623 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_cache_tag-5.snap @@ -0,0 +1,76 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "test_invalidate_by_cache_tag-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_cache_tag.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_cache_tag.snap new file mode 100644 index 0000000000..9d6b31236f --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_cache_tag.snap @@ -0,0 +1,76 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_subgraph-3.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_subgraph-3.snap new file mode 100644 index 0000000000..0fa5d38dba --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_subgraph-3.snap @@ -0,0 +1,69 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "test_invalidate_by_subgraph-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "status": "cached", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [] + } + }, + "status": "cached", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_subgraph.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_subgraph.snap new file mode 100644 index 0000000000..656b238ca9 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_subgraph.snap @@ -0,0 +1,74 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "status": "new", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "status": "new", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_type-3.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_type-3.snap new file mode 100644 index 0000000000..418bc33d99 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_type-3.snap @@ -0,0 +1,71 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "test_invalidate_by_subgraph-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_type-5.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_type-5.snap new file mode 100644 index 0000000000..7f8fc4fd6a --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_type-5.snap @@ -0,0 +1,76 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "test_invalidate_by_subgraph-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_type.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_type.snap new file mode 100644 index 0000000000..9d6b31236f --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__invalidate_by_type.snap @@ -0,0 +1,76 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__missing_entities-2.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__missing_entities-2.snap new file mode 100644 index 0000000000..b17ee0c5d8 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__missing_entities-2.snap @@ -0,0 +1,37 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: response +--- +{ + "data": { + "currentUser": { + "allOrganizations": [ + { + "id": "1", + "name": "Organization 1" + }, + { + "id": "2", + "name": "Organization 2" + }, + { + "id": "3", + "name": null + } + ] + } + }, + "errors": [ + { + "message": "Organization not found", + "path": [ + "currentUser", + "allOrganizations", + 2 + ], + "extensions": { + "service": "orga" + } + } + ] +} diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__missing_entities.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__missing_entities.snap new file mode 100644 index 0000000000..3263d6ba25 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__missing_entities.snap @@ -0,0 +1,20 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: response +--- +{ + "data": { + "currentUser": { + "allOrganizations": [ + { + "id": "1", + "name": "Organization 1" + }, + { + "id": "2", + "name": "Organization 2" + } + ] + } + } +} diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__no_data-3.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__no_data-3.snap new file mode 100644 index 0000000000..a1af89d18c --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__no_data-3.snap @@ -0,0 +1,119 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:user:type:Query:hash:de5bb560dcef94cd4b5cfc0ceecd75d4f3ae3cc371fd979a3dbbab0eccafc5f6:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { allOrganizations { __typename id } } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "noStore": true + }, + "data": { + "data": { + "currentUser": { + "allOrganizations": [ + { + "__typename": "Organization", + "id": "1" + }, + { + "__typename": "Organization", + "id": "2" + }, + { + "__typename": "Organization", + "id": "3" + } + ] + } + } + } + }, + { + "key": "no_data-version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:80648d58db616e50fbca283d6de1bd85440a02c5df2172f55f5c53fc35acdd10:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { name } } }", + "variables": { + "representations": [ + { + "id": "2", + "__typename": "Organization" + } + ] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 3600, + "public": true + }, + "data": { + "data": { + "name": "Organization 1" + } + } + }, + { + "key": "no_data-version:1.0:subgraph:orga:type:Organization:entity:2a66208010218056832ffcb8e3e26c636cb2a57e71fc62b424909e2ab2246145:representation::hash:80648d58db616e50fbca283d6de1bd85440a02c5df2172f55f5c53fc35acdd10:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-3" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "3" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { name } } }", + "variables": { + "representations": [ + { + "id": "2", + "__typename": "Organization" + } + ] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 3600, + "public": true + }, + "data": { + "data": { + "name": "Organization 3" + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__no_data.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__no_data.snap new file mode 100644 index 0000000000..6dde3c6ab0 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__no_data.snap @@ -0,0 +1,123 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:user:type:Query:hash:de5bb560dcef94cd4b5cfc0ceecd75d4f3ae3cc371fd979a3dbbab0eccafc5f6:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { allOrganizations { __typename id } } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "noStore": true + }, + "data": { + "data": { + "currentUser": { + "allOrganizations": [ + { + "__typename": "Organization", + "id": "1" + }, + { + "__typename": "Organization", + "id": "3" + } + ] + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:80648d58db616e50fbca283d6de1bd85440a02c5df2172f55f5c53fc35acdd10:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "test", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { name } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + }, + { + "id": "3", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 3600, + "public": true + }, + "data": { + "data": { + "name": "Organization 1" + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:2a66208010218056832ffcb8e3e26c636cb2a57e71fc62b424909e2ab2246145:representation::hash:80648d58db616e50fbca283d6de1bd85440a02c5df2172f55f5c53fc35acdd10:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-3" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "3" + } + }, + "subgraphName": "test", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { name } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + }, + { + "id": "3", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 3600, + "public": true + }, + "data": { + "data": { + "name": "Organization 3" + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-11.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-11.snap new file mode 100644 index 0000000000..014cf67e88 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-11.snap @@ -0,0 +1,97 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "polymorphic_private_and_public-version:1.0:subgraph:orga:type:Query:hash:d72fc5bd9cc1994bdd5bee5d0f62417a27385b44300212a56d332be2e064798e:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [], + "kind": { + "rootFields": [ + "orga" + ] + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "{ orga(id: \"2\") { name } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "orga": { + "name": "test_orga_public" + } + } + } + }, + { + "key": "polymorphic_private_and_public-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 3 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-3.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-3.snap new file mode 100644 index 0000000000..3bd8360721 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-3.snap @@ -0,0 +1,102 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:orga:type:Query:hash:d72fc5bd9cc1994bdd5bee5d0f62417a27385b44300212a56d332be2e064798e:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [], + "kind": { + "rootFields": [ + "orga" + ] + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "{ orga(id: \"2\") { name } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "orga": { + "name": "test_orga_public" + } + } + } + }, + { + "key": "polymorphic_private_and_public-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 3 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-5.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-5.snap new file mode 100644 index 0000000000..f5d7836aa6 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-5.snap @@ -0,0 +1,100 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "polymorphic_private_and_public-version:1.0:subgraph:orga:type:Query:hash:d72fc5bd9cc1994bdd5bee5d0f62417a27385b44300212a56d332be2e064798e:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [], + "kind": { + "rootFields": [ + "orga" + ] + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "{ orga(id: \"2\") { name } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "orga": { + "name": "test_orga" + } + } + } + }, + { + "key": "polymorphic_private_and_public-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-7.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-7.snap new file mode 100644 index 0000000000..014cf67e88 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-7.snap @@ -0,0 +1,97 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "polymorphic_private_and_public-version:1.0:subgraph:orga:type:Query:hash:d72fc5bd9cc1994bdd5bee5d0f62417a27385b44300212a56d332be2e064798e:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [], + "kind": { + "rootFields": [ + "orga" + ] + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "{ orga(id: \"2\") { name } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "orga": { + "name": "test_orga_public" + } + } + } + }, + { + "key": "polymorphic_private_and_public-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 3 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-9.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-9.snap new file mode 100644 index 0000000000..f5d7836aa6 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public-9.snap @@ -0,0 +1,100 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "polymorphic_private_and_public-version:1.0:subgraph:orga:type:Query:hash:d72fc5bd9cc1994bdd5bee5d0f62417a27385b44300212a56d332be2e064798e:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [], + "kind": { + "rootFields": [ + "orga" + ] + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "{ orga(id: \"2\") { name } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "orga": { + "name": "test_orga" + } + } + } + }, + { + "key": "polymorphic_private_and_public-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public.snap new file mode 100644 index 0000000000..2def1535a5 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__polymorphic_private_and_public.snap @@ -0,0 +1,106 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +description: "Make sure everything is in status 'new' and we have all the entities and root fields" +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:orga:type:Query:hash:d72fc5bd9cc1994bdd5bee5d0f62417a27385b44300212a56d332be2e064798e:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [], + "kind": { + "rootFields": [ + "orga" + ] + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "{ orga(id: \"2\") { name } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "orga": { + "name": "test_orga" + } + } + } + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_and_public-3.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_and_public-3.snap new file mode 100644 index 0000000000..8b1a1da553 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_and_public-3.snap @@ -0,0 +1,100 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "private_and_public-version:1.0:subgraph:orga:type:Query:hash:d72fc5bd9cc1994bdd5bee5d0f62417a27385b44300212a56d332be2e064798e:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [], + "kind": { + "rootFields": [ + "orga" + ] + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "{ orga(id: \"2\") { name } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "orga": { + "name": "test_orga" + } + } + } + }, + { + "key": "private_and_public-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_and_public-5.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_and_public-5.snap new file mode 100644 index 0000000000..dd82d9b64f --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_and_public-5.snap @@ -0,0 +1,104 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:orga:type:Query:hash:d72fc5bd9cc1994bdd5bee5d0f62417a27385b44300212a56d332be2e064798e:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:f8638b979b2f4f793ddb6dbd197e0ee25a7a6ea32b0ae22f5e3c5d119d839e75", + "invalidationKeys": [], + "kind": { + "rootFields": [ + "orga" + ] + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "{ orga(id: \"2\") { name } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "f8638b979b2f4f793ddb6dbd197e0ee25a7a6ea32b0ae22f5e3c5d119d839e75", + "data": { + "data": { + "orga": { + "name": "test_orga" + } + } + } + }, + { + "key": "private_and_public-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "hashedPrivateId": "f8638b979b2f4f793ddb6dbd197e0ee25a7a6ea32b0ae22f5e3c5d119d839e75", + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:f8638b979b2f4f793ddb6dbd197e0ee25a7a6ea32b0ae22f5e3c5d119d839e75", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_and_public.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_and_public.snap new file mode 100644 index 0000000000..9c63d1fae6 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_and_public.snap @@ -0,0 +1,105 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:orga:type:Query:hash:d72fc5bd9cc1994bdd5bee5d0f62417a27385b44300212a56d332be2e064798e:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [], + "kind": { + "rootFields": [ + "orga" + ] + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "{ orga(id: \"2\") { name } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "orga": { + "name": "test_orga" + } + } + } + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "public": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_only-3.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_only-3.snap new file mode 100644 index 0000000000..4580026816 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_only-3.snap @@ -0,0 +1,73 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "private_only-version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_only-5.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_only-5.snap new file mode 100644 index 0000000000..b22087ffcb --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_only-5.snap @@ -0,0 +1,77 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:f8638b979b2f4f793ddb6dbd197e0ee25a7a6ea32b0ae22f5e3c5d119d839e75", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "f8638b979b2f4f793ddb6dbd197e0ee25a7a6ea32b0ae22f5e3c5d119d839e75", + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:f8638b979b2f4f793ddb6dbd197e0ee25a7a6ea32b0ae22f5e3c5d119d839e75", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_only.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_only.snap new file mode 100644 index 0000000000..5f1ed38e0a --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_only.snap @@ -0,0 +1,78 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "hashedPrivateId": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_without_private_id-3.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_without_private_id-3.snap new file mode 100644 index 0000000000..f534b2bf7f --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_without_private_id-3.snap @@ -0,0 +1,71 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "-", + "invalidationKeys": [], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "private": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "-", + "invalidationKeys": [], + "kind": { + "typename": "", + "entityKey": {} + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "__typename": "Organization", + "id": "1" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "private": true + }, + "data": { + "data": { + "_entities": [ + { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + ] + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_without_private_id.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_without_private_id.snap new file mode 100644 index 0000000000..7f0eec4803 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__tests__private_without_private_id.snap @@ -0,0 +1,76 @@ +--- +source: apollo-router/src/plugins/response_cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:user:type:Query:hash:26ab4118dbabffe5dfbef4462513caa8b6c8941363cf2eafa874bf1d47a3c468:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "currentUser" + ], + "kind": { + "rootFields": [ + "currentUser" + ] + }, + "subgraphName": "user", + "subgraphRequest": { + "query": "{ currentUser { activeOrganization { __typename id } } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "data": { + "data": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1" + } + } + } + } + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:bcc0a4a9f8c595510c0ff8849bc36b402ac3f52506392d67107c623528ff11f4:representation::hash:a7ccf1576978c6598f6d10d8855fc683f71242533dc6d5a706743bd9bbfa554c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [ + "organization", + "organization-1" + ], + "kind": { + "typename": "Organization", + "entityKey": { + "id": "1" + } + }, + "subgraphName": "orga", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { creatorUser { __typename id } } } }", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 86400, + "private": true + }, + "data": { + "data": { + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +] diff --git a/apollo-router/src/plugins/response_cache/tests.rs b/apollo-router/src/plugins/response_cache/tests.rs new file mode 100644 index 0000000000..bb1542b6a4 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/tests.rs @@ -0,0 +1,4313 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use apollo_compiler::Schema; +use http::HeaderName; +use http::HeaderValue; +use http::header::CACHE_CONTROL; +use tokio::sync::broadcast; +use tower::Service; +use tower::ServiceExt; + +use super::plugin::ResponseCache; +use crate::Context; +use crate::MockedSubgraphs; +use crate::TestHarness; +use crate::metrics::FutureMetricsExt; +use crate::plugin::test::MockSubgraph; +use crate::plugin::test::MockSubgraphService; +use crate::plugins::response_cache::cache_control::CacheControl; +use crate::plugins::response_cache::invalidation::InvalidationRequest; +use crate::plugins::response_cache::metrics; +use crate::plugins::response_cache::plugin::CACHE_DEBUG_EXTENSIONS_KEY; +use crate::plugins::response_cache::plugin::CACHE_DEBUG_HEADER_NAME; +use crate::plugins::response_cache::plugin::CONTEXT_DEBUG_CACHE_KEYS; +use crate::plugins::response_cache::plugin::CacheKeysContext; +use crate::plugins::response_cache::plugin::Subgraph; +use crate::plugins::response_cache::postgres::PostgresCacheConfig; +use crate::plugins::response_cache::postgres::PostgresCacheStorage; +use crate::plugins::response_cache::postgres::default_batch_size; +use crate::plugins::response_cache::postgres::default_cleanup_interval; +use crate::plugins::response_cache::postgres::default_pool_size; +use crate::services::subgraph; +use crate::services::supergraph; + +const SCHEMA: &str = include_str!("../../testdata/orga_supergraph_cache_key.graphql"); +const SCHEMA_REQUIRES: &str = include_str!("../../testdata/supergraph_cache_key.graphql"); +const SCHEMA_NESTED_KEYS: &str = + include_str!("../../testdata/supergraph_nested_fields_cache_key.graphql"); + +#[tokio::test] +async fn insert() { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }"; + + let subgraphs = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1", + } + } + }, + "headers": {"cache-control": "public"}, + }, + "orga": { + "entities": [ + { + "__typename": "Organization", + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + ], + "headers": {"cache-control": "public"}, + }, + }); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + cleanup_interval: default_cleanup_interval(), + tls: Default::default(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("test_insert_simple")), + }) + .await + .unwrap(); + let map = [ + ( + "user".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "orga".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let response_cache = + ResponseCache::for_test(pg_cache.clone(), map, valid_schema.clone(), true, false) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({ + "include_subgraph_errors": { "all": true }, + "experimental_mock_subgraphs": subgraphs, + })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::with_settings!({ + description => "Make sure everything is in status 'new' and we have all the entities and root fields" + }, { + insta::assert_json_snapshot!(cache_keys); + }); + + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::with_settings!({ + description => "Make sure everything is in status 'cached' and we have all the entities and root fields" + }, { + insta::assert_json_snapshot!(cache_keys); + }); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); +} + +#[tokio::test] +async fn insert_without_debug_header() { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }"; + + let subgraphs = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1", + } + } + }, + "headers": {"cache-control": "public"}, + }, + "orga": { + "entities": [ + { + "__typename": "Organization", + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + ], + "headers": {"cache-control": "public"}, + }, + }); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + tls: Default::default(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + cleanup_interval: Duration::from_secs(60 * 60), + namespace: Some(String::from("insert_without_debug_header")), + }) + .await + .unwrap(); + let map = [ + ( + "user".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "orga".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let response_cache = + ResponseCache::for_test(pg_cache.clone(), map, valid_schema.clone(), true, false) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({ + "include_subgraph_errors": { "all": true }, + "experimental_mock_subgraphs": subgraphs, + })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + assert!( + response + .context + .get::<_, CacheKeysContext>(CONTEXT_DEBUG_CACHE_KEYS) + .ok() + .flatten() + .is_none() + ); + + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_none() + ); + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + assert!( + response + .context + .get::<_, CacheKeysContext>(CONTEXT_DEBUG_CACHE_KEYS) + .ok() + .flatten() + .is_none() + ); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_none() + ); + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); +} + +#[tokio::test] +async fn insert_with_requires() { + let valid_schema = + Arc::new(Schema::parse_and_validate(SCHEMA_REQUIRES, "test.graphql").unwrap()); + let query = "query { topProducts { name shippingEstimate price } }"; + + let subgraphs = MockedSubgraphs([ + ("products", MockSubgraph::builder().with_json( + serde_json::json!{{"query":"{ topProducts { __typename upc name price weight } }"}}, + serde_json::json!{{"data": {"topProducts": [{ + "__typename": "Product", + "upc": "1", + "name": "Test", + "price": 150, + "weight": 5 + }]}}} + ).with_header(CACHE_CONTROL, HeaderValue::from_static("public")).build()), + ("inventory", MockSubgraph::builder().with_json( + serde_json::json!{{ + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { shippingEstimate } } }", + "variables": { + "representations": [ + { + "weight": 5, + "price": 150, + "upc": "1", + "__typename": "Product" + } + ] + }}}, + serde_json::json!{{"data": { + "_entities": [{ + "shippingEstimate": 15 + }] + }}} + ).with_header(CACHE_CONTROL, HeaderValue::from_static("public")).build()) + ].into_iter().collect()); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + cleanup_interval: default_cleanup_interval(), + tls: Default::default(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("test_insert_with_requires")), + }) + .await + .unwrap(); + let map: HashMap = [ + ( + "products".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "inventory".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let response_cache = ResponseCache::for_test( + pg_cache.clone(), + map.clone(), + valid_schema.clone(), + true, + false, + ) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(SCHEMA_REQUIRES) + .extra_private_plugin(response_cache.clone()) + .extra_plugin(subgraphs.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::with_settings!({ + description => "Make sure everything is in status 'new' and we have all the entities and root fields" + }, { + insta::assert_json_snapshot!(cache_keys); + }); + + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "topProducts": [ + { + "name": "Test", + "shippingEstimate": 15, + "price": 150 + } + ] + } + } + "#); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(SCHEMA_REQUIRES) + .extra_private_plugin(response_cache) + .extra_plugin(subgraphs.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::with_settings!({ + description => "Make sure everything is in status 'cached' and we have all the entities and root fields" + }, { + insta::assert_json_snapshot!(cache_keys); + }); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "topProducts": [ + { + "name": "Test", + "shippingEstimate": 15, + "price": 150 + } + ] + } + } + "#); +} + +#[tokio::test] +async fn insert_with_nested_field_set() { + let valid_schema = + Arc::new(Schema::parse_and_validate(SCHEMA_NESTED_KEYS, "test.graphql").unwrap()); + let query = "query { allProducts { name createdBy { name country { a } } } }"; + + let subgraphs = serde_json::json!({ + "products": { + "query": {"allProducts": [{ + "id": "1", + "name": "Test", + "sku": "150", + "createdBy": { "__typename": "User", "email": "test@test.com", "country": {"a": "France"} } + }]}, + "headers": {"cache-control": "public"}, + }, + "users": { + "entities": [{ + "__typename": "User", + "email": "test@test.com", + "name": "test", + "country": { + "a": "France" + } + }], + "headers": {"cache-control": "public"}, + } + }); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + cleanup_interval: default_cleanup_interval(), + tls: Default::default(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("test_insert_with_nested_field_set")), + }) + .await + .unwrap(); + let map = [ + ( + "products".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "users".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let response_cache = + ResponseCache::for_test(pg_cache.clone(), map, valid_schema.clone(), true, false) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA_NESTED_KEYS) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::with_settings!({ + description => "Make sure everything is in status 'new' and we have all the entities and root fields" + }, { + insta::assert_json_snapshot!(cache_keys); + }); + + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "allProducts": [ + { + "name": "Test", + "createdBy": { + "name": "test", + "country": { + "a": "France" + } + } + } + ] + } + } + "#); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA_NESTED_KEYS) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::with_settings!({ + description => "Make sure everything is in status 'cached' and we have all the entities and root fields" + }, { + insta::assert_json_snapshot!(cache_keys); + }); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "allProducts": [ + { + "name": "Test", + "createdBy": { + "name": "test", + "country": { + "a": "France" + } + } + } + ] + } + } + "#); +} + +#[tokio::test] +async fn no_cache_control() { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }"; + + let subgraphs = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1", + } + } + } + }, + "orga": { + "entities": [ + { + "__typename": "Organization", + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + ] + }, + }); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + cleanup_interval: default_cleanup_interval(), + tls: Default::default(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("test_no_cache_control")), + }) + .await + .unwrap(); + let response_cache = ResponseCache::for_test( + pg_cache.clone(), + HashMap::new(), + valid_schema.clone(), + false, + false, + ) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + + assert_eq!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap(), + "no-store" + ); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + + assert_eq!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap(), + "no-store" + ); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); +} + +#[tokio::test] +async fn no_store_from_request() { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }"; + + let subgraphs = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1", + } + } + } + }, + "orga": { + "entities": [ + { + "__typename": "Organization", + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + ] + }, + }); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + cleanup_interval: default_cleanup_interval(), + tls: Default::default(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("test_no_store_from_client")), + }) + .await + .unwrap(); + let response_cache = ResponseCache::for_test( + pg_cache.clone(), + HashMap::new(), + valid_schema.clone(), + false, + false, + ) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone(), "headers": { + "all": { + "request": [{ + "propagate": { + "named": "cache-control" + } + }] + } + } })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .header(CACHE_CONTROL, HeaderValue::from_static("no-store")) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + + assert_eq!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap(), + "no-store" + ); + let response = response.next_response().await.unwrap(); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + // Just to make sure it doesn't invalidate anything, which means nothing has been stored + assert!( + pg_cache + .invalidate( + vec![ + "user".to_string(), + "organization".to_string(), + "currentUser".to_string() + ], + vec!["orga".to_string(), "user".to_string()] + ) + .await + .unwrap() + .is_empty() + ); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone(), "headers": { + "all": { + "request": [{ + "propagate": { + "named": "cache-control" + } + }] + } + } })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .header(CACHE_CONTROL, HeaderValue::from_static("no-store")) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + + assert_eq!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap(), + "no-store" + ); + let response = response.next_response().await.unwrap(); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + // Just to make sure it doesn't invalidate anything, which means nothing has been stored + assert!( + pg_cache + .invalidate( + vec![ + "user".to_string(), + "organization".to_string(), + "currentUser".to_string() + ], + vec!["orga".to_string(), "user".to_string()] + ) + .await + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn private_only() { + async { + let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }"; + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + + let subgraphs = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1", + } + } + }, + "headers": {"cache-control": "private"}, + }, + "orga": { + "entities": [ + { + "__typename": "Organization", + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + ], + "headers": {"cache-control": "private"}, + }, + }); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + cleanup_interval: default_cleanup_interval(), + tls: Default::default(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("private_only")), + }) + .await + .unwrap(); + let map = [ + ( + "user".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "orga".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let response_cache = + ResponseCache::for_test(pg_cache.clone(), map, valid_schema.clone(), true, false) + .await + .unwrap(); + + let mut service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let context = Context::new(); + context.insert_json_value("sub", "1234".into()); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + + assert_gauge!("apollo.router.response_cache.private_queries.lru.size", 1); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + // First request with only private response cache-control + let mut service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let context = Context::new(); + context.insert_json_value("sub", "1234".into()); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .unwrap() + .to_str() + .unwrap() + .contains("private") + ); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + let context = Context::new(); + context.insert_json_value("sub", "5678".into()); + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .unwrap() + .to_str() + .unwrap() + .contains("private") + ); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + }.with_metrics().await; +} + +// In this test we want to make sure when we have 2 root fields with both public and private data it still returns private +#[tokio::test] +async fn private_and_public() { + let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } orga(id: \"2\") { name } }"; + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + + let subgraphs = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1", + } + } + }, + "headers": {"cache-control": "public"}, + }, + "orga": { + "query": { + "orga": { + "__typename": "Organization", + "id": "2", + "name": "test_orga" + } + }, + "entities": [ + { + "__typename": "Organization", + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + ], + "headers": {"cache-control": "private"}, + }, + }); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + cleanup_interval: default_cleanup_interval(), + tls: Default::default(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("private_and_public")), + }) + .await + .unwrap(); + let map = [ + ( + "user".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "orga".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let response_cache = + ResponseCache::for_test(pg_cache.clone(), map, valid_schema.clone(), true, false) + .await + .unwrap(); + + let mut service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let context = Context::new(); + context.insert_json_value("sub", "1234".into()); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + }, + "orga": { + "name": "test_orga" + } + } + } + "#); + + let mut service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let context = Context::new(); + context.insert_json_value("sub", "1234".into()); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .unwrap() + .to_str() + .unwrap() + .contains("private") + ); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + }, + "orga": { + "name": "test_orga" + } + } + } + "#); + + let context = Context::new(); + context.insert_json_value("sub", "5678".into()); + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .unwrap() + .to_str() + .unwrap() + .contains("private") + ); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + }, + "orga": { + "name": "test_orga" + } + } + } + "#); +} + +// In this test we want to make sure when we have a subgraph query that could be either public or private depending of private_id it still works +#[tokio::test] +async fn polymorphic_private_and_public() { + async { + let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } orga(id: \"2\") { name } }"; + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + + let subgraphs = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1", + } + } + }, + "headers": {"cache-control": "public"}, + }, + "orga": { + "query": { + "orga": { + "__typename": "Organization", + "id": "2", + "name": "test_orga" + } + }, + "entities": [ + { + "__typename": "Organization", + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + ], + "headers": {"cache-control": "private"}, + }, + }); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + cleanup_interval: default_cleanup_interval(), + tls: Default::default(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("polymorphic_private_and_public")), + }) + .await + .unwrap(); + let map = [ + ( + "user".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "orga".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let response_cache = + ResponseCache::for_test(pg_cache.clone(), map, valid_schema.clone(), true, false) + .await + .unwrap(); + + let mut service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let context = Context::new(); + context.insert_json_value("sub", "1234".into()); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::with_settings!({ + description => "Make sure everything is in status 'new' and we have all the entities and root fields" + }, { + insta::assert_json_snapshot!(cache_keys); + }); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + }, + "orga": { + "name": "test_orga" + } + } + } + "#); + + let subgraphs_public = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1", + } + } + }, + "headers": {"cache-control": "public"}, + }, + "orga": { + "query": { + "orga": { + "__typename": "Organization", + "id": "2", + "name": "test_orga_public" + } + }, + "entities": [ + { + "__typename": "Organization", + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 3 + } + } + ], + "headers": {"cache-control": "public"}, + }, + }); + + let mut service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs_public.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let context = Context::new(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .unwrap() + .to_str() + .unwrap() + .contains("public") + ); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 3 + } + } + }, + "orga": { + "name": "test_orga_public" + } + } + } + "#); + + // Put back private cache-control to check it's still in cache + let mut service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let context = Context::new(); + context.insert_json_value("sub", "1234".into()); + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .unwrap() + .to_str() + .unwrap() + .contains("private") + ); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + }, + "orga": { + "name": "test_orga" + } + } + } + "#); + + // Test again with subgraph public to make sure it's still cached + let mut service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs_public.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let context = Context::new(); + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .unwrap() + .to_str() + .unwrap() + .contains("public") + ); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 3 + } + } + }, + "orga": { + "name": "test_orga_public" + } + } + } + "#); + assert_gauge!("apollo.router.response_cache.private_queries.lru.size", 1); + + // Test again with public subgraph but with a private_id set, it should be private because this query is private once we have private_id set, even if the subgraph is public, it's coming from the cache + let context = Context::new(); + context.insert_json_value("sub", "1234".into()); + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .unwrap() + .to_str() + .unwrap() + .contains("private") + ); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + }, + "orga": { + "name": "test_orga" + } + } + } + "#); + assert_gauge!("apollo.router.response_cache.private_queries.lru.size", 1); + + // Test again with private subgraph but without private_id set, it should give the public values because it's cached and it knows even if the subgraphs are private it was public without private_id + let mut service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + let context = Context::new(); + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .unwrap() + .to_str() + .unwrap() + .contains("public") + ); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 3 + } + } + }, + "orga": { + "name": "test_orga_public" + } + } + } + "#); + assert_gauge!("apollo.router.response_cache.private_queries.lru.size", 1); + }.with_metrics().await; +} + +#[tokio::test] +async fn private_without_private_id() { + async { + let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }"; + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + + let subgraphs = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1", + } + } + }, + "headers": {"cache-control": "private"}, + }, + "orga": { + "entities": [ + { + "__typename": "Organization", + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + ], + "headers": {"cache-control": "private"}, + }, + }); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + cleanup_interval: default_cleanup_interval(), + tls: Default::default(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("private_without_private_id")), + }) + .await + .unwrap(); + let map = [ + ( + "user".to_string(), + Subgraph { + postgres: None, + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "orga".to_string(), + Subgraph { + postgres: None, + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let response_cache = + ResponseCache::for_test(pg_cache.clone(), map, valid_schema.clone(), true, false) + .await + .unwrap(); + + let mut service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let context = Context::new(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .unwrap() + .to_str() + .unwrap() + .contains("private") + ); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + + assert_gauge!("apollo.router.response_cache.private_queries.lru.size", 1); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + // Now testing without any mock subgraphs, all the data should come from the cache + let mut service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let context = Context::new(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(context) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.ready().await.unwrap().call(request).await.unwrap(); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .unwrap() + .to_str() + .unwrap() + .contains("private") + ); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + }.with_metrics().await; +} + +#[tokio::test] +async fn no_data() { + let query = "query { currentUser { allOrganizations { id name } } }"; + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + + let subgraphs = MockedSubgraphs([ + ("user", MockSubgraph::builder().with_json( + serde_json::json!{{"query":"{currentUser{allOrganizations{__typename id}}}"}}, + serde_json::json!{{"data": {"currentUser": { "allOrganizations": [ + { + "__typename": "Organization", + "id": "1" + }, + { + "__typename": "Organization", + "id": "3" + } + ] }}}} + ).with_header(CACHE_CONTROL, HeaderValue::from_static("no-store")).build()), + ("orga", MockSubgraph::builder().with_json( + serde_json::json!{{ + "query": "query($representations:[_Any!]!){_entities(representations:$representations){...on Organization{name}}}", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization", + }, + { + "id": "3", + "__typename": "Organization", + } + ] + }}}, + serde_json::json!{{ + "data": { + "_entities": [{ + "name": "Organization 1", + }, + { + "name": "Organization 3" + }] + } + }} + ).with_header(CACHE_CONTROL, HeaderValue::from_static("public, max-age=3600")).build()) + ].into_iter().collect()); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + cleanup_interval: default_cleanup_interval(), + tls: Default::default(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("no_data")), + }) + .await + .unwrap(); + let map = [ + ( + "user".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "orga".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let response_cache = + ResponseCache::for_test(pg_cache.clone(), map, valid_schema.clone(), true, false) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .extra_plugin(subgraphs) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys, { + "[].cache_control" => insta::dynamic_redaction(|value, _path| { + let cache_control = value.as_str().unwrap().to_string(); + assert!(cache_control.contains("max-age=")); + assert!(cache_control.contains("public")); + "[REDACTED]" + }) + }); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "allOrganizations": [ + { + "id": "1", + "name": "Organization 1" + }, + { + "id": "3", + "name": "Organization 3" + } + ] + } + } + } + "#); + + let subgraphs = MockedSubgraphs( + [( + "user", + MockSubgraph::builder() + .with_json( + serde_json::json! {{"query":"{currentUser{allOrganizations{__typename id}}}"}}, + serde_json::json! {{"data": {"currentUser": { "allOrganizations": [ + { + "__typename": "Organization", + "id": "1" + }, + { + "__typename": "Organization", + "id": "2" + }, + { + "__typename": "Organization", + "id": "3" + } + ] }}}}, + ) + .with_header(CACHE_CONTROL, HeaderValue::from_static("no-store")) + .build(), + )] + .into_iter() + .collect(), + ); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache) + .subgraph_hook(|name, service| { + if name == "orga" { + let mut subgraph = MockSubgraphService::new(); + subgraph + .expect_call() + .times(1) + .returning(move |_req: subgraph::Request| Err("orga not found".into())); + subgraph.boxed() + } else { + service + } + }) + .extra_plugin(subgraphs) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "allOrganizations": [ + { + "id": "1", + "name": "Organization 1" + }, + { + "id": "2", + "name": null + }, + { + "id": "3", + "name": "Organization 3" + } + ] + } + }, + "errors": [ + { + "message": "HTTP fetch failed from 'orga': orga not found", + "path": [ + "currentUser", + "allOrganizations", + 1 + ], + "extensions": { + "code": "SUBREQUEST_HTTP_ERROR", + "service": "orga", + "reason": "orga not found" + } + } + ] + } + "#); +} + +#[tokio::test] +async fn missing_entities() { + let query = "query { currentUser { allOrganizations { id name } } }"; + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + let subgraphs = MockedSubgraphs([ + ("user", MockSubgraph::builder().with_json( + serde_json::json!{{"query":"{currentUser{allOrganizations{__typename id}}}"}}, + serde_json::json!{{"data": {"currentUser": { "allOrganizations": [ + { + "__typename": "Organization", + "id": "1" + }, + { + "__typename": "Organization", + "id": "2" + } + ] }}}} + ).with_header(CACHE_CONTROL, HeaderValue::from_static("no-store")).build()), + ("orga", MockSubgraph::builder().with_json( + serde_json::json!{{ + "query": "query($representations:[_Any!]!){_entities(representations:$representations){...on Organization{name}}}", + "variables": { + "representations": [ + { + "id": "1", + "__typename": "Organization", + }, + { + "id": "2", + "__typename": "Organization", + } + ] + }}}, + serde_json::json!{{ + "data": { + "_entities": [ + { + "name": "Organization 1", + }, + { + "name": "Organization 2" + } + ] + } + }} + ).with_header(CACHE_CONTROL, HeaderValue::from_static("public, max-age=3600")).build()) + ].into_iter().collect()); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + cleanup_interval: default_cleanup_interval(), + tls: Default::default(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("missing_entities")), + }) + .await + .unwrap(); + let map = [ + ( + "user".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "orga".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let response_cache = + ResponseCache::for_test(pg_cache.clone(), map, valid_schema.clone(), true, false) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .extra_plugin(subgraphs) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + insta::assert_json_snapshot!(response); + + let response_cache = ResponseCache::for_test( + pg_cache.clone(), + HashMap::new(), + valid_schema.clone(), + false, + false, + ) + .await + .unwrap(); + + let subgraphs = MockedSubgraphs([ + ("user", MockSubgraph::builder().with_json( + serde_json::json!{{"query":"{currentUser{allOrganizations{__typename id}}}"}}, + serde_json::json!{{"data": {"currentUser": { "allOrganizations": [ + { + "__typename": "Organization", + "id": "1" + }, + { + "__typename": "Organization", + "id": "2" + }, + { + "__typename": "Organization", + "id": "3" + } + ] }}}} + ).with_header(CACHE_CONTROL, HeaderValue::from_static("no-store")).build()), + ("orga", MockSubgraph::builder().with_json( + serde_json::json!{{ + "query": "query($representations:[_Any!]!){_entities(representations:$representations){...on Organization{name}}}", + "variables": { + "representations": [ + { + "id": "3", + "__typename": "Organization", + } + ] + }}}, + serde_json::json!{{ + "data": null, + "errors": [{ + "message": "Organization not found", + }] + }} + ).with_header(CACHE_CONTROL, HeaderValue::from_static("public, max-age=3600")).build()) + ].into_iter().collect()); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache) + .extra_plugin(subgraphs) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalidate_by_cache_tag() { + async move { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }"; + let subgraphs = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1", + } + } + }, + "headers": {"cache-control": "public"}, + }, + "orga": { + "entities": [ + { + "__typename": "Organization", + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + ], + "headers": {"cache-control": "public"}, + }, + }); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + cleanup_interval: default_cleanup_interval(), + tls: Default::default(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("test_invalidate_by_cache_tag")), + }) + .await + .unwrap(); + let map = [ + ( + "user".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "orga".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let response_cache = + ResponseCache::for_test(pg_cache.clone(), map, valid_schema.clone(), true, false) + .await + .unwrap(); + + let invalidation = response_cache.invalidation.clone(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + assert_histogram_sum!("apollo.router.operations.response_cache.fetch.entity", 1u64, "subgraph.name" = "orga", "graphql.type" = "Organization"); + + + // Now testing without any mock subgraphs, all the data should come from the cache + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.clone().oneshot(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + assert_histogram_sum!("apollo.router.operations.response_cache.fetch.entity", 2u64, "subgraph.name" = "orga", "graphql.type" = "Organization"); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + // now we invalidate data + let res = invalidation + .invalidate(vec![InvalidationRequest::CacheTag { + subgraphs: vec!["orga".to_string()].into_iter().collect(), + cache_tag: String::from("organization-1"), + }]) + .await + .unwrap(); + assert_eq!(res, 1); + + assert_counter!("apollo.router.operations.response_cache.invalidation.entry", 1u64, "subgraph.name" = "orga"); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.clone().oneshot(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + assert_histogram_sum!("apollo.router.operations.response_cache.fetch.entity", 3u64, "subgraph.name" = "orga", "graphql.type" = "Organization"); + + }.with_metrics().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalidate_by_type() { + async move { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }"; + let subgraphs = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1", + } + } + }, + "headers": {"cache-control": "public"}, + }, + "orga": { + "entities": [ + { + "__typename": "Organization", + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + ], + "headers": {"cache-control": "public"}, + }, + }); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + tls: Default::default(), + cleanup_interval: default_cleanup_interval(), + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("test_invalidate_by_subgraph")), + }) + .await + .unwrap(); + let map = [ + ( + "user".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "orga".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let response_cache = + ResponseCache::for_test(pg_cache.clone(), map, valid_schema.clone(), true, false) + .await + .unwrap(); + + let invalidation = response_cache.invalidation.clone(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + // Now testing without any mock subgraphs, all the data should come from the cache + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.clone().oneshot(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + // now we invalidate data + let res = invalidation + .invalidate(vec![InvalidationRequest::Type {subgraph:"orga".to_string(), r#type: "Organization".to_string() }]) + .await + .unwrap(); + assert_eq!(res, 1); + + assert_counter!("apollo.router.operations.response_cache.invalidation.entry", 1u64, "subgraph.name" = "orga"); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.clone().oneshot(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::assert_json_snapshot!(cache_keys); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains("max-age="), + ); + assert!( + response + .response + .headers() + .get(CACHE_CONTROL) + .and_then(|h| h.to_str().ok()) + .unwrap() + .contains(",public"), + ); + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + }.with_metrics().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn interval_cleanup_config() { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + tls: Default::default(), + cleanup_interval: std::time::Duration::from_secs(60 * 7), // Every 7 minutes + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("interval_cleanup_config_1")), + }) + .await + .unwrap(); + let _response_cache = ResponseCache::for_test( + pg_cache.clone(), + Default::default(), + valid_schema.clone(), + true, + true, + ) + .await + .unwrap(); + + let cron = pg_cache.get_cron().await.unwrap(); + assert_eq!(cron.0, String::from("*/7 * * * *")); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + tls: Default::default(), + cleanup_interval: std::time::Duration::from_secs(60 * 60 * 7), // Every 7 hours + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("interval_cleanup_config_2")), + }) + .await + .unwrap(); + let _response_cache = ResponseCache::for_test( + pg_cache.clone(), + Default::default(), + valid_schema.clone(), + true, + true, + ) + .await + .unwrap(); + + let cron = pg_cache.get_cron().await.unwrap(); + assert_eq!(cron.0, String::from("0 */7 * * *")); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + tls: Default::default(), + cleanup_interval: std::time::Duration::from_secs(60 * 60 * 24 * 7), // Every 7 days + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("interval_cleanup_config_2")), + }) + .await + .unwrap(); + let _response_cache = ResponseCache::for_test( + pg_cache.clone(), + Default::default(), + valid_schema.clone(), + true, + true, + ) + .await + .unwrap(); + + let cron = pg_cache.get_cron().await.unwrap(); + assert_eq!(cron.0, String::from("0 0 */7 * *")); +} + +#[tokio::test] +async fn failure_mode() { + async { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + let query = + "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }"; + + let subgraphs = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1", + } + } + }, + "headers": {"cache-control": "public"}, + }, + "orga": { + "entities": [ + { + "__typename": "Organization", + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + ], + "headers": {"cache-control": "public"}, + }, + }); + + let map = [ + ( + "user".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "orga".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let response_cache = + ResponseCache::without_storage_for_failure_mode(map, valid_schema.clone()) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({ + "include_subgraph_errors": { "all": true }, + "experimental_mock_subgraphs": subgraphs.clone(), + })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let response = response.next_response().await.unwrap(); + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + assert_counter!( + "apollo.router.operations.response_cache.fetch.error", + 1, + "subgraph.name" = "orga", + "code" = "NO_STORAGE" + ); + assert_counter!( + "apollo.router.operations.response_cache.fetch.error", + 1, + "subgraph.name" = "user", + "code" = "NO_STORAGE" + ); + + let service = TestHarness::builder() + .configuration_json( + serde_json::json!({"include_subgraph_errors": { "all": true }, + "experimental_mock_subgraphs": subgraphs.clone(), + }), + ) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + + let response = response.next_response().await.unwrap(); + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + assert_counter!( + "apollo.router.operations.response_cache.fetch.error", + 2, + "subgraph.name" = "orga", + "code" = "NO_STORAGE" + ); + assert_counter!( + "apollo.router.operations.response_cache.fetch.error", + 2, + "subgraph.name" = "user", + "code" = "NO_STORAGE" + ); + } + .with_metrics() + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn expired_data_count() { + async { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + tls: Default::default(), + cleanup_interval: std::time::Duration::from_secs(60 * 7), // Every 7 minutes + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("expired_data_count")), + }) + .await + .unwrap(); + let _response_cache = ResponseCache::for_test( + pg_cache.clone(), + Default::default(), + valid_schema.clone(), + true, + true, + ) + .await + .unwrap(); + let cache_key = uuid::Uuid::new_v4().to_string(); + pg_cache + .insert( + &cache_key, + std::time::Duration::from_millis(2), + vec![], + serde_json_bytes::json!({}), + CacheControl::default(), + "test", + ) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let (_drop_rx, drop_tx) = broadcast::channel(2); + tokio::spawn( + metrics::expired_data_task(pg_cache.clone(), drop_tx, None) + .with_current_meter_provider(), + ); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert_gauge!("apollo.router.response_cache.data.expired", 1); + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn failure_mode_reconnect() { + async { + let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap()); + let query = + "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }"; + + let subgraphs = serde_json::json!({ + "user": { + "query": { + "currentUser": { + "activeOrganization": { + "__typename": "Organization", + "id": "1", + } + } + }, + "headers": {"cache-control": "public"}, + }, + "orga": { + "entities": [ + { + "__typename": "Organization", + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + ], + "headers": {"cache-control": "public"}, + }, + }); + + let map = [ + ( + "user".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ( + "orga".to_string(), + Subgraph { + postgres: None, + private_id: Some("sub".to_string()), + enabled: true.into(), + ttl: None, + ..Default::default() + }, + ), + ] + .into_iter() + .collect(); + let pg_cache = PostgresCacheStorage::new(&PostgresCacheConfig { + tls: Default::default(), + cleanup_interval: std::time::Duration::from_secs(60 * 7), // Every 7 minutes + url: "postgres://127.0.0.1".parse().unwrap(), + username: None, + password: None, + idle_timeout: std::time::Duration::from_secs(5), + acquire_timeout: std::time::Duration::from_millis(50), + required_to_start: true, + pool_size: default_pool_size(), + batch_size: default_batch_size(), + namespace: Some(String::from("failure_mode_reconnect")), + }) + .await + .unwrap(); + pg_cache.migrate().await.unwrap(); + pg_cache.truncate_namespace().await.unwrap(); + + let response_cache = + ResponseCache::without_storage_for_failure_mode(map, valid_schema.clone()) + .await + .unwrap(); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({ + "include_subgraph_errors": { "all": true }, + "experimental_mock_subgraphs": subgraphs.clone(), + })) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let response = response.next_response().await.unwrap(); + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + assert_counter!( + "apollo.router.operations.response_cache.fetch.error", + 1, + "subgraph.name" = "orga", + "code" = "NO_STORAGE" + ); + assert_counter!( + "apollo.router.operations.response_cache.fetch.error", + 1, + "subgraph.name" = "user", + "code" = "NO_STORAGE" + ); + + + let service = TestHarness::builder() + .configuration_json( + serde_json::json!({"include_subgraph_errors": { "all": true }, + "experimental_mock_subgraphs": subgraphs.clone(), + }), + ) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + response_cache + .storage + .all + .as_ref() + .expect("the database all should already be Some") + .set(pg_cache) + .map_err(|_| "this should not be already set") + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::with_settings!({ + description => "Make sure everything is in status 'new' and we have all the entities and root fields" + }, { + insta::assert_json_snapshot!(cache_keys); + }); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + assert_counter!( + "apollo.router.operations.response_cache.fetch.error", + 1, + "subgraph.name" = "orga", + "code" = "NO_STORAGE" + ); + assert_counter!( + "apollo.router.operations.response_cache.fetch.error", + 1, + "subgraph.name" = "user", + "code" = "NO_STORAGE" + ); + + let service = TestHarness::builder() + .configuration_json( + serde_json::json!({"include_subgraph_errors": { "all": true }, + "experimental_mock_subgraphs": subgraphs.clone(), + }), + ) + .unwrap() + .schema(SCHEMA) + .extra_private_plugin(response_cache.clone()) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ) + .build() + .unwrap(); + let mut response = service.oneshot(request).await.unwrap(); + let mut cache_keys: CacheKeysContext = response + .context + .get(CONTEXT_DEBUG_CACHE_KEYS) + .unwrap() + .unwrap(); + cache_keys.iter_mut().for_each(|ck| { + ck.invalidation_keys.sort(); + ck.cache_control.created = 0; + }); + cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys)); + insta::with_settings!({ + description => "Make sure everything is in status 'cached' and we have all the entities and root fields" + }, { + insta::assert_json_snapshot!(cache_keys); + }); + + let mut response = response.next_response().await.unwrap(); + assert!( + response + .extensions + .remove(CACHE_DEBUG_EXTENSIONS_KEY) + .is_some() + ); + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } + } + "#); + + assert_counter!( + "apollo.router.operations.response_cache.fetch.error", + 1, + "subgraph.name" = "orga", + "code" = "NO_STORAGE" + ); + assert_counter!( + "apollo.router.operations.response_cache.fetch.error", + 1, + "subgraph.name" = "user", + "code" = "NO_STORAGE" + ); + } + .with_metrics() + .await; +} diff --git a/apollo-router/src/plugins/rhai/engine.rs b/apollo-router/src/plugins/rhai/engine.rs index fde2fa8e7d..6433a331e5 100644 --- a/apollo-router/src/plugins/rhai/engine.rs +++ b/apollo-router/src/plugins/rhai/engine.rs @@ -1,27 +1,25 @@ use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; -use std::sync::Mutex; use std::time::SystemTime; +use base64::Engine as _; use base64::prelude::BASE64_STANDARD; use base64::prelude::BASE64_STANDARD_NO_PAD; use base64::prelude::BASE64_URL_SAFE; use base64::prelude::BASE64_URL_SAFE_NO_PAD; -use base64::Engine as _; use bytes::Bytes; -use http::header::InvalidHeaderName; -use http::uri::Authority; -use http::uri::Parts; -use http::uri::PathAndQuery; use http::HeaderMap; use http::Method; use http::StatusCode; use http::Uri; -use rhai::module_resolvers::FileModuleResolver; -use rhai::plugin::*; -use rhai::serde::from_dynamic; -use rhai::serde::to_dynamic; +use http::header::InvalidHeaderName; +use http::uri::Authority; +use http::uri::Parts; +use http::uri::PathAndQuery; +use http::uri::Scheme; +use parking_lot::Mutex; +use rhai::AST; use rhai::Array; use rhai::Dynamic; use rhai::Engine; @@ -30,16 +28,20 @@ use rhai::FnPtr; use rhai::Instant; use rhai::Map; use rhai::Scope; -use rhai::AST; +use rhai::module_resolvers::FileModuleResolver; +use rhai::plugin::*; +use rhai::serde::from_dynamic; +use rhai::serde::to_dynamic; use tower::BoxError; use uuid::Uuid; +use super::Rhai; +use super::ServiceStep; use super::execution; use super::router; use super::subgraph; use super::supergraph; -use super::Rhai; -use super::ServiceStep; +use crate::Context; use crate::configuration::expansion; use crate::graphql::Request; use crate::graphql::Response; @@ -52,7 +54,6 @@ use crate::plugins::demand_control::COST_RESULT_KEY; use crate::plugins::demand_control::COST_STRATEGY_KEY; use crate::plugins::subscription::SUBSCRIPTION_WS_CUSTOM_CONNECTION_PARAMS; use crate::query_planner::APOLLO_OPERATION_ID; -use crate::Context; const CANNOT_ACCESS_HEADERS_ON_A_DEFERRED_RESPONSE: &str = "cannot access headers on a deferred response"; @@ -74,22 +75,22 @@ pub(super) type SharedMut = rhai::Shared>>; impl OptionDance for SharedMut { fn with_mut(&self, f: impl FnOnce(&mut T) -> R) -> R { - let mut guard = self.lock().expect("poisoned mutex"); + let mut guard = self.lock(); f(guard.as_mut().expect("re-entrant option dance")) } fn replace(&self, f: impl FnOnce(T) -> T) { - let mut guard = self.lock().expect("poisoned mutex"); + let mut guard = self.lock(); *guard = Some(f(guard.take().expect("re-entrant option dance"))) } fn take_unwrap(self) -> T { match Arc::try_unwrap(self) { - Ok(mutex) => mutex.into_inner().expect("poisoned mutex"), + Ok(mutex) => mutex.into_inner(), // TODO: Should we assume the Arc refcount is 1 // and use `try_unwrap().expect("shared ownership")` instead of this fallback ? - Err(arc) => arc.lock().expect("poisoned mutex").take(), + Err(arc) => arc.lock().take(), } .expect("re-entrant option dance") } @@ -294,11 +295,7 @@ mod router_header_map { x: &mut HeaderMap, key: &str, ) -> Result> { - Ok(x.remove(key) - .ok_or("")? - .to_str() - .map_err(|e| e.to_string())? - .to_string()) + Ok(String::from_utf8_lossy(x.remove(key).ok_or("")?.as_bytes()).to_string()) } // Register a HeaderMap indexer so we can get/set headers @@ -309,11 +306,7 @@ mod router_header_map { ) -> Result> { let search_name = HeaderName::from_str(key).map_err(|e: InvalidHeaderName| e.to_string())?; - Ok(x.get(search_name) - .ok_or("")? - .to_str() - .map_err(|e| e.to_string())? - .to_string()) + Ok(String::from_utf8_lossy(x.get(search_name).ok_or("")?.as_bytes()).to_string()) } #[rhai_fn(index_set, return_raw)] @@ -362,13 +355,7 @@ mod router_header_map { HeaderName::from_str(key).map_err(|e: InvalidHeaderName| e.to_string())?; let mut response = Array::new(); for value in x.get_all(search_name).iter() { - response.push( - value - .to_str() - .map_err(|e| e.to_string())? - .to_string() - .into(), - ) + response.push(String::from_utf8_lossy(value.as_bytes()).to_string().into()) } Ok(response) } @@ -426,7 +413,7 @@ mod router_context { // Register a contains function for Context so that "in" works #[rhai_fn(name = "contains", pure)] pub(crate) fn context_contains(x: &mut Context, key: &str) -> bool { - x.get(key).map_or(false, |v: Option| v.is_some()) + x.get(key).is_ok_and(|v: Option| v.is_some()) } // Register a Context indexer so we can get/set context @@ -1187,6 +1174,21 @@ mod router_plugin { } } + // Uri.scheme + #[rhai_fn(get = "scheme", pure, return_raw)] + pub(crate) fn uri_scheme_get(x: &mut Uri) -> Result> { + to_dynamic(x.scheme_str()) + } + + #[rhai_fn(set = "scheme", return_raw)] + pub(crate) fn uri_scheme_set(x: &mut Uri, value: &str) -> Result<(), Box> { + let mut parts: Parts = x.clone().into_parts(); + let new_scheme = Scheme::from_str(value).map_err(|e| e.to_string())?; + parts.scheme = Some(new_scheme); + *x = Uri::from_parts(parts).map_err(|e| e.to_string())?; + Ok(()) + } + // Response.label #[rhai_fn(get = "label", pure)] pub(crate) fn response_label_get(x: &mut Response) -> Dynamic { @@ -1721,14 +1723,13 @@ impl Rhai { service: ServiceStep, scope: Arc>>, ) -> Result<(), String> { - let block = self.block.load(); let rhai_service = RhaiService { scope: scope.clone(), service, - engine: block.engine.clone(), - ast: block.ast.clone(), + engine: self.engine.clone(), + ast: self.ast.clone(), }; - let mut guard = scope.lock().unwrap(); + let mut guard = scope.lock(); // Note: We don't use `process_error()` here, because this code executes in the context of // the pipeline processing. We can't return an HTTP error, we can only return a boxed // service which represents the next stage of the pipeline. @@ -1736,20 +1737,20 @@ impl Rhai { // change and one that requires more thought in the future. match subgraph { Some(name) => { - block + let _ = self .engine - .call_fn( + .call_fn::( &mut guard, - &block.ast, + &self.ast, function_name, (rhai_service, name.to_string()), ) .map_err(|err| err.to_string())?; } None => { - block + let _ = self .engine - .call_fn(&mut guard, &block.ast, function_name, (rhai_service,)) + .call_fn::(&mut guard, &self.ast, function_name, (rhai_service,)) .map_err(|err| err.to_string())?; } } @@ -1874,10 +1875,6 @@ impl Rhai { } pub(super) fn ast_has_function(&self, name: &str) -> bool { - self.block - .load() - .ast - .iter_fn_def() - .any(|fn_def| fn_def.name == name) + self.ast.iter_fn_def().any(|fn_def| fn_def.name == name) } } diff --git a/apollo-router/src/plugins/rhai/execution.rs b/apollo-router/src/plugins/rhai/execution.rs index a04b00211d..5af1fabc93 100644 --- a/apollo-router/src/plugins/rhai/execution.rs +++ b/apollo-router/src/plugins/rhai/execution.rs @@ -5,9 +5,9 @@ use std::ops::ControlFlow; use tower::BoxError; use super::ErrorDetails; +use crate::Context; use crate::graphql::Error; pub(crate) use crate::services::execution::*; -use crate::Context; pub(crate) type FirstResponse = super::engine::RhaiExecutionResponse; pub(crate) type DeferredResponse = super::engine::RhaiExecutionDeferredResponse; @@ -28,10 +28,11 @@ pub(super) fn request_failure( .build()? } else { Response::error_builder() - .errors(vec![Error { - message: error_details.message.unwrap_or_default(), - ..Default::default() - }]) + .errors(vec![ + Error::builder() + .message(error_details.message.unwrap_or_default()) + .build(), + ]) .context(context) .status_code(error_details.status) .build()? @@ -53,10 +54,11 @@ pub(super) fn response_failure(context: Context, error_details: ErrorDetails) -> .build() } else { Response::error_builder() - .errors(vec![Error { - message: error_details.message.unwrap_or_default(), - ..Default::default() - }]) + .errors(vec![ + Error::builder() + .message(error_details.message.unwrap_or_default()) + .build(), + ]) .status_code(error_details.status) .context(context) .build() diff --git a/apollo-router/src/plugins/rhai/mod.rs b/apollo-router/src/plugins/rhai/mod.rs index 8ef1e61e8f..d18255db99 100644 --- a/apollo-router/src/plugins/rhai/mod.rs +++ b/apollo-router/src/plugins/rhai/mod.rs @@ -3,25 +3,14 @@ use std::fmt; use std::ops::ControlFlow; use std::path::PathBuf; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; use std::sync::Arc; -use std::sync::Mutex; -use std::time::Duration; -use arc_swap::ArcSwap; +use futures::StreamExt; use futures::future::ready; use futures::stream::once; -use futures::StreamExt; use http::StatusCode; -use notify::event::DataChange; -use notify::event::MetadataKind; -use notify::event::ModifyKind; -use notify::Config; -use notify::EventKind; -use notify::PollWatcher; -use notify::RecursiveMode; -use notify::Watcher; +use parking_lot::Mutex; +use rhai::AST; use rhai::Dynamic; use rhai::Engine; use rhai::EvalAltResult; @@ -30,13 +19,12 @@ use rhai::FuncArgs; use rhai::Instant; use rhai::Scope; use rhai::Shared; -use rhai::AST; use schemars::JsonSchema; use serde::Deserialize; -use tower::util::BoxService; use tower::BoxError; use tower::ServiceBuilder; use tower::ServiceExt; +use tower::util::BoxService; use self::engine::RhaiService; use self::engine::SharedMut; @@ -56,57 +44,13 @@ mod router; mod subgraph; mod supergraph; -struct EngineBlock { +/// Plugin which implements Rhai functionality +struct Rhai { ast: AST, engine: Arc, scope: Arc>>, } -impl EngineBlock { - fn try_new( - scripts: Option, - main: PathBuf, - sdl: Arc, - ) -> Result { - let engine = Arc::new(Rhai::new_rhai_engine( - scripts, - sdl.to_string(), - main.clone(), - )); - let ast = engine - .compile_file(main.clone()) - .map_err(|err| format!("in Rhai script {}: {}", main.display(), err))?; - let mut scope = Scope::new(); - // Keep these two lower cases ones as mistakes until 2.0 - // At 2.0 (or maybe before), replace with upper case - // Note: Any constants that we add to scope here, *must* be catered for in the on_var - // functionality in `new_rhai_engine`. - scope.push_constant("apollo_sdl", sdl.to_string()); - scope.push_constant("apollo_start", Instant::now()); - - // Run the AST with our scope to put any global variables - // defined in scripts into scope. - engine.run_ast_with_scope(&mut scope, &ast)?; - - Ok(EngineBlock { - ast, - engine, - scope: Arc::new(Mutex::new(scope)), - }) - } -} - -/// Plugin which implements Rhai functionality -/// Note: We use ArcSwap here in preference to a shared RwLock. Updates to -/// the engine block will be infrequent in relation to the accesses of it. -/// We'd love to use AtomicArc if such a thing existed, but since it doesn't -/// we'll use ArcSwap to accomplish our goal. -struct Rhai { - block: Arc>, - park_flag: Arc, - watcher_handle: Option>, -} - /// Configuration for the Rhai Plugin #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -135,90 +79,30 @@ impl Plugin for Rhai { let main = scripts_path.join(main_file); - let watched_path = scripts_path.clone(); - let watched_main = main.clone(); - let watched_sdl = sdl.clone(); - - let block = Arc::new(ArcSwap::from_pointee(EngineBlock::try_new( + let engine = Arc::new(Rhai::new_rhai_engine( Some(scripts_path), - main, - sdl, - )?)); - let watched_block = block.clone(); - - let park_flag = Arc::new(AtomicBool::new(false)); - let watching_flag = park_flag.clone(); - - let watcher_handle = std::thread::spawn(move || { - let watching_path = watched_path.clone(); - let config = Config::default() - .with_poll_interval(Duration::from_secs(3)) - .with_compare_contents(true); - let mut watcher = PollWatcher::new( - move |res: Result| { - match res { - Ok(event) => { - // Let's limit the events we are interested in to: - // - Modified files - // - Created/Remove files - // - with suffix "rhai" - if matches!( - event.kind, - EventKind::Modify(ModifyKind::Metadata(MetadataKind::WriteTime)) - | EventKind::Modify(ModifyKind::Data(DataChange::Any)) - | EventKind::Create(_) - | EventKind::Remove(_) - ) { - let mut proceed = false; - for path in event.paths { - if path.extension().map_or(false, |ext| ext == "rhai") { - proceed = true; - break; - } - } + sdl.to_string(), + main.clone(), + )); + let ast = engine + .compile_file(main.clone()) + .map_err(|err| format!("in Rhai script {}: {}", main.display(), err))?; + let mut scope = Scope::new(); + // Keep these two lower cases ones as mistakes until 2.0 + // At 2.0 (or maybe before), replace with upper case + // Note: Any constants that we add to scope here, *must* be catered for in the on_var + // functionality in `new_rhai_engine`. + scope.push_constant("apollo_sdl", sdl.to_string()); + scope.push_constant("apollo_start", Instant::now()); - if proceed { - match EngineBlock::try_new( - Some(watching_path.clone()), - watched_main.clone(), - watched_sdl.clone(), - ) { - Ok(eb) => { - tracing::info!("updating rhai execution engine"); - watched_block.store(Arc::new(eb)) - } - Err(e) => { - tracing::warn!( - "could not create new rhai execution engine: {}", - e - ); - } - } - } - } - } - Err(e) => tracing::error!("rhai watching event error: {:?}", e), - } - }, - config, - ) - .unwrap_or_else(|_| panic!("could not create watch on: {watched_path:?}")); - watcher - .watch(&watched_path, RecursiveMode::Recursive) - .unwrap_or_else(|_| panic!("could not watch: {watched_path:?}")); - // Park the thread until this Rhai instance is dropped (see Drop impl) - // We may actually unpark() before this code executes or exit from park() spuriously. - // Use the watching_flag to control a loop which waits from the flag to be updated - // from Drop. - while !watching_flag.load(Ordering::Acquire) { - std::thread::park(); - } - }); + // Run the AST with our scope to put any global variables + // defined in scripts into scope. + engine.run_ast_with_scope(&mut scope, &ast)?; Ok(Self { - block, - park_flag, - watcher_handle: Some(watcher_handle), + ast, + engine, + scope: Arc::new(Mutex::new(scope)), }) } @@ -233,9 +117,12 @@ impl Plugin for Rhai { FUNCTION_NAME_SERVICE, None, ServiceStep::Router(shared_service.clone()), - self.block.load().scope.clone(), + self.scope.clone(), ) { - tracing::error!("service callback failed: {error}"); + tracing::error!( + service = "RouterService", + "service callback failed: {error}" + ); } shared_service.take_unwrap() } @@ -251,9 +138,12 @@ impl Plugin for Rhai { FUNCTION_NAME_SERVICE, None, ServiceStep::Supergraph(shared_service.clone()), - self.block.load().scope.clone(), + self.scope.clone(), ) { - tracing::error!("service callback failed: {error}"); + tracing::error!( + service = "SupergraphService", + "service callback failed: {error}" + ); } shared_service.take_unwrap() } @@ -269,9 +159,12 @@ impl Plugin for Rhai { FUNCTION_NAME_SERVICE, None, ServiceStep::Execution(shared_service.clone()), - self.block.load().scope.clone(), + self.scope.clone(), ) { - tracing::error!("service callback failed: {error}"); + tracing::error!( + service = "ExecutionService", + "service callback failed: {error}" + ); } shared_service.take_unwrap() } @@ -287,24 +180,18 @@ impl Plugin for Rhai { FUNCTION_NAME_SERVICE, Some(name), ServiceStep::Subgraph(shared_service.clone()), - self.block.load().scope.clone(), + self.scope.clone(), ) { - tracing::error!("service callback failed: {error}"); + tracing::error!( + service = "SubgraphService", + subgraph = name, + "service callback failed: {error}" + ); } shared_service.take_unwrap() } } -impl Drop for Rhai { - fn drop(&mut self) { - if let Some(wh) = self.watcher_handle.take() { - self.park_flag.store(true, Ordering::Release); - wh.thread().unpark(); - wh.join().expect("rhai file watcher thread terminating"); - } - } -} - #[derive(Clone, Debug)] pub(crate) enum ServiceStep { Router(SharedMut), @@ -335,11 +222,11 @@ macro_rules! gen_map_request { if let Err(error) = result { let error_details = process_error(error); tracing::error!("map_request callback failed: {error_details:#?}"); - let mut guard = shared_request.lock().unwrap(); + let mut guard = shared_request.lock(); let request_opt = guard.take(); return $base::request_failure(request_opt.unwrap().context, error_details); } - let mut guard = shared_request.lock().unwrap(); + let mut guard = shared_request.lock(); let request_opt = guard.take(); Ok(ControlFlow::Continue(request_opt.unwrap())) }) @@ -383,12 +270,12 @@ macro_rules! gen_map_router_deferred_request { if let Err(error) = result { tracing::error!("map_request callback failed: {error}"); let error_details = process_error(error); - let mut guard = shared_request.lock().unwrap(); + let mut guard = shared_request.lock(); let request_opt = guard.take(); return $base::request_failure(request_opt.unwrap().context, error_details); } - let request_opt = shared_request.lock().unwrap().take(); + let request_opt = shared_request.lock().take(); let $base::FirstRequest { context, request } = request_opt.unwrap(); @@ -438,7 +325,7 @@ macro_rules! gen_map_router_deferred_request { return Ok(serde_json::to_vec(&error_response)?.into()); } - let request_opt = shared_request.lock().unwrap().take(); + let request_opt = shared_request.lock().take(); let $base::ChunkedRequest { request, .. } = request_opt.unwrap(); Ok(request) @@ -470,14 +357,14 @@ macro_rules! gen_map_response { if let Err(error) = result { tracing::error!("map_response callback failed: {error}"); let error_details = process_error(error); - let mut guard = shared_response.lock().unwrap(); + let mut guard = shared_response.lock(); let response_opt = guard.take(); return $base::response_failure( response_opt.unwrap().context, error_details, ); } - let mut guard = shared_response.lock().unwrap(); + let mut guard = shared_response.lock(); let response_opt = guard.take(); response_opt.unwrap() }) @@ -516,14 +403,14 @@ macro_rules! gen_map_router_deferred_response { if let Err(error) = result { tracing::error!("map_response callback failed: {error}"); let error_details = process_error(error); - let response_opt = shared_response.lock().unwrap().take(); + let response_opt = shared_response.lock().take(); return Ok($base::response_failure( response_opt.unwrap().context, error_details )); } - let response_opt = shared_response.lock().unwrap().take(); + let response_opt = shared_response.lock().take(); let $base::FirstResponse { context, response } = response_opt.unwrap(); @@ -572,7 +459,7 @@ macro_rules! gen_map_router_deferred_response { return Ok(serde_json::to_vec(&error_response)?.into()); } - let response_opt = shared_response.lock().unwrap().take(); + let response_opt = shared_response.lock().take(); let $base::DeferredResponse { response, .. } = response_opt.unwrap(); Ok(response) @@ -603,7 +490,7 @@ macro_rules! gen_map_deferred_response { // for which we will implement mapping later let $base::Response { response, context } = mapped_response; let (parts, stream) = response.into_parts(); - let (first, rest) = stream.into_future().await; + let (first, rest) = StreamExt::into_future(stream).await; if first.is_none() { let error_details = ErrorDetails { @@ -633,7 +520,7 @@ macro_rules! gen_map_deferred_response { if let Err(error) = result { tracing::error!("map_response callback failed: {error}"); let error_details = process_error(error); - let mut guard = shared_response.lock().unwrap(); + let mut guard = shared_response.lock(); let response_opt = guard.take(); return Ok($base::response_failure( response_opt.unwrap().context, @@ -641,7 +528,7 @@ macro_rules! gen_map_deferred_response { )); } - let mut guard = shared_response.lock().unwrap(); + let mut guard = shared_response.lock(); let response_opt = guard.take(); let $base::FirstResponse { context, response } = response_opt.unwrap(); @@ -668,18 +555,17 @@ macro_rules! gen_map_deferred_response { if let Err(error) = result { tracing::error!("map_response callback failed: {error}"); let error_details = process_error(error); - let mut guard = shared_response.lock().unwrap(); + let mut guard = shared_response.lock(); let response_opt = guard.take(); let $base::DeferredResponse { mut response, .. } = response_opt.unwrap(); - let error = Error { - message: error_details.message.unwrap_or_default(), - ..Default::default() - }; + let error = Error::builder() + .message(error_details.message.unwrap_or_default()) + .build(); response.errors = vec![error]; return Some(response); } - let mut guard = shared_response.lock().unwrap(); + let mut guard = shared_response.lock(); let response_opt = guard.take(); let $base::DeferredResponse { response, .. } = response_opt.unwrap(); @@ -746,15 +632,10 @@ struct Position { impl fmt::Display for Position { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.line.is_none() || self.pos.is_none() { - write!(f, "none") + if let Some((line, pos)) = self.line.zip(self.pos) { + write!(f, "line {line}, position {pos}") } else { - write!( - f, - "line {}, position {}", - self.line.expect("checked above;qed"), - self.pos.expect("checked above;qed") - ) + write!(f, "none") } } } @@ -815,7 +696,7 @@ fn execute( if callback.is_curried() { callback.call(&rhai_service.engine, &rhai_service.ast, args) } else { - let mut guard = rhai_service.scope.lock().unwrap(); + let mut guard = rhai_service.scope.lock(); rhai_service .engine .call_fn(&mut guard, &rhai_service.ast, callback.fn_name(), args) diff --git a/apollo-router/src/plugins/rhai/router.rs b/apollo-router/src/plugins/rhai/router.rs index 2a6313daf2..06d9faa911 100644 --- a/apollo-router/src/plugins/rhai/router.rs +++ b/apollo-router/src/plugins/rhai/router.rs @@ -5,9 +5,9 @@ use std::ops::ControlFlow; use tower::BoxError; use super::ErrorDetails; +use crate::Context; use crate::graphql::Error; pub(crate) use crate::services::router::*; -use crate::Context; pub(crate) type FirstRequest = super::engine::RhaiRouterFirstRequest; pub(crate) type ChunkedRequest = super::engine::RhaiRouterChunkedRequest; @@ -30,10 +30,11 @@ pub(super) fn request_failure( .build()? } else { crate::services::router::Response::error_builder() - .errors(vec![Error { - message: error_details.message.unwrap_or_default(), - ..Default::default() - }]) + .errors(vec![ + Error::builder() + .message(error_details.message.unwrap_or_default()) + .build(), + ]) .context(context) .status_code(error_details.status) .build()? @@ -58,10 +59,11 @@ pub(super) fn response_failure( .build() } else { crate::services::router::Response::error_builder() - .errors(vec![Error { - message: error_details.message.unwrap_or_default(), - ..Default::default() - }]) + .errors(vec![ + Error::builder() + .message(error_details.message.unwrap_or_default()) + .build(), + ]) .status_code(error_details.status) .context(context) .build() diff --git a/apollo-router/src/plugins/rhai/snapshots/apollo_router__plugins__rhai__tests__it_prints_messages_to_log@logs.snap b/apollo-router/src/plugins/rhai/snapshots/apollo_router__plugins__rhai__tests__it_prints_messages_to_log@logs.snap new file mode 100644 index 0000000000..00a34b7033 --- /dev/null +++ b/apollo-router/src/plugins/rhai/snapshots/apollo_router__plugins__rhai__tests__it_prints_messages_to_log@logs.snap @@ -0,0 +1,8 @@ +--- +source: apollo-router/src/plugins/rhai/tests.rs +expression: yaml +--- +- fields: + target: "" + level: INFO + message: info log diff --git a/apollo-router/src/plugins/rhai/snapshots/apollo_router__plugins__rhai__tests__rhai_plugin_execution_service_error@logs.snap b/apollo-router/src/plugins/rhai/snapshots/apollo_router__plugins__rhai__tests__rhai_plugin_execution_service_error@logs.snap new file mode 100644 index 0000000000..4ff6d144b1 --- /dev/null +++ b/apollo-router/src/plugins/rhai/snapshots/apollo_router__plugins__rhai__tests__rhai_plugin_execution_service_error@logs.snap @@ -0,0 +1,15 @@ +--- +source: apollo-router/src/plugins/rhai/tests.rs +expression: yaml +--- +- fields: {} + level: ERROR + message: "[message]" + span: + name: rhai_plugin + otel.kind: INTERNAL + rhai service: "execution :: Request" + spans: + - name: rhai_plugin + otel.kind: INTERNAL + rhai service: "execution :: Request" diff --git a/apollo-router/src/plugins/rhai/snapshots/apollo_router__plugins__rhai__tests__rhai_plugin_supergraph_service@logs.snap b/apollo-router/src/plugins/rhai/snapshots/apollo_router__plugins__rhai__tests__rhai_plugin_supergraph_service@logs.snap new file mode 100644 index 0000000000..8e85242247 --- /dev/null +++ b/apollo-router/src/plugins/rhai/snapshots/apollo_router__plugins__rhai__tests__rhai_plugin_supergraph_service@logs.snap @@ -0,0 +1,5 @@ +--- +source: apollo-router/src/plugins/rhai/tests.rs +expression: yaml +--- +[] diff --git a/apollo-router/src/plugins/rhai/subgraph.rs b/apollo-router/src/plugins/rhai/subgraph.rs index 110dce38d5..17ecc41405 100644 --- a/apollo-router/src/plugins/rhai/subgraph.rs +++ b/apollo-router/src/plugins/rhai/subgraph.rs @@ -5,9 +5,9 @@ use std::ops::ControlFlow; use tower::BoxError; use super::ErrorDetails; +use crate::Context; use crate::graphql::Error; pub(crate) use crate::services::subgraph::*; -use crate::Context; pub(super) fn request_failure( context: Context, @@ -22,16 +22,19 @@ pub(super) fn request_failure( .and_data(body.data) .and_label(body.label) .and_path(body.path) + .subgraph_name(String::default()) // XXX: We don't know the subgraph name .build() } else { Response::error_builder() - .errors(vec![Error { - message: error_details.message.unwrap_or_default(), - ..Default::default() - }]) + .errors(vec![ + Error::builder() + .message(error_details.message.unwrap_or_default()) + .build(), + ]) .context(context) .status_code(error_details.status) - .build()? + .subgraph_name(String::default()) // XXX: We don't know the subgraph name + .build() }; Ok(ControlFlow::Break(res)) @@ -47,16 +50,18 @@ pub(super) fn response_failure(context: Context, error_details: ErrorDetails) -> .and_data(body.data) .and_label(body.label) .and_path(body.path) + .subgraph_name(String::default()) // XXX: We don't know the subgraph name .build() } else { Response::error_builder() - .errors(vec![Error { - message: error_details.message.unwrap_or_default(), - ..Default::default() - }]) + .errors(vec![ + Error::builder() + .message(error_details.message.unwrap_or_default()) + .build(), + ]) .status_code(error_details.status) .context(context) + .subgraph_name(String::default()) // XXX: We don't know the subgraph name .build() - .expect("can't fail to build our error message") } } diff --git a/apollo-router/src/plugins/rhai/supergraph.rs b/apollo-router/src/plugins/rhai/supergraph.rs index 9f2905ab62..4b7ac475dc 100644 --- a/apollo-router/src/plugins/rhai/supergraph.rs +++ b/apollo-router/src/plugins/rhai/supergraph.rs @@ -5,9 +5,9 @@ use std::ops::ControlFlow; use tower::BoxError; use super::ErrorDetails; +use crate::Context; use crate::graphql::Error; pub(crate) use crate::services::supergraph::*; -use crate::Context; pub(crate) type FirstResponse = super::engine::RhaiSupergraphResponse; pub(crate) type DeferredResponse = super::engine::RhaiSupergraphDeferredResponse; @@ -28,10 +28,11 @@ pub(super) fn request_failure( .build()? } else { Response::error_builder() - .errors(vec![Error { - message: error_details.message.unwrap_or_default(), - ..Default::default() - }]) + .errors(vec![ + Error::builder() + .message(error_details.message.unwrap_or_default()) + .build(), + ]) .context(context) .status_code(error_details.status) .build()? @@ -53,10 +54,11 @@ pub(super) fn response_failure(context: Context, error_details: ErrorDetails) -> .build() } else { Response::error_builder() - .errors(vec![Error { - message: error_details.message.unwrap_or_default(), - ..Default::default() - }]) + .errors(vec![ + Error::builder() + .message(error_details.message.unwrap_or_default()) + .build(), + ]) .status_code(error_details.status) .context(context) .build() diff --git a/apollo-router/src/plugins/rhai/tests.rs b/apollo-router/src/plugins/rhai/tests.rs index dd3d4080f6..5c911f257f 100644 --- a/apollo-router/src/plugins/rhai/tests.rs +++ b/apollo-router/src/plugins/rhai/tests.rs @@ -2,34 +2,38 @@ use std::str::FromStr; use std::sync::Arc; -use std::sync::Mutex; use std::time::SystemTime; use http::HeaderMap; use http::HeaderValue; use http::Method; use http::StatusCode; +use parking_lot::Mutex; use rhai::Engine; use rhai::EvalAltResult; use serde_json::Value; use sha2::Digest; -use tower::util::BoxService; use tower::BoxError; use tower::Service; use tower::ServiceExt; +use tower::util::BoxService; +use tracing_futures::WithSubscriber; use uuid::Uuid; -use super::process_error; -use super::subgraph; use super::PathBuf; use super::Rhai; +use super::process_error; +use super::subgraph; +use crate::Context; +use crate::assert_response_eq_ignoring_error_id; +use crate::assert_snapshot_subscriber; use crate::graphql; use crate::graphql::Error; use crate::graphql::Request; use crate::http_ext; +use crate::plugin::DynPlugin; use crate::plugin::test::MockExecutionService; use crate::plugin::test::MockSupergraphService; -use crate::plugin::DynPlugin; use crate::plugins::rhai::engine::RhaiExecutionDeferredResponse; use crate::plugins::rhai::engine::RhaiExecutionResponse; use crate::plugins::rhai::engine::RhaiRouterChunkedResponse; @@ -41,7 +45,7 @@ use crate::services::ExecutionRequest; use crate::services::SubgraphRequest; use crate::services::SupergraphRequest; use crate::services::SupergraphResponse; -use crate::Context; +use crate::test_harness::tracing_test; // There is a lot of repetition in these tests, so I've tried to reduce that with these two // functions. The repetition could probably be reduced further, but ... @@ -62,21 +66,19 @@ async fn call_rhai_function(fn_name: &str) -> Result<(), Box().expect("downcast"); - let block = rhai_instance.block.load(); - // Get a scope to use for our test - let scope = block.scope.clone(); + let scope = rhai_instance.scope.clone(); - let mut guard = scope.lock().unwrap(); + let mut guard = scope.lock(); // We must wrap our canned response in Arc>> to keep the rhai runtime // happy let response = Arc::new(Mutex::new(Some(subgraph::Response::fake_builder().build()))); // Call our rhai test function. If it doesn't return an error, the test failed. - block + rhai_instance .engine - .call_fn(&mut guard, &block.ast, fn_name, (response,)) + .call_fn(&mut guard, &rhai_instance.ast, fn_name, (response,)) } async fn call_rhai_function_with_arg( @@ -99,136 +101,142 @@ async fn call_rhai_function_with_arg( let it: &dyn std::any::Any = dyn_plugin.as_any(); let rhai_instance: &Rhai = it.downcast_ref::().expect("downcast"); - let block = rhai_instance.block.load(); - // Get a scope to use for our test - let scope = block.scope.clone(); + let scope = rhai_instance.scope.clone(); - let mut guard = scope.lock().unwrap(); + let mut guard = scope.lock(); // We must wrap our canned request in Arc>> to keep the rhai runtime // happy let wrapped_arg = Arc::new(Mutex::new(Some(arg))); - block + rhai_instance .engine - .call_fn(&mut guard, &block.ast, fn_name, (wrapped_arg,)) + .call_fn(&mut guard, &rhai_instance.ast, fn_name, (wrapped_arg,)) } #[tokio::test] async fn rhai_plugin_supergraph_service() -> Result<(), BoxError> { - let mut mock_service = MockSupergraphService::new(); - mock_service - .expect_call() - .times(1) - .returning(move |req: SupergraphRequest| { - Ok(SupergraphResponse::fake_builder() - .header("x-custom-header", "CUSTOM_VALUE") - .context(req.context) - .build() - .unwrap()) - }); - - let dyn_plugin: Box = crate::plugin::plugins() - .find(|factory| factory.name == "apollo.rhai") - .expect("Plugin not found") - .create_instance_without_schema( - &Value::from_str(r#"{"scripts":"tests/fixtures", "main":"test.rhai"}"#).unwrap(), - ) - .await - .unwrap(); - let mut router_service = dyn_plugin.supergraph_service(BoxService::new(mock_service)); - let context = Context::new(); - context.insert("test", 5i64).unwrap(); - let supergraph_req = SupergraphRequest::fake_builder().context(context).build()?; - - let mut supergraph_resp = router_service.ready().await?.call(supergraph_req).await?; - assert_eq!(supergraph_resp.response.status(), 200); - let headers = supergraph_resp.response.headers().clone(); - let context = supergraph_resp.context.clone(); - // Check if it fails - let resp = supergraph_resp.next_response().await.unwrap(); - if !resp.errors.is_empty() { - panic!( - "Contains errors : {}", - resp.errors - .into_iter() - .map(|err| err.to_string()) - .collect::>() - .join("\n") + async { + let mut mock_service = MockSupergraphService::new(); + mock_service + .expect_call() + .times(1) + .returning(move |req: SupergraphRequest| { + Ok(SupergraphResponse::fake_builder() + .header("x-custom-header", "CUSTOM_VALUE") + .context(req.context) + .build() + .unwrap()) + }); + + let dyn_plugin: Box = crate::plugin::plugins() + .find(|factory| factory.name == "apollo.rhai") + .expect("Plugin not found") + .create_instance_without_schema( + &Value::from_str(r#"{"scripts":"tests/fixtures", "main":"test.rhai"}"#).unwrap(), + ) + .await + .unwrap(); + let mut router_service = dyn_plugin.supergraph_service(BoxService::new(mock_service)); + let context = Context::new(); + context.insert("test", 5i64).unwrap(); + let supergraph_req = SupergraphRequest::fake_builder().context(context).build()?; + + let mut supergraph_resp = router_service.ready().await?.call(supergraph_req).await?; + assert_eq!(supergraph_resp.response.status(), 200); + let headers = supergraph_resp.response.headers().clone(); + let context = supergraph_resp.context.clone(); + // Check if it fails + let resp = supergraph_resp.next_response().await.unwrap(); + if !resp.errors.is_empty() { + panic!( + "Contains errors : {}", + resp.errors + .into_iter() + .map(|err| err.to_string()) + .collect::>() + .join("\n") + ); + } + + assert_eq!(headers.get("coucou").unwrap(), &"hello"); + assert_eq!(headers.get("coming_from_entries").unwrap(), &"value_15"); + assert_eq!(context.get::<_, i64>("test").unwrap().unwrap(), 42i64); + assert_eq!( + context.get::<_, String>("addition").unwrap().unwrap(), + "Here is a new element in the context".to_string() ); + Ok(()) } - - assert_eq!(headers.get("coucou").unwrap(), &"hello"); - assert_eq!(headers.get("coming_from_entries").unwrap(), &"value_15"); - assert_eq!(context.get::<_, i64>("test").unwrap().unwrap(), 42i64); - assert_eq!( - context.get::<_, String>("addition").unwrap().unwrap(), - "Here is a new element in the context".to_string() - ); - Ok(()) + .with_subscriber(assert_snapshot_subscriber!()) + .await } #[tokio::test] async fn rhai_plugin_execution_service_error() -> Result<(), BoxError> { - let mut mock_service = MockExecutionService::new(); - mock_service.expect_clone().return_once(move || { + async { let mut mock_service = MockExecutionService::new(); - // The execution_service in test.rhai throws an exception, so we never - // get a call into the mock service... - mock_service.expect_call().never(); - mock_service - }); + mock_service.expect_clone().return_once(move || { + let mut mock_service = MockExecutionService::new(); + // The execution_service in test.rhai throws an exception, so we never + // get a call into the mock service... + mock_service.expect_call().never(); + mock_service + }); - let dyn_plugin: Box = crate::plugin::plugins() - .find(|factory| factory.name == "apollo.rhai") - .expect("Plugin not found") - .create_instance_without_schema( - &Value::from_str(r#"{"scripts":"tests/fixtures", "main":"test.rhai"}"#).unwrap(), - ) - .await - .unwrap(); - let mut router_service = dyn_plugin.execution_service(BoxService::new(mock_service)); - let fake_req = http_ext::Request::fake_builder() - .header("x-custom-header", "CUSTOM_VALUE") - .body(Request::builder().query(String::new()).build()) - .build()?; - let context = Context::new(); - context.insert("test", 5i64).unwrap(); - let exec_req = ExecutionRequest::fake_builder() - .context(context) - .supergraph_request(fake_req) - .build(); + let dyn_plugin: Box = crate::plugin::plugins() + .find(|factory| factory.name == "apollo.rhai") + .expect("Plugin not found") + .create_instance_without_schema( + &Value::from_str(r#"{"scripts":"tests/fixtures", "main":"test.rhai"}"#).unwrap(), + ) + .await + .unwrap(); + let mut router_service = dyn_plugin.execution_service(BoxService::new(mock_service)); + let fake_req = http_ext::Request::fake_builder() + .header("x-custom-header", "CUSTOM_VALUE") + .body(Request::builder().query(String::new()).build()) + .build()?; + let context = Context::new(); + context.insert("test", 5i64).unwrap(); + let exec_req = ExecutionRequest::fake_builder() + .context(context) + .supergraph_request(fake_req) + .build(); + + let mut exec_resp = router_service + .ready() + .await + .unwrap() + .call(exec_req) + .await + .unwrap(); + assert_eq!( + exec_resp.response.status(), + http::StatusCode::INTERNAL_SERVER_ERROR + ); + // Check if it fails + let body = exec_resp.next_response().await.unwrap(); + if body.errors.is_empty() { + panic!( + "Must contain errors : {}", + body.errors + .into_iter() + .map(|err| err.to_string()) + .collect::>() + .join("\n") + ); + } - let mut exec_resp = router_service - .ready() - .await - .unwrap() - .call(exec_req) - .await - .unwrap(); - assert_eq!( - exec_resp.response.status(), - http::StatusCode::INTERNAL_SERVER_ERROR - ); - // Check if it fails - let body = exec_resp.next_response().await.unwrap(); - if body.errors.is_empty() { - panic!( - "Must contain errors : {}", - body.errors - .into_iter() - .map(|err| err.to_string()) - .collect::>() - .join("\n") + assert_eq!( + body.errors.first().unwrap().message.as_str(), + "rhai execution error: 'Runtime error: An error occured (line 30, position 5)'" ); + Ok(()) } - - assert_eq!( - body.errors.first().unwrap().message.as_str(), - "rhai execution error: 'Runtime error: An error occured (line 30, position 5)'" - ); - Ok(()) + .with_subscriber(assert_snapshot_subscriber!({r#"[].message"# => "[message]"})) + .await } // A Rhai engine suitable for minimal testing. There are no scripts and the SDL is an empty @@ -237,19 +245,10 @@ fn new_rhai_test_engine() -> Engine { Rhai::new_rhai_engine(None, "".to_string(), PathBuf::new()) } -// Some of these tests rely extensively on internal implementation details of the tracing_test crate. -// These are unstable, so these test may break if the tracing_test crate is updated. -// -// This is done to avoid using the public interface of tracing_test which installs a global -// subscriber which breaks other tests in our stack which also insert a global subscriber. -// (there can be only one...) which means we cannot test it with #[tokio::test(flavor = "multi_thread")] #[test] fn it_logs_messages() { - let env_filter = "apollo_router=trace"; - let mock_writer = tracing_test::internal::MockWriter::new(tracing_test::internal::global_buf()); - let subscriber = tracing_test::internal::get_subscriber(mock_writer, env_filter); + let _guard = tracing_test::dispatcher_guard(); - let _guard = tracing::dispatcher::set_default(&subscriber); let engine = new_rhai_test_engine(); let input_logs = vec![ r#"log_trace("trace log")"#, @@ -261,43 +260,26 @@ fn it_logs_messages() { for log in input_logs { engine.eval::<()>(log).expect("it logged a message"); } - assert!(tracing_test::internal::logs_with_scope_contain( - "apollo_router", - "trace log" - )); - assert!(tracing_test::internal::logs_with_scope_contain( - "apollo_router", - "debug log" - )); - assert!(tracing_test::internal::logs_with_scope_contain( - "apollo_router", - "info log" - )); - assert!(tracing_test::internal::logs_with_scope_contain( - "apollo_router", - "warn log" - )); - assert!(tracing_test::internal::logs_with_scope_contain( - "apollo_router", - "error log" - )); + + assert!(tracing_test::logs_contain("trace log")); + assert!(tracing_test::logs_contain("debug log")); + assert!(tracing_test::logs_contain("info log")); + assert!(tracing_test::logs_contain("warn log")); + assert!(tracing_test::logs_contain("error log")); } #[test] fn it_prints_messages_to_log() { - let env_filter = "apollo_router=trace"; - let mock_writer = tracing_test::internal::MockWriter::new(tracing_test::internal::global_buf()); - let subscriber = tracing_test::internal::get_subscriber(mock_writer, env_filter); + use tracing::subscriber; - let _guard = tracing::dispatcher::set_default(&subscriber); - let engine = new_rhai_test_engine(); - engine - .eval::<()>(r#"print("info log")"#) - .expect("it logged a message"); - assert!(tracing_test::internal::logs_with_scope_contain( - "apollo_router", - "info log" - )); + use crate::assert_snapshot_subscriber; + + subscriber::with_default(assert_snapshot_subscriber!(), || { + let engine = new_rhai_test_engine(); + engine + .eval::<()>(r#"print("info log")"#) + .expect("it logged a message"); + }); } #[tokio::test] @@ -315,17 +297,15 @@ async fn it_can_access_sdl_constant() { let it: &dyn std::any::Any = dyn_plugin.as_any(); let rhai_instance: &Rhai = it.downcast_ref::().expect("downcast"); - let block = rhai_instance.block.load(); - // Get a scope to use for our test - let scope = block.scope.clone(); + let scope = rhai_instance.scope.clone(); - let mut guard = scope.lock().unwrap(); + let mut guard = scope.lock(); // Call our function to make sure we can access the sdl - let sdl: String = block + let sdl: String = rhai_instance .engine - .call_fn(&mut guard, &block.ast, "get_sdl", ()) + .call_fn(&mut guard, &rhai_instance.ast, "get_sdl", ()) .expect("can get sdl"); assert_eq!(sdl.as_str(), ""); } @@ -590,15 +570,15 @@ async fn base_globals_function(fn_name: &str) -> Result().expect("downcast"); - let block = rhai_instance.block.load(); - // Get a scope to use for our test - let scope = block.scope.clone(); + let scope = rhai_instance.scope.clone(); - let mut guard = scope.lock().unwrap(); + let mut guard = scope.lock(); // Call our rhai test function. If it doesn't return an error, the test failed. - block.engine.call_fn(&mut guard, &block.ast, fn_name, ()) + rhai_instance + .engine + .call_fn(&mut guard, &rhai_instance.ast, fn_name, ()) } #[tokio::test] @@ -631,18 +611,16 @@ async fn it_can_process_om_subgraph_forbidden_with_graphql_payload() { let processed_error = process_error(error); assert_eq!(processed_error.status, StatusCode::FORBIDDEN); - assert_eq!( - processed_error.body, - Some( - graphql::Response::builder() - .errors(vec![{ - Error::builder() - .message("I have raised a 403") - .extension_code("ACCESS_DENIED") - .build() - }]) - .build() - ) + assert_response_eq_ignoring_error_id!( + processed_error.body.unwrap(), + graphql::Response::builder() + .errors(vec![{ + Error::builder() + .message("I have raised a 403") + .extension_code("ACCESS_DENIED") + .build() + }]) + .build() ); } @@ -669,7 +647,7 @@ async fn it_can_process_string_subgraph_forbidden() { if let Err(error) = call_rhai_function("process_subgraph_response_string").await { let processed_error = process_error(error); assert_eq!(processed_error.status, StatusCode::INTERNAL_SERVER_ERROR); - assert_eq!(processed_error.message, Some("rhai execution error: 'Runtime error: I have raised an error (line 251, position 5)'".to_string())); + assert_eq!(processed_error.message, Some("rhai execution error: 'Runtime error: I have raised an error (line 257, position 5)'".to_string())); } else { // Test failed panic!("error processed incorrectly"); @@ -697,7 +675,7 @@ async fn it_cannot_process_om_subgraph_missing_message_and_body() { assert_eq!( processed_error.message, Some( - "rhai execution error: 'Runtime error: #{\"status\": 400} (line 262, position 5)'" + "rhai execution error: 'Runtime error: #{\"status\": 400} (line 268, position 5)'" .to_string() ) ); @@ -873,3 +851,64 @@ async fn it_can_access_demand_control_context() -> Result<(), BoxError> { Ok(()) } + +#[tokio::test] +async fn test_rhai_header_removal_with_non_utf8_header() -> Result<(), BoxError> { + let bytes = b"\x80"; + // Prove that the bytes are not valid UTF-8 + assert!(String::from_utf8(bytes.to_vec()).is_err()); + + let mut mock_service = MockSupergraphService::new(); + mock_service + .expect_call() + .times(1) + .returning(move |req: SupergraphRequest| { + let mut response_builder = SupergraphResponse::fake_builder().context(req.context); + let header_value = HeaderValue::from_bytes(bytes).unwrap(); + response_builder = response_builder.header("x-binary-header", header_value); + + Ok(response_builder.build().unwrap()) + }); + + let dyn_plugin: Box = crate::plugin::plugins() + .find(|factory| factory.name == "apollo.rhai") + .expect("Plugin not found") + .create_instance_without_schema( + &Value::from_str( + r#"{"scripts":"tests/fixtures", "main":"non_utf8_header_removal.rhai"}"#, + ) + .unwrap(), + ) + .await + .unwrap(); + + let mut router_service = dyn_plugin.supergraph_service(BoxService::new(mock_service)); + let context = Context::new(); + let supergraph_req = SupergraphRequest::fake_builder().context(context).build()?; + + let mut service_response = router_service.ready().await?.call(supergraph_req).await?; + + assert_eq!(StatusCode::OK, service_response.response.status()); + + // Removing a non-UTF-8 header should be OK + let body = service_response.next_response().await.unwrap(); + if body.errors.is_empty() { + // yay, no errors + } else { + let rhai_error = body + .errors + .iter() + .find(|e| e.message.contains("rhai execution error")) + .expect("unexpected non-rhai error"); + panic!("Got an unexpected rhai error: {rhai_error:?}"); + } + + // Check that the header was actually removed + let headers = service_response.response.headers().clone(); + assert!( + headers.get("x-binary-header").is_none(), + "x-binary-header should have been removed but it's still present" + ); + + Ok(()) +} diff --git a/apollo-router/src/plugins/snapshots/apollo_router__plugins__expose_query_plan__tests__it_expose_query_plan-2.snap b/apollo-router/src/plugins/snapshots/apollo_router__plugins__expose_query_plan__tests__it_expose_query_plan-2.snap index b8130ecf59..cb80da3c18 100644 --- a/apollo-router/src/plugins/snapshots/apollo_router__plugins__expose_query_plan__tests__it_expose_query_plan-2.snap +++ b/apollo-router/src/plugins/snapshots/apollo_router__plugins__expose_query_plan__tests__it_expose_query_plan-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/src/plugins/expose_query_plan.rs expression: "serde_json::to_value(response).unwrap()" +snapshot_kind: text --- { "data": { @@ -62,14 +63,14 @@ expression: "serde_json::to_value(response).unwrap()" "variableUsages": [ "first" ], - "operation": "query TopProducts__products__0($first:Int){topProducts(first:$first){__typename upc name}}", + "operation": "query TopProducts__products__0($first: Int) { topProducts(first: $first) { __typename upc name } }", "operationName": "TopProducts__products__0", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "c595a39efeab9494c75a29de44ec4748c1741ddb96e1833e99139b058aa9da84", + "schemaAwareHash": "5c4bde1b693a9d93618856d221a620783601d3e6141991ea1d49763dca5fe94b", "authorization": { "is_authenticated": false, "scopes": [], @@ -102,14 +103,14 @@ expression: "serde_json::to_value(response).unwrap()" } ], "variableUsages": [], - "operation": "query TopProducts__reviews__1($representations:[_Any!]!){_entities(representations:$representations){...on Product{reviews{id product{__typename upc}author{__typename id}}}}}", + "operation": "query TopProducts__reviews__1($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviews { id product { __typename upc } author { __typename id } } } } }", "operationName": "TopProducts__reviews__1", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "7054d7662e20905b01d6f937e6b588ed422e0e79de737c98e3d51b6dc610179f", + "schemaAwareHash": "1763ef26b5543dd364a96f6b29f9db6edbbe06ef4b260fd6dd59258cf09134b8", "authorization": { "is_authenticated": false, "scopes": [], @@ -127,15 +128,15 @@ expression: "serde_json::to_value(response).unwrap()" "@", "reviews", "@", - "product" + "author" ], "node": { "kind": "Fetch", - "serviceName": "products", + "serviceName": "accounts", "requires": [ { "kind": "InlineFragment", - "typeCondition": "Product", + "typeCondition": "User", "selections": [ { "kind": "Field", @@ -143,20 +144,20 @@ expression: "serde_json::to_value(response).unwrap()" }, { "kind": "Field", - "name": "upc" + "name": "id" } ] } ], "variableUsages": [], - "operation": "query TopProducts__products__2($representations:[_Any!]!){_entities(representations:$representations){...on Product{name}}}", - "operationName": "TopProducts__products__2", + "operation": "query TopProducts__accounts__2($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { name } } }", + "operationName": "TopProducts__accounts__2", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "76d400fc6a494cbe05a44751923e570ee31928f0fb035ea36c14d4d6f4545482", + "schemaAwareHash": "b634e94c76926292e24ea336046389758058cccf227b49917b625adccfc29d07", "authorization": { "is_authenticated": false, "scopes": [], @@ -171,15 +172,15 @@ expression: "serde_json::to_value(response).unwrap()" "@", "reviews", "@", - "author" + "product" ], "node": { "kind": "Fetch", - "serviceName": "accounts", + "serviceName": "products", "requires": [ { "kind": "InlineFragment", - "typeCondition": "User", + "typeCondition": "Product", "selections": [ { "kind": "Field", @@ -187,20 +188,20 @@ expression: "serde_json::to_value(response).unwrap()" }, { "kind": "Field", - "name": "id" + "name": "upc" } ] } ], "variableUsages": [], - "operation": "query TopProducts__accounts__3($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}", - "operationName": "TopProducts__accounts__3", + "operation": "query TopProducts__products__3($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { name } } }", + "operationName": "TopProducts__products__3", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "bff0ce0cfd6e2830949c59ae26f350d06d76150d6041b08c3d0c4384bc20b271", + "schemaAwareHash": "2ff7e653609dee610e4c5e06a666391889af36a0f78ce44a15cf758e4cc897e5", "authorization": { "is_authenticated": false, "scopes": [], @@ -213,7 +214,7 @@ expression: "serde_json::to_value(response).unwrap()" ] } }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"products\") {\n {\n topProducts(first: $first) {\n __typename\n upc\n name\n }\n }\n },\n Flatten(path: \"topProducts.@\") {\n Fetch(service: \"reviews\") {\n {\n ... on Product {\n __typename\n upc\n }\n } =>\n {\n ... on Product {\n reviews {\n id\n product {\n __typename\n upc\n }\n author {\n __typename\n id\n }\n }\n }\n }\n },\n },\n Parallel {\n Flatten(path: \"topProducts.@.reviews.@.product\") {\n Fetch(service: \"products\") {\n {\n ... on Product {\n __typename\n upc\n }\n } =>\n {\n ... on Product {\n name\n }\n }\n },\n },\n Flatten(path: \"topProducts.@.reviews.@.author\") {\n Fetch(service: \"accounts\") {\n {\n ... on User {\n __typename\n id\n }\n } =>\n {\n ... on User {\n name\n }\n }\n },\n },\n },\n },\n}" + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"products\") {\n {\n topProducts(first: $first) {\n __typename\n upc\n name\n }\n }\n },\n Flatten(path: \"topProducts.@\") {\n Fetch(service: \"reviews\") {\n {\n ... on Product {\n __typename\n upc\n }\n } =>\n {\n ... on Product {\n reviews {\n id\n product {\n __typename\n upc\n }\n author {\n __typename\n id\n }\n }\n }\n }\n },\n },\n Parallel {\n Flatten(path: \"topProducts.@.reviews.@.author\") {\n Fetch(service: \"accounts\") {\n {\n ... on User {\n __typename\n id\n }\n } =>\n {\n ... on User {\n name\n }\n }\n },\n },\n Flatten(path: \"topProducts.@.reviews.@.product\") {\n Fetch(service: \"products\") {\n {\n ... on Product {\n __typename\n upc\n }\n } =>\n {\n ... on Product {\n name\n }\n }\n },\n },\n },\n },\n}" } } } diff --git a/apollo-router/src/plugins/snapshots/apollo_router__plugins__expose_query_plan__tests__it_expose_query_plan.snap b/apollo-router/src/plugins/snapshots/apollo_router__plugins__expose_query_plan__tests__it_expose_query_plan.snap index b8130ecf59..cb80da3c18 100644 --- a/apollo-router/src/plugins/snapshots/apollo_router__plugins__expose_query_plan__tests__it_expose_query_plan.snap +++ b/apollo-router/src/plugins/snapshots/apollo_router__plugins__expose_query_plan__tests__it_expose_query_plan.snap @@ -1,6 +1,7 @@ --- source: apollo-router/src/plugins/expose_query_plan.rs expression: "serde_json::to_value(response).unwrap()" +snapshot_kind: text --- { "data": { @@ -62,14 +63,14 @@ expression: "serde_json::to_value(response).unwrap()" "variableUsages": [ "first" ], - "operation": "query TopProducts__products__0($first:Int){topProducts(first:$first){__typename upc name}}", + "operation": "query TopProducts__products__0($first: Int) { topProducts(first: $first) { __typename upc name } }", "operationName": "TopProducts__products__0", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "c595a39efeab9494c75a29de44ec4748c1741ddb96e1833e99139b058aa9da84", + "schemaAwareHash": "5c4bde1b693a9d93618856d221a620783601d3e6141991ea1d49763dca5fe94b", "authorization": { "is_authenticated": false, "scopes": [], @@ -102,14 +103,14 @@ expression: "serde_json::to_value(response).unwrap()" } ], "variableUsages": [], - "operation": "query TopProducts__reviews__1($representations:[_Any!]!){_entities(representations:$representations){...on Product{reviews{id product{__typename upc}author{__typename id}}}}}", + "operation": "query TopProducts__reviews__1($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviews { id product { __typename upc } author { __typename id } } } } }", "operationName": "TopProducts__reviews__1", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "7054d7662e20905b01d6f937e6b588ed422e0e79de737c98e3d51b6dc610179f", + "schemaAwareHash": "1763ef26b5543dd364a96f6b29f9db6edbbe06ef4b260fd6dd59258cf09134b8", "authorization": { "is_authenticated": false, "scopes": [], @@ -127,15 +128,15 @@ expression: "serde_json::to_value(response).unwrap()" "@", "reviews", "@", - "product" + "author" ], "node": { "kind": "Fetch", - "serviceName": "products", + "serviceName": "accounts", "requires": [ { "kind": "InlineFragment", - "typeCondition": "Product", + "typeCondition": "User", "selections": [ { "kind": "Field", @@ -143,20 +144,20 @@ expression: "serde_json::to_value(response).unwrap()" }, { "kind": "Field", - "name": "upc" + "name": "id" } ] } ], "variableUsages": [], - "operation": "query TopProducts__products__2($representations:[_Any!]!){_entities(representations:$representations){...on Product{name}}}", - "operationName": "TopProducts__products__2", + "operation": "query TopProducts__accounts__2($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { name } } }", + "operationName": "TopProducts__accounts__2", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "76d400fc6a494cbe05a44751923e570ee31928f0fb035ea36c14d4d6f4545482", + "schemaAwareHash": "b634e94c76926292e24ea336046389758058cccf227b49917b625adccfc29d07", "authorization": { "is_authenticated": false, "scopes": [], @@ -171,15 +172,15 @@ expression: "serde_json::to_value(response).unwrap()" "@", "reviews", "@", - "author" + "product" ], "node": { "kind": "Fetch", - "serviceName": "accounts", + "serviceName": "products", "requires": [ { "kind": "InlineFragment", - "typeCondition": "User", + "typeCondition": "Product", "selections": [ { "kind": "Field", @@ -187,20 +188,20 @@ expression: "serde_json::to_value(response).unwrap()" }, { "kind": "Field", - "name": "id" + "name": "upc" } ] } ], "variableUsages": [], - "operation": "query TopProducts__accounts__3($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}", - "operationName": "TopProducts__accounts__3", + "operation": "query TopProducts__products__3($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { name } } }", + "operationName": "TopProducts__products__3", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "bff0ce0cfd6e2830949c59ae26f350d06d76150d6041b08c3d0c4384bc20b271", + "schemaAwareHash": "2ff7e653609dee610e4c5e06a666391889af36a0f78ce44a15cf758e4cc897e5", "authorization": { "is_authenticated": false, "scopes": [], @@ -213,7 +214,7 @@ expression: "serde_json::to_value(response).unwrap()" ] } }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"products\") {\n {\n topProducts(first: $first) {\n __typename\n upc\n name\n }\n }\n },\n Flatten(path: \"topProducts.@\") {\n Fetch(service: \"reviews\") {\n {\n ... on Product {\n __typename\n upc\n }\n } =>\n {\n ... on Product {\n reviews {\n id\n product {\n __typename\n upc\n }\n author {\n __typename\n id\n }\n }\n }\n }\n },\n },\n Parallel {\n Flatten(path: \"topProducts.@.reviews.@.product\") {\n Fetch(service: \"products\") {\n {\n ... on Product {\n __typename\n upc\n }\n } =>\n {\n ... on Product {\n name\n }\n }\n },\n },\n Flatten(path: \"topProducts.@.reviews.@.author\") {\n Fetch(service: \"accounts\") {\n {\n ... on User {\n __typename\n id\n }\n } =>\n {\n ... on User {\n name\n }\n }\n },\n },\n },\n },\n}" + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"products\") {\n {\n topProducts(first: $first) {\n __typename\n upc\n name\n }\n }\n },\n Flatten(path: \"topProducts.@\") {\n Fetch(service: \"reviews\") {\n {\n ... on Product {\n __typename\n upc\n }\n } =>\n {\n ... on Product {\n reviews {\n id\n product {\n __typename\n upc\n }\n author {\n __typename\n id\n }\n }\n }\n }\n },\n },\n Parallel {\n Flatten(path: \"topProducts.@.reviews.@.author\") {\n Fetch(service: \"accounts\") {\n {\n ... on User {\n __typename\n id\n }\n } =>\n {\n ... on User {\n name\n }\n }\n },\n },\n Flatten(path: \"topProducts.@.reviews.@.product\") {\n Fetch(service: \"products\") {\n {\n ... on Product {\n __typename\n upc\n }\n } =>\n {\n ... on Product {\n name\n }\n }\n },\n },\n },\n },\n}" } } } diff --git a/apollo-router/src/plugins/subscription.rs b/apollo-router/src/plugins/subscription.rs index 50d5e78ead..e91cec120a 100644 --- a/apollo-router/src/plugins/subscription.rs +++ b/apollo-router/src/plugins/subscription.rs @@ -26,6 +26,8 @@ use tower::ServiceExt; use tracing_futures::Instrument; use uuid::Uuid; +use crate::Endpoint; +use crate::ListenAddr; use crate::context::Context; use crate::graphql; use crate::graphql::Response; @@ -41,8 +43,6 @@ use crate::register_plugin; use crate::services::router; use crate::services::router::body::RouterBody; use crate::services::subgraph; -use crate::Endpoint; -use crate::ListenAddr; type HmacSha256 = Hmac; pub(crate) const APOLLO_SUBSCRIPTION_PLUGIN: &str = "apollo.subscription"; @@ -68,21 +68,42 @@ pub(crate) struct SubscriptionConfig { pub(crate) enabled: bool, /// Select a subscription mode (callback or passthrough) pub(crate) mode: SubscriptionModeConfig, - /// Enable the deduplication of subscription (for example if we detect the exact same request to subgraph we won't open a new websocket to the subgraph in passthrough mode) - /// (default: true) - pub(crate) enable_deduplication: bool, + /// Configure subgraph subscription deduplication + pub(crate) deduplication: DeduplicationConfig, /// This is a limit to only have maximum X opened subscriptions at the same time. By default if it's not set there is no limit. pub(crate) max_opened_subscriptions: Option, /// It represent the capacity of the in memory queue to know how many events we can keep in a buffer pub(crate) queue_capacity: Option, } +/// Subscription deduplication configuration +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct DeduplicationConfig { + /// Enable subgraph subscription deduplication. When enabled, multiple identical requests to the same subgraph will share one WebSocket connection in passthrough mode. + /// (default: true) + pub(crate) enabled: bool, + /// List of headers to ignore for deduplication. Even if these headers are different, the subscription request is considered identical. + /// For example, if you forward the "User-Agent" header, but the subgraph doesn't depend on the value of that header, + /// adding it to this list will let the router dedupe subgraph subscriptions even if the header value is different. + pub(crate) ignored_headers: HashSet, +} + +impl Default for DeduplicationConfig { + fn default() -> Self { + Self { + enabled: true, + ignored_headers: Default::default(), + } + } +} + impl Default for SubscriptionConfig { fn default() -> Self { Self { enabled: true, mode: Default::default(), - enable_deduplication: true, + deduplication: DeduplicationConfig::default(), max_opened_subscriptions: None, queue_capacity: None, } @@ -109,17 +130,17 @@ impl SubscriptionModeConfig { } } - if let Some(callback_cfg) = &self.callback { - if callback_cfg.subgraphs.contains(service_name) || callback_cfg.subgraphs.is_empty() { - let callback_cfg = CallbackMode { - public_url: callback_cfg.public_url.clone(), - heartbeat_interval: callback_cfg.heartbeat_interval, - listen: callback_cfg.listen.clone(), - path: callback_cfg.path.clone(), - subgraphs: HashSet::new(), // We don't need it - }; - return SubscriptionMode::Callback(callback_cfg).into(); - } + if let Some(callback_cfg) = &self.callback + && (callback_cfg.subgraphs.contains(service_name) || callback_cfg.subgraphs.is_empty()) + { + let callback_cfg = CallbackMode { + public_url: callback_cfg.public_url.clone(), + heartbeat_interval: callback_cfg.heartbeat_interval, + listen: callback_cfg.listen.clone(), + path: callback_cfg.path.clone(), + subgraphs: HashSet::new(), // We don't need it + }; + return SubscriptionMode::Callback(callback_cfg).into(); } None @@ -276,7 +297,7 @@ impl Plugin for Subscription { ServiceBuilder::new() .checkpoint(move |req: subgraph::Request| { if req.operation_kind == OperationKind::Subscription && !enabled { - Ok(ControlFlow::Break(subgraph::Response::builder().context(req.context).error(graphql::Error::builder().message("cannot execute a subscription if it's not enabled in the configuration").extension_code("SUBSCRIPTION_DISABLED").build()).extensions(Object::default()).build())) + Ok(ControlFlow::Break(subgraph::Response::builder().context(req.context).subgraph_name(req.subgraph_name).error(graphql::Error::builder().message("cannot execute a subscription if it's not enabled in the configuration").extension_code("SUBSCRIPTION_DISABLED").build()).extensions(Object::default()).build())) } else { Ok(ControlFlow::Continue(req)) } @@ -295,7 +316,7 @@ impl Plugin for Subscription { .clone() .expect("cannot run subscription in callback mode without a hmac key"); let endpoint = Endpoint::from_router_service( - format!("{path}/:callback"), + format!("{path}/{{callback}}"), CallbackService::new(self.notify.clone(), path.to_string(), callback_hmac_key) .boxed(), ); @@ -354,7 +375,7 @@ pub(crate) enum SubscriptionPayload { #[serde(rename = "next")] Next { id: String, - payload: Response, + payload: Box, verifier: String, }, #[serde(rename = "complete")] @@ -430,7 +451,7 @@ impl Service for CallbackService { match parts.method { Method::POST => { - let cb_body = Into::::into(body).to_bytes() + let cb_body = router::body::into_bytes(Into::::into(body)) .await .map_err(|e| format!("failed to get the request body: {e}")) .and_then(|bytes| { @@ -444,13 +465,15 @@ impl Service for CallbackService { let cb_body = match cb_body { Ok(cb_body) => cb_body, Err(err) => { - return Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(err.into()) - .map_err(BoxError::from)?, - context: req.context, - }); + return router::Response::error_builder() + .status_code(StatusCode::BAD_REQUEST) + .error(graphql::Error::builder() + .message(err) + .extension_code(StatusCode::BAD_REQUEST.to_string()) + .build() + ) + .context(req.context) + .build(); } }; let id = cb_body.id().clone(); @@ -471,13 +494,15 @@ impl Service for CallbackService { let expected_hashed_verifier = verifier_hasher.finalize(); if hashed_verifier != expected_hashed_verifier { - return Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body("verifier doesn't match".into()) - .map_err(BoxError::from)?, - context: req.context, - }); + return router::Response::error_builder() + .status_code(StatusCode::UNAUTHORIZED) + .error(graphql::Error::builder() + .message("verifier doesn't match") + .extension_code(StatusCode::UNAUTHORIZED.to_string()) + .build() + ) + .context(req.context) + .build(); } if let Err(res) = ensure_id_consistency(&req.context, &sub_id, &id) { @@ -492,52 +517,56 @@ impl Service for CallbackService { let mut handle = match notify.subscribe_if_exist(id).await? { Some(handle) => handle.into_sink(), None => { - return Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::NOT_FOUND) - .body("suscription doesn't exist".into()) - .map_err(BoxError::from)?, - context: req.context, - }); + return router::Response::error_builder() + .status_code(StatusCode::NOT_FOUND) + .error(graphql::Error::builder() + .message("subscription doesn't exist") + .extension_code(StatusCode::NOT_FOUND.to_string()) + .build() + ) + .context(req.context) + .build(); } }; // Keep the subscription to the client opened payload.subscribed = Some(true); - tracing::info!( - monotonic_counter.apollo.router.operations.subscriptions.events = 1u64, - subscriptions.mode="callback" - ); - handle.send_sync(payload)?; - - Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::OK) - .body("".into()) - .map_err(BoxError::from)?, - context: req.context, - }) + u64_counter!( + "apollo.router.operations.subscriptions.events", + "Number of subscription events", + 1, + subscriptions.mode = "callback" + ); + handle.send_sync(*payload)?; + + router::Response::builder() + .context(req.context) + .build() } CallbackPayload::Subscription(SubscriptionPayload::Check { .. }) => { if notify.exist(id).await? { - Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::NO_CONTENT) - .header(HeaderName::from_static(CALLBACK_SUBSCRIPTION_HEADER_NAME), HeaderValue::from_static(CALLBACK_SUBSCRIPTION_HEADER_VALUE)) - .body("".into()) - .map_err(BoxError::from)?, - context: req.context, - }) + router::Response::error_builder() + .status_code(StatusCode::NO_CONTENT) + .header(HeaderName::from_static(CALLBACK_SUBSCRIPTION_HEADER_NAME), HeaderValue::from_static(CALLBACK_SUBSCRIPTION_HEADER_VALUE)) + .error(graphql::Error::builder() + .message(String::default()) + .extension_code(StatusCode::NO_CONTENT.to_string()) + .build() + ) + .context(req.context) + .build() } else { - Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::NOT_FOUND) - .header(HeaderName::from_static(CALLBACK_SUBSCRIPTION_HEADER_NAME), HeaderValue::from_static(CALLBACK_SUBSCRIPTION_HEADER_VALUE)) - .body("suscription doesn't exist".into()) - .map_err(BoxError::from)?, - context: req.context, - }) + router::Response::error_builder() + .status_code(StatusCode::NOT_FOUND) + .header(HeaderName::from_static(CALLBACK_SUBSCRIPTION_HEADER_NAME), HeaderValue::from_static(CALLBACK_SUBSCRIPTION_HEADER_VALUE)) + .error(graphql::Error::builder() + .message("subscription doesn't exist") + .extension_code(StatusCode::NOT_FOUND.to_string()) + .build() + ) + .context(req.context) + .build() } } CallbackPayload::Subscription(SubscriptionPayload::Heartbeat { @@ -546,32 +575,38 @@ impl Service for CallbackService { verifier, }) => { if !ids.contains(&id) { - return Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body("id used for the verifier is not part of ids array".into()) - .map_err(BoxError::from)?, - context: req.context, - }); + return router::Response::error_builder() + .status_code(StatusCode::UNAUTHORIZED) + .error(graphql::Error::builder() + .message("id used for the verifier is not part of ids array") + .extension_code(StatusCode::UNAUTHORIZED.to_string()) + .build() + ) + .context(req.context) + .build() } let (mut valid_ids, invalid_ids) = notify.invalid_ids(ids).await?; if invalid_ids.is_empty() { - Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::NO_CONTENT) - .body("".into()) - .map_err(BoxError::from)?, - context: req.context, - }) + router::Response::error_builder() + .status_code(StatusCode::NO_CONTENT) + .error(graphql::Error::builder() + .message(String::default()) + .extension_code(StatusCode::NO_CONTENT.to_string()) + .build() + ) + .context(req.context) + .build() } else if valid_ids.is_empty() { - Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::NOT_FOUND) - .body("suscriptions don't exist".into()) - .map_err(BoxError::from)?, - context: req.context, - }) + router::Response::error_builder() + .status_code(StatusCode::NOT_FOUND) + .error(graphql::Error::builder() + .message("subscriptions don't exist") + .extension_code(StatusCode::NOT_FOUND.to_string()) + .build() + ) + .context(req.context) + .build() } else { let (id, verifier) = if invalid_ids.contains(&id) { (id, verifier) @@ -587,17 +622,19 @@ impl Service for CallbackService { (new_id, verifier) }; - Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::NOT_FOUND) - .body(serde_json::to_string_pretty(&InvalidIdsPayload{ + router::Response::error_builder() + .status_code(StatusCode::NOT_FOUND) + .error(graphql::Error::builder() + .message(serde_json::to_string_pretty(&InvalidIdsPayload{ invalid_ids, id, verifier, - })?.into()) - .map_err(BoxError::from)?, - context: req.context, - }) + }).map_err(BoxError::from)?) + .extension_code(StatusCode::NOT_FOUND.to_string()) + .build() + ) + .context(req.context) + .build() } } CallbackPayload::Subscription(SubscriptionPayload::Complete { @@ -608,67 +645,80 @@ impl Service for CallbackService { let mut handle = match notify.subscribe(id.clone()).await { Ok(handle) => handle.into_sink(), Err(NotifyError::UnknownTopic) => { - return Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::NOT_FOUND) - .body("unknown topic".into()) - .map_err(BoxError::from)?, - context: req.context, - }); + return router::Response::error_builder() + .status_code(StatusCode::NOT_FOUND) + .error(graphql::Error::builder() + .message("unknown topic") + .extension_code(StatusCode::NOT_FOUND.to_string()) + .build() + ) + .context(req.context) + .build(); }, Err(err) => { - return Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::NOT_FOUND) - .body(err.to_string().into()) - .map_err(BoxError::from)?, - context: req.context, - }); + return router::Response::error_builder() + .status_code(StatusCode::NOT_FOUND) + .error(graphql::Error::builder() + .message(err.to_string()) + .extension_code(StatusCode::NOT_FOUND.to_string()) + .build() + ) + .context(req.context) + .build(); } }; - tracing::info!( - monotonic_counter.apollo.router.operations.subscriptions.events = 1u64, - subscriptions.mode="callback", - subscriptions.complete=true + u64_counter!( + "apollo.router.operations.subscriptions.events", + "Number of subscription events", + 1, + subscriptions.mode = "callback", + subscriptions.complete = true ); if let Err(_err) = handle.send_sync( graphql::Response::builder().errors(errors).build(), ) { - return Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::NOT_FOUND) - .body("cannot send errors to the client".into()) - .map_err(BoxError::from)?, - context: req.context, - }); + return router::Response::error_builder() + .status_code(StatusCode::NOT_FOUND) + .error(graphql::Error::builder() + .message("cannot send errors to the client") + .extension_code(StatusCode::NOT_FOUND.to_string()) + .build() + ) + .context(req.context) + .build(); } } if let Err(_err) = notify.force_delete(id).await { - return Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::NOT_FOUND) - .body("cannot force delete".into()) - .map_err(BoxError::from)?, - context: req.context, - }); + return router::Response::error_builder() + .status_code(StatusCode::NOT_FOUND) + .error(graphql::Error::builder() + .message("cannot force delete") + .extension_code(StatusCode::NOT_FOUND.to_string()) + .build() + ) + .context(req.context) + .build(); } - Ok(router::Response { - response: http::Response::builder() + + router::Response::http_response_builder() + .response(http::Response::builder() .status(StatusCode::ACCEPTED) - .body("".into()) - .map_err(BoxError::from)?, - context: req.context, - }) + .body(router::body::empty()) + .map_err(BoxError::from)?) + .context(req.context) + .build() } } } - _ => Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::METHOD_NOT_ALLOWED) - .body("".into()) - .map_err(BoxError::from)?, - context: req.context, - }), + _ => router::Response::error_builder() + .status_code(StatusCode::METHOD_NOT_ALLOWED) + .error(graphql::Error::builder() + .message(String::default()) + .extension_code(StatusCode::METHOD_NOT_ALLOWED.to_string()) + .build() + ) + .context(req.context) + .build() } } .instrument(tracing::info_span!("subscription_callback")), @@ -688,42 +738,51 @@ pub(crate) fn create_verifier(sub_id: &str) -> Result { Ok(verifier) } +#[allow(clippy::result_large_err)] fn ensure_id_consistency( context: &Context, id_from_path: &str, id_from_body: &str, ) -> Result<(), router::Response> { - (id_from_path != id_from_body) - .then(|| { - Err(router::Response { - response: http::Response::builder() - .status(StatusCode::BAD_REQUEST) - .body("id from url path and id from body are different".into()) - .expect("this body is valid"), - context: context.clone(), - }) - }) - .unwrap_or_else(|| Ok(())) + if id_from_path != id_from_body { + Err(router::Response::error_builder() + .status_code(StatusCode::BAD_REQUEST) + .error( + graphql::Error::builder() + .message("id from url path and id from body are different") + .extension_code(StatusCode::BAD_REQUEST.to_string()) + .build(), + ) + .context(context.clone()) + .build() + .expect("this response is valid")) + } else { + Ok(()) + } } #[cfg(test)] mod tests { use std::str::FromStr; + use std::sync::Arc; use futures::StreamExt; use serde_json::Value; - use tower::util::BoxService; use tower::Service; use tower::ServiceExt; + use tower::util::BoxService; use super::*; + use crate::Notify; + use crate::assert_response_eq_ignoring_error_id; use crate::graphql::Request; use crate::http_ext; - use crate::plugin::test::MockSubgraphService; use crate::plugin::DynPlugin; + use crate::plugin::test::MockSubgraphService; use crate::services::SubgraphRequest; use crate::services::SubgraphResponse; - use crate::Notify; + use crate::services::router::body; + use crate::uplink::license_enforcement::LicenseState; #[tokio::test(flavor = "multi_thread")] async fn it_test_callback_endpoint() { @@ -755,7 +814,7 @@ mod tests { .unwrap(); let http_req_prom = http::Request::get("http://localhost:4000/subscription/callback") - .body(Default::default()) + .body(body::empty()) .unwrap(); let mut web_endpoint = dyn_plugin .web_endpoints() @@ -768,6 +827,7 @@ mod tests { .unwrap() .into_router(); let resp = web_endpoint + .as_service() .ready() .await .unwrap() @@ -777,23 +837,20 @@ mod tests { assert_eq!(resp.status(), http::StatusCode::NOT_FOUND); let new_sub_id = uuid::Uuid::new_v4().to_string(); let (handler, _created) = notify - .create_or_subscribe(new_sub_id.clone(), true) + .create_or_subscribe(new_sub_id.clone(), true, None) .await .unwrap(); let verifier = create_verifier(&new_sub_id).unwrap(); let http_req = http::Request::post(format!( "http://localhost:4000/subscription/callback/{new_sub_id}" )) - .body( - RouterBody::from( - serde_json::to_vec(&CallbackPayload::Subscription(SubscriptionPayload::Check { - id: new_sub_id.clone(), - verifier: verifier.clone(), - })) - .unwrap(), - ) - .into_inner(), - ) + .body(router::body::from_bytes( + serde_json::to_vec(&CallbackPayload::Subscription(SubscriptionPayload::Check { + id: new_sub_id.clone(), + verifier: verifier.clone(), + })) + .unwrap(), + )) .unwrap(); let resp = web_endpoint.clone().oneshot(http_req).await.unwrap(); assert_eq!(resp.status(), http::StatusCode::NO_CONTENT); @@ -807,16 +864,16 @@ mod tests { let http_req = http::Request::post(format!( "http://localhost:4000/subscription/callback/{new_sub_id}" )) - .body(RouterBody::from( + .body(router::body::from_bytes( serde_json::to_vec(&CallbackPayload::Subscription(SubscriptionPayload::Next { id: new_sub_id.clone(), - payload: graphql::Response::builder() + payload: Box::new(graphql::Response::builder() .data(serde_json_bytes::json!({"userWasCreated": {"username": "ada_lovelace"}})) - .build(), + .build()), verifier: verifier.clone(), })) .unwrap(), - ).into_inner()) + )) .unwrap(); let resp = web_endpoint.clone().oneshot(http_req).await.unwrap(); assert_eq!(resp.status(), http::StatusCode::OK); @@ -836,16 +893,16 @@ mod tests { let http_req = http::Request::post(format!( "http://localhost:4000/subscription/callback/{new_sub_id}" )) - .body(RouterBody::from( + .body(router::body::from_bytes( serde_json::to_vec(&CallbackPayload::Subscription(SubscriptionPayload::Next { id: new_sub_id.clone(), - payload: graphql::Response::builder() + payload: Box::new(graphql::Response::builder() .data(serde_json_bytes::json!({"userWasCreated": {"username": "ada_lovelace"}})) - .build(), + .build()), verifier: verifier.clone(), })) .unwrap(), - ).into_inner()) + )) .unwrap(); let resp = web_endpoint.clone().oneshot(http_req).await.unwrap(); assert_eq!(resp.status(), http::StatusCode::NOT_FOUND); @@ -854,19 +911,16 @@ mod tests { let http_req = http::Request::post(format!( "http://localhost:4000/subscription/callback/{new_sub_id}" )) - .body( - RouterBody::from( - serde_json::to_vec(&CallbackPayload::Subscription( - SubscriptionPayload::Heartbeat { - id: new_sub_id.clone(), - ids: vec![new_sub_id, "FAKE_SUB_ID".to_string()], - verifier: verifier.clone(), - }, - )) - .unwrap(), - ) - .into_inner(), - ) + .body(router::body::from_bytes( + serde_json::to_vec(&CallbackPayload::Subscription( + SubscriptionPayload::Heartbeat { + id: new_sub_id.clone(), + ids: vec![new_sub_id, "FAKE_SUB_ID".to_string()], + verifier: verifier.clone(), + }, + )) + .unwrap(), + )) .unwrap(); let resp = web_endpoint.oneshot(http_req).await.unwrap(); assert_eq!(resp.status(), http::StatusCode::NOT_FOUND); @@ -896,13 +950,14 @@ mod tests { .unwrap(), ) .notify(notify.clone()) + .license(Arc::new(LicenseState::default())) .build(), ) .await .unwrap(); let http_req_prom = http::Request::get("http://localhost:4000/subscription/callback") - .body(Default::default()) + .body(body::empty()) .unwrap(); let mut web_endpoint = dyn_plugin .web_endpoints() @@ -915,6 +970,7 @@ mod tests { .unwrap() .into_router(); let resp = web_endpoint + .as_service() .ready() .await .unwrap() @@ -924,23 +980,20 @@ mod tests { assert_eq!(resp.status(), http::StatusCode::NOT_FOUND); let new_sub_id = uuid::Uuid::new_v4().to_string(); let (_handler, _created) = notify - .create_or_subscribe(new_sub_id.clone(), true) + .create_or_subscribe(new_sub_id.clone(), true, None) .await .unwrap(); let verifier = String::from("XXX"); let http_req = http::Request::post(format!( "http://localhost:4000/subscription/callback/{new_sub_id}" )) - .body( - RouterBody::from( - serde_json::to_vec(&CallbackPayload::Subscription(SubscriptionPayload::Check { - id: new_sub_id.clone(), - verifier: verifier.clone(), - })) - .unwrap(), - ) - .into_inner(), - ) + .body(router::body::from_bytes( + serde_json::to_vec(&CallbackPayload::Subscription(SubscriptionPayload::Check { + id: new_sub_id.clone(), + verifier: verifier.clone(), + })) + .unwrap(), + )) .unwrap(); let resp = web_endpoint.clone().oneshot(http_req).await.unwrap(); assert_eq!(resp.status(), http::StatusCode::UNAUTHORIZED); @@ -948,16 +1001,16 @@ mod tests { let http_req = http::Request::post(format!( "http://localhost:4000/subscription/callback/{new_sub_id}" )) - .body(RouterBody::from( + .body(router::body::from_bytes( serde_json::to_vec(&CallbackPayload::Subscription(SubscriptionPayload::Next { id: new_sub_id.clone(), - payload: graphql::Response::builder() + payload: Box::new(graphql::Response::builder() .data(serde_json_bytes::json!({"userWasCreated": {"username": "ada_lovelace"}})) - .build(), + .build()), verifier: verifier.clone(), })) .unwrap(), - ).into_inner()) + )) .unwrap(); let resp = web_endpoint.clone().oneshot(http_req).await.unwrap(); assert_eq!(resp.status(), http::StatusCode::UNAUTHORIZED); @@ -987,13 +1040,14 @@ mod tests { .unwrap(), ) .notify(notify.clone()) + .license(Arc::new(LicenseState::default())) .build(), ) .await .unwrap(); let http_req_prom = http::Request::get("http://localhost:4000/subscription/callback") - .body(Default::default()) + .body(body::empty()) .unwrap(); let mut web_endpoint = dyn_plugin .web_endpoints() @@ -1006,6 +1060,7 @@ mod tests { .unwrap() .into_router(); let resp = web_endpoint + .as_service() .ready() .await .unwrap() @@ -1015,7 +1070,7 @@ mod tests { assert_eq!(resp.status(), http::StatusCode::NOT_FOUND); let new_sub_id = uuid::Uuid::new_v4().to_string(); let (handler, _created) = notify - .create_or_subscribe(new_sub_id.clone(), true) + .create_or_subscribe(new_sub_id.clone(), true, None) .await .unwrap(); let verifier = create_verifier(&new_sub_id).unwrap(); @@ -1023,16 +1078,13 @@ mod tests { let http_req = http::Request::post(format!( "http://localhost:4000/subscription/callback/{new_sub_id}" )) - .body( - RouterBody::from( - serde_json::to_vec(&CallbackPayload::Subscription(SubscriptionPayload::Check { - id: new_sub_id.clone(), - verifier: verifier.clone(), - })) - .unwrap(), - ) - .into_inner(), - ) + .body(router::body::from_bytes( + serde_json::to_vec(&CallbackPayload::Subscription(SubscriptionPayload::Check { + id: new_sub_id.clone(), + verifier: verifier.clone(), + })) + .unwrap(), + )) .unwrap(); let resp = web_endpoint.clone().oneshot(http_req).await.unwrap(); assert_eq!(resp.status(), http::StatusCode::NO_CONTENT); @@ -1046,12 +1098,12 @@ mod tests { let http_req = http::Request::post(format!( "http://localhost:4000/subscription/callback/{new_sub_id}" )) - .body(crate::services::router::Body::from( + .body(router::body::from_bytes( serde_json::to_vec(&CallbackPayload::Subscription(SubscriptionPayload::Next { id: new_sub_id.clone(), - payload: graphql::Response::builder() + payload: Box::new(graphql::Response::builder() .data(serde_json_bytes::json!({"userWasCreated": {"username": "ada_lovelace"}})) - .build(), + .build()), verifier: verifier.clone(), })) .unwrap(), @@ -1073,34 +1125,34 @@ mod tests { let http_req = http::Request::post(format!( "http://localhost:4000/subscription/callback/{new_sub_id}" )) - .body( - RouterBody::from( - serde_json::to_vec(&CallbackPayload::Subscription( - SubscriptionPayload::Complete { - id: new_sub_id.clone(), - errors: Some(vec![graphql::Error::builder() + .body(router::body::from_bytes( + serde_json::to_vec(&CallbackPayload::Subscription( + SubscriptionPayload::Complete { + id: new_sub_id.clone(), + errors: Some(vec![ + graphql::Error::builder() .message("cannot complete the subscription") .extension_code("SUBSCRIPTION_ERROR") - .build()]), - verifier: verifier.clone(), - }, - )) - .unwrap(), - ) - .into_inner(), - ) + .build(), + ]), + verifier: verifier.clone(), + }, + )) + .unwrap(), + )) .unwrap(); let resp = web_endpoint.clone().oneshot(http_req).await.unwrap(); assert_eq!(resp.status(), http::StatusCode::ACCEPTED); let msg = handler.next().await.unwrap(); - - assert_eq!( + assert_response_eq_ignoring_error_id!( msg, graphql::Response::builder() - .errors(vec![graphql::Error::builder() - .message("cannot complete the subscription") - .extension_code("SUBSCRIPTION_ERROR") - .build()]) + .errors(vec![ + graphql::Error::builder() + .message("cannot complete the subscription") + .extension_code("SUBSCRIPTION_ERROR") + .build() + ]) .build() ); @@ -1108,16 +1160,16 @@ mod tests { let http_req = http::Request::post(format!( "http://localhost:4000/subscription/callback/{new_sub_id}" )) - .body(RouterBody::from( + .body(router::body::from_bytes( serde_json::to_vec(&CallbackPayload::Subscription(SubscriptionPayload::Next { id: new_sub_id.clone(), - payload: graphql::Response::builder() + payload: Box::new(graphql::Response::builder() .data(serde_json_bytes::json!({"userWasCreated": {"username": "ada_lovelace"}})) - .build(), + .build()), verifier, })) .unwrap(), - ).into_inner()) + )) .unwrap(); let resp = web_endpoint.oneshot(http_req).await.unwrap(); assert_eq!(resp.status(), http::StatusCode::NOT_FOUND); @@ -1174,7 +1226,21 @@ mod tests { .await .unwrap(); - assert_eq!(subgraph_response.response.body(), &graphql::Response::builder().data(serde_json_bytes::Value::Null).error(graphql::Error::builder().message("cannot execute a subscription if it's not enabled in the configuration").extension_code("SUBSCRIPTION_DISABLED").build()).extensions(Object::default()).build()); + assert_response_eq_ignoring_error_id!( + subgraph_response.response.body(), + &graphql::Response::builder() + .data(serde_json_bytes::Value::Null) + .error( + graphql::Error::builder() + .message( + "cannot execute a subscription if it's not enabled in the configuration" + ) + .extension_code("SUBSCRIPTION_DISABLED") + .build() + ) + .extensions(Object::default()) + .build() + ); } #[test] @@ -1408,7 +1474,7 @@ mod tests { .unwrap(); assert!(sub_config.enabled); - assert!(sub_config.enable_deduplication); + assert!(sub_config.deduplication.enabled); assert!(sub_config.max_opened_subscriptions.is_none()); assert!(sub_config.queue_capacity.is_none()); } diff --git a/apollo-router/src/plugins/telemetry/apollo.rs b/apollo-router/src/plugins/telemetry/apollo.rs index 69780cbc9e..9b3c700451 100644 --- a/apollo-router/src/plugins/telemetry/apollo.rs +++ b/apollo-router/src/plugins/telemetry/apollo.rs @@ -9,12 +9,13 @@ use std::time::SystemTime; use http::header::HeaderName; use itertools::Itertools; use schemars::JsonSchema; -use serde::ser::SerializeMap; use serde::Deserialize; use serde::Serialize; +use serde::ser::SerializeMap; use url::Url; use uuid::Uuid; +use super::apollo_exporter::proto::reports::QueryMetadata; use super::config::ApolloMetricsReferenceMode; use super::config::ApolloSignatureNormalizationAlgorithm; use super::config::Sampler; @@ -67,7 +68,7 @@ pub(crate) struct Config { #[schemars(skip)] pub(crate) apollo_graph_ref: Option, - /// The name of the header to extract from requests when populating 'client nane' for traces and metrics in Apollo Studio. + /// The name of the header to extract from requests when populating 'client name' for traces and metrics in Apollo Studio. #[schemars(with = "Option", default = "client_name_header_default_str")] #[serde(deserialize_with = "deserialize_header_name")] pub(crate) client_name_header: HeaderName, @@ -84,12 +85,16 @@ pub(crate) struct Config { pub(crate) field_level_instrumentation_sampler: SamplerOption, /// Percentage of traces to send via the OTel protocol when sending to Apollo Studio. - pub(crate) experimental_otlp_tracing_sampler: SamplerOption, + pub(crate) otlp_tracing_sampler: SamplerOption, /// OTLP protocol used for OTel traces. /// Note this only applies if OTel traces are enabled and is only intended for use in tests. pub(crate) experimental_otlp_tracing_protocol: Protocol, + /// OTLP protocol used for OTel metrics. + /// Note this is only intended for use in tests. + pub(crate) experimental_otlp_metrics_protocol: Protocol, + /// To configure which request header names and values are included in trace data that's sent to Apollo Studio. pub(crate) send_headers: ForwardHeaders, /// To configure which GraphQL variable values are included in trace data that's sent to Apollo Studio @@ -114,6 +119,9 @@ pub(crate) struct Config { /// Enable field metrics that are generated without FTV1 to be sent to Apollo Studio. pub(crate) experimental_local_field_metrics: bool, + + /// Enable sending additional subgraph metrics to Apollo Studio via OTLP + pub(crate) experimental_subgraph_metrics: bool, } #[derive(Debug, Clone, Deserialize, JsonSchema, Default)] @@ -121,6 +129,9 @@ pub(crate) struct Config { pub(crate) struct ErrorsConfiguration { /// Handling of errors coming from subgraph pub(crate) subgraph: SubgraphErrorConfig, + + /// Send error metrics via OTLP with additional dimensions [`extensions.service`, `extensions.code`] + pub(crate) preview_extended_error_metrics: ExtendedErrorMetricsMode, } #[derive(Debug, Clone, Deserialize, JsonSchema, Default)] @@ -139,6 +150,9 @@ pub(crate) struct ErrorConfiguration { pub(crate) send: bool, /// Redact subgraph errors to Apollo Studio pub(crate) redact: bool, + /// Allows additional dimension `extensions.code` to be sent with errors + /// even when `redact` is set to `true`. Has no effect when `redact` is false. + pub(crate) redaction_policy: ErrorRedactionPolicy, } impl Default for ErrorConfiguration { @@ -146,6 +160,7 @@ impl Default for ErrorConfiguration { Self { send: true, redact: true, + redaction_policy: ErrorRedactionPolicy::default(), } } } @@ -160,12 +175,35 @@ impl SubgraphErrorConfig { } } +/// Extended Open Telemetry error metrics mode +#[derive(Clone, Default, Debug, Deserialize, JsonSchema, Copy)] +#[serde(deny_unknown_fields, rename_all = "lowercase")] +pub(crate) enum ExtendedErrorMetricsMode { + /// Do not send extended OTLP error metrics + #[default] + Disabled, + /// Send extended OTLP error metrics to Apollo Studio with additional dimensions [`extensions.service`, `extensions.code`]. + /// If enabled, it's also recommended to enable `redaction_policy: extended` on subgraphs to send the `extensions.code` for subgraph errors. + Enabled, +} + +/// Allow some error fields to be send to Apollo Studio even when `redact` is true. +#[derive(Clone, Default, Debug, Deserialize, JsonSchema, Copy)] +#[serde(deny_unknown_fields, rename_all = "lowercase")] +pub(crate) enum ErrorRedactionPolicy { + /// Applies redaction to all error details. + #[default] + Strict, + /// Modifies the `redact` setting by excluding the `extensions.code` field in errors from redaction. + Extended, +} + const fn default_field_level_instrumentation_sampler() -> SamplerOption { SamplerOption::TraceIdRatioBased(0.01) } -const fn default_experimental_otlp_tracing_sampler() -> SamplerOption { - SamplerOption::Always(Sampler::AlwaysOff) +const fn default_otlp_tracing_sampler() -> SamplerOption { + SamplerOption::Always(Sampler::AlwaysOn) } fn endpoint_default() -> Url { @@ -202,6 +240,7 @@ impl Default for Config { endpoint: endpoint_default(), experimental_otlp_endpoint: otlp_endpoint_default(), experimental_otlp_tracing_protocol: Protocol::default(), + experimental_otlp_metrics_protocol: Protocol::default(), apollo_key: apollo_key(), apollo_graph_ref: apollo_graph_reference(), client_name_header: client_name_header_default(), @@ -209,7 +248,7 @@ impl Default for Config { schema_id: "".to_string(), buffer_size: default_buffer_size(), field_level_instrumentation_sampler: default_field_level_instrumentation_sampler(), - experimental_otlp_tracing_sampler: default_experimental_otlp_tracing_sampler(), + otlp_tracing_sampler: default_otlp_tracing_sampler(), send_headers: ForwardHeaders::None, send_variable_values: ForwardValues::None, batch_processor: BatchProcessorConfig::default(), @@ -217,6 +256,7 @@ impl Default for Config { signature_normalization_algorithm: ApolloSignatureNormalizationAlgorithm::default(), experimental_local_field_metrics: false, metrics_reference_mode: ApolloMetricsReferenceMode::default(), + experimental_subgraph_metrics: false, } } } @@ -305,6 +345,7 @@ pub(crate) struct Report { #[serde(serialize_with = "serialize_licensed_operation_count_by_type")] pub(crate) licensed_operation_count_by_type: HashMap<(OperationKind, Option), LicensedOperationCountByType>, + pub(crate) router_features_enabled: Vec, } #[derive(Clone, Default, Debug, Serialize, PartialEq, Eq, Hash)] @@ -400,6 +441,7 @@ impl Report { .collect(), traces_pre_aggregated: true, extended_references_enabled, + router_features_enabled: self.router_features_enabled.clone(), ..Default::default() }; @@ -453,6 +495,13 @@ impl AddAssign for Report { }) .or_insert(licensed_operation_count_by_type); } + self.router_features_enabled = self + .router_features_enabled + .clone() + .into_iter() + .chain(report.router_features_enabled) + .unique() + .collect(); } } @@ -462,6 +511,7 @@ pub(crate) struct TracesAndStats { #[serde(with = "vectorize")] pub(crate) stats_with_context: HashMap, pub(crate) referenced_fields_by_type: HashMap, + pub(crate) query_metadata: Option, } impl From @@ -472,7 +522,7 @@ impl From stats_with_context: stats.stats_with_context.into_values().map_into().collect(), referenced_fields_by_type: stats.referenced_fields_by_type, trace: stats.traces, - ..Default::default() + query_metadata: stats.query_metadata, } } } @@ -484,8 +534,10 @@ impl AddAssign for TracesAndStats { .entry(stats.stats_with_context.context.clone()) .or_default() += stats.stats_with_context; - // No merging required here because references fields by type will always be the same for each stats report key. + // No merging required here because references fields by type and metadata will always be the same for + // each stats report key. self.referenced_fields_by_type = stats.referenced_fields_by_type; + self.query_metadata = stats.query_metadata; } } diff --git a/apollo-router/src/plugins/telemetry/apollo_exporter.rs b/apollo-router/src/plugins/telemetry/apollo_exporter.rs index 4a1a7b3e50..4ca04737d5 100644 --- a/apollo-router/src/plugins/telemetry/apollo_exporter.rs +++ b/apollo-router/src/plugins/telemetry/apollo_exporter.rs @@ -4,20 +4,20 @@ use std::io::Write; use std::str::FromStr; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use std::sync::Mutex; use std::time::Duration; use std::time::Instant; use bytes::BytesMut; -use flate2::write::GzEncoder; use flate2::Compression; +use flate2::write::GzEncoder; +use http::StatusCode; use http::header::ACCEPT; use http::header::CONTENT_ENCODING; use http::header::CONTENT_TYPE; use http::header::RETRY_AFTER; use http::header::USER_AGENT; -use http::StatusCode; use opentelemetry::ExportError; +use parking_lot::Mutex; pub(crate) use prost::*; use reqwest::Client; use serde::ser::SerializeStruct; @@ -103,6 +103,7 @@ impl ApolloExporter { apollo_key: &str, apollo_graph_ref: &str, schema_id: &str, + agent_id: String, metrics_reference_mode: ApolloMetricsReferenceMode, ) -> Result { let header = proto::reports::ReportHeader { @@ -116,6 +117,7 @@ impl ApolloExporter { runtime_version: "rust".to_string(), uname: get_uname()?, executable_schema_id: schema_id.to_string(), + agent_id, ..Default::default() }; @@ -193,7 +195,7 @@ impl ApolloExporter { } // If studio has previously told us not to submit reports, return for further processing - let expires_at = *self.studio_backoff.lock().unwrap(); + let expires_at = *self.studio_backoff.lock(); let now = Instant::now(); if expires_at > now { let remaining = expires_at - now; @@ -261,8 +263,11 @@ impl ApolloExporter { let retries = if has_traces { 5 } else { 1 }; for i in 0..retries { - // We know these requests can be cloned - let task_req = req.try_clone().expect("requests must be clone-able"); + let task_req = req.try_clone().ok_or_else(|| { + ApolloExportError::ServerError( + "Tried to clone a request that cannot be cloned".to_string(), + ) + })?; match self.client.execute(task_req).await { Ok(v) => { let status = v.status(); @@ -292,7 +297,7 @@ impl ApolloExporter { opt_header_retry.and_then(|v| v.to_str().ok()?.parse::().ok()) { retry_after = returned_retry_after; - *self.studio_backoff.lock().unwrap() = + *self.studio_backoff.lock() = Instant::now() + Duration::from_secs(retry_after); } // Even if we can't update the studio_backoff, we should not continue to @@ -316,11 +321,13 @@ impl ApolloExporter { ); if has_traces && !self.strip_traces.load(Ordering::SeqCst) { // If we had traces then maybe disable sending traces from this exporter based on the response. - if let Ok(response) = serde_json::Value::from_str(&data) { - if let Some(Value::Bool(true)) = response.get("tracesIgnored") { - tracing::warn!("traces will not be sent to Apollo as this account is on a free plan"); - self.strip_traces.store(true, Ordering::SeqCst); - } + if let Ok(response) = serde_json::Value::from_str(&data) + && let Some(Value::Bool(true)) = response.get("tracesIgnored") + { + tracing::warn!( + "traces will not be sent to Apollo as this account is on a free plan" + ); + self.strip_traces.store(true, Ordering::SeqCst); } } return Ok(()); diff --git a/apollo-router/src/plugins/telemetry/apollo_otlp_exporter.rs b/apollo-router/src/plugins/telemetry/apollo_otlp_exporter.rs index dd745ef48d..6926d5522e 100644 --- a/apollo-router/src/plugins/telemetry/apollo_otlp_exporter.rs +++ b/apollo-router/src/plugins/telemetry/apollo_otlp_exporter.rs @@ -1,60 +1,57 @@ -use std::borrow::Cow; -use std::sync::Arc; - use derivative::Derivative; +use futures::TryFutureExt; use futures::future; use futures::future::BoxFuture; -use futures::TryFutureExt; -use opentelemetry::sdk::export::trace::ExportResult; -use opentelemetry::sdk::export::trace::SpanData; -use opentelemetry::sdk::export::trace::SpanExporter; -use opentelemetry::sdk::trace::EvictedQueue; -use opentelemetry::sdk::Resource; +use opentelemetry::InstrumentationLibrary; +use opentelemetry::KeyValue; +use opentelemetry::trace::Event; use opentelemetry::trace::SpanContext; use opentelemetry::trace::Status; use opentelemetry::trace::TraceFlags; use opentelemetry::trace::TraceState; -use opentelemetry::InstrumentationLibrary; -use opentelemetry::KeyValue; use opentelemetry_otlp::SpanExporterBuilder; use opentelemetry_otlp::WithExportConfig; -use parking_lot::Mutex; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::export::trace::ExportResult; +use opentelemetry_sdk::export::trace::SpanData; +use opentelemetry_sdk::export::trace::SpanExporter; +use opentelemetry_sdk::trace::SpanEvents; +use opentelemetry_sdk::trace::SpanLinks; use sys_info::hostname; -use tonic::codec::CompressionEncoding; use tonic::metadata::MetadataMap; use tonic::metadata::MetadataValue; +use tonic::transport::ClientTlsConfig; use tower::BoxError; use url::Url; use super::apollo::ErrorsConfiguration; -use super::config_new::attributes::SUBGRAPH_NAME; +use super::config_new::subgraph::attributes::SUBGRAPH_NAME; use super::otlp::Protocol; +use super::tracing::apollo_telemetry::APOLLO_PRIVATE_FTV1; +use super::tracing::apollo_telemetry::LightSpanData; use super::tracing::apollo_telemetry::encode_ftv1_trace; use super::tracing::apollo_telemetry::extract_ftv1_trace_with_error_count; use super::tracing::apollo_telemetry::extract_string; -use super::tracing::apollo_telemetry::LightSpanData; -use super::tracing::apollo_telemetry::APOLLO_PRIVATE_FTV1; +use crate::plugins::telemetry::GLOBAL_TRACER_NAME; use crate::plugins::telemetry::apollo::router_id; -use crate::plugins::telemetry::apollo_exporter::get_uname; use crate::plugins::telemetry::apollo_exporter::ROUTER_REPORT_TYPE_TRACES; use crate::plugins::telemetry::apollo_exporter::ROUTER_TRACING_PROTOCOL_OTLP; +use crate::plugins::telemetry::apollo_exporter::get_uname; use crate::plugins::telemetry::consts::SUBGRAPH_SPAN_NAME; use crate::plugins::telemetry::consts::SUPERGRAPH_SPAN_NAME; -use crate::plugins::telemetry::tracing::apollo_telemetry::APOLLO_PRIVATE_OPERATION_SIGNATURE; use crate::plugins::telemetry::tracing::BatchProcessorConfig; -use crate::plugins::telemetry::GLOBAL_TRACER_NAME; +use crate::plugins::telemetry::tracing::apollo_telemetry::APOLLO_PRIVATE_OPERATION_SIGNATURE; /// The Apollo Otlp exporter is a thin wrapper around the OTLP SpanExporter. -#[derive(Clone, Derivative)] +#[derive(Derivative)] #[derivative(Debug)] pub(crate) struct ApolloOtlpExporter { batch_config: BatchProcessorConfig, endpoint: Url, apollo_key: String, - resource_template: Resource, intrumentation_library: InstrumentationLibrary, #[derivative(Debug = "ignore")] - otlp_exporter: Arc>, + otlp_exporter: opentelemetry_otlp::SpanExporter, errors_configuration: ErrorsConfiguration, } @@ -73,77 +70,54 @@ impl ApolloOtlpExporter { let mut metadata = MetadataMap::new(); metadata.insert("apollo.api.key", MetadataValue::try_from(apollo_key)?); - let otlp_exporter = match protocol { - Protocol::Grpc => { - let mut span_exporter = SpanExporterBuilder::from( - opentelemetry_otlp::new_exporter() - .tonic() - .with_timeout(batch_config.max_export_timeout) - .with_endpoint(endpoint.to_string()) - .with_metadata(metadata) - .with_compression(opentelemetry_otlp::Compression::Gzip), - ) - .build_span_exporter()?; - - // This is a hack and won't be needed anymore once opentelemetry_otlp will be upgraded - span_exporter = if let opentelemetry_otlp::SpanExporter::Tonic { - trace_exporter, - metadata, - timeout, - } = span_exporter - { - opentelemetry_otlp::SpanExporter::Tonic { - timeout, - metadata, - trace_exporter: trace_exporter.accept_compressed(CompressionEncoding::Gzip), - } - } else { - span_exporter - }; - - Arc::new(Mutex::new(span_exporter)) - } + let mut otlp_exporter = match protocol { + Protocol::Grpc => SpanExporterBuilder::from( + opentelemetry_otlp::new_exporter() + .tonic() + .with_tls_config(ClientTlsConfig::new().with_native_roots()) + .with_timeout(batch_config.max_export_timeout) + .with_endpoint(endpoint.to_string()) + .with_metadata(metadata) + .with_compression(opentelemetry_otlp::Compression::Gzip), + ) + .build_span_exporter()?, // So far only using HTTP path for testing - the Studio backend only accepts GRPC today. - Protocol::Http => Arc::new(Mutex::new( - SpanExporterBuilder::from( - opentelemetry_otlp::new_exporter() - .http() - .with_timeout(batch_config.max_export_timeout) - .with_endpoint(endpoint.to_string()), - ) - .build_span_exporter()?, - )), + Protocol::Http => SpanExporterBuilder::from( + opentelemetry_otlp::new_exporter() + .http() + .with_timeout(batch_config.max_export_timeout) + .with_endpoint(endpoint.to_string()), + ) + .build_span_exporter()?, }; + otlp_exporter.set_resource(&Resource::new([ + KeyValue::new("apollo.router.id", router_id()), + KeyValue::new("apollo.graph.ref", apollo_graph_ref.to_string()), + KeyValue::new("apollo.schema.id", schema_id.to_string()), + KeyValue::new( + "apollo.user.agent", + format!( + "{}@{}", + std::env!("CARGO_PKG_NAME"), + std::env!("CARGO_PKG_VERSION") + ), + ), + KeyValue::new("apollo.client.host", hostname()?), + KeyValue::new("apollo.client.uname", get_uname()?), + ])); + Ok(Self { endpoint: endpoint.clone(), batch_config: batch_config.clone(), apollo_key: apollo_key.to_string(), - resource_template: Resource::new([ - KeyValue::new("apollo.router.id", router_id()), - KeyValue::new("apollo.graph.ref", apollo_graph_ref.to_string()), - KeyValue::new("apollo.schema.id", schema_id.to_string()), - KeyValue::new( - "apollo.user.agent", - format!( - "{}@{}", - std::env!("CARGO_PKG_NAME"), - std::env!("CARGO_PKG_VERSION") - ), - ), - KeyValue::new("apollo.client.host", hostname()?), - KeyValue::new("apollo.client.uname", get_uname()?), - ]), - intrumentation_library: InstrumentationLibrary::new( - GLOBAL_TRACER_NAME, - Some(format!( + intrumentation_library: InstrumentationLibrary::builder(GLOBAL_TRACER_NAME) + .with_version(format!( "{}@{}", std::env!("CARGO_PKG_NAME"), std::env!("CARGO_PKG_VERSION") - )), - Option::::None, - None, - ), + )) + .build(), otlp_exporter, errors_configuration: errors_configuration.clone(), }) @@ -162,8 +136,7 @@ impl ApolloOtlpExporter { SUPERGRAPH_SPAN_NAME => { if span .attributes - .get(&APOLLO_PRIVATE_OPERATION_SIGNATURE) - .is_some() + .contains_key(&APOLLO_PRIVATE_OPERATION_SIGNATURE) { export_spans.push(self.base_prepare_span(span)); // Mirrors the existing implementation in apollo_telemetry @@ -185,6 +158,23 @@ impl ApolloOtlpExporter { } } + fn extract_span_events(span: &LightSpanData) -> SpanEvents { + let mut span_events = SpanEvents::default(); + for light_event in &span.events { + span_events.events.push(Event::new( + light_event.name.clone(), + light_event.timestamp, + light_event + .attributes + .iter() + .map(|(k, v)| KeyValue::new(k.clone(), v.clone())) + .collect(), + 0, + )); + } + span_events + } + fn base_prepare_span(&self, span: LightSpanData) -> SpanData { SpanData { span_context: SpanContext::new( @@ -199,15 +189,16 @@ impl ApolloOtlpExporter { name: span.name.clone(), start_time: span.start_time, end_time: span.end_time, - attributes: span.attributes, - events: EvictedQueue::new(0), - links: EvictedQueue::new(0), + attributes: span + .attributes + .iter() + .map(|(k, v)| KeyValue::new(k.clone(), v.clone())) + .collect(), + events: Self::extract_span_events(&span), + links: SpanLinks::default(), status: span.status, - // If the underlying exporter supported it, we could - // group by resource attributes here and significantly reduce the - // duplicate resource / scope data that will get sent on every span. - resource: Cow::Owned(self.resource_template.to_owned()), instrumentation_lib: self.intrumentation_library.clone(), + dropped_attributes_count: span.droppped_attribute_count, } } @@ -234,8 +225,7 @@ impl ApolloOtlpExporter { status = Status::error("ftv1") } let encoded = encode_ftv1_trace(&trace_result); - span.attributes - .insert(KeyValue::new(APOLLO_PRIVATE_FTV1, encoded)); + span.attributes.insert(APOLLO_PRIVATE_FTV1, encoded.into()); } } @@ -252,19 +242,21 @@ impl ApolloOtlpExporter { name: span.name.clone(), start_time: span.start_time, end_time: span.end_time, - attributes: span.attributes, - events: EvictedQueue::new(0), - links: EvictedQueue::new(0), + attributes: span + .attributes + .iter() + .map(|(k, v)| KeyValue::new(k.clone(), v.clone())) + .collect(), + events: Self::extract_span_events(&span), + links: SpanLinks::default(), status, - resource: Cow::Owned(self.resource_template.to_owned()), instrumentation_lib: self.intrumentation_library.clone(), + dropped_attributes_count: span.droppped_attribute_count, } } - pub(crate) fn export(&self, spans: Vec) -> BoxFuture<'static, ExportResult> { - let mut exporter = self.otlp_exporter.lock(); - let fut = exporter.export(spans); - drop(exporter); + pub(crate) fn export(&mut self, spans: Vec) -> BoxFuture<'static, ExportResult> { + let fut = self.otlp_exporter.export(spans); Box::pin(fut.and_then(|_| { // re-use the metric we already have in apollo_exporter but attach the protocol u64_counter!( @@ -278,8 +270,7 @@ impl ApolloOtlpExporter { })) } - pub(crate) fn shutdown(&self) { - let mut exporter = self.otlp_exporter.lock(); - exporter.shutdown() + pub(crate) fn shutdown(&mut self) { + self.otlp_exporter.shutdown() } } diff --git a/apollo-router/src/plugins/telemetry/config.rs b/apollo-router/src/plugins/telemetry/config.rs index 4c9be01135..9d5797fb9d 100644 --- a/apollo-router/src/plugins/telemetry/config.rs +++ b/apollo-router/src/plugins/telemetry/config.rs @@ -2,33 +2,35 @@ use std::collections::BTreeMap; use std::collections::HashSet; -use axum::headers::HeaderName; +use axum_extra::headers::HeaderName; use derivative::Derivative; use num_traits::ToPrimitive; -use opentelemetry::sdk::metrics::new_view; -use opentelemetry::sdk::metrics::Aggregation; -use opentelemetry::sdk::metrics::Instrument; -use opentelemetry::sdk::metrics::Stream; -use opentelemetry::sdk::metrics::View; -use opentelemetry::sdk::trace::SpanLimits; use opentelemetry::Array; use opentelemetry::Value; -use opentelemetry_api::metrics::MetricsError; -use opentelemetry_api::metrics::Unit; +use opentelemetry::metrics::MetricsError; +use opentelemetry_sdk::metrics::Aggregation; +use opentelemetry_sdk::metrics::Instrument; +use opentelemetry_sdk::metrics::Stream; +use opentelemetry_sdk::metrics::View; +use opentelemetry_sdk::metrics::new_view; +use opentelemetry_sdk::trace::SpanLimits; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; -use super::metrics::MetricsAttributesConf; use super::*; +use crate::Configuration; use crate::plugin::serde::deserialize_option_header_name; +use crate::plugins::telemetry::apollo::Config as ApolloTelemetryConfig; use crate::plugins::telemetry::metrics; use crate::plugins::telemetry::resource::ConfigResource; -use crate::Configuration; +use crate::plugins::telemetry::tracing::datadog::DatadogAgentSampling; #[derive(thiserror::Error, Debug)] pub(crate) enum Error { - #[error("field level instrumentation sampler must sample less frequently than tracing level sampler")] + #[error( + "field level instrumentation sampler must sample less frequently than tracing level sampler" + )] InvalidFieldLevelInstrumentationSampler, } @@ -42,16 +44,6 @@ where } self } - fn try_with( - self, - option: &Option, - apply: fn(Self, &B) -> Result, - ) -> Result { - if let Some(option) = option { - return apply(self, option); - } - Ok(self) - } } impl GenericWith for T where Self: Sized {} @@ -117,8 +109,6 @@ pub(crate) struct Metrics { #[derive(Clone, Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields, default)] pub(crate) struct MetricsCommon { - /// Configuration to add custom labels/attributes to metrics - pub(crate) attributes: MetricsAttributesConf, /// Set a service.name resource in your metrics pub(crate) service_name: Option, /// Set a service.namespace attribute in your metrics @@ -134,7 +124,6 @@ pub(crate) struct MetricsCommon { impl Default for MetricsCommon { fn default() -> Self { Self { - attributes: Default::default(), service_name: None, service_namespace: None, resource: BTreeMap::new(), @@ -182,7 +171,7 @@ impl TryInto> for MetricView { mask = mask.description(desc); } if let Some(unit) = self.unit { - mask = mask.unit(Unit::new(unit)); + mask = mask.unit(unit); } if let Some(aggregation) = aggregation { mask = mask.aggregation(aggregation); @@ -220,8 +209,6 @@ pub(crate) struct Tracing { pub(crate) common: TracingCommon, /// OpenTelemetry native exporter configuration pub(crate) otlp: otlp::Config, - /// Jaeger exporter configuration - pub(crate) jaeger: tracing::jaeger::Config, /// Zipkin exporter configuration pub(crate) zipkin: tracing::zipkin::Config, /// Datadog exporter configuration @@ -270,7 +257,7 @@ impl TraceIdFormat { pub(crate) fn format(&self, trace_id: TraceId) -> String { match self { TraceIdFormat::Hexadecimal | TraceIdFormat::OpenTelemetry => { - format!("{:032x}", trace_id) + format!("{trace_id:032x}") } TraceIdFormat::Decimal => format!("{}", u128::from_be_bytes(trace_id.to_bytes())), TraceIdFormat::Datadog => trace_id.to_datadog(), @@ -285,10 +272,10 @@ impl TraceIdFormat { #[serde(deny_unknown_fields, rename_all = "lowercase")] pub(crate) enum ApolloSignatureNormalizationAlgorithm { /// Use the algorithm that matches the JavaScript-based implementation. - #[default] Legacy, /// Use a new algorithm that includes input object forms, normalized aliases and variable names, and removes some /// edge cases from the JS implementation that affected normalization. + #[default] Enhanced, } @@ -297,9 +284,9 @@ pub(crate) enum ApolloSignatureNormalizationAlgorithm { #[serde(deny_unknown_fields, rename_all = "lowercase")] pub(crate) enum ApolloMetricsReferenceMode { /// Use the extended mode to report input object fields and enum value references as well as object fields. + #[default] Extended, /// Use the standard mode that only reports referenced object fields. - #[default] Standard, } @@ -347,6 +334,9 @@ pub(crate) struct TracingCommon { pub(crate) service_namespace: Option, /// The sampler, always_on, always_off or a decimal between 0.0 and 1.0 pub(crate) sampler: SamplerOption, + /// Use datadog agent sampling. This means that all spans will be sent to the Datadog agent + /// and the `sampling.priority` attribute will be used to control if the span will then be sent to Datadog + pub(crate) preview_datadog_agent_sampling: Option, /// Whether to use parent based sampling pub(crate) parent_based_sampler: bool, /// The maximum events per span before discarding @@ -401,6 +391,7 @@ impl Default for TracingCommon { service_name: Default::default(), service_namespace: Default::default(), sampler: default_sampler(), + preview_datadog_agent_sampling: None, parent_based_sampler: default_parent_based_sampler(), max_events_per_span: default_max_events_per_span(), max_attributes_per_span: default_max_attributes_per_span(), @@ -430,7 +421,8 @@ fn default_max_attributes_per_link() -> u32 { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(untagged, deny_unknown_fields)] -pub(crate) enum AttributeValue { +#[allow(missing_docs)] // only public-but-hidden for tests +pub enum AttributeValue { /// bool values Bool(bool), /// i64 values @@ -440,6 +432,7 @@ pub(crate) enum AttributeValue { /// String values String(String), /// Array of homogeneous values + #[allow(private_interfaces)] Array(AttributeArray), } @@ -640,36 +633,43 @@ pub(crate) enum Sampler { AlwaysOff, } -impl From for opentelemetry::sdk::trace::Sampler { +impl From for opentelemetry_sdk::trace::Sampler { fn from(s: Sampler) -> Self { match s { - Sampler::AlwaysOn => opentelemetry::sdk::trace::Sampler::AlwaysOn, - Sampler::AlwaysOff => opentelemetry::sdk::trace::Sampler::AlwaysOff, + Sampler::AlwaysOn => opentelemetry_sdk::trace::Sampler::AlwaysOn, + Sampler::AlwaysOff => opentelemetry_sdk::trace::Sampler::AlwaysOff, } } } -impl From for opentelemetry::sdk::trace::Sampler { +impl From for opentelemetry_sdk::trace::Sampler { fn from(s: SamplerOption) -> Self { match s { SamplerOption::Always(s) => s.into(), SamplerOption::TraceIdRatioBased(ratio) => { - opentelemetry::sdk::trace::Sampler::TraceIdRatioBased(ratio) + opentelemetry_sdk::trace::Sampler::TraceIdRatioBased(ratio) } } } } -impl From<&TracingCommon> for opentelemetry::sdk::trace::Config { +impl From<&TracingCommon> for opentelemetry_sdk::trace::Config { fn from(config: &TracingCommon) -> Self { - let mut common = opentelemetry::sdk::trace::config(); + let mut common = opentelemetry_sdk::trace::Config::default(); - let mut sampler: opentelemetry::sdk::trace::Sampler = config.sampler.clone().into(); + let mut sampler: opentelemetry_sdk::trace::Sampler = config.sampler.clone().into(); if config.parent_based_sampler { sampler = parent_based(sampler); } + if config.preview_datadog_agent_sampling.unwrap_or_default() { + common = common.with_sampler(DatadogAgentSampling::new( + sampler, + config.parent_based_sampler, + )); + } else { + common = common.with_sampler(sampler); + } - common = common.with_sampler(sampler); common = common.with_max_events_per_span(config.max_events_per_span); common = common.with_max_attributes_per_span(config.max_attributes_per_span); common = common.with_max_links_per_span(config.max_links_per_span); @@ -682,12 +682,28 @@ impl From<&TracingCommon> for opentelemetry::sdk::trace::Config { } } -fn parent_based(sampler: opentelemetry::sdk::trace::Sampler) -> opentelemetry::sdk::trace::Sampler { - opentelemetry::sdk::trace::Sampler::ParentBased(Box::new(sampler)) +fn parent_based(sampler: opentelemetry_sdk::trace::Sampler) -> opentelemetry_sdk::trace::Sampler { + opentelemetry_sdk::trace::Sampler::ParentBased(Box::new(sampler)) } impl Conf { pub(crate) fn calculate_field_level_instrumentation_ratio(&self) -> Result { + // Because when Datadog is enabled the global sampling is overridden to always_on + if self + .exporters + .tracing + .common + .preview_datadog_agent_sampling + .unwrap_or_default() + { + let field_ratio = match &self.apollo.field_level_instrumentation_sampler { + SamplerOption::TraceIdRatioBased(ratio) => *ratio, + SamplerOption::Always(Sampler::AlwaysOn) => 1.0, + SamplerOption::Always(Sampler::AlwaysOff) => 0.0, + }; + + return Ok(field_ratio); + } Ok( match ( &self.exporters.tracing.common.sampler, @@ -766,6 +782,18 @@ impl Conf { _ => ApolloSignatureNormalizationAlgorithm::default(), } } + + pub(crate) fn apollo(configuration: &Configuration) -> ApolloTelemetryConfig { + match configuration.apollo_plugins.plugins.get("telemetry") { + Some(telemetry_config) => { + match serde_json::from_value::(telemetry_config.clone()) { + Ok(conf) => conf.apollo, + _ => ApolloTelemetryConfig::default(), + } + } + _ => ApolloTelemetryConfig::default(), + } + } } #[cfg(test)] diff --git a/apollo-router/src/plugins/telemetry/config_new/apollo/instruments.rs b/apollo-router/src/plugins/telemetry/config_new/apollo/instruments.rs new file mode 100644 index 0000000000..0be663c45c --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/apollo/instruments.rs @@ -0,0 +1,312 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use opentelemetry::metrics::MeterProvider; +use tokio::time::Instant; +use tower::BoxError; + +use crate::Context; +use crate::metrics; +use crate::plugins::telemetry::APOLLO_CLIENT_NAME_ATTRIBUTE; +use crate::plugins::telemetry::APOLLO_CLIENT_VERSION_ATTRIBUTE; +use crate::plugins::telemetry::APOLLO_HAS_ERRORS_ATTRIBUTE; +use crate::plugins::telemetry::APOLLO_OPERATION_ID_ATTRIBUTE; +use crate::plugins::telemetry::CLIENT_NAME; +use crate::plugins::telemetry::CLIENT_VERSION; +use crate::plugins::telemetry::GRAPHQL_OPERATION_NAME_ATTRIBUTE; +use crate::plugins::telemetry::GRAPHQL_OPERATION_TYPE_ATTRIBUTE; +use crate::plugins::telemetry::apollo::Config; +use crate::plugins::telemetry::config_new::attributes::StandardAttribute; +use crate::plugins::telemetry::config_new::connector::ConnectorRequest; +use crate::plugins::telemetry::config_new::connector::ConnectorResponse; +use crate::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes; +use crate::plugins::telemetry::config_new::connector::selectors::ConnectorSelector; +use crate::plugins::telemetry::config_new::extendable::Extendable; +use crate::plugins::telemetry::config_new::instruments::APOLLO_ROUTER_OPERATIONS_FETCH_DURATION; +use crate::plugins::telemetry::config_new::instruments::CustomHistogram; +use crate::plugins::telemetry::config_new::instruments::Increment; +use crate::plugins::telemetry::config_new::instruments::Instrumented; +use crate::plugins::telemetry::config_new::instruments::METER_NAME; +use crate::plugins::telemetry::config_new::instruments::StaticInstrument; +use crate::plugins::telemetry::config_new::selectors::OperationKind; +use crate::plugins::telemetry::config_new::selectors::OperationName; +use crate::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes; +use crate::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector; +use crate::query_planner::APOLLO_OPERATION_ID; +use crate::services::subgraph; + +pub(crate) struct ApolloSubgraphInstruments { + pub(crate) apollo_router_operations_fetch_duration: Option< + CustomHistogram< + subgraph::Request, + subgraph::Response, + (), + SubgraphAttributes, + SubgraphSelector, + >, + >, +} + +pub(crate) struct ApolloConnectorInstruments { + pub(crate) apollo_router_operations_fetch_duration: Option< + CustomHistogram< + ConnectorRequest, + ConnectorResponse, + (), + ConnectorAttributes, + ConnectorSelector, + >, + >, +} + +impl ApolloSubgraphInstruments { + pub(crate) fn new( + static_instruments: Arc>, + apollo_config: Config, + ) -> Self { + let selectors = Extendable { + attributes: SubgraphAttributes::builder() + .subgraph_name(StandardAttribute::Bool(true)) + .build(), + custom: HashMap::from([ + ( + APOLLO_CLIENT_NAME_ATTRIBUTE.to_string(), + SubgraphSelector::ResponseContext { + response_context: CLIENT_NAME.to_string(), + redact: None, + default: None, + }, + ), + ( + APOLLO_CLIENT_VERSION_ATTRIBUTE.to_string(), + SubgraphSelector::ResponseContext { + response_context: CLIENT_VERSION.to_string(), + redact: None, + default: None, + }, + ), + ( + GRAPHQL_OPERATION_NAME_ATTRIBUTE.to_string(), + SubgraphSelector::SupergraphOperationName { + supergraph_operation_name: OperationName::String, + redact: None, + default: None, + }, + ), + ( + GRAPHQL_OPERATION_TYPE_ATTRIBUTE.to_string(), + SubgraphSelector::SupergraphOperationKind { + supergraph_operation_kind: OperationKind::String, + }, + ), + ( + APOLLO_OPERATION_ID_ATTRIBUTE.to_string(), + SubgraphSelector::ResponseContext { + response_context: APOLLO_OPERATION_ID.to_string(), + redact: None, + default: None, + }, + ), + ( + APOLLO_HAS_ERRORS_ATTRIBUTE.to_string(), + SubgraphSelector::OnGraphQLError { + subgraph_on_graphql_error: true, + }, + ), + ]), + }; + let attribute_count = selectors.custom.len() + 1; // 1 for subgraph_name on attributes + + let apollo_router_operations_fetch_duration = + apollo_config.experimental_subgraph_metrics.then(|| { + CustomHistogram::builder() + .increment(Increment::Duration(Instant::now())) + .attributes(Vec::with_capacity(attribute_count)) + .selectors(Arc::new(selectors)) + .histogram(static_instruments + .get(APOLLO_ROUTER_OPERATIONS_FETCH_DURATION) + .expect( + "cannot get apollo static instrument for subgraph; this should not happen", + ) + .as_histogram() + .cloned() + .expect( + "cannot convert apollo instrument to histogram for subgraph; this should not happen", + ) + ) + .build() + }); + + Self { + apollo_router_operations_fetch_duration, + } + } + + pub(crate) fn new_builtin() -> HashMap { + create_subgraph_and_connector_shared_static_instruments() + } +} + +impl Instrumented for ApolloSubgraphInstruments { + type Request = subgraph::Request; + type Response = subgraph::Response; + type EventResponse = (); + + fn on_request(&self, request: &Self::Request) { + if let Some(apollo_router_operations_fetch_duration) = + &self.apollo_router_operations_fetch_duration + { + apollo_router_operations_fetch_duration.on_request(request); + } + } + + fn on_response(&self, response: &Self::Response) { + if let Some(apollo_router_operations_fetch_duration) = + &self.apollo_router_operations_fetch_duration + { + apollo_router_operations_fetch_duration.on_response(response); + } + } + + fn on_error(&self, error: &BoxError, ctx: &Context) { + if let Some(apollo_router_operations_fetch_duration) = + &self.apollo_router_operations_fetch_duration + { + apollo_router_operations_fetch_duration.on_error(error, ctx); + } + } +} + +impl ApolloConnectorInstruments { + pub(crate) fn new( + static_instruments: Arc>, + apollo_config: Config, + ) -> Self { + let selectors = Extendable { + attributes: ConnectorAttributes::builder() + .subgraph_name(StandardAttribute::Bool(true)) + .build(), + custom: HashMap::from([ + ( + APOLLO_CLIENT_NAME_ATTRIBUTE.to_string(), + ConnectorSelector::RequestContext { + request_context: CLIENT_NAME.to_string(), + redact: None, + default: None, + }, + ), + ( + APOLLO_CLIENT_VERSION_ATTRIBUTE.to_string(), + ConnectorSelector::RequestContext { + request_context: CLIENT_VERSION.to_string(), + redact: None, + default: None, + }, + ), + ( + GRAPHQL_OPERATION_NAME_ATTRIBUTE.to_string(), + ConnectorSelector::SupergraphOperationName { + supergraph_operation_name: OperationName::String, + redact: None, + default: None, + }, + ), + ( + GRAPHQL_OPERATION_TYPE_ATTRIBUTE.to_string(), + ConnectorSelector::SupergraphOperationKind { + supergraph_operation_kind: OperationKind::String, + }, + ), + ( + APOLLO_OPERATION_ID_ATTRIBUTE.to_string(), + ConnectorSelector::RequestContext { + request_context: APOLLO_OPERATION_ID.to_string(), + redact: None, + default: None, + }, + ), + ( + APOLLO_HAS_ERRORS_ATTRIBUTE.to_string(), + ConnectorSelector::OnResponseError { + connector_on_response_error: true, + }, + ), + ]), + }; + let attribute_count = selectors.custom.len() + 1; // 1 for subgraph_name on attributes + + let apollo_router_operations_fetch_duration = + apollo_config.experimental_subgraph_metrics.then(|| { + CustomHistogram::builder() + .increment(Increment::Duration(Instant::now())) + .attributes(Vec::with_capacity(attribute_count)) + .selectors(Arc::new(selectors)) + .histogram(static_instruments + .get(APOLLO_ROUTER_OPERATIONS_FETCH_DURATION) + .expect( + "cannot get apollo static instrument for subgraph; this should not happen", + ) + .as_histogram() + .cloned() + .expect( + "cannot convert apollo instrument to histogram for subgraph; this should not happen", + ) + ) + .build() + }); + + Self { + apollo_router_operations_fetch_duration, + } + } + + pub(crate) fn new_builtin() -> HashMap { + create_subgraph_and_connector_shared_static_instruments() + } +} + +impl Instrumented for ApolloConnectorInstruments { + type Request = ConnectorRequest; + type Response = ConnectorResponse; + type EventResponse = (); + + fn on_request(&self, request: &Self::Request) { + if let Some(apollo_router_operations_fetch_duration) = + &self.apollo_router_operations_fetch_duration + { + apollo_router_operations_fetch_duration.on_request(request); + } + } + + fn on_response(&self, response: &Self::Response) { + if let Some(apollo_router_operations_fetch_duration) = + &self.apollo_router_operations_fetch_duration + { + apollo_router_operations_fetch_duration.on_response(response); + } + } + + fn on_error(&self, error: &BoxError, ctx: &Context) { + if let Some(apollo_router_operations_fetch_duration) = + &self.apollo_router_operations_fetch_duration + { + apollo_router_operations_fetch_duration.on_error(error, ctx); + } + } +} + +fn create_subgraph_and_connector_shared_static_instruments() -> HashMap { + let meter = metrics::meter_provider().meter(METER_NAME); + let mut static_instruments = HashMap::with_capacity(1); + static_instruments.insert( + APOLLO_ROUTER_OPERATIONS_FETCH_DURATION.to_string(), + StaticInstrument::Histogram( + meter + .f64_histogram(APOLLO_ROUTER_OPERATIONS_FETCH_DURATION) + .with_unit("s") + .with_description("Duration of a subgraph fetch.") + .init(), + ), + ); + static_instruments +} diff --git a/apollo-router/src/plugins/telemetry/config_new/apollo/mod.rs b/apollo-router/src/plugins/telemetry/config_new/apollo/mod.rs new file mode 100644 index 0000000000..0cac8eccc3 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/apollo/mod.rs @@ -0,0 +1 @@ +pub(crate) mod instruments; diff --git a/apollo-router/src/plugins/telemetry/config_new/attributes.rs b/apollo-router/src/plugins/telemetry/config_new/attributes.rs index 53640cd87b..41fc78ddcd 100644 --- a/apollo-router/src/plugins/telemetry/config_new/attributes.rs +++ b/apollo-router/src/plugins/telemetry/config_new/attributes.rs @@ -1,69 +1,28 @@ use std::fmt::Debug; -use std::net::SocketAddr; -use http::header::CONTENT_LENGTH; -use http::header::FORWARDED; -use http::header::USER_AGENT; -use http::StatusCode; -use http::Uri; use opentelemetry::Key; -use opentelemetry::KeyValue; -use opentelemetry_api::baggage::BaggageExt; -use opentelemetry_semantic_conventions::trace::CLIENT_ADDRESS; -use opentelemetry_semantic_conventions::trace::CLIENT_PORT; -use opentelemetry_semantic_conventions::trace::GRAPHQL_DOCUMENT; -use opentelemetry_semantic_conventions::trace::GRAPHQL_OPERATION_NAME; -use opentelemetry_semantic_conventions::trace::GRAPHQL_OPERATION_TYPE; -use opentelemetry_semantic_conventions::trace::HTTP_REQUEST_BODY_SIZE; -use opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD; -use opentelemetry_semantic_conventions::trace::HTTP_RESPONSE_BODY_SIZE; -use opentelemetry_semantic_conventions::trace::HTTP_RESPONSE_STATUS_CODE; -use opentelemetry_semantic_conventions::trace::HTTP_ROUTE; -use opentelemetry_semantic_conventions::trace::NETWORK_PROTOCOL_NAME; -use opentelemetry_semantic_conventions::trace::NETWORK_PROTOCOL_VERSION; -use opentelemetry_semantic_conventions::trace::NETWORK_TRANSPORT; -use opentelemetry_semantic_conventions::trace::NETWORK_TYPE; -use opentelemetry_semantic_conventions::trace::SERVER_ADDRESS; -use opentelemetry_semantic_conventions::trace::SERVER_PORT; -use opentelemetry_semantic_conventions::trace::URL_PATH; -use opentelemetry_semantic_conventions::trace::URL_QUERY; -use opentelemetry_semantic_conventions::trace::URL_SCHEME; -use opentelemetry_semantic_conventions::trace::USER_AGENT_ORIGINAL; use schemars::JsonSchema; use serde::Deserialize; -use tower::BoxError; -use tracing::Span; -use crate::axum_factory::utils::ConnectionInfo; -use crate::context::OPERATION_KIND; -use crate::context::OPERATION_NAME; -use crate::plugins::telemetry::config_new::cost::SupergraphCostAttributes; -use crate::plugins::telemetry::config_new::trace_id; -use crate::plugins::telemetry::config_new::DatadogId; -use crate::plugins::telemetry::config_new::DefaultForLevel; -use crate::plugins::telemetry::config_new::Selectors; -use crate::plugins::telemetry::otel::OpenTelemetrySpanExt; -use crate::plugins::telemetry::otlp::TelemetryDataKind; -use crate::services::router; -use crate::services::router::Request; -use crate::services::subgraph; -use crate::services::supergraph; -use crate::Context; +pub(crate) const HTTP_REQUEST_RESEND_COUNT: Key = Key::from_static_str("http.request.resend_count"); -pub(crate) const SUBGRAPH_NAME: Key = Key::from_static_str("subgraph.name"); -pub(crate) const SUBGRAPH_GRAPHQL_DOCUMENT: Key = Key::from_static_str("subgraph.graphql.document"); -pub(crate) const SUBGRAPH_GRAPHQL_OPERATION_NAME: Key = - Key::from_static_str("subgraph.graphql.operation.name"); -pub(crate) const SUBGRAPH_GRAPHQL_OPERATION_TYPE: Key = - Key::from_static_str("subgraph.graphql.operation.type"); +pub(crate) const ERROR_TYPE: Key = Key::from_static_str("error.type"); -const ERROR_TYPE: Key = Key::from_static_str("error.type"); +pub(crate) const NETWORK_LOCAL_ADDRESS: Key = Key::from_static_str("network.local.address"); +pub(crate) const NETWORK_LOCAL_PORT: Key = Key::from_static_str("network.local.port"); -const NETWORK_LOCAL_ADDRESS: Key = Key::from_static_str("network.local.address"); -const NETWORK_LOCAL_PORT: Key = Key::from_static_str("network.local.port"); +pub(crate) const NETWORK_PEER_ADDRESS: Key = Key::from_static_str("network.peer.address"); +pub(crate) const NETWORK_PEER_PORT: Key = Key::from_static_str("network.peer.port"); -const NETWORK_PEER_ADDRESS: Key = Key::from_static_str("network.peer.address"); -const NETWORK_PEER_PORT: Key = Key::from_static_str("network.peer.port"); +pub(crate) const HTTP_REQUEST_HEADERS: Key = Key::from_static_str("http.request.headers"); +pub(crate) const HTTP_REQUEST_URI: Key = Key::from_static_str("http.request.uri"); +pub(crate) const HTTP_REQUEST_VERSION: Key = Key::from_static_str("http.request.version"); +pub(crate) const HTTP_REQUEST_BODY: Key = Key::from_static_str("http.request.body"); + +pub(crate) const HTTP_RESPONSE_HEADERS: Key = Key::from_static_str("http.response.headers"); +pub(crate) const HTTP_RESPONSE_STATUS: Key = Key::from_static_str("http.response.status"); +pub(crate) const HTTP_RESPONSE_VERSION: Key = Key::from_static_str("http.response.version"); +pub(crate) const HTTP_RESPONSE_BODY: Key = Key::from_static_str("http.response.body"); #[derive(Deserialize, JsonSchema, Clone, Debug, Default, Copy)] #[serde(deny_unknown_fields, rename_all = "snake_case")] @@ -93,2005 +52,3 @@ impl StandardAttribute { } } } - -#[derive(Deserialize, JsonSchema, Clone, Default, Debug)] -#[cfg_attr(test, derive(PartialEq))] -#[serde(deny_unknown_fields, default)] -pub(crate) struct RouterAttributes { - /// The datadog trace ID. - /// This can be output in logs and used to correlate traces in Datadog. - #[serde(rename = "dd.trace_id")] - pub(crate) datadog_trace_id: Option, - - /// The OpenTelemetry trace ID. - /// This can be output in logs. - pub(crate) trace_id: Option, - - /// All key values from trace baggage. - pub(crate) baggage: Option, - - /// Http attributes from Open Telemetry semantic conventions. - #[serde(flatten)] - pub(crate) common: HttpCommonAttributes, - /// Http server attributes from Open Telemetry semantic conventions. - #[serde(flatten)] - pub(crate) server: HttpServerAttributes, -} - -impl DefaultForLevel for RouterAttributes { - fn defaults_for_level( - &mut self, - requirement_level: DefaultAttributeRequirementLevel, - kind: TelemetryDataKind, - ) { - self.common.defaults_for_level(requirement_level, kind); - self.server.defaults_for_level(requirement_level, kind); - } -} - -#[derive(Deserialize, JsonSchema, Clone, Default, Debug)] -#[cfg_attr(test, derive(PartialEq))] -#[serde(deny_unknown_fields, default)] -pub(crate) struct SupergraphAttributes { - /// The GraphQL document being executed. - /// Examples: - /// - /// * `query findBookById { bookById(id: ?) { name } }` - /// - /// Requirement level: Recommended - #[serde(rename = "graphql.document")] - pub(crate) graphql_document: Option, - - /// The name of the operation being executed. - /// Examples: - /// - /// * findBookById - /// - /// Requirement level: Recommended - #[serde(rename = "graphql.operation.name")] - pub(crate) graphql_operation_name: Option, - - /// The type of the operation being executed. - /// Examples: - /// - /// * query - /// * subscription - /// * mutation - /// - /// Requirement level: Recommended - #[serde(rename = "graphql.operation.type")] - pub(crate) graphql_operation_type: Option, - - /// Cost attributes for the operation being executed - #[serde(flatten)] - pub(crate) cost: SupergraphCostAttributes, -} - -impl DefaultForLevel for SupergraphAttributes { - fn defaults_for_level( - &mut self, - requirement_level: DefaultAttributeRequirementLevel, - _kind: TelemetryDataKind, - ) { - match requirement_level { - DefaultAttributeRequirementLevel::Required => {} - DefaultAttributeRequirementLevel::Recommended => { - if self.graphql_document.is_none() { - self.graphql_document = Some(StandardAttribute::Bool(true)); - } - if self.graphql_operation_name.is_none() { - self.graphql_operation_name = Some(StandardAttribute::Bool(true)); - } - if self.graphql_operation_type.is_none() { - self.graphql_operation_type = Some(StandardAttribute::Bool(true)); - } - } - DefaultAttributeRequirementLevel::None => {} - } - } -} - -#[derive(Deserialize, JsonSchema, Clone, Default, Debug)] -#[serde(deny_unknown_fields, default)] -pub(crate) struct SubgraphAttributes { - /// The name of the subgraph - /// Examples: - /// - /// * products - /// - /// Requirement level: Required - #[serde(rename = "subgraph.name")] - subgraph_name: Option, - - /// The GraphQL document being executed. - /// Examples: - /// - /// * `query findBookById { bookById(id: ?) { name } }` - /// - /// Requirement level: Recommended - #[serde(rename = "subgraph.graphql.document")] - graphql_document: Option, - - /// The name of the operation being executed. - /// Examples: - /// - /// * findBookById - /// - /// Requirement level: Recommended - #[serde(rename = "subgraph.graphql.operation.name")] - graphql_operation_name: Option, - - /// The type of the operation being executed. - /// Examples: - /// - /// * query - /// * subscription - /// * mutation - /// - /// Requirement level: Recommended - #[serde(rename = "subgraph.graphql.operation.type")] - graphql_operation_type: Option, -} - -impl DefaultForLevel for SubgraphAttributes { - fn defaults_for_level( - &mut self, - requirement_level: DefaultAttributeRequirementLevel, - _kind: TelemetryDataKind, - ) { - match requirement_level { - DefaultAttributeRequirementLevel::Required => { - if self.subgraph_name.is_none() { - self.subgraph_name = Some(StandardAttribute::Bool(true)); - } - } - DefaultAttributeRequirementLevel::Recommended => { - if self.subgraph_name.is_none() { - self.subgraph_name = Some(StandardAttribute::Bool(true)); - } - if self.graphql_document.is_none() { - self.graphql_document = Some(StandardAttribute::Bool(true)); - } - if self.graphql_operation_name.is_none() { - self.graphql_operation_name = Some(StandardAttribute::Bool(true)); - } - if self.graphql_operation_type.is_none() { - self.graphql_operation_type = Some(StandardAttribute::Bool(true)); - } - } - DefaultAttributeRequirementLevel::None => {} - } - } -} - -/// Common attributes for http server and client. -/// See https://opentelemetry.io/docs/specs/semconv/http/http-spans/#common-attributes -#[derive(Deserialize, JsonSchema, Clone, Default, Debug)] -#[cfg_attr(test, derive(PartialEq))] -#[serde(deny_unknown_fields, default)] -pub(crate) struct HttpCommonAttributes { - /// Describes a class of error the operation ended with. - /// Examples: - /// - /// * timeout - /// * name_resolution_error - /// * 500 - /// - /// Requirement level: Conditionally Required: If request has ended with an error. - #[serde(rename = "error.type")] - pub(crate) error_type: Option, - - /// The size of the request payload body in bytes. This is the number of bytes transferred excluding headers and is often, but not always, present as the Content-Length header. For requests using transport encoding, this should be the compressed size. - /// Examples: - /// - /// * 3495 - /// - /// Requirement level: Recommended - #[serde(rename = "http.request.body.size")] - pub(crate) http_request_body_size: Option, - - /// HTTP request method. - /// Examples: - /// - /// * GET - /// * POST - /// * HEAD - /// - /// Requirement level: Required - #[serde(rename = "http.request.method")] - pub(crate) http_request_method: Option, - - /// Original HTTP method sent by the client in the request line. - /// Examples: - /// - /// * GeT - /// * ACL - /// * foo - /// - /// Requirement level: Conditionally Required (If and only if it’s different than http.request.method) - #[serde(rename = "http.request.method.original", skip)] - pub(crate) http_request_method_original: Option, - - /// The size of the response payload body in bytes. This is the number of bytes transferred excluding headers and is often, but not always, present as the Content-Length header. For requests using transport encoding, this should be the compressed size. - /// Examples: - /// - /// * 3495 - /// - /// Requirement level: Recommended - #[serde(rename = "http.response.body.size")] - pub(crate) http_response_body_size: Option, - - /// HTTP response status code. - /// Examples: - /// - /// * 200 - /// - /// Requirement level: Conditionally Required: If and only if one was received/sent. - #[serde(rename = "http.response.status_code")] - pub(crate) http_response_status_code: Option, - - /// OSI application layer or non-OSI equivalent. - /// Examples: - /// - /// * http - /// * spdy - /// - /// Requirement level: Recommended: if not default (http). - #[serde(rename = "network.protocol.name")] - pub(crate) network_protocol_name: Option, - - /// Version of the protocol specified in network.protocol.name. - /// Examples: - /// - /// * 1.0 - /// * 1.1 - /// * 2 - /// * 3 - /// - /// Requirement level: Recommended - #[serde(rename = "network.protocol.version")] - pub(crate) network_protocol_version: Option, - - /// OSI transport layer. - /// Examples: - /// - /// * tcp - /// * udp - /// - /// Requirement level: Conditionally Required - #[serde(rename = "network.transport")] - pub(crate) network_transport: Option, - - /// OSI network layer or non-OSI equivalent. - /// Examples: - /// - /// * ipv4 - /// * ipv6 - /// - /// Requirement level: Recommended - #[serde(rename = "network.type")] - pub(crate) network_type: Option, -} - -impl DefaultForLevel for HttpCommonAttributes { - fn defaults_for_level( - &mut self, - requirement_level: DefaultAttributeRequirementLevel, - kind: TelemetryDataKind, - ) { - match requirement_level { - DefaultAttributeRequirementLevel::Required => { - if self.error_type.is_none() { - self.error_type = Some(StandardAttribute::Bool(true)); - } - if self.http_request_method.is_none() { - self.http_request_method = Some(StandardAttribute::Bool(true)); - } - if self.http_response_status_code.is_none() { - self.http_response_status_code = Some(StandardAttribute::Bool(true)); - } - } - DefaultAttributeRequirementLevel::Recommended => { - // Recommended - match kind { - TelemetryDataKind::Traces => { - if self.http_request_body_size.is_none() { - self.http_request_body_size = Some(StandardAttribute::Bool(true)); - } - if self.http_response_body_size.is_none() { - self.http_response_body_size = Some(StandardAttribute::Bool(true)); - } - if self.network_protocol_version.is_none() { - self.network_protocol_version = Some(StandardAttribute::Bool(true)); - } - if self.network_type.is_none() { - self.network_type = Some(StandardAttribute::Bool(true)); - } - } - TelemetryDataKind::Metrics => { - if self.network_protocol_version.is_none() { - self.network_protocol_version = Some(StandardAttribute::Bool(true)); - } - } - } - } - DefaultAttributeRequirementLevel::None => {} - } - } -} - -/// Attributes for Http servers -/// See https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server -#[derive(Deserialize, JsonSchema, Clone, Default, Debug)] -#[cfg_attr(test, derive(PartialEq))] -#[serde(deny_unknown_fields, default)] -pub(crate) struct HttpServerAttributes { - /// Client address - domain name if available without reverse DNS lookup, otherwise IP address or Unix domain socket name. - /// Examples: - /// - /// * 83.164.160.102 - /// - /// Requirement level: Recommended - #[serde(rename = "client.address", skip)] - pub(crate) client_address: Option, - /// The port of the original client behind all proxies, if known (e.g. from Forwarded or a similar header). Otherwise, the immediate client peer port. - /// Examples: - /// - /// * 65123 - /// - /// Requirement level: Recommended - #[serde(rename = "client.port", skip)] - pub(crate) client_port: Option, - /// The matched route (path template in the format used by the respective server framework). - /// Examples: - /// - /// * /graphql - /// - /// Requirement level: Conditionally Required: If and only if it’s available - #[serde(rename = "http.route")] - pub(crate) http_route: Option, - /// Local socket address. Useful in case of a multi-IP host. - /// Examples: - /// - /// * 10.1.2.80 - /// * /tmp/my.sock - /// - /// Requirement level: Opt-In - #[serde(rename = "network.local.address")] - pub(crate) network_local_address: Option, - /// Local socket port. Useful in case of a multi-port host. - /// Examples: - /// - /// * 65123 - /// - /// Requirement level: Opt-In - #[serde(rename = "network.local.port")] - pub(crate) network_local_port: Option, - /// Peer address of the network connection - IP address or Unix domain socket name. - /// Examples: - /// - /// * 10.1.2.80 - /// * /tmp/my.sock - /// - /// Requirement level: Recommended - #[serde(rename = "network.peer.address")] - pub(crate) network_peer_address: Option, - /// Peer port number of the network connection. - /// Examples: - /// - /// * 65123 - /// - /// Requirement level: Recommended - #[serde(rename = "network.peer.port")] - pub(crate) network_peer_port: Option, - /// Name of the local HTTP server that received the request. - /// Examples: - /// - /// * example.com - /// * 10.1.2.80 - /// * /tmp/my.sock - /// - /// Requirement level: Recommended - #[serde(rename = "server.address")] - pub(crate) server_address: Option, - /// Port of the local HTTP server that received the request. - /// Examples: - /// - /// * 80 - /// * 8080 - /// * 443 - /// - /// Requirement level: Recommended - #[serde(rename = "server.port")] - pub(crate) server_port: Option, - /// The URI path component - /// Examples: - /// - /// * /search - /// - /// Requirement level: Required - #[serde(rename = "url.path")] - pub(crate) url_path: Option, - /// The URI query component - /// Examples: - /// - /// * q=OpenTelemetry - /// - /// Requirement level: Conditionally Required: If and only if one was received/sent. - #[serde(rename = "url.query")] - pub(crate) url_query: Option, - - /// The URI scheme component identifying the used protocol. - /// Examples: - /// - /// * http - /// * https - /// - /// Requirement level: Required - #[serde(rename = "url.scheme")] - pub(crate) url_scheme: Option, - - /// Value of the HTTP User-Agent header sent by the client. - /// Examples: - /// - /// * CERN-LineMode/2.15 - /// * libwww/2.17b3 - /// - /// Requirement level: Recommended - #[serde(rename = "user_agent.original")] - pub(crate) user_agent_original: Option, -} - -impl DefaultForLevel for HttpServerAttributes { - fn defaults_for_level( - &mut self, - requirement_level: DefaultAttributeRequirementLevel, - kind: TelemetryDataKind, - ) { - match requirement_level { - DefaultAttributeRequirementLevel::Required => match kind { - TelemetryDataKind::Traces => { - if self.url_scheme.is_none() { - self.url_scheme = Some(StandardAttribute::Bool(true)); - } - if self.url_path.is_none() { - self.url_path = Some(StandardAttribute::Bool(true)); - } - if self.url_query.is_none() { - self.url_query = Some(StandardAttribute::Bool(true)); - } - - if self.http_route.is_none() { - self.http_route = Some(StandardAttribute::Bool(true)); - } - } - TelemetryDataKind::Metrics => { - if self.server_address.is_none() { - self.server_address = Some(StandardAttribute::Bool(true)); - } - if self.server_port.is_none() && self.server_address.is_some() { - self.server_port = Some(StandardAttribute::Bool(true)); - } - } - }, - DefaultAttributeRequirementLevel::Recommended => match kind { - TelemetryDataKind::Traces => { - if self.client_address.is_none() { - self.client_address = Some(StandardAttribute::Bool(true)); - } - if self.server_address.is_none() { - self.server_address = Some(StandardAttribute::Bool(true)); - } - if self.server_port.is_none() && self.server_address.is_some() { - self.server_port = Some(StandardAttribute::Bool(true)); - } - if self.user_agent_original.is_none() { - self.user_agent_original = Some(StandardAttribute::Bool(true)); - } - } - TelemetryDataKind::Metrics => {} - }, - DefaultAttributeRequirementLevel::None => {} - } - } -} - -impl Selectors for RouterAttributes { - type Request = router::Request; - type Response = router::Response; - type EventResponse = (); - - fn on_request(&self, request: &router::Request) -> Vec { - let mut attrs = self.common.on_request(request); - attrs.extend(self.server.on_request(request)); - if let Some(key) = self - .trace_id - .as_ref() - .and_then(|a| a.key(Key::from_static_str("trace_id"))) - { - if let Some(trace_id) = trace_id() { - attrs.push(KeyValue::new(key, trace_id.to_string())); - } - } - - if let Some(key) = self - .datadog_trace_id - .as_ref() - .and_then(|a| a.key(Key::from_static_str("dd.trace_id"))) - { - if let Some(trace_id) = trace_id() { - attrs.push(KeyValue::new(key, trace_id.to_datadog())); - } - } - if let Some(true) = &self.baggage { - let context = Span::current().context(); - let baggage = context.baggage(); - for (key, (value, _)) in baggage { - attrs.push(KeyValue::new(key.clone(), value.clone())); - } - } - - attrs - } - - fn on_response(&self, response: &router::Response) -> Vec { - let mut attrs = self.common.on_response(response); - attrs.extend(self.server.on_response(response)); - attrs - } - - fn on_error(&self, error: &BoxError, ctx: &Context) -> Vec { - let mut attrs = self.common.on_error(error, ctx); - attrs.extend(self.server.on_error(error, ctx)); - attrs - } -} - -impl Selectors for HttpCommonAttributes { - type Request = router::Request; - type Response = router::Response; - type EventResponse = (); - - fn on_request(&self, request: &router::Request) -> Vec { - let mut attrs = Vec::new(); - if let Some(key) = self - .http_request_method - .as_ref() - .and_then(|a| a.key(HTTP_REQUEST_METHOD)) - { - attrs.push(KeyValue::new( - key, - request.router_request.method().as_str().to_string(), - )); - } - - if let Some(key) = self - .http_request_body_size - .as_ref() - .and_then(|a| a.key(HTTP_REQUEST_BODY_SIZE)) - { - if let Some(content_length) = request - .router_request - .headers() - .get(&CONTENT_LENGTH) - .and_then(|h| h.to_str().ok()) - { - if let Ok(content_length) = content_length.parse::() { - attrs.push(KeyValue::new( - key, - opentelemetry::Value::I64(content_length), - )); - } - } - } - if let Some(key) = self - .network_protocol_name - .as_ref() - .and_then(|a| a.key(NETWORK_PROTOCOL_NAME)) - { - if let Some(scheme) = request.router_request.uri().scheme() { - attrs.push(KeyValue::new(key, scheme.to_string())); - } - } - if let Some(key) = self - .network_protocol_version - .as_ref() - .and_then(|a| a.key(NETWORK_PROTOCOL_VERSION)) - { - attrs.push(KeyValue::new( - key, - format!("{:?}", request.router_request.version()), - )); - } - if let Some(key) = self - .network_transport - .as_ref() - .and_then(|a| a.key(NETWORK_TRANSPORT)) - { - attrs.push(KeyValue::new(key, "tcp".to_string())); - } - if let Some(key) = self.network_type.as_ref().and_then(|a| a.key(NETWORK_TYPE)) { - if let Some(connection_info) = - request.router_request.extensions().get::() - { - if let Some(socket) = connection_info.server_address { - if socket.is_ipv4() { - attrs.push(KeyValue::new(key, "ipv4".to_string())); - } else if socket.is_ipv6() { - attrs.push(KeyValue::new(key, "ipv6".to_string())); - } - } - } - } - - attrs - } - - fn on_response(&self, response: &router::Response) -> Vec { - let mut attrs = Vec::new(); - if let Some(key) = self - .http_response_body_size - .as_ref() - .and_then(|a| a.key(HTTP_RESPONSE_BODY_SIZE)) - { - if let Some(content_length) = response - .response - .headers() - .get(&CONTENT_LENGTH) - .and_then(|h| h.to_str().ok()) - { - if let Ok(content_length) = content_length.parse::() { - attrs.push(KeyValue::new( - key, - opentelemetry::Value::I64(content_length), - )); - } - } - } - - if let Some(key) = self - .http_response_status_code - .as_ref() - .and_then(|a| a.key(HTTP_RESPONSE_STATUS_CODE)) - { - attrs.push(KeyValue::new( - key, - response.response.status().as_u16() as i64, - )); - } - - if let Some(key) = self.error_type.as_ref().and_then(|a| a.key(ERROR_TYPE)) { - if !response.response.status().is_success() { - attrs.push(KeyValue::new( - key, - response - .response - .status() - .canonical_reason() - .unwrap_or("unknown"), - )); - } - } - - attrs - } - - fn on_error(&self, _error: &BoxError, _ctx: &Context) -> Vec { - let mut attrs = Vec::new(); - if let Some(key) = self.error_type.as_ref().and_then(|a| a.key(ERROR_TYPE)) { - attrs.push(KeyValue::new( - key, - StatusCode::INTERNAL_SERVER_ERROR - .canonical_reason() - .unwrap_or("unknown"), - )); - } - if let Some(key) = self - .http_response_status_code - .as_ref() - .and_then(|a| a.key(HTTP_RESPONSE_STATUS_CODE)) - { - attrs.push(KeyValue::new( - key, - StatusCode::INTERNAL_SERVER_ERROR.as_u16() as i64, - )); - } - - attrs - } -} - -impl Selectors for HttpServerAttributes { - type Request = router::Request; - type Response = router::Response; - type EventResponse = (); - - fn on_request(&self, request: &router::Request) -> Vec { - let mut attrs = Vec::new(); - if let Some(key) = self.http_route.as_ref().and_then(|a| a.key(HTTP_ROUTE)) { - attrs.push(KeyValue::new( - key, - request.router_request.uri().path().to_string(), - )); - } - if let Some(key) = self - .client_address - .as_ref() - .and_then(|a| a.key(CLIENT_ADDRESS)) - { - if let Some(forwarded) = Self::forwarded_for(request) { - attrs.push(KeyValue::new(key, forwarded.ip().to_string())); - } else if let Some(connection_info) = - request.router_request.extensions().get::() - { - if let Some(socket) = connection_info.peer_address { - attrs.push(KeyValue::new(key, socket.ip().to_string())); - } - } - } - if let Some(key) = self.client_port.as_ref().and_then(|a| a.key(CLIENT_PORT)) { - if let Some(forwarded) = Self::forwarded_for(request) { - attrs.push(KeyValue::new(key, forwarded.port() as i64)); - } else if let Some(connection_info) = - request.router_request.extensions().get::() - { - if let Some(socket) = connection_info.peer_address { - attrs.push(KeyValue::new(key, socket.port() as i64)); - } - } - } - - if let Some(key) = self - .server_address - .as_ref() - .and_then(|a| a.key(SERVER_ADDRESS)) - { - if let Some(forwarded) = - Self::forwarded_host(request).and_then(|h| h.host().map(|h| h.to_string())) - { - attrs.push(KeyValue::new(key, forwarded)); - } else if let Some(connection_info) = - request.router_request.extensions().get::() - { - if let Some(socket) = connection_info.server_address { - attrs.push(KeyValue::new(key, socket.ip().to_string())); - } - } - } - if let Some(key) = self.server_port.as_ref().and_then(|a| a.key(SERVER_PORT)) { - if let Some(forwarded) = Self::forwarded_host(request).and_then(|h| h.port_u16()) { - attrs.push(KeyValue::new(key, forwarded as i64)); - } else if let Some(connection_info) = - request.router_request.extensions().get::() - { - if let Some(socket) = connection_info.server_address { - attrs.push(KeyValue::new(key, socket.port() as i64)); - } - } - } - - if let Some(key) = self - .network_local_address - .as_ref() - .and_then(|a| a.key(NETWORK_LOCAL_ADDRESS)) - { - if let Some(connection_info) = - request.router_request.extensions().get::() - { - if let Some(socket) = connection_info.server_address { - attrs.push(KeyValue::new(key, socket.ip().to_string())); - } - } - } - if let Some(key) = self - .network_local_port - .as_ref() - .and_then(|a| a.key(NETWORK_LOCAL_PORT)) - { - if let Some(connection_info) = - request.router_request.extensions().get::() - { - if let Some(socket) = connection_info.server_address { - attrs.push(KeyValue::new(key, socket.port() as i64)); - } - } - } - - if let Some(key) = self - .network_peer_address - .as_ref() - .and_then(|a| a.key(NETWORK_PEER_ADDRESS)) - { - if let Some(connection_info) = - request.router_request.extensions().get::() - { - if let Some(socket) = connection_info.peer_address { - attrs.push(KeyValue::new(key, socket.ip().to_string())); - } - } - } - if let Some(key) = self - .network_peer_port - .as_ref() - .and_then(|a| a.key(NETWORK_PEER_PORT)) - { - if let Some(connection_info) = - request.router_request.extensions().get::() - { - if let Some(socket) = connection_info.peer_address { - attrs.push(KeyValue::new(key, socket.port() as i64)); - } - } - } - - let router_uri = request.router_request.uri(); - if let Some(key) = self.url_path.as_ref().and_then(|a| a.key(URL_PATH)) { - attrs.push(KeyValue::new(key, router_uri.path().to_string())); - } - if let Some(key) = self.url_query.as_ref().and_then(|a| a.key(URL_QUERY)) { - if let Some(query) = router_uri.query() { - attrs.push(KeyValue::new(key, query.to_string())); - } - } - if let Some(key) = self.url_scheme.as_ref().and_then(|a| a.key(URL_SCHEME)) { - if let Some(scheme) = router_uri.scheme_str() { - attrs.push(KeyValue::new(key, scheme.to_string())); - } - } - if let Some(key) = self - .user_agent_original - .as_ref() - .and_then(|a| a.key(USER_AGENT_ORIGINAL)) - { - if let Some(user_agent) = request - .router_request - .headers() - .get(&USER_AGENT) - .and_then(|h| h.to_str().ok()) - { - attrs.push(KeyValue::new(key, user_agent.to_string())); - } - } - - attrs - } - - fn on_response(&self, _response: &router::Response) -> Vec { - Vec::default() - } - - fn on_error(&self, _error: &BoxError, _ctx: &Context) -> Vec { - Vec::default() - } -} - -impl HttpServerAttributes { - fn forwarded_for(request: &Request) -> Option { - request - .router_request - .headers() - .get_all(FORWARDED) - .iter() - .filter_map(|h| h.to_str().ok()) - .filter_map(|h| { - if h.to_lowercase().starts_with("for=") { - Some(&h[4..]) - } else { - None - } - }) - .filter_map(|forwarded| forwarded.parse::().ok()) - .next() - } - - pub(crate) fn forwarded_host(request: &Request) -> Option { - request - .router_request - .headers() - .get_all(FORWARDED) - .iter() - .filter_map(|h| h.to_str().ok()) - .filter_map(|h| { - if h.to_lowercase().starts_with("host=") { - Some(&h[5..]) - } else { - None - } - }) - .filter_map(|forwarded| forwarded.parse::().ok()) - .next() - } -} - -impl Selectors for SupergraphAttributes { - type Request = supergraph::Request; - type Response = supergraph::Response; - type EventResponse = crate::graphql::Response; - - fn on_request(&self, request: &supergraph::Request) -> Vec { - let mut attrs = Vec::new(); - if let Some(key) = self - .graphql_document - .as_ref() - .and_then(|a| a.key(GRAPHQL_DOCUMENT)) - { - if let Some(query) = &request.supergraph_request.body().query { - attrs.push(KeyValue::new(key, query.clone())); - } - } - if let Some(key) = self - .graphql_operation_name - .as_ref() - .and_then(|a| a.key(GRAPHQL_OPERATION_NAME)) - { - if let Some(operation_name) = &request - .context - .get::<_, String>(OPERATION_NAME) - .unwrap_or_default() - { - attrs.push(KeyValue::new(key, operation_name.clone())); - } - } - if let Some(key) = self - .graphql_operation_type - .as_ref() - .and_then(|a| a.key(GRAPHQL_OPERATION_TYPE)) - { - if let Some(operation_type) = &request - .context - .get::<_, String>(OPERATION_KIND) - .unwrap_or_default() - { - attrs.push(KeyValue::new(key, operation_type.clone())); - } - } - - attrs - } - - fn on_response(&self, response: &supergraph::Response) -> Vec { - let mut attrs = Vec::new(); - attrs.append(&mut self.cost.on_response(response)); - attrs - } - - fn on_response_event( - &self, - response: &Self::EventResponse, - context: &Context, - ) -> Vec { - let mut attrs = Vec::new(); - attrs.append(&mut self.cost.on_response_event(response, context)); - attrs - } - - fn on_error(&self, _error: &BoxError, _ctx: &Context) -> Vec { - Vec::default() - } -} - -impl Selectors for SubgraphAttributes { - type Request = subgraph::Request; - type Response = subgraph::Response; - type EventResponse = (); - - fn on_request(&self, request: &subgraph::Request) -> Vec { - let mut attrs = Vec::new(); - if let Some(key) = self - .graphql_document - .as_ref() - .and_then(|a| a.key(SUBGRAPH_GRAPHQL_DOCUMENT)) - { - if let Some(query) = &request.subgraph_request.body().query { - attrs.push(KeyValue::new(key, query.clone())); - } - } - if let Some(key) = self - .graphql_operation_name - .as_ref() - .and_then(|a| a.key(SUBGRAPH_GRAPHQL_OPERATION_NAME)) - { - if let Some(op_name) = &request.subgraph_request.body().operation_name { - attrs.push(KeyValue::new(key, op_name.clone())); - } - } - if let Some(key) = self - .graphql_operation_type - .as_ref() - .and_then(|a| a.key(SUBGRAPH_GRAPHQL_OPERATION_TYPE)) - { - // Subgraph operation type wil always match the supergraph operation type - if let Some(operation_type) = &request - .context - .get::<_, String>(OPERATION_KIND) - .unwrap_or_default() - { - attrs.push(KeyValue::new(key, operation_type.clone())); - } - } - if let Some(key) = self - .subgraph_name - .as_ref() - .and_then(|a| a.key(SUBGRAPH_NAME)) - { - if let Some(subgraph_name) = &request.subgraph_name { - attrs.push(KeyValue::new(key, subgraph_name.clone())); - } - } - - attrs - } - - fn on_response(&self, _response: &subgraph::Response) -> Vec { - Vec::default() - } - - fn on_error(&self, _error: &BoxError, _ctx: &Context) -> Vec { - Vec::default() - } -} - -#[cfg(test)] -mod test { - use std::net::SocketAddr; - use std::str::FromStr; - - use anyhow::anyhow; - use http::header::FORWARDED; - use http::header::USER_AGENT; - use http::HeaderValue; - use http::StatusCode; - use http::Uri; - use opentelemetry::trace::SpanContext; - use opentelemetry::trace::SpanId; - use opentelemetry::trace::TraceContextExt; - use opentelemetry::trace::TraceFlags; - use opentelemetry::trace::TraceId; - use opentelemetry::trace::TraceState; - use opentelemetry::Context; - use opentelemetry_api::baggage::BaggageExt; - use opentelemetry_api::KeyValue; - use opentelemetry_semantic_conventions::trace::CLIENT_ADDRESS; - use opentelemetry_semantic_conventions::trace::CLIENT_PORT; - use opentelemetry_semantic_conventions::trace::GRAPHQL_DOCUMENT; - use opentelemetry_semantic_conventions::trace::GRAPHQL_OPERATION_NAME; - use opentelemetry_semantic_conventions::trace::GRAPHQL_OPERATION_TYPE; - use opentelemetry_semantic_conventions::trace::HTTP_REQUEST_BODY_SIZE; - use opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD; - use opentelemetry_semantic_conventions::trace::HTTP_RESPONSE_BODY_SIZE; - use opentelemetry_semantic_conventions::trace::HTTP_RESPONSE_STATUS_CODE; - use opentelemetry_semantic_conventions::trace::HTTP_ROUTE; - use opentelemetry_semantic_conventions::trace::NETWORK_PROTOCOL_NAME; - use opentelemetry_semantic_conventions::trace::NETWORK_PROTOCOL_VERSION; - use opentelemetry_semantic_conventions::trace::NETWORK_TRANSPORT; - use opentelemetry_semantic_conventions::trace::NETWORK_TYPE; - use opentelemetry_semantic_conventions::trace::SERVER_ADDRESS; - use opentelemetry_semantic_conventions::trace::SERVER_PORT; - use opentelemetry_semantic_conventions::trace::URL_PATH; - use opentelemetry_semantic_conventions::trace::URL_QUERY; - use opentelemetry_semantic_conventions::trace::URL_SCHEME; - use opentelemetry_semantic_conventions::trace::USER_AGENT_ORIGINAL; - use tracing::span; - use tracing::subscriber; - use tracing_subscriber::layer::SubscriberExt; - - use crate::axum_factory::utils::ConnectionInfo; - use crate::context::OPERATION_KIND; - use crate::context::OPERATION_NAME; - use crate::graphql; - use crate::plugins::telemetry::config_new::attributes::HttpCommonAttributes; - use crate::plugins::telemetry::config_new::attributes::HttpServerAttributes; - use crate::plugins::telemetry::config_new::attributes::RouterAttributes; - use crate::plugins::telemetry::config_new::attributes::StandardAttribute; - use crate::plugins::telemetry::config_new::attributes::SubgraphAttributes; - use crate::plugins::telemetry::config_new::attributes::SupergraphAttributes; - use crate::plugins::telemetry::config_new::attributes::ERROR_TYPE; - use crate::plugins::telemetry::config_new::attributes::NETWORK_LOCAL_ADDRESS; - use crate::plugins::telemetry::config_new::attributes::NETWORK_LOCAL_PORT; - use crate::plugins::telemetry::config_new::attributes::NETWORK_PEER_ADDRESS; - use crate::plugins::telemetry::config_new::attributes::NETWORK_PEER_PORT; - use crate::plugins::telemetry::config_new::attributes::SUBGRAPH_GRAPHQL_DOCUMENT; - use crate::plugins::telemetry::config_new::attributes::SUBGRAPH_GRAPHQL_OPERATION_NAME; - use crate::plugins::telemetry::config_new::attributes::SUBGRAPH_GRAPHQL_OPERATION_TYPE; - use crate::plugins::telemetry::config_new::attributes::SUBGRAPH_NAME; - use crate::plugins::telemetry::config_new::Selectors; - use crate::plugins::telemetry::otel; - use crate::services::router; - use crate::services::subgraph; - use crate::services::supergraph; - - #[test] - fn test_router_trace_attributes() { - let subscriber = tracing_subscriber::registry().with(otel::layer()); - subscriber::with_default(subscriber, || { - let span_context = SpanContext::new( - TraceId::from_u128(42), - SpanId::from_u64(42), - TraceFlags::default().with_sampled(true), - false, - TraceState::default(), - ); - let _context = Context::current() - .with_remote_span_context(span_context) - .with_baggage(vec![ - KeyValue::new("baggage_key", "baggage_value"), - KeyValue::new("baggage_key_bis", "baggage_value_bis"), - ]) - .attach(); - let span = span!(tracing::Level::INFO, "test"); - let _guard = span.enter(); - - let attributes = RouterAttributes { - datadog_trace_id: Some(StandardAttribute::Bool(true)), - trace_id: Some(StandardAttribute::Bool(true)), - baggage: Some(true), - common: Default::default(), - server: Default::default(), - }; - let attributes = - attributes.on_request(&router::Request::fake_builder().build().unwrap()); - - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == opentelemetry::Key::from_static_str("trace_id")) - .map(|key_val| &key_val.value), - Some(&"0000000000000000000000000000002a".into()) - ); - assert_eq!( - attributes - .iter() - .find( - |key_val| key_val.key == opentelemetry::Key::from_static_str("dd.trace_id") - ) - .map(|key_val| &key_val.value), - Some(&"42".into()) - ); - assert_eq!( - attributes - .iter() - .find( - |key_val| key_val.key == opentelemetry::Key::from_static_str("baggage_key") - ) - .map(|key_val| &key_val.value), - Some(&"baggage_value".into()) - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key - == opentelemetry::Key::from_static_str("baggage_key_bis")) - .map(|key_val| &key_val.value), - Some(&"baggage_value_bis".into()) - ); - - let attributes = RouterAttributes { - datadog_trace_id: Some(StandardAttribute::Aliased { - alias: "datatoutou_id".to_string(), - }), - trace_id: Some(StandardAttribute::Aliased { - alias: "my_trace_id".to_string(), - }), - baggage: Some(false), - common: Default::default(), - server: Default::default(), - }; - let attributes = - attributes.on_request(&router::Request::fake_builder().build().unwrap()); - - assert_eq!( - attributes - .iter() - .find( - |key_val| key_val.key == opentelemetry::Key::from_static_str("my_trace_id") - ) - .map(|key_val| &key_val.value), - Some(&"0000000000000000000000000000002a".into()) - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key - == opentelemetry::Key::from_static_str("datatoutou_id")) - .map(|key_val| &key_val.value), - Some(&"42".into()) - ); - }); - } - - #[test] - fn test_supergraph_graphql_document() { - let attributes = SupergraphAttributes { - graphql_document: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - let attributes = attributes.on_request( - &supergraph::Request::fake_builder() - .query("query { __typename }") - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == GRAPHQL_DOCUMENT) - .map(|key_val| &key_val.value), - Some(&"query { __typename }".into()) - ); - } - - #[test] - fn test_supergraph_graphql_operation_name() { - let attributes = SupergraphAttributes { - graphql_operation_name: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - let context = crate::Context::new(); - let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); - let attributes = attributes.on_request( - &supergraph::Request::fake_builder() - .context(context) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == GRAPHQL_OPERATION_NAME) - .map(|key_val| &key_val.value), - Some(&"topProducts".into()) - ); - let attributes = SupergraphAttributes { - graphql_operation_name: Some(StandardAttribute::Aliased { - alias: String::from("graphql_query"), - }), - ..Default::default() - }; - let context = crate::Context::new(); - let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); - let attributes = attributes.on_request( - &supergraph::Request::fake_builder() - .context(context) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key.as_str() == "graphql_query") - .map(|key_val| &key_val.value), - Some(&"topProducts".into()) - ); - } - - #[test] - fn test_supergraph_graphql_operation_type() { - let attributes = SupergraphAttributes { - graphql_operation_type: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - let context = crate::Context::new(); - let _ = context.insert(OPERATION_KIND, "query".to_string()); - let attributes = attributes.on_request( - &supergraph::Request::fake_builder() - .context(context) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == GRAPHQL_OPERATION_TYPE) - .map(|key_val| &key_val.value), - Some(&"query".into()) - ); - } - - #[test] - fn test_subgraph_graphql_document() { - let attributes = SubgraphAttributes { - graphql_document: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - let attributes = attributes.on_request( - &subgraph::Request::fake_builder() - .subgraph_request( - ::http::Request::builder() - .uri("http://localhost/graphql") - .body( - graphql::Request::fake_builder() - .query("query { __typename }") - .build(), - ) - .unwrap(), - ) - .build(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == SUBGRAPH_GRAPHQL_DOCUMENT) - .map(|key_val| &key_val.value), - Some(&"query { __typename }".into()) - ); - } - - #[test] - fn test_subgraph_graphql_operation_name() { - let attributes = SubgraphAttributes { - graphql_operation_name: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = attributes.on_request( - &subgraph::Request::fake_builder() - .subgraph_request( - ::http::Request::builder() - .uri("http://localhost/graphql") - .body( - graphql::Request::fake_builder() - .operation_name("topProducts") - .build(), - ) - .unwrap(), - ) - .build(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == SUBGRAPH_GRAPHQL_OPERATION_NAME) - .map(|key_val| &key_val.value), - Some(&"topProducts".into()) - ); - } - - #[test] - fn test_subgraph_graphql_operation_type() { - let attributes = SubgraphAttributes { - graphql_operation_type: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let context = crate::Context::new(); - let _ = context.insert(OPERATION_KIND, "query".to_string()); - let attributes = attributes.on_request( - &subgraph::Request::fake_builder() - .context(context) - .subgraph_request( - ::http::Request::builder() - .uri("http://localhost/graphql") - .body(graphql::Request::fake_builder().build()) - .unwrap(), - ) - .build(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == SUBGRAPH_GRAPHQL_OPERATION_TYPE) - .map(|key_val| &key_val.value), - Some(&"query".into()) - ); - } - - #[test] - fn test_subgraph_name() { - let attributes = SubgraphAttributes { - subgraph_name: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = attributes.on_request( - &subgraph::Request::fake_builder() - .subgraph_name("products") - .subgraph_request( - ::http::Request::builder() - .uri("http://localhost/graphql") - .body(graphql::Request::fake_builder().build()) - .unwrap(), - ) - .build(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == SUBGRAPH_NAME) - .map(|key_val| &key_val.value), - Some(&"products".into()) - ); - } - - #[test] - fn test_http_common_error_type() { - let common = HttpCommonAttributes { - error_type: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = common.on_response( - &router::Response::fake_builder() - .status_code(StatusCode::BAD_REQUEST) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == ERROR_TYPE) - .map(|key_val| &key_val.value), - Some( - &StatusCode::BAD_REQUEST - .canonical_reason() - .unwrap_or_default() - .into() - ) - ); - - let attributes = common.on_error(&anyhow!("test error").into(), &Default::default()); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == ERROR_TYPE) - .map(|key_val| &key_val.value), - Some( - &StatusCode::INTERNAL_SERVER_ERROR - .canonical_reason() - .unwrap_or_default() - .into() - ) - ); - } - - #[test] - fn test_http_common_request_body_size() { - let common = HttpCommonAttributes { - http_request_body_size: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = common.on_request( - &router::Request::fake_builder() - .header( - http::header::CONTENT_LENGTH, - HeaderValue::from_static("256"), - ) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == HTTP_REQUEST_BODY_SIZE) - .map(|key_val| &key_val.value), - Some(&256.into()) - ); - } - - #[test] - fn test_http_common_response_body_size() { - let common = HttpCommonAttributes { - http_response_body_size: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = common.on_response( - &router::Response::fake_builder() - .header( - http::header::CONTENT_LENGTH, - HeaderValue::from_static("256"), - ) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == HTTP_RESPONSE_BODY_SIZE) - .map(|key_val| &key_val.value), - Some(&256.into()) - ); - } - - #[test] - fn test_http_common_request_method() { - let common = HttpCommonAttributes { - http_request_method: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = common.on_request( - &router::Request::fake_builder() - .method(http::Method::POST) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == HTTP_REQUEST_METHOD) - .map(|key_val| &key_val.value), - Some(&"POST".into()) - ); - } - - #[test] - fn test_http_common_response_status_code() { - let common = HttpCommonAttributes { - http_response_status_code: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = common.on_response( - &router::Response::fake_builder() - .status_code(StatusCode::BAD_REQUEST) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == HTTP_RESPONSE_STATUS_CODE) - .map(|key_val| &key_val.value), - Some(&(StatusCode::BAD_REQUEST.as_u16() as i64).into()) - ); - - let attributes = common.on_error(&anyhow!("test error").into(), &Default::default()); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == HTTP_RESPONSE_STATUS_CODE) - .map(|key_val| &key_val.value), - Some(&(StatusCode::INTERNAL_SERVER_ERROR.as_u16() as i64).into()) - ); - } - - #[test] - fn test_http_common_network_protocol_name() { - let common = HttpCommonAttributes { - network_protocol_name: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = common.on_request( - &router::Request::fake_builder() - .uri(Uri::from_static("https://localhost/graphql")) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == NETWORK_PROTOCOL_NAME) - .map(|key_val| &key_val.value), - Some(&"https".into()) - ); - } - - #[test] - fn test_http_common_network_protocol_version() { - let common = HttpCommonAttributes { - network_protocol_version: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = common.on_request( - &router::Request::fake_builder() - .uri(Uri::from_static("https://localhost/graphql")) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == NETWORK_PROTOCOL_VERSION) - .map(|key_val| &key_val.value), - Some(&"HTTP/1.1".into()) - ); - } - - #[test] - fn test_http_common_network_transport() { - let common = HttpCommonAttributes { - network_transport: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = common.on_request(&router::Request::fake_builder().build().unwrap()); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == NETWORK_TRANSPORT) - .map(|key_val| &key_val.value), - Some(&"tcp".into()) - ); - } - - #[test] - fn test_http_common_network_type() { - let common = HttpCommonAttributes { - network_type: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let mut req = router::Request::fake_builder().build().unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = common.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == NETWORK_TYPE) - .map(|key_val| &key_val.value), - Some(&"ipv4".into()) - ); - } - - #[test] - fn test_http_server_client_address() { - let server = HttpServerAttributes { - client_address: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let mut req = router::Request::fake_builder().build().unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = server.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == CLIENT_ADDRESS) - .map(|key_val| &key_val.value), - Some(&"192.168.0.8".into()) - ); - - let mut req = router::Request::fake_builder() - .header(FORWARDED, "for=2.4.6.8:8000") - .build() - .unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = server.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == CLIENT_ADDRESS) - .map(|key_val| &key_val.value), - Some(&"2.4.6.8".into()) - ); - } - - #[test] - fn test_http_server_client_port() { - let server = HttpServerAttributes { - client_port: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let mut req = router::Request::fake_builder().build().unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = server.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == CLIENT_PORT) - .map(|key_val| &key_val.value), - Some(&6060.into()) - ); - - let mut req = router::Request::fake_builder() - .header(FORWARDED, "for=2.4.6.8:8000") - .build() - .unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = server.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == CLIENT_PORT) - .map(|key_val| &key_val.value), - Some(&8000.into()) - ); - } - - #[test] - fn test_http_server_http_route() { - let server = HttpServerAttributes { - http_route: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let mut req = router::Request::fake_builder() - .uri(Uri::from_static("https://localhost/graphql")) - .build() - .unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = server.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == HTTP_ROUTE) - .map(|key_val| &key_val.value), - Some(&"/graphql".into()) - ); - } - - #[test] - fn test_http_server_network_local_address() { - let server = HttpServerAttributes { - network_local_address: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let mut req = router::Request::fake_builder() - .uri(Uri::from_static("https://localhost/graphql")) - .build() - .unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = server.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == NETWORK_LOCAL_ADDRESS) - .map(|key_val| &key_val.value), - Some(&"192.168.0.1".into()) - ); - } - - #[test] - fn test_http_server_network_local_port() { - let server = HttpServerAttributes { - network_local_port: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let mut req = router::Request::fake_builder() - .uri(Uri::from_static("https://localhost/graphql")) - .build() - .unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = server.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == NETWORK_LOCAL_PORT) - .map(|key_val| &key_val.value), - Some(&8080.into()) - ); - } - - #[test] - fn test_http_server_network_peer_address() { - let server = HttpServerAttributes { - network_peer_address: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let mut req = router::Request::fake_builder() - .uri(Uri::from_static("https://localhost/graphql")) - .build() - .unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = server.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == NETWORK_PEER_ADDRESS) - .map(|key_val| &key_val.value), - Some(&"192.168.0.8".into()) - ); - } - - #[test] - fn test_http_server_network_peer_port() { - let server = HttpServerAttributes { - network_peer_port: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let mut req = router::Request::fake_builder() - .uri(Uri::from_static("https://localhost/graphql")) - .build() - .unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = server.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == NETWORK_PEER_PORT) - .map(|key_val| &key_val.value), - Some(&6060.into()) - ); - } - - #[test] - fn test_http_server_server_address() { - let server = HttpServerAttributes { - server_address: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let mut req = router::Request::fake_builder().build().unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = server.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == SERVER_ADDRESS) - .map(|key_val| &key_val.value), - Some(&"192.168.0.1".into()) - ); - - let mut req = router::Request::fake_builder() - .header(FORWARDED, "host=2.4.6.8:8000") - .build() - .unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = server.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == SERVER_ADDRESS) - .map(|key_val| &key_val.value), - Some(&"2.4.6.8".into()) - ); - } - - #[test] - fn test_http_server_server_port() { - let server = HttpServerAttributes { - server_port: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let mut req = router::Request::fake_builder().build().unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = server.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == SERVER_PORT) - .map(|key_val| &key_val.value), - Some(&8080.into()) - ); - - let mut req = router::Request::fake_builder() - .header(FORWARDED, "host=2.4.6.8:8000") - .build() - .unwrap(); - req.router_request.extensions_mut().insert(ConnectionInfo { - peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), - server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), - }); - let attributes = server.on_request(&req); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == SERVER_PORT) - .map(|key_val| &key_val.value), - Some(&8000.into()) - ); - } - #[test] - fn test_http_server_url_path() { - let server = HttpServerAttributes { - url_path: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = server.on_request( - &router::Request::fake_builder() - .uri(Uri::from_static("https://localhost/graphql")) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == URL_PATH) - .map(|key_val| &key_val.value), - Some(&"/graphql".into()) - ); - } - #[test] - fn test_http_server_query() { - let server = HttpServerAttributes { - url_query: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = server.on_request( - &router::Request::fake_builder() - .uri(Uri::from_static("https://localhost/graphql?hi=5")) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == URL_QUERY) - .map(|key_val| &key_val.value), - Some(&"hi=5".into()) - ); - } - #[test] - fn test_http_server_scheme() { - let server = HttpServerAttributes { - url_scheme: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = server.on_request( - &router::Request::fake_builder() - .uri(Uri::from_static("https://localhost/graphql")) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == URL_SCHEME) - .map(|key_val| &key_val.value), - Some(&"https".into()) - ); - } - - #[test] - fn test_http_server_user_agent_original() { - let server = HttpServerAttributes { - user_agent_original: Some(StandardAttribute::Bool(true)), - ..Default::default() - }; - - let attributes = server.on_request( - &router::Request::fake_builder() - .header(USER_AGENT, HeaderValue::from_static("my-agent")) - .build() - .unwrap(), - ); - assert_eq!( - attributes - .iter() - .find(|key_val| key_val.key == USER_AGENT_ORIGINAL) - .map(|key_val| &key_val.value), - Some(&"my-agent".into()) - ); - } -} diff --git a/apollo-router/src/plugins/telemetry/config_new/cache/attributes.rs b/apollo-router/src/plugins/telemetry/config_new/cache/attributes.rs index 00d0b4b240..b415ae9ca5 100644 --- a/apollo-router/src/plugins/telemetry/config_new/cache/attributes.rs +++ b/apollo-router/src/plugins/telemetry/config_new/cache/attributes.rs @@ -1,15 +1,15 @@ -use opentelemetry_api::KeyValue; +use opentelemetry::KeyValue; use schemars::JsonSchema; use serde::Deserialize; use tower::BoxError; -use crate::plugins::telemetry::config_new::attributes::StandardAttribute; +use crate::Context; use crate::plugins::telemetry::config_new::DefaultAttributeRequirementLevel; use crate::plugins::telemetry::config_new::DefaultForLevel; use crate::plugins::telemetry::config_new::Selectors; +use crate::plugins::telemetry::config_new::attributes::StandardAttribute; use crate::plugins::telemetry::otlp::TelemetryDataKind; use crate::services::subgraph; -use crate::Context; #[derive(Deserialize, JsonSchema, Clone, Default, Debug, PartialEq)] #[serde(deny_unknown_fields, default)] @@ -25,27 +25,23 @@ impl DefaultForLevel for CacheAttributes { requirement_level: DefaultAttributeRequirementLevel, kind: TelemetryDataKind, ) { - if let TelemetryDataKind::Metrics = kind { - if let DefaultAttributeRequirementLevel::Required = requirement_level { - self.entity_type - .get_or_insert(StandardAttribute::Bool(false)); - } + if let TelemetryDataKind::Metrics = kind + && let DefaultAttributeRequirementLevel::Required = requirement_level + { + self.entity_type + .get_or_insert(StandardAttribute::Bool(false)); } } } // Nothing to do here because we're using a trick because entity_type is related to CacheControl data we put in the context and for one request we have several entity types // and so several metrics to generate it can't be done here -impl Selectors for CacheAttributes { - type Request = subgraph::Request; - type Response = subgraph::Response; - type EventResponse = (); - - fn on_request(&self, _request: &Self::Request) -> Vec { +impl Selectors for CacheAttributes { + fn on_request(&self, _request: &subgraph::Request) -> Vec { Vec::default() } - fn on_response(&self, _response: &Self::Response) -> Vec { + fn on_response(&self, _response: &subgraph::Response) -> Vec { Vec::default() } diff --git a/apollo-router/src/plugins/telemetry/config_new/cache/mod.rs b/apollo-router/src/plugins/telemetry/config_new/cache/mod.rs index 7000278edc..226608d2da 100644 --- a/apollo-router/src/plugins/telemetry/config_new/cache/mod.rs +++ b/apollo-router/src/plugins/telemetry/config_new/cache/mod.rs @@ -8,16 +8,16 @@ use serde::Deserialize; use tower::BoxError; use super::instruments::CustomCounter; -use super::selectors::SubgraphSelector; +use super::subgraph::selectors::SubgraphSelector; use crate::plugins::cache::entity::CacheHitMiss; use crate::plugins::cache::entity::CacheSubgraph; use crate::plugins::cache::metrics::CacheMetricContextKey; use crate::plugins::telemetry::config::AttributeValue; +use crate::plugins::telemetry::config_new::DefaultForLevel; use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; use crate::plugins::telemetry::config_new::extendable::Extendable; use crate::plugins::telemetry::config_new::instruments::DefaultedStandardInstrument; use crate::plugins::telemetry::config_new::instruments::Instrumented; -use crate::plugins::telemetry::config_new::DefaultForLevel; use crate::plugins::telemetry::otlp::TelemetryDataKind; use crate::services::subgraph; @@ -49,7 +49,7 @@ impl DefaultForLevel for CacheInstrumentsConfig { pub(crate) struct CacheInstruments { pub(crate) cache_hit: Option< - CustomCounter, + CustomCounter, >, } @@ -65,15 +65,10 @@ impl Instrumented for CacheInstruments { } fn on_response(&self, response: &Self::Response) { - let subgraph_name = match &response.subgraph_name { - Some(subgraph_name) => subgraph_name, - None => { - return; - } - }; + let subgraph_name = response.subgraph_name.clone(); let cache_info: CacheSubgraph = match response .context - .get(CacheMetricContextKey::new(subgraph_name.clone())) + .get(CacheMetricContextKey::new(subgraph_name)) .ok() .flatten() { diff --git a/apollo-router/src/plugins/telemetry/config_new/conditional.rs b/apollo-router/src/plugins/telemetry/config_new/conditional.rs index a42f112a8c..f1f9383904 100644 --- a/apollo-router/src/plugins/telemetry/config_new/conditional.rs +++ b/apollo-router/src/plugins/telemetry/config_new/conditional.rs @@ -4,26 +4,26 @@ use std::mem; use std::sync::Arc; use parking_lot::Mutex; -use schemars::gen::SchemaGenerator; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; use schemars::schema::ObjectValidation; use schemars::schema::Schema; use schemars::schema::SchemaObject; use schemars::schema::SubschemaValidation; -use schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; use serde::de::Error; use serde::de::MapAccess; use serde::de::Visitor; -use serde::Deserialize; -use serde::Deserializer; use serde_json::Map; use serde_json::Value; -use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; -use crate::plugins::telemetry::config_new::conditions::Condition; +use crate::Context; use crate::plugins::telemetry::config_new::DefaultForLevel; use crate::plugins::telemetry::config_new::Selector; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::conditions::Condition; use crate::plugins::telemetry::otlp::TelemetryDataKind; -use crate::Context; /// The state of the conditional. #[derive(Debug, Default)] @@ -78,10 +78,10 @@ where format!("conditional_attribute_{}", type_name::()) } - fn json_schema(gen: &mut SchemaGenerator) -> Schema { + fn json_schema(generator: &mut SchemaGenerator) -> Schema { // Add condition to each variant in the schema. //Maybe we can rearrange this for a smaller schema - let selector = gen.subschema_for::(); + let selector = generator.subschema_for::(); Schema::Object(SchemaObject { metadata: None, @@ -108,7 +108,7 @@ where required: Default::default(), properties: [( "condition".to_string(), - gen.subschema_for::>(), + generator.subschema_for::>(), )] .into(), pattern_properties: Default::default(), @@ -309,7 +309,7 @@ where field: &apollo_compiler::executable::Field, response_value: &serde_json_bytes::Value, ctx: &Context, - ) -> Option { + ) -> Option { // We may have got the value from the request. let value = mem::take(&mut *self.value.lock()); @@ -424,11 +424,11 @@ where #[cfg(test)] mod test { use http::StatusCode; - use opentelemetry_api::Value; + use opentelemetry::Value; - use crate::plugins::telemetry::config_new::conditional::Conditional; - use crate::plugins::telemetry::config_new::selectors::RouterSelector; use crate::plugins::telemetry::config_new::Selector; + use crate::plugins::telemetry::config_new::conditional::Conditional; + use crate::plugins::telemetry::config_new::router::selectors::RouterSelector; fn on_response(conditional: Conditional) -> Option { conditional.on_response( @@ -645,10 +645,12 @@ mod test { "#; let result = serde_yaml::from_str::>(config); - assert!(result - .expect_err("should have got error") - .to_string() - .contains("data did not match any variant of untagged enum RouterSelector"),) + assert!( + result + .expect_err("should have got error") + .to_string() + .contains("data did not match any variant of untagged enum RouterSelector"), + ) } #[test] @@ -660,10 +662,12 @@ mod test { "#; let result = serde_yaml::from_str::>(config); - assert!(result - .expect_err("should have got error") - .to_string() - .contains("unknown variant `aaargh`"),) + assert!( + result + .expect_err("should have got error") + .to_string() + .contains("unknown variant `aaargh`"), + ) } #[test] diff --git a/apollo-router/src/plugins/telemetry/config_new/conditions.rs b/apollo-router/src/plugins/telemetry/config_new/conditions.rs index 915fad6135..075339e983 100644 --- a/apollo-router/src/plugins/telemetry/config_new/conditions.rs +++ b/apollo-router/src/plugins/telemetry/config_new/conditions.rs @@ -4,9 +4,9 @@ use serde::Deserialize; use tower::BoxError; use super::Stage; +use crate::Context; use crate::plugins::telemetry::config::AttributeValue; use crate::plugins::telemetry::config_new::Selector; -use crate::Context; #[derive(Deserialize, JsonSchema, Clone, Debug, PartialEq)] #[serde(deny_unknown_fields, rename_all = "snake_case")] @@ -56,46 +56,55 @@ impl Condition where T: Selector, { - /// restricted_stage is Some if this condiiton will only applies at a specific stage like for events for example + /// restricted_stage is Some if this condition will only applies at a specific stage like for events for example pub(crate) fn validate(&self, restricted_stage: Option) -> Result<(), String> { match self { - Condition::Eq(arr) | Condition::Gt(arr) | Condition::Lt(arr) => match (&arr[0], &arr[1]) { - (SelectorOrValue::Value(val1), SelectorOrValue::Value(val2)) => { - Err(format!("trying to compare 2 values ('{val1}' and '{val2}'), usually it's a syntax error because you want to use a specific selector and a value in a condition")) - } - (SelectorOrValue::Value(_), SelectorOrValue::Selector(sel)) | (SelectorOrValue::Selector(sel), SelectorOrValue::Value(_)) => { - // Special condition for events - if let Some(Stage::Request) = &restricted_stage { - if !sel.is_active(Stage::Request) { - return Err(format!("selector {sel:?} is only valid for request stage, this log event will never trigger")); + Condition::Eq(arr) | Condition::Gt(arr) | Condition::Lt(arr) => { + match (&arr[0], &arr[1]) { + (SelectorOrValue::Value(val1), SelectorOrValue::Value(val2)) => Err(format!( + "trying to compare 2 values ('{val1}' and '{val2}'), usually it's a syntax error because you want to use a specific selector and a value in a condition" + )), + (SelectorOrValue::Value(_), SelectorOrValue::Selector(sel)) + | (SelectorOrValue::Selector(sel), SelectorOrValue::Value(_)) => { + // Special condition for events + if let Some(Stage::Request) = &restricted_stage + && !sel.is_active(Stage::Request) + { + return Err(format!( + "selector {sel:?} is only valid for request stage, this log event will never trigger" + )); } + Ok(()) } - Ok(()) - }, - (SelectorOrValue::Selector(sel1), SelectorOrValue::Selector(sel2)) => { - // Special condition for events - if let Some(Stage::Request) = &restricted_stage { - if !sel1.is_active(Stage::Request) { - return Err(format!("selector {sel1:?} is only valid for request stage, this log event will never trigger")); - } - if !sel2.is_active(Stage::Request) { - return Err(format!("selector {sel2:?} is only valid for request stage, this log event will never trigger")); + (SelectorOrValue::Selector(sel1), SelectorOrValue::Selector(sel2)) => { + // Special condition for events + if let Some(Stage::Request) = &restricted_stage { + if !sel1.is_active(Stage::Request) { + return Err(format!( + "selector {sel1:?} is only valid for request stage, this log event will never trigger" + )); + } + if !sel2.is_active(Stage::Request) { + return Err(format!( + "selector {sel2:?} is only valid for request stage, this log event will never trigger" + )); + } } + Ok(()) } - Ok(()) - }, - }, - Condition::Exists(sel) => { - match restricted_stage { - Some(stage) => { - if sel.is_active(stage) { - Ok(()) - } else { - Err(format!("the 'exists' condition use a selector applied at the wrong stage, this condition will be executed at the {} stage", stage)) - } - }, - None => Ok(()) } + } + Condition::Exists(sel) => match restricted_stage { + Some(stage) => { + if sel.is_active(stage) { + Ok(()) + } else { + Err(format!( + "the 'exists' condition use a selector applied at the wrong stage, this condition will be executed at the {stage} stage" + )) + } + } + None => Ok(()), }, Condition::All(all) => { for cond in all { @@ -103,14 +112,14 @@ where } Ok(()) - }, + } Condition::Any(any) => { for cond in any { cond.validate(restricted_stage)?; } Ok(()) - }, + } Condition::Not(cond) => cond.validate(restricted_stage), Condition::True | Condition::False => Ok(()), } @@ -295,7 +304,7 @@ where let right_att = gt[1] .on_response_event(response, ctx) .map(AttributeValue::from); - left_att.zip(right_att).map_or(false, |(l, r)| l > r) + left_att.zip(right_att).is_some_and(|(l, r)| l > r) } Condition::Lt(gt) => { let left_att = gt[0] @@ -304,7 +313,7 @@ where let right_att = gt[1] .on_response_event(response, ctx) .map(AttributeValue::from); - left_att.zip(right_att).map_or(false, |(l, r)| l < r) + left_att.zip(right_att).is_some_and(|(l, r)| l < r) } Condition::Exists(exist) => exist.on_response_event(response, ctx).is_some(), Condition::All(all) => all.iter().all(|c| c.evaluate_event_response(response, ctx)), @@ -325,12 +334,12 @@ where Condition::Gt(gt) => { let left_att = gt[0].on_response(response).map(AttributeValue::from); let right_att = gt[1].on_response(response).map(AttributeValue::from); - left_att.zip(right_att).map_or(false, |(l, r)| l > r) + left_att.zip(right_att).is_some_and(|(l, r)| l > r) } Condition::Lt(gt) => { let left_att = gt[0].on_response(response).map(AttributeValue::from); let right_att = gt[1].on_response(response).map(AttributeValue::from); - left_att.zip(right_att).map_or(false, |(l, r)| l < r) + left_att.zip(right_att).is_some_and(|(l, r)| l < r) } Condition::Exists(exist) => exist.on_response(response).is_some(), Condition::All(all) => all.iter().all(|c| c.evaluate_response(response)), @@ -351,12 +360,12 @@ where Condition::Gt(gt) => { let left_att = gt[0].on_error(error, ctx).map(AttributeValue::from); let right_att = gt[1].on_error(error, ctx).map(AttributeValue::from); - left_att.zip(right_att).map_or(false, |(l, r)| l > r) + left_att.zip(right_att).is_some_and(|(l, r)| l > r) } Condition::Lt(gt) => { let left_att = gt[0].on_error(error, ctx).map(AttributeValue::from); let right_att = gt[1].on_error(error, ctx).map(AttributeValue::from); - left_att.zip(right_att).map_or(false, |(l, r)| l < r) + left_att.zip(right_att).is_some_and(|(l, r)| l < r) } Condition::Exists(exist) => exist.on_error(error, ctx).is_some(), Condition::All(all) => all.iter().all(|c| c.evaluate_error(error, ctx)), @@ -387,7 +396,7 @@ where let right_att = gt[1] .on_response_field(ty, field, value, ctx) .map(AttributeValue::from); - left_att.zip(right_att).map_or(false, |(l, r)| l > r) + left_att.zip(right_att).is_some_and(|(l, r)| l > r) } Condition::Lt(gt) => { let left_att = gt[0] @@ -396,7 +405,7 @@ where let right_att = gt[1] .on_response_field(ty, field, value, ctx) .map(AttributeValue::from); - left_att.zip(right_att).map_or(false, |(l, r)| l < r) + left_att.zip(right_att).is_some_and(|(l, r)| l < r) } Condition::Exists(exist) => exist.on_response_field(ty, field, value, ctx).is_some(), Condition::All(all) => all @@ -569,20 +578,20 @@ where #[cfg(test)] mod test { - use opentelemetry::Value; - use serde_json_bytes::json; - use tower::BoxError; use TestSelector::Req; use TestSelector::Resp; use TestSelector::Static; + use opentelemetry::Value; + use serde_json_bytes::json; + use tower::BoxError; + use crate::Context; + use crate::plugins::telemetry::config_new::Selector; + use crate::plugins::telemetry::config_new::Stage; use crate::plugins::telemetry::config_new::conditions::Condition; use crate::plugins::telemetry::config_new::conditions::SelectorOrValue; use crate::plugins::telemetry::config_new::test::field; use crate::plugins::telemetry::config_new::test::ty; - use crate::plugins::telemetry::config_new::Selector; - use crate::plugins::telemetry::config_new::Stage; - use crate::Context; #[derive(Debug)] enum TestSelector { diff --git a/apollo-router/src/plugins/telemetry/config_new/connector/attributes.rs b/apollo-router/src/plugins/telemetry/config_new/connector/attributes.rs new file mode 100644 index 0000000000..61f27e96cd --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/connector/attributes.rs @@ -0,0 +1,146 @@ +use opentelemetry::Key; +use opentelemetry::KeyValue; +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; + +use crate::Context; +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::Selectors; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::attributes::StandardAttribute; +use crate::plugins::telemetry::config_new::connector::ConnectorRequest; +use crate::plugins::telemetry::config_new::connector::ConnectorResponse; +use crate::plugins::telemetry::config_new::subgraph::attributes::SUBGRAPH_NAME; +use crate::plugins::telemetry::otlp::TelemetryDataKind; + +const CONNECTOR_HTTP_METHOD: Key = Key::from_static_str("connector.http.method"); +const CONNECTOR_SOURCE_NAME: Key = Key::from_static_str("connector.source.name"); +const CONNECTOR_URL_TEMPLATE: Key = Key::from_static_str("connector.url.template"); + +#[derive(Deserialize, JsonSchema, Clone, Default, Debug, buildstructor::Builder)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct ConnectorAttributes { + /// The name of the subgraph containing the connector + /// Examples: + /// + /// * posts + /// + /// Requirement level: Required + #[serde(rename = "subgraph.name")] + subgraph_name: Option, + + /// The name of the source for this connector, if defined + /// Examples: + /// + /// * posts_api + /// + /// Requirement level: Conditionally Required: If the connector has a source defined + #[serde(rename = "connector.source.name")] + connector_source_name: Option, + + /// The HTTP method for the connector + /// Examples: + /// + /// * GET + /// * POST + /// + /// Requirement level: Required + #[serde(rename = "connector.http.method")] + connector_http_method: Option, + + /// The connector URL template, relative to the source base URL if one is defined + /// Examples: + /// + /// * /users/{$this.id!}/post + /// + /// Requirement level: Required + #[serde(rename = "connector.url.template")] + connector_url_template: Option, +} + +impl DefaultForLevel for ConnectorAttributes { + fn defaults_for_level( + &mut self, + requirement_level: DefaultAttributeRequirementLevel, + _kind: TelemetryDataKind, + ) { + match requirement_level { + DefaultAttributeRequirementLevel::Required => { + if self.subgraph_name.is_none() { + self.subgraph_name = Some(StandardAttribute::Bool(true)); + } + } + DefaultAttributeRequirementLevel::Recommended => { + if self.subgraph_name.is_none() { + self.subgraph_name = Some(StandardAttribute::Bool(true)); + } + if self.connector_source_name.is_none() { + self.connector_source_name = Some(StandardAttribute::Bool(true)); + } + if self.connector_http_method.is_none() { + self.connector_http_method = Some(StandardAttribute::Bool(true)); + } + if self.connector_url_template.is_none() { + self.connector_url_template = Some(StandardAttribute::Bool(true)); + } + } + DefaultAttributeRequirementLevel::None => {} + } + } +} + +impl Selectors for ConnectorAttributes { + fn on_request(&self, request: &ConnectorRequest) -> Vec { + let mut attrs = Vec::new(); + + if let Some(key) = self + .subgraph_name + .as_ref() + .and_then(|a| a.key(SUBGRAPH_NAME)) + { + attrs.push(KeyValue::new( + key, + request.connector.id.subgraph_name.clone(), + )); + } + if let Some(key) = self + .connector_source_name + .as_ref() + .and_then(|a| a.key(CONNECTOR_SOURCE_NAME)) + && let Some(ref source_name) = request.connector.id.source_name + { + attrs.push(KeyValue::new(key, source_name.value.clone())); + } + if let Some(key) = self + .connector_http_method + .as_ref() + .and_then(|a| a.key(CONNECTOR_HTTP_METHOD)) + { + attrs.push(KeyValue::new( + key, + request.connector.transport.method.as_str().to_string(), + )); + } + if let Some(key) = self + .connector_url_template + .as_ref() + .and_then(|a| a.key(CONNECTOR_URL_TEMPLATE)) + { + attrs.push(KeyValue::new( + key, + request.connector.transport.connect_template.to_string(), + )); + } + + attrs + } + + fn on_response(&self, _response: &ConnectorResponse) -> Vec { + Vec::default() + } + + fn on_error(&self, _error: &BoxError, _ctx: &Context) -> Vec { + Vec::default() + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/connector/events.rs b/apollo-router/src/plugins/telemetry/config_new/connector/events.rs new file mode 100644 index 0000000000..c1e0e0053f --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/connector/events.rs @@ -0,0 +1,323 @@ +use std::sync::Arc; + +use opentelemetry::Key; +use opentelemetry::KeyValue; +use parking_lot::Mutex; +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; + +use crate::Context; +use crate::plugins::telemetry::config_new::conditions::Condition; +use crate::plugins::telemetry::config_new::connector::ConnectorRequest; +use crate::plugins::telemetry::config_new::connector::ConnectorResponse; +use crate::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes; +use crate::plugins::telemetry::config_new::connector::selectors::ConnectorSelector; +use crate::plugins::telemetry::config_new::events::CustomEvent; +use crate::plugins::telemetry::config_new::events::CustomEvents; +use crate::plugins::telemetry::config_new::events::Event; +use crate::plugins::telemetry::config_new::events::EventLevel; +use crate::plugins::telemetry::config_new::events::StandardEvent; +use crate::plugins::telemetry::config_new::events::StandardEventConfig; +use crate::plugins::telemetry::config_new::events::log_event; +use crate::plugins::telemetry::config_new::extendable::Extendable; + +#[derive(Clone)] +pub(crate) struct ConnectorEventRequest { + // XXX(@IvanGoncharov): As part of removing Mutex from StandardEvent I moved it here + // I think it's not nessary here but can't verify it right now, so in future can just wrap StandardEvent + pub(crate) level: EventLevel, + pub(crate) condition: Arc>>, +} + +#[derive(Clone)] +pub(crate) struct ConnectorEventResponse { + // XXX(@IvanGoncharov): As part of removing Arc from StandardEvent I moved it here + // I think it's not nessary here but can't verify it right now, so in future can just wrap StandardEvent + pub(crate) level: EventLevel, + pub(crate) condition: Arc>, +} + +#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct ConnectorEventsConfig { + /// Log the connector HTTP request + pub(crate) request: StandardEventConfig, + /// Log the connector HTTP response + pub(crate) response: StandardEventConfig, + /// Log the connector HTTP error + pub(crate) error: StandardEventConfig, +} + +pub(crate) type ConnectorEvents = + CustomEvents; + +pub(crate) fn new_connector_events( + config: &Extendable>, +) -> ConnectorEvents { + let custom_events = config + .custom + .iter() + .filter_map(|(name, config)| CustomEvent::from_config(name, config)) + .collect(); + + ConnectorEvents { + request: StandardEvent::from_config(&config.attributes.request), + response: StandardEvent::from_config(&config.attributes.response), + error: StandardEvent::from_config(&config.attributes.error), + custom: custom_events, + } +} + +impl CustomEvents { + pub(crate) fn on_request(&mut self, request: &ConnectorRequest) { + // Any condition on the request is NOT evaluated here. It must be evaluated later when + // getting the ConnectorEventRequest from the context. The request context is shared + // between all connector requests, so any request could find this ConnectorEventRequest in + // the context. Its presence on the context cannot be conditional on an individual request. + if let Some(request_event) = self.request.take() { + request.context.extensions().with_lock(|lock| { + lock.insert(ConnectorEventRequest { + level: request_event.level, + condition: Arc::new(Mutex::new(request_event.condition)), + }) + }); + } + + if let Some(response_event) = self.response.take() { + request.context.extensions().with_lock(|lock| { + lock.insert(ConnectorEventResponse { + level: response_event.level, + condition: Arc::new(response_event.condition), + }) + }); + } + + for custom_event in &mut self.custom { + custom_event.on_request(request); + } + } + + pub(crate) fn on_response(&mut self, response: &ConnectorResponse) { + for custom_event in &mut self.custom { + custom_event.on_response(response); + } + } + + pub(crate) fn on_error(&mut self, error: &BoxError, ctx: &Context) { + if let Some(error_event) = &mut self.error + && error_event.condition.evaluate_error(error, ctx) + { + log_event( + error_event.level, + "connector.http.error", + vec![KeyValue::new( + Key::from_static_str("error"), + opentelemetry::Value::String(error.to_string().into()), + )], + "", + ); + } + for custom_event in &mut self.custom { + custom_event.on_error(error, ctx); + } + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use apollo_compiler::name; + use apollo_federation::connectors::ConnectId; + use apollo_federation::connectors::ConnectSpec; + use apollo_federation::connectors::Connector; + use apollo_federation::connectors::HttpJsonTransport; + use apollo_federation::connectors::JSONSelection; + use apollo_federation::connectors::SourceName; + use apollo_federation::connectors::StringTemplate; + use apollo_federation::connectors::runtime::http_json_transport::HttpRequest; + use apollo_federation::connectors::runtime::http_json_transport::HttpResponse; + use apollo_federation::connectors::runtime::http_json_transport::TransportRequest; + use apollo_federation::connectors::runtime::http_json_transport::TransportResponse; + use apollo_federation::connectors::runtime::key::ResponseKey; + use apollo_federation::connectors::runtime::responses::MappedResponse; + use http::HeaderValue; + use tracing::instrument::WithSubscriber; + + use super::*; + use crate::assert_snapshot_subscriber; + use crate::plugins::telemetry::Telemetry; + use crate::plugins::test::PluginTestHarness; + use crate::services::connector::request_service::Request; + use crate::services::connector::request_service::Response; + use crate::services::router::body; + + #[tokio::test(flavor = "multi_thread")] + async fn test_connector_events_request() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!("../../testdata/custom_events.router.yaml")) + .build() + .await + .expect("test harness"); + + async { + let context = crate::Context::default(); + let mut http_request = http::Request::builder().body("".into()).unwrap(); + http_request + .headers_mut() + .insert("x-log-request", HeaderValue::from_static("log")); + let transport_request = TransportRequest::Http(HttpRequest { + inner: http_request, + debug: Default::default(), + }); + let connector = Connector { + id: ConnectId::new( + "subgraph".into(), + Some(SourceName::cast("source")), + name!(Query), + name!(users), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: None, + connect_template: StringTemplate::from_str("/test").unwrap(), + ..Default::default() + }, + selection: JSONSelection::empty(), + config: None, + max_requests: None, + entity_resolver: None, + spec: ConnectSpec::V0_1, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "label".into(), + }; + let response_key = ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + let connector_request = Request { + context: context.clone(), + connector: Arc::new(connector.clone()), + transport_request, + key: response_key.clone(), + mapping_problems: vec![], + supergraph_request: Default::default(), + }; + test_harness + .call_connector_request_service(connector_request, |request| Response { + transport_result: Ok(TransportResponse::Http(HttpResponse { + inner: http::Response::builder() + .status(200) + .header("x-log-request", HeaderValue::from_static("log")) + .body(body::empty()) + .expect("expecting valid response") + .into_parts() + .0, + })), + mapped_response: MappedResponse::Data { + data: serde_json::json!({}) + .try_into() + .expect("expecting valid JSON"), + key: request.key.clone(), + problems: vec![], + }, + }) + .await + .expect("expecting successful response"); + } + .with_subscriber(assert_snapshot_subscriber!()) + .await + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_connector_events_response() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!("../../testdata/custom_events.router.yaml")) + .build() + .await + .expect("test harness"); + + async { + let context = crate::Context::default(); + let mut http_request = http::Request::builder().body("".into()).unwrap(); + http_request + .headers_mut() + .insert("x-log-response", HeaderValue::from_static("log")); + let transport_request = TransportRequest::Http(HttpRequest { + inner: http_request, + debug: Default::default(), + }); + let connector = Connector { + id: ConnectId::new( + "subgraph".into(), + Some(SourceName::cast("source")), + name!(Query), + name!(users), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: None, + connect_template: StringTemplate::from_str("/test").unwrap(), + ..Default::default() + }, + selection: JSONSelection::empty(), + config: None, + max_requests: None, + entity_resolver: None, + spec: ConnectSpec::V0_1, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "label".into(), + }; + let response_key = ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + let connector_request = Request { + context: context.clone(), + connector: Arc::new(connector.clone()), + transport_request, + key: response_key.clone(), + mapping_problems: vec![], + supergraph_request: Default::default(), + }; + test_harness + .call_connector_request_service(connector_request, |request| Response { + transport_result: Ok(TransportResponse::Http(HttpResponse { + inner: http::Response::builder() + .status(200) + .header("x-log-response", HeaderValue::from_static("log")) + .body(body::empty()) + .expect("expecting valid response") + .into_parts() + .0, + })), + mapped_response: MappedResponse::Data { + data: serde_json::json!({}) + .try_into() + .expect("expecting valid JSON"), + key: request.key.clone(), + problems: vec![], + }, + }) + .await + .expect("expecting successful response"); + } + .with_subscriber(assert_snapshot_subscriber!()) + .await + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/connector/instruments.rs b/apollo-router/src/plugins/telemetry/config_new/connector/instruments.rs new file mode 100644 index 0000000000..c58b0cbad4 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/connector/instruments.rs @@ -0,0 +1,347 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use opentelemetry::metrics::MeterProvider; +use parking_lot::Mutex; +use schemars::JsonSchema; +use serde::Deserialize; +use tokio::time::Instant; +use tower::BoxError; + +use crate::Context; +use crate::metrics; +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::conditions::Condition; +use crate::plugins::telemetry::config_new::connector::ConnectorRequest; +use crate::plugins::telemetry::config_new::connector::ConnectorResponse; +use crate::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes; +use crate::plugins::telemetry::config_new::connector::selectors::ConnectorSelector; +use crate::plugins::telemetry::config_new::connector::selectors::ConnectorValue; +use crate::plugins::telemetry::config_new::extendable::Extendable; +use crate::plugins::telemetry::config_new::instruments::CustomHistogram; +use crate::plugins::telemetry::config_new::instruments::CustomHistogramInner; +use crate::plugins::telemetry::config_new::instruments::CustomInstruments; +use crate::plugins::telemetry::config_new::instruments::DefaultedStandardInstrument; +use crate::plugins::telemetry::config_new::instruments::HTTP_CLIENT_REQUEST_BODY_SIZE_METRIC; +use crate::plugins::telemetry::config_new::instruments::HTTP_CLIENT_REQUEST_DURATION_METRIC; +use crate::plugins::telemetry::config_new::instruments::HTTP_CLIENT_RESPONSE_BODY_SIZE_METRIC; +use crate::plugins::telemetry::config_new::instruments::Increment; +use crate::plugins::telemetry::config_new::instruments::Instrument; +use crate::plugins::telemetry::config_new::instruments::Instrumented; +use crate::plugins::telemetry::config_new::instruments::METER_NAME; +use crate::plugins::telemetry::config_new::instruments::StaticInstrument; +use crate::plugins::telemetry::otlp::TelemetryDataKind; + +#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct ConnectorInstrumentsConfig { + /// Histogram of client request duration + #[serde(rename = "http.client.request.duration")] + http_client_request_duration: + DefaultedStandardInstrument>, + + /// Histogram of client request body size + #[serde(rename = "http.client.request.body.size")] + http_client_request_body_size: + DefaultedStandardInstrument>, + + /// Histogram of client response body size + #[serde(rename = "http.client.response.body.size")] + http_client_response_body_size: + DefaultedStandardInstrument>, +} + +impl DefaultForLevel for ConnectorInstrumentsConfig { + fn defaults_for_level( + &mut self, + requirement_level: DefaultAttributeRequirementLevel, + kind: TelemetryDataKind, + ) { + self.http_client_request_duration + .defaults_for_level(requirement_level, kind); + self.http_client_request_body_size + .defaults_for_level(requirement_level, kind); + self.http_client_response_body_size + .defaults_for_level(requirement_level, kind); + } +} + +pub(crate) struct ConnectorInstruments { + http_client_request_duration: Option< + CustomHistogram< + ConnectorRequest, + ConnectorResponse, + (), + ConnectorAttributes, + ConnectorSelector, + >, + >, + http_client_request_body_size: Option< + CustomHistogram< + ConnectorRequest, + ConnectorResponse, + (), + ConnectorAttributes, + ConnectorSelector, + >, + >, + http_client_response_body_size: Option< + CustomHistogram< + ConnectorRequest, + ConnectorResponse, + (), + ConnectorAttributes, + ConnectorSelector, + >, + >, + custom: ConnectorCustomInstruments, +} + +impl ConnectorInstruments { + pub(crate) fn new( + config: &Extendable< + ConnectorInstrumentsConfig, + Instrument, + >, + static_instruments: Arc>, + ) -> Self { + let http_client_request_duration = + config + .attributes + .http_client_request_duration + .is_enabled() + .then(|| { + let mut nb_attributes = 0; + let selectors = match &config.attributes.http_client_request_duration { + DefaultedStandardInstrument::Bool(_) + | DefaultedStandardInstrument::Unset => None, + DefaultedStandardInstrument::Extendable { attributes } => { + nb_attributes = attributes.custom.len(); + Some(attributes.clone()) + } + }; + CustomHistogram { + inner: Mutex::new(CustomHistogramInner { + increment: Increment::Duration(Instant::now()), + condition: Condition::True, + histogram: Some(static_instruments + .get(HTTP_CLIENT_REQUEST_DURATION_METRIC) + .expect( + "cannot get static instrument for connector; this should not happen", + ) + .as_histogram() + .cloned() + .expect( + "cannot convert instrument to histogram for connector; this should not happen", + ) + ), + attributes: Vec::with_capacity(nb_attributes), + selector: None, + selectors, + updated: false, + _phantom: Default::default() + }), + } + }); + let http_client_request_body_size = + config + .attributes + .http_client_request_body_size + .is_enabled() + .then(|| { + let mut nb_attributes = 0; + let selectors = match &config.attributes.http_client_request_body_size { + DefaultedStandardInstrument::Bool(_) + | DefaultedStandardInstrument::Unset => None, + DefaultedStandardInstrument::Extendable { attributes } => { + nb_attributes = attributes.custom.len(); + Some(attributes.clone()) + } + }; + CustomHistogram { + inner: Mutex::new(CustomHistogramInner { + increment: Increment::Custom(None), + condition: Condition::True, + histogram: Some(static_instruments + .get(HTTP_CLIENT_REQUEST_BODY_SIZE_METRIC) + .expect( + "cannot get static instrument for connector; this should not happen", + ) + .as_histogram() + .cloned() + .expect( + "cannot convert instrument to histogram for connector; this should not happen", + ) + ), + attributes: Vec::with_capacity(nb_attributes), + selector: Some(Arc::new(ConnectorSelector::HttpRequestHeader { + connector_http_request_header: "content-length".to_string(), + redact: None, + default: None, + })), + selectors, + updated: false, + _phantom: Default::default() + }), + } + }); + let http_client_response_body_size = + config + .attributes + .http_client_response_body_size + .is_enabled() + .then(|| { + let mut nb_attributes = 0; + let selectors = match &config.attributes.http_client_response_body_size { + DefaultedStandardInstrument::Bool(_) + | DefaultedStandardInstrument::Unset => None, + DefaultedStandardInstrument::Extendable { attributes } => { + nb_attributes = attributes.custom.len(); + Some(attributes.clone()) + } + }; + CustomHistogram { + inner: Mutex::new(CustomHistogramInner { + increment: Increment::Custom(None), + condition: Condition::True, + histogram: Some(static_instruments + .get(HTTP_CLIENT_RESPONSE_BODY_SIZE_METRIC) + .expect( + "cannot get static instrument for connector; this should not happen", + ) + .as_histogram() + .cloned() + .expect( + "cannot convert instrument to histogram for connector; this should not happen", + ) + ), + attributes: Vec::with_capacity(nb_attributes), + selector: Some(Arc::new(ConnectorSelector::ConnectorResponseHeader { + connector_http_response_header: "content-length".to_string(), + redact: None, + default: None, + })), + selectors, + updated: false, + _phantom: Default::default() + }), + } + }); + ConnectorInstruments { + http_client_request_duration, + http_client_request_body_size, + http_client_response_body_size, + custom: CustomInstruments::new(&config.custom, static_instruments), + } + } + + pub(crate) fn new_builtin( + config: &Extendable< + ConnectorInstrumentsConfig, + Instrument, + >, + ) -> HashMap { + let meter = metrics::meter_provider().meter(METER_NAME); + let mut static_instruments = HashMap::with_capacity(3); + + if config.attributes.http_client_request_duration.is_enabled() { + static_instruments.insert( + HTTP_CLIENT_REQUEST_DURATION_METRIC.to_string(), + StaticInstrument::Histogram( + meter + .f64_histogram(HTTP_CLIENT_REQUEST_DURATION_METRIC) + .with_unit("s") + .with_description("Duration of HTTP client requests.") + .init(), + ), + ); + } + + if config.attributes.http_client_request_body_size.is_enabled() { + static_instruments.insert( + HTTP_CLIENT_REQUEST_BODY_SIZE_METRIC.to_string(), + StaticInstrument::Histogram( + meter + .f64_histogram(HTTP_CLIENT_REQUEST_BODY_SIZE_METRIC) + .with_unit("By") + .with_description("Size of HTTP client request bodies.") + .init(), + ), + ); + } + + if config + .attributes + .http_client_response_body_size + .is_enabled() + { + static_instruments.insert( + HTTP_CLIENT_RESPONSE_BODY_SIZE_METRIC.to_string(), + StaticInstrument::Histogram( + meter + .f64_histogram(HTTP_CLIENT_RESPONSE_BODY_SIZE_METRIC) + .with_unit("By") + .with_description("Size of HTTP client response bodies.") + .init(), + ), + ); + } + + static_instruments + } +} + +impl Instrumented for ConnectorInstruments { + type Request = ConnectorRequest; + type Response = ConnectorResponse; + type EventResponse = (); + + fn on_request(&self, request: &Self::Request) { + if let Some(http_client_request_duration) = &self.http_client_request_duration { + http_client_request_duration.on_request(request); + } + if let Some(http_client_request_body_size) = &self.http_client_request_body_size { + http_client_request_body_size.on_request(request); + } + if let Some(http_client_response_body_size) = &self.http_client_response_body_size { + http_client_response_body_size.on_request(request); + } + self.custom.on_request(request); + } + + fn on_response(&self, response: &Self::Response) { + if let Some(http_client_request_duration) = &self.http_client_request_duration { + http_client_request_duration.on_response(response); + } + if let Some(http_client_request_body_size) = &self.http_client_request_body_size { + http_client_request_body_size.on_response(response); + } + if let Some(http_client_response_body_size) = &self.http_client_response_body_size { + http_client_response_body_size.on_response(response); + } + self.custom.on_response(response); + } + + fn on_error(&self, error: &BoxError, ctx: &Context) { + if let Some(http_client_request_duration) = &self.http_client_request_duration { + http_client_request_duration.on_error(error, ctx); + } + if let Some(http_client_request_body_size) = &self.http_client_request_body_size { + http_client_request_body_size.on_error(error, ctx); + } + if let Some(http_client_response_body_size) = &self.http_client_response_body_size { + http_client_response_body_size.on_error(error, ctx); + } + self.custom.on_error(error, ctx); + } +} + +pub(crate) type ConnectorCustomInstruments = CustomInstruments< + ConnectorRequest, + ConnectorResponse, + (), + ConnectorAttributes, + ConnectorSelector, + ConnectorValue, +>; diff --git a/apollo-router/src/plugins/telemetry/config_new/connector/mod.rs b/apollo-router/src/plugins/telemetry/config_new/connector/mod.rs new file mode 100644 index 0000000000..8ced6036b6 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/connector/mod.rs @@ -0,0 +1,10 @@ +//! Connectors telemetry. + +pub(crate) mod attributes; +pub(crate) mod events; +pub(crate) mod instruments; +pub(crate) mod selectors; +pub(crate) mod spans; + +pub(crate) type ConnectorRequest = crate::services::connector::request_service::Request; +pub(crate) type ConnectorResponse = crate::services::connector::request_service::Response; diff --git a/apollo-router/src/plugins/telemetry/config_new/connector/selectors.rs b/apollo-router/src/plugins/telemetry/config_new/connector/selectors.rs new file mode 100644 index 0000000000..20b042969b --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/connector/selectors.rs @@ -0,0 +1,979 @@ +use apollo_federation::connectors::runtime::http_json_transport::TransportRequest; +use apollo_federation::connectors::runtime::http_json_transport::TransportResponse; +use apollo_federation::connectors::runtime::responses::MappedResponse; +use derivative::Derivative; +use opentelemetry::Array; +use opentelemetry::StringValue; +use opentelemetry::Value; +use schemars::JsonSchema; +use serde::Deserialize; +use sha2::Digest; +use tower::BoxError; + +use crate::Context; +use crate::context::OPERATION_KIND; +use crate::context::OPERATION_NAME; +use crate::plugins::telemetry::config::AttributeValue; +use crate::plugins::telemetry::config_new::Selector; +use crate::plugins::telemetry::config_new::Stage; +use crate::plugins::telemetry::config_new::ToOtelValue; +use crate::plugins::telemetry::config_new::connector::ConnectorRequest; +use crate::plugins::telemetry::config_new::connector::ConnectorResponse; +use crate::plugins::telemetry::config_new::instruments::InstrumentValue; +use crate::plugins::telemetry::config_new::instruments::Standard; +use crate::plugins::telemetry::config_new::selectors::ErrorRepr; +use crate::plugins::telemetry::config_new::selectors::OperationKind; +use crate::plugins::telemetry::config_new::selectors::OperationName; +use crate::plugins::telemetry::config_new::selectors::ResponseStatus; + +#[derive(Deserialize, JsonSchema, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub(crate) enum ConnectorSource { + /// The name of the connector source. + Name, +} + +#[derive(Deserialize, JsonSchema, Clone, Debug)] +#[serde(deny_unknown_fields, rename_all = "snake_case", untagged)] +pub(crate) enum ConnectorValue { + Standard(Standard), + Custom(ConnectorSelector), +} + +impl From<&ConnectorValue> for InstrumentValue { + fn from(value: &ConnectorValue) -> Self { + match value { + ConnectorValue::Standard(s) => InstrumentValue::Standard(s.clone()), + ConnectorValue::Custom(selector) => InstrumentValue::Custom(selector.clone()), + } + } +} + +#[derive(Deserialize, JsonSchema, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub(crate) enum MappingProblems { + /// String representation of all problems + Problems, + /// The count of mapping problems + Count, + /// Whether there are any mapping problems + Boolean, +} + +#[derive(Deserialize, JsonSchema, Clone, Derivative)] +#[serde(deny_unknown_fields, rename_all = "snake_case", untagged)] +#[derivative(Debug, PartialEq)] +pub(crate) enum ConnectorSelector { + SubgraphName { + /// The subgraph name + subgraph_name: bool, + }, + ConnectorSource { + /// The connector source. + connector_source: ConnectorSource, + }, + HttpRequestHeader { + /// The name of a connector HTTP request header. + connector_http_request_header: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + ConnectorResponseHeader { + /// The name of a connector HTTP response header. + connector_http_response_header: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + ConnectorResponseStatus { + /// The connector HTTP response status code. + connector_http_response_status: ResponseStatus, + }, + ConnectorHttpMethod { + /// The connector HTTP method. + connector_http_method: bool, + }, + ConnectorUrlTemplate { + /// The connector URL template. + connector_url_template: bool, + }, + StaticField { + /// A static value + r#static: AttributeValue, + }, + Error { + /// Critical error if it happens + error: ErrorRepr, + }, + RequestMappingProblems { + /// Request mapping problems, if any + connector_request_mapping_problems: MappingProblems, + }, + ResponseMappingProblems { + /// Response mapping problems, if any + connector_response_mapping_problems: MappingProblems, + }, + RequestContext { + /// The request context key. + request_context: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + SupergraphOperationName { + /// The supergraph query operation name. + supergraph_operation_name: OperationName, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + SupergraphOperationKind { + /// The supergraph query operation kind (query|mutation|subscription). + // Allow dead code is required because there is only one variant in OperationKind and we need to avoid the dead code warning. + #[allow(dead_code)] + supergraph_operation_kind: OperationKind, + }, + OnResponseError { + /// Boolean set to true if the response's `is_successful` condition is false. If this is not + /// set, returns true when the response contains a non-200 status code + connector_on_response_error: bool, + }, +} + +impl Selector for ConnectorSelector { + type Request = ConnectorRequest; + type Response = ConnectorResponse; + type EventResponse = (); + + fn on_request(&self, request: &Self::Request) -> Option { + match self { + ConnectorSelector::SubgraphName { subgraph_name } if *subgraph_name => Some( + opentelemetry::Value::from(request.connector.id.subgraph_name.clone()), + ), + ConnectorSelector::ConnectorSource { .. } => request + .connector + .id + .source_name + .as_ref() + .map(|name| name.value.clone()) + .map(opentelemetry::Value::from), + ConnectorSelector::ConnectorHttpMethod { + connector_http_method, + } if *connector_http_method => Some(opentelemetry::Value::from( + request.connector.transport.method.as_str().to_string(), + )), + ConnectorSelector::ConnectorUrlTemplate { + connector_url_template, + } if *connector_url_template => Some(opentelemetry::Value::from( + request.connector.transport.connect_template.to_string(), + )), + ConnectorSelector::HttpRequestHeader { + connector_http_request_header: connector_request_header, + default, + .. + } => { + let TransportRequest::Http(ref http_request) = request.transport_request; + http_request + .inner + .headers() + .get(connector_request_header) + .and_then(|h| Some(h.to_str().ok()?.to_string())) + .or_else(|| default.clone()) + .map(opentelemetry::Value::from) + } + ConnectorSelector::RequestMappingProblems { + connector_request_mapping_problems: mapping_problems, + } => match mapping_problems { + MappingProblems::Problems => Some(Value::Array(Array::String( + request + .mapping_problems + .iter() + .filter_map(|problem| { + serde_json::to_string(problem).ok().map(StringValue::from) + }) + .collect(), + ))), + MappingProblems::Count => Some(Value::I64( + request + .mapping_problems + .iter() + .map(|problem| problem.count as i64) + .sum(), + )), + MappingProblems::Boolean => Some(Value::Bool(!request.mapping_problems.is_empty())), + }, + ConnectorSelector::StaticField { r#static } => Some(r#static.clone().into()), + ConnectorSelector::RequestContext { + request_context, + default, + .. + } => request + .context + .get_json_value(request_context) + .as_ref() + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + ConnectorSelector::SupergraphOperationName { + supergraph_operation_name, + default, + .. + } => { + let op_name = request.context.get(OPERATION_NAME).ok().flatten(); + match supergraph_operation_name { + OperationName::String => op_name.or_else(|| default.clone()), + OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { + let mut hasher = sha2::Sha256::new(); + hasher.update(op_name.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) + }), + } + .map(opentelemetry::Value::from) + } + ConnectorSelector::SupergraphOperationKind { .. } => request + .context + .get::<_, String>(OPERATION_KIND) + .ok() + .flatten() + .map(opentelemetry::Value::from), + _ => None, + } + } + + fn on_response(&self, response: &Self::Response) -> Option { + match self { + ConnectorSelector::ConnectorResponseHeader { + connector_http_response_header: connector_response_header, + default, + .. + } => { + if let Ok(TransportResponse::Http(ref http_response)) = response.transport_result { + http_response + .inner + .headers + .get(connector_response_header) + .and_then(|h| Some(h.to_str().ok()?.to_string())) + .or_else(|| default.clone()) + .map(opentelemetry::Value::from) + } else { + None + } + } + ConnectorSelector::ConnectorResponseStatus { + connector_http_response_status: response_status, + } => { + if let Ok(TransportResponse::Http(ref http_response)) = response.transport_result { + let status = http_response.inner.status; + match response_status { + ResponseStatus::Code => Some(Value::I64(status.as_u16() as i64)), + ResponseStatus::Reason => { + status.canonical_reason().map(|reason| reason.into()) + } + } + } else { + None + } + } + ConnectorSelector::ResponseMappingProblems { + connector_response_mapping_problems: mapping_problems, + } => { + if let MappedResponse::Data { ref problems, .. } = response.mapped_response { + match mapping_problems { + MappingProblems::Problems => Some(Value::Array(Array::String( + problems + .iter() + .filter_map(|problem| { + serde_json::to_string(problem).ok().map(StringValue::from) + }) + .collect(), + ))), + MappingProblems::Count => Some(Value::I64( + problems.iter().map(|problem| problem.count as i64).sum(), + )), + MappingProblems::Boolean => Some(Value::Bool(!problems.is_empty())), + } + } else { + None + } + } + ConnectorSelector::OnResponseError { + connector_on_response_error, + } if *connector_on_response_error => { + Some(matches!(response.mapped_response, MappedResponse::Error { .. }).into()) + } + _ => None, + } + } + + fn on_error(&self, error: &BoxError, _ctx: &Context) -> Option { + match self { + ConnectorSelector::Error { .. } => Some(error.to_string().into()), + ConnectorSelector::StaticField { r#static } => Some(r#static.clone().into()), + _ => None, + } + } + + fn on_drop(&self) -> Option { + match self { + ConnectorSelector::StaticField { r#static } => Some(r#static.clone().into()), + _ => None, + } + } + + fn is_active(&self, stage: Stage) -> bool { + match stage { + Stage::Request => matches!( + self, + ConnectorSelector::HttpRequestHeader { .. } + | ConnectorSelector::SubgraphName { .. } + | ConnectorSelector::ConnectorSource { .. } + | ConnectorSelector::ConnectorHttpMethod { .. } + | ConnectorSelector::ConnectorUrlTemplate { .. } + | ConnectorSelector::StaticField { .. } + | ConnectorSelector::RequestMappingProblems { .. } + | ConnectorSelector::RequestContext { .. } + | ConnectorSelector::SupergraphOperationName { .. } + | ConnectorSelector::SupergraphOperationKind { .. } + ), + Stage::Response => matches!( + self, + ConnectorSelector::ConnectorResponseHeader { .. } + | ConnectorSelector::ConnectorResponseStatus { .. } + | ConnectorSelector::SubgraphName { .. } + | ConnectorSelector::ConnectorSource { .. } + | ConnectorSelector::ConnectorHttpMethod { .. } + | ConnectorSelector::ConnectorUrlTemplate { .. } + | ConnectorSelector::StaticField { .. } + | ConnectorSelector::ResponseMappingProblems { .. } + | ConnectorSelector::OnResponseError { .. } + ), + Stage::ResponseEvent => false, + Stage::ResponseField => false, + Stage::Error => matches!( + self, + ConnectorSelector::Error { .. } + | ConnectorSelector::SubgraphName { .. } + | ConnectorSelector::ConnectorSource { .. } + | ConnectorSelector::ConnectorHttpMethod { .. } + | ConnectorSelector::ConnectorUrlTemplate { .. } + | ConnectorSelector::StaticField { .. } + ), + Stage::Drop => matches!(self, ConnectorSelector::StaticField { .. }), + } + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + use std::sync::Arc; + + use apollo_compiler::name; + use apollo_federation::connectors::ConnectId; + use apollo_federation::connectors::ConnectSpec; + use apollo_federation::connectors::Connector; + use apollo_federation::connectors::HttpJsonTransport; + use apollo_federation::connectors::JSONSelection; + use apollo_federation::connectors::ProblemLocation; + use apollo_federation::connectors::SourceName; + use apollo_federation::connectors::StringTemplate; + use apollo_federation::connectors::runtime::errors::RuntimeError; + use apollo_federation::connectors::runtime::http_json_transport::HttpRequest; + use apollo_federation::connectors::runtime::http_json_transport::HttpResponse; + use apollo_federation::connectors::runtime::http_json_transport::TransportRequest; + use apollo_federation::connectors::runtime::http_json_transport::TransportResponse; + use apollo_federation::connectors::runtime::key::ResponseKey; + use apollo_federation::connectors::runtime::mapping::Problem; + use apollo_federation::connectors::runtime::responses::MappedResponse; + use http::HeaderValue; + use http::StatusCode; + use opentelemetry::Array; + use opentelemetry::StringValue; + use opentelemetry::Value; + + use super::ConnectorSelector; + use super::ConnectorSource; + use super::MappingProblems; + use crate::Context; + use crate::context::OPERATION_KIND; + use crate::context::OPERATION_NAME; + use crate::plugins::telemetry::config_new::Selector; + use crate::plugins::telemetry::config_new::selectors::ErrorRepr; + use crate::plugins::telemetry::config_new::selectors::OperationKind; + use crate::plugins::telemetry::config_new::selectors::OperationName; + use crate::plugins::telemetry::config_new::selectors::ResponseStatus; + use crate::services::connector::request_service::Request; + use crate::services::connector::request_service::Response; + use crate::services::router::body; + + const TEST_SUBGRAPH_NAME: &str = "test_subgraph_name"; + const TEST_SOURCE_NAME: &str = "test_source_name"; + const TEST_URL_TEMPLATE: &str = "/test"; + const TEST_HEADER_NAME: &str = "test_header_name"; + const TEST_HEADER_VALUE: &str = "test_header_value"; + const TEST_STATIC: &str = "test_static"; + + fn connector() -> Connector { + Connector { + id: ConnectId::new( + TEST_SUBGRAPH_NAME.into(), + Some(SourceName::cast(TEST_SOURCE_NAME)), + name!(Query), + name!(users), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: None, + connect_template: StringTemplate::from_str(TEST_URL_TEMPLATE).unwrap(), + ..Default::default() + }, + selection: JSONSelection::empty(), + config: None, + max_requests: None, + entity_resolver: None, + spec: ConnectSpec::V0_1, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "label".into(), + } + } + + fn response_key() -> ResponseKey { + ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + } + } + + fn http_request() -> http::Request { + http::Request::builder().body("".into()).unwrap() + } + + fn http_request_with_header() -> http::Request { + let mut http_request = http::Request::builder().body("".into()).unwrap(); + http_request.headers_mut().insert( + TEST_HEADER_NAME, + HeaderValue::from_static(TEST_HEADER_VALUE), + ); + http_request + } + + fn connector_request( + http_request: http::Request, + context: Option, + mapping_problems: Option>, + ) -> Request { + Request { + context: context.unwrap_or_default(), + connector: Arc::new(connector()), + transport_request: TransportRequest::Http(HttpRequest { + inner: http_request, + debug: Default::default(), + }), + key: response_key(), + mapping_problems: mapping_problems.unwrap_or_default(), + supergraph_request: Default::default(), + } + } + + fn connector_response(status_code: StatusCode) -> Response { + connector_response_with_mapping_problems(status_code, vec![]) + } + + fn connector_response_with_mapping_problems( + status_code: StatusCode, + mapping_problems: Vec, + ) -> Response { + Response { + transport_result: Ok(TransportResponse::Http(HttpResponse { + inner: http::Response::builder() + .status(status_code) + .body(body::empty()) + .expect("expecting valid response") + .into_parts() + .0, + })), + mapped_response: MappedResponse::Data { + data: serde_json::json!({}) + .try_into() + .expect("expecting valid JSON"), + key: response_key(), + problems: mapping_problems, + }, + } + } + + fn connector_response_with_mapped_error(status_code: StatusCode) -> Response { + Response { + transport_result: Ok(TransportResponse::Http(HttpResponse { + inner: http::Response::builder() + .status(status_code) + .body(body::empty()) + .expect("expecting valid response") + .into_parts() + .0, + })), + mapped_response: MappedResponse::Error { + error: RuntimeError::new("Internal server errror", &response_key()), + key: response_key(), + problems: vec![], + }, + } + } + + fn connector_response_with_header() -> Response { + Response { + transport_result: Ok(TransportResponse::Http(HttpResponse { + inner: http::Response::builder() + .status(200) + .header(TEST_HEADER_NAME, TEST_HEADER_VALUE) + .body(body::empty()) + .expect("expecting valid response") + .into_parts() + .0, + })), + mapped_response: MappedResponse::Data { + data: serde_json::json!({}) + .try_into() + .expect("expecting valid JSON"), + key: response_key(), + problems: vec![], + }, + } + } + + fn mapping_problems() -> Vec { + vec![ + Problem { + count: 1, + message: "error message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + Problem { + count: 2, + message: "warn message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + Problem { + count: 3, + message: "info message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + ] + } + + fn mapping_problem_array() -> Value { + Value::Array(Array::String(vec![ + StringValue::from(String::from( + r#"{"message":"error message","path":"@.id","count":1,"location":"Selection"}"#, + )), + StringValue::from(String::from( + r#"{"message":"warn message","path":"@.id","count":2,"location":"Selection"}"#, + )), + StringValue::from(String::from( + r#"{"message":"info message","path":"@.id","count":3,"location":"Selection"}"#, + )), + ])) + } + + #[test] + fn connector_on_request_static_field() { + let selector = ConnectorSelector::StaticField { + r#static: TEST_STATIC.into(), + }; + assert_eq!( + Some(TEST_STATIC.into()), + selector.on_request(&connector_request(http_request(), None, None)) + ); + } + + #[test] + fn connector_on_request_subgraph_name() { + let selector = ConnectorSelector::SubgraphName { + subgraph_name: true, + }; + assert_eq!( + Some(TEST_SUBGRAPH_NAME.into()), + selector.on_request(&connector_request(http_request(), None, None)) + ); + } + + #[test] + fn connector_on_request_connector_source() { + let selector = ConnectorSelector::ConnectorSource { + connector_source: ConnectorSource::Name, + }; + assert_eq!( + Some(TEST_SOURCE_NAME.into()), + selector.on_request(&connector_request(http_request(), None, None)) + ); + } + + #[test] + fn connector_on_request_url_template() { + let selector = ConnectorSelector::ConnectorUrlTemplate { + connector_url_template: true, + }; + assert_eq!( + Some(TEST_URL_TEMPLATE.into()), + selector.on_request(&connector_request(http_request(), None, None)) + ); + } + + #[test] + fn connector_on_request_header_defaulted() { + let selector = ConnectorSelector::HttpRequestHeader { + connector_http_request_header: TEST_HEADER_NAME.to_string(), + redact: None, + default: Some("defaulted".into()), + }; + assert_eq!( + Some("defaulted".into()), + selector.on_request(&connector_request(http_request(), None, None)) + ); + } + + #[test] + fn connector_on_request_header_with_value() { + let selector = ConnectorSelector::HttpRequestHeader { + connector_http_request_header: TEST_HEADER_NAME.to_string(), + redact: None, + default: None, + }; + assert_eq!( + Some(TEST_HEADER_VALUE.into()), + selector.on_request(&connector_request(http_request_with_header(), None, None)) + ); + } + + #[test] + fn connector_on_response_header_defaulted() { + let selector = ConnectorSelector::ConnectorResponseHeader { + connector_http_response_header: TEST_HEADER_NAME.to_string(), + redact: None, + default: Some("defaulted".into()), + }; + assert_eq!( + Some("defaulted".into()), + selector.on_response(&connector_response(StatusCode::OK)) + ); + } + + #[test] + fn connector_on_response_header_with_value() { + let selector = ConnectorSelector::ConnectorResponseHeader { + connector_http_response_header: TEST_HEADER_NAME.to_string(), + redact: None, + default: None, + }; + assert_eq!( + Some(TEST_HEADER_VALUE.into()), + selector.on_response(&connector_response_with_header()) + ); + } + + #[test] + fn connector_on_response_status_code() { + let selector = ConnectorSelector::ConnectorResponseStatus { + connector_http_response_status: ResponseStatus::Code, + }; + assert_eq!( + Some(200.into()), + selector.on_response(&connector_response(StatusCode::OK)) + ); + } + + #[test] + fn connector_on_response_status_reason_ok() { + let selector = ConnectorSelector::ConnectorResponseStatus { + connector_http_response_status: ResponseStatus::Reason, + }; + assert_eq!( + Some("OK".into()), + selector.on_response(&connector_response(StatusCode::OK)) + ); + } + + #[test] + fn connector_on_response_status_code_not_found() { + let selector = ConnectorSelector::ConnectorResponseStatus { + connector_http_response_status: ResponseStatus::Reason, + }; + assert_eq!( + Some("Not Found".into()), + selector.on_response(&connector_response(StatusCode::NOT_FOUND)) + ); + } + + #[test] + fn connector_on_request_mapping_problems_none() { + let selector = ConnectorSelector::RequestMappingProblems { + connector_request_mapping_problems: MappingProblems::Problems, + }; + assert_eq!( + Some(Value::Array(Array::String(vec![]))), + selector.on_request(&connector_request(http_request(), None, None)) + ); + } + + #[test] + fn connector_on_request_mapping_problems_count_zero() { + let selector = ConnectorSelector::RequestMappingProblems { + connector_request_mapping_problems: MappingProblems::Count, + }; + assert_eq!( + Some(0.into()), + selector.on_request(&connector_request(http_request(), None, None)) + ); + } + + #[test] + fn connector_on_request_mapping_problems() { + let selector = ConnectorSelector::RequestMappingProblems { + connector_request_mapping_problems: MappingProblems::Problems, + }; + assert_eq!( + Some(mapping_problem_array()), + selector.on_request(&connector_request( + http_request(), + None, + Some(mapping_problems()) + )) + ); + } + + #[test] + fn connector_on_request_mapping_problems_count() { + let selector = ConnectorSelector::RequestMappingProblems { + connector_request_mapping_problems: MappingProblems::Count, + }; + assert_eq!( + Some(6.into()), + selector.on_request(&connector_request( + http_request(), + None, + Some(mapping_problems()), + )) + ); + } + + #[test] + fn connector_on_request_mapping_problems_boolean() { + let selector = ConnectorSelector::RequestMappingProblems { + connector_request_mapping_problems: MappingProblems::Boolean, + }; + assert_eq!( + Some(true.into()), + selector.on_request(&connector_request( + http_request(), + None, + Some(mapping_problems()), + )) + ); + } + + #[test] + fn connector_on_response_mapping_problems_none() { + let selector = ConnectorSelector::ResponseMappingProblems { + connector_response_mapping_problems: MappingProblems::Problems, + }; + assert_eq!( + Some(Value::Array(Array::String(vec![]))), + selector.on_response(&connector_response(StatusCode::OK)) + ); + } + + #[test] + fn connector_on_response_mapping_problems_count_zero() { + let selector = ConnectorSelector::ResponseMappingProblems { + connector_response_mapping_problems: MappingProblems::Count, + }; + assert_eq!( + Some(0.into()), + selector.on_response(&connector_response(StatusCode::OK)) + ); + } + + #[test] + fn connector_on_response_mapping_problems() { + let selector = ConnectorSelector::ResponseMappingProblems { + connector_response_mapping_problems: MappingProblems::Problems, + }; + assert_eq!( + Some(mapping_problem_array()), + selector.on_response(&connector_response_with_mapping_problems( + StatusCode::OK, + mapping_problems() + )) + ); + } + + #[test] + fn connector_on_response_mapping_problems_count() { + let selector = ConnectorSelector::ResponseMappingProblems { + connector_response_mapping_problems: MappingProblems::Count, + }; + assert_eq!( + Some(6.into()), + selector.on_response(&connector_response_with_mapping_problems( + StatusCode::OK, + mapping_problems() + )) + ); + } + + #[test] + fn connector_on_response_mapping_problems_boolean() { + let selector = ConnectorSelector::ResponseMappingProblems { + connector_response_mapping_problems: MappingProblems::Boolean, + }; + assert_eq!( + Some(true.into()), + selector.on_response(&connector_response_with_mapping_problems( + StatusCode::OK, + mapping_problems() + )) + ); + } + + #[test] + fn connector_on_drop_static_field() { + let selector = ConnectorSelector::StaticField { + r#static: TEST_STATIC.into(), + }; + assert_eq!(Some(TEST_STATIC.into()), selector.on_drop()); + } + + #[test] + fn connector_request_context() { + let selector = ConnectorSelector::RequestContext { + request_context: "context_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + let context = Context::new(); + let _ = context.insert("context_key".to_string(), "context_value".to_string()); + assert_eq!( + selector + .on_request(&connector_request(http_request(), Some(context), None)) + .unwrap(), + "context_value".into() + ); + + assert_eq!( + selector + .on_request(&connector_request(http_request(), None, None)) + .unwrap(), + "defaulted".into() + ); + } + + #[test] + fn connector_supergraph_operation_name_string() { + let selector = ConnectorSelector::SupergraphOperationName { + supergraph_operation_name: OperationName::String, + redact: None, + default: Some("defaulted".to_string()), + }; + let context = Context::new(); + let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); + + assert_eq!( + selector.on_request(&connector_request(http_request(), None, None)), + Some("defaulted".into()) + ); + assert_eq!( + selector.on_request(&connector_request(http_request(), Some(context), None)), + Some("topProducts".into()) + ); + } + + #[test] + fn connector_supergraph_operation_name_hash() { + let selector = ConnectorSelector::SupergraphOperationName { + supergraph_operation_name: OperationName::Hash, + redact: None, + default: Some("defaulted".to_string()), + }; + let context = Context::new(); + let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); + assert_eq!( + selector.on_request(&connector_request(http_request(), None, None)), + Some("96294f50edb8f006f6b0a2dadae50d3c521e9841d07d6395d91060c8ccfed7f0".into()) + ); + + assert_eq!( + selector.on_request(&connector_request(http_request(), Some(context), None)), + Some("bd141fca26094be97c30afd42e9fc84755b252e7052d8c992358319246bd555a".into()) + ); + } + + #[test] + fn connector_supergraph_operation_kind() { + let selector = ConnectorSelector::SupergraphOperationKind { + supergraph_operation_kind: OperationKind::String, + }; + let context = Context::new(); + let _ = context.insert(OPERATION_KIND, "query".to_string()); + assert_eq!( + selector.on_request(&connector_request(http_request(), Some(context), None)), + Some("query".into()) + ); + } + + #[test] + fn connector_on_response_error() { + let selector = ConnectorSelector::OnResponseError { + connector_on_response_error: true, + }; + assert_eq!( + selector + .on_response(&connector_response_with_mapped_error( + StatusCode::INTERNAL_SERVER_ERROR + )) + .unwrap(), + Value::Bool(true) + ); + + assert_eq!( + selector + .on_response(&connector_response(StatusCode::OK)) + .unwrap(), + Value::Bool(false) + ); + } + + #[test] + fn error_reason() { + let selector = ConnectorSelector::Error { + error: ErrorRepr::Reason, + }; + let err = "NaN".parse::().unwrap_err(); + assert_eq!( + selector.on_error(&err.into(), &Context::new()).unwrap(), + Value::String("invalid digit found in string".into()) + ); + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/connector/snapshots/apollo_router__plugins__telemetry__config_new__connector__events__tests__connector_events_request@logs.snap b/apollo-router/src/plugins/telemetry/config_new/connector/snapshots/apollo_router__plugins__telemetry__config_new__connector__events__tests__connector_events_request@logs.snap new file mode 100644 index 0000000000..5d36bbee0d --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/connector/snapshots/apollo_router__plugins__telemetry__config_new__connector__events__tests__connector_events_request@logs.snap @@ -0,0 +1,14 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/connector/events.rs +expression: yaml +--- +- fields: + kind: my.request.event + level: INFO + message: my request event message + span: + name: connect_request + otel.kind: INTERNAL + spans: + - name: connect_request + otel.kind: INTERNAL diff --git a/apollo-router/src/plugins/telemetry/config_new/connector/snapshots/apollo_router__plugins__telemetry__config_new__connector__events__tests__connector_events_response@logs.snap b/apollo-router/src/plugins/telemetry/config_new/connector/snapshots/apollo_router__plugins__telemetry__config_new__connector__events__tests__connector_events_response@logs.snap new file mode 100644 index 0000000000..cf87433eac --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/connector/snapshots/apollo_router__plugins__telemetry__config_new__connector__events__tests__connector_events_response@logs.snap @@ -0,0 +1,14 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/connector/events.rs +expression: yaml +--- +- fields: + kind: my.response.event + level: ERROR + message: my response event message + span: + name: connect_request + otel.kind: INTERNAL + spans: + - name: connect_request + otel.kind: INTERNAL diff --git a/apollo-router/src/plugins/telemetry/config_new/connector/spans.rs b/apollo-router/src/plugins/telemetry/config_new/connector/spans.rs new file mode 100644 index 0000000000..777bde1d48 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/connector/spans.rs @@ -0,0 +1,27 @@ +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::conditional::Conditional; +use crate::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes; +use crate::plugins::telemetry::config_new::connector::selectors::ConnectorSelector; +use crate::plugins::telemetry::config_new::extendable::Extendable; +use crate::plugins::telemetry::otlp::TelemetryDataKind; + +#[derive(Deserialize, JsonSchema, Clone, Default, Debug)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct ConnectorSpans { + /// Custom attributes that are attached to the connector span. + pub(crate) attributes: Extendable>, +} + +impl DefaultForLevel for ConnectorSpans { + fn defaults_for_level( + &mut self, + requirement_level: DefaultAttributeRequirementLevel, + kind: TelemetryDataKind, + ) { + self.attributes.defaults_for_level(requirement_level, kind); + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/cost/mod.rs b/apollo-router/src/plugins/telemetry/config_new/cost/mod.rs index 8341790eac..3bc33f0286 100644 --- a/apollo-router/src/plugins/telemetry/config_new/cost/mod.rs +++ b/apollo-router/src/plugins/telemetry/config_new/cost/mod.rs @@ -1,9 +1,10 @@ use std::collections::HashMap; +use std::marker::PhantomData; use std::sync::Arc; +use opentelemetry::Key; +use opentelemetry::KeyValue; use opentelemetry::metrics::MeterProvider; -use opentelemetry_api::Key; -use opentelemetry_api::KeyValue; use parking_lot::Mutex; use schemars::JsonSchema; use serde::Deserialize; @@ -12,25 +13,22 @@ use tower::BoxError; use super::attributes::StandardAttribute; use super::instruments::Increment; use super::instruments::StaticInstrument; +use crate::Context; +use crate::graphql; use crate::metrics; -use crate::plugins::demand_control::COST_ACTUAL_KEY; -use crate::plugins::demand_control::COST_DELTA_KEY; -use crate::plugins::demand_control::COST_ESTIMATED_KEY; -use crate::plugins::demand_control::COST_RESULT_KEY; use crate::plugins::telemetry::config::AttributeValue; -use crate::plugins::telemetry::config_new::attributes::SupergraphAttributes; +use crate::plugins::telemetry::config_new::Selectors; use crate::plugins::telemetry::config_new::conditions::Condition; use crate::plugins::telemetry::config_new::extendable::Extendable; use crate::plugins::telemetry::config_new::instruments::CustomHistogram; use crate::plugins::telemetry::config_new::instruments::CustomHistogramInner; use crate::plugins::telemetry::config_new::instruments::DefaultedStandardInstrument; use crate::plugins::telemetry::config_new::instruments::Instrumented; -use crate::plugins::telemetry::config_new::selectors::SupergraphSelector; -use crate::plugins::telemetry::config_new::Selectors; +use crate::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes; +use crate::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector; use crate::services::supergraph; use crate::services::supergraph::Request; use crate::services::supergraph::Response; -use crate::Context; pub(crate) const APOLLO_PRIVATE_COST_ESTIMATED: Key = Key::from_static_str("apollo_private.cost.estimated"); @@ -41,6 +39,11 @@ pub(crate) const APOLLO_PRIVATE_COST_STRATEGY: Key = pub(crate) const APOLLO_PRIVATE_COST_RESULT: Key = Key::from_static_str("apollo_private.cost.result"); +const COST_ACTUAL_KEY: &str = "cost.actual"; +const COST_DELTA_KEY: &str = "cost.delta"; +const COST_ESTIMATED_KEY: &str = "cost.estimated"; +const COST_RESULT_KEY: &str = "cost.result"; + /// Attributes for Cost #[derive(Deserialize, JsonSchema, Clone, Default, Debug, PartialEq)] #[serde(deny_unknown_fields, default)] @@ -59,16 +62,14 @@ pub(crate) struct SupergraphCostAttributes { cost_result: Option, } -impl Selectors for SupergraphCostAttributes { - type Request = supergraph::Request; - type Response = supergraph::Response; - type EventResponse = crate::graphql::Response; - - fn on_request(&self, _request: &Self::Request) -> Vec { +impl Selectors + for SupergraphCostAttributes +{ + fn on_request(&self, _request: &supergraph::Request) -> Vec { Vec::default() } - fn on_response(&self, _response: &Self::Response) -> Vec { + fn on_response(&self, _response: &supergraph::Response) -> Vec { Vec::default() } @@ -76,7 +77,11 @@ impl Selectors for SupergraphCostAttributes { Vec::default() } - fn on_response_event(&self, _response: &Self::EventResponse, ctx: &Context) -> Vec { + fn on_response_event( + &self, + _response: &crate::graphql::Response, + ctx: &Context, + ) -> Vec { let mut attrs = Vec::with_capacity(4); if let Some(estimated_cost) = self.estimated_cost_if_configured(ctx) { attrs.push(estimated_cost); @@ -117,7 +122,7 @@ impl SupergraphCostAttributes { let key = self .cost_delta .as_ref()? - .key(Key::from_static_str("cost.delta"))?; + .key(Key::from_static_str(COST_DELTA_KEY))?; let value = ctx.get_cost_delta().ok()??; Some(KeyValue::new(key, value)) } @@ -216,7 +221,13 @@ impl CostInstrumentsConfig { config: &DefaultedStandardInstrument>, selector: SupergraphSelector, static_instruments: &Arc>, - ) -> CustomHistogram { + ) -> CustomHistogram< + Request, + Response, + graphql::Response, + SupergraphAttributes, + SupergraphSelector, + > { let mut nb_attributes = 0; let selectors = match config { DefaultedStandardInstrument::Bool(_) | DefaultedStandardInstrument::Unset => None, @@ -241,6 +252,7 @@ impl CostInstrumentsConfig { selector: Some(Arc::new(selector)), selectors, updated: false, + _phantom: PhantomData, }), } } @@ -254,6 +266,7 @@ pub(crate) struct CostInstruments { CustomHistogram< supergraph::Request, supergraph::Response, + crate::graphql::Response, SupergraphAttributes, SupergraphSelector, >, @@ -264,6 +277,7 @@ pub(crate) struct CostInstruments { CustomHistogram< supergraph::Request, supergraph::Response, + crate::graphql::Response, SupergraphAttributes, SupergraphSelector, >, @@ -273,6 +287,7 @@ pub(crate) struct CostInstruments { CustomHistogram< supergraph::Request, supergraph::Response, + crate::graphql::Response, SupergraphAttributes, SupergraphSelector, >, @@ -377,12 +392,12 @@ pub(crate) fn add_cost_attributes(context: &Context, custom_attributes: &mut Vec mod test { use std::sync::Arc; + use crate::Context; use crate::context::OPERATION_NAME; use crate::plugins::telemetry::config_new::cost::CostInstruments; use crate::plugins::telemetry::config_new::cost::CostInstrumentsConfig; use crate::plugins::telemetry::config_new::instruments::Instrumented; use crate::services::supergraph; - use crate::Context; #[test] fn test_default_estimated() { diff --git a/apollo-router/src/plugins/telemetry/config_new/events.rs b/apollo-router/src/plugins/telemetry/config_new/events.rs index c5fac133a2..fc43e316f9 100644 --- a/apollo-router/src/plugins/telemetry/config_new/events.rs +++ b/apollo-router/src/plugins/telemetry/config_new/events.rs @@ -1,34 +1,37 @@ use std::fmt::Debug; +use std::marker::PhantomData; use std::sync::Arc; -#[cfg(test)] -use http::HeaderValue; -use opentelemetry::Key; use opentelemetry::KeyValue; -use parking_lot::Mutex; use schemars::JsonSchema; use serde::Deserialize; use tower::BoxError; -use tracing::info_span; use tracing::Span; +use tracing::info_span; -use super::instruments::Instrumented; use super::Selector; use super::Selectors; use super::Stage; -use crate::plugins::telemetry::config_new::attributes::RouterAttributes; -use crate::plugins::telemetry::config_new::attributes::SubgraphAttributes; -use crate::plugins::telemetry::config_new::attributes::SupergraphAttributes; +use super::router::events::RouterEvents; +use super::subgraph::events::SubgraphEvents; +use super::supergraph::events::SupergraphEvents; +use crate::Context; use crate::plugins::telemetry::config_new::conditions::Condition; +use crate::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes; +use crate::plugins::telemetry::config_new::connector::events::ConnectorEvents; +use crate::plugins::telemetry::config_new::connector::events::ConnectorEventsConfig; +use crate::plugins::telemetry::config_new::connector::selectors::ConnectorSelector; use crate::plugins::telemetry::config_new::extendable::Extendable; -use crate::plugins::telemetry::config_new::selectors::RouterSelector; -use crate::plugins::telemetry::config_new::selectors::SubgraphSelector; -use crate::plugins::telemetry::config_new::selectors::SupergraphSelector; +use crate::plugins::telemetry::config_new::router::attributes::RouterAttributes; +use crate::plugins::telemetry::config_new::router::events::RouterEventsConfig; +use crate::plugins::telemetry::config_new::router::selectors::RouterSelector; +use crate::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes; +use crate::plugins::telemetry::config_new::subgraph::events::SubgraphEventsConfig; +use crate::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector; +use crate::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes; +use crate::plugins::telemetry::config_new::supergraph::events::SupergraphEventsConfig; +use crate::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector; use crate::plugins::telemetry::dynamic_attribute::EventDynAttribute; -use crate::services::router; -use crate::services::subgraph; -use crate::services::supergraph; -use crate::Context; /// Events are #[derive(Deserialize, JsonSchema, Clone, Default, Debug)] @@ -40,6 +43,8 @@ pub(crate) struct Events { supergraph: Extendable>, /// Supergraph service events subgraph: Extendable>, + /// Connector events + connector: Extendable>, } impl Events { @@ -48,26 +53,13 @@ impl Events { .router .custom .iter() - .filter_map(|(event_name, event_cfg)| match &event_cfg.level { - EventLevel::Off => None, - _ => Some(CustomEvent { - inner: Mutex::new(CustomEventInner { - name: event_name.clone(), - level: event_cfg.level, - event_on: event_cfg.on, - message: event_cfg.message.clone(), - selectors: event_cfg.attributes.clone().into(), - condition: event_cfg.condition.clone(), - attributes: Vec::new(), - }), - }), - }) + .filter_map(|(name, config)| CustomEvent::from_config(name, config)) .collect(); RouterEvents { - request: self.router.attributes.request.clone().into(), - response: self.router.attributes.response.clone().into(), - error: self.router.attributes.error.clone().into(), + request: StandardEvent::from_config(&self.router.attributes.request), + response: StandardEvent::from_config(&self.router.attributes.response), + error: StandardEvent::from_config(&self.router.attributes.error), custom: custom_events, } } @@ -77,26 +69,13 @@ impl Events { .supergraph .custom .iter() - .filter_map(|(event_name, event_cfg)| match &event_cfg.level { - EventLevel::Off => None, - _ => Some(CustomEvent { - inner: Mutex::new(CustomEventInner { - name: event_name.clone(), - level: event_cfg.level, - event_on: event_cfg.on, - message: event_cfg.message.clone(), - selectors: event_cfg.attributes.clone().into(), - condition: event_cfg.condition.clone(), - attributes: Vec::new(), - }), - }), - }) + .filter_map(|(name, config)| CustomEvent::from_config(name, config)) .collect(); SupergraphEvents { - request: self.supergraph.attributes.request.clone().into(), - response: self.supergraph.attributes.response.clone().into(), - error: self.supergraph.attributes.error.clone().into(), + request: StandardEvent::from_config(&self.supergraph.attributes.request), + response: StandardEvent::from_config(&self.supergraph.attributes.response), + error: StandardEvent::from_config(&self.supergraph.attributes.error), custom: custom_events, } } @@ -106,59 +85,54 @@ impl Events { .subgraph .custom .iter() - .filter_map(|(event_name, event_cfg)| match &event_cfg.level { - EventLevel::Off => None, - _ => Some(CustomEvent { - inner: Mutex::new(CustomEventInner { - name: event_name.clone(), - level: event_cfg.level, - event_on: event_cfg.on, - message: event_cfg.message.clone(), - selectors: event_cfg.attributes.clone().into(), - condition: event_cfg.condition.clone(), - attributes: Vec::new(), - }), - }), - }) + .filter_map(|(name, config)| CustomEvent::from_config(name, config)) .collect(); SubgraphEvents { - request: self.subgraph.attributes.request.clone().into(), - response: self.subgraph.attributes.response.clone().into(), - error: self.subgraph.attributes.error.clone().into(), + request: StandardEvent::from_config(&self.subgraph.attributes.request), + response: StandardEvent::from_config(&self.subgraph.attributes.response), + error: StandardEvent::from_config(&self.subgraph.attributes.error), custom: custom_events, } } + pub(crate) fn new_connector_events(&self) -> ConnectorEvents { + super::connector::events::new_connector_events(&self.connector) + } + pub(crate) fn validate(&self) -> Result<(), String> { - if let StandardEventConfig::Conditional { condition, .. } = &self.router.attributes.request - { - condition.validate(Some(Stage::Request))?; - } - if let StandardEventConfig::Conditional { condition, .. } = &self.router.attributes.response - { - condition.validate(Some(Stage::Response))?; - } - if let StandardEventConfig::Conditional { condition, .. } = - &self.supergraph.attributes.request - { - condition.validate(Some(Stage::Request))?; - } - if let StandardEventConfig::Conditional { condition, .. } = - &self.supergraph.attributes.response - { - condition.validate(Some(Stage::Response))?; - } - if let StandardEventConfig::Conditional { condition, .. } = - &self.subgraph.attributes.request - { - condition.validate(Some(Stage::Request))?; - } - if let StandardEventConfig::Conditional { condition, .. } = - &self.subgraph.attributes.response - { - condition.validate(Some(Stage::Response))?; - } + self.router + .attributes + .request + .validate(Some(Stage::Request))?; + self.router + .attributes + .response + .validate(Some(Stage::Response))?; + self.supergraph + .attributes + .request + .validate(Some(Stage::Request))?; + self.supergraph + .attributes + .response + .validate(Some(Stage::Response))?; + self.subgraph + .attributes + .request + .validate(Some(Stage::Request))?; + self.subgraph + .attributes + .response + .validate(Some(Stage::Response))?; + self.connector + .attributes + .request + .validate(Some(Stage::Request))?; + self.connector + .attributes + .response + .validate(Some(Stage::Response))?; for (name, custom_event) in &self.router.custom { custom_event.validate().map_err(|err| { format!("configuration error for router custom event {name:?}: {err}") @@ -174,424 +148,78 @@ impl Events { format!("configuration error for subgraph custom event {name:?}: {err}") })?; } + for (name, custom_event) in &self.connector.custom { + custom_event.validate().map_err(|err| { + format!("configuration error for connector HTTP custom event {name:?}: {err}") + })?; + } Ok(()) } } -pub(crate) type RouterEvents = - CustomEvents; - -pub(crate) type SupergraphEvents = CustomEvents< - supergraph::Request, - supergraph::Response, - SupergraphAttributes, - SupergraphSelector, ->; - -pub(crate) type SubgraphEvents = - CustomEvents; - -pub(crate) struct CustomEvents +pub(crate) struct CustomEvents where - Attributes: Selectors + Default, + Attributes: Selectors + Default, Sel: Selector + Debug, { - request: StandardEvent, - response: StandardEvent, - error: StandardEvent, - custom: Vec>, -} - -impl Instrumented - for CustomEvents -{ - type Request = router::Request; - type Response = router::Response; - type EventResponse = (); - - fn on_request(&self, request: &Self::Request) { - if self.request.level() != EventLevel::Off { - if let Some(condition) = self.request.condition() { - if condition.lock().evaluate_request(request) != Some(true) { - return; - } - } - let mut attrs = Vec::with_capacity(5); - #[cfg(test)] - let mut headers: indexmap::IndexMap = request - .router_request - .headers() - .clone() - .into_iter() - .filter_map(|(name, val)| Some((name?.to_string(), val))) - .collect(); - #[cfg(test)] - headers.sort_keys(); - #[cfg(not(test))] - let headers = request.router_request.headers(); - - attrs.push(KeyValue::new( - Key::from_static_str("http.request.headers"), - opentelemetry::Value::String(format!("{:?}", headers).into()), - )); - attrs.push(KeyValue::new( - Key::from_static_str("http.request.method"), - opentelemetry::Value::String(format!("{}", request.router_request.method()).into()), - )); - attrs.push(KeyValue::new( - Key::from_static_str("http.request.uri"), - opentelemetry::Value::String(format!("{}", request.router_request.uri()).into()), - )); - attrs.push(KeyValue::new( - Key::from_static_str("http.request.version"), - opentelemetry::Value::String( - format!("{:?}", request.router_request.version()).into(), - ), - )); - attrs.push(KeyValue::new( - Key::from_static_str("http.request.body"), - opentelemetry::Value::String(format!("{:?}", request.router_request.body()).into()), - )); - log_event(self.request.level(), "router.request", attrs, ""); - } - for custom_event in &self.custom { - custom_event.on_request(request); - } - } - - fn on_response(&self, response: &Self::Response) { - if self.response.level() != EventLevel::Off { - if let Some(condition) = self.response.condition() { - if !condition.lock().evaluate_response(response) { - return; - } - } - let mut attrs = Vec::with_capacity(4); - - #[cfg(test)] - let mut headers: indexmap::IndexMap = response - .response - .headers() - .clone() - .into_iter() - .filter_map(|(name, val)| Some((name?.to_string(), val))) - .collect(); - #[cfg(test)] - headers.sort_keys(); - #[cfg(not(test))] - let headers = response.response.headers(); - attrs.push(KeyValue::new( - Key::from_static_str("http.response.headers"), - opentelemetry::Value::String(format!("{:?}", headers).into()), - )); - attrs.push(KeyValue::new( - Key::from_static_str("http.response.status"), - opentelemetry::Value::String(format!("{}", response.response.status()).into()), - )); - attrs.push(KeyValue::new( - Key::from_static_str("http.response.version"), - opentelemetry::Value::String(format!("{:?}", response.response.version()).into()), - )); - attrs.push(KeyValue::new( - Key::from_static_str("http.response.body"), - opentelemetry::Value::String(format!("{:?}", response.response.body()).into()), - )); - log_event(self.response.level(), "router.response", attrs, ""); - } - for custom_event in &self.custom { - custom_event.on_response(response); - } - } - - fn on_error(&self, error: &BoxError, ctx: &Context) { - if self.error.level() != EventLevel::Off { - if let Some(condition) = self.error.condition() { - if !condition.lock().evaluate_error(error, ctx) { - return; - } - } - log_event( - self.error.level(), - "router.error", - vec![KeyValue::new( - Key::from_static_str("error"), - opentelemetry::Value::String(error.to_string().into()), - )], - "", - ); - } - for custom_event in &self.custom { - custom_event.on_error(error, ctx); - } - } -} - -impl Instrumented - for CustomEvents< - supergraph::Request, - supergraph::Response, - SupergraphAttributes, - SupergraphSelector, - > -{ - type Request = supergraph::Request; - type Response = supergraph::Response; - type EventResponse = crate::graphql::Response; - - fn on_request(&self, request: &Self::Request) { - if self.request.level() != EventLevel::Off { - if let Some(condition) = self.request.condition() { - if condition.lock().evaluate_request(request) != Some(true) { - return; - } - } - let mut attrs = Vec::with_capacity(5); - #[cfg(test)] - let mut headers: indexmap::IndexMap = request - .supergraph_request - .headers() - .clone() - .into_iter() - .filter_map(|(name, val)| Some((name?.to_string(), val))) - .collect(); - #[cfg(test)] - headers.sort_keys(); - #[cfg(not(test))] - let headers = request.supergraph_request.headers(); - attrs.push(KeyValue::new( - Key::from_static_str("http.request.headers"), - opentelemetry::Value::String(format!("{:?}", headers).into()), - )); - attrs.push(KeyValue::new( - Key::from_static_str("http.request.method"), - opentelemetry::Value::String( - format!("{}", request.supergraph_request.method()).into(), - ), - )); - attrs.push(KeyValue::new( - Key::from_static_str("http.request.uri"), - opentelemetry::Value::String( - format!("{}", request.supergraph_request.uri()).into(), - ), - )); - attrs.push(KeyValue::new( - Key::from_static_str("http.request.version"), - opentelemetry::Value::String( - format!("{:?}", request.supergraph_request.version()).into(), - ), - )); - attrs.push(KeyValue::new( - Key::from_static_str("http.request.body"), - opentelemetry::Value::String( - serde_json::to_string(request.supergraph_request.body()) - .unwrap_or_default() - .into(), - ), - )); - log_event(self.request.level(), "supergraph.request", attrs, ""); - } - if self.response.level() != EventLevel::Off { - request - .context - .extensions() - .with_lock(|mut lock| lock.insert(SupergraphEventResponse(self.response.clone()))); - } - for custom_event in &self.custom { - custom_event.on_request(request); - } - } - - fn on_response(&self, response: &Self::Response) { - for custom_event in &self.custom { - custom_event.on_response(response); - } - } - - fn on_response_event(&self, response: &Self::EventResponse, ctx: &Context) { - for custom_event in &self.custom { - custom_event.on_response_event(response, ctx); - } - } - - fn on_error(&self, error: &BoxError, ctx: &Context) { - if self.error.level() != EventLevel::Off { - if let Some(condition) = self.error.condition() { - if !condition.lock().evaluate_error(error, ctx) { - return; - } - } - log_event( - self.error.level(), - "supergraph.error", - vec![KeyValue::new( - Key::from_static_str("error"), - opentelemetry::Value::String(error.to_string().into()), - )], - "", - ); - } - for custom_event in &self.custom { - custom_event.on_error(error, ctx); - } - } -} - -impl Instrumented - for CustomEvents -{ - type Request = subgraph::Request; - type Response = subgraph::Response; - type EventResponse = (); - - fn on_request(&self, request: &Self::Request) { - if let Some(condition) = self.request.condition() { - if condition.lock().evaluate_request(request) != Some(true) { - return; - } - } - if self.request.level() != EventLevel::Off { - request - .context - .extensions() - .with_lock(|mut lock| lock.insert(SubgraphEventRequest(self.request.clone()))); - } - if self.response.level() != EventLevel::Off { - request - .context - .extensions() - .with_lock(|mut lock| lock.insert(SubgraphEventResponse(self.response.clone()))); - } - for custom_event in &self.custom { - custom_event.on_request(request); - } - } - - fn on_response(&self, response: &Self::Response) { - for custom_event in &self.custom { - custom_event.on_response(response); - } - } - - fn on_error(&self, error: &BoxError, ctx: &Context) { - if self.error.level() != EventLevel::Off { - if let Some(condition) = self.error.condition() { - if !condition.lock().evaluate_error(error, ctx) { - return; - } - } - log_event( - self.error.level(), - "subgraph.error", - vec![KeyValue::new( - Key::from_static_str("error"), - opentelemetry::Value::String(error.to_string().into()), - )], - "", - ); - } - for custom_event in &self.custom { - custom_event.on_error(error, ctx); - } - } -} - -#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] -#[serde(deny_unknown_fields, default)] -struct RouterEventsConfig { - /// Log the router request - request: StandardEventConfig, - /// Log the router response - response: StandardEventConfig, - /// Log the router error - error: StandardEventConfig, -} - -#[derive(Clone)] -pub(crate) struct SupergraphEventResponse(pub(crate) StandardEvent); -#[derive(Clone)] -pub(crate) struct SubgraphEventResponse(pub(crate) StandardEvent); -#[derive(Clone)] -pub(crate) struct SubgraphEventRequest(pub(crate) StandardEvent); - -#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] -#[serde(deny_unknown_fields, default)] -struct SupergraphEventsConfig { - /// Log the supergraph request - request: StandardEventConfig, - /// Log the supergraph response - response: StandardEventConfig, - /// Log the supergraph error - error: StandardEventConfig, -} - -#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] -#[serde(deny_unknown_fields, default)] -struct SubgraphEventsConfig { - /// Log the subgraph request - request: StandardEventConfig, - /// Log the subgraph response - response: StandardEventConfig, - /// Log the subgraph error - error: StandardEventConfig, + pub(super) request: Option>, + pub(super) response: Option>, + pub(super) error: Option>, + pub(super) custom: Vec>, } #[derive(Deserialize, JsonSchema, Clone, Debug)] #[serde(untagged)] pub(crate) enum StandardEventConfig { - Level(EventLevel), + Level(EventLevelConfig), Conditional { - level: EventLevel, + level: EventLevelConfig, condition: Condition, }, } -#[derive(Debug, Clone)] -pub(crate) enum StandardEvent { - Level(EventLevel), - Conditional { - level: EventLevel, - condition: Arc>>, - }, -} - -impl From> for StandardEvent { - fn from(value: StandardEventConfig) -> Self { - match value { - StandardEventConfig::Level(level) => StandardEvent::Level(level), - StandardEventConfig::Conditional { level, condition } => StandardEvent::Conditional { - level, - condition: Arc::new(Mutex::new(condition)), - }, +impl StandardEventConfig { + fn validate(&self, restricted_stage: Option) -> Result<(), String> { + if let Self::Conditional { condition, .. } = self { + condition.validate(restricted_stage) + } else { + Ok(()) } } } impl Default for StandardEventConfig { fn default() -> Self { - Self::Level(EventLevel::default()) + Self::Level(EventLevelConfig::default()) } } -impl StandardEvent { - pub(crate) fn level(&self) -> EventLevel { - match self { - Self::Level(level) => *level, - Self::Conditional { level, .. } => *level, - } - } +#[derive(Debug)] +pub(crate) struct StandardEvent { + pub(crate) level: EventLevel, + pub(crate) condition: Condition, +} - pub(crate) fn condition(&self) -> Option<&Arc>>> { - match self { - Self::Level(_) => None, - Self::Conditional { condition, .. } => Some(condition), +impl StandardEvent { + pub(crate) fn from_config(config: &StandardEventConfig) -> Option { + match &config { + StandardEventConfig::Level(level) => EventLevel::from_config(level).map(|level| Self { + level, + condition: Condition::True, + }), + StandardEventConfig::Conditional { level, condition } => EventLevel::from_config(level) + .map(|level| Self { + level, + condition: condition.clone(), + }), } } } #[derive(Deserialize, JsonSchema, Clone, Debug, Default, PartialEq, Copy)] #[serde(rename_all = "snake_case")] -pub(crate) enum EventLevel { +pub(crate) enum EventLevelConfig { Info, Warn, Error, @@ -599,6 +227,24 @@ pub(crate) enum EventLevel { Off, } +#[derive(Debug, PartialEq, Clone, Copy)] +pub(crate) enum EventLevel { + Info, + Warn, + Error, +} + +impl EventLevel { + pub(crate) fn from_config(config: &EventLevelConfig) -> Option { + match config { + EventLevelConfig::Off => None, + EventLevelConfig::Info => Some(EventLevel::Info), + EventLevelConfig::Warn => Some(EventLevel::Warn), + EventLevelConfig::Error => Some(EventLevel::Error), + } + } +} + /// An event that can be logged as part of a trace. /// The event has an implicit `type` attribute that matches the name of the event in the yaml /// and a message that can be used to provide additional information. @@ -609,28 +255,26 @@ where E: Debug, { /// The log level of the event. - level: EventLevel, + pub(super) level: EventLevelConfig, /// The event message. - message: Arc, + pub(super) message: Arc, /// When to trigger the event. - on: EventOn, + pub(super) on: EventOn, /// The event attributes. #[serde(default = "Extendable::empty_arc::")] - attributes: Arc>, + pub(super) attributes: Arc>, /// The event conditions. #[serde(default = "Condition::empty::")] - condition: Condition, + pub(super) condition: Condition, } impl Event where - A: Selectors - + Default - + Debug, + A: Selectors + Default + Debug, E: Selector + Debug, { pub(crate) fn validate(&self) -> Result<(), String> { @@ -655,124 +299,101 @@ pub(crate) enum EventOn { Error, } -pub(crate) struct CustomEvent -where - A: Selectors + Default, - T: Selector + Debug, -{ - inner: Mutex>, -} - -struct CustomEventInner +pub(crate) struct CustomEvent where - A: Selectors + Default, + A: Selectors + Default, T: Selector + Debug, { - name: String, - level: EventLevel, - event_on: EventOn, - message: Arc, - selectors: Option>>, - condition: Condition, - attributes: Vec, + pub(super) name: String, + pub(super) level: EventLevel, + pub(super) event_on: EventOn, + pub(super) message: Arc, + pub(super) selectors: Arc>, + pub(super) condition: Condition, + pub(super) attributes: Vec, + pub(super) _phantom: PhantomData, } -impl Instrumented for CustomEvent +impl CustomEvent where - A: Selectors + Default, + A: Selectors + Default + Clone + Debug, T: Selector + Debug - + Debug, + + Clone, { - type Request = Request; - type Response = Response; - type EventResponse = EventResponse; - - fn on_request(&self, request: &Self::Request) { - let mut inner = self.inner.lock(); - if inner.condition.evaluate_request(request) != Some(true) - && inner.event_on == EventOn::Request + pub(crate) fn from_config(name: &str, config: &Event) -> Option { + EventLevel::from_config(&config.level).map(|level| Self { + name: name.to_owned(), + level, + event_on: config.on, + message: config.message.clone(), + selectors: config.attributes.clone(), + condition: config.condition.clone(), + attributes: Vec::new(), + _phantom: PhantomData, + }) + } + + pub(crate) fn on_request(&mut self, request: &Request) { + if self.condition.evaluate_request(request) != Some(true) + && self.event_on == EventOn::Request { return; } - if let Some(selectors) = &inner.selectors { - inner.attributes = selectors.on_request(request); - } + self.attributes = self.selectors.on_request(request); - if inner.event_on == EventOn::Request - && inner.condition.evaluate_request(request) != Some(false) - { - let attrs = std::mem::take(&mut inner.attributes); - inner.send_event(attrs); + if self.event_on == EventOn::Request { + let attrs = std::mem::take(&mut self.attributes); + log_event(self.level, &self.name, attrs, &self.message); } } - fn on_response(&self, response: &Self::Response) { - let mut inner = self.inner.lock(); - if inner.event_on != EventOn::Response { + pub(crate) fn on_response(&mut self, response: &Response) { + if self.event_on != EventOn::Response { return; } - if !inner.condition.evaluate_response(response) { + if !self.condition.evaluate_response(response) { return; } - if let Some(selectors) = &inner.selectors { - let mut new_attributes = selectors.on_response(response); - inner.attributes.append(&mut new_attributes); - } + let mut new_attributes = self.selectors.on_response(response); + self.attributes.append(&mut new_attributes); - let attrs = std::mem::take(&mut inner.attributes); - inner.send_event(attrs); + let attrs = std::mem::take(&mut self.attributes); + log_event(self.level, &self.name, attrs, &self.message); } - fn on_response_event(&self, response: &Self::EventResponse, ctx: &Context) { - let inner = self.inner.lock(); - if inner.event_on != EventOn::EventResponse { + pub(crate) fn on_response_event(&self, response: &EventResponse, ctx: &Context) { + if self.event_on != EventOn::EventResponse { return; } - if !inner.condition.evaluate_event_response(response, ctx) { + if !self.condition.evaluate_event_response(response, ctx) { return; } - let mut attributes = inner.attributes.clone(); - if let Some(selectors) = &inner.selectors { - let mut new_attributes = selectors.on_response_event(response, ctx); - attributes.append(&mut new_attributes); - } + let mut attributes = self.attributes.clone(); + let mut new_attributes = self.selectors.on_response_event(response, ctx); + attributes.append(&mut new_attributes); // Stub span to make sure the custom attributes are saved in current span extensions // It won't be extracted or sampled at all if Span::current().is_none() { let span = info_span!("supergraph_event_send_event"); let _entered = span.enter(); - inner.send_event(attributes); + log_event(self.level, &self.name, attributes, &self.message); } else { - inner.send_event(attributes); + log_event(self.level, &self.name, attributes, &self.message); } } - fn on_error(&self, error: &BoxError, ctx: &Context) { - let mut inner = self.inner.lock(); - if inner.event_on != EventOn::Error { + pub(crate) fn on_error(&mut self, error: &BoxError, ctx: &Context) { + if self.event_on != EventOn::Error { return; } - if let Some(selectors) = &inner.selectors { - let mut new_attributes = selectors.on_error(error, ctx); - inner.attributes.append(&mut new_attributes); - } - - let attrs = std::mem::take(&mut inner.attributes); - inner.send_event(attrs); - } -} + let mut new_attributes = self.selectors.on_error(error, ctx); + self.attributes.append(&mut new_attributes); -impl CustomEventInner -where - A: Selectors + Default, - T: Selector + Debug + Debug, -{ - #[inline] - fn send_event(&self, attributes: Vec) { - log_event(self.level, &self.name, attributes, &self.message); + let attrs = std::mem::take(&mut self.attributes); + log_event(self.level, &self.name, attrs, &self.message); } } @@ -795,348 +416,5 @@ pub(crate) fn log_event(level: EventLevel, kind: &str, attributes: Vec EventLevel::Error => { ::tracing::error!(%kind, "{}", message) } - EventLevel::Off => {} - } -} - -#[cfg(test)] -mod tests { - use http::header::CONTENT_LENGTH; - use http::HeaderValue; - use tracing::instrument::WithSubscriber; - - use super::*; - use crate::assert_snapshot_subscriber; - use crate::context::CONTAINS_GRAPHQL_ERROR; - use crate::context::OPERATION_NAME; - use crate::graphql; - use crate::plugins::telemetry::Telemetry; - use crate::plugins::test::PluginTestHarness; - - #[tokio::test(flavor = "multi_thread")] - async fn test_router_events() { - let test_harness: PluginTestHarness = PluginTestHarness::builder() - .config(include_str!("../testdata/custom_events.router.yaml")) - .build() - .await; - - async { - test_harness - .call_router( - router::Request::fake_builder() - .header(CONTENT_LENGTH, "0") - .header("custom-header", "val1") - .header("x-log-request", HeaderValue::from_static("log")) - .build() - .unwrap(), - |_r|async { - Ok(router::Response::fake_builder() - .header("custom-header", "val1") - .header(CONTENT_LENGTH, "25") - .header("x-log-request", HeaderValue::from_static("log")) - .data(serde_json_bytes::json!({"data": "res"})) - .build() - .expect("expecting valid response")) - }, - ) - .await - .expect("expecting successful response"); - } - .with_subscriber( - assert_snapshot_subscriber!({r#"[].span["apollo_private.duration_ns"]"# => "[duration]", r#"[].spans[]["apollo_private.duration_ns"]"# => "[duration]", "[].fields.attributes" => insta::sorted_redaction()}), - ) - .await - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_router_events_graphql_error() { - let test_harness: PluginTestHarness = PluginTestHarness::builder() - .config(include_str!("../testdata/custom_events.router.yaml")) - .build() - .await; - - async { - // Without the header to enable custom event - test_harness - .call_router( - router::Request::fake_builder() - .header("custom-header", "val1") - .build() - .unwrap(), - |_r| async { - let context_with_error = Context::new(); - let _ = context_with_error - .insert(CONTAINS_GRAPHQL_ERROR, true) - .unwrap(); - Ok(router::Response::fake_builder() - .header("custom-header", "val1") - .context(context_with_error) - .data(serde_json_bytes::json!({"errors": [{"message": "res"}]})) - .build() - .expect("expecting valid response")) - }, - ) - .await - .expect("expecting successful response"); - } - .with_subscriber( - assert_snapshot_subscriber!({r#"[].span["apollo_private.duration_ns"]"# => "[duration]", r#"[].spans[]["apollo_private.duration_ns"]"# => "[duration]", "[].fields.attributes" => insta::sorted_redaction()}), - ) - .await - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_router_events_graphql_response() { - let test_harness: PluginTestHarness = PluginTestHarness::builder() - .config(include_str!("../testdata/custom_events.router.yaml")) - .build() - .await; - - async { - // Without the header to enable custom event - test_harness - .call_router( - router::Request::fake_builder() - .header("custom-header", "val1") - .build() - .unwrap(), - |_r| async { - Ok(router::Response::fake_builder() - .header("custom-header", "val1") - .header(CONTENT_LENGTH, "25") - .header("x-log-response", HeaderValue::from_static("log")) - .data(serde_json_bytes::json!({"data": "res"})) - .build() - .expect("expecting valid response")) - }, - ) - .await - .expect("expecting successful response"); - } - .with_subscriber( - assert_snapshot_subscriber!({r#"[].span["apollo_private.duration_ns"]"# => "[duration]", r#"[].spans[]["apollo_private.duration_ns"]"# => "[duration]", "[].fields.attributes" => insta::sorted_redaction()}), - ) - .await - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_supergraph_events() { - let test_harness: PluginTestHarness = PluginTestHarness::builder() - .config(include_str!("../testdata/custom_events.router.yaml")) - .build() - .await; - - async { - test_harness - .call_supergraph( - supergraph::Request::fake_builder() - .query("query { foo }") - .header("x-log-request", HeaderValue::from_static("log")) - .build() - .unwrap(), - |_r| { - supergraph::Response::fake_builder() - .header("custom-header", "val1") - .header("x-log-request", HeaderValue::from_static("log")) - .data(serde_json::json!({"data": "res"}).to_string()) - .build() - .expect("expecting valid response") - }, - ) - .await - .expect("expecting successful response"); - } - .with_subscriber(assert_snapshot_subscriber!()) - .await - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_supergraph_events_with_exists_condition() { - let test_harness: PluginTestHarness = PluginTestHarness::builder() - .config(include_str!( - "../testdata/custom_events_exists_condition.router.yaml" - )) - .build() - .await; - - async { - let ctx = Context::new(); - ctx.insert(OPERATION_NAME, String::from("Test")).unwrap(); - test_harness - .call_supergraph( - supergraph::Request::fake_builder() - .query("query Test { foo }") - .context(ctx) - .build() - .unwrap(), - |_r| { - supergraph::Response::fake_builder() - .data(serde_json::json!({"data": "res"}).to_string()) - .build() - .expect("expecting valid response") - }, - ) - .await - .expect("expecting successful response"); - test_harness - .call_supergraph( - supergraph::Request::fake_builder() - .query("query { foo }") - .build() - .unwrap(), - |_r| { - supergraph::Response::fake_builder() - .data(serde_json::json!({"data": "res"}).to_string()) - .build() - .expect("expecting valid response") - }, - ) - .await - .expect("expecting successful response"); - } - .with_subscriber(assert_snapshot_subscriber!()) - .await - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_supergraph_events_on_graphql_error() { - let test_harness: PluginTestHarness = PluginTestHarness::builder() - .config(include_str!("../testdata/custom_events.router.yaml")) - .build() - .await; - - async { - test_harness - .call_supergraph( - supergraph::Request::fake_builder() - .query("query { foo }") - .build() - .unwrap(), - |_r| { - let context_with_error = Context::new(); - let _ = context_with_error - .insert(CONTAINS_GRAPHQL_ERROR, true) - .unwrap(); - supergraph::Response::fake_builder() - .header("custom-header", "val1") - .header("x-log-request", HeaderValue::from_static("log")) - .context(context_with_error) - .data(serde_json_bytes::json!({"errors": [{"message": "res"}]})) - .build() - .expect("expecting valid response") - }, - ) - .await - .expect("expecting successful response"); - } - .with_subscriber(assert_snapshot_subscriber!()) - .await - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_supergraph_events_on_response() { - let test_harness: PluginTestHarness = PluginTestHarness::builder() - .config(include_str!("../testdata/custom_events.router.yaml")) - .build() - .await; - - async { - test_harness - .call_supergraph( - supergraph::Request::fake_builder() - .query("query { foo }") - .build() - .unwrap(), - |_r| { - supergraph::Response::fake_builder() - .header("custom-header", "val1") - .header("x-log-response", HeaderValue::from_static("log")) - .data(serde_json_bytes::json!({"errors": [{"message": "res"}]})) - .build() - .expect("expecting valid response") - }, - ) - .await - .expect("expecting successful response"); - } - .with_subscriber(assert_snapshot_subscriber!()) - .await - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_subgraph_events() { - let test_harness: PluginTestHarness = PluginTestHarness::builder() - .config(include_str!("../testdata/custom_events.router.yaml")) - .build() - .await; - - async { - let mut subgraph_req = http::Request::new( - graphql::Request::fake_builder() - .query("query { foo }") - .build(), - ); - subgraph_req - .headers_mut() - .insert("x-log-request", HeaderValue::from_static("log")); - test_harness - .call_subgraph( - subgraph::Request::fake_builder() - .subgraph_name("subgraph") - .subgraph_request(subgraph_req) - .build(), - |_r| { - subgraph::Response::fake2_builder() - .header("custom-header", "val1") - .header("x-log-request", HeaderValue::from_static("log")) - .data(serde_json::json!({"data": "res"}).to_string()) - .build() - .expect("expecting valid response") - }, - ) - .await - .expect("expecting successful response"); - } - .with_subscriber(assert_snapshot_subscriber!()) - .await - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_subgraph_events_response() { - let test_harness: PluginTestHarness = PluginTestHarness::builder() - .config(include_str!("../testdata/custom_events.router.yaml")) - .build() - .await; - - async { - let mut subgraph_req = http::Request::new( - graphql::Request::fake_builder() - .query("query { foo }") - .build(), - ); - subgraph_req - .headers_mut() - .insert("x-log-request", HeaderValue::from_static("log")); - test_harness - .call_subgraph( - subgraph::Request::fake_builder() - .subgraph_name("subgraph") - .subgraph_request(subgraph_req) - .build(), - |_r| { - subgraph::Response::fake2_builder() - .header("custom-header", "val1") - .header("x-log-response", HeaderValue::from_static("log")) - .subgraph_name("subgraph") - .data(serde_json::json!({"data": "res"}).to_string()) - .build() - .expect("expecting valid response") - }, - ) - .await - .expect("expecting successful response"); - } - .with_subscriber(assert_snapshot_subscriber!()) - .await } } diff --git a/apollo-router/src/plugins/telemetry/config_new/experimental_when_header.rs b/apollo-router/src/plugins/telemetry/config_new/experimental_when_header.rs deleted file mode 100644 index 11bbf8dcb1..0000000000 --- a/apollo-router/src/plugins/telemetry/config_new/experimental_when_header.rs +++ /dev/null @@ -1,89 +0,0 @@ -// Note that this configuration will be removed when events are implemented. - -use regex::Regex; -use schemars::JsonSchema; -use serde::Deserialize; - -use crate::plugin::serde::deserialize_regex; -use crate::services::SupergraphRequest; - -#[derive(Clone, Debug, Deserialize, JsonSchema)] -#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] -pub(crate) enum HeaderLoggingCondition { - /// Match header value given a regex to display logs - Matching { - /// Header name - name: String, - /// Regex to match the header value - #[schemars(with = "String", rename = "match")] - #[serde(deserialize_with = "deserialize_regex", rename = "match")] - matching: Regex, - /// Display request/response headers (default: false) - #[serde(default)] - headers: bool, - /// Display request/response body (default: false) - #[serde(default)] - body: bool, - }, - /// Match header value given a value to display logs - Value { - /// Header name - name: String, - /// Header value - value: String, - /// Display request/response headers (default: false) - #[serde(default)] - headers: bool, - /// Display request/response body (default: false) - #[serde(default)] - body: bool, - }, -} - -impl HeaderLoggingCondition { - /// Returns if we should display the request/response headers and body given the `SupergraphRequest` - pub(crate) fn should_log(&self, req: &SupergraphRequest) -> (bool, bool) { - match self { - HeaderLoggingCondition::Matching { - name, - matching: matched, - headers, - body, - } => { - let header_match = req - .supergraph_request - .headers() - .get(name) - .and_then(|h| h.to_str().ok()) - .map(|h| matched.is_match(h)) - .unwrap_or_default(); - - if header_match { - (*headers, *body) - } else { - (false, false) - } - } - HeaderLoggingCondition::Value { - name, - value, - headers, - body, - } => { - let header_match = req - .supergraph_request - .headers() - .get(name) - .and_then(|h| h.to_str().ok()) - .map(|h| value.as_str() == h) - .unwrap_or_default(); - - if header_match { - (*headers, *body) - } else { - (false, false) - } - } - } - } -} diff --git a/apollo-router/src/plugins/telemetry/config_new/extendable.rs b/apollo-router/src/plugins/telemetry/config_new/extendable.rs index c515a352bd..12e764c350 100644 --- a/apollo-router/src/plugins/telemetry/config_new/extendable.rs +++ b/apollo-router/src/plugins/telemetry/config_new/extendable.rs @@ -5,25 +5,25 @@ use std::fmt::Debug; use std::sync::Arc; use opentelemetry::KeyValue; -use schemars::gen::SchemaGenerator; -use schemars::schema::Schema; use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::Schema; +use serde::Deserialize; +use serde::Deserializer; use serde::de::Error; use serde::de::MapAccess; use serde::de::Visitor; -use serde::Deserialize; -use serde::Deserializer; use serde_json::Map; use serde_json::Value; use tower::BoxError; use super::Stage; -use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::Context; use crate::plugins::telemetry::config_new::DefaultForLevel; use crate::plugins::telemetry::config_new::Selector; use crate::plugins::telemetry::config_new::Selectors; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; use crate::plugins::telemetry::otlp::TelemetryDataKind; -use crate::Context; /// This struct can be used as an attributes container, it has a custom JsonSchema implementation that will merge the schemas of the attributes and custom fields. #[derive(Clone, Debug)] @@ -98,10 +98,7 @@ where let mut temp_attributes: Map = Map::new(); temp_attributes.insert(key.clone(), value.clone()); Att::deserialize(Value::Object(temp_attributes)).map_err(|e| { - A::Error::custom(format!( - "failed to parse attribute '{}': {}", - key, e - )) + A::Error::custom(format!("failed to parse attribute '{key}': {e}")) })?; attributes.insert(key, value); } @@ -134,36 +131,36 @@ where ) } - fn json_schema(gen: &mut SchemaGenerator) -> Schema { + fn json_schema(generator: &mut SchemaGenerator) -> Schema { // Extendable json schema is composed of and anyOf of A and additional properties of E // To allow this to happen we need to generate a schema that contains all the properties of A // and a schema ref to A. // We can then add additional properties to the schema of type E. - let attributes = gen.subschema_for::(); - let custom = gen.subschema_for::>(); + let attributes = generator.subschema_for::(); + let custom = generator.subschema_for::>(); // Get a list of properties from the attributes schema - let attribute_schema = gen + let attribute_schema = generator .dereference(&attributes) .expect("failed to dereference attributes"); let mut properties = BTreeMap::new(); - if let Schema::Object(schema_object) = attribute_schema { - if let Some(object_validation) = &schema_object.object { - for key in object_validation.properties.keys() { - properties.insert(key.clone(), Schema::Bool(true)); - } + if let Schema::Object(schema_object) = attribute_schema + && let Some(object_validation) = &schema_object.object + { + for key in object_validation.properties.keys() { + properties.insert(key.clone(), Schema::Bool(true)); } } let mut schema = attribute_schema.clone(); - if let Schema::Object(schema_object) = &mut schema { - if let Some(object_validation) = &mut schema_object.object { - object_validation.additional_properties = custom - .into_object() - .object - .expect("could not get obejct validation") - .additional_properties; - } + if let Schema::Object(schema_object) = &mut schema + && let Some(object_validation) = &mut schema_object.object + { + object_validation.additional_properties = custom + .into_object() + .object + .expect("could not get obejct validation") + .additional_properties; } schema } @@ -181,16 +178,13 @@ where } } -impl Selectors for Extendable +impl Selectors + for Extendable where - A: Default + Selectors, + A: Default + Selectors, E: Selector, { - type Request = Request; - type Response = Response; - type EventResponse = EventResponse; - - fn on_request(&self, request: &Self::Request) -> Vec { + fn on_request(&self, request: &Request) -> Vec { let mut attrs = self.attributes.on_request(request); let custom_attributes = self.custom.iter().filter_map(|(key, value)| { value @@ -202,7 +196,7 @@ where attrs } - fn on_response(&self, response: &Self::Response) -> Vec { + fn on_response(&self, response: &Response) -> Vec { let mut attrs = self.attributes.on_response(response); let custom_attributes = self.custom.iter().filter_map(|(key, value)| { value @@ -226,7 +220,7 @@ where attrs } - fn on_response_event(&self, response: &Self::EventResponse, ctx: &Context) -> Vec { + fn on_response_event(&self, response: &EventResponse, ctx: &Context) -> Vec { let mut attrs = self.attributes.on_response_event(response, ctx); let custom_attributes = self.custom.iter().filter_map(|(key, value)| { value @@ -258,14 +252,16 @@ where impl Extendable where - A: Default + Selectors, + A: Default + Selectors, E: Selector, { pub(crate) fn validate(&self, restricted_stage: Option) -> Result<(), String> { if let Some(Stage::Request) = &restricted_stage { for (name, custom) in &self.custom { if !custom.is_active(Stage::Request) { - return Err(format!("cannot set the attribute {name:?} because it is using a selector computed in another stage than 'request' so it will not be computed")); + return Err(format!( + "cannot set the attribute {name:?} because it is using a selector computed in another stage than 'request' so it will not be computed" + )); } } } @@ -280,19 +276,19 @@ mod test { use parking_lot::Mutex; use crate::plugins::telemetry::config::AttributeValue; - use crate::plugins::telemetry::config_new::attributes::HttpCommonAttributes; - use crate::plugins::telemetry::config_new::attributes::HttpServerAttributes; - use crate::plugins::telemetry::config_new::attributes::RouterAttributes; use crate::plugins::telemetry::config_new::attributes::StandardAttribute; - use crate::plugins::telemetry::config_new::attributes::SupergraphAttributes; use crate::plugins::telemetry::config_new::conditional::Conditional; use crate::plugins::telemetry::config_new::conditions::Condition; use crate::plugins::telemetry::config_new::conditions::SelectorOrValue; use crate::plugins::telemetry::config_new::extendable::Extendable; + use crate::plugins::telemetry::config_new::http_common::attributes::HttpCommonAttributes; + use crate::plugins::telemetry::config_new::http_server::attributes::HttpServerAttributes; + use crate::plugins::telemetry::config_new::router::attributes::RouterAttributes; + use crate::plugins::telemetry::config_new::router::selectors::RouterSelector; use crate::plugins::telemetry::config_new::selectors::OperationName; use crate::plugins::telemetry::config_new::selectors::ResponseStatus; - use crate::plugins::telemetry::config_new::selectors::RouterSelector; - use crate::plugins::telemetry::config_new::selectors::SupergraphSelector; + use crate::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes; + use crate::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector; #[test] fn test_extendable_serde() { diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/connector_fetch_duration/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/connector_fetch_duration/metrics.snap new file mode 100644 index 0000000000..1b4573aa39 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/connector_fetch_duration/metrics.snap @@ -0,0 +1,29 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/instruments.rs +description: Apollo subgraph fetch duration histogram +expression: "&metrics.all()" +info: + telemetry: + apollo: + experimental_subgraph_metrics: true + instrumentation: + instruments: + connector: + http.client.request.duration: false +snapshot_kind: text +--- +- name: apollo.router.operations.fetch.duration + description: Duration of a subgraph fetch. + unit: s + data: + datapoints: + - sum: 0.1 + count: 1 + attributes: + apollo.client.name: myClient + apollo.client.version: v0.1.0 + apollo.operation.id: myOperationID + graphql.operation.name: Test + graphql.operation.type: query + has_errors: false + subgraph.name: posts diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/connector_fetch_duration/router.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/connector_fetch_duration/router.yaml new file mode 100644 index 0000000000..aac863bcc5 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/connector_fetch_duration/router.yaml @@ -0,0 +1,7 @@ +telemetry: + apollo: + experimental_subgraph_metrics: true + instrumentation: + instruments: + connector: + http.client.request.duration: false \ No newline at end of file diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/connector_fetch_duration/test.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/connector_fetch_duration/test.yaml new file mode 100644 index 0000000000..a91c223e9a --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/connector_fetch_duration/test.yaml @@ -0,0 +1,21 @@ +description: Apollo subgraph fetch duration histogram +events: + - - context: + map: + "apollo::supergraph::operation_name": "Test" + "apollo::supergraph::operation_id": "myOperationID" + "apollo::supergraph::operation_kind": "query" + "apollo::telemetry::client_name": "myClient" + "apollo::telemetry::client_version": "v0.1.0" + - connector_request: + subgraph_name: posts + source_name: posts_api + http_method: GET + url_template: "/posts" + uri: "/posts" + - connector_response: + status: 200 + body: | + { "foo": "bar" } + headers: + custom_response_header: custom_response_header_value diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/subgraph_fetch_duration/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/subgraph_fetch_duration/metrics.snap new file mode 100644 index 0000000000..df1e2d4808 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/subgraph_fetch_duration/metrics.snap @@ -0,0 +1,29 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/instruments.rs +description: Apollo subgraph fetch duration histogram +expression: "&metrics.all()" +info: + telemetry: + apollo: + experimental_subgraph_metrics: true + instrumentation: + instruments: + subgraph: + http.client.request.duration: false +snapshot_kind: text +--- +- name: apollo.router.operations.fetch.duration + description: Duration of a subgraph fetch. + unit: s + data: + datapoints: + - sum: 0.1 + count: 1 + attributes: + apollo.client.name: myClient + apollo.client.version: v0.1.0 + apollo.operation.id: myOperationID + graphql.operation.name: Test + graphql.operation.type: query + has_errors: false + subgraph.name: products diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/subgraph_fetch_duration/router.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/subgraph_fetch_duration/router.yaml new file mode 100644 index 0000000000..ca75086292 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/subgraph_fetch_duration/router.yaml @@ -0,0 +1,7 @@ +telemetry: + apollo: + experimental_subgraph_metrics: true + instrumentation: + instruments: + subgraph: + http.client.request.duration: false \ No newline at end of file diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/subgraph_fetch_duration/test.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/subgraph_fetch_duration/test.yaml new file mode 100644 index 0000000000..8a615ee9a1 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/apollo/subgraph_fetch_duration/test.yaml @@ -0,0 +1,18 @@ +description: Apollo subgraph fetch duration histogram +events: + - - context: + map: + "apollo::supergraph::operation_name": "Test" + "apollo::supergraph::operation_id": "myOperationID" + "apollo::supergraph::operation_kind": "query" + "apollo::telemetry::client_name": "myClient" + "apollo::telemetry::client_version": "v0.1.0" + - subgraph_request: + query: "query { hello }" + operation_name: "Products" + operation_kind: query + subgraph_name: "products" + - subgraph_response: + status: 200 + data: + hello: "world" \ No newline at end of file diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_counter_with_conditions/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_counter_with_conditions/metrics.snap new file mode 100644 index 0000000000..df5e1cec39 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_counter_with_conditions/metrics.snap @@ -0,0 +1,35 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/instruments.rs +description: Custom counter with conditions +expression: "&metrics.all()" +info: + telemetry: + instrumentation: + instruments: + default_requirement_level: none + connector: + not.found.count: + description: Count of 404 responses from the user API + type: counter + unit: count + value: unit + attributes: + url_template: + connector_url_template: true + condition: + all: + - eq: + - 404 + - connector_http_response_status: code + - eq: + - user_api + - connector_source: name +--- +- name: not.found.count + description: Count of 404 responses from the user API + unit: count + data: + datapoints: + - value: 1 + attributes: + url_template: "/user/{$this.userid}" diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_counter_with_conditions/router.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_counter_with_conditions/router.yaml new file mode 100644 index 0000000000..a62a2ddce8 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_counter_with_conditions/router.yaml @@ -0,0 +1,21 @@ +telemetry: + instrumentation: + instruments: + default_requirement_level: none + connector: + not.found.count: + description: "Count of 404 responses from the user API" + type: counter + unit: count + value: unit + attributes: + "url_template": + connector_url_template: true + condition: + all: + - eq: + - 404 + - connector_http_response_status: code + - eq: + - "user_api" + - connector_source: name \ No newline at end of file diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_counter_with_conditions/test.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_counter_with_conditions/test.yaml new file mode 100644 index 0000000000..5260c02c82 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_counter_with_conditions/test.yaml @@ -0,0 +1,22 @@ +description: Custom counter with conditions +events: + - - connector_request: + subgraph_name: users + source_name: user_api + http_method: GET + url_template: "/user/{$this.userid}" + uri: "/user/1" + - connector_response: + status: 200 + body: | + { "username": "foo" } + - connector_request: + subgraph_name: users + source_name: user_api + http_method: GET + url_template: "/user/{$this.userid}" + uri: "/user/1" + - connector_response: + status: 404 + body: | + { "error": "not found" } diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_histogram/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_histogram/metrics.snap new file mode 100644 index 0000000000..625db3923b --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_histogram/metrics.snap @@ -0,0 +1,33 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/instruments.rs +description: Both subgraph and connector HTTP client duration metrics +expression: "&metrics.all()" +info: + telemetry: + instrumentation: + instruments: + default_requirement_level: none + connector: + rate.limit: + value: + connector_http_response_header: x-ratelimit-remaining + unit: count + type: histogram + description: Rate limit remaining + condition: + all: + - eq: + - 200 + - connector_http_response_status: code + - eq: + - user_api + - connector_source: name +--- +- name: rate.limit + description: Rate limit remaining + unit: count + data: + datapoints: + - sum: 1499 + count: 2 + attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_histogram/router.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_histogram/router.yaml new file mode 100644 index 0000000000..c91eacbc00 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_histogram/router.yaml @@ -0,0 +1,19 @@ +telemetry: + instrumentation: + instruments: + default_requirement_level: none + connector: + rate.limit: + value: + connector_http_response_header: "x-ratelimit-remaining" + unit: count + type: histogram + description: "Rate limit remaining" + condition: + all: + - eq: + - 200 + - connector_http_response_status: code + - eq: + - "user_api" + - connector_source: name diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_histogram/test.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_histogram/test.yaml new file mode 100644 index 0000000000..15e75fd7a1 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/custom_histogram/test.yaml @@ -0,0 +1,26 @@ +description: Both subgraph and connector HTTP client duration metrics +events: + - - connector_request: + subgraph_name: users + source_name: user_api + http_method: GET + url_template: "/users" + uri: "/users" + - connector_response: + status: 200 + headers: + x-ratelimit-remaining: 999 + body: | + { "username": "foo" } + - connector_request: + subgraph_name: users + source_name: user_api + http_method: GET + url_template: "/users" + uri: "/users" + - connector_response: + status: 200 + headers: + x-ratelimit-remaining: 500 + body: | + { "username": "foo" } \ No newline at end of file diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/http_client_request_duration/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/http_client_request_duration/metrics.snap new file mode 100644 index 0000000000..057e879a3f --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/http_client_request_duration/metrics.snap @@ -0,0 +1,55 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/instruments.rs +description: Connector HTTP client duration metric +expression: "&metrics.all()" +info: + telemetry: + instrumentation: + instruments: + default_requirement_level: none + connector: + http.client.request.duration: + attributes: + subgraph.name: true + connector.source: + connector_source: name + connector.http.method: true + connector.url.template: true + custom.request.header.attribute: + connector_http_request_header: custom_request_header + custom.request.context: + request_context: "custom::context::key" + custom.response.header.attribute: + connector_http_response_header: custom_response_header + custom.response.status.attribute: + connector_http_response_status: code + custom.static.attribute: + static: custom_value + custom.supergraph.operation.name: + supergraph_operation_name: string + custom.supergraph.operation.kind: + supergraph_operation_kind: string + custom.has_error: + error: boolean +snapshot_kind: text +--- +- name: http.client.request.duration + description: Duration of HTTP client requests. + unit: s + data: + datapoints: + - sum: 0.1 + count: 1 + attributes: + connector.http.method: GET + connector.source: posts_api + connector.url.template: /posts + custom.has_error: false + custom.request.context: custom_context_value + custom.request.header.attribute: custom_request_header_value + custom.response.header.attribute: custom_response_header_value + custom.response.status.attribute: 200 + custom.static.attribute: custom_value + custom.supergraph.operation.kind: query + custom.supergraph.operation.name: Test + subgraph.name: posts diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/http_client_request_duration/router.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/http_client_request_duration/router.yaml new file mode 100644 index 0000000000..f092715928 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/http_client_request_duration/router.yaml @@ -0,0 +1,28 @@ +telemetry: + instrumentation: + instruments: + default_requirement_level: none + connector: + http.client.request.duration: + attributes: + subgraph.name: true + connector.source: + connector_source: name + connector.http.method: true + connector.url.template: true + custom.request.header.attribute: + connector_http_request_header: "custom_request_header" + custom.request.context: + request_context: "custom::context::key" + custom.response.header.attribute: + connector_http_response_header: "custom_response_header" + custom.response.status.attribute: + connector_http_response_status: code + custom.static.attribute: + static: "custom_value" + custom.supergraph.operation.name: + supergraph_operation_name: string + custom.supergraph.operation.kind: + supergraph_operation_kind: string + custom.has_error: + connector_on_response_error: true \ No newline at end of file diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/http_client_request_duration/test.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/http_client_request_duration/test.yaml new file mode 100644 index 0000000000..b2fd3f8d18 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/http_client_request_duration/test.yaml @@ -0,0 +1,21 @@ +description: Connector HTTP client duration metric +events: + - - context: + map: + "custom::context::key": "custom_context_value" + "apollo::supergraph::operation_name": "Test" + "apollo::supergraph::operation_kind": "query" + - connector_request: + subgraph_name: posts + source_name: posts_api + http_method: GET + url_template: "/posts" + uri: "/posts" + headers: + custom_request_header: custom_request_header_value + - connector_response: + status: 200 + body: | + { "foo": "bar" } + headers: + custom_response_header: custom_response_header_value diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/mapping_problems/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/mapping_problems/metrics.snap new file mode 100644 index 0000000000..1efce9ccb4 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/mapping_problems/metrics.snap @@ -0,0 +1,52 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/instruments.rs +description: Both subgraph and connector HTTP client duration metrics +expression: "&metrics.all()" +info: + telemetry: + instrumentation: + instruments: + default_requirement_level: none + connector: + request.mapping.problems: + description: Count of connectors request mapping problems + value: + connector_request_mapping_problems: count + attributes: + connector.source: + connector_source: name + mapping.problems.exist: + connector_request_mapping_problems: boolean + unit: count + type: counter + response.mapping.problems: + description: Count of connectors response mapping problems + value: + connector_response_mapping_problems: count + attributes: + connector.source: + connector_source: name + mapping.problems.exist: + connector_response_mapping_problems: boolean + unit: count + type: counter +snapshot_kind: text +--- +- name: request.mapping.problems + description: Count of connectors request mapping problems + unit: count + data: + datapoints: + - value: 15 + attributes: + connector.source: user_api + mapping.problems.exist: true +- name: response.mapping.problems + description: Count of connectors response mapping problems + unit: count + data: + datapoints: + - value: 5 + attributes: + connector.source: user_api + mapping.problems.exist: true diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/mapping_problems/router.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/mapping_problems/router.yaml new file mode 100644 index 0000000000..492cc5b504 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/mapping_problems/router.yaml @@ -0,0 +1,28 @@ +telemetry: + instrumentation: + instruments: + default_requirement_level: none + connector: + request.mapping.problems: + description: "Count of connectors request mapping problems" + value: + connector_request_mapping_problems: count + attributes: + connector.source: + connector_source: name + mapping.problems.exist: + connector_request_mapping_problems: boolean + unit: count + type: counter + response.mapping.problems: + description: "Count of connectors response mapping problems" + value: + connector_response_mapping_problems: count + attributes: + connector.source: + connector_source: name + mapping.problems.exist: + connector_response_mapping_problems: boolean + unit: count + type: counter + diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/mapping_problems/test.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/mapping_problems/test.yaml new file mode 100644 index 0000000000..7d320af936 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/mapping_problems/test.yaml @@ -0,0 +1,56 @@ +description: Both subgraph and connector HTTP client duration metrics +events: + - - connector_request: + subgraph_name: users + source_name: user_api + http_method: GET + url_template: "/users" + uri: "/users" + mapping_problems: + - level: Warn + count: 3 + message: "request warn message" + path: "@.id" + location: Selection + - level: Info + count: 7 + message: "request info message" + path: "@.id" + location: Selection + - connector_response: + status: 200 + body: | + { "username": "foo" } + mapping_problems: + - level: Info + count: 3 + message: "response info message" + path: "@.id" + location: Selection + - connector_request: + subgraph_name: users + source_name: user_api + http_method: GET + url_template: "/users" + uri: "/users" + mapping_problems: + - level: Info + count: 5 + message: "request info message" + path: "@.id" + location: Selection + - connector_response: + status: 200 + body: | + { "username": "foo" } + mapping_problems: + - level: Warn + count: 1 + message: "response warn message" + path: "@.id" + location: Selection + - level: Error + count: 1 + message: "response error message" + path: "@.id" + location: Selection \ No newline at end of file diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/subgraph_and_connector/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/subgraph_and_connector/metrics.snap new file mode 100644 index 0000000000..bbf1d2ad82 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/subgraph_and_connector/metrics.snap @@ -0,0 +1,31 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/instruments.rs +description: Both subgraph and connector HTTP client duration metrics +expression: "&metrics.all()" +info: + telemetry: + instrumentation: + instruments: + default_requirement_level: none + subgraph: + http.client.request.duration: + attributes: + subgraph.name: true + connector: + http.client.request.duration: + attributes: + subgraph.name: true +--- +- name: http.client.request.duration + description: Duration of HTTP client requests. + unit: s + data: + datapoints: + - sum: 0.1 + count: 1 + attributes: + subgraph.name: products + - sum: 0.1 + count: 1 + attributes: + subgraph.name: reviews diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/subgraph_and_connector/router.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/subgraph_and_connector/router.yaml new file mode 100644 index 0000000000..054f4931aa --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/subgraph_and_connector/router.yaml @@ -0,0 +1,12 @@ +telemetry: + instrumentation: + instruments: + default_requirement_level: none + subgraph: + http.client.request.duration: + attributes: + subgraph.name: true + connector: + http.client.request.duration: + attributes: + subgraph.name: true \ No newline at end of file diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/subgraph_and_connector/test.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/subgraph_and_connector/test.yaml new file mode 100644 index 0000000000..8f539dc4d9 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/connector/subgraph_and_connector/test.yaml @@ -0,0 +1,38 @@ +description: Both subgraph and connector HTTP client duration metrics +events: + - - router_request: + uri: "/hello" + method: GET + body: | + hello + - supergraph_request: + uri: "/hello" + method: GET + query: "query { hello }" + - subgraph_request: + query: "query { hello }" + operation_name: "Products" + operation_kind: query + subgraph_name: "products" + - subgraph_response: + status: 200 + data: + hello: "world" + - connector_request: + subgraph_name: reviews + source_name: reviews_api + http_method: GET + url_template: "/reviews" + uri: "/reviews" + - connector_response: + status: 200 + body: | + { "foo": "bar" } + - supergraph_response: + status: 200 + data: + hello: "world" + - router_response: + body: | + hello + status: 200 diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/custom_histogram/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/custom_histogram/metrics.snap index 0b139624ac..bc8edad7c7 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/custom_histogram/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/custom_histogram/metrics.snap @@ -12,8 +12,7 @@ info: type: histogram unit: unit value: - field_custom: - list_length: value + list_length: value --- - name: custom_counter description: count of requests @@ -21,4 +20,5 @@ info: data: datapoints: - sum: 5 + count: 2 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/custom_histogram_with_custom_attributes/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/custom_histogram_with_custom_attributes/metrics.snap index aec235fbd7..c7254d9c48 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/custom_histogram_with_custom_attributes/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/custom_histogram_with_custom_attributes/metrics.snap @@ -25,12 +25,14 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: custom_attribute: name graphql.field.name: name graphql.field.type: String graphql.type.name: Product - sum: 1 + count: 1 attributes: custom_attribute: products graphql.field.name: products diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/custom_histogram_with_custom_conditions/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/custom_histogram_with_custom_conditions/metrics.snap index a495657612..db096af812 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/custom_histogram_with_custom_conditions/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/custom_histogram_with_custom_conditions/metrics.snap @@ -25,5 +25,6 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: graphql.field.name: products diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/field.length/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/field.length/metrics.snap index d3e0270014..6cff0b5d6c 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/field.length/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/graphql/field.length/metrics.snap @@ -15,4 +15,5 @@ info: data: datapoints: - sum: 3 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/attribute.error.type/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/attribute.error.type/metrics.snap index f32638093b..07378dd323 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/attribute.error.type/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/attribute.error.type/metrics.snap @@ -18,5 +18,6 @@ info: data: datapoints: - sum: 0.1 + count: 1 attributes: error.type: Internal Server Error diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/attribute.on_graphql_error/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/attribute.on_graphql_error/metrics.snap index 6af70de078..e3b9d96d37 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/attribute.on_graphql_error/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/attribute.on_graphql_error/metrics.snap @@ -19,5 +19,10 @@ info: data: datapoints: - sum: 0.1 + count: 1 + attributes: + on.graphql.error: false + - sum: 0.1 + count: 1 attributes: on.graphql.error: true diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/attribute.on_graphql_error/test.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/attribute.on_graphql_error/test.yaml index c61052951f..7ae7588c0a 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/attribute.on_graphql_error/test.yaml +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/attribute.on_graphql_error/test.yaml @@ -12,4 +12,16 @@ events: - router_response: body: | hello - status: 200 \ No newline at end of file + status: 200 + - - router_request: + uri: "/hello" + method: GET + body: | + hello + - context: + map: + "apollo::telemetry::contains_graphql_error": false + - router_response: + body: | + hello + status: 200 diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_counter_on_error/router.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_counter_on_error/router.yaml index 3b739ec2ee..7065393551 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_counter_on_error/router.yaml +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_counter_on_error/router.yaml @@ -11,7 +11,7 @@ telemetry: unit: request attributes: graphql.operation.name: - response_context: operation_name + response_context: "apollo::supergraph::operation_name" condition: eq: - "request timed out" diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_counter_on_error/test.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_counter_on_error/test.yaml index b3b61fd94e..483311b4a9 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_counter_on_error/test.yaml +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_counter_on_error/test.yaml @@ -2,7 +2,7 @@ description: Custom counter should be incremented on timeout error with operatio events: - - context: map: - operation_name: TestQuery + "apollo::supergraph::operation_name": TestQuery - router_request: uri: "/hello" method: POST diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_custom_value/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_custom_value/metrics.snap index 957fec0e25..165bc54edd 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_custom_value/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_custom_value/metrics.snap @@ -22,4 +22,5 @@ info: data: datapoints: - sum: 10 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_duration/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_duration/metrics.snap index 815e578506..c5b3045a46 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_duration/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_duration/metrics.snap @@ -21,4 +21,5 @@ info: data: datapoints: - sum: 0.1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_duration_aborted_request/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_duration_aborted_request/metrics.snap index a01097bae5..8e18798177 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_duration_aborted_request/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_duration_aborted_request/metrics.snap @@ -21,4 +21,5 @@ info: data: datapoints: - sum: 0.1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_duration_aborted_request_with_condition_on_request/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_duration_aborted_request_with_condition_on_request/metrics.snap index 54cb6228f9..f11de25a03 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_duration_aborted_request_with_condition_on_request/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_duration_aborted_request_with_condition_on_request/metrics.snap @@ -25,4 +25,5 @@ info: data: datapoints: - sum: 0.1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_unit/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_unit/metrics.snap index 5d4f3f4e4e..642610641d 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_unit/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_unit/metrics.snap @@ -21,4 +21,5 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_unit_aborted_request/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_unit_aborted_request/metrics.snap index 5d4f3f4e4e..13a2628c14 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_unit_aborted_request/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_unit_aborted_request/metrics.snap @@ -1,6 +1,6 @@ --- source: apollo-router/src/plugins/telemetry/config_new/instruments.rs -description: Custom histogram unit +description: "Custom histogram where router response doesn't happen. This should still increment the metric on Drop." expression: "&metrics.all()" info: telemetry: @@ -21,4 +21,5 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_unit_aborted_request_with_condition_on_request/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_unit_aborted_request_with_condition_on_request/metrics.snap index c32ce4a968..5b6a730710 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_unit_aborted_request_with_condition_on_request/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_unit_aborted_request_with_condition_on_request/metrics.snap @@ -25,4 +25,5 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_with_attributes/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_with_attributes/metrics.snap index 66de37a22b..513b748d0a 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_with_attributes/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_with_attributes/metrics.snap @@ -25,6 +25,7 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: custom_attribute: custom_value http.request.method: GET diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_with_conditions/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_with_conditions/metrics.snap index 5377b7f289..2960a408be 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_with_conditions/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/custom_histogram_with_conditions/metrics.snap @@ -29,6 +29,7 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: custom_attribute: allowed http.request.method: GET diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.body.size/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.body.size/metrics.snap index ced697f058..2518b56f59 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.body.size/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.body.size/metrics.snap @@ -17,6 +17,7 @@ info: data: datapoints: - sum: 35 + count: 1 attributes: http.request.method: GET http.response.status_code: 200 diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.body.size_with_custom_attributes/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.body.size_with_custom_attributes/metrics.snap index ced697f058..2518b56f59 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.body.size_with_custom_attributes/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.body.size_with_custom_attributes/metrics.snap @@ -17,6 +17,7 @@ info: data: datapoints: - sum: 35 + count: 1 attributes: http.request.method: GET http.response.status_code: 200 diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.duration/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.duration/metrics.snap index 1cdde4fc8b..acb5a25f5c 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.duration/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.duration/metrics.snap @@ -16,6 +16,7 @@ info: data: datapoints: - sum: 0.1 + count: 1 attributes: http.request.method: GET http.response.status_code: 200 diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.duration_with_custom_attributes/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.duration_with_custom_attributes/metrics.snap index c586792480..4ee5036659 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.duration_with_custom_attributes/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.duration_with_custom_attributes/metrics.snap @@ -23,6 +23,7 @@ info: data: datapoints: - sum: 0.1 + count: 1 attributes: graphql.operation.name: TestQuery http.response.status_code: 200 diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.duration_with_custom_attributes/test.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.duration_with_custom_attributes/test.yaml index 65b1964665..ce313c5b4a 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.duration_with_custom_attributes/test.yaml +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.request.duration_with_custom_attributes/test.yaml @@ -7,7 +7,7 @@ events: hello - context: map: - operation_name: TestQuery + "apollo::supergraph::operation_name": TestQuery - router_response: body: | hello diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.response.body.size/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.response.body.size/metrics.snap index 18d1ce2f0a..245131f105 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.response.body.size/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/router/http.server.response.body.size/metrics.snap @@ -17,6 +17,7 @@ info: data: datapoints: - sum: 35 + count: 1 attributes: http.request.method: GET http.response.status_code: 200 diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/schema.json b/apollo-router/src/plugins/telemetry/config_new/fixtures/schema.json index efa2b5fa12..b11af440a8 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/schema.json +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/schema.json @@ -443,6 +443,96 @@ } }, "additionalProperties": false + }, + { + "type": "object", + "required": [ + "connector_request" + ], + "properties": { + "connector_request": { + "type": "object", + "required": [ + "http_method", + "source_name", + "subgraph_name", + "uri", + "url_template" + ], + "properties": { + "body": { + "type": [ + "string", + "null" + ] + }, + "headers": { + "default": {}, + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "http_method": { + "type": "string" + }, + "mapping_problems": { + "default": [] + }, + "source_name": { + "type": "string" + }, + "subgraph_name": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "url_template": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "connector_response" + ], + "properties": { + "connector_response": { + "type": "object", + "required": [ + "body", + "status" + ], + "properties": { + "body": { + "type": "string" + }, + "headers": { + "default": {}, + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "mapping_problems": { + "default": [] + }, + "status": { + "type": "integer", + "format": "uint16", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ] }, diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/caching/test.yaml b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/caching/test.yaml index 369491b02e..03783d8a64 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/caching/test.yaml +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/caching/test.yaml @@ -9,7 +9,7 @@ events: hello - context: map: - "operation_name": "Test" + "apollo::supergraph::operation_name": "Test" - supergraph_request: uri: "/hello" method: GET diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_custom_value/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_custom_value/metrics.snap index 957fec0e25..c6df2c801b 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_custom_value/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_custom_value/metrics.snap @@ -6,15 +6,14 @@ info: telemetry: instrumentation: instruments: - router: - http.server.active_requests: false - http.server.request.duration: false + default_requirement_level: none + subgraph: custom.histogram: description: histogram of requests type: histogram unit: unit value: - request_header: count_header + subgraph_request_header: count_header --- - name: custom.histogram description: histogram of requests @@ -22,4 +21,5 @@ info: data: datapoints: - sum: 10 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_duration copy/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_duration copy/metrics.snap index ae36e59b7d..f4ffc8c504 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_duration copy/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_duration copy/metrics.snap @@ -19,5 +19,6 @@ info: data: datapoints: - sum: 0.1 + count: 1 attributes: apollo_subgraph_name: products diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_duration/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_duration/metrics.snap index 815e578506..7eff2b686b 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_duration/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_duration/metrics.snap @@ -6,9 +6,8 @@ info: telemetry: instrumentation: instruments: - router: - http.server.active_requests: false - http.server.request.duration: false + default_requirement_level: none + subgraph: custom.histogram.duration: description: histogram of requests type: histogram @@ -21,4 +20,5 @@ info: data: datapoints: - sum: 0.1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_duration_aborted_request/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_duration_aborted_request/metrics.snap index 815e578506..a76d8fa27b 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_duration_aborted_request/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_duration_aborted_request/metrics.snap @@ -1,14 +1,13 @@ --- source: apollo-router/src/plugins/telemetry/config_new/instruments.rs -description: Custom histogram duration +description: "Custom histogram where subgraph response doesn't happen. This should still increment the metric on Drop." expression: "&metrics.all()" info: telemetry: instrumentation: instruments: - router: - http.server.active_requests: false - http.server.request.duration: false + default_requirement_level: none + subgraph: custom.histogram.duration: description: histogram of requests type: histogram @@ -21,4 +20,5 @@ info: data: datapoints: - sum: 0.1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_unit/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_unit/metrics.snap index 5d4f3f4e4e..97055c3348 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_unit/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_unit/metrics.snap @@ -6,9 +6,8 @@ info: telemetry: instrumentation: instruments: - router: - http.server.active_requests: false - http.server.request.duration: false + default_requirement_level: none + subgraph: custom.histogram: description: histogram of requests type: histogram @@ -21,4 +20,5 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_unit_aborted_request/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_unit_aborted_request/metrics.snap index 5d4f3f4e4e..93633beaae 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_unit_aborted_request/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_unit_aborted_request/metrics.snap @@ -1,14 +1,13 @@ --- source: apollo-router/src/plugins/telemetry/config_new/instruments.rs -description: Custom histogram unit +description: "Custom histogram where subgraph response doesn't happen. This should still increment the metric on Drop." expression: "&metrics.all()" info: telemetry: instrumentation: instruments: - router: - http.server.active_requests: false - http.server.request.duration: false + default_requirement_level: none + subgraph: custom.histogram: description: histogram of requests type: histogram @@ -21,4 +20,5 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_with_attributes/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_with_attributes/metrics.snap index a64336ff0e..7d7df87966 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_with_attributes/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_with_attributes/metrics.snap @@ -24,6 +24,7 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: custom_attribute: custom_value subgraph.graphql.document: "query { hello }" diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_with_conditions/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_with_conditions/metrics.snap index 5d2531612b..dd45cb8f47 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_with_conditions/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/subgraph/custom_histogram_with_conditions/metrics.snap @@ -28,6 +28,7 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: custom_attribute: allowed subgraph.graphql.document: "query { hello }" diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_custom_value/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_custom_value/metrics.snap index 957fec0e25..343a0aba2b 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_custom_value/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_custom_value/metrics.snap @@ -6,9 +6,8 @@ info: telemetry: instrumentation: instruments: - router: - http.server.active_requests: false - http.server.request.duration: false + default_requirement_level: none + supergraph: custom.histogram: description: histogram of requests type: histogram @@ -22,4 +21,5 @@ info: data: datapoints: - sum: 10 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_duration/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_duration/metrics.snap index 815e578506..9793783c2e 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_duration/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_duration/metrics.snap @@ -6,9 +6,8 @@ info: telemetry: instrumentation: instruments: - router: - http.server.active_requests: false - http.server.request.duration: false + default_requirement_level: none + supergraph: custom.histogram.duration: description: histogram of requests type: histogram @@ -21,4 +20,5 @@ info: data: datapoints: - sum: 0.1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_duration_aborted_request/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_duration_aborted_request/metrics.snap index 815e578506..9793783c2e 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_duration_aborted_request/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_duration_aborted_request/metrics.snap @@ -6,9 +6,8 @@ info: telemetry: instrumentation: instruments: - router: - http.server.active_requests: false - http.server.request.duration: false + default_requirement_level: none + supergraph: custom.histogram.duration: description: histogram of requests type: histogram @@ -21,4 +20,5 @@ info: data: datapoints: - sum: 0.1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_aliases/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_aliases/metrics.snap index da07e887b0..1a457a28c2 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_aliases/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_aliases/metrics.snap @@ -20,4 +20,5 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_depth/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_depth/metrics.snap index 8f2f035d01..9da9d7b79e 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_depth/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_depth/metrics.snap @@ -20,4 +20,5 @@ info: data: datapoints: - sum: 2 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_height/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_height/metrics.snap index f5c1b7e5f3..69b6ae5878 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_height/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_height/metrics.snap @@ -20,4 +20,5 @@ info: data: datapoints: - sum: 3 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_root_fields/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_root_fields/metrics.snap index 330921cc09..4c2da50527 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_root_fields/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_supergraph_root_fields/metrics.snap @@ -20,4 +20,5 @@ info: data: datapoints: - sum: 4 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_unit/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_unit/metrics.snap index 5d4f3f4e4e..60f1ceae6e 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_unit/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_unit/metrics.snap @@ -1,14 +1,13 @@ --- source: apollo-router/src/plugins/telemetry/config_new/instruments.rs -description: Custom histogram unit +description: "Custom histogram where supergraph response doesn't happen. This should still increment the metric on Drop." expression: "&metrics.all()" info: telemetry: instrumentation: instruments: - router: - http.server.active_requests: false - http.server.request.duration: false + default_requirement_level: none + supergraph: custom.histogram: description: histogram of requests type: histogram @@ -21,4 +20,5 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_unit_aborted_request/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_unit_aborted_request/metrics.snap index 5d4f3f4e4e..60f1ceae6e 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_unit_aborted_request/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_unit_aborted_request/metrics.snap @@ -1,14 +1,13 @@ --- source: apollo-router/src/plugins/telemetry/config_new/instruments.rs -description: Custom histogram unit +description: "Custom histogram where supergraph response doesn't happen. This should still increment the metric on Drop." expression: "&metrics.all()" info: telemetry: instrumentation: instruments: - router: - http.server.active_requests: false - http.server.request.duration: false + default_requirement_level: none + supergraph: custom.histogram: description: histogram of requests type: histogram @@ -21,4 +20,5 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: {} diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_with_attributes/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_with_attributes/metrics.snap index 14b31dba79..f202af208d 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_with_attributes/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_with_attributes/metrics.snap @@ -24,6 +24,7 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: custom_attribute: custom_value graphql.document: "query { hello }" diff --git a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_with_conditions/metrics.snap b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_with_conditions/metrics.snap index 6afdc30d2f..28485b9341 100644 --- a/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_with_conditions/metrics.snap +++ b/apollo-router/src/plugins/telemetry/config_new/fixtures/supergraph/custom_histogram_with_conditions/metrics.snap @@ -28,6 +28,7 @@ info: data: datapoints: - sum: 1 + count: 1 attributes: custom_attribute: allowed graphql.document: "query { hello }" diff --git a/apollo-router/src/plugins/telemetry/config_new/graphql/attributes.rs b/apollo-router/src/plugins/telemetry/config_new/graphql/attributes.rs index 8765348d78..e71541d6fb 100644 --- a/apollo-router/src/plugins/telemetry/config_new/graphql/attributes.rs +++ b/apollo-router/src/plugins/telemetry/config_new/graphql/attributes.rs @@ -1,12 +1,17 @@ use apollo_compiler::executable::Field; use apollo_compiler::executable::NamedType; use opentelemetry::Key; -use opentelemetry_api::KeyValue; +use opentelemetry::KeyValue; use schemars::JsonSchema; use serde::Deserialize; use serde_json_bytes::Value; use tower::BoxError; +use crate::Context; +use crate::plugins::telemetry::config_new::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::Selector; +use crate::plugins::telemetry::config_new::Selectors; use crate::plugins::telemetry::config_new::attributes::StandardAttribute; use crate::plugins::telemetry::config_new::graphql::selectors::FieldName; use crate::plugins::telemetry::config_new::graphql::selectors::FieldType; @@ -14,13 +19,8 @@ use crate::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector; use crate::plugins::telemetry::config_new::graphql::selectors::ListLength; use crate::plugins::telemetry::config_new::graphql::selectors::TypeName; use crate::plugins::telemetry::config_new::selectors::OperationName; -use crate::plugins::telemetry::config_new::DefaultAttributeRequirementLevel; -use crate::plugins::telemetry::config_new::DefaultForLevel; -use crate::plugins::telemetry::config_new::Selector; -use crate::plugins::telemetry::config_new::Selectors; use crate::plugins::telemetry::otlp::TelemetryDataKind; use crate::services::supergraph; -use crate::Context; #[derive(Deserialize, JsonSchema, Clone, Default, Debug, PartialEq)] #[serde(deny_unknown_fields, default)] @@ -48,26 +48,24 @@ impl DefaultForLevel for GraphQLAttributes { requirement_level: DefaultAttributeRequirementLevel, kind: TelemetryDataKind, ) { - if let TelemetryDataKind::Metrics = kind { - if let DefaultAttributeRequirementLevel::Required = requirement_level { - self.field_name.get_or_insert(StandardAttribute::Bool(true)); - self.field_type.get_or_insert(StandardAttribute::Bool(true)); - self.type_name.get_or_insert(StandardAttribute::Bool(true)); - } + if let TelemetryDataKind::Metrics = kind + && let DefaultAttributeRequirementLevel::Required = requirement_level + { + self.field_name.get_or_insert(StandardAttribute::Bool(true)); + self.field_type.get_or_insert(StandardAttribute::Bool(true)); + self.type_name.get_or_insert(StandardAttribute::Bool(true)); } } } -impl Selectors for GraphQLAttributes { - type Request = supergraph::Request; - type Response = supergraph::Response; - type EventResponse = crate::graphql::Response; - - fn on_request(&self, _request: &Self::Request) -> Vec { +impl Selectors + for GraphQLAttributes +{ + fn on_request(&self, _request: &supergraph::Request) -> Vec { Vec::default() } - fn on_response(&self, _response: &Self::Response) -> Vec { + fn on_response(&self, _response: &supergraph::Response) -> Vec { Vec::default() } @@ -87,67 +85,57 @@ impl Selectors for GraphQLAttributes { .field_name .as_ref() .and_then(|a| a.key(Key::from_static_str("graphql.field.name"))) - { - if let Some(name) = (GraphQLSelector::FieldName { + && let Some(name) = (GraphQLSelector::FieldName { field_name: FieldName::String, }) .on_response_field(ty, field, value, ctx) - { - attrs.push(KeyValue::new(key, name)); - } + { + attrs.push(KeyValue::new(key, name)); } if let Some(key) = self .field_type .as_ref() .and_then(|a| a.key(Key::from_static_str("graphql.field.type"))) - { - if let Some(ty) = (GraphQLSelector::FieldType { + && let Some(ty) = (GraphQLSelector::FieldType { field_type: FieldType::Name, }) .on_response_field(ty, field, value, ctx) - { - attrs.push(KeyValue::new(key, ty)); - } + { + attrs.push(KeyValue::new(key, ty)); } if let Some(key) = self .type_name .as_ref() .and_then(|a| a.key(Key::from_static_str("graphql.type.name"))) - { - if let Some(ty) = (GraphQLSelector::TypeName { + && let Some(ty) = (GraphQLSelector::TypeName { type_name: TypeName::String, }) .on_response_field(ty, field, value, ctx) - { - attrs.push(KeyValue::new(key, ty)); - } + { + attrs.push(KeyValue::new(key, ty)); } if let Some(key) = self .list_length .as_ref() .and_then(|a| a.key(Key::from_static_str("graphql.list.length"))) - { - if let Some(length) = (GraphQLSelector::ListLength { + && let Some(length) = (GraphQLSelector::ListLength { list_length: ListLength::Value, }) .on_response_field(ty, field, value, ctx) - { - attrs.push(KeyValue::new(key, length)); - } + { + attrs.push(KeyValue::new(key, length)); } if let Some(key) = self .operation_name .as_ref() .and_then(|a| a.key(Key::from_static_str("graphql.operation.name"))) - { - if let Some(length) = (GraphQLSelector::OperationName { + && let Some(length) = (GraphQLSelector::OperationName { operation_name: OperationName::String, default: None, }) .on_response_field(ty, field, value, ctx) - { - attrs.push(KeyValue::new(key, length)); - } + { + attrs.push(KeyValue::new(key, length)); } } } @@ -156,13 +144,13 @@ impl Selectors for GraphQLAttributes { mod test { use serde_json_bytes::json; + use crate::Context; use crate::context::OPERATION_NAME; + use crate::plugins::telemetry::config_new::DefaultForLevel; + use crate::plugins::telemetry::config_new::Selectors; use crate::plugins::telemetry::config_new::attributes::StandardAttribute; use crate::plugins::telemetry::config_new::test::field; use crate::plugins::telemetry::config_new::test::ty; - use crate::plugins::telemetry::config_new::DefaultForLevel; - use crate::plugins::telemetry::config_new::Selectors; - use crate::Context; #[test] fn test_default_for_level() { diff --git a/apollo-router/src/plugins/telemetry/config_new/graphql/mod.rs b/apollo-router/src/plugins/telemetry/config_new/graphql/mod.rs index 3cabed05ff..ae3758cc1b 100644 --- a/apollo-router/src/plugins/telemetry/config_new/graphql/mod.rs +++ b/apollo-router/src/plugins/telemetry/config_new/graphql/mod.rs @@ -1,6 +1,6 @@ +use apollo_compiler::ExecutableDocument; use apollo_compiler::ast::NamedType; use apollo_compiler::executable::Field; -use apollo_compiler::ExecutableDocument; use schemars::JsonSchema; use serde::Deserialize; use serde_json_bytes::Value; @@ -8,8 +8,10 @@ use tower::BoxError; use super::instruments::CustomCounter; use super::instruments::CustomInstruments; +use crate::Context; use crate::graphql::ResponseVisitor; use crate::json_ext::Object; +use crate::plugins::telemetry::config_new::DefaultForLevel; use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; use crate::plugins::telemetry::config_new::extendable::Extendable; use crate::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes; @@ -18,10 +20,8 @@ use crate::plugins::telemetry::config_new::graphql::selectors::GraphQLValue; use crate::plugins::telemetry::config_new::instruments::CustomHistogram; use crate::plugins::telemetry::config_new::instruments::DefaultedStandardInstrument; use crate::plugins::telemetry::config_new::instruments::Instrumented; -use crate::plugins::telemetry::config_new::DefaultForLevel; use crate::plugins::telemetry::otlp::TelemetryDataKind; use crate::services::supergraph; -use crate::Context; pub(crate) mod attributes; pub(crate) mod selectors; @@ -62,6 +62,7 @@ impl DefaultForLevel for GraphQLInstrumentsConfig { pub(crate) type GraphQLCustomInstruments = CustomInstruments< supergraph::Request, supergraph::Response, + crate::graphql::Response, GraphQLAttributes, GraphQLSelector, GraphQLValue, @@ -72,6 +73,7 @@ pub(crate) struct GraphQLInstruments { CustomHistogram< supergraph::Request, supergraph::Response, + crate::graphql::Response, GraphQLAttributes, GraphQLSelector, >, @@ -80,6 +82,7 @@ pub(crate) struct GraphQLInstruments { CustomCounter< supergraph::Request, supergraph::Response, + crate::graphql::Response, GraphQLAttributes, GraphQLSelector, >, @@ -131,20 +134,20 @@ impl Instrumented for GraphQLInstruments { } self.custom.on_response_event(response, ctx); - if !self.custom.is_empty() || self.list_length.is_some() || self.field_execution.is_some() { - if let Some(executable_document) = ctx.unsupported_executable_document() { - GraphQLInstrumentsVisitor { - ctx, - instruments: self, - } - .visit( - &executable_document, - response, - &ctx.get_demand_control_context() - .map(|c| c.variables) - .unwrap_or_default(), - ); + if (!self.custom.is_empty() || self.list_length.is_some() || self.field_execution.is_some()) + && let Some(executable_document) = ctx.executable_document() + { + GraphQLInstrumentsVisitor { + ctx, + instruments: self, } + .visit( + &executable_document, + response, + &ctx.get_demand_control_context() + .map(|c| c.variables) + .unwrap_or_default(), + ); } } @@ -164,7 +167,7 @@ struct GraphQLInstrumentsVisitor<'a> { instruments: &'a GraphQLInstruments, } -impl<'a> ResponseVisitor for GraphQLInstrumentsVisitor<'a> { +impl ResponseVisitor for GraphQLInstrumentsVisitor<'_> { fn visit_field( &mut self, request: &ExecutableDocument, @@ -200,10 +203,10 @@ impl<'a> ResponseVisitor for GraphQLInstrumentsVisitor<'a> { pub(crate) mod test { use super::*; + use crate::Configuration; use crate::metrics::FutureMetricsExt; use crate::plugins::telemetry::Telemetry; use crate::plugins::test::PluginTestHarness; - use crate::Configuration; #[test_log::test(tokio::test)] async fn basic_metric_publishing() { @@ -224,10 +227,10 @@ pub(crate) mod test { .config(include_str!("fixtures/field_length_enabled.router.yaml")) .schema(schema_str) .build() - .await; + .await.expect("test harness"); harness - .call_supergraph(request, |req| { + .supergraph_service(|req| async { let response: serde_json::Value = serde_json::from_str(include_str!( "../../../demand_control/cost_calculator/fixtures/federated_ships_named_response.json" )) @@ -236,8 +239,8 @@ pub(crate) mod test { .data(response["data"].clone()) .context(req.context) .build() - .unwrap() }) + .call(request) .await .unwrap(); @@ -272,9 +275,9 @@ pub(crate) mod test { .config(include_str!("fixtures/field_length_enabled.router.yaml")) .schema(schema_str) .build() - .await; + .await.expect("test harness"); harness - .call_supergraph(request, |req| { + .supergraph_service(|req| async { let response: serde_json::Value = serde_json::from_str(include_str!( "../../../demand_control/cost_calculator/fixtures/federated_ships_fragment_response.json" )) @@ -283,8 +286,9 @@ pub(crate) mod test { .data(response["data"].clone()) .context(req.context) .build() - .unwrap() + }) + .call(request) .await .unwrap(); @@ -326,10 +330,10 @@ pub(crate) mod test { .config(include_str!("fixtures/field_length_disabled.router.yaml")) .schema(schema_str) .build() - .await; + .await.expect("test harness"); harness - .call_supergraph(request, |req| { + .supergraph_service(|req| async { let response: serde_json::Value = serde_json::from_str(include_str!( "../../../demand_control/cost_calculator/fixtures/federated_ships_named_response.json" )) @@ -338,8 +342,8 @@ pub(crate) mod test { .data(response["data"].clone()) .context(req.context) .build() - .unwrap() }) + .call(request) .await .unwrap(); @@ -368,10 +372,10 @@ pub(crate) mod test { .config(include_str!("fixtures/filtered_field_length.router.yaml")) .schema(schema_str) .build() - .await; + .await.expect("test harness"); harness - .call_supergraph(request, |req| { + .supergraph_service(|req| async { let response: serde_json::Value = serde_json::from_str(include_str!( "../../../demand_control/cost_calculator/fixtures/federated_ships_fragment_response.json" )) @@ -380,8 +384,8 @@ pub(crate) mod test { .data(response["data"].clone()) .context(req.context) .build() - .unwrap() }) + .call(request) .await .unwrap(); @@ -397,9 +401,7 @@ pub(crate) mod test { crate::spec::Query::parse_document(query_str, None, &schema, &Configuration::default()) .unwrap(); let context = Context::new(); - context - .extensions() - .with_lock(|mut lock| lock.insert(query)); + context.extensions().with_lock(|lock| lock.insert(query)); context } diff --git a/apollo-router/src/plugins/telemetry/config_new/graphql/selectors.rs b/apollo-router/src/plugins/telemetry/config_new/graphql/selectors.rs index 853681087f..6fc9b8443d 100644 --- a/apollo-router/src/plugins/telemetry/config_new/graphql/selectors.rs +++ b/apollo-router/src/plugins/telemetry/config_new/graphql/selectors.rs @@ -6,15 +6,15 @@ use serde_json_bytes::Value; use sha2::Digest; use tower::BoxError; +use crate::Context; use crate::context::OPERATION_NAME; use crate::plugins::telemetry::config::AttributeValue; +use crate::plugins::telemetry::config_new::Selector; +use crate::plugins::telemetry::config_new::Stage; use crate::plugins::telemetry::config_new::instruments; use crate::plugins::telemetry::config_new::instruments::InstrumentValue; use crate::plugins::telemetry::config_new::instruments::StandardUnit; use crate::plugins::telemetry::config_new::selectors::OperationName; -use crate::plugins::telemetry::config_new::Selector; -use crate::plugins::telemetry::config_new::Stage; -use crate::Context; #[derive(Deserialize, JsonSchema, Clone, Debug)] #[serde(deny_unknown_fields, rename_all = "snake_case")] diff --git a/apollo-router/src/plugins/telemetry/config_new/http_common/attributes.rs b/apollo-router/src/plugins/telemetry/config_new/http_common/attributes.rs new file mode 100644 index 0000000000..4c1c5f7b61 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/http_common/attributes.rs @@ -0,0 +1,577 @@ +use std::fmt::Debug; + +use http::StatusCode; +use http::header::CONTENT_LENGTH; +use opentelemetry::KeyValue; +use opentelemetry_semantic_conventions::attribute::HTTP_REQUEST_BODY_SIZE; +use opentelemetry_semantic_conventions::attribute::HTTP_RESPONSE_BODY_SIZE; +use opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD; +use opentelemetry_semantic_conventions::trace::HTTP_RESPONSE_STATUS_CODE; +use opentelemetry_semantic_conventions::trace::NETWORK_PROTOCOL_NAME; +use opentelemetry_semantic_conventions::trace::NETWORK_PROTOCOL_VERSION; +use opentelemetry_semantic_conventions::trace::NETWORK_TRANSPORT; +use opentelemetry_semantic_conventions::trace::NETWORK_TYPE; +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; + +use crate::Context; +use crate::axum_factory::utils::ConnectionInfo; +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::Selectors; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::attributes::ERROR_TYPE; +use crate::plugins::telemetry::config_new::attributes::StandardAttribute; +use crate::plugins::telemetry::otlp::TelemetryDataKind; +use crate::services::router; + +/// Common attributes for http server and client. +/// See https://opentelemetry.io/docs/specs/semconv/http/http-spans/#common-attributes +#[derive(Deserialize, JsonSchema, Clone, Default, Debug)] +#[cfg_attr(test, derive(PartialEq))] +#[serde(deny_unknown_fields, default)] +pub(crate) struct HttpCommonAttributes { + /// Describes a class of error the operation ended with. + /// Examples: + /// + /// * timeout + /// * name_resolution_error + /// * 500 + /// + /// Requirement level: Conditionally Required: If request has ended with an error. + #[serde(rename = "error.type")] + pub(crate) error_type: Option, + + /// The size of the request payload body in bytes. This is the number of bytes transferred excluding headers and is often, but not always, present as the Content-Length header. For requests using transport encoding, this should be the compressed size. + /// Examples: + /// + /// * 3495 + /// + /// Requirement level: Recommended + #[serde(rename = "http.request.body.size")] + pub(crate) http_request_body_size: Option, + + /// HTTP request method. + /// Examples: + /// + /// * GET + /// * POST + /// * HEAD + /// + /// Requirement level: Required + #[serde(rename = "http.request.method")] + pub(crate) http_request_method: Option, + + /// Original HTTP method sent by the client in the request line. + /// Examples: + /// + /// * GeT + /// * ACL + /// * foo + /// + /// Requirement level: Conditionally Required (If and only if it’s different than http.request.method) + #[serde(rename = "http.request.method.original", skip)] + pub(crate) http_request_method_original: Option, + + /// The size of the response payload body in bytes. This is the number of bytes transferred excluding headers and is often, but not always, present as the Content-Length header. For requests using transport encoding, this should be the compressed size. + /// Examples: + /// + /// * 3495 + /// + /// Requirement level: Recommended + #[serde(rename = "http.response.body.size")] + pub(crate) http_response_body_size: Option, + + /// HTTP response status code. + /// Examples: + /// + /// * 200 + /// + /// Requirement level: Conditionally Required: If and only if one was received/sent. + #[serde(rename = "http.response.status_code")] + pub(crate) http_response_status_code: Option, + + /// OSI application layer or non-OSI equivalent. + /// Examples: + /// + /// * http + /// * spdy + /// + /// Requirement level: Recommended: if not default (http). + #[serde(rename = "network.protocol.name")] + pub(crate) network_protocol_name: Option, + + /// Version of the protocol specified in network.protocol.name. + /// Examples: + /// + /// * 1.0 + /// * 1.1 + /// * 2 + /// * 3 + /// + /// Requirement level: Recommended + #[serde(rename = "network.protocol.version")] + pub(crate) network_protocol_version: Option, + + /// OSI transport layer. + /// Examples: + /// + /// * tcp + /// * udp + /// + /// Requirement level: Conditionally Required + #[serde(rename = "network.transport")] + pub(crate) network_transport: Option, + + /// OSI network layer or non-OSI equivalent. + /// Examples: + /// + /// * ipv4 + /// * ipv6 + /// + /// Requirement level: Recommended + #[serde(rename = "network.type")] + pub(crate) network_type: Option, +} + +impl DefaultForLevel for HttpCommonAttributes { + fn defaults_for_level( + &mut self, + requirement_level: DefaultAttributeRequirementLevel, + kind: TelemetryDataKind, + ) { + match requirement_level { + DefaultAttributeRequirementLevel::Required => { + if self.error_type.is_none() { + self.error_type = Some(StandardAttribute::Bool(true)); + } + if self.http_request_method.is_none() { + self.http_request_method = Some(StandardAttribute::Bool(true)); + } + if self.http_response_status_code.is_none() { + self.http_response_status_code = Some(StandardAttribute::Bool(true)); + } + } + DefaultAttributeRequirementLevel::Recommended => { + // Recommended + match kind { + TelemetryDataKind::Traces => { + if self.http_request_body_size.is_none() { + self.http_request_body_size = Some(StandardAttribute::Bool(true)); + } + if self.http_response_body_size.is_none() { + self.http_response_body_size = Some(StandardAttribute::Bool(true)); + } + if self.network_protocol_version.is_none() { + self.network_protocol_version = Some(StandardAttribute::Bool(true)); + } + if self.network_type.is_none() { + self.network_type = Some(StandardAttribute::Bool(true)); + } + } + TelemetryDataKind::Metrics => { + if self.network_protocol_version.is_none() { + self.network_protocol_version = Some(StandardAttribute::Bool(true)); + } + } + } + } + DefaultAttributeRequirementLevel::None => {} + } + } +} + +impl Selectors for HttpCommonAttributes { + fn on_request(&self, request: &router::Request) -> Vec { + let mut attrs = Vec::new(); + if let Some(key) = self + .http_request_method + .as_ref() + .and_then(|a| a.key(HTTP_REQUEST_METHOD.into())) + { + attrs.push(KeyValue::new( + key, + request.router_request.method().as_str().to_string(), + )); + } + + if let Some(key) = self + .http_request_body_size + .as_ref() + .and_then(|a| a.key(HTTP_REQUEST_BODY_SIZE.into())) + && let Some(content_length) = request + .router_request + .headers() + .get(&CONTENT_LENGTH) + .and_then(|h| h.to_str().ok()) + && let Ok(content_length) = content_length.parse::() + { + attrs.push(KeyValue::new( + key, + opentelemetry::Value::I64(content_length), + )); + } + if let Some(key) = self + .network_protocol_name + .as_ref() + .and_then(|a| a.key(NETWORK_PROTOCOL_NAME.into())) + && let Some(scheme) = request.router_request.uri().scheme() + { + attrs.push(KeyValue::new(key, scheme.to_string())); + } + if let Some(key) = self + .network_protocol_version + .as_ref() + .and_then(|a| a.key(NETWORK_PROTOCOL_VERSION.into())) + { + attrs.push(KeyValue::new( + key, + format!("{:?}", request.router_request.version()), + )); + } + if let Some(key) = self + .network_transport + .as_ref() + .and_then(|a| a.key(NETWORK_TRANSPORT.into())) + { + attrs.push(KeyValue::new(key, "tcp".to_string())); + } + if let Some(key) = self + .network_type + .as_ref() + .and_then(|a| a.key(NETWORK_TYPE.into())) + && let Some(connection_info) = + request.router_request.extensions().get::() + && let Some(socket) = connection_info.server_address + { + if socket.is_ipv4() { + attrs.push(KeyValue::new(key, "ipv4".to_string())); + } else if socket.is_ipv6() { + attrs.push(KeyValue::new(key, "ipv6".to_string())); + } + } + + attrs + } + + fn on_response(&self, response: &router::Response) -> Vec { + let mut attrs = Vec::new(); + if let Some(key) = self + .http_response_body_size + .as_ref() + .and_then(|a| a.key(HTTP_RESPONSE_BODY_SIZE.into())) + && let Some(content_length) = response + .response + .headers() + .get(&CONTENT_LENGTH) + .and_then(|h| h.to_str().ok()) + && let Ok(content_length) = content_length.parse::() + { + attrs.push(KeyValue::new( + key, + opentelemetry::Value::I64(content_length), + )); + } + + if let Some(key) = self + .http_response_status_code + .as_ref() + .and_then(|a| a.key(HTTP_RESPONSE_STATUS_CODE.into())) + { + attrs.push(KeyValue::new( + key, + response.response.status().as_u16() as i64, + )); + } + + if let Some(key) = self.error_type.as_ref().and_then(|a| a.key(ERROR_TYPE)) + && !response.response.status().is_success() + { + attrs.push(KeyValue::new( + key, + response + .response + .status() + .canonical_reason() + .unwrap_or("unknown"), + )); + } + + attrs + } + + fn on_error(&self, _error: &BoxError, _ctx: &Context) -> Vec { + let mut attrs = Vec::new(); + if let Some(key) = self.error_type.as_ref().and_then(|a| a.key(ERROR_TYPE)) { + attrs.push(KeyValue::new( + key, + StatusCode::INTERNAL_SERVER_ERROR + .canonical_reason() + .unwrap_or("unknown"), + )); + } + if let Some(key) = self + .http_response_status_code + .as_ref() + .and_then(|a| a.key(HTTP_RESPONSE_STATUS_CODE.into())) + { + attrs.push(KeyValue::new( + key, + StatusCode::INTERNAL_SERVER_ERROR.as_u16() as i64, + )); + } + + attrs + } +} + +#[cfg(test)] +mod test { + use std::net::SocketAddr; + use std::str::FromStr; + + use anyhow::anyhow; + use http::HeaderValue; + use http::StatusCode; + use http::Uri; + use opentelemetry_semantic_conventions::attribute::HTTP_REQUEST_BODY_SIZE; + use opentelemetry_semantic_conventions::attribute::HTTP_RESPONSE_BODY_SIZE; + use opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD; + use opentelemetry_semantic_conventions::trace::HTTP_RESPONSE_STATUS_CODE; + use opentelemetry_semantic_conventions::trace::NETWORK_PROTOCOL_NAME; + use opentelemetry_semantic_conventions::trace::NETWORK_PROTOCOL_VERSION; + use opentelemetry_semantic_conventions::trace::NETWORK_TRANSPORT; + use opentelemetry_semantic_conventions::trace::NETWORK_TYPE; + + use super::*; + use crate::axum_factory::utils::ConnectionInfo; + use crate::plugins::telemetry::config_new::attributes::ERROR_TYPE; + use crate::services::router; + + #[test] + fn test_http_common_error_type() { + let common = HttpCommonAttributes { + error_type: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = common.on_response( + &router::Response::fake_builder() + .status_code(StatusCode::BAD_REQUEST) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key == ERROR_TYPE) + .map(|key_val| &key_val.value), + Some( + &StatusCode::BAD_REQUEST + .canonical_reason() + .unwrap_or_default() + .into() + ) + ); + + let attributes = common.on_error(&anyhow!("test error").into(), &Default::default()); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key == ERROR_TYPE) + .map(|key_val| &key_val.value), + Some( + &StatusCode::INTERNAL_SERVER_ERROR + .canonical_reason() + .unwrap_or_default() + .into() + ) + ); + } + + #[test] + fn test_http_common_request_body_size() { + let common = HttpCommonAttributes { + http_request_body_size: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = common.on_request( + &router::Request::fake_builder() + .header( + http::header::CONTENT_LENGTH, + HeaderValue::from_static("256"), + ) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == HTTP_REQUEST_BODY_SIZE) + .map(|key_val| &key_val.value), + Some(&256.into()) + ); + } + + #[test] + fn test_http_common_response_body_size() { + let common = HttpCommonAttributes { + http_response_body_size: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = common.on_response( + &router::Response::fake_builder() + .header( + http::header::CONTENT_LENGTH, + HeaderValue::from_static("256"), + ) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == HTTP_RESPONSE_BODY_SIZE) + .map(|key_val| &key_val.value), + Some(&256.into()) + ); + } + + #[test] + fn test_http_common_request_method() { + let common = HttpCommonAttributes { + http_request_method: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = common.on_request( + &router::Request::fake_builder() + .method(http::Method::POST) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == HTTP_REQUEST_METHOD) + .map(|key_val| &key_val.value), + Some(&"POST".into()) + ); + } + + #[test] + fn test_http_common_response_status_code() { + let common = HttpCommonAttributes { + http_response_status_code: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = common.on_response( + &router::Response::fake_builder() + .status_code(StatusCode::BAD_REQUEST) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == HTTP_RESPONSE_STATUS_CODE) + .map(|key_val| &key_val.value), + Some(&(StatusCode::BAD_REQUEST.as_u16() as i64).into()) + ); + + let attributes = common.on_error(&anyhow!("test error").into(), &Default::default()); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == HTTP_RESPONSE_STATUS_CODE) + .map(|key_val| &key_val.value), + Some(&(StatusCode::INTERNAL_SERVER_ERROR.as_u16() as i64).into()) + ); + } + + #[test] + fn test_http_common_network_protocol_name() { + let common = HttpCommonAttributes { + network_protocol_name: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = common.on_request( + &router::Request::fake_builder() + .uri(Uri::from_static("https://localhost/graphql")) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == NETWORK_PROTOCOL_NAME) + .map(|key_val| &key_val.value), + Some(&"https".into()) + ); + } + + #[test] + fn test_http_common_network_protocol_version() { + let common = HttpCommonAttributes { + network_protocol_version: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = common.on_request( + &router::Request::fake_builder() + .uri(Uri::from_static("https://localhost/graphql")) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == NETWORK_PROTOCOL_VERSION) + .map(|key_val| &key_val.value), + Some(&"HTTP/1.1".into()) + ); + } + + #[test] + fn test_http_common_network_transport() { + let common = HttpCommonAttributes { + network_transport: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = common.on_request(&router::Request::fake_builder().build().unwrap()); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == NETWORK_TRANSPORT) + .map(|key_val| &key_val.value), + Some(&"tcp".into()) + ); + } + + #[test] + fn test_http_common_network_type() { + let common = HttpCommonAttributes { + network_type: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let mut req = router::Request::fake_builder().build().unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = common.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == NETWORK_TYPE) + .map(|key_val| &key_val.value), + Some(&"ipv4".into()) + ); + } +} diff --git a/apollo-router-scaffold/templates/base/src/plugins/mod.rs b/apollo-router/src/plugins/telemetry/config_new/http_common/events.rs similarity index 100% rename from apollo-router-scaffold/templates/base/src/plugins/mod.rs rename to apollo-router/src/plugins/telemetry/config_new/http_common/events.rs diff --git a/apollo-router/src/plugins/telemetry/config_new/http_common/instruments.rs b/apollo-router/src/plugins/telemetry/config_new/http_common/instruments.rs new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/http_common/instruments.rs @@ -0,0 +1 @@ + diff --git a/apollo-router/src/plugins/telemetry/config_new/http_common/mod.rs b/apollo-router/src/plugins/telemetry/config_new/http_common/mod.rs new file mode 100644 index 0000000000..232f3aa106 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/http_common/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod attributes; +pub(crate) mod events; +pub(crate) mod instruments; +pub(crate) mod selectors; +pub(crate) mod spans; diff --git a/apollo-router/src/plugins/telemetry/config_new/http_common/selectors.rs b/apollo-router/src/plugins/telemetry/config_new/http_common/selectors.rs new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/http_common/selectors.rs @@ -0,0 +1 @@ + diff --git a/apollo-router/src/plugins/telemetry/config_new/http_common/spans.rs b/apollo-router/src/plugins/telemetry/config_new/http_common/spans.rs new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/http_common/spans.rs @@ -0,0 +1 @@ + diff --git a/apollo-router/src/plugins/telemetry/config_new/http_server/attributes.rs b/apollo-router/src/plugins/telemetry/config_new/http_server/attributes.rs new file mode 100644 index 0000000000..1685810e0d --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/http_server/attributes.rs @@ -0,0 +1,827 @@ +use std::fmt::Debug; +use std::net::SocketAddr; + +use http::Uri; +use http::header::FORWARDED; +use http::header::USER_AGENT; +use opentelemetry::KeyValue; +use opentelemetry_semantic_conventions::trace::CLIENT_ADDRESS; +use opentelemetry_semantic_conventions::trace::CLIENT_PORT; +use opentelemetry_semantic_conventions::trace::HTTP_ROUTE; +use opentelemetry_semantic_conventions::trace::SERVER_ADDRESS; +use opentelemetry_semantic_conventions::trace::SERVER_PORT; +use opentelemetry_semantic_conventions::trace::URL_PATH; +use opentelemetry_semantic_conventions::trace::URL_QUERY; +use opentelemetry_semantic_conventions::trace::URL_SCHEME; +use opentelemetry_semantic_conventions::trace::USER_AGENT_ORIGINAL; +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; + +use crate::Context; +use crate::axum_factory::utils::ConnectionInfo; +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::Selectors; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::attributes::NETWORK_LOCAL_ADDRESS; +use crate::plugins::telemetry::config_new::attributes::NETWORK_LOCAL_PORT; +use crate::plugins::telemetry::config_new::attributes::NETWORK_PEER_ADDRESS; +use crate::plugins::telemetry::config_new::attributes::NETWORK_PEER_PORT; +use crate::plugins::telemetry::config_new::attributes::StandardAttribute; +use crate::plugins::telemetry::otlp::TelemetryDataKind; +use crate::services::router; +use crate::services::router::Request; + +/// Attributes for Http servers +/// See https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server +#[derive(Deserialize, JsonSchema, Clone, Default, Debug)] +#[cfg_attr(test, derive(PartialEq))] +#[serde(deny_unknown_fields, default)] +pub(crate) struct HttpServerAttributes { + /// Client address - domain name if available without reverse DNS lookup, otherwise IP address or Unix domain socket name. + /// Examples: + /// + /// * 83.164.160.102 + /// + /// Requirement level: Recommended + #[serde(rename = "client.address", skip)] + pub(crate) client_address: Option, + /// The port of the original client behind all proxies, if known (e.g. from Forwarded or a similar header). Otherwise, the immediate client peer port. + /// Examples: + /// + /// * 65123 + /// + /// Requirement level: Recommended + #[serde(rename = "client.port", skip)] + pub(crate) client_port: Option, + /// The matched route (path template in the format used by the respective server framework). + /// Examples: + /// + /// * /graphql + /// + /// Requirement level: Conditionally Required: If and only if it’s available + #[serde(rename = "http.route")] + pub(crate) http_route: Option, + /// Local socket address. Useful in case of a multi-IP host. + /// Examples: + /// + /// * 10.1.2.80 + /// * /tmp/my.sock + /// + /// Requirement level: Opt-In + #[serde(rename = "network.local.address")] + pub(crate) network_local_address: Option, + /// Local socket port. Useful in case of a multi-port host. + /// Examples: + /// + /// * 65123 + /// + /// Requirement level: Opt-In + #[serde(rename = "network.local.port")] + pub(crate) network_local_port: Option, + /// Peer address of the network connection - IP address or Unix domain socket name. + /// Examples: + /// + /// * 10.1.2.80 + /// * /tmp/my.sock + /// + /// Requirement level: Recommended + #[serde(rename = "network.peer.address")] + pub(crate) network_peer_address: Option, + /// Peer port number of the network connection. + /// Examples: + /// + /// * 65123 + /// + /// Requirement level: Recommended + #[serde(rename = "network.peer.port")] + pub(crate) network_peer_port: Option, + /// Name of the local HTTP server that received the request. + /// Examples: + /// + /// * example.com + /// * 10.1.2.80 + /// * /tmp/my.sock + /// + /// Requirement level: Recommended + #[serde(rename = "server.address")] + pub(crate) server_address: Option, + /// Port of the local HTTP server that received the request. + /// Examples: + /// + /// * 80 + /// * 8080 + /// * 443 + /// + /// Requirement level: Recommended + #[serde(rename = "server.port")] + pub(crate) server_port: Option, + /// The URI path component + /// Examples: + /// + /// * /search + /// + /// Requirement level: Required + #[serde(rename = "url.path")] + pub(crate) url_path: Option, + /// The URI query component + /// Examples: + /// + /// * q=OpenTelemetry + /// + /// Requirement level: Conditionally Required: If and only if one was received/sent. + #[serde(rename = "url.query")] + pub(crate) url_query: Option, + + /// The URI scheme component identifying the used protocol. + /// Examples: + /// + /// * http + /// * https + /// + /// Requirement level: Required + #[serde(rename = "url.scheme")] + pub(crate) url_scheme: Option, + + /// Value of the HTTP User-Agent header sent by the client. + /// Examples: + /// + /// * CERN-LineMode/2.15 + /// * libwww/2.17b3 + /// + /// Requirement level: Recommended + #[serde(rename = "user_agent.original")] + pub(crate) user_agent_original: Option, +} + +impl DefaultForLevel for HttpServerAttributes { + fn defaults_for_level( + &mut self, + requirement_level: DefaultAttributeRequirementLevel, + kind: TelemetryDataKind, + ) { + match requirement_level { + DefaultAttributeRequirementLevel::Required => match kind { + TelemetryDataKind::Traces => { + if self.url_scheme.is_none() { + self.url_scheme = Some(StandardAttribute::Bool(true)); + } + if self.url_path.is_none() { + self.url_path = Some(StandardAttribute::Bool(true)); + } + if self.url_query.is_none() { + self.url_query = Some(StandardAttribute::Bool(true)); + } + + if self.http_route.is_none() { + self.http_route = Some(StandardAttribute::Bool(true)); + } + } + TelemetryDataKind::Metrics => { + if self.server_address.is_none() { + self.server_address = Some(StandardAttribute::Bool(true)); + } + if self.server_port.is_none() && self.server_address.is_some() { + self.server_port = Some(StandardAttribute::Bool(true)); + } + } + }, + DefaultAttributeRequirementLevel::Recommended => match kind { + TelemetryDataKind::Traces => { + if self.client_address.is_none() { + self.client_address = Some(StandardAttribute::Bool(true)); + } + if self.server_address.is_none() { + self.server_address = Some(StandardAttribute::Bool(true)); + } + if self.server_port.is_none() && self.server_address.is_some() { + self.server_port = Some(StandardAttribute::Bool(true)); + } + if self.user_agent_original.is_none() { + self.user_agent_original = Some(StandardAttribute::Bool(true)); + } + } + TelemetryDataKind::Metrics => {} + }, + DefaultAttributeRequirementLevel::None => {} + } + } +} + +impl Selectors for HttpServerAttributes { + fn on_request(&self, request: &router::Request) -> Vec { + let mut attrs = Vec::new(); + if let Some(key) = self + .http_route + .as_ref() + .and_then(|a| a.key(HTTP_ROUTE.into())) + { + attrs.push(KeyValue::new( + key, + request.router_request.uri().path().to_string(), + )); + } + if let Some(key) = self + .client_address + .as_ref() + .and_then(|a| a.key(CLIENT_ADDRESS.into())) + { + if let Some(forwarded) = Self::forwarded_for(request) { + attrs.push(KeyValue::new(key, forwarded.ip().to_string())); + } else if let Some(connection_info) = + request.router_request.extensions().get::() + && let Some(socket) = connection_info.peer_address + { + attrs.push(KeyValue::new(key, socket.ip().to_string())); + } + } + if let Some(key) = self + .client_port + .as_ref() + .and_then(|a| a.key(CLIENT_PORT.into())) + { + if let Some(forwarded) = Self::forwarded_for(request) { + attrs.push(KeyValue::new(key, forwarded.port() as i64)); + } else if let Some(connection_info) = + request.router_request.extensions().get::() + && let Some(socket) = connection_info.peer_address + { + attrs.push(KeyValue::new(key, socket.port() as i64)); + } + } + + if let Some(key) = self + .server_address + .as_ref() + .and_then(|a| a.key(SERVER_ADDRESS.into())) + { + if let Some(forwarded) = + Self::forwarded_host(request).and_then(|h| h.host().map(|h| h.to_string())) + { + attrs.push(KeyValue::new(key, forwarded)); + } else if let Some(connection_info) = + request.router_request.extensions().get::() + && let Some(socket) = connection_info.server_address + { + attrs.push(KeyValue::new(key, socket.ip().to_string())); + } + } + if let Some(key) = self + .server_port + .as_ref() + .and_then(|a| a.key(SERVER_PORT.into())) + { + if let Some(forwarded) = Self::forwarded_host(request).and_then(|h| h.port_u16()) { + attrs.push(KeyValue::new(key, forwarded as i64)); + } else if let Some(connection_info) = + request.router_request.extensions().get::() + && let Some(socket) = connection_info.server_address + { + attrs.push(KeyValue::new(key, socket.port() as i64)); + } + } + + if let Some(key) = self + .network_local_address + .as_ref() + .and_then(|a| a.key(NETWORK_LOCAL_ADDRESS)) + && let Some(connection_info) = + request.router_request.extensions().get::() + && let Some(socket) = connection_info.server_address + { + attrs.push(KeyValue::new(key, socket.ip().to_string())); + } + if let Some(key) = self + .network_local_port + .as_ref() + .and_then(|a| a.key(NETWORK_LOCAL_PORT)) + && let Some(connection_info) = + request.router_request.extensions().get::() + && let Some(socket) = connection_info.server_address + { + attrs.push(KeyValue::new(key, socket.port() as i64)); + } + + if let Some(key) = self + .network_peer_address + .as_ref() + .and_then(|a| a.key(NETWORK_PEER_ADDRESS)) + && let Some(connection_info) = + request.router_request.extensions().get::() + && let Some(socket) = connection_info.peer_address + { + attrs.push(KeyValue::new(key, socket.ip().to_string())); + } + if let Some(key) = self + .network_peer_port + .as_ref() + .and_then(|a| a.key(NETWORK_PEER_PORT)) + && let Some(connection_info) = + request.router_request.extensions().get::() + && let Some(socket) = connection_info.peer_address + { + attrs.push(KeyValue::new(key, socket.port() as i64)); + } + + let router_uri = request.router_request.uri(); + if let Some(key) = self.url_path.as_ref().and_then(|a| a.key(URL_PATH.into())) { + attrs.push(KeyValue::new(key, router_uri.path().to_string())); + } + if let Some(key) = self + .url_query + .as_ref() + .and_then(|a| a.key(URL_QUERY.into())) + && let Some(query) = router_uri.query() + { + attrs.push(KeyValue::new(key, query.to_string())); + } + if let Some(key) = self + .url_scheme + .as_ref() + .and_then(|a| a.key(URL_SCHEME.into())) + && let Some(scheme) = router_uri.scheme_str() + { + attrs.push(KeyValue::new(key, scheme.to_string())); + } + if let Some(key) = self + .user_agent_original + .as_ref() + .and_then(|a| a.key(USER_AGENT_ORIGINAL.into())) + && let Some(user_agent) = request + .router_request + .headers() + .get(&USER_AGENT) + .and_then(|h| h.to_str().ok()) + { + attrs.push(KeyValue::new(key, user_agent.to_string())); + } + + attrs + } + + fn on_response(&self, _response: &router::Response) -> Vec { + Vec::default() + } + + fn on_error(&self, _error: &BoxError, _ctx: &Context) -> Vec { + Vec::default() + } +} + +impl HttpServerAttributes { + fn forwarded_for(request: &Request) -> Option { + request + .router_request + .headers() + .get_all(FORWARDED) + .iter() + .filter_map(|h| h.to_str().ok()) + .filter_map(|h| { + if h.to_lowercase().starts_with("for=") { + Some(&h[4..]) + } else { + None + } + }) + .filter_map(|forwarded| forwarded.parse::().ok()) + .next() + } + + pub(crate) fn forwarded_host(request: &Request) -> Option { + request + .router_request + .headers() + .get_all(FORWARDED) + .iter() + .filter_map(|h| h.to_str().ok()) + .filter_map(|h| { + if h.to_lowercase().starts_with("host=") { + Some(&h[5..]) + } else { + None + } + }) + .filter_map(|forwarded| forwarded.parse::().ok()) + .next() + } +} + +#[cfg(test)] +mod test { + use std::net::SocketAddr; + use std::str::FromStr; + + use http::HeaderValue; + use http::Uri; + use http::header::FORWARDED; + use http::header::USER_AGENT; + use opentelemetry_semantic_conventions::trace::CLIENT_ADDRESS; + use opentelemetry_semantic_conventions::trace::CLIENT_PORT; + use opentelemetry_semantic_conventions::trace::HTTP_ROUTE; + use opentelemetry_semantic_conventions::trace::NETWORK_TYPE; + use opentelemetry_semantic_conventions::trace::SERVER_ADDRESS; + use opentelemetry_semantic_conventions::trace::SERVER_PORT; + use opentelemetry_semantic_conventions::trace::URL_PATH; + use opentelemetry_semantic_conventions::trace::URL_QUERY; + use opentelemetry_semantic_conventions::trace::URL_SCHEME; + use opentelemetry_semantic_conventions::trace::USER_AGENT_ORIGINAL; + + use crate::axum_factory::utils::ConnectionInfo; + use crate::plugins::telemetry::config_new::Selectors; + use crate::plugins::telemetry::config_new::attributes::NETWORK_LOCAL_ADDRESS; + use crate::plugins::telemetry::config_new::attributes::NETWORK_LOCAL_PORT; + use crate::plugins::telemetry::config_new::attributes::NETWORK_PEER_ADDRESS; + use crate::plugins::telemetry::config_new::attributes::NETWORK_PEER_PORT; + use crate::plugins::telemetry::config_new::attributes::StandardAttribute; + use crate::plugins::telemetry::config_new::http_common::attributes::HttpCommonAttributes; + use crate::plugins::telemetry::config_new::http_server::attributes::HttpServerAttributes; + use crate::services::router; + + #[test] + fn test_http_common_network_type() { + let common = HttpCommonAttributes { + network_type: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let mut req = router::Request::fake_builder().build().unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = common.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == NETWORK_TYPE) + .map(|key_val| &key_val.value), + Some(&"ipv4".into()) + ); + } + + #[test] + fn test_http_server_client_address() { + let server = HttpServerAttributes { + client_address: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let mut req = router::Request::fake_builder().build().unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = server.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == CLIENT_ADDRESS) + .map(|key_val| &key_val.value), + Some(&"192.168.0.8".into()) + ); + + let mut req = router::Request::fake_builder() + .header(FORWARDED, "for=2.4.6.8:8000") + .build() + .unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = server.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == CLIENT_ADDRESS) + .map(|key_val| &key_val.value), + Some(&"2.4.6.8".into()) + ); + } + + #[test] + fn test_http_server_client_port() { + let server = HttpServerAttributes { + client_port: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let mut req = router::Request::fake_builder().build().unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = server.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == CLIENT_PORT) + .map(|key_val| &key_val.value), + Some(&6060.into()) + ); + + let mut req = router::Request::fake_builder() + .header(FORWARDED, "for=2.4.6.8:8000") + .build() + .unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = server.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == CLIENT_PORT) + .map(|key_val| &key_val.value), + Some(&8000.into()) + ); + } + + #[test] + fn test_http_server_http_route() { + let server = HttpServerAttributes { + http_route: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let mut req = router::Request::fake_builder() + .uri(Uri::from_static("https://localhost/graphql")) + .build() + .unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = server.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == HTTP_ROUTE) + .map(|key_val| &key_val.value), + Some(&"/graphql".into()) + ); + } + + #[test] + fn test_http_server_network_local_address() { + let server = HttpServerAttributes { + network_local_address: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let mut req = router::Request::fake_builder() + .uri(Uri::from_static("https://localhost/graphql")) + .build() + .unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = server.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key == NETWORK_LOCAL_ADDRESS) + .map(|key_val| &key_val.value), + Some(&"192.168.0.1".into()) + ); + } + + #[test] + fn test_http_server_network_local_port() { + let server = HttpServerAttributes { + network_local_port: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let mut req = router::Request::fake_builder() + .uri(Uri::from_static("https://localhost/graphql")) + .build() + .unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = server.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key == NETWORK_LOCAL_PORT) + .map(|key_val| &key_val.value), + Some(&8080.into()) + ); + } + + #[test] + fn test_http_server_network_peer_address() { + let server = HttpServerAttributes { + network_peer_address: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let mut req = router::Request::fake_builder() + .uri(Uri::from_static("https://localhost/graphql")) + .build() + .unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = server.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key == NETWORK_PEER_ADDRESS) + .map(|key_val| &key_val.value), + Some(&"192.168.0.8".into()) + ); + } + + #[test] + fn test_http_server_network_peer_port() { + let server = HttpServerAttributes { + network_peer_port: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let mut req = router::Request::fake_builder() + .uri(Uri::from_static("https://localhost/graphql")) + .build() + .unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = server.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key == NETWORK_PEER_PORT) + .map(|key_val| &key_val.value), + Some(&6060.into()) + ); + } + + #[test] + fn test_http_server_server_address() { + let server = HttpServerAttributes { + server_address: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let mut req = router::Request::fake_builder().build().unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = server.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == SERVER_ADDRESS) + .map(|key_val| &key_val.value), + Some(&"192.168.0.1".into()) + ); + + let mut req = router::Request::fake_builder() + .header(FORWARDED, "host=2.4.6.8:8000") + .build() + .unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = server.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == SERVER_ADDRESS) + .map(|key_val| &key_val.value), + Some(&"2.4.6.8".into()) + ); + } + + #[test] + fn test_http_server_server_port() { + let server = HttpServerAttributes { + server_port: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let mut req = router::Request::fake_builder().build().unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = server.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == SERVER_PORT) + .map(|key_val| &key_val.value), + Some(&8080.into()) + ); + + let mut req = router::Request::fake_builder() + .header(FORWARDED, "host=2.4.6.8:8000") + .build() + .unwrap(); + req.router_request.extensions_mut().insert(ConnectionInfo { + peer_address: Some(SocketAddr::from_str("192.168.0.8:6060").unwrap()), + server_address: Some(SocketAddr::from_str("192.168.0.1:8080").unwrap()), + }); + let attributes = server.on_request(&req); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == SERVER_PORT) + .map(|key_val| &key_val.value), + Some(&8000.into()) + ); + } + #[test] + fn test_http_server_url_path() { + let server = HttpServerAttributes { + url_path: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = server.on_request( + &router::Request::fake_builder() + .uri(Uri::from_static("https://localhost/graphql")) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == URL_PATH) + .map(|key_val| &key_val.value), + Some(&"/graphql".into()) + ); + } + #[test] + fn test_http_server_query() { + let server = HttpServerAttributes { + url_query: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = server.on_request( + &router::Request::fake_builder() + .uri(Uri::from_static("https://localhost/graphql?hi=5")) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == URL_QUERY) + .map(|key_val| &key_val.value), + Some(&"hi=5".into()) + ); + } + #[test] + fn test_http_server_scheme() { + let server = HttpServerAttributes { + url_scheme: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = server.on_request( + &router::Request::fake_builder() + .uri(Uri::from_static("https://localhost/graphql")) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == URL_SCHEME) + .map(|key_val| &key_val.value), + Some(&"https".into()) + ); + } + + #[test] + fn test_http_server_user_agent_original() { + let server = HttpServerAttributes { + user_agent_original: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = server.on_request( + &router::Request::fake_builder() + .header(USER_AGENT, HeaderValue::from_static("my-agent")) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == USER_AGENT_ORIGINAL) + .map(|key_val| &key_val.value), + Some(&"my-agent".into()) + ); + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/http_server/events.rs b/apollo-router/src/plugins/telemetry/config_new/http_server/events.rs new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/http_server/events.rs @@ -0,0 +1 @@ + diff --git a/apollo-router/src/plugins/telemetry/config_new/http_server/instruments.rs b/apollo-router/src/plugins/telemetry/config_new/http_server/instruments.rs new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/http_server/instruments.rs @@ -0,0 +1 @@ + diff --git a/apollo-router/src/plugins/telemetry/config_new/http_server/mod.rs b/apollo-router/src/plugins/telemetry/config_new/http_server/mod.rs new file mode 100644 index 0000000000..232f3aa106 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/http_server/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod attributes; +pub(crate) mod events; +pub(crate) mod instruments; +pub(crate) mod selectors; +pub(crate) mod spans; diff --git a/apollo-router/src/plugins/telemetry/config_new/http_server/selectors.rs b/apollo-router/src/plugins/telemetry/config_new/http_server/selectors.rs new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/http_server/selectors.rs @@ -0,0 +1 @@ + diff --git a/apollo-router/src/plugins/telemetry/config_new/http_server/spans.rs b/apollo-router/src/plugins/telemetry/config_new/http_server/spans.rs new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/http_server/spans.rs @@ -0,0 +1 @@ + diff --git a/apollo-router/src/plugins/telemetry/config_new/instruments.rs b/apollo-router/src/plugins/telemetry/config_new/instruments.rs index 341f84ad35..d196f5d150 100644 --- a/apollo-router/src/plugins/telemetry/config_new/instruments.rs +++ b/apollo-router/src/plugins/telemetry/config_new/instruments.rs @@ -3,12 +3,12 @@ use std::fmt::Debug; use std::marker::PhantomData; use std::sync::Arc; -use opentelemetry::metrics::Unit; -use opentelemetry_api::metrics::Counter; -use opentelemetry_api::metrics::Histogram; -use opentelemetry_api::metrics::MeterProvider; -use opentelemetry_api::metrics::UpDownCounter; -use opentelemetry_api::KeyValue; +use opentelemetry::KeyValue; +use opentelemetry::metrics::Counter; +use opentelemetry::metrics::Histogram; +use opentelemetry::metrics::MeterProvider; +use opentelemetry::metrics::ObservableGauge; +use opentelemetry::metrics::UpDownCounter; use opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD; use opentelemetry_semantic_conventions::trace::SERVER_ADDRESS; use opentelemetry_semantic_conventions::trace::SERVER_PORT; @@ -20,43 +20,60 @@ use serde_json_bytes::Value; use tokio::time::Instant; use tower::BoxError; -use super::attributes::HttpServerAttributes; -use super::cache::attributes::CacheAttributes; +use super::DefaultForLevel; +use super::Selector; +use super::cache::CACHE_METRIC; use super::cache::CacheInstruments; use super::cache::CacheInstrumentsConfig; -use super::cache::CACHE_METRIC; -use super::graphql::selectors::ListLength; -use super::graphql::GraphQLInstruments; +use super::cache::attributes::CacheAttributes; use super::graphql::FIELD_EXECUTION; use super::graphql::FIELD_LENGTH; +use super::graphql::GraphQLInstruments; +use super::graphql::selectors::ListLength; +use super::http_server::attributes::HttpServerAttributes; +use super::router::instruments::RouterInstruments; +use super::router::instruments::RouterInstrumentsConfig; use super::selectors::CacheKind; -use super::DefaultForLevel; -use super::Selector; +use super::subgraph::instruments::SubgraphInstruments; +use super::subgraph::instruments::SubgraphInstrumentsConfig; +use super::supergraph::instruments::SupergraphCustomInstruments; +use super::supergraph::instruments::SupergraphInstrumentsConfig; +use crate::Context; +use crate::axum_factory::connection_handle::ConnectionState; +use crate::axum_factory::connection_handle::OPEN_CONNECTIONS_METRIC; use crate::metrics; +use crate::metrics::meter_provider; +use crate::plugins::telemetry::apollo::Config; +use crate::plugins::telemetry::config_new::Selectors; +use crate::plugins::telemetry::config_new::apollo::instruments::ApolloConnectorInstruments; +use crate::plugins::telemetry::config_new::apollo::instruments::ApolloSubgraphInstruments; use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; -use crate::plugins::telemetry::config_new::attributes::RouterAttributes; -use crate::plugins::telemetry::config_new::attributes::SubgraphAttributes; -use crate::plugins::telemetry::config_new::attributes::SupergraphAttributes; use crate::plugins::telemetry::config_new::conditions::Condition; +use crate::plugins::telemetry::config_new::connector::attributes::ConnectorAttributes; +use crate::plugins::telemetry::config_new::connector::instruments::ConnectorInstruments; +use crate::plugins::telemetry::config_new::connector::instruments::ConnectorInstrumentsConfig; +use crate::plugins::telemetry::config_new::connector::selectors::ConnectorSelector; +use crate::plugins::telemetry::config_new::connector::selectors::ConnectorValue; use crate::plugins::telemetry::config_new::cost::CostInstruments; -use crate::plugins::telemetry::config_new::cost::CostInstrumentsConfig; use crate::plugins::telemetry::config_new::extendable::Extendable; +use crate::plugins::telemetry::config_new::graphql::GraphQLInstrumentsConfig; use crate::plugins::telemetry::config_new::graphql::attributes::GraphQLAttributes; use crate::plugins::telemetry::config_new::graphql::selectors::GraphQLSelector; use crate::plugins::telemetry::config_new::graphql::selectors::GraphQLValue; -use crate::plugins::telemetry::config_new::graphql::GraphQLInstrumentsConfig; -use crate::plugins::telemetry::config_new::selectors::RouterSelector; -use crate::plugins::telemetry::config_new::selectors::RouterValue; -use crate::plugins::telemetry::config_new::selectors::SubgraphSelector; -use crate::plugins::telemetry::config_new::selectors::SubgraphValue; -use crate::plugins::telemetry::config_new::selectors::SupergraphSelector; -use crate::plugins::telemetry::config_new::selectors::SupergraphValue; -use crate::plugins::telemetry::config_new::Selectors; +use crate::plugins::telemetry::config_new::router::attributes::RouterAttributes; +use crate::plugins::telemetry::config_new::router::selectors::RouterSelector; +use crate::plugins::telemetry::config_new::router::selectors::RouterValue; +use crate::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes; +use crate::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector; +use crate::plugins::telemetry::config_new::subgraph::selectors::SubgraphValue; +use crate::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes; +use crate::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector; +use crate::plugins::telemetry::config_new::supergraph::selectors::SupergraphValue; use crate::plugins::telemetry::otlp::TelemetryDataKind; use crate::services::router; -use crate::services::subgraph; +use crate::services::router::pipeline_handle::PIPELINE_METRIC; +use crate::services::router::pipeline_handle::pipeline_counts; use crate::services::supergraph; -use crate::Context; pub(crate) const METER_NAME: &str = "apollo/router"; @@ -81,6 +98,11 @@ pub(crate) struct InstrumentsConfig { SubgraphInstrumentsConfig, Instrument, >, + /// Connector service instruments. + pub(crate) connector: Extendable< + ConnectorInstrumentsConfig, + Instrument, + >, /// GraphQL response field instruments. pub(crate) graphql: Extendable< GraphQLInstrumentsConfig, @@ -98,10 +120,12 @@ const HTTP_SERVER_REQUEST_BODY_SIZE_METRIC: &str = "http.server.request.body.siz const HTTP_SERVER_RESPONSE_BODY_SIZE_METRIC: &str = "http.server.response.body.size"; const HTTP_SERVER_ACTIVE_REQUESTS: &str = "http.server.active_requests"; -const HTTP_CLIENT_REQUEST_DURATION_METRIC: &str = "http.client.request.duration"; -const HTTP_CLIENT_REQUEST_BODY_SIZE_METRIC: &str = "http.client.request.body.size"; -const HTTP_CLIENT_RESPONSE_BODY_SIZE_METRIC: &str = "http.client.response.body.size"; +pub(super) const HTTP_CLIENT_REQUEST_DURATION_METRIC: &str = "http.client.request.duration"; +pub(super) const HTTP_CLIENT_REQUEST_BODY_SIZE_METRIC: &str = "http.client.request.body.size"; +pub(super) const HTTP_CLIENT_RESPONSE_BODY_SIZE_METRIC: &str = "http.client.response.body.size"; +pub(super) const APOLLO_ROUTER_OPERATIONS_FETCH_DURATION: &str = + "apollo.router.operations.fetch.duration"; impl InstrumentsConfig { pub(crate) fn validate(&self) -> Result<(), String> { for (name, custom) in &self.router.custom { @@ -129,6 +153,11 @@ impl InstrumentsConfig { format!("error for custom cache instrument {name:?} in condition: {err}") })?; } + for (name, custom) in &self.connector.custom { + custom.condition.validate(None).map_err(|err| { + format!("error for custom connector instrument {name:?} in condition: {err}") + })?; + } Ok(()) } @@ -144,6 +173,8 @@ impl InstrumentsConfig { .defaults_for_levels(self.default_requirement_level, TelemetryDataKind::Metrics); self.graphql .defaults_for_levels(self.default_requirement_level, TelemetryDataKind::Metrics); + self.connector + .defaults_for_levels(self.default_requirement_level, TelemetryDataKind::Metrics); } pub(crate) fn new_builtin_router_instruments(&self) -> HashMap { @@ -161,7 +192,7 @@ impl InstrumentsConfig { StaticInstrument::Histogram( meter .f64_histogram(HTTP_SERVER_REQUEST_DURATION_METRIC) - .with_unit(Unit::new("s")) + .with_unit("s") .with_description("Duration of HTTP server requests.") .init(), ), @@ -179,7 +210,7 @@ impl InstrumentsConfig { StaticInstrument::Histogram( meter .f64_histogram(HTTP_SERVER_REQUEST_BODY_SIZE_METRIC) - .with_unit(Unit::new("By")) + .with_unit("By") .with_description("Size of HTTP server request bodies.") .init(), ), @@ -197,7 +228,7 @@ impl InstrumentsConfig { StaticInstrument::Histogram( meter .f64_histogram(HTTP_SERVER_RESPONSE_BODY_SIZE_METRIC) - .with_unit(Unit::new("By")) + .with_unit("By") .with_description("Size of HTTP server response bodies.") .init(), ), @@ -215,7 +246,7 @@ impl InstrumentsConfig { StaticInstrument::UpDownCounterI64( meter .i64_up_down_counter(HTTP_SERVER_ACTIVE_REQUESTS) - .with_unit(Unit::new("request")) + .with_unit("request") .with_description("Number of active HTTP server requests.") .init(), ), @@ -231,7 +262,7 @@ impl InstrumentsConfig { meter .f64_counter(instrument_name.clone()) .with_description(instrument.description.clone()) - .with_unit(Unit::new(instrument.unit.clone())) + .with_unit(instrument.unit.clone()) .init(), ), ); @@ -243,7 +274,7 @@ impl InstrumentsConfig { meter .f64_histogram(instrument_name.clone()) .with_description(instrument.description.clone()) - .with_unit(Unit::new(instrument.unit.clone())) + .with_unit(instrument.unit.clone()) .init(), ), ); @@ -289,6 +320,7 @@ impl InstrumentsConfig { } }, updated: false, + _phantom: PhantomData, }), }); let http_server_request_body_size = @@ -318,8 +350,8 @@ impl InstrumentsConfig { ) .as_histogram() .cloned().expect( - "cannot convert instrument to histogram for router; this should not happen", - ) + "cannot convert instrument to histogram for router; this should not happen", + ) ), attributes: Vec::with_capacity(nb_attributes), selector: Some(Arc::new(RouterSelector::RequestHeader { @@ -329,6 +361,7 @@ impl InstrumentsConfig { })), selectors, updated: false, + _phantom: PhantomData, }), } }); @@ -361,7 +394,7 @@ impl InstrumentsConfig { .as_histogram() .cloned() .expect( - "cannot convert instrument to histogram for router; this should not happen", + "cannot convert instrument to histogram for router; this should not happen", ) ), attributes: Vec::with_capacity(nb_attributes), @@ -372,6 +405,7 @@ impl InstrumentsConfig { })), selectors, updated: false, + _phantom: PhantomData, }), } }); @@ -426,7 +460,7 @@ impl InstrumentsConfig { meter .f64_counter(instrument_name.clone()) .with_description(instrument.description.clone()) - .with_unit(Unit::new(instrument.unit.clone())) + .with_unit(instrument.unit.clone()) .init(), ), ); @@ -438,7 +472,7 @@ impl InstrumentsConfig { meter .f64_histogram(instrument_name.clone()) .with_description(instrument.description.clone()) - .with_unit(Unit::new(instrument.unit.clone())) + .with_unit(instrument.unit.clone()) .init(), ), ); @@ -479,7 +513,7 @@ impl InstrumentsConfig { StaticInstrument::Histogram( meter .f64_histogram(HTTP_CLIENT_REQUEST_DURATION_METRIC) - .with_unit(Unit::new("s")) + .with_unit("s") .with_description("Duration of HTTP client requests.") .init(), ), @@ -497,7 +531,7 @@ impl InstrumentsConfig { StaticInstrument::Histogram( meter .f64_histogram(HTTP_CLIENT_REQUEST_BODY_SIZE_METRIC) - .with_unit(Unit::new("By")) + .with_unit("By") .with_description("Size of HTTP client request bodies.") .init(), ), @@ -515,7 +549,7 @@ impl InstrumentsConfig { StaticInstrument::Histogram( meter .f64_histogram(HTTP_CLIENT_RESPONSE_BODY_SIZE_METRIC) - .with_unit(Unit::new("By")) + .with_unit("By") .with_description("Size of HTTP client response bodies.") .init(), ), @@ -531,7 +565,7 @@ impl InstrumentsConfig { meter .f64_counter(instrument_name.clone()) .with_description(instrument.description.clone()) - .with_unit(Unit::new(instrument.unit.clone())) + .with_unit(instrument.unit.clone()) .init(), ), ); @@ -543,7 +577,7 @@ impl InstrumentsConfig { meter .f64_histogram(instrument_name.clone()) .with_description(instrument.description.clone()) - .with_unit(Unit::new(instrument.unit.clone())) + .with_unit(instrument.unit.clone()) .init(), ), ); @@ -592,6 +626,7 @@ impl InstrumentsConfig { selector: None, selectors, updated: false, + _phantom: PhantomData, }), } }); @@ -633,6 +668,7 @@ impl InstrumentsConfig { })), selectors, updated: false, + _phantom: PhantomData, }), } }); @@ -674,6 +710,7 @@ impl InstrumentsConfig { })), selectors, updated: false, + _phantom: PhantomData, }), } }); @@ -685,6 +722,77 @@ impl InstrumentsConfig { } } + pub(crate) fn new_builtin_apollo_subgraph_instruments( + &self, + ) -> HashMap { + ApolloSubgraphInstruments::new_builtin() + } + + pub(crate) fn new_apollo_subgraph_instruments( + &self, + static_instruments: Arc>, + apollo_config: Config, + ) -> ApolloSubgraphInstruments { + ApolloSubgraphInstruments::new(static_instruments, apollo_config) + } + + pub(crate) fn new_connector_instruments( + &self, + static_instruments: Arc>, + ) -> ConnectorInstruments { + ConnectorInstruments::new(&self.connector, static_instruments) + } + + pub(crate) fn new_builtin_connector_instruments(&self) -> HashMap { + let meter = metrics::meter_provider().meter(METER_NAME); + let mut static_instruments = ConnectorInstruments::new_builtin(&self.connector); + + for (instrument_name, instrument) in &self.connector.custom { + match instrument.ty { + InstrumentType::Counter => { + static_instruments.insert( + instrument_name.clone(), + StaticInstrument::CounterF64( + meter + .f64_counter(instrument_name.clone()) + .with_description(instrument.description.clone()) + .with_unit(instrument.unit.clone()) + .init(), + ), + ); + } + InstrumentType::Histogram => { + static_instruments.insert( + instrument_name.clone(), + StaticInstrument::Histogram( + meter + .f64_histogram(instrument_name.clone()) + .with_description(instrument.description.clone()) + .with_unit(instrument.unit.clone()) + .init(), + ), + ); + } + } + } + + static_instruments + } + + pub(crate) fn new_builtin_apollo_connector_instruments( + &self, + ) -> HashMap { + ApolloConnectorInstruments::new_builtin() + } + + pub(crate) fn new_apollo_connector_instruments( + &self, + static_instruments: Arc>, + apollo_config: Config, + ) -> ApolloConnectorInstruments { + ApolloConnectorInstruments::new(static_instruments, apollo_config) + } + pub(crate) fn new_builtin_graphql_instruments(&self) -> HashMap { let meter = metrics::meter_provider().meter(METER_NAME); let mut static_instruments = HashMap::with_capacity(self.graphql.custom.len()); @@ -721,7 +829,7 @@ impl InstrumentsConfig { meter .f64_counter(instrument_name.clone()) .with_description(instrument.description.clone()) - .with_unit(Unit::new(instrument.unit.clone())) + .with_unit(instrument.unit.clone()) .init(), ), ); @@ -733,7 +841,7 @@ impl InstrumentsConfig { meter .f64_histogram(instrument_name.clone()) .with_description(instrument.description.clone()) - .with_unit(Unit::new(instrument.unit.clone())) + .with_unit(instrument.unit.clone()) .init(), ), ); @@ -765,22 +873,23 @@ impl InstrumentsConfig { increment: Increment::FieldCustom(None), condition: Condition::True, histogram: Some(static_instruments - .get(FIELD_LENGTH) - .expect( - "cannot get static instrument for graphql; this should not happen", - ) - .as_histogram() - .cloned() - .expect( - "cannot convert instrument to counter for graphql; this should not happen", - ) - ), + .get(FIELD_LENGTH) + .expect( + "cannot get static instrument for graphql; this should not happen", + ) + .as_histogram() + .cloned() + .expect( + "cannot convert instrument to counter for graphql; this should not happen", + ) + ), attributes: Vec::with_capacity(nb_attributes), selector: Some(Arc::new(GraphQLSelector::ListLength { list_length: ListLength::Value, })), selectors, updated: false, + _phantom: PhantomData, }), } }), @@ -818,6 +927,7 @@ impl InstrumentsConfig { selector: None, selectors, incremented: false, + _phantom: PhantomData, }), } }), @@ -834,7 +944,7 @@ impl InstrumentsConfig { StaticInstrument::CounterF64( meter .f64_counter(CACHE_METRIC) - .with_unit(Unit::new("ops")) + .with_unit("ops") .with_description("Entity cache hit/miss operations at the subgraph level") .init(), ), @@ -865,16 +975,16 @@ impl InstrumentsConfig { increment: Increment::Custom(None), condition: Condition::True, counter: Some(static_instruments - .get(CACHE_METRIC) - .expect( - "cannot get static instrument for cache; this should not happen", - ) - .as_counter_f64() - .cloned() - .expect( - "cannot convert instrument to counter for cache; this should not happen", - ) - ), + .get(CACHE_METRIC) + .expect( + "cannot get static instrument for cache; this should not happen", + ) + .as_counter_f64() + .cloned() + .expect( + "cannot convert instrument to counter for cache; this should not happen", + ) + ), attributes: Vec::with_capacity(nb_attributes), selector: Some(Arc::new(SubgraphSelector::Cache { cache: CacheKind::Hit, @@ -882,11 +992,86 @@ impl InstrumentsConfig { })), selectors, incremented: false, + _phantom: PhantomData, }), } }), } } + + pub(crate) fn new_pipeline_instruments(&self) -> HashMap { + let meter = meter_provider().meter("apollo/router"); + let mut instruments = HashMap::new(); + instruments.insert( + PIPELINE_METRIC.to_string(), + StaticInstrument::GaugeU64( + meter + .u64_observable_gauge(PIPELINE_METRIC) + .with_description("The number of request pipelines active in the router") + .with_callback(|i| { + for (pipeline, count) in &*pipeline_counts() { + let mut attributes = Vec::with_capacity(3); + attributes.push(KeyValue::new("schema.id", pipeline.schema_id.clone())); + if let Some(launch_id) = &pipeline.launch_id { + attributes.push(KeyValue::new("launch.id", launch_id.clone())); + } + attributes + .push(KeyValue::new("config.hash", pipeline.config_hash.clone())); + + i.observe(*count, &attributes); + } + }) + .init(), + ), + ); + instruments.insert( + OPEN_CONNECTIONS_METRIC.to_string(), + StaticInstrument::GaugeU64( + meter + .u64_observable_gauge(OPEN_CONNECTIONS_METRIC) + .with_description("Number of currently connected clients") + .with_callback(move |gauge| { + let connections = + crate::axum_factory::connection_handle::connection_counts(); + for (connection, count) in connections.iter() { + let mut attributes = Vec::with_capacity(6); + if let Some((ip, port)) = connection.address.ip_and_port() { + attributes.push(KeyValue::new("server.address", ip.to_string())); + attributes.push(KeyValue::new("server.port", port.to_string())); + } else { + // Unix socket + attributes.push(KeyValue::new( + "server.address", + connection.address.to_string(), + )); + } + attributes.push(KeyValue::new( + "schema.id", + connection.pipeline_ref.schema_id.clone(), + )); + if let Some(launch_id) = &connection.pipeline_ref.launch_id { + attributes.push(KeyValue::new("launch.id", launch_id.clone())); + } + attributes.push(KeyValue::new( + "config.hash", + connection.pipeline_ref.config_hash.clone(), + )); + // Technically we need to support `idle` state, but that will have to be a follow-up, + attributes.push(KeyValue::new( + "http.connection.state", + match connection.state { + ConnectionState::Active => "active", + ConnectionState::Terminating => "terminating", + }, + )); + gauge.observe(*count, &attributes); + } + }) + .init(), + ), + ); + instruments + } } #[derive(Debug)] @@ -894,6 +1079,9 @@ pub(crate) enum StaticInstrument { CounterF64(Counter), UpDownCounterI64(UpDownCounter), Histogram(Histogram), + // Gauges are never read + #[allow(dead_code)] + GaugeU64(ObservableGauge), } impl StaticInstrument { @@ -922,46 +1110,6 @@ impl StaticInstrument { } } -#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] -#[serde(deny_unknown_fields, default)] -pub(crate) struct RouterInstrumentsConfig { - /// Histogram of server request duration - #[serde(rename = "http.server.request.duration")] - http_server_request_duration: - DefaultedStandardInstrument>, - - /// Counter of active requests - #[serde(rename = "http.server.active_requests")] - http_server_active_requests: DefaultedStandardInstrument, - - /// Histogram of server request body size - #[serde(rename = "http.server.request.body.size")] - http_server_request_body_size: - DefaultedStandardInstrument>, - - /// Histogram of server response body size - #[serde(rename = "http.server.response.body.size")] - http_server_response_body_size: - DefaultedStandardInstrument>, -} - -impl DefaultForLevel for RouterInstrumentsConfig { - fn defaults_for_level( - &mut self, - requirement_level: DefaultAttributeRequirementLevel, - kind: TelemetryDataKind, - ) { - self.http_server_request_duration - .defaults_for_levels(requirement_level, kind); - self.http_server_active_requests - .defaults_for_levels(requirement_level, kind); - self.http_server_request_body_size - .defaults_for_levels(requirement_level, kind); - self.http_server_response_body_size - .defaults_for_levels(requirement_level, kind); - } -} - #[derive(Clone, Deserialize, JsonSchema, Debug, Default)] #[serde(deny_unknown_fields, default)] pub(crate) struct ActiveRequestsAttributes { @@ -1057,36 +1205,33 @@ where } } -impl Selectors for DefaultedStandardInstrument +impl Selectors + for DefaultedStandardInstrument where - T: Selectors, + T: Selectors, { - type Request = Request; - type Response = Response; - type EventResponse = EventResponse; - - fn on_request(&self, request: &Self::Request) -> Vec { + fn on_request(&self, request: &Request) -> Vec { match self { Self::Bool(_) | Self::Unset => Vec::with_capacity(0), Self::Extendable { attributes } => attributes.on_request(request), } } - fn on_response(&self, response: &Self::Response) -> Vec { + fn on_response(&self, response: &Response) -> Vec { match self { Self::Bool(_) | Self::Unset => Vec::with_capacity(0), Self::Extendable { attributes } => attributes.on_response(response), } } - fn on_error(&self, error: &BoxError, ctx: &Context) -> Vec { + fn on_error(&self, error: &BoxError, ctx: &Context) -> Vec { match self { Self::Bool(_) | Self::Unset => Vec::with_capacity(0), Self::Extendable { attributes } => attributes.on_error(error, ctx), } } - fn on_response_event(&self, response: &Self::EventResponse, ctx: &Context) -> Vec { + fn on_response_event(&self, response: &EventResponse, ctx: &Context) -> Vec { match self { Self::Bool(_) | Self::Unset => Vec::with_capacity(0), Self::Extendable { attributes } => attributes.on_response_event(response, ctx), @@ -1094,56 +1239,6 @@ where } } -#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] -#[serde(deny_unknown_fields, default)] -pub(crate) struct SupergraphInstrumentsConfig { - #[serde(flatten)] - pub(crate) cost: CostInstrumentsConfig, -} - -impl DefaultForLevel for SupergraphInstrumentsConfig { - fn defaults_for_level( - &mut self, - _requirement_level: DefaultAttributeRequirementLevel, - _kind: TelemetryDataKind, - ) { - } -} - -#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] -#[serde(deny_unknown_fields, default)] -pub(crate) struct SubgraphInstrumentsConfig { - /// Histogram of client request duration - #[serde(rename = "http.client.request.duration")] - http_client_request_duration: - DefaultedStandardInstrument>, - - /// Histogram of client request body size - #[serde(rename = "http.client.request.body.size")] - http_client_request_body_size: - DefaultedStandardInstrument>, - - /// Histogram of client response body size - #[serde(rename = "http.client.response.body.size")] - http_client_response_body_size: - DefaultedStandardInstrument>, -} - -impl DefaultForLevel for SubgraphInstrumentsConfig { - fn defaults_for_level( - &mut self, - requirement_level: DefaultAttributeRequirementLevel, - kind: TelemetryDataKind, - ) { - self.http_client_request_duration - .defaults_for_level(requirement_level, kind); - self.http_client_request_body_size - .defaults_for_level(requirement_level, kind); - self.http_client_response_body_size - .defaults_for_level(requirement_level, kind); - } -} - #[derive(Clone, Deserialize, JsonSchema, Debug)] #[serde(deny_unknown_fields)] pub(crate) struct Instrument @@ -1174,36 +1269,30 @@ where condition: Condition, } -impl Selectors - for Instrument +impl + Selectors for Instrument where - A: Debug - + Default - + Selectors, + A: Debug + Default + Selectors, E: Debug + Selector, for<'a> &'a SelectorValue: Into>, { - type Request = Request; - type Response = Response; - type EventResponse = EventResponse; - - fn on_request(&self, request: &Self::Request) -> Vec { + fn on_request(&self, request: &Request) -> Vec { self.attributes.on_request(request) } - fn on_response(&self, response: &Self::Response) -> Vec { + fn on_response(&self, response: &Response) -> Vec { self.attributes.on_response(response) } fn on_response_event( &self, - response: &Self::EventResponse, + response: &EventResponse, ctx: &Context, - ) -> Vec { + ) -> Vec { self.attributes.on_response_event(response, ctx) } - fn on_error(&self, error: &BoxError, ctx: &Context) -> Vec { + fn on_error(&self, error: &BoxError, ctx: &Context) -> Vec { self.attributes.on_error(error, ctx) } } @@ -1293,9 +1382,7 @@ impl Instrumented where A: Default + Instrumented, - B: Default - + Debug - + Selectors, + B: Default + Debug + Selectors, E: Debug + Selector, for<'a> InstrumentValue: From<&'a SelectorValue>, { @@ -1330,50 +1417,26 @@ where } } -impl Selectors for SubgraphInstrumentsConfig { - type Request = subgraph::Request; - type Response = subgraph::Response; - type EventResponse = (); - - fn on_request(&self, request: &Self::Request) -> Vec { - let mut attrs = self.http_client_request_body_size.on_request(request); - attrs.extend(self.http_client_request_duration.on_request(request)); - attrs.extend(self.http_client_response_body_size.on_request(request)); - - attrs - } - - fn on_response(&self, response: &Self::Response) -> Vec { - let mut attrs = self.http_client_request_body_size.on_response(response); - attrs.extend(self.http_client_request_duration.on_response(response)); - attrs.extend(self.http_client_response_body_size.on_response(response)); - - attrs - } - - fn on_error(&self, error: &BoxError, ctx: &Context) -> Vec { - let mut attrs = self.http_client_request_body_size.on_error(error, ctx); - attrs.extend(self.http_client_request_duration.on_error(error, ctx)); - attrs.extend(self.http_client_response_body_size.on_error(error, ctx)); - - attrs - } -} - -pub(crate) struct CustomInstruments -where - Attributes: Selectors + Default, +pub(crate) struct CustomInstruments< + Request, + Response, + EventResponse, + Attributes, + Select, + SelectorValue, +> where + Attributes: Selectors + Default, Select: Selector + Debug, { _phantom: PhantomData, - counters: Vec>, - histograms: Vec>, + counters: Vec>, + histograms: Vec>, } -impl - CustomInstruments +impl + CustomInstruments where - Attributes: Selectors + Default, + Attributes: Selectors + Default, Select: Selector + Debug, { pub(crate) fn is_empty(&self) -> bool { @@ -1381,10 +1444,10 @@ where } } -impl - CustomInstruments +impl + CustomInstruments where - Attributes: Selectors + Default + Debug + Clone, + Attributes: Selectors + Default + Debug + Clone, Select: Selector + Debug + Clone, for<'a> &'a SelectorValue: Into>, { @@ -1440,13 +1503,16 @@ where selector, selectors: Some(instrument.attributes.clone()), incremented: false, + _phantom: PhantomData, }; counters.push(CustomCounter { inner: Mutex::new(counter), }) } None => { - ::tracing::error!("cannot convert static instrument into a counter, this is an error; please fill an issue on GitHub"); + failfast_debug!( + "cannot convert static instrument into a counter, this is an error; please fill an issue on GitHub" + ); } } } @@ -1494,6 +1560,7 @@ where selector, selectors: Some(instrument.attributes.clone()), updated: false, + _phantom: PhantomData, }; histograms.push(CustomHistogram { @@ -1501,7 +1568,9 @@ where }); } None => { - ::tracing::error!("cannot convert static instrument into a histogram, this is an error; please fill an issue on GitHub"); + failfast_debug!( + "cannot convert static instrument into a histogram, this is an error; please fill an issue on GitHub" + ); } } } @@ -1517,10 +1586,9 @@ where } impl Instrumented - for CustomInstruments + for CustomInstruments where - Attributes: - Selectors + Default, + Attributes: Selectors + Default, Select: Selector + Debug, { type Request = Request; @@ -1579,74 +1647,6 @@ where } } -pub(crate) struct RouterInstruments { - http_server_request_duration: Option< - CustomHistogram, - >, - http_server_active_requests: Option, - http_server_request_body_size: Option< - CustomHistogram, - >, - http_server_response_body_size: Option< - CustomHistogram, - >, - custom: RouterCustomInstruments, -} - -impl Instrumented for RouterInstruments { - type Request = router::Request; - type Response = router::Response; - type EventResponse = (); - - fn on_request(&self, request: &Self::Request) { - if let Some(http_server_request_duration) = &self.http_server_request_duration { - http_server_request_duration.on_request(request); - } - if let Some(http_server_active_requests) = &self.http_server_active_requests { - http_server_active_requests.on_request(request); - } - if let Some(http_server_request_body_size) = &self.http_server_request_body_size { - http_server_request_body_size.on_request(request); - } - if let Some(http_server_response_body_size) = &self.http_server_response_body_size { - http_server_response_body_size.on_request(request); - } - self.custom.on_request(request); - } - - fn on_response(&self, response: &Self::Response) { - if let Some(http_server_request_duration) = &self.http_server_request_duration { - http_server_request_duration.on_response(response); - } - if let Some(http_server_active_requests) = &self.http_server_active_requests { - http_server_active_requests.on_response(response); - } - if let Some(http_server_request_body_size) = &self.http_server_request_body_size { - http_server_request_body_size.on_response(response); - } - if let Some(http_server_response_body_size) = &self.http_server_response_body_size { - http_server_response_body_size.on_response(response); - } - self.custom.on_response(response); - } - - fn on_error(&self, error: &BoxError, ctx: &Context) { - if let Some(http_server_request_duration) = &self.http_server_request_duration { - http_server_request_duration.on_error(error, ctx); - } - if let Some(http_server_active_requests) = &self.http_server_active_requests { - http_server_active_requests.on_error(error, ctx); - } - if let Some(http_server_request_body_size) = &self.http_server_request_body_size { - http_server_request_body_size.on_error(error, ctx); - } - if let Some(http_server_response_body_size) = &self.http_server_response_body_size { - http_server_response_body_size.on_error(error, ctx); - } - self.custom.on_error(error, ctx); - } -} - pub(crate) struct SupergraphInstruments { cost: CostInstruments, custom: SupergraphCustomInstruments, @@ -1678,103 +1678,6 @@ impl Instrumented for SupergraphInstruments { } } -pub(crate) struct SubgraphInstruments { - http_client_request_duration: Option< - CustomHistogram< - subgraph::Request, - subgraph::Response, - SubgraphAttributes, - SubgraphSelector, - >, - >, - http_client_request_body_size: Option< - CustomHistogram< - subgraph::Request, - subgraph::Response, - SubgraphAttributes, - SubgraphSelector, - >, - >, - http_client_response_body_size: Option< - CustomHistogram< - subgraph::Request, - subgraph::Response, - SubgraphAttributes, - SubgraphSelector, - >, - >, - custom: SubgraphCustomInstruments, -} - -impl Instrumented for SubgraphInstruments { - type Request = subgraph::Request; - type Response = subgraph::Response; - type EventResponse = (); - - fn on_request(&self, request: &Self::Request) { - if let Some(http_client_request_duration) = &self.http_client_request_duration { - http_client_request_duration.on_request(request); - } - if let Some(http_client_request_body_size) = &self.http_client_request_body_size { - http_client_request_body_size.on_request(request); - } - if let Some(http_client_response_body_size) = &self.http_client_response_body_size { - http_client_response_body_size.on_request(request); - } - self.custom.on_request(request); - } - - fn on_response(&self, response: &Self::Response) { - if let Some(http_client_request_duration) = &self.http_client_request_duration { - http_client_request_duration.on_response(response); - } - if let Some(http_client_request_body_size) = &self.http_client_request_body_size { - http_client_request_body_size.on_response(response); - } - if let Some(http_client_response_body_size) = &self.http_client_response_body_size { - http_client_response_body_size.on_response(response); - } - self.custom.on_response(response); - } - - fn on_error(&self, error: &BoxError, ctx: &Context) { - if let Some(http_client_request_duration) = &self.http_client_request_duration { - http_client_request_duration.on_error(error, ctx); - } - if let Some(http_client_request_body_size) = &self.http_client_request_body_size { - http_client_request_body_size.on_error(error, ctx); - } - if let Some(http_client_response_body_size) = &self.http_client_response_body_size { - http_client_response_body_size.on_error(error, ctx); - } - self.custom.on_error(error, ctx); - } -} - -pub(crate) type RouterCustomInstruments = CustomInstruments< - router::Request, - router::Response, - RouterAttributes, - RouterSelector, - RouterValue, ->; - -pub(crate) type SupergraphCustomInstruments = CustomInstruments< - supergraph::Request, - supergraph::Response, - SupergraphAttributes, - SupergraphSelector, - SupergraphValue, ->; - -pub(crate) type SubgraphCustomInstruments = CustomInstruments< - subgraph::Request, - subgraph::Response, - SubgraphAttributes, - SubgraphSelector, - SubgraphValue, ->; - // ---------------- Counter ----------------------- #[derive(Debug, Clone)] pub(crate) enum Increment { @@ -1798,17 +1701,18 @@ fn to_i64(value: opentelemetry::Value) -> Option { } } -pub(crate) struct CustomCounter +pub(crate) struct CustomCounter where - A: Selectors + Default, + A: Selectors + Default, T: Selector + Debug, { - pub(crate) inner: Mutex>, + pub(crate) inner: Mutex>, } -impl Clone for CustomCounter +impl Clone + for CustomCounter where - A: Selectors + Default, + A: Selectors + Default, T: Selector + Debug + Clone, { fn clone(&self) -> Self { @@ -1818,9 +1722,9 @@ where } } -pub(crate) struct CustomCounterInner +pub(crate) struct CustomCounterInner where - A: Selectors + Default, + A: Selectors + Default, T: Selector + Debug, { pub(crate) increment: Increment, @@ -1828,14 +1732,16 @@ where pub(crate) selectors: Option>>, pub(crate) counter: Option>, pub(crate) condition: Condition, - pub(crate) attributes: Vec, + pub(crate) attributes: Vec, // Useful when it's a counter on events to know if we have to count for an event or not pub(crate) incremented: bool, + pub(crate) _phantom: PhantomData, } -impl Clone for CustomCounterInner +impl Clone + for CustomCounterInner where - A: Selectors + Default, + A: Selectors + Default, T: Selector + Debug + Clone, { fn clone(&self) -> Self { @@ -1847,13 +1753,15 @@ where condition: self.condition.clone(), attributes: self.attributes.clone(), incremented: self.incremented, + _phantom: PhantomData, } } } -impl Instrumented for CustomCounter +impl Instrumented + for CustomCounter where - A: Selectors + Default, + A: Selectors + Default, T: Selector + Debug + Debug, @@ -1877,7 +1785,9 @@ where Increment::EventCustom(None) => Increment::EventCustom(to_i64(selected_value)), Increment::Custom(None) => Increment::Custom(to_i64(selected_value)), other => { - failfast_error!("this is a bug and should not happen, the increment should only be Custom or EventCustom, please open an issue: {other:?}"); + failfast_error!( + "this is a bug and should not happen, the increment should only be Custom or EventCustom, please open an issue: {other:?}" + ); return; } }; @@ -1917,7 +1827,9 @@ where Increment::EventCustom(None) => Increment::Custom(to_i64(selected_value)), Increment::Custom(None) => Increment::Custom(to_i64(selected_value)), other => { - failfast_error!("this is a bug and should not happen, the increment should only be Custom or EventCustom, please open an issue: {other:?}"); + failfast_error!( + "this is a bug and should not happen, the increment should only be Custom or EventCustom, please open an issue: {other:?}" + ); return; } }; @@ -1974,7 +1886,9 @@ where Increment::EventCustom(None) => Increment::EventCustom(to_i64(selected_value)), Increment::Custom(None) => Increment::EventCustom(to_i64(selected_value)), other => { - failfast_error!("this is a bug and should not happen, the increment should only be Custom or EventCustom, please open an issue: {other:?}"); + failfast_error!( + "this is a bug and should not happen, the increment should only be Custom or EventCustom, please open an issue: {other:?}" + ); return; } }; @@ -2062,7 +1976,9 @@ where Increment::FieldCustom(None) => Increment::FieldCustom(to_i64(selected_value)), Increment::Custom(None) => Increment::FieldCustom(to_i64(selected_value)), other => { - failfast_error!("this is a bug and should not happen, the increment should only be Custom or FieldCustom, please open an issue: {other:?}"); + failfast_error!( + "this is a bug and should not happen, the increment should only be Custom or FieldCustom, please open an issue: {other:?}" + ); return; } }; @@ -2109,9 +2025,10 @@ where } } -impl Drop for CustomCounter +impl Drop + for CustomCounter where - A: Selectors + Default, + A: Selectors + Default, T: Selector + Debug, { fn drop(&mut self) { @@ -2146,14 +2063,14 @@ where } } -struct ActiveRequestsCounter { +pub(crate) struct ActiveRequestsCounter { inner: Mutex, } struct ActiveRequestsCounterInner { counter: Option>, attrs_config: Arc, - attributes: Vec, + attributes: Vec, } impl Instrumented for ActiveRequestsCounter { @@ -2163,39 +2080,36 @@ impl Instrumented for ActiveRequestsCounter { fn on_request(&self, request: &Self::Request) { let mut inner = self.inner.lock(); - if inner.attrs_config.http_request_method { - if let Some(attr) = (RouterSelector::RequestMethod { + if inner.attrs_config.http_request_method + && let Some(attr) = (RouterSelector::RequestMethod { request_method: true, }) .on_request(request) - { - inner - .attributes - .push(KeyValue::new(HTTP_REQUEST_METHOD, attr)); - } + { + inner + .attributes + .push(KeyValue::new(HTTP_REQUEST_METHOD, attr)); } - if inner.attrs_config.server_address { - if let Some(attr) = HttpServerAttributes::forwarded_host(request) + if inner.attrs_config.server_address + && let Some(attr) = HttpServerAttributes::forwarded_host(request) .and_then(|h| h.host().map(|h| h.to_string())) - { - inner.attributes.push(KeyValue::new(SERVER_ADDRESS, attr)); - } + { + inner.attributes.push(KeyValue::new(SERVER_ADDRESS, attr)); } - if inner.attrs_config.server_port { - if let Some(attr) = + if inner.attrs_config.server_port + && let Some(attr) = HttpServerAttributes::forwarded_host(request).and_then(|h| h.port_u16()) - { - inner - .attributes - .push(KeyValue::new(SERVER_PORT, attr as i64)); - } + { + inner + .attributes + .push(KeyValue::new(SERVER_PORT, attr as i64)); } - if inner.attrs_config.url_scheme { - if let Some(attr) = request.router_request.uri().scheme_str() { - inner - .attributes - .push(KeyValue::new(URL_SCHEME, attr.to_string())); - } + if inner.attrs_config.url_scheme + && let Some(attr) = request.router_request.uri().scheme_str() + { + inner + .attributes + .push(KeyValue::new(URL_SCHEME, attr.to_string())); } if let Some(counter) = &inner.counter { counter.add(1, &inner.attributes); @@ -2220,27 +2134,27 @@ impl Instrumented for ActiveRequestsCounter { impl Drop for ActiveRequestsCounter { fn drop(&mut self) { let inner = self.inner.try_lock(); - if let Some(mut inner) = inner { - if let Some(counter) = &inner.counter.take() { - counter.add(-1, &inner.attributes); - } + if let Some(mut inner) = inner + && let Some(counter) = &inner.counter.take() + { + counter.add(-1, &inner.attributes); } } } // ---------------- Histogram ----------------------- -pub(crate) struct CustomHistogram +pub(crate) struct CustomHistogram where - A: Selectors + Default, + A: Selectors + Default, T: Selector, { - pub(crate) inner: Mutex>, + pub(crate) inner: Mutex>, } -pub(crate) struct CustomHistogramInner +pub(crate) struct CustomHistogramInner where - A: Selectors + Default, + A: Selectors + Default, T: Selector, { pub(crate) increment: Increment, @@ -2248,15 +2162,47 @@ where pub(crate) selector: Option>, pub(crate) selectors: Option>>, pub(crate) histogram: Option>, - pub(crate) attributes: Vec, + pub(crate) attributes: Vec, // Useful when it's an histogram on events to know if we have to count for an event or not pub(crate) updated: bool, + pub(crate) _phantom: PhantomData, +} + +#[buildstructor::buildstructor] +impl + CustomHistogram +where + A: Selectors + Default, + T: Selector, +{ + #[builder(visibility = "pub")] + fn new( + increment: Increment, + condition: Option>, + selector: Option>, + selectors: Option>>, + histogram: Option>, + attributes: Vec, + ) -> Self { + Self { + inner: Mutex::new(CustomHistogramInner { + increment, + condition: condition.unwrap_or(Condition::True), + attributes, + selector, + selectors, + histogram, + updated: false, + _phantom: PhantomData, + }), + } + } } impl Instrumented - for CustomHistogram + for CustomHistogram where - A: Selectors + Default, + A: Selectors + Default, T: Selector, { type Request = Request; @@ -2278,7 +2224,9 @@ where Increment::FieldCustom(None) => Increment::FieldCustom(to_i64(selected_value)), Increment::Custom(None) => Increment::Custom(to_i64(selected_value)), other => { - failfast_error!("this is a bug and should not happen, the increment should only be Custom or EventCustom, please open an issue: {other:?}"); + failfast_error!( + "this is a bug and should not happen, the increment should only be Custom or EventCustom, please open an issue: {other:?}" + ); return; } }; @@ -2317,7 +2265,9 @@ where Increment::FieldCustom(None) => Increment::FieldCustom(to_i64(selected_value)), Increment::Custom(None) => Increment::Custom(to_i64(selected_value)), other => { - failfast_error!("this is a bug and should not happen, the increment should only be Custom or EventCustom, please open an issue: {other:?}"); + failfast_error!( + "this is a bug and should not happen, the increment should only be Custom or EventCustom, please open an issue: {other:?}" + ); return; } }; @@ -2370,7 +2320,9 @@ where Increment::EventCustom(None) => Increment::EventCustom(to_i64(selected_value)), Increment::Custom(None) => Increment::EventCustom(to_i64(selected_value)), other => { - failfast_error!("this is a bug and should not happen, the increment should only be Custom or EventCustom, please open an issue: {other:?}"); + failfast_error!( + "this is a bug and should not happen, the increment should only be Custom or EventCustom, please open an issue: {other:?}" + ); return; } }; @@ -2454,7 +2406,9 @@ where Increment::FieldCustom(None) => Increment::FieldCustom(to_i64(selected_value)), Increment::Custom(None) => Increment::FieldCustom(to_i64(selected_value)), other => { - failfast_error!("this is a bug and should not happen, the increment should only be Custom or FieldCustom, please open an issue: {other:?}"); + failfast_error!( + "this is a bug and should not happen, the increment should only be Custom or FieldCustom, please open an issue: {other:?}" + ); return; } }; @@ -2501,9 +2455,10 @@ where } } -impl Drop for CustomHistogram +impl Drop + for CustomHistogram where - A: Selectors + Default, + A: Selectors + Default, T: Selector, { fn drop(&mut self) { @@ -2546,9 +2501,25 @@ mod tests { use std::path::PathBuf; use std::str::FromStr; + use apollo_compiler::Name; use apollo_compiler::ast::NamedType; use apollo_compiler::executable::SelectionSet; - use apollo_compiler::Name; + use apollo_compiler::name; + use apollo_federation::connectors::ConnectId; + use apollo_federation::connectors::ConnectSpec; + use apollo_federation::connectors::Connector; + use apollo_federation::connectors::HTTPMethod; + use apollo_federation::connectors::HttpJsonTransport; + use apollo_federation::connectors::JSONSelection; + use apollo_federation::connectors::SourceName; + use apollo_federation::connectors::StringTemplate; + use apollo_federation::connectors::runtime::http_json_transport::HttpRequest; + use apollo_federation::connectors::runtime::http_json_transport::HttpResponse; + use apollo_federation::connectors::runtime::http_json_transport::TransportRequest; + use apollo_federation::connectors::runtime::http_json_transport::TransportResponse; + use apollo_federation::connectors::runtime::key::ResponseKey; + use apollo_federation::connectors::runtime::mapping::Problem; + use apollo_federation::connectors::runtime::responses::MappedResponse; use http::HeaderMap; use http::HeaderName; use http::Method; @@ -2556,13 +2527,14 @@ mod tests { use http::Uri; use multimap::MultiMap; use rust_embed::RustEmbed; - use schemars::gen::SchemaGenerator; + use schemars::r#gen::SchemaGenerator; use serde::Deserialize; use serde_json::json; use serde_json_bytes::ByteString; use serde_json_bytes::Value; use super::*; + use crate::Context; use crate::context::CONTAINS_GRAPHQL_ERROR; use crate::context::OPERATION_KIND; use crate::error::Error; @@ -2571,19 +2543,21 @@ mod tests { use crate::http_ext::TryIntoHeaderValue; use crate::json_ext::Path; use crate::metrics::FutureMetricsExt; - use crate::plugins::telemetry::config_new::cache::CacheInstruments; - use crate::plugins::telemetry::config_new::graphql::GraphQLInstruments; - use crate::plugins::telemetry::config_new::instruments::Instrumented; - use crate::plugins::telemetry::config_new::instruments::InstrumentsConfig; use crate::plugins::telemetry::APOLLO_PRIVATE_QUERY_ALIASES; use crate::plugins::telemetry::APOLLO_PRIVATE_QUERY_DEPTH; use crate::plugins::telemetry::APOLLO_PRIVATE_QUERY_HEIGHT; use crate::plugins::telemetry::APOLLO_PRIVATE_QUERY_ROOT_FIELDS; + use crate::plugins::telemetry::config_new::cache::CacheInstruments; + use crate::plugins::telemetry::config_new::graphql::GraphQLInstruments; + use crate::plugins::telemetry::config_new::instruments::Instrumented; + use crate::plugins::telemetry::config_new::instruments::InstrumentsConfig; + use crate::plugins::telemetry::config_new::supergraph::instruments::SupergraphCustomInstruments; use crate::services::OperationKind; use crate::services::RouterRequest; use crate::services::RouterResponse; + use crate::services::connector::request_service::Request; + use crate::services::connector::request_service::Response; use crate::spec::operation_limits::OperationLimits; - use crate::Context; type JsonMap = serde_json_bytes::Map; @@ -2688,6 +2662,28 @@ mod tests { ResponseField { typed_value: TypedValueMirror, }, + ConnectorRequest { + subgraph_name: String, + source_name: String, + http_method: String, + url_template: String, + uri: String, + #[serde(default)] + headers: HashMap, + body: Option, + #[serde(default)] + #[schemars(with = "Option")] + mapping_problems: Vec, + }, + ConnectorResponse { + status: u16, + #[serde(default)] + headers: HashMap, + body: String, + #[serde(default)] + #[schemars(with = "Option")] + mapping_problems: Vec, + }, } #[derive(Deserialize, JsonSchema)] @@ -2838,6 +2834,7 @@ mod tests { events: Vec>, } + const DEFAULT_CONNECT_SPEC: ConnectSpec = ConnectSpec::V0_2; #[tokio::test] async fn test_instruments() { // This test is data driven. @@ -2848,7 +2845,7 @@ mod tests { // There's no async in this test, but introducing an async block allows us to separate metrics for each fixture. async move { if fixture.ends_with("test.yaml") { - println!("Running test for fixture: {}", fixture); + println!("Running test for fixture: {fixture}"); let path = PathBuf::from_str(&fixture).unwrap(); let fixture_name = path .parent() @@ -2866,12 +2863,16 @@ mod tests { let mut config = load_config(&router_config_file.data); config.update_defaults(); + let apollo_config = load_apollo_config(&router_config_file.data); for request in test_definition.events { // each array of actions is a separate request let mut router_instruments = None; let mut supergraph_instruments = None; let mut subgraph_instruments = None; + let mut connector_instruments = None; + let mut apollo_subgraph_instruments = None; + let mut apollo_connector_instruments = None; let mut cache_instruments: Option = None; let graphql_instruments: GraphQLInstruments = config .new_graphql_instruments(Arc::new( @@ -2891,7 +2892,7 @@ mod tests { .method(Method::from_str(&method).expect("method")) .uri(Uri::from_str(&uri).expect("uri")) .headers(convert_headers(headers)) - .body(body) + .body(router::body::from_bytes(body)) .build() .unwrap(); router_instruments = Some(config.new_router_instruments( @@ -2989,6 +2990,10 @@ mod tests { subgraph_instruments = Some(config.new_subgraph_instruments( Arc::new(config.new_builtin_subgraph_instruments()), )); + apollo_subgraph_instruments = Some(config.new_apollo_subgraph_instruments( + Arc::new(config.new_builtin_apollo_subgraph_instruments()), + apollo_config.clone() + )); cache_instruments = Some(config.new_cache_instruments( Arc::new(config.new_builtin_cache_instruments()), )); @@ -3001,7 +3006,7 @@ mod tests { let mut http_request = http::Request::new(graphql_request); *http_request.headers_mut() = convert_http_headers(headers); - let request = subgraph::Request::fake_builder() + let request = crate::plugins::telemetry::subgraph::Request::fake_builder() .context(context.clone()) .subgraph_name(subgraph_name) .and_operation_kind(operation_kind) @@ -3009,6 +3014,7 @@ mod tests { .build(); subgraph_instruments.as_mut().unwrap().on_request(&request); + apollo_subgraph_instruments.as_mut().unwrap().on_request(&request); cache_instruments.as_mut().unwrap().on_request(&request); } Event::SubgraphResponse { @@ -3019,7 +3025,7 @@ mod tests { errors, headers, } => { - let response = subgraph::Response::fake2_builder() + let response = crate::plugins::telemetry::subgraph::Response::fake2_builder() .context(context.clone()) .and_subgraph_name(subgraph_name) .status_code(StatusCode::from_u16(status).expect("status")) @@ -3033,6 +3039,10 @@ mod tests { .take() .expect("subgraph request must have been made first") .on_response(&response); + apollo_subgraph_instruments + .take() + .expect("subgraph request must have been made first") + .on_response(&response); cache_instruments .take() .expect("subgraph request must have been made first") @@ -3079,7 +3089,7 @@ mod tests { Event::Extension { map } => { for (key, value) in map { if key == APOLLO_PRIVATE_QUERY_ALIASES.to_string() { - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { let limits = lock .get_or_default_mut::>(); let value_as_u32 = value.as_u64().unwrap() as u32; @@ -3087,7 +3097,7 @@ mod tests { }); } if key == APOLLO_PRIVATE_QUERY_DEPTH.to_string() { - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { let limits = lock .get_or_default_mut::>(); let value_as_u32 = value.as_u64().unwrap() as u32; @@ -3095,7 +3105,7 @@ mod tests { }); } if key == APOLLO_PRIVATE_QUERY_HEIGHT.to_string() { - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { let limits = lock .get_or_default_mut::>(); let value_as_u32 = value.as_u64().unwrap() as u32; @@ -3103,7 +3113,7 @@ mod tests { }); } if key == APOLLO_PRIVATE_QUERY_ROOT_FIELDS.to_string() { - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { let limits = lock .get_or_default_mut::>(); let value_as_u32 = value.as_u64().unwrap() as u32; @@ -3112,6 +3122,134 @@ mod tests { } } } + Event::ConnectorRequest { + subgraph_name, + source_name, + http_method, + url_template, + uri, + headers, + body, + mapping_problems, + } => { + let mut http_request = http::Request::builder() + .method(Method::from_str(&http_method).expect("method")) + .uri(Uri::from_str(&uri).expect("uri")) + .body(body.unwrap_or("".into())) + .unwrap(); + *http_request.headers_mut() = convert_http_headers(headers); + let transport_request = + TransportRequest::Http(HttpRequest { + inner: http_request, + debug: Default::default(), + }); + let connector = Connector { + id: ConnectId::new( + subgraph_name, + Some(SourceName::cast(&source_name)), + name!(Query), + name!(field), + None, + 0, + ), + transport: HttpJsonTransport { + connect_template: StringTemplate::parse_with_spec( + url_template.as_str(), + DEFAULT_CONNECT_SPEC, + ) + .unwrap(), + method: HTTPMethod::from_str(http_method.as_str()) + .unwrap(), + ..Default::default() + }, + selection: JSONSelection::empty(), + config: None, + max_requests: None, + entity_resolver: None, + spec: DEFAULT_CONNECT_SPEC, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "label".into(), + }; + let response_key = ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new( + JSONSelection::parse_with_spec("$.data", DEFAULT_CONNECT_SPEC).unwrap(), + ), + }; + let request = Request { + context: context.clone(), + connector: Arc::new(connector), + transport_request, + key: response_key.clone(), + mapping_problems, + supergraph_request: Default::default(), + }; + connector_instruments = Some({ + let connector_instruments = config + .new_connector_instruments(Arc::new( + config.new_builtin_connector_instruments(), + )); + connector_instruments.on_request(&request); + connector_instruments + }); + apollo_connector_instruments = Some({ + let apollo_connector_instruments = config + .new_apollo_connector_instruments( + Arc::new(config.new_builtin_apollo_connector_instruments()), + apollo_config.clone(), + ); + apollo_connector_instruments.on_request(&request); + apollo_connector_instruments + }) + } + Event::ConnectorResponse { + status, + headers, + body, + mapping_problems, + .. + } => { + let response_key = ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new( + JSONSelection::parse_with_spec("$.data", DEFAULT_CONNECT_SPEC).unwrap(), + ), + }; + let mut http_response = http::Response::builder() + .status(StatusCode::from_u16(status).expect("status")) + .body(router::body::from_bytes(body)) + .unwrap(); + *http_response.headers_mut() = convert_http_headers(headers); + let response = Response { + transport_result: Ok(TransportResponse::Http( + HttpResponse { + inner: http_response.into_parts().0, + }, + )), + mapped_response: MappedResponse::Data { + data: json!({}) + .try_into() + .expect("expecting valid JSON"), + key: response_key, + problems: mapping_problems, + }, + }; + connector_instruments + .take() + .expect("connector request must have been made first") + .on_response(&response); + apollo_connector_instruments + .take() + .expect("connector request must have been made first") + .on_response(&response); + } } } } @@ -3181,6 +3319,12 @@ mod tests { serde_json::from_value(instruments.clone()).unwrap() } + fn load_apollo_config(config: &[u8]) -> Config { + let val: serde_json::Value = serde_yaml::from_slice(config).unwrap(); + let apollo_config = &val["telemetry"]["apollo"]; + serde_json::from_value(apollo_config.clone()).unwrap_or_default() + } + #[test] fn write_schema() { // Write a json schema for the above test @@ -3566,10 +3710,12 @@ mod tests { .header("content-type", "application/json") .header("x-my-header", "TEST") .header("content-length", "35") - .errors(vec![graphql::Error::builder() - .message("nope") - .extension_code("NOPE") - .build()]) + .errors(vec![ + graphql::Error::builder() + .message("nope") + .extension_code("NOPE") + .build(), + ]) .build() .unwrap(); custom_instruments.on_response(&supergraph_response); @@ -3578,10 +3724,12 @@ mod tests { .data(json!({ "price": 500 })) - .errors(vec![graphql::Error::builder() - .message("nope") - .extension_code("NOPE") - .build()]) + .errors(vec![ + graphql::Error::builder() + .message("nope") + .extension_code("NOPE") + .build(), + ]) .build(), &context_with_error, ); @@ -3623,10 +3771,12 @@ mod tests { .status_code(StatusCode::BAD_REQUEST) .header("content-type", "application/json") .header("content-length", "35") - .errors(vec![graphql::Error::builder() - .message("nope") - .extension_code("NOPE") - .build()]) + .errors(vec![ + graphql::Error::builder() + .message("nope") + .extension_code("NOPE") + .build(), + ]) .build() .unwrap(); custom_instruments.on_response(&supergraph_response); @@ -3635,10 +3785,12 @@ mod tests { .data(json!({ "price": 500 })) - .errors(vec![graphql::Error::builder() - .message("nope") - .extension_code("NOPE") - .build()]) + .errors(vec![ + graphql::Error::builder() + .message("nope") + .extension_code("NOPE") + .build(), + ]) .build(), &context_with_error, ); diff --git a/apollo-router/src/plugins/telemetry/config_new/logging.rs b/apollo-router/src/plugins/telemetry/config_new/logging.rs index db2dda6588..4aead7cae3 100644 --- a/apollo-router/src/plugins/telemetry/config_new/logging.rs +++ b/apollo-router/src/plugins/telemetry/config_new/logging.rs @@ -3,7 +3,8 @@ use std::collections::HashSet; use std::io::IsTerminal; use std::time::Duration; -use schemars::gen::SchemaGenerator; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; use schemars::schema::InstanceType; use schemars::schema::Metadata; use schemars::schema::ObjectValidation; @@ -11,18 +12,14 @@ use schemars::schema::Schema; use schemars::schema::SchemaObject; use schemars::schema::SingleOrVec; use schemars::schema::SubschemaValidation; -use schemars::JsonSchema; -use serde::de::MapAccess; -use serde::de::Visitor; use serde::Deserialize; use serde::Deserializer; +use serde::de::MapAccess; +use serde::de::Visitor; -use crate::configuration::ConfigurationError; use crate::plugins::telemetry::config::AttributeValue; use crate::plugins::telemetry::config::TraceIdFormat; -use crate::plugins::telemetry::config_new::experimental_when_header::HeaderLoggingCondition; use crate::plugins::telemetry::resource::ConfigResource; -use crate::services::SupergraphRequest; /// Logging configuration. #[derive(Deserialize, JsonSchema, Clone, Default, Debug)] @@ -35,44 +32,6 @@ pub(crate) struct Logging { #[serde(skip)] /// Settings for logging to a file. pub(crate) file: File, - - /// Log configuration to log request and response for subgraphs and supergraph - /// Note that this will be removed when events are implemented. - #[serde(rename = "experimental_when_header")] - pub(crate) when_header: Vec, -} - -impl Logging { - pub(crate) fn validate(&self) -> Result<(), ConfigurationError> { - let misconfiguration = self.when_header.iter().any(|cfg| match cfg { - HeaderLoggingCondition::Matching { headers, body, .. } - | HeaderLoggingCondition::Value { headers, body, .. } => !body && !headers, - }); - - if misconfiguration { - Err(ConfigurationError::InvalidConfiguration { - message: "'experimental_when_header' configuration for logging is invalid", - error: String::from( - "body and headers must not be both false because it doesn't enable any logs", - ), - }) - } else { - Ok(()) - } - } - - /// Returns if we should display the request/response headers and body given the `SupergraphRequest` - pub(crate) fn should_log(&self, req: &SupergraphRequest) -> (bool, bool) { - self.when_header - .iter() - .fold((false, false), |(log_headers, log_body), current| { - let (current_log_headers, current_log_body) = current.should_log(req); - ( - log_headers || current_log_headers, - log_body || current_log_body, - ) - }) - } } #[derive(Clone, Debug, Deserialize, JsonSchema, Default)] @@ -194,11 +153,19 @@ impl JsonSchema for Format { "logging_format".to_string() } - fn json_schema(gen: &mut SchemaGenerator) -> Schema { + fn json_schema(generator: &mut SchemaGenerator) -> Schema { // Does nothing, but will compile error if the let types = vec![ - ("json", JsonFormat::json_schema(gen), "Tracing subscriber https://docs.rs/tracing-subscriber/latest/tracing_subscriber/fmt/format/struct.Json.html"), - ("text", TextFormat::json_schema(gen), "Tracing subscriber https://docs.rs/tracing-subscriber/latest/tracing_subscriber/fmt/format/struct.Full.html"), + ( + "json", + JsonFormat::json_schema(generator), + "Tracing subscriber https://docs.rs/tracing-subscriber/latest/tracing_subscriber/fmt/format/struct.Json.html", + ), + ( + "text", + TextFormat::json_schema(generator), + "Tracing subscriber https://docs.rs/tracing-subscriber/latest/tracing_subscriber/fmt/format/struct.Full.html", + ), ]; Schema::Object(SchemaObject { @@ -277,7 +244,7 @@ impl<'de> Deserialize<'de> for Format { match value { "json" => Ok(Format::Json(JsonFormat::default())), "text" => Ok(Format::Text(TextFormat::default())), - _ => Err(E::custom(format!("unknown log format: {}", value))), + _ => Err(E::custom(format!("unknown log format: {value}"))), } } @@ -291,8 +258,7 @@ impl<'de> Deserialize<'de> for Format { Some("json") => Ok(Format::Json(map.next_value::()?)), Some("text") => Ok(Format::Text(map.next_value::()?)), Some(value) => Err(serde::de::Error::custom(format!( - "unknown log format: {}", - value + "unknown log format: {value}" ))), _ => Err(serde::de::Error::custom("unknown log format")), } @@ -470,13 +436,10 @@ pub(crate) enum Rollover { #[cfg(test)] mod test { - use regex::Regex; use serde_json::json; - use crate::plugins::telemetry::config_new::experimental_when_header::HeaderLoggingCondition; use crate::plugins::telemetry::config_new::logging::Format; - use crate::plugins::telemetry::config_new::logging::Logging; - use crate::services::SupergraphRequest; + #[test] fn format_de() { let format = serde_json::from_value::(json!("text")).unwrap(); @@ -488,92 +451,4 @@ mod test { let format = serde_json::from_value::(json!({"json":{}})).unwrap(); assert_eq!(format, Format::Json(Default::default())); } - - #[test] - fn test_logging_conf_validation() { - let logging_conf = Logging { - when_header: vec![HeaderLoggingCondition::Value { - name: "test".to_string(), - value: String::new(), - headers: true, - body: false, - }], - ..Default::default() - }; - - logging_conf.validate().unwrap(); - - let logging_conf = Logging { - when_header: vec![HeaderLoggingCondition::Value { - name: "test".to_string(), - value: String::new(), - headers: false, - body: false, - }], - ..Default::default() - }; - - let validate_res = logging_conf.validate(); - assert!(validate_res.is_err()); - assert_eq!(validate_res.unwrap_err().to_string(), "'experimental_when_header' configuration for logging is invalid: body and headers must not be both false because it doesn't enable any logs"); - } - - #[test] - fn test_logging_conf_should_log() { - let logging_conf = Logging { - when_header: vec![HeaderLoggingCondition::Matching { - name: "test".to_string(), - matching: Regex::new("^foo*").unwrap(), - headers: true, - body: false, - }], - ..Default::default() - }; - let req = SupergraphRequest::fake_builder() - .header("test", "foobar") - .build() - .unwrap(); - assert_eq!(logging_conf.should_log(&req), (true, false)); - - let logging_conf = Logging { - when_header: vec![HeaderLoggingCondition::Value { - name: "test".to_string(), - value: String::from("foobar"), - headers: true, - body: false, - }], - ..Default::default() - }; - assert_eq!(logging_conf.should_log(&req), (true, false)); - - let logging_conf = Logging { - when_header: vec![ - HeaderLoggingCondition::Matching { - name: "test".to_string(), - matching: Regex::new("^foo*").unwrap(), - headers: true, - body: false, - }, - HeaderLoggingCondition::Matching { - name: "test".to_string(), - matching: Regex::new("^*bar$").unwrap(), - headers: false, - body: true, - }, - ], - ..Default::default() - }; - assert_eq!(logging_conf.should_log(&req), (true, true)); - - let logging_conf = Logging { - when_header: vec![HeaderLoggingCondition::Matching { - name: "testtest".to_string(), - matching: Regex::new("^foo*").unwrap(), - headers: true, - body: false, - }], - ..Default::default() - }; - assert_eq!(logging_conf.should_log(&req), (false, false)); - } } diff --git a/apollo-router/src/plugins/telemetry/config_new/mod.rs b/apollo-router/src/plugins/telemetry/config_new/mod.rs index 082d0a438e..bb9fc5dbf0 100644 --- a/apollo-router/src/plugins/telemetry/config_new/mod.rs +++ b/apollo-router/src/plugins/telemetry/config_new/mod.rs @@ -1,43 +1,45 @@ use events::EventOn; +use opentelemetry::KeyValue; +use opentelemetry::Value; use opentelemetry::baggage::BaggageExt; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceId; -use opentelemetry::KeyValue; -use opentelemetry_api::Value; use paste::paste; use tower::BoxError; use tracing::Span; use super::otel::OpenTelemetrySpanExt; use super::otlp::TelemetryDataKind; +use crate::Context; use crate::plugins::telemetry::config::AttributeValue; use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; -use crate::Context; /// These modules contain a new config structure for telemetry that will progressively move to pub(crate) mod attributes; pub(crate) mod conditions; +pub(crate) mod apollo; pub(crate) mod cache; mod conditional; +pub(crate) mod connector; pub(crate) mod cost; pub(crate) mod events; -mod experimental_when_header; pub(crate) mod extendable; pub(crate) mod graphql; +pub(crate) mod http_common; +pub(crate) mod http_server; pub(crate) mod instruments; pub(crate) mod logging; +pub(crate) mod router; pub(crate) mod selectors; pub(crate) mod spans; +pub(crate) mod subgraph; +pub(crate) mod supergraph; -pub(crate) trait Selectors { - type Request; - type Response; - type EventResponse; - - fn on_request(&self, request: &Self::Request) -> Vec; - fn on_response(&self, response: &Self::Response) -> Vec; - fn on_response_event(&self, _response: &Self::EventResponse, _ctx: &Context) -> Vec { +pub(crate) trait Selectors { + fn on_request(&self, request: &Request) -> Vec; + fn on_response(&self, response: &Response) -> Vec; + fn on_response_event(&self, _response: &EventResponse, _ctx: &Context) -> Vec { Vec::with_capacity(0) } fn on_error(&self, error: &BoxError, ctx: &Context) -> Vec; @@ -149,8 +151,9 @@ pub(crate) trait DatadogId { } impl DatadogId for TraceId { fn to_datadog(&self) -> String { - let bytes = &self.to_bytes()[std::mem::size_of::()..std::mem::size_of::()]; - u64::from_be_bytes(bytes.try_into().unwrap()).to_string() + let mut bytes: [u8; 8] = Default::default(); + bytes.copy_from_slice(&self.to_bytes()[8..16]); + u64::from_be_bytes(bytes).to_string() } } @@ -168,7 +171,7 @@ pub(crate) fn trace_id() -> Option { pub(crate) fn get_baggage(key: &str) -> Option { let context = Span::current().context(); let baggage = context.baggage(); - baggage.get(key.to_string()).cloned() + baggage.get(key).cloned() } pub(crate) trait ToOtelValue { @@ -251,26 +254,26 @@ impl From for AttributeValue { mod test { use std::sync::OnceLock; + use apollo_compiler::Node; use apollo_compiler::ast::FieldDefinition; use apollo_compiler::ast::NamedType; use apollo_compiler::executable::Field; use apollo_compiler::name; - use apollo_compiler::Node; + use opentelemetry::Context; + use opentelemetry::StringValue; use opentelemetry::trace::SpanContext; use opentelemetry::trace::SpanId; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceFlags; use opentelemetry::trace::TraceId; use opentelemetry::trace::TraceState; - use opentelemetry::Context; - use opentelemetry::StringValue; use serde_json::json; use tracing::span; use tracing_subscriber::layer::SubscriberExt; - use crate::plugins::telemetry::config_new::trace_id; use crate::plugins::telemetry::config_new::DatadogId; use crate::plugins::telemetry::config_new::ToOtelValue; + use crate::plugins::telemetry::config_new::trace_id; use crate::plugins::telemetry::otel; pub(crate) fn field() -> &'static Field { diff --git a/apollo-router/src/plugins/telemetry/config_new/router/attributes.rs b/apollo-router/src/plugins/telemetry/config_new/router/attributes.rs new file mode 100644 index 0000000000..a43292b433 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/router/attributes.rs @@ -0,0 +1,222 @@ +use std::fmt::Debug; + +use opentelemetry::Key; +use opentelemetry::KeyValue; +use opentelemetry::baggage::BaggageExt; +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; +use tracing::Span; + +use crate::Context; +use crate::plugins::telemetry::config_new::DatadogId; +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::Selectors; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::attributes::StandardAttribute; +use crate::plugins::telemetry::config_new::http_common::attributes::HttpCommonAttributes; +use crate::plugins::telemetry::config_new::http_server::attributes::HttpServerAttributes; +use crate::plugins::telemetry::config_new::trace_id; +use crate::plugins::telemetry::otel::OpenTelemetrySpanExt; +use crate::plugins::telemetry::otlp::TelemetryDataKind; +use crate::services::router; + +#[derive(Deserialize, JsonSchema, Clone, Default, Debug)] +#[cfg_attr(test, derive(PartialEq))] +#[serde(deny_unknown_fields, default)] +pub(crate) struct RouterAttributes { + /// The datadog trace ID. + /// This can be output in logs and used to correlate traces in Datadog. + #[serde(rename = "dd.trace_id")] + pub(crate) datadog_trace_id: Option, + + /// The OpenTelemetry trace ID. + /// This can be output in logs. + pub(crate) trace_id: Option, + + /// All key values from trace baggage. + pub(crate) baggage: Option, + + /// Http attributes from Open Telemetry semantic conventions. + #[serde(flatten)] + pub(crate) common: HttpCommonAttributes, + /// Http server attributes from Open Telemetry semantic conventions. + #[serde(flatten)] + pub(crate) server: HttpServerAttributes, +} + +impl DefaultForLevel for RouterAttributes { + fn defaults_for_level( + &mut self, + requirement_level: DefaultAttributeRequirementLevel, + kind: TelemetryDataKind, + ) { + self.common.defaults_for_level(requirement_level, kind); + self.server.defaults_for_level(requirement_level, kind); + } +} + +impl Selectors for RouterAttributes { + fn on_request(&self, request: &router::Request) -> Vec { + let mut attrs = self.common.on_request(request); + attrs.extend(self.server.on_request(request)); + if let Some(key) = self + .trace_id + .as_ref() + .and_then(|a| a.key(Key::from_static_str("trace_id"))) + && let Some(trace_id) = trace_id() + { + attrs.push(KeyValue::new(key, trace_id.to_string())); + } + + if let Some(key) = self + .datadog_trace_id + .as_ref() + .and_then(|a| a.key(Key::from_static_str("dd.trace_id"))) + && let Some(trace_id) = trace_id() + { + attrs.push(KeyValue::new(key, trace_id.to_datadog())); + } + if let Some(true) = &self.baggage { + let context = Span::current().context(); + let baggage = context.baggage(); + for (key, (value, _)) in baggage { + attrs.push(KeyValue::new(key.clone(), value.clone())); + } + } + + attrs + } + + fn on_response(&self, response: &router::Response) -> Vec { + let mut attrs = self.common.on_response(response); + attrs.extend(self.server.on_response(response)); + attrs + } + + fn on_error(&self, error: &BoxError, ctx: &Context) -> Vec { + let mut attrs = self.common.on_error(error, ctx); + attrs.extend(self.server.on_error(error, ctx)); + attrs + } +} + +#[cfg(test)] +mod test { + use opentelemetry::Context; + use opentelemetry::KeyValue; + use opentelemetry::baggage::BaggageExt; + use opentelemetry::trace::SpanContext; + use opentelemetry::trace::SpanId; + use opentelemetry::trace::TraceContextExt; + use opentelemetry::trace::TraceFlags; + use opentelemetry::trace::TraceId; + use opentelemetry::trace::TraceState; + use tracing::span; + use tracing::subscriber; + use tracing_subscriber::layer::SubscriberExt; + + use super::*; + use crate::plugins::telemetry::config_new::Selectors; + use crate::plugins::telemetry::otel; + use crate::services::router; + + #[test] + fn test_router_trace_attributes() { + let subscriber = tracing_subscriber::registry().with(otel::layer()); + subscriber::with_default(subscriber, || { + let span_context = SpanContext::new( + TraceId::from_u128(42), + SpanId::from_u64(42), + TraceFlags::default().with_sampled(true), + false, + TraceState::default(), + ); + let _context = Context::current() + .with_remote_span_context(span_context) + .with_baggage(vec![ + KeyValue::new("baggage_key", "baggage_value"), + KeyValue::new("baggage_key_bis", "baggage_value_bis"), + ]) + .attach(); + let span = span!(tracing::Level::INFO, "test"); + let _guard = span.enter(); + + let attributes = RouterAttributes { + datadog_trace_id: Some(StandardAttribute::Bool(true)), + trace_id: Some(StandardAttribute::Bool(true)), + baggage: Some(true), + common: Default::default(), + server: Default::default(), + }; + let attributes = + attributes.on_request(&router::Request::fake_builder().build().unwrap()); + + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key == opentelemetry::Key::from_static_str("trace_id")) + .map(|key_val| &key_val.value), + Some(&"0000000000000000000000000000002a".into()) + ); + assert_eq!( + attributes + .iter() + .find( + |key_val| key_val.key == opentelemetry::Key::from_static_str("dd.trace_id") + ) + .map(|key_val| &key_val.value), + Some(&"42".into()) + ); + assert_eq!( + attributes + .iter() + .find( + |key_val| key_val.key == opentelemetry::Key::from_static_str("baggage_key") + ) + .map(|key_val| &key_val.value), + Some(&"baggage_value".into()) + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key + == opentelemetry::Key::from_static_str("baggage_key_bis")) + .map(|key_val| &key_val.value), + Some(&"baggage_value_bis".into()) + ); + + let attributes = RouterAttributes { + datadog_trace_id: Some(StandardAttribute::Aliased { + alias: "datatoutou_id".to_string(), + }), + trace_id: Some(StandardAttribute::Aliased { + alias: "my_trace_id".to_string(), + }), + baggage: Some(false), + common: Default::default(), + server: Default::default(), + }; + let attributes = + attributes.on_request(&router::Request::fake_builder().build().unwrap()); + + assert_eq!( + attributes + .iter() + .find( + |key_val| key_val.key == opentelemetry::Key::from_static_str("my_trace_id") + ) + .map(|key_val| &key_val.value), + Some(&"0000000000000000000000000000002a".into()) + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key + == opentelemetry::Key::from_static_str("datatoutou_id")) + .map(|key_val| &key_val.value), + Some(&"42".into()) + ); + }); + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/router/events.rs b/apollo-router/src/plugins/telemetry/config_new/router/events.rs new file mode 100644 index 0000000000..e653a41603 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/router/events.rs @@ -0,0 +1,259 @@ +use std::fmt::Debug; + +use opentelemetry::Key; +use opentelemetry::KeyValue; +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; + +use super::selectors::RouterSelector; +use crate::Context; +use crate::plugins::telemetry::config_new::attributes::HTTP_RESPONSE_BODY; +use crate::plugins::telemetry::config_new::attributes::HTTP_RESPONSE_HEADERS; +use crate::plugins::telemetry::config_new::attributes::HTTP_RESPONSE_STATUS; +use crate::plugins::telemetry::config_new::attributes::HTTP_RESPONSE_VERSION; +use crate::plugins::telemetry::config_new::events::CustomEvents; +use crate::plugins::telemetry::config_new::events::EventLevel; +use crate::plugins::telemetry::config_new::events::StandardEventConfig; +use crate::plugins::telemetry::config_new::events::log_event; +use crate::plugins::telemetry::config_new::router::attributes::RouterAttributes; +use crate::services::router; + +#[derive(Clone)] +pub(crate) struct DisplayRouterRequest(pub(crate) EventLevel); +#[derive(Default, Clone, Debug)] +pub(crate) struct DisplayRouterResponse; +#[derive(Default, Clone, Debug)] +pub(crate) struct RouterResponseBodyExtensionType(pub(crate) String); + +pub(crate) type RouterEvents = + CustomEvents; + +impl CustomEvents { + pub(crate) fn on_request(&mut self, request: &router::Request) { + if let Some(request_event) = &mut self.request + && request_event.condition.evaluate_request(request) == Some(true) + { + request + .context + .extensions() + .with_lock(|ext| ext.insert(DisplayRouterRequest(request_event.level))); + } + if let Some(response_event) = &mut self.response + && response_event.condition.evaluate_request(request) != Some(false) + { + request + .context + .extensions() + .with_lock(|ext| ext.insert(DisplayRouterResponse)); + } + for custom_event in &mut self.custom { + custom_event.on_request(request); + } + } + + pub(crate) fn on_response(&mut self, response: &router::Response) { + if let Some(response_event) = &self.response + && response_event.condition.evaluate_response(response) + { + let mut attrs = Vec::with_capacity(4); + + #[cfg(test)] + let mut headers: indexmap::IndexMap = response + .response + .headers() + .clone() + .into_iter() + .filter_map(|(name, val)| Some((name?.to_string(), val))) + .collect(); + #[cfg(test)] + headers.sort_keys(); + #[cfg(not(test))] + let headers = response.response.headers(); + attrs.push(KeyValue::new( + HTTP_RESPONSE_HEADERS, + opentelemetry::Value::String(format!("{headers:?}").into()), + )); + attrs.push(KeyValue::new( + HTTP_RESPONSE_STATUS, + opentelemetry::Value::String(format!("{}", response.response.status()).into()), + )); + attrs.push(KeyValue::new( + HTTP_RESPONSE_VERSION, + opentelemetry::Value::String(format!("{:?}", response.response.version()).into()), + )); + + if let Some(body) = response + .context + .extensions() + // Clone here in case anything else also needs access to the body + .with_lock(|ext| ext.get::().cloned()) + { + attrs.push(KeyValue::new( + HTTP_RESPONSE_BODY, + opentelemetry::Value::String(body.0.into()), + )); + } + + log_event(response_event.level, "router.response", attrs, ""); + } + for custom_event in &mut self.custom { + custom_event.on_response(response); + } + } + + pub(crate) fn on_error(&mut self, error: &BoxError, ctx: &Context) { + if let Some(error_event) = &self.error + && error_event.condition.evaluate_error(error, ctx) + { + log_event( + error_event.level, + "router.error", + vec![KeyValue::new( + Key::from_static_str("error"), + opentelemetry::Value::String(error.to_string().into()), + )], + "", + ); + } + for custom_event in &mut self.custom { + custom_event.on_error(error, ctx); + } + } +} + +#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct RouterEventsConfig { + /// Log the router request + pub(crate) request: StandardEventConfig, + /// Log the router response + pub(crate) response: StandardEventConfig, + /// Log the router error + pub(crate) error: StandardEventConfig, +} + +#[cfg(test)] +mod tests { + use http::HeaderValue; + use http::header::CONTENT_LENGTH; + use tracing::instrument::WithSubscriber; + + use super::*; + use crate::assert_snapshot_subscriber; + use crate::context::CONTAINS_GRAPHQL_ERROR; + use crate::plugins::telemetry::Telemetry; + use crate::plugins::test::PluginTestHarness; + + #[tokio::test(flavor = "multi_thread")] + async fn test_router_events() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!("../../testdata/custom_events.router.yaml")) + .build() + .await + .expect("test harness"); + + async { + test_harness + .router_service(|_r| async { + Ok(router::Response::fake_builder() + .header("custom-header", "val1") + .header(CONTENT_LENGTH, "25") + .header("x-log-request", HeaderValue::from_static("log")) + .data(serde_json_bytes::json!({"data": "res"})) + .build() + .expect("expecting valid response")) + }) + .call( + router::Request::fake_builder() + .header(CONTENT_LENGTH, "0") + .header("custom-header", "val1") + .header("x-log-request", HeaderValue::from_static("log")) + .build() + .unwrap(), + ) + .await + .expect("expecting successful response"); + } + .with_subscriber(assert_snapshot_subscriber!({ + r#"[].span["apollo_private.duration_ns"]"# => "[duration]", + r#"[].spans[]["apollo_private.duration_ns"]"# => "[duration]", + "[].fields.attributes" => insta::sorted_redaction() + })) + .await + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_router_events_graphql_error() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!("../../testdata/custom_events.router.yaml")) + .build() + .await + .expect("test harness"); + + async { + // Without the header to enable custom event + test_harness + .router_service( + + |_r| async { + let context_with_error = Context::new(); + let _ = context_with_error + .insert(CONTAINS_GRAPHQL_ERROR, true) + .unwrap(); + Ok(router::Response::fake_builder() + .header("custom-header", "val1") + .context(context_with_error) + .data(serde_json_bytes::json!({"errors": [{"message": "res"}]})) + .build() + .expect("expecting valid response")) + }, + ) + .call(router::Request::fake_builder() + .header("custom-header", "val1") + .build() + .unwrap()) + .await + .expect("expecting successful response"); + } + .with_subscriber( + assert_snapshot_subscriber!({r#"[].span["apollo_private.duration_ns"]"# => "[duration]", r#"[].spans[]["apollo_private.duration_ns"]"# => "[duration]", "[].fields.attributes" => insta::sorted_redaction()}), + ) + .await + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_router_events_graphql_response() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!("../../testdata/custom_events.router.yaml")) + .build() + .await + .expect("test harness"); + + async { + // Without the header to enable custom event + test_harness + .router_service( + |_r| async { + Ok(router::Response::fake_builder() + .header("custom-header", "val1") + .header(CONTENT_LENGTH, "25") + .header("x-log-response", HeaderValue::from_static("log")) + .data(serde_json_bytes::json!({"data": "res"})) + .build() + .expect("expecting valid response")) + }, + ) + .call(router::Request::fake_builder() + .header("custom-header", "val1") + .build() + .unwrap()) + .await + .expect("expecting successful response"); + } + .with_subscriber( + assert_snapshot_subscriber!({r#"[].span["apollo_private.duration_ns"]"# => "[duration]", r#"[].spans[]["apollo_private.duration_ns"]"# => "[duration]", "[].fields.attributes" => insta::sorted_redaction()}), + ) + .await + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/router/instruments.rs b/apollo-router/src/plugins/telemetry/config_new/router/instruments.rs new file mode 100644 index 0000000000..25d856f25a --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/router/instruments.rs @@ -0,0 +1,138 @@ +use std::fmt::Debug; + +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; + +use super::selectors::RouterSelector; +use super::selectors::RouterValue; +use crate::Context; +use crate::plugins::telemetry::Instrumented; +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::extendable::Extendable; +use crate::plugins::telemetry::config_new::instruments::ActiveRequestsAttributes; +use crate::plugins::telemetry::config_new::instruments::ActiveRequestsCounter; +use crate::plugins::telemetry::config_new::instruments::CustomHistogram; +use crate::plugins::telemetry::config_new::instruments::CustomInstruments; +use crate::plugins::telemetry::config_new::instruments::DefaultedStandardInstrument; +use crate::plugins::telemetry::config_new::router::attributes::RouterAttributes; +use crate::plugins::telemetry::otlp::TelemetryDataKind; +use crate::services::router; + +#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct RouterInstrumentsConfig { + /// Histogram of server request duration + #[serde(rename = "http.server.request.duration")] + pub(crate) http_server_request_duration: + DefaultedStandardInstrument>, + + /// Counter of active requests + #[serde(rename = "http.server.active_requests")] + pub(crate) http_server_active_requests: DefaultedStandardInstrument, + + /// Histogram of server request body size + #[serde(rename = "http.server.request.body.size")] + pub(crate) http_server_request_body_size: + DefaultedStandardInstrument>, + + /// Histogram of server response body size + #[serde(rename = "http.server.response.body.size")] + pub(crate) http_server_response_body_size: + DefaultedStandardInstrument>, +} + +impl DefaultForLevel for RouterInstrumentsConfig { + fn defaults_for_level( + &mut self, + requirement_level: DefaultAttributeRequirementLevel, + kind: TelemetryDataKind, + ) { + self.http_server_request_duration + .defaults_for_levels(requirement_level, kind); + self.http_server_active_requests + .defaults_for_levels(requirement_level, kind); + self.http_server_request_body_size + .defaults_for_levels(requirement_level, kind); + self.http_server_response_body_size + .defaults_for_levels(requirement_level, kind); + } +} + +pub(crate) struct RouterInstruments { + pub(crate) http_server_request_duration: Option< + CustomHistogram, + >, + pub(crate) http_server_active_requests: Option, + pub(crate) http_server_request_body_size: Option< + CustomHistogram, + >, + pub(crate) http_server_response_body_size: Option< + CustomHistogram, + >, + pub(crate) custom: RouterCustomInstruments, +} + +impl Instrumented for RouterInstruments { + type Request = router::Request; + type Response = router::Response; + type EventResponse = (); + + fn on_request(&self, request: &Self::Request) { + if let Some(http_server_request_duration) = &self.http_server_request_duration { + http_server_request_duration.on_request(request); + } + if let Some(http_server_active_requests) = &self.http_server_active_requests { + http_server_active_requests.on_request(request); + } + if let Some(http_server_request_body_size) = &self.http_server_request_body_size { + http_server_request_body_size.on_request(request); + } + if let Some(http_server_response_body_size) = &self.http_server_response_body_size { + http_server_response_body_size.on_request(request); + } + self.custom.on_request(request); + } + + fn on_response(&self, response: &Self::Response) { + if let Some(http_server_request_duration) = &self.http_server_request_duration { + http_server_request_duration.on_response(response); + } + if let Some(http_server_active_requests) = &self.http_server_active_requests { + http_server_active_requests.on_response(response); + } + if let Some(http_server_request_body_size) = &self.http_server_request_body_size { + http_server_request_body_size.on_response(response); + } + if let Some(http_server_response_body_size) = &self.http_server_response_body_size { + http_server_response_body_size.on_response(response); + } + self.custom.on_response(response); + } + + fn on_error(&self, error: &BoxError, ctx: &Context) { + if let Some(http_server_request_duration) = &self.http_server_request_duration { + http_server_request_duration.on_error(error, ctx); + } + if let Some(http_server_active_requests) = &self.http_server_active_requests { + http_server_active_requests.on_error(error, ctx); + } + if let Some(http_server_request_body_size) = &self.http_server_request_body_size { + http_server_request_body_size.on_error(error, ctx); + } + if let Some(http_server_response_body_size) = &self.http_server_response_body_size { + http_server_response_body_size.on_error(error, ctx); + } + self.custom.on_error(error, ctx); + } +} + +pub(crate) type RouterCustomInstruments = CustomInstruments< + router::Request, + router::Response, + (), + RouterAttributes, + RouterSelector, + RouterValue, +>; diff --git a/apollo-router/src/plugins/telemetry/config_new/router/mod.rs b/apollo-router/src/plugins/telemetry/config_new/router/mod.rs new file mode 100644 index 0000000000..232f3aa106 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/router/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod attributes; +pub(crate) mod events; +pub(crate) mod instruments; +pub(crate) mod selectors; +pub(crate) mod spans; diff --git a/apollo-router/src/plugins/telemetry/config_new/router/selectors.rs b/apollo-router/src/plugins/telemetry/config_new/router/selectors.rs new file mode 100644 index 0000000000..741f2a7f7d --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/router/selectors.rs @@ -0,0 +1,913 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use sha2::Digest; + +use super::events::DisplayRouterResponse; +use crate::Context; +use crate::context::CONTAINS_GRAPHQL_ERROR; +use crate::context::OPERATION_NAME; +use crate::plugins::telemetry::config::AttributeValue; +use crate::plugins::telemetry::config::TraceIdFormat; +use crate::plugins::telemetry::config_new::Selector; +use crate::plugins::telemetry::config_new::Stage; +use crate::plugins::telemetry::config_new::ToOtelValue; +use crate::plugins::telemetry::config_new::get_baggage; +use crate::plugins::telemetry::config_new::instruments::InstrumentValue; +use crate::plugins::telemetry::config_new::instruments::Standard; +use crate::plugins::telemetry::config_new::router::events::RouterResponseBodyExtensionType; +use crate::plugins::telemetry::config_new::selectors::ErrorRepr; +use crate::plugins::telemetry::config_new::selectors::OperationName; +use crate::plugins::telemetry::config_new::selectors::ResponseStatus; +use crate::plugins::telemetry::config_new::trace_id; +use crate::query_planner::APOLLO_OPERATION_ID; +use crate::services::router; + +#[derive(Deserialize, JsonSchema, Clone, Debug)] +#[serde(deny_unknown_fields, rename_all = "snake_case", untagged)] +pub(crate) enum RouterValue { + Standard(Standard), + Custom(RouterSelector), +} + +impl From<&RouterValue> for InstrumentValue { + fn from(value: &RouterValue) -> Self { + match value { + RouterValue::Standard(standard) => InstrumentValue::Standard(standard.clone()), + RouterValue::Custom(selector) => InstrumentValue::Custom(selector.clone()), + } + } +} + +#[derive(Deserialize, JsonSchema, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields, untagged)] +pub(crate) enum RouterSelector { + /// A value from baggage. + Baggage { + /// The name of the baggage item. + baggage: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + /// A value from an environment variable. + Env { + /// The name of the environment variable + env: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + /// Avoid unsafe std::env::set_var in tests + #[cfg(test)] + #[serde(skip)] + mocked_env_var: Option, + }, + /// Critical error if it happens + Error { + #[allow(dead_code)] + error: ErrorRepr, + }, + /// Boolean set to true if the response body contains graphql error + OnGraphQLError { on_graphql_error: bool }, + /// The operation name from the query. + OperationName { + /// The operation name from the query. + operation_name: OperationName, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + /// A header from the request + RequestHeader { + /// The name of the request header. + request_header: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + /// A value from context. + RequestContext { + /// The request context key. + request_context: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + /// The request method. + RequestMethod { + /// The request method enabled or not + request_method: bool, + }, + /// The body of the response + ResponseBody { + /// The response body enabled or not + response_body: bool, + }, + /// A header from the response + ResponseHeader { + /// The name of the request header. + response_header: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + /// A value from context. + ResponseContext { + /// The response context key. + response_context: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + /// A status from the response + ResponseStatus { + /// The http response status code. + response_status: ResponseStatus, + }, + /// Deprecated, should not be used anymore, use static field instead + Static(String), + StaticField { + /// A static value + r#static: AttributeValue, + }, + /// Apollo Studio operation id + StudioOperationId { + /// Apollo Studio operation id + studio_operation_id: bool, + }, + /// The trace ID of the request. + TraceId { + /// The format of the trace ID. + trace_id: TraceIdFormat, + }, +} + +impl Selector for RouterSelector { + type Request = router::Request; + type Response = router::Response; + type EventResponse = (); + + fn on_request(&self, request: &router::Request) -> Option { + match self { + RouterSelector::RequestMethod { request_method } if *request_method => { + Some(request.router_request.method().to_string().into()) + } + RouterSelector::RequestContext { + request_context, + default, + .. + } => request + .context + .get_json_value(request_context) + .as_ref() + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + RouterSelector::RequestHeader { + request_header, + default, + .. + } => request + .router_request + .headers() + .get(request_header) + .and_then(|h| Some(h.to_str().ok()?.to_string().into())) + .or_else(|| default.maybe_to_otel_value()), + RouterSelector::Env { + env, + default, + #[cfg(test)] + mocked_env_var, + .. + } => { + #[cfg(test)] + let value = mocked_env_var.clone(); + #[cfg(not(test))] + let value = None; + value + .or_else(|| std::env::var(env).ok()) + .or_else(|| default.clone()) + .map(opentelemetry::Value::from) + } + RouterSelector::TraceId { + trace_id: trace_id_format, + } => trace_id().map(|id| trace_id_format.format(id).into()), + RouterSelector::Baggage { + baggage, default, .. + } => get_baggage(baggage).or_else(|| default.maybe_to_otel_value()), + RouterSelector::Static(val) => Some(val.clone().into()), + RouterSelector::StaticField { r#static } => Some(r#static.clone().into()), + RouterSelector::ResponseBody { response_body } if *response_body => { + request.context.extensions().with_lock(|ext| { + ext.insert(DisplayRouterResponse); + }); + None + } + // Related to Response + _ => None, + } + } + + fn on_response(&self, response: &router::Response) -> Option { + match self { + RouterSelector::ResponseBody { response_body } if *response_body => { + response + .context + .extensions() + .with_lock(|ext| { + // Clone here in case anything else also needs access to the body + ext.get::().cloned() + }) + .map(|v| opentelemetry::Value::String(v.0.into())) + } + RouterSelector::ResponseHeader { + response_header, + default, + .. + } => response + .response + .headers() + .get(response_header) + .and_then(|h| Some(h.to_str().ok()?.to_string().into())) + .or_else(|| default.maybe_to_otel_value()), + RouterSelector::ResponseStatus { response_status } => match response_status { + ResponseStatus::Code => Some(opentelemetry::Value::I64( + response.response.status().as_u16() as i64, + )), + ResponseStatus::Reason => response + .response + .status() + .canonical_reason() + .map(|reason| reason.to_string().into()), + }, + RouterSelector::ResponseContext { + response_context, + default, + .. + } => response + .context + .get_json_value(response_context) + .as_ref() + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + RouterSelector::OperationName { + operation_name, + default, + .. + } => { + let op_name = response.context.get(OPERATION_NAME).ok().flatten(); + match operation_name { + OperationName::String => op_name.or_else(|| default.clone()), + OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { + let mut hasher = sha2::Sha256::new(); + hasher.update(op_name.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) + }), + } + .map(opentelemetry::Value::from) + } + RouterSelector::Baggage { + baggage, default, .. + } => get_baggage(baggage).or_else(|| default.maybe_to_otel_value()), + RouterSelector::OnGraphQLError { on_graphql_error } if *on_graphql_error => { + let contains_error = response + .context + .get_json_value(CONTAINS_GRAPHQL_ERROR) + .and_then(|value| value.as_bool()) + .unwrap_or_default(); + Some(opentelemetry::Value::Bool(contains_error)) + } + RouterSelector::Static(val) => Some(val.clone().into()), + RouterSelector::StaticField { r#static } => Some(r#static.clone().into()), + RouterSelector::StudioOperationId { + studio_operation_id, + } if *studio_operation_id => response + .context + .get::<_, String>(APOLLO_OPERATION_ID) + .ok() + .flatten() + .map(opentelemetry::Value::from), + _ => None, + } + } + + fn on_error(&self, error: &tower::BoxError, ctx: &Context) -> Option { + match self { + RouterSelector::Error { .. } => Some(error.to_string().into()), + RouterSelector::Static(val) => Some(val.clone().into()), + RouterSelector::StaticField { r#static } => Some(r#static.clone().into()), + RouterSelector::ResponseContext { + response_context, + default, + .. + } => ctx + .get_json_value(response_context) + .as_ref() + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + RouterSelector::OperationName { + operation_name, + default, + .. + } => { + let op_name = ctx.get(OPERATION_NAME).ok().flatten(); + match operation_name { + OperationName::String => op_name.or_else(|| default.clone()), + OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { + let mut hasher = sha2::Sha256::new(); + hasher.update(op_name.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) + }), + } + .map(opentelemetry::Value::from) + } + _ => None, + } + } + + fn on_drop(&self) -> Option { + match self { + RouterSelector::Static(val) => Some(val.clone().into()), + RouterSelector::StaticField { r#static } => Some(r#static.clone().into()), + _ => None, + } + } + + fn is_active(&self, stage: Stage) -> bool { + match stage { + Stage::Request => { + matches!( + self, + RouterSelector::RequestHeader { .. } + | RouterSelector::RequestContext { .. } + | RouterSelector::RequestMethod { .. } + | RouterSelector::TraceId { .. } + | RouterSelector::StudioOperationId { .. } + | RouterSelector::Baggage { .. } + | RouterSelector::Static(_) + | RouterSelector::Env { .. } + | RouterSelector::StaticField { .. } + ) + } + Stage::Response | Stage::ResponseEvent => matches!( + self, + RouterSelector::TraceId { .. } + | RouterSelector::StudioOperationId { .. } + | RouterSelector::OperationName { .. } + | RouterSelector::Baggage { .. } + | RouterSelector::Static(_) + | RouterSelector::Env { .. } + | RouterSelector::StaticField { .. } + | RouterSelector::ResponseHeader { .. } + | RouterSelector::ResponseContext { .. } + | RouterSelector::ResponseStatus { .. } + | RouterSelector::OnGraphQLError { .. } + ), + Stage::ResponseField => false, + Stage::Error => matches!( + self, + RouterSelector::TraceId { .. } + | RouterSelector::StudioOperationId { .. } + | RouterSelector::OperationName { .. } + | RouterSelector::Baggage { .. } + | RouterSelector::Static(_) + | RouterSelector::Env { .. } + | RouterSelector::StaticField { .. } + | RouterSelector::ResponseContext { .. } + | RouterSelector::Error { .. } + ), + Stage::Drop => matches!( + self, + RouterSelector::Static(_) | RouterSelector::StaticField { .. } + ), + } + } +} + +#[cfg(test)] +mod test { + use http::StatusCode; + use opentelemetry::Context; + use opentelemetry::KeyValue; + use opentelemetry::baggage::BaggageExt; + use opentelemetry::trace::SpanContext; + use opentelemetry::trace::SpanId; + use opentelemetry::trace::TraceContextExt; + use opentelemetry::trace::TraceFlags; + use opentelemetry::trace::TraceId; + use opentelemetry::trace::TraceState; + use serde_json::json; + use tower::BoxError; + use tracing::span; + use tracing::subscriber; + use tracing_subscriber::layer::SubscriberExt; + + use crate::context::OPERATION_NAME; + use crate::plugins::telemetry::TraceIdFormat; + use crate::plugins::telemetry::config_new::Selector; + use crate::plugins::telemetry::config_new::router::selectors::RouterSelector; + use crate::plugins::telemetry::config_new::selectors::OperationName; + use crate::plugins::telemetry::config_new::selectors::ResponseStatus; + use crate::plugins::telemetry::otel; + use crate::query_planner::APOLLO_OPERATION_ID; + + #[test] + fn router_static() { + let selector = RouterSelector::Static("test_static".to_string()); + assert_eq!( + selector + .on_request( + &crate::services::RouterRequest::fake_builder() + .build() + .unwrap() + ) + .unwrap(), + "test_static".into() + ); + assert_eq!(selector.on_drop().unwrap(), "test_static".into()); + } + + #[test] + fn router_static_field() { + let selector = RouterSelector::StaticField { + r#static: "test_static".to_string().into(), + }; + assert_eq!( + selector + .on_request( + &crate::services::RouterRequest::fake_builder() + .build() + .unwrap() + ) + .unwrap(), + "test_static".into() + ); + assert_eq!(selector.on_drop().unwrap(), "test_static".into()); + } + + #[test] + fn router_request_header() { + let selector = RouterSelector::RequestHeader { + request_header: "header_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + assert_eq!( + selector + .on_request( + &crate::services::RouterRequest::fake_builder() + .header("header_key", "header_value") + .build() + .unwrap() + ) + .unwrap(), + "header_value".into() + ); + + assert_eq!( + selector + .on_request( + &crate::services::RouterRequest::fake_builder() + .build() + .unwrap() + ) + .unwrap(), + "defaulted".into() + ); + + assert_eq!( + selector.on_response( + &crate::services::RouterResponse::fake_builder() + .context(crate::context::Context::default()) + .header("header_key", "header_value") + .data(json!({})) + .build() + .unwrap() + ), + None + ); + } + + #[test] + fn router_request_context() { + let selector = RouterSelector::RequestContext { + request_context: "context_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + let context = crate::context::Context::new(); + let _ = context.insert("context_key".to_string(), "context_value".to_string()); + assert_eq!( + selector + .on_request( + &crate::services::RouterRequest::fake_builder() + .context(context.clone()) + .build() + .unwrap() + ) + .unwrap(), + "context_value".into() + ); + + assert_eq!( + selector + .on_request( + &crate::services::RouterRequest::fake_builder() + .build() + .unwrap() + ) + .unwrap(), + "defaulted".into() + ); + assert_eq!( + selector.on_response( + &crate::services::RouterResponse::fake_builder() + .context(context) + .build() + .unwrap() + ), + None + ); + } + + #[test] + fn router_response_context() { + let selector = RouterSelector::ResponseContext { + response_context: "context_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + let context = crate::context::Context::new(); + let _ = context.insert("context_key".to_string(), "context_value".to_string()); + assert_eq!( + selector + .on_response( + &crate::services::RouterResponse::fake_builder() + .context(context.clone()) + .build() + .unwrap() + ) + .unwrap(), + "context_value".into() + ); + + assert_eq!( + selector + .on_error(&BoxError::from(String::from("my error")), &context) + .unwrap(), + "context_value".into() + ); + + assert_eq!( + selector + .on_response( + &crate::services::RouterResponse::fake_builder() + .build() + .unwrap() + ) + .unwrap(), + "defaulted".into() + ); + assert_eq!( + selector.on_request( + &crate::services::RouterRequest::fake_builder() + .context(context) + .build() + .unwrap() + ), + None + ); + } + + #[test] + fn router_response_header() { + let selector = RouterSelector::ResponseHeader { + response_header: "header_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + assert_eq!( + selector + .on_response( + &crate::services::RouterResponse::fake_builder() + .header("header_key", "header_value") + .build() + .unwrap() + ) + .unwrap(), + "header_value".into() + ); + + assert_eq!( + selector + .on_response( + &crate::services::RouterResponse::fake_builder() + .build() + .unwrap() + ) + .unwrap(), + "defaulted".into() + ); + + assert_eq!( + selector.on_request( + &crate::services::RouterRequest::fake_builder() + .header("header_key", "header_value") + .build() + .unwrap() + ), + None + ); + } + + #[test] + fn router_baggage() { + let subscriber = tracing_subscriber::registry().with(otel::layer()); + subscriber::with_default(subscriber, || { + let selector = RouterSelector::Baggage { + baggage: "baggage_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + let span_context = SpanContext::new( + TraceId::from_u128(42), + SpanId::from_u64(42), + // Make sure it's sampled if not, it won't create anything at the otel layer + TraceFlags::default().with_sampled(true), + false, + TraceState::default(), + ); + let _context_guard = Context::new() + .with_remote_span_context(span_context) + .with_baggage(vec![KeyValue::new("baggage_key", "baggage_value")]) + .attach(); + assert_eq!( + selector + .on_request( + &crate::services::RouterRequest::fake_builder() + .build() + .unwrap(), + ) + .unwrap(), + "defaulted".into() + ); + + let span = span!(tracing::Level::INFO, "test"); + let _guard = span.enter(); + assert_eq!( + selector + .on_request( + &crate::services::RouterRequest::fake_builder() + .build() + .unwrap(), + ) + .unwrap(), + "baggage_value".into() + ); + }); + } + + #[test] + fn router_trace_id() { + let subscriber = tracing_subscriber::registry().with(otel::layer()); + subscriber::with_default(subscriber, || { + let selector = RouterSelector::TraceId { + trace_id: TraceIdFormat::Hexadecimal, + }; + assert_eq!( + selector.on_request( + &crate::services::RouterRequest::fake_builder() + .build() + .unwrap(), + ), + None + ); + + let span_context = SpanContext::new( + TraceId::from_u128(42), + SpanId::from_u64(42), + TraceFlags::default().with_sampled(true), + false, + TraceState::default(), + ); + let _context = Context::current() + .with_remote_span_context(span_context) + .attach(); + let span = span!(tracing::Level::INFO, "test"); + let _guard = span.enter(); + + assert_eq!( + selector + .on_request( + &crate::services::RouterRequest::fake_builder() + .build() + .unwrap(), + ) + .unwrap(), + "0000000000000000000000000000002a".into() + ); + + let selector = RouterSelector::TraceId { + trace_id: TraceIdFormat::Datadog, + }; + + assert_eq!( + selector + .on_request( + &crate::services::RouterRequest::fake_builder() + .build() + .unwrap(), + ) + .unwrap(), + opentelemetry::Value::String("42".into()) + ); + + let selector = RouterSelector::TraceId { + trace_id: TraceIdFormat::Uuid, + }; + + assert_eq!( + selector + .on_request( + &crate::services::RouterRequest::fake_builder() + .build() + .unwrap(), + ) + .unwrap(), + opentelemetry::Value::String("00000000-0000-0000-0000-00000000002a".into()) + ); + + let selector = RouterSelector::TraceId { + trace_id: TraceIdFormat::Decimal, + }; + + assert_eq!( + selector + .on_request( + &crate::services::RouterRequest::fake_builder() + .build() + .unwrap(), + ) + .unwrap(), + opentelemetry::Value::String("42".into()) + ); + }); + } + + #[test] + fn test_router_studio_trace_id() { + let selector = RouterSelector::StudioOperationId { + studio_operation_id: true, + }; + let ctx = crate::Context::new(); + let _ = ctx.insert(APOLLO_OPERATION_ID, "42".to_string()).unwrap(); + + assert_eq!( + selector + .on_response( + &crate::services::RouterResponse::fake_builder() + .context(ctx) + .build() + .unwrap(), + ) + .unwrap(), + opentelemetry::Value::String("42".into()) + ); + } + + #[test] + fn router_env() { + let mut selector = RouterSelector::Env { + env: "SELECTOR_ENV_VARIABLE".to_string(), + redact: None, + default: Some("defaulted".to_string()), + mocked_env_var: None, + }; + assert_eq!( + selector.on_request( + &crate::services::RouterRequest::fake_builder() + .build() + .unwrap(), + ), + Some("defaulted".into()) + ); + + if let RouterSelector::Env { mocked_env_var, .. } = &mut selector { + *mocked_env_var = Some("env_value".to_string()) + } + assert_eq!( + selector.on_request( + &crate::services::RouterRequest::fake_builder() + .build() + .unwrap(), + ), + Some("env_value".into()) + ); + } + + #[test] + fn router_operation_name_string() { + let selector = RouterSelector::OperationName { + operation_name: OperationName::String, + redact: None, + default: Some("defaulted".to_string()), + }; + let context = crate::context::Context::new(); + assert_eq!( + selector.on_response( + &crate::services::RouterResponse::fake_builder() + .context(context.clone()) + .build() + .unwrap(), + ), + Some("defaulted".into()) + ); + let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); + assert_eq!( + selector.on_response( + &crate::services::RouterResponse::fake_builder() + .context(context.clone()) + .build() + .unwrap(), + ), + Some("topProducts".into()) + ); + assert_eq!( + selector.on_error(&BoxError::from(String::from("my error")), &context), + Some("topProducts".into()) + ); + } + + #[test] + fn router_response_status_code() { + let selector = RouterSelector::ResponseStatus { + response_status: ResponseStatus::Code, + }; + assert_eq!( + selector + .on_response( + &crate::services::RouterResponse::fake_builder() + .status_code(StatusCode::NO_CONTENT) + .build() + .unwrap() + ) + .unwrap(), + opentelemetry::Value::I64(204) + ); + } + + #[test] + fn router_response_status_reason() { + let selector = RouterSelector::ResponseStatus { + response_status: ResponseStatus::Reason, + }; + assert_eq!( + selector + .on_response( + &crate::services::RouterResponse::fake_builder() + .status_code(StatusCode::NO_CONTENT) + .build() + .unwrap() + ) + .unwrap(), + "No Content".into() + ); + } + + #[test] + fn router_response_body() { + let selector = RouterSelector::ResponseBody { + response_body: true, + }; + let res = &crate::services::RouterResponse::fake_builder() + .status_code(StatusCode::OK) + .data("some data") + .build() + .unwrap(); + assert_eq!( + selector.on_response(res).unwrap().as_str(), + r#"{"data":"some data"}"# + ); + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/router/snapshots/apollo_router__plugins__telemetry__config_new__router__events__tests__router_events@logs.snap b/apollo-router/src/plugins/telemetry/config_new/router/snapshots/apollo_router__plugins__telemetry__config_new__router__events__tests__router_events@logs.snap new file mode 100644 index 0000000000..ba1323d6ba --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/router/snapshots/apollo_router__plugins__telemetry__config_new__router__events__tests__router_events@logs.snap @@ -0,0 +1,12 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/router/events.rs +expression: yaml +--- +- fields: + kind: my.request_event + level: INFO + message: my event message +- fields: + kind: my.response_event + level: INFO + message: my response event message diff --git a/apollo-router/src/plugins/telemetry/config_new/router/snapshots/apollo_router__plugins__telemetry__config_new__router__events__tests__router_events_graphql_error@logs.snap b/apollo-router/src/plugins/telemetry/config_new/router/snapshots/apollo_router__plugins__telemetry__config_new__router__events__tests__router_events_graphql_error@logs.snap new file mode 100644 index 0000000000..fc6869d5ad --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/router/snapshots/apollo_router__plugins__telemetry__config_new__router__events__tests__router_events_graphql_error@logs.snap @@ -0,0 +1,8 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/router/events.rs +expression: yaml +--- +- fields: + kind: router.response + level: INFO + message: "" diff --git a/apollo-router/src/plugins/telemetry/config_new/router/snapshots/apollo_router__plugins__telemetry__config_new__router__events__tests__router_events_graphql_response@logs.snap b/apollo-router/src/plugins/telemetry/config_new/router/snapshots/apollo_router__plugins__telemetry__config_new__router__events__tests__router_events_graphql_response@logs.snap new file mode 100644 index 0000000000..fc6869d5ad --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/router/snapshots/apollo_router__plugins__telemetry__config_new__router__events__tests__router_events_graphql_response@logs.snap @@ -0,0 +1,8 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/router/events.rs +expression: yaml +--- +- fields: + kind: router.response + level: INFO + message: "" diff --git a/apollo-router/src/plugins/telemetry/config_new/router/spans.rs b/apollo-router/src/plugins/telemetry/config_new/router/spans.rs new file mode 100644 index 0000000000..c767bfd43b --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/router/spans.rs @@ -0,0 +1,459 @@ +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::conditional::Conditional; +use crate::plugins::telemetry::config_new::extendable::Extendable; +use crate::plugins::telemetry::config_new::router::attributes::RouterAttributes; +use crate::plugins::telemetry::config_new::router::selectors::RouterSelector; +use crate::plugins::telemetry::otlp::TelemetryDataKind; + +#[derive(Deserialize, JsonSchema, Clone, Debug, Default)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct RouterSpans { + /// Custom attributes that are attached to the router span. + pub(crate) attributes: Extendable>, +} + +impl DefaultForLevel for RouterSpans { + fn defaults_for_level( + &mut self, + requirement_level: DefaultAttributeRequirementLevel, + kind: TelemetryDataKind, + ) { + self.attributes.defaults_for_level(requirement_level, kind); + } +} + +#[cfg(test)] +mod test { + use std::sync::Arc; + + use http::header::USER_AGENT; + use opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD; + use opentelemetry_semantic_conventions::trace::NETWORK_PROTOCOL_VERSION; + use opentelemetry_semantic_conventions::trace::URL_PATH; + use opentelemetry_semantic_conventions::trace::USER_AGENT_ORIGINAL; + use parking_lot::Mutex; + + use super::RouterSpans; + use crate::Context; + use crate::context::CONTAINS_GRAPHQL_ERROR; + use crate::plugins::telemetry::OTEL_NAME; + use crate::plugins::telemetry::config::AttributeValue; + use crate::plugins::telemetry::config_new::DefaultForLevel; + use crate::plugins::telemetry::config_new::Selectors; + use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; + use crate::plugins::telemetry::config_new::attributes::StandardAttribute; + use crate::plugins::telemetry::config_new::conditional::Conditional; + use crate::plugins::telemetry::config_new::conditions::Condition; + use crate::plugins::telemetry::config_new::conditions::SelectorOrValue; + use crate::plugins::telemetry::config_new::router::selectors::RouterSelector; + use crate::plugins::telemetry::otlp::TelemetryDataKind; + use crate::services::router; + + #[test] + fn test_router_spans_level_none() { + let mut spans = RouterSpans::default(); + spans.defaults_for_levels( + DefaultAttributeRequirementLevel::None, + TelemetryDataKind::Traces, + ); + let values = spans.attributes.on_request( + &router::Request::fake_builder() + .method(http::Method::POST) + .header(USER_AGENT, "test") + .build() + .unwrap(), + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key.as_str() == HTTP_REQUEST_METHOD) + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key.as_str() == NETWORK_PROTOCOL_VERSION) + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key.as_str() == URL_PATH) + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key.as_str() == USER_AGENT_ORIGINAL) + ); + } + + #[test] + fn test_router_spans_level_required() { + let mut spans = RouterSpans::default(); + spans.defaults_for_levels( + DefaultAttributeRequirementLevel::Required, + TelemetryDataKind::Traces, + ); + let values = spans.attributes.on_request( + &router::Request::fake_builder() + .method(http::Method::POST) + .header(USER_AGENT, "test") + .build() + .unwrap(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key.as_str() == HTTP_REQUEST_METHOD) + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key.as_str() == NETWORK_PROTOCOL_VERSION) + ); + assert!( + values + .iter() + .any(|key_val| key_val.key.as_str() == URL_PATH) + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key.as_str() == USER_AGENT_ORIGINAL) + ); + } + + #[test] + fn test_router_spans_level_recommended() { + let mut spans = RouterSpans::default(); + spans.defaults_for_levels( + DefaultAttributeRequirementLevel::Recommended, + TelemetryDataKind::Traces, + ); + let values = spans.attributes.on_request( + &router::Request::fake_builder() + .method(http::Method::POST) + .header(USER_AGENT, "test") + .build() + .unwrap(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key.as_str() == HTTP_REQUEST_METHOD) + ); + assert!( + values + .iter() + .any(|key_val| key_val.key.as_str() == NETWORK_PROTOCOL_VERSION) + ); + assert!( + values + .iter() + .any(|key_val| key_val.key.as_str() == URL_PATH) + ); + assert!( + values + .iter() + .any(|key_val| key_val.key.as_str() == USER_AGENT_ORIGINAL) + ); + } + + #[test] + fn test_router_request_static_custom_attribute_on_graphql_error() { + let mut spans = RouterSpans::default(); + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: RouterSelector::StaticField { + r#static: "my-static-value".to_string().into(), + }, + condition: Some(Arc::new(Mutex::new(Condition::Eq([ + SelectorOrValue::Value(AttributeValue::Bool(true)), + SelectorOrValue::Selector(RouterSelector::OnGraphQLError { + on_graphql_error: true, + }), + ])))), + value: Arc::new(Default::default()), + }, + ); + let context = Context::new(); + context.insert_json_value(CONTAINS_GRAPHQL_ERROR, serde_json_bytes::Value::Bool(true)); + let values = spans.attributes.on_response( + &router::Response::fake_builder() + .header("my-header", "test_val") + .context(context) + .build() + .unwrap(), + ); + assert!(values.iter().any(|key_val| key_val.key + == opentelemetry::Key::from_static_str("test") + && key_val.value + == opentelemetry::Value::String("my-static-value".to_string().into()))); + } + + #[test] + fn test_router_request_custom_attribute_on_graphql_error() { + let mut spans = RouterSpans::default(); + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: RouterSelector::ResponseHeader { + response_header: "my-header".to_string(), + redact: None, + default: None, + }, + condition: Some(Arc::new(Mutex::new(Condition::Eq([ + SelectorOrValue::Value(AttributeValue::Bool(true)), + SelectorOrValue::Selector(RouterSelector::OnGraphQLError { + on_graphql_error: true, + }), + ])))), + value: Arc::new(Default::default()), + }, + ); + let context = Context::new(); + context.insert_json_value(CONTAINS_GRAPHQL_ERROR, serde_json_bytes::Value::Bool(true)); + let values = spans.attributes.on_response( + &router::Response::fake_builder() + .header("my-header", "test_val") + .context(context) + .build() + .unwrap(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test")) + ); + } + + #[test] + fn test_router_request_custom_attribute_not_on_graphql_error_context_false() { + let mut spans = RouterSpans::default(); + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: RouterSelector::ResponseHeader { + response_header: "my-header".to_string(), + redact: None, + default: None, + }, + condition: Some(Arc::new(Mutex::new(Condition::Eq([ + SelectorOrValue::Value(AttributeValue::Bool(true)), + SelectorOrValue::Selector(RouterSelector::OnGraphQLError { + on_graphql_error: true, + }), + ])))), + value: Arc::new(Default::default()), + }, + ); + let context = Context::new(); + let values = spans.attributes.on_response( + &router::Response::fake_builder() + .header("my-header", "test_val") + .context(context) + .build() + .unwrap(), + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test")) + ); + } + + #[test] + fn test_router_request_custom_attribute_not_on_graphql_error_context_missing() { + let mut spans = RouterSpans::default(); + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: RouterSelector::ResponseHeader { + response_header: "my-header".to_string(), + redact: None, + default: None, + }, + condition: Some(Arc::new(Mutex::new(Condition::Eq([ + SelectorOrValue::Value(AttributeValue::Bool(true)), + SelectorOrValue::Selector(RouterSelector::OnGraphQLError { + on_graphql_error: true, + }), + ])))), + value: Arc::new(Default::default()), + }, + ); + let context = Context::new(); + let values = spans.attributes.on_response( + &router::Response::fake_builder() + .header("my-header", "test_val") + .context(context) + .build() + .unwrap(), + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test")) + ); + } + + #[test] + fn test_router_request_custom_attribute_condition_true() { + let mut spans = RouterSpans::default(); + let selector = RouterSelector::RequestHeader { + request_header: "my-header".to_string(), + redact: None, + default: None, + }; + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: selector.clone(), + condition: Some(Arc::new(Mutex::new(Condition::Eq([ + SelectorOrValue::Value(AttributeValue::String("test_val".to_string())), + SelectorOrValue::Selector(selector), + ])))), + value: Default::default(), + }, + ); + let values = spans.attributes.on_request( + &router::Request::fake_builder() + .method(http::Method::POST) + .header("my-header", "test_val") + .build() + .unwrap(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test")) + ); + } + + #[test] + fn test_router_request_custom_attribute_condition_false() { + let mut spans = RouterSpans::default(); + let selector = RouterSelector::RequestHeader { + request_header: "my-header".to_string(), + redact: None, + default: None, + }; + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: selector.clone(), + condition: Some(Arc::new(Mutex::new(Condition::Eq([ + SelectorOrValue::Value(AttributeValue::String("test_val".to_string())), + SelectorOrValue::Selector(selector), + ])))), + value: Arc::new(Default::default()), + }, + ); + let values = spans.attributes.on_request( + &router::Request::fake_builder() + .method(http::Method::POST) + .header("my-header", "bar") + .build() + .unwrap(), + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test")) + ); + } + + #[test] + fn test_router_request_custom_attribute() { + let mut spans = RouterSpans::default(); + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: RouterSelector::RequestHeader { + request_header: "my-header".to_string(), + redact: None, + default: None, + }, + condition: None, + value: Arc::new(Default::default()), + }, + ); + let values = spans.attributes.on_request( + &router::Request::fake_builder() + .method(http::Method::POST) + .header("my-header", "test_val") + .build() + .unwrap(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test")) + ); + } + + #[test] + fn test_router_request_standard_attribute_aliased() { + let mut spans = RouterSpans::default(); + spans.attributes.attributes.common.http_request_method = Some(StandardAttribute::Aliased { + alias: String::from("my.method"), + }); + let values = spans.attributes.on_request( + &router::Request::fake_builder() + .method(http::Method::POST) + .header("my-header", "test_val") + .build() + .unwrap(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("my.method")) + ); + } + + #[test] + fn test_router_response_custom_attribute() { + let mut spans = RouterSpans::default(); + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: RouterSelector::ResponseHeader { + response_header: "my-header".to_string(), + redact: None, + default: None, + }, + condition: None, + value: Arc::new(Default::default()), + }, + ); + spans.attributes.custom.insert( + OTEL_NAME.to_string(), + Conditional { + selector: RouterSelector::StaticField { + r#static: String::from("new_name").into(), + }, + condition: None, + value: Arc::new(Default::default()), + }, + ); + let values = spans.attributes.on_response( + &router::Response::fake_builder() + .header("my-header", "test_val") + .build() + .unwrap(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test")) + ); + + assert!(values.iter().any(|key_val| key_val.key + == opentelemetry::Key::from_static_str(OTEL_NAME) + && key_val.value == opentelemetry::Value::String(String::from("new_name").into()))); + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/selectors.rs b/apollo-router/src/plugins/telemetry/config_new/selectors.rs index 1b49d9548c..c9e30e6e3b 100644 --- a/apollo-router/src/plugins/telemetry/config_new/selectors.rs +++ b/apollo-router/src/plugins/telemetry/config_new/selectors.rs @@ -1,36 +1,5 @@ -use access_json::JSONQuery; -use derivative::Derivative; -use opentelemetry_api::Value; use schemars::JsonSchema; use serde::Deserialize; -use serde_json_bytes::path::JsonPathInst; -use serde_json_bytes::ByteString; -use sha2::Digest; - -use crate::context::CONTAINS_GRAPHQL_ERROR; -use crate::context::OPERATION_KIND; -use crate::context::OPERATION_NAME; -use crate::plugin::serde::deserialize_json_query; -use crate::plugin::serde::deserialize_jsonpath; -use crate::plugins::cache::entity::CacheSubgraph; -use crate::plugins::cache::metrics::CacheMetricContextKey; -use crate::plugins::telemetry::config::AttributeValue; -use crate::plugins::telemetry::config::TraceIdFormat; -use crate::plugins::telemetry::config_new::cost::CostValue; -use crate::plugins::telemetry::config_new::get_baggage; -use crate::plugins::telemetry::config_new::instruments::Event; -use crate::plugins::telemetry::config_new::instruments::InstrumentValue; -use crate::plugins::telemetry::config_new::instruments::Standard; -use crate::plugins::telemetry::config_new::trace_id; -use crate::plugins::telemetry::config_new::Selector; -use crate::plugins::telemetry::config_new::ToOtelValue; -use crate::query_planner::APOLLO_OPERATION_ID; -use crate::services::router; -use crate::services::subgraph; -use crate::services::supergraph; -use crate::services::FIRST_EVENT_CONTEXT_KEY; -use crate::spec::operation_limits::OperationLimits; -use crate::Context; #[derive(Deserialize, JsonSchema, Clone, Debug, PartialEq)] #[serde(deny_unknown_fields, rename_all = "snake_case")] @@ -66,13 +35,6 @@ pub(crate) enum Query { RootFields, } -#[derive(Deserialize, JsonSchema, Clone, Debug, PartialEq)] -#[serde(deny_unknown_fields, rename_all = "snake_case")] -pub(crate) enum SubgraphQuery { - /// The raw query kind. - String, -} - #[derive(Deserialize, JsonSchema, Clone, Debug, PartialEq)] #[serde(deny_unknown_fields, rename_all = "snake_case")] pub(crate) enum ResponseStatus { @@ -89,551 +51,6 @@ pub(crate) enum OperationKind { String, } -#[derive(Deserialize, JsonSchema, Clone, Debug)] -#[serde(deny_unknown_fields, rename_all = "snake_case", untagged)] -pub(crate) enum RouterValue { - Standard(Standard), - Custom(RouterSelector), -} - -impl From<&RouterValue> for InstrumentValue { - fn from(value: &RouterValue) -> Self { - match value { - RouterValue::Standard(standard) => InstrumentValue::Standard(standard.clone()), - RouterValue::Custom(selector) => InstrumentValue::Custom(selector.clone()), - } - } -} - -#[derive(Deserialize, JsonSchema, Clone, Debug, PartialEq)] -#[serde(deny_unknown_fields, untagged)] -pub(crate) enum RouterSelector { - /// A header from the request - RequestHeader { - /// The name of the request header. - request_header: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - /// The request method. - RequestMethod { - /// The request method enabled or not - request_method: bool, - }, - /// A value from context. - RequestContext { - /// The request context key. - request_context: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - /// A header from the response - ResponseHeader { - /// The name of the request header. - response_header: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - /// A status from the response - ResponseStatus { - /// The http response status code. - response_status: ResponseStatus, - }, - /// The trace ID of the request. - TraceId { - /// The format of the trace ID. - trace_id: TraceIdFormat, - }, - /// Apollo Studio operation id - StudioOperationId { - /// Apollo Studio operation id - studio_operation_id: bool, - }, - /// A value from context. - ResponseContext { - /// The response context key. - response_context: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - /// The operation name from the query. - OperationName { - /// The operation name from the query. - operation_name: OperationName, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - /// A value from baggage. - Baggage { - /// The name of the baggage item. - baggage: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - /// A value from an environment variable. - Env { - /// The name of the environment variable - env: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - /// Deprecated, should not be used anymore, use static field instead - Static(String), - StaticField { - /// A static value - r#static: AttributeValue, - }, - OnGraphQLError { - /// Boolean set to true if the response body contains graphql error - on_graphql_error: bool, - }, - Error { - #[allow(dead_code)] - /// Critical error if it happens - error: ErrorRepr, - }, -} - -#[derive(Deserialize, JsonSchema, Clone, Debug)] -#[serde(deny_unknown_fields, rename_all = "snake_case", untagged)] -pub(crate) enum SupergraphValue { - Standard(Standard), - Event(Event), - Custom(SupergraphSelector), -} - -impl From<&SupergraphValue> for InstrumentValue { - fn from(value: &SupergraphValue) -> Self { - match value { - SupergraphValue::Standard(s) => InstrumentValue::Standard(s.clone()), - SupergraphValue::Custom(selector) => match selector { - SupergraphSelector::Cost { .. } => { - InstrumentValue::Chunked(Event::Custom(selector.clone())) - } - _ => InstrumentValue::Custom(selector.clone()), - }, - SupergraphValue::Event(e) => InstrumentValue::Chunked(e.clone()), - } - } -} - -#[derive(Deserialize, JsonSchema, Clone, Derivative)] -#[serde(deny_unknown_fields, untagged)] -#[derivative(Debug, PartialEq)] -pub(crate) enum SupergraphSelector { - OperationName { - /// The operation name from the query. - operation_name: OperationName, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - OperationKind { - /// The operation kind from the query (query|mutation|subscription). - // Allow dead code is required because there is only one variant in OperationKind and we need to avoid the dead code warning. - #[allow(dead_code)] - operation_kind: OperationKind, - }, - Query { - /// The graphql query. - query: Query, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - QueryVariable { - /// The name of a graphql query variable. - query_variable: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - RequestHeader { - /// The name of the request header. - request_header: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - ResponseHeader { - /// The name of the response header. - response_header: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - /// A status from the response - ResponseStatus { - /// The http response status code. - response_status: ResponseStatus, - }, - RequestContext { - /// The request context key. - request_context: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - ResponseContext { - /// The response context key. - response_context: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - ResponseData { - /// The supergraph response body json path of the chunks. - #[schemars(with = "String")] - #[derivative(Debug = "ignore", PartialEq = "ignore")] - #[serde(deserialize_with = "deserialize_jsonpath")] - response_data: JsonPathInst, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - ResponseErrors { - /// The supergraph response body json path of the chunks. - #[schemars(with = "String")] - #[derivative(Debug = "ignore", PartialEq = "ignore")] - #[serde(deserialize_with = "deserialize_jsonpath")] - response_errors: JsonPathInst, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - Baggage { - /// The name of the baggage item. - baggage: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - Env { - /// The name of the environment variable - env: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - /// Deprecated, should not be used anymore, use static field instead - Static(String), - StaticField { - /// A static value - r#static: AttributeValue, - }, - OnGraphQLError { - /// Boolean set to true if the response body contains graphql error - on_graphql_error: bool, - }, - Error { - #[allow(dead_code)] - /// Critical error if it happens - error: ErrorRepr, - }, - /// Cost attributes - Cost { - /// The cost value to select, one of: estimated, actual, delta. - cost: CostValue, - }, - /// Boolean returning true if it's the primary response and not events like subscription events or deferred responses - IsPrimaryResponse { - /// Boolean returning true if it's the primary response and not events like subscription events or deferred responses - is_primary_response: bool, - }, -} - -#[derive(Deserialize, JsonSchema, Clone, Debug)] -#[serde(deny_unknown_fields, rename_all = "snake_case", untagged)] -pub(crate) enum SubgraphValue { - Standard(Standard), - Custom(SubgraphSelector), -} - -impl From<&SubgraphValue> for InstrumentValue { - fn from(value: &SubgraphValue) -> Self { - match value { - SubgraphValue::Standard(s) => InstrumentValue::Standard(s.clone()), - SubgraphValue::Custom(selector) => InstrumentValue::Custom(selector.clone()), - } - } -} - -#[derive(Deserialize, JsonSchema, Clone, Derivative)] -#[serde(deny_unknown_fields, rename_all = "snake_case", untagged)] -#[derivative(Debug, PartialEq)] -pub(crate) enum SubgraphSelector { - SubgraphOperationName { - /// The operation name from the subgraph query. - subgraph_operation_name: OperationName, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - SubgraphOperationKind { - /// The kind of the subgraph operation (query|mutation|subscription). - // Allow dead code is required because there is only one variant in OperationKind and we need to avoid the dead code warning. - #[allow(dead_code)] - subgraph_operation_kind: OperationKind, - }, - SubgraphName { - /// The subgraph name - subgraph_name: bool, - }, - SubgraphQuery { - /// The graphql query to the subgraph. - subgraph_query: SubgraphQuery, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - SubgraphQueryVariable { - /// The name of a subgraph query variable. - subgraph_query_variable: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - /// Deprecated, use SubgraphResponseData and SubgraphResponseError instead - SubgraphResponseBody { - /// The subgraph response body json path. - #[schemars(with = "String")] - #[serde(deserialize_with = "deserialize_json_query")] - subgraph_response_body: JSONQuery, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - SubgraphResponseData { - /// The subgraph response body json path. - #[schemars(with = "String")] - #[derivative(Debug = "ignore", PartialEq = "ignore")] - #[serde(deserialize_with = "deserialize_jsonpath")] - subgraph_response_data: JsonPathInst, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - SubgraphResponseErrors { - /// The subgraph response body json path. - #[schemars(with = "String")] - #[derivative(Debug = "ignore", PartialEq = "ignore")] - #[serde(deserialize_with = "deserialize_jsonpath")] - subgraph_response_errors: JsonPathInst, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - SubgraphRequestHeader { - /// The name of a subgraph request header. - subgraph_request_header: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - SubgraphResponseHeader { - /// The name of a subgraph response header. - subgraph_response_header: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - SubgraphResponseStatus { - /// The subgraph http response status code. - subgraph_response_status: ResponseStatus, - }, - SupergraphOperationName { - /// The supergraph query operation name. - supergraph_operation_name: OperationName, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - SupergraphOperationKind { - /// The supergraph query operation kind (query|mutation|subscription). - // Allow dead code is required because there is only one variant in OperationKind and we need to avoid the dead code warning. - #[allow(dead_code)] - supergraph_operation_kind: OperationKind, - }, - SupergraphQuery { - /// The supergraph query to the subgraph. - supergraph_query: Query, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - SupergraphQueryVariable { - /// The supergraph query variable name. - supergraph_query_variable: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - SupergraphRequestHeader { - /// The supergraph request header name. - supergraph_request_header: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - RequestContext { - /// The request context key. - request_context: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - ResponseContext { - /// The response context key. - response_context: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - OnGraphQLError { - /// Boolean set to true if the response body contains graphql error - subgraph_on_graphql_error: bool, - }, - Baggage { - /// The name of the baggage item. - baggage: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - Env { - /// The name of the environment variable - env: String, - #[serde(skip)] - #[allow(dead_code)] - /// Optional redaction pattern. - redact: Option, - /// Optional default value. - default: Option, - }, - /// Deprecated, should not be used anymore, use static field instead - Static(String), - StaticField { - /// A static value - r#static: AttributeValue, - }, - Error { - /// Critical error if it happens - error: ErrorRepr, - }, - Cache { - /// Select if you want to get cache hit or cache miss - cache: CacheKind, - /// Specify the entity type on which you want the cache data. (default: all) - entity_type: Option, - }, -} - #[derive(Deserialize, JsonSchema, Clone, PartialEq, Debug)] #[serde(rename_all = "snake_case", untagged)] pub(crate) enum EntityType { @@ -660,2978 +77,3 @@ pub(crate) enum CacheKind { Hit, Miss, } - -impl Selector for RouterSelector { - type Request = router::Request; - type Response = router::Response; - type EventResponse = (); - - fn on_request(&self, request: &router::Request) -> Option { - match self { - RouterSelector::RequestMethod { request_method } if *request_method => { - Some(request.router_request.method().to_string().into()) - } - RouterSelector::RequestContext { - request_context, - default, - .. - } => request - .context - .get_json_value(request_context) - .as_ref() - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - RouterSelector::RequestHeader { - request_header, - default, - .. - } => request - .router_request - .headers() - .get(request_header) - .and_then(|h| Some(h.to_str().ok()?.to_string().into())) - .or_else(|| default.maybe_to_otel_value()), - RouterSelector::Env { env, default, .. } => std::env::var(env) - .ok() - .or_else(|| default.clone()) - .map(opentelemetry::Value::from), - RouterSelector::TraceId { - trace_id: trace_id_format, - } => trace_id().map(|id| trace_id_format.format(id).into()), - RouterSelector::Baggage { - baggage, default, .. - } => get_baggage(baggage).or_else(|| default.maybe_to_otel_value()), - RouterSelector::Static(val) => Some(val.clone().into()), - RouterSelector::StaticField { r#static } => Some(r#static.clone().into()), - // Related to Response - _ => None, - } - } - - fn on_response(&self, response: &router::Response) -> Option { - match self { - RouterSelector::ResponseHeader { - response_header, - default, - .. - } => response - .response - .headers() - .get(response_header) - .and_then(|h| Some(h.to_str().ok()?.to_string().into())) - .or_else(|| default.maybe_to_otel_value()), - RouterSelector::ResponseStatus { response_status } => match response_status { - ResponseStatus::Code => Some(opentelemetry::Value::I64( - response.response.status().as_u16() as i64, - )), - ResponseStatus::Reason => response - .response - .status() - .canonical_reason() - .map(|reason| reason.to_string().into()), - }, - RouterSelector::ResponseContext { - response_context, - default, - .. - } => response - .context - .get_json_value(response_context) - .as_ref() - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - RouterSelector::OperationName { - operation_name, - default, - .. - } => { - let op_name = response.context.get(OPERATION_NAME).ok().flatten(); - match operation_name { - OperationName::String => op_name.or_else(|| default.clone()), - OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { - let mut hasher = sha2::Sha256::new(); - hasher.update(op_name.as_bytes()); - let result = hasher.finalize(); - hex::encode(result) - }), - } - .map(opentelemetry::Value::from) - } - RouterSelector::Baggage { - baggage, default, .. - } => get_baggage(baggage).or_else(|| default.maybe_to_otel_value()), - RouterSelector::OnGraphQLError { on_graphql_error } if *on_graphql_error => { - if response.context.get_json_value(CONTAINS_GRAPHQL_ERROR) - == Some(serde_json_bytes::Value::Bool(true)) - { - Some(opentelemetry::Value::Bool(true)) - } else { - None - } - } - RouterSelector::Static(val) => Some(val.clone().into()), - RouterSelector::StaticField { r#static } => Some(r#static.clone().into()), - RouterSelector::StudioOperationId { - studio_operation_id, - } if *studio_operation_id => response - .context - .get::<_, String>(APOLLO_OPERATION_ID) - .ok() - .flatten() - .map(opentelemetry::Value::from), - _ => None, - } - } - - fn on_error(&self, error: &tower::BoxError, ctx: &Context) -> Option { - match self { - RouterSelector::Error { .. } => Some(error.to_string().into()), - RouterSelector::Static(val) => Some(val.clone().into()), - RouterSelector::StaticField { r#static } => Some(r#static.clone().into()), - RouterSelector::ResponseContext { - response_context, - default, - .. - } => ctx - .get_json_value(response_context) - .as_ref() - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - RouterSelector::OperationName { - operation_name, - default, - .. - } => { - let op_name = ctx.get(OPERATION_NAME).ok().flatten(); - match operation_name { - OperationName::String => op_name.or_else(|| default.clone()), - OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { - let mut hasher = sha2::Sha256::new(); - hasher.update(op_name.as_bytes()); - let result = hasher.finalize(); - hex::encode(result) - }), - } - .map(opentelemetry::Value::from) - } - _ => None, - } - } - - fn on_drop(&self) -> Option { - match self { - RouterSelector::Static(val) => Some(val.clone().into()), - RouterSelector::StaticField { r#static } => Some(r#static.clone().into()), - _ => None, - } - } - - fn is_active(&self, stage: super::Stage) -> bool { - match stage { - super::Stage::Request => { - matches!( - self, - RouterSelector::RequestHeader { .. } - | RouterSelector::RequestContext { .. } - | RouterSelector::RequestMethod { .. } - | RouterSelector::TraceId { .. } - | RouterSelector::StudioOperationId { .. } - | RouterSelector::Baggage { .. } - | RouterSelector::Static(_) - | RouterSelector::Env { .. } - | RouterSelector::StaticField { .. } - ) - } - super::Stage::Response | super::Stage::ResponseEvent => matches!( - self, - RouterSelector::TraceId { .. } - | RouterSelector::StudioOperationId { .. } - | RouterSelector::OperationName { .. } - | RouterSelector::Baggage { .. } - | RouterSelector::Static(_) - | RouterSelector::Env { .. } - | RouterSelector::StaticField { .. } - | RouterSelector::ResponseHeader { .. } - | RouterSelector::ResponseContext { .. } - | RouterSelector::ResponseStatus { .. } - | RouterSelector::OnGraphQLError { .. } - ), - super::Stage::ResponseField => false, - super::Stage::Error => matches!( - self, - RouterSelector::TraceId { .. } - | RouterSelector::StudioOperationId { .. } - | RouterSelector::OperationName { .. } - | RouterSelector::Baggage { .. } - | RouterSelector::Static(_) - | RouterSelector::Env { .. } - | RouterSelector::StaticField { .. } - | RouterSelector::ResponseContext { .. } - | RouterSelector::Error { .. } - ), - super::Stage::Drop => matches!( - self, - RouterSelector::Static(_) | RouterSelector::StaticField { .. } - ), - } - } -} - -impl Selector for SupergraphSelector { - type Request = supergraph::Request; - type Response = supergraph::Response; - type EventResponse = crate::graphql::Response; - - fn on_request(&self, request: &supergraph::Request) -> Option { - match self { - SupergraphSelector::OperationName { - operation_name, - default, - .. - } => { - let op_name = request.context.get(OPERATION_NAME).ok().flatten(); - match operation_name { - OperationName::String => op_name.or_else(|| default.clone()), - OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { - let mut hasher = sha2::Sha256::new(); - hasher.update(op_name.as_bytes()); - let result = hasher.finalize(); - hex::encode(result) - }), - } - .map(opentelemetry::Value::from) - } - SupergraphSelector::OperationKind { .. } => request - .context - .get::<_, String>(OPERATION_KIND) - .ok() - .flatten() - .map(opentelemetry::Value::from), - - SupergraphSelector::Query { - default, - query: Query::String, - .. - } => request - .supergraph_request - .body() - .query - .clone() - .or_else(|| default.clone()) - .map(opentelemetry::Value::from), - SupergraphSelector::RequestHeader { - request_header, - default, - .. - } => request - .supergraph_request - .headers() - .get(request_header) - .and_then(|h| Some(h.to_str().ok()?.to_string())) - .or_else(|| default.clone()) - .map(opentelemetry::Value::from), - SupergraphSelector::QueryVariable { - query_variable, - default, - .. - } => request - .supergraph_request - .body() - .variables - .get(&ByteString::from(query_variable.as_str())) - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - SupergraphSelector::RequestContext { - request_context, - default, - .. - } => request - .context - .get::<_, serde_json_bytes::Value>(request_context) - .ok() - .flatten() - .as_ref() - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - SupergraphSelector::Baggage { - baggage, default, .. - } => get_baggage(baggage).or_else(|| default.maybe_to_otel_value()), - - SupergraphSelector::Env { env, default, .. } => std::env::var(env) - .ok() - .or_else(|| default.clone()) - .map(opentelemetry::Value::from), - SupergraphSelector::Static(val) => Some(val.clone().into()), - SupergraphSelector::StaticField { r#static } => Some(r#static.clone().into()), - // For response - _ => None, - } - } - - fn on_response(&self, response: &supergraph::Response) -> Option { - match self { - SupergraphSelector::Query { query, .. } => { - let limits_opt = response - .context - .extensions() - .with_lock(|lock| lock.get::>().cloned()); - match query { - Query::Aliases => { - limits_opt.map(|limits| opentelemetry::Value::I64(limits.aliases as i64)) - } - Query::Depth => { - limits_opt.map(|limits| opentelemetry::Value::I64(limits.depth as i64)) - } - Query::Height => { - limits_opt.map(|limits| opentelemetry::Value::I64(limits.height as i64)) - } - Query::RootFields => limits_opt - .map(|limits| opentelemetry::Value::I64(limits.root_fields as i64)), - Query::String => None, - } - } - SupergraphSelector::ResponseHeader { - response_header, - default, - .. - } => response - .response - .headers() - .get(response_header) - .and_then(|h| Some(h.to_str().ok()?.to_string())) - .or_else(|| default.clone()) - .map(opentelemetry::Value::from), - SupergraphSelector::ResponseStatus { response_status } => match response_status { - ResponseStatus::Code => Some(opentelemetry::Value::I64( - response.response.status().as_u16() as i64, - )), - ResponseStatus::Reason => response - .response - .status() - .canonical_reason() - .map(|reason| reason.to_string().into()), - }, - SupergraphSelector::ResponseContext { - response_context, - default, - .. - } => response - .context - .get_json_value(response_context) - .as_ref() - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - SupergraphSelector::OnGraphQLError { on_graphql_error } if *on_graphql_error => { - if response.context.get_json_value(CONTAINS_GRAPHQL_ERROR) - == Some(serde_json_bytes::Value::Bool(true)) - { - Some(opentelemetry::Value::Bool(true)) - } else { - None - } - } - SupergraphSelector::OperationName { - operation_name, - default, - .. - } => { - let op_name = response.context.get(OPERATION_NAME).ok().flatten(); - match operation_name { - OperationName::String => op_name.or_else(|| default.clone()), - OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { - let mut hasher = sha2::Sha256::new(); - hasher.update(op_name.as_bytes()); - let result = hasher.finalize(); - hex::encode(result) - }), - } - .map(opentelemetry::Value::from) - } - SupergraphSelector::OperationKind { .. } => response - .context - .get::<_, String>(OPERATION_KIND) - .ok() - .flatten() - .map(opentelemetry::Value::from), - SupergraphSelector::IsPrimaryResponse { - is_primary_response: is_primary, - } if *is_primary => Some(true.into()), - SupergraphSelector::Static(val) => Some(val.clone().into()), - SupergraphSelector::StaticField { r#static } => Some(r#static.clone().into()), - // For request - _ => None, - } - } - - fn on_response_event( - &self, - response: &Self::EventResponse, - ctx: &Context, - ) -> Option { - match self { - SupergraphSelector::Query { query, .. } => { - let limits_opt = ctx - .extensions() - .with_lock(|lock| lock.get::>().cloned()); - match query { - Query::Aliases => { - limits_opt.map(|limits| opentelemetry::Value::I64(limits.aliases as i64)) - } - Query::Depth => { - limits_opt.map(|limits| opentelemetry::Value::I64(limits.depth as i64)) - } - Query::Height => { - limits_opt.map(|limits| opentelemetry::Value::I64(limits.height as i64)) - } - Query::RootFields => limits_opt - .map(|limits| opentelemetry::Value::I64(limits.root_fields as i64)), - Query::String => None, - } - } - SupergraphSelector::ResponseData { - response_data, - default, - .. - } => if let Some(data) = &response.data { - let val = response_data.find(data); - val.maybe_to_otel_value() - } else { - None - } - .or_else(|| default.maybe_to_otel_value()), - SupergraphSelector::ResponseErrors { - response_errors, - default, - .. - } => { - let errors = response.errors.clone(); - let data: serde_json_bytes::Value = serde_json_bytes::to_value(errors).ok()?; - let val = response_errors.find(&data); - - val.maybe_to_otel_value() - } - .or_else(|| default.maybe_to_otel_value()), - SupergraphSelector::Cost { cost } => match cost { - CostValue::Estimated => ctx - .get_estimated_cost() - .ok() - .flatten() - .map(opentelemetry::Value::from), - CostValue::Actual => ctx - .get_actual_cost() - .ok() - .flatten() - .map(opentelemetry::Value::from), - CostValue::Delta => ctx - .get_cost_delta() - .ok() - .flatten() - .map(opentelemetry::Value::from), - CostValue::Result => ctx - .get_cost_result() - .ok() - .flatten() - .map(opentelemetry::Value::from), - }, - SupergraphSelector::OnGraphQLError { on_graphql_error } if *on_graphql_error => { - if ctx.get_json_value(CONTAINS_GRAPHQL_ERROR) - == Some(serde_json_bytes::Value::Bool(true)) - { - Some(opentelemetry::Value::Bool(true)) - } else { - None - } - } - SupergraphSelector::OperationName { - operation_name, - default, - .. - } => { - let op_name = ctx.get(OPERATION_NAME).ok().flatten(); - match operation_name { - OperationName::String => op_name.or_else(|| default.clone()), - OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { - let mut hasher = sha2::Sha256::new(); - hasher.update(op_name.as_bytes()); - let result = hasher.finalize(); - hex::encode(result) - }), - } - .map(opentelemetry::Value::from) - } - SupergraphSelector::OperationKind { .. } => ctx - .get::<_, String>(OPERATION_KIND) - .ok() - .flatten() - .map(opentelemetry::Value::from), - SupergraphSelector::IsPrimaryResponse { - is_primary_response: is_primary, - } if *is_primary => Some(opentelemetry::Value::Bool( - ctx.get_json_value(FIRST_EVENT_CONTEXT_KEY) - == Some(serde_json_bytes::Value::Bool(true)), - )), - SupergraphSelector::ResponseContext { - response_context, - default, - .. - } => ctx - .get_json_value(response_context) - .as_ref() - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - SupergraphSelector::Static(val) => Some(val.clone().into()), - SupergraphSelector::StaticField { r#static } => Some(r#static.clone().into()), - _ => None, - } - } - - fn on_error(&self, error: &tower::BoxError, ctx: &Context) -> Option { - match self { - SupergraphSelector::OperationName { - operation_name, - default, - .. - } => { - let op_name = ctx.get(OPERATION_NAME).ok().flatten(); - match operation_name { - OperationName::String => op_name.or_else(|| default.clone()), - OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { - let mut hasher = sha2::Sha256::new(); - hasher.update(op_name.as_bytes()); - let result = hasher.finalize(); - hex::encode(result) - }), - } - .map(opentelemetry::Value::from) - } - SupergraphSelector::OperationKind { .. } => ctx - .get::<_, String>(OPERATION_KIND) - .ok() - .flatten() - .map(opentelemetry::Value::from), - SupergraphSelector::Query { query, .. } => { - let limits_opt = ctx - .extensions() - .with_lock(|lock| lock.get::>().cloned()); - match query { - Query::Aliases => { - limits_opt.map(|limits| opentelemetry::Value::I64(limits.aliases as i64)) - } - Query::Depth => { - limits_opt.map(|limits| opentelemetry::Value::I64(limits.depth as i64)) - } - Query::Height => { - limits_opt.map(|limits| opentelemetry::Value::I64(limits.height as i64)) - } - Query::RootFields => limits_opt - .map(|limits| opentelemetry::Value::I64(limits.root_fields as i64)), - Query::String => None, - } - } - SupergraphSelector::Error { .. } => Some(error.to_string().into()), - SupergraphSelector::Static(val) => Some(val.clone().into()), - SupergraphSelector::StaticField { r#static } => Some(r#static.clone().into()), - SupergraphSelector::ResponseContext { - response_context, - default, - .. - } => ctx - .get_json_value(response_context) - .as_ref() - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - SupergraphSelector::IsPrimaryResponse { - is_primary_response: is_primary, - } if *is_primary => Some(opentelemetry::Value::Bool( - ctx.get_json_value(FIRST_EVENT_CONTEXT_KEY) - == Some(serde_json_bytes::Value::Bool(true)), - )), - _ => None, - } - } - - fn on_drop(&self) -> Option { - match self { - SupergraphSelector::Static(val) => Some(val.clone().into()), - SupergraphSelector::StaticField { r#static } => Some(r#static.clone().into()), - _ => None, - } - } - - fn is_active(&self, stage: super::Stage) -> bool { - match stage { - super::Stage::Request => matches!( - self, - SupergraphSelector::OperationName { .. } - | SupergraphSelector::OperationKind { .. } - | SupergraphSelector::Query { .. } - | SupergraphSelector::RequestHeader { .. } - | SupergraphSelector::QueryVariable { .. } - | SupergraphSelector::RequestContext { .. } - | SupergraphSelector::Baggage { .. } - | SupergraphSelector::Env { .. } - | SupergraphSelector::Static(_) - | SupergraphSelector::StaticField { .. } - ), - super::Stage::Response => matches!( - self, - SupergraphSelector::Query { .. } - | SupergraphSelector::ResponseHeader { .. } - | SupergraphSelector::ResponseStatus { .. } - | SupergraphSelector::ResponseContext { .. } - | SupergraphSelector::OnGraphQLError { .. } - | SupergraphSelector::OperationName { .. } - | SupergraphSelector::OperationKind { .. } - | SupergraphSelector::IsPrimaryResponse { .. } - | SupergraphSelector::Static(_) - | SupergraphSelector::StaticField { .. } - ), - super::Stage::ResponseEvent => matches!( - self, - SupergraphSelector::ResponseData { .. } - | SupergraphSelector::ResponseErrors { .. } - | SupergraphSelector::Cost { .. } - | SupergraphSelector::OnGraphQLError { .. } - | SupergraphSelector::OperationName { .. } - | SupergraphSelector::OperationKind { .. } - | SupergraphSelector::IsPrimaryResponse { .. } - | SupergraphSelector::ResponseContext { .. } - | SupergraphSelector::Static(_) - | SupergraphSelector::StaticField { .. } - ), - super::Stage::ResponseField => false, - super::Stage::Error => matches!( - self, - SupergraphSelector::OperationName { .. } - | SupergraphSelector::OperationKind { .. } - | SupergraphSelector::Query { .. } - | SupergraphSelector::Error { .. } - | SupergraphSelector::Static(_) - | SupergraphSelector::StaticField { .. } - | SupergraphSelector::ResponseContext { .. } - | SupergraphSelector::IsPrimaryResponse { .. } - ), - super::Stage::Drop => matches!( - self, - SupergraphSelector::Static(_) | SupergraphSelector::StaticField { .. } - ), - } - } -} - -impl Selector for SubgraphSelector { - type Request = subgraph::Request; - type Response = subgraph::Response; - type EventResponse = (); - - fn on_request(&self, request: &subgraph::Request) -> Option { - match self { - SubgraphSelector::SubgraphOperationName { - subgraph_operation_name, - default, - .. - } => { - let op_name = request.subgraph_request.body().operation_name.clone(); - match subgraph_operation_name { - OperationName::String => op_name.or_else(|| default.clone()), - OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { - let mut hasher = sha2::Sha256::new(); - hasher.update(op_name.as_bytes()); - let result = hasher.finalize(); - hex::encode(result) - }), - } - .map(opentelemetry::Value::from) - } - SubgraphSelector::SupergraphOperationName { - supergraph_operation_name, - default, - .. - } => { - let op_name = request.context.get(OPERATION_NAME).ok().flatten(); - match supergraph_operation_name { - OperationName::String => op_name.or_else(|| default.clone()), - OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { - let mut hasher = sha2::Sha256::new(); - hasher.update(op_name.as_bytes()); - let result = hasher.finalize(); - hex::encode(result) - }), - } - .map(opentelemetry::Value::from) - } - SubgraphSelector::SubgraphName { subgraph_name } if *subgraph_name => request - .subgraph_name - .clone() - .map(opentelemetry::Value::from), - SubgraphSelector::SubgraphOperationKind { .. } => request - .context - .get::<_, String>(OPERATION_KIND) - .ok() - .flatten() - .map(opentelemetry::Value::from), - SubgraphSelector::SupergraphOperationKind { .. } => request - .context - .get::<_, String>(OPERATION_KIND) - .ok() - .flatten() - .map(opentelemetry::Value::from), - - SubgraphSelector::SupergraphQuery { - default, - supergraph_query, - .. - } => { - let limits_opt = request - .context - .extensions() - .with_lock(|lock| lock.get::>().cloned()); - match supergraph_query { - Query::Aliases => { - limits_opt.map(|limits| opentelemetry::Value::I64(limits.aliases as i64)) - } - Query::Depth => { - limits_opt.map(|limits| opentelemetry::Value::I64(limits.depth as i64)) - } - Query::Height => { - limits_opt.map(|limits| opentelemetry::Value::I64(limits.height as i64)) - } - Query::RootFields => limits_opt - .map(|limits| opentelemetry::Value::I64(limits.root_fields as i64)), - Query::String => request - .supergraph_request - .body() - .query - .clone() - .or_else(|| default.clone()) - .map(opentelemetry::Value::from), - } - } - SubgraphSelector::SubgraphQuery { default, .. } => request - .subgraph_request - .body() - .query - .clone() - .or_else(|| default.clone()) - .map(opentelemetry::Value::from), - SubgraphSelector::SubgraphQueryVariable { - subgraph_query_variable, - default, - .. - } => request - .subgraph_request - .body() - .variables - .get(&ByteString::from(subgraph_query_variable.as_str())) - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - - SubgraphSelector::SupergraphQueryVariable { - supergraph_query_variable, - default, - .. - } => request - .supergraph_request - .body() - .variables - .get(&ByteString::from(supergraph_query_variable.as_str())) - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - SubgraphSelector::SubgraphRequestHeader { - subgraph_request_header, - default, - .. - } => request - .subgraph_request - .headers() - .get(subgraph_request_header) - .and_then(|h| Some(h.to_str().ok()?.to_string())) - .or_else(|| default.clone()) - .map(opentelemetry::Value::from), - SubgraphSelector::SupergraphRequestHeader { - supergraph_request_header, - default, - .. - } => request - .supergraph_request - .headers() - .get(supergraph_request_header) - .and_then(|h| Some(h.to_str().ok()?.to_string())) - .or_else(|| default.clone()) - .map(opentelemetry::Value::from), - SubgraphSelector::RequestContext { - request_context, - default, - .. - } => request - .context - .get::<_, serde_json_bytes::Value>(request_context) - .ok() - .flatten() - .as_ref() - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - SubgraphSelector::Baggage { - baggage: baggage_name, - default, - .. - } => get_baggage(baggage_name).or_else(|| default.maybe_to_otel_value()), - - SubgraphSelector::Env { env, default, .. } => std::env::var(env) - .ok() - .or_else(|| default.clone()) - .map(opentelemetry::Value::from), - SubgraphSelector::Static(val) => Some(val.clone().into()), - SubgraphSelector::StaticField { r#static } => Some(r#static.clone().into()), - - // For response - _ => None, - } - } - - fn on_response(&self, response: &subgraph::Response) -> Option { - match self { - SubgraphSelector::SubgraphResponseHeader { - subgraph_response_header, - default, - .. - } => response - .response - .headers() - .get(subgraph_response_header) - .and_then(|h| Some(h.to_str().ok()?.to_string())) - .or_else(|| default.clone()) - .map(opentelemetry::Value::from), - SubgraphSelector::SubgraphResponseStatus { - subgraph_response_status: response_status, - } => match response_status { - ResponseStatus::Code => Some(opentelemetry::Value::I64( - response.response.status().as_u16() as i64, - )), - ResponseStatus::Reason => response - .response - .status() - .canonical_reason() - .map(|reason| reason.into()), - }, - SubgraphSelector::SubgraphOperationKind { .. } => response - .context - .get::<_, String>(OPERATION_KIND) - .ok() - .flatten() - .map(opentelemetry::Value::from), - SubgraphSelector::SupergraphOperationKind { .. } => response - .context - .get::<_, String>(OPERATION_KIND) - .ok() - .flatten() - .map(opentelemetry::Value::from), - SubgraphSelector::SupergraphOperationName { - supergraph_operation_name, - default, - .. - } => { - let op_name = response.context.get(OPERATION_NAME).ok().flatten(); - match supergraph_operation_name { - OperationName::String => op_name.or_else(|| default.clone()), - OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { - let mut hasher = sha2::Sha256::new(); - hasher.update(op_name.as_bytes()); - let result = hasher.finalize(); - hex::encode(result) - }), - } - .map(opentelemetry::Value::from) - } - SubgraphSelector::SubgraphName { subgraph_name } if *subgraph_name => response - .subgraph_name - .clone() - .map(opentelemetry::Value::from), - SubgraphSelector::SubgraphResponseBody { - subgraph_response_body, - default, - .. - } => subgraph_response_body - .execute(response.response.body()) - .ok() - .flatten() - .as_ref() - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - SubgraphSelector::SubgraphResponseData { - subgraph_response_data, - default, - .. - } => if let Some(data) = &response.response.body().data { - let val = subgraph_response_data.find(data); - - val.maybe_to_otel_value() - } else { - None - } - .or_else(|| default.maybe_to_otel_value()), - SubgraphSelector::SubgraphResponseErrors { - subgraph_response_errors: subgraph_response_error, - default, - .. - } => { - let errors = response.response.body().errors.clone(); - let data: serde_json_bytes::Value = serde_json_bytes::to_value(errors).ok()?; - - let val = subgraph_response_error.find(&data); - - val.maybe_to_otel_value() - } - .or_else(|| default.maybe_to_otel_value()), - SubgraphSelector::ResponseContext { - response_context, - default, - .. - } => response - .context - .get_json_value(response_context) - .as_ref() - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - SubgraphSelector::OnGraphQLError { - subgraph_on_graphql_error: on_graphql_error, - } if *on_graphql_error => Some((!response.response.body().errors.is_empty()).into()), - SubgraphSelector::Static(val) => Some(val.clone().into()), - SubgraphSelector::StaticField { r#static } => Some(r#static.clone().into()), - SubgraphSelector::Cache { cache, entity_type } => { - let cache_info: CacheSubgraph = response - .context - .get(CacheMetricContextKey::new(response.subgraph_name.clone()?)) - .ok() - .flatten()?; - - match entity_type { - Some(EntityType::All(All::All)) | None => Some( - (cache_info - .0 - .iter() - .fold(0usize, |acc, (_entity_type, cache_hit_miss)| match cache { - CacheKind::Hit => acc + cache_hit_miss.hit, - CacheKind::Miss => acc + cache_hit_miss.miss, - }) as i64) - .into(), - ), - Some(EntityType::Named(entity_type_name)) => { - let res = cache_info.0.iter().fold( - 0usize, - |acc, (entity_type, cache_hit_miss)| { - if entity_type == entity_type_name { - match cache { - CacheKind::Hit => acc + cache_hit_miss.hit, - CacheKind::Miss => acc + cache_hit_miss.miss, - } - } else { - acc - } - }, - ); - - (res != 0).then_some((res as i64).into()) - } - } - } - // For request - _ => None, - } - } - - fn on_error(&self, error: &tower::BoxError, ctx: &Context) -> Option { - match self { - SubgraphSelector::SubgraphOperationKind { .. } => ctx - .get::<_, String>(OPERATION_KIND) - .ok() - .flatten() - .map(opentelemetry::Value::from), - SubgraphSelector::SupergraphOperationKind { .. } => ctx - .get::<_, String>(OPERATION_KIND) - .ok() - .flatten() - .map(opentelemetry::Value::from), - SubgraphSelector::SupergraphOperationName { - supergraph_operation_name, - default, - .. - } => { - let op_name = ctx.get(OPERATION_NAME).ok().flatten(); - match supergraph_operation_name { - OperationName::String => op_name.or_else(|| default.clone()), - OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { - let mut hasher = sha2::Sha256::new(); - hasher.update(op_name.as_bytes()); - let result = hasher.finalize(); - hex::encode(result) - }), - } - .map(opentelemetry::Value::from) - } - SubgraphSelector::Error { .. } => Some(error.to_string().into()), - SubgraphSelector::Static(val) => Some(val.clone().into()), - SubgraphSelector::StaticField { r#static } => Some(r#static.clone().into()), - SubgraphSelector::ResponseContext { - response_context, - default, - .. - } => ctx - .get_json_value(response_context) - .as_ref() - .and_then(|v| v.maybe_to_otel_value()) - .or_else(|| default.maybe_to_otel_value()), - _ => None, - } - } - - fn on_drop(&self) -> Option { - match self { - SubgraphSelector::Static(val) => Some(val.clone().into()), - SubgraphSelector::StaticField { r#static } => Some(r#static.clone().into()), - _ => None, - } - } - - fn is_active(&self, stage: super::Stage) -> bool { - match stage { - super::Stage::Request => matches!( - self, - SubgraphSelector::SubgraphOperationName { .. } - | SubgraphSelector::SupergraphOperationName { .. } - | SubgraphSelector::SubgraphName { .. } - | SubgraphSelector::SubgraphOperationKind { .. } - | SubgraphSelector::SupergraphOperationKind { .. } - | SubgraphSelector::SupergraphQuery { .. } - | SubgraphSelector::SubgraphQuery { .. } - | SubgraphSelector::SubgraphQueryVariable { .. } - | SubgraphSelector::SupergraphQueryVariable { .. } - | SubgraphSelector::SubgraphRequestHeader { .. } - | SubgraphSelector::SupergraphRequestHeader { .. } - | SubgraphSelector::RequestContext { .. } - | SubgraphSelector::Baggage { .. } - | SubgraphSelector::Env { .. } - | SubgraphSelector::Static(_) - | SubgraphSelector::StaticField { .. } - ), - super::Stage::Response => matches!( - self, - SubgraphSelector::SubgraphResponseHeader { .. } - | SubgraphSelector::SubgraphResponseStatus { .. } - | SubgraphSelector::SubgraphOperationKind { .. } - | SubgraphSelector::SupergraphOperationKind { .. } - | SubgraphSelector::SupergraphOperationName { .. } - | SubgraphSelector::SubgraphName { .. } - | SubgraphSelector::SubgraphResponseBody { .. } - | SubgraphSelector::SubgraphResponseData { .. } - | SubgraphSelector::SubgraphResponseErrors { .. } - | SubgraphSelector::ResponseContext { .. } - | SubgraphSelector::OnGraphQLError { .. } - | SubgraphSelector::Static(_) - | SubgraphSelector::StaticField { .. } - | SubgraphSelector::Cache { .. } - ), - super::Stage::ResponseEvent => false, - super::Stage::ResponseField => false, - super::Stage::Error => matches!( - self, - SubgraphSelector::SubgraphOperationKind { .. } - | SubgraphSelector::SupergraphOperationKind { .. } - | SubgraphSelector::SupergraphOperationName { .. } - | SubgraphSelector::Error { .. } - | SubgraphSelector::Static(_) - | SubgraphSelector::StaticField { .. } - | SubgraphSelector::ResponseContext { .. } - ), - super::Stage::Drop => matches!( - self, - SubgraphSelector::Static(_) | SubgraphSelector::StaticField { .. } - ), - } - } -} - -#[cfg(test)] -mod test { - use std::str::FromStr; - use std::sync::Arc; - - use http::StatusCode; - use opentelemetry::baggage::BaggageExt; - use opentelemetry::trace::SpanContext; - use opentelemetry::trace::SpanId; - use opentelemetry::trace::TraceContextExt; - use opentelemetry::trace::TraceFlags; - use opentelemetry::trace::TraceId; - use opentelemetry::trace::TraceState; - use opentelemetry::Context; - use opentelemetry::KeyValue; - use opentelemetry_api::StringValue; - use serde_json::json; - use serde_json_bytes::path::JsonPathInst; - use tower::BoxError; - use tracing::span; - use tracing::subscriber; - use tracing_subscriber::layer::SubscriberExt; - - use crate::context::OPERATION_KIND; - use crate::context::OPERATION_NAME; - use crate::graphql; - use crate::plugins::cache::entity::CacheHitMiss; - use crate::plugins::cache::entity::CacheSubgraph; - use crate::plugins::cache::metrics::CacheMetricContextKey; - use crate::plugins::telemetry::config::AttributeValue; - use crate::plugins::telemetry::config_new::selectors::All; - use crate::plugins::telemetry::config_new::selectors::CacheKind; - use crate::plugins::telemetry::config_new::selectors::EntityType; - use crate::plugins::telemetry::config_new::selectors::OperationKind; - use crate::plugins::telemetry::config_new::selectors::OperationName; - use crate::plugins::telemetry::config_new::selectors::Query; - use crate::plugins::telemetry::config_new::selectors::ResponseStatus; - use crate::plugins::telemetry::config_new::selectors::RouterSelector; - use crate::plugins::telemetry::config_new::selectors::SubgraphQuery; - use crate::plugins::telemetry::config_new::selectors::SubgraphSelector; - use crate::plugins::telemetry::config_new::selectors::SupergraphSelector; - use crate::plugins::telemetry::config_new::selectors::TraceIdFormat; - use crate::plugins::telemetry::config_new::Selector; - use crate::plugins::telemetry::otel; - use crate::query_planner::APOLLO_OPERATION_ID; - use crate::services::FIRST_EVENT_CONTEXT_KEY; - use crate::spec::operation_limits::OperationLimits; - - #[test] - fn router_static() { - let selector = RouterSelector::Static("test_static".to_string()); - assert_eq!( - selector - .on_request( - &crate::services::RouterRequest::fake_builder() - .build() - .unwrap() - ) - .unwrap(), - "test_static".into() - ); - assert_eq!(selector.on_drop().unwrap(), "test_static".into()); - } - - #[test] - fn router_static_field() { - let selector = RouterSelector::StaticField { - r#static: "test_static".to_string().into(), - }; - assert_eq!( - selector - .on_request( - &crate::services::RouterRequest::fake_builder() - .build() - .unwrap() - ) - .unwrap(), - "test_static".into() - ); - assert_eq!(selector.on_drop().unwrap(), "test_static".into()); - } - - #[test] - fn router_request_header() { - let selector = RouterSelector::RequestHeader { - request_header: "header_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - assert_eq!( - selector - .on_request( - &crate::services::RouterRequest::fake_builder() - .header("header_key", "header_value") - .build() - .unwrap() - ) - .unwrap(), - "header_value".into() - ); - - assert_eq!( - selector - .on_request( - &crate::services::RouterRequest::fake_builder() - .build() - .unwrap() - ) - .unwrap(), - "defaulted".into() - ); - - assert_eq!( - selector.on_response( - &crate::services::RouterResponse::fake_builder() - .context(crate::context::Context::default()) - .header("header_key", "header_value") - .data(json!({})) - .build() - .unwrap() - ), - None - ); - } - - #[test] - fn router_request_context() { - let selector = RouterSelector::RequestContext { - request_context: "context_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - let context = crate::context::Context::new(); - let _ = context.insert("context_key".to_string(), "context_value".to_string()); - assert_eq!( - selector - .on_request( - &crate::services::RouterRequest::fake_builder() - .context(context.clone()) - .build() - .unwrap() - ) - .unwrap(), - "context_value".into() - ); - - assert_eq!( - selector - .on_request( - &crate::services::RouterRequest::fake_builder() - .build() - .unwrap() - ) - .unwrap(), - "defaulted".into() - ); - assert_eq!( - selector.on_response( - &crate::services::RouterResponse::fake_builder() - .context(context) - .build() - .unwrap() - ), - None - ); - } - - #[test] - fn router_response_context() { - let selector = RouterSelector::ResponseContext { - response_context: "context_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - let context = crate::context::Context::new(); - let _ = context.insert("context_key".to_string(), "context_value".to_string()); - assert_eq!( - selector - .on_response( - &crate::services::RouterResponse::fake_builder() - .context(context.clone()) - .build() - .unwrap() - ) - .unwrap(), - "context_value".into() - ); - - assert_eq!( - selector - .on_error(&BoxError::from(String::from("my error")), &context) - .unwrap(), - "context_value".into() - ); - - assert_eq!( - selector - .on_response( - &crate::services::RouterResponse::fake_builder() - .build() - .unwrap() - ) - .unwrap(), - "defaulted".into() - ); - assert_eq!( - selector.on_request( - &crate::services::RouterRequest::fake_builder() - .context(context) - .build() - .unwrap() - ), - None - ); - } - - #[test] - fn router_response_header() { - let selector = RouterSelector::ResponseHeader { - response_header: "header_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - assert_eq!( - selector - .on_response( - &crate::services::RouterResponse::fake_builder() - .header("header_key", "header_value") - .build() - .unwrap() - ) - .unwrap(), - "header_value".into() - ); - - assert_eq!( - selector - .on_response( - &crate::services::RouterResponse::fake_builder() - .build() - .unwrap() - ) - .unwrap(), - "defaulted".into() - ); - - assert_eq!( - selector.on_request( - &crate::services::RouterRequest::fake_builder() - .header("header_key", "header_value") - .build() - .unwrap() - ), - None - ); - } - - #[test] - fn supergraph_request_header() { - let selector = SupergraphSelector::RequestHeader { - request_header: "header_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - assert_eq!( - selector - .on_request( - &crate::services::SupergraphRequest::fake_builder() - .header("header_key", "header_value") - .build() - .unwrap() - ) - .unwrap(), - "header_value".into() - ); - - assert_eq!( - selector - .on_request( - &crate::services::SupergraphRequest::fake_builder() - .build() - .unwrap() - ) - .unwrap(), - "defaulted".into() - ); - - assert_eq!( - selector.on_response( - &crate::services::SupergraphResponse::fake_builder() - .header("header_key", "header_value") - .build() - .unwrap() - ), - None - ); - } - - #[test] - fn supergraph_static() { - let selector = SupergraphSelector::Static("test_static".to_string()); - assert_eq!( - selector - .on_request( - &crate::services::SupergraphRequest::fake_builder() - .build() - .unwrap() - ) - .unwrap(), - "test_static".into() - ); - assert_eq!(selector.on_drop().unwrap(), "test_static".into()); - } - - #[test] - fn supergraph_static_field() { - let selector = SupergraphSelector::StaticField { - r#static: "test_static".to_string().into(), - }; - assert_eq!( - selector - .on_request( - &crate::services::SupergraphRequest::fake_builder() - .build() - .unwrap() - ) - .unwrap(), - "test_static".into() - ); - assert_eq!(selector.on_drop().unwrap(), "test_static".into()); - } - - #[test] - fn supergraph_response_header() { - let selector = SupergraphSelector::ResponseHeader { - response_header: "header_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - assert_eq!( - selector - .on_response( - &crate::services::SupergraphResponse::fake_builder() - .header("header_key", "header_value") - .build() - .unwrap() - ) - .unwrap(), - "header_value".into() - ); - - assert_eq!( - selector - .on_response( - &crate::services::SupergraphResponse::fake_builder() - .build() - .unwrap() - ) - .unwrap(), - "defaulted".into() - ); - - assert_eq!( - selector.on_request( - &crate::services::SupergraphRequest::fake_builder() - .header("header_key", "header_value") - .build() - .unwrap() - ), - None - ); - } - - #[test] - fn subgraph_static() { - let selector = SubgraphSelector::Static("test_static".to_string()); - assert_eq!( - selector - .on_request( - &crate::services::SubgraphRequest::fake_builder() - .supergraph_request(Arc::new( - http::Request::builder() - .body(graphql::Request::builder().build()) - .unwrap() - )) - .build() - ) - .unwrap(), - "test_static".into() - ); - assert_eq!(selector.on_drop().unwrap(), "test_static".into()); - } - - #[test] - fn subgraph_static_field() { - let selector = SubgraphSelector::StaticField { - r#static: "test_static".to_string().into(), - }; - assert_eq!( - selector - .on_request( - &crate::services::SubgraphRequest::fake_builder() - .supergraph_request(Arc::new( - http::Request::builder() - .body(graphql::Request::builder().build()) - .unwrap() - )) - .build() - ) - .unwrap(), - "test_static".into() - ); - assert_eq!(selector.on_drop().unwrap(), "test_static".into()); - } - - #[test] - fn subgraph_supergraph_request_header() { - let selector = SubgraphSelector::SupergraphRequestHeader { - supergraph_request_header: "header_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - assert_eq!( - selector - .on_request( - &crate::services::SubgraphRequest::fake_builder() - .supergraph_request(Arc::new( - http::Request::builder() - .header("header_key", "header_value") - .body(graphql::Request::builder().build()) - .unwrap() - )) - .build() - ) - .unwrap(), - "header_value".into() - ); - - assert_eq!( - selector - .on_request(&crate::services::SubgraphRequest::fake_builder().build()) - .unwrap(), - "defaulted".into() - ); - - assert_eq!( - selector.on_response( - &crate::services::SubgraphResponse::fake2_builder() - .header("header_key", "header_value") - .build() - .unwrap() - ), - None - ); - } - - #[test] - fn subgraph_subgraph_request_header() { - let selector = SubgraphSelector::SubgraphRequestHeader { - subgraph_request_header: "header_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - assert_eq!( - selector - .on_request( - &crate::services::SubgraphRequest::fake_builder() - .subgraph_request( - http::Request::builder() - .header("header_key", "header_value") - .body(graphql::Request::fake_builder().build()) - .unwrap() - ) - .build() - ) - .unwrap(), - "header_value".into() - ); - - assert_eq!( - selector - .on_request(&crate::services::SubgraphRequest::fake_builder().build()) - .unwrap(), - "defaulted".into() - ); - - assert_eq!( - selector.on_response( - &crate::services::SubgraphResponse::fake2_builder() - .header("header_key", "header_value") - .build() - .unwrap() - ), - None - ); - } - - #[test] - fn subgraph_subgraph_response_header() { - let selector = SubgraphSelector::SubgraphResponseHeader { - subgraph_response_header: "header_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - assert_eq!( - selector - .on_response( - &crate::services::SubgraphResponse::fake2_builder() - .header("header_key", "header_value") - .build() - .unwrap() - ) - .unwrap(), - "header_value".into() - ); - - assert_eq!( - selector - .on_response( - &crate::services::SubgraphResponse::fake2_builder() - .build() - .unwrap() - ) - .unwrap(), - "defaulted".into() - ); - - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .subgraph_request( - http::Request::builder() - .header("header_key", "header_value") - .body(graphql::Request::fake_builder().build()) - .unwrap() - ) - .build() - ), - None - ); - } - - #[test] - fn supergraph_request_context() { - let selector = SupergraphSelector::RequestContext { - request_context: "context_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - let context = crate::context::Context::new(); - let _ = context.insert("context_key".to_string(), "context_value".to_string()); - assert_eq!( - selector - .on_request( - &crate::services::SupergraphRequest::fake_builder() - .context(context.clone()) - .build() - .unwrap() - ) - .unwrap(), - "context_value".into() - ); - - assert_eq!( - selector - .on_request( - &crate::services::SupergraphRequest::fake_builder() - .build() - .unwrap() - ) - .unwrap(), - "defaulted".into() - ); - assert_eq!( - selector.on_response( - &crate::services::SupergraphResponse::fake_builder() - .context(context) - .build() - .unwrap() - ), - None - ); - } - - #[test] - fn supergraph_is_primary() { - let selector = SupergraphSelector::IsPrimaryResponse { - is_primary_response: true, - }; - let context = crate::context::Context::new(); - let _ = context.insert(FIRST_EVENT_CONTEXT_KEY, true); - assert_eq!( - selector - .on_response( - &crate::services::SupergraphResponse::fake_builder() - .context(context.clone()) - .build() - .unwrap() - ) - .unwrap(), - true.into() - ); - assert_eq!( - selector - .on_response_event(&crate::graphql::Response::builder().build(), &context) - .unwrap(), - true.into() - ); - } - - #[test] - fn supergraph_response_context() { - let selector = SupergraphSelector::ResponseContext { - response_context: "context_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - let context = crate::context::Context::new(); - let _ = context.insert("context_key".to_string(), "context_value".to_string()); - assert_eq!( - selector - .on_response( - &crate::services::SupergraphResponse::fake_builder() - .context(context.clone()) - .build() - .unwrap() - ) - .unwrap(), - "context_value".into() - ); - - assert_eq!( - selector - .on_error(&BoxError::from(String::from("my error")), &context) - .unwrap(), - "context_value".into() - ); - - assert_eq!( - selector - .on_response( - &crate::services::SupergraphResponse::fake_builder() - .build() - .unwrap() - ) - .unwrap(), - "defaulted".into() - ); - assert_eq!( - selector.on_request( - &crate::services::SupergraphRequest::fake_builder() - .context(context) - .build() - .unwrap() - ), - None - ); - } - - #[test] - fn subgraph_request_context() { - let selector = SubgraphSelector::RequestContext { - request_context: "context_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - let context = crate::context::Context::new(); - let _ = context.insert("context_key".to_string(), "context_value".to_string()); - assert_eq!( - selector - .on_request( - &crate::services::SubgraphRequest::fake_builder() - .context(context.clone()) - .build() - ) - .unwrap(), - "context_value".into() - ); - - assert_eq!( - selector - .on_request(&crate::services::SubgraphRequest::fake_builder().build()) - .unwrap(), - "defaulted".into() - ); - assert_eq!( - selector.on_response( - &crate::services::SubgraphResponse::fake2_builder() - .context(context) - .build() - .unwrap() - ), - None - ); - } - - #[test] - fn subgraph_response_context() { - let selector = SubgraphSelector::ResponseContext { - response_context: "context_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - let context = crate::context::Context::new(); - let _ = context.insert("context_key".to_string(), "context_value".to_string()); - assert_eq!( - selector - .on_response( - &crate::services::SubgraphResponse::fake2_builder() - .context(context.clone()) - .build() - .unwrap() - ) - .unwrap(), - "context_value".into() - ); - - assert_eq!( - selector - .on_error(&BoxError::from(String::from("my error")), &context) - .unwrap(), - "context_value".into() - ); - - assert_eq!( - selector - .on_response( - &crate::services::SubgraphResponse::fake2_builder() - .build() - .unwrap() - ) - .unwrap(), - "defaulted".into() - ); - - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .context(context) - .build() - ), - None - ); - } - - #[test] - fn router_baggage() { - let subscriber = tracing_subscriber::registry().with(otel::layer()); - subscriber::with_default(subscriber, || { - let selector = RouterSelector::Baggage { - baggage: "baggage_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - let span_context = SpanContext::new( - TraceId::from_u128(42), - SpanId::from_u64(42), - // Make sure it's sampled if not, it won't create anything at the otel layer - TraceFlags::default().with_sampled(true), - false, - TraceState::default(), - ); - let _context_guard = Context::new() - .with_remote_span_context(span_context) - .with_baggage(vec![KeyValue::new("baggage_key", "baggage_value")]) - .attach(); - assert_eq!( - selector - .on_request( - &crate::services::RouterRequest::fake_builder() - .build() - .unwrap(), - ) - .unwrap(), - "defaulted".into() - ); - - let span = span!(tracing::Level::INFO, "test"); - let _guard = span.enter(); - assert_eq!( - selector - .on_request( - &crate::services::RouterRequest::fake_builder() - .build() - .unwrap(), - ) - .unwrap(), - "baggage_value".into() - ); - }); - } - - #[test] - fn supergraph_baggage() { - let subscriber = tracing_subscriber::registry().with(otel::layer()); - subscriber::with_default(subscriber, || { - let selector = SupergraphSelector::Baggage { - baggage: "baggage_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - let span_context = SpanContext::new( - TraceId::from_u128(42), - SpanId::from_u64(42), - // Make sure it's sampled if not, it won't create anything at the otel layer - TraceFlags::default().with_sampled(true), - false, - TraceState::default(), - ); - assert_eq!( - selector - .on_request( - &crate::services::SupergraphRequest::fake_builder() - .build() - .unwrap(), - ) - .unwrap(), - "defaulted".into() - ); - let _outer_guard = Context::new() - .with_remote_span_context(span_context) - .with_baggage(vec![KeyValue::new("baggage_key", "baggage_value")]) - .attach(); - let span = span!(tracing::Level::INFO, "test"); - let _guard = span.enter(); - - assert_eq!( - selector - .on_request( - &crate::services::SupergraphRequest::fake_builder() - .build() - .unwrap(), - ) - .unwrap(), - "baggage_value".into() - ); - }); - } - - #[test] - fn subgraph_baggage() { - let subscriber = tracing_subscriber::registry().with(otel::layer()); - subscriber::with_default(subscriber, || { - let selector = SubgraphSelector::Baggage { - baggage: "baggage_key".to_string(), - redact: None, - default: Some("defaulted".into()), - }; - let span_context = SpanContext::new( - TraceId::from_u128(42), - SpanId::from_u64(42), - // Make sure it's sampled if not, it won't create anything at the otel layer - TraceFlags::default().with_sampled(true), - false, - TraceState::default(), - ); - assert_eq!( - selector - .on_request(&crate::services::SubgraphRequest::fake_builder().build()) - .unwrap(), - "defaulted".into() - ); - let _outer_guard = Context::new() - .with_baggage(vec![KeyValue::new("baggage_key", "baggage_value")]) - .with_remote_span_context(span_context) - .attach(); - - let span = span!(tracing::Level::INFO, "test"); - let _guard = span.enter(); - - assert_eq!( - selector - .on_request(&crate::services::SubgraphRequest::fake_builder().build()) - .unwrap(), - "baggage_value".into() - ); - }); - } - - #[test] - fn router_trace_id() { - let subscriber = tracing_subscriber::registry().with(otel::layer()); - subscriber::with_default(subscriber, || { - let selector = RouterSelector::TraceId { - trace_id: TraceIdFormat::Hexadecimal, - }; - assert_eq!( - selector.on_request( - &crate::services::RouterRequest::fake_builder() - .build() - .unwrap(), - ), - None - ); - - let span_context = SpanContext::new( - TraceId::from_u128(42), - SpanId::from_u64(42), - TraceFlags::default().with_sampled(true), - false, - TraceState::default(), - ); - let _context = Context::current() - .with_remote_span_context(span_context) - .attach(); - let span = span!(tracing::Level::INFO, "test"); - let _guard = span.enter(); - - assert_eq!( - selector - .on_request( - &crate::services::RouterRequest::fake_builder() - .build() - .unwrap(), - ) - .unwrap(), - "0000000000000000000000000000002a".into() - ); - - let selector = RouterSelector::TraceId { - trace_id: TraceIdFormat::Datadog, - }; - - assert_eq!( - selector - .on_request( - &crate::services::RouterRequest::fake_builder() - .build() - .unwrap(), - ) - .unwrap(), - opentelemetry::Value::String("42".into()) - ); - - let selector = RouterSelector::TraceId { - trace_id: TraceIdFormat::Uuid, - }; - - assert_eq!( - selector - .on_request( - &crate::services::RouterRequest::fake_builder() - .build() - .unwrap(), - ) - .unwrap(), - opentelemetry::Value::String("00000000-0000-0000-0000-00000000002a".into()) - ); - - let selector = RouterSelector::TraceId { - trace_id: TraceIdFormat::Decimal, - }; - - assert_eq!( - selector - .on_request( - &crate::services::RouterRequest::fake_builder() - .build() - .unwrap(), - ) - .unwrap(), - opentelemetry::Value::String("42".into()) - ); - }); - } - - #[test] - fn test_router_studio_trace_id() { - let selector = RouterSelector::StudioOperationId { - studio_operation_id: true, - }; - let ctx = crate::Context::new(); - let _ = ctx.insert(APOLLO_OPERATION_ID, "42".to_string()).unwrap(); - - assert_eq!( - selector - .on_response( - &crate::services::RouterResponse::fake_builder() - .context(ctx) - .build() - .unwrap(), - ) - .unwrap(), - opentelemetry::Value::String("42".into()) - ); - } - - #[test] - fn router_env() { - let selector = RouterSelector::Env { - env: "SELECTOR_ENV_VARIABLE".to_string(), - redact: None, - default: Some("defaulted".to_string()), - }; - assert_eq!( - selector.on_request( - &crate::services::RouterRequest::fake_builder() - .build() - .unwrap(), - ), - Some("defaulted".into()) - ); - // Env set - std::env::set_var("SELECTOR_ENV_VARIABLE", "env_value"); - - assert_eq!( - selector.on_request( - &crate::services::RouterRequest::fake_builder() - .build() - .unwrap(), - ), - Some("env_value".into()) - ); - } - - #[test] - fn router_operation_name_string() { - let selector = RouterSelector::OperationName { - operation_name: OperationName::String, - redact: None, - default: Some("defaulted".to_string()), - }; - let context = crate::context::Context::new(); - assert_eq!( - selector.on_response( - &crate::services::RouterResponse::fake_builder() - .context(context.clone()) - .build() - .unwrap(), - ), - Some("defaulted".into()) - ); - let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); - assert_eq!( - selector.on_response( - &crate::services::RouterResponse::fake_builder() - .context(context.clone()) - .build() - .unwrap(), - ), - Some("topProducts".into()) - ); - assert_eq!( - selector.on_error(&BoxError::from(String::from("my error")), &context), - Some("topProducts".into()) - ); - } - - #[test] - fn supergraph_env() { - let selector = SupergraphSelector::Env { - env: "SELECTOR_SUPERGRAPH_ENV_VARIABLE".to_string(), - redact: None, - default: Some("defaulted".to_string()), - }; - assert_eq!( - selector.on_request( - &crate::services::SupergraphRequest::fake_builder() - .build() - .unwrap(), - ), - Some("defaulted".into()) - ); - // Env set - std::env::set_var("SELECTOR_SUPERGRAPH_ENV_VARIABLE", "env_value"); - - assert_eq!( - selector.on_request( - &crate::services::SupergraphRequest::fake_builder() - .build() - .unwrap(), - ), - Some("env_value".into()) - ); - std::env::remove_var("SELECTOR_SUPERGRAPH_ENV_VARIABLE"); - } - - #[test] - fn subgraph_env() { - let selector = SubgraphSelector::Env { - env: "SELECTOR_SUBGRAPH_ENV_VARIABLE".to_string(), - redact: None, - default: Some("defaulted".to_string()), - }; - assert_eq!( - selector.on_request(&crate::services::SubgraphRequest::fake_builder().build()), - Some("defaulted".into()) - ); - // Env set - std::env::set_var("SELECTOR_SUBGRAPH_ENV_VARIABLE", "env_value"); - - assert_eq!( - selector.on_request(&crate::services::SubgraphRequest::fake_builder().build()), - Some("env_value".into()) - ); - std::env::remove_var("SELECTOR_SUBGRAPH_ENV_VARIABLE"); - } - - #[test] - fn supergraph_operation_kind() { - let selector = SupergraphSelector::OperationKind { - operation_kind: OperationKind::String, - }; - let context = crate::context::Context::new(); - let _ = context.insert(OPERATION_KIND, "query".to_string()); - // For now operation kind is contained in context - assert_eq!( - selector.on_request( - &crate::services::SupergraphRequest::fake_builder() - .context(context) - .build() - .unwrap(), - ), - Some("query".into()) - ); - } - - #[test] - fn subgraph_operation_kind() { - let selector = SubgraphSelector::SupergraphOperationKind { - supergraph_operation_kind: OperationKind::String, - }; - let context = crate::context::Context::new(); - let _ = context.insert(OPERATION_KIND, "query".to_string()); - // For now operation kind is contained in context - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .context(context.clone()) - .build(), - ), - Some("query".into()) - ); - assert_eq!( - selector.on_response( - &crate::services::SubgraphResponse::fake_builder() - .context(context) - .build(), - ), - Some("query".into()) - ); - } - - #[test] - fn subgraph_name() { - let selector = SubgraphSelector::SubgraphName { - subgraph_name: true, - }; - let context = crate::context::Context::new(); - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .context(context.clone()) - .subgraph_name("test".to_string()) - .build(), - ), - Some("test".into()) - ); - assert_eq!( - selector.on_response( - &crate::services::SubgraphResponse::fake_builder() - .context(context) - .subgraph_name("test".to_string()) - .build(), - ), - Some("test".into()) - ); - } - - #[test] - fn supergraph_operation_name_string() { - let selector = SupergraphSelector::OperationName { - operation_name: OperationName::String, - redact: None, - default: Some("defaulted".to_string()), - }; - let context = crate::context::Context::new(); - assert_eq!( - selector.on_request( - &crate::services::SupergraphRequest::fake_builder() - .context(context.clone()) - .build() - .unwrap(), - ), - Some("defaulted".into()) - ); - let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); - // For now operation kind is contained in context - assert_eq!( - selector.on_request( - &crate::services::SupergraphRequest::fake_builder() - .context(context) - .build() - .unwrap(), - ), - Some("topProducts".into()) - ); - } - - #[test] - fn subgraph_cache_hit_all_entities() { - let selector = SubgraphSelector::Cache { - cache: CacheKind::Hit, - entity_type: Some(EntityType::All(All::All)), - }; - let context = crate::context::Context::new(); - assert_eq!( - selector.on_response( - &crate::services::SubgraphResponse::fake_builder() - .subgraph_name("test".to_string()) - .context(context.clone()) - .build(), - ), - None - ); - let cache_info = CacheSubgraph( - [ - ("Products".to_string(), CacheHitMiss { hit: 3, miss: 0 }), - ("Reviews".to_string(), CacheHitMiss { hit: 2, miss: 0 }), - ] - .into_iter() - .collect(), - ); - let _ = context - .insert(CacheMetricContextKey::new("test".to_string()), cache_info) - .unwrap(); - assert_eq!( - selector.on_response( - &crate::services::SubgraphResponse::fake_builder() - .subgraph_name("test".to_string()) - .context(context.clone()) - .build(), - ), - Some(opentelemetry::Value::I64(5)) - ); - } - - #[test] - fn subgraph_cache_hit_one_entity() { - let selector = SubgraphSelector::Cache { - cache: CacheKind::Hit, - entity_type: Some(EntityType::Named("Reviews".to_string())), - }; - let context = crate::context::Context::new(); - assert_eq!( - selector.on_response( - &crate::services::SubgraphResponse::fake_builder() - .subgraph_name("test".to_string()) - .context(context.clone()) - .build(), - ), - None - ); - let cache_info = CacheSubgraph( - [ - ("Products".to_string(), CacheHitMiss { hit: 3, miss: 0 }), - ("Reviews".to_string(), CacheHitMiss { hit: 2, miss: 0 }), - ] - .into_iter() - .collect(), - ); - let _ = context - .insert(CacheMetricContextKey::new("test".to_string()), cache_info) - .unwrap(); - assert_eq!( - selector.on_response( - &crate::services::SubgraphResponse::fake_builder() - .subgraph_name("test".to_string()) - .context(context.clone()) - .build(), - ), - Some(opentelemetry::Value::I64(2)) - ); - } - - #[test] - fn subgraph_supergraph_operation_name_string() { - let selector = SubgraphSelector::SupergraphOperationName { - supergraph_operation_name: OperationName::String, - redact: None, - default: Some("defaulted".to_string()), - }; - let context = crate::context::Context::new(); - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .context(context.clone()) - .build(), - ), - Some("defaulted".into()) - ); - let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); - // For now operation kind is contained in context - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .context(context.clone()) - .build(), - ), - Some("topProducts".into()) - ); - assert_eq!( - selector.on_response( - &crate::services::SubgraphResponse::fake_builder() - .context(context) - .build(), - ), - Some("topProducts".into()) - ); - } - - #[test] - fn subgraph_subgraph_operation_name_string() { - let selector = SubgraphSelector::SubgraphOperationName { - subgraph_operation_name: OperationName::String, - redact: None, - default: Some("defaulted".to_string()), - }; - assert_eq!( - selector.on_request(&crate::services::SubgraphRequest::fake_builder().build(),), - Some("defaulted".into()) - ); - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .subgraph_request( - ::http::Request::builder() - .uri("http://localhost/graphql") - .body( - graphql::Request::fake_builder() - .operation_name("topProducts") - .build() - ) - .unwrap() - ) - .build(), - ), - Some("topProducts".into()) - ); - } - - #[test] - fn supergraph_operation_name_hash() { - let selector = SupergraphSelector::OperationName { - operation_name: OperationName::Hash, - redact: None, - default: Some("defaulted".to_string()), - }; - let context = crate::context::Context::new(); - assert_eq!( - selector.on_request( - &crate::services::SupergraphRequest::fake_builder() - .context(context.clone()) - .build() - .unwrap(), - ), - Some("96294f50edb8f006f6b0a2dadae50d3c521e9841d07d6395d91060c8ccfed7f0".into()) - ); - - let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); - assert_eq!( - selector.on_request( - &crate::services::SupergraphRequest::fake_builder() - .context(context) - .build() - .unwrap(), - ), - Some("bd141fca26094be97c30afd42e9fc84755b252e7052d8c992358319246bd555a".into()) - ); - } - - #[test] - fn subgraph_supergraph_operation_name_hash() { - let selector = SubgraphSelector::SupergraphOperationName { - supergraph_operation_name: OperationName::Hash, - redact: None, - default: Some("defaulted".to_string()), - }; - let context = crate::context::Context::new(); - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .context(context.clone()) - .build(), - ), - Some("96294f50edb8f006f6b0a2dadae50d3c521e9841d07d6395d91060c8ccfed7f0".into()) - ); - - let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .context(context) - .build(), - ), - Some("bd141fca26094be97c30afd42e9fc84755b252e7052d8c992358319246bd555a".into()) - ); - } - - #[test] - fn subgraph_subgraph_operation_name_hash() { - let selector = SubgraphSelector::SubgraphOperationName { - subgraph_operation_name: OperationName::Hash, - redact: None, - default: Some("defaulted".to_string()), - }; - assert_eq!( - selector.on_request(&crate::services::SubgraphRequest::fake_builder().build()), - Some("96294f50edb8f006f6b0a2dadae50d3c521e9841d07d6395d91060c8ccfed7f0".into()) - ); - - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .subgraph_request( - ::http::Request::builder() - .uri("http://localhost/graphql") - .body( - graphql::Request::fake_builder() - .operation_name("topProducts") - .build() - ) - .unwrap() - ) - .build() - ), - Some("bd141fca26094be97c30afd42e9fc84755b252e7052d8c992358319246bd555a".into()) - ); - } - - #[test] - fn supergraph_query() { - let selector = SupergraphSelector::Query { - query: Query::String, - redact: None, - default: Some("default".to_string()), - }; - assert_eq!( - selector.on_request( - &crate::services::SupergraphRequest::fake_builder() - .query("topProducts{name}") - .build() - .unwrap(), - ), - Some("topProducts{name}".into()) - ); - - assert_eq!( - selector.on_request( - &crate::services::SupergraphRequest::fake_builder() - .build() - .unwrap(), - ), - Some("default".into()) - ); - } - - fn create_select_and_context(query: Query) -> (SupergraphSelector, crate::Context) { - let selector = SupergraphSelector::Query { - query, - redact: None, - default: Some("default".to_string()), - }; - let limits = OperationLimits { - aliases: 1, - depth: 2, - height: 3, - root_fields: 4, - }; - let context = crate::Context::new(); - context - .extensions() - .with_lock(|mut lock| lock.insert::>(limits)); - (selector, context) - } - - #[test] - fn supergraph_query_aliases() { - let (selector, context) = create_select_and_context(Query::Aliases); - assert_eq!( - selector - .on_response( - &crate::services::SupergraphResponse::fake_builder() - .context(context) - .build() - .unwrap() - ) - .unwrap(), - 1.into() - ); - } - - #[test] - fn supergraph_query_depth() { - let (selector, context) = create_select_and_context(Query::Depth); - assert_eq!( - selector - .on_response( - &crate::services::SupergraphResponse::fake_builder() - .context(context) - .build() - .unwrap() - ) - .unwrap(), - 2.into() - ); - } - - #[test] - fn supergraph_query_height() { - let (selector, context) = create_select_and_context(Query::Height); - assert_eq!( - selector - .on_response( - &crate::services::SupergraphResponse::fake_builder() - .context(context) - .build() - .unwrap() - ) - .unwrap(), - 3.into() - ); - } - - #[test] - fn supergraph_query_root_fields() { - let (selector, context) = create_select_and_context(Query::RootFields); - assert_eq!( - selector - .on_response( - &crate::services::SupergraphResponse::fake_builder() - .context(context.clone()) - .build() - .unwrap() - ) - .unwrap(), - 4.into() - ); - assert_eq!( - selector - .on_response_event(&crate::graphql::Response::builder().build(), &context) - .unwrap(), - 4.into() - ); - } - - #[test] - fn subgraph_supergraph_query() { - let selector = SubgraphSelector::SupergraphQuery { - supergraph_query: Query::String, - redact: None, - default: Some("default".to_string()), - }; - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .supergraph_request(Arc::new( - http::Request::builder() - .body( - graphql::Request::fake_builder() - .query("topProducts{name}") - .build() - ) - .unwrap() - )) - .build(), - ), - Some("topProducts{name}".into()) - ); - - assert_eq!( - selector.on_request(&crate::services::SubgraphRequest::fake_builder().build(),), - Some("default".into()) - ); - } - - #[test] - fn subgraph_subgraph_query() { - let selector = SubgraphSelector::SubgraphQuery { - subgraph_query: SubgraphQuery::String, - redact: None, - default: Some("default".to_string()), - }; - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .subgraph_request( - http::Request::builder() - .body( - graphql::Request::fake_builder() - .query("topProducts{name}") - .build() - ) - .unwrap() - ) - .build(), - ), - Some("topProducts{name}".into()) - ); - - assert_eq!( - selector.on_request(&crate::services::SubgraphRequest::fake_builder().build(),), - Some("default".into()) - ); - } - - #[test] - fn router_response_status_code() { - let selector = RouterSelector::ResponseStatus { - response_status: ResponseStatus::Code, - }; - assert_eq!( - selector - .on_response( - &crate::services::RouterResponse::fake_builder() - .status_code(StatusCode::NO_CONTENT) - .build() - .unwrap() - ) - .unwrap(), - opentelemetry::Value::I64(204) - ); - } - - #[test] - fn subgraph_subgraph_response_status_code() { - let selector = SubgraphSelector::SubgraphResponseStatus { - subgraph_response_status: ResponseStatus::Code, - }; - assert_eq!( - selector - .on_response( - &crate::services::SubgraphResponse::fake_builder() - .status_code(StatusCode::NO_CONTENT) - .build() - ) - .unwrap(), - opentelemetry::Value::I64(204) - ); - } - - #[test] - fn subgraph_subgraph_response_data() { - let selector = SubgraphSelector::SubgraphResponseData { - subgraph_response_data: JsonPathInst::from_str("$.hello").unwrap(), - redact: None, - default: None, - }; - assert_eq!( - selector - .on_response( - &crate::services::SubgraphResponse::fake_builder() - .data(serde_json_bytes::json!({ - "hello": "bonjour" - })) - .build() - ) - .unwrap(), - opentelemetry::Value::String("bonjour".into()) - ); - - assert_eq!( - selector - .on_response( - &crate::services::SubgraphResponse::fake_builder() - .data(serde_json_bytes::json!({ - "hello": ["bonjour", "hello", "ciao"] - })) - .build() - ) - .unwrap(), - opentelemetry::Value::Array( - vec![ - StringValue::from("bonjour"), - StringValue::from("hello"), - StringValue::from("ciao") - ] - .into() - ) - ); - - assert!(selector - .on_response( - &crate::services::SubgraphResponse::fake_builder() - .data(serde_json_bytes::json!({ - "hi": ["bonjour", "hello", "ciao"] - })) - .build() - ) - .is_none()); - - let selector = SubgraphSelector::SubgraphResponseData { - subgraph_response_data: JsonPathInst::from_str("$.hello.*.greeting").unwrap(), - redact: None, - default: None, - }; - assert_eq!( - selector - .on_response( - &crate::services::SubgraphResponse::fake_builder() - .data(serde_json_bytes::json!({ - "hello": { - "french": { - "greeting": "bonjour" - }, - "english": { - "greeting": "hello" - }, - "italian": { - "greeting": "ciao" - } - } - })) - .build() - ) - .unwrap(), - opentelemetry::Value::Array( - vec![ - StringValue::from("bonjour"), - StringValue::from("hello"), - StringValue::from("ciao") - ] - .into() - ) - ); - } - - #[test] - fn subgraph_on_graphql_error() { - let selector = SubgraphSelector::OnGraphQLError { - subgraph_on_graphql_error: true, - }; - assert_eq!( - selector - .on_response( - &crate::services::SubgraphResponse::fake_builder() - .error( - graphql::Error::builder() - .message("not found") - .extension_code("NOT_FOUND") - .build() - ) - .build() - ) - .unwrap(), - opentelemetry::Value::Bool(true) - ); - - assert_eq!( - selector - .on_response( - &crate::services::SubgraphResponse::fake_builder() - .data(serde_json_bytes::json!({ - "hello": ["bonjour", "hello", "ciao"] - })) - .build() - ) - .unwrap(), - opentelemetry::Value::Bool(false) - ); - } - - #[test] - fn router_response_status_reason() { - let selector = RouterSelector::ResponseStatus { - response_status: ResponseStatus::Reason, - }; - assert_eq!( - selector - .on_response( - &crate::services::RouterResponse::fake_builder() - .status_code(StatusCode::NO_CONTENT) - .build() - .unwrap() - ) - .unwrap(), - "No Content".into() - ); - } - - #[test] - fn subgraph_subgraph_response_status_reason() { - let selector = SubgraphSelector::SubgraphResponseStatus { - subgraph_response_status: ResponseStatus::Reason, - }; - assert_eq!( - selector - .on_response( - &crate::services::SubgraphResponse::fake_builder() - .status_code(StatusCode::NO_CONTENT) - .build() - ) - .unwrap(), - "No Content".into() - ); - } - - #[test] - fn supergraph_query_variable() { - let selector = SupergraphSelector::QueryVariable { - query_variable: "key".to_string(), - redact: None, - default: Some(AttributeValue::String("default".to_string())), - }; - assert_eq!( - selector.on_request( - &crate::services::SupergraphRequest::fake_builder() - .variable("key", "value") - .build() - .unwrap(), - ), - Some("value".into()) - ); - - assert_eq!( - selector.on_request( - &crate::services::SupergraphRequest::fake_builder() - .build() - .unwrap(), - ), - Some("default".into()) - ); - } - - #[test] - fn subgraph_supergraph_query_variable() { - let selector = SubgraphSelector::SupergraphQueryVariable { - supergraph_query_variable: "key".to_string(), - redact: None, - default: Some(AttributeValue::String("default".to_string())), - }; - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .supergraph_request(Arc::new( - http::Request::builder() - .body( - graphql::Request::fake_builder() - .variable("key", "value") - .build() - ) - .unwrap() - )) - .build(), - ), - Some("value".into()) - ); - - assert_eq!( - selector.on_request(&crate::services::SubgraphRequest::fake_builder().build(),), - Some("default".into()) - ); - } - - #[test] - fn subgraph_subgraph_query_variable() { - let selector = SubgraphSelector::SubgraphQueryVariable { - subgraph_query_variable: "key".to_string(), - redact: None, - default: Some("default".into()), - }; - assert_eq!( - selector.on_request( - &crate::services::SubgraphRequest::fake_builder() - .subgraph_request( - http::Request::builder() - .body( - graphql::Request::fake_builder() - .variable("key", "value") - .build() - ) - .unwrap() - ) - .build(), - ), - Some("value".into()) - ); - - assert_eq!( - selector.on_request(&crate::services::SubgraphRequest::fake_builder().build(),), - Some("default".into()) - ); - } -} diff --git a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__connector_events_request@logs.snap b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__connector_events_request@logs.snap new file mode 100644 index 0000000000..097cddab9d --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__connector_events_request@logs.snap @@ -0,0 +1,14 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/events.rs +expression: yaml +--- +- fields: + kind: my.request.event + level: INFO + message: my request event message + span: + name: connect_request + otel.kind: INTERNAL + spans: + - name: connect_request + otel.kind: INTERNAL diff --git a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__connector_events_response@logs.snap b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__connector_events_response@logs.snap new file mode 100644 index 0000000000..f0c68cf9f7 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__connector_events_response@logs.snap @@ -0,0 +1,14 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/events.rs +expression: yaml +--- +- fields: + kind: my.response.event + level: ERROR + message: my response event message + span: + name: connect_request + otel.kind: INTERNAL + spans: + - name: connect_request + otel.kind: INTERNAL diff --git a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__router_events@logs.snap b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__router_events@logs.snap index d06b607b54..b4c9599153 100644 --- a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__router_events@logs.snap +++ b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__router_events@logs.snap @@ -1,44 +1,13 @@ --- source: apollo-router/src/plugins/telemetry/config_new/events.rs expression: yaml +snapshot_kind: text --- -- fields: - kind: router.request - level: INFO - message: "" - span: - http.flavor: HTTP/1.1 - http.method: GET - http.request.method: GET - http.route: "http://example.com/" - name: router - otel.kind: INTERNAL - trace_id: "" - spans: - - http.flavor: HTTP/1.1 - http.method: GET - http.request.method: GET - http.route: "http://example.com/" - name: router - otel.kind: INTERNAL - trace_id: "" - fields: kind: my.request_event level: INFO message: my event message - span: - http.flavor: HTTP/1.1 - http.method: GET - http.request.method: GET - http.route: "http://example.com/" - name: router - otel.kind: INTERNAL - trace_id: "" - spans: - - http.flavor: HTTP/1.1 - http.method: GET - http.request.method: GET - http.route: "http://example.com/" - name: router - otel.kind: INTERNAL - trace_id: "" +- fields: + kind: my.response_event + level: INFO + message: my response event message diff --git a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__router_events_graphql_error@logs.snap b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__router_events_graphql_error@logs.snap index b144d30cad..2f12c713e7 100644 --- a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__router_events_graphql_error@logs.snap +++ b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__router_events_graphql_error@logs.snap @@ -6,21 +6,3 @@ expression: yaml kind: router.response level: INFO message: "" - span: - apollo_private.duration_ns: "[duration]" - http.flavor: HTTP/1.1 - http.method: GET - http.request.method: GET - http.route: "http://example.com/" - name: router - otel.kind: INTERNAL - trace_id: "" - spans: - - apollo_private.duration_ns: "[duration]" - http.flavor: HTTP/1.1 - http.method: GET - http.request.method: GET - http.route: "http://example.com/" - name: router - otel.kind: INTERNAL - trace_id: "" diff --git a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__router_events_graphql_response@logs.snap b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__router_events_graphql_response@logs.snap index b144d30cad..2f12c713e7 100644 --- a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__router_events_graphql_response@logs.snap +++ b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__router_events_graphql_response@logs.snap @@ -6,21 +6,3 @@ expression: yaml kind: router.response level: INFO message: "" - span: - apollo_private.duration_ns: "[duration]" - http.flavor: HTTP/1.1 - http.method: GET - http.request.method: GET - http.route: "http://example.com/" - name: router - otel.kind: INTERNAL - trace_id: "" - spans: - - apollo_private.duration_ns: "[duration]" - http.flavor: HTTP/1.1 - http.method: GET - http.request.method: GET - http.route: "http://example.com/" - name: router - otel.kind: INTERNAL - trace_id: "" diff --git a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__subgraph_events@logs.snap b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__subgraph_events@logs.snap index b0d8235272..b065a93888 100644 --- a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__subgraph_events@logs.snap +++ b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__subgraph_events@logs.snap @@ -7,32 +7,20 @@ expression: yaml level: INFO message: my event message span: - apollo.subgraph.name: subgraph - graphql.document: "query { foo }" - graphql.operation.name: "" name: subgraph otel.kind: INTERNAL spans: - - apollo.subgraph.name: subgraph - graphql.document: "query { foo }" - graphql.operation.name: "" - name: subgraph + - name: subgraph otel.kind: INTERNAL - fields: kind: my.response.event level: ERROR message: my response event message span: - apollo.subgraph.name: subgraph - graphql.document: "query { foo }" - graphql.operation.name: "" name: subgraph otel.kind: INTERNAL otel.status_code: OK spans: - - apollo.subgraph.name: subgraph - graphql.document: "query { foo }" - graphql.operation.name: "" - name: subgraph + - name: subgraph otel.kind: INTERNAL otel.status_code: OK diff --git a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__subgraph_events_response@logs.snap b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__subgraph_events_response@logs.snap index b0d8235272..b065a93888 100644 --- a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__subgraph_events_response@logs.snap +++ b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__subgraph_events_response@logs.snap @@ -7,32 +7,20 @@ expression: yaml level: INFO message: my event message span: - apollo.subgraph.name: subgraph - graphql.document: "query { foo }" - graphql.operation.name: "" name: subgraph otel.kind: INTERNAL spans: - - apollo.subgraph.name: subgraph - graphql.document: "query { foo }" - graphql.operation.name: "" - name: subgraph + - name: subgraph otel.kind: INTERNAL - fields: kind: my.response.event level: ERROR message: my response event message span: - apollo.subgraph.name: subgraph - graphql.document: "query { foo }" - graphql.operation.name: "" name: subgraph otel.kind: INTERNAL otel.status_code: OK spans: - - apollo.subgraph.name: subgraph - graphql.document: "query { foo }" - graphql.operation.name: "" - name: subgraph + - name: subgraph otel.kind: INTERNAL otel.status_code: OK diff --git a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events@logs.snap b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events@logs.snap index a6e65f3a10..b520f23847 100644 --- a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events@logs.snap +++ b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events@logs.snap @@ -9,13 +9,11 @@ expression: yaml span: apollo_private.field_level_instrumentation_ratio: 0.01 apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" name: supergraph otel.kind: INTERNAL spans: - apollo_private.field_level_instrumentation_ratio: 0.01 apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" name: supergraph otel.kind: INTERNAL - fields: @@ -25,12 +23,10 @@ expression: yaml span: apollo_private.field_level_instrumentation_ratio: 0.01 apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" name: supergraph otel.kind: INTERNAL spans: - apollo_private.field_level_instrumentation_ratio: 0.01 apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" name: supergraph otel.kind: INTERNAL diff --git a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events_on_graphql_error@logs.snap b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events_on_graphql_error@logs.snap index 56959fec5b..37ea5cf154 100644 --- a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events_on_graphql_error@logs.snap +++ b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events_on_graphql_error@logs.snap @@ -9,12 +9,10 @@ expression: yaml span: apollo_private.field_level_instrumentation_ratio: 0.01 apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" name: supergraph otel.kind: INTERNAL spans: - apollo_private.field_level_instrumentation_ratio: 0.01 apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" name: supergraph otel.kind: INTERNAL diff --git a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events_on_response@logs.snap b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events_on_response@logs.snap index 56959fec5b..37ea5cf154 100644 --- a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events_on_response@logs.snap +++ b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events_on_response@logs.snap @@ -9,12 +9,10 @@ expression: yaml span: apollo_private.field_level_instrumentation_ratio: 0.01 apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" name: supergraph otel.kind: INTERNAL spans: - apollo_private.field_level_instrumentation_ratio: 0.01 apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" name: supergraph otel.kind: INTERNAL diff --git a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events_with_exists_condition@logs.snap b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events_with_exists_condition@logs.snap index 0c9630144c..14da6517ce 100644 --- a/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events_with_exists_condition@logs.snap +++ b/apollo-router/src/plugins/telemetry/config_new/snapshots/apollo_router__plugins__telemetry__config_new__events__tests__supergraph_events_with_exists_condition@logs.snap @@ -9,14 +9,10 @@ expression: yaml span: apollo_private.field_level_instrumentation_ratio: 0.01 apollo_private.graphql.variables: "{}" - graphql.document: "query Test { foo }" - graphql.operation.name: Test name: supergraph otel.kind: INTERNAL spans: - apollo_private.field_level_instrumentation_ratio: 0.01 apollo_private.graphql.variables: "{}" - graphql.document: "query Test { foo }" - graphql.operation.name: Test name: supergraph otel.kind: INTERNAL diff --git a/apollo-router/src/plugins/telemetry/config_new/spans.rs b/apollo-router/src/plugins/telemetry/config_new/spans.rs index 0abf12b797..5dcfdfe9ad 100644 --- a/apollo-router/src/plugins/telemetry/config_new/spans.rs +++ b/apollo-router/src/plugins/telemetry/config_new/spans.rs @@ -1,16 +1,12 @@ use schemars::JsonSchema; use serde::Deserialize; -use super::conditional::Conditional; -use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; -use crate::plugins::telemetry::config_new::attributes::RouterAttributes; -use crate::plugins::telemetry::config_new::attributes::SubgraphAttributes; -use crate::plugins::telemetry::config_new::attributes::SupergraphAttributes; -use crate::plugins::telemetry::config_new::extendable::Extendable; -use crate::plugins::telemetry::config_new::selectors::RouterSelector; -use crate::plugins::telemetry::config_new::selectors::SubgraphSelector; -use crate::plugins::telemetry::config_new::selectors::SupergraphSelector; +use super::connector::spans::ConnectorSpans; +use super::router::spans::RouterSpans; +use super::subgraph::spans::SubgraphSpans; +use super::supergraph::spans::SupergraphSpans; use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; use crate::plugins::telemetry::otlp::TelemetryDataKind; use crate::plugins::telemetry::span_factory::SpanMode; @@ -35,6 +31,10 @@ pub(crate) struct Spans { /// Attributes to include on the subgraph span. /// Subgraph spans contain information about the subgraph request and response and therefore contain subgraph specific attributes. pub(crate) subgraph: SubgraphSpans, + + /// Attributes to include on the connector span. + /// Connector spans contain information about the connector request and response and therefore contain connector specific attributes. + pub(crate) connector: ConnectorSpans, } impl Spans { @@ -74,777 +74,3 @@ impl Spans { Ok(()) } } - -#[derive(Deserialize, JsonSchema, Clone, Debug, Default)] -#[serde(deny_unknown_fields, default)] -pub(crate) struct RouterSpans { - /// Custom attributes that are attached to the router span. - pub(crate) attributes: Extendable>, -} - -impl DefaultForLevel for RouterSpans { - fn defaults_for_level( - &mut self, - requirement_level: DefaultAttributeRequirementLevel, - kind: TelemetryDataKind, - ) { - self.attributes.defaults_for_level(requirement_level, kind); - } -} - -#[derive(Deserialize, JsonSchema, Clone, Debug, Default)] -#[serde(deny_unknown_fields, default)] -pub(crate) struct SupergraphSpans { - /// Custom attributes that are attached to the supergraph span. - pub(crate) attributes: Extendable>, -} -impl DefaultForLevel for SupergraphSpans { - fn defaults_for_level( - &mut self, - requirement_level: DefaultAttributeRequirementLevel, - kind: TelemetryDataKind, - ) { - self.attributes.defaults_for_level(requirement_level, kind); - } -} - -#[derive(Deserialize, JsonSchema, Clone, Default, Debug)] -#[serde(deny_unknown_fields, default)] -pub(crate) struct SubgraphSpans { - /// Custom attributes that are attached to the subgraph span. - pub(crate) attributes: Extendable>, -} - -impl DefaultForLevel for SubgraphSpans { - fn defaults_for_level( - &mut self, - requirement_level: DefaultAttributeRequirementLevel, - kind: TelemetryDataKind, - ) { - self.attributes.defaults_for_level(requirement_level, kind); - } -} - -#[cfg(test)] -mod test { - use std::str::FromStr; - use std::sync::Arc; - - use http::header::USER_AGENT; - use opentelemetry_semantic_conventions::trace::GRAPHQL_DOCUMENT; - use opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD; - use opentelemetry_semantic_conventions::trace::NETWORK_PROTOCOL_VERSION; - use opentelemetry_semantic_conventions::trace::URL_PATH; - use opentelemetry_semantic_conventions::trace::USER_AGENT_ORIGINAL; - use parking_lot::Mutex; - use serde_json_bytes::path::JsonPathInst; - - use crate::context::CONTAINS_GRAPHQL_ERROR; - use crate::context::OPERATION_KIND; - use crate::graphql; - use crate::plugins::telemetry::config::AttributeValue; - use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; - use crate::plugins::telemetry::config_new::attributes::StandardAttribute; - use crate::plugins::telemetry::config_new::attributes::SUBGRAPH_GRAPHQL_DOCUMENT; - use crate::plugins::telemetry::config_new::conditional::Conditional; - use crate::plugins::telemetry::config_new::conditions::Condition; - use crate::plugins::telemetry::config_new::conditions::SelectorOrValue; - use crate::plugins::telemetry::config_new::selectors::ResponseStatus; - use crate::plugins::telemetry::config_new::selectors::RouterSelector; - use crate::plugins::telemetry::config_new::selectors::SubgraphSelector; - use crate::plugins::telemetry::config_new::selectors::SupergraphSelector; - use crate::plugins::telemetry::config_new::spans::RouterSpans; - use crate::plugins::telemetry::config_new::spans::SubgraphSpans; - use crate::plugins::telemetry::config_new::spans::SupergraphSpans; - use crate::plugins::telemetry::config_new::DefaultForLevel; - use crate::plugins::telemetry::config_new::Selectors; - use crate::plugins::telemetry::otlp::TelemetryDataKind; - use crate::plugins::telemetry::OTEL_NAME; - use crate::services::router; - use crate::services::subgraph; - use crate::services::supergraph; - use crate::Context; - - #[test] - fn test_router_spans_level_none() { - let mut spans = RouterSpans::default(); - spans.defaults_for_levels( - DefaultAttributeRequirementLevel::None, - TelemetryDataKind::Traces, - ); - let values = spans.attributes.on_request( - &router::Request::fake_builder() - .method(http::Method::POST) - .header(USER_AGENT, "test") - .build() - .unwrap(), - ); - assert!(!values - .iter() - .any(|key_val| key_val.key == HTTP_REQUEST_METHOD)); - assert!(!values - .iter() - .any(|key_val| key_val.key == NETWORK_PROTOCOL_VERSION)); - assert!(!values.iter().any(|key_val| key_val.key == URL_PATH)); - assert!(!values - .iter() - .any(|key_val| key_val.key == USER_AGENT_ORIGINAL)); - } - - #[test] - fn test_router_spans_level_required() { - let mut spans = RouterSpans::default(); - spans.defaults_for_levels( - DefaultAttributeRequirementLevel::Required, - TelemetryDataKind::Traces, - ); - let values = spans.attributes.on_request( - &router::Request::fake_builder() - .method(http::Method::POST) - .header(USER_AGENT, "test") - .build() - .unwrap(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == HTTP_REQUEST_METHOD)); - assert!(!values - .iter() - .any(|key_val| key_val.key == NETWORK_PROTOCOL_VERSION)); - assert!(values.iter().any(|key_val| key_val.key == URL_PATH)); - assert!(!values - .iter() - .any(|key_val| key_val.key == USER_AGENT_ORIGINAL)); - } - - #[test] - fn test_router_spans_level_recommended() { - let mut spans = RouterSpans::default(); - spans.defaults_for_levels( - DefaultAttributeRequirementLevel::Recommended, - TelemetryDataKind::Traces, - ); - let values = spans.attributes.on_request( - &router::Request::fake_builder() - .method(http::Method::POST) - .header(USER_AGENT, "test") - .build() - .unwrap(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == HTTP_REQUEST_METHOD)); - assert!(values - .iter() - .any(|key_val| key_val.key == NETWORK_PROTOCOL_VERSION)); - assert!(values.iter().any(|key_val| key_val.key == URL_PATH)); - assert!(values - .iter() - .any(|key_val| key_val.key == USER_AGENT_ORIGINAL)); - } - - #[test] - fn test_supergraph_spans_level_none() { - let mut spans = SupergraphSpans::default(); - spans.defaults_for_levels( - DefaultAttributeRequirementLevel::None, - TelemetryDataKind::Traces, - ); - let values = spans.attributes.on_request( - &supergraph::Request::fake_builder() - .query("query { __typename }") - .build() - .unwrap(), - ); - assert!(!values.iter().any(|key_val| key_val.key == GRAPHQL_DOCUMENT)); - } - - #[test] - fn test_supergraph_spans_level_required() { - let mut spans = SupergraphSpans::default(); - spans.defaults_for_levels( - DefaultAttributeRequirementLevel::Required, - TelemetryDataKind::Traces, - ); - let values = spans.attributes.on_request( - &supergraph::Request::fake_builder() - .query("query { __typename }") - .build() - .unwrap(), - ); - assert!(!values.iter().any(|key_val| key_val.key == GRAPHQL_DOCUMENT)); - } - - #[test] - fn test_supergraph_spans_level_recommended() { - let mut spans = SupergraphSpans::default(); - spans.defaults_for_levels( - DefaultAttributeRequirementLevel::Recommended, - TelemetryDataKind::Traces, - ); - let values = spans.attributes.on_request( - &supergraph::Request::fake_builder() - .query("query { __typename }") - .build() - .unwrap(), - ); - assert!(values.iter().any(|key_val| key_val.key == GRAPHQL_DOCUMENT)); - } - - #[test] - fn test_subgraph_spans_level_none() { - let mut spans = SubgraphSpans::default(); - spans.defaults_for_levels( - DefaultAttributeRequirementLevel::None, - TelemetryDataKind::Traces, - ); - let values = spans.attributes.on_request( - &subgraph::Request::fake_builder() - .subgraph_request( - ::http::Request::builder() - .uri("http://localhost/graphql") - .body( - graphql::Request::fake_builder() - .query("query { __typename }") - .build(), - ) - .unwrap(), - ) - .build(), - ); - assert!(!values.iter().any(|key_val| key_val.key == GRAPHQL_DOCUMENT)); - } - - #[test] - fn test_subgraph_spans_level_required() { - let mut spans = SubgraphSpans::default(); - spans.defaults_for_levels( - DefaultAttributeRequirementLevel::Required, - TelemetryDataKind::Traces, - ); - let values = spans.attributes.on_request( - &subgraph::Request::fake_builder() - .subgraph_request( - ::http::Request::builder() - .uri("http://localhost/graphql") - .body( - graphql::Request::fake_builder() - .query("query { __typename }") - .build(), - ) - .unwrap(), - ) - .build(), - ); - assert!(!values.iter().any(|key_val| key_val.key == GRAPHQL_DOCUMENT)); - } - - #[test] - fn test_subgraph_spans_level_recommended() { - let mut spans = SubgraphSpans::default(); - spans.defaults_for_levels( - DefaultAttributeRequirementLevel::Recommended, - TelemetryDataKind::Traces, - ); - let values = spans.attributes.on_request( - &subgraph::Request::fake_builder() - .subgraph_request( - ::http::Request::builder() - .uri("http://localhost/graphql") - .body( - graphql::Request::fake_builder() - .query("query { __typename }") - .build(), - ) - .unwrap(), - ) - .build(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == SUBGRAPH_GRAPHQL_DOCUMENT)); - } - - #[test] - fn test_router_request_static_custom_attribute_on_graphql_error() { - let mut spans = RouterSpans::default(); - spans.attributes.custom.insert( - "test".to_string(), - Conditional { - selector: RouterSelector::StaticField { - r#static: "my-static-value".to_string().into(), - }, - condition: Some(Arc::new(Mutex::new(Condition::Eq([ - SelectorOrValue::Value(AttributeValue::Bool(true)), - SelectorOrValue::Selector(RouterSelector::OnGraphQLError { - on_graphql_error: true, - }), - ])))), - value: Arc::new(Default::default()), - }, - ); - let context = Context::new(); - context.insert_json_value(CONTAINS_GRAPHQL_ERROR, serde_json_bytes::Value::Bool(true)); - let values = spans.attributes.on_response( - &router::Response::fake_builder() - .header("my-header", "test_val") - .context(context) - .build() - .unwrap(), - ); - assert!(values.iter().any(|key_val| key_val.key - == opentelemetry::Key::from_static_str("test") - && key_val.value - == opentelemetry::Value::String("my-static-value".to_string().into()))); - } - - #[test] - fn test_router_request_custom_attribute_on_graphql_error() { - let mut spans = RouterSpans::default(); - spans.attributes.custom.insert( - "test".to_string(), - Conditional { - selector: RouterSelector::ResponseHeader { - response_header: "my-header".to_string(), - redact: None, - default: None, - }, - condition: Some(Arc::new(Mutex::new(Condition::Eq([ - SelectorOrValue::Value(AttributeValue::Bool(true)), - SelectorOrValue::Selector(RouterSelector::OnGraphQLError { - on_graphql_error: true, - }), - ])))), - value: Arc::new(Default::default()), - }, - ); - let context = Context::new(); - context.insert_json_value(CONTAINS_GRAPHQL_ERROR, serde_json_bytes::Value::Bool(true)); - let values = spans.attributes.on_response( - &router::Response::fake_builder() - .header("my-header", "test_val") - .context(context) - .build() - .unwrap(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test"))); - } - - #[test] - fn test_router_request_custom_attribute_not_on_graphql_error() { - let mut spans = RouterSpans::default(); - spans.attributes.custom.insert( - "test".to_string(), - Conditional { - selector: RouterSelector::ResponseHeader { - response_header: "my-header".to_string(), - redact: None, - default: None, - }, - condition: Some(Arc::new(Mutex::new(Condition::Eq([ - SelectorOrValue::Value(AttributeValue::Bool(true)), - SelectorOrValue::Selector(RouterSelector::OnGraphQLError { - on_graphql_error: true, - }), - ])))), - value: Arc::new(Default::default()), - }, - ); - let context = Context::new(); - context.insert_json_value(CONTAINS_GRAPHQL_ERROR, serde_json_bytes::Value::Bool(false)); - let values = spans.attributes.on_response( - &router::Response::fake_builder() - .header("my-header", "test_val") - .context(context) - .build() - .unwrap(), - ); - assert!(!values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test"))); - } - - #[test] - fn test_router_request_custom_attribute_condition_true() { - let mut spans = RouterSpans::default(); - let selector = RouterSelector::RequestHeader { - request_header: "my-header".to_string(), - redact: None, - default: None, - }; - spans.attributes.custom.insert( - "test".to_string(), - Conditional { - selector: selector.clone(), - condition: Some(Arc::new(Mutex::new(Condition::Eq([ - SelectorOrValue::Value(AttributeValue::String("test_val".to_string())), - SelectorOrValue::Selector(selector), - ])))), - value: Default::default(), - }, - ); - let values = spans.attributes.on_request( - &router::Request::fake_builder() - .method(http::Method::POST) - .header("my-header", "test_val") - .build() - .unwrap(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test"))); - } - - #[test] - fn test_router_request_custom_attribute_condition_false() { - let mut spans = RouterSpans::default(); - let selector = RouterSelector::RequestHeader { - request_header: "my-header".to_string(), - redact: None, - default: None, - }; - spans.attributes.custom.insert( - "test".to_string(), - Conditional { - selector: selector.clone(), - condition: Some(Arc::new(Mutex::new(Condition::Eq([ - SelectorOrValue::Value(AttributeValue::String("test_val".to_string())), - SelectorOrValue::Selector(selector), - ])))), - value: Arc::new(Default::default()), - }, - ); - let values = spans.attributes.on_request( - &router::Request::fake_builder() - .method(http::Method::POST) - .header("my-header", "bar") - .build() - .unwrap(), - ); - assert!(!values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test"))); - } - - #[test] - fn test_router_request_custom_attribute() { - let mut spans = RouterSpans::default(); - spans.attributes.custom.insert( - "test".to_string(), - Conditional { - selector: RouterSelector::RequestHeader { - request_header: "my-header".to_string(), - redact: None, - default: None, - }, - condition: None, - value: Arc::new(Default::default()), - }, - ); - let values = spans.attributes.on_request( - &router::Request::fake_builder() - .method(http::Method::POST) - .header("my-header", "test_val") - .build() - .unwrap(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test"))); - } - - #[test] - fn test_router_request_standard_attribute_aliased() { - let mut spans = RouterSpans::default(); - spans.attributes.attributes.common.http_request_method = Some(StandardAttribute::Aliased { - alias: String::from("my.method"), - }); - let values = spans.attributes.on_request( - &router::Request::fake_builder() - .method(http::Method::POST) - .header("my-header", "test_val") - .build() - .unwrap(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("my.method"))); - } - - #[test] - fn test_router_response_custom_attribute() { - let mut spans = RouterSpans::default(); - spans.attributes.custom.insert( - "test".to_string(), - Conditional { - selector: RouterSelector::ResponseHeader { - response_header: "my-header".to_string(), - redact: None, - default: None, - }, - condition: None, - value: Arc::new(Default::default()), - }, - ); - spans.attributes.custom.insert( - OTEL_NAME.to_string(), - Conditional { - selector: RouterSelector::StaticField { - r#static: String::from("new_name").into(), - }, - condition: None, - value: Arc::new(Default::default()), - }, - ); - let values = spans.attributes.on_response( - &router::Response::fake_builder() - .header("my-header", "test_val") - .build() - .unwrap(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test"))); - - assert!(values.iter().any(|key_val| key_val.key - == opentelemetry::Key::from_static_str(OTEL_NAME) - && key_val.value == opentelemetry::Value::String(String::from("new_name").into()))); - } - - #[test] - fn test_supergraph_request_custom_attribute() { - let mut spans = SupergraphSpans::default(); - spans.attributes.custom.insert( - "test".to_string(), - Conditional { - selector: SupergraphSelector::RequestHeader { - request_header: "my-header".to_string(), - redact: None, - default: None, - }, - condition: None, - value: Arc::new(Default::default()), - }, - ); - let values = spans.attributes.on_request( - &supergraph::Request::fake_builder() - .method(http::Method::POST) - .header("my-header", "test_val") - .build() - .unwrap(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test"))); - } - - #[test] - fn test_supergraph_standard_attribute_aliased() { - let mut spans = SupergraphSpans::default(); - spans.attributes.attributes.graphql_operation_type = Some(StandardAttribute::Aliased { - alias: String::from("my_op"), - }); - let context = Context::new(); - context.insert(OPERATION_KIND, "Query".to_string()).unwrap(); - let values = spans.attributes.on_request( - &supergraph::Request::fake_builder() - .method(http::Method::POST) - .header("my-header", "test_val") - .query("Query { me { id } }") - .context(context) - .build() - .unwrap(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("my_op"))); - } - - #[test] - fn test_supergraph_response_event_custom_attribute() { - let mut spans = SupergraphSpans::default(); - spans.attributes.custom.insert( - "otel.status_code".to_string(), - Conditional { - selector: SupergraphSelector::StaticField { - r#static: String::from("error").into(), - }, - condition: Some(Arc::new(Mutex::new(Condition::Exists( - SupergraphSelector::ResponseErrors { - response_errors: JsonPathInst::from_str("$[0].extensions.code").unwrap(), - redact: None, - default: None, - }, - )))), - value: Arc::new(Default::default()), - }, - ); - let values = spans.attributes.on_response_event( - &graphql::Response::builder() - .error( - graphql::Error::builder() - .message("foo") - .extension_code("MY_EXTENSION_CODE") - .build(), - ) - .build(), - &Context::new(), - ); - assert!(values.iter().any(|key_val| key_val.key - == opentelemetry::Key::from_static_str("otel.status_code") - && key_val.value == opentelemetry::Value::String(String::from("error").into()))); - } - - #[test] - fn test_supergraph_response_custom_attribute() { - let mut spans = SupergraphSpans::default(); - spans.attributes.custom.insert( - "test".to_string(), - Conditional { - selector: SupergraphSelector::ResponseHeader { - response_header: "my-header".to_string(), - redact: None, - default: None, - }, - condition: None, - value: Arc::new(Default::default()), - }, - ); - let values = spans.attributes.on_response( - &supergraph::Response::fake_builder() - .header("my-header", "test_val") - .build() - .unwrap(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test"))); - } - - #[test] - fn test_subgraph_request_custom_attribute() { - let mut spans = SubgraphSpans::default(); - spans.attributes.custom.insert( - "test".to_string(), - Conditional { - selector: SubgraphSelector::SubgraphRequestHeader { - subgraph_request_header: "my-header".to_string(), - redact: None, - default: None, - }, - condition: None, - value: Arc::new(Default::default()), - }, - ); - let values = spans.attributes.on_request( - &subgraph::Request::fake_builder() - .subgraph_request( - ::http::Request::builder() - .uri("http://localhost/graphql") - .header("my-header", "test_val") - .body( - graphql::Request::fake_builder() - .query("query { __typename }") - .build(), - ) - .unwrap(), - ) - .build(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test"))); - } - - #[test] - fn test_subgraph_response_custom_attribute() { - let mut spans = SubgraphSpans::default(); - spans.attributes.custom.insert( - "test".to_string(), - Conditional { - selector: SubgraphSelector::SubgraphResponseHeader { - subgraph_response_header: "my-header".to_string(), - redact: None, - default: None, - }, - condition: None, - value: Arc::new(Default::default()), - }, - ); - let values = spans.attributes.on_response( - &subgraph::Response::fake2_builder() - .header("my-header", "test_val") - .build() - .unwrap(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test"))); - } - - #[test] - fn test_subgraph_response_custom_attribute_good_condition() { - let mut spans = SubgraphSpans::default(); - spans.attributes.custom.insert( - "test".to_string(), - Conditional { - selector: SubgraphSelector::SubgraphResponseHeader { - subgraph_response_header: "my-header".to_string(), - redact: None, - default: None, - }, - condition: Some(Arc::new(Mutex::new(Condition::Eq([ - SelectorOrValue::Value(AttributeValue::I64(200)), - SelectorOrValue::Selector(SubgraphSelector::SubgraphResponseStatus { - subgraph_response_status: ResponseStatus::Code, - }), - ])))), - value: Arc::new(Default::default()), - }, - ); - let values = spans.attributes.on_response( - &subgraph::Response::fake2_builder() - .header("my-header", "test_val") - .status_code(http::StatusCode::OK) - .build() - .unwrap(), - ); - assert!(values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test"))); - } - - #[test] - fn test_subgraph_response_custom_attribute_bad_condition() { - let mut spans = SubgraphSpans::default(); - spans.attributes.custom.insert( - "test".to_string(), - Conditional { - selector: SubgraphSelector::SubgraphResponseHeader { - subgraph_response_header: "my-header".to_string(), - redact: None, - default: None, - }, - condition: Some(Arc::new(Mutex::new(Condition::Eq([ - SelectorOrValue::Value(AttributeValue::I64(400)), - SelectorOrValue::Selector(SubgraphSelector::SubgraphResponseStatus { - subgraph_response_status: ResponseStatus::Code, - }), - ])))), - value: Arc::new(Default::default()), - }, - ); - let values = spans.attributes.on_response( - &subgraph::Response::fake2_builder() - .header("my-header", "test_val") - .status_code(http::StatusCode::OK) - .build() - .unwrap(), - ); - assert!(!values - .iter() - .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test"))); - } -} diff --git a/apollo-router/src/plugins/telemetry/config_new/subgraph/attributes.rs b/apollo-router/src/plugins/telemetry/config_new/subgraph/attributes.rs new file mode 100644 index 0000000000..b21cc42d7e --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/subgraph/attributes.rs @@ -0,0 +1,316 @@ +use std::fmt::Debug; + +use opentelemetry::Key; +use opentelemetry::KeyValue; +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; + +use crate::Context; +use crate::context::OPERATION_KIND; +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::Selectors; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::attributes::HTTP_REQUEST_RESEND_COUNT; +use crate::plugins::telemetry::config_new::attributes::StandardAttribute; +use crate::plugins::telemetry::otlp::TelemetryDataKind; +use crate::services::subgraph; +use crate::services::subgraph::SubgraphRequestId; + +pub(crate) const SUBGRAPH_NAME: Key = Key::from_static_str("subgraph.name"); +pub(crate) const SUBGRAPH_GRAPHQL_DOCUMENT: Key = Key::from_static_str("subgraph.graphql.document"); +pub(crate) const SUBGRAPH_GRAPHQL_OPERATION_NAME: Key = + Key::from_static_str("subgraph.graphql.operation.name"); +pub(crate) const SUBGRAPH_GRAPHQL_OPERATION_TYPE: Key = + Key::from_static_str("subgraph.graphql.operation.type"); + +#[derive(Deserialize, JsonSchema, Clone, Default, Debug, buildstructor::Builder)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct SubgraphAttributes { + /// The name of the subgraph + /// Examples: + /// + /// * products + /// + /// Requirement level: Required + #[serde(rename = "subgraph.name")] + subgraph_name: Option, + + /// The GraphQL document being executed. + /// Examples: + /// + /// * `query findBookById { bookById(id: ?) { name } }` + /// + /// Requirement level: Recommended + #[serde(rename = "subgraph.graphql.document")] + graphql_document: Option, + + /// The name of the operation being executed. + /// Examples: + /// + /// * findBookById + /// + /// Requirement level: Recommended + #[serde(rename = "subgraph.graphql.operation.name")] + graphql_operation_name: Option, + + /// The type of the operation being executed. + /// Examples: + /// + /// * query + /// * subscription + /// * mutation + /// + /// Requirement level: Recommended + #[serde(rename = "subgraph.graphql.operation.type")] + graphql_operation_type: Option, + + /// The number of times the request has been resent + #[serde(rename = "http.request.resend_count")] + http_request_resend_count: Option, +} + +impl Selectors for SubgraphAttributes { + fn on_request(&self, request: &subgraph::Request) -> Vec { + let mut attrs = Vec::new(); + if let Some(key) = self + .graphql_document + .as_ref() + .and_then(|a| a.key(SUBGRAPH_GRAPHQL_DOCUMENT)) + && let Some(query) = &request.subgraph_request.body().query + { + attrs.push(KeyValue::new(key, query.clone())); + } + if let Some(key) = self + .graphql_operation_name + .as_ref() + .and_then(|a| a.key(SUBGRAPH_GRAPHQL_OPERATION_NAME)) + && let Some(op_name) = &request.subgraph_request.body().operation_name + { + attrs.push(KeyValue::new(key, op_name.clone())); + } + if let Some(key) = self + .graphql_operation_type + .as_ref() + .and_then(|a| a.key(SUBGRAPH_GRAPHQL_OPERATION_TYPE)) + { + // Subgraph operation type wil always match the supergraph operation type + if let Some(operation_type) = &request + .context + .get::<_, String>(OPERATION_KIND) + .unwrap_or_default() + { + attrs.push(KeyValue::new(key, operation_type.clone())); + } + } + if let Some(key) = self + .subgraph_name + .as_ref() + .and_then(|a| a.key(SUBGRAPH_NAME)) + { + attrs.push(KeyValue::new(key, request.subgraph_name.clone())); + } + + attrs + } + + fn on_response(&self, response: &subgraph::Response) -> Vec { + let mut attrs = Vec::new(); + if let Some(key) = self + .http_request_resend_count + .as_ref() + .and_then(|a| a.key(HTTP_REQUEST_RESEND_COUNT)) + && let Some(resend_count) = response + .context + .get::<_, usize>(SubgraphRequestResendCountKey::new(&response.id)) + .ok() + .flatten() + { + attrs.push(KeyValue::new(key, resend_count as i64)); + } + + attrs + } + + fn on_error(&self, _error: &BoxError, _ctx: &Context) -> Vec { + Vec::default() + } +} + +/// Key used in context to save number of retries for a subgraph http request +pub(crate) struct SubgraphRequestResendCountKey<'a> { + subgraph_req_id: &'a SubgraphRequestId, +} + +impl<'a> SubgraphRequestResendCountKey<'a> { + pub(crate) fn new(subgraph_req_id: &'a SubgraphRequestId) -> Self { + Self { subgraph_req_id } + } +} + +impl From> for String { + fn from(value: SubgraphRequestResendCountKey) -> Self { + format!( + "apollo::telemetry::http_request_resend_count_{}", + value.subgraph_req_id + ) + } +} + +impl DefaultForLevel for SubgraphAttributes { + fn defaults_for_level( + &mut self, + requirement_level: DefaultAttributeRequirementLevel, + _kind: TelemetryDataKind, + ) { + match requirement_level { + DefaultAttributeRequirementLevel::Required => { + if self.subgraph_name.is_none() { + self.subgraph_name = Some(StandardAttribute::Bool(true)); + } + } + DefaultAttributeRequirementLevel::Recommended => { + if self.subgraph_name.is_none() { + self.subgraph_name = Some(StandardAttribute::Bool(true)); + } + if self.graphql_document.is_none() { + self.graphql_document = Some(StandardAttribute::Bool(true)); + } + if self.graphql_operation_name.is_none() { + self.graphql_operation_name = Some(StandardAttribute::Bool(true)); + } + if self.graphql_operation_type.is_none() { + self.graphql_operation_type = Some(StandardAttribute::Bool(true)); + } + if self.http_request_resend_count.is_none() { + self.http_request_resend_count = Some(StandardAttribute::Bool(true)); + } + } + DefaultAttributeRequirementLevel::None => {} + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::context::OPERATION_KIND; + use crate::graphql; + use crate::plugins::telemetry::config_new::Selectors; + use crate::services::subgraph; + + #[test] + fn test_subgraph_graphql_document() { + let attributes = SubgraphAttributes { + graphql_document: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + let attributes = attributes.on_request( + &subgraph::Request::fake_builder() + .subgraph_request( + ::http::Request::builder() + .uri("http://localhost/graphql") + .body( + graphql::Request::fake_builder() + .query("query { __typename }") + .build(), + ) + .unwrap(), + ) + .build(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key == SUBGRAPH_GRAPHQL_DOCUMENT) + .map(|key_val| &key_val.value), + Some(&"query { __typename }".into()) + ); + } + + #[test] + fn test_subgraph_graphql_operation_name() { + let attributes = SubgraphAttributes { + graphql_operation_name: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = attributes.on_request( + &subgraph::Request::fake_builder() + .subgraph_request( + ::http::Request::builder() + .uri("http://localhost/graphql") + .body( + graphql::Request::fake_builder() + .operation_name("topProducts") + .build(), + ) + .unwrap(), + ) + .build(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key == SUBGRAPH_GRAPHQL_OPERATION_NAME) + .map(|key_val| &key_val.value), + Some(&"topProducts".into()) + ); + } + + #[test] + fn test_subgraph_graphql_operation_type() { + let attributes = SubgraphAttributes { + graphql_operation_type: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let context = crate::Context::new(); + let _ = context.insert(OPERATION_KIND, "query".to_string()); + let attributes = attributes.on_request( + &subgraph::Request::fake_builder() + .context(context) + .subgraph_request( + ::http::Request::builder() + .uri("http://localhost/graphql") + .body(graphql::Request::fake_builder().build()) + .unwrap(), + ) + .build(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key == SUBGRAPH_GRAPHQL_OPERATION_TYPE) + .map(|key_val| &key_val.value), + Some(&"query".into()) + ); + } + + #[test] + fn test_subgraph_name() { + let attributes = SubgraphAttributes { + subgraph_name: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + + let attributes = attributes.on_request( + &subgraph::Request::fake_builder() + .subgraph_name("products") + .subgraph_request( + ::http::Request::builder() + .uri("http://localhost/graphql") + .body(graphql::Request::fake_builder().build()) + .unwrap(), + ) + .build(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key == SUBGRAPH_NAME) + .map(|key_val| &key_val.value), + Some(&"products".into()) + ); + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/subgraph/events.rs b/apollo-router/src/plugins/telemetry/config_new/subgraph/events.rs new file mode 100644 index 0000000000..830033a96d --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/subgraph/events.rs @@ -0,0 +1,190 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use opentelemetry::Key; +use opentelemetry::KeyValue; +use parking_lot::Mutex; +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; + +use super::selectors::SubgraphSelector; +use crate::Context; +use crate::plugins::telemetry::config_new::conditions::Condition; +use crate::plugins::telemetry::config_new::events::CustomEvents; +use crate::plugins::telemetry::config_new::events::EventLevel; +use crate::plugins::telemetry::config_new::events::StandardEventConfig; +use crate::plugins::telemetry::config_new::events::log_event; +use crate::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes; +use crate::services::subgraph; + +pub(crate) type SubgraphEvents = + CustomEvents; +impl CustomEvents { + pub(crate) fn on_request(&mut self, request: &subgraph::Request) { + if let Some(mut request_event) = self.request.take() + && request_event.condition.evaluate_request(request) == Some(true) + { + request.context.extensions().with_lock(|lock| { + lock.insert(SubgraphEventRequest { + level: request_event.level, + condition: Arc::new(Mutex::new(request_event.condition)), + }) + }); + } + if let Some(mut response_event) = self.response.take() + && response_event.condition.evaluate_request(request) != Some(false) + { + request.context.extensions().with_lock(|lock| { + lock.insert(SubgraphEventResponse { + level: response_event.level, + condition: Arc::new(response_event.condition), + }) + }); + } + for custom_event in &mut self.custom { + custom_event.on_request(request); + } + } + + pub(crate) fn on_response(&mut self, response: &subgraph::Response) { + for custom_event in &mut self.custom { + custom_event.on_response(response); + } + } + + pub(crate) fn on_error(&mut self, error: &BoxError, ctx: &Context) { + if let Some(error_event) = &self.error + && error_event.condition.evaluate_error(error, ctx) + { + log_event( + error_event.level, + "subgraph.error", + vec![KeyValue::new( + Key::from_static_str("error"), + opentelemetry::Value::String(error.to_string().into()), + )], + "", + ); + } + for custom_event in &mut self.custom { + custom_event.on_error(error, ctx); + } + } +} + +#[derive(Clone)] +pub(crate) struct SubgraphEventResponse { + // XXX(@IvanGoncharov): As part of removing Arc from StandardEvent I moved it here + // I think it's not nessary here but can't verify it right now, so in future can just wrap StandardEvent + pub(crate) level: EventLevel, + pub(crate) condition: Arc>, +} + +#[derive(Clone)] +pub(crate) struct SubgraphEventRequest { + // XXX(@IvanGoncharov): As part of removing Mutex from StandardEvent I moved it here + // I think it's not nessary here but can't verify it right now, so in future can just wrap StandardEvent + pub(crate) level: EventLevel, + pub(crate) condition: Arc>>, +} + +#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct SubgraphEventsConfig { + /// Log the subgraph request + pub(crate) request: StandardEventConfig, + /// Log the subgraph response + pub(crate) response: StandardEventConfig, + /// Log the subgraph error + pub(crate) error: StandardEventConfig, +} + +#[cfg(test)] +mod test { + use http::HeaderValue; + use tracing::instrument::WithSubscriber; + + use super::*; + use crate::assert_snapshot_subscriber; + use crate::graphql; + use crate::plugins::telemetry::Telemetry; + use crate::plugins::test::PluginTestHarness; + + #[tokio::test(flavor = "multi_thread")] + async fn test_subgraph_events() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!("../../testdata/custom_events.router.yaml")) + .build() + .await + .expect("test harness"); + + async { + let mut subgraph_req = http::Request::new( + graphql::Request::fake_builder() + .query("query { foo }") + .build(), + ); + subgraph_req + .headers_mut() + .insert("x-log-request", HeaderValue::from_static("log")); + test_harness + .subgraph_service("subgraph", |_r| async { + subgraph::Response::fake2_builder() + .header("custom-header", "val1") + .header("x-log-request", HeaderValue::from_static("log")) + .data(serde_json::json!({"data": "res"}).to_string()) + .build() + }) + .call( + subgraph::Request::fake_builder() + .subgraph_name("subgraph") + .subgraph_request(subgraph_req) + .build(), + ) + .await + .expect("expecting successful response"); + } + .with_subscriber(assert_snapshot_subscriber!()) + .await + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_subgraph_events_response() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!("../../testdata/custom_events.router.yaml")) + .build() + .await + .expect("test harness"); + + async { + let mut subgraph_req = http::Request::new( + graphql::Request::fake_builder() + .query("query { foo }") + .build(), + ); + subgraph_req + .headers_mut() + .insert("x-log-request", HeaderValue::from_static("log")); + test_harness + .subgraph_service("subgraph", |_r| async { + subgraph::Response::fake2_builder() + .header("custom-header", "val1") + .header("x-log-response", HeaderValue::from_static("log")) + .subgraph_name("subgraph") + .data(serde_json::json!({"data": "res"}).to_string()) + .build() + }) + .call( + subgraph::Request::fake_builder() + .subgraph_name("subgraph") + .subgraph_request(subgraph_req) + .build(), + ) + .await + .expect("expecting successful response"); + } + .with_subscriber(assert_snapshot_subscriber!()) + .await + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/subgraph/instruments.rs b/apollo-router/src/plugins/telemetry/config_new/subgraph/instruments.rs new file mode 100644 index 0000000000..65383020ca --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/subgraph/instruments.rs @@ -0,0 +1,165 @@ +use std::fmt::Debug; + +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; + +use super::selectors::SubgraphSelector; +use super::selectors::SubgraphValue; +use crate::Context; +use crate::plugins::telemetry::Instrumented; +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::Selectors; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::extendable::Extendable; +use crate::plugins::telemetry::config_new::instruments::CustomHistogram; +use crate::plugins::telemetry::config_new::instruments::CustomInstruments; +use crate::plugins::telemetry::config_new::instruments::DefaultedStandardInstrument; +use crate::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes; +use crate::plugins::telemetry::otlp::TelemetryDataKind; +use crate::services::subgraph; + +#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct SubgraphInstrumentsConfig { + /// Histogram of client request duration + #[serde(rename = "http.client.request.duration")] + pub(crate) http_client_request_duration: + DefaultedStandardInstrument>, + + /// Histogram of client request body size + #[serde(rename = "http.client.request.body.size")] + pub(crate) http_client_request_body_size: + DefaultedStandardInstrument>, + + /// Histogram of client response body size + #[serde(rename = "http.client.response.body.size")] + pub(crate) http_client_response_body_size: + DefaultedStandardInstrument>, +} + +impl DefaultForLevel for SubgraphInstrumentsConfig { + fn defaults_for_level( + &mut self, + requirement_level: DefaultAttributeRequirementLevel, + kind: TelemetryDataKind, + ) { + self.http_client_request_duration + .defaults_for_level(requirement_level, kind); + self.http_client_request_body_size + .defaults_for_level(requirement_level, kind); + self.http_client_response_body_size + .defaults_for_level(requirement_level, kind); + } +} + +impl Selectors for SubgraphInstrumentsConfig { + fn on_request(&self, request: &subgraph::Request) -> Vec { + let mut attrs = self.http_client_request_body_size.on_request(request); + attrs.extend(self.http_client_request_duration.on_request(request)); + attrs.extend(self.http_client_response_body_size.on_request(request)); + + attrs + } + + fn on_response(&self, response: &subgraph::Response) -> Vec { + let mut attrs = self.http_client_request_body_size.on_response(response); + attrs.extend(self.http_client_request_duration.on_response(response)); + attrs.extend(self.http_client_response_body_size.on_response(response)); + + attrs + } + + fn on_error(&self, error: &BoxError, ctx: &Context) -> Vec { + let mut attrs = self.http_client_request_body_size.on_error(error, ctx); + attrs.extend(self.http_client_request_duration.on_error(error, ctx)); + attrs.extend(self.http_client_response_body_size.on_error(error, ctx)); + + attrs + } +} + +pub(crate) struct SubgraphInstruments { + pub(crate) http_client_request_duration: Option< + CustomHistogram< + subgraph::Request, + subgraph::Response, + (), + SubgraphAttributes, + SubgraphSelector, + >, + >, + pub(crate) http_client_request_body_size: Option< + CustomHistogram< + subgraph::Request, + subgraph::Response, + (), + SubgraphAttributes, + SubgraphSelector, + >, + >, + pub(crate) http_client_response_body_size: Option< + CustomHistogram< + subgraph::Request, + subgraph::Response, + (), + SubgraphAttributes, + SubgraphSelector, + >, + >, + pub(crate) custom: SubgraphCustomInstruments, +} + +impl Instrumented for SubgraphInstruments { + type Request = subgraph::Request; + type Response = subgraph::Response; + type EventResponse = (); + + fn on_request(&self, request: &Self::Request) { + if let Some(http_client_request_duration) = &self.http_client_request_duration { + http_client_request_duration.on_request(request); + } + if let Some(http_client_request_body_size) = &self.http_client_request_body_size { + http_client_request_body_size.on_request(request); + } + if let Some(http_client_response_body_size) = &self.http_client_response_body_size { + http_client_response_body_size.on_request(request); + } + self.custom.on_request(request); + } + + fn on_response(&self, response: &Self::Response) { + if let Some(http_client_request_duration) = &self.http_client_request_duration { + http_client_request_duration.on_response(response); + } + if let Some(http_client_request_body_size) = &self.http_client_request_body_size { + http_client_request_body_size.on_response(response); + } + if let Some(http_client_response_body_size) = &self.http_client_response_body_size { + http_client_response_body_size.on_response(response); + } + self.custom.on_response(response); + } + + fn on_error(&self, error: &BoxError, ctx: &Context) { + if let Some(http_client_request_duration) = &self.http_client_request_duration { + http_client_request_duration.on_error(error, ctx); + } + if let Some(http_client_request_body_size) = &self.http_client_request_body_size { + http_client_request_body_size.on_error(error, ctx); + } + if let Some(http_client_response_body_size) = &self.http_client_response_body_size { + http_client_response_body_size.on_error(error, ctx); + } + self.custom.on_error(error, ctx); + } +} + +pub(crate) type SubgraphCustomInstruments = CustomInstruments< + subgraph::Request, + subgraph::Response, + (), + SubgraphAttributes, + SubgraphSelector, + SubgraphValue, +>; diff --git a/apollo-router/src/plugins/telemetry/config_new/subgraph/mod.rs b/apollo-router/src/plugins/telemetry/config_new/subgraph/mod.rs new file mode 100644 index 0000000000..232f3aa106 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/subgraph/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod attributes; +pub(crate) mod events; +pub(crate) mod instruments; +pub(crate) mod selectors; +pub(crate) mod spans; diff --git a/apollo-router/src/plugins/telemetry/config_new/subgraph/selectors.rs b/apollo-router/src/plugins/telemetry/config_new/subgraph/selectors.rs new file mode 100644 index 0000000000..a50e6cf902 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/subgraph/selectors.rs @@ -0,0 +1,1637 @@ +use derivative::Derivative; +use opentelemetry::Value; +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json_bytes::ByteString; +use serde_json_bytes::path::JsonPathInst; +use sha2::Digest; + +use super::attributes::SubgraphRequestResendCountKey; +use crate::Context; +use crate::context::OPERATION_KIND; +use crate::context::OPERATION_NAME; +use crate::plugin::serde::deserialize_jsonpath; +use crate::plugins::cache::entity::CacheSubgraph; +use crate::plugins::cache::metrics::CacheMetricContextKey; +use crate::plugins::telemetry::config::AttributeValue; +use crate::plugins::telemetry::config_new::Selector; +use crate::plugins::telemetry::config_new::Stage; +use crate::plugins::telemetry::config_new::ToOtelValue; +use crate::plugins::telemetry::config_new::get_baggage; +use crate::plugins::telemetry::config_new::instruments::InstrumentValue; +use crate::plugins::telemetry::config_new::instruments::Standard; +use crate::plugins::telemetry::config_new::selectors::All; +use crate::plugins::telemetry::config_new::selectors::CacheKind; +use crate::plugins::telemetry::config_new::selectors::EntityType; +use crate::plugins::telemetry::config_new::selectors::ErrorRepr; +use crate::plugins::telemetry::config_new::selectors::OperationKind; +use crate::plugins::telemetry::config_new::selectors::OperationName; +use crate::plugins::telemetry::config_new::selectors::Query; +use crate::plugins::telemetry::config_new::selectors::ResponseStatus; +use crate::services::subgraph; +use crate::spec::operation_limits::OperationLimits; + +#[derive(Deserialize, JsonSchema, Clone, Debug)] +#[serde(deny_unknown_fields, rename_all = "snake_case", untagged)] +pub(crate) enum SubgraphValue { + Standard(Standard), + Custom(Box), +} + +impl From<&SubgraphValue> for InstrumentValue { + fn from(value: &SubgraphValue) -> Self { + match value { + SubgraphValue::Standard(s) => InstrumentValue::Standard(s.clone()), + SubgraphValue::Custom(selector) => InstrumentValue::Custom((**selector).clone()), + } + } +} + +#[derive(Deserialize, JsonSchema, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub(crate) enum SubgraphQuery { + /// The raw query kind. + String, +} + +#[derive(Deserialize, JsonSchema, Clone, Derivative)] +#[serde(deny_unknown_fields, rename_all = "snake_case", untagged)] +#[derivative(Debug, PartialEq)] +pub(crate) enum SubgraphSelector { + SubgraphOperationName { + /// The operation name from the subgraph query. + subgraph_operation_name: OperationName, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + SubgraphOperationKind { + /// The kind of the subgraph operation (query|mutation|subscription). + // Allow dead code is required because there is only one variant in OperationKind and we need to avoid the dead code warning. + #[allow(dead_code)] + subgraph_operation_kind: OperationKind, + }, + SubgraphName { + /// The subgraph name + subgraph_name: bool, + }, + SubgraphQuery { + /// The graphql query to the subgraph. + subgraph_query: SubgraphQuery, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + SubgraphQueryVariable { + /// The name of a subgraph query variable. + subgraph_query_variable: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + SubgraphResponseData { + /// The subgraph response body json path. + #[schemars(with = "String")] + #[derivative(Debug = "ignore", PartialEq = "ignore")] + #[serde(deserialize_with = "deserialize_jsonpath")] + subgraph_response_data: JsonPathInst, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + SubgraphResponseErrors { + /// The subgraph response body json path. + #[schemars(with = "String")] + #[derivative(Debug = "ignore", PartialEq = "ignore")] + #[serde(deserialize_with = "deserialize_jsonpath")] + subgraph_response_errors: JsonPathInst, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + SubgraphRequestHeader { + /// The name of a subgraph request header. + subgraph_request_header: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + SubgraphResponseHeader { + /// The name of a subgraph response header. + subgraph_response_header: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + SubgraphResponseStatus { + /// The subgraph http response status code. + subgraph_response_status: ResponseStatus, + }, + SubgraphResendCount { + /// The subgraph http resend count + subgraph_resend_count: bool, + /// Optional default value. + default: Option, + }, + SupergraphOperationName { + /// The supergraph query operation name. + supergraph_operation_name: OperationName, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + SupergraphOperationKind { + /// The supergraph query operation kind (query|mutation|subscription). + // Allow dead code is required because there is only one variant in OperationKind and we need to avoid the dead code warning. + #[allow(dead_code)] + supergraph_operation_kind: OperationKind, + }, + SupergraphQuery { + /// The supergraph query to the subgraph. + supergraph_query: Query, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + SupergraphQueryVariable { + /// The supergraph query variable name. + supergraph_query_variable: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + SupergraphRequestHeader { + /// The supergraph request header name. + supergraph_request_header: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + RequestContext { + /// The request context key. + request_context: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + ResponseContext { + /// The response context key. + response_context: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + OnGraphQLError { + /// Boolean set to true if the response body contains graphql error + subgraph_on_graphql_error: bool, + }, + Baggage { + /// The name of the baggage item. + baggage: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + Env { + /// The name of the environment variable + env: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + /// Avoid unsafe std::env::set_var in tests + #[cfg(test)] + #[serde(skip)] + mocked_env_var: Option, + }, + /// Deprecated, should not be used anymore, use static field instead + Static(String), + StaticField { + /// A static value + r#static: AttributeValue, + }, + Error { + /// Critical error if it happens + error: ErrorRepr, + }, + Cache { + /// Select if you want to get cache hit or cache miss + cache: CacheKind, + /// Specify the entity type on which you want the cache data. (default: all) + entity_type: Option, + }, +} + +impl Selector for SubgraphSelector { + type Request = subgraph::Request; + type Response = subgraph::Response; + type EventResponse = (); + + fn on_request(&self, request: &subgraph::Request) -> Option { + match self { + SubgraphSelector::SubgraphOperationName { + subgraph_operation_name, + default, + .. + } => { + let op_name = request.subgraph_request.body().operation_name.clone(); + match subgraph_operation_name { + OperationName::String => op_name.or_else(|| default.clone()), + OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { + let mut hasher = sha2::Sha256::new(); + hasher.update(op_name.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) + }), + } + .map(opentelemetry::Value::from) + } + SubgraphSelector::SupergraphOperationName { + supergraph_operation_name, + default, + .. + } => { + let op_name = request.context.get(OPERATION_NAME).ok().flatten(); + match supergraph_operation_name { + OperationName::String => op_name.or_else(|| default.clone()), + OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { + let mut hasher = sha2::Sha256::new(); + hasher.update(op_name.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) + }), + } + .map(opentelemetry::Value::from) + } + SubgraphSelector::SubgraphName { subgraph_name } if *subgraph_name => { + Some(request.subgraph_name.clone().into()) + } + // .clone() + // .map(opentelemetry::Value::from), + SubgraphSelector::SubgraphOperationKind { .. } => request + .context + .get::<_, String>(OPERATION_KIND) + .ok() + .flatten() + .map(opentelemetry::Value::from), + SubgraphSelector::SupergraphOperationKind { .. } => request + .context + .get::<_, String>(OPERATION_KIND) + .ok() + .flatten() + .map(opentelemetry::Value::from), + + SubgraphSelector::SupergraphQuery { + default, + supergraph_query, + .. + } => { + let limits_opt = request + .context + .extensions() + .with_lock(|lock| lock.get::>().cloned()); + match supergraph_query { + Query::Aliases => { + limits_opt.map(|limits| opentelemetry::Value::I64(limits.aliases as i64)) + } + Query::Depth => { + limits_opt.map(|limits| opentelemetry::Value::I64(limits.depth as i64)) + } + Query::Height => { + limits_opt.map(|limits| opentelemetry::Value::I64(limits.height as i64)) + } + Query::RootFields => limits_opt + .map(|limits| opentelemetry::Value::I64(limits.root_fields as i64)), + Query::String => request + .supergraph_request + .body() + .query + .clone() + .or_else(|| default.clone()) + .map(opentelemetry::Value::from), + } + } + SubgraphSelector::SubgraphQuery { default, .. } => request + .subgraph_request + .body() + .query + .clone() + .or_else(|| default.clone()) + .map(opentelemetry::Value::from), + SubgraphSelector::SubgraphQueryVariable { + subgraph_query_variable, + default, + .. + } => request + .subgraph_request + .body() + .variables + .get(&ByteString::from(subgraph_query_variable.as_str())) + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + + SubgraphSelector::SupergraphQueryVariable { + supergraph_query_variable, + default, + .. + } => request + .supergraph_request + .body() + .variables + .get(&ByteString::from(supergraph_query_variable.as_str())) + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + SubgraphSelector::SubgraphRequestHeader { + subgraph_request_header, + default, + .. + } => request + .subgraph_request + .headers() + .get(subgraph_request_header) + .and_then(|h| Some(h.to_str().ok()?.to_string())) + .or_else(|| default.clone()) + .map(opentelemetry::Value::from), + SubgraphSelector::SupergraphRequestHeader { + supergraph_request_header, + default, + .. + } => request + .supergraph_request + .headers() + .get(supergraph_request_header) + .and_then(|h| Some(h.to_str().ok()?.to_string())) + .or_else(|| default.clone()) + .map(opentelemetry::Value::from), + SubgraphSelector::RequestContext { + request_context, + default, + .. + } => request + .context + .get::<_, serde_json_bytes::Value>(request_context) + .ok() + .flatten() + .as_ref() + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + SubgraphSelector::Baggage { + baggage: baggage_name, + default, + .. + } => get_baggage(baggage_name).or_else(|| default.maybe_to_otel_value()), + + SubgraphSelector::Env { + env, + default, + #[cfg(test)] + mocked_env_var, + .. + } => { + #[cfg(test)] + let value = mocked_env_var.clone(); + #[cfg(not(test))] + let value = None; + value + .or_else(|| std::env::var(env).ok()) + .or_else(|| default.clone()) + .map(opentelemetry::Value::from) + } + SubgraphSelector::Static(val) => Some(val.clone().into()), + SubgraphSelector::StaticField { r#static } => Some(r#static.clone().into()), + + // For response + _ => None, + } + } + + fn on_response(&self, response: &subgraph::Response) -> Option { + match self { + SubgraphSelector::SubgraphResponseHeader { + subgraph_response_header, + default, + .. + } => response + .response + .headers() + .get(subgraph_response_header) + .and_then(|h| Some(h.to_str().ok()?.to_string())) + .or_else(|| default.clone()) + .map(opentelemetry::Value::from), + SubgraphSelector::SubgraphResponseStatus { + subgraph_response_status: response_status, + } => match response_status { + ResponseStatus::Code => Some(opentelemetry::Value::I64( + response.response.status().as_u16() as i64, + )), + ResponseStatus::Reason => response + .response + .status() + .canonical_reason() + .map(|reason| reason.into()), + }, + SubgraphSelector::SubgraphOperationKind { .. } => response + .context + .get::<_, String>(OPERATION_KIND) + .ok() + .flatten() + .map(opentelemetry::Value::from), + SubgraphSelector::SupergraphOperationKind { .. } => response + .context + .get::<_, String>(OPERATION_KIND) + .ok() + .flatten() + .map(opentelemetry::Value::from), + SubgraphSelector::SupergraphOperationName { + supergraph_operation_name, + default, + .. + } => { + let op_name = response.context.get(OPERATION_NAME).ok().flatten(); + match supergraph_operation_name { + OperationName::String => op_name.or_else(|| default.clone()), + OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { + let mut hasher = sha2::Sha256::new(); + hasher.update(op_name.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) + }), + } + .map(opentelemetry::Value::from) + } + SubgraphSelector::SubgraphName { subgraph_name } if *subgraph_name => { + Some(response.subgraph_name.clone().into()) + } + SubgraphSelector::SubgraphResponseData { + subgraph_response_data, + default, + .. + } => if let Some(data) = &response.response.body().data { + let val = subgraph_response_data.find(data); + + val.maybe_to_otel_value() + } else { + None + } + .or_else(|| default.maybe_to_otel_value()), + SubgraphSelector::SubgraphResponseErrors { + subgraph_response_errors: subgraph_response_error, + default, + .. + } => { + let errors = response.response.body().errors.clone(); + let data: serde_json_bytes::Value = serde_json_bytes::to_value(errors).ok()?; + + let val = subgraph_response_error.find(&data); + + val.maybe_to_otel_value() + } + .or_else(|| default.maybe_to_otel_value()), + SubgraphSelector::ResponseContext { + response_context, + default, + .. + } => response + .context + .get_json_value(response_context) + .as_ref() + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + SubgraphSelector::OnGraphQLError { + subgraph_on_graphql_error: on_graphql_error, + } if *on_graphql_error => Some((!response.response.body().errors.is_empty()).into()), + SubgraphSelector::SubgraphResendCount { + subgraph_resend_count, + default, + } if *subgraph_resend_count => { + response + .context + .get::<_, usize>(SubgraphRequestResendCountKey::new(&response.id)) + .ok() + .flatten() + .map(|v| opentelemetry::Value::from(v as i64)) + } + .or_else(|| default.maybe_to_otel_value()), + SubgraphSelector::Static(val) => Some(val.clone().into()), + SubgraphSelector::StaticField { r#static } => Some(r#static.clone().into()), + SubgraphSelector::Cache { cache, entity_type } => { + let cache_info: CacheSubgraph = response + .context + .get(CacheMetricContextKey::new(response.subgraph_name.clone())) + .ok() + .flatten()?; + + match entity_type { + Some(EntityType::All(All::All)) | None => Some( + (cache_info + .0 + .iter() + .fold(0usize, |acc, (_entity_type, cache_hit_miss)| match cache { + CacheKind::Hit => acc + cache_hit_miss.hit, + CacheKind::Miss => acc + cache_hit_miss.miss, + }) as i64) + .into(), + ), + Some(EntityType::Named(entity_type_name)) => { + let res = cache_info.0.iter().fold( + 0usize, + |acc, (entity_type, cache_hit_miss)| { + if entity_type == entity_type_name { + match cache { + CacheKind::Hit => acc + cache_hit_miss.hit, + CacheKind::Miss => acc + cache_hit_miss.miss, + } + } else { + acc + } + }, + ); + + (res != 0).then_some((res as i64).into()) + } + } + } + // For request + _ => None, + } + } + + fn on_error(&self, error: &tower::BoxError, ctx: &Context) -> Option { + match self { + SubgraphSelector::SubgraphOperationKind { .. } => ctx + .get::<_, String>(OPERATION_KIND) + .ok() + .flatten() + .map(opentelemetry::Value::from), + SubgraphSelector::SupergraphOperationKind { .. } => ctx + .get::<_, String>(OPERATION_KIND) + .ok() + .flatten() + .map(opentelemetry::Value::from), + SubgraphSelector::SupergraphOperationName { + supergraph_operation_name, + default, + .. + } => { + let op_name = ctx.get(OPERATION_NAME).ok().flatten(); + match supergraph_operation_name { + OperationName::String => op_name.or_else(|| default.clone()), + OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { + let mut hasher = sha2::Sha256::new(); + hasher.update(op_name.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) + }), + } + .map(opentelemetry::Value::from) + } + SubgraphSelector::Error { .. } => Some(error.to_string().into()), + SubgraphSelector::Static(val) => Some(val.clone().into()), + SubgraphSelector::StaticField { r#static } => Some(r#static.clone().into()), + SubgraphSelector::ResponseContext { + response_context, + default, + .. + } => ctx + .get_json_value(response_context) + .as_ref() + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + _ => None, + } + } + + fn on_drop(&self) -> Option { + match self { + SubgraphSelector::Static(val) => Some(val.clone().into()), + SubgraphSelector::StaticField { r#static } => Some(r#static.clone().into()), + _ => None, + } + } + + fn is_active(&self, stage: Stage) -> bool { + match stage { + Stage::Request => matches!( + self, + SubgraphSelector::SubgraphOperationName { .. } + | SubgraphSelector::SupergraphOperationName { .. } + | SubgraphSelector::SubgraphName { .. } + | SubgraphSelector::SubgraphOperationKind { .. } + | SubgraphSelector::SupergraphOperationKind { .. } + | SubgraphSelector::SupergraphQuery { .. } + | SubgraphSelector::SubgraphQuery { .. } + | SubgraphSelector::SubgraphQueryVariable { .. } + | SubgraphSelector::SupergraphQueryVariable { .. } + | SubgraphSelector::SubgraphRequestHeader { .. } + | SubgraphSelector::SupergraphRequestHeader { .. } + | SubgraphSelector::RequestContext { .. } + | SubgraphSelector::Baggage { .. } + | SubgraphSelector::Env { .. } + | SubgraphSelector::Static(_) + | SubgraphSelector::StaticField { .. } + ), + Stage::Response => matches!( + self, + SubgraphSelector::SubgraphResponseHeader { .. } + | SubgraphSelector::SubgraphResponseStatus { .. } + | SubgraphSelector::SubgraphOperationKind { .. } + | SubgraphSelector::SupergraphOperationKind { .. } + | SubgraphSelector::SupergraphOperationName { .. } + | SubgraphSelector::SubgraphName { .. } + | SubgraphSelector::SubgraphResponseData { .. } + | SubgraphSelector::SubgraphResponseErrors { .. } + | SubgraphSelector::ResponseContext { .. } + | SubgraphSelector::OnGraphQLError { .. } + | SubgraphSelector::Static(_) + | SubgraphSelector::StaticField { .. } + | SubgraphSelector::Cache { .. } + ), + Stage::ResponseEvent => false, + Stage::ResponseField => false, + Stage::Error => matches!( + self, + SubgraphSelector::SubgraphOperationKind { .. } + | SubgraphSelector::SupergraphOperationKind { .. } + | SubgraphSelector::SupergraphOperationName { .. } + | SubgraphSelector::Error { .. } + | SubgraphSelector::Static(_) + | SubgraphSelector::StaticField { .. } + | SubgraphSelector::ResponseContext { .. } + ), + Stage::Drop => matches!( + self, + SubgraphSelector::Static(_) | SubgraphSelector::StaticField { .. } + ), + } + } +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + use std::sync::Arc; + + use http::StatusCode; + use opentelemetry::Context; + use opentelemetry::KeyValue; + use opentelemetry::StringValue; + use opentelemetry::baggage::BaggageExt; + use opentelemetry::trace::SpanContext; + use opentelemetry::trace::SpanId; + use opentelemetry::trace::TraceContextExt; + use opentelemetry::trace::TraceFlags; + use opentelemetry::trace::TraceId; + use opentelemetry::trace::TraceState; + use serde_json_bytes::path::JsonPathInst; + use tower::BoxError; + use tracing::span; + use tracing::subscriber; + use tracing_subscriber::layer::SubscriberExt; + + use crate::context::OPERATION_KIND; + use crate::context::OPERATION_NAME; + use crate::graphql; + use crate::plugins::cache::entity::CacheHitMiss; + use crate::plugins::cache::entity::CacheSubgraph; + use crate::plugins::cache::metrics::CacheMetricContextKey; + use crate::plugins::telemetry::config::AttributeValue; + use crate::plugins::telemetry::config_new::Selector; + use crate::plugins::telemetry::config_new::selectors::All; + use crate::plugins::telemetry::config_new::selectors::CacheKind; + use crate::plugins::telemetry::config_new::selectors::EntityType; + use crate::plugins::telemetry::config_new::selectors::OperationKind; + use crate::plugins::telemetry::config_new::selectors::OperationName; + use crate::plugins::telemetry::config_new::selectors::Query; + use crate::plugins::telemetry::config_new::selectors::ResponseStatus; + use crate::plugins::telemetry::config_new::subgraph::attributes::SubgraphRequestResendCountKey; + use crate::plugins::telemetry::config_new::subgraph::selectors::SubgraphQuery; + use crate::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector; + use crate::plugins::telemetry::otel; + use crate::services::subgraph::SubgraphRequestId; + + #[test] + fn subgraph_static() { + let selector = SubgraphSelector::Static("test_static".to_string()); + assert_eq!( + selector + .on_request( + &crate::services::SubgraphRequest::fake_builder() + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap() + )) + .build() + ) + .unwrap(), + "test_static".into() + ); + assert_eq!(selector.on_drop().unwrap(), "test_static".into()); + } + + #[test] + fn subgraph_static_field() { + let selector = SubgraphSelector::StaticField { + r#static: "test_static".to_string().into(), + }; + assert_eq!( + selector + .on_request( + &crate::services::SubgraphRequest::fake_builder() + .supergraph_request(Arc::new( + http::Request::builder() + .body(graphql::Request::builder().build()) + .unwrap() + )) + .build() + ) + .unwrap(), + "test_static".into() + ); + assert_eq!(selector.on_drop().unwrap(), "test_static".into()); + } + + #[test] + fn subgraph_supergraph_request_header() { + let selector = SubgraphSelector::SupergraphRequestHeader { + supergraph_request_header: "header_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + assert_eq!( + selector + .on_request( + &crate::services::SubgraphRequest::fake_builder() + .supergraph_request(Arc::new( + http::Request::builder() + .header("header_key", "header_value") + .body(graphql::Request::builder().build()) + .unwrap() + )) + .build() + ) + .unwrap(), + "header_value".into() + ); + + assert_eq!( + selector + .on_request(&crate::services::SubgraphRequest::fake_builder().build()) + .unwrap(), + "defaulted".into() + ); + + assert_eq!( + selector.on_response( + &crate::services::SubgraphResponse::fake2_builder() + .header("header_key", "header_value") + .build() + .unwrap() + ), + None + ); + } + + #[test] + fn subgraph_subgraph_request_header() { + let selector = SubgraphSelector::SubgraphRequestHeader { + subgraph_request_header: "header_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + assert_eq!( + selector + .on_request( + &crate::services::SubgraphRequest::fake_builder() + .subgraph_request( + http::Request::builder() + .header("header_key", "header_value") + .body(graphql::Request::fake_builder().build()) + .unwrap() + ) + .build() + ) + .unwrap(), + "header_value".into() + ); + + assert_eq!( + selector + .on_request(&crate::services::SubgraphRequest::fake_builder().build()) + .unwrap(), + "defaulted".into() + ); + + assert_eq!( + selector.on_response( + &crate::services::SubgraphResponse::fake2_builder() + .header("header_key", "header_value") + .build() + .unwrap() + ), + None + ); + } + + #[test] + fn subgraph_subgraph_response_header() { + let selector = SubgraphSelector::SubgraphResponseHeader { + subgraph_response_header: "header_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + assert_eq!( + selector + .on_response( + &crate::services::SubgraphResponse::fake2_builder() + .header("header_key", "header_value") + .build() + .unwrap() + ) + .unwrap(), + "header_value".into() + ); + + assert_eq!( + selector + .on_response( + &crate::services::SubgraphResponse::fake2_builder() + .build() + .unwrap() + ) + .unwrap(), + "defaulted".into() + ); + + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .subgraph_request( + http::Request::builder() + .header("header_key", "header_value") + .body(graphql::Request::fake_builder().build()) + .unwrap() + ) + .build() + ), + None + ); + } + + #[test] + fn subgraph_request_context() { + let selector = SubgraphSelector::RequestContext { + request_context: "context_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + let context = crate::context::Context::new(); + let _ = context.insert("context_key".to_string(), "context_value".to_string()); + assert_eq!( + selector + .on_request( + &crate::services::SubgraphRequest::fake_builder() + .context(context.clone()) + .build() + ) + .unwrap(), + "context_value".into() + ); + + assert_eq!( + selector + .on_request(&crate::services::SubgraphRequest::fake_builder().build()) + .unwrap(), + "defaulted".into() + ); + assert_eq!( + selector.on_response( + &crate::services::SubgraphResponse::fake2_builder() + .context(context) + .build() + .unwrap() + ), + None + ); + } + + #[test] + fn subgraph_response_context() { + let selector = SubgraphSelector::ResponseContext { + response_context: "context_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + let context = crate::context::Context::new(); + let _ = context.insert("context_key".to_string(), "context_value".to_string()); + assert_eq!( + selector + .on_response( + &crate::services::SubgraphResponse::fake2_builder() + .context(context.clone()) + .build() + .unwrap() + ) + .unwrap(), + "context_value".into() + ); + + assert_eq!( + selector + .on_error(&BoxError::from(String::from("my error")), &context) + .unwrap(), + "context_value".into() + ); + + assert_eq!( + selector + .on_response( + &crate::services::SubgraphResponse::fake2_builder() + .build() + .unwrap() + ) + .unwrap(), + "defaulted".into() + ); + + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .context(context) + .build() + ), + None + ); + } + + #[test] + fn subgraph_resend_count() { + let selector = SubgraphSelector::SubgraphResendCount { + subgraph_resend_count: true, + default: Some("defaulted".into()), + }; + let context = crate::context::Context::new(); + assert_eq!( + selector + .on_response( + &crate::services::SubgraphResponse::fake2_builder() + .context(context.clone()) + .build() + .unwrap() + ) + .unwrap(), + "defaulted".into() + ); + let subgraph_req_id = SubgraphRequestId(String::from("test")); + let _ = context.insert(SubgraphRequestResendCountKey::new(&subgraph_req_id), 2usize); + + assert_eq!( + selector + .on_response( + &crate::services::SubgraphResponse::fake2_builder() + .context(context.clone()) + .id(subgraph_req_id) + .build() + .unwrap() + ) + .unwrap(), + 2i64.into() + ); + } + + #[test] + fn subgraph_baggage() { + let subscriber = tracing_subscriber::registry().with(otel::layer()); + subscriber::with_default(subscriber, || { + let selector = SubgraphSelector::Baggage { + baggage: "baggage_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + let span_context = SpanContext::new( + TraceId::from_u128(42), + SpanId::from_u64(42), + // Make sure it's sampled if not, it won't create anything at the otel layer + TraceFlags::default().with_sampled(true), + false, + TraceState::default(), + ); + assert_eq!( + selector + .on_request(&crate::services::SubgraphRequest::fake_builder().build()) + .unwrap(), + "defaulted".into() + ); + let _outer_guard = Context::new() + .with_baggage(vec![KeyValue::new("baggage_key", "baggage_value")]) + .with_remote_span_context(span_context) + .attach(); + + let span = span!(tracing::Level::INFO, "test"); + let _guard = span.enter(); + + assert_eq!( + selector + .on_request(&crate::services::SubgraphRequest::fake_builder().build()) + .unwrap(), + "baggage_value".into() + ); + }); + } + + #[test] + fn subgraph_env() { + let mut selector = SubgraphSelector::Env { + env: "SELECTOR_SUBGRAPH_ENV_VARIABLE".to_string(), + redact: None, + default: Some("defaulted".to_string()), + mocked_env_var: None, + }; + assert_eq!( + selector.on_request(&crate::services::SubgraphRequest::fake_builder().build()), + Some("defaulted".into()) + ); + + if let SubgraphSelector::Env { mocked_env_var, .. } = &mut selector { + *mocked_env_var = Some("env_value".to_string()) + } + assert_eq!( + selector.on_request(&crate::services::SubgraphRequest::fake_builder().build()), + Some("env_value".into()) + ); + } + + #[test] + fn subgraph_operation_kind() { + let selector = SubgraphSelector::SupergraphOperationKind { + supergraph_operation_kind: OperationKind::String, + }; + let context = crate::context::Context::new(); + let _ = context.insert(OPERATION_KIND, "query".to_string()); + // For now operation kind is contained in context + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .context(context.clone()) + .build(), + ), + Some("query".into()) + ); + assert_eq!( + selector.on_response( + &crate::services::SubgraphResponse::fake_builder() + .context(context) + .build(), + ), + Some("query".into()) + ); + } + + #[test] + fn subgraph_name() { + let selector = SubgraphSelector::SubgraphName { + subgraph_name: true, + }; + let context = crate::context::Context::new(); + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .context(context.clone()) + .subgraph_name("test".to_string()) + .build(), + ), + Some("test".into()) + ); + assert_eq!( + selector.on_response( + &crate::services::SubgraphResponse::fake_builder() + .context(context) + .subgraph_name("test".to_string()) + .build(), + ), + Some("test".into()) + ); + } + + #[test] + fn response_cache_hit_all_entities() { + let selector = SubgraphSelector::Cache { + cache: CacheKind::Hit, + entity_type: Some(EntityType::All(All::All)), + }; + let context = crate::context::Context::new(); + assert_eq!( + selector.on_response( + &crate::services::SubgraphResponse::fake_builder() + .subgraph_name("test".to_string()) + .context(context.clone()) + .build(), + ), + None + ); + let cache_info = CacheSubgraph( + [ + ("Products".to_string(), CacheHitMiss { hit: 3, miss: 0 }), + ("Reviews".to_string(), CacheHitMiss { hit: 2, miss: 0 }), + ] + .into_iter() + .collect(), + ); + let _ = context + .insert(CacheMetricContextKey::new("test".to_string()), cache_info) + .unwrap(); + assert_eq!( + selector.on_response( + &crate::services::SubgraphResponse::fake_builder() + .subgraph_name("test".to_string()) + .context(context.clone()) + .build(), + ), + Some(opentelemetry::Value::I64(5)) + ); + } + + #[test] + fn response_cache_hit_one_entity() { + let selector = SubgraphSelector::Cache { + cache: CacheKind::Hit, + entity_type: Some(EntityType::Named("Reviews".to_string())), + }; + let context = crate::context::Context::new(); + assert_eq!( + selector.on_response( + &crate::services::SubgraphResponse::fake_builder() + .subgraph_name("test".to_string()) + .context(context.clone()) + .build(), + ), + None + ); + let cache_info = CacheSubgraph( + [ + ("Products".to_string(), CacheHitMiss { hit: 3, miss: 0 }), + ("Reviews".to_string(), CacheHitMiss { hit: 2, miss: 0 }), + ] + .into_iter() + .collect(), + ); + let _ = context + .insert(CacheMetricContextKey::new("test".to_string()), cache_info) + .unwrap(); + assert_eq!( + selector.on_response( + &crate::services::SubgraphResponse::fake_builder() + .subgraph_name("test".to_string()) + .context(context.clone()) + .build(), + ), + Some(opentelemetry::Value::I64(2)) + ); + } + + #[test] + fn subgraph_supergraph_operation_name_string() { + let selector = SubgraphSelector::SupergraphOperationName { + supergraph_operation_name: OperationName::String, + redact: None, + default: Some("defaulted".to_string()), + }; + let context = crate::context::Context::new(); + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .context(context.clone()) + .build(), + ), + Some("defaulted".into()) + ); + let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); + // For now operation kind is contained in context + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .context(context.clone()) + .build(), + ), + Some("topProducts".into()) + ); + assert_eq!( + selector.on_response( + &crate::services::SubgraphResponse::fake_builder() + .context(context) + .build(), + ), + Some("topProducts".into()) + ); + } + + #[test] + fn subgraph_subgraph_operation_name_string() { + let selector = SubgraphSelector::SubgraphOperationName { + subgraph_operation_name: OperationName::String, + redact: None, + default: Some("defaulted".to_string()), + }; + assert_eq!( + selector.on_request(&crate::services::SubgraphRequest::fake_builder().build(),), + Some("defaulted".into()) + ); + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .subgraph_request( + ::http::Request::builder() + .uri("http://localhost/graphql") + .body( + graphql::Request::fake_builder() + .operation_name("topProducts") + .build() + ) + .unwrap() + ) + .build(), + ), + Some("topProducts".into()) + ); + } + + #[test] + fn subgraph_supergraph_operation_name_hash() { + let selector = SubgraphSelector::SupergraphOperationName { + supergraph_operation_name: OperationName::Hash, + redact: None, + default: Some("defaulted".to_string()), + }; + let context = crate::context::Context::new(); + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .context(context.clone()) + .build(), + ), + Some("96294f50edb8f006f6b0a2dadae50d3c521e9841d07d6395d91060c8ccfed7f0".into()) + ); + + let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .context(context) + .build(), + ), + Some("bd141fca26094be97c30afd42e9fc84755b252e7052d8c992358319246bd555a".into()) + ); + } + + #[test] + fn subgraph_subgraph_operation_name_hash() { + let selector = SubgraphSelector::SubgraphOperationName { + subgraph_operation_name: OperationName::Hash, + redact: None, + default: Some("defaulted".to_string()), + }; + assert_eq!( + selector.on_request(&crate::services::SubgraphRequest::fake_builder().build()), + Some("96294f50edb8f006f6b0a2dadae50d3c521e9841d07d6395d91060c8ccfed7f0".into()) + ); + + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .subgraph_request( + ::http::Request::builder() + .uri("http://localhost/graphql") + .body( + graphql::Request::fake_builder() + .operation_name("topProducts") + .build() + ) + .unwrap() + ) + .build() + ), + Some("bd141fca26094be97c30afd42e9fc84755b252e7052d8c992358319246bd555a".into()) + ); + } + + #[test] + fn subgraph_supergraph_query() { + let selector = SubgraphSelector::SupergraphQuery { + supergraph_query: Query::String, + redact: None, + default: Some("default".to_string()), + }; + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .supergraph_request(Arc::new( + http::Request::builder() + .body( + graphql::Request::fake_builder() + .query("topProducts{name}") + .build() + ) + .unwrap() + )) + .build(), + ), + Some("topProducts{name}".into()) + ); + + assert_eq!( + selector.on_request(&crate::services::SubgraphRequest::fake_builder().build(),), + Some("default".into()) + ); + } + + #[test] + fn subgraph_subgraph_query() { + let selector = SubgraphSelector::SubgraphQuery { + subgraph_query: SubgraphQuery::String, + redact: None, + default: Some("default".to_string()), + }; + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .subgraph_request( + http::Request::builder() + .body( + graphql::Request::fake_builder() + .query("topProducts{name}") + .build() + ) + .unwrap() + ) + .build(), + ), + Some("topProducts{name}".into()) + ); + + assert_eq!( + selector.on_request(&crate::services::SubgraphRequest::fake_builder().build(),), + Some("default".into()) + ); + } + + #[test] + fn subgraph_subgraph_response_status_code() { + let selector = SubgraphSelector::SubgraphResponseStatus { + subgraph_response_status: ResponseStatus::Code, + }; + assert_eq!( + selector + .on_response( + &crate::services::SubgraphResponse::fake_builder() + .status_code(StatusCode::NO_CONTENT) + .build() + ) + .unwrap(), + opentelemetry::Value::I64(204) + ); + } + + #[test] + fn subgraph_subgraph_response_data() { + let selector = SubgraphSelector::SubgraphResponseData { + subgraph_response_data: JsonPathInst::from_str("$.hello").unwrap(), + redact: None, + default: None, + }; + assert_eq!( + selector + .on_response( + &crate::services::SubgraphResponse::fake_builder() + .data(serde_json_bytes::json!({ + "hello": "bonjour" + })) + .build() + ) + .unwrap(), + opentelemetry::Value::String("bonjour".into()) + ); + + assert_eq!( + selector + .on_response( + &crate::services::SubgraphResponse::fake_builder() + .data(serde_json_bytes::json!({ + "hello": ["bonjour", "hello", "ciao"] + })) + .build() + ) + .unwrap(), + opentelemetry::Value::Array( + vec![ + StringValue::from("bonjour"), + StringValue::from("hello"), + StringValue::from("ciao") + ] + .into() + ) + ); + + assert!( + selector + .on_response( + &crate::services::SubgraphResponse::fake_builder() + .data(serde_json_bytes::json!({ + "hi": ["bonjour", "hello", "ciao"] + })) + .build() + ) + .is_none() + ); + + let selector = SubgraphSelector::SubgraphResponseData { + subgraph_response_data: JsonPathInst::from_str("$.hello.*.greeting").unwrap(), + redact: None, + default: None, + }; + assert_eq!( + selector + .on_response( + &crate::services::SubgraphResponse::fake_builder() + .data(serde_json_bytes::json!({ + "hello": { + "french": { + "greeting": "bonjour" + }, + "english": { + "greeting": "hello" + }, + "italian": { + "greeting": "ciao" + } + } + })) + .build() + ) + .unwrap(), + opentelemetry::Value::Array( + vec![ + StringValue::from("bonjour"), + StringValue::from("hello"), + StringValue::from("ciao") + ] + .into() + ) + ); + } + + #[test] + fn subgraph_on_graphql_error() { + let selector = SubgraphSelector::OnGraphQLError { + subgraph_on_graphql_error: true, + }; + assert_eq!( + selector + .on_response( + &crate::services::SubgraphResponse::fake_builder() + .error( + graphql::Error::builder() + .message("not found") + .extension_code("NOT_FOUND") + .build() + ) + .build() + ) + .unwrap(), + opentelemetry::Value::Bool(true) + ); + + assert_eq!( + selector + .on_response( + &crate::services::SubgraphResponse::fake_builder() + .data(serde_json_bytes::json!({ + "hello": ["bonjour", "hello", "ciao"] + })) + .build() + ) + .unwrap(), + opentelemetry::Value::Bool(false) + ); + } + + #[test] + fn subgraph_subgraph_response_status_reason() { + let selector = SubgraphSelector::SubgraphResponseStatus { + subgraph_response_status: ResponseStatus::Reason, + }; + assert_eq!( + selector + .on_response( + &crate::services::SubgraphResponse::fake_builder() + .status_code(StatusCode::NO_CONTENT) + .build() + ) + .unwrap(), + "No Content".into() + ); + } + + #[test] + fn subgraph_supergraph_query_variable() { + let selector = SubgraphSelector::SupergraphQueryVariable { + supergraph_query_variable: "key".to_string(), + redact: None, + default: Some(AttributeValue::String("default".to_string())), + }; + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .supergraph_request(Arc::new( + http::Request::builder() + .body( + graphql::Request::fake_builder() + .variable("key", "value") + .build() + ) + .unwrap() + )) + .build(), + ), + Some("value".into()) + ); + + assert_eq!( + selector.on_request(&crate::services::SubgraphRequest::fake_builder().build(),), + Some("default".into()) + ); + } + + #[test] + fn subgraph_subgraph_query_variable() { + let selector = SubgraphSelector::SubgraphQueryVariable { + subgraph_query_variable: "key".to_string(), + redact: None, + default: Some("default".into()), + }; + assert_eq!( + selector.on_request( + &crate::services::SubgraphRequest::fake_builder() + .subgraph_request( + http::Request::builder() + .body( + graphql::Request::fake_builder() + .variable("key", "value") + .build() + ) + .unwrap() + ) + .build(), + ), + Some("value".into()) + ); + + assert_eq!( + selector.on_request(&crate::services::SubgraphRequest::fake_builder().build(),), + Some("default".into()) + ); + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/subgraph/snapshots/apollo_router__plugins__telemetry__config_new__subgraph__events__test__subgraph_events@logs.snap b/apollo-router/src/plugins/telemetry/config_new/subgraph/snapshots/apollo_router__plugins__telemetry__config_new__subgraph__events__test__subgraph_events@logs.snap new file mode 100644 index 0000000000..91df73b8cc --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/subgraph/snapshots/apollo_router__plugins__telemetry__config_new__subgraph__events__test__subgraph_events@logs.snap @@ -0,0 +1,26 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/subgraph/events.rs +expression: yaml +--- +- fields: + kind: my.request.event + level: INFO + message: my event message + span: + name: subgraph + otel.kind: INTERNAL + spans: + - name: subgraph + otel.kind: INTERNAL +- fields: + kind: my.response.event + level: ERROR + message: my response event message + span: + name: subgraph + otel.kind: INTERNAL + otel.status_code: OK + spans: + - name: subgraph + otel.kind: INTERNAL + otel.status_code: OK diff --git a/apollo-router/src/plugins/telemetry/config_new/subgraph/snapshots/apollo_router__plugins__telemetry__config_new__subgraph__events__test__subgraph_events_response@logs.snap b/apollo-router/src/plugins/telemetry/config_new/subgraph/snapshots/apollo_router__plugins__telemetry__config_new__subgraph__events__test__subgraph_events_response@logs.snap new file mode 100644 index 0000000000..91df73b8cc --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/subgraph/snapshots/apollo_router__plugins__telemetry__config_new__subgraph__events__test__subgraph_events_response@logs.snap @@ -0,0 +1,26 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/subgraph/events.rs +expression: yaml +--- +- fields: + kind: my.request.event + level: INFO + message: my event message + span: + name: subgraph + otel.kind: INTERNAL + spans: + - name: subgraph + otel.kind: INTERNAL +- fields: + kind: my.response.event + level: ERROR + message: my response event message + span: + name: subgraph + otel.kind: INTERNAL + otel.status_code: OK + spans: + - name: subgraph + otel.kind: INTERNAL + otel.status_code: OK diff --git a/apollo-router/src/plugins/telemetry/config_new/subgraph/spans.rs b/apollo-router/src/plugins/telemetry/config_new/subgraph/spans.rs new file mode 100644 index 0000000000..3a65298e40 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/subgraph/spans.rs @@ -0,0 +1,270 @@ +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::conditional::Conditional; +use crate::plugins::telemetry::config_new::extendable::Extendable; +use crate::plugins::telemetry::config_new::subgraph::attributes::SubgraphAttributes; +use crate::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector; +use crate::plugins::telemetry::otlp::TelemetryDataKind; + +#[derive(Deserialize, JsonSchema, Clone, Default, Debug)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct SubgraphSpans { + /// Custom attributes that are attached to the subgraph span. + pub(crate) attributes: Extendable>, +} + +impl DefaultForLevel for SubgraphSpans { + fn defaults_for_level( + &mut self, + requirement_level: DefaultAttributeRequirementLevel, + kind: TelemetryDataKind, + ) { + self.attributes.defaults_for_level(requirement_level, kind); + } +} + +#[cfg(test)] +mod test { + use std::sync::Arc; + + use opentelemetry_semantic_conventions::trace::GRAPHQL_DOCUMENT; + use parking_lot::Mutex; + + use super::SubgraphSpans; + use crate::graphql; + use crate::plugins::telemetry::config::AttributeValue; + use crate::plugins::telemetry::config_new::DefaultForLevel; + use crate::plugins::telemetry::config_new::Selectors; + use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; + use crate::plugins::telemetry::config_new::conditional::Conditional; + use crate::plugins::telemetry::config_new::conditions::Condition; + use crate::plugins::telemetry::config_new::conditions::SelectorOrValue; + use crate::plugins::telemetry::config_new::selectors::ResponseStatus; + use crate::plugins::telemetry::config_new::subgraph::attributes::SUBGRAPH_GRAPHQL_DOCUMENT; + use crate::plugins::telemetry::config_new::subgraph::selectors::SubgraphSelector; + use crate::plugins::telemetry::otlp::TelemetryDataKind; + use crate::services::subgraph; + + #[test] + fn test_subgraph_spans_level_none() { + let mut spans = SubgraphSpans::default(); + spans.defaults_for_levels( + DefaultAttributeRequirementLevel::None, + TelemetryDataKind::Traces, + ); + let values = spans.attributes.on_request( + &subgraph::Request::fake_builder() + .subgraph_request( + ::http::Request::builder() + .uri("http://localhost/graphql") + .body( + graphql::Request::fake_builder() + .query("query { __typename }") + .build(), + ) + .unwrap(), + ) + .build(), + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key.as_str() == GRAPHQL_DOCUMENT) + ); + } + + #[test] + fn test_subgraph_spans_level_required() { + let mut spans = SubgraphSpans::default(); + spans.defaults_for_levels( + DefaultAttributeRequirementLevel::Required, + TelemetryDataKind::Traces, + ); + let values = spans.attributes.on_request( + &subgraph::Request::fake_builder() + .subgraph_request( + ::http::Request::builder() + .uri("http://localhost/graphql") + .body( + graphql::Request::fake_builder() + .query("query { __typename }") + .build(), + ) + .unwrap(), + ) + .build(), + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key.as_str() == GRAPHQL_DOCUMENT) + ); + } + + #[test] + fn test_subgraph_spans_level_recommended() { + let mut spans = SubgraphSpans::default(); + spans.defaults_for_levels( + DefaultAttributeRequirementLevel::Recommended, + TelemetryDataKind::Traces, + ); + let values = spans.attributes.on_request( + &subgraph::Request::fake_builder() + .subgraph_request( + ::http::Request::builder() + .uri("http://localhost/graphql") + .body( + graphql::Request::fake_builder() + .query("query { __typename }") + .build(), + ) + .unwrap(), + ) + .build(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key == SUBGRAPH_GRAPHQL_DOCUMENT) + ); + } + + #[test] + fn test_subgraph_request_custom_attribute() { + let mut spans = SubgraphSpans::default(); + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: SubgraphSelector::SubgraphRequestHeader { + subgraph_request_header: "my-header".to_string(), + redact: None, + default: None, + }, + condition: None, + value: Arc::new(Default::default()), + }, + ); + let values = spans.attributes.on_request( + &subgraph::Request::fake_builder() + .subgraph_request( + ::http::Request::builder() + .uri("http://localhost/graphql") + .header("my-header", "test_val") + .body( + graphql::Request::fake_builder() + .query("query { __typename }") + .build(), + ) + .unwrap(), + ) + .build(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test")) + ); + } + + #[test] + fn test_subgraph_response_custom_attribute() { + let mut spans = SubgraphSpans::default(); + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: SubgraphSelector::SubgraphResponseHeader { + subgraph_response_header: "my-header".to_string(), + redact: None, + default: None, + }, + condition: None, + value: Arc::new(Default::default()), + }, + ); + let values = spans.attributes.on_response( + &subgraph::Response::fake2_builder() + .header("my-header", "test_val") + .subgraph_name(String::default()) + .build() + .unwrap(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test")) + ); + } + + #[test] + fn test_subgraph_response_custom_attribute_good_condition() { + let mut spans = SubgraphSpans::default(); + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: SubgraphSelector::SubgraphResponseHeader { + subgraph_response_header: "my-header".to_string(), + redact: None, + default: None, + }, + condition: Some(Arc::new(Mutex::new(Condition::Eq([ + SelectorOrValue::Value(AttributeValue::I64(200)), + SelectorOrValue::Selector(SubgraphSelector::SubgraphResponseStatus { + subgraph_response_status: ResponseStatus::Code, + }), + ])))), + value: Arc::new(Default::default()), + }, + ); + let values = spans.attributes.on_response( + &subgraph::Response::fake2_builder() + .header("my-header", "test_val") + .status_code(http::StatusCode::OK) + .subgraph_name(String::default()) + .build() + .unwrap(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test")) + ); + } + + #[test] + fn test_subgraph_response_custom_attribute_bad_condition() { + let mut spans = SubgraphSpans::default(); + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: SubgraphSelector::SubgraphResponseHeader { + subgraph_response_header: "my-header".to_string(), + redact: None, + default: None, + }, + condition: Some(Arc::new(Mutex::new(Condition::Eq([ + SelectorOrValue::Value(AttributeValue::I64(400)), + SelectorOrValue::Selector(SubgraphSelector::SubgraphResponseStatus { + subgraph_response_status: ResponseStatus::Code, + }), + ])))), + value: Arc::new(Default::default()), + }, + ); + let values = spans.attributes.on_response( + &subgraph::Response::fake2_builder() + .header("my-header", "test_val") + .status_code(http::StatusCode::OK) + .subgraph_name(String::default()) + .build() + .unwrap(), + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test")) + ); + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/supergraph/attributes.rs b/apollo-router/src/plugins/telemetry/config_new/supergraph/attributes.rs new file mode 100644 index 0000000000..80edc0e123 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/supergraph/attributes.rs @@ -0,0 +1,243 @@ +use std::fmt::Debug; + +use opentelemetry::KeyValue; +use opentelemetry_semantic_conventions::trace::GRAPHQL_DOCUMENT; +use opentelemetry_semantic_conventions::trace::GRAPHQL_OPERATION_NAME; +use opentelemetry_semantic_conventions::trace::GRAPHQL_OPERATION_TYPE; +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; + +use crate::Context; +use crate::context::OPERATION_KIND; +use crate::context::OPERATION_NAME; +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::Selectors; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::attributes::StandardAttribute; +use crate::plugins::telemetry::config_new::cost::SupergraphCostAttributes; +use crate::plugins::telemetry::otlp::TelemetryDataKind; +use crate::services::supergraph; + +#[derive(Deserialize, JsonSchema, Clone, Default, Debug)] +#[cfg_attr(test, derive(PartialEq))] +#[serde(deny_unknown_fields, default)] +pub(crate) struct SupergraphAttributes { + /// The GraphQL document being executed. + /// Examples: + /// + /// * `query findBookById { bookById(id: ?) { name } }` + /// + /// Requirement level: Recommended + #[serde(rename = "graphql.document")] + pub(crate) graphql_document: Option, + + /// The name of the operation being executed. + /// Examples: + /// + /// * findBookById + /// + /// Requirement level: Recommended + #[serde(rename = "graphql.operation.name")] + pub(crate) graphql_operation_name: Option, + + /// The type of the operation being executed. + /// Examples: + /// + /// * query + /// * subscription + /// * mutation + /// + /// Requirement level: Recommended + #[serde(rename = "graphql.operation.type")] + pub(crate) graphql_operation_type: Option, + + /// Cost attributes for the operation being executed + #[serde(flatten)] + pub(crate) cost: SupergraphCostAttributes, +} + +impl DefaultForLevel for SupergraphAttributes { + fn defaults_for_level( + &mut self, + requirement_level: DefaultAttributeRequirementLevel, + _kind: TelemetryDataKind, + ) { + match requirement_level { + DefaultAttributeRequirementLevel::Required => {} + DefaultAttributeRequirementLevel::Recommended => { + if self.graphql_document.is_none() { + self.graphql_document = Some(StandardAttribute::Bool(true)); + } + if self.graphql_operation_name.is_none() { + self.graphql_operation_name = Some(StandardAttribute::Bool(true)); + } + if self.graphql_operation_type.is_none() { + self.graphql_operation_type = Some(StandardAttribute::Bool(true)); + } + } + DefaultAttributeRequirementLevel::None => {} + } + } +} + +impl Selectors + for SupergraphAttributes +{ + fn on_request(&self, request: &supergraph::Request) -> Vec { + let mut attrs = Vec::new(); + if let Some(key) = self + .graphql_document + .as_ref() + .and_then(|a| a.key(GRAPHQL_DOCUMENT.into())) + && let Some(query) = &request.supergraph_request.body().query + { + attrs.push(KeyValue::new(key, query.clone())); + } + if let Some(key) = self + .graphql_operation_name + .as_ref() + .and_then(|a| a.key(GRAPHQL_OPERATION_NAME.into())) + && let Some(operation_name) = &request + .context + .get::<_, String>(OPERATION_NAME) + .unwrap_or_default() + { + attrs.push(KeyValue::new(key, operation_name.clone())); + } + if let Some(key) = self + .graphql_operation_type + .as_ref() + .and_then(|a| a.key(GRAPHQL_OPERATION_TYPE.into())) + && let Some(operation_type) = &request + .context + .get::<_, String>(OPERATION_KIND) + .unwrap_or_default() + { + attrs.push(KeyValue::new(key, operation_type.clone())); + } + + attrs + } + + fn on_response(&self, response: &supergraph::Response) -> Vec { + let mut attrs = Vec::new(); + attrs.append(&mut self.cost.on_response(response)); + attrs + } + + fn on_response_event( + &self, + response: &crate::graphql::Response, + context: &Context, + ) -> Vec { + let mut attrs = Vec::new(); + attrs.append(&mut self.cost.on_response_event(response, context)); + attrs + } + + fn on_error(&self, _error: &BoxError, _ctx: &Context) -> Vec { + Vec::default() + } +} + +#[cfg(test)] +mod test { + use opentelemetry_semantic_conventions::trace::GRAPHQL_DOCUMENT; + use opentelemetry_semantic_conventions::trace::GRAPHQL_OPERATION_NAME; + use opentelemetry_semantic_conventions::trace::GRAPHQL_OPERATION_TYPE; + + use super::*; + use crate::context::OPERATION_KIND; + use crate::context::OPERATION_NAME; + use crate::plugins::telemetry::config_new::Selectors; + use crate::services::supergraph; + + #[test] + fn test_supergraph_graphql_document() { + let attributes = SupergraphAttributes { + graphql_document: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + let attributes = attributes.on_request( + &supergraph::Request::fake_builder() + .query("query { __typename }") + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == GRAPHQL_DOCUMENT) + .map(|key_val| &key_val.value), + Some(&"query { __typename }".into()) + ); + } + + #[test] + fn test_supergraph_graphql_operation_name() { + let attributes = SupergraphAttributes { + graphql_operation_name: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + let context = crate::Context::new(); + let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); + let attributes = attributes.on_request( + &supergraph::Request::fake_builder() + .context(context) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == GRAPHQL_OPERATION_NAME) + .map(|key_val| &key_val.value), + Some(&"topProducts".into()) + ); + let attributes = SupergraphAttributes { + graphql_operation_name: Some(StandardAttribute::Aliased { + alias: String::from("graphql_query"), + }), + ..Default::default() + }; + let context = crate::Context::new(); + let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); + let attributes = attributes.on_request( + &supergraph::Request::fake_builder() + .context(context) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == "graphql_query") + .map(|key_val| &key_val.value), + Some(&"topProducts".into()) + ); + } + + #[test] + fn test_supergraph_graphql_operation_type() { + let attributes = SupergraphAttributes { + graphql_operation_type: Some(StandardAttribute::Bool(true)), + ..Default::default() + }; + let context = crate::Context::new(); + let _ = context.insert(OPERATION_KIND, "query".to_string()); + let attributes = attributes.on_request( + &supergraph::Request::fake_builder() + .context(context) + .build() + .unwrap(), + ); + assert_eq!( + attributes + .iter() + .find(|key_val| key_val.key.as_str() == GRAPHQL_OPERATION_TYPE) + .map(|key_val| &key_val.value), + Some(&"query".into()) + ); + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/supergraph/events.rs b/apollo-router/src/plugins/telemetry/config_new/supergraph/events.rs new file mode 100644 index 0000000000..5d3451e50a --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/supergraph/events.rs @@ -0,0 +1,312 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use opentelemetry::Key; +use opentelemetry::KeyValue; +use opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD; +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; + +use super::selectors::SupergraphSelector; +use crate::Context; +use crate::graphql; +use crate::plugins::telemetry::config_new::attributes::HTTP_REQUEST_BODY; +use crate::plugins::telemetry::config_new::attributes::HTTP_REQUEST_HEADERS; +use crate::plugins::telemetry::config_new::attributes::HTTP_REQUEST_URI; +use crate::plugins::telemetry::config_new::attributes::HTTP_REQUEST_VERSION; +use crate::plugins::telemetry::config_new::conditions::Condition; +use crate::plugins::telemetry::config_new::events::CustomEvents; +use crate::plugins::telemetry::config_new::events::EventLevel; +use crate::plugins::telemetry::config_new::events::StandardEventConfig; +use crate::plugins::telemetry::config_new::events::log_event; +use crate::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes; +use crate::services::supergraph; + +pub(crate) type SupergraphEvents = CustomEvents< + supergraph::Request, + supergraph::Response, + graphql::Response, + SupergraphAttributes, + SupergraphSelector, +>; + +impl + CustomEvents< + supergraph::Request, + supergraph::Response, + graphql::Response, + SupergraphAttributes, + SupergraphSelector, + > +{ + pub(crate) fn on_request(&mut self, request: &supergraph::Request) { + if let Some(request_event) = &mut self.request + && request_event.condition.evaluate_request(request) == Some(true) + { + let mut attrs = Vec::with_capacity(5); + #[cfg(test)] + let mut headers: indexmap::IndexMap = request + .supergraph_request + .headers() + .clone() + .into_iter() + .filter_map(|(name, val)| Some((name?.to_string(), val))) + .collect(); + #[cfg(test)] + headers.sort_keys(); + #[cfg(not(test))] + let headers = request.supergraph_request.headers(); + attrs.push(KeyValue::new( + HTTP_REQUEST_HEADERS, + opentelemetry::Value::String(format!("{headers:?}").into()), + )); + attrs.push(KeyValue::new( + HTTP_REQUEST_METHOD, + opentelemetry::Value::String( + format!("{}", request.supergraph_request.method()).into(), + ), + )); + attrs.push(KeyValue::new( + HTTP_REQUEST_URI, + opentelemetry::Value::String( + format!("{}", request.supergraph_request.uri()).into(), + ), + )); + attrs.push(KeyValue::new( + HTTP_REQUEST_VERSION, + opentelemetry::Value::String( + format!("{:?}", request.supergraph_request.version()).into(), + ), + )); + attrs.push(KeyValue::new( + HTTP_REQUEST_BODY, + opentelemetry::Value::String( + serde_json::to_string(request.supergraph_request.body()) + .unwrap_or_default() + .into(), + ), + )); + log_event(request_event.level, "supergraph.request", attrs, ""); + } + if let Some(mut response_event) = self.response.take() + && response_event.condition.evaluate_request(request) != Some(false) + { + request.context.extensions().with_lock(|lock| { + lock.insert(SupergraphEventResponse { + level: response_event.level, + condition: Arc::new(response_event.condition), + }) + }); + } + for custom_event in &mut self.custom { + custom_event.on_request(request); + } + } + + pub(crate) fn on_response(&mut self, response: &supergraph::Response) { + for custom_event in &mut self.custom { + custom_event.on_response(response); + } + } + + pub(crate) fn on_response_event(&self, response: &graphql::Response, ctx: &Context) { + for custom_event in &self.custom { + custom_event.on_response_event(response, ctx); + } + } + + pub(crate) fn on_error(&mut self, error: &BoxError, ctx: &Context) { + if let Some(error_event) = &self.error + && error_event.condition.evaluate_error(error, ctx) + { + log_event( + error_event.level, + "supergraph.error", + vec![KeyValue::new( + Key::from_static_str("error"), + opentelemetry::Value::String(error.to_string().into()), + )], + "", + ); + } + for custom_event in &mut self.custom { + custom_event.on_error(error, ctx); + } + } +} +#[derive(Clone)] +pub(crate) struct SupergraphEventResponse { + // XXX(@IvanGoncharov): As part of removing Arc from StandardEvent I moved it here + // I think it's not nessary here but can't verify it right now, so in future can just wrap StandardEvent + pub(crate) level: EventLevel, + pub(crate) condition: Arc>, +} + +#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct SupergraphEventsConfig { + /// Log the supergraph request + pub(crate) request: StandardEventConfig, + /// Log the supergraph response + pub(crate) response: StandardEventConfig, + /// Log the supergraph error + pub(crate) error: StandardEventConfig, +} + +#[cfg(test)] +mod tests { + use http::HeaderValue; + use tracing::instrument::WithSubscriber; + + use super::*; + use crate::assert_snapshot_subscriber; + use crate::context::CONTAINS_GRAPHQL_ERROR; + use crate::context::OPERATION_NAME; + use crate::plugins::telemetry::Telemetry; + use crate::plugins::test::PluginTestHarness; + + #[tokio::test(flavor = "multi_thread")] + async fn test_supergraph_events() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!("../../testdata/custom_events.router.yaml")) + .build() + .await + .expect("test harness"); + + async { + test_harness + .supergraph_service(|_r| async { + supergraph::Response::fake_builder() + .header("custom-header", "val1") + .header("x-log-request", HeaderValue::from_static("log")) + .data(serde_json::json!({"data": "res"}).to_string()) + .build() + }) + .call( + supergraph::Request::fake_builder() + .query("query { foo }") + .header("x-log-request", HeaderValue::from_static("log")) + .build() + .unwrap(), + ) + .await + .expect("expecting successful response"); + } + .with_subscriber(assert_snapshot_subscriber!()) + .await + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_supergraph_events_with_exists_condition() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!( + "../../testdata/custom_events_exists_condition.router.yaml" + )) + .build() + .await + .expect("test harness"); + + async { + let ctx = Context::new(); + ctx.insert(OPERATION_NAME, String::from("Test")).unwrap(); + test_harness + .supergraph_service(|_r| async { + supergraph::Response::fake_builder() + .data(serde_json::json!({"data": "res"}).to_string()) + .build() + }) + .call( + supergraph::Request::fake_builder() + .query("query Test { foo }") + .context(ctx) + .build() + .unwrap(), + ) + .await + .expect("expecting successful response"); + test_harness + .supergraph_service(|_r| async { + Ok(supergraph::Response::fake_builder() + .data(serde_json::json!({"data": "res"}).to_string()) + .build() + .expect("expecting valid response")) + }) + .call( + supergraph::Request::fake_builder() + .query("query { foo }") + .build() + .unwrap(), + ) + .await + .expect("expecting successful response"); + } + .with_subscriber(assert_snapshot_subscriber!()) + .await + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_supergraph_events_on_graphql_error() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!("../../testdata/custom_events.router.yaml")) + .build() + .await + .expect("test harness"); + + async { + test_harness + .supergraph_service(|_r| async { + let context_with_error = Context::new(); + let _ = context_with_error + .insert(CONTAINS_GRAPHQL_ERROR, true) + .unwrap(); + supergraph::Response::fake_builder() + .header("custom-header", "val1") + .header("x-log-request", HeaderValue::from_static("log")) + .context(context_with_error) + .data(serde_json_bytes::json!({"errors": [{"message": "res"}]})) + .build() + }) + .call( + supergraph::Request::fake_builder() + .query("query { foo }") + .build() + .unwrap(), + ) + .await + .expect("expecting successful response"); + } + .with_subscriber(assert_snapshot_subscriber!()) + .await + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_supergraph_events_on_response() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!("../../testdata/custom_events.router.yaml")) + .build() + .await + .expect("test harness"); + + async { + test_harness + .supergraph_service(|_r| async { + supergraph::Response::fake_builder() + .header("custom-header", "val1") + .header("x-log-response", HeaderValue::from_static("log")) + .data(serde_json_bytes::json!({"errors": [{"message": "res"}]})) + .build() + }) + .call( + supergraph::Request::fake_builder() + .query("query { foo }") + .build() + .unwrap(), + ) + .await + .expect("expecting successful response"); + } + .with_subscriber(assert_snapshot_subscriber!()) + .await + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/supergraph/instruments.rs b/apollo-router/src/plugins/telemetry/config_new/supergraph/instruments.rs new file mode 100644 index 0000000000..87ebf46f12 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/supergraph/instruments.rs @@ -0,0 +1,39 @@ +use std::fmt::Debug; + +use schemars::JsonSchema; +use serde::Deserialize; + +use super::selectors::SupergraphSelector; +use super::selectors::SupergraphValue; +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::cost::CostInstrumentsConfig; +use crate::plugins::telemetry::config_new::instruments::CustomInstruments; +use crate::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes; +use crate::plugins::telemetry::otlp::TelemetryDataKind; +use crate::services::supergraph; + +#[derive(Clone, Deserialize, JsonSchema, Debug, Default)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct SupergraphInstrumentsConfig { + #[serde(flatten)] + pub(crate) cost: CostInstrumentsConfig, +} + +impl DefaultForLevel for SupergraphInstrumentsConfig { + fn defaults_for_level( + &mut self, + _requirement_level: DefaultAttributeRequirementLevel, + _kind: TelemetryDataKind, + ) { + } +} + +pub(crate) type SupergraphCustomInstruments = CustomInstruments< + supergraph::Request, + supergraph::Response, + crate::graphql::Response, + SupergraphAttributes, + SupergraphSelector, + SupergraphValue, +>; diff --git a/apollo-router/src/plugins/telemetry/config_new/supergraph/mod.rs b/apollo-router/src/plugins/telemetry/config_new/supergraph/mod.rs new file mode 100644 index 0000000000..232f3aa106 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/supergraph/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod attributes; +pub(crate) mod events; +pub(crate) mod instruments; +pub(crate) mod selectors; +pub(crate) mod spans; diff --git a/apollo-router/src/plugins/telemetry/config_new/supergraph/selectors.rs b/apollo-router/src/plugins/telemetry/config_new/supergraph/selectors.rs new file mode 100644 index 0000000000..ad54a09c0d --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/supergraph/selectors.rs @@ -0,0 +1,1202 @@ +use derivative::Derivative; +use opentelemetry::Value; +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json_bytes::ByteString; +use serde_json_bytes::path::JsonPathInst; +use sha2::Digest; + +use crate::Context; +use crate::context::CONTAINS_GRAPHQL_ERROR; +use crate::context::OPERATION_KIND; +use crate::context::OPERATION_NAME; +use crate::plugin::serde::deserialize_jsonpath; +use crate::plugins::telemetry::config::AttributeValue; +use crate::plugins::telemetry::config_new::Selector; +use crate::plugins::telemetry::config_new::Stage; +use crate::plugins::telemetry::config_new::ToOtelValue; +use crate::plugins::telemetry::config_new::cost::CostValue; +use crate::plugins::telemetry::config_new::get_baggage; +use crate::plugins::telemetry::config_new::instruments::Event; +use crate::plugins::telemetry::config_new::instruments::InstrumentValue; +use crate::plugins::telemetry::config_new::instruments::Standard; +use crate::plugins::telemetry::config_new::selectors::ErrorRepr; +use crate::plugins::telemetry::config_new::selectors::OperationKind; +use crate::plugins::telemetry::config_new::selectors::OperationName; +use crate::plugins::telemetry::config_new::selectors::Query; +use crate::plugins::telemetry::config_new::selectors::ResponseStatus; +use crate::services::FIRST_EVENT_CONTEXT_KEY; +use crate::services::supergraph; +use crate::spec::operation_limits::OperationLimits; + +#[derive(Deserialize, JsonSchema, Clone, Debug)] +#[serde(deny_unknown_fields, rename_all = "snake_case", untagged)] +pub(crate) enum SupergraphValue { + Standard(Standard), + Event(Event), + Custom(SupergraphSelector), +} + +impl From<&SupergraphValue> for InstrumentValue { + fn from(value: &SupergraphValue) -> Self { + match value { + SupergraphValue::Standard(s) => InstrumentValue::Standard(s.clone()), + SupergraphValue::Custom(selector) => match selector { + SupergraphSelector::Cost { .. } => { + InstrumentValue::Chunked(Event::Custom(selector.clone())) + } + _ => InstrumentValue::Custom(selector.clone()), + }, + SupergraphValue::Event(e) => InstrumentValue::Chunked(e.clone()), + } + } +} + +#[derive(Deserialize, JsonSchema, Clone, Derivative)] +#[serde(deny_unknown_fields, untagged)] +#[derivative(Debug, PartialEq)] +pub(crate) enum SupergraphSelector { + OperationName { + /// The operation name from the query. + operation_name: OperationName, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + OperationKind { + /// The operation kind from the query (query|mutation|subscription). + // Allow dead code is required because there is only one variant in OperationKind and we need to avoid the dead code warning. + #[allow(dead_code)] + operation_kind: OperationKind, + }, + Query { + /// The graphql query. + query: Query, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + QueryVariable { + /// The name of a graphql query variable. + query_variable: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + RequestHeader { + /// The name of the request header. + request_header: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + ResponseHeader { + /// The name of the response header. + response_header: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + /// A status from the response + ResponseStatus { + /// The http response status code. + response_status: ResponseStatus, + }, + RequestContext { + /// The request context key. + request_context: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + ResponseContext { + /// The response context key. + response_context: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + ResponseData { + /// The supergraph response body json path of the chunks. + #[schemars(with = "String")] + #[derivative(Debug = "ignore", PartialEq = "ignore")] + #[serde(deserialize_with = "deserialize_jsonpath")] + response_data: JsonPathInst, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + ResponseErrors { + /// The supergraph response body json path of the chunks. + #[schemars(with = "String")] + #[derivative(Debug = "ignore", PartialEq = "ignore")] + #[serde(deserialize_with = "deserialize_jsonpath")] + response_errors: JsonPathInst, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + Baggage { + /// The name of the baggage item. + baggage: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + }, + Env { + /// The name of the environment variable + env: String, + #[serde(skip)] + #[allow(dead_code)] + /// Optional redaction pattern. + redact: Option, + /// Optional default value. + default: Option, + /// Avoid unsafe std::env::set_var in tests + #[cfg(test)] + #[serde(skip)] + mocked_env_var: Option, + }, + /// Deprecated, should not be used anymore, use static field instead + Static(String), + StaticField { + /// A static value + r#static: AttributeValue, + }, + OnGraphQLError { + /// Boolean set to true if the response body contains graphql error + on_graphql_error: bool, + }, + Error { + #[allow(dead_code)] + /// Critical error if it happens + error: ErrorRepr, + }, + /// Cost attributes + Cost { + /// The cost value to select, one of: estimated, actual, delta. + cost: CostValue, + }, + /// Boolean returning true if it's the primary response and not events like subscription events or deferred responses + IsPrimaryResponse { + /// Boolean returning true if it's the primary response and not events like subscription events or deferred responses + is_primary_response: bool, + }, +} + +impl Selector for SupergraphSelector { + type Request = supergraph::Request; + type Response = supergraph::Response; + type EventResponse = crate::graphql::Response; + + fn on_request(&self, request: &supergraph::Request) -> Option { + match self { + SupergraphSelector::OperationName { + operation_name, + default, + .. + } => { + let op_name = request.context.get(OPERATION_NAME).ok().flatten(); + match operation_name { + OperationName::String => op_name.or_else(|| default.clone()), + OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { + let mut hasher = sha2::Sha256::new(); + hasher.update(op_name.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) + }), + } + .map(opentelemetry::Value::from) + } + SupergraphSelector::OperationKind { .. } => request + .context + .get::<_, String>(OPERATION_KIND) + .ok() + .flatten() + .map(opentelemetry::Value::from), + + SupergraphSelector::Query { + default, + query: Query::String, + .. + } => request + .supergraph_request + .body() + .query + .clone() + .or_else(|| default.clone()) + .map(opentelemetry::Value::from), + SupergraphSelector::RequestHeader { + request_header, + default, + .. + } => request + .supergraph_request + .headers() + .get(request_header) + .and_then(|h| Some(h.to_str().ok()?.to_string())) + .or_else(|| default.clone()) + .map(opentelemetry::Value::from), + SupergraphSelector::QueryVariable { + query_variable, + default, + .. + } => request + .supergraph_request + .body() + .variables + .get(&ByteString::from(query_variable.as_str())) + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + SupergraphSelector::RequestContext { + request_context, + default, + .. + } => request + .context + .get::<_, serde_json_bytes::Value>(request_context) + .ok() + .flatten() + .as_ref() + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + SupergraphSelector::Baggage { + baggage, default, .. + } => get_baggage(baggage).or_else(|| default.maybe_to_otel_value()), + + SupergraphSelector::Env { + env, + default, + #[cfg(test)] + mocked_env_var, + .. + } => { + #[cfg(test)] + let value = mocked_env_var.clone(); + #[cfg(not(test))] + let value = None; + value + .or_else(|| std::env::var(env).ok()) + .or_else(|| default.clone()) + .map(opentelemetry::Value::from) + } + SupergraphSelector::Static(val) => Some(val.clone().into()), + SupergraphSelector::StaticField { r#static } => Some(r#static.clone().into()), + // For response + _ => None, + } + } + + fn on_response(&self, response: &supergraph::Response) -> Option { + match self { + SupergraphSelector::Query { query, .. } => { + let limits_opt = response + .context + .extensions() + .with_lock(|lock| lock.get::>().cloned()); + match query { + Query::Aliases => { + limits_opt.map(|limits| opentelemetry::Value::I64(limits.aliases as i64)) + } + Query::Depth => { + limits_opt.map(|limits| opentelemetry::Value::I64(limits.depth as i64)) + } + Query::Height => { + limits_opt.map(|limits| opentelemetry::Value::I64(limits.height as i64)) + } + Query::RootFields => limits_opt + .map(|limits| opentelemetry::Value::I64(limits.root_fields as i64)), + Query::String => None, + } + } + SupergraphSelector::ResponseHeader { + response_header, + default, + .. + } => response + .response + .headers() + .get(response_header) + .and_then(|h| Some(h.to_str().ok()?.to_string())) + .or_else(|| default.clone()) + .map(opentelemetry::Value::from), + SupergraphSelector::ResponseStatus { response_status } => match response_status { + ResponseStatus::Code => Some(opentelemetry::Value::I64( + response.response.status().as_u16() as i64, + )), + ResponseStatus::Reason => response + .response + .status() + .canonical_reason() + .map(|reason| reason.to_string().into()), + }, + SupergraphSelector::ResponseContext { + response_context, + default, + .. + } => response + .context + .get_json_value(response_context) + .as_ref() + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + SupergraphSelector::OnGraphQLError { on_graphql_error } if *on_graphql_error => { + let contains_error = response + .context + .get_json_value(CONTAINS_GRAPHQL_ERROR) + .and_then(|value| value.as_bool()) + .unwrap_or_default(); + Some(opentelemetry::Value::Bool(contains_error)) + } + SupergraphSelector::OperationName { + operation_name, + default, + .. + } => { + let op_name = response.context.get(OPERATION_NAME).ok().flatten(); + match operation_name { + OperationName::String => op_name.or_else(|| default.clone()), + OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { + let mut hasher = sha2::Sha256::new(); + hasher.update(op_name.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) + }), + } + .map(opentelemetry::Value::from) + } + SupergraphSelector::OperationKind { .. } => response + .context + .get::<_, String>(OPERATION_KIND) + .ok() + .flatten() + .map(opentelemetry::Value::from), + SupergraphSelector::IsPrimaryResponse { + is_primary_response: is_primary, + } if *is_primary => Some(true.into()), + SupergraphSelector::Static(val) => Some(val.clone().into()), + SupergraphSelector::StaticField { r#static } => Some(r#static.clone().into()), + // For request + _ => None, + } + } + + fn on_response_event( + &self, + response: &Self::EventResponse, + ctx: &Context, + ) -> Option { + match self { + SupergraphSelector::ResponseData { + response_data, + default, + .. + } => if let Some(data) = &response.data { + let val = response_data.find(data); + val.maybe_to_otel_value() + } else { + None + } + .or_else(|| default.maybe_to_otel_value()), + SupergraphSelector::ResponseErrors { + response_errors, + default, + .. + } => { + let errors = response.errors.clone(); + let data: serde_json_bytes::Value = serde_json_bytes::to_value(errors).ok()?; + let val = response_errors.find(&data); + + val.maybe_to_otel_value() + } + .or_else(|| default.maybe_to_otel_value()), + SupergraphSelector::Cost { cost } => match cost { + CostValue::Estimated => ctx + .get_estimated_cost() + .ok() + .flatten() + .map(opentelemetry::Value::from), + CostValue::Actual => ctx + .get_actual_cost() + .ok() + .flatten() + .map(opentelemetry::Value::from), + CostValue::Delta => ctx + .get_cost_delta() + .ok() + .flatten() + .map(opentelemetry::Value::from), + CostValue::Result => ctx + .get_cost_result() + .ok() + .flatten() + .map(opentelemetry::Value::from), + }, + SupergraphSelector::OnGraphQLError { on_graphql_error } if *on_graphql_error => { + let contains_error = ctx + .get_json_value(CONTAINS_GRAPHQL_ERROR) + .and_then(|value| value.as_bool()) + .unwrap_or_default(); + Some(opentelemetry::Value::Bool(contains_error)) + } + SupergraphSelector::OperationName { + operation_name, + default, + .. + } => { + let op_name = ctx.get(OPERATION_NAME).ok().flatten(); + match operation_name { + OperationName::String => op_name.or_else(|| default.clone()), + OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { + let mut hasher = sha2::Sha256::new(); + hasher.update(op_name.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) + }), + } + .map(opentelemetry::Value::from) + } + SupergraphSelector::OperationKind { .. } => ctx + .get::<_, String>(OPERATION_KIND) + .ok() + .flatten() + .map(opentelemetry::Value::from), + SupergraphSelector::IsPrimaryResponse { + is_primary_response: is_primary, + } if *is_primary => Some(opentelemetry::Value::Bool( + ctx.get_json_value(FIRST_EVENT_CONTEXT_KEY) + == Some(serde_json_bytes::Value::Bool(true)), + )), + SupergraphSelector::ResponseContext { + response_context, + default, + .. + } => ctx + .get_json_value(response_context) + .as_ref() + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + SupergraphSelector::Static(val) => Some(val.clone().into()), + SupergraphSelector::StaticField { r#static } => Some(r#static.clone().into()), + _ => None, + } + } + + fn on_error(&self, error: &tower::BoxError, ctx: &Context) -> Option { + match self { + SupergraphSelector::OperationName { + operation_name, + default, + .. + } => { + let op_name = ctx.get(OPERATION_NAME).ok().flatten(); + match operation_name { + OperationName::String => op_name.or_else(|| default.clone()), + OperationName::Hash => op_name.or_else(|| default.clone()).map(|op_name| { + let mut hasher = sha2::Sha256::new(); + hasher.update(op_name.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) + }), + } + .map(opentelemetry::Value::from) + } + SupergraphSelector::OperationKind { .. } => ctx + .get::<_, String>(OPERATION_KIND) + .ok() + .flatten() + .map(opentelemetry::Value::from), + SupergraphSelector::Query { query, .. } => { + let limits_opt = ctx + .extensions() + .with_lock(|lock| lock.get::>().cloned()); + match query { + Query::Aliases => { + limits_opt.map(|limits| opentelemetry::Value::I64(limits.aliases as i64)) + } + Query::Depth => { + limits_opt.map(|limits| opentelemetry::Value::I64(limits.depth as i64)) + } + Query::Height => { + limits_opt.map(|limits| opentelemetry::Value::I64(limits.height as i64)) + } + Query::RootFields => limits_opt + .map(|limits| opentelemetry::Value::I64(limits.root_fields as i64)), + Query::String => None, + } + } + SupergraphSelector::Error { .. } => Some(error.to_string().into()), + SupergraphSelector::Static(val) => Some(val.clone().into()), + SupergraphSelector::StaticField { r#static } => Some(r#static.clone().into()), + SupergraphSelector::ResponseContext { + response_context, + default, + .. + } => ctx + .get_json_value(response_context) + .as_ref() + .and_then(|v| v.maybe_to_otel_value()) + .or_else(|| default.maybe_to_otel_value()), + SupergraphSelector::IsPrimaryResponse { + is_primary_response: is_primary, + } if *is_primary => Some(opentelemetry::Value::Bool( + ctx.get_json_value(FIRST_EVENT_CONTEXT_KEY) + == Some(serde_json_bytes::Value::Bool(true)), + )), + _ => None, + } + } + + fn on_drop(&self) -> Option { + match self { + SupergraphSelector::Static(val) => Some(val.clone().into()), + SupergraphSelector::StaticField { r#static } => Some(r#static.clone().into()), + _ => None, + } + } + + fn is_active(&self, stage: Stage) -> bool { + match stage { + Stage::Request => matches!( + self, + SupergraphSelector::OperationName { .. } + | SupergraphSelector::OperationKind { .. } + | SupergraphSelector::Query { .. } + | SupergraphSelector::RequestHeader { .. } + | SupergraphSelector::QueryVariable { .. } + | SupergraphSelector::RequestContext { .. } + | SupergraphSelector::Baggage { .. } + | SupergraphSelector::Env { .. } + | SupergraphSelector::Static(_) + | SupergraphSelector::StaticField { .. } + ), + Stage::Response => matches!( + self, + SupergraphSelector::Query { .. } + | SupergraphSelector::ResponseHeader { .. } + | SupergraphSelector::ResponseStatus { .. } + | SupergraphSelector::ResponseContext { .. } + | SupergraphSelector::OnGraphQLError { .. } + | SupergraphSelector::OperationName { .. } + | SupergraphSelector::OperationKind { .. } + | SupergraphSelector::IsPrimaryResponse { .. } + | SupergraphSelector::Static(_) + | SupergraphSelector::StaticField { .. } + ), + Stage::ResponseEvent => matches!( + self, + SupergraphSelector::ResponseData { .. } + | SupergraphSelector::ResponseErrors { .. } + | SupergraphSelector::Cost { .. } + | SupergraphSelector::OnGraphQLError { .. } + | SupergraphSelector::OperationName { .. } + | SupergraphSelector::OperationKind { .. } + | SupergraphSelector::IsPrimaryResponse { .. } + | SupergraphSelector::ResponseContext { .. } + | SupergraphSelector::Static(_) + | SupergraphSelector::StaticField { .. } + ), + Stage::ResponseField => false, + Stage::Error => matches!( + self, + SupergraphSelector::OperationName { .. } + | SupergraphSelector::OperationKind { .. } + | SupergraphSelector::Query { .. } + | SupergraphSelector::Error { .. } + | SupergraphSelector::Static(_) + | SupergraphSelector::StaticField { .. } + | SupergraphSelector::ResponseContext { .. } + | SupergraphSelector::IsPrimaryResponse { .. } + ), + Stage::Drop => matches!( + self, + SupergraphSelector::Static(_) | SupergraphSelector::StaticField { .. } + ), + } + } +} + +#[cfg(test)] +mod test { + use opentelemetry::Context; + use opentelemetry::KeyValue; + use opentelemetry::baggage::BaggageExt; + use opentelemetry::trace::SpanContext; + use opentelemetry::trace::SpanId; + use opentelemetry::trace::TraceContextExt; + use opentelemetry::trace::TraceFlags; + use opentelemetry::trace::TraceId; + use opentelemetry::trace::TraceState; + use tower::BoxError; + use tracing::span; + use tracing::subscriber; + use tracing_subscriber::layer::SubscriberExt; + + use crate::context::OPERATION_KIND; + use crate::context::OPERATION_NAME; + use crate::plugins::telemetry::config::AttributeValue; + use crate::plugins::telemetry::config_new::Selector; + use crate::plugins::telemetry::config_new::selectors::OperationKind; + use crate::plugins::telemetry::config_new::selectors::OperationName; + use crate::plugins::telemetry::config_new::selectors::Query; + use crate::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector; + use crate::plugins::telemetry::otel; + use crate::services::FIRST_EVENT_CONTEXT_KEY; + use crate::spec::operation_limits::OperationLimits; + + #[test] + fn supergraph_request_header() { + let selector = SupergraphSelector::RequestHeader { + request_header: "header_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + assert_eq!( + selector + .on_request( + &crate::services::SupergraphRequest::fake_builder() + .header("header_key", "header_value") + .build() + .unwrap() + ) + .unwrap(), + "header_value".into() + ); + + assert_eq!( + selector + .on_request( + &crate::services::SupergraphRequest::fake_builder() + .build() + .unwrap() + ) + .unwrap(), + "defaulted".into() + ); + + assert_eq!( + selector.on_response( + &crate::services::SupergraphResponse::fake_builder() + .header("header_key", "header_value") + .build() + .unwrap() + ), + None + ); + } + + #[test] + fn supergraph_static() { + let selector = SupergraphSelector::Static("test_static".to_string()); + assert_eq!( + selector + .on_request( + &crate::services::SupergraphRequest::fake_builder() + .build() + .unwrap() + ) + .unwrap(), + "test_static".into() + ); + assert_eq!(selector.on_drop().unwrap(), "test_static".into()); + } + + #[test] + fn supergraph_static_field() { + let selector = SupergraphSelector::StaticField { + r#static: "test_static".to_string().into(), + }; + assert_eq!( + selector + .on_request( + &crate::services::SupergraphRequest::fake_builder() + .build() + .unwrap() + ) + .unwrap(), + "test_static".into() + ); + assert_eq!(selector.on_drop().unwrap(), "test_static".into()); + } + + #[test] + fn supergraph_response_header() { + let selector = SupergraphSelector::ResponseHeader { + response_header: "header_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + assert_eq!( + selector + .on_response( + &crate::services::SupergraphResponse::fake_builder() + .header("header_key", "header_value") + .build() + .unwrap() + ) + .unwrap(), + "header_value".into() + ); + + assert_eq!( + selector + .on_response( + &crate::services::SupergraphResponse::fake_builder() + .build() + .unwrap() + ) + .unwrap(), + "defaulted".into() + ); + + assert_eq!( + selector.on_request( + &crate::services::SupergraphRequest::fake_builder() + .header("header_key", "header_value") + .build() + .unwrap() + ), + None + ); + } + + #[test] + fn supergraph_request_context() { + let selector = SupergraphSelector::RequestContext { + request_context: "context_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + let context = crate::context::Context::new(); + let _ = context.insert("context_key".to_string(), "context_value".to_string()); + assert_eq!( + selector + .on_request( + &crate::services::SupergraphRequest::fake_builder() + .context(context.clone()) + .build() + .unwrap() + ) + .unwrap(), + "context_value".into() + ); + + assert_eq!( + selector + .on_request( + &crate::services::SupergraphRequest::fake_builder() + .build() + .unwrap() + ) + .unwrap(), + "defaulted".into() + ); + assert_eq!( + selector.on_response( + &crate::services::SupergraphResponse::fake_builder() + .context(context) + .build() + .unwrap() + ), + None + ); + } + + #[test] + fn supergraph_is_primary() { + let selector = SupergraphSelector::IsPrimaryResponse { + is_primary_response: true, + }; + let context = crate::context::Context::new(); + let _ = context.insert(FIRST_EVENT_CONTEXT_KEY, true); + assert_eq!( + selector + .on_response( + &crate::services::SupergraphResponse::fake_builder() + .context(context.clone()) + .build() + .unwrap() + ) + .unwrap(), + true.into() + ); + assert_eq!( + selector + .on_response_event(&crate::graphql::Response::builder().build(), &context) + .unwrap(), + true.into() + ); + } + + #[test] + fn supergraph_response_context() { + let selector = SupergraphSelector::ResponseContext { + response_context: "context_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + let context = crate::context::Context::new(); + let _ = context.insert("context_key".to_string(), "context_value".to_string()); + assert_eq!( + selector + .on_response( + &crate::services::SupergraphResponse::fake_builder() + .context(context.clone()) + .build() + .unwrap() + ) + .unwrap(), + "context_value".into() + ); + + assert_eq!( + selector + .on_error(&BoxError::from(String::from("my error")), &context) + .unwrap(), + "context_value".into() + ); + + assert_eq!( + selector + .on_response( + &crate::services::SupergraphResponse::fake_builder() + .build() + .unwrap() + ) + .unwrap(), + "defaulted".into() + ); + assert_eq!( + selector.on_request( + &crate::services::SupergraphRequest::fake_builder() + .context(context) + .build() + .unwrap() + ), + None + ); + } + + #[test] + fn supergraph_baggage() { + let subscriber = tracing_subscriber::registry().with(otel::layer()); + subscriber::with_default(subscriber, || { + let selector = SupergraphSelector::Baggage { + baggage: "baggage_key".to_string(), + redact: None, + default: Some("defaulted".into()), + }; + let span_context = SpanContext::new( + TraceId::from_u128(42), + SpanId::from_u64(42), + // Make sure it's sampled if not, it won't create anything at the otel layer + TraceFlags::default().with_sampled(true), + false, + TraceState::default(), + ); + assert_eq!( + selector + .on_request( + &crate::services::SupergraphRequest::fake_builder() + .build() + .unwrap(), + ) + .unwrap(), + "defaulted".into() + ); + let _outer_guard = Context::new() + .with_remote_span_context(span_context) + .with_baggage(vec![KeyValue::new("baggage_key", "baggage_value")]) + .attach(); + let span = span!(tracing::Level::INFO, "test"); + let _guard = span.enter(); + + assert_eq!( + selector + .on_request( + &crate::services::SupergraphRequest::fake_builder() + .build() + .unwrap(), + ) + .unwrap(), + "baggage_value".into() + ); + }); + } + + #[test] + fn supergraph_env() { + let mut selector = SupergraphSelector::Env { + env: "SELECTOR_SUPERGRAPH_ENV_VARIABLE".to_string(), + redact: None, + default: Some("defaulted".to_string()), + mocked_env_var: None, + }; + assert_eq!( + selector.on_request( + &crate::services::SupergraphRequest::fake_builder() + .build() + .unwrap(), + ), + Some("defaulted".into()) + ); + + if let SupergraphSelector::Env { mocked_env_var, .. } = &mut selector { + *mocked_env_var = Some("env_value".to_string()) + } + assert_eq!( + selector.on_request( + &crate::services::SupergraphRequest::fake_builder() + .build() + .unwrap(), + ), + Some("env_value".into()) + ); + } + + #[test] + fn supergraph_operation_kind() { + let selector = SupergraphSelector::OperationKind { + operation_kind: OperationKind::String, + }; + let context = crate::context::Context::new(); + let _ = context.insert(OPERATION_KIND, "query".to_string()); + // For now operation kind is contained in context + assert_eq!( + selector.on_request( + &crate::services::SupergraphRequest::fake_builder() + .context(context) + .build() + .unwrap(), + ), + Some("query".into()) + ); + } + + #[test] + fn supergraph_operation_name_string() { + let selector = SupergraphSelector::OperationName { + operation_name: OperationName::String, + redact: None, + default: Some("defaulted".to_string()), + }; + let context = crate::context::Context::new(); + assert_eq!( + selector.on_request( + &crate::services::SupergraphRequest::fake_builder() + .context(context.clone()) + .build() + .unwrap(), + ), + Some("defaulted".into()) + ); + let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); + // For now operation kind is contained in context + assert_eq!( + selector.on_request( + &crate::services::SupergraphRequest::fake_builder() + .context(context) + .build() + .unwrap(), + ), + Some("topProducts".into()) + ); + } + + #[test] + fn supergraph_operation_name_hash() { + let selector = SupergraphSelector::OperationName { + operation_name: OperationName::Hash, + redact: None, + default: Some("defaulted".to_string()), + }; + let context = crate::context::Context::new(); + assert_eq!( + selector.on_request( + &crate::services::SupergraphRequest::fake_builder() + .context(context.clone()) + .build() + .unwrap(), + ), + Some("96294f50edb8f006f6b0a2dadae50d3c521e9841d07d6395d91060c8ccfed7f0".into()) + ); + + let _ = context.insert(OPERATION_NAME, "topProducts".to_string()); + assert_eq!( + selector.on_request( + &crate::services::SupergraphRequest::fake_builder() + .context(context) + .build() + .unwrap(), + ), + Some("bd141fca26094be97c30afd42e9fc84755b252e7052d8c992358319246bd555a".into()) + ); + } + + #[test] + fn supergraph_query() { + let selector = SupergraphSelector::Query { + query: Query::String, + redact: None, + default: Some("default".to_string()), + }; + assert_eq!( + selector.on_request( + &crate::services::SupergraphRequest::fake_builder() + .query("topProducts{name}") + .build() + .unwrap(), + ), + Some("topProducts{name}".into()) + ); + + assert_eq!( + selector.on_request( + &crate::services::SupergraphRequest::fake_builder() + .build() + .unwrap(), + ), + Some("default".into()) + ); + } + + fn create_select_and_context(query: Query) -> (SupergraphSelector, crate::Context) { + let selector = SupergraphSelector::Query { + query, + redact: None, + default: Some("default".to_string()), + }; + let limits = OperationLimits { + aliases: 1, + depth: 2, + height: 3, + root_fields: 4, + }; + let context = crate::Context::new(); + context + .extensions() + .with_lock(|lock| lock.insert::>(limits)); + (selector, context) + } + + #[test] + fn supergraph_query_aliases() { + let (selector, context) = create_select_and_context(Query::Aliases); + assert_eq!( + selector + .on_response( + &crate::services::SupergraphResponse::fake_builder() + .context(context) + .build() + .unwrap() + ) + .unwrap(), + 1.into() + ); + } + + #[test] + fn supergraph_query_depth() { + let (selector, context) = create_select_and_context(Query::Depth); + assert_eq!( + selector + .on_response( + &crate::services::SupergraphResponse::fake_builder() + .context(context) + .build() + .unwrap() + ) + .unwrap(), + 2.into() + ); + } + + #[test] + fn supergraph_query_height() { + let (selector, context) = create_select_and_context(Query::Height); + assert_eq!( + selector + .on_response( + &crate::services::SupergraphResponse::fake_builder() + .context(context) + .build() + .unwrap() + ) + .unwrap(), + 3.into() + ); + } + + #[test] + fn supergraph_query_root_fields() { + let (selector, context) = create_select_and_context(Query::RootFields); + assert_eq!( + selector + .on_response( + &crate::services::SupergraphResponse::fake_builder() + .context(context.clone()) + .build() + .unwrap() + ) + .unwrap(), + 4.into() + ); + } + + #[test] + fn supergraph_query_variable() { + let selector = SupergraphSelector::QueryVariable { + query_variable: "key".to_string(), + redact: None, + default: Some(AttributeValue::String("default".to_string())), + }; + assert_eq!( + selector.on_request( + &crate::services::SupergraphRequest::fake_builder() + .variable("key", "value") + .build() + .unwrap(), + ), + Some("value".into()) + ); + + assert_eq!( + selector.on_request( + &crate::services::SupergraphRequest::fake_builder() + .build() + .unwrap(), + ), + Some("default".into()) + ); + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events@logs.snap b/apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events@logs.snap new file mode 100644 index 0000000000..4fcffd9e08 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events@logs.snap @@ -0,0 +1,32 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/supergraph/events.rs +expression: yaml +--- +- fields: + kind: supergraph.request + level: INFO + message: "" + span: + apollo_private.field_level_instrumentation_ratio: 0.01 + apollo_private.graphql.variables: "{}" + name: supergraph + otel.kind: INTERNAL + spans: + - apollo_private.field_level_instrumentation_ratio: 0.01 + apollo_private.graphql.variables: "{}" + name: supergraph + otel.kind: INTERNAL +- fields: + kind: my.request.event + level: INFO + message: my event message + span: + apollo_private.field_level_instrumentation_ratio: 0.01 + apollo_private.graphql.variables: "{}" + name: supergraph + otel.kind: INTERNAL + spans: + - apollo_private.field_level_instrumentation_ratio: 0.01 + apollo_private.graphql.variables: "{}" + name: supergraph + otel.kind: INTERNAL diff --git a/apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events_on_graphql_error@logs.snap b/apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events_on_graphql_error@logs.snap new file mode 100644 index 0000000000..0e7ec88c87 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events_on_graphql_error@logs.snap @@ -0,0 +1,18 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/supergraph/events.rs +expression: yaml +--- +- fields: + kind: my.response_event + level: WARN + message: my response event message + span: + apollo_private.field_level_instrumentation_ratio: 0.01 + apollo_private.graphql.variables: "{}" + name: supergraph + otel.kind: INTERNAL + spans: + - apollo_private.field_level_instrumentation_ratio: 0.01 + apollo_private.graphql.variables: "{}" + name: supergraph + otel.kind: INTERNAL diff --git a/apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events_on_response@logs.snap b/apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events_on_response@logs.snap new file mode 100644 index 0000000000..0e7ec88c87 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events_on_response@logs.snap @@ -0,0 +1,18 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/supergraph/events.rs +expression: yaml +--- +- fields: + kind: my.response_event + level: WARN + message: my response event message + span: + apollo_private.field_level_instrumentation_ratio: 0.01 + apollo_private.graphql.variables: "{}" + name: supergraph + otel.kind: INTERNAL + spans: + - apollo_private.field_level_instrumentation_ratio: 0.01 + apollo_private.graphql.variables: "{}" + name: supergraph + otel.kind: INTERNAL diff --git a/apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events_with_exists_condition@logs.snap b/apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events_with_exists_condition@logs.snap new file mode 100644 index 0000000000..7e618c377f --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/supergraph/snapshots/apollo_router__plugins__telemetry__config_new__supergraph__events__tests__supergraph_events_with_exists_condition@logs.snap @@ -0,0 +1,18 @@ +--- +source: apollo-router/src/plugins/telemetry/config_new/supergraph/events.rs +expression: yaml +--- +- fields: + kind: my.event + level: INFO + message: Auditing Router Event + span: + apollo_private.field_level_instrumentation_ratio: 0.01 + apollo_private.graphql.variables: "{}" + name: supergraph + otel.kind: INTERNAL + spans: + - apollo_private.field_level_instrumentation_ratio: 0.01 + apollo_private.graphql.variables: "{}" + name: supergraph + otel.kind: INTERNAL diff --git a/apollo-router/src/plugins/telemetry/config_new/supergraph/spans.rs b/apollo-router/src/plugins/telemetry/config_new/supergraph/spans.rs new file mode 100644 index 0000000000..87c3ba5b25 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/config_new/supergraph/spans.rs @@ -0,0 +1,226 @@ +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::plugins::telemetry::config_new::DefaultForLevel; +use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; +use crate::plugins::telemetry::config_new::conditional::Conditional; +use crate::plugins::telemetry::config_new::extendable::Extendable; +use crate::plugins::telemetry::config_new::supergraph::attributes::SupergraphAttributes; +use crate::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector; +use crate::plugins::telemetry::otlp::TelemetryDataKind; + +#[derive(Deserialize, JsonSchema, Clone, Debug, Default)] +#[serde(deny_unknown_fields, default)] +pub(crate) struct SupergraphSpans { + /// Custom attributes that are attached to the supergraph span. + pub(crate) attributes: Extendable>, +} +impl DefaultForLevel for SupergraphSpans { + fn defaults_for_level( + &mut self, + requirement_level: DefaultAttributeRequirementLevel, + kind: TelemetryDataKind, + ) { + self.attributes.defaults_for_level(requirement_level, kind); + } +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + use std::sync::Arc; + + use opentelemetry_semantic_conventions::trace::GRAPHQL_DOCUMENT; + use parking_lot::Mutex; + use serde_json_bytes::path::JsonPathInst; + + use super::SupergraphSpans; + use crate::Context; + use crate::context::OPERATION_KIND; + use crate::graphql; + use crate::plugins::telemetry::config_new::DefaultForLevel; + use crate::plugins::telemetry::config_new::Selectors; + use crate::plugins::telemetry::config_new::attributes::DefaultAttributeRequirementLevel; + use crate::plugins::telemetry::config_new::attributes::StandardAttribute; + use crate::plugins::telemetry::config_new::conditional::Conditional; + use crate::plugins::telemetry::config_new::conditions::Condition; + use crate::plugins::telemetry::config_new::supergraph::selectors::SupergraphSelector; + use crate::plugins::telemetry::otlp::TelemetryDataKind; + use crate::services::supergraph; + + #[test] + fn test_supergraph_spans_level_none() { + let mut spans = SupergraphSpans::default(); + spans.defaults_for_levels( + DefaultAttributeRequirementLevel::None, + TelemetryDataKind::Traces, + ); + let values = spans.attributes.on_request( + &supergraph::Request::fake_builder() + .query("query { __typename }") + .build() + .unwrap(), + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key.as_str() == GRAPHQL_DOCUMENT) + ); + } + + #[test] + fn test_supergraph_spans_level_required() { + let mut spans = SupergraphSpans::default(); + spans.defaults_for_levels( + DefaultAttributeRequirementLevel::Required, + TelemetryDataKind::Traces, + ); + let values = spans.attributes.on_request( + &supergraph::Request::fake_builder() + .query("query { __typename }") + .build() + .unwrap(), + ); + assert!( + !values + .iter() + .any(|key_val| key_val.key.as_str() == GRAPHQL_DOCUMENT) + ); + } + + #[test] + fn test_supergraph_spans_level_recommended() { + let mut spans = SupergraphSpans::default(); + spans.defaults_for_levels( + DefaultAttributeRequirementLevel::Recommended, + TelemetryDataKind::Traces, + ); + let values = spans.attributes.on_request( + &supergraph::Request::fake_builder() + .query("query { __typename }") + .build() + .unwrap(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key.as_str() == GRAPHQL_DOCUMENT) + ); + } + + #[test] + fn test_supergraph_request_custom_attribute() { + let mut spans = SupergraphSpans::default(); + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: SupergraphSelector::RequestHeader { + request_header: "my-header".to_string(), + redact: None, + default: None, + }, + condition: None, + value: Arc::new(Default::default()), + }, + ); + let values = spans.attributes.on_request( + &supergraph::Request::fake_builder() + .method(http::Method::POST) + .header("my-header", "test_val") + .build() + .unwrap(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test")) + ); + } + + #[test] + fn test_supergraph_standard_attribute_aliased() { + let mut spans = SupergraphSpans::default(); + spans.attributes.attributes.graphql_operation_type = Some(StandardAttribute::Aliased { + alias: String::from("my_op"), + }); + let context = Context::new(); + context.insert(OPERATION_KIND, "Query".to_string()).unwrap(); + let values = spans.attributes.on_request( + &supergraph::Request::fake_builder() + .method(http::Method::POST) + .header("my-header", "test_val") + .query("Query { me { id } }") + .context(context) + .build() + .unwrap(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("my_op")) + ); + } + + #[test] + fn test_supergraph_response_event_custom_attribute() { + let mut spans = SupergraphSpans::default(); + spans.attributes.custom.insert( + "otel.status_code".to_string(), + Conditional { + selector: SupergraphSelector::StaticField { + r#static: String::from("error").into(), + }, + condition: Some(Arc::new(Mutex::new(Condition::Exists( + SupergraphSelector::ResponseErrors { + response_errors: JsonPathInst::from_str("$[0].extensions.code").unwrap(), + redact: None, + default: None, + }, + )))), + value: Arc::new(Default::default()), + }, + ); + let values = spans.attributes.on_response_event( + &graphql::Response::builder() + .error( + graphql::Error::builder() + .message("foo") + .extension_code("MY_EXTENSION_CODE") + .build(), + ) + .build(), + &Context::new(), + ); + assert!(values.iter().any(|key_val| key_val.key + == opentelemetry::Key::from_static_str("otel.status_code") + && key_val.value == opentelemetry::Value::String(String::from("error").into()))); + } + + #[test] + fn test_supergraph_response_custom_attribute() { + let mut spans = SupergraphSpans::default(); + spans.attributes.custom.insert( + "test".to_string(), + Conditional { + selector: SupergraphSelector::ResponseHeader { + response_header: "my-header".to_string(), + redact: None, + default: None, + }, + condition: None, + value: Arc::new(Default::default()), + }, + ); + let values = spans.attributes.on_response( + &supergraph::Response::fake_builder() + .header("my-header", "test_val") + .build() + .unwrap(), + ); + assert!( + values + .iter() + .any(|key_val| key_val.key == opentelemetry::Key::from_static_str("test")) + ); + } +} diff --git a/apollo-router/src/plugins/telemetry/consts.rs b/apollo-router/src/plugins/telemetry/consts.rs index c82d7b202b..d63d98901a 100644 --- a/apollo-router/src/plugins/telemetry/consts.rs +++ b/apollo-router/src/plugins/telemetry/consts.rs @@ -7,6 +7,7 @@ pub(crate) const OTEL_STATUS_MESSAGE: &str = "otel.status_message"; pub(crate) const OTEL_STATUS_DESCRIPTION: &str = "otel.status_description"; pub(crate) const OTEL_STATUS_CODE_OK: &str = "OK"; pub(crate) const OTEL_STATUS_CODE_ERROR: &str = "ERROR"; +pub(crate) const EVENT_ATTRIBUTE_OMIT_LOG: &str = "omit.log"; pub(crate) const FIELD_EXCEPTION_MESSAGE: &str = "exception.message"; pub(crate) const FIELD_EXCEPTION_STACKTRACE: &str = "exception.stacktrace"; @@ -19,8 +20,12 @@ pub(crate) const QUERY_PLANNING_SPAN_NAME: &str = "query_planning"; pub(crate) const HTTP_REQUEST_SPAN_NAME: &str = "http_request"; pub(crate) const SUBGRAPH_REQUEST_SPAN_NAME: &str = "subgraph_request"; pub(crate) const QUERY_PARSING_SPAN_NAME: &str = "parse_query"; +pub(crate) const CONNECT_SPAN_NAME: &str = "connect"; +pub(crate) const CONNECT_REQUEST_SPAN_NAME: &str = "connect_request"; +pub(crate) const COMPUTE_JOB_SPAN_NAME: &str = "compute_job"; +pub(crate) const COMPUTE_JOB_EXECUTION_SPAN_NAME: &str = "compute_job.execution"; -pub(crate) const BUILT_IN_SPAN_NAMES: [&str; 9] = [ +pub(crate) const BUILT_IN_SPAN_NAMES: [&str; 11] = [ REQUEST_SPAN_NAME, ROUTER_SPAN_NAME, SUPERGRAPH_SPAN_NAME, @@ -30,4 +35,6 @@ pub(crate) const BUILT_IN_SPAN_NAMES: [&str; 9] = [ QUERY_PLANNING_SPAN_NAME, EXECUTION_SPAN_NAME, QUERY_PARSING_SPAN_NAME, + CONNECT_SPAN_NAME, + CONNECT_REQUEST_SPAN_NAME, ]; diff --git a/apollo-router/src/plugins/telemetry/dynamic_attribute.rs b/apollo-router/src/plugins/telemetry/dynamic_attribute.rs index d9cde3ca21..6fd7b616eb 100644 --- a/apollo-router/src/plugins/telemetry/dynamic_attribute.rs +++ b/apollo-router/src/plugins/telemetry/dynamic_attribute.rs @@ -1,19 +1,19 @@ use opentelemetry::Key; use opentelemetry::KeyValue; -use opentelemetry::OrderMap; -use tracing_subscriber::layer::Context; -use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::Layer; use tracing_subscriber::Registry; +use tracing_subscriber::layer::Context; +use tracing_subscriber::registry::LookupSpan; use super::consts::OTEL_KIND; use super::consts::OTEL_NAME; use super::consts::OTEL_STATUS_CODE; use super::consts::OTEL_STATUS_MESSAGE; +use super::formatters::APOLLO_CONNECTOR_PREFIX; use super::formatters::APOLLO_PRIVATE_PREFIX; +use super::otel::OtelData; use super::otel::layer::str_to_span_kind; use super::otel::layer::str_to_status; -use super::otel::OtelData; use super::reload::IsSampled; #[derive(Debug, Default)] @@ -48,13 +48,16 @@ where id: &tracing_core::span::Id, ctx: Context<'_, S>, ) { - let span = ctx.span(id).expect("Span not found, this is a bug"); - let mut extensions = span.extensions_mut(); - if extensions.get_mut::().is_none() { - extensions.insert(LogAttributes::default()); - } - if extensions.get_mut::().is_none() { - extensions.insert(EventAttributes::default()); + if let Some(span) = ctx.span(id) { + let mut extensions = span.extensions_mut(); + if extensions.get_mut::().is_none() { + extensions.insert(LogAttributes::default()); + } + if extensions.get_mut::().is_none() { + extensions.insert(EventAttributes::default()); + } + } else { + tracing::error!("Span not found, this is a bug"); } } } @@ -83,16 +86,11 @@ impl SpanDynAttribute for ::tracing::Span { match extensions.get_mut::() { Some(otel_data) => { update_otel_data(otel_data, &key, &value); - if otel_data.builder.attributes.is_none() { - otel_data.builder.attributes = - Some([(key, value)].into_iter().collect()); + if let Some(attrs) = otel_data.builder.attributes.as_mut() { + attrs.push(KeyValue::new(key, value)) } else { - otel_data - .builder - .attributes - .as_mut() - .expect("we checked the attributes value in the condition above") - .insert(key, value); + otel_data.builder.attributes = + Some([KeyValue::new(key, value)].into_iter().collect()); } } None => { @@ -101,7 +99,9 @@ impl SpanDynAttribute for ::tracing::Span { } } } else { - if key.as_str().starts_with(APOLLO_PRIVATE_PREFIX) { + if key.as_str().starts_with(APOLLO_PRIVATE_PREFIX) + || key.as_str().starts_with(APOLLO_CONNECTOR_PREFIX) + { return; } let mut extensions = s.extensions_mut(); @@ -137,30 +137,17 @@ impl SpanDynAttribute for ::tracing::Span { let mut extensions = s.extensions_mut(); match extensions.get_mut::() { Some(otel_data) => { - if otel_data.builder.attributes.is_none() { - otel_data.builder.attributes = Some( - attributes - .inspect(|attr| { - update_otel_data( - otel_data, - &attr.key, - &attr.value, - ) - }) - .collect(), - ); + let attributes: Vec = attributes + .inspect(|attr| { + update_otel_data(otel_data, &attr.key, &attr.value) + }) + .collect(); + if let Some(existing_attributes) = + otel_data.builder.attributes.as_mut() + { + existing_attributes.extend(attributes); } else { - let attributes: Vec = attributes - .inspect(|attr| { - update_otel_data(otel_data, &attr.key, &attr.value) - }) - .collect(); - otel_data - .builder - .attributes - .as_mut() - .unwrap() - .extend(attributes); + otel_data.builder.attributes = Some(attributes); } } None => { @@ -170,7 +157,10 @@ impl SpanDynAttribute for ::tracing::Span { } } else { let mut attributes = attributes - .filter(|kv| !kv.key.as_str().starts_with(APOLLO_PRIVATE_PREFIX)) + .filter(|kv| { + !kv.key.as_str().starts_with(APOLLO_PRIVATE_PREFIX) + && !kv.key.as_str().starts_with(APOLLO_CONNECTOR_PREFIX) + }) .peekable(); if attributes.peek().is_none() { return; @@ -249,13 +239,16 @@ impl EventDynAttribute for ::tracing::Span { match extensions.get_mut::() { Some(otel_data) => match &mut otel_data.event_attributes { Some(event_attributes) => { - event_attributes - .extend(attributes.map(|kv| (kv.key, kv.value))); + event_attributes.extend( + attributes.map(|KeyValue { key, value }| (key, value)), + ); } None => { - otel_data.event_attributes = Some(OrderMap::from_iter( - attributes.map(|kv| (kv.key, kv.value)), - )); + otel_data.event_attributes = Some( + attributes + .map(|KeyValue { key, value }| (key, value)) + .collect(), + ); } }, None => { @@ -265,7 +258,10 @@ impl EventDynAttribute for ::tracing::Span { } } else { let mut attributes = attributes - .filter(|kv| !kv.key.as_str().starts_with(APOLLO_PRIVATE_PREFIX)) + .filter(|kv| { + !kv.key.as_str().starts_with(APOLLO_PRIVATE_PREFIX) + && !kv.key.as_str().starts_with(APOLLO_CONNECTOR_PREFIX) + }) .peekable(); if attributes.peek().is_none() { return; diff --git a/apollo-router/src/plugins/telemetry/endpoint.rs b/apollo-router/src/plugins/telemetry/endpoint.rs index b5af0ede1e..9487d6adb0 100644 --- a/apollo-router/src/plugins/telemetry/endpoint.rs +++ b/apollo-router/src/plugins/telemetry/endpoint.rs @@ -2,15 +2,15 @@ use std::fmt::Formatter; use std::net::SocketAddr; use std::str::FromStr; -use http::uri::Authority; use http::Uri; -use schemars::gen::SchemaGenerator; -use schemars::schema::Schema; +use http::uri::Authority; use schemars::JsonSchema; -use serde::de::Error; -use serde::de::Visitor; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::Schema; use serde::Deserialize; use serde::Deserializer; +use serde::de::Error; +use serde::de::Visitor; #[derive(Debug, Clone, Default, Eq, PartialEq)] pub(crate) struct UriEndpoint { @@ -20,8 +20,8 @@ pub(crate) struct UriEndpoint { impl UriEndpoint { /// Converts an endpoint to a URI using the default endpoint as reference for any URI parts that are missing. - pub(crate) fn to_uri(&self, default_endpoint: &Uri) -> Option { - self.uri.as_ref().map(|uri| { + pub(crate) fn to_full_uri(&self, default_endpoint: &Uri) -> Uri { + if let Some(uri) = &self.uri { let mut parts = uri.clone().into_parts(); if parts.scheme.is_none() { parts.scheme = default_endpoint.scheme().cloned(); @@ -45,7 +45,7 @@ impl UriEndpoint { if let Some(port) = port { parts.authority = Some( - Authority::from_str(format!("{}:{}", host, port).as_str()) + Authority::from_str(format!("{host}:{port}").as_str()) .expect("host and port must have come from a valid uri, qed"), ) } else { @@ -64,7 +64,9 @@ impl UriEndpoint { Uri::from_parts(parts) .expect("uri cannot be invalid as it was constructed from existing parts") - }) + } else { + default_endpoint.clone() + } } } @@ -72,7 +74,7 @@ impl<'de> Deserialize<'de> for UriEndpoint { fn deserialize>(deserializer: D) -> Result { struct EndpointVisitor; - impl<'de> Visitor<'de> for EndpointVisitor { + impl Visitor<'_> for EndpointVisitor { type Value = UriEndpoint; fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { @@ -91,8 +93,7 @@ impl<'de> Deserialize<'de> for UriEndpoint { match Uri::from_str(v) { Ok(uri) => Ok(UriEndpoint { uri: Some(uri) }), Err(_) => Err(Error::custom(format!( - "invalid endpoint: {}. Expected a valid uri or 'default'", - v + "invalid endpoint: {v}. Expected a valid uri or 'default'" ))), } } @@ -107,8 +108,8 @@ impl JsonSchema for UriEndpoint { "UriEndpoint".to_string() } - fn json_schema(gen: &mut SchemaGenerator) -> Schema { - gen.subschema_for::() + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + generator.subschema_for::() } } @@ -124,17 +125,11 @@ pub(crate) struct SocketEndpoint { socket: Option, } -impl SocketEndpoint { - pub(crate) fn to_socket(&self) -> Option { - self.socket - } -} - impl<'de> Deserialize<'de> for SocketEndpoint { fn deserialize>(deserializer: D) -> Result { struct EndpointVisitor; - impl<'de> Visitor<'de> for EndpointVisitor { + impl Visitor<'_> for EndpointVisitor { type Value = SocketEndpoint; fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { @@ -155,8 +150,7 @@ impl<'de> Deserialize<'de> for SocketEndpoint { socket: Some(socket), }), Err(_) => Err(Error::custom(format!( - "invalid endpoint: {}. Expected a valid socket or 'default'", - v + "invalid endpoint: {v}. Expected a valid socket or 'default'" ))), } } @@ -171,8 +165,8 @@ impl JsonSchema for SocketEndpoint { "SocketEndpoint".to_string() } - fn json_schema(gen: &mut SchemaGenerator) -> Schema { - gen.subschema_for::() + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + generator.subschema_for::() } } @@ -194,6 +188,12 @@ mod test { use crate::plugins::telemetry::endpoint::SocketEndpoint; use crate::plugins::telemetry::endpoint::UriEndpoint; + impl SocketEndpoint { + fn to_socket(&self) -> Option { + self.socket + } + } + #[test] fn test_parse_uri_default() { let endpoint = serde_yaml::from_str::("default").unwrap(); @@ -212,57 +212,56 @@ mod test { fn test_parse_uri_error() { let error = serde_yaml::from_str::("example.com:2000/path") .expect_err("expected error"); - assert_eq!(error.to_string(), "invalid endpoint: example.com:2000/path. Expected a valid uri or 'default' at line 1 column 1"); + assert_eq!( + error.to_string(), + "invalid endpoint: example.com:2000/path. Expected a valid uri or 'default' at line 1 column 1" + ); } #[test] - fn test_to_url() { + fn test_to_full_uri() { + assert_eq!( + UriEndpoint::default().to_full_uri(&Uri::from_static("http://localhost:9411/path2")), + Uri::from_static("http://localhost:9411/path2") + ); assert_eq!( UriEndpoint::from(Uri::from_static("example.com")) - .to_uri(&Uri::from_static("http://localhost:9411/path2")) - .unwrap(), + .to_full_uri(&Uri::from_static("http://localhost:9411/path2")), Uri::from_static("http://example.com:9411/path2") ); assert_eq!( UriEndpoint::from(Uri::from_static("example.com:2000")) - .to_uri(&Uri::from_static("http://localhost:9411/path2")) - .unwrap(), + .to_full_uri(&Uri::from_static("http://localhost:9411/path2")), Uri::from_static("http://example.com:2000/path2") ); assert_eq!( UriEndpoint::from(Uri::from_static("http://example.com:2000/")) - .to_uri(&Uri::from_static("http://localhost:9411/path2")) - .unwrap(), + .to_full_uri(&Uri::from_static("http://localhost:9411/path2")), Uri::from_static("http://example.com:2000/") ); assert_eq!( UriEndpoint::from(Uri::from_static("http://example.com:2000/path1")) - .to_uri(&Uri::from_static("http://localhost:9411/path2")) - .unwrap(), + .to_full_uri(&Uri::from_static("http://localhost:9411/path2")), Uri::from_static("http://example.com:2000/path1") ); assert_eq!( UriEndpoint::from(Uri::from_static("http://example.com:2000")) - .to_uri(&Uri::from_static("http://localhost:9411/path2")) - .unwrap(), + .to_full_uri(&Uri::from_static("http://localhost:9411/path2")), Uri::from_static("http://example.com:2000") ); assert_eq!( UriEndpoint::from(Uri::from_static("http://example.com/path1")) - .to_uri(&Uri::from_static("http://localhost:9411/path2")) - .unwrap(), + .to_full_uri(&Uri::from_static("http://localhost:9411/path2")), Uri::from_static("http://example.com:9411/path1") ); assert_eq!( UriEndpoint::from(Uri::from_static("http://:2000/path1")) - .to_uri(&Uri::from_static("http://localhost:9411/path2")) - .unwrap(), + .to_full_uri(&Uri::from_static("http://localhost:9411/path2")), Uri::from_static("http://localhost:2000/path1") ); assert_eq!( UriEndpoint::from(Uri::from_static("/path1")) - .to_uri(&Uri::from_static("http://localhost:9411/path2")) - .unwrap(), + .to_full_uri(&Uri::from_static("http://localhost:9411/path2")), Uri::from_static("http://localhost:9411/path1") ); } @@ -285,7 +284,10 @@ mod test { fn test_parse_socket_error() { let error = serde_yaml::from_str::("example.com:2000/path") .expect_err("expected error"); - assert_eq!(error.to_string(), "invalid endpoint: example.com:2000/path. Expected a valid socket or 'default' at line 1 column 1"); + assert_eq!( + error.to_string(), + "invalid endpoint: example.com:2000/path. Expected a valid socket or 'default' at line 1 column 1" + ); } #[test] diff --git a/apollo-router/src/plugins/telemetry/error_counter/mod.rs b/apollo-router/src/plugins/telemetry/error_counter/mod.rs new file mode 100644 index 0000000000..5e08700a2a --- /dev/null +++ b/apollo-router/src/plugins/telemetry/error_counter/mod.rs @@ -0,0 +1,270 @@ +use std::sync::Arc; + +use ahash::HashMap; +use ahash::HashSet; +use futures::StreamExt; +use futures::future::ready; +use futures::stream::once; +use serde::de::DeserializeOwned; +use uuid::Uuid; + +use crate::Context; +use crate::apollo_studio_interop::UsageReporting; +use crate::context::COUNTED_ERRORS; +use crate::context::OPERATION_KIND; +use crate::context::OPERATION_NAME; +use crate::context::ROUTER_RESPONSE_ERRORS; +use crate::graphql; +use crate::graphql::Error; +use crate::plugins::telemetry::CLIENT_NAME; +use crate::plugins::telemetry::CLIENT_VERSION; +use crate::plugins::telemetry::apollo::ErrorsConfiguration; +use crate::plugins::telemetry::apollo::ExtendedErrorMetricsMode; +use crate::query_planner::APOLLO_OPERATION_ID; +use crate::services::ExecutionResponse; +use crate::services::RouterResponse; +use crate::services::SubgraphResponse; +use crate::services::SupergraphResponse; +use crate::spec::query::EXTENSIONS_VALUE_COMPLETION_KEY; + +#[cfg(test)] +mod tests; + +pub(crate) async fn count_subgraph_errors( + response: SubgraphResponse, + errors_config: &ErrorsConfiguration, +) -> SubgraphResponse { + let context = response.context.clone(); + let errors_config = errors_config.clone(); + + let response_body = response.response.body(); + if !response_body.errors.is_empty() { + count_operation_errors(&response_body.errors, &context, &errors_config); + // Refresh context with the most up-to-date list of errors + let _ = context.insert(COUNTED_ERRORS, to_set(&response_body.errors)); + } + SubgraphResponse { + context: response.context, + subgraph_name: response.subgraph_name, + id: response.id, + response: response.response, + } +} + +pub(crate) async fn count_supergraph_errors( + response: SupergraphResponse, + errors_config: &ErrorsConfiguration, +) -> SupergraphResponse { + let context = response.context.clone(); + let errors_config = errors_config.clone(); + + let (parts, stream) = response.response.into_parts(); + + let stream = stream.inspect(move |response_body| { + if !response_body.errors.is_empty() { + count_operation_errors(&response_body.errors, &context, &errors_config); + } + if let Some(value_completion) = response_body + .extensions + .get(EXTENSIONS_VALUE_COMPLETION_KEY) + && let Some(vc_array) = value_completion.as_array() + { + // We only count these in the supergraph layer to avoid double counting + let errors: Vec = vc_array + .iter() + .filter_map(graphql::Error::from_value_completion_value) + .collect(); + count_operation_errors(&errors, &context, &errors_config); + } + + // Refresh context with the most up-to-date list of errors + let _ = context.insert(COUNTED_ERRORS, to_set(&response_body.errors)); + }); + + let (first_response, rest) = StreamExt::into_future(stream).await; + let new_response = http::Response::from_parts( + parts, + once(ready(first_response.unwrap_or_default())) + .chain(rest) + .boxed(), + ); + + SupergraphResponse { + context: response.context, + response: new_response, + } +} + +pub(crate) async fn count_execution_errors( + response: ExecutionResponse, + errors_config: &ErrorsConfiguration, +) -> ExecutionResponse { + let context = response.context.clone(); + let errors_config = errors_config.clone(); + + let (parts, stream) = response.response.into_parts(); + + let stream = stream.inspect(move |response_body| { + if !response_body.errors.is_empty() { + count_operation_errors(&response_body.errors, &context, &errors_config); + // Refresh context with the most up-to-date list of errors + let _ = context.insert(COUNTED_ERRORS, to_set(&response_body.errors)); + } + }); + + let (first_response, rest) = StreamExt::into_future(stream).await; + let new_response = http::Response::from_parts( + parts, + once(ready(first_response.unwrap_or_default())) + .chain(rest) + .boxed(), + ); + + ExecutionResponse { + context: response.context, + response: new_response, + } +} + +pub(crate) async fn count_router_errors( + response: RouterResponse, + errors_config: &ErrorsConfiguration, +) -> RouterResponse { + let context = response.context.clone(); + let errors_config = errors_config.clone(); + + // We look at context for our current errors instead of the existing response to avoid a full + // response deserialization. + let errors_by_id: HashMap = unwrap_from_context(&context, ROUTER_RESPONSE_ERRORS); + let errors: Vec = errors_by_id + .iter() + .map(|(id, error)| error.with_apollo_id(*id)) + .collect(); + if !errors.is_empty() { + count_operation_errors(&errors, &context, &errors_config); + // Router layer handling is unique in that the list of new errors from context may not + // include errors we previously counted. Thus, we must combine the set of previously counted + // errors with the set of new errors here before adding to context. + let mut counted_errors: HashSet = unwrap_from_context(&context, COUNTED_ERRORS); + counted_errors.extend(errors.iter().map(Error::apollo_id)); + let _ = context.insert(COUNTED_ERRORS, counted_errors); + } + + RouterResponse { + context: response.context, + response: response.response, + } +} + +fn to_set(errors: &[Error]) -> HashSet { + errors.iter().map(Error::apollo_id).collect() +} + +fn count_operation_errors( + errors: &[Error], + context: &Context, + errors_config: &ErrorsConfiguration, +) { + let previously_counted_errors_map: HashSet = unwrap_from_context(context, COUNTED_ERRORS); + + let mut operation_id: String = unwrap_from_context(context, APOLLO_OPERATION_ID); + let mut operation_name: String = unwrap_from_context(context, OPERATION_NAME); + let operation_kind: String = unwrap_from_context(context, OPERATION_KIND); + let client_name: String = unwrap_from_context(context, CLIENT_NAME); + let client_version: String = unwrap_from_context(context, CLIENT_VERSION); + + let maybe_usage_reporting = context + .extensions() + .with_lock(|lock| lock.get::>().cloned()); + + if let Some(usage_reporting) = maybe_usage_reporting { + // Try to get operation ID from usage reporting if it's not in context (e.g. on parse/validation error) + if operation_id.is_empty() { + operation_id = usage_reporting.get_operation_id(); + } + + // Also try to get operation name from usage reporting if it's not in context + if operation_name.is_empty() { + operation_name = usage_reporting.get_operation_name(); + } + } + + for error in errors { + let apollo_id = error.apollo_id(); + + // If we already counted this error in a previous layer, then skip counting it again + if previously_counted_errors_map.contains(&apollo_id) { + continue; + } + + // If we haven't seen this error before, then count it + let service = error + .extensions + .get("service") + .and_then(|s| s.as_str()) + .unwrap_or_default() + .to_string(); + let severity = error.extensions.get("severity").and_then(|s| s.as_str()); + let path = match &error.path { + None => "".into(), + Some(path) => path.to_string(), + }; + + let send_otlp_errors = if service.is_empty() { + matches!( + errors_config.preview_extended_error_metrics, + ExtendedErrorMetricsMode::Enabled + ) + } else { + let subgraph_error_config = errors_config.subgraph.get_error_config(&service); + subgraph_error_config.send + && matches!( + errors_config.preview_extended_error_metrics, + ExtendedErrorMetricsMode::Enabled + ) + }; + + let maybe_code = error.extension_code(); + + if send_otlp_errors { + let severity_str = severity + .unwrap_or(tracing::Level::ERROR.as_str()) + .to_string(); + u64_counter!( + "apollo.router.operations.error", + "Number of errors returned by operation", + 1, + "apollo.operation.id" = operation_id.clone(), + "graphql.operation.name" = operation_name.clone(), + "graphql.operation.type" = operation_kind.clone(), + "apollo.client.name" = client_name.clone(), + "apollo.client.version" = client_version.clone(), + "graphql.error.extensions.code" = maybe_code.clone().unwrap_or_default(), + "graphql.error.extensions.severity" = severity_str, + "graphql.error.path" = path, + "apollo.router.error.service" = service + ); + } + count_graphql_error(1, maybe_code); + } +} + +fn unwrap_from_context(context: &Context, key: &str) -> V { + context + .get::<_, V>(key) + .unwrap_or_default() + .unwrap_or_default() +} + +fn count_graphql_error(count: u64, maybe_code: Option) { + let mut attrs = Vec::new(); + if let Some(code) = maybe_code { + attrs.push(opentelemetry::KeyValue::new("code", code)); + } + u64_counter!( + "apollo.router.graphql_error", + "Number of GraphQL error responses returned by the router", + count, + attrs + ); +} diff --git a/apollo-router/src/plugins/telemetry/error_counter/tests.rs b/apollo-router/src/plugins/telemetry/error_counter/tests.rs new file mode 100644 index 0000000000..023df21308 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/error_counter/tests.rs @@ -0,0 +1,1301 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +use http::Method; +use http::StatusCode; +use http::Uri; +use http::header::CONTENT_TYPE; +use mime::APPLICATION_JSON; +use opentelemetry::KeyValue; +use serde_json_bytes::Value; +use serde_json_bytes::json; +use uuid::Uuid; + +use crate::Context; +use crate::context::COUNTED_ERRORS; +use crate::context::OPERATION_KIND; +use crate::context::OPERATION_NAME; +use crate::graphql; +use crate::json_ext::Path; +use crate::metrics::FutureMetricsExt; +use crate::plugins::telemetry::CLIENT_NAME; +use crate::plugins::telemetry::CLIENT_VERSION; +use crate::plugins::telemetry::Telemetry; +use crate::plugins::telemetry::apollo::ErrorConfiguration; +use crate::plugins::telemetry::apollo::ErrorRedactionPolicy; +use crate::plugins::telemetry::apollo::ErrorsConfiguration; +use crate::plugins::telemetry::apollo::ExtendedErrorMetricsMode; +use crate::plugins::telemetry::apollo::SubgraphErrorConfig; +use crate::plugins::telemetry::error_counter::count_execution_errors; +use crate::plugins::telemetry::error_counter::count_operation_errors; +use crate::plugins::telemetry::error_counter::count_router_errors; +use crate::plugins::telemetry::error_counter::count_subgraph_errors; +use crate::plugins::telemetry::error_counter::count_supergraph_errors; +use crate::plugins::telemetry::error_counter::unwrap_from_context; +use crate::plugins::test::PluginTestHarness; +use crate::query_planner::APOLLO_OPERATION_ID; +use crate::services::ExecutionResponse; +use crate::services::RouterResponse; +use crate::services::SubgraphResponse; +use crate::services::SupergraphResponse; +use crate::services::execution; +use crate::services::router; +use crate::services::subgraph; +use crate::services::subgraph::SubgraphRequestId; +use crate::services::supergraph; +use crate::spec::query::EXTENSIONS_VALUE_COMPLETION_KEY; + +#[tokio::test] +async fn test_count_supergraph_errors_with_no_previously_counted_errors() { + async { + let config = ErrorsConfiguration { + preview_extended_error_metrics: ExtendedErrorMetricsMode::Enabled, + ..Default::default() + }; + + let context = Context::default(); + let _ = context.insert(APOLLO_OPERATION_ID, "some-id".to_string()); + let _ = context.insert(OPERATION_NAME, "SomeOperation".to_string()); + let _ = context.insert(OPERATION_KIND, "query".to_string()); + let _ = context.insert(CLIENT_NAME, "client-1".to_string()); + let _ = context.insert(CLIENT_VERSION, "version-1".to_string()); + + let error_id = Uuid::new_v4(); + let new_response = count_supergraph_errors( + SupergraphResponse::fake_builder() + .header("Accept", "application/json") + .context(context) + .status_code(StatusCode::BAD_REQUEST) + .errors(vec![ + graphql::Error::builder() + .message("You did a bad request.") + .extension_code("GRAPHQL_VALIDATION_FAILED") + .apollo_id(error_id) + .build(), + ]) + .build() + .unwrap(), + &config, + ) + .await; + + assert_counter!( + "apollo.router.operations.error", + 1, + "apollo.operation.id" = "some-id", + "graphql.operation.name" = "SomeOperation", + "graphql.operation.type" = "query", + "apollo.client.name" = "client-1", + "apollo.client.version" = "version-1", + "graphql.error.extensions.code" = "GRAPHQL_VALIDATION_FAILED", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "", + "apollo.router.error.service" = "" + ); + + assert_counter!( + "apollo.router.graphql_error", + 1, + code = "GRAPHQL_VALIDATION_FAILED" + ); + + assert_eq!( + unwrap_from_context::>(&new_response.context, COUNTED_ERRORS), + HashSet::from([error_id]) + ) + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_count_supergraph_errors_with_previously_counted_errors() { + async { + let config = ErrorsConfiguration { + preview_extended_error_metrics: ExtendedErrorMetricsMode::Enabled, + ..Default::default() + }; + + let context = Context::default(); + let validation_error_id = Uuid::new_v4(); + let custom_error_id = Uuid::new_v4(); + + let _ = context.insert(COUNTED_ERRORS, HashSet::from([validation_error_id])); + + let _ = context.insert(APOLLO_OPERATION_ID, "some-id".to_string()); + let _ = context.insert(OPERATION_NAME, "SomeOperation".to_string()); + let _ = context.insert(OPERATION_KIND, "query".to_string()); + let _ = context.insert(CLIENT_NAME, "client-1".to_string()); + let _ = context.insert(CLIENT_VERSION, "version-1".to_string()); + + let new_response = count_supergraph_errors( + SupergraphResponse::fake_builder() + .header("Accept", "application/json") + .context(context) + .status_code(StatusCode::BAD_REQUEST) + .error( + graphql::Error::builder() + .message("You did a bad request.") + .extension_code("GRAPHQL_VALIDATION_FAILED") + .apollo_id(validation_error_id) + .build(), + ) + .error( + graphql::Error::builder() + .message("Custom error text") + .extension_code("CUSTOM_ERROR") + .apollo_id(custom_error_id) + .build(), + ) + .build() + .unwrap(), + &config, + ) + .await; + + assert_counter!( + "apollo.router.operations.error", + 1, + "apollo.operation.id" = "some-id", + "graphql.operation.name" = "SomeOperation", + "graphql.operation.type" = "query", + "apollo.client.name" = "client-1", + "apollo.client.version" = "version-1", + "graphql.error.extensions.code" = "CUSTOM_ERROR", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "", + "apollo.router.error.service" = "" + ); + + assert_counter!("apollo.router.graphql_error", 1, code = "CUSTOM_ERROR"); + + assert_counter_not_exists!( + "apollo.router.operations.error", + u64, + "apollo.operation.id" = "some-id", + "graphql.operation.name" = "SomeOperation", + "graphql.operation.type" = "query", + "apollo.client.name" = "client-1", + "apollo.client.version" = "version-1", + "graphql.error.extensions.code" = "GRAPHQL_VALIDATION_FAILED", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "", + "apollo.router.error.service" = "" + ); + + assert_counter_not_exists!( + "apollo.router.graphql_error", + u64, + code = "GRAPHQL_VALIDATION_FAILED" + ); + + assert_eq!( + unwrap_from_context::>(&new_response.context, COUNTED_ERRORS), + HashSet::from([validation_error_id, custom_error_id]) + ) + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_count_subgraph_errors_with_include_subgraphs_enabled() { + async { + let config = ErrorsConfiguration { + preview_extended_error_metrics: ExtendedErrorMetricsMode::Enabled, + subgraph: SubgraphErrorConfig { + subgraphs: HashMap::from([( + "some-subgraph".to_string(), + ErrorConfiguration { + send: true, + redact: false, + redaction_policy: ErrorRedactionPolicy::Strict, + }, + )]), + ..Default::default() + }, + }; + + let context = Context::default(); + let _ = context.insert(APOLLO_OPERATION_ID, "some-id".to_string()); + let _ = context.insert(OPERATION_NAME, "SomeOperation".to_string()); + let _ = context.insert(OPERATION_KIND, "query".to_string()); + let _ = context.insert(CLIENT_NAME, "client-1".to_string()); + let _ = context.insert(CLIENT_VERSION, "version-1".to_string()); + + let error_id = Uuid::new_v4(); + let new_response = count_subgraph_errors( + SubgraphResponse::fake_builder() + .context(context) + .subgraph_name("some-subgraph".to_string()) + .status_code(StatusCode::BAD_REQUEST) + .errors(vec![ + graphql::Error::builder() + .message("You did a bad request.") + .path(Path::from("obj/field")) + .extension_code("GRAPHQL_VALIDATION_FAILED") + .extension("service", "some-subgraph") + .apollo_id(error_id) + .build(), + ]) + .build(), + &config, + ) + .await; + + assert_counter!( + "apollo.router.operations.error", + 1, + "apollo.operation.id" = "some-id", + "graphql.operation.name" = "SomeOperation", + "graphql.operation.type" = "query", + "apollo.client.name" = "client-1", + "apollo.client.version" = "version-1", + "graphql.error.extensions.code" = "GRAPHQL_VALIDATION_FAILED", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "some-subgraph" + ); + + assert_counter!( + "apollo.router.graphql_error", + 1, + code = "GRAPHQL_VALIDATION_FAILED" + ); + + assert_eq!( + unwrap_from_context::>(&new_response.context, COUNTED_ERRORS), + HashSet::from([error_id]) + ) + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_count_subgraph_errors_with_include_subgraphs_disabled() { + async { + let config = ErrorsConfiguration { + preview_extended_error_metrics: ExtendedErrorMetricsMode::Enabled, + subgraph: SubgraphErrorConfig { + subgraphs: HashMap::from([( + "some-subgraph".to_string(), + ErrorConfiguration { + send: false, + redact: true, + redaction_policy: ErrorRedactionPolicy::Strict, + }, + )]), + ..Default::default() + }, + }; + + let context = Context::default(); + let error_id = Uuid::new_v4(); + let new_response = count_subgraph_errors( + SubgraphResponse::fake_builder() + .context(context) + .subgraph_name("some-subgraph".to_string()) + .status_code(StatusCode::BAD_REQUEST) + .errors(vec![ + graphql::Error::builder() + .message("You did a bad request.") + .path(Path::from("obj/field")) + .extension_code("GRAPHQL_VALIDATION_FAILED") + .extension("service", "some-subgraph") + .apollo_id(error_id) + .build(), + ]) + .build(), + &config, + ) + .await; + + assert_counter_not_exists!( + "apollo.router.operations.error", + u64, + "apollo.operation.id" = "some-id", + "graphql.operation.name" = "SomeOperation", + "graphql.operation.type" = "query", + "apollo.client.name" = "client-1", + "apollo.client.version" = "version-1", + "graphql.error.extensions.code" = "GRAPHQL_VALIDATION_FAILED", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "some-subgraph" + ); + + assert_counter!( + // TODO(tim): is this a bug? Should we not count these when the subgraph is excluded? + "apollo.router.graphql_error", + 1, + code = "GRAPHQL_VALIDATION_FAILED" + ); + + assert_eq!( + unwrap_from_context::>(&new_response.context, COUNTED_ERRORS), + HashSet::from([error_id]) + ) + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_count_execution_errors() { + async { + let config = ErrorsConfiguration { + preview_extended_error_metrics: ExtendedErrorMetricsMode::Enabled, + ..Default::default() + }; + + let context = Context::default(); + let _ = context.insert(APOLLO_OPERATION_ID, "some-id".to_string()); + let _ = context.insert(OPERATION_NAME, "SomeOperation".to_string()); + let _ = context.insert(OPERATION_KIND, "query".to_string()); + let _ = context.insert(CLIENT_NAME, "client-1".to_string()); + let _ = context.insert(CLIENT_VERSION, "version-1".to_string()); + + let error_id = Uuid::new_v4(); + let new_response = count_execution_errors( + ExecutionResponse::fake_builder() + .context(context) + .status_code(StatusCode::BAD_REQUEST) + .errors(vec![ + graphql::Error::builder() + .message("You did a bad request.") + .path(Path::from("obj/field")) + .extension_code("GRAPHQL_VALIDATION_FAILED") + .extension("service", "some-subgraph") + .apollo_id(error_id) + .build(), + ]) + .build() + .unwrap(), + &config, + ) + .await; + + assert_counter!( + "apollo.router.operations.error", + 1, + "apollo.operation.id" = "some-id", + "graphql.operation.name" = "SomeOperation", + "graphql.operation.type" = "query", + "apollo.client.name" = "client-1", + "apollo.client.version" = "version-1", + "graphql.error.extensions.code" = "GRAPHQL_VALIDATION_FAILED", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "some-subgraph" + ); + + assert_counter!( + "apollo.router.graphql_error", + 1, + code = "GRAPHQL_VALIDATION_FAILED" + ); + + assert_eq!( + unwrap_from_context::>(&new_response.context, COUNTED_ERRORS), + HashSet::from([error_id]) + ) + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_count_router_errors() { + async { + let config = ErrorsConfiguration { + preview_extended_error_metrics: ExtendedErrorMetricsMode::Enabled, + ..Default::default() + }; + + let context = Context::default(); + let _ = context.insert(APOLLO_OPERATION_ID, "some-id".to_string()); + let _ = context.insert(OPERATION_NAME, "SomeOperation".to_string()); + let _ = context.insert(OPERATION_KIND, "query".to_string()); + let _ = context.insert(CLIENT_NAME, "client-1".to_string()); + let _ = context.insert(CLIENT_VERSION, "version-1".to_string()); + + let error_id = Uuid::new_v4(); + let new_response = count_router_errors( + RouterResponse::fake_builder() + .context(context) + .status_code(StatusCode::BAD_REQUEST) + .errors(vec![ + graphql::Error::builder() + .message("You did a bad request.") + .path(Path::from("obj/field")) + .extension_code("GRAPHQL_VALIDATION_FAILED") + .extension("service", "some-subgraph") + .apollo_id(error_id) + .build(), + ]) + .build() + .unwrap(), + &config, + ) + .await; + + assert_counter!( + "apollo.router.operations.error", + 1, + "apollo.operation.id" = "some-id", + "graphql.operation.name" = "SomeOperation", + "graphql.operation.type" = "query", + "apollo.client.name" = "client-1", + "apollo.client.version" = "version-1", + "graphql.error.extensions.code" = "GRAPHQL_VALIDATION_FAILED", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "some-subgraph" + ); + + assert_counter!( + "apollo.router.graphql_error", + 1, + code = "GRAPHQL_VALIDATION_FAILED" + ); + + assert_eq!( + unwrap_from_context::>(&new_response.context, COUNTED_ERRORS), + HashSet::from([error_id]) + ) + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_count_operation_errors_with_extended_config_enabled() { + async { + let config = ErrorsConfiguration { + preview_extended_error_metrics: ExtendedErrorMetricsMode::Enabled, + ..Default::default() + }; + + let context = Context::default(); + let _ = context.insert(APOLLO_OPERATION_ID, "some-id".to_string()); + let _ = context.insert(OPERATION_NAME, "SomeOperation".to_string()); + let _ = context.insert(OPERATION_KIND, "query".to_string()); + let _ = context.insert(CLIENT_NAME, "client-1".to_string()); + let _ = context.insert(CLIENT_VERSION, "version-1".to_string()); + + let error = graphql::Error::builder() + .message("some error") + .extension_code("SOME_ERROR_CODE") + .extension("service", "mySubgraph") + .path(Path::from("obj/field")) + .build(); + + count_operation_errors(&[error], &context, &config); + + assert_counter!( + "apollo.router.operations.error", + 1, + "apollo.operation.id" = "some-id", + "graphql.operation.name" = "SomeOperation", + "graphql.operation.type" = "query", + "apollo.client.name" = "client-1", + "apollo.client.version" = "version-1", + "graphql.error.extensions.code" = "SOME_ERROR_CODE", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "mySubgraph" + ); + + assert_counter!("apollo.router.graphql_error", 1, code = "SOME_ERROR_CODE"); + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_count_operation_errors_with_all_json_types_and_extended_config_enabled() { + async { + let config = ErrorsConfiguration { + preview_extended_error_metrics: ExtendedErrorMetricsMode::Enabled, + ..Default::default() + }; + + let context = Context::default(); + let _ = context.insert(APOLLO_OPERATION_ID, "some-id".to_string()); + let _ = context.insert(OPERATION_NAME, "SomeOperation".to_string()); + let _ = context.insert(OPERATION_KIND, "query".to_string()); + let _ = context.insert(CLIENT_NAME, "client-1".to_string()); + let _ = context.insert(CLIENT_VERSION, "version-1".to_string()); + + let codes = [ + json!("VALID_ERROR_CODE"), + json!(400), + json!(true), + Value::Null, + json!(["code1", "code2"]), + json!({"inner": "myCode"}), + ]; + + let errors = codes.map(|code| { + graphql::Error::from_value(json!( + { + "message": "error occurred", + "extensions": { + "code": code, + "service": "mySubgraph" + }, + "path": ["obj", "field"] + } + )) + .unwrap() + }); + + count_operation_errors(&errors, &context, &config); + + assert_counter!( + "apollo.router.operations.error", + 1, + "apollo.operation.id" = "some-id", + "graphql.operation.name" = "SomeOperation", + "graphql.operation.type" = "query", + "apollo.client.name" = "client-1", + "apollo.client.version" = "version-1", + "graphql.error.extensions.code" = "VALID_ERROR_CODE", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "mySubgraph" + ); + + assert_counter!("apollo.router.graphql_error", 1, code = "VALID_ERROR_CODE"); + + assert_counter!( + "apollo.router.operations.error", + 1, + "apollo.operation.id" = "some-id", + "graphql.operation.name" = "SomeOperation", + "graphql.operation.type" = "query", + "apollo.client.name" = "client-1", + "apollo.client.version" = "version-1", + "graphql.error.extensions.code" = "400", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "mySubgraph" + ); + + assert_counter!("apollo.router.graphql_error", 1, code = "400"); + + // Code is ignored for null, arrays, booleans and objects + + assert_counter!( + "apollo.router.operations.error", + 4, + "apollo.operation.id" = "some-id", + "graphql.operation.name" = "SomeOperation", + "graphql.operation.type" = "query", + "apollo.client.name" = "client-1", + "apollo.client.version" = "version-1", + "graphql.error.extensions.code" = "", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "mySubgraph" + ); + + // Ensure these have NO attributes + assert_counter!("apollo.router.graphql_error", 4, &[]); + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_count_operation_errors_with_duplicate_errors_and_extended_config_enabled() { + async { + let config = ErrorsConfiguration { + preview_extended_error_metrics: ExtendedErrorMetricsMode::Enabled, + ..Default::default() + }; + + let context = Context::default(); + let _ = context.insert(APOLLO_OPERATION_ID, "some-id".to_string()); + let _ = context.insert(OPERATION_NAME, "SomeOperation".to_string()); + let _ = context.insert(OPERATION_KIND, "query".to_string()); + let _ = context.insert(CLIENT_NAME, "client-1".to_string()); + let _ = context.insert(CLIENT_VERSION, "version-1".to_string()); + + let codes = [ + json!("VALID_ERROR_CODE"), + Value::Null, + json!("VALID_ERROR_CODE"), + Value::Null, + ]; + + let errors = codes.map(|code| { + graphql::Error::from_value(json!( + { + "message": "error occurred", + "extensions": { + "code": code, + "service": "mySubgraph" + }, + "path": ["obj", "field"] + } + )) + .unwrap() + }); + + count_operation_errors(&errors, &context, &config); + + assert_counter!( + "apollo.router.operations.error", + 2, + "apollo.operation.id" = "some-id", + "graphql.operation.name" = "SomeOperation", + "graphql.operation.type" = "query", + "apollo.client.name" = "client-1", + "apollo.client.version" = "version-1", + "graphql.error.extensions.code" = "VALID_ERROR_CODE", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "mySubgraph" + ); + + assert_counter!("apollo.router.graphql_error", 2, code = "VALID_ERROR_CODE"); + + assert_counter!( + "apollo.router.operations.error", + 2, + "apollo.operation.id" = "some-id", + "graphql.operation.name" = "SomeOperation", + "graphql.operation.type" = "query", + "apollo.client.name" = "client-1", + "apollo.client.version" = "version-1", + "graphql.error.extensions.code" = "", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "mySubgraph" + ); + + assert_counter!("apollo.router.graphql_error", 2); + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_subgraph_error_counting() { + async { + let operation_name = "operationName"; + let operation_type = "query"; + let operation_id = "opId"; + let client_name = "client"; + let client_version = "version"; + let previously_counted_error_id = Uuid::new_v4(); + let subgraph_name = "mySubgraph"; + let subgraph_request_id = SubgraphRequestId("5678".to_string()); + let example_response = graphql::Response::builder() + .data(json!({"data": null})) + .errors(vec![ + graphql::Error::builder() + .message("previously counted error") + .extension_code("ERROR_CODE") + .extension("service", subgraph_name) + .path(Path::from("obj/field")) + .apollo_id(previously_counted_error_id) + .build(), + graphql::Error::builder() + .message("error in supergraph layer") + .extension_code("SUPERGRAPH_CODE") + .extension("service", subgraph_name) + .path(Path::from("obj/field")) + .build(), + ]) + .build(); + let config = json!({ + "telemetry":{ + "apollo": { + "errors": { + "preview_extended_error_metrics": "enabled", + "subgraph": { + "subgraphs": { + "myIgnoredSubgraph": { + "send": false, + } + } + } + } + } + } + }) + .to_string(); + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(&config) + .build() + .await + .expect("test harness"); + + let router_service = test_harness.subgraph_service(subgraph_name, move |req| { + let subgraph_response = example_response.clone(); + let subgraph_request_id = subgraph_request_id.clone(); + async move { + Ok(SubgraphResponse::new_from_response( + http::Response::new(subgraph_response.clone()), + req.context, + subgraph_name.to_string(), + subgraph_request_id, + )) + } + }); + + let context = Context::new(); + context.insert_json_value(APOLLO_OPERATION_ID, operation_id.into()); + context.insert_json_value(OPERATION_NAME, operation_name.into()); + context.insert_json_value(OPERATION_KIND, operation_type.into()); + context.insert_json_value(CLIENT_NAME, client_name.into()); + context.insert_json_value(CLIENT_VERSION, client_version.into()); + let _ = context.insert(COUNTED_ERRORS, HashSet::from([previously_counted_error_id])); + + let request = subgraph::Request::fake_builder() + .subgraph_name(subgraph_name) + .context(context) + .build(); + router_service.call(request).await.unwrap(); + + assert_counter!( + "apollo.router.operations.error", + 1, + &[ + KeyValue::new("apollo.operation.id", operation_id), + KeyValue::new("graphql.operation.name", operation_name), + KeyValue::new("graphql.operation.type", operation_type), + KeyValue::new("apollo.client.name", client_name), + KeyValue::new("apollo.client.version", client_version), + KeyValue::new("graphql.error.extensions.code", "SUPERGRAPH_CODE"), + KeyValue::new("graphql.error.extensions.severity", "ERROR"), + KeyValue::new("graphql.error.path", "/obj/field"), + KeyValue::new("apollo.router.error.service", "mySubgraph"), + ] + ); + assert_counter!("apollo.router.graphql_error", 1, code = "SUPERGRAPH_CODE"); + + assert_counter_not_exists!( + "apollo.router.operations.error", + u64, + "apollo.operation.id" = operation_id, + "graphql.operation.name" = operation_name, + "graphql.operation.type" = operation_type, + "apollo.client.name" = client_name, + "apollo.client.version" = client_version, + "graphql.error.extensions.code" = "ERROR_CODE", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "mySubgraph" + ); + + assert_counter_not_exists!("apollo.router.graphql_error", u64, code = "ERROR_CODE"); + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_execution_error_counting() { + async { + let operation_name = "operationName"; + let operation_type = "query"; + let operation_id = "opId"; + let client_name = "client"; + let client_version = "version"; + let previously_counted_error_id = Uuid::new_v4(); + let subgraph_name = "mySubgraph"; + let example_response = graphql::Response::builder() + .data(json!({"data": null})) + .errors(vec![ + graphql::Error::builder() + .message("previously counted error") + .extension_code("ERROR_CODE") + .extension("service", subgraph_name) + .path(Path::from("obj/field")) + .apollo_id(previously_counted_error_id) + .build(), + graphql::Error::builder() + .message("error in supergraph layer") + .extension_code("SUPERGRAPH_CODE") + .extension("service", subgraph_name) + .path(Path::from("obj/field")) + .build(), + ]) + .build(); + let config = json!({ + "telemetry":{ + "apollo": { + "errors": { + "preview_extended_error_metrics": "enabled", + "subgraph": { + "subgraphs": { + "myIgnoredSubgraph": { + "send": false, + } + } + } + } + } + } + }) + .to_string(); + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(&config) + .build() + .await + .expect("test harness"); + + let router_service = test_harness.execution_service(move |req| { + let execution_response = example_response.clone(); + async move { + Ok(ExecutionResponse::new_from_graphql_response( + execution_response.clone(), + req.context, + )) + } + }); + + let context = Context::new(); + context.insert_json_value(APOLLO_OPERATION_ID, operation_id.into()); + context.insert_json_value(OPERATION_NAME, operation_name.into()); + context.insert_json_value(OPERATION_KIND, operation_type.into()); + context.insert_json_value(CLIENT_NAME, client_name.into()); + context.insert_json_value(CLIENT_VERSION, client_version.into()); + let _ = context.insert(COUNTED_ERRORS, HashSet::from([previously_counted_error_id])); + + router_service + .call(execution::Request::fake_builder().context(context).build()) + .await + .unwrap(); + + assert_counter!( + "apollo.router.operations.error", + 1, + &[ + KeyValue::new("apollo.operation.id", operation_id), + KeyValue::new("graphql.operation.name", operation_name), + KeyValue::new("graphql.operation.type", operation_type), + KeyValue::new("apollo.client.name", client_name), + KeyValue::new("apollo.client.version", client_version), + KeyValue::new("graphql.error.extensions.code", "SUPERGRAPH_CODE"), + KeyValue::new("graphql.error.extensions.severity", "ERROR"), + KeyValue::new("graphql.error.path", "/obj/field"), + KeyValue::new("apollo.router.error.service", "mySubgraph"), + ] + ); + assert_counter!("apollo.router.graphql_error", 1, code = "SUPERGRAPH_CODE"); + + assert_counter_not_exists!( + "apollo.router.operations.error", + u64, + "apollo.operation.id" = operation_id, + "graphql.operation.name" = operation_name, + "graphql.operation.type" = operation_type, + "apollo.client.name" = client_name, + "apollo.client.version" = client_version, + "graphql.error.extensions.code" = "ERROR_CODE", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "mySubgraph" + ); + + assert_counter_not_exists!("apollo.router.graphql_error", u64, code = "ERROR_CODE"); + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_supergraph_error_counting() { + async { + let query = "query operationName { __typename }"; + let operation_name = "operationName"; + let operation_type = "query"; + let operation_id = "opId"; + let client_name = "client"; + let client_version = "version"; + let previously_counted_error_id = Uuid::new_v4(); + let subgraph_name = "mySubgraph"; + let example_response = graphql::Response::builder() + .data(json!({"data": null})) + .errors(vec![ + graphql::Error::builder() + .message("previously counted error") + .extension_code("ERROR_CODE") + .extension("service", subgraph_name) + .path(Path::from("obj/field")) + .apollo_id(previously_counted_error_id) + .build(), + graphql::Error::builder() + .message("error in supergraph layer") + .extension_code("SUPERGRAPH_CODE") + .extension("service", subgraph_name) + .path(Path::from("obj/field")) + .build(), + ]) + .build(); + let config = json!({ + "telemetry":{ + "apollo": { + "errors": { + "preview_extended_error_metrics": "enabled", + "subgraph": { + "subgraphs": { + "myIgnoredSubgraph": { + "send": false, + } + } + } + } + } + } + }) + .to_string(); + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(&config) + .build() + .await + .expect("test harness"); + + let router_service = test_harness.supergraph_service(move |req| { + let supergraph_response = example_response.clone(); + async move { + Ok(SupergraphResponse::new_from_graphql_response( + supergraph_response.clone(), + req.context, + )) + } + }); + + let context = Context::new(); + context.insert_json_value(APOLLO_OPERATION_ID, operation_id.into()); + context.insert_json_value(OPERATION_NAME, operation_name.into()); + context.insert_json_value(OPERATION_KIND, operation_type.into()); + context.insert_json_value(CLIENT_NAME, client_name.into()); + context.insert_json_value(CLIENT_VERSION, client_version.into()); + let _ = context.insert(COUNTED_ERRORS, HashSet::from([previously_counted_error_id])); + + router_service + .call( + supergraph::Request::builder() + .query(query) + .operation_name(operation_name) + .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .uri(Uri::from_static("/")) + .method(Method::POST) + .context(context) + .build() + .unwrap(), + ) + .await + .unwrap(); + + assert_counter!( + "apollo.router.operations.error", + 1, + &[ + KeyValue::new("apollo.operation.id", operation_id), + KeyValue::new("graphql.operation.name", operation_name), + KeyValue::new("graphql.operation.type", operation_type), + KeyValue::new("apollo.client.name", client_name), + KeyValue::new("apollo.client.version", client_version), + KeyValue::new("graphql.error.extensions.code", "SUPERGRAPH_CODE"), + KeyValue::new("graphql.error.extensions.severity", "ERROR"), + KeyValue::new("graphql.error.path", "/obj/field"), + KeyValue::new("apollo.router.error.service", "mySubgraph"), + ] + ); + assert_counter!("apollo.router.graphql_error", 1, code = "SUPERGRAPH_CODE"); + + assert_counter_not_exists!( + "apollo.router.operations.error", + u64, + "apollo.operation.id" = operation_id, + "graphql.operation.name" = operation_name, + "graphql.operation.type" = operation_type, + "apollo.client.name" = client_name, + "apollo.client.version" = client_version, + "graphql.error.extensions.code" = "ERROR_CODE", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "mySubgraph" + ); + + assert_counter_not_exists!("apollo.router.graphql_error", u64, code = "ERROR_CODE"); + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_router_error_counting() { + async { + let operation_name = "operationName"; + let operation_type = "query"; + let operation_id = "opId"; + let client_name = "client"; + let client_version = "version"; + let previously_counted_error_id = + Uuid::parse_str("cfe70a37-4651-4228-a56b-bad8444e67ad").unwrap(); + let subgraph_name = "mySubgraph"; + let config = json!({ + "telemetry":{ + "apollo": { + "errors": { + "preview_extended_error_metrics": "enabled", + "subgraph": { + "subgraphs": { + "myIgnoredSubgraph": { + "send": false, + } + } + } + } + } + } + }) + .to_string(); + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(&config) + .build() + .await + .expect("test harness"); + + let router_service = test_harness.router_service(move |req| async move { + RouterResponse::fake_builder() + .errors(vec![ + graphql::Error::builder() + .message("previously counted error") + .extension_code("ERROR_CODE") + .extension("service", subgraph_name) + .path(Path::from("obj/field")) + .apollo_id(previously_counted_error_id) + .build(), + graphql::Error::builder() + .message("error in supergraph layer") + .extension_code("SUPERGRAPH_CODE") + .extension("service", subgraph_name) + .path(Path::from("obj/field")) + .build(), + ]) + .context(req.context) + .build() + }); + + let context = Context::new(); + context.insert_json_value(APOLLO_OPERATION_ID, operation_id.into()); + context.insert_json_value(OPERATION_NAME, operation_name.into()); + context.insert_json_value(OPERATION_KIND, operation_type.into()); + context.insert_json_value(CLIENT_NAME, client_name.into()); + context.insert_json_value(CLIENT_VERSION, client_version.into()); + let _ = context.insert(COUNTED_ERRORS, HashSet::from([previously_counted_error_id])); + + router_service + .call( + router::Request::fake_builder() + .context(context) + .build() + .unwrap(), + ) + .await + .unwrap(); + + assert_counter!( + "apollo.router.operations.error", + 1, + &[ + KeyValue::new("apollo.operation.id", operation_id), + KeyValue::new("graphql.operation.name", operation_name), + KeyValue::new("graphql.operation.type", operation_type), + KeyValue::new("apollo.client.name", client_name), + KeyValue::new("apollo.client.version", client_version), + KeyValue::new("graphql.error.extensions.code", "SUPERGRAPH_CODE"), + KeyValue::new("graphql.error.extensions.severity", "ERROR"), + KeyValue::new("graphql.error.path", "/obj/field"), + KeyValue::new("apollo.router.error.service", "mySubgraph"), + ] + ); + assert_counter!("apollo.router.graphql_error", 1, code = "SUPERGRAPH_CODE"); + + assert_counter_not_exists!( + "apollo.router.operations.error", + u64, + "apollo.operation.id" = operation_id, + "graphql.operation.name" = operation_name, + "graphql.operation.type" = operation_type, + "apollo.client.name" = client_name, + "apollo.client.version" = client_version, + "graphql.error.extensions.code" = "ERROR_CODE", + "graphql.error.extensions.severity" = "ERROR", + "graphql.error.path" = "/obj/field", + "apollo.router.error.service" = "mySubgraph" + ); + + assert_counter_not_exists!("apollo.router.graphql_error", u64, code = "ERROR_CODE"); + } + .with_metrics() + .await; +} + +#[tokio::test] +async fn test_operation_errors_emitted_when_config_is_enabled() { + async { + let query = "query operationName { __typename }"; + let operation_name = "operationName"; + let operation_type = "query"; + let operation_id = "opId"; + let client_name = "client"; + let client_version = "version"; + + let config = json!({ + "telemetry":{ + "apollo": { + "errors": { + "preview_extended_error_metrics": "enabled", + "subgraph": { + "subgraphs": { + "myIgnoredSubgraph": { + "send": false, + } + } + } + } + } + } + }) + .to_string(); + + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .config(&config) + .build() + .await + .expect("test harness"); + + let router_service = + test_harness.supergraph_service(|req| async { + let example_response = graphql::Response::builder() + .data(json!({"data": null})) + .extension(EXTENSIONS_VALUE_COMPLETION_KEY, json!([{ + "message": "Cannot return null for non-nullable field SomeType.someField", + "path": Path::from("someType/someField") + }])) + .errors(vec![ + graphql::Error::builder() + .message("some error") + .extension_code("SOME_ERROR_CODE") + .extension("service", "mySubgraph") + .path(Path::from("obj/field")) + .build(), + graphql::Error::builder() + .message("some other error") + .extension_code("SOME_OTHER_ERROR_CODE") + .extension("service", "myOtherSubgraph") + .path(Path::from("obj/arr/@/firstElementField")) + .build(), + graphql::Error::builder() + .message("some ignored error") + .extension_code("SOME_IGNORED_ERROR_CODE") + .extension("service", "myIgnoredSubgraph") + .path(Path::from("obj/arr/@/firstElementField")) + .build(), + ]) + .build(); + Ok(SupergraphResponse::new_from_graphql_response( + example_response, + req.context, + )) + }); + + let context = Context::new(); + context.insert_json_value(APOLLO_OPERATION_ID, operation_id.into()); + context.insert_json_value(OPERATION_NAME, operation_name.into()); + context.insert_json_value(OPERATION_KIND, operation_type.into()); + context.insert_json_value(CLIENT_NAME, client_name.into()); + context.insert_json_value(CLIENT_VERSION, client_version.into()); + + router_service + .call( + supergraph::Request::builder() + .query(query) + .operation_name(operation_name) + .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .uri(Uri::from_static("/")) + .method(Method::POST) + .context(context) + .build() + .unwrap(), + ) + .await + .unwrap(); + + assert_counter!( + "apollo.router.operations.error", + 1, + &[ + KeyValue::new("apollo.operation.id", operation_id), + KeyValue::new("graphql.operation.name", operation_name), + KeyValue::new("graphql.operation.type", operation_type), + KeyValue::new("apollo.client.name", client_name), + KeyValue::new("apollo.client.version", client_version), + KeyValue::new("graphql.error.extensions.code", "SOME_ERROR_CODE"), + KeyValue::new("graphql.error.extensions.severity", "ERROR"), + KeyValue::new("graphql.error.path", "/obj/field"), + KeyValue::new("apollo.router.error.service", "mySubgraph"), + ] + ); + assert_counter!( + "apollo.router.operations.error", + 1, + &[ + KeyValue::new("apollo.operation.id", operation_id), + KeyValue::new("graphql.operation.name", operation_name), + KeyValue::new("graphql.operation.type", operation_type), + KeyValue::new("apollo.client.name", client_name), + KeyValue::new("apollo.client.version", client_version), + KeyValue::new("graphql.error.extensions.code", "SOME_OTHER_ERROR_CODE"), + KeyValue::new("graphql.error.extensions.severity", "ERROR"), + KeyValue::new("graphql.error.path", "/obj/arr/@/firstElementField"), + KeyValue::new("apollo.router.error.service", "myOtherSubgraph"), + ] + ); + assert_counter!( + "apollo.router.operations.error", + 1, + &[ + KeyValue::new("apollo.operation.id", operation_id), + KeyValue::new("graphql.operation.name", operation_name), + KeyValue::new("graphql.operation.type", operation_type), + KeyValue::new("apollo.client.name", client_name), + KeyValue::new("apollo.client.version", client_version), + KeyValue::new( + "graphql.error.extensions.code", + "RESPONSE_VALIDATION_FAILED" + ), + KeyValue::new("graphql.error.extensions.severity", "WARN"), + KeyValue::new("graphql.error.path", "/someType/someField"), + KeyValue::new("apollo.router.error.service", ""), + ] + ); + assert_counter_not_exists!( + "apollo.router.operations.error", + u64, + &[ + KeyValue::new("apollo.operation.id", operation_id), + KeyValue::new("graphql.operation.name", operation_name), + KeyValue::new("graphql.operation.type", operation_type), + KeyValue::new("apollo.client.name", client_name), + KeyValue::new("apollo.client.version", client_version), + KeyValue::new("graphql.error.extensions.code", "SOME_IGNORED_ERROR_CODE"), + KeyValue::new("graphql.error.extensions.severity", "ERROR"), + KeyValue::new("graphql.error.path", "/obj/arr/@/firstElementField"), + KeyValue::new("apollo.router.error.service", "myIgnoredSubgraph"), + ] + ); + } + .with_metrics() + .await; +} diff --git a/apollo-router/src/plugins/telemetry/error_handler.rs b/apollo-router/src/plugins/telemetry/error_handler.rs new file mode 100644 index 0000000000..91ac5124a4 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/error_handler.rs @@ -0,0 +1,216 @@ +use std::time::Duration; +use std::time::Instant; + +use dashmap::DashMap; +use once_cell::sync::OnceCell; +use opentelemetry::metrics::MetricsError; + +#[derive(Eq, PartialEq, Hash)] +enum ErrorType { + Trace, + Metric, + Other, +} +static OTEL_ERROR_LAST_LOGGED: OnceCell> = OnceCell::new(); + +pub(crate) fn handle_error>(err: T) { + // We have to rate limit these errors because when they happen they are very frequent. + // Use a dashmap to store the message type with the last time it was logged. + handle_error_with_map(err, OTEL_ERROR_LAST_LOGGED.get_or_init(DashMap::new)); +} + +// Allow for map injection to avoid using global map in tests +fn handle_error_with_map>( + err: T, + last_logged_map: &DashMap, +) { + let err = err.into(); + + // We don't want the dashmap to get big, so we key the error messages by type. + let error_type = match err { + opentelemetry::global::Error::Trace(_) => ErrorType::Trace, + opentelemetry::global::Error::Metric(_) => ErrorType::Metric, + _ => ErrorType::Other, + }; + #[cfg(not(test))] + let threshold = Duration::from_secs(10); + #[cfg(test)] + let threshold = Duration::from_millis(100); + + if let opentelemetry::global::Error::Metric(err) = &err { + // For now we have to suppress Metrics error: reader is shut down or not registered + // https://github.com/open-telemetry/opentelemetry-rust/issues/1244 + + if err.to_string() == "Metrics error: reader is shut down or not registered" { + return; + } + + // Keep track of the number of cardinality overflow errors otel emits. This can be removed after upgrading to 0.28.0 when the cardinality limit is removed. + // The version upgrade will also cause this log to be removed from our visibility even if we were set up custom a cardinality limit. + // https://github.com/open-telemetry/opentelemetry-rust/pull/2528 + if err.to_string() + == "Metrics error: Warning: Maximum data points for metric stream exceeded. Entry added to overflow. Subsequent overflows to same metric until next collect will not be logged." + { + u64_counter!( + "apollo.router.telemetry.metrics.cardinality_overflow", + "A count of how often a telemetry metric hit the hard cardinality limit", + 1 + ); + } + } + + // Copy here so that we don't retain a mutable reference into the dashmap and lock the shard + let now = Instant::now(); + let last_logged = *last_logged_map + .entry(error_type) + .and_modify(|last_logged| { + if last_logged.elapsed() > threshold { + *last_logged = now; + } + }) + .or_insert_with(|| now); + + if last_logged == now { + // These events are logged with explicitly no parent. This allows them to be detached from traces. + match err { + opentelemetry::global::Error::Trace(err) => { + ::tracing::error!("OpenTelemetry trace error occurred: {}", err) + } + opentelemetry::global::Error::Metric(err) => { + if let MetricsError::Other(msg) = &err { + if msg.contains("Warning") { + ::tracing::warn!(parent: None, "OpenTelemetry metric warning occurred: {}", msg); + return; + } + + // TODO: We should be able to remove this after upgrading to 0.26.0, which addresses the double-shutdown + // called out in https://github.com/open-telemetry/opentelemetry-rust/issues/1661 + if msg == "metrics provider already shut down" { + return; + } + } + ::tracing::error!(parent: None, "OpenTelemetry metric error occurred: {}", err); + } + opentelemetry::global::Error::Other(err) => { + ::tracing::error!(parent: None, "OpenTelemetry error occurred: {}", err) + } + other => { + ::tracing::error!(parent: None, "OpenTelemetry error occurred: {:?}", other) + } + } + } +} + +#[cfg(test)] +mod tests { + use std::fmt::Debug; + use std::ops::DerefMut; + use std::sync::Arc; + use std::time::Duration; + + use dashmap::DashMap; + use parking_lot::Mutex; + use tracing_core::Event; + use tracing_core::Field; + use tracing_core::Subscriber; + use tracing_core::field::Visit; + use tracing_futures::WithSubscriber; + use tracing_subscriber::Layer; + use tracing_subscriber::layer::Context; + use tracing_subscriber::layer::SubscriberExt; + + use crate::metrics::FutureMetricsExt; + use crate::plugins::telemetry::error_handler::handle_error_with_map; + + #[tokio::test] + async fn test_handle_error_throttling() { + let error_map = DashMap::new(); + // Set up a fake subscriber so we can check log events. If this is useful then maybe it can be factored out into something reusable + #[derive(Default)] + struct TestVisitor { + log_entries: Vec, + } + + #[derive(Default, Clone)] + struct TestLayer { + visitor: Arc>, + } + impl TestLayer { + fn assert_log_entry_count(&self, message: &str, expected: usize) { + let log_entries = self.visitor.lock().log_entries.clone(); + let actual = log_entries.iter().filter(|e| e.contains(message)).count(); + assert_eq!(actual, expected); + } + } + impl Visit for TestVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn Debug) { + self.log_entries + .push(format!("{}={:?}", field.name(), value)); + } + } + + impl Layer for TestLayer + where + S: Subscriber, + Self: 'static, + { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + event.record(self.visitor.lock().deref_mut()) + } + } + + let test_layer = TestLayer::default(); + + async { + // Log twice rapidly, they should get deduped + handle_error_with_map( + opentelemetry::global::Error::Other("other error".to_string()), + &error_map, + ); + handle_error_with_map( + opentelemetry::global::Error::Other("other error".to_string()), + &error_map, + ); + handle_error_with_map( + opentelemetry::global::Error::Trace("trace error".to_string().into()), + &error_map, + ); + } + .with_subscriber(tracing_subscriber::registry().with(test_layer.clone())) + .await; + + test_layer.assert_log_entry_count("other error", 1); + test_layer.assert_log_entry_count("trace error", 1); + + // Sleep a bit and then log again, it should get logged + tokio::time::sleep(Duration::from_millis(200)).await; + async { + handle_error_with_map( + opentelemetry::global::Error::Other("other error".to_string()), + &error_map, + ); + } + .with_subscriber(tracing_subscriber::registry().with(test_layer.clone())) + .await; + test_layer.assert_log_entry_count("other error", 2); + } + + #[tokio::test] + async fn test_cardinality_overflow() { + async { + let error_map = DashMap::new(); + let msg = "Warning: Maximum data points for metric stream exceeded. Entry added to overflow. Subsequent overflows to same metric until next collect will not be logged."; + handle_error_with_map( + opentelemetry::global::Error::Metric(opentelemetry::metrics::MetricsError::Other(msg.to_string())), + &error_map, + ); + + assert_counter!( + "apollo.router.telemetry.metrics.cardinality_overflow", + 1 + ); + } + .with_metrics() + .await; + } +} diff --git a/apollo-router/src/plugins/telemetry/fmt_layer.rs b/apollo-router/src/plugins/telemetry/fmt_layer.rs index cf5eb49c8f..012e0f55f9 100644 --- a/apollo-router/src/plugins/telemetry/fmt_layer.rs +++ b/apollo-router/src/plugins/telemetry/fmt_layer.rs @@ -7,26 +7,26 @@ use std::marker::PhantomData; use opentelemetry::Key; use opentelemetry::KeyValue; use tracing::field; -use tracing_core::span::Id; -use tracing_core::span::Record; use tracing_core::Event; use tracing_core::Field; +use tracing_core::span::Id; +use tracing_core::span::Record; +use tracing_subscriber::Layer; use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::layer::Context; -use tracing_subscriber::Layer; use super::config_new::ToOtelValue; use super::dynamic_attribute::LogAttributes; -use super::formatters::EventFormatter; use super::formatters::EXCLUDED_ATTRIBUTES; +use super::formatters::EventFormatter; use super::reload::IsSampled; use crate::plugins::telemetry::config; use crate::plugins::telemetry::config_new::logging::Format; use crate::plugins::telemetry::config_new::logging::StdOut; -use crate::plugins::telemetry::formatters::filter_metric_events; +use crate::plugins::telemetry::consts::EVENT_ATTRIBUTE_OMIT_LOG; +use crate::plugins::telemetry::formatters::RateLimitFormatter; use crate::plugins::telemetry::formatters::json::Json; use crate::plugins::telemetry::formatters::text::Text; -use crate::plugins::telemetry::formatters::FilteringFormatter; use crate::plugins::telemetry::reload::LayeredTracer; use crate::plugins::telemetry::resource::ConfigResource; @@ -40,12 +40,9 @@ pub(crate) fn create_fmt_layer( tty_format, rate_limit, } if *enabled => { - let format = if std::io::stdout().is_terminal() && tty_format.is_some() { - tty_format - .as_ref() - .expect("checked previously in the if; qed") - } else { - format + let format = match tty_format { + Some(tty) if std::io::stdout().is_terminal() => tty, + _ => format, }; match format { Format::Json(format_config) => { @@ -53,11 +50,8 @@ pub(crate) fn create_fmt_layer( config.exporters.logging.common.to_resource(), format_config.clone(), ); - FmtLayer::new( - FilteringFormatter::new(format, filter_metric_events, rate_limit), - std::io::stdout, - ) - .boxed() + FmtLayer::new(RateLimitFormatter::new(format, rate_limit), std::io::stdout) + .boxed() } Format::Text(format_config) => { @@ -65,11 +59,8 @@ pub(crate) fn create_fmt_layer( config.exporters.logging.common.to_resource(), format_config.clone(), ); - FmtLayer::new( - FilteringFormatter::new(format, filter_metric_events, rate_limit), - std::io::stdout, - ) - .boxed() + FmtLayer::new(RateLimitFormatter::new(format, rate_limit), std::io::stdout) + .boxed() } } } @@ -116,51 +107,53 @@ where id: &tracing_core::span::Id, ctx: Context<'_, S>, ) { - let span = ctx.span(id).expect("Span not found, this is a bug"); - let mut visitor = FieldsVisitor::new(&self.excluded_attributes); - // We're checking if it's sampled to not add both attributes in OtelData and our LogAttributes - if !span.is_sampled() { - attrs.record(&mut visitor); - } - let mut extensions = span.extensions_mut(); - if extensions.get_mut::().is_none() { - let mut fields = LogAttributes::default(); - fields.extend( - visitor.values.into_iter().filter_map(|(k, v)| { + if let Some(span) = ctx.span(id) { + let mut visitor = FieldsVisitor::new(&self.excluded_attributes); + // We're checking if it's sampled to not add both attributes in OtelData and our LogAttributes + if !span.is_sampled() { + attrs.record(&mut visitor); + } + let mut extensions = span.extensions_mut(); + if let Some(log_attrs) = extensions.get_mut::() { + log_attrs.extend(visitor.values.into_iter().filter_map(|(k, v)| { Some(KeyValue::new(Key::new(k), v.maybe_to_otel_value()?)) - }), - ); - - extensions.insert(fields); - } else if !visitor.values.is_empty() { - let log_attrs = extensions - .get_mut::() - .expect("LogAttributes exists, we checked just before"); - log_attrs.extend( - visitor.values.into_iter().filter_map(|(k, v)| { + })); + } else { + let mut fields = LogAttributes::default(); + fields.extend(visitor.values.into_iter().filter_map(|(k, v)| { Some(KeyValue::new(Key::new(k), v.maybe_to_otel_value()?)) - }), - ); + })); + extensions.insert(fields); + } + } else { + eprintln!("FmtLayer::on_new_span: Span not found, this is a bug"); } } fn on_record(&self, id: &Id, values: &Record<'_>, ctx: Context<'_, S>) { - let span = ctx.span(id).expect("Span not found, this is a bug"); - let mut extensions = span.extensions_mut(); - if let Some(fields) = extensions.get_mut::() { - let mut visitor = FieldsVisitor::new(&self.excluded_attributes); - values.record(&mut visitor); - fields.extend( - visitor.values.into_iter().filter_map(|(k, v)| { + if let Some(span) = ctx.span(id) { + let mut extensions = span.extensions_mut(); + if let Some(fields) = extensions.get_mut::() { + let mut visitor = FieldsVisitor::new(&self.excluded_attributes); + values.record(&mut visitor); + fields.extend(visitor.values.into_iter().filter_map(|(k, v)| { Some(KeyValue::new(Key::new(k), v.maybe_to_otel_value()?)) - }), - ); + })); + } else { + eprintln!("FmtLayer::on_record: cannot access to LogAttributes, this is a bug"); + } } else { - eprintln!("cannot access to LogAttributes, this is a bug"); + eprintln!("FmtLayer::on_record: Span not found, this is a bug"); } } fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { + let mut visitor = FieldsVisitor::new(&self.excluded_attributes); + event.record(&mut visitor); + if visitor.omit_from_logs { + return; + } + thread_local! { static BUF: RefCell = const { RefCell::new(String::new()) }; } @@ -182,7 +175,7 @@ where if self.fmt_event.format_event(&ctx, &mut buf, event).is_ok() { let mut writer = self.make_writer.make_writer(); if let Err(err) = std::io::Write::write_all(&mut writer, buf.as_bytes()) { - eprintln!("cannot flush the logging buffer, this is a bug: {err:?}"); + eprintln!("FmtLayer::on_event: cannot flush the logging buffer, this is a bug: {err:?}"); } } buf.clear(); @@ -194,18 +187,20 @@ where pub(crate) struct FieldsVisitor<'a, 'b> { pub(crate) values: HashMap<&'a str, serde_json::Value>, excluded_attributes: &'b HashSet<&'static str>, + omit_from_logs: bool, } -impl<'a, 'b> FieldsVisitor<'a, 'b> { +impl<'b> FieldsVisitor<'_, 'b> { fn new(excluded_attributes: &'b HashSet<&'static str>) -> Self { Self { values: HashMap::with_capacity(0), excluded_attributes, + omit_from_logs: false, } } } -impl<'a, 'b> field::Visit for FieldsVisitor<'a, 'b> { +impl field::Visit for FieldsVisitor<'_, '_> { /// Visit a double precision floating point value. fn record_f64(&mut self, field: &Field, value: f64) { self.values @@ -228,6 +223,10 @@ impl<'a, 'b> field::Visit for FieldsVisitor<'a, 'b> { fn record_bool(&mut self, field: &Field, value: bool) { self.values .insert(field.name(), serde_json::Value::from(value)); + + if field.name() == EVENT_ATTRIBUTE_OMIT_LOG && value { + self.omit_from_logs = true; + } } /// Visit a string value. @@ -248,11 +247,11 @@ impl<'a, 'b> field::Visit for FieldsVisitor<'a, 'b> { match field_name { name if name.starts_with("r#") => { self.values - .insert(&name[2..], serde_json::Value::from(format!("{:?}", value))); + .insert(&name[2..], serde_json::Value::from(format!("{value:?}"))); } name => { self.values - .insert(name, serde_json::Value::from(format!("{:?}", value))); + .insert(name, serde_json::Value::from(format!("{value:?}"))); } }; } @@ -260,12 +259,30 @@ impl<'a, 'b> field::Visit for FieldsVisitor<'a, 'b> { #[cfg(test)] mod tests { + use std::str::FromStr; use std::sync::Arc; - use std::sync::Mutex; - use std::sync::MutexGuard; - use http::header::CONTENT_LENGTH; + use apollo_compiler::name; + use apollo_federation::connectors::ConnectId; + use apollo_federation::connectors::ConnectSpec; + use apollo_federation::connectors::Connector; + use apollo_federation::connectors::HttpJsonTransport; + use apollo_federation::connectors::JSONSelection; + use apollo_federation::connectors::ProblemLocation; + use apollo_federation::connectors::SourceName; + use apollo_federation::connectors::StringTemplate; + use apollo_federation::connectors::runtime::http_json_transport::HttpRequest; + use apollo_federation::connectors::runtime::http_json_transport::HttpResponse; + use apollo_federation::connectors::runtime::http_json_transport::TransportRequest; + use apollo_federation::connectors::runtime::http_json_transport::TransportResponse; + use apollo_federation::connectors::runtime::key::ResponseKey; + use apollo_federation::connectors::runtime::mapping::Problem; + use apollo_federation::connectors::runtime::responses::MappedResponse; use http::HeaderValue; + use http::header::CONTENT_LENGTH; + use parking_lot::Mutex; + use parking_lot::MutexGuard; + use tests::events::EventLevel; use tracing::error; use tracing::info; use tracing::info_span; @@ -276,14 +293,16 @@ mod tests { use crate::graphql; use crate::plugins::telemetry::config_new::events; use crate::plugins::telemetry::config_new::events::log_event; - use crate::plugins::telemetry::config_new::events::EventLevel; - use crate::plugins::telemetry::config_new::instruments::Instrumented; use crate::plugins::telemetry::config_new::logging::JsonFormat; use crate::plugins::telemetry::config_new::logging::RateLimit; use crate::plugins::telemetry::config_new::logging::TextFormat; + use crate::plugins::telemetry::config_new::router::events::RouterResponseBodyExtensionType; use crate::plugins::telemetry::dynamic_attribute::SpanDynAttribute; use crate::plugins::telemetry::otel; + use crate::services::connector::request_service::Request; + use crate::services::connector::request_service::Response; use crate::services::router; + use crate::services::router::body; use crate::services::subgraph; use crate::services::supergraph; @@ -301,7 +320,7 @@ router: on: request attributes: http.request.body.size: true - # Only log when the x-log-request header is `log` + # Only log when the x-log-request header is `log` condition: eq: - "log" @@ -312,7 +331,7 @@ router: on: response attributes: http.response.body.size: true - # Only log when the x-log-request header is `log` + # Only log when the x-log-request header is `log` condition: eq: - "log" @@ -328,7 +347,7 @@ supergraph: message: "my event message" level: info on: request - # Only log when the x-log-request header is `log` + # Only log when the x-log-request header is `log` condition: eq: - "log" @@ -362,7 +381,46 @@ subgraph: subgraph_response_status: code "my.custom.attribute": subgraph_response_data: "$.*" - default: "missing""#; + default: "missing" + +connector: + # Standard events cannot be tested, because the test does not call the service that emits them + + # Custom events + my.connector.request.event: + message: "my request event message" + level: info + on: request + attributes: + subgraph.name: true + connector_source: + connector_source: name + http_method: + connector_http_method: true + url_template: + connector_url_template: true + mapping_problems: + connector_request_mapping_problems: problems + mapping_problems_count: + connector_request_mapping_problems: count + my.connector.response.event: + message: "my response event message" + level: error + on: response + attributes: + subgraph.name: true + connector_source: + connector_source: name + http_method: + connector_http_method: true + url_template: + connector_url_template: true + response_status: + connector_http_response_status: code + mapping_problems: + connector_response_mapping_problems: problems + mapping_problems_count: + connector_response_mapping_problems: count"#; #[derive(Default, Clone)] struct LogBuffer(Arc>>); @@ -370,12 +428,12 @@ subgraph: type Writer = Guard<'a>; fn make_writer(&'a self) -> Self::Writer { - Guard(self.0.lock().unwrap()) + Guard(self.0.lock()) } } struct Guard<'a>(MutexGuard<'a, Vec>); - impl<'a> std::io::Write for Guard<'a> { + impl std::io::Write for Guard<'_> { fn write(&mut self, buf: &[u8]) -> std::io::Result { self.0.write(buf) } @@ -387,7 +445,7 @@ subgraph: impl std::fmt::Display for LogBuffer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let content = String::from_utf8(self.0.lock().unwrap().clone()).unwrap(); + let content = String::from_utf8(self.0.lock().clone()).map_err(|_e| std::fmt::Error)?; write!(f, "{content}") } @@ -436,11 +494,7 @@ subgraph: async fn test_text_logging_attributes() { let buff = LogBuffer::default(); let format = Text::default(); - let fmt_layer = FmtLayer::new( - FilteringFormatter::new(format, filter_metric_events, &RateLimit::default()), - buff.clone(), - ) - .boxed(); + let fmt_layer = FmtLayer::new(format, buff.clone()).boxed(); ::tracing::subscriber::with_default( fmt::Subscriber::new().with(fmt_layer), @@ -453,11 +507,7 @@ subgraph: async fn test_text_logging_attributes_nested_spans() { let buff = LogBuffer::default(); let format = Text::default(); - let fmt_layer = FmtLayer::new( - FilteringFormatter::new(format, filter_metric_events, &RateLimit::default()), - buff.clone(), - ) - .boxed(); + let fmt_layer = FmtLayer::new(format, buff.clone()).boxed(); ::tracing::subscriber::with_default( fmt::Subscriber::new().with(fmt_layer), @@ -471,11 +521,7 @@ subgraph: async fn test_json_logging_attributes() { let buff = LogBuffer::default(); let format = Json::default(); - let fmt_layer = FmtLayer::new( - FilteringFormatter::new(format, filter_metric_events, &RateLimit::default()), - buff.clone(), - ) - .boxed(); + let fmt_layer = FmtLayer::new(format, buff.clone()).boxed(); ::tracing::subscriber::with_default( fmt::Subscriber::new().with(fmt_layer), @@ -488,11 +534,7 @@ subgraph: async fn test_json_logging_attributes_nested_spans() { let buff = LogBuffer::default(); let format = Json::default(); - let fmt_layer = FmtLayer::new( - FilteringFormatter::new(format, filter_metric_events, &RateLimit::default()), - buff.clone(), - ) - .boxed(); + let fmt_layer = FmtLayer::new(format, buff.clone()).boxed(); ::tracing::subscriber::with_default( fmt::Subscriber::new().with(fmt_layer), @@ -512,11 +554,7 @@ subgraph: ..Default::default() }; let format = Json::new(Default::default(), json_format); - let fmt_layer = FmtLayer::new( - FilteringFormatter::new(format, filter_metric_events, &RateLimit::default()), - buff.clone(), - ) - .boxed(); + let fmt_layer = FmtLayer::new(format, buff.clone()).boxed(); ::tracing::subscriber::with_default( fmt::Subscriber::new().with(fmt_layer), @@ -537,11 +575,7 @@ subgraph: ..Default::default() }; let format = Text::new(Default::default(), text_format); - let fmt_layer = FmtLayer::new( - FilteringFormatter::new(format, filter_metric_events, &RateLimit::default()), - buff.clone(), - ) - .boxed(); + let fmt_layer = FmtLayer::new(format, buff.clone()).boxed(); ::tracing::subscriber::with_default( fmt::Subscriber::new().with(fmt_layer), @@ -559,11 +593,7 @@ subgraph: ..Default::default() }; let format = Text::new(Default::default(), text_format); - let fmt_layer = FmtLayer::new( - FilteringFormatter::new(format, filter_metric_events, &RateLimit::default()), - buff.clone(), - ) - .boxed(); + let fmt_layer = FmtLayer::new(format, buff.clone()).boxed(); ::tracing::subscriber::with_default( fmt::Subscriber::new() @@ -612,11 +642,7 @@ subgraph: ..Default::default() }; let format = Json::new(Default::default(), text_format); - let fmt_layer = FmtLayer::new( - FilteringFormatter::new(format, filter_metric_events, &RateLimit::default()), - buff.clone(), - ) - .boxed(); + let fmt_layer = FmtLayer::new(format, buff.clone()).boxed(); ::tracing::subscriber::with_default( fmt::Subscriber::new() @@ -665,11 +691,7 @@ subgraph: ..Default::default() }; let format = Json::new(Default::default(), text_format); - let fmt_layer = FmtLayer::new( - FilteringFormatter::new(format, filter_metric_events, &RateLimit::default()), - buff.clone(), - ) - .boxed(); + let fmt_layer = FmtLayer::new(format, buff.clone()).boxed(); let event_config: events::Events = serde_yaml::from_str(EVENT_CONFIGURATION).unwrap(); @@ -706,7 +728,7 @@ subgraph: error!(http.method = "GET", "Hello from test"); - let router_events = event_config.new_router_events(); + let mut router_events = event_config.new_router_events(); let router_req = router::Request::fake_builder() .header(CONTENT_LENGTH, "0") .header("custom-header", "val1") @@ -724,7 +746,7 @@ subgraph: .expect("expecting valid response"); router_events.on_response(&router_resp); - let supergraph_events = event_config.new_supergraph_events(); + let mut supergraph_events = event_config.new_supergraph_events(); let supergraph_req = supergraph::Request::fake_builder() .query("query { foo }") .header("x-log-request", HeaderValue::from_static("log")) @@ -740,7 +762,7 @@ subgraph: .expect("expecting valid response"); supergraph_events.on_response(&supergraph_resp); - let subgraph_events = event_config.new_subgraph_events(); + let mut subgraph_events = event_config.new_subgraph_events(); let mut subgraph_req = http::Request::new( graphql::Request::fake_builder() .query("query { foo }") @@ -760,11 +782,12 @@ subgraph: .header("custom-header", "val1") .header("x-log-request", HeaderValue::from_static("log")) .data(serde_json::json!({"products": [{"id": 1234, "name": "first_name"}, {"id": 567, "name": "second_name"}]})) + .subgraph_name("subgraph") .build() .expect("expecting valid response"); subgraph_events.on_response(&subgraph_resp); - let subgraph_events = event_config.new_subgraph_events(); + let mut subgraph_events = event_config.new_subgraph_events(); let mut subgraph_req = http::Request::new( graphql::Request::fake_builder() .query("query { foo }") @@ -784,9 +807,224 @@ subgraph: .header("custom-header", "val1") .header("x-log-request", HeaderValue::from_static("log")) .data(serde_json::json!({"products": [{"id": 1234, "name": "first_name"}, {"id": 567, "name": "second_name"}], "other": {"foo": "bar"}})) + .subgraph_name("subgraph_bis") .build() .expect("expecting valid response"); subgraph_events.on_response(&subgraph_resp); + + let context = crate::Context::default(); + let mut http_request = http::Request::builder().body("".into()).unwrap(); + http_request + .headers_mut() + .insert("x-log-request", HeaderValue::from_static("log")); + let transport_request = TransportRequest::Http(HttpRequest { + inner: http_request, + debug: Default::default(), + }); + let connector = Arc::new(Connector { + id: ConnectId::new( + "connector_subgraph".into(), + Some(SourceName::cast("source")), + name!(Query), + name!(users), + None, + 0, + ), + transport: HttpJsonTransport { + connect_template: StringTemplate::from_str("/test").unwrap(), + ..Default::default() + }, + selection: JSONSelection::empty(), + config: None, + max_requests: None, + entity_resolver: None, + spec: ConnectSpec::V0_1, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "label".into(), + }); + let response_key = ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + let connector_request = Request { + context: context.clone(), + connector: connector.clone(), + transport_request, + key: response_key.clone(), + mapping_problems: vec![ + Problem { + count: 1, + message: "error message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + Problem { + count: 2, + message: "warn message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + Problem { + count: 3, + message: "info message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + ], + supergraph_request: Default::default(), + }; + let mut connector_events = event_config.new_connector_events(); + connector_events.on_request(&connector_request); + + let connector_response = Response { + transport_result: Ok(TransportResponse::Http(HttpResponse { + inner: http::Response::builder() + .status(200) + .header("x-log-response", HeaderValue::from_static("log")) + .body(body::empty()) + .expect("expecting valid response") + .into_parts() + .0, + })), + mapped_response: MappedResponse::Data { + data: serde_json::json!({}) + .try_into() + .expect("expecting valid JSON"), + key: response_key, + problems: vec![ + Problem { + count: 1, + message: "error message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + Problem { + count: 2, + message: "warn message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + Problem { + count: 3, + message: "info message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + ], + }, + }; + connector_events.on_response(&connector_response); + }, + ); + + insta::assert_snapshot!(buff.to_string()); + } + + #[tokio::test] + async fn test_json_logging_deduplicates_attributes() { + let buff = LogBuffer::default(); + let text_format = JsonFormat { + display_span_list: false, + display_current_span: false, + display_resource: false, + ..Default::default() + }; + let format = Json::new(Default::default(), text_format); + let fmt_layer = FmtLayer::new( + RateLimitFormatter::new(format, &RateLimit::default()), + buff.clone(), + ) + .boxed(); + + let event_config: events::Events = serde_yaml::from_str( + r#" +subgraph: + request: info + response: warn + error: error + event.with.duplicate.attribute: + message: "this event has a duplicate attribute" + level: error + on: response + attributes: + subgraph.name: true + static: foo # This shows up twice without attribute deduplication + "#, + ) + .unwrap(); + + ::tracing::subscriber::with_default( + fmt::Subscriber::new() + .with(otel::layer().force_sampling()) + .with(fmt_layer), + move || { + let test_span = info_span!("test"); + let _enter = test_span.enter(); + + let mut router_events = event_config.new_router_events(); + let mut supergraph_events = event_config.new_supergraph_events(); + let mut subgraph_events = event_config.new_subgraph_events(); + + // In: Router -> Supergraph -> Subgraphs + let router_req = router::Request::fake_builder().build().unwrap(); + router_events.on_request(&router_req); + + let supergraph_req = supergraph::Request::fake_builder() + .query("query { foo }") + .build() + .unwrap(); + supergraph_events.on_request(&supergraph_req); + + let subgraph_req_1 = subgraph::Request::fake_builder() + .subgraph_name("subgraph") + .subgraph_request(http::Request::new( + graphql::Request::fake_builder() + .query("query { foo }") + .build(), + )) + .build(); + subgraph_events.on_request(&subgraph_req_1); + + let subgraph_req_2 = subgraph::Request::fake_builder() + .subgraph_name("subgraph_bis") + .subgraph_request(http::Request::new( + graphql::Request::fake_builder() + .query("query { foo }") + .build(), + )) + .build(); + subgraph_events.on_request(&subgraph_req_2); + + // Out: Subgraphs -> Supergraph -> Router + let subgraph_resp_1 = subgraph::Response::fake2_builder() + .data(serde_json::json!({"products": [{"id": 1234, "name": "first_name"}, {"id": 567, "name": "second_name"}]})) + .build() + .expect("expecting valid response"); + subgraph_events.on_response(&subgraph_resp_1); + + let subgraph_resp_2 = subgraph::Response::fake2_builder() + .data(serde_json::json!({"products": [{"id": 1234, "name": "first_name"}, {"id": 567, "name": "second_name"}], "other": {"foo": "bar"}})) + .build() + .expect("expecting valid response"); + subgraph_events.on_response(&subgraph_resp_2); + + let supergraph_resp = supergraph::Response::fake_builder() + .data(serde_json::json!({"data": "res"}).to_string()) + .build() + .expect("expecting valid response"); + supergraph_events.on_response(&supergraph_resp); + + let router_resp = router::Response::fake_builder() + .data(serde_json_bytes::json!({"data": "res"})) + .build() + .expect("expecting valid response"); + router_events.on_response(&router_resp); }, ); @@ -804,11 +1042,7 @@ subgraph: ..Default::default() }; let format = Text::new(Default::default(), text_format); - let fmt_layer = FmtLayer::new( - FilteringFormatter::new(format, filter_metric_events, &RateLimit::default()), - buff.clone(), - ) - .boxed(); + let fmt_layer = FmtLayer::new(format, buff.clone()).boxed(); let event_config: events::Events = serde_yaml::from_str(EVENT_CONFIGURATION).unwrap(); @@ -845,7 +1079,7 @@ subgraph: error!(http.method = "GET", "Hello from test"); - let router_events = event_config.new_router_events(); + let mut router_events = event_config.new_router_events(); let router_req = router::Request::fake_builder() .header(CONTENT_LENGTH, "0") .header("custom-header", "val1") @@ -853,17 +1087,23 @@ subgraph: .build() .unwrap(); router_events.on_request(&router_req); - + let ctx = crate::Context::new(); + ctx.extensions().with_lock(|ext| { + ext.insert(RouterResponseBodyExtensionType( + r#"{"data": {"data": "res"}}"#.to_string(), + )); + }); let router_resp = router::Response::fake_builder() .header("custom-header", "val1") .header(CONTENT_LENGTH, "25") .header("x-log-request", HeaderValue::from_static("log")) .data(serde_json_bytes::json!({"data": "res"})) + .context(ctx) .build() .expect("expecting valid response"); router_events.on_response(&router_resp); - let supergraph_events = event_config.new_supergraph_events(); + let mut supergraph_events = event_config.new_supergraph_events(); let supergraph_req = supergraph::Request::fake_builder() .query("query { foo }") .header("x-log-request", HeaderValue::from_static("log")) @@ -879,7 +1119,7 @@ subgraph: .expect("expecting valid response"); supergraph_events.on_response(&supergraph_resp); - let subgraph_events = event_config.new_subgraph_events(); + let mut subgraph_events = event_config.new_subgraph_events(); let mut subgraph_req = http::Request::new( graphql::Request::fake_builder() .query("query { foo }") @@ -899,11 +1139,12 @@ subgraph: .header("custom-header", "val1") .header("x-log-request", HeaderValue::from_static("log")) .data(serde_json::json!({"products": [{"id": 1234, "name": "first_name"}, {"id": 567, "name": "second_name"}]})) + .subgraph_name("subgraph") .build() .expect("expecting valid response"); subgraph_events.on_response(&subgraph_resp); - let subgraph_events = event_config.new_subgraph_events(); + let mut subgraph_events = event_config.new_subgraph_events(); let mut subgraph_req = http::Request::new( graphql::Request::fake_builder() .query("query { foo }") @@ -923,9 +1164,119 @@ subgraph: .header("custom-header", "val1") .header("x-log-request", HeaderValue::from_static("log")) .data(serde_json::json!({"products": [{"id": 1234, "name": "first_name"}, {"id": 567, "name": "second_name"}], "other": {"foo": "bar"}})) + .subgraph_name("subgraph_bis") .build() .expect("expecting valid response"); subgraph_events.on_response(&subgraph_resp); + + let context = crate::Context::default(); + let mut http_request = http::Request::builder().body("".into()).unwrap(); + http_request + .headers_mut() + .insert("x-log-request", HeaderValue::from_static("log")); + let transport_request = TransportRequest::Http(HttpRequest { + inner: http_request, + debug: Default::default(), + }); + let connector = Arc::new(Connector { + id: ConnectId::new( + "connector_subgraph".into(), + Some(SourceName::cast("source")), + name!(Query), + name!(users), + None, + 0, + ), + transport: HttpJsonTransport { + connect_template: StringTemplate::from_str("/test").unwrap(), + ..Default::default() + }, + selection: JSONSelection::empty(), + config: None, + max_requests: None, + entity_resolver: None, + spec: ConnectSpec::V0_1, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "label".into(), + }); + let response_key = ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + let connector_request = Request { + context: context.clone(), + connector: connector.clone(), + transport_request, + key: response_key.clone(), + mapping_problems: vec![ + Problem { + count: 1, + message: "error message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + Problem { + count: 2, + message: "warn message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + Problem { + count: 3, + message: "info message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + ], + supergraph_request: Default::default(), + }; + let mut connector_events = event_config.new_connector_events(); + connector_events.on_request(&connector_request); + + let connector_response = Response { + transport_result: Ok(TransportResponse::Http(HttpResponse { + inner: http::Response::builder() + .status(200) + .header("x-log-response", HeaderValue::from_static("log")) + .body(body::empty()) + .expect("expecting valid response") + .into_parts() + .0, + })), + mapped_response: MappedResponse::Data { + data: serde_json::json!({}) + .try_into() + .expect("expecting valid JSON"), + key: response_key, + problems: vec![ + Problem { + count: 1, + message: "error message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + Problem { + count: 2, + message: "warn message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + Problem { + count: 3, + message: "info message".to_string(), + path: "@.id".to_string(), + location: ProblemLocation::Selection, + }, + ], + }, + }; + connector_events.on_response(&connector_response); }, ); diff --git a/apollo-router/src/plugins/telemetry/formatters/json.rs b/apollo-router/src/plugins/telemetry/formatters/json.rs index 7bb94bfebd..961d549a9b 100644 --- a/apollo-router/src/plugins/telemetry/formatters/json.rs +++ b/apollo-router/src/plugins/telemetry/formatters/json.rs @@ -3,11 +3,11 @@ use std::collections::HashSet; use std::fmt; use std::io; -use opentelemetry::sdk::Resource; use opentelemetry::Array; use opentelemetry::Key; -use opentelemetry::OrderMap; +use opentelemetry::KeyValue; use opentelemetry::Value; +use opentelemetry_sdk::Resource; use serde::ser::SerializeMap; use serde::ser::Serializer as _; use serde_json::Serializer; @@ -18,10 +18,11 @@ use tracing_subscriber::layer::Context; use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::registry::SpanRef; -use super::get_trace_and_span_id; -use super::EventFormatter; +use super::APOLLO_CONNECTOR_PREFIX; use super::APOLLO_PRIVATE_PREFIX; use super::EXCLUDED_ATTRIBUTES; +use super::EventFormatter; +use super::get_trace_and_span_id; use crate::plugins::telemetry::config::AttributeValue; use crate::plugins::telemetry::config::TraceIdFormat; use crate::plugins::telemetry::config_new::logging::DisplayTraceIdFormat; @@ -60,7 +61,7 @@ impl Default for Json { struct SerializableResources<'a>(&'a Vec<(String, serde_json::Value)>); -impl<'a> serde::ser::Serialize for SerializableResources<'a> { +impl serde::ser::Serialize for SerializableResources<'_> { fn serialize(&self, serializer_o: Ser) -> Result where Ser: serde::ser::Serializer, @@ -79,7 +80,7 @@ struct SerializableContext<'a, 'b, Span>(Option>, &'b HashSet< where Span: Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>; -impl<'a, 'b, Span> serde::ser::Serialize for SerializableContext<'a, 'b, Span> +impl serde::ser::Serialize for SerializableContext<'_, '_, Span> where Span: Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>, { @@ -108,7 +109,7 @@ struct SerializableSpan<'a, 'b, Span>( where Span: for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>; -impl<'a, 'b, Span> serde::ser::Serialize for SerializableSpan<'a, 'b, Span> +impl serde::ser::Serialize for SerializableSpan<'_, '_, Span> where Span: for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>, { @@ -126,11 +127,13 @@ where .get::() .and_then(|otel_data| otel_data.builder.attributes.as_ref()); if let Some(otel_attributes) = otel_attributes { - for (key, value) in otel_attributes.iter().filter(|(key, _)| { - let key_name = key.as_str(); - !key_name.starts_with(APOLLO_PRIVATE_PREFIX) && !self.1.contains(&key_name) + for kv in otel_attributes.iter().filter(|kv| { + let key_name = kv.key.as_str(); + !key_name.starts_with(APOLLO_PRIVATE_PREFIX) + && !key_name.starts_with(APOLLO_CONNECTOR_PREFIX) + && !self.1.contains(&key_name) }) { - serializer.serialize_entry(key.as_str(), &value.as_str())?; + serializer.serialize_entry(kv.key.as_str(), &kv.value.as_str())?; } } } @@ -147,7 +150,9 @@ where }; for kv in custom_attributes.iter().filter(|kv| { let key_name = kv.key.as_str(); - !key_name.starts_with(APOLLO_PRIVATE_PREFIX) && !self.1.contains(&key_name) + !key_name.starts_with(APOLLO_PRIVATE_PREFIX) + && !key_name.starts_with(APOLLO_CONNECTOR_PREFIX) + && !self.1.contains(&key_name) }) { match &kv.value { Value::Bool(value) => { @@ -268,12 +273,11 @@ where None => { let event_attributes = extensions.get_mut::(); event_attributes.map(|event_attributes| { - OrderMap::from_iter( - event_attributes - .take() - .into_iter() - .map(|kv| (kv.key, kv.value)), - ) + event_attributes + .take() + .into_iter() + .map(|KeyValue { key, value }| (key, value)) + .collect() }) } } @@ -302,31 +306,31 @@ where serializer.serialize_entry("target", meta.target())?; } - if self.config.display_filename { - if let Some(filename) = meta.file() { - serializer.serialize_entry("filename", filename)?; - } + if self.config.display_filename + && let Some(filename) = meta.file() + { + serializer.serialize_entry("filename", filename)?; } - if self.config.display_line_number { - if let Some(line_number) = meta.line() { - serializer.serialize_entry("line_number", &line_number)?; - } + if self.config.display_line_number + && let Some(line_number) = meta.line() + { + serializer.serialize_entry("line_number", &line_number)?; } - if self.config.display_current_span { - if let Some(ref span) = current_span { - serializer - .serialize_entry("span", &SerializableSpan(span, &self.excluded_attributes)) - .unwrap_or(()); - } + if self.config.display_current_span + && let Some(ref span) = current_span + { + serializer + .serialize_entry("span", &SerializableSpan(span, &self.excluded_attributes)) + .unwrap_or(()); } // dd.trace_id is special. It must appear as a root attribute on log lines, so we need to extract it from the root span. // We're just going to assume if it's there then we should output it, as the user will have to have configured it to be there. - if let Some(span) = ¤t_span { - if let Some(dd_trace_id) = extract_dd_trace_id(span) { - serializer.serialize_entry("dd.trace_id", &dd_trace_id)?; - } + if let Some(span) = ¤t_span + && let Some(dd_trace_id) = extract_dd_trace_id(span) + { + serializer.serialize_entry("dd.trace_id", &dd_trace_id)?; } if self.config.display_span_list && current_span.is_some() { serializer.serialize_entry( @@ -353,27 +357,23 @@ fn extract_dd_trace_id<'a, 'b, T: LookupSpan<'a>>(span: &SpanRef<'a, T>) -> Opti if let Some(root_span) = root.next() { let ext = root_span.extensions(); // Extract dd_trace_id, this could be in otel data or log attributes - if let Some(otel_data) = root_span.extensions().get::() { - if let Some(attributes) = otel_data.builder.attributes.as_ref() { - if let Some((_k, v)) = attributes - .iter() - .find(|(k, _v)| k.as_str() == "dd.trace_id") - { - dd_trace_id = Some(v.to_string()); - } - } + if let Some(otel_data) = ext.get::() + && let Some(attributes) = otel_data.builder.attributes.as_ref() + && let Some(kv) = attributes + .iter() + .find(|kv| kv.key.as_str() == "dd.trace_id") + { + dd_trace_id = Some(kv.value.to_string()); }; - if dd_trace_id.is_none() { - if let Some(log_attr) = ext.get::() { - if let Some(kv) = log_attr - .attributes() - .iter() - .find(|kv| kv.key.as_str() == "dd.trace_id") - { - dd_trace_id = Some(kv.value.to_string()); - } - } + if dd_trace_id.is_none() + && let Some(log_attr) = ext.get::() + && let Some(kv) = log_attr + .attributes() + .iter() + .find(|kv| kv.key.as_str() == "dd.trace_id") + { + dd_trace_id = Some(kv.value.to_string()); } } dd_trace_id @@ -400,12 +400,13 @@ where attributes.extend( otel_attributes .iter() - .filter(|(key, _)| { - let key_name = key.as_str(); + .filter(|kv| { + let key_name = kv.key.as_str(); !key_name.starts_with(APOLLO_PRIVATE_PREFIX) + && !key_name.starts_with(APOLLO_CONNECTOR_PREFIX) && include_attributes.contains(key_name) }) - .map(|(key, val)| (key.clone(), val.clone())), + .map(|kv| (kv.key.clone(), kv.value.clone())), ); } } @@ -427,6 +428,7 @@ where .filter(|kv| { let key_name = kv.key.as_str(); !key_name.starts_with(APOLLO_PRIVATE_PREFIX) + && !key_name.starts_with(APOLLO_CONNECTOR_PREFIX) && include_attributes.contains(key_name) }) .map(|kv| (kv.key.clone(), kv.value.clone())), @@ -449,16 +451,14 @@ impl<'a> WriteAdaptor<'a> { } } -impl<'a> io::Write for WriteAdaptor<'a> { +impl io::Write for WriteAdaptor<'_> { fn write(&mut self, buf: &[u8]) -> io::Result { let s = std::str::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - self.fmt_write - .write_str(s) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + self.fmt_write.write_str(s).map_err(io::Error::other)?; - Ok(s.as_bytes().len()) + Ok(s.len()) } fn flush(&mut self) -> io::Result<()> { @@ -466,7 +466,7 @@ impl<'a> io::Write for WriteAdaptor<'a> { } } -impl<'a> fmt::Debug for WriteAdaptor<'a> { +impl fmt::Debug for WriteAdaptor<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.pad("WriteAdaptor { .. }") } @@ -477,11 +477,11 @@ mod test { use tracing::subscriber; use tracing_core::Event; use tracing_core::Subscriber; + use tracing_subscriber::Layer; + use tracing_subscriber::Registry; use tracing_subscriber::layer::Context; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::LookupSpan; - use tracing_subscriber::Layer; - use tracing_subscriber::Registry; use crate::plugins::telemetry::dynamic_attribute::DynAttributeLayer; use crate::plugins::telemetry::dynamic_attribute::SpanDynAttribute; diff --git a/apollo-router/src/plugins/telemetry/formatters/mod.rs b/apollo-router/src/plugins/telemetry/formatters/mod.rs index f99bc6a91c..48d0eb0610 100644 --- a/apollo-router/src/plugins/telemetry/formatters/mod.rs +++ b/apollo-router/src/plugins/telemetry/formatters/mod.rs @@ -6,18 +6,18 @@ use std::collections::HashMap; use std::fmt; use std::time::Instant; -use opentelemetry::sdk::Resource; -use opentelemetry_api::trace::SpanId; -use opentelemetry_api::trace::TraceContextExt; -use opentelemetry_api::trace::TraceId; -use opentelemetry_api::KeyValue; +use opentelemetry::KeyValue; +use opentelemetry::trace::SpanId; +use opentelemetry::trace::TraceContextExt; +use opentelemetry::trace::TraceId; +use opentelemetry_sdk::Resource; use parking_lot::Mutex; use serde_json::Number; use tracing::Subscriber; use tracing_core::callsite::Identifier; -use tracing_subscriber::fmt::format::Writer; use tracing_subscriber::fmt::FormatEvent; use tracing_subscriber::fmt::FormatFields; +use tracing_subscriber::fmt::format::Writer; use tracing_subscriber::layer::Context; use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::registry::SpanRef; @@ -25,13 +25,12 @@ use tracing_subscriber::registry::SpanRef; use super::config_new::logging::RateLimit; use super::dynamic_attribute::LogAttributes; use super::reload::SampledSpan; -use crate::metrics::layer::METRIC_PREFIX_COUNTER; -use crate::metrics::layer::METRIC_PREFIX_HISTOGRAM; -use crate::metrics::layer::METRIC_PREFIX_MONOTONIC_COUNTER; -use crate::metrics::layer::METRIC_PREFIX_VALUE; use crate::plugins::telemetry::otel::OtelData; pub(crate) const APOLLO_PRIVATE_PREFIX: &str = "apollo_private."; +// FIXME: this is a temporary solution to avoid exposing hardcoded attributes in connector spans instead of using the custom telemetry features. +// The reason this is introduced right now is to directly avoid people relying on these attributes and then creating a breaking change in the future. +pub(crate) const APOLLO_CONNECTOR_PREFIX: &str = "apollo.connector."; // This list comes from Otel https://opentelemetry.io/docs/specs/semconv/attributes-registry/code/ and pub(crate) const EXCLUDED_ATTRIBUTES: [&str; 5] = [ "code.filepath", @@ -41,43 +40,39 @@ pub(crate) const EXCLUDED_ATTRIBUTES: [&str; 5] = [ "thread.name", ]; -/// `FilteringFormatter` is useful if you want to not filter the entire event but only want to not display it +/// Wrap a [tracing] event formatter with rate limiting. +/// /// ```ignore /// use tracing_core::Event; -/// use tracing_subscriber::fmt::format::{Format}; +/// use tracing_subscriber::fmt::format::Format; +/// use crate::plugins::telemetry::config_new::logging::RateLimit; +/// /// tracing_subscriber::fmt::fmt() -/// .event_format(FilteringFormatter::new( -/// Format::default().pretty(), -/// // Do not display the event if an attribute name starts with "counter" -/// |event: &Event| !event.metadata().fields().iter().any(|f| f.name().starts_with("counter")), -/// )) -/// .finish(); +/// .event_format(RateLimitFormatter::new( +/// Format::default().pretty(), +/// &RateLimit::default(), +/// )) +/// .finish(); /// ``` -pub(crate) struct FilteringFormatter { +pub(crate) struct RateLimitFormatter { inner: T, - filter_fn: F, rate_limiter: Mutex>, config: RateLimit, } -impl FilteringFormatter -where - F: Fn(&tracing::Event<'_>) -> bool, -{ - pub(crate) fn new(inner: T, filter_fn: F, rate_limit: &RateLimit) -> Self { +impl RateLimitFormatter { + pub(crate) fn new(inner: T, rate_limit: &RateLimit) -> Self { Self { inner, - filter_fn, rate_limiter: Mutex::new(HashMap::new()), config: rate_limit.clone(), } } } -impl FormatEvent for FilteringFormatter +impl FormatEvent for RateLimitFormatter where T: FormatEvent, - F: Fn(&tracing::Event<'_>) -> bool, S: Subscriber + for<'a> LookupSpan<'a>, N: for<'a> FormatFields<'a> + 'static, { @@ -87,44 +82,37 @@ where writer: Writer<'_>, event: &tracing::Event<'_>, ) -> fmt::Result { - if (self.filter_fn)(event) { - match self.rate_limit(event) { - RateResult::Deny => return Ok(()), + match self.rate_limit(event) { + RateResult::Deny => return Ok(()), - RateResult::Allow => {} - RateResult::AllowSkipped(skipped) => { - if let Some(span) = event - .parent() - .and_then(|id| ctx.span(id)) - .or_else(|| ctx.lookup_current()) - { - let mut extensions = span.extensions_mut(); - match extensions.get_mut::() { - None => { - let mut attributes = LogAttributes::default(); - attributes - .insert(KeyValue::new("skipped_messages", skipped as i64)); - extensions.insert(attributes); - } - Some(attributes) => { - attributes - .insert(KeyValue::new("skipped_messages", skipped as i64)); - } + RateResult::Allow => {} + RateResult::AllowSkipped(skipped) => { + if let Some(span) = event + .parent() + .and_then(|id| ctx.span(id)) + .or_else(|| ctx.lookup_current()) + { + let mut extensions = span.extensions_mut(); + match extensions.get_mut::() { + None => { + let mut attributes = LogAttributes::default(); + attributes.insert(KeyValue::new("skipped_messages", skipped as i64)); + extensions.insert(attributes); + } + Some(attributes) => { + attributes.insert(KeyValue::new("skipped_messages", skipped as i64)); } } } } - self.inner.format_event(ctx, writer, event) - } else { - Ok(()) } + self.inner.format_event(ctx, writer, event) } } -impl EventFormatter for FilteringFormatter +impl EventFormatter for RateLimitFormatter where T: EventFormatter, - F: Fn(&tracing::Event<'_>) -> bool, S: Subscriber + for<'a> LookupSpan<'a>, { fn format_event( @@ -136,37 +124,31 @@ where where W: std::fmt::Write, { - if (self.filter_fn)(event) { - match self.rate_limit(event) { - RateResult::Deny => return Ok(()), + match self.rate_limit(event) { + RateResult::Deny => return Ok(()), - RateResult::Allow => {} - RateResult::AllowSkipped(skipped) => { - if let Some(span) = event - .parent() - .and_then(|id| ctx.span(id)) - .or_else(|| ctx.lookup_current()) - { - let mut extensions = span.extensions_mut(); - match extensions.get_mut::() { - None => { - let mut attributes = LogAttributes::default(); - attributes - .insert(KeyValue::new("skipped_messages", skipped as i64)); - extensions.insert(attributes); - } - Some(attributes) => { - attributes - .insert(KeyValue::new("skipped_messages", skipped as i64)); - } + RateResult::Allow => {} + RateResult::AllowSkipped(skipped) => { + if let Some(span) = event + .parent() + .and_then(|id| ctx.span(id)) + .or_else(|| ctx.lookup_current()) + { + let mut extensions = span.extensions_mut(); + match extensions.get_mut::() { + None => { + let mut attributes = LogAttributes::default(); + attributes.insert(KeyValue::new("skipped_messages", skipped as i64)); + extensions.insert(attributes); + } + Some(attributes) => { + attributes.insert(KeyValue::new("skipped_messages", skipped as i64)); } } } } - self.inner.format_event(ctx, writer, event) - } else { - Ok(()) } + self.inner.format_event(ctx, writer, event) } } @@ -175,7 +157,7 @@ enum RateResult { AllowSkipped(u32), Deny, } -impl FilteringFormatter { +impl RateLimitFormatter { fn rate_limit(&self, event: &tracing::Event<'_>) -> RateResult { if self.config.enabled { let now = Instant::now(); @@ -225,54 +207,46 @@ struct RateCounter { count: u32, } -// Function to filter metric event for the filter formatter -pub(crate) fn filter_metric_events(event: &tracing::Event<'_>) -> bool { - !event.metadata().fields().iter().any(|f| { - f.name().starts_with(METRIC_PREFIX_COUNTER) - || f.name().starts_with(METRIC_PREFIX_HISTOGRAM) - || f.name().starts_with(METRIC_PREFIX_MONOTONIC_COUNTER) - || f.name().starts_with(METRIC_PREFIX_VALUE) - }) -} - pub(crate) fn to_list(resource: Resource) -> Vec<(String, serde_json::Value)> { resource .into_iter() .map(|(k, v)| { ( - k.into(), + k.to_string(), match v { - opentelemetry::Value::Bool(value) => serde_json::Value::Bool(value), + opentelemetry::Value::Bool(value) => serde_json::Value::Bool(*value), opentelemetry::Value::I64(value) => { - serde_json::Value::Number(Number::from(value)) + serde_json::Value::Number(Number::from(*value)) } opentelemetry::Value::F64(value) => serde_json::Value::Number( - Number::from_f64(value).unwrap_or(Number::from(0)), + Number::from_f64(*value).unwrap_or(Number::from(0)), ), - opentelemetry::Value::String(value) => serde_json::Value::String(value.into()), + opentelemetry::Value::String(value) => { + serde_json::Value::String(value.to_string()) + } opentelemetry::Value::Array(value) => match value { opentelemetry::Array::Bool(array) => serde_json::Value::Array( - array.into_iter().map(serde_json::Value::Bool).collect(), + array.iter().copied().map(serde_json::Value::Bool).collect(), ), opentelemetry::Array::I64(array) => serde_json::Value::Array( array - .into_iter() - .map(|value| serde_json::Value::Number(Number::from(value))) + .iter() + .map(|value| serde_json::Value::Number(Number::from(*value))) .collect(), ), opentelemetry::Array::F64(array) => serde_json::Value::Array( array - .into_iter() + .iter() .map(|value| { serde_json::Value::Number( - Number::from_f64(value).unwrap_or(Number::from(0)), + Number::from_f64(*value).unwrap_or(Number::from(0)), ) }) .collect(), ), opentelemetry::Array::String(array) => serde_json::Value::Array( array - .into_iter() + .iter() .map(|s| serde_json::Value::String(s.to_string())) .collect(), ), @@ -318,7 +292,7 @@ where if let Some(sampled_span) = ext.get::() { let (trace_id, span_id) = sampled_span.trace_and_span_id(); return Some(( - opentelemetry_api::trace::TraceId::from(trace_id.to_u128()), + opentelemetry::trace::TraceId::from(trace_id.to_u128()), span_id, )); } diff --git a/apollo-router/src/plugins/telemetry/formatters/text.rs b/apollo-router/src/plugins/telemetry/formatters/text.rs index d809496964..2766b78866 100644 --- a/apollo-router/src/plugins/telemetry/formatters/text.rs +++ b/apollo-router/src/plugins/telemetry/formatters/text.rs @@ -1,12 +1,10 @@ -#[cfg(test)] -use std::collections::BTreeMap; use std::collections::HashSet; use std::fmt; use nu_ansi_term::Color; use nu_ansi_term::Style; -use opentelemetry::sdk::Resource; -use opentelemetry::OrderMap; +use opentelemetry::KeyValue; +use opentelemetry_sdk::Resource; use serde_json::Value; use tracing_core::Event; use tracing_core::Field; @@ -23,10 +21,10 @@ use tracing_subscriber::layer::Context; use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::registry::SpanRef; -use super::get_trace_and_span_id; -use super::EventFormatter; use super::APOLLO_PRIVATE_PREFIX; use super::EXCLUDED_ATTRIBUTES; +use super::EventFormatter; +use super::get_trace_and_span_id; use crate::plugins::telemetry::config::TraceIdFormat; use crate::plugins::telemetry::config_new::logging::DisplayTraceIdFormat; use crate::plugins::telemetry::config_new::logging::TextFormat; @@ -234,8 +232,8 @@ impl Text { { let mut attrs = otel_attributes .iter() - .filter(|(key, _value)| { - let key_name = key.as_str(); + .filter(|kv| { + let key_name = kv.key.as_str(); !key_name.starts_with(APOLLO_PRIVATE_PREFIX) && !self.excluded_attributes.contains(&key_name) }) @@ -245,9 +243,11 @@ impl Text { write!(writer, "{}{{", span.name())?; } #[cfg(test)] - let attrs: BTreeMap<&opentelemetry::Key, &opentelemetry::Value> = attrs.collect(); - for (key, value) in attrs { - write!(writer, "{key}={value},")?; + let mut attrs: Vec<_> = attrs.collect(); + #[cfg(test)] + attrs.sort_by_key(|kv| kv.key.clone()); + for kv in attrs { + write!(writer, "{}={},", kv.key, kv.value)?; } } @@ -324,31 +324,31 @@ where .and_then(|id| ctx.span(id)) .or_else(|| ctx.lookup_current()); - if let Some(ref span) = current_span { - if let Some((trace_id, span_id)) = get_trace_and_span_id(span) { - let trace_id = match self.config.display_trace_id { - DisplayTraceIdFormat::Bool(true) - | DisplayTraceIdFormat::TraceIdFormat(TraceIdFormat::Hexadecimal) - | DisplayTraceIdFormat::TraceIdFormat(TraceIdFormat::OpenTelemetry) => { - Some(TraceIdFormat::Hexadecimal.format(trace_id)) - } - DisplayTraceIdFormat::TraceIdFormat(TraceIdFormat::Decimal) => { - Some(TraceIdFormat::Decimal.format(trace_id)) - } - DisplayTraceIdFormat::TraceIdFormat(TraceIdFormat::Datadog) => { - Some(TraceIdFormat::Datadog.format(trace_id)) - } - DisplayTraceIdFormat::TraceIdFormat(TraceIdFormat::Uuid) => { - Some(TraceIdFormat::Uuid.format(trace_id)) - } - DisplayTraceIdFormat::Bool(false) => None, - }; - if let Some(trace_id) = trace_id { - write!(writer, "trace_id: {} ", trace_id)?; + if let Some(ref span) = current_span + && let Some((trace_id, span_id)) = get_trace_and_span_id(span) + { + let trace_id = match self.config.display_trace_id { + DisplayTraceIdFormat::Bool(true) + | DisplayTraceIdFormat::TraceIdFormat(TraceIdFormat::Hexadecimal) + | DisplayTraceIdFormat::TraceIdFormat(TraceIdFormat::OpenTelemetry) => { + Some(TraceIdFormat::Hexadecimal.format(trace_id)) + } + DisplayTraceIdFormat::TraceIdFormat(TraceIdFormat::Decimal) => { + Some(TraceIdFormat::Decimal.format(trace_id)) } - if self.config.display_span_id { - write!(writer, "span_id: {} ", span_id)?; + DisplayTraceIdFormat::TraceIdFormat(TraceIdFormat::Datadog) => { + Some(TraceIdFormat::Datadog.format(trace_id)) } + DisplayTraceIdFormat::TraceIdFormat(TraceIdFormat::Uuid) => { + Some(TraceIdFormat::Uuid.format(trace_id)) + } + DisplayTraceIdFormat::Bool(false) => None, + }; + if let Some(trace_id) = trace_id { + write!(writer, "trace_id: {trace_id} ")?; + } + if self.config.display_span_id { + write!(writer, "span_id: {span_id} ")?; } } @@ -391,12 +391,11 @@ where None => { let event_attributes = extensions.get_mut::(); event_attributes.map(|event_attributes| { - OrderMap::from_iter( - event_attributes - .take() - .into_iter() - .map(|kv| (kv.key, kv.value)), - ) + event_attributes + .take() + .into_iter() + .map(|KeyValue { key, value }| (key, value)) + .collect() }) } }; @@ -422,7 +421,7 @@ impl<'a> FmtThreadName<'a> { } } -impl<'a> fmt::Display for FmtThreadName<'a> { +impl fmt::Display for FmtThreadName<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering::AcqRel; @@ -553,7 +552,7 @@ impl<'a> DefaultVisitor<'a> { self.maybe_pad(); self.result = match field_name { - "message" => write!(self.writer, "{:?}", value), + "message" => write!(self.writer, "{value:?}"), name if name.starts_with("r#") => write!( self.writer, "{}{}{:?}", @@ -572,14 +571,14 @@ impl<'a> DefaultVisitor<'a> { } } -impl<'a> field::Visit for DefaultVisitor<'a> { +impl field::Visit for DefaultVisitor<'_> { fn record_str(&mut self, field: &Field, value: &str) { if self.result.is_err() { return; } if field.name() == "message" { - self.record_debug(field, &format_args!("{}", value)) + self.record_debug(field, &format_args!("{value}")) } else { self.record_debug(field, &value) } @@ -600,7 +599,7 @@ impl<'a> field::Visit for DefaultVisitor<'a> { ), ) } else { - self.record_debug(field, &format_args!("{}", value)) + self.record_debug(field, &format_args!("{value}")) } } @@ -609,13 +608,13 @@ impl<'a> field::Visit for DefaultVisitor<'a> { } } -impl<'a> VisitOutput for DefaultVisitor<'a> { +impl VisitOutput for DefaultVisitor<'_> { fn finish(self) -> fmt::Result { self.result } } -impl<'a> VisitFmt for DefaultVisitor<'a> { +impl VisitFmt for DefaultVisitor<'_> { fn writer(&mut self) -> &mut dyn fmt::Write { &mut self.writer } @@ -624,12 +623,12 @@ impl<'a> VisitFmt for DefaultVisitor<'a> { /// Renders an error into a list of sources, *including* the error struct ErrorSourceList<'a>(&'a (dyn std::error::Error + 'static)); -impl<'a> std::fmt::Display for ErrorSourceList<'a> { +impl std::fmt::Display for ErrorSourceList<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut list = f.debug_list(); let mut curr = Some(self.0); while let Some(curr_err) = curr { - list.entry(&format_args!("{}", curr_err)); + list.entry(&format_args!("{curr_err}")); curr = curr_err.source(); } list.finish() diff --git a/apollo-router/src/plugins/telemetry/logging/mod.rs b/apollo-router/src/plugins/telemetry/logging/mod.rs index 83cf680c9b..047459e087 100644 --- a/apollo-router/src/plugins/telemetry/logging/mod.rs +++ b/apollo-router/src/plugins/telemetry/logging/mod.rs @@ -13,23 +13,26 @@ mod test { #[tokio::test(flavor = "multi_thread")] async fn test_router_service() { - let test_harness: PluginTestHarness = PluginTestHarness::builder().build().await; + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); async { let mut response = test_harness - .call_router( + .router_service(|_r| async { + tracing::info!("response"); + Ok(router::Response::fake_builder() + .header("custom-header", "val1") + .data(serde_json::json!({"data": "res"})) + .build() + .expect("expecting valid response")) + }) + .call( router::Request::fake_builder() - .body("query { foo }") + .body(router::body::from_bytes("query { foo }")) .build() .expect("expecting valid request"), - |_r| async { - tracing::info!("response"); - Ok(router::Response::fake_builder() - .header("custom-header", "val1") - .data(serde_json::json!({"data": "res"})) - .build() - .expect("expecting valid response")) - }, ) .await .expect("expecting successful response"); @@ -42,24 +45,26 @@ mod test { #[tokio::test(flavor = "multi_thread")] async fn test_supergraph_service() { - let test_harness: PluginTestHarness = PluginTestHarness::builder().build().await; + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); async { let mut response = test_harness - .call_supergraph( + .supergraph_service(|_r| async { + tracing::info!("response"); + supergraph::Response::fake_builder() + .header("custom-header", "val1") + .data(serde_json::json!({"data": "res"})) + .build() + }) + .call( supergraph::Request::fake_builder() .query("query { foo }") .variable("a", "b") .build() .expect("expecting valid request"), - |_r| { - tracing::info!("response"); - supergraph::Response::fake_builder() - .header("custom-header", "val1") - .data(serde_json::json!({"data": "res"})) - .build() - .expect("expecting valid response") - }, ) .await .expect("expecting successful response"); @@ -72,11 +77,22 @@ mod test { #[tokio::test(flavor = "multi_thread")] async fn test_subgraph_service() { - let test_harness: PluginTestHarness = PluginTestHarness::builder().build().await; + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); async { test_harness - .call_subgraph( + .subgraph_service("subgraph", |_r| async { + tracing::info!("response"); + subgraph::Response::fake2_builder() + .header("custom-header", "val1") + .data(serde_json::json!({"data": "res"}).to_string()) + .subgraph_name("subgraph") + .build() + }) + .call( subgraph::Request::fake_builder() .subgraph_name("subgraph") .subgraph_request(http::Request::new( @@ -85,54 +101,9 @@ mod test { .build(), )) .build(), - |_r| { - tracing::info!("response"); - subgraph::Response::fake2_builder() - .header("custom-header", "val1") - .data(serde_json::json!({"data": "res"}).to_string()) - .build() - .expect("expecting valid response") - }, - ) - .await - .expect("expecting successful response"); - } - .with_subscriber(assert_snapshot_subscriber!()) - .await - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_when_header() { - let test_harness: PluginTestHarness = PluginTestHarness::builder() - .config(include_str!( - "testdata/experimental_when_header.router.yaml" - )) - .build() - .await; - - async { - let mut response = test_harness - .call_supergraph( - supergraph::Request::fake_builder() - .header("custom-header1", "val1") - .header("custom-header2", "val2") - .query("query { foo }") - .build() - .expect("expecting valid request"), - |_r| { - tracing::info!("response"); - supergraph::Response::fake_builder() - .header("custom-header1", "val1") - .header("custom-header2", "val2") - .data(serde_json::json!({"data": "res"})) - .build() - .expect("expecting valid response") - }, ) .await .expect("expecting successful response"); - - response.next_response().await; } .with_subscriber(assert_snapshot_subscriber!()) .await diff --git a/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__router_service@logs.snap b/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__router_service@logs.snap index c5fb1f0cf1..3b65943e63 100644 --- a/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__router_service@logs.snap +++ b/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__router_service@logs.snap @@ -5,20 +5,3 @@ expression: yaml - fields: {} level: INFO message: response - span: - http.flavor: HTTP/1.1 - http.method: GET - http.request.method: GET - http.route: "http://example.com/" - name: router - otel.kind: INTERNAL - trace_id: "" - spans: - - http.flavor: HTTP/1.1 - http.method: GET - http.request.method: GET - http.route: "http://example.com/" - name: router - otel.kind: INTERNAL - trace_id: "" - diff --git a/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__subgraph_service@logs.snap b/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__subgraph_service@logs.snap index b554825f63..9e7097d9a9 100644 --- a/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__subgraph_service@logs.snap +++ b/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__subgraph_service@logs.snap @@ -6,15 +6,8 @@ expression: yaml level: INFO message: response span: - apollo.subgraph.name: subgraph - graphql.document: "query { foo }" - graphql.operation.name: "" name: subgraph otel.kind: INTERNAL spans: - - apollo.subgraph.name: subgraph - graphql.document: "query { foo }" - graphql.operation.name: "" - name: subgraph + - name: subgraph otel.kind: INTERNAL - diff --git a/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__supergraph_service@logs.snap b/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__supergraph_service@logs.snap index 0b616aeb85..8fe3a0053c 100644 --- a/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__supergraph_service@logs.snap +++ b/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__supergraph_service@logs.snap @@ -8,13 +8,10 @@ expression: yaml span: apollo_private.field_level_instrumentation_ratio: 0.01 apollo_private.graphql.variables: "{\"a\":\"\"}" - graphql.document: "query { foo }" name: supergraph otel.kind: INTERNAL spans: - apollo_private.field_level_instrumentation_ratio: 0.01 apollo_private.graphql.variables: "{\"a\":\"\"}" - graphql.document: "query { foo }" name: supergraph otel.kind: INTERNAL - diff --git a/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__when_header@logs.snap b/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__when_header@logs.snap deleted file mode 100644 index 22bcfd4c1f..0000000000 --- a/apollo-router/src/plugins/telemetry/logging/snapshots/apollo_router__plugins__telemetry__logging__test__when_header@logs.snap +++ /dev/null @@ -1,71 +0,0 @@ ---- -source: apollo-router/src/plugins/telemetry/logging/mod.rs -expression: yaml ---- -- fields: - http.request.headers: "{\"content-type\": \"application/json\", \"custom-header1\": \"val1\", \"custom-header2\": \"val2\"}" - level: INFO - message: Supergraph request headers - span: - apollo_private.field_level_instrumentation_ratio: 0.01 - apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" - name: supergraph - otel.kind: INTERNAL - spans: - - apollo_private.field_level_instrumentation_ratio: 0.01 - apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" - name: supergraph - otel.kind: INTERNAL -- fields: - http.request.body: "Request { query: Some(\"query { foo }\"), operation_name: None, variables: {}, extensions: {} }" - level: INFO - message: Supergraph request body - span: - apollo_private.field_level_instrumentation_ratio: 0.01 - apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" - name: supergraph - otel.kind: INTERNAL - spans: - - apollo_private.field_level_instrumentation_ratio: 0.01 - apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" - name: supergraph - otel.kind: INTERNAL -- fields: {} - level: INFO - message: response - span: - apollo_private.field_level_instrumentation_ratio: 0.01 - apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" - name: supergraph - otel.kind: INTERNAL - spans: - - apollo_private.field_level_instrumentation_ratio: 0.01 - apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" - name: supergraph - otel.kind: INTERNAL -- fields: - http.response.headers: "{\"custom-header1\": \"val1\", \"custom-header2\": \"val2\"}" - level: INFO - message: Supergraph response headers - span: - apollo_private.field_level_instrumentation_ratio: 0.01 - apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" - name: supergraph - otel.kind: INTERNAL - spans: - - apollo_private.field_level_instrumentation_ratio: 0.01 - apollo_private.graphql.variables: "{}" - graphql.document: "query { foo }" - name: supergraph - otel.kind: INTERNAL -- fields: - http.response.body: "Response { label: None, data: Some(Object({\"data\": String(\"res\")})), path: None, errors: [], extensions: {}, has_next: None, subscribed: None, created_at: None, incremental: [] }" - level: INFO - message: Supergraph GraphQL response diff --git a/apollo-router/src/plugins/telemetry/logging/testdata/experimental_when_header.router.yaml b/apollo-router/src/plugins/telemetry/logging/testdata/experimental_when_header.router.yaml deleted file mode 100644 index 55c0431beb..0000000000 --- a/apollo-router/src/plugins/telemetry/logging/testdata/experimental_when_header.router.yaml +++ /dev/null @@ -1,9 +0,0 @@ -telemetry: - exporters: - logging: - experimental_when_header: - - name: "custom-header1" - match: "^val.*" - headers: true - body: true - diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/histogram/cost.rs b/apollo-router/src/plugins/telemetry/metrics/apollo/histogram/cost.rs index da27b757d6..d78f9110d6 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/histogram/cost.rs +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/histogram/cost.rs @@ -1,6 +1,6 @@ use num_traits::AsPrimitive; -use serde::ser::SerializeMap; use serde::Serialize; +use serde::ser::SerializeMap; use crate::plugins::telemetry::metrics::apollo::histogram::Histogram; use crate::plugins::telemetry::metrics::apollo::histogram::HistogramConfig; diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/histogram/duration.rs b/apollo-router/src/plugins/telemetry/metrics/apollo/histogram/duration.rs index f8774f04e9..3f9dc9aeac 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/histogram/duration.rs +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/histogram/duration.rs @@ -4,8 +4,8 @@ use std::time::Duration; use num_traits::AsPrimitive; use num_traits::FromPrimitive; -use serde::ser::SerializeMap; use serde::Serialize; +use serde::ser::SerializeMap; use crate::plugins::telemetry::metrics::apollo::histogram::Histogram; use crate::plugins::telemetry::metrics::apollo::histogram::HistogramConfig; @@ -36,8 +36,8 @@ where fn bucket(value: Self::Value) -> usize { const EXPONENT_LOG: f64 = 0.09531017980432493f64; // ln(1.1) Update when ln() is a const fn (see: https://github.com/rust-lang/rust/issues/57241) - // If you use as_micros() here to avoid the divide, tests will fail - // Because, internally, as_micros() is losing remainders + // If you use as_micros() here to avoid the divide, tests will fail + // Because, internally, as_micros() is losing remainders let float_value = value.as_nanos() as f64 / 1000.0; let log_duration = f64::ln(float_value); let unbounded_bucket = f64::ceil(log_duration / EXPONENT_LOG); diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/histogram/list_length.rs b/apollo-router/src/plugins/telemetry/metrics/apollo/histogram/list_length.rs index d4087318ac..d5508ffc2d 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/histogram/list_length.rs +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/histogram/list_length.rs @@ -1,5 +1,5 @@ -use serde::ser::SerializeMap; use serde::Serialize; +use serde::ser::SerializeMap; use crate::plugins::telemetry::metrics::apollo::histogram::Histogram; use crate::plugins::telemetry::metrics::apollo::histogram::HistogramConfig; @@ -58,16 +58,16 @@ mod test { assert_eq!(v.len(), MAXIMUM_SIZE); for (i, item) in v.iter().enumerate().take(100) { - assert_eq!(*item, 1, "testing contents of bucket {}", i); + assert_eq!(*item, 1, "testing contents of bucket {i}"); } for (i, item) in v.iter().enumerate().take(190).skip(100) { - assert_eq!(*item, 10, "testing contents of bucket {}", i); + assert_eq!(*item, 10, "testing contents of bucket {i}"); } for (i, item) in v.iter().enumerate().take(280).skip(190) { - assert_eq!(*item, 100, "testing contents of bucket {}", i); + assert_eq!(*item, 100, "testing contents of bucket {i}"); } for (i, item) in v.iter().enumerate().take(382).skip(280) { - assert_eq!(*item, 1000, "testing contents of bucket {}", i); + assert_eq!(*item, 1000, "testing contents of bucket {i}"); } assert_eq!(v[MAXIMUM_SIZE - 1], 7000, "testing contents of last bucket"); } diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs b/apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs index 47a13762d0..c7fb255126 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs @@ -3,27 +3,32 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; -use opentelemetry::runtime; -use opentelemetry::sdk::metrics::PeriodicReader; -use opentelemetry::sdk::Resource; -use opentelemetry_api::KeyValue; +use opentelemetry::KeyValue; use opentelemetry_otlp::MetricsExporterBuilder; use opentelemetry_otlp::WithExportConfig; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::metrics::PeriodicReader; +use opentelemetry_sdk::runtime; +use prometheus::exponential_buckets; use sys_info::hostname; use tonic::metadata::MetadataMap; +use tonic::transport::ClientTlsConfig; use tower::BoxError; use url::Url; -use crate::plugins::telemetry::apollo::router_id; use crate::plugins::telemetry::apollo::Config; -use crate::plugins::telemetry::apollo_exporter::get_uname; +use crate::plugins::telemetry::apollo::router_id; use crate::plugins::telemetry::apollo_exporter::ApolloExporter; +use crate::plugins::telemetry::apollo_exporter::get_uname; use crate::plugins::telemetry::config::ApolloMetricsReferenceMode; use crate::plugins::telemetry::config::MetricsCommon; use crate::plugins::telemetry::metrics::CustomAggregationSelector; use crate::plugins::telemetry::metrics::MetricsBuilder; use crate::plugins::telemetry::metrics::MetricsConfigurator; use crate::plugins::telemetry::otlp::CustomTemporalitySelector; +use crate::plugins::telemetry::otlp::Protocol; +use crate::plugins::telemetry::otlp::TelemetryDataKind; +use crate::plugins::telemetry::otlp::process_endpoint; use crate::plugins::telemetry::tracing::BatchProcessorConfig; pub(crate) mod histogram; @@ -51,6 +56,7 @@ impl MetricsConfigurator for Config { Config { endpoint, experimental_otlp_endpoint: otlp_endpoint, + experimental_otlp_metrics_protocol: otlp_metrics_protocol, apollo_key: Some(key), apollo_graph_ref: Some(reference), schema_id, @@ -59,7 +65,9 @@ impl MetricsConfigurator for Config { .. } => { if !ENABLED.swap(true, Ordering::Relaxed) { - tracing::info!("Apollo Studio usage reporting is enabled. See https://go.apollo.dev/o/data for details"); + tracing::info!( + "Apollo Studio usage reporting is enabled. See https://go.apollo.dev/o/data for details" + ); } builder = Self::configure_apollo_metrics( @@ -79,6 +87,7 @@ impl MetricsConfigurator for Config { builder = Self::configure_apollo_otlp_metrics( builder, otlp_endpoint, + otlp_metrics_protocol, key, reference, schema_id, @@ -99,6 +108,7 @@ impl Config { fn configure_apollo_otlp_metrics( mut builder: MetricsBuilder, endpoint: &Url, + otlp_protocol: &Protocol, key: &str, reference: &str, schema_id: &str, @@ -107,17 +117,37 @@ impl Config { tracing::debug!(endpoint = %endpoint, "creating Apollo OTLP metrics exporter"); let mut metadata = MetadataMap::new(); metadata.insert("apollo.api.key", key.parse()?); - let exporter = MetricsExporterBuilder::Tonic( - opentelemetry_otlp::new_exporter() - .tonic() - .with_endpoint(endpoint.as_str()) - .with_timeout(batch_processor.max_export_timeout) - .with_metadata(metadata) - .with_compression(opentelemetry_otlp::Compression::Gzip), - ) + let exporter = match otlp_protocol { + Protocol::Grpc => MetricsExporterBuilder::Tonic( + opentelemetry_otlp::new_exporter() + .tonic() + .with_tls_config(ClientTlsConfig::new().with_native_roots()) + .with_endpoint(endpoint.as_str()) + .with_timeout(batch_processor.max_export_timeout) + .with_metadata(metadata.clone()) + .with_compression(opentelemetry_otlp::Compression::Gzip), + ), + // While Apollo doesn't use the HTTP protocol, we support it here for + // use in tests to enable WireMock. + Protocol::Http => { + let maybe_endpoint = process_endpoint( + &Some(endpoint.to_string()), + &TelemetryDataKind::Metrics, + &Protocol::Http, + )?; + let mut otlp_exporter = opentelemetry_otlp::new_exporter() + .http() + .with_protocol(opentelemetry_otlp::Protocol::Grpc) + .with_timeout(batch_processor.max_export_timeout); + if let Some(endpoint) = maybe_endpoint { + otlp_exporter = otlp_exporter.with_endpoint(endpoint); + } + MetricsExporterBuilder::Http(otlp_exporter) + } + } .build_metrics_exporter( Box::new(CustomTemporalitySelector( - opentelemetry::sdk::metrics::data::Temporality::Delta, + opentelemetry_sdk::metrics::data::Temporality::Delta, )), Box::new( CustomAggregationSelector::builder() @@ -125,28 +155,83 @@ impl Config { .build(), ), )?; - let reader = PeriodicReader::builder(exporter, runtime::Tokio) + // MetricsExporterBuilder does not implement Clone, so we need to create a new builder for the realtime exporter + let realtime_exporter = match otlp_protocol { + Protocol::Grpc => MetricsExporterBuilder::Tonic( + opentelemetry_otlp::new_exporter() + .tonic() + .with_tls_config(ClientTlsConfig::new().with_native_roots()) + .with_endpoint(endpoint.as_str()) + .with_timeout(batch_processor.max_export_timeout) + .with_metadata(metadata.clone()) + .with_compression(opentelemetry_otlp::Compression::Gzip), + ), + Protocol::Http => { + let maybe_endpoint = process_endpoint( + &Some(endpoint.to_string()), + &TelemetryDataKind::Metrics, + &Protocol::Http, + )?; + let mut otlp_exporter = opentelemetry_otlp::new_exporter() + .http() + .with_protocol(opentelemetry_otlp::Protocol::Grpc) + .with_timeout(batch_processor.max_export_timeout); + if let Some(endpoint) = maybe_endpoint { + otlp_exporter = otlp_exporter.with_endpoint(endpoint); + } + MetricsExporterBuilder::Http(otlp_exporter) + } + } + .build_metrics_exporter( + Box::new(CustomTemporalitySelector( + opentelemetry_sdk::metrics::data::Temporality::Delta, + )), + // This aggregation uses the Apollo histogram format where a duration, x, in μs is + // counted in the bucket of index max(0, min(ceil(ln(x)/ln(1.1)), 383)). + Box::new( + CustomAggregationSelector::builder() + .boundaries( + // Returns [~1.4ms ... ~5min] + exponential_buckets(0.001399084909, 1.1, 129).unwrap(), + ) + .build(), + ), + )?; + let default_reader = PeriodicReader::builder(exporter, runtime::Tokio) .with_interval(Duration::from_secs(60)) + .with_timeout(batch_processor.max_export_timeout) .build(); + let realtime_reader = PeriodicReader::builder(realtime_exporter, runtime::Tokio) + .with_interval(batch_processor.scheduled_delay) + .with_timeout(batch_processor.max_export_timeout) + .build(); + + let resource = Resource::new([ + KeyValue::new("apollo.router.id", router_id()), + KeyValue::new("apollo.graph.ref", reference.to_string()), + KeyValue::new("apollo.schema.id", schema_id.to_string()), + KeyValue::new( + "apollo.user.agent", + format!( + "{}@{}", + std::env!("CARGO_PKG_NAME"), + std::env!("CARGO_PKG_VERSION") + ), + ), + KeyValue::new("apollo.client.host", hostname()?), + KeyValue::new("apollo.client.uname", get_uname()?), + ]); + builder.apollo_meter_provider_builder = builder .apollo_meter_provider_builder - .with_reader(reader) - .with_resource(Resource::new([ - KeyValue::new("apollo.router.id", router_id()), - KeyValue::new("apollo.graph.ref", reference.to_string()), - KeyValue::new("apollo.schema.id", schema_id.to_string()), - KeyValue::new( - "apollo.user.agent", - format!( - "{}@{}", - std::env!("CARGO_PKG_NAME"), - std::env!("CARGO_PKG_VERSION") - ), - ), - KeyValue::new("apollo.client.host", hostname()?), - KeyValue::new("apollo.client.uname", get_uname()?), - ])); + .with_reader(default_reader) + .with_resource(resource.clone()); + + builder.apollo_realtime_meter_provider_builder = builder + .apollo_realtime_meter_provider_builder + .with_reader(realtime_reader) + .with_resource(resource.clone()); Ok(builder) } @@ -167,6 +252,7 @@ impl Config { key, reference, schema_id, + router_id(), metrics_reference_mode, )?; @@ -180,50 +266,47 @@ mod test { use std::future::Future; use std::time::Duration; - use http::header::HeaderName; - use tokio_stream::wrappers::ReceiverStream; + use serde_json::Value; use tokio_stream::StreamExt; + use tokio_stream::wrappers::ReceiverStream; use tower::ServiceExt; - use url::Url; - use super::super::super::config; use super::studio::SingleStatsReport; use super::*; + use crate::Context; + use crate::TestHarness; use crate::context::OPERATION_KIND; use crate::plugin::Plugin; use crate::plugin::PluginInit; + use crate::plugin::PluginPrivate; use crate::plugins::subscription; + use crate::plugins::telemetry::STUDIO_EXCLUDE; + use crate::plugins::telemetry::Telemetry; use crate::plugins::telemetry::apollo; - use crate::plugins::telemetry::apollo::default_buffer_size; use crate::plugins::telemetry::apollo::ENDPOINT_DEFAULT; use crate::plugins::telemetry::apollo_exporter::Sender; - use crate::plugins::telemetry::Telemetry; - use crate::plugins::telemetry::STUDIO_EXCLUDE; use crate::query_planner::OperationKind; use crate::services::SupergraphRequest; - use crate::Context; - use crate::TestHarness; #[tokio::test] async fn apollo_metrics_disabled() -> Result<(), BoxError> { - let plugin = create_plugin_with_apollo_config(super::super::apollo::Config { - endpoint: Url::parse("http://example.com")?, - apollo_key: None, - apollo_graph_ref: None, - client_name_header: HeaderName::from_static("name_header"), - client_version_header: HeaderName::from_static("version_header"), - buffer_size: default_buffer_size(), - schema_id: "schema_sha".to_string(), - ..Default::default() - }) - .await?; + let config = r#" + telemetry: + apollo: + endpoint: "http://example.com" + client_name_header: "name_header" + client_version_header: "version_header" + buffer_size: 10000 + schema_id: "schema_sha" + "#; + let plugin = create_telemetry_plugin(config).await?; assert!(matches!(plugin.apollo_metrics_sender, Sender::Noop)); Ok(()) } #[tokio::test(flavor = "multi_thread")] async fn apollo_metrics_enabled() -> Result<(), BoxError> { - let plugin = create_plugin().await?; + let plugin = create_default_telemetry_plugin().await?; assert!(matches!(plugin.apollo_metrics_sender, Sender::Apollo(_))); Ok(()) } @@ -231,7 +314,7 @@ mod test { #[tokio::test(flavor = "multi_thread")] async fn apollo_metrics_single_operation() -> Result<(), BoxError> { let query = "query {topProducts{name}}"; - let results = get_metrics_for_request(query, None, None, false).await?; + let results = get_metrics_for_request(query, None, None, false, None).await?; let mut settings = insta::Settings::clone_current(); settings.set_sort_maps(true); settings.add_redaction("[].request_id", "[REDACTED]"); @@ -248,7 +331,7 @@ mod test { let _ = context .insert(OPERATION_KIND, OperationKind::Subscription) .unwrap(); - let results = get_metrics_for_request(query, None, Some(context), true).await?; + let results = get_metrics_for_request(query, None, Some(context), true, None).await?; let mut settings = insta::Settings::clone_current(); settings.set_sort_maps(true); settings.add_redaction("[].request_id", "[REDACTED]"); @@ -265,7 +348,7 @@ mod test { let _ = context .insert(OPERATION_KIND, OperationKind::Subscription) .unwrap(); - let results = get_metrics_for_request(query, None, Some(context), true).await?; + let results = get_metrics_for_request(query, None, Some(context), true, None).await?; let mut settings = insta::Settings::clone_current(); settings.set_sort_maps(true); settings.add_redaction("[].request_id", "[REDACTED]"); @@ -278,7 +361,7 @@ mod test { #[tokio::test(flavor = "multi_thread")] async fn apollo_metrics_multiple_operations() -> Result<(), BoxError> { let query = "query {topProducts{name}} query {topProducts{name}}"; - let results = get_metrics_for_request(query, None, None, false).await?; + let results = get_metrics_for_request(query, None, None, false, None).await?; let mut settings = insta::Settings::clone_current(); settings.set_sort_maps(true); settings.add_redaction("[].request_id", "[REDACTED]"); @@ -291,7 +374,7 @@ mod test { #[tokio::test(flavor = "multi_thread")] async fn apollo_metrics_parse_failure() -> Result<(), BoxError> { let query = "garbage"; - let results = get_metrics_for_request(query, None, None, false).await?; + let results = get_metrics_for_request(query, None, None, false, None).await?; let mut settings = insta::Settings::clone_current(); settings.set_sort_maps(true); settings.add_redaction("[].request_id", "[REDACTED]"); @@ -304,7 +387,7 @@ mod test { #[tokio::test(flavor = "multi_thread")] async fn apollo_metrics_unknown_operation() -> Result<(), BoxError> { let query = "query {topProducts{name}}"; - let results = get_metrics_for_request(query, Some("UNKNOWN"), None, false).await?; + let results = get_metrics_for_request(query, Some("UNKNOWN"), None, false, None).await?; let mut settings = insta::Settings::clone_current(); settings.set_sort_maps(true); settings.add_redaction("[].request_id", "[REDACTED]"); @@ -315,7 +398,7 @@ mod test { #[tokio::test(flavor = "multi_thread")] async fn apollo_metrics_validation_failure() -> Result<(), BoxError> { let query = "query {topProducts(minStarRating: 4.7){name}}"; - let results = get_metrics_for_request(query, None, None, false).await?; + let results = get_metrics_for_request(query, None, None, false, None).await?; let mut settings = insta::Settings::clone_current(); settings.set_sort_maps(true); settings.add_redaction("[].request_id", "[REDACTED]"); @@ -331,7 +414,99 @@ mod test { let query = "query {topProducts{name}}"; let context = Context::new(); context.insert(STUDIO_EXCLUDE, true)?; - let results = get_metrics_for_request(query, None, Some(context), false).await?; + let results = get_metrics_for_request(query, None, Some(context), false, None).await?; + let mut settings = insta::Settings::clone_current(); + settings.set_sort_maps(true); + settings.add_redaction("[].request_id", "[REDACTED]"); + settings.bind(|| { + insta::assert_json_snapshot!(results); + }); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn apollo_metrics_features_explicitly_enabled() -> Result<(), BoxError> { + let query = "query {topProducts{name}}"; + let plugin = create_telemetry_plugin(include_str!( + "../../testdata/full_config_all_features_enabled.router.yaml" + )) + .await?; + let results = get_metrics_for_request(query, None, None, false, Some(plugin)).await?; + let mut settings = insta::Settings::clone_current(); + settings.set_sort_maps(true); + settings.add_redaction("[].request_id", "[REDACTED]"); + settings.bind(|| { + insta::assert_json_snapshot!(results); + }); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn apollo_metrics_features_explicitly_disabled() -> Result<(), BoxError> { + let query = "query {topProducts{name}}"; + let plugin = create_telemetry_plugin(include_str!( + "../../testdata/full_config_all_features_explicitly_disabled.router.yaml" + )) + .await?; + let results = get_metrics_for_request(query, None, None, false, Some(plugin)).await?; + let mut settings = insta::Settings::clone_current(); + settings.set_sort_maps(true); + settings.add_redaction("[].request_id", "[REDACTED]"); + settings.bind(|| { + insta::assert_json_snapshot!(results); + }); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn apollo_metrics_features_disabled_when_defaulted() -> Result<(), BoxError> { + let query = "query {topProducts{name}}"; + let plugin = create_telemetry_plugin(include_str!( + "../../testdata/full_config_all_features_defaults.router.yaml" + )) + .await?; + let results = get_metrics_for_request(query, None, None, false, Some(plugin)).await?; + let mut settings = insta::Settings::clone_current(); + settings.set_sort_maps(true); + settings.add_redaction("[].request_id", "[REDACTED]"); + settings.bind(|| { + insta::assert_json_snapshot!(results); + }); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn apollo_metrics_distributed_apq_cache_feature_enabled_with_partial_defaults() + -> Result<(), BoxError> { + let query = "query {topProducts{name}}"; + let plugin = create_telemetry_plugin(include_str!( + "../../testdata/full_config_apq_enabled_partial_defaults.router.yaml" + )) + .await?; + let results = get_metrics_for_request(query, None, None, false, Some(plugin)).await?; + let mut settings = insta::Settings::clone_current(); + settings.set_sort_maps(true); + settings.add_redaction("[].request_id", "[REDACTED]"); + settings.bind(|| { + insta::assert_json_snapshot!(results); + }); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn apollo_metrics_distributed_apq_cache_feature_disabled_with_partial_defaults() + -> Result<(), BoxError> { + let query = "query {topProducts{name}}"; + let plugin = create_telemetry_plugin(include_str!( + "../../testdata/full_config_apq_disabled_partial_defaults.router.yaml" + )) + .await?; + let results = get_metrics_for_request(query, None, None, false, Some(plugin)).await?; let mut settings = insta::Settings::clone_current(); settings.set_sort_maps(true); settings.add_redaction("[].request_id", "[REDACTED]"); @@ -347,9 +522,15 @@ mod test { operation_name: Option<&str>, context: Option, is_subscription: bool, + telemetry_plugin: Option, ) -> Result, BoxError> { let _ = tracing_subscriber::fmt::try_init(); - let mut plugin = create_plugin().await?; + + let mut plugin = if let Some(p) = telemetry_plugin { + p + } else { + create_default_telemetry_plugin().await? + }; // Replace the apollo metrics sender so we can test metrics collection. let (tx, rx) = tokio::sync::mpsc::channel(100); plugin.apollo_metrics_sender = Sender::Apollo(tx); @@ -364,7 +545,7 @@ mod test { request_builder.header("accept", "multipart/mixed;subscriptionSpec=1.0"); } TestHarness::builder() - .extra_plugin(plugin) + .extra_private_plugin(plugin) .extra_plugin(create_subscription_plugin().await?) .build_router() .await? @@ -394,34 +575,43 @@ mod test { Ok(results) } - fn create_plugin() -> impl Future> { - create_plugin_with_apollo_config(apollo::Config { - endpoint: Url::parse(ENDPOINT_DEFAULT).expect("default endpoint must be parseable"), - apollo_key: Some("key".to_string()), - apollo_graph_ref: Some("ref".to_string()), - client_name_header: HeaderName::from_static("name_header"), - client_version_header: HeaderName::from_static("version_header"), - buffer_size: default_buffer_size(), - schema_id: "schema_sha".to_string(), - ..Default::default() - }) + fn create_default_telemetry_plugin() -> impl Future> { + let config = format!( + r#" + telemetry: + apollo: + endpoint: "{ENDPOINT_DEFAULT}" + apollo_key: "key" + apollo_graph_ref: "ref" + client_name_header: "name_header" + client_version_header: "version_header" + buffer_size: 10000 + schema_id: "schema_sha" + "# + ); + + async move { create_telemetry_plugin(&config).await } } - async fn create_plugin_with_apollo_config( - apollo_config: apollo::Config, - ) -> Result { - Telemetry::new(PluginInit::fake_new( - config::Conf { - apollo: apollo_config, - ..Default::default() - }, - Default::default(), - )) - .await + async fn create_telemetry_plugin(full_config: &str) -> Result { + let full_config = serde_yaml::from_str::(full_config).expect("yaml must be valid"); + let telemetry_config = full_config + .as_object() + .expect("must be an object") + .get("telemetry") + .expect("telemetry must be a root key"); + let init = PluginInit::fake_builder() + .config(telemetry_config.clone()) + .full_config(full_config) + .build() + .with_deserialized_config() + .expect("unable to deserialize telemetry config"); + + Telemetry::new(init).await } async fn create_subscription_plugin() -> Result { - subscription::Subscription::new(PluginInit::fake_new( + ::new(PluginInit::fake_new( subscription::SubscriptionConfig::default(), Default::default(), )) diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__studio__test__aggregation.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__studio__test__aggregation.snap index f04a995fe0..b2f33c9252 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__studio__test__aggregation.snap +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__studio__test__aggregation.snap @@ -1,6 +1,7 @@ --- source: apollo-router/src/plugins/telemetry/metrics/apollo/studio.rs expression: aggregated_metrics +snapshot_kind: text --- { "traces_per_query": { @@ -13,7 +14,9 @@ expression: aggregated_metrics "client_version": "version_1", "operation_type": "", "operation_subtype": "", - "result": "" + "result": "", + "client_library_name": "library_name_1", + "client_library_version": "library_version_1" }, { "context": { @@ -21,7 +24,9 @@ expression: aggregated_metrics "client_version": "version_1", "operation_type": "", "operation_subtype": "", - "result": "" + "result": "", + "client_library_name": "library_name_1", + "client_library_version": "library_version_1" }, "query_latency_stats": { "request_latencies": { @@ -1451,7 +1456,8 @@ expression: aggregated_metrics ], "is_interface": false } - } + }, + "query_metadata": null } }, "licensed_operation_count_by_type": { @@ -1460,5 +1466,9 @@ expression: aggregated_metrics "subtype": null, "licensed_operation_count": 2 } - } + }, + "router_features_enabled": [ + "distributed_apq_cache", + "entity_cache" + ] } diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_distributed_apq_cache_feature_disabled_with_partial_defaults.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_distributed_apq_cache_feature_disabled_with_partial_defaults.snap new file mode 100644 index 0000000000..8cee03b9b1 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_distributed_apq_cache_feature_disabled_with_partial_defaults.snap @@ -0,0 +1,82 @@ +--- +source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs +expression: results +snapshot_kind: text +--- +[ + { + "request_id": "[REDACTED]", + "stats": { + "# -\n{topProducts{name}}": { + "stats_with_context": { + "context": { + "client_name": "", + "client_version": "", + "operation_type": "query", + "operation_subtype": "", + "result": "", + "client_library_name": "", + "client_library_version": "" + }, + "query_latency_stats": { + "latency": { + "secs": 0, + "nanos": 100000000 + }, + "cache_hit": false, + "persisted_query_hit": null, + "cache_latency": null, + "root_error_stats": { + "children": {}, + "errors_count": 0, + "requests_with_errors_count": 0 + }, + "has_errors": false, + "public_cache_ttl_latency": null, + "private_cache_ttl_latency": null, + "registered_operation": false, + "forbidden_operation": false, + "without_field_instrumentation": false + }, + "limits_stats": { + "strategy": null, + "cost_estimated": null, + "cost_actual": null, + "depth": 2, + "height": 2, + "alias_count": 0, + "root_field_count": 1 + }, + "per_type_stat": {}, + "extended_references": { + "referenced_input_fields": {}, + "referenced_enums": {} + }, + "enum_response_references": {}, + "local_per_type_stat": {} + }, + "referenced_fields_by_type": { + "Product": { + "field_names": [ + "name" + ], + "is_interface": false + }, + "Query": { + "field_names": [ + "topProducts" + ], + "is_interface": false + } + }, + "query_metadata": null + } + }, + "licensed_operation_count_by_type": { + "type": "query", + "subtype": null, + "licensed_operation_count": 1 + }, + "router_features_enabled": [] + } +] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_distributed_apq_cache_feature_enabled_with_partial_defaults.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_distributed_apq_cache_feature_enabled_with_partial_defaults.snap new file mode 100644 index 0000000000..709d09bacd --- /dev/null +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_distributed_apq_cache_feature_enabled_with_partial_defaults.snap @@ -0,0 +1,84 @@ +--- +source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs +expression: results +snapshot_kind: text +--- +[ + { + "request_id": "[REDACTED]", + "stats": { + "# -\n{topProducts{name}}": { + "stats_with_context": { + "context": { + "client_name": "", + "client_version": "", + "operation_type": "query", + "operation_subtype": "", + "result": "", + "client_library_name": "", + "client_library_version": "" + }, + "query_latency_stats": { + "latency": { + "secs": 0, + "nanos": 100000000 + }, + "cache_hit": false, + "persisted_query_hit": null, + "cache_latency": null, + "root_error_stats": { + "children": {}, + "errors_count": 0, + "requests_with_errors_count": 0 + }, + "has_errors": false, + "public_cache_ttl_latency": null, + "private_cache_ttl_latency": null, + "registered_operation": false, + "forbidden_operation": false, + "without_field_instrumentation": false + }, + "limits_stats": { + "strategy": null, + "cost_estimated": null, + "cost_actual": null, + "depth": 2, + "height": 2, + "alias_count": 0, + "root_field_count": 1 + }, + "per_type_stat": {}, + "extended_references": { + "referenced_input_fields": {}, + "referenced_enums": {} + }, + "enum_response_references": {}, + "local_per_type_stat": {} + }, + "referenced_fields_by_type": { + "Product": { + "field_names": [ + "name" + ], + "is_interface": false + }, + "Query": { + "field_names": [ + "topProducts" + ], + "is_interface": false + } + }, + "query_metadata": null + } + }, + "licensed_operation_count_by_type": { + "type": "query", + "subtype": null, + "licensed_operation_count": 1 + }, + "router_features_enabled": [ + "distributed_apq_cache" + ] + } +] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_exclude.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_exclude.snap index 45b8145459..90f5bce746 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_exclude.snap +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_exclude.snap @@ -1,6 +1,7 @@ --- -source: apollo-router/src/plugins/telemetry/metrics/apollo.rs +source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs expression: results +snapshot_kind: text --- [ { @@ -10,6 +11,7 @@ expression: results "type": "query", "subtype": null, "licensed_operation_count": 1 - } + }, + "router_features_enabled": [] } ] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_disabled_when_defaulted.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_disabled_when_defaulted.snap new file mode 100644 index 0000000000..8cee03b9b1 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_disabled_when_defaulted.snap @@ -0,0 +1,82 @@ +--- +source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs +expression: results +snapshot_kind: text +--- +[ + { + "request_id": "[REDACTED]", + "stats": { + "# -\n{topProducts{name}}": { + "stats_with_context": { + "context": { + "client_name": "", + "client_version": "", + "operation_type": "query", + "operation_subtype": "", + "result": "", + "client_library_name": "", + "client_library_version": "" + }, + "query_latency_stats": { + "latency": { + "secs": 0, + "nanos": 100000000 + }, + "cache_hit": false, + "persisted_query_hit": null, + "cache_latency": null, + "root_error_stats": { + "children": {}, + "errors_count": 0, + "requests_with_errors_count": 0 + }, + "has_errors": false, + "public_cache_ttl_latency": null, + "private_cache_ttl_latency": null, + "registered_operation": false, + "forbidden_operation": false, + "without_field_instrumentation": false + }, + "limits_stats": { + "strategy": null, + "cost_estimated": null, + "cost_actual": null, + "depth": 2, + "height": 2, + "alias_count": 0, + "root_field_count": 1 + }, + "per_type_stat": {}, + "extended_references": { + "referenced_input_fields": {}, + "referenced_enums": {} + }, + "enum_response_references": {}, + "local_per_type_stat": {} + }, + "referenced_fields_by_type": { + "Product": { + "field_names": [ + "name" + ], + "is_interface": false + }, + "Query": { + "field_names": [ + "topProducts" + ], + "is_interface": false + } + }, + "query_metadata": null + } + }, + "licensed_operation_count_by_type": { + "type": "query", + "subtype": null, + "licensed_operation_count": 1 + }, + "router_features_enabled": [] + } +] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_explicitly_disabled.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_explicitly_disabled.snap new file mode 100644 index 0000000000..8cee03b9b1 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_explicitly_disabled.snap @@ -0,0 +1,82 @@ +--- +source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs +expression: results +snapshot_kind: text +--- +[ + { + "request_id": "[REDACTED]", + "stats": { + "# -\n{topProducts{name}}": { + "stats_with_context": { + "context": { + "client_name": "", + "client_version": "", + "operation_type": "query", + "operation_subtype": "", + "result": "", + "client_library_name": "", + "client_library_version": "" + }, + "query_latency_stats": { + "latency": { + "secs": 0, + "nanos": 100000000 + }, + "cache_hit": false, + "persisted_query_hit": null, + "cache_latency": null, + "root_error_stats": { + "children": {}, + "errors_count": 0, + "requests_with_errors_count": 0 + }, + "has_errors": false, + "public_cache_ttl_latency": null, + "private_cache_ttl_latency": null, + "registered_operation": false, + "forbidden_operation": false, + "without_field_instrumentation": false + }, + "limits_stats": { + "strategy": null, + "cost_estimated": null, + "cost_actual": null, + "depth": 2, + "height": 2, + "alias_count": 0, + "root_field_count": 1 + }, + "per_type_stat": {}, + "extended_references": { + "referenced_input_fields": {}, + "referenced_enums": {} + }, + "enum_response_references": {}, + "local_per_type_stat": {} + }, + "referenced_fields_by_type": { + "Product": { + "field_names": [ + "name" + ], + "is_interface": false + }, + "Query": { + "field_names": [ + "topProducts" + ], + "is_interface": false + } + }, + "query_metadata": null + } + }, + "licensed_operation_count_by_type": { + "type": "query", + "subtype": null, + "licensed_operation_count": 1 + }, + "router_features_enabled": [] + } +] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_explicitly_enabled.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_explicitly_enabled.snap new file mode 100644 index 0000000000..3b3fd3ed58 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_explicitly_enabled.snap @@ -0,0 +1,85 @@ +--- +source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs +expression: results +snapshot_kind: text +--- +[ + { + "request_id": "[REDACTED]", + "stats": { + "# -\n{topProducts{name}}": { + "stats_with_context": { + "context": { + "client_name": "", + "client_version": "", + "operation_type": "query", + "operation_subtype": "", + "result": "", + "client_library_name": "", + "client_library_version": "" + }, + "query_latency_stats": { + "latency": { + "secs": 0, + "nanos": 100000000 + }, + "cache_hit": false, + "persisted_query_hit": null, + "cache_latency": null, + "root_error_stats": { + "children": {}, + "errors_count": 0, + "requests_with_errors_count": 0 + }, + "has_errors": false, + "public_cache_ttl_latency": null, + "private_cache_ttl_latency": null, + "registered_operation": false, + "forbidden_operation": false, + "without_field_instrumentation": false + }, + "limits_stats": { + "strategy": null, + "cost_estimated": null, + "cost_actual": null, + "depth": 2, + "height": 2, + "alias_count": 0, + "root_field_count": 1 + }, + "per_type_stat": {}, + "extended_references": { + "referenced_input_fields": {}, + "referenced_enums": {} + }, + "enum_response_references": {}, + "local_per_type_stat": {} + }, + "referenced_fields_by_type": { + "Product": { + "field_names": [ + "name" + ], + "is_interface": false + }, + "Query": { + "field_names": [ + "topProducts" + ], + "is_interface": false + } + }, + "query_metadata": null + } + }, + "licensed_operation_count_by_type": { + "type": "query", + "subtype": null, + "licensed_operation_count": 1 + }, + "router_features_enabled": [ + "distributed_apq_cache", + "entity_cache" + ] + } +] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_implicitly_disabled.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_implicitly_disabled.snap new file mode 100644 index 0000000000..4b51dae9a8 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_features_implicitly_disabled.snap @@ -0,0 +1,82 @@ +--- +source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs +expression: results +snapshot_kind: text +--- +[ + { + "request_id": "[REDACTED]", + "stats": { + "# -\n{topProducts{name}}": { + "stats_with_context": { + "context": { + "client_name": "", + "client_version": "", + "operation_type": "query", + "operation_subtype": "", + "result": "", + "client_library_name": "", + "client_library_version": "" + }, + "query_latency_stats": { + "latency": { + "secs": 0, + "nanos": 100000000 + }, + "cache_hit": false, + "persisted_query_hit": null, + "cache_latency": null, + "root_error_stats": { + "children": {}, + "errors_count": 0, + "requests_with_errors_count": 0 + }, + "has_errors": true, + "public_cache_ttl_latency": null, + "private_cache_ttl_latency": null, + "registered_operation": false, + "forbidden_operation": false, + "without_field_instrumentation": false + }, + "limits_stats": { + "strategy": null, + "cost_estimated": null, + "cost_actual": null, + "depth": 2, + "height": 2, + "alias_count": 0, + "root_field_count": 1 + }, + "per_type_stat": {}, + "extended_references": { + "referenced_input_fields": {}, + "referenced_enums": {} + }, + "enum_response_references": {}, + "local_per_type_stat": {} + }, + "referenced_fields_by_type": { + "Product": { + "field_names": [ + "name" + ], + "is_interface": false + }, + "Query": { + "field_names": [ + "topProducts" + ], + "is_interface": false + } + }, + "query_metadata": null + } + }, + "licensed_operation_count_by_type": { + "type": "query", + "subtype": null, + "licensed_operation_count": 1 + }, + "router_features_enabled": [] + } +] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_for_subscription.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_for_subscription.snap index 275ef0eeb8..4ba50c0854 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_for_subscription.snap +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_for_subscription.snap @@ -1,6 +1,7 @@ --- source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs expression: results +snapshot_kind: text --- [ { @@ -13,7 +14,9 @@ expression: results "client_version": "1.0-test", "operation_type": "subscription", "operation_subtype": "subscription-request", - "result": "" + "result": "", + "client_library_name": "", + "client_library_version": "" }, "query_latency_stats": { "latency": { @@ -65,13 +68,15 @@ expression: results ], "is_interface": false } - } + }, + "query_metadata": null } }, "licensed_operation_count_by_type": { "type": "subscription", "subtype": "subscription-request", "licensed_operation_count": 1 - } + }, + "router_features_enabled": [] } ] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_for_subscription_error.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_for_subscription_error.snap index d79b797424..d7ff92d2e9 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_for_subscription_error.snap +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_for_subscription_error.snap @@ -1,6 +1,7 @@ --- source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs expression: results +snapshot_kind: text --- [ { @@ -13,7 +14,9 @@ expression: results "client_version": "1.0-test", "operation_type": "subscription", "operation_subtype": "subscription-request", - "result": "" + "result": "", + "client_library_name": "", + "client_library_version": "" }, "query_latency_stats": { "latency": { @@ -65,13 +68,15 @@ expression: results ], "is_interface": false } - } + }, + "query_metadata": null } }, "licensed_operation_count_by_type": { "type": "subscription", "subtype": "subscription-request", "licensed_operation_count": 1 - } + }, + "router_features_enabled": [] } ] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_multiple_operations.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_multiple_operations.snap index 3d784242db..7fc2545a85 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_multiple_operations.snap +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_multiple_operations.snap @@ -1,6 +1,7 @@ --- source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs expression: results +snapshot_kind: text --- [ { @@ -13,7 +14,9 @@ expression: results "client_version": "1.0-test", "operation_type": "query", "operation_subtype": "", - "result": "" + "result": "", + "client_library_name": "", + "client_library_version": "" }, "query_latency_stats": { "latency": { @@ -52,9 +55,11 @@ expression: results "enum_response_references": {}, "local_per_type_stat": {} }, - "referenced_fields_by_type": {} + "referenced_fields_by_type": {}, + "query_metadata": null } }, - "licensed_operation_count_by_type": null + "licensed_operation_count_by_type": null, + "router_features_enabled": [] } ] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_parse_failure.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_parse_failure.snap index 2f375f6382..bd1d86ae6e 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_parse_failure.snap +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_parse_failure.snap @@ -1,6 +1,7 @@ --- source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs expression: results +snapshot_kind: text --- [ { @@ -13,7 +14,9 @@ expression: results "client_version": "1.0-test", "operation_type": "query", "operation_subtype": "", - "result": "" + "result": "", + "client_library_name": "", + "client_library_version": "" }, "query_latency_stats": { "latency": { @@ -52,9 +55,11 @@ expression: results "enum_response_references": {}, "local_per_type_stat": {} }, - "referenced_fields_by_type": {} + "referenced_fields_by_type": {}, + "query_metadata": null } }, - "licensed_operation_count_by_type": null + "licensed_operation_count_by_type": null, + "router_features_enabled": [] } ] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_single_operation.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_single_operation.snap index 6900d6c06d..a480ca1cf6 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_single_operation.snap +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_single_operation.snap @@ -1,6 +1,7 @@ --- source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs expression: results +snapshot_kind: text --- [ { @@ -13,7 +14,9 @@ expression: results "client_version": "1.0-test", "operation_type": "query", "operation_subtype": "", - "result": "" + "result": "", + "client_library_name": "", + "client_library_version": "" }, "query_latency_stats": { "latency": { @@ -28,7 +31,7 @@ expression: results "errors_count": 0, "requests_with_errors_count": 0 }, - "has_errors": true, + "has_errors": false, "public_cache_ttl_latency": null, "private_cache_ttl_latency": null, "registered_operation": false, @@ -65,13 +68,15 @@ expression: results ], "is_interface": false } - } + }, + "query_metadata": null } }, "licensed_operation_count_by_type": { "type": "query", "subtype": null, "licensed_operation_count": 1 - } + }, + "router_features_enabled": [] } ] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_unknown_operation.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_unknown_operation.snap index fc45c8125d..a7a537d6ee 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_unknown_operation.snap +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_unknown_operation.snap @@ -1,6 +1,7 @@ --- source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs expression: results +snapshot_kind: text --- [ { @@ -13,7 +14,9 @@ expression: results "client_version": "1.0-test", "operation_type": "query", "operation_subtype": "", - "result": "" + "result": "", + "client_library_name": "", + "client_library_version": "" }, "query_latency_stats": { "latency": { @@ -52,9 +55,11 @@ expression: results "enum_response_references": {}, "local_per_type_stat": {} }, - "referenced_fields_by_type": {} + "referenced_fields_by_type": {}, + "query_metadata": null } }, - "licensed_operation_count_by_type": null + "licensed_operation_count_by_type": null, + "router_features_enabled": [] } ] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_validation_failure.snap b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_validation_failure.snap index 3d784242db..7fc2545a85 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_validation_failure.snap +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/snapshots/apollo_router__plugins__telemetry__metrics__apollo__test__apollo_metrics_validation_failure.snap @@ -1,6 +1,7 @@ --- source: apollo-router/src/plugins/telemetry/metrics/apollo/mod.rs expression: results +snapshot_kind: text --- [ { @@ -13,7 +14,9 @@ expression: results "client_version": "1.0-test", "operation_type": "query", "operation_subtype": "", - "result": "" + "result": "", + "client_library_name": "", + "client_library_version": "" }, "query_latency_stats": { "latency": { @@ -52,9 +55,11 @@ expression: results "enum_response_references": {}, "local_per_type_stat": {} }, - "referenced_fields_by_type": {} + "referenced_fields_by_type": {}, + "query_metadata": null } }, - "licensed_operation_count_by_type": null + "licensed_operation_count_by_type": null, + "router_features_enabled": [] } ] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/studio.rs b/apollo-router/src/plugins/telemetry/metrics/apollo/studio.rs index 6e57f94501..1566c6b451 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/studio.rs +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/studio.rs @@ -15,6 +15,7 @@ use crate::plugins::telemetry::apollo::LicensedOperationCountByType; use crate::plugins::telemetry::apollo_exporter::proto::reports::EnumStats; use crate::plugins::telemetry::apollo_exporter::proto::reports::InputFieldStats; use crate::plugins::telemetry::apollo_exporter::proto::reports::InputTypeStats; +use crate::plugins::telemetry::apollo_exporter::proto::reports::QueryMetadata; use crate::plugins::telemetry::apollo_exporter::proto::reports::ReferencedFieldsForType; use crate::plugins::telemetry::apollo_exporter::proto::reports::StatsContext; @@ -23,12 +24,14 @@ pub(crate) struct SingleStatsReport { pub(crate) request_id: Uuid, pub(crate) stats: HashMap, pub(crate) licensed_operation_count_by_type: Option, + pub(crate) router_features_enabled: Vec, } #[derive(Default, Debug, Serialize)] pub(crate) struct SingleStats { pub(crate) stats_with_context: SingleContextualizedStats, pub(crate) referenced_fields_by_type: HashMap, + pub(crate) query_metadata: Option, } #[derive(Default, Debug, Serialize)] @@ -477,8 +480,20 @@ mod test { #[test] fn test_aggregation() { - let metric_1 = create_test_metric("client_1", "version_1", "report_key_1"); - let metric_2 = create_test_metric("client_1", "version_1", "report_key_1"); + let metric_1 = create_test_metric( + "client_1", + "version_1", + "library_name_1", + "library_version_1", + "report_key_1", + ); + let metric_2 = create_test_metric( + "client_1", + "version_1", + "library_name_1", + "library_version_1", + "report_key_1", + ); let aggregated_metrics = Report::new(vec![metric_1, metric_2]); insta::with_settings!({sort_maps => true}, { insta::assert_json_snapshot!(aggregated_metrics); @@ -487,11 +502,41 @@ mod test { #[test] fn test_aggregation_grouping() { - let metric_1 = create_test_metric("client_1", "version_1", "report_key_1"); - let metric_2 = create_test_metric("client_1", "version_1", "report_key_1"); - let metric_3 = create_test_metric("client_2", "version_1", "report_key_1"); - let metric_4 = create_test_metric("client_1", "version_2", "report_key_1"); - let metric_5 = create_test_metric("client_1", "version_1", "report_key_2"); + let metric_1 = create_test_metric( + "client_1", + "version_1", + "library_name_1", + "library_version_1", + "report_key_1", + ); + let metric_2 = create_test_metric( + "client_1", + "version_1", + "library_name_1", + "library_version_1", + "report_key_1", + ); + let metric_3 = create_test_metric( + "client_2", + "version_1", + "library_name_2", + "library_version_1", + "report_key_1", + ); + let metric_4 = create_test_metric( + "client_1", + "version_2", + "library_name_1", + "library_version_2", + "report_key_1", + ); + let metric_5 = create_test_metric( + "client_1", + "version_1", + "library_name_1", + "library_version_1", + "report_key_2", + ); let aggregated_metrics = Report::new(vec![metric_1, metric_2, metric_3, metric_4, metric_5]); assert_eq!(aggregated_metrics.traces_per_query.len(), 2); @@ -512,6 +557,8 @@ mod test { fn create_test_metric( client_name: &str, client_version: &str, + library_name: &str, + library_version: &str, stats_report_key: &str, ) -> SingleStatsReport { // This makes me sad. Really this should have just been a case of generate a couple of metrics using @@ -535,6 +582,8 @@ mod test { result: "".to_string(), client_name: client_name.to_string(), client_version: client_version.to_string(), + client_library_name: library_name.to_string(), + client_library_version: library_version.to_string(), operation_type: String::new(), operation_subtype: String::new(), }, @@ -615,8 +664,13 @@ mod test { is_interface: false, }, )]), + query_metadata: None, }, )]), + router_features_enabled: vec![ + "distributed_apq_cache".to_string(), + "entity_cache".to_string(), + ], } } diff --git a/apollo-router/src/plugins/telemetry/metrics/mod.rs b/apollo-router/src/plugins/telemetry/metrics/mod.rs index d6e6797e2e..5ec8e54d87 100644 --- a/apollo-router/src/plugins/telemetry/metrics/mod.rs +++ b/apollo-router/src/plugins/telemetry/metrics/mod.rs @@ -1,436 +1,28 @@ -use std::collections::HashMap; - -use ::serde::Deserialize; -use access_json::JSONQuery; -use http::header::HeaderName; -use http::response::Parts; -use http::HeaderMap; use multimap::MultiMap; -use opentelemetry::sdk::metrics::reader::AggregationSelector; -use opentelemetry::sdk::metrics::Aggregation; -use opentelemetry::sdk::metrics::InstrumentKind; -use opentelemetry::sdk::Resource; -use regex::Regex; -use schemars::JsonSchema; -use serde::Serialize; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::metrics::Aggregation; +use opentelemetry_sdk::metrics::InstrumentKind; +use opentelemetry_sdk::metrics::reader::AggregationSelector; use tower::BoxError; -use crate::error::FetchError; -use crate::graphql; -use crate::graphql::Request; -use crate::plugin::serde::deserialize_header_name; -use crate::plugin::serde::deserialize_json_query; -use crate::plugin::serde::deserialize_regex; +use crate::ListenAddr; use crate::plugins::telemetry::apollo_exporter::Sender; -use crate::plugins::telemetry::config::AttributeValue; use crate::plugins::telemetry::config::Conf; use crate::plugins::telemetry::config::MetricsCommon; use crate::plugins::telemetry::resource::ConfigResource; use crate::router_factory::Endpoint; -use crate::Context; -use crate::ListenAddr; pub(crate) mod apollo; pub(crate) mod local_type_stats; pub(crate) mod otlp; pub(crate) mod prometheus; -pub(crate) mod span_metrics_exporter; - -#[derive(Debug, Clone, Deserialize, JsonSchema, Default)] -#[serde(deny_unknown_fields, default)] -/// Configuration to add custom attributes/labels on metrics -pub(crate) struct MetricsAttributesConf { - /// Configuration to forward header values or body values from router request/response in metric attributes/labels - pub(crate) supergraph: AttributesForwardConf, - /// Configuration to forward header values or body values from subgraph request/response in metric attributes/labels - pub(crate) subgraph: SubgraphAttributesConf, -} - -/// Configuration to add custom attributes/labels on metrics to subgraphs -#[derive(Debug, Clone, Deserialize, JsonSchema, Default)] -#[serde(deny_unknown_fields, default)] -pub(crate) struct SubgraphAttributesConf { - /// Attributes for all subgraphs - pub(crate) all: AttributesForwardConf, - /// Attributes per subgraph - pub(crate) subgraphs: HashMap, -} - -/// Configuration to add custom attributes/labels on metrics to subgraphs -#[derive(Debug, Clone, Deserialize, JsonSchema, Default)] -#[serde(deny_unknown_fields, default)] -pub(crate) struct AttributesForwardConf { - /// Configuration to insert custom attributes/labels in metrics - #[serde(rename = "static")] - pub(crate) insert: Vec, - /// Configuration to forward headers or body values from the request to custom attributes/labels in metrics - pub(crate) request: Forward, - /// Configuration to forward headers or body values from the response to custom attributes/labels in metrics - pub(crate) response: Forward, - /// Configuration to forward values from the context to custom attributes/labels in metrics - pub(crate) context: Vec, - /// Configuration to forward values from the error to custom attributes/labels in metrics - pub(crate) errors: ErrorsForward, -} - -#[derive(Clone, JsonSchema, Deserialize, Debug)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -/// Configuration to insert custom attributes/labels in metrics -pub(crate) struct Insert { - /// The name of the attribute to insert - pub(crate) name: String, - /// The value of the attribute to insert - pub(crate) value: AttributeValue, -} - -/// Configuration to forward from headers/body -#[derive(Debug, Clone, Deserialize, JsonSchema, Default)] -#[serde(deny_unknown_fields, default)] -pub(crate) struct Forward { - /// Forward header values as custom attributes/labels in metrics - pub(crate) header: Vec, - /// Forward body values as custom attributes/labels in metrics - pub(crate) body: Vec, -} - -#[derive(Debug, Clone, Deserialize, JsonSchema, Default)] -#[serde(deny_unknown_fields, default)] -pub(crate) struct ErrorsForward { - /// Will include the error message in a "message" attribute - pub(crate) include_messages: Option, - /// Forward extensions values as custom attributes/labels in metrics - pub(crate) extensions: Vec, -} - -schemar_fn!( - forward_header_matching, - String, - "Using a regex on the header name" -); - -#[derive(Clone, JsonSchema, Deserialize, Debug)] -#[serde(rename_all = "snake_case", deny_unknown_fields, untagged)] -/// Configuration to forward header values in metric labels -pub(crate) enum HeaderForward { - /// Match via header name - Named { - /// The name of the header - #[schemars(with = "String")] - #[serde(deserialize_with = "deserialize_header_name")] - named: HeaderName, - /// The optional output name - rename: Option, - /// The optional default value - default: Option, - }, - - /// Match via rgex - Matching { - /// Using a regex on the header name - #[schemars(schema_with = "forward_header_matching")] - #[serde(deserialize_with = "deserialize_regex")] - matching: Regex, - }, -} - -#[derive(Clone, JsonSchema, Deserialize, Debug)] -#[serde(deny_unknown_fields)] -/// Configuration to forward body values in metric attributes/labels -pub(crate) struct BodyForward { - /// The path in the body - #[schemars(with = "String")] - #[serde(deserialize_with = "deserialize_json_query")] - pub(crate) path: JSONQuery, - /// The name of the attribute - pub(crate) name: String, - /// The optional default value - pub(crate) default: Option, -} - -#[derive(Debug, Clone, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -/// Configuration to forward context values in metric attributes/labels -pub(crate) struct ContextForward { - /// The name of the value in the context - pub(crate) named: String, - /// The optional output name - pub(crate) rename: Option, - /// The optional default value - pub(crate) default: Option, -} - -impl HeaderForward { - pub(crate) fn get_attributes_from_headers( - &self, - headers: &HeaderMap, - ) -> HashMap { - let mut attributes = HashMap::new(); - match self { - HeaderForward::Named { - named, - rename, - default, - } => { - let value = headers.get(named); - if let Some(value) = value - .and_then(|v| { - v.to_str() - .ok() - .map(|v| AttributeValue::String(v.to_string())) - }) - .or_else(|| default.clone()) - { - attributes.insert(rename.clone().unwrap_or_else(|| named.to_string()), value); - } - } - HeaderForward::Matching { matching } => { - headers - .iter() - .filter(|(name, _)| matching.is_match(name.as_str())) - .for_each(|(name, value)| { - if let Ok(value) = value.to_str() { - attributes.insert( - name.to_string(), - AttributeValue::String(value.to_string()), - ); - } - }); - } - } - - attributes - } -} - -impl Forward { - pub(crate) fn merge(&mut self, to_merge: Self) { - self.body.extend(to_merge.body); - self.header.extend(to_merge.header); - } -} - -impl ErrorsForward { - pub(crate) fn merge(&mut self, to_merge: Self) { - self.extensions.extend(to_merge.extensions); - self.include_messages = to_merge.include_messages.or(self.include_messages); - } - - pub(crate) fn get_attributes_from_error( - &self, - err: &BoxError, - ) -> HashMap { - let mut attributes = HashMap::new(); - if let Some(fetch_error) = err - .source() - .and_then(|e| e.downcast_ref::()) - .or_else(|| err.downcast_ref::()) - { - let gql_error = fetch_error.to_graphql_error(None); - // Include error message - if self.include_messages.unwrap_or_default() { - attributes.insert( - "message".to_string(), - AttributeValue::String(gql_error.message), - ); - } - // Extract data from extensions - for ext_fw in &self.extensions { - let output = ext_fw.path.execute(&gql_error.extensions).unwrap(); - if let Some(val) = output { - if let Ok(val) = AttributeValue::try_from(val) { - attributes.insert(ext_fw.name.clone(), val); - } - } else if let Some(default_val) = &ext_fw.default { - attributes.insert(ext_fw.name.clone(), default_val.clone()); - } - } - } else if self.include_messages.unwrap_or_default() { - attributes.insert( - "message".to_string(), - AttributeValue::String(err.to_string()), - ); - } - - attributes - } -} - -impl AttributesForwardConf { - pub(crate) fn get_attributes_from_router_response( - &self, - parts: &Parts, - context: &Context, - first_response: &Option, - ) -> HashMap { - let mut attributes = HashMap::new(); - - // Fill from static - for Insert { name, value } in &self.insert { - attributes.insert(name.clone(), value.clone()); - } - // Fill from context - for ContextForward { - named, - default, - rename, - } in &self.context - { - match context.get::<_, AttributeValue>(named) { - Ok(Some(value)) => { - attributes.insert(rename.as_ref().unwrap_or(named).clone(), value); - } - _ => { - if let Some(default_val) = default { - attributes.insert( - rename.as_ref().unwrap_or(named).clone(), - default_val.clone(), - ); - } - } - }; - } - - // Fill from response - attributes.extend( - self.response - .header - .iter() - .fold(HashMap::new(), |mut acc, current| { - acc.extend(current.get_attributes_from_headers(&parts.headers)); - acc - }), - ); - - if let Some(body) = &first_response { - for body_fw in &self.response.body { - let output = body_fw.path.execute(body).unwrap(); - if let Some(val) = output { - if let Ok(val) = AttributeValue::try_from(val) { - attributes.insert(body_fw.name.clone(), val); - } - } else if let Some(default_val) = &body_fw.default { - attributes.insert(body_fw.name.clone(), default_val.clone()); - } - } - } - - attributes - } - - /// Get attributes from context - pub(crate) fn get_attributes_from_context( - &self, - context: &Context, - ) -> HashMap { - let mut attributes = HashMap::new(); - - for ContextForward { - named, - default, - rename, - } in &self.context - { - match context.get::<_, AttributeValue>(named) { - Ok(Some(value)) => { - attributes.insert(rename.as_ref().unwrap_or(named).clone(), value); - } - _ => { - if let Some(default_val) = default { - attributes.insert( - rename.as_ref().unwrap_or(named).clone(), - default_val.clone(), - ); - } - } - }; - } - - attributes - } - - pub(crate) fn get_attributes_from_response( - &self, - headers: &HeaderMap, - body: &T, - ) -> HashMap { - let mut attributes = HashMap::new(); - - // Fill from static - for Insert { name, value } in &self.insert { - attributes.insert(name.clone(), value.clone()); - } - - // Fill from response - attributes.extend( - self.response - .header - .iter() - .fold(HashMap::new(), |mut acc, current| { - acc.extend(current.get_attributes_from_headers(headers)); - acc - }), - ); - for body_fw in &self.response.body { - let output = body_fw.path.execute(body).unwrap(); - if let Some(val) = output { - if let Ok(val) = AttributeValue::try_from(val) { - attributes.insert(body_fw.name.clone(), val); - } - } else if let Some(default_val) = &body_fw.default { - attributes.insert(body_fw.name.clone(), default_val.clone()); - } - } - - attributes - } - - pub(crate) fn get_attributes_from_request( - &self, - headers: &HeaderMap, - body: &Request, - ) -> HashMap { - let mut attributes = HashMap::new(); - - // Fill from static - for Insert { name, value } in &self.insert { - attributes.insert(name.clone(), value.clone()); - } - // Fill from response - attributes.extend( - self.request - .header - .iter() - .fold(HashMap::new(), |mut acc, current| { - acc.extend(current.get_attributes_from_headers(headers)); - acc - }), - ); - for body_fw in &self.request.body { - let output = body_fw.path.execute(body).ok().flatten(); - if let Some(val) = output { - if let Ok(val) = AttributeValue::try_from(val) { - attributes.insert(body_fw.name.clone(), val); - } - } else if let Some(default_val) = &body_fw.default { - attributes.insert(body_fw.name.clone(), default_val.clone()); - } - } - - attributes - } - - pub(crate) fn get_attributes_from_error( - &self, - err: &BoxError, - ) -> HashMap { - self.errors.get_attributes_from_error(err) - } -} pub(crate) struct MetricsBuilder { - pub(crate) public_meter_provider_builder: opentelemetry::sdk::metrics::MeterProviderBuilder, - pub(crate) apollo_meter_provider_builder: opentelemetry::sdk::metrics::MeterProviderBuilder, - pub(crate) prometheus_meter_provider: Option, + pub(crate) public_meter_provider_builder: opentelemetry_sdk::metrics::MeterProviderBuilder, + pub(crate) apollo_meter_provider_builder: opentelemetry_sdk::metrics::MeterProviderBuilder, + pub(crate) apollo_realtime_meter_provider_builder: + opentelemetry_sdk::metrics::MeterProviderBuilder, + pub(crate) prometheus_meter_provider: Option, pub(crate) custom_endpoints: MultiMap, pub(crate) apollo_metrics_sender: Sender, pub(crate) resource: Resource, @@ -442,9 +34,11 @@ impl MetricsBuilder { Self { resource: resource.clone(), - public_meter_provider_builder: opentelemetry::sdk::metrics::MeterProvider::builder() + public_meter_provider_builder: opentelemetry_sdk::metrics::SdkMeterProvider::builder() .with_resource(resource.clone()), - apollo_meter_provider_builder: opentelemetry::sdk::metrics::MeterProvider::builder(), + apollo_meter_provider_builder: opentelemetry_sdk::metrics::SdkMeterProvider::builder(), + apollo_realtime_meter_provider_builder: + opentelemetry_sdk::metrics::SdkMeterProvider::builder(), prometheus_meter_provider: None, custom_endpoints: MultiMap::new(), apollo_metrics_sender: Sender::default(), @@ -489,7 +83,7 @@ impl AggregationSelector for CustomAggregationSelector { | InstrumentKind::UpDownCounter | InstrumentKind::ObservableCounter | InstrumentKind::ObservableUpDownCounter => Aggregation::Sum, - InstrumentKind::ObservableGauge => Aggregation::LastValue, + InstrumentKind::Gauge | InstrumentKind::ObservableGauge => Aggregation::LastValue, InstrumentKind::Histogram => Aggregation::ExplicitBucketHistogram { boundaries: self.boundaries.clone(), record_min_max: self.record_min_max, diff --git a/apollo-router/src/plugins/telemetry/metrics/otlp.rs b/apollo-router/src/plugins/telemetry/metrics/otlp.rs index 2ee503a53d..92e62768c6 100644 --- a/apollo-router/src/plugins/telemetry/metrics/otlp.rs +++ b/apollo-router/src/plugins/telemetry/metrics/otlp.rs @@ -1,7 +1,7 @@ -use opentelemetry::runtime; -use opentelemetry::sdk::metrics::PeriodicReader; -use opentelemetry::sdk::metrics::View; use opentelemetry_otlp::MetricsExporterBuilder; +use opentelemetry_sdk::metrics::PeriodicReader; +use opentelemetry_sdk::metrics::View; +use opentelemetry_sdk::runtime; use tower::BoxError; use crate::plugins::telemetry::config::MetricsCommon; diff --git a/apollo-router/src/plugins/telemetry/metrics/prometheus.rs b/apollo-router/src/plugins/telemetry/metrics/prometheus.rs index f14e9d637c..dd11211962 100644 --- a/apollo-router/src/plugins/telemetry/metrics/prometheus.rs +++ b/apollo-router/src/plugins/telemetry/metrics/prometheus.rs @@ -1,13 +1,14 @@ -use std::sync::Mutex; use std::task::Context; use std::task::Poll; use futures::future::BoxFuture; use http::StatusCode; use once_cell::sync::Lazy; -use opentelemetry::sdk::metrics::MeterProvider; -use opentelemetry::sdk::metrics::View; -use opentelemetry::sdk::Resource; +use opentelemetry_prometheus::ResourceSelector; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::metrics::SdkMeterProvider; +use opentelemetry_sdk::metrics::View; +use parking_lot::Mutex; use prometheus::Encoder; use prometheus::Registry; use prometheus::TextEncoder; @@ -17,6 +18,7 @@ use tower::BoxError; use tower::ServiceExt; use tower_service::Service; +use crate::ListenAddr; use crate::plugins::telemetry::config::MetricView; use crate::plugins::telemetry::config::MetricsCommon; use crate::plugins::telemetry::metrics::CustomAggregationSelector; @@ -24,8 +26,6 @@ use crate::plugins::telemetry::metrics::MetricsBuilder; use crate::plugins::telemetry::metrics::MetricsConfigurator; use crate::router_factory::Endpoint; use crate::services::router; -use crate::services::router::Body; -use crate::ListenAddr; /// Prometheus configuration #[derive(Debug, Clone, Deserialize, JsonSchema)] @@ -33,16 +33,38 @@ use crate::ListenAddr; pub(crate) struct Config { /// Set to true to enable pub(crate) enabled: bool, + /// resource_selector is used to select which resource to export with every metrics. + pub(crate) resource_selector: ResourceSelectorConfig, /// The listen address pub(crate) listen: ListenAddr, /// The path where prometheus will be exposed pub(crate) path: String, } +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ResourceSelectorConfig { + /// Export all resource attributes with every metrics. + All, + #[default] + /// Do not export any resource attributes with every metrics. + None, +} + +impl From for ResourceSelector { + fn from(value: ResourceSelectorConfig) -> Self { + match value { + ResourceSelectorConfig::All => ResourceSelector::All, + ResourceSelectorConfig::None => ResourceSelector::None, + } + } +} + impl Default for Config { fn default() -> Self { Self { enabled: false, + resource_selector: ResourceSelectorConfig::default(), listen: ListenAddr::SocketAddr("127.0.0.1:9090".parse().expect("valid listenAddr")), path: "/metrics".to_string(), } @@ -65,12 +87,9 @@ struct PrometheusConfig { } pub(crate) fn commit_prometheus() { - if let Some(prometheus) = NEW_PROMETHEUS.lock().expect("lock poisoned").take() { + if let Some(prometheus) = NEW_PROMETHEUS.lock().take() { tracing::debug!("committing prometheus registry"); - EXISTING_PROMETHEUS - .lock() - .expect("lock poisoned") - .replace(prometheus); + EXISTING_PROMETHEUS.lock().replace(prometheus); } } @@ -98,9 +117,7 @@ impl MetricsConfigurator for Config { // Note that during tests the prom registry cannot be reused as we have a different meter provider for each test. // Prom reloading IS tested in an integration test. #[cfg(not(test))] - if let Some((last_config, last_registry)) = - EXISTING_PROMETHEUS.lock().expect("lock poisoned").clone() - { + if let Some((last_config, last_registry)) = EXISTING_PROMETHEUS.lock().clone() { if prometheus_config == last_config { tracing::debug!("prometheus registry can be reused"); builder.custom_endpoints.insert( @@ -133,10 +150,11 @@ impl MetricsConfigurator for Config { .record_min_max(true) .build(), ) + .with_resource_selector(self.resource_selector) .with_registry(registry.clone()) .build()?; - let mut meter_provider_builder = MeterProvider::builder() + let mut meter_provider_builder = SdkMeterProvider::builder() .with_reader(exporter) .with_resource(builder.resource.clone()); for metric_view in metrics_config.views.clone() { @@ -156,10 +174,7 @@ impl MetricsConfigurator for Config { ); builder.prometheus_meter_provider = Some(meter_provider.clone()); - NEW_PROMETHEUS - .lock() - .expect("lock poisoned") - .replace((prometheus_config, registry)); + NEW_PROMETHEUS.lock().replace((prometheus_config, registry)); tracing::info!( "Prometheus endpoint exposed at {}{}", @@ -195,14 +210,17 @@ impl Service for PrometheusService { // Let's remove any problems they may have created for us. let stats = String::from_utf8_lossy(&result); let modified_stats = stats.replace("_total_total", "_total"); - Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::OK) - .header(http::header::CONTENT_TYPE, "text/plain; version=0.0.4") - .body::(modified_stats.into()) - .map_err(BoxError::from)?, - context: req.context, - }) + + router::Response::http_response_builder() + .response( + http::Response::builder() + .status(StatusCode::OK) + .header(http::header::CONTENT_TYPE, "text/plain; version=0.0.4") + .body(router::body::from_bytes(modified_stats)) + .map_err(BoxError::from)?, + ) + .context(req.context) + .build() }) } } diff --git a/apollo-router/src/plugins/telemetry/metrics/span_metrics_exporter.rs b/apollo-router/src/plugins/telemetry/metrics/span_metrics_exporter.rs deleted file mode 100644 index 6129b6b9e4..0000000000 --- a/apollo-router/src/plugins/telemetry/metrics/span_metrics_exporter.rs +++ /dev/null @@ -1,166 +0,0 @@ -use std::collections::HashSet; -use std::time::Instant; - -use opentelemetry_api::KeyValue; -use opentelemetry_api::Value; -use tracing_core::field::Visit; -use tracing_core::span; -use tracing_core::Field; -use tracing_core::Subscriber; -use tracing_subscriber::layer::Context; -use tracing_subscriber::registry::LookupSpan; -use tracing_subscriber::Layer; - -use crate::plugins::telemetry::consts::EXECUTION_SPAN_NAME; -use crate::plugins::telemetry::consts::QUERY_PLANNING_SPAN_NAME; -use crate::plugins::telemetry::consts::REQUEST_SPAN_NAME; -use crate::plugins::telemetry::consts::SUBGRAPH_SPAN_NAME; -use crate::plugins::telemetry::consts::SUPERGRAPH_SPAN_NAME; - -const SUBGRAPH_ATTRIBUTE_NAME: &str = "apollo.subgraph.name"; - -#[derive(Debug)] -pub(crate) struct SpanMetricsLayer { - span_names: HashSet<&'static str>, -} - -impl Default for SpanMetricsLayer { - fn default() -> Self { - Self { - span_names: [ - REQUEST_SPAN_NAME, - SUPERGRAPH_SPAN_NAME, - SUBGRAPH_SPAN_NAME, - QUERY_PLANNING_SPAN_NAME, - EXECUTION_SPAN_NAME, - ] - .into(), - } - } -} - -impl Layer for SpanMetricsLayer -where - S: Subscriber + for<'span> LookupSpan<'span>, -{ - fn on_new_span(&self, attrs: &span::Attributes<'_>, id: &span::Id, ctx: Context<'_, S>) { - let span = ctx.span(id).expect("Span not found, this is a bug"); - let mut extensions = span.extensions_mut(); - - let name = attrs.metadata().name(); - if self.span_names.contains(name) && extensions.get_mut::().is_none() { - let mut timings = Timings::new(); - if name == SUBGRAPH_SPAN_NAME { - attrs.values().record(&mut ValueVisitor { - timings: &mut timings, - }); - } - extensions.insert(Timings::new()); - } - } - - fn on_record(&self, _span: &span::Id, _values: &span::Record<'_>, _ctx: Context<'_, S>) {} - - fn on_close(&self, id: span::Id, ctx: Context<'_, S>) { - let span = ctx.span(&id).expect("Span not found, this is a bug"); - let mut extensions = span.extensions_mut(); - - if let Some(timings) = extensions.get_mut::() { - let duration = timings.start.elapsed().as_secs_f64(); - - // Convert it in seconds - let idle: f64 = timings.idle as f64 / 1_000_000_000_f64; - let busy: f64 = timings.busy as f64 / 1_000_000_000_f64; - let name = span.metadata().name(); - - if let Some(subgraph_name) = timings.subgraph.take() { - record(duration, "duration", name, Some(&subgraph_name)); - record(duration, "idle", name, Some(&subgraph_name)); - record(duration, "busy", name, Some(&subgraph_name)); - } else { - record(duration, "duration", name, None); - record(idle, "idle", name, None); - record(busy, "busy", name, None); - } - } - } - - fn on_enter(&self, id: &span::Id, ctx: Context<'_, S>) { - let span = ctx.span(id).expect("Span not found, this is a bug"); - let mut extensions = span.extensions_mut(); - - if let Some(timings) = extensions.get_mut::() { - let now = Instant::now(); - timings.idle += (now - timings.last).as_nanos() as i64; - timings.last = now; - } - } - - fn on_exit(&self, id: &span::Id, ctx: Context<'_, S>) { - let span = ctx.span(id).expect("Span not found, this is a bug"); - let mut extensions = span.extensions_mut(); - - if let Some(timings) = extensions.get_mut::() { - let now = Instant::now(); - timings.busy += (now - timings.last).as_nanos() as i64; - timings.last = now; - } - } -} - -fn record(duration: f64, kind: &'static str, name: &str, subgraph_name: Option<&str>) { - // Avoid a heap allocation for a vec by using a slice - let attrs = [ - KeyValue::new("kind", kind), - KeyValue::new("span", Value::String(name.to_string().into())), - KeyValue::new( - "subgraph", - Value::String( - subgraph_name - .map(|s| s.to_string().into()) - .unwrap_or_else(|| "".into()), - ), - ), - ]; - let splice = if subgraph_name.is_some() { - &attrs - } else { - &attrs[0..2] - }; - - f64_histogram!("apollo_router_span", "Duration of span", duration, splice); -} - -struct Timings { - idle: i64, - busy: i64, - last: Instant, - start: Instant, - subgraph: Option, -} - -impl Timings { - fn new() -> Self { - Self { - idle: 0, - busy: 0, - last: Instant::now(), - start: Instant::now(), - subgraph: None, - } - } -} - -struct ValueVisitor<'a> { - timings: &'a mut Timings, -} - -impl<'a> Visit for ValueVisitor<'a> { - fn record_debug(&mut self, _field: &Field, _value: &dyn std::fmt::Debug) {} - - fn record_str(&mut self, field: &Field, value: &str) { - if field.name() == SUBGRAPH_ATTRIBUTE_NAME { - self.timings.subgraph = Some(value.to_string()); - } - } -} diff --git a/apollo-router/src/plugins/telemetry/mod.rs b/apollo-router/src/plugins/telemetry/mod.rs index b6a0e7a1a2..dfda213417 100644 --- a/apollo-router/src/plugins/telemetry/mod.rs +++ b/apollo-router/src/plugins/telemetry/mod.rs @@ -3,55 +3,56 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::fmt; use std::sync::Arc; +use std::sync::atomic::AtomicU64; use std::time::Duration; use std::time::Instant; -use ::tracing::info_span; use ::tracing::Span; -use axum::headers::HeaderName; +use ::tracing::info_span; +use axum_extra::headers::HeaderName; +use config_new::Selectors; use config_new::cache::CacheInstruments; +use config_new::connector::instruments::ConnectorInstruments; use config_new::instruments::InstrumentsConfig; use config_new::instruments::StaticInstrument; -use config_new::Selectors; -use dashmap::DashMap; -use futures::future::ready; +use error_handler::handle_error; +use futures::StreamExt; use futures::future::BoxFuture; +use futures::future::ready; use futures::stream::once; -use futures::StreamExt; -use http::header; use http::HeaderMap; use http::HeaderValue; use http::StatusCode; +use http::header; use metrics::apollo::studio::SingleLimitsStats; use metrics::local_type_stats::LocalTypeStatRecorder; use multimap::MultiMap; -use once_cell::sync::OnceCell; +use opentelemetry::Key; +use opentelemetry::KeyValue; use opentelemetry::global::GlobalTracerProvider; -use opentelemetry::metrics::MetricsError; -use opentelemetry::propagation::text_map_propagator::FieldIter; +use opentelemetry::metrics::MeterProvider; +use opentelemetry::metrics::ObservableGauge; use opentelemetry::propagation::Extractor; use opentelemetry::propagation::Injector; +use opentelemetry::propagation::TextMapCompositePropagator; use opentelemetry::propagation::TextMapPropagator; -use opentelemetry::sdk::propagation::TextMapCompositePropagator; -use opentelemetry::sdk::trace::Builder; +use opentelemetry::propagation::text_map_propagator::FieldIter; use opentelemetry::trace::SpanContext; use opentelemetry::trace::SpanId; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceFlags; +use opentelemetry::trace::TraceId; use opentelemetry::trace::TraceState; use opentelemetry::trace::TracerProvider; -use opentelemetry::Key; -use opentelemetry::KeyValue; -use opentelemetry_api::trace::TraceId; +use opentelemetry_sdk::trace::Builder; use opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD; use parking_lot::Mutex; use parking_lot::RwLock; use rand::Rng; -use router_bridge::planner::UsageReporting; -use serde_json_bytes::json; use serde_json_bytes::ByteString; use serde_json_bytes::Map; use serde_json_bytes::Value; +use serde_json_bytes::json; use tokio::runtime::Handle; use tower::BoxError; use tower::ServiceBuilder; @@ -62,50 +63,53 @@ use self::apollo::ForwardValues; use self::apollo::LicensedOperationCountByType; use self::apollo::OperationSubType; use self::apollo::SingleReport; -use self::apollo_exporter::proto; use self::apollo_exporter::Sender; +use self::apollo_exporter::proto; use self::config::Conf; -use self::config::Sampler; -use self::config::SamplerOption; use self::config::TraceIdFormat; -use self::config_new::events::RouterEvents; -use self::config_new::events::SubgraphEvents; -use self::config_new::events::SupergraphEvents; use self::config_new::instruments::Instrumented; -use self::config_new::instruments::RouterInstruments; -use self::config_new::instruments::SubgraphInstruments; +use self::config_new::router::events::RouterEvents; +use self::config_new::router::instruments::RouterInstruments; use self::config_new::spans::Spans; +use self::config_new::subgraph::events::SubgraphEvents; +use self::config_new::subgraph::instruments::SubgraphInstruments; +use self::config_new::supergraph::events::SupergraphEvents; use self::metrics::apollo::studio::SingleTypeStat; -use self::metrics::AttributesForwardConf; use self::reload::reload_fmt; pub(crate) use self::span_factory::SpanMode; use self::tracing::apollo_telemetry::APOLLO_PRIVATE_DURATION_NS; use self::tracing::apollo_telemetry::CLIENT_NAME_KEY; use self::tracing::apollo_telemetry::CLIENT_VERSION_KEY; +use crate::Context; +use crate::ListenAddr; use crate::apollo_studio_interop::ExtendedReferenceStats; use crate::apollo_studio_interop::ReferencedEnums; -use crate::context::CONTAINS_GRAPHQL_ERROR; +use crate::apollo_studio_interop::UsageReporting; use crate::context::OPERATION_KIND; use crate::context::OPERATION_NAME; use crate::graphql::ResponseVisitor; -use crate::layers::instrument::InstrumentLayer; use crate::layers::ServiceBuilderExt; +use crate::layers::instrument::InstrumentLayer; use crate::metrics::aggregation::MeterProviderType; use crate::metrics::filter::FilterMeterProvider; use crate::metrics::meter_provider; -use crate::plugin::Plugin; +use crate::metrics::meter_provider_internal; use crate::plugin::PluginInit; +use crate::plugin::PluginPrivate; use crate::plugins::telemetry::apollo::ForwardHeaders; -use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::node::Id::ResponseName; use crate::plugins::telemetry::apollo_exporter::proto::reports::StatsContext; +use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::node::Id::ResponseName; use crate::plugins::telemetry::config::AttributeValue; use crate::plugins::telemetry::config::MetricsCommon; use crate::plugins::telemetry::config::TracingCommon; +use crate::plugins::telemetry::config_new::DatadogId; +use crate::plugins::telemetry::config_new::apollo::instruments::ApolloConnectorInstruments; +use crate::plugins::telemetry::config_new::apollo::instruments::ApolloSubgraphInstruments; +use crate::plugins::telemetry::config_new::connector::events::ConnectorEvents; use crate::plugins::telemetry::config_new::cost::add_cost_attributes; use crate::plugins::telemetry::config_new::graphql::GraphQLInstruments; use crate::plugins::telemetry::config_new::instruments::SupergraphInstruments; use crate::plugins::telemetry::config_new::trace_id; -use crate::plugins::telemetry::config_new::DatadogId; use crate::plugins::telemetry::consts::EXECUTION_SPAN_NAME; use crate::plugins::telemetry::consts::OTEL_NAME; use crate::plugins::telemetry::consts::OTEL_STATUS_CODE; @@ -114,7 +118,13 @@ use crate::plugins::telemetry::consts::OTEL_STATUS_CODE_OK; use crate::plugins::telemetry::consts::REQUEST_SPAN_NAME; use crate::plugins::telemetry::consts::ROUTER_SPAN_NAME; use crate::plugins::telemetry::dynamic_attribute::SpanDynAttribute; +use crate::plugins::telemetry::error_counter::count_execution_errors; +use crate::plugins::telemetry::error_counter::count_router_errors; +use crate::plugins::telemetry::error_counter::count_subgraph_errors; +use crate::plugins::telemetry::error_counter::count_supergraph_errors; use crate::plugins::telemetry::fmt_layer::create_fmt_layer; +use crate::plugins::telemetry::metrics::MetricsBuilder; +use crate::plugins::telemetry::metrics::MetricsConfigurator; use crate::plugins::telemetry::metrics::apollo::histogram::ListLengthHistogram; use crate::plugins::telemetry::metrics::apollo::studio::LocalTypeStat; use crate::plugins::telemetry::metrics::apollo::studio::SingleContextualizedStats; @@ -123,32 +133,28 @@ use crate::plugins::telemetry::metrics::apollo::studio::SingleQueryLatencyStats; use crate::plugins::telemetry::metrics::apollo::studio::SingleStats; use crate::plugins::telemetry::metrics::apollo::studio::SingleStatsReport; use crate::plugins::telemetry::metrics::prometheus::commit_prometheus; -use crate::plugins::telemetry::metrics::MetricsBuilder; -use crate::plugins::telemetry::metrics::MetricsConfigurator; use crate::plugins::telemetry::otel::OpenTelemetrySpanExt; -use crate::plugins::telemetry::reload::metrics_layer; use crate::plugins::telemetry::reload::OPENTELEMETRY_TRACER_HANDLE; -use crate::plugins::telemetry::tracing::apollo_telemetry::decode_ftv1_trace; -use crate::plugins::telemetry::tracing::apollo_telemetry::APOLLO_PRIVATE_OPERATION_SIGNATURE; use crate::plugins::telemetry::tracing::TracingConfigurator; -use crate::plugins::telemetry::utils::TracingUtils; +use crate::plugins::telemetry::tracing::apollo_telemetry::APOLLO_PRIVATE_OPERATION_SIGNATURE; +use crate::plugins::telemetry::tracing::apollo_telemetry::decode_ftv1_trace; use crate::query_planner::OperationKind; -use crate::register_plugin; +use crate::register_private_plugin; use crate::router_factory::Endpoint; -use crate::services::execution; -use crate::services::router; -use crate::services::subgraph; -use crate::services::subgraph::Request; -use crate::services::subgraph::Response; -use crate::services::supergraph; use crate::services::ExecutionRequest; +use crate::services::ExecutionResponse; use crate::services::SubgraphRequest; use crate::services::SubgraphResponse; use crate::services::SupergraphRequest; use crate::services::SupergraphResponse; +use crate::services::connector; +use crate::services::execution; +use crate::services::layers::apq::PERSISTED_QUERY_CACHE_HIT; +use crate::services::layers::persisted_queries::UsedQueryIdFromManifest; +use crate::services::router; +use crate::services::subgraph; +use crate::services::supergraph; use crate::spec::operation_limits::OperationLimits; -use crate::Context; -use crate::ListenAddr; pub(crate) mod apollo; pub(crate) mod apollo_exporter; @@ -158,6 +164,8 @@ pub(crate) mod config_new; pub(crate) mod consts; pub(crate) mod dynamic_attribute; mod endpoint; +mod error_counter; +mod error_handler; mod fmt_layer; pub(crate) mod formatters; mod logging; @@ -166,18 +174,19 @@ pub(crate) mod metrics; pub(crate) mod otel; mod otlp; pub(crate) mod reload; -mod resource; +pub(crate) mod resource; +pub(crate) mod span_ext; mod span_factory; pub(crate) mod tracing; pub(crate) mod utils; // Tracing consts -const CLIENT_NAME: &str = "apollo_telemetry::client_name"; -const CLIENT_VERSION: &str = "apollo_telemetry::client_version"; -const SUBGRAPH_FTV1: &str = "apollo_telemetry::subgraph_ftv1"; -pub(crate) const STUDIO_EXCLUDE: &str = "apollo_telemetry::studio::exclude"; -pub(crate) const LOGGING_DISPLAY_HEADERS: &str = "apollo_telemetry::logging::display_headers"; -pub(crate) const LOGGING_DISPLAY_BODY: &str = "apollo_telemetry::logging::display_body"; +pub(crate) const CLIENT_NAME: &str = "apollo::telemetry::client_name"; +pub(crate) const CLIENT_LIBRARY_NAME: &str = "apollo::telemetry::client_library_name"; +pub(crate) const CLIENT_VERSION: &str = "apollo::telemetry::client_version"; +pub(crate) const CLIENT_LIBRARY_VERSION: &str = "apollo::telemetry::client_library_version"; +pub(crate) const SUBGRAPH_FTV1: &str = "apollo::telemetry::subgraph_ftv1"; +pub(crate) const STUDIO_EXCLUDE: &str = "apollo::telemetry::studio_exclude"; pub(crate) const SUPERGRAPH_SCHEMA_ID_CONTEXT_KEY: &str = "apollo::supergraph_schema_id"; const GLOBAL_TRACER_NAME: &str = "apollo-router"; const DEFAULT_EXPOSE_TRACE_ID_HEADER: &str = "apollo-trace-id"; @@ -195,6 +204,14 @@ pub(crate) const APOLLO_PRIVATE_QUERY_HEIGHT: Key = pub(crate) const APOLLO_PRIVATE_QUERY_ROOT_FIELDS: Key = Key::from_static_str("apollo_private.query.root_fields"); +// Standard Apollo Otel Metric Attribute Names +pub(crate) const APOLLO_CLIENT_NAME_ATTRIBUTE: &str = "apollo.client.name"; +pub(crate) const APOLLO_CLIENT_VERSION_ATTRIBUTE: &str = "apollo.client.version"; +pub(crate) const GRAPHQL_OPERATION_NAME_ATTRIBUTE: &str = "graphql.operation.name"; +pub(crate) const GRAPHQL_OPERATION_TYPE_ATTRIBUTE: &str = "graphql.operation.type"; +pub(crate) const APOLLO_OPERATION_ID_ATTRIBUTE: &str = "apollo.operation.id"; +pub(crate) const APOLLO_HAS_ERRORS_ATTRIBUTE: &str = "has_errors"; + #[doc(hidden)] // Only public for integration tests pub(crate) struct Telemetry { pub(crate) config: Arc, @@ -202,21 +219,18 @@ pub(crate) struct Telemetry { custom_endpoints: MultiMap, apollo_metrics_sender: apollo_exporter::Sender, field_level_instrumentation_ratio: f64, - sampling_filter_ratio: SamplerOption, - pub(crate) graphql_custom_instruments: RwLock>>, - router_custom_instruments: RwLock>>, - supergraph_custom_instruments: RwLock>>, - subgraph_custom_instruments: RwLock>>, - cache_custom_instruments: RwLock>>, + builtin_instruments: RwLock, activation: Mutex, + enabled_features: EnabledFeatures, } struct TelemetryActivation { - tracer_provider: Option, + tracer_provider: Option, // We have to have separate meter providers for prometheus metrics so that they don't get zapped on router reload. public_meter_provider: Option, public_prometheus_meter_provider: Option, private_meter_provider: Option, + private_realtime_meter_provider: Option, is_active: bool, } @@ -246,7 +260,8 @@ fn setup_metrics_exporter( impl Drop for Telemetry { fn drop(&mut self) { let mut activation = self.activation.lock(); - let metrics_providers: [Option; 3] = [ + let metrics_providers: [Option; 4] = [ + activation.private_realtime_meter_provider.take(), activation.private_meter_provider.take(), activation.public_meter_provider.take(), activation.public_prometheus_meter_provider.take(), @@ -261,12 +276,55 @@ impl Drop for Telemetry { } } +/// When observed, it reports the most recently stored value (give or take atomicity looseness). +/// +/// This *could* be generalised to any kind of gauge, but we should ideally have gauges that can just +/// observe their accurate value whenever requested. The externally updateable approach is kind of +/// a hack that happens to work here because we only have one place where the value can change, and +/// otherwise we might have to use an inconvenient Mutex or RwLock around the entire LRU cache. +#[derive(Debug, Clone)] +pub(crate) struct LruSizeInstrument { + value: Arc, + _gauge: ObservableGauge, +} + +impl LruSizeInstrument { + pub(crate) fn new(gauge_name: &'static str) -> Self { + let value = Arc::new(AtomicU64::new(0)); + + let meter = meter_provider().meter("apollo/router"); + let gauge = meter + .u64_observable_gauge(gauge_name) + .with_callback({ + let value = Arc::clone(&value); + move |gauge| { + gauge.observe(value.load(std::sync::atomic::Ordering::Relaxed), &[]); + } + }) + .init(); + + Self { + value, + _gauge: gauge, + } + } + + pub(crate) fn update(&self, value: u64) { + self.value + .store(value, std::sync::atomic::Ordering::Relaxed); + } +} + struct BuiltinInstruments { graphql_custom_instruments: Arc>, router_custom_instruments: Arc>, supergraph_custom_instruments: Arc>, subgraph_custom_instruments: Arc>, + apollo_subgraph_instruments: Arc>, + connector_custom_instruments: Arc>, + apollo_connector_instruments: Arc>, cache_custom_instruments: Arc>, + _pipeline_instruments: Arc>, } fn create_builtin_instruments(config: &InstrumentsConfig) -> BuiltinInstruments { @@ -275,12 +333,36 @@ fn create_builtin_instruments(config: &InstrumentsConfig) -> BuiltinInstruments router_custom_instruments: Arc::new(config.new_builtin_router_instruments()), supergraph_custom_instruments: Arc::new(config.new_builtin_supergraph_instruments()), subgraph_custom_instruments: Arc::new(config.new_builtin_subgraph_instruments()), + apollo_subgraph_instruments: Arc::new(config.new_builtin_apollo_subgraph_instruments()), + connector_custom_instruments: Arc::new(config.new_builtin_connector_instruments()), + apollo_connector_instruments: Arc::new(config.new_builtin_apollo_connector_instruments()), cache_custom_instruments: Arc::new(config.new_builtin_cache_instruments()), + _pipeline_instruments: Arc::new(config.new_pipeline_instruments()), + } +} + +#[derive(Clone, Debug)] +struct EnabledFeatures { + distributed_apq_cache: bool, + entity_cache: bool, +} + +impl EnabledFeatures { + fn list(&self) -> Vec { + // Map enabled features to their names for usage reports + [ + ("distributed_apq_cache", self.distributed_apq_cache), + ("entity_cache", self.entity_cache), + ] + .iter() + .filter(|&&(_, enabled)| enabled) + .map(&|(name, _): &(&str, _)| name.to_string()) + .collect() } } #[async_trait::async_trait] -impl Plugin for Telemetry { +impl PluginPrivate for Telemetry { type Config = config::Conf; async fn new(init: PluginInit) -> Result { @@ -290,28 +372,30 @@ impl Plugin for Telemetry { let mut config = init.config; config.instrumentation.spans.update_defaults(); config.instrumentation.instruments.update_defaults(); - config.exporters.logging.validate()?; if let Err(err) = config.instrumentation.validate() { - ::tracing::warn!("Potential configuration error for 'instrumentation': {err}, please check the documentation on https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/events"); + ::tracing::warn!( + "Potential configuration error for 'instrumentation': {err}, please check the documentation on https://www.apollographql.com/docs/router/configuration/telemetry/instrumentation/events" + ); } let field_level_instrumentation_ratio = config.calculate_field_level_instrumentation_ratio()?; let metrics_builder = Self::create_metrics_builder(&config)?; - - let (sampling_filter_ratio, tracer_provider) = Self::create_tracer_provider(&config)?; + let tracer_provider = Self::create_tracer_provider(&config)?; if config.instrumentation.spans.mode == SpanMode::Deprecated { - ::tracing::warn!("telemetry.instrumentation.spans.mode is currently set to 'deprecated', either explicitly or via defaulting. Set telemetry.instrumentation.spans.mode explicitly in your router.yaml to 'spec_compliant' for log and span attributes that follow OpenTelemetry semantic conventions. This option will be defaulted to 'spec_compliant' in a future release and eventually removed altogether"); + ::tracing::warn!( + "telemetry.instrumentation.spans.mode is currently set to 'deprecated', either explicitly or via defaulting. Set telemetry.instrumentation.spans.mode explicitly in your router.yaml to 'spec_compliant' for log and span attributes that follow OpenTelemetry semantic conventions. This option will be defaulted to 'spec_compliant' in a future release and eventually removed altogether" + ); } - let BuiltinInstruments { - graphql_custom_instruments, - router_custom_instruments, - supergraph_custom_instruments, - subgraph_custom_instruments, - cache_custom_instruments, - } = create_builtin_instruments(&config.instrumentation.instruments); + // Set up feature usage list + let full_config = init + .full_config + .as_ref() + .expect("Required full router configuration not found in telemetry plugin"); + let enabled_features = Self::extract_enabled_features(full_config); + ::tracing::debug!("Enabled scale features: {:?}", enabled_features); Ok(Telemetry { custom_endpoints: metrics_builder.custom_endpoints, @@ -326,17 +410,20 @@ impl Plugin for Telemetry { private_meter_provider: Some(FilterMeterProvider::private( metrics_builder.apollo_meter_provider_builder.build(), )), + private_realtime_meter_provider: Some(FilterMeterProvider::private_realtime( + metrics_builder + .apollo_realtime_meter_provider_builder + .build(), + )), public_prometheus_meter_provider: metrics_builder .prometheus_meter_provider .map(FilterMeterProvider::public), is_active: false, }), - graphql_custom_instruments: RwLock::new(graphql_custom_instruments), - router_custom_instruments: RwLock::new(router_custom_instruments), - supergraph_custom_instruments: RwLock::new(supergraph_custom_instruments), - subgraph_custom_instruments: RwLock::new(subgraph_custom_instruments), - cache_custom_instruments: RwLock::new(cache_custom_instruments), - sampling_filter_ratio, + builtin_instruments: RwLock::new(create_builtin_instruments( + &config.instrumentation.instruments, + )), + enabled_features, config: Arc::new(config), }) } @@ -349,42 +436,44 @@ impl Plugin for Telemetry { let span_mode = config.instrumentation.spans.mode; let use_legacy_request_span = matches!(config.instrumentation.spans.mode, SpanMode::Deprecated); + let enabled_features = self.enabled_features.clone(); let field_level_instrumentation_ratio = self.field_level_instrumentation_ratio; let metrics_sender = self.apollo_metrics_sender.clone(); - let static_router_instruments = self.router_custom_instruments.read().clone(); + let static_router_instruments = self + .builtin_instruments + .read() + .router_custom_instruments + .clone(); ServiceBuilder::new() .map_response(move |response: router::Response| { // The current span *should* be the request span as we are outside the instrument block. let span = Span::current(); - if let Some(span_name) = span.metadata().map(|metadata| metadata.name()) { - if (use_legacy_request_span && span_name == REQUEST_SPAN_NAME) - || (!use_legacy_request_span && span_name == ROUTER_SPAN_NAME) - { - //https://opentelemetry.io/docs/specs/otel/trace/semantic_conventions/instrumentation/graphql/ - let operation_kind = response.context.get::<_, String>(OPERATION_KIND); - let operation_name = response.context.get::<_, String>(OPERATION_NAME); - - if let Ok(Some(operation_kind)) = &operation_kind { - span.record("graphql.operation.type", operation_kind); - } - if let Ok(Some(operation_name)) = &operation_name { - span.record("graphql.operation.name", operation_name); - } - match (&operation_kind, &operation_name) { - (Ok(Some(kind)), Ok(Some(name))) => span.set_span_dyn_attribute( - OTEL_NAME.into(), - format!("{kind} {name}").into(), - ), - (Ok(Some(kind)), _) => { - span.set_span_dyn_attribute(OTEL_NAME.into(), kind.clone().into()) - } - _ => span.set_span_dyn_attribute( - OTEL_NAME.into(), - "GraphQL Operation".into(), - ), - }; + if let Some(span_name) = span.metadata().map(|metadata| metadata.name()) + && ((use_legacy_request_span && span_name == REQUEST_SPAN_NAME) + || (!use_legacy_request_span && span_name == ROUTER_SPAN_NAME)) + { + //https://opentelemetry.io/docs/specs/otel/trace/semantic_conventions/instrumentation/graphql/ + let operation_kind = response.context.get::<_, String>(OPERATION_KIND); + let operation_name = response.context.get::<_, String>(OPERATION_NAME); + + if let Ok(Some(operation_kind)) = &operation_kind { + span.record("graphql.operation.type", operation_kind); } + if let Ok(Some(operation_name)) = &operation_name { + span.record("graphql.operation.name", operation_name); + } + match (&operation_kind, &operation_name) { + (Ok(Some(kind)), Ok(Some(name))) => span.set_span_dyn_attribute( + OTEL_NAME.into(), + format!("{kind} {name}").into(), + ), + (Ok(Some(kind)), _) => { + span.set_span_dyn_attribute(OTEL_NAME.into(), kind.clone().into()) + } + _ => span + .set_span_dyn_attribute(OTEL_NAME.into(), "GraphQL Operation".into()), + }; } response @@ -404,7 +493,7 @@ impl Plugin for Telemetry { let span = Span::current(); span.set_span_dyn_attribute( - HTTP_REQUEST_METHOD, + HTTP_REQUEST_METHOD.into(), request.router_request.method().to_string().into(), ); } @@ -435,17 +524,13 @@ impl Plugin for Telemetry { .attributes .on_request(request); - custom_attributes.extend([ - KeyValue::new(CLIENT_NAME_KEY, client_name.unwrap_or("").to_string()), - KeyValue::new(CLIENT_VERSION_KEY, client_version.unwrap_or("").to_string()), - KeyValue::new( - Key::from_static_str("apollo_private.http.request_headers"), - filter_headers( - request.router_request.headers(), - &config_request.apollo.send_headers, - ), + custom_attributes.push(KeyValue::new( + Key::from_static_str("apollo_private.http.request_headers"), + filter_headers( + request.router_request.headers(), + &config_request.apollo.send_headers, ), - ]); + )); let custom_instruments: RouterInstruments = config_request .instrumentation @@ -453,7 +538,7 @@ impl Plugin for Telemetry { .new_router_instruments(static_router_instruments.clone()); custom_instruments.on_request(request); - let custom_events: RouterEvents = + let mut custom_events: RouterEvents = config_request.instrumentation.events.new_router_events(); custom_events.on_request(request); @@ -464,7 +549,7 @@ impl Plugin for Telemetry { request.context.clone(), ) }, - move |(custom_attributes, custom_instruments, custom_events, ctx): ( + move |(mut custom_attributes, custom_instruments, mut custom_events, ctx): ( Vec, RouterInstruments, RouterEvents, @@ -474,10 +559,33 @@ impl Plugin for Telemetry { let start = Instant::now(); let config = config_later.clone(); let sender = metrics_sender.clone(); + let enabled_features = enabled_features.clone(); Self::plugin_metrics(&config); async move { + // NB: client name and version must be picked up here, rather than in the + // `req_fn` of this `map_future_with_request_data` call, to allow plugins + // at the router service to modify the name and version. + let get_from_context = + |ctx: &Context, key| ctx.get::<&str, String>(key).ok().flatten(); + let client_name = get_from_context(&ctx, CLIENT_NAME).or_else(|| { + get_from_context( + &ctx, + crate::context::deprecated::DEPRECATED_CLIENT_NAME, + ) + }); + let client_version = get_from_context(&ctx, CLIENT_VERSION).or_else(|| { + get_from_context( + &ctx, + crate::context::deprecated::DEPRECATED_CLIENT_VERSION, + ) + }); + custom_attributes.extend([ + KeyValue::new(CLIENT_NAME_KEY, client_name.unwrap_or_default()), + KeyValue::new(CLIENT_VERSION_KEY, client_version.unwrap_or_default()), + ]); + let span = Span::current(); span.set_span_dyn_attributes(custom_attributes); let response: Result = fut.await; @@ -523,12 +631,7 @@ impl Plugin for Telemetry { if response.context.extensions().with_lock(|lock| { lock.get::>() - .map(|u| { - u.stats_report_key == "## GraphQLValidationFailure\n" - || u.stats_report_key == "## GraphQLParseFailure\n" - || u.stats_report_key - == "## GraphQLUnknownOperationName\n" - }) + .map(|u| matches!(**u, UsageReporting::Error { .. })) .unwrap_or(false) }) { Self::update_apollo_metrics( @@ -541,6 +644,7 @@ impl Plugin for Telemetry { OperationKind::Query, None, Default::default(), + enabled_features.clone(), ); } @@ -563,7 +667,11 @@ impl Plugin for Telemetry { custom_events.on_error(err, &ctx); } - response + if let Ok(resp) = response { + Ok(count_router_errors(resp, &config.apollo.errors).await) + } else { + response + } } }, ) @@ -578,71 +686,83 @@ impl Plugin for Telemetry { let config_instrument = self.config.clone(); let config_map_res_first = config.clone(); let config_map_res = config.clone(); + let enabled_features = self.enabled_features.clone(); let field_level_instrumentation_ratio = self.field_level_instrumentation_ratio; - let static_supergraph_instruments = self.supergraph_custom_instruments.read().clone(); - let static_graphql_instruments = self.graphql_custom_instruments.read().clone(); + let static_supergraph_instruments = self + .builtin_instruments + .read() + .supergraph_custom_instruments + .clone(); + let static_graphql_instruments = self + .builtin_instruments + .read() + .graphql_custom_instruments + .clone(); ServiceBuilder::new() - .instrument(move |supergraph_req: &SupergraphRequest| span_mode.create_supergraph( - &config_instrument.apollo, - supergraph_req, - field_level_instrumentation_ratio, - )) + .instrument(move |supergraph_req: &SupergraphRequest| { + span_mode.create_supergraph( + &config_instrument.apollo, + supergraph_req, + field_level_instrumentation_ratio, + ) + }) .map_response(move |mut resp: SupergraphResponse| { let config = config_map_res_first.clone(); - if let Some(usage_reporting) = resp.context.extensions().with_lock(|lock| lock.get::>().cloned()) { + if let Some(usage_reporting) = resp + .context + .extensions() + .with_lock(|lock| lock.get::>().cloned()) + { // Record the operation signature on the router span Span::current().record( APOLLO_PRIVATE_OPERATION_SIGNATURE.as_str(), - usage_reporting.stats_report_key.as_str(), + usage_reporting.get_stats_report_key().as_str(), ); } // To expose trace_id or not - let expose_trace_id_header = config.exporters.tracing.response_trace_id.enabled.then(|| { - config.exporters.tracing.response_trace_id - .header_name - .clone() - .unwrap_or_else(|| DEFAULT_EXPOSE_TRACE_ID_HEADER_NAME.clone()) - }); + let expose_trace_id_header = + config.exporters.tracing.response_trace_id.enabled.then(|| { + config + .exporters + .tracing + .response_trace_id + .header_name + .clone() + .unwrap_or_else(|| DEFAULT_EXPOSE_TRACE_ID_HEADER_NAME.clone()) + }); // Append the trace ID with the right format, based on the config let format_id = |trace_id: TraceId| { let id = match config.exporters.tracing.response_trace_id.format { - TraceIdFormat::Hexadecimal | TraceIdFormat::OpenTelemetry => format!("{:032x}", trace_id), - TraceIdFormat::Decimal => format!("{}", u128::from_be_bytes(trace_id.to_bytes())), + TraceIdFormat::Hexadecimal | TraceIdFormat::OpenTelemetry => { + format!("{trace_id:032x}") + } + TraceIdFormat::Decimal => { + format!("{}", u128::from_be_bytes(trace_id.to_bytes())) + } TraceIdFormat::Datadog => trace_id.to_datadog(), TraceIdFormat::Uuid => Uuid::from_bytes(trace_id.to_bytes()).to_string(), }; HeaderValue::from_str(&id).ok() }; - if let (Some(header_name), Some(trace_id)) = ( - expose_trace_id_header, - trace_id().and_then(format_id), - ) { + if let (Some(header_name), Some(trace_id)) = + (expose_trace_id_header, trace_id().and_then(format_id)) + { resp.response.headers_mut().append(header_name, trace_id); } - if resp.context.contains_key(LOGGING_DISPLAY_HEADERS) { - let sorted_headers = resp - .response - .headers() - .iter() - .map(|(k, v)| (k.as_str(), v)) - .collect::>(); - ::tracing::info!(http.response.headers = ?sorted_headers, "Supergraph response headers"); - } - let display_body = resp.context.contains_key(LOGGING_DISPLAY_BODY); - resp.map_stream(move |gql_response| { - if display_body { - ::tracing::info!(http.response.body = ?gql_response, "Supergraph GraphQL response"); - } - gql_response - }) + resp }) .map_future_with_request_data( move |req: &SupergraphRequest| { - let custom_attributes = config.instrumentation.spans.supergraph.attributes.on_request(req); - Self::populate_context(config.clone(), field_level_instrumentation_ratio, req); + let custom_attributes = config + .instrumentation + .spans + .supergraph + .attributes + .on_request(req); + Self::populate_context(field_level_instrumentation_ratio, req); let custom_instruments = config .instrumentation .instruments @@ -650,51 +770,98 @@ impl Plugin for Telemetry { custom_instruments.on_request(req); let custom_graphql_instruments: GraphQLInstruments = config .instrumentation - .instruments.new_graphql_instruments(static_graphql_instruments.clone()); + .instruments + .new_graphql_instruments(static_graphql_instruments.clone()); custom_graphql_instruments.on_request(req); - let supergraph_events = config.instrumentation.events.new_supergraph_events(); + let mut supergraph_events = + config.instrumentation.events.new_supergraph_events(); supergraph_events.on_request(req); - (req.context.clone(), custom_instruments, custom_attributes, supergraph_events, custom_graphql_instruments) + ( + req.context.clone(), + custom_instruments, + custom_attributes, + supergraph_events, + custom_graphql_instruments, + ) }, - move |(ctx, custom_instruments, mut custom_attributes, supergraph_events, custom_graphql_instruments): (Context, SupergraphInstruments, Vec, SupergraphEvents, GraphQLInstruments), fut| { + move |( + ctx, + custom_instruments, + mut custom_attributes, + mut supergraph_events, + custom_graphql_instruments, + ): ( + Context, + SupergraphInstruments, + Vec, + SupergraphEvents, + GraphQLInstruments, + ), + fut| { let config = config_map_res.clone(); let sender = metrics_sender.clone(); + let enabled_features = enabled_features.clone(); let start = Instant::now(); async move { let span = Span::current(); let mut result: Result = fut.await; + add_query_attributes(&ctx, &mut custom_attributes); add_cost_attributes(&ctx, &mut custom_attributes); span.set_span_dyn_attributes(custom_attributes); match &result { Ok(resp) => { - span.set_span_dyn_attributes(config.instrumentation.spans.supergraph.attributes.on_response(resp)); + span.set_span_dyn_attributes( + config + .instrumentation + .spans + .supergraph + .attributes + .on_response(resp), + ); custom_instruments.on_response(resp); supergraph_events.on_response(resp); custom_graphql_instruments.on_response(resp); - }, + } Err(err) => { - span.set_span_dyn_attributes(config.instrumentation.spans.supergraph.attributes.on_error(err, &ctx)); + span.set_span_dyn_attributes( + config + .instrumentation + .spans + .supergraph + .attributes + .on_error(err, &ctx), + ); custom_instruments.on_error(err, &ctx); supergraph_events.on_error(err, &ctx); custom_graphql_instruments.on_error(err, &ctx); - }, + } } + + if let Ok(resp) = result { + result = Ok(count_supergraph_errors(resp, &config.apollo.errors).await); + } + result = Self::update_otel_metrics( config.clone(), ctx.clone(), result, - start.elapsed(), custom_instruments, supergraph_events, custom_graphql_instruments, ) .await; Self::update_metrics_on_response_events( - &ctx, config, field_level_instrumentation_ratio, sender, start, result, + &ctx, + config, + field_level_instrumentation_ratio, + sender, + start, + result, + enabled_features, ) } }, @@ -704,6 +871,9 @@ impl Plugin for Telemetry { } fn execution_service(&self, service: execution::BoxService) -> execution::BoxService { + let config = self.config.clone(); + let config_map_res_first = config.clone(); + ServiceBuilder::new() .instrument(move |req: &ExecutionRequest| { let operation_kind = req.query_plan.query.operation.kind(); @@ -723,6 +893,13 @@ impl Plugin for Telemetry { ), } }) + .and_then(move |resp: ExecutionResponse| { + let config = config_map_res_first.clone(); + async move { + let resp = count_execution_errors(resp, &config.apollo.errors).await; + Ok::<_, BoxError>(resp) + } + }) .service(service) .boxed() } @@ -731,24 +908,29 @@ impl Plugin for Telemetry { let config = self.config.clone(); let span_mode = self.config.instrumentation.spans.mode; let conf = self.config.clone(); - let subgraph_attribute = KeyValue::new("subgraph", name.to_string()); - let subgraph_metrics_conf_req = self.create_subgraph_metrics_conf(name); - let subgraph_metrics_conf_resp = subgraph_metrics_conf_req.clone(); let subgraph_name = ByteString::from(name); let name = name.to_owned(); - let static_subgraph_instruments = self.subgraph_custom_instruments.read().clone(); - let static_cache_instruments = self.cache_custom_instruments.read().clone(); + let static_subgraph_instruments = self + .builtin_instruments + .read() + .subgraph_custom_instruments + .clone(); + let static_apollo_subgraph_instruments = self + .builtin_instruments + .read() + .apollo_subgraph_instruments + .clone(); + let static_cache_instruments = self + .builtin_instruments + .read() + .cache_custom_instruments + .clone(); ServiceBuilder::new() .instrument(move |req: &SubgraphRequest| span_mode.create_subgraph(name.as_str(), req)) .map_request(move |req: SubgraphRequest| request_ftv1(req)) .map_response(move |resp| store_ftv1(&subgraph_name, resp)) .map_future_with_request_data( move |sub_request: &SubgraphRequest| { - Self::store_subgraph_request_attributes( - subgraph_metrics_conf_req.as_ref(), - sub_request, - ); - let custom_attributes = config .instrumentation .spans @@ -760,9 +942,18 @@ impl Plugin for Telemetry { .instruments .new_subgraph_instruments(static_subgraph_instruments.clone()); custom_instruments.on_request(sub_request); - let custom_events = config.instrumentation.events.new_subgraph_events(); + let mut custom_events = config.instrumentation.events.new_subgraph_events(); custom_events.on_request(sub_request); + let apollo_instruments: ApolloSubgraphInstruments = config + .instrumentation + .instruments + .new_apollo_subgraph_instruments( + static_apollo_subgraph_instruments.clone(), + config.apollo.clone(), + ); + apollo_instruments.on_request(sub_request); + let custom_cache_instruments: CacheInstruments = config .instrumentation .instruments @@ -774,6 +965,7 @@ impl Plugin for Telemetry { custom_instruments, custom_attributes, custom_events, + apollo_instruments, custom_cache_instruments, ) }, @@ -781,21 +973,19 @@ impl Plugin for Telemetry { context, custom_instruments, custom_attributes, - custom_events, + mut custom_events, + apollo_instruments, custom_cache_instruments, ): ( Context, SubgraphInstruments, Vec, SubgraphEvents, + ApolloSubgraphInstruments, CacheInstruments, ), f: BoxFuture<'static, Result>| { - let subgraph_attribute = subgraph_attribute.clone(); - let subgraph_metrics_conf = subgraph_metrics_conf_resp.clone(); let conf = conf.clone(); - // Using Instant because it is guaranteed to be monotonically increasing. - let now = Instant::now(); async move { let span = Span::current(); span.set_span_dyn_attributes(custom_attributes); @@ -815,6 +1005,7 @@ impl Plugin for Telemetry { .attributes .on_response(resp), ); + apollo_instruments.on_response(resp); custom_cache_instruments.on_response(resp); custom_instruments.on_response(resp); custom_events.on_response(resp); @@ -829,19 +1020,130 @@ impl Plugin for Telemetry { .attributes .on_error(err, &context), ); + apollo_instruments.on_error(err, &context); custom_cache_instruments.on_error(err, &context); custom_instruments.on_error(err, &context); custom_events.on_error(err, &context); } } - Self::store_subgraph_response_attributes( - &context, - subgraph_attribute, - subgraph_metrics_conf.as_ref(), - now, - &result, + if let Ok(resp) = result { + Ok(count_subgraph_errors(resp, &conf.apollo.errors).await) + } else { + result + } + } + }, + ) + .service(service) + .boxed() + } + + fn connector_request_service( + &self, + service: connector::request_service::BoxService, + source_name: String, + ) -> connector::request_service::BoxService { + let req_fn_config = self.config.clone(); + let res_fn_config = self.config.clone(); + let span_mode = self.config.instrumentation.spans.mode; + let static_connector_instruments = self + .builtin_instruments + .read() + .connector_custom_instruments + .clone(); + let static_apollo_connector_instruments = self + .builtin_instruments + .read() + .apollo_connector_instruments + .clone(); + ServiceBuilder::new() + .instrument(move |_req: &connector::request_service::Request| { + span_mode.create_connector(source_name.as_str()) + }) + .map_future_with_request_data( + move |request: &connector::request_service::Request| { + let custom_instruments = req_fn_config + .instrumentation + .instruments + .new_connector_instruments(static_connector_instruments.clone()); + custom_instruments.on_request(request); + let apollo_instruments = req_fn_config + .instrumentation + .instruments + .new_apollo_connector_instruments( + static_apollo_connector_instruments.clone(), + req_fn_config.apollo.clone(), ); + apollo_instruments.on_request(request); + let mut custom_events = + req_fn_config.instrumentation.events.new_connector_events(); + custom_events.on_request(request); + + let custom_span_attributes = req_fn_config + .instrumentation + .spans + .connector + .attributes + .on_request(request); + + ( + request.context.clone(), + custom_instruments, + apollo_instruments, + custom_events, + custom_span_attributes, + ) + }, + move |( + context, + custom_instruments, + apollo_connector_instruments, + mut custom_events, + custom_span_attributes, + ): ( + Context, + ConnectorInstruments, + ApolloConnectorInstruments, + ConnectorEvents, + Vec, + ), + f: BoxFuture< + 'static, + Result, + >| { + let conf = res_fn_config.clone(); + async move { + let span = Span::current(); + span.set_span_dyn_attributes(custom_span_attributes); + + let result = f.await; + match &result { + Ok(response) => { + span.set_span_dyn_attributes( + conf.instrumentation + .spans + .connector + .attributes + .on_response(response), + ); + custom_instruments.on_response(response); + apollo_connector_instruments.on_response(response); + custom_events.on_response(response); + } + Err(err) => { + span.set_span_dyn_attributes( + conf.instrumentation + .spans + .connector + .attributes + .on_error(err, &context), + ); + custom_instruments.on_error(err, &context); + apollo_connector_instruments.on_error(err, &context); + custom_events.on_error(err, &context); + } + } result } }, @@ -853,10 +1155,8 @@ impl Plugin for Telemetry { fn web_endpoints(&self) -> MultiMap { self.custom_endpoints.clone() } -} -impl Telemetry { - pub(crate) fn activate(&self) { + fn activate(&self) { let mut activation = self.activation.lock(); if activation.is_active { return; @@ -865,8 +1165,6 @@ impl Telemetry { // Only apply things if we were executing in the context of a vanilla the Apollo executable. // Users that are rolling their own routers will need to set up telemetry themselves. if let Some(hot_tracer) = OPENTELEMETRY_TRACER_HANDLE.get() { - otel::layer::configure(&self.sampling_filter_ratio); - // The reason that this has to happen here is that we are interacting with global state. // If we do this logic during plugin init then if a subsequent plugin fails to init then we // will already have set the new tracer provider and we will be in an inconsistent state. @@ -876,41 +1174,30 @@ impl Telemetry { .take() .expect("must have new tracer_provider"); - let tracer = tracer_provider.versioned_tracer( - GLOBAL_TRACER_NAME, - Some(env!("CARGO_PKG_VERSION")), - None::, - None, - ); + let tracer = tracer_provider + .tracer_builder(GLOBAL_TRACER_NAME) + .with_version(env!("CARGO_PKG_VERSION")) + .build(); hot_tracer.reload(tracer); let last_provider = opentelemetry::global::set_tracer_provider(tracer_provider); Self::checked_global_tracer_shutdown(last_provider); - opentelemetry::global::set_text_map_propagator(Self::create_propagator(&self.config)); + let propagator = Self::create_propagator(&self.config); + opentelemetry::global::set_text_map_propagator(propagator); } activation.reload_metrics(); - let BuiltinInstruments { - graphql_custom_instruments, - router_custom_instruments, - supergraph_custom_instruments, - subgraph_custom_instruments, - cache_custom_instruments, - } = create_builtin_instruments(&self.config.instrumentation.instruments); - - *self.graphql_custom_instruments.write() = graphql_custom_instruments; - *self.router_custom_instruments.write() = router_custom_instruments; - *self.supergraph_custom_instruments.write() = supergraph_custom_instruments; - *self.subgraph_custom_instruments.write() = subgraph_custom_instruments; - *self.cache_custom_instruments.write() = cache_custom_instruments; - + *self.builtin_instruments.write() = + create_builtin_instruments(&self.config.instrumentation.instruments); reload_fmt(create_fmt_layer(&self.config)); activation.is_active = true; } +} +impl Telemetry { fn create_propagator(config: &config::Conf) -> TextMapCompositePropagator { let propagation = &config.exporters.tracing.propagation; @@ -920,15 +1207,15 @@ impl Telemetry { // TLDR the jaeger propagator MUST BE the first one because the version of opentelemetry_jaeger is buggy. // It overrides the current span context with an empty one if it doesn't find the corresponding headers. // Waiting for the >=0.16.1 release - if propagation.jaeger || tracing.jaeger.enabled() { - propagators.push(Box::::default()); + if propagation.jaeger { + propagators.push(Box::::default()); } if propagation.baggage { - propagators.push(Box::::default()); + propagators.push(Box::::default()); } if propagation.trace_context || tracing.otlp.enabled { propagators - .push(Box::::default()); + .push(Box::::default()); } if propagation.zipkin || tracing.zipkin.enabled { propagators.push(Box::::default()); @@ -937,8 +1224,11 @@ impl Telemetry { propagators.push(Box::::default()); } if propagation.aws_xray { - propagators.push(Box::::default()); + propagators.push(Box::::default()); } + + // This propagator MUST come last because the user is trying to override the default behavior of the + // other propagators. if let Some(from_request_header) = &propagation.request.header_name { propagators.push(Box::new(CustomTraceIdPropagator::new( from_request_header.to_string(), @@ -951,35 +1241,21 @@ impl Telemetry { fn create_tracer_provider( config: &config::Conf, - ) -> Result<(SamplerOption, opentelemetry::sdk::trace::TracerProvider), BoxError> { + ) -> Result { let tracing_config = &config.exporters.tracing; let spans_config = &config.instrumentation.spans; - let mut common = tracing_config.common.clone(); - let mut sampler = common.sampler.clone(); - // set it to AlwaysOn: it is now done in the SamplingFilter, so whatever is sent to an exporter - // should be accepted - common.sampler = SamplerOption::Always(Sampler::AlwaysOn); + let common = &tracing_config.common; let mut builder = - opentelemetry::sdk::trace::TracerProvider::builder().with_config((&common).into()); - - builder = setup_tracing(builder, &tracing_config.jaeger, &common, spans_config)?; - builder = setup_tracing(builder, &tracing_config.zipkin, &common, spans_config)?; - builder = setup_tracing(builder, &tracing_config.datadog, &common, spans_config)?; - builder = setup_tracing(builder, &tracing_config.otlp, &common, spans_config)?; - builder = setup_tracing(builder, &config.apollo, &common, spans_config)?; - - if !tracing_config.jaeger.enabled() - && !tracing_config.zipkin.enabled() - && !tracing_config.datadog.enabled() - && !TracingConfigurator::enabled(&tracing_config.otlp) - && !TracingConfigurator::enabled(&config.apollo) - { - sampler = SamplerOption::Always(Sampler::AlwaysOff); - } + opentelemetry_sdk::trace::TracerProvider::builder().with_config((common).into()); + + builder = setup_tracing(builder, &tracing_config.zipkin, common, spans_config)?; + builder = setup_tracing(builder, &tracing_config.datadog, common, spans_config)?; + builder = setup_tracing(builder, &tracing_config.otlp, common, spans_config)?; + builder = setup_tracing(builder, &config.apollo, common, spans_config)?; let tracer_provider = builder.build(); - Ok((sampler, tracer_provider)) + Ok(tracer_provider) } fn create_metrics_builder(config: &config::Conf) -> Result { @@ -1036,126 +1312,43 @@ impl Telemetry { config: Arc, context: Context, result: Result, - request_duration: Duration, custom_instruments: SupergraphInstruments, custom_events: SupergraphEvents, custom_graphql_instruments: GraphQLInstruments, ) -> Result { - let mut metric_attrs = context - .extensions() - .with_lock(|lock| lock.get::().cloned()) - .map(|attrs| { - attrs - .0 - .into_iter() - .map(|(attr_name, attr_value)| KeyValue::new(attr_name, attr_value)) - .collect::>() - }) - .unwrap_or_default(); - let res = match result { - Ok(response) => { - metric_attrs.push(KeyValue::new( - "status", - response.response.status().as_u16().to_string(), - )); - - let ctx = context.clone(); - // Wait for the first response of the stream - let (parts, stream) = response.response.into_parts(); - let config_cloned = config.clone(); - let stream = stream.inspect(move |resp| { - let has_errors = !resp.errors.is_empty(); - // Useful for selector in spans/instruments/events - ctx.insert_json_value( - CONTAINS_GRAPHQL_ERROR, - serde_json_bytes::Value::Bool(has_errors), - ); - let span = Span::current(); - span.set_span_dyn_attributes( - config_cloned - .instrumentation - .spans - .supergraph - .attributes - .on_response_event(resp, &ctx), - ); - custom_instruments.on_response_event(resp, &ctx); - custom_events.on_response_event(resp, &ctx); - custom_graphql_instruments.on_response_event(resp, &ctx); - }); - let (first_response, rest) = stream.into_future().await; - - let attributes = config - .exporters - .metrics - .common - .attributes + let response = result?; + let ctx = context.clone(); + // Wait for the first response of the stream + let (parts, stream) = response.response.into_parts(); + let config_cloned = config.clone(); + let stream = stream.inspect(move |resp| { + let span = Span::current(); + span.set_span_dyn_attributes( + config_cloned + .instrumentation + .spans .supergraph - .get_attributes_from_router_response(&parts, &context, &first_response); - - metric_attrs.extend(attributes.into_iter().map(|(k, v)| KeyValue::new(k, v))); - - if !parts.status.is_success() { - metric_attrs.push(KeyValue::new("error", parts.status.to_string())); - } - let response = http::Response::from_parts( - parts, - once(ready(first_response.unwrap_or_default())) - .chain(rest) - .boxed(), - ); - - Ok(SupergraphResponse { context, response }) - } - Err(err) => { - metric_attrs.push(KeyValue::new("status", "500")); - Err(err) - } - }; - - // http_requests_total - the total number of HTTP requests received - u64_counter!( - "apollo_router_http_requests_total", - "Total number of HTTP requests made.", - 1, - metric_attrs + .attributes + .on_response_event(resp, &ctx), + ); + custom_instruments.on_response_event(resp, &ctx); + custom_events.on_response_event(resp, &ctx); + custom_graphql_instruments.on_response_event(resp, &ctx); + }); + let (first_response, rest) = StreamExt::into_future(stream).await; + + let response = http::Response::from_parts( + parts, + once(ready(first_response.unwrap_or_default())) + .chain(rest) + .boxed(), ); - f64_histogram!( - "apollo_router_http_request_duration_seconds", - "Duration of HTTP requests.", - request_duration.as_secs_f64(), - metric_attrs - ); - res + Ok(SupergraphResponse { context, response }) } - fn populate_context( - config: Arc, - field_level_instrumentation_ratio: f64, - req: &SupergraphRequest, - ) { + fn populate_context(field_level_instrumentation_ratio: f64, req: &SupergraphRequest) { let context = &req.context; - let http_request = &req.supergraph_request; - let headers = http_request.headers(); - - let (should_log_headers, should_log_body) = config.exporters.logging.should_log(req); - if should_log_headers { - let sorted_headers = req - .supergraph_request - .headers() - .iter() - .map(|(k, v)| (k.as_str(), v)) - .collect::>(); - ::tracing::info!(http.request.headers = ?sorted_headers, "Supergraph request headers"); - - let _ = req.context.insert(LOGGING_DISPLAY_HEADERS, true); - } - if should_log_body { - ::tracing::info!(http.request.body = ?req.supergraph_request.body(), "Supergraph request body"); - - let _ = req.context.insert(LOGGING_DISPLAY_BODY, true); - } // List of custom attributes for metrics let mut attributes: HashMap = HashMap::new(); @@ -1166,158 +1359,11 @@ impl Telemetry { ); } - let router_attributes_conf = &config.exporters.metrics.common.attributes.supergraph; - attributes.extend( - router_attributes_conf - .get_attributes_from_request(headers, req.supergraph_request.body()), - ); - attributes.extend(router_attributes_conf.get_attributes_from_context(context)); - - let _ = context - .extensions() - .with_lock(|mut lock| lock.insert(MetricsAttributes(attributes))); - if rand::thread_rng().gen_bool(field_level_instrumentation_ratio) { + if rand::rng().random_bool(field_level_instrumentation_ratio) { context .extensions() - .with_lock(|mut lock| lock.insert(EnableSubgraphFtv1)); - } - } - - fn create_subgraph_metrics_conf(&self, name: &str) -> Arc { - let subgraph_cfg = &self.config.exporters.metrics.common.attributes.subgraph; - macro_rules! extend_config { - ($forward_kind: ident) => {{ - let mut cfg = subgraph_cfg.all.$forward_kind.clone(); - cfg.extend( - subgraph_cfg - .subgraphs - .get(&name.to_owned()) - .map(|s| s.$forward_kind.clone()) - .unwrap_or_default(), - ); - - cfg - }}; + .with_lock(|lock| lock.insert(EnableSubgraphFtv1)); } - macro_rules! merge_config { - ($forward_kind: ident) => {{ - let mut cfg = subgraph_cfg.all.$forward_kind.clone(); - cfg.merge( - subgraph_cfg - .subgraphs - .get(&name.to_owned()) - .map(|s| s.$forward_kind.clone()) - .unwrap_or_default(), - ); - - cfg - }}; - } - - Arc::new(AttributesForwardConf { - insert: extend_config!(insert), - request: merge_config!(request), - response: merge_config!(response), - errors: merge_config!(errors), - context: extend_config!(context), - }) - } - - fn store_subgraph_request_attributes( - attribute_forward_config: &AttributesForwardConf, - sub_request: &Request, - ) { - let mut attributes = HashMap::new(); - attributes.extend(attribute_forward_config.get_attributes_from_request( - sub_request.subgraph_request.headers(), - sub_request.subgraph_request.body(), - )); - attributes - .extend(attribute_forward_config.get_attributes_from_context(&sub_request.context)); - sub_request - .context - .extensions() - .with_lock(|mut lock| lock.insert(SubgraphMetricsAttributes(attributes))); - //.unwrap(); - } - - #[allow(clippy::too_many_arguments)] - fn store_subgraph_response_attributes( - context: &Context, - subgraph_attribute: KeyValue, - attribute_forward_config: &AttributesForwardConf, - now: Instant, - result: &Result, - ) { - let mut metric_attrs = context - .extensions() - .with_lock(|lock| lock.get::().cloned()) - .map(|attrs| { - attrs - .0 - .into_iter() - .map(|(attr_name, attr_value)| KeyValue::new(attr_name, attr_value)) - .collect::>() - }) - .unwrap_or_default(); - metric_attrs.push(subgraph_attribute); - // Fill attributes from context - metric_attrs.extend( - attribute_forward_config - .get_attributes_from_context(context) - .into_iter() - .map(|(k, v)| KeyValue::new(k, v)), - ); - - match &result { - Ok(response) => { - metric_attrs.push(KeyValue::new( - "status", - response.response.status().as_u16().to_string(), - )); - - // Fill attributes from response - metric_attrs.extend( - attribute_forward_config - .get_attributes_from_response( - response.response.headers(), - response.response.body(), - ) - .into_iter() - .map(|(k, v)| KeyValue::new(k, v)), - ); - - u64_counter!( - "apollo_router_http_requests_total", - "Total number of HTTP requests made.", - 1, - metric_attrs - ); - } - Err(err) => { - metric_attrs.push(KeyValue::new("status", "500")); - // Fill attributes from error - metric_attrs.extend( - attribute_forward_config - .get_attributes_from_error(err) - .into_iter() - .map(|(k, v)| KeyValue::new(k, v)), - ); - - u64_counter!( - "apollo_router_http_requests_total", - "Total number of HTTP requests made.", - 1, - metric_attrs - ); - } - } - f64_histogram!( - "apollo_router_http_request_duration_seconds", - "Duration of HTTP requests.", - now.elapsed().as_secs_f64(), - metric_attrs - ); } #[allow(clippy::too_many_arguments)] @@ -1328,6 +1374,7 @@ impl Telemetry { sender: Sender, start: Instant, result: Result, + enabled_features: EnabledFeatures, ) -> Result { let operation_kind: OperationKind = ctx.get(OPERATION_KIND).ok().flatten().unwrap_or_default(); @@ -1346,29 +1393,9 @@ impl Telemetry { operation_kind, operation_subtype, Default::default(), + enabled_features.clone(), ); } - let mut metric_attrs = Vec::new(); - // Fill attributes from error - - metric_attrs.extend( - config - .exporters - .metrics - .common - .attributes - .supergraph - .get_attributes_from_error(&e) - .into_iter() - .map(|(k, v)| KeyValue::new(k, v)), - ); - - u64_counter!( - "apollo_router_http_requests_total", - "Total number of HTTP requests made.", - 1, - metric_attrs - ); Err(e) } @@ -1386,6 +1413,7 @@ impl Telemetry { operation_kind, Some(OperationSubType::SubscriptionRequest), Default::default(), + enabled_features.clone(), ); } Ok(router_response.map(move |response_stream| { @@ -1407,7 +1435,7 @@ impl Telemetry { if !matches!(sender, Sender::Noop) { if let (true, Some(query)) = ( config.apollo.experimental_local_field_metrics, - ctx.unsupported_executable_document(), + ctx.executable_document(), ) { local_stat_recorder.visit( &query, @@ -1435,6 +1463,7 @@ impl Telemetry { .local_type_stats .drain() .collect(), + enabled_features.clone(), ); } } else { @@ -1451,6 +1480,7 @@ impl Telemetry { operation_kind, Some(OperationSubType::SubscriptionEvent), local_stat_recorder.local_type_stats.drain().collect(), + enabled_features.clone(), ); } } else { @@ -1465,6 +1495,7 @@ impl Telemetry { operation_kind, None, local_stat_recorder.local_type_stats.drain().collect(), + enabled_features.clone(), ); } } @@ -1488,20 +1519,20 @@ impl Telemetry { operation_kind: OperationKind, operation_subtype: Option, local_per_type_stat: HashMap, + enabled_features: EnabledFeatures, ) { let metrics = if let Some(usage_reporting) = context .extensions() .with_lock(|lock| lock.get::>().cloned()) { - let licensed_operation_count = - licensed_operation_count(&usage_reporting.stats_report_key); + let licensed_operation_count = licensed_operation_count(&usage_reporting); let persisted_query_hit = context - .get::<_, bool>("persisted_query_hit") + .get::<_, bool>(PERSISTED_QUERY_CACHE_HIT) .unwrap_or_default(); if context .get(STUDIO_EXCLUDE) - .map_or(false, |x| x.unwrap_or_default()) + .is_ok_and(|x| x.unwrap_or_default()) { // The request was excluded don't report the details, but do report the operation count SingleStatsReport { @@ -1512,6 +1543,7 @@ impl Telemetry { licensed_operation_count, }, ), + router_features_enabled: enabled_features.list(), ..Default::default() } } else { @@ -1543,9 +1575,19 @@ impl Telemetry { // values for deferred responses and subscriptions. let enum_response_references = context .extensions() - .with_lock(|mut lock| lock.remove::()) + .with_lock(|lock| lock.remove::()) .unwrap_or_default(); + let maybe_pq_id = context + .extensions() + .with_lock(|lock| lock.get::().cloned()) + .map(|u| u.pq_id); + let usage_reporting = if let Some(pq_id) = maybe_pq_id { + Arc::new(usage_reporting.with_pq_id(pq_id)) + } else { + usage_reporting + }; + SingleStatsReport { request_id: uuid::Uuid::from_bytes( Span::current() @@ -1563,7 +1605,7 @@ impl Telemetry { }, ), stats: HashMap::from([( - usage_reporting.stats_report_key.to_string(), + usage_reporting.get_stats_report_key(), SingleStats { stats_with_context: SingleContextualizedStats { context: StatsContext { @@ -1576,6 +1618,14 @@ impl Telemetry { .get(CLIENT_VERSION) .unwrap_or_default() .unwrap_or_default(), + client_library_name: context + .get(CLIENT_LIBRARY_NAME) + .unwrap_or_default() + .unwrap_or_default(), + client_library_version: context + .get(CLIENT_LIBRARY_VERSION) + .unwrap_or_default() + .unwrap_or_default(), operation_type: operation_kind .as_apollo_operation_type() .to_string(), @@ -1597,13 +1647,14 @@ impl Telemetry { local_per_type_stat, }, referenced_fields_by_type: usage_reporting - .referenced_fields_by_type - .clone() + .get_referenced_fields() .into_iter() .map(|(k, v)| (k, convert(v))) .collect(), + query_metadata: usage_reporting.get_query_metadata(), }, )]), + router_features_enabled: enabled_features.list(), } } } else { @@ -1615,6 +1666,7 @@ impl Telemetry { licensed_operation_count: 1, } .into(), + router_features_enabled: enabled_features.list(), ..Default::default() } }; @@ -1747,33 +1799,34 @@ impl Telemetry { } fn plugin_metrics(config: &Arc) { - let metrics_prom_used = config.exporters.metrics.prometheus.enabled; - let metrics_otlp_used = MetricsConfigurator::enabled(&config.exporters.metrics.otlp); - let tracing_otlp_used = TracingConfigurator::enabled(&config.exporters.tracing.otlp); - let tracing_datadog_used = config.exporters.tracing.datadog.enabled(); - let tracing_jaeger_used = config.exporters.tracing.jaeger.enabled(); - let tracing_zipkin_used = config.exporters.tracing.zipkin.enabled(); - - if metrics_prom_used - || metrics_otlp_used - || tracing_jaeger_used - || tracing_otlp_used - || tracing_zipkin_used - || tracing_datadog_used - { - ::tracing::info!( - monotonic_counter.apollo.router.operations.telemetry = 1u64, - telemetry.metrics.otlp = metrics_otlp_used.or_empty(), - telemetry.metrics.prometheus = metrics_prom_used.or_empty(), - telemetry.tracing.otlp = tracing_otlp_used.or_empty(), - telemetry.tracing.datadog = tracing_datadog_used.or_empty(), - telemetry.tracing.jaeger = tracing_jaeger_used.or_empty(), - telemetry.tracing.zipkin = tracing_zipkin_used.or_empty(), + let mut attributes = Vec::new(); + if MetricsConfigurator::enabled(&config.exporters.metrics.otlp) { + attributes.push(KeyValue::new("telemetry.metrics.otlp", true)); + } + if config.exporters.metrics.prometheus.enabled { + attributes.push(KeyValue::new("telemetry.metrics.prometheus", true)); + } + if TracingConfigurator::enabled(&config.exporters.tracing.otlp) { + attributes.push(KeyValue::new("telemetry.tracing.otlp", true)); + } + if config.exporters.tracing.datadog.enabled() { + attributes.push(KeyValue::new("telemetry.tracing.datadog", true)); + } + if config.exporters.tracing.zipkin.enabled() { + attributes.push(KeyValue::new("telemetry.tracing.zipkin", true)); + } + + if !attributes.is_empty() { + u64_counter!( + "apollo.router.operations.telemetry", + "Telemetry exporters enabled", + 1, + attributes ); } } - fn checked_tracer_shutdown(tracer_provider: opentelemetry::sdk::trace::TracerProvider) { + fn checked_tracer_shutdown(tracer_provider: opentelemetry_sdk::trace::TracerProvider) { Self::checked_spawn_task(Box::new(move || { drop(tracer_provider); })); @@ -1807,13 +1860,32 @@ impl Telemetry { } } } + + fn extract_enabled_features(full_config: &serde_json::Value) -> EnabledFeatures { + EnabledFeatures { + // The APQ cache enabled config defaults to true. + // The distributed APQ cache is only considered enabled if the redis config is also set. + distributed_apq_cache: { + let enabled = full_config["apq"]["enabled"].as_bool().unwrap_or(true); + let redis_cache_config_set = + full_config["apq"]["router"]["cache"]["redis"].is_object(); + enabled && redis_cache_config_set + }, + // Entity cache's top-level enabled flag defaults to false. If the top-level flag is + // enabled, the feature is considered enabled regardless of the subgraph-level enabled + // settings. + entity_cache: full_config["preview_entity_cache"]["enabled"] + .as_bool() + .unwrap_or(false), + } + } } impl TelemetryActivation { fn reload_metrics(&mut self) { - let meter_provider = meter_provider(); + let meter_provider = meter_provider_internal(); commit_prometheus(); - let mut old_meter_providers: [Option; 3] = Default::default(); + let mut old_meter_providers: [Option; 4] = Default::default(); old_meter_providers[0] = meter_provider.set( MeterProviderType::PublicPrometheus, @@ -1825,15 +1897,18 @@ impl TelemetryActivation { self.private_meter_provider.take(), ); - old_meter_providers[2] = - meter_provider.set(MeterProviderType::Public, self.public_meter_provider.take()); + old_meter_providers[2] = meter_provider.set( + MeterProviderType::ApolloRealtime, + self.private_realtime_meter_provider.take(), + ); - metrics_layer().clear(); + old_meter_providers[3] = + meter_provider.set(MeterProviderType::Public, self.public_meter_provider.take()); Self::checked_meter_shutdown(old_meter_providers); } - fn checked_meter_shutdown(meters: [Option; 3]) { + fn checked_meter_shutdown(meters: [Option; 4]) { for meter_provider in meters.into_iter().flatten() { Telemetry::checked_spawn_task(Box::new(move || { if let Err(e) = meter_provider.shutdown() { @@ -1885,18 +1960,15 @@ fn filter_headers(headers: &HeaderMap, forward_rules: &ForwardHeaders) -> String } } -// Planner errors return stats report key that start with `## ` -// while successful planning stats report key start with `# ` -fn licensed_operation_count(stats_report_key: &str) -> u64 { - if stats_report_key.starts_with("## ") { - 0 - } else { - 1 +fn licensed_operation_count(usage_reporting: &UsageReporting) -> u64 { + match usage_reporting { + UsageReporting::Error(_) => 0, + _ => 1, } } fn convert( - referenced_fields: router_bridge::planner::ReferencedFieldsForType, + referenced_fields: crate::apollo_studio_interop::ReferencedFieldsForType, ) -> crate::plugins::telemetry::apollo_exporter::proto::reports::ReferencedFieldsForType { crate::plugins::telemetry::apollo_exporter::proto::reports::ReferencedFieldsForType { field_names: referenced_fields.field_names, @@ -1904,82 +1976,7 @@ fn convert( } } -#[derive(Eq, PartialEq, Hash)] -enum ErrorType { - Trace, - Metric, - Other, -} -static OTEL_ERROR_LAST_LOGGED: OnceCell> = OnceCell::new(); - -fn handle_error>(err: T) { - // We have to rate limit these errors because when they happen they are very frequent. - // Use a dashmap to store the message type with the last time it was logged. - let last_logged_map = OTEL_ERROR_LAST_LOGGED.get_or_init(DashMap::new); - - handle_error_internal(err, last_logged_map); -} - -fn handle_error_internal>( - err: T, - last_logged_map: &DashMap, -) { - let err = err.into(); - - // We don't want the dashmap to get big, so we key the error messages by type. - let error_type = match err { - opentelemetry::global::Error::Trace(_) => ErrorType::Trace, - opentelemetry::global::Error::Metric(_) => ErrorType::Metric, - _ => ErrorType::Other, - }; - #[cfg(not(test))] - let threshold = Duration::from_secs(10); - #[cfg(test)] - let threshold = Duration::from_millis(100); - - // For now we have to suppress Metrics error: reader is shut down or not registered - // https://github.com/open-telemetry/opentelemetry-rust/issues/1244 - if let opentelemetry::global::Error::Metric(err) = &err { - if err.to_string() == "Metrics error: reader is shut down or not registered" { - return; - } - } - // Copy here so that we don't retain a mutable reference into the dashmap and lock the shard - let now = Instant::now(); - let last_logged = *last_logged_map - .entry(error_type) - .and_modify(|last_logged| { - if last_logged.elapsed() > threshold { - *last_logged = now; - } - }) - .or_insert_with(|| now); - - if last_logged == now { - match err { - opentelemetry::global::Error::Trace(err) => { - ::tracing::error!("OpenTelemetry trace error occurred: {}", err) - } - opentelemetry::global::Error::Metric(err) => { - if let MetricsError::Other(msg) = &err { - if msg.contains("Warning") { - ::tracing::warn!("OpenTelemetry metric warning occurred: {}", msg); - return; - } - } - ::tracing::error!("OpenTelemetry metric error occurred: {}", err); - } - opentelemetry::global::Error::Other(err) => { - ::tracing::error!("OpenTelemetry error occurred: {}", err) - } - other => { - ::tracing::error!("OpenTelemetry error occurred: {:?}", other) - } - } - } -} - -register_plugin!("apollo", "telemetry", Telemetry); +register_private_plugin!("apollo", "telemetry", Telemetry); fn request_ftv1(mut req: SubgraphRequest) -> SubgraphRequest { if req @@ -2001,24 +1998,22 @@ fn store_ftv1(subgraph_name: &ByteString, resp: SubgraphResponse) -> SubgraphRes .context .extensions() .with_lock(|lock| lock.contains_key::()) - { - if let Some(serde_json_bytes::Value::String(ftv1)) = + && let Some(serde_json_bytes::Value::String(ftv1)) = resp.response.body().extensions.get("ftv1") - { - // Record the ftv1 trace for processing later - Span::current().record("apollo_private.ftv1", ftv1.as_str()); - resp.context - .upsert_json_value(SUBGRAPH_FTV1, move |value: Value| { - let mut vec = match value { - Value::Array(array) => array, - // upsert_json_value populate the entry with null if it was vacant - Value::Null => Vec::new(), - _ => panic!("unexpected JSON value kind"), - }; - vec.push(json!([subgraph_name, ftv1])); - Value::Array(vec) - }) - } + { + // Record the ftv1 trace for processing later + Span::current().record("apollo_private.ftv1", ftv1.as_str()); + resp.context + .upsert_json_value(SUBGRAPH_FTV1, move |value: Value| { + let mut vec = match value { + Value::Array(array) => array, + // upsert_json_value populate the entry with null if it was vacant + Value::Null => Vec::new(), + _ => panic!("unexpected JSON value kind"), + }; + vec.push(json!([subgraph_name, ftv1])); + Value::Array(vec) + }) } resp } @@ -2049,7 +2044,7 @@ impl CustomTraceIdPropagator { let trace_id = match opentelemetry::trace::TraceId::from_hex(&trace_id) { Ok(trace_id) => trace_id, Err(err) => { - ::tracing::error!("cannot generate custom trace_id: {err}"); + ::tracing::error!(trace_id = %trace_id, error = %err, "cannot generate custom trace_id"); return None; } }; @@ -2114,59 +2109,44 @@ pub(crate) fn add_query_attributes(context: &Context, custom_attributes: &mut Ve }); } -#[derive(Clone)] -struct MetricsAttributes(HashMap); - -#[derive(Clone)] -struct SubgraphMetricsAttributes(HashMap); - struct EnableSubgraphFtv1; + // // Please ensure that any tests added to the tests module use the tokio multi-threaded test executor. // #[cfg(test)] mod tests { use std::collections::HashMap; - use std::fmt::Debug; - use std::ops::DerefMut; use std::sync::Arc; - use std::sync::Mutex; - use std::time::Duration; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; - use axum::headers::HeaderName; - use dashmap::DashMap; - use http::header::CONTENT_TYPE; + use axum_extra::headers::HeaderName; use http::HeaderMap; use http::HeaderValue; use http::StatusCode; + use http::header::CONTENT_TYPE; use insta::assert_snapshot; use itertools::Itertools; - use opentelemetry_api::propagation::Injector; - use opentelemetry_api::propagation::TextMapPropagator; - use opentelemetry_api::trace::SpanContext; - use opentelemetry_api::trace::SpanId; - use opentelemetry_api::trace::TraceContextExt; - use opentelemetry_api::trace::TraceFlags; - use opentelemetry_api::trace::TraceId; - use opentelemetry_api::trace::TraceState; + use opentelemetry::propagation::Injector; + use opentelemetry::propagation::TextMapPropagator; + use opentelemetry::trace::SpanContext; + use opentelemetry::trace::SpanId; + use opentelemetry::trace::TraceContextExt; + use opentelemetry::trace::TraceFlags; + use opentelemetry::trace::TraceId; + use opentelemetry::trace::TraceState; use serde_json::Value; - use serde_json_bytes::json; use serde_json_bytes::ByteString; - use tower::util::BoxService; + use serde_json_bytes::json; use tower::Service; use tower::ServiceExt; - use tracing_core::field::Visit; - use tracing_core::Event; - use tracing_core::Field; - use tracing_core::Subscriber; - use tracing_futures::WithSubscriber; - use tracing_subscriber::layer::Context; - use tracing_subscriber::layer::SubscriberExt; - use tracing_subscriber::Layer; + use tower::util::BoxService; - use super::apollo::ForwardHeaders; use super::CustomTraceIdPropagator; + use super::EnabledFeatures; use super::Telemetry; + use super::apollo::ForwardHeaders; use crate::error::FetchError; use crate::graphql; use crate::graphql::Error; @@ -2175,48 +2155,68 @@ mod tests { use crate::http_ext; use crate::json_ext::Object; use crate::metrics::FutureMetricsExt; + use crate::plugin::DynPlugin; + use crate::plugin::PluginInit; use crate::plugin::test::MockRouterService; use crate::plugin::test::MockSubgraphService; use crate::plugin::test::MockSupergraphService; - use crate::plugin::DynPlugin; - use crate::plugins::demand_control::DemandControlError; use crate::plugins::demand_control::COST_ACTUAL_KEY; use crate::plugins::demand_control::COST_ESTIMATED_KEY; use crate::plugins::demand_control::COST_RESULT_KEY; use crate::plugins::demand_control::COST_STRATEGY_KEY; + use crate::plugins::demand_control::DemandControlError; + use crate::plugins::telemetry::EnableSubgraphFtv1; use crate::plugins::telemetry::config::TraceIdFormat; - use crate::plugins::telemetry::handle_error_internal; - use crate::services::router::body::get_body_bytes; use crate::services::RouterRequest; use crate::services::RouterResponse; use crate::services::SubgraphRequest; use crate::services::SubgraphResponse; use crate::services::SupergraphRequest; use crate::services::SupergraphResponse; + use crate::services::router; + + macro_rules! assert_prometheus_metrics { + ($plugin:expr) => {{ + let prometheus_metrics = get_prometheus_metrics($plugin.as_ref()).await; + let regexp = regex::Regex::new( + r#"process_executable_name="(?P[^"]+)",?|service_name="(?P[^"]+)",?"#, + ) + .unwrap(); + let prometheus_metrics = regexp.replace_all(&prometheus_metrics, "").to_owned(); + assert_snapshot!(prometheus_metrics.replace( + &format!(r#"service_version="{}""#, std::env!("CARGO_PKG_VERSION")), + r#"service_version="X""# + )); + }}; + } - async fn create_plugin_with_config(config: &str) -> Box { - let prometheus_support = config.contains("prometheus"); - let config: Value = serde_yaml::from_str(config).expect("yaml must be valid"); - let telemetry_config = config + async fn create_plugin_with_config(full_config: &str) -> Box { + let full_config = serde_yaml::from_str::(full_config).expect("yaml must be valid"); + let telemetry_config = full_config .as_object() .expect("must be an object") .get("telemetry") - .expect("root key must be telemetry"); - let mut plugin = crate::plugin::plugins() + .expect("telemetry must be a root key"); + let init = PluginInit::fake_builder() + .config(telemetry_config.clone()) + .full_config(full_config) + .build() + .with_deserialized_config() + .expect("unable to deserialize telemetry config"); + + let plugin = crate::plugin::plugins() .find(|factory| factory.name == "apollo.telemetry") .expect("Plugin not found") - .create_instance_without_schema(telemetry_config) + .create_instance(init) .await - .unwrap(); + .expect("unable to create telemetry plugin"); - if prometheus_support { - plugin - .as_any_mut() - .downcast_mut::() - .unwrap() - .activation - .lock() - .reload_metrics(); + let downcast = plugin + .as_any() + .downcast_ref::() + .expect("Telemetry plugin expected"); + if downcast.config.exporters.metrics.prometheus.enabled { + downcast.activation.lock().reload_metrics(); } plugin } @@ -2234,14 +2234,14 @@ mod tests { .into_router(); let http_req_prom = http::Request::get("http://localhost:9090/metrics") - .body(Default::default()) + .body(axum::body::Body::empty()) .unwrap(); let mut resp = web_endpoint.oneshot(http_req_prom).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); - let body = get_body_bytes(resp.body_mut()).await.unwrap(); + let body = router::body::into_bytes(resp.body_mut()).await.unwrap(); String::from_utf8_lossy(&body) .split('\n') - .filter(|l| l.contains("bucket") && !l.contains("apollo_router_span_count")) + .filter(|l| l.contains("bucket")) .sorted() .join("\n") } @@ -2276,11 +2276,25 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn plugin_registered() { + let full_config = serde_json::json!({ + "telemetry": { + "apollo": { + "schema_id": "abc" + }, + "exporters": { + "tracing": {}, + }, + }, + }); + let telemetry_config = full_config["telemetry"].clone(); crate::plugin::plugins() .find(|factory| factory.name == "apollo.telemetry") .expect("Plugin not found") - .create_instance_without_schema( - &serde_json::json!({"apollo": {"schema_id":"abc"}, "exporters": {"tracing": {}}}), + .create_instance( + PluginInit::fake_builder() + .config(telemetry_config) + .full_config(full_config) + .build(), ) .await .unwrap(); @@ -2291,6 +2305,84 @@ mod tests { create_plugin_with_config(include_str!("testdata/config.router.yaml")).await; } + #[tokio::test] + async fn test_enabled_features() { + // Explicitly enabled + let plugin = create_plugin_with_config(include_str!( + "testdata/full_config_all_features_enabled.router.yaml" + )) + .await; + let features = enabled_features(plugin.as_ref()); + assert!( + features.distributed_apq_cache, + "Telemetry plugin should consider apq feature enabled when explicitly enabled" + ); + assert!( + features.entity_cache, + "Telemetry plugin should consider entity cache feature enabled when explicitly enabled" + ); + + // Explicitly disabled + let plugin = create_plugin_with_config(include_str!( + "testdata/full_config_all_features_explicitly_disabled.router.yaml" + )) + .await; + let features = enabled_features(plugin.as_ref()); + assert!( + !features.distributed_apq_cache, + "Telemetry plugin should consider apq feature disabled when explicitly disabled" + ); + assert!( + !features.entity_cache, + "Telemetry plugin should consider entity cache feature disabled when explicitly disabled" + ); + + // Default Values + let plugin = create_plugin_with_config(include_str!( + "testdata/full_config_all_features_defaults.router.yaml" + )) + .await; + let features = enabled_features(plugin.as_ref()); + assert!( + !features.distributed_apq_cache, + "Telemetry plugin should consider apq feature disabled when all values are defaulted" + ); + assert!( + !features.entity_cache, + "Telemetry plugin should consider entity cache feature disabled when all values are defaulted" + ); + + // APQ enabled when default enabled with redis config defined + let plugin = create_plugin_with_config(include_str!( + "testdata/full_config_apq_enabled_partial_defaults.router.yaml" + )) + .await; + let features = enabled_features(plugin.as_ref()); + assert!( + features.distributed_apq_cache, + "Telemetry plugin should consider apq feature enabled when top-level enabled flag is defaulted and redis config is defined" + ); + + // APQ disabled when default enabled with redis config NOT defined + let plugin = create_plugin_with_config(include_str!( + "testdata/full_config_apq_disabled_partial_defaults.router.yaml" + )) + .await; + let features = enabled_features(plugin.as_ref()); + assert!( + !features.distributed_apq_cache, + "Telemetry plugin should consider apq feature disabled when redis cache is not enabled" + ); + } + + fn enabled_features(plugin: &dyn DynPlugin) -> &EnabledFeatures { + &plugin + .as_any() + .downcast_ref::() + .expect("telemetry plugin") + .enabled_features + } + #[tokio::test] async fn test_supergraph_metrics_ok() { async { @@ -2300,13 +2392,12 @@ mod tests { make_supergraph_request(plugin.as_ref()).await; assert_counter!( - "apollo_router_http_requests_total", + "http.request", 1, "another_test" = "my_default_value", "my_value" = 2, "myname" = "label_value", "renamed_value" = "my_value_set", - "status" = "200", "x-custom" = "coming_from_header" ); } @@ -2327,7 +2418,12 @@ mod tests { Ok(SupergraphResponse::fake_builder() .context(req.context) .status_code(StatusCode::BAD_REQUEST) - .data(json!({"errors": [{"message": "nope"}]})) + .errors(vec![ + crate::graphql::Error::builder() + .message("nope") + .extension_code("NOPE") + .build(), + ]) .build() .unwrap()) }, @@ -2347,13 +2443,12 @@ mod tests { .unwrap(); assert_counter!( - "apollo_router_http_requests_total", + "http.request", 1, "another_test" = "my_default_value", - "error" = "400 Bad Request", + "error" = "nope", "myname" = "label_value", - "renamed_value" = "my_value_set", - "status" = "400" + "renamed_value" = "my_value_set" ); } .with_metrics() @@ -2734,9 +2829,10 @@ mod tests { #[tokio::test] async fn test_custom_subgraph_instruments() { async { - let plugin = + let plugin = Box::new( create_plugin_with_config(include_str!("testdata/custom_instruments.router.yaml")) - .await; + .await, + ); let mut mock_bad_request_service = MockSubgraphService::new(); mock_bad_request_service.expect_call().times(2).returning( @@ -2831,6 +2927,63 @@ mod tests { .await; } + #[tokio::test] + async fn test_field_instrumentation_sampler_with_preview_datadog_agent_sampling() { + let plugin = create_plugin_with_config(include_str!( + "testdata/config.field_instrumentation_sampler.router.yaml" + )) + .await; + + let ftv1_counter = Arc::new(AtomicUsize::new(0)); + let ftv1_counter_cloned = ftv1_counter.clone(); + + let mut mock_request_service = MockSupergraphService::new(); + mock_request_service + .expect_call() + .times(10) + .returning(move |req: SupergraphRequest| { + if req + .context + .extensions() + .with_lock(|lock| lock.contains_key::()) + { + ftv1_counter_cloned.fetch_add(1, Ordering::Relaxed); + } + Ok(SupergraphResponse::fake_builder() + .context(req.context) + .status_code(StatusCode::OK) + .header("content-type", "application/json") + .data(json!({"errors": [{"message": "nope"}]})) + .build() + .unwrap()) + }); + let mut request_supergraph_service = + plugin.supergraph_service(BoxService::new(mock_request_service)); + + for _ in 0..10 { + let supergraph_req = SupergraphRequest::fake_builder() + .header("x-custom", "TEST") + .header("conditional-custom", "X") + .header("custom-length", "55") + .header("content-length", "55") + .header("content-type", "application/graphql") + .query("Query test { me {name} }") + .operation_name("test".to_string()); + let _router_response = request_supergraph_service + .ready() + .await + .unwrap() + .call(supergraph_req.build().unwrap()) + .await + .unwrap() + .next_response() + .await + .unwrap(); + } + // It should be 100% because when we set preview_datadog_agent_sampling, we only take the value of field_level_instrumentation_sampler + assert_eq!(ftv1_counter.load(Ordering::Relaxed), 10); + } + #[tokio::test] async fn test_subgraph_metrics_ok() { async { @@ -2880,6 +3033,7 @@ mod tests { .build() .unwrap(), ) + .subgraph_name("my_subgraph_name") .build(); let _subgraph_response = subgraph_service .ready() @@ -2889,15 +3043,15 @@ mod tests { .await .unwrap(); - assert_counter!( - "apollo_router_http_requests_total", + assert_histogram_count!( + "http.client.request.duration", 1, "error" = "custom_error_for_propagation", "my_key" = "my_custom_attribute_from_context", "query_from_request" = "query { test }", - "status" = "200", + "status" = 200, "subgraph" = "my_subgraph_name", - "unknown_data" = "default_value" + "subgraph_error_extended_code" = "FETCH_ERROR" ); } .with_metrics() @@ -2940,6 +3094,7 @@ mod tests { .build() .unwrap(), ) + .subgraph_name("my_subgraph_name_error") .build(); let _subgraph_response = subgraph_service .ready() @@ -2949,62 +3104,13 @@ mod tests { .await .expect_err("should be an error"); - assert_counter!( - "apollo_router_http_requests_total", + assert_histogram_count!( + "http.client.request.duration", 1, - "message" = "cannot contact the subgraph", - "status" = "500", + "message" = + "HTTP fetch failed from 'my_subgraph_name_error': cannot contact the subgraph", "subgraph" = "my_subgraph_name_error", - "subgraph_error_extended_code" = "SUBREQUEST_HTTP_ERROR" - ); - } - .with_metrics() - .await; - } - - #[tokio::test] - async fn test_subgraph_metrics_bad_request() { - async { - let plugin = - create_plugin_with_config(include_str!("testdata/custom_attributes.router.yaml")) - .await; - - let mut mock_bad_request_service = MockSupergraphService::new(); - mock_bad_request_service.expect_call().times(1).returning( - move |req: SupergraphRequest| { - Ok(SupergraphResponse::fake_builder() - .context(req.context) - .status_code(StatusCode::BAD_REQUEST) - .data(json!({"errors": [{"message": "nope"}]})) - .build() - .unwrap()) - }, - ); - - let mut bad_request_supergraph_service = - plugin.supergraph_service(BoxService::new(mock_bad_request_service)); - - let router_req = SupergraphRequest::fake_builder().header("test", "my_value_set"); - - let _router_response = bad_request_supergraph_service - .ready() - .await - .unwrap() - .call(router_req.build().unwrap()) - .await - .unwrap() - .next_response() - .await - .unwrap(); - - assert_counter!( - "apollo_router_http_requests_total", - 1, - "another_test" = "my_default_value", - "error" = "400 Bad Request", - "myname" = "label_value", - "renamed_value" = "my_value_set", - "status" = "400" + "query_from_request" = "query { test }" ); } .with_metrics() @@ -3029,16 +3135,17 @@ mod tests { .into_router(); let http_req_prom = http::Request::get("http://localhost:9090/WRONG/URL/metrics") - .body(Default::default()) + .body(crate::services::router::body::empty()) .unwrap(); - let resp = web_endpoint - .ready() - .await - .unwrap() - .call(http_req_prom) - .await - .unwrap(); + let resp = >>::ready( + &mut web_endpoint, + ) + .await + .unwrap() + .call(http_req_prom) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } .with_metrics() @@ -3050,9 +3157,10 @@ mod tests { async { let plugin = create_plugin_with_config(include_str!("testdata/prometheus.router.yaml")).await; + u64_histogram!("apollo.test.histo", "it's a test", 1u64); + make_supergraph_request(plugin.as_ref()).await; - let prometheus_metrics = get_prometheus_metrics(plugin.as_ref()).await; - assert_snapshot!(prometheus_metrics); + assert_prometheus_metrics!(plugin); } .with_metrics() .await; @@ -3065,10 +3173,10 @@ mod tests { "testdata/prometheus_custom_buckets.router.yaml" )) .await; - make_supergraph_request(plugin.as_ref()).await; - let prometheus_metrics = get_prometheus_metrics(plugin.as_ref()).await; + u64_histogram!("apollo.test.histo", "it's a test", 1u64); - assert_snapshot!(prometheus_metrics); + make_supergraph_request(plugin.as_ref()).await; + assert_prometheus_metrics!(plugin); } .with_metrics() .await; @@ -3082,9 +3190,8 @@ mod tests { )) .await; make_supergraph_request(plugin.as_ref()).await; - let prometheus_metrics = get_prometheus_metrics(plugin.as_ref()).await; - - assert_snapshot!(prometheus_metrics); + u64_histogram!("apollo.test.histo", "it's a test", 1u64); + assert_prometheus_metrics!(plugin); } .with_metrics() .await; @@ -3098,9 +3205,22 @@ mod tests { )) .await; make_supergraph_request(plugin.as_ref()).await; - let prometheus_metrics = get_prometheus_metrics(plugin.as_ref()).await; + assert_prometheus_metrics!(plugin); + } + .with_metrics() + .await; + } - assert!(prometheus_metrics.is_empty()); + #[tokio::test(flavor = "multi_thread")] + async fn it_test_prometheus_metrics_units_are_included() { + async { + let plugin = + create_plugin_with_config(include_str!("testdata/prometheus.router.yaml")).await; + u64_histogram_with_unit!("apollo.test.histo1", "no unit", "{request}", 1u64); + f64_histogram_with_unit!("apollo.test.histo2", "unit", "s", 1f64); + + make_supergraph_request(plugin.as_ref()).await; + assert_prometheus_metrics!(plugin); } .with_metrics() .await; @@ -3143,90 +3263,65 @@ mod tests { } #[tokio::test] - async fn test_handle_error_throttling() { - let error_map = DashMap::new(); - // Set up a fake subscriber so we can check log events. If this is useful then maybe it can be factored out into something reusable - #[derive(Default)] - struct TestVisitor { - log_entries: Vec, - } + async fn test_custom_trace_id_propagator_strip_dashes_in_trace_id() { + let header = String::from("x-trace-id"); + let trace_id = String::from("04f9e396-465c-4840-bc2b-f493b8b1a7fc"); + let expected_trace_id = String::from("04f9e396465c4840bc2bf493b8b1a7fc"); - #[derive(Default, Clone)] - struct TestLayer { - visitor: Arc>, - } - impl TestLayer { - fn assert_log_entry_count(&self, message: &str, expected: usize) { - let log_entries = self.visitor.lock().unwrap().log_entries.clone(); - let actual = log_entries.iter().filter(|e| e.contains(message)).count(); - assert_eq!(actual, expected); - } - } - impl Visit for TestVisitor { - fn record_debug(&mut self, field: &Field, value: &dyn Debug) { - self.log_entries - .push(format!("{}={:?}", field.name(), value)); - } - } + let propagator = CustomTraceIdPropagator::new(header.clone(), TraceIdFormat::Uuid); + let mut headers: HashMap = HashMap::new(); + headers.insert(header, trace_id); + let span = propagator.extract_span_context(&headers); + assert!(span.is_some()); + assert_eq!(span.unwrap().trace_id().to_string(), expected_trace_id); + } - impl Layer for TestLayer - where - S: Subscriber, - Self: 'static, - { - fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { - event.record(self.visitor.lock().unwrap().deref_mut()) - } - } + #[test] + fn test_custom_trace_id_propagator_invalid_hex_characters() { + use crate::test_harness::tracing_test; - let test_layer = TestLayer::default(); + let _guard = tracing_test::dispatcher_guard(); - async { - // Log twice rapidly, they should get deduped - handle_error_internal( - opentelemetry::global::Error::Other("other error".to_string()), - &error_map, - ); - handle_error_internal( - opentelemetry::global::Error::Other("other error".to_string()), - &error_map, - ); - handle_error_internal( - opentelemetry::global::Error::Trace("trace error".to_string().into()), - &error_map, - ); - } - .with_subscriber(tracing_subscriber::registry().with(test_layer.clone())) - .await; + let header = String::from("x-trace-id"); + let invalid_trace_id = String::from("invalid-hex-chars"); - test_layer.assert_log_entry_count("other error", 1); - test_layer.assert_log_entry_count("trace error", 1); + let propagator = CustomTraceIdPropagator::new(header.clone(), TraceIdFormat::Uuid); + let mut headers: HashMap = HashMap::new(); + headers.insert(header, invalid_trace_id.clone()); - // Sleep a bit and then log again, it should get logged - tokio::time::sleep(Duration::from_millis(200)).await; - async { - handle_error_internal( - opentelemetry::global::Error::Other("other error".to_string()), - &error_map, - ); - } - .with_subscriber(tracing_subscriber::registry().with(test_layer.clone())) - .await; - test_layer.assert_log_entry_count("other error", 2); + let span = propagator.extract_span_context(&headers); + assert!(span.is_none()); + + // Verify that the error log contains both trace_id and error details + assert!(tracing_test::logs_contain( + "cannot generate custom trace_id" + )); + assert!(tracing_test::logs_contain(&invalid_trace_id)); + assert!(tracing_test::logs_contain("invalid digit found in string")); } - #[tokio::test] - async fn test_custom_trace_id_propagator_strip_dashes_in_trace_id() { + #[test] + fn test_custom_trace_id_propagator_invalid_length() { + use crate::test_harness::tracing_test; + + let _guard = tracing_test::dispatcher_guard(); + let header = String::from("x-trace-id"); - let trace_id = String::from("04f9e396-465c-4840-bc2b-f493b8b1a7fc"); - let expected_trace_id = String::from("04f9e396465c4840bc2bf493b8b1a7fc"); + let invalid_trace_id = String::from("short"); let propagator = CustomTraceIdPropagator::new(header.clone(), TraceIdFormat::Uuid); let mut headers: HashMap = HashMap::new(); - headers.insert(header, trace_id); + headers.insert(header, invalid_trace_id.clone()); + let span = propagator.extract_span_context(&headers); - assert!(span.is_some()); - assert_eq!(span.unwrap().trace_id().to_string(), expected_trace_id); + assert!(span.is_none()); + + // Verify that the error log contains both trace_id and error details + assert!(tracing_test::logs_contain( + "cannot generate custom trace_id" + )); + assert!(tracing_test::logs_contain(&invalid_trace_id)); + assert!(tracing_test::logs_contain("invalid length")); } #[test] @@ -3269,7 +3364,7 @@ mod tests { .expect_call() .times(1) .returning(move |req: SupergraphRequest| { - req.context.extensions().with_lock(|mut lock| { + req.context.extensions().with_lock(|lock| { lock.insert(cost_details.clone()); }); req.context diff --git a/apollo-router/src/plugins/telemetry/otel/layer.rs b/apollo-router/src/plugins/telemetry/otel/layer.rs index 866bf50a35..369d542f61 100644 --- a/apollo-router/src/plugins/telemetry/otel/layer.rs +++ b/apollo-router/src/plugins/telemetry/otel/layer.rs @@ -1,38 +1,34 @@ use std::any::TypeId; use std::fmt; use std::marker; -use std::sync::atomic::Ordering; use std::thread; use std::time::Instant; use std::time::SystemTime; use once_cell::unsync; -use opentelemetry::trace::noop; -use opentelemetry::trace::OrderMap; -use opentelemetry::trace::TraceContextExt; -use opentelemetry::trace::{self as otel}; use opentelemetry::Context as OtelContext; use opentelemetry::Key; use opentelemetry::KeyValue; use opentelemetry::StringValue; use opentelemetry::Value; -use rand::thread_rng; -use rand::Rng; +use opentelemetry::trace as otel; +use opentelemetry::trace::TraceContextExt; +use opentelemetry::trace::noop; +use tracing_core::Event; +use tracing_core::Subscriber; use tracing_core::field; use tracing_core::span; use tracing_core::span::Attributes; use tracing_core::span::Id; use tracing_core::span::Record; -use tracing_core::Event; -use tracing_core::Subscriber; +use tracing_subscriber::Layer; use tracing_subscriber::layer::Context; use tracing_subscriber::registry::LookupSpan; -use tracing_subscriber::Layer; +use tracing_subscriber::registry::SpanRef; use super::OtelData; use super::PreSampledTracer; -use crate::plugins::telemetry::config::Sampler; -use crate::plugins::telemetry::config::SamplerOption; +use crate::plugins::cache::invalidation_endpoint::INVALIDATION_ENDPOINT_SPAN_NAME; use crate::plugins::telemetry::consts::FIELD_EXCEPTION_MESSAGE; use crate::plugins::telemetry::consts::FIELD_EXCEPTION_STACKTRACE; use crate::plugins::telemetry::consts::OTEL_KIND; @@ -42,10 +38,8 @@ use crate::plugins::telemetry::consts::OTEL_STATUS_CODE; use crate::plugins::telemetry::consts::OTEL_STATUS_MESSAGE; use crate::plugins::telemetry::consts::REQUEST_SPAN_NAME; use crate::plugins::telemetry::consts::ROUTER_SPAN_NAME; -use crate::plugins::telemetry::formatters::filter_metric_events; use crate::plugins::telemetry::reload::IsSampled; use crate::plugins::telemetry::reload::SampledSpan; -use crate::plugins::telemetry::reload::SPAN_SAMPLING_RATE; use crate::query_planner::subscription::SUBSCRIPTION_EVENT_SPAN_NAME; use crate::router_factory::STARTING_SPAN_NAME; @@ -156,7 +150,7 @@ struct SpanEventVisitor<'a, 'b> { custom_event: bool, } -impl<'a, 'b> field::Visit for SpanEventVisitor<'a, 'b> { +impl field::Visit for SpanEventVisitor<'_, '_> { /// Record events on the underlying OpenTelemetry [`Span`] from `bool` values. /// /// [`Span`]: opentelemetry::trace::Span @@ -222,14 +216,14 @@ impl<'a, 'b> field::Visit for SpanEventVisitor<'a, 'b> { /// [`Span`]: opentelemetry::trace::Span fn record_debug(&mut self, field: &field::Field, value: &dyn fmt::Debug) { match field.name() { - "message" => self.event_builder.name = format!("{:?}", value).into(), + "message" => self.event_builder.name = format!("{value:?}").into(), name => { if name == "kind" { self.custom_event = true; } self.event_builder .attributes - .push(KeyValue::new(name, format!("{:?}", value))); + .push(KeyValue::new(name, format!("{value:?}"))); } } } @@ -259,7 +253,7 @@ impl<'a, 'b> field::Visit for SpanEventVisitor<'a, 'b> { .push(Key::new(FIELD_EXCEPTION_MESSAGE).string(error_msg.clone())); // NOTE: This is actually not the stacktrace of the exception. This is - // the "source chain". It represents the heirarchy of errors from the + // the "source chain". It represents the hierarchy of errors from the // app level to the lowest level such as IO. It does not represent all // of the callsites in the code that led to the error happening. // `std::error::Error::backtrace` is a nightly-only API and cannot be @@ -269,26 +263,22 @@ impl<'a, 'b> field::Visit for SpanEventVisitor<'a, 'b> { .push(Key::new(FIELD_EXCEPTION_STACKTRACE).array(chain.clone())); } - if self.exception_config.propagate { - if let Some(span) = &mut self.span_builder { - if let Some(attrs) = span.attributes.as_mut() { - attrs.insert( - Key::new(FIELD_EXCEPTION_MESSAGE), - Value::String(error_msg.clone().into()), - ); + if self.exception_config.propagate + && let Some(span) = &mut self.span_builder + && let Some(attrs) = span.attributes.as_mut() + { + attrs.push(KeyValue::new(FIELD_EXCEPTION_MESSAGE, error_msg.clone())); - // NOTE: This is actually not the stacktrace of the exception. This is - // the "source chain". It represents the heirarchy of errors from the - // app level to the lowest level such as IO. It does not represent all - // of the callsites in the code that led to the error happening. - // `std::error::Error::backtrace` is a nightly-only API and cannot be - // used here until the feature is stabilized. - attrs.insert( - Key::new(FIELD_EXCEPTION_STACKTRACE), - Value::Array(chain.clone().into()), - ); - } - } + // NOTE: This is actually not the stacktrace of the exception. This is + // the "source chain". It represents the hierarchy of errors from the + // app level to the lowest level such as IO. It does not represent all + // of the callsites in the code that led to the error happening. + // `std::error::Error::backtrace` is a nightly-only API and cannot be + // used here until the feature is stabilized. + attrs.push(KeyValue::new( + FIELD_EXCEPTION_STACKTRACE, + Value::Array(chain.clone().into()), + )); } self.event_builder @@ -317,16 +307,16 @@ struct SpanAttributeVisitor<'a> { exception_config: ExceptionFieldConfig, } -impl<'a> SpanAttributeVisitor<'a> { +impl SpanAttributeVisitor<'_> { fn record(&mut self, attribute: KeyValue) { debug_assert!(self.span_builder.attributes.is_some()); if let Some(v) = self.span_builder.attributes.as_mut() { - v.insert(attribute.key, attribute.value); + v.push(attribute); } } } -impl<'a> field::Visit for SpanAttributeVisitor<'a> { +impl field::Visit for SpanAttributeVisitor<'_> { /// Set attributes on the underlying OpenTelemetry [`Span`] from `bool` values. /// /// [`Span`]: opentelemetry::trace::Span @@ -371,13 +361,13 @@ impl<'a> field::Visit for SpanAttributeVisitor<'a> { /// [`Span`]: opentelemetry::trace::Span fn record_debug(&mut self, field: &field::Field, value: &dyn fmt::Debug) { match field.name() { - OTEL_NAME => self.span_builder.name = format!("{:?}", value).into(), - OTEL_KIND => self.span_builder.span_kind = str_to_span_kind(&format!("{:?}", value)), - OTEL_STATUS_CODE => self.span_builder.status = str_to_status(&format!("{:?}", value)), + OTEL_NAME => self.span_builder.name = format!("{value:?}").into(), + OTEL_KIND => self.span_builder.span_kind = str_to_span_kind(&format!("{value:?}")), + OTEL_STATUS_CODE => self.span_builder.status = str_to_status(&format!("{value:?}")), OTEL_STATUS_MESSAGE => { - self.span_builder.status = otel::Status::error(format!("{:?}", value)) + self.span_builder.status = otel::Status::error(format!("{value:?}")) } - _ => self.record(Key::new(field.name()).string(format!("{:?}", value))), + _ => self.record(Key::new(field.name()).string(format!("{value:?}"))), } } @@ -404,7 +394,7 @@ impl<'a> field::Visit for SpanAttributeVisitor<'a> { self.record(Key::new(FIELD_EXCEPTION_MESSAGE).string(error_msg.clone())); // NOTE: This is actually not the stacktrace of the exception. This is - // the "source chain". It represents the heirarchy of errors from the + // the "source chain". It represents the hierarchy of errors from the // app level to the lowest level such as IO. It does not represent all // of the callsites in the code that led to the error happening. // `std::error::Error::backtrace` is a nightly-only API and cannot be @@ -661,32 +651,6 @@ thread_local! { }); } -pub(crate) fn configure(sampler: &SamplerOption) { - let ratio = match sampler { - SamplerOption::TraceIdRatioBased(ratio) => { - // can't use std::cmp::min because f64 is not Ord - if *ratio > 1.0 { - 1.0 - } else { - *ratio - } - } - SamplerOption::Always(s) => match s { - Sampler::AlwaysOn => 1f64, - Sampler::AlwaysOff => 0f64, - }, - }; - - SPAN_SAMPLING_RATE.store(f64::to_bits(ratio), Ordering::Relaxed); -} - -impl OpenTelemetryLayer { - fn sample(&self) -> bool { - let s: f64 = thread_rng().gen_range(0.0..=1.0); - s <= f64::from_bits(SPAN_SAMPLING_RATE.load(Ordering::Relaxed)) - } -} - impl OpenTelemetryLayer where S: Subscriber + for<'span> LookupSpan<'span>, @@ -706,7 +670,7 @@ where return true; } - // if there's an exsting otel context set by the client request, and it is sampled, + // if there's an existing otel context set by the client request, and it is sampled, // then that trace is sampled let current_otel_context = opentelemetry::Context::current(); if current_otel_context.span().span_context().is_sampled() { @@ -733,12 +697,28 @@ where if meta.name() != REQUEST_SPAN_NAME && meta.name() != ROUTER_SPAN_NAME && meta.name() != SUBSCRIPTION_EVENT_SPAN_NAME + && meta.name() != INVALIDATION_ENDPOINT_SPAN_NAME { return false; } // - there's no parent span (it's the root), so we make the sampling decision - self.sample() + true + } + + /// Check whether this span should be sampled by looking at `SampledSpan` in the span's + /// extensions. + /// + /// # Panics + /// + /// This function takes (and then drops) a read lock on `Extensions`. Be careful with using it, + /// since if you're already holding a write lock on `Extensions` the code can deadlock. + fn sampled(span: &SpanRef) -> bool { + let extensions = span.extensions(); + extensions + .get::() + .map(|s| matches!(s, SampledSpan::Sampled(_, _))) + .unwrap_or(false) } } @@ -752,99 +732,92 @@ where /// [OpenTelemetry `Span`]: opentelemetry::trace::Span /// [tracing `Span`]: tracing::Span fn on_new_span(&self, attrs: &Attributes<'_>, id: &span::Id, ctx: Context<'_, S>) { - let span = ctx.span(id).expect("Span not found, this is a bug"); - let is_sampled = self.enabled(span.metadata(), &ctx); - let mut extensions = span.extensions_mut(); - let parent_cx = self.parent_context(attrs, &ctx); + if let Some(span) = ctx.span(id) { + // NB: order matters here! `parent_context` will temporarily lock `extensions` and we + // need to make sure that there isn't a lock already in place. + let parent_cx = self.parent_context(attrs, &ctx); + let mut extensions = span.extensions_mut(); - // Record new trace id if there is no active parent span - let trace_id = if parent_cx.span().span_context().is_valid() - || parent_cx.span().span_context().trace_id() != opentelemetry::trace::TraceId::INVALID - { - // It probably means we have a remote parent trace - parent_cx.span().span_context().trace_id() - } else { - let sampled_span = span - .parent() - .and_then(|s| s.extensions().get::().cloned()); - - match sampled_span { - // It's not the root span - Some(SampledSpan::Sampled(trace_id, _) | SampledSpan::NotSampled(trace_id, _)) => { - opentelemetry_api::trace::TraceId::from(trace_id.to_u128()) - } - // It's probably the root span - None => self.tracer.new_trace_id(), - } - }; + // Record new trace id if there is no active parent span + let trace_id = if parent_cx.span().span_context().trace_id() + != opentelemetry::trace::TraceId::INVALID + { + parent_cx.span().span_context().trace_id() + } else { + self.tracer.new_trace_id() + }; + let span_id = self.tracer.new_span_id(); + let sampled = if self.enabled(attrs.metadata(), &ctx) { + SampledSpan::Sampled(trace_id.to_bytes().into(), span_id) + } else { + SampledSpan::NotSampled(trace_id.to_bytes().into(), span_id) + }; + let is_sampled = matches!(sampled, SampledSpan::Sampled(_, _)); + extensions.insert(sampled); - let span_id = self.tracer.new_span_id(); - let sampled = if is_sampled { - SampledSpan::Sampled(trace_id.to_bytes().into(), span_id) - } else { - SampledSpan::NotSampled(trace_id.to_bytes().into(), span_id) - }; - extensions.insert(sampled); + // Inactivity may still be tracked even if the span isn't sampled. + if self.tracked_inactivity && extensions.get_mut::().is_none() { + extensions.insert(Timings::new()); + } - if !is_sampled { - // Nothing more to do as it's not sampled - return; - } + if !is_sampled { + // Nothing more to do as it's not sampled + return; + } - if self.tracked_inactivity && extensions.get_mut::().is_none() { - extensions.insert(Timings::new()); - } + let mut builder = self + .tracer + .span_builder(attrs.metadata().name()) + .with_start_time(SystemTime::now()) + // Eagerly assign span id so children have stable parent id + .with_span_id(self.tracer.new_span_id()) + .with_trace_id(trace_id); - let mut builder = self - .tracer - .span_builder(attrs.metadata().name()) - .with_start_time(SystemTime::now()) - // Eagerly assign span id so children have stable parent id - .with_span_id(span_id) - .with_trace_id(trace_id); + let builder_attrs = builder.attributes.get_or_insert(Vec::with_capacity( + attrs.fields().len() + self.extra_span_attrs(), + )); - let builder_attrs = builder.attributes.get_or_insert(OrderMap::with_capacity( - attrs.fields().len() + self.extra_span_attrs(), - )); + if self.location { + let meta = attrs.metadata(); - if self.location { - let meta = attrs.metadata(); + if let Some(filename) = meta.file() { + builder_attrs.push(KeyValue::new("code.filepath", filename)); + } - if let Some(filename) = meta.file() { - builder_attrs.insert("code.filepath".into(), filename.into()); - } + if let Some(module) = meta.module_path() { + builder_attrs.push(KeyValue::new("code.namespace", module)); + } - if let Some(module) = meta.module_path() { - builder_attrs.insert("code.namespace".into(), module.into()); + if let Some(line) = meta.line() { + builder_attrs.push(KeyValue::new("code.lineno", line as i64)); + } } - if let Some(line) = meta.line() { - builder_attrs.insert("code.lineno".into(), (line as i64).into()); + if self.with_threads { + THREAD_ID.with(|id| builder_attrs.push(KeyValue::new("thread.id", **id as i64))); + if let Some(name) = std::thread::current().name() { + // TODO(eliza): it's a bummer that we have to allocate here, but + // we can't easily get the string as a `static`. it would be + // nice if `opentelemetry` could also take `Arc`s as + // `String` values... + builder_attrs.push(KeyValue::new("thread.name", name.to_owned())); + } } - } - if self.with_threads { - THREAD_ID.with(|id| builder_attrs.insert("thread.id".into(), (**id as i64).into())); - if let Some(name) = std::thread::current().name() { - // TODO(eliza): it's a bummer that we have to allocate here, but - // we can't easily get the string as a `static`. it would be - // nice if `opentelemetry` could also take `Arc`s as - // `String` values... - builder_attrs.insert("thread.name".into(), name.to_owned().into()); - } + attrs.record(&mut SpanAttributeVisitor { + span_builder: &mut builder, + exception_config: self.exception_config, + }); + extensions.insert(OtelData { + builder, + parent_cx, + event_attributes: None, + forced_status: None, + forced_span_name: None, + }); + } else { + eprintln!("OpenTelemetryLayer::on_new_span: Span not found, this is a bug"); } - - attrs.record(&mut SpanAttributeVisitor { - span_builder: &mut builder, - exception_config: self.exception_config, - }); - extensions.insert(OtelData { - builder, - parent_cx, - event_attributes: None, - forced_status: None, - forced_span_name: None, - }); } fn on_enter(&self, id: &span::Id, ctx: Context<'_, S>) { @@ -852,21 +825,19 @@ where return; } - let span = ctx.span(id).expect("Span not found, this is a bug"); - let mut extensions = span.extensions_mut(); - if extensions - .get_mut::() - .map(|s| matches!(s, SampledSpan::NotSampled(_, _))) - .unwrap_or(true) - { - // It's not sampled - return; - } + if let Some(span) = ctx.span(id) { + if !Self::sampled(&span) { + return; + } - if let Some(timings) = extensions.get_mut::() { - let now = Instant::now(); - timings.idle += (now - timings.last).as_nanos() as i64; - timings.last = now; + let mut extensions = span.extensions_mut(); + if let Some(timings) = extensions.get_mut::() { + let now = Instant::now(); + timings.idle += (now - timings.last).as_nanos() as i64; + timings.last = now; + } + } else { + eprintln!("OpenTelemetryLayer::on_enter: Span not found, this is a bug"); } } @@ -875,21 +846,19 @@ where return; } - let span = ctx.span(id).expect("Span not found, this is a bug"); - let mut extensions = span.extensions_mut(); - if extensions - .get_mut::() - .map(|s| matches!(s, SampledSpan::NotSampled(_, _))) - .unwrap_or(true) - { - // It's not sampled - return; - } + if let Some(span) = ctx.span(id) { + if !Self::sampled(&span) { + return; + } - if let Some(timings) = extensions.get_mut::() { - let now = Instant::now(); - timings.busy += (now - timings.last).as_nanos() as i64; - timings.last = now; + let mut extensions = span.extensions_mut(); + if let Some(timings) = extensions.get_mut::() { + let now = Instant::now(); + timings.busy += (now - timings.last).as_nanos() as i64; + timings.last = now; + } + } else { + eprintln!("OpenTelemetryLayer::on_exit: Span not found, this is a bug"); } } @@ -897,60 +866,58 @@ where /// /// [`attributes`]: opentelemetry::trace::SpanBuilder::attributes fn on_record(&self, id: &Id, values: &Record<'_>, ctx: Context<'_, S>) { - let span = ctx.span(id).expect("Span not found, this is a bug"); - let mut extensions = span.extensions_mut(); - if extensions - .get_mut::() - .map(|s| matches!(s, SampledSpan::NotSampled(_, _))) - .unwrap_or(true) - { - // It's not sampled - return; - } + if let Some(span) = ctx.span(id) { + if !Self::sampled(&span) { + return; + } - if let Some(data) = extensions.get_mut::() { - values.record(&mut SpanAttributeVisitor { - span_builder: &mut data.builder, - exception_config: self.exception_config, - }); + let mut extensions = span.extensions_mut(); + if let Some(data) = extensions.get_mut::() { + values.record(&mut SpanAttributeVisitor { + span_builder: &mut data.builder, + exception_config: self.exception_config, + }); + } + } else { + eprintln!("OpenTelemetryLayer::on_record: Span not found, this is a bug"); } } fn on_follows_from(&self, id: &Id, follows: &Id, ctx: Context) { - let span = ctx.span(id).expect("Span not found, this is a bug"); - let mut extensions = span.extensions_mut(); - if extensions - .get_mut::() - .map(|s| matches!(s, SampledSpan::NotSampled(_, _))) - .unwrap_or(true) - { - // It's not sampled - return; - } + if let (Some(span), Some(follows_span)) = (ctx.span(id), ctx.span(follows)) { + if !Self::sampled(&span) { + return; + } - let data = extensions - .get_mut::() - .expect("Missing otel data span extensions"); - - let follows_span = ctx - .span(follows) - .expect("Span to follow not found, this is a bug"); - let mut follows_extensions = follows_span.extensions_mut(); - let follows_data = follows_extensions - .get_mut::() - .expect("Missing otel data span extensions"); - - let follows_context = self - .tracer - .sampled_context(follows_data) - .span() - .span_context() - .clone(); - let follows_link = otel::Link::new(follows_context, Vec::new()); - if let Some(ref mut links) = data.builder.links { - links.push(follows_link); + // NB: inside block so that `follows_span.extensions_mut()` will be dropped before + // `span.extensions_mut()` is called later. + let follows_link = { + let mut follows_extensions = follows_span.extensions_mut(); + let follows_data = follows_extensions + .get_mut::() + .expect("Missing otel data span extensions"); + + let follows_context = self + .tracer + .sampled_context(follows_data) + .span() + .span_context() + .clone(); + otel::Link::new(follows_context, Vec::new(), 0) + }; + + let mut extensions = span.extensions_mut(); + let data = extensions + .get_mut::() + .expect("Missing otel data span extensions"); + + if let Some(ref mut links) = data.builder.links { + links.push(follows_link); + } else { + data.builder.links = Some(vec![follows_link]); + } } else { - data.builder.links = Some(vec![follows_link]); + eprintln!("OpenTelemetryLayer::on_follows_from: Span not found, this is a bug"); } } @@ -963,21 +930,12 @@ where /// [`ERROR`]: tracing::Level::ERROR /// [`Error`]: opentelemetry::trace::StatusCode::Error fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { - // Don't include deprecated metric events - if !filter_metric_events(event) { - return; - } // Ignore events that are not in the context of a span if let Some(span) = ctx.lookup_current() { - let mut extensions = span.extensions_mut(); - if extensions - .get_mut::() - .map(|s| matches!(s, SampledSpan::NotSampled(_, _))) - .unwrap_or(true) - { - // It's not sampled + if !Self::sampled(&span) { return; } + // Performing read operations before getting a write lock to avoid a deadlock // See https://github.com/tokio-rs/tracing/issues/763 let meta = event.metadata(); @@ -985,6 +943,7 @@ where let target = target.string(meta.target()); + let mut extensions = span.extensions_mut(); let mut otel_data = extensions.get_mut::(); let span_builder = otel_data.as_mut().map(|o| &mut o.builder); @@ -1010,12 +969,12 @@ where otel_event.attributes.extend( event_attributes .into_iter() - .map(|(key, value)| KeyValue::new(key, value)), + .map(|(k, v)| KeyValue::new(k, v)), ) } } - if let Some(OtelData { builder, .. }) = extensions.get_mut::() { + if let Some(builder) = otel_data.map(|o| &mut o.builder) { if builder.status == otel::Status::Unset && *meta.level() == tracing_core::Level::ERROR { @@ -1058,53 +1017,51 @@ where /// /// [`Span`]: opentelemetry::trace::Span fn on_close(&self, id: span::Id, ctx: Context<'_, S>) { - let span = ctx.span(&id).expect("Span not found, this is a bug"); - let mut extensions = span.extensions_mut(); - if extensions - .get_mut::() - .map(|s| matches!(s, SampledSpan::NotSampled(_, _))) - .unwrap_or(true) - { - // It's not sampled - return; - } + if let Some(span) = ctx.span(&id) { + if !Self::sampled(&span) { + return; + } - if let Some(OtelData { - mut builder, - parent_cx, - forced_status, - forced_span_name, - .. - }) = extensions.remove::() - { - if self.tracked_inactivity { - // Append busy/idle timings when enabled. - if let Some(timings) = extensions.get_mut::() { - let busy_ns = Key::new("busy_ns"); - let idle_ns = Key::new("idle_ns"); + let mut extensions = span.extensions_mut(); + if let Some(OtelData { + mut builder, + parent_cx, + forced_status, + forced_span_name, + .. + }) = extensions.remove::() + { + if self.tracked_inactivity { + // Append busy/idle timings when enabled. + if let Some(timings) = extensions.get_mut::() { + let busy_ns = Key::new("busy_ns"); + let idle_ns = Key::new("idle_ns"); + let attributes = builder + .attributes + .get_or_insert_with(|| Vec::with_capacity(3)); + attributes.push(KeyValue::new(busy_ns, timings.busy)); + attributes.push(KeyValue::new(idle_ns, timings.idle)); + } + } + if let Some(forced_status) = forced_status { + builder.status = forced_status; + } + if let Some(forced_span_name) = forced_span_name { + // Insert the original span name as an attribute so that we can map it later let attributes = builder .attributes - .get_or_insert_with(|| OrderMap::with_capacity(3)); - attributes.insert(busy_ns, timings.busy.into()); - attributes.insert(idle_ns, timings.idle.into()); + .get_or_insert_with(|| Vec::with_capacity(1)); + attributes.push(KeyValue::new(OTEL_ORIGINAL_NAME, builder.name)); + builder.name = forced_span_name.into(); } + // Assign end time, build and start span, drop span to export + builder + .with_end_time(SystemTime::now()) + .start_with_context(&self.tracer, &parent_cx); } - if let Some(forced_status) = forced_status { - builder.status = forced_status; - } - if let Some(forced_span_name) = forced_span_name { - // Insert the original span name as an attribute so that we can map it later - let attributes = builder - .attributes - .get_or_insert_with(|| OrderMap::with_capacity(1)); - attributes.insert(OTEL_ORIGINAL_NAME.into(), builder.name.into()); - builder.name = forced_span_name.into(); - } - // Assign end time, build and start span, drop span to export - builder - .with_end_time(SystemTime::now()) - .start_with_context(&self.tracer, &parent_cx); + } else { + eprintln!("OpenTelemetryLayer::on_close: Span not found, this is a bug"); } } @@ -1138,7 +1095,7 @@ impl Timings { } fn thread_id_integer(id: thread::ThreadId) -> u64 { - let thread_id = format!("{:?}", id); + let thread_id = format!("{id:?}"); thread_id .trim_start_matches("ThreadId(") .trim_end_matches(')') @@ -1153,18 +1110,18 @@ mod tests { use std::error::Error; use std::fmt::Display; use std::sync::Arc; - use std::sync::Mutex; use std::thread; use std::time::SystemTime; - use opentelemetry::trace::noop; - use opentelemetry::trace::TraceFlags; use opentelemetry::StringValue; + use opentelemetry::trace::TraceFlags; + use opentelemetry::trace::noop; + use parking_lot::Mutex; use tracing_subscriber::prelude::*; use super::*; - use crate::plugins::telemetry::dynamic_attribute::SpanDynAttribute; use crate::plugins::telemetry::OTEL_NAME; + use crate::plugins::telemetry::dynamic_attribute::SpanDynAttribute; #[derive(Debug, Clone)] struct TestTracer(Arc>>); @@ -1174,7 +1131,7 @@ mod tests { where T: Into>, { - noop::NoopSpan::new() + noop::NoopSpan::DEFAULT } fn span_builder(&self, name: T) -> otel::SpanBuilder where @@ -1187,14 +1144,14 @@ mod tests { builder: otel::SpanBuilder, parent_cx: &OtelContext, ) -> Self::Span { - *self.0.lock().unwrap() = Some(OtelData { + *self.0.lock() = Some(OtelData { builder, parent_cx: parent_cx.clone(), event_attributes: None, forced_status: None, forced_span_name: None, }); - noop::NoopSpan::new() + noop::NoopSpan::DEFAULT } } @@ -1212,7 +1169,7 @@ mod tests { impl TestTracer { fn with_data(&self, f: impl FnOnce(&OtelData) -> T) -> T { - let lock = self.0.lock().unwrap(); + let lock = self.0.lock(); let data = lock.as_ref().expect("no span data has been recorded yet"); f(data) } @@ -1238,6 +1195,7 @@ mod tests { fn set_status(&mut self, _status: otel::Status) {} fn update_name>>(&mut self, _new_name: T) {} fn end_with_timestamp(&mut self, _timestamp: SystemTime) {} + fn add_link(&mut self, _span_context: otel::SpanContext, _attributes: Vec) {} } #[derive(Debug)] @@ -1281,12 +1239,7 @@ mod tests { tracing::debug_span!("static_name", otel.name = dynamic_name.as_str()); }); - let recorded_name = tracer - .0 - .lock() - .unwrap() - .as_ref() - .map(|b| b.builder.name.clone()); + let recorded_name = tracer.0.lock().as_ref().map(|b| b.builder.name.clone()); assert_eq!(recorded_name, Some(dynamic_name.into())) } @@ -1307,12 +1260,7 @@ mod tests { ); }); - let recorded_name = tracer - .0 - .lock() - .unwrap() - .as_ref() - .map(|b| b.builder.name.clone()); + let recorded_name = tracer.0.lock().as_ref().map(|b| b.builder.name.clone()); assert_eq!(recorded_name, Some(Cow::Owned(forced_dynamic_name))) } @@ -1356,15 +1304,7 @@ mod tests { tracing::debug_span!("request", otel.status_message = message); }); - let recorded_status_message = tracer - .0 - .lock() - .unwrap() - .as_ref() - .unwrap() - .builder - .status - .clone(); + let recorded_status_message = tracer.0.lock().as_ref().unwrap().builder.status.clone(); assert_eq!(recorded_status_message, otel::Status::error(message)) } @@ -1410,7 +1350,7 @@ mod tests { let attributes = tracer.with_data(|data| data.builder.attributes.as_ref().unwrap().clone()); let keys = attributes .iter() - .map(|(key, _)| key.as_str()) + .map(|kv| kv.key.as_str()) .collect::>(); assert!(keys.contains(&"idle_ns")); assert!(keys.contains(&"busy_ns")); @@ -1440,7 +1380,6 @@ mod tests { let attributes = tracer .0 .lock() - .unwrap() .as_ref() .unwrap() .builder @@ -1451,7 +1390,7 @@ mod tests { let key_values = attributes .into_iter() - .map(|(key, value)| (key.as_str().to_owned(), value)) + .map(|kv| (kv.key.to_string(), kv.value)) .collect::>(); assert_eq!(key_values["error"].as_str(), "user error"); @@ -1492,7 +1431,7 @@ mod tests { let attributes = tracer.with_data(|data| data.builder.attributes.as_ref().unwrap().clone()); let keys = attributes .iter() - .map(|(key, _)| key.as_str()) + .map(|kv| kv.key.as_str()) .collect::>(); assert!(keys.contains(&"code.filepath")); assert!(keys.contains(&"code.namespace")); @@ -1522,7 +1461,7 @@ mod tests { let attributes = tracer .with_data(|data| data.builder.attributes.as_ref().unwrap().clone()) .drain(..) - .map(|(key, value)| (key.as_str().to_string(), value)) + .map(|kv| (kv.key.to_string(), kv.value)) .collect::>(); assert_eq!(attributes.get("thread.name"), expected_name.as_ref()); assert_eq!(attributes.get("thread.id"), Some(&expected_id)); @@ -1545,7 +1484,7 @@ mod tests { let attributes = tracer.with_data(|data| data.builder.attributes.as_ref().unwrap().clone()); let keys = attributes .iter() - .map(|(key, _)| key.as_str()) + .map(|kv| kv.key.as_str()) .collect::>(); assert!(!keys.contains(&"thread.name")); assert!(!keys.contains(&"thread.id")); @@ -1577,7 +1516,6 @@ mod tests { let attributes = tracer .0 .lock() - .unwrap() .as_ref() .unwrap() .builder @@ -1588,7 +1526,7 @@ mod tests { let key_values = attributes .into_iter() - .map(|(key, value)| (key.as_str().to_owned(), value)) + .map(|kv| (kv.key.to_string(), kv.value)) .collect::>(); assert_eq!(key_values[FIELD_EXCEPTION_MESSAGE].as_str(), "user error"); diff --git a/apollo-router/src/plugins/telemetry/otel/mod.rs b/apollo-router/src/plugins/telemetry/otel/mod.rs index 63a1ae72cb..a13327472b 100644 --- a/apollo-router/src/plugins/telemetry/otel/mod.rs +++ b/apollo-router/src/plugins/telemetry/otel/mod.rs @@ -1,14 +1,14 @@ /// Implementation of the trace::Layer as a source of OpenTelemetry data. pub(crate) mod layer; +pub(crate) mod named_runtime_channel; /// Span extension which enables OpenTelemetry context management. pub(crate) mod span_ext; /// Protocols for OpenTelemetry Tracers that are compatible with Tracing pub(crate) mod tracer; -pub(crate) use layer::layer; pub(crate) use layer::OpenTelemetryLayer; +pub(crate) use layer::layer; use opentelemetry::Key; -use opentelemetry::OrderMap; use opentelemetry::Value; pub(crate) use span_ext::OpenTelemetrySpanExt; pub(crate) use tracer::PreSampledTracer; @@ -25,7 +25,10 @@ pub(crate) struct OtelData { pub(crate) builder: opentelemetry::trace::SpanBuilder, /// Attributes gathered for the next event - pub(crate) event_attributes: Option>, + #[cfg(not(test))] + pub(crate) event_attributes: Option>, + #[cfg(test)] + pub(crate) event_attributes: Option>, /// Forced status in case it's coming from the custom attributes pub(crate) forced_status: Option, diff --git a/apollo-router/src/plugins/telemetry/otel/named_runtime_channel.rs b/apollo-router/src/plugins/telemetry/otel/named_runtime_channel.rs new file mode 100644 index 0000000000..c49b76e332 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/otel/named_runtime_channel.rs @@ -0,0 +1,184 @@ +use std::fmt::Debug; +use std::time::Duration; + +use futures::future::BoxFuture; +use opentelemetry_sdk::runtime::Runtime; +use opentelemetry_sdk::runtime::RuntimeChannel; +use opentelemetry_sdk::runtime::Tokio; +use opentelemetry_sdk::runtime::TrySend; +use opentelemetry_sdk::runtime::TrySendError; + +/// Wraps an otel tokio runtime to provide a name in the error messages and metrics +#[derive(Debug, Clone)] +pub(crate) struct NamedTokioRuntime { + name: &'static str, + parent: Tokio, +} + +impl NamedTokioRuntime { + pub(crate) fn new(name: &'static str) -> Self { + Self { + name, + parent: Tokio, + } + } +} + +impl Runtime for NamedTokioRuntime { + type Interval = ::Interval; + type Delay = ::Delay; + + fn interval(&self, duration: Duration) -> Self::Interval { + self.parent.interval(duration) + } + + fn spawn(&self, future: BoxFuture<'static, ()>) { + self.parent.spawn(future) + } + + fn delay(&self, duration: Duration) -> Self::Delay { + self.parent.delay(duration) + } +} + +impl RuntimeChannel for NamedTokioRuntime { + type Receiver = ::Receiver; + type Sender = NamedSender; + + fn batch_message_channel( + &self, + capacity: usize, + ) -> (Self::Sender, Self::Receiver) { + let (sender, receiver) = tokio::sync::mpsc::channel(capacity); + ( + NamedSender::new(self.name, sender), + tokio_stream::wrappers::ReceiverStream::new(receiver), + ) + } +} + +#[derive(Debug)] +pub(crate) struct NamedSender { + name: &'static str, + channel_full_message: String, + channel_closed_message: String, + sender: tokio::sync::mpsc::Sender, +} + +impl NamedSender { + fn new(name: &'static str, sender: tokio::sync::mpsc::Sender) -> NamedSender { + NamedSender { + name, + channel_full_message: format!( + "cannot send message to batch processor '{name}' as the channel is full" + ), + channel_closed_message: format!( + "cannot send message to batch processor '{name}' as the channel is closed" + ), + sender, + } + } +} + +impl TrySend for NamedSender { + type Message = T; + + fn try_send(&self, item: Self::Message) -> Result<(), TrySendError> { + // Convert the error into something that has a name + self.sender.try_send(item).map_err(|err| { + let error = match &err { + tokio::sync::mpsc::error::TrySendError::Full(_) => "channel full", + tokio::sync::mpsc::error::TrySendError::Closed(_) => "channel closed", + }; + u64_counter!( + "apollo.router.telemetry.batch_processor.errors", + "Errors when sending to a batch processor", + 1, + "name" = self.name, + "error" = error + ); + + match err { + tokio::sync::mpsc::error::TrySendError::Full(_) => { + TrySendError::Other(self.channel_full_message.as_str().into()) + } + tokio::sync::mpsc::error::TrySendError::Closed(_) => { + TrySendError::Other(self.channel_closed_message.as_str().into()) + } + } + }) + } +} +#[cfg(test)] +mod tests { + use super::*; + use crate::metrics::FutureMetricsExt; + + #[tokio::test] + async fn test_channel_full_error_metrics() { + async { + let runtime = NamedTokioRuntime::new("test_processor"); + let (sender, mut _receiver) = runtime.batch_message_channel(1); + + // Fill the channel + sender.try_send("first").expect("should send first message"); + + // This should fail and emit metrics + let result = sender.try_send("second"); + assert!(result.is_err()); + + assert_counter!( + "apollo.router.telemetry.batch_processor.errors", + 1, + "name" = "test_processor", + "error" = "channel full" + ); + } + .with_metrics() + .await; + } + + #[tokio::test] + async fn test_channel_closed_error_metrics() { + async { + let runtime = NamedTokioRuntime::new("test_processor"); + let (sender, receiver) = runtime.batch_message_channel(1); + + // Drop receiver to close channel + drop(receiver); + + let result = sender.try_send("message"); + assert!(result.is_err()); + + assert_counter!( + "apollo.router.telemetry.batch_processor.errors", + 1, + "name" = "test_processor", + "error" = "channel closed" + ); + } + .with_metrics() + .await; + } + + #[tokio::test] + async fn test_successful_message_send() { + async { + let runtime = NamedTokioRuntime::new("test_processor"); + let (sender, _receiver) = runtime.batch_message_channel(1); + + let result = sender.try_send("message"); + assert!(result.is_ok()); + + // No metrics should be emitted for success case + let metrics = crate::metrics::collect_metrics(); + assert!( + metrics + .find("apollo.router.telemetry.batch_processor.errors") + .is_none() + ); + } + .with_metrics() + .await; + } +} diff --git a/apollo-router/src/plugins/telemetry/otel/span_ext.rs b/apollo-router/src/plugins/telemetry/otel/span_ext.rs index 5d1c723b66..9dbb2acb42 100644 --- a/apollo-router/src/plugins/telemetry/otel/span_ext.rs +++ b/apollo-router/src/plugins/telemetry/otel/span_ext.rs @@ -1,6 +1,6 @@ -use opentelemetry::trace::SpanContext; use opentelemetry::Context; use opentelemetry::KeyValue; +use opentelemetry::trace::SpanContext; use super::layer::WithContext; /// Utility functions to allow tracing [`Span`]s to accept and return @@ -98,7 +98,7 @@ impl OpenTelemetrySpanExt for tracing::Span { get_context.with_context(subscriber, id, move |data, _tracer| { if let Some(cx) = cx.take() { let attr = att.take().unwrap_or_default(); - let follows_link = opentelemetry::trace::Link::new(cx, attr); + let follows_link = opentelemetry::trace::Link::new(cx, attr, 0); data.builder .links .get_or_insert_with(|| Vec::with_capacity(1)) diff --git a/apollo-router/src/plugins/telemetry/otel/tracer.rs b/apollo-router/src/plugins/telemetry/otel/tracer.rs index 463fd8cb2c..163b9791aa 100644 --- a/apollo-router/src/plugins/telemetry/otel/tracer.rs +++ b/apollo-router/src/plugins/telemetry/otel/tracer.rs @@ -1,6 +1,5 @@ +use opentelemetry::Context as OtelContext; use opentelemetry::trace as otel; -use opentelemetry::trace::noop; -use opentelemetry::trace::OrderMap; use opentelemetry::trace::SamplingDecision; use opentelemetry::trace::SamplingResult; use opentelemetry::trace::SpanBuilder; @@ -11,12 +10,11 @@ use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceFlags; use opentelemetry::trace::TraceId; use opentelemetry::trace::TraceState; -use opentelemetry::Context as OtelContext; +use opentelemetry::trace::noop; +use opentelemetry_sdk::trace::IdGenerator; use opentelemetry_sdk::trace::Tracer as SdkTracer; -use opentelemetry_sdk::trace::TracerProvider as SdkTracerProvider; use super::OtelData; -use crate::plugins::telemetry::tracing::datadog_exporter::DatadogTraceState; /// An interface for authors of OpenTelemetry SDKs to build pre-sampled tracers. /// @@ -73,34 +71,29 @@ impl PreSampledTracer for noop::NoopTracer { impl PreSampledTracer for SdkTracer { fn sampled_context(&self, data: &mut OtelData) -> OtelContext { - // Ensure tracing pipeline is still installed. - if self.provider().is_none() { - return OtelContext::new(); - } - let provider = self.provider().unwrap(); let parent_cx = &data.parent_cx; let builder = &mut data.builder; + // If we have a parent span that means we have a parent span coming from a propagator // Gather trace state - let (trace_id, parent_trace_flags) = current_trace_state(builder, parent_cx, &provider); + let (trace_id, parent_trace_flags) = + current_trace_state(builder, parent_cx, self.id_generator()); // Sample or defer to existing sampling decisions let (flags, trace_state) = if let Some(result) = &builder.sampling_result { process_sampling_result(result, parent_trace_flags) } else { - builder.sampling_result = Some(provider.config().sampler.should_sample( + let sampling_result = self.should_sample().should_sample( Some(parent_cx), trace_id, &builder.name, builder.span_kind.as_ref().unwrap_or(&SpanKind::Internal), - builder.attributes.as_ref().unwrap_or(&OrderMap::default()), + builder.attributes.as_ref().unwrap_or(&Vec::new()), builder.links.as_deref().unwrap_or(&[]), - )); - - process_sampling_result( - builder.sampling_result.as_ref().unwrap(), - parent_trace_flags, - ) + ); + let processed_result = process_sampling_result(&sampling_result, parent_trace_flags); + builder.sampling_result = Some(sampling_result); + processed_result } .unwrap_or_default(); @@ -110,22 +103,18 @@ impl PreSampledTracer for SdkTracer { } fn new_trace_id(&self) -> otel::TraceId { - self.provider() - .map(|provider| provider.config().id_generator.new_trace_id()) - .unwrap_or(otel::TraceId::INVALID) + self.id_generator().new_trace_id() } fn new_span_id(&self) -> otel::SpanId { - self.provider() - .map(|provider| provider.config().id_generator.new_span_id()) - .unwrap_or(otel::SpanId::INVALID) + self.id_generator().new_span_id() } } fn current_trace_state( builder: &SpanBuilder, parent_cx: &OtelContext, - provider: &SdkTracerProvider, + id_generator: &dyn IdGenerator, ) -> (TraceId, TraceFlags) { if parent_cx.has_active_span() { let span = parent_cx.span(); @@ -135,7 +124,7 @@ fn current_trace_state( ( builder .trace_id - .unwrap_or_else(|| provider.config().id_generator.new_trace_id()), + .unwrap_or_else(|| id_generator.new_trace_id()), Default::default(), ) } @@ -159,12 +148,7 @@ fn process_sampling_result( decision: SamplingDecision::RecordAndSample, trace_state, .. - } => Some(( - trace_flags | TraceFlags::SAMPLED, - trace_state - .with_priority_sampling(true) - .with_measuring(true), - )), + } => Some((trace_flags | TraceFlags::SAMPLED, trace_state.clone())), } } @@ -173,7 +157,7 @@ mod tests { use opentelemetry::trace::SpanBuilder; use opentelemetry::trace::SpanId; use opentelemetry::trace::TracerProvider as _; - use opentelemetry_sdk::trace::config; + use opentelemetry_sdk::trace::Config; use opentelemetry_sdk::trace::Sampler; use opentelemetry_sdk::trace::TracerProvider; @@ -227,7 +211,7 @@ mod tests { fn sampled_context() { for (name, sampler, parent_cx, previous_sampling_result, is_sampled) in sampler_data() { let provider = TracerProvider::builder() - .with_config(config().with_sampler(sampler)) + .with_config(Config::default().with_sampler(sampler)) .build(); let tracer = provider.tracer("test"); let mut builder = SpanBuilder::from_name("parent".to_string()); @@ -243,8 +227,7 @@ mod tests { assert_eq!( sampled.span().span_context().is_sampled(), is_sampled, - "{}", - name + "{name}" ) } } diff --git a/apollo-router/src/plugins/telemetry/otlp.rs b/apollo-router/src/plugins/telemetry/otlp.rs index e1f4dfe966..056f0f8592 100644 --- a/apollo-router/src/plugins/telemetry/otlp.rs +++ b/apollo-router/src/plugins/telemetry/otlp.rs @@ -1,16 +1,12 @@ //! Shared configuration for Otlp tracing and metrics. use std::collections::HashMap; -use std::str::FromStr; -use http::uri::Parts; -use http::uri::PathAndQuery; use http::Uri; -use lazy_static::lazy_static; -use opentelemetry::sdk::metrics::reader::TemporalitySelector; -use opentelemetry::sdk::metrics::InstrumentKind; use opentelemetry_otlp::HttpExporterBuilder; use opentelemetry_otlp::TonicExporterBuilder; use opentelemetry_otlp::WithExportConfig; +use opentelemetry_sdk::metrics::InstrumentKind; +use opentelemetry_sdk::metrics::reader::TemporalitySelector; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -22,17 +18,8 @@ use tonic::transport::Identity; use tower::BoxError; use url::Url; -use crate::plugins::telemetry::config::GenericWith; -use crate::plugins::telemetry::endpoint::UriEndpoint; use crate::plugins::telemetry::tracing::BatchProcessorConfig; -lazy_static! { - static ref DEFAULT_GRPC_ENDPOINT: Uri = Uri::from_static("http://127.0.0.1:4317"); - static ref DEFAULT_HTTP_ENDPOINT: Uri = Uri::from_static("http://127.0.0.1:4318"); -} - -const DEFAULT_HTTP_ENDPOINT_PATH: &str = "/v1/traces"; - #[derive(Debug, Clone, Deserialize, JsonSchema, Default)] #[serde(deny_unknown_fields)] pub(crate) struct Config { @@ -41,7 +28,7 @@ pub(crate) struct Config { /// The endpoint to send data to #[serde(default)] - pub(crate) endpoint: UriEndpoint, + pub(crate) endpoint: Option, /// The protocol to use when sending data #[serde(default)] @@ -65,12 +52,106 @@ pub(crate) struct Config { pub(crate) temporality: Temporality, } -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] pub(crate) enum TelemetryDataKind { Traces, Metrics, } +// In older versions of `opentelemetry_otlp` the crate would "helpfully" try to make sure that the +// path for metrics or tracing was correct. This didn't always work consistently and so we added +// some code to the router to try and make this work better. We also implemented configuration so +// that: +// - "default" would result in the default from the specification +// - ":" would be an acceptable value even though no path was specified. +// +// The latter is particularly problematic, since this used to work in version 0.13, but had stopped +// working by the time we updated to 0.17. +// +// Our previous implementation didn't perform endpoint manipulation for metrics, so this +// implementation unifies the processing of endpoints. +// +// The processing does the following: +// - If an endpoint is not specified, this results in `None` +// - If an endpoint is specified as "default", this results in `""` +// - If an endpoint is `""` or ends with a protocol appropriate suffix, we stop processing +// - If we continue processing: +// - If an endpoint has no scheme, we prepend "http://" +// - If our endpoint has no path, we append a protocol specific suffix +// - If it has a path, we return it unmodified +// +// Note: "" is the empty string and is thus interpreted by any opentelemetry sdk as indicating that +// the default endpoint should be used. +// +// If you are interested in learning more about opentelemetry endpoints: +// https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md +// contains the details. +pub(super) fn process_endpoint( + endpoint: &Option, + kind: &TelemetryDataKind, + protocol: &Protocol, +) -> Result, BoxError> { + // If there is no endpoint, None, do no processing because the user must be relying on the + // router processing OTEL environment variables for endpoint. + // If there is an endpoint, Some(value), we must process that value. Most of this processing is + // performed to try and remain backwards compatible with previous versions of the router which + // depended on "non-standard" behaviour of the opentelemetry_otlp crate. I've tried documenting + // each of the outcomes clearly for the benefit of future maintainers. + endpoint + .as_ref() + .map(|v| { + let mut base = if v == "default" { + "".to_string() + } else { + v.to_string() + }; + if base.is_empty() { + // We don't want to process empty strings + Ok(base) + } else { + // We require a scheme on our endpoint or we can't parse it as a Uri. + // If we don't have one, prepend with "http://" + if !base.starts_with("http") { + base = format!("http://{base}"); + } + // We expect different suffixes by protocol and signal type + let suffix = match protocol { + Protocol::Grpc => "/", + Protocol::Http => match kind { + TelemetryDataKind::Metrics => "/v1/metrics", + TelemetryDataKind::Traces => "/v1/traces", + }, + }; + if base.ends_with(suffix) { + // Our suffix is in place, all is good + Ok(base) + } else { + let uri = http::Uri::try_from(&base)?; + // Note: If our endpoint is ":://host:port", then the path will be "/". + // We already ensured that our base does not end with , so we must append + // + if uri.path() == "/" { + // Remove any trailing slash from the base so we don't end up with a + // double slash when concatenating e.g. "http://my-base//v1/metrics" + if base.ends_with("/") { + base.pop(); + } + // We don't have a path, we need to add one + Ok(format!("{base}{suffix}")) + } else { + // We have a path, it doesn't end with , let it pass... + // We could try and enforce the standard here and only let through paths + // which end with the expected suffix. However, I think that would reduce + // backwards compatibility and we should just trust that the user knows + // what they are doing. + Ok(base) + } + } + } + }) + .transpose() +} + impl Config { pub(crate) fn exporter + From>( &self, @@ -78,84 +159,47 @@ impl Config { ) -> Result { match self.protocol { Protocol::Grpc => { - let endpoint = self.endpoint.to_uri(&DEFAULT_GRPC_ENDPOINT); - let grpc = self.grpc.clone(); - let exporter = opentelemetry_otlp::new_exporter() + let endpoint_opt = process_endpoint(&self.endpoint, &kind, &self.protocol)?; + // Figure out if we need to set tls config for our exporter + let tls_config_opt = if let Some(endpoint) = &endpoint_opt { + if !endpoint.is_empty() { + let tls_url = Uri::try_from(endpoint)?; + Some(self.grpc.clone().to_tls_config(&tls_url)?) + } else { + None + } + } else { + None + }; + + let mut exporter = opentelemetry_otlp::new_exporter() .tonic() + .with_protocol(opentelemetry_otlp::Protocol::Grpc) .with_timeout(self.batch_processor.max_export_timeout) - .with(&endpoint, |b, endpoint| { - b.with_endpoint(endpoint.to_string()) - }) - .with(&grpc.try_from(&endpoint)?, |b, t| { - b.with_tls_config(t.clone()) - }) - .with_metadata(MetadataMap::from_headers(self.grpc.metadata.clone())) - .into(); - Ok(exporter) + .with_metadata(MetadataMap::from_headers(self.grpc.metadata.clone())); + if let Some(endpoint) = endpoint_opt { + exporter = exporter.with_endpoint(endpoint); + } + if let Some(tls_config) = tls_config_opt { + exporter = exporter.with_tls_config(tls_config); + } + Ok(exporter.into()) } Protocol::Http => { - let endpoint = add_missing_path( - kind, - self.endpoint - .to_uri(&DEFAULT_HTTP_ENDPOINT) - .map(|e| e.into_parts()), - )?; - let http = self.http.clone(); - let exporter = opentelemetry_otlp::new_exporter() + let endpoint_opt = process_endpoint(&self.endpoint, &kind, &self.protocol)?; + let headers = self.http.headers.clone(); + let mut exporter: HttpExporterBuilder = opentelemetry_otlp::new_exporter() .http() + .with_protocol(opentelemetry_otlp::Protocol::Grpc) .with_timeout(self.batch_processor.max_export_timeout) - .with(&endpoint, |b, endpoint| { - b.with_endpoint(endpoint.to_string()) - }) - .with_headers(http.headers) - .into(); - - Ok(exporter) - } - } - } -} - -// Waiting for https://github.com/open-telemetry/opentelemetry-rust/issues/1618 to be fixed -fn add_missing_path( - kind: TelemetryDataKind, - mut endpoint_parts: Option, -) -> Result, BoxError> { - if let Some(endpoint_parts) = &mut endpoint_parts { - if let TelemetryDataKind::Traces = kind { - match &mut endpoint_parts.path_and_query { - Some(path_and_query) => { - if !path_and_query.path().ends_with(DEFAULT_HTTP_ENDPOINT_PATH) { - match path_and_query.query() { - Some(query) => { - endpoint_parts.path_and_query = - Some(PathAndQuery::from_str(&format!( - "{}{DEFAULT_HTTP_ENDPOINT_PATH}?{query}", - path_and_query.path().trim_end_matches('/') - ))?); - } - None => { - *path_and_query = PathAndQuery::from_str(&format!( - "{}{DEFAULT_HTTP_ENDPOINT_PATH}", - path_and_query.path().trim_end_matches('/') - ))?; - } - } - } - } - None => { - endpoint_parts.path_and_query = - Some(PathAndQuery::from_static(DEFAULT_HTTP_ENDPOINT_PATH)); + .with_headers(headers); + if let Some(endpoint) = endpoint_opt { + exporter = exporter.with_endpoint(endpoint); } + Ok(exporter.into()) } } } - let endpoint = match endpoint_parts { - Some(endpoint_parts) => Some(Uri::from_parts(endpoint_parts)?), - None => None, - }; - - Ok(endpoint) } #[derive(Debug, Clone, Deserialize, Serialize, Default, JsonSchema)] @@ -184,57 +228,46 @@ pub(crate) struct GrpcExporter { pub(crate) metadata: http::HeaderMap, } -fn header_map(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { - HashMap::::json_schema(gen) +fn header_map(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + HashMap::::json_schema(generator) } impl GrpcExporter { - // Return a TlsConfig if it has something actually set. - pub(crate) fn try_from( - self, - endpoint: &Option, - ) -> Result, BoxError> { - if let Some(endpoint) = endpoint { - let endpoint = endpoint.to_string().parse::().map_err(|e| { - BoxError::from(format!("invalid GRPC endpoint {}, {}", endpoint, e)) - })?; - let domain_name = self.default_tls_domain(&endpoint); - - if self.ca.is_some() - || self.key.is_some() - || self.cert.is_some() - || domain_name.is_some() - { - return Some( - ClientTlsConfig::new() - .with(&domain_name, |b, d| b.domain_name(*d)) - .try_with(&self.ca, |b, c| { - Ok(b.ca_certificate(Certificate::from_pem(c))) - })? - .try_with( - &self.cert.clone().zip(self.key.clone()), - |b, (cert, key)| Ok(b.identity(Identity::from_pem(cert, key))), - ), - ) - .transpose(); - } + pub(crate) fn to_tls_config(&self, endpoint: &Uri) -> Result { + let endpoint = endpoint + .to_string() + .parse::() + .map_err(|e| BoxError::from(format!("invalid GRPC endpoint {endpoint}, {e}")))?; + let domain_name = self.default_tls_domain(&endpoint); + + if let (Some(ca), Some(key), Some(cert), Some(domain_name)) = + (&self.ca, &self.key, &self.cert, domain_name) + { + Ok(ClientTlsConfig::new() + .with_native_roots() + .domain_name(domain_name) + .ca_certificate(Certificate::from_pem(ca.clone())) + .identity(Identity::from_pem(cert.clone(), key.clone()))) + } else { + // This was a breaking change in tonic where we now have to specify native roots. + Ok(ClientTlsConfig::new().with_native_roots()) } - Ok(None) } fn default_tls_domain<'a>(&'a self, endpoint: &'a Url) -> Option<&'a str> { - let domain_name = match (&self.domain_name, endpoint) { + match (&self.domain_name, endpoint) { // If the URL contains the https scheme then default the tls config to use the domain from the URL. We know it's TLS. // If the URL contains no scheme and the port is 443 emit a warning suggesting that they may have forgotten to configure TLS domain. (Some(domain), _) => Some(domain.as_str()), (None, endpoint) if endpoint.scheme() == "https" => endpoint.host_str(), (None, endpoint) if endpoint.port() == Some(443) && endpoint.scheme() != "http" => { - tracing::warn!("telemetry otlp exporter has been configured with port 443 but TLS domain has not been set. This is likely a configuration error"); + tracing::warn!( + "telemetry otlp exporter has been configured with port 443 but TLS domain has not been set. This is likely a configuration error" + ); None } _ => None, - }; - domain_name + } } } @@ -257,11 +290,11 @@ pub(crate) enum Temporality { } pub(crate) struct CustomTemporalitySelector( - pub(crate) opentelemetry::sdk::metrics::data::Temporality, + pub(crate) opentelemetry_sdk::metrics::data::Temporality, ); impl TemporalitySelector for CustomTemporalitySelector { - fn temporality(&self, _kind: InstrumentKind) -> opentelemetry::sdk::metrics::data::Temporality { + fn temporality(&self, _kind: InstrumentKind) -> opentelemetry_sdk::metrics::data::Temporality { self.0 } } @@ -269,11 +302,11 @@ impl TemporalitySelector for CustomTemporalitySelector { impl From<&Temporality> for Box { fn from(value: &Temporality) -> Self { Box::new(match value { - Temporality::Cumulative => CustomTemporalitySelector( - opentelemetry::sdk::metrics::data::Temporality::Cumulative, - ), + Temporality::Cumulative => { + CustomTemporalitySelector(opentelemetry_sdk::metrics::data::Temporality::Cumulative) + } Temporality::Delta => { - CustomTemporalitySelector(opentelemetry::sdk::metrics::data::Temporality::Delta) + CustomTemporalitySelector(opentelemetry_sdk::metrics::data::Temporality::Delta) } }) } @@ -311,41 +344,137 @@ mod tests { } #[test] - fn test_add_missing_path() { - let url = Uri::from_str("https://api.apm.com:433/v1/traces").unwrap(); - let url = add_missing_path(TelemetryDataKind::Traces, url.into_parts().into()) - .unwrap() - .unwrap(); + fn test_process_endpoint() { + // Traces + let endpoint = None; + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Traces, &Protocol::Grpc).unwrap(); + assert_eq!(endpoint, processed_endpoint); + + let endpoint = Some("default".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Traces, &Protocol::Grpc).unwrap(); + assert_eq!(Some("".to_string()), processed_endpoint); + + let endpoint = Some("https://api.apm.com:433/v1/traces".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Traces, &Protocol::Grpc).unwrap(); + assert_eq!(endpoint, processed_endpoint); + + let endpoint = Some("https://api.apm.com:433".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Traces, &Protocol::Grpc).unwrap(); assert_eq!( - url.to_string(), - String::from("https://api.apm.com:433/v1/traces") + Some("https://api.apm.com:433/".to_string()), + processed_endpoint ); - let url = Uri::from_str("https://api.apm.com:433/").unwrap(); - let url = add_missing_path(TelemetryDataKind::Traces, url.into_parts().into()) - .unwrap() - .unwrap(); + let endpoint = Some("https://api.apm.com:433".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Traces, &Protocol::Http).unwrap(); + assert_eq!( + Some("https://api.apm.com:433/v1/traces".to_string()), + processed_endpoint + ); + + let endpoint = Some("https://api.apm.com:433/".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Traces, &Protocol::Grpc).unwrap(); + assert_eq!(endpoint, processed_endpoint); + + let endpoint = Some("https://api.apm.com:433/traces".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Traces, &Protocol::Grpc).unwrap(); + assert_eq!(endpoint, processed_endpoint); + + let endpoint = Some("localhost:4317".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Traces, &Protocol::Grpc).unwrap(); + assert_eq!( + Some("http://localhost:4317/".to_string()), + processed_endpoint + ); + + let endpoint = Some("localhost:4317".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Traces, &Protocol::Http).unwrap(); + assert_eq!( + Some("http://localhost:4317/v1/traces".to_string()), + processed_endpoint + ); + + let endpoint = Some("https://otlp.nr-data.net".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Traces, &Protocol::Http).unwrap(); + assert_eq!( + Some("https://otlp.nr-data.net/v1/traces".to_string()), + processed_endpoint + ); + + // Metrics + let endpoint = None; + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Metrics, &Protocol::Grpc).unwrap(); + assert_eq!(None, processed_endpoint); + + let endpoint = Some("default".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Metrics, &Protocol::Grpc).unwrap(); + assert_eq!(Some("".to_string()), processed_endpoint); + + let endpoint = Some("https://api.apm.com:433/v1/metrics".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Metrics, &Protocol::Grpc).unwrap(); + assert_eq!(endpoint, processed_endpoint); + + let endpoint = Some("https://api.apm.com:433".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Metrics, &Protocol::Grpc).unwrap(); + assert_eq!( + Some("https://api.apm.com:433/".to_string()), + processed_endpoint + ); + + let endpoint = Some("https://api.apm.com:433".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Metrics, &Protocol::Http).unwrap(); + assert_eq!( + Some("https://api.apm.com:433/v1/metrics".to_string()), + processed_endpoint + ); + + let endpoint = Some("https://api.apm.com:433/".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Metrics, &Protocol::Grpc).unwrap(); + assert_eq!(endpoint, processed_endpoint); + + let endpoint = Some("https://api.apm.com:433/metrics".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Metrics, &Protocol::Grpc).unwrap(); + assert_eq!(endpoint, processed_endpoint); + + let endpoint = Some("localhost:4317".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Metrics, &Protocol::Grpc).unwrap(); assert_eq!( - url.to_string(), - String::from("https://api.apm.com:433/v1/traces") + Some("http://localhost:4317/".to_string()), + processed_endpoint ); - let url = Uri::from_str("https://api.apm.com:433/?hi=hello").unwrap(); - let url = add_missing_path(TelemetryDataKind::Traces, url.into_parts().into()) - .unwrap() - .unwrap(); + let endpoint = Some("localhost:4317".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Metrics, &Protocol::Http).unwrap(); assert_eq!( - url.to_string(), - String::from("https://api.apm.com:433/v1/traces?hi=hello") + Some("http://localhost:4317/v1/metrics".to_string()), + processed_endpoint ); - let url = Uri::from_str("https://api.apm.com:433/v1?hi=hello").unwrap(); - let url = add_missing_path(TelemetryDataKind::Traces, url.into_parts().into()) - .unwrap() - .unwrap(); + let endpoint = Some("https://otlp.nr-data.net".to_string()); + let processed_endpoint = + process_endpoint(&endpoint, &TelemetryDataKind::Metrics, &Protocol::Http).unwrap(); assert_eq!( - url.to_string(), - String::from("https://api.apm.com:433/v1/v1/traces?hi=hello") + Some("https://otlp.nr-data.net/v1/metrics".to_string()), + processed_endpoint ); } } diff --git a/apollo-router/src/plugins/telemetry/proto/reports.proto b/apollo-router/src/plugins/telemetry/proto/reports.proto index 2d9062f8d8..9e4309d666 100644 --- a/apollo-router/src/plugins/telemetry/proto/reports.proto +++ b/apollo-router/src/plugins/telemetry/proto/reports.proto @@ -343,6 +343,9 @@ message ReportHeader { // attached to a schema in the backend. string executable_schema_id = 11; + // The unique reporting agent that generated this report. + string agent_id = 13; + reserved 3; // removed string service = 3; } @@ -428,6 +431,9 @@ message StatsContext { // The result of the operation. Either OK or the error code that caused the operation to fail. // This will not contain all errors from a query, only the primary reason the operation failed. e.g. a limits failure or an auth failure. string result = 6; + // Client awareness contexts + string client_library_name = 7; + string client_library_version = 8; } message ContextualizedQueryLatencyStats { @@ -551,6 +557,10 @@ message Report { // input type and enum value references. We need this flag so we can tell if the option is enabled even when there are // no extended references to report. bool extended_references_enabled = 9; + + // A list of features enabled by router at the time this report was generated. + // It is expected to be included only by Apollo Router, not by any other reporting agent. + repeated string router_features_enabled = 10; } @@ -578,12 +588,13 @@ message ContextualizedStats { } message QueryMetadata { - // The operation name. For now this is a required field if QueryMetadata is present. + // The operation name. For operations with a PQ ID as the stats report key, either name or signature must be present in the metadata. string name = 1; - // the operation signature. For now this is a required field if QueryMetadata is present. + // the operation signature. For operations with a PQ ID as the stats report key, either name or signature must be present in the metadata. string signature = 2; // (Optional) Persisted query ID that was used to request this operation. string pq_id = 3; + } message ExtendedReferences { diff --git a/apollo-router/src/plugins/telemetry/reload.rs b/apollo-router/src/plugins/telemetry/reload.rs index 2ca69191c9..c3445b27ce 100644 --- a/apollo-router/src/plugins/telemetry/reload.rs +++ b/apollo-router/src/plugins/telemetry/reload.rs @@ -1,44 +1,37 @@ use std::io::IsTerminal; -use std::sync::atomic::AtomicU64; -use anyhow::anyhow; use anyhow::Result; +use anyhow::anyhow; use once_cell::sync::OnceCell; -use opentelemetry::sdk::trace::Tracer; +use opentelemetry::Context; +use opentelemetry::trace::SpanContext; use opentelemetry::trace::SpanId; use opentelemetry::trace::TraceContextExt; +use opentelemetry::trace::TraceFlags; +use opentelemetry::trace::TraceState; use opentelemetry::trace::TracerProvider; -use opentelemetry_api::trace::SpanContext; -use opentelemetry_api::trace::TraceFlags; -use opentelemetry_api::trace::TraceState; -use opentelemetry_api::Context; +use opentelemetry_sdk::trace::Tracer; use tower::BoxError; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::Registry; use tracing_subscriber::layer::Layer; use tracing_subscriber::layer::Layered; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::SpanRef; use tracing_subscriber::reload::Handle; use tracing_subscriber::util::SubscriberInitExt; -use tracing_subscriber::EnvFilter; -use tracing_subscriber::Registry; -use super::config_new::logging::RateLimit; use super::dynamic_attribute::DynAttributeLayer; use super::fmt_layer::FmtLayer; use super::formatters::json::Json; -use super::metrics::span_metrics_exporter::SpanMetricsLayer; -use crate::metrics::layer::MetricsLayer; -use crate::metrics::meter_provider; -use crate::plugins::telemetry::formatters::filter_metric_events; use crate::plugins::telemetry::formatters::text::Text; -use crate::plugins::telemetry::formatters::FilteringFormatter; use crate::plugins::telemetry::otel; use crate::plugins::telemetry::otel::OpenTelemetryLayer; use crate::plugins::telemetry::otel::PreSampledTracer; use crate::plugins::telemetry::tracing::reload::ReloadTracer; use crate::tracer::TraceId; -pub(crate) type LayeredRegistry = Layered>; +pub(crate) type LayeredRegistry = Layered; pub(super) type LayeredTracer = Layered>, LayeredRegistry>; @@ -46,50 +39,30 @@ pub(super) type LayeredTracer = // These handles allow hot tracing of layers. They have complex type definitions because tracing has // generic types in the layer definition. pub(super) static OPENTELEMETRY_TRACER_HANDLE: OnceCell< - ReloadTracer, + ReloadTracer, > = OnceCell::new(); static FMT_LAYER_HANDLE: OnceCell< Handle + Send + Sync>, LayeredTracer>, > = OnceCell::new(); -pub(super) static SPAN_SAMPLING_RATE: AtomicU64 = AtomicU64::new(0); - -pub(super) static METRICS_LAYER: OnceCell = OnceCell::new(); -pub(crate) fn metrics_layer() -> &'static MetricsLayer { - METRICS_LAYER.get_or_init(|| MetricsLayer::new(meter_provider().clone())) -} - pub(crate) fn init_telemetry(log_level: &str) -> Result<()> { let hot_tracer = ReloadTracer::new( - opentelemetry::sdk::trace::TracerProvider::default().versioned_tracer( - "noop", - None::, - None::, - None, - ), + opentelemetry_sdk::trace::TracerProvider::default() + .tracer_builder("noop") + .build(), ); let opentelemetry_layer = otel::layer().with_tracer(hot_tracer.clone()); // We choose json or plain based on tty let fmt = if std::io::stdout().is_terminal() { - FmtLayer::new( - FilteringFormatter::new(Text::default(), filter_metric_events, &RateLimit::default()), - std::io::stdout, - ) - .boxed() + FmtLayer::new(Text::default(), std::io::stdout).boxed() } else { - FmtLayer::new( - FilteringFormatter::new(Json::default(), filter_metric_events, &RateLimit::default()), - std::io::stdout, - ) - .boxed() + FmtLayer::new(Json::default(), std::io::stdout).boxed() }; let (fmt_layer, fmt_handle) = tracing_subscriber::reload::Layer::new(fmt); - let metrics_layer = metrics_layer(); - // Stash the reload handles so that we can hot reload later OPENTELEMETRY_TRACER_HANDLE .get_or_try_init(move || { @@ -100,10 +73,9 @@ pub(crate) fn init_telemetry(log_level: &str) -> Result<()> { // the tracing registry is only created once tracing_subscriber::registry() .with(DynAttributeLayer::new()) - .with(SpanMetricsLayer::default()) .with(opentelemetry_layer) .with(fmt_layer) - .with(metrics_layer.clone()) + .with(WarnLegacyMetricsLayer) .with(EnvFilter::try_new(log_level)?) .try_init()?; @@ -133,17 +105,17 @@ pub(crate) fn apollo_opentelemetry_initialized() -> bool { // To that end, we update the context just for that request to create valid span et trace ids, with the // sampling bit set to false pub(crate) fn prepare_context(context: Context) -> Context { - if !context.span().span_context().is_valid() { - if let Some(tracer) = OPENTELEMETRY_TRACER_HANDLE.get() { - let span_context = SpanContext::new( - tracer.new_trace_id(), - tracer.new_span_id(), - TraceFlags::default(), - false, - TraceState::default(), - ); - return context.with_remote_span_context(span_context); - } + if !context.span().span_context().is_valid() + && let Some(tracer) = OPENTELEMETRY_TRACER_HANDLE.get() + { + let span_context = SpanContext::new( + tracer.new_trace_id(), + tracer.new_span_id(), + TraceFlags::default(), + false, + TraceState::default(), + ); + return context.with_remote_span_context(span_context); } context } @@ -191,3 +163,58 @@ where }) } } + +const LEGACY_METRIC_PREFIX_MONOTONIC_COUNTER: &str = "monotonic_counter."; +const LEGACY_METRIC_PREFIX_COUNTER: &str = "counter."; +const LEGACY_METRIC_PREFIX_HISTOGRAM: &str = "histogram."; +const LEGACY_METRIC_PREFIX_VALUE: &str = "value."; + +/// Detects use of the 1.x `tracing`-based metrics events, which are no longer supported in 2.x. +struct WarnLegacyMetricsLayer; + +// We can't use the tracing macros inside our `on_event` callback, instead we have to manually +// produce an event, which requires a significant amount of ceremony. +// This metadata mimicks what `tracing::error!()` does. +static WARN_LEGACY_METRIC_CALLSITE: tracing_core::callsite::DefaultCallsite = + tracing_core::callsite::DefaultCallsite::new(&WARN_LEGACY_METRIC_METADATA); +static WARN_LEGACY_METRIC_METADATA: tracing_core::Metadata = tracing_core::metadata! { + name: "warn_legacy_metric", + target: module_path!(), + level: tracing_core::Level::ERROR, + fields: &["message", "metric_name"], + callsite: &WARN_LEGACY_METRIC_CALLSITE, + kind: tracing_core::metadata::Kind::EVENT, +}; + +impl Layer for WarnLegacyMetricsLayer { + fn on_event(&self, event: &tracing::Event<'_>, ctx: tracing_subscriber::layer::Context<'_, S>) { + if let Some(field) = event.fields().find(|field| { + field + .name() + .starts_with(LEGACY_METRIC_PREFIX_MONOTONIC_COUNTER) + || field.name().starts_with(LEGACY_METRIC_PREFIX_COUNTER) + || field.name().starts_with(LEGACY_METRIC_PREFIX_HISTOGRAM) + || field.name().starts_with(LEGACY_METRIC_PREFIX_VALUE) + }) { + // Doing all this manually is a flippin nightmare! + // We allocate a bunch but I reckon it's fine because this only happens in a deprecated + // code path that we want people to upgrade from. + let fields = WARN_LEGACY_METRIC_METADATA.fields(); + let message_field = fields.field("message").unwrap(); + let message = + "Detected unsupported legacy metrics reporting, remove or migrate to opentelemetry" + .to_string(); + let name_field = fields.field("metric_name").unwrap(); + let metric_name = field.name().to_string(); + let value_set = &[ + (&message_field, Some(&message as &dyn tracing::Value)), + (&name_field, Some(&metric_name as &dyn tracing::Value)), + ]; + let value_set = fields.value_set(value_set); + ctx.event(&tracing_core::Event::new( + &WARN_LEGACY_METRIC_METADATA, + &value_set, + )); + } + } +} diff --git a/apollo-router/src/plugins/telemetry/resource.rs b/apollo-router/src/plugins/telemetry/resource.rs index 580bd25e38..338e22d8f2 100644 --- a/apollo-router/src/plugins/telemetry/resource.rs +++ b/apollo-router/src/plugins/telemetry/resource.rs @@ -2,10 +2,10 @@ use std::collections::BTreeMap; use std::env; use std::time::Duration; -use opentelemetry::sdk::resource::EnvResourceDetector; -use opentelemetry::sdk::resource::ResourceDetector; -use opentelemetry::sdk::Resource; use opentelemetry::KeyValue; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::resource::EnvResourceDetector; +use opentelemetry_sdk::resource::ResourceDetector; use crate::plugins::telemetry::config::AttributeValue; const UNKNOWN_SERVICE: &str = "unknown_service"; @@ -47,7 +47,8 @@ impl ResourceDetector for EnvServiceNameDetector { } } -pub(crate) trait ConfigResource { +#[allow(missing_docs)] // only public-but-hidden for tests +pub trait ConfigResource { fn service_name(&self) -> &Option; fn service_namespace(&self) -> &Option; @@ -73,14 +74,14 @@ pub(crate) trait ConfigResource { // Default service name if resource - .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME) + .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME.into()) .is_none() { let executable_name = executable_name(); resource.merge(&Resource::new(vec![KeyValue::new( opentelemetry_semantic_conventions::resource::SERVICE_NAME, executable_name - .map(|executable_name| format!("{}:{}", UNKNOWN_SERVICE, executable_name)) + .map(|executable_name| format!("{UNKNOWN_SERVICE}:{executable_name}")) .unwrap_or_else(|| UNKNOWN_SERVICE.to_string()), )])) } else { @@ -123,7 +124,7 @@ impl ResourceDetector for ConfigResourceDetector { // Yaml resources if let Some(AttributeValue::String(name)) = self .resources - .get(&opentelemetry_semantic_conventions::resource::SERVICE_NAME.to_string()) + .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME) { Some(name.clone()) } else { @@ -139,202 +140,4 @@ impl ResourceDetector for ConfigResourceDetector { } } -#[cfg(test)] -mod test { - use std::collections::BTreeMap; - use std::env; - - use opentelemetry::Key; - use serial_test::serial; - - use crate::plugins::telemetry::config::AttributeValue; - use crate::plugins::telemetry::resource::ConfigResource; - - struct TestConfig { - service_name: Option, - service_namespace: Option, - resources: BTreeMap, - } - impl ConfigResource for TestConfig { - fn service_name(&self) -> &Option { - &self.service_name - } - fn service_namespace(&self) -> &Option { - &self.service_namespace - } - fn resource(&self) -> &BTreeMap { - &self.resources - } - } - - // All of the tests in this module must execute serially wrt each other because they rely on - // env settings and one of the tests modifies the env for the duration of the test. We enforce - // this with the #[serial] derive. - #[test] - #[serial] - fn test_empty() { - let test_config = TestConfig { - service_name: None, - service_namespace: None, - resources: Default::default(), - }; - let resource = test_config.to_resource(); - assert!(resource - .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME) - .unwrap() - .as_str() - .starts_with("unknown_service:apollo_router")); - assert!(resource - .get(opentelemetry_semantic_conventions::resource::SERVICE_NAMESPACE) - .is_none()); - assert_eq!( - resource.get(opentelemetry_semantic_conventions::resource::SERVICE_VERSION), - Some(std::env!("CARGO_PKG_VERSION").into()) - ); - - assert!(resource - .get(opentelemetry_semantic_conventions::resource::PROCESS_EXECUTABLE_NAME) - .expect("expected excutable name") - .as_str() - .contains("apollo")); - } - - #[test] - #[serial] - fn test_config_resources() { - let test_config = TestConfig { - service_name: None, - service_namespace: None, - resources: BTreeMap::from_iter(vec![ - ( - opentelemetry_semantic_conventions::resource::SERVICE_NAME.to_string(), - AttributeValue::String("override-service-name".to_string()), - ), - ( - opentelemetry_semantic_conventions::resource::SERVICE_NAMESPACE.to_string(), - AttributeValue::String("override-namespace".to_string()), - ), - ( - "extra-key".to_string(), - AttributeValue::String("extra-value".to_string()), - ), - ]), - }; - let resource = test_config.to_resource(); - assert_eq!( - resource.get(opentelemetry_semantic_conventions::resource::SERVICE_NAME), - Some("override-service-name".into()) - ); - assert_eq!( - resource.get(opentelemetry_semantic_conventions::resource::SERVICE_NAMESPACE), - Some("override-namespace".into()) - ); - assert_eq!( - resource.get(Key::from_static_str("extra-key")), - Some("extra-value".into()) - ); - } - - #[test] - #[serial] - fn test_service_name_service_namespace() { - let test_config = TestConfig { - service_name: Some("override-service-name".to_string()), - service_namespace: Some("override-namespace".to_string()), - resources: BTreeMap::new(), - }; - let resource = test_config.to_resource(); - assert_eq!( - resource.get(opentelemetry_semantic_conventions::resource::SERVICE_NAME), - Some("override-service-name".into()) - ); - assert_eq!( - resource.get(opentelemetry_semantic_conventions::resource::SERVICE_NAMESPACE), - Some("override-namespace".into()) - ); - } - - #[test] - #[serial] - fn test_service_name_override() { - // Order of precedence - // OTEL_SERVICE_NAME env - // OTEL_RESOURCE_ATTRIBUTES env - // config service_name - // config resources - // unknown_service:executable_name - // unknown_service (Untested as it can't happen) - - assert!(TestConfig { - service_name: None, - service_namespace: None, - resources: Default::default(), - } - .to_resource() - .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME) - .unwrap() - .as_str() - .starts_with("unknown_service:apollo_router")); - - assert_eq!( - TestConfig { - service_name: None, - service_namespace: None, - resources: BTreeMap::from_iter(vec![( - opentelemetry_semantic_conventions::resource::SERVICE_NAME.to_string(), - AttributeValue::String("yaml-resource".to_string()), - )]), - } - .to_resource() - .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME), - Some("yaml-resource".into()) - ); - - assert_eq!( - TestConfig { - service_name: Some("yaml-service-name".to_string()), - service_namespace: None, - resources: BTreeMap::from_iter(vec![( - opentelemetry_semantic_conventions::resource::SERVICE_NAME.to_string(), - AttributeValue::String("yaml-resource".to_string()), - )]), - } - .to_resource() - .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME), - Some("yaml-service-name".into()) - ); - - env::set_var("OTEL_RESOURCE_ATTRIBUTES", "service.name=env-resource"); - assert_eq!( - TestConfig { - service_name: Some("yaml-service-name".to_string()), - service_namespace: None, - resources: BTreeMap::from_iter(vec![( - opentelemetry_semantic_conventions::resource::SERVICE_NAME.to_string(), - AttributeValue::String("yaml-resource".to_string()), - )]), - } - .to_resource() - .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME), - Some("env-resource".into()) - ); - - env::set_var("OTEL_SERVICE_NAME", "env-service-name"); - assert_eq!( - TestConfig { - service_name: Some("yaml-service-name".to_string()), - service_namespace: None, - resources: BTreeMap::from_iter(vec![( - opentelemetry_semantic_conventions::resource::SERVICE_NAME.to_string(), - AttributeValue::String("yaml-resource".to_string()), - )]), - } - .to_resource() - .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME), - Some("env-service-name".into()) - ); - - env::remove_var("OTEL_SERVICE_NAME"); - env::remove_var("OTEL_RESOURCE_ATTRIBUTES"); - } -} +// Tests in apollo-router/tests/telemetry_resource_tests.rs diff --git a/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__fmt_layer__tests__json_logging_deduplicates_attributes.snap b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__fmt_layer__tests__json_logging_deduplicates_attributes.snap new file mode 100644 index 0000000000..07685fa82c --- /dev/null +++ b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__fmt_layer__tests__json_logging_deduplicates_attributes.snap @@ -0,0 +1,6 @@ +--- +source: apollo-router/src/plugins/telemetry/fmt_layer.rs +expression: buff.to_string() +--- +{"timestamp":"[timestamp]","level":"ERROR","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","static":"foo","subgraph.name":"subgraph_bis","message":"this event has a duplicate attribute","kind":"event.with.duplicate.attribute","target":"apollo_router::plugins::telemetry::config_new::events"} +{"timestamp":"[timestamp]","level":"ERROR","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","static":"foo","message":"this event has a duplicate attribute","kind":"event.with.duplicate.attribute","target":"apollo_router::plugins::telemetry::config_new::events"} diff --git a/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__fmt_layer__tests__json_logging_with_custom_events_with_instrumented.snap b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__fmt_layer__tests__json_logging_with_custom_events_with_instrumented.snap index 0169cc4c8d..54869c90a7 100644 --- a/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__fmt_layer__tests__json_logging_with_custom_events_with_instrumented.snap +++ b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__fmt_layer__tests__json_logging_with_custom_events_with_instrumented.snap @@ -4,9 +4,8 @@ expression: buff.to_string() --- {"timestamp":"[timestamp]","level":"INFO","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","http.response.body":"{\"foo\": \"bar\"}","http.response.body.size":125,"message":"my message","kind":"my_custom_event","target":"apollo_router::plugins::telemetry::config_new::events"} {"timestamp":"[timestamp]","level":"ERROR","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","message":"Hello from test","http.method":"GET","target":"apollo_router::plugins::telemetry::fmt_layer::tests"} -{"timestamp":"[timestamp]","level":"INFO","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","http.request.body":"Body(Empty)","http.request.headers":"{\"content-length\": \"0\", \"custom-header\": \"val1\", \"x-log-request\": \"log\"}","http.request.method":"GET","http.request.uri":"http://example.com/","http.request.version":"HTTP/1.1","message":"","kind":"router.request","target":"apollo_router::plugins::telemetry::config_new::events"} {"timestamp":"[timestamp]","level":"INFO","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","http.request.body.size":0,"message":"my event message","kind":"my.request_event","target":"apollo_router::plugins::telemetry::config_new::events"} -{"timestamp":"[timestamp]","level":"INFO","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","http.response.body":"Body(Full(b\"{\\\"data\\\":{\\\"data\\\":\\\"res\\\"}}\"))","http.response.headers":"{\"content-length\": \"25\", \"custom-header\": \"val1\", \"x-log-request\": \"log\"}","http.response.status":"200 OK","http.response.version":"HTTP/1.1","message":"","kind":"router.response","target":"apollo_router::plugins::telemetry::config_new::events"} +{"timestamp":"[timestamp]","level":"INFO","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","http.response.body":"{\"data\":{\"data\":\"res\"}}","http.response.headers":"{\"content-length\": \"25\", \"custom-header\": \"val1\", \"x-log-request\": \"log\"}","http.response.status":"200 OK","http.response.version":"HTTP/1.1","message":"","kind":"router.response","target":"apollo_router::plugins::telemetry::config_new::events"} {"timestamp":"[timestamp]","level":"INFO","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","http.response.body.size":25,"message":"my response event message","kind":"my.response_event","target":"apollo_router::plugins::telemetry::config_new::events"} {"timestamp":"[timestamp]","level":"INFO","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","http.request.body":"{\"query\":\"query { foo }\"}","http.request.headers":"{\"content-type\": \"application/json\", \"x-log-request\": \"log\"}","http.request.method":"POST","http.request.uri":"http://default/","http.request.version":"HTTP/1.1","message":"","kind":"supergraph.request","target":"apollo_router::plugins::telemetry::config_new::events"} {"timestamp":"[timestamp]","level":"INFO","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","message":"my event message","kind":"my.request.event","target":"apollo_router::plugins::telemetry::config_new::events"} @@ -15,3 +14,5 @@ expression: buff.to_string() {"timestamp":"[timestamp]","level":"ERROR","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","my.custom.attribute":["{\"id\":1234,\"name\":\"first_name\"}","{\"id\":567,\"name\":\"second_name\"}"],"response_status":200,"subgraph.name":"subgraph","message":"my response event message","kind":"my.subgraph.response.event","target":"apollo_router::plugins::telemetry::config_new::events"} {"timestamp":"[timestamp]","level":"INFO","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","message":"my event message","kind":"my.subgraph.request.event","target":"apollo_router::plugins::telemetry::config_new::events"} {"timestamp":"[timestamp]","level":"ERROR","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","my.custom.attribute":"[[{\"id\":1234,\"name\":\"first_name\"},{\"id\":567,\"name\":\"second_name\"}],{\"foo\":\"bar\"}]","response_status":200,"subgraph.name":"subgraph_bis","message":"my response event message","kind":"my.subgraph.response.event","target":"apollo_router::plugins::telemetry::config_new::events"} +{"timestamp":"[timestamp]","level":"INFO","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","connector_source":"source","http_method":"GET","mapping_problems":["{\"message\":\"error message\",\"path\":\"@.id\",\"count\":1,\"location\":\"Selection\"}","{\"message\":\"warn message\",\"path\":\"@.id\",\"count\":2,\"location\":\"Selection\"}","{\"message\":\"info message\",\"path\":\"@.id\",\"count\":3,\"location\":\"Selection\"}"],"mapping_problems_count":6,"subgraph.name":"connector_subgraph","url_template":"/test","message":"my request event message","kind":"my.connector.request.event","target":"apollo_router::plugins::telemetry::config_new::events"} +{"timestamp":"[timestamp]","level":"ERROR","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","connector_source":"source","http_method":"GET","mapping_problems":["{\"message\":\"error message\",\"path\":\"@.id\",\"count\":1,\"location\":\"Selection\"}","{\"message\":\"warn message\",\"path\":\"@.id\",\"count\":2,\"location\":\"Selection\"}","{\"message\":\"info message\",\"path\":\"@.id\",\"count\":3,\"location\":\"Selection\"}"],"mapping_problems_count":6,"response_status":200,"subgraph.name":"connector_subgraph","url_template":"/test","message":"my response event message","kind":"my.connector.response.event","target":"apollo_router::plugins::telemetry::config_new::events"} diff --git a/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__fmt_layer__tests__text_logging_with_custom_events_with_instrumented.snap b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__fmt_layer__tests__text_logging_with_custom_events_with_instrumented.snap index d329c82801..1f064cdc6c 100644 --- a/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__fmt_layer__tests__text_logging_with_custom_events_with_instrumented.snap +++ b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__fmt_layer__tests__text_logging_with_custom_events_with_instrumented.snap @@ -4,9 +4,8 @@ expression: buff.to_string() --- [timestamp] INFO http.response.body={"foo": "bar"} http.response.body.size=125 my message kind=my_custom_event [timestamp] ERROR Hello from test http.method="GET" -[timestamp] INFO http.request.body=Body(Empty) http.request.headers={"content-length": "0", "custom-header": "val1", "x-log-request": "log"} http.request.method=GET http.request.uri=http://example.com/ http.request.version=HTTP/1.1 kind=router.request [timestamp] INFO http.request.body.size=0 my event message kind=my.request_event -[timestamp] INFO http.response.body=Body(Full(b"{\"data\":{\"data\":\"res\"}}")) http.response.headers={"content-length": "25", "custom-header": "val1", "x-log-request": "log"} http.response.status=200 OK http.response.version=HTTP/1.1 kind=router.response +[timestamp] INFO http.response.body={"data":{"data":"res"}} http.response.headers={"content-length": "25", "custom-header": "val1", "x-log-request": "log"} http.response.status=200 OK http.response.version=HTTP/1.1 kind=router.response [timestamp] INFO http.response.body.size=25 my response event message kind=my.response_event [timestamp] INFO http.request.body={"query":"query { foo }"} http.request.headers={"content-type": "application/json", "x-log-request": "log"} http.request.method=POST http.request.uri=http://default/ http.request.version=HTTP/1.1 kind=supergraph.request [timestamp] INFO my event message kind=my.request.event @@ -15,3 +14,5 @@ expression: buff.to_string() [timestamp] ERROR my.custom.attribute=["{"id":1234,"name":"first_name"}","{"id":567,"name":"second_name"}"] response_status=200 subgraph.name=subgraph my response event message kind=my.subgraph.response.event [timestamp] INFO my event message kind=my.subgraph.request.event [timestamp] ERROR my.custom.attribute=[[{"id":1234,"name":"first_name"},{"id":567,"name":"second_name"}],{"foo":"bar"}] response_status=200 subgraph.name=subgraph_bis my response event message kind=my.subgraph.response.event +[timestamp] INFO connector_source=source http_method=GET mapping_problems=["{"message":"error message","path":"@.id","count":1,"location":"Selection"}","{"message":"warn message","path":"@.id","count":2,"location":"Selection"}","{"message":"info message","path":"@.id","count":3,"location":"Selection"}"] mapping_problems_count=6 subgraph.name=connector_subgraph url_template=/test my request event message kind=my.connector.request.event +[timestamp] ERROR connector_source=source http_method=GET mapping_problems=["{"message":"error message","path":"@.id","count":1,"location":"Selection"}","{"message":"warn message","path":"@.id","count":2,"location":"Selection"}","{"message":"info message","path":"@.id","count":3,"location":"Selection"}"] mapping_problems_count=6 response_status=200 subgraph.name=connector_subgraph url_template=/test my response event message kind=my.connector.response.event diff --git a/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics.snap b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics.snap index eae5de460a..925e5b8de3 100644 --- a/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics.snap +++ b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics.snap @@ -1,17 +1,17 @@ --- source: apollo-router/src/plugins/telemetry/mod.rs -expression: prometheus_metrics +expression: "prometheus_metrics.replace(& format!\n(r#\"service_version=\"{}\"\"#, std :: env! (\"CARGO_PKG_VERSION\")),\nr#\"service_version=\"X\"\"#)" --- -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="+Inf"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="0.001"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="0.005"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="0.015"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="0.05"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="0.1"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="0.2"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="0.3"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="0.4"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="0.5"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="1"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="10"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="5"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="+Inf"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.001"} 0 +apollo_test_histo_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.005"} 0 +apollo_test_histo_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.015"} 0 +apollo_test_histo_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.05"} 0 +apollo_test_histo_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.1"} 0 +apollo_test_histo_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.2"} 0 +apollo_test_histo_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.3"} 0 +apollo_test_histo_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.4"} 0 +apollo_test_histo_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.5"} 0 +apollo_test_histo_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="1"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="10"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="5"} 1 diff --git a/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_custom_buckets.snap b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_custom_buckets.snap index 3f346c2ad6..7d18a1854c 100644 --- a/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_custom_buckets.snap +++ b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_custom_buckets.snap @@ -1,8 +1,8 @@ --- source: apollo-router/src/plugins/telemetry/mod.rs -expression: prometheus_metrics +expression: "prometheus_metrics.replace(& format!\n(r#\"service_version=\"{}\"\"#, std :: env! (\"CARGO_PKG_VERSION\")),\nr#\"service_version=\"X\"\"#)" --- -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="+Inf"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="10"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="20"} 1 -apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="5"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",le="+Inf"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",le="10"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",le="20"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",le="5"} 1 diff --git a/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_custom_buckets_for_specific_metrics.snap b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_custom_buckets_for_specific_metrics.snap index 49dfbd2b76..4949edee8a 100644 --- a/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_custom_buckets_for_specific_metrics.snap +++ b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_custom_buckets_for_specific_metrics.snap @@ -1,10 +1,10 @@ --- source: apollo-router/src/plugins/telemetry/mod.rs -expression: prometheus_metrics +expression: "prometheus_metrics.replace(& format!\n(r#\"service_version=\"{}\"\"#, std :: env! (\"CARGO_PKG_VERSION\")),\nr#\"service_version=\"X\"\"#)" --- -apollo_router_http_request_duration_seconds_bucket{otel_scope_name="apollo/router",le="+Inf"} 1 -apollo_router_http_request_duration_seconds_bucket{otel_scope_name="apollo/router",le="1"} 1 -apollo_router_http_request_duration_seconds_bucket{otel_scope_name="apollo/router",le="2"} 1 -apollo_router_http_request_duration_seconds_bucket{otel_scope_name="apollo/router",le="3"} 1 -apollo_router_http_request_duration_seconds_bucket{otel_scope_name="apollo/router",le="4"} 1 -apollo_router_http_request_duration_seconds_bucket{otel_scope_name="apollo/router",le="5"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",le="+Inf"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",le="1"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",le="2"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",le="3"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",le="4"} 1 +apollo_test_histo_bucket{otel_scope_name="apollo/router",le="5"} 1 diff --git a/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_custom_view_drop.snap b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_custom_view_drop.snap new file mode 100644 index 0000000000..e615546e4a --- /dev/null +++ b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_custom_view_drop.snap @@ -0,0 +1,5 @@ +--- +source: apollo-router/src/plugins/telemetry/mod.rs +expression: "prometheus_metrics.replace(& format!\n(r#\"service_version=\"{}\"\"#, std :: env! (\"CARGO_PKG_VERSION\")),\nr#\"service_version=\"X\"\"#)" +--- + diff --git a/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_units_are_included.snap b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_units_are_included.snap new file mode 100644 index 0000000000..b6cf273ce5 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/snapshots/apollo_router__plugins__telemetry__tests__it_test_prometheus_metrics_units_are_included.snap @@ -0,0 +1,30 @@ +--- +source: apollo-router/src/plugins/telemetry/mod.rs +expression: "prometheus_metrics.replace(& format!\n(r#\"service_version=\"{}\"\"#, std :: env! (\"CARGO_PKG_VERSION\")),\nr#\"service_version=\"X\"\"#)" +--- +apollo_test_histo1_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="+Inf"} 1 +apollo_test_histo1_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.001"} 0 +apollo_test_histo1_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.005"} 0 +apollo_test_histo1_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.015"} 0 +apollo_test_histo1_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.05"} 0 +apollo_test_histo1_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.1"} 0 +apollo_test_histo1_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.2"} 0 +apollo_test_histo1_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.3"} 0 +apollo_test_histo1_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.4"} 0 +apollo_test_histo1_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.5"} 0 +apollo_test_histo1_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="1"} 1 +apollo_test_histo1_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="10"} 1 +apollo_test_histo1_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="5"} 1 +apollo_test_histo2_seconds_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="+Inf"} 1 +apollo_test_histo2_seconds_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.001"} 0 +apollo_test_histo2_seconds_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.005"} 0 +apollo_test_histo2_seconds_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.015"} 0 +apollo_test_histo2_seconds_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.05"} 0 +apollo_test_histo2_seconds_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.1"} 0 +apollo_test_histo2_seconds_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.2"} 0 +apollo_test_histo2_seconds_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.3"} 0 +apollo_test_histo2_seconds_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.4"} 0 +apollo_test_histo2_seconds_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="0.5"} 0 +apollo_test_histo2_seconds_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="1"} 1 +apollo_test_histo2_seconds_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="10"} 1 +apollo_test_histo2_seconds_bucket{otel_scope_name="apollo/router",service_version="X",test_resource="test",le="5"} 1 diff --git a/apollo-router/src/plugins/telemetry/span_ext.rs b/apollo-router/src/plugins/telemetry/span_ext.rs new file mode 100644 index 0000000000..5de2f3d297 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/span_ext.rs @@ -0,0 +1,27 @@ +use opentelemetry::Key; +use opentelemetry::KeyValue; +use opentelemetry::Value; + +use crate::plugins::telemetry::consts::OTEL_STATUS_CODE; +use crate::plugins::telemetry::consts::OTEL_STATUS_DESCRIPTION; +use crate::plugins::telemetry::dynamic_attribute::SpanDynAttribute; + +/// To add dynamic attributes for spans +pub(crate) trait SpanMarkError { + fn mark_as_error(&self, error_message: String); +} + +impl SpanMarkError for ::tracing::Span { + fn mark_as_error(&self, error_message: String) { + self.set_span_dyn_attributes([ + KeyValue::new( + Key::from_static_str(OTEL_STATUS_CODE), + Value::String("ERROR".to_string().into()), + ), + KeyValue::new( + Key::from_static_str(OTEL_STATUS_DESCRIPTION), + Value::String(error_message.into()), + ), + ]); + } +} diff --git a/apollo-router/src/plugins/telemetry/span_factory.rs b/apollo-router/src/plugins/telemetry/span_factory.rs index a13b2da7fd..b7a0f044a5 100644 --- a/apollo-router/src/plugins/telemetry/span_factory.rs +++ b/apollo-router/src/plugins/telemetry/span_factory.rs @@ -4,25 +4,26 @@ use tracing::error_span; use tracing::info_span; use crate::context::OPERATION_NAME; +use crate::plugins::telemetry::Telemetry; +use crate::plugins::telemetry::consts::CONNECT_REQUEST_SPAN_NAME; use crate::plugins::telemetry::consts::REQUEST_SPAN_NAME; use crate::plugins::telemetry::consts::ROUTER_SPAN_NAME; use crate::plugins::telemetry::consts::SUBGRAPH_SPAN_NAME; use crate::plugins::telemetry::consts::SUPERGRAPH_SPAN_NAME; -use crate::plugins::telemetry::Telemetry; use crate::services::SubgraphRequest; use crate::services::SupergraphRequest; use crate::tracer::TraceId; -use crate::uplink::license_enforcement::LicenseState; use crate::uplink::license_enforcement::LICENSE_EXPIRED_SHORT_MESSAGE; +use crate::uplink::license_enforcement::LicenseState; #[derive(Debug, Copy, Clone, Deserialize, JsonSchema, Default, Eq, PartialEq)] /// Span mode to create new or deprecated spans #[serde(rename_all = "snake_case")] pub(crate) enum SpanMode { /// Keep the request span as root span and deprecated attributes. This option will eventually removed. - #[default] Deprecated, /// Use new OpenTelemetry spec compliant span attributes or preserve existing. This will be the default in future. + #[default] SpecCompliant, } @@ -30,19 +31,20 @@ impl SpanMode { pub(crate) fn create_request( &self, request: &http::Request, - license_state: LicenseState, + license_state: &LicenseState, ) -> ::tracing::span::Span { match self { SpanMode::Deprecated => { if matches!( license_state, - LicenseState::LicensedWarn | LicenseState::LicensedHalt + LicenseState::LicensedWarn { limits: _ } + | LicenseState::LicensedHalt { limits: _ } ) { error_span!( REQUEST_SPAN_NAME, "http.method" = %request.method(), "http.request.method" = %request.method(), - "http.route" = %request.uri(), + "http.route" = %request.uri().path(), "http.flavor" = ?request.version(), "http.status" = 500, // This prevents setting later "otel.name" = ::tracing::field::Empty, @@ -57,7 +59,7 @@ impl SpanMode { REQUEST_SPAN_NAME, "http.method" = %request.method(), "http.request.method" = %request.method(), - "http.route" = %request.uri(), + "http.route" = %request.uri().path(), "http.flavor" = ?request.version(), "otel.name" = ::tracing::field::Empty, "otel.kind" = "SERVER", @@ -82,7 +84,7 @@ impl SpanMode { let span = info_span!(ROUTER_SPAN_NAME, "http.method" = %request.method(), "http.request.method" = %request.method(), - "http.route" = %request.uri(), + "http.route" = %request.uri().path(), "http.flavor" = ?request.version(), "trace_id" = %trace_id, "client.name" = ::tracing::field::Empty, @@ -98,7 +100,7 @@ impl SpanMode { SpanMode::SpecCompliant => { info_span!(ROUTER_SPAN_NAME, // Needed for apollo_telemetry and datadog span mapping - "http.route" = %request.uri(), + "http.route" = %request.uri().path(), "http.request.method" = %request.method(), "otel.name" = ::tracing::field::Empty, "otel.kind" = "SERVER", @@ -207,4 +209,125 @@ impl SpanMode { } } } + + pub(crate) fn create_connector(&self, source_name: &str) -> ::tracing::span::Span { + match self { + SpanMode::Deprecated => { + info_span!( + CONNECT_REQUEST_SPAN_NAME, + "apollo.source.name" = source_name, + "otel.kind" = "INTERNAL", + "otel.status_code" = ::tracing::field::Empty, + ) + } + SpanMode::SpecCompliant => { + info_span!( + CONNECT_REQUEST_SPAN_NAME, + "otel.kind" = "INTERNAL", + "otel.status_code" = ::tracing::field::Empty, + ) + } + } + } +} + +#[cfg(test)] +mod tests { + use tracing_mock::expect; + use tracing_mock::subscriber; + + use crate::plugins::telemetry::SpanMode; + use crate::plugins::telemetry::consts::REQUEST_SPAN_NAME; + use crate::plugins::telemetry::consts::ROUTER_SPAN_NAME; + use crate::uplink::license_enforcement::LicenseState; + + #[test] + fn test_specific_span() { + // NB: this test checks the behavior of tracing_mock for a specific span. + // Most tests should probably follow the pattern of `test_http_route_on_array_of_router_spans` + // where they check a behavior across a variety of parameters. + let request = http::Request::builder() + .method("GET") + .uri("http://example.com/path/to/location?with=query&another=UN1QU3_query") + .header("apollographql-client-name", "client") + .body("useful info") + .unwrap(); + + let expected_fields = expect::field("http.route") + .with_value(&tracing::field::display("/path/to/location")) + .and(expect::field("http.request.method").with_value(&tracing::field::display("GET"))) + .and(expect::field("otel.kind").with_value(&"SERVER")) + .and(expect::field("apollo_private.request").with_value(&true)); + + let expected_span = expect::span() + .named(ROUTER_SPAN_NAME) + .with_fields(expected_fields); + + let (subscriber, handle) = subscriber::mock() + .new_span(expected_span) + .enter(ROUTER_SPAN_NAME) + .event(expect::event()) + .exit(ROUTER_SPAN_NAME) + .run_with_handle(); + tracing::subscriber::with_default(subscriber, || { + let span = SpanMode::SpecCompliant.create_router(&request); + let _guard = span.enter(); + tracing::info!("an event happened!"); + }); + handle.assert_finished(); + } + + #[test] + fn test_http_route_on_array_of_router_spans() { + let expected_routes = [ + ("https://www.example.com/", "/"), + ("https://www.example.com/path", "/path"), + ("http://example.com/path/to/location", "/path/to/location"), + ("http://www.example.com/path?with=query", "/path"), + ("/foo/bar?baz", "/foo/bar"), + ]; + + let span_modes = [SpanMode::SpecCompliant, SpanMode::Deprecated]; + let license_states = [ + LicenseState::LicensedHalt { limits: None }, + LicenseState::Unlicensed, + ]; + + for (uri, expected_route) in expected_routes { + let request = http::Request::builder().uri(uri).body("").unwrap(); + + // test `request` spans + for license_state in &license_states { + let expected_span = expect::span().named(REQUEST_SPAN_NAME).with_fields( + expect::field("http.route") + .with_value(&tracing::field::display(expected_route)), + ); + + let span_mode = SpanMode::Deprecated; + let (subscriber, handle) = + subscriber::mock().new_span(expected_span).run_with_handle(); + tracing::subscriber::with_default(subscriber, || { + let span = span_mode.create_request(&request, license_state); + let _guard = span.enter(); + }); + handle.assert_finished(); + } + + // test `router` spans + for span_mode in span_modes { + let expected_span = expect::span().named(ROUTER_SPAN_NAME).with_fields( + expect::field("http.route") + .with_value(&tracing::field::display(expected_route)), + ); + + let (subscriber, handle) = + subscriber::mock().new_span(expected_span).run_with_handle(); + tracing::subscriber::with_default(subscriber, || { + let span = span_mode.create_router(&request); + let _guard = span.enter(); + }); + handle.assert_finished(); + } + } + } } diff --git a/apollo-router/src/plugins/telemetry/testdata/config.field_instrumentation_sampler.router.yaml b/apollo-router/src/plugins/telemetry/testdata/config.field_instrumentation_sampler.router.yaml new file mode 100644 index 0000000000..54f4167b22 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/testdata/config.field_instrumentation_sampler.router.yaml @@ -0,0 +1,11 @@ +telemetry: + instrumentation: + spans: + mode: spec_compliant + apollo: + field_level_instrumentation_sampler: 1.0 + exporters: + tracing: + common: + preview_datadog_agent_sampling: true + sampler: 0.5 \ No newline at end of file diff --git a/apollo-router/src/plugins/telemetry/testdata/config.router.yaml b/apollo-router/src/plugins/telemetry/testdata/config.router.yaml index 39bc874c65..660ca5005f 100644 --- a/apollo-router/src/plugins/telemetry/testdata/config.router.yaml +++ b/apollo-router/src/plugins/telemetry/testdata/config.router.yaml @@ -162,83 +162,3 @@ telemetry: bool_arr: - true - false - metrics: - common: - attributes: - supergraph: - static: - - name: myname - value: label_value - request: - header: - - named: test - default: default_value - rename: renamed_value - body: - - path: .data.test - name: my_new_name - default: default_value - response: - header: - - named: test - default: default_value - rename: renamed_value - - named: test - default: default_value - rename: renamed_value - body: - - path: .data.test - name: my_new_name - default: default_value - subgraph: - all: - static: - - name: myname - value: label_value - request: - header: - - named: test - default: default_value - rename: renamed_value - body: - - path: .data.test - name: my_new_name - default: default_value - response: - header: - - named: test - default: default_value - rename: renamed_value - - named: test - default: default_value - rename: renamed_value - body: - - path: .data.test - name: my_new_name - default: default_value - subgraphs: - subgraph_name_test: - static: - - name: myname - value: label_value - request: - header: - - named: test - default: default_value - rename: renamed_value - body: - - path: .data.test - name: my_new_name - default: default_value - response: - header: - - named: test - default: default_value - rename: renamed_value - - named: test - default: default_value - rename: renamed_value - body: - - path: .data.test - name: my_new_name - default: default_value diff --git a/apollo-router/src/plugins/telemetry/testdata/custom_attributes.router.yaml b/apollo-router/src/plugins/telemetry/testdata/custom_attributes.router.yaml index ec2045ecba..d4628b155b 100644 --- a/apollo-router/src/plugins/telemetry/testdata/custom_attributes.router.yaml +++ b/apollo-router/src/plugins/telemetry/testdata/custom_attributes.router.yaml @@ -2,51 +2,43 @@ telemetry: apollo: client_name_header: name_header client_version_header: version_header - exporters: - metrics: - common: - service_name: apollo-router - attributes: - supergraph: - static: - - name: myname - value: label_value - request: - header: - - named: test - default: default_value - rename: renamed_value - - named: another_test - default: my_default_value - response: - header: - - named: x-custom - body: - - path: .data.data.my_value - name: my_value - subgraph: - all: - errors: - include_messages: true - extensions: - - name: subgraph_error_extended_code - path: .code - - name: message - path: .reason - subgraphs: - my_subgraph_name: - request: - body: - - path: .query - name: query_from_request - - path: .data - name: unknown_data - default: default_value - - path: .data2 - name: unknown_data_bis - response: - body: - - path: .errors[0].extensions.status - name: error - context: - - named: my_key + instrumentation: + instruments: + supergraph: + http.request: + value: event_unit + type: counter + unit: count + description: "supergraph requests" + attributes: + myname: + static: label_value + renamed_value: + request_header: test + default: default_value + another_test: + request_header: another_test + default: my_default_value + x-custom: + response_header: x-custom + my_value: + response_data: $.data.my_value + error: + response_errors: $[0].message + subgraph: + http.client.request.duration: + attributes: + subgraph.name: + alias: subgraph + subgraph.graphql.document: + alias: query_from_request + status: + subgraph_response_status: code + subgraph_error_extended_code: + subgraph_response_errors: $[0].extensions.code + message: + error: reason + error: + subgraph_response_errors: $[0].extensions.status + my_key: + response_context: my_key \ No newline at end of file diff --git a/apollo-router/src/plugins/telemetry/testdata/custom_events.router.yaml b/apollo-router/src/plugins/telemetry/testdata/custom_events.router.yaml index c3c23cb68f..83b2bbaa55 100644 --- a/apollo-router/src/plugins/telemetry/testdata/custom_events.router.yaml +++ b/apollo-router/src/plugins/telemetry/testdata/custom_events.router.yaml @@ -144,4 +144,39 @@ telemetry: subgraph_response_status: code "my.custom.attribute": subgraph_response_data: "$.*" - default: "missing" \ No newline at end of file + default: "missing" + connector: + # Standard events cannot be tested, because the test harness mocks the service that emits them + + # Custom events + my.disabled_request.event: + message: "my disabled event message" + level: off + on: request + my.request.event: + message: "my request event message" + level: info + on: request + condition: + eq: + - connector_http_request_header: x-log-request + - "log" + my.response.event: + message: "my response event message" + level: error + on: response + condition: + all: + - eq: + - connector_http_response_header: x-log-response + - "log" + - eq: + - 200 + - connector_http_response_status: code + - eq: + - subgraph_name: true + - "subgraph" + attributes: + subgraph.name: true + response_status: + connector_http_response_status: code \ No newline at end of file diff --git a/apollo-router/src/plugins/telemetry/testdata/full_config_all_features_defaults.router.yaml b/apollo-router/src/plugins/telemetry/testdata/full_config_all_features_defaults.router.yaml new file mode 100644 index 0000000000..55c5224aee --- /dev/null +++ b/apollo-router/src/plugins/telemetry/testdata/full_config_all_features_defaults.router.yaml @@ -0,0 +1,169 @@ +# Disable apq distributed cache by omission + +# Disable entity cache by omission + +# Telemetry plugin config +telemetry: + instrumentation: + spans: + mode: spec_compliant + default_attribute_requirement_level: recommended + router: + attributes: + "custom_one": + request_header: host + supergraph: + attributes: + graphql.document: true + subgraph: + attributes: + subgraph.graphql.document: true + instruments: + router: + http.server.request.body.size: + attributes: + # Standard attributes + http.response.status_code: true + "my_attribute": + response_header: "content-type" + http.server.request.duration: + attributes: + # Standard attributes + http.response.status_code: true + http.request.method: true + # Custom attribute + "my_attribute": + response_header: "content-type" + my.request.duration: # The name of your custom instrument/metric + value: duration + type: counter + unit: s + description: "my description" + acme.request.size: # The name of your custom instrument/metric + value: + request_header: "content-length" + type: counter + unit: s + description: "my description" + + acme.request.length: # The name of your custom instrument/metric + value: + request_header: "content-length" + type: histogram + unit: s + description: "my description" + supergraph: + acme.graphql.requests: + value: unit + type: counter + unit: request + description: "supergraph requests" + attributes: + static: hello + graphql_operation_kind: + operation_kind: string + subgraph: + request_including_price1: + value: unit + type: counter + unit: request + description: "supergraph requests" + condition: + exists: + subgraph_response_data: "$.products[*].price1" + attributes: + subgraph.name: true + events: + router: + # Standard events + request: info + response: info + error: info + + # Custom events + my.request_event: + message: "my event message" + level: info + on: request + attributes: + http.request.body.size: true + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - request_header: "x-log-request" + my.response_event: + message: "my response event message" + level: info + on: response + attributes: + http.response.body.size: true + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - response_header: "x-log-request" + supergraph: + # Standard events + request: info + response: info + error: info + + # Custom events + my.request.event: + message: "my event message" + level: info + on: request + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - request_header: "x-log-request" + my.response_event: + message: "my response event message" + level: warn + on: response + condition: + eq: + - "log" + - response_header: "x-log-request" + subgraph: + # Standard events + request: info + response: warn + error: error + + # Custom events + my.request.event: + message: "my event message" + level: info + on: request + my.response.event: + message: "my response event message" + level: error + on: response + attributes: + subgraph.name: true + response_status: + subgraph_response_status: code + exporters: + tracing: + common: + service_name: router + resource: + str: a + int: 1 + float: 1 + bool: true + str_arr: + - a + - b + int_arr: + - 1 + - 2 + float_arr: + - 1 + - 2 + bool_arr: + - true + - false diff --git a/apollo-router/src/plugins/telemetry/testdata/full_config_all_features_enabled.router.yaml b/apollo-router/src/plugins/telemetry/testdata/full_config_all_features_enabled.router.yaml new file mode 100644 index 0000000000..89971c36b5 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/testdata/full_config_all_features_enabled.router.yaml @@ -0,0 +1,186 @@ +# Enable distributed apq cache +apq: + enabled: true + router: + cache: + redis: + urls: + - https://example.com + subgraph: + all: + enabled: true + +# Enable entity cache +preview_entity_cache: + enabled: true + subgraph: + all: + enabled: true + redis: + urls: [ "redis://..." ] + +# Telemetry plugin config +telemetry: + instrumentation: + spans: + mode: spec_compliant + default_attribute_requirement_level: recommended + router: + attributes: + "custom_one": + request_header: host + supergraph: + attributes: + graphql.document: true + subgraph: + attributes: + subgraph.graphql.document: true + instruments: + router: + http.server.request.body.size: + attributes: + # Standard attributes + http.response.status_code: true + "my_attribute": + response_header: "content-type" + http.server.request.duration: + attributes: + # Standard attributes + http.response.status_code: true + http.request.method: true + # Custom attribute + "my_attribute": + response_header: "content-type" + my.request.duration: # The name of your custom instrument/metric + value: duration + type: counter + unit: s + description: "my description" + acme.request.size: # The name of your custom instrument/metric + value: + request_header: "content-length" + type: counter + unit: s + description: "my description" + + acme.request.length: # The name of your custom instrument/metric + value: + request_header: "content-length" + type: histogram + unit: s + description: "my description" + supergraph: + acme.graphql.requests: + value: unit + type: counter + unit: request + description: "supergraph requests" + attributes: + static: hello + graphql_operation_kind: + operation_kind: string + subgraph: + request_including_price1: + value: unit + type: counter + unit: request + description: "supergraph requests" + condition: + exists: + subgraph_response_data: "$.products[*].price1" + attributes: + subgraph.name: true + events: + router: + # Standard events + request: info + response: info + error: info + + # Custom events + my.request_event: + message: "my event message" + level: info + on: request + attributes: + http.request.body.size: true + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - request_header: "x-log-request" + my.response_event: + message: "my response event message" + level: info + on: response + attributes: + http.response.body.size: true + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - response_header: "x-log-request" + supergraph: + # Standard events + request: info + response: info + error: info + + # Custom events + my.request.event: + message: "my event message" + level: info + on: request + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - request_header: "x-log-request" + my.response_event: + message: "my response event message" + level: warn + on: response + condition: + eq: + - "log" + - response_header: "x-log-request" + subgraph: + # Standard events + request: info + response: warn + error: error + + # Custom events + my.request.event: + message: "my event message" + level: info + on: request + my.response.event: + message: "my response event message" + level: error + on: response + attributes: + subgraph.name: true + response_status: + subgraph_response_status: code + exporters: + tracing: + common: + service_name: router + resource: + str: a + int: 1 + float: 1 + bool: true + str_arr: + - a + - b + int_arr: + - 1 + - 2 + float_arr: + - 1 + - 2 + bool_arr: + - true + - false diff --git a/apollo-router/src/plugins/telemetry/testdata/full_config_all_features_explicitly_disabled.router.yaml b/apollo-router/src/plugins/telemetry/testdata/full_config_all_features_explicitly_disabled.router.yaml new file mode 100644 index 0000000000..a3c876d614 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/testdata/full_config_all_features_explicitly_disabled.router.yaml @@ -0,0 +1,185 @@ +# Disable apq +apq: + enabled: false + router: + cache: + in_memory: + limit: 1000 + subgraph: + all: + enabled: true + +# Disable entity cache +preview_entity_cache: + enabled: false + subgraph: + all: + enabled: true + redis: + urls: [ "redis://..." ] + +# Telemetry plugin config +telemetry: + instrumentation: + spans: + mode: spec_compliant + default_attribute_requirement_level: recommended + router: + attributes: + "custom_one": + request_header: host + supergraph: + attributes: + graphql.document: true + subgraph: + attributes: + subgraph.graphql.document: true + instruments: + router: + http.server.request.body.size: + attributes: + # Standard attributes + http.response.status_code: true + "my_attribute": + response_header: "content-type" + http.server.request.duration: + attributes: + # Standard attributes + http.response.status_code: true + http.request.method: true + # Custom attribute + "my_attribute": + response_header: "content-type" + my.request.duration: # The name of your custom instrument/metric + value: duration + type: counter + unit: s + description: "my description" + acme.request.size: # The name of your custom instrument/metric + value: + request_header: "content-length" + type: counter + unit: s + description: "my description" + + acme.request.length: # The name of your custom instrument/metric + value: + request_header: "content-length" + type: histogram + unit: s + description: "my description" + supergraph: + acme.graphql.requests: + value: unit + type: counter + unit: request + description: "supergraph requests" + attributes: + static: hello + graphql_operation_kind: + operation_kind: string + subgraph: + request_including_price1: + value: unit + type: counter + unit: request + description: "supergraph requests" + condition: + exists: + subgraph_response_data: "$.products[*].price1" + attributes: + subgraph.name: true + events: + router: + # Standard events + request: info + response: info + error: info + + # Custom events + my.request_event: + message: "my event message" + level: info + on: request + attributes: + http.request.body.size: true + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - request_header: "x-log-request" + my.response_event: + message: "my response event message" + level: info + on: response + attributes: + http.response.body.size: true + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - response_header: "x-log-request" + supergraph: + # Standard events + request: info + response: info + error: info + + # Custom events + my.request.event: + message: "my event message" + level: info + on: request + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - request_header: "x-log-request" + my.response_event: + message: "my response event message" + level: warn + on: response + condition: + eq: + - "log" + - response_header: "x-log-request" + subgraph: + # Standard events + request: info + response: warn + error: error + + # Custom events + my.request.event: + message: "my event message" + level: info + on: request + my.response.event: + message: "my response event message" + level: error + on: response + attributes: + subgraph.name: true + response_status: + subgraph_response_status: code + exporters: + tracing: + common: + service_name: router + resource: + str: a + int: 1 + float: 1 + bool: true + str_arr: + - a + - b + int_arr: + - 1 + - 2 + float_arr: + - 1 + - 2 + bool_arr: + - true + - false diff --git a/apollo-router/src/plugins/telemetry/testdata/full_config_apq_disabled_partial_defaults.router.yaml b/apollo-router/src/plugins/telemetry/testdata/full_config_apq_disabled_partial_defaults.router.yaml new file mode 100644 index 0000000000..ccff9a0383 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/testdata/full_config_apq_disabled_partial_defaults.router.yaml @@ -0,0 +1,175 @@ +# Disable apq b/c redis not configured +apq: + router: + cache: + in_memory: + limit: 2000 + subgraph: + all: + enabled: true + +# Telemetry plugin config +telemetry: + instrumentation: + spans: + mode: spec_compliant + default_attribute_requirement_level: recommended + router: + attributes: + "custom_one": + request_header: host + supergraph: + attributes: + graphql.document: true + subgraph: + attributes: + subgraph.graphql.document: true + instruments: + router: + http.server.request.body.size: + attributes: + # Standard attributes + http.response.status_code: true + "my_attribute": + response_header: "content-type" + http.server.request.duration: + attributes: + # Standard attributes + http.response.status_code: true + http.request.method: true + # Custom attribute + "my_attribute": + response_header: "content-type" + my.request.duration: # The name of your custom instrument/metric + value: duration + type: counter + unit: s + description: "my description" + acme.request.size: # The name of your custom instrument/metric + value: + request_header: "content-length" + type: counter + unit: s + description: "my description" + + acme.request.length: # The name of your custom instrument/metric + value: + request_header: "content-length" + type: histogram + unit: s + description: "my description" + supergraph: + acme.graphql.requests: + value: unit + type: counter + unit: request + description: "supergraph requests" + attributes: + static: hello + graphql_operation_kind: + operation_kind: string + subgraph: + request_including_price1: + value: unit + type: counter + unit: request + description: "supergraph requests" + condition: + exists: + subgraph_response_data: "$.products[*].price1" + attributes: + subgraph.name: true + events: + router: + # Standard events + request: info + response: info + error: info + + # Custom events + my.request_event: + message: "my event message" + level: info + on: request + attributes: + http.request.body.size: true + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - request_header: "x-log-request" + my.response_event: + message: "my response event message" + level: info + on: response + attributes: + http.response.body.size: true + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - response_header: "x-log-request" + supergraph: + # Standard events + request: info + response: info + error: info + + # Custom events + my.request.event: + message: "my event message" + level: info + on: request + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - request_header: "x-log-request" + my.response_event: + message: "my response event message" + level: warn + on: response + condition: + eq: + - "log" + - response_header: "x-log-request" + subgraph: + # Standard events + request: info + response: warn + error: error + + # Custom events + my.request.event: + message: "my event message" + level: info + on: request + my.response.event: + message: "my response event message" + level: error + on: response + attributes: + subgraph.name: true + response_status: + subgraph_response_status: code + exporters: + tracing: + common: + service_name: router + resource: + str: a + int: 1 + float: 1 + bool: true + str_arr: + - a + - b + int_arr: + - 1 + - 2 + float_arr: + - 1 + - 2 + bool_arr: + - true + - false diff --git a/apollo-router/src/plugins/telemetry/testdata/full_config_apq_enabled_partial_defaults.router.yaml b/apollo-router/src/plugins/telemetry/testdata/full_config_apq_enabled_partial_defaults.router.yaml new file mode 100644 index 0000000000..80211068bd --- /dev/null +++ b/apollo-router/src/plugins/telemetry/testdata/full_config_apq_enabled_partial_defaults.router.yaml @@ -0,0 +1,176 @@ +# Enable distributed apq cache even when top-level flag omitted +apq: + router: + cache: + redis: + urls: + - https://example.com + subgraph: + all: + enabled: true + +# Telemetry plugin config +telemetry: + instrumentation: + spans: + mode: spec_compliant + default_attribute_requirement_level: recommended + router: + attributes: + "custom_one": + request_header: host + supergraph: + attributes: + graphql.document: true + subgraph: + attributes: + subgraph.graphql.document: true + instruments: + router: + http.server.request.body.size: + attributes: + # Standard attributes + http.response.status_code: true + "my_attribute": + response_header: "content-type" + http.server.request.duration: + attributes: + # Standard attributes + http.response.status_code: true + http.request.method: true + # Custom attribute + "my_attribute": + response_header: "content-type" + my.request.duration: # The name of your custom instrument/metric + value: duration + type: counter + unit: s + description: "my description" + acme.request.size: # The name of your custom instrument/metric + value: + request_header: "content-length" + type: counter + unit: s + description: "my description" + + acme.request.length: # The name of your custom instrument/metric + value: + request_header: "content-length" + type: histogram + unit: s + description: "my description" + supergraph: + acme.graphql.requests: + value: unit + type: counter + unit: request + description: "supergraph requests" + attributes: + static: hello + graphql_operation_kind: + operation_kind: string + subgraph: + request_including_price1: + value: unit + type: counter + unit: request + description: "supergraph requests" + condition: + exists: + subgraph_response_data: "$.products[*].price1" + attributes: + subgraph.name: true + events: + router: + # Standard events + request: info + response: info + error: info + + # Custom events + my.request_event: + message: "my event message" + level: info + on: request + attributes: + http.request.body.size: true + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - request_header: "x-log-request" + my.response_event: + message: "my response event message" + level: info + on: response + attributes: + http.response.body.size: true + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - response_header: "x-log-request" + supergraph: + # Standard events + request: info + response: info + error: info + + # Custom events + my.request.event: + message: "my event message" + level: info + on: request + # Only log when the x-log-request header is `log` + condition: + eq: + - "log" + - request_header: "x-log-request" + my.response_event: + message: "my response event message" + level: warn + on: response + condition: + eq: + - "log" + - response_header: "x-log-request" + subgraph: + # Standard events + request: info + response: warn + error: error + + # Custom events + my.request.event: + message: "my event message" + level: info + on: request + my.response.event: + message: "my response event message" + level: error + on: response + attributes: + subgraph.name: true + response_status: + subgraph_response_status: code + exporters: + tracing: + common: + service_name: router + resource: + str: a + int: 1 + float: 1 + bool: true + str_arr: + - a + - b + int_arr: + - 1 + - 2 + float_arr: + - 1 + - 2 + bool_arr: + - true + - false diff --git a/apollo-router/src/plugins/telemetry/testdata/prometheus.router.yaml b/apollo-router/src/plugins/telemetry/testdata/prometheus.router.yaml index 6273bcf4b2..238694e24e 100644 --- a/apollo-router/src/plugins/telemetry/testdata/prometheus.router.yaml +++ b/apollo-router/src/plugins/telemetry/testdata/prometheus.router.yaml @@ -4,5 +4,9 @@ telemetry: client_version_header: version_header exporters: metrics: + common: + resource: + "test-resource": "test" prometheus: enabled: true + resource_selector: all diff --git a/apollo-router/src/plugins/telemetry/testdata/prometheus_custom_buckets_specific_metrics.router.yaml b/apollo-router/src/plugins/telemetry/testdata/prometheus_custom_buckets_specific_metrics.router.yaml index c24056770f..23352c1cf6 100644 --- a/apollo-router/src/plugins/telemetry/testdata/prometheus_custom_buckets_specific_metrics.router.yaml +++ b/apollo-router/src/plugins/telemetry/testdata/prometheus_custom_buckets_specific_metrics.router.yaml @@ -7,7 +7,7 @@ telemetry: common: service_name: apollo-router views: - - name: apollo_router_http_request_duration_seconds + - name: apollo.test.histo unit: seconds description: duration of the http request aggregation: diff --git a/apollo-router/src/plugins/telemetry/tracing/apollo.rs b/apollo-router/src/plugins/telemetry/tracing/apollo.rs index b4f6589e37..a33c1139fa 100644 --- a/apollo-router/src/plugins/telemetry/tracing/apollo.rs +++ b/apollo-router/src/plugins/telemetry/tracing/apollo.rs @@ -1,16 +1,18 @@ //! Tracing configuration for apollo telemetry. -use opentelemetry::sdk::trace::BatchSpanProcessor; -use opentelemetry::sdk::trace::Builder; +use opentelemetry_sdk::trace::BatchSpanProcessor; +use opentelemetry_sdk::trace::Builder; use serde::Serialize; use tower::BoxError; use crate::plugins::telemetry::apollo::Config; +use crate::plugins::telemetry::apollo::router_id; use crate::plugins::telemetry::apollo_exporter::proto::reports::Trace; use crate::plugins::telemetry::config; use crate::plugins::telemetry::config_new::spans::Spans; +use crate::plugins::telemetry::otel::named_runtime_channel::NamedTokioRuntime; use crate::plugins::telemetry::span_factory::SpanMode; -use crate::plugins::telemetry::tracing::apollo_telemetry; use crate::plugins::telemetry::tracing::TracingConfigurator; +use crate::plugins::telemetry::tracing::apollo_telemetry; impl TracingConfigurator for Config { fn enabled(&self) -> bool { @@ -28,7 +30,7 @@ impl TracingConfigurator for Config { .endpoint(&self.endpoint) .otlp_endpoint(&self.experimental_otlp_endpoint) .otlp_tracing_protocol(&self.experimental_otlp_tracing_protocol) - .otlp_tracing_sampler(&self.experimental_otlp_tracing_sampler) + .otlp_tracing_sampler(&self.otlp_tracing_sampler) .apollo_key( self.apollo_key .as_ref() @@ -40,6 +42,7 @@ impl TracingConfigurator for Config { .expect("apollo_graph_ref is checked in the enabled function, qed"), ) .schema_id(&self.schema_id) + .router_id(router_id()) .buffer_size(self.buffer_size) .field_execution_sampler(&self.field_level_instrumentation_sampler) .batch_config(&self.batch_processor) @@ -48,7 +51,7 @@ impl TracingConfigurator for Config { .metrics_reference_mode(self.metrics_reference_mode) .build()?; Ok(builder.with_span_processor( - BatchSpanProcessor::builder(exporter, opentelemetry::runtime::Tokio) + BatchSpanProcessor::builder(exporter, NamedTokioRuntime::new("apollo-tracing")) .with_batch_config(self.batch_processor.clone().into()) .build(), )) diff --git a/apollo-router/src/plugins/telemetry/tracing/apollo_telemetry.rs b/apollo-router/src/plugins/telemetry/tracing/apollo_telemetry.rs index fc343c6b4d..9e6af7db79 100644 --- a/apollo-router/src/plugins/telemetry/tracing/apollo_telemetry.rs +++ b/apollo-router/src/plugins/telemetry/tracing/apollo_telemetry.rs @@ -8,38 +8,52 @@ use std::time::SystemTime; use std::time::SystemTimeError; use async_trait::async_trait; -use base64::prelude::BASE64_STANDARD; use base64::Engine as _; +use base64::prelude::BASE64_STANDARD; use derivative::Derivative; -use futures::future::BoxFuture; use futures::FutureExt; -use futures::TryFutureExt; +use futures::future::BoxFuture; use itertools::Itertools; use lru::LruCache; -use opentelemetry::sdk::export::trace::ExportResult; -use opentelemetry::sdk::export::trace::SpanData; -use opentelemetry::sdk::export::trace::SpanExporter; -use opentelemetry::sdk::trace::EvictedHashMap; +use opentelemetry::Key; +use opentelemetry::KeyValue; +use opentelemetry::Value; use opentelemetry::trace::SpanId; use opentelemetry::trace::SpanKind; use opentelemetry::trace::Status; use opentelemetry::trace::TraceError; use opentelemetry::trace::TraceId; -use opentelemetry::Key; -use opentelemetry::KeyValue; -use opentelemetry::Value; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::export::trace::ExportResult; +use opentelemetry_sdk::export::trace::SpanData; +use opentelemetry_sdk::export::trace::SpanExporter; use prost::Message; use rand::Rng; use serde::de::DeserializeOwned; +use serde_json::Value as JSONValue; use thiserror::Error; +use tracing::Level; use url::Url; +use crate::json_ext::Path; use crate::plugins::telemetry; +use crate::plugins::telemetry::APOLLO_PRIVATE_QUERY_ALIASES; +use crate::plugins::telemetry::APOLLO_PRIVATE_QUERY_DEPTH; +use crate::plugins::telemetry::APOLLO_PRIVATE_QUERY_HEIGHT; +use crate::plugins::telemetry::APOLLO_PRIVATE_QUERY_ROOT_FIELDS; +use crate::plugins::telemetry::BoxError; +use crate::plugins::telemetry::LruSizeInstrument; use crate::plugins::telemetry::apollo::ErrorConfiguration; +use crate::plugins::telemetry::apollo::ErrorRedactionPolicy; use crate::plugins::telemetry::apollo::ErrorsConfiguration; use crate::plugins::telemetry::apollo::OperationSubType; use crate::plugins::telemetry::apollo::SingleReport; +use crate::plugins::telemetry::apollo_exporter::ApolloExporter; use crate::plugins::telemetry::apollo_exporter::proto; +use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::Details; +use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::Http; +use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::Limits; +use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::QueryPlanNode; use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::http::Method; use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::http::Values; use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::query_plan_node::ConditionNode; @@ -53,11 +67,6 @@ use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::query_pla use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::query_plan_node::ParallelNode; use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::query_plan_node::ResponsePathElement; use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::query_plan_node::SequenceNode; -use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::Details; -use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::Http; -use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::Limits; -use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::QueryPlanNode; -use crate::plugins::telemetry::apollo_exporter::ApolloExporter; use crate::plugins::telemetry::apollo_otlp_exporter::ApolloOtlpExporter; use crate::plugins::telemetry::config::ApolloMetricsReferenceMode; use crate::plugins::telemetry::config::Sampler; @@ -66,20 +75,15 @@ use crate::plugins::telemetry::config_new::cost::APOLLO_PRIVATE_COST_ACTUAL; use crate::plugins::telemetry::config_new::cost::APOLLO_PRIVATE_COST_ESTIMATED; use crate::plugins::telemetry::config_new::cost::APOLLO_PRIVATE_COST_RESULT; use crate::plugins::telemetry::config_new::cost::APOLLO_PRIVATE_COST_STRATEGY; +use crate::plugins::telemetry::consts::EVENT_ATTRIBUTE_OMIT_LOG; use crate::plugins::telemetry::consts::EXECUTION_SPAN_NAME; +use crate::plugins::telemetry::consts::FIELD_EXCEPTION_MESSAGE; use crate::plugins::telemetry::consts::ROUTER_SPAN_NAME; use crate::plugins::telemetry::consts::SUBGRAPH_SPAN_NAME; use crate::plugins::telemetry::consts::SUPERGRAPH_SPAN_NAME; use crate::plugins::telemetry::otlp::Protocol; -use crate::plugins::telemetry::tracing::apollo::TracesReport; use crate::plugins::telemetry::tracing::BatchProcessorConfig; -use crate::plugins::telemetry::BoxError; -use crate::plugins::telemetry::APOLLO_PRIVATE_QUERY_ALIASES; -use crate::plugins::telemetry::APOLLO_PRIVATE_QUERY_DEPTH; -use crate::plugins::telemetry::APOLLO_PRIVATE_QUERY_HEIGHT; -use crate::plugins::telemetry::APOLLO_PRIVATE_QUERY_ROOT_FIELDS; -use crate::query_planner::subscription::SUBSCRIPTION_EVENT_SPAN_NAME; -use crate::query_planner::OperationKind; +use crate::plugins::telemetry::tracing::apollo::TracesReport; use crate::query_planner::CONDITION_ELSE_SPAN_NAME; use crate::query_planner::CONDITION_IF_SPAN_NAME; use crate::query_planner::CONDITION_SPAN_NAME; @@ -88,9 +92,19 @@ use crate::query_planner::DEFER_PRIMARY_SPAN_NAME; use crate::query_planner::DEFER_SPAN_NAME; use crate::query_planner::FETCH_SPAN_NAME; use crate::query_planner::FLATTEN_SPAN_NAME; +use crate::query_planner::OperationKind; use crate::query_planner::PARALLEL_SPAN_NAME; use crate::query_planner::SEQUENCE_SPAN_NAME; use crate::query_planner::SUBSCRIBE_SPAN_NAME; +use crate::query_planner::subscription::SUBSCRIPTION_EVENT_SPAN_NAME; +use crate::services::connector_service::APOLLO_CONNECTOR_DETAIL; +use crate::services::connector_service::APOLLO_CONNECTOR_FIELD_ALIAS; +use crate::services::connector_service::APOLLO_CONNECTOR_FIELD_NAME; +use crate::services::connector_service::APOLLO_CONNECTOR_FIELD_RETURN_TYPE; +use crate::services::connector_service::APOLLO_CONNECTOR_SELECTION; +use crate::services::connector_service::APOLLO_CONNECTOR_SOURCE_DETAIL; +use crate::services::connector_service::APOLLO_CONNECTOR_SOURCE_NAME; +use crate::services::connector_service::APOLLO_CONNECTOR_TYPE; pub(crate) const APOLLO_PRIVATE_REQUEST: Key = Key::from_static_str("apollo_private.request"); pub(crate) const APOLLO_PRIVATE_DURATION_NS: &str = "apollo_private.duration_ns"; @@ -118,6 +132,8 @@ const OPERATION_NAME: Key = Key::from_static_str("graphql.operation.name"); const OPERATION_TYPE: Key = Key::from_static_str("graphql.operation.type"); pub(crate) const OPERATION_SUBTYPE: Key = Key::from_static_str("apollo_private.operation.subtype"); const EXT_TRACE_ID: Key = Key::from_static_str("trace_id"); +pub(crate) const GRAPHQL_ERROR_EXT_CODE: &str = "graphql.error.extensions.code"; +pub(crate) const GRAPHQL_ERROR_PATH: &str = "graphql.error.path"; /// The set of attributes to include when sending to the Apollo Reports protocol. const REPORTS_INCLUDE_ATTRS: [Key; 26] = [ @@ -146,16 +162,31 @@ const REPORTS_INCLUDE_ATTRS: [Key; 26] = [ CONDITION, OPERATION_NAME, OPERATION_TYPE, - opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD, + Key::from_static_str(opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD), ]; /// Additional attributes to include when sending to the OTLP protocol. -const OTLP_EXT_INCLUDE_ATTRS: [Key; 5] = [ +const OTLP_EXT_INCLUDE_ATTRS: [Key; 13] = [ OPERATION_SUBTYPE, EXT_TRACE_ID, - opentelemetry_semantic_conventions::trace::HTTP_REQUEST_BODY_SIZE, - opentelemetry_semantic_conventions::trace::HTTP_RESPONSE_BODY_SIZE, - opentelemetry_semantic_conventions::trace::HTTP_RESPONSE_STATUS_CODE, + Key::from_static_str(opentelemetry_semantic_conventions::attribute::HTTP_REQUEST_BODY_SIZE), + Key::from_static_str(opentelemetry_semantic_conventions::attribute::HTTP_RESPONSE_BODY_SIZE), + Key::from_static_str(opentelemetry_semantic_conventions::trace::HTTP_RESPONSE_STATUS_CODE), + APOLLO_CONNECTOR_TYPE, + APOLLO_CONNECTOR_DETAIL, + APOLLO_CONNECTOR_SELECTION, + APOLLO_CONNECTOR_FIELD_NAME, + APOLLO_CONNECTOR_FIELD_ALIAS, + APOLLO_CONNECTOR_FIELD_RETURN_TYPE, + APOLLO_CONNECTOR_SOURCE_NAME, + APOLLO_CONNECTOR_SOURCE_DETAIL, +]; + +/// Attributes on events to include when sending to the OTLP protocol. +const OTLP_EXT_INCLUDE_EVENT_ATTRS: [Key; 3] = [ + Key::from_static_str(GRAPHQL_ERROR_EXT_CODE), + Key::from_static_str(FIELD_EXCEPTION_MESSAGE), + Key::from_static_str(GRAPHQL_ERROR_PATH), ]; const REPORTS_INCLUDE_SPANS: [&str; 16] = [ @@ -177,6 +208,27 @@ const REPORTS_INCLUDE_SPANS: [&str; 16] = [ SUBSCRIPTION_EVENT_SPAN_NAME, ]; +pub(crate) fn emit_error_event(error_code: &str, error_message: &str, error_path: Option) { + if let Some(path) = error_path { + tracing::event!( + Level::ERROR, + { GRAPHQL_ERROR_EXT_CODE } = error_code, + { FIELD_EXCEPTION_MESSAGE } = error_message, + { GRAPHQL_ERROR_PATH } = path.to_string().as_str(), + { EVENT_ATTRIBUTE_OMIT_LOG } = true, + error_message + ); + } else { + tracing::event!( + Level::ERROR, + { GRAPHQL_ERROR_EXT_CODE } = error_code, + { FIELD_EXCEPTION_MESSAGE } = error_message, + { EVENT_ATTRIBUTE_OMIT_LOG } = true, + error_message + ); + } +} + #[derive(Error, Debug)] pub(crate) enum Error { #[error("subgraph protobuf decode error")] @@ -195,6 +247,13 @@ pub(crate) enum Error { SystemTime(#[from] SystemTimeError), } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct LightSpanEventData { + pub(crate) timestamp: SystemTime, + pub(crate) name: Cow<'static, str>, + pub(crate) attributes: HashMap, +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct LightSpanData { pub(crate) trace_id: TraceId, @@ -204,33 +263,63 @@ pub(crate) struct LightSpanData { pub(crate) name: Cow<'static, str>, pub(crate) start_time: SystemTime, pub(crate) end_time: SystemTime, - pub(crate) attributes: EvictedHashMap, + pub(crate) attributes: HashMap, pub(crate) status: Status, + pub(crate) droppped_attribute_count: u32, + pub(crate) events: Vec, } impl LightSpanData { /// Convert from a full Span into a lighter more memory-efficient span for caching purposes. /// - If `include_attr_names` is passed, filter out any attributes that are not in the list. - fn from_span_data(value: SpanData, include_attr_names: &Option>) -> Self { + fn from_span_data( + value: SpanData, + include_attr_names: &Option>, + include_attr_event_names: &Option>, + ) -> Self { let filtered_attributes = match include_attr_names { - None => value.attributes, - Some(attr_names) => { - // Looks like this transformation will be easier after upgrading opentelemetry_sdk >= 0.21 - // when attributes are stored as Vec. - // https://github.com/open-telemetry/opentelemetry-rust/blob/943bb7a03f9cd17a0b6b53c2eb12acf77764c122/opentelemetry-sdk/CHANGELOG.md?plain=1#L157-L159 - let max_attr_len = std::cmp::min(attr_names.len(), value.attributes.len()); - let mut new_attrs = EvictedHashMap::new( - max_attr_len.try_into().expect("expected usize -> u32"), - max_attr_len, - ); - value.attributes.into_iter().for_each(|(key, value)| { - if attr_names.contains(&key) { - new_attrs.insert(KeyValue::new(key, value)) + None => value + .attributes + .into_iter() + .map(|KeyValue { key, value }| (key, value)) + .collect(), + Some(attr_names) => value + .attributes + .into_iter() + .filter_map(|kv| { + if attr_names.contains(&kv.key) { + Some((kv.key, kv.value)) + } else { + None } - }); - new_attrs - } + }) + .collect(), + }; + + let filtered_events = match include_attr_event_names { + None => vec![], + Some(event_names) => value + .events + .into_iter() + .map(|event| LightSpanEventData { + timestamp: event.timestamp, + name: event.name, + attributes: event + .attributes + .into_iter() + .filter_map(|kv| { + if event_names.contains(&kv.key) { + Some((kv.key, kv.value)) + } else { + None + } + }) + .collect(), + }) + .filter(|event| !event.attributes.is_empty()) + .collect(), }; + Self { trace_id: value.span_context.trace_id(), span_id: value.span_context.span_id(), @@ -241,6 +330,8 @@ impl LightSpanData { end_time: value.end_time, attributes: filtered_attributes, status: value.status, + droppped_attribute_count: value.dropped_attributes_count, + events: filtered_events, } } } @@ -253,16 +344,19 @@ impl LightSpanData { #[derivative(Debug)] pub(crate) struct Exporter { spans_by_parent_id: LruCache>, + /// An externally updateable gauge for "apollo.router.exporter.span.lru.size". + span_lru_size_instrument: LruSizeInstrument, #[derivative(Debug = "ignore")] report_exporter: Option>, #[derivative(Debug = "ignore")] - otlp_exporter: Option>, + otlp_exporter: Option, otlp_tracing_ratio: f64, field_execution_weight: f64, errors_configuration: ErrorsConfiguration, use_legacy_request_span: bool, include_span_names: HashSet<&'static str>, include_attr_names: Option>, + include_attr_event_names: Option>, } #[derive(Debug)] @@ -301,6 +395,7 @@ impl Exporter { apollo_key: &'a str, apollo_graph_ref: &'a str, schema_id: &'a str, + router_id: String, buffer_size: NonZeroUsize, field_execution_sampler: &'a SamplerOption, errors_configuration: &'a ErrorsConfiguration, @@ -313,19 +408,20 @@ impl Exporter { let otlp_tracing_ratio = match otlp_tracing_sampler { SamplerOption::TraceIdRatioBased(ratio) => { // can't use std::cmp::min because f64 is not Ord - if *ratio > 1.0 { - 1.0 - } else { - *ratio - } + if *ratio > 1.0 { 1.0 } else { *ratio } } SamplerOption::Always(s) => match s { Sampler::AlwaysOn => 1f64, Sampler::AlwaysOff => 0f64, }, }; + + let span_lru_size_instrument = + LruSizeInstrument::new("apollo.router.exporter.span.lru.size"); + Ok(Self { spans_by_parent_id: LruCache::new(buffer_size), + span_lru_size_instrument, report_exporter: if otlp_tracing_ratio < 1f64 { Some(Arc::new(ApolloExporter::new( endpoint, @@ -333,13 +429,14 @@ impl Exporter { apollo_key, apollo_graph_ref, schema_id, + router_id, metrics_reference_mode, )?)) } else { None }, otlp_exporter: if otlp_tracing_ratio > 0f64 { - Some(Arc::new(ApolloOtlpExporter::new( + Some(ApolloOtlpExporter::new( otlp_endpoint, otlp_tracing_protocol, batch_config, @@ -347,7 +444,7 @@ impl Exporter { apollo_graph_ref, schema_id, errors_configuration, - )?)) + )?) } else { None }, @@ -367,6 +464,11 @@ impl Exporter { } else { Some(HashSet::from(REPORTS_INCLUDE_ATTRS)) }, + include_attr_event_names: if otlp_tracing_ratio > 0f64 { + Some(HashSet::from(OTLP_EXT_INCLUDE_EVENT_ATTRS)) + } else { + None + }, }) } @@ -643,7 +745,7 @@ impl Exporter { .collect() } } - _ if span.attributes.get(&APOLLO_PRIVATE_REQUEST).is_some() => { + _ if span.attributes.contains_key(&APOLLO_PRIVATE_REQUEST) => { if !self.use_legacy_request_span { child_nodes.push(TreeData::Router { http: Box::new(extract_http_data(span)), @@ -932,6 +1034,27 @@ pub(crate) fn extract_ftv1_trace( None } +fn perform_extended_redaction(error_json: &str) -> String { + serde_json::from_str::(error_json) + .ok() + .and_then(|error_json_value| { + let error_code = &error_json_value["extensions"]["code"]; + if !error_code.is_null() { + Some( + serde_json_bytes::json!({ + "extensions": { + "code": error_code, + } + }) + .to_string(), + ) + } else { + None + } + }) + .unwrap_or_default() +} + fn preprocess_errors( t: &mut proto::reports::trace::Node, error_config: &ErrorConfiguration, @@ -942,7 +1065,14 @@ fn preprocess_errors( t.error.iter_mut().for_each(|err| { err.message = String::from(""); err.location = Vec::new(); - err.json = String::new(); + err.json = if matches!( + error_config.redaction_policy, + ErrorRedactionPolicy::Extended + ) { + perform_extended_redaction(&err.json) + } else { + String::new() + } }); } error_count += u64::try_from(t.error.len()).expect("expected u64"); @@ -967,7 +1097,7 @@ pub(crate) fn encode_ftv1_trace(trace: &proto::reports::Trace) -> String { fn extract_http_data(span: &LightSpanData) -> Http { let method = match span .attributes - .get(&opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD) + .get(opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD) .map(|data| data.as_str()) .unwrap_or_default() .as_ref() @@ -1020,15 +1150,21 @@ impl SpanExporter for Exporter { // Decide whether to send via OTLP or reports proto based on the sampling config. Roll dice if using a percentage rollout. let send_otlp = self.otlp_exporter.is_some() - && rand::thread_rng().gen_range(0.0..1.0) < self.otlp_tracing_ratio; + && rand::rng().random_range(0.0..1.0) < self.otlp_tracing_ratio; let send_reports = self.report_exporter.is_some() && !send_otlp; for span in batch { - if span.attributes.get(&APOLLO_PRIVATE_REQUEST).is_some() - || span.name == SUBSCRIPTION_EVENT_SPAN_NAME + if span.name == SUBSCRIPTION_EVENT_SPAN_NAME + || span + .attributes + .iter() + .any(|kv| kv.key == APOLLO_PRIVATE_REQUEST) { - let root_span: LightSpanData = - LightSpanData::from_span_data(span, &self.include_attr_names); + let root_span: LightSpanData = LightSpanData::from_span_data( + span, + &self.include_attr_names, + &self.include_attr_event_names, + ); if send_otlp { let grouped_trace_spans = self.group_by_trace(root_span); if let Some(trace) = self @@ -1077,52 +1213,56 @@ impl SpanExporter for Exporter { .expect("capacity of cache was zero") .push( len, - LightSpanData::from_span_data(span, &self.include_attr_names), + LightSpanData::from_span_data( + span, + &self.include_attr_names, + &self.include_attr_event_names, + ), ); } } - tracing::info!(value.apollo_router_span_lru_size = self.spans_by_parent_id.len() as u64,); - #[allow(clippy::manual_map)] // https://github.com/rust-lang/rust-clippy/issues/8346 - let report_exporter = match self.report_exporter.as_ref() { - Some(exporter) => Some(exporter.clone()), - None => None, - }; - #[allow(clippy::manual_map)] // https://github.com/rust-lang/rust-clippy/issues/8346 - let otlp_exporter = match self.otlp_exporter.as_ref() { - Some(exporter) => Some(exporter.clone()), - None => None, - }; - - let fut = async move { - if send_otlp && !otlp_trace_spans.is_empty() { - otlp_exporter - .as_ref() - .expect("expected an otel exporter") - .export(otlp_trace_spans.into_iter().flatten().collect()) - .await - } else if send_reports && !traces.is_empty() { - let mut report = telemetry::apollo::Report::default(); - report += SingleReport::Traces(TracesReport { traces }); - report_exporter - .as_ref() - .expect("expected an apollo exporter") + // Note this won't be correct anymore if there is any way outside of `.export()` + // to affect the size of the cache. + self.span_lru_size_instrument + .update(self.spans_by_parent_id.len() as u64); + + if send_otlp && !otlp_trace_spans.is_empty() { + self.otlp_exporter + .as_mut() + .expect("expected an otel exporter") + .export(otlp_trace_spans.into_iter().flatten().collect()) + } else if send_reports && !traces.is_empty() { + let mut report = telemetry::apollo::Report::default(); + report += SingleReport::Traces(TracesReport { traces }); + let exporter = self + .report_exporter + .as_ref() + .expect("expected an apollo exporter") + .clone(); + async move { + exporter .submit_report(report) - .map_err(|e| TraceError::ExportFailed(Box::new(e))) .await - } else { - ExportResult::Ok(()) + .map_err(|e| TraceError::ExportFailed(Box::new(e))) } - }; - fut.boxed() + .boxed() + } else { + async { ExportResult::Ok(()) }.boxed() + } } fn shutdown(&mut self) { // Currently only handled in the OTLP case. - if let Some(exporter) = &self.otlp_exporter { + if let Some(exporter) = &mut self.otlp_exporter { exporter.shutdown() }; } + + fn set_resource(&mut self, _resource: &Resource) { + // This is intentionally a NOOP. The reason for this is that we do not allow users to set the resource attributes + // for telemetry that is sent to Apollo. To do so would expose potential private information that the user did not intend for us. + } } trait ChildNodes { @@ -1139,10 +1279,9 @@ impl ChildNodes for Vec { if let Some((idx, _)) = self .iter() .find_position(|child| matches!(child, TreeData::QueryPlanNode(_))) + && let TreeData::QueryPlanNode(node) = self.remove(idx) { - if let TreeData::QueryPlanNode(node) = self.remove(idx) { - return Some(node); - } + return Some(node); } None } @@ -1165,10 +1304,9 @@ impl ChildNodes for Vec { if let Some((idx, _)) = self .iter() .find_position(|child| matches!(child, TreeData::DeferPrimary(_))) + && let TreeData::DeferPrimary(node) = self.remove(idx) { - if let TreeData::DeferPrimary(node) = self.remove(idx) { - return Some(node); - } + return Some(node); } None } @@ -1191,10 +1329,9 @@ impl ChildNodes for Vec { if let Some((idx, _)) = self .iter() .find_position(|child| matches!(child, TreeData::ConditionIf(_))) + && let TreeData::ConditionIf(node) = self.remove(idx) { - if let TreeData::ConditionIf(node) = self.remove(idx) { - return node; - } + return node; } None } @@ -1203,10 +1340,9 @@ impl ChildNodes for Vec { if let Some((idx, _)) = self .iter() .find_position(|child| matches!(child, TreeData::ConditionElse(_))) + && let TreeData::ConditionElse(node) = self.remove(idx) { - if let TreeData::ConditionElse(node) = self.remove(idx) { - return node; - } + return node; } None } @@ -1214,13 +1350,12 @@ impl ChildNodes for Vec { #[cfg(test)] mod test { + use std::collections::HashMap; use std::time::SystemTime; use opentelemetry::Value; - use opentelemetry_api::KeyValue; - use opentelemetry_api::trace::{SpanId, SpanKind, TraceId}; - use opentelemetry_sdk::trace::EvictedHashMap; + use opentelemetry::trace::{SpanId, SpanKind, TraceId}; use serde_json::json; - use crate::plugins::telemetry::apollo::ErrorConfiguration; + use crate::plugins::telemetry::apollo::{ErrorConfiguration, ErrorRedactionPolicy}; use crate::plugins::telemetry::apollo_exporter::proto::reports::Trace; use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::query_plan_node::{DeferNodePrimary, DeferredNode, ResponsePathElement}; use crate::plugins::telemetry::apollo_exporter::proto::reports::trace::{QueryPlanNode, Node, Error}; @@ -1427,6 +1562,7 @@ mod test { &ErrorConfiguration { send: true, redact: false, + redaction_policy: ErrorRedactionPolicy::Strict, }, ) .expect("there was a trace here") @@ -1436,13 +1572,15 @@ mod test { } #[test] - fn test_preprocess_errors() { + fn test_preprocess_errors_with_strict_redaction() { let sub_node = Node { error: vec![Error { message: "this is my error".to_string(), location: Vec::new(), time_ns: 5, - json: String::from(r#"{"foo": "bar"}"#), + json: String::from( + r#"{"extensions":{"code":"AN_ERROR_CODE","ignored":"other stuff"}}"#, + ), }], ..Default::default() }; @@ -1467,6 +1605,7 @@ mod test { let error_config = ErrorConfiguration { send: true, redact: true, + redaction_policy: ErrorRedactionPolicy::Strict, }; let error_count = preprocess_errors(&mut node, &error_config); assert_eq!(error_count, 3); @@ -1483,13 +1622,18 @@ mod test { assert!(node.child[0].error[0].location.is_empty()); assert_eq!(node.child[0].error[0].message.as_str(), ""); assert_eq!(node.child[0].error[0].time_ns, 5u64); + } + #[test] + fn test_preprocess_errors_with_redaction_disabled() { let sub_node = Node { error: vec![Error { message: "this is my error".to_string(), location: Vec::new(), time_ns: 5, - json: String::from(r#"{"foo": "bar"}"#), + json: String::from( + r#"{"extensions":{"code":"AN_ERROR_CODE","ignored":"other stuff"}}"#, + ), }], ..Default::default() }; @@ -1514,6 +1658,7 @@ mod test { let error_config = ErrorConfiguration { send: true, redact: false, + redaction_policy: ErrorRedactionPolicy::Strict, }; let error_count = preprocess_errors(&mut node, &error_config); assert_eq!(error_count, 3); @@ -1523,11 +1668,71 @@ mod test { assert_eq!(node.error[1].message.as_str(), "this is my other error"); assert_eq!(node.error[1].time_ns, 5u64); - assert!(!node.child[0].error[0].json.is_empty()); + assert_eq!( + node.child[0].error[0].json, + String::from(r#"{"extensions":{"code":"AN_ERROR_CODE","ignored":"other stuff"}}"#,) + ); assert_eq!(node.child[0].error[0].message.as_str(), "this is my error"); assert_eq!(node.child[0].error[0].time_ns, 5u64); } + #[test] + fn test_preprocess_errors_with_extended_redaction_enabled() { + let sub_node = Node { + error: vec![Error { + message: "this is my error".to_string(), + location: Vec::new(), + time_ns: 5, + json: String::from( + r#"{"extensions":{"code":"AN_ERROR_CODE","ignored":"other stuff"}}"#, + ), + }], + ..Default::default() + }; + let mut node = Node { + error: vec![ + Error { + message: "this is my error".to_string(), + location: Vec::new(), + time_ns: 5, + json: String::from(r#"{"foo": "bar"}"#), + }, + Error { + message: "this is my other error".to_string(), + location: Vec::new(), + time_ns: 5, + json: String::from(r#"{"foo": "bar"}"#), + }, + ], + ..Default::default() + }; + node.child.push(sub_node); + let error_config = ErrorConfiguration { + send: true, + redact: true, + redaction_policy: ErrorRedactionPolicy::Extended, + }; + let error_count = preprocess_errors(&mut node, &error_config); + assert_eq!(error_count, 3); + assert!(node.error[0].location.is_empty()); + assert_eq!(node.error[0].message.as_str(), ""); + assert_eq!(node.error[0].time_ns, 5u64); + assert!(node.error[1].json.is_empty()); + assert!(node.error[1].location.is_empty()); + assert_eq!(node.error[1].message.as_str(), ""); + assert_eq!(node.error[1].time_ns, 5u64); + + // the "ignored" field should be filtered out in this scenario, but the + // code left alone. + assert_eq!( + node.child[0].error[0].json, + String::from(r#"{"extensions":{"code":"AN_ERROR_CODE"}}"#,) + ); + assert!(node.child[0].error[0].location.is_empty()); + assert_eq!(node.child[0].error[0].message.as_str(), ""); + assert_eq!(node.child[0].error[0].time_ns, 5u64); + } + #[test] fn test_delete_node_errors() { let sub_node = Node { @@ -1560,6 +1765,7 @@ mod test { let error_config = ErrorConfiguration { send: false, redact: true, + redaction_policy: ErrorRedactionPolicy::Strict, }; let error_count = preprocess_errors(&mut node, &error_config); assert_eq!(error_count, 0); @@ -1577,40 +1783,30 @@ mod test { name: Default::default(), start_time: SystemTime::now(), end_time: SystemTime::now(), - attributes: EvictedHashMap::new(10, 10), + attributes: HashMap::with_capacity(10), status: Default::default(), + droppped_attribute_count: 0, + events: Default::default(), }; - span.attributes.insert(KeyValue::new( - APOLLO_PRIVATE_COST_RESULT, - Value::String("OK".into()), - )); - span.attributes.insert(KeyValue::new( - APOLLO_PRIVATE_COST_ESTIMATED, - Value::F64(9.2), - )); span.attributes - .insert(KeyValue::new(APOLLO_PRIVATE_COST_ACTUAL, Value::F64(6.9))); - span.attributes.insert(KeyValue::new( + .insert(APOLLO_PRIVATE_COST_RESULT, Value::String("OK".into())); + span.attributes + .insert(APOLLO_PRIVATE_COST_ESTIMATED, Value::F64(9.2)); + span.attributes + .insert(APOLLO_PRIVATE_COST_ACTUAL, Value::F64(6.9)); + span.attributes.insert( APOLLO_PRIVATE_COST_STRATEGY, Value::String("static_estimated".into()), - )); - span.attributes.insert(KeyValue::new( - APOLLO_PRIVATE_QUERY_ALIASES, - Value::I64(0.into()), - )); - span.attributes.insert(KeyValue::new( - APOLLO_PRIVATE_QUERY_DEPTH, - Value::I64(5.into()), - )); - span.attributes.insert(KeyValue::new( - APOLLO_PRIVATE_QUERY_HEIGHT, - Value::I64(7.into()), - )); - span.attributes.insert(KeyValue::new( - APOLLO_PRIVATE_QUERY_ROOT_FIELDS, - Value::I64(1.into()), - )); + ); + span.attributes + .insert(APOLLO_PRIVATE_QUERY_ALIASES, Value::I64(0.into())); + span.attributes + .insert(APOLLO_PRIVATE_QUERY_DEPTH, Value::I64(5.into())); + span.attributes + .insert(APOLLO_PRIVATE_QUERY_HEIGHT, Value::I64(7.into())); + span.attributes + .insert(APOLLO_PRIVATE_QUERY_ROOT_FIELDS, Value::I64(1.into())); let limits = extract_limits(&span); assert_eq!(limits.result, "OK"); assert_eq!(limits.cost_estimated, 9); diff --git a/apollo-router/src/plugins/telemetry/tracing/datadog/agent_sampling.rs b/apollo-router/src/plugins/telemetry/tracing/datadog/agent_sampling.rs new file mode 100644 index 0000000000..9b523c4b39 --- /dev/null +++ b/apollo-router/src/plugins/telemetry/tracing/datadog/agent_sampling.rs @@ -0,0 +1,383 @@ +use opentelemetry::KeyValue; +use opentelemetry::Value; +use opentelemetry::trace::Link; +use opentelemetry::trace::SamplingDecision; +use opentelemetry::trace::SamplingResult; +use opentelemetry::trace::SpanKind; +use opentelemetry::trace::TraceId; +use opentelemetry_sdk::trace::ShouldSample; + +use crate::plugins::telemetry::tracing::datadog_exporter::DatadogTraceState; +use crate::plugins::telemetry::tracing::datadog_exporter::propagator::SamplingPriority; + +/// The Datadog Agent Sampler +/// +/// This sampler overrides the sampling decision to ensure that spans are recorded even if they were originally dropped. +/// It performs the following tasks: +/// 1. Ensures the appropriate trace state is set +/// 2. Adds the sampling.priority attribute to the span +/// +/// The sampler can be configured to use parent-based sampling for consistent trace sampling. +/// +#[derive(Debug, Clone)] +pub(crate) struct DatadogAgentSampling { + /// The underlying sampler used for initial sampling decisions + pub(crate) sampler: opentelemetry_sdk::trace::Sampler, + /// Flag to enable parent-based sampling for consistent trace sampling + pub(crate) parent_based_sampler: bool, +} +impl DatadogAgentSampling { + /// Creates a new DatadogAgentSampling instance + /// + /// # Arguments + /// * `sampler` - The underlying sampler to use for initial sampling decisions + /// * `parent_based_sampler` - Whether to use parent-based sampling for consistent trace sampling + pub(crate) fn new( + sampler: opentelemetry_sdk::trace::Sampler, + parent_based_sampler: bool, + ) -> Self { + Self { + sampler, + parent_based_sampler, + } + } +} + +impl ShouldSample for DatadogAgentSampling { + fn should_sample( + &self, + parent_context: Option<&opentelemetry::Context>, + trace_id: TraceId, + name: &str, + span_kind: &SpanKind, + attributes: &[KeyValue], + links: &[Link], + ) -> SamplingResult { + let mut result = self.sampler.should_sample( + parent_context, + trace_id, + name, + span_kind, + attributes, + links, + ); + // Override the sampling decision to record and make sure that the trace state is set correctly + // if either parent sampling is disabled or it has not been populated by a propagator. + // The propagator gets first dibs on setting the trace state, so if it sets it, we don't override it unless we are not parent based. + match result.decision { + SamplingDecision::Drop | SamplingDecision::RecordOnly => { + result.decision = SamplingDecision::RecordOnly; + if !self.parent_based_sampler || result.trace_state.sampling_priority().is_none() { + result.trace_state = result + .trace_state + .with_priority_sampling(SamplingPriority::AutoReject) + } + } + SamplingDecision::RecordAndSample => { + if !self.parent_based_sampler || result.trace_state.sampling_priority().is_none() { + result.trace_state = result + .trace_state + .with_priority_sampling(SamplingPriority::AutoKeep) + } + } + } + + // We always want to measure + result.trace_state = result.trace_state.with_measuring(true); + // We always want to set the sampling.priority attribute in case we are communicating with the agent via otlp. + // Reverse engineered from https://github.com/DataDog/datadog-agent/blob/c692f62423f93988b008b669008f9199a5ad196b/pkg/trace/api/otlp.go#L502 + if let Some(priority) = result.trace_state.sampling_priority() { + result.attributes.push(KeyValue::new( + "sampling.priority", + Value::I64(priority.as_i64()), + )); + } else { + tracing::error!("Failed to set trace sampling priority."); + } + result + } +} +#[cfg(test)] +mod tests { + use buildstructor::Builder; + use opentelemetry::Context; + use opentelemetry::KeyValue; + use opentelemetry::Value; + use opentelemetry::trace::Link; + use opentelemetry::trace::SamplingDecision; + use opentelemetry::trace::SamplingResult; + use opentelemetry::trace::SpanContext; + use opentelemetry::trace::SpanId; + use opentelemetry::trace::SpanKind; + use opentelemetry::trace::TraceContextExt; + use opentelemetry::trace::TraceFlags; + use opentelemetry::trace::TraceId; + use opentelemetry::trace::TraceState; + use opentelemetry_sdk::trace::Sampler; + use opentelemetry_sdk::trace::ShouldSample; + + use crate::plugins::telemetry::tracing::datadog::DatadogAgentSampling; + use crate::plugins::telemetry::tracing::datadog_exporter::DatadogTraceState; + use crate::plugins::telemetry::tracing::datadog_exporter::propagator::SamplingPriority; + + #[derive(Debug, Clone, Builder)] + struct StubSampler { + decision: SamplingDecision, + } + + impl ShouldSample for StubSampler { + fn should_sample( + &self, + _parent_context: Option<&Context>, + _trace_id: TraceId, + _name: &str, + _span_kind: &SpanKind, + _attributes: &[KeyValue], + _links: &[Link], + ) -> SamplingResult { + SamplingResult { + decision: self.decision.clone(), + attributes: Vec::new(), + trace_state: Default::default(), + } + } + } + + #[test] + fn test_should_sample_drop() { + // Test case where the sampling decision is Drop + let sampler = StubSampler::builder() + .decision(SamplingDecision::Drop) + .build(); + let datadog_sampler = + DatadogAgentSampling::new(Sampler::ParentBased(Box::new(sampler)), false); + + let result = datadog_sampler.should_sample( + None, + TraceId::from_u128(1), + "test_span", + &SpanKind::Internal, + &[], + &[], + ); + + // Verify that the decision is RecordOnly (converted from Drop) + assert_eq!(result.decision, SamplingDecision::RecordOnly); + // Verify that the sampling priority is set to AutoReject + assert_eq!( + result.trace_state.sampling_priority(), + Some(SamplingPriority::AutoReject) + ); + // Verify that the sampling.priority attribute is set correctly + assert!( + result + .attributes + .iter() + .any(|kv| kv.key.as_str() == "sampling.priority" + && kv.value == Value::I64(SamplingPriority::AutoReject.as_i64())) + ); + + // Verify that measuring is enabled + assert!(result.trace_state.measuring_enabled()); + } + + #[test] + fn test_should_sample_record_only() { + let sampler = StubSampler::builder() + .decision(SamplingDecision::RecordOnly) + .build(); + let datadog_sampler = + DatadogAgentSampling::new(Sampler::ParentBased(Box::new(sampler)), false); + + let result = datadog_sampler.should_sample( + None, + TraceId::from_u128(1), + "test_span", + &SpanKind::Internal, + &[], + &[], + ); + + // Record only should remain as record only + assert_eq!(result.decision, SamplingDecision::RecordOnly); + + // Verify that the sampling priority is set to AutoReject so the trace won't be transmitted to Datadog + assert_eq!( + result.trace_state.sampling_priority(), + Some(SamplingPriority::AutoReject) + ); + assert!( + result + .attributes + .iter() + .any(|kv| kv.key.as_str() == "sampling.priority" + && kv.value == Value::I64(SamplingPriority::AutoReject.as_i64())) + ); + + // Verify that measuring is enabled + assert!(result.trace_state.measuring_enabled()); + } + + #[test] + fn test_should_sample_record_and_sample() { + let sampler = StubSampler::builder() + .decision(SamplingDecision::RecordAndSample) + .build(); + let datadog_sampler = + DatadogAgentSampling::new(Sampler::ParentBased(Box::new(sampler)), false); + + let result = datadog_sampler.should_sample( + None, + TraceId::from_u128(1), + "test_span", + &SpanKind::Internal, + &[], + &[], + ); + + // Record and sample should remain as record and sample + assert_eq!(result.decision, SamplingDecision::RecordAndSample); + + // Verify that the sampling priority is set to AutoKeep so the trace will be transmitted to Datadog + assert_eq!( + result.trace_state.sampling_priority(), + Some(SamplingPriority::AutoKeep) + ); + assert!( + result + .attributes + .iter() + .any(|kv| kv.key.as_str() == "sampling.priority" + && kv.value == Value::I64(SamplingPriority::AutoKeep.as_i64())) + ); + + // Verify that measuring is enabled + assert!(result.trace_state.measuring_enabled()); + } + + #[test] + fn test_should_sample_with_parent_based_sampler() { + let sampler = StubSampler::builder() + .decision(SamplingDecision::RecordAndSample) + .build(); + + let datadog_sampler = + DatadogAgentSampling::new(Sampler::ParentBased(Box::new(sampler)), true); + + let result = datadog_sampler.should_sample( + Some(&Context::new()), + TraceId::from_u128(1), + "test_span", + &SpanKind::Internal, + &[], + &[], + ); + + // Record and sample should remain as record and sample + assert_eq!(result.decision, SamplingDecision::RecordAndSample); + + // Verify that the sampling priority is set to AutoKeep so the trace will be transmitted to Datadog + assert_eq!( + result.trace_state.sampling_priority(), + Some(SamplingPriority::AutoKeep) + ); + assert!( + result + .attributes + .iter() + .any(|kv| kv.key.as_str() == "sampling.priority" + && kv.value == Value::I64(SamplingPriority::AutoKeep.as_i64())) + ); + + // Verify that measuring is enabled + assert!(result.trace_state.measuring_enabled()); + } + + #[test] + fn test_trace_state_already_populated_record_and_sample() { + let sampler = StubSampler::builder() + .decision(SamplingDecision::RecordAndSample) + .build(); + + let datadog_sampler = + DatadogAgentSampling::new(Sampler::ParentBased(Box::new(sampler)), true); + + let result = datadog_sampler.should_sample( + Some(&Context::new().with_remote_span_context(SpanContext::new( + TraceId::from_u128(1), + SpanId::from_u64(1), + TraceFlags::SAMPLED, + true, + TraceState::default().with_priority_sampling(SamplingPriority::UserReject), + ))), + TraceId::from_u128(1), + "test_span", + &SpanKind::Internal, + &[], + &[], + ); + + // Record and sample should remain as record and sample + assert_eq!(result.decision, SamplingDecision::RecordAndSample); + + // Verify that the sampling priority is not overridden + assert_eq!( + result.trace_state.sampling_priority(), + Some(SamplingPriority::UserReject) + ); + assert!( + result + .attributes + .iter() + .any(|kv| kv.key.as_str() == "sampling.priority" + && kv.value == Value::I64(SamplingPriority::UserReject.as_i64())) + ); + + // Verify that measuring is enabled + assert!(result.trace_state.measuring_enabled()); + } + + #[test] + fn test_trace_state_already_populated_record_drop() { + let sampler = StubSampler::builder() + .decision(SamplingDecision::Drop) + .build(); + + let datadog_sampler = + DatadogAgentSampling::new(Sampler::ParentBased(Box::new(sampler)), true); + + let result = datadog_sampler.should_sample( + Some(&Context::new().with_remote_span_context(SpanContext::new( + TraceId::from_u128(1), + SpanId::from_u64(1), + TraceFlags::default(), + true, + TraceState::default().with_priority_sampling(SamplingPriority::UserReject), + ))), + TraceId::from_u128(1), + "test_span", + &SpanKind::Internal, + &[], + &[], + ); + + // Drop is converted to RecordOnly + assert_eq!(result.decision, SamplingDecision::RecordOnly); + + // Verify that the sampling priority is not overridden + assert_eq!( + result.trace_state.sampling_priority(), + Some(SamplingPriority::UserReject) + ); + assert!( + result + .attributes + .iter() + .any(|kv| kv.key.as_str() == "sampling.priority" + && kv.value == Value::I64(SamplingPriority::UserReject.as_i64())) + ); + + // Verify that measuring is enabled + assert!(result.trace_state.measuring_enabled()); + } +} diff --git a/apollo-router/src/plugins/telemetry/tracing/datadog.rs b/apollo-router/src/plugins/telemetry/tracing/datadog/mod.rs similarity index 72% rename from apollo-router/src/plugins/telemetry/tracing/datadog.rs rename to apollo-router/src/plugins/telemetry/tracing/datadog/mod.rs index 4574b529ff..0a841584fb 100644 --- a/apollo-router/src/plugins/telemetry/tracing/datadog.rs +++ b/apollo-router/src/plugins/telemetry/tracing/datadog/mod.rs @@ -1,28 +1,32 @@ //! Configuration for datadog tracing. +mod agent_sampling; +mod span_processor; + use std::fmt::Debug; use std::fmt::Formatter; use std::time::Duration; +pub(crate) use agent_sampling::DatadogAgentSampling; use ahash::HashMap; use ahash::HashMapExt; use futures::future::BoxFuture; use http::Uri; -use opentelemetry::sdk; -use opentelemetry::sdk::trace::BatchSpanProcessor; -use opentelemetry::sdk::trace::Builder; +use opentelemetry::Key; +use opentelemetry::KeyValue; use opentelemetry::Value; -use opentelemetry_api::trace::SpanContext; -use opentelemetry_api::trace::SpanKind; -use opentelemetry_api::Key; -use opentelemetry_api::KeyValue; +use opentelemetry::trace::SpanContext; +use opentelemetry::trace::SpanKind; +use opentelemetry_sdk::Resource; use opentelemetry_sdk::export::trace::ExportResult; use opentelemetry_sdk::export::trace::SpanData; use opentelemetry_sdk::export::trace::SpanExporter; +use opentelemetry_sdk::trace::Builder; use opentelemetry_semantic_conventions::resource::SERVICE_NAME; use opentelemetry_semantic_conventions::resource::SERVICE_VERSION; use schemars::JsonSchema; use serde::Deserialize; +pub(crate) use span_processor::DatadogSpanProcessor; use tower::BoxError; use crate::plugins::telemetry::config::GenericWith; @@ -38,11 +42,12 @@ use crate::plugins::telemetry::consts::SUBGRAPH_REQUEST_SPAN_NAME; use crate::plugins::telemetry::consts::SUBGRAPH_SPAN_NAME; use crate::plugins::telemetry::consts::SUPERGRAPH_SPAN_NAME; use crate::plugins::telemetry::endpoint::UriEndpoint; -use crate::plugins::telemetry::tracing::datadog_exporter; -use crate::plugins::telemetry::tracing::datadog_exporter::DatadogTraceState; +use crate::plugins::telemetry::otel::named_runtime_channel::NamedTokioRuntime; use crate::plugins::telemetry::tracing::BatchProcessorConfig; use crate::plugins::telemetry::tracing::SpanProcessorExt; use crate::plugins::telemetry::tracing::TracingConfigurator; +use crate::plugins::telemetry::tracing::datadog_exporter; +use crate::plugins::telemetry::tracing::datadog_exporter::DatadogTraceState; fn default_resource_mappings() -> HashMap { let mut map = HashMap::with_capacity(7); @@ -94,7 +99,7 @@ pub(crate) struct Config { resource_mapping: HashMap, /// Which spans will be eligible for span stats to be collected for viewing in the APM view. - /// Defaults to true for `request`, `router`, `query_parsing`, `supergraph`, `execution`, `query_planning`, `subgraph`, `subgraph_request` and `http_request`. + /// Defaults to true for `request`, `router`, `query_parsing`, `supergraph`, `execution`, `query_planning`, `subgraph`, `subgraph_request`, `connect`, `connect_request` and `http_request`. #[serde(default = "default_span_metrics")] span_metrics: HashMap, } @@ -123,7 +128,7 @@ impl TracingConfigurator for Config { _spans_config: &Spans, ) -> Result { tracing::info!("Configuring Datadog tracing: {}", self.batch_processor); - let common: sdk::trace::Config = trace.into(); + let common: opentelemetry_sdk::trace::Config = trace.into(); // Precompute representation otel Keys for the mappings so that we don't do heap allocation for each span let resource_mappings = self.enable_span_mapping.then(|| { @@ -136,50 +141,54 @@ impl TracingConfigurator for Config { }); let fixed_span_names = self.fixed_span_names; + let endpoint = &self + .endpoint + .to_full_uri(&Uri::from_static(DEFAULT_ENDPOINT)); let exporter = datadog_exporter::new_pipeline() - .with( - &self.endpoint.to_uri(&Uri::from_static(DEFAULT_ENDPOINT)), - |builder, e| builder.with_agent_endpoint(e.to_string().trim_end_matches('/')), - ) + .with_agent_endpoint(endpoint.to_string().trim_end_matches('/')) .with(&resource_mappings, |builder, resource_mappings| { let resource_mappings = resource_mappings.clone(); builder.with_resource_mapping(move |span, _model_config| { let span_name = if let Some(original) = span .attributes - .get(&Key::from_static_str(OTEL_ORIGINAL_NAME)) + .iter() + .find(|kv| kv.key.as_str() == OTEL_ORIGINAL_NAME) { - original.as_str() + original.value.as_str() } else { span.name.clone() }; - if let Some(mapping) = resource_mappings.get(span_name.as_ref()) { - if let Some(Value::String(value)) = span.attributes.get(mapping) { - return value.as_str(); - } + if let Some(mapping) = resource_mappings.get(span_name.as_ref()) + && let Some(KeyValue { + key: _, + value: Value::String(v), + }) = span.attributes.iter().find(|kv| kv.key == *mapping) + { + return v.as_str(); } - return span.name.as_ref(); + span.name.as_ref() }) }) .with_name_mapping(move |span, _model_config| { - if fixed_span_names { - if let Some(original) = span + if fixed_span_names + && let Some(original) = span .attributes - .get(&Key::from_static_str(OTEL_ORIGINAL_NAME)) - { - // Datadog expects static span names, not the ones in the otel spec. - // Remap the span name to the original name if it was remapped. - for name in BUILT_IN_SPAN_NAMES { - if name == original.as_str() { - return name; - } + .iter() + .find(|kv| kv.key.as_str() == OTEL_ORIGINAL_NAME) + { + // Datadog expects static span names, not the ones in the otel spec. + // Remap the span name to the original name if it was remapped. + for name in BUILT_IN_SPAN_NAMES { + if name == original.value.as_str() { + return name; } } } &span.name }) .with( - &common.resource.get(SERVICE_NAME), + &common.resource.get(SERVICE_NAME.into()), |builder, service_name| { // Datadog exporter incorrectly ignores the service name in the resource // Set it explicitly here @@ -192,7 +201,7 @@ impl TracingConfigurator for Config { .with_version( common .resource - .get(SERVICE_VERSION) + .get(SERVICE_VERSION.into()) .expect("cargo version is set as a resource default;qed") .to_string(), ) @@ -210,18 +219,24 @@ impl TracingConfigurator for Config { let mut span_metrics = default_span_metrics(); span_metrics.extend(self.span_metrics.clone()); - Ok(builder.with_span_processor( - BatchSpanProcessor::builder( - ExporterWrapper { - delegate: exporter, - span_metrics, - }, - opentelemetry::runtime::Tokio, - ) - .with_batch_config(self.batch_processor.clone().into()) - .build() - .filtered(), - )) + let batch_processor = opentelemetry_sdk::trace::BatchSpanProcessor::builder( + ExporterWrapper { + delegate: exporter, + span_metrics, + }, + NamedTokioRuntime::new("datadog-tracing"), + ) + .with_batch_config(self.batch_processor.clone().into()) + .build() + .filtered(); + + Ok( + if trace.preview_datadog_agent_sampling.unwrap_or_default() { + builder.with_span_processor(batch_processor.always_sampled()) + } else { + builder.with_span_processor(batch_processor) + }, + ) } } @@ -245,8 +260,9 @@ impl SpanExporter for ExporterWrapper { // We do all this dancing to avoid allocating. let original_span_name = span .attributes - .get(&Key::from_static_str(OTEL_ORIGINAL_NAME)) - .map(|v| v.as_str()); + .iter() + .find(|kv| kv.key.as_str() == OTEL_ORIGINAL_NAME) + .map(|kv| kv.value.as_str()); let final_span_name = if let Some(span_name) = &original_span_name { span_name.as_ref() } else { @@ -254,17 +270,17 @@ impl SpanExporter for ExporterWrapper { }; // Unfortunately trace state is immutable, so we have to create a new one - if let Some(setting) = self.span_metrics.get(final_span_name) { - if *setting != span.span_context.trace_state().measuring_enabled() { - let new_trace_state = span.span_context.trace_state().with_measuring(*setting); - span.span_context = SpanContext::new( - span.span_context.trace_id(), - span.span_context.span_id(), - span.span_context.trace_flags(), - span.span_context.is_remote(), - new_trace_state, - ) - } + if let Some(setting) = self.span_metrics.get(final_span_name) + && *setting != span.span_context.trace_state().measuring_enabled() + { + let new_trace_state = span.span_context.trace_state().with_measuring(*setting); + span.span_context = SpanContext::new( + span.span_context.trace_id(), + span.span_context.span_id(), + span.span_context.trace_flags(), + span.span_context.is_remote(), + new_trace_state, + ) } // Set the span kind https://github.com/DataDog/dd-trace-go/blob/main/ddtrace/ext/span_kind.go @@ -275,8 +291,7 @@ impl SpanExporter for ExporterWrapper { SpanKind::Consumer => "consumer", SpanKind::Internal => "internal", }; - span.attributes - .insert(KeyValue::new("span.kind", span_kind)); + span.attributes.push(KeyValue::new("span.kind", span_kind)); // Note we do NOT set span.type as it isn't a good fit for otel. } @@ -288,4 +303,7 @@ impl SpanExporter for ExporterWrapper { fn force_flush(&mut self) -> BoxFuture<'static, ExportResult> { self.delegate.force_flush() } + fn set_resource(&mut self, resource: &Resource) { + self.delegate.set_resource(resource); + } } diff --git a/apollo-router/src/plugins/telemetry/tracing/datadog/span_processor.rs b/apollo-router/src/plugins/telemetry/tracing/datadog/span_processor.rs new file mode 100644 index 0000000000..e362ca967c --- /dev/null +++ b/apollo-router/src/plugins/telemetry/tracing/datadog/span_processor.rs @@ -0,0 +1,138 @@ +use opentelemetry::Context; +use opentelemetry::trace::SpanContext; +use opentelemetry::trace::TraceResult; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::export::trace::SpanData; +use opentelemetry_sdk::trace::Span; +use opentelemetry_sdk::trace::SpanProcessor; + +/// When using the Datadog agent we need spans to always be exported. However, the batch span processor will only export spans that are sampled. +/// This wrapper will override the trace flags to always sample. +/// THe datadog exporter itself will look at the `sampling.priority` trace context attribute to determine if the span should be sampled. +#[derive(Debug)] +pub(crate) struct DatadogSpanProcessor { + delegate: T, +} + +impl DatadogSpanProcessor { + pub(crate) fn new(delegate: T) -> Self { + Self { delegate } + } +} + +impl SpanProcessor for DatadogSpanProcessor { + fn on_start(&self, span: &mut Span, cx: &Context) { + self.delegate.on_start(span, cx) + } + + fn on_end(&self, mut span: SpanData) { + // Note that the trace state for measuring and sampling priority is handled in the AgentSampler + // The only purpose of this span processor is to ensure that a span can pass through a batch processor. + let new_trace_flags = span.span_context.trace_flags().with_sampled(true); + span.span_context = SpanContext::new( + span.span_context.trace_id(), + span.span_context.span_id(), + new_trace_flags, + span.span_context.is_remote(), + span.span_context.trace_state().clone(), + ); + self.delegate.on_end(span) + } + + fn force_flush(&self) -> TraceResult<()> { + self.delegate.force_flush() + } + + fn shutdown(&self) -> TraceResult<()> { + self.delegate.shutdown() + } + + fn set_resource(&mut self, resource: &Resource) { + self.delegate.set_resource(resource) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::SystemTime; + + use opentelemetry::Context; + use opentelemetry::trace::SpanId; + use opentelemetry::trace::SpanKind; + use opentelemetry::trace::TraceFlags; + use opentelemetry::trace::TraceId; + use opentelemetry_sdk::trace::SpanEvents; + use opentelemetry_sdk::trace::SpanLinks; + use opentelemetry_sdk::trace::SpanProcessor; + use parking_lot::Mutex; + + use super::*; + + #[derive(Debug, Clone)] + struct MockSpanProcessor { + spans: Arc>>, + } + + impl MockSpanProcessor { + fn new() -> Self { + Self { + spans: Default::default(), + } + } + } + + impl SpanProcessor for MockSpanProcessor { + fn on_start(&self, _span: &mut Span, _cx: &Context) {} + + fn on_end(&self, span: SpanData) { + self.spans.lock().push(span); + } + + fn force_flush(&self) -> TraceResult<()> { + Ok(()) + } + + fn shutdown(&self) -> TraceResult<()> { + Ok(()) + } + } + + #[test] + fn test_on_end_updates_trace_flags() { + let mock_processor = MockSpanProcessor::new(); + let processor = DatadogSpanProcessor::new(mock_processor.clone()); + let span_context = SpanContext::new( + TraceId::from_u128(1), + SpanId::from_u64(1), + TraceFlags::default(), + false, + Default::default(), + ); + let span_data = SpanData { + span_context, + parent_span_id: SpanId::from_u64(1), + span_kind: SpanKind::Client, + name: Default::default(), + start_time: SystemTime::now(), + end_time: SystemTime::now(), + attributes: Vec::with_capacity(32), + events: SpanEvents::default(), + links: SpanLinks::default(), + status: Default::default(), + instrumentation_lib: Default::default(), + dropped_attributes_count: 0, + }; + + processor.on_end(span_data.clone()); + + // Verify that the trace flags are updated to sampled + let updated_trace_flags = span_data.span_context.trace_flags().with_sampled(true); + let stored_spans = mock_processor.spans.lock(); + assert_eq!(stored_spans.len(), 1); + assert_eq!( + stored_spans[0].span_context.trace_flags(), + updated_trace_flags + ); + } +} diff --git a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/intern.rs b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/intern.rs index fd1f69375f..d63fb9a42e 100644 --- a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/intern.rs +++ b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/intern.rs @@ -16,7 +16,7 @@ pub(crate) enum InternValue<'a> { OpenTelemetryValue(&'a Value), } -impl<'a> Hash for InternValue<'a> { +impl Hash for InternValue<'_> { fn hash(&self, state: &mut H) { match &self { InternValue::RegularString(s) => s.hash(state), @@ -40,7 +40,7 @@ impl<'a> Hash for InternValue<'a> { } } -impl<'a> Eq for InternValue<'a> {} +impl Eq for InternValue<'_> {} const BOOLEAN_TRUE: &str = "true"; const BOOLEAN_FALSE: &str = "false"; @@ -80,7 +80,7 @@ impl WriteAsLiteral for StringValue { } } -impl<'a> InternValue<'a> { +impl InternValue<'_> { pub(crate) fn write_as_str( &self, payload: &mut W, @@ -452,13 +452,13 @@ mod tests { f1.write_to(&mut buffer); - assert_eq!(&buffer[..], format!("{}", f1).as_bytes()); + assert_eq!(&buffer[..], format!("{f1}").as_bytes()); buffer.clear(); f2.write_to(&mut buffer); - assert_eq!(&buffer[..], format!("{}", f2).as_bytes()); + assert_eq!(&buffer[..], format!("{f2}").as_bytes()); } #[test] @@ -470,13 +470,13 @@ mod tests { s1.write_to(&mut buffer); - assert_eq!(&buffer[..], format!("\"{}\"", s1).as_bytes()); + assert_eq!(&buffer[..], format!("\"{s1}\"").as_bytes()); buffer.clear(); s2.write_to(&mut buffer); - assert_eq!(&buffer[..], format!("\"{}\"", s2).as_bytes()); + assert_eq!(&buffer[..], format!("\"{s2}\"").as_bytes()); } fn test_encoding_intern_value(value: InternValue<'_>) { diff --git a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/mod.rs b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/mod.rs index ae4a37ba07..5bace8a3f9 100644 --- a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/mod.rs +++ b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/mod.rs @@ -8,29 +8,24 @@ use std::sync::Arc; use std::time::Duration; use futures::future::BoxFuture; -use http::Method; -use http::Request; -use http::Uri; pub use model::ApiVersion; pub use model::Error; pub use model::FieldMappingFn; +use opentelemetry::KeyValue; use opentelemetry::global; -use opentelemetry::sdk; use opentelemetry::trace::TraceError; -use opentelemetry::KeyValue; -use opentelemetry_api::trace::TracerProvider; +use opentelemetry::trace::TracerProvider; use opentelemetry_http::HttpClient; use opentelemetry_http::ResponseExt; +use opentelemetry_sdk::Resource; use opentelemetry_sdk::export::trace::ExportResult; use opentelemetry_sdk::export::trace::SpanData; use opentelemetry_sdk::export::trace::SpanExporter; use opentelemetry_sdk::resource::ResourceDetector; use opentelemetry_sdk::resource::SdkProvidedResourceDetector; use opentelemetry_sdk::runtime::RuntimeChannel; -use opentelemetry_sdk::trace::BatchMessage; use opentelemetry_sdk::trace::Config; use opentelemetry_sdk::trace::Tracer; -use opentelemetry_sdk::Resource; use opentelemetry_semantic_conventions as semcov; use url::Url; @@ -74,17 +69,18 @@ impl Mapping { /// Datadog span exporter pub struct DatadogExporter { client: Arc, - request_url: Uri, + request_url: http::Uri, model_config: ModelConfig, api_version: ApiVersion, mapping: Mapping, unified_tags: UnifiedTags, + resource: Option, } impl DatadogExporter { fn new( model_config: ModelConfig, - request_url: Uri, + request_url: http::Uri, api_version: ApiVersion, client: Arc, mapping: Mapping, @@ -97,6 +93,7 @@ impl DatadogExporter { api_version, mapping, unified_tags, + resource: None, } } @@ -111,9 +108,10 @@ impl DatadogExporter { traces, &self.mapping, &self.unified_tags, + self.resource.as_ref(), )?; - let req = Request::builder() - .method(Method::POST) + let req = http::Request::builder() + .method(http::Method::POST) .uri(self.request_url.clone()) .header(http::header::CONTENT_TYPE, self.api_version.content_type()) .header(DATADOG_TRACE_COUNT_HEADER, trace_count) @@ -169,26 +167,7 @@ impl Default for DatadogPipelineBuilder { mapping: Mapping::empty(), api_version: ApiVersion::Version05, unified_tags: UnifiedTags::new(), - #[cfg(all( - not(feature = "reqwest-client"), - not(feature = "reqwest-blocking-client"), - not(feature = "surf-client"), - ))] client: None, - #[cfg(all( - not(feature = "reqwest-client"), - not(feature = "reqwest-blocking-client"), - feature = "surf-client" - ))] - client: Some(Arc::new(surf::Client::new())), - #[cfg(all( - not(feature = "surf-client"), - not(feature = "reqwest-blocking-client"), - feature = "reqwest-client" - ))] - client: Some(Arc::new(reqwest::Client::new())), - #[cfg(feature = "reqwest-blocking-client")] - client: Some(Arc::new(reqwest::blocking::Client::new())), } } } @@ -225,29 +204,23 @@ impl DatadogPipelineBuilder { cfg.resource = Cow::Owned(Resource::new( cfg.resource .iter() - .filter(|(k, _v)| *k != &semcov::resource::SERVICE_NAME) + .filter(|(k, _v)| k.as_str() != semcov::resource::SERVICE_NAME) .map(|(k, v)| KeyValue::new(k.clone(), v.clone())), )); cfg } else { - Config { - resource: Cow::Owned(Resource::empty()), - ..Default::default() - } + Config::default().with_resource(Resource::empty()) }; (config, service_name) } else { let service_name = SdkProvidedResourceDetector .detect(Duration::from_secs(0)) - .get(semcov::resource::SERVICE_NAME) + .get(semcov::resource::SERVICE_NAME.into()) .unwrap() .to_string(); ( - Config { - // use a empty resource to prevent TracerProvider to assign a service name. - resource: Cow::Owned(Resource::empty()), - ..Default::default() - }, + // use a empty resource to prevent TracerProvider to assign a service name. + Config::default().with_resource(Resource::empty()), service_name, ) } @@ -255,7 +228,7 @@ impl DatadogPipelineBuilder { // parse the endpoint and append the path based on versions. // keep the query and host the same. - fn build_endpoint(agent_endpoint: &str, version: &str) -> Result { + fn build_endpoint(agent_endpoint: &str, version: &str) -> Result { // build agent endpoint based on version let mut endpoint = agent_endpoint .parse::() @@ -298,37 +271,32 @@ impl DatadogPipelineBuilder { let (config, service_name) = self.build_config_and_service_name(); let exporter = self.build_exporter_with_service_name(service_name)?; let mut provider_builder = - sdk::trace::TracerProvider::builder().with_simple_exporter(exporter); + opentelemetry_sdk::trace::TracerProvider::builder().with_simple_exporter(exporter); provider_builder = provider_builder.with_config(config); let provider = provider_builder.build(); - let tracer = provider.versioned_tracer( - "opentelemetry-datadog", - Some(env!("CARGO_PKG_VERSION")), - Some(semcov::SCHEMA_URL), - None, - ); + let tracer = provider + .tracer_builder("opentelemetry-datadog") + .with_version(env!("CARGO_PKG_VERSION")) + .with_schema_url(semcov::SCHEMA_URL) + .build(); let _ = global::set_tracer_provider(provider); Ok(tracer) } /// Install the Datadog trace exporter pipeline using a batch span processor with the specified /// runtime. - pub fn install_batch>( - mut self, - runtime: R, - ) -> Result { + pub fn install_batch(mut self, runtime: R) -> Result { let (config, service_name) = self.build_config_and_service_name(); let exporter = self.build_exporter_with_service_name(service_name)?; - let mut provider_builder = - sdk::trace::TracerProvider::builder().with_batch_exporter(exporter, runtime); + let mut provider_builder = opentelemetry_sdk::trace::TracerProvider::builder() + .with_batch_exporter(exporter, runtime); provider_builder = provider_builder.with_config(config); let provider = provider_builder.build(); - let tracer = provider.versioned_tracer( - "opentelemetry-datadog", - Some(env!("CARGO_PKG_VERSION")), - Some(semcov::SCHEMA_URL), - None, - ); + let tracer = provider + .tracer_builder("opentelemetry-datadog") + .with_version(env!("CARGO_PKG_VERSION")) + .with_schema_url(semcov::SCHEMA_URL) + .build(); let _ = global::set_tracer_provider(provider); Ok(tracer) } @@ -450,6 +418,10 @@ impl SpanExporter for DatadogExporter { let client = self.client.clone(); Box::pin(send_request(client, request)) } + + fn set_resource(&mut self, resource: &Resource) { + self.resource = Some(resource.clone()); + } } /// Helper struct to custom the mapping between Opentelemetry spans and datadog spans. @@ -473,8 +445,8 @@ fn mapping_debug(f: &Option) -> String { #[cfg(test)] mod tests { use super::*; - use crate::plugins::telemetry::tracing::datadog_exporter::exporter::model::tests::get_span; use crate::plugins::telemetry::tracing::datadog_exporter::ApiVersion::Version05; + use crate::plugins::telemetry::tracing::datadog_exporter::exporter::model::tests::get_span; #[test] fn test_out_of_order_group() { @@ -528,7 +500,7 @@ mod tests { impl HttpClient for DummyClient { async fn send( &self, - _request: Request>, + _request: http::Request>, ) -> Result, opentelemetry_http::HttpError> { Ok(http::Response::new("dummy response".into())) } diff --git a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/mod.rs b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/mod.rs index d6db4b72b4..dfd3649dd1 100644 --- a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/mod.rs +++ b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/mod.rs @@ -1,9 +1,10 @@ use std::fmt::Debug; use http::uri; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::export::ExportError; use opentelemetry_sdk::export::trace::SpanData; use opentelemetry_sdk::export::trace::{self}; -use opentelemetry_sdk::export::ExportError; use url::ParseError; use self::unified_tags::UnifiedTags; @@ -82,7 +83,9 @@ pub enum Error { #[error("message pack error")] MessagePackError, /// No http client founded. User should provide one or enable features - #[error("http client must be set, users can enable reqwest or surf feature to use http client implementation within create")] + #[error( + "http client must be set, users can enable reqwest or surf feature to use http client implementation within create" + )] NoHttpClient, /// Http requests failed with following errors #[error(transparent)] @@ -150,6 +153,7 @@ impl ApiVersion { traces: Vec<&[trace::SpanData]>, mapping: &Mapping, unified_tags: &UnifiedTags, + resource: Option<&Resource>, ) -> Result, Error> { match self { Self::Version03 => v03::encode( @@ -167,6 +171,7 @@ impl ApiVersion { Some(f) => f(span, config), None => default_resource_mapping(span, config), }, + resource, ), Self::Version05 => v05::encode( model_config, @@ -184,6 +189,7 @@ impl ApiVersion { None => default_resource_mapping(span, config), }, unified_tags, + resource, ), } } @@ -191,11 +197,11 @@ impl ApiVersion { #[cfg(test)] pub(crate) mod tests { - use std::borrow::Cow; use std::time::Duration; use std::time::SystemTime; use base64::Engine; + use opentelemetry::KeyValue; use opentelemetry::trace::SpanContext; use opentelemetry::trace::SpanId; use opentelemetry::trace::SpanKind; @@ -203,11 +209,9 @@ pub(crate) mod tests { use opentelemetry::trace::TraceFlags; use opentelemetry::trace::TraceId; use opentelemetry::trace::TraceState; - use opentelemetry::KeyValue; - use opentelemetry_sdk::trace::EvictedHashMap; - use opentelemetry_sdk::trace::EvictedQueue; use opentelemetry_sdk::InstrumentationLibrary; - use opentelemetry_sdk::Resource; + use opentelemetry_sdk::trace::SpanEvents; + use opentelemetry_sdk::trace::SpanLinks; use opentelemetry_sdk::{self}; use super::*; @@ -228,15 +232,11 @@ pub(crate) mod tests { let start_time = SystemTime::UNIX_EPOCH; let end_time = start_time.checked_add(Duration::from_secs(1)).unwrap(); - let mut attributes: EvictedHashMap = EvictedHashMap::new(1, 1); - attributes.insert(KeyValue::new("span.type", "web")); - let resource = Resource::new(vec![KeyValue::new("host.name", "test")]); - let instrumentation_lib = InstrumentationLibrary::new( - "component", - None::<&'static str>, - None::<&'static str>, - None, - ); + let attributes = vec![ + KeyValue::new("span.type", "web"), + KeyValue::new("host.name", "test"), + ]; + let instrumentation_lib = InstrumentationLibrary::builder("component").build(); trace::SpanData { span_context, @@ -246,11 +246,11 @@ pub(crate) mod tests { start_time, end_time, attributes, - events: EvictedQueue::new(0), - links: EvictedQueue::new(0), + events: SpanEvents::default(), + links: SpanLinks::default(), status: Status::Ok, - resource: Cow::Owned(resource), instrumentation_lib, + dropped_attributes_count: 0, } } @@ -267,12 +267,16 @@ pub(crate) mod tests { traces.iter().map(|x| &x[..]).collect(), &Mapping::empty(), &UnifiedTags::new(), + None, )?); - assert_eq!(encoded.as_str(), "kZGMpHR5cGWjd2Vip3NlcnZpY2Wsc2VydmljZV9uYW1lpG5hbWWpY29tcG9uZW\ + assert_eq!( + encoded.as_str(), + "kZGMpHR5cGWjd2Vip3NlcnZpY2Wsc2VydmljZV9uYW1lpG5hbWWpY29tcG9uZW\ 50qHJlc291cmNlqHJlc291cmNlqHRyYWNlX2lkzwAAAAAAAAAHp3NwYW5faWTPAAAAAAAAAGOpcGFyZW50X2lkzwAAAA\ - AAAAABpXN0YXJ00wAAAAAAAAAAqGR1cmF0aW9u0wAAAAA7msoApWVycm9y0gAAAACkbWV0YYKpaG9zdC5uYW1lpHRlc3\ - Spc3Bhbi50eXBlo3dlYqdtZXRyaWNzgbVfc2FtcGxpbmdfcHJpb3JpdHlfdjHLAAAAAAAAAAA="); + AAAAABpXN0YXJ00wAAAAAAAAAAqGR1cmF0aW9u0wAAAAA7msoApWVycm9y0gAAAACkbWV0YYKpc3Bhbi50eXBlo3dlYq\ + lob3N0Lm5hbWWkdGVzdKdtZXRyaWNzgbVfc2FtcGxpbmdfcHJpb3JpdHlfdjHLAAAAAAAAAAA=" + ); Ok(()) } @@ -296,6 +300,7 @@ pub(crate) mod tests { traces.iter().map(|x| &x[..]).collect(), &Mapping::empty(), &unified_tags, + None, )?); // TODO: Need someone to generate the expected result or instructions to do so. diff --git a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/unified_tags.rs b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/unified_tags.rs index e4e835c550..85bece7e9f 100644 --- a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/unified_tags.rs +++ b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/unified_tags.rs @@ -1,4 +1,4 @@ -/// Unified tags - See: https://docs.datadoghq.com/getting_started/tagging/unified_service_tagging +//! Unified tags - See: pub struct UnifiedTags { pub service: UnifiedTagField, @@ -83,41 +83,3 @@ impl UnifiedTagEnum { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_service() { - std::env::set_var("DD_SERVICE", "test-SERVICE"); - let mut unified_tags = UnifiedTags::new(); - assert_eq!("test-service", unified_tags.service.value.clone().unwrap()); - unified_tags.set_service(Some(String::from("new_service"))); - assert_eq!("new_service", unified_tags.service().unwrap()); - std::env::remove_var("DD_SERVICE"); - } - - #[test] - fn test_env() { - std::env::set_var("DD_ENV", "test-env"); - let mut unified_tags = UnifiedTags::new(); - assert_eq!("test-env", unified_tags.env.value.clone().unwrap()); - unified_tags.set_env(Some(String::from("new_env"))); - assert_eq!("new_env", unified_tags.env.value.unwrap()); - std::env::remove_var("DD_ENV"); - } - - #[test] - fn test_version() { - std::env::set_var("DD_VERSION", "test-version-1.2.3"); - let mut unified_tags = UnifiedTags::new(); - assert_eq!( - "test-version-1.2.3", - unified_tags.version.value.clone().unwrap() - ); - unified_tags.set_version(Some(String::from("new_version"))); - assert_eq!("new_version", unified_tags.version.value.unwrap()); - std::env::remove_var("DD_VERSION"); - } -} diff --git a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/v03.rs b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/v03.rs index 8f7242ea36..e29a4b9c00 100644 --- a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/v03.rs +++ b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/v03.rs @@ -1,11 +1,13 @@ use std::time::SystemTime; +use opentelemetry::KeyValue; use opentelemetry::trace::Status; +use opentelemetry_sdk::Resource; use opentelemetry_sdk::export::trace::SpanData; -use crate::plugins::telemetry::tracing::datadog_exporter::exporter::model::SAMPLING_PRIORITY_KEY; use crate::plugins::telemetry::tracing::datadog_exporter::Error; use crate::plugins::telemetry::tracing::datadog_exporter::ModelConfig; +use crate::plugins::telemetry::tracing::datadog_exporter::exporter::model::SAMPLING_PRIORITY_KEY; pub(crate) fn encode( model_config: &ModelConfig, @@ -13,6 +15,7 @@ pub(crate) fn encode( get_service_name: S, get_name: N, get_resource: R, + resource: Option<&Resource>, ) -> Result, Error> where for<'a> S: Fn(&'a SpanData, &'a ModelConfig) -> &'a str, @@ -40,12 +43,12 @@ where .unwrap_or(0); let mut span_type_found = false; - for (key, value) in &span.attributes { - if key.as_str() == "span.type" { + for kv in &span.attributes { + if kv.key.as_str() == "span.type" { span_type_found = true; rmp::encode::write_map_len(&mut encoded, 12)?; rmp::encode::write_str(&mut encoded, "type")?; - rmp::encode::write_str(&mut encoded, value.as_str().as_ref())?; + rmp::encode::write_str(&mut encoded, kv.value.as_str().as_ref())?; break; } } @@ -100,13 +103,15 @@ where rmp::encode::write_str(&mut encoded, "meta")?; rmp::encode::write_map_len( &mut encoded, - (span.attributes.len() + span.resource.len()) as u32, + (span.attributes.len() + resource.map(|r| r.len()).unwrap_or(0)) as u32, )?; - for (key, value) in span.resource.iter() { - rmp::encode::write_str(&mut encoded, key.as_str())?; - rmp::encode::write_str(&mut encoded, value.as_str().as_ref())?; + if let Some(resource) = resource { + for (key, value) in resource.iter() { + rmp::encode::write_str(&mut encoded, key.as_str())?; + rmp::encode::write_str(&mut encoded, value.as_str().as_ref())?; + } } - for (key, value) in span.attributes.iter() { + for KeyValue { key, value } in span.attributes.iter() { rmp::encode::write_str(&mut encoded, key.as_str())?; rmp::encode::write_str(&mut encoded, value.as_str().as_ref())?; } diff --git a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/v05.rs b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/v05.rs index fd1590966e..5bd8f24e0e 100644 --- a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/v05.rs +++ b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/exporter/model/v05.rs @@ -1,16 +1,19 @@ use std::time::SystemTime; +use opentelemetry::KeyValue; use opentelemetry::trace::Status; +use opentelemetry_sdk::Resource; use opentelemetry_sdk::export::trace::SpanData; use super::unified_tags::UnifiedTagField; use super::unified_tags::UnifiedTags; -use crate::plugins::telemetry::tracing::datadog_exporter::exporter::intern::StringInterner; -use crate::plugins::telemetry::tracing::datadog_exporter::exporter::model::DD_MEASURED_KEY; -use crate::plugins::telemetry::tracing::datadog_exporter::exporter::model::SAMPLING_PRIORITY_KEY; use crate::plugins::telemetry::tracing::datadog_exporter::DatadogTraceState; use crate::plugins::telemetry::tracing::datadog_exporter::Error; use crate::plugins::telemetry::tracing::datadog_exporter::ModelConfig; +use crate::plugins::telemetry::tracing::datadog_exporter::exporter::intern::StringInterner; +use crate::plugins::telemetry::tracing::datadog_exporter::exporter::model::DD_MEASURED_KEY; +use crate::plugins::telemetry::tracing::datadog_exporter::exporter::model::SAMPLING_PRIORITY_KEY; +use crate::plugins::telemetry::tracing::datadog_exporter::propagator::SamplingPriority; const SPAN_NUM_ELEMENTS: u32 = 12; const METRICS_LEN: u32 = 2; @@ -78,6 +81,7 @@ pub(crate) fn encode( get_name: N, get_resource: R, unified_tags: &UnifiedTags, + resource: Option<&Resource>, ) -> Result, Error> where for<'a> S: Fn(&'a SpanData, &'a ModelConfig) -> &'a str, @@ -93,6 +97,7 @@ where get_resource, &traces, unified_tags, + resource, )?; let mut payload = Vec::with_capacity(traces.len() * 512); @@ -129,10 +134,22 @@ fn write_unified_tag<'a>( } fn get_sampling_priority(span: &SpanData) -> f64 { - if span.span_context.trace_state().priority_sampling_enabled() { - 1.0 - } else { - 0.0 + match span + .span_context + .trace_state() + .sampling_priority() + .unwrap_or_else(|| { + // Datadog sampling has not been set, revert to traceflags + if span.span_context.trace_flags().is_sampled() { + SamplingPriority::AutoKeep + } else { + SamplingPriority::AutoReject + } + }) { + SamplingPriority::UserReject => -1.0, + SamplingPriority::AutoReject => 0.0, + SamplingPriority::AutoKeep => 1.0, + SamplingPriority::UserKeep => 2.0, } } @@ -144,6 +161,7 @@ fn get_measuring(span: &SpanData) -> f64 { } } +#[allow(clippy::too_many_arguments)] fn encode_traces<'interner, S, N, R>( interner: &mut StringInterner<'interner>, model_config: &'interner ModelConfig, @@ -152,6 +170,7 @@ fn encode_traces<'interner, S, N, R>( get_resource: R, traces: &'interner [&[SpanData]], unified_tags: &'interner UnifiedTags, + resource: Option<&'interner Resource>, ) -> Result, Error> where for<'a> S: Fn(&'a SpanData, &'a ModelConfig) -> &'a str, @@ -179,7 +198,7 @@ where .unwrap_or(0); let mut span_type = interner.intern(""); - for (key, value) in &span.attributes { + for KeyValue { key, value } in &span.attributes { if key.as_str() == "span.type" { span_type = interner.intern_value(value); break; @@ -221,18 +240,20 @@ where rmp::encode::write_map_len( &mut encoded, - (span.attributes.len() + span.resource.len()) as u32 + (span.attributes.len() + resource.map(|r| r.len()).unwrap_or(0)) as u32 + unified_tags.compute_attribute_size() + GIT_META_TAGS_COUNT, )?; - for (key, value) in span.resource.iter() { - rmp::encode::write_u32(&mut encoded, interner.intern(key.as_str()))?; - rmp::encode::write_u32(&mut encoded, interner.intern_value(value))?; + if let Some(resource) = resource { + for (key, value) in resource.iter() { + rmp::encode::write_u32(&mut encoded, interner.intern(key.as_str()))?; + rmp::encode::write_u32(&mut encoded, interner.intern_value(value))?; + } } write_unified_tags(&mut encoded, interner, unified_tags)?; - for (key, value) in span.attributes.iter() { + for KeyValue { key, value } in span.attributes.iter() { rmp::encode::write_u32(&mut encoded, interner.intern(key.as_str()))?; rmp::encode::write_u32(&mut encoded, interner.intern_value(value))?; } diff --git a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/mod.rs b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/mod.rs index 1c586d48c8..f2d5c21aef 100644 --- a/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/mod.rs +++ b/apollo-router/src/plugins/telemetry/tracing/datadog_exporter/mod.rs @@ -136,8 +136,6 @@ mod exporter; -#[allow(unused_imports)] -pub use exporter::new_pipeline; #[allow(unused_imports)] pub use exporter::ApiVersion; #[allow(unused_imports)] @@ -151,6 +149,8 @@ pub use exporter::FieldMappingFn; #[allow(unused_imports)] pub use exporter::ModelConfig; #[allow(unused_imports)] +pub use exporter::new_pipeline; +#[allow(unused_imports)] pub use propagator::DatadogPropagator; #[allow(unused_imports)] pub use propagator::DatadogTraceState; @@ -158,18 +158,20 @@ pub use propagator::DatadogTraceState; pub use propagator::DatadogTraceStateBuilder; pub(crate) mod propagator { + use std::fmt::Display; + use once_cell::sync::Lazy; - use opentelemetry::propagation::text_map_propagator::FieldIter; + use opentelemetry::Context; use opentelemetry::propagation::Extractor; use opentelemetry::propagation::Injector; use opentelemetry::propagation::TextMapPropagator; + use opentelemetry::propagation::text_map_propagator::FieldIter; use opentelemetry::trace::SpanContext; use opentelemetry::trace::SpanId; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceFlags; use opentelemetry::trace::TraceId; use opentelemetry::trace::TraceState; - use opentelemetry::Context; const DATADOG_TRACE_ID_HEADER: &str = "x-datadog-trace-id"; const DATADOG_PARENT_ID_HEADER: &str = "x-datadog-parent-id"; @@ -177,9 +179,9 @@ pub(crate) mod propagator { const TRACE_FLAG_DEFERRED: TraceFlags = TraceFlags::new(0x02); const TRACE_STATE_PRIORITY_SAMPLING: &str = "psr"; - pub(crate) const TRACE_STATE_MEASURE: &str = "m"; - pub(crate) const TRACE_STATE_TRUE_VALUE: &str = "1"; - pub(crate) const TRACE_STATE_FALSE_VALUE: &str = "0"; + const TRACE_STATE_MEASURE: &str = "m"; + const TRACE_STATE_TRUE_VALUE: &str = "1"; + const TRACE_STATE_FALSE_VALUE: &str = "0"; static DATADOG_HEADER_FIELDS: Lazy<[String; 3]> = Lazy::new(|| { [ @@ -191,8 +193,8 @@ pub(crate) mod propagator { #[derive(Default)] pub struct DatadogTraceStateBuilder { - priority_sampling: bool, - measuring: bool, + sampling_priority: SamplingPriority, + measuring: Option, } fn boolean_to_trace_state_flag(value: bool) -> &'static str { @@ -209,33 +211,39 @@ pub(crate) mod propagator { #[allow(clippy::needless_update)] impl DatadogTraceStateBuilder { - pub fn with_priority_sampling(self, enabled: bool) -> Self { + pub fn with_priority_sampling(self, sampling_priority: SamplingPriority) -> Self { Self { - priority_sampling: enabled, + sampling_priority, ..self } } pub fn with_measuring(self, enabled: bool) -> Self { Self { - measuring: enabled, + measuring: Some(enabled), ..self } } pub fn build(self) -> TraceState { - let values = [ - ( - TRACE_STATE_MEASURE, - boolean_to_trace_state_flag(self.measuring), - ), - ( + if let Some(measuring) = self.measuring { + let values = [ + (TRACE_STATE_MEASURE, boolean_to_trace_state_flag(measuring)), + ( + TRACE_STATE_PRIORITY_SAMPLING, + &self.sampling_priority.to_string(), + ), + ]; + + TraceState::from_key_value(values).unwrap_or_default() + } else { + let values = [( TRACE_STATE_PRIORITY_SAMPLING, - boolean_to_trace_state_flag(self.priority_sampling), - ), - ]; + &self.sampling_priority.to_string(), + )]; - TraceState::from_key_value(values).unwrap_or_default() + TraceState::from_key_value(values).unwrap_or_default() + } } } @@ -244,9 +252,9 @@ pub(crate) mod propagator { fn measuring_enabled(&self) -> bool; - fn with_priority_sampling(&self, enabled: bool) -> TraceState; + fn with_priority_sampling(&self, sampling_priority: SamplingPriority) -> TraceState; - fn priority_sampling_enabled(&self) -> bool; + fn sampling_priority(&self) -> Option; } impl DatadogTraceState for TraceState { @@ -261,30 +269,77 @@ pub(crate) mod propagator { .unwrap_or_default() } - fn with_priority_sampling(&self, enabled: bool) -> TraceState { - self.insert( - TRACE_STATE_PRIORITY_SAMPLING, - boolean_to_trace_state_flag(enabled), - ) - .unwrap_or_else(|_err| self.clone()) + fn with_priority_sampling(&self, sampling_priority: SamplingPriority) -> TraceState { + self.insert(TRACE_STATE_PRIORITY_SAMPLING, sampling_priority.to_string()) + .unwrap_or_else(|_err| self.clone()) } - fn priority_sampling_enabled(&self) -> bool { - self.get(TRACE_STATE_PRIORITY_SAMPLING) - .map(trace_flag_to_boolean) - .unwrap_or_default() + fn sampling_priority(&self) -> Option { + self.get(TRACE_STATE_PRIORITY_SAMPLING).map(|value| { + SamplingPriority::try_from(value).unwrap_or(SamplingPriority::AutoReject) + }) } } - enum SamplingPriority { + #[derive(Default, Debug, Eq, PartialEq)] + pub(crate) enum SamplingPriority { UserReject = -1, + #[default] AutoReject = 0, AutoKeep = 1, UserKeep = 2, } + impl SamplingPriority { + pub(crate) fn as_i64(&self) -> i64 { + match self { + SamplingPriority::UserReject => -1, + SamplingPriority::AutoReject => 0, + SamplingPriority::AutoKeep => 1, + SamplingPriority::UserKeep => 2, + } + } + } + + impl Display for SamplingPriority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = match self { + SamplingPriority::UserReject => -1, + SamplingPriority::AutoReject => 0, + SamplingPriority::AutoKeep => 1, + SamplingPriority::UserKeep => 2, + }; + write!(f, "{value}") + } + } + + impl SamplingPriority { + pub fn as_str(&self) -> &'static str { + match self { + SamplingPriority::UserReject => "-1", + SamplingPriority::AutoReject => "0", + SamplingPriority::AutoKeep => "1", + SamplingPriority::UserKeep => "2", + } + } + } + + impl TryFrom<&str> for SamplingPriority { + type Error = ExtractError; + + fn try_from(value: &str) -> Result { + match value { + "-1" => Ok(SamplingPriority::UserReject), + "0" => Ok(SamplingPriority::AutoReject), + "1" => Ok(SamplingPriority::AutoKeep), + "2" => Ok(SamplingPriority::UserKeep), + _ => Err(ExtractError::SamplingPriority), + } + } + } + #[derive(Debug)] - enum ExtractError { + pub(crate) enum ExtractError { TraceId, SpanId, SamplingPriority, @@ -311,16 +366,7 @@ pub(crate) mod propagator { } fn create_trace_state_and_flags(trace_flags: TraceFlags) -> (TraceState, TraceFlags) { - if trace_flags & TRACE_FLAG_DEFERRED == TRACE_FLAG_DEFERRED { - (TraceState::default(), trace_flags) - } else { - ( - DatadogTraceStateBuilder::default() - .with_priority_sampling(trace_flags.is_sampled()) - .build(), - TraceFlags::SAMPLED, - ) - } + (TraceState::default(), trace_flags) } impl DatadogPropagator { @@ -343,23 +389,6 @@ pub(crate) mod propagator { .map_err(|_| ExtractError::SpanId) } - fn extract_sampling_priority( - &self, - sampling_priority: &str, - ) -> Result { - let i = sampling_priority - .parse::() - .map_err(|_| ExtractError::SamplingPriority)?; - - match i { - -1 => Ok(SamplingPriority::UserReject), - 0 => Ok(SamplingPriority::AutoReject), - 1 => Ok(SamplingPriority::AutoKeep), - 2 => Ok(SamplingPriority::UserKeep), - _ => Err(ExtractError::SamplingPriority), - } - } - fn extract_span_context( &self, extractor: &dyn Extractor, @@ -371,11 +400,11 @@ pub(crate) mod propagator { let span_id = self .extract_span_id(extractor.get(DATADOG_PARENT_ID_HEADER).unwrap_or("")) .unwrap_or(SpanId::INVALID); - let sampling_priority = self.extract_sampling_priority( - extractor - .get(DATADOG_SAMPLING_PRIORITY_HEADER) - .unwrap_or(""), - ); + let sampling_priority = extractor + .get(DATADOG_SAMPLING_PRIORITY_HEADER) + .unwrap_or("") + .try_into(); + let sampled = match sampling_priority { Ok(SamplingPriority::UserReject) | Ok(SamplingPriority::AutoReject) => { TraceFlags::default() @@ -387,7 +416,10 @@ pub(crate) mod propagator { Err(_) => TRACE_FLAG_DEFERRED, }; - let (trace_state, trace_flags) = create_trace_state_and_flags(sampled); + let (mut trace_state, trace_flags) = create_trace_state_and_flags(sampled); + if let Ok(sampling_priority) = sampling_priority { + trace_state = trace_state.with_priority_sampling(sampling_priority); + } Ok(SpanContext::new( trace_id, @@ -399,14 +431,6 @@ pub(crate) mod propagator { } } - fn get_sampling_priority(span_context: &SpanContext) -> SamplingPriority { - if span_context.trace_state().priority_sampling_enabled() { - SamplingPriority::AutoKeep - } else { - SamplingPriority::AutoReject - } - } - impl TextMapPropagator for DatadogPropagator { fn inject_context(&self, cx: &Context, injector: &mut dyn Injector) { let span = cx.span(); @@ -422,8 +446,17 @@ pub(crate) mod propagator { ); if span_context.trace_flags() & TRACE_FLAG_DEFERRED != TRACE_FLAG_DEFERRED { - let sampling_priority = get_sampling_priority(span_context); - + // The sampling priority + let sampling_priority = span_context + .trace_state() + .sampling_priority() + .unwrap_or_else(|| { + if span_context.is_sampled() { + SamplingPriority::AutoKeep + } else { + SamplingPriority::AutoReject + } + }); injector.set( DATADOG_SAMPLING_PRIORITY_HEADER, (sampling_priority as i32).to_string(), @@ -460,8 +493,10 @@ pub(crate) mod propagator { (vec![(DATADOG_TRACE_ID_HEADER, "garbage")], SpanContext::empty_context()), (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "garbage")], SpanContext::new(TraceId::from_u128(1234), SpanId::INVALID, TRACE_FLAG_DEFERRED, true, TraceState::default())), (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TRACE_FLAG_DEFERRED, true, TraceState::default())), - (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12"), (DATADOG_SAMPLING_PRIORITY_HEADER, "0")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TraceFlags::SAMPLED, true, DatadogTraceStateBuilder::default().with_priority_sampling(false).build())), - (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12"), (DATADOG_SAMPLING_PRIORITY_HEADER, "1")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TraceFlags::SAMPLED, true, DatadogTraceStateBuilder::default().with_priority_sampling(true).build())), + (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12"), (DATADOG_SAMPLING_PRIORITY_HEADER, "-1")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TraceFlags::default(), true, DatadogTraceStateBuilder::default().with_priority_sampling(SamplingPriority::UserReject).build())), + (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12"), (DATADOG_SAMPLING_PRIORITY_HEADER, "0")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TraceFlags::default(), true, DatadogTraceStateBuilder::default().with_priority_sampling(SamplingPriority::AutoReject).build())), + (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12"), (DATADOG_SAMPLING_PRIORITY_HEADER, "1")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TraceFlags::SAMPLED, true, DatadogTraceStateBuilder::default().with_priority_sampling(SamplingPriority::AutoKeep).build())), + (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12"), (DATADOG_SAMPLING_PRIORITY_HEADER, "2")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TraceFlags::SAMPLED, true, DatadogTraceStateBuilder::default().with_priority_sampling(SamplingPriority::UserKeep).build())), ] } @@ -473,8 +508,10 @@ pub(crate) mod propagator { (vec![], SpanContext::new(TraceId::from_hex("1234").unwrap(), SpanId::INVALID, TRACE_FLAG_DEFERRED, true, TraceState::default())), (vec![], SpanContext::new(TraceId::from_hex("1234").unwrap(), SpanId::INVALID, TraceFlags::SAMPLED, true, TraceState::default())), (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TRACE_FLAG_DEFERRED, true, TraceState::default())), - (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12"), (DATADOG_SAMPLING_PRIORITY_HEADER, "0")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TraceFlags::SAMPLED, true, DatadogTraceStateBuilder::default().with_priority_sampling(false).build())), - (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12"), (DATADOG_SAMPLING_PRIORITY_HEADER, "1")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TraceFlags::SAMPLED, true, DatadogTraceStateBuilder::default().with_priority_sampling(true).build())), + (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12"), (DATADOG_SAMPLING_PRIORITY_HEADER, "-1")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TraceFlags::default(), true, DatadogTraceStateBuilder::default().with_priority_sampling(SamplingPriority::UserReject).build())), + (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12"), (DATADOG_SAMPLING_PRIORITY_HEADER, "0")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TraceFlags::default(), true, DatadogTraceStateBuilder::default().with_priority_sampling(SamplingPriority::AutoReject).build())), + (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12"), (DATADOG_SAMPLING_PRIORITY_HEADER, "1")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TraceFlags::SAMPLED, true, DatadogTraceStateBuilder::default().with_priority_sampling(SamplingPriority::AutoKeep).build())), + (vec![(DATADOG_TRACE_ID_HEADER, "1234"), (DATADOG_PARENT_ID_HEADER, "12"), (DATADOG_SAMPLING_PRIORITY_HEADER, "2")], SpanContext::new(TraceId::from_u128(1234), SpanId::from_u64(12), TraceFlags::SAMPLED, true, DatadogTraceStateBuilder::default().with_priority_sampling(SamplingPriority::UserKeep).build())), ] } diff --git a/apollo-router/src/plugins/telemetry/tracing/jaeger.rs b/apollo-router/src/plugins/telemetry/tracing/jaeger.rs index f50aebefc2..e69de29bb2 100644 --- a/apollo-router/src/plugins/telemetry/tracing/jaeger.rs +++ b/apollo-router/src/plugins/telemetry/tracing/jaeger.rs @@ -1,149 +0,0 @@ -//! Configuration for jaeger tracing. -use std::fmt::Debug; - -use http::Uri; -use lazy_static::lazy_static; -use opentelemetry::runtime; -use opentelemetry::sdk::trace::BatchSpanProcessor; -use opentelemetry::sdk::trace::Builder; -use schemars::JsonSchema; -use serde::Deserialize; -use tower::BoxError; - -use crate::plugins::telemetry::config::GenericWith; -use crate::plugins::telemetry::config::TracingCommon; -use crate::plugins::telemetry::config_new::spans::Spans; -use crate::plugins::telemetry::endpoint::SocketEndpoint; -use crate::plugins::telemetry::endpoint::UriEndpoint; -use crate::plugins::telemetry::tracing::BatchProcessorConfig; -use crate::plugins::telemetry::tracing::SpanProcessorExt; -use crate::plugins::telemetry::tracing::TracingConfigurator; - -lazy_static! { - static ref DEFAULT_ENDPOINT: Uri = Uri::from_static("http://127.0.0.1:14268/api/traces"); -} -#[derive(Debug, Clone, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields, untagged)] -pub(crate) enum Config { - Agent { - /// Enable Jaeger - enabled: bool, - - /// Agent configuration - #[serde(default)] - agent: AgentConfig, - - /// Batch processor configuration - #[serde(default)] - batch_processor: BatchProcessorConfig, - }, - Collector { - /// Enable Jaeger - enabled: bool, - - /// Collector configuration - #[serde(default)] - collector: CollectorConfig, - - /// Batch processor configuration - #[serde(default)] - batch_processor: BatchProcessorConfig, - }, -} - -impl Default for Config { - fn default() -> Self { - Config::Agent { - enabled: false, - agent: Default::default(), - batch_processor: Default::default(), - } - } -} - -#[derive(Debug, Clone, Deserialize, JsonSchema, Default)] -#[serde(deny_unknown_fields, default)] -pub(crate) struct AgentConfig { - /// The endpoint to send to - endpoint: SocketEndpoint, -} - -#[derive(Debug, Clone, Deserialize, JsonSchema, Default)] -#[serde(deny_unknown_fields, default)] -pub(crate) struct CollectorConfig { - /// The endpoint to send reports to - endpoint: UriEndpoint, - /// The optional username - username: Option, - /// The optional password - password: Option, -} - -impl TracingConfigurator for Config { - fn enabled(&self) -> bool { - matches!( - self, - Config::Agent { enabled: true, .. } | Config::Collector { enabled: true, .. } - ) - } - - fn apply( - &self, - builder: Builder, - common: &TracingCommon, - _spans_config: &Spans, - ) -> Result { - match &self { - Config::Agent { - enabled, - agent, - batch_processor, - } if *enabled => { - tracing::info!("Configuring Jaeger tracing: {} (agent)", batch_processor); - let exporter = opentelemetry_jaeger::new_agent_pipeline() - .with_trace_config(common.into()) - .with(&agent.endpoint.to_socket(), |b, s| b.with_endpoint(s)) - .build_async_agent_exporter(opentelemetry::runtime::Tokio)?; - Ok(builder.with_span_processor( - BatchSpanProcessor::builder(exporter, opentelemetry::runtime::Tokio) - .with_batch_config(batch_processor.clone().into()) - .build() - .filtered(), - )) - } - Config::Collector { - enabled, - collector, - batch_processor, - } if *enabled => { - tracing::info!( - "Configuring Jaeger tracing: {} (collector)", - batch_processor - ); - - let exporter = opentelemetry_jaeger::new_collector_pipeline() - .with_trace_config(common.into()) - .with(&collector.username, |b, u| b.with_username(u)) - .with(&collector.password, |b, p| b.with_password(p)) - .with( - &collector - .endpoint - .to_uri(&DEFAULT_ENDPOINT) - // https://github.com/open-telemetry/opentelemetry-rust/issues/1280 Default jaeger endpoint for collector looks incorrect - .or_else(|| Some(DEFAULT_ENDPOINT.clone())), - |b, p| b.with_endpoint(p.to_string()), - ) - .with_reqwest() - .with_batch_processor_config(batch_processor.clone().into()) - .build_collector_exporter::()?; - Ok(builder.with_span_processor( - BatchSpanProcessor::builder(exporter, runtime::Tokio) - .with_batch_config(batch_processor.clone().into()) - .build() - .filtered(), - )) - } - _ => Ok(builder), - } - } -} diff --git a/apollo-router/src/plugins/telemetry/tracing/mod.rs b/apollo-router/src/plugins/telemetry/tracing/mod.rs index 0172f3e094..95d949b6c4 100644 --- a/apollo-router/src/plugins/telemetry/tracing/mod.rs +++ b/apollo-router/src/plugins/telemetry/tracing/mod.rs @@ -2,29 +2,30 @@ use std::fmt::Display; use std::fmt::Formatter; use std::time::Duration; -use opentelemetry::sdk::export::trace::SpanData; -use opentelemetry::sdk::trace::BatchConfig; -use opentelemetry::sdk::trace::Builder; -use opentelemetry::sdk::trace::EvictedHashMap; -use opentelemetry::sdk::trace::Span; -use opentelemetry::sdk::trace::SpanProcessor; -use opentelemetry::trace::TraceResult; use opentelemetry::Context; -use opentelemetry::KeyValue; +use opentelemetry::trace::TraceResult; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::export::trace::SpanData; +use opentelemetry_sdk::trace::BatchConfig; +use opentelemetry_sdk::trace::BatchConfigBuilder; +use opentelemetry_sdk::trace::Builder; +use opentelemetry_sdk::trace::Span; +use opentelemetry_sdk::trace::SpanProcessor; use schemars::JsonSchema; use serde::Deserialize; use tower::BoxError; use super::config_new::spans::Spans; +use super::formatters::APOLLO_CONNECTOR_PREFIX; use super::formatters::APOLLO_PRIVATE_PREFIX; use crate::plugins::telemetry::config::TracingCommon; +use crate::plugins::telemetry::tracing::datadog::DatadogSpanProcessor; pub(crate) mod apollo; pub(crate) mod apollo_telemetry; pub(crate) mod datadog; #[allow(unreachable_pub, dead_code)] pub(crate) mod datadog_exporter; -pub(crate) mod jaeger; pub(crate) mod otlp; pub(crate) mod reload; pub(crate) mod zipkin; @@ -50,24 +51,19 @@ impl SpanProcessor for ApolloFilterSpanProcessor { } fn on_end(&self, span: SpanData) { - if span - .attributes - .iter() - .any(|(key, _)| key.as_str().starts_with(APOLLO_PRIVATE_PREFIX)) - { - let attributes_len = span.attributes.len(); + if span.attributes.iter().any(|kv| { + kv.key.as_str().starts_with(APOLLO_PRIVATE_PREFIX) + || kv.key.as_str().starts_with(APOLLO_CONNECTOR_PREFIX) + }) { let span = SpanData { attributes: span .attributes .into_iter() - .filter(|(k, _)| !k.as_str().starts_with(APOLLO_PRIVATE_PREFIX)) - .fold( - EvictedHashMap::new(attributes_len as u32, attributes_len), - |mut m, (k, v)| { - m.insert(KeyValue::new(k, v)); - m - }, - ), + .filter(|kv| { + !kv.key.as_str().starts_with(APOLLO_PRIVATE_PREFIX) + && !kv.key.as_str().starts_with(APOLLO_CONNECTOR_PREFIX) + }) + .collect(), ..span }; @@ -81,9 +77,13 @@ impl SpanProcessor for ApolloFilterSpanProcessor { self.delegate.force_flush() } - fn shutdown(&mut self) -> TraceResult<()> { + fn shutdown(&self) -> TraceResult<()> { self.delegate.shutdown() } + + fn set_resource(&mut self, resource: &Resource) { + self.delegate.set_resource(resource) + } } trait SpanProcessorExt @@ -91,6 +91,7 @@ where Self: Sized + SpanProcessor, { fn filtered(self) -> ApolloFilterSpanProcessor; + fn always_sampled(self) -> DatadogSpanProcessor; } impl SpanProcessorExt for T @@ -100,6 +101,12 @@ where fn filtered(self) -> ApolloFilterSpanProcessor { ApolloFilterSpanProcessor { delegate: self } } + + /// This span processor will always send spans to the exporter even if they are not sampled. This is useful for the datadog agent which + /// uses spans for metrics. + fn always_sampled(self) -> DatadogSpanProcessor { + DatadogSpanProcessor::new(self) + } } /// Batch processor configuration @@ -159,13 +166,13 @@ fn max_concurrent_exports_default() -> usize { impl From for BatchConfig { fn from(config: BatchProcessorConfig) -> Self { - let mut default = BatchConfig::default(); - default = default.with_scheduled_delay(config.scheduled_delay); - default = default.with_max_queue_size(config.max_queue_size); - default = default.with_max_export_batch_size(config.max_export_batch_size); - default = default.with_max_export_timeout(config.max_export_timeout); - default = default.with_max_concurrent_exports(config.max_concurrent_exports); - default + BatchConfigBuilder::default() + .with_scheduled_delay(config.scheduled_delay) + .with_max_queue_size(config.max_queue_size) + .with_max_export_batch_size(config.max_export_batch_size) + .with_max_export_timeout(config.max_export_timeout) + .with_max_concurrent_exports(config.max_concurrent_exports) + .build() } } diff --git a/apollo-router/src/plugins/telemetry/tracing/otlp.rs b/apollo-router/src/plugins/telemetry/tracing/otlp.rs index be294427f2..51b785fded 100644 --- a/apollo-router/src/plugins/telemetry/tracing/otlp.rs +++ b/apollo-router/src/plugins/telemetry/tracing/otlp.rs @@ -1,13 +1,14 @@ //! Configuration for Otlp tracing. use std::result::Result; -use opentelemetry::sdk::trace::BatchSpanProcessor; -use opentelemetry::sdk::trace::Builder; use opentelemetry_otlp::SpanExporterBuilder; +use opentelemetry_sdk::trace::BatchSpanProcessor; +use opentelemetry_sdk::trace::Builder; use tower::BoxError; use crate::plugins::telemetry::config::TracingCommon; use crate::plugins::telemetry::config_new::spans::Spans; +use crate::plugins::telemetry::otel::named_runtime_channel::NamedTokioRuntime; use crate::plugins::telemetry::otlp::TelemetryDataKind; use crate::plugins::telemetry::tracing::SpanProcessorExt; use crate::plugins::telemetry::tracing::TracingConfigurator; @@ -20,20 +21,23 @@ impl TracingConfigurator for super::super::otlp::Config { fn apply( &self, builder: Builder, - _common: &TracingCommon, + common: &TracingCommon, _spans_config: &Spans, ) -> Result { - tracing::info!("Configuring Otlp tracing: {}", self.batch_processor); let exporter: SpanExporterBuilder = self.exporter(TelemetryDataKind::Traces)?; - - Ok(builder.with_span_processor( - BatchSpanProcessor::builder( - exporter.build_span_exporter()?, - opentelemetry::runtime::Tokio, - ) - .with_batch_config(self.batch_processor.clone().into()) - .build() - .filtered(), - )) + let batch_span_processor = BatchSpanProcessor::builder( + exporter.build_span_exporter()?, + NamedTokioRuntime::new("otlp-tracing"), + ) + .with_batch_config(self.batch_processor.clone().into()) + .build() + .filtered(); + Ok( + if common.preview_datadog_agent_sampling.unwrap_or_default() { + builder.with_span_processor(batch_span_processor.always_sampled()) + } else { + builder.with_span_processor(batch_span_processor) + }, + ) } } diff --git a/apollo-router/src/plugins/telemetry/tracing/reload.rs b/apollo-router/src/plugins/telemetry/tracing/reload.rs index c058ede1a6..493bdf4791 100644 --- a/apollo-router/src/plugins/telemetry/tracing/reload.rs +++ b/apollo-router/src/plugins/telemetry/tracing/reload.rs @@ -1,9 +1,9 @@ use std::borrow::Cow; use std::sync::Arc; -use std::sync::RwLock; use opentelemetry::trace::SpanBuilder; use opentelemetry::trace::Tracer; +use parking_lot::RwLock; use crate::plugins::telemetry::otel::OtelData; use crate::plugins::telemetry::otel::PreSampledTracer; @@ -15,24 +15,15 @@ pub(crate) struct ReloadTracer { impl PreSampledTracer for ReloadTracer { fn sampled_context(&self, data: &mut OtelData) -> opentelemetry::Context { - self.parent - .read() - .expect("parent tracer must be available") - .sampled_context(data) + self.parent.read().sampled_context(data) } fn new_trace_id(&self) -> opentelemetry::trace::TraceId { - self.parent - .read() - .expect("parent tracer must be available") - .new_trace_id() + self.parent.read().new_trace_id() } fn new_span_id(&self) -> opentelemetry::trace::SpanId { - self.parent - .read() - .expect("parent tracer must be available") - .new_span_id() + self.parent.read().new_span_id() } } @@ -43,37 +34,25 @@ impl Tracer for ReloadTracer { where T: Into>, { - self.parent - .read() - .expect("parent tracer must be available") - .start(name) + self.parent.read().start(name) } fn start_with_context(&self, name: T, parent_cx: &opentelemetry::Context) -> Self::Span where T: Into>, { - self.parent - .read() - .expect("parent tracer must be available") - .start_with_context(name, parent_cx) + self.parent.read().start_with_context(name, parent_cx) } fn span_builder(&self, name: T) -> SpanBuilder where T: Into>, { - self.parent - .read() - .expect("parent tracer must be available") - .span_builder(name) + self.parent.read().span_builder(name) } fn build(&self, builder: SpanBuilder) -> Self::Span { - self.parent - .read() - .expect("parent tracer must be available") - .build(builder) + self.parent.read().build(builder) } fn build_with_context( @@ -81,10 +60,7 @@ impl Tracer for ReloadTracer { builder: SpanBuilder, parent_cx: &opentelemetry::Context, ) -> Self::Span { - self.parent - .read() - .expect("parent tracer must be available") - .build_with_context(builder, parent_cx) + self.parent.read().build_with_context(builder, parent_cx) } fn in_span(&self, name: N, f: F) -> T @@ -93,10 +69,7 @@ impl Tracer for ReloadTracer { N: Into>, Self::Span: Send + Sync + 'static, { - self.parent - .read() - .expect("parent tracer must be available") - .in_span(name, f) + self.parent.read().in_span(name, f) } } @@ -108,9 +81,6 @@ impl ReloadTracer { } pub(crate) fn reload(&self, new: S) { - *self - .parent - .write() - .expect("parent tracer must be available") = new; + *self.parent.write() = new; } } diff --git a/apollo-router/src/plugins/telemetry/tracing/zipkin.rs b/apollo-router/src/plugins/telemetry/tracing/zipkin.rs index 1a9ccbd654..91e998aa67 100644 --- a/apollo-router/src/plugins/telemetry/tracing/zipkin.rs +++ b/apollo-router/src/plugins/telemetry/tracing/zipkin.rs @@ -1,9 +1,9 @@ //! Configuration for zipkin tracing. +use std::sync::LazyLock; + use http::Uri; -use lazy_static::lazy_static; -use opentelemetry::sdk; -use opentelemetry::sdk::trace::BatchSpanProcessor; -use opentelemetry::sdk::trace::Builder; +use opentelemetry_sdk::trace::BatchSpanProcessor; +use opentelemetry_sdk::trace::Builder; use opentelemetry_semantic_conventions::resource::SERVICE_NAME; use schemars::JsonSchema; use serde::Deserialize; @@ -13,13 +13,13 @@ use crate::plugins::telemetry::config::GenericWith; use crate::plugins::telemetry::config::TracingCommon; use crate::plugins::telemetry::config_new::spans::Spans; use crate::plugins::telemetry::endpoint::UriEndpoint; +use crate::plugins::telemetry::otel::named_runtime_channel::NamedTokioRuntime; use crate::plugins::telemetry::tracing::BatchProcessorConfig; use crate::plugins::telemetry::tracing::SpanProcessorExt; use crate::plugins::telemetry::tracing::TracingConfigurator; -lazy_static! { - static ref DEFAULT_ENDPOINT: Uri = Uri::from_static("http://127.0.0.1:9411/api/v2/spans"); -} +static DEFAULT_ENDPOINT: LazyLock = + LazyLock::new(|| Uri::from_static("http://127.0.0.1:9411/api/v2/spans")); #[derive(Debug, Clone, Deserialize, JsonSchema, Default)] #[serde(deny_unknown_fields)] @@ -48,13 +48,12 @@ impl TracingConfigurator for Config { _spans_config: &Spans, ) -> Result { tracing::info!("configuring Zipkin tracing: {}", self.batch_processor); - let common: sdk::trace::Config = trace.into(); + let common: opentelemetry_sdk::trace::Config = trace.into(); + let endpoint = &self.endpoint.to_full_uri(&DEFAULT_ENDPOINT); let exporter = opentelemetry_zipkin::new_pipeline() - .with(&self.endpoint.to_uri(&DEFAULT_ENDPOINT), |b, endpoint| { - b.with_collector_endpoint(endpoint.to_string()) - }) + .with_collector_endpoint(endpoint.to_string()) .with( - &common.resource.get(SERVICE_NAME), + &common.resource.get(SERVICE_NAME.into()), |builder, service_name| { // Zipkin exporter incorrectly ignores the service name in the resource // Set it explicitly here @@ -65,7 +64,7 @@ impl TracingConfigurator for Config { .init_exporter()?; Ok(builder.with_span_processor( - BatchSpanProcessor::builder(exporter, opentelemetry::runtime::Tokio) + BatchSpanProcessor::builder(exporter, NamedTokioRuntime::new("zipkin-tracing")) .with_batch_config(self.batch_processor.clone().into()) .build() .filtered(), diff --git a/apollo-router/src/plugins/telemetry/utils.rs b/apollo-router/src/plugins/telemetry/utils.rs index 42b57f122c..50d12ac81b 100644 --- a/apollo-router/src/plugins/telemetry/utils.rs +++ b/apollo-router/src/plugins/telemetry/utils.rs @@ -1,26 +1,14 @@ use std::time::Duration; use std::time::Instant; -use tracing_core::field::Value; - -pub(crate) trait TracingUtils { - fn or_empty(&self) -> &dyn Value; -} - -impl TracingUtils for bool { - fn or_empty(&self) -> &dyn Value { - if *self { - self as &dyn Value - } else { - &::tracing::field::Empty - } - } -} - /// Timer implementing Drop to automatically compute the duration between the moment it has been created until it's dropped ///```ignore /// Timer::new(|duration| { -/// tracing::info!(histogram.apollo_router_test = duration.as_secs_f64()); +/// f64_histogram!( +/// "apollo.router.test", +/// "Time spent testing the timer", +/// duration.as_secs_f64() +/// ); /// }) /// ``` pub(crate) struct Timer diff --git a/apollo-router/src/plugins/test.rs b/apollo-router/src/plugins/test.rs deleted file mode 100644 index ec1d9c509e..0000000000 --- a/apollo-router/src/plugins/test.rs +++ /dev/null @@ -1,230 +0,0 @@ -use std::any::TypeId; -use std::future::Future; -use std::ops::Deref; -use std::str::FromStr; -use std::sync::Arc; - -use apollo_compiler::validation::Valid; -use serde_json::Value; -use tower::BoxError; -use tower::ServiceBuilder; -use tower_service::Service; - -use crate::introspection::IntrospectionCache; -use crate::plugin::DynPlugin; -use crate::plugin::Plugin; -use crate::plugin::PluginInit; -use crate::query_planner::BridgeQueryPlanner; -use crate::query_planner::PlannerMode; -use crate::services::execution; -use crate::services::http; -use crate::services::router; -use crate::services::subgraph; -use crate::services::supergraph; -use crate::spec::Schema; -use crate::Configuration; -use crate::Notify; - -/// Test harness for plugins -/// The difference between this and the regular TestHarness is that this is more suited for unit testing. -/// It doesn't create the entire router stack, and is mostly just a convenient way to call a plugin service given an optional config and a schema. -/// -/// Here is a basic example that calls a router service and checks that validates logs are generated for the telemetry plugin. -/// -/// ``` -/// #[tokio::test(flavor = "multi_thread")] -/// async fn test_router_service() { -/// let test_harness: PluginTestHarness = PluginTestHarness::builder().build().await; -/// -/// async { -/// let mut response = test_harness -/// .call_router( -/// router::Request::fake_builder() -/// .body("query { foo }") -/// .build() -/// .expect("expecting valid request"), -/// |_r| { -/// tracing::info!("response"); -/// router::Response::fake_builder() -/// .header("custom-header", "val1") -/// .data(serde_json::json!({"data": "res"})) -/// .build() -/// .expect("expecting valid response") -/// }, -/// ) -/// .await -/// .expect("expecting successful response"); -/// -/// response.next_response().await; -/// } -/// .with_subscriber(assert_snapshot_subscriber!()) -/// .await -/// } -/// ``` -/// -/// You can pass in a configuration and a schema to the test harness. If you pass in a schema, the test harness will create a query planner and use the schema to extract subgraph schemas. -/// -/// -pub(crate) struct PluginTestHarness { - plugin: Box, - phantom: std::marker::PhantomData, -} -#[buildstructor::buildstructor] -impl PluginTestHarness { - #[builder] - pub(crate) async fn new<'a, 'b>(config: Option<&'a str>, schema: Option<&'b str>) -> Self { - let factory = crate::plugin::plugins() - .find(|factory| factory.type_id == TypeId::of::()) - .expect("plugin not registered"); - - let config = Configuration::from_str(config.unwrap_or_default()) - .expect("valid config required for test"); - - let name = &factory.name.replace("apollo.", ""); - let config_for_plugin = config - .validated_yaml - .clone() - .expect("invalid yaml") - .as_object() - .expect("invalid yaml") - .get(name) - .cloned() - .unwrap_or(Value::Object(Default::default())); - - let (supergraph_sdl, parsed_schema, subgraph_schemas) = if let Some(schema) = schema { - let schema = Schema::parse(schema, &config).unwrap(); - let sdl = schema.raw_sdl.clone(); - let supergraph = schema.supergraph_schema().clone(); - let rust_planner = PlannerMode::maybe_rust(&schema, &config).unwrap(); - let introspection = Arc::new(IntrospectionCache::new(&config)); - let planner = BridgeQueryPlanner::new( - schema.into(), - Arc::new(config), - None, - rust_planner, - introspection, - ) - .await - .unwrap(); - (sdl, supergraph, planner.subgraph_schemas()) - } else { - ( - "".to_string().into(), - Valid::assume_valid(apollo_compiler::Schema::new()), - Default::default(), - ) - }; - - let plugin_init = PluginInit::builder() - .config(config_for_plugin.clone()) - .supergraph_schema_id(crate::spec::Schema::schema_id(&supergraph_sdl).into()) - .supergraph_sdl(supergraph_sdl) - .supergraph_schema(Arc::new(parsed_schema)) - .subgraph_schemas(subgraph_schemas) - .notify(Notify::default()) - .build(); - - let plugin = factory - .create_instance(plugin_init) - .await - .expect("failed to create plugin"); - - Self { - plugin, - phantom: Default::default(), - } - } - - #[allow(dead_code)] - pub(crate) async fn call_router( - &self, - request: router::Request, - response_fn: fn(router::Request) -> F, - ) -> Result - where - F: Future> + Send + 'static, - { - let service: router::BoxService = router::BoxService::new( - ServiceBuilder::new() - .service_fn(move |req: router::Request| async move { (response_fn)(req).await }), - ); - - self.plugin.router_service(service).call(request).await - } - - pub(crate) async fn call_supergraph( - &self, - request: supergraph::Request, - response_fn: fn(supergraph::Request) -> supergraph::Response, - ) -> Result { - let service: supergraph::BoxService = supergraph::BoxService::new( - ServiceBuilder::new() - .service_fn(move |req: supergraph::Request| async move { Ok((response_fn)(req)) }), - ); - - self.plugin.supergraph_service(service).call(request).await - } - - #[allow(dead_code)] - pub(crate) async fn call_execution( - &self, - request: execution::Request, - response_fn: fn(execution::Request) -> execution::Response, - ) -> Result { - let service: execution::BoxService = execution::BoxService::new( - ServiceBuilder::new() - .service_fn(move |req: execution::Request| async move { Ok((response_fn)(req)) }), - ); - - self.plugin.execution_service(service).call(request).await - } - - #[allow(dead_code)] - pub(crate) async fn call_subgraph( - &self, - request: subgraph::Request, - response_fn: fn(subgraph::Request) -> subgraph::Response, - ) -> Result { - let name = request.subgraph_name.clone(); - let service: subgraph::BoxService = subgraph::BoxService::new( - ServiceBuilder::new() - .service_fn(move |req: subgraph::Request| async move { Ok((response_fn)(req)) }), - ); - - self.plugin - .subgraph_service(&name.expect("subgraph name must be populated"), service) - .call(request) - .await - } - #[allow(dead_code)] - pub(crate) async fn call_http_client( - &self, - subgraph_name: &str, - request: http::HttpRequest, - response_fn: fn(http::HttpRequest) -> http::HttpResponse, - ) -> Result { - let service: http::BoxService = http::BoxService::new( - ServiceBuilder::new() - .service_fn(move |req: http::HttpRequest| async move { Ok((response_fn)(req)) }), - ); - - self.plugin - .http_client_service(subgraph_name, service) - .call(request) - .await - } -} - -impl Deref for PluginTestHarness -where - T: Plugin, -{ - type Target = T; - - fn deref(&self) -> &Self::Target { - self.plugin - .as_any() - .downcast_ref() - .expect("plugin should be of type T") - } -} diff --git a/apollo-router/src/plugins/test/mod.rs b/apollo-router/src/plugins/test/mod.rs new file mode 100644 index 0000000000..377e00d432 --- /dev/null +++ b/apollo-router/src/plugins/test/mod.rs @@ -0,0 +1,992 @@ +mod router_ext; +mod subgraph_ext; +mod supergraph_ext; + +use std::any::TypeId; +use std::any::type_name; +use std::fmt::Debug; +use std::fmt::Formatter; +use std::future::Future; +use std::ops::Deref; +use std::str::FromStr; +use std::sync::Arc; +use std::task::Poll; + +use apollo_compiler::validation::Valid; +use pin_project_lite::pin_project; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use tower::BoxError; +use tower::ServiceBuilder; +use tower::ServiceExt; +use tower_service::Service; + +use crate::Configuration; +use crate::Notify; +use crate::plugin; +use crate::plugin::DynPlugin; +use crate::plugin::PluginInit; +use crate::plugin::PluginPrivate; +use crate::query_planner::QueryPlannerService; +use crate::services::connector; +use crate::services::execution; +use crate::services::http; +use crate::services::router; +use crate::services::subgraph; +use crate::services::supergraph; +use crate::spec::Schema; +use crate::uplink::license_enforcement::LicenseState; + +/// Test harness for plugins +/// The difference between this and the regular TestHarness is that this is more suited for unit testing. +/// It doesn't create the entire router stack, and is mostly just a convenient way to call a plugin service given an optional config and a schema. +/// +/// Here is a basic example that calls a router service and checks that validates logs are generated for the telemetry plugin. +/// +/// ``` +/// #[tokio::test(flavor = "multi_thread")] +/// async fn test_router_service() { +/// let test_harness: PluginTestHarness = PluginTestHarness::builder().build().await; +/// +/// async { +/// let test_harness: PluginTestHarness = +/// PluginTestHarness::builder().build().await; +/// +/// let mut service = test_harness.router_service(|_req| async { +/// Ok(router::Response::fake_builder() +/// .data(serde_json::json!({"data": {"field": "value"}})) +/// .header("x-custom-header", "test-value") +/// .build() +/// .unwrap()) +/// }); +/// +/// let response = service.call_default().await.unwrap(); +/// assert_eq!( +/// response.response.headers().get("x-custom-header"), +/// Some(&HeaderValue::from_static("test-value")) +/// ); +/// +/// response.next_response().await; +/// } +/// .with_subscriber(assert_snapshot_subscriber!()) +/// .await +/// } +/// ``` +/// +/// You can pass in a configuration and a schema to the test harness. If you pass in a schema, the test harness will create a query planner and use the schema to extract subgraph schemas. +/// +/// +pub(crate) struct PluginTestHarness>> { + plugin: Box, + phantom: std::marker::PhantomData, +} + +impl Debug for PluginTestHarness { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "PluginTestHarness<{}>", type_name::()) + } +} + +#[buildstructor::buildstructor] +impl> + 'static> PluginTestHarness { + #[builder] + #[allow(clippy::needless_lifetimes)] // needless in `new` but not in generated builder methods + pub(crate) async fn new<'a, 'b>( + config: Option<&'b str>, + schema: Option<&'a str>, + license: Option, + ) -> Result { + let factory = crate::plugin::plugins() + .find(|factory| factory.type_id == TypeId::of::()) + .expect("plugin not registered"); + + let config = Configuration::from_str(config.unwrap_or_default()) + .expect("valid config required for test"); + + let name = &factory.name.replace("apollo.", ""); + let config_for_plugin = config + .validated_yaml + .clone() + .expect("invalid yaml") + .as_object() + .expect("invalid yaml") + .get(name) + .cloned() + .unwrap_or(Value::Object(Default::default())); + + // Only the telemetry plugin should have access to the full router config (even in tests) + let full_config = config + .validated_yaml + .clone() + .filter(|_| name == "telemetry"); + + let (supergraph_sdl, parsed_schema, subgraph_schemas) = if let Some(schema) = schema { + let schema = Schema::parse(schema, &config).unwrap(); + let sdl = schema.raw_sdl.clone(); + let supergraph = schema.supergraph_schema().clone(); + let planner = QueryPlannerService::new(schema.into(), Arc::new(config)) + .await + .unwrap(); + (sdl, supergraph, planner.subgraph_schemas()) + } else { + ( + "".to_string().into(), + Valid::assume_valid(apollo_compiler::Schema::new()), + Default::default(), + ) + }; + + let plugin_init = PluginInit::builder() + .config(config_for_plugin.clone()) + .supergraph_schema_id(crate::spec::Schema::schema_id(&supergraph_sdl).into_inner()) + .supergraph_sdl(supergraph_sdl) + .supergraph_schema(Arc::new(parsed_schema)) + .subgraph_schemas(Arc::new( + subgraph_schemas + .iter() + .map(|(k, v)| (k.clone(), v.schema.clone())) + .collect(), + )) + .notify(Notify::default()) + .license(Arc::new(license.unwrap_or_default())) + .full_config(full_config) + .build(); + + let plugin = factory.create_instance(plugin_init).await?; + + Ok(Self { + plugin, + phantom: Default::default(), + }) + } + + pub(crate) fn router_service( + &self, + response_fn: impl Fn(router::Request) -> F + Send + Sync + Clone + 'static, + ) -> ServiceHandle + where + F: Future> + Send + 'static, + { + let service: router::BoxService = router::BoxService::new( + ServiceBuilder::new().service_fn(move |req: router::Request| { + let response_fn = response_fn.clone(); + async move { (response_fn)(req).await } + }), + ); + + ServiceHandle::new(self.plugin.router_service(service)) + } + + pub(crate) fn supergraph_service( + &self, + response_fn: impl Fn(supergraph::Request) -> F + Send + Sync + Clone + 'static, + ) -> ServiceHandle + where + F: Future> + Send + 'static, + { + let service: supergraph::BoxService = supergraph::BoxService::new( + ServiceBuilder::new().service_fn(move |req: supergraph::Request| { + let response_fn = response_fn.clone(); + async move { (response_fn)(req).await } + }), + ); + + ServiceHandle::new(self.plugin.supergraph_service(service)) + } + + #[allow(dead_code)] + pub(crate) fn execution_service( + &self, + response_fn: impl Fn(execution::Request) -> F + Send + Sync + Clone + 'static, + ) -> ServiceHandle + where + F: Future> + Send + 'static, + { + let service: execution::BoxService = execution::BoxService::new( + ServiceBuilder::new().service_fn(move |req: execution::Request| { + let response_fn = response_fn.clone(); + async move { (response_fn)(req).await } + }), + ); + + ServiceHandle::new(self.plugin.execution_service(service)) + } + + #[allow(dead_code)] + pub(crate) fn subgraph_service( + &self, + subgraph: &str, + response_fn: impl Fn(subgraph::Request) -> F + Send + Sync + Clone + 'static, + ) -> ServiceHandle + where + F: Future> + Send + 'static, + { + let service: subgraph::BoxService = subgraph::BoxService::new( + ServiceBuilder::new().service_fn(move |req: subgraph::Request| { + let response_fn = response_fn.clone(); + async move { (response_fn)(req).await } + }), + ); + ServiceHandle::new(self.plugin.subgraph_service(subgraph, service)) + } + + #[allow(dead_code)] + pub(crate) fn http_client_service( + &self, + subgraph: &str, + response_fn: impl Fn(http::HttpRequest) -> F + Send + Sync + Clone + 'static, + ) -> ServiceHandle + where + F: Future> + Send + 'static, + { + let service: http::BoxService = http::BoxService::new(ServiceBuilder::new().service_fn( + move |req: http::HttpRequest| { + let response_fn = response_fn.clone(); + async move { (response_fn)(req).await } + }, + )); + + ServiceHandle::new(self.plugin.http_client_service(subgraph, service)) + } + + #[allow(dead_code)] + pub(crate) async fn call_connector_request_service( + &self, + request: connector::request_service::Request, + response_fn: impl Fn( + connector::request_service::Request, + ) -> connector::request_service::Response + + Send + + Sync + + Clone + + 'static, + ) -> Result { + let service: connector::request_service::BoxService = + connector::request_service::BoxService::new(ServiceBuilder::new().service_fn( + move |req: connector::request_service::Request| { + let response_fn = response_fn.clone(); + async move { Ok((response_fn)(req)) } + }, + )); + + self.plugin + .connector_request_service(service, "my_connector".to_string()) + .call(request) + .await + } +} + +impl Deref for PluginTestHarness +where + T: PluginPrivate, +{ + type Target = T; + + fn deref(&self) -> &Self::Target { + self.plugin + .as_any() + .downcast_ref() + .expect("plugin should be of type T") + } +} + +pub(crate) struct ServiceHandle +where + S: Service, +{ + _phantom: std::marker::PhantomData, + service: Arc>, +} + +impl Clone for ServiceHandle { + fn clone(&self) -> Self { + Self { + _phantom: Default::default(), + service: self.service.clone(), + } + } +} + +impl ServiceHandle +where + S: Service, +{ + pub(crate) fn new(service: S) -> Self { + Self { + _phantom: Default::default(), + service: Arc::new(tokio::sync::Mutex::new(service)), + } + } + + /// Await the service to be ready and make a call to the service. + pub(crate) async fn call(&self, request: Req) -> Result { + // This is a bit of a dance to ensure that we wait until the service is readu to call, make + // the call and then drop the mutex guard before the call is executed. + // This means that other calls to the service can take place. + let mut service = self.service.lock().await; + let fut = service.ready().await?.call(request); + drop(service); + fut.await + } + + /// Call using the default request for the service. + pub(crate) async fn call_default(&self) -> Result + where + Req: FakeDefault, + { + self.call(FakeDefault::default()).await + } + + /// Returns the result of calling `poll_ready` on the service. + /// This is useful for testing things where a service may exert backpressure, but load shedding is not + /// is expected elsewhere in the pipeline. + pub(crate) async fn poll_ready(&self) -> Poll> { + PollReadyFuture { + _phantom: Default::default(), + service: self.service.clone().lock_owned().await, + } + .await + } +} + +pin_project! { + struct PollReadyFuture + where + S: Service, + { + _phantom: std::marker::PhantomData, + #[pin] + service: tokio::sync::OwnedMutexGuard, + } +} + +impl Future for PollReadyFuture +where + S: Service, +{ + type Output = Poll>; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + let mut this = self.project(); + Poll::Ready(this.service.poll_ready(cx)) + } +} + +pub(crate) trait FakeDefault { + fn default() -> Self; +} + +impl FakeDefault for router::Request { + fn default() -> Self { + router::Request::canned_request() + } +} + +impl FakeDefault for supergraph::Request { + fn default() -> Self { + supergraph::Request::fake_builder().build().unwrap() + } +} + +impl FakeDefault for execution::Request { + fn default() -> Self { + execution::Request::fake_builder().build() + } +} + +impl FakeDefault for subgraph::Request { + fn default() -> Self { + subgraph::Request::fake_builder().build() + } +} + +impl FakeDefault for http::HttpRequest { + fn default() -> Self { + http::HttpRequest { + http_request: Default::default(), + context: Default::default(), + } + } +} + +pub(crate) trait RequestTestExt +where + Request: Send + 'static, + Response: Send + 'static, +{ + fn canned_request() -> Request; + fn canned_result(self) -> Result; + fn assert_context_eq(&self, key: &str, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug; + fn assert_context_contains(&self, key: &str); + fn assert_context_not_contains(&self, key: &str); + fn assert_header_eq(&self, key: &str, value: &str); + async fn assert_body_eq(&mut self, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug + Serialize; + async fn assert_canned_body(&mut self); +} + +pub(crate) trait ResponseTestExt { + fn assert_context_eq(&self, key: &str, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug; + fn assert_context_contains(&self, key: &str); + fn assert_context_not_contains(&self, key: &str); + fn assert_header_eq(&self, key: &str, value: &str); + async fn assert_body_eq(&mut self, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug + Serialize; + async fn assert_canned_body(&mut self); + fn assert_status_code(&self, status_code: ::http::StatusCode); + async fn assert_contains_error(&mut self, error: &Value); +} + +#[cfg(test)] +mod test_for_harness { + use ::http::HeaderMap; + use ::http::HeaderValue; + use async_trait::async_trait; + use schemars::JsonSchema; + use serde::Deserialize; + use tokio::join; + + use super::*; + use crate::Context; + use crate::graphql; + use crate::metrics::FutureMetricsExt; + use crate::plugin::Plugin; + use crate::services::router; + use crate::services::router::BoxService; + use crate::services::router::body; + + /// Config for the test plugin + #[derive(JsonSchema, Deserialize)] + struct MyTestPluginConfig {} + + struct MyTestPlugin {} + #[async_trait] + impl Plugin for MyTestPlugin { + type Config = MyTestPluginConfig; + + async fn new(_init: PluginInit) -> Result + where + Self: Sized, + { + Ok(Self {}) + } + + fn router_service(&self, service: BoxService) -> BoxService { + ServiceBuilder::new() + .load_shed() + .concurrency_limit(1) + .service(service) + .boxed() + } + + fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { + // This purposely does not use load_shed to allow us to test readiness. + ServiceBuilder::new() + .concurrency_limit(1) + .service(service) + .boxed() + } + } + register_plugin!("apollo_testing", "my_test_plugin", MyTestPlugin); + + #[tokio::test] + async fn test_router_service() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.router_service(|_req| async { + Ok(router::Response::fake_builder() + .data(serde_json::json!({"data": {"field": "value"}})) + .header("x-custom-header", "test-value") + .build() + .unwrap()) + }); + + for _ in 0..2 { + let response = service.call_default().await.unwrap(); + assert!(service.poll_ready().await.is_ready()); + assert_eq!( + response.response.headers().get("x-custom-header"), + Some(&HeaderValue::from_static("test-value")) + ); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_router_service_multi_threaded() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.router_service(|_req| async { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + Ok(router::Response::fake_builder() + .data(serde_json::json!({"data": {"field": "value"}})) + .header("x-custom-header", "test-value") + .build() + .unwrap()) + }); + + let f1 = service.call_default(); + let f2 = service.call_default(); + + let (r1, r2) = join!(f1, f2); + let results = vec![r1, r2]; + // One of the calls should succeed, the other should fail due to concurrency limit + assert!(results.iter().any(|r| r.is_ok())); + assert!(results.iter().any(|r| r.is_err())); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_is_ready() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.supergraph_service(|_req| async { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + Ok(supergraph::Response::fake_builder() + .data(serde_json::json!({"data": {"field": "value"}})) + .header("x-custom-header", "test-value") + .build() + .unwrap()) + }); + + // Join will progress each future in turn, so we are guaranteed that the service will enter not + // ready state.. + let request = service.call_default(); + let (resp, poll) = join!(request, service.poll_ready()); + assert!(resp.is_ok()); + assert!(poll.is_pending()); + // Now that the first request has completed, the service should be ready again + assert!(service.poll_ready().await.is_ready()) + } + + #[tokio::test] + async fn test_supergraph_service() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.supergraph_service(|_req| async { + Ok(supergraph::Response::fake_builder() + .data(serde_json::json!({"data": {"field": "value"}})) + .header("x-custom-header", "test-value") + .build() + .unwrap()) + }); + + let response = service.call_default().await.unwrap(); + assert_eq!( + response.response.headers().get("x-custom-header"), + Some(&HeaderValue::from_static("test-value")) + ); + } + + #[tokio::test] + async fn test_execution_service() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.execution_service(|_req| async { + Ok(execution::Response::fake_builder() + .data(serde_json::json!({"data": {"field": "value"}})) + .header("x-custom-header", "test-value") + .build() + .unwrap()) + }); + + let response = service.call_default().await.unwrap(); + assert_eq!( + response.response.headers().get("x-custom-header"), + Some(&HeaderValue::from_static("test-value")) + ); + } + + #[tokio::test] + async fn test_subgraph_service() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.subgraph_service("test_subgraph", |_req| async { + let mut headers = HeaderMap::new(); + headers.insert("x-custom-header", "test-value".parse().unwrap()); + Ok(subgraph::Response::fake_builder() + .data(serde_json::json!({"data": {"field": "value"}})) + .headers(headers) + .build()) + }); + + let response = service.call_default().await.unwrap(); + assert_eq!( + response.response.headers().get("x-custom-header"), + Some(&HeaderValue::from_static("test-value")) + ); + } + + #[tokio::test] + async fn test_http_client_service() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.http_client_service("test_client", |req| async { + Ok(http::HttpResponse { + http_response: ::http::Response::builder() + .status(200) + .header("x-custom-header", "test-value") + .body(body::empty()) + .expect("valid response"), + context: req.context, + }) + }); + + let response = service.call_default().await.unwrap(); + assert_eq!( + response.http_response.headers().get("x-custom-header"), + Some(&HeaderValue::from_static("test-value")) + ); + } + + #[tokio::test] + async fn test_router_service_metrics() { + async { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.router_service(|_req| async { + u64_counter!("test", "test", 1u64); + Ok(router::Response::fake_builder() + .data(serde_json::json!({"data": {"field": "value"}})) + .header("x-custom-header", "test-value") + .build() + .unwrap()) + }); + + let _ = service.call_default().await; + assert_counter!("test", 1u64); + } + .with_metrics() + .await; + } + + #[tokio::test] + async fn test_router_service_assertions() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.router_service(|mut req| async move { + req.assert_context_contains("request-context-key"); + req.assert_context_not_contains("non-existent-key"); + req.assert_context_eq("request-context-key", "request-context-value".to_string()); + req.assert_header_eq("x-request-header", "request-value"); + req.assert_body_eq(serde_json::json!({"query": "topProducts"})) + .await; + let context = req.context.clone(); + context + .insert("response-context-key", "response-context-value".to_string()) + .expect("context"); + Ok(router::Response::fake_builder() + .data(serde_json::json!({"field": "value"})) + .header("x-custom-header", "test-value") + .context(context) + .build() + .unwrap()) + }); + + let context = Context::new(); + context + .insert("request-context-key", "request-context-value".to_string()) + .unwrap(); + let mut response = service + .call( + router::Request::fake_builder() + .header("x-request-header", "request-value") + .context(context) + .body(serde_json::json!({"query": "topProducts"}).to_string()) + .build() + .unwrap(), + ) + .await + .unwrap(); + response.assert_header_eq("x-custom-header", "test-value"); + response.assert_context_contains("response-context-key"); + response.assert_context_eq("response-context-key", "response-context-value".to_string()); + response.assert_context_not_contains("non-existent-key"); + response.assert_status_code(::http::StatusCode::OK); + response + .assert_body_eq(serde_json::json!({"data": {"field": "value"}})) + .await; + } + + #[tokio::test] + async fn test_supergraph_service_assertions() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.supergraph_service(|mut req| async move { + req.assert_context_contains("request-context-key"); + req.assert_context_not_contains("non-existent-key"); + req.assert_context_eq("request-context-key", "request-context-value".to_string()); + req.assert_header_eq("x-request-header", "request-value"); + req.assert_body_eq(serde_json::json!({"query": "topProducts"})) + .await; + let context = req.context.clone(); + context + .insert("response-context-key", "response-context-value".to_string()) + .expect("context"); + Ok(supergraph::Response::fake_builder() + .data(serde_json::json!({"field": "value"})) + .header("x-custom-header", "test-value") + .context(context) + .build() + .unwrap()) + }); + + let context = Context::new(); + context + .insert("request-context-key", "request-context-value".to_string()) + .unwrap(); + let mut response = service + .call( + supergraph::Request::fake_builder() + .header("x-request-header", "request-value") + .context(context) + .query("topProducts".to_string()) + .build() + .unwrap(), + ) + .await + .unwrap(); + response.assert_header_eq("x-custom-header", "test-value"); + response.assert_context_contains("response-context-key"); + response.assert_context_eq("response-context-key", "response-context-value".to_string()); + response.assert_context_not_contains("non-existent-key"); + response.assert_status_code(::http::StatusCode::OK); + response + .assert_body_eq(serde_json::json!([{"data": {"field": "value"}}])) + .await; + } + + #[tokio::test] + async fn test_subgraph_service_assertions() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.subgraph_service("test_subgraph", |mut req| async move { + req.assert_context_contains("request-context-key"); + req.assert_context_not_contains("non-existent-key"); + req.assert_context_eq("request-context-key", "request-context-value".to_string()); + req.assert_header_eq("x-request-header", "request-value"); + req.assert_body_eq(serde_json::json!({"query": "topProducts"})) + .await; + let context = req.context.clone(); + context + .insert("response-context-key", "response-context-value".to_string()) + .expect("context"); + let mut headers = HeaderMap::new(); + headers.insert("x-custom-header", "test-value".parse().unwrap()); + Ok(subgraph::Response::fake_builder() + .data(serde_json::json!({"field": "value"})) + .headers(headers) + .context(context) + .build()) + }); + + let context = Context::new(); + context + .insert("request-context-key", "request-context-value".to_string()) + .unwrap(); + let mut response = service + .call( + subgraph::Request::fake_builder() + .subgraph_request( + ::http::Request::builder() + .header("x-request-header", "request-value") + .body( + graphql::Request::fake_builder() + .query("topProducts".to_string()) + .build(), + ) + .unwrap(), + ) + .context(context) + .build(), + ) + .await + .unwrap(); + response.assert_header_eq("x-custom-header", "test-value"); + response.assert_context_contains("response-context-key"); + response.assert_context_eq("response-context-key", "response-context-value".to_string()); + response.assert_context_not_contains("non-existent-key"); + response.assert_status_code(::http::StatusCode::OK); + response + .assert_body_eq(serde_json::json!({"data": {"field": "value"}})) + .await; + } + + #[tokio::test] + async fn test_canned_router_request_response() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.router_service(|mut req| async move { + req.assert_canned_body().await; + req.canned_result() + }); + + let mut response = service + .call(router::Request::canned_request()) + .await + .unwrap(); + response.assert_canned_body().await; + } + + #[tokio::test] + async fn test_canned_supergraph_request_response() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.supergraph_service(|mut req| async move { + req.assert_canned_body().await; + req.canned_result() + }); + + let mut response = service + .call(supergraph::Request::canned_request()) + .await + .unwrap(); + response.assert_canned_body().await; + } + + #[tokio::test] + async fn test_canned_subgraph_request_response() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.subgraph_service("test_subgraph", |mut req| async move { + req.assert_canned_body().await; + req.canned_result() + }); + + let mut response = service + .call(subgraph::Request::canned_request()) + .await + .unwrap(); + response.assert_canned_body().await + } + + #[tokio::test] + async fn test_router_service_assert_contains_error() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.router_service(|_req| async { + Ok(router::Response::fake_builder() + .error( + graphql::Error::builder() + .message("Test error") + .extension_code("TEST_ERROR") + .build(), + ) + .build() + .unwrap()) + }); + + let mut response = service.call_default().await.unwrap(); + response + .assert_contains_error( + &serde_json::json!({"message": "Test error", "extensions":{"code": "TEST_ERROR"}}), + ) + .await; + } + + #[tokio::test] + async fn test_supergraph_service_assert_contains_error() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.supergraph_service(|_req| async { + Ok(supergraph::Response::fake_builder() + .error( + graphql::Error::builder() + .message("Test error") + .extension_code("TEST_ERROR") + .build(), + ) + .build() + .unwrap()) + }); + + let mut response = service.call_default().await.unwrap(); + response + .assert_contains_error( + &serde_json::json!({"message": "Test error", "extensions":{"code": "TEST_ERROR"}}), + ) + .await; + } + + #[tokio::test] + async fn test_subgraph_service_assert_error_contains_error() { + let test_harness: PluginTestHarness = PluginTestHarness::builder() + .build() + .await + .expect("test harness"); + + let service = test_harness.subgraph_service("test_subgraph", |_req| async { + Ok(subgraph::Response::fake_builder() + .error( + graphql::Error::builder() + .message("Test error") + .extension_code("TEST_ERROR") + .build(), + ) + .build()) + }); + + let mut response = service.call_default().await.unwrap(); + response + .assert_contains_error( + &serde_json::json!({"message": "Test error", "extensions":{"code": "TEST_ERROR"}}), + ) + .await; + } +} diff --git a/apollo-router/src/plugins/test/router_ext.rs b/apollo-router/src/plugins/test/router_ext.rs new file mode 100644 index 0000000000..8808b38436 --- /dev/null +++ b/apollo-router/src/plugins/test/router_ext.rs @@ -0,0 +1,192 @@ +use std::fmt::Debug; + +use http_body_util::BodyExt; +use itertools::Itertools; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use serde_json::json; + +use crate::plugins::test::RequestTestExt; +use crate::plugins::test::ResponseTestExt; +use crate::services::RouterRequest; +use crate::services::RouterResponse; +use crate::services::router; + +fn canned_request_body() -> Value { + json!({ + "query":"query SimpleQuery {\ntopProducts {\n name\n price\n \n}\n}" + }) +} + +fn canned_response_body() -> Value { + json!({ + "data": { + "field": "value" + } + }) +} + +impl RequestTestExt for RouterRequest { + fn canned_request() -> router::Request { + router::Request::fake_builder() + .body(canned_request_body().to_string()) + .build() + .expect("canned request") + } + + fn canned_result(self) -> router::ServiceResult { + router::Response::fake_builder() + .context(self.context.clone()) + .data(json! ({"field": "value"})) + .build() + } + + fn assert_context_eq(&self, key: &str, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug, + { + let ctx_value = self + .context + .get::<_, T>(key) + .expect("context value not deserializable") + .expect("context value not found"); + pretty_assertions::assert_eq!(ctx_value, value, "context '{}' value mismatch", key); + } + + fn assert_context_contains(&self, key: &str) { + if !self.context.contains_key(key) { + panic!("context '{key}' value not found") + } + } + + fn assert_context_not_contains(&self, key: &str) { + if self.context.contains_key(key) { + panic!("context '{key}' value was present") + } + } + + fn assert_header_eq(&self, key: &str, value: &str) { + let header_value = self + .router_request + .headers() + .get(key) + .unwrap_or_else(|| panic!("header '{key}' not found")); + pretty_assertions::assert_eq!(header_value, value, "header '{}' value mismatch", key); + } + + async fn assert_body_eq(&mut self, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug + Serialize, + { + let body_value = self + .router_request + .body_mut() + .collect() + .await + .expect("no body"); + let body_bytes = body_value.to_bytes(); + if body_bytes.is_empty() { + panic!("body value is empty"); + } + let body_value = serde_json::from_slice::(&body_bytes) + .expect("body value not deserializable"); + let expected_value = serde_json::to_value(value).expect("expected value not serializable"); + pretty_assertions::assert_eq!( + serde_yaml::to_string(&body_value).expect("could not serialize"), + serde_yaml::to_string(&expected_value).expect("could not serialize") + ); + } + + async fn assert_canned_body(&mut self) { + self.assert_body_eq(canned_request_body()).await + } +} + +impl ResponseTestExt for RouterResponse { + fn assert_context_eq(&self, key: &str, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug, + { + let ctx_value = self + .context + .get::<_, T>(key) + .expect("context value not deserializable") + .expect("context value not found"); + pretty_assertions::assert_eq!(ctx_value, value, "context '{}' value mismatch", key); + } + + fn assert_context_contains(&self, key: &str) { + if !self.context.contains_key(key) { + panic!("context '{key}' value not found") + } + } + + fn assert_context_not_contains(&self, key: &str) { + if self.context.contains_key(key) { + panic!("context '{key}' value was present") + } + } + + fn assert_header_eq(&self, key: &str, value: &str) { + let header_value = self + .response + .headers() + .get(key) + .unwrap_or_else(|| panic!("header '{key}' not found")); + pretty_assertions::assert_eq!(header_value, value, "header '{}' value mismatch", key); + } + + async fn assert_body_eq(&mut self, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug + Serialize, + { + let body_value = self.response.body_mut().collect().await.expect("no body"); + let body_bytes = body_value.to_bytes(); + if body_bytes.is_empty() { + panic!("body value is empty"); + } + let body_value = serde_json::from_slice::(&body_bytes) + .expect("body value not deserializable"); + let expected_value = serde_json::to_value(value).expect("expected value not serializable"); + pretty_assertions::assert_eq!( + serde_yaml::to_string(&body_value).expect("could not serialize"), + serde_yaml::to_string(&expected_value).expect("could not serialize") + ); + } + + async fn assert_contains_error(&mut self, error: &Value) { + let body_value = self.response.body_mut().collect().await.expect("no body"); + let body_bytes = body_value.to_bytes(); + if body_bytes.is_empty() { + panic!("body value is empty"); + } + let body_value = serde_json::from_slice::(&body_bytes) + .expect("body value not deserializable"); + + let errors = body_value + .get("errors") + .expect("errors not found") + .as_array() + .expect("expected object"); + if !errors.iter().contains(error) { + panic!( + "Expected error {}\nActual errors\n{}", + serde_yaml::to_string(error).expect("error"), + serde_yaml::to_string(errors).expect("errors") + ) + } + } + + async fn assert_canned_body(&mut self) { + self.assert_body_eq(canned_response_body()).await + } + + fn assert_status_code(&self, status_code: ::http::StatusCode) { + pretty_assertions::assert_eq!( + self.response.status(), + status_code, + "http status code mismatch" + ); + } +} diff --git a/apollo-router/src/plugins/test/subgraph_ext.rs b/apollo-router/src/plugins/test/subgraph_ext.rs new file mode 100644 index 0000000000..a9d65e4a9e --- /dev/null +++ b/apollo-router/src/plugins/test/subgraph_ext.rs @@ -0,0 +1,174 @@ +use std::fmt::Debug; + +use itertools::Itertools; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use serde_json::json; + +use crate::graphql; +use crate::plugins::test::RequestTestExt; +use crate::plugins::test::ResponseTestExt; +use crate::services::SubgraphRequest; +use crate::services::SubgraphResponse; +use crate::services::subgraph; + +fn canned_request_body() -> Value { + json!({ + "query":"query SimpleQuery {\ntopProducts {\n name\n price\n \n}\n}" + }) +} + +fn canned_query() -> &'static str { + "query SimpleQuery {\ntopProducts {\n name\n price\n \n}\n}" +} + +fn canned_response_body() -> Value { + json!({ + "field": "value" + }) +} + +fn canned_response_body_data() -> Value { + json!({ + "data": { + "field": "value" + } + }) +} + +impl RequestTestExt for SubgraphRequest { + fn canned_request() -> subgraph::Request { + subgraph::Request::fake_builder() + .subgraph_request( + http::Request::builder() + .body(graphql::Request::builder().query(canned_query()).build()) + .expect("canned request"), + ) + .build() + } + + fn canned_result(self) -> subgraph::ServiceResult { + Ok(subgraph::Response::fake_builder() + .context(self.context.clone()) + .data(canned_response_body()) + .build()) + } + + fn assert_context_eq(&self, key: &str, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug, + { + let ctx_value = self + .context + .get::<_, T>(key) + .expect("context value not deserializable") + .expect("context value not found"); + pretty_assertions::assert_eq!(ctx_value, value, "context '{}' value mismatch", key); + } + + fn assert_context_contains(&self, key: &str) { + if !self.context.contains_key(key) { + panic!("context '{key}' value not found") + } + } + + fn assert_context_not_contains(&self, key: &str) { + if self.context.contains_key(key) { + panic!("context '{key}' value was present") + } + } + + fn assert_header_eq(&self, key: &str, value: &str) { + let header_value = self + .subgraph_request + .headers() + .get(key) + .unwrap_or_else(|| panic!("header '{key}' not found")); + pretty_assertions::assert_eq!(header_value, value, "header '{}' value mismatch", key); + } + + async fn assert_body_eq(&mut self, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug + Serialize, + { + pretty_assertions::assert_eq!( + serde_yaml::to_string(&self.subgraph_request.body()).expect("could not serialize"), + serde_yaml::to_string(&value).expect("could not serialize") + ); + } + + async fn assert_canned_body(&mut self) { + self.assert_body_eq(canned_request_body()).await + } +} + +impl ResponseTestExt for SubgraphResponse { + fn assert_context_eq(&self, key: &str, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug, + { + let ctx_value = self + .context + .get::<_, T>(key) + .expect("context value not deserializable") + .expect("context value not found"); + pretty_assertions::assert_eq!(ctx_value, value, "context '{}' value mismatch", key); + } + + fn assert_context_contains(&self, key: &str) { + if !self.context.contains_key(key) { + panic!("context '{key}' value not found") + } + } + + fn assert_context_not_contains(&self, key: &str) { + if self.context.contains_key(key) { + panic!("context '{key}' value was present") + } + } + + fn assert_header_eq(&self, key: &str, value: &str) { + let header_value = self + .response + .headers() + .get(key) + .unwrap_or_else(|| panic!("header '{key}' not found")); + pretty_assertions::assert_eq!(header_value, value, "header '{}' value mismatch", key); + } + + async fn assert_body_eq(&mut self, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug + Serialize, + { + pretty_assertions::assert_eq!( + serde_yaml::to_string(&self.response.body_mut()).expect("could not serialize"), + serde_yaml::to_string(&value).expect("could not serialize") + ); + } + + async fn assert_contains_error(&mut self, error: &Value) { + let errors = &self.response.body().errors; + let serialized = serde_json::to_value(errors).expect("could not serialize"); + let serialized_errors = serialized.as_array().expect("expected array"); + if !serialized_errors.iter().contains(error) { + panic!( + "Expected error {}\nActual errors\n{}", + serde_yaml::to_string(error).expect("error"), + serde_yaml::to_string(errors).expect("errors") + ) + } + } + + async fn assert_canned_body(&mut self) { + self.assert_body_eq(canned_response_body_data()).await + } + + fn assert_status_code(&self, status_code: ::http::StatusCode) { + pretty_assertions::assert_eq!( + self.response.status(), + status_code, + "http status code mismatch" + ); + } +} diff --git a/apollo-router/src/plugins/test/supergraph_ext.rs b/apollo-router/src/plugins/test/supergraph_ext.rs new file mode 100644 index 0000000000..f5470205db --- /dev/null +++ b/apollo-router/src/plugins/test/supergraph_ext.rs @@ -0,0 +1,183 @@ +use std::fmt::Debug; + +use futures::StreamExt; +use itertools::Itertools; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use serde_json::json; + +use crate::graphql; +use crate::plugins::test::RequestTestExt; +use crate::plugins::test::ResponseTestExt; +use crate::services::SupergraphRequest; +use crate::services::SupergraphResponse; +use crate::services::supergraph; + +fn canned_request_body() -> Value { + json!({ + "query":"query SimpleQuery {\ntopProducts {\n name\n price\n \n}\n}" + }) +} + +fn canned_request_query() -> &'static str { + "query SimpleQuery {\ntopProducts {\n name\n price\n \n}\n}" +} + +fn canned_response_body() -> Value { + json!({ + "field": "value" + }) +} + +fn canned_response_body_array() -> Value { + json!([{ + "data": { + "field": "value" + } + }]) +} + +impl RequestTestExt for SupergraphRequest { + fn canned_request() -> supergraph::Request { + supergraph::Request::fake_builder() + .query(canned_request_query()) + .build() + .expect("canned request") + } + + fn canned_result(self) -> supergraph::ServiceResult { + supergraph::Response::fake_builder() + .context(self.context.clone()) + .data(canned_response_body()) + .build() + } + + fn assert_context_eq(&self, key: &str, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug, + { + let ctx_value = self + .context + .get::<_, T>(key) + .expect("context value not deserializable") + .expect("context value not found"); + pretty_assertions::assert_eq!(ctx_value, value, "context '{}' value mismatch", key); + } + + fn assert_context_contains(&self, key: &str) { + if !self.context.contains_key(key) { + panic!("context '{key}' value not found") + } + } + + fn assert_context_not_contains(&self, key: &str) { + if self.context.contains_key(key) { + panic!("context '{key}' value was present") + } + } + + fn assert_header_eq(&self, key: &str, value: &str) { + let header_value = self + .supergraph_request + .headers() + .get(key) + .unwrap_or_else(|| panic!("header '{key}' not found")); + pretty_assertions::assert_eq!(header_value, value, "header '{}' value mismatch", key); + } + + async fn assert_body_eq(&mut self, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug + Serialize, + { + pretty_assertions::assert_eq!( + serde_yaml::to_string(&self.supergraph_request.body_mut()) + .expect("could not serialize"), + serde_yaml::to_string(&value).expect("could not serialize") + ); + } + + async fn assert_canned_body(&mut self) { + self.assert_body_eq(canned_request_body()).await + } +} + +impl ResponseTestExt for SupergraphResponse { + fn assert_context_eq(&self, key: &str, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug, + { + let ctx_value = self + .context + .get::<_, T>(key) + .expect("context value not deserializable") + .expect("context value not found"); + pretty_assertions::assert_eq!(ctx_value, value, "context '{}' value mismatch", key); + } + + fn assert_context_contains(&self, key: &str) { + if !self.context.contains_key(key) { + panic!("context '{key}' value not found") + } + } + + fn assert_context_not_contains(&self, key: &str) { + if self.context.contains_key(key) { + panic!("context '{key}' value was present") + } + } + + fn assert_header_eq(&self, key: &str, value: &str) { + let header_value = self + .response + .headers() + .get(key) + .unwrap_or_else(|| panic!("header '{key}' not found")); + pretty_assertions::assert_eq!(header_value, value, "header '{}' value mismatch", key); + } + + async fn assert_body_eq(&mut self, value: T) + where + T: for<'de> Deserialize<'de> + Eq + PartialEq + Debug + Serialize, + { + let response_stream = self.response.body_mut(); + let responses: Vec<_> = response_stream.collect().await; + pretty_assertions::assert_eq!( + serde_yaml::to_string(&responses).expect("could not serialize"), + serde_yaml::to_string(&value).expect("could not serialize") + ); + } + + async fn assert_contains_error(&mut self, error: &Value) { + let responses: Vec = self.response.body_mut().collect::>().await; + let errors: Vec = responses.iter().fold(Vec::new(), |mut errors, r| { + errors.append( + &mut r + .errors + .iter() + .map(|e| serde_json::to_value(e).expect("could not serialize error")) + .collect::>(), + ); + errors + }); + if !errors.iter().contains(error) { + panic!( + "Expected error {}\nActual errors\n{}", + serde_yaml::to_string(error).expect("error"), + serde_yaml::to_string(&errors).expect("errors") + ) + } + } + + async fn assert_canned_body(&mut self) { + self.assert_body_eq(canned_response_body_array()).await + } + + fn assert_status_code(&self, status_code: ::http::StatusCode) { + pretty_assertions::assert_eq!( + self.response.status(), + status_code, + "http status code mismatch" + ); + } +} diff --git a/apollo-router/src/plugins/traffic_shaping/deduplication.rs b/apollo-router/src/plugins/traffic_shaping/deduplication.rs index 5c2165f775..235302b355 100644 --- a/apollo-router/src/plugins/traffic_shaping/deduplication.rs +++ b/apollo-router/src/plugins/traffic_shaping/deduplication.rs @@ -13,7 +13,6 @@ use tokio::sync::broadcast::{self}; use tokio::sync::oneshot; use tower::BoxError; use tower::Layer; -use tower::ServiceExt; use crate::batching::BatchQuery; use crate::graphql::Request; @@ -72,7 +71,7 @@ where } async fn dedup( - service: S, + mut service: S, wait_map: WaitMap, request: SubgraphRequest, ) -> Result { @@ -85,7 +84,7 @@ where .extensions() .with_lock(|lock| lock.contains_key::()) { - return service.ready_oneshot().await?.call(request).await; + return service.call(request).await; } loop { let mut locked_wait_map = wait_map.lock().await; @@ -98,7 +97,6 @@ where let mut receiver = waiter.subscribe(); drop(locked_wait_map); - let _guard = request.context.enter_active_request(); match receiver.recv().await { Ok(value) => { return value @@ -106,11 +104,11 @@ where SubgraphResponse::new_from_response( response.0.response, request.context, - request.subgraph_name.unwrap_or_default(), + request.subgraph_name, request.id, ) }) - .map_err(|e| e.into()) + .map_err(|e| e.into()); } // there was an issue with the broadcast channel, retry fetching Err(_) => continue, @@ -126,46 +124,45 @@ where let authorization_cache_key = request.authorization.clone(); let id = request.id.clone(); let cache_key = ((&request.subgraph_request).into(), authorization_cache_key); - let res = { + let (res, handle) = { // when _drop_signal is dropped, either by getting out of the block, returning // the error from ready_oneshot or by cancellation, the drop_sentinel future will // return with Err(), then we remove the entry from the wait map let (_drop_signal, drop_sentinel) = oneshot::channel::<()>(); - tokio::task::spawn(async move { + let handle = tokio::task::spawn(async move { let _ = drop_sentinel.await; let mut locked_wait_map = wait_map.lock().await; locked_wait_map.remove(&cache_key); }); - service - .ready_oneshot() - .await? - .call(request) - .await - .map(CloneSubgraphResponse) + ( + service.call(request).await.map(CloneSubgraphResponse), + handle, + ) }; - // Let our waiters know - - // Clippy is wrong, the suggestion adds a useless clone of the error - #[allow(clippy::useless_asref)] - let broadcast_value = res - .as_ref() - .map(|response| response.clone()) - .map_err(|e| e.to_string()); + // Make sure that our spawned task has completed. Ignore the result to preserve + // existing behaviour. + let _ = handle.await; + // At this point we have removed ourselves from the wait_map, so we won't get + // any more receivers. If we have any receivers, let them know + if tx.receiver_count() > 0 { + // Clippy is wrong, the suggestion adds a useless clone of the error + #[allow(clippy::useless_asref)] + let broadcast_value = res + .as_ref() + .map(|response| response.clone()) + .map_err(|e: &BoxError| e.to_string()); - // We may get errors here, for instance if a task is cancelled, - // so just ignore the result of send - let _ = tokio::task::spawn_blocking(move || { - tx.send(broadcast_value) - }).await - .expect("can only fail if the task is aborted or if the internal code panics, neither is possible here; qed"); + // Ignore the result of send, receivers may drop... + let _ = tx.send(broadcast_value); + } return res.map(|response| { SubgraphResponse::new_from_response( response.0.response, context, - response.0.subgraph_name.unwrap_or_default(), + response.0.subgraph_name, id, ) }); @@ -187,19 +184,121 @@ where type Error = BoxError; type Future = BoxFuture<'static, Result>; - fn poll_ready(&mut self, _cx: &mut std::task::Context<'_>) -> Poll> { - Poll::Ready(Ok(())) + fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll> { + self.service.poll_ready(cx) } fn call(&mut self, request: SubgraphRequest) -> Self::Future { let service = self.service.clone(); + let mut inner = std::mem::replace(&mut self.service, service); if request.operation_kind == OperationKind::Query { let wait_map = self.wait_map.clone(); - Box::pin(async move { Self::dedup(service, wait_map, request).await }) + Box::pin(async move { Self::dedup(inner, wait_map, request).await }) } else { - Box::pin(async move { service.oneshot(request).await }) + Box::pin(async move { inner.call(request).await }) } } } + +#[cfg(test)] +mod tests { + + use std::sync::Arc; + use std::sync::atomic::AtomicU8; + use std::sync::atomic::Ordering; + use std::time::Duration; + + use tower::Service; + use tower::ServiceExt; + + use super::QueryDeduplicationService; + use crate::plugin::test::MockSubgraphService; + use crate::services::SubgraphRequest; + use crate::services::SubgraphResponse; + + // Testing strategy: + // - We make our subgraph invocations slow (100ms) to increase our chance of a positive dedup + // result + // - We count how many times our inner service is invoked across all service invocations + // - We never know exactly which inner service is going to be invoked (since we are driving + // the service requests concurrently and in parallel), so we set times to 0..2 (== 0 or 1) + // for each expectation. + // - Every time an inner service is invoked we increment our shared counter. + // - If our shared counter == 1 at the end, then our test passes. + // + // Note: If this test starts to fail it may be because we need to increase the sleep time for + // each inner service above 100ms. + // + #[tokio::test(flavor = "multi_thread")] + async fn test_dedup_service() { + let mut mock = MockSubgraphService::new(); + + let inner_invocation_count = Arc::new(AtomicU8::new(0)); + let inner_invocation_count_1 = inner_invocation_count.clone(); + let inner_invocation_count_2 = inner_invocation_count.clone(); + let inner_invocation_count_3 = inner_invocation_count.clone(); + + mock.expect_clone().returning(move || { + let mut mock = MockSubgraphService::new(); + + let inner_invocation_count_1 = inner_invocation_count_1.clone(); + mock.expect_clone().returning(move || { + let mut mock = MockSubgraphService::new(); + let inner_invocation_count_1 = inner_invocation_count_1.clone(); + mock.expect_call() + .times(0..2) + .returning(move |req: SubgraphRequest| { + std::thread::sleep(Duration::from_millis(100)); + inner_invocation_count_1.fetch_add(1, Ordering::Relaxed); + Ok(SubgraphResponse::fake_builder() + .context(req.context) + .build()) + }); + mock + }); + let inner_invocation_count_2 = inner_invocation_count_2.clone(); + mock.expect_call() + .times(0..2) + .returning(move |req: SubgraphRequest| { + std::thread::sleep(Duration::from_millis(100)); + inner_invocation_count_2.fetch_add(1, Ordering::Relaxed); + Ok(SubgraphResponse::fake_builder() + .context(req.context) + .build()) + }); + mock + }); + mock.expect_call() + .times(0..2) + .returning(move |req: SubgraphRequest| { + std::thread::sleep(Duration::from_millis(100)); + inner_invocation_count_3.fetch_add(1, Ordering::Relaxed); + Ok(SubgraphResponse::fake_builder() + .context(req.context) + .build()) + }); + + let mut svc = QueryDeduplicationService::new(mock); + + let request = SubgraphRequest::fake_builder().build(); + + // Spawn our service invocations so they execute in parallel + let fut1 = tokio::spawn( + svc.ready() + .await + .expect("it is ready") + .call(request.clone()), + ); + let fut2 = tokio::spawn(svc.ready().await.expect("it is ready").call(request)); + let (res1, res2) = tokio::join!(fut1, fut2); + + // We don't care about our actual request/responses, we just want to make sure that + // deduplication occurs... + res1.expect("fut1 spawned").expect("fut1 joined"); + res2.expect("fut2 spawned").expect("fut2 joined"); + + assert_eq!(1, inner_invocation_count.load(Ordering::Relaxed)); + } +} diff --git a/apollo-router/src/plugins/traffic_shaping/mod.rs b/apollo-router/src/plugins/traffic_shaping/mod.rs index 4335bfe988..afc587bd5e 100644 --- a/apollo-router/src/plugins/traffic_shaping/mod.rs +++ b/apollo-router/src/plugins/traffic_shaping/mod.rs @@ -7,45 +7,43 @@ //! * Rate limiting //! mod deduplication; -pub(crate) mod rate; -mod retry; -pub(crate) mod timeout; use std::collections::HashMap; use std::num::NonZeroU64; -use std::sync::Mutex; use std::time::Duration; -use futures::future::BoxFuture; -use futures::FutureExt; -use http::header::CONTENT_ENCODING; +use apollo_federation::connectors::runtime::errors::Error; +use apollo_federation::connectors::runtime::http_json_transport::TransportRequest; use http::HeaderValue; use http::StatusCode; +use http::header::CONTENT_ENCODING; +use parking_lot::Mutex; use schemars::JsonSchema; use serde::Deserialize; -use tower::util::Either; use tower::BoxError; -use tower::Service; use tower::ServiceBuilder; use tower::ServiceExt; +use tower::limit::ConcurrencyLimitLayer; +use tower::limit::RateLimitLayer; +use tower::load_shed::error::Overloaded; +use tower::timeout::TimeoutLayer; +use tower::timeout::error::Elapsed; use self::deduplication::QueryDeduplicationLayer; -use self::rate::RateLimitLayer; -use self::rate::RateLimited; -pub(crate) use self::retry::RetryPolicy; -use self::timeout::Elapsed; -use self::timeout::TimeoutLayer; use crate::configuration::shared::DnsResolutionStrategy; -use crate::error::ConfigurationError; use crate::graphql; use crate::layers::ServiceBuilderExt; -use crate::plugin::Plugin; use crate::plugin::PluginInit; -use crate::register_plugin; +use crate::plugin::PluginPrivate; +use crate::services::RouterResponse; +use crate::services::SubgraphRequest; +use crate::services::SubgraphResponse; +use crate::services::connector; +use crate::services::connector::request_service::Request; +use crate::services::connector::request_service::Response; use crate::services::http::service::Compression; +use crate::services::router; use crate::services::subgraph; -use crate::services::supergraph; -use crate::services::SubgraphRequest; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); pub(crate) const APOLLO_TRAFFIC_SHAPING: &str = "apollo.traffic_shaping"; @@ -68,9 +66,6 @@ struct Shaping { #[schemars(with = "String", default)] /// Enable timeout for incoming requests timeout: Option, - /// Retry configuration - // *experimental feature*: Enables request retry - experimental_retry: Option, /// Enable HTTP2 for subgraphs experimental_http2: Option, /// DNS resolution strategy for subgraphs @@ -102,11 +97,6 @@ impl Merge for Shaping { .as_ref() .or(fallback.global_rate_limit.as_ref()) .cloned(), - experimental_retry: self - .experimental_retry - .as_ref() - .or(fallback.experimental_retry.as_ref()) - .cloned(), experimental_http2: self .experimental_http2 .as_ref() @@ -122,64 +112,84 @@ impl Merge for Shaping { } } -/// Retry configuration +// this is a wrapper struct to add subgraph specific options over Shaping #[derive(PartialEq, Debug, Clone, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -struct RetryConfig { - #[serde(deserialize_with = "humantime_serde::deserialize", default)] - #[schemars(with = "String", default)] - /// how long a single deposit should be considered. Must be between 1 and 60 seconds, - /// default value is 10 seconds - ttl: Option, - /// minimum rate of retries allowed to accomodate clients that have just started - /// issuing requests, or clients that do not issue many requests per window. The - /// default value is 10 - min_per_sec: Option, - /// percentage of calls to deposit that can be retried. This is in addition to any - /// retries allowed for via min_per_sec. Must be between 0 and 1000, default value - /// is 0.2 - retry_percent: Option, - /// allows request retries on mutations. This should only be activated if mutations - /// are idempotent. Disabled by default - retry_mutations: Option, +struct SubgraphShaping { + #[serde(flatten)] + shaping: Shaping, } -impl Merge for RetryConfig { +impl Merge for SubgraphShaping { fn merge(&self, fallback: Option<&Self>) -> Self { match fallback { None => self.clone(), - Some(fallback) => RetryConfig { - ttl: self.ttl.or(fallback.ttl), - min_per_sec: self.min_per_sec.or(fallback.min_per_sec), - retry_percent: self.retry_percent.or(fallback.retry_percent), - retry_mutations: self.retry_mutations.or(fallback.retry_mutations), + Some(fallback) => SubgraphShaping { + shaping: self.shaping.merge(Some(&fallback.shaping)), }, } } } -// this is a wrapper struct to add subgraph specific options over Shaping +#[derive(PartialEq, Debug, Clone, Deserialize, JsonSchema, Default)] +#[serde(deny_unknown_fields, default)] +struct ConnectorsShapingConfig { + /// Applied on all connectors + all: Option, + /// Applied on specific connector sources + sources: HashMap, +} + #[derive(PartialEq, Debug, Clone, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -struct SubgraphShaping { - #[serde(flatten)] - shaping: Shaping, +struct ConnectorShaping { + /// Enable compression for connectors (available compressions are deflate, br, gzip) + compression: Option, + /// Enable global rate limiting + global_rate_limit: Option, + #[serde(deserialize_with = "humantime_serde::deserialize", default)] + #[schemars(with = "String", default)] + /// Enable timeout for connectors requests + timeout: Option, + /// Enable HTTP2 for connectors + experimental_http2: Option, + /// DNS resolution strategy for connectors + dns_resolution_strategy: Option, } -impl Merge for SubgraphShaping { +impl Merge for ConnectorShaping { fn merge(&self, fallback: Option<&Self>) -> Self { match fallback { None => self.clone(), - Some(fallback) => SubgraphShaping { - shaping: self.shaping.merge(Some(&fallback.shaping)), + Some(fallback) => ConnectorShaping { + compression: self.compression.or(fallback.compression), + timeout: self.timeout.or(fallback.timeout), + global_rate_limit: self + .global_rate_limit + .as_ref() + .or(fallback.global_rate_limit.as_ref()) + .cloned(), + experimental_http2: self + .experimental_http2 + .as_ref() + .or(fallback.experimental_http2.as_ref()) + .cloned(), + dns_resolution_strategy: self + .dns_resolution_strategy + .as_ref() + .or(fallback.dns_resolution_strategy.as_ref()) + .cloned(), }, } } } -#[derive(PartialEq, Debug, Clone, Deserialize, JsonSchema)] +#[derive(PartialEq, Debug, Clone, Deserialize, JsonSchema, Default)] #[serde(deny_unknown_fields)] struct RouterShaping { + /// The global concurrency limit + concurrency_limit: Option, + /// Enable global rate limiting global_rate_limit: Option, #[serde(deserialize_with = "humantime_serde::deserialize", default)] @@ -200,6 +210,9 @@ pub(crate) struct Config { all: Option, /// Applied on specific subgraphs subgraphs: HashMap, + /// Applied on specific subgraphs + connector: ConnectorsShapingConfig, + /// DEPRECATED, now always enabled: Enable variable deduplication optimization when sending requests to subgraphs (https://github.com/apollographql/router/issues/87) deduplicate_variables: Option, } @@ -231,112 +244,50 @@ impl Merge for RateLimitConf { // Remove this once the configuration yml changes. pub(crate) struct TrafficShaping { config: Config, - rate_limit_router: Option, rate_limit_subgraphs: Mutex>, + rate_limit_sources: Mutex>, } #[async_trait::async_trait] -impl Plugin for TrafficShaping { +impl PluginPrivate for TrafficShaping { type Config = Config; async fn new(init: PluginInit) -> Result { - let rate_limit_router = init - .config - .router - .as_ref() - .and_then(|r| r.global_rate_limit.as_ref()) - .map(|router_rate_limit_conf| { - if router_rate_limit_conf.interval.as_millis() > u64::MAX as u128 { - Err(ConfigurationError::InvalidConfiguration { - message: "bad configuration for traffic_shaping plugin", - error: format!( - "cannot set an interval for the rate limit greater than {} ms", - u64::MAX - ), - }) - } else { - Ok(RateLimitLayer::new( - router_rate_limit_conf.capacity, - router_rate_limit_conf.interval, - )) - } - }) - .transpose()?; - - { - Ok(Self { - config: init.config, - rate_limit_router, - rate_limit_subgraphs: Mutex::new(HashMap::new()), - }) - } - } -} - -pub(crate) type TrafficShapingSubgraphFuture = Either< - Either< - BoxFuture<'static, Result>, - BoxFuture<'static, Result>, - >, - >::Future, ->; - -impl TrafficShaping { - fn merge_config( - all_config: Option<&T>, - subgraph_config: Option<&T>, - ) -> Option { - let merged_subgraph_config = subgraph_config.map(|c| c.merge(all_config)); - merged_subgraph_config.or_else(|| all_config.cloned()) + Ok(Self { + config: init.config, + rate_limit_subgraphs: Mutex::new(HashMap::new()), + rate_limit_sources: Mutex::new(HashMap::new()), + }) } - pub(crate) fn supergraph_service_internal( - &self, - service: S, - ) -> impl Service< - supergraph::Request, - Response = supergraph::Response, - Error = BoxError, - Future = BoxFuture<'static, Result>, - > + Clone - + Send - + Sync - + 'static - where - S: Service - + Clone - + Send - + Sync - + 'static, - >::Future: std::marker::Send, - { + fn router_service(&self, service: router::BoxService) -> router::BoxService { ServiceBuilder::new() .map_future_with_request_data( - |req: &supergraph::Request| req.context.clone(), + |req: &router::Request| req.context.clone(), move |ctx, future| { async { - let response: Result = future.await; + let response: Result = future.await; match response { - Err(error) if error.is::() => { - supergraph::Response::error_builder() + Ok(ok) => Ok(ok), + Err(err) if err.is::() => { + // TODO add metrics + let error = graphql::Error::builder() + .message("Your request has been timed out") + .extension_code("GATEWAY_TIMEOUT") + .build(); + Ok(RouterResponse::error_builder() .status_code(StatusCode::GATEWAY_TIMEOUT) - .error::(Elapsed::new().into()) + .error(error) .context(ctx) .build() + .expect("should build overloaded response")) } - Err(error) if error.is::() => { - supergraph::Response::error_builder() - .status_code(StatusCode::TOO_MANY_REQUESTS) - .error::(RateLimited::new().into()) - .context(ctx) - .build() - } - _ => response, + Err(err) => Err(err), } } - .boxed() }, ) + .load_shed() .layer(TimeoutLayer::new( self.config .router @@ -344,31 +295,75 @@ impl TrafficShaping { .and_then(|r| r.timeout) .unwrap_or(DEFAULT_TIMEOUT), )) - .option_layer(self.rate_limit_router.clone()) + .map_future_with_request_data( + |req: &router::Request| req.context.clone(), + move |ctx, future| { + async { + let response: Result = future.await; + match response { + Ok(ok) => Ok(ok), + Err(err) if err.is::() => { + // TODO add metrics + let error = graphql::Error::builder() + .message("Your request has been concurrency limited") + .extension_code("REQUEST_CONCURRENCY_LIMITED") + .build(); + Ok(RouterResponse::error_builder() + .status_code(StatusCode::SERVICE_UNAVAILABLE) + .error(error) + .context(ctx) + .build() + .expect("should build overloaded response")) + } + Err(err) => Err(err), + } + } + }, + ) + .load_shed() + .option_layer(self.config.router.as_ref().and_then(|router| { + router + .concurrency_limit + .as_ref() + .map(|limit| ConcurrencyLimitLayer::new(*limit)) + })) + .map_future_with_request_data( + |req: &router::Request| req.context.clone(), + move |ctx, future| { + async { + let response: Result = future.await; + match response { + Ok(ok) => Ok(ok), + Err(err) if err.is::() => { + // TODO add metrics + let error = graphql::Error::builder() + .message("Your request has been rate limited") + .extension_code("REQUEST_RATE_LIMITED") + .build(); + Ok(RouterResponse::error_builder() + .status_code(StatusCode::SERVICE_UNAVAILABLE) + .error(error) + .context(ctx) + .build() + .expect("should build overloaded response")) + } + Err(err) => Err(err), + } + } + }, + ) + .load_shed() + .option_layer(self.config.router.as_ref().and_then(|router| { + router + .global_rate_limit + .as_ref() + .map(|limit| RateLimitLayer::new(limit.capacity.into(), limit.interval)) + })) .service(service) + .boxed() } - pub(crate) fn subgraph_service_internal( - &self, - name: &str, - service: S, - ) -> impl Service< - subgraph::Request, - Response = subgraph::Response, - Error = BoxError, - Future = TrafficShapingSubgraphFuture, - > + Clone - + Send - + Sync - + 'static - where - S: Service - + Clone - + Send - + Sync - + 'static, - >::Future: std::marker::Send, - { + fn subgraph_service(&self, name: &str, service: subgraph::BoxService) -> subgraph::BoxService { // Either we have the subgraph config and we merge it with the all config, or we just have the all config or we have nothing. let all_config = self.config.all.as_ref(); let subgraph_config = self.config.subgraphs.get(name); @@ -382,75 +377,164 @@ impl TrafficShaping { .map(|rate_limit_conf| { self.rate_limit_subgraphs .lock() - .unwrap() .entry(name.to_string()) .or_insert_with(|| { - RateLimitLayer::new(rate_limit_conf.capacity, rate_limit_conf.interval) + RateLimitLayer::new( + rate_limit_conf.capacity.into(), + rate_limit_conf.interval, + ) }) .clone() }); - let retry = config.shaping.experimental_retry.as_ref().map(|config| { - let retry_policy = RetryPolicy::new( - config.ttl, - config.min_per_sec, - config.retry_percent, - config.retry_mutations, - name.to_string(), - ); - tower::retry::RetryLayer::new(retry_policy) - }); - - Either::A(ServiceBuilder::new() - - .option_layer(config.shaping.deduplicate_query.unwrap_or_default().then( - QueryDeduplicationLayer::default - )) - .map_future_with_request_data( - |req: &subgraph::Request| req.context.clone(), - move |ctx, future| { - async { - let response: Result = future.await; - match response { - Err(error) if error.is::() => { - subgraph::Response::error_builder() - .status_code(StatusCode::GATEWAY_TIMEOUT) - .error::(Elapsed::new().into()) - .context(ctx) - .build() - } - Err(error) if error.is::() => { - subgraph::Response::error_builder() - .status_code(StatusCode::TOO_MANY_REQUESTS) - .error::(RateLimited::new().into()) - .context(ctx) - .build() - } - _ => response, + ServiceBuilder::new() + .map_future_with_request_data( + |req: &subgraph::Request| (req.context.clone(), req.subgraph_name.clone()), + move |(ctx, subgraph_name), future| { + async { + let response: Result = future.await; + match response { + Ok(ok) => Ok(ok), + Err(err) if err.is::() => { + // TODO add metrics + let error = graphql::Error::builder() + .message("Your request has been timed out") + .extension_code("GATEWAY_TIMEOUT") + .build(); + Ok(SubgraphResponse::error_builder() + .status_code(StatusCode::GATEWAY_TIMEOUT) + .subgraph_name(subgraph_name) + .error(error) + .context(ctx) + .build()) } - }.boxed() - }, - ) - .layer(TimeoutLayer::new( - config.shaping - .timeout - .unwrap_or(DEFAULT_TIMEOUT), - )) - .option_layer(retry) - .option_layer(rate_limit) - .service(service) + Err(err) if err.is::() => { + // TODO add metrics + let error = graphql::Error::builder() + .message("Your request has been rate limited") + .extension_code("REQUEST_RATE_LIMITED") + .build(); + Ok(SubgraphResponse::error_builder() + .status_code(StatusCode::SERVICE_UNAVAILABLE) + .subgraph_name(subgraph_name) + .error(error) + .context(ctx) + .build()) + } + Err(err) => Err(err), + } + } + }, + ) + .load_shed() + .layer(TimeoutLayer::new( + config.shaping.timeout.unwrap_or(DEFAULT_TIMEOUT), + )) + .option_layer(rate_limit) + .option_layer( + config + .shaping + .deduplicate_query + .unwrap_or_default() + .then(QueryDeduplicationLayer::default), + ) .map_request(move |mut req: SubgraphRequest| { if let Some(compression) = config.shaping.compression { let compression_header_val = HeaderValue::from_str(&compression.to_string()).expect("compression is manually implemented and already have the right values; qed"); req.subgraph_request.headers_mut().insert(CONTENT_ENCODING, compression_header_val); } + req + }) + .buffered() + .service(service) + .boxed() + } else { + service + } + } + + fn connector_request_service( + &self, + service: crate::services::connector::request_service::BoxService, + source_name: String, + ) -> crate::services::connector::request_service::BoxService { + let all_config = self.config.connector.all.as_ref(); + let source_config = self.config.connector.sources.get(&source_name).cloned(); + let final_config = Self::merge_config(all_config, source_config.as_ref()); + + if let Some(config) = final_config { + let rate_limit = config.global_rate_limit.as_ref().map(|rate_limit_conf| { + self.rate_limit_sources + .lock() + .entry(source_name.clone()) + .or_insert_with(|| { + RateLimitLayer::new( + rate_limit_conf.capacity.into(), + rate_limit_conf.interval, + ) + }) + .clone() + }); + ServiceBuilder::new() + .map_future_with_request_data( + |req: &Request| req.key.clone(), + move |response_key, future| { + async { + let response: Result = future.await; + match response { + Ok(ok) => Ok(ok), + Err(err) if err.is::() => { + let response = Response::error_new( + Error::GatewayTimeout, + "Your request has been timed out", + response_key, + ); + Ok(response) + } + Err(err) if err.is::() => { + let response = Response::error_new( + Error::RateLimited, + "Your request has been rate limited", + response_key, + ); + Ok(response) + } + Err(err) => Err(err), + } + } + }, + ) + .load_shed() + .layer(TimeoutLayer::new( + config.timeout.unwrap_or(DEFAULT_TIMEOUT), + )) + .option_layer(rate_limit) + .map_request(move |mut req: connector::request_service::Request| { + if let Some(compression) = config.compression { + let TransportRequest::Http(ref mut http_request) = req.transport_request; + let compression_header_val = HeaderValue::from_str(&compression.to_string()).expect("compression is manually implemented and already have the right values; qed"); + http_request.inner.headers_mut().insert(CONTENT_ENCODING, compression_header_val); + } req - })) + }) + .buffered() + .service(service) + .boxed() } else { - Either::B(service) + service } } +} + +impl TrafficShaping { + fn merge_config( + all_config: Option<&T>, + subgraph_config: Option<&T>, + ) -> Option { + let merged_subgraph_config = subgraph_config.map(|c| c.merge(all_config)); + merged_subgraph_config.or_else(|| all_config.cloned()) + } pub(crate) fn subgraph_client_config( &self, @@ -466,40 +550,66 @@ impl TrafficShaping { }) .unwrap_or_default() } + + pub(crate) fn connector_client_config( + &self, + source_name: &str, + ) -> crate::configuration::shared::Client { + let source_config = self.config.connector.sources.get(source_name).cloned(); + Self::merge_config(self.config.connector.all.as_ref(), source_config.as_ref()) + .map(|config| crate::configuration::shared::Client { + experimental_http2: config.experimental_http2, + dns_resolution_strategy: config.dns_resolution_strategy, + }) + .unwrap_or_default() + } } -register_plugin!("apollo", "traffic_shaping", TrafficShaping); +register_private_plugin!("apollo", "traffic_shaping", TrafficShaping); #[cfg(test)] mod test { - use std::num::NonZeroUsize; use std::sync::Arc; + use apollo_compiler::name; + use apollo_federation::connectors::ConnectId; + use apollo_federation::connectors::ConnectSpec; + use apollo_federation::connectors::Connector; + use apollo_federation::connectors::HttpJsonTransport; + use apollo_federation::connectors::JSONSelection; + use apollo_federation::connectors::SourceName; + use apollo_federation::connectors::runtime::http_json_transport::HttpRequest; + use apollo_federation::connectors::runtime::key::ResponseKey; use bytes::Bytes; + use http::HeaderMap; use maplit::hashmap; use once_cell::sync::Lazy; - use serde_json_bytes::json; use serde_json_bytes::ByteString; use serde_json_bytes::Value; + use serde_json_bytes::json; use tower::Service; use super::*; + use crate::Configuration; + use crate::Context; use crate::json_ext::Object; - use crate::plugin::test::MockSubgraph; - use crate::plugin::test::MockSupergraphService; use crate::plugin::DynPlugin; - use crate::query_planner::BridgeQueryPlannerPool; + use crate::plugin::test::MockConnector; + use crate::plugin::test::MockRouterService; + use crate::plugin::test::MockSubgraph; + use crate::query_planner::QueryPlannerService; use crate::router_factory::create_plugins; + use crate::services::HasSchema; + use crate::services::PluggableSupergraphServiceBuilder; + use crate::services::RouterRequest; + use crate::services::RouterResponse; + use crate::services::SupergraphRequest; + use crate::services::connector::request_service::Request as ConnectorRequest; use crate::services::layers::persisted_queries::PersistedQueryLayer; use crate::services::layers::query_analysis::QueryAnalysisLayer; use crate::services::router; use crate::services::router::service::RouterCreator; - use crate::services::HasSchema; - use crate::services::PluggableSupergraphServiceBuilder; - use crate::services::SupergraphRequest; - use crate::services::SupergraphResponse; use crate::spec::Schema; - use crate::Configuration; static EXPECTED_RESPONSE: Lazy = Lazy::new(|| { Bytes::from_static(r#"{"data":{"topProducts":[{"upc":"1","name":"Table","reviews":[{"id":"1","product":{"name":"Table"},"author":{"id":"1","name":"Ada Lovelace"}},{"id":"4","product":{"name":"Table"},"author":{"id":"2","name":"Alan Turing"}}]},{"upc":"2","name":"Couch","reviews":[{"id":"2","product":{"name":"Couch"},"author":{"id":"1","name":"Ada Lovelace"}}]}]}}"#.as_bytes()) @@ -587,15 +697,16 @@ mod test { let config = Arc::new(config); let schema = Arc::new(Schema::parse(schema, &config).unwrap()); - let planner = BridgeQueryPlannerPool::new( - Vec::new(), - schema.clone(), - config.clone(), - NonZeroUsize::new(1).unwrap(), - ) - .await - .unwrap(); - let subgraph_schemas = planner.subgraph_schemas(); + let planner = QueryPlannerService::new(schema.clone(), config.clone()) + .await + .unwrap(); + let subgraph_schemas = Arc::new( + planner + .subgraph_schemas() + .iter() + .map(|(k, v)| (k.clone(), v.schema.clone())) + .collect(), + ); let mut builder = PluggableSupergraphServiceBuilder::new(planner).with_configuration(config.clone()); @@ -607,6 +718,7 @@ mod test { subgraph_schemas, None, Some(vec![(APOLLO_TRAFFIC_SHAPING.to_string(), plugin)]), + Default::default(), ) .await .expect("create plugins should work"), @@ -642,6 +754,68 @@ mod test { .expect("Plugin not created") } + fn get_fake_connector_request( + headers: Option>, + data: String, + ) -> ConnectorRequest { + let context = Context::default(); + let connector = Arc::new(Connector { + spec: ConnectSpec::V0_1, + id: ConnectId::new( + "test_subgraph".into(), + Some(SourceName::cast("test_sourcename")), + name!(Query), + name!(hello), + None, + 0, + ), + transport: HttpJsonTransport { + source_template: "http://localhost/api".parse().ok(), + connect_template: "/path".parse().unwrap(), + ..Default::default() + }, + selection: JSONSelection::parse("$.data").unwrap(), + entity_resolver: None, + config: Default::default(), + max_requests: None, + batch_settings: None, + request_headers: Default::default(), + response_headers: Default::default(), + request_variable_keys: Default::default(), + response_variable_keys: Default::default(), + error_settings: Default::default(), + label: "test label".into(), + }); + let key = ResponseKey::RootField { + name: "hello".to_string(), + inputs: Default::default(), + selection: Arc::new(JSONSelection::parse("$.data").unwrap()), + }; + let mapping_problems = Default::default(); + + let mut request_builder = http::Request::builder(); + if let Some(headers) = headers { + for (header_name, header_value) in headers.iter() { + request_builder = request_builder.header(header_name, header_value); + } + } + let request = request_builder.body(data).unwrap(); + + let http_request = HttpRequest { + inner: request, + debug: Default::default(), + }; + + ConnectorRequest { + context, + connector, + transport_request: http_request.into(), + key, + mapping_problems, + supergraph_request: Default::default(), + } + } + #[tokio::test] async fn it_returns_valid_response_for_deduplicated_variables() { let config = serde_yaml::from_str::( @@ -683,10 +857,44 @@ mod test { }); let _response = plugin - .as_any() - .downcast_ref::() - .unwrap() - .subgraph_service_internal("test", test_service) + .subgraph_service("test", test_service.boxed()) + .oneshot(request) + .await + .unwrap(); + } + + #[tokio::test] + async fn it_adds_correct_headers_for_compression_for_connector() { + let config = serde_yaml::from_str::( + r#" + connector: + sources: + test_subgraph.test_sourcename: + compression: gzip + "#, + ) + .unwrap(); + + let plugin = get_traffic_shaping_plugin(&config).await; + let request = get_fake_connector_request(None, "testing".to_string()); + + let test_service = + MockConnector::new(HashMap::new()).map_request(|req: ConnectorRequest| { + let TransportRequest::Http(ref http_request) = req.transport_request; + + assert_eq!( + http_request.inner.headers().get(&CONTENT_ENCODING).unwrap(), + HeaderValue::from_static("gzip") + ); + + req + }); + + let _response = plugin + .connector_request_service( + test_service.boxed(), + "test_subgraph.test_sourcename".to_string(), + ) .oneshot(request) .await .unwrap(); @@ -698,7 +906,7 @@ mod test { r#" all: deduplicate_query: true - subgraphs: + subgraphs: products: deduplicate_query: false "#, @@ -728,7 +936,7 @@ mod test { r#" all: experimental_http2: disable - subgraphs: + subgraphs: products: experimental_http2: enable reviews: @@ -772,7 +980,7 @@ mod test { all: experimental_http2: disable dns_resolution_strategy: ipv6_only - subgraphs: + subgraphs: products: experimental_http2: enable dns_resolution_strategy: ipv6_then_ipv4 @@ -832,60 +1040,112 @@ mod test { graphql::Request::default() => graphql::Response::default() }); - assert!(&plugin - .as_any() - .downcast_ref::() - .unwrap() - .subgraph_service_internal("test", test_service.clone()) - .oneshot(SubgraphRequest::fake_builder().build()) - .await - .unwrap() - .response - .body() - .errors - .is_empty()); - assert_eq!( - plugin - .as_any() - .downcast_ref::() - .unwrap() - .subgraph_service_internal("test", test_service.clone()) - .oneshot(SubgraphRequest::fake_builder().build()) + let mut svc = plugin.subgraph_service("test", test_service.boxed()); + + assert!( + svc.ready() + .await + .expect("it is ready") + .call(SubgraphRequest::fake_builder().build()) .await .unwrap() .response .body() - .errors[0] - .extensions - .get("code") - .unwrap(), - "REQUEST_RATE_LIMITED" + .errors + .is_empty() ); - assert!(plugin - .as_any() - .downcast_ref::() - .unwrap() - .subgraph_service_internal("another", test_service.clone()) - .oneshot(SubgraphRequest::fake_builder().build()) + let response = svc + .ready() .await - .unwrap() - .response - .body() - .errors - .is_empty()); + .expect("it is ready") + .call(SubgraphRequest::fake_builder().build()) + .await + .expect("it responded"); + + assert_eq!(StatusCode::SERVICE_UNAVAILABLE, response.response.status()); + tokio::time::sleep(Duration::from_millis(300)).await; - assert!(plugin - .as_any() - .downcast_ref::() - .unwrap() - .subgraph_service_internal("test", test_service.clone()) - .oneshot(SubgraphRequest::fake_builder().build()) + + assert!( + svc.ready() + .await + .expect("it is ready") + .call(SubgraphRequest::fake_builder().build()) + .await + .unwrap() + .response + .body() + .errors + .is_empty() + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn it_rate_limit_connector_requests() { + let config = serde_yaml::from_str::( + r#" + connector: + sources: + test_subgraph.test_sourcename: + global_rate_limit: + capacity: 1 + interval: 100ms + timeout: 500ms + "#, + ) + .unwrap(); + + let plugin = get_traffic_shaping_plugin(&config).await; + let request = get_fake_connector_request(None, "testing".to_string()); + + let test_service = MockConnector::new(hashmap! { + "test_request".into() => "test_request".into() + }); + + let mut svc = plugin.connector_request_service( + test_service.boxed(), + "test_subgraph.test_sourcename".to_string(), + ); + + assert!( + svc.ready() + .await + .expect("it is ready") + .call(request) + .await + .unwrap() + .transport_result + .is_ok() + ); + + let request = get_fake_connector_request(None, "testing".to_string()); + let response = svc + .ready() .await - .unwrap() - .response - .body() - .errors - .is_empty()); + .expect("it is ready") + .call(request) + .await + .expect("it responded"); + + assert!(response.transport_result.is_err()); + assert!(matches!( + response.transport_result.err().unwrap(), + Error::RateLimited + )); + + tokio::time::sleep(Duration::from_millis(300)).await; + + let request = get_fake_connector_request(None, "testing".to_string()); + assert!( + svc.ready() + .await + .expect("it is ready") + .call(request) + .await + .unwrap() + .transport_result + .is_ok() + ); } #[tokio::test(flavor = "multi_thread")] @@ -902,68 +1162,98 @@ mod test { .unwrap(); let plugin = get_traffic_shaping_plugin(&config).await; - let mut mock_service = MockSupergraphService::new(); - mock_service.expect_clone().returning(|| { - let mut mock_service = MockSupergraphService::new(); - - mock_service.expect_clone().returning(|| { - let mut mock_service = MockSupergraphService::new(); - mock_service.expect_call().times(0..2).returning(move |_| { - Ok(SupergraphResponse::fake_builder() - .data(json!({ "test": 1234_u32 })) - .build() - .unwrap()) - }); - mock_service - }); - mock_service + let mut mock_service = MockRouterService::new(); + + mock_service.expect_call().times(0..3).returning(|_| { + Ok(RouterResponse::fake_builder() + .data(json!({ "test": 1234_u32 })) + .build() + .unwrap()) }); + mock_service + .expect_clone() + .returning(MockRouterService::new); - assert!(plugin - .as_any() - .downcast_ref::() - .unwrap() - .supergraph_service_internal(mock_service.clone()) - .oneshot(SupergraphRequest::fake_builder().build().unwrap()) + // let mut svc = plugin.router_service(mock_service.clone().boxed()); + let mut svc = plugin.router_service(mock_service.boxed()); + + let response: RouterResponse = svc + .ready() .await - .unwrap() - .next_response() + .expect("it is ready") + .call(RouterRequest::fake_builder().build().unwrap()) .await - .unwrap() - .errors - .is_empty()); + .unwrap(); + assert_eq!(StatusCode::OK, response.response.status()); - assert_eq!( - plugin - .as_any() - .downcast_ref::() - .unwrap() - .supergraph_service_internal(mock_service.clone()) - .oneshot(SupergraphRequest::fake_builder().build().unwrap()) - .await - .unwrap() - .next_response() + let response: RouterResponse = svc + .ready() + .await + .expect("it is ready") + .call(RouterRequest::fake_builder().build().unwrap()) + .await + .unwrap(); + assert_eq!(StatusCode::SERVICE_UNAVAILABLE, response.response.status()); + let j: serde_json::Value = serde_json::from_slice( + &router::body::into_bytes(response.response) .await - .unwrap() - .errors[0] - .extensions - .get("code") - .unwrap(), - "REQUEST_RATE_LIMITED" + .expect("we have a body"), + ) + .expect("our body is valid json"); + assert_eq!( + "Your request has been rate limited", + j["errors"][0]["message"] ); + tokio::time::sleep(Duration::from_millis(300)).await; - assert!(plugin - .as_any() - .downcast_ref::() - .unwrap() - .supergraph_service_internal(mock_service.clone()) - .oneshot(SupergraphRequest::fake_builder().build().unwrap()) + + let response: RouterResponse = svc + .ready() .await - .unwrap() - .next_response() + .expect("it is ready") + .call(RouterRequest::fake_builder().build().unwrap()) .await - .unwrap() - .errors - .is_empty()); + .unwrap(); + assert_eq!(StatusCode::OK, response.response.status()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn it_timeout_router_requests() { + let config = serde_yaml::from_str::( + r#" + router: + timeout: 1ns + "#, + ) + .unwrap(); + + let plugin = get_traffic_shaping_plugin(&config).await; + + let svc = ServiceBuilder::new() + .service_fn(move |_req: router::Request| async { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + RouterResponse::fake_builder() + .data(json!({ "test": 1234_u32 })) + .build() + }) + .boxed(); + + let mut rs = plugin.router_service(svc); + + let response: RouterResponse = rs + .ready() + .await + .expect("it is ready") + .call(RouterRequest::fake_builder().build().unwrap()) + .await + .unwrap(); + assert_eq!(StatusCode::GATEWAY_TIMEOUT, response.response.status()); + let j: serde_json::Value = serde_json::from_slice( + &crate::services::router::body::into_bytes(response.response) + .await + .expect("we have a body"), + ) + .expect("our body is valid json"); + assert_eq!("Your request has been timed out", j["errors"][0]["message"]); } } diff --git a/apollo-router/src/plugins/traffic_shaping/rate/error.rs b/apollo-router/src/plugins/traffic_shaping/rate/error.rs deleted file mode 100644 index d1c7ef09e3..0000000000 --- a/apollo-router/src/plugins/traffic_shaping/rate/error.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Error types - -use std::error; -use std::fmt; - -use crate::graphql; - -/// The rate limit error. -#[derive(Debug, Default)] -pub(crate) struct RateLimited; - -impl RateLimited { - /// Construct a new RateLimited error - pub(crate) fn new() -> Self { - RateLimited {} - } -} - -impl fmt::Display for RateLimited { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.pad("your request has been rate limited") - } -} - -impl From for graphql::Error { - fn from(_: RateLimited) -> Self { - graphql::Error::builder() - .message(String::from("Your request has been rate limited")) - .extension_code("REQUEST_RATE_LIMITED") - .build() - } -} - -impl error::Error for RateLimited {} diff --git a/apollo-router/src/plugins/traffic_shaping/rate/future.rs b/apollo-router/src/plugins/traffic_shaping/rate/future.rs deleted file mode 100644 index cb3ed8eb1e..0000000000 --- a/apollo-router/src/plugins/traffic_shaping/rate/future.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Future types - -use std::future::Future; -use std::pin::Pin; -use std::task::Context; -use std::task::Poll; - -use pin_project_lite::pin_project; - -pin_project! { - #[derive(Debug)] - pub(crate) struct ResponseFuture { - #[pin] - response: T, - } -} - -impl ResponseFuture { - pub(crate) fn new(response: T) -> Self { - ResponseFuture { response } - } -} - -impl Future for ResponseFuture -where - F: Future>, - E: Into, -{ - type Output = Result; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.project(); - - match this.response.poll(cx) { - Poll::Ready(v) => Poll::Ready(v.map_err(Into::into)), - Poll::Pending => Poll::Pending, - } - } -} diff --git a/apollo-router/src/plugins/traffic_shaping/rate/layer.rs b/apollo-router/src/plugins/traffic_shaping/rate/layer.rs deleted file mode 100644 index 107b394a70..0000000000 --- a/apollo-router/src/plugins/traffic_shaping/rate/layer.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::num::NonZeroU64; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::AtomicUsize; -use std::sync::Arc; -use std::time::Duration; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; - -use tower::Layer; - -use super::Rate; -use super::RateLimit; -/// Enforces a rate limit on the number of requests the underlying -/// service can handle over a period of time. -#[derive(Debug, Clone)] -pub(crate) struct RateLimitLayer { - rate: Rate, - window_start: Arc, - previous_nb_requests: Arc, - current_nb_requests: Arc, -} - -impl RateLimitLayer { - /// Create new rate limit layer. - pub(crate) fn new(num: NonZeroU64, per: Duration) -> Self { - let rate = Rate::new(num, per); - RateLimitLayer { - rate, - window_start: Arc::new(AtomicU64::new( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time must be after EPOCH") - .as_millis() as u64, - )), - previous_nb_requests: Arc::default(), - current_nb_requests: Arc::new(AtomicUsize::new(1)), - } - } -} - -impl Layer for RateLimitLayer { - type Service = RateLimit; - - fn layer(&self, service: S) -> Self::Service { - RateLimit { - inner: service, - rate: self.rate, - window_start: self.window_start.clone(), - previous_nb_requests: self.previous_nb_requests.clone(), - current_nb_requests: self.current_nb_requests.clone(), - } - } -} diff --git a/apollo-router/src/plugins/traffic_shaping/rate/mod.rs b/apollo-router/src/plugins/traffic_shaping/rate/mod.rs deleted file mode 100644 index 53c16998d0..0000000000 --- a/apollo-router/src/plugins/traffic_shaping/rate/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Limit the rate at which requests are processed. - -mod error; -pub(crate) mod future; -mod layer; -#[allow(clippy::module_inception)] -mod rate; -pub(crate) mod service; - -pub(crate) use self::error::RateLimited; -pub(crate) use self::layer::RateLimitLayer; -pub(crate) use self::rate::Rate; -pub(crate) use self::service::RateLimit; diff --git a/apollo-router/src/plugins/traffic_shaping/rate/rate.rs b/apollo-router/src/plugins/traffic_shaping/rate/rate.rs deleted file mode 100644 index eb73f74f10..0000000000 --- a/apollo-router/src/plugins/traffic_shaping/rate/rate.rs +++ /dev/null @@ -1,33 +0,0 @@ -use std::num::NonZeroU64; -use std::time::Duration; - -/// A rate of requests per time period. -#[derive(Debug, Copy, Clone)] -pub(crate) struct Rate { - num: u64, - per: Duration, -} - -impl Rate { - /// Create a new rate. - /// - /// # Panics - /// - /// This function panics if `num` or `per` is 0. - pub(crate) fn new(num: NonZeroU64, per: Duration) -> Self { - assert!(per > Duration::default()); - - Rate { - num: num.into(), - per, - } - } - - pub(crate) fn num(&self) -> u64 { - self.num - } - - pub(crate) fn per(&self) -> Duration { - self.per - } -} diff --git a/apollo-router/src/plugins/traffic_shaping/rate/service.rs b/apollo-router/src/plugins/traffic_shaping/rate/service.rs deleted file mode 100644 index f826723b0a..0000000000 --- a/apollo-router/src/plugins/traffic_shaping/rate/service.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::sync::atomic::AtomicU64; -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::Ordering; -use std::sync::Arc; -use std::task::Context; -use std::task::Poll; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; - -use futures::ready; -use tower::Service; - -use super::future::ResponseFuture; -use super::Rate; -use crate::plugins::traffic_shaping::rate::error::RateLimited; - -#[derive(Debug, Clone)] -pub(crate) struct RateLimit { - pub(crate) inner: T, - pub(crate) rate: Rate, - /// We're using an atomic u64 because it's basically a timestamp in milliseconds for the start of the window - /// Instead of using an Instant which is not thread safe we're using an atomic u64 - /// It's ok to have an u64 because we just care about milliseconds for this use case - pub(crate) window_start: Arc, - pub(crate) previous_nb_requests: Arc, - pub(crate) current_nb_requests: Arc, -} - -impl Service for RateLimit -where - S: Service, - S::Error: Into, -{ - type Response = S::Response; - type Error = tower::BoxError; - type Future = ResponseFuture; - - fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - let time_unit = self.rate.per().as_millis() as u64; - - let updated = - self.window_start - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |window_start| { - let duration_now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time must be after EPOCH") - .as_millis() as u64; - if duration_now - window_start > self.rate.per().as_millis() as u64 { - Some(duration_now) - } else { - None - } - }); - // If it has been updated - if let Ok(_updated_window_start) = updated { - self.previous_nb_requests.swap( - self.current_nb_requests.load(Ordering::SeqCst), - Ordering::SeqCst, - ); - self.current_nb_requests.swap(1, Ordering::SeqCst); - } - - let estimated_cap = (self.previous_nb_requests.load(Ordering::SeqCst) - * (time_unit - .checked_sub(self.window_start.load(Ordering::SeqCst)) - .unwrap_or_default() - / time_unit) as usize) - + self.current_nb_requests.load(Ordering::SeqCst); - - if estimated_cap as u64 > self.rate.num() { - tracing::trace!("rate limit exceeded; sleeping."); - return Poll::Ready(Err(RateLimited::new().into())); - } - - self.current_nb_requests.fetch_add(1, Ordering::SeqCst); - - Poll::Ready(ready!(self.inner.poll_ready(cx)).map_err(Into::into)) - } - - fn call(&mut self, request: Request) -> Self::Future { - ResponseFuture::new(self.inner.call(request)) - } -} diff --git a/apollo-router/src/plugins/traffic_shaping/retry.rs b/apollo-router/src/plugins/traffic_shaping/retry.rs deleted file mode 100644 index 40727cc6dd..0000000000 --- a/apollo-router/src/plugins/traffic_shaping/retry.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::future; -use std::sync::Arc; -use std::time::Duration; - -use tower::retry::budget::Budget; -use tower::retry::Policy; - -use crate::query_planner::OperationKind; -use crate::services::subgraph; - -#[derive(Clone, Default)] -pub(crate) struct RetryPolicy { - budget: Arc, - retry_mutations: bool, - subgraph_name: String, -} - -impl RetryPolicy { - pub(crate) fn new( - duration: Option, - min_per_sec: Option, - retry_percent: Option, - retry_mutations: Option, - subgraph_name: String, - ) -> Self { - Self { - budget: Arc::new(Budget::new( - duration.unwrap_or_else(|| Duration::from_secs(10)), - min_per_sec.unwrap_or(10), - retry_percent.unwrap_or(0.2), - )), - retry_mutations: retry_mutations.unwrap_or(false), - subgraph_name, - } - } -} - -impl Policy for RetryPolicy { - type Future = future::Ready; - - fn retry(&self, req: &subgraph::Request, result: Result<&Res, &E>) -> Option { - match result { - Ok(_) => { - // Treat all `Response`s as success, - // so deposit budget and don't retry... - self.budget.deposit(); - None - } - Err(_e) => { - if req.operation_kind == OperationKind::Mutation && !self.retry_mutations { - return None; - } - - let withdrew = self.budget.withdraw(); - if withdrew.is_err() { - tracing::info!( - monotonic_counter.apollo_router_http_request_retry_total = 1u64, - status = "aborted", - subgraph = %self.subgraph_name, - ); - - return None; - } - - tracing::info!( - monotonic_counter.apollo_router_http_request_retry_total = 1u64, - subgraph = %self.subgraph_name, - ); - - Some(future::ready(self.clone())) - } - } - } - - fn clone_request(&self, req: &subgraph::Request) -> Option { - Some(req.clone()) - } -} diff --git a/apollo-router/src/plugins/traffic_shaping/timeout/error.rs b/apollo-router/src/plugins/traffic_shaping/timeout/error.rs deleted file mode 100644 index 38e36dc8ad..0000000000 --- a/apollo-router/src/plugins/traffic_shaping/timeout/error.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Error types - -use std::error; -use std::fmt; - -use crate::graphql; - -/// The timeout elapsed. -#[derive(Debug, Default)] -pub(crate) struct Elapsed; - -impl Elapsed { - /// Construct a new elapsed error - pub(crate) fn new() -> Self { - Elapsed {} - } -} - -impl fmt::Display for Elapsed { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.pad("request timed out") - } -} - -impl From for graphql::Error { - fn from(_: Elapsed) -> Self { - graphql::Error::builder() - .message(String::from("Request timed out")) - .extension_code("REQUEST_TIMEOUT") - .build() - } -} - -impl error::Error for Elapsed {} diff --git a/apollo-router/src/plugins/traffic_shaping/timeout/future.rs b/apollo-router/src/plugins/traffic_shaping/timeout/future.rs deleted file mode 100644 index 8a390b393e..0000000000 --- a/apollo-router/src/plugins/traffic_shaping/timeout/future.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Future types - -use std::future::Future; -use std::pin::Pin; -use std::task::Context; -use std::task::Poll; - -use pin_project_lite::pin_project; -use tokio::time::Sleep; - -use super::error::Elapsed; - -pin_project! { - /// [`Timeout`] response future - /// - /// [`Timeout`]: crate::timeout::Timeout - #[derive(Debug)] - pub(crate) struct ResponseFuture { - #[pin] - response: T, - #[pin] - sleep: Pin>, - } -} - -impl ResponseFuture { - pub(crate) fn new(response: T, sleep: Pin>) -> Self { - ResponseFuture { response, sleep } - } -} - -impl Future for ResponseFuture -where - F: Future>, - E: Into, -{ - type Output = Result; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let mut this = self.project(); - - // First, try polling the future - match this.response.poll(cx) { - Poll::Ready(v) => return Poll::Ready(v.map_err(Into::into)), - Poll::Pending => {} - } - - // Now check the sleep - match Pin::new(&mut this.sleep).poll(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(_) => { - tracing::info!(monotonic_counter.apollo_router_timeout = 1u64,); - Poll::Ready(Err(Elapsed::new().into())) - } - } - } -} diff --git a/apollo-router/src/plugins/traffic_shaping/timeout/layer.rs b/apollo-router/src/plugins/traffic_shaping/timeout/layer.rs deleted file mode 100644 index fd1b5ea59e..0000000000 --- a/apollo-router/src/plugins/traffic_shaping/timeout/layer.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::time::Duration; - -use tower::Layer; - -use super::Timeout; - -/// Applies a timeout to requests via the supplied inner service. -#[derive(Debug, Clone)] -pub(crate) struct TimeoutLayer { - timeout: Duration, -} - -impl TimeoutLayer { - /// Create a timeout from a duration - pub(crate) fn new(timeout: Duration) -> Self { - TimeoutLayer { timeout } - } -} - -impl Layer for TimeoutLayer { - type Service = Timeout; - - fn layer(&self, service: S) -> Self::Service { - Timeout::new(service, self.timeout) - } -} diff --git a/apollo-router/src/plugins/traffic_shaping/timeout/mod.rs b/apollo-router/src/plugins/traffic_shaping/timeout/mod.rs deleted file mode 100644 index 6b7cb9abce..0000000000 --- a/apollo-router/src/plugins/traffic_shaping/timeout/mod.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! This is a modified Timeout service copy/pasted from the tower codebase. -//! This Timeout is also checking if we do not timeout on the `poll_ready` and not only on the `call` part -//! Middleware that applies a timeout to requests. -//! -//! If the response does not complete within the specified timeout, the response -//! will be aborted. - -pub(crate) mod error; -pub(crate) mod future; -mod layer; - -use std::task::Context; -use std::task::Poll; -use std::time::Duration; - -use tower::util::Oneshot; -use tower::Service; -use tower::ServiceExt; - -use self::future::ResponseFuture; -pub(crate) use self::layer::TimeoutLayer; -pub(crate) use crate::plugins::traffic_shaping::timeout::error::Elapsed; - -/// Applies a timeout to requests. -#[derive(Debug, Clone)] -pub(crate) struct Timeout { - inner: T, - timeout: Duration, -} - -// ===== impl Timeout ===== - -impl Timeout { - /// Creates a new [`Timeout`] - pub(crate) fn new(inner: T, timeout: Duration) -> Self { - Timeout { inner, timeout } - } -} - -impl Service for Timeout -where - S: Service + Clone, - S::Error: Into, -{ - type Response = S::Response; - type Error = tower::BoxError; - type Future = ResponseFuture>; - - fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn call(&mut self, request: Request) -> Self::Future { - let service = self.inner.clone(); - - let response = service.oneshot(request); - - ResponseFuture::new(response, Box::pin(tokio::time::sleep(self.timeout))) - } -} diff --git a/apollo-router/src/protocols/multipart.rs b/apollo-router/src/protocols/multipart.rs index b9325b9f5d..c8c7331da9 100644 --- a/apollo-router/src/protocols/multipart.rs +++ b/apollo-router/src/protocols/multipart.rs @@ -3,15 +3,16 @@ use std::task::Poll; use std::time::Duration; use bytes::Bytes; -use futures::stream::select; -use futures::stream::StreamExt; use futures::Stream; +use futures::stream::StreamExt; +use futures::stream::select; use serde::Serialize; use serde_json_bytes::Value; use tokio_stream::once; use tokio_stream::wrappers::IntervalStream; use crate::graphql; +use crate::services::SUBSCRIPTION_ERROR_EXTENSION_KEY; #[cfg(test)] const HEARTBEAT_INTERVAL: Duration = Duration::from_millis(10); @@ -40,7 +41,7 @@ struct SubscriptionPayload { #[derive(Debug)] enum MessageKind { Heartbeat, - Message(graphql::Response), + Message(Box), Eof, } @@ -56,16 +57,15 @@ impl Multipart { where S: Stream + Send + 'static, { + let stream = stream.map(|message| MessageKind::Message(Box::new(message))); let stream = match mode { ProtocolMode::Subscription => select( - stream - .map(MessageKind::Message) - .chain(once(MessageKind::Eof)), + stream.chain(once(MessageKind::Eof)), IntervalStream::new(tokio::time::interval(HEARTBEAT_INTERVAL)) .map(|_| MessageKind::Heartbeat), ) .boxed(), - ProtocolMode::Defer => stream.map(MessageKind::Message).boxed(), + ProtocolMode::Defer => stream.boxed(), }; Self { @@ -116,27 +116,39 @@ impl Stream for Multipart { match self.mode { ProtocolMode::Subscription => { - let resp = SubscriptionPayload { - errors: if is_still_open { - Vec::new() - } else { - response.errors.drain(..).collect() - }, - payload: match response.data { - None | Some(Value::Null) if response.extensions.is_empty() => { - None - } - _ => response.into(), - }, - }; - - // Gracefully closed at the server side - if !is_still_open && resp.payload.is_none() && resp.errors.is_empty() { + let is_transport_error = + response.extensions.remove(SUBSCRIPTION_ERROR_EXTENSION_KEY) + == Some(true.into()); + // Magic empty response (that we create internally) means the connection was gracefully closed at the server side + if !is_still_open + && response.data.is_none() + && response.errors.is_empty() + && response.extensions.is_empty() + { self.is_terminated = true; return Poll::Ready(Some(Ok(Bytes::from_static(&b"--\r\n"[..])))); - } else { - serde_json::to_writer(&mut buf, &resp)?; } + + let response = if is_transport_error { + SubscriptionPayload { + errors: std::mem::take(&mut response.errors), + payload: match response.data { + None | Some(Value::Null) + if response.extensions.is_empty() => + { + None + } + _ => (*response).into(), + }, + } + } else { + SubscriptionPayload { + errors: Vec::new(), + payload: (*response).into(), + } + }; + + serde_json::to_writer(&mut buf, &response)?; } ProtocolMode::Defer => { serde_json::to_writer(&mut buf, &response)?; @@ -229,7 +241,10 @@ mod tests { } else { match curr_index { 0 => { - assert_eq!(res, "\r\n--graphql\r\ncontent-type: application/json\r\n\r\n{\"payload\":{\"data\":\"foo\"}}\r\n--graphql"); + assert_eq!( + res, + "\r\n--graphql\r\ncontent-type: application/json\r\n\r\n{\"payload\":{\"data\":\"foo\"}}\r\n--graphql" + ); } 1 => { assert_eq!( diff --git a/apollo-router/src/protocols/websocket.rs b/apollo-router/src/protocols/websocket.rs index bd556232ac..fe6176e4ae 100644 --- a/apollo-router/src/protocols/websocket.rs +++ b/apollo-router/src/protocols/websocket.rs @@ -1,15 +1,14 @@ -use std::borrow::Cow; use std::pin::Pin; use std::task::Poll; use std::time::Duration; -use futures::future; -use futures::stream::SplitStream; use futures::Future; use futures::Sink; use futures::SinkExt; use futures::Stream; use futures::StreamExt; +use futures::future; +use futures::stream::SplitStream; use http::HeaderValue; use pin_project_lite::pin_project; use schemars::JsonSchema; @@ -19,10 +18,10 @@ use serde_json_bytes::Value; use tokio::io::AsyncRead; use tokio::io::AsyncWrite; use tokio_stream::wrappers::IntervalStream; -use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; -use tokio_tungstenite::tungstenite::protocol::CloseFrame; -use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::protocol::CloseFrame; +use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; use crate::graphql; @@ -283,7 +282,7 @@ where })?; if !matches!(resp, Some(Ok(ServerMessage::ConnectionAck))) { return Err(graphql::Error::builder() - .message(format!("didn't receive the connection ack from websocket connection but instead got: {:?}", resp)) + .message(format!("didn't receive the connection ack from websocket connection but instead got: {resp:?}")) .extension_code("WEBSOCKET_ACK_ERROR") .build()); } @@ -300,13 +299,10 @@ where request: graphql::Request, heartbeat_interval: Option, ) -> Result, graphql::Error> { - tracing::info!( - monotonic_counter - .apollo - .router - .operations - .subscriptions - .events = 1u64, + u64_counter!( + "apollo.router.operations.subscriptions.events", + "Number of subscription events", + 1, subscriptions.mode = "passthrough" ); @@ -347,12 +343,12 @@ where ClientMessage::CloseWebsocket => { future::ready(Ok(Message::Close(Some(CloseFrame{ code: CloseCode::Normal, - reason: Cow::default(), + reason: Default::default(), })))) }, message => { future::ready(match serde_json::to_string(&message) { - Ok(client_message_str) => Ok(Message::Text(client_message_str)), + Ok(client_message_str) => Ok(Message::text(client_message_str)), Err(err) => Err(Error::SerdeError(err)), }) }, @@ -433,23 +429,20 @@ where .take_until(close_sentinel); if let Err(err) = sink.send_all(&mut heartbeat_stream).await { tracing::trace!("cannot send heartbeat: {err:?}"); - if let Some(close_sentinel) = heartbeat_stream.take_future() { - if let Err(err) = close_sentinel.await { - tracing::trace!("cannot shutdown sink: {err:?}"); - } + if let Some(close_sentinel) = heartbeat_stream.take_future() + && let Err(err) = close_sentinel.await + { + tracing::trace!("cannot shutdown sink: {err:?}"); } } } else if let Err(err) = close_sentinel.await { tracing::trace!("cannot shutdown sink: {err:?}"); }; - tracing::info!( - monotonic_counter - .apollo - .router - .operations - .subscriptions - .events = 1u64, + u64_counter!( + "apollo.router.operations.subscriptions.events", + "Number of subscription events", + 1, subscriptions.mode = "passthrough", subscriptions.complete = true ); @@ -468,10 +461,10 @@ where impl Drop for SubscriptionStream { fn drop(&mut self) { - if let Some(close_signal) = self.close_signal.take() { - if let Err(err) = close_signal.send(()) { - tracing::trace!("cannot close the websocket stream: {err:?}"); - } + if let Some(close_signal) = self.close_signal.take() + && let Err(err) = close_signal.send(()) + { + tracing::trace!("cannot close the websocket stream: {err:?}"); } } } @@ -536,11 +529,13 @@ where Poll::Ready(message) => match message { Some(server_message) => match server_message { Ok(server_message) => { - if let Some(id) = &server_message.id() { - if this.id != id { - tracing::error!("we should not receive data from other subscriptions, closing the stream"); - return Poll::Ready(None); - } + if let Some(id) = &server_message.id() + && this.id != id + { + tracing::error!( + "we should not receive data from other subscriptions, closing the stream" + ); + return Poll::Ready(None); } if let ServerMessage::Ping { .. } = server_message { // Send pong asynchronously @@ -646,19 +641,17 @@ where } } } - if let WebSocketProtocol::SubscriptionsTransportWs = this.protocol { - if !*this.terminated { - match Pin::new( - &mut Pin::new(&mut this.stream).send(ClientMessage::ConnectionTerminate), - ) + if let WebSocketProtocol::SubscriptionsTransportWs = this.protocol + && !*this.terminated + { + match Pin::new(&mut Pin::new(&mut this.stream).send(ClientMessage::ConnectionTerminate)) .poll(cx) - { - Poll::Ready(_) => { - *this.terminated = true; - } - Poll::Pending => { - return Poll::Pending; - } + { + Poll::Ready(_) => { + *this.terminated = true; + } + Poll::Pending => { + return Poll::Pending; } } } @@ -691,11 +684,10 @@ mod tests { use std::convert::Infallible; use std::net::SocketAddr; - use axum::extract::ws::Message as AxumWsMessage; + use axum::Router; use axum::extract::WebSocketUpgrade; + use axum::extract::ws::Message as AxumWsMessage; use axum::routing::get; - use axum::Router; - use axum::Server; use futures::FutureExt; use http::HeaderValue; use tokio_tungstenite::connect_async; @@ -703,6 +695,7 @@ mod tests { use uuid::Uuid; use super::*; + use crate::assert_response_eq_ignoring_error_id; use crate::graphql::Request; async fn emulate_correct_websocket_server_new_protocol( @@ -711,7 +704,7 @@ mod tests { port: Option, ) -> SocketAddr { let ws_handler = move |ws: WebSocketUpgrade| async move { - let res = ws.on_upgrade(move |mut socket| async move { + let res = ws.protocols(["graphql-transport-ws"]).on_upgrade(move |mut socket| async move { let connection_ack = socket.recv().await.unwrap().unwrap().into_text().unwrap(); let ack_msg: ClientMessage = serde_json::from_str(&connection_ack).unwrap(); if let ClientMessage::ConnectionInit { payload } = ack_msg { @@ -725,7 +718,7 @@ mod tests { if send_ping { // It turns out some servers may send Pings before they even ack the connection. socket - .send(AxumWsMessage::Text( + .send(AxumWsMessage::text( serde_json::to_string(&ServerMessage::Ping { payload: None }).unwrap(), )) .await @@ -736,7 +729,7 @@ mod tests { } socket - .send(AxumWsMessage::Text( + .send(AxumWsMessage::text( serde_json::to_string(&ServerMessage::ConnectionAck).unwrap(), )) .await @@ -759,9 +752,7 @@ mod tests { } socket - .send(AxumWsMessage::Text( - "coucou".to_string(), - )) + .send(AxumWsMessage::text("coucou")) .await .unwrap(); @@ -774,7 +765,7 @@ mod tests { tokio::time::sleep(duration).await; let ping_message = socket.next().await.unwrap().unwrap(); - assert_eq!(ping_message, AxumWsMessage::Text( + assert_eq!(ping_message, AxumWsMessage::text( serde_json::to_string(&ClientMessage::Ping { payload: None }).unwrap(), )); @@ -786,38 +777,38 @@ mod tests { } socket - .send(AxumWsMessage::Text( + .send(AxumWsMessage::text( serde_json::to_string(&ServerMessage::Next { id: client_id.clone().unwrap(), payload: graphql::Response::builder().data(serde_json_bytes::json!({"userWasCreated": {"username": "ada_lovelace"}})).build() }).unwrap(), )) .await .unwrap(); socket - .send(AxumWsMessage::Text( + .send(AxumWsMessage::text( serde_json::to_string(&ServerMessage::Ping { payload: None }).unwrap(), )) .await .unwrap(); let pong_message = socket.next().await.unwrap().unwrap(); - assert_eq!(pong_message, AxumWsMessage::Text( + assert_eq!(pong_message, AxumWsMessage::text( serde_json::to_string(&ClientMessage::Pong { payload: None }).unwrap(), )); socket - .send(AxumWsMessage::Text( + .send(AxumWsMessage::text( serde_json::to_string(&ServerMessage::Ping { payload: None }).unwrap(), )) .await .unwrap(); let pong_message = socket.next().await.unwrap().unwrap(); - assert_eq!(pong_message, AxumWsMessage::Text( + assert_eq!(pong_message, AxumWsMessage::text( serde_json::to_string(&ClientMessage::Pong { payload: None }).unwrap(), )); socket - .send(AxumWsMessage::Text( + .send(AxumWsMessage::text( serde_json::to_string(&ServerMessage::Complete { id: client_id.unwrap() }).unwrap(), )) .await @@ -833,13 +824,12 @@ mod tests { }; let app = Router::new().route("/ws", get(ws_handler)); - let server = Server::bind( - &format!("127.0.0.1:{}", port.unwrap_or_default()) - .parse() - .unwrap(), - ) - .serve(app.into_make_service()); - let local_addr = server.local_addr(); + let listener = + tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port.unwrap_or_default())) + .await + .unwrap(); + let server = axum::serve(listener, app); + let local_addr = server.local_addr().unwrap(); tokio::spawn(async { server.await.unwrap() }); local_addr } @@ -849,7 +839,7 @@ mod tests { port: Option, ) -> SocketAddr { let ws_handler = move |ws: WebSocketUpgrade| async move { - let res = ws.on_upgrade(move |mut socket| async move { + let res = ws.protocols(["graphql-ws"]).on_upgrade(move |mut socket| async move { let init_connection = socket.recv().await.unwrap().unwrap().into_text().unwrap(); let init_msg: ClientMessage = serde_json::from_str(&init_connection).unwrap(); assert!(matches!(init_msg, ClientMessage::ConnectionInit { .. })); @@ -857,7 +847,7 @@ mod tests { if send_ping { // It turns out some servers may send Pings before they even ack the connection. socket - .send(AxumWsMessage::Text( + .send(AxumWsMessage::text( serde_json::to_string(&ServerMessage::Ping { payload: None }).unwrap(), )) .await @@ -867,13 +857,13 @@ mod tests { assert!(matches!(pong_message, ClientMessage::Pong { payload: None })); } socket - .send(AxumWsMessage::Text( + .send(AxumWsMessage::text( serde_json::to_string(&ServerMessage::ConnectionAck).unwrap(), )) .await .unwrap(); socket - .send(AxumWsMessage::Text( + .send(AxumWsMessage::text( serde_json::to_string(&ServerMessage::KeepAlive).unwrap(), )) .await @@ -896,20 +886,18 @@ mod tests { } socket - .send(AxumWsMessage::Text( - "coucou".to_string(), - )) + .send(AxumWsMessage::text("coucou")) .await .unwrap(); socket - .send(AxumWsMessage::Text( + .send(AxumWsMessage::text( serde_json::to_string(&ServerMessage::Next { id: client_id.clone().unwrap(), payload: graphql::Response::builder().data(serde_json_bytes::json!({"userWasCreated": {"username": "ada_lovelace"}})).build() }).unwrap(), )) .await .unwrap(); socket - .send(AxumWsMessage::Text( + .send(AxumWsMessage::text( serde_json::to_string(&ServerMessage::KeepAlive).unwrap(), )) .await @@ -930,13 +918,12 @@ mod tests { }; let app = Router::new().route("/ws", get(ws_handler)); - let server = Server::bind( - &format!("127.0.0.1:{}", port.unwrap_or_default()) - .parse() - .unwrap(), - ) - .serve(app.into_make_service()); - let local_addr = server.local_addr(); + let listener = + tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port.unwrap_or_default())) + .await + .unwrap(); + let server = axum::serve(listener, app); + let local_addr = server.local_addr().unwrap(); tokio::spawn(async { server.await.unwrap() }); local_addr } @@ -964,7 +951,7 @@ mod tests { let socket_addr = emulate_correct_websocket_server_new_protocol(send_ping, heartbeat_interval, port) .await; - let url = url::Url::parse(format!("ws://{}/ws", socket_addr).as_str()).unwrap(); + let url = format!("ws://{socket_addr}/ws"); let mut request = url.into_client_request().unwrap(); request.headers_mut().insert( http::header::SEC_WEBSOCKET_PROTOCOL, @@ -994,7 +981,7 @@ mod tests { .unwrap(); let next_payload = gql_read_stream.next().await.unwrap(); - assert_eq!(next_payload, graphql::Response::builder() + assert_response_eq_ignoring_error_id!(next_payload, graphql::Response::builder() .error( graphql::Error::builder() .message( @@ -1031,7 +1018,7 @@ mod tests { async fn test_ws_connection_old_proto(send_ping: bool, port: Option) { let socket_addr = emulate_correct_websocket_server_old_protocol(send_ping, port).await; - let url = url::Url::parse(format!("ws://{}/ws", socket_addr).as_str()).unwrap(); + let url = format!("ws://{socket_addr}/ws"); let mut request = url.into_client_request().unwrap(); request.headers_mut().insert( http::header::SEC_WEBSOCKET_PROTOCOL, @@ -1056,7 +1043,8 @@ mod tests { .unwrap(); let next_payload = gql_read_stream.next().await.unwrap(); - assert_eq!(next_payload, graphql::Response::builder() + + assert_response_eq_ignoring_error_id!(next_payload, graphql::Response::builder() .error( graphql::Error::builder() .message( diff --git a/apollo-router/src/query_planner/bridge_query_planner_pool.rs b/apollo-router/src/query_planner/bridge_query_planner_pool.rs deleted file mode 100644 index 652bc997b9..0000000000 --- a/apollo-router/src/query_planner/bridge_query_planner_pool.rs +++ /dev/null @@ -1,370 +0,0 @@ -use std::collections::HashMap; -use std::num::NonZeroUsize; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; -use std::sync::Arc; -use std::sync::Mutex; -use std::time::Instant; - -use apollo_compiler::validation::Valid; -use async_channel::bounded; -use async_channel::Sender; -use futures::future::BoxFuture; -use opentelemetry::metrics::MeterProvider; -use opentelemetry::metrics::ObservableGauge; -use opentelemetry::metrics::Unit; -use router_bridge::planner::Planner; -use tokio::sync::oneshot; -use tokio::task::JoinSet; -use tower::Service; -use tower::ServiceExt; - -use super::bridge_query_planner::BridgeQueryPlanner; -use super::QueryPlanResult; -use crate::error::QueryPlannerError; -use crate::error::ServiceBuildError; -use crate::introspection::IntrospectionCache; -use crate::metrics::meter_provider; -use crate::query_planner::PlannerMode; -use crate::services::QueryPlannerRequest; -use crate::services::QueryPlannerResponse; -use crate::spec::Schema; -use crate::Configuration; - -static CHANNEL_SIZE: usize = 1_000; - -#[derive(Clone)] -pub(crate) struct BridgeQueryPlannerPool { - js_planners: Vec>>, - sender: Sender<( - QueryPlannerRequest, - oneshot::Sender>, - )>, - schema: Arc, - subgraph_schemas: Arc>>>, - pool_size_gauge: Arc>>>, - v8_heap_used: Arc, - v8_heap_used_gauge: Arc>>>, - v8_heap_total: Arc, - v8_heap_total_gauge: Arc>>>, - introspection_cache: Arc, -} - -impl BridgeQueryPlannerPool { - pub(crate) async fn new( - old_js_planners: Vec>>, - schema: Arc, - configuration: Arc, - size: NonZeroUsize, - ) -> Result { - let rust_planner = PlannerMode::maybe_rust(&schema, &configuration)?; - - let mut join_set = JoinSet::new(); - - let (sender, receiver) = bounded::<( - QueryPlannerRequest, - oneshot::Sender>, - )>(CHANNEL_SIZE); - - let mut old_js_planners_iterator = old_js_planners.into_iter(); - - // All query planners in the pool now share the same introspection cache. - // This allows meaningful gauges, and it makes sense that queries should be cached across all planners. - let introspection_cache = Arc::new(IntrospectionCache::new(&configuration)); - - for _ in 0..size.into() { - let schema = schema.clone(); - let configuration = configuration.clone(); - let rust_planner = rust_planner.clone(); - let introspection_cache = introspection_cache.clone(); - - let old_planner = old_js_planners_iterator.next(); - join_set.spawn(async move { - BridgeQueryPlanner::new( - schema, - configuration, - old_planner, - rust_planner, - introspection_cache, - ) - .await - }); - } - - let mut bridge_query_planners = Vec::new(); - - while let Some(task_result) = join_set.join_next().await { - let bridge_query_planner = - task_result.map_err(|e| ServiceBuildError::ServiceError(Box::new(e)))??; - bridge_query_planners.push(bridge_query_planner); - } - - let subgraph_schemas = bridge_query_planners - .first() - .ok_or_else(|| { - ServiceBuildError::QueryPlannerError(QueryPlannerError::PoolProcessing( - "There should be at least 1 Query Planner service in pool".to_string(), - )) - })? - .subgraph_schemas(); - - let js_planners: Vec<_> = bridge_query_planners - .iter() - .filter_map(|p| p.js_planner()) - .collect(); - - for mut planner in bridge_query_planners.into_iter() { - let receiver = receiver.clone(); - - tokio::spawn(async move { - while let Ok((request, res_sender)) = receiver.recv().await { - let svc = match planner.ready().await { - Ok(svc) => svc, - Err(e) => { - let _ = res_sender.send(Err(e)); - - continue; - } - }; - - let res = svc.call(request).await; - - let _ = res_sender.send(res); - } - }); - } - let v8_heap_used: Arc = Default::default(); - let v8_heap_total: Arc = Default::default(); - - // initialize v8 metrics - if let Some(bridge_query_planner) = js_planners.first().cloned() { - Self::get_v8_metrics( - bridge_query_planner, - v8_heap_used.clone(), - v8_heap_total.clone(), - ) - .await; - } - - Ok(Self { - js_planners, - sender, - schema, - subgraph_schemas, - pool_size_gauge: Default::default(), - v8_heap_used, - v8_heap_used_gauge: Default::default(), - v8_heap_total, - v8_heap_total_gauge: Default::default(), - introspection_cache, - }) - } - - fn create_pool_size_gauge(&self) -> ObservableGauge { - let sender = self.sender.clone(); - let meter = meter_provider().meter("apollo/router"); - meter - .u64_observable_gauge("apollo.router.query_planning.queued") - .with_description("Number of queries waiting to be planned") - .with_unit(Unit::new("query")) - .with_callback(move |m| m.observe(sender.len() as u64, &[])) - .init() - } - - fn create_heap_used_gauge(&self) -> ObservableGauge { - let meter = meter_provider().meter("apollo/router"); - let current_heap_used_for_gauge = self.v8_heap_used.clone(); - let heap_used_gauge = meter - .u64_observable_gauge("apollo.router.v8.heap.used") - .with_description("V8 heap used, in bytes") - .with_unit(Unit::new("By")) - .with_callback(move |i| { - i.observe(current_heap_used_for_gauge.load(Ordering::SeqCst), &[]) - }) - .init(); - heap_used_gauge - } - - fn create_heap_total_gauge(&self) -> ObservableGauge { - let meter = meter_provider().meter("apollo/router"); - let current_heap_total_for_gauge = self.v8_heap_total.clone(); - let heap_total_gauge = meter - .u64_observable_gauge("apollo.router.v8.heap.total") - .with_description("V8 heap total, in bytes") - .with_unit(Unit::new("By")) - .with_callback(move |i| { - i.observe(current_heap_total_for_gauge.load(Ordering::SeqCst), &[]) - }) - .init(); - heap_total_gauge - } - - pub(crate) fn js_planners(&self) -> Vec>> { - self.js_planners.clone() - } - - pub(crate) fn schema(&self) -> Arc { - self.schema.clone() - } - - pub(crate) fn subgraph_schemas( - &self, - ) -> Arc>>> { - self.subgraph_schemas.clone() - } - - async fn get_v8_metrics( - planner: Arc>, - v8_heap_used: Arc, - v8_heap_total: Arc, - ) { - let metrics = planner.get_heap_statistics().await; - if let Ok(metrics) = metrics { - v8_heap_used.store(metrics.heap_used, Ordering::SeqCst); - v8_heap_total.store(metrics.heap_total, Ordering::SeqCst); - } - } - - pub(super) fn activate(&self) { - // Gauges MUST be initialized after a meter provider is created. - // When a hot reload happens this means that the gauges must be re-initialized. - *self.pool_size_gauge.lock().expect("lock poisoned") = Some(self.create_pool_size_gauge()); - *self.v8_heap_used_gauge.lock().expect("lock poisoned") = - Some(self.create_heap_used_gauge()); - *self.v8_heap_total_gauge.lock().expect("lock poisoned") = - Some(self.create_heap_total_gauge()); - self.introspection_cache.activate(); - } -} - -impl tower::Service for BridgeQueryPlannerPool { - type Response = QueryPlannerResponse; - - type Error = QueryPlannerError; - - type Future = BoxFuture<'static, Result>; - - fn poll_ready( - &mut self, - _cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - if self.sender.is_full() { - std::task::Poll::Ready(Err(QueryPlannerError::PoolProcessing( - "query plan queue is full".into(), - ))) - } else { - std::task::Poll::Ready(Ok(())) - } - } - - fn call(&mut self, req: QueryPlannerRequest) -> Self::Future { - let (response_sender, response_receiver) = oneshot::channel(); - let sender = self.sender.clone(); - - let get_metrics_future = - if let Some(bridge_query_planner) = self.js_planners.first().cloned() { - Some(Self::get_v8_metrics( - bridge_query_planner, - self.v8_heap_used.clone(), - self.v8_heap_total.clone(), - )) - } else { - None - }; - - Box::pin(async move { - let start = Instant::now(); - let _ = sender.send((req, response_sender)).await; - - let res = response_receiver - .await - .map_err(|_| QueryPlannerError::UnhandledPlannerResult)?; - - f64_histogram!( - "apollo.router.query_planning.total.duration", - "Duration of the time the router waited for a query plan, including both the queue time and planning time.", - start.elapsed().as_secs_f64() - ); - - if let Some(f) = get_metrics_future { - // execute in a separate task to avoid blocking the request - tokio::task::spawn(f); - } - - res - }) - } -} - -#[cfg(test)] - -mod tests { - use opentelemetry_sdk::metrics::data::Gauge; - use router_bridge::planner::PlanOptions; - - use super::*; - use crate::metrics::FutureMetricsExt; - use crate::plugins::authorization::CacheKeyMetadata; - use crate::spec::Query; - use crate::Context; - - #[tokio::test] - async fn test_v8_metrics() { - let sdl = include_str!("../testdata/supergraph.graphql"); - let config = Arc::default(); - let schema = Arc::new(Schema::parse(sdl, &config).unwrap()); - - async move { - let mut pool = BridgeQueryPlannerPool::new( - Vec::new(), - schema.clone(), - config.clone(), - NonZeroUsize::new(2).unwrap(), - ) - .await - .unwrap(); - pool.activate(); - let query = "query { me { name } }".to_string(); - - let doc = Query::parse_document(&query, None, &schema, &config).unwrap(); - let context = Context::new(); - context - .extensions() - .with_lock(|mut lock| lock.insert(doc.clone())); - - pool.call(QueryPlannerRequest::new( - query, - None, - doc, - CacheKeyMetadata::default(), - PlanOptions::default(), - )) - .await - .unwrap(); - - let metrics = crate::metrics::collect_metrics(); - let heap_used = metrics.find("apollo.router.v8.heap.used").unwrap(); - let heap_total = metrics.find("apollo.router.v8.heap.total").unwrap(); - - println!( - "got heap_used: {:?}, heap_total: {:?}", - heap_used - .data - .as_any() - .downcast_ref::>() - .unwrap() - .data_points[0] - .value, - heap_total - .data - .as_any() - .downcast_ref::>() - .unwrap() - .data_points[0] - .value - ); - } - .with_metrics() - .await; - } -} diff --git a/apollo-router/src/query_planner/caching_query_planner.rs b/apollo-router/src/query_planner/caching_query_planner.rs index ee5db8dccb..0cf6ca9872 100644 --- a/apollo-router/src/query_planner/caching_query_planner.rs +++ b/apollo-router/src/query_planner/caching_query_planner.rs @@ -1,67 +1,106 @@ -use std::collections::HashMap; use std::hash::Hash; use std::hash::Hasher; use std::sync::Arc; +use std::sync::atomic::AtomicU8; +use std::sync::atomic::Ordering; use std::task; -use apollo_compiler::validation::Valid; use futures::future::BoxFuture; use indexmap::IndexMap; use query_planner::QueryPlannerPlugin; use rand::seq::SliceRandom; -use rand::thread_rng; -use router_bridge::planner::PlanOptions; -use router_bridge::planner::Planner; -use router_bridge::planner::QueryPlannerConfig; -use router_bridge::planner::UsageReporting; use sha2::Digest; use sha2::Sha256; +use tokio_util::time::FutureExt; use tower::BoxError; use tower::ServiceBuilder; use tower::ServiceExt; use tower_service::Service; use tracing::Instrument; -use super::fetch::QueryHash; +use crate::Configuration; +use crate::apollo_studio_interop::UsageReporting; +use crate::cache::DeduplicatingCache; +use crate::cache::EntryError; use crate::cache::estimate_size; use crate::cache::storage::InMemoryCache; use crate::cache::storage::ValueType; -use crate::cache::DeduplicatingCache; +use crate::compute_job::ComputeBackPressureError; +use crate::compute_job::ComputeJobType; +use crate::compute_job::MaybeBackPressureError; use crate::configuration::PersistedQueriesPrewarmQueryPlanCache; +use crate::configuration::cooperative_cancellation::CooperativeCancellation; use crate::error::CacheResolverError; use crate::error::QueryPlannerError; use crate::plugins::authorization::AuthorizationPlugin; use crate::plugins::authorization::CacheKeyMetadata; use crate::plugins::progressive_override::LABELS_TO_OVERRIDE_KEY; use crate::plugins::telemetry::utils::Timer; +use crate::query_planner::QueryPlannerService; use crate::query_planner::fetch::SubgraphSchemas; -use crate::query_planner::BridgeQueryPlannerPool; -use crate::query_planner::QueryPlanResult; +use crate::services::QueryPlannerContent; +use crate::services::QueryPlannerRequest; +use crate::services::QueryPlannerResponse; use crate::services::layers::persisted_queries::PersistedQueryLayer; use crate::services::layers::query_analysis::ParsedDocument; use crate::services::layers::query_analysis::QueryAnalysisLayer; use crate::services::query_planner; -use crate::services::QueryPlannerContent; -use crate::services::QueryPlannerRequest; -use crate::services::QueryPlannerResponse; +use crate::services::query_planner::PlanOptions; +use crate::spec::QueryHash; use crate::spec::Schema; +use crate::spec::SchemaHash; use crate::spec::SpecError; -use crate::Configuration; + +#[repr(u8)] +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +enum Outcome { + None = 0, + Timeout = 1, + Cancelled = 2, + Success = 3, + Error = 4, + Backpressure = 5, + BatchingError = 6, +} + +impl std::fmt::Display for Outcome { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Outcome::None => write!(f, "none"), + Outcome::Timeout => write!(f, "timeout"), + Outcome::Cancelled => write!(f, "cancelled"), + Outcome::Success => write!(f, "success"), + Outcome::Error => write!(f, "error"), + Outcome::Backpressure => write!(f, "backpressure"), + Outcome::BatchingError => write!(f, "batching_error"), + } + } +} /// An [`IndexMap`] of available plugins. pub(crate) type Plugins = IndexMap>; pub(crate) type InMemoryCachePlanner = InMemoryCache>>; -pub(crate) const APOLLO_OPERATION_ID: &str = "apollo_operation_id"; - -#[derive(Debug, Clone, Hash)] -pub(crate) enum ConfigMode { - //FIXME: add the Rust planner structure once it is hashable and serializable, - // for now use the JS config as it expected to be identical to the Rust one - Rust(Arc), - Both(Arc), - BothBestEffort(Arc), - Js(Arc), +pub(crate) const APOLLO_OPERATION_ID: &str = "apollo::supergraph::operation_id"; + +/// Hashed value of query planner configuration for use in cache keys. +#[derive(Clone, Hash, PartialEq, Eq)] +// XXX(@goto-bus-stop): I think this probably should not be pub(crate), but right now all fields in +// the cache keys are pub(crate), which I'm not going to change at this time :) +pub(crate) struct ConfigModeHash(Vec); + +impl std::fmt::Display for ConfigModeHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", hex::encode(&self.0)) + } +} + +impl std::fmt::Debug for ConfigModeHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("ConfigModeHash") + .field(&hex::encode(&self.0)) + .finish() + } } /// A query planner wrapper that caches results. @@ -70,14 +109,19 @@ pub(crate) enum ConfigMode { #[derive(Clone)] pub(crate) struct CachingQueryPlanner { cache: Arc< - DeduplicatingCache>>, + DeduplicatingCache< + CachingQueryKey, + Result>, + ComputeBackPressureError, + >, >, delegate: T, schema: Arc, - subgraph_schemas: Arc>>>, + subgraph_schemas: Arc, plugins: Arc, enable_authorization_directives: bool, - config_mode_hash: Arc, + config_mode_hash: Arc, + cooperative_cancellation: CooperativeCancellation, } fn init_query_plan_from_redis( @@ -85,7 +129,7 @@ fn init_query_plan_from_redis( cache_entry: &mut Result>, ) -> Result<(), String> { if let Ok(QueryPlannerContent::Plan { plan }) = cache_entry { - // Arc freshly deserialized from Redis should be unique, so this doesn’t clone: + // Arc freshly deserialized from Redis should be unique, so this doesn't clone: let plan = Arc::make_mut(plan); let root = Arc::make_mut(&mut plan.root); root.init_parsed_operations(subgraph_schemas) @@ -99,7 +143,7 @@ where T: tower::Service< QueryPlannerRequest, Response = QueryPlannerResponse, - Error = QueryPlannerError, + Error = MaybeBackPressureError, > + Send, >::Future: Send, { @@ -107,7 +151,7 @@ where pub(crate) async fn new( delegate: T, schema: Arc, - subgraph_schemas: Arc>>>, + subgraph_schemas: Arc, configuration: &Configuration, plugins: Plugins, ) -> Result, BoxError> { @@ -123,32 +167,13 @@ where AuthorizationPlugin::enable_directives(configuration, &schema).unwrap_or(false); let mut hasher = StructHasher::new(); - match configuration.experimental_query_planner_mode { - crate::configuration::QueryPlannerMode::New => { - "PLANNER-NEW".hash(&mut hasher); - ConfigMode::Rust(Arc::new(configuration.rust_query_planner_config())) - .hash(&mut hasher); - } - crate::configuration::QueryPlannerMode::Legacy => { - "PLANNER-LEGACY".hash(&mut hasher); - ConfigMode::Js(Arc::new(configuration.js_query_planner_config())).hash(&mut hasher); - } - crate::configuration::QueryPlannerMode::Both => { - "PLANNER-BOTH".hash(&mut hasher); - ConfigMode::Both(Arc::new(configuration.js_query_planner_config())) - .hash(&mut hasher); - ConfigMode::Rust(Arc::new(configuration.rust_query_planner_config())) - .hash(&mut hasher); - } - crate::configuration::QueryPlannerMode::BothBestEffort => { - "PLANNER-BOTH-BEST-EFFORT".hash(&mut hasher); - ConfigMode::BothBestEffort(Arc::new(configuration.js_query_planner_config())) - .hash(&mut hasher); - ConfigMode::Rust(Arc::new(configuration.rust_query_planner_config())) - .hash(&mut hasher); - } - }; - let config_mode_hash = Arc::new(QueryHash(hasher.finalize())); + configuration.rust_query_planner_config().hash(&mut hasher); + let config_mode_hash = Arc::new(ConfigModeHash(hasher.finalize())); + let cooperative_cancellation = configuration + .supergraph + .query_planning + .experimental_cooperative_cancellation + .clone(); Ok(Self { cache, @@ -157,6 +182,7 @@ where subgraph_schemas, plugins: Arc::new(plugins), enable_authorization_directives, + cooperative_cancellation, config_mode_hash, }) } @@ -175,8 +201,10 @@ where experimental_pql_prewarm: &PersistedQueriesPrewarmQueryPlanCache, ) { let _timer = Timer::new(|duration| { - ::tracing::info!( - histogram.apollo.router.query.planning.warmup.duration = duration.as_secs_f64() + f64_histogram!( + "apollo.router.query_planning.warmup.duration", + "Time spent warming up the query planner queries in seconds", + duration.as_secs_f64() ); }); @@ -205,7 +233,7 @@ where hash, metadata, plan_options, - config_mode: _, + config_mode_hash: _, schema_id: _, }, _, @@ -215,7 +243,7 @@ where hash: Some(hash.clone()), metadata: metadata.clone(), plan_options: plan_options.clone(), - config_mode: self.config_mode_hash.clone(), + config_mode_hash: self.config_mode_hash.clone(), }, ) .take(count) @@ -224,7 +252,7 @@ where None => Vec::new(), }; - cache_keys.shuffle(&mut thread_rng()); + cache_keys.shuffle(&mut rand::rng()); let should_warm_with_pqs = (experimental_pql_prewarm.on_startup && previous_cache.is_none()) @@ -251,66 +279,77 @@ where // persisted queries are added first because they should get a lower priority in the LRU cache, // since a lot of them may be there to support old clients let mut all_cache_keys: Vec = Vec::with_capacity(capacity); - if should_warm_with_pqs { - if let Some(queries) = persisted_queries_operations { - for query in queries { - all_cache_keys.push(WarmUpCachingQueryKey { - query, - operation_name: None, - hash: None, - metadata: CacheKeyMetadata::default(), - plan_options: PlanOptions::default(), - config_mode: self.config_mode_hash.clone(), - }); - } + if should_warm_with_pqs && let Some(queries) = persisted_queries_operations { + for query in queries { + all_cache_keys.push(WarmUpCachingQueryKey { + query, + operation_name: None, + hash: None, + metadata: CacheKeyMetadata::default(), + plan_options: PlanOptions::default(), + config_mode_hash: self.config_mode_hash.clone(), + }); } } + all_cache_keys.shuffle(&mut rand::rng()); + all_cache_keys.extend(cache_keys.into_iter()); let mut count = 0usize; let mut reused = 0usize; - for WarmUpCachingQueryKey { + 'all_cache_keys_loop: for WarmUpCachingQueryKey { query, operation_name, hash, metadata, plan_options, - config_mode: _, + config_mode_hash: _, } in all_cache_keys { - let doc = match query_analysis - .parse_document(&query, operation_name.as_deref()) - .await - { - Ok(doc) => doc, - Err(_) => continue, + // NB: warmup tasks have a low priority so that real requests are prioritized + let doc = loop { + match query_analysis + .parse_document( + &query, + operation_name.as_deref(), + ComputeJobType::QueryParsingWarmup, + ) + .await + { + Ok(doc) => break doc, + Err(MaybeBackPressureError::PermanentError(_)) => { + continue 'all_cache_keys_loop; + } + Err(MaybeBackPressureError::TemporaryError(ComputeBackPressureError)) => { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + // try again + } + } }; let caching_key = CachingQueryKey { query: query.clone(), operation: operation_name.clone(), hash: doc.hash.clone(), - schema_id: Arc::clone(&self.schema.schema_id), + schema_id: self.schema.schema_id.clone(), metadata, plan_options, - config_mode: self.config_mode_hash.clone(), + config_mode_hash: self.config_mode_hash.clone(), }; if experimental_reuse_query_plans { // check if prewarming via seeing if the previous cache exists (aka a reloaded router); if reloading, try to reuse the if let Some(ref previous_cache) = previous_cache { // if the query hash did not change with the schema update, we can reuse the previously cached entry - if let Some(hash) = hash { - if hash == doc.hash { - if let Some(entry) = - { previous_cache.lock().await.get(&caching_key).cloned() } - { - self.cache.insert_in_memory(caching_key, entry).await; - reused += 1; - continue; - } - } + if let Some(hash) = hash + && hash == doc.hash + && let Some(entry) = + { previous_cache.lock().await.get(&caching_key).cloned() } + { + self.cache.insert_in_memory(caching_key, entry).await; + reused += 1; + continue; } } }; @@ -322,65 +361,55 @@ where }) .await; if entry.is_first() { - let doc = match query_analysis - .parse_document(&query, operation_name.as_deref()) - .await - { - Ok(doc) => doc, - Err(error) => { - let e = Arc::new(QueryPlannerError::SpecError(error)); - tokio::spawn(async move { - entry.insert(Err(e)).await; - }); - continue; - } - }; - - let request = QueryPlannerRequest { - query, - operation_name, - document: doc, - metadata: caching_key.metadata, - plan_options: caching_key.plan_options, - }; - - let res = match service.ready().await { - Ok(service) => service.call(request).await, - Err(_) => break, - }; + loop { + let request = QueryPlannerRequest { + query: query.clone(), + operation_name: operation_name.clone(), + document: doc.clone(), + metadata: caching_key.metadata.clone(), + plan_options: caching_key.plan_options.clone(), + compute_job_type: ComputeJobType::QueryPlanningWarmup, + }; + let res = match service.ready().await { + Ok(service) => service.call(request).await, + Err(_) => break 'all_cache_keys_loop, + }; - match res { - Ok(QueryPlannerResponse { content, .. }) => { - if let Some(content) = content.clone() { + match res { + Ok(QueryPlannerResponse { content, .. }) => { + if let Some(content) = content.clone() { + count += 1; + tokio::spawn(async move { + entry.insert(Ok(content.clone())).await; + }); + } + break; + } + Err(MaybeBackPressureError::PermanentError(error)) => { count += 1; + let e = Arc::new(error); tokio::spawn(async move { - entry.insert(Ok(content.clone())).await; + entry.insert(Err(e)).await; }); + break; + } + Err(MaybeBackPressureError::TemporaryError(ComputeBackPressureError)) => { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + // try again } - } - Err(error) => { - count += 1; - let e = Arc::new(error); - tokio::spawn(async move { - entry.insert(Err(e)).await; - }); } } } } - tracing::debug!("warmed up the query planner cache with {count} queries planned and {reused} queries reused"); + tracing::debug!( + "warmed up the query planner cache with {count} queries planned and {reused} queries reused" + ); } } -impl CachingQueryPlanner { - pub(crate) fn js_planners(&self) -> Vec>> { - self.delegate.js_planners() - } - - pub(crate) fn subgraph_schemas( - &self, - ) -> Arc>>> { +impl CachingQueryPlanner { + pub(crate) fn subgraph_schemas(&self) -> Arc { self.delegate.subgraph_schemas() } @@ -390,15 +419,14 @@ impl CachingQueryPlanner { } } -impl tower::Service - for CachingQueryPlanner +impl Service for CachingQueryPlanner where - T: tower::Service< - QueryPlannerRequest, - Response = QueryPlannerResponse, - Error = QueryPlannerError, - >, - >::Future: Send, + T: Service< + QueryPlannerRequest, + Response = QueryPlannerResponse, + Error = MaybeBackPressureError, + >, + >::Future: Send, { type Response = QueryPlannerResponse; type Error = CacheResolverError; @@ -417,13 +445,10 @@ where .extensions() .with_lock(|lock| lock.get::>().cloned()) { - let _ = context.insert( - APOLLO_OPERATION_ID, - stats_report_key_hash(usage_reporting.stats_report_key.as_str()), - ); + let _ = context.insert(APOLLO_OPERATION_ID, usage_reporting.get_operation_id()); let _ = context.insert( "apollo_operation_signature", - usage_reporting.stats_report_key.clone(), + usage_reporting.get_stats_report_key(), ); } }) @@ -431,21 +456,40 @@ where } } +const OUTCOME: &str = "outcome"; + +fn record_outcome_if_none(outcome_recorded: &AtomicU8, outcome: Outcome) -> bool { + if outcome_recorded + .compare_exchange( + Outcome::None as u8, + outcome as u8, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_ok() + { + tracing::Span::current().record(OUTCOME, outcome.to_string()); + true + } else { + false + } +} + impl CachingQueryPlanner where - T: tower::Service< + T: Service< QueryPlannerRequest, Response = QueryPlannerResponse, - Error = QueryPlannerError, + Error = MaybeBackPressureError, > + Clone + Send + 'static, - >::Future: Send, + >::Future: Send, { async fn plan( mut self, request: query_planner::CachingRequest, - ) -> Result<>::Response, CacheResolverError> { + ) -> Result<>::Response, CacheResolverError> { if self.enable_authorization_directives { AuthorizationPlugin::update_cache_key(&request.context); } @@ -484,10 +528,10 @@ where query: request.query.clone(), operation: request.operation_name.to_owned(), hash: doc.hash.clone(), - schema_id: Arc::clone(&self.schema.schema_id), + schema_id: self.schema.schema_id.clone(), metadata, plan_options, - config_mode: self.config_mode_hash.clone(), + config_mode_hash: self.config_mode_hash.clone(), }; let context = request.context.clone(); @@ -510,92 +554,194 @@ where .document(doc) .metadata(caching_key.metadata) .plan_options(caching_key.plan_options) + .compute_job_type(ComputeJobType::QueryPlanning) .build(); - // some clients might timeout and cancel the request before query planning is finished, - // so we execute it in a task that can continue even after the request was canceled and - // the join handle was dropped. That way, the next similar query will use the cache instead - // of restarting the query planner until another timeout - tokio::task::spawn( - async move { - let service = match self.delegate.ready().await { - Ok(service) => service, - Err(error) => { - let e = Arc::new(error); - let err = e.clone(); - tokio::spawn(async move { - entry.insert(Err(err)).await; - }); - return Err(CacheResolverError::RetrievalError(e)); - } - }; - - let res = service.call(request).await; + let planning_task = async move { + let service = match self.delegate.ready().await { + Ok(service) => service, + Err(MaybeBackPressureError::PermanentError(error)) => { + let e = Arc::new(error); + let err = e.clone(); + tokio::spawn(async move { + entry.insert(Err(err)).await; + }); + return Err(CacheResolverError::RetrievalError(e)); + } + Err(MaybeBackPressureError::TemporaryError(error)) => { + let err = error.clone(); + tokio::spawn(async move { + // Temporary errors are never cached + entry.send(Err(err)).await; + }); + return Err(CacheResolverError::Backpressure(error)); + } + }; - match res { - Ok(QueryPlannerResponse { content, errors }) => { - if let Some(content) = content.clone() { - let can_cache = match &content { - // Already cached in an introspection-specific, small-size, - // in-memory-only cache. - QueryPlannerContent::CachedIntrospectionResponse { .. } => { - false - } - _ => true, - }; - - if can_cache { - tokio::spawn(async move { - entry.insert(Ok(content)).await; - }); - } else { - tokio::spawn(async move { - entry.send(Ok(content)).await; - }); - } - } + let res = service.call(request).await; - // This will be overridden by the Rust usage reporting implementation - if let Some(QueryPlannerContent::Plan { plan, .. }) = &content { - context.extensions().with_lock(|mut lock| { - lock.insert::>(plan.usage_reporting.clone()) + match res { + Ok(QueryPlannerResponse { content, errors }) => { + if let Some(content) = content.clone() { + let can_cache = match &content { + // Already cached in an introspection-specific, small-size, + // in-memory-only cache. + QueryPlannerContent::CachedIntrospectionResponse { .. } => false, + _ => true, + }; + + if can_cache { + tokio::spawn(async move { + entry.insert(Ok(content)).await; + }); + } else { + tokio::spawn(async move { + entry.send(Ok(Ok(content))).await; }); } - Ok(QueryPlannerResponse { content, errors }) } - Err(error) => { - let e = Arc::new(error); - let err = e.clone(); - tokio::spawn(async move { - entry.insert(Err(err)).await; + + // This will be overridden by the Rust usage reporting implementation + if let Some(QueryPlannerContent::Plan { plan, .. }) = &content { + context.extensions().with_lock(|lock| { + lock.insert::>(plan.usage_reporting.clone()) + }); + } + Ok(QueryPlannerResponse { content, errors }) + } + Err(MaybeBackPressureError::PermanentError(error)) => { + let e = Arc::new(error); + let err = e.clone(); + tokio::spawn(async move { + entry.insert(Err(err)).await; + }); + if let Some(usage_reporting) = e.usage_reporting() { + context.extensions().with_lock(|lock| { + lock.insert::>(Arc::new(usage_reporting)); }); - if let Some(usage_reporting) = e.usage_reporting() { - context.extensions().with_lock(|mut lock| { - lock.insert::>(Arc::new(usage_reporting)); - }); - } - Err(CacheResolverError::RetrievalError(e)) } + Err(CacheResolverError::RetrievalError(e)) + } + Err(MaybeBackPressureError::TemporaryError(error)) => { + let err = error.clone(); + tokio::spawn(async move { + // Temporary errors are never cached + entry.send(Err(err)).await; + }); + Err(CacheResolverError::Backpressure(error)) } } - .in_current_span(), - ) - .await - .map_err(|e| { + } + .in_current_span(); + + fn convert_join_error(e: impl std::fmt::Display) -> CacheResolverError { CacheResolverError::RetrievalError(Arc::new(QueryPlannerError::JoinError( e.to_string(), ))) - })? + } + + let outcome_recorded = Arc::new(AtomicU8::new(Outcome::None as u8)); + // When cooperative cancellation is enabled, we want to cancel the query planner + // task if the request is canceled. + if self.cooperative_cancellation.is_enabled() { + let planning_task = tokio::task::spawn(planning_task); + let outcome_recorded_for_abort = outcome_recorded.clone(); + let enforce_mode = self.cooperative_cancellation.is_enforce_mode(); + let measure_mode = self.cooperative_cancellation.is_measure_mode(); + let _abort_guard = + scopeguard::guard(planning_task.abort_handle(), move |abort_handle| { + if record_outcome_if_none(&outcome_recorded_for_abort, Outcome::Cancelled) + && enforce_mode + { + abort_handle.abort(); + } + }); + + match self.cooperative_cancellation.timeout() { + Some(timeout) => { + let outcome_recorded_for_timeout = outcome_recorded.clone(); + if enforce_mode { + fn convert_timeout_error( + e: impl std::fmt::Display, + ) -> CacheResolverError { + CacheResolverError::RetrievalError(Arc::new( + QueryPlannerError::Timeout(e.to_string()), + )) + } + + let planning_task_with_timeout = planning_task.timeout(timeout); + let res = planning_task_with_timeout.await; + // If timeout occurred, record outcome (if not already recorded) + if res.is_err() { + record_outcome_if_none( + &outcome_recorded_for_timeout, + Outcome::Timeout, + ); + } + res.map_err(convert_timeout_error)? + } else if measure_mode { + // In measure mode, spawn a timeout task that only records outcome + let timeout_task = tokio::task::spawn(async move { + tokio::time::sleep(timeout).await; + record_outcome_if_none( + &outcome_recorded_for_timeout, + Outcome::Timeout, + ); + }); + let _dropped_timeout_guard = + scopeguard::guard(timeout_task.abort_handle(), |abort_handle| { + abort_handle.abort(); + }); + planning_task.await + } else { + unreachable!( + "Can't set a timeout without enabling cooperative cancellation" + ); + } + } + None => planning_task.await, + } + } else { + // some clients might timeout and cancel the request before query planning is finished, + // so we execute it in a task that can continue even after the request was canceled and + // the join handle was dropped. That way, the next similar query will use the cache instead + // of restarting the query planner until another timeout + tokio::task::spawn(planning_task).await + } + .inspect(|res| { + // We won't reach this code path if the plan was cancelled, and + // thus it won't overwrite the outcome. + match res { + Ok(_) => { + record_outcome_if_none(&outcome_recorded, Outcome::Success); + } + Err(CacheResolverError::RetrievalError(e)) => { + if matches!(e.as_ref(), QueryPlannerError::Timeout(_)) { + record_outcome_if_none(&outcome_recorded, Outcome::Timeout); + } else { + record_outcome_if_none(&outcome_recorded, Outcome::Error); + }; + } + Err(CacheResolverError::Backpressure(_)) => { + record_outcome_if_none(&outcome_recorded, Outcome::Backpressure); + } + Err(CacheResolverError::BatchingError(_)) => { + record_outcome_if_none(&outcome_recorded, Outcome::BatchingError); + } + }; + }) + .map_err(convert_join_error)? } else { - let res = entry - .get() - .await - .map_err(|_| QueryPlannerError::UnhandledPlannerResult)?; + let res = entry.get().await.map_err(|e| match e { + EntryError::IsFirst | // IsFirst should be unreachable + EntryError::RecvError => QueryPlannerError::UnhandledPlannerResult.into(), + EntryError::UncachedError(e) => CacheResolverError::Backpressure(e), + })?; match res { Ok(content) => { if let QueryPlannerContent::Plan { plan, .. } = &content { - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { lock.insert::>(plan.usage_reporting.clone()) }); } @@ -604,7 +750,7 @@ where } Err(error) => { if let Some(usage_reporting) = error.usage_reporting() { - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { lock.insert::>(Arc::new(usage_reporting)); }); } @@ -616,28 +762,21 @@ where } } -fn stats_report_key_hash(stats_report_key: &str) -> String { - let mut hasher = sha1::Sha1::new(); - hasher.update(stats_report_key.as_bytes()); - let result = hasher.finalize(); - hex::encode(result) -} - #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct CachingQueryKey { pub(crate) query: String, - pub(crate) schema_id: Arc, pub(crate) operation: Option, pub(crate) hash: Arc, + // XXX(@goto-bus-stop): It's probably correct to remove this, since having it here is + // misleading. The schema ID is *not* used in the Redis cache, but it's okay because the QueryHash + // is schema-aware. + pub(crate) schema_id: SchemaHash, pub(crate) metadata: CacheKeyMetadata, pub(crate) plan_options: PlanOptions, - pub(crate) config_mode: Arc, + pub(crate) config_mode_hash: Arc, } -// Update this key every time the cache key or the query plan format has to change. -// When changed it MUST BE CALLED OUT PROMINENTLY IN THE CHANGELOG. -const CACHE_KEY_VERSION: usize = 1; -const FEDERATION_VERSION: &str = std::env!("FEDERATION_VERSION"); +const ROUTER_VERSION: &str = env!("CARGO_PKG_VERSION"); impl std::fmt::Display for CachingQueryKey { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -651,13 +790,13 @@ impl std::fmt::Display for CachingQueryKey { "^plan_options".hash(&mut hasher); self.plan_options.hash(&mut hasher); "^config_mode".hash(&mut hasher); - self.config_mode.hash(&mut hasher); + self.config_mode_hash.hash(&mut hasher); let metadata = hex::encode(hasher.finalize()); write!( f, - "plan:cache:{}:federation:{}:{}:opname:{}:metadata:{}", - CACHE_KEY_VERSION, FEDERATION_VERSION, self.hash, operation, metadata, + "plan:router:{}:{}:opname:{}:metadata:{}", + ROUTER_VERSION, self.hash, operation, metadata, ) } } @@ -669,7 +808,7 @@ pub(crate) struct WarmUpCachingQueryKey { pub(crate) hash: Option>, pub(crate) metadata: CacheKeyMetadata, pub(crate) plan_options: PlanOptions, - pub(crate) config_mode: Arc, + pub(crate) config_mode_hash: Arc, } struct StructHasher { @@ -714,20 +853,82 @@ impl ValueType for Result> { #[cfg(test)] mod tests { + use std::collections::HashMap; + use std::time::Duration; + use mockall::mock; - use mockall::predicate::*; - use router_bridge::planner::UsageReporting; + use parking_lot::Mutex; + use serde_json_bytes::json; use test_log::test; use tower::Service; + use tracing::Subscriber; + use tracing_core::Field; + use tracing_subscriber::Layer; + use tracing_subscriber::Registry; + use tracing_subscriber::layer::Context as TracingContext; + use tracing_subscriber::prelude::*; use super::*; - use crate::error::PlanErrors; + use crate::Configuration; + use crate::Context; + use crate::apollo_studio_interop::UsageReporting; + use crate::configuration::QueryPlanning; + use crate::configuration::Supergraph; use crate::json_ext::Object; use crate::query_planner::QueryPlan; use crate::spec::Query; use crate::spec::Schema; - use crate::Configuration; - use crate::Context; + + // Custom layer that records any field updates on spans. + #[derive(Default, Clone)] + struct RecordingLayer { + values: Arc>>, + } + + impl RecordingLayer { + fn get(&self, key: &str) -> Option { + self.values.lock().get(key).cloned() + } + } + + impl Layer for RecordingLayer + where + S: Subscriber, + { + fn on_record( + &self, + _span: &tracing::span::Id, + values: &tracing::span::Record<'_>, + _ctx: TracingContext<'_, S>, + ) { + let mut guard = self.values.lock(); + struct Visitor<'a> { + map: &'a mut HashMap, + } + + impl<'a> tracing_core::field::Visit for Visitor<'a> { + fn record_str(&mut self, field: &Field, value: &str) { + self.map.insert(field.name().to_string(), value.to_string()); + } + + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.map + .insert(field.name().to_string(), format!("{value:?}")); + } + } + + let mut visitor = Visitor { map: &mut guard }; + values.record(&mut visitor); + } + } + + // Helper function to set up tracing for tests + fn setup_tracing() -> (RecordingLayer, tracing::subscriber::DefaultGuard) { + let layer = RecordingLayer::default(); + let subscriber = Registry::default().with(layer.clone()); + let guard = tracing::subscriber::set_default(subscriber); + (layer, guard) + } mock! { #[derive(Debug)] @@ -735,7 +936,7 @@ mod tests { fn sync_call( &self, key: QueryPlannerRequest, - ) -> Result; + ) -> Result>; } impl Clone for MyQueryPlanner { @@ -746,7 +947,7 @@ mod tests { impl Service for MockMyQueryPlanner { type Response = QueryPlannerResponse; - type Error = QueryPlannerError; + type Error = MaybeBackPressureError; type Future = BoxFuture<'static, Result>; @@ -768,15 +969,10 @@ mod tests { let mut delegate = MockMyQueryPlanner::new(); delegate.expect_clone().returning(|| { let mut planner = MockMyQueryPlanner::new(); - planner.expect_sync_call().times(0..2).returning(|_| { - Err(QueryPlannerError::from(PlanErrors { - errors: Default::default(), - usage_reporting: UsageReporting { - stats_report_key: "this is a test key".to_string(), - referenced_fields_by_type: Default::default(), - }, - })) - }); + planner + .expect_sync_call() + .times(0..2) + .returning(|_| Err(QueryPlannerError::UnhandledPlannerResult.into())); planner }); @@ -807,19 +1003,102 @@ mod tests { let context = Context::new(); context .extensions() - .with_lock(|mut lock| lock.insert::(doc1)); + .with_lock(|lock| lock.insert::(doc1)); for _ in 0..5 { - assert!(planner + assert!( + planner + .call(query_planner::CachingRequest::new( + "query Me { me { username } }".to_string(), + Some("".into()), + context.clone() + )) + .await + .is_err() + ); + } + let doc2 = Query::parse_document( + "query Me { me { name { first } } }", + None, + &schema, + &configuration, + ) + .unwrap(); + + let context = Context::new(); + context + .extensions() + .with_lock(|lock| lock.insert::(doc2)); + + assert!( + planner .call(query_planner::CachingRequest::new( - "query Me { me { username } }".to_string(), + "query Me { me { name { first } } }".to_string(), Some("".into()), context.clone() )) .await - .is_err()); + .is_err() + ); + } + + #[test(tokio::test)] + async fn test_cooperative_cancellation_timeout() { + let (layer, _guard) = setup_tracing(); + + #[derive(Clone)] + struct SlowQueryPlanner; + + impl Service for SlowQueryPlanner { + type Response = QueryPlannerResponse; + type Error = MaybeBackPressureError; + type Future = BoxFuture<'static, Result>; + + fn poll_ready( + &mut self, + _cx: &mut task::Context<'_>, + ) -> task::Poll> { + task::Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: QueryPlannerRequest) -> Self::Future { + Box::pin(async move { + tokio::time::sleep(Duration::from_secs(1)).await; + panic!("This query planner should not be called, as it is expected to timeout"); + }) + } } - let doc2 = Query::parse_document( + + let configuration = Configuration::builder() + .and_supergraph(Some( + Supergraph::builder() + .query_planning( + QueryPlanning::builder() + .experimental_cooperative_cancellation( + CooperativeCancellation::enabled_with_timeout( + std::time::Duration::from_secs(1), + ), + ) + .build(), + ) + .build(), + )) + .build() + .expect("configuration is valid"); + let schema = include_str!("testdata/schema.graphql"); + let schema = Arc::new(Schema::parse(schema, &configuration).unwrap()); + + let mut planner = CachingQueryPlanner::new( + SlowQueryPlanner, + schema.clone(), + Default::default(), + &configuration, + IndexMap::default(), + ) + .await + .unwrap(); + + let doc = Query::parse_document( "query Me { me { name { first } } }", None, &schema, @@ -830,16 +1109,259 @@ mod tests { let context = Context::new(); context .extensions() - .with_lock(|mut lock| lock.insert::(doc2)); + .with_lock(|lock| lock.insert::(doc)); + + // Create a span with the outcome field declared + let span = tracing::info_span!("test_span", outcome = tracing::field::Empty); + // Keep the span alive and ensure it's the current span during the entire operation + let _span_guard = span.enter(); - assert!(planner + let result = planner .call(query_planner::CachingRequest::new( "query Me { me { name { first } } }".to_string(), Some("".into()), - context.clone() + context.clone(), )) - .await - .is_err()); + .await; + + match result { + Ok(_) => panic!("Expected an error, but got a response"), + Err(e) => { + assert!(matches!(e, CacheResolverError::RetrievalError(_))); + assert!(e.to_string().contains("timed out")); + } + } + + // Give a small delay to ensure the span is recorded + tokio::time::sleep(Duration::from_millis(10)).await; + + // Verify that the span recorded the timeout outcome + assert_eq!(layer.get("outcome"), Some("timeout".to_string())); + } + + #[test(tokio::test)] + async fn test_cooperative_cancellation_client_drop() { + use std::sync::Arc; + + use tokio::sync::Barrier; + + let (layer, _guard) = setup_tracing(); + let barrier = Arc::new(Barrier::new(2)); + let barrier_clone = barrier.clone(); + + #[derive(Clone)] + struct SlowQueryPlanner { + barrier: Arc, + } + + impl Service for SlowQueryPlanner { + type Response = QueryPlannerResponse; + type Error = MaybeBackPressureError; + type Future = BoxFuture<'static, Result>; + + fn poll_ready( + &mut self, + _cx: &mut task::Context<'_>, + ) -> task::Poll> { + task::Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: QueryPlannerRequest) -> Self::Future { + let barrier = self.barrier.clone(); + Box::pin(async move { + // Signal that we've started + barrier.wait().await; + + // Now sleep for a long time - this should get cancelled + tokio::time::sleep(Duration::from_secs(10)).await; + panic!( + "This query planner should not complete, as it should be cancelled by client drop" + ); + }) + } + } + + let configuration = Configuration::builder() + .and_supergraph(Some( + Supergraph::builder() + .query_planning( + QueryPlanning::builder() + .experimental_cooperative_cancellation( + CooperativeCancellation::enabled(), + ) + .build(), + ) + .build(), + )) + .build() + .expect("configuration is valid"); + let schema = include_str!("testdata/schema.graphql"); + let schema = Arc::new(Schema::parse(schema, &configuration).unwrap()); + + let mut planner = CachingQueryPlanner::new( + SlowQueryPlanner { + barrier: barrier_clone, + }, + schema.clone(), + Default::default(), + &configuration, + IndexMap::default(), + ) + .await + .unwrap(); + + let doc = Query::parse_document( + "query Me { me { name { first } } }", + None, + &schema, + &configuration, + ) + .unwrap(); + + let context = Context::new(); + context + .extensions() + .with_lock(|lock| lock.insert::(doc)); + + // Create a span with the outcome field declared + let span = tracing::info_span!("test_span", outcome = tracing::field::Empty); + + // Keep the span alive and ensure it's the current span during the entire operation + let _span_guard = span.enter(); + + // Spawn the planning task + let planning_task = tokio::spawn(async move { + planner + .call(query_planner::CachingRequest::new( + "query Me { me { name { first } } }".to_string(), + Some("".into()), + context.clone(), + )) + .await + }); + + // Wait for the inner SlowQueryPlanner task to start + barrier.wait().await; + + // Now abort the outer task - the inner task should have definitely started + planning_task.abort(); + + // Verify the task was cancelled + match planning_task.await { + Ok(_) => panic!( + "Expected the task to be aborted due to client drop, but it completed successfully" + ), + Err(e) => assert!(e.is_cancelled(), "Task should be cancelled, got: {e:?}"), + } + + // Give a small delay to ensure the span is recorded + tokio::time::sleep(Duration::from_millis(10)).await; + + // Verify that the span recorded the cancelled outcome + assert_eq!(layer.get("outcome"), Some("cancelled".to_string())); + } + + #[test(tokio::test)] + async fn test_cooperative_cancellation_measurement_mode() { + let (layer, _guard) = setup_tracing(); + + #[derive(Clone)] + struct SlowQueryPlanner; + + impl Service for SlowQueryPlanner { + type Response = QueryPlannerResponse; + type Error = MaybeBackPressureError; + type Future = BoxFuture<'static, Result>; + + fn poll_ready( + &mut self, + _cx: &mut task::Context<'_>, + ) -> task::Poll> { + task::Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: QueryPlannerRequest) -> Self::Future { + Box::pin(async move { + // Sleep for a long time - this should trigger timeout in measurement mode + tokio::time::sleep(Duration::from_secs(2)).await; + // In measurement mode, this should complete successfully even after timeout + let plan = Arc::new(QueryPlan::fake_new(None, None)); + Ok(QueryPlannerResponse::builder() + .content(QueryPlannerContent::Plan { plan }) + .build()) + }) + } + } + + let configuration = Configuration::builder() + .and_supergraph(Some( + Supergraph::builder() + .query_planning( + QueryPlanning::builder() + .experimental_cooperative_cancellation( + CooperativeCancellation::measure_with_timeout( + std::time::Duration::from_millis(100), + ), + ) + .build(), + ) + .build(), + )) + .build() + .expect("configuration is valid"); + let schema = include_str!("testdata/schema.graphql"); + let schema = Arc::new(Schema::parse(schema, &configuration).unwrap()); + + let mut planner = CachingQueryPlanner::new( + SlowQueryPlanner, + schema.clone(), + Default::default(), + &configuration, + IndexMap::default(), + ) + .await + .unwrap(); + + let doc = Query::parse_document( + "query Me { me { name { first } } }", + None, + &schema, + &configuration, + ) + .unwrap(); + + let context = Context::new(); + context + .extensions() + .with_lock(|lock| lock.insert::(doc)); + + // Create a span with the outcome field declared + let span = tracing::info_span!("test_span", outcome = tracing::field::Empty); + // Keep the span alive and ensure it's the current span during the entire operation + let _span_guard = span.enter(); + + // In measurement mode, the request should complete successfully even though it times out + // The timeout should be recorded as an outcome, but the request should not fail + let result = planner + .call(query_planner::CachingRequest::new( + "query Me { me { name { first } } }".to_string(), + Some("".into()), + context.clone(), + )) + .await; + + // In measurement mode, the request should succeed even though it times out + assert!( + result.is_ok(), + "Expected success in measurement mode, got error" + ); + + // Give a small delay to ensure the span is recorded + tokio::time::sleep(Duration::from_millis(10)).await; + + // Verify that the span recorded the timeout outcome (not success) + // In measurement mode, we should record timeout and not overwrite it with success + assert_eq!(layer.get("outcome"), Some("timeout".to_string())); } macro_rules! test_query_plan { @@ -857,12 +1379,9 @@ mod tests { let query_plan: QueryPlan = QueryPlan { formatted_query_plan: Default::default(), root: serde_json::from_str(test_query_plan!()).unwrap(), - usage_reporting: UsageReporting { - stats_report_key: "this is a test report key".to_string(), - referenced_fields_by_type: Default::default(), - } - .into(), - query: Arc::new(Query::empty()), + usage_reporting: UsageReporting::Error("this is a test report key".to_string()) + .into(), + query: Arc::new(Query::empty_for_tests()), query_metrics: Default::default(), estimated_size: Default::default(), }; @@ -898,10 +1417,10 @@ mod tests { .await .unwrap(); - let context = Context::new(); + let context = crate::Context::new(); context .extensions() - .with_lock(|mut lock| lock.insert::(doc)); + .with_lock(|lock| lock.insert::(doc)); for _ in 0..5 { let _ = planner @@ -912,20 +1431,14 @@ mod tests { )) .await .unwrap(); - assert!(context - .extensions() - .with_lock(|lock| lock.contains_key::>())); + assert!( + context + .extensions() + .with_lock(|lock| lock.contains_key::>()) + ); } } - #[test] - fn apollo_operation_id_hash() { - assert_eq!( - "d1554552698157b05c2a462827fb4367a4548ee5", - stats_report_key_hash("# IgnitionMeQuery\nquery IgnitionMeQuery{me{id}}") - ); - } - #[test(tokio::test)] async fn test_introspection_cache() { let mut delegate = MockMyQueryPlanner::new(); @@ -983,38 +1496,280 @@ mod tests { let context = Context::new(); context .extensions() - .with_lock(|mut lock| lock.insert::(doc1)); + .with_lock(|lock| lock.insert::(doc1)); - assert!(planner - .call(query_planner::CachingRequest::new( - "{ + assert!( + planner + .call(query_planner::CachingRequest::new( + "{ __schema { types { name } } }" - .to_string(), - Some("".into()), - context.clone(), - )) - .await - .is_ok()); + .to_string(), + Some("".into()), + context.clone(), + )) + .await + .is_ok() + ); - assert!(planner - .call(query_planner::CachingRequest::new( - "{ + assert!( + planner + .call(query_planner::CachingRequest::new( + "{ __schema { types { name } } }" + .to_string(), + Some("".into()), + context.clone(), + )) + .await + .is_ok() + ); + } + + // Expect that if we call the CQP twice, the second call will return cached data + #[test(tokio::test)] + async fn test_cache_works() { + let mut delegate = MockMyQueryPlanner::new(); + delegate.expect_clone().times(2).returning(|| { + let mut planner = MockMyQueryPlanner::new(); + planner + .expect_sync_call() + // Don't allow the delegate to be called more than once + .times(1) + .returning(|_| { + let qp_content = QueryPlannerContent::CachedIntrospectionResponse { + response: Box::new( + crate::graphql::Response::builder() + .data(json!(r#"{"data":{"me":{"name":"Ada Lovelace"}}}%"#)) + .build(), + ), + }; + + Ok(QueryPlannerResponse::builder().content(qp_content).build()) + }); + planner + }); + + let configuration = Default::default(); + let schema = include_str!("../testdata/starstuff@current.graphql"); + let schema = Arc::new(Schema::parse(schema, &configuration).unwrap()); + + let mut planner = CachingQueryPlanner::new( + delegate, + schema.clone(), + Default::default(), + &configuration, + IndexMap::default(), + ) + .await + .unwrap(); + + let doc = Query::parse_document( + "query ExampleQuery { me { name } }", + None, + &schema, + &configuration, + ) + .unwrap(); + let context = Context::new(); + context + .extensions() + .with_lock(|lock| lock.insert::(doc)); + + let _ = planner + .call(query_planner::CachingRequest::new( + "query ExampleQuery { + me { + name + } + }" .to_string(), - Some("".into()), + None, + context.clone(), + )) + .await + .unwrap(); + + let _ = planner + .call(query_planner::CachingRequest::new( + "query ExampleQuery { + me { + name + } + }" + .to_string(), + None, + context.clone(), + )) + .await + .unwrap(); + } + + #[test(tokio::test)] + async fn test_temporary_errors_arent_cached() { + let mut delegate = MockMyQueryPlanner::new(); + delegate + .expect_clone() + // We're calling the caching QP twice, so we expect the delegate to be cloned twice + .times(2) + .returning(|| { + // Expect each clone to be called once since the return value isn't cached + let mut planner = MockMyQueryPlanner::new(); + planner.expect_sync_call().times(1).returning(|_| { + Err(MaybeBackPressureError::TemporaryError( + ComputeBackPressureError, + )) + }); + planner + }); + + let configuration = Default::default(); + let schema = include_str!("../testdata/starstuff@current.graphql"); + let schema = Arc::new(Schema::parse(schema, &configuration).unwrap()); + + let mut planner = CachingQueryPlanner::new( + delegate, + schema.clone(), + Default::default(), + &configuration, + IndexMap::default(), + ) + .await + .unwrap(); + + let doc = Query::parse_document( + "query ExampleQuery { me { name } }", + None, + &schema, + &configuration, + ) + .unwrap(); + + let context = Context::new(); + context + .extensions() + .with_lock(|lock| lock.insert::(doc)); + + let r = planner + .call(query_planner::CachingRequest::new( + "query ExampleQuery { + me { + name + } + }" + .to_string(), + None, + context.clone(), + )) + .await; + + let r2 = planner + .call(query_planner::CachingRequest::new( + "query ExampleQuery { + me { + name + } + }" + .to_string(), + None, context.clone(), )) + .await; + + if let (Err(e), Err(e2)) = (r, r2) { + assert_eq!(e.to_string(), e2.to_string()); + } else { + panic!("Expected both calls to return same error"); + } + } + + #[tokio::test] + async fn test_cache_warmup() { + let create_delegate = |call_count| { + let mut delegate = MockMyQueryPlanner::new(); + delegate.expect_clone().times(1).returning(move || { + let mut planner = MockMyQueryPlanner::new(); + planner.expect_sync_call().times(call_count).returning(|_| { + let plan = Arc::new(QueryPlan::fake_new(None, None)); + Ok(QueryPlannerResponse::builder() + .content(QueryPlannerContent::Plan { plan }) + .build()) + }); + planner + }); + delegate + }; + + let configuration: Configuration = Default::default(); + let schema = Arc::new( + Schema::parse( + include_str!("../testdata/starstuff@current.graphql"), + &configuration, + ) + .unwrap(), + ); + + let create_planner = async |delegate| { + CachingQueryPlanner::new( + delegate, + schema.clone(), + Default::default(), + &configuration, + IndexMap::default(), + ) .await - .is_ok()); + .unwrap() + }; + + let create_request = || { + let query_str = "query ExampleQuery { me { name } }".to_string(); + let doc = Query::parse_document(&query_str, None, &schema, &configuration).unwrap(); + let context = Context::new(); + context + .extensions() + .with_lock(|lock| lock.insert::(doc)); + query_planner::CachingRequest::new(query_str, None, context) + }; + + // send query to caching planner. it should save this query plan in its cache + let mut planner = create_planner(create_delegate(1)).await; + let response = planner.call(create_request()).await.unwrap(); + assert!(response.content.is_some()); + assert_eq!(planner.cache.len().await, 1); + + // create and warm up a new planner. new planner's delegate should be called once during + // the warm-up phase to populate the cache + let query_analysis_layer = + QueryAnalysisLayer::new(schema.clone(), Arc::new(configuration.clone())).await; + let mut new_planner = create_planner(create_delegate(1)).await; + new_planner + .warm_up( + &query_analysis_layer, + &Arc::new(PersistedQueryLayer::new(&configuration).await.unwrap()), + Some(planner.previous_cache()), + Some(1), + Default::default(), + &Default::default(), + ) + .await; + // wait a beat - items are added to cache asynchronously, so this helps avoid flakiness + tokio::time::sleep(Duration::from_millis(10)).await; + assert_eq!(new_planner.cache.len().await, 1); + + // create a new delegate that _shouldn't_ be called since the new planner already has the + // result in its cache + new_planner.delegate = create_delegate(0); + let response = new_planner.call(create_request()).await.unwrap(); + assert!(response.content.is_some()); + assert_eq!(new_planner.cache.len().await, 1); } } diff --git a/apollo-router/src/query_planner/convert.rs b/apollo-router/src/query_planner/convert.rs index 27592834c6..515f0caaf5 100644 --- a/apollo-router/src/query_planner/convert.rs +++ b/apollo-router/src/query_planner/convert.rs @@ -1,12 +1,9 @@ use std::sync::Arc; -use apollo_compiler::executable; use apollo_federation::query_plan as next; -use crate::query_planner::fetch::SubgraphOperation; use crate::query_planner::plan; use crate::query_planner::rewrites; -use crate::query_planner::selection; use crate::query_planner::subscription; pub(crate) fn convert_root_query_plan_node(js: &next::QueryPlan) -> Option { @@ -75,10 +72,9 @@ impl From<&'_ Box> for plan::PlanNode { } = &**value; Self::Fetch(super::fetch::FetchNode { service_name: subgraph_name.clone(), - requires: requires.as_deref().map(vec).unwrap_or_default(), + requires: requires.clone(), variable_usages: variable_usages.iter().map(|v| v.clone().into()).collect(), - // TODO: use Arc in apollo_federation to avoid this clone - operation: SubgraphOperation::from_parsed(Arc::new(operation_document.clone())), + operation: operation_document.clone(), operation_name: operation_name.clone().map(|n| n.into()), operation_kind: (*operation_kind).into(), id: id.map(|id| id.to_string()), @@ -157,8 +153,7 @@ impl From<&'_ next::FetchNode> for subscription::SubscriptionNode { Self { service_name: subgraph_name.clone(), variable_usages: variable_usages.iter().map(|v| v.clone().into()).collect(), - // TODO: use Arc in apollo_federation to avoid this clone - operation: SubgraphOperation::from_parsed(Arc::new(operation_document.clone())), + operation: operation_document.clone(), operation_name: operation_name.clone().map(|n| n.into()), operation_kind: (*operation_kind).into(), input_rewrites: option_vec(input_rewrites), @@ -175,7 +170,7 @@ impl From<&'_ next::PrimaryDeferBlock> for plan::Primary { } = value; Self { node: option(node).map(Box::new), - subselection: sub_selection.as_ref().map(|s| s.to_string()), + subselection: sub_selection.clone(), } } } @@ -195,24 +190,21 @@ impl From<&'_ next::DeferredDeferBlock> for plan::DeferredNode { query_path: crate::json_ext::Path( query_path .iter() - .filter_map(|e| match e { - next::QueryPathElement::Field(field) => Some( - // TODO: type conditioned fetching once it s available in the rust planner - crate::graphql::JsonPathElement::Key( - field.response_key().to_string(), - None, - ), - ), - next::QueryPathElement::InlineFragment(inline) => { - inline.type_condition.as_ref().map(|cond| { - crate::graphql::JsonPathElement::Fragment(cond.to_string()) - }) + .map(|e| match e { + next::QueryPathElement::Field { response_key } => + // TODO: type conditioned fetching once it s available in the rust planner + { + crate::graphql::JsonPathElement::Key(response_key.to_string(), None) + } + + next::QueryPathElement::InlineFragment { type_condition } => { + crate::graphql::JsonPathElement::Fragment(type_condition.to_string()) } }) .collect(), ), node: option(node).map(Arc::new), - subselection: sub_selection.as_ref().map(|s| s.to_string()), + subselection: sub_selection.clone(), } } } @@ -224,54 +216,6 @@ impl From<&'_ next::DeferredDependency> for plan::Depends { } } -impl From<&'_ executable::Selection> for selection::Selection { - fn from(value: &'_ executable::Selection) -> Self { - match value { - executable::Selection::Field(field) => Self::Field(field.as_ref().into()), - executable::Selection::InlineFragment(inline) => { - Self::InlineFragment(inline.as_ref().into()) - } - executable::Selection::FragmentSpread(_) => unreachable!(), - } - } -} - -impl From<&'_ executable::Field> for selection::Field { - fn from(value: &'_ executable::Field) -> Self { - let executable::Field { - definition: _, - alias, - name, - arguments: _, - directives: _, - selection_set, - } = value; - Self { - alias: alias.clone(), - name: name.clone(), - selections: if selection_set.selections.is_empty() { - None - } else { - Some(vec(&selection_set.selections)) - }, - } - } -} - -impl From<&'_ executable::InlineFragment> for selection::InlineFragment { - fn from(value: &'_ executable::InlineFragment) -> Self { - let executable::InlineFragment { - type_condition, - directives: _, - selection_set, - } = value; - Self { - type_condition: type_condition.clone(), - selections: vec(&selection_set.selections), - } - } -} - impl From<&'_ Arc> for rewrites::DataRewrite { fn from(value: &'_ Arc) -> Self { match value.as_ref() { @@ -310,20 +254,17 @@ impl From<&'_ next::FetchDataPathElement> for crate::json_ext::PathElement { match value { next::FetchDataPathElement::Key(name, conditions) => Self::Key( name.to_string(), - if conditions.is_empty() { - None - } else { - Some(conditions.iter().map(|c| c.to_string()).collect()) - }, + conditions + .as_ref() + .map(|conditions| conditions.iter().map(|c| c.to_string()).collect()), + ), + next::FetchDataPathElement::AnyIndex(conditions) => Self::Flatten( + conditions + .as_ref() + .map(|conditions| conditions.iter().map(|c| c.to_string()).collect()), ), - next::FetchDataPathElement::AnyIndex(conditions) => { - Self::Flatten(if conditions.is_empty() { - None - } else { - Some(conditions.iter().map(|c| c.to_string()).collect()) - }) - } next::FetchDataPathElement::TypenameEquals(value) => Self::Fragment(value.to_string()), + next::FetchDataPathElement::Parent => Self::Key("..".to_owned(), None), } } } diff --git a/apollo-router/src/query_planner/dual_query_planner.rs b/apollo-router/src/query_planner/dual_query_planner.rs deleted file mode 100644 index 6b409b44ae..0000000000 --- a/apollo-router/src/query_planner/dual_query_planner.rs +++ /dev/null @@ -1,1323 +0,0 @@ -//! Running two query planner implementations and comparing their results - -use std::borrow::Borrow; -use std::collections::hash_map::HashMap; -use std::fmt::Write; -use std::hash::DefaultHasher; -use std::hash::Hash; -use std::hash::Hasher; -use std::sync::Arc; -use std::sync::OnceLock; -use std::time::Instant; - -use apollo_compiler::ast; -use apollo_compiler::validation::Valid; -use apollo_compiler::ExecutableDocument; -use apollo_compiler::Name; -use apollo_federation::query_plan::query_planner::QueryPlanOptions; -use apollo_federation::query_plan::query_planner::QueryPlanner; -use apollo_federation::query_plan::QueryPlan; - -use super::fetch::FetchNode; -use super::fetch::SubgraphOperation; -use super::subscription::SubscriptionNode; -use super::FlattenNode; -use crate::error::format_bridge_errors; -use crate::executable::USING_CATCH_UNWIND; -use crate::query_planner::bridge_query_planner::metric_query_planning_plan_duration; -use crate::query_planner::bridge_query_planner::JS_QP_MODE; -use crate::query_planner::bridge_query_planner::RUST_QP_MODE; -use crate::query_planner::convert::convert_root_query_plan_node; -use crate::query_planner::render_diff; -use crate::query_planner::rewrites::DataRewrite; -use crate::query_planner::selection::Selection; -use crate::query_planner::DeferredNode; -use crate::query_planner::PlanNode; -use crate::query_planner::Primary; -use crate::query_planner::QueryPlanResult; - -/// Jobs are dropped if this many are already queued -const QUEUE_SIZE: usize = 10; -const WORKER_THREAD_COUNT: usize = 1; - -pub(crate) struct BothModeComparisonJob { - pub(crate) rust_planner: Arc, - pub(crate) js_duration: f64, - pub(crate) document: Arc>, - pub(crate) operation_name: Option, - pub(crate) js_result: Result>>, - pub(crate) plan_options: QueryPlanOptions, -} - -type Queue = crossbeam_channel::Sender; - -static QUEUE: OnceLock = OnceLock::new(); - -fn queue() -> &'static Queue { - QUEUE.get_or_init(|| { - let (sender, receiver) = crossbeam_channel::bounded::(QUEUE_SIZE); - for _ in 0..WORKER_THREAD_COUNT { - let job_receiver = receiver.clone(); - std::thread::spawn(move || { - for job in job_receiver { - job.execute() - } - }); - } - sender - }) -} - -impl BothModeComparisonJob { - pub(crate) fn schedule(self) { - // We use a bounded queue: try_send returns an error when full. This is fine. - // We prefer dropping some comparison jobs and only gathering some of the data - // rather than consume too much resources. - // - // Either way we move on and let this thread continue proceed with the query plan from JS. - let _ = queue().try_send(self).is_err(); - } - - fn execute(self) { - // TODO: once the Rust query planner does not use `todo!()` anymore, - // remove `USING_CATCH_UNWIND` and this use of `catch_unwind`. - let rust_result = std::panic::catch_unwind(|| { - let name = self - .operation_name - .clone() - .map(Name::try_from) - .transpose()?; - USING_CATCH_UNWIND.set(true); - - let start = Instant::now(); - - // No question mark operator or macro from here … - let result = - self.rust_planner - .build_query_plan(&self.document, name, self.plan_options); - - let elapsed = start.elapsed().as_secs_f64(); - metric_query_planning_plan_duration(RUST_QP_MODE, elapsed); - - metric_query_planning_plan_both_comparison_duration(RUST_QP_MODE, elapsed); - metric_query_planning_plan_both_comparison_duration(JS_QP_MODE, self.js_duration); - - // … to here, so the thread can only eiher reach here or panic. - // We unset USING_CATCH_UNWIND in both cases. - USING_CATCH_UNWIND.set(false); - result - }) - .unwrap_or_else(|panic| { - USING_CATCH_UNWIND.set(false); - Err(apollo_federation::error::FederationError::internal( - format!( - "query planner panicked: {}", - panic - .downcast_ref::() - .map(|s| s.as_str()) - .or_else(|| panic.downcast_ref::<&str>().copied()) - .unwrap_or_default() - ), - )) - }); - - let name = self.operation_name.as_deref(); - let operation_desc = if let Ok(operation) = self.document.operations.get(name) { - if let Some(parsed_name) = &operation.name { - format!(" in {} `{parsed_name}`", operation.operation_type) - } else { - format!(" in anonymous {}", operation.operation_type) - } - } else { - String::new() - }; - - let is_matched; - match (&self.js_result, &rust_result) { - (Err(js_errors), Ok(_)) => { - tracing::warn!( - "JS query planner error{operation_desc}: {}", - format_bridge_errors(js_errors) - ); - is_matched = false; - } - (Ok(_), Err(rust_error)) => { - tracing::warn!("Rust query planner error{operation_desc}: {}", rust_error); - is_matched = false; - } - (Err(_), Err(_)) => { - is_matched = true; - } - - (Ok(js_plan), Ok(rust_plan)) => { - let js_root_node = &js_plan.query_plan.node; - let rust_root_node = convert_root_query_plan_node(rust_plan); - let match_result = opt_plan_node_matches(js_root_node, &rust_root_node); - is_matched = match_result.is_ok(); - match match_result { - Ok(_) => tracing::trace!("JS and Rust query plans match{operation_desc}! 🎉"), - Err(err) => { - tracing::debug!("JS v.s. Rust query plan mismatch{operation_desc}"); - tracing::debug!("{}", err.full_description()); - tracing::debug!( - "Diff of formatted plans:\n{}", - diff_plan(js_plan, rust_plan) - ); - tracing::trace!("JS query plan Debug: {js_root_node:#?}"); - tracing::trace!("Rust query plan Debug: {rust_root_node:#?}"); - } - } - } - } - - u64_counter!( - "apollo.router.operations.query_planner.both", - "Comparing JS v.s. Rust query plans", - 1, - "generation.is_matched" = is_matched, - "generation.js_error" = self.js_result.is_err(), - "generation.rust_error" = rust_result.is_err() - ); - } -} - -pub(crate) fn metric_query_planning_plan_both_comparison_duration( - planner: &'static str, - elapsed: f64, -) { - f64_histogram!( - "apollo.router.operations.query_planner.both.duration", - "Comparing JS v.s. Rust query plan duration.", - elapsed, - "planner" = planner - ); -} - -// Specific comparison functions - -pub struct MatchFailure { - description: String, - backtrace: std::backtrace::Backtrace, -} - -impl MatchFailure { - pub fn description(&self) -> String { - self.description.clone() - } - - pub fn full_description(&self) -> String { - format!("{}\n\nBacktrace:\n{}", self.description, self.backtrace) - } - - fn new(description: String) -> MatchFailure { - MatchFailure { - description, - backtrace: std::backtrace::Backtrace::force_capture(), - } - } - - fn add_description(self: MatchFailure, description: &str) -> MatchFailure { - MatchFailure { - description: format!("{}\n{}", self.description, description), - backtrace: self.backtrace, - } - } -} - -macro_rules! check_match { - ($pred:expr) => { - if !$pred { - return Err(MatchFailure::new(format!( - "mismatch at {}", - stringify!($pred) - ))); - } - }; -} - -macro_rules! check_match_eq { - ($a:expr, $b:expr) => { - if $a != $b { - let message = format!( - "mismatch between {} and {}:\nleft: {:?}\nright: {:?}", - stringify!($a), - stringify!($b), - $a, - $b - ); - return Err(MatchFailure::new(message)); - } - }; -} - -fn fetch_node_matches(this: &FetchNode, other: &FetchNode) -> Result<(), MatchFailure> { - let FetchNode { - service_name, - requires, - variable_usages, - operation, - operation_name: _, // ignored (reordered parallel fetches may have different names) - operation_kind, - id, - input_rewrites, - output_rewrites, - context_rewrites, - schema_aware_hash: _, // ignored - authorization, - } = this; - - check_match_eq!(*service_name, other.service_name); - check_match_eq!(*operation_kind, other.operation_kind); - check_match_eq!(*id, other.id); - check_match_eq!(*authorization, other.authorization); - check_match!(same_requires(requires, &other.requires)); - check_match!(vec_matches_sorted(variable_usages, &other.variable_usages)); - check_match!(same_rewrites(input_rewrites, &other.input_rewrites)); - check_match!(same_rewrites(output_rewrites, &other.output_rewrites)); - check_match!(same_rewrites(context_rewrites, &other.context_rewrites)); - operation_matches(operation, &other.operation)?; - Ok(()) -} - -fn subscription_primary_matches( - this: &SubscriptionNode, - other: &SubscriptionNode, -) -> Result<(), MatchFailure> { - let SubscriptionNode { - service_name, - variable_usages, - operation, - operation_name: _, // ignored (reordered parallel fetches may have different names) - operation_kind, - input_rewrites, - output_rewrites, - } = this; - check_match_eq!(*service_name, other.service_name); - check_match_eq!(*operation_kind, other.operation_kind); - check_match!(vec_matches_sorted(variable_usages, &other.variable_usages)); - check_match!(same_rewrites(input_rewrites, &other.input_rewrites)); - check_match!(same_rewrites(output_rewrites, &other.output_rewrites)); - operation_matches(operation, &other.operation)?; - Ok(()) -} - -fn operation_matches( - this: &SubgraphOperation, - other: &SubgraphOperation, -) -> Result<(), MatchFailure> { - document_str_matches(this.as_serialized(), other.as_serialized()) -} - -// Compare operation document strings such as query or just selection set. -fn document_str_matches(this: &str, other: &str) -> Result<(), MatchFailure> { - let this_ast = match ast::Document::parse(this, "this_operation.graphql") { - Ok(document) => document, - Err(_) => { - return Err(MatchFailure::new( - "Failed to parse this operation".to_string(), - )); - } - }; - let other_ast = match ast::Document::parse(other, "other_operation.graphql") { - Ok(document) => document, - Err(_) => { - return Err(MatchFailure::new( - "Failed to parse other operation".to_string(), - )); - } - }; - same_ast_document(&this_ast, &other_ast) -} - -fn opt_document_string_matches( - this: &Option, - other: &Option, -) -> Result<(), MatchFailure> { - match (this, other) { - (None, None) => Ok(()), - (Some(this_sel), Some(other_sel)) => document_str_matches(this_sel, other_sel), - _ => Err(MatchFailure::new(format!( - "mismatched at opt_document_string_matches\nleft: {:?}\nright: {:?}", - this, other - ))), - } -} - -// The rest is calling the comparison functions above instead of `PartialEq`, -// but otherwise behave just like `PartialEq`: - -// Note: Reexported under `apollo_router::_private` -pub fn plan_matches(js_plan: &QueryPlanResult, rust_plan: &QueryPlan) -> Result<(), MatchFailure> { - let js_root_node = &js_plan.query_plan.node; - let rust_root_node = convert_root_query_plan_node(rust_plan); - opt_plan_node_matches(js_root_node, &rust_root_node) -} - -pub fn diff_plan(js_plan: &QueryPlanResult, rust_plan: &QueryPlan) -> String { - let js_root_node = &js_plan.query_plan.node; - let rust_root_node = convert_root_query_plan_node(rust_plan); - - match (js_root_node, rust_root_node) { - (None, None) => String::from(""), - (None, Some(rust)) => { - let rust = &format!("{rust:#?}"); - let differences = diff::lines("", rust); - render_diff(&differences) - } - (Some(js), None) => { - let js = &format!("{js:#?}"); - let differences = diff::lines(js, ""); - render_diff(&differences) - } - (Some(js), Some(rust)) => { - let rust = &format!("{rust:#?}"); - let js = &format!("{js:#?}"); - let differences = diff::lines(js, rust); - render_diff(&differences) - } - } -} - -fn opt_plan_node_matches( - this: &Option>, - other: &Option>, -) -> Result<(), MatchFailure> { - match (this, other) { - (None, None) => Ok(()), - (None, Some(_)) | (Some(_), None) => Err(MatchFailure::new(format!( - "mismatch at opt_plan_node_matches\nleft: {:?}\nright: {:?}", - this.is_some(), - other.is_some() - ))), - (Some(this), Some(other)) => plan_node_matches(this.borrow(), other.borrow()), - } -} - -//================================================================================================== -// Vec comparison functions - -fn vec_matches(this: &[T], other: &[T], item_matches: impl Fn(&T, &T) -> bool) -> bool { - this.len() == other.len() - && std::iter::zip(this, other).all(|(this, other)| item_matches(this, other)) -} - -fn vec_matches_result( - this: &[T], - other: &[T], - item_matches: impl Fn(&T, &T) -> Result<(), MatchFailure>, -) -> Result<(), MatchFailure> { - check_match_eq!(this.len(), other.len()); - std::iter::zip(this, other) - .enumerate() - .try_fold((), |_acc, (index, (this, other))| { - item_matches(this, other) - .map_err(|err| err.add_description(&format!("under item[{}]", index))) - })?; - Ok(()) -} - -fn vec_matches_sorted(this: &[T], other: &[T]) -> bool { - let mut this_sorted = this.to_owned(); - let mut other_sorted = other.to_owned(); - this_sorted.sort(); - other_sorted.sort(); - vec_matches(&this_sorted, &other_sorted, T::eq) -} - -fn vec_matches_sorted_by( - this: &[T], - other: &[T], - compare: impl Fn(&T, &T) -> std::cmp::Ordering, - item_matches: impl Fn(&T, &T) -> bool, -) -> bool { - let mut this_sorted = this.to_owned(); - let mut other_sorted = other.to_owned(); - this_sorted.sort_by(&compare); - other_sorted.sort_by(&compare); - vec_matches(&this_sorted, &other_sorted, item_matches) -} - -fn vec_matches_result_sorted_by( - this: &[T], - other: &[T], - compare: impl Fn(&T, &T) -> std::cmp::Ordering, - item_matches: impl Fn(&T, &T) -> Result<(), MatchFailure>, -) -> Result<(), MatchFailure> { - check_match_eq!(this.len(), other.len()); - let mut this_sorted = this.to_owned(); - let mut other_sorted = other.to_owned(); - this_sorted.sort_by(&compare); - other_sorted.sort_by(&compare); - std::iter::zip(&this_sorted, &other_sorted) - .try_fold((), |_acc, (this, other)| item_matches(this, other))?; - Ok(()) -} - -// `this` vector includes `other` vector as a set -fn vec_includes_as_set(this: &[T], other: &[T], item_matches: impl Fn(&T, &T) -> bool) -> bool { - other.iter().all(|other_node| { - this.iter() - .any(|this_node| item_matches(this_node, other_node)) - }) -} - -// performs a set comparison, ignoring order -fn vec_matches_as_set(this: &[T], other: &[T], item_matches: impl Fn(&T, &T) -> bool) -> bool { - // Set-inclusion test in both directions - this.len() == other.len() - && vec_includes_as_set(this, other, &item_matches) - && vec_includes_as_set(other, this, &item_matches) -} - -// Forward/reverse mappings from one Vec items (indices) to another. -type VecMapping = (HashMap, HashMap); - -// performs a set comparison, ignoring order -// and returns a mapping from `this` to `other`. -fn vec_matches_as_set_with_mapping( - this: &[T], - other: &[T], - item_matches: impl Fn(&T, &T) -> bool, -) -> VecMapping { - // Set-inclusion test in both directions - // - record forward/reverse mapping from this items <-> other items for reporting mismatches - let mut forward_map: HashMap = HashMap::new(); - let mut reverse_map: HashMap = HashMap::new(); - for (this_pos, this_node) in this.iter().enumerate() { - if let Some(other_pos) = other - .iter() - .position(|other_node| item_matches(this_node, other_node)) - { - forward_map.insert(this_pos, other_pos); - reverse_map.insert(other_pos, this_pos); - } - } - for (other_pos, other_node) in other.iter().enumerate() { - if reverse_map.contains_key(&other_pos) { - continue; - } - if let Some(this_pos) = this - .iter() - .position(|this_node| item_matches(this_node, other_node)) - { - forward_map.insert(this_pos, other_pos); - reverse_map.insert(other_pos, this_pos); - } - } - (forward_map, reverse_map) -} - -// Returns a formatted mismatch message and an optional pair of mismatched positions if the pair -// are the only remaining unmatched items. -fn format_mismatch_as_set( - this_len: usize, - other_len: usize, - forward_map: &HashMap, - reverse_map: &HashMap, -) -> Result<(String, Option<(usize, usize)>), std::fmt::Error> { - let mut ret = String::new(); - let buf = &mut ret; - write!(buf, "- mapping from left to right: [")?; - let mut this_missing_pos = None; - for this_pos in 0..this_len { - if this_pos != 0 { - write!(buf, ", ")?; - } - if let Some(other_pos) = forward_map.get(&this_pos) { - write!(buf, "{}", other_pos)?; - } else { - this_missing_pos = Some(this_pos); - write!(buf, "?")?; - } - } - writeln!(buf, "]")?; - - write!(buf, "- left-over on the right: [")?; - let mut other_missing_count = 0; - let mut other_missing_pos = None; - for other_pos in 0..other_len { - if reverse_map.get(&other_pos).is_none() { - if other_missing_count != 0 { - write!(buf, ", ")?; - } - other_missing_count += 1; - other_missing_pos = Some(other_pos); - write!(buf, "{}", other_pos)?; - } - } - write!(buf, "]")?; - let unmatched_pair = if let (Some(this_missing_pos), Some(other_missing_pos)) = - (this_missing_pos, other_missing_pos) - { - if this_len == 1 + forward_map.len() && other_len == 1 + reverse_map.len() { - // Special case: There are only one missing item on each side. They are supposed to - // match each other. - Some((this_missing_pos, other_missing_pos)) - } else { - None - } - } else { - None - }; - Ok((ret, unmatched_pair)) -} - -fn vec_matches_result_as_set( - this: &[T], - other: &[T], - item_matches: impl Fn(&T, &T) -> Result<(), MatchFailure>, -) -> Result { - // Set-inclusion test in both directions - // - record forward/reverse mapping from this items <-> other items for reporting mismatches - let (forward_map, reverse_map) = - vec_matches_as_set_with_mapping(this, other, |a, b| item_matches(a, b).is_ok()); - if forward_map.len() == this.len() && reverse_map.len() == other.len() { - Ok((forward_map, reverse_map)) - } else { - // report mismatch - let Ok((message, unmatched_pair)) = - format_mismatch_as_set(this.len(), other.len(), &forward_map, &reverse_map) - else { - // Exception: Unable to format mismatch report => fallback to most generic message - return Err(MatchFailure::new( - "mismatch at vec_matches_result_as_set (failed to format mismatched sets)" - .to_string(), - )); - }; - if let Some(unmatched_pair) = unmatched_pair { - // found a unique pair to report => use that pair's error message - let Err(err) = item_matches(&this[unmatched_pair.0], &other[unmatched_pair.1]) else { - // Exception: Unable to format unique pair mismatch error => fallback to overall report - return Err(MatchFailure::new(format!( - "mismatched sets (failed to format unique pair mismatch error):\n{}", - message - ))); - }; - Err(err.add_description(&format!( - "under a sole unmatched pair ({} -> {}) in a set comparison", - unmatched_pair.0, unmatched_pair.1 - ))) - } else { - Err(MatchFailure::new(format!("mismatched sets:\n{}", message))) - } - } -} - -//================================================================================================== -// PlanNode comparison functions - -fn option_to_string(name: Option) -> String { - name.map_or_else(|| "".to_string(), |name| name.to_string()) -} - -fn plan_node_matches(this: &PlanNode, other: &PlanNode) -> Result<(), MatchFailure> { - match (this, other) { - (PlanNode::Sequence { nodes: this }, PlanNode::Sequence { nodes: other }) => { - vec_matches_result(this, other, plan_node_matches) - .map_err(|err| err.add_description("under Sequence node"))?; - } - (PlanNode::Parallel { nodes: this }, PlanNode::Parallel { nodes: other }) => { - vec_matches_result_as_set(this, other, plan_node_matches) - .map_err(|err| err.add_description("under Parallel node"))?; - } - (PlanNode::Fetch(this), PlanNode::Fetch(other)) => { - fetch_node_matches(this, other).map_err(|err| { - err.add_description(&format!( - "under Fetch node (operation name: {})", - option_to_string(this.operation_name.as_ref()) - )) - })?; - } - (PlanNode::Flatten(this), PlanNode::Flatten(other)) => { - flatten_node_matches(this, other).map_err(|err| { - err.add_description(&format!("under Flatten node (path: {})", this.path)) - })?; - } - ( - PlanNode::Defer { primary, deferred }, - PlanNode::Defer { - primary: other_primary, - deferred: other_deferred, - }, - ) => { - defer_primary_node_matches(primary, other_primary)?; - vec_matches_result(deferred, other_deferred, deferred_node_matches)?; - } - ( - PlanNode::Subscription { primary, rest }, - PlanNode::Subscription { - primary: other_primary, - rest: other_rest, - }, - ) => { - subscription_primary_matches(primary, other_primary)?; - opt_plan_node_matches(rest, other_rest) - .map_err(|err| err.add_description("under Subscription"))?; - } - ( - PlanNode::Condition { - condition, - if_clause, - else_clause, - }, - PlanNode::Condition { - condition: other_condition, - if_clause: other_if_clause, - else_clause: other_else_clause, - }, - ) => { - check_match_eq!(condition, other_condition); - opt_plan_node_matches(if_clause, other_if_clause) - .map_err(|err| err.add_description("under Condition node (if_clause)"))?; - opt_plan_node_matches(else_clause, other_else_clause) - .map_err(|err| err.add_description("under Condition node (else_clause)"))?; - } - _ => { - return Err(MatchFailure::new(format!( - "mismatched plan node types\nleft: {:?}\nright: {:?}", - this, other - ))) - } - }; - Ok(()) -} - -fn defer_primary_node_matches(this: &Primary, other: &Primary) -> Result<(), MatchFailure> { - let Primary { subselection, node } = this; - opt_document_string_matches(subselection, &other.subselection) - .map_err(|err| err.add_description("under defer primary subselection"))?; - opt_plan_node_matches(node, &other.node) - .map_err(|err| err.add_description("under defer primary plan node")) -} - -fn deferred_node_matches(this: &DeferredNode, other: &DeferredNode) -> Result<(), MatchFailure> { - let DeferredNode { - depends, - label, - query_path, - subselection, - node, - } = this; - - check_match_eq!(*depends, other.depends); - check_match_eq!(*label, other.label); - check_match_eq!(*query_path, other.query_path); - opt_document_string_matches(subselection, &other.subselection) - .map_err(|err| err.add_description("under deferred subselection"))?; - opt_plan_node_matches(node, &other.node) - .map_err(|err| err.add_description("under deferred node")) -} - -fn flatten_node_matches(this: &FlattenNode, other: &FlattenNode) -> Result<(), MatchFailure> { - let FlattenNode { path, node } = this; - check_match_eq!(*path, other.path); - plan_node_matches(node, &other.node) -} - -// Copied and modified from `apollo_federation::operation::SelectionKey` -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -enum SelectionKey { - Field { - /// The field alias (if specified) or field name in the resulting selection set. - response_name: Name, - directives: ast::DirectiveList, - }, - FragmentSpread { - /// The name of the fragment. - fragment_name: Name, - directives: ast::DirectiveList, - }, - InlineFragment { - /// The optional type condition of the fragment. - type_condition: Option, - directives: ast::DirectiveList, - }, -} - -fn get_selection_key(selection: &Selection) -> SelectionKey { - match selection { - Selection::Field(field) => SelectionKey::Field { - response_name: field.response_name().clone(), - directives: Default::default(), - }, - Selection::InlineFragment(fragment) => SelectionKey::InlineFragment { - type_condition: fragment.type_condition.clone(), - directives: Default::default(), - }, - } -} - -fn hash_value(x: &T) -> u64 { - let mut hasher = DefaultHasher::new(); - x.hash(&mut hasher); - hasher.finish() -} - -fn hash_selection_key(selection: &Selection) -> u64 { - hash_value(&get_selection_key(selection)) -} - -// Note: This `Selection` struct is a limited version used for the `requires` field. -fn same_selection(x: &Selection, y: &Selection) -> bool { - match (x, y) { - (Selection::Field(x), Selection::Field(y)) => { - x.name == y.name - && x.alias == y.alias - && match (&x.selections, &y.selections) { - (Some(x), Some(y)) => same_selection_set_sorted(x, y), - (None, None) => true, - _ => false, - } - } - (Selection::InlineFragment(x), Selection::InlineFragment(y)) => { - x.type_condition == y.type_condition - && same_selection_set_sorted(&x.selections, &y.selections) - } - _ => false, - } -} - -fn same_selection_set_sorted(x: &[Selection], y: &[Selection]) -> bool { - fn sorted_by_selection_key(s: &[Selection]) -> Vec<&Selection> { - let mut sorted: Vec<&Selection> = s.iter().collect(); - sorted.sort_by_key(|x| hash_selection_key(x)); - sorted - } - - if x.len() != y.len() { - return false; - } - sorted_by_selection_key(x) - .into_iter() - .zip(sorted_by_selection_key(y)) - .all(|(x, y)| same_selection(x, y)) -} - -fn same_requires(x: &[Selection], y: &[Selection]) -> bool { - vec_matches_as_set(x, y, same_selection) -} - -fn same_rewrites(x: &Option>, y: &Option>) -> bool { - match (x, y) { - (None, None) => true, - (Some(x), Some(y)) => vec_matches_as_set(x, y, |a, b| a == b), - _ => false, - } -} - -//================================================================================================== -// AST comparison functions - -fn same_ast_document(x: &ast::Document, y: &ast::Document) -> Result<(), MatchFailure> { - fn split_definitions( - doc: &ast::Document, - ) -> ( - Vec<&ast::OperationDefinition>, - Vec<&ast::FragmentDefinition>, - Vec<&ast::Definition>, - ) { - let mut operations: Vec<&ast::OperationDefinition> = Vec::new(); - let mut fragments: Vec<&ast::FragmentDefinition> = Vec::new(); - let mut others: Vec<&ast::Definition> = Vec::new(); - for def in doc.definitions.iter() { - match def { - ast::Definition::OperationDefinition(op) => operations.push(op), - ast::Definition::FragmentDefinition(frag) => fragments.push(frag), - _ => others.push(def), - } - } - (operations, fragments, others) - } - - let (x_ops, x_frags, x_others) = split_definitions(x); - let (y_ops, y_frags, y_others) = split_definitions(y); - - debug_assert!(x_others.is_empty(), "Unexpected definition types"); - debug_assert!(y_others.is_empty(), "Unexpected definition types"); - debug_assert!( - x_ops.len() == y_ops.len(), - "Different number of operation definitions" - ); - - check_match_eq!(x_frags.len(), y_frags.len()); - let mut fragment_map: HashMap = HashMap::new(); - // Assumption: x_frags and y_frags are topologically sorted. - // Thus, we can build the fragment name mapping in a single pass and compare - // fragment definitions using the mapping at the same time, since earlier fragments - // will never reference later fragments. - x_frags.iter().try_fold((), |_, x_frag| { - let y_frag = y_frags - .iter() - .find(|y_frag| same_ast_fragment_definition(x_frag, y_frag, &fragment_map).is_ok()); - if let Some(y_frag) = y_frag { - if x_frag.name != y_frag.name { - // record it only if they are not identical - fragment_map.insert(x_frag.name.clone(), y_frag.name.clone()); - } - Ok(()) - } else { - Err(MatchFailure::new(format!( - "mismatch: no matching fragment definition for {}", - x_frag.name - ))) - } - })?; - - check_match_eq!(x_ops.len(), y_ops.len()); - x_ops - .iter() - .zip(y_ops.iter()) - .try_fold((), |_, (x_op, y_op)| { - same_ast_operation_definition(x_op, y_op, &fragment_map) - .map_err(|err| err.add_description("under operation definition")) - })?; - Ok(()) -} - -fn same_ast_operation_definition( - x: &ast::OperationDefinition, - y: &ast::OperationDefinition, - fragment_map: &HashMap, -) -> Result<(), MatchFailure> { - // Note: Operation names are ignored, since parallel fetches may have different names. - check_match_eq!(x.operation_type, y.operation_type); - vec_matches_result_sorted_by( - &x.variables, - &y.variables, - |a, b| a.name.cmp(&b.name), - |a, b| same_variable_definition(a, b), - ) - .map_err(|err| err.add_description("under Variable definition"))?; - check_match_eq!(x.directives, y.directives); - check_match!(same_ast_selection_set_sorted( - &x.selection_set, - &y.selection_set, - fragment_map, - )); - Ok(()) -} - -// `x` may be coerced to `y`. -// - `x` should be a value from JS QP. -// - `y` should be a value from Rust QP. -// - Assume: x and y are already checked not equal. -// Due to coercion differences, we need to compare AST values with special cases. -fn ast_value_maybe_coerced_to(x: &ast::Value, y: &ast::Value) -> bool { - match (x, y) { - // Special case 1: JS QP may convert an enum value into string. - // - In this case, compare them as strings. - (ast::Value::String(ref x), ast::Value::Enum(ref y)) => { - if x == y.as_str() { - return true; - } - } - - // Special case 2: Rust QP expands a object value by filling in its - // default field values. - // - If the Rust QP object value subsumes the JS QP object value, consider it a match. - // - Assuming the Rust QP object value has only default field values. - // - Warning: This is an unsound heuristic. - (ast::Value::Object(ref x), ast::Value::Object(ref y)) => { - if vec_includes_as_set(y, x, |(yy_name, yy_val), (xx_name, xx_val)| { - xx_name == yy_name - && (xx_val == yy_val || ast_value_maybe_coerced_to(xx_val, yy_val)) - }) { - return true; - } - } - - // Special case 3: JS QP may convert string to int for custom scalars, while Rust doesn't. - // - Note: This conversion seems a bit difficult to implement in the `apollo-federation`'s - // `coerce_value` function, since IntValue's constructor is private to the crate. - (ast::Value::Int(ref x), ast::Value::String(ref y)) => { - if x.as_str() == y { - return true; - } - } - - // Recurse into list items. - (ast::Value::List(ref x), ast::Value::List(ref y)) => { - if vec_matches(x, y, |xx, yy| { - xx == yy || ast_value_maybe_coerced_to(xx, yy) - }) { - return true; - } - } - - _ => {} // otherwise, fall through - } - false -} - -// Use this function, instead of `VariableDefinition`'s `PartialEq` implementation, -// due to known differences. -fn same_variable_definition( - x: &ast::VariableDefinition, - y: &ast::VariableDefinition, -) -> Result<(), MatchFailure> { - check_match_eq!(x.name, y.name); - check_match_eq!(x.ty, y.ty); - if x.default_value != y.default_value { - if let (Some(x), Some(y)) = (&x.default_value, &y.default_value) { - if ast_value_maybe_coerced_to(x, y) { - return Ok(()); - } - } - - return Err(MatchFailure::new(format!( - "mismatch between default values:\nleft: {:?}\nright: {:?}", - x.default_value, y.default_value - ))); - } - check_match_eq!(x.directives, y.directives); - Ok(()) -} - -fn same_ast_fragment_definition( - x: &ast::FragmentDefinition, - y: &ast::FragmentDefinition, - fragment_map: &HashMap, -) -> Result<(), MatchFailure> { - // Note: Fragment names at definitions are ignored. - check_match_eq!(x.type_condition, y.type_condition); - check_match_eq!(x.directives, y.directives); - check_match!(same_ast_selection_set_sorted( - &x.selection_set, - &y.selection_set, - fragment_map, - )); - Ok(()) -} - -fn same_ast_argument_value(x: &ast::Value, y: &ast::Value) -> bool { - x == y || ast_value_maybe_coerced_to(x, y) -} - -fn same_ast_argument(x: &ast::Argument, y: &ast::Argument) -> bool { - x.name == y.name && same_ast_argument_value(&x.value, &y.value) -} - -fn get_ast_selection_key( - selection: &ast::Selection, - fragment_map: &HashMap, -) -> SelectionKey { - match selection { - ast::Selection::Field(field) => SelectionKey::Field { - response_name: field.response_name().clone(), - directives: field.directives.clone(), - }, - ast::Selection::FragmentSpread(fragment) => SelectionKey::FragmentSpread { - fragment_name: fragment_map - .get(&fragment.fragment_name) - .unwrap_or(&fragment.fragment_name) - .clone(), - directives: fragment.directives.clone(), - }, - ast::Selection::InlineFragment(fragment) => SelectionKey::InlineFragment { - type_condition: fragment.type_condition.clone(), - directives: fragment.directives.clone(), - }, - } -} - -fn same_ast_selection( - x: &ast::Selection, - y: &ast::Selection, - fragment_map: &HashMap, -) -> bool { - match (x, y) { - (ast::Selection::Field(x), ast::Selection::Field(y)) => { - x.name == y.name - && x.alias == y.alias - && vec_matches_sorted_by( - &x.arguments, - &y.arguments, - |a, b| a.name.cmp(&b.name), - |a, b| same_ast_argument(a, b), - ) - && x.directives == y.directives - && same_ast_selection_set_sorted(&x.selection_set, &y.selection_set, fragment_map) - } - (ast::Selection::FragmentSpread(x), ast::Selection::FragmentSpread(y)) => { - let mapped_fragment_name = fragment_map - .get(&x.fragment_name) - .unwrap_or(&x.fragment_name); - *mapped_fragment_name == y.fragment_name && x.directives == y.directives - } - (ast::Selection::InlineFragment(x), ast::Selection::InlineFragment(y)) => { - x.type_condition == y.type_condition - && x.directives == y.directives - && same_ast_selection_set_sorted(&x.selection_set, &y.selection_set, fragment_map) - } - _ => false, - } -} - -fn hash_ast_selection_key(selection: &ast::Selection, fragment_map: &HashMap) -> u64 { - hash_value(&get_ast_selection_key(selection, fragment_map)) -} - -// Selections are sorted and compared after renaming x's fragment spreads according to the -// fragment_map. -fn same_ast_selection_set_sorted( - x: &[ast::Selection], - y: &[ast::Selection], - fragment_map: &HashMap, -) -> bool { - fn sorted_by_selection_key<'a>( - s: &'a [ast::Selection], - fragment_map: &HashMap, - ) -> Vec<&'a ast::Selection> { - let mut sorted: Vec<&ast::Selection> = s.iter().collect(); - sorted.sort_by_key(|x| hash_ast_selection_key(x, fragment_map)); - sorted - } - - if x.len() != y.len() { - return false; - } - let x_sorted = sorted_by_selection_key(x, fragment_map); // Map fragment spreads - let y_sorted = sorted_by_selection_key(y, &Default::default()); // Don't map fragment spreads - x_sorted - .into_iter() - .zip(y_sorted) - .all(|(x, y)| same_ast_selection(x, y, fragment_map)) -} - -#[cfg(test)] -mod ast_comparison_tests { - use super::*; - - #[test] - fn test_query_variable_decl_order() { - let op_x = r#"query($qv2: String!, $qv1: Int!) { x(arg1: $qv1, arg2: $qv2) }"#; - let op_y = r#"query($qv1: Int!, $qv2: String!) { x(arg1: $qv1, arg2: $qv2) }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } - - #[test] - fn test_query_variable_decl_enum_value_coercion() { - // Note: JS QP converts enum default values into strings. - let op_x = r#"query($qv1: E! = "default_value") { x(arg1: $qv1) }"#; - let op_y = r#"query($qv1: E! = default_value) { x(arg1: $qv1) }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } - - #[test] - fn test_query_variable_decl_object_value_coercion_empty_case() { - // Note: Rust QP expands empty object default values by filling in its default field - // values. - let op_x = r#"query($qv1: T! = {}) { x(arg1: $qv1) }"#; - let op_y = - r#"query($qv1: T! = { field1: true, field2: "default_value" }) { x(arg1: $qv1) }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } - - #[test] - fn test_query_variable_decl_object_value_coercion_non_empty_case() { - // Note: Rust QP expands an object default values by filling in its default field values. - let op_x = r#"query($qv1: T! = {field1: true}) { x(arg1: $qv1) }"#; - let op_y = - r#"query($qv1: T! = { field1: true, field2: "default_value" }) { x(arg1: $qv1) }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } - - #[test] - fn test_query_variable_decl_list_of_object_value_coercion() { - // Testing a combination of list and object value coercion. - let op_x = r#"query($qv1: [T!]! = [{}]) { x(arg1: $qv1) }"#; - let op_y = - r#"query($qv1: [T!]! = [{field1: true, field2: "default_value"}]) { x(arg1: $qv1) }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } - - #[test] - fn test_entities_selection_order() { - let op_x = r#" - query subgraph1__1($representations: [_Any!]!) { - _entities(representations: $representations) { x { w } y } - } - "#; - let op_y = r#" - query subgraph1__1($representations: [_Any!]!) { - _entities(representations: $representations) { y x { w } } - } - "#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } - - #[test] - fn test_top_level_selection_order() { - let op_x = r#"{ x { w z } y }"#; - let op_y = r#"{ y x { z w } }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } - - #[test] - fn test_fragment_definition_order() { - let op_x = r#"{ q { ...f1 ...f2 } } fragment f1 on T { x y } fragment f2 on T { w z }"#; - let op_y = r#"{ q { ...f1 ...f2 } } fragment f2 on T { w z } fragment f1 on T { x y }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } - - #[test] - fn test_selection_argument_is_compared() { - let op_x = r#"{ x(arg1: "one") }"#; - let op_y = r#"{ x(arg1: "two") }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_err()); - } - - #[test] - fn test_selection_argument_order() { - let op_x = r#"{ x(arg1: "one", arg2: "two") }"#; - let op_y = r#"{ x(arg2: "two", arg1: "one") }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } - - #[test] - fn test_string_to_id_coercion_difference() { - // JS QP coerces strings into integer for ID type, while Rust QP doesn't. - // This tests a special case that same_ast_document accepts this difference. - let op_x = r#"{ x(id: 123) }"#; - let op_y = r#"{ x(id: "123") }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } - - #[test] - fn test_fragment_definition_different_names() { - let op_x = r#"{ q { ...f1 ...f2 } } fragment f1 on T { x y } fragment f2 on T { w z }"#; - let op_y = r#"{ q { ...g1 ...g2 } } fragment g1 on T { x y } fragment g2 on T { w z }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } - - #[test] - fn test_fragment_definition_different_names_nested_1() { - // Nested fragments have the same name, only top-level fragments have different names. - let op_x = r#"{ q { ...f2 } } fragment f1 on T { x y } fragment f2 on T { z ...f1 }"#; - let op_y = r#"{ q { ...g2 } } fragment f1 on T { x y } fragment g2 on T { z ...f1 }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } - - #[test] - fn test_fragment_definition_different_names_nested_2() { - // Nested fragments have different names. - let op_x = r#"{ q { ...f2 } } fragment f1 on T { x y } fragment f2 on T { z ...f1 }"#; - let op_y = r#"{ q { ...g2 } } fragment g1 on T { x y } fragment g2 on T { z ...g1 }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } - - #[test] - fn test_fragment_definition_different_names_nested_3() { - // Nested fragments have different names. - // Also, fragment definitions are in different order. - let op_x = r#"{ q { ...f2 ...f3 } } fragment f1 on T { x y } fragment f2 on T { z ...f1 } fragment f3 on T { w } "#; - let op_y = r#"{ q { ...g2 ...g3 } } fragment g1 on T { x y } fragment g2 on T { w } fragment g3 on T { z ...g1 }"#; - let ast_x = ast::Document::parse(op_x, "op_x").unwrap(); - let ast_y = ast::Document::parse(op_y, "op_y").unwrap(); - assert!(super::same_ast_document(&ast_x, &ast_y).is_ok()); - } -} - -#[cfg(test)] -mod qp_selection_comparison_tests { - use serde_json::json; - - use super::*; - - #[test] - fn test_requires_comparison_with_same_selection_key() { - let requires_json = json!([ - { - "kind": "InlineFragment", - "typeCondition": "T", - "selections": [ - { - "kind": "Field", - "name": "id", - }, - ] - }, - { - "kind": "InlineFragment", - "typeCondition": "T", - "selections": [ - { - "kind": "Field", - "name": "id", - }, - { - "kind": "Field", - "name": "job", - } - ] - }, - ]); - - // The only difference between requires1 and requires2 is the order of selections. - // But, their items all have the same SelectionKey. - let requires1: Vec = serde_json::from_value(requires_json).unwrap(); - let requires2: Vec = requires1.iter().rev().cloned().collect(); - - // `same_selection_set_sorted` fails to match, since it doesn't account for - // two items with the same SelectionKey but in different order. - assert!(!same_selection_set_sorted(&requires1, &requires2)); - // `same_requires` should succeed. - assert!(same_requires(&requires1, &requires2)); - } -} - -#[cfg(test)] -mod tests { - use std::time::Instant; - - use super::*; - - #[test] - fn test_metric_query_planning_plan_both_comparison_duration() { - let start = Instant::now(); - let elapsed = start.elapsed().as_secs_f64(); - metric_query_planning_plan_both_comparison_duration(RUST_QP_MODE, elapsed); - assert_histogram_exists!( - "apollo.router.operations.query_planner.both.duration", - f64, - "planner" = "rust" - ); - - let start = Instant::now(); - let elapsed = start.elapsed().as_secs_f64(); - metric_query_planning_plan_both_comparison_duration(JS_QP_MODE, elapsed); - assert_histogram_exists!( - "apollo.router.operations.query_planner.both.duration", - f64, - "planner" = "js" - ); - } -} diff --git a/apollo-router/src/query_planner/execution.rs b/apollo-router/src/query_planner/execution.rs index bd23f549ea..edbda4551c 100644 --- a/apollo-router/src/query_planner/execution.rs +++ b/apollo-router/src/query_planner/execution.rs @@ -1,45 +1,53 @@ use std::collections::HashMap; use std::sync::Arc; -use apollo_compiler::validation::Valid; +use futures::StreamExt; use futures::future::join_all; use futures::prelude::*; use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio_stream::wrappers::BroadcastStream; +use tower::ServiceExt; use tracing::Instrument; -use super::log; -use super::subscription::SubscriptionHandle; use super::DeferredNode; use super::PlanNode; use super::QueryPlan; +use super::log; +use super::subscription::SubscriptionHandle; +use crate::Context; use crate::axum_factory::CanceledRequest; use crate::error::Error; use crate::graphql::Request; use crate::graphql::Response; use crate::json_ext::Object; use crate::json_ext::Path; +use crate::json_ext::PathElement; use crate::json_ext::Value; use crate::json_ext::ValueExt; use crate::plugins::subscription::SubscriptionConfig; -use crate::query_planner::FlattenNode; -use crate::query_planner::Primary; use crate::query_planner::CONDITION_ELSE_SPAN_NAME; use crate::query_planner::CONDITION_IF_SPAN_NAME; use crate::query_planner::CONDITION_SPAN_NAME; use crate::query_planner::DEFER_DEFERRED_SPAN_NAME; use crate::query_planner::DEFER_PRIMARY_SPAN_NAME; use crate::query_planner::DEFER_SPAN_NAME; -use crate::query_planner::FETCH_SPAN_NAME; use crate::query_planner::FLATTEN_SPAN_NAME; +use crate::query_planner::FlattenNode; use crate::query_planner::PARALLEL_SPAN_NAME; +use crate::query_planner::Primary; use crate::query_planner::SEQUENCE_SPAN_NAME; -use crate::query_planner::SUBSCRIBE_SPAN_NAME; -use crate::services::SubgraphServiceFactory; +use crate::query_planner::fetch::FetchNode; +use crate::query_planner::fetch::SubgraphSchemas; +use crate::query_planner::fetch::Variables; +use crate::services::FetchRequest; +use crate::services::fetch; +use crate::services::fetch::ErrorMapping; +use crate::services::fetch::SubscriptionRequest; +use crate::services::fetch_service::FetchServiceFactory; +use crate::services::new_service::ServiceFactory; use crate::spec::Query; use crate::spec::Schema; -use crate::Context; impl QueryPlan { #[allow(clippy::too_many_arguments)] @@ -47,13 +55,18 @@ impl QueryPlan { pub(crate) async fn execute<'a>( &self, context: &'a Context, - service_factory: &'a Arc, + service_factory: &'a Arc, + // The original supergraph request is used to populate variable values and for plugin + // features like propagating headers or subgraph telemetry based on supergraph request + // values. supergraph_request: &'a Arc>, schema: &'a Arc, - subgraph_schemas: &'a Arc>>>, + subgraph_schemas: &'a Arc, + // Sender for additional responses past the first one (@defer, @stream, subscriptions) sender: mpsc::Sender, subscription_handle: Option, subscription_config: &'a Option, + // Query plan execution builds up a JSON result value, use this as the initial data. initial_value: Option, ) -> Response { let root = Path::empty(); @@ -82,7 +95,11 @@ impl QueryPlan { ) .await; if !deferred_fetches.is_empty() { - tracing::info!(monotonic_counter.apollo.router.operations.defer = 1u64); + u64_counter!( + "apollo.router.operations.defer", + "Number of requests that request deferred data", + 1 + ); } Response::builder().data(value).errors(errors).build() @@ -100,9 +117,9 @@ impl QueryPlan { // holds the query plan executon arguments that do not change between calls pub(crate) struct ExecutionParameters<'a> { pub(crate) context: &'a Context, - pub(crate) service_factory: &'a Arc, + pub(crate) service_factory: &'a Arc, pub(crate) schema: &'a Arc, - pub(crate) subgraph_schemas: &'a Arc>>>, + pub(crate) subgraph_schemas: &'a Arc, pub(crate) supergraph_request: &'a Arc>, pub(crate) deferred_fetches: &'a HashMap)>>, pub(crate) query: &'a Arc, @@ -118,7 +135,7 @@ impl PlanNode { current_dir: &'a Path, parent_value: &'a Value, sender: mpsc::Sender, - ) -> future::BoxFuture<(Value, Vec)> { + ) -> future::BoxFuture<'a, (Value, Vec)> { Box::pin(async move { tracing::trace!("executing plan:\n{:#?}", self); let mut value; @@ -197,33 +214,65 @@ impl PlanNode { value = v; errors = err; } - PlanNode::Subscription { primary, .. } => { - if parameters.subscription_handle.is_some() { - let fetch_time_offset = - parameters.context.created_at.elapsed().as_nanos() as i64; - errors = primary - .execute_recursively(parameters, current_dir, parent_value, sender) - .instrument(tracing::info_span!( - SUBSCRIBE_SPAN_NAME, - "otel.kind" = "INTERNAL", - "apollo.subgraph.name" = primary.service_name.as_ref(), - "apollo_private.sent_time_offset" = fetch_time_offset - )) - .await; - } else { + PlanNode::Subscription { + primary: subscription_node, + .. + } => { + if parameters.subscription_handle.is_none() { tracing::error!("No subscription handle provided for a subscription"); - errors = vec![Error::builder() - .message("no subscription handle provided for a subscription") - .extension_code("NO_SUBSCRIPTION_HANDLE") - .build()]; - }; - - value = Value::default(); + value = Value::default(); + errors = vec![ + Error::builder() + .message("no subscription handle provided for a subscription") + .extension_code("NO_SUBSCRIPTION_HANDLE") + .build(), + ]; + } else { + match Variables::new( + &[], + &subscription_node.variable_usages, + parent_value, + current_dir, + parameters.supergraph_request, + parameters.schema, + &subscription_node.input_rewrites, + &None, + ) { + Some(variables) => { + let service = parameters.service_factory.create(); + let request = fetch::Request::Subscription( + SubscriptionRequest::builder() + .context(parameters.context.clone()) + .subscription_node(subscription_node.clone()) + .supergraph_request(parameters.supergraph_request.clone()) + .variables(variables) + .current_dir(current_dir.clone()) + .sender(sender) + .and_subscription_handle( + parameters.subscription_handle.clone(), + ) + .and_subscription_config( + parameters.subscription_config.clone(), + ) + .build(), + ); + (value, errors) = + match service.oneshot(request).await.map_to_graphql_error( + subscription_node.service_name.to_string(), + current_dir, + ) { + Ok(r) => r, + Err(e) => (Value::default(), vec![e]), + }; + } + None => { + value = Value::Object(Object::default()); + errors = Vec::new(); + } + }; + } } PlanNode::Fetch(fetch_node) => { - let fetch_time_offset = - parameters.context.created_at.elapsed().as_nanos() as i64; - // The client closed the connection, we are still executing the request pipeline, // but we won't send unused trafic to subgraph if parameters @@ -234,17 +283,76 @@ impl PlanNode { value = Value::Object(Object::default()); errors = Vec::new(); } else { - let (v, e) = fetch_node - .fetch_node(parameters, parent_value, current_dir) - .instrument(tracing::info_span!( - FETCH_SPAN_NAME, - "otel.kind" = "INTERNAL", - "apollo.subgraph.name" = fetch_node.service_name.as_ref(), - "apollo_private.sent_time_offset" = fetch_time_offset - )) - .await; - value = v; - errors = e; + match Variables::new( + &fetch_node.requires, + &fetch_node.variable_usages, + parent_value, + current_dir, + parameters.supergraph_request, + parameters.schema.as_ref(), + &fetch_node.input_rewrites, + &fetch_node.context_rewrites, + ) { + Some(variables) => { + let paths = variables.inverted_paths.clone(); + let service = parameters.service_factory.create(); + let request = fetch::Request::Fetch( + FetchRequest::builder() + .context(parameters.context.clone()) + .fetch_node(fetch_node.clone()) + .supergraph_request(parameters.supergraph_request.clone()) + .variables(variables) + .current_dir(current_dir.clone()) + .build(), + ); + let raw_errors; + (value, raw_errors) = + match service.oneshot(request).await.map_to_graphql_error( + fetch_node.service_name.to_string(), + current_dir, + ) { + Ok(r) => r, + Err(e) => (Value::default(), vec![e]), + }; + + // When a subgraph returns an unexpected response (ie not a body with + // at least one of errors or data), the errors surfaced by the router + // include an @ in the path. This indicates the error should be applied + // to all elements in the array. + errors = Vec::default(); + for err in raw_errors { + if let Some(err_path) = err.path.as_ref() + && err_path + .iter() + .any(|elem| matches!(elem, PathElement::Flatten(_))) + { + for path in paths.iter().flatten() { + if err_path.equal_if_flattened(path) { + let mut err = err.clone(); + err.path = Some(path.clone()); + errors.push(err); + } + } + + continue; + } + + errors.push(err); + } + + FetchNode::deferred_fetches( + current_dir, + &fetch_node.id, + parameters.deferred_fetches, + &value, + &errors, + ); + } + None => { + value = Value::Object(Object::default()); + errors = Vec::new(); + } + }; } } PlanNode::Defer { @@ -403,7 +511,7 @@ impl DeferredNode { sender: mpsc::Sender, primary_sender: &broadcast::Sender<(Value, Vec)>, deferred_fetches: &mut HashMap)>>, - ) -> impl Future { + ) -> impl Future + use<> { let mut deferred_receivers = Vec::new(); for d in self.depends.iter() { @@ -411,11 +519,11 @@ impl DeferredNode { None => { let (sender, receiver) = tokio::sync::broadcast::channel(1); deferred_fetches.insert(d.id.clone(), sender.clone()); - deferred_receivers.push(BroadcastStream::new(receiver).into_future()); + deferred_receivers.push(StreamExt::into_future(BroadcastStream::new(receiver))); } Some(sender) => { let receiver = sender.subscribe(); - deferred_receivers.push(BroadcastStream::new(receiver).into_future()); + deferred_receivers.push(StreamExt::into_future(BroadcastStream::new(receiver))); } } } diff --git a/apollo-router/src/query_planner/fetch.rs b/apollo-router/src/query_planner/fetch.rs index 47069283ca..a6a341496f 100644 --- a/apollo-router/src/query_planner/fetch.rs +++ b/apollo-router/src/query_planner/fetch.rs @@ -1,22 +1,24 @@ -use std::collections::HashMap; use std::fmt::Display; use std::sync::Arc; +use apollo_compiler::ExecutableDocument; use apollo_compiler::ast; +use apollo_compiler::collections::HashMap; use apollo_compiler::validation::Valid; -use apollo_compiler::ExecutableDocument; +use apollo_federation::query_plan::requires_selection; +use apollo_federation::query_plan::serializable_document::SerializableDocument; use indexmap::IndexSet; use serde::Deserialize; use serde::Serialize; +use serde_json_bytes::ByteString; +use serde_json_bytes::Map; +use tokio::sync::broadcast::Sender; use tower::ServiceExt; -use tracing::instrument; use tracing::Instrument; +use tracing::instrument; -use super::execution::ExecutionParameters; use super::rewrites; use super::selection::execute_selection_set; -use super::selection::Selection; -use super::subgraph_context::build_operation_with_aliasing; use super::subgraph_context::ContextualArguments; use super::subgraph_context::SubgraphContext; use crate::error::Error; @@ -24,7 +26,6 @@ use crate::error::FetchError; use crate::error::ValidationErrors; use crate::graphql; use crate::graphql::Request; -use crate::http_ext; use crate::json_ext; use crate::json_ext::Object; use crate::json_ext::Path; @@ -33,8 +34,11 @@ use crate::json_ext::ValueExt; use crate::plugins::authorization::AuthorizationPlugin; use crate::plugins::authorization::CacheKeyMetadata; use crate::services::SubgraphRequest; -use crate::spec::query::change::QueryHashVisitor; +use crate::services::fetch::ErrorMapping; +use crate::services::subgraph::BoxService; +use crate::spec::QueryHash; use crate::spec::Schema; +use crate::spec::SchemaHash; /// GraphQL operation type. #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Deserialize, Serialize)] @@ -93,7 +97,23 @@ impl From for OperationKind { } } -pub(crate) type SubgraphSchemas = HashMap>>; +pub(crate) type SubgraphSchemas = HashMap; + +pub(crate) struct SubgraphSchema { + pub(crate) schema: Arc>, + // TODO: Ideally should have separate nominal type for subgraph's schema hash + pub(crate) hash: SchemaHash, +} + +impl SubgraphSchema { + pub(crate) fn new(schema: Valid) -> Self { + let sdl = schema.serialize().no_indent().to_string(); + Self { + schema: Arc::new(schema), + hash: SchemaHash::new(&sdl), + } + } +} /// A fetch node. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] @@ -105,13 +125,13 @@ pub(crate) struct FetchNode { /// The data that is required for the subgraph fetch. #[serde(skip_serializing_if = "Vec::is_empty")] #[serde(default)] - pub(crate) requires: Vec, + pub(crate) requires: Vec, /// The variables that are used for the subgraph fetch. pub(crate) variable_usages: Vec>, /// The GraphQL subquery that is used for the fetch. - pub(crate) operation: SubgraphOperation, + pub(crate) operation: SerializableDocument, /// The GraphQL subquery operation name. pub(crate) operation_name: Option>, @@ -141,128 +161,7 @@ pub(crate) struct FetchNode { pub(crate) authorization: Arc, } -#[derive(Clone)] -pub(crate) struct SubgraphOperation { - serialized: String, - /// Ideally this would be always present, but we don’t have access to the subgraph schemas - /// during `Deserialize`. - parsed: Option>>, -} - -impl SubgraphOperation { - pub(crate) fn from_string(serialized: impl Into) -> Self { - Self { - serialized: serialized.into(), - parsed: None, - } - } - - pub(crate) fn from_parsed(parsed: impl Into>>) -> Self { - let parsed = parsed.into(); - Self { - serialized: parsed.serialize().no_indent().to_string(), - parsed: Some(parsed), - } - } - - pub(crate) fn as_serialized(&self) -> &str { - &self.serialized - } - - pub(crate) fn init_parsed( - &mut self, - subgraph_schema: &Valid, - ) -> Result<&Arc>, ValidationErrors> { - match &mut self.parsed { - Some(parsed) => Ok(parsed), - option => { - let parsed = Arc::new(ExecutableDocument::parse_and_validate( - subgraph_schema, - &self.serialized, - "operation.graphql", - )?); - Ok(option.insert(parsed)) - } - } - } - - pub(crate) fn as_parsed( - &self, - ) -> Result<&Arc>, SubgraphOperationNotInitialized> { - self.parsed.as_ref().ok_or(SubgraphOperationNotInitialized) - } -} - -/// Failed to call `SubgraphOperation::init_parsed` after creating a query plan -#[derive(Debug, displaydoc::Display, thiserror::Error)] -pub(crate) struct SubgraphOperationNotInitialized; - -impl SubgraphOperationNotInitialized { - pub(crate) fn into_graphql_errors(self) -> Vec { - vec![graphql::Error::builder() - .extension_code(self.code()) - .message(self.to_string()) - .build()] - } - - pub(crate) fn code(&self) -> &'static str { - "SUBGRAPH_OPERATION_NOT_INITIALIZED" - } -} - -impl Serialize for SubgraphOperation { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.as_serialized().serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for SubgraphOperation { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - Ok(Self::from_string(String::deserialize(deserializer)?)) - } -} - -impl PartialEq for SubgraphOperation { - fn eq(&self, other: &Self) -> bool { - self.as_serialized() == other.as_serialized() - } -} - -impl std::fmt::Debug for SubgraphOperation { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - std::fmt::Debug::fmt(self.as_serialized(), f) - } -} - -impl std::fmt::Display for SubgraphOperation { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - std::fmt::Display::fmt(self.as_serialized(), f) - } -} - -#[derive(Clone, Default, Hash, PartialEq, Eq, Deserialize, Serialize)] -pub(crate) struct QueryHash(#[serde(with = "hex")] pub(crate) Vec); - -impl std::fmt::Debug for QueryHash { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("QueryHash") - .field(&hex::encode(&self.0)) - .finish() - } -} - -impl Display for QueryHash { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", hex::encode(&self.0)) - } -} - +#[derive(Default)] pub(crate) struct Variables { pub(crate) variables: Object, pub(crate) inverted_paths: Vec>, @@ -272,8 +171,8 @@ pub(crate) struct Variables { impl Variables { #[instrument(skip_all, level = "debug", name = "make_variables")] #[allow(clippy::too_many_arguments)] - pub(super) fn new( - requires: &[Selection], + pub(crate) fn new( + requires: &[requires_selection::Selection], variable_usages: &[Arc], data: &Value, current_dir: &Path, @@ -366,172 +265,75 @@ impl Variables { impl FetchNode { #[allow(clippy::too_many_arguments)] - pub(crate) async fn fetch_node<'a>( - &'a self, - parameters: &'a ExecutionParameters<'a>, - data: &'a Value, - current_dir: &'a Path, + pub(crate) async fn subgraph_fetch( + &self, + service: BoxService, + subgraph_request: SubgraphRequest, + current_dir: &Path, + schema: &Schema, + paths: Vec>, + operation_str: &str, + variables: Map, ) -> (Value, Vec) { - let FetchNode { - operation, - operation_kind, - operation_name, - service_name, - .. - } = self; - - let Variables { - variables, - inverted_paths: paths, - contextual_arguments, - } = match Variables::new( - &self.requires, - &self.variable_usages, - data, - current_dir, - // Needs the original request here - parameters.supergraph_request, - parameters.schema, - &self.input_rewrites, - &self.context_rewrites, - ) { - Some(variables) => variables, - None => { - return (Value::Object(Object::default()), Vec::new()); - } - }; - - let alias_query_string; // this exists outside the if block to allow the as_str() to be longer lived - let aliased_operation = if let Some(ctx_arg) = contextual_arguments { - if let Some(subgraph_schema) = - parameters.subgraph_schemas.get(&service_name.to_string()) - { - match build_operation_with_aliasing(operation, &ctx_arg, subgraph_schema) { - Ok(op) => { - alias_query_string = op.serialize().no_indent().to_string(); - alias_query_string.as_str() - } - Err(errors) => { - tracing::debug!( - "couldn't generate a valid executable document? {:?}", - errors - ); - operation.as_serialized() - } - } - } else { - tracing::debug!( - "couldn't find a subgraph schema for service {:?}", - &service_name - ); - operation.as_serialized() - } - } else { - operation.as_serialized() - }; - - let mut subgraph_request = SubgraphRequest::builder() - .supergraph_request(parameters.supergraph_request.clone()) - .subgraph_request( - http_ext::Request::builder() - .method(http::Method::POST) - .uri( - parameters - .schema - .subgraph_url(service_name) - .unwrap_or_else(|| { - panic!( - "schema uri for subgraph '{service_name}' should already have been checked" - ) - }) - .clone(), - ) - .body( - Request::builder() - .query(aliased_operation) - .and_operation_name(operation_name.as_ref().map(|n| n.to_string())) - .variables(variables.clone()) - .build(), - ) - .build() - .expect("it won't fail because the url is correct and already checked; qed"), - ) - .subgraph_name(self.service_name.to_string()) - .operation_kind(*operation_kind) - .context(parameters.context.clone()) - .build(); - subgraph_request.query_hash = self.schema_aware_hash.clone(); - subgraph_request.authorization = self.authorization.clone(); - - let service = parameters - .service_factory - .create(service_name) - .expect("we already checked that the service exists during planning; qed"); - let (_parts, response) = match service .oneshot(subgraph_request) .instrument(tracing::trace_span!("subfetch_stream")) .await - // TODO this is a problem since it restores details about failed service - // when errors have been redacted in the include_subgraph_errors module. - // Unfortunately, not easy to fix here, because at this point we don't - // know if we should be redacting errors for this subgraph... - .map_err(|e| match e.downcast::() { - Ok(inner) => match *inner { - FetchError::SubrequestHttpError { .. } => *inner, - _ => FetchError::SubrequestHttpError { - status_code: None, - service: service_name.to_string(), - reason: inner.to_string(), - }, - }, - Err(e) => FetchError::SubrequestHttpError { - status_code: None, - service: service_name.to_string(), - reason: e.to_string(), - }, - }) { + .map_to_graphql_error(self.service_name.to_string(), current_dir) + { Err(e) => { - return ( - Value::default(), - vec![e.to_graphql_error(Some(current_dir.to_owned()))], - ); + return (Value::default(), vec![e]); } Ok(res) => res.response.into_parts(), }; - super::log::trace_subfetch( - service_name, - operation.as_serialized(), - &variables, - &response, - ); + super::log::trace_subfetch(&self.service_name, operation_str, &variables, &response); if !response.is_primary() { return ( Value::default(), - vec![FetchError::SubrequestUnexpectedPatchResponse { - service: service_name.to_string(), - } - .to_graphql_error(Some(current_dir.to_owned()))], + vec![ + FetchError::SubrequestUnexpectedPatchResponse { + service: self.service_name.to_string(), + } + .to_graphql_error(Some(current_dir.to_owned())), + ], ); } - let (value, errors) = - self.response_at_path(parameters.schema, current_dir, paths, response); - if let Some(id) = &self.id { - if let Some(sender) = parameters.deferred_fetches.get(id.as_str()) { - tracing::info!(monotonic_counter.apollo.router.operations.defer.fetch = 1u64); - if let Err(e) = sender.clone().send((value.clone(), errors.clone())) { - tracing::error!("error sending fetch result at path {} and id {:?} for deferred response building: {}", current_dir, self.id, e); - } + let (value, errors) = self.response_at_path(schema, current_dir, paths, response); + + (value, errors) + } + + pub(crate) fn deferred_fetches( + current_dir: &Path, + id: &Option, + deferred_fetches: &std::collections::HashMap)>>, + value: &Value, + errors: &[Error], + ) { + if let Some(id) = id + && let Some(sender) = deferred_fetches.get(id.as_str()) + { + u64_counter!( + "apollo.router.operations.defer.fetch", + "Number of deferred responses fetched from subgraphs", + 1 + ); + if let Err(e) = sender.clone().send((value.clone(), Vec::from(errors))) { + tracing::error!( + "error sending fetch result at path {} and id {:?} for deferred response building: {}", + current_dir, + id, + e + ); } } - (value, errors) } #[instrument(skip_all, level = "debug", name = "response_insert")] - fn response_at_path<'a>( + pub(crate) fn response_at_path<'a>( &'a self, schema: &Schema, current_dir: &'a Path, @@ -560,16 +362,21 @@ impl FetchNode { for values_path in inverted_paths.get(*i).iter().flat_map(|v| v.iter()) { - errors.push(Error { - locations: error.locations.clone(), - // append to the entitiy's path the error's path without - //`_entities` and the index - path: Some(Path::from_iter( - values_path.0.iter().chain(&path.0[2..]).cloned(), - )), - message: error.message.clone(), - extensions: error.extensions.clone(), - }) + errors.push( + Error::builder() + .locations(error.locations.clone()) + // append to the entity's path the error's path without + //`_entities` and the index + .path(Path::from_iter( + values_path.0.iter().chain(&path.0[2..]).cloned(), + )) + .message(error.message.clone()) + .and_extension_code(error.extension_code()) + .extensions(error.extensions.clone()) + // re-use the original ID so we don't double count this error + .apollo_id(error.apollo_id()) + .build(), + ) } } _ => { @@ -589,30 +396,30 @@ impl FetchNode { // we have to nest conditions and do early returns here // because we need to take ownership of the inner value - if let Some(Value::Object(mut map)) = response.data { - if let Some(entities) = map.remove("_entities") { - tracing::trace!("received entities: {:?}", &entities); + if let Some(Value::Object(mut map)) = response.data + && let Some(entities) = map.remove("_entities") + { + tracing::trace!("received entities: {:?}", &entities); - if let Value::Array(array) = entities { - let mut value = Value::default(); + if let Value::Array(array) = entities { + let mut value = Value::default(); - for (index, mut entity) in array.into_iter().enumerate() { - rewrites::apply_rewrites(schema, &mut entity, &self.output_rewrites); + for (index, mut entity) in array.into_iter().enumerate() { + rewrites::apply_rewrites(schema, &mut entity, &self.output_rewrites); - if let Some(paths) = inverted_paths.get(index) { - if paths.len() > 1 { - for path in &paths[1..] { - let _ = value.insert(path, entity.clone()); - } + if let Some(paths) = inverted_paths.get(index) { + if paths.len() > 1 { + for path in &paths[1..] { + let _ = value.insert(path, entity.clone()); } + } - if let Some(path) = paths.first() { - let _ = value.insert(path, entity); - } + if let Some(path) = paths.first() { + let _ = value.insert(path, entity); } } - return (value, errors); } + return (value, errors); } } @@ -648,12 +455,14 @@ impl FetchNode { }) .unwrap_or_else(|| current_dir.clone()); - Error { - locations: error.locations, - path: Some(path), - message: error.message, - extensions: error.extensions, - } + Error::builder() + .locations(error.locations.clone()) + .path(path) + .message(error.message.clone()) + .and_extension_code(error.extension_code()) + .extensions(error.extensions.clone()) + .apollo_id(error.apollo_id()) + .build() }) .collect(); let mut data = response.data.unwrap_or_default(); @@ -676,26 +485,20 @@ impl FetchNode { subgraph_schemas: &SubgraphSchemas, ) -> Result<(), ValidationErrors> { let schema = &subgraph_schemas[self.service_name.as_ref()]; - self.operation.init_parsed(schema)?; + self.operation.init_parsed(&schema.schema)?; Ok(()) } pub(crate) fn init_parsed_operation_and_hash_subquery( &mut self, subgraph_schemas: &SubgraphSchemas, - supergraph_schema_hash: &str, ) -> Result<(), ValidationErrors> { let schema = &subgraph_schemas[self.service_name.as_ref()]; - let doc = self.operation.init_parsed(schema)?; - - if let Ok(hash) = QueryHashVisitor::hash_query( - schema, - supergraph_schema_hash, - doc, + self.operation.init_parsed(&schema.schema)?; + self.schema_aware_hash = Arc::new(schema.hash.operation_hash( + self.operation.as_serialized(), self.operation_name.as_deref(), - ) { - self.schema_aware_hash = Arc::new(QueryHash(hash)); - } + )); Ok(()) } diff --git a/apollo-router/src/query_planner/labeler.rs b/apollo-router/src/query_planner/labeler.rs index 856dc7385c..be27aeea42 100644 --- a/apollo-router/src/query_planner/labeler.rs +++ b/apollo-router/src/query_planner/labeler.rs @@ -1,18 +1,18 @@ //! Query Transformer implementation adding labels to @defer directives to identify deferred responses //! -use apollo_compiler::ast; -use apollo_compiler::name; use apollo_compiler::Name; use apollo_compiler::Node; use apollo_compiler::Schema; +use apollo_compiler::ast; +use apollo_compiler::name; use tower::BoxError; use crate::spec::query::subselections::DEFER_DIRECTIVE_NAME; use crate::spec::query::transform; -use crate::spec::query::transform::document; use crate::spec::query::transform::TransformState; use crate::spec::query::transform::Visitor; +use crate::spec::query::transform::document; const LABEL_NAME: Name = name!("label"); diff --git a/apollo-router/src/query_planner/mod.rs b/apollo-router/src/query_planner/mod.rs index ad23fbb80e..f2f4a73da8 100644 --- a/apollo-router/src/query_planner/mod.rs +++ b/apollo-router/src/query_planner/mod.rs @@ -2,25 +2,23 @@ #![allow(missing_docs)] // FIXME -pub(crate) use bridge_query_planner::*; -pub(crate) use bridge_query_planner_pool::*; pub(crate) use caching_query_planner::*; pub use plan::QueryPlan; pub(crate) use plan::*; +pub(crate) use query_planner_service::*; +pub(crate) use subgraph_context::build_operation_with_aliasing; pub use self::fetch::OperationKind; -pub(crate) mod bridge_query_planner; -mod bridge_query_planner_pool; mod caching_query_planner; mod convert; -pub(crate) mod dual_query_planner; mod execution; pub(crate) mod fetch; mod labeler; mod plan; +pub(crate) mod query_planner_service; pub(crate) mod rewrites; -mod selection; +pub(crate) mod selection; mod subgraph_context; pub(crate) mod subscription; diff --git a/apollo-router/src/query_planner/plan.rs b/apollo-router/src/query_planner/plan.rs index f0e5763358..a4768d6e21 100644 --- a/apollo-router/src/query_planner/plan.rs +++ b/apollo-router/src/query_planner/plan.rs @@ -1,16 +1,16 @@ +use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; -use std::sync::Arc; +use apollo_compiler::collections::HashSet; use apollo_compiler::validation::Valid; -use router_bridge::planner::PlanOptions; -use router_bridge::planner::UsageReporting; use serde::Deserialize; use serde::Serialize; pub(crate) use self::fetch::OperationKind; use super::fetch; use super::subscription::SubscriptionNode; +use crate::apollo_studio_interop::UsageReporting; use crate::cache::estimate_size; use crate::configuration::Batching; use crate::error::CacheResolverError; @@ -19,10 +19,11 @@ use crate::json_ext::Object; use crate::json_ext::Path; use crate::json_ext::Value; use crate::plugins::authorization::CacheKeyMetadata; -use crate::query_planner::fetch::QueryHash; use crate::query_planner::fetch::SubgraphSchemas; -use crate::spec::operation_limits::OperationLimits; +use crate::services::query_planner::PlanOptions; use crate::spec::Query; +use crate::spec::QueryHash; +use crate::spec::operation_limits::OperationLimits; /// A planner key. /// @@ -62,14 +63,11 @@ impl QueryPlan { ) -> Self { Self { usage_reporting: usage_reporting - .unwrap_or_else(|| UsageReporting { - stats_report_key: "this is a test report key".to_string(), - referenced_fields_by_type: Default::default(), - }) + .unwrap_or_else(|| UsageReporting::Error("this is a test report key".to_string())) .into(), root: Arc::new(root.unwrap_or_else(|| PlanNode::Sequence { nodes: Vec::new() })), formatted_query_plan: Default::default(), - query: Arc::new(Query::empty()), + query: Arc::new(Query::empty_for_tests()), query_metrics: Default::default(), estimated_size: Default::default(), } @@ -161,15 +159,15 @@ impl PlanNode { else_clause, .. } => { - if let Some(node) = if_clause { - if node.contains_mutations() { - return true; - } + if let Some(node) = if_clause + && node.contains_mutations() + { + return true; } - if let Some(node) = else_clause { - if node.contains_mutations() { - return true; - } + if let Some(node) = else_clause + && node.contains_mutations() + { + return true; } false } @@ -196,15 +194,15 @@ impl PlanNode { { // right now ConditionNode is only used with defer, but it might be used // in the future to implement @skip and @include execution - if let Some(node) = if_clause { - if node.is_deferred(variables, query) { - return true; - } - } - } else if let Some(node) = else_clause { - if node.is_deferred(variables, query) { + if let Some(node) = if_clause + && node.is_deferred(variables, query) + { return true; } + } else if let Some(node) = else_clause + && node.is_deferred(variables, query) + { + return true; } false @@ -258,13 +256,13 @@ impl PlanNode { return Err(CacheResolverError::BatchingError( "unexpected defer node encountered during query_hash processing" .to_string(), - )) + )); } PlanNode::Subscription { .. } => { return Err(CacheResolverError::BatchingError( "unexpected subscription node encountered during query_hash processing" .to_string(), - )) + )); } PlanNode::Condition { if_clause, @@ -354,7 +352,8 @@ impl PlanNode { } } } - PlanNode::Subscription { primary: _, rest } => { + PlanNode::Subscription { primary, rest } => { + primary.init_parsed_operation(subgraph_schemas)?; if let Some(node) = rest.as_mut() { node.init_parsed_operations(subgraph_schemas)?; } @@ -378,58 +377,39 @@ impl PlanNode { pub(crate) fn init_parsed_operations_and_hash_subqueries( &mut self, subgraph_schemas: &SubgraphSchemas, - supergraph_schema_hash: &str, ) -> Result<(), ValidationErrors> { match self { PlanNode::Fetch(fetch_node) => { - fetch_node.init_parsed_operation_and_hash_subquery( - subgraph_schemas, - supergraph_schema_hash, - )?; + fetch_node.init_parsed_operation_and_hash_subquery(subgraph_schemas)?; } PlanNode::Sequence { nodes } => { for node in nodes { - node.init_parsed_operations_and_hash_subqueries( - subgraph_schemas, - supergraph_schema_hash, - )?; + node.init_parsed_operations_and_hash_subqueries(subgraph_schemas)?; } } PlanNode::Parallel { nodes } => { for node in nodes { - node.init_parsed_operations_and_hash_subqueries( - subgraph_schemas, - supergraph_schema_hash, - )?; + node.init_parsed_operations_and_hash_subqueries(subgraph_schemas)?; } } - PlanNode::Flatten(flatten) => flatten.node.init_parsed_operations_and_hash_subqueries( - subgraph_schemas, - supergraph_schema_hash, - )?, + PlanNode::Flatten(flatten) => flatten + .node + .init_parsed_operations_and_hash_subqueries(subgraph_schemas)?, PlanNode::Defer { primary, deferred } => { if let Some(node) = primary.node.as_mut() { - node.init_parsed_operations_and_hash_subqueries( - subgraph_schemas, - supergraph_schema_hash, - )?; + node.init_parsed_operations_and_hash_subqueries(subgraph_schemas)?; } for deferred_node in deferred { if let Some(node) = &mut deferred_node.node { - Arc::make_mut(node).init_parsed_operations_and_hash_subqueries( - subgraph_schemas, - supergraph_schema_hash, - )? + Arc::make_mut(node) + .init_parsed_operations_and_hash_subqueries(subgraph_schemas)? } } } PlanNode::Subscription { primary: _, rest } => { if let Some(node) = rest.as_mut() { - node.init_parsed_operations_and_hash_subqueries( - subgraph_schemas, - supergraph_schema_hash, - )?; + node.init_parsed_operations_and_hash_subqueries(subgraph_schemas)?; } } PlanNode::Condition { @@ -438,16 +418,10 @@ impl PlanNode { else_clause, } => { if let Some(node) = if_clause.as_mut() { - node.init_parsed_operations_and_hash_subqueries( - subgraph_schemas, - supergraph_schema_hash, - )?; + node.init_parsed_operations_and_hash_subqueries(subgraph_schemas)?; } if let Some(node) = else_clause.as_mut() { - node.init_parsed_operations_and_hash_subqueries( - subgraph_schemas, - supergraph_schema_hash, - )?; + node.init_parsed_operations_and_hash_subqueries(subgraph_schemas)?; } } } @@ -503,6 +477,51 @@ impl PlanNode { } } + /// A version of `service_usage` that doesn't use recursion + /// and returns a `HashSet` instead of an `Iterator`. + pub(crate) fn service_usage_set(&self) -> HashSet<&str> { + let mut services = HashSet::default(); + let mut stack = vec![self]; + while let Some(node) = stack.pop() { + match node { + Self::Sequence { nodes } | Self::Parallel { nodes } => { + stack.extend(nodes.iter()); + } + Self::Fetch(fetch) => { + services.insert(fetch.service_name.as_ref()); + } + Self::Subscription { primary, rest } => { + services.insert(primary.service_name.as_ref()); + if let Some(rest) = rest { + stack.push(rest); + } + } + Self::Flatten(flatten) => { + stack.push(&flatten.node); + } + Self::Defer { primary, deferred } => { + if let Some(primary) = primary.node.as_ref() { + stack.push(primary); + } + stack.extend(deferred.iter().flat_map(|d| d.node.as_deref())); + } + Self::Condition { + if_clause, + else_clause, + .. + } => { + if let Some(if_clause) = if_clause { + stack.push(if_clause); + } + if let Some(else_clause) = else_clause { + stack.push(else_clause); + } + } + } + } + services + } + pub(crate) fn extract_authorization_metadata( &mut self, schema: &Valid, diff --git a/apollo-router/src/query_planner/bridge_query_planner.rs b/apollo-router/src/query_planner/query_planner_service.rs similarity index 59% rename from apollo-router/src/query_planner/bridge_query_planner.rs rename to apollo-router/src/query_planner/query_planner_service.rs index e3cc52dcdd..57729b7269 100644 --- a/apollo-router/src/query_planner/bridge_query_planner.rs +++ b/apollo-router/src/query_planner/query_planner_service.rs @@ -1,38 +1,35 @@ -//! Calls out to nodejs query planner +//! Calls out to the apollo-federation crate -use std::collections::HashMap; use std::fmt::Debug; -use std::fmt::Write; use std::ops::ControlFlow; use std::sync::Arc; +use std::sync::OnceLock; +use std::task::Poll; use std::time::Instant; -use apollo_compiler::ast; -use apollo_compiler::validation::Valid; use apollo_compiler::Name; +use apollo_compiler::ast; use apollo_federation::error::FederationError; use apollo_federation::error::SingleFederationError; use apollo_federation::query_plan::query_planner::QueryPlanOptions; use apollo_federation::query_plan::query_planner::QueryPlanner; use futures::future::BoxFuture; -use opentelemetry_api::metrics::MeterProvider as _; -use opentelemetry_api::metrics::ObservableGauge; -use opentelemetry_api::KeyValue; -use router_bridge::planner::PlanOptions; -use router_bridge::planner::PlanSuccess; -use router_bridge::planner::Planner; -use router_bridge::planner::UsageReporting; -use serde::Deserialize; +use opentelemetry::KeyValue; +use opentelemetry::metrics::MeterProvider as _; +use opentelemetry::metrics::ObservableGauge; +use parking_lot::Mutex; use serde_json_bytes::Value; use tower::Service; use super::PlanNode; use super::QueryKey; +use crate::Configuration; use crate::apollo_studio_interop::generate_usage_reporting; -use crate::configuration::QueryPlannerMode; -use crate::error::PlanErrors; +use crate::compute_job; +use crate::compute_job::ComputeJobType; +use crate::compute_job::MaybeBackPressureError; +use crate::error::FederationErrorBridge; use crate::error::QueryPlannerError; -use crate::error::SchemaError; use crate::error::ServiceBuildError; use crate::error::ValidationErrors; use crate::graphql; @@ -46,52 +43,59 @@ use crate::plugins::authorization::UnauthorizedPaths; use crate::plugins::telemetry::config::ApolloSignatureNormalizationAlgorithm; use crate::plugins::telemetry::config::Conf as TelemetryConfig; use crate::query_planner::convert::convert_root_query_plan_node; -use crate::query_planner::dual_query_planner::BothModeComparisonJob; -use crate::query_planner::fetch::QueryHash; +use crate::query_planner::fetch::SubgraphSchema; +use crate::query_planner::fetch::SubgraphSchemas; use crate::query_planner::labeler::add_defer_labels; -use crate::services::layers::query_analysis::ParsedDocument; -use crate::services::layers::query_analysis::ParsedDocumentInner; use crate::services::QueryPlannerContent; use crate::services::QueryPlannerRequest; use crate::services::QueryPlannerResponse; -use crate::spec::operation_limits::OperationLimits; -use crate::spec::query::change::QueryHashVisitor; +use crate::services::layers::query_analysis::ParsedDocument; +use crate::services::layers::query_analysis::ParsedDocumentInner; +use crate::services::query_planner::PlanOptions; use crate::spec::Query; use crate::spec::Schema; use crate::spec::SpecError; -use crate::Configuration; +use crate::spec::operation_limits::OperationLimits; pub(crate) const RUST_QP_MODE: &str = "rust"; -pub(crate) const JS_QP_MODE: &str = "js"; -const UNSUPPORTED_CONTEXT: &str = "context"; const UNSUPPORTED_FED1: &str = "fed1"; const INTERNAL_INIT_ERROR: &str = "internal"; -#[derive(Clone)] -/// A query planner that calls out to the nodejs router-bridge query planner. +const ENV_DISABLE_NON_LOCAL_SELECTIONS_CHECK: &str = + "APOLLO_ROUTER_DISABLE_SECURITY_NON_LOCAL_SELECTIONS_CHECK"; +/// Should we enforce the non-local selections limit? Default true, can be toggled off with an +/// environment variable. +/// +/// Disabling this check is very much not advisable and we don't expect that anyone will need to do +/// it. In the extremely unlikely case that the new protection breaks someone's legitimate queries, +/// though, they could temporarily disable this individual limit so they can still benefit from the +/// other new limits, until we improve the detection. +pub(crate) fn non_local_selections_check_enabled() -> bool { + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| { + let disabled = + std::env::var(ENV_DISABLE_NON_LOCAL_SELECTIONS_CHECK).as_deref() == Ok("true"); + + !disabled + }) +} + +/// A query planner that calls out to the apollo-federation crate. /// /// No caching is performed. To cache, wrap in a [`CachingQueryPlanner`]. -pub(crate) struct BridgeQueryPlanner { - planner: PlannerMode, +#[derive(Clone)] +pub(crate) struct QueryPlannerService { + planner: Arc, schema: Arc, - subgraph_schemas: Arc>>>, + subgraph_schemas: Arc, configuration: Arc, enable_authorization_directives: bool, _federation_instrument: ObservableGauge, + compute_jobs_queue_size_gauge: Arc>>>, signature_normalization_algorithm: ApolloSignatureNormalizationAlgorithm, introspection: Arc, } -#[derive(Clone)] -pub(crate) enum PlannerMode { - Js(Arc>), - Both { - js: Arc>, - rust: Arc, - }, - Rust(Arc), -} - fn federation_version_instrument(federation_version: Option) -> ObservableGauge { meter_provider() .meter("apollo/router") @@ -108,93 +112,23 @@ fn federation_version_instrument(federation_version: Option) -> ObservableG .init() } -impl PlannerMode { - async fn new( - schema: &Schema, - configuration: &Configuration, - old_planner: &Option>>, - rust_planner: Option>, - ) -> Result { - Ok(match configuration.experimental_query_planner_mode { - QueryPlannerMode::New => Self::Rust( - rust_planner - .expect("expected Rust QP instance for `experimental_query_planner_mode: new`"), - ), - QueryPlannerMode::Legacy => { - Self::Js(Self::js_planner(&schema.raw_sdl, configuration, old_planner).await?) - } - QueryPlannerMode::Both => Self::Both { - js: Self::js_planner(&schema.raw_sdl, configuration, old_planner).await?, - rust: rust_planner.expect( - "expected Rust QP instance for `experimental_query_planner_mode: both`", - ), - }, - QueryPlannerMode::BothBestEffort => { - if let Some(rust) = rust_planner { - Self::Both { - js: Self::js_planner(&schema.raw_sdl, configuration, old_planner).await?, - rust, - } - } else { - Self::Js(Self::js_planner(&schema.raw_sdl, configuration, old_planner).await?) - } - } - }) - } - - pub(crate) fn maybe_rust( - schema: &Schema, - configuration: &Configuration, - ) -> Result>, ServiceBuildError> { - match configuration.experimental_query_planner_mode { - QueryPlannerMode::Legacy => Ok(None), - QueryPlannerMode::New | QueryPlannerMode::Both => { - Ok(Some(Self::rust(schema, configuration)?)) - } - QueryPlannerMode::BothBestEffort => match Self::rust(schema, configuration) { - Ok(planner) => Ok(Some(planner)), - Err(error) => { - tracing::info!("Falling back to the legacy query planner: {error}"); - Ok(None) - } - }, - } - } - - fn rust( +impl QueryPlannerService { + fn create_planner( schema: &Schema, configuration: &Configuration, ) -> Result, ServiceBuildError> { - let config = apollo_federation::query_plan::query_planner::QueryPlannerConfig { - reuse_query_fragments: configuration - .supergraph - .reuse_query_fragments - .unwrap_or(true), - subgraph_graphql_validation: false, - generate_query_fragments: configuration.supergraph.generate_query_fragments, - incremental_delivery: - apollo_federation::query_plan::query_planner::QueryPlanIncrementalDeliveryConfig { - enable_defer: configuration.supergraph.defer_support, - }, - type_conditioned_fetching: configuration.experimental_type_conditioned_fetching, - debug: Default::default(), - }; + let config = configuration.rust_query_planner_config(); let result = QueryPlanner::new(schema.federation_supergraph(), config); match &result { - Err(FederationError::SingleFederationError { - inner: error, - trace: _, - }) => match error { + Err(FederationError::SingleFederationError(error)) => match error { SingleFederationError::UnsupportedFederationVersion { .. } => { metric_rust_qp_init(Some(UNSUPPORTED_FED1)); } - SingleFederationError::UnsupportedFeature { message: _, kind } => match kind { - apollo_federation::error::UnsupportedFeatureKind::Context => { - metric_rust_qp_init(Some(UNSUPPORTED_CONTEXT)) - } - _ => metric_rust_qp_init(Some(INTERNAL_INIT_ERROR)), - }, + SingleFederationError::UnsupportedFeature { + message: _, + kind: _, + } => metric_rust_qp_init(Some(INTERNAL_INIT_ERROR)), _ => { metric_rust_qp_init(Some(INTERNAL_INIT_ERROR)); } @@ -206,196 +140,99 @@ impl PlannerMode { Ok(Arc::new(result.map_err(ServiceBuildError::QpInitError)?)) } - async fn js_planner( - sdl: &str, - configuration: &Configuration, - old_js_planner: &Option>>, - ) -> Result>, ServiceBuildError> { - let query_planner_configuration = configuration.js_query_planner_config(); - let planner = match old_js_planner { - None => Planner::new(sdl.to_owned(), query_planner_configuration).await?, - Some(old_planner) => { - old_planner - .update(sdl.to_owned(), query_planner_configuration) - .await? - } - }; - Ok(Arc::new(planner)) - } - - async fn plan( + async fn plan_inner( &self, doc: &ParsedDocument, - filtered_query: String, operation: Option, plan_options: PlanOptions, + compute_job_type: ComputeJobType, // Initialization code that needs mutable access to the plan, // before we potentially share it in Arc with a background thread // for "both" mode. init_query_plan_root_node: impl Fn(&mut PlanNode) -> Result<(), ValidationErrors>, - ) -> Result, QueryPlannerError> { - match self { - PlannerMode::Js(js) => { - let start = Instant::now(); - - let result = js.plan(filtered_query, operation, plan_options).await; - - let elapsed = start.elapsed().as_secs_f64(); - metric_query_planning_plan_duration(JS_QP_MODE, elapsed); - - let mut success = result - .map_err(QueryPlannerError::RouterBridgeError)? - .into_result() - .map_err(PlanErrors::from)?; + ) -> Result> { + let doc = doc.clone(); + let rust_planner = self.planner.clone(); + let job = move |status: compute_job::JobStatus<'_, _>| -> Result<_, QueryPlannerError> { + let start = Instant::now(); + + let check = move || status.check_for_cooperative_cancellation(); + let query_plan_options = QueryPlanOptions { + override_conditions: plan_options.override_conditions, + check_for_cooperative_cancellation: Some(&check), + non_local_selections_limit_enabled: non_local_selections_check_enabled(), + disabled_subgraph_names: Default::default(), + }; - if let Some(root_node) = &mut success.data.query_plan.node { - // Arc freshly deserialized from Deno should be unique, so this doesn’t clone: - let root_node = Arc::make_mut(root_node); - init_query_plan_root_node(root_node)?; - } - Ok(success) + let result = operation + .as_deref() + .map(|n| Name::new(n).map_err(FederationError::from)) + .transpose() + .and_then(|operation| { + rust_planner.build_query_plan(&doc.executable, operation, query_plan_options) + }); + if let Err(FederationError::SingleFederationError( + SingleFederationError::InternalUnmergeableFields { .. }, + )) = &result + { + u64_counter!( + "apollo.router.operations.query_planner.unmergeable_fields", + "Query planner caught attempting to merge unmergeable fields", + 1 + ); } - PlannerMode::Rust(rust_planner) => { - let doc = doc.clone(); - let rust_planner = rust_planner.clone(); - let (plan, mut root_node) = tokio::task::spawn_blocking(move || { - let start = Instant::now(); - - let query_plan_options = QueryPlanOptions { - override_conditions: plan_options.override_conditions, - }; - - let result = operation - .as_deref() - .map(|n| Name::new(n).map_err(FederationError::from)) - .transpose() - .and_then(|operation| { - rust_planner.build_query_plan( - &doc.executable, - operation, - query_plan_options, - ) - }) - .map_err(|e| QueryPlannerError::FederationError(e.to_string())); - - let elapsed = start.elapsed().as_secs_f64(); - metric_query_planning_plan_duration(RUST_QP_MODE, elapsed); - - result.map(|plan| { - let root_node = convert_root_query_plan_node(&plan); - (plan, root_node) - }) - }) - .await - .expect("query planner panicked")?; - if let Some(node) = &mut root_node { - init_query_plan_root_node(node)?; - } + let result = result.map_err(FederationErrorBridge::from); - // Dummy value overwritten below in `BrigeQueryPlanner::plan` - let usage_reporting = UsageReporting { - stats_report_key: Default::default(), - referenced_fields_by_type: Default::default(), - }; - - Ok(PlanSuccess { - usage_reporting, - data: QueryPlanResult { - formatted_query_plan: Some(Arc::new(plan.to_string())), - query_plan: QueryPlan { - node: root_node.map(Arc::new), - }, - evaluated_plan_count: plan - .statistics - .evaluated_plan_count - .clone() - .into_inner() as u64, - }, - }) - } - PlannerMode::Both { js, rust } => { - let start = Instant::now(); - - let result = js - .plan(filtered_query, operation.clone(), plan_options.clone()) - .await; - - let elapsed = start.elapsed().as_secs_f64(); - metric_query_planning_plan_duration(JS_QP_MODE, elapsed); - - let mut js_result = result - .map_err(QueryPlannerError::RouterBridgeError)? - .into_result() - .map_err(PlanErrors::from); - - if let Ok(success) = &mut js_result { - if let Some(root_node) = &mut success.data.query_plan.node { - // Arc freshly deserialized from Deno should be unique, so this doesn’t clone: - let root_node = Arc::make_mut(root_node); - init_query_plan_root_node(root_node)?; - } + let elapsed = start.elapsed().as_secs_f64(); + match &result { + Ok(_) => metric_query_planning_plan_duration(RUST_QP_MODE, elapsed, "success"), + Err(FederationErrorBridge::Cancellation(e)) if e.contains("timeout") => { + metric_query_planning_plan_duration(RUST_QP_MODE, elapsed, "timeout") } - - let query_plan_options = QueryPlanOptions { - override_conditions: plan_options.override_conditions, - }; - BothModeComparisonJob { - rust_planner: rust.clone(), - js_duration: elapsed, - document: doc.executable.clone(), - operation_name: operation, - // Exclude usage reporting from the Result sent for comparison - js_result: js_result - .as_ref() - .map(|success| success.data.clone()) - .map_err(|e| e.errors.clone()), - plan_options: query_plan_options, + Err(FederationErrorBridge::Cancellation(_)) => { + metric_query_planning_plan_duration(RUST_QP_MODE, elapsed, "cancelled") } - .schedule(); - - Ok(js_result?) + Err(_) => metric_query_planning_plan_duration(RUST_QP_MODE, elapsed, "error"), } - } - } - async fn subgraphs( - &self, - ) -> Result>>, ServiceBuildError> { - let js = match self { - PlannerMode::Js(js) => js, - PlannerMode::Both { js, .. } => js, - PlannerMode::Rust(rust) => { - return Ok(rust - .subgraph_schemas() - .iter() - .map(|(name, schema)| (name.to_string(), Arc::new(schema.schema().clone()))) - .collect()) - } + let plan = result?; + let root_node = convert_root_query_plan_node(&plan); + Ok((plan, root_node)) }; - js.subgraphs() - .await? - .into_iter() - .map(|(name, schema_str)| { - let schema = apollo_compiler::Schema::parse_and_validate(schema_str, "") - .map_err(|errors| SchemaError::Validate(errors.into()))?; - Ok((name, Arc::new(schema))) - }) - .collect() + let (plan, mut root_node) = compute_job::execute(compute_job_type, job) + .map_err(MaybeBackPressureError::TemporaryError)? + .await?; + if let Some(node) = &mut root_node { + init_query_plan_root_node(node).map_err(QueryPlannerError::from)?; + } + + Ok(QueryPlanResult { + formatted_query_plan: Some(Arc::new(plan.to_string())), + query_plan_root_node: root_node.map(Arc::new), + evaluated_plan_count: plan.statistics.evaluated_plan_count.clone().into_inner() as u64, + evaluated_plan_paths: plan.statistics.evaluated_plan_paths.clone().into_inner() as u64, + }) } -} -impl BridgeQueryPlanner { pub(crate) async fn new( schema: Arc, configuration: Arc, - old_js_planner: Option>>, - rust_planner: Option>, - introspection_cache: Arc, ) -> Result { - let planner = - PlannerMode::new(&schema, &configuration, &old_js_planner, rust_planner).await?; - - let subgraph_schemas = Arc::new(planner.subgraphs().await?); + let introspection = Arc::new(IntrospectionCache::new(&configuration)); + let planner = Self::create_planner(&schema, &configuration)?; + + let subgraph_schemas = Arc::new( + planner + .subgraph_schemas() + .iter() + .map(|(name, schema)| { + ( + name.to_string(), + SubgraphSchema::new(schema.schema().clone()), + ) + }) + .collect(), + ); let enable_authorization_directives = AuthorizationPlugin::enable_directives(&configuration, &schema)?; @@ -410,27 +247,17 @@ impl BridgeQueryPlanner { enable_authorization_directives, configuration, _federation_instrument: federation_instrument, + compute_jobs_queue_size_gauge: Default::default(), signature_normalization_algorithm, - introspection: introspection_cache, + introspection, }) } - pub(crate) fn js_planner(&self) -> Option>> { - match &self.planner { - PlannerMode::Js(js) => Some(js.clone()), - PlannerMode::Both { js, .. } => Some(js.clone()), - PlannerMode::Rust(_) => None, - } - } - - #[cfg(test)] pub(crate) fn schema(&self) -> Arc { self.schema.clone() } - pub(crate) fn subgraph_schemas( - &self, - ) -> Arc>>> { + pub(crate) fn subgraph_schemas(&self) -> Arc { self.subgraph_schemas.clone() } @@ -451,7 +278,7 @@ impl BridgeQueryPlanner { )?; let (fragments, operation, defer_stats, schema_aware_hash) = - Query::extract_query_information(&self.schema, executable, operation_name)?; + Query::extract_query_information(&self.schema, &query, executable, operation_name)?; let subselections = crate::spec::query::subselections::collect_subselections( &self.configuration, @@ -485,117 +312,100 @@ impl BridgeQueryPlanner { selections: Query, plan_options: PlanOptions, doc: &ParsedDocument, + compute_job_type: ComputeJobType, query_metrics: OperationLimits, - ) -> Result { - let plan_success = self - .planner - .plan( + ) -> Result> { + let plan_result = self + .plan_inner( doc, - filtered_query.clone(), operation.clone(), plan_options, + compute_job_type, |root_node| { - root_node.init_parsed_operations_and_hash_subqueries( - &self.subgraph_schemas, - &self.schema.raw_sdl, - )?; + root_node.init_parsed_operations_and_hash_subqueries(&self.subgraph_schemas)?; root_node.extract_authorization_metadata(self.schema.supergraph_schema(), &key); Ok(()) }, ) .await?; + let QueryPlanResult { + query_plan_root_node, + formatted_query_plan, + evaluated_plan_count, + evaluated_plan_paths, + } = plan_result; + + // If the query is filtered, we want to generate the signature using the original query and generate the + // reference using the filtered query. To do this, we need to re-parse the original query here. + let signature_doc = if original_query != filtered_query { + Query::parse_document( + &original_query, + operation.clone().as_deref(), + &self.schema, + &self.configuration, + ) + .unwrap_or(doc.clone()) + } else { + doc.clone() + }; - match plan_success { - PlanSuccess { - data: - QueryPlanResult { - query_plan: QueryPlan { node: Some(node) }, - formatted_query_plan, - evaluated_plan_count, - }, - mut usage_reporting, - } => { - // If the query is filtered, we want to generate the signature using the original query and generate the - // reference using the filtered query. To do this, we need to re-parse the original query here. - let signature_doc = if original_query != filtered_query { - Query::parse_document( - &original_query, - operation.clone().as_deref(), - &self.schema, - &self.configuration, - ) - .unwrap_or(doc.clone()) - } else { - doc.clone() - }; - - u64_histogram!( - "apollo.router.query_planning.plan.evaluated_plans", - "Number of query plans evaluated for a query before choosing the best one", - evaluated_plan_count - ); + let usage_reporting = generate_usage_reporting( + &signature_doc.executable, + &doc.executable, + &operation, + self.schema.supergraph_schema(), + &self.signature_normalization_algorithm, + ); - let generated_usage_reporting = generate_usage_reporting( - &signature_doc.executable, - &doc.executable, - &operation, - self.schema.supergraph_schema(), - &self.signature_normalization_algorithm, - ); + if let Some(node) = query_plan_root_node { + u64_histogram!( + "apollo.router.query_planning.plan.evaluated_plans", + "Number of query plans evaluated for a query before choosing the best one", + evaluated_plan_count + ); + u64_histogram!( + "apollo.router.query_planning.plan.evaluated_paths", + "Number of paths (including intermediate ones) considered to plan a query before starting to generate a plan", + evaluated_plan_paths + ); - usage_reporting.stats_report_key = - generated_usage_reporting.result.stats_report_key; - usage_reporting.referenced_fields_by_type = - generated_usage_reporting.result.referenced_fields_by_type; - - Ok(QueryPlannerContent::Plan { - plan: Arc::new(super::QueryPlan { - usage_reporting: Arc::new(usage_reporting), - root: node, - formatted_query_plan, - query: Arc::new(selections), - query_metrics, - estimated_size: Default::default(), - }), - }) - } - #[cfg_attr(feature = "failfast", allow(unused_variables))] - PlanSuccess { - data: - QueryPlanResult { - query_plan: QueryPlan { node: None }, - .. - }, - usage_reporting, - } => { - failfast_debug!("empty query plan"); - Err(QueryPlannerError::EmptyPlan(usage_reporting)) - } + Ok(QueryPlannerContent::Plan { + plan: Arc::new(super::QueryPlan { + usage_reporting: Arc::new(usage_reporting), + root: node, + formatted_query_plan, + query: Arc::new(selections), + query_metrics, + estimated_size: Default::default(), + }), + }) + } else { + failfast_debug!("empty query plan"); + Err(QueryPlannerError::EmptyPlan(usage_reporting.get_stats_report_key()).into()) } } } -impl Service for BridgeQueryPlanner { +impl Service for QueryPlannerService { type Response = QueryPlannerResponse; - type Error = QueryPlannerError; + type Error = MaybeBackPressureError; type Future = BoxFuture<'static, Result>; - fn poll_ready( - &mut self, - _cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::task::Poll::Ready(Ok(())) + fn poll_ready(&mut self, _cx: &mut std::task::Context<'_>) -> Poll> { + Poll::Ready(Ok(())) } fn call(&mut self, req: QueryPlannerRequest) -> Self::Future { + let start = Instant::now(); let QueryPlannerRequest { query: original_query, operation_name, document, metadata, plan_options, + compute_job_type, } = req; let this = self.clone(); @@ -607,26 +417,27 @@ impl Service for BridgeQueryPlanner { Err(e) => { return Err(QueryPlannerError::SpecError(SpecError::TransformError( e.to_string(), - ))) + )) + .into()); } Ok(modified_query) => { let executable_document = modified_query .to_executable_validate(api_schema) // Assume transformation creates a valid document: ignore conversion errors - .map_err(|e| SpecError::ValidationError(e.into()))?; - let hash = QueryHashVisitor::hash_query( - this.schema.supergraph_schema(), - &this.schema.raw_sdl, - &executable_document, - operation_name.as_deref(), - ) - .map_err(|e| SpecError::QueryHashing(e.to_string()))?; + .map_err(|e| { + QueryPlannerError::from(SpecError::ValidationError(e.into())) + })?; + let hash = this + .schema + .schema_id + .operation_hash(&modified_query.to_string(), operation_name.as_deref()); doc = ParsedDocumentInner::new( modified_query, Arc::new(executable_document), operation_name.as_deref(), - Arc::new(QueryHash(hash)), - )?; + Arc::new(hash), + ) + .map_err(QueryPlannerError::from)?; } } @@ -640,9 +451,16 @@ impl Service for BridgeQueryPlanner { plan_options, }, doc, + compute_job_type, ) .await; + f64_histogram!( + "apollo.router.query_planning.total.duration", + "Duration of the time the router waited for a query plan, including both the queue time and planning time, in seconds.", + start.elapsed().as_secs_f64() + ); + match res { Ok(query_planner_content) => Ok(QueryPlannerResponse::builder() .content(query_planner_content) @@ -659,12 +477,13 @@ impl Service for BridgeQueryPlanner { // Appease clippy::type_complexity pub(crate) type FilteredQuery = (Vec, ast::Document); -impl BridgeQueryPlanner { +impl QueryPlannerService { async fn get( &self, mut key: QueryKey, mut doc: ParsedDocument, - ) -> Result { + compute_job_type: ComputeJobType, + ) -> Result> { let mut query_metrics = Default::default(); let mut selections = self .parse_selections( @@ -693,10 +512,10 @@ impl BridgeQueryPlanner { .await { ControlFlow::Continue(()) => (), - ControlFlow::Break(response) => { + ControlFlow::Break(result) => { return Ok(QueryPlannerContent::CachedIntrospectionResponse { - response: Box::new(response), - }) + response: Box::new(result.map_err(MaybeBackPressureError::TemporaryError)?), + }); } } @@ -729,23 +548,23 @@ impl BridgeQueryPlanner { }; if let Some((unauthorized_paths, new_doc)) = filter_res { - key.filtered_query = new_doc.to_string(); + let new_query = new_doc.to_string(); + let new_hash = self + .schema + .schema_id + .operation_hash(&new_query, key.operation_name.as_deref()); + + key.filtered_query = new_query; let executable_document = new_doc .to_executable_validate(self.schema.api_schema()) - .map_err(|e| SpecError::ValidationError(e.into()))?; - let hash = QueryHashVisitor::hash_query( - self.schema.supergraph_schema(), - &self.schema.raw_sdl, - &executable_document, - key.operation_name.as_deref(), - ) - .map_err(|e| SpecError::QueryHashing(e.to_string()))?; + .map_err(|e| QueryPlannerError::from(SpecError::ValidationError(e.into())))?; doc = ParsedDocumentInner::new( new_doc, Arc::new(executable_document), key.operation_name.as_deref(), - Arc::new(QueryHash(hash)), - )?; + Arc::new(new_hash), + ) + .map_err(QueryPlannerError::from)?; selections.unauthorized.paths = unauthorized_paths; } @@ -770,69 +589,40 @@ impl BridgeQueryPlanner { selections, key.plan_options, &doc, + compute_job_type, query_metrics, ) .await } -} -/// Data coming from the `plan` method on the router_bridge -// Note: Reexported under `apollo_router::_private` -#[derive(Debug, Clone, PartialEq, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct QueryPlanResult { - pub(super) formatted_query_plan: Option>, - pub(super) query_plan: QueryPlan, - pub(super) evaluated_plan_count: u64, -} - -impl QueryPlanResult { - pub fn formatted_query_plan(&self) -> Option<&str> { - self.formatted_query_plan.as_deref().map(String::as_str) + pub(super) fn activate(&self) { + // Gauges MUST be initialized after a meter provider is created. + // When a hot reload happens this means that the gauges must be re-initialized. + *self.compute_jobs_queue_size_gauge.lock() = + Some(crate::compute_job::create_queue_size_gauge()); + self.introspection.activate(); } } -#[derive(Debug, Clone, PartialEq, Deserialize)] -#[serde(rename_all = "camelCase")] -/// The root query plan container. -pub(super) struct QueryPlan { - /// The hierarchical nodes that make up the query plan - pub(super) node: Option>, -} - -// Note: Reexported under `apollo_router::_private` -pub fn render_diff(differences: &[diff::Result<&str>]) -> String { - let mut output = String::new(); - for diff_line in differences { - match diff_line { - diff::Result::Left(l) => { - let trimmed = l.trim(); - if !trimmed.starts_with('#') && !trimmed.is_empty() { - writeln!(&mut output, "-{l}").expect("write will never fail"); - } else { - writeln!(&mut output, " {l}").expect("write will never fail"); - } - } - diff::Result::Both(l, _) => { - writeln!(&mut output, " {l}").expect("write will never fail"); - } - diff::Result::Right(r) => { - let trimmed = r.trim(); - if trimmed != "---" && !trimmed.is_empty() { - writeln!(&mut output, "+{r}").expect("write will never fail"); - } - } - } - } - output +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct QueryPlanResult { + pub(super) formatted_query_plan: Option>, + pub(super) query_plan_root_node: Option>, + pub(super) evaluated_plan_count: u64, + pub(super) evaluated_plan_paths: u64, } -pub(crate) fn metric_query_planning_plan_duration(planner: &'static str, elapsed: f64) { +pub(crate) fn metric_query_planning_plan_duration( + planner: &'static str, + elapsed: f64, + outcome: &'static str, +) { f64_histogram!( "apollo.router.query_planning.plan.duration", - "Duration of the query planning.", + "Duration of the query planning, in seconds.", elapsed, - "planner" = planner + "planner" = planner, + "outcome" = outcome ); } @@ -857,12 +647,9 @@ pub(crate) fn metric_rust_qp_init(init_error_kind: Option<&'static str>) { #[cfg(test)] mod tests { - use std::fs; - use std::path::PathBuf; + use std::collections::HashMap; - use serde_json::json; use test_log::test; - use tower::Service; use tower::ServiceExt; use super::*; @@ -896,44 +683,27 @@ mod tests { } } - #[test] - fn empty_query_plan() { - serde_json::from_value::(json!({ "plan": { "kind": "QueryPlan"} } )).expect( - "If this test fails, It probably means QueryPlan::node isn't an Option anymore.\n - Introspection queries return an empty QueryPlan, so the node field needs to remain optional.", - ); - } - #[test(tokio::test)] async fn federation_versions() { - async { - let sdl = include_str!("../testdata/minimal_fed1_supergraph.graphql"); - let config = Arc::default(); - let schema = Schema::parse(sdl, &config).unwrap(); - let introspection = Arc::new(IntrospectionCache::new(&config)); - let _planner = - BridgeQueryPlanner::new(schema.into(), config, None, None, introspection) - .await - .unwrap(); - - assert_gauge!( - "apollo.router.supergraph.federation", - 1, - federation.version = 1 - ); - } - .with_metrics() - .await; + let sdl = include_str!("../testdata/minimal_fed1_supergraph.graphql"); + let config = Arc::default(); + let schema = Schema::parse(sdl, &config).unwrap(); + let error = QueryPlannerService::new(schema.into(), config) + .await + .err() + .expect("expected error for fed1 supergraph"); + assert_eq!( + error.to_string(), + "failed to initialize the query planner: Supergraphs composed with federation version 1 are not supported. Please recompose your supergraph with federation version 2 or greater" + ); async { let sdl = include_str!("../testdata/minimal_supergraph.graphql"); let config = Arc::default(); let schema = Schema::parse(sdl, &config).unwrap(); - let introspection = Arc::new(IntrospectionCache::new(&config)); - let _planner = - BridgeQueryPlanner::new(schema.into(), config, None, None, introspection) - .await - .unwrap(); + let _planner = QueryPlannerService::new(schema.into(), config) + .await + .unwrap(); assert_gauge!( "apollo.router.supergraph.federation", @@ -951,15 +721,9 @@ mod tests { let schema = Arc::new(Schema::parse(EXAMPLE_SCHEMA, &config).unwrap()); let query = include_str!("testdata/unknown_introspection_query.graphql"); - let planner = BridgeQueryPlanner::new( - schema.clone(), - Default::default(), - None, - None, - Arc::new(IntrospectionCache::new(&config)), - ) - .await - .unwrap(); + let planner = QueryPlannerService::new(schema.clone(), Default::default()) + .await + .unwrap(); let doc = Query::parse_document(query, None, &schema, &Configuration::default()).unwrap(); @@ -981,15 +745,18 @@ mod tests { selections, PlanOptions::default(), &doc, + ComputeJobType::QueryPlanning, query_metrics ) .await .unwrap_err(); match err { - QueryPlannerError::EmptyPlan(usage_reporting) => { + MaybeBackPressureError::PermanentError(QueryPlannerError::EmptyPlan( + stats_report_key, + )) => { insta::with_settings!({sort_maps => true}, { - insta::assert_json_snapshot!("empty_query_plan_usage_reporting", usage_reporting); + insta::assert_json_snapshot!("empty_query_plan_usage_reporting", stats_report_key); }); } e => { @@ -1058,15 +825,9 @@ mod tests { let configuration = Arc::new(configuration); let schema = Schema::parse(EXAMPLE_SCHEMA, &configuration).unwrap(); - let planner = BridgeQueryPlanner::new( - schema.into(), - configuration.clone(), - None, - None, - Arc::new(IntrospectionCache::new(&configuration)), - ) - .await - .unwrap(); + let planner = QueryPlannerService::new(schema.into(), configuration.clone()) + .await + .unwrap(); macro_rules! s { ($query: expr) => { @@ -1208,7 +969,7 @@ mod tests { }}"#); } - async fn subselections_keys(query: &str, planner: &BridgeQueryPlanner) -> String { + async fn subselections_keys(query: &str, planner: &QueryPlannerService) -> String { fn check_query_plan_coverage( node: &PlanNode, parent_label: Option<&str>, @@ -1337,6 +1098,7 @@ mod tests { plan_options: PlanOptions::default(), }, doc, + ComputeJobType::QueryPlanning, ) .await .unwrap(); @@ -1372,15 +1134,9 @@ mod tests { let configuration = Arc::new(configuration); let schema = Schema::parse(schema, &configuration).unwrap(); - let planner = BridgeQueryPlanner::new( - schema.into(), - configuration.clone(), - None, - None, - Arc::new(IntrospectionCache::new(&configuration)), - ) - .await - .unwrap(); + let planner = QueryPlannerService::new(schema.into(), configuration.clone()) + .await + .unwrap(); let doc = Query::parse_document( original_query, @@ -1389,7 +1145,7 @@ mod tests { &configuration, )?; - planner + let result = planner .get( QueryKey { original_query: original_query.to_string(), @@ -1399,54 +1155,14 @@ mod tests { plan_options, }, doc, + ComputeJobType::QueryPlanning, ) - .await - } - - #[test] - fn router_bridge_dependency_is_pinned() { - let cargo_manifest: serde_json::Value = basic_toml::from_str( - &fs::read_to_string(PathBuf::from(&env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) - .expect("could not read Cargo.toml"), - ) - .expect("could not parse Cargo.toml"); - let router_bridge_version = cargo_manifest - .get("dependencies") - .expect("Cargo.toml does not contain dependencies") - .as_object() - .expect("Cargo.toml dependencies key is not an object") - .get("router-bridge") - .expect("Cargo.toml dependencies does not have an entry for router-bridge") - .as_str() - .unwrap_or_default(); - assert!( - router_bridge_version.contains('='), - "router-bridge in Cargo.toml is not pinned with a '=' prefix" - ); - } - - #[tokio::test] - async fn test_both_mode() { - let mut harness = crate::TestHarness::builder() - // auth is not relevant here, but supergraph.graphql uses join/v0.1 - // which is not supported by the Rust query planner - .schema(include_str!("../../tests/fixtures/supergraph-auth.graphql")) - .configuration_json(serde_json::json!({ - "experimental_query_planner_mode": "both", - })) - .unwrap() - .build_supergraph() - .await - .unwrap(); - - let request = supergraph::Request::fake_builder() - .query("{ topProducts { name }}") - .build() - .unwrap(); - let mut response = harness.ready().await.unwrap().call(request).await.unwrap(); - assert!(response.response.status().is_success()); - let response = response.next_response().await.unwrap(); - assert!(response.errors.is_empty()); + .await; + match result { + Ok(x) => Ok(x), + Err(MaybeBackPressureError::PermanentError(e)) => Err(e), + Err(MaybeBackPressureError::TemporaryError(e)) => panic!("{e:?}"), + } } #[tokio::test] @@ -1457,10 +1173,6 @@ mod tests { // auth is not relevant here, but supergraph.graphql uses join/v0.1 // which is not supported by the Rust query planner .schema(include_str!("../../tests/fixtures/supergraph-auth.graphql")) - .configuration_json(serde_json::json!({ - "experimental_query_planner_mode": "new", - })) - .unwrap() .subgraph_hook(move |_name, _default| { let subgraph_queries = Arc::clone(&subgraph_queries); tower::service_fn(move |request: subgraph::Request| { @@ -1478,6 +1190,7 @@ mod tests { Ok(subgraph::Response::builder() .extensions(crate::json_ext::Object::new()) .context(request.context) + .subgraph_name(String::default()) .build()) } }) @@ -1506,20 +1219,12 @@ mod tests { fn test_metric_query_planning_plan_duration() { let start = Instant::now(); let elapsed = start.elapsed().as_secs_f64(); - metric_query_planning_plan_duration(RUST_QP_MODE, elapsed); + metric_query_planning_plan_duration(RUST_QP_MODE, elapsed, "success"); assert_histogram_exists!( "apollo.router.query_planning.plan.duration", f64, - "planner" = "rust" - ); - - let start = Instant::now(); - let elapsed = start.elapsed().as_secs_f64(); - metric_query_planning_plan_duration(JS_QP_MODE, elapsed); - assert_histogram_exists!( - "apollo.router.query_planning.plan.duration", - f64, - "planner" = "js" + "planner" = "rust", + "outcome" = "success" ); } @@ -1531,13 +1236,6 @@ mod tests { 1, "init.is_success" = true ); - metric_rust_qp_init(Some(UNSUPPORTED_CONTEXT)); - assert_counter!( - "apollo.router.lifecycle.query_planner.init", - 1, - "init.error_kind" = "context", - "init.is_success" = false - ); metric_rust_qp_init(Some(UNSUPPORTED_FED1)); assert_counter!( "apollo.router.lifecycle.query_planner.init", diff --git a/apollo-router/src/query_planner/rewrites.rs b/apollo-router/src/query_planner/rewrites.rs index 878c18da04..1d8abee3f8 100644 --- a/apollo-router/src/query_planner/rewrites.rs +++ b/apollo-router/src/query_planner/rewrites.rs @@ -21,7 +21,7 @@ use crate::spec::Schema; /// Given a path, separates the last element of path and the rest of it and return them as a pair. /// This will return `None` if the path is empty. fn split_path_last_element(path: &Path) -> Option<(Path, &PathElement)> { - // If we have a `last()`, then we have a `parent()` too, so unwrapping shoud be safe. + // If we have a `last()`, then we have a `parent()` too, so unwrapping should be safe. path.last().map(|last| (path.parent().unwrap(), last)) } @@ -72,18 +72,18 @@ impl DataRewrite { split_path_last_element(&renamer.path) { data.select_values_and_paths_mut(schema, &parent, |_path, selected| { - if let Some(obj) = selected.as_object_mut() { - if let Some(value) = obj.remove(k.as_str()) { - obj.insert(renamer.rename_key_to.as_str(), value); - } + if let Some(obj) = selected.as_object_mut() + && let Some(value) = obj.remove(k.as_str()) + { + obj.insert(renamer.rename_key_to.as_str(), value); } if let Some(arr) = selected.as_array_mut() { for item in arr { - if let Some(obj) = item.as_object_mut() { - if let Some(value) = obj.remove(k.as_str()) { - obj.insert(renamer.rename_key_to.as_str(), value); - } + if let Some(obj) = item.as_object_mut() + && let Some(value) = obj.remove(k.as_str()) + { + obj.insert(renamer.rename_key_to.as_str(), value); } } } diff --git a/apollo-router/src/query_planner/selection.rs b/apollo-router/src/query_planner/selection.rs index cf58be1244..ad221cad05 100644 --- a/apollo-router/src/query_planner/selection.rs +++ b/apollo-router/src/query_planner/selection.rs @@ -1,7 +1,7 @@ use apollo_compiler::schema::ExtendedType; -use apollo_compiler::Name; -use serde::Deserialize; -use serde::Serialize; +use apollo_federation::query_plan::requires_selection::Field; +use apollo_federation::query_plan::requires_selection::InlineFragment; +use apollo_federation::query_plan::requires_selection::Selection; use serde_json_bytes::ByteString; use serde_json_bytes::Entry; @@ -11,53 +11,6 @@ use crate::json_ext::ValueExt; use crate::spec::Schema; use crate::spec::TYPENAME; -/// A selection that is part of a fetch. -/// Selections are used to propagate data to subgraph fetches. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "PascalCase", tag = "kind")] -pub(crate) enum Selection { - /// A field selection. - Field(Field), - - /// An inline fragment selection. - InlineFragment(InlineFragment), -} - -/// The field that is used -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct Field { - /// An optional alias for the field. - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) alias: Option, - - /// The name of the field. - pub(crate) name: Name, - - /// The selections for the field. - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) selections: Option>, -} - -impl Field { - // Mirroring `apollo_compiler::Field::response_name` - pub(crate) fn response_name(&self) -> &Name { - self.alias.as_ref().unwrap_or(&self.name) - } -} - -/// An inline fragment. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct InlineFragment { - /// The required fragment type. - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) type_condition: Option, - - /// The selections from the fragment. - pub(crate) selections: Vec, -} - pub(crate) fn execute_selection_set<'a>( input_content: &'a Value, selections: &[Selection], @@ -130,25 +83,28 @@ pub(crate) fn execute_selection_set<'a>( if let Some(elements) = value.as_array() { let selected = elements .iter() - .map(|element| match selections { - Some(sels) => execute_selection_set( - element, - sels, - schema, - field_type - .as_ref() - .map(|ty| ty.inner_named_type().as_str()), - ), - None => element.clone(), + .map(|element| { + if !selections.is_empty() { + execute_selection_set( + element, + selections, + schema, + field_type + .as_ref() + .map(|ty| ty.inner_named_type().as_str()), + ) + } else { + element.clone() + } }) .collect::>(); output.insert(key.clone(), Value::Array(selected)); - } else if let Some(sels) = selections { + } else if !selections.is_empty() { output.insert( key.clone(), execute_selection_set( value, - sels, + selections, schema, field_type.as_ref().map(|ty| ty.inner_named_type().as_str()), ), @@ -165,18 +121,17 @@ pub(crate) fn execute_selection_set<'a>( }) => match type_condition { None => continue, Some(condition) => { - if type_condition_matches(schema, current_type, condition) { - if let Value::Object(selected) = + if type_condition_matches(schema, current_type, condition) + && let Value::Object(selected) = execute_selection_set(input_content, selections, schema, current_type) - { - for (key, value) in selected.into_iter() { - match output.entry(key) { - Entry::Vacant(e) => { - e.insert(value); - } - Entry::Occupied(e) => { - e.into_mut().type_aware_deep_merge(value, schema); - } + { + for (key, value) in selected.into_iter() { + match output.entry(key) { + Entry::Vacant(e) => { + e.insert(value); + } + Entry::Occupied(e) => { + e.into_mut().type_aware_deep_merge(value, schema); } } } @@ -736,9 +691,9 @@ mod tests { @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { query: Query } - + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE - + directive @join__field( graph: join__Graph requires: join__FieldSet @@ -748,14 +703,14 @@ mod tests { override: String usedOverridden: Boolean ) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION - + directive @join__graph(name: String!, url: String!) on ENUM_VALUE - + directive @join__implements( graph: join__Graph! interface: String! ) repeatable on OBJECT | INTERFACE - + directive @join__type( graph: join__Graph! key: join__FieldSet @@ -763,27 +718,27 @@ mod tests { resolvable: Boolean! = true isInterfaceObject: Boolean! = false ) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR - + directive @join__unionMember( graph: join__Graph! member: String! ) repeatable on UNION - + directive @link( url: String as: String for: link__Purpose import: [link__Import] ) repeatable on SCHEMA - + scalar join__FieldSet - + enum join__Graph { TEST @join__graph(name: "test", url: "http://localhost:4001/graphql") } - + scalar link__Import - + enum link__Purpose { SECURITY EXECUTION diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__empty_query_plan_usage_reporting.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__empty_query_plan_usage_reporting.snap deleted file mode 100644 index 49806e8f96..0000000000 --- a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__empty_query_plan_usage_reporting.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: apollo-router/src/query_planner/bridge_query_planner.rs -expression: usage_reporting ---- -{ - "statsReportKey": "# -\n{__schema{queryType{name}}}", - "referencedFieldsByType": { - "Query": { - "fieldNames": [ - "__schema" - ], - "isInterface": false - }, - "__Schema": { - "fieldNames": [ - "queryType" - ], - "isInterface": false - }, - "__Type": { - "fieldNames": [ - "name" - ], - "isInterface": false - } - } -} diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__plan_usage_reporting.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__plan_usage_reporting.snap deleted file mode 100644 index 34878ddbfb..0000000000 --- a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__plan_usage_reporting.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: apollo-router/src/query_planner/bridge_query_planner.rs -expression: plan.usage_reporting ---- -{ - "statsReportKey": "# -\n{me{name{first last}}}", - "referencedFieldsByType": { - "Name": { - "fieldNames": [ - "first", - "last" - ], - "isInterface": false - }, - "Query": { - "fieldNames": [ - "me" - ], - "isInterface": false - }, - "User": { - "fieldNames": [ - "name" - ], - "isInterface": false - } - } -} diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__empty_query_plan_usage_reporting.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__empty_query_plan_usage_reporting.snap new file mode 100644 index 0000000000..0967182d1b --- /dev/null +++ b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__empty_query_plan_usage_reporting.snap @@ -0,0 +1,5 @@ +--- +source: apollo-router/src/query_planner/query_planner_service.rs +expression: stats_report_key +--- +"# -\n{__schema{queryType{name}}}" diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__plan_invalid_query_errors.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__plan_invalid_query_errors.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__plan_invalid_query_errors.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__plan_invalid_query_errors.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__plan_root.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__plan_root.snap similarity index 71% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__plan_root.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__plan_root.snap index 16ba934103..0f893c8a00 100644 --- a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__plan_root.snap +++ b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__plan_root.snap @@ -1,5 +1,5 @@ --- -source: apollo-router/src/query_planner/bridge_query_planner.rs +source: apollo-router/src/query_planner/query_planner_service.rs expression: plan.root --- Fetch( @@ -7,7 +7,7 @@ Fetch( service_name: "accounts", requires: [], variable_usages: [], - operation: "{me{name{first last}}}", + operation: "{ me { name { first last } } }", operation_name: None, operation_kind: Query, id: None, @@ -15,7 +15,7 @@ Fetch( output_rewrites: None, context_rewrites: None, schema_aware_hash: QueryHash( - "5c5036eef33484e505dd5a8666fd0a802e60d830964a4dbbf662526398563ffd", + "61bc0ed60e853ee0c5a7ad17248fcdcc84910858c2aa53f51615711c373c5810", ), authorization: CacheKeyMetadata { is_authenticated: false, diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__plan_usage_reporting.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__plan_usage_reporting.snap new file mode 100644 index 0000000000..39ea2ab1de --- /dev/null +++ b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__plan_usage_reporting.snap @@ -0,0 +1,31 @@ +--- +source: apollo-router/src/query_planner/query_planner_service.rs +expression: plan.usage_reporting +--- +{ + "operation": { + "operationName": null, + "operationSignature": "{me{name{first last}}}", + "referencedFieldsByType": { + "Name": { + "fieldNames": [ + "first", + "last" + ], + "isInterface": false + }, + "Query": { + "fieldNames": [ + "me" + ], + "isInterface": false + }, + "User": { + "fieldNames": [ + "name" + ], + "isInterface": false + } + } + } +} diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-10.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-10.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-10.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-10.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-11.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-11.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-11.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-11.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-12.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-12.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-12.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-12.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-13.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-13.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-13.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-13.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-14.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-14.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-14.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-14.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-15.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-15.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-15.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-15.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-16.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-16.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-16.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-16.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-17.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-17.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-17.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-17.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-18.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-18.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-18.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-18.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-2.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-2.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-2.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-2.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-3.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-3.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-3.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-3.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-4.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-4.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-4.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-4.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-5.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-5.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-5.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-5.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-6.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-6.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-6.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-6.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-7.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-7.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-7.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-7.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-8.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-8.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-8.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-8.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-9.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-9.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections-9.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections-9.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections.snap similarity index 100% rename from apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__subselections.snap rename to apollo-router/src/query_planner/snapshots/apollo_router__query_planner__query_planner_service__tests__subselections.snap diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__tests__query_plan_from_json.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__tests__query_plan_from_json.snap index c18018d7a2..fa77a185b4 100644 --- a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__tests__query_plan_from_json.snap +++ b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__tests__query_plan_from_json.snap @@ -59,14 +59,14 @@ Sequence { Field { alias: None, name: "__typename", - selections: None, + selections: [], }, ), Field( Field { alias: None, name: "isbn", - selections: None, + selections: [], }, ), ], @@ -122,28 +122,28 @@ Sequence { Field { alias: None, name: "__typename", - selections: None, + selections: [], }, ), Field( Field { alias: None, name: "isbn", - selections: None, + selections: [], }, ), Field( Field { alias: None, name: "title", - selections: None, + selections: [], }, ), Field( Field { alias: None, name: "year", - selections: None, + selections: [], }, ), ], @@ -198,14 +198,14 @@ Sequence { Field { alias: None, name: "__typename", - selections: None, + selections: [], }, ), Field( Field { alias: None, name: "isbn", - selections: None, + selections: [], }, ), ], @@ -256,28 +256,28 @@ Sequence { Field { alias: None, name: "__typename", - selections: None, + selections: [], }, ), Field( Field { alias: None, name: "isbn", - selections: None, + selections: [], }, ), Field( Field { alias: None, name: "title", - selections: None, + selections: [], }, ), Field( Field { alias: None, name: "year", - selections: None, + selections: [], }, ), ], diff --git a/apollo-router/src/query_planner/subgraph_context.rs b/apollo-router/src/query_planner/subgraph_context.rs index e841cf27c3..1be70f04eb 100644 --- a/apollo-router/src/query_planner/subgraph_context.rs +++ b/apollo-router/src/query_planner/subgraph_context.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; use std::collections::HashSet; +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::ast; use apollo_compiler::ast::VariableDefinition; use apollo_compiler::executable; @@ -9,13 +12,10 @@ use apollo_compiler::executable::Selection; use apollo_compiler::executable::SelectionSet; use apollo_compiler::validation::Valid; use apollo_compiler::validation::WithErrors; -use apollo_compiler::ExecutableDocument; -use apollo_compiler::Name; -use apollo_compiler::Node; +use apollo_federation::query_plan::serializable_document::SerializableDocument; use serde_json_bytes::ByteString; use serde_json_bytes::Map; -use super::fetch::SubgraphOperation; use super::rewrites::DataKeyRenamer; use super::rewrites::DataRewrite; use crate::json_ext::Path; @@ -86,15 +86,15 @@ impl<'a> SubgraphContext<'a> { schema: &'a Schema, context_rewrites: &'a Option>, ) -> Option> { - if let Some(rewrites) = context_rewrites { - if !rewrites.is_empty() { - return Some(SubgraphContext { - data, - schema, - context_rewrites: rewrites, - named_args: Vec::new(), - }); - } + if let Some(rewrites) = context_rewrites + && !rewrites.is_empty() + { + return Some(SubgraphContext { + data, + schema, + context_rewrites: rewrites, + named_args: Vec::new(), + }); } None } @@ -175,7 +175,7 @@ impl<'a> SubgraphContext<'a> { // append _ to each of the arguments and push all the values into hash_map hash_map.extend(item.iter().map(|(k, v)| { let mut new_named_param = k.clone(); - new_named_param.push_str(&format!("_{}", index)); + new_named_param.push_str(&format!("_{index}")); (new_named_param, v.clone()) })); } @@ -202,9 +202,9 @@ impl<'a> SubgraphContext<'a> { } // Take the existing subgraph operation and rewrite it to use aliasing. This will occur in the case -// where we are collecting entites and different entities may have different variables passed to the resolver. +// where we are collecting entities and different entities may have different variables passed to the resolver. pub(crate) fn build_operation_with_aliasing( - subgraph_operation: &SubgraphOperation, + subgraph_operation: &SerializableDocument, contextual_arguments: &ContextualArguments, subgraph_schema: &Valid, ) -> Result, ContextBatchingError> { @@ -229,7 +229,7 @@ pub(crate) fn build_operation_with_aliasing( return ed .validate(subgraph_schema) - .map_err(ContextBatchingError::InvalidDocumentGenerated); + .map_err(|e| ContextBatchingError::InvalidDocumentGenerated(Box::new(e))); } Err(ContextBatchingError::NoSelectionSet) } @@ -284,7 +284,7 @@ fn transform_operation( // it is a field selection for _entities, so it's ok to reach in and give it an alias let mut cloned = field_selection.clone(); let cfs = cloned.make_mut(); - cfs.alias = Some(Name::new_unchecked(&format!("_{}", i))); + cfs.alias = Some(Name::new_unchecked(&format!("_{i}"))); transform_field_arguments(&mut cfs.arguments, arguments, i); transform_selection_set(&mut cfs.selection_set, arguments, i); @@ -324,7 +324,7 @@ fn transform_selection_set( }); } -// transforms the variable name on the field argment +// transforms the variable name on the field argument fn transform_field_arguments( arguments_in_selection: &mut [Node], arguments: &HashSet, @@ -332,14 +332,14 @@ fn transform_field_arguments( ) { arguments_in_selection.iter_mut().for_each(|arg| { let arg = arg.make_mut(); - if let Some(v) = arg.value.as_variable() { - if arguments.contains(v.as_str()) { - arg.value = Node::new(ast::Value::Variable(Name::new_unchecked(&format!( - "{}_{}", - v.as_str(), - index - )))); - } + if let Some(v) = arg.value.as_variable() + && arguments.contains(v.as_str()) + { + arg.value = Node::new(ast::Value::Variable(Name::new_unchecked(&format!( + "{}_{}", + v.as_str(), + index + )))); } }); } @@ -348,7 +348,7 @@ fn transform_field_arguments( pub(crate) enum ContextBatchingError { NoSelectionSet, // The only use of the field is in `Debug`, on purpose. - InvalidDocumentGenerated(#[allow(unused)] WithErrors), + InvalidDocumentGenerated(#[allow(unused)] Box>), InvalidRelativePath, UnexpectedSelection, } diff --git a/apollo-router/src/query_planner/subscription.rs b/apollo-router/src/query_planner/subscription.rs index 31f4a37319..f00a1403cd 100644 --- a/apollo-router/src/query_planner/subscription.rs +++ b/apollo-router/src/query_planner/subscription.rs @@ -1,28 +1,15 @@ -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::Ordering; use std::sync::Arc; +use std::sync::atomic::AtomicUsize; -use futures::future; +use apollo_federation::query_plan::serializable_document::SerializableDocument; use serde::Deserialize; use serde::Serialize; -use serde_json_bytes::Value; use tokio::sync::broadcast; -use tokio::sync::mpsc; -use tower::ServiceExt; -use tracing_futures::Instrument; -use super::execution::ExecutionParameters; -use super::fetch::Variables; -use super::rewrites; use super::OperationKind; -use crate::error::FetchError; -use crate::graphql::Error; -use crate::graphql::Request; -use crate::graphql::Response; -use crate::http_ext; -use crate::json_ext::Path; -use crate::services::subgraph::BoxGqlStream; -use crate::services::SubgraphRequest; +use super::fetch::SubgraphSchemas; +use super::rewrites; +use crate::error::ValidationErrors; use crate::services::SubscriptionTaskParams; pub(crate) const SUBSCRIPTION_EVENT_SPAN_NAME: &str = "subscription_event"; @@ -63,7 +50,7 @@ pub(crate) struct SubscriptionNode { pub(crate) variable_usages: Vec>, /// The GraphQL subquery that is used for the subscription. - pub(crate) operation: super::fetch::SubgraphOperation, + pub(crate) operation: SerializableDocument, /// The GraphQL subquery operation name. pub(crate) operation_name: Option>, @@ -79,197 +66,12 @@ pub(crate) struct SubscriptionNode { } impl SubscriptionNode { - pub(crate) fn execute_recursively<'a>( - &'a self, - parameters: &'a ExecutionParameters<'a>, - current_dir: &'a Path, - parent_value: &'a Value, - sender: tokio::sync::mpsc::Sender, - ) -> future::BoxFuture> { - if parameters.subscription_handle.is_none() { - tracing::error!("No subscription handle provided for a subscription"); - return Box::pin(async { - vec![Error::builder() - .message("no subscription handle provided for a subscription") - .extension_code("NO_SUBSCRIPTION_HANDLE") - .build()] - }); - }; - if let Some(max_opened_subscriptions) = parameters - .subscription_config - .as_ref() - .and_then(|s| s.max_opened_subscriptions) - { - if OPENED_SUBSCRIPTIONS.load(Ordering::Relaxed) >= max_opened_subscriptions { - return Box::pin(async { - vec![Error::builder() - .message("can't open new subscription, limit reached") - .extension_code("SUBSCRIPTION_MAX_LIMIT") - .build()] - }); - } - } - let subscription_handle = parameters - .subscription_handle - .as_ref() - .expect("checked above; qed"); - let mode = match parameters.subscription_config.as_ref() { - Some(config) => config - .mode - .get_subgraph_config(&self.service_name) - .map(|mode| (config.clone(), mode)), - None => { - return Box::pin(async { - vec![Error::builder() - .message("subscription support is not enabled") - .extension_code("SUBSCRIPTION_DISABLED") - .build()] - }); - } - }; - - Box::pin(async move { - let mut subscription_handle = subscription_handle.clone(); - - match mode { - Some((subscription_config, _mode)) => { - let (tx_handle, rx_handle) = mpsc::channel::(1); - - let subscription_conf_tx = match subscription_handle.subscription_conf_tx.take() - { - Some(sc) => sc, - None => { - return vec![Error::builder() - .message("no subscription conf sender provided for a subscription") - .extension_code("NO_SUBSCRIPTION_CONF_TX") - .build()]; - } - }; - - let subs_params = SubscriptionTaskParams { - client_sender: sender, - subscription_handle, - subscription_config, - stream_rx: rx_handle.into(), - service_name: self.service_name.to_string(), - }; - - if let Err(err) = subscription_conf_tx.send(subs_params).await { - return vec![Error::builder() - .message(format!("cannot send the subscription data: {err:?}")) - .extension_code("SUBSCRIPTION_DATA_SEND_ERROR") - .build()]; - } - - match self - .subgraph_call(parameters, current_dir, parent_value, tx_handle) - .await - { - Ok(e) => e, - Err(err) => { - failfast_error!("subgraph call fetch error: {}", err); - vec![err.to_graphql_error(Some(current_dir.to_owned()))] - } - } - } - None => { - vec![Error::builder() - .message(format!( - "subscription mode is not configured for subgraph {:?}", - self.service_name - )) - .extension_code("INVALID_SUBSCRIPTION_MODE") - .build()] - } - } - }) - } - - pub(crate) async fn subgraph_call<'a>( - &'a self, - parameters: &'a ExecutionParameters<'a>, - current_dir: &'a Path, - data: &Value, - tx_gql: mpsc::Sender, - ) -> Result, FetchError> { - let SubscriptionNode { - operation, - operation_name, - service_name, - .. - } = self; - - let Variables { variables, .. } = match Variables::new( - &[], - &self.variable_usages, - data, - current_dir, - // Needs the original request here - parameters.supergraph_request, - parameters.schema, - &self.input_rewrites, - &None, - ) { - Some(variables) => variables, - None => { - return Ok(Vec::new()); - } - }; - - let subgraph_request = SubgraphRequest::builder() - .supergraph_request(parameters.supergraph_request.clone()) - .subgraph_request( - http_ext::Request::builder() - .method(http::Method::POST) - .uri( - parameters - .schema - .subgraph_url(service_name) - .unwrap_or_else(|| { - panic!( - "schema uri for subgraph '{service_name}' should already have been checked" - ) - }) - .clone(), - ) - .body( - Request::builder() - .query(operation.as_serialized()) - .and_operation_name(operation_name.as_ref().map(|n| n.to_string())) - .variables(variables.clone()) - .build(), - ) - .build() - .expect("it won't fail because the url is correct and already checked; qed"), - ) - .operation_kind(OperationKind::Subscription) - .context(parameters.context.clone()) - .subgraph_name(self.service_name.to_string()) - .subscription_stream(tx_gql) - .and_connection_closed_signal(parameters.subscription_handle.as_ref().map(|s| s.closed_signal.resubscribe())) - .build(); - - let service = parameters - .service_factory - .create(service_name) - .expect("we already checked that the service exists during planning; qed"); - - let (_parts, response) = service - .oneshot(subgraph_request) - .instrument(tracing::trace_span!("subscription_call")) - .await - // TODO this is a problem since it restores details about failed service - // when errors have been redacted in the include_subgraph_errors module. - // Unfortunately, not easy to fix here, because at this point we don't - // know if we should be redacting errors for this subgraph... - .map_err(|e| FetchError::SubrequestHttpError { - service: service_name.to_string(), - reason: e.to_string(), - status_code: None, - })? - .response - .into_parts(); - - Ok(response.errors) + pub(crate) fn init_parsed_operation( + &mut self, + subgraph_schemas: &SubgraphSchemas, + ) -> Result<(), ValidationErrors> { + let schema = &subgraph_schemas[self.service_name.as_ref()]; + self.operation.init_parsed(&schema.schema)?; + Ok(()) } } diff --git a/apollo-router/src/query_planner/tests.rs b/apollo-router/src/query_planner/tests.rs index 766ce9a715..1fce6fa816 100644 --- a/apollo-router/src/query_planner/tests.rs +++ b/apollo-router/src/query_planner/tests.rs @@ -1,12 +1,13 @@ use std::collections::HashMap; +use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use std::sync::Arc; use apollo_compiler::name; +use apollo_federation::query_plan::requires_selection; +use apollo_federation::query_plan::serializable_document::SerializableDocument; use futures::StreamExt; use http::Method; -use router_bridge::planner::UsageReporting; use serde_json_bytes::json; use tokio_stream::wrappers::ReceiverStream; use tower::ServiceExt; @@ -18,6 +19,11 @@ use super::OperationKind; use super::PlanNode; use super::Primary; use super::QueryPlan; +use crate::Configuration; +use crate::Context; +use crate::MockedSubgraphs; +use crate::TestHarness; +use crate::apollo_studio_interop::UsageReporting; use crate::graphql; use crate::json_ext::Path; use crate::json_ext::PathElement; @@ -25,17 +31,14 @@ use crate::plugin; use crate::plugin::test::MockSubgraph; use crate::query_planner; use crate::query_planner::fetch::FetchNode; -use crate::query_planner::fetch::SubgraphOperation; -use crate::services::subgraph_service::MakeSubgraphService; -use crate::services::supergraph; use crate::services::SubgraphResponse; use crate::services::SubgraphServiceFactory; +use crate::services::connector_service::ConnectorServiceFactory; +use crate::services::fetch_service::FetchServiceFactory; +use crate::services::subgraph_service::MakeSubgraphService; +use crate::services::supergraph; use crate::spec::Query; use crate::spec::Schema; -use crate::Configuration; -use crate::Context; -use crate::MockedSubgraphs; -use crate::TestHarness; macro_rules! test_query_plan { () => { @@ -75,25 +78,18 @@ fn service_usage() { /// The query planner reports the failed subgraph fetch as an error with a reason of "service /// closed", which is what this test expects. #[tokio::test] -#[should_panic(expected = "this panic should be propagated to the test harness")] -async fn mock_subgraph_service_withf_panics_should_be_reported_as_service_closed() { +async fn mock_subgraph_service_with_panics_should_be_reported_as_service_closed() { let query_plan: QueryPlan = QueryPlan { root: serde_json::from_str(test_query_plan!()).unwrap(), formatted_query_plan: Default::default(), - query: Arc::new(Query::empty()), + query: Arc::new(Query::empty_for_tests()), query_metrics: Default::default(), - usage_reporting: UsageReporting { - stats_report_key: "this is a test report key".to_string(), - referenced_fields_by_type: Default::default(), - } - .into(), + usage_reporting: UsageReporting::Error("this is a test report key".to_string()).into(), estimated_size: Default::default(), }; let mut mock_products_service = plugin::test::MockSubgraphService::new(); - mock_products_service.expect_call().times(1).withf(|_| { - panic!("this panic should be propagated to the test harness"); - }); + // This clone happens in the `MakeSubgraphService` impl for MockSubgraphService. mock_products_service.expect_clone().return_once(|| { let mut mock_products_service = plugin::test::MockSubgraphService::new(); mock_products_service.expect_call().times(1).withf(|_| { @@ -103,20 +99,29 @@ async fn mock_subgraph_service_withf_panics_should_be_reported_as_service_closed }); let (sender, _) = tokio::sync::mpsc::channel(10); - let sf = Arc::new(SubgraphServiceFactory { - services: Arc::new(HashMap::from([( + + let schema = Arc::new(Schema::parse(test_schema!(), &Default::default()).unwrap()); + let ssf = SubgraphServiceFactory::new( + vec![( "product".into(), Arc::new(mock_products_service) as Arc, - )])), - plugins: Default::default(), - }); + )], + Default::default(), + ); + let sf = Arc::new(FetchServiceFactory::new( + schema.clone(), + Default::default(), + Arc::new(ssf), + None, + Arc::new(ConnectorServiceFactory::empty(schema.clone())), + )); let result = query_plan .execute( &Context::new(), &sf, &Default::default(), - &Arc::new(Schema::parse(test_schema!(), &Default::default()).unwrap()), + &schema, &Default::default(), sender, None, @@ -128,7 +133,7 @@ async fn mock_subgraph_service_withf_panics_should_be_reported_as_service_closed let reason: String = serde_json_bytes::from_value(result.errors[0].extensions.get("reason").unwrap().clone()) .unwrap(); - assert_eq!(reason, "service closed".to_string()); + assert_eq!(reason, "buffer's worker closed unexpectedly".to_string()); } #[tokio::test] @@ -136,12 +141,8 @@ async fn fetch_includes_operation_name() { let query_plan: QueryPlan = QueryPlan { root: serde_json::from_str(test_query_plan!()).unwrap(), formatted_query_plan: Default::default(), - usage_reporting: UsageReporting { - stats_report_key: "this is a test report key".to_string(), - referenced_fields_by_type: Default::default(), - } - .into(), - query: Arc::new(Query::empty()), + usage_reporting: UsageReporting::Error("this is a test report key".to_string()).into(), + query: Arc::new(Query::empty_for_tests()), query_metrics: Default::default(), estimated_size: Default::default(), }; @@ -167,20 +168,28 @@ async fn fetch_includes_operation_name() { let (sender, _) = tokio::sync::mpsc::channel(10); - let sf = Arc::new(SubgraphServiceFactory { - services: Arc::new(HashMap::from([( + let schema = Arc::new(Schema::parse(test_schema!(), &Default::default()).unwrap()); + let ssf = SubgraphServiceFactory::new( + vec![( "product".into(), Arc::new(mock_products_service) as Arc, - )])), - plugins: Default::default(), - }); + )], + Default::default(), + ); + let sf = Arc::new(FetchServiceFactory::new( + schema.clone(), + Default::default(), + Arc::new(ssf), + None, + Arc::new(ConnectorServiceFactory::empty(schema.clone())), + )); let _response = query_plan .execute( &Context::new(), &sf, &Default::default(), - &Arc::new(Schema::parse(test_schema!(), &Default::default()).unwrap()), + &schema, &Default::default(), sender, None, @@ -197,12 +206,8 @@ async fn fetch_makes_post_requests() { let query_plan: QueryPlan = QueryPlan { root: serde_json::from_str(test_query_plan!()).unwrap(), formatted_query_plan: Default::default(), - usage_reporting: UsageReporting { - stats_report_key: "this is a test report key".to_string(), - referenced_fields_by_type: Default::default(), - } - .into(), - query: Arc::new(Query::empty()), + usage_reporting: UsageReporting::Error("this is a test report key".to_string()).into(), + query: Arc::new(Query::empty_for_tests()), query_metrics: Default::default(), estimated_size: Default::default(), }; @@ -228,20 +233,28 @@ async fn fetch_makes_post_requests() { let (sender, _) = tokio::sync::mpsc::channel(10); - let sf = Arc::new(SubgraphServiceFactory { - services: Arc::new(HashMap::from([( + let schema = Arc::new(Schema::parse(test_schema!(), &Default::default()).unwrap()); + let ssf = SubgraphServiceFactory::new( + vec![( "product".into(), Arc::new(mock_products_service) as Arc, - )])), - plugins: Default::default(), - }); + )], + Default::default(), + ); + let sf = Arc::new(FetchServiceFactory::new( + schema.clone(), + Default::default(), + Arc::new(ssf), + None, + Arc::new(ConnectorServiceFactory::empty(schema.clone())), + )); let _response = query_plan .execute( &Context::new(), &sf, &Default::default(), - &Arc::new(Schema::parse(test_schema!(), &Default::default()).unwrap()), + &schema, &Default::default(), sender, None, @@ -268,7 +281,7 @@ async fn defer() { service_name: "X".into(), requires: vec![], variable_usages: vec![], - operation: SubgraphOperation::from_string("{ t { id __typename x } }"), + operation: SerializableDocument::from_string("{ t { id __typename x } }"), operation_name: Some("t".into()), operation_kind: OperationKind::Query, id: Some("fetch1".into()), @@ -290,29 +303,29 @@ async fn defer() { path: Path(vec![PathElement::Key("t".to_string(), None)]), node: Box::new(PlanNode::Fetch(FetchNode { service_name: "Y".into(), - requires: vec![query_planner::selection::Selection::InlineFragment( - query_planner::selection::InlineFragment { + requires: vec![requires_selection::Selection::InlineFragment( + requires_selection::InlineFragment { type_condition: Some(name!("T")), selections: vec![ - query_planner::selection::Selection::Field( - query_planner::selection::Field { + requires_selection::Selection::Field( + requires_selection::Field { alias: None, name: name!("id"), - selections: None, + selections: Vec::new(), }, ), - query_planner::selection::Selection::Field( - query_planner::selection::Field { + requires_selection::Selection::Field( + requires_selection::Field { alias: None, name: name!("__typename"), - selections: None, + selections: Vec::new(), }, ), ], }, )], variable_usages: vec![], - operation: SubgraphOperation::from_string( + operation: SerializableDocument::from_string( "query($representations:[_Any!]!){_entities(representations:$representations){...on T{y}}}" ), operation_name: None, @@ -327,11 +340,8 @@ async fn defer() { }))), }], }.into(), - usage_reporting: UsageReporting { - stats_report_key: "this is a test report key".to_string(), - referenced_fields_by_type: Default::default(), - }.into(), - query: Arc::new(Query::empty()), + usage_reporting: UsageReporting::Error("this is a test report key".to_string()).into(), + query: Arc::new(Query::empty_for_tests()), query_metrics: Default::default(), estimated_size: Default::default(), }; @@ -377,8 +387,8 @@ async fn defer() { let schema = include_str!("testdata/defer_schema.graphql"); let schema = Arc::new(Schema::parse(schema, &Default::default()).unwrap()); - let sf = Arc::new(SubgraphServiceFactory { - services: Arc::new(HashMap::from([ + let ssf = SubgraphServiceFactory::new( + vec![ ( "X".into(), Arc::new(mock_x_service) as Arc, @@ -387,9 +397,16 @@ async fn defer() { "Y".into(), Arc::new(mock_y_service) as Arc, ), - ])), - plugins: Default::default(), - }); + ], + Default::default(), + ); + let sf = Arc::new(FetchServiceFactory::new( + schema.clone(), + Default::default(), + Arc::new(ssf), + None, + Arc::new(ConnectorServiceFactory::empty(schema.clone())), + )); let response = query_plan .execute( @@ -448,11 +465,7 @@ async fn defer_if_condition() { let query_plan = QueryPlan { root, - usage_reporting: UsageReporting { - stats_report_key: "this is a test report key".to_string(), - referenced_fields_by_type: Default::default(), - } - .into(), + usage_reporting: UsageReporting::Error("this is a test report key".to_string()).into(), query: Arc::new( Query::parse( query, @@ -486,13 +499,21 @@ async fn defer_if_condition() { let (sender, receiver) = tokio::sync::mpsc::channel(10); let mut receiver_stream = ReceiverStream::new(receiver); - let service_factory = Arc::new(SubgraphServiceFactory { - services: Arc::new(HashMap::from([( + let ssf = SubgraphServiceFactory::new( + vec![( "accounts".into(), Arc::new(mocked_accounts) as Arc, - )])), - plugins: Default::default(), - }); + )], + Default::default(), + ); + let service_factory = Arc::new(FetchServiceFactory::new( + schema.clone(), + Default::default(), + Arc::new(ssf), + None, + Arc::new(ConnectorServiceFactory::empty(schema.clone())), + )); + let defer_primary_response = query_plan .execute( &Context::new(), @@ -613,12 +634,8 @@ async fn dependent_mutations() { }"#, ) .unwrap(), - usage_reporting: UsageReporting { - stats_report_key: "this is a test report key".to_string(), - referenced_fields_by_type: Default::default(), - } - .into(), - query: Arc::new(Query::empty()), + usage_reporting: UsageReporting::Error("this is a test report key".to_string()).into(), + query: Arc::new(Query::empty_for_tests()), query_metrics: Default::default(), estimated_size: Default::default(), }; @@ -636,10 +653,14 @@ async fn dependent_mutations() { // the first fetch returned null, so there should never be a call to B let mut mock_b_service = plugin::test::MockSubgraphService::new(); + mock_b_service + .expect_clone() + .returning(plugin::test::MockSubgraphService::new); mock_b_service.expect_call().never(); - let sf = Arc::new(SubgraphServiceFactory { - services: Arc::new(HashMap::from([ + let schema = Arc::new(Schema::parse(schema, &Default::default()).unwrap()); + let ssf = SubgraphServiceFactory::new( + vec![ ( "A".into(), Arc::new(mock_a_service) as Arc, @@ -648,9 +669,16 @@ async fn dependent_mutations() { "B".into(), Arc::new(mock_b_service) as Arc, ), - ])), - plugins: Default::default(), - }); + ], + Default::default(), + ); + let sf = Arc::new(FetchServiceFactory::new( + schema.clone(), + Default::default(), + Arc::new(ssf), + None, + Arc::new(ConnectorServiceFactory::empty(schema.clone())), + )); let (sender, _) = tokio::sync::mpsc::channel(10); let _response = query_plan @@ -658,7 +686,7 @@ async fn dependent_mutations() { &Context::new(), &sf, &Default::default(), - &Arc::new(Schema::parse(schema, &Default::default()).unwrap()), + &schema, &Default::default(), sender, None, @@ -676,56 +704,56 @@ async fn alias_renaming() { { query: Query } - + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE - + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION - + directive @join__graph(name: String!, url: String!) on ENUM_VALUE - + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE - + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR - + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION - + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA - + interface I @join__type(graph: S1) @join__type(graph: S2) { id: String! } - + scalar join__FieldSet - + enum join__Graph { S1 @join__graph(name: "S1", url: "http://localhost/s1") S2 @join__graph(name: "S2", url: "http://localhost/s2") } - + scalar link__Import - + enum link__Purpose { """ `SECURITY` features provide metadata necessary to securely resolve fields. """ SECURITY - + """ `EXECUTION` features provide metadata necessary for operation execution. """ EXECUTION } - + type Query @join__type(graph: S1) @join__type(graph: S2) { testQuery(id: String!): I @join__field(graph: S1) } - + type T1 implements I @join__implements(graph: S1, interface: "I") @join__implements(graph: S2, interface: "I") @@ -735,7 +763,7 @@ async fn alias_renaming() { id: String! foo: Test @join__field(graph: S2) } - + type T2 implements I @join__implements(graph: S1, interface: "I") @join__implements(graph: S2, interface: "I") @@ -745,7 +773,7 @@ async fn alias_renaming() { id: String! bar: Test @join__field(graph: S2) } - + type Test @join__type(graph: S2) { @@ -876,56 +904,56 @@ async fn missing_fields_in_requires() { { query: Query } - + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE - + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION - + directive @join__graph(name: String!, url: String!) on ENUM_VALUE - + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE - + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR - + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION - + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA - + type Details @join__type(graph: SUB1) @join__type(graph: SUB2) { enabled: Boolean } - + scalar join__FieldSet - + enum join__Graph { SUB1 @join__graph(name: "sub1", url: "http://localhost:4002/test") SUB2 @join__graph(name: "sub2", url: "http://localhost:4002/test2") } - + scalar link__Import - + enum link__Purpose { """ `SECURITY` features provide metadata necessary to securely resolve fields. """ SECURITY - + """ `EXECUTION` features provide metadata necessary for operation execution. """ EXECUTION } - + type Query @join__type(graph: SUB1) @join__type(graph: SUB2) { stuff: Stuff @join__field(graph: SUB1) } - + type Stuff @join__type(graph: SUB1, key: "id") @join__type(graph: SUB2, key: "id", extension: true) @@ -1019,49 +1047,49 @@ async fn missing_typename_and_fragments_in_requires() { { query: Query } - + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE - + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION - + directive @join__graph(name: String!, url: String!) on ENUM_VALUE - + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE - + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR - + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION - + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA - + scalar join__FieldSet - + enum join__Graph { SUB1 @join__graph(name: "sub1", url: "http://localhost:4002/test") SUB2 @join__graph(name: "sub2", url: "http://localhost:4002/test2") } - + scalar link__Import - + enum link__Purpose { """ `SECURITY` features provide metadata necessary to securely resolve fields. """ SECURITY - + """ `EXECUTION` features provide metadata necessary for operation execution. """ EXECUTION } - + type Query @join__type(graph: SUB1) @join__type(graph: SUB2) { stuff: Stuff @join__field(graph: SUB1) } - + type Stuff @join__type(graph: SUB1, key: "id") @join__type(graph: SUB2, key: "id", extension: true) @@ -1070,7 +1098,7 @@ async fn missing_typename_and_fragments_in_requires() { thing: Thing isEnabled: Boolean @join__field(graph: SUB2, requires: "thing { ... on Thing { text } }") } - + type Thing @join__type(graph: SUB1, key: "id") @join__type(graph: SUB2, key: "id") { @@ -1155,58 +1183,58 @@ async fn missing_typename_and_fragments_in_requires2() { { query: Query } - + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE - + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION - + directive @join__graph(name: String!, url: String!) on ENUM_VALUE - + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE - + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR - + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION - + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA - + scalar join__FieldSet - + enum join__Graph { SUB1 @join__graph(name: "sub1", url: "http://localhost:4002/test") SUB2 @join__graph(name: "sub2", url: "http://localhost:4002/test2") } - + scalar link__Import - + enum link__Purpose { """ `SECURITY` features provide metadata necessary to securely resolve fields. """ SECURITY - + """ `EXECUTION` features provide metadata necessary for operation execution. """ EXECUTION } - + type Query @join__type(graph: SUB1) @join__type(graph: SUB2) { stuff: Stuff @join__field(graph: SUB1) } - + type Stuff @join__type(graph: SUB1, key: "id") @join__type(graph: SUB2, key: "id", extension: true) { id: ID - thing: PossibleThing @join__field(graph: SUB1) @join__field(graph: SUB2, external: true) + thing: PossibleThing @join__field(graph: SUB1) @join__field(graph: SUB2, external: true) isEnabled: Boolean @join__field(graph: SUB2, requires: "thing { ... on Thing1 { __typename text1 } ... on Thing2 { __typename text2 } }") } - + union PossibleThing @join__type(graph: SUB1) @join__type(graph: SUB2) @join__unionMember(graph: SUB1, member: "Thing1") @join__unionMember(graph: SUB1, member: "Thing2") @join__unionMember(graph: SUB2, member: "Thing1") @join__unionMember(graph: SUB2, member: "Thing2") @@ -1309,49 +1337,49 @@ async fn null_in_requires() { { query: Query } - + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE - + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION - + directive @join__graph(name: String!, url: String!) on ENUM_VALUE - + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE - + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR - + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION - + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA - + scalar join__FieldSet - + enum join__Graph { SUB1 @join__graph(name: "sub1", url: "http://localhost:4002/test") SUB2 @join__graph(name: "sub2", url: "http://localhost:4002/test2") } - + scalar link__Import - + enum link__Purpose { """ `SECURITY` features provide metadata necessary to securely resolve fields. """ SECURITY - + """ `EXECUTION` features provide metadata necessary for operation execution. """ EXECUTION } - + type Query @join__type(graph: SUB1) @join__type(graph: SUB2) { stuff: Stuff @join__field(graph: SUB1) } - + type Stuff @join__type(graph: SUB1, key: "id") @join__type(graph: SUB2, key: "id", extension: true) @@ -1360,7 +1388,7 @@ async fn null_in_requires() { thing: Thing isEnabled: Boolean @join__field(graph: SUB2, requires: "thing { a text }") } - + type Thing @join__type(graph: SUB1, key: "id") @join__type(graph: SUB2, key: "id") { @@ -1816,7 +1844,7 @@ fn broken_plan_does_not_panic() { service_name: "X".into(), requires: vec![], variable_usages: vec![], - operation: SubgraphOperation::from_string(operation), + operation: SerializableDocument::from_string(operation), operation_name: Some("t".into()), operation_kind: OperationKind::Query, id: Some("fetch1".into()), @@ -1828,20 +1856,20 @@ fn broken_plan_does_not_panic() { }) .into(), formatted_query_plan: Default::default(), - usage_reporting: UsageReporting { - stats_report_key: "this is a test report key".to_string(), - referenced_fields_by_type: Default::default(), - } - .into(), - query: Arc::new(Query::empty()), + usage_reporting: UsageReporting::Error("this is a test report key".to_string()).into(), + query: Arc::new(Query::empty_for_tests()), query_metrics: Default::default(), estimated_size: Default::default(), }; let subgraph_schema = apollo_compiler::Schema::parse_and_validate(subgraph_schema, "").unwrap(); - let mut subgraph_schemas = HashMap::new(); - subgraph_schemas.insert("X".to_owned(), Arc::new(subgraph_schema)); - let result = Arc::make_mut(&mut plan.root) - .init_parsed_operations_and_hash_subqueries(&subgraph_schemas, ""); + let mut subgraph_schemas = HashMap::default(); + subgraph_schemas.insert( + "X".to_owned(), + query_planner::fetch::SubgraphSchema::new(subgraph_schema), + ); + // Run the plan initialization code to make sure it doesn't panic. + let result = + Arc::make_mut(&mut plan.root).init_parsed_operations_and_hash_subqueries(&subgraph_schemas); assert_eq!( result.unwrap_err().to_string(), r#"[1:3] Cannot query field "invalid" on type "Query"."# diff --git a/apollo-router/src/registry/mod.rs b/apollo-router/src/registry/mod.rs new file mode 100644 index 0000000000..c83b8c05d2 --- /dev/null +++ b/apollo-router/src/registry/mod.rs @@ -0,0 +1,314 @@ +use std::string::FromUtf8Error; + +use docker_credential::CredentialRetrievalError; +use docker_credential::DockerCredential; +use oci_client::Client as ociClient; +use oci_client::Client; +use oci_client::Reference; +use oci_client::errors::OciDistributionError; +use oci_client::secrets::RegistryAuth; +use thiserror::Error; + +/// Configuration for fetching an OCI Bundle +/// This struct does not change on router reloads - they are all sourced from CLI options. +#[derive(Debug, Clone)] +pub struct OciConfig { + /// The Apollo key: `` + pub apollo_key: String, + + /// OCI Compliant URL pointing to the release bundle + pub reference: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct OciContent { + pub schema: String, +} + +#[derive(Debug, Error)] +pub(crate) enum OciError { + #[error("OCI layer does not have a title")] + LayerMissingTitle, + #[error("Oci Distribution error: {0}")] + Distribution(OciDistributionError), + #[error("Oci Parsing error: {0}")] + Parse(oci_client::ParseError), + #[error("Unable to parse layer: {0}")] + LayerParse(FromUtf8Error), +} + +const APOLLO_REGISTRY_ENDING: &str = "apollographql.com"; +const APOLLO_REGISTRY_USERNAME: &str = "apollo-registry"; +const APOLLO_SCHEMA_MEDIA_TYPE: &str = "application/apollo.schema"; + +impl From for OciError { + fn from(value: oci_client::ParseError) -> Self { + OciError::Parse(value) + } +} + +impl From for OciError { + fn from(value: OciDistributionError) -> Self { + OciError::Distribution(value) + } +} + +impl From for OciError { + fn from(value: FromUtf8Error) -> Self { + OciError::LayerParse(value) + } +} + +fn build_auth(reference: &Reference, apollo_key: &str) -> RegistryAuth { + let server = reference + .resolve_registry() + .strip_suffix('/') + .unwrap_or_else(|| reference.resolve_registry()); + + // Check if the server registry ends with apollographql.com + if server.ends_with(APOLLO_REGISTRY_ENDING) { + tracing::debug!("Using Apollo registry authentication"); + return RegistryAuth::Basic(APOLLO_REGISTRY_USERNAME.to_string(), apollo_key.to_string()); + } + + match docker_credential::get_credential(server) { + Err(CredentialRetrievalError::ConfigNotFound) + | Err(CredentialRetrievalError::NoCredentialConfigured) => RegistryAuth::Anonymous, + Err(e) => { + tracing::warn!("Error handling docker configuration file: {e}"); + RegistryAuth::Anonymous + } + Ok(DockerCredential::UsernamePassword(username, password)) => { + tracing::debug!("Found username/password docker credentials"); + RegistryAuth::Basic(username, password) + } + Ok(DockerCredential::IdentityToken(token)) => { + tracing::debug!("Found identity token docker credentials"); + RegistryAuth::Bearer(token) + } + } +} + +async fn pull_oci( + client: &mut ociClient, + auth: &RegistryAuth, + reference: &Reference, +) -> Result { + tracing::debug!(?reference, "pulling oci bundle"); + + // We aren't using the default `pull` function because that validates that all the layers are in the + // set of supported layers. Since we want to be able to add new layers for new features, we want the + // client to have forwards compatibility. + // To achieve that, we are going to fetch the manifest and then fetch the layers that this code cares about directly. + let (manifest, _) = client.pull_image_manifest(reference, auth).await?; + + let schema_layer = manifest + .layers + .iter() + .find(|layer| layer.media_type == APOLLO_SCHEMA_MEDIA_TYPE) + .ok_or(OciError::LayerMissingTitle)? + .clone(); + + let mut schema = Vec::new(); + client + .pull_blob(reference, &schema_layer, &mut schema) + .await?; + + Ok(OciContent { + schema: String::from_utf8(schema)?, + }) +} + +/// Fetch an OCI bundle +pub(crate) async fn fetch_oci(oci_config: OciConfig) -> Result { + let reference: Reference = oci_config.reference.as_str().parse()?; + let auth = build_auth(&reference, &oci_config.apollo_key); + pull_oci(&mut Client::default(), &auth, &reference).await +} + +#[cfg(test)] +mod tests { + use futures::future::join_all; + use oci_client::client::ClientConfig; + use oci_client::client::ClientProtocol; + use oci_client::client::ImageLayer; + use oci_client::manifest::IMAGE_MANIFEST_MEDIA_TYPE; + use oci_client::manifest::OCI_IMAGE_MEDIA_TYPE; + use oci_client::manifest::OciDescriptor; + use oci_client::manifest::OciImageManifest; + use oci_client::manifest::OciManifest; + use url::Url; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + use super::*; + use crate::registry::OciError::LayerMissingTitle; + + #[test] + fn test_build_auth_apollo_registry() { + // Create a reference for an Apollo registry + let reference: Reference = "registry.apollographql.com/my-graph:latest" + .parse() + .unwrap(); + let apollo_key = "test-api-key".to_string(); + + // Call build_auth + let auth = build_auth(&reference, &apollo_key); + + // Check that it returns the correct RegistryAuth + match auth { + RegistryAuth::Basic(username, password) => { + assert_eq!(username, APOLLO_REGISTRY_USERNAME); + assert_eq!(password, apollo_key); + } + _ => panic!("Expected RegistryAuth::Basic, got something else"), + } + } + + #[test] + fn test_build_auth_non_apollo_registry() { + // Create a reference for a non-Apollo registry + let reference: Reference = "docker.io/library/alpine:latest".parse().unwrap(); + let apollo_key = "test-api-key".to_string(); + + // Mock the docker_credential::get_credential function + // Since we can't easily mock this in Rust without additional libraries, + // we'll just verify that it doesn't return the Apollo registry auth + let auth = build_auth(&reference, &apollo_key); + + // Check that it doesn't return the Apollo registry auth + if let RegistryAuth::Basic(username, _) = auth { + assert_ne!(username, "apollo_registry"); + } + } + + async fn setup_mocks(mock_server: MockServer, layers: Vec) -> Reference { + let graph_id = "test-graph-id"; + let reference = "latest"; + + let layer_descriptors = join_all(layers.iter().map(async |layer| { + let blob_digest = layer.sha256_digest(); + let blob_url = Url::parse(&format!( + "{}/v2/{graph_id}/blobs/{blob_digest}", + mock_server.uri() + )) + .expect("url must be valid"); + Mock::given(method("GET")) + .and(path(blob_url.path())) + .respond_with( + ResponseTemplate::new(200) + .append_header(http::header::CONTENT_TYPE, "application/octet-stream") + .set_body_bytes(layer.data.clone()), + ) + .mount(&mock_server) + .await; + OciDescriptor { + media_type: layer.media_type.clone(), + digest: blob_digest, + size: layer.data.len().try_into().unwrap(), + urls: None, + annotations: None, + } + })) + .await; + + let manifest_url = Url::parse(&format!( + "{}/v2/{}/manifests/{}", + mock_server.uri(), + graph_id, + reference + )) + .expect("url must be valid"); + let oci_manifest = OciManifest::Image(OciImageManifest { + schema_version: 2, + media_type: Some(IMAGE_MANIFEST_MEDIA_TYPE.to_string()), + config: Default::default(), + layers: layer_descriptors, + subject: None, + artifact_type: None, + annotations: None, + }); + let _ = Mock::given(method("GET")) + .and(path(manifest_url.path())) + .respond_with( + ResponseTemplate::new(200) + .append_header(http::header::CONTENT_TYPE, OCI_IMAGE_MEDIA_TYPE) + .set_body_bytes(serde_json::to_vec(&oci_manifest).unwrap()), + ) + .mount(&mock_server) + .await; + + format!("{}/{graph_id}:{reference}", mock_server.address()) + .parse::() + .expect("url must be valid") + } + + #[tokio::test(flavor = "multi_thread")] + async fn fetch_blob() { + let mock_server = MockServer::start().await; + let mut client = Client::new(ClientConfig { + protocol: ClientProtocol::Http, + ..Default::default() + }); + let schema_layer = ImageLayer { + data: "test schema".to_string().into_bytes(), + media_type: APOLLO_SCHEMA_MEDIA_TYPE.to_string(), + annotations: None, + }; + let image_reference = setup_mocks(mock_server, vec![schema_layer]).await; + let result = pull_oci(&mut client, &RegistryAuth::Anonymous, &image_reference) + .await + .expect("Failed to fetch OCI bundle"); + assert_eq!(result.schema, "test schema"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn handle_extra_layers() { + let mock_server = MockServer::start().await; + let mut client = Client::new(ClientConfig { + protocol: ClientProtocol::Http, + ..Default::default() + }); + let schema_layer = ImageLayer { + data: "test schema".into(), + media_type: APOLLO_SCHEMA_MEDIA_TYPE.to_string(), + annotations: None, + }; + let random_layer = ImageLayer { + data: "foo_bar".into(), + media_type: "foo_bar".to_string(), + annotations: None, + }; + let image_reference = setup_mocks(mock_server, vec![schema_layer, random_layer]).await; + let result = pull_oci(&mut client, &RegistryAuth::Anonymous, &image_reference) + .await + .expect("Failed to fetch OCI bundle"); + assert_eq!(result.schema, "test schema"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn error_layer_not_found() { + let mock_server = MockServer::start().await; + let mut client = Client::new(ClientConfig { + protocol: ClientProtocol::Http, + ..Default::default() + }); + let random_layer = ImageLayer { + data: "foo_bar".to_string().into_bytes(), + media_type: "foo_bar".to_string(), + annotations: None, + }; + let image_reference = setup_mocks(mock_server, vec![random_layer]).await; + let result = pull_oci(&mut client, &RegistryAuth::Anonymous, &image_reference) + .await + .expect_err("Expect can't fetch OCI bundle"); + if let LayerMissingTitle = result { + // Expected error + } else { + panic!("Expected OCILayerMissingTitle error, got {result:?}"); + } + } +} diff --git a/apollo-router/src/router/error.rs b/apollo-router/src/router/error.rs index 2fad0130d9..92f063a94e 100644 --- a/apollo-router/src/router/error.rs +++ b/apollo-router/src/router/error.rs @@ -23,8 +23,8 @@ pub enum ApolloRouterError { /// no valid license was supplied NoLicense, - /// license violation - LicenseViolation, + /// license violation, the router is using features not available for your license: {0:?} + LicenseViolation(Vec), /// could not create router: {0} ServiceCreationError(BoxError), @@ -40,4 +40,7 @@ pub enum ApolloRouterError { /// TLS configuration error: {0} Rustls(rustls::Error), + + /// Preview feature in supergraph schema not enabled via configuration + FeatureGateViolation, } diff --git a/apollo-router/src/router/event/configuration.rs b/apollo-router/src/router/event/configuration.rs index a007832cff..5d08a9908c 100644 --- a/apollo-router/src/router/event/configuration.rs +++ b/apollo-router/src/router/event/configuration.rs @@ -1,18 +1,19 @@ use std::path::Path; use std::path::PathBuf; use std::pin::Pin; -use std::time::Duration; +use std::sync::Arc; use derivative::Derivative; use derive_more::Display; use derive_more::From; use futures::prelude::*; +use crate::Configuration; use crate::router::Event; use crate::router::Event::NoMoreConfiguration; +use crate::router::Event::RhaiReload; use crate::router::Event::UpdateConfiguration; use crate::uplink::UplinkConfig; -use crate::Configuration; type ConfigurationStream = Pin + Send>>; @@ -25,28 +26,23 @@ pub enum ConfigurationSource { /// /// Can be created through `serde::Deserialize` from various formats, /// or inline in Rust code with `serde_json::json!` and `serde_json::from_value`. - #[display(fmt = "Static")] - #[from(types(Configuration))] + #[display("Static")] + #[from(Configuration, Box)] Static(Box), /// A configuration stream where the server will react to new configuration. If possible /// the configuration will be applied without restarting the internal http server. - #[display(fmt = "Stream")] + #[display("Stream")] Stream(#[derivative(Debug = "ignore")] ConfigurationStream), /// A yaml file that may be watched for changes - #[display(fmt = "File")] + #[display("File")] File { /// The path of the configuration file. path: PathBuf, /// `true` to watch the file for changes and hot apply them. watch: bool, - - /// When watching, the delay to wait before applying the new configuration. - /// Note: This variable is deprecated and has no effect. - #[deprecated] - delay: Option, }, } @@ -65,20 +61,15 @@ impl ConfigurationSource { match self { ConfigurationSource::Static(mut instance) => { instance.uplink = uplink_config; - stream::iter(vec![UpdateConfiguration(*instance)]).boxed() + stream::iter(vec![UpdateConfiguration(instance.into())]).boxed() } ConfigurationSource::Stream(stream) => stream .map(move |mut c| { c.uplink = uplink_config.clone(); - UpdateConfiguration(c) + UpdateConfiguration(Arc::new(c)) }) .boxed(), - #[allow(deprecated)] - ConfigurationSource::File { - path, - watch, - delay: _, - } => { + ConfigurationSource::File { path, watch } => { // Sanity check, does the config file exists, if it doesn't then bail. if !path.exists() { tracing::error!( @@ -90,7 +81,7 @@ impl ConfigurationSource { match ConfigurationSource::read_config(&path) { Ok(mut configuration) => { if watch { - crate::files::watch(&path) + let config_watcher = crate::files::watch(&path) .filter_map(move |_| { let path = path.clone(); let uplink_config = uplink_config.clone(); @@ -100,7 +91,9 @@ impl ConfigurationSource { { Ok(mut configuration) => { configuration.uplink = uplink_config.clone(); - Some(UpdateConfiguration(configuration)) + Some(UpdateConfiguration(Arc::new( + configuration, + ))) } Err(err) => { tracing::error!("{}", err); @@ -109,11 +102,39 @@ impl ConfigurationSource { } } }) - .boxed() + .boxed(); + if let Some(rhai_plugin) = + configuration.apollo_plugins.plugins.get("rhai") + { + let scripts_path = match rhai_plugin["scripts"].as_str() { + Some(path) => Path::new(path), + None => Path::new("rhai"), + }; + // If our path is relative, add it to the current dir + let scripts_watch = if scripts_path.is_relative() { + let current_directory = std::env::current_dir(); + if current_directory.is_err() { + tracing::error!("No current directory found",); + return stream::empty().boxed(); + } + current_directory.unwrap().join(scripts_path) + } else { + scripts_path.into() + }; + let rhai_watcher = crate::files::watch_rhai(&scripts_watch) + .filter_map(move |_| future::ready(Some(RhaiReload))) + .boxed(); + // Select across both our streams + futures::stream::select(config_watcher, rhai_watcher).boxed() + } else { + config_watcher + } } else { configuration.uplink = uplink_config.clone(); - stream::once(future::ready(UpdateConfiguration(configuration))) - .boxed() + stream::once(future::ready(UpdateConfiguration(Arc::new( + configuration, + )))) + .boxed() } } Err(err) => { @@ -150,6 +171,8 @@ enum ReadConfigError { mod tests { use std::env::temp_dir; + use futures::StreamExt; + use super::*; use crate::files::tests::create_temp_file; use crate::files::tests::write_and_flush; @@ -160,13 +183,9 @@ mod tests { let (path, mut file) = create_temp_file(); let contents = include_str!("../../testdata/supergraph_config.router.yaml"); write_and_flush(&mut file, contents).await; - let mut stream = ConfigurationSource::File { - path, - watch: true, - delay: None, - } - .into_stream(Some(UplinkConfig::default())) - .boxed(); + let mut stream = ConfigurationSource::File { path, watch: true } + .into_stream(Some(UplinkConfig::default())) + .boxed(); // First update is guaranteed assert!(matches!( @@ -185,7 +204,7 @@ mod tests { // This time write garbage, there should not be an update. write_and_flush(&mut file, ":garbage").await; - let event = stream.into_future().now_or_never(); + let event = StreamExt::into_future(stream).now_or_never(); assert!(event.is_none() || matches!(event, Some((Some(NoMoreConfiguration), _)))); } @@ -194,7 +213,6 @@ mod tests { let mut stream = ConfigurationSource::File { path: temp_dir().join("does_not_exit"), watch: true, - delay: None, } .into_stream(Some(UplinkConfig::default())); @@ -206,12 +224,8 @@ mod tests { async fn config_by_file_invalid() { let (path, mut file) = create_temp_file(); write_and_flush(&mut file, "Garbage").await; - let mut stream = ConfigurationSource::File { - path, - watch: true, - delay: None, - } - .into_stream(Some(UplinkConfig::default())); + let mut stream = ConfigurationSource::File { path, watch: true } + .into_stream(Some(UplinkConfig::default())); // First update fails because the file is invalid. assert!(matches!(stream.next().await.unwrap(), NoMoreConfiguration)); @@ -223,12 +237,8 @@ mod tests { let contents = include_str!("../../testdata/supergraph_config.router.yaml"); write_and_flush(&mut file, contents).await; - let mut stream = ConfigurationSource::File { - path, - watch: false, - delay: None, - } - .into_stream(Some(UplinkConfig::default())); + let mut stream = ConfigurationSource::File { path, watch: false } + .into_stream(Some(UplinkConfig::default())); assert!(matches!( stream.next().await.unwrap(), UpdateConfiguration(_) diff --git a/apollo-router/src/router/event/license.rs b/apollo-router/src/router/event/license.rs index 343efd6c7c..f9e5fbf640 100644 --- a/apollo-router/src/router/event/license.rs +++ b/apollo-router/src/router/event/license.rs @@ -9,12 +9,12 @@ use futures::prelude::*; use crate::router::Event; use crate::router::Event::NoMoreLicense; +use crate::uplink::UplinkConfig; use crate::uplink::license_enforcement::Audience; use crate::uplink::license_enforcement::License; use crate::uplink::license_stream::LicenseQuery; use crate::uplink::license_stream::LicenseStreamExt; use crate::uplink::stream_from_uplink; -use crate::uplink::UplinkConfig; const APOLLO_ROUTER_LICENSE_INVALID: &str = "APOLLO_ROUTER_LICENSE_INVALID"; @@ -27,19 +27,19 @@ type LicenseStream = Pin + Send>>; #[non_exhaustive] pub enum LicenseSource { /// A static license. EXPERIMENTAL and not subject to semver. - #[display(fmt = "Static")] + #[display("Static")] Static { license: License }, /// A license supplied via APOLLO_ROUTER_LICENSE. EXPERIMENTAL and not subject to semver. - #[display(fmt = "Env")] + #[display("Env")] Env, /// A stream of license. EXPERIMENTAL and not subject to semver. - #[display(fmt = "Stream")] + #[display("Stream")] Stream(#[derivative(Debug = "ignore")] LicenseStream), /// A raw file that may be watched for changes. EXPERIMENTAL and not subject to semver. - #[display(fmt = "File")] + #[display("File")] File { /// The path of the license file. path: PathBuf, @@ -49,7 +49,7 @@ pub enum LicenseSource { }, /// Apollo uplink. - #[display(fmt = "Registry")] + #[display("Registry")] Registry(UplinkConfig), } diff --git a/apollo-router/src/router/event/mod.rs b/apollo-router/src/router/event/mod.rs index 2645ad4755..2efe8b9962 100644 --- a/apollo-router/src/router/event/mod.rs +++ b/apollo-router/src/router/event/mod.rs @@ -1,15 +1,15 @@ mod configuration; mod license; -mod reload; +pub(crate) mod reload; mod schema; mod shutdown; use std::fmt::Debug; use std::fmt::Formatter; +use std::sync::Arc; pub use configuration::ConfigurationSource; pub use license::LicenseSource; -pub(crate) use reload::ReloadSource; pub use schema::SchemaSource; pub use shutdown::ShutdownSource; @@ -17,29 +17,31 @@ use self::Event::NoMoreConfiguration; use self::Event::NoMoreLicense; use self::Event::NoMoreSchema; use self::Event::Reload; +use self::Event::RhaiReload; use self::Event::Shutdown; use self::Event::UpdateConfiguration; use self::Event::UpdateLicense; use self::Event::UpdateSchema; -use crate::uplink::license_enforcement::LicenseState; use crate::Configuration; +use crate::uplink::license_enforcement::LicenseState; +use crate::uplink::schema::SchemaState; /// Messages that are broadcast across the app. pub(crate) enum Event { /// The configuration was updated. - UpdateConfiguration(Configuration), + UpdateConfiguration(Arc), /// There are no more updates to the configuration NoMoreConfiguration, /// The schema was updated. - UpdateSchema(String), + UpdateSchema(SchemaState), /// There are no more updates to the schema NoMoreSchema, /// Update license {} - UpdateLicense(LicenseState), + UpdateLicense(Arc), /// There were no more updates to license. NoMoreLicense, @@ -47,6 +49,9 @@ pub(crate) enum Event { /// Artificial hot reload for chaos testing Reload, + /// Hot reload for rhai scripts + RhaiReload, + /// The server should gracefully shutdown. Shutdown, } @@ -75,6 +80,9 @@ impl Debug for Event { Reload => { write!(f, "ForcedHotReload") } + RhaiReload => { + write!(f, "RhaiReload") + } Shutdown => { write!(f, "Shutdown") } diff --git a/apollo-router/src/router/event/reload.rs b/apollo-router/src/router/event/reload.rs index 423c23cb08..1788bb6115 100644 --- a/apollo-router/src/router/event/reload.rs +++ b/apollo-router/src/router/event/reload.rs @@ -1,36 +1,15 @@ -use std::sync::Arc; -use std::sync::Mutex; +#[cfg(unix)] use std::task::Poll; -use std::time::Duration; use futures::prelude::*; -use tokio_util::time::DelayQueue; use crate::router::Event; -#[derive(Default)] -struct ReloadSourceInner { - queue: DelayQueue<()>, - period: Option, -} - -/// Reload source is an internal event emitter for the state machine that will send reload events on SIGUP and/or on a timer. +/// Reload source is an internal event emitter for the state machine that will send reload events on SIGHUP #[derive(Clone, Default)] -pub(crate) struct ReloadSource { - inner: Arc>, -} +pub(crate) struct ReloadSource; impl ReloadSource { - pub(crate) fn set_period(&self, period: &Option) { - let mut inner = self.inner.lock().unwrap(); - // Clear the queue before setting the period - inner.queue.clear(); - inner.period = *period; - if let Some(period) = period { - inner.queue.insert((), *period); - } - } - pub(crate) fn into_stream(self) -> impl Stream { #[cfg(unix)] let signal_stream = { @@ -47,22 +26,18 @@ impl ReloadSource { #[cfg(not(unix))] let signal_stream = futures::stream::empty().boxed(); - let periodic_reload = futures::stream::poll_fn(move |cx| { - let mut inner = self.inner.lock().unwrap(); - match inner.queue.poll_expired(cx) { - Poll::Ready(Some(_expired)) => { - if let Some(period) = inner.period { - inner.queue.insert((), period); - } - Poll::Ready(Some(Event::Reload)) - } - // We must return pending even if the queue is empty, otherwise the stream will never be polled again - // The waker will still be used, so this won't end up in a hot loop. - Poll::Ready(None) => Poll::Pending, - Poll::Pending => Poll::Pending, - } - }); + signal_stream + } +} - futures::stream::select(signal_stream, periodic_reload) +/// Extension trait to add chaos reload functionality to event streams. +/// +/// This trait provides the `.with_sighub_reload()` method that automatically triggers a reload event when SIGHUP is received. +pub(crate) trait ReloadableEventStream: Stream + Sized { + /// Adds sighub reload to the event stream. + fn with_sighup_reload(self) -> impl Stream { + stream::select(self, ReloadSource.into_stream()) } } + +impl ReloadableEventStream for S where S: Stream {} diff --git a/apollo-router/src/router/event/schema.rs b/apollo-router/src/router/event/schema.rs index 229992fa68..6dbd818751 100644 --- a/apollo-router/src/router/event/schema.rs +++ b/apollo-router/src/router/event/schema.rs @@ -8,12 +8,15 @@ use derive_more::From; use futures::prelude::*; use url::Url; +use crate::registry::OciConfig; +use crate::registry::fetch_oci; use crate::router::Event; use crate::router::Event::NoMoreSchema; use crate::router::Event::UpdateSchema; +use crate::uplink::UplinkConfig; +use crate::uplink::schema::SchemaState; use crate::uplink::schema_stream::SupergraphSdlQuery; use crate::uplink::stream_from_uplink; -use crate::uplink::UplinkConfig; type SchemaStream = Pin + Send>>; @@ -23,42 +26,36 @@ type SchemaStream = Pin + Send>>; #[non_exhaustive] pub enum SchemaSource { /// A static schema. - #[display(fmt = "String")] + #[display("String")] Static { schema_sdl: String }, /// A stream of schema. - #[display(fmt = "Stream")] + #[display("Stream")] Stream(#[derivative(Debug = "ignore")] SchemaStream), /// A YAML file that may be watched for changes. - #[display(fmt = "File")] + #[display("File")] File { /// The path of the schema file. path: PathBuf, /// `true` to watch the file for changes and hot apply them. watch: bool, - - /// When watching, the delay to wait before applying the new schema. - /// Note: This variable is deprecated and has no effect. - #[deprecated] - delay: Option, }, /// Apollo managed federation. - #[display(fmt = "Registry")] + #[display("Registry")] Registry(UplinkConfig), /// A list of URLs to fetch the schema from. - #[display(fmt = "URLs")] + #[display("URLs")] URLs { /// The URLs to fetch the schema from. urls: Vec, - /// `true` to watch the URLs for changes and hot apply them. - watch: bool, - /// When watching, the delay to wait between each poll. - period: Duration, }, + + #[display("Registry")] + OCI(OciConfig), } impl From<&'_ str> for SchemaSource { @@ -74,14 +71,23 @@ impl SchemaSource { pub(crate) fn into_stream(self) -> impl Stream { match self { SchemaSource::Static { schema_sdl: schema } => { - stream::once(future::ready(UpdateSchema(schema))).boxed() + let update_schema = UpdateSchema(SchemaState { + sdl: schema, + launch_id: None, + }); + stream::once(future::ready(update_schema)).boxed() } - SchemaSource::Stream(stream) => stream.map(UpdateSchema).boxed(), - #[allow(deprecated)] + SchemaSource::Stream(stream) => stream + .map(|sdl| { + UpdateSchema(SchemaState { + sdl, + launch_id: None, + }) + }) + .boxed(), SchemaSource::File { path, watch, - delay: _, } => { // Sanity check, does the schema file exists, if it doesn't then bail. if !path.exists() { @@ -100,7 +106,13 @@ impl SchemaSource { let path = path.clone(); async move { match tokio::fs::read_to_string(&path).await { - Ok(schema) => Some(UpdateSchema(schema)), + Ok(schema) => { + let update_schema = UpdateSchema(SchemaState { + sdl: schema, + launch_id: None, + }); + Some(update_schema) + } Err(err) => { tracing::error!(reason = %err, "failed to read supergraph schema"); None @@ -110,7 +122,11 @@ impl SchemaSource { }) .boxed() } else { - stream::once(future::ready(UpdateSchema(schema))).boxed() + let update_schema = UpdateSchema(SchemaState { + sdl: schema, + launch_id: None, + }); + stream::once(future::ready(update_schema)).boxed() } } Err(err) => { @@ -121,10 +137,13 @@ impl SchemaSource { } } SchemaSource::Registry(uplink_config) => { - stream_from_uplink::(uplink_config) + stream_from_uplink::(uplink_config) .filter_map(|res| { future::ready(match res { - Ok(schema) => Some(UpdateSchema(schema)), + Ok(schema) => { + let update_schema = UpdateSchema(schema); + Some(update_schema) + } Err(e) => { tracing::error!("{}", e); None @@ -133,44 +152,30 @@ impl SchemaSource { }) .boxed() } - SchemaSource::URLs { - urls, - watch, - period, - } => { - let mut fetcher = match Fetcher::new(urls, period) { - Ok(fetcher) => fetcher, - Err(err) => { - tracing::error!(reason = %err, "failed to fetch supergraph schema"); - return stream::empty().boxed(); - } - }; - - if watch { - stream::unfold(fetcher, |mut state| async move { - if state.first_call { - // First call we may terminate the stream if there are no viable urls, None may be returned - state - .fetch_supergraph_from_first_viable_url() - .await - .map(|event| (Some(event), state)) - } else { - // Subsequent calls we don't want to terminate the stream, so we always return Some - Some(match state.fetch_supergraph_from_first_viable_url().await { - None => (None, state), - Some(event) => (Some(event), state), + SchemaSource::URLs { urls } => { + futures::stream::once(async move { + fetch_supergraph_from_first_viable_url(&urls).await + }) + .filter_map(|s| async move { s.map(Event::UpdateSchema) }) + .boxed() + } + SchemaSource::OCI(oci_config) => { + futures::stream::once(async move { + match fetch_oci(oci_config).await { + Ok(oci_result) => { + Some(SchemaState { + sdl: oci_result.schema, + launch_id: None, }) } - }) - .filter_map(|s| async move { s }) - .boxed() - } else { - futures::stream::once(async move { - fetcher.fetch_supergraph_from_first_viable_url().await - }) - .filter_map(|s| async move { s }) + Err(err) => { + tracing::error!("{}", err); + None + } + } + }) + .filter_map(|s| async move { s.map(Event::UpdateSchema) }) .boxed() - } } } .chain(stream::iter(vec![NoMoreSchema])) @@ -178,88 +183,65 @@ impl SchemaSource { } } -#[derive(thiserror::Error, Debug)] -enum FetcherError { - #[error("failed to build http client")] - InitializationError(#[from] reqwest::Error), -} - // Encapsulates fetching the schema from the first viable url. // It will try each url in order until it finds one that works. -// On the second and subsequent calls it will wait for the period before making the call. -struct Fetcher { - client: reqwest::Client, - urls: Vec, - period: Duration, - first_call: bool, -} - -impl Fetcher { - fn new(urls: Vec, period: Duration) -> Result { - Ok(Self { - client: reqwest::Client::builder() - .no_gzip() - .timeout(Duration::from_secs(10)) - .build() - .map_err(FetcherError::InitializationError)?, - urls, - period, - first_call: true, - }) - } - async fn fetch_supergraph_from_first_viable_url(&mut self) -> Option { - // If this is not the first call then we need to wait for the period before trying again. - if !self.first_call { - tokio::time::sleep(self.period).await; - } - self.first_call = false; - - for url in &self.urls { - match self - .client - .get(reqwest::Url::parse(url.as_ref()).unwrap()) - .send() - .await - { - Ok(res) if res.status().is_success() => match res.text().await { - Ok(schema) => return Some(UpdateSchema(schema)), - Err(err) => { - tracing::warn!( - url.full = %url, - reason = %err, - "failed to fetch supergraph schema" - ) - } - }, - Ok(res) => tracing::warn!( - http.response.status_code = res.status().as_u16(), - url.full = %url, - "failed to fetch supergraph schema" - ), - Err(err) => tracing::warn!( - url.full = %url, - reason = %err, - "failed to fetch supergraph schema" - ), - } +async fn fetch_supergraph_from_first_viable_url(urls: &[Url]) -> Option { + let Ok(client) = reqwest::Client::builder() + .no_gzip() + .timeout(Duration::from_secs(10)) + .build() + else { + tracing::error!("failed to create HTTP client to fetch supergraph schema"); + return None; + }; + for url in urls { + match client + .get(reqwest::Url::parse(url.as_ref()).unwrap()) + .send() + .await + { + Ok(res) if res.status().is_success() => match res.text().await { + Ok(schema) => { + return Some(SchemaState { + sdl: schema, + launch_id: None, + }); + } + Err(err) => { + tracing::warn!( + url.full = %url, + reason = %err, + "failed to fetch supergraph schema" + ) + } + }, + Ok(res) => tracing::warn!( + http.response.status_code = res.status().as_u16(), + url.full = %url, + "failed to fetch supergraph schema" + ), + Err(err) => tracing::warn!( + url.full = %url, + reason = %err, + "failed to fetch supergraph schema" + ), } - tracing::error!("failed to fetch supergraph schema from all urls"); - None } + tracing::error!("failed to fetch supergraph schema from all urls"); + None } #[cfg(test)] mod tests { use std::env::temp_dir; - use futures::select; use test_log::test; use tracing_futures::WithSubscriber; - use wiremock::matchers::method; - use wiremock::matchers::path; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; use super::*; use crate::assert_snapshot_subscriber; @@ -271,13 +253,9 @@ mod tests { let (path, mut file) = create_temp_file(); let schema = include_str!("../../testdata/supergraph.graphql"); write_and_flush(&mut file, schema).await; - let mut stream = SchemaSource::File { - path, - watch: true, - delay: None, - } - .into_stream() - .boxed(); + let mut stream = SchemaSource::File { path, watch: true } + .into_stream() + .boxed(); // First update is guaranteed assert!(matches!(stream.next().await.unwrap(), UpdateSchema(_))); @@ -295,12 +273,7 @@ mod tests { let schema = include_str!("../../testdata/supergraph.graphql"); write_and_flush(&mut file, schema).await; - let mut stream = SchemaSource::File { - path, - watch: false, - delay: None, - } - .into_stream(); + let mut stream = SchemaSource::File { path, watch: false }.into_stream(); assert!(matches!(stream.next().await.unwrap(), UpdateSchema(_))); assert!(matches!(stream.next().await.unwrap(), NoMoreSchema)); } @@ -310,7 +283,6 @@ mod tests { let mut stream = SchemaSource::File { path: temp_dir().join("does_not_exist"), watch: true, - delay: None, } .into_stream(); @@ -340,17 +312,13 @@ mod tests { Url::parse(&format!("http://{}/schema1", mock_server.address())).unwrap(), Url::parse(&format!("http://{}/schema2", mock_server.address())).unwrap(), ], - watch: true, - period: Duration::from_secs(1), } .into_stream(); assert!( - matches!(stream.next().await.unwrap(), UpdateSchema(schema) if schema == SCHEMA_1) - ); - assert!( - matches!(stream.next().await.unwrap(), UpdateSchema(schema) if schema == SCHEMA_1) + matches!(stream.next().await.unwrap(), UpdateSchema(schema) if schema.sdl == SCHEMA_1) ); + assert!(matches!(stream.next().await.unwrap(), NoMoreSchema)); } .with_subscriber(assert_snapshot_subscriber!()) .await; @@ -376,17 +344,13 @@ mod tests { Url::parse(&format!("http://{}/schema1", mock_server.address())).unwrap(), Url::parse(&format!("http://{}/schema2", mock_server.address())).unwrap(), ], - watch: true, - period: Duration::from_secs(1), } .into_stream(); assert!( - matches!(stream.next().await.unwrap(), UpdateSchema(schema) if schema == SCHEMA_2) - ); - assert!( - matches!(stream.next().await.unwrap(), UpdateSchema(schema) if schema == SCHEMA_2) + matches!(stream.next().await.unwrap(), UpdateSchema(schema) if schema.sdl == SCHEMA_2) ); + assert!(matches!(stream.next().await.unwrap(), NoMoreSchema)); } .with_subscriber(assert_snapshot_subscriber!({ "[].fields[\"url.full\"]" => "[url.full]" @@ -414,8 +378,6 @@ mod tests { Url::parse(&format!("http://{}/schema1", mock_server.address())).unwrap(), Url::parse(&format!("http://{}/schema2", mock_server.address())).unwrap(), ], - watch: true, - period: Duration::from_secs(1), } .into_stream(); @@ -426,82 +388,4 @@ mod tests { })) .await; } - #[test(tokio::test)] - async fn schema_success_fail_success() { - async { - let mock_server = MockServer::start().await; - let mut stream = SchemaSource::URLs { - urls: vec![ - Url::parse(&format!("http://{}/schema1", mock_server.address())).unwrap(), - ], - watch: true, - period: Duration::from_secs(1), - } - .into_stream() - .boxed() - .fuse(); - - let success = Mock::given(method("GET")) - .and(path("/schema1")) - .respond_with(ResponseTemplate::new(200).set_body_string(SCHEMA_1)) - .mount_as_scoped(&mock_server) - .await; - - assert!( - matches!(stream.next().await.unwrap(), UpdateSchema(schema) if schema == SCHEMA_1) - ); - - drop(success); - - // Next call will timeout - assert!(select! { - _res = stream.next() => false, - _res = tokio::time::sleep(Duration::from_secs(2)).boxed().fuse() => true, - - }); - - // Now we should get the schema again if the endpoint is back - Mock::given(method("GET")) - .and(path("/schema1")) - .respond_with(ResponseTemplate::new(200).set_body_string(SCHEMA_1)) - .mount(&mock_server) - .await; - - assert!( - matches!(stream.next().await.unwrap(), UpdateSchema(schema) if schema == SCHEMA_1) - ); - } - .with_subscriber(assert_snapshot_subscriber!({ - "[].fields[\"url.full\"]" => "[url.full]" - })) - .await; - } - - #[test(tokio::test)] - async fn schema_no_watch() { - async { - let mock_server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/schema1")) - .respond_with(ResponseTemplate::new(200).set_body_string(SCHEMA_1)) - .mount(&mock_server) - .await; - - let mut stream = SchemaSource::URLs { - urls: vec![ - Url::parse(&format!("http://{}/schema1", mock_server.address())).unwrap(), - ], - watch: false, - period: Duration::from_secs(1), - } - .into_stream(); - - assert!( - matches!(stream.next().await.unwrap(), UpdateSchema(schema) if schema == SCHEMA_1) - ); - assert!(matches!(stream.next().await.unwrap(), NoMoreSchema)); - } - .with_subscriber(assert_snapshot_subscriber!()) - .await; - } } diff --git a/apollo-router/src/router/event/shutdown.rs b/apollo-router/src/router/event/shutdown.rs index 1c3cc06899..2c7981c6c8 100644 --- a/apollo-router/src/router/event/shutdown.rs +++ b/apollo-router/src/router/event/shutdown.rs @@ -15,15 +15,15 @@ type ShutdownFuture = Pin + Send>>; #[non_exhaustive] pub enum ShutdownSource { /// No graceful shutdown - #[display(fmt = "None")] + #[display("None")] None, /// A custom shutdown future. - #[display(fmt = "Custom")] + #[display("Custom")] Custom(#[derivative(Debug = "ignore")] ShutdownFuture), /// Watch for Ctl-C signal. - #[display(fmt = "CtrlC")] + #[display("CtrlC")] CtrlC, } diff --git a/apollo-router/src/router/event/snapshots/apollo_router__router__event__schema__tests__schema_by_url_fallback@logs.snap b/apollo-router/src/router/event/snapshots/apollo_router__router__event__schema__tests__schema_by_url_fallback@logs.snap index 1b46f69fa9..d0178808a0 100644 --- a/apollo-router/src/router/event/snapshots/apollo_router__router__event__schema__tests__schema_by_url_fallback@logs.snap +++ b/apollo-router/src/router/event/snapshots/apollo_router__router__event__schema__tests__schema_by_url_fallback@logs.snap @@ -1,15 +1,10 @@ --- source: apollo-router/src/router/event/schema.rs expression: yaml +snapshot_kind: text --- - fields: http.response.status_code: 400 url.full: "[url.full]" level: WARN message: failed to fetch supergraph schema -- fields: - http.response.status_code: 400 - url.full: "[url.full]" - level: WARN - message: failed to fetch supergraph schema - diff --git a/apollo-router/src/router/mod.rs b/apollo-router/src/router/mod.rs index bd5461a046..3a5d6489d0 100644 --- a/apollo-router/src/router/mod.rs +++ b/apollo-router/src/router/mod.rs @@ -1,6 +1,4 @@ #![allow(missing_docs)] // FIXME -#![allow(deprecated)] // Note: Required to prevents complaints on enum declaration - mod error; mod event; @@ -13,16 +11,15 @@ pub use error::ApolloRouterError; pub use event::ConfigurationSource; pub(crate) use event::Event; pub use event::LicenseSource; -pub(crate) use event::ReloadSource; pub use event::SchemaSource; pub use event::ShutdownSource; +use futures::FutureExt; #[cfg(test)] use futures::channel::mpsc; #[cfg(test)] use futures::channel::mpsc::SendError; use futures::channel::oneshot; use futures::prelude::*; -use futures::FutureExt; #[cfg(test)] use tokio::sync::Notify; use tokio::sync::RwLock; @@ -32,6 +29,8 @@ use tracing_futures::WithSubscriber; use crate::axum_factory::AxumHttpServerFactory; use crate::configuration::ListenAddr; use crate::orbiter::OrbiterRouterSuperServiceFactory; +use crate::plugins::chaos::ChaosEventStream; +use crate::router::event::reload::ReloadableEventStream; use crate::router_factory::YamlRouterFactory; use crate::state_machine::ListenAddresses; use crate::state_machine::StateMachine; @@ -233,32 +232,21 @@ fn generate_event_stream( license: LicenseSource, shutdown_receiver: oneshot::Receiver<()>, ) -> impl Stream { - let reload_source = ReloadSource::default(); - - let stream = stream::select_all(vec![ + stream::select_all(vec![ shutdown.into_stream().boxed(), schema.into_stream().boxed(), license.into_stream().boxed(), - reload_source.clone().into_stream().boxed(), - configuration - .into_stream(uplink_config) - .map(move |config_event| { - if let Event::UpdateConfiguration(config) = &config_event { - reload_source.set_period(&config.experimental_chaos.force_reload) - } - config_event - }) - .boxed(), + configuration.into_stream(uplink_config).boxed(), shutdown_receiver .into_stream() .map(|_| Event::Shutdown) .boxed(), ]) + .with_sighup_reload() + .with_chaos_reload() .take_while(|msg| future::ready(!matches!(msg, Event::Shutdown))) - // Chain is required so that the final shutdown message is sent. .chain(stream::iter(vec![Event::Shutdown])) - .boxed(); - stream + .boxed() } #[cfg(test)] @@ -348,13 +336,14 @@ mod tests { use serde_json::to_string_pretty; use super::*; + use crate::Configuration; use crate::graphql; use crate::graphql::Request; use crate::router::Event::UpdateConfiguration; use crate::router::Event::UpdateLicense; use crate::router::Event::UpdateSchema; use crate::uplink::license_enforcement::LicenseState; - use crate::Configuration; + use crate::uplink::schema::SchemaState; fn init_with_server() -> RouterHttpServer { let configuration = @@ -413,15 +402,18 @@ mod tests { // let's push a valid configuration to the state machine, so it can start up router_handle - .send_event(UpdateConfiguration(configuration)) + .send_event(UpdateConfiguration(Arc::new(configuration))) .await .unwrap(); router_handle - .send_event(UpdateSchema(schema.to_string())) + .send_event(UpdateSchema(SchemaState { + sdl: schema.to_string(), + launch_id: None, + })) .await .unwrap(); router_handle - .send_event(UpdateLicense(LicenseState::Unlicensed)) + .send_event(UpdateLicense(Arc::new(LicenseState::Unlicensed))) .await .unwrap(); @@ -453,20 +445,21 @@ mod tests { let mut router_handle = TestRouterHttpServer::new(); // let's push a valid configuration to the state machine, so it can start up router_handle - .send_event(UpdateConfiguration( + .send_event(UpdateConfiguration(Arc::new( Configuration::from_str(include_str!("../testdata/supergraph_config.router.yaml")) .unwrap(), - )) + ))) .await .unwrap(); router_handle - .send_event(UpdateSchema( - include_str!("../testdata/supergraph_missing_name.graphql").to_string(), - )) + .send_event(UpdateSchema(SchemaState { + sdl: include_str!("../testdata/supergraph_missing_name.graphql").to_string(), + launch_id: None, + })) .await .unwrap(); router_handle - .send_event(UpdateLicense(LicenseState::Unlicensed)) + .send_event(UpdateLicense(Arc::new(LicenseState::Unlicensed))) .await .unwrap(); @@ -502,9 +495,10 @@ mod tests { // let's update the schema to add the field router_handle - .send_event(UpdateSchema( - include_str!("../testdata/supergraph.graphql").to_string(), - )) + .send_event(UpdateSchema(SchemaState { + sdl: include_str!("../testdata/supergraph.graphql").to_string(), + launch_id: None, + })) .await .unwrap(); @@ -528,9 +522,10 @@ mod tests { // let's go back and remove the field router_handle - .send_event(UpdateSchema( - include_str!("../testdata/supergraph_missing_name.graphql").to_string(), - )) + .send_event(UpdateSchema(SchemaState { + sdl: include_str!("../testdata/supergraph_missing_name.graphql").to_string(), + launch_id: None, + })) .await .unwrap(); diff --git a/apollo-router/src/router_factory.rs b/apollo-router/src/router_factory.rs index ca40bd0a86..3e9245857a 100644 --- a/apollo-router/src/router_factory.rs +++ b/apollo-router/src/router_factory.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::HashSet; use std::io; use std::sync::Arc; @@ -8,29 +9,36 @@ use http::StatusCode; use indexmap::IndexMap; use multimap::MultiMap; use rustls::RootCertStore; +use rustls::pki_types::CertificateDer; use serde_json::Map; use serde_json::Value; -use tower::service_fn; use tower::BoxError; -use tower::ServiceBuilder; use tower::ServiceExt; +use tower::service_fn; use tower_service::Service; use tracing::Instrument; +use crate::AllowedFeature; +use crate::ListenAddr; +use crate::configuration::APOLLO_PLUGIN_PREFIX; use crate::configuration::Configuration; use crate::configuration::ConfigurationError; use crate::configuration::TlsClient; -use crate::configuration::APOLLO_PLUGIN_PREFIX; use crate::plugin::DynPlugin; use crate::plugin::Handler; use crate::plugin::PluginFactory; use crate::plugin::PluginInit; -use crate::plugins::subscription::Subscription; use crate::plugins::subscription::APOLLO_SUBSCRIPTION_PLUGIN; +use crate::plugins::subscription::Subscription; use crate::plugins::telemetry::reload::apollo_opentelemetry_initialized; -use crate::plugins::traffic_shaping::TrafficShaping; use crate::plugins::traffic_shaping::APOLLO_TRAFFIC_SHAPING; -use crate::query_planner::BridgeQueryPlannerPool; +use crate::plugins::traffic_shaping::TrafficShaping; +use crate::query_planner::QueryPlannerService; +use crate::services::HasSchema; +use crate::services::PluggableSupergraphServiceBuilder; +use crate::services::Plugins; +use crate::services::SubgraphService; +use crate::services::SupergraphCreator; use crate::services::apollo_graph_reference; use crate::services::apollo_key; use crate::services::http::HttpClientServiceFactory; @@ -38,17 +46,10 @@ use crate::services::layers::persisted_queries::PersistedQueryLayer; use crate::services::layers::query_analysis::QueryAnalysisLayer; use crate::services::new_service::ServiceFactory; use crate::services::router; +use crate::services::router::pipeline_handle::PipelineRef; use crate::services::router::service::RouterCreator; -use crate::services::subgraph; -use crate::services::transport; -use crate::services::HasConfig; -use crate::services::HasSchema; -use crate::services::PluggableSupergraphServiceBuilder; -use crate::services::Plugins; -use crate::services::SubgraphService; -use crate::services::SupergraphCreator; use crate::spec::Schema; -use crate::ListenAddr; +use crate::uplink::license_enforcement::LicenseState; pub(crate) const STARTING_SPAN_NAME: &str = "starting"; @@ -70,21 +71,6 @@ impl std::fmt::Debug for Endpoint { } impl Endpoint { - /// Creates an Endpoint given a path and a Boxed Service - #[deprecated = "use `from_router_service` instead"] - #[allow(deprecated)] - pub fn new(path: String, handler: transport::BoxService) -> Self { - let router_service = ServiceBuilder::new() - .map_request(|request: router::Request| request.router_request) - .map_response(|response: transport::Response| response.into()) - .service(handler) - .boxed(); - Self { - path, - handler: Handler::new(router_service), - } - } - /// Creates an Endpoint given a path and a Boxed Service pub fn from_router_service(path: String, handler: router::BoxService) -> Self { Self { @@ -92,8 +78,9 @@ impl Endpoint { handler: Handler::new(handler), } } + pub(crate) fn into_router(self) -> axum::Router { - let handler = move |req: http::Request| { + let handler = move |req: http::Request| { let endpoint = self.handler.clone(); async move { Ok(endpoint @@ -123,6 +110,8 @@ pub(crate) trait RouterFactory: type Future: Send; fn web_endpoints(&self) -> MultiMap; + + fn pipeline_ref(&self) -> Arc; } /// Factory for creating a RouterFactory @@ -140,6 +129,7 @@ pub(crate) trait RouterSuperServiceFactory: Send + Sync + 'static { schema: Arc, previous_router: Option<&'a Self::RouterFactory>, extra_plugins: Option)>>, + license: Arc, ) -> Result; } @@ -158,47 +148,50 @@ impl RouterSuperServiceFactory for YamlRouterFactory { schema: Arc, previous_router: Option<&'a Self::RouterFactory>, extra_plugins: Option)>>, + license: Arc, ) -> Result { // we have to create a telemetry plugin before creating everything else, to generate a trace // of router and plugin creation let plugin_registry = &*crate::plugin::PLUGINS; let mut initial_telemetry_plugin = None; - if previous_router.is_none() && apollo_opentelemetry_initialized() { - if let Some(factory) = plugin_registry + if previous_router.is_none() + && apollo_opentelemetry_initialized() + && let Some(factory) = plugin_registry .iter() .find(|factory| factory.name == "apollo.telemetry") - { - let mut telemetry_config = configuration - .apollo_plugins - .plugins - .get("telemetry") - .cloned(); - if let Some(plugin_config) = &mut telemetry_config { - inject_schema_id(Some(&schema.schema_id), plugin_config); - match factory - .create_instance( - PluginInit::builder() - .config(plugin_config.clone()) - .supergraph_sdl(schema.raw_sdl.clone()) - .supergraph_schema_id(schema.schema_id.clone()) - .supergraph_schema(Arc::new(schema.supergraph_schema().clone())) - .notify(configuration.notify.clone()) - .build(), - ) - .await - { - Ok(plugin) => { - if let Some(telemetry) = plugin - .as_any() - .downcast_ref::( - ) { - telemetry.activate(); - } - initial_telemetry_plugin = Some(plugin); + { + let mut telemetry_config = configuration + .apollo_plugins + .plugins + .get("telemetry") + .cloned(); + if let Some(plugin_config) = &mut telemetry_config { + inject_schema_id(schema.schema_id.as_str(), plugin_config); + match factory + .create_instance( + PluginInit::builder() + .config(plugin_config.clone()) + .supergraph_sdl(schema.raw_sdl.clone()) + .supergraph_schema_id(schema.schema_id.clone().into_inner()) + .supergraph_schema(Arc::new(schema.supergraph_schema().clone())) + .notify(configuration.notify.clone()) + .license(license.clone()) + .full_config(configuration.validated_yaml.clone()) + .build(), + ) + .await + { + Ok(plugin) => { + if let Some(telemetry) = plugin + .as_any() + .downcast_ref::() + { + telemetry.activate(); } - Err(e) => return Err(e), + initial_telemetry_plugin = Some(plugin); } + Err(e) => return Err(e), } } } @@ -210,6 +203,7 @@ impl RouterSuperServiceFactory for YamlRouterFactory { previous_router, initial_telemetry_plugin, extra_plugins, + license, ) .instrument(router_span) .await @@ -224,14 +218,15 @@ impl YamlRouterFactory { previous_router: Option<&'a RouterCreator>, initial_telemetry_plugin: Option>, extra_plugins: Option)>>, + license: Arc, ) -> Result { let mut supergraph_creator = self .inner_create_supergraph( configuration.clone(), schema, - previous_router.map(|router| &*router.supergraph_creator), initial_telemetry_plugin, extra_plugins, + license, ) .await?; @@ -285,65 +280,39 @@ impl YamlRouterFactory { .await } - pub(crate) async fn inner_create_supergraph<'a>( - &'a mut self, + pub(crate) async fn inner_create_supergraph( + &mut self, configuration: Arc, schema: Arc, - previous_supergraph: Option<&'a SupergraphCreator>, initial_telemetry_plugin: Option>, extra_plugins: Option)>>, + license: Arc, ) -> Result { let query_planner_span = tracing::info_span!("query_planner_creation"); // QueryPlannerService takes an UnplannedRequest and outputs PlannedRequest - let bridge_query_planner = BridgeQueryPlannerPool::new( - previous_supergraph - .as_ref() - .map(|router| router.js_planners()) - .unwrap_or_default(), - schema.clone(), - configuration.clone(), - configuration - .supergraph - .query_planning - .experimental_query_planner_parallelism()?, - ) - .instrument(query_planner_span) - .await?; - - let schema_changed = previous_supergraph - .map(|supergraph_creator| supergraph_creator.schema().raw_sdl == schema.raw_sdl) - .unwrap_or_default(); - - let config_changed = previous_supergraph - .map(|supergraph_creator| supergraph_creator.config() == configuration) - .unwrap_or_default(); - - if config_changed { - configuration - .notify - .broadcast_configuration(Arc::downgrade(&configuration)); - } - - let schema_span = tracing::info_span!("schema"); - let _guard = schema_span.enter(); - - let schema = bridge_query_planner.schema(); - if schema_changed { - configuration.notify.broadcast_schema(schema.clone()); - } - drop(_guard); - drop(schema_span); + let planner = QueryPlannerService::new(schema.clone(), configuration.clone()) + .instrument(query_planner_span) + .await?; let span = tracing::info_span!("plugins"); // Process the plugins. + let subgraph_schemas = Arc::new( + planner + .subgraph_schemas() + .iter() + .map(|(k, v)| (k.clone(), v.schema.clone())) + .collect(), + ); + let plugins: Arc = Arc::new( create_plugins( &configuration, &schema, - bridge_query_planner.subgraph_schemas(), + subgraph_schemas, initial_telemetry_plugin, extra_plugins, + license, ) .instrument(span) .await? @@ -352,10 +321,13 @@ impl YamlRouterFactory { ); async { - let mut builder = PluggableSupergraphServiceBuilder::new(bridge_query_planner); + let mut builder = PluggableSupergraphServiceBuilder::new(planner); builder = builder.with_configuration(configuration.clone()); + let http_service_factory = + create_http_services(&plugins, &schema, &configuration).await?; let subgraph_services = - create_subgraph_services(&plugins, &schema, &configuration).await?; + create_subgraph_services(&http_service_factory, &plugins, &configuration).await?; + builder = builder.with_http_service_factory(http_service_factory); for (name, subgraph_service) in subgraph_services { builder = builder.with_subgraph_service(&name, subgraph_service); } @@ -371,39 +343,52 @@ impl YamlRouterFactory { } pub(crate) async fn create_subgraph_services( + http_service_factory: &IndexMap, + plugins: &Arc, + configuration: &Configuration, +) -> Result, BoxError> { + let subscription_plugin_conf = plugins + .iter() + .find(|i| i.0.as_str() == APOLLO_SUBSCRIPTION_PLUGIN) + .and_then(|plugin| (*plugin.1).as_any().downcast_ref::()) + .map(|p| p.config.clone()); + + let mut subgraph_services = IndexMap::default(); + for (name, http_service_factory) in http_service_factory.iter() { + let subgraph_service = SubgraphService::from_config( + name.clone(), + configuration, + subscription_plugin_conf.clone(), + http_service_factory.clone(), + )?; + subgraph_services.insert(name.clone(), subgraph_service); + } + + Ok(subgraph_services) +} + +pub(crate) async fn create_http_services( plugins: &Arc, schema: &Schema, configuration: &Configuration, -) -> Result< - IndexMap< - String, - impl Service< - subgraph::Request, - Response = subgraph::Response, - Error = BoxError, - Future = crate::plugins::traffic_shaping::TrafficShapingSubgraphFuture< - SubgraphService, - >, - > + Clone - + Send - + Sync - + 'static, - >, - BoxError, -> { - let tls_root_store: RootCertStore = configuration +) -> Result, BoxError> { + // Note we are grabbing these root stores once and then reusing it for each subgraph. Why? + // When TLS was not configured for subgraphs, the OS provided list of certificates was parsed once per subgraph, which resulted in long loading times on OSX. + // This generates the native root store once, and reuses it across subgraphs + let subgraph_tls_root_store: RootCertStore = configuration .tls .subgraph .all .create_certificate_store() .transpose()? .unwrap_or_else(crate::services::http::HttpClientService::native_roots_store); - - let subscription_plugin_conf = plugins - .iter() - .find(|i| i.0.as_str() == APOLLO_SUBSCRIPTION_PLUGIN) - .and_then(|plugin| (*plugin.1).as_any().downcast_ref::()) - .map(|p| p.config.clone()); + let connector_tls_root_store: RootCertStore = configuration + .tls + .connector + .all + .create_certificate_store() + .transpose()? + .unwrap_or_else(crate::services::http::HttpClientService::native_roots_store); let shaping = plugins .iter() @@ -411,30 +396,53 @@ pub(crate) async fn create_subgraph_services( .and_then(|plugin| (*plugin.1).as_any().downcast_ref::()) .expect("traffic shaping should always be part of the plugin list"); - let mut subgraph_services = IndexMap::default(); + let connector_subgraphs: HashSet = schema + .connectors + .as_ref() + .map(|c| { + c.by_service_name + .iter() + .map(|(_, connector)| connector.id.subgraph_name.clone()) + .collect() + }) + .unwrap_or_default(); + + let mut http_services = IndexMap::new(); for (name, _) in schema.subgraphs() { - let http_service = crate::services::http::HttpClientService::from_config( + if connector_subgraphs.contains(name) { + continue; // Avoid adding services for subgraphs that are actually connectors since we'll separately add them below per source + } + let http_service = crate::services::http::HttpClientService::from_config_for_subgraph( name, configuration, - &tls_root_store, + &subgraph_tls_root_store, shaping.subgraph_client_config(name), )?; let http_service_factory = HttpClientServiceFactory::new(http_service, plugins.clone()); + http_services.insert(name.clone(), http_service_factory); + } + + // Also create client service factories for connector sources + let connector_sources = schema + .connectors + .as_ref() + .map(|c| c.source_config_keys.clone()) + .unwrap_or_default(); - let subgraph_service = shaping.subgraph_service_internal( + for name in connector_sources.iter() { + let http_service = crate::services::http::HttpClientService::from_config_for_connector( name, - SubgraphService::from_config( - name, - configuration, - subscription_plugin_conf.clone(), - http_service_factory, - )?, - ); - subgraph_services.insert(name.clone(), subgraph_service); + configuration, + &connector_tls_root_store, + shaping.connector_client_config(name), + )?; + + let http_service_factory = HttpClientServiceFactory::new(http_service, plugins.clone()); + http_services.insert(name.clone(), http_service_factory); } - Ok(subgraph_services) + Ok(http_services) } impl TlsClient { @@ -458,7 +466,7 @@ pub(crate) fn create_certificate_store( })?; for certificate in certificates { store - .add(&certificate) + .add(certificate) .map_err(|e| ConfigurationError::CertificateAuthorities { error: format!("could not add certificate to root store: {e}"), })?; @@ -472,17 +480,15 @@ pub(crate) fn create_certificate_store( } } -fn load_certs(certificates: &str) -> io::Result> { +fn load_certs(certificates: &str) -> io::Result>> { tracing::debug!("loading root certificates"); // Load and return certificate. - let certs = rustls_pemfile::certs(&mut certificates.as_bytes()).map_err(|_| { - io::Error::new( - io::ErrorKind::Other, - "failed to load certificate".to_string(), - ) - })?; - Ok(certs.into_iter().map(rustls::Certificate).collect()) + rustls_pemfile::certs(&mut certificates.as_bytes()) + .collect::, _>>() + // XXX(@goto-bus-stop): the error type here is already io::Error. Should we wrap it, + // instead of replacing it with this generic error message? + .map_err(|_| io::Error::other("failed to load certificate")) } /// test only helper method to create a router factory in integration tests @@ -494,16 +500,20 @@ pub async fn create_test_service_factory_from_yaml(schema: &str, configuration: let is_telemetry_disabled = false; let service = YamlRouterFactory - .create(is_telemetry_disabled, Arc::new(config), schema, None, None) + .create( + is_telemetry_disabled, + Arc::new(config), + schema, + None, + None, + Default::default(), + ) .await; assert_eq!( service.map(|_| ()).unwrap_err().to_string().as_str(), - r#"couldn't build Query Planner Service: couldn't instantiate query planner; invalid schema: schema validation errors: Unexpected error extracting subgraphs from the supergraph: this is either a bug, or the supergraph has been corrupted. + r#"failed to initialize the query planner: An internal error has occurred, please report this bug to Apollo. -Details: -Error: Cannot find type "Review" in subgraph "products" -caused by -"# +Details: Object field "Product.reviews"'s inner type "Review" does not refer to an existing output type."# ); } @@ -516,9 +526,12 @@ pub(crate) async fn add_plugin( schema_id: Arc, supergraph_schema: Arc>, subgraph_schemas: Arc>>>, + launch_id: Option>, notify: &crate::notification::Notify, plugin_instances: &mut Plugins, errors: &mut Vec, + license: Arc, + full_config: Option, ) { match factory .create_instance( @@ -528,7 +541,10 @@ pub(crate) async fn add_plugin( .supergraph_schema_id(schema_id) .supergraph_schema(supergraph_schema) .subgraph_schemas(subgraph_schemas) + .launch_id(launch_id) .notify(notify.clone()) + .license(license) + .and_full_config(full_config) .build(), ) .await @@ -549,9 +565,10 @@ pub(crate) async fn create_plugins( subgraph_schemas: Arc>>>, initial_telemetry_plugin: Option>, extra_plugins: Option)>>, + license: Arc, ) -> Result { let supergraph_schema = Arc::new(schema.supergraph_schema().clone()); - let supergraph_schema_id = schema.schema_id.clone(); + let supergraph_schema_id = schema.schema_id.clone().into_inner(); let mut apollo_plugins_config = configuration.apollo_plugins.clone().plugins; let user_plugins_config = configuration.plugins.clone().plugins.unwrap_or_default(); let extra = extra_plugins.unwrap_or_default(); @@ -576,7 +593,7 @@ pub(crate) async fn create_plugins( // Use function-like macros to avoid borrow conflicts of captures macro_rules! add_plugin { - ($name: expr, $factory: expr, $plugin_config: expr) => {{ + ($name: expr, $factory: expr, $plugin_config: expr, $maybe_full_config: expr) => {{ add_plugin( $name, $factory, @@ -585,15 +602,18 @@ pub(crate) async fn create_plugins( supergraph_schema_id.clone(), supergraph_schema.clone(), subgraph_schemas.clone(), + schema.launch_id.clone(), &configuration.notify.clone(), &mut plugin_instances, &mut errors, + license.clone(), + $maybe_full_config, ) .await; }}; } - macro_rules! add_apollo_plugin { + macro_rules! add_mandatory_apollo_plugin_inner { ($name: literal, $opt_plugin_config: expr) => {{ let name = concat!("apollo.", $name); let span = tracing::info_span!(concat!("plugin: ", "apollo.", $name)); @@ -602,17 +622,69 @@ pub(crate) async fn create_plugins( .remove(name) .unwrap_or_else(|| panic!("Apollo plugin not registered: {name}")); if let Some(mut plugin_config) = $opt_plugin_config { + let mut full_config = None; if name == "apollo.telemetry" { // The apollo.telemetry" plugin isn't happy with empty config, so we // give it some. If any of the other mandatory plugins need special - // treatment, then we'll have to perform it here. - // This is *required* by the telemetry module or it will fail... - inject_schema_id( - Some(&Schema::schema_id(&schema.raw_sdl)), - &mut plugin_config, - ); + // treatment, then we'll have to perform it here + inject_schema_id(&supergraph_schema_id, &mut plugin_config); + + // Only the telemetry plugin should have access to the full configuration + full_config = configuration.validated_yaml.clone(); } - add_plugin!(name.to_string(), factory, plugin_config); + add_plugin!(name.to_string(), factory, plugin_config, full_config); + } + } + .instrument(span) + .await; + }}; + } + + macro_rules! add_optional_apollo_plugin_inner { + ($name: literal, $opt_plugin_config: expr, $license: expr) => {{ + let name = concat!("apollo.", $name); + let span = tracing::info_span!(concat!("plugin: ", "apollo.", $name)); + async { + let factory = apollo_plugin_factories + .remove(name) + .unwrap_or_else(|| panic!("Apollo plugin not registered: {name}")); + if let Some(plugin_config) = $opt_plugin_config { + let allowed_features = $license.get_allowed_features(); + + match AllowedFeature::from_plugin_name($name) { + Some(allowed_feature) => { + if allowed_features.contains(&allowed_feature) { + add_plugin!(name.to_string(), factory, plugin_config, None); + } else { + tracing::warn!( + "{name} plugin is not registered, {name} is a restricted feature that requires a license" + ); + } + } + None => { + // If the plugin name did not map to an allowed feature we add it + add_plugin!(name.to_string(), factory, plugin_config, None); + } + } + } + } + .instrument(span) + .await; + }}; + } + + macro_rules! add_oss_apollo_plugin_inner { + ($name: literal, $opt_plugin_config: expr) => {{ + let name = concat!("apollo.", $name); + let span = tracing::info_span!(concat!("plugin: ", "apollo.", $name)); + async { + let factory = apollo_plugin_factories + .remove(name) + .unwrap_or_else(|| panic!("Apollo plugin not registered: {name}")); + if let Some(plugin_config) = $opt_plugin_config { + // We add oss plugins without a license check + add_plugin!(name.to_string(), factory, plugin_config, None); + return; } } .instrument(span) @@ -622,7 +694,7 @@ pub(crate) async fn create_plugins( macro_rules! add_mandatory_apollo_plugin { ($name: literal) => { - add_apollo_plugin!( + add_mandatory_apollo_plugin_inner!( $name, Some( apollo_plugins_config @@ -635,7 +707,13 @@ pub(crate) async fn create_plugins( macro_rules! add_optional_apollo_plugin { ($name: literal) => { - add_apollo_plugin!($name, apollo_plugins_config.remove($name)); + add_optional_apollo_plugin_inner!($name, apollo_plugins_config.remove($name), &license); + }; + } + + macro_rules! add_oss_apollo_plugin { + ($name: literal) => { + add_oss_apollo_plugin_inner!($name, apollo_plugins_config.remove($name)); }; } @@ -648,7 +726,7 @@ pub(crate) async fn create_plugins( if let Some(factory) = plugin_registry.iter().find(|factory| factory.name == name) { - add_plugin!(name, factory, plugin_config); + add_plugin!(name, factory, plugin_config, None); } else { errors.push(ConfigurationError::PluginUnknown(name)) } @@ -661,8 +739,29 @@ pub(crate) async fn create_plugins( }; } + // Be careful with this list! Moving things around can have subtle consequences. + // Requests flow through this list multiple times in two directions. First, they go "down" + // through the list several times as requests at the different services. Then, they go + // "up" through the list as a response several times, once for each service. + // + // The order of this list determines the relative order of plugin hooks executing at each + // service. This is *not* the same as the order a request flows through the router. + // For example, assume these three plugins: + // 1. header propagation (has a hook at the subgraph service) + // 2. telemetry (has hooks at router, supergraph, and subgraph services) + // 3. rate limiting (has a hook at the router service) + // The order here means that header propagation happens before telemetry *at the subgraph + // service*. Depending on the requirements of plugins, it may have to be in this order. The + // *router service* hook for telemetry still happens well before header propagation. Similarly, + // header propagation being first does not mean that it's exempt from rate limiting, for the + // same reason. Rate limiting must be after telemetry, though, because telemetry and rate + // limiting both work at the router service, and requests rejected from the router service must + // flow through telemetry so we can record errors. + // + // Broadly, for telemetry to work, we must make sure that the telemetry plugin is the first + // plugin in this list *that adds a router service hook*. Other plugins can be before the + // telemetry plugin if they must do work *before* telemetry at specific services. add_mandatory_apollo_plugin!("include_subgraph_errors"); - add_mandatory_apollo_plugin!("csrf"); add_mandatory_apollo_plugin!("headers"); if apollo_telemetry_plugin_mandatory { match initial_telemetry_plugin { @@ -676,23 +775,36 @@ pub(crate) async fn create_plugins( } } } - add_mandatory_apollo_plugin!("limits"); + add_mandatory_apollo_plugin!("license_enforcement"); + add_mandatory_apollo_plugin!("health_check"); add_mandatory_apollo_plugin!("traffic_shaping"); - add_optional_apollo_plugin!("forbid_mutations"); + add_mandatory_apollo_plugin!("limits"); + add_mandatory_apollo_plugin!("csrf"); + add_mandatory_apollo_plugin!("fleet_detector"); + add_mandatory_apollo_plugin!("enhanced_client_awareness"); + + add_oss_apollo_plugin!("forbid_mutations"); add_optional_apollo_plugin!("subscription"); - add_optional_apollo_plugin!("override_subgraph_url"); + add_oss_apollo_plugin!("override_subgraph_url"); add_optional_apollo_plugin!("authorization"); add_optional_apollo_plugin!("authentication"); - add_optional_apollo_plugin!("preview_file_uploads"); + add_oss_apollo_plugin!("preview_file_uploads"); add_optional_apollo_plugin!("preview_entity_cache"); + add_oss_apollo_plugin!("experimental_response_cache"); add_mandatory_apollo_plugin!("progressive_override"); add_optional_apollo_plugin!("demand_control"); // This relative ordering is documented in `docs/source/customizations/native.mdx`: - add_optional_apollo_plugin!("rhai"); + add_oss_apollo_plugin!("connectors"); + add_oss_apollo_plugin!("rhai"); add_optional_apollo_plugin!("coprocessor"); add_user_plugins!(); + // Because this plugin intercepts subgraph requests + // and does not forward them to the next service in the chain, + // it needs to intervene after user plugins for users plugins to run at all. + add_optional_apollo_plugin!("experimental_mock_subgraphs"); + // Macros above remove from `apollo_plugin_factories`, so anything left at the end // indicates a missing macro call. let unused_apollo_plugin_names = apollo_plugin_factories.keys().copied().collect::>(); @@ -720,16 +832,27 @@ pub(crate) async fn create_plugins( tracing::error!("{:#}", error); } + let errors_list = errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + Err(BoxError::from(format!( - "there were {} configuration errors", - errors.len() + "there were {} configuration errors\n{}", + errors.len(), + errors_list ))) } else { Ok(plugin_instances) } } -fn inject_schema_id(schema_id: Option<&str>, configuration: &mut Value) { +fn inject_schema_id( + // Ideally we'd use &SchemaHash, but we'll need to update a bunch of tests to do so + schema_id: &str, + configuration: &mut Value, +) { if configuration.get("apollo").is_none() { // Warning: this must be done here, otherwise studio reporting will not work if apollo_key().is_some() && apollo_graph_reference().is_some() { @@ -740,33 +863,59 @@ fn inject_schema_id(schema_id: Option<&str>, configuration: &mut Value) { return; } } - if let (Some(schema_id), Some(apollo)) = (schema_id, configuration.get_mut("apollo")) { - if let Some(apollo) = apollo.as_object_mut() { - apollo.insert( - "schema_id".to_string(), - Value::String(schema_id.to_string()), - ); - } + if let Some(apollo) = configuration.get_mut("apollo") + && let Some(apollo) = apollo.as_object_mut() + { + apollo.insert( + "schema_id".to_string(), + Value::String(schema_id.to_string()), + ); } } #[cfg(test)] mod test { + use std::collections::HashSet; use std::sync::Arc; + use rstest::rstest; use schemars::JsonSchema; use serde::Deserialize; use serde_json::json; use tower_http::BoxError; + use crate::AllowedFeature; use crate::configuration::Configuration; use crate::plugin::Plugin; use crate::plugin::PluginInit; use crate::register_plugin; - use crate::router_factory::inject_schema_id; use crate::router_factory::RouterSuperServiceFactory; use crate::router_factory::YamlRouterFactory; + use crate::router_factory::inject_schema_id; + use crate::services::supergraph::service::HasPlugins; use crate::spec::Schema; + use crate::uplink::license_enforcement::LicenseLimits; + use crate::uplink::license_enforcement::LicenseState; + + const MANDATORY_PLUGINS: &[&str] = &[ + "apollo.include_subgraph_errors", + "apollo.headers", + "apollo.license_enforcement", + "apollo.health_check", + "apollo.traffic_shaping", + "apollo.limits", + "apollo.csrf", + "apollo.fleet_detector", + "apollo.enhanced_client_awareness", + "apollo.progressive_override", + ]; + + const OSS_PLUGINS: &[&str] = &[ + "apollo.forbid_mutations", + "apollo.override_subgraph_url", + "apollo.experimental_response_cache", + "apollo.connectors", + ]; // Always starts and stops plugin @@ -813,6 +962,24 @@ mod test { register_plugin!("test", "always_fails_to_start", AlwaysFailsToStartPlugin); + async fn create_service(config: Configuration) -> Result<(), BoxError> { + let schema = include_str!("testdata/supergraph.graphql"); + let schema = Schema::parse(schema, &config)?; + + let is_telemetry_disabled = false; + let service = YamlRouterFactory + .create( + is_telemetry_disabled, + Arc::new(config), + Arc::new(schema), + None, + None, + Arc::new(LicenseState::default()), + ) + .await; + service.map(|_| ()) + } + #[tokio::test] async fn test_yaml_no_extras() { let config = Configuration::builder().build().unwrap(); @@ -864,35 +1031,663 @@ mod test { assert!(service.is_err()) } - async fn create_service(config: Configuration) -> Result<(), BoxError> { + #[test] + fn test_inject_schema_id() { + let mut config = json!({ "apollo": {} }); + inject_schema_id( + "8e2021d131b23684671c3b85f82dfca836908c6a541bbd5c3772c66e7f8429d8", + &mut config, + ); + let config = + serde_json::from_value::(config).unwrap(); + assert_eq!( + &config.apollo.schema_id, + "8e2021d131b23684671c3b85f82dfca836908c6a541bbd5c3772c66e7f8429d8" + ); + } + + fn get_plugin_config(plugin: &str) -> &str { + match plugin { + "subscription" => { + r#" + enabled: true + "# + } + "authentication" => { + r#" + connector: + sources: {} + "# + } + "authorization" => { + r#" + require_authentication: false + "# + } + "preview_file_uploads" => { + r#" + enabled: true + protocols: + multipart: + enabled: false + "# + } + "preview_entity_cache" => { + r#" + enabled: true + subgraph: + all: + enabled: true + "# + } + "demand_control" => { + r#" + enabled: true + mode: measure + strategy: + static_estimated: + list_size: 0 + max: 0.0 + "# + } + "coprocessor" => { + r#" + url: http://service.example.com/url + "# + } + "connectors" => { + r#" + debug_extensions: false + "# + } + "experimental_response_cache" => { + r#" + enabled: true + subgraph: {} + "# + } + "experimental_mock_subgraphs" => { + r#" + subgraphs: {} + "# + } + "forbid_mutations" => { + r#" + false + "# + } + "override_subgraph_url" => { + r#" + {} + "# + } + _ => panic!("This function does not contain config for plugin: {plugin}"), + } + } + + #[tokio::test] + #[rstest] + #[case::empty_allowed_features_set(HashSet::new())] + #[case::nonempty_allowed_features_set(HashSet::from_iter(vec![AllowedFeature::Coprocessors]))] + async fn test_mandatory_plugins_added(#[case] allowed_features: HashSet) { + /* + * GIVEN + * - a valid license + * - a valid config + * - a valid schema + * */ + let license = LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features, + }), + }; + + let router_config = Configuration::builder().build().unwrap(); let schema = include_str!("testdata/supergraph.graphql"); - let schema = Schema::parse(schema, &config)?; + let schema = Schema::parse(schema, &router_config).unwrap(); + /* + * WHEN + * - the router factory runs (including the plugin inits gated by the license) + * */ let is_telemetry_disabled = false; let service = YamlRouterFactory .create( is_telemetry_disabled, - Arc::new(config), + Arc::new(router_config), Arc::new(schema), None, None, + Arc::new(license), ) - .await; - service.map(|_| ()) + .await + .unwrap(); + + /* + * THEN + * - the mandatory plugins are added + * */ + assert!( + MANDATORY_PLUGINS + .iter() + .all(|plugin| { service.supergraph_creator.plugins().contains_key(*plugin) }) + ); } - #[test] - fn test_inject_schema_id() { - let mut config = json!({ "apollo": {} }); - inject_schema_id( - Some("8e2021d131b23684671c3b85f82dfca836908c6a541bbd5c3772c66e7f8429d8"), - &mut config, + #[tokio::test] + #[rstest] + #[case::allowed_features_empty(HashSet::new())] + #[case::allowed_features_nonempty(HashSet::from_iter(vec![ + AllowedFeature::Coprocessors, + AllowedFeature::DemandControl + ]))] + async fn test_oss_plugins_added(#[case] allowed_features: HashSet) { + /* + * GIVEN + * - a valid license + * - a valid config that contains configuration for oss plugins + * - a valid schema + * */ + let license = LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features, + }), + }; + + // Create config for oss plugins + let forbid_mutations_config = + serde_yaml::from_str::(get_plugin_config("forbid_mutations")) + .unwrap(); + let override_subgraph_url_config = + serde_yaml::from_str::(get_plugin_config("override_subgraph_url")) + .unwrap(); + let connectors_config = + serde_yaml::from_str::(get_plugin_config("connectors")).unwrap(); + let response_cache_config = serde_yaml::from_str::(get_plugin_config( + "experimental_response_cache", + )) + .unwrap(); + + let router_config = Configuration::builder() + .apollo_plugin("forbid_mutations", forbid_mutations_config) + .apollo_plugin("override_subgraph_url", override_subgraph_url_config) + .apollo_plugin("connectors", connectors_config) + .apollo_plugin("experimental_response_cache", response_cache_config) + .build() + .unwrap(); + + let schema = include_str!("testdata/supergraph.graphql"); + let schema = Schema::parse(schema, &router_config).unwrap(); + + /* + * WHEN + * - the router factory runs (including the plugin inits gated by the license) + * */ + let is_telemetry_disabled = false; + let service = YamlRouterFactory + .create( + is_telemetry_disabled, + Arc::new(router_config), + Arc::new(schema), + None, + None, + Arc::new(license), + ) + .await + .unwrap(); + + /* + * THEN + * - all oss plugins should have been added + * */ + assert!( + OSS_PLUGINS + .iter() + .all(|plugin| { service.supergraph_creator.plugins().contains_key(*plugin) }) ); - let config = - serde_json::from_value::(config).unwrap(); - assert_eq!( - &config.apollo.schema_id, - "8e2021d131b23684671c3b85f82dfca836908c6a541bbd5c3772c66e7f8429d8" + } + + #[tokio::test] + #[rstest] + #[case::subscripions( + "subscription", + HashSet::from_iter(vec![AllowedFeature::DemandControl, AllowedFeature::Subscriptions])) + ] + #[case::authorization( + "authorization", + HashSet::from_iter(vec![AllowedFeature::Authorization, AllowedFeature::Subscriptions])) + ] + #[case::authentication( + "authentication", + HashSet::from_iter(vec![AllowedFeature::DemandControl, AllowedFeature::Authentication, AllowedFeature::Subscriptions])) + ] + #[case::entity_caching( + "preview_entity_cache", + HashSet::from_iter(vec![AllowedFeature::EntityCaching, AllowedFeature::DemandControl])) + ] + #[case::authorization( + "demand_control", + HashSet::from_iter(vec![AllowedFeature::Authorization, AllowedFeature::Subscriptions, AllowedFeature::DemandControl])) + ] + #[case::coprocessor( + "coprocessor", + HashSet::from_iter(vec![AllowedFeature::Coprocessors, AllowedFeature::DemandControl])) + ] + async fn test_optional_plugin_added_with_restricted_allowed_features( + #[case] plugin: &str, + #[case] allowed_features: HashSet, + ) { + /* + * GIVEN + * - a restricted license with allowed feature set containing the given `plugin` + * - a valid config including valid config for the given `plugin` + * - a valid schema + * */ + let license = LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features, + }), + }; + + let plugin_config = + serde_yaml::from_str::(get_plugin_config(plugin)).unwrap(); + let router_config = Configuration::builder() + .apollo_plugin(plugin, plugin_config) + .build() + .unwrap(); + + let schema = include_str!("testdata/supergraph.graphql"); + let schema = Schema::parse(schema, &router_config).unwrap(); + + /* + * WHEN + * - the router factory runs (including the plugin inits gated by the license) + * */ + let is_telemetry_disabled = false; + let service = YamlRouterFactory + .create( + is_telemetry_disabled, + Arc::new(router_config), + Arc::new(schema), + None, + None, + Arc::new(license), + ) + .await + .unwrap(); + + /* + * THEN + * - since the plugin is part of the `allowed_features` set + * the plugin should have been added. + * - mandatory plugins should have been added. + * */ + assert!( + service + .supergraph_creator + .plugins() + .contains_key(&format!("apollo.{plugin}")), + "Plugin {plugin} should have been added" + ); + assert!( + MANDATORY_PLUGINS + .iter() + .all(|plugin| { service.supergraph_creator.plugins().contains_key(*plugin) }) + ); + } + + #[tokio::test] + #[rstest] + #[case::subscripions( + "subscription", + HashSet::from_iter(vec![])) + ] + #[case::authorization( + "authorization", + HashSet::from_iter(vec![AllowedFeature::Authentication, AllowedFeature::Subscriptions])) + ] + #[case::authentication( + "authentication", + HashSet::from_iter(vec![AllowedFeature::DemandControl,AllowedFeature::Subscriptions])) + ] + #[case::entity_caching( + "preview_entity_cache", + HashSet::from_iter(vec![AllowedFeature::DemandControl])) + ] + #[case::authorization( + "demand_control", + HashSet::from_iter(vec![AllowedFeature::Authorization, AllowedFeature::Subscriptions, AllowedFeature::Experimental])) + ] + #[case::coprocessor( + "coprocessor", + HashSet::from_iter(vec![AllowedFeature::DemandControl])) + ] + async fn test_optional_plugin_not_added_with_restricted_allowed_features( + #[case] plugin: &str, + #[case] allowed_features: HashSet, + ) { + /* + * GIVEN + * - a restricted license whose allowed feature set does not contain the given `plugin` + * - a valid config including valid config for the given `plugin` + * - a valid schema + * */ + let license = LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features, + }), + }; + + let plugin_config = + serde_yaml::from_str::(get_plugin_config(plugin)).unwrap(); + let router_config = Configuration::builder() + .apollo_plugin(plugin, plugin_config) + .build() + .unwrap(); + + let schema = include_str!("testdata/supergraph.graphql"); + let schema = Schema::parse(schema, &router_config).unwrap(); + + /* + * WHEN + * - the router factory runs (including the plugin inits gated by the license) + * */ + let is_telemetry_disabled = false; + let service = YamlRouterFactory + .create( + is_telemetry_disabled, + Arc::new(router_config), + Arc::new(schema), + None, + None, + Arc::new(license), + ) + .await + .unwrap(); + + /* + * THEN + * - since the plugin is not part of the `allowed_features` set + * the plugin should not have been added. + * - mandatory plugins should have been added. + * */ + assert!( + !service + .supergraph_creator + .plugins() + .contains_key(&format!("apollo.{plugin}")), + "Plugin {plugin} should not have been added" + ); + assert!( + MANDATORY_PLUGINS + .iter() + .all(|plugin| { service.supergraph_creator.plugins().contains_key(*plugin) }) + ); + } + + #[tokio::test] + #[rstest] + #[case::mock_subgraphs_non_empty_allowed_features( + "experimental_mock_subgraphs", + HashSet::from_iter(vec![AllowedFeature::DemandControl]) + )] + #[case::mock_subgraphs_empty_allowed_features( + "experimental_mock_subgraphs", + HashSet::from_iter(vec![]) + )] + async fn test_optional_plugin_that_does_not_map_to_an_allowed_feature_is_added( + #[case] plugin: &str, + #[case] allowed_features: HashSet, + ) { + /* + * GIVEN + * - a valid license + * - a valid config including valid config for the optional plugin that does + * not map to an allowed feature + * - a valid schema + * */ + let license = LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features, + }), + }; + + let plugin_config = + serde_yaml::from_str::(get_plugin_config(plugin)).unwrap(); + let router_config = Configuration::builder() + .apollo_plugin(plugin, plugin_config) + .build() + .unwrap(); + + let schema = include_str!("testdata/supergraph.graphql"); + let schema = Schema::parse(schema, &router_config).unwrap(); + + /* + * WHEN + * - the router factory runs (including the plugin inits gated by the license) + * */ + let is_telemetry_disabled = false; + let service = YamlRouterFactory + .create( + is_telemetry_disabled, + Arc::new(router_config), + Arc::new(schema), + None, + None, + Arc::new(license), + ) + .await + .unwrap(); + + /* + * THEN + * - the plugin should be added + * - mandatory plugins should have been added. + * - coprocessors and subscritions (both gated features) should not have been added. + * */ + assert!( + service + .supergraph_creator + .plugins() + .contains_key(&format!("apollo.{plugin}")), + "Plugin {plugin} should have been added" + ); + assert!( + MANDATORY_PLUGINS + .iter() + .all(|plugin| { service.supergraph_creator.plugins().contains_key(*plugin) }) + ); + // These gated features should not have been added + assert!( + !service + .supergraph_creator + .plugins() + .contains_key("apollo.subscription"), + "Plugin {plugin} should not have been added" + ); + assert!( + !service + .supergraph_creator + .plugins() + .contains_key("apollo.coprocessor"), + "Plugin {plugin} should not have been added" + ); + } + + #[tokio::test] + #[rstest] + // NB: this is temporary behavior and will change once the `allowed_features` claim is in all licenses + #[case::forbid_mutations("forbid_mutations")] + #[case::subscriptions("subscription")] + #[case::override_subgraph_url("override_subgraph_url")] + #[case::authorization("authorization")] + #[case::authentication("authentication")] + #[case::file_upload("preview_file_uploads")] + #[case::entity_cache("preview_entity_cache")] + #[case::response_cache("experimental_response_cache")] + #[case::demand_control("demand_control")] + #[case::connectors("connectors")] + #[case::coprocessor("coprocessor")] + #[case::mock_subgraphs("experimental_mock_subgraphs")] + async fn test_optional_plugin_with_unrestricted_allowed_features(#[case] plugin: &str) { + /* + * GIVEN + * - a license with unrestricted limits (includes allowing all features) + * - a valid config including valid config for the given `plugin` + * - a valid schema + * */ + let license = LicenseState::Licensed { + limits: Default::default(), + }; + + let plugin_config = + serde_yaml::from_str::(get_plugin_config(plugin)).unwrap(); + let router_config = Configuration::builder() + .apollo_plugin(plugin, plugin_config) + .build() + .unwrap(); + + let schema = include_str!("testdata/supergraph.graphql"); + let schema = Schema::parse(schema, &router_config).unwrap(); + + /* + * WHEN + * - the router factory runs (including the plugin inits gated by the license) + * */ + let is_telemetry_disabled = false; + let service = YamlRouterFactory + .create( + is_telemetry_disabled, + Arc::new(router_config), + Arc::new(schema), + None, + None, + Arc::new(license), + ) + .await + .unwrap(); + + /* + * THEN + * - since `allowed_features` is unrestricted plugin should have been added. + * */ + assert!( + service + .supergraph_creator + .plugins() + .contains_key(&format!("apollo.{plugin}")), + "Plugin {plugin} should have been added" + ); + assert!( + MANDATORY_PLUGINS + .iter() + .all(|plugin| { service.supergraph_creator.plugins().contains_key(*plugin) }) + ); + } + + #[tokio::test] + #[rstest] + // NB: this is temporary behavior and will change once the `allowed_features` claim is in all licenses + #[case::forbid_mutations("forbid_mutations")] + #[case::subscriptions("subscription")] + #[case::override_subgraph_url("override_subgraph_url")] + #[case::authorization("authorization")] + #[case::authentication("authentication")] + #[case::file_upload("preview_file_uploads")] + #[case::entity_cache("preview_entity_cache")] + #[case::response_cache("experimental_response_cache")] + #[case::demand_control("demand_control")] + #[case::connectors("connectors")] + #[case::coprocessor("coprocessor")] + #[case::mock_subgraphs("experimental_mock_subgraphs")] + async fn test_optional_plugin_with_default_license_limits(#[case] plugin: &str) { + /* + * GIVEN + * - a license with license limits None + * - a valid config including valid config for the given `plugin` + * - a valid schema + * */ + let license = LicenseState::Licensed { + limits: Default::default(), + }; + + // Create config for the given `plugin` + let plugin_config = + serde_yaml::from_str::(get_plugin_config(plugin)).unwrap(); + + // Create config for oss plugins + // Create config for oss plugins + let forbid_mutations_config = + serde_yaml::from_str::(get_plugin_config("forbid_mutations")) + .unwrap(); + let override_subgraph_url_config = + serde_yaml::from_str::(get_plugin_config("override_subgraph_url")) + .unwrap(); + let connectors_config = + serde_yaml::from_str::(get_plugin_config("connectors")).unwrap(); + let response_cache_config = serde_yaml::from_str::(get_plugin_config( + "experimental_response_cache", + )) + .unwrap(); + + let router_config = Configuration::builder() + .apollo_plugin("forbid_mutations", forbid_mutations_config) + .apollo_plugin("override_subgraph_url", override_subgraph_url_config) + .apollo_plugin("connectors", connectors_config) + .apollo_plugin("experimental_response_cache", response_cache_config) + .apollo_plugin(plugin, plugin_config) + .build() + .unwrap(); + + let schema = include_str!("testdata/supergraph.graphql"); + let schema = Schema::parse(schema, &router_config).unwrap(); + + /* + * WHEN + * - the router factory runs (including the plugin inits gated by the license) + * */ + let is_telemetry_disabled = false; + let service = YamlRouterFactory + .create( + is_telemetry_disabled, + Arc::new(router_config), + Arc::new(schema), + None, + None, + Arc::new(license), + ) + .await + .unwrap(); + + /* + * THEN + * // NB: this behavior may change once all licenses have an `allowed_features` claim + * - when license limits are None we default to unrestricted allowed features + * - the given `plugin` should have been added + * - all mandatory plugins should have been added + * - all oss plugins in the config should have been added + * */ + assert!( + service + .supergraph_creator + .plugins() + .contains_key(&format!("apollo.{plugin}")), + "Plugin {plugin} should have been added" + ); + assert!( + MANDATORY_PLUGINS + .iter() + .all(|plugin| { service.supergraph_creator.plugins().contains_key(*plugin) }) + ); + assert!( + OSS_PLUGINS + .iter() + .all(|plugin| { service.supergraph_creator.plugins().contains_key(*plugin) }) ); } } diff --git a/apollo-router/src/services/connect.rs b/apollo-router/src/services/connect.rs new file mode 100644 index 0000000000..898faa4968 --- /dev/null +++ b/apollo-router/src/services/connect.rs @@ -0,0 +1,71 @@ +//! Connect service request and response types. + +use std::fmt::Debug; +use std::sync::Arc; + +use apollo_compiler::ExecutableDocument; +use apollo_compiler::executable::FieldSet; +use apollo_compiler::validation::Valid; +use static_assertions::assert_impl_all; +use tower::BoxError; + +use crate::Context; +use crate::graphql; +use crate::graphql::Request as GraphQLRequest; +use crate::query_planner::fetch::Variables; + +pub(crate) type BoxService = tower::util::BoxService; + +#[non_exhaustive] +pub(crate) struct Request { + pub(crate) service_name: Arc, + pub(crate) context: Context, + pub(crate) operation: Arc>, + pub(crate) supergraph_request: Arc>, + pub(crate) variables: Variables, + pub(crate) keys: Option>, +} + +impl Debug for Request { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Request") + .field("service_name", &self.service_name) + .field("context", &self.context) + .field("operation", &self.operation) + .field("supergraph_request", &self.supergraph_request) + .field("variables", &self.variables.variables) + .finish() + } +} + +assert_impl_all!(Response: Send); +#[derive(Debug)] +#[non_exhaustive] +pub(crate) struct Response { + pub(crate) response: http::Response, +} + +#[buildstructor::buildstructor] +impl Request { + /// This is the constructor (or builder) to use when constructing a real Request. + /// + /// Required parameters are required in non-testing code to create a Request. + #[builder(visibility = "pub")] + fn new( + service_name: Arc, + context: Context, + operation: Arc>, + supergraph_request: Arc>, + variables: Variables, + keys: Option>, + ) -> Self { + Self { + service_name, + context, + operation, + supergraph_request, + variables, + keys, + } + } +} diff --git a/apollo-router/src/services/connector.rs b/apollo-router/src/services/connector.rs new file mode 100644 index 0000000000..f01aac875c --- /dev/null +++ b/apollo-router/src/services/connector.rs @@ -0,0 +1 @@ +pub(crate) mod request_service; diff --git a/apollo-router/src/services/connector/request_service.rs b/apollo-router/src/services/connector/request_service.rs new file mode 100644 index 0000000000..486025afbd --- /dev/null +++ b/apollo-router/src/services/connector/request_service.rs @@ -0,0 +1,362 @@ +//! Service which makes individual requests to Apollo Connectors over some transport + +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; +use std::task::Poll; + +use apollo_federation::connectors::Connector; +use apollo_federation::connectors::runtime::debug::ConnectorContext; +use apollo_federation::connectors::runtime::errors::Error; +use apollo_federation::connectors::runtime::errors::RuntimeError; +#[cfg(test)] +use apollo_federation::connectors::runtime::http_json_transport::HttpResponse; +use apollo_federation::connectors::runtime::http_json_transport::TransportRequest; +use apollo_federation::connectors::runtime::http_json_transport::TransportResponse; +use apollo_federation::connectors::runtime::key::ResponseKey; +use apollo_federation::connectors::runtime::mapping::Problem; +use apollo_federation::connectors::runtime::responses::MappedResponse; +use futures::future::BoxFuture; +use indexmap::IndexMap; +use opentelemetry::KeyValue; +use opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD; +use parking_lot::Mutex; +use static_assertions::assert_impl_all; +use tower::BoxError; +use tower::ServiceExt; +use tower::buffer::Buffer; + +use crate::Context; +use crate::error::FetchError; +use crate::graphql; +use crate::layers::DEFAULT_BUFFER_SIZE; +use crate::plugins::connectors::handle_responses::process_response; +use crate::plugins::connectors::request_limit::RequestLimits; +use crate::plugins::connectors::tracing::CONNECTOR_TYPE_HTTP; +use crate::plugins::telemetry::config_new::attributes::HTTP_REQUEST_BODY; +use crate::plugins::telemetry::config_new::attributes::HTTP_REQUEST_HEADERS; +use crate::plugins::telemetry::config_new::attributes::HTTP_REQUEST_URI; +use crate::plugins::telemetry::config_new::attributes::HTTP_REQUEST_VERSION; +use crate::plugins::telemetry::config_new::connector::events::ConnectorEventRequest; +use crate::plugins::telemetry::config_new::events::EventLevel; +use crate::plugins::telemetry::config_new::events::log_event; +use crate::services::Plugins; +use crate::services::http::HttpClientServiceFactory; +use crate::services::router; + +pub(crate) type BoxService = tower::util::BoxService; +pub(crate) type ServiceResult = Result; + +assert_impl_all!(Request: Send); +assert_impl_all!(Response: Send); + +/// Request type for a single connector request +#[derive(Debug)] +pub struct Request { + /// The request context + pub(crate) context: Context, + + /// The connector associated with this request + // If this service moves into the public API, consider whether this exposes too much + // internal information about the connector. A new type may be needed which exposes only + // what is necessary for customizations. + pub(crate) connector: Arc, + + /// The request to the underlying transport + pub(crate) transport_request: TransportRequest, + + /// Information about how to map the response to GraphQL + pub(crate) key: ResponseKey, + + /// Mapping problems encountered when creating the transport request + pub(crate) mapping_problems: Vec, + + /// Original request to the Router. + pub(crate) supergraph_request: Arc>, +} + +/// Response type for a connector +#[derive(Debug)] +pub struct Response { + /// The result of the transport request + pub(crate) transport_result: Result, + + /// The mapped response, including any mapping problems encountered when processing the response + pub(crate) mapped_response: MappedResponse, +} + +impl Response { + pub(crate) fn error_new( + error: Error, + message: impl Into, + response_key: ResponseKey, + ) -> Self { + let graphql_error = RuntimeError::new(message, &response_key).with_code(error.code()); + + let mapped_response = MappedResponse::Error { + error: graphql_error, + key: response_key, + problems: Vec::new(), + }; + + Self { + transport_result: Err(error), + mapped_response, + } + } + + #[cfg(test)] + pub(crate) fn test_new( + response_key: ResponseKey, + problems: Vec, + data: serde_json_bytes::Value, + headers: Option>, + ) -> Self { + let mapped_response = MappedResponse::Data { + data: data.clone(), + problems, + key: response_key, + }; + + let mut response_builder = http::Response::builder(); + if let Some(headers) = headers { + for (header_name, header_value) in headers.iter() { + response_builder = response_builder.header(header_name, header_value); + } + } + let (parts, _value) = response_builder.body(data).unwrap().into_parts(); + let http_response = HttpResponse { inner: parts }; + + Self { + transport_result: Ok(http_response.into()), + mapped_response, + } + } +} + +#[derive(Clone)] +pub(crate) struct ConnectorRequestServiceFactory { + pub(crate) services: Arc>>>, +} + +impl ConnectorRequestServiceFactory { + pub(crate) fn new( + http_client_service_factory: Arc>, + plugins: Arc, + connector_sources: Arc>, + ) -> Self { + let mut map = HashMap::with_capacity(connector_sources.len()); + for source in connector_sources.iter() { + let service = Buffer::new( + plugins + .iter() + .rev() + .fold( + ConnectorRequestService { + http_client_service_factory: http_client_service_factory.clone(), + } + .boxed(), + |acc, (_, e)| e.connector_request_service(acc, source.clone()), + ) + .boxed(), + DEFAULT_BUFFER_SIZE, + ); + map.insert(source.clone(), service); + } + + Self { + services: Arc::new(map), //connector_sources, + } + } + + pub(crate) fn create(&self, source_name: String) -> BoxService { + // Note: We have to box our cloned service to erase the type of the Buffer. + self.services + .get(&source_name) + .map(|svc| svc.clone().boxed()) + .expect("We should always get a service, even if it is a blank/default one") + } +} + +/// A service for executing individual requests to Apollo Connectors +#[derive(Clone)] +pub(crate) struct ConnectorRequestService { + pub(crate) http_client_service_factory: Arc>, +} + +impl tower::Service for ConnectorRequestService { + type Response = Response; + type Error = BoxError; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, _cx: &mut std::task::Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, request: Request) -> Self::Future { + let original_subgraph_name = request.connector.id.subgraph_name.to_string(); + let http_client_service_factory = self.http_client_service_factory.clone(); + + // Load the information needed from the context + let (debug, connector_request_event, request_limit) = + request.context.extensions().with_lock(|lock| { + ( + lock.get::>>().cloned(), + lock.get::().cloned(), + lock.get::>() + .map(|limits| { + limits.get( + request.connector.as_ref().into(), + request.connector.max_requests, + ) + }) + .unwrap_or(None), + ) + }); + + let log_request_level = connector_request_event.and_then(|s| { + if s.condition.lock().evaluate_request(&request) == Some(true) { + Some(s.level) + } else { + None + } + }); + + Box::pin(async move { + let mut debug_request = (None, Default::default()); + let result = if request_limit.is_some_and(|request_limit| !request_limit.allow()) { + Err(Error::RequestLimitExceeded) + } else { + let result = match request.transport_request { + TransportRequest::Http(http_request) => { + debug_request = http_request.debug; + + log_request( + &http_request.inner, + log_request_level, + request.connector.label.as_ref(), + ); + + let source_name = request.connector.source_config_key(); + + if let Some(http_client_service_factory) = + http_client_service_factory.get(&source_name).cloned() + { + let (parts, body) = http_request.inner.into_parts(); + let http_request = + http::Request::from_parts(parts, router::body::from_bytes(body)); + + http_client_service_factory + .create(&original_subgraph_name) + .oneshot(crate::services::http::HttpRequest { + http_request, + context: request.context.clone(), + }) + .await + .map(|result| result.http_response) + .map_err(|e| + // Note: this previously used `#[from] BoxError` but when we moved `Error` into the + // `apollo-federation` crate, we could longer reference `BoxError` from there. + Error::TransportFailure((replace_subgraph_name(e, &request.connector)).to_string()) + ) + } else { + Err(Error::TransportFailure("no http client found".into())) + } + } + }; + + u64_counter!( + "apollo.router.operations.connectors", + "Total number of requests to connectors", + 1, + "connector.type" = CONNECTOR_TYPE_HTTP, + "subgraph.name" = original_subgraph_name + ); + + result + }; + + Ok(process_response( + result, + request.key, + request.connector, + &request.context, + debug_request, + debug.as_ref(), + request.supergraph_request, + ) + .await) + }) + } +} + +/// Log an event for this request, if configured +fn log_request( + request: &http::Request, + log_request_level: Option, + label: &str, +) { + if let Some(level) = log_request_level { + let mut attrs = Vec::with_capacity(5); + + #[cfg(test)] + let headers = { + let mut headers: IndexMap = request + .headers() + .clone() + .into_iter() + .filter_map(|(name, val)| Some((name?.to_string(), val))) + .collect(); + headers.sort_keys(); + headers + }; + #[cfg(not(test))] + let headers = request.headers().clone(); + + attrs.push(KeyValue::new( + HTTP_REQUEST_HEADERS, + opentelemetry::Value::String(format!("{headers:?}").into()), + )); + attrs.push(KeyValue::new( + HTTP_REQUEST_METHOD, + opentelemetry::Value::String(request.method().as_str().to_string().into()), + )); + attrs.push(KeyValue::new( + HTTP_REQUEST_URI, + opentelemetry::Value::String(format!("{}", request.uri()).into()), + )); + attrs.push(KeyValue::new( + HTTP_REQUEST_VERSION, + opentelemetry::Value::String(format!("{:?}", request.version()).into()), + )); + attrs.push(KeyValue::new( + HTTP_REQUEST_BODY, + opentelemetry::Value::String(request.body().clone().into()), + )); + log_event( + level, + "connector.request", + attrs, + &format!("Request to connector {label:?}"), + ); + } +} + +/// Replace the internal subgraph name in an error with the connector label +fn replace_subgraph_name(err: BoxError, connector: &Connector) -> BoxError { + match err.downcast::() { + Ok(inner) => match *inner { + FetchError::SubrequestHttpError { + status_code, + service: _, + reason, + } => Box::new(FetchError::SubrequestHttpError { + status_code, + service: connector.id.subgraph_source(), + reason, + }), + _ => inner, + }, + Err(e) => e, + } +} diff --git a/apollo-router/src/services/connector_service.rs b/apollo-router/src/services/connector_service.rs new file mode 100644 index 0000000000..ec6d3b60da --- /dev/null +++ b/apollo-router/src/services/connector_service.rs @@ -0,0 +1,281 @@ +//! Tower service for connectors. + +use std::fmt::Display; +use std::str::FromStr; +use std::sync::Arc; +use std::task::Poll; + +use apollo_federation::connectors::Connector; +use apollo_federation::connectors::SourceName; +use apollo_federation::connectors::runtime::debug::ConnectorContext; +use futures::future::BoxFuture; +use indexmap::IndexMap; +use opentelemetry::Key; +use opentelemetry::metrics::ObservableGauge; +use parking_lot::Mutex; +use serde::Deserialize; +use serde::Serialize; +use tower::BoxError; +use tower::ServiceExt; +use tracing_futures::Instrument; + +use super::connect::BoxService; +use super::new_service::ServiceFactory; +use crate::plugins::connectors::handle_responses::aggregate_responses; +use crate::plugins::connectors::make_requests::make_requests; +use crate::plugins::connectors::tracing::CONNECTOR_TYPE_HTTP; +use crate::plugins::connectors::tracing::connect_spec_version_instrument; +use crate::plugins::subscription::SubscriptionConfig; +use crate::plugins::telemetry::consts::CONNECT_SPAN_NAME; +use crate::query_planner::fetch::SubgraphSchemas; +use crate::services::ConnectRequest; +use crate::services::ConnectResponse; +use crate::services::connector::request_service::ConnectorRequestServiceFactory; +use crate::spec::Schema; + +pub(crate) const APOLLO_CONNECTOR_TYPE: Key = Key::from_static_str("apollo.connector.type"); +pub(crate) const APOLLO_CONNECTOR_DETAIL: Key = Key::from_static_str("apollo.connector.detail"); +pub(crate) const APOLLO_CONNECTOR_SELECTION: Key = + Key::from_static_str("apollo.connector.selection"); +pub(crate) const APOLLO_CONNECTOR_FIELD_NAME: Key = + Key::from_static_str("apollo.connector.field.name"); +pub(crate) const APOLLO_CONNECTOR_FIELD_ALIAS: Key = + Key::from_static_str("apollo.connector.field.alias"); +pub(crate) const APOLLO_CONNECTOR_FIELD_RETURN_TYPE: Key = + Key::from_static_str("apollo.connector.field.return_type"); +pub(crate) const APOLLO_CONNECTOR_SOURCE_NAME: Key = + Key::from_static_str("apollo.connector.source.name"); +pub(crate) const APOLLO_CONNECTOR_SOURCE_DETAIL: Key = + Key::from_static_str("apollo.connector.source.detail"); + +/// A service for executing connector requests. +#[derive(Clone)] +pub(crate) struct ConnectorService { + pub(crate) _schema: Arc, + pub(crate) _subgraph_schemas: Arc, + pub(crate) _subscription_config: Option, + pub(crate) connectors_by_service_name: Arc, Connector>>, + pub(crate) connector_request_service_factory: Arc, +} + +/// A reference to a unique Connector source. +#[derive(Hash, Eq, PartialEq, Clone, Serialize, Deserialize)] +pub(crate) struct ConnectorSourceRef { + pub(crate) subgraph_name: String, + pub(crate) source_name: SourceName, +} + +impl ConnectorSourceRef { + pub(crate) fn new(subgraph_name: String, source_name: SourceName) -> Self { + Self { + subgraph_name, + source_name, + } + } +} + +impl FromStr for ConnectorSourceRef { + type Err = String; + + fn from_str(s: &str) -> Result { + let mut parts = s.split('.'); + let subgraph_name = parts + .next() + .ok_or(format!("Invalid connector source reference '{s}'"))? + .to_string(); + let source_name = parts + .next() + .ok_or(format!("Invalid connector source reference '{s}'"))?; + Ok(Self::new(subgraph_name, SourceName::cast(source_name))) + } +} + +impl TryFrom<&Connector> for ConnectorSourceRef { + type Error = (); + + fn try_from(value: &Connector) -> Result { + Ok(Self { + subgraph_name: value.id.subgraph_name.to_string(), + source_name: value.id.source_name.clone().ok_or(())?, + }) + } +} + +impl TryFrom<&mut Connector> for ConnectorSourceRef { + type Error = (); + + fn try_from(value: &mut Connector) -> Result { + Ok(Self { + subgraph_name: value.id.subgraph_name.to_string(), + source_name: value.id.source_name.clone().ok_or(())?, + }) + } +} + +impl Display for ConnectorSourceRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}", self.subgraph_name, self.source_name) + } +} + +impl tower::Service for ConnectorService { + type Response = ConnectResponse; + type Error = BoxError; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, _cx: &mut std::task::Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, request: ConnectRequest) -> Self::Future { + let connector = self + .connectors_by_service_name + .get(&request.service_name) + .cloned(); + + let connector_request_service_factory = self.connector_request_service_factory.clone(); + + Box::pin(async move { + let Some(connector) = connector else { + return Err("no connector found".into()); + }; + + let fetch_time_offset = request.context.created_at.elapsed().as_nanos() as i64; + let span = tracing::info_span!( + CONNECT_SPAN_NAME, + "otel.kind" = "INTERNAL", + "apollo.connector.type" = CONNECTOR_TYPE_HTTP, + "apollo.connector.detail" = tracing::field::Empty, + "apollo.connector.coordinate" = %connector.id.coordinate(), + "apollo.connector.selection" = %connector.selection, + "apollo.connector.source.name" = tracing::field::Empty, + "apollo.connector.source.detail" = tracing::field::Empty, + "apollo_private.sent_time_offset" = fetch_time_offset, + "otel.status_code" = tracing::field::Empty, + ); + // TODO: I think we should get rid of these attributes by default and only add it from custom telemetry. We just need to double check it's not required for Studio. + + // These additional attributes will be added to custom telemetry feature + // TODO: apollo.connector.field.alias + // TODO: apollo.connector.field.return_type + // TODO: apollo.connector.field.selection_set + let transport = &connector.transport; + if let Ok(detail) = serde_json::to_string( + &serde_json::json!({ transport.method.as_str(): transport.connect_template.to_string() }), + ) { + span.record("apollo.connector.detail", detail); + } + if let Some(source_name) = connector.id.source_name.as_ref() { + span.record("apollo.connector.source.name", source_name.as_str()); + if let Ok(detail) = serde_json::to_string( + &serde_json::json!({ "baseURL": transport.source_template.as_ref().map(|uri| uri.to_string()) }), + ) { + span.record("apollo.connector.source.detail", detail); + } + } + + execute(&connector_request_service_factory, request, connector) + .instrument(span) + .await + }) + } +} + +async fn execute( + connector_request_service_factory: &ConnectorRequestServiceFactory, + request: ConnectRequest, + connector: Connector, +) -> Result { + let context = request.context.clone(); + let connector = Arc::new(connector); + let source_name = connector.source_config_key(); + let debug = &context + .extensions() + .with_lock(|lock| lock.get::>>().cloned()); + + let tasks = make_requests(request, &context, connector, debug) + .map_err(BoxError::from)? + .into_iter() + .map(move |request| { + let source_name = source_name.clone(); + async move { + connector_request_service_factory + .create(source_name) + .oneshot(request) + .await + } + }); + + aggregate_responses( + futures::future::try_join_all(tasks) + .await + .map(|responses| { + responses + .into_iter() + .map(|response| response.mapped_response) + .collect() + })?, + ) + .map_err(BoxError::from) +} + +#[derive(Clone)] +pub(crate) struct ConnectorServiceFactory { + pub(crate) schema: Arc, + pub(crate) subgraph_schemas: Arc, + pub(crate) subscription_config: Option, + pub(crate) connectors_by_service_name: Arc, Connector>>, + _connect_spec_version_instrument: Option>, + pub(crate) connector_request_service_factory: Arc, +} + +impl ConnectorServiceFactory { + pub(crate) fn new( + schema: Arc, + subgraph_schemas: Arc, + subscription_config: Option, + connectors_by_service_name: Arc, Connector>>, + connector_request_service_factory: Arc, + ) -> Self { + Self { + subgraph_schemas, + schema: schema.clone(), + subscription_config, + connectors_by_service_name, + _connect_spec_version_instrument: connect_spec_version_instrument( + schema.connectors.as_ref(), + ), + connector_request_service_factory, + } + } + + #[cfg(test)] + pub(crate) fn empty(schema: Arc) -> Self { + Self::new( + schema, + Default::default(), + Default::default(), + Default::default(), + Arc::new(ConnectorRequestServiceFactory::new( + Default::default(), + Default::default(), + Default::default(), + )), + ) + } +} + +impl ServiceFactory for ConnectorServiceFactory { + type Service = BoxService; + + fn create(&self) -> Self::Service { + ConnectorService { + _schema: self.schema.clone(), + _subgraph_schemas: self.subgraph_schemas.clone(), + _subscription_config: self.subscription_config.clone(), + connectors_by_service_name: self.connectors_by_service_name.clone(), + connector_request_service_factory: self.connector_request_service_factory.clone(), + } + .boxed() + } +} diff --git a/apollo-router/src/services/execution.rs b/apollo-router/src/services/execution.rs index 570faf90ea..cebf1bb6ef 100644 --- a/apollo-router/src/services/execution.rs +++ b/apollo-router/src/services/execution.rs @@ -7,8 +7,8 @@ use static_assertions::assert_impl_all; use tokio::sync::mpsc; use tower::BoxError; -use crate::graphql; use crate::Context; +use crate::graphql; pub(crate) mod service; diff --git a/apollo-router/src/services/execution/service.rs b/apollo-router/src/services/execution/service.rs index 2869f1ade9..a68b130582 100644 --- a/apollo-router/src/services/execution/service.rs +++ b/apollo-router/src/services/execution/service.rs @@ -1,6 +1,5 @@ //! Implements the Execution phase of the request lifecycle. -use std::collections::HashMap; use std::future::ready; use std::pin::Pin; use std::sync::Arc; @@ -9,30 +8,29 @@ use std::task::Poll; use std::time::SystemTime; use std::time::UNIX_EPOCH; -use apollo_compiler::validation::Valid; -use futures::future::BoxFuture; -use futures::stream::once; use futures::Stream; use futures::StreamExt; +use futures::future::BoxFuture; +use futures::stream::once; use serde_json_bytes::Value; use tokio::sync::broadcast; use tokio::sync::mpsc; -use tokio::sync::mpsc::error::SendError; -use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Sender; +use tokio::sync::mpsc::error::SendError; +use tokio::sync::mpsc::error::TryRecvError; use tokio_stream::wrappers::ReceiverStream; use tower::BoxError; use tower::ServiceBuilder; use tower::ServiceExt; use tower_service::Service; -use tracing::event; use tracing::Instrument; use tracing::Span; +use tracing::event; use tracing_core::Level; -use crate::apollo_studio_interop::extract_enums_from_response; use crate::apollo_studio_interop::ReferencedEnums; +use crate::apollo_studio_interop::extract_enums_from_response; use crate::graphql::Error; use crate::graphql::IncrementalResponse; use crate::graphql::Response; @@ -41,29 +39,31 @@ use crate::json_ext::Path; use crate::json_ext::PathElement; use crate::json_ext::ValueExt; use crate::plugins::authentication::APOLLO_AUTHENTICATION_JWT_CLAIMS; +use crate::plugins::subscription::APOLLO_SUBSCRIPTION_PLUGIN; use crate::plugins::subscription::Subscription; use crate::plugins::subscription::SubscriptionConfig; -use crate::plugins::subscription::APOLLO_SUBSCRIPTION_PLUGIN; +use crate::plugins::telemetry::Telemetry; use crate::plugins::telemetry::apollo::Config as ApolloTelemetryConfig; use crate::plugins::telemetry::config::ApolloMetricsReferenceMode; -use crate::plugins::telemetry::Telemetry; +use crate::query_planner::fetch::SubgraphSchemas; use crate::query_planner::subscription::SubscriptionHandle; -use crate::services::execution; -use crate::services::new_service::ServiceFactory; use crate::services::ExecutionRequest; use crate::services::ExecutionResponse; use crate::services::Plugins; -use crate::services::SubgraphServiceFactory; -use crate::spec::query::subselections::BooleanValues; +use crate::services::execution; +use crate::services::fetch_service::FetchServiceFactory; +use crate::services::new_service::ServiceFactory; use crate::spec::Query; use crate::spec::Schema; +use crate::spec::query::EXTENSIONS_VALUE_COMPLETION_KEY; +use crate::spec::query::subselections::BooleanValues; /// [`Service`] for query execution. #[derive(Clone)] pub(crate) struct ExecutionService { pub(crate) schema: Arc, - pub(crate) subgraph_schemas: Arc>>>, - pub(crate) subgraph_service_factory: Arc, + pub(crate) subgraph_schemas: Arc, + pub(crate) fetch_service_factory: Arc, /// Subscription config if enabled subscription_config: Option, apollo_telemetry_config: Option, @@ -83,10 +83,10 @@ impl Stream for StreamWrapper { impl Drop for StreamWrapper { fn drop(&mut self) { - if let Some(closed_signal) = self.1.take() { - if let Err(err) = closed_signal.send(()) { - tracing::trace!("cannot close the subscription: {err:?}"); - } + if let Some(closed_signal) = self.1.take() + && let Err(err) = closed_signal.send(()) + { + tracing::trace!("cannot close the subscription: {err:?}"); } self.0.close(); @@ -149,7 +149,7 @@ impl ExecutionService { .query_plan .execute( &context, - &self.subgraph_service_factory, + &self.fetch_service_factory, &Arc::new(req.supergraph_request), &self.schema, &self.subgraph_schemas, @@ -344,6 +344,23 @@ impl ExecutionService { , ); + for error in response.errors.iter_mut() { + if let Some(path) = &mut error.path { + // Check if path can be matched to the supergraph query and truncate if not + let matching_len = query.matching_error_path_length(path); + if path.len() != matching_len { + path.0.drain(matching_len..); + + if path.is_empty() { + error.path = None; + } + + // if path was invalid that means we can't trust locations either + error.locations.clear(); + } + } + } + nullified_paths.extend(paths); let mut referenced_enums = context @@ -361,7 +378,7 @@ impl ExecutionService { context .extensions() - .with_lock(|mut lock| lock.insert::(referenced_enums)); + .with_lock(|lock| lock.insert::(referenced_enums)); }); match (response.path.as_ref(), response.data.as_ref()) { @@ -462,7 +479,7 @@ impl ExecutionService { .extensions .iter() .map(|(key, value)| { - if key.as_str() == "valueCompletion" { + if key.as_str() == EXTENSIONS_VALUE_COMPLETION_KEY { let value = match value.as_array() { None => Value::Null, Some(v) => Value::Array( @@ -615,9 +632,9 @@ async fn consume_responses( #[derive(Clone)] pub(crate) struct ExecutionServiceFactory { pub(crate) schema: Arc, - pub(crate) subgraph_schemas: Arc>>>, + pub(crate) subgraph_schemas: Arc, pub(crate) plugins: Arc, - pub(crate) subgraph_service_factory: Arc, + pub(crate) fetch_service_factory: Arc, } impl ServiceFactory for ExecutionServiceFactory { @@ -642,7 +659,7 @@ impl ServiceFactory for ExecutionServiceFactory { self.plugins.iter().rev().fold( crate::services::execution::service::ExecutionService { schema: self.schema.clone(), - subgraph_service_factory: self.subgraph_service_factory.clone(), + fetch_service_factory: self.fetch_service_factory.clone(), subscription_config: subscription_plugin_conf, subgraph_schemas: self.subgraph_schemas.clone(), apollo_telemetry_config: apollo_telemetry_conf, diff --git a/apollo-router/src/services/external.rs b/apollo-router/src/services/external.rs index c356ddd39c..2ff4fcc874 100644 --- a/apollo-router/src/services/external.rs +++ b/apollo-router/src/services/external.rs @@ -1,32 +1,35 @@ -#![allow(missing_docs)] // FIXME +//! Structures for externalised data, communicating the state of the router pipeline at the +//! different stages. use std::collections::HashMap; use std::fmt::Debug; use std::sync::Arc; use std::time::Duration; -use http::header::ACCEPT; -use http::header::CONTENT_TYPE; use http::HeaderMap; use http::HeaderValue; use http::Method; use http::StatusCode; +use http::header::ACCEPT; +use http::header::CONTENT_TYPE; use opentelemetry::global::get_text_map_propagator; use schemars::JsonSchema; -use serde::de::DeserializeOwned; use serde::Deserialize; use serde::Serialize; +use serde::de::DeserializeOwned; use strum_macros::Display; use tower::BoxError; use tower::Service; +use tracing::Instrument; use super::subgraph::SubgraphRequestId; +use crate::Context; +use crate::plugins::telemetry::consts::HTTP_REQUEST_SPAN_NAME; use crate::plugins::telemetry::otel::OpenTelemetrySpanExt; use crate::plugins::telemetry::reload::prepare_context; use crate::query_planner::QueryPlan; -use crate::services::router::body::get_body_bytes; +use crate::services::router; use crate::services::router::body::RouterBody; -use crate::Context; pub(crate) const DEFAULT_EXTERNALIZATION_TIMEOUT: Duration = Duration::from_secs(1); @@ -112,10 +115,9 @@ impl Externalizable where T: Debug + DeserializeOwned + Serialize + Send + Sync, { - #[builder(visibility = "pub(crate)")] /// This is the constructor (or builder) to use when constructing a Router /// `Externalizable`. - /// + #[builder(visibility = "pub(crate)")] fn router_new( stage: PipelineStep, control: Option, @@ -152,10 +154,9 @@ where } } - #[builder(visibility = "pub(crate)")] /// This is the constructor (or builder) to use when constructing a Supergraph /// `Externalizable`. - /// + #[builder(visibility = "pub(crate)")] fn supergraph_new( stage: PipelineStep, control: Option, @@ -192,10 +193,9 @@ where } } - #[builder(visibility = "pub(crate)")] /// This is the constructor (or builder) to use when constructing an Execution /// `Externalizable`. - /// + #[builder(visibility = "pub(crate)")] fn execution_new( stage: PipelineStep, control: Option, @@ -233,10 +233,9 @@ where } } - #[builder(visibility = "pub(crate)")] /// This is the constructor (or builder) to use when constructing a Subgraph /// `Externalizable`. - /// + #[builder(visibility = "pub(crate)")] fn subgraph_new( stage: PipelineStep, control: Option, @@ -292,17 +291,41 @@ where .method(Method::POST) .header(ACCEPT, "application/json") .header(CONTENT_TYPE, "application/json") - .body(serde_json::to_vec(&self)?.into())?; + .body(router::body::from_bytes(serde_json::to_vec(&self)?))?; + + let schema_uri = request.uri(); + let host = schema_uri.host().unwrap_or_default(); + let port = schema_uri.port_u16().unwrap_or_else(|| { + let scheme = schema_uri.scheme_str(); + if scheme == Some("https") { + 443 + } else if scheme == Some("http") { + 80 + } else { + 0 + } + }); + let otel_name = format!("POST {schema_uri}"); + + let http_req_span = tracing::info_span!(HTTP_REQUEST_SPAN_NAME, + "otel.kind" = "CLIENT", + "http.request.method" = "POST", + "server.address" = %host, + "server.port" = %port, + "url.full" = %schema_uri, + "otel.name" = %otel_name, + "otel.original_name" = "http_request", + ); get_text_map_propagator(|propagator| { propagator.inject_context( - &prepare_context(tracing::span::Span::current().context()), - &mut opentelemetry_http::HeaderInjector(request.headers_mut()), + &prepare_context(http_req_span.context()), + &mut crate::otel_compat::HeaderInjector(request.headers_mut()), ); }); - let response = client.call(request).await?; - get_body_bytes(response.into_body()) + let response = client.call(request).instrument(http_req_span).await?; + router::body::into_bytes(response.into_body()) .await .map_err(BoxError::from) .and_then(|bytes| serde_json::from_slice(&bytes).map_err(BoxError::from)) @@ -324,7 +347,12 @@ pub(crate) fn externalize_header_map( #[cfg(test)] mod test { + use http::Response; + use tower::service_fn; + use tracing_futures::WithSubscriber; + use super::*; + use crate::assert_snapshot_subscriber; #[test] fn it_will_build_router_externalizable_correctly() { @@ -388,4 +416,33 @@ mod test { .id(String::default()) .build(); } + + #[tokio::test] + async fn it_will_create_an_http_request_span() { + async { + // Create a mock service that returns a simple response + let service = service_fn(|_req: http::Request| async { + tracing::info!("got request"); + Ok::<_, BoxError>( + Response::builder() + .status(200) + .body(router::body::from_bytes(vec![])) + .unwrap(), + ) + }); + + // Create an externalizable request + let externalizable = Externalizable::::router_builder() + .stage(PipelineStep::RouterRequest) + .id("test-id".to_string()) + .build(); + + // Make the call which should create the HTTP request span + let _ = externalizable + .call(service, "http://example.com/test") + .await; + } + .with_subscriber(assert_snapshot_subscriber!()) + .await; + } } diff --git a/apollo-router/src/services/fetch.rs b/apollo-router/src/services/fetch.rs new file mode 100644 index 0000000000..b137d46101 --- /dev/null +++ b/apollo-router/src/services/fetch.rs @@ -0,0 +1,190 @@ +//! Fetch request and response types. + +use std::sync::Arc; + +use serde_json_bytes::Value; +use serde_json_bytes::json; +use tokio::sync::mpsc; +use tower::BoxError; + +use crate::Context; +use crate::error::Error; +use crate::error::FetchError; +use crate::graphql::Request as GraphQLRequest; +use crate::json_ext::Path; +use crate::plugins::subscription::SubscriptionConfig; +use crate::query_planner::fetch::FetchNode; +use crate::query_planner::fetch::Variables; +use crate::query_planner::subscription::SubscriptionHandle; +use crate::query_planner::subscription::SubscriptionNode; + +/// This extension key is private to apollo and may change in the future. Users should not rely on +/// its existence. +const SUBGRAPH_NAME_EXTENSION_KEY: &str = "apollo.private.subgraph.name"; + +pub(crate) type BoxService = tower::util::BoxService; + +// XXX(@goto-bus-stop): instead of two variants of wildly different +// size, should there be two separate services with one request type +// each? +#[allow(clippy::large_enum_variant)] +pub(crate) enum Request { + Fetch(FetchRequest), + Subscription(SubscriptionRequest), +} + +pub(crate) type Response = (Value, Vec); + +#[non_exhaustive] +pub(crate) struct FetchRequest { + pub(crate) context: Context, + pub(crate) fetch_node: FetchNode, + pub(crate) supergraph_request: Arc>, + pub(crate) variables: Variables, + pub(crate) current_dir: Path, +} + +#[buildstructor::buildstructor] +impl FetchRequest { + /// This is the constructor (or builder) to use when constructing a fetch Request. + /// + /// Required parameters are required in non-testing code to create a Request. + #[builder(visibility = "pub")] + fn new( + context: Context, + fetch_node: FetchNode, + supergraph_request: Arc>, + variables: Variables, + current_dir: Path, + ) -> Self { + Self { + context, + fetch_node, + supergraph_request, + variables, + current_dir, + } + } +} + +pub(crate) struct SubscriptionRequest { + pub(crate) context: Context, + pub(crate) subscription_node: SubscriptionNode, + pub(crate) supergraph_request: Arc>, + pub(crate) variables: Variables, + pub(crate) current_dir: Path, + pub(crate) sender: mpsc::Sender, + pub(crate) subscription_handle: Option, + pub(crate) subscription_config: Option, +} + +#[buildstructor::buildstructor] +impl SubscriptionRequest { + /// This is the constructor (or builder) to use when constructing a subscription Request. + /// + /// Required parameters are required in non-testing code to create a Request. + #[builder(visibility = "pub")] + fn new( + context: Context, + subscription_node: SubscriptionNode, + supergraph_request: Arc>, + variables: Variables, + current_dir: Path, + sender: mpsc::Sender, + subscription_handle: Option, + subscription_config: Option, + ) -> Self { + Self { + context, + subscription_node, + supergraph_request, + variables, + current_dir, + sender, + subscription_handle, + subscription_config, + } + } +} + +/// Map a fetch error result to a [GraphQL error](GraphQLError). +pub(crate) trait ErrorMapping { + #[allow(clippy::result_large_err)] + fn map_to_graphql_error(self, service_name: String, current_dir: &Path) -> Result; +} + +impl ErrorMapping for Result { + fn map_to_graphql_error(self, service_name: String, current_dir: &Path) -> Result { + // TODO this is a problem since it restores details about failed service + // when errors have been redacted in the include_subgraph_errors module. + // Unfortunately, not easy to fix here, because at this point we don't + // know if we should be redacting errors for this subgraph... + self.map_err(|e| match e.downcast::() { + Ok(inner) => match *inner { + FetchError::SubrequestHttpError { .. } => *inner, + _ => FetchError::SubrequestHttpError { + status_code: None, + service: service_name, + reason: inner.to_string(), + }, + }, + Err(e) => FetchError::SubrequestHttpError { + status_code: None, + service: service_name, + reason: e.to_string(), + }, + }) + .map_err(|e| e.to_graphql_error(Some(current_dir.to_owned()))) + } +} + +/// Extension trait for adding a subgraph name associated with an error. +pub(crate) trait AddSubgraphNameExt { + /// Add the subgraph name associated with an error + fn with_subgraph_name(self, subgraph_name: &str) -> Self; + + /// Add the subgraph name associated with an error + fn add_subgraph_name(&mut self, subgraph_name: &str); +} + +impl AddSubgraphNameExt for Error { + fn with_subgraph_name(mut self, subgraph_name: &str) -> Self { + self.add_subgraph_name(subgraph_name); + self + } + + fn add_subgraph_name(&mut self, subgraph_name: &str) { + self.extensions + .insert(SUBGRAPH_NAME_EXTENSION_KEY, json!(subgraph_name)); + } +} + +impl AddSubgraphNameExt for Result { + fn with_subgraph_name(self, subgraph_name: &str) -> Self { + self.map_err(|e| e.with_subgraph_name(subgraph_name)) + } + + fn add_subgraph_name(&mut self, subgraph_name: &str) { + if let Err(e) = self { + e.add_subgraph_name(subgraph_name); + } + } +} + +/// Extension trait for getting the subgraph name associated with an error, if any. +pub(crate) trait SubgraphNameExt { + /// Get the subgraph name associated with an error, if any + fn subgraph_name(&mut self) -> Option; +} + +impl SubgraphNameExt for Error { + fn subgraph_name(&mut self) -> Option { + if let Some(subgraph_name) = self.extensions.remove(SUBGRAPH_NAME_EXTENSION_KEY) { + subgraph_name + .as_str() + .map(|subgraph_name| subgraph_name.to_string()) + } else { + None + } + } +} diff --git a/apollo-router/src/services/fetch_service.rs b/apollo-router/src/services/fetch_service.rs new file mode 100644 index 0000000000..4b87a1dd71 --- /dev/null +++ b/apollo-router/src/services/fetch_service.rs @@ -0,0 +1,511 @@ +//! Tower fetcher for fetch node execution. + +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::task::Poll; + +use futures::future::BoxFuture; +use serde_json_bytes::Value; +use tokio::sync::mpsc; +use tower::BoxError; +use tower::ServiceExt; +use tracing::Instrument; +use tracing::instrument::Instrumented; + +use super::ConnectRequest; +use super::FetchRequest; +use super::SubgraphRequest; +use super::SubscriptionTaskParams; +use super::connector_service::ConnectorServiceFactory; +use super::fetch::AddSubgraphNameExt; +use super::fetch::BoxService; +use super::fetch::SubscriptionRequest; +use super::new_service::ServiceFactory; +use crate::error::Error; +use crate::graphql::Request as GraphQLRequest; +use crate::http_ext; +use crate::plugins::subscription::SubscriptionConfig; +use crate::query_planner::FETCH_SPAN_NAME; +use crate::query_planner::OperationKind; +use crate::query_planner::SUBSCRIBE_SPAN_NAME; +use crate::query_planner::build_operation_with_aliasing; +use crate::query_planner::fetch::FetchNode; +use crate::query_planner::fetch::SubgraphSchemas; +use crate::query_planner::subscription::OPENED_SUBSCRIPTIONS; +use crate::query_planner::subscription::SubscriptionNode; +use crate::services::FetchResponse; +use crate::services::SubgraphServiceFactory; +use crate::services::fetch::ErrorMapping; +use crate::services::fetch::Request; +use crate::services::subgraph::BoxGqlStream; +use crate::spec::Schema; + +/// The fetch service delegates to either the subgraph service or connector service depending +/// on whether connectors are present in the subgraph. +#[derive(Clone)] +pub(crate) struct FetchService { + pub(crate) subgraph_service_factory: Arc, + pub(crate) schema: Arc, + pub(crate) subgraph_schemas: Arc, + pub(crate) _subscription_config: Option, // TODO: add subscription support to FetchService + pub(crate) connector_service_factory: Arc, +} + +impl tower::Service for FetchService { + type Response = FetchResponse; + type Error = BoxError; + type Future = Instrumented>>; + + fn poll_ready(&mut self, _cx: &mut std::task::Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, request: Request) -> Self::Future { + match request { + Request::Fetch(request) => self.handle_fetch(request), + Request::Subscription(request) => self.handle_subscription(request), + } + } +} + +impl FetchService { + fn handle_fetch( + &mut self, + request: FetchRequest, + ) -> >::Future { + let FetchRequest { + ref context, + fetch_node: FetchNode { + ref service_name, .. + }, + .. + } = request; + let service_name = service_name.clone(); + let fetch_time_offset = context.created_at.elapsed().as_nanos() as i64; + + if let Some(connector) = self + .connector_service_factory + .connectors_by_service_name + .get(service_name.as_ref()) + { + Self::fetch_with_connector_service( + self.schema.clone(), + self.connector_service_factory.clone(), + connector.id.subgraph_name.clone(), + request, + ) + .instrument(tracing::info_span!( + FETCH_SPAN_NAME, + "otel.kind" = "INTERNAL", + "apollo.subgraph.name" = connector.id.subgraph_name, + "apollo_private.sent_time_offset" = fetch_time_offset + )) + } else { + Self::fetch_with_subgraph_service( + self.schema.clone(), + self.subgraph_service_factory.clone(), + self.subgraph_schemas.clone(), + request, + ) + .instrument(tracing::info_span!( + FETCH_SPAN_NAME, + "otel.kind" = "INTERNAL", + "apollo.subgraph.name" = service_name.as_ref(), + "apollo_private.sent_time_offset" = fetch_time_offset + )) + } + } + + fn fetch_with_connector_service( + schema: Arc, + connector_service_factory: Arc, + subgraph_name: String, + request: FetchRequest, + ) -> BoxFuture<'static, Result> { + let FetchRequest { + fetch_node, + supergraph_request, + variables, + context, + current_dir, + .. + } = request; + + let paths = variables.inverted_paths.clone(); + let operation = fetch_node.operation.as_parsed().cloned(); + + Box::pin(async move { + let connector = schema + .connectors + .as_ref() + .and_then(|c| c.by_service_name.get(&fetch_node.service_name)) + .ok_or("no connector found for service")?; + + let keys = connector.resolvable_key(schema.supergraph_schema())?; + + let (_parts, response) = match connector_service_factory + .create() + .oneshot( + ConnectRequest::builder() + .service_name(fetch_node.service_name.clone()) + .context(context) + .operation(operation?.clone()) + .supergraph_request(supergraph_request) + .variables(variables) + .and_keys(keys) + .build(), + ) + .await + .map_to_graphql_error(subgraph_name.clone(), ¤t_dir.to_owned()) + .with_subgraph_name(subgraph_name.as_str()) + { + Err(e) => { + return Ok((Value::default(), vec![e])); + } + Ok(res) => res.response.into_parts(), + }; + + let (value, errors) = + fetch_node.response_at_path(&schema, ¤t_dir, paths, response); + Ok((value, errors)) + }) + } + + fn fetch_with_subgraph_service( + schema: Arc, + subgraph_service_factory: Arc, + subgraph_schemas: Arc, + request: FetchRequest, + ) -> BoxFuture<'static, Result> { + let FetchRequest { + fetch_node, + supergraph_request, + variables, + current_dir, + context, + } = request; + + let FetchNode { + ref service_name, + ref operation, + ref operation_kind, + ref operation_name, + .. + } = fetch_node; + + let uri = schema + .subgraph_url(service_name.as_ref()) + .unwrap_or_else(|| { + panic!("schema uri for subgraph '{service_name}' should already have been checked") + }) + .clone(); + + let alias_query_string; // this exists outside the if block to allow the as_str() to be longer lived + let aliased_operation = if let Some(ctx_arg) = &variables.contextual_arguments { + if let Some(subgraph_schema) = subgraph_schemas.get(&service_name.to_string()) { + match build_operation_with_aliasing(operation, ctx_arg, &subgraph_schema.schema) { + Ok(op) => { + alias_query_string = op.serialize().no_indent().to_string(); + alias_query_string.as_str() + } + Err(errors) => { + tracing::debug!( + "couldn't generate a valid executable document? {:?}", + errors + ); + operation.as_serialized() + } + } + } else { + tracing::debug!( + "couldn't find a subgraph schema for service {:?}", + &service_name + ); + operation.as_serialized() + } + } else { + operation.as_serialized() + }; + + let aqs = aliased_operation.to_string(); // TODO + let current_dir = current_dir.clone(); + let service = subgraph_service_factory + .create(&service_name.clone()) + .expect("we already checked that the service exists during planning; qed"); + + let mut subgraph_request = SubgraphRequest::builder() + .supergraph_request(supergraph_request.clone()) + .subgraph_request( + http_ext::Request::builder() + .method(http::Method::POST) + .uri(uri) + .body( + GraphQLRequest::builder() + .query(aliased_operation) + .and_operation_name(operation_name.as_ref().map(|n| n.to_string())) + .variables(variables.variables.clone()) + .build(), + ) + .build() + .expect("it won't fail because the url is correct and already checked; qed"), + ) + .subgraph_name(service_name.to_string()) + .operation_kind(*operation_kind) + .and_executable_document(operation.as_parsed().ok().cloned()) + .context(context.clone()) + .build(); + subgraph_request.query_hash = fetch_node.schema_aware_hash.clone(); + subgraph_request.authorization = fetch_node.authorization.clone(); + Box::pin(async move { + Ok(fetch_node + .subgraph_fetch( + service, + subgraph_request, + ¤t_dir, + &schema, + variables.inverted_paths, + &aqs, + variables.variables, + ) + .await) + }) + } + + fn handle_subscription( + &mut self, + request: SubscriptionRequest, + ) -> >::Future { + let SubscriptionRequest { + ref context, + subscription_node: SubscriptionNode { + ref service_name, .. + }, + .. + } = request; + + let service_name = service_name.clone(); + let fetch_time_offset = context.created_at.elapsed().as_nanos() as i64; + + // Subscriptions are not supported for connectors, so they always go to the subgraph service + Self::subscription_with_subgraph_service( + self.schema.clone(), + self.subgraph_service_factory.clone(), + request, + ) + .instrument(tracing::info_span!( + SUBSCRIBE_SPAN_NAME, + "otel.kind" = "INTERNAL", + "apollo.subgraph.name" = service_name.as_ref(), + "apollo_private.sent_time_offset" = fetch_time_offset + )) + } + + fn subscription_with_subgraph_service( + schema: Arc, + subgraph_service_factory: Arc, + request: SubscriptionRequest, + ) -> BoxFuture<'static, Result> { + let SubscriptionRequest { + context, + subscription_node, + current_dir, + sender, + variables, + supergraph_request, + subscription_handle, + subscription_config, + .. + } = request; + let SubscriptionNode { + ref service_name, + ref operation, + ref operation_name, + .. + } = subscription_node; + + let service_name = service_name.clone(); + + if let Some(max_opened_subscriptions) = subscription_config + .as_ref() + .and_then(|s| s.max_opened_subscriptions) + && OPENED_SUBSCRIPTIONS.load(Ordering::Relaxed) >= max_opened_subscriptions + { + return Box::pin(async { + Ok(( + Value::default(), + vec![ + Error::builder() + .message("can't open new subscription, limit reached") + .extension_code("SUBSCRIPTION_MAX_LIMIT") + .build(), + ], + )) + }); + } + let mode = match subscription_config.as_ref() { + Some(config) => config + .mode + .get_subgraph_config(&service_name) + .map(|mode| (config.clone(), mode)), + None => { + return Box::pin(async { + Ok(( + Value::default(), + vec![ + Error::builder() + .message("subscription support is not enabled") + .extension_code("SUBSCRIPTION_DISABLED") + .build(), + ], + )) + }); + } + }; + + let service = subgraph_service_factory + .create(&service_name) + .expect("we already checked that the service exists during planning; qed"); + + let uri = schema + .subgraph_url(service_name.as_ref()) + .unwrap_or_else(|| { + panic!("schema uri for subgraph '{service_name}' should already have been checked") + }) + .clone(); + + let (tx_handle, rx_handle) = mpsc::channel::(1); + + let subscription_handle = subscription_handle + .as_ref() + .expect("checked in PlanNode; qed"); + + let subgraph_request = SubgraphRequest::builder() + .supergraph_request(supergraph_request.clone()) + .subgraph_request( + http_ext::Request::builder() + .method(http::Method::POST) + .uri(uri) + .body( + crate::graphql::Request::builder() + .query(operation.as_serialized()) + .and_operation_name(operation_name.as_ref().map(|n| n.to_string())) + .variables(variables.variables.clone()) + .build(), + ) + .build() + .expect("it won't fail because the url is correct and already checked; qed"), + ) + .operation_kind(OperationKind::Subscription) + .context(context) + .subgraph_name(service_name.to_string()) + .subscription_stream(tx_handle) + .and_connection_closed_signal(Some(subscription_handle.closed_signal.resubscribe())) + .build(); + + let mut subscription_handle = subscription_handle.clone(); + Box::pin(async move { + let response = match mode { + Some((subscription_config, _mode)) => { + let subscription_params = SubscriptionTaskParams { + client_sender: sender, + subscription_handle: subscription_handle.clone(), + subscription_config: subscription_config.clone(), + stream_rx: rx_handle.into(), + }; + + let subscription_conf_tx = match subscription_handle.subscription_conf_tx.take() + { + Some(sc) => sc, + None => { + return Ok(( + Value::default(), + vec![Error::builder() + .message("no subscription conf sender provided for a subscription") + .extension_code("NO_SUBSCRIPTION_CONF_TX") + .build()], + )); + } + }; + + if let Err(err) = subscription_conf_tx.send(subscription_params).await { + return Ok(( + Value::default(), + vec![ + Error::builder() + .message(format!("cannot send the subscription data: {err:?}")) + .extension_code("SUBSCRIPTION_DATA_SEND_ERROR") + .build(), + ], + )); + } + + match service + .oneshot(subgraph_request) + .instrument(tracing::trace_span!("subscription_call")) + .await + .map_to_graphql_error(service_name.to_string(), ¤t_dir) + { + Err(e) => { + failfast_error!("subgraph call fetch error: {}", e); + vec![e] + } + Ok(response) => response.response.into_parts().1.errors, + } + } + None => { + vec![ + Error::builder() + .message(format!( + "subscription mode is not configured for subgraph {service_name:?}" + )) + .extension_code("INVALID_SUBSCRIPTION_MODE") + .build(), + ] + } + }; + Ok((Value::default(), response)) + }) + } +} + +#[derive(Clone)] +pub(crate) struct FetchServiceFactory { + pub(crate) schema: Arc, + pub(crate) subgraph_schemas: Arc, + pub(crate) subgraph_service_factory: Arc, + pub(crate) subscription_config: Option, + pub(crate) connector_service_factory: Arc, +} + +impl FetchServiceFactory { + pub(crate) fn new( + schema: Arc, + subgraph_schemas: Arc, + subgraph_service_factory: Arc, + subscription_config: Option, + connector_service_factory: Arc, + ) -> Self { + Self { + subgraph_service_factory, + subgraph_schemas, + schema, + subscription_config, + connector_service_factory, + } + } +} + +impl ServiceFactory for FetchServiceFactory { + type Service = BoxService; + + fn create(&self) -> Self::Service { + FetchService { + subgraph_service_factory: self.subgraph_service_factory.clone(), + schema: self.schema.clone(), + subgraph_schemas: self.subgraph_schemas.clone(), + _subscription_config: self.subscription_config.clone(), + connector_service_factory: self.connector_service_factory.clone(), + } + .boxed() + } +} diff --git a/apollo-router/src/services/hickory_dns_connector.rs b/apollo-router/src/services/hickory_dns_connector.rs index 987c6ec52f..7e21e4472f 100644 --- a/apollo-router/src/services/hickory_dns_connector.rs +++ b/apollo-router/src/services/hickory_dns_connector.rs @@ -3,36 +3,37 @@ use std::io; use std::net::SocketAddr; use std::net::ToSocketAddrs; use std::pin::Pin; +use std::sync::Arc; use std::task::Context; use std::task::Poll; +use hickory_resolver::TokioResolver; use hickory_resolver::config::LookupIpStrategy; -use hickory_resolver::system_conf::read_system_conf; -use hickory_resolver::TokioAsyncResolver; -use hyper::client::connect::dns::Name; -use hyper::client::HttpConnector; -use hyper::service::Service; +use hyper_util::client::legacy::connect::HttpConnector; +use hyper_util::client::legacy::connect::dns::Name; +use tower::Service; use crate::configuration::shared::DnsResolutionStrategy; -/// Wrapper around hickory-resolver's -/// [`TokioAsyncResolver`](https://docs.rs/hickory-resolver/0.24.1/hickory_resolver/type.TokioAsyncResolver.html) +/// Wrapper around [`hickory_resolver::TokioResolver`]. /// /// The resolver runs a background Task which manages dns requests. When a new resolver is created, /// the background task is also created, it needs to be spawned on top of an executor before using the client, /// or dns requests will block. #[derive(Debug, Clone)] -pub(crate) struct AsyncHyperResolver(TokioAsyncResolver); +pub(crate) struct AsyncHyperResolver(Arc); impl AsyncHyperResolver { - /// constructs a new resolver from default configuration, using [read_system_conf](https://docs.rs/hickory-resolver/0.24.1/hickory_resolver/system_conf/fn.read_system_conf.html) + /// Constructs a new resolver from system configuration. + /// + /// This will use `/etc/resolv.conf` on Unix OSes and the registry on Windows. fn new_from_system_conf( dns_resolution_strategy: DnsResolutionStrategy, ) -> Result { - let (config, mut options) = read_system_conf()?; - options.ip_strategy = dns_resolution_strategy.into(); + let mut builder = TokioResolver::builder_tokio()?; + builder.options_mut().ip_strategy = dns_resolution_strategy.into(); - Ok(Self(TokioAsyncResolver::tokio(config, options))) + Ok(Self(Arc::new(builder.build()))) } } diff --git a/apollo-router/src/services/http.rs b/apollo-router/src/services/http.rs index 105bb26065..f6e919fd82 100644 --- a/apollo-router/src/services/http.rs +++ b/apollo-router/src/services/http.rs @@ -5,11 +5,10 @@ use tower::BoxError; use tower::ServiceExt; use tower_service::Service; -use super::router::body::RouterBody; use super::Plugins; +use super::router::body::RouterBody; use crate::Context; -pub(crate) mod body_stream; pub(crate) mod service; #[cfg(test)] mod tests; @@ -51,7 +50,7 @@ impl HttpClientServiceFactory { ) -> Self { use indexmap::IndexMap; - let service = HttpClientService::from_config( + let service = HttpClientService::from_config_for_subgraph( service, configuration, &rustls::RootCertStore::empty(), diff --git a/apollo-router/src/services/http/body_stream.rs b/apollo-router/src/services/http/body_stream.rs deleted file mode 100644 index e75ed22c50..0000000000 --- a/apollo-router/src/services/http/body_stream.rs +++ /dev/null @@ -1,34 +0,0 @@ -use std::task::Poll; - -use futures::Stream; -use pin_project_lite::pin_project; - -pin_project! { - /// Allows conversion between an http_body::Body and a futures stream. - pub(crate) struct BodyStream { - #[pin] - inner: B - } -} - -impl BodyStream { - /// Create a new `BodyStream`. - pub(crate) fn new(body: B) -> Self { - Self { inner: body } - } -} - -impl Stream for BodyStream -where - B: http_body::Body, - B::Error: Into, -{ - type Item = Result; - - fn poll_next( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> Poll> { - self.project().inner.poll_data(cx) - } -} diff --git a/apollo-router/src/services/http/service.rs b/apollo-router/src/services/http/service.rs index d629412f0c..1e07c2d69b 100644 --- a/apollo-router/src/services/http/service.rs +++ b/apollo-router/src/services/http/service.rs @@ -1,38 +1,37 @@ +use std::error::Error as _; use std::fmt::Display; use std::sync::Arc; use std::task::Poll; use std::time::Duration; use ::serde::Deserialize; -use bytes::Bytes; use futures::future::BoxFuture; -use futures::Stream; -use futures::TryFutureExt; use global::get_text_map_propagator; -use http::header::ACCEPT_ENCODING; -use http::header::CONTENT_ENCODING; use http::HeaderValue; use http::Request; -use hyper::client::HttpConnector; +use http::header::ACCEPT_ENCODING; +use http::header::CONTENT_ENCODING; +use http_body_util::BodyExt; use hyper_rustls::HttpsConnector; +use hyper_util::client::legacy::connect::HttpConnector; #[cfg(unix)] use hyperlocal::UnixConnector; use opentelemetry::global; -use pin_project_lite::pin_project; use rustls::ClientConfig; use rustls::RootCertStore; use schemars::JsonSchema; -use tower::util::Either; use tower::BoxError; use tower::Service; use tower::ServiceBuilder; +#[cfg(unix)] +use tower::util::Either; use tower_http::decompression::Decompression; -use tower_http::decompression::DecompressionBody; use tower_http::decompression::DecompressionLayer; use tracing::Instrument; use super::HttpRequest; use super::HttpResponse; +use crate::Configuration; use crate::axum_factory::compression::Compressor; use crate::configuration::TlsClientAuth; use crate::error::FetchError; @@ -40,19 +39,20 @@ use crate::plugins::authentication::subgraph::SigningParamsConfig; use crate::plugins::telemetry::consts::HTTP_REQUEST_SPAN_NAME; use crate::plugins::telemetry::otel::OpenTelemetrySpanExt; use crate::plugins::telemetry::reload::prepare_context; -use crate::plugins::telemetry::LOGGING_DISPLAY_BODY; -use crate::plugins::telemetry::LOGGING_DISPLAY_HEADERS; use crate::plugins::traffic_shaping::Http2Config; -use crate::services::hickory_dns_connector::new_async_http_connector; use crate::services::hickory_dns_connector::AsyncHyperResolver; +use crate::services::hickory_dns_connector::new_async_http_connector; +use crate::services::router; use crate::services::router::body::RouterBody; -use crate::Configuration; -use crate::Context; -type HTTPClient = - Decompression>, RouterBody>>; +type HTTPClient = Decompression< + hyper_util::client::legacy::Client< + HttpsConnector>, + RouterBody, + >, +>; #[cfg(unix)] -type UnixHTTPClient = Decompression>; +type UnixHTTPClient = Decompression>; #[cfg(unix)] type MixedClient = Either; #[cfg(not(unix))] @@ -89,7 +89,7 @@ impl Display for Compression { #[derive(Clone)] pub(crate) struct HttpClientService { - // Note: We use hyper::Client here in preference to reqwest to avoid expensive URL translation + // Note: We use hyper_util::client::legacy::Client here in preference to reqwest to avoid expensive URL translation // in the hot path. We use reqwest elsewhere because it's convenient and some of the // opentelemetry crate require reqwest clients to work correctly (at time of writing). http_client: HTTPClient, @@ -99,13 +99,20 @@ pub(crate) struct HttpClientService { } impl HttpClientService { - pub(crate) fn from_config( + pub(crate) fn from_config_for_subgraph( service: impl Into, configuration: &Configuration, tls_root_store: &RootCertStore, client_config: crate::configuration::shared::Client, ) -> Result { let name: String = service.into(); + let default_client_cert_config = configuration + .tls + .subgraph + .all + .client_authentication + .as_ref(); + let tls_cert_store = configuration .tls .subgraph @@ -122,14 +129,48 @@ impl HttpClientService { .get(&name) .as_ref() .and_then(|tls| tls.client_authentication.as_ref()) - .or(configuration - .tls - .subgraph - .all - .client_authentication - .as_ref()); + .or(default_client_cert_config); - let tls_client_config = generate_tls_client_config(tls_cert_store, client_cert_config)?; + let tls_client_config = + generate_tls_client_config(tls_cert_store, client_cert_config.map(|arc| arc.as_ref()))?; + + HttpClientService::new(name, tls_client_config, client_config) + } + + pub(crate) fn from_config_for_connector( + source_name: impl Into, + configuration: &Configuration, + tls_root_store: &RootCertStore, + client_config: crate::configuration::shared::Client, + ) -> Result { + let name: String = source_name.into(); + let default_client_cert_config = configuration + .tls + .connector + .all + .client_authentication + .as_ref(); + + let tls_cert_store = configuration + .tls + .connector + .sources + .get(&name) + .as_ref() + .and_then(|subgraph| subgraph.create_certificate_store()) + .transpose()? + .unwrap_or_else(|| tls_root_store.clone()); + let client_cert_config = configuration + .tls + .connector + .sources + .get(&name) + .as_ref() + .and_then(|tls| tls.client_authentication.as_ref()) + .or(default_client_cert_config); + + let tls_client_config = + generate_tls_client_config(tls_cert_store, client_cert_config.map(|arc| arc.as_ref()))?; HttpClientService::new(name, tls_client_config, client_config) } @@ -157,10 +198,11 @@ impl HttpClientService { builder.wrap_connector(http_connector) }; - let http_client = hyper::Client::builder() - .pool_idle_timeout(POOL_IDLE_TIMEOUT_DURATION) - .http2_only(http2 == Http2Config::Http2Only) - .build(connector); + let http_client = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .pool_idle_timeout(POOL_IDLE_TIMEOUT_DURATION) + .http2_only(http2 == Http2Config::Http2Only) + .build(connector); Ok(Self { http_client: ServiceBuilder::new() .layer(DecompressionLayer::new()) @@ -168,33 +210,23 @@ impl HttpClientService { #[cfg(unix)] unix_client: ServiceBuilder::new() .layer(DecompressionLayer::new()) - .service(hyper::Client::builder().build(UnixConnector)), + .service( + hyper_util::client::legacy::Client::builder( + hyper_util::rt::TokioExecutor::new(), + ) + .build(UnixConnector), + ), service: Arc::new(service.into()), }) } pub(crate) fn native_roots_store() -> RootCertStore { let mut roots = rustls::RootCertStore::empty(); - let mut valid_count = 0; - let mut invalid_count = 0; - - for cert in rustls_native_certs::load_native_certs().expect("could not load platform certs") - { - let cert = rustls::Certificate(cert.0); - match roots.add(&cert) { - Ok(_) => valid_count += 1, - Err(err) => { - tracing::trace!("invalid cert der {:?}", cert.0); - tracing::debug!("certificate parsing failed: {:?}", err); - invalid_count += 1 - } - } - } - tracing::debug!( - "with_native_roots processed {} valid and {} invalid certs", - valid_count, - invalid_count + + roots.add_parsable_certificates( + rustls_native_certs::load_native_certs().expect("could not load platform certs"), ); + assert!(!roots.is_empty(), "no CA certificates found"); roots } @@ -204,13 +236,14 @@ pub(crate) fn generate_tls_client_config( tls_cert_store: RootCertStore, client_cert_config: Option<&TlsClientAuth>, ) -> Result { - let tls_builder = rustls::ClientConfig::builder().with_safe_defaults(); + let tls_builder = rustls::ClientConfig::builder(); + Ok(match client_cert_config { Some(client_auth_config) => tls_builder .with_root_certificates(tls_cert_store) .with_client_auth_cert( client_auth_config.certificate_chain.clone(), - client_auth_config.key.clone(), + client_auth_config.key.clone_key(), )?, None => tls_builder .with_root_certificates(tls_cert_store) @@ -233,6 +266,7 @@ impl tower::Service for HttpClientService { let HttpRequest { mut http_request, context, + .. } = request; let schema_uri = http_request.uri(); @@ -250,11 +284,23 @@ impl tower::Service for HttpClientService { #[cfg(unix)] let client = match schema_uri.scheme().map(|s| s.as_str()) { - Some("unix") => Either::B(self.unix_client.clone()), - _ => Either::A(self.http_client.clone()), + Some("unix") => { + // Because we clone our inner service, we'd better swap the readied one + let clone = self.unix_client.clone(); + Either::Right(std::mem::replace(&mut self.unix_client, clone)) + } + _ => { + // Because we clone our inner service, we'd better swap the readied one + let clone = self.http_client.clone(); + Either::Left(std::mem::replace(&mut self.http_client, clone)) + } }; #[cfg(not(unix))] - let client = self.http_client.clone(); + let client = { + // Because we clone our inner service, we'd better swap the readied one + let clone = self.http_client.clone(); + std::mem::replace(&mut self.http_client, clone) + }; let service_name = self.service.clone(); @@ -273,7 +319,7 @@ impl tower::Service for HttpClientService { get_text_map_propagator(|propagator| { propagator.inject_context( &prepare_context(http_req_span.context()), - &mut opentelemetry_http::HeaderInjector(http_request.headers_mut()), + &mut crate::otel_compat::HeaderInjector(http_request.headers_mut()), ); }); @@ -287,8 +333,9 @@ impl tower::Service for HttpClientService { let body = match opt_compressor { None => body, - Some(compressor) => RouterBody::wrap_stream(compressor.process(body)), + Some(compressor) => router::body::from_result_stream(compressor.process(body)), }; + let mut http_request = http::Request::from_parts(parts, body); http_request @@ -306,26 +353,10 @@ impl tower::Service for HttpClientService { http_request }; - let display_headers = context.contains_key(LOGGING_DISPLAY_HEADERS); - let display_body = context.contains_key(LOGGING_DISPLAY_BODY); - - // Print out the debug for the request - if display_headers { - tracing::info!(http.request.headers = ?http_request.headers(), apollo.subgraph.name = %service_name, "Request headers to subgraph {service_name:?}"); - } - if display_body { - tracing::info!(http.request.body = ?http_request.body(), apollo.subgraph.name = %service_name, "Request body to subgraph {service_name:?}"); - } - - let http_response = do_fetch(client, &context, &service_name, http_request) + let http_response = do_fetch(client, &service_name, http_request) .instrument(http_req_span) .await?; - // Print out the debug for the response - if display_headers { - tracing::info!(response.headers = ?http_response.headers(), apollo.subgraph.name = %service_name, "Response headers from subgraph {service_name:?}"); - } - Ok(HttpResponse { http_response, context, @@ -334,58 +365,53 @@ impl tower::Service for HttpClientService { } } +/// Hyper client errors are very opaque. This function peels back the layers and attempts to +/// provide a useful message to end users. +fn report_hyper_client_error(err: hyper_util::client::legacy::Error) -> String { + // At the time of writing, a hyper-util error only prints "client error", and no useful further + // information. So if we have a source error (always true in practice), we simply discard the + // "client error" part and only report the inner error. + let Some(source) = err.source() else { + // No further information + return err.to_string(); + }; + + // If there was a connection, parsing, http, etc, error, the source will be a + // `hyper::Error`. `hyper::Error` provides a minimal error message only, that + // will explain vaguely where the problem is, like "error in user's Body stream", + // or "error parsing http header". + // This is important to preserve as it may clarify the difference between a malfunctioning + // subgraph and a buggy router. + // It's not enough information though, in particular for the user error kinds, so if there is + // another inner error, we report *both* the hyper error and the inner error. + let subsource = source + .downcast_ref::() + .and_then(|err| err.source()); + match subsource { + Some(inner_err) => format!("{source}: {inner_err}"), + None => source.to_string(), + } +} + async fn do_fetch( mut client: MixedClient, - context: &Context, service_name: &str, request: Request, ) -> Result, FetchError> { - let _active_request_guard = context.enter_active_request(); let (parts, body) = client .call(request) + .await .map_err(|err| { tracing::error!(fetch_error = ?err); FetchError::SubrequestHttpError { status_code: None, service: service_name.to_string(), - reason: err.to_string(), + reason: report_hyper_client_error(err), } - }) - .await? + })? .into_parts(); Ok(http::Response::from_parts( parts, - RouterBody::wrap_stream(BodyStream { inner: body }), + RouterBody::new(body.map_err(axum::Error::new)), )) } - -pin_project! { - pub(crate) struct BodyStream { - #[pin] - inner: DecompressionBody - } -} - -impl BodyStream { - /// Create a new `BodyStream`. - pub(crate) fn new(body: DecompressionBody) -> Self { - Self { inner: body } - } -} - -impl Stream for BodyStream -where - B: hyper::body::HttpBody, - B::Error: Into, -{ - type Item = Result; - - fn poll_next( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> Poll> { - use hyper::body::HttpBody; - - self.project().inner.poll_data(cx) - } -} diff --git a/apollo-router/src/services/http/tests.rs b/apollo-router/src/services/http/tests.rs index 68bf996939..4b0007a094 100644 --- a/apollo-router/src/services/http/tests.rs +++ b/apollo-router/src/services/http/tests.rs @@ -1,80 +1,145 @@ use std::convert::Infallible; use std::io; -use std::net::TcpListener; use std::str::FromStr; +use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use std::sync::Arc; use async_compression::tokio::write::GzipDecoder; use async_compression::tokio::write::GzipEncoder; -use axum::Server; -use http::header::CONTENT_ENCODING; -use http::header::CONTENT_TYPE; +use axum::body::Body; +use http::Request; use http::StatusCode; use http::Uri; use http::Version; -use hyper::server::conn::AddrIncoming; -use hyper::service::make_service_fn; -use hyper::Body; +use http::header::CONTENT_ENCODING; +use http::header::CONTENT_TYPE; +use hyper::body::Incoming; use hyper_rustls::ConfigBuilderExt; -use hyper_rustls::TlsAcceptor; -#[cfg(unix)] -use hyperlocal::UnixServerExt; +use hyper_util::rt::TokioExecutor; +use hyper_util::rt::TokioIo; use mime::APPLICATION_JSON; -use rustls::server::AllowAnyAuthenticatedClient; -use rustls::Certificate; -use rustls::PrivateKey; use rustls::RootCertStore; use rustls::ServerConfig; +use rustls::pki_types::CertificateDer; +use rustls::pki_types::PrivateKeyDer; +use rustls::server::WebPkiClientVerifier; use serde_json_bytes::ByteString; use serde_json_bytes::Value; use tokio::io::AsyncWriteExt; -use tower::service_fn; +use tokio::net::TcpListener; +#[cfg(unix)] +use tokio::net::UnixListener; +use tokio_rustls::TlsAcceptor; use tower::BoxError; use tower::ServiceExt; -use crate::configuration::load_certs; -use crate::configuration::load_key; +use crate::Configuration; +use crate::Context; +use crate::TestHarness; use crate::configuration::TlsClient; use crate::configuration::TlsClientAuth; +use crate::configuration::load_certs; +use crate::configuration::load_key; use crate::graphql::Response; use crate::plugin::PluginInit; use crate::plugin::PluginPrivate; use crate::plugins::traffic_shaping::Http2Config; use crate::services::http::HttpClientService; use crate::services::http::HttpRequest; -use crate::services::router::body::get_body_bytes; +use crate::services::router; use crate::services::supergraph; -use crate::Configuration; -use crate::Context; -use crate::TestHarness; async fn tls_server( - listener: tokio::net::TcpListener, - certificates: Vec, - key: PrivateKey, + listener: TcpListener, + certificates: Vec>, + key: PrivateKeyDer<'static>, body: &'static str, ) { - let acceptor = TlsAcceptor::builder() - .with_single_cert(certificates, key) - .unwrap() - .with_all_versions_alpn() - .with_incoming(AddrIncoming::from_listener(listener).unwrap()); - let service = make_service_fn(|_| async { - Ok::<_, io::Error>(service_fn(|_req| async { - Ok::<_, io::Error>( - http::Response::builder() - .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) - .status(StatusCode::OK) - .version(Version::HTTP_11) - .body::(body.into()) - .unwrap(), - ) - })) - }); - let server = Server::builder(acceptor).serve(service); - server.await.unwrap() + let tls_config = Arc::new( + ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certificates, key) + .expect("built our tls config"), + ); + let acceptor = TlsAcceptor::from(tls_config); + + loop { + let (stream, _) = listener.accept().await.expect("accepting connections"); + let acceptor = acceptor.clone(); + + tokio::spawn(async move { + let acceptor_stream = acceptor.accept(stream).await.expect("accepted stream"); + let tokio_stream = TokioIo::new(acceptor_stream); + + let hyper_service = + hyper::service::service_fn(move |_request: Request| async { + Ok::<_, io::Error>( + http::Response::builder() + .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .status(StatusCode::OK) + .version(Version::HTTP_11) + .body::(body.into()) + .unwrap(), + ) + }); + if let Err(err) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(tokio_stream, hyper_service) + .await + { + eprintln!("failed to serve connection: {err:#}"); + } + }); + } +} + +async fn serve(listener: TcpListener, handle: Handler) -> std::io::Result<()> +where + Handler: (Fn(http::Request) -> Fut) + Clone + Sync + Send + 'static, + Fut: std::future::Future, Infallible>> + Send + 'static, +{ + loop { + let (stream, _) = listener.accept().await?; + let io = TokioIo::new(stream); + let handle = handle.clone(); + tokio::spawn(async move { + // N.B. should use hyper service_fn here, since it's required to be implemented hyper Service trait! + let svc = hyper::service::service_fn(|request: Request| { + handle(request.map(Body::new)) + }); + if let Err(err) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(io, svc) + .await + { + eprintln!("server error: {err}"); + } + }); + } +} + +#[cfg(unix)] +async fn serve_unix(listener: UnixListener, handle: Handler) -> std::io::Result<()> +where + Handler: (Fn(http::Request) -> Fut) + Clone + Sync + Send + 'static, + Fut: std::future::Future, Infallible>> + Send + 'static, +{ + loop { + let (stream, _) = listener.accept().await?; + let io = TokioIo::new(stream); + let handle = handle.clone(); + tokio::spawn(async move { + // N.B. should use hyper service_fn here, since it's required to be implemented hyper Service trait! + let svc = hyper::service::service_fn(|request: Request| { + handle(request.map(Body::new)) + }); + if let Err(err) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(io, svc) + .await + { + eprintln!("server error: {err}"); + } + }); + } } // Note: This test relies on a checked in certificate with the following validity @@ -97,7 +162,7 @@ async fn tls_self_signed() { let certificates = load_certs(certificate_pem).unwrap(); let key = load_key(key_pem).unwrap(); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(tls_server(listener, certificates, key, r#"{"data": null}"#)); @@ -112,7 +177,7 @@ async fn tls_self_signed() { client_authentication: None, }, ); - let subgraph_service = HttpClientService::from_config( + let subgraph_service = HttpClientService::from_config_for_subgraph( "test", &config, &rustls::RootCertStore::empty(), @@ -126,7 +191,9 @@ async fn tls_self_signed() { http_request: http::Request::builder() .uri(url) .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) - .body(r#"{"query":"{ me { name username } }"#.into()) + .body(router::body::from_bytes( + r#"{"query":"{ me { name username } }"#, + )) .unwrap(), context: Context::new(), }) @@ -135,7 +202,7 @@ async fn tls_self_signed() { assert_eq!( std::str::from_utf8( - &get_body_bytes(response.http_response.into_parts().1) + &router::body::into_bytes(response.http_response.into_parts().1) .await .unwrap() ) @@ -144,6 +211,66 @@ async fn tls_self_signed() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn tls_self_signed_connector() { + let certificate_pem = include_str!("./testdata/server_self_signed.crt"); + let key_pem = include_str!("./testdata/server.key"); + + let certificates = load_certs(certificate_pem).unwrap(); + let key = load_key(key_pem).unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let socket_addr = listener.local_addr().unwrap(); + tokio::task::spawn(tls_server( + listener, + certificates, + key, + r#"{"my_field": "abc"}"#, + )); + + // we cannot parse a configuration from text, because certificates are generally + // added by file expansion and we don't have access to that here, and inserting + // the PEM data directly generates parsing issues due to end of line characters + let mut config = Configuration::default(); + config.tls.connector.sources.insert( + "test".to_string(), + TlsClient { + certificate_authorities: Some(certificate_pem.into()), + client_authentication: None, + }, + ); + let subgraph_service = HttpClientService::from_config_for_connector( + "test", + &config, + &rustls::RootCertStore::empty(), + crate::configuration::shared::Client::default(), + ) + .unwrap(); + + let url = Uri::from_str(&format!("https://localhost:{}", socket_addr.port())).unwrap(); + let response = subgraph_service + .oneshot(HttpRequest { + http_request: http::Request::builder() + .uri(url) + .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .body(router::body::from_bytes(r#"{}"#)) + .unwrap(), + context: Context::new(), + }) + .await + .unwrap(); + + assert_eq!( + std::str::from_utf8( + &router::body::into_bytes(response.http_response.into_parts().1) + .await + .unwrap() + ) + .unwrap(), + r#"{"my_field": "abc"}"# + ); +} + #[tokio::test(flavor = "multi_thread")] async fn tls_custom_root() { let certificate_pem = include_str!("./testdata/server.crt"); @@ -154,7 +281,7 @@ async fn tls_custom_root() { certificates.extend(load_certs(ca_pem).unwrap()); let key = load_key(key_pem).unwrap(); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(tls_server(listener, certificates, key, r#"{"data": null}"#)); @@ -169,7 +296,7 @@ async fn tls_custom_root() { client_authentication: None, }, ); - let subgraph_service = HttpClientService::from_config( + let subgraph_service = HttpClientService::from_config_for_subgraph( "test", &config, &rustls::RootCertStore::empty(), @@ -183,7 +310,9 @@ async fn tls_custom_root() { http_request: http::Request::builder() .uri(url) .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) - .body(r#"{"query":"{ me { name username } }"#.into()) + .body(router::body::from_bytes( + r#"{"query":"{ me { name username } }"#, + )) .unwrap(), context: Context::new(), }) @@ -191,7 +320,7 @@ async fn tls_custom_root() { .unwrap(); assert_eq!( std::str::from_utf8( - &get_body_bytes(response.http_response.into_parts().1) + &router::body::into_bytes(response.http_response.into_parts().1) .await .unwrap() ) @@ -200,42 +329,116 @@ async fn tls_custom_root() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn tls_custom_root_connector() { + let certificate_pem = include_str!("./testdata/server.crt"); + let ca_pem = include_str!("./testdata/CA/ca.crt"); + let key_pem = include_str!("./testdata/server.key"); + + let mut certificates = load_certs(certificate_pem).unwrap(); + certificates.extend(load_certs(ca_pem).unwrap()); + let key = load_key(key_pem).unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let socket_addr = listener.local_addr().unwrap(); + tokio::task::spawn(tls_server( + listener, + certificates, + key, + r#"{"my_field": "abc"}"#, + )); + + // we cannot parse a configuration from text, because certificates are generally + // added by file expansion and we don't have access to that here, and inserting + // the PEM data directly generates parsing issues due to end of line characters + let mut config = Configuration::default(); + config.tls.connector.sources.insert( + "test".to_string(), + TlsClient { + certificate_authorities: Some(ca_pem.into()), + client_authentication: None, + }, + ); + let subgraph_service = HttpClientService::from_config_for_connector( + "test", + &config, + &rustls::RootCertStore::empty(), + crate::configuration::shared::Client::default(), + ) + .unwrap(); + + let url = Uri::from_str(&format!("https://localhost:{}", socket_addr.port())).unwrap(); + let response = subgraph_service + .oneshot(HttpRequest { + http_request: http::Request::builder() + .uri(url) + .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .body(router::body::from_bytes(r#"{}"#)) + .unwrap(), + context: Context::new(), + }) + .await + .unwrap(); + assert_eq!( + std::str::from_utf8( + &router::body::into_bytes(response.http_response.into_parts().1) + .await + .unwrap() + ) + .unwrap(), + r#"{"my_field": "abc"}"# + ); +} + async fn tls_server_with_client_auth( - listener: tokio::net::TcpListener, - certificates: Vec, - key: PrivateKey, - client_root: Certificate, + listener: TcpListener, + certificates: Vec>, + key: PrivateKeyDer<'static>, + client_root: CertificateDer<'static>, body: &'static str, ) { let mut client_auth_roots = RootCertStore::empty(); - client_auth_roots.add(&client_root).unwrap(); + client_auth_roots.add(client_root).unwrap(); - let client_auth = AllowAnyAuthenticatedClient::new(client_auth_roots).boxed(); + let client_auth = WebPkiClientVerifier::builder(Arc::new(client_auth_roots)) + .build() + .unwrap(); - let acceptor = TlsAcceptor::builder() - .with_tls_config( - ServerConfig::builder() - .with_safe_defaults() - .with_client_cert_verifier(client_auth) - .with_single_cert(certificates, key) - .unwrap(), - ) - .with_all_versions_alpn() - .with_incoming(AddrIncoming::from_listener(listener).unwrap()); - let service = make_service_fn(|_| async { - Ok::<_, io::Error>(service_fn(|_req| async { - Ok::<_, io::Error>( - http::Response::builder() - .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) - .status(StatusCode::OK) - .version(Version::HTTP_11) - .body::(body.into()) - .unwrap(), - ) - })) - }); - let server = Server::builder(acceptor).serve(service); - server.await.unwrap() + let tls_config = Arc::new( + ServerConfig::builder() + .with_client_cert_verifier(client_auth) + .with_single_cert(certificates, key) + .unwrap(), + ); + let acceptor = TlsAcceptor::from(tls_config); + + loop { + let (stream, _) = listener.accept().await.expect("accepting connections"); + let acceptor = acceptor.clone(); + + tokio::spawn(async move { + let acceptor_stream = acceptor.accept(stream).await.expect("accepted stream"); + let tokio_stream = TokioIo::new(acceptor_stream); + + let hyper_service = + hyper::service::service_fn(move |_request: Request| async { + Ok::<_, io::Error>( + http::Response::builder() + .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .status(StatusCode::OK) + .version(Version::HTTP_11) + .body::(body.into()) + .unwrap(), + ) + }); + if let Err(err) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(tokio_stream, hyper_service) + .await + { + eprintln!("failed to serve connection: {err:#}"); + } + }); + } } #[tokio::test(flavor = "multi_thread")] @@ -249,7 +452,7 @@ async fn tls_client_auth() { server_certificates.push(ca_certificate.clone()); let key = load_key(server_key_pem).unwrap(); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(tls_server_with_client_auth( listener, @@ -273,13 +476,13 @@ async fn tls_client_auth() { "test".to_string(), TlsClient { certificate_authorities: Some(ca_pem.into()), - client_authentication: Some(TlsClientAuth { + client_authentication: Some(Arc::new(TlsClientAuth { certificate_chain: client_certificates, key: client_key, - }), + })), }, ); - let subgraph_service = HttpClientService::from_config( + let subgraph_service = HttpClientService::from_config_for_subgraph( "test", &config, &rustls::RootCertStore::empty(), @@ -293,7 +496,9 @@ async fn tls_client_auth() { http_request: http::Request::builder() .uri(url) .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) - .body(r#"{"query":"{ me { name username } }"#.into()) + .body(router::body::from_bytes( + r#"{"query":"{ me { name username } }"#, + )) .unwrap(), context: Context::new(), }) @@ -301,7 +506,7 @@ async fn tls_client_auth() { .unwrap(); assert_eq!( std::str::from_utf8( - &get_body_bytes(response.http_response.into_parts().1) + &router::body::into_bytes(response.http_response.into_parts().1) .await .unwrap() ) @@ -310,6 +515,78 @@ async fn tls_client_auth() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn tls_client_auth_connector() { + let server_certificate_pem = include_str!("./testdata/server.crt"); + let ca_pem = include_str!("./testdata/CA/ca.crt"); + let server_key_pem = include_str!("./testdata/server.key"); + + let mut server_certificates = load_certs(server_certificate_pem).unwrap(); + let ca_certificate = load_certs(ca_pem).unwrap().remove(0); + server_certificates.push(ca_certificate.clone()); + let key = load_key(server_key_pem).unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let socket_addr = listener.local_addr().unwrap(); + tokio::task::spawn(tls_server_with_client_auth( + listener, + server_certificates, + key, + ca_certificate, + r#"{"my_field": "abc"}"#, + )); + + let client_certificate_pem = include_str!("./testdata/client.crt"); + let client_key_pem = include_str!("./testdata/client.key"); + + let client_certificates = load_certs(client_certificate_pem).unwrap(); + let client_key = load_key(client_key_pem).unwrap(); + + // we cannot parse a configuration from text, because certificates are generally + // added by file expansion and we don't have access to that here, and inserting + // the PEM data directly generates parsing issues due to end of line characters + let mut config = Configuration::default(); + config.tls.connector.sources.insert( + "test".to_string(), + TlsClient { + certificate_authorities: Some(ca_pem.into()), + client_authentication: Some(Arc::new(TlsClientAuth { + certificate_chain: client_certificates, + key: client_key, + })), + }, + ); + let subgraph_service = HttpClientService::from_config_for_connector( + "test", + &config, + &rustls::RootCertStore::empty(), + crate::configuration::shared::Client::default(), + ) + .unwrap(); + + let url = Uri::from_str(&format!("https://localhost:{}", socket_addr.port())).unwrap(); + let response = subgraph_service + .oneshot(HttpRequest { + http_request: http::Request::builder() + .uri(url) + .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .body(router::body::from_bytes(r#"{}"#)) + .unwrap(), + context: Context::new(), + }) + .await + .unwrap(); + assert_eq!( + std::str::from_utf8( + &router::body::into_bytes(response.http_response.into_parts().1) + .await + .unwrap() + ) + .unwrap(), + r#"{"my_field": "abc"}"# + ); +} + // starts a local server emulating a subgraph returning status code 401 async fn emulate_h2c_server(listener: TcpListener) { async fn handle(_request: http::Request) -> Result, Infallible> { @@ -328,24 +605,20 @@ async fn emulate_h2c_server(listener: TcpListener) { .unwrap()) } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener) - .unwrap() - .http2_only(true) - .serve(make_svc); - server.await.unwrap(); + // XXX(@goto-bus-stop): ideally this server would *only* support HTTP 2 and not HTTP 1 + serve(listener, handle).await.unwrap(); } #[tokio::test(flavor = "multi_thread")] async fn test_subgraph_h2c() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_h2c_server(listener)); let subgraph_service = HttpClientService::new( "test", rustls::ClientConfig::builder() - .with_safe_defaults() .with_native_roots() + .expect("read native TLS root certificates") .with_no_client_auth(), crate::configuration::shared::Client::builder() .experimental_http2(Http2Config::Http2Only) @@ -359,7 +632,9 @@ async fn test_subgraph_h2c() { http_request: http::Request::builder() .uri(url) .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) - .body(r#"{"query":"{ me { name username } }"#.into()) + .body(router::body::from_bytes( + r#"{"query":"{ me { name username } }"#, + )) .unwrap(), context: Context::new(), }) @@ -367,7 +642,7 @@ async fn test_subgraph_h2c() { .unwrap(); assert_eq!( std::str::from_utf8( - &get_body_bytes(response.http_response.into_parts().1) + &router::body::into_bytes(response.http_response.into_parts().1) .await .unwrap() ) @@ -379,7 +654,10 @@ async fn test_subgraph_h2c() { // starts a local server emulating a subgraph returning compressed response async fn emulate_subgraph_compressed_response(listener: TcpListener) { async fn handle(request: http::Request) -> Result, Infallible> { - let body = get_body_bytes(request.into_body()).await.unwrap().to_vec(); + let body = router::body::into_bytes(request.into_body()) + .await + .unwrap() + .to_vec(); let mut decoder = GzipDecoder::new(Vec::new()); decoder.write_all(&body).await.unwrap(); decoder.shutdown().await.unwrap(); @@ -409,21 +687,21 @@ async fn emulate_subgraph_compressed_response(listener: TcpListener) { .unwrap()) } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } #[tokio::test(flavor = "multi_thread")] async fn test_compressed_request_response_body() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + // Though the server doesn't use TLS, the client still supports it, and so we need crypto stuff + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_subgraph_compressed_response(listener)); let subgraph_service = HttpClientService::new( "test", rustls::ClientConfig::builder() - .with_safe_defaults() .with_native_roots() + .expect("read native TLS root certificates") .with_no_client_auth(), crate::configuration::shared::Client::builder() .experimental_http2(Http2Config::Http2Only) @@ -438,7 +716,9 @@ async fn test_compressed_request_response_body() { .uri(url) .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) .header(CONTENT_ENCODING, "gzip") - .body(r#"{"query":"{ me { name username } }"#.into()) + .body(router::body::from_bytes( + r#"{"query":"{ me { name username } }"#, + )) .unwrap(), context: Context::new(), }) @@ -447,7 +727,7 @@ async fn test_compressed_request_response_body() { assert_eq!( std::str::from_utf8( - &get_body_bytes(response.http_response.into_parts().1) + &router::body::into_bytes(response.http_response.into_parts().1) .await .unwrap() ) @@ -569,29 +849,22 @@ async fn test_unix_socket() { let path = dir.path().join("router.sock"); let schema = make_schema(path.to_str().unwrap()); - let make_service = make_service_fn(|_| async { - Ok::<_, hyper::Error>(service_fn(|mut req: http::Request| async move { - let data = get_body_bytes(req.body_mut()).await.unwrap(); - let body = std::str::from_utf8(&data).unwrap(); - println!("{:?}", body); - let response = http::Response::builder() - .status(StatusCode::OK) - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - r#"{ "data": { "currentUser": { "id": "0" } } }"#, - )) - .unwrap(); - Ok::<_, hyper::Error>(response) - })) - }); - - tokio::task::spawn(async move { - hyper::Server::bind_unix(path) - .unwrap() - .serve(make_service) - .await + async fn handle(mut req: http::Request) -> Result, Infallible> { + let data = router::body::into_bytes(req.body_mut()).await.unwrap(); + let body = std::str::from_utf8(&data).unwrap(); + println!("{body:?}"); + let response = http::Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{ "data": { "currentUser": { "id": "0" } } }"#, + )) .unwrap(); - }); + Ok(response) + } + + let listener = UnixListener::bind(path).unwrap(); + tokio::task::spawn(serve_unix(listener, handle)); let service = TestHarness::builder() .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) diff --git a/apollo-router/src/services/layers/allow_only_http_post_mutations.rs b/apollo-router/src/services/layers/allow_only_http_post_mutations.rs index 6c737ec76e..dd3ca1370d 100644 --- a/apollo-router/src/services/layers/allow_only_http_post_mutations.rs +++ b/apollo-router/src/services/layers/allow_only_http_post_mutations.rs @@ -1,15 +1,13 @@ -//! Prevent mutations if the HTTP method is GET. -//! -//! See [`Layer`] and [`Service`] for more details. +//! A supergraph service layer that requires that GraphQL mutations use the HTTP POST method. use std::ops::ControlFlow; use apollo_compiler::ast::OperationType; use futures::future::BoxFuture; -use http::header::HeaderName; use http::HeaderValue; use http::Method; use http::StatusCode; +use http::header::HeaderName; use tower::BoxError; use tower::Layer; use tower::Service; @@ -18,11 +16,19 @@ use tower::ServiceBuilder; use super::query_analysis::ParsedDocument; use crate::graphql::Error; use crate::json_ext::Object; -use crate::layers::async_checkpoint::OneShotAsyncCheckpointService; use crate::layers::ServiceBuilderExt; +use crate::layers::async_checkpoint::AsyncCheckpointService; use crate::services::SupergraphRequest; use crate::services::SupergraphResponse; +/// A supergraph service layer that requires that GraphQL mutations use the HTTP POST method. +/// +/// Responds with a 405 Method Not Allowed if it receives a GraphQL mutation using any other HTTP +/// method. +/// +/// This layer requires that a ParsedDocument is available on the context and that the request has +/// a valid GraphQL operation and operation name. If these conditions are not met the layer will +/// return early with an unspecified error response. #[derive(Default)] pub(crate) struct AllowOnlyHttpPostMutationsLayer {} @@ -34,7 +40,7 @@ where + 'static, >::Future: Send + 'static, { - type Service = OneShotAsyncCheckpointService< + type Service = AsyncCheckpointService< S, BoxFuture<'static, Result, BoxError>>, SupergraphRequest, @@ -42,7 +48,7 @@ where fn layer(&self, service: S) -> Self::Service { ServiceBuilder::new() - .oneshot_checkpoint_async(|req: SupergraphRequest| { + .checkpoint_async(|req: SupergraphRequest| { Box::pin(async { if req.supergraph_request.method() == Method::POST { return Ok(ControlFlow::Continue(req)); @@ -54,10 +60,15 @@ where .with_lock(|lock| lock.get::().cloned()) { None => { - let errors = vec![Error::builder() - .message("Cannot find executable document".to_string()) - .extension_code("MISSING_EXECUTABLE_DOCUMENT") - .build()]; + // We shouldn't ever reach here unless the pipeline was set up + // improperly (i.e. programmer error), but do something better than + // panicking just in case. + let errors = vec![ + Error::builder() + .message("Cannot find executable document".to_string()) + .extension_code("MISSING_EXECUTABLE_DOCUMENT") + .build(), + ]; let res = SupergraphResponse::infallible_builder() .errors(errors) .extensions(Object::default()) @@ -77,10 +88,14 @@ where match op { Err(_) => { - let errors = vec![Error::builder() - .message("Cannot find operation".to_string()) - .extension_code("MISSING_OPERATION") - .build()]; + // We shouldn't end up here if the request is valid, and validation + // should happen well before this, but do something just in case. + let errors = vec![ + Error::builder() + .message("Cannot find operation".to_string()) + .extension_code("MISSING_OPERATION") + .build(), + ]; let res = SupergraphResponse::infallible_builder() .errors(errors) .extensions(Object::default()) @@ -92,12 +107,14 @@ where } Ok(op) => { if op.operation_type == OperationType::Mutation { - let errors = vec![Error::builder() - .message( - "Mutations can only be sent over HTTP POST".to_string(), - ) - .extension_code("MUTATION_FORBIDDEN") - .build()]; + let errors = vec![ + Error::builder() + .message( + "Mutations can only be sent over HTTP POST".to_string(), + ) + .extension_code("MUTATION_FORBIDDEN") + .build(), + ]; let mut res = SupergraphResponse::builder() .errors(errors) .extensions(Object::default()) @@ -132,17 +149,21 @@ mod forbid_http_get_mutations_tests { use tower::ServiceExt; use super::*; + use crate::Context; + use crate::assert_error_eq_ignoring_id; use crate::error::Error; - use crate::graphql::Response; use crate::plugin::test::MockSupergraphService; use crate::query_planner::fetch::OperationKind; use crate::services::layers::query_analysis::ParsedDocumentInner; - use crate::Context; #[tokio::test] async fn it_lets_http_post_queries_pass_through() { let mut mock_service = MockSupergraphService::new(); + mock_service + .expect_clone() + .returning(MockSupergraphService::new); + mock_service .expect_call() .times(1) @@ -166,6 +187,10 @@ mod forbid_http_get_mutations_tests { async fn it_lets_http_post_mutations_pass_through() { let mut mock_service = MockSupergraphService::new(); + mock_service + .expect_clone() + .returning(MockSupergraphService::new); + mock_service .expect_call() .times(1) @@ -189,6 +214,10 @@ mod forbid_http_get_mutations_tests { async fn it_lets_http_get_queries_pass_through() { let mut mock_service = MockSupergraphService::new(); + mock_service + .expect_clone() + .returning(MockSupergraphService::new); + mock_service .expect_call() .times(1) @@ -210,17 +239,10 @@ mod forbid_http_get_mutations_tests { #[tokio::test] async fn it_doesnt_let_non_http_post_mutations_pass_through() { - let expected_error = Error { - message: "Mutations can only be sent over HTTP POST".to_string(), - locations: Default::default(), - path: Default::default(), - extensions: serde_json_bytes::json!({ - "code": "MUTATION_FORBIDDEN" - }) - .as_object() - .unwrap() - .to_owned(), - }; + let expected_error = Error::builder() + .message("Mutations can only be sent over HTTP POST".to_string()) + .extension_code("MUTATION_FORBIDDEN") + .build(); let expected_status = StatusCode::METHOD_NOT_ALLOWED; let expected_allow_header = "POST"; @@ -238,25 +260,27 @@ mod forbid_http_get_mutations_tests { .map(|method| create_request(method, OperationKind::Mutation)); for request in forbidden_requests { - let mock_service = MockSupergraphService::new(); + let mut mock_service = MockSupergraphService::new(); + + mock_service + .expect_clone() + .returning(MockSupergraphService::new); + let mut service_stack = AllowOnlyHttpPostMutationsLayer::default().layer(mock_service); let services = service_stack.ready().await.unwrap(); - let mut actual_error = services.call(request).await.unwrap(); + let mut error_response = services.call(request).await.unwrap(); + let response = error_response.next_response().await.unwrap(); - assert_eq!(expected_status, actual_error.response.status()); + assert_eq!(expected_status, error_response.response.status()); assert_eq!( expected_allow_header, - actual_error.response.headers().get("Allow").unwrap() + error_response.response.headers().get("Allow").unwrap() ); - assert_error_matches(&expected_error, actual_error.next_response().await.unwrap()); + assert_error_eq_ignoring_id!(expected_error, response.errors[0]); } } - fn assert_error_matches(expected_error: &Error, response: Response) { - assert_eq!(&response.errors[0], expected_error); - } - fn create_request(method: Method, operation_kind: OperationKind) -> SupergraphRequest { let query = match operation_kind { OperationKind::Query => { @@ -285,7 +309,7 @@ mod forbid_http_get_mutations_tests { let (_schema, executable) = ast.to_mixed_validate().unwrap(); let context = Context::new(); - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { lock.insert::( ParsedDocumentInner::new(ast, Arc::new(executable), None, Default::default()) .unwrap(), diff --git a/apollo-router/src/services/layers/apq.rs b/apollo-router/src/services/layers/apq.rs index d4f1119161..088709aad3 100644 --- a/apollo-router/src/services/layers/apq.rs +++ b/apollo-router/src/services/layers/apq.rs @@ -1,13 +1,12 @@ -//! (A)utomatic (P)ersisted (Q)ueries cache. +//! (A)utomatic (P)ersisted (Q)ueries cache. //! -//! For more information on APQ see: -//! +//! For more information on APQ see: +//! -use http::header::CACHE_CONTROL; use http::HeaderValue; use http::StatusCode; +use http::header::CACHE_CONTROL; use serde::Deserialize; -use serde_json_bytes::json; use serde_json_bytes::Value; use sha2::Digest; use sha2::Sha256; @@ -18,6 +17,8 @@ use crate::services::SupergraphResponse; const DONT_CACHE_RESPONSE_VALUE: &str = "private, no-cache, must-revalidate"; static DONT_CACHE_HEADER_VALUE: HeaderValue = HeaderValue::from_static(DONT_CACHE_RESPONSE_VALUE); +pub(crate) const PERSISTED_QUERY_CACHE_HIT: &str = "apollo::apq::cache_hit"; +pub(crate) const PERSISTED_QUERY_REGISTERED: &str = "apollo::apq::registered"; /// A persisted query. #[derive(Deserialize, Clone, Debug)] @@ -47,7 +48,7 @@ impl PersistedQuery { } } -/// [`Layer`] for APQ implementation. +/// A layer-like type implementing Automatic Persisted Queries. #[derive(Clone)] pub(crate) struct APQLayer { /// set to None if APQ is disabled @@ -71,6 +72,20 @@ impl APQLayer { Self { cache: None } } + /// Supergraph service implementation for Automatic Persisted Queries. + /// + /// For more information about APQ: + /// https://www.apollographql.com/docs/apollo-server/performance/apq. + /// + /// If APQ is disabled, it rejects requests that try to use a persisted query hash. + /// If APQ is enabled, requests using APQ will populate the cache and use the cache as needed, + /// see [`apq_request`] for details. + /// + /// This must happen before GraphQL query parsing. + /// + /// This functions similarly to a checkpoint service, short-circuiting the pipeline on error + /// (using an `Err()` return value). + /// The user of this function is responsible for propagating short-circuiting. pub(crate) async fn supergraph_request( &self, request: SupergraphRequest, @@ -82,6 +97,15 @@ impl APQLayer { } } +/// Used when APQ is enabled. +/// +/// If the request contains a hash and a query string, that query is added to the APQ cache. +/// Then, the client can submit only the hash and not the query string on subsequent requests. +/// The request is rejected if the hash does not match the query string. +/// +/// If the request contains only a hash, attempts to read the query from the APQ cache, and +/// populates the query string in the request body. +/// The request is rejected if the hash is not present in the cache. async fn apq_request( cache: &DeduplicatingCache, mut request: SupergraphRequest, @@ -95,7 +119,7 @@ async fn apq_request( (Some((query_hash, query_hash_bytes)), Some(query)) => { if query_matches_hash(query.as_str(), query_hash_bytes.as_slice()) { tracing::trace!("apq: cache insert"); - let _ = request.context.insert("persisted_query_register", true); + let _ = request.context.insert(PERSISTED_QUERY_REGISTERED, true); let query = query.to_owned(); let cache = cache.clone(); tokio::spawn(async move { @@ -104,15 +128,13 @@ async fn apq_request( Ok(request) } else { tracing::debug!("apq: graphql request doesn't match provided sha256Hash"); - let errors = vec![crate::error::Error { - message: "provided sha does not match query".to_string(), - locations: Default::default(), - path: Default::default(), - extensions: serde_json_bytes::from_value(json!({ - "code": "PERSISTED_QUERY_HASH_MISMATCH", - })) - .unwrap(), - }]; + let errors = vec![ + crate::error::Error::builder() + .message("provided sha does not match query".to_string()) + .locations(Default::default()) + .extension_code("PERSISTED_QUERY_HASH_MISMATCH") + .build(), + ]; let res = SupergraphResponse::builder() .status_code(StatusCode::BAD_REQUEST) .data(Value::default()) @@ -130,22 +152,20 @@ async fn apq_request( .get() .await { - let _ = request.context.insert("persisted_query_hit", true); + let _ = request.context.insert(PERSISTED_QUERY_CACHE_HIT, true); tracing::trace!("apq: cache hit"); request.supergraph_request.body_mut().query = Some(cached_query); Ok(request) } else { - let _ = request.context.insert("persisted_query_hit", false); + let _ = request.context.insert(PERSISTED_QUERY_CACHE_HIT, false); tracing::trace!("apq: cache miss"); - let errors = vec![crate::error::Error { - message: "PersistedQueryNotFound".to_string(), - locations: Default::default(), - path: Default::default(), - extensions: serde_json_bytes::from_value(json!({ - "code": "PERSISTED_QUERY_NOT_FOUND", - })) - .unwrap(), - }]; + let errors = vec![ + crate::error::Error::builder() + .message("PersistedQueryNotFound".to_string()) + .locations(Default::default()) + .extension_code("PERSISTED_QUERY_NOT_FOUND") + .build(), + ]; let res = SupergraphResponse::builder() .data(Value::default()) .errors(errors) @@ -180,6 +200,7 @@ pub(crate) fn calculate_hash_for_query(query: &str) -> String { hex::encode(hasher.finalize()) } +/// Used when APQ is disabled. Rejects requests that try to use a persisted query hash anyways. async fn disabled_apq_request( request: SupergraphRequest, ) -> Result { @@ -189,15 +210,13 @@ async fn disabled_apq_request( .extensions .contains_key("persistedQuery") { - let errors = vec![crate::error::Error { - message: "PersistedQueryNotSupported".to_string(), - locations: Default::default(), - path: Default::default(), - extensions: serde_json_bytes::from_value(json!({ - "code": "PERSISTED_QUERY_NOT_SUPPORTED", - })) - .unwrap(), - }]; + let errors = vec![ + crate::error::Error::builder() + .message("PersistedQueryNotSupported".to_string()) + .locations(Default::default()) + .extension_code("PERSISTED_QUERY_NOT_SUPPORTED") + .build(), + ]; let res = SupergraphResponse::builder() .data(Value::default()) .errors(errors) @@ -219,30 +238,27 @@ mod apq_tests { use http::StatusCode; use serde_json_bytes::json; use tower::Service; + use tower::ServiceExt; use super::*; + use crate::Configuration; + use crate::Context; + use crate::assert_error_eq_ignoring_id; use crate::error::Error; - use crate::graphql::Response; + use crate::services::router::ClientRequestAccepts; use crate::services::router::service::from_supergraph_mock_callback; use crate::services::router::service::from_supergraph_mock_callback_and_configuration; - use crate::services::router::ClientRequestAccepts; - use crate::Configuration; - use crate::Context; #[tokio::test] async fn it_works() { let hash = Cow::from("ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"); let hash2 = hash.clone(); - let expected_apq_miss_error = Error { - message: "PersistedQueryNotFound".to_string(), - locations: Default::default(), - path: Default::default(), - extensions: serde_json_bytes::from_value(json!({ - "code": "PERSISTED_QUERY_NOT_FOUND", - })) - .unwrap(), - }; + let expected_apq_miss_error = Error::builder() + .message("PersistedQueryNotFound".to_string()) + .locations(Default::default()) + .extension_code("PERSISTED_QUERY_NOT_FOUND") + .build(); let mut router_service = from_supergraph_mock_callback(move |req| { let body = req.supergraph_request.body(); @@ -281,7 +297,13 @@ mod apq_tests { .expect("expecting valid request") .try_into() .unwrap(); - let apq_response = router_service.call(hash_only).await.unwrap(); + let apq_response = router_service + .ready() + .await + .expect("readied") + .call(hash_only) + .await + .unwrap(); // make sure clients won't cache apq missed response assert_eq!( @@ -297,7 +319,7 @@ mod apq_tests { .unwrap() .unwrap(); - assert_error_matches(&expected_apq_miss_error, apq_error); + assert_error_eq_ignoring_id!(expected_apq_miss_error, apq_error.errors[0]); let with_query = SupergraphRequest::fake_builder() .extension("persistedQuery", persisted.clone()) @@ -308,14 +330,22 @@ mod apq_tests { .try_into() .unwrap(); - let full_response = router_service.call(with_query).await.unwrap(); + let full_response = router_service + .ready() + .await + .expect("readied") + .call(with_query) + .await + .unwrap(); // the cache control header shouldn't have been tampered with - assert!(full_response - .response - .headers() - .get(CACHE_CONTROL) - .is_none()); + assert!( + full_response + .response + .headers() + .get(CACHE_CONTROL) + .is_none() + ); // We need to yield here to make sure the router // runs the Drop implementation of the deduplicating cache Entry. @@ -329,7 +359,13 @@ mod apq_tests { .try_into() .unwrap(); - let apq_response = router_service.call(second_hash_only).await.unwrap(); + let apq_response = router_service + .ready() + .await + .expect("readied") + .call(second_hash_only) + .await + .unwrap(); // the cache control header shouldn't have been tampered with assert!(apq_response.response.headers().get(CACHE_CONTROL).is_none()); @@ -340,15 +376,11 @@ mod apq_tests { let hash = Cow::from("ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b36"); let hash2 = hash.clone(); - let expected_apq_miss_error = Error { - message: "PersistedQueryNotFound".to_string(), - locations: Default::default(), - path: Default::default(), - extensions: serde_json_bytes::from_value(json!({ - "code": "PERSISTED_QUERY_NOT_FOUND", - })) - .unwrap(), - }; + let expected_apq_miss_error = Error::builder() + .message("PersistedQueryNotFound".to_string()) + .locations(Default::default()) + .extension_code("PERSISTED_QUERY_NOT_FOUND") + .build(); let mut router_service = from_supergraph_mock_callback(move |req| { let body = req.supergraph_request.body(); @@ -406,6 +438,9 @@ mod apq_tests { // This apq call will miss the APQ cache let apq_error = router_service + .ready() + .await + .expect("readied") .call(hash_only) .await .unwrap() @@ -416,10 +451,16 @@ mod apq_tests { .unwrap() .unwrap(); - assert_error_matches(&expected_apq_miss_error, apq_error); + assert_error_eq_ignoring_id!(expected_apq_miss_error, apq_error.errors[0]); // sha256 is wrong, apq insert won't happen - let insert_failed_response = router_service.call(with_query).await.unwrap(); + let insert_failed_response = router_service + .ready() + .await + .expect("readied") + .call(with_query) + .await + .unwrap(); assert_eq!( StatusCode::BAD_REQUEST, @@ -433,19 +474,18 @@ mod apq_tests { .await .unwrap() .unwrap(); - let expected_apq_insert_failed_error = Error { - message: "provided sha does not match query".to_string(), - locations: Default::default(), - path: Default::default(), - extensions: serde_json_bytes::from_value(json!({ - "code": "PERSISTED_QUERY_HASH_MISMATCH", - })) - .unwrap(), - }; - assert_eq!(graphql_response.errors[0], expected_apq_insert_failed_error); + let expected_apq_insert_failed_error = Error::builder() + .message("provided sha does not match query".to_string()) + .locations(Default::default()) + .extension_code("PERSISTED_QUERY_HASH_MISMATCH") + .build(); + assert_error_eq_ignoring_id!(expected_apq_insert_failed_error, graphql_response.errors[0]); // apq insert failed, this call will miss let second_apq_error = router_service + .ready() + .await + .expect("readied") .call(second_hash_only) .await .unwrap() @@ -456,20 +496,16 @@ mod apq_tests { .unwrap() .unwrap(); - assert_error_matches(&expected_apq_miss_error, second_apq_error); + assert_error_eq_ignoring_id!(expected_apq_miss_error, second_apq_error.errors[0]); } #[tokio::test] async fn return_not_supported_when_disabled() { - let expected_apq_miss_error = Error { - message: "PersistedQueryNotSupported".to_string(), - locations: Default::default(), - path: Default::default(), - extensions: serde_json_bytes::from_value(json!({ - "code": "PERSISTED_QUERY_NOT_SUPPORTED", - })) - .unwrap(), - }; + let expected_apq_miss_error = Error::builder() + .message("PersistedQueryNotSupported".to_string()) + .locations(Default::default()) + .extension_code("PERSISTED_QUERY_NOT_SUPPORTED") + .build(); let mut config = Configuration::default(); config.apq.enabled = false; @@ -497,7 +533,13 @@ mod apq_tests { .expect("expecting valid request") .try_into() .unwrap(); - let apq_response = router_service.call(hash_only).await.unwrap(); + let apq_response = router_service + .ready() + .await + .expect("readied") + .call(hash_only) + .await + .unwrap(); let apq_error = apq_response .into_graphql_response_stream() @@ -507,7 +549,7 @@ mod apq_tests { .unwrap() .unwrap(); - assert_error_matches(&expected_apq_miss_error, apq_error); + assert_error_eq_ignoring_id!(expected_apq_miss_error, apq_error.errors[0]); let with_query = SupergraphRequest::fake_builder() .extension("persistedQuery", persisted.clone()) @@ -518,7 +560,13 @@ mod apq_tests { .try_into() .unwrap(); - let with_query_response = router_service.call(with_query).await.unwrap(); + let with_query_response = router_service + .ready() + .await + .expect("readied") + .call(with_query) + .await + .unwrap(); let apq_error = with_query_response .into_graphql_response_stream() @@ -528,7 +576,7 @@ mod apq_tests { .unwrap() .unwrap(); - assert_error_matches(&expected_apq_miss_error, apq_error); + assert_error_eq_ignoring_id!(expected_apq_miss_error, apq_error.errors[0]); let without_apq = SupergraphRequest::fake_builder() .query("{__typename}".to_string()) @@ -538,7 +586,13 @@ mod apq_tests { .try_into() .unwrap(); - let without_apq_response = router_service.call(without_apq).await.unwrap(); + let without_apq_response = router_service + .ready() + .await + .expect("readied") + .call(without_apq) + .await + .unwrap(); let without_apq_graphql_response = without_apq_response .into_graphql_response_stream() @@ -551,13 +605,9 @@ mod apq_tests { assert!(without_apq_graphql_response.errors.is_empty()); } - fn assert_error_matches(expected_error: &Error, res: Response) { - assert_eq!(&res.errors[0], expected_error); - } - fn new_context() -> Context { let context = Context::new(); - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { lock.insert(ClientRequestAccepts { json: true, ..Default::default() diff --git a/apollo-router/src/services/layers/content_negotiation.rs b/apollo-router/src/services/layers/content_negotiation.rs index 480cdc1dab..84261a5513 100644 --- a/apollo-router/src/services/layers/content_negotiation.rs +++ b/apollo-router/src/services/layers/content_negotiation.rs @@ -1,17 +1,21 @@ +//! Layers that do HTTP content negotiation using the Accept and Content-Type headers. +//! +//! Content negotiation uses a pair of layers that work together at the router and supergraph stages. + use std::ops::ControlFlow; -use http::header::ACCEPT; -use http::header::CONTENT_TYPE; use http::HeaderMap; use http::Method; use http::StatusCode; +use http::header::ACCEPT; +use http::header::CONTENT_TYPE; +use mediatype::MediaTypeList; +use mediatype::ReadParams; +use mediatype::names::_STAR; use mediatype::names::APPLICATION; use mediatype::names::JSON; use mediatype::names::MIXED; use mediatype::names::MULTIPART; -use mediatype::names::_STAR; -use mediatype::MediaTypeList; -use mediatype::ReadParams; use mime::APPLICATION_JSON; use tower::BoxError; use tower::Layer; @@ -19,13 +23,8 @@ use tower::Service; use tower::ServiceExt; use crate::graphql; -use crate::layers::sync_checkpoint::CheckpointService; use crate::layers::ServiceExt as _; -use crate::services::router; -use crate::services::router::service::MULTIPART_DEFER_CONTENT_TYPE_HEADER_VALUE; -use crate::services::router::service::MULTIPART_SUBSCRIPTION_CONTENT_TYPE_HEADER_VALUE; -use crate::services::router::ClientRequestAccepts; -use crate::services::supergraph; +use crate::layers::sync_checkpoint::CheckpointService; use crate::services::APPLICATION_JSON_HEADER_VALUE; use crate::services::MULTIPART_DEFER_ACCEPT; use crate::services::MULTIPART_DEFER_SPEC_PARAMETER; @@ -33,9 +32,22 @@ use crate::services::MULTIPART_DEFER_SPEC_VALUE; use crate::services::MULTIPART_SUBSCRIPTION_ACCEPT; use crate::services::MULTIPART_SUBSCRIPTION_SPEC_PARAMETER; use crate::services::MULTIPART_SUBSCRIPTION_SPEC_VALUE; +use crate::services::router; +use crate::services::router::ClientRequestAccepts; +use crate::services::router::service::MULTIPART_DEFER_CONTENT_TYPE_HEADER_VALUE; +use crate::services::router::service::MULTIPART_SUBSCRIPTION_CONTENT_TYPE_HEADER_VALUE; +use crate::services::supergraph; pub(crate) const GRAPHQL_JSON_RESPONSE_HEADER_VALUE: &str = "application/graphql-response+json"; -/// [`Layer`] for Content-Type checks implementation. + +/// A layer for the router service that rejects requests that do not have an expected Content-Type, +/// or that have an Accept header that is not supported by the router. +/// +/// In particular, the Content-Type must be JSON, and the Accept header must include */*, or one of +/// the JSON/GraphQL MIME types. +/// +/// # Context +/// If the request is valid, this layer adds a [`ClientRequestAccepts`] value to the context. #[derive(Clone, Default)] pub(crate) struct RouterLayer {} @@ -52,10 +64,10 @@ where if req.router_request.method() != Method::GET && !content_type_is_json(req.router_request.headers()) { - let response: http::Response = http::Response::builder() + let response = http::Response::builder() .status(StatusCode::UNSUPPORTED_MEDIA_TYPE) .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) - .body(crate::services::router::Body::from( + .body(router::body::from_bytes( serde_json::json!({ "errors": [ graphql::Error::builder() @@ -71,17 +83,6 @@ where .to_string(), )) .expect("cannot fail"); - u64_counter!( - "apollo_router_http_requests_total", - "Total number of HTTP requests made.", - 1, - status = StatusCode::UNSUPPORTED_MEDIA_TYPE.as_u16() as i64, - error = format!( - r#"'content-type' header must be one of: {:?} or {:?}"#, - APPLICATION_JSON.essence_str(), - GRAPHQL_JSON_RESPONSE_HEADER_VALUE, - ) - ); return Ok(ControlFlow::Break(response.into())); } @@ -95,12 +96,14 @@ where { req.context .extensions() - .with_lock(|mut lock| lock.insert(accepts)); + .with_lock(|lock| lock.insert(accepts)); Ok(ControlFlow::Continue(req)) } else { - let response: http::Response = http::Response::builder().status(StatusCode::NOT_ACCEPTABLE).header(CONTENT_TYPE, APPLICATION_JSON.essence_str()).body( - hyper::Body::from( + let response = http::Response::builder() + .status(StatusCode::NOT_ACCEPTABLE) + .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .body(router::body::from_bytes( serde_json::json!({ "errors": [ graphql::Error::builder() @@ -114,7 +117,9 @@ where .extension_code("INVALID_ACCEPT_HEADER") .build() ] - }).to_string())).expect("cannot fail"); + }) + .to_string() + )).expect("cannot fail"); Ok(ControlFlow::Break(response.into())) } @@ -124,7 +129,12 @@ where } } -/// [`Layer`] for Content-Type checks implementation. +/// A layer for the supergraph service that populates the Content-Type response header. +/// +/// The content type is decided based on the [`ClientRequestAccepts`] context value, which is +/// populated by the content negotiation [`RouterLayer`]. +// XXX(@goto-bus-stop): this feels a bit odd. It probably works fine because we can only ever respond +// with JSON, but maybe this should be done as close as possible to where we populate the response body..? #[derive(Clone, Default)] pub(crate) struct SupergraphLayer {} @@ -293,5 +303,14 @@ mod tests { default_headers.append(ACCEPT, HeaderValue::from_static(MULTIPART_DEFER_ACCEPT)); let accepts = parse_accept(&default_headers); assert!(accepts.multipart_defer); + + // Multiple accepted types, including one with a parameter we are interested in + let mut default_headers = HeaderMap::new(); + default_headers.insert( + ACCEPT, + HeaderValue::from_static("multipart/mixed;subscriptionSpec=1.0, application/json"), + ); + let accepts = parse_accept(&default_headers); + assert!(accepts.multipart_subscription); } } diff --git a/apollo-router/src/services/layers/persisted_queries/freeform_graphql_behavior.rs b/apollo-router/src/services/layers/persisted_queries/freeform_graphql_behavior.rs new file mode 100644 index 0000000000..05c6e100f5 --- /dev/null +++ b/apollo-router/src/services/layers/persisted_queries/freeform_graphql_behavior.rs @@ -0,0 +1,316 @@ +use std::collections::HashSet; + +use apollo_compiler::ast; + +use super::PersistedQueryManifest; +use crate::Configuration; + +/// Describes whether the router should allow or deny a given request. +/// with an error, or allow it but log the operation as unknown. +pub(crate) struct FreeformGraphQLAction { + pub(crate) should_allow: bool, + pub(crate) should_log: bool, +} + +/// How the router should respond to requests that are not resolved as the IDs +/// of an operation in the manifest. (For the most part this means "requests +/// sent as freeform GraphQL", though it also includes requests sent as an ID +/// that is not found in the PQ manifest but is found in the APQ cache; because +/// you cannot combine APQs with safelisting, this is only relevant in "allow +/// all" and "log unknown" modes.) +#[derive(Debug)] +pub(crate) enum FreeformGraphQLBehavior { + AllowAll { + apq_enabled: bool, + }, + DenyAll { + log_unknown: bool, + }, + AllowIfInSafelist { + safelist: FreeformGraphQLSafelist, + log_unknown: bool, + }, + LogUnlessInSafelist { + safelist: FreeformGraphQLSafelist, + apq_enabled: bool, + }, +} + +impl FreeformGraphQLBehavior { + pub(super) fn action_for_freeform_graphql( + &self, + ast: Result<&ast::Document, &str>, + ) -> FreeformGraphQLAction { + match self { + FreeformGraphQLBehavior::AllowAll { .. } => FreeformGraphQLAction { + should_allow: true, + should_log: false, + }, + // Note that this branch doesn't get called in practice, because we catch + // DenyAll at an earlier phase with never_allows_freeform_graphql. + FreeformGraphQLBehavior::DenyAll { log_unknown, .. } => FreeformGraphQLAction { + should_allow: false, + should_log: *log_unknown, + }, + FreeformGraphQLBehavior::AllowIfInSafelist { + safelist, + log_unknown, + .. + } => { + if safelist.is_allowed(ast) { + FreeformGraphQLAction { + should_allow: true, + should_log: false, + } + } else { + FreeformGraphQLAction { + should_allow: false, + should_log: *log_unknown, + } + } + } + FreeformGraphQLBehavior::LogUnlessInSafelist { safelist, .. } => { + FreeformGraphQLAction { + should_allow: true, + should_log: !safelist.is_allowed(ast), + } + } + } + } +} + +/// The normalized bodies of all operations in the PQ manifest. +/// +/// Normalization currently consists of: +/// - Sorting the top-level definitions (operation and fragment definitions) +/// deterministically. +/// - Printing the AST using apollo-encoder's default formatting (ie, +/// normalizing all ignored characters such as whitespace and comments). +/// +/// Sorting top-level definitions is important because common clients such as +/// Apollo Client Web have modes of use where it is easy to find all the +/// operation and fragment definitions at build time, but challenging to +/// determine what order the client will put them in at run time. +/// +/// Normalizing ignored characters is helpful because being strict on whitespace +/// is more likely to get in your way than to aid in security --- but more +/// importantly, once we're doing any normalization at all, it's much easier to +/// normalize to the default formatting instead of trying to preserve +/// formatting. +#[derive(Debug)] +pub(crate) struct FreeformGraphQLSafelist { + normalized_bodies: HashSet, +} + +impl FreeformGraphQLSafelist { + pub(super) fn new(manifest: &PersistedQueryManifest) -> Self { + let mut safelist = Self { + normalized_bodies: HashSet::new(), + }; + + for body in manifest.values() { + safelist.insert_from_manifest(body); + } + + safelist + } + + fn insert_from_manifest(&mut self, body_from_manifest: &str) { + self.normalized_bodies.insert( + self.normalize_body( + ast::Document::parse(body_from_manifest, "from_manifest") + .as_ref() + .map_err(|_| body_from_manifest), + ), + ); + } + + pub(super) fn is_allowed(&self, ast: Result<&ast::Document, &str>) -> bool { + // Note: consider adding an LRU cache that caches this function's return + // value based solely on body_from_request without needing to normalize + // the body. + self.normalized_bodies.contains(&self.normalize_body(ast)) + } + + pub(super) fn normalize_body(&self, ast: Result<&ast::Document, &str>) -> String { + match ast { + Err(body_from_request) => { + // If we can't parse the operation (whether from the PQ list or the + // incoming request), then we can't normalize it. We keep it around + // unnormalized, so that it at least works as a byte-for-byte + // safelist entry. + body_from_request.to_string() + } + Ok(ast) => { + let mut operations = vec![]; + let mut fragments = vec![]; + + for definition in &ast.definitions { + match definition { + ast::Definition::OperationDefinition(def) => operations.push(def.clone()), + ast::Definition::FragmentDefinition(def) => fragments.push(def.clone()), + _ => {} + } + } + + let mut new_document = ast::Document::new(); + + // First include operation definitions, sorted by name. + operations.sort_by_key(|x| x.name.clone()); + new_document + .definitions + .extend(operations.into_iter().map(Into::into)); + + // Next include fragment definitions, sorted by name. + fragments.sort_by_key(|x| x.name.clone()); + new_document + .definitions + .extend(fragments.into_iter().map(Into::into)); + new_document.to_string() + } + } + } +} + +/// Determine behavior based on PQ configuration +pub(super) fn get_freeform_graphql_behavior( + config: &Configuration, + new_manifest: &PersistedQueryManifest, +) -> FreeformGraphQLBehavior { + if config.persisted_queries.safelist.enabled { + if config.persisted_queries.safelist.require_id { + FreeformGraphQLBehavior::DenyAll { + log_unknown: config.persisted_queries.log_unknown, + } + } else { + FreeformGraphQLBehavior::AllowIfInSafelist { + safelist: FreeformGraphQLSafelist::new(new_manifest), + log_unknown: config.persisted_queries.log_unknown, + } + } + } else if config.persisted_queries.log_unknown { + FreeformGraphQLBehavior::LogUnlessInSafelist { + safelist: FreeformGraphQLSafelist::new(new_manifest), + apq_enabled: config.apq.enabled, + } + } else { + FreeformGraphQLBehavior::AllowAll { + apq_enabled: config.apq.enabled, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::configuration::Apq; + use crate::configuration::PersistedQueries; + use crate::configuration::PersistedQueriesSafelist; + use crate::services::layers::persisted_queries::manifest::ManifestOperation; + + #[test] + fn safelist_body_normalization() { + let safelist = FreeformGraphQLSafelist::new(&PersistedQueryManifest::from(vec![ + ManifestOperation { + id: "valid-syntax".to_string(), + body: "fragment A on T { a } query SomeOp { ...A ...B } fragment,,, B on U{b c } # yeah".to_string(), + client_name: None, + }, + ManifestOperation { + id: "invalid-syntax".to_string(), + body: "}}}".to_string(), + client_name: None, + }, + ManifestOperation { + id: "multiple-ops".to_string(), + body: "query Op1 { a b } query Op2 { b a }".to_string(), + client_name: None, + }, + ])); + + let is_allowed = |body: &str| -> bool { + safelist.is_allowed(ast::Document::parse(body, "").as_ref().map_err(|_| body)) + }; + + // Precise string matches. + assert!(is_allowed( + "fragment A on T { a } query SomeOp { ...A ...B } fragment,,, B on U{b c } # yeah" + )); + + // Reordering definitions and reformatting a bit matches. + assert!(is_allowed( + "#comment\n fragment, B on U , { b c } query SomeOp { ...A ...B } fragment \nA on T { a }" + )); + + // Reordering operation definitions matches + assert!(is_allowed("query Op2 { b a } query Op1 { a b }")); + + // Reordering fields does not match! + assert!(!is_allowed( + "fragment A on T { a } query SomeOp { ...A ...B } fragment,,, B on U{c b } # yeah" + )); + + // Documents with invalid syntax don't match... + assert!(!is_allowed("}}}}")); + + // ... unless they precisely match a safelisted document that also has invalid syntax. + assert!(is_allowed("}}}")); + } + + fn freeform_behavior_from_pq_options( + safe_list: bool, + require_id: Option, + log_unknown: Option, + ) -> FreeformGraphQLBehavior { + let manifest = &PersistedQueryManifest::from(vec![ManifestOperation { + id: "valid-syntax".to_string(), + body: "query SomeOp { a b }".to_string(), + client_name: None, + }]); + + let config = Configuration::builder() + .persisted_query( + PersistedQueries::builder() + .enabled(true) + .safelist( + PersistedQueriesSafelist::builder() + .enabled(safe_list) + .require_id(require_id.unwrap_or_default()) + .build(), + ) + .log_unknown(log_unknown.unwrap_or_default()) + .build(), + ) + .apq(Apq::fake_new(Some(false))) + .build() + .unwrap(); + get_freeform_graphql_behavior(&config, manifest) + } + + #[test] + fn test_get_freeform_graphql_behavior() { + // safelist disabled + assert!(matches!( + freeform_behavior_from_pq_options(false, None, None), + FreeformGraphQLBehavior::AllowAll { .. } + )); + + // safelist disabled, log_unknown enabled + assert!(matches!( + freeform_behavior_from_pq_options(false, None, Some(true)), + FreeformGraphQLBehavior::LogUnlessInSafelist { .. } + )); + + // safelist enabled, id required + assert!(matches!( + freeform_behavior_from_pq_options(true, Some(true), None), + FreeformGraphQLBehavior::DenyAll { .. } + )); + + // safelist enabled, id not required + assert!(matches!( + freeform_behavior_from_pq_options(true, None, None), + FreeformGraphQLBehavior::AllowIfInSafelist { .. } + )); + } +} diff --git a/apollo-router/src/services/layers/persisted_queries/id_extractor.rs b/apollo-router/src/services/layers/persisted_queries/id_extractor.rs index fcd1b9b0fe..0ef7556b57 100644 --- a/apollo-router/src/services/layers/persisted_queries/id_extractor.rs +++ b/apollo-router/src/services/layers/persisted_queries/id_extractor.rs @@ -1,7 +1,7 @@ //! Persisted Query ID extractor -use crate::services::layers::apq::PersistedQuery; use crate::services::SupergraphRequest; +use crate::services::layers::apq::PersistedQuery; #[derive(Debug, Clone)] pub(crate) struct PersistedQueryIdExtractor; diff --git a/apollo-router/src/services/layers/persisted_queries/manifest.rs b/apollo-router/src/services/layers/persisted_queries/manifest.rs new file mode 100644 index 0000000000..9070160566 --- /dev/null +++ b/apollo-router/src/services/layers/persisted_queries/manifest.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; +use std::ops::Deref; +use std::ops::DerefMut; + +use serde::Deserialize; +use serde::Serialize; +use tower::BoxError; + +/// The full identifier for an operation in a PQ list consists of an operation +/// ID and an optional client name. +#[derive(Debug, Clone, Eq, Hash, PartialEq)] +pub struct FullPersistedQueryOperationId { + /// The operation ID (usually a hash). + pub operation_id: String, + /// The client name associated with the operation; if None, can be any client. + pub client_name: Option, +} + +/// A single operation containing an ID and a body, +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ManifestOperation { + /// The operation ID (usually a hash). + pub id: String, + /// The operation body. + pub body: String, + /// The client name associated with the operation. If None, can be any client. + pub client_name: Option, +} + +/// The format of each persisted query chunk returned from uplink. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub(crate) struct SignedUrlChunk { + pub(crate) format: String, + pub(crate) version: u64, + pub(crate) operations: Vec, +} + +impl SignedUrlChunk { + pub(crate) fn validate(self) -> Result { + if self.format != "apollo-persisted-query-manifest" { + return Err("chunk format is not 'apollo-persisted-query-manifest'".into()); + } + + if self.version != 1 { + return Err("persisted query manifest chunk version is not 1".into()); + } + + Ok(self) + } + + pub(crate) fn parse_and_validate(raw_chunk: &str) -> Result { + let parsed_chunk = + serde_json::from_str::(raw_chunk).map_err(|e| -> BoxError { + format!("Could not parse persisted query manifest chunk: {e}").into() + })?; + + parsed_chunk.validate() + } +} + +/// An in memory cache of persisted queries. +// pub type PersistedQueryManifest = HashMap; +#[derive(Debug, Clone, Default)] +pub struct PersistedQueryManifest { + inner: HashMap, +} + +impl PersistedQueryManifest { + /// Add a chunk to the manifest. + pub(crate) fn add_chunk(&mut self, chunk: &SignedUrlChunk) { + for operation in &chunk.operations { + self.inner.insert( + FullPersistedQueryOperationId { + operation_id: operation.id.clone(), + client_name: operation.client_name.clone(), + }, + operation.body.clone(), + ); + } + } +} + +impl From> for PersistedQueryManifest { + fn from(operations: Vec) -> Self { + let mut manifest = PersistedQueryManifest::default(); + for operation in operations { + manifest.insert( + FullPersistedQueryOperationId { + operation_id: operation.id, + client_name: operation.client_name, + }, + operation.body, + ); + } + manifest + } +} + +impl Deref for PersistedQueryManifest { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for PersistedQueryManifest { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} diff --git a/apollo-router/src/services/layers/persisted_queries/manifest_poller.rs b/apollo-router/src/services/layers/persisted_queries/manifest_poller.rs index 7051ec97be..3c2ae28fd8 100644 --- a/apollo-router/src/services/layers/persisted_queries/manifest_poller.rs +++ b/apollo-router/src/services/layers/persisted_queries/manifest_poller.rs @@ -1,194 +1,29 @@ //! Persisted query manifest poller. Once created, will poll for updates continuously, reading persisted queries into memory. use std::collections::HashMap; -use std::collections::HashSet; +use std::pin::Pin; use std::sync::Arc; -use std::sync::RwLock; use apollo_compiler::ast; use futures::prelude::*; +use parking_lot::RwLock; use reqwest::Client; -use serde::Deserialize; -use serde::Serialize; use tokio::fs::read_to_string; use tokio::sync::mpsc; use tower::BoxError; +use super::freeform_graphql_behavior::FreeformGraphQLAction; +use super::freeform_graphql_behavior::FreeformGraphQLBehavior; +use super::freeform_graphql_behavior::get_freeform_graphql_behavior; +use super::manifest::FullPersistedQueryOperationId; +use super::manifest::PersistedQueryManifest; +use super::manifest::SignedUrlChunk; +use crate::Configuration; +use crate::uplink::UplinkConfig; use crate::uplink::persisted_queries_manifest_stream::MaybePersistedQueriesManifestChunks; use crate::uplink::persisted_queries_manifest_stream::PersistedQueriesManifestChunk; use crate::uplink::persisted_queries_manifest_stream::PersistedQueriesManifestQuery; use crate::uplink::stream_from_uplink_transforming_new_response; -use crate::uplink::UplinkConfig; -use crate::Configuration; - -/// An in memory cache of persisted queries. -pub(crate) type PersistedQueryManifest = HashMap; - -/// How the router should respond to requests that are not resolved as the IDs -/// of an operation in the manifest. (For the most part this means "requests -/// sent as freeform GraphQL", though it also includes requests sent as an ID -/// that is not found in the PQ manifest but is found in the APQ cache; because -/// you cannot combine APQs with safelisting, this is only relevant in "allow -/// all" and "log unknown" modes.) -#[derive(Debug)] -pub(crate) enum FreeformGraphQLBehavior { - AllowAll { - apq_enabled: bool, - }, - DenyAll { - log_unknown: bool, - }, - AllowIfInSafelist { - safelist: FreeformGraphQLSafelist, - log_unknown: bool, - }, - LogUnlessInSafelist { - safelist: FreeformGraphQLSafelist, - apq_enabled: bool, - }, -} - -/// Describes what the router should do for a given request: allow it, deny it -/// with an error, or allow it but log the operation as unknown. -pub(crate) enum FreeformGraphQLAction { - Allow, - Deny, - AllowAndLog, - DenyAndLog, -} - -impl FreeformGraphQLBehavior { - fn action_for_freeform_graphql( - &self, - ast: Result<&ast::Document, &str>, - ) -> FreeformGraphQLAction { - match self { - FreeformGraphQLBehavior::AllowAll { .. } => FreeformGraphQLAction::Allow, - // Note that this branch doesn't get called in practice, because we catch - // DenyAll at an earlier phase with never_allows_freeform_graphql. - FreeformGraphQLBehavior::DenyAll { log_unknown, .. } => { - if *log_unknown { - FreeformGraphQLAction::DenyAndLog - } else { - FreeformGraphQLAction::Deny - } - } - FreeformGraphQLBehavior::AllowIfInSafelist { - safelist, - log_unknown, - .. - } => { - if safelist.is_allowed(ast) { - FreeformGraphQLAction::Allow - } else if *log_unknown { - FreeformGraphQLAction::DenyAndLog - } else { - FreeformGraphQLAction::Deny - } - } - FreeformGraphQLBehavior::LogUnlessInSafelist { safelist, .. } => { - if safelist.is_allowed(ast) { - FreeformGraphQLAction::Allow - } else { - FreeformGraphQLAction::AllowAndLog - } - } - } - } -} - -/// The normalized bodies of all operations in the PQ manifest. -/// -/// Normalization currently consists of: -/// - Sorting the top-level definitions (operation and fragment definitions) -/// deterministically. -/// - Printing the AST using apollo-encoder's default formatting (ie, -/// normalizing all ignored characters such as whitespace and comments). -/// -/// Sorting top-level definitions is important because common clients such as -/// Apollo Client Web have modes of use where it is easy to find all the -/// operation and fragment definitions at build time, but challenging to -/// determine what order the client will put them in at run time. -/// -/// Normalizing ignored characters is helpful because being strict on whitespace -/// is more likely to get in your way than to aid in security --- but more -/// importantly, once we're doing any normalization at all, it's much easier to -/// normalize to the default formatting instead of trying to preserve -/// formatting. -#[derive(Debug)] -pub(crate) struct FreeformGraphQLSafelist { - normalized_bodies: HashSet, -} - -impl FreeformGraphQLSafelist { - fn new(manifest: &PersistedQueryManifest) -> Self { - let mut safelist = Self { - normalized_bodies: HashSet::new(), - }; - - for body in manifest.values() { - safelist.insert_from_manifest(body); - } - - safelist - } - - fn insert_from_manifest(&mut self, body_from_manifest: &str) { - self.normalized_bodies.insert( - self.normalize_body( - ast::Document::parse(body_from_manifest, "from_manifest") - .as_ref() - .map_err(|_| body_from_manifest), - ), - ); - } - - fn is_allowed(&self, ast: Result<&ast::Document, &str>) -> bool { - // Note: consider adding an LRU cache that caches this function's return - // value based solely on body_from_request without needing to normalize - // the body. - self.normalized_bodies.contains(&self.normalize_body(ast)) - } - - fn normalize_body(&self, ast: Result<&ast::Document, &str>) -> String { - match ast { - Err(body_from_request) => { - // If we can't parse the operation (whether from the PQ list or the - // incoming request), then we can't normalize it. We keep it around - // unnormalized, so that it at least works as a byte-for-byte - // safelist entry. - body_from_request.to_string() - } - Ok(ast) => { - let mut operations = vec![]; - let mut fragments = vec![]; - - for definition in &ast.definitions { - match definition { - ast::Definition::OperationDefinition(def) => operations.push(def.clone()), - ast::Definition::FragmentDefinition(def) => fragments.push(def.clone()), - _ => {} - } - } - - let mut new_document = ast::Document::new(); - - // First include operation definitions, sorted by name. - operations.sort_by_key(|x| x.name.clone()); - new_document - .definitions - .extend(operations.into_iter().map(Into::into)); - - // Next include fragment definitions, sorted by name. - fragments.sort_by_key(|x| x.name.clone()); - new_document - .definitions - .extend(fragments.into_iter().map(Into::into)); - new_document.to_string() - } - } - } -} #[derive(Debug)] pub(crate) struct PersistedQueryManifestPollerState { @@ -208,157 +43,73 @@ impl PersistedQueryManifestPoller { /// Starts polling immediately and this function only returns after all chunks have been fetched /// and the [`PersistedQueryManifest`] has been fully populated. pub(crate) async fn new(config: Configuration) -> Result { - if let Some(manifest_files) = config.persisted_queries.experimental_local_manifests { - if manifest_files.is_empty() { - return Err("no local persisted query list files specified".into()); - } - let mut manifest: HashMap = PersistedQueryManifest::new(); - - for local_pq_list in manifest_files { - tracing::info!( - "Loading persisted query list from local file: {}", - local_pq_list - ); - - let local_manifest: String = - read_to_string(local_pq_list.clone()) - .await - .map_err(|e| -> BoxError { - format!( - "could not read local persisted query list file {}: {}", - local_pq_list, e - ) - .into() - })?; - - let manifest_file: SignedUrlChunk = - serde_json::from_str(&local_manifest).map_err(|e| -> BoxError { - format!( - "could not parse local persisted query list file {}: {}", - local_pq_list.clone(), - e - ) - .into() - })?; - - if manifest_file.format != "apollo-persisted-query-manifest" { - return Err("chunk format is not 'apollo-persisted-query-manifest'".into()); - } - - if manifest_file.version != 1 { - return Err("persisted query manifest chunk version is not 1".into()); - } - - for operation in manifest_file.operations { - manifest.insert(operation.id, operation.body); - } - } - - let freeform_graphql_behavior = if config.persisted_queries.safelist.enabled { - if config.persisted_queries.safelist.require_id { - FreeformGraphQLBehavior::DenyAll { - log_unknown: config.persisted_queries.log_unknown, - } - } else { - FreeformGraphQLBehavior::AllowIfInSafelist { - safelist: FreeformGraphQLSafelist::new(&manifest), - log_unknown: config.persisted_queries.log_unknown, - } - } - } else if config.persisted_queries.log_unknown { - FreeformGraphQLBehavior::LogUnlessInSafelist { - safelist: FreeformGraphQLSafelist::new(&manifest), - apq_enabled: config.apq.enabled, - } - } else { - FreeformGraphQLBehavior::AllowAll { - apq_enabled: config.apq.enabled, - } - }; - - let state = Arc::new(RwLock::new(PersistedQueryManifestPollerState { - persisted_query_manifest: manifest.clone(), - freeform_graphql_behavior, - })); - - tracing::info!( - "Loaded {} persisted queries from local file.", - manifest.len() - ); - - Ok(Self { - state, - _drop_signal: mpsc::channel::<()>(1).0, - }) - } else if let Some(uplink_config) = config.uplink.as_ref() { - // Note that the contents of this Arc will be overwritten by poll_uplink before - // we return from this `new` method, so the particular choice of freeform_graphql_behavior - // here does not matter. (Can we improve this? We could use an Option but then we'd just - // end up `unwrap`ping a lot later. Perhaps MaybeUninit, but that's even worse?) - let state = Arc::new(RwLock::new(PersistedQueryManifestPollerState { - persisted_query_manifest: PersistedQueryManifest::new(), - freeform_graphql_behavior: FreeformGraphQLBehavior::DenyAll { log_unknown: false }, - })); - - let http_client = Client::builder().timeout(uplink_config.timeout).gzip(true).build() - .map_err(|e| -> BoxError { - format!( - "could not initialize HTTP client for fetching persisted queries manifest chunks: {}", - e - ).into() - })?; - - let (_drop_signal, drop_receiver) = mpsc::channel::<()>(1); - let (ready_sender, mut ready_receiver) = - mpsc::channel::(1); - - // start polling uplink for persisted query chunks - tokio::task::spawn(poll_uplink( - uplink_config.clone(), - state.clone(), - config, + let manifest_source = ManifestSource::from_config(&config)?; + let manifest_stream = create_manifest_stream(manifest_source).await?; + + // Initialize state + let state = Arc::new(RwLock::new(PersistedQueryManifestPollerState { + persisted_query_manifest: PersistedQueryManifest::default(), + freeform_graphql_behavior: FreeformGraphQLBehavior::DenyAll { log_unknown: false }, + })); + + // Start the background polling task + let (_drop_signal, drop_receiver) = mpsc::channel::<()>(1); + let (ready_sender, mut ready_receiver) = mpsc::channel::(1); + + let state_clone = state.clone(); + let config_clone = config.clone(); + + tokio::task::spawn(async move { + poll_manifest_stream( + manifest_stream, + state_clone, + config_clone, ready_sender, drop_receiver, - http_client, - )); - - // wait for the uplink poller to report its first success and continue - // or report the error - match ready_receiver.recv().await { - Some(startup_result) => match startup_result { - ManifestPollResultOnStartup::LoadedOperations => (), - ManifestPollResultOnStartup::Err(error) => return Err(error), - }, - None => { - return Err("could not receive ready event for persisted query layer".into()); - } - } + ) + .await; + }); - Ok(Self { + match ready_receiver.recv().await { + Some(ManifestPollResultOnStartup::LoadedOperations) => Ok(Self { state, _drop_signal, - }) - } else { - Err("persisted queries requires Apollo GraphOS. ensure that you have set APOLLO_KEY and APOLLO_GRAPH_REF environment variables".into()) + }), + Some(ManifestPollResultOnStartup::Err(e)) => Err(e), + None => Err("could not receive ready event for persisted query layer".into()), } } - pub(crate) fn get_operation_body(&self, persisted_query_id: &str) -> Option { - let state = self - .state - .read() - .expect("could not acquire read lock on persisted query manifest state"); - state + pub(crate) fn get_operation_body( + &self, + persisted_query_id: &str, + client_name: Option, + ) -> Option { + let state = self.state.read(); + if let Some(body) = state .persisted_query_manifest - .get(persisted_query_id) + .get(&FullPersistedQueryOperationId { + operation_id: persisted_query_id.to_string(), + client_name: client_name.clone(), + }) .cloned() + { + Some(body) + } else if client_name.is_some() { + state + .persisted_query_manifest + .get(&FullPersistedQueryOperationId { + operation_id: persisted_query_id.to_string(), + client_name: None, + }) + .cloned() + } else { + None + } } pub(crate) fn get_all_operations(&self) -> Vec { - let state = self - .state - .read() - .expect("could not acquire read lock on persisted query manifest state"); + let state = self.state.read(); state.persisted_query_manifest.values().cloned().collect() } @@ -366,10 +117,7 @@ impl PersistedQueryManifestPoller { &self, ast: Result<&ast::Document, &str>, ) -> FreeformGraphQLAction { - let state = self - .state - .read() - .expect("could not acquire read lock on persisted query state"); + let state = self.state.read(); state .freeform_graphql_behavior .action_for_freeform_graphql(ast) @@ -378,10 +126,7 @@ impl PersistedQueryManifestPoller { // Some(bool) means "never allows freeform GraphQL, bool is whether or not to log" // None means "sometimes allows freeform GraphQL" pub(crate) fn never_allows_freeform_graphql(&self) -> Option { - let state = self - .state - .read() - .expect("could not acquire read lock on persisted query state"); + let state = self.state.read(); if let FreeformGraphQLBehavior::DenyAll { log_unknown } = state.freeform_graphql_behavior { Some(log_unknown) } else { @@ -395,10 +140,7 @@ impl PersistedQueryManifestPoller { // in a safelisting mode; this function ensures that by only ever returning // true from non-safelisting modes. pub(crate) fn augmenting_apq_with_pre_registration_and_no_safelisting(&self) -> bool { - let state = self - .state - .read() - .expect("could not acquire read lock on persisted query state"); + let state = self.state.read(); match state.freeform_graphql_behavior { FreeformGraphQLBehavior::AllowAll { apq_enabled, .. } | FreeformGraphQLBehavior::LogUnlessInSafelist { apq_enabled, .. } => apq_enabled, @@ -407,162 +149,15 @@ impl PersistedQueryManifestPoller { } } -async fn poll_uplink( - uplink_config: UplinkConfig, - state: Arc>, - config: Configuration, - ready_sender: mpsc::Sender, - mut drop_receiver: mpsc::Receiver<()>, - http_client: Client, -) { - let http_client = http_client.clone(); - let mut uplink_executor = stream::select_all(vec![ - stream_from_uplink_transforming_new_response::< - PersistedQueriesManifestQuery, - MaybePersistedQueriesManifestChunks, - Option, - >(uplink_config.clone(), move |response| { - let http_client = http_client.clone(); - Box::new(Box::pin(async move { - match response { - Some(chunks) => manifest_from_chunks(chunks, http_client) - .await - .map(Some) - .map_err(|err| { - format!("could not download persisted query lists: {}", err).into() - }), - None => Ok(None), - } - })) - }) - .map(|res| match res { - Ok(Some(new_manifest)) => ManifestPollEvent::NewManifest(new_manifest), - Ok(None) => ManifestPollEvent::NoPersistedQueryList { - graph_ref: uplink_config.apollo_graph_ref.clone(), - }, - Err(e) => ManifestPollEvent::Err(e.into()), - }) - .boxed(), - drop_receiver - .recv() - .into_stream() - .filter_map(|res| { - future::ready(match res { - None => Some(ManifestPollEvent::Shutdown), - Some(()) => Some(ManifestPollEvent::Err( - "received message on drop channel in persisted query layer, which never \ - gets sent" - .into(), - )), - }) - }) - .boxed(), - ]) - .take_while(|msg| future::ready(!matches!(msg, ManifestPollEvent::Shutdown))) - .boxed(); - - let mut ready_sender_once = Some(ready_sender); - - while let Some(event) = uplink_executor.next().await { - match event { - ManifestPollEvent::NewManifest(new_manifest) => { - let freeform_graphql_behavior = if config.persisted_queries.safelist.enabled { - if config.persisted_queries.safelist.require_id { - FreeformGraphQLBehavior::DenyAll { - log_unknown: config.persisted_queries.log_unknown, - } - } else { - FreeformGraphQLBehavior::AllowIfInSafelist { - safelist: FreeformGraphQLSafelist::new(&new_manifest), - log_unknown: config.persisted_queries.log_unknown, - } - } - } else if config.persisted_queries.log_unknown { - FreeformGraphQLBehavior::LogUnlessInSafelist { - safelist: FreeformGraphQLSafelist::new(&new_manifest), - apq_enabled: config.apq.enabled, - } - } else { - FreeformGraphQLBehavior::AllowAll { - apq_enabled: config.apq.enabled, - } - }; - - let new_state = PersistedQueryManifestPollerState { - persisted_query_manifest: new_manifest, - freeform_graphql_behavior, - }; - - state - .write() - .map(|mut locked_state| { - *locked_state = new_state; - }) - .expect("could not acquire write lock on persisted query manifest state"); - - send_startup_event_or_log_error( - &mut ready_sender_once, - ManifestPollResultOnStartup::LoadedOperations, - ) - .await; - } - ManifestPollEvent::Err(e) => { - send_startup_event_or_log_error( - &mut ready_sender_once, - ManifestPollResultOnStartup::Err(e), - ) - .await - } - ManifestPollEvent::NoPersistedQueryList { graph_ref } => { - send_startup_event_or_log_error( - &mut ready_sender_once, - ManifestPollResultOnStartup::Err( - format!("no persisted query list found for graph ref {}", &graph_ref) - .into(), - ), - ) - .await - } - // this event is a no-op because we `take_while` on messages that are not this one - ManifestPollEvent::Shutdown => (), - } - } - - async fn send_startup_event_or_log_error( - ready_sender: &mut Option>, - message: ManifestPollResultOnStartup, - ) { - match (ready_sender.take(), message) { - (Some(ready_sender), message) => { - if let Err(e) = ready_sender.send(message).await { - tracing::debug!( - "could not send startup event for the persisted query layer: {e}" - ); - } - } - (None, ManifestPollResultOnStartup::Err(err)) => { - // We've already successfully started up, but we received some sort of error. This doesn't - // need to break our functional router, but we can log in case folks are interested. - tracing::error!( - "error while polling uplink for persisted query manifests: {}", - err - ) - } - // Do nothing in the normal background "new manifest" case. - (None, ManifestPollResultOnStartup::LoadedOperations) => {} - } - } -} - -async fn manifest_from_chunks( +async fn manifest_from_uplink_chunks( new_chunks: Vec, http_client: Client, ) -> Result { - let mut new_persisted_query_manifest = PersistedQueryManifest::new(); + let mut new_persisted_query_manifest = PersistedQueryManifest::default(); tracing::debug!("ingesting new persisted queries: {:?}", &new_chunks); // TODO: consider doing these fetches in parallel for new_chunk in new_chunks { - add_chunk_to_operations( + fetch_chunk_into_manifest( new_chunk, &mut new_persisted_query_manifest, http_client.clone(), @@ -578,18 +173,16 @@ async fn manifest_from_chunks( Ok(new_persisted_query_manifest) } -async fn add_chunk_to_operations( +async fn fetch_chunk_into_manifest( chunk: PersistedQueriesManifestChunk, - operations: &mut PersistedQueryManifest, + manifest: &mut PersistedQueryManifest, http_client: Client, ) -> Result<(), BoxError> { let mut it = chunk.urls.iter().peekable(); while let Some(chunk_url) = it.next() { match fetch_chunk(http_client.clone(), chunk_url).await { Ok(chunk) => { - for operation in chunk.operations { - operations.insert(operation.id, operation.body); - } + manifest.add_chunk(&chunk); return Ok(()); } Err(e) => { @@ -621,40 +214,16 @@ async fn fetch_chunk(http_client: Client, chunk_url: &String) -> Result BoxError { - format!( - "error fetching persisted queries manifest chunk from {}: {}", - chunk_url, e - ) - .into() + format!("error fetching persisted queries manifest chunk from {chunk_url}: {e}").into() })? .json::() .await .map_err(|e| -> BoxError { - format!( - "error reading body of persisted queries manifest chunk from {}: {}", - chunk_url, e - ) - .into() + format!("error reading body of persisted queries manifest chunk from {chunk_url}: {e}") + .into() })?; - if chunk.format != "apollo-persisted-query-manifest" { - return Err("chunk format is not 'apollo-persisted-query-manifest'".into()); - } - - if chunk.version != 1 { - return Err("persisted query manifest chunk version is not 1".into()); - } - - Ok(chunk) -} - -/// Types of events produced by the manifest poller. -#[derive(Debug)] -pub(crate) enum ManifestPollEvent { - NewManifest(PersistedQueryManifest), - NoPersistedQueryList { graph_ref: String }, - Err(BoxError), - Shutdown, + chunk.validate() } /// The result of the first time build of the persisted query manifest. @@ -664,23 +233,196 @@ pub(crate) enum ManifestPollResultOnStartup { Err(BoxError), } -/// The format of each persisted query chunk returned from uplink. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub(crate) struct SignedUrlChunk { - pub(crate) format: String, - pub(crate) version: u64, - pub(crate) operations: Vec, +/// The source of persisted query manifests +#[derive(Debug)] +enum ManifestSource { + LocalStatic(Vec), + LocalHotReload(Vec), + Uplink(UplinkConfig), +} + +impl ManifestSource { + fn from_config(config: &Configuration) -> Result { + let source = if config.persisted_queries.hot_reload { + if let Some(paths) = &config.persisted_queries.local_manifests { + ManifestSource::LocalHotReload(paths.clone()) + } else { + return Err("`persisted_queries.hot_reload` requires `local_manifests`".into()); + } + } else if let Some(paths) = &config.persisted_queries.local_manifests { + ManifestSource::LocalStatic(paths.clone()) + } else if let Some(uplink_config) = config.uplink.as_ref() { + ManifestSource::Uplink(uplink_config.clone()) + } else { + return Err( + "persisted queries requires either local_manifests or Apollo GraphOS configuration" + .into(), + ); + }; + + Ok(source) + } +} + +/// A stream of manifest updates +type ManifestStream = dyn Stream> + Send + 'static; + +async fn create_manifest_stream( + source: ManifestSource, +) -> Result>, BoxError> { + match source { + ManifestSource::LocalStatic(paths) => Ok(stream::once(load_local_manifests(paths)).boxed()), + ManifestSource::LocalHotReload(paths) => Ok(create_hot_reload_stream(paths).boxed()), + ManifestSource::Uplink(uplink_config) => { + let client = Client::builder() + .timeout(uplink_config.timeout) + .gzip(true) + .build()?; + Ok(create_uplink_stream(uplink_config, client).boxed()) + } + } } -/// A single operation containing an ID and a body, -#[derive(Debug, Clone, Deserialize, Serialize)] -pub(crate) struct Operation { - pub(crate) id: String, - pub(crate) body: String, +async fn poll_manifest_stream( + mut manifest_stream: Pin>, + state: Arc>, + config: Configuration, + ready_sender: mpsc::Sender, + mut drop_receiver: mpsc::Receiver<()>, +) { + let mut ready_sender = Some(ready_sender); + + loop { + tokio::select! { + manifest_result = manifest_stream.next() => { + match manifest_result { + Some(Ok(new_manifest)) => { + let operation_count = new_manifest.len(); + let freeform_graphql_behavior = + get_freeform_graphql_behavior(&config, &new_manifest); + + *state.write() = PersistedQueryManifestPollerState { + persisted_query_manifest: new_manifest, + freeform_graphql_behavior, + }; + tracing::info!("persisted query manifest successfully updated ({} operations total)", operation_count); + + if let Some(sender) = ready_sender.take() { + let _ = sender.send(ManifestPollResultOnStartup::LoadedOperations).await; + } + } + Some(Err(e)) => { + if let Some(sender) = ready_sender.take() { + let _ = sender.send(ManifestPollResultOnStartup::Err(e)).await; + } else { + tracing::error!("Error polling manifest: {}", e); + } + } + None => break, + } + } + _ = drop_receiver.recv() => break, + } + } +} + +async fn load_local_manifests(paths: Vec) -> Result { + let mut complete_manifest = PersistedQueryManifest::default(); + + for path in paths.iter() { + let raw_file_contents = read_to_string(path).await.map_err(|e| -> BoxError { + format!("Failed to read persisted query list file at path: {path}, {e}").into() + })?; + + let chunk = SignedUrlChunk::parse_and_validate(&raw_file_contents)?; + complete_manifest.add_chunk(&chunk); + } + + tracing::info!( + "Loaded {} persisted queries from local files.", + complete_manifest.len() + ); + + Ok(complete_manifest) +} + +fn create_uplink_stream( + uplink_config: UplinkConfig, + http_client: Client, +) -> impl Stream> { + stream_from_uplink_transforming_new_response::< + PersistedQueriesManifestQuery, + MaybePersistedQueriesManifestChunks, + Option, + >(uplink_config, move |response| { + let http_client = http_client.clone(); + Box::new(Box::pin(async move { + match response { + Some(chunks) => manifest_from_uplink_chunks(chunks, http_client) + .await + .map(Some) + .map_err(|e| -> BoxError { e }), + None => Ok(None), + } + })) + }) + .filter_map(|result| async move { + match result { + Ok(Some(manifest)) => Some(Ok(manifest)), + Ok(None) => Some(Ok(PersistedQueryManifest::default())), + Err(e) => Some(Err(e.into())), + } + }) +} + +fn create_hot_reload_stream( + paths: Vec, +) -> impl Stream> { + // Create file watchers for each path + let file_watchers = paths.into_iter().map(|raw_path| { + crate::files::watch(std::path::Path::new(&raw_path.clone())).then(move |_| { + let raw_path = raw_path.clone(); + async move { + match read_to_string(&raw_path).await { + Ok(raw_file_contents) => { + match SignedUrlChunk::parse_and_validate(&raw_file_contents) { + Ok(chunk) => Ok((raw_path, chunk)), + Err(e) => Err(e), + } + } + Err(e) => Err(e.into()), + } + } + .boxed() + }) + }); + + // We need to keep track of the local manifest chunks so we can replace them when + // they change. + let mut chunks: HashMap = HashMap::new(); + + // Combine all watchers into a single stream + stream::select_all(file_watchers).map(move |result| { + result.map(|(path, chunk)| { + tracing::info!( + "hot reloading persisted query manifest file at path: {}", + path + ); + chunks.insert(path, chunk); + + let mut manifest = PersistedQueryManifest::default(); + for chunk in chunks.values() { + manifest.add_chunk(chunk); + } + + manifest + }) + }) } #[cfg(test)] mod tests { + use tokio::io::AsyncWriteExt; use url::Url; use super::*; @@ -701,22 +443,24 @@ mod tests { ) .await .unwrap(); - assert_eq!(manifest_manager.get_operation_body(&id), Some(body)) + assert_eq!(manifest_manager.get_operation_body(&id, None), Some(body)) } #[tokio::test(flavor = "multi_thread")] async fn poller_wont_start_without_uplink_connection() { let uplink_endpoint = Url::parse("https://definitely.not.uplink").unwrap(); - assert!(PersistedQueryManifestPoller::new( - Configuration::fake_builder() - .uplink(UplinkConfig::for_tests(Endpoints::fallback(vec![ - uplink_endpoint - ]))) - .build() - .unwrap(), - ) - .await - .is_err()); + assert!( + PersistedQueryManifestPoller::new( + Configuration::fake_builder() + .uplink(UplinkConfig::for_tests(Endpoints::fallback(vec![ + uplink_endpoint + ]))) + .build() + .unwrap(), + ) + .await + .is_err() + ); } #[tokio::test(flavor = "multi_thread")] @@ -734,48 +478,52 @@ mod tests { ) .await .unwrap(); - assert_eq!(manifest_manager.get_operation_body(&id), Some(body)) + assert_eq!(manifest_manager.get_operation_body(&id, None), Some(body)) } - #[test] - fn safelist_body_normalization() { - let safelist = FreeformGraphQLSafelist::new(&PersistedQueryManifest::from([( - "valid-syntax".to_string(), - "fragment A on T { a } query SomeOp { ...A ...B } fragment,,, B on U{b c } # yeah" - .to_string(), - ), ( - "invalid-syntax".to_string(), - "}}}".to_string()), - ])); - - let is_allowed = |body: &str| -> bool { - safelist.is_allowed(ast::Document::parse(body, "").as_ref().map_err(|_| body)) - }; - - // Precise string matches. - assert!(is_allowed( - "fragment A on T { a } query SomeOp { ...A ...B } fragment,,, B on U{b c } # yeah" - )); - - // Reordering definitions and reformatting a bit matches. - assert!(is_allowed( - "#comment\n fragment, B on U , { b c } query SomeOp { ...A ...B } fragment \nA on T { a }" - )); - - // Reordering fields does not match! - assert!(!is_allowed( - "fragment A on T { a } query SomeOp { ...A ...B } fragment,,, B on U{c b } # yeah" - )); + #[tokio::test(flavor = "multi_thread")] + async fn uses_local_manifest() { + let (_, body, _) = fake_manifest(); + let id = "5678".to_string(); - // Documents with invalid syntax don't match... - assert!(!is_allowed("}}}}")); + let manifest_manager = PersistedQueryManifestPoller::new( + Configuration::fake_builder() + .apq(Apq::fake_new(Some(false))) + .persisted_query( + PersistedQueries::builder() + .enabled(true) + .local_manifests(vec![ + "tests/fixtures/persisted-queries-manifest.json".to_string(), + ]) + .build(), + ) + .build() + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(manifest_manager.get_operation_body(&id, None), Some(body)) + } - // ... unless they precisely match a safelisted document that also has invalid syntax. - assert!(is_allowed("}}}")); + #[tokio::test(flavor = "multi_thread")] + async fn hot_reload_config_enforcement() { + let err = PersistedQueryManifestPoller::new( + Configuration::fake_builder() + .apq(Apq::fake_new(Some(false))) + .persisted_query(PersistedQueries::builder().hot_reload(true).build()) + .build() + .unwrap(), + ) + .await + .unwrap_err(); + assert_eq!( + err.to_string(), + "`persisted_queries.hot_reload` requires `local_manifests`" + ); } #[tokio::test(flavor = "multi_thread")] - async fn uses_local_manifest() { + async fn hot_reload_stream_initial_load() { let (_, body, _) = fake_manifest(); let id = "5678".to_string(); @@ -785,9 +533,10 @@ mod tests { .persisted_query( PersistedQueries::builder() .enabled(true) - .experimental_local_manifests(vec![ + .local_manifests(vec![ "tests/fixtures/persisted-queries-manifest.json".to_string(), ]) + .hot_reload(true) .build(), ) .build() @@ -795,6 +544,115 @@ mod tests { ) .await .unwrap(); - assert_eq!(manifest_manager.get_operation_body(&id), Some(body)) + assert_eq!(manifest_manager.get_operation_body(&id, None), Some(body)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn handles_empty_pq_manifest_from_uplink() { + let (_mock_guard, uplink_config) = mock_empty_pq_uplink().await; + let manifest_manager = PersistedQueryManifestPoller::new( + Configuration::fake_builder() + .uplink(uplink_config) + .build() + .unwrap(), + ) + .await + .unwrap(); + + // Should successfully start up with empty manifest + assert_eq!(manifest_manager.get_all_operations().len(), 0); + } + + #[tokio::test(flavor = "multi_thread")] + async fn hot_reload_stream_reloads_on_file_change() { + const FIXTURE_PATH: &str = "tests/fixtures/persisted-queries-manifest-hot-reload.json"; + // Note: this directly matches the contents of the file in the fixtures + // directory in order to ensure the fixture is restored after we modify + // it for this test. + const ORIGINAL_MANIFEST_CONTENTS: &str = r#"{ + "format": "apollo-persisted-query-manifest", + "version": 1, + "operations": [ + { + "id": "5678", + "name": "typename", + "type": "query", + "body": "query { typename }" + } + ] +} +"#; + + const UPDATED_MANIFEST_CONTENTS: &str = r#"{ + "format": "apollo-persisted-query-manifest", + "version": 1, + "operations": [ + { + "id": "1234", + "name": "typename", + "type": "query", + "body": "query { typename }" + } + ] +} +"#; + + let original_id = "5678".to_string(); + let updated_id = "1234".to_string(); + let body = "query { typename }".to_string(); + + let manifest_manager = PersistedQueryManifestPoller::new( + Configuration::fake_builder() + .apq(Apq::fake_new(Some(false))) + .persisted_query( + PersistedQueries::builder() + .enabled(true) + .local_manifests(vec![FIXTURE_PATH.to_string()]) + .hot_reload(true) + .build(), + ) + .build() + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + manifest_manager.get_operation_body(&original_id, None), + Some(body.clone()) + ); + + // Change the file + let mut file = tokio::fs::File::create(FIXTURE_PATH).await.unwrap(); + file.write_all(UPDATED_MANIFEST_CONTENTS.as_bytes()) + .await + .unwrap(); + file.sync_all().await.unwrap(); + + // Wait for the file to be reloaded + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + // The original ID should be gone + assert_eq!( + manifest_manager.get_operation_body(&original_id, None), + None + ); + // The updated ID should be present + assert_eq!( + manifest_manager.get_operation_body(&updated_id, None), + Some(body.clone()) + ); + + // Cleanup, restore the original file + let mut file = tokio::fs::File::create(FIXTURE_PATH).await.unwrap(); + file.write_all(ORIGINAL_MANIFEST_CONTENTS.as_bytes()) + .await + .unwrap(); + file.sync_all().await.unwrap(); + + // Ensure the restoration is successful + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + assert_eq!( + manifest_manager.get_operation_body(&original_id, None), + Some(body) + ); } } diff --git a/apollo-router/src/services/layers/persisted_queries/mod.rs b/apollo-router/src/services/layers/persisted_queries/mod.rs index 0444590958..3150ab7c1f 100644 --- a/apollo-router/src/services/layers/persisted_queries/mod.rs +++ b/apollo-router/src/services/layers/persisted_queries/mod.rs @@ -1,27 +1,52 @@ +//! Implements support for persisted queries and safelisting at the supergraph service stage. + +mod freeform_graphql_behavior; mod id_extractor; +mod manifest; mod manifest_poller; #[cfg(test)] use std::sync::Arc; -use http::header::CACHE_CONTROL; use http::HeaderValue; use http::StatusCode; +use http::header::CACHE_CONTROL; use id_extractor::PersistedQueryIdExtractor; +pub use manifest::FullPersistedQueryOperationId; +pub use manifest::ManifestOperation; +pub use manifest::PersistedQueryManifest; pub(crate) use manifest_poller::PersistedQueryManifestPoller; use tower::BoxError; -use self::manifest_poller::FreeformGraphQLAction; use super::query_analysis::ParsedDocument; +use crate::Configuration; use crate::graphql::Error as GraphQLError; +use crate::plugins::telemetry::CLIENT_NAME; use crate::services::SupergraphRequest; use crate::services::SupergraphResponse; -use crate::Configuration; const DONT_CACHE_RESPONSE_VALUE: &str = "private, no-cache, must-revalidate"; +const PERSISTED_QUERIES_CLIENT_NAME_CONTEXT_KEY: &str = "apollo_persisted_queries::client_name"; +const PERSISTED_QUERIES_SAFELIST_SKIP_ENFORCEMENT_CONTEXT_KEY: &str = + "apollo_persisted_queries::safelist::skip_enforcement"; + +/// Used to identify requests that were expanded from a persisted query ID +#[derive(Clone)] +pub(crate) struct UsedQueryIdFromManifest { + pub(crate) pq_id: String, +} -struct UsedQueryIdFromManifest; - +/// Implements persisted query support, namely expanding requests using persisted query IDs and +/// filtering free-form GraphQL requests based on router configuration. +/// +/// Despite the name, this is not really in any way a layer today. +/// +/// This type actually consists of two conceptual layers that must both be applied at the supergraph +/// service stage, at different points: +/// - [PersistedQueryLayer::supergraph_request] must be done *before* the GraphQL request is parsed +/// and validated. +/// - [PersistedQueryLayer::supergraph_request_with_analyzed_query] must be done *after* the +/// GraphQL request is parsed and validated. #[derive(Debug)] pub(crate) struct PersistedQueryLayer { /// Manages polling uplink for persisted queries and caches the current @@ -30,6 +55,14 @@ pub(crate) struct PersistedQueryLayer { introspection_enabled: bool, } +fn skip_enforcement(request: &SupergraphRequest) -> bool { + request + .context + .get(PERSISTED_QUERIES_SAFELIST_SKIP_ENFORCEMENT_CONTEXT_KEY) + .unwrap_or_default() + .unwrap_or(false) +} + impl PersistedQueryLayer { /// Create a new [`PersistedQueryLayer`] from CLI options, YAML configuration, /// and optionally, an existing persisted query manifest poller. @@ -49,11 +82,18 @@ impl PersistedQueryLayer { } } - /// Run a request through the layer. + /// Handles pre-parsing work for requests using persisted queries. + /// /// Takes care of: /// 1) resolving a persisted query ID to a query body - /// 2) matching a freeform GraphQL request against persisted queries, optionally rejecting it based on configuration - /// 3) continuing to the next stage of the router + /// 2) rejecting free-form GraphQL requests if they are never allowed by configuration. + /// Matching against safelists is done later in + /// [`PersistedQueryLayer::supergraph_request_with_analyzed_query`]. + /// + /// This functions similarly to a checkpoint service, short-circuiting the pipeline on error + /// (using an `Err()` return value). + /// The user of this function is responsible for propagating short-circuiting. + #[allow(clippy::result_large_err)] pub(crate) fn supergraph_request( &self, request: SupergraphRequest, @@ -65,12 +105,21 @@ impl PersistedQueryLayer { manifest_poller, &persisted_query_id, ) + } else if skip_enforcement(&request) { + // A plugin told us to allow this, so let's skip to require_id check. + Ok(request) } else if let Some(log_unknown) = manifest_poller.never_allows_freeform_graphql() { // If we don't have an ID and we require an ID, return an error immediately, - if log_unknown { - if let Some(operation_body) = request.supergraph_request.body().query.as_ref() { - log_unknown_operation(operation_body); - } + if log_unknown + && let Some(operation_body) = request.supergraph_request.body().query.as_ref() + { + // Note: it's kind of inconsistent that if we require + // IDs and skip_enforcement is set, we don't call + // log_unknown_operation on freeform GraphQL, but if we + // *don't* require IDs and skip_enforcement is set, we + // *do* call log_unknown_operation on unknown + // operations. + log_unknown_operation(operation_body, false); } Err(supergraph_err_pq_id_required(request)) } else { @@ -87,6 +136,7 @@ impl PersistedQueryLayer { } /// Places an operation body on a [`SupergraphRequest`] if it has been persisted + #[allow(clippy::result_large_err)] pub(crate) fn replace_query_id_with_operation_body( &self, mut request: SupergraphRequest, @@ -110,19 +160,37 @@ impl PersistedQueryLayer { } else { // if there is no query, look up the persisted query in the manifest // and put the body on the `supergraph_request` - if let Some(persisted_query_body) = - manifest_poller.get_operation_body(persisted_query_id) - { + if let Some(persisted_query_body) = manifest_poller.get_operation_body( + persisted_query_id, + // Use the first one of these that exists: + // - The PQL-specific context name entry + // `apollo_persisted_queries::client_name` (which can be set + // by router_service plugins) + // - The same name used by telemetry (ie, the value of the + // header named by `telemetry.apollo.client_name_header`, + // which defaults to `apollographql-client-name` by default) + request + .context + .get(PERSISTED_QUERIES_CLIENT_NAME_CONTEXT_KEY) + .unwrap_or_default() + .or_else(|| request.context.get(CLIENT_NAME).unwrap_or_default()), + ) { let body = request.supergraph_request.body_mut(); body.query = Some(persisted_query_body); body.extensions.remove("persistedQuery"); // Record that we actually used our ID, so we can skip the // safelist check later. - request - .context - .extensions() - .with_lock(|mut lock| lock.insert(UsedQueryIdFromManifest)); - tracing::info!(monotonic_counter.apollo.router.operations.persisted_queries = 1u64); + + request.context.extensions().with_lock(|lock| { + lock.insert(UsedQueryIdFromManifest { + pq_id: persisted_query_id.into(), + }) + }); + u64_counter!( + "apollo.router.operations.persisted_queries", + "Total requests with persisted queries enabled", + 1 + ); Ok(request) } else if manifest_poller.augmenting_apq_with_pre_registration_and_no_safelisting() { // The query ID isn't in our manifest, but we have APQ enabled @@ -131,9 +199,11 @@ impl PersistedQueryLayer { // safelist later for log_unknown!) Ok(request) } else { - tracing::info!( - monotonic_counter.apollo.router.operations.persisted_queries = 1u64, - persisted_quieries.not_found = true + u64_counter!( + "apollo.router.operations.persisted_queries", + "Total requests with persisted queries enabled", + 1, + persisted_queries.not_found = true ); // if APQ is not enabled, return an error indicating the query was not found Err(supergraph_err_operation_not_found( @@ -144,6 +214,16 @@ impl PersistedQueryLayer { } } + /// Handles post-GraphQL-parsing work for requests using the persisted queries feature, + /// in particular safelisting. + /// + /// Any request that was expanded by the [`PersistedQueryLayer::supergraph_request`] call is + /// passed through immediately. Free-form GraphQL is matched against safelists and rejected or + /// passed through based on router configuration. + /// + /// This functions similarly to a checkpoint service, short-circuiting the pipeline on error + /// (using an `Err()` return value). + /// The user of this function is responsible for propagating short-circuiting. pub(crate) async fn supergraph_request_with_analyzed_query( &self, request: SupergraphRequest, @@ -207,36 +287,39 @@ impl PersistedQueryLayer { return Ok(request); } - match manifest_poller.action_for_freeform_graphql(Ok(&doc.ast)) { - FreeformGraphQLAction::Allow => { - tracing::info!(monotonic_counter.apollo.router.operations.persisted_queries = 1u64,); - Ok(request) - } - FreeformGraphQLAction::Deny => { - tracing::info!( - monotonic_counter.apollo.router.operations.persisted_queries = 1u64, - persisted_queries.safelist.rejected.unknown = false, - ); - Err(supergraph_err_operation_not_in_safelist(request)) - } - // Note that this might even include complaining about an operation that came via APQs. - FreeformGraphQLAction::AllowAndLog => { - tracing::info!( - monotonic_counter.apollo.router.operations.persisted_queries = 1u64, - persisted_queries.logged = true - ); - log_unknown_operation(operation_body); - Ok(request) - } - FreeformGraphQLAction::DenyAndLog => { - tracing::info!( - monotonic_counter.apollo.router.operations.persisted_queries = 1u64, - persisted_queries.safelist.rejected.unknown = true, - persisted_queries.logged = true - ); - log_unknown_operation(operation_body); - Err(supergraph_err_operation_not_in_safelist(request)) - } + let mut metric_attributes = vec![]; + let freeform_graphql_action = manifest_poller.action_for_freeform_graphql(Ok(&doc.ast)); + let skip_enforcement = skip_enforcement(&request); + let allow = skip_enforcement || freeform_graphql_action.should_allow; + if !allow { + metric_attributes.push(opentelemetry::KeyValue::new( + "persisted_queries.safelist.rejected.unknown".to_string(), + true, + )); + } else if !freeform_graphql_action.should_allow { + metric_attributes.push(opentelemetry::KeyValue::new( + "persisted_queries.safelist.enforcement_skipped".to_string(), + true, + )); + } + if freeform_graphql_action.should_log { + log_unknown_operation(operation_body, skip_enforcement); + metric_attributes.push(opentelemetry::KeyValue::new( + "persisted_queries.logged".to_string(), + true, + )); + } + u64_counter!( + "apollo.router.operations.persisted_queries", + "Total requests with persisted queries enabled", + 1, + metric_attributes + ); + + if allow { + Ok(request) + } else { + Err(supergraph_err_operation_not_in_safelist(request)) } } @@ -247,8 +330,12 @@ impl PersistedQueryLayer { } } -fn log_unknown_operation(operation_body: &str) { - tracing::warn!(message = "unknown operation", operation_body); +fn log_unknown_operation(operation_body: &str, enforcement_skipped: bool) { + tracing::warn!( + message = "unknown operation", + operation_body, + enforcement_skipped + ); } #[derive(Debug, Clone, Eq, PartialEq)] @@ -284,11 +371,19 @@ impl ErrorCacheStrategy { } } -fn graphql_err_operation_not_found(persisted_query_id: &str) -> GraphQLError { - graphql_err( - "PERSISTED_QUERY_NOT_IN_LIST", - &format!("Persisted query '{persisted_query_id}' not found in the persisted query list"), - ) +fn graphql_err_operation_not_found( + persisted_query_id: &str, + operation_name: Option, +) -> GraphQLError { + let mut builder = GraphQLError::builder() + .extension_code("PERSISTED_QUERY_NOT_IN_LIST") + .message(format!( + "Persisted query '{persisted_query_id}' not found in the persisted query list" + )); + if let Some(operation_name) = operation_name { + builder = builder.extension("operation_name", operation_name); + } + builder.build() } fn supergraph_err_operation_not_found( @@ -296,7 +391,10 @@ fn supergraph_err_operation_not_found( persisted_query_id: &str, ) -> SupergraphResponse { supergraph_err( - graphql_err_operation_not_found(persisted_query_id), + graphql_err_operation_not_found( + persisted_query_id, + request.supergraph_request.body().operation_name.clone(), + ), request, ErrorCacheStrategy::DontCache, StatusCode::NOT_FOUND, @@ -338,9 +436,10 @@ fn supergraph_err_operation_not_in_safelist(request: SupergraphRequest) -> Super } fn graphql_err_pq_id_required() -> GraphQLError { - graphql_err("PERSISTED_QUERY_ID_REQUIRED", + graphql_err( + "PERSISTED_QUERY_ID_REQUIRED", "This endpoint does not allow freeform GraphQL requests; operations must be sent by ID in the persisted queries GraphQL extension.", - ) + ) } fn supergraph_err_pq_id_required(request: SupergraphRequest) -> SupergraphResponse { @@ -370,17 +469,23 @@ fn supergraph_err( #[cfg(test)] mod tests { - use std::collections::HashMap; use std::time::Duration; use serde_json::json; + use tracing::instrument::WithSubscriber; + use super::manifest::ManifestOperation; use super::*; + use crate::Context; + use crate::assert_errors_eq_ignoring_id; + use crate::assert_snapshot_subscriber; use crate::configuration::Apq; use crate::configuration::PersistedQueries; use crate::configuration::PersistedQueriesSafelist; use crate::configuration::Supergraph; - use crate::services::layers::persisted_queries::manifest_poller::FreeformGraphQLBehavior; + use crate::graphql; + use crate::metrics::FutureMetricsExt; + use crate::services::layers::persisted_queries::freeform_graphql_behavior::FreeformGraphQLBehavior; use crate::services::layers::query_analysis::QueryAnalysisLayer; use crate::spec::Schema; use crate::test_harness::mocks::persisted_queries::*; @@ -422,14 +527,16 @@ mod tests { let (_mock_guard, uplink_config) = mock_pq_uplink_with_delay(&manifest, delay).await; let now = tokio::time::Instant::now(); - assert!(PersistedQueryManifestPoller::new( - Configuration::fake_builder() - .uplink(uplink_config) - .build() - .unwrap(), - ) - .await - .is_ok()); + assert!( + PersistedQueryManifestPoller::new( + Configuration::fake_builder() + .uplink(uplink_config) + .build() + .unwrap(), + ) + .await + .is_ok() + ); assert!(now.elapsed() >= delay); } @@ -457,12 +564,91 @@ mod tests { assert!(incoming_request.supergraph_request.body().query.is_none()); let result = pq_layer.supergraph_request(incoming_request); - let request = result - .ok() - .expect("pq layer returned response instead of putting the query on the request"); + let request = + result.expect("pq layer returned response instead of putting the query on the request"); assert_eq!(request.supergraph_request.body().query, Some(body)); } + #[tokio::test(flavor = "multi_thread")] + async fn enabled_pq_layer_with_client_names() { + let manifest = PersistedQueryManifest::from(vec![ + ManifestOperation { + id: "both-plain-and-cliented".to_string(), + body: "query { bpac_no_client: __typename }".to_string(), + client_name: None, + }, + ManifestOperation { + id: "both-plain-and-cliented".to_string(), + body: "query { bpac_web_client: __typename }".to_string(), + client_name: Some("web".to_string()), + }, + ManifestOperation { + id: "only-cliented".to_string(), + body: "query { oc_web_client: __typename }".to_string(), + client_name: Some("web".to_string()), + }, + ]); + let (_mock_guard, uplink_config) = mock_pq_uplink(&manifest).await; + + let pq_layer = PersistedQueryLayer::new( + &Configuration::fake_builder() + .persisted_query(PersistedQueries::builder().enabled(true).build()) + .uplink(uplink_config) + .build() + .unwrap(), + ) + .await + .unwrap(); + + let map_to_query = |operation_id: &str, client_name: Option<&str>| -> Option { + let context = Context::new(); + if let Some(client_name) = client_name { + context + .insert( + PERSISTED_QUERIES_CLIENT_NAME_CONTEXT_KEY, + client_name.to_string(), + ) + .unwrap(); + } + + let incoming_request = SupergraphRequest::fake_builder() + .extension( + "persistedQuery", + json!({"version": 1, "sha256Hash": operation_id.to_string()}), + ) + .context(context) + .build() + .unwrap(); + + pq_layer + .supergraph_request(incoming_request) + .expect("pq layer returned response instead of putting the query on the request") + .supergraph_request + .body() + .query + .clone() + }; + + assert_eq!( + map_to_query("both-plain-and-cliented", None), + Some("query { bpac_no_client: __typename }".to_string()) + ); + assert_eq!( + map_to_query("both-plain-and-cliented", Some("not-web")), + Some("query { bpac_no_client: __typename }".to_string()) + ); + assert_eq!( + map_to_query("both-plain-and-cliented", Some("web")), + Some("query { bpac_web_client: __typename }".to_string()) + ); + assert_eq!( + map_to_query("only-cliented", Some("web")), + Some("query { oc_web_client: __typename }".to_string()) + ); + assert_eq!(map_to_query("only-cliented", None), None); + assert_eq!(map_to_query("only-cliented", Some("not-web")), None); + } + #[tokio::test(flavor = "multi_thread")] async fn pq_layer_passes_on_to_apq_layer_when_id_not_found() { let (_id, _body, manifest) = fake_manifest(); @@ -490,9 +676,8 @@ mod tests { assert!(incoming_request.supergraph_request.body().query.is_none()); let result = pq_layer.supergraph_request(incoming_request); - let request = result - .ok() - .expect("pq layer returned response instead of continuing to APQ layer"); + let request = + result.expect("pq layer returned response instead of continuing to APQ layer"); assert!(request.supergraph_request.body().query.is_none()); } @@ -531,9 +716,9 @@ mod tests { .next_response() .await .expect("could not get response from pq layer"); - assert_eq!( + assert_errors_eq_ignoring_id!( response.errors, - vec![graphql_err_operation_not_found(invalid_id)] + [graphql_err_operation_not_found(invalid_id, None)] ); } @@ -550,10 +735,12 @@ mod tests { ) .await .unwrap(); - assert!(pq_layer - .manifest_poller - .unwrap() - .augmenting_apq_with_pre_registration_and_no_safelisting()) + assert!( + pq_layer + .manifest_poller + .unwrap() + .augmenting_apq_with_pre_registration_and_no_safelisting() + ) } #[tokio::test(flavor = "multi_thread")] @@ -569,10 +756,12 @@ mod tests { ) .await .unwrap(); - assert!(!pq_layer - .manifest_poller - .unwrap() - .augmenting_apq_with_pre_registration_and_no_safelisting()) + assert!( + !pq_layer + .manifest_poller + .unwrap() + .augmenting_apq_with_pre_registration_and_no_safelisting() + ) } #[tokio::test(flavor = "multi_thread")] @@ -600,7 +789,6 @@ mod tests { .unwrap() .state .read() - .unwrap() .freeform_graphql_behavior, FreeformGraphQLBehavior::AllowIfInSafelist { .. } )) @@ -610,9 +798,21 @@ mod tests { pq_layer: &PersistedQueryLayer, query_analysis_layer: &QueryAnalysisLayer, body: &str, + skip_enforcement: bool, ) -> SupergraphRequest { + let context = Context::new(); + if skip_enforcement { + context + .insert( + PERSISTED_QUERIES_SAFELIST_SKIP_ENFORCEMENT_CONTEXT_KEY, + true, + ) + .unwrap(); + } + let incoming_request = SupergraphRequest::fake_builder() .query(body) + .context(context) .build() .unwrap(); @@ -622,12 +822,10 @@ mod tests { // the operation. let updated_request = pq_layer .supergraph_request(incoming_request) - .ok() .expect("pq layer returned error response instead of returning a request"); query_analysis_layer .supergraph_request(updated_request) .await - .ok() .expect("QA layer returned error response instead of returning a request") } @@ -635,9 +833,11 @@ mod tests { pq_layer: &PersistedQueryLayer, query_analysis_layer: &QueryAnalysisLayer, body: &str, + log_unknown: bool, + counter_value: u64, ) { let request_with_analyzed_query = - run_first_two_layers(pq_layer, query_analysis_layer, body).await; + run_first_two_layers(pq_layer, query_analysis_layer, body, false).await; let mut supergraph_response = pq_layer .supergraph_request_with_analyzed_query(request_with_analyzed_query) @@ -650,9 +850,21 @@ mod tests { .next_response() .await .expect("could not get response from pq layer"); - assert_eq!( - response.errors, - vec![graphql_err_operation_not_in_safelist()] + assert_errors_eq_ignoring_id!(response.errors, [graphql_err_operation_not_in_safelist()]); + let mut metric_attributes = vec![opentelemetry::KeyValue::new( + "persisted_queries.safelist.rejected.unknown".to_string(), + true, + )]; + if log_unknown { + metric_attributes.push(opentelemetry::KeyValue::new( + "persisted_queries.logged".to_string(), + true, + )); + } + assert_counter!( + "apollo.router.operations.persisted_queries", + counter_value, + &metric_attributes ); } @@ -660,107 +872,181 @@ mod tests { pq_layer: &PersistedQueryLayer, query_analysis_layer: &QueryAnalysisLayer, body: &str, + log_unknown: bool, + skip_enforcement: bool, + counter_value: u64, ) { let request_with_analyzed_query = - run_first_two_layers(pq_layer, query_analysis_layer, body).await; + run_first_two_layers(pq_layer, query_analysis_layer, body, skip_enforcement).await; pq_layer .supergraph_request_with_analyzed_query(request_with_analyzed_query) .await - .ok() .expect("pq layer second hook returned error response instead of returning a request"); + + let mut metric_attributes = vec![]; + if skip_enforcement { + metric_attributes.push(opentelemetry::KeyValue::new( + "persisted_queries.safelist.enforcement_skipped".to_string(), + true, + )); + if log_unknown { + metric_attributes.push(opentelemetry::KeyValue::new( + "persisted_queries.logged".to_string(), + true, + )); + } + } + + assert_counter!( + "apollo.router.operations.persisted_queries", + counter_value, + &metric_attributes + ); } - #[tokio::test(flavor = "multi_thread")] - async fn pq_layer_freeform_graphql_with_safelist() { - let manifest = HashMap::from([( - "valid-syntax".to_string(), - "fragment A on Query { me { id } } query SomeOp { ...A ...B } fragment,,, B on Query{me{name,username} } # yeah" - .to_string(), - ), ( - "invalid-syntax".to_string(), - "}}}".to_string()), - ]); + async fn pq_layer_freeform_graphql_with_safelist(log_unknown: bool) { + async move { + let manifest = PersistedQueryManifest::from(vec![ + ManifestOperation { + id: "valid-syntax".to_string(), + body: "fragment A on Query { me { id } } query SomeOp { ...A ...B } fragment,,, B on Query{me{name,username} } # yeah".to_string(), + client_name: None, + }, + ManifestOperation { + id: "invalid-syntax".to_string(), + body: "}}}".to_string(), + client_name: None, + }, + ]); + + let (_mock_guard, uplink_config) = mock_pq_uplink(&manifest).await; + + let config = Configuration::fake_builder() + .persisted_query( + PersistedQueries::builder() + .enabled(true) + .safelist(PersistedQueriesSafelist::builder().enabled(true).build()) + .log_unknown(log_unknown) + .build(), + ) + .uplink(uplink_config) + .apq(Apq::fake_builder().enabled(false).build()) + .supergraph(Supergraph::fake_builder().introspection(true).build()) + .build() + .unwrap(); - let (_mock_guard, uplink_config) = mock_pq_uplink(&manifest).await; + let pq_layer = PersistedQueryLayer::new(&config).await.unwrap(); - let config = Configuration::fake_builder() - .persisted_query( - PersistedQueries::builder() - .enabled(true) - .safelist(PersistedQueriesSafelist::builder().enabled(true).build()) - .build(), - ) - .uplink(uplink_config) - .apq(Apq::fake_builder().enabled(false).build()) - .supergraph(Supergraph::fake_builder().introspection(true).build()) - .build() - .unwrap(); + let schema = Arc::new(Schema::parse(include_str!("../../../testdata/supergraph.graphql"), &Default::default()).unwrap()); + + let query_analysis_layer = QueryAnalysisLayer::new(schema, Arc::new(config)).await; - let pq_layer = PersistedQueryLayer::new(&config).await.unwrap(); + // A random query is blocked. + denied_by_safelist( + &pq_layer, + &query_analysis_layer, + "query SomeQuery { me { id } }", + log_unknown, + 1, + ).await; - let schema = Arc::new( - Schema::parse( - include_str!("../../../testdata/supergraph.graphql"), - &Default::default(), + // But it is allowed with skip_enforcement set. + allowed_by_safelist( + &pq_layer, + &query_analysis_layer, + "query SomeQuery { me { id } }", + log_unknown, + true, + 1, + ).await; + + // The exact string from the manifest is allowed. + allowed_by_safelist( + &pq_layer, + &query_analysis_layer, + "fragment A on Query { me { id } } query SomeOp { ...A ...B } fragment,,, B on Query{me{name,username} } # yeah", + log_unknown, + false, + 1, ) - .unwrap(), - ); + .await; - let query_analysis_layer = QueryAnalysisLayer::new(schema, Arc::new(config)).await; + // Reordering definitions and reformatting a bit matches. + allowed_by_safelist( + &pq_layer, + &query_analysis_layer, + "#comment\n fragment, B on Query , { me{name username} } query SomeOp { ...A ...B } fragment \nA on Query { me{ id} }", + log_unknown, + false, + 2, + ) + .await; - // A random query is blocked. - denied_by_safelist( - &pq_layer, - &query_analysis_layer, - "query SomeQuery { me { id } }", - ) - .await; + // Reordering fields does not match! + denied_by_safelist( + &pq_layer, + &query_analysis_layer, + "fragment A on Query { me { id } } query SomeOp { ...A ...B } fragment,,, B on Query{me{username,name} } # yeah", + log_unknown, + 2, + ) + .await; - // The exact string from the manifest is allowed. - allowed_by_safelist( - &pq_layer, - &query_analysis_layer, - "fragment A on Query { me { id } } query SomeOp { ...A ...B } fragment,,, B on Query{me{name,username} } # yeah", - ) - .await; + // Introspection queries are allowed (even using fragments and aliases), because + // introspection is enabled. + allowed_by_safelist( + &pq_layer, + &query_analysis_layer, + r#"fragment F on Query { __typename foo: __schema { __typename } } query Q { __type(name: "foo") { name } ...F }"#, + log_unknown, + false, + // Note that introspection queries don't actually interact with the PQ machinery enough + // to update this metric, for better or for worse. + 2, + ) + .await; - // Reordering definitions and reformatting a bit matches. - allowed_by_safelist( + // Multiple spreads of the same fragment are also allowed + // (https://github.com/apollographql/apollo-rs/issues/613) + allowed_by_safelist( &pq_layer, &query_analysis_layer, - "#comment\n fragment, B on Query , { me{name username} } query SomeOp { ...A ...B } fragment \nA on Query { me{ id} }" - ).await; + r#"fragment F on Query { __typename foo: __schema { __typename } } query Q { __type(name: "foo") { name } ...F ...F }"#, + log_unknown, + false, + // Note that introspection queries don't actually interact with the PQ machinery enough + // to update this metric, for better or for worse. + 2, + ) + .await; - // Reordering fields does not match! - denied_by_safelist( + // But adding any top-level non-introspection field is enough to make it not count as introspection. + denied_by_safelist( &pq_layer, &query_analysis_layer, - "fragment A on Query { me { id } } query SomeOp { ...A ...B } fragment,,, B on Query{me{username,name} } # yeah" - ).await; + r#"fragment F on Query { __typename foo: __schema { __typename } me { id } } query Q { __type(name: "foo") { name } ...F }"#, + log_unknown, + 3, + ) + .await; + } + .with_metrics() + .await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn pq_layer_freeform_graphql_with_safelist_log_unknown_false() { + pq_layer_freeform_graphql_with_safelist(false).await; + } - // Introspection queries are allowed (even using fragments and aliases), because - // introspection is enabled. - allowed_by_safelist( - &pq_layer, - &query_analysis_layer, - r#"fragment F on Query { __typename foo: __schema { __typename } } query Q { __type(name: "foo") { name } ...F }"#, - ).await; - - // Multiple spreads of the same fragment are also allowed - // (https://github.com/apollographql/apollo-rs/issues/613) - allowed_by_safelist( - &pq_layer, - &query_analysis_layer, - r#"fragment F on Query { __typename foo: __schema { __typename } } query Q { __type(name: "foo") { name } ...F ...F }"#, - ).await; - - // But adding any top-level non-introspection field is enough to make it not count as introspection. - denied_by_safelist( - &pq_layer, - &query_analysis_layer, - r#"fragment F on Query { __typename foo: __schema { __typename } me { id } } query Q { __type(name: "foo") { name } ...F }"#, - ).await; + #[tokio::test(flavor = "multi_thread")] + async fn pq_layer_freeform_graphql_with_safelist_log_unknown_true() { + async { + pq_layer_freeform_graphql_with_safelist(true).await; + } + .with_subscriber(assert_snapshot_subscriber!()) + .await } #[tokio::test(flavor = "multi_thread")] @@ -791,6 +1077,7 @@ mod tests { "persistedQuery", json!({"version": 1, "sha256Hash": invalid_id}), ) + .operation_name("SomeOperation") .build() .unwrap(); @@ -802,9 +1089,12 @@ mod tests { .next_response() .await .expect("could not get response from pq layer"); - assert_eq!( + assert_errors_eq_ignoring_id!( response.errors, - vec![graphql_err_operation_not_found(invalid_id)] + [graphql_err_operation_not_found( + invalid_id, + Some("SomeOperation".to_string()), + )] ); } @@ -812,17 +1102,19 @@ mod tests { async fn apq_and_pq_safelisting_is_invalid_config() { let (_mock_guard, uplink_config) = mock_empty_pq_uplink().await; let safelist_config = PersistedQueriesSafelist::builder().enabled(true).build(); - assert!(Configuration::fake_builder() - .persisted_query( - PersistedQueries::builder() - .enabled(true) - .safelist(safelist_config) - .build(), - ) - .apq(Apq::fake_builder().enabled(true).build()) - .uplink(uplink_config) - .build() - .is_err()); + assert!( + Configuration::fake_builder() + .persisted_query( + PersistedQueries::builder() + .enabled(true) + .safelist(safelist_config) + .build(), + ) + .apq(Apq::fake_builder().enabled(true).build()) + .uplink(uplink_config) + .build() + .is_err() + ); } #[tokio::test(flavor = "multi_thread")] @@ -850,7 +1142,6 @@ mod tests { .unwrap() .state .read() - .unwrap() .freeform_graphql_behavior, FreeformGraphQLBehavior::AllowIfInSafelist { .. } )) @@ -878,11 +1169,13 @@ mod tests { ) .await .unwrap(); - assert!(pq_layer - .manifest_poller - .unwrap() - .never_allows_freeform_graphql() - .is_some()) + assert!( + pq_layer + .manifest_poller + .unwrap() + .never_allows_freeform_graphql() + .is_some() + ) } #[tokio::test(flavor = "multi_thread")] @@ -923,7 +1216,23 @@ mod tests { .next_response() .await .expect("could not get response from pq layer"); - assert_eq!(response.errors, vec![graphql_err_pq_id_required()]); + assert_errors_eq_ignoring_id!(response.errors, [graphql_err_pq_id_required()]); + + // Try again skipping enforcement. + let context = Context::new(); + context + .insert( + PERSISTED_QUERIES_SAFELIST_SKIP_ENFORCEMENT_CONTEXT_KEY, + true, + ) + .unwrap(); + let incoming_request = SupergraphRequest::fake_builder() + .query("query { typename }") + .context(context) + .build() + .unwrap(); + assert!(incoming_request.supergraph_request.body().query.is_some()); + assert!(pq_layer.supergraph_request(incoming_request).is_ok()); } #[tokio::test(flavor = "multi_thread")] @@ -945,7 +1254,6 @@ mod tests { .unwrap() .state .read() - .unwrap() .freeform_graphql_behavior, FreeformGraphQLBehavior::AllowAll { apq_enabled: false } )) @@ -975,7 +1283,6 @@ mod tests { .unwrap() .state .read() - .unwrap() .freeform_graphql_behavior, FreeformGraphQLBehavior::AllowAll { apq_enabled: true } )) @@ -1037,7 +1344,7 @@ mod tests { .next_response() .await .expect("could not get response from pq layer"); - assert_eq!(response.errors, vec![graphql_err_cannot_send_id_and_body()]); + assert_errors_eq_ignoring_id!(response.errors, [graphql_err_cannot_send_id_and_body()]); } #[tokio::test(flavor = "multi_thread")] @@ -1070,6 +1377,6 @@ mod tests { .next_response() .await .expect("could not get response from pq layer"); - assert_eq!(response.errors, vec![graphql_err_cannot_send_id_and_body()]); + assert_errors_eq_ignoring_id!(response.errors, [graphql_err_cannot_send_id_and_body()]); } } diff --git a/apollo-router/src/services/layers/persisted_queries/snapshots/apollo_router__services__layers__persisted_queries__tests__pq_layer_freeform_graphql_with_safelist_log_unknown_true@logs.snap b/apollo-router/src/services/layers/persisted_queries/snapshots/apollo_router__services__layers__persisted_queries__tests__pq_layer_freeform_graphql_with_safelist_log_unknown_true@logs.snap new file mode 100644 index 0000000000..38bf73a998 --- /dev/null +++ b/apollo-router/src/services/layers/persisted_queries/snapshots/apollo_router__services__layers__persisted_queries__tests__pq_layer_freeform_graphql_with_safelist_log_unknown_true@logs.snap @@ -0,0 +1,27 @@ +--- +source: apollo-router/src/services/layers/persisted_queries/mod.rs +expression: yaml +--- +- fields: {} + level: INFO + message: Loaded 2 persisted queries. +- fields: + enforcement_skipped: false + operation_body: "query SomeQuery { me { id } }" + level: WARN + message: unknown operation +- fields: + enforcement_skipped: true + operation_body: "query SomeQuery { me { id } }" + level: WARN + message: unknown operation +- fields: + enforcement_skipped: false + operation_body: "fragment A on Query { me { id } } query SomeOp { ...A ...B } fragment,,, B on Query{me{username,name} } # yeah" + level: WARN + message: unknown operation +- fields: + enforcement_skipped: false + operation_body: "fragment F on Query { __typename foo: __schema { __typename } me { id } } query Q { __type(name: \"foo\") { name } ...F }" + level: WARN + message: unknown operation diff --git a/apollo-router/src/services/layers/query_analysis.rs b/apollo-router/src/services/layers/query_analysis.rs index 4af57a5f38..c6ee13c872 100644 --- a/apollo-router/src/services/layers/query_analysis.rs +++ b/apollo-router/src/services/layers/query_analysis.rs @@ -1,24 +1,38 @@ +//! Implements GraphQL parsing/validation/usage counting of requests at the supergraph service +//! stage. + use std::collections::HashMap; use std::fmt::Display; use std::fmt::Formatter; use std::hash::Hash; use std::sync::Arc; +use std::sync::OnceLock; +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Name; +use apollo_compiler::Node; use apollo_compiler::ast; use apollo_compiler::executable::Operation; +use apollo_compiler::executable::Selection; +use apollo_compiler::executable::SelectionSet; +use apollo_compiler::response::GraphQLError; use apollo_compiler::validation::Valid; -use apollo_compiler::ExecutableDocument; -use apollo_compiler::Node; use http::StatusCode; use lru::LruCache; -use router_bridge::planner::UsageReporting; use tokio::sync::Mutex; -use tokio::task; +use tracing::Instrument; -use crate::apollo_studio_interop::generate_extended_references; +use crate::Configuration; +use crate::Context; use crate::apollo_studio_interop::ExtendedReferenceStats; +use crate::apollo_studio_interop::UsageReporting; +use crate::apollo_studio_interop::generate_extended_references; +use crate::compute_job; +use crate::compute_job::ComputeJobType; +use crate::compute_job::MaybeBackPressureError; use crate::context::OPERATION_KIND; use crate::context::OPERATION_NAME; +use crate::error::ValidationErrors; use crate::graphql::Error; use crate::graphql::ErrorExtension; use crate::graphql::IntoGraphQLErrors; @@ -26,17 +40,36 @@ use crate::plugins::authorization::AuthorizationPlugin; use crate::plugins::telemetry::config::ApolloMetricsReferenceMode; use crate::plugins::telemetry::config::Conf as TelemetryConfig; use crate::plugins::telemetry::consts::QUERY_PARSING_SPAN_NAME; -use crate::query_planner::fetch::QueryHash; use crate::query_planner::OperationKind; use crate::services::SupergraphRequest; use crate::services::SupergraphResponse; use crate::spec::Query; +use crate::spec::QueryHash; use crate::spec::Schema; use crate::spec::SpecError; -use crate::Configuration; -use crate::Context; -/// [`Layer`] for QueryAnalysis implementation. +const ENV_DISABLE_RECURSIVE_SELECTIONS_CHECK: &str = + "APOLLO_ROUTER_DISABLE_SECURITY_RECURSIVE_SELECTIONS_CHECK"; +/// Should we enforce the recursive selections limit? Default true, can be toggled off with an +/// environment variable. +/// +/// Disabling this check is very much not advisable and we don't expect that anyone will need to do +/// it. In the extremely unlikely case that the new protection breaks someone's legitimate queries, +/// though, they could temporarily disable this individual limit so they can still benefit from the +/// other new limits, until we improve the detection. +pub(crate) fn recursive_selections_check_enabled() -> bool { + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| { + let disabled = + std::env::var(ENV_DISABLE_RECURSIVE_SELECTIONS_CHECK).as_deref() == Ok("true"); + + !disabled + }) +} + +/// A layer-like type that handles several aspects of query parsing and analysis. +/// +/// The supergraph layer implementation is in [QueryAnalysisLayer::supergraph_request]. #[derive(Clone)] #[allow(clippy::type_complexity)] pub(crate) struct QueryAnalysisLayer { @@ -54,6 +87,8 @@ struct QueryAnalysisKey { } impl QueryAnalysisLayer { + const MAX_RECURSIVE_SELECTIONS: u32 = 10_000_000; + pub(crate) async fn new(schema: Arc, configuration: Arc) -> Self { let enable_authorization_directives = AuthorizationPlugin::enable_directives(&configuration, &schema).unwrap_or(false); @@ -75,34 +110,141 @@ impl QueryAnalysisLayer { } } + // XXX(@goto-bus-stop): This is public because query warmup uses it. I think the reason that + // warmup uses this instead of `Query::parse_document` directly is to use the worker pool. pub(crate) async fn parse_document( &self, query: &str, operation_name: Option<&str>, - ) -> Result { + compute_job_type: ComputeJobType, + ) -> Result> { let query = query.to_string(); let operation_name = operation_name.map(|o| o.to_string()); let schema = self.schema.clone(); let conf = self.configuration.clone(); - // Must be created *outside* of the spawn_blocking or the span is not connected to the - // parent + // Must be created *outside* of the compute_job or the span is not connected to the parent let span = tracing::info_span!(QUERY_PARSING_SPAN_NAME, "otel.kind" = "INTERNAL"); - - task::spawn_blocking(move || { - span.in_scope(|| { + let compute_job_future = span.in_scope(||{ + compute_job::execute(compute_job_type, move |_| { Query::parse_document( &query, operation_name.as_deref(), schema.as_ref(), conf.as_ref(), ) + .and_then(|doc| { + let recursive_selections = Self::count_recursive_selections( + &doc.executable, + &mut Default::default(), + &doc.operation.selection_set, + 0, + ); + if recursive_selections.is_none() { + if recursive_selections_check_enabled() { + return Err(SpecError::ValidationError(ValidationErrors { + errors: vec![GraphQLError { + message: + "Maximum recursive selections limit exceeded in this operation" + .to_string(), + locations: Default::default(), + path: Default::default(), + extensions: Default::default(), + }], + })) + } + tracing::info!( + operation_name = ?operation_name, + limit = Self::MAX_RECURSIVE_SELECTIONS, + "operation exceeded maximum recursive selections limit, but limit is forcefully disabled", + ); + } + Ok(doc) + }) }) - }) - .await - .expect("parse_document task panicked") + }); + + compute_job_future + .map_err(MaybeBackPressureError::TemporaryError)? + .instrument(span) + .await + .map_err(MaybeBackPressureError::PermanentError) + } + + /// Measure the number of selections that would be encountered if we walked the given selection + /// set while recursing into fragment spreads, and add it to the given count. `None` is returned + /// instead if this number exceeds `Self::MAX_RECURSIVE_SELECTIONS`. + /// + /// This function assumes that fragments referenced by spreads exist and that they don't form + /// cycles. If a fragment spread appears multiple times for the same named fragment, it is + /// counted multiple times. + fn count_recursive_selections<'a>( + document: &'a Valid, + fragment_cache: &mut HashMap<&'a Name, u32>, + selection_set: &'a SelectionSet, + mut count: u32, + ) -> Option { + for selection in &selection_set.selections { + count = count + .checked_add(1) + .take_if(|v| *v <= Self::MAX_RECURSIVE_SELECTIONS)?; + match selection { + Selection::Field(field) => { + count = Self::count_recursive_selections( + document, + fragment_cache, + &field.selection_set, + count, + )?; + } + Selection::InlineFragment(fragment) => { + count = Self::count_recursive_selections( + document, + fragment_cache, + &fragment.selection_set, + count, + )?; + } + Selection::FragmentSpread(fragment) => { + let name = &fragment.fragment_name; + if let Some(cached) = fragment_cache.get(name) { + count = count + .checked_add(*cached) + .take_if(|v| *v <= Self::MAX_RECURSIVE_SELECTIONS)?; + } else { + let old_count = count; + count = Self::count_recursive_selections( + document, + fragment_cache, + &document + .fragments + .get(&fragment.fragment_name) + .expect("validation should have ensured referenced fragments exist") + .selection_set, + count, + )?; + fragment_cache.insert(name, count - old_count); + }; + } + } + } + Some(count) } + /// Parses the GraphQL in the supergraph request and computes Apollo usage references. + /// + /// This functions similarly to a checkpoint service, short-circuiting the pipeline on error + /// (using an `Err()` return value). + /// The user of this function is responsible for propagating short-circuiting. + /// + /// # Context + /// This stores values in the request context: + /// - [`ParsedDocument`] + /// - [`ExtendedReferenceStats`] + /// - "operation_name" and "operation_kind" + /// - authorization details (required scopes, policies), if any + /// - [`Arc`]`<`[`UsageReporting`]`>` if there was an error; normally, this would be populated + /// by the caching query planner, but we do not reach that code if we fail early here. pub(crate) async fn supergraph_request( &self, request: SupergraphRequest, @@ -110,18 +252,12 @@ impl QueryAnalysisLayer { let query = request.supergraph_request.body().query.as_ref(); if query.is_none() || query.unwrap().trim().is_empty() { - let errors = vec![crate::error::Error::builder() - .message("Must provide query string.".to_string()) - .extension_code("MISSING_QUERY_STRING") - .build()]; - u64_counter!( - "apollo_router_http_requests_total", - "Total number of HTTP requests made.", - 1, - status = StatusCode::BAD_REQUEST.as_u16() as i64, - error = "Must provide query string" - ); - + let errors = vec![ + crate::error::Error::builder() + .message("Must provide query string.".to_string()) + .extension_code("MISSING_QUERY_STRING") + .build(), + ]; return Err(SupergraphResponse::builder() .errors(errors) .status_code(StatusCode::BAD_REQUEST) @@ -148,16 +284,21 @@ impl QueryAnalysisLayer { .cloned(); let res = match entry { - None => match self.parse_document(&query, op_name.as_deref()).await { - Err(errors) => { - (*self.cache.lock().await).put( - QueryAnalysisKey { - query, - operation_name: op_name.clone(), - }, - Err(errors.clone()), - ); - Err(errors) + None => match self + .parse_document(&query, op_name.as_deref(), ComputeJobType::QueryParsing) + .await + { + Err(e) => { + if let MaybeBackPressureError::PermanentError(errors) = &e { + (*self.cache.lock().await).put( + QueryAnalysisKey { + query, + operation_name: op_name.clone(), + }, + Err(errors.clone()), + ); + } + Err(e) } Ok(doc) => { let context = Context::new(); @@ -190,7 +331,7 @@ impl QueryAnalysisLayer { Ok((context, doc)) } }, - Some(c) => c, + Some(cached_result) => cached_result.map_err(MaybeBackPressureError::PermanentError), }; match res { @@ -211,7 +352,7 @@ impl QueryAnalysisLayer { None }; - request.context.extensions().with_lock(|mut lock| { + request.context.extensions().with_lock(|lock| { lock.insert::(doc.clone()); if let Some(stats) = extended_ref_stats { lock.insert::(stats); @@ -223,19 +364,20 @@ impl QueryAnalysisLayer { context: request.context, }) } - Err(errors) => { - request.context.extensions().with_lock(|mut lock| { - lock.insert(Arc::new(UsageReporting { - stats_report_key: errors.get_error_key().to_string(), - referenced_fields_by_type: HashMap::new(), - })) + Err(MaybeBackPressureError::PermanentError(errors)) => { + request.context.extensions().with_lock(|lock| { + lock.insert(Arc::new(UsageReporting::Error( + errors.get_error_key().to_string(), + ))) }); let errors = match errors.into_graphql_errors() { Ok(v) => v, - Err(errors) => vec![Error::builder() - .message(errors.to_string()) - .extension_code(errors.extension_code()) - .build()], + Err(errors) => vec![ + Error::builder() + .message(errors.to_string()) + .extension_code(errors.extension_code()) + .build(), + ], }; Err(SupergraphResponse::builder() .errors(errors) @@ -244,6 +386,19 @@ impl QueryAnalysisLayer { .build() .expect("response is valid")) } + Err(MaybeBackPressureError::TemporaryError(error)) => { + request.context.extensions().with_lock(|lock| { + let error_key = SpecError::ValidationError(ValidationErrors { errors: vec![] }) + .get_error_key(); + lock.insert(Arc::new(UsageReporting::Error(error_key.to_string()))) + }); + Err(SupergraphResponse::builder() + .error(error.to_graphql_error()) + .status_code(StatusCode::SERVICE_UNAVAILABLE) + .context(request.context) + .build() + .expect("response is valid")) + } } } } @@ -262,9 +417,6 @@ pub(crate) struct ParsedDocumentInner { pub(crate) has_explicit_root_fields: bool, } -#[derive(Debug)] -pub(crate) struct RootFieldKinds {} - impl ParsedDocumentInner { pub(crate) fn new( ast: ast::Document, @@ -313,13 +465,13 @@ pub(crate) fn get_operation( impl Display for ParsedDocumentInner { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self) + write!(f, "{self:?}") } } impl Hash for ParsedDocumentInner { fn hash(&self, state: &mut H) { - self.hash.0.hash(state); + self.hash.hash(state); } } diff --git a/apollo-router/src/services/layers/static_page.rs b/apollo-router/src/services/layers/static_page.rs index 75ecfb2538..e530554483 100644 --- a/apollo-router/src/services/layers/static_page.rs +++ b/apollo-router/src/services/layers/static_page.rs @@ -1,35 +1,35 @@ -//! (A)utomatic (P)ersisted (Q)ueries cache. -//! -//! For more information on APQ see: -//! +//! Provides the home page and sandbox page implementations. use std::ops::ControlFlow; use bytes::Bytes; -use http::header::CONTENT_TYPE; use http::HeaderMap; use http::HeaderValue; use http::Method; -use mediatype::names::HTML; -use mediatype::names::TEXT; +use http::header::CONTENT_TYPE; use mediatype::MediaType; use mediatype::MediaTypeList; +use mediatype::names::HTML; +use mediatype::names::TEXT; use tower::BoxError; use tower::Layer; use tower::Service; +use crate::Configuration; use crate::configuration::Homepage; use crate::layers::sync_checkpoint::CheckpointService; use crate::services::router; -use crate::Configuration; -/// [`Layer`] That serves Static pages such as Homepage and Sandbox. +/// A layer that serves a static page for all requests that accept a `text/html` response +/// (typically a user navigating to a page in the browser). #[derive(Clone)] pub(crate) struct StaticPageLayer { static_page: Option, } impl StaticPageLayer { + /// Create a static page based on configuration: either an Apollo Sandbox, or a simple home + /// page. pub(crate) fn new(configuration: &Configuration) -> Self { let static_page = if configuration.sandbox.enabled { Some(Bytes::from(sandbox_page_content())) @@ -57,19 +57,25 @@ where CheckpointService::new( move |req| { let res = if req.router_request.method() == Method::GET - && prefers_html(req.router_request.headers()) + && accepts_html(req.router_request.headers()) { - let response = http::Response::builder() - .header( - CONTENT_TYPE, - HeaderValue::from_static(mime::TEXT_HTML_UTF_8.as_ref()), - ) - .body(crate::services::router::Body::from(page.clone())) - .unwrap(); - ControlFlow::Break(router::Response { - response, - context: req.context, - }) + ControlFlow::Break( + router::Response::http_response_builder() + .response( + http::Response::builder() + .header( + CONTENT_TYPE, + HeaderValue::from_static( + mime::TEXT_HTML_UTF_8.as_ref(), + ), + ) + .body(router::body::from_bytes(page.clone())) + .unwrap(), + ) + .context(req.context) + .build() + .unwrap(), + ) } else { ControlFlow::Continue(req) }; @@ -84,7 +90,11 @@ where } } -fn prefers_html(headers: &HeaderMap) -> bool { +/// Returns true if the given header map contains an `Accept` header which contains the "text/html" +/// MIME type. +/// +/// `Accept` priorities or preferences are not considered. +fn accepts_html(headers: &HeaderMap) -> bool { let text_html = MediaType::new(TEXT, HTML); headers.get_all(&http::header::ACCEPT).iter().any(|value| { diff --git a/apollo-router/src/services/mod.rs b/apollo-router/src/services/mod.rs index c244cac420..cd4ab3589a 100644 --- a/apollo-router/src/services/mod.rs +++ b/apollo-router/src/services/mod.rs @@ -2,6 +2,8 @@ use std::sync::Arc; +use parking_lot::Mutex; + pub(crate) use self::execution::service::*; pub(crate) use self::query_planner::*; pub(crate) use self::subgraph_service::*; @@ -11,20 +13,29 @@ use crate::http_ext; pub use crate::http_ext::TryIntoHeaderName; pub use crate::http_ext::TryIntoHeaderValue; pub use crate::query_planner::OperationKind; +pub(crate) use crate::services::connect::Request as ConnectRequest; +pub(crate) use crate::services::connect::Response as ConnectResponse; pub(crate) use crate::services::execution::Request as ExecutionRequest; pub(crate) use crate::services::execution::Response as ExecutionResponse; +pub(crate) use crate::services::fetch::FetchRequest; +pub(crate) use crate::services::fetch::Response as FetchResponse; pub(crate) use crate::services::query_planner::Request as QueryPlannerRequest; pub(crate) use crate::services::query_planner::Response as QueryPlannerResponse; pub(crate) use crate::services::router::Request as RouterRequest; pub(crate) use crate::services::router::Response as RouterResponse; pub(crate) use crate::services::subgraph::Request as SubgraphRequest; pub(crate) use crate::services::subgraph::Response as SubgraphResponse; -pub(crate) use crate::services::supergraph::service::SupergraphCreator; pub(crate) use crate::services::supergraph::Request as SupergraphRequest; pub(crate) use crate::services::supergraph::Response as SupergraphResponse; +pub(crate) use crate::services::supergraph::service::SupergraphCreator; +pub(crate) mod connect; +pub(crate) mod connector; +pub(crate) mod connector_service; pub mod execution; pub(crate) mod external; +pub(crate) mod fetch; +pub(crate) mod fetch_service; pub(crate) mod hickory_dns_connector; pub(crate) mod http; pub(crate) mod layers; @@ -34,7 +45,6 @@ pub mod router; pub mod subgraph; pub(crate) mod subgraph_service; pub mod supergraph; -pub mod transport; impl AsRef for http_ext::Request { fn as_ref(&self) -> &Request { @@ -48,26 +58,18 @@ impl AsRef for Arc> { } } -#[cfg(test)] -pub(crate) fn apollo_key() -> Option { - // During tests we don't want env variables to affect defaults - None -} +// Public-hidden for tests +#[allow(missing_docs)] +pub static APOLLO_KEY: Mutex> = Mutex::new(None); +#[allow(missing_docs)] +pub static APOLLO_GRAPH_REF: Mutex> = Mutex::new(None); -#[cfg(not(test))] pub(crate) fn apollo_key() -> Option { - std::env::var("APOLLO_KEY").ok() -} - -#[cfg(test)] -pub(crate) fn apollo_graph_reference() -> Option { - // During tests we don't want env variables to affect defaults - None + APOLLO_KEY.lock().clone() } -#[cfg(not(test))] pub(crate) fn apollo_graph_reference() -> Option { - std::env::var("APOLLO_GRAPH_REF").ok() + APOLLO_GRAPH_REF.lock().clone() } // set the supported `@defer` specification version to https://github.com/graphql/graphql-spec/pull/742/commits/01d7b98f04810c9a9db4c0e53d3c4d54dbf10b82 diff --git a/apollo-router/src/services/query_batching/testdata/batch_exceeds_maximum_size_response.json b/apollo-router/src/services/query_batching/testdata/batch_exceeds_maximum_size_response.json new file mode 100644 index 0000000000..ad9102d1d1 --- /dev/null +++ b/apollo-router/src/services/query_batching/testdata/batch_exceeds_maximum_size_response.json @@ -0,0 +1,11 @@ +{ + "errors": [ + { + "message": "Invalid GraphQL request", + "extensions": { + "details": "Batch limits exceeded: you provided a batch with 2 entries, but the configured maximum router batch size is 1", + "code": "BATCH_LIMIT_EXCEEDED" + } + } + ] +} diff --git a/apollo-router/src/services/query_planner.rs b/apollo-router/src/services/query_planner.rs index d23a0e2ace..336712a732 100644 --- a/apollo-router/src/services/query_planner.rs +++ b/apollo-router/src/services/query_planner.rs @@ -4,16 +4,25 @@ use std::sync::Arc; use async_trait::async_trait; use derivative::Derivative; -use router_bridge::planner::PlanOptions; use serde::Deserialize; use serde::Serialize; use static_assertions::assert_impl_all; use super::layers::query_analysis::ParsedDocument; +use crate::Context; +use crate::compute_job::ComputeJobType; +use crate::compute_job::MaybeBackPressureError; use crate::error::QueryPlannerError; use crate::graphql; use crate::query_planner::QueryPlan; -use crate::Context; + +/// Options for planning a query +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanOptions { + /// Which labels to override during query planning + pub(crate) override_conditions: Vec, +} assert_impl_all!(Request: Send); /// [`Context`] for the request. @@ -25,6 +34,7 @@ pub(crate) struct Request { pub(crate) document: ParsedDocument, pub(crate) metadata: crate::plugins::authorization::CacheKeyMetadata, pub(crate) plan_options: PlanOptions, + pub(crate) compute_job_type: ComputeJobType, } #[buildstructor::buildstructor] @@ -39,6 +49,7 @@ impl Request { document: ParsedDocument, metadata: crate::plugins::authorization::CacheKeyMetadata, plan_options: PlanOptions, + compute_job_type: ComputeJobType, ) -> Request { Self { query, @@ -46,6 +57,7 @@ impl Request { document, metadata, plan_options, + compute_job_type, } } } @@ -109,16 +121,12 @@ impl Response { } } -pub(crate) type BoxService = tower::util::BoxService; -#[allow(dead_code)] -pub(crate) type BoxCloneService = - tower::util::BoxCloneService; -#[allow(dead_code)] -pub(crate) type ServiceResult = Result; +pub(crate) type ServiceError = MaybeBackPressureError; +pub(crate) type BoxService = tower::util::BoxService; #[allow(dead_code)] -pub(crate) type Body = hyper::Body; +pub(crate) type BoxCloneService = tower::util::BoxCloneService; #[allow(dead_code)] -pub(crate) type Error = hyper::Error; +pub(crate) type ServiceResult = Result; #[async_trait] pub(crate) trait QueryPlannerPlugin: Send + Sync + 'static { diff --git a/apollo-router/src/services/router.rs b/apollo-router/src/services/router.rs index cfd6e69c21..76558db637 100644 --- a/apollo-router/src/services/router.rs +++ b/apollo-router/src/services/router.rs @@ -3,43 +3,51 @@ use std::any::Any; use std::mem; +use ahash::HashMap; use bytes::Bytes; -use futures::future::Either; +use displaydoc::Display; use futures::Stream; use futures::StreamExt; -use http::header::HeaderName; -use http::header::CONTENT_TYPE; +use futures::future::Either; use http::HeaderValue; use http::Method; use http::StatusCode; +use http::header::CONTENT_TYPE; +use http::header::HeaderName; +use http_body_util::BodyExt; use multer::Multipart; use multimap::MultiMap; use serde_json_bytes::ByteString; use serde_json_bytes::Map as JsonMap; use serde_json_bytes::Value; use static_assertions::assert_impl_all; +use thiserror::Error; use tower::BoxError; +use uuid::Uuid; use self::body::RouterBody; use self::service::MULTIPART_DEFER_CONTENT_TYPE_HEADER_VALUE; use self::service::MULTIPART_SUBSCRIPTION_CONTENT_TYPE_HEADER_VALUE; use super::supergraph; +use crate::Context; +use crate::context::CONTAINS_GRAPHQL_ERROR; +use crate::context::ROUTER_RESPONSE_ERRORS; use crate::graphql; use crate::http_ext::header_map; use crate::json_ext::Path; -use crate::services; +use crate::plugins::telemetry::config_new::router::events::RouterResponseBodyExtensionType; use crate::services::TryIntoHeaderName; use crate::services::TryIntoHeaderValue; -use crate::Context; pub type BoxService = tower::util::BoxService; pub type BoxCloneService = tower::util::BoxCloneService; pub type ServiceResult = Result; -//#[deprecated] -pub type Body = hyper::Body; + +pub type Body = RouterBody; pub type Error = hyper::Error; pub mod body; +pub(crate) mod pipeline_handle; pub(crate) mod service; #[cfg(test)] mod tests; @@ -66,12 +74,38 @@ impl From<(http::Request, Context)> for Request { } } +/// Helper type to conveniently construct a body from several types used commonly in tests. +/// +/// It's only meant for integration tests, as the "real" router should create bodies explicitly accounting for +/// streaming, size limits, etc. +pub struct IntoBody(Body); + +impl From for IntoBody { + fn from(value: Body) -> Self { + Self(value) + } +} +impl From for IntoBody { + fn from(value: String) -> Self { + Self(body::from_bytes(value)) + } +} +impl From for IntoBody { + fn from(value: Bytes) -> Self { + Self(body::from_bytes(value)) + } +} +impl From> for IntoBody { + fn from(value: Vec) -> Self { + Self(body::from_bytes(value)) + } +} + #[buildstructor::buildstructor] impl Request { /// This is the constructor (or builder) to use when constructing a real Request. /// /// Required parameters are required in non-testing code to create a Request. - #[allow(clippy::too_many_arguments)] #[builder(visibility = "pub")] fn new( context: Context, @@ -94,19 +128,18 @@ impl Request { /// This is the constructor (or builder) to use when constructing a fake Request. /// /// Required parameters are required in non-testing code to create a Request. - #[allow(clippy::too_many_arguments)] #[builder(visibility = "pub")] fn fake_new( context: Option, headers: MultiMap, uri: Option, method: Option, - body: Option, + body: Option, ) -> Result { let mut router_request = http::Request::builder() .uri(uri.unwrap_or_else(|| http::Uri::from_static("http://example.com/"))) .method(method.unwrap_or(Method::GET)) - .body(body.unwrap_or_else(Body::empty))?; + .body(body.map_or_else(body::empty, |constructed| constructed.0))?; *router_request.headers_mut() = header_map(headers)?; Ok(Self { router_request, @@ -115,9 +148,6 @@ impl Request { } } -use displaydoc::Display; -use thiserror::Error; - #[derive(Error, Display, Debug)] pub enum ParseError { /// couldn't create a valid http GET uri '{0}' @@ -157,14 +187,13 @@ impl TryFrom for Request { .parse() .map_err(ParseError::InvalidUri)?; - http::Request::from_parts(parts, RouterBody::empty().into_inner()) + http::Request::from_parts(parts, body::empty()) } else { http::Request::from_parts( parts, - RouterBody::from( + body::from_bytes( serde_json::to_vec(&request).map_err(ParseError::SerializationError)?, - ) - .into_inner(), + ), ) }; Ok(Self { @@ -184,37 +213,35 @@ pub struct Response { #[buildstructor::buildstructor] impl Response { - pub async fn next_response(&mut self) -> Option> { - self.response.body_mut().next().await + fn stash_the_body_in_extensions(&mut self, body_string: String) { + self.context.extensions().with_lock(|ext| { + ext.insert(RouterResponseBodyExtensionType(body_string)); + }); } - #[deprecated] - pub fn map(self, f: F) -> Response - where - F: FnOnce(Body) -> Body, - { - Response { - context: self.context, - response: self.response.map(f), - } + pub async fn next_response(&mut self) -> Option> { + self.response.body_mut().into_data_stream().next().await } - /// This is the constructor (or builder) to use when constructing a real Response.. + /// This is the constructor (or builder) to use when constructing a real Response. /// - /// Required parameters are required in non-testing code to create a Response.. - #[allow(clippy::too_many_arguments)] + /// Required parameters are required in non-testing code to create a Response. #[builder(visibility = "pub")] fn new( label: Option, - data: Option, + data: Option, path: Option, errors: Vec, - // Skip the `Object` type alias in order to use buildstructor’s map special-casing - extensions: JsonMap, + // Skip the `Object` type alias to use buildstructor’s map special-casing + extensions: JsonMap, status_code: Option, headers: MultiMap, context: Context, ) -> Result { + if !errors.is_empty() { + Self::add_errors_to_context(&errors, &context); + } + // Build a response let b = graphql::Response::builder() .and_label(label) @@ -226,7 +253,7 @@ impl Response { None => b.build(), }; - // Build an http Response + // Build an HTTP Response let mut builder = http::Response::builder().status(status_code.unwrap_or(StatusCode::OK)); for (key, values) in headers { let header_name: HeaderName = key.try_into()?; @@ -236,11 +263,37 @@ impl Response { } } - // let response = builder.body(once(ready(res)).boxed())?; + let body_string = serde_json::to_string(&res)?; - let response = builder.body(RouterBody::from(serde_json::to_vec(&res)?).into_inner())?; + let body = body::from_bytes(body_string.clone()); + let response = builder.body(body)?; + // Stash the body in the extensions so we can access it later + let mut response = Self { response, context }; + response.stash_the_body_in_extensions(body_string); - Ok(Self { response, context }) + Ok(response) + } + + #[builder(visibility = "pub")] + fn http_response_new( + response: http::Response, + context: Context, + body_to_stash: Option, + errors_for_context: Option>, + ) -> Result { + // There are instances where we have errors that need to be counted for telemetry in this + // layer, but we don't want to deserialize the body. In these cases we can pass in the + // list of errors to add to context for counting later in the telemetry plugin. + if let Some(errors) = errors_for_context + && !errors.is_empty() + { + Self::add_errors_to_context(&errors, &context); + } + let mut res = Self { response, context }; + if let Some(body_to_stash) = body_to_stash { + res.stash_the_body_in_extensions(body_to_stash) + } + Ok(res) } /// This is the constructor (or builder) to use when constructing a Response that represents a global error. @@ -265,22 +318,25 @@ impl Response { ) } - /// This is the constructor (or builder) to use when constructing a real Response.. + /// This is the constructor (or builder) to use when constructing a real Response. /// - /// Required parameters are required in non-testing code to create a Response.. - #[allow(clippy::too_many_arguments)] + /// Required parameters are required in non-testing code to create a Response. #[builder(visibility = "pub(crate)")] fn infallible_new( label: Option, - data: Option, + data: Option, path: Option, errors: Vec, - // Skip the `Object` type alias in order to use buildstructor’s map special-casing - extensions: JsonMap, + // Skip the `Object` type alias to use buildstructor’s map special-casing + extensions: JsonMap, status_code: Option, headers: MultiMap, context: Context, ) -> Self { + if !errors.is_empty() { + Self::add_errors_to_context(&errors, &context); + } + // Build a response let b = graphql::Response::builder() .and_label(label) @@ -300,17 +356,39 @@ impl Response { } } - let response = builder - .body(RouterBody::from(serde_json::to_vec(&res).expect("can't fail")).into_inner()) - .expect("can't fail"); + let body_string = serde_json::to_string(&res).expect("JSON is always a valid string"); + + let body = body::from_bytes(body_string.clone()); + let response = builder.body(body).expect("RouterBody is always valid"); Self { response, context } } - /// EXPERIMENTAL: this is function is experimental and subject to potentially change. + fn add_errors_to_context(errors: &[graphql::Error], context: &Context) { + context.insert_json_value(CONTAINS_GRAPHQL_ERROR, Value::Bool(true)); + // This is ONLY guaranteed to capture errors if any were added during router service + // processing. We will sometimes avoid this path if no router service errors exist, even + // if errors were passed from the supergraph service, because that path builds the + // router::Response using parts_new(). This is ok because we only need this context to + // count errors introduced in the router service; however, it means that we handle error + // counting differently in this layer than others. + context + .insert( + ROUTER_RESPONSE_ERRORS, + // We can't serialize the apollo_id, so make a map with id as the key + errors + .iter() + .cloned() + .map(|err| (err.apollo_id(), err)) + .collect::>(), + ) + .expect("Unable to serialize router response errors list for context"); + } + + /// EXPERIMENTAL: THIS FUNCTION IS EXPERIMENTAL AND SUBJECT TO POTENTIAL CHANGE. pub async fn into_graphql_response_stream( self, - ) -> impl Stream> { + ) -> impl Stream> { Box::pin( if self .response @@ -322,43 +400,42 @@ impl Response { || *value == MULTIPART_SUBSCRIPTION_CONTENT_TYPE_HEADER_VALUE }) { - let multipart = Multipart::new(self.response.into_body(), "graphql"); + let multipart = Multipart::new( + http_body_util::BodyDataStream::new(self.response.into_body()), + "graphql", + ); Either::Left(futures::stream::unfold(multipart, |mut m| async { - if let Ok(Some(response)) = m.next_field().await { - if let Ok(bytes) = response.bytes().await { - return Some(( - serde_json::from_slice::(&bytes), - m, - )); - } + if let Ok(Some(response)) = m.next_field().await + && let Ok(bytes) = response.bytes().await + { + return Some((serde_json::from_slice::(&bytes), m)); } None })) } else { - let mut body = self.response.into_body(); + let mut body = http_body_util::BodyDataStream::new(self.response.into_body()); let res = body.next().await.and_then(|res| res.ok()); Either::Right( futures::stream::iter(res.into_iter()) - .map(|bytes| serde_json::from_slice::(&bytes)), + .map(|bytes| serde_json::from_slice::(&bytes)), ) }, ) } - /// This is the constructor (or builder) to use when constructing a fake Response.. + /// This is the constructor (or builder) to use when constructing a fake Response. /// - /// Required parameters are required in non-testing code to create a Response.. - #[allow(clippy::too_many_arguments)] + /// Required parameters are required in non-testing code to create a Response. #[builder(visibility = "pub")] fn fake_new( label: Option, - data: Option, + data: Option, path: Option, errors: Vec, - // Skip the `Object` type alias in order to use buildstructor’s map special-casing - extensions: JsonMap, + // Skip the `Object` type alias to use buildstructor’s map special-casing + extensions: JsonMap, status_code: Option, headers: MultiMap, context: Option, @@ -410,7 +487,6 @@ impl From for http::Response { impl From> for Request where T: http_body::Body + Send + 'static, - ::Error: Into, { fn from(request: http::Request) -> Self { @@ -433,23 +509,20 @@ impl From for http::Request { } } -/// This function is used to convert a `http_body::Body` into a `Body`. -/// It does a downcast check to see if the body is already a `Body` and if it is then it just returns it. +/// This function is used to convert an `http_body::Body` into a `Body`. +/// It does a downcast check to see if the body is already a `Body` and if it is, then it just returns it. /// There is zero overhead if the body is already a `Body`. /// Note that ALL graphql responses are already a stream as they may be part of a deferred or stream response, -/// therefore if a body has to be wrapped the cost is minimal. +/// therefore, if a body has to be wrapped, the cost is minimal. fn convert_to_body(mut b: T) -> Body where T: http_body::Body + Send + 'static, - ::Error: Into, { let val_any = &mut b as &mut dyn Any; match val_any.downcast_mut::() { Some(body) => mem::take(body), - None => Body::wrap_stream(services::http::body_stream::BodyStream::new( - b.map_err(Into::into), - )), + None => Body::new(http_body_util::BodyStream::new(b.map_err(axum::Error::new))), } } @@ -459,11 +532,10 @@ mod test { use std::task::Context; use std::task::Poll; - use http::HeaderMap; use tower::BoxError; - use crate::services::router::body::get_body_bytes; - use crate::services::router::convert_to_body; + use super::convert_to_body; + use crate::services::router; struct MockBody { data: Option<&'static str>, @@ -472,39 +544,32 @@ mod test { type Data = bytes::Bytes; type Error = BoxError; - fn poll_data( - mut self: Pin<&mut Self>, + fn poll_frame( + self: Pin<&mut Self>, _cx: &mut Context<'_>, - ) -> Poll>> { - if let Some(data) = self.data.take() { - Poll::Ready(Some(Ok(bytes::Bytes::from(data)))) + ) -> Poll, Self::Error>>> { + if let Some(data) = self.get_mut().data.take() { + Poll::Ready(Some(Ok(http_body::Frame::data(bytes::Bytes::from(data))))) } else { Poll::Ready(None) } } - - fn poll_trailers( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll, Self::Error>> { - Poll::Ready(Ok(None)) - } } #[tokio::test] async fn test_convert_from_http_body() { let body = convert_to_body(MockBody { data: Some("test") }); assert_eq!( - &String::from_utf8(get_body_bytes(body).await.unwrap().to_vec()).unwrap(), + &String::from_utf8(router::body::into_bytes(body).await.unwrap().to_vec()).unwrap(), "test" ); } #[tokio::test] async fn test_convert_from_hyper_body() { - let body = convert_to_body(hyper::Body::from("test")); + let body = convert_to_body(String::from("test")); assert_eq!( - &String::from_utf8(get_body_bytes(body).await.unwrap().to_vec()).unwrap(), + &String::from_utf8(router::body::into_bytes(body).await.unwrap().to_vec()).unwrap(), "test" ); } diff --git a/apollo-router/src/services/router/body.rs b/apollo-router/src/services/router/body.rs index 4890544817..7aa5819f3a 100644 --- a/apollo-router/src/services/router/body.rs +++ b/apollo-router/src/services/router/body.rs @@ -1,133 +1,62 @@ -#![allow(deprecated)] -use std::fmt::Debug; - +use axum::Error as AxumError; use bytes::Bytes; -use futures::future::BoxFuture; -use futures::FutureExt; use futures::Stream; -use http_body::SizeHint; -use hyper::body::HttpBody; -use tower::BoxError; -use tower::Service; - -pub struct RouterBody(super::Body); - -impl RouterBody { - pub fn empty() -> Self { - Self(super::Body::empty()) - } +use futures::StreamExt; +use http_body::Frame; +use http_body_util::BodyExt; +use http_body_util::Empty; +use http_body_util::Full; +use http_body_util::StreamBody; +use http_body_util::combinators::UnsyncBoxBody; +use hyper::body::Body as HttpBody; - pub fn into_inner(self) -> super::Body { - self.0 - } +pub type RouterBody = UnsyncBoxBody; - pub async fn to_bytes(self) -> Result { - hyper::body::to_bytes(self.0).await - } - - pub fn wrap_stream(stream: S) -> RouterBody - where - S: Stream> + Send + 'static, - O: Into + 'static, - E: Into> + 'static, - { - Self(super::Body::wrap_stream(stream)) - } +pub(crate) async fn into_bytes(body: B) -> Result { + Ok(body.collect().await?.to_bytes()) } -impl> From for RouterBody { - fn from(value: T) -> Self { - RouterBody(value.into()) - } -} +// We create some utility functions to make Empty and Full bodies +// and convert types -impl Debug for RouterBody { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } +/// Create an empty RouterBody +pub(crate) fn empty() -> RouterBody { + Empty::::new() + .map_err(|never| match never {}) + .boxed_unsync() } -impl Stream for RouterBody { - type Item = ::Item; - - fn poll_next( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - let mut pinned = std::pin::pin!(&mut self.0); - pinned.as_mut().poll_next(cx) - } +/// Create a Full RouterBody using the supplied chunk +pub fn from_bytes>(chunk: T) -> RouterBody { + Full::new(chunk.into()) + .map_err(|never| match never {}) + .boxed_unsync() } -impl HttpBody for RouterBody { - type Data = ::Data; - - type Error = ::Error; - - fn poll_data( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll>> { - let mut pinned = std::pin::pin!(&mut self.0); - pinned.as_mut().poll_data(cx) - } - - fn poll_trailers( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll, Self::Error>> { - let mut pinned = std::pin::pin!(&mut self.0); - pinned.as_mut().poll_trailers(cx) - } - - fn is_end_stream(&self) -> bool { - self.0.is_end_stream() - } - - fn size_hint(&self) -> SizeHint { - HttpBody::size_hint(&self.0) - } -} - -pub(crate) async fn get_body_bytes(body: B) -> Result { - hyper::body::to_bytes(body).await -} - -// this is used to wrap a hyper::Client because its Service implementation will always return a hyper::Body, -// it does not keep the body type used by the request -#[derive(Clone)] -pub(crate) struct RouterBodyConverter { - pub(crate) inner: C, +/// Create a streaming RouterBody using the supplied stream +pub(crate) fn from_result_stream(data_stream: S) -> RouterBody +where + S: Stream> + Send + 'static, + S: StreamExt, + E: Into, +{ + RouterBody::new(StreamBody::new( + data_stream.map(|s| s.map(Frame::data).map_err(AxumError::new)), + )) } -impl Service> for RouterBodyConverter +/// Get a body's contents as a utf-8 string for use in test assertions, or return an error. +pub async fn into_string(input: B) -> Result where - C: Service, Response = http::Response, Error = BoxError> - + Clone - + Send - + Sync - + 'static, - >>::Future: Send + Sync + 'static, + B: HttpBody, + B::Error: Into, { - type Response = http::Response; - - type Error = BoxError; - - type Future = BoxFuture<'static, Result, BoxError>>; - - fn poll_ready( - &mut self, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - self.inner.poll_ready(cx) - } - - fn call(&mut self, req: http::Request) -> Self::Future { - Box::pin(self.inner.call(req).map(|res| { - res.map(|http_response| { - let (parts, body) = http_response.into_parts(); - http::Response::from_parts(parts, RouterBody::from(body)) - }) - })) - } + let bytes = input + .collect() + .await + .map_err(AxumError::new)? + .to_bytes() + .to_vec(); + let string = String::from_utf8(bytes).map_err(AxumError::new)?; + Ok(string) } diff --git a/apollo-router/src/services/router/pipeline_handle.rs b/apollo-router/src/services/router/pipeline_handle.rs new file mode 100644 index 0000000000..faee4a5c88 --- /dev/null +++ b/apollo-router/src/services/router/pipeline_handle.rs @@ -0,0 +1,59 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::OnceLock; + +use parking_lot::Mutex; +use parking_lot::MutexGuard; + +/// A pipeline is used to keep track of how many pipelines we have active. It's associated with an instance of RouterCreator +/// The telemetry plugin has a gauge to expose this data +/// Pipeline ref represents a unique pipeline +#[derive(Clone, Hash, Eq, PartialEq, Debug)] +pub(crate) struct PipelineRef { + pub(crate) schema_id: String, + pub(crate) launch_id: Option, + pub(crate) config_hash: String, +} + +/// A pipeline handle does the actual tracking of pipelines +/// Creating a new pipeline handle will insert a PipelineRef into a static map. +/// Dropping all pipeline handles associated with the internal ref will remove the PipelineRef +/// Clone MUST NOT be implemented for this type. Cloning will make extra copies that when dropped will throw off the global count. +pub(crate) struct PipelineHandle { + pub(crate) pipeline_ref: Arc, +} + +static PIPELINE_COUNTS: OnceLock, u64>>> = OnceLock::new(); +pub(crate) fn pipeline_counts() -> MutexGuard<'static, HashMap, u64>> { + PIPELINE_COUNTS.get_or_init(Default::default).lock() +} + +impl PipelineHandle { + pub(crate) fn new(schema_id: String, launch_id: Option, config_hash: String) -> Self { + let pipeline_ref = Arc::new(PipelineRef { + schema_id, + launch_id, + config_hash, + }); + pipeline_counts() + .entry(pipeline_ref.clone()) + .and_modify(|p| *p += 1) + .or_insert(1); + PipelineHandle { pipeline_ref } + } +} + +impl Drop for PipelineHandle { + fn drop(&mut self) { + let mut pipelines = pipeline_counts(); + let value = pipelines + .get_mut(&self.pipeline_ref) + .expect("pipeline_ref MUST be greater than zero"); + *value -= 1; + if *value == 0 { + pipelines.remove(&self.pipeline_ref); + } + } +} + +pub(crate) const PIPELINE_METRIC: &str = "apollo.router.pipelines"; diff --git a/apollo-router/src/services/router/service.rs b/apollo-router/src/services/router/service.rs index 9e379ae3c4..58cac403b9 100644 --- a/apollo-router/src/services/router/service.rs +++ b/apollo-router/src/services/router/service.rs @@ -1,56 +1,79 @@ //! Implements the router phase of the request lifecycle. -use std::collections::HashMap; use std::sync::Arc; use std::task::Poll; -use axum::body::StreamBody; use axum::response::*; use bytes::BufMut; use bytes::Bytes; use bytes::BytesMut; +use futures::TryFutureExt; +use futures::future::BoxFuture; use futures::future::join_all; use futures::future::ready; -use futures::future::BoxFuture; -use futures::stream; -use futures::stream::once; use futures::stream::StreamExt; -use futures::TryFutureExt; -use http::header::CONTENT_TYPE; -use http::header::VARY; -use http::request::Parts; +use futures::stream::once; use http::HeaderMap; use http::HeaderName; use http::HeaderValue; use http::Method; use http::StatusCode; -use http_body::Body as _; +use http::header::CONTENT_TYPE; +use http::header::VARY; +use http::request::Parts; use mime::APPLICATION_JSON; use multimap::MultiMap; +use opentelemetry::KeyValue; +use opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD; use tower::BoxError; use tower::Layer; use tower::ServiceBuilder; use tower::ServiceExt; +use tower::buffer::Buffer; use tower_service::Service; use tracing::Instrument; use super::Body; use super::ClientRequestAccepts; +use crate::Configuration; +use crate::Context; +use crate::Endpoint; +use crate::ListenAddr; use crate::axum_factory::CanceledRequest; use crate::batching::Batch; use crate::batching::BatchQuery; use crate::cache::DeduplicatingCache; use crate::configuration::Batching; use crate::configuration::BatchingMode; -use crate::context::CONTAINS_GRAPHQL_ERROR; use crate::graphql; use crate::http_ext; +use crate::layers::DEFAULT_BUFFER_SIZE; +use crate::layers::ServiceBuilderExt; #[cfg(test)] use crate::plugin::test::MockSupergraphService; +use crate::plugins::telemetry::config_new::attributes::HTTP_REQUEST_BODY; +use crate::plugins::telemetry::config_new::attributes::HTTP_REQUEST_HEADERS; +use crate::plugins::telemetry::config_new::attributes::HTTP_REQUEST_URI; +use crate::plugins::telemetry::config_new::attributes::HTTP_REQUEST_VERSION; +use crate::plugins::telemetry::config_new::events::log_event; +use crate::plugins::telemetry::config_new::router::events::DisplayRouterRequest; +use crate::plugins::telemetry::config_new::router::events::DisplayRouterResponse; use crate::protocols::multipart::Multipart; use crate::protocols::multipart::ProtocolMode; use crate::query_planner::InMemoryCachePlanner; use crate::router_factory::RouterFactory; +use crate::services::APPLICATION_JSON_HEADER_VALUE; +use crate::services::HasPlugins; +use crate::services::HasSchema; +use crate::services::MULTIPART_DEFER_ACCEPT; +use crate::services::MULTIPART_DEFER_CONTENT_TYPE; +use crate::services::MULTIPART_SUBSCRIPTION_ACCEPT; +use crate::services::MULTIPART_SUBSCRIPTION_CONTENT_TYPE; +use crate::services::RouterRequest; +use crate::services::RouterResponse; +use crate::services::SupergraphCreator; +use crate::services::SupergraphRequest; +use crate::services::SupergraphResponse; use crate::services::layers::apq::APQLayer; use crate::services::layers::content_negotiation; use crate::services::layers::content_negotiation::GRAPHQL_JSON_RESPONSE_HEADER_VALUE; @@ -59,27 +82,9 @@ use crate::services::layers::query_analysis::QueryAnalysisLayer; use crate::services::layers::static_page::StaticPageLayer; use crate::services::new_service::ServiceFactory; use crate::services::router; -use crate::services::router::body::get_body_bytes; -use crate::services::router::body::RouterBody; -#[cfg(test)] +use crate::services::router::pipeline_handle::PipelineHandle; +use crate::services::router::pipeline_handle::PipelineRef; use crate::services::supergraph; -use crate::services::HasPlugins; -#[cfg(test)] -use crate::services::HasSchema; -use crate::services::RouterRequest; -use crate::services::RouterResponse; -use crate::services::SupergraphCreator; -use crate::services::SupergraphRequest; -use crate::services::SupergraphResponse; -use crate::services::APPLICATION_JSON_HEADER_VALUE; -use crate::services::MULTIPART_DEFER_ACCEPT; -use crate::services::MULTIPART_DEFER_CONTENT_TYPE; -use crate::services::MULTIPART_SUBSCRIPTION_ACCEPT; -use crate::services::MULTIPART_SUBSCRIPTION_CONTENT_TYPE; -use crate::Configuration; -use crate::Context; -use crate::Endpoint; -use crate::ListenAddr; pub(crate) static MULTIPART_DEFER_CONTENT_TYPE_HEADER_VALUE: HeaderValue = HeaderValue::from_static(MULTIPART_DEFER_CONTENT_TYPE); @@ -92,27 +97,32 @@ static ORIGIN_HEADER_VALUE: HeaderValue = HeaderValue::from_static("origin"); /// Containing [`Service`] in the request lifecyle. #[derive(Clone)] pub(crate) struct RouterService { - supergraph_creator: Arc, - apq_layer: APQLayer, + apq_layer: Arc, persisted_query_layer: Arc, - query_analysis_layer: QueryAnalysisLayer, + query_analysis_layer: Arc, + // Cannot be under Arc. Batching state must be preserved for each RouterService + // instance batching: Batching, + supergraph_service: supergraph::BoxCloneService, } impl RouterService { - pub(crate) fn new( - supergraph_creator: Arc, + fn new( + sgb: supergraph::BoxService, apq_layer: APQLayer, persisted_query_layer: Arc, query_analysis_layer: QueryAnalysisLayer, batching: Batching, ) -> Self { + let supergraph_service: supergraph::BoxCloneService = + ServiceBuilder::new().buffered().service(sgb).boxed_clone(); + RouterService { - supergraph_creator, - apq_layer, + apq_layer: Arc::new(apq_layer), persisted_query_layer, - query_analysis_layer, + query_analysis_layer: Arc::new(query_analysis_layer), batching, + supergraph_service, } } } @@ -120,10 +130,10 @@ impl RouterService { #[cfg(test)] pub(crate) async fn from_supergraph_mock_callback_and_configuration( supergraph_callback: impl FnMut(supergraph::Request) -> supergraph::ServiceResult - + Send - + Sync - + 'static - + Clone, + + Send + + Sync + + 'static + + Clone, configuration: Arc, ) -> impl Service< router::Request, @@ -140,7 +150,7 @@ pub(crate) async fn from_supergraph_mock_callback_and_configuration( supergraph_service }); - let (_, supergraph_creator) = crate::TestHarness::builder() + let (_, _, supergraph_creator) = crate::TestHarness::builder() .configuration(configuration.clone()) .supergraph_hook(move |_| supergraph_service.clone().boxed()) .build_common() @@ -161,10 +171,10 @@ pub(crate) async fn from_supergraph_mock_callback_and_configuration( #[cfg(test)] pub(crate) async fn from_supergraph_mock_callback( supergraph_callback: impl FnMut(supergraph::Request) -> supergraph::ServiceResult - + Send - + Sync - + 'static - + Clone, + + Send + + Sync + + 'static + + Clone, ) -> impl Service< router::Request, Response = router::Response, @@ -190,7 +200,7 @@ pub(crate) async fn empty() -> impl Service< .expect_clone() .returning(MockSupergraphService::new); - let (_, supergraph_creator) = crate::TestHarness::builder() + let (_, _, supergraph_creator) = crate::TestHarness::builder() .configuration(Default::default()) .supergraph_hook(move |_| supergraph_service.clone().boxed()) .build_common() @@ -213,25 +223,29 @@ impl Service for RouterService { type Error = BoxError; type Future = BoxFuture<'static, Result>; - fn poll_ready(&mut self, _cx: &mut std::task::Context<'_>) -> Poll> { - Poll::Ready(Ok(())) + fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll> { + self.supergraph_service.poll_ready(cx) } fn call(&mut self, req: RouterRequest) -> Self::Future { - let clone = self.clone(); + let self_clone = self.clone(); - let this = std::mem::replace(self, clone); + let this = std::mem::replace(self, self_clone); let fut = async move { this.call_inner(req).await }; + Box::pin(fut) } } impl RouterService { async fn process_supergraph_request( - &self, + self, supergraph_request: SupergraphRequest, ) -> Result { + // XXX(@goto-bus-stop): This code looks confusing. we are manually calling several + // layers with various ifs and matches, but *really*, we are calling each layer in order + // and handling short-circuiting. let mut request_res = self .persisted_query_layer .supergraph_request(supergraph_request); @@ -250,11 +264,26 @@ impl RouterService { .await { Err(response) => response, - Ok(request) => self.supergraph_creator.create().oneshot(request).await?, + Ok(request) => { + // self.supergraph_service here is a clone of the service that was readied + // in RouterService::poll_ready. Clones are unready by default, so this + // self.supergraph_service is actually not ready, which is why we need to + // oneshot it here. That technically breaks backpressure, but because we are + // still readying the supergraph service before calling into the router + // service, backpressure is actually still exerted at that point--there's + // just potential for some requests to slip through the cracks and end up + // queueing up at this .oneshot() call. + // + // Not ideal, but an improvement on the situation in Router 1.x. + self.supergraph_service.oneshot(request).await? + } }, }, }; + // XXX(@goto-bus-stop): *all* of the code using these `accepts_` variables looks like it + // duplicates what the content_negotiation::SupergraphLayer is doing. We should delete one + // or the other, and absolutely not do it inline here. let ClientRequestAccepts { wildcard: accepts_wildcard, json: accepts_json, @@ -279,39 +308,54 @@ impl RouterService { match body.next().await { None => { tracing::error!("router service is not available to process request",); - Ok(router::Response { - response: http::Response::builder() - .status(StatusCode::SERVICE_UNAVAILABLE) - .body( - RouterBody::from("router service is not available to process request") - .into_inner(), - ) - .expect("cannot fail"), - context, - }) + router::Response::error_builder() + .error( + graphql::Error::builder() + .message(String::from( + "router service is not available to process request", + )) + .extension_code(StatusCode::SERVICE_UNAVAILABLE.to_string()) + .build(), + ) + .status_code(StatusCode::SERVICE_UNAVAILABLE) + .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .context(context) + .build() } Some(response) => { if !response.has_next.unwrap_or(false) && !response.subscribed.unwrap_or(false) && (accepts_json || accepts_wildcard) { - if !response.errors.is_empty() { - Self::count_errors(&response.errors); - } + let errors = response.errors.clone(); parts .headers .insert(CONTENT_TYPE, APPLICATION_JSON_HEADER_VALUE.clone()); - tracing::trace_span!("serialize_response").in_scope(|| { - let body = serde_json::to_string(&response)?; - Ok(router::Response { - response: http::Response::from_parts( - parts, - RouterBody::from(body).into_inner(), - ), - context, + let body: Result = tracing::trace_span!("serialize_response") + .in_scope(|| { + let body = serde_json::to_string(&response)?; + Ok(body) + }); + let body = body?; + // XXX(@goto-bus-stop): I strongly suspect that it would be better to move this into its own layer. + let display_router_response = context + .extensions() + .with_lock(|ext| ext.get::().is_some()); + + router::Response::http_response_builder() + .response(Response::from_parts( + parts, + router::body::from_bytes(body.clone()), + )) + .and_body_to_stash(if display_router_response { + Some(body) + } else { + None }) - }) + .context(context) + .errors_for_context(errors) + .build() } else if accepts_multipart_defer || accepts_multipart_subscription { if accepts_multipart_defer { parts.headers.insert( @@ -325,64 +369,35 @@ impl RouterService { ); } - if !response.errors.is_empty() { - Self::count_errors(&response.errors); - } + let errors = response.errors.clone(); // Useful when you're using a proxy like nginx which enable proxy_buffering by default (http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering) parts.headers.insert( ACCEL_BUFFERING_HEADER_NAME.clone(), ACCEL_BUFFERING_HEADER_VALUE.clone(), ); - let multipart_stream = match response.subscribed { - Some(true) => StreamBody::new(Multipart::new( - body.inspect(|response| { - if !response.errors.is_empty() { - Self::count_errors(&response.errors); - } - }), - ProtocolMode::Subscription, - )), - _ => StreamBody::new(Multipart::new( - once(ready(response)).chain(body.inspect(|response| { - if !response.errors.is_empty() { - Self::count_errors(&response.errors); - } - })), - ProtocolMode::Defer, - )), + let response = match response.subscribed { + Some(true) => http::Response::from_parts( + parts, + router::body::from_result_stream(Multipart::new( + body, + ProtocolMode::Subscription, + )), + ), + _ => http::Response::from_parts( + parts, + router::body::from_result_stream(Multipart::new( + once(ready(response)).chain(body), + ProtocolMode::Defer, + )), + ), }; - let response = (parts, multipart_stream).into_response().map(|body| { - // Axum makes this `body` have type: - // https://docs.rs/http-body/0.4.5/http_body/combinators/struct.UnsyncBoxBody.html - let mut body = Box::pin(body); - // We make a stream based on its `poll_data` method - // in order to create a `hyper::Body`. - RouterBody::wrap_stream(stream::poll_fn(move |ctx| { - body.as_mut().poll_data(ctx) - })) - .into_inner() - // … but we ignore the `poll_trailers` method: - // https://docs.rs/http-body/0.4.5/http_body/trait.Body.html#tymethod.poll_trailers - // Apparently HTTP/2 trailers are like headers, except after the response body. - // I (Simon) believe nothing in the Apollo Router uses trailers as of this writing, - // so ignoring `poll_trailers` is fine. - // If we want to use trailers, we may need remove this convertion to `hyper::Body` - // and return `UnsyncBoxBody` (a.k.a. `axum::BoxBody`) as-is. - }); - - Ok(RouterResponse { response, context }) + RouterResponse::http_response_builder() + .response(response) + .context(context) + .errors_for_context(errors) + .build() } else { - tracing::info!( - monotonic_counter.apollo.router.graphql_error = 1u64, - code = "INVALID_ACCEPT_HEADER" - ); - // Useful for selector in spans/instruments/events - context.insert_json_value( - CONTAINS_GRAPHQL_ERROR, - serde_json_bytes::Value::Bool(true), - ); - // this should be unreachable due to a previous check, but just to be sure... Ok(router::Response::error_builder() .error( @@ -406,28 +421,21 @@ impl RouterService { } } - async fn call_inner(&self, req: RouterRequest) -> Result { + async fn call_inner(self, req: RouterRequest) -> Result { let context = req.context; let (parts, body) = req.router_request.into_parts(); - let requests = self.get_graphql_requests(&parts, body).await?; + let requests = self + .clone() + .get_graphql_requests(&context, &parts, body) + .await?; + let my_self = self.clone(); let (supergraph_requests, is_batch) = match futures::future::ready(requests) - .and_then(|r| self.translate_request(&context, parts, r)) + .and_then(|r| my_self.translate_request(&context, parts, r)) .await { Ok(requests) => requests, Err(err) => { - u64_counter!( - "apollo_router_http_requests_total", - "Total number of HTTP requests made.", - 1, - status = err.status.as_u16() as i64, - error = err.error.to_string() - ); - // Useful for selector in spans/instruments/events - context - .insert_json_value(CONTAINS_GRAPHQL_ERROR, serde_json_bytes::Value::Bool(true)); - return router::Response::error_builder() .error( graphql::Error::builder() @@ -447,20 +455,20 @@ impl RouterService { // Requests can be cancelled at any point of the router pipeline, but all failures bubble back // up through here, so we can catch them without having to specially handle batch queries in // other portions of the codebase. - let futures = supergraph_requests - .into_iter() - .map(|supergraph_request| async { + let futures = supergraph_requests.into_iter().map(|supergraph_request| { + let my_self = self.clone(); + async move { // We clone the context here, because if the request results in an Err, the // response context will no longer exist. let context = supergraph_request.context.clone(); - let result = self.process_supergraph_request(supergraph_request).await; + let result = my_self.process_supergraph_request(supergraph_request).await; // Regardless of the result, we need to make sure that we cancel any potential batch queries. This is because // custom rust plugins, rhai scripts, and coprocessors can cancel requests at any time and return a GraphQL // error wrapped in an `Ok` or in a `BoxError` wrapped in an `Err`. let batch_query_opt = context .extensions() - .with_lock(|mut lock| lock.remove::()); + .with_lock(|lock| lock.remove::()); if let Some(batch_query) = batch_query_opt { // Only proceed with signalling cancelled if the batch_query is not finished if !batch_query.finished() { @@ -472,7 +480,8 @@ impl RouterService { } result - }); + } + }); // Use join_all to preserve ordering of concurrent operations // (Short circuit processing and propagate any errors in the batch) @@ -494,92 +503,50 @@ impl RouterService { let context = first.context; let mut bytes = BytesMut::new(); bytes.put_u8(b'['); - bytes.extend_from_slice(&get_body_bytes(body).await?); + bytes.extend_from_slice(&router::body::into_bytes(body).await?); for result in results_it { bytes.put(&b", "[..]); - bytes.extend_from_slice(&get_body_bytes(result.response.into_body()).await?); + bytes.extend_from_slice( + &router::body::into_bytes(result.response.into_body()).await?, + ); } bytes.put_u8(b']'); - Ok(RouterResponse { - response: http::Response::from_parts( + RouterResponse::http_response_builder() + .response(http::Response::from_parts( parts, - RouterBody::from(bytes.freeze()).into_inner(), - ), - context, - }) + router::body::from_bytes(bytes.freeze()), + )) + .context(context) + .build() } else { Ok(results.pop().expect("we should have at least one response")) } } async fn translate_query_request( - &self, + self, parts: &Parts, ) -> Result<(Vec, bool), TranslateError> { - let mut is_batch = false; parts.uri.query().map(|q| { - let mut result = vec![]; - match graphql::Request::from_urlencoded_query(q.to_string()) { Ok(request) => { - result.push(request); + Ok((vec![request], false)) } Err(err) => { - // It may be a batch of requests, so try that (if config allows) before - // erroring out - if self.batching.enabled - && matches!(self.batching.mode, BatchingMode::BatchHttpLink) - { - result = graphql::Request::batch_from_urlencoded_query(q.to_string()) - .map_err(|e| TranslateError { - status: StatusCode::BAD_REQUEST, - error: "failed to decode a valid GraphQL request from path", - extension_code: "INVALID_GRAPHQL_REQUEST", - extension_details: format!( - "failed to decode a valid GraphQL request from path {e}" - ), - })?; - if result.is_empty() { - return Err(TranslateError { - status: StatusCode::BAD_REQUEST, - error: "failed to decode a valid GraphQL request from path", - extension_code: "INVALID_GRAPHQL_REQUEST", - extension_details: "failed to decode a valid GraphQL request from path: empty array ".to_string() - }); - } - is_batch = true; - } else if !q.is_empty() && q.as_bytes()[0] == b'[' { - let extension_details = if self.batching.enabled - && !matches!(self.batching.mode, BatchingMode::BatchHttpLink) { - format!("batching not supported for mode `{}`", self.batching.mode) - } else { - "batching not enabled".to_string() - }; - return Err(TranslateError { - status: StatusCode::BAD_REQUEST, - error: "batching not enabled", - extension_code: "BATCHING_NOT_ENABLED", - extension_details, - }); - } else { - return Err(TranslateError { - status: StatusCode::BAD_REQUEST, - error: "failed to decode a valid GraphQL request from path", - extension_code: "INVALID_GRAPHQL_REQUEST", - extension_details: format!( - "failed to decode a valid GraphQL request from path {err}" - ), - }); - } + Err(TranslateError { + status: StatusCode::BAD_REQUEST, + extension_code: "INVALID_GRAPHQL_REQUEST".to_string(), + extension_details: format!( + "failed to decode a valid GraphQL request from path {err}" + ), + }) } - }; - Ok((result, is_batch)) + } }).unwrap_or_else(|| { Err(TranslateError { status: StatusCode::BAD_REQUEST, - error: "There was no GraphQL operation to execute. Use the `query` parameter to send an operation, using either GET or POST.", - extension_code: "INVALID_GRAPHQL_REQUEST", + extension_code: "INVALID_GRAPHQL_REQUEST".to_string(), extension_details: "There was no GraphQL operation to execute. Use the `query` parameter to send an operation, using either GET or POST.".to_string() }) }) @@ -603,8 +570,7 @@ impl RouterService { result = graphql::Request::batch_from_bytes(bytes).map_err(|e| TranslateError { status: StatusCode::BAD_REQUEST, - error: "failed to deserialize the request body into JSON", - extension_code: "INVALID_GRAPHQL_REQUEST", + extension_code: "INVALID_GRAPHQL_REQUEST".to_string(), extension_details: format!( "failed to deserialize the request body into JSON: {e}" ), @@ -612,8 +578,7 @@ impl RouterService { if result.is_empty() { return Err(TranslateError { status: StatusCode::BAD_REQUEST, - error: "failed to decode a valid GraphQL request from path", - extension_code: "INVALID_GRAPHQL_REQUEST", + extension_code: "INVALID_GRAPHQL_REQUEST".to_string(), extension_details: "failed to decode a valid GraphQL request from path: empty array " .to_string(), @@ -630,15 +595,13 @@ impl RouterService { }; return Err(TranslateError { status: StatusCode::BAD_REQUEST, - error: "batching not enabled", - extension_code: "BATCHING_NOT_ENABLED", + extension_code: "BATCHING_NOT_ENABLED".to_string(), extension_details, }); } else { return Err(TranslateError { status: StatusCode::BAD_REQUEST, - error: "failed to deserialize the request body into JSON", - extension_code: "INVALID_GRAPHQL_REQUEST", + extension_code: "INVALID_GRAPHQL_REQUEST".to_string(), extension_details: format!( "failed to deserialize the request body into JSON: {err}" ), @@ -646,11 +609,24 @@ impl RouterService { } } }; + + if is_batch && self.batching.exceeds_batch_size(&result) { + return Err(TranslateError { + status: StatusCode::UNPROCESSABLE_ENTITY, + extension_code: "BATCH_LIMIT_EXCEEDED".to_string(), + extension_details: format!( + "Batch limits exceeded: you provided a batch with {} entries, but the configured maximum router batch size is {}", + result.len(), + self.batching.maximum_size.unwrap_or_default() + ), + }); + } + Ok((result, is_batch)) } async fn translate_request( - &self, + self, context: &Context, parts: Parts, graphql_requests: (Vec, bool), @@ -666,7 +642,7 @@ impl RouterService { .then(|| { context .extensions() - .with_lock(|mut lock| lock.insert(self.batching.clone())); + .with_lock(|lock| lock.insert(self.batching.clone())); self.batching.subgraph.as_ref() }) @@ -702,7 +678,6 @@ impl RouterService { // // Note: If we enter this loop, then we must be processing a batch. for (index, graphql_request) in ok_results_it.enumerate() { - // XXX Lose http extensions, is that ok? let mut new = http_ext::clone_http_request(&sg); *new.body_mut() = graphql_request; // XXX Lose some private entries, is that ok? @@ -717,8 +692,7 @@ impl RouterService { Batch::query_for_index(shared_batch_details.clone(), index + 1).map_err( |err| TranslateError { status: StatusCode::INTERNAL_SERVER_ERROR, - error: "failed to create batch", - extension_code: "BATCHING_ERROR", + extension_code: "BATCHING_ERROR".to_string(), extension_details: format!("failed to create batch entry: {err}"), }, )?, @@ -726,7 +700,7 @@ impl RouterService { } else { None }; - new_context.extensions().with_lock(|mut lock| { + new_context.extensions().with_lock(|lock| { if let Some(client_request_accepts) = client_request_accepts_opt { lock.insert(client_request_accepts); } @@ -746,13 +720,12 @@ impl RouterService { let b_for_index = Batch::query_for_index(shared_batch_details, 0).map_err(|err| TranslateError { status: StatusCode::INTERNAL_SERVER_ERROR, - error: "failed to create batch", - extension_code: "BATCHING_ERROR", + extension_code: "BATCHING_ERROR".to_string(), extension_details: format!("failed to create batch entry: {err}"), })?; context .extensions() - .with_lock(|mut lock| lock.insert(b_for_index)); + .with_lock(|lock| lock.insert(b_for_index)); } results.insert( @@ -767,7 +740,8 @@ impl RouterService { } async fn get_graphql_requests( - &self, + self, + context: &Context, parts: &Parts, body: Body, ) -> Result, bool), TranslateError>, BoxError> { @@ -775,42 +749,61 @@ impl RouterService { if parts.method == Method::GET { self.translate_query_request(parts).await } else { - let bytes = get_body_bytes(body) + let bytes = router::body::into_bytes(body) .instrument(tracing::debug_span!("receive_body")) .await?; + if let Some(level) = context + .extensions() + .with_lock(|ext| ext.get::().cloned()) + .map(|d| d.0) + { + let mut attrs = Vec::with_capacity(5); + #[cfg(test)] + let mut headers: indexmap::IndexMap = parts + .headers + .clone() + .into_iter() + .filter_map(|(name, val)| Some((name?.to_string(), val))) + .collect(); + #[cfg(test)] + headers.sort_keys(); + #[cfg(not(test))] + let headers = &parts.headers; + + attrs.push(KeyValue::new( + HTTP_REQUEST_HEADERS, + opentelemetry::Value::String(format!("{headers:?}").into()), + )); + attrs.push(KeyValue::new( + HTTP_REQUEST_METHOD, + opentelemetry::Value::String(format!("{}", parts.method).into()), + )); + attrs.push(KeyValue::new( + HTTP_REQUEST_URI, + opentelemetry::Value::String(format!("{}", parts.uri).into()), + )); + attrs.push(KeyValue::new( + HTTP_REQUEST_VERSION, + opentelemetry::Value::String(format!("{:?}", parts.version).into()), + )); + attrs.push(KeyValue::new( + HTTP_REQUEST_BODY, + opentelemetry::Value::String( + format!("{:?}", String::from_utf8_lossy(&bytes)).into(), + ), + )); + log_event(level, "router.request", attrs, ""); + } self.translate_bytes_request(&bytes) }; Ok(graphql_requests) } - - fn count_errors(errors: &[graphql::Error]) { - let mut map = HashMap::new(); - for error in errors { - let code = error.extensions.get("code").and_then(|c| c.as_str()); - let entry = map.entry(code).or_insert(0u64); - *entry += 1; - } - - for (code, count) in map { - match code { - None => { - tracing::info!(monotonic_counter.apollo.router.graphql_error = count,); - } - Some(code) => { - tracing::info!( - monotonic_counter.apollo.router.graphql_error = count, - code = code - ); - } - } - } - } } -struct TranslateError<'a> { +#[derive(Clone)] +struct TranslateError { status: StatusCode, - error: &'a str, - extension_code: &'a str, + extension_code: String, extension_details: String, } @@ -826,11 +819,8 @@ pub(crate) fn process_vary_header(headers: &mut HeaderMap) { #[derive(Clone)] pub(crate) struct RouterCreator { pub(crate) supergraph_creator: Arc, - static_page: StaticPageLayer, - apq_layer: APQLayer, - pub(crate) persisted_query_layer: Arc, - query_analysis_layer: QueryAnalysisLayer, - batching: Batching, + sb: Buffer>, + pipeline_handle: Arc, } impl ServiceFactory for RouterCreator { @@ -855,6 +845,10 @@ impl RouterFactory for RouterCreator { .for_each(|p| mm.extend(p.web_endpoints())); mm } + + fn pipeline_ref(&self) -> Arc { + self.pipeline_handle.pipeline_ref.clone() + } } impl RouterCreator { @@ -882,13 +876,44 @@ impl RouterCreator { // For now just call activate to make the gauges work on the happy path. apq_layer.activate(); - Ok(Self { - supergraph_creator, - static_page, + // Create a handle that will help us keep track of this pipeline. + // A metric is exposed that allows the use to see if pipelines are being hung onto. + let schema_id = supergraph_creator.schema().schema_id.to_string(); + let launch_id = supergraph_creator + .schema() + .launch_id + .as_ref() + .map(|launch_id| launch_id.to_string()); + let config_hash = configuration.hash(); + let pipeline_handle = PipelineHandle::new(schema_id, launch_id, config_hash); + + let router_service = content_negotiation::RouterLayer::default().layer(RouterService::new( + supergraph_creator.create(), apq_layer, - query_analysis_layer, persisted_query_layer, - batching: configuration.batching.clone(), + query_analysis_layer, + configuration.batching.clone(), + )); + + // NOTE: This is the start of the router pipeline (router_service) + let sb = Buffer::new( + ServiceBuilder::new() + .layer(static_page.clone()) + .service( + supergraph_creator + .plugins() + .iter() + .rev() + .fold(router_service.boxed(), |acc, (_, e)| e.router_service(acc)), + ) + .boxed(), + DEFAULT_BUFFER_SIZE, + ); + + Ok(Self { + supergraph_creator, + sb, + pipeline_handle: Arc::new(pipeline_handle), }) } @@ -899,24 +924,10 @@ impl RouterCreator { Response = router::Response, Error = BoxError, Future = BoxFuture<'static, router::ServiceResult>, - > + Send { - let router_service = content_negotiation::RouterLayer::default().layer(RouterService::new( - self.supergraph_creator.clone(), - self.apq_layer.clone(), - self.persisted_query_layer.clone(), - self.query_analysis_layer.clone(), - self.batching.clone(), - )); - - ServiceBuilder::new() - .layer(self.static_page.clone()) - .service( - self.supergraph_creator - .plugins() - .iter() - .rev() - .fold(router_service.boxed(), |acc, (_, e)| e.router_service(acc)), - ) + > + Send + + use<> { + // Note: We have to box our cloned service to erase the type of the Buffer. + self.sb.clone().boxed() } } diff --git a/apollo-router/src/services/router/snapshots/apollo_router__services__router__tests__escaped_quotes_in_string_literal.snap b/apollo-router/src/services/router/snapshots/apollo_router__services__router__tests__escaped_quotes_in_string_literal.snap index 4c8165c12c..c433c3d89f 100644 --- a/apollo-router/src/services/router/snapshots/apollo_router__services__router__tests__escaped_quotes_in_string_literal.snap +++ b/apollo-router/src/services/router/snapshots/apollo_router__services__router__tests__escaped_quotes_in_string_literal.snap @@ -35,13 +35,13 @@ expression: "(graphql_response, &subgraph_query_log)" ( "products", Some( - "query TopProducts__products__0($first:Int){topProducts(first:$first){__typename upc name}}", + "query TopProducts__products__0($first: Int) { topProducts(first: $first) { __typename upc name } }", ), ), ( "reviews", Some( - "query TopProducts__reviews__1($representations:[_Any!]!){_entities(representations:$representations){..._generated_onProduct1_0}}fragment _generated_onProduct1_0 on Product{reviewsForAuthor(authorID:\"\\\"1\\\"\"){body}}", + "query TopProducts__reviews__1($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviewsForAuthor(authorID: \"\\\"1\\\"\") { body } } } }", ), ), ], diff --git a/apollo-router/src/services/router/snapshots/apollo_router__services__router__tests__invalid_input_enum.snap b/apollo-router/src/services/router/snapshots/apollo_router__services__router__tests__invalid_input_enum.snap new file mode 100644 index 0000000000..2dc2679da9 --- /dev/null +++ b/apollo-router/src/services/router/snapshots/apollo_router__services__router__tests__invalid_input_enum.snap @@ -0,0 +1,21 @@ +--- +source: apollo-router/src/services/router/tests.rs +expression: response +snapshot_kind: text +--- +{ + "errors": [ + { + "message": "Value \"C\" does not exist in \"InputEnum\" enum.", + "locations": [ + { + "line": 1, + "column": 21 + } + ], + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED" + } + } + ] +} diff --git a/apollo-router/src/services/router/tests.rs b/apollo-router/src/services/router/tests.rs index 94caafd006..edfe6ced8e 100644 --- a/apollo-router/src/services/router/tests.rs +++ b/apollo-router/src/services/router/tests.rs @@ -1,30 +1,32 @@ use std::sync::Arc; -use std::sync::Mutex; use futures::stream::StreamExt; -use http::header::CONTENT_TYPE; -use http::header::VARY; use http::HeaderMap; use http::HeaderValue; use http::Method; +use http::Request; use http::Uri; +use http::header::CONTENT_TYPE; +use http::header::VARY; use mime::APPLICATION_JSON; +use parking_lot::Mutex; use serde_json_bytes::json; use tower::ServiceExt; use tower_service::Service; +use crate::Context; use crate::graphql; +use crate::metrics::FutureMetricsExt; +use crate::services::MULTIPART_DEFER_CONTENT_TYPE; +use crate::services::SupergraphRequest; +use crate::services::SupergraphResponse; use crate::services::router; -use crate::services::router::body::get_body_bytes; +use crate::services::router::body::RouterBody; use crate::services::router::service::from_supergraph_mock_callback; use crate::services::router::service::process_vary_header; use crate::services::subgraph; use crate::services::supergraph; -use crate::services::SupergraphRequest; -use crate::services::SupergraphResponse; -use crate::services::MULTIPART_DEFER_CONTENT_TYPE; use crate::test_harness::make_fake_batch; -use crate::Context; // Test Vary processing @@ -109,7 +111,13 @@ async fn it_extracts_query_and_operation_name() { .try_into() .unwrap(); - router_service.call(get_request).await.unwrap(); + router_service + .ready() + .await + .expect("readied") + .call(get_request) + .await + .unwrap(); // post request let post_request = supergraph::Request::builder() @@ -123,6 +131,9 @@ async fn it_extracts_query_and_operation_name() { .unwrap(); router_service + .ready() + .await + .expect("readied") .call(post_request.try_into().unwrap()) .await .unwrap(); @@ -204,7 +215,7 @@ async fn test_http_max_request_bytes() { in `apollo-router/src/services/supergraph.rs` has changed. \ Please update `CANNED_REQUEST_LEN` accordingly." ); - hyper::Body::from(json_bytes) + router::body::from_bytes(json_bytes) }); let config = serde_json::json!({ "limits": { @@ -259,8 +270,12 @@ async fn it_only_accepts_batch_http_link_mode_for_query_batch() { // Send a request let response = with_config().await.response; assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); - let data: serde_json::Value = - serde_json::from_slice(&get_body_bytes(response.into_body()).await.unwrap()).unwrap(); + let data: serde_json::Value = serde_json::from_slice( + &router::body::into_bytes(response.into_body()) + .await + .unwrap(), + ) + .unwrap(); assert_eq!(expected_response, data); } @@ -272,30 +287,7 @@ async fn it_processes_a_valid_query_batch() { .unwrap(); async fn with_config() -> router::Response { - let http_request = supergraph::Request::canned_builder() - .build() - .unwrap() - .supergraph_request - .map(|req_2: graphql::Request| { - // Create clones of our standard query and update it to have 3 unique queries - let mut req_1 = req_2.clone(); - let mut req_3 = req_2.clone(); - req_1.query = req_2.query.clone().map(|x| x.replace("upc\n", "")); - req_3.query = req_2.query.clone().map(|x| x.replace("id name", "name")); - - // Modify the request so that it is a valid array of 3 requests. - let mut json_bytes_1 = serde_json::to_vec(&req_1).unwrap(); - let mut json_bytes_2 = serde_json::to_vec(&req_2).unwrap(); - let mut json_bytes_3 = serde_json::to_vec(&req_3).unwrap(); - let mut result = vec![b'[']; - result.append(&mut json_bytes_1); - result.push(b','); - result.append(&mut json_bytes_2); - result.push(b','); - result.append(&mut json_bytes_3); - result.push(b']'); - hyper::Body::from(result) - }); + let http_request = batch_with_three_unique_queries(); let config = serde_json::json!({ "batching": { "enabled": true, @@ -312,12 +304,26 @@ async fn it_processes_a_valid_query_batch() { .await .unwrap() } - // Send a request - let response = with_config().await.response; - assert_eq!(response.status(), http::StatusCode::OK); - let data: serde_json::Value = - serde_json::from_slice(&get_body_bytes(response.into_body()).await.unwrap()).unwrap(); - assert_eq!(expected_response, data); + async move { + // Send a request + let response = with_config().await.response; + assert_eq!(response.status(), http::StatusCode::OK); + let data: serde_json::Value = serde_json::from_slice( + &router::body::into_bytes(response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(expected_response, data); + + assert_histogram_sum!( + "apollo.router.operations.batching.size", + 3, + "mode" = "batch_http_link" + ); + } + .with_metrics() + .await; } #[tokio::test] @@ -349,8 +355,12 @@ async fn it_will_not_process_a_query_batch_without_enablement() { // Send a request let response = with_config().await.response; assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); - let data: serde_json::Value = - serde_json::from_slice(&get_body_bytes(response.into_body()).await.unwrap()).unwrap(); + let data: serde_json::Value = serde_json::from_slice( + &router::body::into_bytes(response.into_body()) + .await + .unwrap(), + ) + .unwrap(); assert_eq!(expected_response, data); } @@ -374,7 +384,7 @@ async fn it_will_not_process_a_poorly_formatted_query_batch() { result.push(b','); result.append(&mut json_bytes); // Deliberately omit the required trailing ] - hyper::Body::from(result) + router::body::from_bytes(result) }); let config = serde_json::json!({ "batching": { @@ -395,8 +405,12 @@ async fn it_will_not_process_a_poorly_formatted_query_batch() { // Send a request let response = with_config().await.response; assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); - let data: serde_json::Value = - serde_json::from_slice(&get_body_bytes(response.into_body()).await.unwrap()).unwrap(); + let data: serde_json::Value = serde_json::from_slice( + &router::body::into_bytes(response.into_body()) + .await + .unwrap(), + ) + .unwrap(); assert_eq!(expected_response, data); } @@ -427,7 +441,7 @@ async fn it_will_process_a_non_batched_defered_query() { .supergraph_request .map(|req: graphql::Request| { let bytes = serde_json::to_vec(&req).unwrap(); - hyper::Body::from(bytes) + router::body::from_bytes(bytes) }); let config = serde_json::json!({ "batching": { @@ -448,7 +462,9 @@ async fn it_will_process_a_non_batched_defered_query() { // Send a request let response = with_config().await.response; assert_eq!(response.status(), http::StatusCode::OK); - let bytes = get_body_bytes(response.into_body()).await.unwrap(); + let bytes = router::body::into_bytes(response.into_body()) + .await + .unwrap(); let data = String::from_utf8_lossy(&bytes); assert_eq!(expected_response, data); } @@ -501,7 +517,9 @@ async fn it_will_not_process_a_batched_deferred_query() { // Send a request let response = with_config().await.response; assert_eq!(response.status(), http::StatusCode::NOT_ACCEPTABLE); - let bytes = get_body_bytes(response.into_body()).await.unwrap(); + let bytes = router::body::into_bytes(response.into_body()) + .await + .unwrap(); let data = String::from_utf8_lossy(&bytes); assert_eq!(expected_response, data); } @@ -538,7 +556,7 @@ async fn escaped_quotes_in_string_literal() { let subgraph_query_log_3 = subgraph_query_log_2.clone(); service .map_request(move |request: subgraph::Request| { - subgraph_query_log_3.lock().unwrap().push(( + subgraph_query_log_3.lock().push(( subgraph_name.clone(), request.subgraph_request.body().query.clone(), )); @@ -564,10 +582,188 @@ async fn escaped_quotes_in_string_literal() { .await .unwrap(); let graphql_response = response.next_response().await.unwrap(); - let subgraph_query_log = subgraph_query_log.lock().unwrap(); + let subgraph_query_log = subgraph_query_log.lock(); insta::assert_debug_snapshot!((graphql_response, &subgraph_query_log)); let subgraph_query = subgraph_query_log[1].1.as_ref().unwrap(); // The string literal made it through unchanged: - assert!(subgraph_query.contains(r#"reviewsForAuthor(authorID:"\"1\"")"#)); + assert!(subgraph_query.contains(r#"reviewsForAuthor(authorID: "\"1\"")"#)); +} + +#[tokio::test] +async fn it_processes_a_valid_query_batch_with_maximum_size() { + let expected_response: serde_json::Value = serde_json::from_str(include_str!( + "../query_batching/testdata/expected_good_response.json" + )) + .unwrap(); + + let http_request = batch_with_three_unique_queries(); + let config = serde_json::json!({ + "batching": { + "enabled": true, + "mode" : "batch_http_link", + "maximum_size": 3 + } + }); + + // Send a request + let response = oneshot_request(http_request, config).await.response; + assert_eq!(response.status(), http::StatusCode::OK); + + let data: serde_json::Value = serde_json::from_slice( + &router::body::into_bytes(response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(expected_response, data); +} + +#[tokio::test] +async fn it_will_not_process_a_batch_that_exceeds_the_maximum_size() { + let expected_response: serde_json::Value = serde_json::from_str(include_str!( + "../query_batching/testdata/batch_exceeds_maximum_size_response.json" + )) + .unwrap(); + + // NB: make_fake_batch creates a request with a batch size of 2 + let http_request = make_fake_batch( + supergraph::Request::canned_builder() + .build() + .unwrap() + .supergraph_request, + None, + ); + let config = serde_json::json!({ + "batching": { + "enabled": true, + "mode" : "batch_http_link", + "maximum_size": 1 + } + }); + + // Send a request + let response = oneshot_request(http_request, config).await.response; + assert_eq!(response.status(), http::StatusCode::UNPROCESSABLE_ENTITY); + + let data: serde_json::Value = serde_json::from_slice( + &router::body::into_bytes(response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(expected_response, data); +} + +async fn oneshot_request( + http_request: Request, + config: serde_json::Value, +) -> router::Response { + crate::TestHarness::builder() + .configuration_json(config) + .unwrap() + .build_router() + .await + .unwrap() + .oneshot(router::Request::from(http_request)) + .await + .unwrap() +} + +fn batch_with_three_unique_queries() -> Request { + supergraph::Request::canned_builder() + .build() + .unwrap() + .supergraph_request + .map(|req_2: graphql::Request| { + // Create clones of our standard query and update it to have 3 unique queries + let mut req_1 = req_2.clone(); + let mut req_3 = req_2.clone(); + req_1.query = req_2.query.clone().map(|x| x.replace("upc\n", "")); + req_3.query = req_2.query.clone().map(|x| x.replace("id name", "name")); + + // Modify the request so that it is a valid array of 3 requests. + let mut json_bytes_1 = serde_json::to_vec(&req_1).unwrap(); + let mut json_bytes_2 = serde_json::to_vec(&req_2).unwrap(); + let mut json_bytes_3 = serde_json::to_vec(&req_3).unwrap(); + let mut result = vec![b'[']; + result.append(&mut json_bytes_1); + result.push(b','); + result.append(&mut json_bytes_2); + result.push(b','); + result.append(&mut json_bytes_3); + result.push(b']'); + router::body::from_bytes(result) + }) +} + +const ENUM_SCHEMA: &str = r#"schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { + query: Query + } + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + scalar link__Import + + enum link__Purpose { + SECURITY + EXECUTION + } + + scalar join__FieldSet + + enum join__Graph { + USER @join__graph(name: "user", url: "http://localhost:4001/graphql") + ORGA @join__graph(name: "orga", url: "http://localhost:4002/graphql") + } + type Query @join__type(graph: USER) @join__type(graph: ORGA){ + test(input: InputEnum): String @join__field(graph: USER) + } + + enum InputEnum @join__type(graph: USER) @join__type(graph: ORGA) { + A + B + }"#; + +// Companion test: services::supergraph::tests::invalid_input_enum +#[tokio::test] +async fn invalid_input_enum() { + let service = crate::TestHarness::builder() + .configuration_json(serde_json::json!({ + "include_subgraph_errors": { + "all": true, + }, + })) + .unwrap() + .schema(ENUM_SCHEMA) + .build_router() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query("query { test(input: C) }") + .build() + .unwrap() + .try_into() + .unwrap(); + let response = service + .clone() + .oneshot(request) + .await + .unwrap() + .next_response() + .await + .expect("should have one response") + .unwrap(); + + let response: serde_json::Value = serde_json::from_slice(&response).unwrap(); + + insta::assert_json_snapshot!(response); } diff --git a/apollo-router/src/services/snapshots/apollo_router__services__external__test__it_will_create_an_http_request_span@logs.snap b/apollo-router/src/services/snapshots/apollo_router__services__external__test__it_will_create_an_http_request_span@logs.snap new file mode 100644 index 0000000000..ac8b8f79ad --- /dev/null +++ b/apollo-router/src/services/snapshots/apollo_router__services__external__test__it_will_create_an_http_request_span@logs.snap @@ -0,0 +1,25 @@ +--- +source: apollo-router/src/services/external.rs +expression: yaml +--- +- fields: {} + level: INFO + message: got request + span: + http.request.method: POST + name: http_request + otel.kind: CLIENT + otel.name: "POST http://example.com/test" + otel.original_name: http_request + server.address: example.com + server.port: "80" + url.full: "http://example.com/test" + spans: + - http.request.method: POST + name: http_request + otel.kind: CLIENT + otel.name: "POST http://example.com/test" + otel.original_name: http_request + server.address: example.com + server.port: "80" + url.full: "http://example.com/test" diff --git a/apollo-router/src/services/subgraph.rs b/apollo-router/src/services/subgraph.rs index 987eef24a0..9468b9e933 100644 --- a/apollo-router/src/services/subgraph.rs +++ b/apollo-router/src/services/subgraph.rs @@ -1,5 +1,6 @@ #![allow(missing_docs)] // FIXME +use std::collections::HashSet; use std::fmt::Display; use std::pin::Pin; use std::sync::Arc; @@ -21,18 +22,18 @@ use tokio::sync::mpsc; use tokio_stream::Stream; use tower::BoxError; +use crate::Context; use crate::error::Error; use crate::graphql; -use crate::http_ext::header_map; use crate::http_ext::TryIntoHeaderName; use crate::http_ext::TryIntoHeaderValue; +use crate::http_ext::header_map; use crate::json_ext::Object; use crate::json_ext::Path; use crate::plugins::authentication::APOLLO_AUTHENTICATION_JWT_CLAIMS; use crate::plugins::authorization::CacheKeyMetadata; use crate::query_planner::fetch::OperationKind; -use crate::query_planner::fetch::QueryHash; -use crate::Context; +use crate::spec::QueryHash; pub type BoxService = tower::util::BoxService; pub type BoxCloneService = tower::util::BoxCloneService; @@ -60,9 +61,8 @@ pub struct Request { pub context: Context, - // FIXME for router 2.x - /// Name of the subgraph, it's an Option to not introduce breaking change - pub(crate) subgraph_name: Option, + /// Name of the subgraph + pub(crate) subgraph_name: String, /// Channel to send the subscription stream to listen on events coming from subgraph in a task pub(crate) subscription_stream: Option>, /// Channel triggered when the client connection has been dropped @@ -91,8 +91,9 @@ impl Request { operation_kind: OperationKind, context: Context, subscription_stream: Option>, - subgraph_name: Option, + subgraph_name: String, connection_closed_signal: Option>, + executable_document: Option>>, ) -> Request { Self { supergraph_request, @@ -102,9 +103,12 @@ impl Request { subgraph_name, subscription_stream, connection_closed_signal, - query_hash: Default::default(), + // It's NOT GREAT! to have an empty hash value here. + // This value is populated based on the subgraph query hash in the query planner code. + // At the time of writing it's in `crate::query_planner::fetch::FetchNode::fetch_node`. + query_hash: QueryHash::default().into(), authorization: Default::default(), - executable_document: None, + executable_document, id: SubgraphRequestId::new(), } } @@ -130,8 +134,9 @@ impl Request { operation_kind.unwrap_or(OperationKind::Query), context.unwrap_or_default(), subscription_stream, - subgraph_name, + subgraph_name.unwrap_or_default(), connection_closed_signal, + None, ) } } @@ -205,9 +210,8 @@ assert_impl_all!(Response: Send); #[non_exhaustive] pub struct Response { pub response: http::Response, - // FIXME for router 2.x - /// Name of the subgraph, it's an Option to not introduce breaking change - pub(crate) subgraph_name: Option, + /// Name of the subgraph + pub(crate) subgraph_name: String, pub context: Context, /// unique id matching the corresponding field in the request pub(crate) id: SubgraphRequestId, @@ -224,11 +228,11 @@ impl Response { context: Context, subgraph_name: String, id: SubgraphRequestId, - ) -> Response { + ) -> Self { Self { response, context, - subgraph_name: Some(subgraph_name), + subgraph_name, id, } } @@ -247,9 +251,9 @@ impl Response { status_code: Option, context: Context, headers: Option>, - subgraph_name: Option, + subgraph_name: String, id: Option, - ) -> Response { + ) -> Self { // Build a response let res = graphql::Response::builder() .and_label(label) @@ -268,7 +272,7 @@ impl Response { *response.headers_mut() = headers.unwrap_or_default(); // Warning: the id argument for this builder is an Option to make that a non breaking change - // but this means that if a subgraph response is created explicitely without an id, it will + // but this means that if a subgraph response is created explicitly without an id, it will // be generated here and not match the id from the subgraph request let id = id.unwrap_or_default(); @@ -298,8 +302,8 @@ impl Response { headers: Option>, subgraph_name: Option, id: Option, - ) -> Response { - Response::new( + ) -> Self { + Self::new( label, data, path, @@ -308,7 +312,7 @@ impl Response { status_code, context.unwrap_or_default(), headers, - subgraph_name, + subgraph_name.unwrap_or_default(), id, ) } @@ -333,7 +337,7 @@ impl Response { subgraph_name: Option, id: Option, ) -> Result { - Ok(Response::new( + Ok(Self::new( label, data, path, @@ -342,7 +346,7 @@ impl Response { status_code, context.unwrap_or_default(), Some(header_map(headers)?), - subgraph_name, + subgraph_name.unwrap_or_default(), id, )) } @@ -355,10 +359,10 @@ impl Response { errors: Vec, status_code: Option, context: Context, - subgraph_name: Option, + subgraph_name: String, id: Option, - ) -> Result { - Ok(Response::new( + ) -> Self { + Self::new( Default::default(), Default::default(), Default::default(), @@ -369,13 +373,12 @@ impl Response { Default::default(), subgraph_name, id, - )) + ) } } impl Request { - #[allow(dead_code)] - pub(crate) fn to_sha256(&self) -> String { + pub(crate) fn to_sha256(&self, ignored_headers: &HashSet) -> String { let mut hasher = Sha256::new(); let http_req = &self.subgraph_request; hasher.update(http_req.method().as_str().as_bytes()); @@ -402,7 +405,11 @@ impl Request { } // this assumes headers are in the same order - for (name, value) in http_req.headers() { + for (name, value) in http_req + .headers() + .iter() + .filter(|(name, _)| !ignored_headers.contains(name.as_str())) + { hasher.update(name.as_str().as_bytes()); hasher.update(value.to_str().unwrap_or("ERROR").as_bytes()); } @@ -421,15 +428,70 @@ impl Request { } for (var_name, var_value) in &body.variables { hasher.update(var_name.inner()); - // TODO implement to_bytes() for value in serde_json_bytes - hasher.update(var_value.to_string().as_bytes()); + hasher.update(var_value.to_bytes()); } for (name, val) in &body.extensions { hasher.update(name.inner()); - // TODO implement to_bytes() for value in serde_json_bytes - hasher.update(val.to_string().as_bytes()); + hasher.update(val.to_bytes()); } hex::encode(hasher.finalize()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_subgraph_request_hash() { + let subgraph_req_1 = Request::fake_builder() + .subgraph_request( + http::Request::builder() + .header("public_header", "value") + .header("auth", "my_token") + .body(graphql::Request::default()) + .unwrap(), + ) + .build(); + let subgraph_req_2 = Request::fake_builder() + .subgraph_request( + http::Request::builder() + .header("public_header", "value_bis") + .header("auth", "my_token") + .body(graphql::Request::default()) + .unwrap(), + ) + .build(); + let mut ignored_headers = HashSet::new(); + ignored_headers.insert("public_header".to_string()); + assert_eq!( + subgraph_req_1.to_sha256(&ignored_headers), + subgraph_req_2.to_sha256(&ignored_headers) + ); + + let subgraph_req_1 = Request::fake_builder() + .subgraph_request( + http::Request::builder() + .header("public_header", "value") + .header("auth", "my_token") + .body(graphql::Request::default()) + .unwrap(), + ) + .build(); + let subgraph_req_2 = Request::fake_builder() + .subgraph_request( + http::Request::builder() + .header("public_header", "value_bis") + .header("auth", "my_token") + .body(graphql::Request::default()) + .unwrap(), + ) + .build(); + let ignored_headers = HashSet::new(); + assert_ne!( + subgraph_req_1.to_sha256(&ignored_headers), + subgraph_req_2.to_sha256(&ignored_headers) + ); + } +} diff --git a/apollo-router/src/services/subgraph_service.rs b/apollo-router/src/services/subgraph_service.rs index 9dbb9fb773..bd1b6b8394 100644 --- a/apollo-router/src/services/subgraph_service.rs +++ b/apollo-router/src/services/subgraph_service.rs @@ -1,84 +1,85 @@ //! Tower fetcher for subgraphs. use std::collections::HashMap; +use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering::Relaxed; -use std::sync::Arc; use std::task::Poll; use bytes::Bytes; -use futures::future::BoxFuture; use futures::StreamExt; use futures::TryFutureExt; +use futures::future::BoxFuture; +use http::HeaderValue; +use http::Request; +use http::StatusCode; use http::header::ACCEPT; use http::header::CONTENT_TYPE; use http::header::{self}; use http::response::Parts; -use http::HeaderValue; -use http::Request; -use http::StatusCode; use hyper_rustls::ConfigBuilderExt; use itertools::Itertools; +use mediatype::MediaType; use mediatype::names::APPLICATION; use mediatype::names::JSON; -use mediatype::MediaType; use mime::APPLICATION_JSON; use opentelemetry::Key; use opentelemetry::KeyValue; use rustls::RootCertStore; use serde::Serialize; -use tokio::select; use tokio::sync::oneshot; use tokio_tungstenite::connect_async; use tokio_tungstenite::connect_async_tls_with_config; use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tower::util::BoxService; use tower::BoxError; use tower::Service; use tower::ServiceExt; -use tracing::instrument; +use tower::buffer::Buffer; use tracing::Instrument; +use tracing::instrument; use uuid::Uuid; +use super::Plugins; use super::http::HttpClientServiceFactory; use super::http::HttpRequest; use super::layers::content_negotiation::GRAPHQL_JSON_RESPONSE_HEADER_VALUE; use super::router::body::RouterBody; use super::subgraph::SubgraphRequestId; -use super::Plugins; -use crate::batching::assemble_batch; +use crate::Configuration; +use crate::Context; +use crate::Notify; use crate::batching::BatchQuery; use crate::batching::BatchQueryInfo; +use crate::batching::assemble_batch; use crate::configuration::Batching; use crate::configuration::BatchingMode; use crate::configuration::TlsClientAuth; +use crate::context::OPERATION_NAME; use crate::error::FetchError; use crate::error::SubgraphBatchingError; use crate::graphql; use crate::json_ext::Object; +use crate::layers::DEFAULT_BUFFER_SIZE; use crate::plugins::authentication::subgraph::SigningParamsConfig; use crate::plugins::file_uploads; -use crate::plugins::subscription::create_verifier; use crate::plugins::subscription::CallbackMode; +use crate::plugins::subscription::SUBSCRIPTION_WS_CUSTOM_CONNECTION_PARAMS; use crate::plugins::subscription::SubscriptionConfig; use crate::plugins::subscription::SubscriptionMode; use crate::plugins::subscription::WebSocketConfiguration; -use crate::plugins::subscription::SUBSCRIPTION_WS_CUSTOM_CONNECTION_PARAMS; +use crate::plugins::subscription::create_verifier; use crate::plugins::telemetry::config_new::events::log_event; -use crate::plugins::telemetry::config_new::events::SubgraphEventRequest; -use crate::plugins::telemetry::config_new::events::SubgraphEventResponse; +use crate::plugins::telemetry::config_new::subgraph::events::SubgraphEventRequest; +use crate::plugins::telemetry::config_new::subgraph::events::SubgraphEventResponse; use crate::plugins::telemetry::consts::SUBGRAPH_REQUEST_SPAN_NAME; -use crate::plugins::telemetry::LOGGING_DISPLAY_BODY; -use crate::plugins::telemetry::LOGGING_DISPLAY_HEADERS; -use crate::protocols::websocket::convert_websocket_stream; use crate::protocols::websocket::GraphqlWebSocket; +use crate::protocols::websocket::convert_websocket_stream; use crate::query_planner::OperationKind; -use crate::services::layers::apq; use crate::services::SubgraphRequest; use crate::services::SubgraphResponse; -use crate::Configuration; -use crate::Context; -use crate::Notify; +use crate::services::layers::apq; +use crate::services::router; +use crate::services::subgraph; const PERSISTED_QUERY_NOT_FOUND_EXTENSION_CODE: &str = "PERSISTED_QUERY_NOT_FOUND"; const PERSISTED_QUERY_NOT_SUPPORTED_EXTENSION_CODE: &str = "PERSISTED_QUERY_NOT_SUPPORTED"; @@ -182,21 +183,23 @@ pub(crate) fn generate_tls_client_config( tls_cert_store: Option, client_cert_config: Option<&TlsClientAuth>, ) -> Result { - let tls_builder = rustls::ClientConfig::builder().with_safe_defaults(); + let tls_builder = rustls::ClientConfig::builder(); Ok(match (tls_cert_store, client_cert_config) { - (None, None) => tls_builder.with_native_roots().with_no_client_auth(), + (None, None) => tls_builder.with_native_roots()?.with_no_client_auth(), (Some(store), None) => tls_builder .with_root_certificates(store) .with_no_client_auth(), - (None, Some(client_auth_config)) => tls_builder.with_native_roots().with_client_auth_cert( - client_auth_config.certificate_chain.clone(), - client_auth_config.key.clone(), - )?, + (None, Some(client_auth_config)) => { + tls_builder.with_native_roots()?.with_client_auth_cert( + client_auth_config.certificate_chain.clone(), + client_auth_config.key.clone_key(), + )? + } (Some(store), Some(client_auth_config)) => tls_builder .with_root_certificates(store) .with_client_auth_cert( client_auth_config.certificate_chain.clone(), - client_auth_config.key.clone(), + client_auth_config.key.clone_key(), )?, }) } @@ -229,8 +232,8 @@ impl tower::Service for SubgraphService { }); } }; - if subscription_config.enable_deduplication { - request.to_sha256() + if subscription_config.deduplication.enabled { + request.to_sha256(&subscription_config.deduplication.ignored_headers) } else { Uuid::new_v4().to_string() } @@ -286,9 +289,11 @@ impl tower::Service for SubgraphService { // Hash the subgraph_request let subscription_id = hashed_request; + let operation_name = + context.get::<_, String>(OPERATION_NAME).ok().flatten(); // Call create_or_subscribe on notify let (handle, created) = notify - .create_or_subscribe(subscription_id.clone(), true) + .create_or_subscribe(subscription_id.clone(), true, operation_name) .await?; // If it existed before just send the right stream (handle) and early return @@ -300,17 +305,15 @@ impl tower::Service for SubgraphService { })?; stream_tx.send(Box::pin(handle.into_stream())).await?; - tracing::info!( - monotonic_counter.apollo.router.operations.subscriptions = 1u64, - subscriptions.mode = %"callback", + u64_counter!( + "apollo.router.operations.subscriptions", + "Total requests with subscription operations", + 1, + subscriptions.mode = "callback", subscriptions.deduplicated = !created, - subgraph.service.name = service_name, + subgraph.service.name = service_name.clone() ); if !created { - tracing::info!( - monotonic_counter.apollo_router_deduplicated_subscriptions_total = 1u64, - mode = %"callback", - ); // Dedup happens here return Ok(SubgraphResponse::builder() .subgraph_name(service_name.clone()) @@ -480,21 +483,17 @@ async fn call_websocket( let subgraph_request_event = context .extensions() .with_lock(|lock| lock.get::().cloned()); - let log_request_level = subgraph_request_event.and_then(|s| match s.0.condition() { - Some(condition) => { - if condition.lock().evaluate_request(&request) == Some(true) { - Some(s.0.level()) - } else { - None - } + let log_request_level = subgraph_request_event.and_then(|s| { + if s.condition.lock().evaluate_request(&request) == Some(true) { + Some(s.level) + } else { + None } - None => Some(s.0.level()), }); let SubgraphRequest { subgraph_request, subscription_stream, - connection_closed_signal, id: subgraph_request_id, .. } = request; @@ -503,24 +502,22 @@ async fn call_websocket( service: service_name.clone(), reason: "cannot get the websocket stream".to_string(), })?; - + let supergraph_operation_name = context.get::<_, String>(OPERATION_NAME).ok().flatten(); let (handle, created) = notify - .create_or_subscribe(subscription_hash.clone(), false) + .create_or_subscribe(subscription_hash.clone(), false, supergraph_operation_name) .await?; - tracing::info!( - monotonic_counter.apollo.router.operations.subscriptions = 1u64, - subscriptions.mode = %"passthrough", + u64_counter!( + "apollo.router.operations.subscriptions", + "Total requests with subscription operations", + 1, + subscriptions.mode = "passthrough", subscriptions.deduplicated = !created, - subgraph.service.name = service_name, + subgraph.service.name = service_name.clone() ); if !created { subscription_stream_tx .send(Box::pin(handle.into_stream())) .await?; - tracing::info!( - monotonic_counter.apollo_router_deduplicated_subscriptions_total = 1u64, - mode = %"passthrough", - ); // Dedup happens here return Ok(SubgraphResponse::builder() @@ -547,9 +544,6 @@ async fn call_websocket( let request = get_websocket_request(service_name.clone(), parts, subgraph_cfg)?; - let display_headers = context.contains_key(LOGGING_DISPLAY_HEADERS); - let display_body = context.contains_key(LOGGING_DISPLAY_BODY); - let signing_params = context .extensions() .with_lock(|lock| lock.get::>().cloned()); @@ -596,14 +590,6 @@ async fn call_websocket( ); } - if display_headers { - tracing::info!(http.request.headers = ?request.headers(), apollo.subgraph.name = %service_name, "Websocket request headers to subgraph {service_name:?}"); - } - - if display_body { - tracing::info!(http.request.body = ?request.body(), apollo.subgraph.name = %service_name, "Websocket request body to subgraph {service_name:?}"); - } - let uri = request.uri(); let path = uri.path(); let host = uri.host().unwrap_or_default(); @@ -629,7 +615,7 @@ async fn call_websocket( "graphql.operation.name" = %operation_name, ); - let (ws_stream, mut resp) = match request.uri().scheme_str() { + let (ws_stream, resp) = match request.uri().scheme_str() { Some("wss") => { connect_async_tls_with_config(request, None, false, None) .instrument(subgraph_req_span) @@ -638,26 +624,46 @@ async fn call_websocket( _ => connect_async(request).instrument(subgraph_req_span).await, } .map_err(|err| { - if display_body || display_headers { - tracing::info!( - http.response.error = format!("{:?}", &err), apollo.subgraph.name = %service_name, "Websocket connection error from subgraph {service_name:?} received" - ); - } + let error_details = match &err { + tokio_tungstenite::tungstenite::Error::Utf8(details) => { + format!("invalid UTF-8 in WebSocket handshake: {details}") + } + + tokio_tungstenite::tungstenite::Error::Http(response) => { + let status = response.status(); + let headers = response + .headers() + .iter() + .map(|(k, v)| { + let header_value = v.to_str().unwrap_or("HTTP Error"); + format!("{k:?}: {header_value:?}") + }) + .collect::>() + .join("; "); + + format!("WebSocket upgrade failed. Status: {status}; Headers: [{headers}]") + } + + tokio_tungstenite::tungstenite::Error::Protocol(proto_err) => { + format!("WebSocket protocol error: {proto_err}") + } + + other_error => other_error.to_string(), + }; + + tracing::debug!( + error.type = "websocket_connection_failed", + error.details= %error_details, + error.source = %std::any::type_name_of_val(&err), + "WebSocket connection failed" + ); + FetchError::SubrequestWsError { service: service_name.clone(), - reason: format!("cannot connect websocket to subgraph: {err}"), + reason: format!("cannot connect websocket to subgraph: {error_details}"), } })?; - if display_headers { - tracing::info!(response.headers = ?resp.headers(), apollo.subgraph.name = %service_name, "Websocket response headers to subgraph {service_name:?}"); - } - if display_body { - tracing::info!( - response.body = %String::from_utf8_lossy(&resp.body_mut().take().unwrap_or_default()), apollo.subgraph.name = %service_name, "Websocket response body from subgraph {service_name:?} received" - ); - } - let gql_socket = GraphqlWebSocket::new( convert_websocket_stream(ws_stream, subscription_hash.clone()), subscription_hash, @@ -680,26 +686,16 @@ async fn call_websocket( let (handle_sink, handle_stream) = handle.split(); + // Forward GraphQL subscription stream to WebSocket handle + // Connection lifecycle is managed by the WebSocket infrastructure, + // so we don't need to handle connection_closed_signal here tokio::task::spawn(async move { - match connection_closed_signal { - Some(mut connection_closed_signal) => select! { - // We prefer to specify the order of checks within the select - biased; - _ = gql_stream - .map(Ok::<_, graphql::Error>) - .forward(handle_sink) => { - tracing::debug!("gql_stream empty"); - }, - _ = connection_closed_signal.recv() => { - tracing::debug!("connection_closed_signal triggered"); - } - }, - None => { - let _ = gql_stream - .map(Ok::<_, graphql::Error>) - .forward(handle_sink) - .await; - } + if let Err(e) = gql_stream + .map(Ok::<_, graphql::Error>) + .forward(handle_sink) + .await + { + tracing::debug!("WebSocket subscription stream ended: {}", e); } }); @@ -743,7 +739,11 @@ fn http_response_to_graphql_response( // Application json expects valid graphql response if 2xx tracing::debug_span!("parse_subgraph_response").in_scope(|| { // Application graphql json expects valid graphql response - graphql::Response::from_bytes(service_name, body).unwrap_or_else(|error| { + graphql::Response::from_bytes(body).unwrap_or_else(|error| { + let error = FetchError::SubrequestMalformedResponse { + service: service_name.to_owned(), + reason: error.reason, + }; graphql::Response::builder() .error(error.to_graphql_error(None)) .build() @@ -759,7 +759,7 @@ fn http_response_to_graphql_response( if original_response.is_empty() { original_response = "".into() } - graphql::Response::from_bytes(service_name, body).unwrap_or_else(|_error| { + graphql::Response::from_bytes(body).unwrap_or_else(|_error| { graphql::Response::builder() .error( FetchError::SubrequestMalformedResponse { @@ -859,56 +859,62 @@ pub(crate) async fn process_batch( .expect("we have at least one context in the batch") .0 .clone(); - let display_body = batch_context.contains_key(LOGGING_DISPLAY_BODY); let client = client_factory.create(&service); // Update our batching metrics (just before we fetch) - tracing::info!(histogram.apollo.router.operations.batching.size = listener_count as f64, - mode = %BatchingMode::BatchHttpLink, // Only supported mode right now - subgraph = &service + u64_histogram!( + "apollo.router.operations.batching.size", + "Number of queries contained within each query batch", + listener_count as u64, + mode = BatchingMode::BatchHttpLink.to_string(), // Only supported mode right now + subgraph = service.clone() ); - tracing::info!(monotonic_counter.apollo.router.operations.batching = 1u64, - mode = %BatchingMode::BatchHttpLink, // Only supported mode right now - subgraph = &service + u64_counter!( + "apollo.router.operations.batching", + "Total requests with batched operations", + 1, + // XXX(@goto-bus-stop): Should these be `batching.mode`, `batching.subgraph`? + // Also, other metrics use a different convention to report the subgraph name + mode = BatchingMode::BatchHttpLink.to_string(), // Only supported mode right now + subgraph = service.clone() ); // Perform the actual fetch. If this fails then we didn't manage to make the call at all, so we can't do anything with it. tracing::debug!("fetching from subgraph: {service}"); - let (parts, content_type, body) = - match do_fetch(client, &batch_context, &service, request, display_body) - .instrument(subgraph_req_span) - .await - { - Ok(res) => res, - Err(err) => { - let resp = http::Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(err.to_graphql_error(None)) - .map_err(|err| FetchError::SubrequestHttpError { - status_code: None, - service: service.clone(), - reason: format!("cannot create the http response from error: {err:?}"), - })?; - let (parts, body) = resp.into_parts(); - let body = - serde_json::to_vec(&body).map_err(|err| FetchError::SubrequestHttpError { - status_code: None, - service: service.clone(), - reason: format!("cannot serialize the error: {err:?}"), - })?; - ( - parts, - Ok(ContentType::ApplicationJson), - Some(Ok(body.into())), - ) - } - }; + let (parts, content_type, body) = match do_fetch(client, &batch_context, &service, request) + .instrument(subgraph_req_span) + .await + { + Ok(res) => res, + Err(err) => { + let resp = http::Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(err.to_graphql_error(None)) + .map_err(|err| FetchError::SubrequestHttpError { + status_code: None, + service: service.clone(), + reason: format!("cannot create the http response from error: {err:?}"), + })?; + let (parts, body) = resp.into_parts(); + let body = + serde_json::to_vec(&body).map_err(|err| FetchError::SubrequestHttpError { + status_code: None, + service: service.clone(), + reason: format!("cannot serialize the error: {err:?}"), + })?; + ( + parts, + Ok(ContentType::ApplicationJson), + Some(Ok(body.into())), + ) + } + }; let subgraph_response_event = batch_context .extensions() .with_lock(|lock| lock.get::().cloned()); - if let Some(level) = subgraph_response_event { + if let Some(event) = subgraph_response_event { let mut attrs = Vec::with_capacity(5); attrs.push(KeyValue::new( Key::from_static_str("http.response.headers"), @@ -933,21 +939,13 @@ pub(crate) async fn process_batch( opentelemetry::Value::String(service.clone().into()), )); log_event( - level.0.level(), + event.level, "subgraph.response", attrs, &format!("Raw response from subgraph {service:?} received"), ); } - if display_body { - if let Some(Ok(b)) = &body { - tracing::info!( - response.body = %String::from_utf8_lossy(b), apollo.subgraph.name = %&service, "Raw response body from subgraph {service:?} received" - ); - } - } - tracing::debug!("parts: {parts:?}, content_type: {content_type:?}, body: {body:?}"); let value = serde_json::from_slice(&body.ok_or(FetchError::SubrequestMalformedResponse { @@ -1224,15 +1222,12 @@ pub(crate) async fn call_single_http( let subgraph_request_event = context .extensions() .with_lock(|lock| lock.get::().cloned()); - let log_request_level = subgraph_request_event.and_then(|s| match s.0.condition() { - Some(condition) => { - if condition.lock().evaluate_request(&request) == Some(true) { - Some(s.0.level()) - } else { - None - } + let log_request_level = subgraph_request_event.and_then(|s| { + if s.condition.lock().evaluate_request(&request) == Some(true) { + Some(s.level) + } else { + None } - None => Some(s.0.level()), }); let SubgraphRequest { @@ -1250,7 +1245,7 @@ pub(crate) async fn call_single_http( let (parts, _) = subgraph_request.into_parts(); let body = serde_json::to_string(&body)?; tracing::debug!("our JSON body: {body:?}"); - let mut request = http::Request::from_parts(parts, RouterBody::from(body)); + let mut request = http::Request::from_parts(parts, router::body::from_bytes(body)); request .headers_mut() @@ -1287,8 +1282,6 @@ pub(crate) async fn call_single_http( // 2. If an HTTP status is not 2xx it will always be attached as a graphql error. // 3. If the response type is `application/json` and status is not 2xx and the body the entire body will be output if the response is not valid graphql. - let display_body = context.contains_key(LOGGING_DISPLAY_BODY); - // TODO: Temporary solution to plug FileUploads plugin until 'http_client' will be fixed https://github.com/apollographql/router/pull/4666 let request = file_uploads::http_request_wrapper(request).await; @@ -1324,55 +1317,46 @@ pub(crate) async fn call_single_http( } // Perform the actual fetch. If this fails then we didn't manage to make the call at all, so we can't do anything with it. - let (parts, content_type, body) = - match do_fetch(client, &context, service_name, request, display_body) - .instrument(subgraph_req_span) - .await - { - Ok(resp) => resp, - Err(err) => { - return Ok(SubgraphResponse::builder() - .subgraph_name(service_name.to_string()) - .error(err.to_graphql_error(None)) - .status_code(StatusCode::INTERNAL_SERVER_ERROR) - .context(context) - .extensions(Object::default()) - .build()); - } - }; + let (parts, content_type, body) = match do_fetch(client, &context, service_name, request) + .instrument(subgraph_req_span) + .await + { + Ok(resp) => resp, + Err(err) => { + return Ok(SubgraphResponse::builder() + .subgraph_name(service_name.to_string()) + .error(err.to_graphql_error(None)) + .status_code(StatusCode::INTERNAL_SERVER_ERROR) + .context(context) + .extensions(Object::default()) + .build()); + } + }; let subgraph_response_event = context .extensions() .with_lock(|lock| lock.get::().cloned()); - if display_body { - if let Some(Ok(b)) = &body { - tracing::info!( - response.body = %String::from_utf8_lossy(b), apollo.subgraph.name = %service_name, "Raw response body from subgraph {service_name:?} received" - ); - } - } - if let Some(subgraph_response_event) = subgraph_response_event { - let mut should_log = true; - if let Some(condition) = subgraph_response_event.0.condition() { - // We have to do this in order to use selectors - let mut resp_builder = http::Response::builder() - .status(parts.status) - .version(parts.version); - if let Some(headers) = resp_builder.headers_mut() { - *headers = parts.headers.clone(); - } - let subgraph_response = SubgraphResponse::new_from_response( - resp_builder - .body(graphql::Response::default()) - .expect("it won't fail everything is coming from an existing response"), - context.clone(), - service_name.to_owned(), - subgraph_request_id.clone(), - ); - should_log = condition.lock().evaluate_response(&subgraph_response); + // We have to do this in order to use selectors + let mut resp_builder = http::Response::builder() + .status(parts.status) + .version(parts.version); + if let Some(headers) = resp_builder.headers_mut() { + *headers = parts.headers.clone(); } + let subgraph_response = SubgraphResponse::new_from_response( + resp_builder + .body(graphql::Response::default()) + .expect("it won't fail everything is coming from an existing response"), + context.clone(), + service_name.to_owned(), + subgraph_request_id.clone(), + ); + + let should_log = subgraph_response_event + .condition + .evaluate_response(&subgraph_response); if should_log { let mut attrs = Vec::with_capacity(5); attrs.push(KeyValue::new( @@ -1398,7 +1382,7 @@ pub(crate) async fn call_single_http( opentelemetry::Value::String(service_name.to_string().into()), )); log_event( - subgraph_response_event.0.level(), + subgraph_response_event.level, "subgraph.response", attrs, &format!("Raw response from subgraph {service_name:?} received"), @@ -1443,12 +1427,10 @@ fn get_graphql_content_type(service_name: &str, parts: &Parts) -> Result Err(format!( - "subgraph response contains unsupported content-type: {}", - mime, + "subgraph response contains unsupported content-type: {mime}", )), None => Err(format!( - "subgraph response contains invalid 'content-type' header value {:?}", - raw_content_type, + "subgraph response contains invalid 'content-type' header value {raw_content_type:?}", )), } } else { @@ -1471,7 +1453,6 @@ async fn do_fetch( context: &Context, service_name: &str, request: Request, - display_body: bool, ) -> Result< ( Parts, @@ -1480,7 +1461,6 @@ async fn do_fetch( ), FetchError, > { - let _active_request_guard = context.enter_active_request(); let response = client .call(HttpRequest { http_request: request, @@ -1501,8 +1481,7 @@ async fn do_fetch( let content_type = get_graphql_content_type(service_name, &parts); let body = if content_type.is_ok() { - let body = body - .to_bytes() + let body = router::body::into_bytes(body) .instrument(tracing::debug_span!("aggregate_response_data")) .await .map_err(|err| { @@ -1513,34 +1492,8 @@ async fn do_fetch( reason: err.to_string(), } }); - if let Ok(body) = &body { - if display_body { - tracing::info!( - http.response.body = %String::from_utf8_lossy(body), apollo.subgraph.name = %service_name, "Raw response body from subgraph {service_name:?} received" - ); - } - } Some(body) } else { - if display_body { - let body = body - .to_bytes() - .instrument(tracing::debug_span!("aggregate_response_data")) - .await - .map_err(|err| { - tracing::error!(fetch_error = ?err); - FetchError::SubrequestHttpError { - status_code: Some(parts.status.as_u16()), - service: service_name.to_string(), - reason: err.to_string(), - } - }); - if let Ok(body) = &body { - tracing::info!( - http.response.body = %String::from_utf8_lossy(body), apollo.subgraph.name = %service_name, "Raw response body from subgraph {service_name:?} received" - ); - } - } None }; Ok((parts, content_type, body)) @@ -1581,7 +1534,12 @@ fn get_websocket_request( })?, None => subgraph_url, }; - let mut request = subgraph_url.into_client_request().map_err(|err| { + // XXX During hyper upgrade, observed that we had lost the implementation for Url + // so I made the expedient decision to get a string representation (as_str()) + // for the creation of the client request. This works fine, but I'm not sure + // why we need to do it, because into_client_request **should** be implemented + // for Url... + let mut request = subgraph_url.as_str().into_client_request().map_err(|err| { tracing::error!("cannot create websocket client request: {err:?}"); FetchError::SubrequestWsError { @@ -1625,8 +1583,9 @@ fn get_apq_error(gql_response: &graphql::Response) -> APQError { #[derive(Clone)] pub(crate) struct SubgraphServiceFactory { - pub(crate) services: Arc>>, - pub(crate) plugins: Arc, + pub(crate) services: Arc< + HashMap>>, + >, } impl SubgraphServiceFactory { @@ -1634,23 +1593,27 @@ impl SubgraphServiceFactory { services: Vec<(String, Arc)>, plugins: Arc, ) -> Self { + let mut map = HashMap::with_capacity(services.len()); + for (name, maker) in services.into_iter() { + let service = Buffer::new( + plugins + .iter() + .rev() + .fold(maker.make(), |acc, (_, e)| e.subgraph_service(&name, acc)) + .boxed(), + DEFAULT_BUFFER_SIZE, + ); + map.insert(name, service); + } + SubgraphServiceFactory { - services: Arc::new(services.into_iter().collect()), - plugins, + services: Arc::new(map), } } - pub(crate) fn create( - &self, - name: &str, - ) -> Option> { - self.services.get(name).map(|service| { - let service = service.make(); - self.plugins - .iter() - .rev() - .fold(service, |acc, (_, e)| e.subgraph_service(name, acc)) - }) + pub(crate) fn create(&self, name: &str) -> Option { + // Note: We have to box our cloned service to erase the type of the Buffer. + self.services.get(name).map(|svc| svc.clone().boxed()) } } @@ -1658,7 +1621,7 @@ impl SubgraphServiceFactory { /// /// there can be multiple instances of that service executing at any given time pub(crate) trait MakeSubgraphService: Send + Sync + 'static { - fn make(&self) -> BoxService; + fn make(&self) -> subgraph::BoxService; } impl MakeSubgraphService for S @@ -1670,7 +1633,7 @@ where + 'static, >::Future: Send, { - fn make(&self) -> BoxService { + fn make(&self) -> subgraph::BoxService { self.clone().boxed() } } @@ -1679,46 +1642,77 @@ where mod tests { use std::convert::Infallible; use std::net::SocketAddr; - use std::net::TcpListener; use std::str::FromStr; - use axum::extract::ws::Message; + use SubgraphRequest; + use axum::Router; + use axum::body::Body; use axum::extract::ConnectInfo; use axum::extract::WebSocketUpgrade; + use axum::extract::ws::Message; use axum::response::IntoResponse; use axum::routing::get; - use axum::Router; - use axum::Server; use bytes::Buf; use futures::StreamExt; - use http::header::HOST; use http::StatusCode; use http::Uri; - use hyper::service::make_service_fn; - use hyper::Body; + use http::header::HOST; use serde_json_bytes::ByteString; use serde_json_bytes::Value; + use tokio::net::TcpListener; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; - use tower::service_fn; use tower::ServiceExt; use url::Url; - use SubgraphRequest; use super::*; + use crate::Context; + use crate::assert_response_eq_ignoring_error_id; use crate::graphql::Error; use crate::graphql::Request; use crate::graphql::Response; + use crate::plugins::subscription::DeduplicationConfig; use crate::plugins::subscription::HeartbeatInterval; + use crate::plugins::subscription::SUBSCRIPTION_CALLBACK_HMAC_KEY; use crate::plugins::subscription::SubgraphPassthroughMode; use crate::plugins::subscription::SubscriptionModeConfig; - use crate::plugins::subscription::SUBSCRIPTION_CALLBACK_HMAC_KEY; use crate::protocols::websocket::ClientMessage; use crate::protocols::websocket::ServerMessage; use crate::protocols::websocket::WebSocketProtocol; use crate::query_planner::fetch::OperationKind; - use crate::services::router::body::get_body_bytes; - use crate::Context; + use crate::services::router; + + async fn serve(listener: TcpListener, handle: Handler) -> std::io::Result<()> + where + Handler: (Fn(http::Request) -> Fut) + Clone + Sync + Send + 'static, + Fut: + std::future::Future, Infallible>> + Send + 'static, + { + use hyper::body::Incoming; + use hyper_util::rt::TokioExecutor; + use hyper_util::rt::TokioIo; + + // Not sure this is the *right* place to do it, because it's actually clients that + // use crypto, not the server. + + loop { + let (stream, _) = listener.accept().await?; + let io = TokioIo::new(stream); + let handle = handle.clone(); + tokio::spawn(async move { + // N.B. should use hyper service_fn here, since it's required to be implemented hyper Service trait! + let svc = hyper::service::service_fn(|request: http::Request| { + handle(request.map(Body::new)) + }); + if let Err(err) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(io, svc) + .await + { + eprintln!("server error: {err}"); + } + }); + } + } // starts a local server emulating a subgraph returning status code 400 async fn emulate_subgraph_bad_request(listener: TcpListener) { @@ -1728,10 +1722,12 @@ mod tests { .status(StatusCode::BAD_REQUEST) .body( serde_json::to_string(&Response { - errors: vec![Error::builder() - .message("This went wrong") - .extension_code("FETCH_ERROR") - .build()], + errors: vec![ + Error::builder() + .message("This went wrong") + .extension_code("FETCH_ERROR") + .build(), + ], ..Response::default() }) .expect("always valid") @@ -1740,9 +1736,7 @@ mod tests { .unwrap()) } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning status code 401 @@ -1755,9 +1749,7 @@ mod tests { .unwrap()) } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning connection closed @@ -1767,8 +1759,10 @@ mod tests { panic!("test") } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); + let server = axum::serve( + listener, + Router::new().route("/", axum::routing::any_service(tower::service_fn(handle))), + ); server.await.unwrap(); } @@ -1782,9 +1776,7 @@ mod tests { .unwrap()) } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning bad response format @@ -1799,9 +1791,7 @@ mod tests { .unwrap()) } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning bad response format @@ -1816,9 +1806,7 @@ mod tests { .unwrap()) } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning bad response format @@ -1831,9 +1819,7 @@ mod tests { .unwrap()) } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning bad response format @@ -1846,9 +1832,7 @@ mod tests { .unwrap()) } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning response with missing content_type @@ -1860,9 +1844,7 @@ mod tests { .unwrap()) } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning response with invalid content_type @@ -1875,9 +1857,7 @@ mod tests { .unwrap()) } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning unsupported content_type @@ -1890,9 +1870,7 @@ mod tests { .unwrap()) } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning response with @@ -1900,7 +1878,7 @@ mod tests { async fn emulate_persisted_query_not_supported_message(listener: TcpListener) { async fn handle(request: http::Request) -> Result, Infallible> { let (_, body) = request.into_parts(); - let graphql_request: Result = get_body_bytes(body) + let graphql_request: Result = router::body::into_bytes(body) .await .map_err(|_| ()) .and_then(|bytes| serde_json::from_reader(bytes.reader()).map_err(|_| ())) @@ -1914,10 +1892,12 @@ mod tests { .body( serde_json::to_string(&Response { data: Some(Value::String(ByteString::from("test"))), - errors: vec![Error::builder() - .message(PERSISTED_QUERY_NOT_SUPPORTED_MESSAGE) - .extension_code("Random code") - .build()], + errors: vec![ + Error::builder() + .message(PERSISTED_QUERY_NOT_SUPPORTED_MESSAGE) + .extension_code("Random code") + .build(), + ], ..Response::default() }) .expect("always valid") @@ -1926,7 +1906,7 @@ mod tests { .unwrap()); } - return Ok(http::Response::builder() + Ok(http::Response::builder() .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) .status(StatusCode::OK) .body( @@ -1937,7 +1917,7 @@ mod tests { .expect("always valid") .into(), ) - .unwrap()); + .unwrap()) } Err(_) => { panic!("invalid graphql request recieved") @@ -1945,9 +1925,7 @@ mod tests { } } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning response with @@ -1955,7 +1933,7 @@ mod tests { async fn emulate_persisted_query_not_supported_extension_code(listener: TcpListener) { async fn handle(request: http::Request) -> Result, Infallible> { let (_, body) = request.into_parts(); - let graphql_request: Result = get_body_bytes(body) + let graphql_request: Result = router::body::into_bytes(body) .await .map_err(|_| ()) .and_then(|bytes| serde_json::from_reader(bytes.reader()).map_err(|_| ())) @@ -1969,12 +1947,14 @@ mod tests { .body( serde_json::to_string(&Response { data: Some(Value::String(ByteString::from("test"))), - errors: vec![Error::builder() - .message("Random message") - .extension_code( - PERSISTED_QUERY_NOT_SUPPORTED_EXTENSION_CODE, - ) - .build()], + errors: vec![ + Error::builder() + .message("Random message") + .extension_code( + PERSISTED_QUERY_NOT_SUPPORTED_EXTENSION_CODE, + ) + .build(), + ], ..Response::default() }) .expect("always valid") @@ -1983,7 +1963,7 @@ mod tests { .unwrap()); } - return Ok(http::Response::builder() + Ok(http::Response::builder() .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) .status(StatusCode::OK) .body( @@ -1994,7 +1974,7 @@ mod tests { .expect("always valid") .into(), ) - .unwrap()); + .unwrap()) } Err(_) => { panic!("invalid graphql request recieved") @@ -2002,9 +1982,7 @@ mod tests { } } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning response with @@ -2012,7 +1990,7 @@ mod tests { async fn emulate_persisted_query_not_found_message(listener: TcpListener) { async fn handle(request: http::Request) -> Result, Infallible> { let (_, body) = request.into_parts(); - let graphql_request: Result = get_body_bytes(body) + let graphql_request: Result = router::body::into_bytes(body) .await .map_err(|_| ()) .and_then(|bytes| serde_json::from_reader(bytes.reader()).map_err(|_| ())) @@ -2021,28 +1999,32 @@ mod tests { match graphql_request { Ok(request) => { if !request.extensions.contains_key(PERSISTED_QUERY_KEY) { - panic!("Recieved request without persisted query in persisted_query_not_found test.") + panic!( + "Recieved request without persisted query in persisted_query_not_found test." + ) } if request.query.is_none() { - return Ok(http::Response::builder() + Ok(http::Response::builder() .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) .status(StatusCode::OK) .body( serde_json::to_string(&Response { data: Some(Value::String(ByteString::from("test"))), - errors: vec![Error::builder() - .message(PERSISTED_QUERY_NOT_FOUND_MESSAGE) - .extension_code("Random Code") - .build()], + errors: vec![ + Error::builder() + .message(PERSISTED_QUERY_NOT_FOUND_MESSAGE) + .extension_code("Random Code") + .build(), + ], ..Response::default() }) .expect("always valid") .into(), ) - .unwrap()); + .unwrap()) } else { - return Ok(http::Response::builder() + Ok(http::Response::builder() .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) .status(StatusCode::OK) .body( @@ -2053,7 +2035,7 @@ mod tests { .expect("always valid") .into(), ) - .unwrap()); + .unwrap()) } } Err(_) => { @@ -2062,9 +2044,7 @@ mod tests { } } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning response with @@ -2072,7 +2052,7 @@ mod tests { async fn emulate_persisted_query_not_found_extension_code(listener: TcpListener) { async fn handle(request: http::Request) -> Result, Infallible> { let (_, body) = request.into_parts(); - let graphql_request: Result = get_body_bytes(body) + let graphql_request: Result = router::body::into_bytes(body) .await .map_err(|_| ()) .and_then(|bytes| serde_json::from_reader(bytes.reader()).map_err(|_| ())) @@ -2081,28 +2061,34 @@ mod tests { match graphql_request { Ok(request) => { if !request.extensions.contains_key(PERSISTED_QUERY_KEY) { - panic!("Recieved request without persisted query in persisted_query_not_found test.") + panic!( + "Recieved request without persisted query in persisted_query_not_found test." + ) } if request.query.is_none() { - return Ok(http::Response::builder() + Ok(http::Response::builder() .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) .status(StatusCode::OK) .body( serde_json::to_string(&Response { data: Some(Value::String(ByteString::from("test"))), - errors: vec![Error::builder() - .message("Random message") - .extension_code(PERSISTED_QUERY_NOT_FOUND_EXTENSION_CODE) - .build()], + errors: vec![ + Error::builder() + .message("Random message") + .extension_code( + PERSISTED_QUERY_NOT_FOUND_EXTENSION_CODE, + ) + .build(), + ], ..Response::default() }) .expect("always valid") .into(), ) - .unwrap()); + .unwrap()) } else { - return Ok(http::Response::builder() + Ok(http::Response::builder() .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) .status(StatusCode::OK) .body( @@ -2113,7 +2099,7 @@ mod tests { .expect("always valid") .into(), ) - .unwrap()); + .unwrap()) } } Err(_) => { @@ -2122,9 +2108,7 @@ mod tests { } } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning a response to request with apq @@ -2132,7 +2116,7 @@ mod tests { async fn emulate_expected_apq_enabled_configuration(listener: TcpListener) { async fn handle(request: http::Request) -> Result, Infallible> { let (_, body) = request.into_parts(); - let graphql_request: Result = get_body_bytes(body) + let graphql_request: Result = router::body::into_bytes(body) .await .map_err(|_| ()) .and_then(|bytes| serde_json::from_reader(bytes.reader()).map_err(|_| ())) @@ -2144,7 +2128,7 @@ mod tests { panic!("persistedQuery expected when configuration has apq_enabled=true") } - return Ok(http::Response::builder() + Ok(http::Response::builder() .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) .status(StatusCode::OK) .body( @@ -2155,7 +2139,7 @@ mod tests { .expect("always valid") .into(), ) - .unwrap()); + .unwrap()) } Err(_) => { panic!("invalid graphql request recieved") @@ -2163,9 +2147,7 @@ mod tests { } } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } // starts a local server emulating a subgraph returning a response to request without apq @@ -2173,7 +2155,7 @@ mod tests { async fn emulate_expected_apq_disabled_configuration(listener: TcpListener) { async fn handle(request: http::Request) -> Result, Infallible> { let (_, body) = request.into_parts(); - let graphql_request: Result = get_body_bytes(body) + let graphql_request: Result = router::body::into_bytes(body) .await .map_err(|_| ()) .and_then(|bytes| serde_json::from_reader(bytes.reader()).map_err(|_| ())) @@ -2187,7 +2169,7 @@ mod tests { ) } - return Ok(http::Response::builder() + Ok(http::Response::builder() .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) .status(StatusCode::OK) .body( @@ -2198,7 +2180,7 @@ mod tests { .expect("always valid") .into(), ) - .unwrap()); + .unwrap()) } Err(_) => { panic!("invalid graphql request recieved") @@ -2206,9 +2188,7 @@ mod tests { } } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } async fn emulate_correct_websocket_server(listener: TcpListener) { @@ -2218,13 +2198,13 @@ mod tests { ) -> Result { // finalize the upgrade process by returning upgrade callback. // we can customize the callback by sending additional info such as address. - let res = ws.on_upgrade(move |mut socket| async move { + let res = ws.protocols(["graphql-transport-ws"]).on_upgrade(move |mut socket| async move { let connection_ack = socket.recv().await.unwrap().unwrap().into_text().unwrap(); let ack_msg: ClientMessage = serde_json::from_str(&connection_ack).unwrap(); assert!(matches!(ack_msg, ClientMessage::ConnectionInit { .. })); socket - .send(Message::Text( + .send(Message::text( serde_json::to_string(&ServerMessage::ConnectionAck).unwrap(), )) .await @@ -2246,7 +2226,7 @@ mod tests { }; socket - .send(Message::Text( + .send(Message::text( serde_json::to_string(&ServerMessage::Next { id: client_id, payload: graphql::Response::builder().data(serde_json_bytes::json!({"userWasCreated": {"username": "ada_lovelace"}})).build() }).unwrap(), )) .await @@ -2257,9 +2237,10 @@ mod tests { } let app = Router::new().route("/ws", get(ws_handler)); - let server = Server::from_tcp(listener) - .unwrap() - .serve(app.into_make_service_with_connect_info::()); + let server = axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ); server.await.unwrap(); } @@ -2272,21 +2253,24 @@ mod tests { } let app = Router::new().route("/ws", get(ws_handler)); - let server = Server::from_tcp(listener) - .unwrap() - .serve(app.into_make_service_with_connect_info::()); + let server = axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ); server.await.unwrap(); } async fn emulate_subgraph_with_callback_data(listener: TcpListener) { async fn handle(request: http::Request) -> Result, Infallible> { let (parts, body) = request.into_parts(); - assert!(parts - .headers - .get_all(ACCEPT) - .iter() - .any(|header_value| header_value == CALLBACK_PROTOCOL_ACCEPT)); - let graphql_request: Result = get_body_bytes(body) + assert!( + parts + .headers + .get_all(ACCEPT) + .iter() + .any(|header_value| header_value == CALLBACK_PROTOCOL_ACCEPT) + ); + let graphql_request: Result = router::body::into_bytes(body) .await .map_err(|_| ()) .and_then(|bytes| serde_json::from_reader(bytes.reader()).map_err(|_| ())) @@ -2321,9 +2305,7 @@ mod tests { .unwrap()) } - let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); - let server = Server::from_tcp(listener).unwrap().serve(make_svc); - server.await.unwrap(); + serve(listener, handle).await.unwrap(); } fn subscription_config() -> SubscriptionConfig { @@ -2350,7 +2332,7 @@ mod tests { .into(), }), }, - enable_deduplication: true, + deduplication: DeduplicationConfig::default(), max_opened_subscriptions: None, queue_capacity: None, } @@ -2378,7 +2360,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_subgraph_service_callback() { let _ = SUBSCRIPTION_CALLBACK_HMAC_KEY.set(String::from("TESTEST")); - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); let spawned_task = tokio::task::spawn(emulate_subgraph_with_callback_data(listener)); let subgraph_service = SubgraphService::new( @@ -2422,7 +2404,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_subgraph_service_content_type_application_graphql() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_subgraph_application_graphql_response(listener)); let subgraph_service = SubgraphService::new( @@ -2456,7 +2438,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_subgraph_service_content_type_application_json() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_subgraph_application_json_response(listener)); let subgraph_service = SubgraphService::new( @@ -2491,7 +2473,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[cfg(not(target_os = "macos"))] async fn test_subgraph_service_panic() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_subgraph_panic(listener)); let subgraph_service = SubgraphService::new( @@ -2529,7 +2511,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_subgraph_service_invalid_response() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_subgraph_ok_status_invalid_response(listener)); let subgraph_service = SubgraphService::new( @@ -2566,7 +2548,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_subgraph_invalid_status_invalid_response_application_json() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn( emulate_subgraph_invalid_response_invalid_status_application_json(listener), @@ -2609,7 +2591,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_subgraph_invalid_status_invalid_response_application_graphql() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn( emulate_subgraph_invalid_response_invalid_status_application_graphql(listener), @@ -2652,7 +2634,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_subgraph_service_websocket() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); let spawned_task = tokio::task::spawn(emulate_correct_websocket_server(listener)); let subgraph_service = SubgraphService::new( @@ -2705,7 +2687,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_subgraph_service_websocket_with_error() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_incorrect_websocket_server(listener)); let subgraph_service = SubgraphService::new( @@ -2741,15 +2723,14 @@ mod tests { ) .await .unwrap_err(); - assert_eq!( - err.to_string(), - "Websocket fetch failed from 'test': cannot connect websocket to subgraph: HTTP error: 400 Bad Request".to_string() - ); + + let err_str = err.to_string(); + assert!(err_str.starts_with("Websocket fetch failed from 'test': cannot connect websocket to subgraph: WebSocket upgrade failed. Status: 400 Bad Request; Headers: [\"content-type\": \"text/plain; charset=utf-8\"; \"content-length\": \"11\";")); } #[tokio::test(flavor = "multi_thread")] async fn test_bad_status_code_should_not_fail() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_subgraph_bad_request(listener)); let subgraph_service = SubgraphService::new( @@ -2790,7 +2771,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_missing_content_type() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_subgraph_missing_content_type(listener)); @@ -2828,7 +2809,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_invalid_content_type() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_subgraph_invalid_content_type(listener)); @@ -2866,7 +2847,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_unsupported_content_type() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_subgraph_unsupported_content_type(listener)); @@ -2904,7 +2885,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_unauthorized() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_subgraph_unauthorized(listener)); let subgraph_service = SubgraphService::new( @@ -2941,7 +2922,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_persisted_query_not_supported_message() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_persisted_query_not_supported_message(listener)); let subgraph_service = SubgraphService::new( @@ -2985,7 +2966,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_persisted_query_not_supported_extension_code() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_persisted_query_not_supported_extension_code( listener, @@ -3031,7 +3012,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_persisted_query_not_found_message() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_persisted_query_not_found_message(listener)); let subgraph_service = SubgraphService::new( @@ -3072,7 +3053,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_persisted_query_not_found_extension_code() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_persisted_query_not_found_extension_code(listener)); let subgraph_service = SubgraphService::new( @@ -3113,7 +3094,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_apq_enabled_subgraph_configuration() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_expected_apq_enabled_configuration(listener)); let subgraph_service = SubgraphService::new( @@ -3154,7 +3135,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_apq_disabled_subgraph_configuration() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_expected_apq_disabled_configuration(listener)); let subgraph_service = SubgraphService::new( @@ -3245,7 +3226,7 @@ mod tests { .to_graphql_error(None), ) .build(); - assert_eq!(actual, expected); + assert_response_eq_ignoring_error_id!(actual, expected); } #[test] @@ -3306,7 +3287,7 @@ mod tests { .data(json["data"].take()) .error(error) .build(); - assert_eq!(actual, expected); + assert_response_eq_ignoring_error_id!(actual, expected); } #[test] @@ -3348,6 +3329,6 @@ mod tests { ) .error(error) .build(); - assert_eq!(actual, expected); + assert_response_eq_ignoring_error_id!(expected, actual); } } diff --git a/apollo-router/src/services/supergraph.rs b/apollo-router/src/services/supergraph.rs index 3807cf859d..4af82fa178 100644 --- a/apollo-router/src/services/supergraph.rs +++ b/apollo-router/src/services/supergraph.rs @@ -1,13 +1,13 @@ #![allow(missing_docs)] // FIXME use futures::future::ready; -use futures::stream::once; use futures::stream::StreamExt; -use http::header::HeaderName; -use http::method::Method; +use futures::stream::once; use http::HeaderValue; use http::StatusCode; use http::Uri; +use http::header::HeaderName; +use http::method::Method; use mime::APPLICATION_JSON; use multimap::MultiMap; use serde_json_bytes::ByteString; @@ -16,13 +16,14 @@ use serde_json_bytes::Value; use static_assertions::assert_impl_all; use tower::BoxError; +use crate::Context; +use crate::context::CONTAINS_GRAPHQL_ERROR; use crate::error::Error; use crate::graphql; -use crate::http_ext::header_map; use crate::http_ext::TryIntoHeaderName; use crate::http_ext::TryIntoHeaderValue; +use crate::http_ext::header_map; use crate::json_ext::Path; -use crate::Context; pub(crate) mod service; #[cfg(test)] @@ -68,7 +69,6 @@ impl Request { /// This is the constructor (or builder) to use when constructing a real Request. /// /// Required parameters are required in non-testing code to create a Request. - #[allow(clippy::too_many_arguments)] #[builder(visibility = "pub")] fn new( query: Option, @@ -179,12 +179,19 @@ pub struct Response { pub context: Context, } +impl std::fmt::Debug for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Response") + .field("context", &self.context) + .finish() + } +} + #[buildstructor::buildstructor] impl Response { /// This is the constructor (or builder) to use when constructing a real Response.. /// /// Required parameters are required in non-testing code to create a Response.. - #[allow(clippy::too_many_arguments)] #[builder(visibility = "pub")] fn new( label: Option, @@ -197,6 +204,9 @@ impl Response { headers: MultiMap, context: Context, ) -> Result { + if !errors.is_empty() { + context.insert_json_value(CONTAINS_GRAPHQL_ERROR, Value::Bool(true)); + } // Build a response let b = graphql::Response::builder() .and_label(label) @@ -230,7 +240,6 @@ impl Response { /// difficult to construct and not required for the purposes of the test. /// /// In addition, fake responses are expected to be valid, and will panic if given invalid values. - #[allow(clippy::too_many_arguments)] #[builder(visibility = "pub")] fn fake_new( label: Option, @@ -308,7 +317,6 @@ impl Response { /// This is the constructor (or builder) to use when constructing a real Response.. /// /// Required parameters are required in non-testing code to create a Response.. - #[allow(clippy::too_many_arguments)] #[builder(visibility = "pub(crate)")] fn infallible_new( label: Option, @@ -321,6 +329,9 @@ impl Response { headers: MultiMap, context: Context, ) -> Self { + if !errors.is_empty() { + context.insert_json_value(CONTAINS_GRAPHQL_ERROR, Value::Bool(true)); + } // Build a response let b = graphql::Response::builder() .and_label(label) @@ -346,6 +357,10 @@ impl Response { } pub(crate) fn new_from_graphql_response(response: graphql::Response, context: Context) -> Self { + if !response.errors.is_empty() { + context.insert_json_value(CONTAINS_GRAPHQL_ERROR, Value::Bool(true)); + } + Self { response: http::Response::new(once(ready(response)).boxed()), context, @@ -362,7 +377,7 @@ impl Response { response: http::Response, context: Context, ) -> Self { - Self { response, context } + Self { context, response }.check_for_errors() } pub fn map(self, f: F) -> Response @@ -418,6 +433,16 @@ impl Response { { self.map(move |stream| stream.map(f).boxed()) } + + fn check_for_errors(self) -> Self { + let context = self.context.clone(); + self.map_stream(move |response| { + if !response.errors.is_empty() { + context.insert_json_value(CONTAINS_GRAPHQL_ERROR, Value::Bool(true)); + } + response + }) + } } #[cfg(test)] diff --git a/apollo-router/src/services/supergraph/service.rs b/apollo-router/src/services/supergraph/service.rs index f87c81727f..fe9c176d33 100644 --- a/apollo-router/src/services/supergraph/service.rs +++ b/apollo-router/src/services/supergraph/service.rs @@ -1,19 +1,20 @@ //! Implements the router phase of the request lifecycle. -use std::sync::atomic::Ordering; use std::sync::Arc; +use std::sync::atomic::Ordering; use std::task::Poll; use std::time::Instant; +use futures::FutureExt; +use futures::TryFutureExt; use futures::future::BoxFuture; +use futures::future::ready; use futures::stream::StreamExt; -use futures::TryFutureExt; +use futures::stream::once; use http::StatusCode; use indexmap::IndexMap; use opentelemetry::Key; use opentelemetry::KeyValue; -use router_bridge::planner::Planner; -use router_bridge::planner::UsageReporting; use tokio::sync::mpsc; use tokio::sync::mpsc::error::SendError; use tokio_stream::wrappers::ReceiverStream; @@ -21,11 +22,16 @@ use tower::BoxError; use tower::Layer; use tower::ServiceBuilder; use tower::ServiceExt; +use tower::buffer::Buffer; use tower_service::Service; -use tracing::field; use tracing::Span; +use tracing::field; use tracing_futures::Instrument; +use crate::Configuration; +use crate::Context; +use crate::Notify; +use crate::apollo_studio_interop::UsageReporting; use crate::batching::BatchQuery; use crate::configuration::Batching; use crate::configuration::PersistedQueriesPrewarmQueryPlanCache; @@ -34,26 +40,39 @@ use crate::error::CacheResolverError; use crate::graphql; use crate::graphql::IntoGraphQLErrors; use crate::graphql::Response; +use crate::layers::DEFAULT_BUFFER_SIZE; +use crate::layers::ServiceBuilderExt; use crate::plugin::DynPlugin; +use crate::plugins::authentication::APOLLO_AUTHENTICATION_JWT_CLAIMS; +use crate::plugins::connectors::query_plans::store_connectors; +use crate::plugins::connectors::query_plans::store_connectors_labels; +use crate::plugins::subscription::APOLLO_SUBSCRIPTION_PLUGIN; +use crate::plugins::subscription::Subscription; use crate::plugins::subscription::SubscriptionConfig; use crate::plugins::telemetry::config_new::events::log_event; -use crate::plugins::telemetry::config_new::events::SupergraphEventResponse; +use crate::plugins::telemetry::config_new::supergraph::events::SupergraphEventResponse; use crate::plugins::telemetry::consts::QUERY_PLANNING_SPAN_NAME; use crate::plugins::telemetry::tracing::apollo_telemetry::APOLLO_PRIVATE_DURATION_NS; -use crate::plugins::telemetry::Telemetry; -use crate::plugins::telemetry::LOGGING_DISPLAY_BODY; -use crate::plugins::traffic_shaping::TrafficShaping; -use crate::plugins::traffic_shaping::APOLLO_TRAFFIC_SHAPING; -use crate::query_planner::subscription::SubscriptionHandle; -use crate::query_planner::subscription::OPENED_SUBSCRIPTIONS; -use crate::query_planner::subscription::SUBSCRIPTION_EVENT_SPAN_NAME; -use crate::query_planner::BridgeQueryPlannerPool; use crate::query_planner::CachingQueryPlanner; use crate::query_planner::InMemoryCachePlanner; -use crate::query_planner::QueryPlanResult; -use crate::router_factory::create_plugins; -use crate::router_factory::create_subgraph_services; +use crate::query_planner::QueryPlannerService; +use crate::query_planner::subscription::OPENED_SUBSCRIPTIONS; +use crate::query_planner::subscription::SUBSCRIPTION_EVENT_SPAN_NAME; +use crate::query_planner::subscription::SubscriptionHandle; +use crate::services::ExecutionRequest; +use crate::services::ExecutionResponse; +use crate::services::ExecutionServiceFactory; +use crate::services::QueryPlannerContent; +use crate::services::QueryPlannerResponse; +use crate::services::SubgraphServiceFactory; +use crate::services::SupergraphRequest; +use crate::services::SupergraphResponse; +use crate::services::connector::request_service::ConnectorRequestServiceFactory; +use crate::services::connector_service::ConnectorServiceFactory; +use crate::services::execution; use crate::services::execution::QueryPlan; +use crate::services::fetch_service::FetchServiceFactory; +use crate::services::http::HttpClientServiceFactory; use crate::services::layers::allow_only_http_post_mutations::AllowOnlyHttpPostMutationsLayer; use crate::services::layers::content_negotiation; use crate::services::layers::persisted_queries::PersistedQueryLayer; @@ -63,31 +82,25 @@ use crate::services::query_planner; use crate::services::router::ClientRequestAccepts; use crate::services::subgraph::BoxGqlStream; use crate::services::subgraph_service::MakeSubgraphService; -use crate::services::subgraph_service::SubgraphServiceFactory; use crate::services::supergraph; -use crate::services::ExecutionRequest; -use crate::services::ExecutionResponse; -use crate::services::ExecutionServiceFactory; -use crate::services::QueryPlannerContent; -use crate::services::QueryPlannerResponse; -use crate::services::SupergraphRequest; -use crate::services::SupergraphResponse; -use crate::spec::operation_limits::OperationLimits; use crate::spec::Schema; -use crate::Configuration; -use crate::Context; -use crate::Notify; +use crate::spec::operation_limits::OperationLimits; -pub(crate) const FIRST_EVENT_CONTEXT_KEY: &str = "apollo_router::supergraph::first_event"; +pub(crate) const FIRST_EVENT_CONTEXT_KEY: &str = "apollo::supergraph::first_event"; +pub(crate) const SUBSCRIPTION_ERROR_EXTENSION_KEY: &str = "apollo::subscriptions::fatal_error"; +const SUBSCRIPTION_CONFIG_RELOAD_EXTENSION_CODE: &str = "SUBSCRIPTION_CONFIG_RELOAD"; +const SUBSCRIPTION_SCHEMA_RELOAD_EXTENSION_CODE: &str = "SUBSCRIPTION_SCHEMA_RELOAD"; +const SUBSCRIPTION_JWT_EXPIRED_EXTENSION_CODE: &str = "SUBSCRIPTION_JWT_EXPIRED"; +const SUBSCRIPTION_EXECUTION_ERROR_EXTENSION_CODE: &str = "SUBSCRIPTION_EXECUTION_ERROR"; /// An [`IndexMap`] of available plugins. pub(crate) type Plugins = IndexMap>; -/// Containing [`Service`] in the request lifecyle. +/// Containing [`Service`] in the request lifecycle. #[derive(Clone)] pub(crate) struct SupergraphService { - execution_service_factory: ExecutionServiceFactory, - query_planner_service: CachingQueryPlanner, + query_planner_service: CachingQueryPlanner, + execution_service: execution::BoxCloneService, schema: Arc, notify: Notify, } @@ -96,14 +109,14 @@ pub(crate) struct SupergraphService { impl SupergraphService { #[builder] pub(crate) fn new( - query_planner_service: CachingQueryPlanner, - execution_service_factory: ExecutionServiceFactory, + query_planner_service: CachingQueryPlanner, + execution_service: execution::BoxCloneService, schema: Arc, notify: Notify, ) -> Self { SupergraphService { query_planner_service, - execution_service_factory, + execution_service, schema, notify, } @@ -122,6 +135,11 @@ impl Service for SupergraphService { } fn call(&mut self, req: SupergraphRequest) -> Self::Future { + if let Some(connectors) = &self.schema.connectors { + store_connectors_labels(&req.context, connectors.labels_by_service_name.clone()); + store_connectors(&req.context, connectors.by_service_name.clone()); + } + // Consume our cloned services and allow ownership to be transferred to the async block. let clone = self.query_planner_service.clone(); @@ -132,22 +150,18 @@ impl Service for SupergraphService { let context_cloned = req.context.clone(); let fut = service_call( planning, - self.execution_service_factory.clone(), + self.execution_service.clone(), schema, req, self.notify.clone(), ) .or_else(|error: BoxError| async move { - let errors = vec![crate::error::Error { - message: error.to_string(), - extensions: serde_json_bytes::json!({ - "code": "INTERNAL_SERVER_ERROR", - }) - .as_object() - .unwrap() - .to_owned(), - ..Default::default() - }]; + let errors = vec![ + crate::error::Error::builder() + .message(error.to_string()) + .extension_code("INTERNAL_SERVER_ERROR") + .build(), + ]; Ok(SupergraphResponse::infallible_builder() .errors(errors) @@ -161,8 +175,8 @@ impl Service for SupergraphService { } async fn service_call( - planning: CachingQueryPlanner, - execution_service_factory: ExecutionServiceFactory, + planning: CachingQueryPlanner, + execution_service: execution::BoxCloneService, schema: Arc, req: SupergraphRequest, notify: Notify, @@ -175,26 +189,37 @@ async fn service_call( planning, body.operation_name.clone(), context.clone(), - schema.clone(), + // We cannot assume that the query is present as it may have been modified by coprocessors or plugins. + // There is a deeper issue here in that query analysis is doing a bunch of stuff that it should not and + // places the results in context. Therefore plugins that have modified the query won't actually take effect. + // However, this can't be resolved before looking at the pipeline again. req.supergraph_request .body() .query .clone() - .expect("query presence was checked before"), + .unwrap_or_default(), ) .await { Ok(resp) => resp, - Err(err) => match err.into_graphql_errors() { - Ok(gql_errors) => { - return Ok(SupergraphResponse::infallible_builder() - .context(context) - .errors(gql_errors) - .status_code(StatusCode::BAD_REQUEST) // If it's a graphql error we return a status code 400 - .build()); + Err(err) => { + let status = match &err { + CacheResolverError::Backpressure(_) => StatusCode::SERVICE_UNAVAILABLE, + CacheResolverError::RetrievalError(_) | CacheResolverError::BatchingError(_) => { + StatusCode::BAD_REQUEST + } + }; + match err.into_graphql_errors() { + Ok(gql_errors) => { + return Ok(SupergraphResponse::infallible_builder() + .context(context) + .errors(gql_errors) + .status_code(status) // If it's a graphql error we return a status code 400 + .build()); + } + Err(err) => return Err(err.into()), } - Err(err) => return Err(err.into()), - }, + } }; if !errors.is_empty() { @@ -213,10 +238,12 @@ async fn service_call( Some(QueryPlannerContent::IntrospectionDisabled) => { let mut response = SupergraphResponse::new_from_graphql_response( graphql::Response::builder() - .errors(vec![crate::error::Error::builder() - .message(String::from("introspection has been disabled")) - .extension_code("INTROSPECTION_DISABLED") - .build()]) + .errors(vec![ + crate::error::Error::builder() + .message(String::from("introspection has been disabled")) + .extension_code("INTROSPECTION_DISABLED") + .build(), + ]) .build(), context, ); @@ -226,7 +253,7 @@ async fn service_call( Some(QueryPlannerContent::Plan { plan }) => { let query_metrics = plan.query_metrics; - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { let _ = lock.insert::>(query_metrics); }); @@ -284,16 +311,28 @@ async fn service_call( || (is_subscription && !accepts_multipart_subscription) { let (error_message, error_code) = if is_deferred { - (String::from("the router received a query with the @defer directive but the client does not accept multipart/mixed HTTP responses. To enable @defer support, add the HTTP header 'Accept: multipart/mixed;deferSpec=20220824'"), "DEFER_BAD_HEADER") + ( + String::from( + "the router received a query with the @defer directive but the client does not accept multipart/mixed HTTP responses. To enable @defer support, add the HTTP header 'Accept: multipart/mixed;deferSpec=20220824'", + ), + "DEFER_BAD_HEADER", + ) } else { - (String::from("the router received a query with a subscription but the client does not accept multipart/mixed HTTP responses. To enable subscription support, add the HTTP header 'Accept: multipart/mixed;subscriptionSpec=1.0'"), "SUBSCRIPTION_BAD_HEADER") + ( + String::from( + "the router received a query with a subscription but the client does not accept multipart/mixed HTTP responses. To enable subscription support, add the HTTP header 'Accept: multipart/mixed;subscriptionSpec=1.0'", + ), + "SUBSCRIPTION_BAD_HEADER", + ) }; let mut response = SupergraphResponse::new_from_graphql_response( graphql::Response::builder() - .errors(vec![crate::error::Error::builder() - .message(error_message) - .extension_code(error_code) - .build()]) + .errors(vec![ + crate::error::Error::builder() + .message(error_message) + .extension_code(error_code) + .build(), + ]) .build(), context, ); @@ -308,13 +347,13 @@ async fn service_call( let ctx = context.clone(); let (subs_tx, subs_rx) = mpsc::channel(1); let query_plan = plan.clone(); - let execution_service_factory_cloned = execution_service_factory.clone(); + let execution_service_cloned = execution_service.clone(); let cloned_supergraph_req = clone_supergraph_request(&req.supergraph_request, context.clone()); // Spawn task for subscription tokio::spawn(async move { subscription_task( - execution_service_factory_cloned, + execution_service_cloned, ctx, query_plan, subs_rx, @@ -326,8 +365,7 @@ async fn service_call( subscription_tx = subs_tx.into(); } - let execution_response = execution_service_factory - .create() + let execution_response = execution_service .oneshot( ExecutionRequest::internal_builder() .supergraph_request(req.supergraph_request) @@ -360,6 +398,14 @@ async fn service_call( inserted = true; } }); + + // make sure to resolve the first part of the stream - that way we know context + // variables (`FIRST_EVENT_CONTEXT_KEY`, `CONTAINS_GRAPHQL_ERROR`) have been set + let (first, remaining) = StreamExt::into_future(response_stream).await; + let response_stream = once(ready(first.unwrap_or_default())) + .chain(remaining) + .boxed(); + match supergraph_response_event { Some(supergraph_response_event) => { let mut attrs = Vec::with_capacity(4); @@ -377,10 +423,11 @@ async fn service_call( )); let ctx = context.clone(); let response_stream = Box::pin(response_stream.inspect(move |resp| { - if let Some(condition) = supergraph_response_event.0.condition() { - if !condition.lock().evaluate_event_response(resp, &ctx) { - return; - } + if !supergraph_response_event + .condition + .evaluate_event_response(resp, &ctx) + { + return; } attrs.push(KeyValue::new( Key::from_static_str("http.response.body"), @@ -389,7 +436,7 @@ async fn service_call( ), )); log_event( - supergraph_response_event.0.level(), + supergraph_response_event.level, "supergraph.response", attrs.clone(), "", @@ -418,11 +465,24 @@ pub struct SubscriptionTaskParams { pub(crate) subscription_handle: SubscriptionHandle, pub(crate) subscription_config: SubscriptionConfig, pub(crate) stream_rx: ReceiverStream, - pub(crate) service_name: String, } +fn subscription_fatal_error(message: impl Into, extension_code: &str) -> Response { + Response::builder() + .subscribed(false) + .extension(SUBSCRIPTION_ERROR_EXTENSION_KEY, true) + .error( + graphql::Error::builder() + .message(message) + .extension_code(extension_code) + .build(), + ) + .build() +} + +#[allow(clippy::too_many_arguments)] async fn subscription_task( - mut execution_service_factory: ExecutionServiceFactory, + execution_service: execution::BoxCloneService, context: Context, query_plan: Arc, mut rx: mpsc::Receiver, @@ -437,7 +497,6 @@ async fn subscription_task( }; let subscription_config = sub_params.subscription_config; let subscription_handle = sub_params.subscription_handle; - let service_name = sub_params.service_name; let mut receiver = sub_params.stream_rx; let sender = sub_params.client_sender; @@ -455,16 +514,10 @@ async fn subscription_task( }), _ => { let _ = sender - .send( - graphql::Response::builder() - .error( - graphql::Error::builder() - .message("cannot execute the subscription event") - .extension_code("SUBSCRIPTION_EXECUTION_ERROR") - .build(), - ) - .build(), - ) + .send(subscription_fatal_error( + "cannot execute the subscription event", + SUBSCRIPTION_EXECUTION_ERROR_EXTENSION_CODE, + )) .await; return; } @@ -476,7 +529,7 @@ async fn subscription_task( .extensions() .with_lock(|lock| { lock.get::>() - .map(|usage_reporting| usage_reporting.stats_report_key.clone()) + .map(|usage_reporting| usage_reporting.get_stats_report_key().clone()) }) .unwrap_or_default(); @@ -485,7 +538,6 @@ async fn subscription_task( .ok() .flatten() .unwrap_or_default(); - let display_body = context.contains_key(LOGGING_DISPLAY_BODY); let mut receiver = match receiver.next().await { Some(receiver) => receiver, @@ -502,9 +554,17 @@ async fn subscription_task( let mut configuration_updated_rx = notify.subscribe_configuration(); let mut schema_updated_rx = notify.subscribe_schema(); - let expires_in = crate::plugins::authentication::jwt_expires_in(&supergraph_req.context); - - let mut timeout = Box::pin(tokio::time::sleep(expires_in)); + let mut timeout = if supergraph_req + .context + .get_json_value(APOLLO_AUTHENTICATION_JWT_CLAIMS) + .is_some() + { + let expires_in = + crate::plugins::authentication::jwks::jwt_expires_in(&supergraph_req.context); + tokio::time::sleep(expires_in).boxed() + } else { + futures::future::pending().boxed() + }; loop { tokio::select! { @@ -514,26 +574,14 @@ async fn subscription_task( break; } _ = &mut timeout => { - let response = Response::builder() - .subscribed(false) - .error( - crate::error::Error::builder() - .message("subscription closed because the JWT has expired") - .extension_code("SUBSCRIPTION_JWT_EXPIRED") - .build(), - ) - .build(); - let _ = sender.send(response).await; + let _ = sender.send(subscription_fatal_error("subscription closed because the JWT has expired", SUBSCRIPTION_JWT_EXPIRED_EXTENSION_CODE)).await; break; }, message = receiver.next() => { match message { Some(mut val) => { - if display_body { - tracing::info!(http.request.body = ?val, apollo.subgraph.name = %service_name, "Subscription event body from subgraph {service_name:?}"); - } val.created_at = Some(Instant::now()); - let res = dispatch_event(&supergraph_req, &execution_service_factory, query_plan.as_ref(), context.clone(), val, sender.clone()) + let res = dispatch_subscription_event(&supergraph_req, execution_service.clone(), query_plan.as_ref(), context.clone(), val, sender.clone()) .instrument(tracing::info_span!(SUBSCRIPTION_EVENT_SPAN_NAME, graphql.operation.name = %operation_name, otel.kind = "INTERNAL", @@ -541,54 +589,24 @@ async fn subscription_task( apollo_private.duration_ns = field::Empty,) ).await; if let Err(err) = res { - tracing::error!("cannot send the subscription to the client: {err:?}"); + tracing::error!("cannot send the subscription to the client: {err:?}"); break; } } None => break, } } - Some(new_configuration) = configuration_updated_rx.next() => { - // If the configuration was dropped in the meantime, we ignore this update and will - // pick up the next one. - if let Some(conf) = new_configuration.upgrade() { - let plugins = match create_plugins(&conf, &execution_service_factory.schema, execution_service_factory.subgraph_schemas.clone(), None, None).await { - Ok(plugins) => Arc::new(plugins), - Err(err) => { - tracing::error!("cannot re-create plugins with the new configuration (closing existing subscription): {err:?}"); - break; - }, - }; - let subgraph_services = match create_subgraph_services(&plugins, &execution_service_factory.schema, &conf).await { - Ok(subgraph_services) => subgraph_services, - Err(err) => { - tracing::error!("cannot re-create subgraph service with the new configuration (closing existing subscription): {err:?}"); - break; - }, - }; - - execution_service_factory = ExecutionServiceFactory { - schema: execution_service_factory.schema.clone(), - subgraph_schemas: execution_service_factory.subgraph_schemas.clone(), - plugins: plugins.clone(), - subgraph_service_factory: Arc::new(SubgraphServiceFactory::new(subgraph_services.into_iter().map(|(k, v)| (k, Arc::new(v) as Arc)).collect(), plugins.clone())), - - }; - } + Some(_new_configuration) = configuration_updated_rx.next() => { + let _ = sender + .send(subscription_fatal_error("subscription has been closed due to a configuration reload", SUBSCRIPTION_CONFIG_RELOAD_EXTENSION_CODE)) + .await; } - Some(new_schema) = schema_updated_rx.next() => { - if new_schema.raw_sdl != execution_service_factory.schema.raw_sdl { - let _ = sender - .send( - Response::builder() - .subscribed(false) - .error(graphql::Error::builder().message("subscription has been closed due to a schema reload").extension_code("SUBSCRIPTION_SCHEMA_RELOAD").build()) - .build(), - ) - .await; + Some(_new_schema) = schema_updated_rx.next() => { + let _ = sender + .send(subscription_fatal_error("subscription has been closed due to a schema reload", SUBSCRIPTION_SCHEMA_RELOAD_EXTENSION_CODE)) + .await; - break; - } + break; } } } @@ -599,9 +617,9 @@ async fn subscription_task( } } -async fn dispatch_event( +async fn dispatch_subscription_event( supergraph_req: &SupergraphRequest, - execution_service_factory: &ExecutionServiceFactory, + execution_service: execution::BoxCloneService, query_plan: Option<&Arc>, context: Context, mut val: graphql::Response, @@ -623,23 +641,16 @@ async fn dispatch_event( .build() .await; - let execution_service = execution_service_factory.create(); let execution_response = execution_service.oneshot(execution_request).await; let next_response = match execution_response { Ok(mut execution_response) => execution_response.next_response().await, Err(err) => { tracing::error!("cannot execute the subscription event: {err:?}"); let _ = sender - .send( - graphql::Response::builder() - .error( - graphql::Error::builder() - .message("cannot execute the subscription event") - .extension_code("SUBSCRIPTION_EXECUTION_ERROR") - .build(), - ) - .build(), - ) + .send(subscription_fatal_error( + "cannot execute the subscription event", + SUBSCRIPTION_EXECUTION_ERROR_EXTENSION_CODE, + )) .await; return Ok(()); } @@ -667,34 +678,11 @@ async fn dispatch_event( } async fn plan_query( - mut planning: CachingQueryPlanner, + mut planning: CachingQueryPlanner, operation_name: Option, context: Context, - schema: Arc, query_str: String, ) -> Result { - // FIXME: we have about 80 tests creating a supergraph service and crafting a supergraph request for it - // none of those tests create an executable document to put it in the context, and the document cannot be created - // from inside the supergraph request fake builder, because it needs a schema matching the query. - // So while we are updating the tests to create a document manually, this here will make sure current - // tests will pass. - // During a regular request, `ParsedDocument` is already populated during query analysis. - // Some tests do populate the document, so we only do it if it's not already there. - if !context.extensions().with_lock(|lock| { - lock.contains_key::() - }) { - let doc = crate::spec::Query::parse_document( - &query_str, - operation_name.as_deref(), - &schema, - &Configuration::default(), - ) - .map_err(crate::error::QueryPlannerError::from)?; - context.extensions().with_lock(|mut lock| { - lock.insert::(doc) - }); - } - let qpr = planning .call( query_planner::CachingRequest::builder() @@ -745,15 +733,17 @@ fn clone_supergraph_request( pub(crate) struct PluggableSupergraphServiceBuilder { plugins: Arc, subgraph_services: Vec<(String, Box)>, + http_service_factory: IndexMap, configuration: Option>, - planner: BridgeQueryPlannerPool, + planner: QueryPlannerService, } impl PluggableSupergraphServiceBuilder { - pub(crate) fn new(planner: BridgeQueryPlannerPool) -> Self { + pub(crate) fn new(planner: QueryPlannerService) -> Self { Self { plugins: Arc::new(Default::default()), subgraph_services: Default::default(), + http_service_factory: Default::default(), configuration: None, planner, } @@ -780,6 +770,14 @@ impl PluggableSupergraphServiceBuilder { self } + pub(crate) fn with_http_service_factory( + mut self, + http_service_factory: IndexMap, + ) -> PluggableSupergraphServiceBuilder { + self.http_service_factory = http_service_factory; + self + } + pub(crate) fn with_configuration( mut self, configuration: Arc, @@ -797,7 +795,7 @@ impl PluggableSupergraphServiceBuilder { let query_planner_service = CachingQueryPlanner::new( self.planner, schema.clone(), - subgraph_schemas, + subgraph_schemas.clone(), &configuration, IndexMap::default(), ) @@ -806,29 +804,96 @@ impl PluggableSupergraphServiceBuilder { // Activate the telemetry plugin. // We must NOT fail to go live with the new router from this point as the telemetry plugin activate interacts with globals. for (_, plugin) in self.plugins.iter() { - if let Some(telemetry) = plugin.as_any().downcast_ref::() { - telemetry.activate(); - } + plugin.activate(); } // We need a non-fallible hook so that once we know we are going live with a pipeline we do final initialization. // For now just shoe-horn something in, but if we ever reintroduce the query planner hook in plugins and activate then this can be made clean. query_planner_service.activate(); - let subgraph_service_factory = Arc::new(SubgraphServiceFactory::new( - self.subgraph_services - .into_iter() - .map(|(name, service)| (name, service.into())) - .collect(), - self.plugins.clone(), + let subscription_plugin_conf = self + .plugins + .iter() + .find(|i| i.0.as_str() == APOLLO_SUBSCRIPTION_PLUGIN) + .and_then(|plugin| (*plugin.1).as_any().downcast_ref::()) + .map(|p| p.config.clone()); + + let connector_sources = schema + .connectors + .as_ref() + .map(|c| c.source_config_keys.clone()) + .unwrap_or_default(); + + let fetch_service_factory = Arc::new(FetchServiceFactory::new( + schema.clone(), + subgraph_schemas.clone(), + Arc::new(SubgraphServiceFactory::new( + self.subgraph_services + .into_iter() + .map(|(name, service)| (name, service.into())) + .collect(), + self.plugins.clone(), + )), + subscription_plugin_conf.clone(), + Arc::new(ConnectorServiceFactory::new( + schema.clone(), + subgraph_schemas, + subscription_plugin_conf, + schema + .connectors + .as_ref() + .map(|c| c.by_service_name.clone()) + .unwrap_or_default(), + Arc::new(ConnectorRequestServiceFactory::new( + Arc::new(self.http_service_factory), + self.plugins.clone(), + connector_sources, + )), + )), )); + let execution_service_factory = ExecutionServiceFactory { + schema: schema.clone(), + subgraph_schemas: query_planner_service.subgraph_schemas(), + plugins: self.plugins.clone(), + fetch_service_factory, + }; + + let execution_service: execution::BoxCloneService = ServiceBuilder::new() + .buffered() + .service(execution_service_factory.create()) + .boxed_clone(); + + let supergraph_service = SupergraphService::builder() + .query_planner_service(query_planner_service.clone()) + .execution_service(execution_service) + .schema(schema.clone()) + .notify(configuration.notify.clone()) + .build(); + + let supergraph_service = + AllowOnlyHttpPostMutationsLayer::default().layer(supergraph_service); + + let sb = Buffer::new( + ServiceBuilder::new() + .layer(content_negotiation::SupergraphLayer::default()) + .service( + self.plugins + .iter() + .rev() + .fold(supergraph_service.boxed(), |acc, (_, e)| { + e.supergraph_service(acc) + }), + ) + .boxed(), + DEFAULT_BUFFER_SIZE, + ); + Ok(SupergraphCreator { query_planner_service, - subgraph_service_factory, schema, plugins: self.plugins, - config: configuration, + sb, }) } } @@ -836,11 +901,10 @@ impl PluggableSupergraphServiceBuilder { /// A collection of services and data which may be used to create a "router". #[derive(Clone)] pub(crate) struct SupergraphCreator { - query_planner_service: CachingQueryPlanner, - subgraph_service_factory: Arc, + query_planner_service: CachingQueryPlanner, schema: Arc, - config: Arc, plugins: Arc, + sb: Buffer>, } pub(crate) trait HasPlugins { @@ -863,16 +927,6 @@ impl HasSchema for SupergraphCreator { } } -pub(crate) trait HasConfig { - fn config(&self) -> Arc; -} - -impl HasConfig for SupergraphCreator { - fn config(&self) -> Arc { - Arc::clone(&self.config) - } -} - impl ServiceFactory for SupergraphCreator { type Service = supergraph::BoxService; fn create(&self) -> Self::Service { @@ -888,49 +942,16 @@ impl SupergraphCreator { Response = supergraph::Response, Error = BoxError, Future = BoxFuture<'static, supergraph::ServiceResult>, - > + Send { - let supergraph_service = SupergraphService::builder() - .query_planner_service(self.query_planner_service.clone()) - .execution_service_factory(ExecutionServiceFactory { - schema: self.schema.clone(), - subgraph_schemas: self.query_planner_service.subgraph_schemas(), - plugins: self.plugins.clone(), - subgraph_service_factory: self.subgraph_service_factory.clone(), - }) - .schema(self.schema.clone()) - .notify(self.config.notify.clone()) - .build(); - - let shaping = self - .plugins - .iter() - .find(|i| i.0.as_str() == APOLLO_TRAFFIC_SHAPING) - .and_then(|plugin| plugin.1.as_any().downcast_ref::()) - .expect("traffic shaping should always be part of the plugin list"); - - let supergraph_service = AllowOnlyHttpPostMutationsLayer::default() - .layer(shaping.supergraph_service_internal(supergraph_service)); - - ServiceBuilder::new() - .layer(content_negotiation::SupergraphLayer::default()) - .service( - self.plugins - .iter() - .rev() - .fold(supergraph_service.boxed(), |acc, (_, e)| { - e.supergraph_service(acc) - }), - ) + > + Send + + use<> { + // Note: We have to box our cloned service to erase the type of the Buffer. + self.sb.clone().boxed() } pub(crate) fn previous_cache(&self) -> InMemoryCachePlanner { self.query_planner_service.previous_cache() } - pub(crate) fn js_planners(&self) -> Vec>> { - self.query_planner_service.js_planners() - } - pub(crate) async fn warm_up_query_planner( &mut self, query_parser: &QueryAnalysisLayer, diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__deferred_fragment_bounds_nullability-2.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__deferred_fragment_bounds_nullability-2.snap index dcb6bc3887..cf940ba62d 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__deferred_fragment_bounds_nullability-2.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__deferred_fragment_bounds_nullability-2.snap @@ -13,6 +13,21 @@ expression: stream.next_response().await.unwrap() "suborga", 0 ], + "errors": [ + { + "message": "couldn't find mock for query {\"query\":\"query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { nonNullId } } }\",\"variables\":{\"representations\":[{\"__typename\":\"Organization\",\"id\":\"1\"},{\"__typename\":\"Organization\",\"id\":\"2\"},{\"__typename\":\"Organization\",\"id\":\"3\"}]}}", + "path": [ + "currentUser", + "activeOrganization", + "suborga", + 0 + ], + "extensions": { + "code": "FETCH_ERROR", + "service": "orga" + } + } + ], "extensions": { "valueCompletion": [ { @@ -35,6 +50,21 @@ expression: stream.next_response().await.unwrap() "suborga", 1 ], + "errors": [ + { + "message": "couldn't find mock for query {\"query\":\"query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { nonNullId } } }\",\"variables\":{\"representations\":[{\"__typename\":\"Organization\",\"id\":\"1\"},{\"__typename\":\"Organization\",\"id\":\"2\"},{\"__typename\":\"Organization\",\"id\":\"3\"}]}}", + "path": [ + "currentUser", + "activeOrganization", + "suborga", + 1 + ], + "extensions": { + "code": "FETCH_ERROR", + "service": "orga" + } + } + ], "extensions": { "valueCompletion": [ { @@ -57,6 +87,21 @@ expression: stream.next_response().await.unwrap() "suborga", 2 ], + "errors": [ + { + "message": "couldn't find mock for query {\"query\":\"query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { nonNullId } } }\",\"variables\":{\"representations\":[{\"__typename\":\"Organization\",\"id\":\"1\"},{\"__typename\":\"Organization\",\"id\":\"2\"},{\"__typename\":\"Organization\",\"id\":\"3\"}]}}", + "path": [ + "currentUser", + "activeOrganization", + "suborga", + 2 + ], + "extensions": { + "code": "FETCH_ERROR", + "service": "orga" + } + } + ], "extensions": { "valueCompletion": [ { diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_from_primary_on_deferred_responses-2.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_from_primary_on_deferred_responses-2.snap index 36f064b496..40f678bb4f 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_from_primary_on_deferred_responses-2.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_from_primary_on_deferred_responses-2.snap @@ -24,7 +24,10 @@ expression: stream.next_response().await.unwrap() "path": [ "computer", "errorField" - ] + ], + "extensions": { + "service": "computers" + } } ] } diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_deferred_responses-2.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_deferred_responses-2.snap index d487599dc5..6cfe12e7a7 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_deferred_responses-2.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_deferred_responses-2.snap @@ -17,7 +17,10 @@ expression: stream.next_response().await.unwrap() "message": "error user 0", "path": [ "currentUser" - ] + ], + "extensions": { + "service": "user" + } } ] } diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_incremental_responses-2.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_incremental_responses-2.snap index c36f465d70..8cac0e7164 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_incremental_responses-2.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_incremental_responses-2.snap @@ -23,7 +23,10 @@ expression: stream.next_response().await.unwrap() "activeOrganization", "suborga", 0 - ] + ], + "extensions": { + "service": "orga" + } } ] }, @@ -56,7 +59,10 @@ expression: stream.next_response().await.unwrap() "activeOrganization", "suborga", 2 - ] + ], + "extensions": { + "service": "orga" + } } ] } diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_nullified_paths.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_nullified_paths.snap index bf618438ad..4299132063 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_nullified_paths.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_nullified_paths.snap @@ -20,7 +20,8 @@ expression: stream.next_response().await.unwrap() "bar" ], "extensions": { - "code": "NOT_FOUND" + "code": "NOT_FOUND", + "service": "S2" } } ] diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_with_invalid_paths_on_query_with_defer-2.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_with_invalid_paths_on_query_with_defer-2.snap new file mode 100644 index 0000000000..ab3e954854 --- /dev/null +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_with_invalid_paths_on_query_with_defer-2.snap @@ -0,0 +1,30 @@ +--- +source: apollo-router/src/services/supergraph/tests.rs +expression: stream.next_response().await.unwrap() +snapshot_kind: text +--- +{ + "hasNext": false, + "incremental": [ + { + "data": { + "errorField": null + }, + "path": [ + "computer" + ], + "errors": [ + { + "message": "Error field", + "path": [ + "computer", + "errorField" + ], + "extensions": { + "service": "computers" + } + } + ] + } + ] +} diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_with_invalid_paths_on_query_with_defer.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_with_invalid_paths_on_query_with_defer.snap new file mode 100644 index 0000000000..cf02e6c91f --- /dev/null +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_with_invalid_paths_on_query_with_defer.snap @@ -0,0 +1,27 @@ +--- +source: apollo-router/src/services/supergraph/tests.rs +expression: stream.next_response().await.unwrap() +snapshot_kind: text +--- +{ + "data": { + "computer": { + "id": "Computer1" + } + }, + "errors": [ + { + "message": "Subgraph error with invalid path", + "extensions": { + "service": "computers" + } + }, + { + "message": "Subgraph error with partially valid path", + "extensions": { + "service": "computers" + } + } + ], + "hasNext": true +} diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__invalid_input_enum-2.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__invalid_input_enum-2.snap deleted file mode 100644 index 40b8fa4908..0000000000 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__invalid_input_enum-2.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: apollo-router/src/services/supergraph/tests.rs -expression: response ---- -{ - "errors": [ - { - "message": "invalid type for variable: 'input'", - "extensions": { - "name": "input", - "code": "VALIDATION_INVALID_TYPE_VARIABLE" - } - } - ] -} diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__invalid_input_enum.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__invalid_input_enum.snap index 0e2dd5d122..6a7421388f 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__invalid_input_enum.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__invalid_input_enum.snap @@ -1,19 +1,15 @@ --- source: apollo-router/src/services/supergraph/tests.rs expression: response +snapshot_kind: text --- { "errors": [ { - "message": "Value \"C does not exist in \"InputEnum\" enum.", - "locations": [ - { - "line": 1, - "column": 21 - } - ], + "message": "invalid variable `$input`: found JSON string for GraphQL type `InputEnum`", "extensions": { - "code": "GRAPHQL_VALIDATION_FAILED" + "name": "input", + "code": "VALIDATION_INVALID_TYPE_VARIABLE" } } ] diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__missing_entities.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__missing_entities.snap index a4366f1d9a..a046e3aa13 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__missing_entities.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__missing_entities.snap @@ -18,7 +18,10 @@ expression: stream.next_response().await.unwrap() "path": [ "currentUser", "activeOrganization" - ] + ], + "extensions": { + "service": "orga" + } } ] } diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__query_reconstruction.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__query_reconstruction.snap index 34f783cd01..a55d09c61f 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__query_reconstruction.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__query_reconstruction.snap @@ -5,7 +5,7 @@ expression: stream.next_response().await.unwrap() { "errors": [ { - "message": "invalid type for variable: 'userId'", + "message": "missing variable `$userId`: for required GraphQL type `ID!`", "extensions": { "name": "userId", "code": "VALIDATION_INVALID_TYPE_VARIABLE" diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__subscription_callback_schema_reload-3.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__subscription_callback_schema_reload-3.snap index 0e6d9f126c..c5d49aaedf 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__subscription_callback_schema_reload-3.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__subscription_callback_schema_reload-3.snap @@ -1,6 +1,7 @@ --- -source: apollo-router/src/services/supergraph_service.rs -expression: stream.next_response().await.unwrap() +source: apollo-router/src/services/supergraph/tests.rs +expression: "tokio::time::timeout(Duration::from_secs(1),\nstream.next_response()).await.unwrap().unwrap()" +snapshot_kind: text --- { "data": null, @@ -11,5 +12,8 @@ expression: stream.next_response().await.unwrap() "code": "SUBSCRIPTION_SCHEMA_RELOAD" } } - ] + ], + "extensions": { + "apollo::subscriptions::fatal_error": true + } } diff --git a/apollo-router/src/services/supergraph/tests.rs b/apollo-router/src/services/supergraph/tests.rs index ac2dbab21a..0a4faadef2 100644 --- a/apollo-router/src/services/supergraph/tests.rs +++ b/apollo-router/src/services/supergraph/tests.rs @@ -6,6 +6,10 @@ use http::HeaderValue; use tower::ServiceExt; use tower_service::Service; +use crate::Configuration; +use crate::Context; +use crate::Notify; +use crate::TestHarness; use crate::graphql; use crate::plugin::test::MockSubgraph; use crate::services::router::ClientRequestAccepts; @@ -13,10 +17,6 @@ use crate::services::subgraph; use crate::services::supergraph; use crate::spec::Schema; use crate::test_harness::MockedSubgraphs; -use crate::Configuration; -use crate::Context; -use crate::Notify; -use crate::TestHarness; const SCHEMA: &str = include_str!("../../testdata/orga_supergraph.graphql"); @@ -533,16 +533,15 @@ async fn errors_from_primary_on_deferred_responses() { computer(id: ID!): Computer }"#; - let subgraphs = MockedSubgraphs([ - ("computers", MockSubgraph::builder().with_json( - serde_json::json!{{"query":"{currentUser{__typename id}}"}}, - serde_json::json!{{"data": {"currentUser": { "__typename": "User", "id": "0" }}}} - ) - .with_json( - serde_json::json!{{ - "query":"{computer(id:\"Computer1\"){id errorField}}", - }}, - serde_json::json!{{ + let subgraphs = MockedSubgraphs( + [( + "computers", + MockSubgraph::builder() + .with_json( + serde_json::json! {{ + "query":"{computer(id:\"Computer1\"){id errorField}}", + }}, + serde_json::json! {{ "data": { "computer": { "id": "Computer1" @@ -560,9 +559,126 @@ async fn errors_from_primary_on_deferred_responses() { "path": ["computer","errorField"], } ] - }} - ).build()), - ].into_iter().collect()); + }}, + ) + .build(), + )] + .into_iter() + .collect(), + ); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(schema) + .extra_plugin(subgraphs) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .context(defer_context()) + .query( + r#"query { + computer(id: "Computer1") { + id + ...ComputerErrorField @defer + } + } + fragment ComputerErrorField on Computer { + errorField + }"#, + ) + .build() + .unwrap(); + + let mut stream = service.oneshot(request).await.unwrap(); + + insta::assert_json_snapshot!(stream.next_response().await.unwrap()); + + insta::assert_json_snapshot!(stream.next_response().await.unwrap()); +} + +#[tokio::test] +async fn errors_with_invalid_paths_on_query_with_defer() { + let schema = r#" + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + { + query: Query + } + + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + directive @join__field(graph: join__Graph!, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + scalar link__Import + enum link__Purpose { + SECURITY + EXECUTION + } + + type Computer + @join__type(graph: COMPUTERS) + { + id: ID! + errorField: String + nonNullErrorField: String! + } + + scalar join__FieldSet + + enum join__Graph { + COMPUTERS @join__graph(name: "computers", url: "http://localhost:4001/") + } + + + type Query + @join__type(graph: COMPUTERS) + { + computer(id: ID!): Computer + }"#; + + let subgraphs = MockedSubgraphs( + [( + "computers", + MockSubgraph::builder() + .with_json( + serde_json::json! {{ + "query":"{computer(id:\"Computer1\"){id errorField}}", + }}, + serde_json::json! {{ + "data": { + "computer": { + "id": "Computer1" + } + }, + "errors": [ + { + "message": "Subgraph error with invalid path", + "path": ["invalid","path"], + }, + { + "message": "Subgraph error with partially valid path", + "path": ["currentUser", "invalidSubpath"], + }, + { + "message": "Error field", + "path": ["computer","errorField", "errorFieldSubpath"], + } + ] + }}, + ) + .build(), + )] + .into_iter() + .collect(), + ); let service = TestHarness::builder() .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) @@ -858,7 +974,7 @@ async fn root_typename_with_defer() { async fn subscription_with_callback() { let mut notify = Notify::builder().build(); let (handle, _) = notify - .create_or_subscribe("TEST_TOPIC".to_string(), false) + .create_or_subscribe("TEST_TOPIC".to_string(), false, None) .await .unwrap(); let subgraphs = MockedSubgraphs([ @@ -939,7 +1055,7 @@ async fn subscription_with_callback() { async fn subscription_callback_schema_reload() { let mut notify = Notify::builder().build(); let (handle, _) = notify - .create_or_subscribe("TEST_TOPIC".to_string(), false) + .create_or_subscribe("TEST_TOPIC".to_string(), false, None) .await .unwrap(); let orga_subgraph = MockSubgraph::builder().with_json( @@ -1016,20 +1132,19 @@ async fn subscription_callback_schema_reload() { // reload schema let schema = Schema::parse(&new_schema, &configuration).unwrap(); notify.broadcast_schema(Arc::new(schema)); - insta::assert_json_snapshot!(tokio::time::timeout( - Duration::from_secs(1), - stream.next_response() - ) - .await - .unwrap() - .unwrap()); + insta::assert_json_snapshot!( + tokio::time::timeout(Duration::from_secs(1), stream.next_response()) + .await + .unwrap() + .unwrap() + ); } #[tokio::test] async fn subscription_with_callback_with_limit() { let mut notify = Notify::builder().build(); let (handle, _) = notify - .create_or_subscribe("TEST_TOPIC".to_string(), false) + .create_or_subscribe("TEST_TOPIC".to_string(), false, None) .await .unwrap(); let subgraphs = MockedSubgraphs([ @@ -1161,28 +1276,13 @@ async fn root_typename_with_defer_and_empty_first_response() { let subgraphs = MockedSubgraphs([ ("user", MockSubgraph::builder().with_json( serde_json::json!{{ - "query": " - { ..._generated_onQuery1_0 } - - fragment _generated_onQuery1_0 on Query { - currentUser { activeOrganization { __typename id} } - } - ", + "query": "{ ... { currentUser { activeOrganization { __typename id } } } }", }}, serde_json::json!{{"data": {"currentUser": { "activeOrganization": { "__typename": "Organization", "id": "0" } }}}} ).build()), ("orga", MockSubgraph::builder().with_json( serde_json::json!{{ - "query": " - query($representations: [_Any!]!) { - _entities(representations: $representations) { - ..._generated_onOrganization1_0 - } - } - fragment _generated_onOrganization1_0 on Organization { - suborga { id name } - } - ", + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Organization { suborga { id name } } } }", "variables": { "representations":[{"__typename": "Organization", "id":"0"}] } @@ -1738,7 +1838,7 @@ async fn reconstruct_deferred_query_under_interface() { fn subscription_context() -> Context { let context = Context::new(); - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { lock.insert(ClientRequestAccepts { multipart_subscription: true, ..Default::default() @@ -1750,7 +1850,7 @@ fn subscription_context() -> Context { fn defer_context() -> Context { let context = Context::new(); - context.extensions().with_lock(|mut lock| { + context.extensions().with_lock(|lock| { lock.insert(ClientRequestAccepts { multipart_defer: true, ..Default::default() @@ -2751,9 +2851,7 @@ async fn no_typename_on_interface() { .unwrap() .get("name") .unwrap(), - "{:?}\n{:?}", - with_typename, - no_typename + "{with_typename:?}\n{no_typename:?}" ); insta::assert_json_snapshot!(with_typename); @@ -2794,9 +2892,7 @@ async fn no_typename_on_interface() { .unwrap() .get("name") .unwrap(), - "{:?}\n{:?}", - with_reversed_fragments, - no_typename + "{with_reversed_fragments:?}\n{no_typename:?}" ); insta::assert_json_snapshot!(with_reversed_fragments); } @@ -3146,7 +3242,7 @@ async fn id_scalar_can_overflow_i32() { .await .unwrap(); // The router did not panic or respond with an early validation error. - // Instead it did a subgraph fetch, which recieved the correct ID variable without rounding: + // Instead it did a subgraph fetch, which received the correct ID variable without rounding: assert_eq!( response.errors[0].extensions["reason"].as_str().unwrap(), "$id = 9007199254740993" @@ -3308,9 +3404,9 @@ async fn interface_object_typename() { } } }"#,*/ - // this works too - /* - r#"{ + // this works too + /* + r#"{ searchContacts(name: "max") { inner { ...F @@ -3320,7 +3416,7 @@ async fn interface_object_typename() { fragment F on Contact { country }"#, - */ + */ // this does not r#"{ searchContacts(name: "max") { @@ -3342,119 +3438,6 @@ async fn interface_object_typename() { insta::assert_json_snapshot!(stream.next_response().await.unwrap()); } -#[tokio::test] -async fn fragment_reuse() { - const SCHEMA: &str = r#"schema - @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) - { - query: Query - } - directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA - directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE - directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION - directive @join__graph(name: String!, url: String!) on ENUM_VALUE - directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR - directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION - directive @join__implements( graph: join__Graph! interface: String!) repeatable on OBJECT | INTERFACE - - scalar link__Import - - enum link__Purpose { - SECURITY - EXECUTION - } - scalar join__FieldSet - - enum join__Graph { - USER @join__graph(name: "user", url: "http://localhost:4001/graphql") - ORGA @join__graph(name: "orga", url: "http://localhost:4002/graphql") - } - - type Query - @join__type(graph: ORGA) - @join__type(graph: USER) - { - me: User @join__field(graph: USER) - } - - type User - @join__type(graph: ORGA, key: "id") - @join__type(graph: USER, key: "id") - { - id: ID! - name: String - organizations: [Organization] @join__field(graph: ORGA) - } - type Organization - @join__type(graph: ORGA, key: "id") - { - id: ID - name: String @join__field(graph: ORGA) - }"#; - - let subgraphs = MockedSubgraphs([ - ("user", MockSubgraph::builder().with_json( - serde_json::json!{{ - "query":"query Query__user__0($a:Boolean!=true$b:Boolean!=true){me{name ...on User@include(if:$a){__typename id}...on User@include(if:$b){__typename id}}}", - "operationName": "Query__user__0" - }}, - serde_json::json!{{"data": {"me": { "name": "Ada", "__typename": "User", "id": "1" }}}} - ).build()), - ("orga", MockSubgraph::builder().with_json( - serde_json::json!{{ - "query":"query Query__orga__1($representations:[_Any!]!$a:Boolean!=true$b:Boolean!=true){_entities(representations:$representations){...F@include(if:$a)...F@include(if:$b)}}fragment F on User{organizations{id name}}", - "operationName": "Query__orga__1", - "variables":{"representations":[{"__typename":"User","id":"1"}]} - }}, - serde_json::json!{{"data": {"_entities": [{ "organizations": [{"id": "2", "name": "Apollo"}] }]}}} - ).build()) - ].into_iter().collect()); - - let service = TestHarness::builder() - .configuration_json(serde_json::json!({ - "include_subgraph_errors": { "all": true }, - "supergraph": { - "generate_query_fragments": false, - "experimental_reuse_query_fragments": true, - } - })) - .unwrap() - .schema(SCHEMA) - .extra_plugin(subgraphs) - .build_supergraph() - .await - .unwrap(); - - let request = supergraph::Request::fake_builder() - .query( - r#"query Query($a: Boolean! = true, $b: Boolean! = true) { - me { - name - ...F @include(if: $a) - ...F @include(if: $b) - } - } - fragment F on User { - organizations { - id - name - } - }"#, - ) - .build() - .unwrap(); - let response = service - .oneshot(request) - .await - .unwrap() - .next_response() - .await - .unwrap(); - - insta::assert_json_snapshot!(response); -} - #[tokio::test] async fn abstract_types_in_requires() { let schema = r#"schema @@ -3647,10 +3630,15 @@ const ENUM_SCHEMA: &str = r#"schema B }"#; +// Companion test: services::router::tests::invalid_input_enum #[tokio::test] async fn invalid_input_enum() { let service = TestHarness::builder() - .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .configuration_json(serde_json::json!({ + "include_subgraph_errors": { + "all": true, + }, + })) .unwrap() .schema(ENUM_SCHEMA) //.extra_plugin(subgraphs) @@ -3658,23 +3646,6 @@ async fn invalid_input_enum() { .await .unwrap(); - let request = supergraph::Request::fake_builder() - .query("query { test(input: C) }") - .context(defer_context()) - // Request building here - .build() - .unwrap(); - let response = service - .clone() - .oneshot(request) - .await - .unwrap() - .next_response() - .await - .unwrap(); - - insta::assert_json_snapshot!(response); - let request = supergraph::Request::fake_builder() .query("query($input: InputEnum) { test(input: $input) }") .variable("input", "INVALID") @@ -3682,6 +3653,7 @@ async fn invalid_input_enum() { // Request building here .build() .unwrap(); + let response = service .oneshot(request) .await diff --git a/apollo-router/src/services/transport.rs b/apollo-router/src/services/transport.rs deleted file mode 100644 index b1e1fbccb1..0000000000 --- a/apollo-router/src/services/transport.rs +++ /dev/null @@ -1,15 +0,0 @@ -#![allow(deprecated)] -#![allow(missing_docs)] - -use tower::BoxError; - -#[deprecated = "use `apollo_router::services::router::Request` instead"] -pub type Request = http::Request; -#[deprecated = "use `apollo_router::services::router::Response` instead"] -pub type Response = http::Response; -#[deprecated = "use `apollo_router::services::router::BoxService` instead"] -pub type BoxService = tower::util::BoxService; -#[deprecated = "use `apollo_router::services::router::BoxCloneService` instead"] -pub type BoxCloneService = tower::util::BoxCloneService; -#[deprecated = "use `apollo_router::services::router::ServiceResult` instead"] -pub type ServiceResult = Result; diff --git a/apollo-router/src/spec/field_type.rs b/apollo-router/src/spec/field_type.rs index b160aff214..7ea3d18040 100644 --- a/apollo-router/src/spec/field_type.rs +++ b/apollo-router/src/spec/field_type.rs @@ -1,8 +1,8 @@ -use apollo_compiler::schema; use apollo_compiler::Name; -use serde::de::Error as _; +use apollo_compiler::schema; use serde::Deserialize; use serde::Serialize; +use serde::de::Error as _; use super::query::parse_hir_value; use crate::json_ext::Value; @@ -12,9 +12,39 @@ use crate::spec::Schema; #[derive(Debug)] pub(crate) struct InvalidValue; +/// {0} +#[derive(thiserror::Error, displaydoc::Display, Debug, Clone, Serialize, Eq, PartialEq)] +pub(crate) struct InvalidInputValue(pub(crate) String); + +fn describe_json_value(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "map", + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct FieldType(pub(crate) schema::Type); +/// A path within a JSON object that doesn’t need heap allocation in the happy path +pub(crate) enum JsonValuePath<'a> { + Variable { + name: &'a str, + }, + ObjectKey { + key: &'a str, + parent: &'a JsonValuePath<'a>, + }, + ArrayItem { + index: usize, + parent: &'a JsonValuePath<'a>, + }, +} + // schema::Type does not implement Serialize or Deserialize, // and seems not to work for recursive types. // Instead have explicit `impl`s that are based on derived impl of purpose-built types. @@ -26,7 +56,7 @@ impl Serialize for FieldType { { struct BorrowedFieldType<'a>(&'a schema::Type); - impl<'a> Serialize for BorrowedFieldType<'a> { + impl Serialize for BorrowedFieldType<'_> { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, @@ -85,30 +115,64 @@ impl std::fmt::Display for FieldType { } } +/// This function currently stops at the first error it finds. +/// It may be nicer to return a `Vec` of errors, but its size should be limited +/// in case e.g. every item of a large array is invalid. fn validate_input_value( ty: &schema::Type, - value: &Value, + value: Option<&Value>, schema: &Schema, -) -> Result<(), InvalidValue> { + path: &JsonValuePath<'_>, +) -> Result<(), InvalidInputValue> { + let fmt_path = || match path { + JsonValuePath::Variable { .. } => format!("variable `{path}`"), + _ => format!("input value at `{path}`"), + }; + let Some(value) = value else { + if ty.is_non_null() { + return Err(InvalidInputValue(format!( + "missing {}: for required GraphQL type `{ty}`", + fmt_path(), + ))); + } else { + return Ok(()); + } + }; + let invalid = || { + InvalidInputValue(format!( + "invalid {}: found JSON {} for GraphQL type `{ty}`", + fmt_path(), + describe_json_value(value) + )) + }; if value.is_null() { - return match ty { - schema::Type::Named(_) | schema::Type::List(_) => Ok(()), - schema::Type::NonNullNamed(_) | schema::Type::NonNullList(_) => Err(InvalidValue), - }; + if ty.is_non_null() { + return Err(invalid()); + } else { + return Ok(()); + } } let type_name = match ty { schema::Type::Named(name) | schema::Type::NonNullNamed(name) => name, schema::Type::List(inner_type) | schema::Type::NonNullList(inner_type) => { - return if let Value::Array(vec) = value { - vec.iter() - .try_for_each(|x| validate_input_value(inner_type, x, schema)) + if let Value::Array(vec) = value { + for (i, x) in vec.iter().enumerate() { + let path = JsonValuePath::ArrayItem { + index: i, + parent: path, + }; + validate_input_value(inner_type, Some(x), schema, &path)? + } + return Ok(()); } else { // For coercion from single value to list - validate_input_value(inner_type, value, schema) - }; + return validate_input_value(inner_type, Some(value), schema, path); + } } }; - let from_bool = |condition| if condition { Ok(()) } else { Err(InvalidValue) }; + let from_bool = |condition| { + if condition { Ok(()) } else { Err(invalid()) } + }; match type_name.as_str() { "String" => return from_bool(value.is_string()), // Spec: https://spec.graphql.org/June2018/#sec-Int @@ -129,7 +193,8 @@ fn validate_input_value( .supergraph_schema() .types .get(type_name) - .ok_or(InvalidValue)?; + // Should never happen in a valid schema + .ok_or_else(invalid)?; match (type_def, value) { // Custom scalar: accept any JSON value (schema::ExtendedType::Scalar(_), _) => Ok(()), @@ -137,25 +202,28 @@ fn validate_input_value( (schema::ExtendedType::Enum(def), Value::String(s)) => { from_bool(def.values.contains_key(s.as_str())) } - (schema::ExtendedType::Enum(_), _) => Err(InvalidValue), + (schema::ExtendedType::Enum(_), _) => Err(invalid()), (schema::ExtendedType::InputObject(def), Value::Object(obj)) => { // TODO: check keys in `obj` but not in `def.fields`? - def.fields - .values() - .try_for_each(|field| match obj.get(field.name.as_str()) { + def.fields.values().try_for_each(|field| { + let path = JsonValuePath::ObjectKey { + key: &field.name, + parent: path, + }; + match obj.get(field.name.as_str()) { Some(&Value::Null) | None => { let default = field .default_value .as_ref() - .and_then(|v| parse_hir_value(v)) - .unwrap_or(Value::Null); - validate_input_value(&field.ty, &default, schema) + .and_then(|v| parse_hir_value(v)); + validate_input_value(&field.ty, default.as_ref(), schema, &path) } - Some(value) => validate_input_value(&field.ty, value, schema), - }) + value => validate_input_value(&field.ty, value, schema, &path), + } + }) } - _ => Err(InvalidValue), + _ => Err(invalid()), } } @@ -168,10 +236,11 @@ impl FieldType { // Each of the values are validated against the "input coercion" rules. pub(crate) fn validate_input_value( &self, - value: &Value, + value: Option<&Value>, schema: &Schema, - ) -> Result<(), InvalidValue> { - validate_input_value(&self.0, value, schema) + path: &JsonValuePath<'_>, + ) -> Result<(), InvalidInputValue> { + validate_input_value(&self.0, value, schema, path) } pub(crate) fn is_non_null(&self) -> bool { @@ -185,6 +254,26 @@ impl From<&'_ schema::Type> for FieldType { } } +impl std::fmt::Display for JsonValuePath<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Variable { name } => { + f.write_str("$")?; + f.write_str(name) + } + Self::ObjectKey { key, parent } => { + parent.fmt(f)?; + f.write_str(".")?; + f.write_str(key) + } + Self::ArrayItem { index, parent } => { + parent.fmt(f)?; + write!(f, "[{index}]") + } + } + } +} + /// Make sure custom Serialize and Deserialize impls are compatible with each other #[test] fn test_field_type_serialization() { diff --git a/apollo-router/src/spec/fragments.rs b/apollo-router/src/spec/fragments.rs index 649e6cbbcc..0d93542ac3 100644 --- a/apollo-router/src/spec/fragments.rs +++ b/apollo-router/src/spec/fragments.rs @@ -4,10 +4,10 @@ use apollo_compiler::ExecutableDocument; use serde::Deserialize; use serde::Serialize; -use crate::spec::query::DeferStats; use crate::spec::Schema; use crate::spec::Selection; use crate::spec::SpecError; +use crate::spec::query::DeferStats; #[derive(Debug, Serialize, Deserialize)] pub(crate) struct Fragments { diff --git a/apollo-router/src/spec/mod.rs b/apollo-router/src/spec/mod.rs index 786eb8a056..d559c8138e 100644 --- a/apollo-router/src/spec/mod.rs +++ b/apollo-router/src/spec/mod.rs @@ -14,7 +14,9 @@ pub(crate) use field_type::*; pub(crate) use fragments::*; pub(crate) use query::Query; pub(crate) use query::TYPENAME; +pub(crate) use schema::QueryHash; pub(crate) use schema::Schema; +pub(crate) use schema::SchemaHash; pub(crate) use selection::*; use serde::Deserialize; use serde::Serialize; @@ -62,13 +64,17 @@ pub(crate) enum SpecError { QueryHashing(String), } -pub(crate) const GRAPHQL_VALIDATION_FAILURE_ERROR_KEY: &str = "## GraphQLValidationFailure\n"; +const GRAPHQL_PARSE_FAILURE_ERROR_KEY: &str = "GraphQLParseFailure"; +const GRAPHQL_UNKNOWN_OPERATION_NAME_ERROR_KEY: &str = "GraphQLUnknownOperationName"; +const GRAPHQL_VALIDATION_FAILURE_ERROR_KEY: &str = "GraphQLValidationFailure"; impl SpecError { pub(crate) const fn get_error_key(&self) -> &'static str { match self { - SpecError::TransformError(_) | SpecError::ParseError(_) => "## GraphQLParseFailure\n", - SpecError::UnknownOperation(_) => "## GraphQLUnknownOperationName\n", + SpecError::TransformError(_) | SpecError::ParseError(_) => { + GRAPHQL_PARSE_FAILURE_ERROR_KEY + } + SpecError::UnknownOperation(_) => GRAPHQL_UNKNOWN_OPERATION_NAME_ERROR_KEY, _ => GRAPHQL_VALIDATION_FAILURE_ERROR_KEY, } } @@ -87,7 +93,7 @@ impl ErrorExtension for SpecError { SpecError::TransformError(_) => "PARSING_ERROR", SpecError::ParseError(_) => "PARSING_ERROR", SpecError::ValidationError(_) => "GRAPHQL_VALIDATION_FAILED", - SpecError::UnknownOperation(_) => "GRAPHQL_VALIDATION_FAILED", + SpecError::UnknownOperation(_) => "GRAPHQL_UNKNOWN_OPERATION_NAME", SpecError::MultipleOperationWithoutOperationName => "GRAPHQL_VALIDATION_FAILED", SpecError::NoOperation => "GRAPHQL_VALIDATION_FAILED", SpecError::SubscriptionNotSupported => "SUBSCRIPTION_NOT_SUPPORTED", @@ -160,3 +166,9 @@ impl IntoGraphQLErrors for SpecError { } } } + +impl From for SpecError { + fn from(value: std::convert::Infallible) -> Self { + match value {} + } +} diff --git a/apollo-router/src/spec/operation_limits.rs b/apollo-router/src/spec/operation_limits.rs index c280ba4c62..bcfa974427 100644 --- a/apollo-router/src/spec/operation_limits.rs +++ b/apollo-router/src/spec/operation_limits.rs @@ -1,9 +1,9 @@ use std::collections::HashMap; use std::collections::HashSet; -use apollo_compiler::executable; use apollo_compiler::ExecutableDocument; use apollo_compiler::Name; +use apollo_compiler::executable; use serde::Deserialize; use serde::Serialize; @@ -99,10 +99,10 @@ pub(crate) fn check( if exceeded.any() { let mut messages = Vec::new(); max.combine(measured, |ident, max, measured| { - if let Some(max) = max { - if measured > max { - messages.push(format!("{ident}: {measured}, max_{ident}: {max}")) - } + if let Some(max) = max + && measured > max + { + messages.push(format!("{ident}: {measured}, max_{ident}: {max}")) } }); let message = messages.join(", "); @@ -139,29 +139,29 @@ fn count<'a>( match selection { executable::Selection::Field(field) => { let nested = count(document, fragment_cache, &field.selection_set); - counts.depth = counts.depth.max(1 + nested.depth); - counts.height += nested.height; - counts.aliases += nested.aliases; + counts.depth = counts.depth.max(nested.depth.saturating_add(1)); + counts.height = counts.height.saturating_add(nested.height); + counts.aliases = counts.aliases.saturating_add(nested.aliases); // Multiple aliases for the same field could use different arguments // Until we do full merging for limit checking purpose, // approximate measured height with an upper bound rather than a lower bound. let used_name = if let Some(alias) = &field.alias { - counts.aliases += 1; + counts.aliases = counts.aliases.saturating_add(1); alias } else { &field.name }; let not_seen_before = fields_seen.insert(used_name); if not_seen_before { - counts.height += 1; - counts.root_fields += 1; + counts.height = counts.height.saturating_add(1); + counts.root_fields = counts.root_fields.saturating_add(1); } } executable::Selection::InlineFragment(fragment) => { let nested = count(document, fragment_cache, &fragment.selection_set); counts.depth = counts.depth.max(nested.depth); - counts.height += nested.height; - counts.aliases += nested.aliases; + counts.height = counts.height.saturating_add(nested.height); + counts.aliases = counts.aliases.saturating_add(nested.aliases); } executable::Selection::FragmentSpread(fragment) => { let name = &fragment.fragment_name; @@ -190,8 +190,8 @@ fn count<'a>( Some(Computation::Done(cached)) => nested = *cached, } counts.depth = counts.depth.max(nested.depth); - counts.height += nested.height; - counts.aliases += nested.aliases; + counts.height = counts.height.saturating_add(nested.height); + counts.aliases = counts.aliases.saturating_add(nested.aliases); } } } diff --git a/apollo-router/src/spec/query.rs b/apollo-router/src/spec/query.rs index c8bcddec6d..8c55f01658 100644 --- a/apollo-router/src/spec/query.rs +++ b/apollo-router/src/spec/query.rs @@ -7,9 +7,9 @@ use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; +use apollo_compiler::ExecutableDocument; use apollo_compiler::executable; use apollo_compiler::schema::ExtendedType; -use apollo_compiler::ExecutableDocument; use derivative::Derivative; use indexmap::IndexSet; use serde::Deserialize; @@ -17,11 +17,12 @@ use serde::Serialize; use serde_json_bytes::ByteString; use tracing::level_filters::LevelFilter; -use self::change::QueryHashVisitor; use self::subselections::BooleanValues; use self::subselections::SubSelectionKey; use self::subselections::SubSelectionValue; use super::Fragment; +use super::QueryHash; +use crate::Configuration; use crate::error::FetchError; use crate::graphql::Error; use crate::graphql::Request; @@ -30,29 +31,26 @@ use crate::json_ext::Object; use crate::json_ext::Path; use crate::json_ext::ResponsePathElement; use crate::json_ext::Value; -use crate::json_ext::ValueExt; use crate::plugins::authorization::UnauthorizedPaths; use crate::query_planner::fetch::OperationKind; -use crate::query_planner::fetch::QueryHash; -use crate::services::layers::query_analysis::get_operation; use crate::services::layers::query_analysis::ParsedDocument; use crate::services::layers::query_analysis::ParsedDocumentInner; -use crate::spec::schema::ApiSchema; +use crate::services::layers::query_analysis::get_operation; use crate::spec::FieldType; use crate::spec::Fragments; use crate::spec::InvalidValue; use crate::spec::Schema; use crate::spec::Selection; use crate::spec::SpecError; -use crate::Configuration; +use crate::spec::schema::ApiSchema; -pub(crate) mod change; pub(crate) mod subselections; pub(crate) mod transform; pub(crate) mod traverse; pub(crate) const TYPENAME: &str = "__typename"; -pub(crate) const RESPONSE_VALIDATION: &str = "RESPONSE_VALIDATION_FAILED"; +pub(crate) const ERROR_CODE_RESPONSE_VALIDATION: &str = "RESPONSE_VALIDATION_FAILED"; +pub(crate) const EXTENSIONS_VALUE_COMPLETION_KEY: &str = "valueCompletion"; /// A GraphQL query. #[derive(Derivative, Serialize, Deserialize)] @@ -76,12 +74,9 @@ pub(crate) struct Query { /// This is a hash that depends on: /// - the query itself - /// - the relevant parts of the schema - /// - /// if a schema update does not affect a query, then this will be the same hash - /// with the old and new schema + /// - the schema #[derivative(PartialEq = "ignore", Hash = "ignore")] - pub(crate) schema_aware_hash: Vec, + pub(crate) schema_aware_hash: QueryHash, } #[derive(Debug, Serialize, Deserialize)] @@ -97,7 +92,11 @@ pub(crate) struct DeferStats { } impl Query { - pub(crate) fn empty() -> Self { + /// Returns an empty query. This should be used somewhat carefully and only in tests. + /// Other parts of the router may not handle empty queries properly. + /// + /// FIXME: This should be marked cfg(test) but it's used in places where adding cfg(test) is tricky. + pub(crate) fn empty_for_tests() -> Self { Self { string: String::new(), fragments: Fragments { @@ -113,7 +112,7 @@ impl Query { conditional_defer_variable_names: IndexSet::default(), }, is_original: true, - schema_aware_hash: vec![], + schema_aware_hash: QueryHash::default(), } } @@ -145,9 +144,8 @@ impl Query { let mut parameters = FormatParameters { variables: &variables, schema, - nullification_errors: Vec::new(), + errors: Vec::new(), nullified: Vec::new(), - validation_errors: Vec::new(), }; response.data = Some( @@ -164,16 +162,12 @@ impl Query { }, ); - if !parameters.nullification_errors.is_empty() { - if let Ok(value) = - serde_json_bytes::to_value(¶meters.nullification_errors) - { - response.extensions.insert("valueCompletion", value); - } - } - - if !parameters.validation_errors.is_empty() { - response.errors.append(&mut parameters.validation_errors); + if !parameters.errors.is_empty() + && let Ok(value) = serde_json_bytes::to_value(¶meters.errors) + { + response + .extensions + .insert(EXTENSIONS_VALUE_COMPLETION_KEY, value); } return parameters.nullified; @@ -207,9 +201,8 @@ impl Query { let mut parameters = FormatParameters { variables: &all_variables, schema, - nullification_errors: Vec::new(), + errors: Vec::new(), nullified: Vec::new(), - validation_errors: Vec::new(), }; response.data = Some( @@ -225,16 +218,12 @@ impl Query { Err(InvalidValue) => Value::Null, }, ); - if !parameters.nullification_errors.is_empty() { - if let Ok(value) = - serde_json_bytes::to_value(¶meters.nullification_errors) - { - response.extensions.insert("valueCompletion", value); - } - } - - if !parameters.validation_errors.is_empty() { - response.errors.append(&mut parameters.validation_errors); + if !parameters.errors.is_empty() + && let Ok(value) = serde_json_bytes::to_value(¶meters.errors) + { + response + .extensions + .insert(EXTENSIONS_VALUE_COMPLETION_KEY, value); } return parameters.nullified; @@ -282,37 +271,30 @@ impl Query { let recursion_limit = parser.recursion_reached(); tracing::trace!(?recursion_limit, "recursion limit data"); - let hash = QueryHashVisitor::hash_query( - schema.supergraph_schema(), - &schema.raw_sdl, - &executable_document, - operation_name, - ) - .map_err(|e| SpecError::QueryHashing(e.to_string()))?; - + let hash = schema.schema_id.operation_hash(query, operation_name); ParsedDocumentInner::new( ast, Arc::new(executable_document), operation_name, - Arc::new(QueryHash(hash)), + Arc::new(hash), ) } #[cfg(test)] pub(crate) fn parse( - query: impl Into, + query_text: impl Into, operation_name: Option<&str>, schema: &Schema, configuration: &Configuration, ) -> Result { - let query = query.into(); + let query_text = query_text.into(); - let doc = Self::parse_document(&query, operation_name, schema, configuration)?; + let doc = Self::parse_document(&query_text, operation_name, schema, configuration)?; let (fragments, operation, defer_stats, schema_aware_hash) = - Self::extract_query_information(schema, &doc.executable, operation_name)?; + Self::extract_query_information(schema, &query_text, &doc.executable, operation_name)?; Ok(Query { - string: query, + string: query_text, fragments, operation, subselections: HashMap::new(), @@ -327,9 +309,10 @@ impl Query { /// Extract serializable data structures from the apollo-compiler HIR. pub(crate) fn extract_query_information( schema: &Schema, + query_text: &str, document: &ExecutableDocument, operation_name: Option<&str>, - ) -> Result<(Fragments, Operation, DeferStats, Vec), SpecError> { + ) -> Result<(Fragments, Operation, DeferStats, QueryHash), SpecError> { let mut defer_stats = DeferStats { has_defer: false, has_unconditional_defer: false, @@ -338,13 +321,7 @@ impl Query { let fragments = Fragments::from_hir(document, schema, &mut defer_stats)?; let operation = get_operation(document, operation_name)?; let operation = Operation::from_hir(&operation, schema, &mut defer_stats, &fragments)?; - - let mut visitor = - QueryHashVisitor::new(schema.supergraph_schema(), &schema.raw_sdl, document); - traverse::document(&mut visitor, document, operation_name).map_err(|e| { - SpecError::QueryHashing(format!("could not calculate the query hash: {e}")) - })?; - let hash = visitor.finish(); + let hash = schema.schema_id.operation_hash(query_text, operation_name); Ok((fragments, operation, defer_stats, hash)) } @@ -358,7 +335,6 @@ impl Query { output: &mut Value, path: &mut Vec>, parent_type: &executable::Type, - field_or_index: FieldOrIndex<'a>, selection_set: &'a [Selection], ) -> Result<(), InvalidValue> { // for every type, if we have an invalid value, we will replace it with null @@ -379,8 +355,7 @@ impl Query { input, output, path, - parent_type, - field_or_index, + field_type, selection_set, ) { Err(_) => Err(InvalidValue), @@ -395,11 +370,12 @@ impl Query { ), _ => todo!(), }; - parameters.nullification_errors.push(Error { - message, - path: Some(Path::from_response_slice(path)), - ..Error::default() - }); + parameters.errors.push( + Error::builder() + .message(message) + .path(Path::from_response_slice(path)) + .build(), + ); Err(InvalidValue) } else { @@ -416,11 +392,7 @@ impl Query { executable::Type::List(inner_type) => match input { Value::Array(input_array) => { if output.is_null() { - *output = Value::Array( - std::iter::repeat(Value::Null) - .take(input_array.len()) - .collect(), - ); + *output = Value::Array(vec![Value::Null; input_array.len()]); } let output_array = output.as_array_mut().ok_or(InvalidValue)?; match input_array @@ -435,7 +407,6 @@ impl Query { &mut output_array[i], path, field_type, - FieldOrIndex::Index(i), selection_set, ); path.pop(); @@ -449,22 +420,7 @@ impl Query { Ok(()) => Ok(()), } } - Value::Null => Ok(()), - v => { - parameters.validation_errors.push( - Error::builder() - .message(format!( - "Invalid non-list value of type {} for list type {field_type}", - v.json_type_name() - )) - .path(Path::from_response_slice(path)) - .extension_code(RESPONSE_VALIDATION) - .build(), - ); - - *output = Value::Null; - Ok(()) - } + _ => Ok(()), }, executable::Type::Named(name) if name == "Int" => { let opt = if input.is_i64() { @@ -480,19 +436,6 @@ impl Query { if opt.is_some() { *output = input.clone(); } else { - if !input.is_null() { - parameters.validation_errors.push( - Error::builder() - .message(invalid_value_message( - parent_type, - field_type, - field_or_index, - )) - .path(Path::from_response_slice(path)) - .extension_code(RESPONSE_VALIDATION) - .build(), - ); - } *output = Value::Null; } Ok(()) @@ -501,19 +444,6 @@ impl Query { if input.as_f64().is_some() { *output = input.clone(); } else { - if !input.is_null() { - parameters.validation_errors.push( - Error::builder() - .message(invalid_value_message( - parent_type, - field_type, - field_or_index, - )) - .path(Path::from_response_slice(path)) - .extension_code(RESPONSE_VALIDATION) - .build(), - ); - } *output = Value::Null; } Ok(()) @@ -522,19 +452,6 @@ impl Query { if input.as_bool().is_some() { *output = input.clone(); } else { - if !input.is_null() { - parameters.validation_errors.push( - Error::builder() - .message(invalid_value_message( - parent_type, - field_type, - field_or_index, - )) - .path(Path::from_response_slice(path)) - .extension_code(RESPONSE_VALIDATION) - .build(), - ); - } *output = Value::Null; } Ok(()) @@ -543,19 +460,6 @@ impl Query { if input.as_str().is_some() { *output = input.clone(); } else { - if !input.is_null() { - parameters.validation_errors.push( - Error::builder() - .message(invalid_value_message( - parent_type, - field_type, - field_or_index, - )) - .path(Path::from_response_slice(path)) - .extension_code(RESPONSE_VALIDATION) - .build(), - ); - } *output = Value::Null; } Ok(()) @@ -564,19 +468,6 @@ impl Query { if input.is_string() || input.is_i64() || input.is_u64() || input.is_f64() { *output = input.clone(); } else { - if !input.is_null() { - parameters.validation_errors.push( - Error::builder() - .message(invalid_value_message( - parent_type, - field_type, - field_or_index, - )) - .path(Path::from_response_slice(path)) - .extension_code(RESPONSE_VALIDATION) - .build(), - ); - } *output = Value::Null; } Ok(()) @@ -596,31 +487,11 @@ impl Query { *output = input.clone(); Ok(()) } else { - parameters.validation_errors.push( - Error::builder() - .message(format!( - "Expected a valid enum value for type {}", - enum_type.name - )) - .path(Path::from_response_slice(path)) - .extension_code(RESPONSE_VALIDATION) - .build(), - ); *output = Value::Null; Ok(()) } } None => { - parameters.validation_errors.push( - Error::builder() - .message(format!( - "Expected a valid enum value for type {}", - enum_type.name - )) - .path(Path::from_response_slice(path)) - .extension_code(RESPONSE_VALIDATION) - .build(), - ); *output = Value::Null; Ok(()) } @@ -630,10 +501,7 @@ impl Query { } match input { - Value::Object(ref mut input_object) => { - // FIXME: we should return an error if __typename is not a string - // but this might cause issues for some production deployments where - // __typename might be missing or invalid (cf https://github.com/apollographql/router/commit/4a592f4933b7b9e46f14c7a98404b9e067687f09 ) + Value::Object(input_object) => { if let Some(input_type) = input_object.get(TYPENAME).and_then(|val| val.as_str()) { @@ -686,20 +554,8 @@ impl Query { Ok(()) } - Value::Null => { - *output = Value::Null; - Ok(()) - } - v => { - parameters.validation_errors.push( - Error::builder() - .message(format!( - "Invalid non-object value of type {} for composite type {type_name}", v.json_type_name() - )) - .path(Path::from_response_slice(path)) - .extension_code(RESPONSE_VALIDATION) - .build(), - ); + _ => { + parameters.nullified.push(Path::from_response_slice(path)); *output = Value::Null; Ok(()) } @@ -775,7 +631,6 @@ impl Query { output_value, path, current_type, - FieldOrIndex::Field(field_name.as_str()), selection_set, ); path.pop(); @@ -785,14 +640,15 @@ impl Query { output.insert((*field_name).clone(), Value::Null); } if field_type.is_non_null() { - parameters.nullification_errors.push(Error { - message: format!( - "Cannot return null for non-nullable field {current_type}.{}", - field_name.as_str() - ), - path: Some(Path::from_response_slice(path)), - ..Error::default() - }); + parameters.errors.push( + Error::builder() + .message(format!( + "Cannot return null for non-nullable field {current_type}.{}", + field_name.as_str() + )) + .path(Path::from_response_slice(path)) + .build() + ); return Err(InvalidValue); } @@ -818,10 +674,10 @@ impl Query { if is_apply { // if this is the filtered query, we must keep the __typename field because the original query must know the type - if !self.is_original { - if let Some(input_type) = input.get(TYPENAME) { - output.insert(TYPENAME, input_type.clone()); - } + if !self.is_original + && let Some(input_type) = input.get(TYPENAME) + { + output.insert(TYPENAME, input_type.clone()); } self.apply_selection_set( @@ -859,10 +715,10 @@ impl Query { if is_apply { // if this is the filtered query, we must keep the __typename field because the original query must know the type - if !self.is_original { - if let Some(input_type) = input.get(TYPENAME) { - output.insert(TYPENAME, input_type.clone()); - } + if !self.is_original + && let Some(input_type) = input.get(TYPENAME) + { + output.insert(TYPENAME, input_type.clone()); } self.apply_selection_set( @@ -907,11 +763,6 @@ impl Query { continue; } - let root_type = apollo_compiler::ast::Type::Named( - // Unchecked name instantiation is always safe, and we know the name is - // valid here - apollo_compiler::Name::new_unchecked(root_type_name), - ); let field_name = alias.as_ref().unwrap_or(name); let field_name_str = field_name.as_str(); @@ -940,21 +791,20 @@ impl Query { input_value, output_value, path, - &root_type, - FieldOrIndex::Field(field_name_str), + &field_type.0, selection_set, ); path.pop(); res? } else if field_type.is_non_null() { - parameters.nullification_errors.push(Error { - message: format!( - "Cannot return null for non-nullable field {}.{field_name_str}", - root_type_name - ), - path: Some(Path::from_response_slice(path)), - ..Error::default() - }); + parameters.errors.push( + Error::builder() + .message(format!( + "Cannot return null for non-nullable field {root_type_name}.{field_name_str}" + )) + .path(Path::from_response_slice(path)) + .build(), + ); return Err(InvalidValue); } else { output.insert(field_name.clone(), Value::Null); @@ -1030,6 +880,8 @@ impl Query { /// Validate a [`Request`]'s variables against this [`Query`] using a provided [`Schema`]. #[tracing::instrument(skip_all, level = "trace")] + // `Response` is large, but this is not a frequently used function + #[allow(clippy::result_large_err)] pub(crate) fn validate_variables( &self, request: &Request, @@ -1073,14 +925,19 @@ impl Query { let value = request .variables .get(name.as_str()) - .or(default_value.as_ref()) - .unwrap_or(&Value::Null); - ty.validate_input_value(value, schema).err().map(|_| { - FetchError::ValidationInvalidTypeVariable { - name: name.as_str().to_string(), - } - .to_graphql_error(None) - }) + .or(default_value.as_ref()); + let path = super::JsonValuePath::Variable { + name: name.as_str(), + }; + ty.validate_input_value(value, schema, &path) + .err() + .map(|message| { + FetchError::ValidationInvalidTypeVariable { + name: name.clone(), + message, + } + .to_graphql_error(None) + }) }, ) .collect::>(); @@ -1122,9 +979,21 @@ impl Query { Some(subselection) => &subselection.selection_set, None => &self.operation.selection_set, }; - selection_set + let match_length = selection_set + .iter() + .map(|selection| selection.matching_error_path_length(&path.0, &self.fragments)) + .max() + .unwrap_or(0); + path.len() == match_length + } + + pub(crate) fn matching_error_path_length(&self, path: &Path) -> usize { + self.operation + .selection_set .iter() - .any(|selection| selection.contains_error_path(&path.0, &self.fragments)) + .map(|selection| selection.matching_error_path_length(&path.0, &self.fragments)) + .max() + .unwrap_or(0) } pub(crate) fn defer_variables_set(&self, variables: &Object) -> BooleanValues { @@ -1155,8 +1024,7 @@ impl Query { /// Intermediate structure for arguments passed through the entire formatting struct FormatParameters<'a> { variables: &'a Object, - nullification_errors: Vec, - validation_errors: Vec, + errors: Vec, nullified: Vec, schema: &'a ApiSchema, } @@ -1176,26 +1044,6 @@ pub(crate) struct Variable { default_value: Option, } -enum FieldOrIndex<'a> { - Field(&'a str), - Index(usize), -} - -fn invalid_value_message( - parent_type: &executable::Type, - field_type: &executable::Type, - field_or_index: FieldOrIndex, -) -> String { - match field_or_index { - FieldOrIndex::Field(field_name) => { - format!("Invalid value found for field {parent_type}.{field_name}") - } - FieldOrIndex::Index(i) => { - format!("Invalid value found for array element of type {field_type} at index {i}") - } - } -} - impl Operation { fn empty() -> Self { Self { @@ -1238,9 +1086,9 @@ impl Operation { .as_ref() .and_then(|v| parse_hir_value(v)), }; - Ok((name, variable)) + (name, variable) }) - .collect::>()?; + .collect(); Ok(Operation { selection_set, diff --git a/apollo-router/src/spec/query/change.rs b/apollo-router/src/spec/query/change.rs deleted file mode 100644 index 8bca0e025b..0000000000 --- a/apollo-router/src/spec/query/change.rs +++ /dev/null @@ -1,1087 +0,0 @@ -use std::collections::HashMap; -use std::collections::HashSet; -use std::hash::Hash; -use std::hash::Hasher; - -use apollo_compiler::ast; -use apollo_compiler::ast::Argument; -use apollo_compiler::ast::FieldDefinition; -use apollo_compiler::executable; -use apollo_compiler::parser::Parser; -use apollo_compiler::schema; -use apollo_compiler::schema::DirectiveList; -use apollo_compiler::schema::ExtendedType; -use apollo_compiler::validation::Valid; -use apollo_compiler::Name; -use apollo_compiler::Node; -use sha2::Digest; -use sha2::Sha256; -use tower::BoxError; - -use super::traverse; -use super::traverse::Visitor; -use crate::plugins::progressive_override::JOIN_FIELD_DIRECTIVE_NAME; -use crate::plugins::progressive_override::JOIN_SPEC_BASE_URL; -use crate::spec::Schema; - -pub(crate) const JOIN_TYPE_DIRECTIVE_NAME: &str = "join__type"; - -/// Calculates a hash of the query and the schema, but only looking at the parts of the -/// schema which affect the query. -/// This means that if a schema update does not affect an existing query (example: adding a field), -/// then the hash will stay the same -pub(crate) struct QueryHashVisitor<'a> { - schema: &'a schema::Schema, - // TODO: remove once introspection has been moved out of query planning - // For now, introspection is stiull handled by the planner, so when an - // introspection query is hashed, it should take the whole schema into account - schema_str: &'a str, - hasher: Sha256, - fragments: HashMap<&'a Name, &'a Node>, - hashed_types: HashSet, - // name, field - hashed_fields: HashSet<(String, String)>, - seen_introspection: bool, - join_field_directive_name: Option, - join_type_directive_name: Option, -} - -impl<'a> QueryHashVisitor<'a> { - pub(crate) fn new( - schema: &'a schema::Schema, - schema_str: &'a str, - executable: &'a executable::ExecutableDocument, - ) -> Self { - Self { - schema, - schema_str, - hasher: Sha256::new(), - fragments: executable.fragments.iter().collect(), - hashed_types: HashSet::new(), - hashed_fields: HashSet::new(), - seen_introspection: false, - // should we just return an error if we do not find those directives? - join_field_directive_name: Schema::directive_name( - schema, - JOIN_SPEC_BASE_URL, - ">=0.1.0", - JOIN_FIELD_DIRECTIVE_NAME, - ), - join_type_directive_name: Schema::directive_name( - schema, - JOIN_SPEC_BASE_URL, - ">=0.1.0", - JOIN_TYPE_DIRECTIVE_NAME, - ), - } - } - - pub(crate) fn hash_query( - schema: &'a schema::Schema, - schema_str: &'a str, - executable: &'a executable::ExecutableDocument, - operation_name: Option<&str>, - ) -> Result, BoxError> { - let mut visitor = QueryHashVisitor::new(schema, schema_str, executable); - traverse::document(&mut visitor, executable, operation_name)?; - executable.to_string().hash(&mut visitor); - Ok(visitor.finish()) - } - - pub(crate) fn finish(self) -> Vec { - self.hasher.finalize().as_slice().into() - } - - fn hash_directive(&mut self, directive: &Node) { - directive.name.as_str().hash(self); - for argument in &directive.arguments { - self.hash_argument(argument) - } - } - - fn hash_argument(&mut self, argument: &Node) { - argument.name.hash(self); - self.hash_value(&argument.value); - } - - fn hash_value(&mut self, value: &ast::Value) { - match value { - schema::Value::Null => "null".hash(self), - schema::Value::Enum(e) => { - "enum".hash(self); - e.hash(self); - } - schema::Value::Variable(v) => { - "variable".hash(self); - v.hash(self); - } - schema::Value::String(s) => { - "string".hash(self); - s.hash(self); - } - schema::Value::Float(f) => { - "float".hash(self); - f.hash(self); - } - schema::Value::Int(i) => { - "int".hash(self); - i.hash(self); - } - schema::Value::Boolean(b) => { - "boolean".hash(self); - b.hash(self); - } - schema::Value::List(l) => { - "list[".hash(self); - for v in l.iter() { - self.hash_value(v); - } - "]".hash(self); - } - schema::Value::Object(o) => { - "object{".hash(self); - for (k, v) in o.iter() { - k.hash(self); - ":".hash(self); - self.hash_value(v); - } - "}".hash(self); - } - } - } - - fn hash_type_by_name(&mut self, t: &str) -> Result<(), BoxError> { - if self.hashed_types.contains(t) { - return Ok(()); - } - - self.hashed_types.insert(t.to_string()); - - if let Some(ty) = self.schema.types.get(t) { - self.hash_extended_type(ty)?; - } - Ok(()) - } - - fn hash_extended_type(&mut self, t: &'a ExtendedType) -> Result<(), BoxError> { - match t { - ExtendedType::Scalar(s) => { - for directive in &s.directives { - self.hash_directive(&directive.node); - } - } - ExtendedType::Object(o) => { - for directive in &o.directives { - self.hash_directive(&directive.node); - } - - self.hash_join_type(&o.name, &o.directives)?; - } - ExtendedType::Interface(i) => { - for directive in &i.directives { - self.hash_directive(&directive.node); - } - self.hash_join_type(&i.name, &i.directives)?; - } - ExtendedType::Union(u) => { - for directive in &u.directives { - self.hash_directive(&directive.node); - } - - for member in &u.members { - self.hash_type_by_name(member.as_str())?; - } - } - ExtendedType::Enum(e) => { - for directive in &e.directives { - self.hash_directive(&directive.node); - } - - for (value, def) in &e.values { - value.hash(self); - for directive in &def.directives { - self.hash_directive(directive); - } - } - } - ExtendedType::InputObject(o) => { - for directive in &o.directives { - self.hash_directive(&directive.node); - } - - for (name, ty) in &o.fields { - if ty.default_value.is_some() { - name.hash(self); - self.hash_input_value_definition(&ty.node)?; - } - } - } - } - Ok(()) - } - - fn hash_type(&mut self, t: &ast::Type) -> Result<(), BoxError> { - match t { - schema::Type::Named(name) => self.hash_type_by_name(name.as_str()), - schema::Type::NonNullNamed(name) => { - "!".hash(self); - self.hash_type_by_name(name.as_str()) - } - schema::Type::List(t) => { - "[]".hash(self); - self.hash_type(t) - } - schema::Type::NonNullList(t) => { - "[]!".hash(self); - self.hash_type(t) - } - } - } - - fn hash_field( - &mut self, - parent_type: String, - type_name: String, - field_def: &FieldDefinition, - arguments: &[Node], - ) -> Result<(), BoxError> { - if self.hashed_fields.insert((parent_type.clone(), type_name)) { - self.hash_type_by_name(&parent_type)?; - - field_def.name.hash(self); - - for argument in &field_def.arguments { - self.hash_input_value_definition(argument)?; - } - - for argument in arguments { - self.hash_argument(argument); - } - - self.hash_type(&field_def.ty)?; - - for directive in &field_def.directives { - self.hash_directive(directive); - } - - self.hash_join_field(&parent_type, &field_def.directives)?; - } - Ok(()) - } - - fn hash_input_value_definition( - &mut self, - t: &Node, - ) -> Result<(), BoxError> { - self.hash_type(&t.ty)?; - for directive in &t.directives { - self.hash_directive(directive); - } - if let Some(value) = t.default_value.as_ref() { - self.hash_value(value); - } - Ok(()) - } - - fn hash_join_type(&mut self, name: &Name, directives: &DirectiveList) -> Result<(), BoxError> { - if let Some(dir_name) = self.join_type_directive_name.as_deref() { - if let Some(dir) = directives.get(dir_name) { - if let Some(key) = dir - .specified_argument_by_name("key") - .and_then(|arg| arg.as_str()) - { - let mut parser = Parser::new(); - if let Ok(field_set) = parser.parse_field_set( - Valid::assume_valid_ref(self.schema), - name.clone(), - key, - std::path::Path::new("schema.graphql"), - ) { - traverse::selection_set( - self, - name.as_str(), - &field_set.selection_set.selections[..], - )?; - } - } - } - } - - Ok(()) - } - - fn hash_join_field( - &mut self, - parent_type: &str, - directives: &ast::DirectiveList, - ) -> Result<(), BoxError> { - if let Some(dir_name) = self.join_field_directive_name.as_deref() { - if let Some(dir) = directives.get(dir_name) { - if let Some(requires) = dir - .specified_argument_by_name("requires") - .and_then(|arg| arg.as_str()) - { - if let Ok(parent_type) = Name::new(parent_type) { - let mut parser = Parser::new(); - - if let Ok(field_set) = parser.parse_field_set( - Valid::assume_valid_ref(self.schema), - parent_type.clone(), - requires, - std::path::Path::new("schema.graphql"), - ) { - traverse::selection_set( - self, - parent_type.as_str(), - &field_set.selection_set.selections[..], - )?; - } - } - } - } - } - - Ok(()) - } -} - -impl<'a> Hasher for QueryHashVisitor<'a> { - fn finish(&self) -> u64 { - unreachable!() - } - - fn write(&mut self, bytes: &[u8]) { - self.hasher.update(bytes); - } -} - -impl<'a> Visitor for QueryHashVisitor<'a> { - fn operation(&mut self, root_type: &str, node: &executable::Operation) -> Result<(), BoxError> { - root_type.hash(self); - self.hash_type_by_name(root_type)?; - - traverse::operation(self, root_type, node) - } - - fn field( - &mut self, - parent_type: &str, - field_def: &ast::FieldDefinition, - node: &executable::Field, - ) -> Result<(), BoxError> { - if !self.seen_introspection && (field_def.name == "__schema" || field_def.name == "__type") - { - self.seen_introspection = true; - self.schema_str.hash(self); - } - - self.hash_field( - parent_type.to_string(), - field_def.name.as_str().to_string(), - field_def, - &node.arguments, - )?; - - traverse::field(self, field_def, node) - } - - fn fragment(&mut self, node: &executable::Fragment) -> Result<(), BoxError> { - node.name.hash(self); - self.hash_type_by_name(node.type_condition())?; - - traverse::fragment(self, node) - } - - fn fragment_spread(&mut self, node: &executable::FragmentSpread) -> Result<(), BoxError> { - node.fragment_name.hash(self); - let type_condition = &self - .fragments - .get(&node.fragment_name) - .ok_or("MissingFragment")? - .type_condition(); - self.hash_type_by_name(type_condition)?; - - traverse::fragment_spread(self, node) - } - - fn inline_fragment( - &mut self, - parent_type: &str, - node: &executable::InlineFragment, - ) -> Result<(), BoxError> { - if let Some(type_condition) = &node.type_condition { - self.hash_type_by_name(type_condition)?; - } - traverse::inline_fragment(self, parent_type, node) - } - - fn schema(&self) -> &apollo_compiler::Schema { - self.schema - } -} - -#[cfg(test)] -mod tests { - use apollo_compiler::ast::Document; - use apollo_compiler::schema::Schema; - use apollo_compiler::validation::Valid; - - use super::QueryHashVisitor; - use crate::spec::query::traverse; - - #[derive(Debug)] - struct HashComparator { - from_visitor: String, - from_hash_query: String, - } - - impl From<(String, String)> for HashComparator { - fn from(value: (String, String)) -> Self { - Self { - from_visitor: value.0, - from_hash_query: value.1, - } - } - } - - // The non equality check is not the same - // as one would expect from PartialEq. - // This is why HashComparator doesn't implement it. - impl HashComparator { - fn equals(&self, other: &Self) -> bool { - self.from_visitor == other.from_visitor && self.from_hash_query == other.from_hash_query - } - fn doesnt_match(&self, other: &Self) -> bool { - // This is intentional, we check to prevent BOTH hashes from being equal - self.from_visitor != other.from_visitor && self.from_hash_query != other.from_hash_query - } - } - - #[track_caller] - fn hash(schema_str: &str, query: &str) -> HashComparator { - let schema = Schema::parse(schema_str, "schema.graphql") - .unwrap() - .validate() - .unwrap(); - let doc = Document::parse(query, "query.graphql").unwrap(); - - let exec = doc - .to_executable(&schema) - .unwrap() - .validate(&schema) - .unwrap(); - let mut visitor = QueryHashVisitor::new(&schema, schema_str, &exec); - traverse::document(&mut visitor, &exec, None).unwrap(); - - ( - hex::encode(visitor.finish()), - hex::encode(QueryHashVisitor::hash_query(&schema, schema_str, &exec, None).unwrap()), - ) - .into() - } - - #[track_caller] - fn hash_subgraph_query(schema_str: &str, query: &str) -> String { - let schema = Valid::assume_valid(Schema::parse(schema_str, "schema.graphql").unwrap()); - let doc = Document::parse(query, "query.graphql").unwrap(); - let exec = doc - .to_executable(&schema) - .unwrap() - .validate(&schema) - .unwrap(); - let mut visitor = QueryHashVisitor::new(&schema, schema_str, &exec); - traverse::document(&mut visitor, &exec, None).unwrap(); - - hex::encode(visitor.finish()) - } - - #[test] - fn me() { - let schema1: &str = r#" - schema { - query: Query - } - - type Query { - me: User - customer: User - } - - type User { - id: ID - name: String - } - "#; - - let schema2: &str = r#" - schema { - query: Query - } - - type Query { - me: User - } - - - type User { - id: ID! - name: String - } - "#; - let query = "query { me { name } }"; - assert!(hash(schema1, query).equals(&hash(schema2, query))); - - // id is nullable in 1, non nullable in 2 - let query = "query { me { id name } }"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - - // simple normalization - let query = "query { moi: me { name } }"; - assert!(hash(schema1, query).equals(&hash(schema2, query))); - - assert!(hash(schema1, "query { me { id name } }") - .doesnt_match(&hash(schema1, "query { me { name id } }"))); - } - - #[test] - fn directive() { - let schema1: &str = r#" - schema { - query: Query - } - directive @test on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM - - type Query { - me: User - customer: User - } - - type User { - id: ID! - name: String - } - "#; - - let schema2: &str = r#" - schema { - query: Query - } - directive @test on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM - - type Query { - me: User - customer: User @test - } - - - type User { - id: ID! @test - name: String - } - "#; - let query = "query { me { name } }"; - assert!(hash(schema1, query).equals(&hash(schema2, query))); - - let query = "query { me { id name } }"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - - let query = "query { customer { id } }"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - } - - #[test] - fn interface() { - let schema1: &str = r#" - schema { - query: Query - } - directive @test on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM - - type Query { - me: User - customer: I - } - - interface I { - id: ID - } - - type User implements I { - id: ID! - name: String - } - "#; - - let schema2: &str = r#" - schema { - query: Query - } - directive @test on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM - - type Query { - me: User - customer: I - } - - interface I @test { - id: ID - } - - type User implements I { - id: ID! - name: String - } - "#; - - let query = "query { me { id name } }"; - assert!(hash(schema1, query).equals(&hash(schema2, query))); - - let query = "query { customer { id } }"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - - let query = "query { customer { ... on User { name } } }"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - } - - #[test] - fn arguments() { - let schema1: &str = r#" - type Query { - a(i: Int): Int - b(i: Int = 1): Int - c(i: Int = 1, j: Int): Int - } - "#; - - let schema2: &str = r#" - type Query { - a(i: Int!): Int - b(i: Int = 2): Int - c(i: Int = 2, j: Int): Int - } - "#; - - let query = "query { a(i: 0) }"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - - let query = "query { b }"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - - let query = "query { b(i: 0)}"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - - let query = "query { c(j: 0)}"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - - let query = "query { c(i:0, j: 0)}"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - } - - #[test] - fn entities() { - let schema1: &str = r#" - schema { - query: Query - } - - scalar _Any - - union _Entity = User - - type Query { - _entities(representations: [_Any!]!): [_Entity]! - me: User - customer: User - } - - type User { - id: ID - name: String - } - "#; - - let schema2: &str = r#" - schema { - query: Query - } - - scalar _Any - - union _Entity = User - - type Query { - _entities(representations: [_Any!]!): [_Entity]! - me: User - } - - - type User { - id: ID! - name: String - } - "#; - - let query1 = r#"query Query1($representations:[_Any!]!){ - _entities(representations:$representations){ - ...on User { - id - name - } - } - }"#; - - println!("query1: {query1}"); - - let hash1 = hash_subgraph_query(schema1, query1); - println!("hash1: {hash1}"); - - let hash2 = hash_subgraph_query(schema2, query1); - println!("hash2: {hash2}"); - - assert_ne!(hash1, hash2); - - let query2 = r#"query Query1($representations:[_Any!]!){ - _entities(representations:$representations){ - ...on User { - name - } - } - }"#; - - println!("query2: {query2}"); - - let hash1 = hash_subgraph_query(schema1, query2); - println!("hash1: {hash1}"); - - let hash2 = hash_subgraph_query(schema2, query2); - println!("hash2: {hash2}"); - - assert_eq!(hash1, hash2); - } - - #[test] - fn join_type_key() { - let schema1: &str = r#" - schema - @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { - query: Query - } - directive @test on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM - directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on OBJECT | INTERFACE - directive @join__graph(name: String!, url: String!) on ENUM_VALUE - directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA - - scalar join__FieldSet - - scalar link__Import - - enum link__Purpose { - """ - `SECURITY` features provide metadata necessary to securely resolve fields. - """ - SECURITY - - """ - `EXECUTION` features provide metadata necessary for operation execution. - """ - EXECUTION - } - - enum join__Graph { - ACCOUNTS @join__graph(name: "accounts", url: "https://accounts.demo.starstuff.dev") - INVENTORY @join__graph(name: "inventory", url: "https://inventory.demo.starstuff.dev") - PRODUCTS @join__graph(name: "products", url: "https://products.demo.starstuff.dev") - REVIEWS @join__graph(name: "reviews", url: "https://reviews.demo.starstuff.dev") - } - - type Query { - me: User - customer: User - itf: I - } - - type User @join__type(graph: ACCOUNTS, key: "id") { - id: ID! - name: String - } - - interface I @join__type(graph: ACCOUNTS, key: "id") { - id: ID! - name :String - } - - union U = User - "#; - - let schema2: &str = r#" - schema - @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { - query: Query - } - directive @test on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM - directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on OBJECT | INTERFACE - directive @join__graph(name: String!, url: String!) on ENUM_VALUE - directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA - - scalar join__FieldSet - - scalar link__Import - - enum link__Purpose { - """ - `SECURITY` features provide metadata necessary to securely resolve fields. - """ - SECURITY - - """ - `EXECUTION` features provide metadata necessary for operation execution. - """ - EXECUTION - } - - enum join__Graph { - ACCOUNTS @join__graph(name: "accounts", url: "https://accounts.demo.starstuff.dev") - INVENTORY @join__graph(name: "inventory", url: "https://inventory.demo.starstuff.dev") - PRODUCTS @join__graph(name: "products", url: "https://products.demo.starstuff.dev") - REVIEWS @join__graph(name: "reviews", url: "https://reviews.demo.starstuff.dev") - } - - type Query { - me: User - customer: User @test - itf: I - } - - type User @join__type(graph: ACCOUNTS, key: "id") { - id: ID! @test - name: String - } - - interface I @join__type(graph: ACCOUNTS, key: "id") { - id: ID! @test - name :String - } - "#; - let query = "query { me { name } }"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - - let query = "query { itf { name } }"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - } - - #[test] - fn join_field_requires() { - let schema1: &str = r#" - schema - @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { - query: Query - } - directive @test on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM - directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on OBJECT | INTERFACE - directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION - directive @join__graph(name: String!, url: String!) on ENUM_VALUE - directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA - - scalar join__FieldSet - - scalar link__Import - - enum link__Purpose { - """ - `SECURITY` features provide metadata necessary to securely resolve fields. - """ - SECURITY - - """ - `EXECUTION` features provide metadata necessary for operation execution. - """ - EXECUTION - } - - enum join__Graph { - ACCOUNTS @join__graph(name: "accounts", url: "https://accounts.demo.starstuff.dev") - INVENTORY @join__graph(name: "inventory", url: "https://inventory.demo.starstuff.dev") - PRODUCTS @join__graph(name: "products", url: "https://products.demo.starstuff.dev") - REVIEWS @join__graph(name: "reviews", url: "https://reviews.demo.starstuff.dev") - } - - type Query { - me: User - customer: User - itf: I - } - - type User @join__type(graph: ACCOUNTS, key: "id") { - id: ID! - name: String - username: String @join__field(graph:ACCOUNTS, requires: "name") - a: String @join__field(graph:ACCOUNTS, requires: "itf { ... on A { name } }") - itf: I - } - - interface I @join__type(graph: ACCOUNTS, key: "id") { - id: ID! - name: String - } - - type A implements I @join__type(graph: ACCOUNTS, key: "id") { - id: ID! - name: String - } - "#; - - let schema2: &str = r#" - schema - @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { - query: Query - } - directive @test on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM - directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on OBJECT | INTERFACE - directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION - directive @join__graph(name: String!, url: String!) on ENUM_VALUE - directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA - - scalar join__FieldSet - - scalar link__Import - - enum link__Purpose { - """ - `SECURITY` features provide metadata necessary to securely resolve fields. - """ - SECURITY - - """ - `EXECUTION` features provide metadata necessary for operation execution. - """ - EXECUTION - } - - enum join__Graph { - ACCOUNTS @join__graph(name: "accounts", url: "https://accounts.demo.starstuff.dev") - INVENTORY @join__graph(name: "inventory", url: "https://inventory.demo.starstuff.dev") - PRODUCTS @join__graph(name: "products", url: "https://products.demo.starstuff.dev") - REVIEWS @join__graph(name: "reviews", url: "https://reviews.demo.starstuff.dev") - } - - type Query { - me: User - customer: User @test - itf: I - } - - type User @join__type(graph: ACCOUNTS, key: "id") { - id: ID! - name: String @test - username: String @join__field(graph:ACCOUNTS, requires: "name") - a: String @join__field(graph:ACCOUNTS, requires: "itf { ... on A { name } }") - itf: I - } - - interface I @join__type(graph: ACCOUNTS, key: "id") { - id: ID! - name: String @test - } - - type A implements I @join__type(graph: ACCOUNTS, key: "id") { - id: ID! - name: String @test - } - "#; - let query = "query { me { username } }"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - - let query = "query { me { a } }"; - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - } - - #[test] - fn introspection() { - let schema1: &str = r#" - schema { - query: Query - } - - type Query { - me: User - customer: User - } - - type User { - id: ID - name: String - } - "#; - - let schema2: &str = r#" - schema { - query: Query - } - - type Query { - me: NotUser - } - - - type NotUser { - id: ID! - name: String - } - "#; - - let query = "{ __schema { types { name } } }"; - - assert!(hash(schema1, query).doesnt_match(&hash(schema2, query))); - } - - #[test] - fn fields_with_different_arguments_have_different_hashes() { - let schema: &str = r#" - schema { - query: Query - } - - type Query { - test(arg: Int): String - } - "#; - - let query_one = "query { a: test(arg: 1) b: test(arg: 2) }"; - let query_two = "query { a: test(arg: 1) b: test(arg: 3) }"; - - // This assertion tests an internal hash function that isn't directly - // used for the query hash, and we'll need to make it pass to rely - // solely on the internal function again. - // - // assert!(hash(schema, query_one).doesnt_match(&hash(schema, - // query_two))); - assert!(hash(schema, query_one).from_hash_query != hash(schema, query_two).from_hash_query); - } - - #[test] - fn fields_with_different_aliases_have_different_hashes() { - let schema: &str = r#" - schema { - query: Query - } - - type Query { - test(arg: Int): String - } - "#; - - let query_one = "query { a: test }"; - let query_two = "query { b: test }"; - - // This assertion tests an internal hash function that isn't directly - // used for the query hash, and we'll need to make it pass to rely - // solely on the internal function again. - // - // assert!(hash(schema, query_one).doesnt_match(&hash(schema, query_two))); - assert!(hash(schema, query_one).from_hash_query != hash(schema, query_two).from_hash_query); - } -} diff --git a/apollo-router/src/spec/query/subselections.rs b/apollo-router/src/spec/query/subselections.rs index 2ec203144e..b648975724 100644 --- a/apollo-router/src/spec/query/subselections.rs +++ b/apollo-router/src/spec/query/subselections.rs @@ -1,19 +1,19 @@ use std::collections::HashMap; -use serde::de::Visitor; use serde::Deserialize; use serde::Serialize; +use serde::de::Visitor; use serde_json_bytes::ByteString; use super::DeferStats; use super::Operation; -use crate::spec::selection::Selection; +use crate::Configuration; use crate::spec::Condition; use crate::spec::FieldType; use crate::spec::Fragment; use crate::spec::IncludeSkip; use crate::spec::SpecError; -use crate::Configuration; +use crate::spec::selection::Selection; #[derive(Debug, PartialEq, Hash, Eq)] pub(crate) struct SubSelectionKey { @@ -47,7 +47,7 @@ impl<'de> Deserialize<'de> for SubSelectionKey { } struct SubSelectionKeyVisitor; -impl<'de> Visitor<'de> for SubSelectionKeyVisitor { +impl Visitor<'_> for SubSelectionKeyVisitor { type Value = SubSelectionKey; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { @@ -150,7 +150,7 @@ pub(crate) fn collect_subselections( /// Returns an iterator of functions, one per combination of boolean values of the given variables. /// The function return whether a given variable (by its name) is true in that combination. -fn variable_combinations(defer_stats: &DeferStats) -> impl Iterator { +fn variable_combinations(defer_stats: &DeferStats) -> impl Iterator + use<> { // `N = variables.len()` boolean values have a total of 2^N combinations. // If we enumerate them by counting from 0 to 2^N - 1, // interpreting the N bits of the binary representation of the counter diff --git a/apollo-router/src/spec/query/tests.rs b/apollo-router/src/spec/query/tests.rs index f9af994874..a1739f516e 100644 --- a/apollo-router/src/spec/query/tests.rs +++ b/apollo-router/src/spec/query/tests.rs @@ -40,7 +40,6 @@ macro_rules! assert_eq_and_ordered_json { } #[derive(Default)] -#[must_use = "Must call .test() to run the test"] struct FormatTest { schema: Option<&'static str>, query_type_name: Option<&'static str>, @@ -101,11 +100,6 @@ impl FormatTest { self } - fn expected_errors(mut self, v: serde_json_bytes::Value) -> Self { - self.expected_errors = Some(v); - self - } - fn expected_extensions(mut self, v: serde_json_bytes::Value) -> Self { self.expected_extensions = Some(v); self @@ -1188,452 +1182,6 @@ fn reformat_response_array_of_id_duplicate() { .test(); } -#[test] -// If this test fails, this means you got greedy about allocations, -// beware of aliases! -fn reformat_response_expected_types() { - FormatTest::builder() - .schema( - "type Query { - get: Thing - } - type Thing { - i: Int - s: String - f: Float - b: Boolean - e: E - u: U - id: ID - l: [Int] - } - - enum E { - A - B - } - union U = ObjA | ObjB - type ObjA { - a: String - } - type ObjB { - a: String - } - ", - ) - .query( - r#"{ - get { - i - s - f - ... on Thing { - b - e - u { - ... on ObjA { - a - } - } - id - } - l - } - }"#, - ) - .response(json! {{ - "get": { - "i": "hello", - "s": 1.0, - "f": [1], - "b": 0, - "e": "X", - "u": 1, - "id": { - "test": "test", - }, - "l": "A" - }, - }}) - .expected(json! {{ - "get": { - "i": null, - "s": null, - "f": null, - "b": null, - "e": null, - "u": null, - // FIXME(@goto-bus-stop): this should be null, but we do not - // validate ID values today - "id": { - "test": "test", - }, - "l": null - }, - }}) - .expected_errors(json! ([ - { - "message": "Invalid value found for field Thing.i", - "path": ["get", "i"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Thing.s", - "path": ["get", "s"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Thing.f", - "path": ["get", "f"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Thing.b", - "path": ["get", "b"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Expected a valid enum value for type E", - "path": ["get", "e"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid non-object value of type number for composite type U", - "path": ["get", "u"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid non-list value of type string for list type [Int]", - "path": ["get", "l"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - } - ])) - .test(); -} - -#[test] -fn reformat_response_expected_int() { - FormatTest::builder() - .schema( - r#" - type Query { - a: Int - b: Int - c: Int - d: Int - e: Int - f: Int - g: Int - } - "#, - ) - .query(r#"{ a b c d e f g }"#) - .response(json!({ - "a": 1, - "b": 1.0, // Should be accepted as Int 1 - "c": 1.2, // Float should not be truncated - "d": "1234", // Optional to be coerced by spec: we do not do so - "e": true, - "f": [1], - "g": { "value": 1 }, - })) - .expected(json!({ - "a": 1, - // FIXME(@goto-bus-stop): we should accept this, and truncate it - // to Int value `1`, but do not do so today - "b": null, - "c": null, - "d": null, - "e": null, - "f": null, - "g": null, - })) - .expected_errors(json!([ - { - "message": "Invalid value found for field Query.b", - "path": ["b"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Query.c", - "path": ["c"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Query.d", - "path": ["d"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Query.e", - "path": ["e"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Query.f", - "path": ["f"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Query.g", - "path": ["g"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - ])) - .test(); -} - -#[test] -fn reformat_response_expected_int_range() { - let schema = "type Query { - me: User - } - - type User { - id: String! - name: String - someNumber: Int - someOtherNumber: Int! - } - "; - - let query = "query { me { id name someNumber } }"; - - FormatTest::builder() - .schema(schema) - .query(query) - .response(json!({ - "me": { - "id": "123", - "name": "Guy Guyson", - "someNumber": 51049694213_i64 - }, - })) - .expected(json!({ - "me": { - "id": "123", - "name": "Guy Guyson", - "someNumber": null, - }, - })) - .expected_errors(json!([ - { - "message": "Invalid value found for field User.someNumber", - "path": ["me", "someNumber"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" }, - } - ])) - .test(); - - let query2 = "query { me { id name someOtherNumber } }"; - - FormatTest::builder() - .schema(schema) - .query(query2) - .response(json!({ - "me": { - "id": "123", - "name": "Guy Guyson", - "someOtherNumber": 51049694213_i64 - }, - })) - .expected(json!({ - "me": null, - })) - .expected_errors(json!([ - { - "message": "Invalid value found for field User.someOtherNumber", - "path": ["me", "someOtherNumber"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" }, - }, - ])) - .test(); -} - -#[test] -fn reformat_response_expected_float() { - FormatTest::builder() - .schema( - r#" - type Query { - a: Float - b: Float - c: Float - d: Float - e: Float - f: Float - } - "#, - ) - .query(r#"{ a b c d e f }"#) - .response(json!({ - // Note: NaNs and Infinitys are not supported by GraphQL Floats, - // and handily not representable in JSON, so we don't need to handle them. - "a": 1, // Int can be interpreted as Float - "b": 1.2, - "c": "2.2", // Optional to be coerced by spec: we do not do so - "d": true, - "e": [1.234], - "f": { "value": 12.34 }, - })) - .expected(json!({ - "a": 1, // Representing int-valued float without the decimals is okay in JSON - "b": 1.2, - "c": null, - "d": null, - "e": null, - "f": null, - })) - .expected_errors(json!([ - { - "message": "Invalid value found for field Query.c", - "path": ["c"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Query.d", - "path": ["d"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Query.e", - "path": ["e"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Query.f", - "path": ["f"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - ])) - .test(); -} - -#[test] -fn reformat_response_expected_string() { - FormatTest::builder() - .schema( - r#" - type Query { - a: String - b: String - c: String - d: String - e: String - f: String - } - "#, - ) - .query(r#"{ a b c d e f }"#) - .response(json!({ - "a": "text", - "b": 1, // Optional to be coerced by spec: we do not do so - "c": false, // Optional to be coerced by spec: we do not do so - "d": 1234.5678, // Optional to be coerced by spec: we do not do so - "e": ["s"], - "f": { "text": "text" }, - })) - .expected(json!({ - "a": "text", - "b": null, - "c": null, - "d": null, - "e": null, - "f": null, - })) - .expected_errors(json!([ - { - "message": "Invalid value found for field Query.b", - "path": ["b"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Query.c", - "path": ["c"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Query.d", - "path": ["d"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Query.e", - "path": ["e"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - { - "message": "Invalid value found for field Query.f", - "path": ["f"], - "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - }, - ])) - .test(); -} - -#[test] -fn reformat_response_expected_id() { - FormatTest::builder() - .schema( - r#" - type Query { - a: ID - b: ID - c: ID - d: ID - e: ID - f: ID - g: ID - } - "#, - ) - .query(r#"{ a b c d e f g }"#) - .response(json!({ - "a": "1234", - "b": "ABCD", - "c": 1234, - "d": 1234.0, // Integer represented as a float should be coerced - "e": false, - "f": 1234.5678, // Float should not be truncated - "g": ["s"], - })) - .expected(json!({ - // Note technically IDs should always be represented as a String in JSON, - // though the value returned from a field can be either Int or String. - // We do not coerce the acceptable types to strings today. - "a": "1234", - "b": "ABCD", - "c": 1234, - // FIXME(@goto-bus-stop): We should coerce this to string "1234" (without .0), - // but we don't do so today - "d": 1234.0, - // FIXME(@goto-bus-stop): We should null out all these values, - // but we don't validate IDs today - "e": false, - "f": 1234.5678, - "g": ["s"], - })) - .expected_errors(json!([ - // FIXME(@goto-bus-stop): we should expect these errors: - // { - // "message": "Invalid value found for field Query.e", - // "path": ["e"], - // "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - // }, - // { - // "message": "Invalid value found for field Query.f", - // "path": ["f"], - // "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - // }, - // { - // "message": "Invalid value found for field Query.g", - // "path": ["g"], - // "extensions": { "code": "RESPONSE_VALIDATION_FAILED" } - // }, - ])) - .test(); -} - #[test] fn solve_query_with_single_typename() { FormatTest::builder() @@ -2281,7 +1829,7 @@ fn variable_validation() { }}", json!({"input":{}}) ); - assert!(res.is_ok(), "validation should have succeeded: {:?}", res); + assert!(res.is_ok(), "validation should have succeeded: {res:?}"); } #[test] @@ -6218,7 +5766,7 @@ fn filtered_defer_fragment() { .unwrap(); let doc = ast.to_executable(schema.supergraph_schema()).unwrap(); let (fragments, operation, defer_stats, schema_aware_hash) = - Query::extract_query_information(&schema, &doc, None).unwrap(); + Query::extract_query_information(&schema, filtered_query, &doc, None).unwrap(); let subselections = crate::spec::query::subselections::collect_subselections( &config, @@ -6244,7 +5792,7 @@ fn filtered_defer_fragment() { .unwrap(); let doc = ast.to_executable(schema.supergraph_schema()).unwrap(); let (fragments, operation, defer_stats, schema_aware_hash) = - Query::extract_query_information(&schema, &doc, None).unwrap(); + Query::extract_query_information(&schema, filtered_query, &doc, None).unwrap(); let subselections = crate::spec::query::subselections::collect_subselections( &config, diff --git a/apollo-router/src/spec/query/transform.rs b/apollo-router/src/spec/query/transform.rs index 8b31d6054b..dfbb4d0401 100644 --- a/apollo-router/src/spec/query/transform.rs +++ b/apollo-router/src/spec/query/transform.rs @@ -108,10 +108,9 @@ pub(crate) fn document( used_fragments.extend(local_used_fragments); // remove unused variables - new_def.variables.retain(|var| { - let res = visitor.state().used_variables.contains(var.name.as_str()); - res - }); + new_def + .variables + .retain(|var| visitor.state().used_variables.contains(var.name.as_str())); new.definitions .push(ast::Definition::OperationDefinition(new_def.into())); @@ -218,7 +217,7 @@ pub(crate) trait Visitor: Sized { def: &ast::FragmentSpread, ) -> Result, BoxError> { let res = fragment_spread(self, def); - if let Ok(Some(ref fragment)) = res.as_ref() { + if let Ok(Some(fragment)) = res.as_ref() { self.state() .used_fragments .insert(fragment.fragment_name.as_str().to_string()); @@ -569,13 +568,10 @@ mod tests { def: &ast::Field, ) -> Result, BoxError> { Ok(field(self, field_def, def)?.map(|mut new| { - new.directives.push( - ast::Directive { - name: apollo_compiler::name!("added"), - arguments: Vec::new(), - } - .into(), - ); + new.directives.push(ast::Directive { + name: apollo_compiler::name!("added"), + arguments: Vec::new(), + }); new })) } @@ -707,7 +703,7 @@ fragment F on Query { result: ast::Document, } - impl<'a> std::fmt::Display for TestResult<'a> { + impl std::fmt::Display for TestResult<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "query:\n{}\nfiltered:\n{}", self.query, self.result,) } diff --git a/apollo-router/src/spec/query/traverse.rs b/apollo-router/src/spec/query/traverse.rs index 295ba4a393..a022f2d9fa 100644 --- a/apollo-router/src/spec/query/traverse.rs +++ b/apollo-router/src/spec/query/traverse.rs @@ -1,7 +1,7 @@ +use apollo_compiler::ExecutableDocument; use apollo_compiler::ast; use apollo_compiler::executable; use apollo_compiler::schema::FieldLookupError; -use apollo_compiler::ExecutableDocument; use tower::BoxError; /// Traverse a document with the given visitor. diff --git a/apollo-router/src/spec/schema.rs b/apollo-router/src/spec/schema.rs index 2208a5863e..3c0a173024 100644 --- a/apollo-router/src/spec/schema.rs +++ b/apollo-router/src/spec/schema.rs @@ -1,26 +1,35 @@ //! GraphQL schema. use std::collections::HashMap; +use std::fmt::Display; use std::str::FromStr; use std::sync::Arc; use std::time::Instant; +use apollo_compiler::Name; use apollo_compiler::schema::Implementers; use apollo_compiler::validation::Valid; -use apollo_compiler::Name; -use apollo_federation::schema::ValidFederationSchema; use apollo_federation::ApiSchemaOptions; use apollo_federation::Supergraph; +use apollo_federation::connectors::expand::Connectors; +use apollo_federation::connectors::expand::ExpansionResult; +use apollo_federation::connectors::expand::expand_connectors; +use apollo_federation::router_supported_supergraph_specs; +use apollo_federation::schema::ValidFederationSchema; use http::Uri; use semver::Version; use semver::VersionReq; +use serde::Deserialize; +use serde::Serialize; use sha2::Digest; use sha2::Sha256; +use crate::Configuration; use crate::error::ParseErrors; use crate::error::SchemaError; +use crate::plugins::connectors::configuration::apply_config; use crate::query_planner::OperationKind; -use crate::Configuration; +use crate::uplink::schema::SchemaState; /// A GraphQL schema. pub(crate) struct Schema { @@ -29,7 +38,9 @@ pub(crate) struct Schema { subgraphs: HashMap, pub(crate) implementers_map: apollo_compiler::collections::HashMap, api_schema: ApiSchema, - pub(crate) schema_id: Arc, + pub(crate) schema_id: SchemaHash, + pub(crate) connectors: Option, + pub(crate) launch_id: Option>, } /// Wrapper type to distinguish from `Schema::definitions` for the supergraph schema @@ -38,16 +49,42 @@ pub(crate) struct ApiSchema(pub(crate) ValidFederationSchema); impl Schema { pub(crate) fn parse(raw_sdl: &str, config: &Configuration) -> Result { - Self::parse_arc(raw_sdl.to_owned().into(), config) + Self::parse_arc(raw_sdl.parse::().unwrap().into(), config) } pub(crate) fn parse_arc( - raw_sdl: Arc, + raw_sdl: Arc, config: &Configuration, ) -> Result { let start = Instant::now(); + + let api_schema_options = ApiSchemaOptions { + include_defer: config.supergraph.defer_support, + ..Default::default() + }; + + let expansion = + expand_connectors(&raw_sdl.sdl, &api_schema_options).map_err(SchemaError::Connector)?; + let preserved_launch_id = raw_sdl.launch_id.clone(); + let (raw_sdl, api_schema, connectors) = match expansion { + ExpansionResult::Expanded { + raw_sdl, + api_schema: api, + connectors, + } => ( + Arc::new(SchemaState { + sdl: raw_sdl, + launch_id: preserved_launch_id, + }), + Some(ValidFederationSchema::new(*api).map_err(SchemaError::Connector)?), + Some(apply_config(config, connectors)), + ), + ExpansionResult::Unchanged => (raw_sdl, None, None), + }; + let mut parser = apollo_compiler::parser::Parser::new(); - let result = parser.parse_ast(raw_sdl.as_ref(), "schema.graphql"); + + let result = parser.parse_ast(&raw_sdl.sdl, "schema.graphql"); // Trace log recursion limit data let recursion_limit = parser.recursion_reached(); @@ -65,7 +102,7 @@ impl Schema { let mut subgraphs = HashMap::new(); // TODO: error if not found? if let Some(join_enum) = definitions.get_enum("join__Graph") { - for (name, url) in join_enum.values.iter().filter_map(|(_name, value)| { + for (name, url) in join_enum.values.values().filter_map(|value| { let join_directive = value.directives.get("join__graph")?; let name = join_directive .specified_argument_by_name("name")? @@ -73,25 +110,34 @@ impl Schema { let url = join_directive.specified_argument_by_name("url")?.as_str()?; Some((name, url)) }) { - if url.is_empty() { - return Err(SchemaError::MissingSubgraphUrl(name.to_string())); - } - #[cfg(unix)] - // there is no standard for unix socket URLs apparently - let url = if let Some(path) = url.strip_prefix("unix://") { - // there is no specified format for unix socket URLs (cf https://github.com/whatwg/url/issues/577) - // so a unix:// URL will not be parsed by http::Uri - // To fix that, hyperlocal came up with its own Uri type that can be converted to http::Uri. - // It hides the socket path in a hex encoded authority that the unix socket connector will - // know how to decode - hyperlocal::Uri::new(path, "/").into() + let is_connector = connectors + .as_ref() + .map(|connectors| connectors.by_service_name.contains_key(name)) + .unwrap_or_default(); + + let url = if is_connector { + Uri::from_static("http://unused") } else { + if url.is_empty() { + return Err(SchemaError::MissingSubgraphUrl(name.to_string())); + } + #[cfg(unix)] + // there is no standard for unix socket URLs apparently + if let Some(path) = url.strip_prefix("unix://") { + // there is no specified format for unix socket URLs (cf https://github.com/whatwg/url/issues/577) + // so a unix:// URL will not be parsed by http::Uri + // To fix that, hyperlocal came up with its own Uri type that can be converted to http::Uri. + // It hides the socket path in a hex encoded authority that the unix socket connector will + // know how to decode + hyperlocal::Uri::new(path, "/").into() + } else { + Uri::from_str(url) + .map_err(|err| SchemaError::UrlParse(name.to_string(), err))? + } + #[cfg(not(unix))] Uri::from_str(url) .map_err(|err| SchemaError::UrlParse(name.to_string(), err))? }; - #[cfg(not(unix))] - let url = Uri::from_str(url) - .map_err(|err| SchemaError::UrlParse(name.to_string(), err))?; if subgraphs.insert(name.to_string(), url).is_some() { return Err(SchemaError::Api(format!( @@ -103,33 +149,37 @@ impl Schema { f64_histogram!( "apollo.router.schema.load.duration", - "Time spent loading the supergraph schema.", + "Time spent loading the supergraph schema, in seconds.", start.elapsed().as_secs_f64() ); let implementers_map = definitions.implementers_map(); - let supergraph = Supergraph::from_schema(definitions)?; + let supergraph = + Supergraph::from_schema(definitions, Some(&router_supported_supergraph_specs()))?; - let schema_id = Arc::new(Schema::schema_id(&raw_sdl)); + let schema_id = Schema::schema_id(&raw_sdl.sdl); - let api_schema = supergraph - .to_api_schema(ApiSchemaOptions { - include_defer: config.supergraph.defer_support, - ..Default::default() - }) - .map_err(|e| { + let api_schema = api_schema.map(Ok).unwrap_or_else(|| { + supergraph.to_api_schema(api_schema_options).map_err(|e| { SchemaError::Api(format!( "The supergraph schema failed to produce a valid API schema: {e}" )) - })?; + }) + })?; Ok(Schema { - raw_sdl, + launch_id: raw_sdl + .launch_id + .as_ref() + .map(ToString::to_string) + .map(Arc::new), + raw_sdl: Arc::new(raw_sdl.sdl.to_string()), supergraph, subgraphs, implementers_map, api_schema: ApiSchema(api_schema), schema_id, + connectors, }) } @@ -141,10 +191,9 @@ impl Schema { self.supergraph.schema.schema() } - pub(crate) fn schema_id(sdl: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(sdl.as_bytes()); - format!("{:x}", hasher.finalize()) + /// Compute the Schema ID for an SDL string. + pub(crate) fn schema_id(sdl: &str) -> SchemaHash { + SchemaHash::new(sdl) } /// Extracts a string containing the entire [`Schema`]. @@ -268,7 +317,7 @@ impl Schema { }; let Some(version_in_url) = - Version::parse(format!("{}.0", version_in_link).as_str()).ok() + Version::parse(format!("{version_in_link}.0").as_str()).ok() else { return false; }; @@ -306,7 +355,7 @@ impl Schema { }; let Some(version_in_url) = - Version::parse(format!("{}.0", version_in_link).as_str()).ok() + Version::parse(format!("{version_in_link}.0").as_str()).ok() else { return false; }; @@ -337,6 +386,8 @@ impl std::fmt::Debug for Schema { implementers_map, api_schema: _, // skip schema_id: _, + connectors: _, + launch_id: _, // skip } = self; f.debug_struct("Schema") .field("raw_sdl", raw_sdl) @@ -354,6 +405,118 @@ impl std::ops::Deref for ApiSchema { } } +/// A schema ID is the sha256 hash of the schema text. +/// +/// That means that differences in whitespace and comments affect the hash, not only semantic +/// differences in the schema. +#[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize, Serialize)] +pub(crate) struct SchemaHash( + /// The internal representation is a pointer to a string. + /// This is not ideal, it might be better eg. to just have a fixed-size byte array that can be + /// turned into a string as needed. + /// But `Arc` is used in the public plugin interface and other places, so this is + /// essentially a backwards compatibility decision. + Arc, +); +impl SchemaHash { + pub(crate) fn new(sdl: &str) -> Self { + let mut hasher = Sha256::new(); + hasher.update(sdl); + let hash = format!("{:x}", hasher.finalize()); + Self(Arc::new(hash)) + } + + /// Return the underlying data. + pub(crate) fn into_inner(self) -> Arc { + self.0 + } + + /// Return the hash as a hexadecimal string slice. + pub(crate) fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Compute the hash for an executable document and operation name against this schema. + /// + /// See [QueryHash] for details of what's included. + pub(crate) fn operation_hash( + &self, + query_text: &str, + operation_name: Option<&str>, + ) -> QueryHash { + QueryHash::new(self, query_text, operation_name) + } +} + +impl Display for SchemaHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.as_str()) + } +} + +/// A query hash is a unique hash for an operation from an executable document against a particular +/// schema. +/// +/// For a document with two queries A and B, queries A and B will result in a different hash even +/// if the document text is identical. +/// If query A is then executed against two different versions of the schema, the hash will be +/// different again, depending on the [SchemaHash]. +/// +/// A query hash can be obtained from a schema ID using [SchemaHash::operation_hash]. +// FIXME: rename to OperationHash since it include operation name? +#[derive(Clone, Hash, PartialEq, Eq, Deserialize, Serialize)] +pub(crate) struct QueryHash( + /// Unlike SchemaHash, the query hash has no backwards compatibility motivations for the internal + /// type, as it's fully private. We could consider making this a fixed-size byte array rather + /// than a Vec, but it shouldn't make a huge difference. + #[serde(with = "hex")] + Vec, +); + +impl QueryHash { + /// This constructor is not public, see [SchemaHash::operation_hash] instead. + fn new(schema_id: &SchemaHash, query_text: &str, operation_name: Option<&str>) -> Self { + let mut hasher = Sha256::new(); + hasher.update(schema_id.as_str()); + // byte separator between each part that is hashed + hasher.update(&[0xFF][..]); + hasher.update(query_text); + hasher.update(&[0xFF][..]); + hasher.update(operation_name.unwrap_or("-")); + Self(hasher.finalize().as_slice().into()) + } + + /// Return the hash as a byte slice. + pub(crate) fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +impl std::fmt::Debug for QueryHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("QueryHash") + .field(&hex::encode(&self.0)) + .finish() + } +} + +impl Display for QueryHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", hex::encode(&self.0)) + } +} + +// FIXME: It seems bad that you can create an empty hash easily and use it in security-critical +// places. This impl should be deleted outright and we should update usage sites. +// If the query hash is truly not required to contain data in those usage sites, we should use +// something like an Option instead. +#[allow(clippy::derivable_impls)] // need a place to add that comment ;) +impl Default for QueryHash { + fn default() -> Self { + Self(Default::default()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -406,7 +569,7 @@ mod tests { type Baz { me: String } - + union UnionType2 = Foo | Bar "#, ); @@ -559,7 +722,7 @@ mod tests { let schema = Schema::parse(schema, &Default::default()).unwrap(); assert_eq!( - Schema::schema_id(&schema.raw_sdl), + Schema::schema_id(&schema.raw_sdl).as_str(), "23bcf0ea13a4e0429c942bba59573ba70b8d6970d73ad00c5230d08788bb1ba2".to_string() ); } diff --git a/apollo-router/src/spec/selection.rs b/apollo-router/src/spec/selection.rs index 4e093ea4fb..518f94a306 100644 --- a/apollo-router/src/spec/selection.rs +++ b/apollo-router/src/spec/selection.rs @@ -6,11 +6,11 @@ use serde_json_bytes::ByteString; use super::Fragments; use crate::json_ext::Object; use crate::json_ext::PathElement; -use crate::spec::query::subselections::DEFER_DIRECTIVE_NAME; -use crate::spec::query::DeferStats; use crate::spec::FieldType; use crate::spec::Schema; use crate::spec::SpecError; +use crate::spec::query::DeferStats; +use crate::spec::query::subselections::DEFER_DIRECTIVE_NAME; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub(crate) enum Selection { @@ -189,11 +189,15 @@ impl Selection { }) } - pub(crate) fn contains_error_path(&self, path: &[PathElement], fragments: &Fragments) -> bool { - match (path.first(), self) { - (None, _) => true, + pub(crate) fn matching_error_path_length( + &self, + path: &[PathElement], + fragments: &Fragments, + ) -> usize { + match (path.split_first(), self) { + (None, _) => 0, ( - Some(PathElement::Key(key, _)), + Some((PathElement::Key(key, _), rest)), Selection::Field { name, alias, @@ -204,17 +208,23 @@ impl Selection { if alias.as_ref().unwrap_or(name).as_str() == key.as_str() { match selection_set { // if we don't select after that field, the path should stop there - None => path.len() == 1, - Some(set) => set - .iter() - .any(|selection| selection.contains_error_path(&path[1..], fragments)), + None => 1, + Some(set) => { + set.iter() + .map(|selection| { + selection.matching_error_path_length(rest, fragments) + }) + .max() + .unwrap_or(0) + + 1 + } } } else { - false + 0 } } ( - Some(PathElement::Fragment(fragment)), + Some((PathElement::Fragment(fragment), rest)), Selection::InlineFragment { type_condition, selection_set, @@ -224,42 +234,54 @@ impl Selection { if fragment.as_str() == type_condition.as_str() { selection_set .iter() - .any(|selection| selection.contains_error_path(&path[1..], fragments)) + .map(|selection| selection.matching_error_path_length(rest, fragments)) + .max() + .unwrap_or(0) + + 1 } else { - false + 0 } } - (Some(PathElement::Fragment(fragment)), Self::FragmentSpread { name, .. }) => { + (Some((PathElement::Fragment(fragment), rest)), Self::FragmentSpread { name, .. }) => { if let Some(f) = fragments.get(name) { if fragment.as_str() == f.type_condition.as_str() { f.selection_set .iter() - .any(|selection| selection.contains_error_path(&path[1..], fragments)) + .map(|selection| selection.matching_error_path_length(rest, fragments)) + .max() + .unwrap_or(0) + + 1 } else { - false + 0 } } else { - false + 0 } } (_, Self::FragmentSpread { name, .. }) => { if let Some(f) = fragments.get(name) { f.selection_set .iter() - .any(|selection| selection.contains_error_path(path, fragments)) + .map(|selection| selection.matching_error_path_length(path, fragments)) + .max() + .unwrap_or(0) } else { - false + 0 } } - (Some(PathElement::Index(_)), _) | (Some(PathElement::Flatten(_)), _) => { - self.contains_error_path(&path[1..], fragments) - } - (Some(PathElement::Key(_, _)), Selection::InlineFragment { selection_set, .. }) => { - selection_set - .iter() - .any(|selection| selection.contains_error_path(path, fragments)) + (Some((PathElement::Index(_), rest)), _) + | (Some((PathElement::Flatten(_), rest)), _) => { + self.matching_error_path_length(rest, fragments) + 1 } - (Some(PathElement::Fragment(_)), Selection::Field { .. }) => false, + ( + Some((PathElement::Key(_, _), _)), + Selection::InlineFragment { selection_set, .. }, + ) => selection_set + .iter() + .map(|selection| selection.matching_error_path_length(path, fragments)) + .max() + .unwrap_or(0), + (Some((PathElement::Fragment(_), _)), Selection::Field { .. }) => 0, } } } diff --git a/apollo-router/src/state_machine.rs b/apollo-router/src/state_machine.rs index e3ce6c3a67..29d7d65ef2 100644 --- a/apollo-router/src/state_machine.rs +++ b/apollo-router/src/state_machine.rs @@ -2,22 +2,24 @@ use std::fmt::Debug; use std::fmt::Formatter; use std::sync::Arc; -use futures::prelude::*; -use tokio::sync::mpsc; -#[cfg(test)] -use tokio::sync::Notify; -use tokio::sync::OwnedRwLockWriteGuard; -use tokio::sync::RwLock; use ApolloRouterError::ServiceCreationError; use Event::NoMoreConfiguration; use Event::NoMoreLicense; use Event::NoMoreSchema; use Event::Reload; +use Event::RhaiReload; use Event::Shutdown; use State::Errored; use State::Running; use State::Startup; use State::Stopped; +use futures::prelude::*; +use itertools::Itertools; +#[cfg(test)] +use tokio::sync::Notify; +use tokio::sync::OwnedRwLockWriteGuard; +use tokio::sync::RwLock; +use tokio::sync::mpsc; use super::http_server_factory::HttpServerFactory; use super::http_server_factory::HttpServerHandle; @@ -27,19 +29,22 @@ use super::router::ApolloRouterError::{self}; use super::router::Event::UpdateConfiguration; use super::router::Event::UpdateSchema; use super::router::Event::{self}; -use crate::configuration::metrics::Metrics; +use crate::ApolloRouterError::NoLicense; use crate::configuration::Configuration; use crate::configuration::Discussed; use crate::configuration::ListenAddr; +use crate::configuration::metrics::Metrics; use crate::plugins::telemetry::reload::apollo_opentelemetry_initialized; use crate::router::Event::UpdateLicense; use crate::router_factory::RouterFactory; use crate::router_factory::RouterSuperServiceFactory; use crate::spec::Schema; +use crate::uplink::feature_gate_enforcement::FeatureGateEnforcementReport; +use crate::uplink::license_enforcement::LICENSE_EXPIRED_URL; use crate::uplink::license_enforcement::LicenseEnforcementReport; +use crate::uplink::license_enforcement::LicenseLimits; use crate::uplink::license_enforcement::LicenseState; -use crate::uplink::license_enforcement::LICENSE_EXPIRED_URL; -use crate::ApolloRouterError::NoLicense; +use crate::uplink::schema::SchemaState; const STATE_CHANGE: &str = "state change"; @@ -54,15 +59,15 @@ pub(crate) struct ListenAddresses { enum State { Startup { configuration: Option>, - schema: Option>, - license: Option, + schema: Option>, + license: Option>, listen_addresses_guard: OwnedRwLockWriteGuard, }, Running { configuration: Arc, _metrics: Option, - schema: Arc, - license: LicenseState, + schema: Arc, + license: Arc, server_handle: Option, router_service_factory: FA::RouterFactory, all_connections_stopped_signals: Vec>, @@ -118,9 +123,10 @@ impl State { async fn update_inputs( mut self, state_machine: &mut StateMachine, - new_schema: Option>, + new_schema: Option>, new_configuration: Option>, - new_license: Option, + new_license: Option>, + force_reload: bool, ) -> Self where S: HttpServerFactory, @@ -147,16 +153,13 @@ impl State { None, configuration.clone(), schema.clone(), - *license, + license.clone(), listen_addresses_guard, vec![], ) - .map_ok_or_else(Errored, |f| f) + .map_ok_or_else(Errored, |f| f.0) .await, ); - if matches!(new_state, Some(Running { .. })) { - state_machine.http_server_factory.ready(true); - } } } Running { @@ -170,8 +173,8 @@ impl State { } => { // When we get an unlicensed event, if we were licensed before then just carry on. // This means that users can delete and then undelete their graphs in studio while having their routers continue to run. - if new_license == Some(LicenseState::Unlicensed) - && *license != LicenseState::Unlicensed + if new_license.as_deref() == Some(&LicenseState::Unlicensed) + && **license != LicenseState::Unlicensed { tracing::info!( event = STATE_CHANGE, @@ -183,21 +186,22 @@ impl State { // Have things actually changed? let (mut license_reload, mut schema_reload, mut configuration_reload) = (false, false, false); + let old_notify = configuration.notify.clone(); if let Some(new_configuration) = new_configuration { *configuration = new_configuration; configuration_reload = true; } - if let Some(new_schema) = new_schema { - if schema.as_ref() != new_schema.as_ref() { - *schema = new_schema; - schema_reload = true; - } + if let Some(new_schema) = new_schema + && schema.as_ref() != new_schema.as_ref() + { + *schema = new_schema; + schema_reload = true; } - if let Some(new_license) = new_license { - if *license != new_license { - *license = new_license; - license_reload = true; - } + if let Some(new_license) = new_license + && *license != new_license + { + *license = new_license; + license_reload = true; } // Let users know we are about to process a state reload event @@ -209,7 +213,8 @@ impl State { "processing event" ); - let need_reload = schema_reload || license_reload || configuration_reload; + let need_reload = + force_reload || schema_reload || license_reload || configuration_reload; if need_reload { // We update the running config. This is OK even in the case that the router could not reload as we always want to retain the latest information for when we try to reload next. @@ -222,13 +227,13 @@ impl State { Some(router_service_factory), configuration.clone(), schema.clone(), - *license, + license.clone(), &mut guard, signals, ) .await { - Ok(new_state) => { + Ok((new_state, new_schema)) => { tracing::info!( new_schema = schema_reload, new_license = license_reload, @@ -236,6 +241,16 @@ impl State { event = STATE_CHANGE, "reload complete" ); + + // We broadcast change notifications _after_ the pipelines have fully + // rolled over. + if configuration_reload { + old_notify.broadcast_configuration(Arc::downgrade(configuration)); + } + if schema_reload { + configuration.notify.broadcast_schema(new_schema); + } + Some(new_state) } Err(e) => { @@ -272,18 +287,13 @@ impl State { new_state.unwrap_or(self) } - async fn shutdown(self, http_server_factory: &S) -> Self - where - S: HttpServerFactory, - { + async fn shutdown(self) -> Self { match self { Running { server_handle: Some(server_handle), mut all_connections_stopped_signals, .. } => { - // We want to set the ready state to false before we start shutting down the server. - http_server_factory.ready(false); tracing::info!("shutting down"); let state = server_handle .shutdown() @@ -302,69 +312,137 @@ impl State { } } + /// Start a router. Returns the schema so active subscriptions on a previous + /// configuration or schema can be notified of the new schema. #[allow(clippy::too_many_arguments)] async fn try_start( state_machine: &mut StateMachine, server_handle: &mut Option, previous_router_service_factory: Option<&FA::RouterFactory>, configuration: Arc, - sdl: Arc, - license: LicenseState, + schema_state: Arc, + license: Arc, listen_addresses_guard: &mut OwnedRwLockWriteGuard, mut all_connections_stopped_signals: Vec>, - ) -> Result, ApolloRouterError> + ) -> Result<(State, Arc), ApolloRouterError> where S: HttpServerFactory, FA: RouterSuperServiceFactory, { let schema = Arc::new( - Schema::parse_arc(sdl.clone(), &configuration) + Schema::parse_arc(schema_state.clone(), &configuration) .map_err(|e| ServiceCreationError(e.to_string().into()))?, ); // Check the license - let report = LicenseEnforcementReport::build(&configuration, &schema); - - match license { - LicenseState::Licensed => { - tracing::debug!("A valid Apollo license has been detected."); + let report = LicenseEnforcementReport::build(&configuration, &schema, &license); + + let license_limits = match &*license { + LicenseState::Licensed { limits } => { + if report.uses_restricted_features() { + tracing::error!( + "The router is using features not available for your license:\n\n{}", + report + ); + return Err(ApolloRouterError::LicenseViolation( + report.restricted_features_in_use(), + )); + } else { + tracing::debug!("A valid Apollo license has been detected."); + limits + } } - LicenseState::LicensedWarn if report.uses_restricted_features() => { - tracing::error!("License has expired. The Router will soon stop serving requests. In order to enable these features for a self-hosted instance of Apollo Router, the Router must be connected to a graph in GraphOS that provides an active license for the following features:\n\n{}\n\nSee {LICENSE_EXPIRED_URL} for more information.", report); + LicenseState::LicensedWarn { limits } => { + if report.uses_restricted_features() { + tracing::error!( + "License has expired. The Router will soon stop serving requests. In order to enable these features for a self-hosted instance of Apollo Router, the Router must be connected to a graph in GraphOS that provides an active license for the following features:\n\n{}\n\nSee {LICENSE_EXPIRED_URL} for more information.", + report + ); + limits + } else { + tracing::error!( + "License has expired. The Router will soon stop serving requests. In order to enable these features for a self-hosted instance of Apollo Router, the Router must be connected to a graph in GraphOS that provides an active license for the following features:\n\n{:?}\n\nSee {LICENSE_EXPIRED_URL} for more information.", + // The report does not contain any features because they are contained within the allowedFeatures claim, + // therefore we output all of the allowed features that the user's license enables them to use. + license.get_allowed_features() + ); + limits + } } - LicenseState::LicensedHalt if report.uses_restricted_features() => { - tracing::error!("License has expired. The Router will no longer serve requests. In order to enable these features for a self-hosted instance of Apollo Router, the Router must be connected to a graph in GraphOS that provides an active license for the following features:\n\n{}\n\nSee {LICENSE_EXPIRED_URL} for more information.", report); + // LicensedHalt doesn't return an error, which might be surprising; rather, the middleware in the axum + // server (`license_handler`) will check for halted licenses and send back a canned response + LicenseState::LicensedHalt { limits } => { + if report.uses_restricted_features() { + tracing::error!( + "License has expired. The Router will no longer serve requests. In order to enable these features for a self-hosted instance of Apollo Router, the Router must be connected to a graph in GraphOS that provides an active license for the following features:\n\n{}\n\nSee {LICENSE_EXPIRED_URL} for more information.", + report + ); + limits + } else { + tracing::error!( + "License has expired. The Router will no longer serve requests. In order to enable these features for a self-hosted instance of Apollo Router, the Router must be connected to a graph in GraphOS that provides an active license for the following features:\n\n{:?}\n\nSee {LICENSE_EXPIRED_URL} for more information.", + // The report does not contain any features because they are contained within the allowedFeatures claim, + // therefore we output all of the allowed features that the user's license enables them to use. + license.get_allowed_features() + ); + limits + } } LicenseState::Unlicensed if report.uses_restricted_features() => { // This is OSS, so fail to reload or start. - if std::env::var("APOLLO_KEY").is_ok() && std::env::var("APOLLO_GRAPH_REF").is_ok() + if crate::services::APOLLO_KEY.lock().is_some() + && crate::services::APOLLO_GRAPH_REF.lock().is_some() { - tracing::error!("License not found. In order to enable these features for a self-hosted instance of Apollo Router, the Router must be connected to a graph in GraphOS that provides a license for the following features:\n\n{}\n\nSee {LICENSE_EXPIRED_URL} for more information.", report); + tracing::error!( + "License not found. In order to enable these features for a self-hosted instance of Apollo Router, the Router must be connected to a graph in GraphOS that provides a license for the following features:\n\n{}\n\nSee {LICENSE_EXPIRED_URL} for more information.", + report + ); } else { - tracing::error!("Not connected to GraphOS. In order to enable these features for a self-hosted instance of Apollo Router, the Router must be connected to a graph in GraphOS (using APOLLO_KEY and APOLLO_GRAPH_REF) that provides a license for the following features:\n\n{}\n\nSee {LICENSE_EXPIRED_URL} for more information.", report); + tracing::error!( + "Not connected to GraphOS. In order to enable these features for a self-hosted instance of Apollo Router, the Router must be connected to a graph in GraphOS (using APOLLO_KEY and APOLLO_GRAPH_REF) that provides a license for the following features:\n\n{}\n\nSee {LICENSE_EXPIRED_URL} for more information.", + report + ); } - - return Err(ApolloRouterError::LicenseViolation); + return Err(ApolloRouterError::LicenseViolation( + report.restricted_features_in_use(), + )); } _ => { - tracing::debug!("A valid Apollo license was not detected. However, no restricted features are in use."); + tracing::debug!( + "A valid Apollo license was not detected. However, no restricted features are in use." + ); + // Without restricted features, there's no need to limit the router + &Option::::None } - } + }; - // If there are no restricted featured in use then the effective license is Licensed as we don't need warn or halt behavior. + // If there are no restricted features in use then the effective license is Licensed as we don't need warn or halt behavior. let effective_license = if !report.uses_restricted_features() { - LicenseState::Licensed + Arc::new(LicenseState::Licensed { + limits: license_limits.clone(), + }) } else { - license + license.clone() }; + if let Err(feature_gate_violations) = + FeatureGateEnforcementReport::build(&configuration, &schema).check() + { + tracing::error!( + "The schema contains preview features not enabled in configuration.\n\n{}", + feature_gate_violations.iter().join("\n") + ); + return Err(ApolloRouterError::FeatureGateViolation); + } + let router_service_factory = state_machine .router_configurator .create( state_machine.is_telemetry_disabled, configuration.clone(), - schema, + schema.clone(), previous_router_service_factory, None, + effective_license.clone(), ) .await .map_err(ServiceCreationError)?; @@ -416,18 +494,21 @@ impl State { discussed.log_preview_used(yaml); } - let metrics = - apollo_opentelemetry_initialized().then(|| Metrics::new(&configuration, &license)); + let metrics = apollo_opentelemetry_initialized() + .then(|| Metrics::new(&configuration, Arc::as_ref(&license))); - Ok(Running { - configuration, - _metrics: metrics, - schema: sdl, - license, - server_handle: Some(server_handle), - router_service_factory, - all_connections_stopped_signals, - }) + Ok(( + Running { + configuration, + _metrics: metrics, + schema: schema_state, + license, + server_handle: Some(server_handle), + router_service_factory, + all_connections_stopped_signals, + }, + schema, + )) } } @@ -521,49 +602,63 @@ where .expect("must have listen address guard"), }; - // Mark ourselves as live at this point - - self.http_server_factory.live(true); - // Process all the events in turn until we get to error state or we run out of events. while let Some(event) = messages.next().await { - let event_name = format!("{event:?}"); + let event_name = match &event { + Event::UpdateLicense(license_state) => { + format!("UpdateLicense({})", license_state.get_name()) + } + event => format!("{event:?}"), + }; + let previous_state = format!("{state:?}"); state = match event { UpdateConfiguration(configuration) => { state - .update_inputs(&mut self, None, Some(Arc::new(configuration)), None) + .update_inputs(&mut self, None, Some(configuration), None, false) .await } NoMoreConfiguration => state.no_more_configuration().await, UpdateSchema(schema) => { state - .update_inputs(&mut self, Some(Arc::new(schema)), None, None) + .update_inputs(&mut self, Some(Arc::new(schema)), None, None, false) .await } NoMoreSchema => state.no_more_schema().await, UpdateLicense(license) => { state - .update_inputs(&mut self, None, None, Some(license)) + .update_inputs(&mut self, None, None, Some(license), false) + .await + } + Reload => { + state + .update_inputs(&mut self, None, None, None, false) .await } - Reload => state.update_inputs(&mut self, None, None, None).await, + RhaiReload => state.update_inputs(&mut self, None, None, None, true).await, NoMoreLicense => state.no_more_license().await, - Shutdown => state.shutdown(&self.http_server_factory).await, + Shutdown => state.shutdown().await, }; // Update the shared state #[cfg(test)] self.notify_updated.notify_one(); - tracing::debug!( - monotonic_counter.apollo_router_state_change_total = 1u64, + tracing::info!( event = event_name, state = ?state, previous_state, "state machine transitioned" ); + u64_counter!( + "apollo.router.state.change.total", + "Router state changes", + 1, + event = event_name, + state = format!("{state:?}"), + previous_state = previous_state + ); // If we've errored then exit even if there are potentially more messages if matches!(&state, Stopped | Errored(_)) { @@ -572,9 +667,6 @@ where } tracing::info!("stopped"); - // Note that ready(false) will not be called on a non-graceful shutdown. - self.http_server_factory.live(false); - match state { Stopped => Ok(()), Errored(err) => Err(err), @@ -587,36 +679,43 @@ where #[cfg(test)] mod tests { + use std::collections::HashSet; use std::net::SocketAddr; use std::pin::Pin; use std::str::FromStr; - use std::sync::Mutex; use futures::channel::oneshot; - use mockall::mock; - use mockall::predicate::eq; use mockall::Sequence; + use mockall::mock; use multimap::MultiMap; + use parking_lot::Mutex; + use rstest::rstest; use serde_json::json; use test_log::test; use tower::BoxError; use tower::Service; use super::*; + use crate::AllowedFeature; use crate::configuration::Homepage; use crate::http_server_factory::Listener; use crate::plugin::DynPlugin; use crate::router_factory::Endpoint; use crate::router_factory::RouterFactory; use crate::router_factory::RouterSuperServiceFactory; + use crate::services::RouterRequest; use crate::services::new_service::ServiceFactory; use crate::services::router; - use crate::services::RouterRequest; + use crate::services::router::pipeline_handle::PipelineRef; + use crate::uplink::schema::SchemaState; type SharedOneShotReceiver = Arc>>>; - fn example_schema() -> String { - include_str!("testdata/supergraph.graphql").to_owned() + fn example_schema() -> SchemaState { + SchemaState { + sdl: include_str!("testdata/supergraph.graphql").to_owned(), + launch_id: None, + } } macro_rules! assert_matches { @@ -632,7 +731,7 @@ mod tests { #[test(tokio::test)] async fn no_configuration() { let router_factory = create_mock_router_configurator(0); - let (server_factory, _) = create_mock_server_factory(0, 1, 0, 1, 0); + let (server_factory, _) = create_mock_server_factory(0); assert_matches!( execute( server_factory, @@ -647,7 +746,7 @@ mod tests { #[test(tokio::test)] async fn no_schema() { let router_factory = create_mock_router_configurator(0); - let (server_factory, _) = create_mock_server_factory(0, 1, 0, 1, 0); + let (server_factory, _) = create_mock_server_factory(0); assert_matches!( execute( server_factory, @@ -662,7 +761,7 @@ mod tests { #[test(tokio::test)] async fn no_license() { let router_factory = create_mock_router_configurator(0); - let (server_factory, _) = create_mock_server_factory(0, 1, 0, 1, 0); + let (server_factory, _) = create_mock_server_factory(0); assert_matches!( execute( server_factory, @@ -673,17 +772,58 @@ mod tests { Err(NoLicense) ); } - fn test_config_restricted() -> Configuration { + + fn test_config_restricted() -> Arc { let mut config = Configuration::builder().build().unwrap(); config.validated_yaml = Some(json!({"plugins":{"experimental.restricted":{"enabled":true}}})); - config + Arc::new(config) + } + fn test_config_with_apq_caching() -> Arc { + let mut config = Configuration::builder().build().unwrap(); + config.validated_yaml = Some(json!({"apq":{"router":{"cache":{"redis":{"pool_size":1}}}}})); + Arc::new(config) + } + fn test_config_with_subscriptions() -> Arc { + let mut config = Configuration::builder().build().unwrap(); + config.validated_yaml = Some(json!({"subscription":{"enabled":true}})); + Arc::new(config) + } + fn test_config_with_demand_control() -> Arc { + let mut config = Configuration::builder().build().unwrap(); + config.validated_yaml = Some(json!({"demand_control":{"enabled":true}})); + Arc::new(config) + } + fn test_config_with_request_limits() -> Arc { + let mut config = Configuration::builder().build().unwrap(); + config.validated_yaml = Some(json!({ + "limits": { + "max_height": 100, + "max_aliases": 100, + "max_depth": 20 + } + })); + Arc::new(config) + } + fn test_config_with_auth() -> Arc { + let mut config = Configuration::builder().build().unwrap(); + config.validated_yaml = Some(json!({ + "authentication": { + "router": { + "sources": {} + } + }, + "authorization": { + "require_authentication": true + } + })); + Arc::new(config) } #[test(tokio::test)] async fn restricted_licensed() { let router_factory = create_mock_router_configurator(1); - let (server_factory, shutdown_receivers) = create_mock_server_factory(1, 1, 1, 1, 1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); assert_matches!( execute( @@ -692,20 +832,94 @@ mod tests { stream::iter(vec![ UpdateConfiguration(test_config_restricted()), UpdateSchema(example_schema()), - UpdateLicense(LicenseState::Licensed), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits::default()) + })), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 1); + } + + #[test(tokio::test)] + #[rstest] + #[case::apq(test_config_with_apq_caching(), vec![AllowedFeature::ApqCaching])] + #[case::subscriptions(test_config_with_subscriptions(), vec![AllowedFeature::Subscriptions])] + #[case::demand_control(test_config_with_demand_control(), vec![AllowedFeature::Subscriptions, AllowedFeature::DemandControl])] + #[case::request_limits(test_config_with_request_limits(), vec![AllowedFeature::Subscriptions, AllowedFeature::RequestLimits, AllowedFeature::DemandControl])] + #[case::request_limits(test_config_with_auth(), vec![AllowedFeature::Authentication, AllowedFeature::RequestLimits, AllowedFeature::Authorization])] + async fn restricted_licensed_with_allowed_features_feature_contained_in_allowed_features_claim( + #[case] config: Arc, + #[case] allowed_features: Vec, + ) { + let router_factory = create_mock_router_configurator(1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(config), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: (HashSet::from_iter(allowed_features)) + }) + })), Shutdown ]) ) .await, Ok(()) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 1); + assert_eq!(shutdown_receivers.0.lock().len(), 1); + } + + #[test(tokio::test)] + #[rstest] + #[case::apq_empty_allowed_features(test_config_with_apq_caching(), vec![])] + #[case::subscriptions_not_in_allowed_features(test_config_with_subscriptions(), vec![AllowedFeature::ApqCaching])] + #[case::demand_control_not_in_allowed_features(test_config_with_demand_control(), vec![AllowedFeature::Subscriptions, AllowedFeature::ApqCaching])] + #[case::request_limits_not_in_allowed_features(test_config_with_request_limits(), vec![AllowedFeature::Subscriptions, AllowedFeature::Subscriptions, AllowedFeature::DemandControl])] + #[case::auth_not_in_allowed_features(test_config_with_auth(), vec![AllowedFeature::ApqCaching])] + async fn restricted_licensed_with_allowed_features_feature_not_contained_in_allowed_features_claim( + #[case] config: Arc, + #[case] allowed_features: Vec, + ) { + let router_factory = create_mock_router_configurator(0); + let (server_factory, shutdown_receivers) = create_mock_server_factory(0); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(config), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(allowed_features) + }) + })), + Shutdown + ]) + ) + .await, + Err(ApolloRouterError::LicenseViolation(_)) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 0); } #[test(tokio::test)] async fn restricted_licensed_halted() { let router_factory = create_mock_router_configurator(1); - let (server_factory, shutdown_receivers) = create_mock_server_factory(1, 1, 1, 1, 1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); assert_matches!( execute( @@ -714,20 +928,161 @@ mod tests { stream::iter(vec![ UpdateConfiguration(test_config_restricted()), UpdateSchema(example_schema()), - UpdateLicense(LicenseState::LicensedHalt), + UpdateLicense(Arc::new(LicenseState::LicensedHalt { + limits: Some(LicenseLimits::default()) + })), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 1); + } + + #[test(tokio::test)] + async fn unrestricted_licensed_halted() { + let router_factory = create_mock_router_configurator(1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::LicensedHalt { + limits: Some(LicenseLimits::default()) + })), Shutdown ]) ) .await, Ok(()) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 1); + assert_eq!(shutdown_receivers.0.lock().len(), 1); + } + + #[test(tokio::test)] + #[rstest] + #[case::apq(test_config_with_apq_caching(), vec![AllowedFeature::ApqCaching])] + #[case::subscriptions(test_config_with_subscriptions(), vec![AllowedFeature::Subscriptions])] + #[case::demand_control(test_config_with_demand_control(), vec![AllowedFeature::Subscriptions, AllowedFeature::DemandControl])] + #[case::request_limits(test_config_with_request_limits(), vec![AllowedFeature::Subscriptions, AllowedFeature::RequestLimits, AllowedFeature::DemandControl])] + #[case::auth(test_config_with_auth(), vec![AllowedFeature::Authentication, AllowedFeature::RequestLimits, AllowedFeature::Authorization])] + async fn restricted_licensed_halted_with_allowed_features_feature_contained_in_allowed_features_claim( + #[case] config: Arc, + #[case] allowed_features: Vec, + ) { + let router_factory = create_mock_router_configurator(1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(config), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::LicensedHalt { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(allowed_features) + }) + })), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 1); + } + + #[test(tokio::test)] + #[rstest] + #[case::apq_empty_allowed_features(test_config_with_apq_caching(), vec![])] + #[case::subscriptions_not_in_allowed_features(test_config_with_subscriptions(), vec![AllowedFeature::ApqCaching])] + #[case::demand_control_not_in_allowed_features(test_config_with_demand_control(), vec![AllowedFeature::Subscriptions, AllowedFeature::ApqCaching])] + #[case::request_limits_not_in_allowed_features(test_config_with_request_limits(), vec![AllowedFeature::Subscriptions, AllowedFeature::Subscriptions, AllowedFeature::ApqCaching])] + #[case::auth_not_in_allowed_features(test_config_with_auth(), vec![AllowedFeature::ApqCaching])] + async fn restricted_licensed_halted_with_allowed_features_feature_not_contained_in_allowed_features_claim( + #[case] config: Arc, + #[case] allowed_features: Vec, + ) { + let router_factory = create_mock_router_configurator(1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(config), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::LicensedHalt { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(allowed_features) + }) + })), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 1); + } + + #[test(tokio::test)] + #[rstest] + #[case::apq(test_config_with_apq_caching(), vec![AllowedFeature::ApqCaching])] + #[case::subscriptions(test_config_with_subscriptions(), vec![AllowedFeature::Subscriptions])] + #[case::demand_control(test_config_with_demand_control(), vec![AllowedFeature::Subscriptions, AllowedFeature::DemandControl])] + #[case::request_limits(test_config_with_request_limits(), vec![AllowedFeature::Subscriptions, AllowedFeature::RequestLimits, AllowedFeature::DemandControl])] + #[case::auth(test_config_with_auth(), vec![AllowedFeature::Authentication, AllowedFeature::RequestLimits, AllowedFeature::Authorization])] + async fn restricted_license_warn_reloaded_with_license_halted_with_allowed_features_feature_contained_in_allowed_features_claim( + #[case] config: Arc, + #[case] allowed_features: Vec, + ) { + let router_factory = create_mock_router_configurator(3); + let (server_factory, shutdown_receivers) = create_mock_server_factory(3); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(config.clone()), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::LicensedWarn { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(allowed_features.clone()) + }) + })), + UpdateConfiguration(config), + UpdateLicense(Arc::new(LicenseState::LicensedHalt { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(allowed_features) + }) + })), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 3); } #[test(tokio::test)] async fn restricted_licensed_warn() { let router_factory = create_mock_router_configurator(1); - let (server_factory, shutdown_receivers) = create_mock_server_factory(1, 1, 1, 1, 1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); assert_matches!( execute( @@ -736,45 +1091,133 @@ mod tests { stream::iter(vec![ UpdateConfiguration(test_config_restricted()), UpdateSchema(example_schema()), - UpdateLicense(LicenseState::LicensedWarn), + UpdateLicense(Arc::new(LicenseState::LicensedWarn { + limits: Some(LicenseLimits::default()) + })), Shutdown ]) ) .await, Ok(()) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 1); + assert_eq!(shutdown_receivers.0.lock().len(), 1); } #[test(tokio::test)] - async fn restricted_licensed_unlicensed() { - let router_factory = create_mock_router_configurator(2); - let (server_factory, shutdown_receivers) = create_mock_server_factory(2, 1, 1, 1, 1); + #[rstest] + #[case::apq(test_config_with_apq_caching(), vec![AllowedFeature::ApqCaching])] + #[case::subscriptions(test_config_with_subscriptions(), vec![AllowedFeature::Subscriptions])] + #[case::demand_control(test_config_with_demand_control(), vec![AllowedFeature::Subscriptions, AllowedFeature::DemandControl])] + #[case::request_limits(test_config_with_request_limits(), vec![AllowedFeature::Subscriptions, AllowedFeature::RequestLimits, AllowedFeature::DemandControl])] + #[case::auth(test_config_with_auth(), vec![AllowedFeature::Authentication, AllowedFeature::RequestLimits, AllowedFeature::Authorization])] + async fn restricted_licensed_warn_with_allowed_features_feature_contained_in_allowed_features_claim( + #[case] config: Arc, + #[case] allowed_features: Vec, + ) { + let router_factory = create_mock_router_configurator(1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); - // The unlicensed event is dropped so we should get a reload assert_matches!( execute( server_factory, router_factory, stream::iter(vec![ - UpdateConfiguration(test_config_restricted()), + UpdateConfiguration(config), UpdateSchema(example_schema()), - UpdateLicense(LicenseState::Licensed), - UpdateLicense(LicenseState::Unlicensed), - UpdateConfiguration(test_config_restricted()), + UpdateLicense(Arc::new(LicenseState::LicensedWarn { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(allowed_features) + }) + })), Shutdown ]) ) .await, Ok(()) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 2); + assert_eq!(shutdown_receivers.0.lock().len(), 1); + } + + #[test(tokio::test)] + #[rstest] + #[case::apq_empty_allowed_features(test_config_with_apq_caching(), vec![])] + #[case::subscriptions_not_in_allowed_features(test_config_with_subscriptions(), vec![AllowedFeature::ApqCaching])] + #[case::demand_control_not_in_allowed_features(test_config_with_demand_control(), vec![AllowedFeature::Subscriptions, AllowedFeature::ApqCaching])] + #[case::request_limits_not_in_allowed_features(test_config_with_request_limits(), vec![AllowedFeature::Subscriptions, AllowedFeature::Subscriptions, AllowedFeature::ApqCaching])] + #[case::auth_not_in_allowed_features(test_config_with_auth(), vec![AllowedFeature::ApqCaching])] + async fn restricted_licensed_warn_with_allowed_features_feature_not_contained_in_allowed_features_claim( + #[case] config: Arc, + #[case] allowed_features: Vec, + ) { + let router_factory = create_mock_router_configurator(1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(config), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::LicensedWarn { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(allowed_features) + }) + })), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 1); + } + + #[test(tokio::test)] + #[rstest] + #[case::apq_empty_allowed_features(test_config_with_apq_caching(), vec![])] + #[case::subscriptions_not_in_allowed_features(test_config_with_subscriptions(), vec![AllowedFeature::ApqCaching])] + #[case::demand_control_not_in_allowed_features(test_config_with_demand_control(), vec![AllowedFeature::Subscriptions, AllowedFeature::ApqCaching])] + #[case::request_limits_not_in_allowed_features(test_config_with_request_limits(), vec![AllowedFeature::Subscriptions, AllowedFeature::Subscriptions, AllowedFeature::DemandControl])] + #[case::auth_not_in_allowed_features(test_config_with_auth(), vec![AllowedFeature::ApqCaching])] + async fn restricted_licensed_unlicensed_with_feature_not_contained_in_allowed_features( + #[case] config: Arc, + #[case] allowed_features: Vec, + ) { + let router_factory = create_mock_router_configurator(0); + let (server_factory, shutdown_receivers) = create_mock_server_factory(0); + + // The unlicensed event is dropped so we should get a reload + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(config.clone()), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(allowed_features) + }) + })), + UpdateLicense(Arc::new(LicenseState::Unlicensed)), + UpdateConfiguration(config), + Shutdown + ]) + ) + .await, + Err(ApolloRouterError::LicenseViolation(_)) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 0); } #[test(tokio::test)] async fn restricted_unlicensed() { let router_factory = create_mock_router_configurator(0); - let (server_factory, shutdown_receivers) = create_mock_server_factory(0, 1, 0, 1, 0); + let (server_factory, shutdown_receivers) = create_mock_server_factory(0); assert_matches!( execute( @@ -783,44 +1226,374 @@ mod tests { stream::iter(vec![ UpdateConfiguration(test_config_restricted()), UpdateSchema(example_schema()), - UpdateLicense(LicenseState::Unlicensed), + UpdateLicense(Arc::new(LicenseState::Unlicensed)), Shutdown ]) ) .await, - Err(ApolloRouterError::LicenseViolation) + Err(ApolloRouterError::LicenseViolation(_)) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 0); + assert_eq!(shutdown_receivers.0.lock().len(), 0); } + // NB: this behavior may change once all licenses contain an `allowed_features` claim #[test(tokio::test)] - async fn unrestricted_unlicensed_restricted_licensed() { - let router_factory = create_mock_router_configurator(2); - let (server_factory, shutdown_receivers) = create_mock_server_factory(2, 1, 1, 1, 1); + async fn unrestricted_unlicensed_reload_with_config_using_restricted_features_and_license() { + let router_factory = create_mock_router_configurator_for_reload_with_new_license(2); + let (server_factory, shutdown_receivers) = create_mock_server_factory(2); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::Unlicensed)), + UpdateConfiguration(test_config_restricted()), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits::default()) + })), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 2); + } + + #[test(tokio::test)] + async fn unrestricted_unlicensed_reload_with_config_using_restricted_feature_still_unlicensed_router_fails_to_reload() + { + // Expected times called = 1 since the router failed to reload due to the license violation + let router_factory = create_mock_router_configurator(1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); assert_matches!( execute( server_factory, router_factory, stream::iter(vec![ - UpdateConfiguration(Configuration::builder().build().unwrap()), + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), UpdateSchema(example_schema()), - UpdateLicense(LicenseState::Unlicensed), + UpdateLicense(Arc::new(LicenseState::Unlicensed)), UpdateConfiguration(test_config_restricted()), - UpdateLicense(LicenseState::Licensed), + UpdateLicense(Arc::new(LicenseState::Unlicensed)), Shutdown ]) ) .await, Ok(()) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 2); + assert_eq!(shutdown_receivers.0.lock().len(), 1); + } + + #[test(tokio::test)] + #[rstest] + #[case::apq_empty_allowed_features(test_config_with_apq_caching(), vec![])] + #[case::subscriptions_not_in_allowed_features(test_config_with_subscriptions(), vec![AllowedFeature::ApqCaching])] + #[case::demand_control_not_in_allowed_features(test_config_with_demand_control(), vec![AllowedFeature::Subscriptions, AllowedFeature::ApqCaching])] + #[case::request_limits_not_in_allowed_features(test_config_with_request_limits(), vec![AllowedFeature::Subscriptions, AllowedFeature::Subscriptions, AllowedFeature::DemandControl])] + #[case::auth_not_in_allowed_features(test_config_with_auth(), vec![AllowedFeature::ApqCaching])] + async fn unrestricted_unlicensed_restricted_licensed_with_feature_not_contained_in_allowed_features( + #[case] config: Arc, + #[case] allowed_features: Vec, + ) { + let router_factory = create_mock_router_configurator(1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::Unlicensed)), + UpdateConfiguration(config), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(allowed_features) + }) + })), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 1); + } + + #[test(tokio::test)] + async fn restricted_licensed_with_allowed_features_containing_feature_reload_with_empty_feature_set() + { + let router_factory = create_mock_router_configurator(1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(test_config_with_subscriptions()), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(vec![ + AllowedFeature::Subscriptions + ]) + }) + })), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::new() + }) + })), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 1); + } + + #[test(tokio::test)] + async fn unlicensed_reload_with_license_and_use_feature_enabled_by_that_license() { + let router_factory = create_mock_router_configurator_for_reload_with_new_license(3); + let (server_factory, shutdown_receivers) = create_mock_server_factory(3); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), + UpdateLicense(Arc::new(LicenseState::Unlicensed)), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(vec![ + AllowedFeature::Subscriptions, + ]) + }) + })), + UpdateConfiguration(test_config_with_subscriptions()), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 3); + } + + #[test(tokio::test)] + async fn unlicensed_reload_with_license_and_use_feature_not_enabled_by_that_license() { + let router_factory = create_mock_router_configurator_for_reload_with_new_license(2); + let (server_factory, shutdown_receivers) = create_mock_server_factory(2); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), + UpdateLicense(Arc::new(LicenseState::Unlicensed)), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(vec![ + AllowedFeature::DemandControl, + ]) + }) + })), + UpdateConfiguration(test_config_with_subscriptions()), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 2); + } + + // NB: this behavior will change once all licenses have an `allowed_features` claim + #[test(tokio::test)] + async fn unlicensed_reload_with_license_using_default_limits() { + let router_factory = create_mock_router_configurator_for_reload_with_new_license(3); + let (server_factory, shutdown_receivers) = create_mock_server_factory(3); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), + UpdateLicense(Arc::new(LicenseState::Unlicensed)), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Default::default() + })), + UpdateConfiguration(test_config_with_subscriptions()), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 3); + } + + #[test(tokio::test)] + async fn licensed_with_feature_contained_in_allowed_features_reload_with_feature_set_not_containing_feature_used() + { + let router_factory = create_mock_router_configurator(1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(test_config_with_subscriptions()), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(vec![ + AllowedFeature::Subscriptions, + ]) + }) + })), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(vec![ + AllowedFeature::Authentication, + AllowedFeature::Authorization + ]) + }) + })), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 1); + } + + #[test(tokio::test)] + async fn licensed_with_feature_contained_in_allowed_features_reload_with_feature_set_still_containing_restricted_feature_in_use() + { + let router_factory = create_mock_router_configurator_for_reload_with_new_license(2); + let (server_factory, shutdown_receivers) = create_mock_server_factory(2); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(test_config_with_subscriptions()), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(vec![ + AllowedFeature::Subscriptions, + ]) + }) + })), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(vec![ + AllowedFeature::Authentication, + AllowedFeature::Authorization, + AllowedFeature::Subscriptions, + ]) + }) + })), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 2); + } + + // NB: This behavior will change once all licenses have an `allowed_features` claim + #[test(tokio::test)] + async fn licensed_with_feature_contained_in_allowed_features_reload_with_license_with_default_limits() + { + let router_factory = create_mock_router_configurator_for_reload_with_new_license(2); + let (server_factory, shutdown_receivers) = create_mock_server_factory(2); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(test_config_with_subscriptions()), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(vec![ + AllowedFeature::Subscriptions, + ]) + }) + })), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Default::default() + })), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 2); + } + + // NB: this behavior will change once all licenses have an `allowed_features` claim + #[test(tokio::test)] + async fn restricted_licensed_with_default_license_limits() { + let router_factory = create_mock_router_configurator(1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); + + assert_matches!( + execute( + server_factory, + router_factory, + stream::iter(vec![ + UpdateConfiguration(test_config_with_subscriptions()), + UpdateSchema(example_schema()), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Default::default() + })), + Shutdown + ]) + ) + .await, + Ok(()) + ); + assert_eq!(shutdown_receivers.0.lock().len(), 1); } #[test(tokio::test)] async fn listen_addresses_are_locked() { let router_factory = create_mock_router_configurator(0); - let (server_factory, _) = create_mock_server_factory(0, 0, 0, 0, 0); + let (server_factory, _) = create_mock_server_factory(0); let is_telemetry_disabled = false; let state_machine = StateMachine::new(is_telemetry_disabled, server_factory, router_factory); @@ -830,7 +1603,7 @@ mod tests { #[test(tokio::test)] async fn shutdown_during_startup() { let router_factory = create_mock_router_configurator(0); - let (server_factory, _) = create_mock_server_factory(0, 1, 0, 1, 0); + let (server_factory, _) = create_mock_server_factory(0); assert_matches!( execute(server_factory, router_factory, stream::iter(vec![Shutdown])).await, Ok(()) @@ -840,38 +1613,41 @@ mod tests { #[test(tokio::test)] async fn startup_shutdown() { let router_factory = create_mock_router_configurator(1); - let (server_factory, shutdown_receivers) = create_mock_server_factory(1, 1, 1, 1, 1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); assert_matches!( execute( server_factory, router_factory, stream::iter(vec![ - UpdateConfiguration(Configuration::builder().build().unwrap()), + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), UpdateSchema(example_schema()), - UpdateLicense(LicenseState::default()), + UpdateLicense(Default::default()), Shutdown ]) ) .await, Ok(()) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 1); + assert_eq!(shutdown_receivers.0.lock().len(), 1); } #[test(tokio::test)] async fn startup_reload_schema() { let router_factory = create_mock_router_configurator(2); - let (server_factory, shutdown_receivers) = create_mock_server_factory(2, 1, 1, 1, 1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(2); let minimal_schema = include_str!("testdata/minimal_supergraph.graphql"); assert_matches!( execute( server_factory, router_factory, stream::iter(vec![ - UpdateConfiguration(Configuration::builder().build().unwrap()), - UpdateSchema(minimal_schema.to_owned()), - UpdateLicense(LicenseState::default()), + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), + UpdateSchema(SchemaState { + sdl: minimal_schema.to_owned(), + launch_id: None + }), + UpdateLicense(Default::default()), UpdateSchema(example_schema()), Shutdown ]) @@ -879,69 +1655,80 @@ mod tests { .await, Ok(()) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 2); + assert_eq!(shutdown_receivers.0.lock().len(), 2); } #[test(tokio::test)] async fn startup_no_reload_schema() { let router_factory = create_mock_router_configurator(1); - let (server_factory, shutdown_receivers) = create_mock_server_factory(1, 1, 1, 1, 1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); let minimal_schema = include_str!("testdata/minimal_supergraph.graphql"); assert_matches!( execute( server_factory, router_factory, stream::iter(vec![ - UpdateConfiguration(Configuration::builder().build().unwrap()), - UpdateSchema(minimal_schema.to_owned()), - UpdateLicense(LicenseState::default()), - UpdateSchema(minimal_schema.to_owned()), + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), + UpdateSchema(SchemaState { + sdl: minimal_schema.to_owned(), + launch_id: None + }), + UpdateLicense(Default::default()), + UpdateSchema(SchemaState { + sdl: minimal_schema.to_owned(), + launch_id: None + }), Shutdown ]) ) .await, Ok(()) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 1); + assert_eq!(shutdown_receivers.0.lock().len(), 1); } #[test(tokio::test)] async fn startup_reload_license() { let router_factory = create_mock_router_configurator(2); - let (server_factory, shutdown_receivers) = create_mock_server_factory(2, 1, 1, 1, 1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(2); let minimal_schema = include_str!("testdata/minimal_supergraph.graphql"); assert_matches!( execute( server_factory, router_factory, stream::iter(vec![ - UpdateConfiguration(Configuration::builder().build().unwrap()), - UpdateSchema(minimal_schema.to_owned()), - UpdateLicense(LicenseState::default()), - UpdateLicense(LicenseState::Licensed), + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), + UpdateSchema(SchemaState { + sdl: minimal_schema.to_owned(), + launch_id: None + }), + UpdateLicense(Default::default()), + UpdateLicense(Arc::new(LicenseState::Licensed { + limits: Some(LicenseLimits::default()) + })), Shutdown ]) ) .await, Ok(()) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 2); + assert_eq!(shutdown_receivers.0.lock().len(), 2); } #[test(tokio::test)] async fn startup_reload_configuration() { let router_factory = create_mock_router_configurator(2); - let (server_factory, shutdown_receivers) = create_mock_server_factory(2, 1, 1, 1, 1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(2); assert_matches!( execute( server_factory, router_factory, stream::iter(vec![ - UpdateConfiguration(Configuration::builder().build().unwrap()), + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), UpdateSchema(example_schema()), - UpdateLicense(LicenseState::default()), - UpdateConfiguration( + UpdateLicense(Default::default()), + UpdateConfiguration(Arc::new( Configuration::builder() .supergraph( crate::configuration::Supergraph::builder() @@ -950,36 +1737,36 @@ mod tests { ) .build() .unwrap() - ), + )), Shutdown ]) ) .await, Ok(()) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 2); + assert_eq!(shutdown_receivers.0.lock().len(), 2); } #[test(tokio::test)] async fn extract_routing_urls() { let router_factory = create_mock_router_configurator(1); - let (server_factory, shutdown_receivers) = create_mock_server_factory(1, 1, 1, 1, 1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); assert_matches!( execute( server_factory, router_factory, stream::iter(vec![ - UpdateConfiguration(Configuration::builder().build().unwrap()), + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), UpdateSchema(example_schema()), - UpdateLicense(LicenseState::default()), + UpdateLicense(Default::default()), Shutdown ]) ) .await, Ok(()) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 1); + assert_eq!(shutdown_receivers.0.lock().len(), 1); } #[test(tokio::test)] @@ -988,24 +1775,24 @@ mod tests { router_factory .expect_create() .times(1) - .returning(|_, _, _, _, _| Err(BoxError::from("Error"))); + .returning(|_, _, _, _, _, _| Err(BoxError::from("Error"))); - let (server_factory, shutdown_receivers) = create_mock_server_factory(0, 1, 0, 1, 0); + let (server_factory, shutdown_receivers) = create_mock_server_factory(0); assert_matches!( execute( server_factory, router_factory, stream::iter(vec![ - UpdateConfiguration(Configuration::builder().build().unwrap()), + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), UpdateSchema(example_schema()), - UpdateLicense(LicenseState::default()), + UpdateLicense(Default::default()), ]) ) .await, Err(ApolloRouterError::ServiceCreationError(_)) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 0); + assert_eq!(shutdown_receivers.0.lock().len(), 0); } #[test(tokio::test)] @@ -1016,7 +1803,7 @@ mod tests { .expect_create() .times(1) .in_sequence(&mut seq) - .returning(|_, _, _, _, _| { + .returning(|_, _, _, _, _, _| { let mut router = MockMyRouterFactory::new(); router.expect_clone().return_once(MockMyRouterFactory::new); router.expect_web_endpoints().returning(MultiMap::new); @@ -1026,9 +1813,9 @@ mod tests { .expect_create() .times(1) .in_sequence(&mut seq) - .returning(|_, _, _, _, _| Err(BoxError::from("error"))); + .returning(|_, _, _, _, _, _| Err(BoxError::from("error"))); - let (server_factory, shutdown_receivers) = create_mock_server_factory(1, 1, 1, 1, 1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(1); let minimal_schema = include_str!("testdata/minimal_supergraph.graphql"); assert_matches!( @@ -1036,17 +1823,20 @@ mod tests { server_factory, router_factory, stream::iter(vec![ - UpdateConfiguration(Configuration::builder().build().unwrap()), + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), UpdateSchema(example_schema()), - UpdateLicense(LicenseState::default()), - UpdateSchema(minimal_schema.to_owned()), + UpdateLicense(Default::default()), + UpdateSchema(SchemaState { + sdl: minimal_schema.to_owned(), + launch_id: None + }), Shutdown ]) ) .await, Ok(()) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 1); + assert_eq!(shutdown_receivers.0.lock().len(), 1); } #[test(tokio::test)] @@ -1057,7 +1847,7 @@ mod tests { .expect_create() .times(1) .in_sequence(&mut seq) - .returning(|_, _, _, _, _| { + .returning(|_, _, _, _, _, _| { let mut router = MockMyRouterFactory::new(); router.expect_clone().return_once(MockMyRouterFactory::new); router.expect_web_endpoints().returning(MultiMap::new); @@ -1067,20 +1857,20 @@ mod tests { .expect_create() .times(1) .in_sequence(&mut seq) - .returning(|_, _, _, _, _| Err(BoxError::from("error"))); + .returning(|_, _, _, _, _, _| Err(BoxError::from("error"))); router_factory .expect_create() .times(1) .in_sequence(&mut seq) - .withf(|_, configuration, _, _, _| configuration.homepage.enabled) - .returning(|_, _, _, _, _| { + .withf(|_, configuration, _, _, _, _| configuration.homepage.enabled) + .returning(|_, _, _, _, _, _| { let mut router = MockMyRouterFactory::new(); router.expect_clone().return_once(MockMyRouterFactory::new); router.expect_web_endpoints().returning(MultiMap::new); Ok(router) }); - let (server_factory, shutdown_receivers) = create_mock_server_factory(2, 1, 1, 1, 1); + let (server_factory, shutdown_receivers) = create_mock_server_factory(2); let minimal_schema = include_str!("testdata/minimal_supergraph.graphql"); assert_matches!( @@ -1088,23 +1878,26 @@ mod tests { server_factory, router_factory, stream::iter(vec![ - UpdateConfiguration(Configuration::builder().build().unwrap()), + UpdateConfiguration(Arc::new(Configuration::builder().build().unwrap())), UpdateSchema(example_schema()), - UpdateLicense(LicenseState::default()), - UpdateConfiguration( + UpdateLicense(Default::default()), + UpdateConfiguration(Arc::new( Configuration::builder() .homepage(Homepage::builder().enabled(true).build()) .build() .unwrap() - ), - UpdateSchema(minimal_schema.to_owned()), + )), + UpdateSchema(SchemaState { + sdl: minimal_schema.to_owned(), + launch_id: None + }), Shutdown ]), ) .await, Ok(()) ); - assert_eq!(shutdown_receivers.0.lock().unwrap().len(), 2); + assert_eq!(shutdown_receivers.0.lock().len(), 2); } mock! { @@ -1122,6 +1915,7 @@ mod tests { schema: Arc, previous_router_service_factory: Option<&'a MockMyRouterFactory>, extra_plugins: Option)>>, + license: Arc ) -> Result; } } @@ -1134,6 +1928,7 @@ mod tests { type RouterService = router::BoxService; type Future = >::Future; fn web_endpoints(&self) -> MultiMap; + fn pipeline_ref(&self) -> Arc; } impl ServiceFactory for MyRouterFactory { type Service = router::BoxService; @@ -1151,8 +1946,6 @@ mod tests { fn create_server(&self, configuration: Arc, main_listener: Option,) -> Result; - fn live(&self, live: bool); - fn ready(&self, ready: bool); } } @@ -1168,7 +1961,7 @@ mod tests { _extra_listeners: Vec<(ListenAddr, Listener)>, _web_endpoints: MultiMap, - _license: LicenseState, + _license: Arc, _all_connections_stopped_sender: mpsc::Sender<()>, ) -> Self::Future where @@ -1177,12 +1970,6 @@ mod tests { let res = self.create_server(configuration, main_listener); Box::pin(async move { res }) } - fn live(&self, live: bool) { - self.live(live); - } - fn ready(&self, ready: bool) { - self.ready(ready); - } } async fn execute( @@ -1198,10 +1985,6 @@ mod tests { fn create_mock_server_factory( expect_times_called: usize, - live_true_times: usize, - ready_true_times: usize, - live_false_times: usize, - ready_false_times: usize, ) -> ( MockMyHttpServerFactory, (SharedOneShotReceiver, SharedOneShotReceiver), @@ -1218,13 +2001,9 @@ mod tests { move |configuration: Arc, mut main_listener: Option| { let (shutdown_sender, shutdown_receiver) = oneshot::channel(); let (extra_shutdown_sender, extra_shutdown_receiver) = oneshot::channel(); - shutdown_receivers_clone - .lock() - .unwrap() - .push(shutdown_receiver); + shutdown_receivers_clone.lock().push(shutdown_receiver); extra_shutdown_receivers_clone .lock() - .unwrap() .push(extra_shutdown_receiver); let server = async move { @@ -1251,26 +2030,6 @@ mod tests { )) }, ); - server_factory - .expect_live() - .with(eq(true)) - .times(live_true_times) - .return_const(()); - server_factory - .expect_ready() - .with(eq(true)) - .times(ready_true_times) - .return_const(()); - server_factory - .expect_live() - .with(eq(false)) - .times(live_false_times) - .return_const(()); - server_factory - .expect_ready() - .with(eq(false)) - .times(ready_false_times) - .return_const(()); ( server_factory, (shutdown_receivers, extra_shutdown_receivers), @@ -1287,7 +2046,7 @@ mod tests { } else { expect_times_called }) - .returning(move |_, _, _, _, _| { + .returning(move |_, _, _, _, _, _| { let mut router = MockMyRouterFactory::new(); router.expect_clone().return_once(MockMyRouterFactory::new); router.expect_web_endpoints().returning(MultiMap::new); @@ -1300,15 +2059,14 @@ mod tests { .expect_create() .times(expect_times_called - 1) .withf( - move |_, _configuration: &Arc, + move |_, + _configuration: &Arc, _, previous_router_service_factory: &Option<&MockMyRouterFactory>, - _extra_plugins: &Option)>>| - { - previous_router_service_factory.is_some() - }, + _extra_plugins: &Option)>>, + _| { previous_router_service_factory.is_some() }, ) - .returning(move |_, _, _, _, _| { + .returning(move |_, _, _, _, _, _| { let mut router = MockMyRouterFactory::new(); router.expect_clone().return_once(MockMyRouterFactory::new); router.expect_web_endpoints().returning(MultiMap::new); @@ -1318,4 +2076,22 @@ mod tests { router_factory } + + fn create_mock_router_configurator_for_reload_with_new_license( + expect_times_called: usize, + ) -> MockMyRouterConfigurator { + let mut router_factory = MockMyRouterConfigurator::new(); + + router_factory + .expect_create() + .times(expect_times_called) + .returning(move |_, _, _, _, _, _| { + let mut router = MockMyRouterFactory::new(); + router.expect_clone().return_once(MockMyRouterFactory::new); + router.expect_web_endpoints().returning(MultiMap::new); + Ok(router) + }); + + router_factory + } } diff --git a/apollo-router/src/test_harness.rs b/apollo-router/src/test_harness.rs index 516048e3d7..3c690c5e06 100644 --- a/apollo-router/src/test_harness.rs +++ b/apollo-router/src/test_harness.rs @@ -1,30 +1,36 @@ //! Test harness and mocks for the Apollo Router. use std::collections::HashMap; +use std::collections::HashSet; use std::default::Default; use std::str::FromStr; use std::sync::Arc; +use serde::de::Error as DeserializeError; +use serde::ser::Error as SerializeError; use tower::BoxError; use tower::ServiceBuilder; use tower::ServiceExt; use tower_http::trace::MakeSpan; use tracing_futures::Instrument; +use crate::AllowedFeature; use crate::axum_factory::span_mode; use crate::axum_factory::utils::PropagatingMakeSpan; use crate::configuration::Configuration; use crate::configuration::ConfigurationError; use crate::graphql; -use crate::plugin::test::canned; -use crate::plugin::test::MockSubgraph; use crate::plugin::DynPlugin; use crate::plugin::Plugin; use crate::plugin::PluginInit; use crate::plugin::PluginPrivate; use crate::plugin::PluginUnstable; +use crate::plugin::test::MockSubgraph; +use crate::plugin::test::canned; use crate::plugins::telemetry::reload::init_telemetry; use crate::router_factory::YamlRouterFactory; +use crate::services::HasSchema; +use crate::services::SupergraphCreator; use crate::services::execution; use crate::services::layers::persisted_queries::PersistedQueryLayer; use crate::services::layers::query_analysis::QueryAnalysisLayer; @@ -32,9 +38,8 @@ use crate::services::router; use crate::services::router::service::RouterCreator; use crate::services::subgraph; use crate::services::supergraph; -use crate::services::HasSchema; -use crate::services::SupergraphCreator; use crate::spec::Schema; +use crate::uplink::license_enforcement::LicenseLimits; use crate::uplink::license_enforcement::LicenseState; /// Mocks for services the Apollo Router must integrate with. @@ -43,6 +48,9 @@ pub mod mocks; #[cfg(test)] pub(crate) mod http_client; +#[cfg(any(test, feature = "snapshot"))] +pub(crate) mod http_snapshot; + /// Builder for the part of an Apollo Router that handles GraphQL requests, as a [`tower::Service`]. /// /// This allows tests, benchmarks, etc @@ -89,6 +97,7 @@ pub struct TestHarness<'a> { configuration: Option>, extra_plugins: Vec<(String, Box)>, subgraph_network_requests: bool, + license: Option>, } // Not using buildstructor because `extra_plugin` has non-trivial signature and behavior @@ -100,6 +109,7 @@ impl<'a> TestHarness<'a> { configuration: None, extra_plugins: Vec::new(), subgraph_network_requests: false, + license: None, } } @@ -150,18 +160,40 @@ impl<'a> TestHarness<'a> { self, configuration: serde_json::Value, ) -> Result { - let configuration: Configuration = serde_json::from_value(configuration)?; + // Convert from a json Value to yaml str to Configuration so that we can ensure we validate + // and populate the Configuration's validated_yaml attribute + let yaml = serde_yaml::to_string(&configuration).map_err(SerializeError::custom)?; + let configuration: Configuration = + Configuration::from_str(&yaml).map_err(DeserializeError::custom)?; Ok(self.configuration(Arc::new(configuration))) } - /// Specifies the (static) router configuration as a YAML string, - /// such as from the `serde_json::json!` macro. + /// Specifies the (static) router configuration as a YAML string pub fn configuration_yaml(self, configuration: &'a str) -> Result { let configuration: Configuration = Configuration::from_str(configuration)?; Ok(self.configuration(Arc::new(configuration))) } - /// Adds an extra, already instanciated plugin. + /// Specifies the (static) license. + /// + /// Panics if called more than once. + /// + /// If this isn't called, the default license is used. + pub fn license_from_allowed_features(mut self, allowed_features: Vec) -> Self { + assert!(self.license.is_none(), "license was specified twice"); + self.license = Some(Arc::new(LicenseState::Licensed { + limits: { + Some( + LicenseLimits::builder() + .allowed_features(HashSet::from_iter(allowed_features)) + .build(), + ) + }, + })); + self + } + + /// Adds an extra, already instantiated plugin. /// /// May be called multiple times. /// These extra plugins are added after plugins specified in configuration. @@ -176,7 +208,7 @@ impl<'a> TestHarness<'a> { ), }; - self.extra_plugins.push((name, Box::new(plugin))); + self.extra_plugins.push((name, plugin.into())); self } @@ -264,62 +296,77 @@ impl<'a> TestHarness<'a> { pub(crate) async fn build_common( self, - ) -> Result<(Arc, SupergraphCreator), BoxError> { - let builder = if self.schema.is_none() { - self.subgraph_hook(|subgraph_name, default| match subgraph_name { - "products" => canned::products_subgraph().boxed(), - "accounts" => canned::accounts_subgraph().boxed(), - "reviews" => canned::reviews_subgraph().boxed(), - _ => default, - }) - } else { - self - }; - let builder = if builder.subgraph_network_requests { - builder - } else { - builder.subgraph_hook(|_name, _default| { - tower::service_fn(|request: subgraph::Request| { - let empty_response = subgraph::Response::builder() - .extensions(crate::json_ext::Object::new()) - .context(request.context) - .id(request.id) - .build(); - std::future::ready(Ok(empty_response)) - }) - .boxed() - }) - }; - let config = builder.configuration.unwrap_or_default(); + ) -> Result<(Arc, Arc, SupergraphCreator), BoxError> { + let mut config = self.configuration.unwrap_or_default(); + let has_legacy_mock_subgraphs_plugin = self.extra_plugins.iter().any(|(_, dyn_plugin)| { + dyn_plugin.name() == *crate::plugins::mock_subgraphs::PLUGIN_NAME + }); + if self.schema.is_none() && !has_legacy_mock_subgraphs_plugin { + Arc::make_mut(&mut config) + .apollo_plugins + .plugins + .entry("experimental_mock_subgraphs") + .or_insert_with(canned::mock_subgraphs); + } + if !self.subgraph_network_requests { + Arc::make_mut(&mut config) + .apollo_plugins + .plugins + .entry("experimental_mock_subgraphs") + .or_insert(serde_json::json!({})); + } let canned_schema = include_str!("../testing_schema.graphql"); - let schema = builder.schema.unwrap_or(canned_schema); + let schema = self.schema.unwrap_or(canned_schema); let schema = Arc::new(Schema::parse(schema, &config)?); + // Default to using an unrestricted license + let license = self.license.unwrap_or(Arc::new(LicenseState::Licensed { + limits: Default::default(), + })); let supergraph_creator = YamlRouterFactory .inner_create_supergraph( config.clone(), - schema, + schema.clone(), None, - None, - Some(builder.extra_plugins), + Some(self.extra_plugins), + license, ) .await?; - Ok((config, supergraph_creator)) - } - - /// Builds the supergraph service - #[deprecated = "use build_supergraph instead"] - pub async fn build(self) -> Result { - self.build_supergraph().await + Ok((config, schema, supergraph_creator)) } /// Builds the supergraph service pub async fn build_supergraph(self) -> Result { - let (_config, supergraph_creator) = self.build_common().await?; + let (config, schema, supergraph_creator) = self.build_common().await?; - Ok(tower::service_fn(move |request| { + Ok(tower::service_fn(move |request: supergraph::Request| { let router = supergraph_creator.make(); + // The supergraph service expects a ParsedDocument in the context. In the real world, + // that is always populated by the router service. For the testing harness, however, + // tests normally craft a supergraph request manually, and it's inconvenient to + // manually populate the ParsedDocument. Instead of doing it many different ways + // over and over in different tests, we simulate that part of the router service here. + let body = request.supergraph_request.body(); + // If we don't have a query we definitely won't have a parsed document. + if let Some(query_str) = body.query.as_deref() { + let operation_name = body.operation_name.as_deref(); + if !request.context.extensions().with_lock(|lock| { + lock.contains_key::() + }) { + let doc = crate::spec::Query::parse_document( + query_str, + operation_name, + &schema, + &config, + ) + .expect("parse error in test"); + request.context.extensions().with_lock(|lock| { + lock.insert::(doc) + }); + } + } + async move { router.oneshot(request).await } }) .boxed_clone()) @@ -327,7 +374,7 @@ impl<'a> TestHarness<'a> { /// Builds the router service pub async fn build_router(self) -> Result { - let (config, supergraph_creator) = self.build_common().await?; + let (config, _schema, supergraph_creator) = self.build_common().await?; let router_creator = RouterCreator::new( QueryAnalysisLayer::new(supergraph_creator.schema(), Arc::clone(&config)).await, Arc::new(PersistedQueryLayer::new(&config).await.unwrap()), @@ -340,7 +387,7 @@ impl<'a> TestHarness<'a> { Ok(tower::service_fn(move |request: router::Request| { let router = ServiceBuilder::new().service(router_creator.make()).boxed(); let span = PropagatingMakeSpan { - license: LicenseState::default(), + license: Default::default(), span_mode: span_mode(&config), } .make_span(&request.router_request); @@ -349,13 +396,13 @@ impl<'a> TestHarness<'a> { .boxed_clone()) } - #[cfg(test)] - pub(crate) async fn build_http_service(self) -> Result { - use crate::axum_factory::tests::make_axum_router; + /// Build the HTTP service + pub async fn build_http_service(self) -> Result { use crate::axum_factory::ListenAddrAndRouter; + use crate::axum_factory::axum_http_server_factory::make_axum_router; use crate::router_factory::RouterFactory; - let (config, supergraph_creator) = self.build_common().await?; + let (config, _schema, supergraph_creator) = self.build_common().await?; let router_creator = RouterCreator::new( QueryAnalysisLayer::new(supergraph_creator.schema(), Arc::clone(&config)).await, Arc::new(PersistedQueryLayer::new(&config).await.unwrap()), @@ -366,15 +413,13 @@ impl<'a> TestHarness<'a> { let web_endpoints = router_creator.web_endpoints(); - let live = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let ready = Arc::new(std::sync::atomic::AtomicBool::new(false)); let routers = make_axum_router( - live, - ready, router_creator, &config, web_endpoints, - LicenseState::Unlicensed, + Arc::new(LicenseState::Licensed { + limits: Default::default(), + }), )?; let ListenAddrAndRouter(_listener, router) = routers.main; Ok(router.boxed()) @@ -382,10 +427,9 @@ impl<'a> TestHarness<'a> { } /// An HTTP-level service, as would be given to Hyper’s server -#[cfg(test)] -pub(crate) type HttpService = tower::util::BoxService< +pub type HttpService = tower::util::BoxService< http::Request, - http::Response, + http::Response, std::convert::Infallible, >; @@ -463,7 +507,7 @@ where } /// a list of subgraphs with pregenerated responses -#[derive(Default)] +#[derive(Default, Clone)] pub struct MockedSubgraphs(pub(crate) HashMap<&'static str, MockSubgraph>); impl MockedSubgraphs { @@ -531,13 +575,12 @@ pub fn make_fake_batch( // name from -> to. // If our request doesn't have an operation name or we weren't given an op_from_to, // just duplicate the request as is. - if let Some((from, to)) = op_from_to { - if let Some(operation_name) = &req.operation_name { - if operation_name == from { - new_req.query = req.query.clone().map(|q| q.replace(from, to)); - new_req.operation_name = Some(to.to_string()); - } - } + if let Some((from, to)) = op_from_to + && let Some(operation_name) = &req.operation_name + && operation_name == from + { + new_req.query = req.query.clone().map(|q| q.replace(from, to)); + new_req.operation_name = Some(to.to_string()); } let mut json_bytes_req = serde_json::to_vec(&req).unwrap(); @@ -548,6 +591,92 @@ pub fn make_fake_batch( result.push(b','); result.append(&mut json_bytes_new_req); result.push(b']'); - crate::services::router::Body::from(result) + router::body::from_bytes(result) }) } + +#[tokio::test] +async fn test_intercept_subgraph_network_requests() { + use futures::StreamExt; + let request = crate::services::supergraph::Request::canned_builder() + .build() + .unwrap(); + let response = TestHarness::builder() + .schema(include_str!("../testing_schema.graphql")) + .configuration_json(serde_json::json!({ + "include_subgraph_errors": { + "all": true + } + })) + .unwrap() + .build_router() + .await + .unwrap() + .oneshot(request.try_into().unwrap()) + .await + .unwrap() + .into_graphql_response_stream() + .await + .next() + .await + .unwrap() + .unwrap(); + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "topProducts": null + }, + "errors": [ + { + "message": "subgraph mock not configured", + "path": [], + "extensions": { + "code": "SUBGRAPH_MOCK_NOT_CONFIGURED", + "service": "products" + } + } + ] + } + "###); +} + +/// This module should be used in place of the `::tracing_test::traced_test` macro, +/// which instantiates a global subscriber via a `OnceLock`, causing test failures. +/// +/// # Examples +/// +/// ```rust +/// use crate::test_harness:tracing_test; +/// fn test_logs_are_captured() { +/// let _guard = tracing_test::dispatcher_guard(); +/// +/// // explicit call, but this could also be a router call etc +/// tracing::info!("hello world"); +/// +/// assert!(tracing_test::logs_contain("hello world")); +/// } +/// ``` +/// +/// # Notes +/// This relies on the internal implementation details of the `tracing_test` crate. +#[cfg(test)] +pub(crate) mod tracing_test { + use tracing_core::dispatcher::DefaultGuard; + + /// Create and return a `tracing` subscriber to be used in tests. + pub(crate) fn dispatcher_guard() -> DefaultGuard { + let mock_writer = + ::tracing_test::internal::MockWriter::new(::tracing_test::internal::global_buf()); + let subscriber = + ::tracing_test::internal::get_subscriber(mock_writer, "apollo_router=trace"); + tracing::dispatcher::set_default(&subscriber) + } + + pub(crate) fn logs_with_scope_contain(scope: &str, value: &str) -> bool { + ::tracing_test::internal::logs_with_scope_contain(scope, value) + } + + pub(crate) fn logs_contain(value: &str) -> bool { + logs_with_scope_contain("apollo_router", value) + } +} diff --git a/apollo-router/src/test_harness/http_client.rs b/apollo-router/src/test_harness/http_client.rs index 3486ee079b..ba84e7c34e 100644 --- a/apollo-router/src/test_harness/http_client.rs +++ b/apollo-router/src/test_harness/http_client.rs @@ -3,13 +3,11 @@ use std::pin::Pin; use std::task::Poll; use async_compression::tokio::bufread::BrotliDecoder; -use axum::body::BoxBody; -use futures::stream::poll_fn; +use axum::body::Body; use futures::Future; use futures::Stream; use futures::StreamExt; use http::HeaderValue; -use http_body::Body; use mediatype::MediaType; use mediatype::ReadParams; use mime::APPLICATION_JSON; @@ -20,7 +18,11 @@ use tower::BoxError; use tower::Service; use tower::ServiceBuilder; +use crate::services::router; +use crate::services::router::body::RouterBody; + /// Added by `response_decompression` to `http::Response::extensions` +#[derive(Clone)] pub(crate) struct ResponseBodyWasCompressed(pub(crate) bool); pub(crate) enum MaybeMultipart { @@ -53,7 +55,7 @@ pub(crate) fn response_decompression( > where InnerService: - Service, Response = http::Response, Error = BoxError>, + Service, Response = http::Response, Error = BoxError>, { ServiceBuilder::new() .map_request(|mut request: http::Request| { @@ -62,12 +64,11 @@ where .insert("accept-encoding", "br".try_into().unwrap()); request }) - .map_response(|response: http::Response| { + .map_response(|response: http::Response| { let mut response = response.map(|body| { - // Convert from axum’s BoxBody to AsyncBufRead - let mut body = Box::pin(body); - let stream = poll_fn(move |ctx| body.as_mut().poll_data(ctx)) - .map(|result| result.map_err(|e| io::Error::new(io::ErrorKind::Other, e))); + let stream = body + .into_data_stream() + .map(|result| result.map_err(io::Error::other)); StreamReader::new(stream) }); let content_encoding = response.headers().get("content-encoding"); @@ -101,10 +102,10 @@ pub(crate) fn defer_spec_20220824_multipart( > where InnerService: Service< - http::Request, - Response = http::Response>>, - Error = BoxError, - >, + http::Request, + Response = http::Response>>, + Error = BoxError, + >, { ServiceBuilder::new() .map_request(|mut request: http::Request| { @@ -243,10 +244,10 @@ pub(crate) fn json( > where InnerService: Service< - http::Request, - Response = http::Response>>, - Error = BoxError, - >, + http::Request, + Response = http::Response>>, + Error = BoxError, + >, { ServiceBuilder::new() .map_request(|mut request: http::Request| { @@ -254,7 +255,7 @@ where "content-type", HeaderValue::from_static(APPLICATION_JSON.essence_str()), ); - request.map(|body| serde_json::to_vec(&body).unwrap().into()) + request.map(|body| router::body::from_bytes(serde_json::to_vec(&body).unwrap())) }) .map_response(|response: http::Response>>| { let (parts, body) = response.into_parts(); diff --git a/apollo-router/src/test_harness/http_snapshot.rs b/apollo-router/src/test_harness/http_snapshot.rs new file mode 100644 index 0000000000..125f703656 --- /dev/null +++ b/apollo-router/src/test_harness/http_snapshot.rs @@ -0,0 +1,697 @@ +//! Snapshot server to capture and replay HTTP responses. This is useful for: +//! +//! * Capturing HTTP responses from a real API or server, and replaying them in tests +//! * Mocking responses from a non-existent HTTP API for testing +//! * Working offline by capturing output from a server, and replaying it +//! +//! For example, this can be used with the router `override_subgraph_url` to replay recorded +//! responses from GraphQL subgraphs. Or it can be used with `override_url` in Connectors, to +//! record the HTTP responses from an external REST API. This allows the replayed responses to +//! be used in tests, or even in Apollo Sandbox to work offline or avoid hitting the REST API +//! too frequently. +//! +//! The snapshot server can be started from tests by calling the [`SnapshotServer::spawn`] method, +//! or as a standalone application by invoking [`standalone::main`]. In the latter case, there +//! is a binary wrapper in `http_snapshot_main` that can be run like this: +//! +//! `cargo run --bin snapshot --features snapshot -- --snapshot-path --url [--offline] [--update] [--port ] [-v]` +//! +//! Any requests made to the snapshot server will be proxied on to the given base URL, and the +//! responses will be saved to the given file. The next time the snapshot server receives the +//! same request, it will respond with the response recorded in the file rather than sending the +//! request to the upstream server. +//! +//! The snapshot file can be manually edited to manipulate responses for testing purposes, or to +//! redact information that you don't want to include in source-controlled snapshot files. +//! +//! Requests are matched to snapshots based on the URL path, HTTP method, and base64 encoding of +//! the request body (if there is one). If the snapshot specifies the `path` field, the URL path +//! must match exactly. Alternatively, a snapshot containing a `regex` field will match any URL +//! path that matches the regular expression. A snapshot with an exact `path` match takes +//! precedence over a snapshot with `regex`. Snapshots recorded by the server will always specify +//! `path`. The only way to use `regex` is to manually edit a snapshot file. A typical pattern is +//! to record a snapshot from a REST API, then manually change `path` to `regex` and replace the +//! variable part of the path with `.*`. Note that any special characters in the path that have +//! meaning to the `regex` crate must be escaped with `\\`, such as the `?` in a URL query +//! parameter. +//! +//! The offline mode will never call the upstream server, and will always return a saved snapshot +//! response. If one is not available, a `500` error is returned. This is useful for tests, for +//! example to ensure that CI builds never attempt to access the network. +//! +//! The update mode can be used to force an update of recorded snapshots, even if there is already +//! a snapshot saved in the file. This overrides the offline mode, and is useful to update tests +//! when a change is made to the upstream HTTP responses. +//! +//! The set of response headers returned can be filtered by supplying a list of headers to include. +//! This is typically desirable, as headers may contain ephemeral information like dates or tokens. +//! +//! **IMPORTANT:** this module stores HTTP responses to the local file system in plain text. It +//! should not be used with APIs that return sensitive data. + +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::net::TcpListener; +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; + +use axum::Router; +use axum::extract::Path as AxumPath; +use axum::extract::RawQuery; +use axum::extract::State; +use axum::routing::any; +use base64::Engine; +use http::HeaderMap; +use http::HeaderName; +use http::HeaderValue; +use http::Method; +use http::Uri; +use http::header::CONNECTION; +use http::header::CONTENT_LENGTH; +use http::header::HOST; +use http::header::TRAILER; +use http::header::TRANSFER_ENCODING; +use http::header::UPGRADE; +use hyper::StatusCode; +use hyper_rustls::ConfigBuilderExt; +use indexmap::IndexMap; +use parking_lot::Mutex; +use regex::Regex; +use serde::Deserialize; +use serde::Serialize; +use serde_json_bytes::Value; +use serde_json_bytes::json; +use tower::ServiceExt; +use tracing::debug; +use tracing::error; +use tracing::info; +use tracing::warn; + +use crate::configuration::shared::Client; +use crate::services::http::HttpClientService; +use crate::services::http::HttpRequest; +use crate::services::router; +use crate::services::router::body::RouterBody; + +/// Headers that will not be passed on to the upstream API +static FILTERED_HEADERS: [HeaderName; 6] = [ + CONNECTION, + TRAILER, + TRANSFER_ENCODING, + UPGRADE, + HOST, + HeaderName::from_static("keep-alive"), +]; + +/// An error from the snapshot server +#[derive(Debug, thiserror::Error)] +enum SnapshotError { + /// Unable to load snapshots + #[error("unable to load snapshot file - {0}")] + IoError(#[from] std::io::Error), + /// Unable to parse snapshots + #[error("unable to parse snapshots - {0}")] + ParseError(#[from] serde_json::Error), + /// Invalid snapshot + #[error("invalid snapshot - {0}")] + InvalidSnapshot(String), +} + +/// A server that mocks an API using snapshots recorded from actual HTTP responses. +#[cfg_attr(test, allow(unreachable_pub))] +pub struct SnapshotServer { + // The socket address the server is listening on + #[cfg_attr(not(test), allow(dead_code))] + socket_address: SocketAddr, +} + +#[derive(Clone)] +struct SnapshotServerState { + client: HttpClientService, + base_url: Uri, + snapshots_by_key: Arc>>, + snapshots_by_regex: Arc>>, + snapshot_file: Box, + offline: bool, + update: bool, + include_headers: Option>, +} + +async fn root_handler( + State(state): State, + req: http::Request, +) -> Result, StatusCode> { + handle(State(state), req, "/".to_string(), None).await +} + +async fn handler( + State(state): State, + AxumPath(path): AxumPath, + RawQuery(query): RawQuery, + req: http::Request, +) -> Result, StatusCode> { + handle(State(state), req, path, query).await +} + +async fn handle( + State(state): State, + req: http::Request, + path: String, + query: Option, +) -> Result, StatusCode> { + let path = if let Some(query) = query { + format!("{path}?{query}") + } else { + path + }; + let uri = [state.base_url.to_string(), path.clone()].concat(); + let method = req.method().clone(); + let version = req.version(); + let request_headers: HeaderMap = req + .headers() + .clone() + .drain() + .filter_map(|(name, value)| { + name.and_then(|name| { + if !FILTERED_HEADERS.contains(&name) { + Some((name, value)) + } else { + None + } + }) + }) + .collect(); + let body_bytes = axum::body::to_bytes(req.into_body(), usize::MAX) + .await + .unwrap(); + let request_json_body = serde_json::from_slice(&body_bytes).unwrap_or(Value::Null); + + let key = snapshot_key( + Some(method.as_str()), + Some(path.as_str()), + &request_json_body, + ); + + if let Some(response) = response_from_snapshot(&state, &uri, &method, &key) { + Ok(response) + } else if state.offline && !state.update { + fail( + uri, + method, + "Offline mode enabled and no snapshot available", + ) + } else { + debug!( + url = %uri, + method = %method, + "Taking snapshot" + ); + let mut request = http::Request::builder() + .method(method.clone()) + .version(version) + .uri(uri.clone()) + .body(router::body::from_bytes(body_bytes)) + .unwrap(); + *request.headers_mut() = request_headers; + let response = state + .client + .oneshot(HttpRequest { + http_request: request, + context: crate::context::Context::new(), + }) + .await + .unwrap(); + let (parts, body) = response.http_response.into_parts(); + + if let Ok(body_bytes) = router::body::into_bytes(body).await { + if let Ok(response_json_body) = serde_json::from_slice(&body_bytes) { + let snapshot = Snapshot { + request: Request { + method: Some(method.to_string()), + path: Some(path), + regex: None, + body: request_json_body, + }, + response: Response { + status: parts.status.as_u16(), + headers: map_headers(parts.headers, |name| { + state + .include_headers + .as_ref() + .map(|headers| headers.contains(&name.to_string())) + .unwrap_or(true) + }), + body: response_json_body, + }, + }; + { + let mut snapshots_by_key = state.snapshots_by_key.lock(); + let mut snapshots_by_regex = state.snapshots_by_regex.lock(); + snapshots_by_key.insert(key, snapshot.clone()); + if let Err(e) = save( + state.snapshot_file, + &mut snapshots_by_key, + &mut snapshots_by_regex, + ) { + error!( + url = %uri, + method = %method, + error = ?e, + "Unable to save snapshot" + ); + } + } + if let Ok(response) = snapshot.into_response() { + Ok(response) + } else { + fail(uri, method, "Unable to convert snapshot into response body") + } + } else { + fail(uri, method, "Unable to parse response body as JSON") + } + } else { + fail(uri, method, "Unable to read response body") + } + } +} + +fn response_from_snapshot( + state: &SnapshotServerState, + uri: &String, + method: &Method, + key: &String, +) -> Option> { + let mut snapshots_by_key = state.snapshots_by_key.lock(); + let snapshots_by_regex = state.snapshots_by_regex.lock(); + if state.update { + snapshots_by_key.remove(key); + None + } else { + snapshots_by_key + .get(key) + .inspect(|snapshot| { + debug!( + url = %uri, + method = %method, + path = %snapshot.request.path.as_ref().unwrap_or(&String::from("")), + "Found existing snapshot" + ); + }) + .or_else(|| { + // Look up snapshot using regex + for snapshot in snapshots_by_regex.iter() { + if let Some(regex) = &snapshot.request.regex + && regex.is_match(uri) + { + debug!( + url = %uri, + method = %method, + regex = %regex.to_string(), + "Found existing snapshot" + ); + return Some(snapshot); + } + } + None + }) + .and_then(|snapshot| { + snapshot + .clone() + .into_response() + .map_err(|e| error!("Unable to convert snapshot into HTTP response: {:?}", e)) + .ok() + }) + } +} + +fn fail( + uri: String, + method: Method, + message: &str, +) -> Result, StatusCode> { + error!( + url = %uri, + method = %method, + message + ); + http::Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(router::body::from_bytes( + json!({ "error": message}).to_string(), + )) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn map_headers bool>( + headers: HeaderMap, + include: F, +) -> IndexMap> { + headers.iter().fold( + IndexMap::new(), + |mut map: IndexMap>, (name, value)| { + let name = name.to_string(); + if include(&name) { + let value = value.to_str().unwrap_or_default().to_string(); + map.entry(name).or_default().push(value); + } + map + }, + ) +} + +fn save>( + path: P, + snapshots_by_key: &mut BTreeMap, + snapshots_by_regex: &mut [Snapshot], +) -> Result<(), SnapshotError> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let snapshots = snapshots_by_key + .values() + .cloned() + .chain(snapshots_by_regex.iter().cloned()) + .collect::>(); + std::fs::write(path, serde_json::to_string_pretty(&snapshots)?).map_err(Into::into) +} + +fn load>( + path: P, +) -> Result<(BTreeMap, Vec), SnapshotError> { + let str = std::fs::read_to_string(path)?; + let snapshots: Vec = serde_json::from_str(&str)?; + let mut snapshots_by_key: BTreeMap = Default::default(); + let mut snapshots_by_regex: Vec = Default::default(); + for snapshot in snapshots.into_iter() { + if snapshot.request.regex.is_some() { + if snapshot.request.path.is_some() { + return Err(SnapshotError::InvalidSnapshot(String::from( + "snapshot cannot specify both regex and path", + ))); + } + snapshots_by_regex.push(snapshot); + } else { + snapshots_by_key.insert(snapshot.key(), snapshot); + } + } + Ok((snapshots_by_key, snapshots_by_regex)) +} + +impl SnapshotServer { + /// Spawn the server in a new task and return. Used for tests. + #[cfg_attr(test, allow(unreachable_pub))] + pub async fn spawn>( + snapshot_path: P, + base_url: Uri, + offline: bool, + update: bool, + include_headers: Option>, + port: Option, + ) -> Self { + let listener = port.map(|port| { + TcpListener::bind(format!("127.0.0.1:{port}")) + .expect("Failed to bind an OS port for snapshot server") + }); + Self::inner_start( + snapshot_path, + base_url, + true, + offline, + update, + include_headers, + listener, + ) + .await + } + + /// Start the server and block. Can be used to run the server as a standalone application. + pub(crate) async fn start>( + snapshot_path: P, + base_url: Uri, + offline: bool, + update: bool, + include_headers: Option>, + listener: Option, + ) -> Self { + Self::inner_start( + snapshot_path, + base_url, + false, + offline, + update, + include_headers, + listener, + ) + .await + } + + /// Get the URI the server is listening at + #[cfg_attr(not(test), allow(dead_code))] + #[cfg_attr(test, allow(unreachable_pub))] + pub fn uri(&self) -> String { + format!("http://{}", self.socket_address) + } + + async fn inner_start>( + snapshot_path: P, + base_url: Uri, + spawn: bool, + offline: bool, + update: bool, + include_headers: Option>, + listener: Option, + ) -> Self { + if update { + info!("Running in update mode ⬆️"); + } else if offline { + info!("Running in offline mode ⛔️"); + } + + let snapshot_file = snapshot_path.as_ref(); + + let (snapshots_by_key, snapshots_by_regex) = match load(snapshot_file) { + Err(SnapshotError::IoError(ioe)) if ioe.kind() == std::io::ErrorKind::NotFound => { + if offline { + warn!("Snapshot file not found in offline mode - all requests will fail"); + } else { + info!("Snapshot file not found - new snapshot file will be recorded"); + } + (BTreeMap::default(), vec![]) + } + Err(e) => { + if offline { + warn!( + "Unable to load snapshot file in offline mode - all requests will fail: {e}" + ); + } else { + warn!("Unable to load snapshot file - new snapshot file will be recorded: {e}"); + } + (BTreeMap::default(), vec![]) + } + Ok((snapshots_by_key, snapshots_by_regex)) => { + info!( + "Loaded {} snapshots", + snapshots_by_key.len() + snapshots_by_regex.len() + ); + (snapshots_by_key, snapshots_by_regex) + } + }; + + let http_service = HttpClientService::new( + "test", + rustls::ClientConfig::builder() + .with_native_roots() + .expect("Able to load native roots") + .with_no_client_auth(), + Client::builder().build(), + ) + .expect("can create a HttpService"); + let app = Router::new() + .route("/", any(root_handler)) + .route("/{*path}", any(handler)) // won't match root, so we need the root handler above + .with_state(SnapshotServerState { + client: http_service, + base_url: base_url.clone(), + snapshots_by_key: Arc::new(Mutex::new(snapshots_by_key)), + snapshots_by_regex: Arc::new(Mutex::new(snapshots_by_regex)), + snapshot_file: Box::from(snapshot_file), + offline, + update, + include_headers, + }); + let listener = listener.unwrap_or( + TcpListener::bind("127.0.0.1:0") + .expect("Failed to bind an OS port for snapshot server"), + ); + let local_address = listener + .local_addr() + .expect("Failed to get snapshot server address."); + info!( + "Snapshot server listening on port {:?}", + local_address.port() + ); + if spawn { + tokio::spawn(async move { + axum_server::Server::from_tcp(listener) + .serve(app.into_make_service()) + .await + .unwrap(); + }); + } else { + axum_server::from_tcp(listener) + .serve(app.into_make_service()) + .await + .unwrap(); + } + Self { + socket_address: local_address, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Snapshot { + request: Request, + response: Response, +} + +impl Snapshot { + fn into_response(self) -> Result, ()> { + let mut response = http::Response::builder().status(self.response.status); + let body_string = self.response.body.to_string(); + if let Some(headers) = response.headers_mut() { + for (name, values) in self.response.headers.into_iter() { + if let Ok(name) = HeaderName::from_str(&name.clone()) { + for value in values { + if let Ok(value) = HeaderValue::from_str(&value.clone()) { + headers.insert(name.clone(), value); + } + } + } else { + warn!("Invalid header name `{}` in snapshot", name); + } + } + + // Rewrite the content length header to the actual body length. Serializing and + // deserializing the snapshot may result in a different length due to formatting + // differences. + headers.insert(CONTENT_LENGTH, HeaderValue::from(body_string.len())); + } + if let Ok(response) = response.body(router::body::from_bytes(body_string)) { + return Ok(response); + } + Err(()) + } + + fn key(&self) -> String { + snapshot_key( + self.request.method.as_deref(), + self.request.path.as_deref(), + &self.request.body, + ) + } +} + +fn snapshot_key(method: Option<&str>, path: Option<&str>, body: &Value) -> String { + if body.is_null() { + format!("{}-{}", method.unwrap_or("GET"), path.unwrap_or("/")) + } else { + let body = base64::engine::general_purpose::STANDARD.encode(body.to_string()); + format!( + "{}-{}-{}", + method.unwrap_or("GET"), + path.unwrap_or("/"), + body, + ) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Request { + method: Option, + path: Option, + #[serde(with = "serde_regex", skip_serializing_if = "Option::is_none", default)] + regex: Option, + body: Value, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Response { + status: u16, + #[serde(default)] + headers: IndexMap>, + body: Value, +} + +/// Standalone snapshot server +pub(crate) mod standalone { + use std::net::TcpListener; + use std::path::PathBuf; + + use clap::Parser; + use http::Uri; + use tracing_core::Level; + + use super::SnapshotServer; + + #[derive(Parser, Debug)] + #[clap(name = "snapshot", about = "Apollo snapshot server")] + #[command(disable_version_flag(true))] + struct Args { + /// Snapshot location relative to the project directory. + #[arg(short, long, value_parser)] + snapshot_path: PathBuf, + + /// Base URL for the server. + #[arg(short = 'l', long, value_parser)] + url: Uri, + + /// Run in offline mode, without making any HTTP requests to the base URL. + #[arg(short, long)] + offline: bool, + + /// Force snapshot updates (overrides `offline`). + #[arg(short, long)] + update: bool, + + /// Optional port to listen on (defaults to an ephemeral port). + #[arg(short, long)] + port: Option, + + /// Turn on verbose output + #[arg(short = 'v', long)] + verbose: bool, + } + + /// Run the snapshot server as a standalone application + pub async fn main() { + let args = Args::parse(); + + let subscriber = tracing_subscriber::FmtSubscriber::builder() + .with_max_level(if args.verbose { + Level::DEBUG + } else { + Level::INFO + }) + .finish(); + tracing::subscriber::set_global_default(subscriber) + .expect("setting default subscriber failed"); + + let listener = args.port.map(|port| { + TcpListener::bind(format!("127.0.0.1:{port}")) + .expect("Failed to bind an OS port for snapshot server") + }); + + SnapshotServer::start( + args.snapshot_path, + args.url, + args.offline, + args.update, + None, + listener, + ) + .await; + } +} diff --git a/apollo-router/src/test_harness/http_snapshot_main.rs b/apollo-router/src/test_harness/http_snapshot_main.rs new file mode 100644 index 0000000000..27465f184c --- /dev/null +++ b/apollo-router/src/test_harness/http_snapshot_main.rs @@ -0,0 +1,6 @@ +use apollo_router::snapshot_server; + +#[tokio::main] +async fn main() { + snapshot_server().await +} diff --git a/apollo-router/src/test_harness/mocks/persisted_queries.rs b/apollo-router/src/test_harness/mocks/persisted_queries.rs index cdf8b0e5f9..da32aa50b9 100644 --- a/apollo-router/src/test_harness/mocks/persisted_queries.rs +++ b/apollo-router/src/test_harness/mocks/persisted_queries.rs @@ -1,38 +1,45 @@ -use std::collections::HashMap; use std::time::Duration; use async_compression::tokio::write::GzipEncoder; -use maplit::hashmap; use serde::Deserialize; use serde::Serialize; use serde_json::json; use tokio::io::AsyncWriteExt; use url::Url; -use wiremock::matchers::header; -use wiremock::matchers::method; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +pub use crate::services::layers::persisted_queries::FullPersistedQueryOperationId; +pub use crate::services::layers::persisted_queries::ManifestOperation; +pub use crate::services::layers::persisted_queries::PersistedQueryManifest; use crate::uplink::Endpoints; use crate::uplink::UplinkConfig; /// Get a query ID, body, and a PQ manifest with that ID and body. -pub fn fake_manifest() -> (String, String, HashMap) { +pub fn fake_manifest() -> (String, String, PersistedQueryManifest) { let id = "1234".to_string(); let body = r#"query { typename }"#.to_string(); - let manifest = hashmap! { id.to_string() => body.to_string() }; + + let manifest = PersistedQueryManifest::from(vec![ManifestOperation { + id: id.clone(), + body: body.clone(), + client_name: None, + }]); + (id, body, manifest) } /// Mocks an uplink server with a persisted query list containing no operations. pub async fn mock_empty_pq_uplink() -> (UplinkMockGuard, UplinkConfig) { - mock_pq_uplink(&HashMap::new()).await + mock_pq_uplink(&PersistedQueryManifest::default()).await } /// Mocks an uplink server with a persisted query list with a delay. pub async fn mock_pq_uplink_with_delay( - manifest: &HashMap, + manifest: &PersistedQueryManifest, delay: Duration, ) -> (UplinkMockGuard, UplinkConfig) { let (guard, url) = mock_pq_uplink_one_endpoint(manifest, Some(delay)).await; @@ -43,7 +50,7 @@ pub async fn mock_pq_uplink_with_delay( } /// Mocks an uplink server with a persisted query list containing operations passed to this function. -pub async fn mock_pq_uplink(manifest: &HashMap) -> (UplinkMockGuard, UplinkConfig) { +pub async fn mock_pq_uplink(manifest: &PersistedQueryManifest) -> (UplinkMockGuard, UplinkConfig) { let (guard, url) = mock_pq_uplink_one_endpoint(manifest, None).await; ( guard, @@ -58,22 +65,29 @@ pub struct UplinkMockGuard { } #[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] struct Operation { id: String, body: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + client_name: Option, } /// Mocks an uplink server; returns a single Url rather than a full UplinkConfig, so you /// can combine it with another one to test failover. pub async fn mock_pq_uplink_one_endpoint( - manifest: &HashMap, + manifest: &PersistedQueryManifest, delay: Option, ) -> (UplinkMockGuard, Url) { let operations: Vec = manifest // clone the manifest so the caller can still make assertions about it .clone() .drain() - .map(|(id, body)| Operation { id, body }) + .map(|(full_id, body)| Operation { + id: full_id.operation_id, + body, + client_name: full_id.client_name, + }) .collect(); let mock_gcs_server = MockServer::start().await; diff --git a/apollo-router/src/testdata/jaeger.router.yaml b/apollo-router/src/testdata/jaeger.router.yaml index 0f7c367fe9..d070a55309 100644 --- a/apollo-router/src/testdata/jaeger.router.yaml +++ b/apollo-router/src/testdata/jaeger.router.yaml @@ -13,22 +13,11 @@ telemetry: jaeger: true common: service_name: router - jaeger: + otlp: enabled: true batch_processor: scheduled_delay: 100ms - agent: - endpoint: default - logging: - experimental_when_header: - - name: apollo-router-log-request - value: test - headers: true # default: false - body: true # default: false - # log request for all requests coming from Iphones - - name: custom-header - match: ^foo.* - headers: true + endpoint: default override_subgraph_url: products: http://localhost:4005 include_subgraph_errors: diff --git a/apollo-router/src/testdata/orga_supergraph_cache_key.graphql b/apollo-router/src/testdata/orga_supergraph_cache_key.graphql new file mode 100644 index 0000000000..2852d156ac --- /dev/null +++ b/apollo-router/src/testdata/orga_supergraph_cache_key.graphql @@ -0,0 +1,117 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { + query: Query + subscription: Subscription +} + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @join__graph(name: String!, url: String!) on ENUM_VALUE +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION +scalar join__DirectiveArguments + +scalar join__FieldSet +scalar link__Import + +enum join__Graph { + USER @join__graph(name: "user", url: "http://localhost:4001/graphql") + ORGA @join__graph(name: "orga", url: "http://localhost:4002/graphql") +} + +enum link__Purpose { + SECURITY + EXECUTION +} + +type Query @join__type(graph: USER) @join__type(graph: ORGA) { + currentUser: User + @join__field(graph: USER) + @join__directive( + graphs: [USER] + name: "federation__cacheTag" + args: { format: "currentUser" } + ) + otherUser: User @join__field(graph: USER) + orga(id: ID): Organization @join__field(graph: ORGA) +} + +type Subscription @join__type(graph: USER) { + userWasCreated: User +} + +type User + @join__type(graph: ORGA, key: "id") + @join__type(graph: USER, key: "id") + @join__directive( + graphs: [USER, ORGA] + name: "federation__cacheTag" + args: { format: "user" } + ) + @join__directive( + graphs: [USER, ORGA] + name: "federation__cacheTag" + args: { format: "user-{$key.id}" } + ) { + id: ID! + name: String @join__field(graph: USER) + phone: String @join__field(graph: USER) + activeOrganization: Organization @join__field(graph: USER) + allOrganizations: [Organization] @join__field(graph: USER) +} + +type Organization + @join__type(graph: ORGA, key: "id") + @join__type(graph: USER, key: "id") + @join__directive( + graphs: [USER, ORGA] + name: "federation__cacheTag" + args: { format: "organization" } + ) + @join__directive( + graphs: [USER, ORGA] + name: "federation__cacheTag" + args: { format: "organization-{$key.id}" } + ) { + id: ID + creatorUser: User @join__field(graph: ORGA) + name: String @join__field(graph: ORGA) + nonNullId: ID! @join__field(graph: ORGA) + suborga: [Organization] @join__field(graph: ORGA) +} diff --git a/apollo-router/src/testdata/supergraph_cache_key.graphql b/apollo-router/src/testdata/supergraph_cache_key.graphql new file mode 100644 index 0000000000..d8377956ec --- /dev/null +++ b/apollo-router/src/testdata/supergraph_cache_key.graphql @@ -0,0 +1,149 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { + query: Query +} + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION +scalar join__DirectiveArguments + +scalar join__FieldSet + +enum join__Graph { + ACCOUNTS + @join__graph( + name: "accounts" + url: "https://accounts.demo.starstuff.dev/" + ) + INVENTORY + @join__graph( + name: "inventory" + url: "https://inventory.demo.starstuff.dev/" + ) + PRODUCTS + @join__graph( + name: "products" + url: "https://products.demo.starstuff.dev/" + ) + REVIEWS + @join__graph( + name: "reviews" + url: "https://reviews.demo.starstuff.dev/" + ) +} + +scalar link__Import + +enum link__Purpose { + SECURITY + EXECUTION +} + +type Product + @join__type(graph: INVENTORY, key: "upc") + @join__type(graph: PRODUCTS, key: "upc") + @join__type(graph: REVIEWS, key: "upc") + @join__directive( + graphs: [PRODUCTS, INVENTORY, REVIEWS] + name: "federation__cacheTag" + args: { format: "product-{$key.upc}" } + ) + @join__directive( + graphs: [PRODUCTS, INVENTORY, REVIEWS] + name: "federation__cacheTag" + args: { format: "product" } + ) { + upc: String! + weight: Int + @join__field(graph: INVENTORY, external: true) + @join__field(graph: PRODUCTS) + price: Int + @join__field(graph: INVENTORY, external: true) + @join__field(graph: PRODUCTS) + inStock: Boolean @join__field(graph: INVENTORY) + shippingEstimate: Int + @join__field(graph: INVENTORY, requires: "price weight") + name: String @join__field(graph: PRODUCTS) + reviews: [Review] @join__field(graph: REVIEWS) +} + +type Query + @join__type(graph: ACCOUNTS) + @join__type(graph: INVENTORY) + @join__type(graph: PRODUCTS) + @join__type(graph: REVIEWS) { + me: User @join__field(graph: ACCOUNTS) + topProducts(first: Int = 5): [Product] + @join__field(graph: PRODUCTS) + @join__directive( + graphs: [PRODUCTS] + name: "federation__cacheTag" + args: { format: "topProducts" } + ) + @join__directive( + graphs: [PRODUCTS] + name: "federation__cacheTag" + args: { format: "topProducts-{$args.first}" } + ) +} + +type Review @join__type(graph: REVIEWS, key: "id") { + id: ID! + body: String + author: User @join__field(graph: REVIEWS, provides: "username") + product: Product @join__field(graph: REVIEWS) +} + +type User + @join__type(graph: ACCOUNTS, key: "id") + @join__type(graph: REVIEWS, key: "id") { + id: ID! + name: String @join__field(graph: ACCOUNTS) + username: String + @join__field(graph: ACCOUNTS) + @join__field(graph: REVIEWS, external: true) + reviews: [Review] @join__field(graph: REVIEWS) +} diff --git a/apollo-router/src/testdata/supergraph_nested_fields.graphql b/apollo-router/src/testdata/supergraph_nested_fields.graphql new file mode 100644 index 0000000000..914c5cc057 --- /dev/null +++ b/apollo-router/src/testdata/supergraph_nested_fields.graphql @@ -0,0 +1,82 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type Country + @join__type(graph: PRODUCTS, key: "a") + @join__type(graph: USERS, key: "a") +{ + a: String + b: String +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +enum join__Graph { + PRODUCTS @join__graph(name: "products", url: "http://localhost:4001/graphql") + USERS @join__graph(name: "users", url: "http://localhost:4003/graphql") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Product + @join__type(graph: PRODUCTS, key: "id") + @join__type(graph: PRODUCTS, key: "sku") +{ + id: ID! + sku: String + name: String + createdBy: User +} + +type Query + @join__type(graph: PRODUCTS) + @join__type(graph: USERS) +{ + allProducts: [Product] @join__field(graph: PRODUCTS) + product(id: ID!): Product @join__field(graph: PRODUCTS) +} + +type User + @join__type(graph: PRODUCTS, key: "email country { a }") + @join__type(graph: PRODUCTS, key: "email country { b }") + @join__type(graph: USERS, key: "email country { a }") + @join__type(graph: USERS, key: "email country { b }") +{ + email: ID! + country: Country + name: String @join__field(graph: USERS) +} diff --git a/apollo-router/src/testdata/supergraph_nested_fields_cache_key.graphql b/apollo-router/src/testdata/supergraph_nested_fields_cache_key.graphql new file mode 100644 index 0000000000..7c4525a9e2 --- /dev/null +++ b/apollo-router/src/testdata/supergraph_nested_fields_cache_key.graphql @@ -0,0 +1,122 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +type Country + @join__type(graph: PRODUCTS, key: "a") + @join__type(graph: USERS, key: "a") { + a: String + b: String +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +enum join__Graph { + PRODUCTS + @join__graph(name: "products", url: "http://localhost:4001/graphql") + USERS @join__graph(name: "users", url: "http://localhost:4003/graphql") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Product + @join__type(graph: PRODUCTS, key: "id") + @join__type(graph: PRODUCTS, key: "sku") + @join__directive( + graphs: [PRODUCTS] + name: "federation__cacheTag" + args: { format: "product" } + ) { + id: ID! + sku: String + name: String + createdBy: User +} + +type Query @join__type(graph: PRODUCTS) @join__type(graph: USERS) { + allProducts: [Product] + @join__field(graph: PRODUCTS) + @join__directive( + graphs: [PRODUCTS] + name: "federation__cacheTag" + args: { format: "allProducts" } + ) + product(id: ID!): Product @join__field(graph: PRODUCTS) +} + +type User + @join__type(graph: PRODUCTS, key: "email country { a }") + @join__type(graph: PRODUCTS, key: "email country { b }") + @join__type(graph: USERS, key: "email country { a }") + @join__type(graph: USERS, key: "email country { b }") + @join__directive( + graphs: [PRODUCTS, USERS] + name: "federation__cacheTag" + args: { format: "user-email:{$key.email}-country-a:{$key.country.a}" } + ) { + email: ID! + country: Country + name: String @join__field(graph: USERS) +} diff --git a/apollo-router/src/testdata/supergraph_with_context.graphql b/apollo-router/src/testdata/supergraph_with_context.graphql index a1627ab52e..77eabbb31c 100644 --- a/apollo-router/src/testdata/supergraph_with_context.graphql +++ b/apollo-router/src/testdata/supergraph_with_context.graphql @@ -22,7 +22,7 @@ directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION -directive @context(name: String!) repeatable on INTERFACE | OBJECT +directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION directive @context__fromContext(field: String) on ARGUMENT_DEFINITION diff --git a/apollo-router/src/tracer.rs b/apollo-router/src/tracer.rs index e26628f979..55348d04b4 100644 --- a/apollo-router/src/tracer.rs +++ b/apollo-router/src/tracer.rs @@ -7,8 +7,8 @@ use opentelemetry::trace::TraceContextExt; use serde::Deserialize; use serde::Serialize; use tracing::Span; -use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::Registry; +use tracing_subscriber::registry::LookupSpan; use crate::plugins::telemetry::otel::OpenTelemetrySpanExt; use crate::plugins::telemetry::reload::IsSampled; @@ -34,7 +34,7 @@ impl TraceId { /// Get the current trace id if it's a valid one, even if it's not sampled pub(crate) fn current() -> Option { - let trace_id = Span::current() + Span::current() .with_subscriber(move |(id, dispatch)| { if let Some(reg) = dispatch.downcast_ref::() { match reg.span(id) { @@ -49,9 +49,7 @@ impl TraceId { None } }) - .flatten(); - - trace_id + .flatten() } /// Convert the TraceId to bytes. @@ -84,12 +82,11 @@ impl From<[u8; 16]> for TraceId { // live with... #[cfg(test)] mod test { - use std::sync::Mutex; - use once_cell::sync::Lazy; use opentelemetry::trace::TracerProvider; - use tracing_subscriber::layer::SubscriberExt; + use parking_lot::Mutex; use tracing_subscriber::Registry; + use tracing_subscriber::layer::SubscriberExt; use super::TraceId; use crate::plugins::telemetry::otel; @@ -99,9 +96,6 @@ mod test { // If we set test-threads=1, then this avoids the problem but means all our tests will run slowly. // So: to avoid this problem, we have a mutex lock which just exists to serialize access to the // global resources. - // Note: If a test fails, then it will poison the lock, so when locking we attempt to recover - // from poisoned mutex and continue anyway. This is safe to do, since the lock is effectively - // "read-only" and not protecting shared state but synchronising code access to global state. static TRACING_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); #[test] @@ -121,19 +115,17 @@ mod test { #[tokio::test] async fn it_returns_valid_trace_id() { - let _guard = TRACING_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _guard = TRACING_LOCK.lock(); // Create a tracing layer with the configured tracer - let provider = opentelemetry::sdk::trace::TracerProvider::builder() + let provider = opentelemetry_sdk::trace::TracerProvider::builder() .with_simple_exporter( opentelemetry_stdout::SpanExporter::builder() .with_writer(std::io::stdout()) .build(), ) .build(); - let tracer = provider.versioned_tracer("noop", None::, None::, None); + let tracer = provider.tracer_builder("noop").build(); let telemetry = otel::layer().force_sampling().with_tracer(tracer); // Use the tracing subscriber `Registry`, or any other subscriber @@ -149,16 +141,14 @@ mod test { #[test] fn it_correctly_compares_valid_and_invalid_trace_id() { - let _guard = TRACING_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _guard = TRACING_LOCK.lock(); let my_id = TraceId::maybe_new(); assert!(my_id.is_none()); // Create a tracing layer with the configured tracer - let provider = opentelemetry::sdk::trace::TracerProvider::builder() + let provider = opentelemetry_sdk::trace::TracerProvider::builder() .with_simple_exporter(opentelemetry_stdout::SpanExporter::default()) .build(); - let tracer = provider.versioned_tracer("noop", None::, None::, None); + let tracer = provider.tracer_builder("noop").build(); let telemetry = otel::layer().force_sampling().with_tracer(tracer); // Use the tracing subscriber `Registry`, or any other subscriber // that impls `LookupSpan` @@ -176,14 +166,12 @@ mod test { #[test] fn it_correctly_compares_valid_and_valid_trace_id() { - let _guard = TRACING_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _guard = TRACING_LOCK.lock(); // Create a tracing layer with the configured tracer - let provider = opentelemetry::sdk::trace::TracerProvider::builder() + let provider = opentelemetry_sdk::trace::TracerProvider::builder() .with_simple_exporter(opentelemetry_stdout::SpanExporter::default()) .build(); - let tracer = provider.versioned_tracer("noop", None::, None::, None); + let tracer = provider.tracer_builder("noop").build(); let telemetry = otel::layer().force_sampling().with_tracer(tracer); // Use the tracing subscriber `Registry`, or any other subscriber // that impls `LookupSpan` diff --git a/apollo-router/src/uplink/feature_gate_enforcement.rs b/apollo-router/src/uplink/feature_gate_enforcement.rs new file mode 100644 index 0000000000..2782c7ca72 --- /dev/null +++ b/apollo-router/src/uplink/feature_gate_enforcement.rs @@ -0,0 +1,248 @@ +use std::collections::HashMap; +use std::fmt::Display; +use std::fmt::Formatter; + +use itertools::Itertools; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +use super::parsed_link_spec::ParsedLinkSpec; +use crate::Configuration; +use crate::spec::LINK_DIRECTIVE_NAME; +use crate::spec::Schema; + +#[derive(Debug)] +pub(crate) struct FeatureGateEnforcementReport { + gated_features_in_use: Vec, +} + +impl FeatureGateEnforcementReport { + pub(crate) fn check(&self) -> Result<(), Vec> { + if self.gated_features_in_use.is_empty() { + Ok(()) + } else { + Err(self.gated_features_in_use.clone()) + } + } + + pub(crate) fn build( + configuration: &Configuration, + schema: &Schema, + ) -> FeatureGateEnforcementReport { + FeatureGateEnforcementReport { + gated_features_in_use: Self::validate_schema( + schema, + &Self::schema_restrictions(), + configuration, + ), + } + } + + fn validate_schema( + schema: &Schema, + schema_restrictions: &Vec, + configuration: &Configuration, + ) -> Vec { + let link_specs_in_join_directive = schema + .supergraph_schema() + .schema_definition + .directives + .get_all(LINK_DIRECTIVE_NAME) + .filter_map(|link| { + ParsedLinkSpec::from_link_directive(link).map(|maybe_spec| { + maybe_spec.ok().map(|spec| (spec.spec_url.to_owned(), spec)) + })? + }) + .collect::>(); + + let mut schema_violations: Vec = Vec::new(); + + for restriction in schema_restrictions { + let mut selector = jsonpath_lib::selector( + configuration + .validated_yaml + .as_ref() + .unwrap_or(&Value::Null), + ); + + match restriction { + FeatureRestriction::SpecInJoinDirective { + spec_url, + name, + version_req, + feature_gate_configuration_path, + expected_value, + to_enable, + warning, + } => { + if let Some(link_spec) = link_specs_in_join_directive.get(spec_url) { + let relevant = version_req.matches(&link_spec.version); + let enabled = selector(feature_gate_configuration_path) + .expect("path on restriction was not valid") + .first() + .is_some_and(|config_value| *config_value == expected_value); + + if relevant && enabled && warning.is_some() { + tracing::warn!("{}", warning.as_ref().unwrap_or(&"".to_string())); + } + + if relevant && !enabled { + schema_violations.push(FeatureGateViolation::Spec { + url: link_spec.url.to_string(), + name: name.to_string(), + to_enable: to_enable.to_string(), + }); + } + } + } + } + } + + schema_violations + } + + fn schema_restrictions() -> Vec { + // @link(url: "https://specs.apollo.dev/connect/v0.3") requires `connectors.preview_connect_v0_3: true` + // This uses join__directives to find specs because the we're looking + // at links within individual subgraphs. + vec![FeatureRestriction::SpecInJoinDirective { + name: "Connect v0.3".to_string(), + spec_url: "https://specs.apollo.dev/connect".to_string(), + version_req: semver::VersionReq { + comparators: vec![semver::Comparator { + op: semver::Op::Exact, + major: 0, + minor: 3.into(), + patch: 0.into(), + pre: semver::Prerelease::EMPTY, + }], + }, + feature_gate_configuration_path: "$.connectors.preview_connect_v0_3".to_string(), + expected_value: Value::Bool(true), + to_enable: " connectors: + preview_connect_v0_3: true" + .to_string(), + warning: Some("Support for @link(url: \"https://specs.apollo.dev/connect/v0.3\") is in preview. See https://go.apollo.dev/connectors/preview-v0.3 for more information.".to_string()) + }] + } +} + +impl Display for FeatureGateEnforcementReport { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if !self.gated_features_in_use.is_empty() { + let restricted_schema = self + .gated_features_in_use + .iter() + .map(|v| v.to_string()) + .join("\n\n"); + + write!(f, "Schema features:\n{restricted_schema}")? + } + + Ok(()) + } +} + +/// An individual check for the supergraph schema +#[derive(Clone, Debug)] +pub(crate) enum FeatureRestriction { + SpecInJoinDirective { + spec_url: String, + name: String, + version_req: semver::VersionReq, + feature_gate_configuration_path: String, + expected_value: Value, + to_enable: String, + warning: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) enum FeatureGateViolation { + Spec { + url: String, + name: String, + to_enable: String, + }, +} + +impl Display for FeatureGateViolation { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match self { + FeatureGateViolation::Spec { + name, + url, + to_enable, + } => { + write!( + f, + "* {name} @link(url: \"{url}\")\n To enable:\n\n{to_enable}" + ) + } + } + } +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use super::FeatureGateEnforcementReport; + use super::FeatureGateViolation; + use crate::Configuration; + use crate::spec::Schema; + + fn check(router_yaml: &str, supergraph_schema: &str) -> FeatureGateEnforcementReport { + let config = Configuration::from_str(router_yaml).expect("router config must be valid"); + let schema = + Schema::parse(supergraph_schema, &config).expect("supergraph schema must be valid"); + FeatureGateEnforcementReport::build(&config, &schema) + } + + #[test] + fn feature_gate_connectors_v0_3() { + let report = check( + include_str!("testdata/oss.router.yaml"), + include_str!("testdata/feature_enforcement_connect_v0_3.graphql"), + ); + + assert_eq!( + 1, + report.gated_features_in_use.len(), + "should have found restricted connect feature" + ); + let FeatureGateViolation::Spec { url, name, .. } = &report.gated_features_in_use[0]; + + assert_eq!("https://specs.apollo.dev/connect/v0.3", url); + assert_eq!("Connect v0.3", name); + } + + #[test] + fn feature_gate_connectors_v0_3_enabled() { + let report = check( + include_str!("testdata/connectv0_3.router.yaml"), + include_str!("testdata/feature_enforcement_connect_v0_3.graphql"), + ); + + assert_eq!( + 0, + report.gated_features_in_use.len(), + "should not have found restricted connect feature" + ); + } + + #[test] + fn feature_gate_connectors_v0_2_noop() { + let report = check( + include_str!("testdata/oss.router.yaml"), + include_str!("testdata/feature_enforcement_connect_v0_2.graphql"), + ); + + assert_eq!( + 0, + report.gated_features_in_use.len(), + "should not have found restricted connect feature" + ); + } +} diff --git a/apollo-router/src/uplink/license_enforcement.rs b/apollo-router/src/uplink/license_enforcement.rs index 1d23f9cc6c..244479dd4d 100644 --- a/apollo-router/src/uplink/license_enforcement.rs +++ b/apollo-router/src/uplink/license_enforcement.rs @@ -4,6 +4,7 @@ #![allow(clippy::derive_partial_eq_without_eq)] use std::collections::HashMap; +use std::collections::HashSet; use std::fmt::Display; use std::fmt::Formatter; use std::str::FromStr; @@ -11,30 +12,30 @@ use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; -use apollo_compiler::schema::Directive; use apollo_compiler::schema::ExtendedType; use buildstructor::Builder; use displaydoc::Display; use itertools::Itertools; -use jsonwebtoken::decode; -use jsonwebtoken::jwk::JwkSet; use jsonwebtoken::DecodingKey; use jsonwebtoken::Validation; +use jsonwebtoken::decode; +use jsonwebtoken::jwk::JwkSet; use once_cell::sync::OnceCell; use regex::Regex; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; +use serde::de::Visitor; use serde_json::Value; +use strum::IntoEnumIterator; +use strum_macros::EnumIter; use thiserror::Error; -use url::Url; -use crate::plugins::authentication::convert_key_algorithm; -use crate::spec::Schema; -use crate::spec::LINK_AS_ARGUMENT; -use crate::spec::LINK_DIRECTIVE_NAME; -use crate::spec::LINK_URL_ARGUMENT; +use super::parsed_link_spec::ParsedLinkSpec; use crate::Configuration; +use crate::plugins::authentication::jwks::convert_key_algorithm; +use crate::spec::LINK_DIRECTIVE_NAME; +use crate::spec::Schema; pub(crate) const LICENSE_EXPIRED_URL: &str = "https://go.apollo.dev/o/elp"; pub(crate) const LICENSE_EXPIRED_SHORT_MESSAGE: &str = @@ -71,9 +72,19 @@ pub(crate) struct Claims { pub(crate) sub: String, pub(crate) aud: OneOrMany, #[serde(deserialize_with = "deserialize_epoch_seconds", rename = "warnAt")] + /// When to warn the user about an expiring license that must be renewed to avoid halting the + /// router pub(crate) warn_at: SystemTime, #[serde(deserialize_with = "deserialize_epoch_seconds", rename = "haltAt")] + /// When to halt the router because of an expired license pub(crate) halt_at: SystemTime, + /// TPS limits. These may not exist in a License; if not, no limits apply + #[serde(rename = "throughputLimit")] + pub(crate) tps: Option, + /// Set of allowed features. These may not exist in a License; if not, all features are enabled + /// NB: This is temporary behavior and will be updated once all licenses contain an allowed_features claim. + #[serde(rename = "allowedFeatures")] + pub(crate) allowed_features: Option>, } fn deserialize_epoch_seconds<'de, D>(deserializer: D) -> Result @@ -84,72 +95,20 @@ where Ok(UNIX_EPOCH + Duration::from_secs(seconds as u64)) } +fn deserialize_ms_into_duration<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let seconds = i32::deserialize(deserializer)?; + Ok(Duration::from_millis(seconds as u64)) +} + #[derive(Debug)] pub(crate) struct LicenseEnforcementReport { restricted_config_in_use: Vec, restricted_schema_in_use: Vec, } -#[derive(Debug)] -struct ParsedLinkSpec { - spec_name: String, - version: semver::Version, - spec_url: String, - imported_as: Option, - url: String, -} - -impl ParsedLinkSpec { - fn from_link_directive( - link_directive: &Directive, - ) -> Option> { - link_directive - .specified_argument_by_name(LINK_URL_ARGUMENT) - .and_then(|value| { - let url_string = value.as_str(); - let parsed_url = Url::parse(url_string.unwrap_or_default()).ok()?; - - let mut segments = parsed_url.path_segments()?; - let spec_name = segments.next()?.to_string(); - let spec_url = format!( - "{}://{}/{}", - parsed_url.scheme(), - parsed_url.host()?, - spec_name - ); - let version_string = segments.next()?.strip_prefix('v')?; - let parsed_version = - semver::Version::parse(format!("{}.0", &version_string).as_str()).ok()?; - - let imported_as = link_directive - .specified_argument_by_name(LINK_AS_ARGUMENT) - .map(|as_arg| as_arg.as_str().unwrap_or_default().to_string()); - - Some(Ok(ParsedLinkSpec { - spec_name, - spec_url, - version: parsed_version, - imported_as, - url: url_string?.to_string(), - })) - }) - } - - // Implements directive name construction logic for link directives. - // 1. If the link directive has an `as` argument, use that as the prefix. - // 2. If the link directive's spec name is the same as the default name, use the default name with no prefix. - // 3. Otherwise, use the spec name as the prefix. - fn directive_name(&self, default_name: &str) -> String { - if let Some(imported_as) = &self.imported_as { - format!("{}__{}", imported_as, default_name) - } else if self.spec_name == default_name { - default_name.to_string() - } else { - format!("{}__{}", self.spec_name, default_name) - } - } -} - impl LicenseEnforcementReport { pub(crate) fn uses_restricted_features(&self) -> bool { !self.restricted_config_in_use.is_empty() || !self.restricted_schema_in_use.is_empty() @@ -158,16 +117,38 @@ impl LicenseEnforcementReport { pub(crate) fn build( configuration: &Configuration, schema: &Schema, + license: &LicenseState, ) -> LicenseEnforcementReport { LicenseEnforcementReport { restricted_config_in_use: Self::validate_configuration( configuration, - &Self::configuration_restrictions(), + &Self::configuration_restrictions(license), + ), + restricted_schema_in_use: Self::validate_schema( + schema, + &Self::schema_restrictions(license), ), - restricted_schema_in_use: Self::validate_schema(schema, &Self::schema_restrictions()), } } + pub(crate) fn restricted_features_in_use(&self) -> Vec { + let mut restricted_features_in_use = Vec::new(); + for restricted_config_in_use in self.restricted_config_in_use.clone() { + restricted_features_in_use.push(restricted_config_in_use.name.clone()); + } + for restricted_schema_in_use in self.restricted_schema_in_use.clone() { + match restricted_schema_in_use { + SchemaViolation::Spec { name, .. } => { + restricted_features_in_use.push(name.clone()); + } + SchemaViolation::DirectiveArgument { name, .. } => { + restricted_features_in_use.push(name.clone()); + } + } + } + restricted_features_in_use + } + fn validate_configuration( configuration: &Configuration, configuration_restrictions: &Vec, @@ -212,18 +193,29 @@ impl LicenseEnforcementReport { }) .collect::>(); - let mut schema_violations: Vec = Vec::new(); + let link_specs_in_join_directive = schema + .supergraph_schema() + .schema_definition + .directives + .get_all("join__directive") + .filter(|join| { + join.specified_argument_by_name("name") + .and_then(|name| name.as_str()) + .map(|name| name == LINK_DIRECTIVE_NAME) + .unwrap_or_default() + }) + .filter_map(|join| { + join.specified_argument_by_name("args") + .and_then(|arg| arg.as_object()) + }) + .filter_map(|link| { + ParsedLinkSpec::from_join_directive_args(link).map(|maybe_spec| { + maybe_spec.ok().map(|spec| (spec.spec_url.to_owned(), spec)) + })? + }) + .collect::>(); - for (_subgraph_name, subgraph_url) in schema.subgraphs() { - if subgraph_url.scheme_str() == Some("unix") { - schema_violations.push(SchemaViolation::DirectiveArgument { - url: "https://specs.apollo.dev/join/v0.3".to_string(), - name: "join__Graph".to_string(), - argument: "url".to_string(), - explanation: "Unix socket support for subgraph requests is restricted to Enterprise users".to_string(), - }); - } - } + let mut schema_violations: Vec = Vec::new(); for restriction in schema_restrictions { match restriction { @@ -232,13 +224,13 @@ impl LicenseEnforcementReport { name, version_req, } => { - if let Some(link_spec) = link_specs.get(spec_url) { - if version_req.matches(&link_spec.version) { - schema_violations.push(SchemaViolation::Spec { - url: link_spec.url.to_string(), - name: name.to_string(), - }); - } + if let Some(link_spec) = link_specs.get(spec_url) + && version_req.matches(&link_spec.version) + { + schema_violations.push(SchemaViolation::Spec { + url: link_spec.url.to_string(), + name: name.to_string(), + }); } } SchemaRestriction::DirectiveArgument { @@ -248,158 +240,192 @@ impl LicenseEnforcementReport { argument, explanation, } => { - if let Some(link_spec) = link_specs.get(spec_url) { - if version_req.matches(&link_spec.version) { - let directive_name = link_spec.directive_name(name); - if schema - .supergraph_schema() - .types - .values() - .flat_map(|def| match def { - // To traverse additional directive locations, add match arms for the respective definition types required. - // As of writing this, this is only implemented for finding usages of progressive override on object type fields, but it can be extended to other directive locations trivially. - ExtendedType::Object(object_type_def) => { - let directives_on_object = object_type_def - .directives - .get_all(&directive_name) - .map(|component| &component.node); - let directives_on_fields = - object_type_def.fields.values().flat_map(|field| { - field.directives.get_all(&directive_name) - }); - - directives_on_object - .chain(directives_on_fields) - .collect::>() - } - _ => vec![], - }) - .any(|directive| { - directive.specified_argument_by_name(argument).is_some() - }) - { - schema_violations.push(SchemaViolation::DirectiveArgument { - url: link_spec.url.to_string(), - name: directive_name.to_string(), - argument: argument.to_string(), - explanation: explanation.to_string(), - }); - } + if let Some(link_spec) = link_specs.get(spec_url) + && version_req.matches(&link_spec.version) + { + let directive_name = link_spec.directive_name(name); + if schema + .supergraph_schema() + .types + .values() + .flat_map(|def| match def { + // To traverse additional directive locations, add match arms for the respective definition types required. + ExtendedType::Object(object_type_def) => { + let directives_on_object = object_type_def + .directives + .get_all(&directive_name) + .map(|component| &component.node); + let directives_on_fields = + object_type_def.fields.values().flat_map(|field| { + field.directives.get_all(&directive_name) + }); + + directives_on_object + .chain(directives_on_fields) + .collect::>() + } + _ => vec![], + }) + .any(|directive| { + directive.specified_argument_by_name(argument).is_some() + }) + { + schema_violations.push(SchemaViolation::DirectiveArgument { + url: link_spec.url.to_string(), + name: directive_name.to_string(), + argument: argument.to_string(), + explanation: explanation.to_string(), + }); } } } + SchemaRestriction::SpecInJoinDirective { + spec_url, + name, + version_req, + } => { + if let Some(link_spec) = link_specs_in_join_directive.get(spec_url) + && version_req.matches(&link_spec.version) + { + schema_violations.push(SchemaViolation::Spec { + url: link_spec.url.to_string(), + name: name.to_string(), + }); + } + } } } schema_violations } - fn configuration_restrictions() -> Vec { - vec![ - ConfigurationRestriction::builder() - .path("$.plugins.['experimental.restricted'].enabled") - .value(true) - .name("Restricted") - .build(), - ConfigurationRestriction::builder() - .path("$.authentication.router") - .name("Authentication plugin") - .build(), - ConfigurationRestriction::builder() - .path("$.authorization.directives") - .name("Authorization directives") - .build(), - ConfigurationRestriction::builder() - .path("$.coprocessor") - .name("Coprocessor plugin") - .build(), - ConfigurationRestriction::builder() - .path("$.supergraph.query_planning.cache.redis") - .name("Query plan caching") - .build(), - ConfigurationRestriction::builder() - .path("$.apq.router.cache.redis") - .name("APQ caching") - .build(), - ConfigurationRestriction::builder() - .path("$.preview_entity_cache.enabled") - .value(true) - .name("Subgraph entity caching") - .build(), - ConfigurationRestriction::builder() - .path("$.subscription.enabled") - .value(true) - .name("Federated subscriptions") - .build(), - // Per-operation limits are restricted but parser limits like `parser_max_recursion` - // where the Router only configures apollo-rs are not. - ConfigurationRestriction::builder() - .path("$.limits.max_depth") - .name("Operation depth limiting") - .build(), - ConfigurationRestriction::builder() - .path("$.limits.max_height") - .name("Operation height limiting") - .build(), - ConfigurationRestriction::builder() - .path("$.limits.max_root_fields") - .name("Operation root fields limiting") - .build(), - ConfigurationRestriction::builder() - .path("$.limits.max_aliases") - .name("Operation aliases limiting") - .build(), - ConfigurationRestriction::builder() - .path("$.persisted_queries") - .name("Persisted queries") - .build(), - ConfigurationRestriction::builder() - .path("$.telemetry..spans.router") - .name("Advanced telemetry") - .build(), - ConfigurationRestriction::builder() - .path("$.telemetry..spans.supergraph") - .name("Advanced telemetry") - .build(), - ConfigurationRestriction::builder() - .path("$.telemetry..spans.subgraph") - .name("Advanced telemetry") - .build(), - ConfigurationRestriction::builder() - .path("$.telemetry..events") - .name("Advanced telemetry") - .build(), - ConfigurationRestriction::builder() - .path("$.telemetry..instruments") - .name("Advanced telemetry") - .build(), - ConfigurationRestriction::builder() - .path("$.telemetry..graphql") - .name("Advanced telemetry") - .build(), - ConfigurationRestriction::builder() - .path("$.preview_file_uploads") - .name("File uploads plugin") - .build(), - ConfigurationRestriction::builder() - .path("$.batching") - .name("Batching support") - .build(), - ConfigurationRestriction::builder() - .path("$.demand_control") - .name("Demand control plugin") - .build(), - ConfigurationRestriction::builder() - .path("$.telemetry.apollo.metrics_reference_mode") - .value("extended") - .name("Apollo metrics extended references") - .build(), - ] - } - - fn schema_restrictions() -> Vec { - vec![ - SchemaRestriction::Spec { + fn configuration_restrictions(license: &LicenseState) -> Vec { + let mut configuration_restrictions = vec![]; + + let allowed_features = license.get_allowed_features(); + if !allowed_features.contains(&AllowedFeature::ApqCaching) { + configuration_restrictions.push( + ConfigurationRestriction::builder() + .path("$.apq.router.cache.redis") + .name("APQ caching") + .build(), + ) + } + if !allowed_features.contains(&AllowedFeature::Authentication) { + configuration_restrictions.push( + ConfigurationRestriction::builder() + .path("$.authentication.router") + .name("Authentication plugin") + .build(), + ); + } + if !allowed_features.contains(&AllowedFeature::Authorization) { + configuration_restrictions.push( + ConfigurationRestriction::builder() + .path("$.authorization.directives") + .name("Authorization directives") + .build(), + ); + } + if !allowed_features.contains(&AllowedFeature::Batching) { + configuration_restrictions.push( + ConfigurationRestriction::builder() + .path("$.batching") + .name("Batching support") + .build(), + ); + } + if !allowed_features.contains(&AllowedFeature::EntityCaching) { + configuration_restrictions.push( + ConfigurationRestriction::builder() + .path("$.preview_entity_cache.enabled") + .value(true) + .name("Subgraph entity caching") + .build(), + ); + } + if !allowed_features.contains(&AllowedFeature::PersistedQueries) { + configuration_restrictions.push( + ConfigurationRestriction::builder() + .path("$.persisted_queries") + .name("Persisted queries") + .build(), + ); + } + if !allowed_features.contains(&AllowedFeature::Subscriptions) { + configuration_restrictions.push( + ConfigurationRestriction::builder() + .path("$.subscription.enabled") + .value(true) + .name("Federated subscriptions") + .build(), + ); + } + if !allowed_features.contains(&AllowedFeature::Coprocessors) { + configuration_restrictions.push( + ConfigurationRestriction::builder() + .path("$.coprocessor") + .name("Coprocessor plugin") + .build(), + ) + } + if !allowed_features.contains(&AllowedFeature::DistributedQueryPlanning) { + configuration_restrictions.push( + ConfigurationRestriction::builder() + .path("$.supergraph.query_planning.cache.redis") + .name("Query plan caching") + .build(), + ) + } + if !allowed_features.contains(&AllowedFeature::DemandControl) { + configuration_restrictions.push( + ConfigurationRestriction::builder() + .path("$.demand_control") + .name("Demand control plugin") + .build(), + ); + } + if !allowed_features.contains(&AllowedFeature::Experimental) { + configuration_restrictions.push( + ConfigurationRestriction::builder() + .path("$.plugins.['experimental.restricted'].enabled") + .value(true) + .name("Restricted") + .build(), + ); + } + // Per-operation limits are restricted but parser limits like `parser_max_recursion` + // where the Router only configures apollo-rs are not. + if !allowed_features.contains(&AllowedFeature::RequestLimits) { + configuration_restrictions.extend(vec![ + ConfigurationRestriction::builder() + .path("$.limits.max_depth") + .name("Operation depth limiting") + .build(), + ConfigurationRestriction::builder() + .path("$.limits.max_height") + .name("Operation height limiting") + .build(), + ConfigurationRestriction::builder() + .path("$.limits.max_root_fields") + .name("Operation root fields limiting") + .build(), + ConfigurationRestriction::builder() + .path("$.limits.max_aliases") + .name("Operation aliases limiting") + .build(), + ]); + } + + configuration_restrictions + } + + fn schema_restrictions(license: &LicenseState) -> Vec { + let mut schema_restrictions = vec![]; + let allowed_features = license.get_allowed_features(); + + if !allowed_features.contains(&AllowedFeature::Authentication) { + schema_restrictions.push(SchemaRestriction::Spec { name: "authenticated".to_string(), spec_url: "https://specs.apollo.dev/authenticated".to_string(), version_req: semver::VersionReq { @@ -411,21 +437,8 @@ impl LicenseEnforcementReport { pre: semver::Prerelease::EMPTY, }], }, - }, - SchemaRestriction::Spec { - name: "context".to_string(), - spec_url: "https://specs.apollo.dev/context".to_string(), - version_req: semver::VersionReq { - comparators: vec![semver::Comparator { - op: semver::Op::Exact, - major: 0, - minor: 1.into(), - patch: 0.into(), - pre: semver::Prerelease::EMPTY, - }], - }, - }, - SchemaRestriction::Spec { + }); + schema_restrictions.push(SchemaRestriction::Spec { name: "requiresScopes".to_string(), spec_url: "https://specs.apollo.dev/requiresScopes".to_string(), version_req: semver::VersionReq { @@ -437,38 +450,10 @@ impl LicenseEnforcementReport { pre: semver::Prerelease::EMPTY, }], }, - }, - SchemaRestriction::DirectiveArgument { - name: "field".to_string(), - argument: "overrideLabel".to_string(), - spec_url: "https://specs.apollo.dev/join".to_string(), - version_req: semver::VersionReq { - comparators: vec![semver::Comparator { - op: semver::Op::GreaterEq, - major: 0, - minor: 4.into(), - patch: 0.into(), - pre: semver::Prerelease::EMPTY, - }], - }, - explanation: "The `overrideLabel` argument on the join spec's @field directive is restricted to Enterprise users. This argument exists in your supergraph as a result of using the `@override` directive with the `label` argument in one or more of your subgraphs.".to_string() - }, - SchemaRestriction::DirectiveArgument { - name: "field".to_string(), - argument: "contextArguments".to_string(), - spec_url: "https://specs.apollo.dev/join".to_string(), - version_req: semver::VersionReq { - comparators: vec![semver::Comparator { - op: semver::Op::GreaterEq, - major: 0, - minor: 5.into(), - patch: 0.into(), - pre: semver::Prerelease::EMPTY, - }], - }, - explanation: "The `contextArguments` argument on the join spec's @field directive is restricted to Enterprise users. This argument exists in your supergraph as a result of using the `@fromContext` directive in one or more of your subgraphs.".to_string() - }, - ] + }); + } + + schema_restrictions } } @@ -501,28 +486,205 @@ impl Display for LicenseEnforcementReport { } } -/// License controls availability of certain features of the Router. It must be constructed from a base64 encoded JWT +/// Claims extracted from the License, including ways Apollo limits the router's usage. It must be constructed from a base64 encoded JWT /// This API experimental and is subject to change outside of semver. #[derive(Debug, Clone, Default)] pub struct License { pub(crate) claims: Option, } +/// Transactions Per Second limits. We talk as though this will be in seconds, but the Duration +/// here is actually given to us in milliseconds via the License's JWT's claims +#[derive(Builder, Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct TpsLimit { + pub(crate) capacity: usize, + + #[serde( + deserialize_with = "deserialize_ms_into_duration", + rename = "durationMs" + )] + pub(crate) interval: Duration, +} + +/// Allowed features for a License, representing what's available to a particular pricing tier +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Hash, EnumIter)] +#[serde(rename_all = "snake_case")] +pub enum AllowedFeature { + /// Automated persisted queries + Apq, + /// APQ caching + ApqCaching, + /// Authentication plugin + Authentication, + /// Authorization directives + Authorization, + /// Batching support + Batching, + /// Coprocessor plugin + Coprocessors, + /// Demand control plugin + DemandControl, + /// Distributed query planning + DistributedQueryPlanning, + /// Subgraph entity caching + EntityCaching, + /// Experimental features in the router + Experimental, + /// Extended reference reporting + ExtendedReferenceReporting, + /// Persisted queries safelisting + PersistedQueries, + /// Request limits - depth and breadth + RequestLimits, + /// Federated subscriptions + Subscriptions, + /// Traffic shaping + TrafficShaping, + /// This represents a feature found in the license that the router does not recognize + Other(String), +} + +impl From<&str> for AllowedFeature { + fn from(feature: &str) -> Self { + match feature { + "apq" => Self::Apq, + "apq_caching" => Self::ApqCaching, + "authentication" => Self::Authentication, + "authorization" => Self::Authorization, + "batching" => Self::Batching, + "coprocessors" => Self::Coprocessors, + "demand_control" => Self::DemandControl, + "distributed_query_planning" => Self::DistributedQueryPlanning, + "entity_caching" => Self::EntityCaching, + "experimental" => Self::Experimental, + "extended_reference_reporting" => Self::ExtendedReferenceReporting, + "persisted_queries" => Self::PersistedQueries, + "request_limits" => Self::RequestLimits, + "subscriptions" => Self::Subscriptions, + "traffic_shaping" => Self::TrafficShaping, + other => Self::Other(other.into()), + } + } +} + +impl AllowedFeature { + /// Creates an allowed feature from a plugin name + pub fn from_plugin_name(plugin_name: &str) -> Option { + match plugin_name { + "traffic_shaping" => Some(AllowedFeature::TrafficShaping), + "limits" => Some(AllowedFeature::RequestLimits), + "subscription" => Some(AllowedFeature::Subscriptions), + "authorization" => Some(AllowedFeature::Authorization), + "authentication" => Some(AllowedFeature::Authentication), + "preview_entity_cache" => Some(AllowedFeature::EntityCaching), + "demand_control" => Some(AllowedFeature::DemandControl), + "coprocessor" => Some(AllowedFeature::Coprocessors), + _other => None, + } + } +} + +impl<'de> Deserialize<'de> for AllowedFeature { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct AllowedFeatureVisitor; + + impl<'de> Visitor<'de> for AllowedFeatureVisitor { + type Value = AllowedFeature; + + fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { + formatter.write_str("a string representing an allowed feature") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + Ok(AllowedFeature::from(value)) + } + } + + deserializer.deserialize_str(AllowedFeatureVisitor) + } +} + +/// LicenseLimits represent what can be done with a router based on the claims in the License. You +/// might have a certain tier be limited in its capacity for transactions over a certain duration, +/// as an example +#[derive(Debug, Builder, Clone, Eq, PartialEq)] +pub struct LicenseLimits { + /// Transaction Per Second limits. If none are found in the License's claims, there are no + /// limits to apply + pub(crate) tps: Option, + /// The allowed features based on the allowed features present on the License's claims + pub(crate) allowed_features: HashSet, +} + +impl Default for LicenseLimits { + fn default() -> Self { + Self { + tps: None, + allowed_features: HashSet::from_iter(AllowedFeature::iter()), + } + } +} + /// Licenses are converted into a stream of license states by the expander -#[derive(Debug, Copy, Clone, Eq, PartialEq, Default, Display)] -pub(crate) enum LicenseState { +#[derive(Debug, Clone, Eq, PartialEq, Default, Display)] +pub enum LicenseState { /// licensed - Licensed, + Licensed { limits: Option }, /// warn - LicensedWarn, + LicensedWarn { limits: Option }, /// halt - LicensedHalt, + LicensedHalt { limits: Option }, /// unlicensed #[default] Unlicensed, } +impl LicenseState { + pub(crate) fn get_limits(&self) -> Option<&LicenseLimits> { + match self { + LicenseState::Licensed { limits } + | LicenseState::LicensedWarn { limits } + | LicenseState::LicensedHalt { limits } => limits.as_ref(), + _ => None, + } + } + + pub(crate) fn get_allowed_features(&self) -> HashSet { + match self { + LicenseState::Licensed { limits } + | LicenseState::LicensedWarn { limits } + | LicenseState::LicensedHalt { limits } => { + match limits { + Some(limits) => limits.allowed_features.clone(), + // If the license has no limits and therefore no allowed_features claim, + // we're using a pricing plan that should have the feature enabled regardless. + // NB: This is temporary behavior and will be updated once all licenses contain + // an allowed_features claim. + None => HashSet::from_iter(AllowedFeature::iter()), + } + } + // If we are using an expired license or an unlicesed router we return an empty feature set + LicenseState::Unlicensed => HashSet::new(), + } + } + + pub(crate) fn get_name(&self) -> &'static str { + match self { + Self::Licensed { limits: _ } => "Licensed", + Self::LicensedWarn { limits: _ } => "LicensedWarn", + Self::LicensedHalt { limits: _ } => "LicensedHalt", + Self::Unlicensed => "Unlicensed", + } + } +} + impl Display for License { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { if let Some(claims) = &self.claims { @@ -604,10 +766,12 @@ pub(crate) enum SchemaRestriction { name: String, version_req: semver::VersionReq, }, - // Note: this restriction is currently only traverses directives belonging - // to object types and their fields. See note in `schema_restrictions` loop - // for where to update if this restriction is to be enforced on other - // directives. + // Note: this restriction is currently unused, but it's intention was to + // traverse directives belonging to object types and their fields. It was used for + // progressive overrides when they were gated to enterprise-only. Leaving it here for now + // in case other directives become gated by subscription tier (there's at least one in the + // works that's non-free) + #[allow(dead_code)] DirectiveArgument { spec_url: String, name: String, @@ -615,6 +779,15 @@ pub(crate) enum SchemaRestriction { argument: String, explanation: String, }, + // Note: this restriction is currently unused. + // It was used for connectors when they were gated to license-only. Leaving it here for now + // in case other directives become gated by subscription tier + #[allow(dead_code)] + SpecInJoinDirective { + spec_url: String, + name: String, + version_req: semver::VersionReq, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -635,7 +808,7 @@ impl Display for SchemaViolation { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { match self { SchemaViolation::Spec { name, url } => { - write!(f, "* @{}\n {}", name, url) + write!(f, "* @{name}\n {url}") } SchemaViolation::DirectiveArgument { name, @@ -643,7 +816,7 @@ impl Display for SchemaViolation { argument, explanation, } => { - write!(f, "* @{}.{}\n {}\n\n{}", name, argument, url, explanation) + write!(f, "* @{name}.{argument}\n {url}\n\n{explanation}") } } } @@ -654,7 +827,18 @@ impl License { JWKS.get_or_init(|| { // Strip the comments from the top of the file. let re = Regex::new("(?m)^//.*$").expect("regex must be valid"); - let jwks = re.replace(include_str!("license.jwks.json"), ""); + // We have a set of test JWTs that use this dummy JWKS endpoint. See the internal docs + // of the router team for details on how to mint a dummy JWT for testing + let jwks = if let Ok(jwks_path) = std::env::var("APOLLO_TEST_INTERNAL_UPLINK_JWKS") { + tracing::debug!("using a dummy JWKS endpoint: {jwks_path:?}"); + let jwks = std::fs::read_to_string(jwks_path) + .expect("dummy JWKS endpoint couldn't be read into memory"); + re.replace(&jwks, "").into_owned() + } else { + re.replace(include_str!("license.jwks.json"), "") + .into_owned() + }; + serde_json::from_str::(&jwks).expect("router jwks must be valid") }) } @@ -662,6 +846,7 @@ impl License { #[cfg(test)] mod test { + use std::collections::HashSet; use std::str::FromStr; use std::time::Duration; use std::time::UNIX_EPOCH; @@ -669,20 +854,28 @@ mod test { use insta::assert_snapshot; use serde_json::json; + use crate::AllowedFeature; + use crate::Configuration; use crate::spec::Schema; use crate::uplink::license_enforcement::Audience; use crate::uplink::license_enforcement::Claims; use crate::uplink::license_enforcement::License; use crate::uplink::license_enforcement::LicenseEnforcementReport; + use crate::uplink::license_enforcement::LicenseLimits; + use crate::uplink::license_enforcement::LicenseState; use crate::uplink::license_enforcement::OneOrMany; - use crate::Configuration; #[track_caller] - fn check(router_yaml: &str, supergraph_schema: &str) -> LicenseEnforcementReport { + fn check( + router_yaml: &str, + supergraph_schema: &str, + license: LicenseState, + ) -> LicenseEnforcementReport { let config = Configuration::from_str(router_yaml).expect("router config must be valid"); let schema = Schema::parse(supergraph_schema, &config).expect("supergraph schema must be valid"); - LicenseEnforcementReport::build(&config, &schema) + + LicenseEnforcementReport::build(&config, &schema, &license) } #[test] @@ -690,6 +883,7 @@ mod test { let report = check( include_str!("testdata/oss.router.yaml"), include_str!("testdata/oss.graphql"), + LicenseState::default(), ); assert!( @@ -699,10 +893,61 @@ mod test { } #[test] - fn test_restricted_features_via_config() { + fn test_restricted_features_via_config_unlicensed() { + let report = check( + include_str!("testdata/restricted.router.yaml"), + include_str!("testdata/oss.graphql"), + LicenseState::default(), + ); + + assert!( + !report.restricted_config_in_use.is_empty(), + "should have found restricted features" + ); + assert_snapshot!(report.to_string()); + } + + #[test] + fn test_restricted_features_via_config_allowed_features_empty() { + let report = check( + include_str!("testdata/restricted.router.yaml"), + include_str!("testdata/oss.graphql"), + LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(vec![]), + }), + }, + ); + + assert!( + !report.restricted_config_in_use.is_empty(), + "should have found restricted features" + ); + assert_snapshot!(report.to_string()); + } + + #[test] + fn test_restricted_features_via_config_with_allowed_features() { + // The config includes subscriptions but the license's + // allowed_features claim does not include subscriptions let report = check( include_str!("testdata/restricted.router.yaml"), include_str!("testdata/oss.graphql"), + LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(vec![ + AllowedFeature::Authentication, + AllowedFeature::Authorization, + AllowedFeature::Batching, + AllowedFeature::DemandControl, + AllowedFeature::EntityCaching, + AllowedFeature::PersistedQueries, + AllowedFeature::ApqCaching, + ]), + }), + }, ); assert!( @@ -713,10 +958,11 @@ mod test { } #[test] - fn test_restricted_authorization_directives_via_schema() { + fn test_restricted_authorization_directives_via_schema_unlicensed() { let report = check( include_str!("testdata/oss.router.yaml"), include_str!("testdata/authorization.graphql"), + LicenseState::default(), ); assert!( @@ -727,13 +973,39 @@ mod test { } #[test] - #[cfg(not(windows))] // http::uri::Uri parsing appears to reject unix:// on Windows - fn test_restricted_unix_socket_via_schema() { + fn test_restricted_authorization_directives_via_schema_with_restricted_allowed_features() { + // When auth is contained within the allowed features set + // we should not find any schema violations in the report let report = check( include_str!("testdata/oss.router.yaml"), - include_str!("testdata/unix_socket.graphql"), + include_str!("testdata/authorization.graphql"), + LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(vec![ + AllowedFeature::Authentication, + AllowedFeature::Authorization, + ]), + }), + }, + ); + assert!( + report.restricted_schema_in_use.is_empty(), + "should have not found restricted features" ); + // When auth is not contained within the allowed features set + // we should find schema violations in the report + let report = check( + include_str!("testdata/oss.router.yaml"), + include_str!("testdata/authorization.graphql"), + LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::from_iter(vec![AllowedFeature::DemandControl]), + }), + }, + ); assert!( !report.restricted_schema_in_use.is_empty(), "should have found restricted features" @@ -741,9 +1013,76 @@ mod test { assert_snapshot!(report.to_string()); } + // NB: this behavior will change once all licenses have an `allowed_features` claim + #[test] + fn test_restricted_authorization_directives_via_schema_with_default_license_limits() { + let report = check( + include_str!("testdata/oss.router.yaml"), + include_str!("testdata/authorization.graphql"), + LicenseState::Licensed { + limits: Default::default(), + }, + ); + + assert!( + report.restricted_schema_in_use.is_empty(), + "should have not found restricted features" + ); + } + + #[test] + #[cfg(not(windows))] // http::uri::Uri parsing appears to reject unix:// on Windows + fn unix_socket_available_to_oss() { + let report = check( + include_str!("testdata/oss.router.yaml"), + include_str!("testdata/unix_socket.graphql"), + LicenseState::default(), + ); + + assert!( + report.restricted_schema_in_use.is_empty(), + "shouldn't have found restricted features" + ); + } + + #[test] + fn schema_enforcement_allows_context_directive_for_oss() { + let report = check( + include_str!("testdata/oss.router.yaml"), + include_str!("testdata/set_context.graphql"), + LicenseState::default(), + ); + + assert!( + report.restricted_schema_in_use.is_empty(), + "shouldn't have found restricted features" + ); + } + + #[test] + #[cfg(not(windows))] // http::uri::Uri parsing appears to reject unix:// on Windows + fn test_restricted_unix_socket_via_schema_when_allowed_features_empty() { + let report = check( + include_str!("testdata/oss.router.yaml"), + include_str!("testdata/unix_socket.graphql"), + LicenseState::Licensed { + limits: Some(LicenseLimits { + tps: None, + allowed_features: HashSet::new(), + }), + }, + ); + + assert!( + report.restricted_schema_in_use.is_empty(), + "shouldn't have found restricted features" + ); + } + #[test] fn test_license_parse() { let license = License::from_str("eyJhbGciOiJFZERTQSJ9.eyJpc3MiOiJodHRwczovL3d3dy5hcG9sbG9ncmFwaHFsLmNvbS8iLCJzdWIiOiJhcG9sbG8iLCJhdWQiOiJTRUxGX0hPU1RFRCIsIndhcm5BdCI6MTY3NjgwODAwMCwiaGFsdEF0IjoxNjc4MDE3NjAwfQ.tXexfjZ2SQeqSwkWQ7zD4XBoxS_Hc5x7tSNJ3ln-BCL_GH7i3U9hsIgdRQTczCAjA_jjk34w39DeSV0nTc5WBw").expect("must be able to decode JWT"); // gitleaks:allow + assert_eq!( license.claims, Some(Claims { @@ -752,6 +1091,8 @@ mod test { aud: OneOrMany::One(Audience::SelfHosted), warn_at: UNIX_EPOCH + Duration::from_secs(1676808000), halt_at: UNIX_EPOCH + Duration::from_secs(1678017600), + tps: Default::default(), + allowed_features: Default::default() }), ); } @@ -767,6 +1108,8 @@ mod test { aud: OneOrMany::One(Audience::SelfHosted), warn_at: UNIX_EPOCH + Duration::from_secs(1676808000), halt_at: UNIX_EPOCH + Duration::from_secs(1678017600), + tps: Default::default(), + allowed_features: Default::default() }), ); } @@ -804,20 +1147,31 @@ mod test { "haltAt": 123, })) .expect("json must deserialize"); + + serde_json::from_value::(json!({ + "iss": "Issuer", + "sub": "Subject", + "aud": "OFFLINE", + "warnAt": 122, + "haltAt": 123, + "allowedFeatures": ["SUBSCRIPTIONS", "ENTITY_CACHING"] + })) + .expect("json must deserialize"); } #[test] - fn progressive_override() { + fn progressive_override_available_to_oss() { let report = check( include_str!("testdata/oss.router.yaml"), include_str!("testdata/progressive_override.graphql"), + LicenseState::default(), ); + // progressive override is available for oss assert!( - !report.restricted_schema_in_use.is_empty(), - "should have found restricted features" + report.restricted_schema_in_use.is_empty(), + "shouldn't have found restricted features" ); - assert_snapshot!(report.to_string()); } #[test] @@ -825,13 +1179,13 @@ mod test { let report = check( include_str!("testdata/oss.router.yaml"), include_str!("testdata/set_context.graphql"), + LicenseState::default(), ); assert!( - !report.restricted_schema_in_use.is_empty(), - "should have found restricted features" + report.restricted_schema_in_use.is_empty(), + "shouldn't have found restricted features" ); - assert_snapshot!(report.to_string()); } #[test] @@ -839,13 +1193,13 @@ mod test { let report = check( include_str!("testdata/oss.router.yaml"), include_str!("testdata/progressive_override_renamed_join.graphql"), + LicenseState::default(), ); assert!( - !report.restricted_schema_in_use.is_empty(), - "should have found restricted features" + report.restricted_schema_in_use.is_empty(), + "shouldn't have found restricted features" ); - assert_snapshot!(report.to_string()); } #[test] @@ -853,6 +1207,7 @@ mod test { let report = check( include_str!("testdata/oss.router.yaml"), include_str!("testdata/schema_enforcement_spec_version_in_range.graphql"), + LicenseState::default(), ); assert!( @@ -867,6 +1222,7 @@ mod test { let report = check( include_str!("testdata/oss.router.yaml"), include_str!("testdata/schema_enforcement_spec_version_out_of_range.graphql"), + LicenseState::default(), ); assert!( @@ -880,13 +1236,13 @@ mod test { let report = check( include_str!("testdata/oss.router.yaml"), include_str!("testdata/schema_enforcement_directive_arg_version_in_range.graphql"), + LicenseState::default(), ); assert!( - !report.restricted_schema_in_use.is_empty(), - "should have found restricted features" + report.restricted_schema_in_use.is_empty(), + "shouldn't have found restricted features" ); - assert_snapshot!(report.to_string()); } #[test] @@ -894,6 +1250,7 @@ mod test { let report = check( include_str!("testdata/oss.router.yaml"), include_str!("testdata/schema_enforcement_directive_arg_version_out_of_range.graphql"), + LicenseState::default(), ); assert!( @@ -901,4 +1258,18 @@ mod test { "shouldn't have found restricted features" ); } + + #[test] + fn schema_enforcement_connectors() { + let report = check( + include_str!("testdata/oss.router.yaml"), + include_str!("testdata/schema_enforcement_connectors.graphql"), + LicenseState::default(), + ); + + assert!( + report.restricted_schema_in_use.is_empty(), + "shouldn't have found restricted connect feature" + ); + } } diff --git a/apollo-router/src/uplink/license_stream.rs b/apollo-router/src/uplink/license_stream.rs index a07a497e98..b6f48359c2 100644 --- a/apollo-router/src/uplink/license_stream.rs +++ b/apollo-router/src/uplink/license_stream.rs @@ -12,18 +12,24 @@ use std::task::Poll; use std::time::Instant; use std::time::SystemTime; +use futures::Stream; +use futures::StreamExt; use futures::future::Ready; use futures::stream::FilterMap; use futures::stream::Fuse; use futures::stream::Repeat; use futures::stream::Zip; -use futures::Stream; -use futures::StreamExt; use graphql_client::GraphQLQuery; use pin_project_lite::pin_project; +use strum::IntoEnumIterator; use tokio_util::time::DelayQueue; +use super::license_enforcement::LicenseLimits; +use super::license_enforcement::TpsLimit; +use crate::AllowedFeature; use crate::router::Event; +use crate::uplink::UplinkRequest; +use crate::uplink::UplinkResponse; use crate::uplink::license_enforcement::Audience; use crate::uplink::license_enforcement::Claims; use crate::uplink::license_enforcement::License; @@ -31,8 +37,6 @@ use crate::uplink::license_enforcement::LicenseState; use crate::uplink::license_enforcement::OneOrMany; use crate::uplink::license_stream::license_query::FetchErrorCode; use crate::uplink::license_stream::license_query::LicenseQueryRouterEntitlements; -use crate::uplink::UplinkRequest; -use crate::uplink::UplinkResponse; const APOLLO_ROUTER_LICENSE_OFFLINE_UNSUPPORTED: &str = "APOLLO_ROUTER_LICENSE_OFFLINE_UNSUPPORTED"; @@ -152,7 +156,9 @@ where // Upstream has a new license with no claim. (_, Some(Poll::Ready(Some(_)))) => { // We don't clear the checks if there is a license with no claim. - Poll::Ready(Some(Event::UpdateLicense(LicenseState::Unlicensed))) + Poll::Ready(Some(Event::UpdateLicense(Arc::new( + LicenseState::Unlicensed, + )))) } // If either checks or upstream returned pending then we need to return pending. // It is the responsibility of upstream and checks to schedule wakeup. @@ -176,6 +182,35 @@ fn reset_checks_for_licenses( // We got a new claim, so clear the previous checks. checks.clear(); let claims = license.claims.as_ref().expect("claims is gated, qed"); + + // Router limitations based on claims + let limits = match (claims.tps, &claims.allowed_features) { + (None, None) => None, + (Some(tps_limit), Some(features)) => Some( + LicenseLimits::builder() + .tps( + TpsLimit::builder() + .capacity(tps_limit.capacity) + .interval(tps_limit.interval) + .build(), + ) + .allowed_features(HashSet::from_iter(features.clone())) + .build(), + ), + (Some(tps_limit), None) => Some(LicenseLimits { + tps: Some(TpsLimit { + capacity: tps_limit.capacity, + interval: tps_limit.interval, + }), + allowed_features: HashSet::from_iter(AllowedFeature::iter()), + }), + (None, Some(features)) => Some( + LicenseLimits::builder() + .allowed_features(HashSet::from_iter(features.clone())) + .build(), + ), + }; + let halt_at = to_positive_instant(claims.halt_at); let warn_at = to_positive_instant(claims.warn_at); let now = Instant::now(); @@ -183,24 +218,40 @@ fn reset_checks_for_licenses( if halt_at > now { // Only add halt if it isn't immediately going to be triggered. checks.insert_at( - Event::UpdateLicense(LicenseState::LicensedHalt), + Event::UpdateLicense(Arc::new(LicenseState::LicensedHalt { + limits: limits.clone(), + })), (halt_at).into(), ); } else { - return Poll::Ready(Some(Event::UpdateLicense(LicenseState::LicensedHalt))); + return Poll::Ready(Some(Event::UpdateLicense(Arc::new( + LicenseState::LicensedHalt { + limits: limits.clone(), + }, + )))); } if warn_at > now { // Only add warn if it isn't immediately going to be triggered and halt is not already set. // Something that is halted is by definition also warn. checks.insert_at( - Event::UpdateLicense(LicenseState::LicensedWarn), + Event::UpdateLicense(Arc::new(LicenseState::LicensedWarn { + limits: limits.clone(), + })), (warn_at).into(), ); } else { - return Poll::Ready(Some(Event::UpdateLicense(LicenseState::LicensedWarn))); + return Poll::Ready(Some(Event::UpdateLicense(Arc::new( + LicenseState::LicensedWarn { + limits: limits.clone(), + }, + )))); } - Poll::Ready(Some(Event::UpdateLicense(LicenseState::Licensed))) + Poll::Ready(Some(Event::UpdateLicense(Arc::new( + LicenseState::Licensed { + limits: limits.clone(), + }, + )))) } /// This function exists to generate an approximate Instant from a `SystemTime`. We have externally generated unix timestamps that need to be scheduled, but anything time related to scheduling must be an `Instant`. @@ -210,6 +261,7 @@ fn to_positive_instant(system_time: SystemTime) -> Instant { // This is approximate as there is no real conversion between SystemTime and Instant let now_instant = Instant::now(); let now_system_time = SystemTime::now(); + // system_time is likely to be a time in the future, but may be in the past. match system_time.duration_since(now_system_time) { // system_time was in the future. @@ -292,16 +344,16 @@ mod test { use crate::assert_snapshot_subscriber; use crate::router::Event; + use crate::uplink::UplinkConfig; use crate::uplink::license_enforcement::Audience; use crate::uplink::license_enforcement::Claims; use crate::uplink::license_enforcement::License; use crate::uplink::license_enforcement::LicenseState; use crate::uplink::license_enforcement::OneOrMany; - use crate::uplink::license_stream::to_positive_instant; use crate::uplink::license_stream::LicenseQuery; use crate::uplink::license_stream::LicenseStreamExt; + use crate::uplink::license_stream::to_positive_instant; use crate::uplink::stream_from_uplink; - use crate::uplink::UplinkConfig; #[tokio::test] async fn integration_test() { @@ -320,13 +372,15 @@ mod test { .collect::>() .await; - assert!(results - .first() - .expect("expected one result") - .as_ref() - .expect("license should be OK") - .claims - .is_some()) + assert!( + results + .first() + .expect("expected one result") + .as_ref() + .expect("license should be OK") + .claims + .is_some() + ) } } @@ -474,6 +528,8 @@ mod test { aud: OneOrMany::One(Audience::SelfHosted), warn_at: now + Duration::from_millis(warn_delta), halt_at: now + Duration::from_millis(halt_delta), + tps: Default::default(), + allowed_features: Default::default(), }), } } @@ -503,11 +559,13 @@ mod test { Event::NoMoreConfiguration => SimpleEvent::NoMoreConfiguration, Event::UpdateSchema(_) => SimpleEvent::UpdateSchema, Event::NoMoreSchema => SimpleEvent::NoMoreSchema, - Event::UpdateLicense(LicenseState::LicensedHalt) => SimpleEvent::HaltLicense, - Event::UpdateLicense(LicenseState::LicensedWarn) => SimpleEvent::WarnLicense, - Event::UpdateLicense(_) => SimpleEvent::UpdateLicense, + Event::UpdateLicense(license) => match *license { + LicenseState::LicensedHalt { limits: _ } => SimpleEvent::HaltLicense, + LicenseState::LicensedWarn { limits: _ } => SimpleEvent::WarnLicense, + _ => SimpleEvent::UpdateLicense, + }, Event::NoMoreLicense => SimpleEvent::NoMoreLicense, - Event::Reload => SimpleEvent::ForcedHotReload, + Event::Reload | Event::RhaiReload => SimpleEvent::ForcedHotReload, Event::Shutdown => SimpleEvent::Shutdown, } } @@ -523,6 +581,8 @@ mod test { aud: OneOrMany::One(Audience::Offline), warn_at: SystemTime::now(), halt_at: SystemTime::now(), + tps: Default::default(), + allowed_features: Default::default(), }), })) .validate_audience([Audience::Offline, Audience::Cloud]) @@ -543,6 +603,8 @@ mod test { aud: OneOrMany::One(Audience::SelfHosted), warn_at: SystemTime::now(), halt_at: SystemTime::now(), + tps: Default::default(), + allowed_features: Default::default(), }), })) .validate_audience([Audience::Offline, Audience::Cloud]) @@ -563,6 +625,8 @@ mod test { aud: OneOrMany::Many(vec![Audience::SelfHosted, Audience::Offline]), warn_at: SystemTime::now(), halt_at: SystemTime::now(), + tps: Default::default(), + allowed_features: Default::default(), }), })) .validate_audience([Audience::Offline, Audience::Cloud]) @@ -583,6 +647,8 @@ mod test { aud: OneOrMany::Many(vec![Audience::SelfHosted, Audience::SelfHosted]), warn_at: SystemTime::now(), halt_at: SystemTime::now(), + tps: Default::default(), + allowed_features: Default::default(), }), })) .validate_audience([Audience::Offline, Audience::Cloud]) diff --git a/apollo-router/src/uplink/mod.rs b/apollo-router/src/uplink/mod.rs index 6a8974699e..0f3d7edfe6 100644 --- a/apollo-router/src/uplink/mod.rs +++ b/apollo-router/src/uplink/mod.rs @@ -14,9 +14,12 @@ use tower::BoxError; use tracing::instrument::WithSubscriber; use url::Url; +pub(crate) mod feature_gate_enforcement; pub(crate) mod license_enforcement; pub(crate) mod license_stream; +mod parsed_link_spec; pub(crate) mod persisted_queries_manifest_stream; +pub(crate) mod schema; pub(crate) mod schema_stream; const GCP_URL: &str = "https://uplink.api.apollographql.com"; @@ -196,12 +199,12 @@ where pub(crate) fn stream_from_uplink_transforming_new_response( mut uplink_config: UplinkConfig, transform_new_response: impl Fn( - Response, - ) - -> Box> + Send + Unpin> - + Send - + Sync - + 'static, + Response, + ) -> Box< + dyn Future> + Send + Unpin, + > + Send + + Sync + + 'static, ) -> impl Stream> where Query: graphql_client::GraphQLQuery, @@ -210,7 +213,7 @@ where Response: Send + 'static + Debug, TransformedResponse: Send + 'static + Debug, { - let query = query_name::(); + let query_name = query_name::(); let (sender, receiver) = channel(2); let client = match reqwest::Client::builder() .no_gzip() @@ -245,10 +248,12 @@ where .await { Ok(response) => { - tracing::info!( - monotonic_counter.apollo_router_uplink_fetch_count_total = 1u64, + u64_counter!( + "apollo.router.uplink.fetch.count.total", + "Total number of requests to Apollo Uplink", + 1u64, status = "success", - query + query = query_name ); match response { UplinkResponse::New { @@ -260,7 +265,9 @@ where uplink_config.poll_interval = Duration::from_secs(delay); if let Err(e) = sender.send(Ok(response)).await { - tracing::debug!("failed to push to stream. This is likely to be because the router is shutting down: {e}"); + tracing::debug!( + "failed to push to stream. This is likely to be because the router is shutting down: {e}" + ); break; } } @@ -284,7 +291,9 @@ where Err(Error::UplinkErrorNoRetry { code, message }) }; if let Err(e) = sender.send(err).await { - tracing::debug!("failed to send error to uplink stream. This is likely to be because the router is shutting down: {e}"); + tracing::debug!( + "failed to send error to uplink stream. This is likely to be because the router is shutting down: {e}" + ); break; } if !retry_later { @@ -294,13 +303,17 @@ where } } Err(err) => { - tracing::info!( - monotonic_counter.apollo_router_uplink_fetch_count_total = 1u64, + u64_counter!( + "apollo.router.uplink.fetch.count.total", + "Total number of requests to Apollo Uplink", + 1u64, status = "failure", - query + query = query_name ); if let Err(e) = sender.send(Err(err)).await { - tracing::debug!("failed to send error to uplink stream. This is likely to be because the router is shutting down: {e}"); + tracing::debug!( + "failed to send error to uplink stream. This is likely to be because the router is shutting down: {e}" + ); break; } } @@ -320,12 +333,14 @@ pub(crate) async fn fetch( endpoints: &mut Endpoints, // See stream_from_uplink_transforming_new_response for an explanation of // this argument. - transform_new_response: &(impl Fn( + transform_new_response: &( + impl Fn( Response, ) -> Box> + Send + Unpin> - + Send - + Sync - + 'static), + + Send + + Sync + + 'static + ), ) -> Result, Error> where Query: graphql_client::GraphQLQuery, @@ -340,13 +355,14 @@ where match http_request::(client, url.as_str(), request_body).await { Ok(response) => match response.data.map(Into::into) { None => { - tracing::info!( - histogram.apollo_router_uplink_fetch_duration_seconds = - now.elapsed().as_secs_f64(), - query, + f64_histogram!( + "apollo.router.uplink.fetch.duration.seconds", + "Duration of Apollo Uplink fetches.", + now.elapsed().as_secs_f64(), + query = query, url = url.to_string(), - "kind" = "uplink_error", - error = "empty response from uplink", + kind = "uplink_error", + error = "empty response from uplink" ); } Some(UplinkResponse::New { @@ -354,12 +370,13 @@ where id, delay, }) => { - tracing::info!( - histogram.apollo_router_uplink_fetch_duration_seconds = - now.elapsed().as_secs_f64(), - query, + f64_histogram!( + "apollo.router.uplink.fetch.duration.seconds", + "Duration of Apollo Uplink fetches.", + now.elapsed().as_secs_f64(), + query = query, url = url.to_string(), - "kind" = "new" + kind = "new" ); match transform_new_response(response).await { Ok(res) => { @@ -367,25 +384,24 @@ where response: res, id, delay, - }) + }); } Err(err) => { tracing::debug!( - "failed to process results of Uplink response from {}: {}. Other endpoints will be tried", - url, - err - ); + "failed to process results of Uplink response from {url}: {err}. Other endpoints will be tried" + ); continue; } } } Some(UplinkResponse::Unchanged { id, delay }) => { - tracing::info!( - histogram.apollo_router_uplink_fetch_duration_seconds = - now.elapsed().as_secs_f64(), - query, + f64_histogram!( + "apollo.router.uplink.fetch.duration.seconds", + "Duration of Apollo Uplink fetches.", + now.elapsed().as_secs_f64(), + query = query, url = url.to_string(), - "kind" = "unchanged" + kind = "unchanged" ); return Ok(UplinkResponse::Unchanged { id, delay }); } @@ -394,14 +410,15 @@ where code, retry_later, }) => { - tracing::info!( - histogram.apollo_router_uplink_fetch_duration_seconds = - now.elapsed().as_secs_f64(), - query, + f64_histogram!( + "apollo.router.uplink.fetch.duration.seconds", + "Duration of Apollo Uplink fetches.", + now.elapsed().as_secs_f64(), + query = query, url = url.to_string(), - "kind" = "uplink_error", - error = message, - code + kind = "uplink_error", + error = message.clone(), + code = code.clone() ); return Ok(UplinkResponse::Error { message, @@ -410,20 +427,19 @@ where }); } }, - Err(e) => { - tracing::info!( - histogram.apollo_router_uplink_fetch_duration_seconds = - now.elapsed().as_secs_f64(), - query, + Err(err) => { + f64_histogram!( + "apollo.router.uplink.fetch.duration.seconds", + "Duration of Apollo Uplink fetches.", + now.elapsed().as_secs_f64(), + query = query, url = url.to_string(), - "kind" = "http_error", - error = e.to_string(), - code = e.status().unwrap_or_default().as_str() + kind = "http_error", + error = err.to_string(), + code = err.status().unwrap_or_default().to_string() ); tracing::debug!( - "failed to fetch from Uplink endpoint {}: {}. Other endpoints will be tried", - url, - e + "failed to fetch from Uplink endpoint {url}: {err}. Other endpoints will be tried" ); } }; @@ -463,17 +479,16 @@ where // That's deeply confusing and very hard to debug. Let's try to help by printing out a helpful error message here let res = client .post(url) + .header("x-router-version", env!("CARGO_PKG_VERSION")) .json(request_body) .send() .await .inspect_err(|e| { - if let Some(hyper_err) = e.source() { - if let Some(os_err) = hyper_err.source() { - if os_err.to_string().contains("tcp connect error: Cannot assign requested address (os error 99)") { + if let Some(hyper_err) = e.source() + && let Some(os_err) = hyper_err.source() + && os_err.to_string().contains("tcp connect error: Cannot assign requested address (os error 99)") { tracing::warn!("If your router is executing within a kubernetes pod, this failure may be caused by istio-proxy injection. See https://github.com/apollographql/router/issues/3533 for more details about how to solve this"); } - } - } })?; tracing::debug!("uplink response {:?}", res); let response_body: graphql_client::Response = res.json().await?; @@ -483,7 +498,6 @@ where #[cfg(test)] mod test { use std::collections::VecDeque; - use std::sync::Mutex; use std::time::Duration; use buildstructor::buildstructor; @@ -491,25 +505,26 @@ mod test { use graphql_client::GraphQLQuery; use http::StatusCode; use insta::assert_yaml_snapshot; + use parking_lot::Mutex; use serde_json::json; use test_query::FetchErrorCode; use test_query::TestQueryUplinkQuery; use url::Url; - use wiremock::matchers::method; - use wiremock::matchers::path; use wiremock::Mock; use wiremock::MockServer; use wiremock::Request; use wiremock::Respond; use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; - use crate::uplink::stream_from_uplink; - use crate::uplink::stream_from_uplink_transforming_new_response; use crate::uplink::Endpoints; use crate::uplink::Error; use crate::uplink::UplinkConfig; use crate::uplink::UplinkRequest; use crate::uplink::UplinkResponse; + use crate::uplink::stream_from_uplink; + use crate::uplink::stream_from_uplink_transforming_new_response; #[derive(GraphQLQuery)] #[graphql( @@ -949,7 +964,7 @@ mod test { fn to_friendly(r: Result) -> Result { match r { - Ok(e) => Ok(format!("result {:?}", e)), + Ok(e) => Ok(format!("result {e:?}")), Err(e) => Err(e.to_string()), } } @@ -973,7 +988,6 @@ mod test { fn respond(&self, _request: &Request) -> ResponseTemplate { self.responses .lock() - .expect("lock poisoned") .pop_front() .unwrap_or_else(response_fetch_error_test_error) } diff --git a/apollo-router/src/uplink/parsed_link_spec.rs b/apollo-router/src/uplink/parsed_link_spec.rs new file mode 100644 index 0000000000..cfbf7d972e --- /dev/null +++ b/apollo-router/src/uplink/parsed_link_spec.rs @@ -0,0 +1,106 @@ +use apollo_compiler::Name; +use apollo_compiler::Node; +use apollo_compiler::ast; +use apollo_compiler::schema::Directive; +use url::Url; + +use crate::spec::LINK_AS_ARGUMENT; +use crate::spec::LINK_URL_ARGUMENT; + +#[derive(Debug)] +pub(super) struct ParsedLinkSpec { + pub(super) spec_name: String, + pub(super) version: semver::Version, + pub(super) spec_url: String, + pub(super) imported_as: Option, + pub(super) url: String, +} + +impl ParsedLinkSpec { + pub(super) fn from_link_directive( + link_directive: &Directive, + ) -> Option> { + link_directive + .specified_argument_by_name(LINK_URL_ARGUMENT) + .and_then(|value| { + let url_string = value.as_str(); + + let parsed_url = Url::parse(url_string.unwrap_or_default()).ok()?; + + let mut segments = parsed_url.path_segments()?; + let spec_name = segments.next()?.to_string(); + let spec_url = format!( + "{}://{}/{}", + parsed_url.scheme(), + parsed_url.host()?, + spec_name + ); + let version_string = segments.next()?.strip_prefix('v')?; + let parsed_version = + semver::Version::parse(format!("{}.0", &version_string).as_str()).ok()?; + + let imported_as = link_directive + .specified_argument_by_name(LINK_AS_ARGUMENT) + .map(|as_arg| as_arg.as_str().unwrap_or_default().to_string()); + + Some(Ok(ParsedLinkSpec { + spec_name, + spec_url, + version: parsed_version, + imported_as, + url: url_string?.to_string(), + })) + }) + } + + pub(super) fn from_join_directive_args( + args: &[(Name, Node)], + ) -> Option> { + let url_string = args + .iter() + .find(|(name, _)| name == &Name::new_unchecked(LINK_URL_ARGUMENT)) + .and_then(|(_, value)| value.as_str()); + + let parsed_url = Url::parse(url_string.unwrap_or_default()).ok()?; + + let mut segments = parsed_url.path_segments()?; + let spec_name = segments.next()?.to_string(); + let spec_url = format!( + "{}://{}/{}", + parsed_url.scheme(), + parsed_url.host()?, + spec_name + ); + let version_string = segments.next()?.strip_prefix('v')?; + let parsed_version = + semver::Version::parse(format!("{}.0", &version_string).as_str()).ok()?; + + let imported_as = args + .iter() + .find(|(name, _)| name == &Name::new_unchecked(LINK_AS_ARGUMENT)) + .and_then(|(_, value)| value.as_str()) + .map(|s| s.to_string()); + + Some(Ok(ParsedLinkSpec { + spec_name, + spec_url, + version: parsed_version, + imported_as, + url: url_string?.to_string(), + })) + } + + // Implements directive name construction logic for link directives. + // 1. If the link directive has an `as` argument, use that as the prefix. + // 2. If the link directive's spec name is the same as the default name, use the default name with no prefix. + // 3. Otherwise, use the spec name as the prefix. + pub(super) fn directive_name(&self, default_name: &str) -> String { + if let Some(imported_as) = &self.imported_as { + format!("{imported_as}__{default_name}") + } else if self.spec_name == default_name { + default_name.to_string() + } else { + format!("{}__{}", self.spec_name, default_name) + } + } +} diff --git a/apollo-router/src/uplink/persisted_queries_manifest_stream.rs b/apollo-router/src/uplink/persisted_queries_manifest_stream.rs index 109aad3fc5..cd50614d99 100644 --- a/apollo-router/src/uplink/persisted_queries_manifest_stream.rs +++ b/apollo-router/src/uplink/persisted_queries_manifest_stream.rs @@ -5,11 +5,11 @@ use graphql_client::GraphQLQuery; +use crate::uplink::UplinkRequest; +use crate::uplink::UplinkResponse; use crate::uplink::persisted_queries_manifest_stream::persisted_queries_manifest_query::FetchErrorCode; use crate::uplink::persisted_queries_manifest_stream::persisted_queries_manifest_query::PersistedQueriesManifestQueryPersistedQueries; use crate::uplink::persisted_queries_manifest_stream::persisted_queries_manifest_query::PersistedQueriesManifestQueryPersistedQueriesOnPersistedQueriesResultChunks; -use crate::uplink::UplinkRequest; -use crate::uplink::UplinkResponse; #[derive(GraphQLQuery)] #[graphql( @@ -19,7 +19,6 @@ use crate::uplink::UplinkResponse; response_derives = "PartialEq, Debug, Deserialize", deprecated = "warn" )] - pub(crate) struct PersistedQueriesManifestQuery; impl From for persisted_queries_manifest_query::Variables { @@ -115,12 +114,12 @@ mod test { use futures::stream::StreamExt; use url::Url; + use crate::uplink::Endpoints; + use crate::uplink::GCP_URL; + use crate::uplink::UplinkConfig; use crate::uplink::persisted_queries_manifest_stream::MaybePersistedQueriesManifestChunks; use crate::uplink::persisted_queries_manifest_stream::PersistedQueriesManifestQuery; use crate::uplink::stream_from_uplink; - use crate::uplink::Endpoints; - use crate::uplink::UplinkConfig; - use crate::uplink::GCP_URL; #[tokio::test] async fn integration_test() { @@ -137,7 +136,7 @@ mod test { apollo_key: apollo_key.clone(), apollo_graph_ref: apollo_graph_ref.clone(), endpoints: Some(Endpoints::fallback(vec![ - Url::from_str(url).expect("url must be valid") + Url::from_str(url).expect("url must be valid"), ])), poll_interval: Duration::from_secs(1), timeout: Duration::from_secs(5), @@ -148,9 +147,9 @@ mod test { let persisted_query_manifest = results .first() - .unwrap_or_else(|| panic!("expected one result from {}", url)) + .unwrap_or_else(|| panic!("expected one result from {url}")) .as_ref() - .unwrap_or_else(|_| panic!("schema should be OK from {}", url)) + .unwrap_or_else(|_| panic!("schema should be OK from {url}")) .as_ref() .unwrap(); assert!(!persisted_query_manifest.is_empty()) diff --git a/apollo-router/src/uplink/schema.rs b/apollo-router/src/uplink/schema.rs new file mode 100644 index 0000000000..b7496d3ae4 --- /dev/null +++ b/apollo-router/src/uplink/schema.rs @@ -0,0 +1,20 @@ +use std::convert::Infallible; +use std::str::FromStr; + +/// Represents the new state of a schema after an update. +#[derive(Eq, PartialEq, Debug, Clone)] +pub(crate) struct SchemaState { + pub(crate) sdl: String, + pub(crate) launch_id: Option, +} + +impl FromStr for SchemaState { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self { + sdl: s.to_string(), + launch_id: None, + }) + } +} diff --git a/apollo-router/src/uplink/schema_stream.rs b/apollo-router/src/uplink/schema_stream.rs index ee1dcbda27..8639e3a171 100644 --- a/apollo-router/src/uplink/schema_stream.rs +++ b/apollo-router/src/uplink/schema_stream.rs @@ -5,10 +5,11 @@ use graphql_client::GraphQLQuery; -use crate::uplink::schema_stream::supergraph_sdl_query::FetchErrorCode; -use crate::uplink::schema_stream::supergraph_sdl_query::SupergraphSdlQueryRouterConfig; +use super::schema::SchemaState; use crate::uplink::UplinkRequest; use crate::uplink::UplinkResponse; +use crate::uplink::schema_stream::supergraph_sdl_query::FetchErrorCode; +use crate::uplink::schema_stream::supergraph_sdl_query::SupergraphSdlQueryRouterConfig; #[derive(GraphQLQuery)] #[graphql( @@ -18,7 +19,6 @@ use crate::uplink::UplinkResponse; response_derives = "PartialEq, Debug, Deserialize", deprecated = "warn" )] - pub(crate) struct SupergraphSdlQuery; impl From for supergraph_sdl_query::Variables { @@ -63,6 +63,41 @@ impl From for UplinkResponse { } } +impl From for UplinkResponse { + fn from(response: supergraph_sdl_query::ResponseData) -> Self { + match response.router_config { + SupergraphSdlQueryRouterConfig::RouterConfigResult(result) => UplinkResponse::New { + response: SchemaState { + sdl: result.supergraph_sdl, + launch_id: Some(result.id.clone()), + }, + id: result.id, + // this will truncate the number of seconds to under u64::MAX, which should be + // a large enough delay anyway + delay: result.min_delay_seconds as u64, + }, + SupergraphSdlQueryRouterConfig::Unchanged(response) => UplinkResponse::Unchanged { + id: Some(response.id), + delay: Some(response.min_delay_seconds as u64), + }, + SupergraphSdlQueryRouterConfig::FetchError(err) => UplinkResponse::Error { + retry_later: err.code == FetchErrorCode::RETRY_LATER, + code: match err.code { + FetchErrorCode::AUTHENTICATION_FAILED => "AUTHENTICATION_FAILED".to_string(), + FetchErrorCode::ACCESS_DENIED => "ACCESS_DENIED".to_string(), + FetchErrorCode::UNKNOWN_REF => "UNKNOWN_REF".to_string(), + FetchErrorCode::RETRY_LATER => "RETRY_LATER".to_string(), + FetchErrorCode::NOT_IMPLEMENTED_ON_THIS_INSTANCE => { + "NOT_IMPLEMENTED_ON_THIS_INSTANCE".to_string() + } + FetchErrorCode::Other(other) => other, + }, + message: err.message, + }, + } + } +} + #[cfg(test)] mod test { use std::str::FromStr; @@ -71,12 +106,12 @@ mod test { use futures::stream::StreamExt; use url::Url; - use crate::uplink::schema_stream::SupergraphSdlQuery; - use crate::uplink::stream_from_uplink; - use crate::uplink::Endpoints; - use crate::uplink::UplinkConfig; use crate::uplink::AWS_URL; + use crate::uplink::Endpoints; use crate::uplink::GCP_URL; + use crate::uplink::UplinkConfig; + use crate::uplink::schema_stream::SupergraphSdlQuery; + use crate::uplink::stream_from_uplink; #[tokio::test] async fn integration_test() { @@ -89,7 +124,7 @@ mod test { apollo_key, apollo_graph_ref, endpoints: Some(Endpoints::fallback(vec![ - Url::from_str(url).expect("url must be valid") + Url::from_str(url).expect("url must be valid"), ])), poll_interval: Duration::from_secs(1), timeout: Duration::from_secs(5), @@ -100,9 +135,9 @@ mod test { let schema = results .first() - .unwrap_or_else(|| panic!("expected one result from {}", url)) + .unwrap_or_else(|| panic!("expected one result from {url}")) .as_ref() - .unwrap_or_else(|_| panic!("schema should be OK from {}", url)); + .unwrap_or_else(|_| panic!("schema should be OK from {url}")); assert!(schema.contains("type Product")) } } diff --git a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__progressive_override.snap b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__progressive_override.snap deleted file mode 100644 index 69b219b013..0000000000 --- a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__progressive_override.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: apollo-router/src/uplink/license_enforcement.rs -assertion_line: 660 -expression: report.to_string() ---- -Schema features: -* @join__field.overrideLabel - https://specs.apollo.dev/join/v0.4 - -The `overrideLabel` argument on the join spec's @field directive is restricted to Enterprise users. This argument exists in your supergraph as a result of using the `@override` directive with the `label` argument in one or more of your subgraphs. diff --git a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__progressive_override_with_renamed_join_spec.snap b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__progressive_override_with_renamed_join_spec.snap deleted file mode 100644 index ad404e2292..0000000000 --- a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__progressive_override_with_renamed_join_spec.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: apollo-router/src/uplink/license_enforcement.rs -assertion_line: 674 -expression: report.to_string() ---- -Schema features: -* @j__field.overrideLabel - https://specs.apollo.dev/join/v0.4 - -The `overrideLabel` argument on the join spec's @field directive is restricted to Enterprise users. This argument exists in your supergraph as a result of using the `@override` directive with the `label` argument in one or more of your subgraphs. diff --git a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_authorization_directives_via_schema_unlicensed.snap b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_authorization_directives_via_schema_unlicensed.snap new file mode 100644 index 0000000000..60a9f08ef0 --- /dev/null +++ b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_authorization_directives_via_schema_unlicensed.snap @@ -0,0 +1,10 @@ +--- +source: apollo-router/src/uplink/license_enforcement.rs +expression: report.to_string() +--- +Schema features: +* @authenticated + https://specs.apollo.dev/authenticated/v0.1 + +* @requiresScopes + https://specs.apollo.dev/requiresScopes/v0.1 diff --git a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_authorization_directives_via_schema_with_restricted_allowed_features.snap b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_authorization_directives_via_schema_with_restricted_allowed_features.snap new file mode 100644 index 0000000000..60a9f08ef0 --- /dev/null +++ b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_authorization_directives_via_schema_with_restricted_allowed_features.snap @@ -0,0 +1,10 @@ +--- +source: apollo-router/src/uplink/license_enforcement.rs +expression: report.to_string() +--- +Schema features: +* @authenticated + https://specs.apollo.dev/authenticated/v0.1 + +* @requiresScopes + https://specs.apollo.dev/requiresScopes/v0.1 diff --git a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config.snap b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config.snap index cf517de1b5..b0c0e04aff 100644 --- a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config.snap +++ b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config.snap @@ -6,9 +6,6 @@ Configuration yaml: * Restricted .plugins.['experimental.restricted'].enabled -* Authentication plugin - .authentication.router - * Coprocessor plugin .coprocessor @@ -21,6 +18,9 @@ Configuration yaml: * Subgraph entity caching .preview_entity_cache.enabled +* Response caching + .experimental_response_cache.enabled + * Federated subscriptions .subscription.enabled @@ -51,11 +51,23 @@ Configuration yaml: * Advanced telemetry .telemetry..graphql -* File uploads plugin - .preview_file_uploads +* Apollo metrics extended references + .telemetry.apollo.metrics_reference_mode + +* APQ caching + .apq.router.cache.redis + +* Authentication plugin + .authentication.router * Demand control plugin .demand_control -* Apollo metrics extended references - .telemetry.apollo.metrics_reference_mode +* Subgraph entity caching + .preview_entity_cache.enabled + +* File uploads plugin + .preview_file_uploads + +* Federated subscriptions + .subscription.enabled diff --git a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config_allowed_features_empty.snap b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config_allowed_features_empty.snap new file mode 100644 index 0000000000..6b7ae67a03 --- /dev/null +++ b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config_allowed_features_empty.snap @@ -0,0 +1,40 @@ +--- +source: apollo-router/src/uplink/license_enforcement.rs +expression: report.to_string() +--- +Configuration yaml: +* APQ caching + .apq.router.cache.redis + +* Authentication plugin + .authentication.router + +* Subgraph entity caching + .preview_entity_cache.enabled + +* Federated subscriptions + .subscription.enabled + +* Coprocessor plugin + .coprocessor + +* Query plan caching + .supergraph.query_planning.cache.redis + +* Demand control plugin + .demand_control + +* Restricted + .plugins.['experimental.restricted'].enabled + +* Operation depth limiting + .limits.max_depth + +* Operation height limiting + .limits.max_height + +* Operation root fields limiting + .limits.max_root_fields + +* Operation aliases limiting + .limits.max_aliases diff --git a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config_unlicensed.snap b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config_unlicensed.snap new file mode 100644 index 0000000000..6b7ae67a03 --- /dev/null +++ b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config_unlicensed.snap @@ -0,0 +1,40 @@ +--- +source: apollo-router/src/uplink/license_enforcement.rs +expression: report.to_string() +--- +Configuration yaml: +* APQ caching + .apq.router.cache.redis + +* Authentication plugin + .authentication.router + +* Subgraph entity caching + .preview_entity_cache.enabled + +* Federated subscriptions + .subscription.enabled + +* Coprocessor plugin + .coprocessor + +* Query plan caching + .supergraph.query_planning.cache.redis + +* Demand control plugin + .demand_control + +* Restricted + .plugins.['experimental.restricted'].enabled + +* Operation depth limiting + .limits.max_depth + +* Operation height limiting + .limits.max_height + +* Operation root fields limiting + .limits.max_root_fields + +* Operation aliases limiting + .limits.max_aliases diff --git a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config_with_allowed_features.snap b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config_with_allowed_features.snap new file mode 100644 index 0000000000..b7c1d57949 --- /dev/null +++ b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config_with_allowed_features.snap @@ -0,0 +1,28 @@ +--- +source: apollo-router/src/uplink/license_enforcement.rs +expression: report.to_string() +--- +Configuration yaml: +* Federated subscriptions + .subscription.enabled + +* Coprocessor plugin + .coprocessor + +* Query plan caching + .supergraph.query_planning.cache.redis + +* Restricted + .plugins.['experimental.restricted'].enabled + +* Operation depth limiting + .limits.max_depth + +* Operation height limiting + .limits.max_height + +* Operation root fields limiting + .limits.max_root_fields + +* Operation aliases limiting + .limits.max_aliases diff --git a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_unix_socket_via_schema.snap b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_unix_socket_via_schema.snap deleted file mode 100644 index cead492d12..0000000000 --- a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_unix_socket_via_schema.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: apollo-router/src/uplink/license_enforcement.rs -expression: report.to_string() ---- -Schema features: -* @join__Graph.url - https://specs.apollo.dev/join/v0.3 - -Unix socket support for subgraph requests is restricted to Enterprise users diff --git a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__schema_enforcement_directive_arg_version_in_range.snap b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__schema_enforcement_directive_arg_version_in_range.snap deleted file mode 100644 index dc1cb74972..0000000000 --- a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__schema_enforcement_directive_arg_version_in_range.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: apollo-router/src/uplink/license_enforcement.rs -assertion_line: 741 -expression: report.to_string() ---- -Schema features: -* @join__field.overrideLabel - https://specs.apollo.dev/join/v0.4 - -The `overrideLabel` argument on the join spec's @field directive is restricted to Enterprise users. This argument exists in your supergraph as a result of using the `@override` directive with the `label` argument in one or more of your subgraphs. diff --git a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__set_context.snap b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__set_context.snap deleted file mode 100644 index 79ea2bbce0..0000000000 --- a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__set_context.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: apollo-router/src/uplink/license_enforcement.rs -expression: report.to_string() ---- -Schema features: -* @context - https://specs.apollo.dev/context/v0.1 - -* @join__field.contextArguments - https://specs.apollo.dev/join/v0.5 - -The `contextArguments` argument on the join spec's @field directive is restricted to Enterprise users. This argument exists in your supergraph as a result of using the `@fromContext` directive in one or more of your subgraphs. diff --git a/apollo-router/src/uplink/testdata/connectv0_3.router.yaml b/apollo-router/src/uplink/testdata/connectv0_3.router.yaml new file mode 100644 index 0000000000..8b4c2203b0 --- /dev/null +++ b/apollo-router/src/uplink/testdata/connectv0_3.router.yaml @@ -0,0 +1,8 @@ +health_check: + enabled: false +homepage: + enabled: false +limits: + parser_max_recursion: 1000 +connectors: + preview_connect_v0_3: true diff --git a/apollo-router/src/uplink/testdata/feature_enforcement_connect_v0_2.graphql b/apollo-router/src/uplink/testdata/feature_enforcement_connect_v0_2.graphql new file mode 100644 index 0000000000..56f3269c94 --- /dev/null +++ b/apollo-router/src/uplink/testdata/feature_enforcement_connect_v0_2.graphql @@ -0,0 +1,67 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive(graphs: [ONE], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.2", import: ["@source", "@connect"]}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + ONE @join__graph(name: "one", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Product + @join__type(graph: ONE) +{ + id: ID! +} + +type Query + @join__type(graph: ONE) +{ + products: [Product] @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost:4001/products"}, selection: "id"}) +} diff --git a/apollo-router/src/uplink/testdata/feature_enforcement_connect_v0_3.graphql b/apollo-router/src/uplink/testdata/feature_enforcement_connect_v0_3.graphql new file mode 100644 index 0000000000..1e3d053c02 --- /dev/null +++ b/apollo-router/src/uplink/testdata/feature_enforcement_connect_v0_3.graphql @@ -0,0 +1,67 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.3", for: EXECUTION) + @join__directive(graphs: [ONE], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.3", import: ["@source", "@connect"]}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + ONE @join__graph(name: "one", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Product + @join__type(graph: ONE) +{ + id: ID! +} + +type Query + @join__type(graph: ONE) +{ + products: [Product] @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost:4001/products"}, selection: "id"}) +} diff --git a/apollo-router/src/uplink/testdata/license.jwks.json b/apollo-router/src/uplink/testdata/license.jwks.json new file mode 100644 index 0000000000..90d3666e9a --- /dev/null +++ b/apollo-router/src/uplink/testdata/license.jwks.json @@ -0,0 +1,10 @@ +{ + "keys": [ + { + "kty": "oct", + "alg": "HS256", + "k": "bWFrZV9hX2xvbmdfc2VjcmV0X2Zvcl9yZmNfNzUxOF8yNTZfYml0c19yZXF1aXJlbWVudF9ibGFo", + "use": "sig" + } + ] +} \ No newline at end of file diff --git a/apollo-router/src/uplink/testdata/restricted.router.yaml b/apollo-router/src/uplink/testdata/restricted.router.yaml index fbdc5a0957..a0d8e659b3 100644 --- a/apollo-router/src/uplink/testdata/restricted.router.yaml +++ b/apollo-router/src/uplink/testdata/restricted.router.yaml @@ -52,6 +52,18 @@ plugins: experimental.restricted: enabled: true +experimental_response_cache: + enabled: true + invalidation: + listen: 0.0.0.0:4000 + path: /invalidation + subgraph: + all: + enabled: true + postgres: + url: "postgres://localhost:5432" + ttl: 360s # overrides the global TTL + preview_entity_cache: enabled: true invalidation: diff --git a/apollo-router/src/uplink/testdata/schema_enforcement_connectors.graphql b/apollo-router/src/uplink/testdata/schema_enforcement_connectors.graphql new file mode 100644 index 0000000000..d906273a06 --- /dev/null +++ b/apollo-router/src/uplink/testdata/schema_enforcement_connectors.graphql @@ -0,0 +1,121 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) + @join__directive( + graphs: [CONNECTORS] + name: "link" + args: { + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + } + ) + @join__directive( + graphs: [CONNECTORS] + name: "source" + args: { + name: "json" + http: { baseURL: "https://jsonplaceholder.typicode.com/" } + } + ) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +type Address @join__type(graph: CONNECTORS) { + street: String + suite: String + city: String + zipcode: String + geo: AddressGeo +} + +type AddressGeo @join__type(graph: CONNECTORS) { + lat: String + lng: String +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") + GRAPHQL @join__graph(name: "graphql", url: "https://localhost:4001") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query @join__type(graph: CONNECTORS) @join__type(graph: GRAPHQL) { + users: [User] + @join__field(graph: CONNECTORS) + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { source: "json", http: { GET: "/users" }, selection: "id name" } + ) +} + +type User + @join__type(graph: CONNECTORS, key: "id") + @join__type(graph: GRAPHQL, key: "id") { + id: ID! + name: String @join__field(graph: CONNECTORS) + c: String @join__field(graph: GRAPHQL) +} diff --git a/apollo-router/src/uplink/testdata/schema_enforcement_spec_version_out_of_range.graphql b/apollo-router/src/uplink/testdata/schema_enforcement_spec_version_out_of_range.graphql index 231cb51c48..d960c75e8a 100644 --- a/apollo-router/src/uplink/testdata/schema_enforcement_spec_version_out_of_range.graphql +++ b/apollo-router/src/uplink/testdata/schema_enforcement_spec_version_out_of_range.graphql @@ -1,7 +1,9 @@ schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) - @link(url: "https://specs.apollo.dev/authenticated/v0.2", for: SECURITY) { + # Note: `for` argument is not used on the `authenticated` link. Otherwise, schema validation + # would fail before license enforcement. + @link(url: "https://specs.apollo.dev/authenticated/v0.2") { query: Query } diff --git a/apollo-router/src/uplink/uplink.graphql b/apollo-router/src/uplink/uplink.graphql index cb8f530c7a..6a5f9fda9b 100644 --- a/apollo-router/src/uplink/uplink.graphql +++ b/apollo-router/src/uplink/uplink.graphql @@ -1,3 +1,5 @@ +"Long type" +scalar Long """ ISO 8601, extended format with nanoseconds, Zulu (or "[+-]seconds" as a string or number relative to now) """ @@ -50,6 +52,10 @@ type Query { ref: String! ): PersistedQueriesResponse! } +type RateLimit { + count: Long! + durationMs: Long! +} type RouterConfigResult { "Variant-unique identifier." id: ID! @@ -70,6 +76,8 @@ type RouterEntitlement { haltAt: Timestamp "Router should warn users after this time if commercial features are in use." warnAt: Timestamp + "Router should service requests only till the throughput limits specified in this map." + throughputLimit: RateLimit } type RouterEntitlementsResult { "Unique identifier for this result, to be passed in as `entitlements(unlessId:)`." diff --git a/apollo-router/templates/sandbox_index.html b/apollo-router/templates/sandbox_index.html index ec9db97705..2642787544 100644 --- a/apollo-router/templates/sandbox_index.html +++ b/apollo-router/templates/sandbox_index.html @@ -64,6 +64,7 @@

Welcome to the Apollo Router

}, hideCookieToggle: false, endpointIsEditable: false, + initialRequestConnectorsDebugging: true, runtime: "apollo-router@{{APOLLO_ROUTER_VERSION}}" }); diff --git a/apollo-router/tests/apollo_otel_traces.rs b/apollo-router/tests/apollo_otel_traces.rs index 89fe5428a2..be2e00a56c 100644 --- a/apollo-router/tests/apollo_otel_traces.rs +++ b/apollo-router/tests/apollo_otel_traces.rs @@ -16,16 +16,17 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use apollo_router::TestHarness; use apollo_router::make_fake_batch; use apollo_router::services::router; use apollo_router::services::router::BoxCloneService; use apollo_router::services::supergraph; -use apollo_router::TestHarness; -use axum::routing::post; use axum::Extension; use axum::Json; +use axum::routing::post; use bytes::Bytes; use http::header::ACCEPT; +use http_body_util::BodyExt as _; use once_cell::sync::Lazy; use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; use prost::Message; @@ -47,10 +48,10 @@ async fn config( batch: bool, reports: Arc>>, ) -> (JoinHandle<()>, serde_json::Value) { - std::env::set_var("APOLLO_KEY", "test"); - std::env::set_var("APOLLO_GRAPH_REF", "test"); + *apollo_router::_private::APOLLO_KEY.lock() = Some("test".to_string()); + *apollo_router::_private::APOLLO_GRAPH_REF.lock() = Some("test".to_string()); - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let app = axum::Router::new() .route("/", post(traces_handler)) @@ -58,18 +59,18 @@ async fn config( .layer(tower_http::add_extension::AddExtensionLayer::new(reports)); let task = ROUTER_SERVICE_RUNTIME.spawn(async move { - axum::Server::from_tcp(listener) - .expect("must be able to create otlp receiver") - .serve(app.into_make_service()) + axum::serve(listener, app) .await .expect("could not start axum server") }); let mut config: serde_json::Value = if batch { - serde_yaml::from_str(include_str!("fixtures/apollo_reports_batch.router.yaml")) - .expect("apollo_reports.router.yaml was invalid") + serde_yaml::from_str(include_str!( + "fixtures/reports/apollo_reports_batch.router.yaml" + )) + .expect("apollo_reports.router.yaml was invalid") } else { - serde_yaml::from_str(include_str!("fixtures/apollo_reports.router.yaml")) + serde_yaml::from_str(include_str!("fixtures/reports/apollo_reports.router.yaml")) .expect("apollo_reports.router.yaml was invalid") }; config = jsonpath_lib::replace_with(config, "$.telemetry.apollo.endpoint", &mut |_| { @@ -84,7 +85,7 @@ async fn config( .expect("Could not sub in endpoint"); config = jsonpath_lib::replace_with( config, - "$.telemetry.apollo.experimental_otlp_tracing_sampler", + "$.telemetry.apollo.otlp_tracing_sampler", &mut |_| Some(serde_json::Value::String("always_on".to_string())), ) .expect("Could not sub in otlp sampler"); @@ -108,6 +109,7 @@ async fn get_router_service( mocked: bool, ) -> (JoinHandle<()>, BoxCloneService) { let (task, config) = config(use_legacy_request_span, false, reports).await; + let builder = TestHarness::builder() .try_log_level("INFO") .configuration_json(config) @@ -127,6 +129,32 @@ async fn get_router_service( ) } +async fn get_connector_router_service( + reports: Arc>>, + use_legacy_request_span: bool, + mocked: bool, +) -> (JoinHandle<()>, BoxCloneService) { + let (task, config) = config(use_legacy_request_span, false, reports).await; + + let builder = TestHarness::builder() + .try_log_level("INFO") + .configuration_json(config) + .expect("test harness had config errors") + .schema(include_str!("fixtures/supergraph_connect.graphql")); + let builder = if mocked { + builder.subgraph_hook(|subgraph, _service| tracing_common::subgraph_mocks(subgraph)) + } else { + builder.with_subgraph_network_requests() + }; + ( + task, + builder + .build_router() + .await + .expect("could create router test harness"), + ) +} + async fn get_batch_router_service( reports: Arc>>, use_legacy_request_span: bool, @@ -173,6 +201,7 @@ macro_rules! assert_report { "apollo_private.http.response_headers", "apollo_private.sent_time_offset", "trace_id", + "graphql.error.path", ]; if $batch { redacted_attributes.append(&mut vec![ @@ -197,6 +226,7 @@ macro_rules! assert_report { ".**.parentSpanId" => "[span_id]", ".**.startTimeUnixNano" => "[start_time]", ".**.endTimeUnixNano" => "[end_time]", + ".**.timeUnixNano" => "[time]", }); }); } @@ -267,6 +297,31 @@ async fn get_trace_report( .await } +async fn get_connector_trace_report( + reports: Arc>>, + request: router::Request, + use_legacy_request_span: bool, +) -> ExportTraceServiceRequest { + get_traces( + get_connector_router_service, + reports, + use_legacy_request_span, + false, + request, + |r| { + !r.resource_spans + .first() + .expect("resource spans required") + .scope_spans + .first() + .expect("scope spans required") + .spans + .is_empty() + }, + ) + .await +} + async fn get_batch_trace_report( reports: Arc>>, request: router::Request, @@ -318,9 +373,12 @@ where .expect("router service call failed"); // Drain the response - let mut found_report = match hyper::body::to_bytes(response.response.into_body()) + let mut found_report = match response + .response + .into_body() + .collect() .await - .map(|b| String::from_utf8(b.to_vec())) + .map(|b| String::from_utf8(b.to_bytes().to_vec())) { Ok(Ok(response)) => { if response.contains("errors") { @@ -350,6 +408,34 @@ where .expect("failed to find report") } +#[tokio::test(flavor = "multi_thread")] +async fn connector() { + for use_legacy_request_span in [true, false] { + let request = supergraph::Request::fake_builder() + .query("query{posts{id body title}}") + .build() + .unwrap(); + let req: router::Request = request.try_into().expect("could not convert request"); + let reports = Arc::new(Mutex::new(vec![])); + let report = get_connector_trace_report(reports, req, use_legacy_request_span).await; + assert_report!(report); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn connector_error() { + for use_legacy_request_span in [true, false] { + let request = supergraph::Request::fake_builder() + .query("query{posts{id body title forceError}}") + .build() + .unwrap(); + let req: router::Request = request.try_into().expect("could not convert request"); + let reports = Arc::new(Mutex::new(vec![])); + let report = get_connector_trace_report(reports, req, use_legacy_request_span).await; + assert_report!(report); + } +} + #[tokio::test(flavor = "multi_thread")] async fn non_defer() { for use_legacy_request_span in [true, false] { diff --git a/apollo-router/tests/apollo_reports.rs b/apollo-router/tests/apollo_reports.rs index 006c3d8e97..999071ccde 100644 --- a/apollo-router/tests/apollo_reports.rs +++ b/apollo-router/tests/apollo_reports.rs @@ -23,17 +23,18 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use apollo_router::TestHarness; use apollo_router::make_fake_batch; use apollo_router::services::router; use apollo_router::services::router::BoxCloneService; use apollo_router::services::supergraph; -use apollo_router::TestHarness; -use axum::body::Bytes; -use axum::routing::post; use axum::Extension; use axum::Json; +use axum::body::Bytes; +use axum::routing::post; use flate2::read::GzDecoder; use http::header::ACCEPT; +use http_body_util::BodyExt as _; use once_cell::sync::Lazy; use prost::Message; use proto::reports::Report; @@ -53,15 +54,15 @@ static TEST: Lazy>> = Lazy::new(Default::default); async fn config( use_legacy_request_span: bool, - batch: bool, reports: Arc>>, demand_control: bool, experimental_field_stats: bool, + config_str: &str, ) -> (JoinHandle<()>, serde_json::Value) { - std::env::set_var("APOLLO_KEY", "test"); - std::env::set_var("APOLLO_GRAPH_REF", "test"); + *apollo_router::_private::APOLLO_KEY.lock() = Some("test".to_string()); + *apollo_router::_private::APOLLO_GRAPH_REF.lock() = Some("test".to_string()); - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let app = axum::Router::new() .route("/", post(report)) @@ -69,20 +70,13 @@ async fn config( .layer(tower_http::add_extension::AddExtensionLayer::new(reports)); let task = ROUTER_SERVICE_RUNTIME.spawn(async move { - axum::Server::from_tcp(listener) - .expect("mut be able to create report receiver") - .serve(app.into_make_service()) + axum::serve(listener, app.into_make_service()) .await .expect("could not start axum server") }); - let mut config: serde_json::Value = if batch { - serde_yaml::from_str(include_str!("fixtures/apollo_reports_batch.router.yaml")) - .expect("apollo_reports.router.yaml was invalid") - } else { - serde_yaml::from_str(include_str!("fixtures/apollo_reports.router.yaml")) - .expect("apollo_reports.router.yaml was invalid") - }; + let mut config = serde_yaml::from_str(config_str).expect("config yaml was invalid"); + config = jsonpath_lib::replace_with(config, "$.telemetry.apollo.endpoint", &mut |_| { Some(serde_json::Value::String(format!("http://{addr}"))) }) @@ -112,13 +106,14 @@ async fn get_router_service( mocked: bool, demand_control: bool, experimental_local_field_metrics: bool, + config_str: Option<&str>, ) -> (JoinHandle<()>, BoxCloneService) { let (task, config) = config( use_legacy_request_span, - false, reports, demand_control, experimental_local_field_metrics, + config_str.unwrap_or(include_str!("fixtures/reports/apollo_reports.router.yaml")), ) .await; let builder = TestHarness::builder() @@ -146,13 +141,16 @@ async fn get_batch_router_service( mocked: bool, demand_control: bool, experimental_local_field_metrics: bool, + config_str: Option<&str>, ) -> (JoinHandle<()>, BoxCloneService) { let (task, config) = config( use_legacy_request_span, - true, reports, demand_control, experimental_local_field_metrics, + config_str.unwrap_or(include_str!( + "fixtures/reports/apollo_reports_batch.router.yaml" + )), ) .await; let builder = TestHarness::builder() @@ -180,6 +178,7 @@ macro_rules! assert_report { insta::assert_yaml_snapshot!($report, { ".**.agent_version" => "[agent_version]", ".**.executable_schema_id" => "[executable_schema_id]", + ".**.agent_id" => "[agent_id]", ".header.hostname" => "[hostname]", ".header.uname" => "[uname]", ".**.seconds" => "[seconds]", @@ -245,6 +244,7 @@ async fn get_trace_report( use_legacy_request_span: bool, demand_control: bool, experimental_local_field_metrics: bool, + config_str: Option<&'static str>, ) -> Report { get_report( get_router_service, @@ -262,6 +262,7 @@ async fn get_trace_report( .trace .is_empty() }, + config_str, ) .await } @@ -272,6 +273,7 @@ async fn get_batch_trace_report( use_legacy_request_span: bool, demand_control: bool, experimental_local_field_metrics: bool, + config_str: Option<&'static str>, ) -> Report { get_report( get_batch_router_service, @@ -289,6 +291,7 @@ async fn get_batch_trace_report( .trace .is_empty() }, + config_str, ) .await } @@ -307,6 +310,7 @@ async fn get_metrics_report( request: router::Request, demand_control: bool, experimental_local_field_metrics: bool, + config_str: Option<&'static str>, ) -> Report { get_report( get_router_service, @@ -317,6 +321,7 @@ async fn get_metrics_report( demand_control, experimental_local_field_metrics, has_metrics, + config_str, ) .await } @@ -331,6 +336,7 @@ async fn get_batch_metrics_report( async fn get_metrics_report_mocked( reports: Arc>>, request: router::Request, + config_str: Option<&'static str>, ) -> Report { get_report( get_router_service, @@ -341,13 +347,21 @@ async fn get_metrics_report_mocked( false, false, has_metrics, + config_str, ) .await } #[allow(clippy::too_many_arguments)] async fn get_report bool + Send + Sync + Copy + 'static>( - service_fn: impl FnOnce(Arc>>, bool, bool, bool, bool) -> Fut, + service_fn: impl FnOnce( + Arc>>, + bool, + bool, + bool, + bool, + Option<&'static str>, + ) -> Fut, reports: Arc>>, use_legacy_request_span: bool, mocked: bool, @@ -355,6 +369,7 @@ async fn get_report bool + Send + Sync + Copy + 'static> demand_control: bool, experimental_local_field_metrics: bool, filter: T, + config_str: Option<&'static str>, ) -> Report where Fut: Future, BoxCloneService)>, @@ -367,6 +382,7 @@ where mocked, demand_control, experimental_local_field_metrics, + config_str, ) .await; let response = service @@ -378,9 +394,12 @@ where .expect("router service call failed"); // Drain the response - let mut found_report = match hyper::body::to_bytes(response.response.into_body()) + let mut found_report = match response + .response + .into_body() + .collect() .await - .map(|b| String::from_utf8(b.to_vec())) + .map(|b| String::from_utf8(b.to_bytes().to_vec())) { Ok(Ok(response)) => { if response.contains("errors") { @@ -418,7 +437,7 @@ async fn get_batch_stats_report bool + Send + Sync + Copy + ' let _guard = TEST.lock().await; reports.lock().await.clear(); let (task, mut service) = - get_batch_router_service(reports.clone(), mocked, false, false, false).await; + get_batch_router_service(reports.clone(), mocked, false, false, false, None).await; let response = service .ready() .await @@ -428,7 +447,7 @@ async fn get_batch_stats_report bool + Send + Sync + Copy + ' .expect("router service call failed"); // Drain the response (and throw it away) - let _found_report = hyper::body::to_bytes(response.response.into_body()).await; + let _found_report = response.response.into_body().collect().await; // Give the server a little time to export something // If this test fails, consider increasing this time. @@ -462,7 +481,8 @@ async fn non_defer() { .unwrap(); let req: router::Request = request.try_into().expect("could not convert request"); let reports = Arc::new(Mutex::new(vec![])); - let report = get_trace_report(reports, req, use_legacy_request_span, false, false).await; + let report = + get_trace_report(reports, req, use_legacy_request_span, false, false, None).await; assert_report!(report); } } @@ -478,7 +498,8 @@ async fn test_condition_if() { .unwrap(); let req: router::Request = request.try_into().expect("could not convert request"); let reports = Arc::new(Mutex::new(vec![])); - let report = get_trace_report(reports, req, use_legacy_request_span, false, false).await; + let report = + get_trace_report(reports, req, use_legacy_request_span, false, false, None).await; assert_report!(report); } } @@ -494,7 +515,8 @@ async fn test_condition_else() { .unwrap(); let req: router::Request = request.try_into().expect("could not convert request"); let reports = Arc::new(Mutex::new(vec![])); - let report = get_trace_report(reports, req, use_legacy_request_span, false, false).await; + let report = + get_trace_report(reports, req, use_legacy_request_span, false, false, None).await; assert_report!(report); } } @@ -508,7 +530,8 @@ async fn test_trace_id() { .unwrap(); let req: router::Request = request.try_into().expect("could not convert request"); let reports = Arc::new(Mutex::new(vec![])); - let report = get_trace_report(reports, req, use_legacy_request_span, false, false).await; + let report = + get_trace_report(reports, req, use_legacy_request_span, false, false, None).await; assert_report!(report); } } @@ -532,6 +555,7 @@ async fn test_batch_trace_id() { use_legacy_request_span, false, false, + None, ) .await; assert_report!(report); @@ -548,7 +572,8 @@ async fn test_client_name() { .unwrap(); let req: router::Request = request.try_into().expect("could not convert request"); let reports = Arc::new(Mutex::new(vec![])); - let report = get_trace_report(reports, req, use_legacy_request_span, false, false).await; + let report = + get_trace_report(reports, req, use_legacy_request_span, false, false, None).await; assert_report!(report); } } @@ -563,7 +588,8 @@ async fn test_client_version() { .unwrap(); let req: router::Request = request.try_into().expect("could not convert request"); let reports = Arc::new(Mutex::new(vec![])); - let report = get_trace_report(reports, req, use_legacy_request_span, false, false).await; + let report = + get_trace_report(reports, req, use_legacy_request_span, false, false, None).await; assert_report!(report); } } @@ -579,7 +605,8 @@ async fn test_send_header() { .unwrap(); let req: router::Request = request.try_into().expect("could not convert request"); let reports = Arc::new(Mutex::new(vec![])); - let report = get_trace_report(reports, req, use_legacy_request_span, false, false).await; + let report = + get_trace_report(reports, req, use_legacy_request_span, false, false, None).await; assert_report!(report); } } @@ -605,6 +632,7 @@ async fn test_batch_send_header() { use_legacy_request_span, false, false, + None, ) .await; assert_report!(report); @@ -622,7 +650,8 @@ async fn test_send_variable_value() { .unwrap(); let req: router::Request = request.try_into().expect("could not convert request"); let reports = Arc::new(Mutex::new(vec![])); - let report = get_trace_report(reports, req, use_legacy_request_span, false, false).await; + let report = + get_trace_report(reports, req, use_legacy_request_span, false, false, None).await; assert_report!(report); } } @@ -635,7 +664,7 @@ async fn test_stats() { .unwrap(); let req: router::Request = request.try_into().expect("could not convert request"); let reports = Arc::new(Mutex::new(vec![])); - let report = get_metrics_report(reports, req, false, false).await; + let report = get_metrics_report(reports, req, false, false, None).await; assert_report!(report); } @@ -667,7 +696,7 @@ async fn test_stats_mocked() { .unwrap(); let req: router::Request = request.try_into().expect("could not convert request"); let reports = Arc::new(Mutex::new(vec![])); - let report = get_metrics_report_mocked(reports, req).await; + let report = get_metrics_report_mocked(reports, req, None).await; let per_query = report.traces_per_query.values().next().unwrap(); let stats = per_query.stats_with_context.first().unwrap(); insta::with_settings!({sort_maps => true}, { @@ -685,7 +714,7 @@ async fn test_new_field_stats() { .unwrap(); let req: router::Request = request.try_into().expect("could not convert request"); let reports = Arc::new(Mutex::new(vec![])); - let report = get_metrics_report(reports, req, true, true).await; + let report = get_metrics_report(reports, req, true, true, None).await; assert_report!(report); } @@ -697,7 +726,7 @@ async fn test_demand_control_stats() { .unwrap(); let req: router::Request = request.try_into().expect("could not convert request"); let reports = Arc::new(Mutex::new(vec![])); - let report = get_metrics_report(reports, req, true, false).await; + let report = get_metrics_report(reports, req, true, false, None).await; assert_report!(report); } @@ -710,7 +739,8 @@ async fn test_demand_control_trace() { .unwrap(); let req: router::Request = request.try_into().expect("could not convert request"); let reports = Arc::new(Mutex::new(vec![])); - let report = get_trace_report(reports, req, use_legacy_request_span, true, false).await; + let report = + get_trace_report(reports, req, use_legacy_request_span, true, false, None).await; assert_report!(report); } } @@ -730,7 +760,49 @@ async fn test_demand_control_trace_batched() { let req: router::Request = request.into(); let reports = Arc::new(Mutex::new(vec![])); let report = - get_batch_trace_report(reports, req, use_legacy_request_span, true, false).await; + get_batch_trace_report(reports, req, use_legacy_request_span, true, false, None).await; assert_report!(report); } } + +#[tokio::test(flavor = "multi_thread")] +async fn test_features_enabled() { + let request = supergraph::Request::fake_builder() + .query("query{topProducts{name reviews {author{name}} reviews{author{name}}}}") + .build() + .unwrap(); + let req: router::Request = request.try_into().expect("could not convert request"); + let reports = Arc::new(Mutex::new(vec![])); + let report = get_metrics_report( + reports, + req, + false, + false, + Some(include_str!( + "fixtures/reports/all_features_enabled.router.yaml" + )), + ) + .await; + assert_report!(report); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_features_disabled() { + let request = supergraph::Request::fake_builder() + .query("query{topProducts{name reviews {author{name}} reviews{author{name}}}}") + .build() + .unwrap(); + let req: router::Request = request.try_into().expect("could not convert request"); + let reports = Arc::new(Mutex::new(vec![])); + let report = get_metrics_report( + reports, + req, + false, + false, + Some(include_str!( + "fixtures/reports/all_features_disabled.router.yaml" + )), + ) + .await; + assert_report!(report); +} diff --git a/apollo-router/tests/common.rs b/apollo-router/tests/common.rs index 671c462519..abc26b35b1 100644 --- a/apollo-router/tests/common.rs +++ b/apollo-router/tests/common.rs @@ -1,51 +1,53 @@ use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::ffi::OsString; use std::fs; use std::net::SocketAddr; use std::net::TcpListener; use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; -use std::sync::Mutex; +use std::sync::LazyLock; +use std::sync::OnceLock; use std::time::Duration; use buildstructor::buildstructor; -use fred::clients::RedisClient; +use fred::clients::Client as RedisClient; use fred::interfaces::ClientLike; use fred::interfaces::KeysInterface; -use fred::prelude::RedisConfig; -use fred::types::ScanType; -use fred::types::Scanner; +use fred::prelude::Config as RedisConfig; +use fred::types::scan::ScanType; +use fred::types::scan::Scanner; use futures::StreamExt; use http::header::ACCEPT; use http::header::CONTENT_TYPE; -use http::HeaderValue; -use mediatype::names::BOUNDARY; -use mediatype::names::FORM_DATA; -use mediatype::names::MULTIPART; -use mediatype::MediaType; -use mediatype::WriteParams; use mime::APPLICATION_JSON; +use opentelemetry::Context; +use opentelemetry::KeyValue; use opentelemetry::global; use opentelemetry::propagation::TextMapPropagator; -use opentelemetry::sdk::trace::config; -use opentelemetry::sdk::trace::BatchSpanProcessor; -use opentelemetry::sdk::trace::TracerProvider; -use opentelemetry::sdk::Resource; -use opentelemetry::testing::trace::NoopSpanExporter; +use opentelemetry::trace::SpanContext; use opentelemetry::trace::TraceContextExt; -use opentelemetry_api::trace::TraceId; -use opentelemetry_api::trace::TracerProvider as OtherTracerProvider; -use opentelemetry_api::Context; -use opentelemetry_api::KeyValue; +use opentelemetry::trace::TraceId; +use opentelemetry::trace::TracerProvider as OtherTracerProvider; use opentelemetry_otlp::HttpExporterBuilder; use opentelemetry_otlp::Protocol; use opentelemetry_otlp::SpanExporterBuilder; use opentelemetry_otlp::WithExportConfig; +use opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceRequest; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::testing::trace::NoopSpanExporter; +use opentelemetry_sdk::trace::BatchConfigBuilder; +use opentelemetry_sdk::trace::BatchSpanProcessor; +use opentelemetry_sdk::trace::Config; +use opentelemetry_sdk::trace::TracerProvider; use opentelemetry_semantic_conventions::resource::SERVICE_NAME; +use parking_lot::Mutex; +use prost::Message; use regex::Regex; use reqwest::Request; -use serde_json::json; use serde_json::Value; +use serde_json::json; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; use tokio::io::BufReader; @@ -59,15 +61,138 @@ use tracing_core::LevelFilter; use tracing_futures::Instrument; use tracing_futures::WithSubscriber; use tracing_opentelemetry::OpenTelemetrySpanExt; -use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::EnvFilter; use tracing_subscriber::Layer; use tracing_subscriber::Registry; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; use uuid::Uuid; -use wiremock::matchers::method; use wiremock::Mock; use wiremock::Respond; use wiremock::ResponseTemplate; +use wiremock::http::Method; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::path_regex; + +/// Global registry to keep track of allocated ports across all tests +/// This helps avoid port conflicts between concurrent tests +static ALLOCATED_PORTS: OnceLock>>> = OnceLock::new(); + +/// Global endpoint for JWKS used in testing. If you need to mint a test key, refer to the internal +/// router team's documentation for a script +#[allow(dead_code)] +pub static TEST_JWKS_ENDPOINT: LazyLock = LazyLock::new(|| { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("uplink") + .join("testdata") + .join("license.jwks.json") +}); + +fn get_allocated_ports() -> &'static Arc>> { + ALLOCATED_PORTS.get_or_init(|| Arc::new(Mutex::new(HashMap::new()))) +} + +/// Allocate a port that's currently available +/// The port is not actually bound, just marked as allocated to avoid conflicts +fn allocate_port(name: &str) -> std::io::Result { + let ports_registry = get_allocated_ports(); + + // Try to find an available port + for _ in 0..100 { + // Try up to 100 times to find a port + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + drop(listener); // Release the port immediately + + let mut ports = ports_registry.lock(); + if let Entry::Vacant(e) = ports.entry(port) { + e.insert(name.to_string()); + return Ok(port); + } + } + + Err(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + "Could not find available port after 100 attempts", + )) +} + +pub struct Query { + traced: bool, + psr: Option<&'static str>, + headers: HashMap, + content_type: String, + body: Value, +} + +impl Default for Query { + fn default() -> Self { + Query::builder().build() + } +} + +#[buildstructor::buildstructor] +impl Query { + #[builder] + pub fn new( + traced: Option, + psr: Option<&'static str>, + body: Option, + content_type: Option, + headers: HashMap, + ) -> Self { + Self { + traced: traced.unwrap_or(true), + psr, + body: body.unwrap_or( + json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}), + ), + content_type: content_type + .unwrap_or_else(|| APPLICATION_JSON.essence_str().to_string()), + headers, + } + } +} +impl Query { + #[allow(dead_code)] + pub fn with_bad_content_type(mut self) -> Self { + self.content_type = "garbage".to_string(); + self + } + + #[allow(dead_code)] + pub fn with_bad_query(mut self) -> Self { + self.body = json!({"garbage":{}}); + self + } + + #[allow(dead_code)] + pub fn with_invalid_query(mut self) -> Self { + self.body = json!({"query": "query {anInvalidField}", "variables":{}}); + self + } + + #[allow(dead_code)] + pub fn with_anonymous(mut self) -> Self { + self.body = json!({"query":"query {topProducts{name}}","variables":{}}); + self + } + + #[allow(dead_code)] + pub fn with_huge_query(mut self) -> Self { + self.body = json!({"query":"query {topProducts{name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name}}","variables":{}}); + self + } + + #[allow(dead_code)] + pub fn introspection() -> Query { + Query::builder() + .body(json!({"query":"{__schema {types {name}}}","variables":{}})) + .build() + } +} pub struct IntegrationTest { router: Option, @@ -76,9 +201,12 @@ pub struct IntegrationTest { router_location: PathBuf, stdio_tx: tokio::sync::mpsc::Sender, stdio_rx: tokio::sync::mpsc::Receiver, + apollo_otlp_metrics_rx: tokio::sync::mpsc::Receiver, collect_stdio: Option<(tokio::sync::oneshot::Sender, regex::Regex)>, _subgraphs: wiremock::MockServer, + _apollo_otlp_server: wiremock::MockServer, telemetry: Telemetry, + extra_propagator: Telemetry, pub _tracer_provider_client: TracerProvider, pub _tracer_provider_subgraph: TracerProvider, @@ -88,27 +216,133 @@ pub struct IntegrationTest { bind_address: Arc>>, redis_namespace: String, log: String, + subgraph_context: Arc>>, + logs: Vec, + port_replacements: HashMap, + jwt: Option, + env: Option>, } impl IntegrationTest { pub(crate) fn bind_address(&self) -> SocketAddr { self.bind_address .lock() - .expect("lock poisoned") .expect("no bind address set, router must be started first.") } + + /// Reserve a port for use in the test and return it + /// The port placeholder will be immediately replaced in the config file + /// Panics if the placeholder is not found in the config + /// This helps avoid port conflicts between concurrent tests + #[allow(dead_code)] + pub fn reserve_address(&mut self, placeholder_name: &str) -> u16 { + let port = allocate_port(placeholder_name).expect("Failed to allocate port"); + self.set_address(placeholder_name, port); + port + } + + /// Reserve a specific port for use in the test + /// The port placeholder will be immediately replaced in the config file + /// Panics if the placeholder is not found in the config + #[allow(dead_code)] + pub fn set_address(&mut self, placeholder_name: &str, port: u16) { + // Read current config + let current_config = std::fs::read_to_string(&self.test_config_location) + .expect("Failed to read config file"); + + // Check if placeholder exists in config + let placeholder_pattern = format!("{{{{{placeholder_name}}}}}"); + let port_pattern = format!(":{{{{{placeholder_name}}}}}"); + let addr_pattern = format!("127.0.0.1:{{{{{placeholder_name}}}}}"); + + if !current_config.contains(&placeholder_pattern) + && !current_config.contains(&port_pattern) + && !current_config.contains(&addr_pattern) + { + panic!( + "Placeholder '{placeholder_name}' not found in config file. Expected one of: '{placeholder_pattern}', '{port_pattern}', or '{addr_pattern}'" + ); + } + + // Store the replacement + self.port_replacements + .insert(placeholder_name.to_string(), port); + + // Apply the replacement immediately + let updated_config = merge_overrides( + ¤t_config, + &self._subgraph_overrides, + &self._apollo_otlp_server.uri().to_string(), + None, // Don't override bind address here + &self.redis_namespace, + Some(&self.port_replacements), + ); + + std::fs::write(&self.test_config_location, updated_config) + .expect("Failed to write updated config"); + } + + /// Set environment variables for the router subprocess + #[allow(dead_code)] + pub fn set_env(&mut self, env: HashMap) { + self.env.get_or_insert_with(HashMap::new).extend(env); + } + + /// Set an address placeholder using a URI, extracting the port automatically + /// This is a convenience method for the common pattern of extracting port from a server URI + #[allow(dead_code)] + pub fn set_address_from_uri(&mut self, placeholder_name: &str, uri: &str) { + let port = uri + .split(':') + .next_back() + .expect("URI should contain a port") + .parse::() + .expect("Port should be a valid u16"); + self.set_address(placeholder_name, port); + } + + /// Replace a string in the config file (for non-port replacements) + /// This is useful for dynamic config adjustments beyond port replacements + #[allow(dead_code)] + pub fn replace_config_string(&mut self, from: &str, to: &str) { + let current_config = std::fs::read_to_string(&self.test_config_location) + .expect("Failed to read config file"); + + let updated_config = current_config.replace(from, to); + + std::fs::write(&self.test_config_location, updated_config) + .expect("Failed to write updated config"); + } + + /// Replace a string in the config file (for non-port replacements) + /// This is useful for dynamic config adjustments beyond port replacements + #[allow(dead_code)] + pub fn replace_schema_string(&mut self, from: &str, to: &str) { + let current_schema = std::fs::read_to_string(&self.test_schema_location) + .expect("Failed to read schema file"); + + let updated_schema = current_schema.replace(from, to); + + std::fs::write(&self.test_schema_location, updated_schema) + .expect("Failed to write updated schema"); + } } struct TracedResponder { response_template: ResponseTemplate, telemetry: Telemetry, + extra_propagator: Telemetry, subscriber_subgraph: Dispatch, subgraph_callback: Option>, + subgraph_context: Arc>>, } impl Respond for TracedResponder { fn respond(&self, request: &wiremock::Request) -> ResponseTemplate { - let context = self.telemetry.extract_context(request); + let context = self.telemetry.extract_context(request, &Context::new()); + let context = self.extra_propagator.extract_context(request, &context); + + *self.subgraph_context.lock() = Some(context.span().span_context().clone()); tracing_core::dispatcher::with_default(&self.subscriber_subgraph, || { let _context_guard = context.attach(); let span = info_span!("subgraph server"); @@ -124,9 +358,8 @@ impl Respond for TracedResponder { #[derive(Debug, Clone, Default)] #[allow(dead_code)] pub enum Telemetry { - Jaeger, Otlp { - endpoint: String, + endpoint: Option, }, Datadog, Zipkin, @@ -136,27 +369,15 @@ pub enum Telemetry { impl Telemetry { fn tracer_provider(&self, service_name: &str) -> TracerProvider { - let config = config().with_resource(Resource::new(vec![KeyValue::new( + let config = Config::default().with_resource(Resource::new(vec![KeyValue::new( SERVICE_NAME, service_name.to_string(), )])); match self { - Telemetry::Jaeger => TracerProvider::builder() - .with_config(config) - .with_span_processor( - BatchSpanProcessor::builder( - opentelemetry_jaeger::new_agent_pipeline() - .with_service_name(service_name) - .build_sync_agent_exporter() - .expect("jaeger pipeline failed"), - opentelemetry::runtime::Tokio, - ) - .with_scheduled_delay(Duration::from_millis(10)) - .build(), - ) - .build(), - Telemetry::Otlp { endpoint } => TracerProvider::builder() + Telemetry::Otlp { + endpoint: Some(endpoint), + } => TracerProvider::builder() .with_config(config) .with_span_processor( BatchSpanProcessor::builder( @@ -167,9 +388,13 @@ impl Telemetry { ) .build_span_exporter() .expect("otlp pipeline failed"), - opentelemetry::runtime::Tokio, + opentelemetry_sdk::runtime::Tokio, + ) + .with_batch_config( + BatchConfigBuilder::default() + .with_scheduled_delay(Duration::from_millis(10)) + .build(), ) - .with_scheduled_delay(Duration::from_millis(10)) .build(), ) .build(), @@ -181,9 +406,13 @@ impl Telemetry { .with_service_name(service_name) .build_exporter() .expect("datadog pipeline failed"), - opentelemetry::runtime::Tokio, + opentelemetry_sdk::runtime::Tokio, + ) + .with_batch_config( + BatchConfigBuilder::default() + .with_scheduled_delay(Duration::from_millis(10)) + .build(), ) - .with_scheduled_delay(Duration::from_millis(10)) .build(), ) .build(), @@ -195,13 +424,17 @@ impl Telemetry { .with_service_name(service_name) .init_exporter() .expect("zipkin pipeline failed"), - opentelemetry::runtime::Tokio, + opentelemetry_sdk::runtime::Tokio, + ) + .with_batch_config( + BatchConfigBuilder::default() + .with_scheduled_delay(Duration::from_millis(10)) + .build(), ) - .with_scheduled_delay(Duration::from_millis(10)) .build(), ) .build(), - Telemetry::None => TracerProvider::builder() + Telemetry::None | Telemetry::Otlp { endpoint: None } => TracerProvider::builder() .with_config(config) .with_simple_exporter(NoopSpanExporter::default()) .build(), @@ -212,63 +445,101 @@ impl Telemetry { let ctx = tracing::span::Span::current().context(); match self { - Telemetry::Jaeger => { - let propagator = opentelemetry_jaeger::Propagator::new(); - propagator.inject_context( - &ctx, - &mut opentelemetry_http::HeaderInjector(request.headers_mut()), - ) - } Telemetry::Datadog => { + // Get the existing PSR header if it exists. This is because the existing telemetry propagator doesn't support PSR properly yet. + // In testing we are manually setting the PSR header, and we don't want to override it. + let psr = request + .headers() + .get("x-datadog-sampling-priority") + .cloned(); let propagator = opentelemetry_datadog::DatadogPropagator::new(); propagator.inject_context( &ctx, - &mut opentelemetry_http::HeaderInjector(request.headers_mut()), - ) + &mut apollo_router::otel_compat::HeaderInjector(request.headers_mut()), + ); + + if let Some(psr) = psr { + request + .headers_mut() + .insert("x-datadog-sampling-priority", psr); + } } Telemetry::Otlp { .. } => { - let propagator = opentelemetry::sdk::propagation::TraceContextPropagator::default(); + let propagator = opentelemetry_sdk::propagation::TraceContextPropagator::default(); propagator.inject_context( &ctx, - &mut opentelemetry_http::HeaderInjector(request.headers_mut()), + &mut apollo_router::otel_compat::HeaderInjector(request.headers_mut()), ) } Telemetry::Zipkin => { let propagator = opentelemetry_zipkin::Propagator::new(); propagator.inject_context( &ctx, - &mut opentelemetry_http::HeaderInjector(request.headers_mut()), + &mut apollo_router::otel_compat::HeaderInjector(request.headers_mut()), ) } _ => {} } } - pub(crate) fn extract_context(&self, request: &wiremock::Request) -> Context { + pub(crate) fn extract_context( + &self, + request: &wiremock::Request, + context: &Context, + ) -> Context { let headers: HashMap = request .headers .iter() - .map(|(name, value)| (name.as_str().to_string(), value.as_str().to_string())) + .map(|(name, value)| { + ( + name.as_str().to_string(), + value + .to_str() + .expect("non-UTF-8 header value in tests") + .to_string(), + ) + }) .collect(); match self { - Telemetry::Jaeger => { - let propagator = opentelemetry_jaeger::Propagator::new(); - propagator.extract(&headers) - } Telemetry::Datadog => { + let span_ref = context.span(); + let original_span_context = span_ref.span_context(); let propagator = opentelemetry_datadog::DatadogPropagator::new(); - propagator.extract(&headers) + let mut context = propagator.extract_with_context(context, &headers); + // We're going to override the sampled so that we can test sampling priority + if let Some(psr) = headers.get("x-datadog-sampling-priority") { + let state = context + .span() + .span_context() + .trace_state() + .insert("psr", psr.to_string()) + .expect("psr"); + let new_trace_id = if original_span_context.is_valid() { + original_span_context.trace_id() + } else { + context.span().span_context().trace_id() + }; + context = context.with_remote_span_context(SpanContext::new( + new_trace_id, + context.span().span_context().span_id(), + context.span().span_context().trace_flags(), + true, + state, + )); + } + + context } Telemetry::Otlp { .. } => { - let propagator = opentelemetry::sdk::propagation::TraceContextPropagator::default(); - propagator.extract(&headers) + let propagator = opentelemetry_sdk::propagation::TraceContextPropagator::default(); + propagator.extract_with_context(context, &headers) } Telemetry::Zipkin => { let propagator = opentelemetry_zipkin::Propagator::new(); - propagator.extract(&headers) + propagator.extract_with_context(context, &headers) } - _ => Context::current(), + _ => context.clone(), } } } @@ -279,15 +550,20 @@ impl IntegrationTest { pub async fn new( config: String, telemetry: Option, + extra_propagator: Option, responder: Option, collect_stdio: Option>, supergraph: Option, mut subgraph_overrides: HashMap, log: Option, subgraph_callback: Option>, + http_method: Option, + jwt: Option, + env: Option>, ) -> Self { let redis_namespace = Uuid::new_v4().to_string(); let telemetry = telemetry.unwrap_or_default(); + let extra_propagator = extra_propagator.unwrap_or_default(); let tracer_provider_client = telemetry.tracer_provider("client"); let subscriber_client = Self::dispatch(&tracer_provider_client); let tracer_provider_subgraph = telemetry.tracer_provider("subgraph"); @@ -296,11 +572,30 @@ impl IntegrationTest { let address = listener.local_addr().unwrap(); let url = format!("http://{address}/"); + let apollo_otlp_listener = + TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let apollo_otlp_address = apollo_otlp_listener.local_addr().unwrap(); + let apollo_otlp_endpoint = format!("http://{apollo_otlp_address}"); + // Add a default override for products, if not specified - subgraph_overrides.entry("products".into()).or_insert(url); + subgraph_overrides + .entry("products".into()) + .or_insert(url.clone()); + + // Add a default override for jsonPlaceholder (connectors), if not specified + subgraph_overrides + .entry("jsonPlaceholder".into()) + .or_insert(url.clone()); // Insert the overrides into the config - let config_str = merge_overrides(&config, &subgraph_overrides, None, &redis_namespace); + let config_str = merge_overrides( + &config, + &subgraph_overrides, + &apollo_otlp_endpoint, + None, + &redis_namespace, + None, + ); let supergraph = supergraph.unwrap_or(PathBuf::from_iter([ "..", @@ -313,12 +608,32 @@ impl IntegrationTest { .start() .await; - Mock::given(method("POST")) - .respond_with(TracedResponder{response_template:responder.unwrap_or_else(|| - ResponseTemplate::new(200).set_body_json(json!({"data":{"topProducts":[{"name":"Table"},{"name":"Couch"},{"name":"Chair"}]}}))), + // Allow for GET or POST so that connectors works + let http_method = match http_method.unwrap_or("POST".to_string()).as_str() { + "GET" => Method::GET, + "POST" => Method::POST, + _ => panic!("Unknown http method specified"), + }; + let subgraph_context = Arc::new(Mutex::new(None)); + Mock::given(method(http_method)) + .and(path_regex(".*")) // Match any path so that connectors functions + .respond_with(TracedResponder { + response_template: responder.unwrap_or_else(|| { + ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "topProducts": [ + { "name": "Table" }, + { "name": "Couch" }, + { "name": "Chair" }, + ], + }, + })) + }), telemetry: telemetry.clone(), + extra_propagator: extra_propagator.clone(), subscriber_subgraph: Self::dispatch(&tracer_provider_subgraph), - subgraph_callback + subgraph_callback, + subgraph_context: subgraph_context.clone(), }) .mount(&subgraphs) .await; @@ -338,6 +653,25 @@ impl IntegrationTest { (sender, version_line_re) }); + let (apollo_otlp_metrics_tx, apollo_otlp_metrics_rx) = tokio::sync::mpsc::channel(100); + let apollo_otlp_server = wiremock::MockServer::builder() + .listener(apollo_otlp_listener) + .start() + .await; + Mock::given(method(Method::POST)) + .and(path("/v1/metrics")) + .and(move |req: &wiremock::Request| { + // Decode the OTLP request + if let Ok(msg) = ExportMetricsServiceRequest::decode(req.body.as_ref()) { + // We don't care about the result of send here + let _ = apollo_otlp_metrics_tx.try_send(msg); + } + false + }) + .respond_with(ResponseTemplate::new(200)) + .mount(&apollo_otlp_server) + .await; + Self { router: None, router_location: Self::router_location(), @@ -345,16 +679,24 @@ impl IntegrationTest { test_schema_location, stdio_tx, stdio_rx, + apollo_otlp_metrics_rx, collect_stdio, _subgraphs: subgraphs, _subgraph_overrides: subgraph_overrides, + _apollo_otlp_server: apollo_otlp_server, bind_address: Default::default(), _tracer_provider_client: tracer_provider_client, subscriber_client, _tracer_provider_subgraph: tracer_provider_subgraph, telemetry, + extra_propagator, redis_namespace, log: log.unwrap_or_else(|| "error,apollo_router=info".to_owned()), + subgraph_context, + logs: vec![], + port_replacements: HashMap::new(), + jwt, + env, } } @@ -372,6 +714,11 @@ impl IntegrationTest { Dispatch::new(subscriber) } + #[allow(dead_code)] + pub fn subgraph_context(&self) -> SpanContext { + self.subgraph_context.lock().as_ref().unwrap().clone() + } + pub fn router_location() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_router")) } @@ -388,6 +735,14 @@ impl IntegrationTest { .env("APOLLO_GRAPH_REF", apollo_graph_ref); } + if let Some(env) = &self.env { + router.envs(env); + } + + if let Some(jwt) = &self.jwt { + router.env("APOLLO_ROUTER_LICENSE", jwt); + } + router .args(dbg!([ "--hr", @@ -412,12 +767,10 @@ impl IntegrationTest { let mut collected = Vec::new(); let mut lines = reader.lines(); while let Ok(Some(line)) = lines.next_line().await { - println!("{line}"); - // Extract the bind address from a log line that looks like this: GraphQL endpoint exposed at http://127.0.0.1:51087/ if let Some(captures) = bind_address_regex.captures(&line) { let address = captures.name("address").unwrap().as_str(); - let mut bind_address = bind_address.lock().unwrap(); + let mut bind_address = bind_address.lock(); *bind_address = Some(address.parse().unwrap()); } @@ -429,7 +782,11 @@ impl IntegrationTest { level: String, message: String, } - let log = serde_json::from_str::(&line).unwrap(); + let Ok(log) = serde_json::from_str::(&line) else { + panic!( + "line: '{line}' isn't JSON, might you have some debug output in the logging?" + ); + }; // Omit this message from snapshots since it depends on external environment if !log.message.starts_with("RUST_BACKTRACE=full detected") { collected.push(format!( @@ -453,12 +810,12 @@ impl IntegrationTest { #[allow(dead_code)] pub async fn assert_started(&mut self) { - self.assert_log_contains("GraphQL endpoint exposed").await; + self.wait_for_log_message("GraphQL endpoint exposed").await; } #[allow(dead_code)] pub async fn assert_not_started(&mut self) { - self.assert_log_contains("no valid configuration").await; + self.wait_for_log_message("no valid configuration").await; } #[allow(dead_code)] @@ -480,8 +837,10 @@ impl IntegrationTest { &merge_overrides( yaml, &self._subgraph_overrides, + &self._apollo_otlp_server.uri().to_string(), Some(self.bind_address()), &self.redis_namespace, + Some(&self.port_replacements), ), ) .await @@ -501,94 +860,61 @@ impl IntegrationTest { #[allow(dead_code)] pub fn execute_default_query( &self, - ) -> impl std::future::Future { - self.execute_query_internal( - &json!({"query":"query {topProducts{name}}","variables":{}}), - None, - None, - ) + ) -> impl std::future::Future + use<> { + self.execute_query(Query::builder().build()) } #[allow(dead_code)] pub fn execute_query( &self, - query: &Value, - ) -> impl std::future::Future { - self.execute_query_internal(query, None, None) - } - - #[allow(dead_code)] - pub fn execute_bad_query( - &self, - ) -> impl std::future::Future { - self.execute_query_internal(&json!({"garbage":{}}), None, None) - } - - #[allow(dead_code)] - pub fn execute_huge_query( - &self, - ) -> impl std::future::Future { - self.execute_query_internal(&json!({"query":"query {topProducts{name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name, name}}","variables":{}}), None, None) - } - - #[allow(dead_code)] - pub fn execute_bad_content_type( - &self, - ) -> impl std::future::Future { - self.execute_query_internal(&json!({"garbage":{}}), Some("garbage"), None) - } - - #[allow(dead_code)] - pub fn execute_query_with_headers( - &self, - query: &Value, - headers: HashMap, - ) -> impl std::future::Future { - self.execute_query_internal(query, None, Some(headers)) - } - - fn execute_query_internal( - &self, - query: &Value, - content_type: Option<&'static str>, - headers: Option>, - ) -> impl std::future::Future { + query: Query, + ) -> impl std::future::Future + use<> { assert!( self.router.is_some(), "router was not started, call `router.start().await; router.assert_started().await`" ); let telemetry = self.telemetry.clone(); + let extra_propagator = self.extra_propagator.clone(); - let query = query.clone(); let url = format!("http://{}", self.bind_address()); - + let subgraph_context = self.subgraph_context.clone(); async move { let span = info_span!("client_request"); - let span_id = span.context().span().span_context().trace_id(); + let trace_id = span.context().span().span_context().trace_id(); async move { let client = reqwest::Client::new(); - let mut builder = client - .post(url) - .header( - CONTENT_TYPE, - content_type.unwrap_or(APPLICATION_JSON.essence_str()), - ) - .header("apollographql-client-name", "custom_name") - .header("apollographql-client-version", "1.0") - .header("x-my-header", "test") - .header("head", "test"); + let mut builder = client.post(url).header(CONTENT_TYPE, query.content_type); - if let Some(headers) = headers { - for (name, value) in headers { - builder = builder.header(name, value); - } + for (name, value) in query.headers { + builder = builder.header(name, value); + } + + if let Some(psr) = query.psr { + builder = builder.header("x-datadog-sampling-priority", psr); + } + + let mut request = builder.json(&query.body).build().unwrap(); + if query.traced { + telemetry.inject_context(&mut request); + extra_propagator.inject_context(&mut request); } - let mut request = builder.json(&query).build().unwrap(); - telemetry.inject_context(&mut request); match client.execute(request).await { - Ok(response) => (span_id, response), + Ok(response) => { + if query.traced { + (trace_id, response) + } else { + ( + subgraph_context + .lock() + .as_ref() + .expect("subgraph context") + .trace_id(), + response, + ) + } + } Err(err) => { panic!("unable to send successful request to router, {err}") } @@ -600,60 +926,13 @@ impl IntegrationTest { .with_subscriber(self.subscriber_client.clone()) } - #[allow(dead_code)] - pub fn execute_untraced_query( - &self, - query: &Value, - ) -> impl std::future::Future { - assert!( - self.router.is_some(), - "router was not started, call `router.start().await; router.assert_started().await`" - ); - let query = query.clone(); - let url = format!("http://{}", self.bind_address()); - - async move { - let client = reqwest::Client::new(); - - let mut request = client - .post(url) - .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) - .header("apollographql-client-name", "custom_name") - .header("apollographql-client-version", "1.0") - .json(&query) - .build() - .unwrap(); - - request.headers_mut().remove(ACCEPT); - match client.execute(request).await { - Ok(response) => ( - TraceId::from_hex( - response - .headers() - .get("apollo-custom-trace-id") - .cloned() - .unwrap_or(HeaderValue::from_static("no-trace-id")) - .to_str() - .unwrap_or_default(), - ) - .unwrap_or(TraceId::INVALID), - response, - ), - Err(err) => { - panic!("unable to send successful request to router, {err}") - } - } - } - .with_subscriber(self.subscriber_client.clone()) - } - /// Make a raw multipart request to the router. #[allow(dead_code)] pub fn execute_multipart_request( &self, request: reqwest::multipart::Form, transform: Option reqwest::Request>, - ) -> impl std::future::Future { + ) -> impl std::future::Future + use<> { assert!( self.router.is_some(), "router was not started, call `router.start().await; router.assert_started().await`" @@ -666,19 +945,11 @@ impl IntegrationTest { async move { let client = reqwest::Client::new(); - let mime = { - let mut m = MediaType::new(MULTIPART, FORM_DATA); - m.set_param(BOUNDARY, mediatype::Value::new(request.boundary()).unwrap()); - - m - }; - let mut request = client .post(url) - .header(CONTENT_TYPE, mime.to_string()) .header("apollographql-client-name", "custom_name") .header("apollographql-client-version", "1.0") - .header("x-my-header", "test") + .header("apollo-require-preflight", "test") .multipart(request) .build() .unwrap(); @@ -689,7 +960,7 @@ impl IntegrationTest { global::get_text_map_propagator(|propagator| { propagator.inject_context( &tracing::span::Span::current().context(), - &mut opentelemetry_http::HeaderInjector(request.headers_mut()), + &mut apollo_router::otel_compat::HeaderInjector(request.headers_mut()), ); }); request.headers_mut().remove(ACCEPT); @@ -730,7 +1001,7 @@ impl IntegrationTest { global::get_text_map_propagator(|propagator| { propagator.inject_context( &span.context(), - &mut opentelemetry_http::HeaderInjector(request.headers_mut()), + &mut apollo_router::otel_compat::HeaderInjector(request.headers_mut()), ); }); @@ -756,6 +1027,36 @@ impl IntegrationTest { client.execute(request).await } + /// Waits for any metrics to be emitted for the given duration. This will return as soon as the + /// first batch of metrics is received. + #[allow(dead_code)] + pub async fn wait_for_emitted_otel_metrics( + &mut self, + duration: Duration, + ) -> Vec { + let deadline = Instant::now() + duration; + let mut metrics = Vec::new(); + + while Instant::now() < deadline { + if let Some(msg) = self.apollo_otlp_metrics_rx.recv().await { + // Only break once we see a batch with metrics in it + if msg + .resource_metrics + .iter() + .any(|rm| !rm.scope_metrics.is_empty()) + { + metrics.push(msg); + break; + } + } else { + // channel closed + break; + } + } + + metrics + } + #[allow(dead_code)] #[cfg(target_family = "unix")] pub async fn graceful_shutdown(&mut self) { @@ -784,7 +1085,7 @@ impl IntegrationTest { } #[allow(dead_code)] - pub(crate) fn pid(&mut self) -> i32 { + pub(crate) fn pid(&self) -> i32 { self.router .as_ref() .expect("router must have been started") @@ -794,25 +1095,26 @@ impl IntegrationTest { #[allow(dead_code)] pub async fn assert_reloaded(&mut self) { - self.assert_log_contains("reload complete").await; + self.wait_for_log_message("reload complete").await; } #[allow(dead_code)] pub async fn assert_no_reload_necessary(&mut self) { - self.assert_log_contains("no reload necessary").await; + self.wait_for_log_message("no reload necessary").await; } #[allow(dead_code)] pub async fn assert_not_reloaded(&mut self) { - self.assert_log_contains("continuing with previous configuration") + self.wait_for_log_message("continuing with previous configuration") .await; } #[allow(dead_code)] - pub async fn assert_log_contains(&mut self, msg: &str) { + pub async fn wait_for_log_message(&mut self, msg: &str) { let now = Instant::now(); while now.elapsed() < Duration::from_secs(10) { if let Ok(line) = self.stdio_rx.try_recv() { + self.logs.push(line.to_string()); if line.contains(msg) { return; } @@ -820,27 +1122,161 @@ impl IntegrationTest { tokio::time::sleep(Duration::from_millis(10)).await; } self.dump_stack_traces(); - panic!("'{msg}' not detected in logs"); + panic!( + "'{msg}' not detected in logs. Log dump below:\n\n{logs}", + logs = self.logs.join("\n") + ); + } + + #[allow(dead_code)] + pub fn print_logs(&self) { + for line in &self.logs { + println!("{line}"); + } + } + + #[allow(dead_code)] + pub fn read_logs(&mut self) { + while let Ok(line) = self.stdio_rx.try_recv() { + self.logs.push(line); + } + } + + #[allow(dead_code)] + pub fn capture_logs(&mut self, try_match_line: impl Fn(String) -> Option) -> Vec { + let mut logs = Vec::new(); + while let Ok(line) = self.stdio_rx.try_recv() { + if let Some(log) = try_match_line(line) { + logs.push(log); + } + } + logs + } + + #[allow(dead_code)] + pub fn assert_log_contained(&self, msg: &str) { + for line in &self.logs { + if line.contains(msg) { + return; + } + } + + panic!( + "'{msg}' not detected in logs. Log dump below:\n\n{logs}", + logs = self.logs.join("\n") + ); } #[allow(dead_code)] pub async fn assert_log_not_contains(&mut self, msg: &str) { let now = Instant::now(); while now.elapsed() < Duration::from_secs(5) { - if let Ok(line) = self.stdio_rx.try_recv() { + if let Ok(line) = self.stdio_rx.try_recv() + && line.contains(msg) + { + self.dump_stack_traces(); + panic!( + "'{msg}' detected in logs. Log dump below:\n\n{logs}", + logs = self.logs.join("\n") + ); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + #[allow(dead_code)] + pub fn assert_log_not_contained(&self, msg: &str) { + for line in &self.logs { + if line.contains(msg) { + panic!( + "'{msg}' detected in logs. Log dump below:\n\n{logs}", + logs = self.logs.join("\n") + ); + } + } + } + + #[allow(dead_code)] + pub fn error_logs(&mut self) -> Vec { + // Read any remaining logs from buffer + self.read_logs(); + + const JSON_ERROR_INDICATORS: [&str; 3] = ["\"level\":\"ERROR\"", "panic", "PANIC"]; + + let mut error_logs = Vec::new(); + for line in &self.logs { + if JSON_ERROR_INDICATORS.iter().any(|err| line.contains(err)) + || (line.contains("ERROR") && !line.contains("level")) + { + error_logs.push(line.clone()); + } + } + error_logs + } + #[allow(dead_code)] + pub async fn assert_error_log_contained(&mut self, msg: &str) { + let now = Instant::now(); + let mut found_error_message = false; + while now.elapsed() < Duration::from_secs(10) { + let error_logs = self.error_logs(); + for line in error_logs.into_iter() { if line.contains(msg) { - self.dump_stack_traces(); - panic!("'{msg}' detected in logs"); + found_error_message = true; + break; } } tokio::time::sleep(Duration::from_millis(10)).await; } + if !found_error_message { + panic!( + "Did not find expected error in router logs:\n\n{}\n\nFull log dump:\n\n{}", + self.error_logs().join("\n"), + self.logs.join("\n") + ); + } + } + #[allow(dead_code)] + pub fn assert_no_error_logs(&mut self) { + let error_logs = self.error_logs(); + if !error_logs.is_empty() { + panic!( + "Found {} unexpected error(s) in router logs:\n\n{}\n\nFull log dump:\n\n{}", + error_logs.len(), + error_logs.join("\n"), + self.logs.join("\n") + ); + } + } + #[allow(dead_code)] + pub fn assert_no_error_logs_with_exceptions(&mut self, exceptions: &[&str]) { + let mut error_logs = self.error_logs(); + + // remove any logs that contain our exceptions + error_logs.retain(|line| !exceptions.iter().any(|exception| line.contains(exception))); + if !error_logs.is_empty() { + panic!( + "Found {} unexpected error(s) in router logs (excluding {} exceptions):\n\n{}\n\nFull log dump:\n\n{}", + error_logs.len(), + exceptions.len(), + error_logs.join("\n"), + self.logs.join("\n") + ); + } } #[allow(dead_code)] + /// Checks the metrics contain the supplied string in prometheus format. + /// To allow checking of metrics where the value is not stable the magic tag `` can be used. + /// For example: + /// ```rust,ignore + /// router.assert_metrics_contains(r#"apollo_router_pipelines{config_hash="",schema_id="",otel_scope_name="apollo/router"} 1"#, None) + /// ``` + /// Will allow the metric to be checked even if the config hash and schema id are fluid. pub async fn assert_metrics_contains(&self, text: &str, duration: Option) { let now = Instant::now(); let mut last_metrics = String::new(); + let text = regex::escape(text).replace("", ".+"); + let re = Regex::new(&format!("(?m)^{text}")).expect("Invalid regex"); while now.elapsed() < duration.unwrap_or_else(|| Duration::from_secs(15)) { if let Ok(metrics) = self .get_metrics_response() @@ -849,7 +1285,7 @@ impl IntegrationTest { .text() .await { - if metrics.contains(text) { + if re.is_match(&metrics) { return; } last_metrics = metrics; @@ -901,10 +1337,9 @@ impl IntegrationTest { .expect("failed to fetch metrics") .text() .await + && metrics.contains(text) { - if metrics.contains(text) { - panic!("'{text}' detected in metrics\n{metrics}"); - } + panic!("'{text}' detected in metrics\n{metrics}"); } } @@ -936,7 +1371,7 @@ impl IntegrationTest { } #[cfg(target_os = "linux")] - pub fn dump_stack_traces(&mut self) { + pub fn dump_stack_traces(&self) { if let Ok(trace) = rstack::TraceOptions::new() .symbols(true) .thread_names(true) @@ -962,7 +1397,7 @@ impl IntegrationTest { } } #[cfg(not(target_os = "linux"))] - pub fn dump_stack_traces(&mut self) {} + pub fn dump_stack_traces(&self) {} #[allow(dead_code)] pub(crate) fn force_flush(&self) { @@ -1041,7 +1476,9 @@ impl IntegrationTest { } } } - panic!("key {key} not found: {e}\n This may be caused by a number of things including federation version changes"); + panic!( + "key {key} not found: {e}\n This may be caused by a number of things including federation version changes" + ); } }; @@ -1067,19 +1504,41 @@ impl Drop for IntegrationTest { fn merge_overrides( yaml: &str, subgraph_overrides: &HashMap, + apollo_otlp_endpoint: &str, bind_addr: Option, redis_namespace: &str, + port_replacements: Option<&HashMap>, ) -> String { let bind_addr = bind_addr .map(|a| a.to_string()) .unwrap_or_else(|| "127.0.0.1:0".into()); + + // Apply port replacements to the YAML string first + let mut yaml_with_ports = yaml.to_string(); + if let Some(port_replacements) = port_replacements { + for (placeholder, port) in port_replacements { + // Replace placeholder patterns like {{PLACEHOLDER_NAME}} with the actual port + let placeholder_pattern = format!("{{{{{placeholder}}}}}"); + yaml_with_ports = yaml_with_ports.replace(&placeholder_pattern, &port.to_string()); + + // Also replace patterns like :{{PLACEHOLDER_NAME}} with :port + let port_pattern = format!(":{{{{{placeholder}}}}}"); + yaml_with_ports = yaml_with_ports.replace(&port_pattern, &format!(":{port}")); + + // Replace full address patterns like 127.0.0.1:{{PLACEHOLDER_NAME}} + let addr_pattern = format!("127.0.0.1:{{{{{placeholder}}}}}"); + yaml_with_ports = yaml_with_ports.replace(&addr_pattern, &format!("127.0.0.1:{port}")); + } + } + // Parse the config as yaml - let mut config: Value = serde_yaml::from_str(yaml).unwrap(); + let mut config: Value = serde_yaml::from_str(&yaml_with_ports).unwrap(); // Insert subgraph overrides, making sure to keep other overrides if present let overrides = subgraph_overrides .iter() .map(|(name, url)| (name.clone(), serde_json::Value::String(url.clone()))); + let overrides2 = overrides.clone(); match config .as_object_mut() .and_then(|o| o.get_mut("override_subgraph_url")) @@ -1094,6 +1553,19 @@ fn merge_overrides( override_url.extend(overrides); } } + if let Some(sources) = config + .as_object_mut() + .and_then(|o| o.get_mut("connectors")) + .and_then(|o| o.as_object_mut()) + .and_then(|o| o.get_mut("sources")) + .and_then(|o| o.as_object_mut()) + { + for (name, url) in overrides2 { + let mut obj = serde_json::Map::new(); + obj.insert("override_url".to_string(), url.clone()); + sources.insert(format!("connectors.{name}"), Value::Object(obj)); + } + } // Override the listening address always since we spawn the router on a // random port. @@ -1139,6 +1611,20 @@ fn merge_overrides( ); } + // Override the Apollo OTLP metrics listening address + if let Some(apollo_config) = config + .as_object_mut() + .and_then(|o| o.get_mut("telemetry")) + .and_then(|o| o.as_object_mut()) + .and_then(|o| o.get_mut("apollo")) + .and_then(|o| o.as_object_mut()) + { + apollo_config.insert( + "experimental_otlp_endpoint".to_string(), + serde_json::Value::String(apollo_otlp_endpoint.to_string()), + ); + } + // Set health check listen address to avoid port conflicts config .as_object_mut() @@ -1176,3 +1662,23 @@ pub fn graph_os_enabled() -> bool { (Ok(_), Ok(_)) ) } + +/// Automatic tracing initialization using ctor for integration tests +#[ctor::ctor] +fn init_integration_test_tracing() { + // Initialize tracing for integration tests + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .or_else(|_| tracing_subscriber::EnvFilter::try_new("info,apollo_router=debug")) + .unwrap(); + + let _ = tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::Layer::default() + .with_target(false) + .with_thread_ids(false) + .with_thread_names(false) + .compact() + .with_filter(filter), + ) + .try_init(); +} diff --git a/apollo-router/tests/compute_backpressure.rs b/apollo-router/tests/compute_backpressure.rs new file mode 100644 index 0000000000..399b0f60f5 --- /dev/null +++ b/apollo-router/tests/compute_backpressure.rs @@ -0,0 +1,70 @@ +use std::sync::atomic::Ordering; + +use tower::Service as _; +use tower::ServiceExt as _; + +// This test is separated because it needs to run in a dedicated process. +// nextest does this by default, but using a separate [[test]] also makes it work with `cargo test`. + +#[tokio::test] +async fn test_compute_backpressure_response() { + let mut harness = apollo_router::TestHarness::builder() + .build_router() + .await + .unwrap(); + macro_rules! call { + ($query: expr) => {{ + let request = apollo_router::services::supergraph::Request::canned_builder() + .query($query) + .build() + .unwrap() + .try_into() + .unwrap(); + let mut response = harness.ready().await.unwrap().call(request).await.unwrap(); + let bytes = response.next_response().await.unwrap().unwrap(); + let graphql_response: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + serde_json::json!({ + "status": response.response.status().as_u16(), + "body": graphql_response + }) + }}; + } + + insta::assert_yaml_snapshot!(call!("{__typename} # 1"), @r###" + status: 200 + body: + data: + __typename: Query + "###); + + apollo_router::_private::compute_job_queued_count().fetch_add(1_000_000, Ordering::Relaxed); + + // Slightly different query so parsing isn’t cached and "compute" is needed. + // When the queue is full we quickly return an error + let start = std::time::Instant::now(); + let response = call!("{__typename} # 2"); + let latency = start.elapsed(); + assert!( + latency < std::time::Duration::from_millis(100), + "latency = {latency:?}, status = {:?}", + &response["status"] + ); + insta::assert_yaml_snapshot!(response, @r###" + status: 503 + body: + errors: + - message: Your request has been concurrency limited during query processing + extensions: + code: REQUEST_CONCURRENCY_LIMITED + "###); + + apollo_router::_private::compute_job_queued_count().fetch_sub(1_000_000, Ordering::Relaxed); + + // Router gracefully recovers + insta::assert_yaml_snapshot!(call!("{__typename} # 3"), @r###" + status: 200 + body: + data: + __typename: Query + "###); +} diff --git a/apollo-router/tests/fixtures/connectors/quickstart.graphql b/apollo-router/tests/fixtures/connectors/quickstart.graphql new file mode 100644 index 0000000000..254ca29685 --- /dev/null +++ b/apollo-router/tests/fixtures/connectors/quickstart.graphql @@ -0,0 +1,158 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive( + graphs: [CONNECTORS] + name: "link" + args: { + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + } + ) + @join__directive( + graphs: [CONNECTORS] + name: "source" + args: { + name: "jsonPlaceholder" + http: { baseURL: "https://jsonplaceholder.typicode.com/" } + } + ) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String + contextArguments: [join__ContextArgument!] +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Post @join__type(graph: CONNECTORS) { + id: ID! + body: String + title: String + author: User +} + +type Query @join__type(graph: CONNECTORS) { + posts: [Post] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "jsonPlaceholder" + http: { GET: "/posts" } + selection: "id\ntitle\nbody\nauthor: { id: userId }" + } + ) + post(id: ID!): Post + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "jsonPlaceholder" + http: { GET: "/posts/{$args.id}" } + selection: "id\ntitle\nbody\nauthor: { id: userId }" + entity: true + } + ) + user(id: ID!): User + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "jsonPlaceholder" + http: { GET: "/users/{$args.id}" } + selection: "id\nname\nusername" + entity: true + } + ) +} + +type User @join__type(graph: CONNECTORS) { + id: ID! + name: String + username: String + posts: [Post] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "jsonPlaceholder" + http: { GET: "/users/{$this.id}/posts" } + selection: "id\ntitle\nbody" + } + ) +} diff --git a/apollo-router/tests/fixtures/file_upload/add_header.rhai b/apollo-router/tests/fixtures/file_upload/add_header.rhai new file mode 100644 index 0000000000..287de5c61d --- /dev/null +++ b/apollo-router/tests/fixtures/file_upload/add_header.rhai @@ -0,0 +1,10 @@ +fn process_subgraph_request(request, subgraph) { + request.subgraph.headers["x-bla-bla"] = "bla"; +} + +fn subgraph_service(service, subgraph) { + const request_callback = |request| { + process_subgraph_request(request, subgraph); + }; + service.map_request(request_callback); +} diff --git a/apollo-router/tests/fixtures/file_upload/default.router.yaml b/apollo-router/tests/fixtures/file_upload/default.router.yaml index 1b32ce5bf3..91fce6b5ba 100644 --- a/apollo-router/tests/fixtures/file_upload/default.router.yaml +++ b/apollo-router/tests/fixtures/file_upload/default.router.yaml @@ -1,5 +1,9 @@ # Simple config for testing file uploads +csrf: + required_headers: + - x-my-header + - apollo-require-preflight preview_file_uploads: enabled: true protocols: diff --git a/apollo-router/tests/fixtures/file_upload/large.router.yaml b/apollo-router/tests/fixtures/file_upload/large.router.yaml index 935ace1781..2ffd01d591 100644 --- a/apollo-router/tests/fixtures/file_upload/large.router.yaml +++ b/apollo-router/tests/fixtures/file_upload/large.router.yaml @@ -1,5 +1,9 @@ # Simple config for testing file uploads, including large file uploads +csrf: + required_headers: + - x-my-header + - apollo-require-preflight preview_file_uploads: enabled: true protocols: diff --git a/apollo-router/tests/fixtures/file_upload/rhai.router.yaml b/apollo-router/tests/fixtures/file_upload/rhai.router.yaml new file mode 100644 index 0000000000..6c02b456e5 --- /dev/null +++ b/apollo-router/tests/fixtures/file_upload/rhai.router.yaml @@ -0,0 +1,21 @@ +# Simple config for testing file uploads with rhai header modifications + +csrf: + required_headers: + - x-my-header + - apollo-require-preflight +preview_file_uploads: + enabled: true + protocols: + multipart: + enabled: true + mode: stream + limits: + max_file_size: 512kb + max_files: 5 +include_subgraph_errors: + all: true + +rhai: + scripts: ./tests/fixtures/file_upload + main: add_header.rhai diff --git a/apollo-router/tests/fixtures/non_utf8_header_removal.rhai b/apollo-router/tests/fixtures/non_utf8_header_removal.rhai new file mode 100644 index 0000000000..be9d03cb1e --- /dev/null +++ b/apollo-router/tests/fixtures/non_utf8_header_removal.rhai @@ -0,0 +1,12 @@ +fn supergraph_service(service) { + print("registering callbacks for non-UTF-8 header removal test"); + + const response_callback = Fn("process_response"); + service.map_response(response_callback); +} + +fn process_response(response) { + // This will fail when trying to remove a non-UTF-8 header + // because the remove function calls .to_str() on the header value + response.headers.remove("x-binary-header") +} diff --git a/apollo-router/tests/fixtures/persisted-queries-manifest-hot-reload.json b/apollo-router/tests/fixtures/persisted-queries-manifest-hot-reload.json new file mode 100644 index 0000000000..d22ec21954 --- /dev/null +++ b/apollo-router/tests/fixtures/persisted-queries-manifest-hot-reload.json @@ -0,0 +1,12 @@ +{ + "format": "apollo-persisted-query-manifest", + "version": 1, + "operations": [ + { + "id": "5678", + "name": "typename", + "type": "query", + "body": "query { typename }" + } + ] +} diff --git a/apollo-router/tests/fixtures/reports/all_features_disabled.router.yaml b/apollo-router/tests/fixtures/reports/all_features_disabled.router.yaml new file mode 100644 index 0000000000..29cea59572 --- /dev/null +++ b/apollo-router/tests/fixtures/reports/all_features_disabled.router.yaml @@ -0,0 +1,59 @@ +# Disable distributed apq cache (mising redis definition) +apq: + router: + cache: + in_memory: + limit: 1000 + subgraph: + all: + enabled: true + +# Disable entity cache +preview_entity_cache: + enabled: false + subgraph: + all: + enabled: false + +# Remaining Configuration +include_subgraph_errors: + all: true +rhai: + scripts: tests/fixtures + main: test_callbacks.rhai +demand_control: + mode: measure + enabled: false + strategy: + static_estimated: + max: 1500 + list_size: 10 +telemetry: + instrumentation: + spans: + mode: deprecated + exporters: + tracing: + experimental_response_trace_id: + enabled: true + header_name: "my_trace_id" + common: + sampler: always_on + + apollo: + client_name_header: apollographql-client-name + client_version_header: apollographql-client-version + endpoint: ENDPOINT + batch_processor: + scheduled_delay: 10ms + experimental_local_field_metrics: false + experimental_otlp_endpoint: "http://127.0.0.1" + otlp_tracing_sampler: always_off + experimental_otlp_tracing_protocol: http + field_level_instrumentation_sampler: always_on + send_headers: + only: + - "send-header" + send_variable_values: + only: + - "sendValue" diff --git a/apollo-router/tests/fixtures/reports/all_features_enabled.router.yaml b/apollo-router/tests/fixtures/reports/all_features_enabled.router.yaml new file mode 100644 index 0000000000..950b5509ee --- /dev/null +++ b/apollo-router/tests/fixtures/reports/all_features_enabled.router.yaml @@ -0,0 +1,63 @@ +# Enable distributed apq cache +apq: + enabled: true + router: + cache: + redis: + urls: + - https://example.com + subgraph: + all: + enabled: true + +# Enable entity cache +preview_entity_cache: + enabled: true + subgraph: + all: + enabled: true + redis: + urls: [ "redis://..." ] + +# Remaining Configuration +include_subgraph_errors: + all: true +rhai: + scripts: tests/fixtures + main: test_callbacks.rhai +demand_control: + mode: measure + enabled: false + strategy: + static_estimated: + max: 1500 + list_size: 10 +telemetry: + instrumentation: + spans: + mode: deprecated + exporters: + tracing: + experimental_response_trace_id: + enabled: true + header_name: "my_trace_id" + common: + sampler: always_on + + apollo: + client_name_header: apollographql-client-name + client_version_header: apollographql-client-version + endpoint: ENDPOINT + batch_processor: + scheduled_delay: 10ms + experimental_local_field_metrics: false + experimental_otlp_endpoint: "http://127.0.0.1" + otlp_tracing_sampler: always_off + experimental_otlp_tracing_protocol: http + field_level_instrumentation_sampler: always_on + send_headers: + only: + - "send-header" + send_variable_values: + only: + - "sendValue" diff --git a/apollo-router/tests/fixtures/apollo_reports.router.yaml b/apollo-router/tests/fixtures/reports/apollo_reports.router.yaml similarity index 94% rename from apollo-router/tests/fixtures/apollo_reports.router.yaml rename to apollo-router/tests/fixtures/reports/apollo_reports.router.yaml index 644e286ee7..776dcb36f3 100644 --- a/apollo-router/tests/fixtures/apollo_reports.router.yaml +++ b/apollo-router/tests/fixtures/reports/apollo_reports.router.yaml @@ -30,7 +30,7 @@ telemetry: scheduled_delay: 10ms experimental_local_field_metrics: false experimental_otlp_endpoint: "http://127.0.0.1" - experimental_otlp_tracing_sampler: always_off + otlp_tracing_sampler: always_off experimental_otlp_tracing_protocol: http field_level_instrumentation_sampler: always_on send_headers: diff --git a/apollo-router/tests/fixtures/apollo_reports_batch.router.yaml b/apollo-router/tests/fixtures/reports/apollo_reports_batch.router.yaml similarity index 94% rename from apollo-router/tests/fixtures/apollo_reports_batch.router.yaml rename to apollo-router/tests/fixtures/reports/apollo_reports_batch.router.yaml index e60791ebbc..b1bd1c3b1c 100644 --- a/apollo-router/tests/fixtures/apollo_reports_batch.router.yaml +++ b/apollo-router/tests/fixtures/reports/apollo_reports_batch.router.yaml @@ -30,7 +30,7 @@ telemetry: scheduled_delay: 10ms experimental_local_field_metrics: false experimental_otlp_endpoint: "http://127.0.0.1" - experimental_otlp_tracing_sampler: always_off + otlp_tracing_sampler: always_off experimental_otlp_tracing_protocol: http field_level_instrumentation_sampler: always_on send_headers: diff --git a/apollo-router/tests/fixtures/request_response_test.rhai b/apollo-router/tests/fixtures/request_response_test.rhai index 966a1a42b0..d9057c9ffe 100644 --- a/apollo-router/tests/fixtures/request_response_test.rhai +++ b/apollo-router/tests/fixtures/request_response_test.rhai @@ -29,6 +29,9 @@ fn process_common_request(check_context_method_and_id, check_body, request) { throw(`query: expected: #{}, actual: ${request.body.extensions}`); } } + if request.uri.scheme != () { + throw(`query: expected: (), actual: ${request.uri.scheme}`); + } if request.uri.host != () { throw(`query: expected: (), actual: ${request.uri.host}`); } @@ -160,6 +163,9 @@ fn process_common_response(response) { } fn test_parse_request_details(request){ + if request.uri.scheme != "https" { + throw(`query: expected: "https", actual: ${request.uri.scheme}`); + } if request.uri.host != "not-default" { throw(`query: expected: not-default, actual: ${request.uri.host}`); } diff --git a/apollo-router/tests/fixtures/set_context/one_fetch_failure.json b/apollo-router/tests/fixtures/set_context/one_fetch_failure.json index 5515102f2d..311ebfc23c 100644 --- a/apollo-router/tests/fixtures/set_context/one_fetch_failure.json +++ b/apollo-router/tests/fixtures/set_context/one_fetch_failure.json @@ -41,6 +41,29 @@ } } } + }, + { + "request": { + "query": "query Query_fetch_failure__Subgraph1__2($representations:[_Any!]!$contextualArgument__Subgraph1_0:String){_entities(representations:$representations){...on U{field(a:$contextualArgument__Subgraph1_0)}}}", + "operationName": "Query_fetch_failure__Subgraph1__2", + "variables": { + "contextualArgument__Subgraph1_0": "prop value", + "representations": [{ "__typename": "U", "id": "1" }] + } + }, + "response": { + "data": { + "t": { + "__typename": "T", + "prop": "prop value", + "id": "1", + "u": { + "__typename": "U", + "id": "1" + } + } + } + } } ] } \ No newline at end of file diff --git a/apollo-router/tests/fixtures/set_context/one_null_param.json b/apollo-router/tests/fixtures/set_context/one_null_param.json index f36994a1a0..f4db338c19 100644 --- a/apollo-router/tests/fixtures/set_context/one_null_param.json +++ b/apollo-router/tests/fixtures/set_context/one_null_param.json @@ -38,6 +38,26 @@ ] } } + }, + { + "request": { + "query": "query Query_Null_Param__Subgraph1__1($representations:[_Any!]!$contextualArgument__Subgraph1_0:String){_entities(representations:$representations){...on U{field(a:$contextualArgument__Subgraph1_0)}}}", + "operationName": "Query_Null_Param__Subgraph1__1", + "variables": { + "contextualArgument__Subgraph1_0": null, + "representations": [{ "__typename": "U", "id": "1" }] + } + }, + "response": { + "data": { + "_entities": [ + { + "id": "1", + "field": 1234 + } + ] + } + } } ] } \ No newline at end of file diff --git a/apollo-router/tests/fixtures/supergraph_connect.graphql b/apollo-router/tests/fixtures/supergraph_connect.graphql new file mode 100644 index 0000000000..427701a2f2 --- /dev/null +++ b/apollo-router/tests/fixtures/supergraph_connect.graphql @@ -0,0 +1,74 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [POSTS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [POSTS], name: "source", args: {name: "jsonPlaceholder", http: {baseURL: "https://jsonplaceholder.typicode.com/"}}) + @join__directive(graphs: [POSTS], name: "source", args: {name: "routerHealth", http: {baseURL: "http://localhost:4000/"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + POSTS @join__graph(name: "posts", url: "http://localhost") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Post + @join__type(graph: POSTS) +{ + id: ID! + body: String + title: String + status: String + forceError: String +} + +type Query + @join__type(graph: POSTS) +{ + posts: [Post] @join__directive(graphs: [POSTS], name: "connect", args: {source: "jsonPlaceholder", http: {GET: "/posts"}, selection: "id\ntitle\nbody"}) + post(id: ID!): Post @join__directive(graphs: [POSTS], name: "connect", args: {source: "jsonPlaceholder", http: {GET: "/posts/{$args.id}"}, selection: "id\ntitle\nbody"}) @join__directive(graphs: [POSTS], name: "connect", args: {source: "routerHealth", http: {GET: "/health?_={$args.id}"}, selection: "id: $args.id\nstatus", entity: true}) @join__directive(graphs: [POSTS], name: "connect", args: {source: "jsonPlaceholder", http: {GET: "/missing?_={$args.id}"}, selection: "forceError", entity: true}) +} diff --git a/apollo-router/tests/fixtures/supergraph_connect.yaml b/apollo-router/tests/fixtures/supergraph_connect.yaml new file mode 100644 index 0000000000..2c89b1bec7 --- /dev/null +++ b/apollo-router/tests/fixtures/supergraph_connect.yaml @@ -0,0 +1,38 @@ +# rover supergraph compose --config apollo-router/tests/fixtures/supergraph_connect.yaml > apollo-router/tests/fixtures/supergraph_connect.graphql +federation_version: =2.10.0 +subgraphs: + posts: # required for snapshot overrides + routing_url: http://localhost + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source( + name: "jsonPlaceholder" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + } + ) + @source( + name: "routerHealth" + http: { + baseURL: "http://localhost:4000/" + } + ) + + type Post { + id: ID! + body: String + title: String + status: String + forceError: String + } + + type Query { + posts: [Post] @connect(source: "jsonPlaceholder", http: {GET: "/posts"}, selection: "id\ntitle\nbody") + post(id: ID!): Post + @connect(source: "jsonPlaceholder", http: {GET: "/posts/{$$args.id}"}, selection: "id\ntitle\nbody") + @connect(source: "routerHealth", http: {GET: "/health?_={$$args.id}"}, selection: "id: $$args.id\nstatus", entity: true) + @connect(source: "jsonPlaceholder", http: {GET: "/missing?_={$$args.id}"}, selection: "forceError", entity: true) + } diff --git a/apollo-router/tests/fixtures/test_reload_1.rhai b/apollo-router/tests/fixtures/test_reload_1.rhai new file mode 100644 index 0000000000..9476b9a0f0 --- /dev/null +++ b/apollo-router/tests/fixtures/test_reload_1.rhai @@ -0,0 +1,44 @@ +// This is a test used to make sure each callback is called + +fn router_service(service) { + log_info("1. router_service setup"); + service.map_request(|request| { + log_info("1. from_router_request"); + }); + service.map_response(|response| { + log_info("1. from_router_response"); + }); +} + +fn supergraph_service(service) { + log_info("1. supergraph_service setup"); + service.map_request(|request| { + log_info("1. from_supergraph_request"); + }); + service.map_response(|response| { + log_info("1. from_supergraph_response"); + }); +} + +fn execution_service(service) { + log_info("1. execution_service setup"); + service.map_request(|request| { + log_info("1. from_execution_request"); + }); + service.map_response(|response| { + log_info("1. from_execution_response"); + }); +} + + +fn subgraph_service(service, subgraph) { + log_info("1. subgraph_service setup"); + service.map_request(|request| { + log_info("1. from_subgraph_request"); + }); + service.map_response(|response| { + log_info("1. from_subgraph_response"); + }); +} + + diff --git a/apollo-router/tests/fixtures/test_reload_2.rhai b/apollo-router/tests/fixtures/test_reload_2.rhai new file mode 100644 index 0000000000..5ba4fc3997 --- /dev/null +++ b/apollo-router/tests/fixtures/test_reload_2.rhai @@ -0,0 +1,44 @@ +// This is a test used to make sure each callback is called + +fn router_service(service) { + log_info("2. router_service setup"); + service.map_request(|request| { + log_info("2. from_router_request"); + }); + service.map_response(|response| { + log_info("2. from_router_response"); + }); +} + +fn supergraph_service(service) { + log_info("2. supergraph_service setup"); + service.map_request(|request| { + log_info("2. from_supergraph_request"); + }); + service.map_response(|response| { + log_info("2. from_supergraph_response"); + }); +} + +fn execution_service(service) { + log_info("2. execution_service setup"); + service.map_request(|request| { + log_info("2. from_execution_request"); + }); + service.map_response(|response| { + log_info("2. from_execution_response"); + }); +} + + +fn subgraph_service(service, subgraph) { + log_info("2. subgraph_service setup"); + service.map_request(|request| { + log_info("2. from_subgraph_request"); + }); + service.map_response(|response| { + log_info("2. from_subgraph_response"); + }); +} + + diff --git a/apollo-router/tests/fixtures/type_conditions/artwork.json b/apollo-router/tests/fixtures/type_conditions/artwork.json index e4c437c2cd..ceb58c8b93 100644 --- a/apollo-router/tests/fixtures/type_conditions/artwork.json +++ b/apollo-router/tests/fixtures/type_conditions/artwork.json @@ -2,7 +2,7 @@ "mocks": [ { "request": { - "query":"query Search__artworkSubgraph__1($representations:[_Any!]!$movieResultParam:String){_entities(representations:$representations){..._generated_onEntityCollectionSection2_0 ...on GallerySection{artwork(params:$movieResultParam)}}}fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection{title artwork(params:$movieResultParam)}", + "query":"query Search__artworkSubgraph__1($representations:[_Any!]!$movieResultParam:String){_entities(representations:$representations){...on EntityCollectionSection{title artwork(params:$movieResultParam)}...on GallerySection{artwork(params:$movieResultParam)}}}", "operationName":"Search__artworkSubgraph__1", "variables":{ "movieResultParam":"movieResultEnabled", @@ -50,7 +50,7 @@ }, { "request": { - "query": "query Search__artworkSubgraph__2($representations:[_Any!]!$articleResultParam:String){_entities(representations:$representations){...on GallerySection{artwork(params:$articleResultParam)}..._generated_onEntityCollectionSection2_0}}fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection{artwork(params:$articleResultParam)title}", + "query": "query Search__artworkSubgraph__2($representations:[_Any!]!,$articleResultParam:String){_entities(representations:$representations){... on GallerySection{artwork(params:$articleResultParam)}... on EntityCollectionSection{artwork(params:$articleResultParam)title}}}", "operationName": "Search__artworkSubgraph__2", "variables":{ "articleResultParam":"articleResultEnabled", diff --git a/apollo-router/tests/fixtures/type_conditions/artwork_disabled.json b/apollo-router/tests/fixtures/type_conditions/artwork_disabled.json index 5e230e1e3c..35da7aeb1f 100644 --- a/apollo-router/tests/fixtures/type_conditions/artwork_disabled.json +++ b/apollo-router/tests/fixtures/type_conditions/artwork_disabled.json @@ -2,7 +2,7 @@ "mocks": [ { "request": { - "query":"query Search__artworkSubgraph__1($representations:[_Any!]!$movieResultParam:String){_entities(representations:$representations){..._generated_onEntityCollectionSection2_0...on GallerySection{artwork(params:$movieResultParam)}}}fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection{title artwork(params:$movieResultParam)}", + "query":"query Search__artworkSubgraph__1($representations:[_Any!]!$movieResultParam:String){_entities(representations:$representations){...on EntityCollectionSection{title artwork(params:$movieResultParam)}...on GallerySection{artwork(params:$movieResultParam)}}}", "operationName":"Search__artworkSubgraph__1", "variables":{ "representations":[ diff --git a/apollo-router/tests/fixtures/type_conditions/artwork_query_fragments_enabled.json b/apollo-router/tests/fixtures/type_conditions/artwork_query_fragments_enabled.json index e4c437c2cd..ceb58c8b93 100644 --- a/apollo-router/tests/fixtures/type_conditions/artwork_query_fragments_enabled.json +++ b/apollo-router/tests/fixtures/type_conditions/artwork_query_fragments_enabled.json @@ -2,7 +2,7 @@ "mocks": [ { "request": { - "query":"query Search__artworkSubgraph__1($representations:[_Any!]!$movieResultParam:String){_entities(representations:$representations){..._generated_onEntityCollectionSection2_0 ...on GallerySection{artwork(params:$movieResultParam)}}}fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection{title artwork(params:$movieResultParam)}", + "query":"query Search__artworkSubgraph__1($representations:[_Any!]!$movieResultParam:String){_entities(representations:$representations){...on EntityCollectionSection{title artwork(params:$movieResultParam)}...on GallerySection{artwork(params:$movieResultParam)}}}", "operationName":"Search__artworkSubgraph__1", "variables":{ "movieResultParam":"movieResultEnabled", @@ -50,7 +50,7 @@ }, { "request": { - "query": "query Search__artworkSubgraph__2($representations:[_Any!]!$articleResultParam:String){_entities(representations:$representations){...on GallerySection{artwork(params:$articleResultParam)}..._generated_onEntityCollectionSection2_0}}fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection{artwork(params:$articleResultParam)title}", + "query": "query Search__artworkSubgraph__2($representations:[_Any!]!,$articleResultParam:String){_entities(representations:$representations){... on GallerySection{artwork(params:$articleResultParam)}... on EntityCollectionSection{artwork(params:$articleResultParam)title}}}", "operationName": "Search__artworkSubgraph__2", "variables":{ "articleResultParam":"articleResultEnabled", diff --git a/apollo-router/tests/fixtures/type_conditions/search.json b/apollo-router/tests/fixtures/type_conditions/search.json index 85bc7facaa..0e69a53a4f 100644 --- a/apollo-router/tests/fixtures/type_conditions/search.json +++ b/apollo-router/tests/fixtures/type_conditions/search.json @@ -2,7 +2,7 @@ "mocks": [ { "request": { - "query":"query Search__searchSubgraph__0{search{__typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0}}fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection{__typename id}fragment _generated_onGallerySection2_0 on GallerySection{__typename id}fragment _generated_onMovieResult2_0 on MovieResult{sections{__typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0}id}fragment _generated_onArticleResult2_0 on ArticleResult{id sections{__typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0}}", + "query":"query Search__searchSubgraph__0{search{__typename ...on MovieResult{sections{...c}id} ...on ArticleResult{id sections{...c}}}} fragment a on EntityCollectionSection{__typename id} fragment b on GallerySection{__typename id} fragment c on Section{__typename ...a ...b}", "operationName":"Search__searchSubgraph__0" }, "response": { diff --git a/apollo-router/tests/fixtures/type_conditions/search_list_of_list.json b/apollo-router/tests/fixtures/type_conditions/search_list_of_list.json index 6197584e2b..2f4fcb99be 100644 --- a/apollo-router/tests/fixtures/type_conditions/search_list_of_list.json +++ b/apollo-router/tests/fixtures/type_conditions/search_list_of_list.json @@ -2,7 +2,7 @@ "mocks": [ { "request": { - "query": "query Search__searchSubgraph__0 { searchListOfList { __typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { __typename id } fragment _generated_onGallerySection2_0 on GallerySection { __typename id } fragment _generated_onMovieResult2_0 on MovieResult { sections { __typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0 } id } fragment _generated_onArticleResult2_0 on ArticleResult { id sections { __typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0 } }", + "query": "query Search__searchSubgraph__0{searchListOfList{__typename ... on MovieResult{sections{...c}id} ... on ArticleResult{id sections{...c}}}} fragment a on EntityCollectionSection{__typename id} fragment b on GallerySection{__typename id} fragment c on Section{__typename ...a ...b}", "operationName":"Search__searchSubgraph__0" }, "response": { diff --git a/apollo-router/tests/fixtures/type_conditions/search_list_of_list_of_list.json b/apollo-router/tests/fixtures/type_conditions/search_list_of_list_of_list.json index e183b3e65c..fa5933667c 100644 --- a/apollo-router/tests/fixtures/type_conditions/search_list_of_list_of_list.json +++ b/apollo-router/tests/fixtures/type_conditions/search_list_of_list_of_list.json @@ -2,7 +2,7 @@ "mocks": [ { "request": { - "query":"query Search__searchSubgraph__0 { searchListOfListOfList { __typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { __typename id } fragment _generated_onGallerySection2_0 on GallerySection { __typename id } fragment _generated_onMovieResult2_0 on MovieResult { sections { __typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0 } id } fragment _generated_onArticleResult2_0 on ArticleResult { id sections { __typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0 } }", + "query":"query Search__searchSubgraph__0{searchListOfListOfList{__typename ... on MovieResult{sections{...c}id} ... on ArticleResult{id sections{...c}}}} fragment a on EntityCollectionSection{__typename id} fragment b on GallerySection{__typename id} fragment c on Section{__typename ...a ...b}", "operationName":"Search__searchSubgraph__0" }, "response": { diff --git a/apollo-router/tests/fixtures/type_conditions/search_query_fragments_enabled.json b/apollo-router/tests/fixtures/type_conditions/search_query_fragments_enabled.json index 85bc7facaa..5a763ddf04 100644 --- a/apollo-router/tests/fixtures/type_conditions/search_query_fragments_enabled.json +++ b/apollo-router/tests/fixtures/type_conditions/search_query_fragments_enabled.json @@ -2,7 +2,7 @@ "mocks": [ { "request": { - "query":"query Search__searchSubgraph__0{search{__typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0}}fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection{__typename id}fragment _generated_onGallerySection2_0 on GallerySection{__typename id}fragment _generated_onMovieResult2_0 on MovieResult{sections{__typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0}id}fragment _generated_onArticleResult2_0 on ArticleResult{id sections{__typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0}}", + "query":"query Search__searchSubgraph__0{search{__typename ... on MovieResult{sections{...c}id} ... on ArticleResult{id sections{...c}}}} fragment a on EntityCollectionSection{__typename id} fragment b on GallerySection{__typename id} fragment c on Section {__typename ...a ...b}", "operationName":"Search__searchSubgraph__0" }, "response": { diff --git a/apollo-router/tests/integration/allowed_features.rs b/apollo-router/tests/integration/allowed_features.rs new file mode 100644 index 0000000000..4f82cda0b3 --- /dev/null +++ b/apollo-router/tests/integration/allowed_features.rs @@ -0,0 +1,657 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use http::StatusCode; + +use crate::integration::IntegrationTest; +use crate::integration::common::TEST_JWKS_ENDPOINT; + +// NOTE: if these tests fail for haltAt/warnAt related reasons (that they're in the past), go to +// jwt.io and doublecheck that those claims are still sensible. There's an issue when using +// Instants to schedule things (like we do for license streams) if those Instants are derived from +// some far-future SystemTime: tokio has an upper bound for how far out it schedules, putting a +// pretty hard limit (about a year) for what we can set the haltAt/warnAt values in JWTs to + +const LICENSE_ALLOWED_FEATURES_DOES_NOT_INCLUDE_FEATURE_MSG: &str = + "license violation, the router is using features not available for your license"; +const LICENSE_EXPIRED_MESSAGE: &str = + "License has expired. The Router will no longer serve requests."; + +const JWT_WITH_EMPTY_ALLOWED_FEATURES: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJleHAiOiAxMDAwMDAwMDAwMCwKICAiYWxsb3dlZEZlYXR1cmVzIjogW10sCiAgImlzcyI6ICJodHRwczovL3d3dy5hcG9sbG9ncmFwaHFsLmNvbS8iLAogICJzdWIiOiAiYXBvbGxvIiwKICAiYXVkIjogIlNFTEZfSE9TVEVEIiwgCiAgIndhcm5BdCI6IDE3ODcwMDAwMDAsCiAgImhhbHRBdCI6IDE3ODcwMDAwMDAKfQ.nERzNxBzt7KLgBD4ouHydbht6_1jgyCYF8aKzFKGjhI"; // gitleaks:allow + +const JWT_WITH_COPROCESSORS_IN_ALLOWED_FEATURES: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJleHAiOiAxMDAwMDAwMDAwMCwKICAiYWxsb3dlZEZlYXR1cmVzIjogWyJjb3Byb2Nlc3NvcnMiXSwKICAiaXNzIjogImh0dHBzOi8vd3d3LmFwb2xsb2dyYXBocWwuY29tLyIsCiAgInN1YiI6ICJhcG9sbG8iLAogICJhdWQiOiAiU0VMRl9IT1NURUQiLCAKICAid2FybkF0IjogMTc4NzAwMDAwMCwKICAiaGFsdEF0IjogMTc4NzAwMDAwMAp9.UD2JZtyvCSY6oXeDOsmWZehNGQjDqdhOiw-1f2TW4Og"; // gitleaks:allow + +// In the CI environment we only install Redis on x86_64 Linux; this jwt is part of testing that +// flow +#[cfg(any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")))] +const JWT_WITH_ENTITY_CACHING_COPROCESSORS_IN_ALLOWED_FEATURES: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJleHAiOiAxMDAwMDAwMDAwMCwKICAiaXNzIjogImh0dHBzOi8vd3d3LmFwb2xsb2dyYXBocWwuY29tLyIsCiAgInN1YiI6ICJhcG9sbG8iLAogICJhbGxvd2VkRmVhdHVyZXMiOiBbImVudGl0eV9jYWNoaW5nIiwgImNvcHJvY2Vzc29ycyJdLAogICJhdWQiOiAiU0VMRl9IT1NURUQiLCAKICAid2FybkF0IjogMTc4NzAwMDAwMCwgCiAgImhhbHRBdCI6IDE3ODcwMDAwMDAKfQ.HD_xzVtrXzXp8PdosAircXWPtnVaPRE-N2ZDlv6Llfo"; // gitleaks:allow + +const JWT_WITH_COPROCESSORS_SUBSCRIPTION_IN_ALLOWED_FEATURES: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJleHAiOiAxMDAwMDAwMDAwMCwKICAiYWxsb3dlZEZlYXR1cmVzIjogWwogICAgImNvcHJvY2Vzc29ycyIsCiAgICAic3Vic2NyaXB0aW9ucyIKICBdLAogICJpc3MiOiAiaHR0cHM6Ly93d3cuYXBvbGxvZ3JhcGhxbC5jb20vIiwKICAic3ViIjogImFwb2xsbyIsCiAgImF1ZCI6ICJTRUxGX0hPU1RFRCIsIAogICJ3YXJuQXQiOiAxNzg3MDAwMDAwLAogICJoYWx0QXQiOiAxNzg3MDAwMDAwCn0.MxjeQOea7wBjvs1J0-44oEfdoaVwKuEexy-JdgZ-3R8"; // gitleaks:allow + +const JWT_WITH_ALLOWED_FEATURES_NONE: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJleHAiOiAxMDAwMDAwMDAwMCwKICAiaXNzIjogImh0dHBzOi8vd3d3LmFwb2xsb2dyYXBocWwuY29tLyIsCiAgInN1YiI6ICJhcG9sbG8iLAogICJhdWQiOiAiU0VMRl9IT1NURUQiLCAKICAid2FybkF0IjogMTc4NzAwMDAwMCwKICAiaGFsdEF0IjogMTc4NzAwMDAwMAp9.LPNJgPY20DH054mXgrzaxEFiME656ZJ-ge5y9Zh3kkc"; // gitleaks:allow + +const JWT_WITH_ALLOWED_FEATURES_COPROCESSOR_WITH_FEATURE_UNDEFINED_IN_ROUTER: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJleHAiOiAxMDAwMDAwMDAwMCwKICAiYWxsb3dlZEZlYXR1cmVzIjogWwogICAgImNvcHJvY2Vzc29ycyIsCiAgICAicmFuZG9tIiwKICAgICJzdWJzY3JpcHRpb25zIgogIF0sCiAgImlzcyI6ICJodHRwczovL3d3dy5hcG9sbG9ncmFwaHFsLmNvbS8iLAogICJzdWIiOiAiYXBvbGxvIiwKICAiYXVkIjogIlNFTEZfSE9TVEVEIiwgCiAgIndhcm5BdCI6IDE3ODcwMDAwMDAsCiAgImhhbHRBdCI6IDE3ODcwMDAwMDAKfQ.l4O-YLwIu2hjoSq1HseJQMS_9qFNL9v304I7gfLqV3w"; // gitleaks:allow + +const JWT_WITH_ENTITY_CACHING_COPROCESSORS_TRAFFIC_SHAPING_IN_ALLOWED_FEATURES: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJleHAiOiAxMDAwMDAwMDAwMCwKICAiaXNzIjogImh0dHBzOi8vd3d3LmFwb2xsb2dyYXBocWwuY29tLyIsCiAgInN1YiI6ICJhcG9sbG8iLAogICJhbGxvd2VkRmVhdHVyZXMiOiBbImVudGl0eV9jYWNoaW5nIiwgImNvcHJvY2Vzc29ycyIsICJ0cmFmZmljX3NoYXBpbmciXSwKICAiYXVkIjogIlNFTEZfSE9TVEVEIiwgCiAgIndhcm5BdCI6IDE3ODcwMDAwMDAsIAogICJoYWx0QXQiOiAxNzg3MDAwMDAwCn0.HHfLHmDAjTdQwouAJguvWnpxnHsLzTWswQl70gmkMEM"; // gitleaks:allow + +const JWT_PAST_EXPIRY_WITH_COPROCESSORS_ENTITY_CACHING_TRAFFIC_SHAPING_SUBSCRIPTIONS_IN_ALLOWED_FEATURES: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJleHAiOiAxMDAwMDAwMDAwMCwKICAiaXNzIjogImh0dHBzOi8vd3d3LmFwb2xsb2dyYXBocWwuY29tLyIsCiAgInN1YiI6ICJhcG9sbG8iLAogICJhbGxvd2VkRmVhdHVyZXMiOiBbImNvcHJvY2Vzc29ycyIsICJlbnRpdHlfY2FjaGluZyIsICJ0cmFmZmljX3NoYXBpbmciLCAic3Vic2NyaXB0aW9ucyJdLAogICJhdWQiOiAiU0VMRl9IT1NURUQiLCAKICAid2FybkF0IjogMTc1NTMwMjQwMCwgCiAgImhhbHRBdCI6IDE3NTUzMDI0MDAKfQ.2TPyUd9BUn3NCc2Kq8WsJS_6V16s2lgitElhf0lNcwg"; // gitleaks:allow + +const JWT_PAST_EXPIRY_WITH_COPROCESSORS_ENTITY_CACHING_TRAFFIC_SHAPING_IN_ALLOWED_FEATURES: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJleHAiOiAxMDAwMDAwMDAwMCwKICAiaXNzIjogImh0dHBzOi8vd3d3LmFwb2xsb2dyYXBocWwuY29tLyIsCiAgInN1YiI6ICJhcG9sbG8iLAogICJhbGxvd2VkRmVhdHVyZXMiOiBbImNvcHJvY2Vzc29ycyIsICJlbnRpdHlfY2FjaGluZyIsICJ0cmFmZmljX3NoYXBpbmciXSwKICAiYXVkIjogIlNFTEZfSE9TVEVEIiwgCiAgIndhcm5BdCI6IDE3NTUzMDI0MDAsIAogICJoYWx0QXQiOiAxNzU1MzAyNDAwCn0.CERblSGfOVmKt6PtfB2LjnY-ahzMsNB4EGajXZfKWU4"; // gitleaks:allow + +const JWT_PAST_WARN_AT_BUT_NOT_EXPIRED_WITH_COPROCESSORS_ENTITY_CACHING_TRAFFIC_SHAPING_IN_ALLOWED_FEATURES: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJleHAiOiAxMDAwMDAwMDAwMCwKICAiaXNzIjogImh0dHBzOi8vd3d3LmFwb2xsb2dyYXBocWwuY29tLyIsCiAgInN1YiI6ICJhcG9sbG8iLAogICJhbGxvd2VkRmVhdHVyZXMiOiBbImVudGl0eV9jYWNoaW5nIiwgImNvcHJvY2Vzc29ycyIsICJ0cmFmZmljX3NoYXBpbmciXSwKICAiYXVkIjogIlNFTEZfSE9TVEVEIiwgCiAgIndhcm5BdCI6IDE3NjU5MTA0MDAsIAogICJoYWx0QXQiOiAxNzg3MDAwMDAwCn0.33EWawSaU8dv5KqI8QbAzYFa0KKTcvqTXGaJfRkg-DU"; // gitleaks:allow +const JWT_PAST_WARN_AT_BUT_NOT_EXPIRED_WITH_COPROCESSORS_SUBSCRIPTIONS_IN_ALLOWED_FEATURES: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJleHAiOiAxMDAwMDAwMDAwMCwKICAiaXNzIjogImh0dHBzOi8vd3d3LmFwb2xsb2dyYXBocWwuY29tLyIsCiAgInN1YiI6ICJhcG9sbG8iLAogICJhbGxvd2VkRmVhdHVyZXMiOiBbInN1YnNjcmlwdGlvbnMiLCAiY29wcm9jZXNzb3JzIl0sCiAgImF1ZCI6ICJTRUxGX0hPU1RFRCIsIAogICJ3YXJuQXQiOiAxNzU1MzAyNDAwLCAKICAiaGFsdEF0IjogMTc4NzAwMDAwMAp9.nxyKlFquWBijtIOtL8FnknNfAwvBaZh9TFIDcG7NtiE"; // gitleaks:allow + +const SUBSCRIPTION_CONFIG: &str = include_str!("subscriptions/fixtures/subscription.router.yaml"); +const SUBSCRIPTION_COPROCESSOR_CONFIG: &str = + include_str!("subscriptions/fixtures/subscription_coprocessor.router.yaml"); +const FILE_UPLOADS_CONFIG: &str = + include_str!("../../tests/fixtures/file_upload/default.router.yaml"); + +/* + * GIVEN + * - a valid license whose `allowed_features` claim contains the feature + * - a valid config + * - a valid schema + * + * THEN + * - since the feature is part of the `allowed_features` set + * the router should start successfully with no license violations + * */ +#[tokio::test(flavor = "multi_thread")] +async fn traffic_shaping_when_allowed_features_contains_feature() { + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + + let mut router = IntegrationTest::builder() + .config( + r#" + telemetry: + exporters: + metrics: + prometheus: + listen: 127.0.0.1:4000 + enabled: true + path: /metrics + include_subgraph_errors: + all: true + traffic_shaping: + all: + timeout: 1ns + "#, + ) + .env(env) + .jwt(JWT_WITH_ENTITY_CACHING_COPROCESSORS_TRAFFIC_SHAPING_IN_ALLOWED_FEATURES.to_string()) + .build() + .await; + + router.start().await; + router.assert_started().await; + router.assert_no_error_logs(); +} + +// In the CI environment we only install Redis on x86_64 Linux +#[cfg(any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")))] +#[tokio::test(flavor = "multi_thread")] +async fn connectors_with_entity_caching_enabled_when_allowed_features_contains_features() { + use crate::integration::common::TEST_JWKS_ENDPOINT; + + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + let mut router = IntegrationTest::builder() + .config( + r#" + preview_entity_cache: + enabled: true + subgraph: + all: + redis: + urls: ["redis://127.0.0.1:6379"] + ttl: "10m" + required_to_start: true + subgraphs: + connectors: + enabled: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .env(env) + .jwt(JWT_WITH_ENTITY_CACHING_COPROCESSORS_IN_ALLOWED_FEATURES.to_string()) + .build() + .await; + + router.start().await; + router.assert_started().await; + router.assert_no_error_logs(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn subscription_coprocessors_enabled_when_allowed_features_contains_both_features() { + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + let mut router = IntegrationTest::builder() + .supergraph(PathBuf::from_iter([ + "tests", + "integration", + "subscriptions", + "fixtures", + "supergraph.graphql", + ])) + .config(SUBSCRIPTION_COPROCESSOR_CONFIG) + .env(env) + .jwt(JWT_WITH_COPROCESSORS_SUBSCRIPTION_IN_ALLOWED_FEATURES.to_string()) + .build() + .await; + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", "5000"); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", "5001"); + router.replace_config_string("http://localhost:{{COPROCESSOR_PORT}}", "5002"); + + router.start().await; + router.assert_started().await; + router.assert_no_error_logs(); +} + +/* + * GIVEN + * - a valid license that does not contain an `allowed_features` claim + * - a valid config + * - a valid schema + * + * THEN + * - router should start successfully + * NB: this behavior will change once allowed_features claim is contained in all licenses +*/ +#[tokio::test(flavor = "multi_thread")] +async fn oss_feature_apq_enabled_when_allowed_features_empty() { + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + let mut router = IntegrationTest::builder() + .config( + r#" + apq: + enabled: true + "#, + ) + .env(env) + .jwt(JWT_WITH_EMPTY_ALLOWED_FEATURES.to_string()) + .build() + .await; + + router.start().await; + // Apq is an oss feature + router.assert_started().await; + router.assert_no_error_logs(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn oss_feature_file_uploads_enabled_with_non_empty_allowed_features() { + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + let mut router = IntegrationTest::builder() + .config(FILE_UPLOADS_CONFIG) + .env(env) + .jwt(JWT_WITH_ENTITY_CACHING_COPROCESSORS_TRAFFIC_SHAPING_IN_ALLOWED_FEATURES.to_string()) + .build() + .await; + + router.start().await; + // File uploads is an oss plugin + router.assert_started().await; + router.assert_no_error_logs(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn router_starts_when_allowed_features_contains_feature_undefined_in_router() { + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + let mock_server = wiremock::MockServer::start().await; + let coprocessor_address = mock_server.uri(); + + let mut router = IntegrationTest::builder() + .config( + include_str!("fixtures/coprocessor.router.yaml") + .replace("", &coprocessor_address), + ) + .env(env) + .jwt(JWT_WITH_ALLOWED_FEATURES_COPROCESSOR_WITH_FEATURE_UNDEFINED_IN_ROUTER.to_string()) + .build() + .await; + + router.start().await; + router.assert_started().await; + router.assert_no_error_logs(); +} + +/* + * GIVEN + * - a valid license that does not contain an `allowed_features` claim + * - a valid config + * - a valid schema + * + * THEN + * - router should start successfully + * NB: this behavior will change once allowed_features claim is contained in all licenses +*/ +#[tokio::test(flavor = "multi_thread")] +async fn subscription_coprocessors_enabled_when_allowed_features_none() { + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + let mut router = IntegrationTest::builder() + .supergraph(PathBuf::from_iter([ + "tests", + "integration", + "subscriptions", + "fixtures", + "supergraph.graphql", + ])) + .config(SUBSCRIPTION_COPROCESSOR_CONFIG) + .env(env) + .jwt(JWT_WITH_ALLOWED_FEATURES_NONE.to_string()) + .build() + .await; + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", "5000"); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", "5001"); + router.replace_config_string("http://localhost:{{COPROCESSOR_PORT}}", "5002"); + + router.start().await; + router.assert_started().await; + router.assert_no_error_logs(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn demand_control_enabledwhen_allowed_features_none() { + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + let mock_server = wiremock::MockServer::start().await; + let coprocessor_address = mock_server.uri(); + + let mut router = IntegrationTest::builder() + .config( + include_str!("fixtures/coprocessor_demand_control.router.yaml") + .replace("", &coprocessor_address), + ) + .env(env) + .jwt(JWT_WITH_ALLOWED_FEATURES_NONE.to_string()) + .build() + .await; + + router.start().await; + router.assert_started().await; + router.assert_no_error_logs(); +} + +/* + * GIVEN + * - a valid license whose `allowed_features` claim is empty (does not contain any features) + * or more features + * - a valid config + * - a valid schema + * + * THEN + * - since the feature(s) is/are not part of the `allowed_features` set + * the router should should emit an error log containing the license violations + * */ +#[tokio::test(flavor = "multi_thread")] +async fn feature_violation_when_allowed_features_empty_with_coprocessor_in_config() { + let mock_server = wiremock::MockServer::start().await; + let coprocessor_address = mock_server.uri(); + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + + let mut router = IntegrationTest::builder() + .config( + include_str!("fixtures/coprocessor.router.yaml") + .replace("", &coprocessor_address), + ) + .env(env) + .jwt(JWT_WITH_EMPTY_ALLOWED_FEATURES.to_string()) + .build() + .await; + + router.start().await; + router + .assert_error_log_contained(LICENSE_ALLOWED_FEATURES_DOES_NOT_INCLUDE_FEATURE_MSG) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn feature_violation_when_allowed_features_empty_with_subscripton_in_config() { + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(SUBSCRIPTION_CONFIG) + .env(env) + .jwt(JWT_WITH_EMPTY_ALLOWED_FEATURES.to_string()) + .build() + .await; + + router.start().await; + router + .assert_error_log_contained(LICENSE_ALLOWED_FEATURES_DOES_NOT_INCLUDE_FEATURE_MSG) + .await; +} + +/* + * GIVEN + * - a valid license whose `allowed_features` claim does not contain one + * or more features + * - a valid config + * - a valid schema + * + * THEN + * - since the feature(s) is/are not part of the `allowed_features` set + * the router should should emit an error log containing the license violations + * */ +#[tokio::test(flavor = "multi_thread")] +async fn feature_violation_when_allowed_features_does_not_contain_feature_demand_control() { + let mock_server = wiremock::MockServer::start().await; + let coprocessor_address = mock_server.uri(); + + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + + let mut router = IntegrationTest::builder() + .config( + include_str!("fixtures/coprocessor_demand_control.router.yaml") + .replace("", &coprocessor_address), + ) + .env(env) + .jwt(JWT_WITH_COPROCESSORS_IN_ALLOWED_FEATURES.to_string()) + .build() + .await; + + router.start().await; + router + .assert_error_log_contained(LICENSE_ALLOWED_FEATURES_DOES_NOT_INCLUDE_FEATURE_MSG) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn feature_violation_when_allowed_features_with_coprocessor_only_with_subscripton_and_coprocessor_in_config() + { + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(SUBSCRIPTION_COPROCESSOR_CONFIG) + .env(env) + .jwt(JWT_WITH_COPROCESSORS_IN_ALLOWED_FEATURES.to_string()) + .build() + .await; + + router.start().await; + router + .assert_error_log_contained(LICENSE_ALLOWED_FEATURES_DOES_NOT_INCLUDE_FEATURE_MSG) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn canned_response_when_license_halted_with_valid_config_and_schema() { + /* + * GIVEN + * - an expired license + * - a valid config + * - a valid schema + * */ + + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(SUBSCRIPTION_COPROCESSOR_CONFIG) + .env(env) + .jwt(JWT_PAST_EXPIRY_WITH_COPROCESSORS_ENTITY_CACHING_TRAFFIC_SHAPING_SUBSCRIPTIONS_IN_ALLOWED_FEATURES.to_string()) + .build() + .await; + + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", "localhost:4001"); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", "localhost:4002"); + + /* + * THEN + * - since the license is expired and using restricted features the router should start but + * the axum middleware, license_handler, should return a 500 + * */ + router.start().await; + router + .assert_error_log_contained(LICENSE_EXPIRED_MESSAGE) + .await; + + let (_, response) = router.execute_default_query().await; + // We expect the axum middleware for handling halted licenses to return a server error + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test(flavor = "multi_thread")] +async fn canned_response_when_license_halted_with_restricted_config_and_valid_schema() { + /* + * GIVEN + * - an expired license + * - an invalid config - that contains a feature not in the allowedFeatures claim + * - a valid schema + * */ + + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + // subscriptions not an allowed feature--config invalid + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(SUBSCRIPTION_COPROCESSOR_CONFIG) + .env(env) + // jwt's allowed features does not contain subscriptions + .jwt( + JWT_PAST_EXPIRY_WITH_COPROCESSORS_ENTITY_CACHING_TRAFFIC_SHAPING_IN_ALLOWED_FEATURES + .to_string(), + ) + .build() + .await; + + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", "localhost:4001"); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", "localhost:4002"); + + /* + * THEN + * - since the license is expired and using restricted features the router should start but + * the axum middleware, license_handler, should return a 500 + * */ + router.start().await; + router + .assert_error_log_contained(LICENSE_EXPIRED_MESSAGE) + .await; + + let (_, response) = router.execute_default_query().await; + // We expect the axum middleware for handling halted licenses to return a server error + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test(flavor = "multi_thread")] +async fn canned_response_when_license_halted_with_valid_config_and_invalid_schema() { + /* + * GIVEN + * - an expired license + * - a valid config + * - a invalid schema - that contains a feature not in the allowedFeatures claim + * */ + + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + + // contextArgument is restricted for this JWT + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/fixtures/authenticated_directive.graphql") + .config(FILE_UPLOADS_CONFIG) + .env(env) + .jwt(JWT_PAST_EXPIRY_WITH_COPROCESSORS_ENTITY_CACHING_TRAFFIC_SHAPING_SUBSCRIPTIONS_IN_ALLOWED_FEATURES.to_string()) + .build() + .await; + + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", "localhost:4001"); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", "localhost:4002"); + + /* + * THEN + * - since the license is expired and using restricted features the router should start but + * the axum middleware, license_handler, should return a 500 + * */ + router.start().await; + router + .assert_error_log_contained(LICENSE_EXPIRED_MESSAGE) + .await; + + let (_, response) = router.execute_default_query().await; + // We expect the axum middleware for handling halted licenses to return a server error + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +/* + * GIVEN + * - a license past the warnAt date but not yet expired but the features in use contained + * in the allowedFeatures claim + * - a valid config + * - a valid schema + * + * THEN + * - since the license is not yet expired, the router should start with restricted features in use + * */ +#[tokio::test(flavor = "multi_thread")] +async fn router_starts_when_license_past_warn_at_but_not_expired_allowed_features_contains_feature_subscriptions() + { + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(SUBSCRIPTION_COPROCESSOR_CONFIG) + .env(env) + .jwt( + JWT_PAST_WARN_AT_BUT_NOT_EXPIRED_WITH_COPROCESSORS_SUBSCRIPTIONS_IN_ALLOWED_FEATURES + .to_string(), + ) + .build() + .await; + + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", "localhost:4001"); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", "localhost:4002"); + + router.start().await; + router.assert_started().await; +} + +// In the CI environment we only install Redis on x86_64 Linux +#[cfg(any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")))] +#[tokio::test(flavor = "multi_thread")] +async fn router_starts_when_license_past_warn_at_but_not_expired_allowed_features_contains_feature_entity_caching() + { + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + let mut router = IntegrationTest::builder() + .config( + r#" + preview_entity_cache: + enabled: true + subgraph: + all: + redis: + urls: ["redis://127.0.0.1:6379"] + ttl: "10m" + required_to_start: true + subgraphs: + connectors: + enabled: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .env(env) + .jwt(JWT_PAST_WARN_AT_BUT_NOT_EXPIRED_WITH_COPROCESSORS_ENTITY_CACHING_TRAFFIC_SHAPING_IN_ALLOWED_FEATURES.to_string()) + .build() + .await; + + router.start().await; + router.assert_started().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn feature_violation_when_license_past_warn_at_but_not_expired_allowed_features_does_not_contain_feature() + { + let mut env = HashMap::new(); + env.insert( + "APOLLO_TEST_INTERNAL_UPLINK_JWKS".to_string(), + TEST_JWKS_ENDPOINT.as_os_str().into(), + ); + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(SUBSCRIPTION_COPROCESSOR_CONFIG) + .env(env) + // jwt's allowed features does not contain subscriptions + .jwt( + JWT_PAST_WARN_AT_BUT_NOT_EXPIRED_WITH_COPROCESSORS_ENTITY_CACHING_TRAFFIC_SHAPING_IN_ALLOWED_FEATURES + .to_string(), + ) + .build() + .await; + + router.start().await; + router + .assert_error_log_contained(LICENSE_ALLOWED_FEATURES_DOES_NOT_INCLUDE_FEATURE_MSG) + .await; +} diff --git a/apollo-router/tests/integration/batching.rs b/apollo-router/tests/integration/batching.rs index 071ca5cb7a..0ed3333b1c 100644 --- a/apollo-router/tests/integration/batching.rs +++ b/apollo-router/tests/integration/batching.rs @@ -5,14 +5,11 @@ use tower::BoxError; use wiremock::ResponseTemplate; use crate::integration::ValueExt as _; +use crate::integration::common::graph_os_enabled; const CONFIG: &str = include_str!("../fixtures/batching/all_enabled.router.yaml"); const SHORT_TIMEOUTS_CONFIG: &str = include_str!("../fixtures/batching/short_timeouts.router.yaml"); -fn test_is_enabled() -> bool { - std::env::var("TEST_APOLLO_KEY").is_ok() && std::env::var("TEST_APOLLO_GRAPH_REF").is_ok() -} - #[tokio::test(flavor = "multi_thread")] async fn it_supports_single_subgraph_batching() -> Result<(), BoxError> { const REQUEST_COUNT: usize = 5; @@ -34,7 +31,7 @@ async fn it_supports_single_subgraph_batching() -> Result<(), BoxError> { ) .await?; - if test_is_enabled() { + if graph_os_enabled() { // Make sure that we got back what we wanted assert_yaml_snapshot!(responses, @r###" --- @@ -88,7 +85,7 @@ async fn it_supports_multi_subgraph_batching() -> Result<(), BoxError> { ) .await?; - if test_is_enabled() { + if graph_os_enabled() { // Make sure that we got back what we wanted assert_yaml_snapshot!(responses, @r###" --- @@ -137,7 +134,7 @@ async fn it_batches_with_errors_in_single_graph() -> Result<(), BoxError> { ) .await?; - if test_is_enabled() { + if graph_os_enabled() { // Make sure that we got back what we wanted assert_yaml_snapshot!(responses, @r###" --- @@ -147,6 +144,8 @@ async fn it_batches_with_errors_in_single_graph() -> Result<(), BoxError> { - errors: - message: expected error in A path: [] + extensions: + service: a - data: entryA: index: 2 @@ -188,7 +187,7 @@ async fn it_batches_with_errors_in_multi_graph() -> Result<(), BoxError> { ) .await?; - if test_is_enabled() { + if graph_os_enabled() { assert_yaml_snapshot!(responses, @r###" --- - data: @@ -200,9 +199,13 @@ async fn it_batches_with_errors_in_multi_graph() -> Result<(), BoxError> { - errors: - message: expected error in A path: [] + extensions: + service: a - errors: - message: expected error in B path: [] + extensions: + service: b - data: entryA: index: 2 @@ -245,26 +248,27 @@ async fn it_handles_short_timeouts() -> Result<(), BoxError> { ) .await?; - if test_is_enabled() { - assert_yaml_snapshot!(responses, @r###" - --- + if graph_os_enabled() { + assert_yaml_snapshot!(responses, @r" - data: entryA: index: 0 - errors: - - message: Request timed out + - message: Your request has been timed out path: [] extensions: - code: REQUEST_TIMEOUT + code: GATEWAY_TIMEOUT + service: b - data: entryA: index: 1 - errors: - - message: Request timed out + - message: Your request has been timed out path: [] extensions: - code: REQUEST_TIMEOUT - "###); + code: GATEWAY_TIMEOUT + service: b + "); } Ok(()) @@ -314,9 +318,8 @@ async fn it_handles_indefinite_timeouts() -> Result<(), BoxError> { // verify the output let responses = [results_a, results_b].concat(); - if test_is_enabled() { - assert_yaml_snapshot!(responses, @r###" - --- + if graph_os_enabled() { + assert_yaml_snapshot!(responses, @r" - data: entryA: index: 0 @@ -327,21 +330,24 @@ async fn it_handles_indefinite_timeouts() -> Result<(), BoxError> { entryA: index: 2 - errors: - - message: Request timed out + - message: Your request has been timed out path: [] extensions: - code: REQUEST_TIMEOUT + code: GATEWAY_TIMEOUT + service: b - errors: - - message: Request timed out + - message: Your request has been timed out path: [] extensions: - code: REQUEST_TIMEOUT + code: GATEWAY_TIMEOUT + service: b - errors: - - message: Request timed out + - message: Your request has been timed out path: [] extensions: - code: REQUEST_TIMEOUT - "###); + code: GATEWAY_TIMEOUT + service: b + "); } Ok(()) @@ -378,7 +384,7 @@ async fn it_handles_cancelled_by_rhai() -> Result<(), BoxError> { ) .await?; - if test_is_enabled() { + if graph_os_enabled() { assert_yaml_snapshot!(responses, @r###" --- - data: @@ -434,7 +440,7 @@ async fn it_handles_single_request_cancelled_by_rhai() -> Result<(), BoxError> { assert_eq!( request.query, Some(format!( - "query op{index}__b__0{{entryB(count:{REQUEST_COUNT}){{index}}}}", + "query op{index}__b__0 {{ entryB(count: {REQUEST_COUNT}) {{ index }} }}", )) ); } @@ -466,7 +472,7 @@ async fn it_handles_single_request_cancelled_by_rhai() -> Result<(), BoxError> { ) .await?; - if test_is_enabled() { + if graph_os_enabled() { assert_yaml_snapshot!(responses, @r###" --- - data: @@ -560,7 +566,7 @@ async fn it_handles_cancelled_by_coprocessor() -> Result<(), BoxError> { ) .await?; - if test_is_enabled() { + if graph_os_enabled() { assert_yaml_snapshot!(responses, @r###" --- - errors: @@ -568,6 +574,7 @@ async fn it_handles_cancelled_by_coprocessor() -> Result<(), BoxError> { path: [] extensions: code: ERR_NOT_ALLOWED + service: a - data: entryB: index: 0 @@ -576,6 +583,7 @@ async fn it_handles_cancelled_by_coprocessor() -> Result<(), BoxError> { path: [] extensions: code: ERR_NOT_ALLOWED + service: a - data: entryB: index: 1 @@ -670,7 +678,7 @@ async fn it_handles_single_request_cancelled_by_coprocessor() -> Result<(), BoxE assert_eq!( request.query, Some(format!( - "query op{index}__a__0{{entryA(count:{REQUEST_COUNT}){{index}}}}", + "query op{index}__a__0 {{ entryA(count: {REQUEST_COUNT}) {{ index }} }}", )) ); } @@ -705,7 +713,7 @@ async fn it_handles_single_request_cancelled_by_coprocessor() -> Result<(), BoxE ) .await?; - if test_is_enabled() { + if graph_os_enabled() { assert_yaml_snapshot!(responses, @r###" --- - data: @@ -725,6 +733,7 @@ async fn it_handles_single_request_cancelled_by_coprocessor() -> Result<(), BoxE path: [] extensions: code: ERR_NOT_ALLOWED + service: a - data: entryB: index: 2 @@ -771,7 +780,7 @@ async fn it_handles_single_invalid_graphql() -> Result<(), BoxError> { assert_eq!( request.query, Some(format!( - "query op{index}__a__0{{entryA(count:{REQUEST_COUNT}){{index}}}}", + "query op{index}__a__0 {{ entryA(count: {REQUEST_COUNT}) {{ index }} }}", )) ); } @@ -800,7 +809,7 @@ async fn it_handles_single_invalid_graphql() -> Result<(), BoxError> { ) .await?; - if test_is_enabled() { + if graph_os_enabled() { // Make sure that we got back what we wanted assert_yaml_snapshot!(responses, @r###" --- @@ -836,13 +845,14 @@ mod helper { use apollo_router::graphql::Request; use apollo_router::graphql::Response; use tower::BoxError; - use wiremock::matchers; use wiremock::MockServer; use wiremock::Respond; use wiremock::ResponseTemplate; + use wiremock::matchers; - use super::test_is_enabled; + use super::graph_os_enabled; use crate::integration::common::IntegrationTest; + use crate::integration::common::Query; /// Helper type for specifying a valid handler pub type Handler = fn(&wiremock::Request) -> ResponseTemplate; @@ -878,7 +888,7 @@ mod helper { // Ensure that we have the test keys before running // Note: The [IntegrationTest] ensures that these test credentials get // set before running the router. - if !test_is_enabled() { + if !graph_os_enabled() { return Ok(Vec::new()); }; @@ -902,7 +912,9 @@ mod helper { // Execute the request let request = serde_json::to_value(requests)?; - let (_span, response) = router.execute_query(&request).await; + let (_span, response) = router + .execute_query(Query::builder().body(request).build()) + .await; serde_json::from_slice::>(&response.bytes().await?).map_err(BoxError::from) } @@ -913,7 +925,7 @@ mod helper { // Extract info about this operation let (subgraph, count): (String, usize) = { - let re = regex::Regex::new(r"entry([AB])\(count:([0-9]+)\)").unwrap(); + let re = regex::Regex::new(r"entry([AB])\(count: ?([0-9]+)\)").unwrap(); let captures = re.captures(requests[0].query.as_ref().unwrap()).unwrap(); (captures[1].to_string(), captures[2].parse().unwrap()) @@ -929,7 +941,7 @@ mod helper { assert_eq!( request.query, Some(format!( - "query op{index}__{}__0{{entry{}(count:{count}){{index}}}}", + "query op{index}__{}__0 {{ entry{}(count: {count}) {{ index }} }}", subgraph.to_lowercase(), subgraph )) @@ -957,7 +969,7 @@ mod helper { // Extract info about this operation let (subgraph, count): (String, usize) = { - let re = regex::Regex::new(r"entry([AB])\(count:([0-9]+)\)").unwrap(); + let re = regex::Regex::new(r"entry([AB])\(count: ?([0-9]+)\)").unwrap(); let captures = re.captures(requests[0].query.as_ref().unwrap()).unwrap(); (captures[1].to_string(), captures[2].parse().unwrap()) @@ -996,7 +1008,7 @@ mod helper { // Extract info about this operation let (_, count): (String, usize) = { - let re = regex::Regex::new(r"entry([AB])\(count:([0-9]+)\)").unwrap(); + let re = regex::Regex::new(r"entry([AB])\(count: ?([0-9]+)\)").unwrap(); let captures = re.captures(requests[0].query.as_ref().unwrap()).unwrap(); (captures[1].to_string(), captures[2].parse().unwrap()) diff --git a/apollo-router/tests/integration/connectors.rs b/apollo-router/tests/integration/connectors.rs new file mode 100644 index 0000000000..03bf491934 --- /dev/null +++ b/apollo-router/tests/integration/connectors.rs @@ -0,0 +1,1112 @@ +mod apq { + use std::path::PathBuf; + + use tower::BoxError; + + use crate::integration::IntegrationTest; + use crate::integration::common::graph_os_enabled; + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_all() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + apq: + subgraph: + all: + enabled: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","message":"plugin `apq` indirectly targets a connector-enabled subgraph, which is not supported"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_subgraph() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + apq: + subgraph: + all: + enabled: false + subgraphs: + connectors: + enabled: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","message":"plugin `apq` is explicitly configured for connector-enabled subgraph"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn no_incompatible_warnings_with_overrides() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + apq: + subgraph: + all: + enabled: true + subgraphs: + connectors: + enabled: false + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .assert_log_not_contains(r#""subgraph":"connectors","message":"plugin `apq`"#) + .await; + + Ok(()) + } +} + +mod authentication { + use std::path::PathBuf; + + use serde_json::Value; + use serde_json::json; + use tower::BoxError; + + use crate::integration::IntegrationTest; + use crate::integration::common::Query; + use crate::integration::common::graph_os_enabled; + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_all() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + authentication: + subgraph: + all: + aws_sig_v4: + default_chain: + profile_name: "my-test-profile" + region: "us-east-1" + service_name: "lambda" + assume_role: + role_arn: "test-arn" + session_name: "test-session" + external_id: "test-id" + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraphs":"connectors","message":"plugin `authentication` is enabled for connector-enabled subgraphs"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_subgraph() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + authentication: + subgraph: + subgraphs: + connectors: + aws_sig_v4: + hardcoded: + access_key_id: "my-access-key" + secret_access_key: "my-secret-access-key" + region: "us-east-1" + service_name: "vpc-lattice-svcs" + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraphs":"connectors","message":"plugin `authentication` is enabled for connector-enabled subgraphs"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_with_overrides() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + authentication: + subgraph: + subgraphs: + connectors: + aws_sig_v4: + hardcoded: + access_key_id: "my-access-key" + secret_access_key: "my-secret-access-key" + region: "us-east-1" + service_name: "vpc-lattice-svcs" + connector: + sources: + connectors.something: + aws_sig_v4: + default_chain: + profile_name: "default" + region: "us-east-1" + service_name: "lambda" + assume_role: + role_arn: "arn:aws:iam::XXXXXXXXXXXX:role/lambaexecute" + session_name: "connector" + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","sources":"jsonPlaceholder","message":"plugin `authentication` is enabled for a connector-enabled subgraph"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn no_incompatible_warnings_with_overrides() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + authentication: + subgraph: + subgraphs: + connectors: + aws_sig_v4: + hardcoded: + access_key_id: "my-access-key" + secret_access_key: "my-secret-access-key" + region: "us-east-1" + service_name: "vpc-lattice-svcs" + connector: + sources: + connectors.jsonPlaceholder: + aws_sig_v4: + default_chain: + profile_name: "default" + region: "us-east-1" + service_name: "lambda" + assume_role: + role_arn: "arn:aws:iam::XXXXXXXXXXXX:role/lambaexecute" + session_name: "connector" + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .assert_log_not_contains(r#""subgraph":"connectors","sources":"jsonPlaceholder","message":"plugin `authentication` is enabled for a connector-enabled subgraph"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + #[cfg_attr(not(feature = "ci"), ignore)] + async fn test_aws_sig_v4_signing() { + let mut router = IntegrationTest::builder() + .config(include_str!("fixtures/connectors_sigv4.router.yaml")) + .supergraph(PathBuf::from( + "tests/integration/fixtures/connectors_sigv4.graphql", + )) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_, response) = router + .execute_query( + Query::builder() + .body(json! ({"query": "query { instances }"})) + .build(), + ) + .await; + let body: Value = response.json().await.unwrap(); + router.graceful_shutdown().await; + let body = body.as_object().expect("Response body should be object"); + let errors = body.get("errors"); + assert!(errors.is_none(), "query generated errors: {errors:?}"); + let me = body + .get("data") + .expect("Response body should have data") + .as_object() + .expect("Data should be object") + .get("instances") + .expect("Data should have instances"); + assert!(me.is_null()); + } +} + +mod batching { + use std::path::PathBuf; + + use tower::BoxError; + + use crate::integration::IntegrationTest; + use crate::integration::common::graph_os_enabled; + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_all() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + batching: + enabled: true + mode: batch_http_link + subgraph: + all: + enabled: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","message":"plugin `batching` indirectly targets a connector-enabled subgraph, which is not supported"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_subgraph() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + batching: + enabled: true + mode: batch_http_link + subgraph: + all: + enabled: false + subgraphs: + connectors: + enabled: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","message":"plugin `batching` is explicitly configured for connector-enabled subgraph"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn no_incompatible_warnings_with_overrides() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + batching: + enabled: true + mode: batch_http_link + subgraph: + all: + enabled: true + subgraphs: + connectors: + enabled: false + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .assert_log_not_contains(r#""subgraph":"connectors","message":"plugin `batching`"#) + .await; + + Ok(()) + } +} + +mod coprocessor { + use std::path::PathBuf; + + use tower::BoxError; + + use crate::integration::IntegrationTest; + use crate::integration::common::graph_os_enabled; + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_all() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + coprocessor: + url: http://127.0.0.1:8081 + subgraph: + all: + request: {} + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraphs":"connectors","message":"coprocessors which hook into `subgraph_request` or `subgraph_response`"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn no_incompatible_warnings_for_supergraph() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + coprocessor: + url: http://127.0.0.1:8081 + supergraph: + request: {} + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .assert_log_not_contains(r#""subgraphs":"connectors","message":"coprocessors which hook into `subgraph_request` or `subgraph_response`"#) + .await; + + Ok(()) + } +} + +mod entity_cache { + use std::path::PathBuf; + + use tower::BoxError; + + use crate::integration::IntegrationTest; + use crate::integration::common::graph_os_enabled; + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_all() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + preview_entity_cache: + enabled: true + subgraph: + all: + enabled: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","message":"plugin `preview_entity_cache` indirectly targets a connector-enabled subgraph, which is not supported"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_subgraph() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + preview_entity_cache: + enabled: true + subgraph: + all: + enabled: false + subgraphs: + connectors: + enabled: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","message":"plugin `preview_entity_cache` is explicitly configured for connector-enabled subgraph"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn no_incompatible_warnings_with_overrides() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + preview_entity_cache: + enabled: true + subgraph: + all: + enabled: false + subgraphs: + connectors: + enabled: false + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .assert_log_not_contains( + r#""subgraph":"connectors","message":"plugin `preview_entity_cache`"#, + ) + .await; + + Ok(()) + } +} + +mod headers { + use std::path::PathBuf; + + use tower::BoxError; + + use crate::integration::IntegrationTest; + use crate::integration::common::graph_os_enabled; + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_all() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + headers: + all: + request: + - propagate: + matching: ^upstream-header-.* + - remove: + named: "x-legacy-account-id" + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","message":"plugin `headers` indirectly targets a connector-enabled subgraph"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_subgraph() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + headers: + subgraphs: + connectors: + request: + - propagate: + matching: ^upstream-header-.* + - remove: + named: "x-legacy-account-id" + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","message":"plugin `headers` is explicitly configured for connector-enabled subgraph"#) + .await; + + Ok(()) + } +} + +mod rhai { + use std::path::PathBuf; + + use tower::BoxError; + + use crate::integration::IntegrationTest; + use crate::integration::common::graph_os_enabled; + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_all() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + rhai: + main: "test.rhai" + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraphs":"connectors","message":"rhai scripts which hook into `subgraph_request` or `subgraph_response`"#) + .await; + + Ok(()) + } +} + +mod telemetry { + use std::path::PathBuf; + + use tower::BoxError; + + use crate::integration::IntegrationTest; + use crate::integration::common::graph_os_enabled; + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_all() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + telemetry: + apollo: + errors: + subgraph: + all: + send: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","message":"plugin `telemetry` is indirectly configured to send errors to Apollo studio for a connector-enabled subgraph, which is only supported when `preview_extended_error_metrics` is enabled"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_subgraph() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + telemetry: + apollo: + errors: + subgraph: + all: + send: false + subgraphs: + connectors: + send: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","message":"plugin `telemetry` is explicitly configured to send errors to Apollo studio for connector-enabled subgraph, which is only supported when `preview_extended_error_metrics` is enabled"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn no_incompatible_warnings_with_overrides() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + telemetry: + apollo: + errors: + subgraph: + all: + send: true + subgraphs: + connectors: + send: false + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .assert_log_not_contains(r#""subgraph":"connectors","message":"plugin `telemetry`"#) + .await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn no_incompatible_warnings_with_flag_enabled() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + telemetry: + apollo: + errors: + preview_extended_error_metrics: enabled + subgraph: + all: + send: true + subgraphs: + connectors: + send: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .assert_log_not_contains(r#""subgraph":"connectors","message":"plugin `telemetry`"#) + .await; + + Ok(()) + } +} + +mod tls { + use std::path::PathBuf; + + use tower::BoxError; + + use crate::integration::IntegrationTest; + use crate::integration::common::graph_os_enabled; + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_subgraph() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + tls: + subgraph: + subgraphs: + connectors: + certificate_authorities: "${file./path/to/product_ca.crt}" + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","message":"The `tls` plugin is explicitly configured for a subgraph containing connectors, which is not supported. Instead, configure the connector sources directly using `tls.connector.sources..`."#) + .await; + + Ok(()) + } +} + +mod traffic_shaping { + use std::path::PathBuf; + + use tower::BoxError; + + use crate::integration::IntegrationTest; + use crate::integration::common::graph_os_enabled; + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_subgraph() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + traffic_shaping: + subgraphs: + connectors: + deduplicate_query: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","message":"The `traffic_shaping` plugin is explicitly configured for a subgraph containing connectors, which is not supported. Instead, configure the connector sources directly using `traffic_shaping.connector.sources..`."#) + .await; + + Ok(()) + } +} + +mod url_override { + use std::path::PathBuf; + + use tower::BoxError; + + use crate::integration::IntegrationTest; + use crate::integration::common::graph_os_enabled; + + #[tokio::test(flavor = "multi_thread")] + async fn incompatible_warnings_on_subgraph() -> Result<(), BoxError> { + // Ensure that we have the test keys before running + // Note: The [IntegrationTest] ensures that these test credentials get + // set before running the router. + if !graph_os_enabled() { + return Ok(()); + }; + + let mut router = IntegrationTest::builder() + .config( + r#" + override_subgraph_url: + connectors: http://localhost:8080 + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .build() + .await; + + router.start().await; + router + .wait_for_log_message(r#""subgraph":"connectors","message":"overriding a subgraph URL for a connectors-enabled subgraph is not supported"#) + .await; + + Ok(()) + } +} diff --git a/apollo-router/tests/integration/coprocessor.rs b/apollo-router/tests/integration/coprocessor.rs index d1492fbc87..7bda3487a5 100644 --- a/apollo-router/tests/integration/coprocessor.rs +++ b/apollo-router/tests/integration/coprocessor.rs @@ -1,14 +1,15 @@ use insta::assert_yaml_snapshot; use serde_json::json; use tower::BoxError; +use wiremock::Mock; +use wiremock::ResponseTemplate; use wiremock::matchers::body_partial_json; use wiremock::matchers::method; use wiremock::matchers::path; -use wiremock::Mock; -use wiremock::ResponseTemplate; -use crate::integration::common::graph_os_enabled; use crate::integration::IntegrationTest; +use crate::integration::common::Query; +use crate::integration::common::graph_os_enabled; #[tokio::test(flavor = "multi_thread")] async fn test_error_not_propagated_to_client() -> Result<(), BoxError> { @@ -26,7 +27,7 @@ async fn test_error_not_propagated_to_client() -> Result<(), BoxError> { let (_trace_id, response) = router.execute_default_query().await; assert_eq!(response.status(), 500); assert_yaml_snapshot!(response.text().await?); - router.assert_log_contains("INTERNAL_SERVER_ERROR").await; + router.wait_for_log_message("Internal Server Error").await; router.graceful_shutdown().await; Ok(()) } @@ -43,7 +44,7 @@ async fn test_coprocessor_limit_payload() -> Result<(), BoxError> { // Expect a small query Mock::given(method("POST")) .and(path("/")) - .and(body_partial_json(json!({"version":1,"stage":"RouterRequest","control":"continue","body":"{\"query\":\"query {topProducts{name}}\",\"variables\":{}}","method":"POST"}))) + .and(body_partial_json(json!({"version":1,"stage":"RouterRequest","control":"continue","body":"{\"query\":\"query ExampleQuery {topProducts{name}}\",\"variables\":{}}","method":"POST"}))) .respond_with( ResponseTemplate::new(200).set_body_json(json!({"version":1,"stage":"RouterRequest","control":"continue","body":"{\"query\":\"query {topProducts{name}}\",\"variables\":{}}","method":"POST"})), ) @@ -75,7 +76,9 @@ async fn test_coprocessor_limit_payload() -> Result<(), BoxError> { assert_eq!(response.status(), 200); // This query is huge and will be rejected because it is too large before hitting the coprocessor - let (_trace_id, response) = router.execute_huge_query().await; + let (_trace_id, response) = router + .execute_query(Query::default().with_huge_query()) + .await; assert_eq!(response.status(), 413); assert_yaml_snapshot!(response.text().await?); @@ -83,6 +86,118 @@ async fn test_coprocessor_limit_payload() -> Result<(), BoxError> { Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn test_coprocessor_response_handling() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + test_full_pipeline(400, "RouterRequest", empty_body_string).await; + test_full_pipeline(200, "RouterResponse", empty_body_string).await; + test_full_pipeline(500, "SupergraphRequest", empty_body_string).await; + test_full_pipeline(500, "SupergraphResponse", empty_body_string).await; + test_full_pipeline(200, "SubgraphRequest", empty_body_string).await; + test_full_pipeline(200, "SubgraphResponse", empty_body_string).await; + test_full_pipeline(500, "ExecutionRequest", empty_body_string).await; + test_full_pipeline(500, "ExecutionResponse", empty_body_string).await; + + test_full_pipeline(500, "RouterRequest", empty_body_object).await; + test_full_pipeline(500, "RouterResponse", empty_body_object).await; + test_full_pipeline(200, "SupergraphRequest", empty_body_object).await; + test_full_pipeline(500, "SupergraphResponse", empty_body_object).await; + test_full_pipeline(200, "SubgraphRequest", empty_body_object).await; + test_full_pipeline(200, "SubgraphResponse", empty_body_object).await; + test_full_pipeline(200, "ExecutionRequest", empty_body_object).await; + test_full_pipeline(500, "ExecutionResponse", empty_body_object).await; + + test_full_pipeline(200, "RouterRequest", remove_body).await; + test_full_pipeline(200, "RouterResponse", remove_body).await; + test_full_pipeline(200, "SupergraphRequest", remove_body).await; + test_full_pipeline(200, "SupergraphResponse", remove_body).await; + test_full_pipeline(200, "SubgraphRequest", remove_body).await; + test_full_pipeline(200, "SubgraphResponse", remove_body).await; + test_full_pipeline(200, "ExecutionRequest", remove_body).await; + test_full_pipeline(200, "ExecutionResponse", remove_body).await; + + test_full_pipeline(500, "RouterRequest", null_out_response).await; + test_full_pipeline(500, "RouterResponse", null_out_response).await; + test_full_pipeline(500, "SupergraphRequest", null_out_response).await; + test_full_pipeline(500, "SupergraphResponse", null_out_response).await; + test_full_pipeline(200, "SubgraphRequest", null_out_response).await; + test_full_pipeline(200, "SubgraphResponse", null_out_response).await; + test_full_pipeline(500, "ExecutionRequest", null_out_response).await; + test_full_pipeline(500, "ExecutionResponse", null_out_response).await; + Ok(()) +} + +fn empty_body_object(mut body: serde_json::Value) -> serde_json::Value { + *body.pointer_mut("/body").expect("body") = json!({}); + body +} + +fn empty_body_string(mut body: serde_json::Value) -> serde_json::Value { + *body.pointer_mut("/body").expect("body") = json!(""); + body +} + +fn remove_body(mut body: serde_json::Value) -> serde_json::Value { + body.as_object_mut().expect("body").remove("body"); + body +} + +fn null_out_response(_body: serde_json::Value) -> serde_json::Value { + json!("") +} + +async fn test_full_pipeline( + response_status: u16, + stage: &'static str, + coprocessor: impl Fn(serde_json::Value) -> serde_json::Value + Send + Sync + 'static, +) { + let mock_server = wiremock::MockServer::start().await; + let coprocessor_address = mock_server.uri(); + + // Expect a small query + Mock::given(method("POST")) + .and(path("/")) + .respond_with(move |req: &wiremock::Request| { + let mut body = req.body_json::().expect("body"); + if body + .as_object() + .unwrap() + .get("stage") + .unwrap() + .as_str() + .unwrap() + == stage + { + body = coprocessor(body); + } + ResponseTemplate::new(200).set_body_json(body) + }) + .mount(&mock_server) + .await; + + let mut router = IntegrationTest::builder() + .config( + include_str!("fixtures/coprocessor.router.yaml") + .replace("", &coprocessor_address), + ) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router.execute_default_query().await; + assert_eq!( + response.status(), + response_status, + "Failed at stage {stage}" + ); + + router.graceful_shutdown().await; +} + #[tokio::test(flavor = "multi_thread")] async fn test_coprocessor_demand_control_access() -> Result<(), BoxError> { if !graph_os_enabled() { @@ -99,9 +214,9 @@ async fn test_coprocessor_demand_control_access() -> Result<(), BoxError> { "stage": "ExecutionRequest", "context": { "entries": { - "cost.estimated": 10.0, - "cost.result": "COST_OK", - "cost.strategy": "static_estimated" + "apollo::demand_control::estimated_cost": 10.0, + "apollo::demand_control::result": "COST_OK", + "apollo::demand_control::strategy": "static_estimated" }}}))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "version":1, @@ -118,10 +233,10 @@ async fn test_coprocessor_demand_control_access() -> Result<(), BoxError> { .and(body_partial_json(json!({ "stage": "SupergraphResponse", "context": {"entries": { - "cost.actual": 3.0, - "cost.estimated": 10.0, - "cost.result": "COST_OK", - "cost.strategy": "static_estimated" + "apollo::demand_control::actual_cost": 3.0, + "apollo::demand_control::estimated_cost": 10.0, + "apollo::demand_control::result": "COST_OK", + "apollo::demand_control::strategy": "static_estimated" }}}))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "version":1, @@ -150,3 +265,227 @@ async fn test_coprocessor_demand_control_access() -> Result<(), BoxError> { Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn test_coprocessor_proxying_error_response() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let mock_coprocessor = wiremock::MockServer::start().await; + let coprocessor_address = mock_coprocessor.uri(); + + Mock::given(method("POST")) + .and(path("/")) + .respond_with(|req: &wiremock::Request| { + let body = req.body_json::().expect("body"); + ResponseTemplate::new(200).set_body_json(body) + }) + .mount(&mock_coprocessor) + .await; + + let mut router = IntegrationTest::builder() + .config( + include_str!("fixtures/coprocessor.router.yaml") + .replace("", &coprocessor_address), + ) + .responder(ResponseTemplate::new(200).set_body_json(json!({ + "errors": [{ "message": "subgraph error", "path": [] }], + "data": null + }))) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router.execute_default_query().await; + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await?, + json!({ + "errors": [{ "message": "Subgraph errors redacted", "path": [] }], + "data": null + }) + ); + + router.graceful_shutdown().await; + + Ok(()) +} + +mod on_graphql_error_selector { + use std::collections::HashMap; + use std::sync::Arc; + use std::sync::RwLock; + + use serde_json::json; + use serde_json::value::Value; + use tower::BoxError; + use wiremock::Mock; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + use crate::integration::IntegrationTest; + use crate::integration::common::Query; + use crate::integration::common::graph_os_enabled; + + fn query() -> Query { + Query::builder() + .traced(true) + .body(json!({"query": "query Q { topProducts { name inStock } }"})) + .build() + } + + fn products_response(errors: bool) -> Value { + if errors { + json!({"errors": [{ "message": "products error", "path": [] }]}) + } else { + json!({ + "data": { + "topProducts": [ + { "__typename": "Product", "name": "Table", "upc": "1" }, + { "__typename": "Product", "name": "Chair", "upc": "2" }, + ] + }, + }) + } + } + + fn inventory_response(errors: bool) -> Value { + if errors { + json!({"errors": [{ "message": "inventory error", "path": [] }]}) + } else { + json!({"data": {"_entities": [{"inStock": true}, {"inStock": false}]}}) + } + } + + fn response_template(response_json: Value) -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(response_json) + } + + async fn send_query_to_coprocessor_enabled_router( + query: Query, + subgraph_response_products: ResponseTemplate, + subgraph_response_inventory: ResponseTemplate, + ) -> Result<(Value, HashMap), BoxError> { + let coprocessor_hits: Arc>> = + Arc::new(RwLock::new(HashMap::default())); + let coprocessor_hits_clone = coprocessor_hits.clone(); + let coprocessor_response = move |req: &wiremock::Request| { + let req_body = req.body_json::().expect("body"); + let stage = req_body.as_object()?.get("stage")?.as_str()?.to_string(); + + let mut binding = coprocessor_hits_clone.write().ok()?; + let entry = binding.entry(stage).or_default(); + *entry += 1; + Some(response_template(req_body)) + }; + + let mock_coprocessor = wiremock::MockServer::start().await; + Mock::given(method("POST")) + .and(path("/")) + .respond_with(move |r: &wiremock::Request| coprocessor_response(r).unwrap()) + .mount(&mock_coprocessor) + .await; + + let mock_products = wiremock::MockServer::start().await; + Mock::given(method("POST")) + .and(path("/")) + .respond_with(subgraph_response_products) + .mount(&mock_products) + .await; + + let mock_inventory = wiremock::MockServer::start().await; + Mock::given(method("POST")) + .and(path("/")) + .respond_with(subgraph_response_inventory) + .mount(&mock_inventory) + .await; + + let mut router = IntegrationTest::builder() + .config( + include_str!("fixtures/coprocessor_conditional.router.yaml") + .replace("", &mock_coprocessor.uri()), + ) + .subgraph_override("products", mock_products.uri()) + .subgraph_override("inventory", mock_inventory.uri()) + .build() + .await; + router.start().await; + router.assert_started().await; + + let (_, response) = router.execute_query(query).await; + assert_eq!(response.status(), 200); + + let response = serde_json::from_str(&response.text().await?).unwrap(); + + // NB: should be ok to read and clone bc response should have finished + Ok((response, coprocessor_hits.read().unwrap().clone())) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_all_successful() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let (response, coprocessor_hits) = send_query_to_coprocessor_enabled_router( + query(), + response_template(products_response(false)), + response_template(inventory_response(false)), + ) + .await?; + + let errors = response.as_object().unwrap().get("errors"); + assert!(errors.is_none()); + assert!(coprocessor_hits.is_empty()); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_first_response_failure() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let (response, coprocessor_hits) = send_query_to_coprocessor_enabled_router( + query(), + response_template(products_response(true)), + response_template(inventory_response(false)), + ) + .await?; + + let errors = response.as_object().unwrap().get("errors").unwrap(); + insta::assert_json_snapshot!(errors); + + assert_eq!(*coprocessor_hits.get("RouterResponse").unwrap(), 1); + assert_eq!(*coprocessor_hits.get("SupergraphResponse").unwrap(), 1); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_nested_response_failure() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let (response, coprocessor_hits) = send_query_to_coprocessor_enabled_router( + query(), + response_template(products_response(false)), + response_template(inventory_response(true)), + ) + .await?; + + let errors = response.as_object().unwrap().get("errors").unwrap(); + insta::assert_json_snapshot!(errors); + + assert_eq!(*coprocessor_hits.get("RouterResponse").unwrap(), 1); + assert_eq!(*coprocessor_hits.get("SupergraphResponse").unwrap(), 1); + + Ok(()) + } +} diff --git a/apollo-router/tests/integration/docs.rs b/apollo-router/tests/integration/docs.rs index 0ccbf98c08..7944c5d41d 100644 --- a/apollo-router/tests/integration/docs.rs +++ b/apollo-router/tests/integration/docs.rs @@ -12,20 +12,20 @@ fn check_config_json() { let result = jsonpath_lib::select(&config, "$.sidebar.*.*").expect("values must be selectable"); let re = Regex::new(r"^[a-z/-]+$").expect("regex must be valid"); for value in result { - if let Value::String(path) = value { - if !path.starts_with("https://") { + if let Value::String(path) = value + && !path.starts_with("https://") + { + assert!( + re.is_match(path), + "{path} in config.json was not kebab case" + ); + if path != "/" { + let path_in_docs = format!("../docs/source{path}.mdx"); + let path_in_docs = Path::new(&path_in_docs); assert!( - re.is_match(path), - "{path} in config.json was not kebab case" + path_in_docs.exists(), + "{path} in docs/source/config.json did not exist" ); - if path != "/" { - let path_in_docs = format!("../docs/source{path}.mdx"); - let path_in_docs = Path::new(&path_in_docs); - assert!( - path_in_docs.exists(), - "{path} in docs/source/config.json did not exist" - ); - } } } } diff --git a/apollo-router/tests/integration/entity_cache.rs b/apollo-router/tests/integration/entity_cache.rs new file mode 100644 index 0000000000..b19dc0047c --- /dev/null +++ b/apollo-router/tests/integration/entity_cache.rs @@ -0,0 +1,595 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use apollo_router::graphql; +use apollo_router::services; +use apollo_router::test_harness::HttpService; +use http::HeaderMap; +use http_body_util::BodyExt as _; +use indexmap::IndexMap; +use serde_json::json; +use tower::Service as _; +use tower::ServiceExt as _; + +use crate::integration::common::IntegrationTest; +use crate::integration::common::Query; +use crate::integration::common::graph_os_enabled; + +const INVALIDATION_PATH: &str = "/invalidation"; +const INVALIDATION_SHARED_KEY: &str = "supersecret"; + +fn base_config() -> serde_json::Value { + // Isolate tests from each other by adding a random redis key prefix + let namespace = uuid::Uuid::new_v4().simple().to_string(); + json!({ + "include_subgraph_errors": { + "all": true, + }, + "preview_entity_cache": { + "enabled": true, + "subgraph": { + "all": { + "redis": { + "urls": ["redis://127.0.0.1:6379"], + "ttl": "10m", + "namespace": namespace, + "required_to_start": true, + }, + "invalidation": { + "enabled": true, + "shared_key": INVALIDATION_SHARED_KEY, + }, + }, + }, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": INVALIDATION_PATH, + }, + }, + }) +} + +fn base_subgraphs() -> serde_json::Value { + json!({ + "products": { + "headers": {"cache-control": "public"}, + "query": { + "topProducts": [ + {"upc": "1"}, + {"upc": "2"}, + ], + }, + }, + "reviews": { + "headers": {"cache-control": "public"}, + "entities": [ + {"__typename": "Product", "upc": "1", "reviews": [{"id": "r1a"}, {"id": "r1b"}]}, + {"__typename": "Product", "upc": "2", "reviews": [{"id": "r2"}]}, + ], + }, + }) +} + +async fn harness( + mut config: serde_json::Value, + subgraphs: serde_json::Value, +) -> (HttpService, Arc>>) { + let counters = Arc::new(IndexMap::from([ + ("products".into(), Default::default()), + ("reviews".into(), Default::default()), + ])); + let counters2 = Arc::clone(&counters); + config + .as_object_mut() + .unwrap() + .insert("experimental_mock_subgraphs".into(), subgraphs); + let router = apollo_router::TestHarness::builder() + .schema(include_str!("../../testing_schema.graphql")) + .configuration_json(config) + .unwrap() + .subgraph_hook(move |subgraph_name, service| { + if let Some(counter) = counters2.get(subgraph_name) { + let counter = Arc::::clone(counter); + service + .map_request(move |req| { + counter.fetch_add(1, Ordering::Relaxed); + req + }) + .boxed() + } else { + service + } + }) + .build_http_service() + .await + .unwrap(); + (router, counters) +} + +async fn make_graphql_request(router: &mut HttpService) -> (HeaderMap, graphql::Response) { + let query = "{ topProducts { reviews { id } } }"; + let request = graphql_request(query); + make_http_request(router, request.into()).await +} + +fn graphql_request(query: &str) -> services::router::Request { + services::supergraph::Request::fake_builder() + .query(query) + .build() + .unwrap() + .try_into() + .unwrap() +} + +async fn make_json_request( + router: &mut HttpService, + request: http::Request, +) -> (HeaderMap, serde_json::Value) { + let request = + request.map(|body| services::router::body::from_bytes(serde_json::to_vec(&body).unwrap())); + make_http_request(router, request).await +} + +async fn make_http_request( + router: &mut HttpService, + request: http::Request, +) -> (HeaderMap, ResponseBody) +where + ResponseBody: for<'a> serde::Deserialize<'a>, +{ + let response = router.ready().await.unwrap().call(request).await.unwrap(); + let headers = response + .headers() + .iter() + .map(|(k, v)| (k.clone(), v.to_str().unwrap().to_owned())) + .collect(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (headers, serde_json::from_slice(&body).unwrap()) +} + +#[tokio::test] +async fn basic_cache_skips_subgraph_request() { + if !graph_os_enabled() { + return; + } + + let (mut router, subgraph_request_counters) = harness(base_config(), base_subgraphs()).await; + insta::assert_yaml_snapshot!(subgraph_request_counters, @r###" + products: 0 + reviews: 0 + "###); + let (headers, body) = make_graphql_request(&mut router).await; + assert!(headers["cache-control"].contains("public")); + insta::assert_yaml_snapshot!(body, @r###" + data: + topProducts: + - reviews: + - id: r1a + - id: r1b + - reviews: + - id: r2 + "###); + insta::assert_yaml_snapshot!(subgraph_request_counters, @r###" + products: 1 + reviews: 1 + "###); + let (headers, body) = make_graphql_request(&mut router).await; + assert!(headers["cache-control"].contains("public")); + insta::assert_yaml_snapshot!(body, @r###" + data: + topProducts: + - reviews: + - id: r1a + - id: r1b + - reviews: + - id: r2 + "###); + // Unchanged, everything is in cache so we don’t need to make more subgraph requests: + insta::assert_yaml_snapshot!(subgraph_request_counters, @r###" + products: 1 + reviews: 1 + "###); +} + +#[tokio::test] +async fn not_cached_without_cache_control_header() { + if !graph_os_enabled() { + return; + } + + let mut subgraphs = base_subgraphs(); + subgraphs["products"] + .as_object_mut() + .unwrap() + .remove("headers"); + subgraphs["reviews"] + .as_object_mut() + .unwrap() + .remove("headers"); + let (mut router, subgraph_request_counters) = harness(base_config(), subgraphs).await; + insta::assert_yaml_snapshot!(subgraph_request_counters, @r###" + products: 0 + reviews: 0 + "###); + let (headers, body) = make_graphql_request(&mut router).await; + // When subgraphs don’t set a cache-control header, Router defaults to not caching + // and instructs any downstream cache to do the same: + assert_eq!(headers["cache-control"], "no-store"); + insta::assert_yaml_snapshot!(body, @r###" + data: + topProducts: + - reviews: + - id: r1a + - id: r1b + - reviews: + - id: r2 + "###); + insta::assert_yaml_snapshot!(subgraph_request_counters, @r###" + products: 1 + reviews: 1 + "###); + let (headers, body) = make_graphql_request(&mut router).await; + assert_eq!(headers["cache-control"], "no-store"); + insta::assert_yaml_snapshot!(body, @r###" + data: + topProducts: + - reviews: + - id: r1a + - id: r1b + - reviews: + - id: r2 + "###); + // More supergraph requsets lead to more subgraph requests: + insta::assert_yaml_snapshot!(subgraph_request_counters, @r###" + products: 2 + reviews: 2 + "###); +} + +#[tokio::test] +async fn invalidate_with_endpoint() { + if !graph_os_enabled() { + return; + } + + let (mut router, subgraph_request_counters) = harness(base_config(), base_subgraphs()).await; + let (headers, body) = make_graphql_request(&mut router).await; + assert!(headers["cache-control"].contains("public")); + assert!(body.errors.is_empty()); + insta::assert_yaml_snapshot!(subgraph_request_counters, @r###" + products: 1 + reviews: 1 + "###); + + let request = http::Request::builder() + .method("POST") + .uri(INVALIDATION_PATH) + .header("Authorization", INVALIDATION_SHARED_KEY) + .body(json!([{ + "kind": "entity", + "subgraph": "reviews", + "type": "Product", + "key": { + "upc": "1", + }, + }])) + .unwrap(); + let (_headers, body) = make_json_request(&mut router, request).await; + insta::assert_yaml_snapshot!(body, @"count: 1"); + + let (headers, body) = make_graphql_request(&mut router).await; + assert!(headers["cache-control"].contains("public")); + assert!(body.errors.is_empty()); + // After invalidation, reviews need to be requested again but products are still in cache: + insta::assert_yaml_snapshot!(subgraph_request_counters, @r###" + products: 1 + reviews: 2 + "###); +} + +#[tokio::test] +async fn cache_control_merging_single_fetch() { + if !graph_os_enabled() { + return; + } + + let mut subgraphs = base_subgraphs(); + subgraphs["products"]["headers"]["cache-control"] = "public, s-maxage=120".into(); + subgraphs["reviews"]["headers"]["cache-control"] = "public, s-maxage=60".into(); + let (mut router, _subgraph_request_counters) = harness(base_config(), subgraphs).await; + let query = "{ topProducts { upc } }"; + + // Router responds with `max-age` even if a single subgraph used `s-maxage` + let (headers, _body) = + make_http_request::(&mut router, graphql_request(query).into()).await; + insta::assert_snapshot!(&headers["cache-control"], @"max-age=120,public"); + + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let query = "{ topProducts { upc } }"; + let (headers, _body) = + make_http_request::(&mut router, graphql_request(query).into()).await; + let cache_control = &headers["cache-control"]; + let max_age = parse_max_age(cache_control); + // Usually 120 - 2 = 118, but allow some slack in case CI CPUs are busy + assert!(max_age > 100 && max_age < 120, "got '{cache_control}'"); +} + +#[tokio::test] +async fn cache_control_merging_multi_fetch() { + if !graph_os_enabled() { + return; + } + + let mut subgraphs = base_subgraphs(); + subgraphs["products"]["headers"]["cache-control"] = "public, s-maxage=120".into(); + subgraphs["reviews"]["headers"]["cache-control"] = "public, s-maxage=60".into(); + let (mut router, _subgraph_request_counters) = harness(base_config(), subgraphs).await; + let query = "{ topProducts { reviews { id } } }"; + + // Router responds with `max-age` even if a subgraphs used `s-maxage`. + // The smaller value is used. + let (headers, _body) = + make_http_request::(&mut router, graphql_request(query).into()).await; + insta::assert_snapshot!(&headers["cache-control"], @"max-age=60,public"); + + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let (headers, _body) = + make_http_request::(&mut router, graphql_request(query).into()).await; + let cache_control = &headers["cache-control"]; + let max_age = parse_max_age(cache_control); + // Usually 60 - 2 = 58, but allow some slack in case CI CPUs are busy + assert!(max_age > 40 && max_age < 60, "got '{cache_control}'"); +} + +fn parse_max_age(cache_control: &str) -> u32 { + cache_control + .strip_prefix("max-age=") + .and_then(|s| s.strip_suffix(",public")) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| panic!("expected 'max-age={{seconds}},public', got '{cache_control}'")) +} + +fn subgraphs_with_many_entities(count: usize) -> serde_json::Value { + let mut reviews = vec![]; + let mut top_products = vec![]; + for upc in 1..=count { + top_products.push(json!({ "upc": upc.to_string() })); + reviews.push(json!({ + "__typename": "Product", + "upc": upc.to_string(), + "reviews": [{ "id": format!("r{upc}") }], + })); + } + + json!({ + "products": { + "headers": {"cache-control": "public"}, + "query": { "topProducts": top_products }, + }, + "reviews": { + "headers": {"cache-control": "public"}, + "entities": reviews, + }, + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_cache_metrics() { + if !graph_os_enabled() { + return; + } + + const NUM_PRODUCTS: usize = 1_000; + + // Create configuration with Redis cache and prometheus metrics + let namespace = uuid::Uuid::new_v4().simple().to_string(); + let config = json!({ + "include_subgraph_errors": { + "all": true, + }, + "preview_entity_cache": { + "enabled": true, + "subgraph": { + "all": { + "redis": { + "urls": ["redis://127.0.0.1:6379"], + "ttl": "10m", + "namespace": namespace, + "required_to_start": true, + "metrics_interval": "100ms", + }, + "invalidation": { + "enabled": true, + "shared_key": INVALIDATION_SHARED_KEY, + }, + }, + }, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": INVALIDATION_PATH, + }, + }, + "telemetry": { + "exporters": { + "metrics": { + "prometheus": { + "enabled": true, + "listen": "127.0.0.1:0", + "path": "/metrics", + }, + }, + }, + }, + "experimental_mock_subgraphs": subgraphs_with_many_entities(NUM_PRODUCTS), + }); + + let mut router = IntegrationTest::builder() + .config(serde_yaml::to_string(&config).unwrap()) + .build() + .await; + + router.start().await; + router.assert_started().await; + + // Execute the first query - this should populate the cache + let query = Query::builder() + .body(json!({"query":"{ topProducts { reviews { id } } }","variables":{}})) + .build(); + let (_trace_id, response) = router.execute_query(query).await; + assert_eq!(response.status(), 200); + + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!( + body["data"]["topProducts"] + .as_array() + .expect("topProducts should be array") + .len(), + NUM_PRODUCTS + ); + + // Execute the second query - this should use the cache + let query = Query::builder() + .body(json!({"query":"{ topProducts { reviews { id } } }","variables":{}})) + .build(); + let (_trace_id, response) = router.execute_query(query).await; + assert_eq!(response.status(), 200); + + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!( + body["data"]["topProducts"] + .as_array() + .expect("topProducts should be array") + .len(), + NUM_PRODUCTS + ); + + // Execute more queries to ensure Redis is used and metrics are generated + for _ in 0..5 { + let query = Query::builder() + .body(json!({"query":"{ topProducts { reviews { id } } }","variables":{}})) + .build(); + let (_trace_id, response) = router.execute_query(query).await; + assert_eq!(response.status(), 200); + } + + // Wait a bit to ensure metrics are collected and Redis connections are established + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + // Assert basic Redis connection metrics (these are emitted immediately when connections are established) + // We expect exactly 1 Redis connection for the entity cache + router + .assert_metrics_contains( + r#"apollo_router_cache_redis_connections{kind="entity",otel_scope_name="apollo/router"} 1"#, + None, + ) + .await; + + // Assert Redis redelivery count metric (counter) + // Should be 0 in a successful test scenario (no connection issues) + router + .assert_metrics_contains( + r#"apollo_router_cache_redis_redelivery_count_total{kind="entity",otel_scope_name="apollo/router"} 0"#, + None, + ) + .await; + + // Assert Redis commands executed metric (counter) + // We executed 7 queries (1 initial + 1 second + 5 more), each with cache operations + // Based on actual test run, we expect 17 Redis commands to be executed + router + .assert_metrics_contains( + r#"apollo_router_cache_redis_commands_executed_total{kind="entity",otel_scope_name="apollo/router"} 17"#, + None, + ) + .await; + + // Assert Redis command queue length metric (gauge) + // Should be 0 when not under load (commands processed quickly) + router + .assert_metrics_contains( + r#"apollo_router_cache_redis_command_queue_length{kind="entity",otel_scope_name="apollo/router"} 0"#, + None, + ) + .await; + + // Note: Network latency gauge (apollo_router_cache_redis_network_latency_avg) is implemented + // but may not emit in test environments where Redis network latency samples are not generated. + // This is expected behavior - the gauge only emits when actual network measurements are available. + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_cache_error_metrics() { + if !graph_os_enabled() { + return; + } + + // Create configuration with invalid Redis configuration to trigger errors + let namespace = uuid::Uuid::new_v4().simple().to_string(); + let config = json!({ + "include_subgraph_errors": { + "all": true, + }, + "preview_entity_cache": { + "enabled": true, + "subgraph": { + "all": { + "redis": { + "urls": ["redis://127.0.0.1:9999"], // Invalid port to trigger connection errors + "ttl": "10m", + "namespace": namespace, + "required_to_start": false, // Don't fail startup, allow errors during runtime + "metrics_interval": "100ms", + }, + }, + }, + }, + "telemetry": { + "exporters": { + "metrics": { + "prometheus": { + "enabled": true, + "listen": "127.0.0.1:0", + "path": "/metrics", + }, + }, + }, + }, + "experimental_mock_subgraphs": subgraphs_with_many_entities(10), + }); + + let mut router = IntegrationTest::builder() + .config(serde_yaml::to_string(&config).unwrap()) + .build() + .await; + + router.start().await; + router.assert_started().await; + + // Execute queries that will attempt Redis operations and fail + for _ in 0..3 { + let query = Query::builder() + .body(json!({"query":"{ topProducts { reviews { id } } }","variables":{}})) + .build(); + let (_trace_id, response) = router.execute_query(query).await; + // The query should still succeed (using fallback) even though Redis fails + assert_eq!(response.status(), 200); + } + + // Wait for metrics to be collected + tokio::time::sleep(std::time::Duration::from_millis(3000)).await; + + // Assert that Redis error metrics are emitted when Redis operations fail + // We expect an IO error when connecting to an invalid Redis port + router + .assert_metrics_contains( + r#"apollo_router_cache_redis_errors_total{error_type="io",kind="entity",otel_scope_name="apollo/router"} 1"#, + None, + ) + .await; + + router.graceful_shutdown().await; +} diff --git a/apollo-router/tests/integration/file_upload.rs b/apollo-router/tests/integration/file_upload.rs index add9e81a90..4dd015cd9a 100644 --- a/apollo-router/tests/integration/file_upload.rs +++ b/apollo-router/tests/integration/file_upload.rs @@ -1,14 +1,15 @@ use std::collections::BTreeMap; use bytes::Bytes; +use http::HeaderValue; use http::header::CONTENT_ENCODING; use http::header::CONTENT_LENGTH; use http::header::CONTENT_TYPE; -use http::HeaderValue; use tower::BoxError; const FILE_CONFIG: &str = include_str!("../fixtures/file_upload/default.router.yaml"); const FILE_CONFIG_LARGE_LIMITS: &str = include_str!("../fixtures/file_upload/large.router.yaml"); +const FILE_CONFIG_WITH_RHAI: &str = include_str!("../fixtures/file_upload/rhai.router.yaml"); /// Create a valid handler for the [helper::FileUploadTestServer]. macro_rules! make_handler { @@ -22,6 +23,125 @@ macro_rules! make_handler { } } +#[tokio::test(flavor = "multi_thread")] +async fn it_uploads_file_to_subgraph() -> Result<(), BoxError> { + use reqwest::multipart::Form; + use reqwest::multipart::Part; + + const FILE: &str = "Hello, world!"; + const FILE_NAME: &str = "example.txt"; + + let request = Form::new() + .part( + "operations", + Part::text( + serde_json::json!({ + "query": "mutation SomeMutation($file: Upload) { + file: singleUpload(file: $file) { filename body } + }", + "variables": { "file": null }, + }) + .to_string(), + ), + ) + .part( + "map", + Part::text(serde_json::json!({ "0": ["variables.file"] }).to_string()), + ) + .part("0", Part::text(FILE).file_name(FILE_NAME)); + + async fn subgraph_handler( + request: http::Request, + ) -> impl axum::response::IntoResponse { + let boundary = request + .headers() + .get(CONTENT_TYPE) + .and_then(|v| multer::parse_boundary(v.to_str().ok()?).ok()) + .expect("subgraph request should have valid Content-Type header"); + let mut multipart = + multer::Multipart::new(request.into_body().into_data_stream(), boundary); + + let operations_field = multipart + .next_field() + .await + .ok() + .flatten() + .expect("subgraph request should have valid `operations` field"); + assert_eq!(operations_field.name(), Some("operations")); + let operations: helper::Operation = + serde_json::from_slice(&operations_field.bytes().await.unwrap()).unwrap(); + insta::assert_json_snapshot!(operations, @r###" + { + "query": "mutation SomeMutation__uploads__0($file: Upload) { file: singleUpload(file: $file) { filename body } }", + "variables": { + "file": null + } + } + "###); + + let map_field = multipart + .next_field() + .await + .ok() + .flatten() + .expect("subgraph request should have valid `map` field"); + assert_eq!(map_field.name(), Some("map")); + let map: BTreeMap> = + serde_json::from_slice(&map_field.bytes().await.unwrap()).unwrap(); + insta::assert_json_snapshot!(map, @r#" + { + "0": [ + "variables.file" + ] + } + "#); + + let file_field = multipart + .next_field() + .await + .ok() + .flatten() + .expect("subgraph request should have file field"); + + ( + http::StatusCode::OK, + axum::Json(serde_json::json!({ + "data": { + "file": { + "filename": file_field.file_name().unwrap(), + "body": file_field.text().await.unwrap(), + }, + } + })), + ) + } + + // Run the test + helper::FileUploadTestServer::builder() + .config(FILE_CONFIG) + .handler(make_handler!(subgraph_handler)) + .request(request) + .subgraph_mapping("uploads", "/") + .build() + .run_test(|response| { + // FIXME: workaround to not update bellow snapshot if one of snapshots inside 'subgraph_handler' fails + // This would be fixed if subgraph shapshots are moved out of 'subgraph_handler' + assert_eq!(response.errors.len(), 0); + + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "file": { + "filename": "example.txt", + "body": "Hello, world!" + } + } + } + "###); + }) + .await +} + #[tokio::test(flavor = "multi_thread")] async fn it_uploads_a_single_file() -> Result<(), BoxError> { const FILE: &str = "Hello, world!"; @@ -55,6 +175,39 @@ async fn it_uploads_a_single_file() -> Result<(), BoxError> { .await } +#[tokio::test(flavor = "multi_thread")] +async fn it_uploads_a_single_file_while_adding_a_header_from_rhai_script() -> Result<(), BoxError> { + const FILE: &str = "Hello, world!"; + const FILE_NAME: &str = "example.txt"; + + // Construct the parts of the multipart request as defined by the schema + let request = helper::create_request( + vec![FILE_NAME], + vec![tokio_stream::once(Ok(Bytes::from_static(FILE.as_bytes())))], + ); + + // Run the test + helper::FileUploadTestServer::builder() + .config(FILE_CONFIG_WITH_RHAI) + .handler(make_handler!(helper::echo_single_file)) + .request(request) + .subgraph_mapping("uploads", "/") + .build() + .run_test(|response| { + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "file0": { + "filename": "example.txt", + "body": "Hello, world!" + } + } + } + "###); + }) + .await +} + #[tokio::test(flavor = "multi_thread")] async fn it_uploads_multiple_files() -> Result<(), BoxError> { let files = BTreeMap::from([ @@ -131,7 +284,7 @@ async fn it_uploads_a_massive_file() -> Result<(), BoxError> { // Upload a stream of 10GB const ONE_MB: usize = 1024 * 1024; const TEN_GB: usize = 10 * 1024 * ONE_MB; - const FILE_CHUNK: [u8; ONE_MB] = [0xAA; ONE_MB]; + static FILE_CHUNK: [u8; ONE_MB] = [0xAA; ONE_MB]; const CHUNK_COUNT: usize = TEN_GB / ONE_MB; // Upload a file that is 1GB in size of 0xAA @@ -565,21 +718,21 @@ async fn it_fails_upload_without_file() -> Result<(), BoxError> { .subgraph_mapping("uploads", "/") .build() .run_test(|response| { - insta::assert_json_snapshot!(response, @r###" + insta::assert_json_snapshot!(response, @r#" { "errors": [ { - "message": "HTTP fetch failed from 'uploads': HTTP fetch failed from 'uploads': error from user's HttpBody stream: error reading a body from connection: Missing files in the request: '0'.", + "message": "HTTP fetch failed from 'uploads': HTTP fetch failed from 'uploads': error from user's Body stream: Missing files in the request: '0'.", "path": [], "extensions": { "code": "SUBREQUEST_HTTP_ERROR", "service": "uploads", - "reason": "HTTP fetch failed from 'uploads': error from user's HttpBody stream: error reading a body from connection: Missing files in the request: '0'." + "reason": "HTTP fetch failed from 'uploads': error from user's Body stream: Missing files in the request: '0'." } } ] } - "###); + "#); }) .await } @@ -625,7 +778,7 @@ async fn it_fails_with_file_count_limits() -> Result<(), BoxError> { async fn it_fails_with_file_size_limit() -> Result<(), BoxError> { // Create a file that passes the limit set in the config (512KB) const ONE_MB: usize = 1024 * 1024; - const FILE_CHUNK: [u8; ONE_MB] = [0xAA; ONE_MB]; + static FILE_CHUNK: [u8; ONE_MB] = [0xAA; ONE_MB]; // Construct the parts of the multipart request as defined by the schema for multiple files let request = helper::create_request( @@ -643,21 +796,21 @@ async fn it_fails_with_file_size_limit() -> Result<(), BoxError> { .subgraph_mapping("uploads", "/") .build() .run_test(|response| { - insta::assert_json_snapshot!(response, @r###" + insta::assert_json_snapshot!(response, @r#" { "errors": [ { - "message": "HTTP fetch failed from 'uploads': HTTP fetch failed from 'uploads': error from user's HttpBody stream: error reading a body from connection: Exceeded the limit of 512.0 KB on 'fat.payload.bin' file.", + "message": "HTTP fetch failed from 'uploads': HTTP fetch failed from 'uploads': error from user's Body stream: Exceeded the limit of 512.0 KB on 'fat.payload.bin' file.", "path": [], "extensions": { "code": "SUBREQUEST_HTTP_ERROR", "service": "uploads", - "reason": "HTTP fetch failed from 'uploads': error from user's HttpBody stream: error reading a body from connection: Exceeded the limit of 512.0 KB on 'fat.payload.bin' file." + "reason": "HTTP fetch failed from 'uploads': error from user's Body stream: Exceeded the limit of 512.0 KB on 'fat.payload.bin' file." } } ] } - "###); + "#); }) .await } @@ -757,7 +910,7 @@ async fn it_fails_invalid_file_order() -> Result<(), BoxError> { .subgraph_mapping("uploads_clone", "/s2") .build() .run_test(|response| { - insta::assert_json_snapshot!(response, @r###" + insta::assert_json_snapshot!(response, @r#" { "data": { "file0": { @@ -768,17 +921,17 @@ async fn it_fails_invalid_file_order() -> Result<(), BoxError> { }, "errors": [ { - "message": "HTTP fetch failed from 'uploads_clone': HTTP fetch failed from 'uploads_clone': error from user's HttpBody stream: error reading a body from connection: Missing files in the request: '1'.", + "message": "HTTP fetch failed from 'uploads_clone': HTTP fetch failed from 'uploads_clone': error from user's Body stream: Missing files in the request: '1'.", "path": [], "extensions": { "code": "SUBREQUEST_HTTP_ERROR", "service": "uploads_clone", - "reason": "HTTP fetch failed from 'uploads_clone': error from user's HttpBody stream: error reading a body from connection: Missing files in the request: '1'." + "reason": "HTTP fetch failed from 'uploads_clone': error from user's Body stream: Missing files in the request: '1'." } } ] } - "###); + "#); }) .await } @@ -900,31 +1053,32 @@ mod helper { use std::net::SocketAddr; use std::path::PathBuf; - use axum::extract::State; - use axum::response::IntoResponse; use axum::BoxError; use axum::Json; use axum::Router; + use axum::body::Body; + use axum::extract::State; + use axum::response::IntoResponse; use buildstructor::buildstructor; use futures::StreamExt; - use http::header::CONTENT_TYPE; use http::Request; use http::StatusCode; - use hyper::Body; + use http::header::CONTENT_TYPE; use itertools::Itertools; use multer::Multipart; use reqwest::multipart::Form; use reqwest::multipart::Part; - use serde::de::DeserializeOwned; use serde::Deserialize; use serde::Serialize; - use serde_json::json; + use serde::de::DeserializeOwned; use serde_json::Value; + use serde_json::json; use thiserror::Error; use tokio::net::TcpListener; use tokio_stream::Stream; - use super::super::common::IntegrationTest; + use crate::integration::IntegrationTest; + use crate::integration::common::graph_os_enabled; /// A helper server for testing multipart uploads. /// @@ -976,9 +1130,7 @@ mod helper { // Ensure that we have the test keys before running // Note: The [IntegrationTest] ensures that these test credentials get // set before running the router. - if std::env::var("TEST_APOLLO_KEY").is_err() - || std::env::var("TEST_APOLLO_GRAPH_REF").is_err() - { + if !graph_os_enabled() { return Ok(()); }; @@ -1013,15 +1165,13 @@ mod helper { let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); // Start the server using the tcp listener randomly assigned above - let server = axum::Server::from_tcp(bound.into_std().unwrap()) - .unwrap() - .serve(self.handler.into_make_service()) + let server = axum::serve(bound, self.handler.into_make_service()) .with_graceful_shutdown(async { shutdown_rx.await.ok(); }); // Spawn the server in the background, controlled by the shutdown signal - tokio::spawn(server); + tokio::spawn(async { server.await.unwrap() }); // Make the request and pass it into the validator callback let (_span, response) = router @@ -1043,7 +1193,7 @@ mod helper { pub body: Option, } - #[derive(Serialize, Deserialize)] + #[derive(Debug, Serialize, Deserialize)] pub struct Operation { // TODO: Can we verify that this is a valid graphql query? query: String, @@ -1132,11 +1282,13 @@ mod helper { // The mappings match the field names of the multipart stream to the graphql variables of the query let mappings = Part::text( - serde_json::json!(names - .iter() - .enumerate() - .map(|(index, _)| (index.to_string(), vec![format!("variables.file{index}")])) - .collect::>>()) + serde_json::json!( + names + .iter() + .enumerate() + .map(|(index, _)| (index.to_string(), vec![format!("variables.file{index}")])) + .collect::>>() + ) .to_string(), ) .file_name("mappings.json"); @@ -1147,7 +1299,8 @@ mod helper { .part("map", mappings); for (index, (file_name, file)) in names.into_iter().zip(files).enumerate() { let file_name: String = file_name.into(); - let part = Part::stream(hyper::Body::wrap_stream(file)).file_name(file_name); + + let part = Part::stream(reqwest::Body::wrap_stream(file)).file_name(file_name); request = request.part(index.to_string(), part); } @@ -1158,20 +1311,16 @@ mod helper { /// Handler that echos back the contents of the file that it receives /// /// Note: This will error if more than one file is received - pub async fn echo_single_file( - mut request: Request, - ) -> Result, FileUploadError> { - let (_, map, mut multipart) = decode_request(&mut request).await?; + pub async fn echo_single_file(request: Request) -> Result, FileUploadError> { + let (_, map, mut multipart) = decode_request(request).await?; // Assert that we only have 1 file if map.len() > 1 { return Err(FileUploadError::UnexpectedFile); } - let field_name: String = map - .into_keys() - .take(1) - .next() + let (field_name, _) = map + .first_key_value() .ok_or(FileUploadError::MissingMapping)?; // Extract the single expected file @@ -1181,7 +1330,7 @@ mod helper { .await? .ok_or(FileUploadError::MissingFile(field_name.clone()))?; - let file_name = f.file_name().unwrap_or(&field_name).to_string(); + let file_name = f.file_name().unwrap_or(field_name).to_string(); let body = f.bytes().await?; Upload { @@ -1199,8 +1348,8 @@ mod helper { } /// Handler that echos back the contents of the files that it receives - pub async fn echo_files(mut request: Request) -> Result, FileUploadError> { - let (operation, map, mut multipart) = decode_request(&mut request).await?; + pub async fn echo_files(request: Request) -> Result, FileUploadError> { + let (operation, map, mut multipart) = decode_request(request).await?; // Make sure that we have some mappings if map.is_empty() { @@ -1253,10 +1402,8 @@ mod helper { } /// Handler that echos back the contents of the list of files that it receives - pub async fn echo_file_list( - mut request: Request, - ) -> Result, FileUploadError> { - let (operation, map, mut multipart) = decode_request(&mut request).await?; + pub async fn echo_file_list(request: Request) -> Result, FileUploadError> { + let (operation, map, mut multipart) = decode_request(request).await?; // Make sure that we have some mappings if map.is_empty() { @@ -1316,9 +1463,10 @@ mod helper { } /// A handler that always fails. Useful for tests that should not reach the subgraph at all. - pub async fn always_fail(mut request: Request) -> Result, FileUploadError> { + pub async fn always_fail(request: Request) -> Result, FileUploadError> { // Consume the stream - while request.body_mut().next().await.is_some() {} + let mut body = request.into_body().into_data_stream(); + while body.next().await.is_some() {} // Signal a failure Err(FileUploadError::ShouldHaveFailed) @@ -1329,9 +1477,9 @@ mod helper { /// Note: Make sure to use a router with state (Expected stream length, expected value). pub async fn verify_stream( State((expected_length, byte_value)): State<(usize, u8)>, - mut request: Request, + request: Request, ) -> Result, FileUploadError> { - let (_, _, mut multipart) = decode_request(&mut request).await?; + let (_, _, mut multipart) = decode_request(request).await?; let mut file = multipart .next_field() @@ -1408,8 +1556,9 @@ mod helper { /// Note: The order of the mapping must correspond with the order in the request, so /// we use a [BTreeMap] here to keep the order when traversing the list of files. async fn decode_request( - request: &mut Request, - ) -> Result<(Operation, BTreeMap>, Multipart), FileUploadError> { + request: Request, + ) -> Result<(Operation, BTreeMap>, Multipart<'static>), FileUploadError> + { let content_type = request .headers() .get(CONTENT_TYPE) @@ -1419,7 +1568,7 @@ mod helper { FileUploadError::BadHeaders(format!("could not parse multipart boundary: {e}")) })?)?; - let mut multipart = Multipart::new(request.body_mut(), boundary); + let mut multipart = Multipart::new(request.into_body().into_data_stream(), boundary); // Extract the operations // TODO: Should we be streaming here? diff --git a/apollo-router/tests/integration/fixtures/authenticated_directive.graphql b/apollo-router/tests/integration/fixtures/authenticated_directive.graphql new file mode 100644 index 0000000000..378b5f0572 --- /dev/null +++ b/apollo-router/tests/integration/fixtures/authenticated_directive.graphql @@ -0,0 +1,76 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + @link(url: "https://specs.apollo.dev/authenticated/v0.1", for: SECURITY) +@link(url: "https://specs.apollo.dev/requiresScopes/v0.1", for: SECURITY) +{ + query: Query +} + +directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +scalar federation__Scope +directive @requiresScopes(scopes: [[federation__Scope!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar join__FieldSet + +enum join__Graph { + PRODUCTS @join__graph(name: "products", url: "http://localhost:4001/") + REVIEWS @join__graph(name: "reviews", url: "http://localhost:4002/") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Product + @join__type(graph: PRODUCTS, key: "upc") + @join__type(graph: REVIEWS, key: "upc") + @authenticated +{ + upc: String! + name: String! @join__field(graph: PRODUCTS) + price: String! @join__field(graph: PRODUCTS) @authenticated + related: Product! @join__field(graph: PRODUCTS) + price1: String @join__field(graph: PRODUCTS) + review: Review! @join__field(graph: REVIEWS) @requiresScopes(scopes: [["review"]]) +} + +type Query + @join__type(graph: PRODUCTS) + @join__type(graph: REVIEWS) +{ + products(limit: Int!, size: Int, random: String): [Product!]! @join__field(graph: PRODUCTS) +} + +type Review + @join__type(graph: REVIEWS) +{ + body: String! + product: Product! + body1: String +} diff --git a/apollo-router/tests/integration/fixtures/broken_plugin.router.yaml b/apollo-router/tests/integration/fixtures/broken_plugin.router.yaml index f8f8d372a8..1bbc25b7a3 100644 --- a/apollo-router/tests/integration/fixtures/broken_plugin.router.yaml +++ b/apollo-router/tests/integration/fixtures/broken_plugin.router.yaml @@ -13,22 +13,11 @@ telemetry: jaeger: true common: service_name: router - jaeger: + otlp: enabled: true batch_processor: scheduled_delay: 100ms - agent: - endpoint: default - logging: - experimental_when_header: - - name: apollo-router-log-request - value: test - headers: true # default: false - body: true # default: false - # log request for all requests coming from Iphones - - name: custom-header - match: ^foo.* - headers: true + endpoint: default override_subgraph_url: products: http://localhost:4005 include_subgraph_errors: diff --git a/apollo-router/tests/integration/fixtures/connectors_sigv4.graphql b/apollo-router/tests/integration/fixtures/connectors_sigv4.graphql new file mode 100644 index 0000000000..6b258d6fba --- /dev/null +++ b/apollo-router/tests/integration/fixtures/connectors_sigv4.graphql @@ -0,0 +1,116 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.2", for: EXECUTION) + @join__directive( + graphs: [SUBGRAPH] + name: "link" + args: { + url: "https://specs.apollo.dev/connect/v0.2" + import: ["@connect", "@source"] + } + ) + @join__directive( + graphs: [SUBGRAPH] + name: "source" + args: { + name: "ec2" + http: { + baseURL: "https://ec2.eu-north-1.api.aws" + headers: [{ name: "Accept", value: "application/json" }] + } + } + ) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String + contextArguments: [join__ContextArgument!] +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH @join__graph(name: "subgraph", url: "http://ignore") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query @join__type(graph: SUBGRAPH) { + instances: String + @join__directive( + graphs: [SUBGRAPH] + name: "connect" + args: { + source: "ec2" + http: { GET: "?Action=DescribeInstances&DryRun=1" } + selection: "$" + } + ) +} diff --git a/apollo-router/tests/integration/fixtures/connectors_sigv4.router.yaml b/apollo-router/tests/integration/fixtures/connectors_sigv4.router.yaml new file mode 100644 index 0000000000..c8a998f37c --- /dev/null +++ b/apollo-router/tests/integration/fixtures/connectors_sigv4.router.yaml @@ -0,0 +1,12 @@ +authentication: + connector: + sources: + subgraph.ec2: + aws_sig_v4: + default_chain: + profile_name: "default" + region: "eu-north-1" + service_name: "ec2" + assume_role: + role_arn: ${env.AWS_ROLE_ARN} + session_name: "connector" \ No newline at end of file diff --git a/apollo-router/tests/integration/fixtures/coprocessor.router.yaml b/apollo-router/tests/integration/fixtures/coprocessor.router.yaml new file mode 100644 index 0000000000..a408b3a203 --- /dev/null +++ b/apollo-router/tests/integration/fixtures/coprocessor.router.yaml @@ -0,0 +1,24 @@ +# This coprocessor doesn't point to anything +coprocessor: + url: "" + router: + request: + body: true + response: + body: true + supergraph: + request: + body: true + response: + body: true + subgraph: + all: + request: + body: true + response: + body: true + execution: + request: + body: true + response: + body: true \ No newline at end of file diff --git a/apollo-router/tests/integration/fixtures/coprocessor_conditional.router.yaml b/apollo-router/tests/integration/fixtures/coprocessor_conditional.router.yaml new file mode 100644 index 0000000000..a3499bc339 --- /dev/null +++ b/apollo-router/tests/integration/fixtures/coprocessor_conditional.router.yaml @@ -0,0 +1,16 @@ +coprocessor: + url: "" + router: + response: + condition: + eq: + - on_graphql_error: true + - true + supergraph: + response: + condition: + eq: + - on_graphql_error: true + - true +include_subgraph_errors: + all: true # Propagate errors (message + extensions) from all subgraphs diff --git a/apollo-router/tests/integration/fixtures/coprocessor_demand_control.router.yaml b/apollo-router/tests/integration/fixtures/coprocessor_demand_control.router.yaml index 6535dd92bf..e03e5dd85a 100644 --- a/apollo-router/tests/integration/fixtures/coprocessor_demand_control.router.yaml +++ b/apollo-router/tests/integration/fixtures/coprocessor_demand_control.router.yaml @@ -3,10 +3,10 @@ coprocessor: url: "" execution: request: - context: true + context: all supergraph: response: - context: true + context: all demand_control: enabled: true @@ -14,4 +14,4 @@ demand_control: strategy: static_estimated: list_size: 10 - max: 1000 \ No newline at end of file + max: 1000 diff --git a/apollo-router/tests/integration/fixtures/happy.router.yaml b/apollo-router/tests/integration/fixtures/happy.router.yaml index 0f7c367fe9..d070a55309 100644 --- a/apollo-router/tests/integration/fixtures/happy.router.yaml +++ b/apollo-router/tests/integration/fixtures/happy.router.yaml @@ -13,22 +13,11 @@ telemetry: jaeger: true common: service_name: router - jaeger: + otlp: enabled: true batch_processor: scheduled_delay: 100ms - agent: - endpoint: default - logging: - experimental_when_header: - - name: apollo-router-log-request - value: test - headers: true # default: false - body: true # default: false - # log request for all requests coming from Iphones - - name: custom-header - match: ^foo.* - headers: true + endpoint: default override_subgraph_url: products: http://localhost:4005 include_subgraph_errors: diff --git a/apollo-router/tests/integration/fixtures/prometheus.router.yaml b/apollo-router/tests/integration/fixtures/prometheus.router.yaml new file mode 100644 index 0000000000..ed133f94fa --- /dev/null +++ b/apollo-router/tests/integration/fixtures/prometheus.router.yaml @@ -0,0 +1,8 @@ +telemetry: + exporters: + metrics: + prometheus: + listen: 127.0.0.1:4000 + enabled: true + path: /metrics + diff --git a/apollo-router/tests/integration/fixtures/prometheus_updated.router.yaml b/apollo-router/tests/integration/fixtures/prometheus_updated.router.yaml new file mode 100644 index 0000000000..8a8fffe13e --- /dev/null +++ b/apollo-router/tests/integration/fixtures/prometheus_updated.router.yaml @@ -0,0 +1,15 @@ +telemetry: + exporters: + metrics: + prometheus: + listen: 127.0.0.1:4000 + enabled: true + path: /metrics + + +headers: + all: + request: + - insert: + name: test + value: test \ No newline at end of file diff --git a/apollo-router/tests/integration/fixtures/query_planner_max_evaluated_plans.graphql b/apollo-router/tests/integration/fixtures/query_planner_max_evaluated_plans.graphql new file mode 100644 index 0000000000..a1f388ed61 --- /dev/null +++ b/apollo-router/tests/integration/fixtures/query_planner_max_evaluated_plans.graphql @@ -0,0 +1,73 @@ +# Composed from subgraphs with hash: a9236eee956ed7fc219b2212696478159ced7eea +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + t: T +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + v1: Int + v2: Int + v3: Int + v4: Int +} diff --git a/apollo-router/tests/integration/fixtures/query_planner_redis_config_update_query_planner_mode.router.yaml b/apollo-router/tests/integration/fixtures/query_planner_redis_config_update_query_planner_mode.router.yaml deleted file mode 100644 index 195306ed62..0000000000 --- a/apollo-router/tests/integration/fixtures/query_planner_redis_config_update_query_planner_mode.router.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# This config updates the query plan options so that we can see if there is a different redis cache entry generted for query plans -supergraph: - query_planning: - cache: - redis: - required_to_start: true - urls: - - redis://localhost:6379 - ttl: 10s - -experimental_query_planner_mode: new \ No newline at end of file diff --git a/apollo-router/tests/integration/fixtures/query_planner_redis_config_update_reuse_query_fragments.router.yaml b/apollo-router/tests/integration/fixtures/query_planner_redis_config_update_reuse_query_fragments.router.yaml deleted file mode 100644 index 14136a0268..0000000000 --- a/apollo-router/tests/integration/fixtures/query_planner_redis_config_update_reuse_query_fragments.router.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# This config updates the query plan options so that we can see if there is a different redis cache entry generted for query plans -supergraph: - query_planning: - cache: - redis: - required_to_start: true - urls: - - redis://localhost:6379 - ttl: 10s - experimental_reuse_query_fragments: true - diff --git a/apollo-router/tests/integration/fixtures/redis_connection_closure.router.yaml b/apollo-router/tests/integration/fixtures/redis_connection_closure.router.yaml new file mode 100644 index 0000000000..6e7449ae65 --- /dev/null +++ b/apollo-router/tests/integration/fixtures/redis_connection_closure.router.yaml @@ -0,0 +1,16 @@ +supergraph: + query_planning: + cache: + redis: + required_to_start: true + urls: + - redis://localhost:6379 + ttl: 1s + pool_size: 4 +telemetry: + exporters: + metrics: + prometheus: + listen: 127.0.0.1:4000 + enabled: true + path: /metrics diff --git a/apollo-router/tests/integration/fixtures/rhai_logging.router.yaml b/apollo-router/tests/integration/fixtures/rhai_logging.router.yaml new file mode 100644 index 0000000000..445e798d35 --- /dev/null +++ b/apollo-router/tests/integration/fixtures/rhai_logging.router.yaml @@ -0,0 +1,3 @@ +rhai: + scripts: "tests/fixtures" + main: "test_callbacks.rhai" diff --git a/apollo-router/tests/integration/fixtures/rhai_reload.router.yaml b/apollo-router/tests/integration/fixtures/rhai_reload.router.yaml new file mode 100644 index 0000000000..427fe28922 --- /dev/null +++ b/apollo-router/tests/integration/fixtures/rhai_reload.router.yaml @@ -0,0 +1,4 @@ +# Note: This file is not checked into the repository but is created/removed during test execution so that we can test rhai script reloading +rhai: + scripts: "tests/fixtures" + main: "test_reload.rhai" diff --git a/apollo-router/tests/integration/fixtures/small_connection_shutdown_timeout.router.yaml b/apollo-router/tests/integration/fixtures/small_connection_shutdown_timeout.router.yaml new file mode 100644 index 0000000000..2d9d811022 --- /dev/null +++ b/apollo-router/tests/integration/fixtures/small_connection_shutdown_timeout.router.yaml @@ -0,0 +1,10 @@ +telemetry: + exporters: + metrics: + prometheus: + listen: 127.0.0.1:4000 + enabled: true + path: /metrics +supergraph: + early_cancel: true + connection_shutdown_timeout: 1ms \ No newline at end of file diff --git a/apollo-router/tests/integration/fixtures/small_connection_shutdown_timeout_updated.router.yaml b/apollo-router/tests/integration/fixtures/small_connection_shutdown_timeout_updated.router.yaml new file mode 100644 index 0000000000..e7851959b4 --- /dev/null +++ b/apollo-router/tests/integration/fixtures/small_connection_shutdown_timeout_updated.router.yaml @@ -0,0 +1,10 @@ +telemetry: + exporters: + metrics: + prometheus: + listen: 127.0.0.1:4000 + enabled: true + path: /metrics +supergraph: + early_cancel: true + connection_shutdown_timeout: 2ms \ No newline at end of file diff --git a/apollo-router/tests/integration/introspection.rs b/apollo-router/tests/integration/introspection.rs index 95c8ad9c8c..76c8b2e2fc 100644 --- a/apollo-router/tests/integration/introspection.rs +++ b/apollo-router/tests/integration/introspection.rs @@ -4,6 +4,7 @@ use serde_json::json; use tower::ServiceExt; use crate::integration::IntegrationTest; +use crate::integration::common::Query; #[tokio::test] async fn simple() { @@ -107,13 +108,13 @@ async fn two_operations() { } #[tokio::test] -async fn operation_name_error() { +async fn mixed() { let request = Request::fake_builder() .query( - r#" - query ThisOp { me { id } } - query OtherOp { me { id } } - "#, + r#"{ + __schema { queryType { name } } + me { id } + }"#, ) .build() .unwrap(); @@ -122,44 +123,100 @@ async fn operation_name_error() { { "errors": [ { - "message": "Must provide operation name if query contains multiple operations.", + "message": "Mixed queries with both schema introspection and concrete fields are not supported yet: https://github.com/apollographql/router/issues/2789", "extensions": { - "code": "GRAPHQL_VALIDATION_FAILED" + "code": "MIXED_INTROSPECTION" } } ] } "###); +} +const QUERY_DEPTH_2: &str = r#"{ + __type(name: "Query") { + fields { + type { + fields { + type { + kind + } + } + } + } + } +}"#; + +const QUERY_DEPTH_3: &str = r#"{ + __type(name: "Query") { + fields { + type { + fields { + type { + fields { + name + } + } + } + } + } + } +}"#; + +#[tokio::test] +async fn just_under_max_depth() { let request = Request::fake_builder() - .query("query ThisOp { me { id } }") - .operation_name("NonExistentOp") + .query(QUERY_DEPTH_2) .build() .unwrap(); let response = make_request(request).await; insta::assert_json_snapshot!(response, @r###" { - "errors": [ - { - "message": "Unknown operation named \"NonExistentOp\"", - "extensions": { - "code": "GRAPHQL_VALIDATION_FAILED" - } + "data": { + "__type": { + "fields": [ + { + "type": { + "fields": [ + { + "type": { + "kind": "NON_NULL" + } + }, + { + "type": { + "kind": "SCALAR" + } + }, + { + "type": { + "kind": "SCALAR" + } + }, + { + "type": { + "kind": "LIST" + } + } + ] + } + }, + { + "type": { + "fields": null + } + } + ] } - ] + } } "###); } #[tokio::test] -async fn mixed() { +async fn just_over_max_depth() { let request = Request::fake_builder() - .query( - r#"{ - __schema { queryType { name } } - me { id } - }"#, - ) + .query(QUERY_DEPTH_3) .build() .unwrap(); let response = make_request(request).await; @@ -167,26 +224,94 @@ async fn mixed() { { "errors": [ { - "message": "Mixed queries with both schema introspection and concrete fields are not supported yet: https://github.com/apollographql/router/issues/2789", - "extensions": { - "code": "MIXED_INTROSPECTION" - } + "message": "Maximum introspection depth exceeded", + "locations": [ + { + "line": 7, + "column": 13 + } + ] } ] } "###); } +#[tokio::test] +async fn just_over_max_depth_with_check_disabled() { + let request = Request::fake_builder() + .query(QUERY_DEPTH_3) + .build() + .unwrap(); + let response = make_request_with_extra_config(request, |conf| { + conf.as_object_mut().unwrap().insert( + "limits".to_owned(), + json!({"introspection_max_depth": false}), + ); + }) + .await; + insta::assert_json_snapshot!(response, @r###" + { + "data": { + "__type": { + "fields": [ + { + "type": { + "fields": [ + { + "type": { + "fields": null + } + }, + { + "type": { + "fields": null + } + }, + { + "type": { + "fields": null + } + }, + { + "type": { + "fields": null + } + } + ] + } + }, + { + "type": { + "fields": null + } + } + ] + } + } + } + "###); +} + async fn make_request(request: Request) -> apollo_router::graphql::Response { + make_request_with_extra_config(request, |_| {}).await +} + +async fn make_request_with_extra_config( + request: Request, + modify_config: impl FnOnce(&mut serde_json::Value), +) -> apollo_router::graphql::Response { + let mut conf = json!({ + "supergraph": { + "introspection": true, + }, + "include_subgraph_errors": { + "all": true, + }, + }); + modify_config(&mut conf); apollo_router::TestHarness::builder() - .configuration_json(json!({ - "supergraph": { - "introspection": true, - }, - "include_subgraph_errors": { - "all": true, - }, - })) + .configuration_json(conf) .unwrap() .subgraph_hook(|subgraph_name, default| match subgraph_name { "accounts" => MockSubgraph::builder() @@ -226,7 +351,9 @@ async fn integration() { let query = json!({ "query": include_str!("../fixtures/introspect_full_schema.graphql"), }); - let (_trace_id, response) = router.execute_query(&query).await; + let (_trace_id, response) = router + .execute_query(Query::builder().body(query).build()) + .await; insta::assert_json_snapshot!(response.json::().await.unwrap()); router.graceful_shutdown().await; } diff --git a/apollo-router/tests/integration/lifecycle.rs b/apollo-router/tests/integration/lifecycle.rs index 71af2dbcf8..4fd050cec1 100644 --- a/apollo-router/tests/integration/lifecycle.rs +++ b/apollo-router/tests/integration/lifecycle.rs @@ -1,16 +1,18 @@ use std::path::Path; use std::time::Duration; +use apollo_router::Context; +use apollo_router::TestHarness; use apollo_router::graphql; use apollo_router::plugin::Plugin; use apollo_router::plugin::PluginInit; use apollo_router::register_plugin; use apollo_router::services::router; use apollo_router::services::supergraph; -use apollo_router::Context; -use apollo_router::TestHarness; use async_trait::async_trait; +use axum::handler::HandlerWithoutStateExt; use futures::FutureExt; +use regex::Regex; use schemars::JsonSchema; use serde::Deserialize; use serde_json::json; @@ -22,6 +24,7 @@ use tower::ServiceExt; use wiremock::ResponseTemplate; use crate::integration::IntegrationTest; +use crate::integration::common::graph_os_enabled; const HAPPY_CONFIG: &str = include_str!("fixtures/happy.router.yaml"); const BROKEN_PLUGIN_CONFIG: &str = include_str!("fixtures/broken_plugin.router.yaml"); @@ -136,18 +139,35 @@ async fn test_graceful_shutdown() -> Result<(), BoxError> { } #[tokio::test(flavor = "multi_thread")] -async fn test_force_reload() -> Result<(), BoxError> { +async fn test_force_config_reload_via_chaos() -> Result<(), BoxError> { let mut router = IntegrationTest::builder() .config( "experimental_chaos: - force_reload: 1s", + force_config_reload: 1s", ) .build() .await; router.start().await; router.assert_started().await; tokio::time::sleep(Duration::from_secs(2)).await; - router.assert_no_reload_necessary().await; + router.assert_reloaded().await; + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_force_schema_reload_via_chaos() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config( + "experimental_chaos: + force_schema_reload: 1s", + ) + .build() + .await; + router.start().await; + router.assert_started().await; + tokio::time::sleep(Duration::from_secs(2)).await; + router.assert_reloaded().await; router.graceful_shutdown().await; Ok(()) } @@ -231,12 +251,10 @@ async fn test_experimental_notice() { .config( " telemetry: - logging: - experimental_when_header: - - name: apollo-router-log-request - value: test - headers: true - body: true + exporters: + tracing: + experimental_response_trace_id: + enabled: true ", ) .build() @@ -244,7 +262,9 @@ async fn test_experimental_notice() { router.start().await; router.assert_started().await; router - .assert_log_contains("You're using some \\\"experimental\\\" features of the Apollo Router") + .wait_for_log_message( + "You're using some \\\"experimental\\\" features of the Apollo Router", + ) .await; router.graceful_shutdown().await; } @@ -254,11 +274,7 @@ const TEST_PLUGIN_ORDERING_CONTEXT_KEY: &str = "ordering-trace"; /// #[tokio::test(flavor = "multi_thread")] async fn test_plugin_ordering() { - async fn coprocessor( - request: http::Request, - ) -> Result, BoxError> { - let body = hyper::body::to_bytes(request.into_body()).await?; - let mut json: serde_json::Value = serde_json::from_slice(&body)?; + async fn coprocessor(mut json: axum::Json) -> axum::Json { let stage = json["stage"].as_str().unwrap().to_owned(); json["context"]["entries"] .as_object_mut() @@ -268,26 +284,21 @@ async fn test_plugin_ordering() { .as_array_mut() .unwrap() .push(format!("coprocessor {stage}").into()); - Ok(http::Response::new(hyper::Body::from( - serde_json::to_string(&json)?, - ))) + json } async fn spawn_coprocessor() -> (String, ShutdownOnDrop) { let (tx, rx) = tokio::sync::oneshot::channel::<()>(); let shutdown_on_drop = ShutdownOnDrop(Some(tx)); - let service = hyper::service::make_service_fn(|_| async { - Ok::<_, hyper::Error>(hyper::service::service_fn(coprocessor)) - }); - // Bind to "port 0" to let the kernel choose an available port number. - let server = hyper::Server::bind(&([127, 0, 0, 1], 0).into()).serve(service); - let coprocessor_url = format!("http://{}", server.local_addr()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let coprocessor_url = format!("http://{}", listener.local_addr().unwrap()); + let server = axum::serve(listener, coprocessor.into_make_service()); let server = server.with_graceful_shutdown(async { let _ = rx.await; }); tokio::spawn(async move { if let Err(e) = server.await { - eprintln!("coprocessor server error: {}", e); + eprintln!("coprocessor server error: {e}"); } }); (coprocessor_url, shutdown_on_drop) @@ -309,30 +320,30 @@ async fn test_plugin_ordering() { .join("tests") .join("fixtures") .join("test_plugin_ordering.rhai"); - let mut service = TestHarness::builder() - .configuration_json(json!({ - "plugins": { - "experimental.test_ordering_1": {}, - "experimental.test_ordering_2": {}, - "experimental.test_ordering_3": {}, - }, - "rhai": { - "main": rhai_main, - }, - "coprocessor": { - "url": coprocessor_url, - "router": { - "request": { "context": true }, - "response": { "context": true }, - } - }, - })) - .unwrap() - .build_router() - .await - .unwrap(); // Repeat to get more confidence it’s deterministic for _ in 0..10 { + let mut service = TestHarness::builder() + .configuration_json(json!({ + "plugins": { + "experimental.test_ordering_1": {}, + "experimental.test_ordering_2": {}, + "experimental.test_ordering_3": {}, + }, + "rhai": { + "main": rhai_main, + }, + "coprocessor": { + "url": coprocessor_url, + "router": { + "request": { "context": true }, + "response": { "context": true }, + } + }, + })) + .unwrap() + .build_router() + .await + .unwrap(); let request = supergraph::Request::canned_builder().build().unwrap(); let mut response = service .ready() @@ -460,3 +471,103 @@ fn test_plugin_ordering_push_trace(context: &Context, entry: String) { ) .unwrap(); } + +#[tokio::test(flavor = "multi_thread")] +async fn test_multi_pipelines() { + if !graph_os_enabled() { + eprintln!("test skipped"); + return; + } + let mut router = IntegrationTest::builder() + .config(include_str!("fixtures/prometheus.router.yaml")) + .responder(ResponseTemplate::new(500).set_delay(Duration::from_secs(10))) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let query = router.execute_default_query(); + // Long running request 1 + let _h1 = tokio::task::spawn(query); + router + .update_config(include_str!("fixtures/prometheus_updated.router.yaml")) + .await; + + router.assert_reloaded().await; + // Long running request 2 + let query = router.execute_default_query(); + let _h2 = tokio::task::spawn(query); + let metrics = router + .get_metrics_response() + .await + .expect("metrics") + .text() + .await + .expect("metrics"); + + // There should be two instances of the pipeline metrics + let pipelines = Regex::new(r#"(?m)^apollo_router_pipelines[{].+[}] 1"#).expect("regex"); + assert_eq!(pipelines.captures_iter(&metrics).count(), 2); + + // There should be at least two connections, one active and one terminating. + // There may be more than one in each category because reqwest does connection pooling. + let terminating = + Regex::new(r#"(?m)^apollo_router_open_connections[{].+terminating.+[}]"#).expect("regex"); + assert_eq!(terminating.captures_iter(&metrics).count(), 1); + let active = + Regex::new(r#"(?m)^apollo_router_open_connections[{].+active.+[}]"#).expect("regex"); + assert_eq!(active.captures_iter(&metrics).count(), 1); +} + +/// This test ensures that the router will not leave pipelines hanging around +/// It has early cancel set to true in the config so that when we look at the pipelines after connection +/// termination they are removed. +#[tokio::test(flavor = "multi_thread")] +async fn test_forced_connection_shutdown() { + if !graph_os_enabled() { + eprintln!("test skipped"); + return; + } + let mut router = IntegrationTest::builder() + .config(include_str!( + "fixtures/small_connection_shutdown_timeout.router.yaml" + )) + .responder(ResponseTemplate::new(500).set_delay(Duration::from_secs(10))) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let query = router.execute_default_query(); + // Long running request 1 + let _h1 = tokio::task::spawn(query); + router + .update_config(include_str!( + "fixtures/small_connection_shutdown_timeout_updated.router.yaml" + )) + .await; + + router.assert_reloaded().await; + // Long running request 2 + let query = router.execute_default_query(); + let _h2 = tokio::task::spawn(query); + let metrics = router + .get_metrics_response() + .await + .expect("metrics") + .text() + .await + .expect("metrics"); + tokio::time::sleep(Duration::from_millis(100)).await; + // There should be two instances of the pipeline metrics + let pipelines = Regex::new(r#"(?m)^apollo_router_pipelines[{].+[}] 1"#).expect("regex"); + assert_eq!(pipelines.captures_iter(&metrics).count(), 1); + + let terminating = + Regex::new(r#"(?m)^apollo_router_open_connections[{].+active.+[}]"#).expect("regex"); + assert_eq!(terminating.captures_iter(&metrics).count(), 1); + router.read_logs(); + router.assert_log_contained("connection shutdown exceeded, forcing close"); +} diff --git a/apollo-router/tests/integration/metrics.rs b/apollo-router/tests/integration/metrics.rs new file mode 100644 index 0000000000..2a27665f66 --- /dev/null +++ b/apollo-router/tests/integration/metrics.rs @@ -0,0 +1,33 @@ +#[cfg(all(feature = "global-allocator", not(feature = "dhat-heap"), unix))] +#[tokio::test(flavor = "multi_thread")] +async fn test_jemalloc_metrics_are_emitted() { + use super::common::IntegrationTest; + + let mut router = IntegrationTest::builder() + .config(include_str!("fixtures/prometheus.router.yaml")) + .build() + .await; + + router.start().await; + router.assert_started().await; + router.execute_default_query().await; + + router + .assert_metrics_contains(r#"apollo_router_jemalloc_active"#, None) + .await; + router + .assert_metrics_contains(r#"apollo_router_jemalloc_allocated"#, None) + .await; + router + .assert_metrics_contains(r#"apollo_router_jemalloc_mapped"#, None) + .await; + router + .assert_metrics_contains(r#"apollo_router_jemalloc_metadata"#, None) + .await; + router + .assert_metrics_contains(r#"apollo_router_jemalloc_resident"#, None) + .await; + router + .assert_metrics_contains(r#"apollo_router_jemalloc_retained"#, None) + .await; +} diff --git a/apollo-router/tests/integration/mock_subgraphs.rs b/apollo-router/tests/integration/mock_subgraphs.rs new file mode 100644 index 0000000000..5599063f55 --- /dev/null +++ b/apollo-router/tests/integration/mock_subgraphs.rs @@ -0,0 +1,74 @@ +use apollo_router::_private::mock_subgraphs_subgraph_call; +use apollo_router::graphql::Request; +use serde_json_bytes::json; + +#[test] +fn test_cache_tags() { + let sdl = include_str!("../fixtures/supergraph.graphql"); + let supergraph = apollo_federation::Supergraph::new(sdl).unwrap(); + let subgraphs = supergraph.extract_subgraphs().unwrap(); + + let schema = subgraphs.get("products").unwrap().schema.schema(); + let config = json!({ + "query": { + "topProducts": [ + {"upc": "1", "__cacheTags": ["topProducts"]}, + {"upc": "2"}, + ], + }, + }); + let query = "{ topProducts { upc } }"; + let request = Request::fake_builder().query(query).build(); + let response = mock_subgraphs_subgraph_call(config.clone(), schema, &request).unwrap(); + insta::assert_yaml_snapshot!(response, @r###" + data: + topProducts: + - upc: "1" + - upc: "2" + extensions: + apolloCacheTags: + - topProducts + "###); + + let schema = subgraphs.get("reviews").unwrap().schema.schema(); + let config = json!({ + "entities": [ + { + "__cacheTags": ["product-1"], + "__typename": "Product", + "upc": "1", + "reviews": [{"id": "r1a"}, {"id": "r1b"}], + }, + { + "__cacheTags": ["product-2"], + "__typename": "Product", + "upc": "2", + "reviews": [{"id": "r2"}], + }, + ], + }); + let query = r#" + { + _entities(representations: [{upc: "2"}, {upc: "1"}]) { + ... on Product { + reviews { id } + } + } + } + "#; + let request = Request::fake_builder().query(query).build(); + let response = mock_subgraphs_subgraph_call(config.clone(), schema, &request).unwrap(); + insta::assert_yaml_snapshot!(response, @r###" + data: + _entities: + - reviews: + - id: r2 + - reviews: + - id: r1a + - id: r1b + extensions: + apolloEntityCacheTags: + - - product-2 + - - product-1 + "###); +} diff --git a/apollo-router/tests/integration/mod.rs b/apollo-router/tests/integration/mod.rs index 7e775a21a9..f2357ce318 100644 --- a/apollo-router/tests/integration/mod.rs +++ b/apollo-router/tests/integration/mod.rs @@ -3,11 +3,19 @@ mod batching; pub(crate) mod common; pub(crate) use common::IntegrationTest; +mod allowed_features; + +mod connectors; mod coprocessor; mod docs; +// In the CI environment we only install Redis on x86_64 Linux +#[cfg(any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")))] +mod entity_cache; mod file_upload; mod introspection; mod lifecycle; +mod metrics; +mod mock_subgraphs; mod operation_limits; mod operation_name; mod query_planner; @@ -16,10 +24,18 @@ mod supergraph; mod traffic_shaping; mod typename; +// In the CI environment we only install PostgreSQL on x86_64 Linux +#[cfg(any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")))] +mod postgres; +// In the CI environment we only install PostgreSQL on x86_64 Linux +#[cfg(any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")))] +mod response_cache; +// In the CI environment we only install Redis on x86_64 Linux #[cfg(any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")))] mod redis; mod rhai; -mod subscription; +mod subscription_load_test; +mod subscriptions; mod telemetry; mod validation; @@ -40,3 +56,12 @@ impl ValueExt for Value { self.as_str().map(|s| s.to_string()) } } + +impl ValueExt for &Value { + fn select_path<'a>(&'a self, path: &str) -> Result, BoxError> { + Ok(Selector::new().str_path(path)?.value(self).select()?) + } + fn as_string(&self) -> Option { + self.as_str().map(|s| s.to_string()) + } +} diff --git a/apollo-router/tests/integration/operation_limits.rs b/apollo-router/tests/integration/operation_limits.rs index 79ad7d9f89..a251aa9597 100644 --- a/apollo-router/tests/integration/operation_limits.rs +++ b/apollo-router/tests/integration/operation_limits.rs @@ -1,16 +1,17 @@ +use std::sync::Arc; use std::sync::atomic::AtomicU32; use std::sync::atomic::Ordering; -use std::sync::Arc; +use apollo_router::TestHarness; use apollo_router::graphql; use apollo_router::services::execution; use apollo_router::services::supergraph; -use apollo_router::TestHarness; use serde_json::json; use tower::BoxError; use tower::ServiceExt; use crate::integration::IntegrationTest; +use crate::integration::common::Query; #[tokio::test(flavor = "multi_thread")] async fn test_response_errors() { @@ -310,7 +311,9 @@ async fn test_request_bytes_limit_with_coprocessor() -> Result<(), BoxError> { .await; router.start().await; router.assert_started().await; - let (_, resp) = router.execute_huge_query().await; + let (_, resp) = router + .execute_query(Query::default().with_huge_query()) + .await; assert_eq!(resp.status(), 413); router.graceful_shutdown().await; Ok(()) @@ -324,7 +327,9 @@ async fn test_request_bytes_limit() -> Result<(), BoxError> { .await; router.start().await; router.assert_started().await; - let (_, resp) = router.execute_huge_query().await; + let (_, resp) = router + .execute_query(Query::default().with_huge_query()) + .await; assert_eq!(resp.status(), 413); router.graceful_shutdown().await; Ok(()) diff --git a/apollo-router/tests/integration/operation_name.rs b/apollo-router/tests/integration/operation_name.rs index 6d2ef81226..69f25bc67b 100644 --- a/apollo-router/tests/integration/operation_name.rs +++ b/apollo-router/tests/integration/operation_name.rs @@ -1,5 +1,5 @@ +use apollo_router::graphql::Request; use apollo_router::plugin::test::MockSubgraph; -use apollo_router::services::supergraph::Request; use serde_json::json; use tower::ServiceExt; @@ -7,8 +7,7 @@ use tower::ServiceExt; async fn empty_document() { let request = Request::fake_builder() .query("# intentionally left blank") - .build() - .unwrap(); + .build(); let response = make_request(request).await; insta::assert_json_snapshot!(response, @r###" { @@ -34,8 +33,7 @@ async fn empty_document() { async fn zero_operation() { let request = Request::fake_builder() .query("fragment F on Query { me { id }}") - .build() - .unwrap(); + .build(); let response = make_request(request).await; insta::assert_json_snapshot!(response, @r###" { @@ -59,10 +57,7 @@ async fn zero_operation() { #[tokio::test] async fn anonymous_operation() { - let request = Request::fake_builder() - .query("{ me { id } }") - .build() - .unwrap(); + let request = Request::fake_builder().query("{ me { id } }").build(); let response = make_request(request).await; insta::assert_json_snapshot!(response, @r###" { @@ -80,8 +75,7 @@ async fn named_operation() { let request = Request::fake_builder() .query("query Op { me { id } }") .operation_name("Op") - .build() - .unwrap(); + .build(); let response = make_request(request).await; insta::assert_json_snapshot!(response, @r###" { @@ -104,8 +98,7 @@ async fn two_named_operations() { "#, ) .operation_name("Op") - .build() - .unwrap(); + .build(); let response = make_request(request).await; insta::assert_json_snapshot!(response, @r###" { @@ -127,8 +120,7 @@ async fn missing_operation_name() { query OtherOp { me { name } } "#, ) - .build() - .unwrap(); + .build(); let response = make_request(request).await; insta::assert_json_snapshot!(response, @r###" { @@ -154,8 +146,7 @@ async fn incorrect_operation_name() { "#, ) .operation_name("SecretThirdOp") - .build() - .unwrap(); + .build(); let response = make_request(request).await; insta::assert_json_snapshot!(response, @r###" { @@ -163,7 +154,7 @@ async fn incorrect_operation_name() { { "message": "Unknown operation named \"SecretThirdOp\"", "extensions": { - "code": "GRAPHQL_VALIDATION_FAILED" + "code": "GRAPHQL_UNKNOWN_OPERATION_NAME" } } ] @@ -172,7 +163,7 @@ async fn incorrect_operation_name() { } async fn make_request(request: Request) -> apollo_router::graphql::Response { - apollo_router::TestHarness::builder() + let router = apollo_router::TestHarness::builder() .configuration_json(json!({ "include_subgraph_errors": { "all": true, @@ -196,13 +187,26 @@ async fn make_request(request: Request) -> apollo_router::graphql::Response { .boxed(), _ => default, }) - .build_supergraph() + .build_router() .await - .unwrap() + .unwrap(); + + let request = apollo_router::services::router::Request::fake_builder() + .body(serde_json::to_string(&request).unwrap().into_bytes()) + .method(http::Method::POST) + .header("content-type", "application/json") + .header("accept", "application/json") + .build() + .unwrap(); + + let response = router .oneshot(request) .await .unwrap() .next_response() .await - .unwrap() + .expect("should have one response") + .unwrap(); + + serde_json::from_slice(&response).unwrap() } diff --git a/apollo-router/tests/integration/postgres.rs b/apollo-router/tests/integration/postgres.rs new file mode 100644 index 0000000000..7199173c2d --- /dev/null +++ b/apollo-router/tests/integration/postgres.rs @@ -0,0 +1,642 @@ +use apollo_router::services::router::body::from_bytes; +use apollo_router::services::supergraph; +use http::HeaderValue; +use http::Method; +use serde_json::Value; +use serde_json::json; +use sqlx::Connection; +use sqlx::PgConnection; +use sqlx::prelude::FromRow; +use tower::BoxError; +use tower::ServiceExt; + +use crate::integration::common::graph_os_enabled; +use crate::integration::response_cache::namespace; + +#[derive(FromRow)] +struct Record { + data: String, +} + +macro_rules! check_cache_key { + ($cache_key: expr, $conn: expr) => { + let mut record = None; + // Because insert is async + for _ in 0..10 { + if let Ok(resp) = sqlx::query_as!( + Record, + "SELECT data FROM cache WHERE cache_key = $1", + $cache_key + ) + .fetch_one(&mut $conn) + .await + { + record = Some(resp); + break; + } + } + match record { + Some(s) => { + let v: Value = serde_json::from_str(&s.data).unwrap(); + insta::assert_json_snapshot!(v); + } + None => panic!("cannot get cache key {}", $cache_key), + } + }; +} + +#[tokio::test(flavor = "multi_thread")] +async fn entity_cache_basic() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let namespace = namespace(); + + let mut conn = PgConnection::connect("postgres://127.0.0.1").await?; + sqlx::migrate!().run(&mut conn).await.unwrap(); + let subgraphs = serde_json::json!({ + "products": { + "query": {"topProducts": [{ + "__typename": "Product", + "upc": "1", + "name": "chair" + }, + { + "__typename": "Product", + "upc": "2", + "name": "table" + }, + { + "__typename": "Product", + "upc": "3", + "name": "plate" + }]}, + "headers": {"cache-control": "public"}, + }, + "reviews": { + "entities": [{ + "__typename": "Product", + "upc": "1", + "reviews": [{ + "__typename": "Review", + "body": "I can sit on it", + }] + }, + { + "__typename": "Product", + "upc": "2", + "reviews": [{ + "__typename": "Review", + "body": "I can sit on it", + }, { + "__typename": "Review", + "body": "I can sit on it2", + }] + }, + { + "__typename": "Product", + "upc": "3", + "reviews": [{ + "__typename": "Review", + "body": "I can sit on it", + }, { + "__typename": "Review", + "body": "I can sit on it2", + }, { + "__typename": "Review", + "body": "I can sit on it3", + }] + }], + "headers": {"cache-control": "public"}, + } + }); + let supergraph = apollo_router::TestHarness::builder() + .configuration_json(json!({ + "experimental_response_cache": { + "enabled": true, + "debug": true, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": "/invalidation" + }, + "subgraph": { + "all": { + "enabled": true, + "postgres": { + "url": "postgres://127.0.0.1", + "namespace": namespace, + "pool_size": 3 + }, + }, + "subgraphs": { + "products": { + "enabled": true, + "ttl": "60s" + }, + "reviews": { + "enabled": true, + "ttl": "10s" + } + } + } + }, + "include_subgraph_errors": { + "all": true + }, + "experimental_mock_subgraphs": subgraphs.clone() + })) + .unwrap() + .schema(include_str!("../fixtures/supergraph-auth.graphql")) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(r#"{ topProducts { name reviews { body } } }"#) + .method(Method::POST) + .header("apollo-cache-debugging", "true") + .build() + .unwrap(); + + let response = supergraph + .oneshot(request) + .await + .unwrap() + .next_response() + .await + .unwrap(); + insta::assert_json_snapshot!(response, { + ".extensions.apolloCacheDebugging.data[].cacheControl.created" => 0 + }); + + let cache_key = format!( + "{namespace}-version:1.0:subgraph:products:type:Query:hash:6422a4ef561035dd94b357026091b72dca07429196aed0342e9e32cc1d48a13f:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + ); + + check_cache_key!(&cache_key, conn); + + let cache_key = format!( + "{namespace}-version:1.0:subgraph:reviews:type:Product:entity:72bafad9ffe61307806863b13856470e429e0cf332c99e5b735224fb0b1436f7:representation::hash:3cede4e233486ac841993dd8fc0662ef375351481eeffa8e989008901300a693:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + ); + check_cache_key!(&cache_key, conn); + + let supergraph = apollo_router::TestHarness::builder() + .configuration_json(json!({ + "experimental_response_cache": { + "enabled": true, + "debug": true, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": "/invalidation" + }, + "subgraph": { + "all": { + "enabled": false, + "postgres": { + "url": "postgres://127.0.0.1", + "namespace": namespace, + }, + }, + "subgraphs": { + "products": { + "enabled": true, + "ttl": "60s" + }, + "reviews": { + "enabled": true, + "ttl": "10s" + } + } + } + }, + "include_subgraph_errors": { + "all": true + }, + "experimental_mock_subgraphs": subgraphs.clone() + })) + .unwrap() + .schema(include_str!("../fixtures/supergraph-auth.graphql")) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(r#"{ topProducts(first: 2) { name reviews { body } } }"#) + .header("apollo-cache-debugging", "true") + .method(Method::POST) + .build() + .unwrap(); + + let response = supergraph + .oneshot(request) + .await + .unwrap() + .next_response() + .await + .unwrap(); + insta::assert_json_snapshot!(response, { + ".extensions.apolloCacheDebugging.data[].cacheControl.created" => 0 + }); + + let cache_key = format!( + "{namespace}-version:1.0:subgraph:reviews:type:Product:entity:080fc430afd3fb953a05525a6a00999226c34436466eff7ace1d33d004adaae3:representation::hash:3cede4e233486ac841993dd8fc0662ef375351481eeffa8e989008901300a693:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + ); + check_cache_key!(&cache_key, conn); + + const SECRET_SHARED_KEY: &str = "supersecret"; + let http_service = apollo_router::TestHarness::builder() + .configuration_json(json!({ + "experimental_response_cache": { + "enabled": true, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": "/invalidation" + }, + "subgraph": { + "all": { + "enabled": true, + "postgres": { + "url": "postgres://127.0.0.1", + "namespace": namespace, + }, + "invalidation": { + "enabled": true, + "shared_key": SECRET_SHARED_KEY + } + }, + "subgraphs": { + "products": { + "enabled": true, + "ttl": "60s", + "invalidation": { + "enabled": true, + "shared_key": SECRET_SHARED_KEY + } + }, + "reviews": { + "enabled": true, + "ttl": "10s", + "invalidation": { + "enabled": true, + "shared_key": SECRET_SHARED_KEY + } + } + } + } + }, + "include_subgraph_errors": { + "all": true + }, + "experimental_mock_subgraphs": subgraphs.clone() + })) + .unwrap() + .schema(include_str!("../fixtures/supergraph-auth.graphql")) + .build_http_service() + .await + .unwrap(); + + let request = http::Request::builder() + .uri("http://127.0.0.1:4000/invalidation") + .method(http::Method::POST) + .header( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ) + .header( + http::header::AUTHORIZATION, + HeaderValue::from_static(SECRET_SHARED_KEY), + ) + .body(from_bytes( + serde_json::to_vec(&vec![json!({ + "subgraph": "reviews", + "kind": "type", + "type": "Product" + })]) + .unwrap(), + )) + .unwrap(); + let response = http_service.oneshot(request).await.unwrap(); + let response_status = response.status(); + let mut resp: serde_json::Value = serde_json::from_str( + &apollo_router::services::router::body::into_string(response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + + assert_eq!( + resp.as_object_mut() + .unwrap() + .get("count") + .unwrap() + .as_u64() + .unwrap(), + 3u64 + ); + assert!(response_status.is_success()); + + // This should be in error because we invalidated this entity + let cache_key = format!( + "{namespace}-version:1.0:subgraph:reviews:type:Product:entity:080fc430afd3fb953a05525a6a00999226c34436466eff7ace1d33d004adaae3:representation::hash:b9b8a9c94830cf56329ec2db7d7728881a6ba19cc1587710473e732e775a5870:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + ); + assert!( + sqlx::query_as!( + Record, + "SELECT data FROM cache WHERE cache_key = $1", + cache_key + ) + .fetch_one(&mut conn) + .await + .is_err() + ); + // This entry should still be in redis because we didn't invalidate this entry + let cache_key = format!( + "{namespace}-version:1.0:subgraph:products:type:Query:hash:6422a4ef561035dd94b357026091b72dca07429196aed0342e9e32cc1d48a13f:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + ); + assert!( + sqlx::query_as!( + Record, + "SELECT data FROM cache WHERE cache_key = $1", + cache_key + ) + .fetch_one(&mut conn) + .await + .is_ok() + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn entity_cache_with_nested_field_set() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let namespace = namespace(); + let schema = include_str!("../../src/testdata/supergraph_nested_fields.graphql"); + + let mut conn = PgConnection::connect("postgres://127.0.0.1").await?; + sqlx::migrate!().run(&mut conn).await.unwrap(); + + let subgraphs = serde_json::json!({ + "products": { + "query": {"allProducts": [{ + "id": "1", + "name": "Test", + "sku": "150", + "createdBy": { "__typename": "User", "email": "test@test.com", "country": {"a": "France"} } + }]}, + "headers": {"cache-control": "public"}, + }, + "users": { + "entities": [{ + "__typename": "User", + "email": "test@test.com", + "name": "test", + "country": { + "a": "France" + } + }], + "headers": {"cache-control": "public"}, + } + }); + + let supergraph = apollo_router::TestHarness::builder() + .configuration_json(json!({ + "experimental_response_cache": { + "enabled": true, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": "/invalidation" + }, + "subgraph": { + "all": { + "enabled": true, + "postgres": { + "url": "postgres://127.0.0.1", + "namespace": namespace, + "pool_size": 3 + }, + } + } + }, + "include_subgraph_errors": { + "all": true + }, + "experimental_mock_subgraphs": subgraphs.clone() + })) + .unwrap() + .schema(schema) + .build_supergraph() + .await + .unwrap(); + let query = "query { allProducts { name createdBy { name country { a } } } }"; + + let request = supergraph::Request::fake_builder() + .query(query) + .method(Method::POST) + .build() + .unwrap(); + + let response = supergraph + .oneshot(request) + .await + .unwrap() + .next_response() + .await + .unwrap(); + insta::assert_json_snapshot!(response); + + let cache_key = format!( + "{namespace}-version:1.0:subgraph:products:type:Query:hash:6173063a04125ecfdaf77111980dc68921dded7813208fdf1d7d38dfbb959627:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + ); + check_cache_key!(&cache_key, conn); + + let cache_key = format!( + "{namespace}-version:1.0:subgraph:users:type:User:entity:210e26346d676046faa9fb55d459273a43e5b5397a1a056f179a3521dc5643aa:representation:7cd02a08f4ea96f0affa123d5d3f56abca20e6014e060fe5594d210c00f64b27:hash:2820563c632c1ab498e06030084acf95c97e62afba71a3d4b7c5e81a11cb4d13:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + ); + check_cache_key!(&cache_key, conn); + + let supergraph = apollo_router::TestHarness::builder() + .configuration_json(json!({ + "experimental_response_cache": { + "enabled": true, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": "/invalidation" + }, + "subgraph": { + "all": { + "enabled": false, + "postgres": { + "url": "postgres://127.0.0.1", + "namespace": namespace, + }, + }, + "subgraphs": { + "products": { + "enabled": true, + "ttl": "60s" + }, + "reviews": { + "enabled": true, + "ttl": "10s" + } + } + } + }, + "include_subgraph_errors": { + "all": true + }, + "experimental_mock_subgraphs": subgraphs.clone() + })) + .unwrap() + .schema(schema) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .method(Method::POST) + .build() + .unwrap(); + + let response = supergraph + .oneshot(request) + .await + .unwrap() + .next_response() + .await + .unwrap(); + insta::assert_json_snapshot!(response); + + let cache_key = format!( + "{namespace}-version:1.0:subgraph:users:type:User:entity:210e26346d676046faa9fb55d459273a43e5b5397a1a056f179a3521dc5643aa:representation:7cd02a08f4ea96f0affa123d5d3f56abca20e6014e060fe5594d210c00f64b27:hash:2820563c632c1ab498e06030084acf95c97e62afba71a3d4b7c5e81a11cb4d13:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + ); + check_cache_key!(&cache_key, conn); + + const SECRET_SHARED_KEY: &str = "supersecret"; + let http_service = apollo_router::TestHarness::builder() + .configuration_json(json!({ + "experimental_response_cache": { + "enabled": true, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": "/invalidation" + }, + "subgraph": { + "all": { + "enabled": true, + "postgres": { + "url": "postgres://127.0.0.1", + "namespace": namespace, + }, + "invalidation": { + "enabled": true, + "shared_key": SECRET_SHARED_KEY + } + }, + "subgraphs": { + "products": { + "enabled": true, + "ttl": "60s", + "invalidation": { + "enabled": true, + "shared_key": SECRET_SHARED_KEY + } + }, + "reviews": { + "enabled": true, + "ttl": "10s", + "invalidation": { + "enabled": true, + "shared_key": SECRET_SHARED_KEY + } + } + } + } + }, + "include_subgraph_errors": { + "all": true + }, + "experimental_mock_subgraphs": subgraphs.clone() + })) + .unwrap() + .schema(schema) + .build_http_service() + .await + .unwrap(); + + let request = http::Request::builder() + .uri("http://127.0.0.1:4000/invalidation") + .method(http::Method::POST) + .header( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ) + .header( + http::header::AUTHORIZATION, + HeaderValue::from_static(SECRET_SHARED_KEY), + ) + .body(from_bytes( + serde_json::to_vec(&vec![json!({ + "subgraph": "users", + "kind": "type", + "type": "User" + })]) + .unwrap(), + )) + .unwrap(); + let response = http_service.oneshot(request).await.unwrap(); + let response_status = response.status(); + let mut resp: serde_json::Value = serde_json::from_str( + &apollo_router::services::router::body::into_string(response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + + assert_eq!( + resp.as_object_mut() + .unwrap() + .get("count") + .unwrap() + .as_u64() + .unwrap(), + 1u64 + ); + assert!(response_status.is_success()); + + // This should be in error because we invalidated this entity + let cache_key = format!( + "{namespace}-version:1.0:subgraph:users:type:User:entity:210e26346d676046faa9fb55d459273a43e5b5397a1a056f179a3521dc5643aa:representation:7cd02a08f4ea96f0affa123d5d3f56abca20e6014e060fe5594d210c00f64b27:hash:cfc5f467f767710804724ff6a05c3f63297328cd8283316adb25f5642e1439ad:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + ); + assert!( + sqlx::query_as!( + Record, + "SELECT data FROM cache WHERE cache_key = $1", + cache_key + ) + .fetch_one(&mut conn) + .await + .is_err() + ); + + // This entry should still be in redis because we didn't invalidate this entry + let cache_key = format!( + "{namespace}-version:1.0:subgraph:products:type:Query:hash:6173063a04125ecfdaf77111980dc68921dded7813208fdf1d7d38dfbb959627:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + ); + assert!( + sqlx::query_as!( + Record, + "SELECT data FROM cache WHERE cache_key = $1", + cache_key + ) + .fetch_one(&mut conn) + .await + .is_ok() + ); + + Ok(()) +} diff --git a/apollo-router/tests/integration/query_planner.rs b/apollo-router/tests/integration/query_planner.rs deleted file mode 100644 index 03056ab47d..0000000000 --- a/apollo-router/tests/integration/query_planner.rs +++ /dev/null @@ -1,360 +0,0 @@ -use std::path::PathBuf; - -use crate::integration::common::graph_os_enabled; -use crate::integration::IntegrationTest; - -const PROMETHEUS_METRICS_CONFIG: &str = include_str!("telemetry/fixtures/prometheus.router.yaml"); -const LEGACY_QP: &str = "experimental_query_planner_mode: legacy"; -const NEW_QP: &str = "experimental_query_planner_mode: new"; -const BOTH_QP: &str = "experimental_query_planner_mode: both"; -const BOTH_BEST_EFFORT_QP: &str = "experimental_query_planner_mode: both_best_effort"; - -#[tokio::test(flavor = "multi_thread")] -async fn fed1_schema_with_legacy_qp() { - let mut router = IntegrationTest::builder() - .config(LEGACY_QP) - .supergraph("../examples/graphql/supergraph-fed1.graphql") - .build() - .await; - router.start().await; - router.assert_started().await; - router.execute_default_query().await; - router.graceful_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn fed1_schema_with_new_qp() { - let mut router = IntegrationTest::builder() - .config(NEW_QP) - .supergraph("../examples/graphql/supergraph-fed1.graphql") - .build() - .await; - router.start().await; - router - .assert_log_contains( - "could not create router: \ - failed to initialize the query planner: \ - Supergraphs composed with federation version 1 are not supported.", - ) - .await; - router.assert_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn fed1_schema_with_both_qp() { - let mut router = IntegrationTest::builder() - .config(BOTH_QP) - .supergraph("../examples/graphql/supergraph-fed1.graphql") - .build() - .await; - router.start().await; - router - .assert_log_contains( - "could not create router: \ - failed to initialize the query planner: \ - Supergraphs composed with federation version 1 are not supported.", - ) - .await; - router.assert_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn fed1_schema_with_both_best_effort_qp() { - let mut router = IntegrationTest::builder() - .config(BOTH_BEST_EFFORT_QP) - .supergraph("../examples/graphql/supergraph-fed1.graphql") - .build() - .await; - router.start().await; - router - .assert_log_contains( - "Falling back to the legacy query planner: \ - failed to initialize the query planner: \ - Supergraphs composed with federation version 1 are not supported. \ - Please recompose your supergraph with federation version 2 or greater", - ) - .await; - router.assert_started().await; - router.execute_default_query().await; - router.graceful_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn fed1_schema_with_legacy_qp_reload_to_new_keep_previous_config() { - let config = format!("{PROMETHEUS_METRICS_CONFIG}\n{LEGACY_QP}"); - let mut router = IntegrationTest::builder() - .config(config) - .supergraph("../examples/graphql/supergraph-fed1.graphql") - .build() - .await; - router.start().await; - router.assert_started().await; - router.execute_default_query().await; - - let config = format!("{PROMETHEUS_METRICS_CONFIG}\n{NEW_QP}"); - router.update_config(&config).await; - router - .assert_log_contains("error while reloading, continuing with previous configuration") - .await; - router - .assert_metrics_contains( - r#"apollo_router_lifecycle_query_planner_init_total{init_error_kind="fed1",init_is_success="false",otel_scope_name="apollo/router"} 1"#, - None, - ) - .await; - router.execute_default_query().await; - router.graceful_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn fed1_schema_with_legacy_qp_reload_to_both_best_effort_keep_previous_config() { - let config = format!("{PROMETHEUS_METRICS_CONFIG}\n{LEGACY_QP}"); - let mut router = IntegrationTest::builder() - .config(config) - .supergraph("../examples/graphql/supergraph-fed1.graphql") - .build() - .await; - router.start().await; - router.assert_started().await; - router.execute_default_query().await; - - let config = format!("{PROMETHEUS_METRICS_CONFIG}\n{BOTH_BEST_EFFORT_QP}"); - router.update_config(&config).await; - router - .assert_log_contains( - "Falling back to the legacy query planner: \ - failed to initialize the query planner: \ - Supergraphs composed with federation version 1 are not supported. \ - Please recompose your supergraph with federation version 2 or greater", - ) - .await; - router - .assert_metrics_contains( - r#"apollo_router_lifecycle_query_planner_init_total{init_error_kind="fed1",init_is_success="false",otel_scope_name="apollo/router"} 1"#, - None, - ) - .await; - router.execute_default_query().await; - router.graceful_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn fed2_schema_with_new_qp() { - let config = format!("{PROMETHEUS_METRICS_CONFIG}\n{NEW_QP}"); - let mut router = IntegrationTest::builder() - .config(config) - .supergraph("../examples/graphql/supergraph.graphql") - .build() - .await; - router.start().await; - router.assert_started().await; - router - .assert_metrics_contains( - r#"apollo_router_lifecycle_query_planner_init_total{init_is_success="true",otel_scope_name="apollo/router"} 1"#, - None, - ) - .await; - router.execute_default_query().await; - router.graceful_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn context_with_legacy_qp() { - if !graph_os_enabled() { - return; - } - let mut router = IntegrationTest::builder() - .config(PROMETHEUS_METRICS_CONFIG) - .supergraph("tests/fixtures/set_context/supergraph.graphql") - .build() - .await; - router.start().await; - router.assert_started().await; - router.execute_default_query().await; - router.graceful_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn context_with_new_qp() { - if !graph_os_enabled() { - return; - } - let mut router = IntegrationTest::builder() - .config(NEW_QP) - .supergraph("tests/fixtures/set_context/supergraph.graphql") - .build() - .await; - router.start().await; - router - .assert_log_contains( - "could not create router: \ - failed to initialize the query planner: \ - `experimental_query_planner_mode: new` or `both` cannot yet \ - be used with `@context`. \ - Remove uses of `@context` to try the experimental query planner, \ - otherwise switch back to `legacy` or `both_best_effort`.", - ) - .await; - router.assert_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn context_with_legacy_qp_change_to_new_qp_keeps_old_config() { - if !graph_os_enabled() { - return; - } - let config = format!("{PROMETHEUS_METRICS_CONFIG}\n{LEGACY_QP}"); - let mut router = IntegrationTest::builder() - .config(config) - .supergraph("tests/fixtures/set_context/supergraph.graphql") - .build() - .await; - router.start().await; - router.assert_started().await; - router.execute_default_query().await; - let config = format!("{PROMETHEUS_METRICS_CONFIG}\n{NEW_QP}"); - router.update_config(&config).await; - router - .assert_log_contains("error while reloading, continuing with previous configuration") - .await; - router - .assert_metrics_contains( - r#"apollo_router_lifecycle_query_planner_init_total{init_error_kind="context",init_is_success="false",otel_scope_name="apollo/router"} 1"#, - None, - ) - .await; - router.execute_default_query().await; - router.graceful_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn context_with_legacy_qp_reload_to_both_best_effort_keep_previous_config() { - if !graph_os_enabled() { - return; - } - let config = format!("{PROMETHEUS_METRICS_CONFIG}\n{LEGACY_QP}"); - let mut router = IntegrationTest::builder() - .config(config) - .supergraph("tests/fixtures/set_context/supergraph.graphql") - .build() - .await; - router.start().await; - router.assert_started().await; - router.execute_default_query().await; - - let config = format!("{PROMETHEUS_METRICS_CONFIG}\n{BOTH_BEST_EFFORT_QP}"); - router.update_config(&config).await; - router - .assert_log_contains( - "Falling back to the legacy query planner: \ - failed to initialize the query planner: \ - `experimental_query_planner_mode: new` or `both` cannot yet \ - be used with `@context`. \ - Remove uses of `@context` to try the experimental query planner, \ - otherwise switch back to `legacy` or `both_best_effort`.", - ) - .await; - router - .assert_metrics_contains( - r#"apollo_router_lifecycle_query_planner_init_total{init_error_kind="context",init_is_success="false",otel_scope_name="apollo/router"} 1"#, - None, - ) - .await; - router.execute_default_query().await; - router.graceful_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn invalid_schema_with_legacy_qp_fails_startup() { - let mut router = IntegrationTest::builder() - .config(LEGACY_QP) - .supergraph("tests/fixtures/broken-supergraph.graphql") - .build() - .await; - router.start().await; - router - .assert_log_contains( - "could not create router: \ - Federation error: Invalid supergraph: must be a core schema", - ) - .await; - router.assert_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn invalid_schema_with_new_qp_fails_startup() { - let mut router = IntegrationTest::builder() - .config(NEW_QP) - .supergraph("tests/fixtures/broken-supergraph.graphql") - .build() - .await; - router.start().await; - router - .assert_log_contains( - "could not create router: \ - Federation error: Invalid supergraph: must be a core schema", - ) - .await; - router.assert_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn invalid_schema_with_both_qp_fails_startup() { - let mut router = IntegrationTest::builder() - .config(BOTH_QP) - .supergraph("tests/fixtures/broken-supergraph.graphql") - .build() - .await; - router.start().await; - router - .assert_log_contains( - "could not create router: \ - Federation error: Invalid supergraph: must be a core schema", - ) - .await; - router.assert_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn invalid_schema_with_both_best_effort_qp_fails_startup() { - let mut router = IntegrationTest::builder() - .config(BOTH_BEST_EFFORT_QP) - .supergraph("tests/fixtures/broken-supergraph.graphql") - .build() - .await; - router.start().await; - router - .assert_log_contains( - "could not create router: \ - Federation error: Invalid supergraph: must be a core schema", - ) - .await; - router.assert_shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn valid_schema_with_new_qp_change_to_broken_schema_keeps_old_config() { - let config = format!("{PROMETHEUS_METRICS_CONFIG}\n{NEW_QP}"); - let mut router = IntegrationTest::builder() - .config(config) - .supergraph("tests/fixtures/valid-supergraph.graphql") - .build() - .await; - router.start().await; - router.assert_started().await; - router - .assert_metrics_contains( - r#"apollo_router_lifecycle_query_planner_init_total{init_is_success="true",otel_scope_name="apollo/router"} 1"#, - None, - ) - .await; - router.execute_default_query().await; - router - .update_schema(&PathBuf::from("tests/fixtures/broken-supergraph.graphql")) - .await; - router - .assert_log_contains("error while reloading, continuing with previous configuration") - .await; - router.execute_default_query().await; - router.graceful_shutdown().await; -} diff --git a/apollo-router/tests/integration/query_planner/error_paths.rs b/apollo-router/tests/integration/query_planner/error_paths.rs new file mode 100644 index 0000000000..2017dd2f5e --- /dev/null +++ b/apollo-router/tests/integration/query_planner/error_paths.rs @@ -0,0 +1,391 @@ +use serde_json::json; +use serde_json::value::Value; +use tower::BoxError; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use crate::integration::IntegrationTest; +use crate::integration::common::Query; +use crate::integration::common::graph_os_enabled; + +const CONFIG: &str = r#" +include_subgraph_errors: + all: true +"#; + +const QUERY: &str = r#" + query Q { + topProducts { + name + inStock + reviews { + id + author { + username + name + } + } + } + }"#; + +enum ResponseType { + Ok, + Error(ErrorType), +} + +enum ErrorType { + Malformed, + EmptyPath, + Valid, +} + +fn products_response(response_type: ResponseType) -> ResponseTemplate { + let response_json = match response_type { + ResponseType::Ok => json!({ + "data": { + "topProducts": [ + { "__typename": "Product", "name": "Table", "upc": "1" }, + { "__typename": "Product", "name": "Chair", "upc": "2" }, + ] + }, + }), + ResponseType::Error(ErrorType::Valid) => json!({ + "data": { + "topProducts": [ + { "__typename": "Product", "name": "Table", "upc": "1" }, + null, + ] + }, + "errors": [{ "message": "products error", "path": ["topProducts", 1] }] + }), + ResponseType::Error(ErrorType::EmptyPath) => json!({ + "data": { + "topProducts": [ + { "__typename": "Product", "name": "Table", "upc": "1" }, + null, + ] + }, + "errors": [{ "message": "products error", "path": [] }] + }), + ResponseType::Error(ErrorType::Malformed) => json!({"malformed": true}), + }; + ResponseTemplate::new(200).set_body_json(response_json) +} + +fn inventory_response(response_type: ResponseType) -> ResponseTemplate { + let response_json = match response_type { + ResponseType::Ok => json!({ + "data": {"_entities": [{"inStock": true}, {"inStock": false}]}, + }), + ResponseType::Error(ErrorType::Valid) => json!({ + "data": {"_entities": [null, {"inStock": false}]}, + "errors": [{ "message": "inventory error", "path": ["_entities", 0] }] + }), + ResponseType::Error(ErrorType::EmptyPath) => json!({ + "data": {"_entities": [null, {"inStock": false}]}, + "errors": [{ "message": "inventory error", "path": [] }] + }), + ResponseType::Error(ErrorType::Malformed) => json!({"malformed": true}), + }; + ResponseTemplate::new(200).set_body_json(response_json) +} + +fn reviews_response(response_type: ResponseType) -> ResponseTemplate { + let response_json = match response_type { + ResponseType::Ok => json!({ + "data": { + "_entities": [ + {"reviews": [{"id": "1", "author": {"__typename": "User", "username": "@ada", "id": "1"}}, {"id": "2", "author": {"__typename": "User", "username": "@alan", "id": "2"}}]}, + {"reviews": [{"id": "3", "author": {"__typename": "User", "username": "@alan", "id": "2"}}]}, + ] + } + }), + ResponseType::Error(ErrorType::Valid) => json!({ + "data": { + "_entities": [ + {"reviews": [{"id": "1", "author": {"__typename": "User", "username": "@ada", "id": "1"}}, {"id": "2", "author": {"__typename": "User", "username": "@alan", "id": "2"}}]}, + null, + ] + }, + "errors": [{ "message": "inventory error", "path": ["_entities", 1] }] + }), + ResponseType::Error(ErrorType::EmptyPath) => json!({ + "data": { + "_entities": [ + {"reviews": [{"id": "1", "author": {"__typename": "User", "username": "@ada", "id": "1"}}, {"id": "2", "author": {"__typename": "User", "username": "@alan", "id": "2"}}]}, + null, + ] + }, + "errors": [{ "message": "inventory error", "path": [] }] + }), + ResponseType::Error(ErrorType::Malformed) => json!({"malformed": true}), + }; + ResponseTemplate::new(200).set_body_json(response_json) +} + +fn accounts_response(response_type: ResponseType) -> ResponseTemplate { + let response_json = match response_type { + ResponseType::Ok => json!({ + "data": {"_entities": [{"name": "Ada"}, {"name": "Alan"}]} + }), + ResponseType::Error(ErrorType::Valid) => json!({ + "data": {"_entities": [{"name": "Ada"}, null]}, + "errors": [{ "message": "inventory error", "path": ["_entities", 1] }] + }), + ResponseType::Error(ErrorType::EmptyPath) => json!({ + "data": {"_entities": [{"name": "Ada"}, null]}, + "errors": [{ "message": "inventory error", "path": [] }] + }), + ResponseType::Error(ErrorType::Malformed) => json!({"malformed": true}), + }; + ResponseTemplate::new(200).set_body_json(response_json) +} + +async fn send_query_to_router( + query: &str, + subgraph_response_products: ResponseTemplate, + subgraph_response_inventory: ResponseTemplate, + subgraph_response_reviews: ResponseTemplate, + subgraph_response_accounts: ResponseTemplate, +) -> Result { + let mock_products = wiremock::MockServer::start().await; + Mock::given(method("POST")) + .and(path("/")) + .respond_with(subgraph_response_products) + .mount(&mock_products) + .await; + + let mock_inventory = wiremock::MockServer::start().await; + Mock::given(method("POST")) + .and(path("/")) + .respond_with(subgraph_response_inventory) + .mount(&mock_inventory) + .await; + + let mock_reviews = wiremock::MockServer::start().await; + Mock::given(method("POST")) + .and(path("/")) + .respond_with(subgraph_response_reviews) + .mount(&mock_reviews) + .await; + + let mock_accounts = wiremock::MockServer::start().await; + Mock::given(method("POST")) + .and(path("/")) + .respond_with(subgraph_response_accounts) + .mount(&mock_accounts) + .await; + + let mut router = IntegrationTest::builder() + .config(CONFIG) + .subgraph_override("products", mock_products.uri()) + .subgraph_override("inventory", mock_inventory.uri()) + .subgraph_override("reviews", mock_reviews.uri()) + .subgraph_override("accounts", mock_accounts.uri()) + .build() + .await; + router.start().await; + router.assert_started().await; + + let query = Query::builder() + .traced(true) + .body(json!({"query": query})) + .build(); + + let (_, response) = router.execute_query(query).await; + assert_eq!(response.status(), 200); + let parsed_response = serde_json::from_str(&response.text().await?)?; + Ok(parsed_response) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_all_successful() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let response = send_query_to_router( + QUERY, + products_response(ResponseType::Ok), + inventory_response(ResponseType::Ok), + reviews_response(ResponseType::Ok), + accounts_response(ResponseType::Ok), + ) + .await?; + insta::assert_json_snapshot!(response); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_top_level_response_failure() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let response = send_query_to_router( + QUERY, + products_response(ResponseType::Error(ErrorType::Valid)), + inventory_response(ResponseType::Ok), + reviews_response(ResponseType::Ok), + accounts_response(ResponseType::Ok), + ) + .await?; + insta::assert_json_snapshot!(response); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_top_level_response_failure_malformed() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let response = send_query_to_router( + QUERY, + products_response(ResponseType::Error(ErrorType::Malformed)), + inventory_response(ResponseType::Ok), + reviews_response(ResponseType::Ok), + accounts_response(ResponseType::Ok), + ) + .await?; + insta::assert_json_snapshot!(response); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_second_level_response_failure() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let response = send_query_to_router( + QUERY, + products_response(ResponseType::Ok), + inventory_response(ResponseType::Error(ErrorType::Valid)), + reviews_response(ResponseType::Ok), + accounts_response(ResponseType::Ok), + ) + .await?; + insta::assert_json_snapshot!(response); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_second_level_response_failure_malformed() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let response = send_query_to_router( + QUERY, + products_response(ResponseType::Ok), + inventory_response(ResponseType::Error(ErrorType::Malformed)), + reviews_response(ResponseType::Ok), + accounts_response(ResponseType::Ok), + ) + .await?; + insta::assert_json_snapshot!(response); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_second_level_response_failure_empty_path() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let response = send_query_to_router( + QUERY, + products_response(ResponseType::Ok), + inventory_response(ResponseType::Error(ErrorType::EmptyPath)), + reviews_response(ResponseType::Ok), + accounts_response(ResponseType::Ok), + ) + .await?; + insta::assert_json_snapshot!(response); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_nested_response_failure() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let response = send_query_to_router( + QUERY, + products_response(ResponseType::Ok), + inventory_response(ResponseType::Ok), + reviews_response(ResponseType::Ok), + accounts_response(ResponseType::Error(ErrorType::Valid)), + ) + .await?; + insta::assert_json_snapshot!(response); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_nested_response_failure_malformed() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let response = send_query_to_router( + QUERY, + products_response(ResponseType::Ok), + inventory_response(ResponseType::Ok), + reviews_response(ResponseType::Ok), + accounts_response(ResponseType::Error(ErrorType::Malformed)), + ) + .await?; + insta::assert_json_snapshot!(response); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_nested_response_failure_404() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let response = send_query_to_router( + QUERY, + products_response(ResponseType::Ok), + inventory_response(ResponseType::Ok), + reviews_response(ResponseType::Ok), + ResponseTemplate::new(404), + ) + .await?; + insta::assert_json_snapshot!(response); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_multi_level_response_failure() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + + let response = send_query_to_router( + QUERY, + products_response(ResponseType::Ok), + inventory_response(ResponseType::Error(ErrorType::Malformed)), + reviews_response(ResponseType::Ok), + accounts_response(ResponseType::Error(ErrorType::Malformed)), + ) + .await?; + insta::assert_json_snapshot!(response); + + Ok(()) +} diff --git a/apollo-router/tests/integration/query_planner/max_evaluated_plans.rs b/apollo-router/tests/integration/query_planner/max_evaluated_plans.rs new file mode 100644 index 0000000000..d84e1b0026 --- /dev/null +++ b/apollo-router/tests/integration/query_planner/max_evaluated_plans.rs @@ -0,0 +1,141 @@ +use serde_json::json; + +use crate::integration::IntegrationTest; +use crate::integration::common::Query; + +fn assert_evaluated_plans(prom: &str, expected: u64) { + let line = prom + .lines() + .find(|line| line.starts_with("apollo_router_query_planning_plan_evaluated_plans_sum")) + .expect("apollo.router.query_planning.plan.evaluated_plans metric is missing"); + let (_, num) = line + .split_once(' ') + .expect("apollo.router.query_planning.plan.evaluated_plans metric has unexpected shape"); + assert_eq!(num, format!("{expected}")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn reports_evaluated_plans() { + let mut router = IntegrationTest::builder() + .config( + r#" + telemetry: + exporters: + metrics: + prometheus: + enabled: true + "#, + ) + .supergraph("tests/integration/fixtures/query_planner_max_evaluated_plans.graphql") + .build() + .await; + router.start().await; + router.assert_started().await; + router + .execute_query( + Query::builder() + .body(json!({ + "query": r#"{ t { v1 v2 v3 v4 } }"#, + "variables": {}, + })) + .build(), + ) + .await; + + let metrics = router + .get_metrics_response() + .await + .expect("failed to fetch metrics") + .text() + .await + .expect("metrics are not text?!"); + assert_evaluated_plans(&metrics, 16); + + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn does_not_exceed_max_evaluated_plans_legacy() { + let mut router = IntegrationTest::builder() + .config( + r#" + telemetry: + exporters: + metrics: + prometheus: + enabled: true + supergraph: + query_planning: + experimental_plans_limit: 4 + "#, + ) + .supergraph("tests/integration/fixtures/query_planner_max_evaluated_plans.graphql") + .build() + .await; + router.start().await; + router.assert_started().await; + router + .execute_query( + Query::builder() + .body(json!({ + "query": r#"{ t { v1 v2 v3 v4 } }"#, + "variables": {}, + })) + .build(), + ) + .await; + + let metrics = router + .get_metrics_response() + .await + .expect("failed to fetch metrics") + .text() + .await + .expect("metrics are not text?!"); + assert_evaluated_plans(&metrics, 4); + + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn does_not_exceed_max_evaluated_plans() { + let mut router = IntegrationTest::builder() + .config( + r#" + telemetry: + exporters: + metrics: + prometheus: + enabled: true + supergraph: + query_planning: + experimental_plans_limit: 4 + "#, + ) + .supergraph("tests/integration/fixtures/query_planner_max_evaluated_plans.graphql") + .build() + .await; + router.start().await; + router.assert_started().await; + router + .execute_query( + Query::builder() + .body(json!({ + "query": r#"{ t { v1 v2 v3 v4 } }"#, + "variables": {}, + })) + .build(), + ) + .await; + + let metrics = router + .get_metrics_response() + .await + .expect("failed to fetch metrics") + .text() + .await + .expect("metrics are not text?!"); + assert_evaluated_plans(&metrics, 4); + + router.graceful_shutdown().await; +} diff --git a/apollo-router/tests/integration/query_planner/mod.rs b/apollo-router/tests/integration/query_planner/mod.rs new file mode 100644 index 0000000000..82de414669 --- /dev/null +++ b/apollo-router/tests/integration/query_planner/mod.rs @@ -0,0 +1,106 @@ +use std::path::PathBuf; + +use crate::integration::IntegrationTest; +use crate::integration::common::graph_os_enabled; + +mod error_paths; +mod max_evaluated_plans; + +const PROMETHEUS_METRICS_CONFIG: &str = + include_str!("../telemetry/fixtures/prometheus.router.yaml"); + +#[tokio::test(flavor = "multi_thread")] +async fn fed1_schema_with_new_qp() { + let mut router = IntegrationTest::builder() + .config("{}") // Default config + .supergraph("../examples/graphql/supergraph-fed1.graphql") + .build() + .await; + router.start().await; + router + .wait_for_log_message( + "could not create router: \ + failed to initialize the query planner: \ + Supergraphs composed with federation version 1 are not supported.", + ) + .await; + router.assert_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn fed2_schema_with_new_qp() { + let mut router = IntegrationTest::builder() + .config(PROMETHEUS_METRICS_CONFIG) + .supergraph("../examples/graphql/supergraph.graphql") + .build() + .await; + router.start().await; + router.assert_started().await; + router + .assert_metrics_contains( + r#"apollo_router_lifecycle_query_planner_init_total{init_is_success="true",otel_scope_name="apollo/router"} 1"#, + None, + ) + .await; + router.execute_default_query().await; + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn context_with_new_qp() { + if !graph_os_enabled() { + return; + } + let mut router = IntegrationTest::builder() + .config("{}") // Default config + .supergraph("tests/fixtures/set_context/supergraph.graphql") + .build() + .await; + router.start().await; + router.assert_started().await; + router.execute_default_query().await; + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalid_schema_with_new_qp_fails_startup() { + let mut router = IntegrationTest::builder() + .config("{}") // Default config + .supergraph("tests/fixtures/broken-supergraph.graphql") + .build() + .await; + router.start().await; + router + .wait_for_log_message( + "could not create router: \ + Federation error: Invalid supergraph: must be a core schema", + ) + .await; + router.assert_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn valid_schema_with_new_qp_change_to_broken_schema_keeps_old_config() { + let mut router = IntegrationTest::builder() + .config(PROMETHEUS_METRICS_CONFIG) + .supergraph("tests/fixtures/valid-supergraph.graphql") + .build() + .await; + router.start().await; + router.assert_started().await; + router + .assert_metrics_contains( + r#"apollo_router_lifecycle_query_planner_init_total{init_is_success="true",otel_scope_name="apollo/router"} 1"#, + None, + ) + .await; + router.execute_default_query().await; + router + .update_schema(&PathBuf::from("tests/fixtures/broken-supergraph.graphql")) + .await; + router + .wait_for_log_message("error while reloading, continuing with previous configuration") + .await; + router.execute_default_query().await; + router.graceful_shutdown().await; +} diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__all_successful.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__all_successful.snap new file mode 100644 index 0000000000..a46f08e036 --- /dev/null +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__all_successful.snap @@ -0,0 +1,43 @@ +--- +source: apollo-router/tests/integration/query_planner/error_paths.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "Table", + "inStock": true, + "reviews": [ + { + "id": "1", + "author": { + "username": "@ada", + "name": "Ada" + } + }, + { + "id": "2", + "author": { + "username": "@alan", + "name": "Alan" + } + } + ] + }, + { + "name": "Chair", + "inStock": false, + "reviews": [ + { + "id": "3", + "author": { + "username": "@alan", + "name": "Alan" + } + } + ] + } + ] + } +} diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__multi_level_response_failure.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__multi_level_response_failure.snap new file mode 100644 index 0000000000..dfae1d9262 --- /dev/null +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__multi_level_response_failure.snap @@ -0,0 +1,114 @@ +--- +source: apollo-router/tests/integration/query_planner/error_paths.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "Table", + "inStock": null, + "reviews": [ + { + "id": "1", + "author": { + "username": "@ada", + "name": null + } + }, + { + "id": "2", + "author": { + "username": "@alan", + "name": null + } + } + ] + }, + { + "name": "Chair", + "inStock": null, + "reviews": [ + { + "id": "3", + "author": { + "username": "@alan", + "name": null + } + } + ] + } + ] + }, + "errors": [ + { + "message": "service 'inventory' response was malformed: graphql response without data must contain at least one error", + "path": [ + "topProducts", + 0 + ], + "extensions": { + "service": "inventory", + "reason": "graphql response without data must contain at least one error", + "code": "SUBREQUEST_MALFORMED_RESPONSE" + } + }, + { + "message": "service 'inventory' response was malformed: graphql response without data must contain at least one error", + "path": [ + "topProducts", + 1 + ], + "extensions": { + "service": "inventory", + "reason": "graphql response without data must contain at least one error", + "code": "SUBREQUEST_MALFORMED_RESPONSE" + } + }, + { + "message": "service 'accounts' response was malformed: graphql response without data must contain at least one error", + "path": [ + "topProducts", + 0, + "reviews", + 0, + "author" + ], + "extensions": { + "service": "accounts", + "reason": "graphql response without data must contain at least one error", + "code": "SUBREQUEST_MALFORMED_RESPONSE" + } + }, + { + "message": "service 'accounts' response was malformed: graphql response without data must contain at least one error", + "path": [ + "topProducts", + 0, + "reviews", + 1, + "author" + ], + "extensions": { + "service": "accounts", + "reason": "graphql response without data must contain at least one error", + "code": "SUBREQUEST_MALFORMED_RESPONSE" + } + }, + { + "message": "service 'accounts' response was malformed: graphql response without data must contain at least one error", + "path": [ + "topProducts", + 1, + "reviews", + 0, + "author" + ], + "extensions": { + "service": "accounts", + "reason": "graphql response without data must contain at least one error", + "code": "SUBREQUEST_MALFORMED_RESPONSE" + } + } + ] +} diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure.snap new file mode 100644 index 0000000000..0d270db235 --- /dev/null +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure.snap @@ -0,0 +1,71 @@ +--- +source: apollo-router/tests/integration/query_planner/error_paths.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "Table", + "inStock": true, + "reviews": [ + { + "id": "1", + "author": { + "username": "@ada", + "name": "Ada" + } + }, + { + "id": "2", + "author": { + "username": "@alan", + "name": null + } + } + ] + }, + { + "name": "Chair", + "inStock": false, + "reviews": [ + { + "id": "3", + "author": { + "username": "@alan", + "name": null + } + } + ] + } + ] + }, + "errors": [ + { + "message": "inventory error", + "path": [ + "topProducts", + 0, + "reviews", + 1, + "author" + ], + "extensions": { + "service": "accounts" + } + }, + { + "message": "inventory error", + "path": [ + "topProducts", + 1, + "reviews", + 0, + "author" + ], + "extensions": { + "service": "accounts" + } + } + ] +} diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_404.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_404.snap new file mode 100644 index 0000000000..13ccbc5089 --- /dev/null +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_404.snap @@ -0,0 +1,153 @@ +--- +source: apollo-router/tests/integration/query_planner/error_paths.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "Table", + "inStock": true, + "reviews": [ + { + "id": "1", + "author": { + "username": "@ada", + "name": null + } + }, + { + "id": "2", + "author": { + "username": "@alan", + "name": null + } + } + ] + }, + { + "name": "Chair", + "inStock": false, + "reviews": [ + { + "id": "3", + "author": { + "username": "@alan", + "name": null + } + } + ] + } + ] + }, + "errors": [ + { + "message": "HTTP fetch failed from 'accounts': 404: Not Found", + "path": [ + "topProducts", + 0, + "reviews", + 0, + "author" + ], + "extensions": { + "code": "SUBREQUEST_HTTP_ERROR", + "service": "accounts", + "reason": "404: Not Found", + "http": { + "status": 404 + } + } + }, + { + "message": "HTTP fetch failed from 'accounts': 404: Not Found", + "path": [ + "topProducts", + 0, + "reviews", + 1, + "author" + ], + "extensions": { + "code": "SUBREQUEST_HTTP_ERROR", + "service": "accounts", + "reason": "404: Not Found", + "http": { + "status": 404 + } + } + }, + { + "message": "HTTP fetch failed from 'accounts': 404: Not Found", + "path": [ + "topProducts", + 1, + "reviews", + 0, + "author" + ], + "extensions": { + "code": "SUBREQUEST_HTTP_ERROR", + "service": "accounts", + "reason": "404: Not Found", + "http": { + "status": 404 + } + } + }, + { + "message": "HTTP fetch failed from 'accounts': subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", + "path": [ + "topProducts", + 0, + "reviews", + 0, + "author" + ], + "extensions": { + "code": "SUBREQUEST_HTTP_ERROR", + "service": "accounts", + "reason": "subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", + "http": { + "status": 404 + } + } + }, + { + "message": "HTTP fetch failed from 'accounts': subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", + "path": [ + "topProducts", + 0, + "reviews", + 1, + "author" + ], + "extensions": { + "code": "SUBREQUEST_HTTP_ERROR", + "service": "accounts", + "reason": "subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", + "http": { + "status": 404 + } + } + }, + { + "message": "HTTP fetch failed from 'accounts': subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", + "path": [ + "topProducts", + 1, + "reviews", + 0, + "author" + ], + "extensions": { + "code": "SUBREQUEST_HTTP_ERROR", + "service": "accounts", + "reason": "subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", + "http": { + "status": 404 + } + } + } + ] +} diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_malformed.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_malformed.snap new file mode 100644 index 0000000000..31b679c697 --- /dev/null +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_malformed.snap @@ -0,0 +1,90 @@ +--- +source: apollo-router/tests/integration/query_planner/error_paths.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "Table", + "inStock": true, + "reviews": [ + { + "id": "1", + "author": { + "username": "@ada", + "name": null + } + }, + { + "id": "2", + "author": { + "username": "@alan", + "name": null + } + } + ] + }, + { + "name": "Chair", + "inStock": false, + "reviews": [ + { + "id": "3", + "author": { + "username": "@alan", + "name": null + } + } + ] + } + ] + }, + "errors": [ + { + "message": "service 'accounts' response was malformed: graphql response without data must contain at least one error", + "path": [ + "topProducts", + 0, + "reviews", + 0, + "author" + ], + "extensions": { + "service": "accounts", + "reason": "graphql response without data must contain at least one error", + "code": "SUBREQUEST_MALFORMED_RESPONSE" + } + }, + { + "message": "service 'accounts' response was malformed: graphql response without data must contain at least one error", + "path": [ + "topProducts", + 0, + "reviews", + 1, + "author" + ], + "extensions": { + "service": "accounts", + "reason": "graphql response without data must contain at least one error", + "code": "SUBREQUEST_MALFORMED_RESPONSE" + } + }, + { + "message": "service 'accounts' response was malformed: graphql response without data must contain at least one error", + "path": [ + "topProducts", + 1, + "reviews", + 0, + "author" + ], + "extensions": { + "service": "accounts", + "reason": "graphql response without data must contain at least one error", + "code": "SUBREQUEST_MALFORMED_RESPONSE" + } + } + ] +} diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure.snap new file mode 100644 index 0000000000..082d403e81 --- /dev/null +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure.snap @@ -0,0 +1,55 @@ +--- +source: apollo-router/tests/integration/query_planner/error_paths.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "Table", + "inStock": null, + "reviews": [ + { + "id": "1", + "author": { + "username": "@ada", + "name": "Ada" + } + }, + { + "id": "2", + "author": { + "username": "@alan", + "name": "Alan" + } + } + ] + }, + { + "name": "Chair", + "inStock": false, + "reviews": [ + { + "id": "3", + "author": { + "username": "@alan", + "name": "Alan" + } + } + ] + } + ] + }, + "errors": [ + { + "message": "inventory error", + "path": [ + "topProducts", + 0 + ], + "extensions": { + "service": "inventory" + } + } + ] +} diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_empty_path.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_empty_path.snap new file mode 100644 index 0000000000..983a56dbe1 --- /dev/null +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_empty_path.snap @@ -0,0 +1,65 @@ +--- +source: apollo-router/tests/integration/query_planner/error_paths.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "Table", + "inStock": null, + "reviews": [ + { + "id": "1", + "author": { + "username": "@ada", + "name": "Ada" + } + }, + { + "id": "2", + "author": { + "username": "@alan", + "name": "Alan" + } + } + ] + }, + { + "name": "Chair", + "inStock": false, + "reviews": [ + { + "id": "3", + "author": { + "username": "@alan", + "name": "Alan" + } + } + ] + } + ] + }, + "errors": [ + { + "message": "inventory error", + "path": [ + "topProducts", + 0 + ], + "extensions": { + "service": "inventory" + } + }, + { + "message": "inventory error", + "path": [ + "topProducts", + 1 + ], + "extensions": { + "service": "inventory" + } + } + ] +} diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_malformed.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_malformed.snap new file mode 100644 index 0000000000..6f155d1399 --- /dev/null +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_malformed.snap @@ -0,0 +1,69 @@ +--- +source: apollo-router/tests/integration/query_planner/error_paths.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "Table", + "inStock": null, + "reviews": [ + { + "id": "1", + "author": { + "username": "@ada", + "name": "Ada" + } + }, + { + "id": "2", + "author": { + "username": "@alan", + "name": "Alan" + } + } + ] + }, + { + "name": "Chair", + "inStock": null, + "reviews": [ + { + "id": "3", + "author": { + "username": "@alan", + "name": "Alan" + } + } + ] + } + ] + }, + "errors": [ + { + "message": "service 'inventory' response was malformed: graphql response without data must contain at least one error", + "path": [ + "topProducts", + 0 + ], + "extensions": { + "service": "inventory", + "reason": "graphql response without data must contain at least one error", + "code": "SUBREQUEST_MALFORMED_RESPONSE" + } + }, + { + "message": "service 'inventory' response was malformed: graphql response without data must contain at least one error", + "path": [ + "topProducts", + 1 + ], + "extensions": { + "service": "inventory", + "reason": "graphql response without data must contain at least one error", + "code": "SUBREQUEST_MALFORMED_RESPONSE" + } + } + ] +} diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__top_level_response_failure.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__top_level_response_failure.snap new file mode 100644 index 0000000000..d4dee22649 --- /dev/null +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__top_level_response_failure.snap @@ -0,0 +1,43 @@ +--- +source: apollo-router/tests/integration/query_planner/error_paths.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "Table", + "inStock": true, + "reviews": [ + { + "id": "1", + "author": { + "username": "@ada", + "name": "Ada" + } + }, + { + "id": "2", + "author": { + "username": "@alan", + "name": "Alan" + } + } + ] + }, + null + ] + }, + "errors": [ + { + "message": "products error", + "path": [ + "topProducts", + 1 + ], + "extensions": { + "service": "products" + } + } + ] +} diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__top_level_response_failure_malformed.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__top_level_response_failure_malformed.snap new file mode 100644 index 0000000000..b6595e8a2d --- /dev/null +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__top_level_response_failure_malformed.snap @@ -0,0 +1,20 @@ +--- +source: apollo-router/tests/integration/query_planner/error_paths.rs +expression: response +--- +{ + "data": { + "topProducts": null + }, + "errors": [ + { + "message": "service 'products' response was malformed: graphql response without data must contain at least one error", + "path": [], + "extensions": { + "service": "products", + "reason": "graphql response without data must contain at least one error", + "code": "SUBREQUEST_MALFORMED_RESPONSE" + } + } + ] +} diff --git a/apollo-router/tests/integration/redis.rs b/apollo-router/tests/integration/redis.rs index 955cbc9290..ca3bee84f5 100644 --- a/apollo-router/tests/integration/redis.rs +++ b/apollo-router/tests/integration/redis.rs @@ -1,6 +1,5 @@ // The redis cache keys in this file have to change whenever the following change: // * the supergraph schema -// * experimental_query_planner_mode // * federation version // // How to get the new cache key: @@ -23,40 +22,49 @@ // "EX" "10" // ``` +use apollo_router::Context; +use apollo_router::MockedSubgraphs; use apollo_router::plugin::test::MockSubgraph; use apollo_router::services::router; +use apollo_router::services::router::body::from_bytes; use apollo_router::services::supergraph; -use apollo_router::Context; -use apollo_router::MockedSubgraphs; use fred::cmd; +use fred::prelude::Client as RedisClient; +use fred::prelude::Config as RedisConfig; +use fred::prelude::Value as RedisValue; use fred::prelude::*; -use fred::types::ScanType; -use fred::types::Scanner; +use fred::types::scan::ScanType; +use fred::types::scan::Scanner; use futures::StreamExt; -use http::header::CACHE_CONTROL; use http::HeaderValue; use http::Method; -use serde_json::json; +use http::header::CACHE_CONTROL; use serde_json::Value; +use serde_json::json; use tower::BoxError; use tower::ServiceExt; -use crate::integration::common::graph_os_enabled; use crate::integration::IntegrationTest; +use crate::integration::common::Query; +use crate::integration::common::graph_os_enabled; +use crate::integration::response_cache::namespace; #[tokio::test(flavor = "multi_thread")] async fn query_planner_cache() -> Result<(), BoxError> { if !graph_os_enabled() { return Ok(()); } + let namespace = namespace(); // If this test fails and the cache key format changed you'll need to update the key here. // Look at the top of the file for instructions on getting the new cache key. - let known_cache_key = "plan:cache:1:federation:v2.9.3:70f115ebba5991355c17f4f56ba25bb093c519c4db49a30f3b10de279a4e3fa4:opname:3973e022e93220f9212c18d0d0c543ae7c309e46640da93a4a0314de999f5112:metadata:1cfc840090ac76a98f8bd51442f41fd6ca4c8d918b3f8d87894170745acf0734"; + let known_cache_key = &format!( + "{namespace}:plan:router:{}:47939f0e964372951934fc662c9c2be675bc7116ec3e57029abe555284eb10a4:opname:3973e022e93220f9212c18d0d0c543ae7c309e46640da93a4a0314de999f5112:metadata:d9f7a00bc249cb51cfc8599f86b6dc5272967b37b1409dc4717f105b6939fe43", + env!("CARGO_PKG_VERSION") + ); let config = RedisConfig::from_url("redis://127.0.0.1:6379").unwrap(); let client = RedisClient::new(config, None, None, None); - let connection_task = client.connect(); - client.wait_for_connect().await.unwrap(); + let connection_task = client.init().await.unwrap(); client.del::(known_cache_key).await.unwrap(); @@ -71,6 +79,7 @@ async fn query_planner_cache() -> Result<(), BoxError> { }, "redis": { "urls": ["redis://127.0.0.1:6379"], + "namespace": namespace, "ttl": "10s" } } @@ -105,7 +114,9 @@ async fn query_planner_cache() -> Result<(), BoxError> { let key = key.as_ref().unwrap().results(); println!("\t{key:?}"); } - panic!("key {known_cache_key} not found: {e}\nIf you see this error, make sure the federation version you use matches the redis key."); + panic!( + "key {known_cache_key} not found: {e}\nIf you see this error, make sure the federation version you use matches the redis key." + ); } }; let exp: i64 = client @@ -142,6 +153,7 @@ async fn query_planner_cache() -> Result<(), BoxError> { }, "redis": { "urls": ["redis://127.0.0.1:6379"], + "namespace": namespace, "ttl": "10s" } } @@ -185,11 +197,11 @@ async fn apq() -> Result<(), BoxError> { if !graph_os_enabled() { return Ok(()); } + let namespace = namespace(); let config = RedisConfig::from_url("redis://127.0.0.1:6379").unwrap(); let client = RedisClient::new(config, None, None, None); - let connection_task = client.connect(); - client.wait_for_connect().await.unwrap(); + let connection_task = client.init().await.unwrap(); let config = json!({ "apq": { @@ -200,6 +212,7 @@ async fn apq() -> Result<(), BoxError> { }, "redis": { "urls": ["redis://127.0.0.1:6379"], + "namespace": namespace, "ttl": "10s" } } @@ -219,7 +232,7 @@ async fn apq() -> Result<(), BoxError> { let query_hash = "4c45433039407593557f8a982dafd316a66ec03f0e1ed5fa1b7ef8060d76e8ec"; client - .del::(&format!("apq:{query_hash}")) + .del::(&format!("{namespace}:apq:{query_hash}")) .await .unwrap(); @@ -281,7 +294,10 @@ async fn apq() -> Result<(), BoxError> { assert!(res.data.is_some()); assert!(res.errors.is_empty()); - let s: Option = client.get(format!("apq:{query_hash}")).await.unwrap(); + let s: Option = client + .get(format!("{namespace}:apq:{query_hash}")) + .await + .unwrap(); insta::assert_snapshot!(s.unwrap()); // we start a new router with the same config @@ -329,11 +345,11 @@ async fn entity_cache_basic() -> Result<(), BoxError> { if !graph_os_enabled() { return Ok(()); } + let namespace = namespace(); let config = RedisConfig::from_url("redis://127.0.0.1:6379").unwrap(); let client = RedisClient::new(config, None, None, None); - let connection_task = client.connect(); - client.wait_for_connect().await.unwrap(); + let connection_task = client.init().await.unwrap(); let mut subgraphs = MockedSubgraphs::default(); subgraphs.insert( @@ -402,6 +418,7 @@ async fn entity_cache_basic() -> Result<(), BoxError> { "enabled": false, "redis": { "urls": ["redis://127.0.0.1:6379"], + "namespace": namespace, "ttl": "2s", "pool_size": 3 }, @@ -448,14 +465,15 @@ async fn entity_cache_basic() -> Result<(), BoxError> { .unwrap(); insta::assert_json_snapshot!(response); + // if this is failing due to a cache key change, hook up redis-cli with the MONITOR command to see the keys being set let s:String = client - .get("version:1.0:subgraph:products:type:Query:hash:0b4d791a3403d76643db0a9e4a8d304b1cd1f8c4ab68cb58ab7ccdc116a1da1c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c") + .get(format!("{namespace}:version:1.0:subgraph:products:type:Query:hash:6422a4ef561035dd94b357026091b72dca07429196aed0342e9e32cc1d48a13f:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")) .await .unwrap(); let v: Value = serde_json::from_str(&s).unwrap(); insta::assert_json_snapshot!(v.as_object().unwrap().get("data").unwrap()); - let s: String = client.get("version:1.0:subgraph:reviews:type:Product:entity:4911f7a9dbad8a47b8900d65547503a2f3c0359f65c0bc5652ad9b9843281f66:hash:04c47a3b857394fb0feef5b999adc073b8ab7416e3bc871f54c0b885daae8359:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c").await.unwrap(); + let s: String = client.get(format!("{namespace}:version:1.0:subgraph:reviews:type:Product:entity:72bafad9ffe61307806863b13856470e429e0cf332c99e5b735224fb0b1436f7:representation::hash:3cede4e233486ac841993dd8fc0662ef375351481eeffa8e989008901300a693:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")).await.unwrap(); let v: Value = serde_json::from_str(&s).unwrap(); insta::assert_json_snapshot!(v.as_object().unwrap().get("data").unwrap()); @@ -521,6 +539,7 @@ async fn entity_cache_basic() -> Result<(), BoxError> { "enabled": false, "redis": { "urls": ["redis://127.0.0.1:6379"], + "namespace": namespace, "ttl": "2s" }, }, @@ -545,7 +564,7 @@ async fn entity_cache_basic() -> Result<(), BoxError> { } })) .unwrap() - .extra_plugin(subgraphs) + .extra_plugin(subgraphs.clone()) .schema(include_str!("../fixtures/supergraph-auth.graphql")) .build_supergraph() .await @@ -567,12 +586,122 @@ async fn entity_cache_basic() -> Result<(), BoxError> { insta::assert_json_snapshot!(response); let s:String = client - .get("version:1.0:subgraph:reviews:type:Product:entity:d9a4cd73308dd13ca136390c10340823f94c335b9da198d2339c886c738abf0d:hash:04c47a3b857394fb0feef5b999adc073b8ab7416e3bc871f54c0b885daae8359:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c") + .get(format!("{namespace}:version:1.0:subgraph:reviews:type:Product:entity:080fc430afd3fb953a05525a6a00999226c34436466eff7ace1d33d004adaae3:representation::hash:3cede4e233486ac841993dd8fc0662ef375351481eeffa8e989008901300a693:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")) .await .unwrap(); let v: Value = serde_json::from_str(&s).unwrap(); insta::assert_json_snapshot!(v.as_object().unwrap().get("data").unwrap()); + const SECRET_SHARED_KEY: &str = "supersecret"; + let http_service = apollo_router::TestHarness::builder() + .with_subgraph_network_requests() + .configuration_json(json!({ + "preview_entity_cache": { + "enabled": true, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": "/invalidation" + }, + "subgraph": { + "all": { + "enabled": true, + "redis": { + "urls": ["redis://127.0.0.1:6379"], + "namespace": namespace, + "ttl": "2s" + }, + "invalidation": { + "enabled": true, + "shared_key": SECRET_SHARED_KEY + } + }, + "subgraphs": { + "products": { + "enabled": true, + "ttl": "60s", + "invalidation": { + "enabled": true, + "shared_key": SECRET_SHARED_KEY + } + }, + "reviews": { + "enabled": true, + "ttl": "10s", + "invalidation": { + "enabled": true, + "shared_key": SECRET_SHARED_KEY + } + } + } + } + }, + "include_subgraph_errors": { + "all": true + }, + "supergraph": { + // TODO(@goto-bus-stop): need to update the mocks and remove this, #6013 + "generate_query_fragments": false, + } + })) + .unwrap() + .extra_plugin(subgraphs.clone()) + .schema(include_str!("../fixtures/supergraph-auth.graphql")) + .build_http_service() + .await + .unwrap(); + + let request = http::Request::builder() + .uri("http://127.0.0.1:4000/invalidation") + .method(http::Method::POST) + .header( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ) + .header( + http::header::AUTHORIZATION, + HeaderValue::from_static(SECRET_SHARED_KEY), + ) + .body(from_bytes( + serde_json::to_vec(&vec![json!({ + "subgraph": "reviews", + "kind": "entity", + "type": "Product", + "key": { + "upc": "3" + } + })]) + .unwrap(), + )) + .unwrap(); + let response = http_service.oneshot(request).await.unwrap(); + let response_status = response.status(); + let mut resp: serde_json::Value = serde_json::from_str( + &apollo_router::services::router::body::into_string(response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + + assert_eq!( + resp.as_object_mut() + .unwrap() + .get("count") + .unwrap() + .as_u64() + .unwrap(), + 1u64 + ); + assert!(response_status.is_success()); + + // This should be in error because we invalidated this entity + assert!(client + .get::(format!("{namespace}:version:1.0:subgraph:reviews:type:Product:entity:080fc430afd3fb953a05525a6a00999226c34436466eff7ace1d33d004adaae3:representation::hash:b9b8a9c94830cf56329ec2db7d7728881a6ba19cc1587710473e732e775a5870:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")) + .await.is_err()); + // This entry should still be in redis because we didn't invalidate this entry + assert!(client + .get::(format!("{namespace}:version:1.0:subgraph:products:type:Query:hash:9916d7d8b8c700177e1ba52947c402ad219bf372805a30cb71fee8e76c52b4f0:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")) + .await.is_ok()); + client.quit().await.unwrap(); // calling quit ends the connection and event listener tasks let _ = connection_task.await; @@ -580,16 +709,293 @@ async fn entity_cache_basic() -> Result<(), BoxError> { } #[tokio::test(flavor = "multi_thread")] -async fn entity_cache_authorization() -> Result<(), BoxError> { +async fn entity_cache_with_nested_field_set() -> Result<(), BoxError> { if !graph_os_enabled() { return Ok(()); } + let namespace = namespace(); + let schema = include_str!("../../src/testdata/supergraph_nested_fields.graphql"); let config = RedisConfig::from_url("redis://127.0.0.1:6379").unwrap(); let client = RedisClient::new(config, None, None, None); let connection_task = client.connect(); client.wait_for_connect().await.unwrap(); + let subgraphs = serde_json::json!({ + "products": { + "query": {"allProducts": [{ + "id": "1", + "name": "Test", + "sku": "150", + "createdBy": { "__typename": "User", "email": "test@test.com", "country": {"a": "France"} } + }]}, + "headers": {"cache-control": "public"}, + }, + "users": { + "entities": [{ + "__typename": "User", + "email": "test@test.com", + "name": "test", + "country": { + "a": "France" + } + }], + "headers": {"cache-control": "public"}, + } + }); + + let supergraph = apollo_router::TestHarness::builder() + .with_subgraph_network_requests() + .configuration_json(json!({ + "preview_entity_cache": { + "enabled": true, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": "/invalidation" + }, + "subgraph": { + "all": { + "enabled": true, + "redis": { + "urls": ["redis://127.0.0.1:6379"], + "namespace": namespace, + "ttl": "2s", + "pool_size": 3 + }, + } + } + }, + "include_subgraph_errors": { + "all": true + }, + "experimental_mock_subgraphs": subgraphs.clone() + })) + .unwrap() + .schema(schema) + .build_supergraph() + .await + .unwrap(); + let query = "query { allProducts { name createdBy { name country { a } } } }"; + + let request = supergraph::Request::fake_builder() + .query(query) + .method(Method::POST) + .build() + .unwrap(); + + let response = supergraph + .oneshot(request) + .await + .unwrap() + .next_response() + .await + .unwrap(); + insta::assert_json_snapshot!(response); + + // if this is failing due to a cache key change, hook up redis-cli with the MONITOR command to see the keys being set + let s:String = client + .get(format!("{namespace}:version:1.0:subgraph:products:type:Query:hash:6173063a04125ecfdaf77111980dc68921dded7813208fdf1d7d38dfbb959627:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")) + .await + .unwrap(); + let v: Value = serde_json::from_str(&s).unwrap(); + insta::assert_json_snapshot!(v.as_object().unwrap().get("data").unwrap()); + + let s: String = client + .get(format!("{namespace}:version:1.0:subgraph:users:type:User:entity:210e26346d676046faa9fb55d459273a43e5b5397a1a056f179a3521dc5643aa:representation:7cd02a08f4ea96f0affa123d5d3f56abca20e6014e060fe5594d210c00f64b27:hash:2820563c632c1ab498e06030084acf95c97e62afba71a3d4b7c5e81a11cb4d13:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")) + .await + .unwrap(); + let v: Value = serde_json::from_str(&s).unwrap(); + insta::assert_json_snapshot!(v.as_object().unwrap().get("data").unwrap()); + + let supergraph = apollo_router::TestHarness::builder() + .with_subgraph_network_requests() + .configuration_json(json!({ + "preview_entity_cache": { + "enabled": true, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": "/invalidation" + }, + "subgraph": { + "all": { + "enabled": false, + "redis": { + "urls": ["redis://127.0.0.1:6379"], + "namespace": namespace, + "ttl": "2s" + }, + }, + "subgraphs": { + "products": { + "enabled": true, + "ttl": "60s" + }, + "reviews": { + "enabled": true, + "ttl": "10s" + } + } + } + }, + "include_subgraph_errors": { + "all": true + }, + "experimental_mock_subgraphs": subgraphs.clone() + })) + .unwrap() + .schema(schema) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .method(Method::POST) + .build() + .unwrap(); + + let response = supergraph + .oneshot(request) + .await + .unwrap() + .next_response() + .await + .unwrap(); + insta::assert_json_snapshot!(response); + + let s: String = client + .get(format!("{namespace}:version:1.0:subgraph:users:type:User:entity:210e26346d676046faa9fb55d459273a43e5b5397a1a056f179a3521dc5643aa:representation:7cd02a08f4ea96f0affa123d5d3f56abca20e6014e060fe5594d210c00f64b27:hash:2820563c632c1ab498e06030084acf95c97e62afba71a3d4b7c5e81a11cb4d13:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")) + .await + .unwrap(); + let v: Value = serde_json::from_str(&s).unwrap(); + insta::assert_json_snapshot!(v.as_object().unwrap().get("data").unwrap()); + + const SECRET_SHARED_KEY: &str = "supersecret"; + let http_service = apollo_router::TestHarness::builder() + .with_subgraph_network_requests() + .configuration_json(json!({ + "preview_entity_cache": { + "enabled": true, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": "/invalidation" + }, + "subgraph": { + "all": { + "enabled": true, + "redis": { + "urls": ["redis://127.0.0.1:6379"], + "namespace": namespace, + "ttl": "5s" + }, + "invalidation": { + "enabled": true, + "shared_key": SECRET_SHARED_KEY + } + }, + "subgraphs": { + "products": { + "enabled": true, + "ttl": "60s", + "invalidation": { + "enabled": true, + "shared_key": SECRET_SHARED_KEY + } + }, + "reviews": { + "enabled": true, + "ttl": "10s", + "invalidation": { + "enabled": true, + "shared_key": SECRET_SHARED_KEY + } + } + } + } + }, + "include_subgraph_errors": { + "all": true + }, + "experimental_mock_subgraphs": subgraphs.clone() + })) + .unwrap() + .schema(schema) + .build_http_service() + .await + .unwrap(); + + let request = http::Request::builder() + .uri("http://127.0.0.1:4000/invalidation") + .method(http::Method::POST) + .header( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ) + .header( + http::header::AUTHORIZATION, + HeaderValue::from_static(SECRET_SHARED_KEY), + ) + .body(from_bytes( + serde_json::to_vec(&vec![json!({ + "subgraph": "users", + "kind": "entity", + "type": "User", + "key": { + "email": "test@test.com", + "country": { + "a": "France" + } + } + })]) + .unwrap(), + )) + .unwrap(); + let response = http_service.oneshot(request).await.unwrap(); + let response_status = response.status(); + let mut resp: serde_json::Value = serde_json::from_str( + &apollo_router::services::router::body::into_string(response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + + assert_eq!( + resp.as_object_mut() + .unwrap() + .get("count") + .unwrap() + .as_u64() + .unwrap(), + 1u64 + ); + assert!(response_status.is_success()); + + // This should be in error because we invalidated this entity + assert!(client + .get::(format!("{namespace}:version:1.0:subgraph:users:type:User:entity:210e26346d676046faa9fb55d459273a43e5b5397a1a056f179a3521dc5643aa:representation:7cd02a08f4ea96f0affa123d5d3f56abca20e6014e060fe5594d210c00f64b27:hash:cfc5f467f767710804724ff6a05c3f63297328cd8283316adb25f5642e1439ad:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")) + .await.is_err()); + // This entry should still be in redis because we didn't invalidate this entry + assert!(client + .get::(format!("{namespace}:version:1.0:subgraph:products:type:Query:hash:6173063a04125ecfdaf77111980dc68921dded7813208fdf1d7d38dfbb959627:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")) + .await.is_ok()); + + client.quit().await.unwrap(); + // calling quit ends the connection and event listener tasks + let _ = connection_task.await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn entity_cache_authorization() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let namespace = namespace(); + + let config = RedisConfig::from_url("redis://127.0.0.1:6379").unwrap(); + let client = RedisClient::new(config, None, None, None); + let connection_task = client.init().await.unwrap(); + let mut subgraphs = MockedSubgraphs::default(); subgraphs.insert( "accounts", @@ -736,6 +1142,7 @@ async fn entity_cache_authorization() -> Result<(), BoxError> { "enabled": false, "redis": { "urls": ["redis://127.0.0.1:6379"], + "namespace": namespace, "ttl": "2s" }, }, @@ -774,7 +1181,7 @@ async fn entity_cache_authorization() -> Result<(), BoxError> { let context = Context::new(); context .insert( - "apollo_authorization::scopes::required", + "apollo::authorization::required_scopes", json! {["profile", "read:user", "read:name"]}, ) .unwrap(); @@ -796,7 +1203,7 @@ async fn entity_cache_authorization() -> Result<(), BoxError> { insta::assert_json_snapshot!(response); let s:String = client - .get("version:1.0:subgraph:products:type:Query:hash:0b4d791a3403d76643db0a9e4a8d304b1cd1f8c4ab68cb58ab7ccdc116a1da1c:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c") + .get(format!("{namespace}:version:1.0:subgraph:products:type:Query:hash:6422a4ef561035dd94b357026091b72dca07429196aed0342e9e32cc1d48a13f:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")) .await .unwrap(); let v: Value = serde_json::from_str(&s).unwrap(); @@ -817,7 +1224,7 @@ async fn entity_cache_authorization() -> Result<(), BoxError> { ); let s: String = client - .get("version:1.0:subgraph:reviews:type:Product:entity:4911f7a9dbad8a47b8900d65547503a2f3c0359f65c0bc5652ad9b9843281f66:hash:04c47a3b857394fb0feef5b999adc073b8ab7416e3bc871f54c0b885daae8359:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c") + .get(format!("{namespace}:version:1.0:subgraph:reviews:type:Product:entity:72bafad9ffe61307806863b13856470e429e0cf332c99e5b735224fb0b1436f7:representation::hash:3cede4e233486ac841993dd8fc0662ef375351481eeffa8e989008901300a693:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")) .await .unwrap(); let v: Value = serde_json::from_str(&s).unwrap(); @@ -833,13 +1240,13 @@ async fn entity_cache_authorization() -> Result<(), BoxError> { let context = Context::new(); context .insert( - "apollo_authorization::scopes::required", + "apollo::authorization::required_scopes", json! {["profile", "read:user", "read:name"]}, ) .unwrap(); context .insert( - "apollo_authentication::JWT::claims", + "apollo::authentication::jwt_claims", json! {{ "scope": "read:user read:name" }}, ) .unwrap(); @@ -861,7 +1268,7 @@ async fn entity_cache_authorization() -> Result<(), BoxError> { insta::assert_json_snapshot!(response); let s:String = client - .get("version:1.0:subgraph:reviews:type:Product:entity:4911f7a9dbad8a47b8900d65547503a2f3c0359f65c0bc5652ad9b9843281f66:hash:f7d6d3af2706afe346e3d5fd353e61bd186d2fc64cb7b3c13a62162189519b5f:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c") + .get(format!("{namespace}:version:1.0:subgraph:reviews:type:Product:entity:72bafad9ffe61307806863b13856470e429e0cf332c99e5b735224fb0b1436f7:representation::hash:cb85bbec2ae755057b4229863ea810c364179017179eba6a11afe1e247afd322:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")) .await .unwrap(); let v: Value = serde_json::from_str(&s).unwrap(); @@ -874,17 +1281,41 @@ async fn entity_cache_authorization() -> Result<(), BoxError> { }] }} ); + let s:String = client + .get(format!("{namespace}:version:1.0:subgraph:reviews:type:Product:entity:472484d4df9e800bbb846447c4c077787860c4c9ec59579d50009bfcba275c3b:representation::hash:cb85bbec2ae755057b4229863ea810c364179017179eba6a11afe1e247afd322:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c")) + .await + .unwrap(); + let v: Value = serde_json::from_str(&s).unwrap(); + assert_eq!( + v.as_object().unwrap().get("data").unwrap(), + &json! {{ + "reviews": [{ + "body": "I can sit on it", + "author": { + "__typename": "User", + "id": "1" + } + }, + { + "body": "I can eat on it", + "author": { + "__typename": "User", + "id": "2" + } + }] + }} + ); let context = Context::new(); context .insert( - "apollo_authorization::scopes::required", + "apollo::authorization::required_scopes", json! {["profile", "read:user", "read:name"]}, ) .unwrap(); context .insert( - "apollo_authentication::JWT::claims", + "apollo::authentication::jwt_claims", json! {{ "scope": "read:user profile" }}, ) .unwrap(); @@ -966,33 +1397,13 @@ async fn connection_failure_blocks_startup() { //OSX has a different error code for connection refused let e = e.to_string().replace("61", "111"); // assert_eq!( - e, - "couldn't build Router service: IO Error: Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" }" - ); + e, + "couldn't build Router service: IO Error: Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" }" + ); } #[tokio::test(flavor = "multi_thread")] async fn query_planner_redis_update_query_fragments() { - test_redis_query_plan_config_update( - // This configuration turns the fragment generation option *off*. - include_str!("fixtures/query_planner_redis_config_update_query_fragments.router.yaml"), - "plan:cache:1:federation:v2.9.3:e15b4f5cd51b8cc728e3f5171611073455601e81196cd3cbafc5610d9769a370:opname:3973e022e93220f9212c18d0d0c543ae7c309e46640da93a4a0314de999f5112:metadata:0ade8e18db172d9d51b36a2112513c15032d103100644df418a50596de3adfba", - ) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -#[ignore = "the cache key for different query planner modes is currently different"] -async fn query_planner_redis_update_planner_mode() { - test_redis_query_plan_config_update( - include_str!("fixtures/query_planner_redis_config_update_query_planner_mode.router.yaml"), - "", - ) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn query_planner_redis_update_defer() { // If this test fails and the cache key format changed you'll need to update // the key here. Look at the top of the file for instructions on getting // the new cache key. @@ -1005,14 +1416,18 @@ async fn query_planner_redis_update_defer() { // "TEST_APOLLO_KEY" and "TEST_APOLLO_GRAPH_REF" env vars set, otherwise the // test just passes locally. test_redis_query_plan_config_update( - include_str!("fixtures/query_planner_redis_config_update_defer.router.yaml"), - "plan:cache:1:federation:v2.9.3:e15b4f5cd51b8cc728e3f5171611073455601e81196cd3cbafc5610d9769a370:opname:3973e022e93220f9212c18d0d0c543ae7c309e46640da93a4a0314de999f5112:metadata:066f41523274aed2428e0f08c9de077ee748a1d8470ec31edb5224030a198f3b", + // This configuration turns the fragment generation option *off*. + include_str!("fixtures/query_planner_redis_config_update_query_fragments.router.yaml"), + &format!( + "plan:router:{}:14ece7260081620bb49f1f4934cf48510e5f16c3171181768bb46a5609d7dfb7:opname:3973e022e93220f9212c18d0d0c543ae7c309e46640da93a4a0314de999f5112:metadata:fb1a8e6e454ad6a1d0d48b24dc9c7c4dd6d9bf58b6fdaf43cd24eb77fbbb3a17", + env!("CARGO_PKG_VERSION") + ), ) .await; } #[tokio::test(flavor = "multi_thread")] -async fn query_planner_redis_update_type_conditional_fetching() { +async fn query_planner_redis_update_defer() { // If this test fails and the cache key format changed you'll need to update // the key here. Look at the top of the file for instructions on getting // the new cache key. @@ -1025,16 +1440,17 @@ async fn query_planner_redis_update_type_conditional_fetching() { // "TEST_APOLLO_KEY" and "TEST_APOLLO_GRAPH_REF" env vars set, otherwise the // test just passes locally. test_redis_query_plan_config_update( - include_str!( - "fixtures/query_planner_redis_config_update_type_conditional_fetching.router.yaml" + include_str!("fixtures/query_planner_redis_config_update_defer.router.yaml"), + &format!( + "plan:router:{}:14ece7260081620bb49f1f4934cf48510e5f16c3171181768bb46a5609d7dfb7:opname:3973e022e93220f9212c18d0d0c543ae7c309e46640da93a4a0314de999f5112:metadata:dc062fcc9cfd9582402d1e8b1fa3ee336ea1804d833443869e0b3744996716a2", + env!("CARGO_PKG_VERSION") ), - "plan:cache:1:federation:v2.9.3:e15b4f5cd51b8cc728e3f5171611073455601e81196cd3cbafc5610d9769a370:opname:3973e022e93220f9212c18d0d0c543ae7c309e46640da93a4a0314de999f5112:metadata:b31d320db1af4015998cc89027f0ede2305dcc61724365e9b76d4252f90c7677", ) .await; } #[tokio::test(flavor = "multi_thread")] -async fn query_planner_redis_update_reuse_query_fragments() { +async fn query_planner_redis_update_type_conditional_fetching() { // If this test fails and the cache key format changed you'll need to update // the key here. Look at the top of the file for instructions on getting // the new cache key. @@ -1048,9 +1464,12 @@ async fn query_planner_redis_update_reuse_query_fragments() { // test just passes locally. test_redis_query_plan_config_update( include_str!( - "fixtures/query_planner_redis_config_update_reuse_query_fragments.router.yaml" + "fixtures/query_planner_redis_config_update_type_conditional_fetching.router.yaml" + ), + &format!( + "plan:router:{}:14ece7260081620bb49f1f4934cf48510e5f16c3171181768bb46a5609d7dfb7:opname:3973e022e93220f9212c18d0d0c543ae7c309e46640da93a4a0314de999f5112:metadata:bdc09980aa6ef28a67f5aeb8759763d8ac5a4fc43afa8c5a89f58cc998c48db3", + env!("CARGO_PKG_VERSION") ), - "plan:cache:1:federation:v2.9.3:e15b4f5cd51b8cc728e3f5171611073455601e81196cd3cbafc5610d9769a370:opname:3973e022e93220f9212c18d0d0c543ae7c309e46640da93a4a0314de999f5112:metadata:d54414eeede3a1bf631d88a84a1e3a354683be87746e79a69769cf18d919cc01", ) .await; } @@ -1074,15 +1493,52 @@ async fn test_redis_query_plan_config_update(updated_config: &str, new_cache_key router.assert_started().await; router.clear_redis_cache().await; - let starting_key = "plan:cache:1:federation:v2.9.3:e15b4f5cd51b8cc728e3f5171611073455601e81196cd3cbafc5610d9769a370:opname:3973e022e93220f9212c18d0d0c543ae7c309e46640da93a4a0314de999f5112:metadata:1cfc840090ac76a98f8bd51442f41fd6ca4c8d918b3f8d87894170745acf0734"; - assert_ne!(starting_key, new_cache_key, "starting_key (cache key for the initial config) and new_cache_key (cache key with the updated config) should not be equal. This either means that the cache key is not being generated correctly, or that the test is not actually checking the updated key."); + // If the tests above are failing, this is the key that needs to be changed first. + let starting_key = &format!( + "plan:router:{}:14ece7260081620bb49f1f4934cf48510e5f16c3171181768bb46a5609d7dfb7:opname:3973e022e93220f9212c18d0d0c543ae7c309e46640da93a4a0314de999f5112:metadata:d9f7a00bc249cb51cfc8599f86b6dc5272967b37b1409dc4717f105b6939fe43", + env!("CARGO_PKG_VERSION") + ); + assert_ne!( + starting_key, new_cache_key, + "starting_key (cache key for the initial config) and new_cache_key (cache key with the updated config) should not be equal. This either means that the cache key is not being generated correctly, or that the test is not actually checking the updated key." + ); - router.execute_default_query().await; + router + .execute_query(Query::default().with_anonymous()) + .await; router.assert_redis_cache_contains(starting_key, None).await; router.update_config(updated_config).await; router.assert_reloaded().await; - router.execute_default_query().await; + router + .execute_query(Query::default().with_anonymous()) + .await; router .assert_redis_cache_contains(new_cache_key, Some(starting_key)) .await; } + +#[tokio::test(flavor = "multi_thread")] +async fn test_redis_connections_are_closed_on_router_reload() { + if !graph_os_enabled() { + return; + } + + let router_config = include_str!("fixtures/redis_connection_closure.router.yaml"); + let mut router = IntegrationTest::builder() + .config(router_config) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let expected_metric = r#"apollo_router_cache_redis_connections{kind="query planner",otel_scope_name="apollo/router"} 4"#; + router.assert_metrics_contains(expected_metric, None).await; + + // check that reloading the schema yields the same number of redis connections + let new_router_config = format!("{router_config}\ninclude_subgraph_errors:\n all: true"); + router.update_config(&new_router_config).await; + router.assert_reloaded().await; + + router.assert_metrics_contains(expected_metric, None).await; +} diff --git a/apollo-router/tests/integration/response_cache.rs b/apollo-router/tests/integration/response_cache.rs new file mode 100644 index 0000000000..fecddb8eb1 --- /dev/null +++ b/apollo-router/tests/integration/response_cache.rs @@ -0,0 +1,497 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use apollo_router::graphql; +use apollo_router::services; +use apollo_router::test_harness::HttpService; +use http::HeaderMap; +use http_body_util::BodyExt as _; +use indexmap::IndexMap; +use serde_json::json; +use tower::Service as _; +use tower::ServiceExt as _; + +use crate::integration::common::graph_os_enabled; + +const INVALIDATION_PATH: &str = "/invalidation"; +const INVALIDATION_SHARED_KEY: &str = "supersecret"; + +/// Isolate tests from each other by adding a random redis key prefix +pub(crate) fn namespace() -> String { + uuid::Uuid::new_v4().simple().to_string() +} + +fn base_config() -> serde_json::Value { + json!({ + "include_subgraph_errors": { + "all": true, + }, + "experimental_response_cache": { + "enabled": true, + "subgraph": { + "all": { + "postgres": { + "url": "postgres://127.0.0.1", + "pool_size": 3, + "namespace": namespace(), + "required_to_start": true, + }, + "ttl": "10m", + "invalidation": { + "enabled": true, + "shared_key": INVALIDATION_SHARED_KEY, + }, + }, + }, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": INVALIDATION_PATH, + }, + }, + }) +} + +fn failure_config() -> serde_json::Value { + json!({ + "include_subgraph_errors": { + "all": true, + }, + "experimental_response_cache": { + "enabled": true, + "subgraph": { + "all": { + "postgres": { + "url": "postgres://test", + "pool_size": 3, + "namespace": namespace(), + "required_to_start": false, + }, + "ttl": "10m", + "invalidation": { + "enabled": true, + "shared_key": INVALIDATION_SHARED_KEY, + }, + }, + }, + "invalidation": { + "listen": "127.0.0.1:4000", + "path": INVALIDATION_PATH, + }, + }, + }) +} + +fn base_subgraphs() -> serde_json::Value { + json!({ + "products": { + "headers": {"cache-control": "public"}, + "query": { + "topProducts": [ + {"upc": "1", "__cacheTags": ["topProducts"]}, + {"upc": "2"}, + ], + }, + }, + "reviews": { + "headers": {"cache-control": "public"}, + "entities": [ + { + "__cacheTags": ["product-1"], + "__typename": "Product", + "upc": "1", + "reviews": [{"id": "r1a"}, {"id": "r1b"}], + }, + { + "__cacheTags": ["product-2"], + "__typename": "Product", + "upc": "2", + "reviews": [{"id": "r2"}], + }, + ], + }, + }) +} + +async fn harness( + mut config: serde_json::Value, + subgraphs: serde_json::Value, +) -> (HttpService, Arc>>) { + let counters = Arc::new(IndexMap::from([ + ("products".into(), Default::default()), + ("reviews".into(), Default::default()), + ])); + let counters2 = Arc::clone(&counters); + config + .as_object_mut() + .unwrap() + .insert("experimental_mock_subgraphs".into(), subgraphs); + let router = apollo_router::TestHarness::builder() + .schema(include_str!("../../testing_schema.graphql")) + .configuration_json(config) + .unwrap() + .subgraph_hook(move |subgraph_name, service| { + if let Some(counter) = counters2.get(subgraph_name) { + let counter = Arc::::clone(counter); + service + .map_request(move |req| { + counter.fetch_add(1, Ordering::Relaxed); + req + }) + .boxed() + } else { + service + } + }) + .build_http_service() + .await + .unwrap(); + (router, counters) +} + +async fn make_graphql_request(router: &mut HttpService) -> (HeaderMap, graphql::Response) { + let query = "{ topProducts { reviews { id } } }"; + let request = graphql_request(query); + make_http_request(router, request.into()).await +} + +fn graphql_request(query: &str) -> services::router::Request { + services::supergraph::Request::fake_builder() + .query(query) + .build() + .unwrap() + .try_into() + .unwrap() +} + +async fn make_json_request( + router: &mut HttpService, + request: http::Request, +) -> (HeaderMap, serde_json::Value) { + let request = + request.map(|body| services::router::body::from_bytes(serde_json::to_vec(&body).unwrap())); + make_http_request(router, request).await +} + +async fn make_http_request( + router: &mut HttpService, + request: http::Request, +) -> (HeaderMap, ResponseBody) +where + ResponseBody: for<'a> serde::Deserialize<'a>, +{ + let response = router.ready().await.unwrap().call(request).await.unwrap(); + let headers = response + .headers() + .iter() + .map(|(k, v)| (k.clone(), v.to_str().unwrap().to_owned())) + .collect(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (headers, serde_json::from_slice(&body).unwrap()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn basic_cache_skips_subgraph_request() { + if !graph_os_enabled() { + return; + } + + let (mut router, subgraph_request_counters) = harness(base_config(), base_subgraphs()).await; + insta::assert_yaml_snapshot!(subgraph_request_counters, @r" + products: 0 + reviews: 0 + "); + let (headers, body) = make_graphql_request(&mut router).await; + assert!(headers["cache-control"].contains("public")); + insta::assert_yaml_snapshot!(body, @r" + data: + topProducts: + - reviews: + - id: r1a + - id: r1b + - reviews: + - id: r2 + "); + insta::assert_yaml_snapshot!(subgraph_request_counters, @r" + products: 1 + reviews: 1 + "); + // Needed because insert in the cache is async + tokio::time::sleep(Duration::from_millis(100)).await; + let (headers, body) = make_graphql_request(&mut router).await; + assert!(headers["cache-control"].contains("public")); + insta::assert_yaml_snapshot!(body, @r" + data: + topProducts: + - reviews: + - id: r1a + - id: r1b + - reviews: + - id: r2 + "); + // Unchanged, everything is in cache so we don’t need to make more subgraph requests: + insta::assert_yaml_snapshot!(subgraph_request_counters, @r" + products: 1 + reviews: 1 + "); +} + +#[tokio::test(flavor = "multi_thread")] +async fn no_failure_when_unavailable_pg() { + if !graph_os_enabled() { + return; + } + + let (mut router, subgraph_request_counters) = harness(failure_config(), base_subgraphs()).await; + insta::assert_yaml_snapshot!(subgraph_request_counters, @r" + products: 0 + reviews: 0 + "); + let (headers, body) = make_graphql_request(&mut router).await; + assert!(headers["cache-control"].contains("public")); + insta::assert_yaml_snapshot!(body, @r" + data: + topProducts: + - reviews: + - id: r1a + - id: r1b + - reviews: + - id: r2 + "); + insta::assert_yaml_snapshot!(subgraph_request_counters, @r" + products: 1 + reviews: 1 + "); + let (headers, body) = make_graphql_request(&mut router).await; + assert!(headers["cache-control"].contains("public")); + insta::assert_yaml_snapshot!(body, @r" + data: + topProducts: + - reviews: + - id: r1a + - id: r1b + - reviews: + - id: r2 + "); + // Would have been unchanged because both subgraph requests were cacheable, + // but cache storage isn’t available to we fall back to calling the subgraph again + insta::assert_yaml_snapshot!(subgraph_request_counters, @r" + products: 2 + reviews: 2 + "); +} + +#[tokio::test(flavor = "multi_thread")] +async fn not_cached_without_cache_control_header() { + if !graph_os_enabled() { + return; + } + + let mut subgraphs = base_subgraphs(); + subgraphs["products"] + .as_object_mut() + .unwrap() + .remove("headers"); + subgraphs["reviews"] + .as_object_mut() + .unwrap() + .remove("headers"); + let (mut router, subgraph_request_counters) = harness(base_config(), subgraphs).await; + insta::assert_yaml_snapshot!(subgraph_request_counters, @r" + products: 0 + reviews: 0 + "); + let (headers, body) = make_graphql_request(&mut router).await; + // When subgraphs don’t set a cache-control header, Router defaults to not caching + // and instructs any downstream cache to do the same: + assert_eq!(headers["cache-control"], "no-store"); + insta::assert_yaml_snapshot!(body, @r" + data: + topProducts: + - reviews: + - id: r1a + - id: r1b + - reviews: + - id: r2 + "); + insta::assert_yaml_snapshot!(subgraph_request_counters, @r" + products: 1 + reviews: 1 + "); + // Needed because insert in the cache is async + tokio::time::sleep(Duration::from_millis(100)).await; + + let (headers, body) = make_graphql_request(&mut router).await; + assert_eq!(headers["cache-control"], "no-store"); + insta::assert_yaml_snapshot!(body, @r" + data: + topProducts: + - reviews: + - id: r1a + - id: r1b + - reviews: + - id: r2 + "); + // More supergraph requsets lead to more subgraph requests: + insta::assert_yaml_snapshot!(subgraph_request_counters, @r" + products: 2 + reviews: 2 + "); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalidate_with_endpoint_by_type() { + if !graph_os_enabled() { + return; + } + + let (mut router, subgraph_request_counters) = harness(base_config(), base_subgraphs()).await; + let (headers, body) = make_graphql_request(&mut router).await; + assert!(headers["cache-control"].contains("public")); + assert!(body.errors.is_empty()); + insta::assert_yaml_snapshot!(subgraph_request_counters, @r" + products: 1 + reviews: 1 + "); + let request = http::Request::builder() + .method("POST") + .uri(INVALIDATION_PATH) + .header("Authorization", INVALIDATION_SHARED_KEY) + .body(json!([{ + "kind": "type", + "subgraph": "reviews", + "type": "Product" + }])) + .unwrap(); + // Needed because insert in the cache is async + for i in 0..10 { + let (_headers, body) = make_json_request(&mut router, request.clone()).await; + let expected_value = serde_json::json!({"count": 2}); + + if body == expected_value { + break; + } else if i == 9 { + insta::assert_yaml_snapshot!(body, @"count: 2"); + } + } + + let (headers, body) = make_graphql_request(&mut router).await; + assert!(headers["cache-control"].contains("public")); + assert!(body.errors.is_empty()); + // After invalidation, reviews need to be requested again but products are still in cache: + insta::assert_yaml_snapshot!(subgraph_request_counters, @r" + products: 1 + reviews: 2 + "); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalidate_with_endpoint_by_entity_cache_tag() { + if !graph_os_enabled() { + return; + } + + let (mut router, subgraph_request_counters) = harness(base_config(), base_subgraphs()).await; + let (headers, body) = make_graphql_request(&mut router).await; + assert!(headers["cache-control"].contains("public")); + assert!(body.errors.is_empty()); + insta::assert_yaml_snapshot!(subgraph_request_counters, @r" + products: 1 + reviews: 1 + "); + + let request = http::Request::builder() + .method("POST") + .uri(INVALIDATION_PATH) + .header("Authorization", INVALIDATION_SHARED_KEY) + .body(json!([{ + "kind": "cache_tag", + "subgraphs": ["reviews"], + "cache_tag": "product-1", + }])) + .unwrap(); + // Needed because insert in the cache is async + for i in 0..10 { + let (_headers, body) = make_json_request(&mut router, request.clone()).await; + let expected_value = serde_json::json!({"count": 1}); + + if body == expected_value { + break; + } else if i == 9 { + insta::assert_yaml_snapshot!(body, @"count: 1"); + } + } + let (headers, body) = make_graphql_request(&mut router).await; + assert!(headers["cache-control"].contains("public")); + assert!(body.errors.is_empty()); + // After invalidation, reviews need to be requested again but products are still in cache: + insta::assert_yaml_snapshot!(subgraph_request_counters, @r" + products: 1 + reviews: 2 + "); +} + +#[tokio::test] +async fn cache_control_merging_single_fetch() { + if !graph_os_enabled() { + return; + } + + let mut subgraphs = base_subgraphs(); + subgraphs["products"]["headers"]["cache-control"] = "public, s-maxage=120".into(); + subgraphs["reviews"]["headers"]["cache-control"] = "public, s-maxage=60".into(); + let (mut router, _subgraph_request_counters) = harness(base_config(), subgraphs).await; + let query = "{ topProducts { upc } }"; + + // Router responds with `max-age` even if a single subgraph used `s-maxage` + let (headers, _body) = + make_http_request::(&mut router, graphql_request(query).into()).await; + insta::assert_snapshot!(&headers["cache-control"], @"max-age=120,public"); + + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let query = "{ topProducts { upc } }"; + let (headers, _body) = + make_http_request::(&mut router, graphql_request(query).into()).await; + let cache_control = &headers["cache-control"]; + let max_age = parse_max_age(cache_control); + // Usually 120 - 2 = 118, but allow some slack in case CI CPUs are busy + assert!(max_age > 100 && max_age < 120, "got '{cache_control}'"); +} + +#[tokio::test] +async fn cache_control_merging_multi_fetch() { + if !graph_os_enabled() { + return; + } + + let mut subgraphs = base_subgraphs(); + subgraphs["products"]["headers"]["cache-control"] = "public, s-maxage=120".into(); + subgraphs["reviews"]["headers"]["cache-control"] = "public, s-maxage=60".into(); + let (mut router, _subgraph_request_counters) = harness(base_config(), subgraphs).await; + let query = "{ topProducts { reviews { id } } }"; + + // Router responds with `max-age` even if a subgraphs used `s-maxage`. + // The smaller value is used. + let (headers, _body) = + make_http_request::(&mut router, graphql_request(query).into()).await; + insta::assert_snapshot!(&headers["cache-control"], @"max-age=60,public"); + + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let (headers, _body) = + make_http_request::(&mut router, graphql_request(query).into()).await; + let cache_control = &headers["cache-control"]; + let max_age = parse_max_age(cache_control); + // Usually 60 - 2 = 58, but allow some slack in case CI CPUs are busy + assert!(max_age > 40 && max_age < 60, "got '{cache_control}'"); +} + +fn parse_max_age(cache_control: &str) -> u32 { + cache_control + .strip_prefix("max-age=") + .and_then(|s| s.strip_suffix(",public")) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| panic!("expected 'max-age={{seconds}},public', got '{cache_control}'")) +} diff --git a/apollo-router/tests/integration/rhai.rs b/apollo-router/tests/integration/rhai.rs index d94a47c567..1ecee507cf 100644 --- a/apollo-router/tests/integration/rhai.rs +++ b/apollo-router/tests/integration/rhai.rs @@ -1,49 +1,100 @@ -use apollo_router::graphql; -use apollo_router::services::supergraph; -use apollo_router::TestHarness; -use tower::ServiceExt; - -// This test will fail if run with the "multi_thread" flavor. -// This is because tracing_test doesn't set a global subscriber, so logs will be dropped -// if we're crossing a thread boundary +use std::path::PathBuf; + +use serde_json::json; + +use crate::integration::IntegrationTest; +use crate::integration::common::Query; + #[tokio::test(flavor = "multi_thread")] async fn all_rhai_callbacks_are_invoked() { - let env_filter = "apollo_router=info"; - let mock_writer = tracing_test::internal::MockWriter::new(tracing_test::internal::global_buf()); - let subscriber = tracing_test::internal::get_subscriber(mock_writer, env_filter); + let config = r#" +rhai: + scripts: tests/fixtures + main: test_callbacks.rhai +"#; - let _guard = tracing::dispatcher::set_default(&subscriber); + let mut router = IntegrationTest::builder() + .config(config) + .supergraph(PathBuf::from("tests/fixtures/supergraph.graphql")) + .build() + .await; - let config = serde_json::json!({ - "rhai": { - "scripts": "tests/fixtures", - "main": "test_callbacks.rhai", - } - }); - let router = TestHarness::builder() - .configuration_json(config) - .unwrap() - .schema(include_str!("../fixtures/supergraph.graphql")) - .build_router() - .await - .unwrap(); - let request = supergraph::Request::fake_builder() - .query("{ topProducts { name } }") + router.start().await; + router.assert_started().await; + + // Execute a query to trigger all the callbacks + let (_trace_id, response) = router + .execute_query( + Query::builder() + .body(json!({ + "query": "{ topProducts { name } }", + "variables": {} + })) + .build(), + ) + .await; + + assert!(response.status().is_success()); + + // Read all the logs + router.read_logs(); + + for expected_log in [ + "router_service setup", + "from_router_request", + "from_router_response", + "supergraph_service setup", + "from_supergraph_request", + "from_supergraph_response", + "execution_service setup", + "from_execution_request", + "from_execution_response", + "subgraph_service setup", + "from_subgraph_request", + ] { + router.assert_log_contained(expected_log); + } + + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_rhai_hot_reload_works() { + let (sender, receiver) = tokio::sync::oneshot::channel(); + + let mut current_dir = std::env::current_dir().expect("we have a current directory"); + current_dir.push("tests"); + current_dir.push("fixtures"); + let mut test_reload = current_dir.clone(); + let mut test_reload_1 = current_dir.clone(); + let mut test_reload_2 = current_dir.clone(); + + test_reload.push("test_reload.rhai"); + test_reload_1.push("test_reload_1.rhai"); + test_reload_2.push("test_reload_2.rhai"); + + // Setup our initial rhai file which contains log messages prefixed with 1. + std::fs::copy(&test_reload_1, &test_reload).expect("could not write rhai test file"); + + let mut router = IntegrationTest::builder() + .config(include_str!("fixtures/rhai_reload.router.yaml")) + .collect_stdio(sender) .build() - .unwrap(); - let _response: graphql::Response = serde_json::from_slice( - router - .oneshot(request.try_into().unwrap()) - .await - .unwrap() - .next_response() - .await - .unwrap() - .unwrap() - .to_vec() - .as_slice(), - ) - .unwrap(); + .await; + + router.start().await; + router.assert_started().await; + router.execute_query(Query::default()).await; + + // Copy our updated rhai file which contains log messages prefixed with 2. + std::fs::copy(&test_reload_2, &test_reload).expect("could not write rhai test file"); + // Wait for the router to reload (triggered by our update to the rhai file) + router.assert_reloaded().await; + + router.execute_query(Query::default()).await; + router.graceful_shutdown().await; + + let logs = receiver.await.expect("logs received"); for expected_log in [ "router_service setup", @@ -58,9 +109,11 @@ async fn all_rhai_callbacks_are_invoked() { "subgraph_service setup", "from_subgraph_request", ] { - assert!( - tracing_test::internal::logs_with_scope_contain("apollo_router", expected_log), - "log not found: {expected_log}" - ); + // We should see 1. and 2. versions of the expected logs + for i in 1..3 { + let expected = format!("{i}. {expected_log}"); + assert!(logs.contains(&expected)); + } } + std::fs::remove_file(&test_reload).expect("could not remove rhai test file"); } diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__coprocessor__on_graphql_error_selector__first_response_failure.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__coprocessor__on_graphql_error_selector__first_response_failure.snap new file mode 100644 index 0000000000..8ea6e736cc --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__coprocessor__on_graphql_error_selector__first_response_failure.snap @@ -0,0 +1,13 @@ +--- +source: apollo-router/tests/integration/coprocessor.rs +expression: errors +--- +[ + { + "message": "products error", + "path": [], + "extensions": { + "service": "products" + } + } +] diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__coprocessor__on_graphql_error_selector__nested_response_failure.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__coprocessor__on_graphql_error_selector__nested_response_failure.snap new file mode 100644 index 0000000000..131e577b4e --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__coprocessor__on_graphql_error_selector__nested_response_failure.snap @@ -0,0 +1,26 @@ +--- +source: apollo-router/tests/integration/coprocessor.rs +expression: errors +--- +[ + { + "message": "inventory error", + "path": [ + "topProducts", + 0 + ], + "extensions": { + "service": "inventory" + } + }, + { + "message": "inventory error", + "path": [ + "topProducts", + 1 + ], + "extensions": { + "service": "inventory" + } + } +] diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__lifecycle__cli_config_experimental.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__lifecycle__cli_config_experimental.snap index 928c8e8cb8..3addb79d26 100644 --- a/apollo-router/tests/integration/snapshots/integration_tests__integration__lifecycle__cli_config_experimental.snap +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__lifecycle__cli_config_experimental.snap @@ -1,6 +1,6 @@ --- source: apollo-router/tests/integration/lifecycle.rs -expression: "command_output(Command::new(IntegrationTest::router_location()).arg(\"config\").arg(\"experimental\").env(\"RUST_BACKTRACE\",\n \"\")).await" +expression: "command_output(Command::new(IntegrationTest::router_location()).arg(\"config\").arg(\"experimental\").env(\"RUST_BACKTRACE\",\n\"\")).await" --- Success: true Exit code: Some(0) @@ -10,5 +10,3 @@ stdout: List of all experimental configurations with related GitHub discussions: - experimental_response_trace_id: https://github.com/apollographql/router/discussions/2147 - - experimental_retry: https://github.com/apollographql/router/discussions/2241 - - experimental_when_header: https://github.com/apollographql/router/discussions/1961 diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-2.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-2.snap new file mode 100644 index 0000000000..75fb0f4299 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-2.snap @@ -0,0 +1,23 @@ +--- +source: apollo-router/tests/integration/postgres.rs +expression: v +--- +{ + "topProducts": [ + { + "__typename": "Product", + "upc": "1", + "name": "chair" + }, + { + "__typename": "Product", + "upc": "2", + "name": "table" + }, + { + "__typename": "Product", + "upc": "3", + "name": "plate" + } + ] +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-3.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-3.snap new file mode 100644 index 0000000000..f71c7b6200 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-3.snap @@ -0,0 +1,11 @@ +--- +source: apollo-router/tests/integration/postgres.rs +expression: v +--- +{ + "reviews": [ + { + "body": "I can sit on it" + } + ] +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-4.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-4.snap new file mode 100644 index 0000000000..03e43e47f1 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-4.snap @@ -0,0 +1,195 @@ +--- +source: apollo-router/tests/integration/postgres.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "chair", + "reviews": [ + { + "body": "I can sit on it" + } + ] + }, + { + "name": "table", + "reviews": [ + { + "body": "I can sit on it" + }, + { + "body": "I can sit on it2" + } + ] + }, + { + "name": "plate", + "reviews": [ + { + "body": "I can sit on it" + }, + { + "body": "I can sit on it2" + }, + { + "body": "I can sit on it3" + } + ] + } + ] + }, + "extensions": { + "apolloCacheDebugging": { + "version": "1.0", + "data": [ + { + "key": "version:1.0:subgraph:products:type:Query:hash:9916d7d8b8c700177e1ba52947c402ad219bf372805a30cb71fee8e76c52b4f0:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [], + "kind": { + "rootFields": [ + "topProducts" + ] + }, + "subgraphName": "products", + "subgraphRequest": { + "query": "{ topProducts(first: 2) { __typename upc name } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 60, + "public": true + }, + "data": { + "data": { + "topProducts": [ + { + "__typename": "Product", + "upc": "1", + "name": "chair" + }, + { + "__typename": "Product", + "upc": "2", + "name": "table" + }, + { + "__typename": "Product", + "upc": "3", + "name": "plate" + } + ] + } + } + }, + { + "key": "version:1.0:subgraph:reviews:type:Product:entity:72bafad9ffe61307806863b13856470e429e0cf332c99e5b735224fb0b1436f7:representation::hash:3cede4e233486ac841993dd8fc0662ef375351481eeffa8e989008901300a693:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [], + "kind": { + "typename": "Product", + "entityKey": { + "upc": "1" + } + }, + "subgraphName": "reviews", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviews { body } } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 10, + "public": true + }, + "data": { + "data": { + "reviews": [ + { + "body": "I can sit on it" + } + ] + } + } + }, + { + "key": "version:1.0:subgraph:reviews:type:Product:entity:472484d4df9e800bbb846447c4c077787860c4c9ec59579d50009bfcba275c3b:representation::hash:3cede4e233486ac841993dd8fc0662ef375351481eeffa8e989008901300a693:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [], + "kind": { + "typename": "Product", + "entityKey": { + "upc": "2" + } + }, + "subgraphName": "reviews", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviews { body } } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 10, + "public": true + }, + "data": { + "data": { + "reviews": [ + { + "body": "I can sit on it" + }, + { + "body": "I can sit on it2" + } + ] + } + } + }, + { + "key": "version:1.0:subgraph:reviews:type:Product:entity:080fc430afd3fb953a05525a6a00999226c34436466eff7ace1d33d004adaae3:representation::hash:3cede4e233486ac841993dd8fc0662ef375351481eeffa8e989008901300a693:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [], + "kind": { + "typename": "Product", + "entityKey": { + "upc": "3" + } + }, + "subgraphName": "reviews", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviews { body } } } }", + "variables": { + "representations": [] + } + }, + "source": "cache", + "cacheControl": { + "created": 0, + "maxAge": 10, + "public": true + }, + "data": { + "data": { + "reviews": [ + { + "body": "I can sit on it" + }, + { + "body": "I can sit on it2" + }, + { + "body": "I can sit on it3" + } + ] + } + } + } + ] + } + } +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-5.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-5.snap new file mode 100644 index 0000000000..e40aec095a --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic-5.snap @@ -0,0 +1,17 @@ +--- +source: apollo-router/tests/integration/postgres.rs +expression: v +--- +{ + "reviews": [ + { + "body": "I can sit on it" + }, + { + "body": "I can sit on it2" + }, + { + "body": "I can sit on it3" + } + ] +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic.snap new file mode 100644 index 0000000000..f554f459ce --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_basic.snap @@ -0,0 +1,234 @@ +--- +source: apollo-router/tests/integration/postgres.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "chair", + "reviews": [ + { + "body": "I can sit on it" + } + ] + }, + { + "name": "table", + "reviews": [ + { + "body": "I can sit on it" + }, + { + "body": "I can sit on it2" + } + ] + }, + { + "name": "plate", + "reviews": [ + { + "body": "I can sit on it" + }, + { + "body": "I can sit on it2" + }, + { + "body": "I can sit on it3" + } + ] + } + ] + }, + "extensions": { + "apolloCacheDebugging": { + "version": "1.0", + "data": [ + { + "key": "version:1.0:subgraph:products:type:Query:hash:6422a4ef561035dd94b357026091b72dca07429196aed0342e9e32cc1d48a13f:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [], + "kind": { + "rootFields": [ + "topProducts" + ] + }, + "subgraphName": "products", + "subgraphRequest": { + "query": "{ topProducts { __typename upc name } }" + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 60, + "public": true + }, + "data": { + "data": { + "topProducts": [ + { + "__typename": "Product", + "upc": "1", + "name": "chair" + }, + { + "__typename": "Product", + "upc": "2", + "name": "table" + }, + { + "__typename": "Product", + "upc": "3", + "name": "plate" + } + ] + } + } + }, + { + "key": "version:1.0:subgraph:reviews:type:Product:entity:72bafad9ffe61307806863b13856470e429e0cf332c99e5b735224fb0b1436f7:representation::hash:3cede4e233486ac841993dd8fc0662ef375351481eeffa8e989008901300a693:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [], + "kind": { + "typename": "Product", + "entityKey": { + "upc": "1" + } + }, + "subgraphName": "reviews", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviews { body } } } }", + "variables": { + "representations": [ + { + "upc": "1", + "__typename": "Product" + }, + { + "upc": "2", + "__typename": "Product" + }, + { + "upc": "3", + "__typename": "Product" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 10, + "public": true + }, + "data": { + "data": { + "reviews": [ + { + "body": "I can sit on it" + } + ] + } + } + }, + { + "key": "version:1.0:subgraph:reviews:type:Product:entity:472484d4df9e800bbb846447c4c077787860c4c9ec59579d50009bfcba275c3b:representation::hash:3cede4e233486ac841993dd8fc0662ef375351481eeffa8e989008901300a693:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [], + "kind": { + "typename": "Product", + "entityKey": { + "upc": "2" + } + }, + "subgraphName": "reviews", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviews { body } } } }", + "variables": { + "representations": [ + { + "upc": "1", + "__typename": "Product" + }, + { + "upc": "2", + "__typename": "Product" + }, + { + "upc": "3", + "__typename": "Product" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 10, + "public": true + }, + "data": { + "data": { + "reviews": [ + { + "body": "I can sit on it" + }, + { + "body": "I can sit on it2" + } + ] + } + } + }, + { + "key": "version:1.0:subgraph:reviews:type:Product:entity:080fc430afd3fb953a05525a6a00999226c34436466eff7ace1d33d004adaae3:representation::hash:3cede4e233486ac841993dd8fc0662ef375351481eeffa8e989008901300a693:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "invalidationKeys": [], + "kind": { + "typename": "Product", + "entityKey": { + "upc": "3" + } + }, + "subgraphName": "reviews", + "subgraphRequest": { + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviews { body } } } }", + "variables": { + "representations": [ + { + "upc": "1", + "__typename": "Product" + }, + { + "upc": "2", + "__typename": "Product" + }, + { + "upc": "3", + "__typename": "Product" + } + ] + } + }, + "source": "subgraph", + "cacheControl": { + "created": 0, + "maxAge": 10, + "public": true + }, + "data": { + "data": { + "reviews": [ + { + "body": "I can sit on it" + }, + { + "body": "I can sit on it2" + }, + { + "body": "I can sit on it3" + } + ] + } + } + } + ] + } + } +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-2.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-2.snap new file mode 100644 index 0000000000..5703ec1ff9 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-2.snap @@ -0,0 +1,18 @@ +--- +source: apollo-router/tests/integration/postgres.rs +expression: v +--- +{ + "allProducts": [ + { + "name": "Test", + "createdBy": { + "__typename": "User", + "email": "test@test.com", + "country": { + "a": "France" + } + } + } + ] +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-3.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-3.snap new file mode 100644 index 0000000000..cfef84ded7 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-3.snap @@ -0,0 +1,7 @@ +--- +source: apollo-router/tests/integration/postgres.rs +expression: v +--- +{ + "name": "test" +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-4.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-4.snap new file mode 100644 index 0000000000..8b040d1041 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-4.snap @@ -0,0 +1,19 @@ +--- +source: apollo-router/tests/integration/postgres.rs +expression: response +--- +{ + "data": { + "allProducts": [ + { + "name": "Test", + "createdBy": { + "name": "test", + "country": { + "a": "France" + } + } + } + ] + } +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-5.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-5.snap new file mode 100644 index 0000000000..cfef84ded7 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set-5.snap @@ -0,0 +1,7 @@ +--- +source: apollo-router/tests/integration/postgres.rs +expression: v +--- +{ + "name": "test" +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set.snap new file mode 100644 index 0000000000..8b040d1041 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__postgres__entity_cache_with_nested_field_set.snap @@ -0,0 +1,19 @@ +--- +source: apollo-router/tests/integration/postgres.rs +expression: response +--- +{ + "data": { + "allProducts": [ + { + "name": "Test", + "createdBy": { + "name": "test", + "country": { + "a": "France" + } + } + } + ] + } +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-2.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-2.snap new file mode 100644 index 0000000000..d058bb894f --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-2.snap @@ -0,0 +1,18 @@ +--- +source: apollo-router/tests/integration/redis.rs +expression: "v.as_object().unwrap().get(\"data\").unwrap()" +--- +{ + "allProducts": [ + { + "name": "Test", + "createdBy": { + "__typename": "User", + "email": "test@test.com", + "country": { + "a": "France" + } + } + } + ] +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-3.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-3.snap new file mode 100644 index 0000000000..e7c4f862d8 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-3.snap @@ -0,0 +1,7 @@ +--- +source: apollo-router/tests/integration/redis.rs +expression: "v.as_object().unwrap().get(\"data\").unwrap()" +--- +{ + "name": "test" +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-4.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-4.snap new file mode 100644 index 0000000000..555abed41a --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-4.snap @@ -0,0 +1,19 @@ +--- +source: apollo-router/tests/integration/redis.rs +expression: response +--- +{ + "data": { + "allProducts": [ + { + "name": "Test", + "createdBy": { + "name": "test", + "country": { + "a": "France" + } + } + } + ] + } +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-5.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-5.snap new file mode 100644 index 0000000000..e7c4f862d8 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set-5.snap @@ -0,0 +1,7 @@ +--- +source: apollo-router/tests/integration/redis.rs +expression: "v.as_object().unwrap().get(\"data\").unwrap()" +--- +{ + "name": "test" +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set.snap new file mode 100644 index 0000000000..555abed41a --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__entity_cache_with_nested_field_set.snap @@ -0,0 +1,19 @@ +--- +source: apollo-router/tests/integration/redis.rs +expression: response +--- +{ + "data": { + "allProducts": [ + { + "name": "Test", + "createdBy": { + "name": "test", + "country": { + "a": "France" + } + } + } + ] + } +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__query_planner_cache.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__query_planner_cache.snap index d7330676f2..7682f368b1 100644 --- a/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__query_planner_cache.snap +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__redis__query_planner_cache.snap @@ -6,14 +6,14 @@ expression: query_plan "kind": "Fetch", "serviceName": "products", "variableUsages": [], - "operation": "{topProducts{name name2:name}}", + "operation": "{ topProducts { name name2: name } }", "operationName": null, "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "d38dcce02eea33b3834447eefedabb09d3b14f3b01ad512e881f9e65137f0565", + "schemaAwareHash": "388580d0696ff1ec612ea6ee370a76a5b63876c81f4914a5539122c74a67f8de", "authorization": { "is_authenticated": false, "scopes": [], diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_authorization-2.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_authorization-2.snap new file mode 100644 index 0000000000..b6437c2c7e --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_authorization-2.snap @@ -0,0 +1,50 @@ +--- +source: apollo-router/tests/integration/response_cache.rs +expression: response +--- +{ + "data": { + "me": null, + "topProducts": [ + { + "name": "chair", + "reviews": [ + { + "body": "I can sit on it", + "author": { + "username": "ada" + } + } + ] + }, + { + "name": "table", + "reviews": [ + { + "body": "I can sit on it", + "author": { + "username": "ada" + } + }, + { + "body": "I can eat on it", + "author": { + "username": "charles" + } + } + ] + } + ] + }, + "errors": [ + { + "message": "Unauthorized field or type", + "path": [ + "me" + ], + "extensions": { + "code": "UNAUTHORIZED_FIELD_OR_TYPE" + } + } + ] +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_authorization-3.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_authorization-3.snap new file mode 100644 index 0000000000..b7e02eae2f --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_authorization-3.snap @@ -0,0 +1,54 @@ +--- +source: apollo-router/tests/integration/response_cache.rs +expression: response +--- +{ + "data": { + "me": { + "id": "1", + "name": null + }, + "topProducts": [ + { + "name": "chair", + "reviews": [ + { + "body": "I can sit on it", + "author": { + "username": "ada" + } + } + ] + }, + { + "name": "table", + "reviews": [ + { + "body": "I can sit on it", + "author": { + "username": "ada" + } + }, + { + "body": "I can eat on it", + "author": { + "username": "charles" + } + } + ] + } + ] + }, + "errors": [ + { + "message": "Unauthorized field or type", + "path": [ + "me", + "name" + ], + "extensions": { + "code": "UNAUTHORIZED_FIELD_OR_TYPE" + } + } + ] +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_authorization.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_authorization.snap new file mode 100644 index 0000000000..c8804ce0f4 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_authorization.snap @@ -0,0 +1,57 @@ +--- +source: apollo-router/tests/integration/response_cache.rs +expression: response +--- +{ + "data": { + "me": null, + "topProducts": [ + { + "name": "chair", + "reviews": [ + { + "body": "I can sit on it", + "author": null + } + ] + }, + { + "name": "table", + "reviews": [ + { + "body": "I can sit on it", + "author": null + }, + { + "body": "I can eat on it", + "author": null + } + ] + } + ] + }, + "errors": [ + { + "message": "Unauthorized field or type", + "path": [ + "me" + ], + "extensions": { + "code": "UNAUTHORIZED_FIELD_OR_TYPE" + } + }, + { + "message": "Unauthorized field or type", + "path": [ + "topProducts", + "@", + "reviews", + "@", + "author" + ], + "extensions": { + "code": "UNAUTHORIZED_FIELD_OR_TYPE" + } + } + ] +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-2.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-2.snap new file mode 100644 index 0000000000..465c9f2e6c --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-2.snap @@ -0,0 +1,18 @@ +--- +source: apollo-router/tests/integration/response_cache.rs +expression: "v.as_object().unwrap().get(\"data\").unwrap()" +--- +{ + "topProducts": [ + { + "__typename": "Product", + "upc": "1", + "name": "chair" + }, + { + "__typename": "Product", + "upc": "2", + "name": "table" + } + ] +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-3.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-3.snap new file mode 100644 index 0000000000..2d006a91a8 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-3.snap @@ -0,0 +1,11 @@ +--- +source: apollo-router/tests/integration/response_cache.rs +expression: "v.as_object().unwrap().get(\"data\").unwrap()" +--- +{ + "reviews": [ + { + "body": "I can sit on it" + } + ] +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-4.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-4.snap new file mode 100644 index 0000000000..f7bcc5bbf7 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-4.snap @@ -0,0 +1,26 @@ +--- +source: apollo-router/tests/integration/response_cache.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "chair", + "reviews": [ + { + "body": "I can sit on it" + } + ] + }, + { + "name": "plate", + "reviews": [ + { + "body": "I can eat in it" + } + ] + } + ] + } +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-5.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-5.snap new file mode 100644 index 0000000000..89d58c3bf7 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic-5.snap @@ -0,0 +1,11 @@ +--- +source: apollo-router/tests/integration/response_cache.rs +expression: "v.as_object().unwrap().get(\"data\").unwrap()" +--- +{ + "reviews": [ + { + "body": "I can eat in it" + } + ] +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic.snap new file mode 100644 index 0000000000..ca5d29bb1d --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_basic.snap @@ -0,0 +1,29 @@ +--- +source: apollo-router/tests/integration/response_cache.rs +expression: response +--- +{ + "data": { + "topProducts": [ + { + "name": "chair", + "reviews": [ + { + "body": "I can sit on it" + } + ] + }, + { + "name": "table", + "reviews": [ + { + "body": "I can sit on it" + }, + { + "body": "I can eat on it" + } + ] + } + ] + } +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-2.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-2.snap new file mode 100644 index 0000000000..d8dcf7a309 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-2.snap @@ -0,0 +1,18 @@ +--- +source: apollo-router/tests/integration/response_cache.rs +expression: "v.as_object().unwrap().get(\"data\").unwrap()" +--- +{ + "allProducts": [ + { + "name": "Test", + "createdBy": { + "__typename": "User", + "email": "test@test.com", + "country": { + "a": "France" + } + } + } + ] +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-3.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-3.snap new file mode 100644 index 0000000000..24fd1815fe --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-3.snap @@ -0,0 +1,7 @@ +--- +source: apollo-router/tests/integration/response_cache.rs +expression: "v.as_object().unwrap().get(\"data\").unwrap()" +--- +{ + "name": "test" +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-4.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-4.snap new file mode 100644 index 0000000000..66c53205b8 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-4.snap @@ -0,0 +1,19 @@ +--- +source: apollo-router/tests/integration/response_cache.rs +expression: response +--- +{ + "data": { + "allProducts": [ + { + "name": "Test", + "createdBy": { + "name": "test", + "country": { + "a": "France" + } + } + } + ] + } +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-5.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-5.snap new file mode 100644 index 0000000000..24fd1815fe --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set-5.snap @@ -0,0 +1,7 @@ +--- +source: apollo-router/tests/integration/response_cache.rs +expression: "v.as_object().unwrap().get(\"data\").unwrap()" +--- +{ + "name": "test" +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set.snap new file mode 100644 index 0000000000..66c53205b8 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__response_cache__response_cache_with_nested_field_set.snap @@ -0,0 +1,19 @@ +--- +source: apollo-router/tests/integration/response_cache.rs +expression: response +--- +{ + "data": { + "allProducts": [ + { + "name": "Test", + "createdBy": { + "name": "test", + "country": { + "a": "France" + } + } + } + ] + } +} diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__connector_rate_limit-2.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__connector_rate_limit-2.snap new file mode 100644 index 0000000000..56a170b40f --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__connector_rate_limit-2.snap @@ -0,0 +1,5 @@ +--- +source: apollo-router/tests/integration/traffic_shaping.rs +expression: response +--- +"{\"data\":null,\"errors\":[{\"message\":\"Your request has been rate limited\",\"path\":[\"posts\"],\"extensions\":{\"code\":\"REQUEST_RATE_LIMITED\"}}]}" diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__connector_rate_limit.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__connector_rate_limit.snap new file mode 100644 index 0000000000..9439432727 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__connector_rate_limit.snap @@ -0,0 +1,5 @@ +--- +source: apollo-router/tests/integration/traffic_shaping.rs +expression: response +--- +"{\"data\":{\"posts\":[{\"id\":1}]}}" diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__connector_timeout.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__connector_timeout.snap new file mode 100644 index 0000000000..95392a941e --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__connector_timeout.snap @@ -0,0 +1,5 @@ +--- +source: apollo-router/tests/integration/traffic_shaping.rs +expression: response +--- +"{\"data\":null,\"errors\":[{\"message\":\"Your request has been timed out\",\"path\":[\"posts\"],\"extensions\":{\"code\":\"GATEWAY_TIMEOUT\"}}]}" diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__router_timeout.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__router_timeout.snap index d09e20a31d..f33e3b17e3 100644 --- a/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__router_timeout.snap +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__router_timeout.snap @@ -1,5 +1,5 @@ --- source: apollo-router/tests/integration/traffic_shaping.rs -expression: response.text().await? +expression: response --- -"{\"errors\":[{\"message\":\"Request timed out\",\"extensions\":{\"code\":\"REQUEST_TIMEOUT\"}}]}" +"{\"errors\":[{\"message\":\"Your request has been timed out\",\"extensions\":{\"code\":\"GATEWAY_TIMEOUT\"}}]}" diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_rate_limit-2.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_rate_limit-2.snap index 07df294289..83a52acd05 100644 --- a/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_rate_limit-2.snap +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_rate_limit-2.snap @@ -2,4 +2,4 @@ source: apollo-router/tests/integration/traffic_shaping.rs expression: response --- -"{\"data\":null,\"errors\":[{\"message\":\"Your request has been rate limited\",\"path\":[],\"extensions\":{\"code\":\"REQUEST_RATE_LIMITED\"}}]}" +"{\"data\":null,\"errors\":[{\"message\":\"Your request has been rate limited\",\"path\":[],\"extensions\":{\"code\":\"REQUEST_RATE_LIMITED\",\"service\":\"products\"}}]}" diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_timeout.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_timeout.snap index 407674dfff..65e9eaf8e4 100644 --- a/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_timeout.snap +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_timeout.snap @@ -2,4 +2,4 @@ source: apollo-router/tests/integration/traffic_shaping.rs expression: response --- -"{\"data\":null,\"errors\":[{\"message\":\"Request timed out\",\"path\":[],\"extensions\":{\"code\":\"REQUEST_TIMEOUT\"}}]}" +"{\"data\":null,\"errors\":[{\"message\":\"Your request has been timed out\",\"path\":[],\"extensions\":{\"code\":\"GATEWAY_TIMEOUT\",\"service\":\"products\"}}]}" diff --git a/apollo-router/tests/integration/subgraph_response.rs b/apollo-router/tests/integration/subgraph_response.rs index 52fc56fa27..0cf5d84c03 100644 --- a/apollo-router/tests/integration/subgraph_response.rs +++ b/apollo-router/tests/integration/subgraph_response.rs @@ -3,6 +3,7 @@ use tower::BoxError; use wiremock::ResponseTemplate; use crate::integration::IntegrationTest; +use crate::integration::common::Query; const CONFIG: &str = r#" include_subgraph_errors: @@ -21,7 +22,9 @@ async fn test_subgraph_returning_data_null() -> Result<(), BoxError> { router.assert_started().await; let query = "{ __typename topProducts { name } }"; - let (_trace_id, response) = router.execute_query(&json!({ "query": query })).await; + let (_trace_id, response) = router + .execute_query(Query::builder().body(json!({ "query": query })).build()) + .await; assert_eq!(response.status(), 200); assert_eq!( response.json::().await?, @@ -64,7 +67,9 @@ async fn test_subgraph_returning_different_typename_on_query_root() -> Result<() inside_fragment: __typename } "#; - let (_trace_id, response) = router.execute_query(&json!({ "query": query })).await; + let (_trace_id, response) = router + .execute_query(Query::builder().body(json!({ "query": query })).build()) + .await; assert_eq!(response.status(), 200); assert_eq!( response.json::().await?, @@ -81,6 +86,137 @@ async fn test_subgraph_returning_different_typename_on_query_root() -> Result<() Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn test_valid_extensions_service_for_subgraph_error() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config(CONFIG) + .responder(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "topProducts": null }, + "errors": [{ + "message": "Some error on subgraph", + "path": ["topProducts"] + }] + }))) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router + .execute_query( + Query::builder() + .body(json!({ "query": "{ topProducts { name } }" })) + .build(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await?, + json!({ + "data": { "topProducts": null }, + "errors": [{ + "message": "Some error on subgraph", + "path":["topProducts"], + "extensions": { + "service": "products" + } + }] + }) + ); + + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_valid_extensions_service_is_preserved_for_subgraph_error() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config(CONFIG) + .responder(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "topProducts": null }, + "errors": [{ + "message": "Some error on subgraph", + "path": ["topProducts"], + "extensions": { + "service": 42, + } + }] + }))) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router + .execute_query( + Query::builder() + .body(json!({ "query": "{ topProducts { name } }" })) + .build(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await?, + json!({ + "data": { "topProducts": null }, + "errors": [{ + "message": "Some error on subgraph", + "path":["topProducts"], + "extensions": { + "service": 42, + } + }] + }) + ); + + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_valid_extensions_service_for_invalid_subgraph_response() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config(CONFIG) + .responder(ResponseTemplate::new(200)) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router + .execute_query( + Query::builder() + .body(json!({ "query": "{ topProducts { name } }" })) + .build(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await?, + json!({ + "data": null, + "errors": [ + { + "message": "HTTP fetch failed from 'products': subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", + "path": [], + "extensions": { + "code": "SUBREQUEST_HTTP_ERROR", + "service": "products", + "reason": "subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", + "http": { "status": 200 } + } + } + ] + }) + ); + + router.graceful_shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn test_valid_error_locations() -> Result<(), BoxError> { let mut router = IntegrationTest::builder() @@ -103,7 +239,11 @@ async fn test_valid_error_locations() -> Result<(), BoxError> { router.assert_started().await; let (_trace_id, response) = router - .execute_query(&json!({ "query": "{ topProducts { name } }" })) + .execute_query( + Query::builder() + .body(json!({ "query": "{ topProducts { name } }" })) + .build(), + ) .await; assert_eq!(response.status(), 200); assert_eq!( @@ -116,7 +256,8 @@ async fn test_valid_error_locations() -> Result<(), BoxError> { { "line": 1, "column": 2 }, { "line": 3, "column": 4 }, ], - "path":["topProducts"] + "path":["topProducts"], + "extensions": { "service": "products" } }] }) ); @@ -144,7 +285,11 @@ async fn test_empty_error_locations() -> Result<(), BoxError> { router.assert_started().await; let (_trace_id, response) = router - .execute_query(&json!({ "query": "{ topProducts { name } }" })) + .execute_query( + Query::builder() + .body(json!({ "query": "{ topProducts { name } }" })) + .build(), + ) .await; assert_eq!(response.status(), 200); assert_eq!( @@ -153,7 +298,8 @@ async fn test_empty_error_locations() -> Result<(), BoxError> { "data": { "topProducts": null }, "errors": [{ "message":"Some error on subgraph", - "path":["topProducts"] + "path":["topProducts"], + "extensions": { "service": "products" } }] }) ); @@ -181,7 +327,11 @@ async fn test_invalid_error_locations() -> Result<(), BoxError> { router.assert_started().await; let (_trace_id, response) = router - .execute_query(&json!({ "query": "{ topProducts { name } }" })) + .execute_query( + Query::builder() + .body(json!({ "query": "{ topProducts { name } }" })) + .build(), + ) .await; assert_eq!(response.status(), 200); assert_eq!( @@ -195,6 +345,7 @@ async fn test_invalid_error_locations() -> Result<(), BoxError> { "service": "products", "reason": "invalid `locations` within error: invalid type: boolean `true`, expected u32", "code": "SUBREQUEST_MALFORMED_RESPONSE", + "service": "products" } }] }) @@ -223,7 +374,11 @@ async fn test_invalid_error_locations_with_single_negative_one_location() -> Res router.assert_started().await; let (_trace_id, response) = router - .execute_query(&json!({ "query": "{ topProducts { name } }" })) + .execute_query( + Query::builder() + .body(json!({ "query": "{ topProducts { name } }" })) + .build(), + ) .await; assert_eq!(response.status(), 200); assert_eq!( @@ -232,7 +387,8 @@ async fn test_invalid_error_locations_with_single_negative_one_location() -> Res "data": { "topProducts": null }, "errors": [{ "message":"Some error on subgraph", - "path":["topProducts"] + "path":["topProducts"], + "extensions": { "service": "products" } }] }) ); @@ -264,7 +420,11 @@ async fn test_invalid_error_locations_contains_negative_one_location() -> Result router.assert_started().await; let (_trace_id, response) = router - .execute_query(&json!({ "query": "{ topProducts { name } }" })) + .execute_query( + Query::builder() + .body(json!({ "query": "{ topProducts { name } }" })) + .build(), + ) .await; assert_eq!(response.status(), 200); assert_eq!( @@ -277,7 +437,116 @@ async fn test_invalid_error_locations_contains_negative_one_location() -> Result { "line": 1, "column": 2 }, { "line": 3, "column": 4 }, ], - "path":["topProducts"] + "path":["topProducts"], + "extensions": { "service": "products" } + }] + }) + ); + + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_valid_error_path() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config(CONFIG) + .responder(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "topProducts": null }, + "errors": [{ + "message": "Some error on subgraph", + "path": ["topProducts"] + }] + }))) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router.execute_query(Query::default()).await; + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await?, + json!({ + "data": { "topProducts": null }, + "errors": [{ + "message":"Some error on subgraph", + "path":["topProducts"], + "extensions": { "service": "products" } + }] + }) + ); + + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_error_path() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config(CONFIG) + .responder(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "topProducts": null }, + "errors": [{ + "message": "Some error on subgraph", + "path": ["some","path", 42] + }] + }))) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router.execute_query(Query::default()).await; + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await?, + json!({ + "data": { "topProducts": null }, + "errors": [{ + "message":"Some error on subgraph", + "extensions": { + "service": "products" + } + }] + }) + ); + + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_partially_valid_error_path() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config(CONFIG) + .responder(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "topProducts": null }, + "errors": [{ + "message": "Some error on subgraph", + "path": ["topProducts","invalid", 42] + }] + }))) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router.execute_query(Query::default()).await; + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await?, + json!({ + "data": { "topProducts": null }, + "errors": [{ + "message": "Some error on subgraph", + "path": ["topProducts"], + "extensions": { + "service": "products" + } }] }) ); diff --git a/apollo-router/tests/integration/subscription.rs b/apollo-router/tests/integration/subscription_load_test.rs similarity index 84% rename from apollo-router/tests/integration/subscription.rs rename to apollo-router/tests/integration/subscription_load_test.rs index 911503593f..ff2f553f81 100644 --- a/apollo-router/tests/integration/subscription.rs +++ b/apollo-router/tests/integration/subscription_load_test.rs @@ -1,10 +1,11 @@ -//! This file is to load test subscriptions and should be launched manually, not in our CI +//! This file is to load-test subscriptions and should be launched manually, not in our CI use futures::StreamExt; use http::HeaderValue; use serde_json::json; use tower::BoxError; use super::common::IntegrationTest; +use super::common::Query; use super::common::Telemetry; const SUBSCRIPTION_CONFIG: &str = include_str!("../fixtures/subscription.router.yaml"); @@ -12,27 +13,6 @@ const SUB_QUERY: &str = r#"subscription { userWasCreated(intervalMs: 5, nbEvents: 10) { name reviews { body } }}"#; const UNFEDERATED_SUB_QUERY: &str = r#"subscription { userWasCreated { name username }}"#; -#[tokio::test(flavor = "multi_thread")] -async fn test_subscription() -> Result<(), BoxError> { - if std::env::var("TEST_APOLLO_KEY").is_ok() && std::env::var("TEST_APOLLO_GRAPH_REF").is_ok() { - let mut router = create_router(SUBSCRIPTION_CONFIG).await?; - router.start().await; - router.assert_started().await; - - let (_, response) = router.run_subscription(SUB_QUERY).await; - assert!(response.status().is_success()); - - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.unwrap(); - assert!(chunk.starts_with(b"\r\n--graphql\r\ncontent-type: application/json\r\n\r\n")); - assert!(chunk.ends_with(b"\r\n--graphql--\r\n")); - } - } - - Ok(()) -} - #[ignore] #[tokio::test(flavor = "multi_thread")] async fn test_subscription_load() -> Result<(), BoxError> { @@ -60,7 +40,9 @@ async fn test_subscription_load() -> Result<(), BoxError> { for _ in 0..100 { let (_id, resp) = router .execute_query( - &json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}), + Query::builder() + .body(json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}})) + .build(), ) .await; assert!(resp.status().is_success()); @@ -173,7 +155,7 @@ async fn test_subscription_without_dedup_load_standalone() -> Result<(), BoxErro async fn create_router(config: &'static str) -> Result { Ok(IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) + .telemetry(Telemetry::Otlp { endpoint: None }) .config(config) .build() .await) diff --git a/apollo-router/tests/integration/subscriptions/callback.rs b/apollo-router/tests/integration/subscriptions/callback.rs new file mode 100644 index 0000000000..d0f58a1b58 --- /dev/null +++ b/apollo-router/tests/integration/subscriptions/callback.rs @@ -0,0 +1,501 @@ +use tower::BoxError; + +use crate::integration::common::IntegrationTest; +use crate::integration::common::graph_os_enabled; +use crate::integration::subscriptions::CALLBACK_CONFIG; +use crate::integration::subscriptions::CallbackTestState; +use crate::integration::subscriptions::start_callback_server; +use crate::integration::subscriptions::start_callback_subgraph_server; +use crate::integration::subscriptions::start_callback_subgraph_server_with_payloads; + +#[tokio::test(flavor = "multi_thread")] +async fn test_subscription_callback() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + let nb_events = 3; + let interval_ms = 100; + + // Start callback server to receive router callbacks + let (callback_addr, callback_state) = start_callback_server().await; + let callback_url = format!("http://{callback_addr}/callback"); + + // Start mock subgraph server that will send callbacks + let subgraph_server = + start_callback_subgraph_server(nb_events, interval_ms, callback_url.clone()).await; + + // Create router with port reservations + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(CALLBACK_CONFIG) + .build() + .await; + + // Reserve ports using the existing external ports and allocate new ones + let callback_receiver_port = callback_addr.port(); + let _callback_listener_port = router.reserve_address("CALLBACK_LISTENER_PORT"); + router.set_address("CALLBACK_RECEIVER_PORT", callback_receiver_port); + router.set_address_from_uri("SUBGRAPH_PORT", &subgraph_server.uri()); + + router.start().await; + router.assert_started().await; + + let subscription_query = r#"subscription { userWasCreated(intervalMs: 100, nbEvents: 3) { name reviews { body } } }"#; + + // Send subscription request to router + // For callback mode, we still need the subscription Accept header to indicate subscription support + let mut headers = std::collections::HashMap::new(); + headers.insert( + "Accept".to_string(), + "multipart/mixed;subscriptionSpec=1.0".to_string(), + ); + + let query = crate::integration::common::Query::builder() + .body(serde_json::json!({ + "query": subscription_query + })) + .headers(headers) + .build(); + + let (_trace_id, response) = router.execute_query(query).await; + + // Router should respond with subscription acknowledgment + assert!( + response.status().is_success(), + "Subscription request failed: {}", + response.status() + ); + + // Wait for callbacks to be sent + tokio::time::sleep(tokio::time::Duration::from_millis( + (nb_events as u64 * interval_ms) + 1000, + )) + .await; + + // Verify callbacks were received - expect default user events + let expected_user_events = vec![ + serde_json::json!({ + "name": "User 1", + "reviews": [{ + "body": "Review 1 from user 1" + }] + }), + serde_json::json!({ + "name": "User 2", + "reviews": [{ + "body": "Review 2 from user 2" + }] + }), + serde_json::json!({ + "name": "User 3", + "reviews": [{ + "body": "Review 3 from user 3" + }] + }), + ]; + verify_callback_events(&callback_state, expected_user_events).await?; + + // Check for errors in router logs + router.assert_no_error_logs(); + + Ok(()) +} + +async fn verify_callback_events( + callback_state: &CallbackTestState, + expected_user_events: Vec, +) -> Result<(), BoxError> { + use pretty_assertions::assert_eq; + + let callbacks = callback_state.received_callbacks.lock().unwrap().clone(); + + // Should have received: expected_user_events.len() "next" callbacks + 1 "complete" callback + let next_callbacks: Vec<_> = callbacks.iter().filter(|c| c.action == "next").collect(); + let complete_callbacks: Vec<_> = callbacks + .iter() + .filter(|c| c.action == "complete") + .collect(); + + // Note: We don't check next_callbacks.len() == expected_user_events.len() + // because some callbacks may not have userWasCreated data (e.g., pure error payloads) + + assert_eq!( + complete_callbacks.len(), + 1, + "Expected 1 'complete' callback, got {}. All callbacks: {:?}", + complete_callbacks.len(), + callbacks + ); + + // Extract userWasCreated events for validation + let mut actual_user_events = Vec::new(); + for callback in &next_callbacks { + if let Some(payload) = &callback.payload + && let Some(data) = payload.get("data") + && let Some(user_created) = data.get("userWasCreated") + { + actual_user_events.push(user_created.clone()); + } + // If there's a data field but no userWasCreated, it's an empty/error case + // If there's no data field (pure error payload), we don't extract anything + } + + // Simple equality comparison using pretty_assertions + assert_eq!( + actual_user_events, expected_user_events, + "Callback user events do not match expected events" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subscription_callback_error_scenarios() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + // Test 1: Invalid callback payload (missing fields) + let (callback_addr, callback_state) = start_callback_server().await; + + let client = reqwest::Client::new(); + let callback_url = format!("http://{callback_addr}/callback/test-id"); + + // Test invalid payload - missing required fields + let invalid_payload = serde_json::json!({ + "kind": "subscription", + "action": "next" + // Missing: id, verifier + }); + + let response = client + .post(&callback_url) + .json(&invalid_payload) + .send() + .await?; + + // Should return 422 Unprocessable Entity for malformed JSON payload (missing required fields) + assert_eq!(response.status(), 422, "Invalid payload should return 422"); + + // Test 2: ID mismatch between URL and payload + let mismatched_payload = serde_json::json!({ + "kind": "subscription", + "action": "next", + "id": "different-id", + "verifier": "test-verifier" + }); + + let response = client + .post(&callback_url) + .json(&mismatched_payload) + .send() + .await?; + + assert_eq!(response.status(), 400, "ID mismatch should return 400"); + + // Test 3: Subscription not found (404 scenarios) + let valid_payload = serde_json::json!({ + "kind": "subscription", + "action": "check", + "id": "test-id", + "verifier": "test-verifier" + }); + + let response = client + .post(&callback_url) + .json(&valid_payload) + .send() + .await?; + + assert_eq!( + response.status(), + 404, + "Unknown subscription should return 404" + ); + + // Test 4: Add subscription ID and test success scenarios + { + let mut ids = callback_state.subscription_ids.lock().unwrap(); + ids.push("test-id".to_string()); + } + + // Now check should succeed + let response = client + .post(&callback_url) + .json(&valid_payload) + .send() + .await?; + + assert_eq!(response.status(), 204, "Valid check should return 204"); + + // Test 5: Test heartbeat with mixed valid/invalid IDs + let heartbeat_payload = serde_json::json!({ + "kind": "subscription", + "action": "heartbeat", + "id": "test-id", + "ids": ["test-id", "invalid-id"], + "verifier": "test-verifier" + }); + + let response = client + .post(&callback_url) + .json(&heartbeat_payload) + .send() + .await?; + + assert_eq!( + response.status(), + 404, + "Heartbeat with invalid IDs should return 404" + ); + + // Test 6: Test heartbeat with all valid IDs + let valid_heartbeat_payload = serde_json::json!({ + "kind": "subscription", + "action": "heartbeat", + "id": "test-id", + "ids": ["test-id"], + "verifier": "test-verifier" + }); + + let response = client + .post(&callback_url) + .json(&valid_heartbeat_payload) + .send() + .await?; + + assert_eq!(response.status(), 204, "Valid heartbeat should return 204"); + + // Test 7: Test completion callback + let complete_payload = serde_json::json!({ + "kind": "subscription", + "action": "complete", + "id": "test-id", + "verifier": "test-verifier" + }); + + let response = client + .post(&callback_url) + .json(&complete_payload) + .send() + .await?; + + assert_eq!(response.status(), 202, "Valid completion should return 202"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subscription_callback_error_payload() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + let interval_ms = 100; + + // Create custom payloads: one normal event, one error event (no body, empty errors) + let custom_payloads = vec![ + serde_json::json!({ + "data": { + "userWasCreated": { + "name": "User 1", + "reviews": [{ + "body": "Review 1 from user 1" + }] + } + } + }), + serde_json::json!({ + "data": { + "userWasCreated": { + "name": "User 2" + // Missing reviews field to test error handling + } + }, + "errors": [] + }), + ]; + + // Start callback server to receive router callbacks + let (callback_addr, callback_state) = start_callback_server().await; + let callback_url = format!("http://{callback_addr}/callback"); + + // Start mock subgraph server with custom payloads + let subgraph_server = start_callback_subgraph_server_with_payloads( + custom_payloads.clone(), + interval_ms, + callback_url.clone(), + ) + .await; + + // Create router with port reservations + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(CALLBACK_CONFIG) + .build() + .await; + + // Reserve ports using the existing external ports and allocate new ones + let callback_receiver_port = callback_addr.port(); + let _callback_listener_port = router.reserve_address("CALLBACK_LISTENER_PORT"); + router.set_address("CALLBACK_RECEIVER_PORT", callback_receiver_port); + router.set_address_from_uri("SUBGRAPH_PORT", &subgraph_server.uri()); + + router.start().await; + router.assert_started().await; + + let subscription_query = r#"subscription { userWasCreated(intervalMs: 100, nbEvents: 2) { name reviews { body } } }"#; + + // Send subscription request to router + let mut headers = std::collections::HashMap::new(); + headers.insert( + "Accept".to_string(), + "multipart/mixed;subscriptionSpec=1.0".to_string(), + ); + + let query = crate::integration::common::Query::builder() + .body(serde_json::json!({ + "query": subscription_query + })) + .headers(headers) + .build(); + + let (_trace_id, response) = router.execute_query(query).await; + + // Router should respond with subscription acknowledgment + assert!( + response.status().is_success(), + "Subscription request failed: {}", + response.status() + ); + + // Wait for callbacks to be sent + tokio::time::sleep(tokio::time::Duration::from_millis( + (custom_payloads.len() as u64 * interval_ms) + 1000, + )) + .await; + + // Verify callbacks were received - expect the exact user events from custom payloads + let expected_user_events = vec![ + serde_json::json!({ + "name": "User 1", + "reviews": [{ + "body": "Review 1 from user 1" + }] + }), + serde_json::json!({ + "name": "User 2" + // Missing reviews field to test error handling + }), + ]; + verify_callback_events(&callback_state, expected_user_events).await?; + + // Check for errors in router logs + router.assert_no_error_logs(); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subscription_callback_pure_error_payload() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + let interval_ms = 100; + + // Create custom payloads: one normal event, one pure error event (no data, only errors) + let custom_payloads = vec![ + serde_json::json!({ + "data": { + "userWasCreated": { + "name": "User 1", + "reviews": [{ + "body": "Review 1 from user 1" + }] + } + } + }), + serde_json::json!({ + "errors": [] + // No data attribute at all + }), + ]; + + // Start callback server to receive router callbacks + let (callback_addr, callback_state) = start_callback_server().await; + let callback_url = format!("http://{callback_addr}/callback"); + + // Start mock subgraph server with custom payloads + let subgraph_server = start_callback_subgraph_server_with_payloads( + custom_payloads.clone(), + interval_ms, + callback_url.clone(), + ) + .await; + + // Create router with port reservations + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(CALLBACK_CONFIG) + .build() + .await; + + // Reserve ports using the existing external ports and allocate new ones + let callback_receiver_port = callback_addr.port(); + let _callback_listener_port = router.reserve_address("CALLBACK_LISTENER_PORT"); + router.set_address("CALLBACK_RECEIVER_PORT", callback_receiver_port); + router.set_address_from_uri("SUBGRAPH_PORT", &subgraph_server.uri()); + + router.start().await; + router.assert_started().await; + + let subscription_query = r#"subscription { userWasCreated(intervalMs: 100, nbEvents: 2) { name reviews { body } } }"#; + + // Send subscription request to router + let mut headers = std::collections::HashMap::new(); + headers.insert( + "Accept".to_string(), + "multipart/mixed;subscriptionSpec=1.0".to_string(), + ); + + let query = crate::integration::common::Query::builder() + .body(serde_json::json!({ + "query": subscription_query + })) + .headers(headers) + .build(); + + let (_trace_id, response) = router.execute_query(query).await; + + // Router should respond with subscription acknowledgment + assert!( + response.status().is_success(), + "Subscription request failed: {}", + response.status() + ); + + // Wait for callbacks to be sent + tokio::time::sleep(tokio::time::Duration::from_millis( + (custom_payloads.len() as u64 * interval_ms) + 1000, + )) + .await; + + // Verify callbacks were received - expect only 1 user event since second callback has no userWasCreated data + let expected_user_events = vec![ + serde_json::json!({ + "name": "User 1", + "reviews": [{ + "body": "Review 1 from user 1" + }] + }), + // Second callback has no userWasCreated data (pure error payload), so nothing is extracted from it + ]; + verify_callback_events(&callback_state, expected_user_events).await?; + + // Check for errors in router logs + router.assert_no_error_logs(); + + Ok(()) +} diff --git a/apollo-router/tests/integration/subscriptions/fixtures/callback.router.yaml b/apollo-router/tests/integration/subscriptions/fixtures/callback.router.yaml new file mode 100644 index 0000000000..a60a208ba7 --- /dev/null +++ b/apollo-router/tests/integration/subscriptions/fixtures/callback.router.yaml @@ -0,0 +1,26 @@ +supergraph: + listen: 127.0.0.1:4000 + path: / + introspection: true +homepage: + enabled: false +sandbox: + enabled: true +override_subgraph_url: + accounts: http://localhost:{{SUBGRAPH_PORT}} +include_subgraph_errors: + all: true +subscription: + enabled: true + mode: + callback: + public_url: "http://localhost:{{CALLBACK_RECEIVER_PORT}}/callback" + listen: "127.0.0.1:{{CALLBACK_LISTENER_PORT}}" + path: "/callback" + heartbeat_interval: 5s + subgraphs: ["accounts"] +headers: + all: + request: + - propagate: + named: custom_id \ No newline at end of file diff --git a/apollo-router/tests/integration/subscriptions/fixtures/subscription.router.yaml b/apollo-router/tests/integration/subscriptions/fixtures/subscription.router.yaml new file mode 100644 index 0000000000..31776fea6e --- /dev/null +++ b/apollo-router/tests/integration/subscriptions/fixtures/subscription.router.yaml @@ -0,0 +1,28 @@ +supergraph: + listen: 127.0.0.1:4000 + path: / + introspection: true +homepage: + enabled: false +sandbox: + enabled: true +override_subgraph_url: + products: http://localhost:{{PRODUCTS_PORT}} + accounts: http://localhost:{{ACCOUNTS_PORT}} +include_subgraph_errors: + all: true +subscription: + enabled: true + mode: + passthrough: + all: + path: /ws + subgraphs: + rng: + path: /ws + protocol: graphql_transport_ws +headers: + all: # Header rules for all subgraphs + request: + - propagate: + named: custom_id \ No newline at end of file diff --git a/apollo-router/tests/integration/subscriptions/fixtures/subscription_coprocessor.router.yaml b/apollo-router/tests/integration/subscriptions/fixtures/subscription_coprocessor.router.yaml new file mode 100644 index 0000000000..1dad0b6662 --- /dev/null +++ b/apollo-router/tests/integration/subscriptions/fixtures/subscription_coprocessor.router.yaml @@ -0,0 +1,60 @@ +supergraph: + listen: 127.0.0.1:4000 + path: / + introspection: true +homepage: + enabled: false +sandbox: + enabled: true +override_subgraph_url: + products: http://localhost:{{PRODUCTS_PORT}} + accounts: http://localhost:{{ACCOUNTS_PORT}} +include_subgraph_errors: + all: true +subscription: + enabled: true + mode: + passthrough: + all: + path: /ws + subgraphs: + accounts: + path: /ws + protocol: graphql_transport_ws +coprocessor: + url: http://localhost:{{COPROCESSOR_PORT}} + timeout: 30s + router: + request: + headers: true + body: true + response: + headers: true + body: true + supergraph: + request: + headers: true + body: true + response: + headers: true + body: true + execution: + request: + headers: true + body: true + response: + headers: true + body: true + subgraph: + all: + request: + headers: true + body: true + response: + headers: true + body: true +headers: + all: # Header rules for all subgraphs + request: + - propagate: + named: custom_id \ No newline at end of file diff --git a/apollo-router/tests/integration/subscriptions/fixtures/subscription_schema_reload.router.yaml b/apollo-router/tests/integration/subscriptions/fixtures/subscription_schema_reload.router.yaml new file mode 100644 index 0000000000..cd3d03a13d --- /dev/null +++ b/apollo-router/tests/integration/subscriptions/fixtures/subscription_schema_reload.router.yaml @@ -0,0 +1,35 @@ +supergraph: + listen: 127.0.0.1:4000 + path: / + introspection: true +homepage: + enabled: false +sandbox: + enabled: true +override_subgraph_url: + products: http://localhost:{{PRODUCTS_PORT}} + accounts: http://localhost:{{ACCOUNTS_PORT}} +include_subgraph_errors: + all: true +subscription: + enabled: true + mode: + passthrough: + all: + path: /ws + subgraphs: + rng: + path: /ws + protocol: graphql_transport_ws +telemetry: + exporters: + metrics: + prometheus: + enabled: true +headers: + all: # Header rules for all subgraphs + request: + - propagate: + named: custom_id + - propagate: + named: replaceable \ No newline at end of file diff --git a/apollo-router/tests/integration/subscriptions/fixtures/supergraph.graphql b/apollo-router/tests/integration/subscriptions/fixtures/supergraph.graphql new file mode 100644 index 0000000000..5e39bb3d85 --- /dev/null +++ b/apollo-router/tests/integration/subscriptions/fixtures/supergraph.graphql @@ -0,0 +1,120 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { + query: Query + subscription: Subscription + mutation: Mutation +} + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar join__FieldSet +scalar link__Import + +enum join__Graph { + ACCOUNTS @join__graph(name: "accounts", url: "http://localhost:4001/graphql") + INVENTORY + @join__graph(name: "inventory", url: "http://localhost:4002/graphql") + PRODUCTS @join__graph(name: "products", url: "http://localhost:4003/graphql") + REVIEWS @join__graph(name: "reviews", url: "http://localhost:4004/graphql") +} + +enum link__Purpose { + SECURITY + EXECUTION +} + +type Subscription @join__type(graph: ACCOUNTS) { + userWasCreated(intervalMs: Int, nbEvents: Int): User + @join__field(graph: ACCOUNTS) +} + +type Mutation @join__type(graph: PRODUCTS) @join__type(graph: REVIEWS) { + createProduct(upc: ID!, name: String): Product @join__field(graph: PRODUCTS) + createReview(upc: ID!, id: ID!, body: String): Review + @join__field(graph: REVIEWS) +} + +type Product + @join__type(graph: ACCOUNTS, key: "upc", extension: true) + @join__type(graph: INVENTORY, key: "upc") + @join__type(graph: PRODUCTS, key: "upc") + @join__type(graph: REVIEWS, key: "upc") { + upc: String! + weight: Int + @join__field(graph: INVENTORY, external: true) + @join__field(graph: PRODUCTS) + price: Int + @join__field(graph: INVENTORY, external: true) + @join__field(graph: PRODUCTS) + inStock: Boolean @join__field(graph: INVENTORY) + shippingEstimate: Int @join__field(graph: INVENTORY, requires: "price weight") + name: String @join__field(graph: PRODUCTS) + reviews: [Review] @join__field(graph: REVIEWS) + reviewsForAuthor(authorID: ID!): [Review] @join__field(graph: REVIEWS) +} + +type Query + @join__type(graph: ACCOUNTS) + @join__type(graph: INVENTORY) + @join__type(graph: PRODUCTS) + @join__type(graph: REVIEWS) { + me: User @join__field(graph: ACCOUNTS) + topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCTS) +} + +type Review @join__type(graph: REVIEWS, key: "id") { + id: ID! + author: User @join__field(graph: REVIEWS, provides: "username") + body: String @join__field(graph: REVIEWS) + product: Product @join__field(graph: REVIEWS) +} + +type User + @join__type(graph: ACCOUNTS, key: "id") + @join__type(graph: REVIEWS, key: "id") { + id: ID! + name: String @join__field(graph: ACCOUNTS) + createdAt: String @join__field(graph: ACCOUNTS) + username: String + @join__field(graph: ACCOUNTS) + @join__field(graph: REVIEWS, external: true) + reviews: [Review] @join__field(graph: REVIEWS) +} diff --git a/apollo-router/tests/integration/subscriptions/mod.rs b/apollo-router/tests/integration/subscriptions/mod.rs new file mode 100644 index 0000000000..6f6b89c451 --- /dev/null +++ b/apollo-router/tests/integration/subscriptions/mod.rs @@ -0,0 +1,668 @@ +//! Common subscription testing functionality +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; + +use axum::Router; +use axum::extract::State; +use axum::extract::ws::WebSocket; +use axum::extract::ws::WebSocketUpgrade; +use axum::http::HeaderMap; +use axum::http::StatusCode; +use axum::response::Response; +use axum::routing::get; +use axum::routing::post; +use serde::Deserialize; +use serde::Serialize; +use serde_json::json; +use tracing::debug; +use tracing::info; +use tracing::warn; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; + +pub mod callback; +pub mod ws_passthrough; + +#[derive(Clone)] +struct SubscriptionServerConfig { + payloads: Vec, + interval_ms: u64, + terminate_subscription: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallbackPayload { + pub kind: String, + pub action: String, + pub id: String, + pub verifier: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub payload: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub errors: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ids: Option>, +} + +#[derive(Clone)] +pub struct CallbackTestState { + pub received_callbacks: Arc>>, + pub subscription_ids: Arc>>, +} + +impl Default for CallbackTestState { + fn default() -> Self { + Self { + received_callbacks: Arc::new(Mutex::new(Vec::new())), + subscription_ids: Arc::new(Mutex::new(Vec::new())), + } + } +} + +pub const SUBSCRIPTION_CONFIG: &str = include_str!("fixtures/subscription.router.yaml"); +pub const SUBSCRIPTION_COPROCESSOR_CONFIG: &str = + include_str!("fixtures/subscription_coprocessor.router.yaml"); +pub const CALLBACK_CONFIG: &str = include_str!("fixtures/callback.router.yaml"); +pub fn create_sub_query(interval_ms: u64, nb_events: usize) -> String { + format!( + r#"subscription {{ userWasCreated(intervalMs: {interval_ms}, nbEvents: {nb_events}) {{ name reviews {{ body }} }}}}"# + ) +} + +#[derive(Clone)] +struct CustomState { + config: SubscriptionServerConfig, + is_closed: Arc, +} + +pub async fn start_subscription_server_with_payloads( + payloads: Vec, + interval_ms: u64, + terminate_subscription: bool, + is_closed: Arc, +) -> (SocketAddr, wiremock::MockServer) { + let config = SubscriptionServerConfig { + payloads, + interval_ms, + terminate_subscription, + }; + + // Start WebSocket server using axum + let app = Router::new() + .route("/ws", get(websocket_handler)) + .route("/", get(|| async { "WebSocket server running" })) + .fallback(|uri: axum::http::Uri| async move { + debug!("Fallback route hit: {}", uri); + "Not found" + }) + .with_state(CustomState { config, is_closed }); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let ws_addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + info!("Starting axum WebSocket server..."); + axum::serve(listener, app).await.unwrap(); + }); + + // Wait a moment for the server to start + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + info!("Axum server running on {}", ws_addr); + + // Start HTTP mock server for regular GraphQL queries + let http_server = wiremock::MockServer::start().await; + + // Mock regular GraphQL queries (non-subscription) + Mock::given(method("POST")) + .respond_with(|req: &wiremock::Request| { + let body = req + .body_json::() + .unwrap_or_else(|_| json!({})); + + if let Some(query) = body.get("query").and_then(|q| q.as_str()) { + // Don't handle subscriptions here - they go through WebSocket + if !query.contains("subscription") { + return ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "_entities": [{ + "name": "Test User", + "username": "testuser" + }] + } + })); + } + } + + // For subscription queries over HTTP, redirect to WebSocket + ResponseTemplate::new(400).set_body_json(json!({ + "errors": [{ + "message": "Subscriptions must use WebSocket" + }] + })) + }) + .mount(&http_server) + .await; + + (ws_addr, http_server) +} + +pub async fn start_coprocessor_server() -> wiremock::MockServer { + let coprocessor_server = wiremock::MockServer::start().await; + + // Create a coprocessor that echoes back what it receives + Mock::given(method("POST")) + .respond_with(|req: &wiremock::Request| { + // Echo back the request body as the response + let body = req.body.clone(); + debug!( + "Coprocessor received request: {}", + String::from_utf8_lossy(&body) + ); + + ResponseTemplate::new(200) + .set_body_bytes(body) + .append_header("content-type", "application/json") + }) + .mount(&coprocessor_server) + .await; + + info!( + "Coprocessor server started at: {}", + coprocessor_server.uri() + ); + coprocessor_server +} + +fn is_json_field(field: &multer::Field<'_>) -> bool { + field + .content_type() + .is_some_and(|mime| mime.essence_str() == "application/json") +} + +pub async fn verify_subscription_events( + stream: impl futures::Stream> + Send, + expected_events: Vec, + include_heartbeats: bool, +) -> Vec { + use pretty_assertions::assert_eq; + + // Use `multipart/form-data` parsing. The router actually responds with `multipart/mixed`, but + // the formats are compatible. + let mut multipart = multer::Multipart::new(stream, "graphql"); + + let mut subscription_events = Vec::new(); + // Set a longer timeout for receiving all events + let timeout = tokio::time::timeout(tokio::time::Duration::from_secs(60), async { + while let Some(field) = multipart + .next_field() + .await + .expect("could not read next chunk") + { + assert!(is_json_field(&field), "all response chunks must be JSON"); + + let parsed: serde_json::Value = field.json().await.expect("invalid JSON chunk"); + if parsed == serde_json::json!({}) && !include_heartbeats { + continue; + } + + subscription_events.push(parsed); + } + + // If we've received more events than expected, that's an error + assert!( + subscription_events.len() <= expected_events.len(), + "Received {} events but only expected {}. Extra events should not arrive after termination.\nUnexpected event: {}", + subscription_events.len(), + expected_events.len(), + subscription_events.last().unwrap(), + ); + }); + + timeout.await.expect("Subscription test timed out"); + assert!( + subscription_events.len() == expected_events.len(), + "Received {} events but expected {}. Stream may have terminated early.", + subscription_events.len(), + expected_events.len() + ); + + // Give the stream a moment to ensure it's properly terminated and no more events arrive + let termination_timeout = + tokio::time::timeout(tokio::time::Duration::from_millis(1000), async { + while let Some(field) = multipart + .next_field() + .await + .expect("could not read next chunk") + { + assert!(is_json_field(&field), "all response chunks must be JSON"); + + let parsed: serde_json::Value = field.json().await.expect("invalid JSON chunk"); + let data = parsed + .get("data") + .or_else(|| parsed.get("payload").and_then(|p| p.get("data"))); + + assert!( + data.is_none(), + "Unexpected additional event received after {} expected events: {}", + expected_events.len(), + parsed + ); + } + }); + + assert!( + termination_timeout.await.is_ok(), + "subscription should have closed cleanly" + ); + // Simple equality comparison using pretty_assertions + assert_eq!( + subscription_events, expected_events, + "Subscription events do not match expected events" + ); + + subscription_events +} + +async fn websocket_handler( + State(CustomState { config, is_closed }): State, + ws: WebSocketUpgrade, + headers: axum::http::HeaderMap, +) -> Response { + debug!("WebSocket upgrade requested"); + debug!("Headers: {:?}", headers); + ws.protocols(["graphql-ws"]) + .on_upgrade(move |socket| handle_websocket(socket, config, is_closed)) +} + +async fn handle_websocket( + mut socket: WebSocket, + config: SubscriptionServerConfig, + is_closed: Arc, +) { + info!("WebSocket connection established"); + 'global: while let Some(msg) = socket.recv().await { + if let Ok(msg) = msg { + match msg { + axum::extract::ws::Message::Text(text) => { + if let Ok(parsed) = serde_json::from_str::(&text) { + match parsed.get("type").and_then(|t| t.as_str()) { + Some("connection_init") => { + // Send connection_ack + let ack = json!({ + "type": "connection_ack" + }); + if socket + .send(axum::extract::ws::Message::text( + serde_json::to_string(&ack).unwrap(), + )) + .await + .is_err() + { + break 'global; + } + } + Some("start") => { + let id = parsed.get("id").and_then(|i| i.as_str()).unwrap_or("1"); + + // Handle subscription + if let Some(payload) = parsed.get("payload") + && let Some(query) = + payload.get("query").and_then(|q| q.as_str()) + && query.contains("userWasCreated") + { + let interval_ms = config.interval_ms; + let payloads = &config.payloads; + + info!( + "Starting subscription with {} events, interval {}ms (configured)", + payloads.len(), + interval_ms + ); + + // Give the router time to fully establish the subscription stream + tokio::time::sleep(tokio::time::Duration::from_millis(100)) + .await; + + // Send multiple subscription events + for (i, custom_payload) in payloads.iter().enumerate() { + // Always send exactly what we're given - no transformation + let event_data = json!({ + "id": id, + "type": "data", + "payload": custom_payload + }); + + if socket + .send(axum::extract::ws::Message::text( + serde_json::to_string(&event_data).unwrap(), + )) + .await + .is_err() + { + break 'global; + } + + debug!( + "Sent subscription event {}/{}", + i + 1, + payloads.len() + ); + + // Wait between events + if i < payloads.len() - 1 { + tokio::time::sleep(tokio::time::Duration::from_millis( + interval_ms, + )) + .await; + } + } + + if config.terminate_subscription { + // Send completion + let complete = json!({ + "id": id, + "type": "complete" + }); + if socket + .send(axum::extract::ws::Message::text( + serde_json::to_string(&complete).unwrap(), + )) + .await + .is_err() + { + break 'global; + } + + info!( + "Completed subscription with {} events", + payloads.len() + ); + } else { + info!( + "Sent {} subscription events but did not send `complete` message", + payloads.len() + ); + } + } + } + Some("stop") => { + // Handle stop message + break 'global; + } + _ => {} + } + } + } + axum::extract::ws::Message::Close(_) => break 'global, + _ => {} + } + } + } + is_closed.store(true, std::sync::atomic::Ordering::Relaxed); +} + +pub async fn start_callback_server() -> (SocketAddr, CallbackTestState) { + let state = CallbackTestState::default(); + let app_state = state.clone(); + + let app = Router::new() + .route("/callback/{id}", post(handle_callback)) + .route("/callback", post(handle_callback_no_id)) + .route("/", get(|| async { "Callback server running" })) + .with_state(app_state); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + info!("Starting callback server..."); + axum::serve(listener, app).await.unwrap(); + }); + + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + info!("Callback server running on {}", addr); + + (addr, state) +} + +async fn handle_callback( + State(state): State, + axum::extract::Path(id): axum::extract::Path, + headers: HeaderMap, + axum::extract::Json(payload): axum::extract::Json, +) -> StatusCode { + debug!("Received callback for subscription {}: {:?}", id, payload); + debug!("Headers: {:?}", headers); + + if payload.id != id { + warn!("ID mismatch: URL={}, payload={}", id, payload.id); + return StatusCode::BAD_REQUEST; + } + + { + let mut callbacks = state.received_callbacks.lock().unwrap(); + callbacks.push(payload.clone()); + } + + match payload.action.as_str() { + "check" => { + let ids = state.subscription_ids.lock().unwrap(); + if ids.contains(&payload.id) { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } + } + "next" | "complete" => { + let ids = state.subscription_ids.lock().unwrap(); + if ids.contains(&payload.id) { + if payload.action == "next" { + StatusCode::OK + } else { + StatusCode::ACCEPTED + } + } else { + StatusCode::NOT_FOUND + } + } + "heartbeat" => { + let ids = state.subscription_ids.lock().unwrap(); + let all_valid = payload + .ids + .as_ref() + .is_none_or(|callback_ids| callback_ids.iter().all(|id| ids.contains(id))); + + if all_valid { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } + } + _ => StatusCode::BAD_REQUEST, + } +} + +async fn handle_callback_no_id( + State(state): State, + headers: HeaderMap, + axum::extract::Json(payload): axum::extract::Json, +) -> StatusCode { + debug!("Received callback without ID: {:?}", payload); + debug!("Headers: {:?}", headers); + + { + let mut callbacks = state.received_callbacks.lock().unwrap(); + callbacks.push(payload.clone()); + } + + match payload.action.as_str() { + "heartbeat" => StatusCode::NO_CONTENT, + _ => StatusCode::BAD_REQUEST, + } +} + +pub async fn start_callback_subgraph_server( + nb_events: usize, + interval_ms: u64, + callback_url: String, +) -> wiremock::MockServer { + start_callback_subgraph_server_with_payloads( + generate_default_payloads(nb_events), + interval_ms, + callback_url, + ) + .await +} + +pub async fn start_callback_subgraph_server_with_payloads( + payloads: Vec, + interval_ms: u64, + callback_url: String, +) -> wiremock::MockServer { + let server = wiremock::MockServer::start().await; + + Mock::given(method("POST")) + .respond_with(move |req: &wiremock::Request| { + let body = req + .body_json::() + .unwrap_or_else(|_| json!({})); + + if let Some(query) = body.get("query").and_then(|q| q.as_str()) { + if query.contains("subscription") && query.contains("userWasCreated") { + let extensions = body.get("extensions"); + let subscription_ext = extensions.and_then(|e| e.get("subscription")); + + if let Some(sub_ext) = subscription_ext { + let subscription_id = sub_ext + .get("subscriptionId") + .and_then(|id| id.as_str()) + .unwrap_or("test-sub-id"); + let callback_url = sub_ext + .get("callbackUrl") + .and_then(|url| url.as_str()) + .unwrap_or(&callback_url); + + info!( + "Subgraph received subscription request with callback URL: {}", + callback_url + ); + info!("Subscription ID: {}", subscription_id); + + tokio::spawn(send_callback_events_with_payloads( + callback_url.to_string(), + subscription_id.to_string(), + payloads.clone(), + interval_ms, + )); + + return ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "userWasCreated": null + } + })); + } + } + + return ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "_entities": [{ + "name": "Test User", + "username": "testuser" + }] + } + })); + } + + ResponseTemplate::new(400).set_body_json(json!({ + "errors": [{ + "message": "Invalid request" + }] + })) + }) + .mount(&server) + .await; + + info!("Callback subgraph server started at: {}", server.uri()); + server +} + +pub fn generate_default_payloads(nb_events: usize) -> Vec { + (1..=nb_events) + .map(|i| { + json!({ + "data": { + "userWasCreated": { + "name": format!("User {}", i), + "reviews": [{ + "body": format!("Review {} from user {}", i, i) + }] + } + } + }) + }) + .collect() +} + +async fn send_callback_events_with_payloads( + callback_url: String, + subscription_id: String, + payloads: Vec, + interval_ms: u64, +) { + let client = reqwest::Client::new(); + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + for (i, custom_payload) in payloads.iter().enumerate() { + let payload = CallbackPayload { + kind: "subscription".to_string(), + action: "next".to_string(), + id: subscription_id.clone(), + verifier: "test-verifier".to_string(), + payload: Some(custom_payload.clone()), + errors: None, + ids: None, + }; + + let response = client.post(&callback_url).json(&payload).send().await; + + match response { + Ok(resp) => debug!( + "Sent callback event {}/{}, status: {}", + i + 1, + payloads.len(), + resp.status() + ), + Err(e) => warn!("Failed to send callback event {}: {}", i + 1, e), + } + + if i < payloads.len() - 1 { + tokio::time::sleep(tokio::time::Duration::from_millis(interval_ms)).await; + } + } + + let complete_payload = CallbackPayload { + kind: "subscription".to_string(), + action: "complete".to_string(), + id: subscription_id.clone(), + verifier: "test-verifier".to_string(), + payload: None, + errors: None, + ids: None, + }; + + let response = client + .post(&callback_url) + .json(&complete_payload) + .send() + .await; + + match response { + Ok(resp) => info!("Sent completion callback, status: {}", resp.status()), + Err(e) => warn!("Failed to send completion callback: {}", e), + } +} diff --git a/apollo-router/tests/integration/subscriptions/ws_passthrough.rs b/apollo-router/tests/integration/subscriptions/ws_passthrough.rs new file mode 100644 index 0000000000..e4c61e8d4d --- /dev/null +++ b/apollo-router/tests/integration/subscriptions/ws_passthrough.rs @@ -0,0 +1,919 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use regex::Regex; +use tower::BoxError; +use tracing::info; + +use crate::integration::common::IntegrationTest; +use crate::integration::common::graph_os_enabled; +use crate::integration::subscriptions::SUBSCRIPTION_CONFIG; +use crate::integration::subscriptions::SUBSCRIPTION_COPROCESSOR_CONFIG; +use crate::integration::subscriptions::create_sub_query; +use crate::integration::subscriptions::start_coprocessor_server; +use crate::integration::subscriptions::start_subscription_server_with_payloads; +use crate::integration::subscriptions::verify_subscription_events; + +/// Creates an expected subscription event payload for a schema reload +fn create_expected_schema_reload_payload() -> serde_json::Value { + serde_json::json!({ + "payload": null, + "errors": [ + { + "message": "subscription has been closed due to a schema reload", + "extensions": { + "code": "SUBSCRIPTION_SCHEMA_RELOAD" + } + } + ] + }) +} + +/// Creates an expected subscription event payload for a configuration reload +fn create_expected_config_reload_payload() -> serde_json::Value { + serde_json::json!({ + "payload": null, + "errors": [ + { + "message": "subscription has been closed due to a configuration reload", + "extensions": { + "code": "SUBSCRIPTION_CONFIG_RELOAD" + } + } + ] + }) +} + +/// Creates an expected subscription event payload for the given user number +fn create_expected_user_payload(user_num: u32) -> serde_json::Value { + serde_json::json!({ + "payload": { + "data": { + "userWasCreated": { + "name": format!("User {}", user_num), + "reviews": [{"body": format!("Review {} from user {}", user_num, user_num)}] + } + } + } + }) +} + +/// Creates an expected subscription event payload with null userWasCreated (for empty/error payloads) +fn create_expected_null_payload() -> serde_json::Value { + serde_json::json!({ + "payload": { + "data": { + "userWasCreated": null + } + } + }) +} + +/// Creates an expected subscription event payload for a user with missing reviews field (becomes null) +fn create_expected_user_payload_missing_reviews(user_num: u32) -> serde_json::Value { + serde_json::json!({ + "payload": { + "data": { + "userWasCreated": { + "name": format!("User {}", user_num), + "reviews": null // Missing reviews field gets transformed to null + } + } + } + }) +} + +/// Creates an expected subscription event payload for a user with missing reviews field (becomes null) and error +fn create_expected_partial_error_payload(user_num: u32) -> serde_json::Value { + serde_json::json!({ + "payload": { + "data": { + "userWasCreated": { + "name": format!("User {}", user_num), + "reviews": null // Missing reviews field gets transformed to null + } + }, + "errors": [ + { + "message": "Internal error handling deferred response", + "extensions": { + "code": "INTERNAL_ERROR" + } + } + ] + } + }) +} + +/// Creates an expected subscription event payload for a user with missing reviews field (becomes null) and error +fn create_expected_error_payload() -> serde_json::Value { + serde_json::json!({ + "payload": { + "data": { + "userWasCreated": null + }, + "errors": [{ + "message": "Internal error handling deferred response", + "extensions": {"code": "INTERNAL_ERROR"} + }] + }, + }) +} + +/// Creates the initial empty subscription response +fn create_initial_empty_response() -> serde_json::Value { + serde_json::json!({}) +} + +// Input payload helpers (what we send to the mock WebSocket server) + +/// Creates a GraphQL data payload for a user (sent to mock server) +fn create_user_data_payload(user_num: u32) -> serde_json::Value { + serde_json::json!({ + "data": { + "userWasCreated": { + "name": format!("User {}", user_num), + "reviews": [{ + "body": format!("Review {} from user {}", user_num, user_num) + }] + } + } + }) +} + +/// Creates a GraphQL data payload with missing reviews field (sent to mock server) +fn create_user_data_payload_missing_reviews(user_num: u32) -> serde_json::Value { + serde_json::json!({ + "data": { + "userWasCreated": { + "name": format!("User {}", user_num) + // Missing reviews field to test error handling + } + }, + "errors": [] + }) +} + +/// Creates an empty payload (sent to mock server) +fn create_empty_data_payload() -> serde_json::Value { + serde_json::json!({ + // No data attribute at all + }) +} + +/// Creates an expected error response payload (sent to mock server) +fn create_partial_error_payload(user_num: u32) -> serde_json::Value { + serde_json::json!({ + "data": { + "userWasCreated": { + "name": format!("User {}", user_num), + } + }, + "errors": [ + { + "message": "Internal error handling deferred response", + "extensions": { + "code": "INTERNAL_ERROR" + } + } + ] + }) +} + +/// Creates an expected error response payload (sent to mock server) +fn create_error_payload() -> serde_json::Value { + serde_json::json!({ + "data": { + "userWasCreated": null + }, + "errors": [ + { + "message": "Internal error handling deferred response", + "extensions": { + "code": "INTERNAL_ERROR" + } + } + ] + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subscription_ws_passthrough() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + + // Create fixed payloads for consistent testing + let custom_payloads = vec![create_user_data_payload(1), create_user_data_payload(2)]; + let interval_ms = 10; + let is_closed = Arc::new(AtomicBool::new(false)); + // Start subscription server with fixed payloads + let (ws_addr, http_server) = start_subscription_server_with_payloads( + custom_payloads.clone(), + interval_ms, + true, + is_closed.clone(), + ) + .await; + + // Create router with port reservations + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(SUBSCRIPTION_CONFIG) + .build() + .await; + + // Configure URLs using the string replacement method + let ws_url = format!("ws://{ws_addr}/ws"); + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", &http_server.uri()); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", &ws_url); + router.replace_config_string("rng:", "accounts:"); + + info!("WebSocket server started at: {}", ws_url); + + router.start().await; + router.assert_started().await; + + // Use the configured query that matches our server configuration + let query = create_sub_query(interval_ms, custom_payloads.len()); + let (_, response) = router.run_subscription(&query).await; + + // Expect the router to handle the subscription successfully + assert!( + response.status().is_success(), + "Subscription request failed with status: {}", + response.status() + ); + + let stream = response.bytes_stream(); + let expected_events = vec![ + create_initial_empty_response(), + create_expected_user_payload(1), + create_expected_user_payload(2), + ]; + let _subscription_events = verify_subscription_events(stream, expected_events, true).await; + + // Check for errors in router logs + router.assert_no_error_logs(); + + assert!(is_closed.load(std::sync::atomic::Ordering::Relaxed)); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subscription_ws_passthrough_with_coprocessor() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + // Create fixed payloads for this test (different from first test) + let custom_payloads = vec![create_user_data_payload(1), create_user_data_payload(2)]; + let interval_ms = 10; + let is_closed = Arc::new(AtomicBool::new(false)); + + // Start subscription server and coprocessor + let (ws_addr, http_server) = start_subscription_server_with_payloads( + custom_payloads.clone(), + interval_ms, + true, + is_closed.clone(), + ) + .await; + let coprocessor_server = start_coprocessor_server().await; + + // Create router with port reservations + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(SUBSCRIPTION_COPROCESSOR_CONFIG) + .build() + .await; + + // Configure URLs using the string replacement method + let ws_url = format!("ws://{ws_addr}/ws"); + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", &http_server.uri()); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", &ws_url); + router.replace_config_string( + "http://localhost:{{COPROCESSOR_PORT}}", + &coprocessor_server.uri(), + ); + router.replace_config_string("rng:", "accounts:"); + + info!("WebSocket server started at: {}", ws_url); + info!( + "Coprocessor server started at: {}", + coprocessor_server.uri() + ); + + router.start().await; + router.assert_started().await; + + // Use the configured query that matches our server configuration + let query = create_sub_query(interval_ms, custom_payloads.len()); + let (_, response) = router.run_subscription(&query).await; + + // Expect the router to handle the subscription successfully + assert!( + response.status().is_success(), + "Subscription request failed with status: {}", + response.status() + ); + + let stream = response.bytes_stream(); + // Now we're storing raw responses, so expect the actual multipart response structure + // First event is an empty object (subscription initialization), followed by data events + let expected_events = vec![ + create_initial_empty_response(), + create_expected_user_payload(1), + create_expected_user_payload(2), + ]; + + let _subscription_events = verify_subscription_events(stream, expected_events, true).await; + + // Check for errors in router logs (allow expected coprocessor error) + router.assert_no_error_logs(); + assert!(is_closed.load(std::sync::atomic::Ordering::Relaxed)); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subscription_ws_passthrough_error_payload() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + // Create custom payloads: one normal event, one error event (no reviews field) + let custom_payloads = vec![ + create_user_data_payload(1), + create_user_data_payload_missing_reviews(2), + ]; + let interval_ms = 10; + let is_closed = Arc::new(AtomicBool::new(false)); + + // Start subscription server with custom payloads + let (ws_addr, http_server) = start_subscription_server_with_payloads( + custom_payloads.clone(), + interval_ms, + true, + is_closed.clone(), + ) + .await; + + // Create router with port reservations + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(SUBSCRIPTION_CONFIG) + .build() + .await; + + // Configure URLs using the string replacement method + let ws_url = format!("ws://{ws_addr}/ws"); + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", &http_server.uri()); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", &ws_url); + router.replace_config_string("rng:", "accounts:"); + + info!("WebSocket server started at: {}", ws_url); + + router.start().await; + router.assert_started().await; + + let subscription_query = create_sub_query(interval_ms, custom_payloads.len()); + + let response = router + .execute_query( + crate::integration::common::Query::builder() + .body(serde_json::json!({ + "query": subscription_query + })) + .headers(std::collections::HashMap::from([( + "Accept".to_string(), + "multipart/mixed;subscriptionSpec=1.0".to_string(), + )])) + .build(), + ) + .await; + + assert!( + response.1.status().is_success(), + "Subscription request failed with status: {}", + response.1.status() + ); + + let stream = response.1.bytes_stream(); + // Now we're storing raw responses, so expect the actual multipart response structure + // First event is an empty object (subscription initialization), followed by data events + let expected_events = vec![ + create_initial_empty_response(), + create_expected_user_payload(1), + create_expected_user_payload_missing_reviews(2), + ]; + let _subscription_events = verify_subscription_events(stream, expected_events, true).await; + + // Check for errors in router logs + router.assert_no_error_logs(); + assert!(is_closed.load(std::sync::atomic::Ordering::Relaxed)); + + Ok(()) +} + +// We have disabled this test because this test is failing for reasons that are understood, but are now preventing us from doing other fixes. We will ensure this is fixed by tracking this in the attached ticket as a follow up on its own PR. +// The bug is basically an inconsistency in the way we're returning an error, sometimes it's consider as a critical error, sometimes not. +#[tokio::test(flavor = "multi_thread")] +async fn test_subscription_ws_passthrough_pure_error_payload() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + // Create custom payloads: one normal event, one partial error event (data and errors), one pure error event (no data, only errors) + let custom_payloads = vec![ + create_user_data_payload(1), + create_partial_error_payload(2), + create_error_payload(), + ]; + let interval_ms = 10; + let is_closed = Arc::new(AtomicBool::new(false)); + + // Start subscription server with custom payloads + let (ws_addr, http_server) = start_subscription_server_with_payloads( + custom_payloads.clone(), + interval_ms, + true, + is_closed.clone(), + ) + .await; + + // Create router with port reservations + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(SUBSCRIPTION_CONFIG) + .build() + .await; + + // Configure URLs using the string replacement method + let ws_url = format!("ws://{ws_addr}/ws"); + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", &http_server.uri()); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", &ws_url); + router.replace_config_string("rng:", "accounts:"); + + info!("WebSocket server started at: {}", ws_url); + + router.start().await; + router.assert_started().await; + + let subscription_query = create_sub_query(interval_ms, custom_payloads.len()); + + let response = router + .execute_query( + crate::integration::common::Query::builder() + .body(serde_json::json!({ + "query": subscription_query + })) + .headers(std::collections::HashMap::from([( + "Accept".to_string(), + "multipart/mixed;subscriptionSpec=1.0".to_string(), + )])) + .build(), + ) + .await; + + assert!( + response.1.status().is_success(), + "Subscription request failed with status: {}", + response.1.status() + ); + + let stream = response.1.bytes_stream(); + // Now we're storing raw responses, so expect the actual multipart response structure + // First event is an empty object (subscription initialization), followed by data events + let expected_events = vec![ + create_initial_empty_response(), + create_expected_user_payload(1), + create_expected_partial_error_payload(2), + create_expected_error_payload(), + ]; + let _subscription_events = verify_subscription_events(stream, expected_events, true).await; + + // Check for errors in router logs + router.assert_no_error_logs(); + assert!(is_closed.load(std::sync::atomic::Ordering::Relaxed)); + + Ok(()) +} + +// We have disabled this test because this test is failing for reasons that are understood, but are now preventing us from doing other fixes. We will ensure this is fixed by tracking this in the attached ticket as a follow up on its own PR. +// The bug is basically an inconsistency in the way we're returning an error, sometimes it's consider as a critical error, sometimes not. +#[tokio::test(flavor = "multi_thread")] +async fn test_subscription_ws_passthrough_pure_error_payload_with_coprocessor() +-> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + // Create custom payloads: one normal event, one pure error event (no data, only errors) + let custom_payloads = vec![ + create_user_data_payload(1), + create_empty_data_payload(), // Missing required "data" or "errors" field + create_user_data_payload(2), // This event is received successfully + create_partial_error_payload(3), + create_error_payload(), + ]; + let interval_ms = 10; + let is_closed = Arc::new(AtomicBool::new(false)); + + // Start subscription server and coprocessor + let (ws_addr, http_server) = start_subscription_server_with_payloads( + custom_payloads.clone(), + interval_ms, + true, + is_closed.clone(), + ) + .await; + let coprocessor_server = start_coprocessor_server().await; + + // Create router with port reservations + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(SUBSCRIPTION_COPROCESSOR_CONFIG) + .build() + .await; + + // Configure URLs using the string replacement method + let ws_url = format!("ws://{ws_addr}/ws"); + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", &http_server.uri()); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", &ws_url); + router.replace_config_string( + "http://localhost:{{COPROCESSOR_PORT}}", + &coprocessor_server.uri(), + ); + router.replace_config_string("rng:", "accounts:"); + + info!("WebSocket server started at: {}", ws_url); + info!( + "Coprocessor server started at: {}", + coprocessor_server.uri() + ); + + router.start().await; + router.assert_started().await; + + let subscription_query = create_sub_query(interval_ms, custom_payloads.len()); + + let response = router + .execute_query( + crate::integration::common::Query::builder() + .body(serde_json::json!({ + "query": subscription_query + })) + .headers(std::collections::HashMap::from([( + "Accept".to_string(), + "multipart/mixed;subscriptionSpec=1.0".to_string(), + )])) + .build(), + ) + .await; + + assert!( + response.1.status().is_success(), + "Subscription request failed with status: {}", + response.1.status() + ); + + let stream = response.1.bytes_stream(); + + // Now we're storing raw responses, so expect the actual multipart response structure + // First event is an empty object (subscription initialization), followed by data events + // The coprocessor processes all events successfully (router transforms empty payloads to valid GraphQL) + let expected_events = vec![ + create_initial_empty_response(), + create_expected_user_payload(1), + create_expected_null_payload(), + create_expected_user_payload(2), + create_expected_partial_error_payload(3), + create_expected_error_payload(), + ]; + let _subscription_events = verify_subscription_events(stream, expected_events, true).await; + + // Check for errors in router logs + router.assert_no_error_logs(); + assert!(is_closed.load(std::sync::atomic::Ordering::Relaxed)); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subscription_ws_passthrough_on_config_reload() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + + // Create fixed payloads for consistent testing + let custom_payloads = vec![create_user_data_payload(1), create_user_data_payload(2)]; + let interval_ms = 10; + let is_closed = Arc::new(AtomicBool::new(false)); + + // Start subscription server with fixed payloads, but do not terminate the connection + let (ws_addr, http_server) = start_subscription_server_with_payloads( + custom_payloads.clone(), + interval_ms, + false, + is_closed.clone(), + ) + .await; + + // Create router with port reservations + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(include_str!( + "fixtures/subscription_schema_reload.router.yaml" + )) + .build() + .await; + + // Configure URLs using the string replacement method + let ws_url = format!("ws://{ws_addr}/ws"); + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", &http_server.uri()); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", &ws_url); + router.replace_config_string("rng:", "accounts:"); + + info!("WebSocket server started at: {}", ws_url); + + router.start().await; + router.assert_started().await; + + // Use the configured query that matches our server configuration + let query = create_sub_query(interval_ms, custom_payloads.len()); + let (_, response) = router.run_subscription(&query).await; + + // Expect the router to handle the subscription successfully + assert!( + response.status().is_success(), + "Subscription request failed with status: {}", + response.status() + ); + + let stream = response.bytes_stream(); + let expected_events = vec![ + create_initial_empty_response(), + create_expected_user_payload(1), + create_expected_user_payload(2), + create_expected_config_reload_payload(), + ]; + + // try to reload the config file + router.replace_config_string("replaceable", "replaced"); + + router.assert_reloaded().await; + + let metrics = router.get_metrics_response().await?.text().await?; + let sum_metric_counts = |regex: &Regex| { + regex + .captures_iter(&metrics) + .flat_map(|cap| cap.get(1).unwrap().as_str().parse::()) + .sum() + }; + let terminating = + Regex::new(r#"(?m)^apollo_router_open_connections[{].+terminating.+[}] ([0-9]+)"#) + .expect("regex"); + let total_terminating: usize = sum_metric_counts(&terminating); + let active = Regex::new(r#"(?m)^apollo_router_open_connections[{].+active.+[}] ([0-9]+)"#) + .expect("regex"); + let total_active: usize = sum_metric_counts(&active); + + assert_eq!(total_active, 1); + assert_eq!(total_active + total_terminating, 1); + + verify_subscription_events(stream, expected_events, true).await; + + router.graceful_shutdown().await; + // router.assert_shutdown().await; + + // Check for errors in router logs + router.assert_log_not_contained("connection shutdown exceeded, forcing close"); + + assert!(is_closed.load(std::sync::atomic::Ordering::Relaxed)); + + info!( + "✅ Passthrough subscription mode test completed successfully with {} events", + custom_payloads.len() + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subscription_ws_passthrough_on_schema_reload() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + + // Create fixed payloads for consistent testing + let custom_payloads = vec![create_user_data_payload(1), create_user_data_payload(2)]; + let interval_ms = 10; + let is_closed = Arc::new(AtomicBool::new(false)); + + // Start subscription server with fixed payloads, but do not terminate the connection + let (ws_addr, http_server) = start_subscription_server_with_payloads( + custom_payloads.clone(), + interval_ms, + false, + is_closed.clone(), + ) + .await; + + // Create router with port reservations + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(include_str!( + "fixtures/subscription_schema_reload.router.yaml" + )) + .build() + .await; + + // Configure URLs using the string replacement method + let ws_url = format!("ws://{ws_addr}/ws"); + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", &http_server.uri()); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", &ws_url); + router.replace_config_string("rng:", "accounts:"); + + info!("WebSocket server started at: {}", ws_url); + + router.start().await; + router.assert_started().await; + + // Use the configured query that matches our server configuration + let query = create_sub_query(interval_ms, custom_payloads.len()); + let (_, response) = router.run_subscription(&query).await; + + // Expect the router to handle the subscription successfully + assert!( + response.status().is_success(), + "Subscription request failed with status: {}", + response.status() + ); + + let stream = response.bytes_stream(); + let expected_events = vec![ + create_initial_empty_response(), + create_expected_user_payload(1), + create_expected_user_payload(2), + create_expected_schema_reload_payload(), + ]; + + // try to reload the config file + router.replace_schema_string("createdAt", "created"); + + router.assert_reloaded().await; + + let metrics = router.get_metrics_response().await?.text().await?; + let sum_metric_counts = |regex: &Regex| { + regex + .captures_iter(&metrics) + .flat_map(|cap| cap.get(1).unwrap().as_str().parse::()) + .sum() + }; + let terminating = + Regex::new(r#"(?m)^apollo_router_open_connections[{].+terminating.+[}] ([0-9]+)"#) + .expect("regex"); + let total_terminating: usize = sum_metric_counts(&terminating); + let active = Regex::new(r#"(?m)^apollo_router_open_connections[{].+active.+[}] ([0-9]+)"#) + .expect("regex"); + let total_active: usize = sum_metric_counts(&active); + + assert_eq!(total_active, 1); + assert_eq!(total_active + total_terminating, 1); + + verify_subscription_events(stream, expected_events, true).await; + + router.graceful_shutdown().await; + // router.assert_shutdown().await; + + // Check for errors in router logs + router.assert_log_not_contained("connection shutdown exceeded, forcing close"); + assert!(is_closed.load(std::sync::atomic::Ordering::Relaxed)); + + info!( + "✅ Passthrough subscription mode test completed successfully with {} events", + custom_payloads.len() + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subscription_ws_passthrough_dedup() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + + // Create fixed payloads for consistent testing + let custom_payloads = vec![create_user_data_payload(1), create_user_data_payload(2)]; + let interval_ms = 50; + let is_closed = Arc::new(AtomicBool::new(false)); + + // Start subscription server with fixed payloads, but do not terminate the connection + let (ws_addr, http_server) = start_subscription_server_with_payloads( + custom_payloads.clone(), + interval_ms, + false, + is_closed.clone(), + ) + .await; + + // Create router with port reservations + let mut router = IntegrationTest::builder() + .supergraph("tests/integration/subscriptions/fixtures/supergraph.graphql") + .config(include_str!( + "fixtures/subscription_schema_reload.router.yaml" + )) + .build() + .await; + + // Configure URLs using the string replacement method + let ws_url = format!("ws://{ws_addr}/ws"); + router.replace_config_string("http://localhost:{{PRODUCTS_PORT}}", &http_server.uri()); + router.replace_config_string("http://localhost:{{ACCOUNTS_PORT}}", &ws_url); + router.replace_config_string("rng:", "accounts:"); + + info!("WebSocket server started at: {}", ws_url); + + router.start().await; + router.assert_started().await; + + // Use the configured query that matches our server configuration + let query = create_sub_query(interval_ms, custom_payloads.len()); + let ((_, response), (_, response_bis)) = futures::join!( + router.run_subscription(&query), + router.run_subscription(&query) + ); + + // Expect the router to handle the subscription successfully + assert!( + response.status().is_success(), + "Subscription request failed with status: {}", + response.status() + ); + assert!( + response_bis.status().is_success(), + "Subscription request failed with status: {}", + response_bis.status() + ); + + let metrics = router.get_metrics_response().await?.text().await?; + let sum_metric_counts = |regex: &Regex| { + regex + .captures_iter(&metrics) + .flat_map(|cap| cap.get(1).unwrap().as_str().parse::()) + .sum() + }; + + let stream = response.bytes_stream(); + + let stream_bis = response_bis.bytes_stream(); + + let deduplicated_sub = + Regex::new(r#"(?m)^apollo_router_operations_subscriptions_total[{].+subscriptions_deduplicated="true".+[}] ([0-9]+)"#) + .expect("regex"); + let total_deduplicated_sub: usize = sum_metric_counts(&deduplicated_sub); + assert_eq!(total_deduplicated_sub, 1); + let duplicated_sub = + Regex::new(r#"(?m)^apollo_router_operations_subscriptions_total[{].+subscriptions_deduplicated="false".+[}] ([0-9]+)"#) + .expect("regex"); + let total_duplicated_sub: usize = sum_metric_counts(&duplicated_sub); + assert_eq!(total_duplicated_sub, 1); + + // Trick to close the subscription server side + router.replace_schema_string("createdAt", "created"); + + let expected_events = vec![ + create_initial_empty_response(), + create_expected_user_payload(1), + create_expected_user_payload(2), + create_expected_schema_reload_payload(), + ]; + verify_subscription_events(stream, expected_events, true).await; + let expected_events = vec![ + create_initial_empty_response(), + create_expected_user_payload(1), + create_expected_user_payload(2), + create_expected_schema_reload_payload(), + ]; + verify_subscription_events(stream_bis, expected_events, true).await; + + router.graceful_shutdown().await; + + assert!(is_closed.load(std::sync::atomic::Ordering::Relaxed)); + // Check for errors in router logs + router.assert_log_not_contained("connection shutdown exceeded, forcing close"); + + info!( + "✅ Passthrough subscription mode test completed successfully with {} events", + custom_payloads.len() + ); + + Ok(()) +} diff --git a/apollo-router/tests/integration/supergraph.rs b/apollo-router/tests/integration/supergraph.rs index 97d5131d84..3327858c98 100644 --- a/apollo-router/tests/integration/supergraph.rs +++ b/apollo-router/tests/integration/supergraph.rs @@ -4,27 +4,8 @@ use serde_json::json; use tower::BoxError; use crate::integration::IntegrationTest; +use crate::integration::common::Query; -#[cfg(not(feature = "hyper_header_limits"))] -#[tokio::test(flavor = "multi_thread")] -async fn test_supergraph_error_http1_max_headers_config() -> Result<(), BoxError> { - let mut router = IntegrationTest::builder() - .config( - r#" - limits: - http1_max_request_headers: 100 - "#, - ) - .build() - .await; - - router.start().await; - router.assert_log_contains("'limits.http1_max_request_headers' requires 'hyper_header_limits' feature: enable 'hyper_header_limits' feature in order to use 'limits.http1_max_request_headers'").await; - router.assert_not_started().await; - Ok(()) -} - -#[cfg(feature = "hyper_header_limits")] #[tokio::test(flavor = "multi_thread")] async fn test_supergraph_errors_on_http1_max_headers() -> Result<(), BoxError> { let mut router = IntegrationTest::builder() @@ -46,13 +27,17 @@ async fn test_supergraph_errors_on_http1_max_headers() -> Result<(), BoxError> { } let (_trace_id, response) = router - .execute_query_with_headers(&json!({ "query": "{ __typename }"}), headers) + .execute_query( + Query::builder() + .body(json!({ "query": "{ __typename }"})) + .headers(headers) + .build(), + ) .await; assert_eq!(response.status(), 431); Ok(()) } -#[cfg(feature = "hyper_header_limits")] #[tokio::test(flavor = "multi_thread")] async fn test_supergraph_allow_to_change_http1_max_headers() -> Result<(), BoxError> { let mut router = IntegrationTest::builder() @@ -74,7 +59,12 @@ async fn test_supergraph_allow_to_change_http1_max_headers() -> Result<(), BoxEr } let (_trace_id, response) = router - .execute_query_with_headers(&json!({ "query": "{ __typename }"}), headers) + .execute_query( + Query::builder() + .body(json!({ "query": "{ __typename }"})) + .headers(headers) + .build(), + ) .await; assert_eq!(response.status(), 200); assert_eq!( @@ -85,8 +75,8 @@ async fn test_supergraph_allow_to_change_http1_max_headers() -> Result<(), BoxEr } #[tokio::test(flavor = "multi_thread")] -async fn test_supergraph_errors_on_http1_header_that_does_not_fit_inside_buffer( -) -> Result<(), BoxError> { +async fn test_supergraph_errors_on_http1_header_that_does_not_fit_inside_buffer() +-> Result<(), BoxError> { let mut router = IntegrationTest::builder() .config( r#" @@ -100,11 +90,13 @@ async fn test_supergraph_errors_on_http1_header_that_does_not_fit_inside_buffer( router.start().await; router.assert_started().await; - let mut headers = HashMap::new(); - headers.insert("test-header".to_string(), "x".repeat(1048576 + 1)); - let (_trace_id, response) = router - .execute_query_with_headers(&json!({ "query": "{ __typename }"}), headers) + .execute_query( + Query::builder() + .body(json!({ "query": "{ __typename }"})) + .header("test-header", "x".repeat(1048576 + 1)) + .build(), + ) .await; assert_eq!(response.status(), 431); Ok(()) @@ -125,11 +117,13 @@ async fn test_supergraph_allow_to_change_http1_max_buf_size() -> Result<(), BoxE router.start().await; router.assert_started().await; - let mut headers = HashMap::new(); - headers.insert("test-header".to_string(), "x".repeat(1048576 + 1)); - let (_trace_id, response) = router - .execute_query_with_headers(&json!({ "query": "{ __typename }"}), headers) + .execute_query( + Query::builder() + .body(json!({ "query": "{ __typename }"})) + .header("test-header", "x".repeat(1048576 + 1)) + .build(), + ) .await; assert_eq!(response.status(), 200); assert_eq!( diff --git a/apollo-router/tests/integration/telemetry/apollo_otel_metrics.rs b/apollo-router/tests/integration/telemetry/apollo_otel_metrics.rs new file mode 100644 index 0000000000..c18a29eef2 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/apollo_otel_metrics.rs @@ -0,0 +1,913 @@ +use std::fmt; +use std::fmt::Debug; +use std::fmt::Display; +use std::path::PathBuf; +use std::time::Duration; + +use ahash::HashMap; +use apollo_router::graphql; +use displaydoc::Display; +use opentelemetry::Value; +use opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceRequest; +use opentelemetry_proto::tonic::common::v1::AnyValue; +use opentelemetry_proto::tonic::common::v1::any_value::Value::BoolValue; +use opentelemetry_proto::tonic::common::v1::any_value::Value::DoubleValue; +use opentelemetry_proto::tonic::common::v1::any_value::Value::IntValue; +use opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue; +use opentelemetry_proto::tonic::metrics::v1::HistogramDataPoint; +use opentelemetry_proto::tonic::metrics::v1::NumberDataPoint; +use opentelemetry_proto::tonic::metrics::v1::metric; +use opentelemetry_proto::tonic::metrics::v1::number_data_point; +use serde_json::json; +use wiremock::ResponseTemplate; + +use crate::integration::IntegrationTest; +use crate::integration::common::Query; +use crate::integration::common::Telemetry; +use crate::integration::common::graph_os_enabled; + +#[tokio::test(flavor = "multi_thread")] +async fn test_validation_error_emits_metric() { + if !graph_os_enabled() { + return; + } + let expected_service = ""; + let expected_error_code = "GRAPHQL_VALIDATION_FAILED"; + let expected_operation_name = "# GraphQLValidationFailure"; + + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { endpoint: None }) + .config( + r#" + telemetry: + apollo: + experimental_otlp_metrics_protocol: http + batch_processor: + scheduled_delay: 10ms + errors: + preview_extended_error_metrics: enabled + "#, + ) + .responder(ResponseTemplate::new(500).append_header("Content-Type", "application/json")) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router + .execute_query(Query::default().with_invalid_query()) + .await; + + let response = response.text().await.unwrap(); + assert!(response.contains(expected_error_code)); + + let metrics = router + .wait_for_emitted_otel_metrics(Duration::from_millis(20)) + .await; + assert!(!metrics.is_empty()); + assert_metrics_contain( + &metrics, + Metric::builder() + .name("apollo.router.operations.error".to_string()) + .attribute("graphql.operation.name", expected_operation_name) + .attribute("graphql.error.extensions.code", expected_error_code) + .attribute("apollo.router.error.service", expected_service) + .value(1) + .build(), + ); + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subgraph_http_error_emits_metric() { + if !graph_os_enabled() { + return; + } + let expected_service = "products"; + let expected_error_code = "SUBREQUEST_HTTP_ERROR"; + let expected_client_name = "CLIENT_NAME"; + let expected_client_version = "v0.14"; + + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { endpoint: None }) + .config( + r#" + telemetry: + apollo: + experimental_otlp_metrics_protocol: http + batch_processor: + scheduled_delay: 10ms + errors: + preview_extended_error_metrics: enabled + include_subgraph_errors: + all: true + "#, + ) + .responder(ResponseTemplate::new(500)) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router + .execute_query( + Query::builder() + .header("apollographql-client-name", expected_client_name) + .header("apollographql-client-version", expected_client_version) + .build(), + ) + .await; + + let response = response.text().await.unwrap(); + assert!(response.contains(expected_error_code)); + + let metrics = router + .wait_for_emitted_otel_metrics(Duration::from_millis(20)) + .await; + + assert!(!metrics.is_empty()); + assert_metrics_contain( + &metrics, + Metric::builder() + .name("apollo.router.operations.error".to_string()) + .attribute("graphql.operation.name", "ExampleQuery") + .attribute("graphql.operation.type", "query") + .attribute("apollo.client.name", expected_client_name) + .attribute("apollo.client.version", expected_client_version) + .attribute("graphql.error.extensions.code", expected_error_code) + .attribute("apollo.router.error.service", expected_service) + // One for each subgraph + .value(2) + .build(), + ); + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subgraph_layer_error_emits_metric() { + if !graph_os_enabled() { + return; + } + let expected_service = "products"; + let expected_error_code = "SUBGRAPH_CODE"; + let expected_client_name = "CLIENT_NAME"; + let expected_client_version = "v0.14"; + let expected_path = "/topProducts/name"; + + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { endpoint: None }) + .config( + r#" + telemetry: + apollo: + experimental_otlp_metrics_protocol: http + batch_processor: + scheduled_delay: 10ms + errors: + preview_extended_error_metrics: enabled + "#, + ) + .responder( + ResponseTemplate::new(200).set_body_json( + graphql::Response::builder() + .data(json!({"data": null})) + .errors(vec![ + graphql::Error::builder() + .message("error in subgraph layer") + .extension_code(expected_error_code) + .extension("service", expected_service) + // Path must not have leading slash to match expected + .path("topProducts/name") + .build(), + ]) + .build(), + ), + ) + .build() + .await; + + router.start().await; + router.assert_started().await; + + router + .execute_query( + Query::builder() + .header("apollographql-client-name", expected_client_name) + .header("apollographql-client-version", expected_client_version) + .build(), + ) + .await; + + let metrics = router + .wait_for_emitted_otel_metrics(Duration::from_millis(20)) + .await; + + assert!(!metrics.is_empty()); + assert_metrics_contain( + &metrics, + Metric::builder() + .name("apollo.router.operations.error".to_string()) + .attribute("graphql.operation.name", "ExampleQuery") + .attribute("graphql.operation.type", "query") + .attribute("apollo.client.name", expected_client_name) + .attribute("apollo.client.version", expected_client_version) + .attribute("graphql.error.extensions.code", expected_error_code) + .attribute("apollo.router.error.service", expected_service) + .attribute("graphql.error.path", expected_path) + .value(1) + .build(), + ); + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_include_subgraph_error_disabled_does_not_redact_error_metrics() { + if !graph_os_enabled() { + return; + } + + let expected_service = "products"; + let expected_error_code = "SUBGRAPH_CODE"; + let expected_client_name = "CLIENT_NAME"; + let expected_client_version = "v0.14"; + let expected_path = "/topProducts/name"; + + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { endpoint: None }) + .config( + r#" + telemetry: + apollo: + experimental_otlp_metrics_protocol: http + batch_processor: + scheduled_delay: 10ms + errors: + preview_extended_error_metrics: enabled + include_subgraph_errors: + all: false + "#, + ) + .responder( + ResponseTemplate::new(200).set_body_json( + graphql::Response::builder() + .data(json!({"data": null})) + .errors(vec![ + graphql::Error::builder() + .message("error in subgraph layer") + .extension_code(expected_error_code) + .extension("service", expected_service) + // Path must not have leading slash to match expected + .path("topProducts/name") + .build(), + ]) + .build(), + ), + ) + .build() + .await; + + router.start().await; + router.assert_started().await; + + router + .execute_query( + Query::builder() + .header("apollographql-client-name", expected_client_name) + .header("apollographql-client-version", expected_client_version) + .build(), + ) + .await; + + let metrics = router + .wait_for_emitted_otel_metrics(Duration::from_millis(20)) + .await; + + assert!(!metrics.is_empty()); + assert_metrics_contain( + &metrics, + Metric::builder() + .name("apollo.router.operations.error".to_string()) + .attribute("graphql.operation.name", "ExampleQuery") + .attribute("graphql.operation.type", "query") + .attribute("apollo.client.name", expected_client_name) + .attribute("apollo.client.version", expected_client_version) + .attribute("graphql.error.extensions.code", expected_error_code) + .attribute("apollo.router.error.service", expected_service) + .attribute("graphql.error.path", expected_path) + .value(1) + .build(), + ); + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_supergraph_layer_error_emits_metric() { + if !graph_os_enabled() { + return; + } + + // Empty service indicates a router error + let expected_service = ""; + let expected_error_code = "INTROSPECTION_DISABLED"; + let expected_client_name = "CLIENT_NAME"; + let expected_client_version = "v0.14"; + + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { endpoint: None }) + .config( + r#" + telemetry: + apollo: + experimental_otlp_metrics_protocol: http + batch_processor: + scheduled_delay: 10ms + errors: + preview_extended_error_metrics: enabled + supergraph: + introspection: false + "#, + ) + .build() + .await; + + router.start().await; + router.assert_started().await; + + router + .execute_query( + Query::builder() + .body(json!({"query": "{ __schema { queryType { name } } }", "variables":{}})) + .header("apollographql-client-name", expected_client_name) + .header("apollographql-client-version", expected_client_version) + .build(), + ) + .await; + + let metrics = router + .wait_for_emitted_otel_metrics(Duration::from_millis(20)) + .await; + + assert!(!metrics.is_empty()); + assert_metrics_contain( + &metrics, + Metric::builder() + .name("apollo.router.operations.error".to_string()) + .attribute("graphql.operation.type", "query") + .attribute("apollo.client.name", expected_client_name) + .attribute("apollo.client.version", expected_client_version) + .attribute("graphql.error.extensions.code", expected_error_code) + .attribute("apollo.router.error.service", expected_service) + .value(1) + .build(), + ); + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_execution_layer_error_emits_metric() { + if !graph_os_enabled() { + return; + } + + // Empty service indicates a router error + let expected_service = ""; + let expected_error_code = "MUTATION_FORBIDDEN"; + let expected_client_name = "CLIENT_NAME"; + let expected_client_version = "v0.14"; + + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { endpoint: None }) + .config( + r#" + telemetry: + apollo: + experimental_otlp_metrics_protocol: http + batch_processor: + scheduled_delay: 10ms + errors: + preview_extended_error_metrics: enabled + forbid_mutations: true + "#, + ) + .build() + .await; + + router.start().await; + router.assert_started().await; + + router.execute_query( + Query::builder() + .body(json!({ + "query": "mutation MyMutation($upc: ID!, $name: String!) { createProduct(upc: $upc, name: $name) { name } }", + "variables":{"upc": 123, "name": "myProduct"} + }) + ) + .header("apollographql-client-name", expected_client_name) + .header("apollographql-client-version", expected_client_version) + .build() + ).await; + + let metrics = router + .wait_for_emitted_otel_metrics(Duration::from_millis(20)) + .await; + + assert!(!metrics.is_empty()); + assert_metrics_contain( + &metrics, + Metric::builder() + .name("apollo.router.operations.error".to_string()) + .attribute("graphql.operation.name", "MyMutation") + .attribute("graphql.operation.type", "mutation") + .attribute("apollo.client.name", expected_client_name) + .attribute("apollo.client.version", expected_client_version) + .attribute("graphql.error.extensions.code", expected_error_code) + .attribute("apollo.router.error.service", expected_service) + .value(1) + .build(), + ); + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_router_layer_error_emits_metric() { + if !graph_os_enabled() { + return; + } + + // Empty service indicates a router error + let expected_service = ""; + let expected_error_code = "CSRF_ERROR"; + let expected_client_name = "CLIENT_NAME"; + let expected_client_version = "v0.14"; + + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { endpoint: None }) + .config( + r#" + telemetry: + apollo: + experimental_otlp_metrics_protocol: http + batch_processor: + scheduled_delay: 10ms + errors: + preview_extended_error_metrics: enabled + csrf: + required_headers: + - x-not-matched-header + "#, + ) + .build() + .await; + + router.start().await; + router.assert_started().await; + + router + .execute_query( + Query::builder() + .header("apollographql-client-name", expected_client_name) + .header("apollographql-client-version", expected_client_version) + // Content type cannot be application/json to trigger the error + .content_type("") + .build(), + ) + .await; + + let metrics = router + .wait_for_emitted_otel_metrics(Duration::from_millis(20)) + .await; + + assert!(!metrics.is_empty()); + + assert_metrics_contain( + &metrics, + Metric::builder() + .name("apollo.router.operations.error".to_string()) + .attribute("apollo.client.name", expected_client_name) + .attribute("apollo.client.version", expected_client_version) + .attribute("graphql.error.extensions.code", expected_error_code) + .attribute("apollo.router.error.service", expected_service) + .value(1) + .build(), + ); + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subgraph_request_emits_histogram() { + if !graph_os_enabled() { + return; + } + let expected_operation_name = "ExampleQuery"; + let expected_client_name = "myClient"; + let expected_client_version = "v0.14"; + let expected_service = "products"; + let expected_operation_type = "query"; + + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { endpoint: None }) + .config( + r#" + telemetry: + apollo: + experimental_otlp_metrics_protocol: http + batch_processor: + scheduled_delay: 10ms + experimental_subgraph_metrics: true + include_subgraph_errors: + all: true + "#, + ) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, _response) = router + .execute_query( + Query::builder() + .header("apollographql-client-name", expected_client_name) + .header("apollographql-client-version", expected_client_version) + .build(), + ) + .await; + + let metrics = router + .wait_for_emitted_otel_metrics(Duration::from_millis(20)) + .await; + assert!(!metrics.is_empty()); + assert_metrics_contain( + &metrics, + Metric::builder() + .name("apollo.router.operations.fetch.duration".to_string()) + .attribute("graphql.operation.name", expected_operation_name) + .attribute("apollo.client.name", expected_client_name) + .attribute("apollo.client.version", expected_client_version) + .attribute("subgraph.name", expected_service) + .attribute("graphql.operation.type", expected_operation_type) + .attribute("has_errors", false) + .count(1) + .build(), + ); + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_failed_subgraph_request_emits_histogram() { + if !graph_os_enabled() { + return; + } + let expected_operation_name = "ExampleQuery"; + let expected_client_name = "myClient"; + let expected_client_version = "v0.14"; + let expected_service = "products"; + let expected_operation_type = "query"; + + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { endpoint: None }) + .config( + r#" + telemetry: + apollo: + experimental_otlp_metrics_protocol: http + batch_processor: + scheduled_delay: 10ms + experimental_subgraph_metrics: true + include_subgraph_errors: + all: true + "#, + ) + .responder(ResponseTemplate::new(500).append_header("Content-Type", "application/json")) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, _response) = router + .execute_query( + Query::builder() + .header("apollographql-client-name", expected_client_name) + .header("apollographql-client-version", expected_client_version) + .build(), + ) + .await; + + let metrics = router + .wait_for_emitted_otel_metrics(Duration::from_millis(20)) + .await; + assert!(!metrics.is_empty()); + assert_metrics_contain( + &metrics, + Metric::builder() + .name("apollo.router.operations.fetch.duration".to_string()) + .attribute("graphql.operation.name", expected_operation_name) + .attribute("apollo.client.name", expected_client_name) + .attribute("apollo.client.version", expected_client_version) + .attribute("subgraph.name", expected_service) + .attribute("graphql.operation.type", expected_operation_type) + .attribute("has_errors", true) + .count(1) + .build(), + ); + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_connector_request_emits_histogram() { + if !graph_os_enabled() { + return; + } + let expected_operation_name = "ExampleQuery"; + let expected_client_name = "myClient"; + let expected_client_version = "v0.14"; + let expected_service = "connectors"; + let expected_operation_type = "query"; + + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { endpoint: None }) + .config( + r#" + telemetry: + apollo: + experimental_otlp_metrics_protocol: http + batch_processor: + scheduled_delay: 10ms + experimental_subgraph_metrics: true + include_subgraph_errors: + all: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .responder(ResponseTemplate::new(200).set_body_json(json!([{ + "id": 1, + "title": "Awesome post", + "body:": "This is a really great post", + "userId": 1 + }]))) + .http_method("GET") + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, _response) = router + .execute_query( + Query::builder() + .header("apollographql-client-name", expected_client_name) + .header("apollographql-client-version", expected_client_version) + .body(json!({"query":"query ExampleQuery {posts{id}}","variables":{}})) + .build(), + ) + .await; + + let metrics = router + .wait_for_emitted_otel_metrics(Duration::from_millis(20)) + .await; + assert!(!metrics.is_empty()); + assert_metrics_contain( + &metrics, + Metric::builder() + .name("apollo.router.operations.fetch.duration".to_string()) + .attribute("graphql.operation.name", expected_operation_name) + .attribute("apollo.client.name", expected_client_name) + .attribute("apollo.client.version", expected_client_version) + .attribute("subgraph.name", expected_service) + .attribute("graphql.operation.type", expected_operation_type) + .attribute("has_errors", false) + .count(1) + .build(), + ); + router.graceful_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_failed_connector_request_emits_histogram() { + if !graph_os_enabled() { + return; + } + let expected_operation_name = "ExampleQuery"; + let expected_client_name = "myClient"; + let expected_client_version = "v0.14"; + let expected_service = "connectors"; + let expected_operation_type = "query"; + + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { endpoint: None }) + .config( + r#" + telemetry: + apollo: + experimental_otlp_metrics_protocol: http + batch_processor: + scheduled_delay: 10ms + experimental_subgraph_metrics: true + traffic_shaping: + connector: + sources: + connectors.jsonPlaceholder: + timeout: 1ns + include_subgraph_errors: + all: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "..", + "apollo-router", + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .responder(ResponseTemplate::new(500).set_delay(Duration::from_millis(5))) + .http_method("GET") + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, _response) = router + .execute_query( + Query::builder() + .header("apollographql-client-name", expected_client_name) + .header("apollographql-client-version", expected_client_version) + .body(json!({"query":"query ExampleQuery {posts{id}}","variables":{}})) + .build(), + ) + .await; + + let metrics = router + .wait_for_emitted_otel_metrics(Duration::from_millis(20)) + .await; + assert!(!metrics.is_empty()); + assert_metrics_contain( + &metrics, + Metric::builder() + .name("apollo.router.operations.fetch.duration".to_string()) + .attribute("graphql.operation.name", expected_operation_name) + .attribute("apollo.client.name", expected_client_name) + .attribute("apollo.client.version", expected_client_version) + .attribute("subgraph.name", expected_service) + .attribute("graphql.operation.type", expected_operation_type) + .attribute("has_errors", true) + .count(1) + .build(), + ); + router.graceful_shutdown().await; +} + +/// Assert that the given metric exists in the list of Otel requests. This is a crude attempt at +/// replicating _some_ assert_counter!() functionality since that test util can't be accessed here. +fn assert_metrics_contain(actual_metrics: &[ExportMetricsServiceRequest], expected_metric: Metric) { + let expected_name = &expected_metric.name.clone(); + let actual_metric = find_metric(expected_name, actual_metrics) + .unwrap_or_else(|| panic!("Metric '{expected_name}' not found")); + + let actual_metrics: Vec = match &actual_metric.data { + Some(metric::Data::Sum(sum)) => sum + .data_points + .iter() + .map(|dp| Metric::from_number_datapoint(expected_name, dp)) + .collect(), + Some(metric::Data::Histogram(histogram)) => histogram + .data_points + .iter() + .map(|dp| Metric::from_histogram_datapoint(expected_name, dp)) + .collect(), + _ => panic!("Metric type for '{expected_name}' is not yet implemented"), + }; + + let metric_found = actual_metrics.iter().any(|m| { + // Only match values and attributes that are explicitly set + expected_metric.value.is_none_or(|v| Some(v) == m.value) + && expected_metric.sum.is_none_or(|s| Some(s) == m.sum) + && expected_metric.count.is_none_or(|c| Some(c) == m.count) + && m.attributes_contain(&expected_metric.attributes) + }); + + assert!( + metric_found, + "Expected metric '{}' but no matching datapoint was found.\nInstead, actual metrics with matching name were:\n{}", + expected_metric, + actual_metrics + .iter() + .map(|m| m.to_string()) + .collect::>() + .join("\n") + ); +} + +fn find_metric<'a>( + name: &str, + metrics: &'a [ExportMetricsServiceRequest], +) -> Option<&'a opentelemetry_proto::tonic::metrics::v1::Metric> { + metrics + .iter() + .flat_map(|req| &req.resource_metrics) + .flat_map(|rm| &rm.scope_metrics) + .flat_map(|sm| &sm.metrics) + .find(|m| m.name == name) +} + +#[derive(Display, Clone, Debug)] +struct Metric { + pub name: String, + pub attributes: HashMap, + pub value: Option, + pub sum: Option, + pub count: Option, +} + +#[buildstructor::buildstructor] +impl Metric { + #[builder] + fn new( + name: String, + attributes: HashMap, + value: Option, + sum: Option, + count: Option, + ) -> Self { + Metric { + name, + attributes: attributes.into_iter().map(|(k, v)| (k, v.into())).collect(), + value, + sum, + count, + } + } + fn from_number_datapoint(name: &str, datapoint: &NumberDataPoint) -> Self { + Metric { + name: name.to_string(), + attributes: datapoint + .attributes + .iter() + .map(|kv| (kv.key.clone(), kv.value.clone().unwrap())) + .collect::>(), + value: match datapoint.value { + Some(number_data_point::Value::AsInt(value)) => Some(value), + _ => panic!("expected integer datapoint"), + }, + sum: None, + count: None, + } + } + fn from_histogram_datapoint(name: &str, datapoint: &HistogramDataPoint) -> Self { + Metric { + name: name.to_string(), + attributes: datapoint + .attributes + .iter() + .map(|kv| (kv.key.clone(), kv.value.clone().unwrap())) + .collect::>(), + value: None, + sum: datapoint.sum, + count: Some(datapoint.count as i64), + } + } + fn attributes_contain(&self, other_attributes: &HashMap) -> bool { + other_attributes + .iter() + .all(|(other_key, other_value)| self.attributes.get(other_key) == Some(other_value)) + } +} + +impl Display for Metric { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "name: {},\nvalue: {:?},\ncount: {:?},\nsum: {:?}, \nattributes: [", + self.name, self.value, self.count, self.sum + )?; + let mut attrs: Vec<_> = self.attributes.iter().collect(); + attrs.sort_by(|a, b| a.0.cmp(b.0)); + for (i, (key, any)) in attrs.into_iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + let value = any + .value + .clone() + .map(|value| match value { + StringValue(sv) => sv.clone(), + BoolValue(b) => b.to_string(), + IntValue(n) => n.to_string(), + DoubleValue(d) => d.to_string(), + other => format!("{other:?}"), + }) + .unwrap_or_else(|| "nil".into()); + write!(f, "\n\t{key}={value}")?; + } + write!(f, "\n]") + } +} diff --git a/apollo-router/tests/integration/telemetry/datadog.rs b/apollo-router/tests/integration/telemetry/datadog.rs index 6aed76ff6d..2c4a089899 100644 --- a/apollo-router/tests/integration/telemetry/datadog.rs +++ b/apollo-router/tests/integration/telemetry/datadog.rs @@ -1,63 +1,569 @@ extern crate core; -use std::collections::HashMap; use std::collections::HashSet; -use std::sync::atomic::AtomicBool; -use std::time::Duration; +use std::ops::Deref; use anyhow::anyhow; -use opentelemetry_api::trace::TraceContextExt; -use opentelemetry_api::trace::TraceId; -use serde_json::json; +use opentelemetry::trace::TraceId; use serde_json::Value; use tower::BoxError; -use tracing::Span; -use tracing_opentelemetry::OpenTelemetrySpanExt; -use wiremock::ResponseTemplate; -use crate::integration::common::graph_os_enabled; -use crate::integration::common::Telemetry; use crate::integration::IntegrationTest; use crate::integration::ValueExt; - -#[derive(buildstructor::Builder)] -struct TraceSpec { - operation_name: Option, - version: Option, - services: HashSet<&'static str>, - span_names: HashSet<&'static str>, - measured_spans: HashSet<&'static str>, - unmeasured_spans: HashSet<&'static str>, -} +use crate::integration::common::Query; +use crate::integration::common::Telemetry; +use crate::integration::common::graph_os_enabled; +use crate::integration::telemetry::DatadogId; +use crate::integration::telemetry::TraceSpec; +use crate::integration::telemetry::verifier::Verifier; #[tokio::test(flavor = "multi_thread")] async fn test_no_sample() -> Result<(), BoxError> { if !graph_os_enabled() { return Ok(()); } - let subgraph_was_sampled = std::sync::Arc::new(AtomicBool::new(false)); - let subgraph_was_sampled_callback = subgraph_was_sampled.clone(); let mut router = IntegrationTest::builder() .telemetry(Telemetry::Datadog) .config(include_str!("fixtures/datadog_no_sample.router.yaml")) - .responder(ResponseTemplate::new(200).set_body_json( - json!({"data":{"topProducts":[{"name":"Table"},{"name":"Couch"},{"name":"Chair"}]}}), + .build() + .await; + + router.start().await; + router.assert_started().await; + TraceSpec::builder() + .services(["router"].into()) + .subgraph_sampled(false) + .priority_sampled("0") + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(false).build()) + .await?; + + router.graceful_shutdown().await; + + Ok(()) +} + +// We want to check we're able to override the behavior of preview_datadog_agent_sampling configuration even if we set a datadog exporter +#[tokio::test(flavor = "multi_thread")] +async fn test_sampling_datadog_agent_disabled() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Datadog) + .config(include_str!( + "fixtures/datadog_agent_sampling_disabled.router.yaml" + )) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .services([].into()) + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(false).build()) + .await?; + router.graceful_shutdown().await; + + Ok(()) +} + +// We want to check we're able to override the behavior of preview_datadog_agent_sampling configuration even if we set a datadog exporter +#[tokio::test(flavor = "multi_thread")] +async fn test_sampling_datadog_agent_disabled_always_sample() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Datadog) + .config(include_str!( + "fixtures/datadog_agent_sampling_disabled_1.router.yaml" + )) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .services(["router", "subgraph"].into()) + .subgraph_sampled(true) + .priority_sampled("1") + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(false).build()) + .await?; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .subgraph_sampled(true) + .priority_sampled("1") + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).build()) + .await?; + router.graceful_shutdown().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_sampling_datadog_agent_disabled_never_sample() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Datadog) + .config(include_str!( + "fixtures/datadog_agent_sampling_disabled_0.router.yaml" + )) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .services([].into()) + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(false).build()) + .await?; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .subgraph_sampled(true) + .priority_sampled("1") + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).build()) + .await?; + router.graceful_shutdown().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_priority_sampling_propagated() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Datadog) + .config(include_str!("fixtures/datadog.router.yaml")) + .build() + .await; + + router.start().await; + router.assert_started().await; + + // Parent based sampling. psr MUST be populated with the value that we pass in. + TraceSpec::builder() + .services(["client", "router"].into()) + .subgraph_sampled(false) + .priority_sampled("-1") + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).psr("-1").build()) + .await?; + + TraceSpec::builder() + .services(["client", "router"].into()) + .subgraph_sampled(false) + .priority_sampled("0") + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).psr("0").build()) + .await?; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .subgraph_sampled(true) + .priority_sampled("1") + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).psr("1").build()) + .await?; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .subgraph_sampled(true) + .priority_sampled("2") + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).psr("2").build()) + .await?; + + // No psr was passed in the router is free to set it. This will be 1 as we are going to sample here. + TraceSpec::builder() + .services(["router", "subgraph"].into()) + .subgraph_sampled(true) + .priority_sampled("1") + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(false).build()) + .await?; + + router.graceful_shutdown().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_priority_sampling_propagated_otel_request() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { endpoint: None }) + .extra_propagator(Telemetry::Datadog) + .config(include_str!("fixtures/datadog.router.yaml")) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .services(["router"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).build()) + .await?; + + router.graceful_shutdown().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_priority_sampling_no_parent_propagated() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Datadog) + .config(include_str!( + "fixtures/datadog_no_parent_sampler.router.yaml" )) - .subgraph_callback(Box::new(move || { - let sampled = Span::current().context().span().span_context().is_sampled(); - subgraph_was_sampled_callback.store(sampled, std::sync::atomic::Ordering::SeqCst); - })) .build() .await; router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let (_id, result) = router.execute_untraced_query(&query).await; + // The router will ignore the upstream PSR as parent based sampling is disabled. + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).psr("-1").build()) + .await?; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).psr("0").build()) + .await?; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).psr("1").build()) + .await?; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).psr("2").build()) + .await?; + + TraceSpec::builder() + .services(["router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(false).build()) + .await?; + + router.graceful_shutdown().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_priority_sampling_parent_sampler_very_small() -> Result<(), BoxError> { + // Note that there is a very small chance this test will fail. We are trying to test a non-zero sampler. + + if !graph_os_enabled() { + return Ok(()); + } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Datadog) + .config(include_str!( + "fixtures/datadog_parent_sampler_very_small.router.yaml" + )) + .build() + .await; + + router.start().await; + router.assert_started().await; + + // The router should respect upstream but also almost never sample if left to its own devices. + TraceSpec::builder() + .services(["client", "router"].into()) + .priority_sampled("-1") + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).psr("-1").build()) + .await?; + + TraceSpec::builder() + .services(["client", "router"].into()) + .priority_sampled("0") + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).psr("0").build()) + .await?; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).psr("1").build()) + .await?; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("2") + .subgraph_sampled(true) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).psr("2").build()) + .await?; + + TraceSpec::builder() + .services(["router"].into()) + .priority_sampled("0") + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(false).build()) + .await?; + + router.graceful_shutdown().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_priority_sampling_parent_sampler_very_small_no_parent() -> Result<(), BoxError> { + // Note that there is a very small chance this test will fail. We are trying to test a non-zero sampler. + + if !graph_os_enabled() { + return Ok(()); + } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Datadog) + .config(include_str!( + "fixtures/datadog_parent_sampler_very_small_no_parent.router.yaml" + )) + .build() + .await; + + router.start().await; + router.assert_started().await; + + // // The router should respect upstream but also almost never sample if left to its own devices. + TraceSpec::builder() + .services(["client", "router"].into()) + .priority_sampled("0") + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().psr("-1").traced(true).build()) + .await?; + TraceSpec::builder() + .services(["client", "router"].into()) + .priority_sampled("0") + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().psr("0").traced(true).build()) + .await?; + + TraceSpec::builder() + .services(["client", "router"].into()) + .priority_sampled("0") + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().psr("1").traced(true).build()) + .await?; + + TraceSpec::builder() + .services(["client", "router"].into()) + .priority_sampled("0") + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().psr("2").traced(true).build()) + .await?; + + TraceSpec::builder() + .services(["router"].into()) + .priority_sampled("0") + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(false).build()) + .await?; + + router.graceful_shutdown().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_priority_sampling_parent_sampler_very_small_no_parent_no_agent_sampling() +-> Result<(), BoxError> { + // Note that there is a very small chance this test will fail. We are trying to test a non-zero sampler. + + if !graph_os_enabled() { + return Ok(()); + } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Datadog) + .config(include_str!( + "fixtures/datadog_parent_sampler_very_small_no_parent_no_agent_sampling.router.yaml" + )) + .build() + .await; + + router.start().await; + router.assert_started().await; + + // // The router should not respect upstream but also almost never sample if left to its own devices. + TraceSpec::builder() + .services(["client"].into()) + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().psr("-1").traced(true).build()) + .await?; + TraceSpec::builder() + .services(["client"].into()) + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().psr("0").traced(true).build()) + .await?; + + TraceSpec::builder() + .services(["client"].into()) + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().psr("1").traced(true).build()) + .await?; + + TraceSpec::builder() + .services(["client"].into()) + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().psr("2").traced(true).build()) + .await?; + + TraceSpec::builder() + .services([].into()) + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(false).build()) + .await?; + + router.graceful_shutdown().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_priority_sampling_parent_sampler_very_small_parent_no_agent_sampling() +-> Result<(), BoxError> { + // Note that there is a very small chance this test will fail. We are trying to test a non-zero sampler. + + if !graph_os_enabled() { + return Ok(()); + } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Datadog) + .config(include_str!( + "fixtures/datadog_parent_sampler_very_small_parent_no_agent_sampling.router.yaml" + )) + .build() + .await; + + router.start().await; + router.assert_started().await; + + // // The router should respect upstream but also almost never sample if left to its own devices. + TraceSpec::builder() + .services(["client"].into()) + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().psr("-1").traced(true).build()) + .await?; + TraceSpec::builder() + .services(["client"].into()) + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().psr("0").traced(true).build()) + .await?; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .subgraph_sampled(true) + .build() + .validate_datadog_trace(&mut router, Query::builder().psr("1").traced(true).build()) + .await?; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .subgraph_sampled(true) + .build() + .validate_datadog_trace(&mut router, Query::builder().psr("2").traced(true).build()) + .await?; + + TraceSpec::builder() + .services([].into()) + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(false).build()) + .await?; + + router.graceful_shutdown().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_untraced_request() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Datadog) + .config(include_str!( + "fixtures/datadog_parent_sampler_very_small.router.yaml" + )) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .services(["router"].into()) + .priority_sampled("0") + .subgraph_sampled(false) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(false).build()) + .await?; + router.graceful_shutdown().await; - assert!(result.status().is_success()); - assert!(!subgraph_was_sampled.load(std::sync::atomic::Ordering::SeqCst)); Ok(()) } @@ -78,20 +584,9 @@ async fn test_default_span_names() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let (id, result) = router.execute_query(&query).await; - assert_eq!( - result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .to_str() - .unwrap(), - id.to_datadog() - ); - router.graceful_shutdown().await; TraceSpec::builder() .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") .span_names( [ "query_planning", @@ -109,8 +604,9 @@ async fn test_default_span_names() -> Result<(), BoxError> { .into(), ) .build() - .validate_trace(id) + .validate_datadog_trace(&mut router, Query::builder().traced(true).build()) .await?; + router.graceful_shutdown().await; Ok(()) } @@ -130,20 +626,9 @@ async fn test_override_span_names() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let (id, result) = router.execute_query(&query).await; - assert_eq!( - result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .to_str() - .unwrap(), - id.to_datadog() - ); - router.graceful_shutdown().await; TraceSpec::builder() .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") .span_names( [ "query_planning", @@ -161,8 +646,9 @@ async fn test_override_span_names() -> Result<(), BoxError> { .into(), ) .build() - .validate_trace(id) + .validate_datadog_trace(&mut router, Query::builder().traced(true).build()) .await?; + router.graceful_shutdown().await; Ok(()) } @@ -181,21 +667,9 @@ async fn test_override_span_names_late() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let (id, result) = router.execute_query(&query).await; - assert_eq!( - result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .to_str() - .unwrap(), - id.to_datadog() - ); - router.graceful_shutdown().await; TraceSpec::builder() .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") .span_names( [ "query_planning", @@ -213,8 +687,44 @@ async fn test_override_span_names_late() -> Result<(), BoxError> { .into(), ) .build() - .validate_trace(id) + .validate_datadog_trace(&mut router, Query::builder().traced(true).build()) .await?; + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_header_propagator_override() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Datadog) + .config(include_str!( + "fixtures/datadog_header_propagator_override.router.yaml" + )) + .build() + .await; + + let trace_id = opentelemetry::trace::TraceId::from_u128(uuid::Uuid::new_v4().as_u128()); + + router.start().await; + router.assert_started().await; + TraceSpec::builder() + .services(["router", "subgraph"].into()) + .subgraph_sampled(true) + .trace_id(format!("{:032x}", trace_id.to_datadog())) + .build() + .validate_datadog_trace( + &mut router, + Query::builder() + .header("trace-id", trace_id.to_string()) + .header("x-datadog-trace-id", "2") + .traced(false) + .build(), + ) + .await?; + router.graceful_shutdown().await; Ok(()) } @@ -232,20 +742,9 @@ async fn test_basic() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let (id, result) = router.execute_query(&query).await; - assert_eq!( - result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .to_str() - .unwrap(), - id.to_datadog() - ); - router.graceful_shutdown().await; TraceSpec::builder() .operation_name("ExampleQuery") + .priority_sampled("1") .services(["client", "router", "subgraph"].into()) .span_names( [ @@ -276,8 +775,9 @@ async fn test_basic() -> Result<(), BoxError> { .into(), ) .build() - .validate_trace(id) + .validate_datadog_trace(&mut router, Query::builder().traced(true).build()) .await?; + router.graceful_shutdown().await; Ok(()) } @@ -295,23 +795,6 @@ async fn test_with_parent_span() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let mut headers = HashMap::new(); - headers.insert( - "traceparent".to_string(), - String::from("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"), - ); - let (id, result) = router.execute_query_with_headers(&query, headers).await; - assert_eq!( - result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .to_str() - .unwrap(), - id.to_datadog() - ); - router.graceful_shutdown().await; TraceSpec::builder() .operation_name("ExampleQuery") .services(["client", "router", "subgraph"].into()) @@ -344,8 +827,18 @@ async fn test_with_parent_span() -> Result<(), BoxError> { .into(), ) .build() - .validate_trace(id) + .validate_datadog_trace( + &mut router, + Query::builder() + .traced(true) + .header( + "traceparent", + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + ) + .build(), + ) .await?; + router.graceful_shutdown().await; Ok(()) } @@ -365,13 +858,6 @@ async fn test_resource_mapping_default() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let (id, result) = router.execute_query(&query).await; - assert!(!result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .is_empty()); TraceSpec::builder() .operation_name("ExampleQuery") .services(["client", "router", "subgraph"].into()) @@ -391,7 +877,7 @@ async fn test_resource_mapping_default() -> Result<(), BoxError> { .into(), ) .build() - .validate_trace(id) + .validate_datadog_trace(&mut router, Query::builder().traced(true).build()) .await?; router.graceful_shutdown().await; Ok(()) @@ -413,14 +899,6 @@ async fn test_resource_mapping_override() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let (id, result) = router.execute_query(&query).await; - assert!(!result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .is_empty()); - router.graceful_shutdown().await; TraceSpec::builder() .services(["client", "router", "subgraph"].into()) .span_names( @@ -439,8 +917,9 @@ async fn test_resource_mapping_override() -> Result<(), BoxError> { .into(), ) .build() - .validate_trace(id) + .validate_datadog_trace(&mut router, Query::builder().traced(true).build()) .await?; + router.graceful_shutdown().await; Ok(()) } @@ -458,14 +937,6 @@ async fn test_span_metrics() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let (id, result) = router.execute_query(&query).await; - assert!(!result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .is_empty()); - router.graceful_shutdown().await; TraceSpec::builder() .operation_name("ExampleQuery") .services(["client", "router", "subgraph"].into()) @@ -486,59 +957,96 @@ async fn test_span_metrics() -> Result<(), BoxError> { .measured_span("subgraph") .unmeasured_span("supergraph") .build() - .validate_trace(id) + .validate_datadog_trace(&mut router, Query::builder().traced(true).build()) .await?; + router.graceful_shutdown().await; Ok(()) } -pub(crate) trait DatadogId { - fn to_datadog(&self) -> String; +#[tokio::test(flavor = "multi_thread")] +async fn test_resources() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Datadog) + .config(include_str!("fixtures/datadog.router.yaml")) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .operation_name("ExampleQuery") + .resource("env", "local1") + .resource("service.version", "router_version_override") + .resource("service.name", "router") + .services(["client", "router", "subgraph"].into()) + .build() + .validate_datadog_trace(&mut router, Query::builder().traced(true).build()) + .await?; + router.graceful_shutdown().await; + Ok(()) } -impl DatadogId for TraceId { - fn to_datadog(&self) -> String { - let bytes = &self.to_bytes()[std::mem::size_of::()..std::mem::size_of::()]; - u64::from_be_bytes(bytes.try_into().unwrap()).to_string() + +#[tokio::test(flavor = "multi_thread")] +async fn test_attributes() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); } + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Datadog) + .config(include_str!("fixtures/datadog.router.yaml")) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .attribute("client.name", "foo") + .build() + .validate_datadog_trace( + &mut router, + Query::builder() + .traced(true) + .header("apollographql-client-name", "foo") + .build(), + ) + .await?; + router.graceful_shutdown().await; + Ok(()) } -impl TraceSpec { - #[allow(clippy::too_many_arguments)] - async fn validate_trace(&self, id: TraceId) -> Result<(), BoxError> { - let datadog_id = id.to_datadog(); - let url = format!("http://localhost:8126/test/traces?trace_ids={datadog_id}"); - for _ in 0..10 { - if self.find_valid_trace(&url).await.is_ok() { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - self.find_valid_trace(&url).await?; - Ok(()) +struct DatadogTraceSpec { + trace_spec: TraceSpec, +} +impl Deref for DatadogTraceSpec { + type Target = TraceSpec; + + fn deref(&self) -> &Self::Target { + &self.trace_spec } +} - #[allow(clippy::too_many_arguments)] - async fn find_valid_trace(&self, url: &str) -> Result<(), BoxError> { - // A valid trace has: - // * All three services - // * The correct spans - // * All spans are parented - // * Required attributes of 'router' span has been set +impl Verifier for DatadogTraceSpec { + fn spec(&self) -> &TraceSpec { + &self.trace_spec + } - // For now just validate service name. - let trace: Value = reqwest::get(url) + async fn get_trace(&self, trace_id: TraceId) -> Result { + let datadog_id = trace_id.to_datadog(); + let url = format!("http://localhost:8126/test/traces?trace_ids={datadog_id}"); + println!("url: {}", url); + let value: serde_json::Value = reqwest::get(url) .await .map_err(|e| anyhow!("failed to contact datadog; {}", e))? .json() - .await?; - tracing::debug!("{}", serde_json::to_string_pretty(&trace)?); - self.verify_trace_participants(&trace)?; - self.verify_spans_present(&trace)?; - self.validate_measured_spans(&trace)?; - self.verify_operation_name(&trace)?; - self.verify_priority_sampled(&trace)?; - self.verify_version(&trace)?; - self.validate_span_kinds(&trace)?; - Ok(()) + .await + .map_err(|e| anyhow!("failed to contact datadog; {}", e))?; + Ok(value) } fn verify_version(&self, trace: &Value) -> Result<(), BoxError> { @@ -556,24 +1064,6 @@ impl TraceSpec { Ok(()) } - fn validate_measured_spans(&self, trace: &Value) -> Result<(), BoxError> { - for expected in &self.measured_spans { - assert!( - self.measured_span(trace, expected)?, - "missing measured span {}", - expected - ); - } - for unexpected in &self.unmeasured_spans { - assert!( - !self.measured_span(trace, unexpected)?, - "unexpected measured span {}", - unexpected - ); - } - Ok(()) - } - fn measured_span(&self, trace: &Value, name: &str) -> Result { let binding1 = trace.select_path(&format!( "$..[?(@.meta.['otel.original_name'] == '{}')].metrics.['_dd.measured']", @@ -591,15 +1081,7 @@ impl TraceSpec { .unwrap_or_default()) } - fn validate_span_kinds(&self, trace: &Value) -> Result<(), BoxError> { - // Validate that the span.kind has been propagated. We can just do this for a selection of spans. - self.validate_span_kind(trace, "router", "server")?; - self.validate_span_kind(trace, "supergraph", "internal")?; - self.validate_span_kind(trace, "http_request", "client")?; - Ok(()) - } - - fn verify_trace_participants(&self, trace: &Value) -> Result<(), BoxError> { + fn verify_services(&self, trace: &Value) -> Result<(), BoxError> { let actual_services: HashSet = trace .select_path("$..service")? .into_iter() @@ -614,7 +1096,7 @@ impl TraceSpec { .collect::>(); if actual_services != expected_services { return Err(BoxError::from(format!( - "incomplete traces, got {actual_services:?} expected {expected_services:?}" + "unexpected traces, got {actual_services:?} expected {expected_services:?}" ))); } Ok(()) @@ -627,7 +1109,7 @@ impl TraceSpec { .filter_map(|span_name| span_name.as_string()) .collect(); let mut span_names: HashSet<&str> = self.span_names.clone(); - if self.services.contains("client") { + if self.services.contains(&"client") { span_names.insert("client_request"); } tracing::debug!("found spans {:?}", operation_names); @@ -652,19 +1134,24 @@ impl TraceSpec { trace.select_path(&format!("$..[?(@.name == '{}')].meta.['span.kind']", name))?; let binding = binding1.first().or(binding2.first()); - assert!( - binding.is_some(), - "span.kind missing or incorrect {}, {}", - name, - trace - ); - assert_eq!( - binding - .expect("expected binding") - .as_str() - .expect("expected string"), - kind - ); + if binding.is_none() { + return Err(BoxError::from(format!( + "span.kind missing or incorrect {}, {}", + name, trace + ))); + } + + let binding = binding + .expect("expected binding") + .as_str() + .expect("expected string"); + if binding != kind { + return Err(BoxError::from(format!( + "span.kind mismatch, expected {} got {}", + kind, binding + ))); + } + Ok(()) } @@ -685,17 +1172,82 @@ impl TraceSpec { } fn verify_priority_sampled(&self, trace: &Value) -> Result<(), BoxError> { - let binding = trace.select_path("$.._sampling_priority_v1")?; - let sampling_priority = binding.first(); - // having this priority set to 1.0 everytime is not a problem as we're doing pre sampling in the full telemetry stack - // So basically if the trace was not sampled it wouldn't get to this stage and so nothing would be sent - assert_eq!( - sampling_priority - .expect("sampling priority expected") - .as_f64() - .expect("sampling priority must be a number"), - 1.0 - ); + if let Some(psr) = self.priority_sampled { + let binding = + trace.select_path("$..[?(@.service=='router')].metrics._sampling_priority_v1")?; + if binding.is_empty() { + return Err(BoxError::from("missing sampling priority")); + } + for sampling_priority in binding { + assert_eq!( + sampling_priority + .as_f64() + .expect("psr not string") + .to_string(), + psr, + "psr mismatch" + ); + } + } Ok(()) } + + fn verify_span_attributes(&self, trace: &Value) -> Result<(), BoxError> { + for (key, value) in self.attributes.iter() { + // extracts a list of span attribute values with the provided key + let binding = trace.select_path(&format!("$..meta..['{key}']"))?; + let matches_value = binding.iter().any(|v| match v { + Value::Bool(v) => (*v).to_string() == *value, + Value::Number(n) => (*n).to_string() == *value, + Value::String(s) => s == value, + _ => false, + }); + if !matches_value { + return Err(BoxError::from(format!( + "unexpected attribute values for key `{key}`, expected value `{value}` but got {binding:?}" + ))); + } + } + Ok(()) + } + + fn verify_resources(&self, trace: &Value) -> Result<(), BoxError> { + if !self.trace_spec.resources.is_empty() { + let spans = trace.select_path("$..[?(@.service=='router')]")?; + for span in spans { + for resource in span.select_path("$.meta")? { + for (key, value) in &self.trace_spec.resources { + let mut found = false; + if let Some(resource_value) = + resource.as_object().and_then(|resource| resource.get(*key)) + { + let resource_value = + resource_value.as_string().expect("resources are strings"); + if resource_value == *value { + found = true; + } + } + if !found { + return Err(BoxError::from(format!( + "resource not found: {key}={value}", + ))); + } + } + } + } + } + Ok(()) + } +} + +impl TraceSpec { + async fn validate_datadog_trace( + self, + router: &mut IntegrationTest, + query: Query, + ) -> Result<(), BoxError> { + DatadogTraceSpec { trace_spec: self } + .validate_trace(router, query) + .await + } } diff --git a/apollo-router/tests/integration/telemetry/events.rs b/apollo-router/tests/integration/telemetry/events.rs new file mode 100644 index 0000000000..1dc2075fb6 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/events.rs @@ -0,0 +1,350 @@ +use insta::assert_yaml_snapshot; +use serde::Deserialize; +use serde::Serialize; +use serde_json::json; +use tower::BoxError; + +use crate::integration::common::IntegrationTest; +use crate::integration::common::Query; +use crate::integration::common::graph_os_enabled; + +#[tokio::test(flavor = "multi_thread")] +async fn test_standard_events() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + + let mut router = EventTest::new(json!({ + "router": { "request": "info", "response": "info" }, + "supergraph": { "request": "info", "response": "info" }, + "subgraph": { "request": "info", "response": "info" } + })) + .await; + + assert_yaml_snapshot!(router.execute_default_query().await?, @r" + - kind: router.request + level: INFO + - kind: supergraph.request + level: INFO + - kind: subgraph.request + level: INFO + - kind: subgraph.response + level: INFO + - kind: supergraph.response + level: INFO + - kind: router.response + level: INFO + "); + + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_custom_events() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + + let mut router = EventTest::new(json!({ + "router": { + "custom.router.request": { + "on": "request", + "message": "Custom router request", + "level": "info" + }, + "custom.router.response": { + "on": "response", + "message": "Custom router response", + "level": "info" + } + }, + "supergraph": { + "custom.supergraph.request": { + "on": "request", + "message": "Custom supergraph request", + "level": "info" + }, + "custom.supergraph.response": { + "on": "response", + "message": "Custom supergraph response", + "level": "info" + } + }, + "subgraph": { + "custom.subgraph.request": { + "on": "request", + "message": "Custom subgraph request", + "level": "info" + }, + "custom.subgraph.response": { + "on": "response", + "message": "Custom subgraph response", + "level": "info" + } + } + })) + .await; + + assert_yaml_snapshot!(router.execute_default_query().await?, @r" + - kind: custom.router.request + level: INFO + - kind: custom.supergraph.request + level: INFO + - kind: custom.subgraph.request + level: INFO + - kind: custom.subgraph.response + level: INFO + - kind: custom.supergraph.response + level: INFO + - kind: custom.router.response + level: INFO + "); + + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_events_with_request_header_condition() -> Result<(), BoxError> { + if !graph_os_enabled() { + eprintln!("test skipped"); + return Ok(()); + } + + let x_log_request = String::from("x-log-request"); + let x_log_response = String::from("x-log-response"); + let x_log_custom_request = String::from("x-log-custom-request"); + let x_log_custom_response = String::from("x-log-custom-response"); + + let mut router = EventTest::new(json!({ + "router": { + "request": { + "level": "info", + "condition": { "exists": { "request_header": x_log_request } } + }, + "response": { + "level": "info", + "condition": { "exists": { "request_header": x_log_response } } + }, + "custom.router.request": { + "on": "request", + "message": "Custom router request", + "level": "info", + "condition": { "exists": { "request_header": x_log_custom_request } } + }, + "custom.router.response": { + "on": "response", + "message": "Custom router response", + "level": "info", + "condition": { "exists": { "request_header": x_log_custom_response } } + } + }, + "supergraph": { + "request": { + "level": "info", + "condition": { "exists": { "request_header": x_log_request } } + }, + "response": { + "level": "info", + "condition": { "exists": { "request_header": x_log_response } } + }, + "custom.supergraph.request": { + "on": "request", + "message": "Custom supergraph request", + "level": "info", + "condition": { "exists": { "request_header": x_log_custom_request } } + }, + "custom.supergraph.response": { + "on": "response", + "message": "Custom supergraph response", + "level": "info", + "condition": { "exists": { "request_header": x_log_custom_response } } + } + }, + "subgraph": { + "request": { + "level": "info", + "condition": { "exists": { "subgraph_request_header": x_log_request } } + }, + "response": { + "level": "info", + "condition": { "exists": { "subgraph_request_header": x_log_response } } + }, + "custom.subgraph.request": { + "on": "request", + "message": "Custom subgraph request", + "level": "info", + "condition": { "exists": { "subgraph_request_header": x_log_custom_request } } + }, + "custom.subgraph.response": { + "on": "response", + "message": "Custom subgraph response", + "level": "info", + "condition": { "exists": { "subgraph_request_header": x_log_custom_response } } + } + } + })) + .await; + + assert_yaml_snapshot!(router.execute_default_query().await?, @r"[]"); + + let query = Query::builder() + .header(x_log_request.clone(), "enabled".to_owned()) + .build(); + assert_yaml_snapshot!(router.execute_query(query).await?, @r" + - kind: router.request + level: INFO + - kind: supergraph.request + level: INFO + - kind: subgraph.request + level: INFO + "); + + let query = Query::builder() + .header(x_log_response.clone(), "enabled".to_owned()) + .build(); + assert_yaml_snapshot!(router.execute_query(query).await?, @r" + - kind: subgraph.response + level: INFO + - kind: supergraph.response + level: INFO + - kind: router.response + level: INFO + "); + + let query = Query::builder() + .header(x_log_custom_request.clone(), "enabled".to_owned()) + .build(); + assert_yaml_snapshot!(router.execute_query(query).await?, @r" + - kind: custom.router.request + level: INFO + - kind: custom.supergraph.request + level: INFO + - kind: custom.subgraph.request + level: INFO + "); + + let query = Query::builder() + .header(x_log_custom_response.clone(), "enabled".to_owned()) + .build(); + assert_yaml_snapshot!(router.execute_query(query).await?, @r" + - kind: custom.subgraph.response + level: INFO + - kind: custom.supergraph.response + level: INFO + - kind: custom.router.response + level: INFO + "); + + let query = Query::builder() + .header(x_log_request.clone(), "enabled".to_owned()) + .header(x_log_response.clone(), "enabled".to_owned()) + .header(x_log_custom_request.clone(), "enabled".to_owned()) + .header(x_log_custom_response.clone(), "enabled".to_owned()) + .build(); + assert_yaml_snapshot!(router.execute_query(query).await?, @r" + - kind: custom.router.request + level: INFO + - kind: router.request + level: INFO + - kind: supergraph.request + level: INFO + - kind: custom.supergraph.request + level: INFO + - kind: custom.subgraph.request + level: INFO + - kind: subgraph.request + level: INFO + - kind: subgraph.response + level: INFO + - kind: custom.subgraph.response + level: INFO + - kind: custom.supergraph.response + level: INFO + - kind: supergraph.response + level: INFO + - kind: router.response + level: INFO + - kind: custom.router.response + level: INFO + "); + + router.graceful_shutdown().await; + Ok(()) +} + +#[derive(Serialize, Deserialize, Eq, PartialEq, Debug)] +struct EventLog { + kind: String, + level: String, +} + +struct EventTest { + router: IntegrationTest, +} + +impl EventTest { + async fn new(events_config: serde_json::Value) -> Self { + let config = json!({ + "headers": { + "all": { + "request": [{ "propagate": { "matching": "x-log-.*" } }] + } + }, + "telemetry": { + "instrumentation": { + "events": events_config + }, + "exporters": { + "logging": { + "stdout": { + "enabled": true, + } + } + } + } + }); + let mut router = IntegrationTest::builder() + .config(serde_yaml::to_string(&config).expect("valid yaml")) + .build() + .await; + router.start().await; + router.assert_started().await; + + Self { router } + } + + async fn execute_default_query(&mut self) -> Result, BoxError> { + self.router.read_logs(); + + let (_, response) = self.router.execute_default_query().await; + response.error_for_status()?; + + Ok(self.capture_logged_events()) + } + + async fn execute_query(&mut self, query: Query) -> Result, BoxError> { + self.router.read_logs(); + + let (_, response) = self.router.execute_query(query).await; + response.error_for_status()?; + + Ok(self.capture_logged_events()) + } + + fn capture_logged_events(&mut self) -> Vec { + self.router + .capture_logs(|s| serde_json::from_str::(&s).ok()) + } + + async fn graceful_shutdown(&mut self) { + self.router.graceful_shutdown().await + } +} + +impl Drop for EventTest { + fn drop(&mut self) {} +} diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog.router.yaml index d6ecc66607..c1c4b2096e 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/datadog.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog.router.yaml @@ -5,14 +5,12 @@ telemetry: enabled: true header_name: apollo-custom-trace-id format: datadog - propagation: - trace_context: true - jaeger: true common: service_name: router resource: env: local1 service.version: router_version_override + preview_datadog_agent_sampling: true datadog: enabled: true batch_processor: diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_agent_sampling_disabled.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_agent_sampling_disabled.router.yaml new file mode 100644 index 0000000000..49b1528c94 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_agent_sampling_disabled.router.yaml @@ -0,0 +1,23 @@ +telemetry: + apollo: + field_level_instrumentation_sampler: always_off + exporters: + tracing: + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + common: + service_name: router + # NOT always_off to allow us to test a sampling probability of zero + sampler: 0.0 + preview_datadog_agent_sampling: false + datadog: + enabled: true + batch_processor: + scheduled_delay: 100ms + fixed_span_names: false + enable_span_mapping: false + instrumentation: + spans: + mode: spec_compliant + diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_agent_sampling_disabled_0.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_agent_sampling_disabled_0.router.yaml new file mode 100644 index 0000000000..42f56dd642 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_agent_sampling_disabled_0.router.yaml @@ -0,0 +1,22 @@ +telemetry: + apollo: + field_level_instrumentation_sampler: always_off + exporters: + tracing: + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + common: + service_name: router + sampler: 0.0 + preview_datadog_agent_sampling: false + datadog: + enabled: true + batch_processor: + scheduled_delay: 100ms + fixed_span_names: false + enable_span_mapping: false + instrumentation: + spans: + mode: spec_compliant + diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_agent_sampling_disabled_1.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_agent_sampling_disabled_1.router.yaml new file mode 100644 index 0000000000..2334508de4 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_agent_sampling_disabled_1.router.yaml @@ -0,0 +1,22 @@ +telemetry: + apollo: + field_level_instrumentation_sampler: always_off + exporters: + tracing: + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + common: + service_name: router + sampler: 1.0 + preview_datadog_agent_sampling: false + datadog: + enabled: true + batch_processor: + scheduled_delay: 100ms + fixed_span_names: false + enable_span_mapping: false + instrumentation: + spans: + mode: spec_compliant + diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_default_span_names.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_default_span_names.router.yaml index 67c2c070e6..e874c00fab 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/datadog_default_span_names.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_default_span_names.router.yaml @@ -7,6 +7,7 @@ telemetry: format: datadog common: service_name: router + preview_datadog_agent_sampling: true datadog: enabled: true batch_processor: diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_header_propagator_override.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_header_propagator_override.router.yaml new file mode 100644 index 0000000000..595639f1ff --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_header_propagator_override.router.yaml @@ -0,0 +1,29 @@ +telemetry: + exporters: + tracing: + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + format: datadog + propagation: + datadog: true + request: + header_name: trace-id + common: + service_name: router + parent_based_sampler: false + resource: + env: local1 + service.version: router_version_override + preview_datadog_agent_sampling: true + datadog: + enabled: true + batch_processor: + scheduled_delay: 100ms + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true + diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_no_parent_sampler.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_no_parent_sampler.router.yaml new file mode 100644 index 0000000000..c6ec7c22b7 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_no_parent_sampler.router.yaml @@ -0,0 +1,25 @@ +telemetry: + exporters: + tracing: + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + format: datadog + common: + service_name: router + parent_based_sampler: false + resource: + env: local1 + service.version: router_version_override + preview_datadog_agent_sampling: true + datadog: + enabled: true + batch_processor: + scheduled_delay: 100ms + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true + diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_no_sample.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_no_sample.router.yaml index d89d104346..19af041c56 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/datadog_no_sample.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_no_sample.router.yaml @@ -11,6 +11,7 @@ telemetry: service_name: router # NOT always_off to allow us to test a sampling probability of zero sampler: 0.0 + preview_datadog_agent_sampling: true datadog: enabled: true batch_processor: diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_override_span_names.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_override_span_names.router.yaml index 7d5e1ff2e1..bb793301d0 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/datadog_override_span_names.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_override_span_names.router.yaml @@ -7,6 +7,7 @@ telemetry: format: datadog common: service_name: router + preview_datadog_agent_sampling: true datadog: enabled: true # Span mapping will always override the span name as far as the test agent is concerned diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_override_span_names_late.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_override_span_names_late.router.yaml index dda383a784..821662b5be 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/datadog_override_span_names_late.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_override_span_names_late.router.yaml @@ -7,6 +7,7 @@ telemetry: format: datadog common: service_name: router + preview_datadog_agent_sampling: true datadog: enabled: true # Span mapping will always override the span name as far as the test agent is concerned diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small.router.yaml new file mode 100644 index 0000000000..206e72d1b1 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small.router.yaml @@ -0,0 +1,26 @@ +telemetry: + exporters: + tracing: + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + format: datadog + common: + service_name: router + sampler: 0.00001 + parent_based_sampler: true + resource: + env: local1 + service.version: router_version_override + preview_datadog_agent_sampling: true + datadog: + enabled: true + batch_processor: + scheduled_delay: 100ms + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true + diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small_no_parent.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small_no_parent.router.yaml new file mode 100644 index 0000000000..658b7d2361 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small_no_parent.router.yaml @@ -0,0 +1,25 @@ +telemetry: + exporters: + tracing: + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + format: datadog + common: + service_name: router + sampler: 0.00001 + parent_based_sampler: false + resource: + env: local1 + service.version: router_version_override + preview_datadog_agent_sampling: true + datadog: + enabled: true + batch_processor: + scheduled_delay: 100ms + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small_no_parent_no_agent_sampling.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small_no_parent_no_agent_sampling.router.yaml new file mode 100644 index 0000000000..0ccd42aed5 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small_no_parent_no_agent_sampling.router.yaml @@ -0,0 +1,26 @@ +telemetry: + apollo: + field_level_instrumentation_sampler: 0.00001 + exporters: + tracing: + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + format: datadog + common: + service_name: router + sampler: 0.00001 + parent_based_sampler: false + resource: + env: local1 + service.version: router_version_override + datadog: + enabled: true + batch_processor: + scheduled_delay: 100ms + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small_parent_no_agent_sampling.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small_parent_no_agent_sampling.router.yaml new file mode 100644 index 0000000000..8cb2df9a2e --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_parent_sampler_very_small_parent_no_agent_sampling.router.yaml @@ -0,0 +1,22 @@ +telemetry: + apollo: + field_level_instrumentation_sampler: 0.00001 + exporters: + tracing: + common: + service_name: router + sampler: 0.00001 + parent_based_sampler: true + resource: + env: local1 + service.version: router_version_override + datadog: + enabled: true + batch_processor: + scheduled_delay: 100ms + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_resource_mapping_default.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_resource_mapping_default.router.yaml index 96160b1831..0603e72c9c 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/datadog_resource_mapping_default.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_resource_mapping_default.router.yaml @@ -7,6 +7,7 @@ telemetry: format: datadog common: service_name: router + preview_datadog_agent_sampling: true datadog: enabled: true enable_span_mapping: true diff --git a/apollo-router/tests/integration/telemetry/fixtures/datadog_resource_mapping_override.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/datadog_resource_mapping_override.router.yaml index a01c44fc61..5eba22068b 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/datadog_resource_mapping_override.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/datadog_resource_mapping_override.router.yaml @@ -7,6 +7,7 @@ telemetry: format: datadog common: service_name: router + preview_datadog_agent_sampling: true datadog: enabled: true enable_span_mapping: true diff --git a/apollo-router/tests/integration/telemetry/fixtures/graphql.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/graphql.router.yaml index 24002e9be0..d3e3251545 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/graphql.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/graphql.router.yaml @@ -9,6 +9,40 @@ telemetry: instrumentation: instruments: + router: + my_custom_router_instrument: + type: counter + value: unit + unit: reqs + description: "my test instrument" + attributes: + my_response_body: + response_body: true + supergraph: + oplimits.aliases: + value: + query: aliases + type: histogram + unit: number + description: "Aliases for an operation" + oplimits.depth: + value: + query: depth + type: histogram + unit: number + description: "Depth for an operation" + oplimits.height: + value: + query: height + type: histogram + unit: number + description: "Height for an operation" + oplimits.root_fields: + value: + query: root_fields + type: histogram + unit: number + description: "Root fields for an operation" graphql: field.execution: true list.length: true @@ -38,7 +72,4 @@ telemetry: condition: eq: - field_name: string - - "topProducts" - - - + - "topProducts" \ No newline at end of file diff --git a/apollo-router/tests/integration/telemetry/fixtures/jaeger-0.5-sample.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/jaeger-0.5-sample.router.yaml deleted file mode 100644 index 6bd0fad86c..0000000000 --- a/apollo-router/tests/integration/telemetry/fixtures/jaeger-0.5-sample.router.yaml +++ /dev/null @@ -1,31 +0,0 @@ -telemetry: - exporters: - tracing: - experimental_response_trace_id: - enabled: true - header_name: apollo-custom-trace-id - propagation: - jaeger: true - common: - service_name: router - sampler: 0.5 - jaeger: - enabled: true - batch_processor: - scheduled_delay: 100ms - collector: - endpoint: http://127.0.0.1:14268/api/traces - logging: - experimental_when_header: - - name: apollo-router-log-request - value: test - headers: true # default: false - body: true # default: false - # log request for all requests coming from Iphones - - name: custom-header - match: ^foo.* - headers: true -override_subgraph_url: - products: http://localhost:4005 -include_subgraph_errors: - all: true diff --git a/apollo-router/tests/integration/telemetry/fixtures/jaeger-advanced.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/jaeger-advanced.router.yaml deleted file mode 100644 index bb377026d7..0000000000 --- a/apollo-router/tests/integration/telemetry/fixtures/jaeger-advanced.router.yaml +++ /dev/null @@ -1,62 +0,0 @@ -telemetry: - exporters: - tracing: - experimental_response_trace_id: - enabled: true - header_name: apollo-custom-trace-id - propagation: - jaeger: true - common: - service_name: router - sampler: always_on - jaeger: - enabled: true - batch_processor: - scheduled_delay: 100ms - collector: - endpoint: http://127.0.0.1:14268/api/traces - logging: - experimental_when_header: - - name: apollo-router-log-request - value: test - headers: true # default: false - body: true # default: false - # log request for all requests coming from Iphones - - name: custom-header - match: ^foo.* - headers: true - instrumentation: - spans: - mode: deprecated - router: - attributes: - http.request.method: true - http.response.status_code: true - url.path: true - "http.request.header.x-my-header": - request_header: "x-my-header" - "http.request.header.x-not-present": - request_header: "x-not-present" - default: nope - "http.request.header.x-my-header-condition": - request_header: "x-my-header" - condition: - eq: - - request_header: "head" - - "test" - studio.operation.id: - studio_operation_id: true - supergraph: - attributes: - graphql.operation.name: true - graphql.operation.type: true - graphql.document: true - subgraph: - attributes: - subgraph.graphql.operation.type: true - subgraph.name: true - -override_subgraph_url: - products: http://localhost:4005 -include_subgraph_errors: - all: true \ No newline at end of file diff --git a/apollo-router/tests/integration/telemetry/fixtures/jaeger-no-sample.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/jaeger-no-sample.router.yaml deleted file mode 100644 index ffa63c772d..0000000000 --- a/apollo-router/tests/integration/telemetry/fixtures/jaeger-no-sample.router.yaml +++ /dev/null @@ -1,34 +0,0 @@ -telemetry: - apollo: - field_level_instrumentation_sampler: always_off - exporters: - tracing: - - experimental_response_trace_id: - enabled: true - header_name: apollo-custom-trace-id - propagation: - jaeger: true - common: - service_name: router - sampler: always_off - jaeger: - enabled: true - batch_processor: - scheduled_delay: 100ms - collector: - endpoint: http://127.0.0.1:14268/api/traces - logging: - experimental_when_header: - - name: apollo-router-log-request - value: test - headers: true # default: false - body: true # default: false - # log request for all requests coming from Iphones - - name: custom-header - match: ^foo.* - headers: true -override_subgraph_url: - products: http://localhost:4005 -include_subgraph_errors: - all: true diff --git a/apollo-router/tests/integration/telemetry/fixtures/jaeger.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/jaeger.router.yaml deleted file mode 100644 index 11d6dad4ba..0000000000 --- a/apollo-router/tests/integration/telemetry/fixtures/jaeger.router.yaml +++ /dev/null @@ -1,32 +0,0 @@ -telemetry: - exporters: - tracing: - experimental_response_trace_id: - enabled: true - header_name: apollo-custom-trace-id - propagation: - jaeger: true - common: - service_name: router - sampler: always_on - jaeger: - enabled: true - batch_processor: - scheduled_delay: 100ms - collector: - endpoint: http://127.0.0.1:14268/api/traces - logging: - experimental_when_header: - - name: apollo-router-log-request - value: test - headers: true # default: false - body: true # default: false - # log request for all requests coming from Iphones - - name: custom-header - match: ^foo.* - headers: true - -override_subgraph_url: - products: http://localhost:4005 -include_subgraph_errors: - all: true diff --git a/apollo-router/tests/integration/telemetry/fixtures/jaeger_decimal_trace_id.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/jaeger_decimal_trace_id.router.yaml deleted file mode 100644 index e7c92e3599..0000000000 --- a/apollo-router/tests/integration/telemetry/fixtures/jaeger_decimal_trace_id.router.yaml +++ /dev/null @@ -1,33 +0,0 @@ -telemetry: - exporters: - tracing: - experimental_response_trace_id: - enabled: true - format: decimal - header_name: apollo-custom-trace-id - propagation: - jaeger: true - common: - service_name: router - sampler: always_on - jaeger: - enabled: true - batch_processor: - scheduled_delay: 100ms - collector: - endpoint: http://127.0.0.1:14268/api/traces - logging: - experimental_when_header: - - name: apollo-router-log-request - value: test - headers: true # default: false - body: true # default: false - # log request for all requests coming from Iphones - - name: custom-header - match: ^foo.* - headers: true - -override_subgraph_url: - products: http://localhost:4005 -include_subgraph_errors: - all: true diff --git a/apollo-router/tests/integration/telemetry/fixtures/json.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/json.router.yaml index 8fa0f2a74e..30f4e69446 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/json.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/json.router.yaml @@ -106,16 +106,11 @@ telemetry: tracing: propagation: trace_context: true - jaeger: + otlp: enabled: true batch_processor: scheduled_delay: 100ms - agent: - endpoint: default + endpoint: default logging: - experimental_when_header: - - name: content-type - value: "application/json" - body: true stdout: format: json diff --git a/apollo-router/tests/integration/telemetry/fixtures/json.sampler_off.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/json.sampler_off.router.yaml index 3190c14d34..9bb4754cf1 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/json.sampler_off.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/json.sampler_off.router.yaml @@ -108,16 +108,11 @@ telemetry: sampler: always_off propagation: trace_context: true - jaeger: + otlp: enabled: true batch_processor: scheduled_delay: 100ms - agent: - endpoint: default + endpoint: default logging: - experimental_when_header: - - name: content-type - value: "application/json" - body: true stdout: format: json diff --git a/apollo-router/tests/integration/telemetry/fixtures/json.span_attributes.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/json.span_attributes.router.yaml index 324a22780d..d560feb886 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/json.span_attributes.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/json.span_attributes.router.yaml @@ -115,17 +115,12 @@ telemetry: tracing: propagation: trace_context: true - jaeger: + otlp: enabled: true batch_processor: scheduled_delay: 100ms - agent: - endpoint: default + endpoint: default logging: - experimental_when_header: - - name: content-type - value: "application/json" - body: true stdout: format: json: diff --git a/apollo-router/tests/integration/telemetry/fixtures/json.uuid.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/json.uuid.router.yaml index 7b9a97af99..958e63e9a8 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/json.uuid.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/json.uuid.router.yaml @@ -13,17 +13,12 @@ telemetry: propagation: trace_context: true jaeger: true - jaeger: + otlp: enabled: true batch_processor: scheduled_delay: 100ms - agent: - endpoint: default + endpoint: default logging: - experimental_when_header: - - name: content-type - value: "application/json" - body: true stdout: format: json: diff --git a/apollo-router/tests/integration/telemetry/fixtures/no-telemetry.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/no-telemetry.router.yaml index dd561d6029..1518708634 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/no-telemetry.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/no-telemetry.router.yaml @@ -7,16 +7,6 @@ telemetry: common: service_name: router sampler: always_on - logging: - experimental_when_header: - - name: apollo-router-log-request - value: test - headers: true # default: false - body: true # default: false - # log request for all requests coming from Iphones - - name: custom-header - match: ^foo.* - headers: true override_subgraph_url: products: http://localhost:4005 include_subgraph_errors: diff --git a/apollo-router/tests/integration/telemetry/fixtures/otlp.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/otlp.router.yaml index f4484786f4..92f76c88fe 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/otlp.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/otlp.router.yaml @@ -6,10 +6,13 @@ telemetry: header_name: apollo-custom-trace-id common: service_name: router + resource: + env: local1 + service.version: router_version_override otlp: enabled: true protocol: http - endpoint: /traces + endpoint: batch_processor: scheduled_delay: 10ms metrics: @@ -22,3 +25,15 @@ telemetry: batch_processor: scheduled_delay: 10ms + + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true + + subgraph: + attributes: + otel.name: + subgraph_operation_name: string \ No newline at end of file diff --git a/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_agent_no_sample.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_agent_no_sample.router.yaml new file mode 100644 index 0000000000..77529f500d --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_agent_no_sample.router.yaml @@ -0,0 +1,42 @@ +telemetry: + apollo: + field_level_instrumentation_sampler: always_off + exporters: + tracing: + propagation: + datadog: true + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + common: + service_name: router + preview_datadog_agent_sampling: true + sampler: 0.0 + otlp: + enabled: true + protocol: http + endpoint: + batch_processor: + scheduled_delay: 10ms + metrics: + common: + service_name: router + otlp: + enabled: true + endpoint: /metrics + protocol: http + batch_processor: + scheduled_delay: 10ms + + + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true + + subgraph: + attributes: + otel.name: + subgraph_operation_name: string \ No newline at end of file diff --git a/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_agent_sample.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_agent_sample.router.yaml new file mode 100644 index 0000000000..6b1f32f71f --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_agent_sample.router.yaml @@ -0,0 +1,42 @@ +telemetry: + apollo: + field_level_instrumentation_sampler: always_off + exporters: + tracing: + propagation: + datadog: true + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + common: + service_name: router + preview_datadog_agent_sampling: true + sampler: 1.0 + otlp: + enabled: true + protocol: http + endpoint: + batch_processor: + scheduled_delay: 10ms + metrics: + common: + service_name: router + otlp: + enabled: true + endpoint: /metrics + protocol: http + batch_processor: + scheduled_delay: 10ms + + + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true + + subgraph: + attributes: + otel.name: + subgraph_operation_name: string \ No newline at end of file diff --git a/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_agent_sample_no_sample.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_agent_sample_no_sample.router.yaml new file mode 100644 index 0000000000..77529f500d --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_agent_sample_no_sample.router.yaml @@ -0,0 +1,42 @@ +telemetry: + apollo: + field_level_instrumentation_sampler: always_off + exporters: + tracing: + propagation: + datadog: true + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + common: + service_name: router + preview_datadog_agent_sampling: true + sampler: 0.0 + otlp: + enabled: true + protocol: http + endpoint: + batch_processor: + scheduled_delay: 10ms + metrics: + common: + service_name: router + otlp: + enabled: true + endpoint: /metrics + protocol: http + batch_processor: + scheduled_delay: 10ms + + + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true + + subgraph: + attributes: + otel.name: + subgraph_operation_name: string \ No newline at end of file diff --git a/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_propagation.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_propagation.router.yaml new file mode 100644 index 0000000000..7352f3d620 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_propagation.router.yaml @@ -0,0 +1,39 @@ +telemetry: + exporters: + tracing: + propagation: + datadog: true + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + common: + service_name: router + preview_datadog_agent_sampling: true + otlp: + enabled: true + protocol: http + endpoint: + batch_processor: + scheduled_delay: 10ms + metrics: + common: + service_name: router + otlp: + enabled: true + endpoint: /metrics + protocol: http + batch_processor: + scheduled_delay: 10ms + + + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true + + subgraph: + attributes: + otel.name: + subgraph_operation_name: string \ No newline at end of file diff --git a/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_propagation_no_agent.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_propagation_no_agent.router.yaml new file mode 100644 index 0000000000..08323073f3 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_propagation_no_agent.router.yaml @@ -0,0 +1,38 @@ +telemetry: + exporters: + tracing: + propagation: + datadog: true + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + common: + service_name: router + otlp: + enabled: true + protocol: http + endpoint: + batch_processor: + scheduled_delay: 10ms + metrics: + common: + service_name: router + otlp: + enabled: true + endpoint: /metrics + protocol: http + batch_processor: + scheduled_delay: 10ms + + + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true + + subgraph: + attributes: + otel.name: + subgraph_operation_name: string \ No newline at end of file diff --git a/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_propagation_no_parent_sampler.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_propagation_no_parent_sampler.router.yaml new file mode 100644 index 0000000000..7fd47f096b --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_propagation_no_parent_sampler.router.yaml @@ -0,0 +1,40 @@ +telemetry: + exporters: + tracing: + propagation: + datadog: true + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + common: + parent_based_sampler: false + preview_datadog_agent_sampling: true + service_name: router + otlp: + enabled: true + protocol: http + endpoint: + batch_processor: + scheduled_delay: 10ms + metrics: + common: + service_name: router + otlp: + enabled: true + endpoint: /metrics + protocol: http + batch_processor: + scheduled_delay: 10ms + + + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true + + subgraph: + attributes: + otel.name: + subgraph_operation_name: string \ No newline at end of file diff --git a/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_request_with_zipkin_propagator.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_request_with_zipkin_propagator.router.yaml new file mode 100644 index 0000000000..3bcb4e5db5 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/otlp_datadog_request_with_zipkin_propagator.router.yaml @@ -0,0 +1,41 @@ +telemetry: + apollo: + field_level_instrumentation_sampler: always_off + exporters: + tracing: + propagation: + zipkin: true + datadog: true + trace_context: true + common: + service_name: router + preview_datadog_agent_sampling: true + sampler: 1.0 + otlp: + enabled: true + protocol: http + endpoint: + batch_processor: + scheduled_delay: 10ms + metrics: + common: + service_name: router + otlp: + enabled: true + endpoint: /metrics + protocol: http + batch_processor: + scheduled_delay: 10ms + + + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true + + subgraph: + attributes: + otel.name: + subgraph_operation_name: string \ No newline at end of file diff --git a/apollo-router/tests/integration/telemetry/fixtures/otlp_invalid_endpoint.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/otlp_invalid_endpoint.router.yaml new file mode 100644 index 0000000000..0358e9a560 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/otlp_invalid_endpoint.router.yaml @@ -0,0 +1,16 @@ +telemetry: + exporters: + tracing: + common: + service_name: router + otlp: + enabled: true + endpoint: + batch_processor: + scheduled_delay: 1s + max_queue_size: 1 + max_concurrent_exports: 1 + max_export_batch_size: 1 + metrics: + prometheus: + enabled: true diff --git a/apollo-router/tests/integration/telemetry/fixtures/otlp_no_parent_sampler.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/otlp_no_parent_sampler.router.yaml new file mode 100644 index 0000000000..5fdf22e0d6 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/otlp_no_parent_sampler.router.yaml @@ -0,0 +1,25 @@ +telemetry: + exporters: + tracing: + experimental_response_trace_id: + enabled: true + header_name: apollo-custom-trace-id + common: + service_name: router + parent_based_sampler: false + otlp: + enabled: true + protocol: http + endpoint: /traces + batch_processor: + scheduled_delay: 10ms + metrics: + common: + service_name: router + otlp: + enabled: true + endpoint: /metrics + protocol: http + batch_processor: + scheduled_delay: 10ms + diff --git a/apollo-router/tests/integration/telemetry/fixtures/otlp_override_client_name.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/otlp_override_client_name.router.yaml new file mode 100644 index 0000000000..a2b7a66400 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/otlp_override_client_name.router.yaml @@ -0,0 +1,31 @@ +rhai: + scripts: "tests/integration/telemetry/fixtures" + main: "override_client_name.rhai" +telemetry: + instrumentation: + spans: + mode: spec_compliant + events: + router: + request: info + response: info + error: info + exporters: + tracing: + common: + service_name: router + otlp: + enabled: true + protocol: http + endpoint: + batch_processor: + scheduled_delay: 10ms + metrics: + common: + service_name: router + otlp: + enabled: true + endpoint: /metrics + protocol: http + batch_processor: + scheduled_delay: 10ms diff --git a/apollo-router/tests/integration/telemetry/fixtures/otlp_parent_sampler_very_small_no_parent_no_agent_sampling.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/otlp_parent_sampler_very_small_no_parent_no_agent_sampling.router.yaml new file mode 100644 index 0000000000..1506f44bc5 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/otlp_parent_sampler_very_small_no_parent_no_agent_sampling.router.yaml @@ -0,0 +1,24 @@ +telemetry: + apollo: + field_level_instrumentation_sampler: 0.00001 + exporters: + tracing: + common: + service_name: router + sampler: 0.00001 + parent_based_sampler: false + resource: + env: local1 + service.version: router_version_override + otlp: + enabled: true + protocol: http + endpoint: /traces + batch_processor: + scheduled_delay: 10ms + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true diff --git a/apollo-router/tests/integration/telemetry/fixtures/override_client_name.rhai b/apollo-router/tests/integration/telemetry/fixtures/override_client_name.rhai new file mode 100644 index 0000000000..bd927291c3 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/fixtures/override_client_name.rhai @@ -0,0 +1,8 @@ +fn router_service(service) { + const request_callback = Fn("process_request"); + service.map_request(request_callback); +} + +fn process_request(request) { + request.context["apollo::telemetry::client_name"] = "foo"; +} diff --git a/apollo-router/tests/integration/telemetry/fixtures/prometheus.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/prometheus.router.yaml index 1ae02a4dff..486e322c2a 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/prometheus.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/prometheus.router.yaml @@ -1,6 +1,18 @@ limits: http_max_request_bytes: 200 telemetry: + instrumentation: + instruments: + default_requirement_level: required + router: + http.server.request.duration: + attributes: + server.port: false + server.address: false + http.response.status_code: + alias: status + error: + error: reason exporters: metrics: prometheus: @@ -21,14 +33,6 @@ telemetry: - 4 - 5 - 100 - attributes: - subgraph: - all: - request: - header: - - named: "x-custom-header" - rename: "custom_header" - default: "unknown" headers: all: request: diff --git a/apollo-router/tests/integration/telemetry/fixtures/subgraph_auth.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/subgraph_auth.router.yaml index 48c9964bbf..be71589118 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/subgraph_auth.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/subgraph_auth.router.yaml @@ -19,14 +19,6 @@ telemetry: - 4 - 5 - 100 - attributes: - subgraph: - all: - request: - header: - - named: "x-custom-header" - rename: "custom_header" - default: "unknown" headers: all: request: diff --git a/apollo-router/tests/integration/telemetry/fixtures/text.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/text.router.yaml index ef009e55bd..9f7aef7f27 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/text.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/text.router.yaml @@ -105,17 +105,12 @@ telemetry: tracing: propagation: trace_context: true - jaeger: + otlp: enabled: true batch_processor: scheduled_delay: 100ms - agent: - endpoint: default + endpoint: default logging: - experimental_when_header: - - name: content-type - value: "application/json" - body: true stdout: format: text: diff --git a/apollo-router/tests/integration/telemetry/fixtures/text.sampler_off.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/text.sampler_off.router.yaml index 57a538efcb..2c31f6f5bf 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/text.sampler_off.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/text.sampler_off.router.yaml @@ -102,17 +102,12 @@ telemetry: sampler: always_off propagation: trace_context: true - jaeger: + otlp: enabled: true batch_processor: scheduled_delay: 100ms - agent: - endpoint: default + endpoint: default logging: - experimental_when_header: - - name: content-type - value: "application/json" - body: true stdout: format: text: diff --git a/apollo-router/tests/integration/telemetry/fixtures/text.uuid.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/text.uuid.router.yaml index 13b6084b49..88b6ef43ef 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/text.uuid.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/text.uuid.router.yaml @@ -13,17 +13,12 @@ telemetry: propagation: trace_context: true jaeger: true - jaeger: + otlp: enabled: true batch_processor: scheduled_delay: 100ms - agent: - endpoint: default + endpoint: default logging: - experimental_when_header: - - name: content-type - value: "application/json" - body: true stdout: format: text: diff --git a/apollo-router/tests/integration/telemetry/fixtures/trace_id_via_header.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/trace_id_via_header.router.yaml index a213522b36..b244673a47 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/trace_id_via_header.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/trace_id_via_header.router.yaml @@ -1,9 +1,6 @@ telemetry: - instrumentation: - spans: - mode: spec_compliant router: attributes: @@ -30,4 +27,3 @@ telemetry: display_span_id: true ansi_escape_codes: false display_current_span: true - diff --git a/apollo-router/tests/integration/telemetry/fixtures/zipkin.router.yaml b/apollo-router/tests/integration/telemetry/fixtures/zipkin.router.yaml index 1705113f18..1e0f587f4a 100644 --- a/apollo-router/tests/integration/telemetry/fixtures/zipkin.router.yaml +++ b/apollo-router/tests/integration/telemetry/fixtures/zipkin.router.yaml @@ -1,4 +1,7 @@ telemetry: + instrumentation: + spans: + mode: deprecated exporters: tracing: experimental_response_trace_id: diff --git a/apollo-router/tests/integration/telemetry/jaeger.rs b/apollo-router/tests/integration/telemetry/jaeger.rs deleted file mode 100644 index fcf59e4ef5..0000000000 --- a/apollo-router/tests/integration/telemetry/jaeger.rs +++ /dev/null @@ -1,659 +0,0 @@ -extern crate core; - -use std::collections::HashSet; -use std::time::Duration; - -use anyhow::anyhow; -use opentelemetry_api::trace::TraceId; -use serde_json::json; -use serde_json::Value; -use tower::BoxError; - -use crate::integration::common::Telemetry; -use crate::integration::IntegrationTest; -use crate::integration::ValueExt; - -#[tokio::test(flavor = "multi_thread")] -async fn test_reload() -> Result<(), BoxError> { - let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) - .config(include_str!("fixtures/jaeger.router.yaml")) - .build() - .await; - - router.start().await; - router.assert_started().await; - - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - for _ in 0..2 { - let (id, result) = router.execute_query(&query).await; - assert!(!result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .is_empty()); - validate_trace( - id, - &query, - Some("ExampleQuery"), - &["client", "router", "subgraph"], - false, - ) - .await?; - router.touch_config().await; - router.assert_reloaded().await; - } - router.graceful_shutdown().await; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_remote_root() -> Result<(), BoxError> { - let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) - .config(include_str!("fixtures/jaeger-no-sample.router.yaml")) - .build() - .await; - - router.start().await; - router.assert_started().await; - - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let (id, result) = router.execute_query(&query).await; - assert!(!result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .is_empty()); - validate_trace( - id, - &query, - Some("ExampleQuery"), - &["client", "router", "subgraph"], - false, - ) - .await?; - - router.graceful_shutdown().await; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_local_root() -> Result<(), BoxError> { - let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) - .config(include_str!("fixtures/jaeger.router.yaml")) - .build() - .await; - - router.start().await; - router.assert_started().await; - - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let (id, result) = router.execute_untraced_query(&query).await; - assert!(!result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .is_empty()); - validate_trace( - id, - &query, - Some("ExampleQuery"), - &["router", "subgraph"], - false, - ) - .await?; - - router.graceful_shutdown().await; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_local_root_no_sample() -> Result<(), BoxError> { - let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) - .config(include_str!("fixtures/jaeger-no-sample.router.yaml")) - .build() - .await; - - router.start().await; - router.assert_started().await; - - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let (_, response) = router.execute_untraced_query(&query).await; - assert!(response.headers().get("apollo-custom-trace-id").is_some()); - - router.graceful_shutdown().await; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_local_root_50_percent_sample() -> Result<(), BoxError> { - let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) - .config(include_str!("fixtures/jaeger-0.5-sample.router.yaml")) - .build() - .await; - - router.start().await; - router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}\n","variables":{}, "operationName": "ExampleQuery"}); - - for _ in 0..100 { - let (id, result) = router.execute_untraced_query(&query).await; - - if result.headers().get("apollo-custom-trace-id").is_some() - && validate_trace( - id, - &query, - Some("ExampleQuery"), - &["router", "subgraph"], - false, - ) - .await - .is_ok() - { - router.graceful_shutdown().await; - - return Ok(()); - } - } - panic!("tried 100 requests with telemetry sampled at 50%, no traces were found") -} - -#[tokio::test(flavor = "multi_thread")] -#[ignore] -async fn test_no_telemetry() -> Result<(), BoxError> { - // This test is currently skipped because it will only pass once we default the sampler to always off if there are no exporters. - // Once this is fixed then we can re-enable - let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) - .config(include_str!("fixtures/no-telemetry.router.yaml")) - .build() - .await; - - router.start().await; - router.assert_started().await; - - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let (_, response) = router.execute_untraced_query(&query).await; - assert!(response.headers().get("apollo-custom-trace-id").is_none()); - - router.graceful_shutdown().await; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_default_operation() -> Result<(), BoxError> { - let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) - .config(include_str!("fixtures/jaeger.router.yaml")) - .build() - .await; - - router.start().await; - router.assert_started().await; - let query = json!({"query":"query ExampleQuery1 {topProducts{name}}","variables":{}}); - - let (id, result) = router.execute_query(&query).await; - assert!(!result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .is_empty()); - validate_trace( - id, - &query, - Some("ExampleQuery1"), - &["client", "router", "subgraph"], - false, - ) - .await?; - router.graceful_shutdown().await; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_anonymous_operation() -> Result<(), BoxError> { - let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) - .config(include_str!("fixtures/jaeger.router.yaml")) - .build() - .await; - - router.start().await; - router.assert_started().await; - - let query = json!({"query":"query {topProducts{name}}","variables":{}}); - - let (id, result) = router.execute_query(&query).await; - assert!(!result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .is_empty()); - validate_trace(id, &query, None, &["client", "router", "subgraph"], false).await?; - router.graceful_shutdown().await; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_selected_operation() -> Result<(), BoxError> { - let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) - .config(include_str!("fixtures/jaeger.router.yaml")) - .build() - .await; - - router.start().await; - router.assert_started().await; - let query = json!({"query":"query ExampleQuery1 {topProducts{name}}\nquery ExampleQuery2 {topProducts{name}}","variables":{}, "operationName": "ExampleQuery2"}); - - let (id, result) = router.execute_query(&query).await; - assert!(!result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .is_empty()); - validate_trace( - id, - &query, - Some("ExampleQuery2"), - &["client", "router", "subgraph"], - false, - ) - .await?; - router.graceful_shutdown().await; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_span_customization() -> Result<(), BoxError> { - if std::env::var("TEST_APOLLO_KEY").is_ok() && std::env::var("TEST_APOLLO_GRAPH_REF").is_ok() { - let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) - .config(include_str!("fixtures/jaeger-advanced.router.yaml")) - .build() - .await; - - router.start().await; - router.assert_started().await; - - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - let (id, _res) = router.execute_query(&query).await; - validate_trace( - id, - &query, - Some("ExampleQuery"), - &["client", "router", "subgraph"], - true, - ) - .await?; - router.graceful_shutdown().await; - } - Ok(()) -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_decimal_trace_id() -> Result<(), BoxError> { - let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) - .config(include_str!("fixtures/jaeger_decimal_trace_id.router.yaml")) - .build() - .await; - - router.start().await; - router.assert_started().await; - let query = json!({"query":"query ExampleQuery1 {topProducts{name}}","variables":{}}); - - let (id, result) = router.execute_query(&query).await; - let id_from_router: u128 = result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .to_str() - .unwrap_or_default() - .parse() - .expect("expected decimal trace ID"); - assert_eq!(format!("{:x}", id_from_router), id.to_string()); - - validate_trace( - id, - &query, - Some("ExampleQuery1"), - &["client", "router", "subgraph"], - false, - ) - .await?; - router.graceful_shutdown().await; - Ok(()) -} - -async fn validate_trace( - id: TraceId, - query: &Value, - operation_name: Option<&str>, - services: &[&'static str], - custom_span_instrumentation: bool, -) -> Result<(), BoxError> { - let params = url::form_urlencoded::Serializer::new(String::new()) - .append_pair("service", services.first().expect("expected root service")) - .finish(); - - let id = id.to_string(); - let url = format!("http://localhost:16686/api/traces/{id}?{params}"); - for _ in 0..10 { - if find_valid_trace( - &url, - query, - operation_name, - services, - custom_span_instrumentation, - ) - .await - .is_ok() - { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(1000)).await; - } - find_valid_trace( - &url, - query, - operation_name, - services, - custom_span_instrumentation, - ) - .await?; - Ok(()) -} - -async fn find_valid_trace( - url: &str, - query: &Value, - operation_name: Option<&str>, - services: &[&'static str], - custom_span_instrumentation: bool, -) -> Result<(), BoxError> { - // A valid trace has: - // * All three services - // * The correct spans - // * All spans are parented - // * Required attributes of 'router' span has been set - let trace: Value = reqwest::get(url) - .await - .map_err(|e| anyhow!("failed to contact jaeger; {}", e))? - .json() - .await?; - tracing::debug!("{}", serde_json::to_string_pretty(&trace)?); - - // Verify that we got all the participants in the trace - verify_trace_participants(&trace, services)?; - - // Verify that we got the expected span operation names - verify_spans_present(&trace, operation_name, services)?; - - // Verify that all spans have a path to the root 'client_request' span - verify_span_parenting(&trace, services)?; - - // Verify that root span fields are present - verify_root_span_fields(&trace, operation_name)?; - - // Verify that supergraph span fields are present - verify_supergraph_span_fields(&trace, query, operation_name, custom_span_instrumentation)?; - - // Verify that router span fields are present - verify_router_span_fields(&trace, custom_span_instrumentation)?; - - Ok(()) -} - -fn verify_router_span_fields( - trace: &Value, - custom_span_instrumentation: bool, -) -> Result<(), BoxError> { - let router_span = trace.select_path("$..spans[?(@.operationName == 'router')]")?[0]; - // We can't actually assert the values on a span. Only that a field has been set. - assert_eq!( - router_span - .select_path("$.tags[?(@.key == 'client.name')].value")? - .first(), - Some(&&Value::String("custom_name".to_string())) - ); - assert_eq!( - router_span - .select_path("$.tags[?(@.key == 'client.version')].value")? - .first(), - Some(&&Value::String("1.0".to_string())) - ); - assert!(router_span - .select_path("$.logs[*].fields[?(@.key == 'histogram.apollo_router_span')].value")? - .is_empty(),); - assert!(router_span - .select_path("$.logs[*].fields[?(@.key == 'histogram.apollo_router_span')].value")? - .is_empty(),); - if custom_span_instrumentation { - assert_eq!( - router_span - .select_path("$.tags[?(@.key == 'http.request.method')].value")? - .first(), - Some(&&Value::String("POST".to_string())) - ); - assert_eq!( - router_span - .select_path("$.tags[?(@.key == 'http.request.header.x-not-present')].value")? - .first(), - Some(&&Value::String("nope".to_string())) - ); - assert_eq!( - router_span - .select_path( - "$.tags[?(@.key == 'http.request.header.x-my-header-condition')].value" - )? - .first(), - Some(&&Value::String("test".to_string())) - ); - assert_eq!( - router_span - .select_path("$.tags[?(@.key == 'studio.operation.id')].value")? - .first(), - Some(&&Value::String( - "f60e643d7f52ecda23216f86409d7e2e5c3aa68c".to_string() - )) - ); - } - - Ok(()) -} - -fn verify_root_span_fields(trace: &Value, operation_name: Option<&str>) -> Result<(), BoxError> { - // We can't actually assert the values on a span. Only that a field has been set. - let root_span_name = operation_name - .map(|name| format!("query {}", name)) - .unwrap_or("query".to_string()); - let request_span = trace.select_path(&format!( - "$..spans[?(@.operationName == '{root_span_name}')]" - ))?[0]; - - if let Some(operation_name) = operation_name { - assert_eq!( - request_span - .select_path("$.tags[?(@.key == 'graphql.operation.name')].value")? - .first(), - Some(&&Value::String(operation_name.to_string())) - ); - } else { - assert!(request_span - .select_path("$.tags[?(@.key == 'graphql.operation.name')].value")? - .first() - .is_none(),); - } - - assert_eq!( - request_span - .select_path("$.tags[?(@.key == 'graphql.operation.type')].value")? - .first(), - Some(&&Value::String("query".to_string())) - ); - - Ok(()) -} - -fn verify_supergraph_span_fields( - trace: &Value, - query: &Value, - operation_name: Option<&str>, - custom_span_instrumentation: bool, -) -> Result<(), BoxError> { - // We can't actually assert the values on a span. Only that a field has been set. - let supergraph_span = trace.select_path("$..spans[?(@.operationName == 'supergraph')]")?[0]; - - if let Some(operation_name) = operation_name { - assert_eq!( - supergraph_span - .select_path("$.tags[?(@.key == 'graphql.operation.name')].value")? - .first(), - Some(&&Value::String(operation_name.to_string())) - ); - } else { - assert!(supergraph_span - .select_path("$.tags[?(@.key == 'graphql.operation.name')].value")? - .first() - .is_none(),); - } - if custom_span_instrumentation { - assert_eq!( - supergraph_span - .select_path("$.tags[?(@.key == 'graphql.operation.type')].value")? - .first(), - Some(&&Value::String("query".to_string())) - ); - } - - assert_eq!( - supergraph_span - .select_path("$.tags[?(@.key == 'graphql.document')].value")? - .first(), - Some(&&Value::String( - query - .as_object() - .expect("should have been an object") - .get("query") - .expect("must have a query") - .as_str() - .expect("must be a string") - .to_string() - )) - ); - - Ok(()) -} - -fn verify_trace_participants(trace: &Value, services: &[&'static str]) -> Result<(), BoxError> { - let actual_services: HashSet = trace - .select_path("$..serviceName")? - .into_iter() - .filter_map(|service| service.as_string()) - .collect(); - tracing::debug!("found services {:?}", actual_services); - - let expected_services = services - .iter() - .map(|s| s.to_string()) - .collect::>(); - if actual_services != expected_services { - return Err(BoxError::from(format!( - "incomplete traces, got {actual_services:?} expected {expected_services:?}" - ))); - } - Ok(()) -} - -fn verify_spans_present( - trace: &Value, - operation_name: Option<&str>, - services: &[&'static str], -) -> Result<(), BoxError> { - let operation_names: HashSet = trace - .select_path("$..operationName")? - .into_iter() - .filter_map(|span_name| span_name.as_string()) - .collect(); - let mut expected_operation_names: HashSet = HashSet::from( - [ - "execution", - "subgraph server", - operation_name - .map(|name| format!("query {name}")) - .unwrap_or("query".to_string()) - .as_str(), - "supergraph", - "fetch", - //"parse_query", Parse query will only happen once - //"query_planning", query planning will only happen once - "subgraph", - ] - .map(|s| s.into()), - ); - if services.contains(&"client") { - expected_operation_names.insert("client_request".into()); - } - tracing::debug!("found spans {:?}", operation_names); - let missing_operation_names: Vec<_> = expected_operation_names - .iter() - .filter(|o| !operation_names.contains(*o)) - .collect(); - if !missing_operation_names.is_empty() { - return Err(BoxError::from(format!( - "spans did not match, got {operation_names:?}, missing {missing_operation_names:?}" - ))); - } - Ok(()) -} - -fn verify_span_parenting(trace: &Value, services: &[&'static str]) -> Result<(), BoxError> { - let root_span = if services.contains(&"client") { - trace.select_path("$..spans[?(@.operationName == 'client_request')]")?[0] - } else { - trace.select_path("$..spans[?(@.operationName == 'query ExampleQuery')]")?[0] - }; - let spans = trace.select_path("$..spans[*]")?; - for span in spans { - let mut span_path = vec![span.select_path("$.operationName")?[0] - .as_str() - .expect("operation name not not found")]; - let mut current = span; - while let Some(parent) = parent_span(trace, current) { - span_path.push( - parent.select_path("$.operationName")?[0] - .as_str() - .expect("operation name not not found"), - ); - current = parent; - } - tracing::debug!("span path to root: '{:?}'", span_path); - if current != root_span { - return Err(BoxError::from(format!( - "span {:?} did not have a path to the root span", - span.select_path("$.operationName")?, - ))); - } - } - Ok(()) -} - -fn parent_span<'a>(trace: &'a Value, span: &'a Value) -> Option<&'a Value> { - span.select_path("$.references[?(@.refType == 'CHILD_OF')].spanID") - .ok()? - .into_iter() - .filter_map(|id| id.as_str()) - .filter_map(|id| { - trace - .select_path(&format!("$..spans[?(@.spanID == '{id}')]")) - .ok()? - .into_iter() - .next() - }) - .next() -} diff --git a/apollo-router/tests/integration/telemetry/logging.rs b/apollo-router/tests/integration/telemetry/logging.rs index 9e41160572..91ba0b4685 100644 --- a/apollo-router/tests/integration/telemetry/logging.rs +++ b/apollo-router/tests/integration/telemetry/logging.rs @@ -1,10 +1,10 @@ -use serde_json::json; use tower::BoxError; use uuid::Uuid; -use crate::integration::common::graph_os_enabled; use crate::integration::common::IntegrationTest; +use crate::integration::common::Query; use crate::integration::common::Telemetry; +use crate::integration::common::graph_os_enabled; #[tokio::test(flavor = "multi_thread")] async fn test_json() -> Result<(), BoxError> { @@ -14,7 +14,7 @@ async fn test_json() -> Result<(), BoxError> { } let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) + .telemetry(Telemetry::Otlp { endpoint: None }) .config(include_str!("fixtures/json.router.yaml")) .build() .await; @@ -22,29 +22,30 @@ async fn test_json() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - router.execute_query(&query).await; - router.assert_log_contains("trace_id").await; - router.execute_query(&query).await; - router.assert_log_contains("span_id").await; - router.execute_query(&query).await; - router.assert_log_contains(r#""static_one":"test""#).await; + router.execute_default_query().await; + router.wait_for_log_message("trace_id").await; + router.execute_default_query().await; + router.wait_for_log_message("span_id").await; + router.execute_default_query().await; + router.wait_for_log_message(r#""static_one":"test""#).await; #[cfg(unix)] { - router.execute_query(&query).await; + router.execute_default_query().await; router - .assert_log_contains( + .wait_for_log_message( r#""schema.id":"dd8960ccefda82ca58e8ac0bc266459fd49ee8215fd6b3cc72e7bc3d7f3464b9""#, ) .await; } - router.execute_query(&query).await; + router.execute_default_query().await; router - .assert_log_contains(r#""on_supergraph_response_event":"on_supergraph_event""#) + .wait_for_log_message(r#""on_supergraph_response_event":"on_supergraph_event""#) + .await; + router.execute_default_query().await; + router + .wait_for_log_message(r#""response_status":200"#) .await; - router.execute_query(&query).await; - router.assert_log_contains(r#""response_status":200"#).await; router.graceful_shutdown().await; Ok(()) @@ -58,7 +59,7 @@ async fn test_json_promote_span_attributes() -> Result<(), BoxError> { } let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) + .telemetry(Telemetry::Otlp { endpoint: None }) .config(include_str!("fixtures/json.span_attributes.router.yaml")) .build() .await; @@ -66,24 +67,25 @@ async fn test_json_promote_span_attributes() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - router.execute_query(&query).await; - router.assert_log_contains("trace_id").await; - router.execute_query(&query).await; - router.assert_log_contains("span_id").await; - router.execute_query(&query).await; - router.assert_log_contains(r#""static_one":"test""#).await; - router.execute_query(&query).await; - router.assert_log_contains(r#""response_status":200"#).await; - router.execute_query(&query).await; - router.assert_log_contains(r#""too_big":true"#).await; - router.execute_query(&query).await; - router.assert_log_contains(r#""too_big":"nope""#).await; - router.execute_query(&query).await; + router.execute_default_query().await; + router.wait_for_log_message("trace_id").await; + router.execute_query(Query::default()).await; + router.wait_for_log_message("span_id").await; + router.execute_default_query().await; + router.wait_for_log_message(r#""static_one":"test""#).await; + router.execute_default_query().await; router - .assert_log_contains(r#""graphql.document":"query ExampleQuery {topProducts{name}}""#) + .wait_for_log_message(r#""response_status":200"#) .await; - router.execute_query(&query).await; + router.execute_default_query().await; + router.wait_for_log_message(r#""too_big":true"#).await; + router.execute_default_query().await; + router.wait_for_log_message(r#""too_big":"nope""#).await; + router.execute_default_query().await; + router + .wait_for_log_message(r#""graphql.document":"query ExampleQuery {topProducts{name}}""#) + .await; + router.execute_default_query().await; router.assert_log_not_contains(r#""should_not_log""#).await; router.assert_log_not_contains(r#""another_one""#).await; router.graceful_shutdown().await; @@ -99,7 +101,7 @@ async fn test_json_uuid_format() -> Result<(), BoxError> { } let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) + .telemetry(Telemetry::Otlp { endpoint: None }) .config(include_str!("fixtures/json.uuid.router.yaml")) .build() .await; @@ -107,15 +109,14 @@ async fn test_json_uuid_format() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - router.execute_query(&query).await; - router.assert_log_contains("trace_id").await; - let (trace_id, _) = router.execute_query(&query).await; + router.execute_default_query().await; + router.wait_for_log_message("trace_id").await; + let (trace_id, _) = router.execute_default_query().await; router - .assert_log_contains(&format!("{}", Uuid::from_bytes(trace_id.to_bytes()))) + .wait_for_log_message(&format!("{}", Uuid::from_bytes(trace_id.to_bytes()))) .await; - router.execute_query(&query).await; - router.assert_log_contains("span_id").await; + router.execute_default_query().await; + router.wait_for_log_message("span_id").await; router.graceful_shutdown().await; Ok(()) @@ -129,7 +130,7 @@ async fn test_text_uuid_format() -> Result<(), BoxError> { } let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) + .telemetry(Telemetry::Otlp { endpoint: None }) .config(include_str!("fixtures/text.uuid.router.yaml")) .build() .await; @@ -137,15 +138,14 @@ async fn test_text_uuid_format() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - router.execute_query(&query).await; - router.assert_log_contains("trace_id").await; - let (trace_id, _) = router.execute_query(&query).await; + router.execute_default_query().await; + router.wait_for_log_message("trace_id").await; + let (trace_id, _) = router.execute_default_query().await; router - .assert_log_contains(&format!("{}", Uuid::from_bytes(trace_id.to_bytes()))) + .wait_for_log_message(&format!("{}", Uuid::from_bytes(trace_id.to_bytes()))) .await; - router.execute_query(&query).await; - router.assert_log_contains("span_id").await; + router.execute_default_query().await; + router.wait_for_log_message("span_id").await; router.graceful_shutdown().await; Ok(()) @@ -158,7 +158,7 @@ async fn test_json_sampler_off() -> Result<(), BoxError> { return Ok(()); } let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) + .telemetry(Telemetry::Otlp { endpoint: None }) .config(include_str!("fixtures/json.sampler_off.router.yaml")) .build() .await; @@ -166,19 +166,20 @@ async fn test_json_sampler_off() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - router.execute_query(&query).await; - router.assert_log_contains("trace_id").await; - router.execute_query(&query).await; - router.assert_log_contains("span_id").await; - router.execute_query(&query).await; - router.assert_log_contains(r#""static_one":"test""#).await; - router.execute_query(&query).await; + router.execute_default_query().await; + router.wait_for_log_message("trace_id").await; + router.execute_default_query().await; + router.wait_for_log_message("span_id").await; + router.execute_default_query().await; + router.wait_for_log_message(r#""static_one":"test""#).await; + router.execute_default_query().await; + router + .wait_for_log_message(r#""on_supergraph_response_event":"on_supergraph_event""#) + .await; + router.execute_default_query().await; router - .assert_log_contains(r#""on_supergraph_response_event":"on_supergraph_event""#) + .wait_for_log_message(r#""response_status":200"#) .await; - router.execute_query(&query).await; - router.assert_log_contains(r#""response_status":200"#).await; router.graceful_shutdown().await; Ok(()) @@ -192,7 +193,7 @@ async fn test_text() -> Result<(), BoxError> { } let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) + .telemetry(Telemetry::Otlp { endpoint: None }) .config(include_str!("fixtures/text.router.yaml")) .build() .await; @@ -200,18 +201,17 @@ async fn test_text() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - router.execute_query(&query).await; - router.execute_query(&query).await; - router.assert_log_contains("trace_id").await; - router.execute_query(&query).await; - router.assert_log_contains("span_id").await; + router.execute_query(Query::default()).await; + router.execute_query(Query::default()).await; + router.wait_for_log_message("trace_id").await; + router.execute_query(Query::default()).await; + router.wait_for_log_message("span_id").await; router - .assert_log_contains(r#"on_supergraph_response_event=on_supergraph_event"#) + .wait_for_log_message(r#"on_supergraph_response_event=on_supergraph_event"#) .await; - router.execute_query(&query).await; - router.execute_query(&query).await; - router.assert_log_contains("response_status=200").await; + router.execute_query(Query::default()).await; + router.execute_query(Query::default()).await; + router.wait_for_log_message("response_status=200").await; router.graceful_shutdown().await; Ok(()) } @@ -224,22 +224,20 @@ async fn test_text_sampler_off() -> Result<(), BoxError> { } let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) + .telemetry(Telemetry::Otlp { endpoint: None }) .config(include_str!("fixtures/text.sampler_off.router.yaml")) .build() .await; router.start().await; router.assert_started().await; - - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); - router.execute_query(&query).await; - router.execute_query(&query).await; - router.assert_log_contains("trace_id").await; - router.execute_query(&query).await; - router.assert_log_contains("span_id").await; - router.execute_query(&query).await; - router.assert_log_contains("response_status=200").await; + router.execute_default_query().await; + router.execute_default_query().await; + router.wait_for_log_message("trace_id").await; + router.execute_default_query().await; + router.wait_for_log_message("span_id").await; + router.execute_default_query().await; + router.wait_for_log_message("response_status=200").await; router.graceful_shutdown().await; Ok(()) } diff --git a/apollo-router/tests/integration/telemetry/metrics.rs b/apollo-router/tests/integration/telemetry/metrics.rs index 1ae93f2d4f..a107b471d4 100644 --- a/apollo-router/tests/integration/telemetry/metrics.rs +++ b/apollo-router/tests/integration/telemetry/metrics.rs @@ -2,14 +2,19 @@ use std::time::Duration; use serde_json::json; -use crate::integration::common::graph_os_enabled; use crate::integration::IntegrationTest; +use crate::integration::common::Query; +use crate::integration::common::graph_os_enabled; const PROMETHEUS_CONFIG: &str = include_str!("fixtures/prometheus.router.yaml"); const SUBGRAPH_AUTH_CONFIG: &str = include_str!("fixtures/subgraph_auth.router.yaml"); #[tokio::test(flavor = "multi_thread")] async fn test_metrics_reloading() { + if !graph_os_enabled() { + eprintln!("test skipped"); + return; + } let mut router = IntegrationTest::builder() .config(PROMETHEUS_CONFIG) .build() @@ -39,6 +44,7 @@ async fn test_metrics_reloading() { router.touch_config().await; router.assert_reloaded().await; + router.assert_log_not_contained("OpenTelemetry metric error occurred: Metrics error: metrics provider already shut down"); } let metrics = router @@ -51,34 +57,26 @@ async fn test_metrics_reloading() { check_metrics_contains( &metrics, - r#"apollo_router_cache_hit_count_total{kind="query planner",storage="memory",otel_scope_name="apollo/router"} 4"#, - ); - check_metrics_contains( - &metrics, - r#"apollo_router_cache_miss_count_total{kind="query planner",storage="memory",otel_scope_name="apollo/router"} 2"#, + r#"apollo_router_cache_hit_time_count{kind="query planner",storage="memory",otel_scope_name="apollo/router"} 4"#, ); check_metrics_contains( &metrics, - r#"apollo_router_http_request_duration_seconds_bucket{status="200",otel_scope_name="apollo/router",le="100"}"#, + r#"apollo_router_cache_miss_time_count{kind="query planner",storage="memory",otel_scope_name="apollo/router"} 2"#, ); check_metrics_contains(&metrics, r#"apollo_router_cache_hit_time"#); check_metrics_contains(&metrics, r#"apollo_router_cache_miss_time"#); - check_metrics_contains(&metrics, r#"apollo_router_session_count_total"#); - check_metrics_contains(&metrics, r#"custom_header="test_custom""#); router .assert_metrics_does_not_contain(r#"_total_total{"#) .await; - if std::env::var("TEST_APOLLO_KEY").is_ok() && std::env::var("TEST_APOLLO_GRAPH_REF").is_ok() { - router.assert_metrics_contains_multiple(vec![ - r#"apollo_router_telemetry_studio_reports_total{report_type="metrics",otel_scope_name="apollo/router"} 2"#, - r#"apollo_router_telemetry_studio_reports_total{report_type="traces",otel_scope_name="apollo/router"} 2"#, - r#"apollo_router_uplink_fetch_duration_seconds_count{kind="unchanged",query="License",url="https://uplink.api.apollographql.com/",otel_scope_name="apollo/router"}"#, - r#"apollo_router_uplink_fetch_count_total{query="License",status="success",otel_scope_name="apollo/router"}"# - ], Some(Duration::from_secs(10))) - .await; - } + router.assert_metrics_contains_multiple(vec![ + r#"apollo_router_telemetry_studio_reports_total{report_type="metrics",otel_scope_name="apollo/router"} 2"#, + r#"apollo_router_telemetry_studio_reports_total{report_type="traces",otel_scope_name="apollo/router"} 2"#, + r#"apollo_router_uplink_fetch_duration_seconds_count{kind="unchanged",query="License",url="https://uplink.api.apollographql.com/",otel_scope_name="apollo/router"}"#, + r#"apollo_router_uplink_fetch_count_total{query="License",status="success",otel_scope_name="apollo/router"}"# + ], Some(Duration::from_secs(10))) + .await; } #[track_caller] @@ -91,6 +89,10 @@ fn check_metrics_contains(metrics: &str, text: &str) { #[tokio::test(flavor = "multi_thread")] async fn test_subgraph_auth_metrics() { + if !graph_os_enabled() { + eprintln!("test skipped"); + return; + } let mut router = IntegrationTest::builder() .config(SUBGRAPH_AUTH_CONFIG) .build() @@ -106,9 +108,7 @@ async fn test_subgraph_auth_metrics() { router.update_config(PROMETHEUS_CONFIG).await; router.assert_reloaded().await; // This one will not be signed, counters shouldn't increment. - router - .execute_query(&json! {{ "query": "query { me { name } }"}}) - .await; + router.execute_query(Query::default()).await; // Get Prometheus metrics. let metrics_response = router.get_metrics_response().await.unwrap(); @@ -129,6 +129,10 @@ async fn test_subgraph_auth_metrics() { #[tokio::test(flavor = "multi_thread")] async fn test_metrics_bad_query() { + if !graph_os_enabled() { + eprintln!("test skipped"); + return; + } let mut router = IntegrationTest::builder() .config(SUBGRAPH_AUTH_CONFIG) .build() @@ -137,12 +141,18 @@ async fn test_metrics_bad_query() { router.start().await; router.assert_started().await; // This query won't make it to the supergraph service - router.execute_bad_query().await; + router + .execute_query(Query::default().with_bad_query()) + .await; router.assert_metrics_contains(r#"apollo_router_operations_total{http_response_status_code="400",otel_scope_name="apollo/router"} 1"#, None).await; } #[tokio::test(flavor = "multi_thread")] async fn test_bad_queries() { + if !graph_os_enabled() { + eprintln!("test skipped"); + return; + } let mut router = IntegrationTest::builder() .config(PROMETHEUS_CONFIG) .build() @@ -153,31 +163,42 @@ async fn test_bad_queries() { router.execute_default_query().await; router .assert_metrics_contains( - r#"apollo_router_http_requests_total{status="200",otel_scope_name="apollo/router"}"#, + r#"http_server_request_duration_seconds_count{http_request_method="POST",status="200",otel_scope_name="apollo/router"} 1"#, None, ) .await; - router.execute_bad_content_type().await; + router + .execute_query( + Query::builder() + .header("apollo-require-preflight", "true") + .build() + .with_bad_content_type(), + ) + .await; router .assert_metrics_contains( - r#"apollo_router_http_requests_total{error="'content-type' header must be one of: \"application/json\" or \"application/graphql-response+json\"",status="415",otel_scope_name="apollo/router"}"#, + r#"http_server_request_duration_seconds_count{error_type="Unsupported Media Type",http_request_method="POST",status="415",otel_scope_name="apollo/router"} 1"#, None, ) .await; - router.execute_bad_query().await; + router + .execute_query(Query::default().with_bad_query()) + .await; router .assert_metrics_contains( - r#"apollo_router_http_requests_total{error="Must provide query string",status="400",otel_scope_name="apollo/router"}"#, + r#"http_server_request_duration_seconds_count{error_type="Bad Request",http_request_method="POST",status="400",otel_scope_name="apollo/router"} 1"#, None, ) .await; - router.execute_huge_query().await; + router + .execute_query(Query::default().with_huge_query()) + .await; router .assert_metrics_contains( - r#"apollo_router_http_requests_total{error="Request body payload too large",status="413",otel_scope_name="apollo/router"} 1"#, + r#"http_server_request_duration_seconds_count{error_type="Payload Too Large",http_request_method="POST",status="413",otel_scope_name="apollo/router"} 1"#, None, ) .await; @@ -201,28 +222,72 @@ async fn test_graphql_metrics() { router.start().await; router.assert_started().await; router.execute_default_query().await; + router.print_logs(); router - .assert_metrics_contains(r#"graphql_field_list_length_sum{graphql_field_name="topProducts",graphql_field_type="Product",graphql_type_name="Query",otel_scope_name="apollo/router"} 3"#, None) - .await; + .assert_log_not_contains("this is a bug and should not happen") + .await; router - .assert_metrics_contains(r#"graphql_field_list_length_bucket{graphql_field_name="topProducts",graphql_field_type="Product",graphql_type_name="Query",otel_scope_name="apollo/router",le="5"} 1"#, None) - .await; + .assert_metrics_contains( + r#"my_custom_router_instrument_total{my_response_body="{\"data\":{\"topProducts\":[{\"name\":\"Table\"},{\"name\":\"Couch\"},{\"name\":\"Chair\"}]}}",otel_scope_name="apollo/router"} 1"#, + None, + ) + .await; router - .assert_metrics_contains(r#"graphql_field_execution_total{graphql_field_name="name",graphql_field_type="String",graphql_type_name="Product",otel_scope_name="apollo/router"} 3"#, None) - .await; + .assert_metrics_contains( + r#"oplimits_aliases_sum{otel_scope_name="apollo/router"} 0"#, + None, + ) + .await; router - .assert_metrics_contains(r#"graphql_field_execution_total{graphql_field_name="topProducts",graphql_field_type="Product",graphql_type_name="Query",otel_scope_name="apollo/router"} 1"#, None) - .await; + .assert_metrics_contains( + r#"oplimits_root_fields_sum{otel_scope_name="apollo/router"} 1"#, + None, + ) + .await; router - .assert_metrics_contains(r#"custom_counter_total{graphql_field_name="name",graphql_field_type="String",graphql_type_name="Product",otel_scope_name="apollo/router"} 3"#, None) - .await; + .assert_metrics_contains( + r#"oplimits_depth_sum{otel_scope_name="apollo/router"} 2"#, + None, + ) + .await; router - .assert_metrics_contains(r#"custom_histogram_sum{graphql_field_name="topProducts",graphql_field_type="Product",graphql_type_name="Query",otel_scope_name="apollo/router"} 3"#, None) - .await; + .assert_metrics_contains(r#"graphql_field_list_length_sum{graphql_field_name="topProducts",graphql_field_type="Product",graphql_type_name="Query",otel_scope_name="apollo/router"} 3"#, None) + .await; + router + .assert_metrics_contains(r#"graphql_field_list_length_bucket{graphql_field_name="topProducts",graphql_field_type="Product",graphql_type_name="Query",otel_scope_name="apollo/router",le="5"} 1"#, None) + .await; + router + .assert_metrics_contains(r#"graphql_field_execution_total{graphql_field_name="name",graphql_field_type="String",graphql_type_name="Product",otel_scope_name="apollo/router"} 3"#, None) + .await; + router + .assert_metrics_contains(r#"graphql_field_execution_total{graphql_field_name="topProducts",graphql_field_type="Product",graphql_type_name="Query",otel_scope_name="apollo/router"} 1"#, None) + .await; + router + .assert_metrics_contains(r#"custom_counter_total{graphql_field_name="name",graphql_field_type="String",graphql_type_name="Product",otel_scope_name="apollo/router"} 3"#, None) + .await; + router + .assert_metrics_contains(r#"custom_histogram_sum{graphql_field_name="topProducts",graphql_field_type="Product",graphql_type_name="Query",otel_scope_name="apollo/router"} 3"#, None) + .await; + router + .assert_metrics_contains(r#"apollo_router_compute_jobs_duration_seconds_count{job_outcome="executed_ok",job_type="query_parsing",otel_scope_name="apollo/router"} 1"#, None) + .await; + router + .assert_metrics_contains(r#"apollo_router_compute_jobs_duration_seconds_count{job_outcome="executed_ok",job_type="query_planning",otel_scope_name="apollo/router"} 1"#, None) + .await; + router + .assert_metrics_contains(r#"apollo_router_compute_jobs_queue_wait_duration_seconds_count{job_type="query_parsing",otel_scope_name="apollo/router"} 1"#, None) + .await; + router + .assert_metrics_contains(r#"apollo_router_compute_jobs_execution_duration_seconds_count{job_type="query_planning",otel_scope_name="apollo/router"} 1"#, None) + .await; } #[tokio::test(flavor = "multi_thread")] async fn test_gauges_on_reload() { + if !graph_os_enabled() { + eprintln!("test skipped"); + return; + } let mut router = IntegrationTest::builder() .config(include_str!("fixtures/no-telemetry.router.yaml")) .build() @@ -238,14 +303,12 @@ async fn test_gauges_on_reload() { router.execute_default_query().await; // Introspection query - router - .execute_query(&json!({"query":"{__schema {types {name}}}","variables":{}})) - .await; + router.execute_query(Query::introspection()).await; // Persisted query router .execute_query( - &json!({"query": "{__typename}", "variables":{}, "extensions": {"persistedQuery":{"version" : 1, "sha256Hash" : "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"}}}) + Query::builder().body(json!({"query": "{__typename}", "variables":{}, "extensions": {"persistedQuery":{"version" : 1, "sha256Hash" : "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"}}})).build() ) .await; @@ -254,37 +317,37 @@ async fn test_gauges_on_reload() { .await; router .assert_metrics_contains( - r#"apollo_router_query_planning_queued{otel_scope_name="apollo/router"} "#, + r#"apollo_router_cache_size{kind="APQ",type="memory",otel_scope_name="apollo/router"} 1"#, None, ) .await; router .assert_metrics_contains( - r#"apollo_router_v8_heap_total_bytes{otel_scope_name="apollo/router"} "#, + r#"apollo_router_cache_size{kind="query planner",type="memory",otel_scope_name="apollo/router"} 1"#, None, ) .await; router .assert_metrics_contains( - r#"apollo_router_v8_heap_total_bytes{otel_scope_name="apollo/router"} "#, + r#"apollo_router_cache_size{kind="introspection",type="memory",otel_scope_name="apollo/router"} 1"#, None, ) .await; + router - .assert_metrics_contains( - r#"apollo_router_cache_size{kind="APQ",type="memory",otel_scope_name="apollo/router"} 1"#, - None, - ) + .assert_metrics_contains(r#"apollo_router_pipelines{config_hash="",schema_id="",otel_scope_name="apollo/router"} 1"#, None) .await; + router .assert_metrics_contains( - r#"apollo_router_cache_size{kind="query planner",type="memory",otel_scope_name="apollo/router"} 1"#, + r#"apollo_router_compute_jobs_queued{otel_scope_name="apollo/router"} 0"#, None, ) .await; + router .assert_metrics_contains( - r#"apollo_router_cache_size{kind="introspection",type="memory",otel_scope_name="apollo/router"} 1"#, + r#"apollo_router_compute_jobs_active_jobs{job_type="query_parsing",otel_scope_name="apollo/router"} 0"#, None, ) .await; diff --git a/apollo-router/tests/integration/telemetry/mod.rs b/apollo-router/tests/integration/telemetry/mod.rs index 8df0a1d753..9e7d3c17e8 100644 --- a/apollo-router/tests/integration/telemetry/mod.rs +++ b/apollo-router/tests/integration/telemetry/mod.rs @@ -1,10 +1,73 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +use opentelemetry::trace::TraceId; + +mod apollo_otel_metrics; #[cfg(any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")))] mod datadog; -#[cfg(any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")))] -mod jaeger; +mod events; mod logging; mod metrics; mod otlp; mod propagation; +mod verifier; #[cfg(any(not(feature = "ci"), all(target_arch = "x86_64", target_os = "linux")))] mod zipkin; + +struct TraceSpec { + operation_name: Option, + version: Option, + services: Vec<&'static str>, + span_names: HashSet<&'static str>, + measured_spans: HashSet<&'static str>, + unmeasured_spans: HashSet<&'static str>, + priority_sampled: Option<&'static str>, + subgraph_sampled: Option, + trace_id: Option, + resources: HashMap<&'static str, &'static str>, + attributes: HashMap<&'static str, &'static str>, +} + +#[buildstructor::buildstructor] +impl TraceSpec { + #[builder] + pub fn new( + operation_name: Option, + version: Option, + services: Vec<&'static str>, + span_names: HashSet<&'static str>, + measured_spans: HashSet<&'static str>, + unmeasured_spans: HashSet<&'static str>, + priority_sampled: Option<&'static str>, + subgraph_sampled: Option, + trace_id: Option, + resources: HashMap<&'static str, &'static str>, + attributes: HashMap<&'static str, &'static str>, + ) -> Self { + Self { + operation_name, + version, + services, + span_names, + measured_spans, + unmeasured_spans, + priority_sampled, + subgraph_sampled, + trace_id, + resources, + attributes, + } + } +} + +#[allow(dead_code)] +pub trait DatadogId { + fn to_datadog(&self) -> u64; +} +impl DatadogId for TraceId { + fn to_datadog(&self) -> u64 { + let bytes = &self.to_bytes()[std::mem::size_of::()..std::mem::size_of::()]; + u64::from_be_bytes(bytes.try_into().unwrap()) + } +} diff --git a/apollo-router/tests/integration/telemetry/otlp.rs b/apollo-router/tests/integration/telemetry/otlp.rs index 7eae04f567..66e2c5494d 100644 --- a/apollo-router/tests/integration/telemetry/otlp.rs +++ b/apollo-router/tests/integration/telemetry/otlp.rs @@ -1,54 +1,71 @@ extern crate core; +use std::collections::HashMap; use std::collections::HashSet; +use std::ops::Deref; use std::time::Duration; use anyhow::anyhow; -use itertools::Itertools; -use opentelemetry_api::trace::TraceId; +use opentelemetry::trace::TraceId; use opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceResponse; use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceResponse; use prost::Message; -use serde_json::json; use serde_json::Value; use tower::BoxError; -use wiremock::matchers::method; -use wiremock::matchers::path; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::Times; +use wiremock::matchers::method; +use wiremock::matchers::path; -use crate::integration::common::Telemetry; use crate::integration::IntegrationTest; use crate::integration::ValueExt; +use crate::integration::common::Query; +use crate::integration::common::Telemetry; +use crate::integration::common::graph_os_enabled; +use crate::integration::telemetry::DatadogId; +use crate::integration::telemetry::TraceSpec; +use crate::integration::telemetry::verifier::Verifier; #[tokio::test(flavor = "multi_thread")] -async fn test_basic() -> Result<(), BoxError> { - let mock_server = wiremock::MockServer::start().await; - Mock::given(method("POST")) - .and(path("/traces")) - .respond_with(ResponseTemplate::new(200).set_body_raw( - ExportTraceServiceResponse::default().encode_to_vec(), - "application/x-protobuf", - )) - .expect(1..) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/metrics")) - .respond_with(ResponseTemplate::new(200).set_body_raw( - ExportMetricsServiceResponse::default().encode_to_vec(), - "application/x-protobuf", - )) - .expect(1..) - .mount(&mock_server) +async fn test_trace_error() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let mock_server = mock_otlp_server_delayed().await; + let config = include_str!("fixtures/otlp_invalid_endpoint.router.yaml") + .replace("", &mock_server.uri()); + + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), + }) + .config(config) + .build() .await; + router.start().await; + router.assert_started().await; + router.assert_log_contained("OpenTelemetry trace error occurred: cannot send message to batch processor 'otlp-tracing' as the channel is full"); + router.assert_metrics_contains(r#"apollo_router_telemetry_batch_processor_errors_total{error="channel full",name="otlp-tracing",otel_scope_name="apollo/router"}"#, None).await; + router.graceful_shutdown().await; + + drop(mock_server); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_basic() -> Result<(), BoxError> { + if !graph_os_enabled() { + panic!("Error: test skipped because GraphOS is not enabled"); + } + let mock_server = mock_otlp_server(1..).await; let config = include_str!("fixtures/otlp.router.yaml") .replace("", &mock_server.uri()); let mut router = IntegrationTest::builder() .telemetry(Telemetry::Otlp { - endpoint: format!("{}/traces", mock_server.uri()), + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), }) .config(&config) .build() @@ -57,170 +74,1022 @@ async fn test_basic() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); for _ in 0..2 { - let (id, result) = router.execute_query(&query).await; - assert!(!result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .is_empty()); - validate_telemetry( + TraceSpec::builder() + .operation_name("ExampleQuery") + .services(["client", "router", "subgraph"].into()) + .span_names( + [ + "query_planning", + "client_request", + "ExampleQuery__products__0", + "fetch", + "execution", + "query ExampleQuery", + "subgraph server", + "parse_query", + "http_request", + ] + .into(), + ) + .subgraph_sampled(true) + .build() + .validate_otlp_trace(&mut router, &mock_server, Query::default()) + .await?; + TraceSpec::builder() + .service("router") + .build() + .validate_otlp_metrics(&mock_server) + .await?; + router.touch_config().await; + router.assert_reloaded().await; + router.assert_log_not_contained("OpenTelemetry metric error occurred: Metrics error: metrics provider already shut down"); + } + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_resources() -> Result<(), BoxError> { + if !graph_os_enabled() { + panic!("Error: test skipped because GraphOS is not enabled"); + } + let mock_server = mock_otlp_server(1..).await; + let config = include_str!("fixtures/otlp.router.yaml") + .replace("", &mock_server.uri()); + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), + }) + .config(&config) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .resource("env", "local1") + .resource("service.version", "router_version_override") + .resource("service.name", "router") + .build() + .validate_otlp_trace(&mut router, &mock_server, Query::default()) + .await?; + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_otlp_request_with_datadog_propagator() -> Result<(), BoxError> { + if !graph_os_enabled() { + panic!("Error: test skipped because GraphOS is not enabled"); + } + let mock_server = mock_otlp_server(1..).await; + let config = include_str!("fixtures/otlp_datadog_propagation.router.yaml") + .replace("", &mock_server.uri()); + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), + }) + .extra_propagator(Telemetry::Datadog) + .config(&config) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_otlp_trace(&mut router, &mock_server, Query::default()) + .await?; + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_otlp_request_with_datadog_propagator_no_agent() -> Result<(), BoxError> { + if !graph_os_enabled() { + panic!("Error: test skipped because GraphOS is not enabled"); + } + let mock_server = mock_otlp_server(1..).await; + let config = include_str!("fixtures/otlp_datadog_propagation_no_agent.router.yaml") + .replace("", &mock_server.uri()); + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), + }) + .config(&config) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .subgraph_sampled(true) + .build() + .validate_otlp_trace( + &mut router, &mock_server, - id, - &query, - Some("ExampleQuery"), - &["client", "router", "subgraph"], - false, + Query::builder().traced(true).build(), ) .await?; - router.touch_config().await; - router.assert_reloaded().await; + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_otlp_request_with_zipkin_trace_context_propagator_with_datadog() +-> Result<(), BoxError> { + if !graph_os_enabled() { + panic!("Error: test skipped because GraphOS is not enabled"); + } + let mock_server = mock_otlp_server(1..).await; + let config = include_str!("fixtures/otlp_datadog_request_with_zipkin_propagator.router.yaml") + .replace("", &mock_server.uri()); + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), + }) + .extra_propagator(Telemetry::Datadog) + .config(&config) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(true).build(), + ) + .await?; + // ---------------------- zipkin propagator with unsampled trace + // Testing for an unsampled trace, so it should be sent to the otlp exporter with sampling priority set 0 + // But it shouldn't send the trace to subgraph as the trace is originally not sampled, the main goal is to measure it at the DD agent level + TraceSpec::builder() + .services(["router"].into()) + .priority_sampled("0") + .subgraph_sampled(false) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder() + .traced(false) + .header("X-B3-TraceId", "80f198ee56343ba864fe8b2a57d3eff7") + .header("X-B3-ParentSpanId", "05e3ac9a4f6e3b90") + .header("X-B3-SpanId", "e457b5a2e4d86bd1") + .header("X-B3-Sampled", "0") + .build(), + ) + .await?; + // ---------------------- trace context propagation + // Testing for a trace containing the right tracestate with m and psr for DD and a sampled trace, so it should be sent to the otlp exporter with sampling priority set to 1 + // And it should also send the trace to subgraph as the trace is sampled + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder() + .traced(true) + .header( + "traceparent", + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + ) + .header("tracestate", "m=1,psr=1") + .build(), + ) + .await?; + // ---------------------- + // Testing for a trace containing the right tracestate with m and psr for DD and an unsampled trace, so it should be sent to the otlp exporter with sampling priority set to 0 + // But it shouldn't send the trace to subgraph as the trace is originally not sampled, the main goal is to measure it at the DD agent level + TraceSpec::builder() + .services(["router"].into()) + .priority_sampled("0") + .subgraph_sampled(false) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder() + .traced(false) + .header( + "traceparent", + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-02", + ) + .header("tracestate", "m=1,psr=0") + .build(), + ) + .await?; + // ---------------------- + // Testing for a trace containing a tracestate m and psr with psr set to 1 for DD and an unsampled trace, so it should be sent to the otlp exporter with sampling priority set to 1 + // It should not send the trace to the subgraph as we didn't use the datadog propagator and therefore the trace will remain unsampled. + TraceSpec::builder() + .services(["router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder() + .traced(false) + .header( + "traceparent", + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03", + ) + .header("tracestate", "m=1,psr=1") + .build(), + ) + .await?; + + // Be careful if you add the same kind of test crafting your own trace id, make sure to increment the previous trace id by 1 if not you'll receive all the previous spans tested with the same trace id before + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_untraced_request_no_sample_datadog_agent() -> Result<(), BoxError> { + if !graph_os_enabled() { + panic!("Error: test skipped because GraphOS is not enabled"); } + let mock_server = mock_otlp_server(1..).await; + let config = include_str!("fixtures/otlp_datadog_agent_no_sample.router.yaml") + .replace("", &mock_server.uri()); + let mut router = IntegrationTest::builder() + .config(&config) + .telemetry(Telemetry::Otlp { + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), + }) + .extra_propagator(Telemetry::Datadog) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .services(["router"].into()) + .priority_sampled("0") + .subgraph_sampled(false) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(false).build(), + ) + .await?; router.graceful_shutdown().await; Ok(()) } -async fn validate_telemetry( - mock_server: &MockServer, - _id: TraceId, - query: &Value, - operation_name: Option<&str>, - services: &[&'static str], - custom_span_instrumentation: bool, -) -> Result<(), BoxError> { - for _ in 0..10 { - let trace_valid = find_valid_trace( - mock_server, - query, - operation_name, - services, - custom_span_instrumentation, +#[tokio::test(flavor = "multi_thread")] +async fn test_untraced_request_sample_datadog_agent() -> Result<(), BoxError> { + if !graph_os_enabled() { + panic!("Error: test skipped because GraphOS is not enabled"); + } + let mock_server = mock_otlp_server(1..).await; + let config = include_str!("fixtures/otlp_datadog_agent_sample.router.yaml") + .replace("", &mock_server.uri()); + let mut router = IntegrationTest::builder() + .config(&config) + .telemetry(Telemetry::Otlp { + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), + }) + .extra_propagator(Telemetry::Datadog) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .services(["router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(false).build(), ) + .await?; + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_untraced_request_sample_datadog_agent_unsampled() -> Result<(), BoxError> { + if !graph_os_enabled() { + panic!("Error: test skipped because GraphOS is not enabled"); + } + let mock_server = mock_otlp_server(1..).await; + let config = include_str!("fixtures/otlp_datadog_agent_sample_no_sample.router.yaml") + .replace("", &mock_server.uri()); + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), + }) + .extra_propagator(Telemetry::Datadog) + .config(&config) + .build() .await; - let metrics_valid = find_valid_metrics(mock_server, query, operation_name, services).await; + router.start().await; + router.assert_started().await; - if metrics_valid.is_ok() && trace_valid.is_ok() { - return Ok(()); - } + TraceSpec::builder() + .services(["router"].into()) + .priority_sampled("0") + .subgraph_sampled(false) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(false).build(), + ) + .await?; + router.graceful_shutdown().await; + Ok(()) +} - tokio::time::sleep(Duration::from_millis(100)).await; +#[tokio::test(flavor = "multi_thread")] +async fn test_priority_sampling_propagated() -> Result<(), BoxError> { + if !graph_os_enabled() { + panic!("Error: test skipped because GraphOS is not enabled"); } - find_valid_trace( - mock_server, - query, - operation_name, - services, - custom_span_instrumentation, + let mock_server = mock_otlp_server(1..).await; + let config = include_str!("fixtures/otlp_datadog_propagation.router.yaml") + .replace("", &mock_server.uri()); + let mut router = IntegrationTest::builder() + // We're using datadog propagation as this is what we are trying to test. + .telemetry(Telemetry::Otlp { + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), + }) + .extra_propagator(Telemetry::Datadog) + .config(config) + .build() + .await; + + router.start().await; + router.assert_started().await; + + // Parent based sampling. psr MUST be populated with the value that we pass in. + TraceSpec::builder() + .services(["client", "router"].into()) + .priority_sampled("-1") + .subgraph_sampled(false) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(true).psr("-1").build(), + ) + .await?; + TraceSpec::builder() + .services(["client", "router"].into()) + .priority_sampled("0") + .subgraph_sampled(false) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(true).psr("0").build(), + ) + .await?; + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(true).psr("1").build(), + ) + .await?; + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("2") + .subgraph_sampled(true) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(true).psr("2").build(), + ) + .await?; + + // No psr was passed in the router is free to set it. This will be 1 as we are going to sample here. + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(true).build(), + ) + .await?; + + router.graceful_shutdown().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_priority_sampling_parent_sampler_very_small_no_parent_no_agent_sampling() +-> Result<(), BoxError> { + // Note that there is a very small chance this test will fail. We are trying to test a non-zero sampler. + let mock_server = mock_otlp_server(0..).await; + + if !graph_os_enabled() { + return Ok(()); + } + let config = include_str!( + "fixtures/otlp_parent_sampler_very_small_no_parent_no_agent_sampling.router.yaml" ) - .await?; - find_valid_metrics(mock_server, query, operation_name, services).await?; + .replace("", &mock_server.uri()); + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), + }) + .config(config) + .build() + .await; + + router.start().await; + router.assert_started().await; + + // The router should not respect upstream but also almost never sample if left to its own devices. + + TraceSpec::builder() + .services(["client"].into()) + .subgraph_sampled(false) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(true).build(), + ) + .await?; + + TraceSpec::builder() + .services(["client"].into()) + .subgraph_sampled(false) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().psr("-1").traced(true).build(), + ) + .await?; + TraceSpec::builder() + .services(["client"].into()) + .subgraph_sampled(false) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().psr("0").traced(true).build(), + ) + .await?; + + TraceSpec::builder() + .services(["client"].into()) + .subgraph_sampled(false) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().psr("1").traced(true).build(), + ) + .await?; + + TraceSpec::builder() + .services(["client"].into()) + .subgraph_sampled(false) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().psr("2").traced(true).build(), + ) + .await?; + + TraceSpec::builder() + .services([].into()) + .subgraph_sampled(false) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(false).build(), + ) + .await?; + + router.graceful_shutdown().await; Ok(()) } -async fn find_valid_trace( - mock_server: &MockServer, - _query: &Value, - _operation_name: Option<&str>, - services: &[&'static str], - _custom_span_instrumentation: bool, -) -> Result<(), BoxError> { - let requests = mock_server - .received_requests() - .await - .expect("Could not get otlp requests"); - - // A valid trace has: - // * A valid service name - // * All three services - // * The correct spans - // * All spans are parented - // * Required attributes of 'router' span has been set - let traces: Vec<_>= requests - .iter() - .filter_map(|r| { - if r.url.path().ends_with("/traces") { +#[tokio::test(flavor = "multi_thread")] +async fn test_priority_sampling_no_parent_propagated() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let mock_server = mock_otlp_server(1..).await; + let config = include_str!("fixtures/otlp_datadog_propagation_no_parent_sampler.router.yaml") + .replace("", &mock_server.uri()); + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), + }) + .extra_propagator(Telemetry::Datadog) + .config(config) + .build() + .await; + + router.start().await; + router.assert_started().await; + + // The router will ignore the upstream PSR as parent based sampling is disabled. + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(true).psr("-1").build(), + ) + .await?; + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(true).psr("0").build(), + ) + .await?; + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(true).psr("1").build(), + ) + .await?; + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(true).psr("2").build(), + ) + .await?; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .priority_sampled("1") + .subgraph_sampled(true) + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder().traced(true).build(), + ) + .await?; + + router.graceful_shutdown().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_attributes() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let mock_server = mock_otlp_server(1..).await; + let config = include_str!("fixtures/otlp.router.yaml") + .replace("", &mock_server.uri()); + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), + }) + .config(config) + .build() + .await; + + router.start().await; + router.assert_started().await; + + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .attribute("client.name", "foobar") + .build() + .validate_otlp_trace( + &mut router, + &mock_server, + Query::builder() + .traced(true) + .header("apollographql-client-name", "foobar") + .build(), + ) + .await?; + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_plugin_overridden_client_name_is_included_in_telemetry() -> Result<(), BoxError> { + if !graph_os_enabled() { + return Ok(()); + } + let mock_server = mock_otlp_server(1..).await; + let config = include_str!("fixtures/otlp_override_client_name.router.yaml") + .replace("", &mock_server.uri()); + let mut router = IntegrationTest::builder() + .telemetry(Telemetry::Otlp { + endpoint: Some(format!("{}/v1/traces", mock_server.uri())), + }) + .config(config) + .build() + .await; + + router.start().await; + router.assert_started().await; + + // rhai script overrides client.name - no matter what client name we pass via headers, it should + // end up equalling the value set in the script (`foo`) + for header_value in [None, Some(""), Some("foo"), Some("bar")] { + let mut headers = HashMap::default(); + if let Some(value) = header_value { + headers.insert("apollographql-client-name".to_string(), value.to_string()); + } + + let query = Query::builder().traced(true).headers(headers).build(); + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .attribute("client.name", "foo") + .build() + .validate_otlp_trace(&mut router, &mock_server, query) + .await + .unwrap_or_else(|_| panic!("Failed with header value {header_value:?}")); + } + + router.graceful_shutdown().await; + Ok(()) +} + +struct OtlpTraceSpec<'a> { + trace_spec: TraceSpec, + mock_server: &'a MockServer, +} +impl Deref for OtlpTraceSpec<'_> { + type Target = TraceSpec; + + fn deref(&self) -> &Self::Target { + &self.trace_spec + } +} + +impl Verifier for OtlpTraceSpec<'_> { + fn spec(&self) -> &TraceSpec { + &self.trace_spec + } + + fn measured_span(&self, trace: &Value, name: &str) -> Result { + let binding1 = trace.select_path(&format!( + "$..[?(@.meta.['otel.original_name'] == '{name}')].metrics.['_dd.measured']" + ))?; + let binding2 = trace.select_path(&format!( + "$..[?(@.name == '{name}')].metrics.['_dd.measured']" + ))?; + Ok(binding1 + .first() + .or(binding2.first()) + .and_then(|v| v.as_f64()) + .map(|v| v == 1.0) + .unwrap_or_default()) + } + + async fn find_valid_metrics(&self) -> Result<(), BoxError> { + let requests = self + .mock_server + .received_requests() + .await + .expect("Could not get otlp requests"); + if let Some(metrics) = requests.iter().find(|r| r.url.path().ends_with("/metrics")) { + let metrics = opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceRequest::decode(bytes::Bytes::copy_from_slice(&metrics.body))?; + let json_metrics = serde_json::to_value(metrics)?; + // For now just validate service name. + self.verify_services(&json_metrics)?; + + Ok(()) + } else { + Err(anyhow!("No metrics received").into()) + } + } + + async fn get_trace(&self, trace_id: TraceId) -> Result { + let requests = self.mock_server.received_requests().await; + let trace = Value::Array(requests.unwrap_or_default().iter().filter(|r| r.url.path().ends_with("/traces")) + .filter_map(|r| { match opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest::decode( bytes::Bytes::copy_from_slice(&r.body), ) { Ok(trace) => { - match serde_json::to_value(trace) { - Ok(trace) => { Some(Ok(trace)) } - Err(e) => { - Some(Err(BoxError::from(format!("failed to decode trace: {}", e)))) - } - } + serde_json::to_value(trace).ok() } - Err(e) => { - Some(Err(BoxError::from(format!("failed to decode trace: {}", e)))) + Err(_) => { + None } } + }).filter(|t| { + let datadog_trace_id = TraceId::from_u128(trace_id.to_datadog() as u128); + let trace_found1 = !t.select_path(&format!("$..[?(@.traceId == '{trace_id}')]")).unwrap_or_default().is_empty(); + let trace_found2 = !t.select_path(&format!("$..[?(@.traceId == '{datadog_trace_id}')]")).unwrap_or_default().is_empty(); + trace_found1 | trace_found2 + }).collect()); + Ok(trace) + } + + fn verify_version(&self, trace: &Value) -> Result<(), BoxError> { + if let Some(expected_version) = &self.version { + let binding = trace.select_path("$..version")?; + let version = binding.first(); + assert_eq!( + version + .expect("version expected") + .as_str() + .expect("version must be a string"), + expected_version + ); + } + Ok(()) + } + + fn verify_services(&self, trace: &Value) -> Result<(), axum::BoxError> { + let actual_services: HashSet = trace + .select_path("$..resource.attributes..[?(@.key == 'service.name')].value.stringValue")? + .into_iter() + .filter_map(|service| service.as_string()) + .collect(); + tracing::debug!("found services {:?}", actual_services); + let expected_services = self + .services + .iter() + .map(|s| s.to_string()) + .collect::>(); + if actual_services != expected_services { + return Err(BoxError::from(format!( + "unexpected traces, got {actual_services:?} expected {expected_services:?}" + ))); + } + Ok(()) + } + + fn verify_spans_present(&self, trace: &Value) -> Result<(), BoxError> { + let operation_names: HashSet = trace + .select_path("$..spans..name")? + .into_iter() + .filter_map(|span_name| span_name.as_string()) + .collect(); + let mut span_names: HashSet<&str> = self.span_names.clone(); + if self.services.contains(&"client") { + span_names.insert("client_request"); + } + tracing::debug!("found spans {:?}", operation_names); + let missing_operation_names: Vec<_> = span_names + .iter() + .filter(|o| !operation_names.contains(**o)) + .collect(); + if !missing_operation_names.is_empty() { + return Err(BoxError::from(format!( + "spans did not match, got {operation_names:?}, missing {missing_operation_names:?}" + ))); + } + Ok(()) + } + + fn validate_span_kind(&self, trace: &Value, name: &str, kind: &str) -> Result<(), BoxError> { + let kind = match kind { + "internal" => 1, + "client" => 3, + "server" => 2, + _ => panic!("unknown kind"), + }; + let binding1 = trace.select_path(&format!( + "$..spans..[?(@.kind == {kind})]..[?(@.key == 'otel.original_name')].value..[?(@ == '{name}')]" + ))?; + let binding2 = trace.select_path(&format!( + "$..spans..[?(@.kind == {kind} && @.name == '{name}')]" + ))?; + let binding = binding1.first().or(binding2.first()); + + if binding.is_none() { + return Err(BoxError::from(format!( + "span.kind missing or incorrect {name}, {kind}" + ))); + } + Ok(()) + } + + fn verify_operation_name(&self, trace: &Value) -> Result<(), BoxError> { + if let Some(expected_operation_name) = &self.operation_name { + let binding = + trace.select_path("$..[?(@.name == 'supergraph')]..[?(@.key == 'graphql.operation.name')].value.stringValue")?; + let operation_name = binding.first(); + assert_eq!( + operation_name + .expect("graphql.operation.name expected") + .as_str() + .expect("graphql.operation.name must be a string"), + expected_operation_name + ); + } + Ok(()) + } + + fn verify_priority_sampled(&self, trace: &Value) -> Result<(), BoxError> { + if let Some(psr) = self.priority_sampled { + let binding = trace.select_path( + "$..[?(@.name == 'execution')]..[?(@.key == 'sampling.priority')].value.intValue", + )?; + if binding.is_empty() { + return Err(BoxError::from("missing sampling priority")); } - else { - None + for sampling_priority in binding { + assert_eq!(sampling_priority.as_str().expect("psr not a string"), psr); } - }) - .try_collect()?; - if !traces.is_empty() { - let json_trace = serde_json::Value::Array(traces); - verify_trace_participants(&json_trace, services)?; + } else { + assert!(trace.select_path("$..[?(@.name == 'execution')]..[?(@.key == 'sampling.priority')].value.intValue")?.is_empty()) + } + Ok(()) + } + fn verify_resources(&self, trace: &Value) -> Result<(), BoxError> { + if !self.resources.is_empty() { + let resources = trace.select_path("$..resource.attributes")?; + // Find the attributes for the router service + let router_resources = resources + .iter() + .filter(|r| { + !r.select_path("$..[?(@.stringValue == 'router')]") + .unwrap() + .is_empty() + }) + .collect::>(); + // Let's map this to a map of key value pairs + let router_resources = router_resources + .iter() + .flat_map(|v| v.as_array().expect("array required")) + .map(|v| { + let entry = v.as_object().expect("must be an object"); + ( + entry + .get("key") + .expect("must have key") + .as_string() + .expect("key must be a string"), + entry + .get("value") + .expect("must have value") + .as_object() + .expect("value must be an object") + .get("stringValue") + .expect("value must be a string") + .as_string() + .expect("value must be a string"), + ) + }) + .collect::>(); + + for (key, value) in &self.resources { + if let Some(actual_value) = router_resources.get(*key) { + assert_eq!(actual_value, value); + } else { + return Err(BoxError::from(format!("missing resource key: {}", *key))); + } + } + } + Ok(()) + } + + fn verify_span_attributes(&self, trace: &Value) -> Result<(), BoxError> { + for (key, value) in self.attributes.iter() { + // extracts a list of span attribute values with the provided key + let binding = trace.select_path(&format!( + "$..spans..attributes..[?(@.key == '{key}')].value.*" + ))?; + let matches_value = binding.iter().any(|v| match v { + Value::Bool(v) => (*v).to_string() == *value, + Value::Number(n) => (*n).to_string() == *value, + Value::String(s) => s == value, + _ => false, + }); + if !matches_value { + return Err(BoxError::from(format!( + "unexpected attribute values for key `{key}`, expected value `{value}` but got {binding:?}" + ))); + } + } Ok(()) - } else { - Err(anyhow!("No traces received").into()) } } -fn verify_trace_participants(trace: &Value, services: &[&'static str]) -> Result<(), BoxError> { - let actual_services: HashSet = trace - .select_path("$..resource.attributes[?(@.key=='service.name')].value.stringValue")? - .into_iter() - .filter_map(|service| service.as_string()) - .collect(); - tracing::debug!("found services {:?}", actual_services); +async fn mock_otlp_server_delayed() -> MockServer { + let mock_server = wiremock::MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/traces")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_secs(1)) + .set_body_raw( + ExportTraceServiceResponse::default().encode_to_vec(), + "application/x-protobuf", + ), + ) + .mount(&mock_server) + .await; - let expected_services = services - .iter() - .map(|s| s.to_string()) - .collect::>(); - if actual_services != expected_services { - return Err(BoxError::from(format!( - "incomplete traces, got {actual_services:?} expected {expected_services:?}" - ))); - } - Ok(()) + mock_server } -fn validate_service_name(trace: Value) -> Result<(), BoxError> { - let service_name = - trace.select_path("$..resource.attributes[?(@.key=='service.name')].value.stringValue")?; - assert_eq!( - service_name.first(), - Some(&&Value::String("router".to_string())) - ); - Ok(()) +async fn mock_otlp_server + Clone>(expected_requests: T) -> MockServer { + let mock_server = wiremock::MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/traces")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + ExportTraceServiceResponse::default().encode_to_vec(), + "application/x-protobuf", + )) + .expect(expected_requests.clone()) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/metrics")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + ExportMetricsServiceResponse::default().encode_to_vec(), + "application/x-protobuf", + )) + .expect(expected_requests.clone()) + .mount(&mock_server) + .await; + mock_server } -async fn find_valid_metrics( - mock_server: &MockServer, - _query: &Value, - _operation_name: Option<&str>, - _services: &[&'static str], -) -> Result<(), BoxError> { - let requests = mock_server - .received_requests() +impl TraceSpec { + async fn validate_otlp_trace( + self, + router: &mut IntegrationTest, + mock_server: &MockServer, + query: Query, + ) -> Result<(), BoxError> { + OtlpTraceSpec { + trace_spec: self, + mock_server, + } + .validate_trace(router, query) + .await + } + async fn validate_otlp_metrics(self, mock_server: &MockServer) -> Result<(), BoxError> { + OtlpTraceSpec { + trace_spec: self, + mock_server, + } + .validate_metrics() .await - .expect("Could not get otlp requests"); - if let Some(metrics) = requests.iter().find(|r| r.url.path().ends_with("/metrics")) { - let metrics = opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceRequest::decode(bytes::Bytes::copy_from_slice(&metrics.body))?; - let json_trace = serde_json::to_value(metrics)?; - // For now just validate service name. - validate_service_name(json_trace)?; - - Ok(()) - } else { - Err(anyhow!("No metrics received").into()) } } diff --git a/apollo-router/tests/integration/telemetry/propagation.rs b/apollo-router/tests/integration/telemetry/propagation.rs index e458f1986c..0133e619ff 100644 --- a/apollo-router/tests/integration/telemetry/propagation.rs +++ b/apollo-router/tests/integration/telemetry/propagation.rs @@ -1,9 +1,8 @@ -use serde_json::json; use tower::BoxError; -use crate::integration::common::graph_os_enabled; use crate::integration::common::IntegrationTest; -use crate::integration::common::Telemetry; +use crate::integration::common::Query; +use crate::integration::common::graph_os_enabled; #[tokio::test(flavor = "multi_thread")] async fn test_trace_id_via_header() -> Result<(), BoxError> { @@ -12,12 +11,13 @@ async fn test_trace_id_via_header() -> Result<(), BoxError> { return Ok(()); } async fn make_call(router: &mut IntegrationTest, trace_id: &str) { - let _ = router.execute_query_with_headers(&json!({"query":"query {topProducts{name, name, name, name, name, name, name, name, name, name}}","variables":{}}), - [("id_from_header".to_string(), trace_id.to_string())].into()).await; + let query = Query::builder() + .header("id_from_header".to_string(), trace_id.to_string()) + .build(); + let _ = router.execute_query(query).await; } let mut router = IntegrationTest::builder() - .telemetry(Telemetry::None) .config(include_str!("fixtures/trace_id_via_header.router.yaml")) .build() .await; @@ -27,12 +27,12 @@ async fn test_trace_id_via_header() -> Result<(), BoxError> { router.assert_started().await; make_call(&mut router, trace_id).await; router - .assert_log_contains(&format!("trace_id: {}", trace_id)) + .wait_for_log_message(&format!("trace_id: {trace_id}")) .await; make_call(&mut router, trace_id).await; router - .assert_log_contains(&format!("\"id_from_header\": \"{}\"", trace_id)) + .wait_for_log_message(&format!("\"id_from_header\": \"{trace_id}\"")) .await; router.graceful_shutdown().await; diff --git a/apollo-router/tests/integration/telemetry/verifier.rs b/apollo-router/tests/integration/telemetry/verifier.rs new file mode 100644 index 0000000000..d370e522b9 --- /dev/null +++ b/apollo-router/tests/integration/telemetry/verifier.rs @@ -0,0 +1,163 @@ +use std::time::Duration; + +use anyhow::anyhow; +use opentelemetry::trace::SpanContext; +use opentelemetry::trace::TraceId; +use serde_json::Value; +use tower::BoxError; + +use crate::integration::IntegrationTest; +use crate::integration::common::Query; +use crate::integration::telemetry::TraceSpec; + +pub trait Verifier { + fn spec(&self) -> &TraceSpec; + async fn validate_trace( + &self, + router: &mut IntegrationTest, + query: Query, + ) -> Result<(), BoxError> { + let (id, response) = router.execute_query(query).await; + if let Some(spec_id) = &self.spec().trace_id { + assert_eq!(id.to_string(), *spec_id, "trace id"); + } + for _ in 0..20 { + if self.find_valid_trace(id).await.is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + self.find_valid_trace(id).await?; + let subgraph_context = router.subgraph_context(); + assert!(response.status().is_success()); + self.validate_subgraph(subgraph_context)?; + Ok(()) + } + + async fn validate_metrics(&self) -> Result<(), BoxError> { + for _ in 0..10 { + if self.find_valid_metrics().await.is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + self.find_valid_metrics().await?; + Ok(()) + } + + async fn find_valid_metrics(&self) -> Result<(), BoxError> { + unimplemented!("find_valid_metrics") + } + + fn validate_subgraph(&self, subgraph_context: SpanContext) -> Result<(), BoxError> { + self.validate_subgraph_priority_sampled(&subgraph_context)?; + self.validate_subgraph_sampled(&subgraph_context)?; + Ok(()) + } + fn validate_subgraph_sampled(&self, subgraph_context: &SpanContext) -> Result<(), BoxError> { + if let Some(sampled) = self.spec().priority_sampled { + assert_eq!( + subgraph_context.trace_state().get("psr"), + Some(sampled), + "subgraph psr" + ); + } + + Ok(()) + } + + fn validate_subgraph_priority_sampled( + &self, + subgraph_context: &SpanContext, + ) -> Result<(), BoxError> { + if let Some(sampled) = self.spec().subgraph_sampled { + assert_eq!(subgraph_context.is_sampled(), sampled, "subgraph sampled"); + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + async fn find_valid_trace(&self, trace_id: TraceId) -> Result<(), BoxError> { + // A valid trace has: + // * All three services + // * The correct spans + // * All spans are parented + // * Required attributes of 'router' span has been set + + // For now just validate service name. + let trace: Value = self.get_trace(trace_id).await?; + println!("trace: {trace_id}"); + self.verify_services(&trace)?; + println!("services verified"); + self.verify_spans_present(&trace)?; + println!("spans present verified"); + self.verify_measured_spans(&trace)?; + println!("measured spans verified"); + self.verify_operation_name(&trace)?; + println!("operation name verified"); + self.verify_priority_sampled(&trace)?; + println!("priority sampled verified"); + self.verify_version(&trace)?; + println!("version verified"); + self.verify_span_kinds(&trace)?; + println!("span kinds verified"); + self.verify_span_attributes(&trace)?; + println!("span attributes verified"); + self.verify_resources(&trace)?; + println!("span attributes verified"); + Ok(()) + } + + async fn get_trace(&self, trace_id: TraceId) -> Result; + + fn verify_version(&self, trace: &Value) -> Result<(), BoxError>; + + fn verify_measured_spans(&self, trace: &Value) -> Result<(), BoxError> { + for expected in &self.spec().measured_spans { + let measured = self.measured_span(trace, expected)?; + if !measured { + return Err(anyhow!("missing measured span {}", expected).into()); + } + } + for unexpected in &self.spec().unmeasured_spans { + let measured = self.measured_span(trace, unexpected)?; + if measured { + return Err(anyhow!("unexpected measured span {}", measured).into()); + } + } + Ok(()) + } + + fn measured_span(&self, trace: &Value, name: &str) -> Result; + + fn verify_span_kinds(&self, trace: &Value) -> Result<(), BoxError> { + // Validate that the span.kind has been propagated. We can just do this for a selection of spans. + if self.spec().span_names.contains("router") { + self.validate_span_kind(trace, "router", "server")?; + } + + if self.spec().span_names.contains("supergraph") { + self.validate_span_kind(trace, "supergraph", "internal")?; + } + + if self.spec().span_names.contains("http_request") { + self.validate_span_kind(trace, "http_request", "client")?; + } + + Ok(()) + } + + fn verify_services(&self, trace: &Value) -> Result<(), BoxError>; + + fn verify_spans_present(&self, trace: &Value) -> Result<(), BoxError>; + + fn validate_span_kind(&self, trace: &Value, name: &str, kind: &str) -> Result<(), BoxError>; + + fn verify_span_attributes(&self, trace: &Value) -> Result<(), BoxError>; + + fn verify_operation_name(&self, trace: &Value) -> Result<(), BoxError>; + + fn verify_priority_sampled(&self, trace: &Value) -> Result<(), BoxError>; + + fn verify_resources(&self, _trace: &Value) -> Result<(), BoxError>; +} diff --git a/apollo-router/tests/integration/telemetry/zipkin.rs b/apollo-router/tests/integration/telemetry/zipkin.rs index c0d5e0a8d5..ad5445938f 100644 --- a/apollo-router/tests/integration/telemetry/zipkin.rs +++ b/apollo-router/tests/integration/telemetry/zipkin.rs @@ -1,17 +1,19 @@ extern crate core; use std::collections::HashSet; -use std::time::Duration; +use std::ops::Deref; use anyhow::anyhow; -use opentelemetry_api::trace::TraceId; -use serde_json::json; +use opentelemetry::trace::TraceId; use serde_json::Value; use tower::BoxError; -use crate::integration::common::Telemetry; use crate::integration::IntegrationTest; use crate::integration::ValueExt; +use crate::integration::common::Query; +use crate::integration::common::Telemetry; +use crate::integration::telemetry::TraceSpec; +use crate::integration::telemetry::verifier::Verifier; #[tokio::test(flavor = "multi_thread")] async fn test_basic() -> Result<(), BoxError> { @@ -24,22 +26,13 @@ async fn test_basic() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let query = json!({"query":"query ExampleQuery {topProducts{name}}","variables":{}}); for _ in 0..2 { - let (id, result) = router.execute_query(&query).await; - assert!(!result - .headers() - .get("apollo-custom-trace-id") - .unwrap() - .is_empty()); - validate_trace( - id, - &query, - Some("ExampleQuery"), - &["client", "router", "subgraph"], - false, - ) - .await?; + TraceSpec::builder() + .services(["client", "router", "subgraph"].into()) + .operation_name("ExampleQuery") + .build() + .validate_zipkin_trace(&mut router, Query::default()) + .await?; router.touch_config().await; router.assert_reloaded().await; } @@ -47,85 +40,123 @@ async fn test_basic() -> Result<(), BoxError> { Ok(()) } -async fn validate_trace( - id: TraceId, - query: &Value, - operation_name: Option<&str>, - services: &[&'static str], - custom_span_instrumentation: bool, -) -> Result<(), BoxError> { - let params = url::form_urlencoded::Serializer::new(String::new()) - .append_pair("service", services.first().expect("expected root service")) - .finish(); - - let url = format!("http://localhost:9411/api/v2/trace/{id}?{params}"); - for _ in 0..10 { - if find_valid_trace( - &url, - query, - operation_name, - services, - custom_span_instrumentation, - ) - .await - .is_ok() - { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(100)).await; +struct ZipkinTraceSpec { + trace_spec: TraceSpec, +} +impl Deref for ZipkinTraceSpec { + type Target = TraceSpec; + + fn deref(&self) -> &Self::Target { + &self.trace_spec } - find_valid_trace( - &url, - query, - operation_name, - services, - custom_span_instrumentation, - ) - .await?; - Ok(()) } -async fn find_valid_trace( - url: &str, - _query: &Value, - _operation_name: Option<&str>, - services: &[&'static str], - _custom_span_instrumentation: bool, -) -> Result<(), BoxError> { - // A valid trace has: - // * All three services - // * The correct spans - // * All spans are parented - // * Required attributes of 'router' span has been set - - // For now just validate service name. - let trace: Value = reqwest::get(url) - .await - .map_err(|e| anyhow!("failed to contact zipkin; {}", e))? - .json() - .await?; - tracing::debug!("{}", serde_json::to_string_pretty(&trace)?); - verify_trace_participants(&trace, services)?; +impl Verifier for ZipkinTraceSpec { + fn verify_span_attributes(&self, _trace: &Value) -> Result<(), BoxError> { + Ok(()) + } + fn verify_version(&self, _trace: &Value) -> Result<(), BoxError> { + Ok(()) + } - Ok(()) + fn measured_span(&self, _trace: &Value, _name: &str) -> Result { + Ok(true) + } + + fn verify_span_kinds(&self, _trace: &Value) -> Result<(), BoxError> { + Ok(()) + } + + fn verify_services(&self, trace: &Value) -> Result<(), axum::BoxError> { + let actual_services: HashSet = trace + .select_path("$..serviceName")? + .into_iter() + .filter_map(|service| service.as_string()) + .collect(); + tracing::debug!("found services {:?}", actual_services); + + let expected_services = self + .trace_spec + .services + .iter() + .map(|s| s.to_string()) + .collect::>(); + if actual_services != expected_services { + return Err(BoxError::from(format!( + "incomplete traces, got {actual_services:?} expected {expected_services:?}" + ))); + } + Ok(()) + } + + fn verify_spans_present(&self, _trace: &Value) -> Result<(), BoxError> { + Ok(()) + } + + fn validate_span_kind(&self, _trace: &Value, _name: &str, _kind: &str) -> Result<(), BoxError> { + Ok(()) + } + + fn verify_operation_name(&self, trace: &Value) -> Result<(), BoxError> { + if let Some(expected_operation_name) = &self.operation_name { + let binding = trace + .select_path("$..[?(@.name == 'supergraph')].tags..['graphql.operation.name']")?; + let operation_name = binding.first(); + assert_eq!( + operation_name + .expect("graphql.operation.name expected") + .as_str() + .expect("graphql.operation.name must be a string"), + expected_operation_name + ); + } + Ok(()) + } + + fn verify_priority_sampled(&self, _trace: &Value) -> Result<(), BoxError> { + Ok(()) + } + + async fn get_trace(&self, trace_id: TraceId) -> Result { + let params = url::form_urlencoded::Serializer::new(String::new()) + .append_pair( + "service", + self.trace_spec + .services + .first() + .expect("expected root service"), + ) + .finish(); + + let id = trace_id.to_string(); + let url = format!("http://localhost:9411/api/v2/trace/{id}?{params}"); + println!("url: {}", url); + let value: serde_json::Value = reqwest::get(url) + .await + .map_err(|e| anyhow!("failed to contact datadog; {}", e))? + .json() + .await + .map_err(|e| anyhow!("failed to contact datadog; {}", e))?; + Ok(value) + } + + fn spec(&self) -> &TraceSpec { + &self.trace_spec + } + + fn verify_resources(&self, _trace: &Value) -> Result<(), BoxError> { + Ok(()) + } } -fn verify_trace_participants(trace: &Value, services: &[&'static str]) -> Result<(), BoxError> { - let actual_services: HashSet = trace - .select_path("$..serviceName")? - .into_iter() - .filter_map(|service| service.as_string()) - .collect(); - tracing::debug!("found services {:?}", actual_services); - - let expected_services = services - .iter() - .map(|s| s.to_string()) - .collect::>(); - if actual_services != expected_services { - return Err(BoxError::from(format!( - "incomplete traces, got {actual_services:?} expected {expected_services:?}" - ))); +impl TraceSpec { + async fn validate_zipkin_trace( + self, + router: &mut IntegrationTest, + query: Query, + ) -> Result<(), BoxError> { + ZipkinTraceSpec { trace_spec: self } + .validate_trace(router, query) + .await } - Ok(()) } diff --git a/apollo-router/tests/integration/traffic_shaping.rs b/apollo-router/tests/integration/traffic_shaping.rs index feb9a7e725..816f4102db 100644 --- a/apollo-router/tests/integration/traffic_shaping.rs +++ b/apollo-router/tests/integration/traffic_shaping.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::time::Duration; use insta::assert_yaml_snapshot; @@ -5,11 +6,16 @@ use serde_json::json; use tower::BoxError; use wiremock::ResponseTemplate; -use crate::integration::common::graph_os_enabled; -use crate::integration::common::Telemetry; use crate::integration::IntegrationTest; +use crate::integration::common::Query; +use crate::integration::common::Telemetry; +use crate::integration::common::graph_os_enabled; -const PROMETHEUS_CONFIG: &str = r#" +#[tokio::test(flavor = "multi_thread")] +async fn test_router_timeout() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config( + r#" telemetry: exporters: metrics: @@ -17,19 +23,11 @@ const PROMETHEUS_CONFIG: &str = r#" listen: 127.0.0.1:4000 enabled: true path: /metrics -"#; - -#[tokio::test(flavor = "multi_thread")] -async fn test_router_timeout() -> Result<(), BoxError> { - let mut router = IntegrationTest::builder() - .config(format!( - r#" - {PROMETHEUS_CONFIG} traffic_shaping: router: timeout: 1ns - "# - )) + "#, + ) .responder(ResponseTemplate::new(500).set_delay(Duration::from_millis(20))) .build() .await; @@ -40,10 +38,10 @@ async fn test_router_timeout() -> Result<(), BoxError> { let (_trace_id, response) = router.execute_default_query().await; assert_eq!(response.status(), 504); let response = response.text().await?; - assert!(response.contains("REQUEST_TIMEOUT")); + assert!(response.contains("GATEWAY_TIMEOUT")); assert_yaml_snapshot!(response); - router.assert_metrics_contains(r#"apollo_router_graphql_error_total{code="REQUEST_TIMEOUT",otel_scope_name="apollo/router"} 1"#, None).await; + router.assert_metrics_contains(r#"http_server_request_duration_seconds_count{error_type="Gateway Timeout",http_request_method="POST",http_response_status_code="504""#, None).await; router.graceful_shutdown().await; Ok(()) @@ -52,16 +50,22 @@ async fn test_router_timeout() -> Result<(), BoxError> { #[tokio::test(flavor = "multi_thread")] async fn test_subgraph_timeout() -> Result<(), BoxError> { let mut router = IntegrationTest::builder() - .config(format!( + .config( r#" - {PROMETHEUS_CONFIG} + telemetry: + exporters: + metrics: + prometheus: + listen: 127.0.0.1:4000 + enabled: true + path: /metrics include_subgraph_errors: all: true traffic_shaping: all: timeout: 1ns - "# - )) + "#, + ) .responder(ResponseTemplate::new(500).set_delay(Duration::from_millis(20))) .build() .await; @@ -72,10 +76,63 @@ async fn test_subgraph_timeout() -> Result<(), BoxError> { let (_trace_id, response) = router.execute_default_query().await; assert_eq!(response.status(), 200); let response = response.text().await?; - assert!(response.contains("REQUEST_TIMEOUT")); + assert!(response.contains("GATEWAY_TIMEOUT")); assert_yaml_snapshot!(response); - router.assert_metrics_contains(r#"apollo_router_graphql_error_total{code="REQUEST_TIMEOUT",otel_scope_name="apollo/router"} 1"#, None).await; + // We need to add support for http.client metrics ROUTER-991 + //router.assert_metrics_contains(r#"apollo_router_graphql_error_total{code="REQUEST_TIMEOUT",otel_scope_name="apollo/router"} 1"#, None).await; + + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_connector_timeout() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config( + r#" + telemetry: + exporters: + metrics: + prometheus: + listen: 127.0.0.1:4000 + enabled: true + path: /metrics + traffic_shaping: + connector: + sources: + connectors.jsonPlaceholder: + timeout: 1ns + include_subgraph_errors: + all: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "..", + "apollo-router", + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .responder(ResponseTemplate::new(500).set_delay(Duration::from_millis(20))) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router + .execute_query( + Query::builder() + .body(json!({"query":"query ExampleQuery {posts{id}}","variables":{}})) + .build(), + ) + .await; + assert_eq!(response.status(), 200); + let response = response.text().await?; + assert!(response.contains("GATEWAY_TIMEOUT")); + assert_yaml_snapshot!(response); router.graceful_shutdown().await; Ok(()) @@ -88,10 +145,14 @@ async fn test_router_timeout_operation_name_in_tracing() -> Result<(), BoxError> r#" traffic_shaping: router: - timeout: 1ns + # NB: Normally in tests we would set the timeout to 1ns. But here, + # we are testing a feature that requires GraphQL parsing. If the timeout + # is set to almost 0, then we might time out well before we get to the parser. + # This value could still be racey, but hopefully we can get away with it. + timeout: 100ms "#, ) - .responder(ResponseTemplate::new(500).set_delay(Duration::from_millis(20))) + .responder(ResponseTemplate::new(500).set_delay(Duration::from_millis(250))) .build() .await; @@ -99,16 +160,20 @@ async fn test_router_timeout_operation_name_in_tracing() -> Result<(), BoxError> router.assert_started().await; let (_trace_id, response) = router - .execute_query(&json!({ - "query": "query UniqueName { topProducts { name } }" - })) + .execute_query( + Query::builder() + .body(json!({ + "query": "query UniqueName { topProducts { name } }" + })) + .build(), + ) .await; assert_eq!(response.status(), 504); let response = response.text().await?; - assert!(response.contains("REQUEST_TIMEOUT")); + assert!(response.contains("GATEWAY_TIMEOUT")); router - .assert_log_contains(r#""otel.name":"query UniqueName""#) + .wait_for_log_message(r#""otel.name":"query UniqueName""#) .await; router.graceful_shutdown().await; @@ -116,16 +181,22 @@ async fn test_router_timeout_operation_name_in_tracing() -> Result<(), BoxError> } #[tokio::test(flavor = "multi_thread")] -async fn test_router_timeout_custom_metric() -> Result<(), BoxError> { +async fn test_router_custom_metric() -> Result<(), BoxError> { if !graph_os_enabled() { return Ok(()); } let mut router = IntegrationTest::builder() - .telemetry(Telemetry::Jaeger) - .config(format!( + .telemetry(Telemetry::Otlp { endpoint: None }) + .config( r#" - {PROMETHEUS_CONFIG} + telemetry: + exporters: + metrics: + prometheus: + listen: 127.0.0.1:4000 + enabled: true + path: /metrics instrumentation: instruments: router: @@ -138,8 +209,8 @@ async fn test_router_timeout_custom_metric() -> Result<(), BoxError> { traffic_shaping: router: timeout: 1ns - "# - )) + "#, + ) .responder(ResponseTemplate::new(500).set_delay(Duration::from_millis(20))) .build() .await; @@ -147,12 +218,12 @@ async fn test_router_timeout_custom_metric() -> Result<(), BoxError> { router.start().await; router.assert_started().await; - let (_trace_id, response) = router.execute_default_query().await; - assert_eq!(response.status(), 504); + let (_trace_id, response) = router + .execute_query(Query::default().with_bad_query()) + .await; let response = response.text().await?; - assert!(response.contains("REQUEST_TIMEOUT")); - - router.assert_metrics_contains(r#"http_server_request_duration_seconds_count{error_type="Gateway Timeout",graphql_error="true",http_request_method="POST",http_response_status_code="504""#, None).await; + assert!(response.contains("MISSING_QUERY_STRING")); + router.assert_metrics_contains(r#"http_server_request_duration_seconds_count{error_type="Bad Request",graphql_error="true",http_request_method="POST",http_response_status_code="400""#, None).await; router.graceful_shutdown().await; Ok(()) @@ -161,16 +232,22 @@ async fn test_router_timeout_custom_metric() -> Result<(), BoxError> { #[tokio::test(flavor = "multi_thread")] async fn test_router_rate_limit() -> Result<(), BoxError> { let mut router = IntegrationTest::builder() - .config(format!( + .config( r#" - {PROMETHEUS_CONFIG} + telemetry: + exporters: + metrics: + prometheus: + listen: 127.0.0.1:4000 + enabled: true + path: /metrics traffic_shaping: router: global_rate_limit: capacity: 1 interval: 10min - "# - )) + "#, + ) .build() .await; @@ -184,12 +261,12 @@ async fn test_router_rate_limit() -> Result<(), BoxError> { assert_yaml_snapshot!(response); let (_, response) = router.execute_default_query().await; - assert_eq!(response.status(), 429); + assert_eq!(response.status(), 503); let response = response.text().await?; assert!(response.contains("REQUEST_RATE_LIMITED")); assert_yaml_snapshot!(response); - router.assert_metrics_contains(r#"apollo_router_graphql_error_total{code="REQUEST_RATE_LIMITED",otel_scope_name="apollo/router"} 1"#, None).await; + router.assert_metrics_contains(r#"http_server_request_duration_seconds_count{error_type="Service Unavailable",http_request_method="POST",http_response_status_code="503""#, None).await; router.graceful_shutdown().await; Ok(()) @@ -198,9 +275,15 @@ async fn test_router_rate_limit() -> Result<(), BoxError> { #[tokio::test(flavor = "multi_thread")] async fn test_subgraph_rate_limit() -> Result<(), BoxError> { let mut router = IntegrationTest::builder() - .config(format!( + .config( r#" - {PROMETHEUS_CONFIG} + telemetry: + exporters: + metrics: + prometheus: + listen: 127.0.0.1:4000 + enabled: true + path: /metrics include_subgraph_errors: all: true traffic_shaping: @@ -209,7 +292,7 @@ async fn test_subgraph_rate_limit() -> Result<(), BoxError> { capacity: 1 interval: 10min "#, - )) + ) .build() .await; @@ -233,3 +316,82 @@ async fn test_subgraph_rate_limit() -> Result<(), BoxError> { router.graceful_shutdown().await; Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn test_connector_rate_limit() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config( + r#" + telemetry: + exporters: + metrics: + prometheus: + listen: 127.0.0.1:4000 + enabled: true + path: /metrics + include_subgraph_errors: + all: true + traffic_shaping: + connector: + sources: + connectors.jsonPlaceholder: + global_rate_limit: + capacity: 1 + interval: 10min + connectors: + sources: + connectors.jsonPlaceholder: + $config: + my.config.value: true + "#, + ) + .supergraph(PathBuf::from_iter([ + "..", + "apollo-router", + "tests", + "fixtures", + "connectors", + "quickstart.graphql", + ])) + .responder(ResponseTemplate::new(200).set_body_json(json!([{ + "id": 1, + "title": "Awesome post", + "body:": "This is a really great post", + "userId": 1 + }]))) + .http_method("GET") + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_, response) = router + .execute_query( + Query::builder() + .body(json!({"query":"query ExampleQuery {posts{id}}","variables":{}})) + .build(), + ) + .await; + assert_eq!(response.status(), 200); + let response = response.text().await?; + assert!(!response.contains("REQUEST_RATE_LIMITED")); + assert_yaml_snapshot!(response); + + let (_, response) = router + .execute_query( + Query::builder() + .body(json!({"query":"query ExampleQuery {posts{id}}","variables":{}})) + .build(), + ) + .await; + assert_eq!(response.status(), 200); + let response = response.text().await?; + assert!(response.contains("REQUEST_RATE_LIMITED")); + assert_yaml_snapshot!(response); + + router.assert_metrics_contains(r#"apollo_router_graphql_error_total{code="REQUEST_RATE_LIMITED",otel_scope_name="apollo/router"} 1"#, None).await; + + router.graceful_shutdown().await; + Ok(()) +} diff --git a/apollo-router/tests/integration/typename.rs b/apollo-router/tests/integration/typename.rs index 782e90adb6..15b2363503 100644 --- a/apollo-router/tests/integration/typename.rs +++ b/apollo-router/tests/integration/typename.rs @@ -106,11 +106,7 @@ async fn aliased() { "###); } -// FIXME: bellow test panic because of bug in query planner, failing with: -// "value retrieval failed: empty query plan. This behavior is unexpected and we suggest opening an issue to apollographql/router with a reproduction." -// See: https://github.com/apollographql/router/issues/6154 #[tokio::test] -#[should_panic] async fn inside_inline_fragment() { let request = Request::fake_builder() .query("{ ... { __typename } }") @@ -120,14 +116,13 @@ async fn inside_inline_fragment() { insta::assert_json_snapshot!(response, @r###" { "data": { - "n": "MyQuery" + "__typename": "MyQuery" } } "###); } #[tokio::test] -#[should_panic] // See above FIXME async fn inside_fragment() { let query = r#" { ...SomeFragment } @@ -141,14 +136,13 @@ async fn inside_fragment() { insta::assert_json_snapshot!(response, @r###" { "data": { - "n": "MyQuery" + "__typename": "MyQuery" } } "###); } #[tokio::test] -#[should_panic] // See above FIXME async fn deeply_nested_inside_fragments() { let query = r#" { ...SomeFragment } @@ -168,7 +162,7 @@ async fn deeply_nested_inside_fragments() { insta::assert_json_snapshot!(response, @r###" { "data": { - "n": "MyQuery" + "__typename": "MyQuery" } } "###); diff --git a/apollo-router/tests/integration_tests.rs b/apollo-router/tests/integration_tests.rs index 378a20ddd5..3fe47a7422 100644 --- a/apollo-router/tests/integration_tests.rs +++ b/apollo-router/tests/integration_tests.rs @@ -2,34 +2,36 @@ //! Please ensure that any tests added to this file use the tokio multi-threaded test executor. //! -use std::collections::hash_map::Entry; use std::collections::HashMap; +use std::collections::hash_map::Entry; use std::ffi::OsStr; use std::sync::Arc; -use std::sync::Mutex; +use apollo_router::_private::create_test_service_factory_from_yaml; +use apollo_router::Configuration; +use apollo_router::Context; use apollo_router::graphql; +use apollo_router::graphql::Error; use apollo_router::plugin::Plugin; use apollo_router::plugin::PluginInit; use apollo_router::services::router; use apollo_router::services::subgraph; use apollo_router::services::supergraph; use apollo_router::test_harness::mocks::persisted_queries::*; -use apollo_router::Configuration; -use apollo_router::Context; -use apollo_router::_private::create_test_service_factory_from_yaml; use futures::StreamExt; -use http::header::ACCEPT; -use http::header::CONTENT_TYPE; use http::HeaderValue; use http::Method; use http::StatusCode; use http::Uri; +use http::header::ACCEPT; +use http::header::CONTENT_TYPE; use maplit::hashmap; use mime::APPLICATION_JSON; +use parking_lot::Mutex; use serde_json_bytes::json; use tower::BoxError; use tower::ServiceExt; +use uuid::Uuid; use walkdir::DirEntry; use walkdir::WalkDir; @@ -118,10 +120,6 @@ async fn simple_queries_should_not_work() { Please either specify a 'content-type' header \ (with a mime-type that is not one of application/x-www-form-urlencoded, multipart/form-data, text/plain) \ or provide one of the following headers: x-apollo-operation-name, apollo-require-preflight"; - let expected_error = graphql::Error::builder() - .message(message) - .extension_code("CSRF_ERROR") - .build(); let mut get_request: router::Request = supergraph::Request::builder() .query("{ topProducts { upc name reviews {id product { name } author { id name } } } }") @@ -145,6 +143,13 @@ async fn simple_queries_should_not_work() { let actual = query_with_router(router, get_request).await; + let expected_error = graphql::Error::builder() + .message(message) + .extension_code("CSRF_ERROR") + // Overwrite error ID to avoid comparing random Uuids + .apollo_id(actual.errors[0].apollo_id()) + .build(); + assert_eq!( 1, actual.errors.len(), @@ -162,7 +167,7 @@ async fn empty_posts_should_not_work() { HeaderValue::from_static(APPLICATION_JSON.essence_str()), ) .method(Method::POST) - .body(hyper::Body::empty()) + .body(axum::body::Body::empty()) .unwrap(); let (router, registry) = setup_router_and_registry(serde_json::json!({})).await; @@ -179,6 +184,7 @@ async fn empty_posts_should_not_work() { .message(message) .extension_code("INVALID_GRAPHQL_REQUEST") .extensions(extensions_map) + .apollo_id(actual.errors[0].apollo_id()) .build(); assert_eq!(expected_error, actual.errors[0]); assert_eq!(registry.totals(), hashmap! {}); @@ -234,12 +240,7 @@ async fn queries_should_work_over_post() { async fn service_errors_should_be_propagated() { let message = "Unknown operation named \"invalidOperationName\""; let mut extensions_map = serde_json_bytes::map::Map::new(); - extensions_map.insert("code", "GRAPHQL_VALIDATION_FAILED".into()); - let expected_error = apollo_router::graphql::Error::builder() - .message(message) - .extensions(extensions_map) - .extension_code("VALIDATION_ERROR") - .build(); + extensions_map.insert("code", "GRAPHQL_UNKNOWN_OPERATION_NAME".into()); let request = supergraph::Request::fake_builder() .query(r#"{ topProducts { name } }"#) @@ -251,6 +252,14 @@ async fn service_errors_should_be_propagated() { let (actual, registry) = query_rust(request).await; + let expected_error = apollo_router::graphql::Error::builder() + .message(message) + .extensions(extensions_map) + .extension_code("VALIDATION_ERROR") + // Overwrite error ID to avoid comparing random Uuids + .apollo_id(actual.errors[0].apollo_id()) + .build(); + assert_eq!(expected_error, actual.errors[0]); assert_eq!(registry.totals(), expected_service_hits); } @@ -339,11 +348,6 @@ async fn mutation_should_work_over_post() { async fn automated_persisted_queries() { let (router, registry) = setup_router_and_registry(serde_json::json!({})).await; - let expected_apq_miss_error = apollo_router::graphql::Error::builder() - .message("PersistedQueryNotFound") - .extension_code("PERSISTED_QUERY_NOT_FOUND") - .build(); - let persisted = json!({ "version" : 1u8, "sha256Hash" : "9d1474aa069127ff795d3412b11dfc1f1be0853aed7a54c4a619ee0b1725382e" @@ -361,6 +365,12 @@ async fn automated_persisted_queries() { let actual = query_with_router(router.clone(), apq_only_request.try_into().unwrap()).await; + let expected_apq_miss_error = apollo_router::graphql::Error::builder() + .message("PersistedQueryNotFound") + .extension_code("PERSISTED_QUERY_NOT_FOUND") + .apollo_id(actual.errors[0].apollo_id()) + .build(); + assert_eq!(expected_apq_miss_error, actual.errors[0]); assert_eq!(1, actual.errors.len()); assert_eq!(registry.totals(), expected_service_hits); @@ -430,11 +440,12 @@ async fn persisted_queries() { "name": "Ada Lovelace" } }); - - let (_mock_guard, uplink_config) = mock_pq_uplink( - &hashmap! { PERSISTED_QUERY_ID.to_string() => PERSISTED_QUERY_BODY.to_string() }, - ) - .await; + let manifest = PersistedQueryManifest::from(vec![ManifestOperation { + id: PERSISTED_QUERY_ID.to_string(), + body: PERSISTED_QUERY_BODY.to_string(), + client_name: None, + }]); + let (_mock_guard, uplink_config) = mock_pq_uplink(&manifest).await; let config = serde_json::json!({ "persisted_queries": { @@ -466,12 +477,16 @@ async fn persisted_queries() { let actual = query_with_router(router.clone(), pq_request(UNKNOWN_QUERY_ID)).await; assert_eq!( actual.errors, - vec![apollo_router::graphql::Error::builder() - .message(format!( - "Persisted query '{UNKNOWN_QUERY_ID}' not found in the persisted query list" - )) - .extension_code("PERSISTED_QUERY_NOT_IN_LIST") - .build()] + vec![ + apollo_router::graphql::Error::builder() + .message(format!( + "Persisted query '{UNKNOWN_QUERY_ID}' not found in the persisted query list" + )) + .extension_code("PERSISTED_QUERY_NOT_IN_LIST") + // Overwrite error ID to avoid comparing random Uuids + .apollo_id(actual.errors[0].apollo_id()) + .build() + ] ); assert_eq!(actual.data, None); assert_eq!(registry.totals(), hashmap! {"accounts".to_string() => 1}); @@ -518,7 +533,7 @@ async fn persisted_queries() { CONTENT_TYPE, HeaderValue::from_static(APPLICATION_JSON.essence_str()), ) - .body(router::Body::empty()) + .body(axum::body::Body::empty()) .unwrap() .into(), ) @@ -558,7 +573,7 @@ async fn missing_variables() { assert_eq!(StatusCode::BAD_REQUEST, http_response.response.status()); - let mut response = serde_json::from_slice::( + let response = serde_json::from_slice::( http_response .next_response() .await @@ -569,28 +584,138 @@ async fn missing_variables() { ) .unwrap(); - let mut expected = vec![ + let mut normalized_actual_errors = normalize_errors(response.errors); + normalized_actual_errors.sort_by_key(|e| e.message.clone()); + + let mut expected_errors = vec![ graphql::Error::builder() - .message("invalid type for variable: 'missingVariable'") + .message("missing variable `$missingVariable`: for required GraphQL type `Int!`") .extension_code("VALIDATION_INVALID_TYPE_VARIABLE") .extension("name", "missingVariable") + .apollo_id(Uuid::nil()) .build(), graphql::Error::builder() - .message("invalid type for variable: 'yetAnotherMissingVariable'") + .message( + "missing variable `$yetAnotherMissingVariable`: for required GraphQL type `ID!`", + ) .extension_code("VALIDATION_INVALID_TYPE_VARIABLE") .extension("name", "yetAnotherMissingVariable") + .apollo_id(Uuid::nil()) .build(), ]; - response.errors.sort_by_key(|e| e.message.clone()); - expected.sort_by_key(|e| e.message.clone()); - assert_eq!(response.errors, expected); + + expected_errors.sort_by_key(|e| e.message.clone()); + assert_eq!(normalized_actual_errors, expected_errors); +} + +/// +#[tokio::test(flavor = "multi_thread")] +async fn input_object_variable_validation() { + let schema = r#" + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + { + query: Query + } + + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + input CoordinatesInput + @join__type(graph: SUBGRAPH1) + { + latitude: Float! + longitude: Float! + } + + scalar join__FieldSet + + enum join__Graph { + SUBGRAPH1 @join__graph(name: "subgraph1", url: "http://localhost:4001") + } + + scalar link__Import + + enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION + } + + input MyInput + @join__type(graph: SUBGRAPH1) + { + coordinates: [CoordinatesInput] + } + + type Query + @join__type(graph: SUBGRAPH1) + { + getData(params: MyInput): Int + } + "#; + let request = apollo_router::services::supergraph::Request::fake_builder() + .query("query($x: MyInput) { getData(params: $x) }") + .variable( + "x", + json!({"coordinates": [{"latitude": 45.5, "longitude": null}]}), + ) + .build() + .unwrap(); + let response = apollo_router::TestHarness::builder() + .schema(schema) + .build_supergraph() + .await + .unwrap() + .oneshot(request) + .await + .unwrap() + .next_response() + .await + .unwrap(); + let normalized_errors = normalize_errors(response.errors); + insta::assert_debug_snapshot!(normalized_errors, @r###" + [ + Error { + message: "missing input value at `$x.coordinates[0].longitude`: for required GraphQL type `Float!`", + locations: [], + path: None, + extensions: { + "name": String( + "x", + ), + "code": String( + "VALIDATION_INVALID_TYPE_VARIABLE", + ), + }, + apollo_id: 00000000-0000-0000-0000-000000000000, + }, + ] + "###); } const PARSER_LIMITS_TEST_QUERY: &str = r#"{ me { reviews { author { reviews { author { name } } } } } }"#; const PARSER_LIMITS_TEST_QUERY_TOKEN_COUNT: usize = 36; const PARSER_LIMITS_TEST_QUERY_RECURSION: usize = 6; - #[tokio::test(flavor = "multi_thread")] async fn query_just_under_recursion_limit() { let config = serde_json::json!({ @@ -732,10 +857,8 @@ async fn defer_path_with_disabled_config() { "supergraph": { "defer_support": false, }, - "plugins": { - "apollo.include_subgraph_errors": { - "all": true - } + "include_subgraph_errors": { + "all": true } }); let request = supergraph::Request::fake_builder() @@ -769,10 +892,8 @@ async fn defer_path_with_disabled_config() { #[tokio::test(flavor = "multi_thread")] async fn defer_path() { let config = serde_json::json!({ - "plugins": { - "apollo.include_subgraph_errors": { - "all": true - } + "include_subgraph_errors": { + "all": true } }); let request = supergraph::Request::fake_builder() @@ -807,10 +928,8 @@ async fn defer_path() { #[tokio::test(flavor = "multi_thread")] async fn defer_path_in_array() { let config = serde_json::json!({ - "plugins": { - "apollo.include_subgraph_errors": { - "all": true - } + "include_subgraph_errors": { + "all": true } }); let request = supergraph::Request::fake_builder() @@ -850,10 +969,8 @@ async fn defer_path_in_array() { #[tokio::test(flavor = "multi_thread")] async fn defer_query_without_accept() { let config = serde_json::json!({ - "plugins": { - "apollo.include_subgraph_errors": { - "all": true - } + "include_subgraph_errors": { + "all": true } }); let request = supergraph::Request::fake_builder() @@ -886,10 +1003,8 @@ async fn defer_query_without_accept() { #[tokio::test(flavor = "multi_thread")] async fn defer_empty_primary_response() { let config = serde_json::json!({ - "plugins": { - "apollo.include_subgraph_errors": { - "all": true - } + "include_subgraph_errors": { + "all": true } }); let request = supergraph::Request::fake_builder() @@ -1034,7 +1149,7 @@ async fn query_operation_id() { expected_apollo_operation_id, response .context - .get::<_, String>("apollo_operation_id".to_string()) + .get::<_, String>("apollo::supergraph::operation_id") .unwrap() .unwrap() .as_str() @@ -1060,7 +1175,7 @@ async fn query_operation_id() { expected_apollo_operation_id, response .context - .get::<_, String>("apollo_operation_id".to_string()) + .get::<_, String>("apollo::supergraph::operation_id") .unwrap() .unwrap() .as_str() @@ -1080,7 +1195,7 @@ async fn query_operation_id() { // "## GraphQLParseFailure\n" response .context - .get::<_, String>("apollo_operation_id".to_string()) + .get::<_, String>("apollo::supergraph::operation_id") .unwrap() .is_none() ); @@ -1102,11 +1217,13 @@ async fn query_operation_id() { let response = http_query_with_router(router.clone(), unknown_operation_name).await; // "## GraphQLUnknownOperationName\n" - assert!(response - .context - .get::<_, String>("apollo_operation_id".to_string()) - .unwrap() - .is_none()); + assert!( + response + .context + .get::<_, String>("apollo::supergraph::operation_id") + .unwrap() + .is_none() + ); let validation_error: router::Request = supergraph::Request::fake_builder() .query( @@ -1125,11 +1242,13 @@ async fn query_operation_id() { let response = http_query_with_router(router, validation_error).await; // "## GraphQLValidationFailure\n" - assert!(response - .context - .get::<_, String>("apollo_operation_id".to_string()) - .unwrap() - .is_none()); + assert!( + response + .context + .get::<_, String>("apollo::supergraph::operation_id") + .unwrap() + .is_none() + ); } async fn http_query_rust( @@ -1250,7 +1369,7 @@ impl CountingServiceRegistry { } fn increment(&self, service: &str) { - let mut counts = self.counts.lock().unwrap(); + let mut counts = self.counts.lock(); match counts.entry(service.to_owned()) { Entry::Occupied(mut e) => { *e.get_mut() += 1; @@ -1262,7 +1381,7 @@ impl CountingServiceRegistry { } fn totals(&self) -> HashMap { - self.counts.lock().unwrap().clone() + self.counts.lock().clone() } } @@ -1322,27 +1441,26 @@ async fn all_stock_router_example_yamls_are_valid() { if !cfg!(target_family = "unix") && entry_parent.join(".unixonly").exists() { break; } - if let Some(name) = example_directory_entry.file_name().to_str() { - if name.ends_with("yaml") || name.ends_with("yml") { - let raw_yaml = std::fs::read_to_string(entry_path) - .unwrap_or_else(|e| panic!("unable to read {display_path}: {e}")); - { - let mut configuration: Configuration = serde_yaml::from_str(&raw_yaml) - .unwrap_or_else(|e| panic!("unable to parse YAML {display_path}: {e}")); - let (_mock_guard, configuration) = - if configuration.persisted_queries.enabled { - let (_mock_guard, uplink_config) = mock_empty_pq_uplink().await; - configuration.uplink = Some(uplink_config); - (Some(_mock_guard), configuration) - } else { - (None, configuration) - }; - setup_router_and_registry_with_config(configuration) - .await - .unwrap_or_else(|e| { - panic!("unable to start up router for {display_path}: {e}"); - }); - } + if let Some(name) = example_directory_entry.file_name().to_str() + && (name.ends_with("yaml") || name.ends_with("yml")) + { + let raw_yaml = std::fs::read_to_string(entry_path) + .unwrap_or_else(|e| panic!("unable to read {display_path}: {e}")); + { + let mut configuration: Configuration = serde_yaml::from_str(&raw_yaml) + .unwrap_or_else(|e| panic!("unable to parse YAML {display_path}: {e}")); + let (_mock_guard, configuration) = if configuration.persisted_queries.enabled { + let (_mock_guard, uplink_config) = mock_empty_pq_uplink().await; + configuration.uplink = Some(uplink_config); + (Some(_mock_guard), configuration) + } else { + (None, configuration) + }; + setup_router_and_registry_with_config(configuration) + .await + .unwrap_or_else(|e| { + panic!("unable to start up router for {display_path}: {e}"); + }); } } } @@ -1350,7 +1468,6 @@ async fn all_stock_router_example_yamls_are_valid() { } #[tokio::test] -#[tracing_test::traced_test] async fn test_starstuff_supergraph_is_valid() { let schema = include_str!("../../examples/graphql/supergraph.graphql"); apollo_router::TestHarness::builder() @@ -1369,7 +1486,6 @@ Make sure it is accessible, and the configuration is working with the router."#, // This test must use the multi_thread tokio executor or the opentelemetry hang bug will // be encountered. (See https://github.com/open-telemetry/opentelemetry-rust/issues/536) #[tokio::test(flavor = "multi_thread")] -#[tracing_test::traced_test] async fn test_telemetry_doesnt_hang_with_invalid_schema() { create_test_service_factory_from_yaml( include_str!("../src/testdata/invalid_supergraph.graphql"), @@ -1420,3 +1536,19 @@ fn it_will_not_start_with_loose_file_permissions() { "Apollo key file permissions (0o777) are too permissive\n" ) } + +fn normalize_errors(errors: Vec) -> Vec { + errors + .into_iter() + .map(|e| { + Error::builder() + // Overwrite error ID to avoid comparing random Uuids + .apollo_id(Uuid::nil()) + .message(e.message) + .locations(e.locations) + .and_path(e.path) + .extensions(e.extensions) + .build() + }) + .collect() +} diff --git a/apollo-router/tests/samples/README.md b/apollo-router/tests/samples/README.md index c37c65e06b..28aa1a149c 100644 --- a/apollo-router/tests/samples/README.md +++ b/apollo-router/tests/samples/README.md @@ -4,7 +4,7 @@ This folder contains a series of Router integration tests that can be defined en ## How to write a test -One test is recognized as a folder containing a `plan.json` file. Any number of subfolders is accepted, and the test name will be the path to the test folder. If the folder contains a `README.md` file, it will be added to the captured output of the test, and displayed if the test failed. +One test is recognized as a folder containing a `plan.json` (or `plan.yaml`) file. Any number of subfolders is accepted, and the test name will be the path to the test folder. If the folder contains a `README.md` file, it will be added to the captured output of the test, and displayed if the test failed. The `plan.json` file contains a top level JSON object with an `actions` field, containing an array of possible actions, that will be executed one by one: @@ -119,4 +119,25 @@ Stops the Router. If the Router does not stop correctly, then this action will f { "type": "Stop" } -``` \ No newline at end of file +``` + +## Troubleshooting + +### Query planning related + +When execution does something unexpected, checking the generated query plan can help. +Make sure the YAML Router configuration enables the _expose query plan_ plugin: + +```yaml +plugins: + experimental.expose_query_plan: true +``` + +In a `"type": "Request"` step of `plan.json`, temporarily add the header to ask +for the response to include `extensions.apolloQueryPlan`: + +```json +"headers": { + "Apollo-Expose-Query-Plan": "true" +}, +``` diff --git a/apollo-router/tests/samples/basic/interface-object/plan.json b/apollo-router/tests/samples/basic/interface-object/plan.json index 91a5690a0c..f50fbc4589 100644 --- a/apollo-router/tests/samples/basic/interface-object/plan.json +++ b/apollo-router/tests/samples/basic/interface-object/plan.json @@ -10,7 +10,7 @@ { "request": { "body": { - "query": "query TestItf__accounts__0{i{__typename id x ...on A{a}...on B{b}}}", + "query": "query TestItf__accounts__0 { i { __typename id x ... on A { a } ... on B { b } } }", "operationName": "TestItf__accounts__0" } }, @@ -55,7 +55,7 @@ { "request": { "body": { - "query": "query TestItf__products__1($representations:[_Any!]!){_entities(representations:$representations){...on I{y}}}", + "query": "query TestItf__products__1($representations: [_Any!]!) { _entities(representations: $representations) { ... on I { y } } }", "operationName": "TestItf__products__1", "variables": { "representations": [ @@ -143,7 +143,7 @@ { "request": { "body": { - "query": "query TestItf2__accounts__0{req{__typename id i{__typename id x}}}", + "query": "query TestItf2__accounts__0 { req { __typename id i { __typename id x } } }", "operationName": "TestItf2__accounts__0" } }, @@ -170,7 +170,7 @@ { "request": { "body": { - "query": "query TestItf2__products__1($representations:[_Any!]!){_entities(representations:$representations){...on I{y}}}", + "query": "query TestItf2__products__1($representations: [_Any!]!) { _entities(representations: $representations) { ... on I { y } } }", "operationName": "TestItf2__products__1", "variables": { "representations": [ @@ -201,7 +201,7 @@ { "request": { "body": { - "query": "query TestItf2__reviews__2($representations:[_Any!]!){_entities(representations:$representations){...on C{c}}}", + "query": "query TestItf2__reviews__2($representations: [_Any!]!) { _entities(representations: $representations) { ... on C { c } } }", "operationName": "TestItf2__reviews__2", "variables": { "representations": [ diff --git a/apollo-router/tests/samples/core/defer/plan.json b/apollo-router/tests/samples/core/defer/plan.json index 72dd5efed0..6d6fff51fb 100644 --- a/apollo-router/tests/samples/core/defer/plan.json +++ b/apollo-router/tests/samples/core/defer/plan.json @@ -10,7 +10,7 @@ { "request": { "body": { - "query": "{me{__typename name id}}" + "query": "{ me { __typename name id } }" } }, "response": { @@ -32,7 +32,7 @@ { "request": { "body": { - "query": "query($representations:[_Any!]!){_entities(representations:$representations){..._generated_onUser1_0}}fragment _generated_onUser1_0 on User{reviews{body}}", + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { reviews { body } } } }", "variables": { "representations": [ { @@ -103,4 +103,4 @@ "type": "Stop" } ] -} +} \ No newline at end of file diff --git a/apollo-router/tests/samples/core/query1/plan.json b/apollo-router/tests/samples/core/query1/plan.json index d8fe2c400d..d451236d33 100644 --- a/apollo-router/tests/samples/core/query1/plan.json +++ b/apollo-router/tests/samples/core/query1/plan.json @@ -9,7 +9,7 @@ "requests": [ { "request": { - "body": {"query":"{me{name}}"} + "body": {"query":"{ me { name } }"} }, "response": { "body": {"data": { "me": { "name": "test" } } } @@ -17,7 +17,7 @@ }, { "request": { - "body": {"query":"{me{nom:name}}"} + "body": {"query":"{ me { nom: name } }"} }, "response": { "body": {"data": { "me": { "nom": "test" } } } diff --git a/apollo-router/tests/samples/enterprise/connectors-debugging/README.md b/apollo-router/tests/samples/enterprise/connectors-debugging/README.md new file mode 100644 index 0000000000..a15b5743da --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-debugging/README.md @@ -0,0 +1,3 @@ +Example testing the connectors debugging extensions feature + +Hardcodes a port for the http snapshot server because the port appears in the expected responses. The port is outside the range of 32768..60999 to avoid conflicts with the other tests that use ephemeral ports. \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors-debugging/configuration.yaml b/apollo-router/tests/samples/enterprise/connectors-debugging/configuration.yaml new file mode 100644 index 0000000000..9ece613342 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-debugging/configuration.yaml @@ -0,0 +1,14 @@ +include_subgraph_errors: + all: true + +connectors: + debug_extensions: true + sources: + connectors.jsonPlaceholder: + override_url: http://localhost:4007 + +telemetry: + exporters: + logging: + stdout: + format: text \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors-debugging/http_snapshots.json b/apollo-router/tests/samples/enterprise/connectors-debugging/http_snapshots.json new file mode 100644 index 0000000000..6a257becb6 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-debugging/http_snapshots.json @@ -0,0 +1,88 @@ +[ + { + "request": { + "method": "GET", + "path": "posts/1?invalidInConnectUri=", + "body": null, + "headers": { + "x-test-from": "client-value", + "x-invalid-from-connect": "", + "x-invalid-from-source": "" + } + }, + "response": { + "status": 200, + "headers": { + "content-type": ["application/json; charset=utf-8"], + "date": ["Tue, 07 Jan 2025 18:34:52 GMT"] + }, + "body": { + "userId": 1, + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + } + } + }, + { + "request": { + "method": "GET", + "path": "users/1", + "body": null, + "headers": { + "x-test-from": "client-value" + } + }, + "response": { + "status": 200, + "headers": { + "content-type": ["application/json; charset=utf-8"], + "date": ["Tue, 07 Jan 2025 18:34:52 GMT"] + }, + "body": { + "id": 1, + "name": "Leanne Graham", + "username": "Bret", + "email": "Sincere@april.biz", + "address": { + "street": "Kulas Light", + "suite": "Apt. 556", + "city": "Gwenborough", + "zipcode": "92998-3874", + "geo": { + "lat": "-37.3159", + "lng": "81.1496" + } + }, + "phone": "1-770-736-8031 x56442", + "website": "hildegard.org", + "company": { + "name": "Romaguera-Crona", + "catchPhrase": "Multi-layered client-server neural-net", + "bs": "harness real-time e-markets" + } + } + } + }, + { + "request": { + "method": "GET", + "path": "broken", + "body": null, + "headers": { + "x-test-from": "client-value" + } + }, + "response": { + "status": 500, + "headers": { + "content-type": ["application/json; charset=utf-8"], + "date": ["Tue, 07 Jan 2025 18:34:52 GMT"] + }, + "body": { + "message": "It broke!", + "errorCode": "BROKEN_THING" + } + } + } +] diff --git a/apollo-router/tests/samples/enterprise/connectors-debugging/plan.json b/apollo-router/tests/samples/enterprise/connectors-debugging/plan.json new file mode 100644 index 0000000000..7deb188876 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-debugging/plan.json @@ -0,0 +1,290 @@ +{ + "enterprise": true, + "redis": false, + "snapshot": true, + "actions": [ + { + "type": "Start", + "schema_path": "./supergraph.graphql", + "configuration_path": "./configuration.yaml", + "subgraphs": { + "jsonPlaceholder": { + "snapshot": { + "path": "./http_snapshots.json", + "base_url": "https://jsonplaceholder.typicode.com/", + "port": 61000 + } + } + } + }, + { + "type": "Request", + "headers": { + "Apollo-Connectors-Debugging": "true", + "x-test-from": "client-value" + }, + "request": { + "query": "query { post(id: 1) { id author { name } title } }" + }, + "expected_response": { + "data": { "post": { "id": 1, "author": { "name": "Leanne Graham" }, "title": null } }, + "extensions": { + "apolloConnectorsDebugging": { + "version": "2", + "data": [ + { + "request": { + "url": "http://127.0.0.1:61000/posts/1?invalidInConnectUri=", + "method": "GET", + "headers": [ + ["x-invalid-from-connect", ""], + ["x-from", "client-value"], + ["x-invalid-from-source", ""] + ], + "body": null, + "sourceUrl": { + "base": "http://127.0.0.1:61000/", + "path": null, + "queryParams": "invalidFromSource: $config.abcd" + }, + "connectUrl": { + "base": "/posts/{$args.id}?invalidInConnectUri={$config.aaa}", + "path": null, + "queryParams": "invalidFromConnect: $config.abcdef" + }, + "sourceHeaders": [["x-invalid-from-source", "{$context.abcd}"]], + "connectHeaders": [["x-invalid-from-connect", "{$config.aaa}"]] + }, + "response": { + "status": 200, + "headers": [ + ["content-type", "application/json; charset=utf-8"], + ["date", "Tue, 07 Jan 2025 18:34:52 GMT"], + ["content-length", "275"] + ], + "body": { + "kind": "json", + "content": { + "userId": 1, + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + }, + "selection": { + "source": "id\ntitle: postTitle\nbody\nauthor: {\n id: userId\n}", + "transformed": "id\ntitle: postTitle\nauthor: {\n __typename: $->echo(\"User\")\n id: userId\n}", + "result": { "id": 1, "author": { "__typename": "User", "id": 1 } } + } + }, + "errors": { + "message": "$context.aaaaa", + "sourceExtensions": "fromSource: $config.aaaaa", + "connectExtensions": null + } + }, + "problems": [ + { + "location": "SourceQueryParams", + "details": { "message": "Property .abcd not found in object", "path": "$config.abcd", "count": 1 } + }, + { + "location": "ConnectUrl", + "details": { "message": "Property .aaa not found in object", "path": "$config.aaa", "count": 1 } + }, + { + "location": "ConnectQueryParams", + "details": { + "message": "Property .abcdef not found in object", + "path": "$config.abcdef", + "count": 1 + } + }, + { + "location": "SourceHeaders", + "details": { "message": "Property .abcd not found in object", "path": "$context.abcd", "count": 1 } + }, + { + "location": "ConnectHeaders", + "details": { "message": "Property .aaa not found in object", "path": "$config.aaa", "count": 1 } + }, + { + "location": "Selection", + "details": { "message": "Property .postTitle not found in object", "path": "postTitle", "count": 1 } + } + ] + }, + { + "request": { + "url": "http://127.0.0.1:61000/users/1", + "method": "GET", + "headers": [ + ["x-from", "client-value"], + ["x-invalid-from-source", ""] + ], + "body": null, + "sourceUrl": { + "base": "http://127.0.0.1:61000/", + "path": null, + "queryParams": "invalidFromSource: $config.abcd" + }, + "connectUrl": { "base": "/users/{$args.id}", "path": null, "queryParams": null }, + "sourceHeaders": [["x-invalid-from-source", "{$context.abcd}"]], + "connectHeaders": null + }, + "response": { + "status": 200, + "headers": [ + ["content-type", "application/json; charset=utf-8"], + ["date", "Tue, 07 Jan 2025 18:34:52 GMT"], + ["content-length", "401"] + ], + "body": { + "kind": "json", + "content": { + "id": 1, + "name": "Leanne Graham", + "username": "Bret", + "email": "Sincere@april.biz", + "address": { + "street": "Kulas Light", + "suite": "Apt. 556", + "city": "Gwenborough", + "zipcode": "92998-3874", + "geo": { "lat": "-37.3159", "lng": "81.1496" } + }, + "phone": "1-770-736-8031 x56442", + "website": "hildegard.org", + "company": { + "name": "Romaguera-Crona", + "catchPhrase": "Multi-layered client-server neural-net", + "bs": "harness real-time e-markets" + } + }, + "selection": { + "source": "id\nname\nusername", + "transformed": "name", + "result": { "name": "Leanne Graham" } + } + }, + "errors": { + "message": "$context.aaaaa", + "sourceExtensions": "fromSource: $config.aaaaa", + "connectExtensions": null + } + }, + "problems": [ + { + "location": "SourceQueryParams", + "details": { "message": "Property .abcd not found in object", "path": "$config.abcd", "count": 1 } + }, + { + "location": "SourceHeaders", + "details": { "message": "Property .abcd not found in object", "path": "$context.abcd", "count": 1 } + } + ] + } + ] + } + } + } + }, + { + "type": "Request", + "headers": { + "Apollo-Connectors-Debugging": "true", + "x-test-from": "client-value" + }, + "request": { + "query": "query { broken { id author { name } title } }" + }, + "expected_response": { + "data": { "broken": null }, + "errors": [ + { + "message": "", + "path": ["broken"], + "extensions": { + "http": { "status": 500 }, + "connector": { "coordinate": "connectors:Query.broken[0]" }, + "code": "BROKEN_THING", + "service": "connectors" + } + } + ], + "extensions": { + "apolloConnectorsDebugging": { + "version": "2", + "data": [ + { + "request": { + "url": "http://127.0.0.1:61000/broken", + "method": "GET", + "headers": [ + ["x-from", "client-value"], + ["x-invalid-from-source", ""] + ], + "body": null, + "sourceUrl": { + "base": "http://127.0.0.1:61000/", + "path": null, + "queryParams": "invalidFromSource: $config.abcd" + }, + "connectUrl": { "base": "/broken", "path": null, "queryParams": null }, + "sourceHeaders": [["x-invalid-from-source", "{$context.abcd}"]], + "connectHeaders": null + }, + "response": { + "status": 500, + "headers": [ + ["content-type", "application/json; charset=utf-8"], + ["date", "Tue, 07 Jan 2025 18:34:52 GMT"], + ["content-length", "50"] + ], + "body": { + "kind": "json", + "content": { "message": "It broke!", "errorCode": "BROKEN_THING" }, + "selection": null + }, + "errors": { + "message": "$config.bbbbb", + "sourceExtensions": "fromSource: $config.aaaaa", + "connectExtensions": "fromSource: $context.bbbbb\ncode: errorCode" + } + }, + "problems": [ + { + "location": "SourceQueryParams", + "details": { "message": "Property .abcd not found in object", "path": "$config.abcd", "count": 1 } + }, + { + "location": "SourceHeaders", + "details": { "message": "Property .abcd not found in object", "path": "$context.abcd", "count": 1 } + }, + { + "location": "ErrorsMessage", + "details": { "message": "Property .bbbbb not found in object", "path": "$config.bbbbb", "count": 1 } + }, + { + "location": "SourceErrorsExtensions", + "details": { "message": "Property .aaaaa not found in object", "path": "$config.aaaaa", "count": 1 } + }, + { + "location": "ConnectErrorsExtensions", + "details": { + "message": "Property .bbbbb not found in object", + "path": "$context.bbbbb", + "count": 1 + } + } + ] + } + ] + } + } + } + }, + { + "type": "Stop" + } + ] +} diff --git a/apollo-router/tests/samples/enterprise/connectors-debugging/supergraph.graphql b/apollo-router/tests/samples/enterprise/connectors-debugging/supergraph.graphql new file mode 100644 index 0000000000..8d376da894 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-debugging/supergraph.graphql @@ -0,0 +1,156 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive( + graphs: [CONNECTORS] + name: "link" + args: { url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"] } + ) + @join__directive( + graphs: [CONNECTORS] + name: "source" + args: { + name: "jsonPlaceholder" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [{ name: "x-from", from: "x-test-from" }, { name: "x-invalid-from-source", value: "{$context.abcd}" }] + queryParams: "invalidFromSource: $config.abcd" + } + errors: { message: "$context.aaaaa", extensions: "fromSource: $config.aaaaa" } + } + ) { + query: Query +} + +directive @join__directive( + graphs: [join__Graph!] + name: String! + args: join__DirectiveArguments +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + overrideLabel: String + contextArguments: [join__ContextArgument!] +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Post @join__type(graph: CONNECTORS) { + id: ID! + body: String + title: String + author: User +} + +type Query @join__type(graph: CONNECTORS) { + posts: [Post] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { source: "jsonPlaceholder", http: { GET: "/posts" }, selection: "id title body author: { id: userId }" } + ) + post(id: ID!): Post + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "jsonPlaceholder" + http: { + GET: "/posts/{$args.id}?invalidInConnectUri={$config.aaa}" + headers: [{ name: "x-invalid-from-connect", value: "{$config.aaa}" }] + queryParams: "invalidFromConnect: $config.abcdef" + } + selection: "id title: postTitle body author: { id: userId }" + entity: true + } + ) + user(id: ID!): User + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "jsonPlaceholder" + http: { GET: "/users/{$args.id}" } + selection: "id name username" + entity: true + } + ) + broken: [Post] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { + source: "jsonPlaceholder" + http: { GET: "/broken" } + selection: "id title body author: { id: userId }" + errors: { message: "$config.bbbbb", extensions: "fromSource: $context.bbbbb code: errorCode" } + } + ) +} + +type User @join__type(graph: CONNECTORS) { + id: ID! + name: String + username: String + posts: [Post] + @join__directive( + graphs: [CONNECTORS] + name: "connect" + args: { source: "jsonPlaceholder", http: { GET: "/users/{$this.id}/posts" }, selection: "id title body" } + ) +} diff --git a/apollo-router/tests/samples/enterprise/connectors-debugging/supergraph.yaml b/apollo-router/tests/samples/enterprise/connectors-debugging/supergraph.yaml new file mode 100644 index 0000000000..147460f230 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-debugging/supergraph.yaml @@ -0,0 +1,40 @@ +# rover supergraph compose --config apollo-router/tests/samples/enterprise/connectors-debugging/supergraph.yaml > apollo-router/tests/samples/enterprise/connectors-debugging/supergraph.graphql +federation_version: =2.10.0-preview.3 +subgraphs: + connectors: # required for snapshot overrides + routing_url: http://none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source( + name: "jsonPlaceholder" + http: { + baseURL: "https://jsonplaceholder.typicode.com/" + headers: [{ name: "x-from", from: "x-test-from" }, { name: "x-invalid-from-source", value: "{$context.abcd}" }] + queryParams: "invalidFromSource: $config.abcd" + } + errors: { message: "$context.aaaaa", extensions: "fromSource: $config.aaaaa" } + ) + + type Post { + id: ID! + body: String + title: String + author: User + } + + type Query { + posts: [Post] @connect(source: "jsonPlaceholder", http: {GET: "/posts"}, selection: "id title body author: { id: userId }") + post(id: ID!): Post @connect(source: "jsonPlaceholder", http: {GET: "/posts/{$$args.id}?invalidInConnectUri={$context.aaa}" headers: [{name: "x-invalid-from-connect", value: "{$config.aaa}"}], queryParams: "invalidFromConnect: $config.abcdef"}, selection: "id title: postTitle body author: { id: userId }", entity: true) + user(id: ID!): User @connect(source: "jsonPlaceholder", http: {GET: "/users/{$$args.id}"}, selection: "id name username", entity: true) + broken: [Post] @connect(source: "jsonPlaceholder", http: {GET: "/broken"}, selection: "id title body author: { id: userId }", errors: { message: "$config.bbbbb", extensions: "fromSource: $context.bbbbb code: errorCode" } ) + } + + type User { + id: ID! + name: String + username: String + posts: [Post] @connect(source: "jsonPlaceholder", http: {GET: "/users/{$$this.id}/posts"}, selection: "id title body") + } diff --git a/apollo-router/tests/samples/enterprise/connectors-defer/README.md b/apollo-router/tests/samples/enterprise/connectors-defer/README.md new file mode 100644 index 0000000000..9326529386 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-defer/README.md @@ -0,0 +1 @@ +This tests using defer and connectors. It uses a mutation because there was an expansion bug with mutation root type definitions that appeared only when using defer. \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors-defer/configuration.yaml b/apollo-router/tests/samples/enterprise/connectors-defer/configuration.yaml new file mode 100644 index 0000000000..b9acbc9f42 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-defer/configuration.yaml @@ -0,0 +1,13 @@ +include_subgraph_errors: + all: true + +connectors: + sources: + connectors.jsonPlaceholder: + override_url: http://localhost:4007 + +telemetry: + exporters: + logging: + stdout: + format: text \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors-defer/http_snapshots.json b/apollo-router/tests/samples/enterprise/connectors-defer/http_snapshots.json new file mode 100644 index 0000000000..673dbb3096 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-defer/http_snapshots.json @@ -0,0 +1,38 @@ +[ + { + "request": { + "method": "POST", + "path": "/", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": ["application/json; charset=utf-8"] + }, + "body": { + "f": "1", + "entity": { + "id": "2" + } + } + } + }, + { + "request": { + "method": "GET", + "path": "e/2", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": ["application/json; charset=utf-8"] + }, + "body": { + "id": "2", + "f": "3" + } + } + } +] diff --git a/apollo-router/tests/samples/enterprise/connectors-defer/plan.json b/apollo-router/tests/samples/enterprise/connectors-defer/plan.json new file mode 100644 index 0000000000..a4644fa825 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-defer/plan.json @@ -0,0 +1,41 @@ +{ + "enterprise": true, + "redis": false, + "snapshot": true, + "actions": [ + { + "type": "Start", + "schema_path": "./supergraph.graphql", + "configuration_path": "./configuration.yaml", + "subgraphs": { + "test": { + "snapshot": { + "path": "./http_snapshots.json", + "base_url": "http://localhost:4007/" + } + } + } + }, + { + "type": "Request", + "headers": { + "Accept": "multipart/mixed;deferSpec=20220824" + }, + "request": { + "query": "mutation { m { f ... @defer { entity { id f } } } }" + }, + "expected_response": [ + { "data": { "m": { "f": "1" } }, "hasNext": true }, + { + "hasNext": false, + "incremental": [ + { "data": { "entity": { "id": "2", "f": "3" } }, "path": ["m"] } + ] + } + ] + }, + { + "type": "Stop" + } + ] +} diff --git a/apollo-router/tests/samples/enterprise/connectors-defer/supergraph.graphql b/apollo-router/tests/samples/enterprise/connectors-defer/supergraph.graphql new file mode 100644 index 0000000000..f341d69bbe --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-defer/supergraph.graphql @@ -0,0 +1,83 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "test", http: {baseURL: "http://localhost:4007/"}}) +{ + query: Query + mutation: Mutation +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type E + @join__type(graph: CONNECTORS, key: "id") +{ + id: ID! + f: ID +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type M + @join__type(graph: CONNECTORS) +{ + f: ID + entity: E +} + +type Mutation + @join__type(graph: CONNECTORS) +{ + m: M @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "test", http: {POST: "/"}, selection: "f entity { id }"}) +} + +type Query + @join__type(graph: CONNECTORS) +{ + e(id: ID!): E @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "test", http: {GET: "/e/{$args.id}"}, selection: "f", entity: true}) +} diff --git a/apollo-router/tests/samples/enterprise/connectors-defer/supergraph.yaml b/apollo-router/tests/samples/enterprise/connectors-defer/supergraph.yaml new file mode 100644 index 0000000000..24fc65a323 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-defer/supergraph.yaml @@ -0,0 +1,29 @@ +# rover supergraph compose --config apollo-router/tests/samples/enterprise/connectors-defer/supergraph.yaml > apollo-router/tests/samples/enterprise/connectors-defer/supergraph.graphql +federation_version: =2.10.0-preview.3 +subgraphs: + connectors: # required for snapshot overrides + routing_url: http://none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source(name: "test", http: {baseURL: "http://localhost:4007/"}) + + type Query { + e(id: ID!): E @connect(source: "test", http: { GET: "/e/{$$args.id}" }, selection: "f", entity: true) + } + + type Mutation { + m: M @connect(source: "test", http: { POST: "/" }, selection: "f entity { id }") + } + + type M { + f: ID + entity: E + } + + type E @key(fields: "id") { + id: ID! + f: ID + } \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors-demand-control/README.md b/apollo-router/tests/samples/enterprise/connectors-demand-control/README.md new file mode 100644 index 0000000000..68125ebdac --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-demand-control/README.md @@ -0,0 +1 @@ +Tests using @cost and @listSize directives with connectors \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors-demand-control/configuration.yaml b/apollo-router/tests/samples/enterprise/connectors-demand-control/configuration.yaml new file mode 100644 index 0000000000..9628e1380a --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-demand-control/configuration.yaml @@ -0,0 +1,21 @@ +include_subgraph_errors: + all: true + +demand_control: + enabled: true + mode: enforce + strategy: + static_estimated: + list_size: 1 + max: 100 + +connectors: + sources: + connectors.jsonPlaceholder: + override_url: http://localhost:4008 + +telemetry: + exporters: + logging: + stdout: + format: text \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors-demand-control/http_snapshots.json b/apollo-router/tests/samples/enterprise/connectors-demand-control/http_snapshots.json new file mode 100644 index 0000000000..7f9b9ee00c --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-demand-control/http_snapshots.json @@ -0,0 +1,21 @@ +[ + { + "request": { + "method": "GET", + "path": "/", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": ["application/json; charset=utf-8"] + }, + "body": [ + { + "id": "1", + "f": "2" + } + ] + } + } +] diff --git a/apollo-router/tests/samples/enterprise/connectors-demand-control/plan.json b/apollo-router/tests/samples/enterprise/connectors-demand-control/plan.json new file mode 100644 index 0000000000..fa5b0f5ae4 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-demand-control/plan.json @@ -0,0 +1,48 @@ +{ + "enterprise": true, + "redis": false, + "snapshot": true, + "actions": [ + { + "type": "Start", + "schema_path": "./supergraph.graphql", + "configuration_path": "./configuration.yaml", + "subgraphs": { + "test": { + "snapshot": { + "path": "./http_snapshots.json", + "base_url": "http://localhost:4008/" + } + } + } + }, + { + "type": "Request", + "request": { + "query": "{ f { id } }" + }, + "expected_response": { "data": { "f": [{ "id": "1" }] } } + }, + { + "type": "Request", + "request": { + "query": "{ f { id f } }" + }, + "expected_response": { + "errors": [ + { + "message": "query estimated cost 110 exceeded configured maximum 100", + "extensions": { + "cost.estimated": 110.0, + "cost.max": 100.0, + "code": "COST_ESTIMATED_TOO_EXPENSIVE" + } + } + ] + } + }, + { + "type": "Stop" + } + ] +} diff --git a/apollo-router/tests/samples/enterprise/connectors-demand-control/supergraph.graphql b/apollo-router/tests/samples/enterprise/connectors-demand-control/supergraph.graphql new file mode 100644 index 0000000000..14cf6a870f --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-demand-control/supergraph.graphql @@ -0,0 +1,74 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "test", http: {baseURL: "http://localhost:4007/"}}) +{ + query: Query +} + +directive @cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "http://none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + f: [T] @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "test", http: {GET: "/"}, selection: "id f"}) @listSize(assumedSize: 10) +} + +type T + @join__type(graph: CONNECTORS) +{ + id: ID! + f: String @cost(weight: 10) +} diff --git a/apollo-router/tests/samples/enterprise/connectors-demand-control/supergraph.yaml b/apollo-router/tests/samples/enterprise/connectors-demand-control/supergraph.yaml new file mode 100644 index 0000000000..682a5499f5 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-demand-control/supergraph.yaml @@ -0,0 +1,21 @@ +# rover supergraph compose --config apollo-router/tests/samples/enterprise/connectors-demand-control/supergraph.yaml > apollo-router/tests/samples/enterprise/connectors-demand-control/supergraph.graphql +federation_version: =2.10.0-preview.4 +subgraphs: + connectors: # required for snapshot overrides + routing_url: http://none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key", "@cost", "@listSize"]) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source(name: "test", http: {baseURL: "http://localhost:4007/"}) + + type Query { + f: [T] @listSize(assumedSize: 10) + @connect(source: "test", http: { GET: "/" }, selection: "id f") + } + + type T { + id: ID! + f: String @cost(weight: 10) + } \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors-pqs/README.md b/apollo-router/tests/samples/enterprise/connectors-pqs/README.md new file mode 100644 index 0000000000..3a775dcbb8 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-pqs/README.md @@ -0,0 +1,2 @@ +This is a regression test for a problem that occurs when using PQs and connectors. See +apollo-federation/src/connectors/json_selection/selection_set.rs diff --git a/apollo-router/tests/samples/enterprise/connectors-pqs/configuration.yaml b/apollo-router/tests/samples/enterprise/connectors-pqs/configuration.yaml new file mode 100644 index 0000000000..5b477aab1c --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-pqs/configuration.yaml @@ -0,0 +1,19 @@ +include_subgraph_errors: + all: true + +connectors: + sources: + connectors.one: + override_url: http://localhost:4001 + +telemetry: + exporters: + logging: + stdout: + format: text + +persisted_queries: + enabled: true + log_unknown: true + local_manifests: + - tests/samples/enterprise/connectors-pqs/manifest.json diff --git a/apollo-router/tests/samples/enterprise/connectors-pqs/http_snapshots.json b/apollo-router/tests/samples/enterprise/connectors-pqs/http_snapshots.json new file mode 100644 index 0000000000..f2f28d710a --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-pqs/http_snapshots.json @@ -0,0 +1,51 @@ +[ + { + "request": { + "method": "GET", + "path": "api/search?query=Boston", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "Locations": [ + { + "Coords": { + "Lat": "1.1", + "Lon": "2.2" + } + } + ] + } + } + }, + { + "request": { + "method": "GET", + "path": "weather/1.1,2.2", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "currentConditions": { + "snow": 1.1, + "temp": 2.2, + "windspeed": 3.3, + "conditions": "cold", + "snowdepth": 4.4 + } + } + } + } +] \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors-pqs/manifest.json b/apollo-router/tests/samples/enterprise/connectors-pqs/manifest.json new file mode 100644 index 0000000000..daa02b5a05 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-pqs/manifest.json @@ -0,0 +1,12 @@ +{ + "format": "apollo-persisted-query-manifest", + "version": 1, + "operations": [ + { + "id": "87b3393750af706fde5c3bbdf37533012f4d4eeecb82f621b596c34249428153", + "name": "getWeatherData", + "type": "query", + "body": "query getWeatherData($search: String!) {\n geoByAddress(search: $search) {\n lat\n long\n weather {\n conditions\n forecastSnowFall\n temperature\n windSpeed\n currentSnowDepth\n __typename\n }\n __typename\n }\n}" + } + ] +} \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors-pqs/plan.json b/apollo-router/tests/samples/enterprise/connectors-pqs/plan.json new file mode 100644 index 0000000000..1c62249226 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-pqs/plan.json @@ -0,0 +1,54 @@ +{ + "enterprise": true, + "redis": false, + "snapshot": true, + "actions": [ + { + "type": "Start", + "schema_path": "./supergraph.graphql", + "configuration_path": "./configuration.yaml", + "subgraphs": { + "one": { + "snapshot": { + "path": "./http_snapshots.json", + "base_url": "http://localhost:4001" + } + } + } + }, + { + "type": "Request", + "request": { + "variables": { + "search": "Boston" + }, + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "87b3393750af706fde5c3bbdf37533012f4d4eeecb82f621b596c34249428153" + } + } + }, + "expected_response": { + "data": { + "geoByAddress": { + "lat": "1.1", + "long": "2.2", + "weather": { + "conditions": "cold", + "forecastSnowFall": 1.1, + "temperature": 2.2, + "windSpeed": 3.3, + "currentSnowDepth": 4.4, + "__typename": "Weather" + }, + "__typename": "Geo" + } + } + } + }, + { + "type": "Stop" + } + ] +} \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors-pqs/supergraph.graphql b/apollo-router/tests/samples/enterprise/connectors-pqs/supergraph.graphql new file mode 100644 index 0000000000..aec7d5414b --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-pqs/supergraph.graphql @@ -0,0 +1,83 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "one", http: {baseURL: "http://localhost:4001"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type Geo + @join__type(graph: CONNECTORS) +{ + lat: String! + long: String! + weather: Weather +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + geoByAddress(search: String!): Geo @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "one", http: {GET: "/api/search?query={$args.search}"}, selection: "lat: Locations->first.Coords.Lat\nlong: Locations->first.Coords.Lon\nweather: {\n lat: Locations->first.Coords.Lat\n long: Locations->first.Coords.Lon\n}"}) + getWeatherData(lat: String!, long: String!): Weather @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "one", http: {GET: "/weather/{$args.lat},{$args.long}"}, selection: "lat: $args.lat\nlong: $args.long\n$.currentConditions {\n forecastSnowFall: snow\n temperature: temp\n windSpeed: windspeed\n conditions\n currentSnowDepth: snowdepth\n}", entity: true}) +} + +type Weather + @join__type(graph: CONNECTORS, key: "lat long", resolvable: false) +{ + lat: String! + long: String! + temperature: Float + windSpeed: Float + conditions: String + forecastSnowFall: Float + currentSnowDepth: Float +} diff --git a/apollo-router/tests/samples/enterprise/connectors-pqs/supergraph.yaml b/apollo-router/tests/samples/enterprise/connectors-pqs/supergraph.yaml new file mode 100644 index 0000000000..7059969fa1 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors-pqs/supergraph.yaml @@ -0,0 +1,63 @@ +federation_version: =2.10.0-preview.6 +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"]) + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source(name: "one", http: { baseURL: "http://localhost:4001" }) + + type Geo { + lat: String! + long: String! + weather: Weather + } + + type Weather @key(fields: "lat long", resolvable: false) { + lat: String! + long: String! + temperature: Float + windSpeed: Float + conditions: String + forecastSnowFall: Float + currentSnowDepth: Float + } + + type Query { + geoByAddress(search: String!): Geo + @connect( + source: "one" + http: { GET: "/api/search?query={$args.search}" } + selection: """ + lat: Locations->first.Coords.Lat + long: Locations->first.Coords.Lon + weather: { + lat: Locations->first.Coords.Lat + long: Locations->first.Coords.Lon + } + """ + ) + + getWeatherData (lat: String!, long: String!) : Weather + @connect( + source: "one" + http: { GET: "/weather/{$args.lat},{$args.long}" } + selection: """ + lat: $args.lat + long: $args.long + $.currentConditions { + forecastSnowFall: snow + temperature: temp + windSpeed: windspeed + conditions + currentSnowDepth: snowdepth + } + """ + entity: true + ) + } diff --git a/apollo-router/tests/samples/enterprise/connectors/README.md b/apollo-router/tests/samples/enterprise/connectors/README.md new file mode 100644 index 0000000000..16f7564531 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors/README.md @@ -0,0 +1 @@ +Example testing the connectors feature \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors/configuration.yaml b/apollo-router/tests/samples/enterprise/connectors/configuration.yaml new file mode 100644 index 0000000000..6ddf562c0e --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors/configuration.yaml @@ -0,0 +1,15 @@ +override_subgraph_url: + connectors: http://localhost:4005 +include_subgraph_errors: + all: true + +connectors: + sources: + connectors.jsonPlaceholder: + override_url: http://localhost:4007 + +telemetry: + exporters: + logging: + stdout: + format: text \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors/http_snapshots.json b/apollo-router/tests/samples/enterprise/connectors/http_snapshots.json new file mode 100644 index 0000000000..2373dbdc5b --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors/http_snapshots.json @@ -0,0 +1,61 @@ +[ + { + "request": { + "method": "GET", + "path": "posts/1", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "userId": 1, + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + } + } + }, + { + "request": { + "method": "GET", + "path": "users/1", + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json; charset=utf-8" + ] + }, + "body": { + "id": 1, + "name": "Leanne Graham", + "username": "Bret", + "email": "Sincere@april.biz", + "address": { + "street": "Kulas Light", + "suite": "Apt. 556", + "city": "Gwenborough", + "zipcode": "92998-3874", + "geo": { + "lat": "-37.3159", + "lng": "81.1496" + } + }, + "phone": "1-770-736-8031 x56442", + "website": "hildegard.org", + "company": { + "name": "Romaguera-Crona", + "catchPhrase": "Multi-layered client-server neural-net", + "bs": "harness real-time e-markets" + } + } + } + } +] \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/connectors/plan.json b/apollo-router/tests/samples/enterprise/connectors/plan.json new file mode 100644 index 0000000000..9bd3eda34b --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors/plan.json @@ -0,0 +1,40 @@ +{ + "enterprise": true, + "redis": false, + "snapshot": true, + "actions": [ + { + "type": "Start", + "schema_path": "./supergraph.graphql", + "configuration_path": "./configuration.yaml", + "subgraphs": { + "jsonPlaceholder": { + "snapshot": { + "path": "./http_snapshots.json", + "base_url": "https://jsonplaceholder.typicode.com/" + } + } + } + }, + { + "type": "Request", + "request": { + "query": "query { post(id: 1) { id author { name } title } }" + }, + "expected_response": { + "data": { + "post": { + "id": 1, + "author": { + "name": "Leanne Graham" + }, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + } + } + } + }, + { + "type": "Stop" + } + ] +} diff --git a/apollo-router/tests/samples/enterprise/connectors/supergraph.graphql b/apollo-router/tests/samples/enterprise/connectors/supergraph.graphql new file mode 100644 index 0000000000..ae088619ce --- /dev/null +++ b/apollo-router/tests/samples/enterprise/connectors/supergraph.graphql @@ -0,0 +1,81 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "jsonPlaceholder", http: {baseURL: "https://jsonplaceholder.typicode.com/"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Post + @join__type(graph: CONNECTORS) +{ + id: ID! + body: String + title: String + author: User +} + +type Query + @join__type(graph: CONNECTORS) +{ + posts: [Post] @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "jsonPlaceholder", http: {GET: "/posts"}, selection: "id\ntitle\nbody\nauthor: { id: userId }"}) + post(id: ID!): Post @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "jsonPlaceholder", http: {GET: "/posts/{$args.id}"}, selection: "id\ntitle\nbody\nauthor: { id: userId }", entity: true}) + user(id: ID!): User @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "jsonPlaceholder", http: {GET: "/users/{$args.id}"}, selection: "id\nname\nusername", entity: true}) +} + +type User + @join__type(graph: CONNECTORS) +{ + id: ID! + name: String + username: String + posts: [Post] @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "jsonPlaceholder", http: {GET: "/users/{$this.id}/posts"}, selection: "id\ntitle\nbody"}) +} \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/entity-cache/defer/configuration.yaml b/apollo-router/tests/samples/enterprise/entity-cache/defer/configuration.yaml index fb6b95ecd4..662ba73891 100644 --- a/apollo-router/tests/samples/enterprise/entity-cache/defer/configuration.yaml +++ b/apollo-router/tests/samples/enterprise/entity-cache/defer/configuration.yaml @@ -5,12 +5,12 @@ include_subgraph_errors: preview_entity_cache: enabled: true - redis: - urls: - ["redis://localhost:6379",] subgraph: all: enabled: true + redis: + urls: + ["redis://localhost:6379",] subgraphs: reviews: ttl: 120s @@ -20,4 +20,4 @@ telemetry: exporters: logging: stdout: - format: text \ No newline at end of file + format: text diff --git a/apollo-router/tests/samples/enterprise/entity-cache/defer/plan.json b/apollo-router/tests/samples/enterprise/entity-cache/defer/plan.json index 83a777d329..8024ac0dcf 100644 --- a/apollo-router/tests/samples/enterprise/entity-cache/defer/plan.json +++ b/apollo-router/tests/samples/enterprise/entity-cache/defer/plan.json @@ -12,7 +12,7 @@ { "request": { "body": { - "query": "query CacheDefer__cache_defer_accounts__0{me{__typename name id}}", + "query": "query CacheDefer__cache_defer_accounts__0 { me { __typename name id } }", "operationName": "CacheDefer__cache_defer_accounts__0" } }, @@ -39,7 +39,7 @@ { "request": { "body": { - "query": "query CacheDefer__cache_defer_reviews__1($representations:[_Any!]!){_entities(representations:$representations){..._generated_onUser1_0}}fragment _generated_onUser1_0 on User{reviews{body}}", + "query": "query CacheDefer__cache_defer_reviews__1($representations: [_Any!]!) { _entities(representations: $representations) { ... on User { reviews { body } } } }", "operationName": "CacheDefer__cache_defer_reviews__1", "variables": { "representations": [ @@ -110,4 +110,4 @@ "type": "Stop" } ] -} +} \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/plan.json b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/plan.json index c8588ed2b7..043cf89ab2 100644 --- a/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/plan.json +++ b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/plan.json @@ -12,7 +12,7 @@ { "request": { "body": { - "query":"query InvalidationEntityKey__invalidation_entity_key_products__0{topProducts{__typename upc}}", + "query":"query InvalidationEntityKey__invalidation_entity_key_products__0 { topProducts { __typename upc } }", "operationName": "InvalidationEntityKey__invalidation_entity_key_products__0" } }, @@ -31,7 +31,7 @@ { "request": { "body": { - "query":"query InvalidationEntityKey__invalidation_entity_key_reviews__1($representations:[_Any!]!){_entities(representations:$representations){..._generated_onProduct1_0}}fragment _generated_onProduct1_0 on Product{reviews{body}}", + "query":"query InvalidationEntityKey__invalidation_entity_key_reviews__1($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviews { body } } } }", "operationName": "InvalidationEntityKey__invalidation_entity_key_reviews__1", "variables":{"representations":[{"upc":"0","__typename":"Product"},{"upc":"1","__typename":"Product"}]} } @@ -90,7 +90,8 @@ { "request": { "body": { - "query":"mutation InvalidationEntityKey__invalidation_entity_key_reviews__0{invalidateProductReview}" + "query":"mutation InvalidationEntityKey__invalidation_entity_key_reviews__0 { invalidateProductReview }", + "operationName": "InvalidationEntityKey__invalidation_entity_key_reviews__0" } }, "response": { @@ -115,7 +116,8 @@ { "request": { "body": { - "query":"query InvalidationEntityKey__invalidation_entity_key_reviews__1($representations:[_Any!]!){_entities(representations:$representations){..._generated_onProduct1_0}}fragment _generated_onProduct1_0 on Product{reviews{body}}", + "query": "query InvalidationEntityKey__invalidation_entity_key_reviews__1($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviews { body } } } }", + "operationName": "InvalidationEntityKey__invalidation_entity_key_reviews__1", "variables":{"representations":[{"upc":"1","__typename":"Product"}]} } }, @@ -202,7 +204,8 @@ { "request": { "body": { - "query":"query InvalidationEntityKey__invalidation_entity_key_reviews__1($representations:[_Any!]!){_entities(representations:$representations){..._generated_onProduct1_0}}fragment _generated_onProduct1_0 on Product{reviews{body}}", + "query":"query InvalidationEntityKey__invalidation_entity_key_reviews__1($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviews { body } } } }", + "operationName": "InvalidationEntityKey__invalidation_entity_key_reviews__1", "variables":{"representations":[{"upc":"1","__typename":"Product"}]} } }, diff --git a/apollo-router/tests/samples/enterprise/entity-cache/invalidation-subgraph-name/plan.json b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-subgraph-name/plan.json index 9bbbd1d90c..b60d008b38 100644 --- a/apollo-router/tests/samples/enterprise/entity-cache/invalidation-subgraph-name/plan.json +++ b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-subgraph-name/plan.json @@ -11,7 +11,7 @@ "requests": [ { "request": { - "body": {"query":"query InvalidationSubgraphName__invalidation_subgraph_name_accounts__0{me{name}}"} + "body": {"query":"query InvalidationSubgraphName__invalidation_subgraph_name_accounts__0 { me { name } }"} }, "response": { "headers": { @@ -45,7 +45,7 @@ "requests": [ { "request": { - "body": {"query":"mutation InvalidationSubgraphName__invalidation_subgraph_name_accounts__0{updateMyAccount{name}}"} + "body": {"query":"mutation InvalidationSubgraphName__invalidation_subgraph_name_accounts__0 { updateMyAccount { name } }"} }, "response": { "headers": { @@ -99,7 +99,7 @@ "requests": [ { "request": { - "body": {"query":"query InvalidationSubgraphName__invalidation_subgraph_name_accounts__0{me{name}}"} + "body": {"query":"query InvalidationSubgraphName__invalidation_subgraph_name_accounts__0 { me { name } }"} }, "response": { "headers": { diff --git a/apollo-router/tests/samples/enterprise/entity-cache/invalidation-subgraph-type/plan.json b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-subgraph-type/plan.json index 72e39a7b80..5c2a63dd6d 100644 --- a/apollo-router/tests/samples/enterprise/entity-cache/invalidation-subgraph-type/plan.json +++ b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-subgraph-type/plan.json @@ -11,7 +11,10 @@ "requests": [ { "request": { - "body": {"query":"query InvalidationSubgraphType__invalidation_subgraph_type_accounts__0{me{name id}}","operationName":"InvalidationSubgraphType__invalidation_subgraph_type_accounts__0"} + "body": { + "query": "query InvalidationSubgraphType__invalidation_subgraph_type_accounts__0 { me { name id } }", + "operationName": "InvalidationSubgraphType__invalidation_subgraph_type_accounts__0" + } }, "response": { "headers": { @@ -83,7 +86,10 @@ "requests": [ { "request": { - "body": {"query":"query InvalidationSubgraphType__invalidation_subgraph_type_accounts__0{me{name id}}", "operationName":"InvalidationSubgraphType__invalidation_subgraph_type_accounts__0"} + "body": { + "query": "query InvalidationSubgraphType__invalidation_subgraph_type_accounts__0 { me { name id } }", + "operationName": "InvalidationSubgraphType__invalidation_subgraph_type_accounts__0" + } }, "response": { "headers": { diff --git a/apollo-router/tests/samples/enterprise/entity-cache/private/configuration.yaml b/apollo-router/tests/samples/enterprise/entity-cache/private/configuration.yaml index 65dd9ebad1..29f8e50116 100644 --- a/apollo-router/tests/samples/enterprise/entity-cache/private/configuration.yaml +++ b/apollo-router/tests/samples/enterprise/entity-cache/private/configuration.yaml @@ -9,13 +9,13 @@ rhai: preview_entity_cache: enabled: true - redis: - urls: - ["redis://localhost:6379",] subgraph: all: + redis: + urls: + ["redis://localhost:6379",] enabled: true ttl: 10s subgraphs: accounts: - private_id: "user" \ No newline at end of file + private_id: "user" diff --git a/apollo-router/tests/samples/enterprise/entity-cache/private/plan.json b/apollo-router/tests/samples/enterprise/entity-cache/private/plan.json index b466291766..ecb3b32e71 100644 --- a/apollo-router/tests/samples/enterprise/entity-cache/private/plan.json +++ b/apollo-router/tests/samples/enterprise/entity-cache/private/plan.json @@ -11,7 +11,7 @@ "requests": [ { "request": { - "body": {"query":"query private__accounts__0{me{name}}"} + "body": {"query":"query private__accounts__0 { me { name } }"} }, "response": { "headers": { @@ -48,7 +48,7 @@ "requests": [ { "request": { - "body": {"query":"query private__accounts__0{me{name}}"} + "body": {"query":"query private__accounts__0 { me { name } }"} }, "response": { "headers": { diff --git a/apollo-router/tests/samples/enterprise/persisted-queries/basic/README.md b/apollo-router/tests/samples/enterprise/persisted-queries/basic/README.md new file mode 100644 index 0000000000..09cba4f996 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/persisted-queries/basic/README.md @@ -0,0 +1,3 @@ +# Persisted Queries + +This tests Persisted Query Lists: https://www.apollographql.com/docs/graphos/routing/security/persisted-queries diff --git a/apollo-router/tests/samples/enterprise/persisted-queries/basic/configuration.yaml b/apollo-router/tests/samples/enterprise/persisted-queries/basic/configuration.yaml new file mode 100644 index 0000000000..cbaa62f19e --- /dev/null +++ b/apollo-router/tests/samples/enterprise/persisted-queries/basic/configuration.yaml @@ -0,0 +1,14 @@ +persisted_queries: + enabled: true + local_manifests: + - tests/samples/enterprise/persisted-queries/basic/persisted-query-manifest.json +apq: + enabled: false +telemetry: + apollo: + client_name_header: custom-client-name +rhai: + scripts: "tests/samples/enterprise/persisted-queries/basic/rhai" + main: "main.rhai" +include_subgraph_errors: + all: true diff --git a/apollo-router/tests/samples/enterprise/persisted-queries/basic/persisted-query-manifest.json b/apollo-router/tests/samples/enterprise/persisted-queries/basic/persisted-query-manifest.json new file mode 100644 index 0000000000..1659290d24 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/persisted-queries/basic/persisted-query-manifest.json @@ -0,0 +1,15 @@ +{ + "format": "apollo-persisted-query-manifest", + "version": 1, + "operations": [ + { + "id": "GetMyNameID", + "body": "query GetMyName { me { name } }" + }, + { + "id": "GetMyNameID", + "clientName": "mobile", + "body": "query GetMyName { me { mobileAlias: name } }" + } + ] +} diff --git a/apollo-router/tests/samples/enterprise/persisted-queries/basic/plan.yaml b/apollo-router/tests/samples/enterprise/persisted-queries/basic/plan.yaml new file mode 100644 index 0000000000..82c7a680fc --- /dev/null +++ b/apollo-router/tests/samples/enterprise/persisted-queries/basic/plan.yaml @@ -0,0 +1,129 @@ +enterprise: true + +actions: +- type: Start + schema_path: ./supergraph.graphql + configuration_path: ./configuration.yaml + subgraphs: + accounts: + requests: + - request: + body: + query: "query GetMyName__accounts__0 { me { name } }" + response: + body: + data: + me: + name: "Ada Lovelace" + - request: + body: + query: "query GetMyName__accounts__0 { me { mobileAlias: name } }" + response: + body: + data: + me: + mobileAlias: "Ada Lovelace" + - request: + body: + query: "query GetYourName__accounts__0 { you: me { name } }" + response: + body: + data: + you: + name: "Ada Lovelace" + +# Successfully run a persisted query. +- type: Request + description: "Run a persisted query" + request: + extensions: + persistedQuery: + version: 1 + sha256Hash: "GetMyNameID" + expected_response: + data: + me: + name: "Ada Lovelace" + +# Successfully run a persisted query with client name that has its own +# operation, using the client name header configured in +# `telemetry.apollo.client_name_header`. +- type: Request + description: "Run a persisted query with client_name_header" + request: + extensions: + persistedQuery: + version: 1 + sha256Hash: "GetMyNameID" + headers: + custom-client-name: mobile + expected_response: + data: + me: + mobileAlias: "Ada Lovelace" + +# Successfully run a persisted query with client name that has its own +# operation, setting the client name via context in a Rhai plugin. +- type: Request + description: "Run a persisted query with plugin-set client name" + request: + extensions: + persistedQuery: + version: 1 + sha256Hash: "GetMyNameID" + headers: + plugin-client-name: mobile + expected_response: + data: + me: + mobileAlias: "Ada Lovelace" + +# Successfully run a persisted query with random client name falling back to the +# version without client name. +- type: Request + description: "Run a persisted query with fallback client name" + request: + extensions: + persistedQuery: + version: 1 + sha256Hash: "GetMyNameID" + headers: + custom-client-name: something-not-mobile + expected_response: + data: + me: + name: "Ada Lovelace" + +- type: Request + description: "Unknown persisted query ID" + request: + extensions: + persistedQuery: + version: 1 + sha256Hash: "unknown_query" + expected_response: + errors: + - message: "Persisted query 'unknown_query' not found in the persisted query list" + extensions: + code: PERSISTED_QUERY_NOT_IN_LIST + +- type: Request + description: "Normal non-PQ POSTs work" + request: + query: "query GetYourName { you: me { name } }" + expected_response: + data: + you: + name: "Ada Lovelace" + +- type: Request + description: "Proper error when sending malformed request body" + request: "" + expected_response: + errors: + - message: "Invalid GraphQL request" + extensions: + code: INVALID_GRAPHQL_REQUEST + details: 'failed to deserialize the request body into JSON: invalid type: string "", expected a GraphQL request at line 1 column 2' + +- type: Stop diff --git a/apollo-router/tests/samples/enterprise/persisted-queries/basic/rhai/main.rhai b/apollo-router/tests/samples/enterprise/persisted-queries/basic/rhai/main.rhai new file mode 100644 index 0000000000..b31e749479 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/persisted-queries/basic/rhai/main.rhai @@ -0,0 +1,7 @@ +fn router_service(service) { + service.map_request(|request| { + if (request.headers.contains("plugin-client-name")) { + request.context["apollo_persisted_queries::client_name"] = request.headers["plugin-client-name"]; + } + }); +} diff --git a/apollo-router/tests/samples/enterprise/persisted-queries/basic/supergraph.graphql b/apollo-router/tests/samples/enterprise/persisted-queries/basic/supergraph.graphql new file mode 100644 index 0000000000..c5a920730a --- /dev/null +++ b/apollo-router/tests/samples/enterprise/persisted-queries/basic/supergraph.graphql @@ -0,0 +1,124 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { + query: Query + mutation: Mutation +} + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @tag( + name: String! +) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar join__FieldSet +scalar link__Import + +enum join__Graph { + ACCOUNTS + @join__graph(name: "accounts", url: "https://accounts.demo.starstuff.dev/") + INVENTORY + @join__graph( + name: "inventory" + url: "https://inventory.demo.starstuff.dev/" + ) + PRODUCTS + @join__graph(name: "products", url: "https://products.demo.starstuff.dev/") + REVIEWS + @join__graph(name: "reviews", url: "https://reviews.demo.starstuff.dev/") +} + +enum link__Purpose { + SECURITY + EXECUTION +} + +type Mutation @join__type(graph: PRODUCTS) @join__type(graph: REVIEWS) { + createProduct(upc: ID!, name: String): Product @join__field(graph: PRODUCTS) + createReview(upc: ID!, id: ID!, body: String): Review + @join__field(graph: REVIEWS) +} + +type Product + @join__type(graph: INVENTORY, key: "upc") + @join__type(graph: PRODUCTS, key: "upc") + @join__type(graph: REVIEWS, key: "upc") { + inStock: Boolean + @join__field(graph: INVENTORY) + @tag(name: "private") + @inaccessible + name: String @join__field(graph: PRODUCTS) + weight: Int @join__field(graph: INVENTORY, external: true) @join__field(graph: PRODUCTS) + price: Int @join__field(graph: INVENTORY, external: true) @join__field(graph: PRODUCTS) + reviews: [Review] @join__field(graph: REVIEWS) + reviewsForAuthor(authorID: ID!): [Review] @join__field(graph: REVIEWS) + shippingEstimate: Int @join__field(graph: INVENTORY, requires: "price weight") + upc: String! +} + +type Query + @join__type(graph: ACCOUNTS) + @join__type(graph: INVENTORY) + @join__type(graph: PRODUCTS) + @join__type(graph: REVIEWS) { + me: User @join__field(graph: ACCOUNTS) + topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCTS) +} + +type Review @join__type(graph: REVIEWS, key: "id") { + id: ID! + author: User @join__field(graph: REVIEWS, provides: "username") + body: String @join__field(graph: REVIEWS) + product: Product @join__field(graph: REVIEWS) +} + +type User + @join__type(graph: ACCOUNTS, key: "id") + @join__type(graph: REVIEWS, key: "id") { + id: ID! + name: String @join__field(graph: ACCOUNTS) + username: String + @join__field(graph: ACCOUNTS) + @join__field(graph: REVIEWS, external: true) + reviews: [Review] @join__field(graph: REVIEWS) +} diff --git a/apollo-router/tests/samples/enterprise/progressive-override/basic/configuration.yaml b/apollo-router/tests/samples/enterprise/progressive-override/basic/configuration.yaml index 321fd80abd..3f64e39a39 100644 --- a/apollo-router/tests/samples/enterprise/progressive-override/basic/configuration.yaml +++ b/apollo-router/tests/samples/enterprise/progressive-override/basic/configuration.yaml @@ -7,7 +7,5 @@ telemetry: stdout: format: text -experimental_query_planner_mode: legacy - plugins: experimental.expose_query_plan: true \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/progressive-override/basic/configuration2.yaml b/apollo-router/tests/samples/enterprise/progressive-override/basic/configuration2.yaml index 7c445ce5b2..44ce15d711 100644 --- a/apollo-router/tests/samples/enterprise/progressive-override/basic/configuration2.yaml +++ b/apollo-router/tests/samples/enterprise/progressive-override/basic/configuration2.yaml @@ -7,8 +7,6 @@ telemetry: stdout: format: text -experimental_query_planner_mode: legacy - rhai: scripts: "tests/samples/enterprise/progressive-override/basic/rhai" main: "main.rhai" diff --git a/apollo-router/tests/samples/enterprise/progressive-override/basic/plan.json b/apollo-router/tests/samples/enterprise/progressive-override/basic/plan.json index 6cdf9d7b60..6bcbdecf5a 100644 --- a/apollo-router/tests/samples/enterprise/progressive-override/basic/plan.json +++ b/apollo-router/tests/samples/enterprise/progressive-override/basic/plan.json @@ -11,7 +11,7 @@ { "request": { "body": { - "query": "query progressive1__Subgraph1__0{percent100{__typename id}}", + "query": "query progressive1__Subgraph1__0 { percent100 { __typename id } }", "operationName": "progressive1__Subgraph1__0" } }, @@ -33,7 +33,7 @@ { "request": { "body": { - "query": "query progressive1__Subgraph2__1($representations:[_Any!]!){_entities(representations:$representations){...on T{foo}}}", + "query": "query progressive1__Subgraph2__1($representations: [_Any!]!) { _entities(representations: $representations) { ... on T { foo } } }", "operationName": "progressive1__Subgraph2__1", "variables": { "representations": [ @@ -60,7 +60,7 @@ { "request": { "body": { - "query": "query progressive2__Subgraph2__0{percent0{foo}}", + "query": "query progressive2__Subgraph2__0 { percent0 { foo } }", "operationName": "progressive2__Subgraph2__0" } }, @@ -119,7 +119,7 @@ { "request": { "body": { - "query": "query progressive3__Subgraph1__0{percent100{__typename id}}", + "query": "query progressive3__Subgraph1__0 { percent100 { __typename id } }", "operationName": "progressive3__Subgraph1__0" } }, @@ -137,7 +137,7 @@ { "request": { "body": { - "query": "query progressive4__Subgraph1__0{percent100{bar}}", + "query": "query progressive4__Subgraph1__0 { percent100 { bar } }", "operationName": "progressive4__Subgraph1__0" } }, @@ -158,7 +158,7 @@ { "request": { "body": { - "query": "query progressive3__Subgraph2__1($representations:[_Any!]!){_entities(representations:$representations){...on T{bar}}}", + "query": "query progressive3__Subgraph2__1($representations: [_Any!]!) { _entities(representations: $representations) { ... on T { bar } } }", "operationName": "progressive3__Subgraph2__1", "variables": { "representations": [ diff --git a/apollo-router/tests/samples/enterprise/progressive-override/basic/rhai/main.rhai b/apollo-router/tests/samples/enterprise/progressive-override/basic/rhai/main.rhai index 3ecb55cca9..81891750e6 100644 --- a/apollo-router/tests/samples/enterprise/progressive-override/basic/rhai/main.rhai +++ b/apollo-router/tests/samples/enterprise/progressive-override/basic/rhai/main.rhai @@ -6,16 +6,16 @@ fn supergraph_service(service) { // Add a timestamp to context which we'll use in the response. fn process_request(request) { request.context["request_start"] = Router.APOLLO_START.elapsed; - let labels = request.context["apollo_override::unresolved_labels"]; + let labels = request.context["apollo::progressive_override::unresolved_labels"]; print(`unresolved: ${labels}`); - let override = request.context["apollo_override::labels_to_override"]; + let override = request.context["apollo::progressive_override::labels_to_override"]; print(`override: ${override}`); if "x-override" in request.headers { if request.headers["x-override"] == "true" { - request.context["apollo_override::labels_to_override"] += "bar"; + request.context["apollo::progressive_override::labels_to_override"] += "bar"; } } } diff --git a/apollo-router/tests/samples/enterprise/progressive-override/warmup/configuration.yaml b/apollo-router/tests/samples/enterprise/progressive-override/warmup/configuration.yaml index b069d5af18..8d54c2ee26 100644 --- a/apollo-router/tests/samples/enterprise/progressive-override/warmup/configuration.yaml +++ b/apollo-router/tests/samples/enterprise/progressive-override/warmup/configuration.yaml @@ -11,7 +11,5 @@ telemetry: stdout: format: text -experimental_query_planner_mode: legacy - plugins: experimental.expose_query_plan: true \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/progressive-override/warmup/configuration2.yaml b/apollo-router/tests/samples/enterprise/progressive-override/warmup/configuration2.yaml index 413d26aba3..fa34365889 100644 --- a/apollo-router/tests/samples/enterprise/progressive-override/warmup/configuration2.yaml +++ b/apollo-router/tests/samples/enterprise/progressive-override/warmup/configuration2.yaml @@ -11,8 +11,6 @@ telemetry: stdout: format: text -experimental_query_planner_mode: legacy - # rhai: # scripts: "tests/samples/enterprise/progressive-override/rhai" # main: "main.rhai" diff --git a/apollo-router/tests/samples/enterprise/progressive-override/warmup/plan.json b/apollo-router/tests/samples/enterprise/progressive-override/warmup/plan.json index 8f913eb5be..288a933d4b 100644 --- a/apollo-router/tests/samples/enterprise/progressive-override/warmup/plan.json +++ b/apollo-router/tests/samples/enterprise/progressive-override/warmup/plan.json @@ -11,7 +11,7 @@ { "request": { "body": { - "query": "query progressive1__Subgraph1__0{percent100{__typename id}}", + "query": "query progressive1__Subgraph1__0 { percent100 { __typename id } }", "operationName": "progressive1__Subgraph1__0" } }, @@ -33,7 +33,7 @@ { "request": { "body": { - "query": "query progressive1__Subgraph2__1($representations:[_Any!]!){_entities(representations:$representations){...on T{foo}}}", + "query": "query progressive1__Subgraph2__1($representations: [_Any!]!) { _entities(representations: $representations) { ... on T { foo } } }", "operationName": "progressive1__Subgraph2__1", "variables": { "representations": [ @@ -60,7 +60,7 @@ { "request": { "body": { - "query": "query progressive2__Subgraph2__0{percent0{foo}}", + "query": "query progressive2__Subgraph2__0 { percent0 { foo } }", "operationName": "progressive2__Subgraph2__0" } }, @@ -106,7 +106,7 @@ { "request": { "body": { - "query": "query progressive1__Subgraph1__0{percent100{__typename id}}", + "query": "query progressive1__Subgraph1__0 { percent100 { __typename id } }", "operationName": "progressive1__Subgraph1__0" } }, @@ -128,7 +128,7 @@ { "request": { "body": { - "query": "query progressive1__Subgraph2__1($representations:[_Any!]!){_entities(representations:$representations){...on T{foo}}}", + "query": "query progressive1__Subgraph2__1($representations: [_Any!]!) { _entities(representations: $representations) { ... on T { foo } } }", "operationName": "progressive1__Subgraph2__1", "variables": { "representations": [ @@ -155,7 +155,7 @@ { "request": { "body": { - "query": "query progressive2__Subgraph2__0{percent0{foo}}", + "query": "query progressive2__Subgraph2__0 { percent0 { foo } }", "operationName": "progressive2__Subgraph2__0" } }, diff --git a/apollo-router/tests/samples/enterprise/progressive-override/warmup/rhai/main.rhai b/apollo-router/tests/samples/enterprise/progressive-override/warmup/rhai/main.rhai index 3ecb55cca9..81891750e6 100644 --- a/apollo-router/tests/samples/enterprise/progressive-override/warmup/rhai/main.rhai +++ b/apollo-router/tests/samples/enterprise/progressive-override/warmup/rhai/main.rhai @@ -6,16 +6,16 @@ fn supergraph_service(service) { // Add a timestamp to context which we'll use in the response. fn process_request(request) { request.context["request_start"] = Router.APOLLO_START.elapsed; - let labels = request.context["apollo_override::unresolved_labels"]; + let labels = request.context["apollo::progressive_override::unresolved_labels"]; print(`unresolved: ${labels}`); - let override = request.context["apollo_override::labels_to_override"]; + let override = request.context["apollo::progressive_override::labels_to_override"]; print(`override: ${override}`); if "x-override" in request.headers { if request.headers["x-override"] == "true" { - request.context["apollo_override::labels_to_override"] += "bar"; + request.context["apollo::progressive_override::labels_to_override"] += "bar"; } } } diff --git a/apollo-router/tests/samples/enterprise/query-planning-redis/plan.json b/apollo-router/tests/samples/enterprise/query-planning-redis/plan.json index a864862620..c25a95d031 100644 --- a/apollo-router/tests/samples/enterprise/query-planning-redis/plan.json +++ b/apollo-router/tests/samples/enterprise/query-planning-redis/plan.json @@ -11,7 +11,7 @@ "requests": [ { "request": { - "body": {"query":"{me{name}}"} + "body": {"query":"{ me { name } }"} }, "response": { "body": {"data": { "me": { "name": "test" } } } @@ -19,7 +19,7 @@ }, { "request": { - "body": {"query":"{me{nom:name}}"} + "body": {"query":"{ me { nom: name } }"} }, "response": { "body": {"data": { "me": { "nom": "test" } } } diff --git a/apollo-router/tests/samples_tests.rs b/apollo-router/tests/samples_tests.rs index 8159a40179..7bd3ce315d 100644 --- a/apollo-router/tests/samples_tests.rs +++ b/apollo-router/tests/samples_tests.rs @@ -11,6 +11,7 @@ use std::path::Path; use std::path::PathBuf; use std::process::ExitCode; +use futures::FutureExt as _; use libtest_mimic::Arguments; use libtest_mimic::Failed; use libtest_mimic::Trial; @@ -20,17 +21,20 @@ use multer::Multipart; use serde::Deserialize; use serde_json::Value; use tokio::runtime::Runtime; -use wiremock::matchers::body_partial_json; -use wiremock::matchers::header; -use wiremock::matchers::method; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::body_partial_json; +use wiremock::matchers::header; +use wiremock::matchers::method; #[path = "./common.rs"] pub(crate) mod common; pub(crate) use common::IntegrationTest; +use crate::common::Query; +use crate::common::graph_os_enabled; + fn main() -> Result> { let args = Arguments::from_args(); let mut tests = Vec::new(); @@ -56,7 +60,7 @@ fn lookup_dir( path.file_name().unwrap().to_str().unwrap() ); - if path.join("plan.json").exists() { + let plan: Option = if path.join("plan.json").exists() { let mut file = File::open(path.join("plan.json")).map_err(|e| { format!( "could not open file at path '{:?}': {e}", @@ -71,8 +75,8 @@ fn lookup_dir( ) })?; - let plan: Plan = match serde_json::from_str(&s) { - Ok(data) => data, + match serde_json::from_str(&s) { + Ok(data) => Some(data), Err(e) => { return Err(format!( "could not deserialize test plan at {}: {e}", @@ -80,12 +84,38 @@ fn lookup_dir( ) .into()); } - }; + } + } else if path.join("plan.yaml").exists() { + let mut file = File::open(path.join("plan.yaml")).map_err(|e| { + format!( + "could not open file at path '{:?}': {e}", + &path.join("plan.yaml") + ) + })?; + let mut s = String::new(); + file.read_to_string(&mut s).map_err(|e| { + format!( + "could not read file at path: '{:?}': {e}", + &path.join("plan.yaml") + ) + })?; - if plan.enterprise - && !(std::env::var("TEST_APOLLO_KEY").is_ok() - && std::env::var("TEST_APOLLO_GRAPH_REF").is_ok()) - { + match serde_yaml::from_str(&s) { + Ok(data) => Some(data), + Err(e) => { + return Err(format!( + "could not deserialize test plan at {}: {e}", + path.display() + ) + .into()); + } + } + } else { + None + }; + + if let Some(plan) = plan { + if plan.enterprise && !graph_os_enabled() { continue; } @@ -94,6 +124,11 @@ fn lookup_dir( continue; } + #[cfg(not(feature = "snapshot"))] + if plan.snapshot { + continue; + } + tests.push(Trial::test(name, move || test(&path, plan))); } else { lookup_dir(&path, &name, tests)?; @@ -115,14 +150,25 @@ fn test(path: &PathBuf, plan: Plan) -> Result<(), Failed> { let rt = Runtime::new()?; // Spawn the root task - rt.block_on(async { - let mut execution = TestExecution::new(); - for action in plan.actions { - execution.execute_action(&action, path, &mut out).await?; - } + let caught = rt.block_on( + std::panic::AssertUnwindSafe(async { + let mut execution = TestExecution::new(); + for action in plan.actions { + execution.execute_action(&action, path, &mut out).await?; + } - Ok(()) - }) + Ok(()) + }) + .catch_unwind(), + ); + + match caught { + Ok(result) => result, + Err(any) => { + print!("{out}"); + std::panic::resume_unwind(any); + } + } } struct TestExecution { @@ -168,10 +214,11 @@ impl TestExecution { subgraphs, update_url_overrides, } => { - self.reload_subgraphs(subgraphs, *update_url_overrides, out) + self.reload_subgraphs(subgraphs, *update_url_overrides, path, out) .await } Action::Request { + description, request, query_path, headers, @@ -179,6 +226,7 @@ impl TestExecution { expected_headers, } => { self.request( + description.clone(), request.clone(), query_path.as_deref(), headers, @@ -207,8 +255,10 @@ impl TestExecution { self.subgraphs = subgraphs.clone(); let (mut subgraphs_server, url) = self.start_subgraphs(out).await; - let subgraph_overrides = self.load_subgraph_mocks(&mut subgraphs_server, &url).await; - writeln!(out, "got subgraph mocks: {subgraph_overrides:?}").unwrap(); + let subgraph_overrides = self + .load_subgraph_mocks(&mut subgraphs_server, &url, path, out) + .await; + writeln!(out, "got subgraph mocks: {subgraph_overrides:?}")?; let config = open_file(&path.join(configuration_path), out)?; let schema_path = path.join(schema_path); @@ -260,7 +310,7 @@ impl TestExecution { let subgraph_url = Self::subgraph_url(&subgraphs_server); let subgraph_overrides = self - .load_subgraph_mocks(&mut subgraphs_server, &subgraph_url) + .load_subgraph_mocks(&mut subgraphs_server, &subgraph_url, path, out) .await; let config = open_file(&path.join(configuration_path), out)?; @@ -309,33 +359,74 @@ impl TestExecution { &mut self, subgraphs_server: &mut MockServer, url: &str, + #[cfg_attr(not(feature = "snapshot"), allow(unused_variables))] path: &Path, + #[cfg_attr(not(feature = "snapshot"), allow(unused_variables, clippy::ptr_arg))] + out: &mut String, ) -> HashMap { let mut subgraph_overrides = HashMap::new(); + #[cfg_attr(not(feature = "snapshot"), allow(unused_variables))] for (name, subgraph) in &self.subgraphs { - for SubgraphRequestMock { request, response } in &subgraph.requests { - let mut builder = Mock::given(body_partial_json(&request.body)); - - if let Some(s) = request.method.as_deref() { - builder = builder.and(method(s)); + if let Some(snapshot) = subgraph.snapshot.as_ref() { + #[cfg(feature = "snapshot")] + { + use std::str::FromStr; + + use http::Uri; + use http::header::CONTENT_LENGTH; + use http::header::CONTENT_TYPE; + + let snapshot_server = apollo_router::SnapshotServer::spawn( + &path.join(&snapshot.path), + Uri::from_str(&snapshot.base_url).unwrap(), + true, + snapshot.update.unwrap_or(false), + Some(vec![CONTENT_TYPE.to_string(), CONTENT_LENGTH.to_string()]), + snapshot.port, + ) + .await; + let snapshot_url = snapshot_server.uri(); + writeln!( + out, + "snapshot server for {name} listening on {snapshot_url}" + ) + .unwrap(); + subgraph_overrides + .entry(name.to_string()) + .or_insert(snapshot_url.clone()); } + #[cfg(not(feature = "snapshot"))] + panic!("Tests using the snapshot feature must have `snapshot` set to `true`") + } else { + for SubgraphRequestMock { request, response } in + subgraph.requests.as_ref().unwrap_or(&vec![]) + { + let mut builder = match &request.body { + Some(body) => Mock::given(body_partial_json(body)), + None => Mock::given(wiremock::matchers::AnyMatcher), + }; - if let Some(s) = request.path.as_deref() { - builder = builder.and(wiremock::matchers::path(s)); - } + if let Some(s) = request.method.as_deref() { + builder = builder.and(method(s)); + } - for (header_name, header_value) in &request.headers { - builder = builder.and(header(header_name.as_str(), header_value.as_str())); - } + if let Some(s) = request.path.as_deref() { + builder = builder.and(wiremock::matchers::path(s)); + } + + for (header_name, header_value) in &request.headers { + builder = builder.and(header(header_name.as_str(), header_value.as_str())); + } - let mut res = ResponseTemplate::new(response.status.unwrap_or(200)); - for (header_name, header_value) in &response.headers { - res = res.append_header(header_name.as_str(), header_value.as_str()); + let mut res = ResponseTemplate::new(response.status.unwrap_or(200)); + for (header_name, header_value) in &response.headers { + res = res.append_header(header_name.as_str(), header_value.as_str()); + } + builder + .respond_with(res.set_body_json(&response.body)) + .mount(subgraphs_server) + .await; } - builder - .respond_with(res.set_body_json(&response.body)) - .mount(subgraphs_server) - .await; } // Add a default override for products, if not specified @@ -351,6 +442,7 @@ impl TestExecution { &mut self, subgraphs: &HashMap, update_url_overrides: bool, + path: &Path, out: &mut String, ) -> Result<(), Failed> { writeln!(out, "reloading subgraphs with: {subgraphs:?}").unwrap(); @@ -365,7 +457,7 @@ impl TestExecution { let subgraph_url = Self::subgraph_url(&subgraphs_server); let subgraph_overrides = self - .load_subgraph_mocks(&mut subgraphs_server, &subgraph_url) + .load_subgraph_mocks(&mut subgraphs_server, &subgraph_url, path, out) .await; self.subgraphs_server = Some(subgraphs_server); @@ -429,6 +521,7 @@ impl TestExecution { #[allow(clippy::too_many_arguments)] async fn request( &mut self, + description: Option, mut request: Value, query_path: Option<&str>, headers: &HashMap, @@ -456,11 +549,21 @@ impl TestExecution { } } + writeln!(out).unwrap(); + if let Some(description) = description { + writeln!(out, "description: {description}").unwrap(); + } + writeln!(out, "query: {}\n", serde_json::to_string(&request).unwrap()).unwrap(); - writeln!(out, "header: {:?}\n", headers).unwrap(); + writeln!(out, "header: {headers:?}\n").unwrap(); let (_, response) = router - .execute_query_with_headers(&request, headers.clone()) + .execute_query( + Query::builder() + .body(request) + .headers(headers.clone()) + .build(), + ) .await; writeln!(out, "response headers: {:?}", response.headers()).unwrap(); @@ -468,7 +571,7 @@ impl TestExecution { for (key, value) in expected_headers { if !response.headers().contains_key(key) { failed = true; - writeln!(out, "expected header {} to be present", key).unwrap(); + writeln!(out, "expected header {key} to be present").unwrap(); } else if response.headers().get(key).unwrap() != value { failed = true; writeln!( @@ -482,6 +585,7 @@ impl TestExecution { } } if failed { + self.print_received_requests(out).await; let f: Failed = out.clone().into(); return Err(f); } @@ -560,22 +664,7 @@ impl TestExecution { }; if expected_response != &graphql_response { - if let Some(requests) = self - .subgraphs_server - .as_ref() - .unwrap() - .received_requests() - .await - { - writeln!(out, "subgraphs received requests:").unwrap(); - for request in requests { - writeln!(out, "\tmethod: {}", request.method).unwrap(); - writeln!(out, "\tpath: {}", request.url).unwrap(); - writeln!(out, "\t{}\n", std::str::from_utf8(&request.body).unwrap()).unwrap(); - } - } else { - writeln!(out, "subgraphs received no requests").unwrap(); - } + self.print_received_requests(out).await; writeln!(out, "assertion `left == right` failed").unwrap(); writeln!( @@ -596,6 +685,25 @@ impl TestExecution { Ok(()) } + async fn print_received_requests(&mut self, out: &mut String) { + if let Some(requests) = self + .subgraphs_server + .as_ref() + .unwrap() + .received_requests() + .await + { + writeln!(out, "subgraphs received requests:").unwrap(); + for request in requests { + writeln!(out, "\tmethod: {}", request.method).unwrap(); + writeln!(out, "\tpath: {}", request.url).unwrap(); + writeln!(out, "\t{}\n", std::str::from_utf8(&request.body).unwrap()).unwrap(); + } + } else { + writeln!(out, "subgraphs received no requests").unwrap(); + } + } + async fn endpoint_request( &mut self, url: &url::Url, @@ -660,16 +768,19 @@ fn check_path(path: &Path, out: &mut String) -> Result<(), Failed> { #[derive(Deserialize)] #[allow(dead_code)] +#[serde(deny_unknown_fields)] struct Plan { #[serde(default)] enterprise: bool, #[serde(default)] redis: bool, + #[serde(default)] + snapshot: bool, actions: Vec, } #[derive(Deserialize)] -#[serde(tag = "type")] +#[serde(tag = "type", deny_unknown_fields)] enum Action { Start { schema_path: String, @@ -689,6 +800,7 @@ enum Action { update_url_overrides: bool, }, Request { + description: Option, request: Value, query_path: Option, #[serde(default)] @@ -705,26 +817,40 @@ enum Action { } #[derive(Clone, Debug, Deserialize)] +#[cfg_attr(not(feature = "snapshot"), allow(dead_code))] +struct Snapshot { + path: String, + base_url: String, + update: Option, + port: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct Subgraph { - requests: Vec, + snapshot: Option, + requests: Option>, } #[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct SubgraphRequestMock { request: HttpRequest, response: HttpResponse, } #[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct HttpRequest { method: Option, path: Option, #[serde(default)] headers: HashMap, - body: Value, + body: Option, } #[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct HttpResponse { status: Option, #[serde(default)] diff --git a/apollo-router/tests/set_context.rs b/apollo-router/tests/set_context.rs index aae8107137..a8c17daf5a 100644 --- a/apollo-router/tests/set_context.rs +++ b/apollo-router/tests/set_context.rs @@ -2,12 +2,12 @@ //! Please ensure that any tests added to this file use the tokio multi-threaded test executor. //! +use apollo_router::MockedSubgraphs; +use apollo_router::TestHarness; use apollo_router::graphql::Request; use apollo_router::graphql::Response; use apollo_router::plugin::test::MockSubgraph; use apollo_router::services::supergraph; -use apollo_router::MockedSubgraphs; -use apollo_router::TestHarness; use serde::Deserialize; use serde_json::json; use tower::ServiceExt; @@ -32,24 +32,26 @@ macro_rules! snap } } +fn get_configuration() -> serde_json::Value { + json! {{ + "experimental_type_conditioned_fetching": true, + // will make debugging easier + "plugins": { + "experimental.expose_query_plan": true + }, + "include_subgraph_errors": { + "all": true + }, + "supergraph": { + // TODO(@goto-bus-stop): need to update the mocks and remove this, #6013 + "generate_query_fragments": false, + } + }} +} + async fn run_single_request(query: &str, mocks: &[(&'static str, &'static str)]) -> Response { - let harness = setup_from_mocks( - json! {{ - "experimental_type_conditioned_fetching": true, - // will make debugging easier - "plugins": { - "experimental.expose_query_plan": true - }, - "include_subgraph_errors": { - "all": true - }, - "supergraph": { - // TODO(@goto-bus-stop): need to update the mocks and remove this, #6013 - "generate_query_fragments": false, - } - }}, - mocks, - ); + let configuration = get_configuration(); + let harness = setup_from_mocks(configuration, mocks); let supergraph_service = harness.build_supergraph().await.unwrap(); let request = supergraph::Request::fake_builder() .query(query.to_string()) @@ -68,9 +70,9 @@ async fn run_single_request(query: &str, mocks: &[(&'static str, &'static str)]) } #[tokio::test(flavor = "multi_thread")] -async fn test_set_context() { +async fn test_set_context_rust_qp() { static QUERY: &str = r#" - query Query { + query set_context_rust_qp { t { __typename id @@ -94,9 +96,9 @@ async fn test_set_context() { } #[tokio::test(flavor = "multi_thread")] -async fn test_set_context_no_typenames() { +async fn test_set_context_no_typenames_rust_qp() { static QUERY_NO_TYPENAMES: &str = r#" - query Query { + query set_context_no_typenames_rust_qp { t { id u { @@ -118,9 +120,9 @@ async fn test_set_context_no_typenames() { } #[tokio::test(flavor = "multi_thread")] -async fn test_set_context_list() { +async fn test_set_context_list_rust_qp() { static QUERY_WITH_LIST: &str = r#" - query Query { + query set_context_list_rust_qp { t { id uList { @@ -142,7 +144,7 @@ async fn test_set_context_list() { } #[tokio::test(flavor = "multi_thread")] -async fn test_set_context_list_of_lists() { +async fn test_set_context_list_of_lists_rust_qp() { static QUERY_WITH_LIST_OF_LISTS: &str = r#" query QueryLL { tList { @@ -166,7 +168,7 @@ async fn test_set_context_list_of_lists() { } #[tokio::test(flavor = "multi_thread")] -async fn test_set_context_union() { +async fn test_set_context_union_rust_qp() { static QUERY_WITH_UNION: &str = r#" query QueryUnion { k { @@ -196,7 +198,7 @@ async fn test_set_context_union() { } #[tokio::test(flavor = "multi_thread")] -async fn test_set_context_with_null() { +async fn test_set_context_with_null_rust_qp() { static QUERY: &str = r#" query Query_Null_Param { t { @@ -225,7 +227,7 @@ async fn test_set_context_with_null() { // this test returns the contextual value with a different than expected type // this currently works, but perhaps should do type valdiation in the future to reject #[tokio::test(flavor = "multi_thread")] -async fn test_set_context_type_mismatch() { +async fn test_set_context_type_mismatch_rust_qp() { static QUERY: &str = r#" query Query_type_mismatch { t { @@ -251,7 +253,7 @@ async fn test_set_context_type_mismatch() { // fetch from unrelated (to context) subgraph fails // validates that the error propagation is correct #[tokio::test(flavor = "multi_thread")] -async fn test_set_context_unrelated_fetch_failure() { +async fn test_set_context_unrelated_fetch_failure_rust_qp() { static QUERY: &str = r#" query Query_fetch_failure { t { @@ -281,7 +283,7 @@ async fn test_set_context_unrelated_fetch_failure() { // subgraph fetch fails where context depends on results of fetch. // validates that no fetch will get called that passes context #[tokio::test(flavor = "multi_thread")] -async fn test_set_context_dependent_fetch_failure() { +async fn test_set_context_dependent_fetch_failure_rust_qp() { static QUERY: &str = r#" query Query_fetch_dependent_failure { t { diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__batch_send_header-2.snap b/apollo-router/tests/snapshots/apollo_otel_traces__batch_send_header-2.snap index 7f20450651..7f5bcc7b3e 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__batch_send_header-2.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__batch_send_header-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,71 +36,11 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query one kind: 2 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: apollo_private.request - value: - boolValue: true - - key: graphql.operation.name - value: - stringValue: "[redacted]" - - key: graphql.operation.type - value: - stringValue: query - - key: http.request.method - value: - stringValue: POST - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - - traceId: "[trace_id]" - spanId: "[span_id]" - traceState: "" - parentSpanId: "[span_id]" - flags: 0 - name: router - kind: 1 - startTimeUnixNano: "[start_time]" - endTimeUnixNano: "[end_time]" attributes: - key: apollo_private.duration_ns value: @@ -110,6 +51,9 @@ resourceSpans: - key: apollo_private.http.response_headers value: stringValue: "[redacted]" + - key: apollo_private.request + value: + boolValue: true - key: client.name value: stringValue: "" @@ -121,10 +65,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 - - key: trace_id - value: - stringValue: "[redacted]" + intValue: "200" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -133,41 +74,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -181,41 +92,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -229,41 +110,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -277,41 +164,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -325,19 +218,16 @@ resourceSpans: stringValue: "[redacted]" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 - - key: graphql.operation.name - value: - stringValue: "[redacted]" + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -346,41 +236,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -394,41 +254,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -442,41 +272,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -493,41 +329,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -541,41 +347,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +365,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -640,58 +386,22 @@ resourceSpans: droppedEventsCount: 0 links: [] droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: products - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -700,41 +410,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -748,41 +428,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -802,41 +452,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -850,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -901,41 +491,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -955,55 +515,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: reviews - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1012,41 +536,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1059,42 +553,12 @@ resourceSpans: droppedLinksCount: 0 status: message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1114,41 +578,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1162,41 +596,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +617,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1267,55 +641,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: accounts - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1324,41 +662,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1372,41 +680,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1426,89 +704,29 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: [] - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1522,19 +740,16 @@ resourceSpans: stringValue: "[redacted]" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 - - key: graphql.operation.name - value: - stringValue: "[redacted]" + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1543,41 +758,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1591,41 +776,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -1639,49 +794,16 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: execution + flags: 1 + name: compute_job kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: graphql.operation.type - value: - stringValue: query + attributes: [] droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1690,42 +812,12 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: rhai_plugin + flags: 1 + name: compute_job.execution kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" @@ -1738,46 +830,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: sequence + flags: 1 + name: execution kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: [] + attributes: + - key: graphql.operation.type + value: + stringValue: query droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1786,52 +851,34 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: fetch + flags: 1 + name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: apollo.subgraph.name - value: - stringValue: products - - key: apollo_private.sent_time_offset - value: - stringValue: "[redacted]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: sequence + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1840,42 +887,12 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: subgraph + flags: 1 + name: fetch kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" @@ -1883,10 +900,28 @@ resourceSpans: - key: apollo.subgraph.name value: stringValue: products - - key: apollo_private.ftv1 + - key: apollo_private.sent_time_offset value: stringValue: "[redacted]" - - key: graphql.operation.name + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: subgraph + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.ftv1 value: stringValue: "[redacted]" droppedAttributesCount: 0 @@ -1897,41 +932,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1945,41 +950,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1999,41 +974,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2047,41 +992,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -2098,41 +1013,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -2152,98 +1037,32 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: reviews - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -2257,41 +1076,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2311,41 +1100,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2359,41 +1118,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -2410,41 +1139,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -2464,55 +1163,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: accounts - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -2521,41 +1184,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -2569,41 +1202,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2623,41 +1226,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__batch_send_header.snap b/apollo-router/tests/snapshots/apollo_otel_traces__batch_send_header.snap index 7f20450651..7f5bcc7b3e 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__batch_send_header.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__batch_send_header.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,71 +36,11 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query one kind: 2 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: apollo_private.request - value: - boolValue: true - - key: graphql.operation.name - value: - stringValue: "[redacted]" - - key: graphql.operation.type - value: - stringValue: query - - key: http.request.method - value: - stringValue: POST - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - - traceId: "[trace_id]" - spanId: "[span_id]" - traceState: "" - parentSpanId: "[span_id]" - flags: 0 - name: router - kind: 1 - startTimeUnixNano: "[start_time]" - endTimeUnixNano: "[end_time]" attributes: - key: apollo_private.duration_ns value: @@ -110,6 +51,9 @@ resourceSpans: - key: apollo_private.http.response_headers value: stringValue: "[redacted]" + - key: apollo_private.request + value: + boolValue: true - key: client.name value: stringValue: "" @@ -121,10 +65,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 - - key: trace_id - value: - stringValue: "[redacted]" + intValue: "200" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -133,41 +74,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -181,41 +92,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -229,41 +110,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -277,41 +164,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -325,19 +218,16 @@ resourceSpans: stringValue: "[redacted]" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 - - key: graphql.operation.name - value: - stringValue: "[redacted]" + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -346,41 +236,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -394,41 +254,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -442,41 +272,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -493,41 +329,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -541,41 +347,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +365,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -640,58 +386,22 @@ resourceSpans: droppedEventsCount: 0 links: [] droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: products - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -700,41 +410,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -748,41 +428,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -802,41 +452,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -850,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -901,41 +491,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -955,55 +515,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: reviews - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1012,41 +536,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1059,42 +553,12 @@ resourceSpans: droppedLinksCount: 0 status: message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1114,41 +578,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1162,41 +596,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +617,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1267,55 +641,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: accounts - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1324,41 +662,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1372,41 +680,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1426,89 +704,29 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: [] - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1522,19 +740,16 @@ resourceSpans: stringValue: "[redacted]" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 - - key: graphql.operation.name - value: - stringValue: "[redacted]" + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1543,41 +758,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1591,41 +776,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -1639,49 +794,16 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: execution + flags: 1 + name: compute_job kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: graphql.operation.type - value: - stringValue: query + attributes: [] droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1690,42 +812,12 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: rhai_plugin + flags: 1 + name: compute_job.execution kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" @@ -1738,46 +830,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: sequence + flags: 1 + name: execution kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: [] + attributes: + - key: graphql.operation.type + value: + stringValue: query droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1786,52 +851,34 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: fetch + flags: 1 + name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: apollo.subgraph.name - value: - stringValue: products - - key: apollo_private.sent_time_offset - value: - stringValue: "[redacted]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: sequence + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1840,42 +887,12 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: subgraph + flags: 1 + name: fetch kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" @@ -1883,10 +900,28 @@ resourceSpans: - key: apollo.subgraph.name value: stringValue: products - - key: apollo_private.ftv1 + - key: apollo_private.sent_time_offset value: stringValue: "[redacted]" - - key: graphql.operation.name + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: subgraph + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.ftv1 value: stringValue: "[redacted]" droppedAttributesCount: 0 @@ -1897,41 +932,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1945,41 +950,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1999,41 +974,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2047,41 +992,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -2098,41 +1013,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -2152,98 +1037,32 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: reviews - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -2257,41 +1076,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2311,41 +1100,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2359,41 +1118,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -2410,41 +1139,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -2464,55 +1163,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: accounts - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -2521,41 +1184,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -2569,41 +1202,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2623,41 +1226,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__batch_trace_id-2.snap b/apollo-router/tests/snapshots/apollo_otel_traces__batch_trace_id-2.snap index d64c2daf81..16a24340b1 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__batch_trace_id-2.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__batch_trace_id-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,71 +36,11 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query one kind: 2 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: apollo_private.request - value: - boolValue: true - - key: graphql.operation.name - value: - stringValue: "[redacted]" - - key: graphql.operation.type - value: - stringValue: query - - key: http.request.method - value: - stringValue: POST - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - - traceId: "[trace_id]" - spanId: "[span_id]" - traceState: "" - parentSpanId: "[span_id]" - flags: 0 - name: router - kind: 1 - startTimeUnixNano: "[start_time]" - endTimeUnixNano: "[end_time]" attributes: - key: apollo_private.duration_ns value: @@ -110,6 +51,9 @@ resourceSpans: - key: apollo_private.http.response_headers value: stringValue: "[redacted]" + - key: apollo_private.request + value: + boolValue: true - key: client.name value: stringValue: "" @@ -121,10 +65,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 - - key: trace_id - value: - stringValue: "[redacted]" + intValue: "200" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -133,41 +74,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -181,41 +92,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -229,41 +110,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -277,41 +164,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -325,19 +218,16 @@ resourceSpans: stringValue: "[redacted]" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 - - key: graphql.operation.name - value: - stringValue: "[redacted]" + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -346,41 +236,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -394,41 +254,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -442,41 +272,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -493,41 +329,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -541,41 +347,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +365,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -640,58 +386,22 @@ resourceSpans: droppedEventsCount: 0 links: [] droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: products - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -700,41 +410,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -748,41 +428,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -802,41 +452,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -850,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -901,41 +491,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -955,55 +515,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: reviews - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1012,41 +536,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1059,42 +553,12 @@ resourceSpans: droppedLinksCount: 0 status: message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1114,41 +578,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1162,41 +596,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +617,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1267,55 +641,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: accounts - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1324,41 +662,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1372,41 +680,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1426,89 +704,29 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: [] - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1522,19 +740,16 @@ resourceSpans: stringValue: "[redacted]" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 - - key: graphql.operation.name - value: - stringValue: "[redacted]" + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1543,41 +758,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1591,41 +776,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -1639,49 +794,16 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: execution + flags: 1 + name: compute_job kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: graphql.operation.type - value: - stringValue: query + attributes: [] droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1690,42 +812,12 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: rhai_plugin + flags: 1 + name: compute_job.execution kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" @@ -1738,46 +830,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: sequence + flags: 1 + name: execution kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: [] + attributes: + - key: graphql.operation.type + value: + stringValue: query droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1786,52 +851,34 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: fetch + flags: 1 + name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: apollo.subgraph.name - value: - stringValue: products - - key: apollo_private.sent_time_offset - value: - stringValue: "[redacted]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: sequence + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1840,42 +887,12 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: subgraph + flags: 1 + name: fetch kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" @@ -1883,10 +900,28 @@ resourceSpans: - key: apollo.subgraph.name value: stringValue: products - - key: apollo_private.ftv1 + - key: apollo_private.sent_time_offset value: stringValue: "[redacted]" - - key: graphql.operation.name + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: subgraph + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.ftv1 value: stringValue: "[redacted]" droppedAttributesCount: 0 @@ -1897,41 +932,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1945,41 +950,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1999,41 +974,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2047,41 +992,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -2098,41 +1013,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -2152,98 +1037,32 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: reviews - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -2257,41 +1076,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2311,41 +1100,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2359,41 +1118,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -2410,41 +1139,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -2464,55 +1163,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: accounts - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -2521,41 +1184,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -2569,41 +1202,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2623,41 +1226,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__batch_trace_id.snap b/apollo-router/tests/snapshots/apollo_otel_traces__batch_trace_id.snap index d64c2daf81..16a24340b1 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__batch_trace_id.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__batch_trace_id.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,71 +36,11 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query one kind: 2 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: apollo_private.request - value: - boolValue: true - - key: graphql.operation.name - value: - stringValue: "[redacted]" - - key: graphql.operation.type - value: - stringValue: query - - key: http.request.method - value: - stringValue: POST - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - - traceId: "[trace_id]" - spanId: "[span_id]" - traceState: "" - parentSpanId: "[span_id]" - flags: 0 - name: router - kind: 1 - startTimeUnixNano: "[start_time]" - endTimeUnixNano: "[end_time]" attributes: - key: apollo_private.duration_ns value: @@ -110,6 +51,9 @@ resourceSpans: - key: apollo_private.http.response_headers value: stringValue: "[redacted]" + - key: apollo_private.request + value: + boolValue: true - key: client.name value: stringValue: "" @@ -121,10 +65,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 - - key: trace_id - value: - stringValue: "[redacted]" + intValue: "200" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -133,41 +74,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -181,41 +92,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -229,41 +110,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -277,41 +164,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -325,19 +218,16 @@ resourceSpans: stringValue: "[redacted]" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 - - key: graphql.operation.name - value: - stringValue: "[redacted]" + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -346,41 +236,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -394,41 +254,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -442,41 +272,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -493,41 +329,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -541,41 +347,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +365,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -640,58 +386,22 @@ resourceSpans: droppedEventsCount: 0 links: [] droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: products - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -700,41 +410,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -748,41 +428,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -802,41 +452,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -850,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -901,41 +491,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -955,55 +515,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: reviews - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1012,41 +536,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1059,42 +553,12 @@ resourceSpans: droppedLinksCount: 0 status: message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1114,41 +578,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1162,41 +596,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +617,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1267,55 +641,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: accounts - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1324,41 +662,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1372,41 +680,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1426,89 +704,29 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: [] - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1522,19 +740,16 @@ resourceSpans: stringValue: "[redacted]" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 - - key: graphql.operation.name - value: - stringValue: "[redacted]" + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1543,41 +758,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1591,41 +776,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -1639,49 +794,16 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: execution + flags: 1 + name: compute_job kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: graphql.operation.type - value: - stringValue: query + attributes: [] droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1690,42 +812,12 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: rhai_plugin + flags: 1 + name: compute_job.execution kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" @@ -1738,46 +830,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: sequence + flags: 1 + name: execution kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: [] + attributes: + - key: graphql.operation.type + value: + stringValue: query droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1786,52 +851,34 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: fetch + flags: 1 + name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: apollo.subgraph.name - value: - stringValue: products - - key: apollo_private.sent_time_offset - value: - stringValue: "[redacted]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: sequence + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -1840,42 +887,12 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: subgraph + flags: 1 + name: fetch kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" @@ -1883,10 +900,28 @@ resourceSpans: - key: apollo.subgraph.name value: stringValue: products - - key: apollo_private.ftv1 + - key: apollo_private.sent_time_offset value: stringValue: "[redacted]" - - key: graphql.operation.name + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: subgraph + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.ftv1 value: stringValue: "[redacted]" droppedAttributesCount: 0 @@ -1897,41 +932,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1945,41 +950,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1999,41 +974,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2047,41 +992,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -2098,41 +1013,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -2152,98 +1037,32 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: reviews - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -2257,41 +1076,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2311,41 +1100,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2359,41 +1118,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -2410,41 +1139,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -2464,55 +1163,19 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - - key: apollo.subgraph.name - value: - stringValue: accounts - key: apollo_private.ftv1 value: stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "[redacted]" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -2521,41 +1184,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -2569,41 +1202,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -2623,41 +1226,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__client_name-2.snap b/apollo-router/tests/snapshots/apollo_otel_traces__client_name-2.snap index 7bf95cc216..002cf3d71c 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__client_name-2.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__client_name-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -535,41 +338,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +362,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -646,41 +389,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -694,41 +407,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -748,41 +431,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -796,41 +449,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -901,98 +494,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1006,41 +539,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1060,41 +563,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1108,41 +581,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +626,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1270,41 +653,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1318,41 +671,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1372,41 +695,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__client_name.snap b/apollo-router/tests/snapshots/apollo_otel_traces__client_name.snap index 7bf95cc216..002cf3d71c 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__client_name.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__client_name.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -535,41 +338,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +362,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -646,41 +389,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -694,41 +407,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -748,41 +431,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -796,41 +449,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -901,98 +494,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1006,41 +539,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1060,41 +563,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1108,41 +581,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +626,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1270,41 +653,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1318,41 +671,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1372,41 +695,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__client_version-2.snap b/apollo-router/tests/snapshots/apollo_otel_traces__client_version-2.snap index b3e40fc2e9..1354627235 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__client_version-2.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__client_version-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -535,41 +338,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +362,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -646,41 +389,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -694,41 +407,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -748,41 +431,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -796,41 +449,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -901,98 +494,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1006,41 +539,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1060,41 +563,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1108,41 +581,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +626,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1270,41 +653,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1318,41 +671,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1372,41 +695,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__client_version.snap b/apollo-router/tests/snapshots/apollo_otel_traces__client_version.snap index b3e40fc2e9..1354627235 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__client_version.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__client_version.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -535,41 +338,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +362,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -646,41 +389,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -694,41 +407,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -748,41 +431,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -796,41 +449,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -901,98 +494,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1006,41 +539,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1060,41 +563,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1108,41 +581,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +626,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1270,41 +653,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1318,41 +671,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1372,41 +695,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__condition_else-2.snap b/apollo-router/tests/snapshots/apollo_otel_traces__condition_else-2.snap index 602929d475..0921dc9256 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__condition_else-2.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__condition_else-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\nquery($if:Boolean!){topProducts{name...@defer(if:$if){reviews{author{name}}reviews{author{name}}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: condition kind: 1 startTimeUnixNano: "[start_time]" @@ -538,41 +341,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: condition_else kind: 1 startTimeUnixNano: "[start_time]" @@ -586,41 +359,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -634,41 +377,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -688,41 +401,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -745,41 +428,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -793,41 +446,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -895,41 +488,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -946,41 +509,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1000,98 +533,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1105,41 +578,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1207,41 +620,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1258,41 +641,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1312,41 +665,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1369,41 +692,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1417,41 +710,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1471,41 +734,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__condition_else.snap b/apollo-router/tests/snapshots/apollo_otel_traces__condition_else.snap index 602929d475..0921dc9256 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__condition_else.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__condition_else.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\nquery($if:Boolean!){topProducts{name...@defer(if:$if){reviews{author{name}}reviews{author{name}}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: condition kind: 1 startTimeUnixNano: "[start_time]" @@ -538,41 +341,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: condition_else kind: 1 startTimeUnixNano: "[start_time]" @@ -586,41 +359,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -634,41 +377,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -688,41 +401,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -745,41 +428,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -793,41 +446,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -895,41 +488,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -946,41 +509,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1000,98 +533,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1105,41 +578,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1207,41 +620,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1258,41 +641,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1312,41 +665,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1369,41 +692,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1417,41 +710,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1471,41 +734,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__condition_if-2.snap b/apollo-router/tests/snapshots/apollo_otel_traces__condition_if-2.snap index 69ea7edf5b..7ab763e2fc 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__condition_if-2.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__condition_if-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\nquery($if:Boolean!){topProducts{name...@defer(if:$if){reviews{author{name}}reviews{author{name}}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,49 +245,16 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: execution + flags: 1 + name: compute_job kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: graphql.operation.type - value: - stringValue: query + attributes: [] droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -439,41 +263,50 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: graphql.operation.type + value: + stringValue: query + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: condition kind: 1 startTimeUnixNano: "[start_time]" @@ -538,41 +341,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: condition_if kind: 1 startTimeUnixNano: "[start_time]" @@ -586,41 +359,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: defer kind: 1 startTimeUnixNano: "[start_time]" @@ -634,41 +377,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: defer_primary kind: 1 startTimeUnixNano: "[start_time]" @@ -682,41 +395,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -736,41 +419,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -793,41 +446,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -841,41 +464,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -895,41 +488,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -943,41 +506,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: defer_deferred kind: 1 startTimeUnixNano: "[start_time]" @@ -1000,41 +533,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -1048,41 +551,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1099,41 +572,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1153,98 +596,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1258,41 +641,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1312,41 +665,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1360,41 +683,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1411,41 +704,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1465,41 +728,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1522,41 +755,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1570,41 +773,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1624,41 +797,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__condition_if.snap b/apollo-router/tests/snapshots/apollo_otel_traces__condition_if.snap index 69ea7edf5b..7ab763e2fc 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__condition_if.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__condition_if.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\nquery($if:Boolean!){topProducts{name...@defer(if:$if){reviews{author{name}}reviews{author{name}}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,49 +245,16 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 - name: execution + flags: 1 + name: compute_job kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" - attributes: - - key: graphql.operation.type - value: - stringValue: query + attributes: [] droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -439,41 +263,50 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: graphql.operation.type + value: + stringValue: query + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: condition kind: 1 startTimeUnixNano: "[start_time]" @@ -538,41 +341,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: condition_if kind: 1 startTimeUnixNano: "[start_time]" @@ -586,41 +359,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: defer kind: 1 startTimeUnixNano: "[start_time]" @@ -634,41 +377,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: defer_primary kind: 1 startTimeUnixNano: "[start_time]" @@ -682,41 +395,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -736,41 +419,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -793,41 +446,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -841,41 +464,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -895,41 +488,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -943,41 +506,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: defer_deferred kind: 1 startTimeUnixNano: "[start_time]" @@ -1000,41 +533,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -1048,41 +551,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1099,41 +572,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1153,98 +596,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1258,41 +641,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1312,41 +665,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1360,41 +683,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1411,41 +704,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1465,41 +728,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1522,41 +755,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1570,41 +773,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1624,41 +797,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__connector-2.snap b/apollo-router/tests/snapshots/apollo_otel_traces__connector-2.snap new file mode 100644 index 0000000000..2ebc89bd7d --- /dev/null +++ b/apollo-router/tests/snapshots/apollo_otel_traces__connector-2.snap @@ -0,0 +1,419 @@ +--- +source: apollo-router/tests/apollo_otel_traces.rs +expression: report +--- +resourceSpans: + - resource: + attributes: + - key: apollo.client.host + value: + stringValue: "[redacted]" + - key: apollo.client.uname + value: + stringValue: "[redacted]" + - key: apollo.graph.ref + value: + stringValue: test + - key: apollo.router.id + value: + stringValue: "[redacted]" + - key: apollo.schema.id + value: + stringValue: "[redacted]" + - key: apollo.user.agent + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + scopeSpans: + - scope: + name: apollo-router + version: "[version]" + attributes: [] + droppedAttributesCount: 0 + spans: + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: query + kind: 2 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.request + value: + boolValue: true + - key: graphql.operation.type + value: + stringValue: query + - key: http.request.method + value: + stringValue: POST + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: router + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.duration_ns + value: + stringValue: "[redacted]" + - key: apollo_private.http.request_headers + value: + stringValue: "{}" + - key: apollo_private.http.response_headers + value: + stringValue: "[redacted]" + - key: client.name + value: + stringValue: "" + - key: client.version + value: + stringValue: "" + - key: http.request.method + value: + stringValue: POST + - key: http.response.status_code + value: + intValue: "200" + - key: trace_id + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 1 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: rhai_plugin + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: parse_query + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: supergraph + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.graphql.variables + value: + stringValue: "[redacted]" + - key: apollo_private.operation_signature + value: + stringValue: "# -\n{posts{body id title}}" + - key: apollo_private.query.aliases + value: + intValue: "0" + - key: apollo_private.query.depth + value: + intValue: "2" + - key: apollo_private.query.height + value: + intValue: "4" + - key: apollo_private.query.root_fields + value: + intValue: "1" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: rhai_plugin + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: query_planning + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: graphql.operation.type + value: + stringValue: query + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: rhai_plugin + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: fetch + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo.subgraph.name + value: + stringValue: posts + - key: apollo_private.sent_time_offset + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo.connector.detail + value: + stringValue: "{\"GET\":\"/posts\"}" + - key: apollo.connector.selection + value: + stringValue: "id\ntitle\nbody" + - key: apollo.connector.source.detail + value: + stringValue: "{\"baseURL\":\"https://jsonplaceholder.typicode.com/\"}" + - key: apollo.connector.source.name + value: + stringValue: jsonPlaceholder + - key: apollo.connector.type + value: + stringValue: http + - key: apollo_private.sent_time_offset + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 1 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 1 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + schemaUrl: "" + schemaUrl: "" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__connector.snap b/apollo-router/tests/snapshots/apollo_otel_traces__connector.snap new file mode 100644 index 0000000000..2ebc89bd7d --- /dev/null +++ b/apollo-router/tests/snapshots/apollo_otel_traces__connector.snap @@ -0,0 +1,419 @@ +--- +source: apollo-router/tests/apollo_otel_traces.rs +expression: report +--- +resourceSpans: + - resource: + attributes: + - key: apollo.client.host + value: + stringValue: "[redacted]" + - key: apollo.client.uname + value: + stringValue: "[redacted]" + - key: apollo.graph.ref + value: + stringValue: test + - key: apollo.router.id + value: + stringValue: "[redacted]" + - key: apollo.schema.id + value: + stringValue: "[redacted]" + - key: apollo.user.agent + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + scopeSpans: + - scope: + name: apollo-router + version: "[version]" + attributes: [] + droppedAttributesCount: 0 + spans: + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: query + kind: 2 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.request + value: + boolValue: true + - key: graphql.operation.type + value: + stringValue: query + - key: http.request.method + value: + stringValue: POST + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: router + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.duration_ns + value: + stringValue: "[redacted]" + - key: apollo_private.http.request_headers + value: + stringValue: "{}" + - key: apollo_private.http.response_headers + value: + stringValue: "[redacted]" + - key: client.name + value: + stringValue: "" + - key: client.version + value: + stringValue: "" + - key: http.request.method + value: + stringValue: POST + - key: http.response.status_code + value: + intValue: "200" + - key: trace_id + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 1 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: rhai_plugin + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: parse_query + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: supergraph + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.graphql.variables + value: + stringValue: "[redacted]" + - key: apollo_private.operation_signature + value: + stringValue: "# -\n{posts{body id title}}" + - key: apollo_private.query.aliases + value: + intValue: "0" + - key: apollo_private.query.depth + value: + intValue: "2" + - key: apollo_private.query.height + value: + intValue: "4" + - key: apollo_private.query.root_fields + value: + intValue: "1" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: rhai_plugin + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: query_planning + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: graphql.operation.type + value: + stringValue: query + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: rhai_plugin + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: fetch + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo.subgraph.name + value: + stringValue: posts + - key: apollo_private.sent_time_offset + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo.connector.detail + value: + stringValue: "{\"GET\":\"/posts\"}" + - key: apollo.connector.selection + value: + stringValue: "id\ntitle\nbody" + - key: apollo.connector.source.detail + value: + stringValue: "{\"baseURL\":\"https://jsonplaceholder.typicode.com/\"}" + - key: apollo.connector.source.name + value: + stringValue: jsonPlaceholder + - key: apollo.connector.type + value: + stringValue: http + - key: apollo_private.sent_time_offset + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 1 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 1 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + schemaUrl: "" + schemaUrl: "" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__connector_error-2.snap b/apollo-router/tests/snapshots/apollo_otel_traces__connector_error-2.snap new file mode 100644 index 0000000000..a39dd672c1 --- /dev/null +++ b/apollo-router/tests/snapshots/apollo_otel_traces__connector_error-2.snap @@ -0,0 +1,2968 @@ +--- +source: apollo-router/tests/apollo_otel_traces.rs +expression: report +--- +resourceSpans: + - resource: + attributes: + - key: apollo.client.host + value: + stringValue: "[redacted]" + - key: apollo.client.uname + value: + stringValue: "[redacted]" + - key: apollo.graph.ref + value: + stringValue: test + - key: apollo.router.id + value: + stringValue: "[redacted]" + - key: apollo.schema.id + value: + stringValue: "[redacted]" + - key: apollo.user.agent + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + scopeSpans: + - scope: + name: apollo-router + version: "[version]" + attributes: [] + droppedAttributesCount: 0 + spans: + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: query + kind: 2 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.request + value: + boolValue: true + - key: graphql.operation.type + value: + stringValue: query + - key: http.request.method + value: + stringValue: POST + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: router + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.duration_ns + value: + stringValue: "[redacted]" + - key: apollo_private.http.request_headers + value: + stringValue: "{}" + - key: apollo_private.http.response_headers + value: + stringValue: "[redacted]" + - key: client.name + value: + stringValue: "" + - key: client.version + value: + stringValue: "" + - key: http.request.method + value: + stringValue: POST + - key: http.response.status_code + value: + intValue: "200" + - key: trace_id + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 1 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: rhai_plugin + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: parse_query + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: supergraph + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.graphql.variables + value: + stringValue: "[redacted]" + - key: apollo_private.operation_signature + value: + stringValue: "# -\n{posts{body forceError id title}}" + - key: apollo_private.query.aliases + value: + intValue: "0" + - key: apollo_private.query.depth + value: + intValue: "2" + - key: apollo_private.query.height + value: + intValue: "5" + - key: apollo_private.query.root_fields + value: + intValue: "1" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: rhai_plugin + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: query_planning + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: graphql.operation.type + value: + stringValue: query + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: rhai_plugin + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: sequence + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: fetch + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo.subgraph.name + value: + stringValue: posts + - key: apollo_private.sent_time_offset + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo.connector.detail + value: + stringValue: "{\"GET\":\"/posts\"}" + - key: apollo.connector.selection + value: + stringValue: "id\ntitle\nbody" + - key: apollo.connector.source.detail + value: + stringValue: "{\"baseURL\":\"https://jsonplaceholder.typicode.com/\"}" + - key: apollo.connector.source.name + value: + stringValue: jsonPlaceholder + - key: apollo.connector.type + value: + stringValue: http + - key: apollo_private.sent_time_offset + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 1 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 1 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: flatten + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: graphql.path + value: + stringValue: /posts/@ + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: fetch + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo.subgraph.name + value: + stringValue: posts + - key: apollo_private.sent_time_offset + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo.connector.detail + value: + stringValue: "{\"GET\":\"/missing?_={$args.id}\"}" + - key: apollo.connector.selection + value: + stringValue: forceError + - key: apollo.connector.source.detail + value: + stringValue: "{\"baseURL\":\"https://jsonplaceholder.typicode.com/\"}" + - key: apollo.connector.source.name + value: + stringValue: jsonPlaceholder + - key: apollo.connector.type + value: + stringValue: http + - key: apollo_private.sent_time_offset + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + schemaUrl: "" + schemaUrl: "" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__connector_error.snap b/apollo-router/tests/snapshots/apollo_otel_traces__connector_error.snap new file mode 100644 index 0000000000..a39dd672c1 --- /dev/null +++ b/apollo-router/tests/snapshots/apollo_otel_traces__connector_error.snap @@ -0,0 +1,2968 @@ +--- +source: apollo-router/tests/apollo_otel_traces.rs +expression: report +--- +resourceSpans: + - resource: + attributes: + - key: apollo.client.host + value: + stringValue: "[redacted]" + - key: apollo.client.uname + value: + stringValue: "[redacted]" + - key: apollo.graph.ref + value: + stringValue: test + - key: apollo.router.id + value: + stringValue: "[redacted]" + - key: apollo.schema.id + value: + stringValue: "[redacted]" + - key: apollo.user.agent + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + scopeSpans: + - scope: + name: apollo-router + version: "[version]" + attributes: [] + droppedAttributesCount: 0 + spans: + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: query + kind: 2 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.request + value: + boolValue: true + - key: graphql.operation.type + value: + stringValue: query + - key: http.request.method + value: + stringValue: POST + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: router + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.duration_ns + value: + stringValue: "[redacted]" + - key: apollo_private.http.request_headers + value: + stringValue: "{}" + - key: apollo_private.http.response_headers + value: + stringValue: "[redacted]" + - key: client.name + value: + stringValue: "" + - key: client.version + value: + stringValue: "" + - key: http.request.method + value: + stringValue: POST + - key: http.response.status_code + value: + intValue: "200" + - key: trace_id + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 1 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: rhai_plugin + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: parse_query + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: supergraph + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo_private.graphql.variables + value: + stringValue: "[redacted]" + - key: apollo_private.operation_signature + value: + stringValue: "# -\n{posts{body forceError id title}}" + - key: apollo_private.query.aliases + value: + intValue: "0" + - key: apollo_private.query.depth + value: + intValue: "2" + - key: apollo_private.query.height + value: + intValue: "5" + - key: apollo_private.query.root_fields + value: + intValue: "1" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: rhai_plugin + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: query_planning + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: graphql.operation.type + value: + stringValue: query + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: rhai_plugin + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: sequence + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: fetch + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo.subgraph.name + value: + stringValue: posts + - key: apollo_private.sent_time_offset + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo.connector.detail + value: + stringValue: "{\"GET\":\"/posts\"}" + - key: apollo.connector.selection + value: + stringValue: "id\ntitle\nbody" + - key: apollo.connector.source.detail + value: + stringValue: "{\"baseURL\":\"https://jsonplaceholder.typicode.com/\"}" + - key: apollo.connector.source.name + value: + stringValue: jsonPlaceholder + - key: apollo.connector.type + value: + stringValue: http + - key: apollo_private.sent_time_offset + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 1 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 1 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: flatten + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: graphql.path + value: + stringValue: /posts/@ + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: fetch + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo.subgraph.name + value: + stringValue: posts + - key: apollo_private.sent_time_offset + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: + - key: apollo.connector.detail + value: + stringValue: "{\"GET\":\"/missing?_={$args.id}\"}" + - key: apollo.connector.selection + value: + stringValue: forceError + - key: apollo.connector.source.detail + value: + stringValue: "{\"baseURL\":\"https://jsonplaceholder.typicode.com/\"}" + - key: apollo.connector.source.name + value: + stringValue: jsonPlaceholder + - key: apollo.connector.type + value: + stringValue: http + - key: apollo_private.sent_time_offset + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: connect_request + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: + - timeUnixNano: "[time]" + name: "" + attributes: + - key: exception.message + value: + stringValue: Request failed + - key: graphql.error.extensions.code + value: + stringValue: CONNECTOR_FETCH + - key: graphql.error.path + value: + stringValue: "[redacted]" + droppedAttributesCount: 0 + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 2 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: http_request + kind: 3 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + schemaUrl: "" + schemaUrl: "" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__non_defer-2.snap b/apollo-router/tests/snapshots/apollo_otel_traces__non_defer-2.snap index b3f5f82a75..e11f185c53 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__non_defer-2.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__non_defer-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -535,41 +338,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +362,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -646,41 +389,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -694,41 +407,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -748,41 +431,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -796,41 +449,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -901,98 +494,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1006,41 +539,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1060,41 +563,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1108,41 +581,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +626,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1270,41 +653,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1318,41 +671,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1372,41 +695,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__non_defer.snap b/apollo-router/tests/snapshots/apollo_otel_traces__non_defer.snap index b3f5f82a75..e11f185c53 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__non_defer.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__non_defer.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -535,41 +338,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +362,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -646,41 +389,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -694,41 +407,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -748,41 +431,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -796,41 +449,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -901,98 +494,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1006,41 +539,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1060,41 +563,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1108,41 +581,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +626,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1270,41 +653,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1318,41 +671,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1372,41 +695,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__send_header-2.snap b/apollo-router/tests/snapshots/apollo_otel_traces__send_header-2.snap index d3c70ddaff..293fe3eb7c 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__send_header-2.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__send_header-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -535,41 +338,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +362,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -646,41 +389,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -694,41 +407,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -748,41 +431,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -796,41 +449,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -901,98 +494,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1006,41 +539,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1060,41 +563,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1108,41 +581,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +626,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1270,41 +653,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1318,41 +671,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1372,41 +695,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__send_header.snap b/apollo-router/tests/snapshots/apollo_otel_traces__send_header.snap index d3c70ddaff..293fe3eb7c 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__send_header.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__send_header.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -535,41 +338,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +362,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -646,41 +389,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -694,41 +407,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -748,41 +431,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -796,41 +449,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -901,98 +494,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1006,41 +539,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1060,41 +563,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1108,41 +581,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +626,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1270,41 +653,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1318,41 +671,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1372,41 +695,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__send_variable_value-2.snap b/apollo-router/tests/snapshots/apollo_otel_traces__send_variable_value-2.snap index 20d1abf874..85ad5c7909 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__send_variable_value-2.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__send_variable_value-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\nquery($dontSendValue:Boolean!,$sendValue:Boolean!){topProducts{name reviews@include(if:$sendValue){author{name}}reviews@include(if:$dontSendValue){author{name}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -535,41 +338,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +362,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -646,41 +389,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -694,41 +407,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -748,41 +431,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -796,41 +449,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -901,98 +494,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1006,41 +539,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1060,41 +563,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1108,41 +581,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +626,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1270,41 +653,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1318,41 +671,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1372,41 +695,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__send_variable_value.snap b/apollo-router/tests/snapshots/apollo_otel_traces__send_variable_value.snap index 20d1abf874..85ad5c7909 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__send_variable_value.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__send_variable_value.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\nquery($dontSendValue:Boolean!,$sendValue:Boolean!){topProducts{name reviews@include(if:$sendValue){author{name}}reviews@include(if:$dontSendValue){author{name}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -535,41 +338,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +362,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -646,41 +389,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -694,41 +407,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -748,41 +431,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -796,41 +449,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -901,98 +494,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1006,41 +539,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1060,41 +563,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1108,41 +581,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +626,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1270,41 +653,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1318,41 +671,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1372,41 +695,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__trace_id-2.snap b/apollo-router/tests/snapshots/apollo_otel_traces__trace_id-2.snap index b3f5f82a75..e11f185c53 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__trace_id-2.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__trace_id-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -535,41 +338,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +362,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -646,41 +389,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -694,41 +407,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -748,41 +431,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -796,41 +449,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -901,98 +494,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1006,41 +539,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1060,41 +563,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1108,41 +581,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +626,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1270,41 +653,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1318,41 +671,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1372,41 +695,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_otel_traces__trace_id.snap b/apollo-router/tests/snapshots/apollo_otel_traces__trace_id.snap index b3f5f82a75..e11f185c53 100644 --- a/apollo-router/tests/snapshots/apollo_otel_traces__trace_id.snap +++ b/apollo-router/tests/snapshots/apollo_otel_traces__trace_id.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_otel_traces.rs expression: report +snapshot_kind: text --- resourceSpans: - resource: @@ -35,7 +36,7 @@ resourceSpans: spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query kind: 2 startTimeUnixNano: "[start_time]" @@ -58,41 +59,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: router kind: 1 startTimeUnixNano: "[start_time]" @@ -118,7 +89,7 @@ resourceSpans: stringValue: POST - key: http.response.status_code value: - intValue: 200 + intValue: "200" - key: trace_id value: stringValue: "[redacted]" @@ -130,41 +101,11 @@ resourceSpans: status: message: "" code: 1 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -178,41 +119,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: parse_query kind: 1 startTimeUnixNano: "[start_time]" @@ -226,41 +137,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: supergraph kind: 1 startTimeUnixNano: "[start_time]" @@ -274,16 +191,16 @@ resourceSpans: stringValue: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}" - key: apollo_private.query.aliases value: - intValue: 0 + intValue: "0" - key: apollo_private.query.depth value: - intValue: 4 + intValue: "4" - key: apollo_private.query.height value: - intValue: 7 + intValue: "7" - key: apollo_private.query.root_fields value: - intValue: 1 + intValue: "1" droppedAttributesCount: 0 events: [] droppedEventsCount: 0 @@ -292,41 +209,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -340,41 +227,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: query_planning kind: 1 startTimeUnixNano: "[start_time]" @@ -388,41 +245,47 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 + name: compute_job + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 + name: compute_job.execution + kind: 1 + startTimeUnixNano: "[start_time]" + endTimeUnixNano: "[end_time]" + attributes: [] + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 + - traceId: "[trace_id]" + spanId: "[span_id]" + traceState: "" + parentSpanId: "[span_id]" + flags: 1 name: execution kind: 1 startTimeUnixNano: "[start_time]" @@ -439,41 +302,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -487,41 +320,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: sequence kind: 1 startTimeUnixNano: "[start_time]" @@ -535,41 +338,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -589,41 +362,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -646,41 +389,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -694,41 +407,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -748,41 +431,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -796,41 +449,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -847,41 +470,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -901,98 +494,38 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" endTimeUnixNano: "[end_time]" attributes: - key: apollo.subgraph.name - value: - stringValue: reviews - - key: apollo_private.ftv1 - value: - stringValue: "[redacted]" - - key: graphql.operation.name - value: - stringValue: "" - droppedAttributesCount: 0 - events: [] - droppedEventsCount: 0 - links: [] - droppedLinksCount: 0 - status: - message: "" - code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: + value: + stringValue: reviews + - key: apollo_private.ftv1 + value: + stringValue: "[redacted]" + - key: graphql.operation.name + value: + stringValue: "" + droppedAttributesCount: 0 + events: [] + droppedEventsCount: 0 + links: [] + droppedLinksCount: 0 + status: + message: "" + code: 0 - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1006,41 +539,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1060,41 +563,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1108,41 +581,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: flatten kind: 1 startTimeUnixNano: "[start_time]" @@ -1159,41 +602,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: fetch kind: 1 startTimeUnixNano: "[start_time]" @@ -1213,41 +626,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph kind: 1 startTimeUnixNano: "[start_time]" @@ -1270,41 +653,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: rhai_plugin kind: 1 startTimeUnixNano: "[start_time]" @@ -1318,41 +671,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: subgraph_request kind: 3 startTimeUnixNano: "[start_time]" @@ -1372,41 +695,11 @@ resourceSpans: status: message: "" code: 0 - schemaUrl: "" - schemaUrl: "" - - resource: - attributes: - - key: apollo.client.host - value: - stringValue: "[redacted]" - - key: apollo.client.uname - value: - stringValue: "[redacted]" - - key: apollo.graph.ref - value: - stringValue: test - - key: apollo.router.id - value: - stringValue: "[redacted]" - - key: apollo.schema.id - value: - stringValue: "[redacted]" - - key: apollo.user.agent - value: - stringValue: "[redacted]" - droppedAttributesCount: 0 - scopeSpans: - - scope: - name: apollo-router - version: "[version]" - attributes: [] - droppedAttributesCount: 0 - spans: - traceId: "[trace_id]" spanId: "[span_id]" traceState: "" parentSpanId: "[span_id]" - flags: 0 + flags: 1 name: http_request kind: 3 startTimeUnixNano: "[start_time]" diff --git a/apollo-router/tests/snapshots/apollo_reports__batch_send_header-2.snap b/apollo-router/tests/snapshots/apollo_reports__batch_send_header-2.snap index c0b4f2c27d..8010b76451 100644 --- a/apollo-router/tests/snapshots/apollo_reports__batch_send_header-2.snap +++ b/apollo-router/tests/snapshots/apollo_reports__batch_send_header-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# one\nquery one{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -27,7 +29,7 @@ traces_per_query: unexecuted_operation_name: "" details: variables_json: {} - operation_name: one + operation_name: "" client_name: "" client_version: "" operation_type: query @@ -630,7 +632,7 @@ traces_per_query: unexecuted_operation_name: "" details: variables_json: {} - operation_name: two + operation_name: "" client_name: "" client_version: "" operation_type: query @@ -1221,4 +1223,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__batch_send_header.snap b/apollo-router/tests/snapshots/apollo_reports__batch_send_header.snap index c0b4f2c27d..8010b76451 100644 --- a/apollo-router/tests/snapshots/apollo_reports__batch_send_header.snap +++ b/apollo-router/tests/snapshots/apollo_reports__batch_send_header.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# one\nquery one{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -27,7 +29,7 @@ traces_per_query: unexecuted_operation_name: "" details: variables_json: {} - operation_name: one + operation_name: "" client_name: "" client_version: "" operation_type: query @@ -630,7 +632,7 @@ traces_per_query: unexecuted_operation_name: "" details: variables_json: {} - operation_name: two + operation_name: "" client_name: "" client_version: "" operation_type: query @@ -1221,4 +1223,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__batch_trace_id-2.snap b/apollo-router/tests/snapshots/apollo_reports__batch_trace_id-2.snap index 93d9f40183..81513c6ddf 100644 --- a/apollo-router/tests/snapshots/apollo_reports__batch_trace_id-2.snap +++ b/apollo-router/tests/snapshots/apollo_reports__batch_trace_id-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# one\nquery one{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -27,7 +29,7 @@ traces_per_query: unexecuted_operation_name: "" details: variables_json: {} - operation_name: one + operation_name: "" client_name: "" client_version: "" operation_type: query @@ -627,7 +629,7 @@ traces_per_query: unexecuted_operation_name: "" details: variables_json: {} - operation_name: two + operation_name: "" client_name: "" client_version: "" operation_type: query @@ -1215,4 +1217,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__batch_trace_id.snap b/apollo-router/tests/snapshots/apollo_reports__batch_trace_id.snap index 93d9f40183..81513c6ddf 100644 --- a/apollo-router/tests/snapshots/apollo_reports__batch_trace_id.snap +++ b/apollo-router/tests/snapshots/apollo_reports__batch_trace_id.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# one\nquery one{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -27,7 +29,7 @@ traces_per_query: unexecuted_operation_name: "" details: variables_json: {} - operation_name: one + operation_name: "" client_name: "" client_version: "" operation_type: query @@ -627,7 +629,7 @@ traces_per_query: unexecuted_operation_name: "" details: variables_json: {} - operation_name: two + operation_name: "" client_name: "" client_version: "" operation_type: query @@ -1215,4 +1217,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__client_name-2.snap b/apollo-router/tests/snapshots/apollo_reports__client_name-2.snap index 2e10210b7e..817797290c 100644 --- a/apollo-router/tests/snapshots/apollo_reports__client_name-2.snap +++ b/apollo-router/tests/snapshots/apollo_reports__client_name-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -615,4 +617,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__client_name.snap b/apollo-router/tests/snapshots/apollo_reports__client_name.snap index 2e10210b7e..817797290c 100644 --- a/apollo-router/tests/snapshots/apollo_reports__client_name.snap +++ b/apollo-router/tests/snapshots/apollo_reports__client_name.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -615,4 +617,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__client_version-2.snap b/apollo-router/tests/snapshots/apollo_reports__client_version-2.snap index c87e5b9814..fa4e193ded 100644 --- a/apollo-router/tests/snapshots/apollo_reports__client_version-2.snap +++ b/apollo-router/tests/snapshots/apollo_reports__client_version-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -615,4 +617,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__client_version.snap b/apollo-router/tests/snapshots/apollo_reports__client_version.snap index c87e5b9814..fa4e193ded 100644 --- a/apollo-router/tests/snapshots/apollo_reports__client_version.snap +++ b/apollo-router/tests/snapshots/apollo_reports__client_version.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -615,4 +617,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__condition_else-2.snap b/apollo-router/tests/snapshots/apollo_reports__condition_else-2.snap index 8fd3f3243e..b7fc3bca63 100644 --- a/apollo-router/tests/snapshots/apollo_reports__condition_else-2.snap +++ b/apollo-router/tests/snapshots/apollo_reports__condition_else-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\nquery($if:Boolean!){topProducts{name...@defer(if:$if){reviews{author{name}}reviews{author{name}}}}}": trace: @@ -621,4 +623,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__condition_else.snap b/apollo-router/tests/snapshots/apollo_reports__condition_else.snap index 8fd3f3243e..b7fc3bca63 100644 --- a/apollo-router/tests/snapshots/apollo_reports__condition_else.snap +++ b/apollo-router/tests/snapshots/apollo_reports__condition_else.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\nquery($if:Boolean!){topProducts{name...@defer(if:$if){reviews{author{name}}reviews{author{name}}}}}": trace: @@ -621,4 +623,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__condition_if-2.snap b/apollo-router/tests/snapshots/apollo_reports__condition_if-2.snap index 282dbbb14d..ad08c1cd76 100644 --- a/apollo-router/tests/snapshots/apollo_reports__condition_if-2.snap +++ b/apollo-router/tests/snapshots/apollo_reports__condition_if-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\nquery($if:Boolean!){topProducts{name...@defer(if:$if){reviews{author{name}}reviews{author{name}}}}}": trace: @@ -634,4 +636,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__condition_if.snap b/apollo-router/tests/snapshots/apollo_reports__condition_if.snap index 282dbbb14d..ad08c1cd76 100644 --- a/apollo-router/tests/snapshots/apollo_reports__condition_if.snap +++ b/apollo-router/tests/snapshots/apollo_reports__condition_if.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\nquery($if:Boolean!){topProducts{name...@defer(if:$if){reviews{author{name}}reviews{author{name}}}}}": trace: @@ -634,4 +636,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__demand_control_stats.snap b/apollo-router/tests/snapshots/apollo_reports__demand_control_stats.snap index 25304f10d9..a581f9baf7 100644 --- a/apollo-router/tests/snapshots/apollo_reports__demand_control_stats.snap +++ b/apollo-router/tests/snapshots/apollo_reports__demand_control_stats.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: [] @@ -20,6 +22,8 @@ traces_per_query: operation_type: query operation_subtype: "" result: "" + client_library_name: "" + client_library_version: "" query_latency_stats: latency_count: "[latency_count]" request_count: 1 @@ -233,4 +237,5 @@ operation_count_by_type: subtype: "" operation_count: 1 traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__demand_control_trace-2.snap b/apollo-router/tests/snapshots/apollo_reports__demand_control_trace-2.snap index 20611f5035..2419a5d911 100644 --- a/apollo-router/tests/snapshots/apollo_reports__demand_control_trace-2.snap +++ b/apollo-router/tests/snapshots/apollo_reports__demand_control_trace-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -615,4 +617,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__demand_control_trace.snap b/apollo-router/tests/snapshots/apollo_reports__demand_control_trace.snap index 20611f5035..2419a5d911 100644 --- a/apollo-router/tests/snapshots/apollo_reports__demand_control_trace.snap +++ b/apollo-router/tests/snapshots/apollo_reports__demand_control_trace.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -615,4 +617,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__demand_control_trace_batched-2.snap b/apollo-router/tests/snapshots/apollo_reports__demand_control_trace_batched-2.snap index 8513f44223..9eaa200d72 100644 --- a/apollo-router/tests/snapshots/apollo_reports__demand_control_trace_batched-2.snap +++ b/apollo-router/tests/snapshots/apollo_reports__demand_control_trace_batched-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# one\nquery one{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -27,7 +29,7 @@ traces_per_query: unexecuted_operation_name: "" details: variables_json: {} - operation_name: one + operation_name: "" client_name: "" client_version: "" operation_type: query @@ -627,7 +629,7 @@ traces_per_query: unexecuted_operation_name: "" details: variables_json: {} - operation_name: two + operation_name: "" client_name: "" client_version: "" operation_type: query @@ -1215,4 +1217,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__demand_control_trace_batched.snap b/apollo-router/tests/snapshots/apollo_reports__demand_control_trace_batched.snap index 8513f44223..9eaa200d72 100644 --- a/apollo-router/tests/snapshots/apollo_reports__demand_control_trace_batched.snap +++ b/apollo-router/tests/snapshots/apollo_reports__demand_control_trace_batched.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# one\nquery one{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -27,7 +29,7 @@ traces_per_query: unexecuted_operation_name: "" details: variables_json: {} - operation_name: one + operation_name: "" client_name: "" client_version: "" operation_type: query @@ -627,7 +629,7 @@ traces_per_query: unexecuted_operation_name: "" details: variables_json: {} - operation_name: two + operation_name: "" client_name: "" client_version: "" operation_type: query @@ -1215,4 +1217,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__features_disabled.snap b/apollo-router/tests/snapshots/apollo_reports__features_disabled.snap new file mode 100644 index 0000000000..82bd3336ca --- /dev/null +++ b/apollo-router/tests/snapshots/apollo_reports__features_disabled.snap @@ -0,0 +1,151 @@ +--- +source: apollo-router/tests/apollo_reports.rs +expression: report +snapshot_kind: text +--- +header: + graph_ref: test + hostname: "[hostname]" + agent_version: "[agent_version]" + service_version: "" + runtime_version: rust + uname: "[uname]" + executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" +traces_per_query: + "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": + trace: [] + stats_with_context: + - context: + client_name: "" + client_version: "" + operation_type: query + operation_subtype: "" + result: "" + client_library_name: "" + client_library_version: "" + query_latency_stats: + latency_count: "[latency_count]" + request_count: 1 + cache_hits: 0 + persisted_query_hits: 0 + persisted_query_misses: 0 + cache_latency_count: "[cache_latency_count]" + root_error_stats: + children: {} + errors_count: 0 + requests_with_errors_count: 0 + requests_with_errors_count: 0 + public_cache_ttl_count: "[public_cache_ttl_count]" + private_cache_ttl_count: "[private_cache_ttl_count]" + registered_operation_count: 0 + forbidden_operation_count: 0 + requests_without_field_instrumentation: 0 + per_type_stat: + Product: + per_field_stat: + name: + return_type: String + errors_count: 0 + observed_execution_count: 4 + estimated_execution_count: 4 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + reviews: + return_type: "[Review]" + errors_count: 0 + observed_execution_count: 4 + estimated_execution_count: 4 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + upc: + return_type: String! + errors_count: 0 + observed_execution_count: 4 + estimated_execution_count: 4 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + Query: + per_field_stat: + _entities: + return_type: "[_Entity]!" + errors_count: 0 + observed_execution_count: 2 + estimated_execution_count: 2 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + topProducts: + return_type: "[Product]" + errors_count: 0 + observed_execution_count: 1 + estimated_execution_count: 1 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + Review: + per_field_stat: + author: + return_type: User + errors_count: 0 + observed_execution_count: 4 + estimated_execution_count: 4 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + User: + per_field_stat: + id: + return_type: ID! + errors_count: 0 + observed_execution_count: 4 + estimated_execution_count: 4 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + name: + return_type: String + errors_count: 0 + observed_execution_count: 2 + estimated_execution_count: 2 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + extended_references: ~ + local_per_type_stat: {} + limits_stats: + strategy: "" + cost_estimated: + - 0 + max_cost_estimated: 0 + cost_actual: + - 0 + max_cost_actual: 0 + depth: 4 + height: 7 + alias_count: 0 + root_field_count: 1 + operation_count: 0 + referenced_fields_by_type: + Product: + field_names: + - name + - reviews + is_interface: false + Query: + field_names: + - topProducts + is_interface: false + Review: + field_names: + - author + is_interface: false + User: + field_names: + - name + is_interface: false + query_metadata: ~ +end_time: "[end_time]" +operation_count: 0 +operation_count_by_type: + - type: query + subtype: "" + operation_count: 1 +traces_pre_aggregated: true +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__features_enabled.snap b/apollo-router/tests/snapshots/apollo_reports__features_enabled.snap new file mode 100644 index 0000000000..4109e2d20a --- /dev/null +++ b/apollo-router/tests/snapshots/apollo_reports__features_enabled.snap @@ -0,0 +1,153 @@ +--- +source: apollo-router/tests/apollo_reports.rs +expression: report +snapshot_kind: text +--- +header: + graph_ref: test + hostname: "[hostname]" + agent_version: "[agent_version]" + service_version: "" + runtime_version: rust + uname: "[uname]" + executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" +traces_per_query: + "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": + trace: [] + stats_with_context: + - context: + client_name: "" + client_version: "" + operation_type: query + operation_subtype: "" + result: "" + client_library_name: "" + client_library_version: "" + query_latency_stats: + latency_count: "[latency_count]" + request_count: 1 + cache_hits: 0 + persisted_query_hits: 0 + persisted_query_misses: 0 + cache_latency_count: "[cache_latency_count]" + root_error_stats: + children: {} + errors_count: 0 + requests_with_errors_count: 0 + requests_with_errors_count: 0 + public_cache_ttl_count: "[public_cache_ttl_count]" + private_cache_ttl_count: "[private_cache_ttl_count]" + registered_operation_count: 0 + forbidden_operation_count: 0 + requests_without_field_instrumentation: 0 + per_type_stat: + Product: + per_field_stat: + name: + return_type: String + errors_count: 0 + observed_execution_count: 4 + estimated_execution_count: 4 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + reviews: + return_type: "[Review]" + errors_count: 0 + observed_execution_count: 4 + estimated_execution_count: 4 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + upc: + return_type: String! + errors_count: 0 + observed_execution_count: 4 + estimated_execution_count: 4 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + Query: + per_field_stat: + _entities: + return_type: "[_Entity]!" + errors_count: 0 + observed_execution_count: 2 + estimated_execution_count: 2 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + topProducts: + return_type: "[Product]" + errors_count: 0 + observed_execution_count: 1 + estimated_execution_count: 1 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + Review: + per_field_stat: + author: + return_type: User + errors_count: 0 + observed_execution_count: 4 + estimated_execution_count: 4 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + User: + per_field_stat: + id: + return_type: ID! + errors_count: 0 + observed_execution_count: 4 + estimated_execution_count: 4 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + name: + return_type: String + errors_count: 0 + observed_execution_count: 2 + estimated_execution_count: 2 + requests_with_errors_count: 0 + latency_count: "[latency_count]" + extended_references: ~ + local_per_type_stat: {} + limits_stats: + strategy: "" + cost_estimated: + - 0 + max_cost_estimated: 0 + cost_actual: + - 0 + max_cost_actual: 0 + depth: 4 + height: 7 + alias_count: 0 + root_field_count: 1 + operation_count: 0 + referenced_fields_by_type: + Product: + field_names: + - name + - reviews + is_interface: false + Query: + field_names: + - topProducts + is_interface: false + Review: + field_names: + - author + is_interface: false + User: + field_names: + - name + is_interface: false + query_metadata: ~ +end_time: "[end_time]" +operation_count: 0 +operation_count_by_type: + - type: query + subtype: "" + operation_count: 1 +traces_pre_aggregated: true +extended_references_enabled: true +router_features_enabled: + - distributed_apq_cache + - entity_cache diff --git a/apollo-router/tests/snapshots/apollo_reports__new_field_stats.snap b/apollo-router/tests/snapshots/apollo_reports__new_field_stats.snap index 805a2d472e..7a1f9fe6b7 100644 --- a/apollo-router/tests/snapshots/apollo_reports__new_field_stats.snap +++ b/apollo-router/tests/snapshots/apollo_reports__new_field_stats.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: [] @@ -20,6 +22,8 @@ traces_per_query: operation_type: query operation_subtype: "" result: "" + client_library_name: "" + client_library_version: "" query_latency_stats: latency_count: "[latency_count]" request_count: 1 @@ -251,4 +255,5 @@ operation_count_by_type: subtype: "" operation_count: 1 traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__non_defer-2.snap b/apollo-router/tests/snapshots/apollo_reports__non_defer-2.snap index 4cfbf4acc1..9322cd7f91 100644 --- a/apollo-router/tests/snapshots/apollo_reports__non_defer-2.snap +++ b/apollo-router/tests/snapshots/apollo_reports__non_defer-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -615,4 +617,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__non_defer.snap b/apollo-router/tests/snapshots/apollo_reports__non_defer.snap index 4cfbf4acc1..9322cd7f91 100644 --- a/apollo-router/tests/snapshots/apollo_reports__non_defer.snap +++ b/apollo-router/tests/snapshots/apollo_reports__non_defer.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -615,4 +617,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__send_header-2.snap b/apollo-router/tests/snapshots/apollo_reports__send_header-2.snap index d08aba4499..b719d64c3f 100644 --- a/apollo-router/tests/snapshots/apollo_reports__send_header-2.snap +++ b/apollo-router/tests/snapshots/apollo_reports__send_header-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -618,4 +620,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__send_header.snap b/apollo-router/tests/snapshots/apollo_reports__send_header.snap index d08aba4499..b719d64c3f 100644 --- a/apollo-router/tests/snapshots/apollo_reports__send_header.snap +++ b/apollo-router/tests/snapshots/apollo_reports__send_header.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -618,4 +620,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__send_variable_value-2.snap b/apollo-router/tests/snapshots/apollo_reports__send_variable_value-2.snap index e10815fe3b..2efea626c3 100644 --- a/apollo-router/tests/snapshots/apollo_reports__send_variable_value-2.snap +++ b/apollo-router/tests/snapshots/apollo_reports__send_variable_value-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\nquery($dontSendValue:Boolean!,$sendValue:Boolean!){topProducts{name reviews@include(if:$sendValue){author{name}}reviews@include(if:$dontSendValue){author{name}}}}": trace: @@ -617,4 +619,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__send_variable_value.snap b/apollo-router/tests/snapshots/apollo_reports__send_variable_value.snap index e10815fe3b..2efea626c3 100644 --- a/apollo-router/tests/snapshots/apollo_reports__send_variable_value.snap +++ b/apollo-router/tests/snapshots/apollo_reports__send_variable_value.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\nquery($dontSendValue:Boolean!,$sendValue:Boolean!){topProducts{name reviews@include(if:$sendValue){author{name}}reviews@include(if:$dontSendValue){author{name}}}}": trace: @@ -617,4 +619,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__stats.snap b/apollo-router/tests/snapshots/apollo_reports__stats.snap index a2b3c0eb79..82bd3336ca 100644 --- a/apollo-router/tests/snapshots/apollo_reports__stats.snap +++ b/apollo-router/tests/snapshots/apollo_reports__stats.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: [] @@ -20,6 +22,8 @@ traces_per_query: operation_type: query operation_subtype: "" result: "" + client_library_name: "" + client_library_version: "" query_latency_stats: latency_count: "[latency_count]" request_count: 1 @@ -143,4 +147,5 @@ operation_count_by_type: subtype: "" operation_count: 1 traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__stats_mocked.snap b/apollo-router/tests/snapshots/apollo_reports__stats_mocked.snap index 816bd2e891..b9835f99a4 100644 --- a/apollo-router/tests/snapshots/apollo_reports__stats_mocked.snap +++ b/apollo-router/tests/snapshots/apollo_reports__stats_mocked.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: stats +snapshot_kind: text --- context: client_name: "" @@ -8,6 +9,8 @@ context: operation_type: query operation_subtype: "" result: "" + client_library_name: "" + client_library_version: "" query_latency_stats: latency_count: "[latency_count]" request_count: 1 diff --git a/apollo-router/tests/snapshots/apollo_reports__trace_id-2.snap b/apollo-router/tests/snapshots/apollo_reports__trace_id-2.snap index 4cfbf4acc1..9322cd7f91 100644 --- a/apollo-router/tests/snapshots/apollo_reports__trace_id-2.snap +++ b/apollo-router/tests/snapshots/apollo_reports__trace_id-2.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -615,4 +617,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/apollo_reports__trace_id.snap b/apollo-router/tests/snapshots/apollo_reports__trace_id.snap index 4cfbf4acc1..9322cd7f91 100644 --- a/apollo-router/tests/snapshots/apollo_reports__trace_id.snap +++ b/apollo-router/tests/snapshots/apollo_reports__trace_id.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/apollo_reports.rs expression: report +snapshot_kind: text --- header: graph_ref: test @@ -10,6 +11,7 @@ header: runtime_version: rust uname: "[uname]" executable_schema_id: "[executable_schema_id]" + agent_id: "[agent_id]" traces_per_query: "# -\n{topProducts{name reviews{author{name}}reviews{author{name}}}}": trace: @@ -615,4 +617,5 @@ end_time: "[end_time]" operation_count: 0 operation_count_by_type: [] traces_pre_aggregated: true -extended_references_enabled: false +extended_references_enabled: true +router_features_enabled: [] diff --git a/apollo-router/tests/snapshots/set_context__set_context.snap b/apollo-router/tests/snapshots/set_context__set_context.snap index 18bfcbfcc9..264f37cdb6 100644 --- a/apollo-router/tests/snapshots/set_context__set_context.snap +++ b/apollo-router/tests/snapshots/set_context__set_context.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/set_context.rs expression: response +snapshot_kind: text --- { "data": { @@ -34,7 +35,7 @@ expression: response "operationKind": "query", "operationName": "Query__Subgraph1__0", "outputRewrites": null, - "schemaAwareHash": "0163c552923b61fbde6dbcd879ffc2bb887175dc41bbf75a272875524e664e8d", + "schemaAwareHash": "c36f4148cc7bdd9d001316592ffaa2c0079f93586fb1ca110aa5107262afdb44", "serviceName": "Subgraph1", "variableUsages": [] }, @@ -80,7 +81,7 @@ expression: response "typeCondition": "U" } ], - "schemaAwareHash": "e64d79913c52a4a8b95bfae44986487a1ac73118f27df3b602972a5cbb1f360a", + "schemaAwareHash": "6d7e81f6b6fb544929a7f5fbc9123b6a5ad49687dbd4528d2c5b91d0706ded2f", "serviceName": "Subgraph1", "variableUsages": [ "contextualArgument_1_0" diff --git a/apollo-router/tests/snapshots/set_context__set_context_dependent_fetch_failure.snap b/apollo-router/tests/snapshots/set_context__set_context_dependent_fetch_failure.snap index 4c44587cdd..92771a71ae 100644 --- a/apollo-router/tests/snapshots/set_context__set_context_dependent_fetch_failure.snap +++ b/apollo-router/tests/snapshots/set_context__set_context_dependent_fetch_failure.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/set_context.rs expression: response +snapshot_kind: text --- { "data": null, @@ -16,7 +17,10 @@ expression: response "path": [ "t", "u" - ] + ], + "extensions": { + "service": "Subgraph1" + } } ], "extensions": { @@ -40,7 +44,7 @@ expression: response "operationKind": "query", "operationName": "Query_fetch_dependent_failure__Subgraph1__0", "outputRewrites": null, - "schemaAwareHash": "6bcaa7a2d52a416d5278eaef6be102427f328b6916075f193c87459516a7fb6d", + "schemaAwareHash": "d93d363a607457b0fab08dcc177dadd9f647e2ce2c0e893017c99b9e0a452d15", "serviceName": "Subgraph1", "variableUsages": [] }, @@ -86,7 +90,7 @@ expression: response "typeCondition": "U" } ], - "schemaAwareHash": "0e56752501c8cbf53429c5aa2df95765ea2c7cba95db9213ce42918699232651", + "schemaAwareHash": "1e00dfec6c7e1f482d9615cc2d530e79d2503f8527e2f3ee60a1b5b462088d5d", "serviceName": "Subgraph1", "variableUsages": [ "contextualArgument_1_0" diff --git a/apollo-router/tests/snapshots/set_context__set_context_dependent_fetch_failure_rust_qp.snap b/apollo-router/tests/snapshots/set_context__set_context_dependent_fetch_failure_rust_qp.snap new file mode 100644 index 0000000000..c79c208c94 --- /dev/null +++ b/apollo-router/tests/snapshots/set_context__set_context_dependent_fetch_failure_rust_qp.snap @@ -0,0 +1,115 @@ +--- +source: apollo-router/tests/set_context.rs +expression: response +--- +{ + "data": null, + "errors": [ + { + "message": "Some error", + "locations": [ + { + "line": 3, + "column": 5 + } + ], + "path": [ + "t", + "u" + ], + "extensions": { + "service": "Subgraph1" + } + } + ], + "extensions": { + "apolloQueryPlan": { + "object": { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": null, + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query Query_fetch_dependent_failure__Subgraph1__0 { t { __typename prop id u { __typename id } } }", + "operationKind": "query", + "operationName": "Query_fetch_dependent_failure__Subgraph1__0", + "outputRewrites": null, + "schemaAwareHash": "569ab7ca4da4e942befcc46731e0430b300df8c1722959f69538f9a5c334ced6", + "serviceName": "Subgraph1", + "variableUsages": [] + }, + { + "kind": "Flatten", + "node": { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": [ + { + "kind": "KeyRenamer", + "path": [ + "..", + "... on T", + "prop" + ], + "renameKeyTo": "contextualArgument_1_0" + } + ], + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query Query_fetch_dependent_failure__Subgraph1__1($representations: [_Any!]!, $contextualArgument_1_0: String) { _entities(representations: $representations) { ... on U { field(a: $contextualArgument_1_0) } } }", + "operationKind": "query", + "operationName": "Query_fetch_dependent_failure__Subgraph1__1", + "outputRewrites": null, + "requires": [ + { + "kind": "InlineFragment", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ], + "typeCondition": "U" + } + ], + "schemaAwareHash": "442e475d0c326977d66ce775e73fc09fa78c8283bbc4f46eee9f73bf8d250497", + "serviceName": "Subgraph1", + "variableUsages": [ + "contextualArgument_1_0" + ] + }, + "path": [ + "t", + "u" + ] + } + ] + } + }, + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"Subgraph1\") {\n {\n t {\n __typename\n prop\n id\n u {\n __typename\n id\n }\n }\n }\n },\n Flatten(path: \"t.u\") {\n Fetch(service: \"Subgraph1\") {\n {\n ... on U {\n __typename\n id\n }\n } =>\n {\n ... on U {\n field(a: $contextualArgument_1_0)\n }\n }\n },\n },\n },\n}" + }, + "valueCompletion": [ + { + "message": "Cannot return null for non-nullable field Query.t", + "path": [] + } + ] + } +} diff --git a/apollo-router/tests/snapshots/set_context__set_context_list.snap b/apollo-router/tests/snapshots/set_context__set_context_list.snap index d6dd312f0a..d1111153fb 100644 --- a/apollo-router/tests/snapshots/set_context__set_context_list.snap +++ b/apollo-router/tests/snapshots/set_context__set_context_list.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/set_context.rs expression: response +snapshot_kind: text --- { "data": { @@ -40,7 +41,7 @@ expression: response "operationKind": "query", "operationName": "Query__Subgraph1__0", "outputRewrites": null, - "schemaAwareHash": "805348468cefee0e3e745cb1bcec0ab4bd44ba55f6ddb91e52e0bc9b437c2dee", + "schemaAwareHash": "fae8d004c9012c52e0eaf36b650afe513a58164f3b7c6721a92194dc2594e222", "serviceName": "Subgraph1", "variableUsages": [] }, @@ -86,7 +87,7 @@ expression: response "typeCondition": "U" } ], - "schemaAwareHash": "e64d79913c52a4a8b95bfae44986487a1ac73118f27df3b602972a5cbb1f360a", + "schemaAwareHash": "6d7e81f6b6fb544929a7f5fbc9123b6a5ad49687dbd4528d2c5b91d0706ded2f", "serviceName": "Subgraph1", "variableUsages": [ "contextualArgument_1_0" diff --git a/apollo-router/tests/snapshots/set_context__set_context_list_of_lists.snap b/apollo-router/tests/snapshots/set_context__set_context_list_of_lists.snap index c390c1db88..e089bf37c3 100644 --- a/apollo-router/tests/snapshots/set_context__set_context_list_of_lists.snap +++ b/apollo-router/tests/snapshots/set_context__set_context_list_of_lists.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/set_context.rs expression: response +snapshot_kind: text --- { "data": { @@ -44,7 +45,7 @@ expression: response "operationKind": "query", "operationName": "QueryLL__Subgraph1__0", "outputRewrites": null, - "schemaAwareHash": "53e85332dda78d566187c8886c207b81acfe3ab5ea0cafd3d71fb0b153026d80", + "schemaAwareHash": "1623e5c5abcc506f378e168da719f9163a03ee62feba9413b5664c8d6b5f1b37", "serviceName": "Subgraph1", "variableUsages": [] }, @@ -90,7 +91,7 @@ expression: response "typeCondition": "U" } ], - "schemaAwareHash": "8ed6f85b6a77c293c97171b4a98f7dd563e98a737d4c3a9f5c54911248498ec7", + "schemaAwareHash": "9f32284e712d75def9f4f7d28f563ff31101b9e6492cef5158b9b10b3a389cd2", "serviceName": "Subgraph1", "variableUsages": [ "contextualArgument_1_0" diff --git a/apollo-router/tests/snapshots/set_context__set_context_list_of_lists_rust_qp.snap b/apollo-router/tests/snapshots/set_context__set_context_list_of_lists_rust_qp.snap new file mode 100644 index 0000000000..cbd8b3f2d2 --- /dev/null +++ b/apollo-router/tests/snapshots/set_context__set_context_list_of_lists_rust_qp.snap @@ -0,0 +1,112 @@ +--- +source: apollo-router/tests/set_context.rs +expression: response +--- +{ + "data": { + "tList": [ + { + "id": "1", + "uList": [ + { + "field": 3456 + } + ] + }, + { + "id": "2", + "uList": [ + { + "field": 4567 + } + ] + } + ] + }, + "extensions": { + "apolloQueryPlan": { + "object": { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": null, + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query QueryLL__Subgraph1__0 { tList { __typename prop id uList { __typename id } } }", + "operationKind": "query", + "operationName": "QueryLL__Subgraph1__0", + "outputRewrites": null, + "schemaAwareHash": "425bcf9f712763140064267ac6a4ed1a4535d8173b221e541a45f066d69b1c0e", + "serviceName": "Subgraph1", + "variableUsages": [] + }, + { + "kind": "Flatten", + "node": { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": [ + { + "kind": "KeyRenamer", + "path": [ + "..", + "... on T", + "prop" + ], + "renameKeyTo": "contextualArgument_1_0" + } + ], + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query QueryLL__Subgraph1__1($representations: [_Any!]!, $contextualArgument_1_0: String) { _entities(representations: $representations) { ... on U { field(a: $contextualArgument_1_0) } } }", + "operationKind": "query", + "operationName": "QueryLL__Subgraph1__1", + "outputRewrites": null, + "requires": [ + { + "kind": "InlineFragment", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ], + "typeCondition": "U" + } + ], + "schemaAwareHash": "e6d8f976fff42b1377852384fa799f42b4098aba58d394a735fd3d6dc35d5912", + "serviceName": "Subgraph1", + "variableUsages": [ + "contextualArgument_1_0" + ] + }, + "path": [ + "tList", + "@", + "uList", + "@" + ] + } + ] + } + }, + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"Subgraph1\") {\n {\n tList {\n __typename\n prop\n id\n uList {\n __typename\n id\n }\n }\n }\n },\n Flatten(path: \"tList.@.uList.@\") {\n Fetch(service: \"Subgraph1\") {\n {\n ... on U {\n __typename\n id\n }\n } =>\n {\n ... on U {\n field(a: $contextualArgument_1_0)\n }\n }\n },\n },\n },\n}" + } + } +} diff --git a/apollo-router/tests/snapshots/set_context__set_context_list_rust_qp.snap b/apollo-router/tests/snapshots/set_context__set_context_list_rust_qp.snap new file mode 100644 index 0000000000..fd368dbf67 --- /dev/null +++ b/apollo-router/tests/snapshots/set_context__set_context_list_rust_qp.snap @@ -0,0 +1,107 @@ +--- +source: apollo-router/tests/set_context.rs +expression: response +--- +{ + "data": { + "t": { + "id": "1", + "uList": [ + { + "field": 1234 + }, + { + "field": 2345 + }, + { + "field": 3456 + } + ] + } + }, + "extensions": { + "apolloQueryPlan": { + "object": { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": null, + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query set_context_list_rust_qp__Subgraph1__0 { t { __typename prop id uList { __typename id } } }", + "operationKind": "query", + "operationName": "set_context_list_rust_qp__Subgraph1__0", + "outputRewrites": null, + "schemaAwareHash": "b8eabeaed12ae737a1cc7c50035e2ac63d4c8a6d62bed24c92180c97941a1fb9", + "serviceName": "Subgraph1", + "variableUsages": [] + }, + { + "kind": "Flatten", + "node": { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": [ + { + "kind": "KeyRenamer", + "path": [ + "..", + "... on T", + "prop" + ], + "renameKeyTo": "contextualArgument_1_0" + } + ], + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query set_context_list_rust_qp__Subgraph1__1($representations: [_Any!]!, $contextualArgument_1_0: String) { _entities(representations: $representations) { ... on U { field(a: $contextualArgument_1_0) } } }", + "operationKind": "query", + "operationName": "set_context_list_rust_qp__Subgraph1__1", + "outputRewrites": null, + "requires": [ + { + "kind": "InlineFragment", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ], + "typeCondition": "U" + } + ], + "schemaAwareHash": "d801b69285962867aa552a9c277d044d322a28293dbf308af2e0968b0c1a0b4c", + "serviceName": "Subgraph1", + "variableUsages": [ + "contextualArgument_1_0" + ] + }, + "path": [ + "t", + "uList", + "@" + ] + } + ] + } + }, + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"Subgraph1\") {\n {\n t {\n __typename\n prop\n id\n uList {\n __typename\n id\n }\n }\n }\n },\n Flatten(path: \"t.uList.@\") {\n Fetch(service: \"Subgraph1\") {\n {\n ... on U {\n __typename\n id\n }\n } =>\n {\n ... on U {\n field(a: $contextualArgument_1_0)\n }\n }\n },\n },\n },\n}" + } + } +} diff --git a/apollo-router/tests/snapshots/set_context__set_context_no_typenames.snap b/apollo-router/tests/snapshots/set_context__set_context_no_typenames.snap index e9743a7902..40018ffb66 100644 --- a/apollo-router/tests/snapshots/set_context__set_context_no_typenames.snap +++ b/apollo-router/tests/snapshots/set_context__set_context_no_typenames.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/set_context.rs expression: response +snapshot_kind: text --- { "data": { @@ -32,7 +33,7 @@ expression: response "operationKind": "query", "operationName": "Query__Subgraph1__0", "outputRewrites": null, - "schemaAwareHash": "0163c552923b61fbde6dbcd879ffc2bb887175dc41bbf75a272875524e664e8d", + "schemaAwareHash": "c36f4148cc7bdd9d001316592ffaa2c0079f93586fb1ca110aa5107262afdb44", "serviceName": "Subgraph1", "variableUsages": [] }, @@ -78,7 +79,7 @@ expression: response "typeCondition": "U" } ], - "schemaAwareHash": "e64d79913c52a4a8b95bfae44986487a1ac73118f27df3b602972a5cbb1f360a", + "schemaAwareHash": "6d7e81f6b6fb544929a7f5fbc9123b6a5ad49687dbd4528d2c5b91d0706ded2f", "serviceName": "Subgraph1", "variableUsages": [ "contextualArgument_1_0" diff --git a/apollo-router/tests/snapshots/set_context__set_context_no_typenames_rust_qp.snap b/apollo-router/tests/snapshots/set_context__set_context_no_typenames_rust_qp.snap new file mode 100644 index 0000000000..35c67397d2 --- /dev/null +++ b/apollo-router/tests/snapshots/set_context__set_context_no_typenames_rust_qp.snap @@ -0,0 +1,98 @@ +--- +source: apollo-router/tests/set_context.rs +expression: response +--- +{ + "data": { + "t": { + "id": "1", + "u": { + "field": 1234 + } + } + }, + "extensions": { + "apolloQueryPlan": { + "object": { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": null, + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query set_context_no_typenames_rust_qp__Subgraph1__0 { t { __typename prop id u { __typename id } } }", + "operationKind": "query", + "operationName": "set_context_no_typenames_rust_qp__Subgraph1__0", + "outputRewrites": null, + "schemaAwareHash": "fd904a72b9f87ab2888398206aba6bfe11d8b1714f8b8e097b33d59c603ffef3", + "serviceName": "Subgraph1", + "variableUsages": [] + }, + { + "kind": "Flatten", + "node": { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": [ + { + "kind": "KeyRenamer", + "path": [ + "..", + "... on T", + "prop" + ], + "renameKeyTo": "contextualArgument_1_0" + } + ], + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query set_context_no_typenames_rust_qp__Subgraph1__1($representations: [_Any!]!, $contextualArgument_1_0: String) { _entities(representations: $representations) { ... on U { field(a: $contextualArgument_1_0) } } }", + "operationKind": "query", + "operationName": "set_context_no_typenames_rust_qp__Subgraph1__1", + "outputRewrites": null, + "requires": [ + { + "kind": "InlineFragment", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ], + "typeCondition": "U" + } + ], + "schemaAwareHash": "264ddabb51132e21645ae42be25d66a86ba545741375f84b2acfb74eb051e894", + "serviceName": "Subgraph1", + "variableUsages": [ + "contextualArgument_1_0" + ] + }, + "path": [ + "t", + "u" + ] + } + ] + } + }, + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"Subgraph1\") {\n {\n t {\n __typename\n prop\n id\n u {\n __typename\n id\n }\n }\n }\n },\n Flatten(path: \"t.u\") {\n Fetch(service: \"Subgraph1\") {\n {\n ... on U {\n __typename\n id\n }\n } =>\n {\n ... on U {\n field(a: $contextualArgument_1_0)\n }\n }\n },\n },\n },\n}" + } + } +} diff --git a/apollo-router/tests/snapshots/set_context__set_context_rust_qp.snap b/apollo-router/tests/snapshots/set_context__set_context_rust_qp.snap new file mode 100644 index 0000000000..8c6ab7834e --- /dev/null +++ b/apollo-router/tests/snapshots/set_context__set_context_rust_qp.snap @@ -0,0 +1,100 @@ +--- +source: apollo-router/tests/set_context.rs +expression: response +--- +{ + "data": { + "t": { + "__typename": "T", + "id": "1", + "u": { + "__typename": "U", + "field": 1234 + } + } + }, + "extensions": { + "apolloQueryPlan": { + "object": { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": null, + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query set_context_rust_qp__Subgraph1__0 { t { __typename prop id u { __typename id } } }", + "operationKind": "query", + "operationName": "set_context_rust_qp__Subgraph1__0", + "outputRewrites": null, + "schemaAwareHash": "54f679312d4f2a55540b6f61c2e5edd08730f5fe8144c5a6eb00d70b436f2624", + "serviceName": "Subgraph1", + "variableUsages": [] + }, + { + "kind": "Flatten", + "node": { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": [ + { + "kind": "KeyRenamer", + "path": [ + "..", + "... on T", + "prop" + ], + "renameKeyTo": "contextualArgument_1_0" + } + ], + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query set_context_rust_qp__Subgraph1__1($representations: [_Any!]!, $contextualArgument_1_0: String) { _entities(representations: $representations) { ... on U { field(a: $contextualArgument_1_0) } } }", + "operationKind": "query", + "operationName": "set_context_rust_qp__Subgraph1__1", + "outputRewrites": null, + "requires": [ + { + "kind": "InlineFragment", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ], + "typeCondition": "U" + } + ], + "schemaAwareHash": "6d0ab37ea22f87ec72016e87614235b994299718634744637a5e8c3904a3b6f3", + "serviceName": "Subgraph1", + "variableUsages": [ + "contextualArgument_1_0" + ] + }, + "path": [ + "t", + "u" + ] + } + ] + } + }, + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"Subgraph1\") {\n {\n t {\n __typename\n prop\n id\n u {\n __typename\n id\n }\n }\n }\n },\n Flatten(path: \"t.u\") {\n Fetch(service: \"Subgraph1\") {\n {\n ... on U {\n __typename\n id\n }\n } =>\n {\n ... on U {\n field(a: $contextualArgument_1_0)\n }\n }\n },\n },\n },\n}" + } + } +} diff --git a/apollo-router/tests/snapshots/set_context__set_context_type_mismatch.snap b/apollo-router/tests/snapshots/set_context__set_context_type_mismatch.snap index 3208b9bf0a..c985004f1b 100644 --- a/apollo-router/tests/snapshots/set_context__set_context_type_mismatch.snap +++ b/apollo-router/tests/snapshots/set_context__set_context_type_mismatch.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/set_context.rs expression: response +snapshot_kind: text --- { "data": { @@ -32,7 +33,7 @@ expression: response "operationKind": "query", "operationName": "Query_type_mismatch__Subgraph1__0", "outputRewrites": null, - "schemaAwareHash": "34c8f7c0f16220c5d4b589c8da405f49510e092756fa98629c73dea06fd7c243", + "schemaAwareHash": "76744b6c5c054056f7aa9ebdd8ee4f64ee6534e49ededa336b7dae377d1224bc", "serviceName": "Subgraph1", "variableUsages": [] }, @@ -78,7 +79,7 @@ expression: response "typeCondition": "U" } ], - "schemaAwareHash": "feb578fd1831280f376d8961644e670dd8c3508d0a18fcf69a6de651e25e9ca8", + "schemaAwareHash": "d420e2fba88b355380ea18a55bc27b3aff8d776bd03d04d5f44e19402a4eff10", "serviceName": "Subgraph1", "variableUsages": [ "contextualArgument_1_0" diff --git a/apollo-router/tests/snapshots/set_context__set_context_type_mismatch_rust_qp.snap b/apollo-router/tests/snapshots/set_context__set_context_type_mismatch_rust_qp.snap new file mode 100644 index 0000000000..0d059970c5 --- /dev/null +++ b/apollo-router/tests/snapshots/set_context__set_context_type_mismatch_rust_qp.snap @@ -0,0 +1,98 @@ +--- +source: apollo-router/tests/set_context.rs +expression: response +--- +{ + "data": { + "t": { + "id": "1", + "u": { + "field": 1234 + } + } + }, + "extensions": { + "apolloQueryPlan": { + "object": { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": null, + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query Query_type_mismatch__Subgraph1__0 { t { __typename prop id u { __typename id } } }", + "operationKind": "query", + "operationName": "Query_type_mismatch__Subgraph1__0", + "outputRewrites": null, + "schemaAwareHash": "f53a611eec629c68123f448cc82414a441f84147e8558727a2afde83c76a1086", + "serviceName": "Subgraph1", + "variableUsages": [] + }, + { + "kind": "Flatten", + "node": { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": [ + { + "kind": "KeyRenamer", + "path": [ + "..", + "... on T", + "prop" + ], + "renameKeyTo": "contextualArgument_1_0" + } + ], + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query Query_type_mismatch__Subgraph1__1($representations: [_Any!]!, $contextualArgument_1_0: String) { _entities(representations: $representations) { ... on U { field(a: $contextualArgument_1_0) } } }", + "operationKind": "query", + "operationName": "Query_type_mismatch__Subgraph1__1", + "outputRewrites": null, + "requires": [ + { + "kind": "InlineFragment", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ], + "typeCondition": "U" + } + ], + "schemaAwareHash": "5f0a23ef9f7cdbc32775c09d0745e06b0396772352713e40f911ad515954340d", + "serviceName": "Subgraph1", + "variableUsages": [ + "contextualArgument_1_0" + ] + }, + "path": [ + "t", + "u" + ] + } + ] + } + }, + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"Subgraph1\") {\n {\n t {\n __typename\n prop\n id\n u {\n __typename\n id\n }\n }\n }\n },\n Flatten(path: \"t.u\") {\n Fetch(service: \"Subgraph1\") {\n {\n ... on U {\n __typename\n id\n }\n } =>\n {\n ... on U {\n field(a: $contextualArgument_1_0)\n }\n }\n },\n },\n },\n}" + } + } +} diff --git a/apollo-router/tests/snapshots/set_context__set_context_union.snap b/apollo-router/tests/snapshots/set_context__set_context_union.snap index 6c995c1e8b..ce3c434108 100644 --- a/apollo-router/tests/snapshots/set_context__set_context_union.snap +++ b/apollo-router/tests/snapshots/set_context__set_context_union.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/set_context.rs expression: response +snapshot_kind: text --- { "data": { @@ -31,7 +32,7 @@ expression: response "operationKind": "query", "operationName": "QueryUnion__Subgraph1__0", "outputRewrites": null, - "schemaAwareHash": "3e768a1879f4ced427937721980688052b471dbfee0d653b212c85f2732591cc", + "schemaAwareHash": "5ca91ce52f6337db88ab61d494f5b3b52b37a4a37bf9efb386cec134e86d4660", "serviceName": "Subgraph1", "variableUsages": [] }, @@ -80,7 +81,7 @@ expression: response "typeCondition": "V" } ], - "schemaAwareHash": "0c190d5db5b15f89fa45de844d2cec59725986e44fcb0dbdb9ab870a197cf026", + "schemaAwareHash": "bd757755f6a3cb0116e3a27612807f80a9ea376e3a8fe7bb2bff9c94290953dd", "serviceName": "Subgraph1", "variableUsages": [ "contextualArgument_1_1" @@ -134,7 +135,7 @@ expression: response "typeCondition": "V" } ], - "schemaAwareHash": "2d7376a8d1f7f2a929361e838bb0435ed4c4a6194fa8754af52d4b6dc7140508", + "schemaAwareHash": "f4c7f9d3ce28970fcb8676c9808fa1609e2d88643bf9c43804311ecb4a4001e1", "serviceName": "Subgraph1", "variableUsages": [ "contextualArgument_1_1" diff --git a/apollo-router/tests/snapshots/set_context__set_context_union_rust_qp.snap b/apollo-router/tests/snapshots/set_context__set_context_union_rust_qp.snap new file mode 100644 index 0000000000..9b96ef7db8 --- /dev/null +++ b/apollo-router/tests/snapshots/set_context__set_context_union_rust_qp.snap @@ -0,0 +1,155 @@ +--- +source: apollo-router/tests/set_context.rs +expression: response +--- +{ + "data": { + "k": { + "v": { + "field": 3456 + } + } + }, + "extensions": { + "apolloQueryPlan": { + "object": { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": null, + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query QueryUnion__Subgraph1__0 { k { __typename ... on A { __typename prop v { __typename id } } ... on B { __typename prop v { __typename id } } } }", + "operationKind": "query", + "operationName": "QueryUnion__Subgraph1__0", + "outputRewrites": null, + "schemaAwareHash": "b07c8cbf00f54dd78ca49bcaba8d5accd924374454412a15634381c2ee618d0e", + "serviceName": "Subgraph1", + "variableUsages": [] + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Flatten", + "node": { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": [ + { + "kind": "KeyRenamer", + "path": [ + "..", + "... on B", + "prop" + ], + "renameKeyTo": "contextualArgument_1_1" + } + ], + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query QueryUnion__Subgraph1__1($representations: [_Any!]!, $contextualArgument_1_1: String) { _entities(representations: $representations) { ... on V { field(a: $contextualArgument_1_1) } } }", + "operationKind": "query", + "operationName": "QueryUnion__Subgraph1__1", + "outputRewrites": null, + "requires": [ + { + "kind": "InlineFragment", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ], + "typeCondition": "V" + } + ], + "schemaAwareHash": "60a2a9613aa90408d60cd268833751b54701ba35acae9adaa88171e5717f8384", + "serviceName": "Subgraph1", + "variableUsages": [ + "contextualArgument_1_1" + ] + }, + "path": [ + "k|[B]", + "v" + ] + }, + { + "kind": "Flatten", + "node": { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": [ + { + "kind": "KeyRenamer", + "path": [ + "..", + "... on A", + "prop" + ], + "renameKeyTo": "contextualArgument_1_1" + } + ], + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query QueryUnion__Subgraph1__2($representations: [_Any!]!, $contextualArgument_1_1: String) { _entities(representations: $representations) { ... on V { field(a: $contextualArgument_1_1) } } }", + "operationKind": "query", + "operationName": "QueryUnion__Subgraph1__2", + "outputRewrites": null, + "requires": [ + { + "kind": "InlineFragment", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ], + "typeCondition": "V" + } + ], + "schemaAwareHash": "cf6368974fec9812df4a7d63d36b6bd750e6af3df3542f5d7097a1c9a2c6e326", + "serviceName": "Subgraph1", + "variableUsages": [ + "contextualArgument_1_1" + ] + }, + "path": [ + "k|[A]", + "v" + ] + } + ] + } + ] + } + }, + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"Subgraph1\") {\n {\n k {\n __typename\n ... on A {\n __typename\n prop\n v {\n __typename\n id\n }\n }\n ... on B {\n __typename\n prop\n v {\n __typename\n id\n }\n }\n }\n }\n },\n Parallel {\n Flatten(path: \"k|[B].v\") {\n Fetch(service: \"Subgraph1\") {\n {\n ... on V {\n __typename\n id\n }\n } =>\n {\n ... on V {\n field(a: $contextualArgument_1_1)\n }\n }\n },\n },\n Flatten(path: \"k|[A].v\") {\n Fetch(service: \"Subgraph1\") {\n {\n ... on V {\n __typename\n id\n }\n } =>\n {\n ... on V {\n field(a: $contextualArgument_1_1)\n }\n }\n },\n },\n },\n },\n}" + } + } +} diff --git a/apollo-router/tests/snapshots/set_context__set_context_unrelated_fetch_failure.snap b/apollo-router/tests/snapshots/set_context__set_context_unrelated_fetch_failure.snap index 4f28e80419..868b7de485 100644 --- a/apollo-router/tests/snapshots/set_context__set_context_unrelated_fetch_failure.snap +++ b/apollo-router/tests/snapshots/set_context__set_context_unrelated_fetch_failure.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/set_context.rs expression: response +snapshot_kind: text --- { "data": null, @@ -10,7 +11,10 @@ expression: response "path": [ "t", "u" - ] + ], + "extensions": { + "service": "Subgraph2" + } } ], "extensions": { @@ -34,7 +38,7 @@ expression: response "operationKind": "query", "operationName": "Query_fetch_failure__Subgraph1__0", "outputRewrites": null, - "schemaAwareHash": "84a7305d62d79b5bbca976c5522d6b32c5bbcbf76b495e4430f9cdcb51c80a57", + "schemaAwareHash": "d6133b40599aaa7350d8c321d5313a7823d3430caab4c250754d24bb88ad0bce", "serviceName": "Subgraph1", "variableUsages": [] }, @@ -73,7 +77,7 @@ expression: response "typeCondition": "U" } ], - "schemaAwareHash": "acb960692b01a756fcc627cafef1c47ead8afa60fa70828e5011ba9f825218ab", + "schemaAwareHash": "690a46547feb85f93bad65bf9c7015add2403adb114442971001b437549e45db", "serviceName": "Subgraph2", "variableUsages": [] }, @@ -125,7 +129,7 @@ expression: response "typeCondition": "U" } ], - "schemaAwareHash": "9fd65f6f213899810bce20180de6754354a25dc3c1bc97d0b7214a177cf8b0bb", + "schemaAwareHash": "5281c0f7b42b131f5e9a629d3340eac6026e5042580283b0f3d1b5892b351a22", "serviceName": "Subgraph1", "variableUsages": [ "contextualArgument_1_0" @@ -160,7 +164,7 @@ expression: response ] }, { - "message": "Cannot return null for non-nullable field Query.t", + "message": "Cannot return null for non-nullable field T!.t", "path": [ "t" ] diff --git a/apollo-router/tests/snapshots/set_context__set_context_unrelated_fetch_failure_rust_qp.snap b/apollo-router/tests/snapshots/set_context__set_context_unrelated_fetch_failure_rust_qp.snap new file mode 100644 index 0000000000..bbfe4ad84f --- /dev/null +++ b/apollo-router/tests/snapshots/set_context__set_context_unrelated_fetch_failure_rust_qp.snap @@ -0,0 +1,171 @@ +--- +source: apollo-router/tests/set_context.rs +expression: response +--- +{ + "data": null, + "errors": [ + { + "message": "Some error", + "path": [ + "t", + "u" + ], + "extensions": { + "service": "Subgraph2" + } + } + ], + "extensions": { + "apolloQueryPlan": { + "object": { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": null, + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query Query_fetch_failure__Subgraph1__0 { t { __typename prop id u { __typename id } } }", + "operationKind": "query", + "operationName": "Query_fetch_failure__Subgraph1__0", + "outputRewrites": null, + "schemaAwareHash": "5580d1aad73ae6a685200b5ed6c3bb917f6d6e8da215e0ad9f7f491fc3b776e7", + "serviceName": "Subgraph1", + "variableUsages": [] + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Flatten", + "node": { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": [ + { + "kind": "KeyRenamer", + "path": [ + "..", + "... on T", + "prop" + ], + "renameKeyTo": "contextualArgument_1_0" + } + ], + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query Query_fetch_failure__Subgraph1__1($representations: [_Any!]!, $contextualArgument_1_0: String) { _entities(representations: $representations) { ... on U { field(a: $contextualArgument_1_0) } } }", + "operationKind": "query", + "operationName": "Query_fetch_failure__Subgraph1__1", + "outputRewrites": null, + "requires": [ + { + "kind": "InlineFragment", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ], + "typeCondition": "U" + } + ], + "schemaAwareHash": "d33d8040456aa4c2c67fba60c0621d8bc656baf994541c453c479492a0627f3c", + "serviceName": "Subgraph1", + "variableUsages": [ + "contextualArgument_1_0" + ] + }, + "path": [ + "t", + "u" + ] + }, + { + "kind": "Flatten", + "node": { + "authorization": { + "is_authenticated": false, + "policies": [], + "scopes": [] + }, + "contextRewrites": null, + "id": null, + "inputRewrites": null, + "kind": "Fetch", + "operation": "query Query_fetch_failure__Subgraph2__2($representations: [_Any!]!) { _entities(representations: $representations) { ... on U { b } } }", + "operationKind": "query", + "operationName": "Query_fetch_failure__Subgraph2__2", + "outputRewrites": null, + "requires": [ + { + "kind": "InlineFragment", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ], + "typeCondition": "U" + } + ], + "schemaAwareHash": "496c2e23024bad910adfe1c7327571e9e87685ea51f61c4f37ea05a489a26a22", + "serviceName": "Subgraph2", + "variableUsages": [] + }, + "path": [ + "t", + "u" + ] + } + ] + } + ] + } + }, + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"Subgraph1\") {\n {\n t {\n __typename\n prop\n id\n u {\n __typename\n id\n }\n }\n }\n },\n Parallel {\n Flatten(path: \"t.u\") {\n Fetch(service: \"Subgraph1\") {\n {\n ... on U {\n __typename\n id\n }\n } =>\n {\n ... on U {\n field(a: $contextualArgument_1_0)\n }\n }\n },\n },\n Flatten(path: \"t.u\") {\n Fetch(service: \"Subgraph2\") {\n {\n ... on U {\n __typename\n id\n }\n } =>\n {\n ... on U {\n b\n }\n }\n },\n },\n },\n },\n}" + }, + "valueCompletion": [ + { + "message": "Cannot return null for non-nullable field U.field", + "path": [ + "t", + "u" + ] + }, + { + "message": "Cannot return null for non-nullable field T.u", + "path": [ + "t", + "u" + ] + }, + { + "message": "Cannot return null for non-nullable field T!.t", + "path": [ + "t" + ] + } + ] + } +} diff --git a/apollo-router/tests/snapshots/set_context__set_context_with_null.snap b/apollo-router/tests/snapshots/set_context__set_context_with_null.snap index badc32bc8a..6c03832503 100644 --- a/apollo-router/tests/snapshots/set_context__set_context_with_null.snap +++ b/apollo-router/tests/snapshots/set_context__set_context_with_null.snap @@ -1,6 +1,7 @@ --- source: apollo-router/tests/set_context.rs expression: response +snapshot_kind: text --- { "data": { @@ -29,7 +30,7 @@ expression: response "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "4c0c9f83a57e9a50ff1f6dd601ec0a1588f1485d5cfb1015822af4017263e807", + "schemaAwareHash": "72f6c5c3c41936eba859c4327265e74c9421e41d684ce560f3cc5c0a1bef201f", "authorization": { "is_authenticated": false, "scopes": [], @@ -82,7 +83,7 @@ expression: response "renameKeyTo": "contextualArgument_1_0" } ], - "schemaAwareHash": "8db802e78024d406645f1ddc8972255e917bc738bfbed281691a45e34c92debb", + "schemaAwareHash": "2d6f86d4ed32670400197090358c53094c0f83892f3016ac1d067663d215b83a", "authorization": { "is_authenticated": false, "scopes": [], diff --git a/apollo-router/tests/snapshots/set_context__set_context_with_null_rust_qp.snap b/apollo-router/tests/snapshots/set_context__set_context_with_null_rust_qp.snap new file mode 100644 index 0000000000..64027e5e86 --- /dev/null +++ b/apollo-router/tests/snapshots/set_context__set_context_with_null_rust_qp.snap @@ -0,0 +1,98 @@ +--- +source: apollo-router/tests/set_context.rs +expression: response +--- +{ + "data": { + "t": { + "id": "1", + "u": { + "field": 1234 + } + } + }, + "extensions": { + "apolloQueryPlan": { + "object": { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "Subgraph1", + "variableUsages": [], + "operation": "query Query_Null_Param__Subgraph1__0 { t { __typename prop id u { __typename id } } }", + "operationName": "Query_Null_Param__Subgraph1__0", + "operationKind": "query", + "id": null, + "inputRewrites": null, + "outputRewrites": null, + "contextRewrites": null, + "schemaAwareHash": "2716f0c11f6083698549240a186bbf43c645a0e98a8fea2f7240280f3b239dd7", + "authorization": { + "is_authenticated": false, + "scopes": [], + "policies": [] + } + }, + { + "kind": "Flatten", + "path": [ + "t", + "u" + ], + "node": { + "kind": "Fetch", + "serviceName": "Subgraph1", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "U", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ] + } + ], + "variableUsages": [ + "contextualArgument_1_0" + ], + "operation": "query Query_Null_Param__Subgraph1__1($representations: [_Any!]!, $contextualArgument_1_0: String) { _entities(representations: $representations) { ... on U { field(a: $contextualArgument_1_0) } } }", + "operationName": "Query_Null_Param__Subgraph1__1", + "operationKind": "query", + "id": null, + "inputRewrites": null, + "outputRewrites": null, + "contextRewrites": [ + { + "kind": "KeyRenamer", + "path": [ + "..", + "... on T", + "prop" + ], + "renameKeyTo": "contextualArgument_1_0" + } + ], + "schemaAwareHash": "a2219ecb39711caec9f0ece6ceb7e2ddfa8bc87e9682b5789310ba25923db5ba", + "authorization": { + "is_authenticated": false, + "scopes": [], + "policies": [] + } + } + } + ] + } + }, + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"Subgraph1\") {\n {\n t {\n __typename\n prop\n id\n u {\n __typename\n id\n }\n }\n }\n },\n Flatten(path: \"t.u\") {\n Fetch(service: \"Subgraph1\") {\n {\n ... on U {\n __typename\n id\n }\n } =>\n {\n ... on U {\n field(a: $contextualArgument_1_0)\n }\n }\n },\n },\n },\n}" + } + } +} diff --git a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_disabled-2.snap b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_disabled-2.snap deleted file mode 100644 index 390a7c79df..0000000000 --- a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_disabled-2.snap +++ /dev/null @@ -1,154 +0,0 @@ ---- -source: apollo-router/tests/type_conditions.rs -expression: response ---- -{ - "data": { - "search": [ - { - "id": "a7052397-b605-414a-aba4-408d51c8eef0", - "sections": [ - { - "artwork": "Hello World", - "title": "d0182b8a-a671-4244-ba1c-905274b0d198 title" - }, - { - "artwork": "Hello World", - "title": "e6eec2fc-05ce-40a2-956b-f1335e615204 title" - } - ] - }, - { - "id": "3a7b08c9-d8c0-4c55-b55d-596a272392e0", - "sections": [ - { - "artwork": "Hello World", - "title": "f44f584e-5d3d-4466-96f5-9afc3f5d5a54 title" - }, - { - "artwork": "Hello World" - } - ] - }, - { - "sections": [ - { - "id": "d9077ad2-d79a-45b5-b5ee-25ded226f03c", - "title": "d9077ad2-d79a-45b5-b5ee-25ded226f03c title", - "artwork": "Hello World" - }, - { - "id": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02", - "title": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02 title", - "artwork": "Hello World" - } - ], - "id": "c5f4985f-8fb6-4414-a3f5-56f7f58dd043" - }, - { - "sections": [ - { - "id": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12", - "title": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12 title", - "artwork": "Hello World" - }, - { - "artwork": "Hello World", - "id": "2f772201-42ca-4376-9871-2252cc052262" - } - ], - "id": "ff140d35-ce5d-48fe-bad7-1cfb2c3e310a" - } - ] - }, - "extensions": { - "apolloQueryPlan": { - "object": { - "kind": "QueryPlan", - "node": { - "kind": "Sequence", - "nodes": [ - { - "kind": "Fetch", - "serviceName": "searchSubgraph", - "variableUsages": [], - "operation": "query Search__searchSubgraph__0 { search { __typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { __typename id } fragment _generated_onGallerySection2_0 on GallerySection { __typename id } fragment _generated_onMovieResult2_0 on MovieResult { sections { __typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0 } id } fragment _generated_onArticleResult2_0 on ArticleResult { id sections { __typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0 } }", - "operationName": "Search__searchSubgraph__0", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "70ca85b28e861b24a7749862930a5f965c4c6e8074d60a87a3952d596fe7cc36", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - }, - { - "kind": "Flatten", - "path": [ - "search", - "@", - "sections", - "@" - ], - "node": { - "kind": "Fetch", - "serviceName": "artworkSubgraph", - "requires": [ - { - "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - }, - { - "kind": "InlineFragment", - "typeCondition": "GallerySection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - } - ], - "variableUsages": [ - "movieResultParam" - ], - "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ..._generated_onEntityCollectionSection2_0 ... on GallerySection { artwork(params: $movieResultParam) } } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { title artwork(params: $movieResultParam) }", - "operationName": "Search__artworkSubgraph__1", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "317a722a677563080aeac92f60ac2257d9288ca6851a0e8980fcf18f58b462a8", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - } - } - ] - } - }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n search {\n __typename\n ..._generated_onMovieResult2_0\n ..._generated_onArticleResult2_0\n }\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n __typename\n id\n }\n\n fragment _generated_onGallerySection2_0 on GallerySection {\n __typename\n id\n }\n\n fragment _generated_onMovieResult2_0 on MovieResult {\n sections {\n __typename\n ..._generated_onEntityCollectionSection2_0\n ..._generated_onGallerySection2_0\n }\n id\n }\n\n fragment _generated_onArticleResult2_0 on ArticleResult {\n id\n sections {\n __typename\n ..._generated_onGallerySection2_0\n ..._generated_onEntityCollectionSection2_0\n }\n }\n },\n Flatten(path: \"search.@.sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ..._generated_onEntityCollectionSection2_0\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n },\n },\n },\n}" - } - } -} diff --git a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_disabled.snap b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_disabled.snap index 687028717f..b88f0a5ab5 100644 --- a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_disabled.snap +++ b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_disabled.snap @@ -72,14 +72,14 @@ expression: response "kind": "Fetch", "serviceName": "searchSubgraph", "variableUsages": [], - "operation": "query Search__searchSubgraph__0 { search { __typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { __typename id } fragment _generated_onGallerySection2_0 on GallerySection { __typename id } fragment _generated_onMovieResult2_0 on MovieResult { sections { __typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0 } id } fragment _generated_onArticleResult2_0 on ArticleResult { id sections { __typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0 } }", + "operation": "query Search__searchSubgraph__0 { search { __typename ... on MovieResult { sections { ...c } id } ... on ArticleResult { id sections { ...c } } } } fragment a on EntityCollectionSection { __typename id } fragment b on GallerySection { __typename id } fragment c on Section { __typename ...a ...b }", "operationName": "Search__searchSubgraph__0", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "0e1644746fe4beab7def35ec8cc12bde39874c6bb8b9dfd928456196b814a111", + "schemaAwareHash": "051a0faa33813724fa3bba65b83e23a2297611e368296fa571256f746fb21e31", "authorization": { "is_authenticated": false, "scopes": [], @@ -130,14 +130,14 @@ expression: response "variableUsages": [ "movieResultParam" ], - "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ..._generated_onEntityCollectionSection2_0 ... on GallerySection { artwork(params: $movieResultParam) } } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { title artwork(params: $movieResultParam) }", + "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ... on EntityCollectionSection { title artwork(params: $movieResultParam) } ... on GallerySection { artwork(params: $movieResultParam) } } }", "operationName": "Search__artworkSubgraph__1", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "6510f6b9672829bd9217618b78ef6f329fbddb125f88184d04e6faaa982ff8bb", + "schemaAwareHash": "149c061870d11ccaa57bca965bf7ab4fbcfae01d5405032de5fed8b5564fe3dd", "authorization": { "is_authenticated": false, "scopes": [], @@ -148,7 +148,7 @@ expression: response ] } }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n search {\n __typename\n ..._generated_onMovieResult2_0\n ..._generated_onArticleResult2_0\n }\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n __typename\n id\n }\n \n fragment _generated_onGallerySection2_0 on GallerySection {\n __typename\n id\n }\n \n fragment _generated_onMovieResult2_0 on MovieResult {\n sections {\n __typename\n ..._generated_onEntityCollectionSection2_0\n ..._generated_onGallerySection2_0\n }\n id\n }\n \n fragment _generated_onArticleResult2_0 on ArticleResult {\n id\n sections {\n __typename\n ..._generated_onGallerySection2_0\n ..._generated_onEntityCollectionSection2_0\n }\n }\n },\n Flatten(path: \"search.@.sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ..._generated_onEntityCollectionSection2_0\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n },\n },\n },\n}" + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n search {\n __typename\n ... on MovieResult {\n sections {\n ...c\n }\n id\n }\n ... on ArticleResult {\n id\n sections {\n ...c\n }\n }\n }\n }\n\n fragment a on EntityCollectionSection {\n __typename\n id\n }\n\n fragment b on GallerySection {\n __typename\n id\n }\n\n fragment c on Section {\n __typename\n ...a\n ...b\n }\n },\n Flatten(path: \"search.@.sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ... on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n },\n },\n },\n}" } } } diff --git a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled-2.snap b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled-2.snap deleted file mode 100644 index ed94ed7a85..0000000000 --- a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled-2.snap +++ /dev/null @@ -1,218 +0,0 @@ ---- -source: apollo-router/tests/type_conditions.rs -expression: response ---- -{ - "data": { - "search": [ - { - "id": "a7052397-b605-414a-aba4-408d51c8eef0", - "sections": [ - { - "artwork": "articleResultEnabled artwork", - "title": "d0182b8a-a671-4244-ba1c-905274b0d198 title" - }, - { - "artwork": "articleResultEnabled artwork", - "title": "e6eec2fc-05ce-40a2-956b-f1335e615204 title" - } - ] - }, - { - "id": "3a7b08c9-d8c0-4c55-b55d-596a272392e0", - "sections": [ - { - "artwork": "articleResultEnabled artwork", - "title": "f44f584e-5d3d-4466-96f5-9afc3f5d5a54 title" - }, - { - "artwork": "articleResultEnabled artwork" - } - ] - }, - { - "sections": [ - { - "id": "d9077ad2-d79a-45b5-b5ee-25ded226f03c", - "title": "d9077ad2-d79a-45b5-b5ee-25ded226f03c title", - "artwork": "movieResultEnabled artwork" - }, - { - "id": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02", - "title": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02 title", - "artwork": "movieResultEnabled artwork" - } - ], - "id": "c5f4985f-8fb6-4414-a3f5-56f7f58dd043" - }, - { - "sections": [ - { - "id": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12", - "title": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12 title", - "artwork": "movieResultEnabled artwork" - }, - { - "artwork": "movieResultEnabled artwork", - "id": "2f772201-42ca-4376-9871-2252cc052262" - } - ], - "id": "ff140d35-ce5d-48fe-bad7-1cfb2c3e310a" - } - ] - }, - "extensions": { - "apolloQueryPlan": { - "object": { - "kind": "QueryPlan", - "node": { - "kind": "Sequence", - "nodes": [ - { - "kind": "Fetch", - "serviceName": "searchSubgraph", - "variableUsages": [], - "operation": "query Search__searchSubgraph__0 { search { __typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { __typename id } fragment _generated_onGallerySection2_0 on GallerySection { __typename id } fragment _generated_onMovieResult2_0 on MovieResult { sections { __typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0 } id } fragment _generated_onArticleResult2_0 on ArticleResult { id sections { __typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0 } }", - "operationName": "Search__searchSubgraph__0", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "70ca85b28e861b24a7749862930a5f965c4c6e8074d60a87a3952d596fe7cc36", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - }, - { - "kind": "Parallel", - "nodes": [ - { - "kind": "Flatten", - "path": [ - "search", - "@|[ArticleResult]", - "sections", - "@" - ], - "node": { - "kind": "Fetch", - "serviceName": "artworkSubgraph", - "requires": [ - { - "kind": "InlineFragment", - "typeCondition": "GallerySection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - }, - { - "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - } - ], - "variableUsages": [ - "articleResultParam" - ], - "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ..._generated_onEntityCollectionSection2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { artwork(params: $articleResultParam) title }", - "operationName": "Search__artworkSubgraph__1", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "1d21a65a3b5a31e17f7834750ef5b37fb49d99d0a1e2145f00a62d43c5f8423a", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - } - }, - { - "kind": "Flatten", - "path": [ - "search", - "@|[MovieResult]", - "sections", - "@" - ], - "node": { - "kind": "Fetch", - "serviceName": "artworkSubgraph", - "requires": [ - { - "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - }, - { - "kind": "InlineFragment", - "typeCondition": "GallerySection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - } - ], - "variableUsages": [ - "movieResultParam" - ], - "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ..._generated_onEntityCollectionSection2_0 ... on GallerySection { artwork(params: $movieResultParam) } } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { title artwork(params: $movieResultParam) }", - "operationName": "Search__artworkSubgraph__2", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "df321f6532c2c9eda0d8c042e5f08073c24e558dd0cae01054886b79416a6c08", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - } - } - ] - } - ] - } - }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n search {\n __typename\n ..._generated_onMovieResult2_0\n ..._generated_onArticleResult2_0\n }\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n __typename\n id\n }\n\n fragment _generated_onGallerySection2_0 on GallerySection {\n __typename\n id\n }\n\n fragment _generated_onMovieResult2_0 on MovieResult {\n sections {\n __typename\n ..._generated_onEntityCollectionSection2_0\n ..._generated_onGallerySection2_0\n }\n id\n }\n\n fragment _generated_onArticleResult2_0 on ArticleResult {\n id\n sections {\n __typename\n ..._generated_onGallerySection2_0\n ..._generated_onEntityCollectionSection2_0\n }\n }\n },\n Parallel {\n Flatten(path: \"search.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ..._generated_onEntityCollectionSection2_0\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n },\n },\n Flatten(path: \"search.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ..._generated_onEntityCollectionSection2_0\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n },\n },\n },\n },\n}" - } - } -} diff --git a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled.snap b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled.snap index 08a9782c85..c06d9161cb 100644 --- a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled.snap +++ b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled.snap @@ -72,14 +72,14 @@ expression: response "kind": "Fetch", "serviceName": "searchSubgraph", "variableUsages": [], - "operation": "query Search__searchSubgraph__0 { search { __typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { __typename id } fragment _generated_onGallerySection2_0 on GallerySection { __typename id } fragment _generated_onMovieResult2_0 on MovieResult { sections { __typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0 } id } fragment _generated_onArticleResult2_0 on ArticleResult { id sections { __typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0 } }", + "operation": "query Search__searchSubgraph__0 { search { __typename ... on MovieResult { sections { ...c } id } ... on ArticleResult { id sections { ...c } } } } fragment a on EntityCollectionSection { __typename id } fragment b on GallerySection { __typename id } fragment c on Section { __typename ...a ...b }", "operationName": "Search__searchSubgraph__0", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "0e1644746fe4beab7def35ec8cc12bde39874c6bb8b9dfd928456196b814a111", + "schemaAwareHash": "051a0faa33813724fa3bba65b83e23a2297611e368296fa571256f746fb21e31", "authorization": { "is_authenticated": false, "scopes": [], @@ -92,9 +92,8 @@ expression: response { "kind": "Flatten", "path": [ - "", "search", - "@|[MovieResult]", + "@|[ArticleResult]", "sections", "@" ], @@ -104,7 +103,7 @@ expression: response "requires": [ { "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", + "typeCondition": "GallerySection", "selections": [ { "kind": "Field", @@ -118,7 +117,7 @@ expression: response }, { "kind": "InlineFragment", - "typeCondition": "GallerySection", + "typeCondition": "EntityCollectionSection", "selections": [ { "kind": "Field", @@ -132,16 +131,16 @@ expression: response } ], "variableUsages": [ - "movieResultParam" + "articleResultParam" ], - "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ..._generated_onEntityCollectionSection2_0 ... on GallerySection { artwork(params: $movieResultParam) } } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { title artwork(params: $movieResultParam) }", + "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ... on EntityCollectionSection { artwork(params: $articleResultParam) title } } }", "operationName": "Search__artworkSubgraph__1", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "6510f6b9672829bd9217618b78ef6f329fbddb125f88184d04e6faaa982ff8bb", + "schemaAwareHash": "75bfbb6e482fd72ae0f8a154c8333a8a899928d5845dc27d622a124b9904fe89", "authorization": { "is_authenticated": false, "scopes": [], @@ -152,9 +151,8 @@ expression: response { "kind": "Flatten", "path": [ - "", "search", - "@|[ArticleResult]", + "@|[MovieResult]", "sections", "@" ], @@ -164,7 +162,7 @@ expression: response "requires": [ { "kind": "InlineFragment", - "typeCondition": "GallerySection", + "typeCondition": "EntityCollectionSection", "selections": [ { "kind": "Field", @@ -178,7 +176,7 @@ expression: response }, { "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", + "typeCondition": "GallerySection", "selections": [ { "kind": "Field", @@ -192,16 +190,16 @@ expression: response } ], "variableUsages": [ - "articleResultParam" + "movieResultParam" ], - "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ..._generated_onEntityCollectionSection2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { artwork(params: $articleResultParam) title }", + "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ... on EntityCollectionSection { title artwork(params: $movieResultParam) } ... on GallerySection { artwork(params: $movieResultParam) } } }", "operationName": "Search__artworkSubgraph__2", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "6bc34c108f7cf81896971bffad76dc5275d46231b4dfe492ccc205dda9a4aa16", + "schemaAwareHash": "d49281e1c4281485ae2f9ee8f82c313d79da4d8d54bc8a8a1ddd1a67c61a5f7b", "authorization": { "is_authenticated": false, "scopes": [], @@ -214,7 +212,7 @@ expression: response ] } }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n search {\n __typename\n ..._generated_onMovieResult2_0\n ..._generated_onArticleResult2_0\n }\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n __typename\n id\n }\n \n fragment _generated_onGallerySection2_0 on GallerySection {\n __typename\n id\n }\n \n fragment _generated_onMovieResult2_0 on MovieResult {\n sections {\n __typename\n ..._generated_onEntityCollectionSection2_0\n ..._generated_onGallerySection2_0\n }\n id\n }\n \n fragment _generated_onArticleResult2_0 on ArticleResult {\n id\n sections {\n __typename\n ..._generated_onGallerySection2_0\n ..._generated_onEntityCollectionSection2_0\n }\n }\n },\n Parallel {\n Flatten(path: \".search.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ..._generated_onEntityCollectionSection2_0\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n },\n },\n Flatten(path: \".search.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ..._generated_onEntityCollectionSection2_0\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n },\n },\n },\n },\n}" + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n search {\n __typename\n ... on MovieResult {\n sections {\n ...c\n }\n id\n }\n ... on ArticleResult {\n id\n sections {\n ...c\n }\n }\n }\n }\n\n fragment a on EntityCollectionSection {\n __typename\n id\n }\n\n fragment b on GallerySection {\n __typename\n id\n }\n\n fragment c on Section {\n __typename\n ...a\n ...b\n }\n },\n Parallel {\n Flatten(path: \"search.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ... on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n }\n },\n },\n Flatten(path: \"search.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ... on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n },\n },\n },\n },\n}" } } } diff --git a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_generate_query_fragments-2.snap b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_generate_query_fragments-2.snap deleted file mode 100644 index ed94ed7a85..0000000000 --- a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_generate_query_fragments-2.snap +++ /dev/null @@ -1,218 +0,0 @@ ---- -source: apollo-router/tests/type_conditions.rs -expression: response ---- -{ - "data": { - "search": [ - { - "id": "a7052397-b605-414a-aba4-408d51c8eef0", - "sections": [ - { - "artwork": "articleResultEnabled artwork", - "title": "d0182b8a-a671-4244-ba1c-905274b0d198 title" - }, - { - "artwork": "articleResultEnabled artwork", - "title": "e6eec2fc-05ce-40a2-956b-f1335e615204 title" - } - ] - }, - { - "id": "3a7b08c9-d8c0-4c55-b55d-596a272392e0", - "sections": [ - { - "artwork": "articleResultEnabled artwork", - "title": "f44f584e-5d3d-4466-96f5-9afc3f5d5a54 title" - }, - { - "artwork": "articleResultEnabled artwork" - } - ] - }, - { - "sections": [ - { - "id": "d9077ad2-d79a-45b5-b5ee-25ded226f03c", - "title": "d9077ad2-d79a-45b5-b5ee-25ded226f03c title", - "artwork": "movieResultEnabled artwork" - }, - { - "id": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02", - "title": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02 title", - "artwork": "movieResultEnabled artwork" - } - ], - "id": "c5f4985f-8fb6-4414-a3f5-56f7f58dd043" - }, - { - "sections": [ - { - "id": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12", - "title": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12 title", - "artwork": "movieResultEnabled artwork" - }, - { - "artwork": "movieResultEnabled artwork", - "id": "2f772201-42ca-4376-9871-2252cc052262" - } - ], - "id": "ff140d35-ce5d-48fe-bad7-1cfb2c3e310a" - } - ] - }, - "extensions": { - "apolloQueryPlan": { - "object": { - "kind": "QueryPlan", - "node": { - "kind": "Sequence", - "nodes": [ - { - "kind": "Fetch", - "serviceName": "searchSubgraph", - "variableUsages": [], - "operation": "query Search__searchSubgraph__0 { search { __typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { __typename id } fragment _generated_onGallerySection2_0 on GallerySection { __typename id } fragment _generated_onMovieResult2_0 on MovieResult { sections { __typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0 } id } fragment _generated_onArticleResult2_0 on ArticleResult { id sections { __typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0 } }", - "operationName": "Search__searchSubgraph__0", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "70ca85b28e861b24a7749862930a5f965c4c6e8074d60a87a3952d596fe7cc36", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - }, - { - "kind": "Parallel", - "nodes": [ - { - "kind": "Flatten", - "path": [ - "search", - "@|[ArticleResult]", - "sections", - "@" - ], - "node": { - "kind": "Fetch", - "serviceName": "artworkSubgraph", - "requires": [ - { - "kind": "InlineFragment", - "typeCondition": "GallerySection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - }, - { - "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - } - ], - "variableUsages": [ - "articleResultParam" - ], - "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ..._generated_onEntityCollectionSection2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { artwork(params: $articleResultParam) title }", - "operationName": "Search__artworkSubgraph__1", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "1d21a65a3b5a31e17f7834750ef5b37fb49d99d0a1e2145f00a62d43c5f8423a", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - } - }, - { - "kind": "Flatten", - "path": [ - "search", - "@|[MovieResult]", - "sections", - "@" - ], - "node": { - "kind": "Fetch", - "serviceName": "artworkSubgraph", - "requires": [ - { - "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - }, - { - "kind": "InlineFragment", - "typeCondition": "GallerySection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - } - ], - "variableUsages": [ - "movieResultParam" - ], - "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ..._generated_onEntityCollectionSection2_0 ... on GallerySection { artwork(params: $movieResultParam) } } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { title artwork(params: $movieResultParam) }", - "operationName": "Search__artworkSubgraph__2", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "df321f6532c2c9eda0d8c042e5f08073c24e558dd0cae01054886b79416a6c08", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - } - } - ] - } - ] - } - }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n search {\n __typename\n ..._generated_onMovieResult2_0\n ..._generated_onArticleResult2_0\n }\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n __typename\n id\n }\n\n fragment _generated_onGallerySection2_0 on GallerySection {\n __typename\n id\n }\n\n fragment _generated_onMovieResult2_0 on MovieResult {\n sections {\n __typename\n ..._generated_onEntityCollectionSection2_0\n ..._generated_onGallerySection2_0\n }\n id\n }\n\n fragment _generated_onArticleResult2_0 on ArticleResult {\n id\n sections {\n __typename\n ..._generated_onGallerySection2_0\n ..._generated_onEntityCollectionSection2_0\n }\n }\n },\n Parallel {\n Flatten(path: \"search.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ..._generated_onEntityCollectionSection2_0\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n },\n },\n Flatten(path: \"search.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ..._generated_onEntityCollectionSection2_0\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n },\n },\n },\n },\n}" - } - } -} diff --git a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_generate_query_fragments.snap b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_generate_query_fragments.snap index 08a9782c85..c06d9161cb 100644 --- a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_generate_query_fragments.snap +++ b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_generate_query_fragments.snap @@ -72,14 +72,14 @@ expression: response "kind": "Fetch", "serviceName": "searchSubgraph", "variableUsages": [], - "operation": "query Search__searchSubgraph__0 { search { __typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { __typename id } fragment _generated_onGallerySection2_0 on GallerySection { __typename id } fragment _generated_onMovieResult2_0 on MovieResult { sections { __typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0 } id } fragment _generated_onArticleResult2_0 on ArticleResult { id sections { __typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0 } }", + "operation": "query Search__searchSubgraph__0 { search { __typename ... on MovieResult { sections { ...c } id } ... on ArticleResult { id sections { ...c } } } } fragment a on EntityCollectionSection { __typename id } fragment b on GallerySection { __typename id } fragment c on Section { __typename ...a ...b }", "operationName": "Search__searchSubgraph__0", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "0e1644746fe4beab7def35ec8cc12bde39874c6bb8b9dfd928456196b814a111", + "schemaAwareHash": "051a0faa33813724fa3bba65b83e23a2297611e368296fa571256f746fb21e31", "authorization": { "is_authenticated": false, "scopes": [], @@ -92,9 +92,8 @@ expression: response { "kind": "Flatten", "path": [ - "", "search", - "@|[MovieResult]", + "@|[ArticleResult]", "sections", "@" ], @@ -104,7 +103,7 @@ expression: response "requires": [ { "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", + "typeCondition": "GallerySection", "selections": [ { "kind": "Field", @@ -118,7 +117,7 @@ expression: response }, { "kind": "InlineFragment", - "typeCondition": "GallerySection", + "typeCondition": "EntityCollectionSection", "selections": [ { "kind": "Field", @@ -132,16 +131,16 @@ expression: response } ], "variableUsages": [ - "movieResultParam" + "articleResultParam" ], - "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ..._generated_onEntityCollectionSection2_0 ... on GallerySection { artwork(params: $movieResultParam) } } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { title artwork(params: $movieResultParam) }", + "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ... on EntityCollectionSection { artwork(params: $articleResultParam) title } } }", "operationName": "Search__artworkSubgraph__1", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "6510f6b9672829bd9217618b78ef6f329fbddb125f88184d04e6faaa982ff8bb", + "schemaAwareHash": "75bfbb6e482fd72ae0f8a154c8333a8a899928d5845dc27d622a124b9904fe89", "authorization": { "is_authenticated": false, "scopes": [], @@ -152,9 +151,8 @@ expression: response { "kind": "Flatten", "path": [ - "", "search", - "@|[ArticleResult]", + "@|[MovieResult]", "sections", "@" ], @@ -164,7 +162,7 @@ expression: response "requires": [ { "kind": "InlineFragment", - "typeCondition": "GallerySection", + "typeCondition": "EntityCollectionSection", "selections": [ { "kind": "Field", @@ -178,7 +176,7 @@ expression: response }, { "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", + "typeCondition": "GallerySection", "selections": [ { "kind": "Field", @@ -192,16 +190,16 @@ expression: response } ], "variableUsages": [ - "articleResultParam" + "movieResultParam" ], - "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ..._generated_onEntityCollectionSection2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { artwork(params: $articleResultParam) title }", + "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ... on EntityCollectionSection { title artwork(params: $movieResultParam) } ... on GallerySection { artwork(params: $movieResultParam) } } }", "operationName": "Search__artworkSubgraph__2", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "6bc34c108f7cf81896971bffad76dc5275d46231b4dfe492ccc205dda9a4aa16", + "schemaAwareHash": "d49281e1c4281485ae2f9ee8f82c313d79da4d8d54bc8a8a1ddd1a67c61a5f7b", "authorization": { "is_authenticated": false, "scopes": [], @@ -214,7 +212,7 @@ expression: response ] } }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n search {\n __typename\n ..._generated_onMovieResult2_0\n ..._generated_onArticleResult2_0\n }\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n __typename\n id\n }\n \n fragment _generated_onGallerySection2_0 on GallerySection {\n __typename\n id\n }\n \n fragment _generated_onMovieResult2_0 on MovieResult {\n sections {\n __typename\n ..._generated_onEntityCollectionSection2_0\n ..._generated_onGallerySection2_0\n }\n id\n }\n \n fragment _generated_onArticleResult2_0 on ArticleResult {\n id\n sections {\n __typename\n ..._generated_onGallerySection2_0\n ..._generated_onEntityCollectionSection2_0\n }\n }\n },\n Parallel {\n Flatten(path: \".search.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ..._generated_onEntityCollectionSection2_0\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n },\n },\n Flatten(path: \".search.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ..._generated_onEntityCollectionSection2_0\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n },\n },\n },\n },\n}" + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n search {\n __typename\n ... on MovieResult {\n sections {\n ...c\n }\n id\n }\n ... on ArticleResult {\n id\n sections {\n ...c\n }\n }\n }\n }\n\n fragment a on EntityCollectionSection {\n __typename\n id\n }\n\n fragment b on GallerySection {\n __typename\n id\n }\n\n fragment c on Section {\n __typename\n ...a\n ...b\n }\n },\n Parallel {\n Flatten(path: \"search.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ... on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n }\n },\n },\n Flatten(path: \"search.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ... on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n },\n },\n },\n },\n}" } } } diff --git a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list-2.snap b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list-2.snap deleted file mode 100644 index fc5007829d..0000000000 --- a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list-2.snap +++ /dev/null @@ -1,282 +0,0 @@ ---- -source: apollo-router/tests/type_conditions.rs -expression: response ---- -{ - "data": { - "searchListOfList": [ - [ - { - "id": "a7052397-b605-414a-aba4-408d51c8eef0", - "sections": [ - { - "artwork": "articleResultEnabled artwork", - "title": "d0182b8a-a671-4244-ba1c-905274b0d198 title" - }, - { - "artwork": "articleResultEnabled artwork", - "title": "e6eec2fc-05ce-40a2-956b-f1335e615204 title" - } - ] - }, - { - "id": "3a7b08c9-d8c0-4c55-b55d-596a272392e0", - "sections": [ - { - "artwork": "articleResultEnabled artwork", - "title": "f44f584e-5d3d-4466-96f5-9afc3f5d5a54 title" - }, - { - "artwork": "articleResultEnabled artwork" - } - ] - }, - { - "sections": [ - { - "id": "d9077ad2-d79a-45b5-b5ee-25ded226f03c", - "title": "d9077ad2-d79a-45b5-b5ee-25ded226f03c title", - "artwork": "movieResultEnabled artwork" - }, - { - "id": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02", - "title": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02 title", - "artwork": "movieResultEnabled artwork" - } - ], - "id": "c5f4985f-8fb6-4414-a3f5-56f7f58dd043" - }, - { - "sections": [ - { - "id": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12", - "title": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12 title", - "artwork": "movieResultEnabled artwork" - }, - { - "artwork": "movieResultEnabled artwork", - "id": "2f772201-42ca-4376-9871-2252cc052262" - } - ], - "id": "ff140d35-ce5d-48fe-bad7-1cfb2c3e310a" - } - ], - [ - { - "id": "a7052397-b605-414a-aba4-408d51c8eef0", - "sections": [ - { - "artwork": "articleResultEnabled artwork", - "title": "d0182b8a-a671-4244-ba1c-905274b0d198 title" - }, - { - "artwork": "articleResultEnabled artwork", - "title": "e6eec2fc-05ce-40a2-956b-f1335e615204 title" - } - ] - }, - { - "id": "3a7b08c9-d8c0-4c55-b55d-596a272392e0", - "sections": [ - { - "artwork": "articleResultEnabled artwork", - "title": "f44f584e-5d3d-4466-96f5-9afc3f5d5a54 title" - }, - { - "artwork": "articleResultEnabled artwork" - } - ] - } - ], - [ - { - "sections": [ - { - "id": "d9077ad2-d79a-45b5-b5ee-25ded226f03c", - "title": "d9077ad2-d79a-45b5-b5ee-25ded226f03c title", - "artwork": "movieResultEnabled artwork" - }, - { - "id": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02", - "title": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02 title", - "artwork": "movieResultEnabled artwork" - } - ], - "id": "c5f4985f-8fb6-4414-a3f5-56f7f58dd043" - } - ], - [ - { - "sections": [ - { - "id": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12", - "title": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12 title", - "artwork": "movieResultEnabled artwork" - }, - { - "artwork": "movieResultEnabled artwork", - "id": "2f772201-42ca-4376-9871-2252cc052262" - } - ], - "id": "ff140d35-ce5d-48fe-bad7-1cfb2c3e310a" - } - ] - ] - }, - "extensions": { - "apolloQueryPlan": { - "object": { - "kind": "QueryPlan", - "node": { - "kind": "Sequence", - "nodes": [ - { - "kind": "Fetch", - "serviceName": "searchSubgraph", - "variableUsages": [], - "operation": "query Search__searchSubgraph__0 { searchListOfList { __typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { __typename id } fragment _generated_onGallerySection2_0 on GallerySection { __typename id } fragment _generated_onMovieResult2_0 on MovieResult { sections { __typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0 } id } fragment _generated_onArticleResult2_0 on ArticleResult { id sections { __typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0 } }", - "operationName": "Search__searchSubgraph__0", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "ff18ff586aee784ec507117854cb4b64f9693d528df1ee69c922b5d75ae637fb", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - }, - { - "kind": "Parallel", - "nodes": [ - { - "kind": "Flatten", - "path": [ - "searchListOfList", - "@", - "@|[ArticleResult]", - "sections", - "@" - ], - "node": { - "kind": "Fetch", - "serviceName": "artworkSubgraph", - "requires": [ - { - "kind": "InlineFragment", - "typeCondition": "GallerySection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - }, - { - "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - } - ], - "variableUsages": [ - "articleResultParam" - ], - "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ..._generated_onEntityCollectionSection2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { artwork(params: $articleResultParam) title }", - "operationName": "Search__artworkSubgraph__1", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "1d21a65a3b5a31e17f7834750ef5b37fb49d99d0a1e2145f00a62d43c5f8423a", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - } - }, - { - "kind": "Flatten", - "path": [ - "searchListOfList", - "@", - "@|[MovieResult]", - "sections", - "@" - ], - "node": { - "kind": "Fetch", - "serviceName": "artworkSubgraph", - "requires": [ - { - "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - }, - { - "kind": "InlineFragment", - "typeCondition": "GallerySection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - } - ], - "variableUsages": [ - "movieResultParam" - ], - "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ..._generated_onEntityCollectionSection2_0 ... on GallerySection { artwork(params: $movieResultParam) } } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { title artwork(params: $movieResultParam) }", - "operationName": "Search__artworkSubgraph__2", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "df321f6532c2c9eda0d8c042e5f08073c24e558dd0cae01054886b79416a6c08", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - } - } - ] - } - ] - } - }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n searchListOfList {\n __typename\n ..._generated_onMovieResult2_0\n ..._generated_onArticleResult2_0\n }\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n __typename\n id\n }\n\n fragment _generated_onGallerySection2_0 on GallerySection {\n __typename\n id\n }\n\n fragment _generated_onMovieResult2_0 on MovieResult {\n sections {\n __typename\n ..._generated_onEntityCollectionSection2_0\n ..._generated_onGallerySection2_0\n }\n id\n }\n\n fragment _generated_onArticleResult2_0 on ArticleResult {\n id\n sections {\n __typename\n ..._generated_onGallerySection2_0\n ..._generated_onEntityCollectionSection2_0\n }\n }\n },\n Parallel {\n Flatten(path: \"searchListOfList.@.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ..._generated_onEntityCollectionSection2_0\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n },\n },\n Flatten(path: \"searchListOfList.@.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ..._generated_onEntityCollectionSection2_0\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n },\n },\n },\n },\n}" - } - } -} diff --git a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list.snap b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list.snap index 4c219874d6..cb314cb57e 100644 --- a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list.snap +++ b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list.snap @@ -134,14 +134,14 @@ expression: response "kind": "Fetch", "serviceName": "searchSubgraph", "variableUsages": [], - "operation": "query Search__searchSubgraph__0 { searchListOfList { __typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { __typename id } fragment _generated_onGallerySection2_0 on GallerySection { __typename id } fragment _generated_onMovieResult2_0 on MovieResult { sections { __typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0 } id } fragment _generated_onArticleResult2_0 on ArticleResult { id sections { __typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0 } }", + "operation": "query Search__searchSubgraph__0 { searchListOfList { __typename ... on MovieResult { sections { ...c } id } ... on ArticleResult { id sections { ...c } } } } fragment a on EntityCollectionSection { __typename id } fragment b on GallerySection { __typename id } fragment c on Section { __typename ...a ...b }", "operationName": "Search__searchSubgraph__0", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "70b62e564b3924984694d90de2b10947a2f5c14ceb76d154f43bb3c638c4830b", + "schemaAwareHash": "d5e8e551cbdc60d71b4f951474a7fc3d5df7e9448791d31f8c02ba0d19a05a74", "authorization": { "is_authenticated": false, "scopes": [], @@ -154,10 +154,9 @@ expression: response { "kind": "Flatten", "path": [ - "", "searchListOfList", "@", - "@|[MovieResult]", + "@|[ArticleResult]", "sections", "@" ], @@ -167,7 +166,7 @@ expression: response "requires": [ { "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", + "typeCondition": "GallerySection", "selections": [ { "kind": "Field", @@ -181,7 +180,7 @@ expression: response }, { "kind": "InlineFragment", - "typeCondition": "GallerySection", + "typeCondition": "EntityCollectionSection", "selections": [ { "kind": "Field", @@ -195,16 +194,16 @@ expression: response } ], "variableUsages": [ - "movieResultParam" + "articleResultParam" ], - "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ..._generated_onEntityCollectionSection2_0 ... on GallerySection { artwork(params: $movieResultParam) } } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { title artwork(params: $movieResultParam) }", + "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ... on EntityCollectionSection { artwork(params: $articleResultParam) title } } }", "operationName": "Search__artworkSubgraph__1", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "6510f6b9672829bd9217618b78ef6f329fbddb125f88184d04e6faaa982ff8bb", + "schemaAwareHash": "75bfbb6e482fd72ae0f8a154c8333a8a899928d5845dc27d622a124b9904fe89", "authorization": { "is_authenticated": false, "scopes": [], @@ -215,10 +214,9 @@ expression: response { "kind": "Flatten", "path": [ - "", "searchListOfList", "@", - "@|[ArticleResult]", + "@|[MovieResult]", "sections", "@" ], @@ -228,7 +226,7 @@ expression: response "requires": [ { "kind": "InlineFragment", - "typeCondition": "GallerySection", + "typeCondition": "EntityCollectionSection", "selections": [ { "kind": "Field", @@ -242,7 +240,7 @@ expression: response }, { "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", + "typeCondition": "GallerySection", "selections": [ { "kind": "Field", @@ -256,16 +254,16 @@ expression: response } ], "variableUsages": [ - "articleResultParam" + "movieResultParam" ], - "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ..._generated_onEntityCollectionSection2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { artwork(params: $articleResultParam) title }", + "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ... on EntityCollectionSection { title artwork(params: $movieResultParam) } ... on GallerySection { artwork(params: $movieResultParam) } } }", "operationName": "Search__artworkSubgraph__2", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "6bc34c108f7cf81896971bffad76dc5275d46231b4dfe492ccc205dda9a4aa16", + "schemaAwareHash": "d49281e1c4281485ae2f9ee8f82c313d79da4d8d54bc8a8a1ddd1a67c61a5f7b", "authorization": { "is_authenticated": false, "scopes": [], @@ -278,7 +276,7 @@ expression: response ] } }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n searchListOfList {\n __typename\n ..._generated_onMovieResult2_0\n ..._generated_onArticleResult2_0\n }\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n __typename\n id\n }\n \n fragment _generated_onGallerySection2_0 on GallerySection {\n __typename\n id\n }\n \n fragment _generated_onMovieResult2_0 on MovieResult {\n sections {\n __typename\n ..._generated_onEntityCollectionSection2_0\n ..._generated_onGallerySection2_0\n }\n id\n }\n \n fragment _generated_onArticleResult2_0 on ArticleResult {\n id\n sections {\n __typename\n ..._generated_onGallerySection2_0\n ..._generated_onEntityCollectionSection2_0\n }\n }\n },\n Parallel {\n Flatten(path: \".searchListOfList.@.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ..._generated_onEntityCollectionSection2_0\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n },\n },\n Flatten(path: \".searchListOfList.@.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ..._generated_onEntityCollectionSection2_0\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n },\n },\n },\n },\n}" + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n searchListOfList {\n __typename\n ... on MovieResult {\n sections {\n ...c\n }\n id\n }\n ... on ArticleResult {\n id\n sections {\n ...c\n }\n }\n }\n }\n\n fragment a on EntityCollectionSection {\n __typename\n id\n }\n\n fragment b on GallerySection {\n __typename\n id\n }\n\n fragment c on Section {\n __typename\n ...a\n ...b\n }\n },\n Parallel {\n Flatten(path: \"searchListOfList.@.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ... on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n }\n },\n },\n Flatten(path: \"searchListOfList.@.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ... on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n },\n },\n },\n },\n}" } } } diff --git a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list_of_list-2.snap b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list_of_list-2.snap deleted file mode 100644 index 5cc97759df..0000000000 --- a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list_of_list-2.snap +++ /dev/null @@ -1,288 +0,0 @@ ---- -source: apollo-router/tests/type_conditions.rs -expression: response ---- -{ - "data": { - "searchListOfListOfList": [ - [ - [ - { - "id": "a7052397-b605-414a-aba4-408d51c8eef0", - "sections": [ - { - "artwork": "articleResultEnabled artwork", - "title": "d0182b8a-a671-4244-ba1c-905274b0d198 title" - }, - { - "artwork": "articleResultEnabled artwork", - "title": "e6eec2fc-05ce-40a2-956b-f1335e615204 title" - } - ] - }, - { - "id": "3a7b08c9-d8c0-4c55-b55d-596a272392e0", - "sections": [ - { - "artwork": "articleResultEnabled artwork", - "title": "f44f584e-5d3d-4466-96f5-9afc3f5d5a54 title" - }, - { - "artwork": "articleResultEnabled artwork" - } - ] - }, - { - "sections": [ - { - "id": "d9077ad2-d79a-45b5-b5ee-25ded226f03c", - "title": "d9077ad2-d79a-45b5-b5ee-25ded226f03c title", - "artwork": "movieResultEnabled artwork" - }, - { - "id": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02", - "title": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02 title", - "artwork": "movieResultEnabled artwork" - } - ], - "id": "c5f4985f-8fb6-4414-a3f5-56f7f58dd043" - }, - { - "sections": [ - { - "id": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12", - "title": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12 title", - "artwork": "movieResultEnabled artwork" - }, - { - "artwork": "movieResultEnabled artwork", - "id": "2f772201-42ca-4376-9871-2252cc052262" - } - ], - "id": "ff140d35-ce5d-48fe-bad7-1cfb2c3e310a" - } - ], - [ - { - "id": "a7052397-b605-414a-aba4-408d51c8eef0", - "sections": [ - { - "artwork": "articleResultEnabled artwork", - "title": "d0182b8a-a671-4244-ba1c-905274b0d198 title" - }, - { - "artwork": "articleResultEnabled artwork", - "title": "e6eec2fc-05ce-40a2-956b-f1335e615204 title" - } - ] - }, - { - "id": "3a7b08c9-d8c0-4c55-b55d-596a272392e0", - "sections": [ - { - "artwork": "articleResultEnabled artwork", - "title": "f44f584e-5d3d-4466-96f5-9afc3f5d5a54 title" - }, - { - "artwork": "articleResultEnabled artwork" - } - ] - } - ] - ], - [ - [ - { - "sections": [ - { - "id": "d9077ad2-d79a-45b5-b5ee-25ded226f03c", - "title": "d9077ad2-d79a-45b5-b5ee-25ded226f03c title", - "artwork": "movieResultEnabled artwork" - }, - { - "id": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02", - "title": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02 title", - "artwork": "movieResultEnabled artwork" - } - ], - "id": "c5f4985f-8fb6-4414-a3f5-56f7f58dd043" - } - ], - [ - { - "sections": [ - { - "id": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12", - "title": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12 title", - "artwork": "movieResultEnabled artwork" - }, - { - "artwork": "movieResultEnabled artwork", - "id": "2f772201-42ca-4376-9871-2252cc052262" - } - ], - "id": "ff140d35-ce5d-48fe-bad7-1cfb2c3e310a" - } - ] - ] - ] - }, - "extensions": { - "apolloQueryPlan": { - "object": { - "kind": "QueryPlan", - "node": { - "kind": "Sequence", - "nodes": [ - { - "kind": "Fetch", - "serviceName": "searchSubgraph", - "variableUsages": [], - "operation": "query Search__searchSubgraph__0 { searchListOfListOfList { __typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { __typename id } fragment _generated_onGallerySection2_0 on GallerySection { __typename id } fragment _generated_onMovieResult2_0 on MovieResult { sections { __typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0 } id } fragment _generated_onArticleResult2_0 on ArticleResult { id sections { __typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0 } }", - "operationName": "Search__searchSubgraph__0", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "cb374f6eaa19cb529eeae258f2b136dbc751e3784fdc279954e59622cfb1edde", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - }, - { - "kind": "Parallel", - "nodes": [ - { - "kind": "Flatten", - "path": [ - "searchListOfListOfList", - "@", - "@", - "@|[ArticleResult]", - "sections", - "@" - ], - "node": { - "kind": "Fetch", - "serviceName": "artworkSubgraph", - "requires": [ - { - "kind": "InlineFragment", - "typeCondition": "GallerySection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - }, - { - "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - } - ], - "variableUsages": [ - "articleResultParam" - ], - "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ..._generated_onEntityCollectionSection2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { artwork(params: $articleResultParam) title }", - "operationName": "Search__artworkSubgraph__1", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "1d21a65a3b5a31e17f7834750ef5b37fb49d99d0a1e2145f00a62d43c5f8423a", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - } - }, - { - "kind": "Flatten", - "path": [ - "searchListOfListOfList", - "@", - "@", - "@|[MovieResult]", - "sections", - "@" - ], - "node": { - "kind": "Fetch", - "serviceName": "artworkSubgraph", - "requires": [ - { - "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - }, - { - "kind": "InlineFragment", - "typeCondition": "GallerySection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - } - ], - "variableUsages": [ - "movieResultParam" - ], - "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ..._generated_onEntityCollectionSection2_0 ... on GallerySection { artwork(params: $movieResultParam) } } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { title artwork(params: $movieResultParam) }", - "operationName": "Search__artworkSubgraph__2", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "df321f6532c2c9eda0d8c042e5f08073c24e558dd0cae01054886b79416a6c08", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - } - } - ] - } - ] - } - }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n searchListOfListOfList {\n __typename\n ..._generated_onMovieResult2_0\n ..._generated_onArticleResult2_0\n }\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n __typename\n id\n }\n\n fragment _generated_onGallerySection2_0 on GallerySection {\n __typename\n id\n }\n\n fragment _generated_onMovieResult2_0 on MovieResult {\n sections {\n __typename\n ..._generated_onEntityCollectionSection2_0\n ..._generated_onGallerySection2_0\n }\n id\n }\n\n fragment _generated_onArticleResult2_0 on ArticleResult {\n id\n sections {\n __typename\n ..._generated_onGallerySection2_0\n ..._generated_onEntityCollectionSection2_0\n }\n }\n },\n Parallel {\n Flatten(path: \"searchListOfListOfList.@.@.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ..._generated_onEntityCollectionSection2_0\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n },\n },\n Flatten(path: \"searchListOfListOfList.@.@.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ..._generated_onEntityCollectionSection2_0\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n\n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n },\n },\n },\n },\n}" - } - } -} diff --git a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list_of_list.snap b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list_of_list.snap index 593bd573f6..4818d73e18 100644 --- a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list_of_list.snap +++ b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_list_of_list_of_list.snap @@ -138,14 +138,14 @@ expression: response "kind": "Fetch", "serviceName": "searchSubgraph", "variableUsages": [], - "operation": "query Search__searchSubgraph__0 { searchListOfListOfList { __typename ..._generated_onMovieResult2_0 ..._generated_onArticleResult2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { __typename id } fragment _generated_onGallerySection2_0 on GallerySection { __typename id } fragment _generated_onMovieResult2_0 on MovieResult { sections { __typename ..._generated_onEntityCollectionSection2_0 ..._generated_onGallerySection2_0 } id } fragment _generated_onArticleResult2_0 on ArticleResult { id sections { __typename ..._generated_onGallerySection2_0 ..._generated_onEntityCollectionSection2_0 } }", + "operation": "query Search__searchSubgraph__0 { searchListOfListOfList { __typename ... on MovieResult { sections { ...c } id } ... on ArticleResult { id sections { ...c } } } } fragment a on EntityCollectionSection { __typename id } fragment b on GallerySection { __typename id } fragment c on Section { __typename ...a ...b }", "operationName": "Search__searchSubgraph__0", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "26ae1da614855e4edee344061c0fc95ec4613a99e012de1f33207cb5318487b8", + "schemaAwareHash": "c4687169a3a7ef9487c1ad444e6c0f9eb354bc1c1ab105792723d8fc0705100b", "authorization": { "is_authenticated": false, "scopes": [], @@ -158,11 +158,10 @@ expression: response { "kind": "Flatten", "path": [ - "", "searchListOfListOfList", "@", "@", - "@|[MovieResult]", + "@|[ArticleResult]", "sections", "@" ], @@ -172,7 +171,7 @@ expression: response "requires": [ { "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", + "typeCondition": "GallerySection", "selections": [ { "kind": "Field", @@ -186,7 +185,7 @@ expression: response }, { "kind": "InlineFragment", - "typeCondition": "GallerySection", + "typeCondition": "EntityCollectionSection", "selections": [ { "kind": "Field", @@ -200,16 +199,16 @@ expression: response } ], "variableUsages": [ - "movieResultParam" + "articleResultParam" ], - "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ..._generated_onEntityCollectionSection2_0 ... on GallerySection { artwork(params: $movieResultParam) } } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { title artwork(params: $movieResultParam) }", + "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ... on EntityCollectionSection { artwork(params: $articleResultParam) title } } }", "operationName": "Search__artworkSubgraph__1", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "6510f6b9672829bd9217618b78ef6f329fbddb125f88184d04e6faaa982ff8bb", + "schemaAwareHash": "75bfbb6e482fd72ae0f8a154c8333a8a899928d5845dc27d622a124b9904fe89", "authorization": { "is_authenticated": false, "scopes": [], @@ -220,11 +219,10 @@ expression: response { "kind": "Flatten", "path": [ - "", "searchListOfListOfList", "@", "@", - "@|[ArticleResult]", + "@|[MovieResult]", "sections", "@" ], @@ -234,7 +232,7 @@ expression: response "requires": [ { "kind": "InlineFragment", - "typeCondition": "GallerySection", + "typeCondition": "EntityCollectionSection", "selections": [ { "kind": "Field", @@ -248,7 +246,7 @@ expression: response }, { "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", + "typeCondition": "GallerySection", "selections": [ { "kind": "Field", @@ -262,16 +260,16 @@ expression: response } ], "variableUsages": [ - "articleResultParam" + "movieResultParam" ], - "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ..._generated_onEntityCollectionSection2_0 } } fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection { artwork(params: $articleResultParam) title }", + "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ... on EntityCollectionSection { title artwork(params: $movieResultParam) } ... on GallerySection { artwork(params: $movieResultParam) } } }", "operationName": "Search__artworkSubgraph__2", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "6bc34c108f7cf81896971bffad76dc5275d46231b4dfe492ccc205dda9a4aa16", + "schemaAwareHash": "d49281e1c4281485ae2f9ee8f82c313d79da4d8d54bc8a8a1ddd1a67c61a5f7b", "authorization": { "is_authenticated": false, "scopes": [], @@ -284,7 +282,7 @@ expression: response ] } }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n searchListOfListOfList {\n __typename\n ..._generated_onMovieResult2_0\n ..._generated_onArticleResult2_0\n }\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n __typename\n id\n }\n \n fragment _generated_onGallerySection2_0 on GallerySection {\n __typename\n id\n }\n \n fragment _generated_onMovieResult2_0 on MovieResult {\n sections {\n __typename\n ..._generated_onEntityCollectionSection2_0\n ..._generated_onGallerySection2_0\n }\n id\n }\n \n fragment _generated_onArticleResult2_0 on ArticleResult {\n id\n sections {\n __typename\n ..._generated_onGallerySection2_0\n ..._generated_onEntityCollectionSection2_0\n }\n }\n },\n Parallel {\n Flatten(path: \".searchListOfListOfList.@.@.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ..._generated_onEntityCollectionSection2_0\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n },\n },\n Flatten(path: \".searchListOfListOfList.@.@.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ..._generated_onEntityCollectionSection2_0\n }\n \n fragment _generated_onEntityCollectionSection2_0 on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n },\n },\n },\n },\n}" + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n searchListOfListOfList {\n __typename\n ... on MovieResult {\n sections {\n ...c\n }\n id\n }\n ... on ArticleResult {\n id\n sections {\n ...c\n }\n }\n }\n }\n\n fragment a on EntityCollectionSection {\n __typename\n id\n }\n\n fragment b on GallerySection {\n __typename\n id\n }\n\n fragment c on Section {\n __typename\n ...a\n ...b\n }\n },\n Parallel {\n Flatten(path: \"searchListOfListOfList.@.@.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ... on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n }\n },\n },\n Flatten(path: \"searchListOfListOfList.@.@.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ... on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n },\n },\n },\n },\n}" } } } diff --git a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_shouldnt_make_article_fetch-2.snap b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_shouldnt_make_article_fetch-2.snap deleted file mode 100644 index 41a6433f9f..0000000000 --- a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_shouldnt_make_article_fetch-2.snap +++ /dev/null @@ -1,193 +0,0 @@ ---- -source: apollo-router/tests/type_conditions.rs -expression: response ---- -{ - "data": { - "search": [ - { - "sections": [ - { - "id": "d9077ad2-d79a-45b5-b5ee-25ded226f03c", - "title": "d9077ad2-d79a-45b5-b5ee-25ded226f03c title", - "artwork": "movieResultEnabled artwork" - }, - { - "id": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02", - "title": "9f1f1ebb-21d3-4afe-bb7d-6de706f78f02 title", - "artwork": "movieResultEnabled artwork" - } - ], - "id": "c5f4985f-8fb6-4414-a3f5-56f7f58dd043" - }, - { - "sections": [ - { - "id": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12", - "title": "24cea0de-2ac8-4cbe-85b6-8b1b80647c12 title", - "artwork": "movieResultEnabled artwork" - }, - { - "artwork": "movieResultEnabled artwork", - "id": "2f772201-42ca-4376-9871-2252cc052262" - } - ], - "id": "ff140d35-ce5d-48fe-bad7-1cfb2c3e310a" - } - ] - }, - "extensions": { - "apolloQueryPlan": { - "object": { - "kind": "QueryPlan", - "node": { - "kind": "Sequence", - "nodes": [ - { - "kind": "Fetch", - "serviceName": "searchSubgraph", - "variableUsages": [], - "operation": "query Search__searchSubgraph__0 { search { __typename ... on MovieResult { sections { __typename ... on EntityCollectionSection { __typename id } ... on GallerySection { __typename id } } id } ... on ArticleResult { id sections { __typename ... on GallerySection { __typename id } ... on EntityCollectionSection { __typename id } } } } }", - "operationName": "Search__searchSubgraph__0", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "587c887350ef75eaf4b647be94fd682616bcd33909e15fb797cee226e95fa36a", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - }, - { - "kind": "Parallel", - "nodes": [ - { - "kind": "Flatten", - "path": [ - "search", - "@|[ArticleResult]", - "sections", - "@" - ], - "node": { - "kind": "Fetch", - "serviceName": "artworkSubgraph", - "requires": [ - { - "kind": "InlineFragment", - "typeCondition": "GallerySection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - }, - { - "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - } - ], - "variableUsages": [ - "articleResultParam" - ], - "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ... on EntityCollectionSection { artwork(params: $articleResultParam) title } } }", - "operationName": "Search__artworkSubgraph__1", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "a0bf36d3a611df53c3a60b9b124a2887f2d266858221c606ace0985d101d64bd", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - } - }, - { - "kind": "Flatten", - "path": [ - "search", - "@|[MovieResult]", - "sections", - "@" - ], - "node": { - "kind": "Fetch", - "serviceName": "artworkSubgraph", - "requires": [ - { - "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - }, - { - "kind": "InlineFragment", - "typeCondition": "GallerySection", - "selections": [ - { - "kind": "Field", - "name": "__typename" - }, - { - "kind": "Field", - "name": "id" - } - ] - } - ], - "variableUsages": [ - "movieResultParam" - ], - "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ... on EntityCollectionSection { title artwork(params: $movieResultParam) } ... on GallerySection { artwork(params: $movieResultParam) } } }", - "operationName": "Search__artworkSubgraph__2", - "operationKind": "query", - "id": null, - "inputRewrites": null, - "outputRewrites": null, - "contextRewrites": null, - "schemaAwareHash": "3e84a53f967bf40d4c08254a94f3fa32a828ab3ad8184a22bb3439c596ecaaf4", - "authorization": { - "is_authenticated": false, - "scopes": [], - "policies": [] - } - } - } - ] - } - ] - } - }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n search {\n __typename\n ... on MovieResult {\n sections {\n __typename\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n }\n id\n }\n ... on ArticleResult {\n id\n sections {\n __typename\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n }\n }\n }\n }\n },\n Parallel {\n Flatten(path: \"search.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ... on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n }\n },\n },\n Flatten(path: \"search.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ... on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n },\n },\n },\n },\n}" - } - } -} diff --git a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_shouldnt_make_article_fetch.snap b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_shouldnt_make_article_fetch.snap index c8fe1fb487..f8b1c15acd 100644 --- a/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_shouldnt_make_article_fetch.snap +++ b/apollo-router/tests/snapshots/type_conditions___test_type_conditions_enabled_shouldnt_make_article_fetch.snap @@ -54,7 +54,7 @@ expression: response "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "5201830580c9c5fadd9c59aea072878f84465c1ae9d905207fa281aa7c1d5340", + "schemaAwareHash": "1138e2f3b4c65ff3ecc3bb1e38d5e8bb17923111e28b2cf0aff4e23c216d3854", "authorization": { "is_authenticated": false, "scopes": [], @@ -67,9 +67,8 @@ expression: response { "kind": "Flatten", "path": [ - "", "search", - "@|[MovieResult]", + "@|[ArticleResult]", "sections", "@" ], @@ -79,7 +78,7 @@ expression: response "requires": [ { "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", + "typeCondition": "GallerySection", "selections": [ { "kind": "Field", @@ -93,7 +92,7 @@ expression: response }, { "kind": "InlineFragment", - "typeCondition": "GallerySection", + "typeCondition": "EntityCollectionSection", "selections": [ { "kind": "Field", @@ -107,16 +106,16 @@ expression: response } ], "variableUsages": [ - "movieResultParam" + "articleResultParam" ], - "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ... on EntityCollectionSection { title artwork(params: $movieResultParam) } ... on GallerySection { artwork(params: $movieResultParam) } } }", + "operation": "query Search__artworkSubgraph__1($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ... on EntityCollectionSection { artwork(params: $articleResultParam) title } } }", "operationName": "Search__artworkSubgraph__1", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "62ff891f6971184d3e42b98f8166be72027b5479f9ec098af460a48ea6f6cbf4", + "schemaAwareHash": "75bfbb6e482fd72ae0f8a154c8333a8a899928d5845dc27d622a124b9904fe89", "authorization": { "is_authenticated": false, "scopes": [], @@ -127,9 +126,8 @@ expression: response { "kind": "Flatten", "path": [ - "", "search", - "@|[ArticleResult]", + "@|[MovieResult]", "sections", "@" ], @@ -139,7 +137,7 @@ expression: response "requires": [ { "kind": "InlineFragment", - "typeCondition": "GallerySection", + "typeCondition": "EntityCollectionSection", "selections": [ { "kind": "Field", @@ -153,7 +151,7 @@ expression: response }, { "kind": "InlineFragment", - "typeCondition": "EntityCollectionSection", + "typeCondition": "GallerySection", "selections": [ { "kind": "Field", @@ -167,16 +165,16 @@ expression: response } ], "variableUsages": [ - "articleResultParam" + "movieResultParam" ], - "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $articleResultParam: String) { _entities(representations: $representations) { ... on GallerySection { artwork(params: $articleResultParam) } ... on EntityCollectionSection { artwork(params: $articleResultParam) title } } }", + "operation": "query Search__artworkSubgraph__2($representations: [_Any!]!, $movieResultParam: String) { _entities(representations: $representations) { ... on EntityCollectionSection { title artwork(params: $movieResultParam) } ... on GallerySection { artwork(params: $movieResultParam) } } }", "operationName": "Search__artworkSubgraph__2", "operationKind": "query", "id": null, "inputRewrites": null, "outputRewrites": null, "contextRewrites": null, - "schemaAwareHash": "7e6f6850777335eb1421a30a45f6888bb9e5d0acf8f55d576d55d1c4b7d23ec7", + "schemaAwareHash": "d49281e1c4281485ae2f9ee8f82c313d79da4d8d54bc8a8a1ddd1a67c61a5f7b", "authorization": { "is_authenticated": false, "scopes": [], @@ -189,7 +187,7 @@ expression: response ] } }, - "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n search {\n __typename\n ... on MovieResult {\n sections {\n __typename\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n }\n id\n }\n ... on ArticleResult {\n id\n sections {\n __typename\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n }\n }\n }\n }\n },\n Parallel {\n Flatten(path: \".search.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ... on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n },\n },\n Flatten(path: \".search.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ... on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n }\n },\n },\n },\n },\n}" + "text": "QueryPlan {\n Sequence {\n Fetch(service: \"searchSubgraph\") {\n {\n search {\n __typename\n ... on MovieResult {\n sections {\n __typename\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n }\n id\n }\n ... on ArticleResult {\n id\n sections {\n __typename\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n }\n }\n }\n }\n },\n Parallel {\n Flatten(path: \"search.@|[ArticleResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on GallerySection {\n __typename\n id\n }\n ... on EntityCollectionSection {\n __typename\n id\n }\n } =>\n {\n ... on GallerySection {\n artwork(params: $articleResultParam)\n }\n ... on EntityCollectionSection {\n artwork(params: $articleResultParam)\n title\n }\n }\n },\n },\n Flatten(path: \"search.@|[MovieResult].sections.@\") {\n Fetch(service: \"artworkSubgraph\") {\n {\n ... on EntityCollectionSection {\n __typename\n id\n }\n ... on GallerySection {\n __typename\n id\n }\n } =>\n {\n ... on EntityCollectionSection {\n title\n artwork(params: $movieResultParam)\n }\n ... on GallerySection {\n artwork(params: $movieResultParam)\n }\n }\n },\n },\n },\n },\n}" } } } diff --git a/apollo-router/tests/telemetry_resource_tests.rs b/apollo-router/tests/telemetry_resource_tests.rs new file mode 100644 index 0000000000..2c5c82ea27 --- /dev/null +++ b/apollo-router/tests/telemetry_resource_tests.rs @@ -0,0 +1,229 @@ +//! All of the tests in this file must execute serially wrt each other because they rely on +//! env settings and one of the tests modifies the env for the duration of the test. + +use std::collections::BTreeMap; +use std::env; + +use apollo_router::_private::telemetry::AttributeValue; +use apollo_router::_private::telemetry::ConfigResource; +use libtest_mimic::Arguments; +use libtest_mimic::Failed; +use libtest_mimic::Trial; +use opentelemetry::Key; + +fn main() { + let mut args = Arguments::from_args(); + args.test_threads = Some(1); // Run sequentially + + let tests = vec![ + Trial::test("test_empty", test_empty), + Trial::test("test_config_resources", test_config_resources), + Trial::test("test_service_name_override", test_service_name_override), + Trial::test( + "test_service_name_service_namespace", + test_service_name_service_namespace, + ), + ]; + libtest_mimic::run(&args, tests).exit(); +} + +struct TestConfig { + service_name: Option, + service_namespace: Option, + resources: BTreeMap, +} + +impl ConfigResource for TestConfig { + fn service_name(&self) -> &Option { + &self.service_name + } + fn service_namespace(&self) -> &Option { + &self.service_namespace + } + fn resource(&self) -> &BTreeMap { + &self.resources + } +} + +fn test_empty() -> Result<(), Failed> { + let test_config = TestConfig { + service_name: None, + service_namespace: None, + resources: Default::default(), + }; + let resource = test_config.to_resource(); + let service_name = resource + .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME.into()) + .unwrap(); + assert!( + service_name + .as_str() + .starts_with("unknown_service:telemetry_resources-"), + "{service_name:?}" + ); + assert!( + resource + .get(opentelemetry_semantic_conventions::resource::SERVICE_NAMESPACE.into()) + .is_none() + ); + assert_eq!( + resource.get(opentelemetry_semantic_conventions::resource::SERVICE_VERSION.into()), + Some(std::env!("CARGO_PKG_VERSION").into()) + ); + + assert!( + resource + .get(opentelemetry_semantic_conventions::resource::PROCESS_EXECUTABLE_NAME.into()) + .expect("expected excutable name") + .as_str() + .contains("telemetry_resources") + ); + Ok(()) +} + +fn test_config_resources() -> Result<(), Failed> { + let test_config = TestConfig { + service_name: None, + service_namespace: None, + resources: BTreeMap::from_iter(vec![ + ( + opentelemetry_semantic_conventions::resource::SERVICE_NAME.to_string(), + AttributeValue::String("override-service-name".to_string()), + ), + ( + opentelemetry_semantic_conventions::resource::SERVICE_NAMESPACE.to_string(), + AttributeValue::String("override-namespace".to_string()), + ), + ( + "extra-key".to_string(), + AttributeValue::String("extra-value".to_string()), + ), + ]), + }; + let resource = test_config.to_resource(); + assert_eq!( + resource.get(opentelemetry_semantic_conventions::resource::SERVICE_NAME.into()), + Some("override-service-name".into()) + ); + assert_eq!( + resource.get(opentelemetry_semantic_conventions::resource::SERVICE_NAMESPACE.into()), + Some("override-namespace".into()) + ); + assert_eq!( + resource.get(Key::from_static_str("extra-key")), + Some("extra-value".into()) + ); + Ok(()) +} + +fn test_service_name_service_namespace() -> Result<(), Failed> { + let test_config = TestConfig { + service_name: Some("override-service-name".to_string()), + service_namespace: Some("override-namespace".to_string()), + resources: BTreeMap::new(), + }; + let resource = test_config.to_resource(); + assert_eq!( + resource.get(opentelemetry_semantic_conventions::resource::SERVICE_NAME.into()), + Some("override-service-name".into()) + ); + assert_eq!( + resource.get(opentelemetry_semantic_conventions::resource::SERVICE_NAMESPACE.into()), + Some("override-namespace".into()) + ); + Ok(()) +} + +fn test_service_name_override() -> Result<(), Failed> { + // Order of precedence + // OTEL_SERVICE_NAME env + // OTEL_RESOURCE_ATTRIBUTES env + // config service_name + // config resources + // unknown_service:executable_name + // unknown_service (Untested as it can't happen) + + assert!( + TestConfig { + service_name: None, + service_namespace: None, + resources: Default::default(), + } + .to_resource() + .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME.into()) + .unwrap() + .as_str() + .starts_with("unknown_service:telemetry_resources-") + ); + + assert_eq!( + TestConfig { + service_name: None, + service_namespace: None, + resources: BTreeMap::from_iter(vec![( + opentelemetry_semantic_conventions::resource::SERVICE_NAME.to_string(), + AttributeValue::String("yaml-resource".to_string()), + )]), + } + .to_resource() + .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME.into()), + Some("yaml-resource".into()) + ); + + assert_eq!( + TestConfig { + service_name: Some("yaml-service-name".to_string()), + service_namespace: None, + resources: BTreeMap::from_iter(vec![( + opentelemetry_semantic_conventions::resource::SERVICE_NAME.to_string(), + AttributeValue::String("yaml-resource".to_string()), + )]), + } + .to_resource() + .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME.into()), + Some("yaml-service-name".into()) + ); + + // SAFETY: this program is single-threaded + unsafe { + env::set_var("OTEL_RESOURCE_ATTRIBUTES", "service.name=env-resource"); + } + assert_eq!( + TestConfig { + service_name: Some("yaml-service-name".to_string()), + service_namespace: None, + resources: BTreeMap::from_iter(vec![( + opentelemetry_semantic_conventions::resource::SERVICE_NAME.to_string(), + AttributeValue::String("yaml-resource".to_string()), + )]), + } + .to_resource() + .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME.into()), + Some("env-resource".into()) + ); + + // SAFETY: this program is single-threaded + unsafe { + env::set_var("OTEL_SERVICE_NAME", "env-service-name"); + } + assert_eq!( + TestConfig { + service_name: Some("yaml-service-name".to_string()), + service_namespace: None, + resources: BTreeMap::from_iter(vec![( + opentelemetry_semantic_conventions::resource::SERVICE_NAME.to_string(), + AttributeValue::String("yaml-resource".to_string()), + )]), + } + .to_resource() + .get(opentelemetry_semantic_conventions::resource::SERVICE_NAME.into()), + Some("env-service-name".into()) + ); + + // SAFETY: this program is single-threaded + unsafe { + env::remove_var("OTEL_SERVICE_NAME"); + env::remove_var("OTEL_RESOURCE_ATTRIBUTES"); + } + Ok(()) +} diff --git a/apollo-router/tests/tracing_common/mod.rs b/apollo-router/tests/tracing_common/mod.rs index f7f698b87f..ca2f6e92be 100644 --- a/apollo-router/tests/tracing_common/mod.rs +++ b/apollo-router/tests/tracing_common/mod.rs @@ -1,13 +1,13 @@ use apollo_router::plugin::test::MockSubgraph; use apollo_router::services::subgraph; -use base64::prelude::BASE64_STANDARD; use base64::Engine as _; +use base64::prelude::BASE64_STANDARD; use prost::Message; use prost_types::Timestamp; +use proto::reports::Trace; +use proto::reports::trace::Node; use proto::reports::trace::node::Id::Index; use proto::reports::trace::node::Id::ResponseName; -use proto::reports::trace::Node; -use proto::reports::Trace; use serde_json::json; use tower::ServiceExt; @@ -426,15 +426,7 @@ pub(crate) fn subgraph_mocks(subgraph: &str) -> subgraph::BoxService { }; builder.with_json( json!({ - "query": " - query($representations: [_Any!]!) { - _entities(representations: $representations) { - ..._generated_onProduct1_0 - } - } - fragment _generated_onProduct1_0 on Product { - reviews { author{ __typename id } } - }", + "query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { reviews { author { __typename id } } } } }", "variables": {"representations": [ {"__typename": "Product", "upc": "1"}, {"__typename": "Product", "upc": "2"}, diff --git a/apollo-router/tests/type_conditions.rs b/apollo-router/tests/type_conditions.rs index ab43b3f521..8bed46a375 100644 --- a/apollo-router/tests/type_conditions.rs +++ b/apollo-router/tests/type_conditions.rs @@ -3,12 +3,12 @@ //! use apollo_compiler::ast::Document; +use apollo_router::MockedSubgraphs; +use apollo_router::TestHarness; use apollo_router::graphql::Request; use apollo_router::graphql::Response; use apollo_router::plugin::test::MockSubgraph; use apollo_router::services::supergraph; -use apollo_router::MockedSubgraphs; -use apollo_router::TestHarness; use serde::Deserialize; use serde_json::json; use serde_json_bytes::ByteString; @@ -30,45 +30,38 @@ struct RequestAndResponse { #[tokio::test(flavor = "multi_thread")] async fn test_type_conditions_enabled() { - _test_type_conditions_enabled("legacy").await; - _test_type_conditions_enabled("new").await; + _test_type_conditions_enabled().await; } #[tokio::test(flavor = "multi_thread")] async fn test_type_conditions_enabled_generate_query_fragments() { - _test_type_conditions_enabled_generate_query_fragments("legacy").await; - _test_type_conditions_enabled_generate_query_fragments("new").await; + _test_type_conditions_enabled_generate_query_fragments().await; } #[tokio::test(flavor = "multi_thread")] async fn test_type_conditions_enabled_list_of_list() { - _test_type_conditions_enabled_list_of_list("legacy").await; - _test_type_conditions_enabled_list_of_list("new").await; + _test_type_conditions_enabled_list_of_list().await; } #[tokio::test(flavor = "multi_thread")] async fn test_type_conditions_enabled_list_of_list_of_list() { - _test_type_conditions_enabled_list_of_list_of_list("legacy").await; - _test_type_conditions_enabled_list_of_list_of_list("new").await; + _test_type_conditions_enabled_list_of_list_of_list().await; } #[tokio::test(flavor = "multi_thread")] async fn test_type_conditions_disabled() { - _test_type_conditions_disabled("legacy").await; - _test_type_conditions_disabled("new").await; + _test_type_conditions_disabled().await; } #[tokio::test(flavor = "multi_thread")] async fn test_type_conditions_enabled_shouldnt_make_article_fetch() { - _test_type_conditions_enabled_shouldnt_make_article_fetch("legacy").await; - _test_type_conditions_enabled_shouldnt_make_article_fetch("new").await; + _test_type_conditions_enabled_shouldnt_make_article_fetch().await; } -async fn _test_type_conditions_enabled(planner_mode: &str) -> Response { +async fn _test_type_conditions_enabled() -> Response { let harness = setup_from_mocks( json! {{ "experimental_type_conditioned_fetching": true, - "experimental_query_planner_mode": planner_mode, // will make debugging easier "plugins": { "experimental.expose_query_plan": true @@ -113,11 +106,10 @@ async fn _test_type_conditions_enabled(planner_mode: &str) -> Response { response } -async fn _test_type_conditions_enabled_generate_query_fragments(planner_mode: &str) -> Response { +async fn _test_type_conditions_enabled_generate_query_fragments() -> Response { let harness = setup_from_mocks( json! {{ "experimental_type_conditioned_fetching": true, - "experimental_query_planner_mode": planner_mode, // will make debugging easier "plugins": { "experimental.expose_query_plan": true @@ -162,11 +154,10 @@ async fn _test_type_conditions_enabled_generate_query_fragments(planner_mode: &s response } -async fn _test_type_conditions_enabled_list_of_list(planner_mode: &str) -> Response { +async fn _test_type_conditions_enabled_list_of_list() -> Response { let harness = setup_from_mocks( json! {{ "experimental_type_conditioned_fetching": true, - "experimental_query_planner_mode": planner_mode, // will make debugging easier "plugins": { "experimental.expose_query_plan": true @@ -212,11 +203,10 @@ async fn _test_type_conditions_enabled_list_of_list(planner_mode: &str) -> Respo } // one last to make sure unnesting is correct -async fn _test_type_conditions_enabled_list_of_list_of_list(planner_mode: &str) -> Response { +async fn _test_type_conditions_enabled_list_of_list_of_list() -> Response { let harness = setup_from_mocks( json! {{ "experimental_type_conditioned_fetching": true, - "experimental_query_planner_mode": planner_mode, // will make debugging easier "plugins": { "experimental.expose_query_plan": true @@ -261,11 +251,10 @@ async fn _test_type_conditions_enabled_list_of_list_of_list(planner_mode: &str) response } -async fn _test_type_conditions_disabled(planner_mode: &str) -> Response { +async fn _test_type_conditions_disabled() -> Response { let harness = setup_from_mocks( json! {{ "experimental_type_conditioned_fetching": false, - "experimental_query_planner_mode": planner_mode, // will make debugging easier "plugins": { "experimental.expose_query_plan": true @@ -309,11 +298,10 @@ async fn _test_type_conditions_disabled(planner_mode: &str) -> Response { response } -async fn _test_type_conditions_enabled_shouldnt_make_article_fetch(planner_mode: &str) -> Response { +async fn _test_type_conditions_enabled_shouldnt_make_article_fetch() -> Response { let harness = setup_from_mocks( json! {{ "experimental_type_conditioned_fetching": true, - "experimental_query_planner_mode": planner_mode, // will make debugging easier "plugins": { "experimental.expose_query_plan": true @@ -493,15 +481,15 @@ fn normalize_response_extensions(mut response: Response) -> Response { for (key, value) in extensions.iter_mut() { visit_object(key, value, &mut |key, value| { - if key.as_str() == "operation" { - if let Value::String(s) = value { - let new_value = Document::parse(s.as_str(), key.as_str()) - .unwrap() - .serialize() - .no_indent() - .to_string(); - *value = Value::String(new_value.into()); - } + if key.as_str() == "operation" + && let Value::String(s) = value + { + let new_value = Document::parse(s.as_str(), key.as_str()) + .unwrap() + .serialize() + .no_indent() + .to_string(); + *value = Value::String(new_value.into()); } }); } diff --git a/deny.toml b/deny.toml index 55cd3fb0f0..73e7802527 100644 --- a/deny.toml +++ b/deny.toml @@ -6,16 +6,10 @@ db-path = "~/.cargo/advisory-db" # The url(s) of the advisory databases to use db-urls = ["https://github.com/rustsec/advisory-db"] -# The lint level for security vulnerabilities -vulnerability = "deny" # The lint level for unmaintained crates -unmaintained = "warn" +unmaintained = "workspace" # The lint level for crates that have been yanked from their source registry yanked = "warn" -# The lint level for crates with security notices. Note that as of -# 2019-12-17 there are no security notice advisories in -# https://github.com/rustsec/advisory-db -notice = "warn" # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. @@ -30,15 +24,23 @@ git-fetch-with-cli = true ignore = [ "RUSTSEC-2023-0071", "RUSTSEC-2024-0376", # we do not use tonic::transport::Server + "RUSTSEC-2024-0421", # we only resolve trusted subgraphs + + # protobuf is used only through prometheus crates, enforced by + # a `[bans]` entry below. in the prometheus crates, only the protobuf + # encoder is used, while only the decoder is affected by this advisory. + "RUSTSEC-2024-0437", + + # The following crates are unmaintained + "RUSTSEC-2024-0320", # TODO replace the `yaml-rust` crate with a maintained equivalent + "RUSTSEC-2024-0436", # TODO replace the `paste` crate with a maintained equivalent + "RUSTSEC-2024-0388", # TODO replace the `derivative` crate with a maintained equivalent ] # This section is considered when running `cargo deny check licenses` # More documentation for the licenses section can be found here: # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html [licenses] -# The lint level for crates which do not have a detectable license -# TODO[igni]: remove this once the span / macro are fleshed out -unlicensed = "warn" # List of explictly allowed licenses # See https://spdx.org/licenses/ for list of possible licenses # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. @@ -53,12 +55,13 @@ allow = [ "MIT", "MPL-2.0", "Elastic-2.0", + "OpenSSL", # required by aws-lc-sys "Unicode-DFS-2016", - "Zlib" + "Unicode-3.0", + "Zlib", + "NCSA", # similar to MIT/BSD-3-Clause, used by libfuzzer + "CDLA-Permissive-2.0", # webpki-roots ] -copyleft = "warn" -allow-osi-fsf-free = "neither" -default = "deny" confidence-threshold = 0.8 # ring has a rather complicated LICENSE file due to reasons spelled out @@ -93,25 +96,12 @@ highlight = "all" # List of crates to deny deny = [ - # `cargo-scaffold` uses `git2` which uses `libssh2-sys` and `libgit2-sys`. - # Both require `openssl-sys`. Adding this rule in this way is sufficient to - # allow those to use `openssl-sys` (not a runtime dependency), leveraging the - # capabilities of `cargo-deny` to "block" `openssl-sys`. However, this isn't - # defensive enough on its own since we could introduce `git2` in - # `apollo-router` and we would inadvertently get `openssl-sys` and it would - # _not_ be blocked. That's bad! Unfortunately, the `wrappers` technique of - # `cargo-deny` only enables exceptions for _direct_ dependencies. To defend - # against the above risk, we add additional rules here (below) which _only_ - # allows `git2` in `cargo-scaffold`. This is a bit wonky, since we may at - # some point want `git2` but does accomplish what we want with the desired - # exception. - { name = "openssl-sys", wrappers = ["git2", "libssh2-sys", "libgit2-sys"] }, - # Note! This line is required to support the above exception. - { name = "git2", wrappers = ["auth-git2", "cargo-scaffold"] }, - # Note! This line is required to support the above exception. - { name = "libgit2-sys", wrappers = ["git2"] }, - # Note! This line is required to support the above exception. - { name = "libssh2-sys", wrappers = ["libgit2-sys"] }, + { crate = "openssl-sys" }, + # Prevent adding new dependencies on protobuf that may use code with + # a security advisory in it (see `[advisories]`). + # If you *must* add a new crate to the "wrappers" here, carefully audit + # that it is *not* affected by any of the advisories above. + { crate = "protobuf:<3.7.2", wrappers = ["prometheus", "opentelemetry-prometheus"] }, ] # This section is considered when running `cargo deny check sources`. diff --git a/dev-docs/BACKPRESSURE_REVIEW_NOTES.md b/dev-docs/BACKPRESSURE_REVIEW_NOTES.md new file mode 100644 index 0000000000..63e997d893 --- /dev/null +++ b/dev-docs/BACKPRESSURE_REVIEW_NOTES.md @@ -0,0 +1,127 @@ +# Back-Pressure Review + +This PR is intended to introduce some backpressure into the router so that traffic shaping works effectively. + +## Pipeline Change Motivations + +### Lack of feedback + +Poll::Ready(Ok(())) is a sign that a service is not going to ask the successor service if it's ready. In a pipeline that would like to exercise back-pressure this is not a good thing. + +We don't exercise any back-pressure before we hit the router service. The main reason for this is a desire to put telemetry at the front of the back-pressure pipe. Currently, the router service accepts requests and unquestioningly (recent changes to the query planner mean, it's not entirely unquestioning, but ...) starts to process them. The main motivation of this change is to allow services to poll successors for readiness. + +### State + +Some services require state to work effectively. Creating and throwing away our pipeline for every request is both wasteful and breaks our ability to use state aware layers, such as RateLimits. The secondary motivation for this change is the ability to use standard Tower layers for rate limits or other stateful functionality. + +## Review Sequencing + +I've tried to group all the files which are interesting to review here. All files should be reviewed, but these notes are intended to make understanding the more complex changes a little simpler. + +### Pipeline Cloning + +The most complex (and probably difficult to understand changes) are in the various affected services which implement ServiceFactory. I've not fixed all instances. I've done enough to allow us to exercise effective backpressure from RouterService -> ExecutionService and then again through the SubgraphService. The reason I haven't converted all services is because it's hard/complicated and the improvements we get from these changes are probably enough to materially improve the behaviour of the router. We may be able to do the same thing to other services in 2.1 or it may have to wait until 3.0. We need a Mutex to store our single service under because the various consumers of ServiceFactory require Sync access to our various services. If we could unpick that change of responsibilities we could maybe improve this so that we don't need the Mutex, but I think it would be major open heart surgery on the router to do this. In the performance testing I've performed so far, the Mutex doesn't seem to be a hot spot since it's called once per connection for Router and Service (multiple times for subgraphs) and is a lightweight Arc clone() under the lock which should be pretty fast. + +#### Router Service + +There are substantial changes here to form the start of our cloneable, backpressure enabled pipeline. The primary changes are + - Not to provide a supergraph creator to the router service. We want it to use the same supergraph service as long as it lives. + - Only create the router create service builder once (in the constructor). We story it under a Mutex to keep it Sync and then clone it whenever we need it. + +apollo-router/src/services/router/service.rs + +#### Supergraph Service + +We make a lot of changes here so that our downstream execution service is preserved. Again we only build a single service which we store under a mutex and lock/clone as required. + +apollo-router/src/services/supergraph/service.rs + +#### Subgraph Service + +We do the same thing as the router service and only create a single Subgraph service for each subgraph. The complication is that we have multiple subgraphs, so we store them in a hash. + +apollo-router/src/services/subgraph_service.rs + +### BackPressure Preservation + +In a number of places, we could have preserved backpressure, but didn't. These are fixed in this PR. + +apollo-router/src/plugins/traffic_shaping/deduplication.rs +apollo-router/src/services/hickory_dns_connector.rs + +### Backpressure Control + +In traffic shaping we have the bulk of implementation which exercises control over our new backpressure, via load_shed. + +apollo-router/src/plugins/traffic_shaping/mod.rs + +### Removing Stuff + +OneShotAsyncCheckpoint is a potential footgun in a backpressure pipeline. I've removed it. A number of files are impacted by now using AsyncCheckpoint which requires `buffered`. + +apollo-router/src/layers/async_checkpoint.rs +apollo-router/src/layers/mod.rs +apollo-router/src/plugins/coprocessor/execution.rs +apollo-router/src/plugins/coprocessor/mod.rs +apollo-router/src/plugins/coprocessor/supergraph.rs +apollo-router/src/plugins/file_uploads/mod.rs +apollo-router/src/services/layers/allow_only_http_post_mutations.rs + +### Changes because pipeline is now Cloned + +apollo-router/src/plugin/test/mock/subgraph.rs +apollo-router/src/plugins/cache/entity.rs +apollo-router/src/plugins/cache/metrics.rs +apollo-router/src/plugins/cache/tests.rs +apollo-router/src/services/layers/apq.rs + +### Comments on layer review + +There are a number of comments in the PR relating to dev-docs/layer-inventory.md. + +apollo-router/src/plugins/authorization/mod.rs +apollo-router/src/services/layers/content_negotiation.rs +apollo-router/src/services/layers/query_analysis.rs +apollo-router/src/services/layers/static_page.rs + +### Changes to Snapshots + +#### New Concurrency Feature Added + +apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap + +#### Changes to error/extension codes + +apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__router_timeout.snap +apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_timeout.snap + +### Changes because of old cruft (usually in tests) + +apollo-router/src/axum_factory/tests.rs + +This should probably have been deleted a long time ago. I replaced it with the new test `it_timeout_router_requests` in apollo-router/src/plugins/traffic_shaping/mod.rs + +### Examples + +Mock expectations have changed and OneshotAsyncCheckpoint has been removed. Examples were updated to match the changes. + +### Stuff I'm not sure about NOTE: IT MAY BE LAST IN THE FILE BUT IT NEEDS MOST CAREFUL REVIEW + +#### Body Limits + +This change is a complicated re-factoring so that limits are maintained for a connection. The previous strategy of keeping BodyLimitControl under an Arc does not work, because cloning the pipeline would mean it is shared across all connections. The PR changes this so that limits are still shared across all requests on a single connection, but not across all connections. This enables dynamic updating of the limit to work, but I'm not 100% sure this is the right thing to do. + +This requires detailed review and discussion. + +apollo-router/src/plugins/limits/layer.rs +apollo-router/src/plugins/limits/mod.rs + +#### Sundry stuff I'm not sure about + +I don't know if we want the `mock_subgraph_service_withf_panics_should_be_reported_as_service_closed test anymore` + +apollo-router/src/query_planner/tests.rs + +Now that traffic shaping happens earlier in the router, some things, which probably never were graphql errors anyway, are now no longer reported as graphql errors. I think the answer is to document this in the migration guide, since they now appear as OTEL response standard errors. + +apollo-router/tests/integration/traffic_shaping.rs diff --git a/dev-docs/HYPER_1.0_REVIEW_NOTES.md b/dev-docs/HYPER_1.0_REVIEW_NOTES.md new file mode 100644 index 0000000000..4fdac014ba --- /dev/null +++ b/dev-docs/HYPER_1.0_REVIEW_NOTES.md @@ -0,0 +1,115 @@ +# Hyper 1.0 Review Notes + +## Generally Useful Information + +Read HYPER_1.0_UPDATE.md first. This provides a lot of generally +useful information. + +### Crate updates + +Many crates have been updated as part of the update. In some parts of +codebase we had to continue using the older version of the crate so +that opentelemetry (which has not been updated to by hyper 1.0 +compliant) would continue to work. + +tonic-0_9 = { version = "0.9.0", features = [ +reqwest-0_11 = { version = "0.11.27", default-features = false, features = [ +http-0_2 = { version = "0.2.12", package = "http" } + +When opentelemetry is updated to use hyper 1.0 we will remove these changes. + +### Body Manipulation + +The change in Hyper to have many different types of bodies implementing the +Body trait means it was useful to have a set of useful body manipulation +functions which are collected in apollo-router/src/services/router/body.rs. + +Being familiar with these in the review will be helpful as they are used in +many locations. + +### hyper_header_limits feature + +We removed this since it's not required in hyper 1.0 + +### XXX Comments + +Anywhere you see a XXX comment is an indication that this should be reviewed +carefully. + +## Focussed Review + +Please pay particular attention to these files, since they proved tricky +to update: + +### apollo-router/src/axum_factory/axum_http_server_factory.rs + +Some of the configuration is centralised on `next` as http_config and passed +to serve_router_on_listen_addr. We can't do that following the hyper update +because there are now different builders for Http1 or Http2 configuration. + +The HandleErrorLayer has been removed at line 443 and the comment there +explains the change. Anyone with more specific knowledge about how +decompression works should review this carefully. + +metrics_handler/license_handler no longer need to be generic. + +Changes in Axum routing mean that handle_graphql is cleaner to write as a +generic function. + +### apollo-router/src/axum_factory/listeners.rs + +Some of the most complex changes with respect to TCP stream handling were +encountered here. Note that `TokioIo` and `hyper::service::service_fn` were +used to "wrap" Axum application and service handler to integrate everything +together. Please familiarise yourselves with how these work so that you +can review the changes in this file. + +There is an unresolved problem in the port with graceful shutdown which we +still need to figure out. I believe it is the cause of one of our jaeger +tests which are failing. + +The primary additional changes here are releating to how hyper services +are configured, built and served. + +### apollo-router/src/axum_factory/tests.rs + +`UnixStream` was provided as a helpful wrapper around `tokio::net::UnixStream` +to simplify integration with existing tests.:wq + +### apollo-router/src/plugins/connectors/make_requests.rs + +In order to be able to compare snapshots we hae to `map` our requests +into a tuple where the request has a converted body. + +We can't preserve the existing request becase the type of the body (RouterBody) +would't match. This means we can still snapshot across body contents. + +### apollo-router/src/plugins/coprocessor/mod.rs + +We replace the RouterBodyConverter type with a MapResponse. + +### apollo-router/src/plugins/limits/limited.rs + +We remove `poll_trailers` sincethe router doesn't do anything meaninfgul with +trailers (and neither did this implementation) + +The `poll_data` is replaced with `poll_frame` to utilise our new stream +conversion functionality. + +### apollo-router/src/plugins/telemetry/config_new/connector/selectors.rs + +In tests we replaced bodies of "" with empty bodies. That seems fine, but +more informed opinions are sought here. We've done that in a few other files +as well and the tests are also all passing. + +### apollo-router/src/plugins/traffic_shaping/retry.rs + +I'm not sure why all of the tests from line 91 were deleted. Anyone have any +ideas? + +### apollo-router/src/services/http/tests.rs + +These tests were particularly tricky to convert, so please examine them +carefully for any issues. Especially with regard to TLS. + + diff --git a/dev-docs/HYPER_1.0_UPDATE.md b/dev-docs/HYPER_1.0_UPDATE.md new file mode 100644 index 0000000000..a39d56ea34 --- /dev/null +++ b/dev-docs/HYPER_1.0_UPDATE.md @@ -0,0 +1,129 @@ +# Hyper 1.0 upgrade decisions + +Document useful information for posterity. + +## Additional Crates + +The hyper ecosystem has split functionality into multiple crates. Some +functionality has migrated to new crates (-util). + +axum = { version = "0.6.20", features = ["headers", "json", "original-uri"] } -> { version = "0.7.9", features = ["json", "original-uri"] } +axum-extra = NEW -> { version = "0.9.6", features = [ "typed-header" ] } +Note: Not sure if I need to enabled typed-header, check this later + +http = "0.2.11" -> "1.1.0" +http-body = "0.4.6" -> "1.0.1" +http-body-util = NEW "0.1.2" + +hyper = { version = "0.14.28", features = ["server", "client", "stream"] } -> hyper = { version = "1.5.1", features = ["full"] } +hyper-util = NEW { version = "0.1.10", features = ["full"] } + +## Type Changes + +A lot of types are changing. It's not always a 1:1 change, because the new +versions of hyper/axum offer much more nuance. I've tried to apply the +following changes consistently. + +### hyper::Body + +This is no longer a struct, but a trait with multiple implementations depending +on the use case. I've applied the following principles. + +#### Clearly driven from Axum + +In this case, I'm just using the `axum::body::Body` type as a direct +replacement for `hyper::Body`. I'm assuming that the axum folks know what +they are doing. + +#### Otherwise + +My default choice is `http_body_util::combinators::UnsyncBoxBody` + +This is chosen because it is a trait object which represents any of the many +structs which implement `hyper::body`. Unsync because the future Streams +which we use in the router are only Send, not Sync. + +From an `UnsyncBoxBody` we can easily convert to and from various useful +stream representations. + +### hyper::Error + +In 0.14 this struct was a good choice, however we found it difficult to work +with as we started to connect futures streams back to axum responses. + +We have replaced our use of `hyper::Error` with `axum::Error`. + +### hyper::server::conn::Http -> hyper_util::server::conn::auto::Builder; + +This is a straightforward drop-in replacement because the Http server +has been moved out of the `hyper` crate into `hyper_util` and renamed. + +### hyper::body::HttpBody -> http_body::Body as HttpBody; + +`HttpBody` no longer exists in `hyper`. This could be replaced either by +`http_body::Body` or `axum::body::HttpBody`. The latter is a re-export of the +former. + +I've gone with the former for now, since it is clearly a dependency on +`http_body` rather than a dependency on `axum`. + +### hyper::client::connect::dns::Name -> hyper_util::client::legacy::connect::dns::Name; +This is a straightforward drop-in replacement because the Name struct +has been moved out of the `hyper` crate into `hyper_util`. + +### hyper::client::HttpConnector -> use hyper_util::client::legacy::connect::HttpConnector; +This is a straightforward drop-in replacement because the HttpConnector struct +has been moved out of the `hyper` crate into `hyper_util`. + +### http_body::Full -> axum::body::Full + +This is no longer required in hyper 1.0 conversion. + +### use axum::headers::HeaderName -> use axum_extra::headers::HeaderName + +This is a straightforward drop-in replacement because the ::headers module +has been moved out of the `axum` crate into `axum-extra`. + +Note: Not sure if axum-extra TypedHeader feature needs to be enabled for +this to continue working. Enabled for now. + +### use axum::body::boxed; + +This function appears to be completely removed and no longer required. +Just delete it from the code base. + +### use axum::body::StreamBody -> use http_body_util::StreamBody; + +This type has been moved to the http_body_util crate. + +### hyper::body::to_bytes(body) -> axum::body::to_bytes(body) + +Drop in replacement as functionality migrated from hyper to axum +Note: There may be a better way to do this in hyper 1.0, leave as this +for now. + +### hyper::Body::from(encoded) -> http_body_util::BodyStream::from(encoded) + +`Body` is now a trait, so I *think* this needs to be converted to become a +`BodyStream`. It may be that it should be a `Full`, check later. + +### hyper::Body::empty() -> http_body_util::Empty::new() + +`Body` is now a trait. `Empty` is an implementation of the trait which is +empty. + +### hyper::Client -> hyper_util::client::legacy::Client + +The `Client` has been moved to the `hyper_util` crate. + +### axum::Next is no longer generic + +Simply remove the generic argument + +### transport::Response -> crate::router::Response + +The transport module is no longer required, so we can remove it + +### tower::retry::budget::Budget -> use tower::retry::budget::TpsBudget; + +Ported to new tower Retry logic. diff --git a/dev-docs/layer-inventory.md b/dev-docs/layer-inventory.md new file mode 100644 index 0000000000..cf21fabfdf --- /dev/null +++ b/dev-docs/layer-inventory.md @@ -0,0 +1,157 @@ +# Layer Inventory +This document is an investigation and overview of our tower layers, and layer-like things, during 2.0 development. It describes the order and the purpose of each layer. + +This is ordered from the point of view of a request to the router, starting at the outer boundary and going "deeper" into the layer onion. + +Keep in mind that plugins can add hooks at various points of the pipeline. So a single plugin can have several entries in the list below. Also, requests flow from top to bottom, but responses flow bottom to top. + +Still missing are the execution and subgraph client parts of the onion, and layers added by plugins. + +## Axum +Before entering the router service, we have some layers on our axum Router. These are already functioning properly in the tower service stack. + +- "Metrics handler" + - It only implements the `apollo.router.operations` metric. + - This is using `axum::middleware::from_fn`, so it is functioning properly as a tower layer. +- TraceLayer + - This one is from `tower-http`! +- CorsLayer + - This one is from `tower-http`! + - This layer enables web browser clients to see response bodies. It is important that this layer has access to error responses from traffic shaping, otherwise a browser client can't know about that. +- "License handler" + - Logs a warning if the commercial licence is expired + - Rejects requests if the commercial licence is "halted" (expired + a grace period) + - This is using `axum::middleware::from_fn`, so it is functioning properly as a tower layer. +- RequestDecompressionLayer + - This one is from `tower-http`! + - This replaces the body type with a streaming decompression body type based on the Accept-Encoding header. + - The body is not read in this layer. Decompression happens as-needed when the body is read further down the stack. + +Now, we enter `handle_graphql`. + +- Response Compression + - This is manually written inside `handle_graphql`, but could conceptually be considered a layer. + - I don't see an obvious reason for why this could not use a standard tower-http compression layer? +- Then we create (clone) a router service and oneshot it. + +## Router service +The router service consists of some layers in "front" of the service "proper", and of several layers *inside the router service*, which we appear to call manually. + +I suspect that this is bad and that we should try to make all these layers part of a straightforward tower service stack. + +Front (`RouterCreator`): +- StaticPageLayer + - If configured, responds to any request that accepts a "text/html" response (*at all*, regardless of preference), with a fixed HTML response. + - This must occur before content negotiation, which rejects "Accept: text/html" requests. +- Content negotiation: Request-side + - This layer rejects requests with invalid Accept or Content-Type headers. + +Plugins: +- Telemetry: InstrumentLayer + - Only used with `SpanMode::Deprecated` + - Maybe a candidate for removal in 3.0? +- Telemetry: other work + - A lot of stuff is happening inside a `map_future` layer. I haven't checked this out but I think it's fine from a backpressure/pipeline perspective. + - This would be easier to understand in a named layer, potentially. +- Traffic shaping: + - Load shedding. + - This is just tower! + - TimeoutLayer + - Only present if a timeout is configured in the router. + - We use a `.map_result()` layer to handle the error and turn it into a GraphQL error response. + - This is provided by `tower`! + - Load shedding. + - ConcurrencyLimitLayer + - Only present if a concurrency limit is configured in the router. + - We use a `.map_result()` layer to handle the error and turn it into a GraphQL error response. + - This is provided by `tower`! + - Load shedding. + - RateLimitLayer + - Only present if a global rate limit is configured in the router. + - We use a `.map_result()` layer to handle the error and turn it into a GraphQL error response. + - This is provided by `tower`! +- Body limiting (limits plugin) + - Rejects bodies that are too big. It's an equivalent of the tower-http `RequestBodyLimitLayer`. + - There's a bit of a dance happening here that we can hopefully remove. + - Comment: "This layer differs from the tower version in that it will always generate an error eagerly rather than allowing the downstream service to catch and handle the error." + - I do not understand what that means. But it must be related to making the `map_error_to_graphql` call inside the limits plugin work. + - It may not be trivial to change this to use tower-http. +- CSRF + - This is a checkpoint layer. + - Rejects requests that might be cross-site request forgery, by making sure all requests have a header or a content-type that *requires* the browser to do CORS preflight. (I.e., we prevent requests that are exempt from CORS from executing queries.) +- Fleet detection + - Records router request and response size. + - The size counting has the effect of turning all bodies into streams, which may have negative effects! +- Authentication: InstrumentLayer + - The same underlying implementation as the one in "Telemetry: InstrumentLayer", but creating a different span. +- Authentication: implementation + - It reads a JWT from the request and adds various context values. + - It can short-circuit on invalid JWTs. + - This is just a checkpoint layer, so it will be easy to adapt +- File uploads + - Processes multipart requests and enforces file uploads-specific limits (eg. max amount of files). + - The multipart state is moved into extensions and the request is modified to look like a normal GraphQL request. +- Progressive override + - Adds override labels used in the schema to the context. Coprocessors can supposedly use this. +- Rhai/coprocessors + - I have not looked deeply into it but I think this will be okay + +Proper (`RouterService`): +- Batching + - This is not a layer but the code can sort of be understood conceptually like one. Maybe it could, should be a layer? + - Splits apart the incoming request into multiple requests that go through the rest of the pipeline, and puts the responses back together. +- Persisted queries: Expansion + - This expands requests that use persisted query IDs, and rejects freeform GraphQL requests if not allowed per router configuration. + - This is *not* a real layer right now. I suspect it should be. +- APQs + - Clients can submit a hash+query body to add a query to the cache. Afterward, clients can use only the hash, and this layer will populate the query string in the request body before query analysis. + - This is *not* a real layer right now. I suspect it should be. +- Query analysis + - This does query parsing and validation and schema-aware hashing, + and field/enum usage tracking for apollo studio. + - This is *not* a real layer right now. I suspect it should be. + - This includes an explicit call to the AuthorizationPlugin. I suspect the AuthorizationPlugin should instead add its own layer for this, but chances are there was a good reason to do it this way, like not having precise enough hook-points. Still, maybe it can be a separate layer that we add manually. + - Query analysis also exposes a method that is used by the query planner service, which could just as well be a standalone function in my opinion. +- Persisted queries: Safelisting + - This is *not* a real layer right now. I suspect it should be. + - For requests *not* using persisted queries, this layer checks incoming GraphQL documents against the "free-form GraphQL behaviour" configuration (essentially: safelisting), and rejects requests that are not allowed. +- Straggler bits, not part of sub-layers. I think some of these should be normal layers, and some of them should be just a `.map_response()` layer in the service stack. + - It does something with the `Vary` header. + - It adjusts the status code to 499 if the request was canceled. + - It does various JSON serialization bits. + - It does various telemetry bits such as counting errors. + - It appears to do the *exact same thing* as "Content negotiation: Response-side" to populate the Content-Type header. + +## Supergraph service +The supergraph service consists of some layers in "front" of the service "proper", and several interacting services *inside* the supergraph service. + +The implementation of those interactions is more complicated than in the router service, but I think many things could probably be implemented as a normal tower service stack, and we could benefit from doing that. + +Front (`SupergraphCreator`): +- Content negotiation: Response-side + - This layer sets the Content-Type header on the response. +- AllowOnlyHttpPostMutationsLayer is the final step before going into the supergraph service proper. + +Plugins: +- Telemetry: InstrumentLayer + - The same underlying implementation as the one in `router_service`, but creating a different span. +- Telemetry: other work + - A lot of stuff is happening inside a `map_response` and a `map_future` layer. + - This copies several resources when the service is created, and uses them for the entire lifetime of the service. This will not do if we do not create the pipeline from scratch for every request. + - This would be easier to understand if split apart into several named layers, potentially. +- Authorization + - Rejects requests if not authenticated. + - I'm extremely confused why this happens here as opposed to the authentication plugin. + - This is a checkpoint layer. +- File uploads + - Patches up variables in the parsed JSON to pass validation. +- Entity caching: Cache control headers + - Sets cache control headers on responses based on context. The context is populated by the subgraph service. +- Progressive override + - Collect overriden labels from the context (added by rhai or coprocessors), calculate the special percentage-chance labels to override, and add *all* enabled override labels to the context for use by the query planner service. +- Connectors + - Not sure what this does in detail but it's using `map_future_with_request_data` which is probably fine. +- Rhai/coprocessors + - I have not looked deeply into it but I think this will be okay + +The bulk of the "Proper" `SupergraphService` is doing query planning, and then handing things off to the execution service. This probably could be conceptualised as 2 layers, or 3 if there is also one to handle subscriptions. diff --git a/dev-docs/logging.md b/dev-docs/logging.md index abf7ef32c1..bb2517d74b 100644 --- a/dev-docs/logging.md +++ b/dev-docs/logging.md @@ -106,7 +106,7 @@ expression: yaml - fields: alg: ES256 reason: "invalid type: string \"Hmm\", expected a sequence" - index: 5 + index: 5 level: WARN message: "ignoring a key since it is not valid, enable debug logs to full content" ``` @@ -130,7 +130,7 @@ Use `with_subscriber` to attach a subscriber to an async block. ```rust #[tokio::test] async fn test_async() { - async{...}.with_subscriber(assert_snapshot_subscriber!()) + async{...}.with_subscriber(assert_snapshot_subscriber!()).await } ``` diff --git a/dev-docs/metrics.md b/dev-docs/metrics.md index 34530201ef..e5e4ee33a4 100644 --- a/dev-docs/metrics.md +++ b/dev-docs/metrics.md @@ -113,6 +113,32 @@ meter_provider() .init(); ``` +### Units + +When adding new metrics, the `_with_unit` variant macros should be used. Units should conform to the +[OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/#units), +some of which has been copied here for reference: + +* Instruments that measure a count of something should use annotations with curly braces to + give additional meaning. For example, use `{packet}`, `{error}`, `{request}`, etc., not `packet`, + `error`, `request`, etc. +* Other instrument units should be specified using the UCUM case-sensitive (`c/s`) variant. For + example, `Cel` for the unit with full name "degree Celsius". +* When instruments are measuring durations, seconds (i.e. `s`) should be used. +* Instruments should use non-prefixed units (i.e. `By` instead of `MiBy`) unless there is good + technical reason to not do so. + +We have not yet modified the existing metrics because some metric exporters (notably +Prometheus) include the unit in the metric name, and changing the metric name will be a breaking +change for customers. Ideally this will be accomplished in router 3. + +Examples of Prometheus metric renaming; note that annotations are not appended to the metric names: + +```rust +u64_counter_with_unit!("apollo.test.requests", "test description", "{request}", 1); // apollo_test_requests +f64_counter_with_unit!("apollo.test.total_duration", "test description", "s", 1); // apollo_test_total_duration_seconds +``` + ### Testing When using the macro in a test you will need a different pattern depending on if you are writing a sync or async test. @@ -121,7 +147,7 @@ When using the macro in a test you will need a different pattern depending on if #[test] fn test_non_async() { // Each test is run in a separate thread, metrics are stored in a thread local. - u64_counter!("test", "test description", 1, "attr" => "val"); + u64_counter_with_unit!("test", "test description", 1, "attr" => "val"); assert_counter!("test", 1, "attr" => "val"); } ``` @@ -130,13 +156,18 @@ When using the macro in a test you will need a different pattern depending on if Make sure to use `.with_metrics()` method on the async block to ensure that the metrics are stored in a task local. *Tests will silently fail to record metrics if this is not done.* + +For testing metrics across spawned tasks, use `.with_current_meter_provider()` to propagate the meter provider to child tasks: + ```rust + use crate::metrics::FutureMetricsExt; + #[tokio::test(flavor = "multi_thread")] async fn test_async_multi() { // Multi-threaded runtime needs to use a tokio task local to avoid tests interfering with each other async { u64_counter!("test", "test description", 1, "attr" => "val"); - assert_counter!("test", 1, "attr" => "val"); + assert_counter!("test", 1, "attr" = "val"); } .with_metrics() .await; @@ -147,11 +178,56 @@ Make sure to use `.with_metrics()` method on the async block to ensure that the async { // It's a single threaded tokio runtime, so we can still use a thread local u64_counter!("test", "test description", 1, "attr" => "val"); - assert_counter!("test", 1, "attr" => "val"); + assert_counter!("test", 1, "attr" = "val"); } .with_metrics() .await; } + + #[tokio::test] + async fn test_metrics_across_tasks() { + async { + u64_counter!("apollo.router.test", "metric", 1); + assert_counter!("apollo.router.test", 1); + + // Use with_current_meter_provider to propagate metrics to spawned task + tokio::spawn(async move { + u64_counter!("apollo.router.test", "metric", 2); + }.with_current_meter_provider()) + .await + .unwrap(); + + // Now the metric correctly resolves to 3 since the meter provider was propagated + assert_counter!("apollo.router.test", 3); + } + .with_metrics() + .await; + } +``` + +Note: Without using `with_current_meter_provider()`, metrics updated from spawned tasks will not be collected correctly: + +```rust +#[tokio::test] +async fn test_spawned_metric_resolution() { + async { + u64_counter!("apollo.router.test", "metric", 1); + assert_counter!("apollo.router.test", 1); + + tokio::spawn(async move { + u64_counter!("apollo.router.test", "metric", 2); + }) + .await + .unwrap(); + + // In real operations, this metric resolves to a total of 3! + // However, in testing, it will resolve to 1, because the second incrementation happens in another thread. + // assert_counter!("apollo.router.test", 3); + assert_counter!("apollo.router.test", 1); + } + .with_metrics() + .await; +} ``` ## Callsite instrument caching @@ -179,3 +255,114 @@ Strong references to instruments will be discarded when changes to the aggregate On the fast path the mutex is locked for the period that it takes to upgrade the weak reference. This is a fast operation, and should not block the thread for any meaningful period of time. If there is shown to be contention in future profiling we can revisit. + +## Adding new metrics +There are different types of metrics. + +* Static - Declared via macro, cannot be configured, low cardinality, and are transmitted to Apollo. +* Dynamic - Configurable via yaml, not transmitted to Apollo. + +New features should add BOTH static and dynamic metrics. + +> Why are static metrics less good for users to for debugging? + +They can be used, but usually it'll be only a starting point for them. We can't predict the things that users will want to monitor, and if we tried we would blow up the cardinality of our metrics resulting in high costs for our users via their APMs. + +For instance, we **must not** add operation name to the attributes of a static metric as this is potentially infinite cardinality, but as a dynamic metric this is fine as users can use conditions to reduce the amount of data they are looking at. + +### Naming +Metrics should be named in a way that is consistent with the rest of the metrics in the system. + +**Metrics** +* `` - This should be a noun that describes the feature that the metric is monitoring. + +* `.` - Sub-metrics are usually a verb that describes the action that the metric is monitoring. + +**Attributes** +* `.` - Are always prefixed with the feature name unless they are standard metrics from the [otel semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/). + +### Static metrics +When adding a new feature to the Router you must also add new static metrics to monitor the usage of that feature, they can suppress these via views, but feature usage will always be sent to Apollo. +These metrics must be low cardinality and not leak any sensitive information. Users cannot change the attributes that are attached to these metrics. +These metrics are transmitted to Apollo unless explicitly disabled. + +When adding new static metrics and attributes make sure to: +* Include them in your design document. +* Look at the [OTel semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/) +* Engage with other developers to ensure that the metrics are right. Metrics form part of our public API and can only be removed in a major release. + +To define a static metric us a macro: +```rust +u64_counter!("apollo.router..", "description", 1, "attr" => "val"); +``` +| DO NOT USE `tracing` macros to define static metrics! They are slow, untestable and can lead to subtle bugs due to type mismatches! + +#### Non request/response metrics +Static metrics should be used for things that happen outside of the request response cycle. + +For instance: +* Router lifecycle events. +* Global log error rates. +* Cache connection failures. +* Rust vs JS query planner performance. + +None of these metrics will leak information to apollo, and they are all low cardinality. + +#### Operation counts +Each new feature MUST have an operation count metric that counts the number of requests that the feature has processed. + +When defining new operation metrics use the following conventions: + +**Name:** `apollo.router.operations.` - (counter) +> Note that even if a feature is experimental this should not be reflected in the metric name. + +**Attributes:** +* `.` - (usually a boolean or number, but can be a string if the set of possible values is fixed) + +> [!WARNING] +> **Remember that attributes are not to be used to store high cardinality or user specific information. Operation name is not permitted!** + +#### Config metrics +Each new feature MUST have a config metric that gives us information if a feature has been enabled. + +When defining new config metrics use the following conventions: + +**Name:** `apollo.router.config.` - (gauge) +> Note that even if a feature is experimental this should not be reflected in the metric name. + +**Attributes:** +* `opt.` - (usually a boolean or number, but can be a string if the set of possible values is fixed) + +### Dynamic metrics +Users may create custom instrument to monitor the health and performance of their system. They are highly configurable and the user has the ability to add custom attributes as they see fit. +These metrics will NOT be transmitted to Apollo and are only available to the user via their APM. + +> [!WARNING] +> **Failure to add dynamic metrics for a feature will render it un-debuggable and un-monitorable by the user.** + +Adding a new dynamic instrument means: +* Adding new selector(s) in the telemetry plugin. +* Adding tests that assert that the selector can correctly obtain the value from the relevant request or response type. +* (Optional) Adding new default instruments in the telemetry plugin. +* Adding documentation for new instruments and selectors. + +An example of a new dynamic instrument is the [cost metrics and selectors](https://github.com/apollographql/router/blob/dev/apollo-router/src/plugins/telemetry/config_new/cost/mod.rs) + +When adding new dynamic metrics and attributes make sure to: +* Include them in your design document. +* Look at the [OTel semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/) for guidance on naming. + +When defining new dynamic instruments use the following conventions: + +Name: +`.` - (counter, gauge, histogram) +> Note that even if a feature is experimental this should not be reflected in the metric name. + +Attributes: +* `.` - (selector) + + + + + + diff --git a/dev-docs/mock_subgraphs_plugin.md b/dev-docs/mock_subgraphs_plugin.md new file mode 100644 index 0000000000..bb07355f1a --- /dev/null +++ b/dev-docs/mock_subgraphs_plugin.md @@ -0,0 +1,130 @@ +# Writing tests with subgraphs replaced by fixed mock data + +When testing the Router, spinning up subgraph servers can be more involved than we’d like. +`apollo_router::test_harness::MockedSubgraphs` has long allowed tests to run a modified router +that intercepts subgraph requests and respond with fixed JSON data. +It is configured with a map of expected requests to corresponding responses. +For example: + +```rust +// Don’t do this going forward! See below + +let subgraphs = MockedSubgraphs([ + ( + "subgraph_name", + MockSubgraph::builder() + .with_json( + serde_json::json!({ + "query": "query($representations:[_Any!]!){_entities(representations:$representations){...on Contact{__typename country}}}", + "variables": { + "representations": [ + { + "__typename":"Contact", + "id":"0", + "displayName": "Max", + } + ] + } + }), + serde_json::json!({"data": { + "_entities": [{ + "__typename":"Contact", + "country": "Fr" + }] + }}) + ).with_json( + serde_json::json!({ + "query": "query($representations:[_Any!]!){_entities(representations:$representations){...on Contact{country}}}", + "variables": { + "representations": [ + { + "__typename":"Contact", + "id":"0", + "displayName": "Max", + } + ] + } + }), + serde_json::json!({"data": { + "_entities": [{ + "country": "Fr" + }] + }}) + ).build(), + ) +].collect()); + +let service = TestHarness::builder() + .configuration_json(serde_json::json!({ + "include_subgraph_errors": { "all": true }, + })) + .unwrap() + .schema(SOME_TESTING_SCHEMA) + .extra_plugin(subgraphs) + .build_supergraph() + .await + .unwrap(); +``` + +The map keys contain the `query` strings of subgraph requests generated by the query planner, +which can be hard to predict. + +Instead, the comparatively newer `mock_subgraphs` is a built-in plugin +based on a full-featured GraphQL executor. +There is no need configure expected requests as the executor should be able to handle any request. +Only the data for responses need to be configured, and a request’s selection set can select +any subset of it. + +```rust +// Recommended: + +let subgraphs = serde_json::json!({ + "subgraph_name": { + "entities": [ + { + "__typename": "Contact", + "id": "0", + "displayName": "Max", + "country": "Fr", + }, + ], + }, +}); + +let service = TestHarness::builder() + .configuration_json(serde_json::json!({ + "include_subgraph_errors": { "all": true }, + "experimental_mock_subgraphs": subgraphs, + })) + .unwrap() + .schema(SOME_TESTING_SCHEMA) + .build_supergraph() + .await + .unwrap(); +``` + +Compared to before, note the additional `configuration_json` line and the lack of `extra_plugin`. + +The full plugin configuration simplifies to: + +```rust +type MockSubgraphPluginConfig = Map; +type SubgraphName = String; + +struct ConfigForOneSubgraph { + /// Entities that can be queried through Federation’s special `_entities` field + entities: Vec, + /// Data for `query` operations (excluding the special `_entities` field) + query: JsonMap, + /// Data for `mutation` operations + mutation: Option, + /// HTTP headers for the subgraph response + headers: Map, +} +``` + +As of this writing, this plugin is only intended for the Router’s own tests but is always available. +It is excluded from the JSON Schema for Router configuration (see `HIDDEN_FROM_CONFIG_JSON_SCHEMA`) +and prefixed `experimental_`, but nothing actually prevents its use externally. + +The plugin implementation lives at `apollo-router/src/plugins/mock_subgraphs/mod.rs`. diff --git a/dev-docs/yaml-design-guidance.md b/dev-docs/yaml-design-guidance.md index bc62f41ecd..fde25cfbbd 100644 --- a/dev-docs/yaml-design-guidance.md +++ b/dev-docs/yaml-design-guidance.md @@ -6,15 +6,17 @@ In general users should have a pretty good idea of what a configuration option d ## Migrations -We won't always get things right, and sometimes we'll need to provide [migrations](apollo-router/src/configuration/migrations/README.md) from old config to new config. +We won't always get things right, and sometimes we'll need to provide [migrations][] from old config to new config. Make sure you: 1. Mention the change in the changelog 2. Update docs 3. Update any test configuration -4. Create a migration test as detailed in [migrations](apollo-router/src/configuration/migrations/README.md) +4. Create a migration test as detailed in [migrations][] 5. In your migration description tell the users what they have to update. +[migrations]: /apollo-router/src/configuration/migrations/README.md + ## Process It should be obvious to the user what they are configuring and how it will affect Router behaviour. It's tricky for us as developers to know when something isn't obvious to users as often we are too close to the domain. diff --git a/docker-compose.yml b/docker-compose.yml index f56b40eea1..cf403fbbf7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,20 +2,31 @@ version: "3.9" services: redis: image: redis:latest + security_opt: + - no-new-privileges:true + read_only: true ports: - 6379:6379 - jaeger: - image: jaegertracing/all-in-one:latest + postgres: + image: cimg/postgres:17.5 + security_opt: + - no-new-privileges:true + environment: + POSTGRES_USER: ${USER} + POSTGRES_DB: ${USER} ports: - - 6831:6831/udp - - 6832:6832/udp - - 16686:16686 - - 14268:14268 + - 5432:5432 zipkin: image: openzipkin/zipkin:latest + security_opt: + - no-new-privileges:true + read_only: true ports: - 9411:9411 datadog: image: ghcr.io/datadog/dd-apm-test-agent/ddapm-test-agent:latest + security_opt: + - no-new-privileges:true + read_only: true ports: - - 8126:8126 \ No newline at end of file + - 8126:8126 diff --git a/dockerfiles/Dockerfile.router b/dockerfiles/Dockerfile.router index 2a2751fede..55b5f92f66 100644 --- a/dockerfiles/Dockerfile.router +++ b/dockerfiles/Dockerfile.router @@ -2,27 +2,24 @@ FROM debian:bookworm-slim AS downloader ARG ROUTER_RELEASE=latest ARG ARTIFACT_URL= ARG CIRCLE_TOKEN= +ARG ARTIFACT_URL_SHA256SUM= -# Install curl RUN \ apt-get update -y \ && apt-get install -y \ curl -WORKDIR /dist +# We work in /, and the tarball produces /dist/router after extraction. +WORKDIR / -# Run the Router downloader which puts Router into current working directory -RUN if [ -z "${ARTIFACT_URL}"]; then \ - curl -sSL "https://router.apollo.dev/download/nix/${ROUTER_RELEASE}"/ | sh; \ - else \ - cd /; \ - curl -sSL -H "Circle-Token: ${CIRCLE_TOKEN}" -o - "${ARTIFACT_URL}" | tar -xzf -; \ - cd -; \ - fi +# Copy and run the router download script +COPY download_and_validate_router.sh /tmp/download_and_validate_router.sh +RUN chmod +x /tmp/download_and_validate_router.sh && /tmp/download_and_validate_router.sh FROM debian:bookworm-slim AS distro ARG DEBUG_IMAGE=false ARG REPO_URL=https://github.com/apollographql/router +ARG BASE_VERSION # Add a user to run the router as RUN useradd -m router @@ -52,16 +49,17 @@ RUN rm -rf /var/lib/apt/lists/* RUN mkdir config schema # Copy configuration for docker image -COPY dockerfiles/router.yaml config +COPY router.yaml config LABEL org.opencontainers.image.authors="Apollo Graph, Inc. ${REPO_URL}" LABEL org.opencontainers.image.source="${REPO_URL}" +LABEL org.opencontainers.image.version="${BASE_VERSION}" ENV APOLLO_ROUTER_CONFIG_PATH="/dist/config/router.yaml" # Create a wrapper script to run the router, use exec to ensure signals are handled correctly RUN \ - echo '#!/usr/bin/env bash \ + echo '#!/bin/bash \ \nset -e \ \n \ \nif [ -f "/usr/bin/heaptrack" ]; then \ diff --git a/dockerfiles/diy/build_docker_image.sh b/dockerfiles/diy/build_docker_image.sh index 63d9f5df1c..ddaf5a41c6 100755 --- a/dockerfiles/diy/build_docker_image.sh +++ b/dockerfiles/diy/build_docker_image.sh @@ -173,6 +173,7 @@ cd "$(dirname "${0}")" || terminate "Couldn't cd to source location"; mkdir "${BUILD_DIR}/dockerfiles" cp dockerfiles/Dockerfile.repo "${BUILD_DIR}" || terminate "Couldn't copy dockerfiles to ${BUILD_DIR}" cp ../Dockerfile.router "${BUILD_DIR}" || terminate "Couldn't copy dockerfiles to ${BUILD_DIR}" +cp ../download_and_validate_router.sh "${BUILD_DIR}" || terminate "Couldn't copy random sidecar download and validate script to ${BUILD_DIR}" cp ../router.yaml "${BUILD_DIR}/dockerfiles" || terminate "Couldn't copy ../router.yaml to ${BUILD_DIR}" # Change to our build directory diff --git a/dockerfiles/diy/dockerfiles/Dockerfile.repo b/dockerfiles/diy/dockerfiles/Dockerfile.repo index 210d165e07..29808cf9d5 100644 --- a/dockerfiles/diy/dockerfiles/Dockerfile.repo +++ b/dockerfiles/diy/dockerfiles/Dockerfile.repo @@ -1,6 +1,6 @@ # Use the rust build image from docker as our base # renovate-automation: rustc version -FROM rust:1.76.0 as build +FROM rust:1.89.0 as build # Set our working directory for the build WORKDIR /usr/src/router @@ -8,8 +8,7 @@ WORKDIR /usr/src/router # Update our build image and install required packages RUN apt-get update RUN apt-get -y install \ - protobuf-compiler \ - cmake + protobuf-compiler # Add rustfmt since build requires it RUN rustup component add rustfmt diff --git a/dockerfiles/docker-compose-redis.yml b/dockerfiles/docker-compose-redis.yml index c23731a5a7..52fe2745d0 100644 --- a/dockerfiles/docker-compose-redis.yml +++ b/dockerfiles/docker-compose-redis.yml @@ -1,7 +1,9 @@ version: '2' services: redis-node-0: - image: docker.io/bitnami/redis-cluster:7.2 + image: docker.io/bitnami/redis-cluster:8.2 + security_opt: + - no-new-privileges:true volumes: - redis-cluster_data-0:/bitnami/redis/data environment: @@ -11,7 +13,9 @@ services: - 6379:6379 redis-node-1: - image: docker.io/bitnami/redis-cluster:7.2 + image: docker.io/bitnami/redis-cluster:8.2 + security_opt: + - no-new-privileges:true volumes: - redis-cluster_data-1:/bitnami/redis/data environment: @@ -21,7 +25,9 @@ services: - 6380:6379 redis-node-2: - image: docker.io/bitnami/redis-cluster:7.2 + image: docker.io/bitnami/redis-cluster:8.2 + security_opt: + - no-new-privileges:true volumes: - redis-cluster_data-2:/bitnami/redis/data environment: @@ -31,7 +37,9 @@ services: - 6381:6379 redis-node-3: - image: docker.io/bitnami/redis-cluster:7.2 + image: docker.io/bitnami/redis-cluster:8.2 + security_opt: + - no-new-privileges:true volumes: - redis-cluster_data-3:/bitnami/redis/data environment: @@ -41,7 +49,9 @@ services: - 6382:6379 redis-node-4: - image: docker.io/bitnami/redis-cluster:7.2 + image: docker.io/bitnami/redis-cluster:8.2 + security_opt: + - no-new-privileges:true volumes: - redis-cluster_data-4:/bitnami/redis/data environment: @@ -51,7 +61,9 @@ services: - 6383:6379 redis-node-5: - image: docker.io/bitnami/redis-cluster:7.2 + image: docker.io/bitnami/redis-cluster:8.2 + security_opt: + - no-new-privileges:true volumes: - redis-cluster_data-5:/bitnami/redis/data depends_on: diff --git a/dockerfiles/download_and_validate_router.sh b/dockerfiles/download_and_validate_router.sh new file mode 100644 index 0000000000..25c5b15620 --- /dev/null +++ b/dockerfiles/download_and_validate_router.sh @@ -0,0 +1,116 @@ +#!/bin/bash +set -e + +# Download and validate Router binary This script handles both release downloads +# and artifact downloads with checksum validation NOTE: This script is only +# intended to be executed from inside Dockerfile.router which lives besides +# this. + +# Validate required environment variables early +if [ -z "${ARTIFACT_URL}" ]; then + # Release build path - requires ROUTER_RELEASE + if [ -z "${ROUTER_RELEASE}" ]; then + echo "Error: ROUTER_RELEASE environment variable is required for release builds" + exit 1 + fi +else + # Artifact build path - requires CIRCLE_TOKEN. + if [ -z "${CIRCLE_TOKEN}" ]; then + echo "Error: CIRCLE_TOKEN environment variable is required when ARTIFACT_URL is set" + exit 1 + fi + # ARTIFACT_URL_SHA256SUM is optional but recommended +fi + +# MOTIVATION +# +# Once upon a time, we encountered a case where the Router binary which was +# inside the built Docker container was corrupted. This script supports +# downloading the Router binary and validating it against a provided checksum +# which is calculated out of band (in CircleCI, typically) and passed into the +# Docker build as an optional environment variables. + +# ARTIFACT_URL is used for nightly builds, which are built in CircleCI and +# downloaded from the CircleCI API. If ARTIFACT_URL is not set, we assume we +# are building a release and will download the Router binary from the official +# release URL. + +if [ -z "${ARTIFACT_URL}" ]; then + echo "Downloading Router release: ${ROUTER_RELEASE}" + # Download router tarball directly instead of using installer + TARBALL_NAME="router-${ROUTER_RELEASE}-x86_64-unknown-linux-gnu.tar.gz" + + # We use the rover-plugin service to download the Router tarball, rather + # than the actual executable which is what our usual curl installer does. + # + # Expanding on that with a couple notes: + # + # - There is, as of the time of this writing, NO fixed Apollo-controlled URL + # that lets you download a specific Router release tarball. + # - We currently only have the curl installer which downloads and extracts + # the tarballs. + # - This approach is acceptable and defensive since rover is guaranteed to + # have this in order for it to download the router, and that's not going + # away. They also go through the same Orbiter endpoint/code anyhow. + # - It IS possible to fix orbiter to also serve on the router domain and w + # could do that, but this seemed more than acceptable, and is a well-tested + # and monitored endpoint. + # - The hard-coding of the x86_64 bit (rather than letting the install script + # decide) also seems acceptable because this Docker container is built + # with --platform linux/amd64 in the CircleCI config where this is called. + + # Download the router tarball from the rover service + curl -sSL \ + "https://rover.apollo.dev/tar/router/x86_64-unknown-linux-gnu/${ROUTER_RELEASE}" \ + -o "${TARBALL_NAME}" + + # Download and validate checksum + curl -sSL \ + "https://github.com/apollographql/router/releases/download/${ROUTER_RELEASE}/sha256sums.txt" \ + -o sha256sums.txt + + # Extract the expected checksum for the tarball + EXPECTED_SHA256SUM=$(grep "${TARBALL_NAME}" sha256sums.txt | cut -d' ' -f1) + if [ -z "${EXPECTED_SHA256SUM}" ]; then + echo "ERROR: Could not find checksum for ${TARBALL_NAME} in sha256sums.txt" + exit 1 + fi + + # Calculate actual checksum of downloaded tarball + ACTUAL_SHA256SUM=$(sha256sum "${TARBALL_NAME}" | cut -d' ' -f1) + + if [ "${EXPECTED_SHA256SUM}" != "${ACTUAL_SHA256SUM}" ]; then + echo "Error: Tarball checksum validation failed!" + echo "Expected: ${EXPECTED_SHA256SUM}" + echo "Actual: ${ACTUAL_SHA256SUM}" + exit 1 + fi + + echo "Tarball checksum validation passed: ${ACTUAL_SHA256SUM}" + + # Extract the tarball, which literally drops a dist/ directory. + tar -xzf "${TARBALL_NAME}" +else + echo "Downloading Router artifact: ${ARTIFACT_URL}" + + curl -sSL -H "Circle-Token: ${CIRCLE_TOKEN}" -o "artifact.tar.gz" "${ARTIFACT_URL}" + + # Validate checksum if ARTIFACT_URL_SHA256SUM is provided + if [ -n "${ARTIFACT_URL_SHA256SUM}" ]; then + ACTUAL_SHA256SUM=$(sha256sum "artifact.tar.gz" | cut -d' ' -f1) + if [ "${ARTIFACT_URL_SHA256SUM}" != "${ACTUAL_SHA256SUM}" ]; then + echo "Error: Artifact tarball checksum validation failed!" + echo "Expected: ${ARTIFACT_URL_SHA256SUM}" + echo "Actual: ${ACTUAL_SHA256SUM}" + exit 1 + fi + echo "Artifact tarball checksum validation passed: ${ACTUAL_SHA256SUM}" + else + echo "WARN: No checksum provided for artifact validation" + fi + + # Extract the tarball, which literally drops a dist/ directory. + tar -xzf "artifact.tar.gz" +fi + +echo "Router download and validation completed successfully" diff --git a/dockerfiles/tracing/datadog-subgraph/Dockerfile b/dockerfiles/tracing/datadog-subgraph/Dockerfile deleted file mode 100644 index aa70806ccd..0000000000 --- a/dockerfiles/tracing/datadog-subgraph/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM node:20-bullseye - -WORKDIR /src - -ADD ./*.ts ./*.json /src/ - -RUN npm ci -RUN npx tsc - -CMD [ "node", "dist/index.js" ] diff --git a/dockerfiles/tracing/datadog-subgraph/index.ts b/dockerfiles/tracing/datadog-subgraph/index.ts deleted file mode 100644 index edc75f03e9..0000000000 --- a/dockerfiles/tracing/datadog-subgraph/index.ts +++ /dev/null @@ -1,85 +0,0 @@ -import './tracer'; -import { ApolloServer, gql } from 'apollo-server-express'; -import { ApolloServerPluginDrainHttpServer } from 'apollo-server-core'; -import { buildFederatedSchema } from '@apollo/federation'; - -import express from 'express'; -import http from 'http'; - -const typeDefs = gql` - extend type Query { - me: User - } - - type User @key(fields: "id") { - id: ID! - name: String - username: String - } -`; - -const resolvers = { - Query: { - me() { - return users[0]; - } - }, - User: { - __resolveReference(object) { - return users.find(user => user.id === object.id); - } - } -}; - - -const users = [ - { - id: "1", - name: "Ada Lovelace", - birthDate: "1815-12-10", - username: "@ada" - }, - { - id: "2", - name: "Alan Turing", - birthDate: "1912-06-23", - username: "@complete" - } -]; - -async function startApolloServer(typeDefs, resolvers) { - // Required logic for integrating with Express - const app = express(); - - const httpServer = http.createServer(app); - - // Same ApolloServer initialization as before, plus the drain plugin. - const server = new ApolloServer({ - schema: buildFederatedSchema([ - { - typeDefs, - resolvers - } - ]), - csrfPrevention: true, - plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], - }); - - // More required logic for integrating with Express - await server.start(); - server.applyMiddleware({ - app, - - // By default, apollo-server hosts its GraphQL endpoint at the - // server root. However, *other* Apollo Server packages host it at - // /graphql. Optionally provide this to match apollo-server. - path: '/' - }); - - // Modified server startup - await new Promise(resolve => httpServer.listen({ port: 4001 }, resolve)); - console.log(`🚀 Server ready at http://localhost:4001${server.graphqlPath}`); -} - -console.log("starting") -startApolloServer(typeDefs, resolvers) \ No newline at end of file diff --git a/dockerfiles/tracing/datadog-subgraph/package-lock.json b/dockerfiles/tracing/datadog-subgraph/package-lock.json deleted file mode 100644 index 849552a1fd..0000000000 --- a/dockerfiles/tracing/datadog-subgraph/package-lock.json +++ /dev/null @@ -1,1863 +0,0 @@ -{ - "name": "datadog-subgraph", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "datadog-subgraph", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@apollo/federation": "^0.38.0", - "@apollo/server": "^4.0.0", - "dd-trace": "^5.0.0", - "express": "^4.18.1", - "graphql": "^16.5.0" - }, - "devDependencies": { - "typescript": "5.5.3" - } - }, - "node_modules/@apollo/cache-control-types": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", - "integrity": "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==", - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/federation": { - "version": "0.38.1", - "resolved": "https://registry.npmjs.org/@apollo/federation/-/federation-0.38.1.tgz", - "integrity": "sha512-miifyAEsFgiYKeM3lUHFH6+vKa2vm9dXKSyWVpX6oeJiPblFLe2/iByN3psZQO2sRdVqO1OKYrGXdgKc74XDKw==", - "deprecated": "The @apollo/federation package is deprecated and will reach end-of-life September 22, 2023. It contains outdated utilities for both running subgraphs and composing supergraph schemas. Please migrate to the appropriate package for your use case (@apollo/subgraph or @apollo/composition). For more details, see our announcement blog post (https://www.apollographql.com/blog/announcement/backend/announcing-the-end-of-life-schedule-for-apollo-gateway-v0-x/) and documentation (https://www.apollographql.com/docs/federation/federation-2/backward-compatibility/#is-official-support-ending-for-apollogateway-v0x).", - "dependencies": { - "@apollo/subgraph": "^0.6.1", - "apollo-server-types": "^3.0.2", - "lodash.xorby": "^4.7.0" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "graphql": "^15.8.0 || ^16.0.0" - } - }, - "node_modules/@apollo/protobufjs": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.7.tgz", - "integrity": "sha512-Lahx5zntHPZia35myYDBRuF58tlwPskwHc5CWBZC/4bMKB6siTBWwtMrkqXcsNwQiFSzSx5hKdRPUmemrEp3Gg==", - "hasInstallScript": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "long": "^4.0.0" - }, - "bin": { - "apollo-pbjs": "bin/pbjs", - "apollo-pbts": "bin/pbts" - } - }, - "node_modules/@apollo/server": { - "version": "4.10.4", - "resolved": "https://registry.npmjs.org/@apollo/server/-/server-4.10.4.tgz", - "integrity": "sha512-HS12CUa1wq8f5zKXOKJRwRdESFp4por9AINecpcsEUV9jsCP/NqPILgx0hCOOFJuKxmnaL7070xO6l5xmOq4Fw==", - "license": "MIT", - "dependencies": { - "@apollo/cache-control-types": "^1.0.3", - "@apollo/server-gateway-interface": "^1.1.1", - "@apollo/usage-reporting-protobuf": "^4.1.1", - "@apollo/utils.createhash": "^2.0.0", - "@apollo/utils.fetcher": "^2.0.0", - "@apollo/utils.isnodelike": "^2.0.0", - "@apollo/utils.keyvaluecache": "^2.1.0", - "@apollo/utils.logger": "^2.0.0", - "@apollo/utils.usagereporting": "^2.1.0", - "@apollo/utils.withrequired": "^2.0.0", - "@graphql-tools/schema": "^9.0.0", - "@josephg/resolvable": "^1.0.0", - "@types/express": "^4.17.13", - "@types/express-serve-static-core": "^4.17.30", - "@types/node-fetch": "^2.6.1", - "async-retry": "^1.2.1", - "cors": "^2.8.5", - "express": "^4.17.1", - "loglevel": "^1.6.8", - "lru-cache": "^7.10.1", - "negotiator": "^0.6.3", - "node-abort-controller": "^3.1.1", - "node-fetch": "^2.6.7", - "uuid": "^9.0.0", - "whatwg-mimetype": "^3.0.0" - }, - "engines": { - "node": ">=14.16.0" - }, - "peerDependencies": { - "graphql": "^16.6.0" - } - }, - "node_modules/@apollo/server-gateway-interface": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@apollo/server-gateway-interface/-/server-gateway-interface-1.1.1.tgz", - "integrity": "sha512-pGwCl/po6+rxRmDMFgozKQo2pbsSwE91TpsDBAOgf74CRDPXHHtM88wbwjab0wMMZh95QfR45GGyDIdhY24bkQ==", - "dependencies": { - "@apollo/usage-reporting-protobuf": "^4.1.1", - "@apollo/utils.fetcher": "^2.0.0", - "@apollo/utils.keyvaluecache": "^2.1.0", - "@apollo/utils.logger": "^2.0.0" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/server-gateway-interface/node_modules/@apollo/utils.keyvaluecache": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-2.1.1.tgz", - "integrity": "sha512-qVo5PvUUMD8oB9oYvq4ViCjYAMWnZ5zZwEjNF37L2m1u528x5mueMlU+Cr1UinupCgdB78g+egA1G98rbJ03Vw==", - "dependencies": { - "@apollo/utils.logger": "^2.0.1", - "lru-cache": "^7.14.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/server-gateway-interface/node_modules/@apollo/utils.logger": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-2.0.1.tgz", - "integrity": "sha512-YuplwLHaHf1oviidB7MxnCXAdHp3IqYV8n0momZ3JfLniae92eYqMIx+j5qJFX6WKJPs6q7bczmV4lXIsTu5Pg==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/server-gateway-interface/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/@apollo/server/node_modules/@apollo/utils.keyvaluecache": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-2.1.1.tgz", - "integrity": "sha512-qVo5PvUUMD8oB9oYvq4ViCjYAMWnZ5zZwEjNF37L2m1u528x5mueMlU+Cr1UinupCgdB78g+egA1G98rbJ03Vw==", - "license": "MIT", - "dependencies": { - "@apollo/utils.logger": "^2.0.1", - "lru-cache": "^7.14.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/server/node_modules/@apollo/utils.logger": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-2.0.1.tgz", - "integrity": "sha512-YuplwLHaHf1oviidB7MxnCXAdHp3IqYV8n0momZ3JfLniae92eYqMIx+j5qJFX6WKJPs6q7bczmV4lXIsTu5Pg==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/server/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/@apollo/subgraph": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@apollo/subgraph/-/subgraph-0.6.1.tgz", - "integrity": "sha512-w/6FoubSxuzXSx8uvLE1wEuHZVHRXFyfHPKdM76wX5U/xw82zlUKseVO7wTuVODTcnUzEA30udYeCApUoC3/Xw==", - "deprecated": "All v0.x versions of @apollo/subgraph are now deprecated with an end-of-life date of September 22, 2023. Apollo recommends upgrading to v2.x as soon as possible. For more details, see our announcement blog post (https://www.apollographql.com/blog/announcement/backend/announcing-the-end-of-life-schedule-for-apollo-gateway-v0-x/) and documentation (https://www.apollographql.com/docs/federation/federation-2/backward-compatibility/#is-official-support-ending-for-apollogateway-v0x).", - "dependencies": { - "@apollo/cache-control-types": "^1.0.2" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "graphql": "^15.8.0 || ^16.0.0" - } - }, - "node_modules/@apollo/usage-reporting-protobuf": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@apollo/usage-reporting-protobuf/-/usage-reporting-protobuf-4.1.1.tgz", - "integrity": "sha512-u40dIUePHaSKVshcedO7Wp+mPiZsaU6xjv9J+VyxpoU/zL6Jle+9zWeG98tr/+SZ0nZ4OXhrbb8SNr0rAPpIDA==", - "dependencies": { - "@apollo/protobufjs": "1.2.7" - } - }, - "node_modules/@apollo/utils.createhash": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.createhash/-/utils.createhash-2.0.1.tgz", - "integrity": "sha512-fQO4/ZOP8LcXWvMNhKiee+2KuKyqIcfHrICA+M4lj/h/Lh1H10ICcUtk6N/chnEo5HXu0yejg64wshdaiFitJg==", - "dependencies": { - "@apollo/utils.isnodelike": "^2.0.1", - "sha.js": "^2.4.11" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/utils.dropunuseddefinitions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.dropunuseddefinitions/-/utils.dropunuseddefinitions-2.0.1.tgz", - "integrity": "sha512-EsPIBqsSt2BwDsv8Wu76LK5R1KtsVkNoO4b0M5aK0hx+dGg9xJXuqlr7Fo34Dl+y83jmzn+UvEW+t1/GP2melA==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.fetcher": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.fetcher/-/utils.fetcher-2.0.1.tgz", - "integrity": "sha512-jvvon885hEyWXd4H6zpWeN3tl88QcWnHp5gWF5OPF34uhvoR+DFqcNxs9vrRaBBSY3qda3Qe0bdud7tz2zGx1A==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/utils.isnodelike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.isnodelike/-/utils.isnodelike-2.0.1.tgz", - "integrity": "sha512-w41XyepR+jBEuVpoRM715N2ZD0xMD413UiJx8w5xnAZD2ZkSJnMJBoIzauK83kJpSgNuR6ywbV29jG9NmxjK0Q==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/utils.keyvaluecache": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-1.0.2.tgz", - "integrity": "sha512-p7PVdLPMnPzmXSQVEsy27cYEjVON+SH/Wb7COyW3rQN8+wJgT1nv9jZouYtztWW8ZgTkii5T6tC9qfoDREd4mg==", - "dependencies": { - "@apollo/utils.logger": "^1.0.0", - "lru-cache": "7.10.1 - 7.13.1" - } - }, - "node_modules/@apollo/utils.keyvaluecache/node_modules/lru-cache": { - "version": "7.13.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.13.1.tgz", - "integrity": "sha512-CHqbAq7NFlW3RSnoWXLJBxCWaZVBrfa9UEHId2M3AW8iEBurbqduNexEUCGc3SHc6iCYXNJCDi903LajSVAEPQ==", - "engines": { - "node": ">=12" - } - }, - "node_modules/@apollo/utils.logger": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-1.0.1.tgz", - "integrity": "sha512-XdlzoY7fYNK4OIcvMD2G94RoFZbzTQaNP0jozmqqMudmaGo2I/2Jx71xlDJ801mWA/mbYRihyaw6KJii7k5RVA==" - }, - "node_modules/@apollo/utils.printwithreducedwhitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.printwithreducedwhitespace/-/utils.printwithreducedwhitespace-2.0.1.tgz", - "integrity": "sha512-9M4LUXV/fQBh8vZWlLvb/HyyhjJ77/I5ZKu+NBWV/BmYGyRmoEP9EVAy7LCVoY3t8BDcyCAGfxJaLFCSuQkPUg==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.removealiases": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.removealiases/-/utils.removealiases-2.0.1.tgz", - "integrity": "sha512-0joRc2HBO4u594Op1nev+mUF6yRnxoUH64xw8x3bX7n8QBDYdeYgY4tF0vJReTy+zdn2xv6fMsquATSgC722FA==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.sortast": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.sortast/-/utils.sortast-2.0.1.tgz", - "integrity": "sha512-eciIavsWpJ09za1pn37wpsCGrQNXUhM0TktnZmHwO+Zy9O4fu/WdB4+5BvVhFiZYOXvfjzJUcc+hsIV8RUOtMw==", - "dependencies": { - "lodash.sortby": "^4.7.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.stripsensitiveliterals": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.stripsensitiveliterals/-/utils.stripsensitiveliterals-2.0.1.tgz", - "integrity": "sha512-QJs7HtzXS/JIPMKWimFnUMK7VjkGQTzqD9bKD1h3iuPAqLsxd0mUNVbkYOPTsDhUKgcvUOfOqOJWYohAKMvcSA==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.usagereporting": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@apollo/utils.usagereporting/-/utils.usagereporting-2.1.0.tgz", - "integrity": "sha512-LPSlBrn+S17oBy5eWkrRSGb98sWmnEzo3DPTZgp8IQc8sJe0prDgDuppGq4NeQlpoqEHz0hQeYHAOA0Z3aQsxQ==", - "dependencies": { - "@apollo/usage-reporting-protobuf": "^4.1.0", - "@apollo/utils.dropunuseddefinitions": "^2.0.1", - "@apollo/utils.printwithreducedwhitespace": "^2.0.1", - "@apollo/utils.removealiases": "2.0.1", - "@apollo/utils.sortast": "^2.0.1", - "@apollo/utils.stripsensitiveliterals": "^2.0.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.withrequired": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.withrequired/-/utils.withrequired-2.0.1.tgz", - "integrity": "sha512-YBDiuAX9i1lLc6GeTy1m7DGLFn/gMnvXqlalOIMjM7DeOgIacEjjfwPqb0M1CQ2v11HhR15d1NmxJoRCfrNqcA==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@datadog/native-appsec": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@datadog/native-appsec/-/native-appsec-8.0.1.tgz", - "integrity": "sha512-SpWkoo7K4+pwxFze1ogRF1qBaKm8sZjWfZKnQ8Ex67f6L5odLjWOoiiIAs5rp01sLKGXjxU8IJf+X9j4PvI2zQ==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "node-gyp-build": "^3.9.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@datadog/native-iast-rewriter": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@datadog/native-iast-rewriter/-/native-iast-rewriter-2.3.1.tgz", - "integrity": "sha512-3pmt5G1Ai/+MPyxP7wBerIu/zH/BnAHxEu/EAMr+77IMpK5m7THPDUoWrPRCWcgFBfn0pK5DR7gRItG0wX3e0g==", - "license": "Apache-2.0", - "dependencies": { - "lru-cache": "^7.14.0", - "node-gyp-build": "^4.5.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@datadog/native-iast-rewriter/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/@datadog/native-iast-rewriter/node_modules/node-gyp-build": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz", - "integrity": "sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/@datadog/native-iast-taint-tracking": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@datadog/native-iast-taint-tracking/-/native-iast-taint-tracking-3.0.0.tgz", - "integrity": "sha512-V+25+edlNCQSNRUvL45IajN+CFEjii9NbjfSMG6HRHbH/zeLL9FCNE+GU88dwB1bqXKNpBdrIxsfgTN65Yq9tA==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "node-gyp-build": "^3.9.0" - } - }, - "node_modules/@datadog/native-metrics": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@datadog/native-metrics/-/native-metrics-2.0.0.tgz", - "integrity": "sha512-YklGVwUtmKGYqFf1MNZuOHvTYdKuR4+Af1XkWcMD8BwOAjxmd9Z+97328rCOY8TFUJzlGUPaXzB8j2qgG/BMwA==", - "hasInstallScript": true, - "dependencies": { - "node-addon-api": "^6.1.0", - "node-gyp-build": "^3.9.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@datadog/pprof": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@datadog/pprof/-/pprof-5.3.0.tgz", - "integrity": "sha512-53z2Q3K92T6Pf4vz4Ezh8kfkVEvLzbnVqacZGgcbkP//q0joFzO8q00Etw1S6NdnCX0XmX08ULaF4rUI5r14mw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "delay": "^5.0.0", - "node-gyp-build": "<4.0", - "p-limit": "^3.1.0", - "pprof-format": "^2.1.0", - "source-map": "^0.7.4" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@datadog/sketches-js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@datadog/sketches-js/-/sketches-js-2.1.0.tgz", - "integrity": "sha512-smLocSfrt3s53H/XSVP3/1kP42oqvrkjUPtyaFd1F79ux24oE31BKt+q0c6lsa6hOYrFzsIwyc5GXAI5JmfOew==" - }, - "node_modules/@graphql-tools/merge": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.4.2.tgz", - "integrity": "sha512-XbrHAaj8yDuINph+sAfuq3QCZ/tKblrTLOpirK0+CAgNlZUCHs0Fa+xtMUURgwCVThLle1AF7svJCxFizygLsw==", - "dependencies": { - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/schema": { - "version": "9.0.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-9.0.19.tgz", - "integrity": "sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w==", - "dependencies": { - "@graphql-tools/merge": "^8.4.1", - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0", - "value-or-promise": "^1.0.12" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/utils": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", - "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", - "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@josephg/resolvable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@josephg/resolvable/-/resolvable-1.0.1.tgz", - "integrity": "sha512-CtzORUwWTTOTqfVtHaKRJ0I1kNQd1bpn3sUh8I3nJDVY+5/M/Oe1DnEWzPQvqq/xPIIkzzzIP7mfCoAjFRvDhg==" - }, - "node_modules/@opentelemetry/api": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", - "integrity": "sha512-O2yRJce1GOc6PAy3QxFM4NzFiWzvScDC1/5ihYBL6BUEVdq0XMWN01sppE+H6bBXbaFYipjwFLEWLg5PaSOThA==", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.15.2.tgz", - "integrity": "sha512-+gBv15ta96WqkHZaPpcDHiaz0utiiHZVfm2YOYSqFGrUaJpPkMoSuLBB58YFQGi6Rsb9EHos84X6X5+9JspmLw==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.15.2" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.5.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.15.2.tgz", - "integrity": "sha512-CjbOKwk2s+3xPIMcd5UNYQzsf+v94RczbdNix9/kQh38WiQkM90sUOi3if8eyHFgiBjBjhwXrA7W3ydiSQP9mw==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" - }, - "node_modules/@types/body-parser": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", - "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.35", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", - "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.14.tgz", - "integrity": "sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.18", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.17.31", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz", - "integrity": "sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==" - }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" - }, - "node_modules/@types/mime": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", - "integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==" - }, - "node_modules/@types/node": { - "version": "20.4.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.9.tgz", - "integrity": "sha512-8e2HYcg7ohnTUbHk8focoklEQYvemQmu9M/f43DZVx43kHn0tE3BY/6gSDxS7k0SprtS0NHvj+L80cGLnoOUcQ==" - }, - "node_modules/@types/node-fetch": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.4.tgz", - "integrity": "sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==", - "dependencies": { - "@types/node": "*", - "form-data": "^3.0.0" - } - }, - "node_modules/@types/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" - }, - "node_modules/@types/range-parser": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", - "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" - }, - "node_modules/@types/serve-static": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.2.tgz", - "integrity": "sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==", - "dependencies": { - "@types/http-errors": "*", - "@types/mime": "*", - "@types/node": "*" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, - "node_modules/apollo-reporting-protobuf": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/apollo-reporting-protobuf/-/apollo-reporting-protobuf-3.4.0.tgz", - "integrity": "sha512-h0u3EbC/9RpihWOmcSsvTW2O6RXVaD/mPEjfrPkxRPTEPWqncsgOoRJw+wih4OqfH3PvTJvoEIf4LwKrUaqWog==", - "deprecated": "The `apollo-reporting-protobuf` package is part of Apollo Server v2 and v3, which are now deprecated (end-of-life October 22nd 2023). This package's functionality is now found in the `@apollo/usage-reporting-protobuf` package. See https://www.apollographql.com/docs/apollo-server/previous-versions/ for more details.", - "dependencies": { - "@apollo/protobufjs": "1.2.6" - } - }, - "node_modules/apollo-reporting-protobuf/node_modules/@apollo/protobufjs": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.6.tgz", - "integrity": "sha512-Wqo1oSHNUj/jxmsVp4iR3I480p6qdqHikn38lKrFhfzcDJ7lwd7Ck7cHRl4JE81tWNArl77xhnG/OkZhxKBYOw==", - "hasInstallScript": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "@types/node": "^10.1.0", - "long": "^4.0.0" - }, - "bin": { - "apollo-pbjs": "bin/pbjs", - "apollo-pbts": "bin/pbts" - } - }, - "node_modules/apollo-reporting-protobuf/node_modules/@types/node": { - "version": "10.17.60", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", - "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" - }, - "node_modules/apollo-server-env": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-4.2.1.tgz", - "integrity": "sha512-vm/7c7ld+zFMxibzqZ7SSa5tBENc4B0uye9LTfjJwGoQFY5xsUPH5FpO5j0bMUDZ8YYNbrF9SNtzc5Cngcr90g==", - "deprecated": "The `apollo-server-env` package is part of Apollo Server v2 and v3, which are now deprecated (end-of-life October 22nd 2023). This package's functionality is now found in the `@apollo/utils.fetcher` package. See https://www.apollographql.com/docs/apollo-server/previous-versions/ for more details.", - "dependencies": { - "node-fetch": "^2.6.7" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/apollo-server-types": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-3.8.0.tgz", - "integrity": "sha512-ZI/8rTE4ww8BHktsVpb91Sdq7Cb71rdSkXELSwdSR0eXu600/sY+1UXhTWdiJvk+Eq5ljqoHLwLbY2+Clq2b9A==", - "deprecated": "The `apollo-server-types` package is part of Apollo Server v2 and v3, which are now deprecated (end-of-life October 22nd 2023). This package's functionality is now found in the `@apollo/server` package. See https://www.apollographql.com/docs/apollo-server/previous-versions/ for more details.", - "dependencies": { - "@apollo/utils.keyvaluecache": "^1.0.1", - "@apollo/utils.logger": "^1.0.0", - "apollo-reporting-protobuf": "^3.4.0", - "apollo-server-env": "^4.2.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "graphql": "^15.3.0 || ^16.0.0" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "node_modules/async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", - "dependencies": { - "retry": "0.13.1" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/crypto-randomuuid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/crypto-randomuuid/-/crypto-randomuuid-1.0.0.tgz", - "integrity": "sha512-/RC5F4l1SCqD/jazwUF6+t34Cd8zTSAGZ7rvvZu1whZUhD2a5MOGKjSGowoGcpj/cbVZk1ZODIooJEQQq3nNAA==" - }, - "node_modules/dc-polyfill": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/dc-polyfill/-/dc-polyfill-0.1.4.tgz", - "integrity": "sha512-8iwEduR2jR9wWYggeaYtYZWRiUe3XZPyAQtMTL1otv8X3kfR8xUIVb4l5awHEeyDrH6Je7N324lKzMKlMMN6Yw==", - "engines": { - "node": ">=12.17" - } - }, - "node_modules/dd-trace": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/dd-trace/-/dd-trace-5.18.0.tgz", - "integrity": "sha512-2akfBl6cA2ROWDog3Xfykid/Ep3jf5dzF5YT8XLzyqRB/jmfsGp4pPWxJEVo/SMXYdj9/G307weCJ7X47Mg8sQ==", - "hasInstallScript": true, - "license": "(Apache-2.0 OR BSD-3-Clause)", - "dependencies": { - "@datadog/native-appsec": "8.0.1", - "@datadog/native-iast-rewriter": "2.3.1", - "@datadog/native-iast-taint-tracking": "3.0.0", - "@datadog/native-metrics": "^2.0.0", - "@datadog/pprof": "5.3.0", - "@datadog/sketches-js": "^2.1.0", - "@opentelemetry/api": ">=1.0.0 <1.9.0", - "@opentelemetry/core": "^1.14.0", - "crypto-randomuuid": "^1.0.0", - "dc-polyfill": "^0.1.4", - "ignore": "^5.2.4", - "import-in-the-middle": "^1.8.1", - "int64-buffer": "^0.1.9", - "istanbul-lib-coverage": "3.2.0", - "jest-docblock": "^29.7.0", - "koalas": "^1.0.2", - "limiter": "1.1.5", - "lodash.sortby": "^4.7.0", - "lru-cache": "^7.14.0", - "module-details-from-path": "^1.0.3", - "msgpack-lite": "^0.1.26", - "opentracing": ">=0.12.1", - "path-to-regexp": "^0.1.2", - "pprof-format": "^2.1.0", - "protobufjs": "^7.2.5", - "retry": "^0.13.1", - "semver": "^7.5.4", - "shell-quote": "^1.8.1", - "tlhunter-sorted-set": "^0.1.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/dd-trace/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/delay": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", - "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "engines": { - "node": ">=8" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/event-lite": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/event-lite/-/event-lite-0.1.3.tgz", - "integrity": "sha512-8qz9nOz5VeD2z96elrEKD2U433+L3DWdUdDkOINLGOJvx1GsMBbMn0aCeu28y8/e85A6mCigBiFlYMnTBEGlSw==" - }, - "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.2", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "node_modules/get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graphql": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", - "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-in-the-middle": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.8.1.tgz", - "integrity": "sha512-yhRwoHtiLGvmSozNOALgjRPFI6uYsds60EoMqqnXyyv+JOIW/BrrLejuTGBt+bq0T5tLzOHrN0T7xYTm4Qt/ng==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.8.2", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/int64-buffer": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-0.1.10.tgz", - "integrity": "sha512-v7cSY1J8ydZ0GyjUHqF+1bshJ6cnEVLo9EnjB8p+4HDRPZc9N5jjmvUV7NvEsqQOKyH0pmIBFWXVQbiS0+OBbA==" - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", - "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/koalas": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/koalas/-/koalas-1.0.2.tgz", - "integrity": "sha512-RYhBbYaTTTHId3l6fnMZc3eGQNW6FVCqMG6AMwA5I1Mafr6AflaXeoi6x3xQuATRotGYRLk6+1ELZH4dstFNOA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/limiter": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", - "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" - }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" - }, - "node_modules/lodash.xorby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.xorby/-/lodash.xorby-4.7.0.tgz", - "integrity": "sha512-gYiD6nvuQy0AEkMoUju+t4f4Rn18fjsLB/7x7YZFqtFT9kmegRLrj/uGEQVyVDy7otTmSrIMXNOk2wwuLcfHCQ==" - }, - "node_modules/loglevel": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz", - "integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, - "node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/module-details-from-path": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", - "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==" - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/msgpack-lite": { - "version": "0.1.26", - "resolved": "https://registry.npmjs.org/msgpack-lite/-/msgpack-lite-0.1.26.tgz", - "integrity": "sha512-SZ2IxeqZ1oRFGo0xFGbvBJWMp3yLIY9rlIJyxy8CGrwZn1f0ZK4r6jV/AM1r0FZMDUkWkglOk/eeKIL9g77Nxw==", - "dependencies": { - "event-lite": "^0.1.1", - "ieee754": "^1.1.8", - "int64-buffer": "^0.1.9", - "isarray": "^1.0.0" - }, - "bin": { - "msgpack": "bin/msgpack" - } - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" - }, - "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==" - }, - "node_modules/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-gyp-build": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-3.9.0.tgz", - "integrity": "sha512-zLcTg6P4AbcHPq465ZMFNXx7XpKKJh+7kkN699NiQWisR2uWYOWNWqRHAmbnmKiL4e9aLSlmy5U7rEMUXV59+A==", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/opentracing": { - "version": "0.14.7", - "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", - "integrity": "sha512-vz9iS7MJ5+Bp1URw8Khvdyw1H/hGvzHWlKQ7eRrQojSCDL1/SrWfrY9QebLw97n2deyRtzHRC3MkQfVNUCo91Q==", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" - }, - "node_modules/pprof-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pprof-format/-/pprof-format-2.1.0.tgz", - "integrity": "sha512-0+G5bHH0RNr8E5hoZo/zJYsL92MhkZjwrHp3O2IxmY8RJL9ooKeuZ8Tm0ZNBw5sGZ9TiM71sthTjWoR2Vf5/xw==", - "license": "MIT" - }, - "node_modules/protobufjs": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.5.tgz", - "integrity": "sha512-gGXRSXvxQ7UiPgfw8gevrfRWcTlSbOFg+p/N+JVJEK5VhueL2miT6qTymqAmjr1Q5WbOCyJbyrk6JfWKwlFn6A==", - "hasInstallScript": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/protobufjs/node_modules/long": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", - "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "engines": { - "node": ">= 4" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - }, - "bin": { - "sha.js": "bin.js" - } - }, - "node_modules/shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/tlhunter-sorted-set": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/tlhunter-sorted-set/-/tlhunter-sorted-set-0.1.0.tgz", - "integrity": "sha512-eGYW4bjf1DtrHzUYxYfAcSytpOkA44zsr7G2n3PV7yOUR23vmkGe3LL4R+1jL9OsXtbsFOwe8XtbCrabeaEFnw==" - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/tslib": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz", - "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/value-or-promise": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/value-or-promise/-/value-or-promise-1.0.12.tgz", - "integrity": "sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==", - "engines": { - "node": ">=12" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/dockerfiles/tracing/datadog-subgraph/package.json b/dockerfiles/tracing/datadog-subgraph/package.json deleted file mode 100644 index 0d38223e9d..0000000000 --- a/dockerfiles/tracing/datadog-subgraph/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "datadog-subgraph", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "start": "node dist/index.js" - }, - "author": "", - "license": "ISC", - "dependencies": { - "@apollo/federation": "^0.38.0", - "@apollo/server": "^4.0.0", - "@apollo/server": "^4.0.0", - "dd-trace": "^5.0.0", - "express": "^4.18.1", - "graphql": "^16.5.0" - }, - "devDependencies": { - "typescript": "5.5.3" - } -} diff --git a/dockerfiles/tracing/datadog-subgraph/tracer.ts b/dockerfiles/tracing/datadog-subgraph/tracer.ts deleted file mode 100644 index 46464a8652..0000000000 --- a/dockerfiles/tracing/datadog-subgraph/tracer.ts +++ /dev/null @@ -1,3 +0,0 @@ -import tracer from 'dd-trace'; -tracer.init(); // initialized in a different file to avoid hoisting. -export default tracer; \ No newline at end of file diff --git a/dockerfiles/tracing/datadog-subgraph/tsconfig.json b/dockerfiles/tracing/datadog-subgraph/tsconfig.json deleted file mode 100644 index ff87005192..0000000000 --- a/dockerfiles/tracing/datadog-subgraph/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "esModuleInterop": true, - "target": "es6", - "moduleResolution": "node", - "sourceMap": true, - "outDir": "dist" - }, - "lib": ["es2015"] -} diff --git a/dockerfiles/tracing/docker-compose.datadog.yml b/dockerfiles/tracing/docker-compose.datadog.yml deleted file mode 100644 index db46f878fb..0000000000 --- a/dockerfiles/tracing/docker-compose.datadog.yml +++ /dev/null @@ -1,40 +0,0 @@ -version: "3.9" -services: - - apollo-router: - container_name: apollo-router - image: ghcr.io/apollographql/router:v1.57.1 - volumes: - - ./supergraph.graphql:/etc/config/supergraph.graphql - - ./router/datadog.router.yaml:/etc/config/configuration.yaml - command: - [ - "-c", - "/etc/config/configuration.yaml", - "-s", - "/etc/config/supergraph.graphql", - "--log", - "info" - ] - ports: - - 4000:4000 - depends_on: - - datadog-agent - - subgraph: - build: datadog-subgraph - ports: - - 4001:4001 - environment: - - DD_SERVICE=accounts - - DD_AGENT_HOST=datadog-agent - - DD_TRACE_AGENT_PORT=8126 - depends_on: - - datadog-agent - - datadog-agent: - image: gcr.io/datadoghq/agent - ports: - - 4317:4317 - environment: - - DD_API_KEY diff --git a/dockerfiles/tracing/docker-compose.jaeger.yml b/dockerfiles/tracing/docker-compose.jaeger.yml deleted file mode 100644 index a38d04b6ba..0000000000 --- a/dockerfiles/tracing/docker-compose.jaeger.yml +++ /dev/null @@ -1,42 +0,0 @@ -version: "3.9" -services: - - apollo-router: - container_name: apollo-router - #build: ./router - image: ghcr.io/apollographql/router:v1.57.1 - volumes: - - ./supergraph.graphql:/etc/config/supergraph.graphql - - ./router/jaeger.router.yaml:/etc/config/configuration.yaml - command: - [ - "-c", - "/etc/config/configuration.yaml", - "-s", - "/etc/config/supergraph.graphql", - "--log", - "info" - ] - ports: - - 4000:4000 - depends_on: - - jaeger - - subgraph: - build: jaeger-subgraph - ports: - - 4001:4001 - environment: - - JAEGER_SERVICE_NAME=accounts - - JAEGER_AGENT_HOST=jaeger - - JAEGER_AGENT_PORT=6832 - depends_on: - - jaeger - - jaeger: - image: jaegertracing/all-in-one:latest - ports: - - 6831:6831/udp - - 6832:6832/udp - - 16686:16686 - - 14268:14268 diff --git a/dockerfiles/tracing/docker-compose.zipkin.yml b/dockerfiles/tracing/docker-compose.zipkin.yml deleted file mode 100644 index 366bff5506..0000000000 --- a/dockerfiles/tracing/docker-compose.zipkin.yml +++ /dev/null @@ -1,41 +0,0 @@ -version: "3.9" -services: - - apollo-router: - container_name: apollo-router - build: ./router - image: ghcr.io/apollographql/router:v1.57.1 - volumes: - - ./supergraph.graphql:/etc/config/supergraph.graphql - - ./router/zipkin.router.yaml:/etc/config/configuration.yaml - command: - [ - "-c", - "/etc/config/configuration.yaml", - "-s", - "/etc/config/supergraph.graphql", - "--log", - "info" - ] - ports: - - 4000:4000 - environment: - - RUST_BACKTRACE=1 - # environment: - # - JAEGER_ENDPOINT=http://jaeger:14268/api/traces - depends_on: - - zipkin - - subgraph: - build: zipkin-subgraph - ports: - - 4001:4001 - #environment: - depends_on: - - zipkin - - zipkin: - container_name: zipkin - image: openzipkin/zipkin:3.0.6 - ports: - - 9411:9411 diff --git a/dockerfiles/tracing/jaeger-subgraph/Dockerfile b/dockerfiles/tracing/jaeger-subgraph/Dockerfile deleted file mode 100644 index aa70806ccd..0000000000 --- a/dockerfiles/tracing/jaeger-subgraph/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM node:20-bullseye - -WORKDIR /src - -ADD ./*.ts ./*.json /src/ - -RUN npm ci -RUN npx tsc - -CMD [ "node", "dist/index.js" ] diff --git a/dockerfiles/tracing/jaeger-subgraph/index.ts b/dockerfiles/tracing/jaeger-subgraph/index.ts deleted file mode 100644 index 2589c8a932..0000000000 --- a/dockerfiles/tracing/jaeger-subgraph/index.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { ApolloServer } from '@apollo/server'; -import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'; -import { expressMiddleware } from "@apollo/server/express4"; -import express from 'express'; -import http from 'http'; -import cors from "cors"; -import { json } from "body-parser"; -import { gql } from 'graphql-tag'; -import { buildFederatedSchema } from '@apollo/federation'; -const { Tags, FORMAT_HTTP_HEADERS } = require('opentracing'); - -var initTracerFromEnv = require('jaeger-client').initTracerFromEnv; - -// See schema https://github.com/jaegertracing/jaeger-client-node/blob/master/src/configuration.js#L37 -var config = { - serviceName: 'accounts', -}; -var options = {}; -var tracer = initTracerFromEnv(config, options); - -const typeDefs = gql` - extend type Query { - me: User - } - - type User @key(fields: "id") { - id: ID! - name: String - username: String - } -`; - -const resolvers = { - Query: { - me() { - return users[0]; - } - }, - User: { - __resolveReference(object) { - return users.find(user => user.id === object.id); - } - } -}; - - -const users = [ - { - id: "1", - name: "Ada Lovelace", - birthDate: "1815-12-10", - username: "@ada" - }, - { - id: "2", - name: "Alan Turing", - birthDate: "1912-06-23", - username: "@complete" - } -]; - -async function startApolloServer(typeDefs, resolvers) { - const app = express(); - - app.use(function jaegerExpressMiddleware(req, res, next) { - const parentSpanContext = tracer.extract(FORMAT_HTTP_HEADERS, req.headers) - const span = tracer.startSpan('http_server', { - childOf: parentSpanContext, - tags: {[Tags.SPAN_KIND]: Tags.SPAN_KIND_RPC_SERVER} - }); - - setTimeout(() => { - span.log({ - statusCode: "200", - objectId: "42" - }); - }, 1); - - setTimeout(() => { - span.finish(); - }, 2); - - next(); - }); - - const httpServer = http.createServer(app); - - // Same ApolloServer initialization as before, plus the drain plugin. - const server = new ApolloServer({ - schema: buildFederatedSchema([ - { - typeDefs, - resolvers - } - ]), - csrfPrevention: true, - plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], - }); - - // More required logic for integrating with Express - await server.start(); - - app.use( - "/", - cors(), - json(), - expressMiddleware(server, { - context: async ({ req }) => ({ token: req.headers.token }), - }) - ); - - // Modified server startup - await new Promise(resolve => httpServer.listen({ port: 4001 }, resolve)); - console.log(`🚀 Server ready at http://localhost:4001/`); -} - -console.log("starting") -startApolloServer(typeDefs, resolvers) diff --git a/dockerfiles/tracing/jaeger-subgraph/package-lock.json b/dockerfiles/tracing/jaeger-subgraph/package-lock.json deleted file mode 100644 index 3687cb6aa4..0000000000 --- a/dockerfiles/tracing/jaeger-subgraph/package-lock.json +++ /dev/null @@ -1,2651 +0,0 @@ -{ - "name": "datadog-subgraph", - "version": "1.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "datadog-subgraph", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@apollo/federation": "^0.38.0", - "@apollo/server": "^4.0.0", - "express": "^4.18.1", - "graphql": "^16.5.0", - "graphql-tag": "^2.12.6", - "jaeger-client": "^3.19.0", - "opentracing": "^0.14.7" - }, - "devDependencies": { - "typescript": "5.5.3" - } - }, - "node_modules/@apollo/cache-control-types": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", - "integrity": "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==", - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/federation": { - "version": "0.38.1", - "resolved": "https://registry.npmjs.org/@apollo/federation/-/federation-0.38.1.tgz", - "integrity": "sha512-miifyAEsFgiYKeM3lUHFH6+vKa2vm9dXKSyWVpX6oeJiPblFLe2/iByN3psZQO2sRdVqO1OKYrGXdgKc74XDKw==", - "dependencies": { - "@apollo/subgraph": "^0.6.1", - "apollo-server-types": "^3.0.2", - "lodash.xorby": "^4.7.0" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "graphql": "^15.8.0 || ^16.0.0" - } - }, - "node_modules/@apollo/protobufjs": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.6.tgz", - "integrity": "sha512-Wqo1oSHNUj/jxmsVp4iR3I480p6qdqHikn38lKrFhfzcDJ7lwd7Ck7cHRl4JE81tWNArl77xhnG/OkZhxKBYOw==", - "hasInstallScript": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "@types/node": "^10.1.0", - "long": "^4.0.0" - }, - "bin": { - "apollo-pbjs": "bin/pbjs", - "apollo-pbts": "bin/pbts" - } - }, - "node_modules/@apollo/server": { - "version": "4.10.4", - "resolved": "https://registry.npmjs.org/@apollo/server/-/server-4.10.4.tgz", - "integrity": "sha512-HS12CUa1wq8f5zKXOKJRwRdESFp4por9AINecpcsEUV9jsCP/NqPILgx0hCOOFJuKxmnaL7070xO6l5xmOq4Fw==", - "dependencies": { - "@apollo/cache-control-types": "^1.0.3", - "@apollo/server-gateway-interface": "^1.1.1", - "@apollo/usage-reporting-protobuf": "^4.1.1", - "@apollo/utils.createhash": "^2.0.0", - "@apollo/utils.fetcher": "^2.0.0", - "@apollo/utils.isnodelike": "^2.0.0", - "@apollo/utils.keyvaluecache": "^2.1.0", - "@apollo/utils.logger": "^2.0.0", - "@apollo/utils.usagereporting": "^2.1.0", - "@apollo/utils.withrequired": "^2.0.0", - "@graphql-tools/schema": "^9.0.0", - "@josephg/resolvable": "^1.0.0", - "@types/express": "^4.17.13", - "@types/express-serve-static-core": "^4.17.30", - "@types/node-fetch": "^2.6.1", - "async-retry": "^1.2.1", - "cors": "^2.8.5", - "express": "^4.17.1", - "loglevel": "^1.6.8", - "lru-cache": "^7.10.1", - "negotiator": "^0.6.3", - "node-abort-controller": "^3.1.1", - "node-fetch": "^2.6.7", - "uuid": "^9.0.0", - "whatwg-mimetype": "^3.0.0" - }, - "engines": { - "node": ">=14.16.0" - }, - "peerDependencies": { - "graphql": "^16.6.0" - } - }, - "node_modules/@apollo/server-gateway-interface": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@apollo/server-gateway-interface/-/server-gateway-interface-1.1.1.tgz", - "integrity": "sha512-pGwCl/po6+rxRmDMFgozKQo2pbsSwE91TpsDBAOgf74CRDPXHHtM88wbwjab0wMMZh95QfR45GGyDIdhY24bkQ==", - "dependencies": { - "@apollo/usage-reporting-protobuf": "^4.1.1", - "@apollo/utils.fetcher": "^2.0.0", - "@apollo/utils.keyvaluecache": "^2.1.0", - "@apollo/utils.logger": "^2.0.0" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/server-gateway-interface/node_modules/@apollo/utils.keyvaluecache": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-2.1.1.tgz", - "integrity": "sha512-qVo5PvUUMD8oB9oYvq4ViCjYAMWnZ5zZwEjNF37L2m1u528x5mueMlU+Cr1UinupCgdB78g+egA1G98rbJ03Vw==", - "dependencies": { - "@apollo/utils.logger": "^2.0.1", - "lru-cache": "^7.14.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/server-gateway-interface/node_modules/@apollo/utils.logger": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-2.0.1.tgz", - "integrity": "sha512-YuplwLHaHf1oviidB7MxnCXAdHp3IqYV8n0momZ3JfLniae92eYqMIx+j5qJFX6WKJPs6q7bczmV4lXIsTu5Pg==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/server/node_modules/@apollo/utils.keyvaluecache": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-2.1.1.tgz", - "integrity": "sha512-qVo5PvUUMD8oB9oYvq4ViCjYAMWnZ5zZwEjNF37L2m1u528x5mueMlU+Cr1UinupCgdB78g+egA1G98rbJ03Vw==", - "dependencies": { - "@apollo/utils.logger": "^2.0.1", - "lru-cache": "^7.14.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/server/node_modules/@apollo/utils.logger": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-2.0.1.tgz", - "integrity": "sha512-YuplwLHaHf1oviidB7MxnCXAdHp3IqYV8n0momZ3JfLniae92eYqMIx+j5qJFX6WKJPs6q7bczmV4lXIsTu5Pg==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/server/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@apollo/subgraph": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@apollo/subgraph/-/subgraph-0.6.1.tgz", - "integrity": "sha512-w/6FoubSxuzXSx8uvLE1wEuHZVHRXFyfHPKdM76wX5U/xw82zlUKseVO7wTuVODTcnUzEA30udYeCApUoC3/Xw==", - "dependencies": { - "@apollo/cache-control-types": "^1.0.2" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "graphql": "^15.8.0 || ^16.0.0" - } - }, - "node_modules/@apollo/usage-reporting-protobuf": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@apollo/usage-reporting-protobuf/-/usage-reporting-protobuf-4.1.1.tgz", - "integrity": "sha512-u40dIUePHaSKVshcedO7Wp+mPiZsaU6xjv9J+VyxpoU/zL6Jle+9zWeG98tr/+SZ0nZ4OXhrbb8SNr0rAPpIDA==", - "dependencies": { - "@apollo/protobufjs": "1.2.7" - } - }, - "node_modules/@apollo/usage-reporting-protobuf/node_modules/@apollo/protobufjs": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.7.tgz", - "integrity": "sha512-Lahx5zntHPZia35myYDBRuF58tlwPskwHc5CWBZC/4bMKB6siTBWwtMrkqXcsNwQiFSzSx5hKdRPUmemrEp3Gg==", - "hasInstallScript": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "long": "^4.0.0" - }, - "bin": { - "apollo-pbjs": "bin/pbjs", - "apollo-pbts": "bin/pbts" - } - }, - "node_modules/@apollo/utils.createhash": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.createhash/-/utils.createhash-2.0.1.tgz", - "integrity": "sha512-fQO4/ZOP8LcXWvMNhKiee+2KuKyqIcfHrICA+M4lj/h/Lh1H10ICcUtk6N/chnEo5HXu0yejg64wshdaiFitJg==", - "dependencies": { - "@apollo/utils.isnodelike": "^2.0.1", - "sha.js": "^2.4.11" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/utils.dropunuseddefinitions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.dropunuseddefinitions/-/utils.dropunuseddefinitions-2.0.1.tgz", - "integrity": "sha512-EsPIBqsSt2BwDsv8Wu76LK5R1KtsVkNoO4b0M5aK0hx+dGg9xJXuqlr7Fo34Dl+y83jmzn+UvEW+t1/GP2melA==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.fetcher": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.fetcher/-/utils.fetcher-2.0.1.tgz", - "integrity": "sha512-jvvon885hEyWXd4H6zpWeN3tl88QcWnHp5gWF5OPF34uhvoR+DFqcNxs9vrRaBBSY3qda3Qe0bdud7tz2zGx1A==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/utils.isnodelike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.isnodelike/-/utils.isnodelike-2.0.1.tgz", - "integrity": "sha512-w41XyepR+jBEuVpoRM715N2ZD0xMD413UiJx8w5xnAZD2ZkSJnMJBoIzauK83kJpSgNuR6ywbV29jG9NmxjK0Q==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/utils.keyvaluecache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-1.0.1.tgz", - "integrity": "sha512-nLgYLomqjVimEzQ4cdvVQkcryi970NDvcRVPfd0OPeXhBfda38WjBq+WhQFk+czSHrmrSp34YHBxpat0EtiowA==", - "dependencies": { - "@apollo/utils.logger": "^1.0.0", - "lru-cache": "^7.10.1" - } - }, - "node_modules/@apollo/utils.logger": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-1.0.0.tgz", - "integrity": "sha512-dx9XrjyisD2pOa+KsB5RcDbWIAdgC91gJfeyLCgy0ctJMjQe7yZK5kdWaWlaOoCeX0z6YI9iYlg7vMPyMpQF3Q==" - }, - "node_modules/@apollo/utils.printwithreducedwhitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.printwithreducedwhitespace/-/utils.printwithreducedwhitespace-2.0.1.tgz", - "integrity": "sha512-9M4LUXV/fQBh8vZWlLvb/HyyhjJ77/I5ZKu+NBWV/BmYGyRmoEP9EVAy7LCVoY3t8BDcyCAGfxJaLFCSuQkPUg==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.removealiases": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.removealiases/-/utils.removealiases-2.0.1.tgz", - "integrity": "sha512-0joRc2HBO4u594Op1nev+mUF6yRnxoUH64xw8x3bX7n8QBDYdeYgY4tF0vJReTy+zdn2xv6fMsquATSgC722FA==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.sortast": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.sortast/-/utils.sortast-2.0.1.tgz", - "integrity": "sha512-eciIavsWpJ09za1pn37wpsCGrQNXUhM0TktnZmHwO+Zy9O4fu/WdB4+5BvVhFiZYOXvfjzJUcc+hsIV8RUOtMw==", - "dependencies": { - "lodash.sortby": "^4.7.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.stripsensitiveliterals": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.stripsensitiveliterals/-/utils.stripsensitiveliterals-2.0.1.tgz", - "integrity": "sha512-QJs7HtzXS/JIPMKWimFnUMK7VjkGQTzqD9bKD1h3iuPAqLsxd0mUNVbkYOPTsDhUKgcvUOfOqOJWYohAKMvcSA==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.usagereporting": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@apollo/utils.usagereporting/-/utils.usagereporting-2.1.0.tgz", - "integrity": "sha512-LPSlBrn+S17oBy5eWkrRSGb98sWmnEzo3DPTZgp8IQc8sJe0prDgDuppGq4NeQlpoqEHz0hQeYHAOA0Z3aQsxQ==", - "dependencies": { - "@apollo/usage-reporting-protobuf": "^4.1.0", - "@apollo/utils.dropunuseddefinitions": "^2.0.1", - "@apollo/utils.printwithreducedwhitespace": "^2.0.1", - "@apollo/utils.removealiases": "2.0.1", - "@apollo/utils.sortast": "^2.0.1", - "@apollo/utils.stripsensitiveliterals": "^2.0.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.withrequired": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.withrequired/-/utils.withrequired-2.0.1.tgz", - "integrity": "sha512-YBDiuAX9i1lLc6GeTy1m7DGLFn/gMnvXqlalOIMjM7DeOgIacEjjfwPqb0M1CQ2v11HhR15d1NmxJoRCfrNqcA==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@graphql-tools/merge": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.4.2.tgz", - "integrity": "sha512-XbrHAaj8yDuINph+sAfuq3QCZ/tKblrTLOpirK0+CAgNlZUCHs0Fa+xtMUURgwCVThLle1AF7svJCxFizygLsw==", - "dependencies": { - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/schema": { - "version": "9.0.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-9.0.19.tgz", - "integrity": "sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w==", - "dependencies": { - "@graphql-tools/merge": "^8.4.1", - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0", - "value-or-promise": "^1.0.12" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/utils": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", - "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", - "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@josephg/resolvable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@josephg/resolvable/-/resolvable-1.0.1.tgz", - "integrity": "sha512-CtzORUwWTTOTqfVtHaKRJ0I1kNQd1bpn3sUh8I3nJDVY+5/M/Oe1DnEWzPQvqq/xPIIkzzzIP7mfCoAjFRvDhg==" - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" - }, - "node_modules/@types/body-parser": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", - "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.35", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", - "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.14.tgz", - "integrity": "sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.18", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.17.31", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz", - "integrity": "sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*" - } - }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" - }, - "node_modules/@types/mime": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", - "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" - }, - "node_modules/@types/node": { - "version": "10.17.60", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", - "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" - }, - "node_modules/@types/node-fetch": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.4.tgz", - "integrity": "sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==", - "dependencies": { - "@types/node": "*", - "form-data": "^3.0.0" - } - }, - "node_modules/@types/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" - }, - "node_modules/@types/range-parser": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", - "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" - }, - "node_modules/@types/serve-static": { - "version": "1.13.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", - "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ansi-color": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/ansi-color/-/ansi-color-0.2.1.tgz", - "integrity": "sha512-bF6xLaZBLpOQzgYUtYEhJx090nPSZk1BQ/q2oyBK9aMMcJHzx9uXGCjI2Y+LebsN4Jwoykr0V9whbPiogdyHoQ==", - "engines": { - "node": "*" - } - }, - "node_modules/apollo-reporting-protobuf": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/apollo-reporting-protobuf/-/apollo-reporting-protobuf-3.4.0.tgz", - "integrity": "sha512-h0u3EbC/9RpihWOmcSsvTW2O6RXVaD/mPEjfrPkxRPTEPWqncsgOoRJw+wih4OqfH3PvTJvoEIf4LwKrUaqWog==", - "deprecated": "The `apollo-reporting-protobuf` package is part of Apollo Server v2 and v3, which are now deprecated (end-of-life October 22nd 2023). This package's functionality is now found in the `@apollo/usage-reporting-protobuf` package. See https://www.apollographql.com/docs/apollo-server/previous-versions/ for more details.", - "dependencies": { - "@apollo/protobufjs": "1.2.6" - } - }, - "node_modules/apollo-server-env": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-4.2.1.tgz", - "integrity": "sha512-vm/7c7ld+zFMxibzqZ7SSa5tBENc4B0uye9LTfjJwGoQFY5xsUPH5FpO5j0bMUDZ8YYNbrF9SNtzc5Cngcr90g==", - "dependencies": { - "node-fetch": "^2.6.7" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/apollo-server-types": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-3.8.0.tgz", - "integrity": "sha512-ZI/8rTE4ww8BHktsVpb91Sdq7Cb71rdSkXELSwdSR0eXu600/sY+1UXhTWdiJvk+Eq5ljqoHLwLbY2+Clq2b9A==", - "deprecated": "The `apollo-server-types` package is part of Apollo Server v2 and v3, which are now deprecated (end-of-life October 22nd 2023). This package's functionality is now found in the `@apollo/server` package. See https://www.apollographql.com/docs/apollo-server/previous-versions/ for more details.", - "dependencies": { - "@apollo/utils.keyvaluecache": "^1.0.1", - "@apollo/utils.logger": "^1.0.0", - "apollo-reporting-protobuf": "^3.4.0", - "apollo-server-env": "^4.2.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "graphql": "^15.3.0 || ^16.0.0" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "node_modules/async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", - "dependencies": { - "retry": "0.13.1" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bufrw": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bufrw/-/bufrw-1.3.0.tgz", - "integrity": "sha512-jzQnSbdJqhIltU9O5KUiTtljP9ccw2u5ix59McQy4pV2xGhVLhRZIndY8GIrgh5HjXa6+QJ9AQhOd2QWQizJFQ==", - "dependencies": { - "ansi-color": "^0.2.1", - "error": "^7.0.0", - "hexer": "^1.5.0", - "xtend": "^4.0.0" - }, - "engines": { - "node": ">= 0.10.x" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/error": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/error/-/error-7.0.2.tgz", - "integrity": "sha512-UtVv4l5MhijsYUxPJo4390gzfZvAnTHreNnDjnTZaKIiZ/SemXxAhBkYSKtWa5RtBXbLP8tMgn/n0RUa/H7jXw==", - "dependencies": { - "string-template": "~0.2.1", - "xtend": "~4.0.0" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.2", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "node_modules/get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graphql": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", - "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, - "node_modules/graphql-tag": { - "version": "2.12.6", - "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", - "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hexer": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/hexer/-/hexer-1.5.0.tgz", - "integrity": "sha512-dyrPC8KzBzUJ19QTIo1gXNqIISRXQ0NwteW6OeQHRN4ZuZeHkdODfj0zHBdOlHbRY8GqbqK57C9oWSvQZizFsg==", - "dependencies": { - "ansi-color": "^0.2.1", - "minimist": "^1.1.0", - "process": "^0.10.0", - "xtend": "^4.0.0" - }, - "bin": { - "hexer": "cli.js" - }, - "engines": { - "node": ">= 0.10.x" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/jaeger-client": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/jaeger-client/-/jaeger-client-3.19.0.tgz", - "integrity": "sha512-M0c7cKHmdyEUtjemnJyx/y9uX16XHocL46yQvyqDlPdvAcwPDbHrIbKjQdBqtiE4apQ/9dmr+ZLJYYPGnurgpw==", - "dependencies": { - "node-int64": "^0.4.0", - "opentracing": "^0.14.4", - "thriftrw": "^3.5.0", - "uuid": "^8.3.2", - "xorshift": "^1.1.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" - }, - "node_modules/lodash.xorby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.xorby/-/lodash.xorby-4.7.0.tgz", - "integrity": "sha512-gYiD6nvuQy0AEkMoUju+t4f4Rn18fjsLB/7x7YZFqtFT9kmegRLrj/uGEQVyVDy7otTmSrIMXNOk2wwuLcfHCQ==" - }, - "node_modules/loglevel": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.0.tgz", - "integrity": "sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA==", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, - "node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" - }, - "node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" - }, - "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/opentracing": { - "version": "0.14.7", - "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", - "integrity": "sha512-vz9iS7MJ5+Bp1URw8Khvdyw1H/hGvzHWlKQ7eRrQojSCDL1/SrWfrY9QebLw97n2deyRtzHRC3MkQfVNUCo91Q==", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" - }, - "node_modules/process": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/process/-/process-0.10.1.tgz", - "integrity": "sha512-dyIett8dgGIZ/TXKUzeYExt7WA6ldDzys9vTDU/cCA9L17Ypme+KzS+NjQCjpn9xsvi/shbMC+yP/BcFMBz0NA==", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "engines": { - "node": ">= 4" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - }, - "bin": { - "sha.js": "bin.js" - } - }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/string-template": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz", - "integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==" - }, - "node_modules/thriftrw": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/thriftrw/-/thriftrw-3.12.0.tgz", - "integrity": "sha512-4YZvR4DPEI41n4Opwr4jmrLGG4hndxr7387kzRFIIzxHQjarPusH4lGXrugvgb7TtPrfZVTpZCVe44/xUxowEw==", - "dependencies": { - "bufrw": "^1.3.0", - "error": "7.0.2", - "long": "^2.4.0" - }, - "bin": { - "thrift2json": "thrift2json.js" - }, - "engines": { - "node": ">= 0.10.x" - } - }, - "node_modules/thriftrw/node_modules/long": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/long/-/long-2.4.0.tgz", - "integrity": "sha512-ijUtjmO/n2A5PaosNG9ZGDsQ3vxJg7ZW8vsY8Kp0f2yIZWhSJvjmegV7t+9RPQKxKrvj8yKGehhS+po14hPLGQ==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/value-or-promise": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/value-or-promise/-/value-or-promise-1.0.12.tgz", - "integrity": "sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==", - "engines": { - "node": ">=12" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/xorshift": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/xorshift/-/xorshift-1.2.0.tgz", - "integrity": "sha512-iYgNnGyeeJ4t6U11NpA/QiKy+PXn5Aa3Azg5qkwIFz1tBLllQrjjsk9yzD7IAK0naNU4JxdeDgqW9ov4u/hc4g==" - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "engines": { - "node": ">=0.4" - } - } - }, - "dependencies": { - "@apollo/cache-control-types": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", - "integrity": "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==", - "requires": {} - }, - "@apollo/federation": { - "version": "0.38.1", - "resolved": "https://registry.npmjs.org/@apollo/federation/-/federation-0.38.1.tgz", - "integrity": "sha512-miifyAEsFgiYKeM3lUHFH6+vKa2vm9dXKSyWVpX6oeJiPblFLe2/iByN3psZQO2sRdVqO1OKYrGXdgKc74XDKw==", - "requires": { - "@apollo/subgraph": "^0.6.1", - "apollo-server-types": "^3.0.2", - "lodash.xorby": "^4.7.0" - } - }, - "@apollo/protobufjs": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.6.tgz", - "integrity": "sha512-Wqo1oSHNUj/jxmsVp4iR3I480p6qdqHikn38lKrFhfzcDJ7lwd7Ck7cHRl4JE81tWNArl77xhnG/OkZhxKBYOw==", - "requires": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "@types/node": "^10.1.0", - "long": "^4.0.0" - } - }, - "@apollo/server": { - "version": "4.10.4", - "resolved": "https://registry.npmjs.org/@apollo/server/-/server-4.10.4.tgz", - "integrity": "sha512-HS12CUa1wq8f5zKXOKJRwRdESFp4por9AINecpcsEUV9jsCP/NqPILgx0hCOOFJuKxmnaL7070xO6l5xmOq4Fw==", - "requires": { - "@apollo/cache-control-types": "^1.0.3", - "@apollo/server-gateway-interface": "^1.1.1", - "@apollo/usage-reporting-protobuf": "^4.1.1", - "@apollo/utils.createhash": "^2.0.0", - "@apollo/utils.fetcher": "^2.0.0", - "@apollo/utils.isnodelike": "^2.0.0", - "@apollo/utils.keyvaluecache": "^2.1.0", - "@apollo/utils.logger": "^2.0.0", - "@apollo/utils.usagereporting": "^2.1.0", - "@apollo/utils.withrequired": "^2.0.0", - "@graphql-tools/schema": "^9.0.0", - "@josephg/resolvable": "^1.0.0", - "@types/express": "^4.17.13", - "@types/express-serve-static-core": "^4.17.30", - "@types/node-fetch": "^2.6.1", - "async-retry": "^1.2.1", - "cors": "^2.8.5", - "express": "^4.17.1", - "loglevel": "^1.6.8", - "lru-cache": "^7.10.1", - "negotiator": "^0.6.3", - "node-abort-controller": "^3.1.1", - "node-fetch": "^2.6.7", - "uuid": "^9.0.0", - "whatwg-mimetype": "^3.0.0" - }, - "dependencies": { - "@apollo/utils.keyvaluecache": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-2.1.1.tgz", - "integrity": "sha512-qVo5PvUUMD8oB9oYvq4ViCjYAMWnZ5zZwEjNF37L2m1u528x5mueMlU+Cr1UinupCgdB78g+egA1G98rbJ03Vw==", - "requires": { - "@apollo/utils.logger": "^2.0.1", - "lru-cache": "^7.14.1" - } - }, - "@apollo/utils.logger": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-2.0.1.tgz", - "integrity": "sha512-YuplwLHaHf1oviidB7MxnCXAdHp3IqYV8n0momZ3JfLniae92eYqMIx+j5qJFX6WKJPs6q7bczmV4lXIsTu5Pg==" - }, - "uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" - } - } - }, - "@apollo/server-gateway-interface": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@apollo/server-gateway-interface/-/server-gateway-interface-1.1.1.tgz", - "integrity": "sha512-pGwCl/po6+rxRmDMFgozKQo2pbsSwE91TpsDBAOgf74CRDPXHHtM88wbwjab0wMMZh95QfR45GGyDIdhY24bkQ==", - "requires": { - "@apollo/usage-reporting-protobuf": "^4.1.1", - "@apollo/utils.fetcher": "^2.0.0", - "@apollo/utils.keyvaluecache": "^2.1.0", - "@apollo/utils.logger": "^2.0.0" - }, - "dependencies": { - "@apollo/utils.keyvaluecache": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-2.1.1.tgz", - "integrity": "sha512-qVo5PvUUMD8oB9oYvq4ViCjYAMWnZ5zZwEjNF37L2m1u528x5mueMlU+Cr1UinupCgdB78g+egA1G98rbJ03Vw==", - "requires": { - "@apollo/utils.logger": "^2.0.1", - "lru-cache": "^7.14.1" - } - }, - "@apollo/utils.logger": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-2.0.1.tgz", - "integrity": "sha512-YuplwLHaHf1oviidB7MxnCXAdHp3IqYV8n0momZ3JfLniae92eYqMIx+j5qJFX6WKJPs6q7bczmV4lXIsTu5Pg==" - } - } - }, - "@apollo/subgraph": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@apollo/subgraph/-/subgraph-0.6.1.tgz", - "integrity": "sha512-w/6FoubSxuzXSx8uvLE1wEuHZVHRXFyfHPKdM76wX5U/xw82zlUKseVO7wTuVODTcnUzEA30udYeCApUoC3/Xw==", - "requires": { - "@apollo/cache-control-types": "^1.0.2" - } - }, - "@apollo/usage-reporting-protobuf": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@apollo/usage-reporting-protobuf/-/usage-reporting-protobuf-4.1.1.tgz", - "integrity": "sha512-u40dIUePHaSKVshcedO7Wp+mPiZsaU6xjv9J+VyxpoU/zL6Jle+9zWeG98tr/+SZ0nZ4OXhrbb8SNr0rAPpIDA==", - "requires": { - "@apollo/protobufjs": "1.2.7" - }, - "dependencies": { - "@apollo/protobufjs": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.7.tgz", - "integrity": "sha512-Lahx5zntHPZia35myYDBRuF58tlwPskwHc5CWBZC/4bMKB6siTBWwtMrkqXcsNwQiFSzSx5hKdRPUmemrEp3Gg==", - "requires": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "long": "^4.0.0" - } - } - } - }, - "@apollo/utils.createhash": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.createhash/-/utils.createhash-2.0.1.tgz", - "integrity": "sha512-fQO4/ZOP8LcXWvMNhKiee+2KuKyqIcfHrICA+M4lj/h/Lh1H10ICcUtk6N/chnEo5HXu0yejg64wshdaiFitJg==", - "requires": { - "@apollo/utils.isnodelike": "^2.0.1", - "sha.js": "^2.4.11" - } - }, - "@apollo/utils.dropunuseddefinitions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.dropunuseddefinitions/-/utils.dropunuseddefinitions-2.0.1.tgz", - "integrity": "sha512-EsPIBqsSt2BwDsv8Wu76LK5R1KtsVkNoO4b0M5aK0hx+dGg9xJXuqlr7Fo34Dl+y83jmzn+UvEW+t1/GP2melA==", - "requires": {} - }, - "@apollo/utils.fetcher": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.fetcher/-/utils.fetcher-2.0.1.tgz", - "integrity": "sha512-jvvon885hEyWXd4H6zpWeN3tl88QcWnHp5gWF5OPF34uhvoR+DFqcNxs9vrRaBBSY3qda3Qe0bdud7tz2zGx1A==" - }, - "@apollo/utils.isnodelike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.isnodelike/-/utils.isnodelike-2.0.1.tgz", - "integrity": "sha512-w41XyepR+jBEuVpoRM715N2ZD0xMD413UiJx8w5xnAZD2ZkSJnMJBoIzauK83kJpSgNuR6ywbV29jG9NmxjK0Q==" - }, - "@apollo/utils.keyvaluecache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-1.0.1.tgz", - "integrity": "sha512-nLgYLomqjVimEzQ4cdvVQkcryi970NDvcRVPfd0OPeXhBfda38WjBq+WhQFk+czSHrmrSp34YHBxpat0EtiowA==", - "requires": { - "@apollo/utils.logger": "^1.0.0", - "lru-cache": "^7.10.1" - } - }, - "@apollo/utils.logger": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-1.0.0.tgz", - "integrity": "sha512-dx9XrjyisD2pOa+KsB5RcDbWIAdgC91gJfeyLCgy0ctJMjQe7yZK5kdWaWlaOoCeX0z6YI9iYlg7vMPyMpQF3Q==" - }, - "@apollo/utils.printwithreducedwhitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.printwithreducedwhitespace/-/utils.printwithreducedwhitespace-2.0.1.tgz", - "integrity": "sha512-9M4LUXV/fQBh8vZWlLvb/HyyhjJ77/I5ZKu+NBWV/BmYGyRmoEP9EVAy7LCVoY3t8BDcyCAGfxJaLFCSuQkPUg==", - "requires": {} - }, - "@apollo/utils.removealiases": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.removealiases/-/utils.removealiases-2.0.1.tgz", - "integrity": "sha512-0joRc2HBO4u594Op1nev+mUF6yRnxoUH64xw8x3bX7n8QBDYdeYgY4tF0vJReTy+zdn2xv6fMsquATSgC722FA==", - "requires": {} - }, - "@apollo/utils.sortast": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.sortast/-/utils.sortast-2.0.1.tgz", - "integrity": "sha512-eciIavsWpJ09za1pn37wpsCGrQNXUhM0TktnZmHwO+Zy9O4fu/WdB4+5BvVhFiZYOXvfjzJUcc+hsIV8RUOtMw==", - "requires": { - "lodash.sortby": "^4.7.0" - } - }, - "@apollo/utils.stripsensitiveliterals": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.stripsensitiveliterals/-/utils.stripsensitiveliterals-2.0.1.tgz", - "integrity": "sha512-QJs7HtzXS/JIPMKWimFnUMK7VjkGQTzqD9bKD1h3iuPAqLsxd0mUNVbkYOPTsDhUKgcvUOfOqOJWYohAKMvcSA==", - "requires": {} - }, - "@apollo/utils.usagereporting": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@apollo/utils.usagereporting/-/utils.usagereporting-2.1.0.tgz", - "integrity": "sha512-LPSlBrn+S17oBy5eWkrRSGb98sWmnEzo3DPTZgp8IQc8sJe0prDgDuppGq4NeQlpoqEHz0hQeYHAOA0Z3aQsxQ==", - "requires": { - "@apollo/usage-reporting-protobuf": "^4.1.0", - "@apollo/utils.dropunuseddefinitions": "^2.0.1", - "@apollo/utils.printwithreducedwhitespace": "^2.0.1", - "@apollo/utils.removealiases": "2.0.1", - "@apollo/utils.sortast": "^2.0.1", - "@apollo/utils.stripsensitiveliterals": "^2.0.1" - } - }, - "@apollo/utils.withrequired": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.withrequired/-/utils.withrequired-2.0.1.tgz", - "integrity": "sha512-YBDiuAX9i1lLc6GeTy1m7DGLFn/gMnvXqlalOIMjM7DeOgIacEjjfwPqb0M1CQ2v11HhR15d1NmxJoRCfrNqcA==" - }, - "@graphql-tools/merge": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.4.2.tgz", - "integrity": "sha512-XbrHAaj8yDuINph+sAfuq3QCZ/tKblrTLOpirK0+CAgNlZUCHs0Fa+xtMUURgwCVThLle1AF7svJCxFizygLsw==", - "requires": { - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0" - } - }, - "@graphql-tools/schema": { - "version": "9.0.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-9.0.19.tgz", - "integrity": "sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w==", - "requires": { - "@graphql-tools/merge": "^8.4.1", - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0", - "value-or-promise": "^1.0.12" - } - }, - "@graphql-tools/utils": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", - "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", - "requires": { - "@graphql-typed-document-node/core": "^3.1.1", - "tslib": "^2.4.0" - } - }, - "@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", - "requires": {} - }, - "@josephg/resolvable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@josephg/resolvable/-/resolvable-1.0.1.tgz", - "integrity": "sha512-CtzORUwWTTOTqfVtHaKRJ0I1kNQd1bpn3sUh8I3nJDVY+5/M/Oe1DnEWzPQvqq/xPIIkzzzIP7mfCoAjFRvDhg==" - }, - "@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" - }, - "@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" - }, - "@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" - }, - "@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" - }, - "@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "requires": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" - }, - "@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" - }, - "@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" - }, - "@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" - }, - "@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" - }, - "@types/body-parser": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", - "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", - "requires": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "@types/connect": { - "version": "3.4.35", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", - "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", - "requires": { - "@types/node": "*" - } - }, - "@types/express": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.14.tgz", - "integrity": "sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==", - "requires": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.18", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "@types/express-serve-static-core": { - "version": "4.17.31", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz", - "integrity": "sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==", - "requires": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*" - } - }, - "@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" - }, - "@types/mime": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", - "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" - }, - "@types/node": { - "version": "10.17.60", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", - "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" - }, - "@types/node-fetch": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.4.tgz", - "integrity": "sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==", - "requires": { - "@types/node": "*", - "form-data": "^3.0.0" - } - }, - "@types/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" - }, - "@types/range-parser": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", - "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" - }, - "@types/serve-static": { - "version": "1.13.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", - "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", - "requires": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "requires": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - } - }, - "ansi-color": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/ansi-color/-/ansi-color-0.2.1.tgz", - "integrity": "sha512-bF6xLaZBLpOQzgYUtYEhJx090nPSZk1BQ/q2oyBK9aMMcJHzx9uXGCjI2Y+LebsN4Jwoykr0V9whbPiogdyHoQ==" - }, - "apollo-reporting-protobuf": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/apollo-reporting-protobuf/-/apollo-reporting-protobuf-3.4.0.tgz", - "integrity": "sha512-h0u3EbC/9RpihWOmcSsvTW2O6RXVaD/mPEjfrPkxRPTEPWqncsgOoRJw+wih4OqfH3PvTJvoEIf4LwKrUaqWog==", - "requires": { - "@apollo/protobufjs": "1.2.6" - } - }, - "apollo-server-env": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-4.2.1.tgz", - "integrity": "sha512-vm/7c7ld+zFMxibzqZ7SSa5tBENc4B0uye9LTfjJwGoQFY5xsUPH5FpO5j0bMUDZ8YYNbrF9SNtzc5Cngcr90g==", - "requires": { - "node-fetch": "^2.6.7" - } - }, - "apollo-server-types": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-3.8.0.tgz", - "integrity": "sha512-ZI/8rTE4ww8BHktsVpb91Sdq7Cb71rdSkXELSwdSR0eXu600/sY+1UXhTWdiJvk+Eq5ljqoHLwLbY2+Clq2b9A==", - "requires": { - "@apollo/utils.keyvaluecache": "^1.0.1", - "@apollo/utils.logger": "^1.0.0", - "apollo-reporting-protobuf": "^3.4.0", - "apollo-server-env": "^4.2.1" - } - }, - "array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", - "requires": { - "retry": "0.13.1" - } - }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", - "requires": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - } - }, - "bufrw": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bufrw/-/bufrw-1.3.0.tgz", - "integrity": "sha512-jzQnSbdJqhIltU9O5KUiTtljP9ccw2u5ix59McQy4pV2xGhVLhRZIndY8GIrgh5HjXa6+QJ9AQhOd2QWQizJFQ==", - "requires": { - "ansi-color": "^0.2.1", - "error": "^7.0.0", - "hexer": "^1.5.0", - "xtend": "^4.0.0" - } - }, - "bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" - }, - "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - } - }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "requires": { - "delayed-stream": "~1.0.0" - } - }, - "content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "requires": { - "safe-buffer": "5.2.1" - } - }, - "content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" - }, - "cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" - }, - "cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" - }, - "cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "requires": { - "object-assign": "^4", - "vary": "^1" - } - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" - }, - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - }, - "destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" - }, - "ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" - }, - "error": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/error/-/error-7.0.2.tgz", - "integrity": "sha512-UtVv4l5MhijsYUxPJo4390gzfZvAnTHreNnDjnTZaKIiZ/SemXxAhBkYSKtWa5RtBXbLP8tMgn/n0RUa/H7jXw==", - "requires": { - "string-template": "~0.2.1", - "xtend": "~4.0.0" - } - }, - "escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" - }, - "express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", - "requires": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.2", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - } - }, - "finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "requires": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - } - }, - "form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, - "forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" - }, - "fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", - "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" - } - }, - "graphql": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", - "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==" - }, - "graphql-tag": { - "version": "2.12.6", - "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", - "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", - "requires": { - "tslib": "^2.1.0" - } - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==" - }, - "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" - }, - "hexer": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/hexer/-/hexer-1.5.0.tgz", - "integrity": "sha512-dyrPC8KzBzUJ19QTIo1gXNqIISRXQ0NwteW6OeQHRN4ZuZeHkdODfj0zHBdOlHbRY8GqbqK57C9oWSvQZizFsg==", - "requires": { - "ansi-color": "^0.2.1", - "minimist": "^1.1.0", - "process": "^0.10.0", - "xtend": "^4.0.0" - } - }, - "http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "requires": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - } - }, - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" - }, - "jaeger-client": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/jaeger-client/-/jaeger-client-3.19.0.tgz", - "integrity": "sha512-M0c7cKHmdyEUtjemnJyx/y9uX16XHocL46yQvyqDlPdvAcwPDbHrIbKjQdBqtiE4apQ/9dmr+ZLJYYPGnurgpw==", - "requires": { - "node-int64": "^0.4.0", - "opentracing": "^0.14.4", - "thriftrw": "^3.5.0", - "uuid": "^8.3.2", - "xorshift": "^1.1.1" - } - }, - "lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" - }, - "lodash.xorby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.xorby/-/lodash.xorby-4.7.0.tgz", - "integrity": "sha512-gYiD6nvuQy0AEkMoUju+t4f4Rn18fjsLB/7x7YZFqtFT9kmegRLrj/uGEQVyVDy7otTmSrIMXNOk2wwuLcfHCQ==" - }, - "loglevel": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.0.tgz", - "integrity": "sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA==" - }, - "long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" - }, - "lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==" - }, - "media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" - }, - "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" - }, - "methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" - }, - "mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" - }, - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "requires": { - "mime-db": "1.52.0" - } - }, - "minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" - }, - "node-abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" - }, - "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "requires": { - "whatwg-url": "^5.0.0" - } - }, - "node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==" - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" - }, - "object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" - }, - "on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "requires": { - "ee-first": "1.1.1" - } - }, - "opentracing": { - "version": "0.14.7", - "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", - "integrity": "sha512-vz9iS7MJ5+Bp1URw8Khvdyw1H/hGvzHWlKQ7eRrQojSCDL1/SrWfrY9QebLw97n2deyRtzHRC3MkQfVNUCo91Q==" - }, - "parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" - }, - "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" - }, - "process": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/process/-/process-0.10.1.tgz", - "integrity": "sha512-dyIett8dgGIZ/TXKUzeYExt7WA6ldDzys9vTDU/cCA9L17Ypme+KzS+NjQCjpn9xsvi/shbMC+yP/BcFMBz0NA==" - }, - "proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "requires": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - } - }, - "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "requires": { - "side-channel": "^1.0.4" - } - }, - "range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" - }, - "raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "requires": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - } - }, - "retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==" - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "requires": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "dependencies": { - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - } - } - }, - "serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "requires": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - } - }, - "setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - } - }, - "statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" - }, - "string-template": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz", - "integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==" - }, - "thriftrw": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/thriftrw/-/thriftrw-3.12.0.tgz", - "integrity": "sha512-4YZvR4DPEI41n4Opwr4jmrLGG4hndxr7387kzRFIIzxHQjarPusH4lGXrugvgb7TtPrfZVTpZCVe44/xUxowEw==", - "requires": { - "bufrw": "^1.3.0", - "error": "7.0.2", - "long": "^2.4.0" - }, - "dependencies": { - "long": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/long/-/long-2.4.0.tgz", - "integrity": "sha512-ijUtjmO/n2A5PaosNG9ZGDsQ3vxJg7ZW8vsY8Kp0f2yIZWhSJvjmegV7t+9RPQKxKrvj8yKGehhS+po14hPLGQ==" - } - } - }, - "toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" - }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" - }, - "type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "requires": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - } - }, - "typescript": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", - "dev": true - }, - "unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" - }, - "utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" - }, - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" - }, - "value-or-promise": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/value-or-promise/-/value-or-promise-1.0.12.tgz", - "integrity": "sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==" - }, - "vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" - }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==" - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "xorshift": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/xorshift/-/xorshift-1.2.0.tgz", - "integrity": "sha512-iYgNnGyeeJ4t6U11NpA/QiKy+PXn5Aa3Azg5qkwIFz1tBLllQrjjsk9yzD7IAK0naNU4JxdeDgqW9ov4u/hc4g==" - }, - "xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" - } - } -} diff --git a/dockerfiles/tracing/jaeger-subgraph/package.json b/dockerfiles/tracing/jaeger-subgraph/package.json deleted file mode 100644 index 2be04d726e..0000000000 --- a/dockerfiles/tracing/jaeger-subgraph/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "datadog-subgraph", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "start": "node dist/index.js" - }, - "author": "", - "license": "ISC", - "dependencies": { - "@apollo/federation": "^0.38.0", - "@apollo/server": "^4.0.0", - "express": "^4.18.1", - "graphql": "^16.5.0", - "graphql-tag": "^2.12.6", - "jaeger-client": "^3.19.0", - "opentracing": "^0.14.7" - }, - "devDependencies": { - "typescript": "5.5.3" - } -} diff --git a/dockerfiles/tracing/jaeger-subgraph/tsconfig.json b/dockerfiles/tracing/jaeger-subgraph/tsconfig.json deleted file mode 100644 index ff87005192..0000000000 --- a/dockerfiles/tracing/jaeger-subgraph/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "esModuleInterop": true, - "target": "es6", - "moduleResolution": "node", - "sourceMap": true, - "outDir": "dist" - }, - "lib": ["es2015"] -} diff --git a/dockerfiles/tracing/router/Dockerfile b/dockerfiles/tracing/router/Dockerfile deleted file mode 100644 index ad5f3ed3b8..0000000000 --- a/dockerfiles/tracing/router/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM --platform=linux/amd64 ubuntu:latest -WORKDIR /usr/src/app -RUN apt-get update && apt-get install -y \ - libssl-dev \ - curl \ - jq - -#COPY install.sh . -#RUN ./install.sh - -COPY ./router /usr/src/app - -STOPSIGNAL SIGINT - - -# Default executable is the router -ENTRYPOINT ["/usr/src/app/router"] \ No newline at end of file diff --git a/dockerfiles/tracing/router/datadog.router.yaml b/dockerfiles/tracing/router/datadog.router.yaml deleted file mode 100644 index d51b616133..0000000000 --- a/dockerfiles/tracing/router/datadog.router.yaml +++ /dev/null @@ -1,16 +0,0 @@ -supergraph: - listen: 0.0.0.0:4000 -cors: - origins: - - https://studio.apollographql.com - -telemetry: - exporters: - tracing: - common: - service_name: router - datadog: - enabled: true - endpoint: datadog-agent:8126 - propagation: - datadog: true diff --git a/dockerfiles/tracing/router/jaeger.router.yaml b/dockerfiles/tracing/router/jaeger.router.yaml deleted file mode 100644 index bf015fc217..0000000000 --- a/dockerfiles/tracing/router/jaeger.router.yaml +++ /dev/null @@ -1,17 +0,0 @@ -supergraph: - listen: 0.0.0.0:4000 -cors: - origins: - - https://studio.apollographql.com - -telemetry: - exporters: - tracing: - common: - service_name: router - jaeger: - enabled: true - agent: - endpoint: jaeger:6831 - propagation: - jaeger: true diff --git a/dockerfiles/tracing/router/zipkin.router.yaml b/dockerfiles/tracing/router/zipkin.router.yaml deleted file mode 100644 index 3eb2fab1be..0000000000 --- a/dockerfiles/tracing/router/zipkin.router.yaml +++ /dev/null @@ -1,16 +0,0 @@ -supergraph: - listen: 0.0.0.0:4000 -cors: - origins: - - https://studio.apollographql.com - -telemetry: - exporters: - tracing: - common: - service_name: router - zipkin: - enabled: true - endpoint: http://zipkin:9411/api/v2/spans - propagation: - zipkin: true diff --git a/dockerfiles/tracing/supergraph.graphql b/dockerfiles/tracing/supergraph.graphql deleted file mode 100644 index 962b3169e9..0000000000 --- a/dockerfiles/tracing/supergraph.graphql +++ /dev/null @@ -1,51 +0,0 @@ - -schema - @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.2", for: EXECUTION) -{ - query: Query -} - -directive @join__field(graph: join__Graph!, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION - -directive @join__graph(name: String!, url: String!) on ENUM_VALUE - -directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE - -directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR - -directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA - -scalar join__FieldSet - -enum join__Graph { - ACCOUNTS @join__graph(name: "accounts", url: "http://subgraph:4001/") -} - -scalar link__Import - -enum link__Purpose { - """ - `SECURITY` features provide metadata necessary to securely resolve fields. - """ - SECURITY - - """ - `EXECUTION` features provide metadata necessary for operation execution. - """ - EXECUTION -} - -type Query - @join__type(graph: ACCOUNTS) -{ - me: User -} - -type User - @join__type(graph: ACCOUNTS, key: "id") -{ - id: ID! - name: String - username: String -} \ No newline at end of file diff --git a/dockerfiles/tracing/supergraph.yml b/dockerfiles/tracing/supergraph.yml deleted file mode 100644 index b917a0a1c7..0000000000 --- a/dockerfiles/tracing/supergraph.yml +++ /dev/null @@ -1,6 +0,0 @@ -subgraphs: - accounts: - routing_url: http://localhost:4001/ - schema: - subgraph_url: http://localhost:4001 -federation_version: 2 diff --git a/dockerfiles/tracing/zipkin-subgraph/Dockerfile b/dockerfiles/tracing/zipkin-subgraph/Dockerfile deleted file mode 100644 index aa70806ccd..0000000000 --- a/dockerfiles/tracing/zipkin-subgraph/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM node:20-bullseye - -WORKDIR /src - -ADD ./*.ts ./*.json /src/ - -RUN npm ci -RUN npx tsc - -CMD [ "node", "dist/index.js" ] diff --git a/dockerfiles/tracing/zipkin-subgraph/index.ts b/dockerfiles/tracing/zipkin-subgraph/index.ts deleted file mode 100644 index 436b52cbe7..0000000000 --- a/dockerfiles/tracing/zipkin-subgraph/index.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { ApolloServer } from '@apollo/server'; -import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer"; -import { buildFederatedSchema } from '@apollo/federation'; -import { gql } from 'graphql-tag'; -import { expressMiddleware } from "@apollo/server/express4"; -import cors from 'cors'; -import { json } from 'body-parser'; -import express from 'express'; -import http from 'http'; -const ZipkinJavascriptOpentracing = require("zipkin-javascript-opentracing"); - -const { recorder } = require("./recorder"); - -const tracer = new ZipkinJavascriptOpentracing({ - serviceName: "accounts", - recorder, - kind: "server" -}); - -const typeDefs = gql` - extend type Query { - me: User - } - - type User @key(fields: "id") { - id: ID! - name: String - username: String - } -`; - -const resolvers = { - Query: { - me() { - return users[0]; - } - }, - User: { - __resolveReference(object) { - return users.find(user => user.id === object.id); - } - } -}; - - -const users = [ - { - id: "1", - name: "Ada Lovelace", - birthDate: "1815-12-10", - username: "@ada" - }, - { - id: "2", - name: "Alan Turing", - birthDate: "1912-06-23", - username: "@complete" - } -]; - -async function startApolloServer(typeDefs, resolvers) { - const app = express(); - - app.use(function zipkinExpressMiddleware(req, res, next) { - const context = tracer.extract( - ZipkinJavascriptOpentracing.FORMAT_HTTP_HEADERS, - req.headers - ); - const span = tracer.startSpan("subgraph", { childOf: context }); - - setTimeout(() => { - span.log({ - statusCode: "200", - objectId: "42" - }); - }, 1); - - setTimeout(() => { - span.finish(); - }, 2); - - next(); - }); - - const httpServer = http.createServer(app); - - // Same ApolloServer initialization as before, plus the drain plugin. - const server = new ApolloServer({ - schema: buildFederatedSchema([ - { - typeDefs, - resolvers - } - ]), - csrfPrevention: true, - plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], - }); - - // More required logic for integrating with Express - await server.start(); - - app.use( - "/", - cors(), - json(), - expressMiddleware(server, { - context: async ({ req }) => ({ token: req.headers.token }), - }) - ); - - // Modified server startup - await new Promise(resolve => httpServer.listen({ port: 4001 }, resolve)); - console.log(`🚀 Server ready at http://localhost:4001/`); -} - -console.log("starting") -startApolloServer(typeDefs, resolvers) diff --git a/dockerfiles/tracing/zipkin-subgraph/package-lock.json b/dockerfiles/tracing/zipkin-subgraph/package-lock.json deleted file mode 100644 index dbfad73245..0000000000 --- a/dockerfiles/tracing/zipkin-subgraph/package-lock.json +++ /dev/null @@ -1,2747 +0,0 @@ -{ - "name": "datadog-subgraph", - "version": "1.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "datadog-subgraph", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@apollo/federation": "^0.38.0", - "@apollo/server": "^4.0.0", - "express": "^4.18.1", - "graphql": "^16.5.0", - "graphql-tag": "^2.12.6", - "jaeger-client": "^3.19.0", - "opentracing": "^0.14.7", - "zipkin-javascript-opentracing": "^3.0.0" - }, - "devDependencies": { - "typescript": "5.5.3" - } - }, - "node_modules/@apollo/cache-control-types": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", - "integrity": "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==", - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/federation": { - "version": "0.38.1", - "resolved": "https://registry.npmjs.org/@apollo/federation/-/federation-0.38.1.tgz", - "integrity": "sha512-miifyAEsFgiYKeM3lUHFH6+vKa2vm9dXKSyWVpX6oeJiPblFLe2/iByN3psZQO2sRdVqO1OKYrGXdgKc74XDKw==", - "dependencies": { - "@apollo/subgraph": "^0.6.1", - "apollo-server-types": "^3.0.2", - "lodash.xorby": "^4.7.0" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "graphql": "^15.8.0 || ^16.0.0" - } - }, - "node_modules/@apollo/protobufjs": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.6.tgz", - "integrity": "sha512-Wqo1oSHNUj/jxmsVp4iR3I480p6qdqHikn38lKrFhfzcDJ7lwd7Ck7cHRl4JE81tWNArl77xhnG/OkZhxKBYOw==", - "hasInstallScript": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "@types/node": "^10.1.0", - "long": "^4.0.0" - }, - "bin": { - "apollo-pbjs": "bin/pbjs", - "apollo-pbts": "bin/pbts" - } - }, - "node_modules/@apollo/server": { - "version": "4.10.4", - "resolved": "https://registry.npmjs.org/@apollo/server/-/server-4.10.4.tgz", - "integrity": "sha512-HS12CUa1wq8f5zKXOKJRwRdESFp4por9AINecpcsEUV9jsCP/NqPILgx0hCOOFJuKxmnaL7070xO6l5xmOq4Fw==", - "dependencies": { - "@apollo/cache-control-types": "^1.0.3", - "@apollo/server-gateway-interface": "^1.1.1", - "@apollo/usage-reporting-protobuf": "^4.1.1", - "@apollo/utils.createhash": "^2.0.0", - "@apollo/utils.fetcher": "^2.0.0", - "@apollo/utils.isnodelike": "^2.0.0", - "@apollo/utils.keyvaluecache": "^2.1.0", - "@apollo/utils.logger": "^2.0.0", - "@apollo/utils.usagereporting": "^2.1.0", - "@apollo/utils.withrequired": "^2.0.0", - "@graphql-tools/schema": "^9.0.0", - "@josephg/resolvable": "^1.0.0", - "@types/express": "^4.17.13", - "@types/express-serve-static-core": "^4.17.30", - "@types/node-fetch": "^2.6.1", - "async-retry": "^1.2.1", - "cors": "^2.8.5", - "express": "^4.17.1", - "loglevel": "^1.6.8", - "lru-cache": "^7.10.1", - "negotiator": "^0.6.3", - "node-abort-controller": "^3.1.1", - "node-fetch": "^2.6.7", - "uuid": "^9.0.0", - "whatwg-mimetype": "^3.0.0" - }, - "engines": { - "node": ">=14.16.0" - }, - "peerDependencies": { - "graphql": "^16.6.0" - } - }, - "node_modules/@apollo/server-gateway-interface": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@apollo/server-gateway-interface/-/server-gateway-interface-1.1.1.tgz", - "integrity": "sha512-pGwCl/po6+rxRmDMFgozKQo2pbsSwE91TpsDBAOgf74CRDPXHHtM88wbwjab0wMMZh95QfR45GGyDIdhY24bkQ==", - "dependencies": { - "@apollo/usage-reporting-protobuf": "^4.1.1", - "@apollo/utils.fetcher": "^2.0.0", - "@apollo/utils.keyvaluecache": "^2.1.0", - "@apollo/utils.logger": "^2.0.0" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/server-gateway-interface/node_modules/@apollo/utils.keyvaluecache": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-2.1.1.tgz", - "integrity": "sha512-qVo5PvUUMD8oB9oYvq4ViCjYAMWnZ5zZwEjNF37L2m1u528x5mueMlU+Cr1UinupCgdB78g+egA1G98rbJ03Vw==", - "dependencies": { - "@apollo/utils.logger": "^2.0.1", - "lru-cache": "^7.14.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/server-gateway-interface/node_modules/@apollo/utils.logger": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-2.0.1.tgz", - "integrity": "sha512-YuplwLHaHf1oviidB7MxnCXAdHp3IqYV8n0momZ3JfLniae92eYqMIx+j5qJFX6WKJPs6q7bczmV4lXIsTu5Pg==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/server/node_modules/@apollo/utils.keyvaluecache": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-2.1.1.tgz", - "integrity": "sha512-qVo5PvUUMD8oB9oYvq4ViCjYAMWnZ5zZwEjNF37L2m1u528x5mueMlU+Cr1UinupCgdB78g+egA1G98rbJ03Vw==", - "dependencies": { - "@apollo/utils.logger": "^2.0.1", - "lru-cache": "^7.14.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/server/node_modules/@apollo/utils.logger": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-2.0.1.tgz", - "integrity": "sha512-YuplwLHaHf1oviidB7MxnCXAdHp3IqYV8n0momZ3JfLniae92eYqMIx+j5qJFX6WKJPs6q7bczmV4lXIsTu5Pg==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/server/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@apollo/subgraph": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@apollo/subgraph/-/subgraph-0.6.1.tgz", - "integrity": "sha512-w/6FoubSxuzXSx8uvLE1wEuHZVHRXFyfHPKdM76wX5U/xw82zlUKseVO7wTuVODTcnUzEA30udYeCApUoC3/Xw==", - "dependencies": { - "@apollo/cache-control-types": "^1.0.2" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "graphql": "^15.8.0 || ^16.0.0" - } - }, - "node_modules/@apollo/usage-reporting-protobuf": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@apollo/usage-reporting-protobuf/-/usage-reporting-protobuf-4.1.1.tgz", - "integrity": "sha512-u40dIUePHaSKVshcedO7Wp+mPiZsaU6xjv9J+VyxpoU/zL6Jle+9zWeG98tr/+SZ0nZ4OXhrbb8SNr0rAPpIDA==", - "dependencies": { - "@apollo/protobufjs": "1.2.7" - } - }, - "node_modules/@apollo/usage-reporting-protobuf/node_modules/@apollo/protobufjs": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.7.tgz", - "integrity": "sha512-Lahx5zntHPZia35myYDBRuF58tlwPskwHc5CWBZC/4bMKB6siTBWwtMrkqXcsNwQiFSzSx5hKdRPUmemrEp3Gg==", - "hasInstallScript": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "long": "^4.0.0" - }, - "bin": { - "apollo-pbjs": "bin/pbjs", - "apollo-pbts": "bin/pbts" - } - }, - "node_modules/@apollo/utils.createhash": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.createhash/-/utils.createhash-2.0.1.tgz", - "integrity": "sha512-fQO4/ZOP8LcXWvMNhKiee+2KuKyqIcfHrICA+M4lj/h/Lh1H10ICcUtk6N/chnEo5HXu0yejg64wshdaiFitJg==", - "dependencies": { - "@apollo/utils.isnodelike": "^2.0.1", - "sha.js": "^2.4.11" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/utils.dropunuseddefinitions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.dropunuseddefinitions/-/utils.dropunuseddefinitions-2.0.1.tgz", - "integrity": "sha512-EsPIBqsSt2BwDsv8Wu76LK5R1KtsVkNoO4b0M5aK0hx+dGg9xJXuqlr7Fo34Dl+y83jmzn+UvEW+t1/GP2melA==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.fetcher": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.fetcher/-/utils.fetcher-2.0.1.tgz", - "integrity": "sha512-jvvon885hEyWXd4H6zpWeN3tl88QcWnHp5gWF5OPF34uhvoR+DFqcNxs9vrRaBBSY3qda3Qe0bdud7tz2zGx1A==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/utils.isnodelike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.isnodelike/-/utils.isnodelike-2.0.1.tgz", - "integrity": "sha512-w41XyepR+jBEuVpoRM715N2ZD0xMD413UiJx8w5xnAZD2ZkSJnMJBoIzauK83kJpSgNuR6ywbV29jG9NmxjK0Q==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@apollo/utils.keyvaluecache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-1.0.1.tgz", - "integrity": "sha512-nLgYLomqjVimEzQ4cdvVQkcryi970NDvcRVPfd0OPeXhBfda38WjBq+WhQFk+czSHrmrSp34YHBxpat0EtiowA==", - "dependencies": { - "@apollo/utils.logger": "^1.0.0", - "lru-cache": "^7.10.1" - } - }, - "node_modules/@apollo/utils.logger": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-1.0.0.tgz", - "integrity": "sha512-dx9XrjyisD2pOa+KsB5RcDbWIAdgC91gJfeyLCgy0ctJMjQe7yZK5kdWaWlaOoCeX0z6YI9iYlg7vMPyMpQF3Q==" - }, - "node_modules/@apollo/utils.printwithreducedwhitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.printwithreducedwhitespace/-/utils.printwithreducedwhitespace-2.0.1.tgz", - "integrity": "sha512-9M4LUXV/fQBh8vZWlLvb/HyyhjJ77/I5ZKu+NBWV/BmYGyRmoEP9EVAy7LCVoY3t8BDcyCAGfxJaLFCSuQkPUg==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.removealiases": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.removealiases/-/utils.removealiases-2.0.1.tgz", - "integrity": "sha512-0joRc2HBO4u594Op1nev+mUF6yRnxoUH64xw8x3bX7n8QBDYdeYgY4tF0vJReTy+zdn2xv6fMsquATSgC722FA==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.sortast": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.sortast/-/utils.sortast-2.0.1.tgz", - "integrity": "sha512-eciIavsWpJ09za1pn37wpsCGrQNXUhM0TktnZmHwO+Zy9O4fu/WdB4+5BvVhFiZYOXvfjzJUcc+hsIV8RUOtMw==", - "dependencies": { - "lodash.sortby": "^4.7.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.stripsensitiveliterals": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.stripsensitiveliterals/-/utils.stripsensitiveliterals-2.0.1.tgz", - "integrity": "sha512-QJs7HtzXS/JIPMKWimFnUMK7VjkGQTzqD9bKD1h3iuPAqLsxd0mUNVbkYOPTsDhUKgcvUOfOqOJWYohAKMvcSA==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.usagereporting": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@apollo/utils.usagereporting/-/utils.usagereporting-2.1.0.tgz", - "integrity": "sha512-LPSlBrn+S17oBy5eWkrRSGb98sWmnEzo3DPTZgp8IQc8sJe0prDgDuppGq4NeQlpoqEHz0hQeYHAOA0Z3aQsxQ==", - "dependencies": { - "@apollo/usage-reporting-protobuf": "^4.1.0", - "@apollo/utils.dropunuseddefinitions": "^2.0.1", - "@apollo/utils.printwithreducedwhitespace": "^2.0.1", - "@apollo/utils.removealiases": "2.0.1", - "@apollo/utils.sortast": "^2.0.1", - "@apollo/utils.stripsensitiveliterals": "^2.0.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" - } - }, - "node_modules/@apollo/utils.withrequired": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.withrequired/-/utils.withrequired-2.0.1.tgz", - "integrity": "sha512-YBDiuAX9i1lLc6GeTy1m7DGLFn/gMnvXqlalOIMjM7DeOgIacEjjfwPqb0M1CQ2v11HhR15d1NmxJoRCfrNqcA==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@graphql-tools/merge": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.4.2.tgz", - "integrity": "sha512-XbrHAaj8yDuINph+sAfuq3QCZ/tKblrTLOpirK0+CAgNlZUCHs0Fa+xtMUURgwCVThLle1AF7svJCxFizygLsw==", - "dependencies": { - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/schema": { - "version": "9.0.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-9.0.19.tgz", - "integrity": "sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w==", - "dependencies": { - "@graphql-tools/merge": "^8.4.1", - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0", - "value-or-promise": "^1.0.12" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/utils": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", - "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", - "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@josephg/resolvable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@josephg/resolvable/-/resolvable-1.0.1.tgz", - "integrity": "sha512-CtzORUwWTTOTqfVtHaKRJ0I1kNQd1bpn3sUh8I3nJDVY+5/M/Oe1DnEWzPQvqq/xPIIkzzzIP7mfCoAjFRvDhg==" - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" - }, - "node_modules/@types/body-parser": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", - "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.35", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", - "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.14.tgz", - "integrity": "sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.18", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.17.31", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz", - "integrity": "sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*" - } - }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" - }, - "node_modules/@types/mime": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", - "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" - }, - "node_modules/@types/node": { - "version": "10.17.60", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", - "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" - }, - "node_modules/@types/node-fetch": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.4.tgz", - "integrity": "sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==", - "dependencies": { - "@types/node": "*", - "form-data": "^3.0.0" - } - }, - "node_modules/@types/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" - }, - "node_modules/@types/range-parser": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", - "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" - }, - "node_modules/@types/serve-static": { - "version": "1.13.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", - "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ansi-color": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/ansi-color/-/ansi-color-0.2.1.tgz", - "integrity": "sha512-bF6xLaZBLpOQzgYUtYEhJx090nPSZk1BQ/q2oyBK9aMMcJHzx9uXGCjI2Y+LebsN4Jwoykr0V9whbPiogdyHoQ==", - "engines": { - "node": "*" - } - }, - "node_modules/apollo-reporting-protobuf": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/apollo-reporting-protobuf/-/apollo-reporting-protobuf-3.4.0.tgz", - "integrity": "sha512-h0u3EbC/9RpihWOmcSsvTW2O6RXVaD/mPEjfrPkxRPTEPWqncsgOoRJw+wih4OqfH3PvTJvoEIf4LwKrUaqWog==", - "deprecated": "The `apollo-reporting-protobuf` package is part of Apollo Server v2 and v3, which are now deprecated (end-of-life October 22nd 2023). This package's functionality is now found in the `@apollo/usage-reporting-protobuf` package. See https://www.apollographql.com/docs/apollo-server/previous-versions/ for more details.", - "dependencies": { - "@apollo/protobufjs": "1.2.6" - } - }, - "node_modules/apollo-server-env": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-4.2.1.tgz", - "integrity": "sha512-vm/7c7ld+zFMxibzqZ7SSa5tBENc4B0uye9LTfjJwGoQFY5xsUPH5FpO5j0bMUDZ8YYNbrF9SNtzc5Cngcr90g==", - "dependencies": { - "node-fetch": "^2.6.7" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/apollo-server-types": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-3.8.0.tgz", - "integrity": "sha512-ZI/8rTE4ww8BHktsVpb91Sdq7Cb71rdSkXELSwdSR0eXu600/sY+1UXhTWdiJvk+Eq5ljqoHLwLbY2+Clq2b9A==", - "deprecated": "The `apollo-server-types` package is part of Apollo Server v2 and v3, which are now deprecated (end-of-life October 22nd 2023). This package's functionality is now found in the `@apollo/server` package. See https://www.apollographql.com/docs/apollo-server/previous-versions/ for more details.", - "dependencies": { - "@apollo/utils.keyvaluecache": "^1.0.1", - "@apollo/utils.logger": "^1.0.0", - "apollo-reporting-protobuf": "^3.4.0", - "apollo-server-env": "^4.2.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "graphql": "^15.3.0 || ^16.0.0" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "node_modules/async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", - "dependencies": { - "retry": "0.13.1" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true - }, - "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bufrw": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bufrw/-/bufrw-1.3.0.tgz", - "integrity": "sha512-jzQnSbdJqhIltU9O5KUiTtljP9ccw2u5ix59McQy4pV2xGhVLhRZIndY8GIrgh5HjXa6+QJ9AQhOd2QWQizJFQ==", - "dependencies": { - "ansi-color": "^0.2.1", - "error": "^7.0.0", - "hexer": "^1.5.0", - "xtend": "^4.0.0" - }, - "engines": { - "node": ">= 0.10.x" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/error": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/error/-/error-7.0.2.tgz", - "integrity": "sha512-UtVv4l5MhijsYUxPJo4390gzfZvAnTHreNnDjnTZaKIiZ/SemXxAhBkYSKtWa5RtBXbLP8tMgn/n0RUa/H7jXw==", - "dependencies": { - "string-template": "~0.2.1", - "xtend": "~4.0.0" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.2", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "node_modules/get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graphql": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", - "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, - "node_modules/graphql-tag": { - "version": "2.12.6", - "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", - "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hexer": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/hexer/-/hexer-1.5.0.tgz", - "integrity": "sha512-dyrPC8KzBzUJ19QTIo1gXNqIISRXQ0NwteW6OeQHRN4ZuZeHkdODfj0zHBdOlHbRY8GqbqK57C9oWSvQZizFsg==", - "dependencies": { - "ansi-color": "^0.2.1", - "minimist": "^1.1.0", - "process": "^0.10.0", - "xtend": "^4.0.0" - }, - "bin": { - "hexer": "cli.js" - }, - "engines": { - "node": ">= 0.10.x" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", - "peer": true - }, - "node_modules/jaeger-client": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/jaeger-client/-/jaeger-client-3.19.0.tgz", - "integrity": "sha512-M0c7cKHmdyEUtjemnJyx/y9uX16XHocL46yQvyqDlPdvAcwPDbHrIbKjQdBqtiE4apQ/9dmr+ZLJYYPGnurgpw==", - "dependencies": { - "node-int64": "^0.4.0", - "opentracing": "^0.14.4", - "thriftrw": "^3.5.0", - "uuid": "^8.3.2", - "xorshift": "^1.1.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" - }, - "node_modules/lodash.xorby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.xorby/-/lodash.xorby-4.7.0.tgz", - "integrity": "sha512-gYiD6nvuQy0AEkMoUju+t4f4Rn18fjsLB/7x7YZFqtFT9kmegRLrj/uGEQVyVDy7otTmSrIMXNOk2wwuLcfHCQ==" - }, - "node_modules/loglevel": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.0.tgz", - "integrity": "sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA==", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, - "node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" - }, - "node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" - }, - "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/opentracing": { - "version": "0.14.7", - "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", - "integrity": "sha512-vz9iS7MJ5+Bp1URw8Khvdyw1H/hGvzHWlKQ7eRrQojSCDL1/SrWfrY9QebLw97n2deyRtzHRC3MkQfVNUCo91Q==", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" - }, - "node_modules/process": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/process/-/process-0.10.1.tgz", - "integrity": "sha512-dyIett8dgGIZ/TXKUzeYExt7WA6ldDzys9vTDU/cCA9L17Ypme+KzS+NjQCjpn9xsvi/shbMC+yP/BcFMBz0NA==", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "engines": { - "node": ">= 4" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - }, - "bin": { - "sha.js": "bin.js" - } - }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/string-template": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz", - "integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==" - }, - "node_modules/thriftrw": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/thriftrw/-/thriftrw-3.12.0.tgz", - "integrity": "sha512-4YZvR4DPEI41n4Opwr4jmrLGG4hndxr7387kzRFIIzxHQjarPusH4lGXrugvgb7TtPrfZVTpZCVe44/xUxowEw==", - "dependencies": { - "bufrw": "^1.3.0", - "error": "7.0.2", - "long": "^2.4.0" - }, - "bin": { - "thrift2json": "thrift2json.js" - }, - "engines": { - "node": ">= 0.10.x" - } - }, - "node_modules/thriftrw/node_modules/long": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/long/-/long-2.4.0.tgz", - "integrity": "sha512-ijUtjmO/n2A5PaosNG9ZGDsQ3vxJg7ZW8vsY8Kp0f2yIZWhSJvjmegV7t+9RPQKxKrvj8yKGehhS+po14hPLGQ==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/value-or-promise": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/value-or-promise/-/value-or-promise-1.0.12.tgz", - "integrity": "sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==", - "engines": { - "node": ">=12" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/xorshift": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/xorshift/-/xorshift-1.2.0.tgz", - "integrity": "sha512-iYgNnGyeeJ4t6U11NpA/QiKy+PXn5Aa3Azg5qkwIFz1tBLllQrjjsk9yzD7IAK0naNU4JxdeDgqW9ov4u/hc4g==" - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/zipkin": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/zipkin/-/zipkin-0.22.0.tgz", - "integrity": "sha512-SZgorBAvywnj5R26mqsFP+2MRUsBXGg0B8GLRLw9R0FeE4alMUAfhXYvzeAt2+MvkXne9QdQyziuqO5oXNQ0Sg==", - "peer": true, - "dependencies": { - "base64-js": "^1.1.2", - "is-promise": "^2.1.0" - } - }, - "node_modules/zipkin-javascript-opentracing": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/zipkin-javascript-opentracing/-/zipkin-javascript-opentracing-3.0.0.tgz", - "integrity": "sha512-ubqMBXVXw0VvioQd6BlaozEyU00P1qV3rwtnD30YAKPKvNQb7Q1MC+BMa/G4T/JM6Jcrpyg3dHn31SeE4FZvjw==", - "peerDependencies": { - "opentracing": "*", - "zipkin": ">=0.10.1", - "zipkin-transport-http": ">=0.10.1" - } - }, - "node_modules/zipkin-transport-http": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/zipkin-transport-http/-/zipkin-transport-http-0.22.0.tgz", - "integrity": "sha512-bjM2fm/hurseBuvpyB8mYCBBGOz3gc2f9KUSQl71LGxmgYcUeXvDHJyE9MgzWMhl+3HcD8l5nNn6OmdX63he+g==", - "peer": true, - "dependencies": { - "zipkin": "^0.22.0" - }, - "peerDependencies": { - "node-fetch": ">=1.4.0 <3.0.0" - } - } - }, - "dependencies": { - "@apollo/cache-control-types": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", - "integrity": "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==", - "requires": {} - }, - "@apollo/federation": { - "version": "0.38.1", - "resolved": "https://registry.npmjs.org/@apollo/federation/-/federation-0.38.1.tgz", - "integrity": "sha512-miifyAEsFgiYKeM3lUHFH6+vKa2vm9dXKSyWVpX6oeJiPblFLe2/iByN3psZQO2sRdVqO1OKYrGXdgKc74XDKw==", - "requires": { - "@apollo/subgraph": "^0.6.1", - "apollo-server-types": "^3.0.2", - "lodash.xorby": "^4.7.0" - } - }, - "@apollo/protobufjs": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.6.tgz", - "integrity": "sha512-Wqo1oSHNUj/jxmsVp4iR3I480p6qdqHikn38lKrFhfzcDJ7lwd7Ck7cHRl4JE81tWNArl77xhnG/OkZhxKBYOw==", - "requires": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "@types/node": "^10.1.0", - "long": "^4.0.0" - } - }, - "@apollo/server": { - "version": "4.10.4", - "resolved": "https://registry.npmjs.org/@apollo/server/-/server-4.10.4.tgz", - "integrity": "sha512-HS12CUa1wq8f5zKXOKJRwRdESFp4por9AINecpcsEUV9jsCP/NqPILgx0hCOOFJuKxmnaL7070xO6l5xmOq4Fw==", - "requires": { - "@apollo/cache-control-types": "^1.0.3", - "@apollo/server-gateway-interface": "^1.1.1", - "@apollo/usage-reporting-protobuf": "^4.1.1", - "@apollo/utils.createhash": "^2.0.0", - "@apollo/utils.fetcher": "^2.0.0", - "@apollo/utils.isnodelike": "^2.0.0", - "@apollo/utils.keyvaluecache": "^2.1.0", - "@apollo/utils.logger": "^2.0.0", - "@apollo/utils.usagereporting": "^2.1.0", - "@apollo/utils.withrequired": "^2.0.0", - "@graphql-tools/schema": "^9.0.0", - "@josephg/resolvable": "^1.0.0", - "@types/express": "^4.17.13", - "@types/express-serve-static-core": "^4.17.30", - "@types/node-fetch": "^2.6.1", - "async-retry": "^1.2.1", - "cors": "^2.8.5", - "express": "^4.17.1", - "loglevel": "^1.6.8", - "lru-cache": "^7.10.1", - "negotiator": "^0.6.3", - "node-abort-controller": "^3.1.1", - "node-fetch": "^2.6.7", - "uuid": "^9.0.0", - "whatwg-mimetype": "^3.0.0" - }, - "dependencies": { - "@apollo/utils.keyvaluecache": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-2.1.1.tgz", - "integrity": "sha512-qVo5PvUUMD8oB9oYvq4ViCjYAMWnZ5zZwEjNF37L2m1u528x5mueMlU+Cr1UinupCgdB78g+egA1G98rbJ03Vw==", - "requires": { - "@apollo/utils.logger": "^2.0.1", - "lru-cache": "^7.14.1" - } - }, - "@apollo/utils.logger": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-2.0.1.tgz", - "integrity": "sha512-YuplwLHaHf1oviidB7MxnCXAdHp3IqYV8n0momZ3JfLniae92eYqMIx+j5qJFX6WKJPs6q7bczmV4lXIsTu5Pg==" - }, - "uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" - } - } - }, - "@apollo/server-gateway-interface": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@apollo/server-gateway-interface/-/server-gateway-interface-1.1.1.tgz", - "integrity": "sha512-pGwCl/po6+rxRmDMFgozKQo2pbsSwE91TpsDBAOgf74CRDPXHHtM88wbwjab0wMMZh95QfR45GGyDIdhY24bkQ==", - "requires": { - "@apollo/usage-reporting-protobuf": "^4.1.1", - "@apollo/utils.fetcher": "^2.0.0", - "@apollo/utils.keyvaluecache": "^2.1.0", - "@apollo/utils.logger": "^2.0.0" - }, - "dependencies": { - "@apollo/utils.keyvaluecache": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-2.1.1.tgz", - "integrity": "sha512-qVo5PvUUMD8oB9oYvq4ViCjYAMWnZ5zZwEjNF37L2m1u528x5mueMlU+Cr1UinupCgdB78g+egA1G98rbJ03Vw==", - "requires": { - "@apollo/utils.logger": "^2.0.1", - "lru-cache": "^7.14.1" - } - }, - "@apollo/utils.logger": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-2.0.1.tgz", - "integrity": "sha512-YuplwLHaHf1oviidB7MxnCXAdHp3IqYV8n0momZ3JfLniae92eYqMIx+j5qJFX6WKJPs6q7bczmV4lXIsTu5Pg==" - } - } - }, - "@apollo/subgraph": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@apollo/subgraph/-/subgraph-0.6.1.tgz", - "integrity": "sha512-w/6FoubSxuzXSx8uvLE1wEuHZVHRXFyfHPKdM76wX5U/xw82zlUKseVO7wTuVODTcnUzEA30udYeCApUoC3/Xw==", - "requires": { - "@apollo/cache-control-types": "^1.0.2" - } - }, - "@apollo/usage-reporting-protobuf": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@apollo/usage-reporting-protobuf/-/usage-reporting-protobuf-4.1.1.tgz", - "integrity": "sha512-u40dIUePHaSKVshcedO7Wp+mPiZsaU6xjv9J+VyxpoU/zL6Jle+9zWeG98tr/+SZ0nZ4OXhrbb8SNr0rAPpIDA==", - "requires": { - "@apollo/protobufjs": "1.2.7" - }, - "dependencies": { - "@apollo/protobufjs": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.7.tgz", - "integrity": "sha512-Lahx5zntHPZia35myYDBRuF58tlwPskwHc5CWBZC/4bMKB6siTBWwtMrkqXcsNwQiFSzSx5hKdRPUmemrEp3Gg==", - "requires": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "long": "^4.0.0" - } - } - } - }, - "@apollo/utils.createhash": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.createhash/-/utils.createhash-2.0.1.tgz", - "integrity": "sha512-fQO4/ZOP8LcXWvMNhKiee+2KuKyqIcfHrICA+M4lj/h/Lh1H10ICcUtk6N/chnEo5HXu0yejg64wshdaiFitJg==", - "requires": { - "@apollo/utils.isnodelike": "^2.0.1", - "sha.js": "^2.4.11" - } - }, - "@apollo/utils.dropunuseddefinitions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.dropunuseddefinitions/-/utils.dropunuseddefinitions-2.0.1.tgz", - "integrity": "sha512-EsPIBqsSt2BwDsv8Wu76LK5R1KtsVkNoO4b0M5aK0hx+dGg9xJXuqlr7Fo34Dl+y83jmzn+UvEW+t1/GP2melA==", - "requires": {} - }, - "@apollo/utils.fetcher": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.fetcher/-/utils.fetcher-2.0.1.tgz", - "integrity": "sha512-jvvon885hEyWXd4H6zpWeN3tl88QcWnHp5gWF5OPF34uhvoR+DFqcNxs9vrRaBBSY3qda3Qe0bdud7tz2zGx1A==" - }, - "@apollo/utils.isnodelike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.isnodelike/-/utils.isnodelike-2.0.1.tgz", - "integrity": "sha512-w41XyepR+jBEuVpoRM715N2ZD0xMD413UiJx8w5xnAZD2ZkSJnMJBoIzauK83kJpSgNuR6ywbV29jG9NmxjK0Q==" - }, - "@apollo/utils.keyvaluecache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-1.0.1.tgz", - "integrity": "sha512-nLgYLomqjVimEzQ4cdvVQkcryi970NDvcRVPfd0OPeXhBfda38WjBq+WhQFk+czSHrmrSp34YHBxpat0EtiowA==", - "requires": { - "@apollo/utils.logger": "^1.0.0", - "lru-cache": "^7.10.1" - } - }, - "@apollo/utils.logger": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-1.0.0.tgz", - "integrity": "sha512-dx9XrjyisD2pOa+KsB5RcDbWIAdgC91gJfeyLCgy0ctJMjQe7yZK5kdWaWlaOoCeX0z6YI9iYlg7vMPyMpQF3Q==" - }, - "@apollo/utils.printwithreducedwhitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.printwithreducedwhitespace/-/utils.printwithreducedwhitespace-2.0.1.tgz", - "integrity": "sha512-9M4LUXV/fQBh8vZWlLvb/HyyhjJ77/I5ZKu+NBWV/BmYGyRmoEP9EVAy7LCVoY3t8BDcyCAGfxJaLFCSuQkPUg==", - "requires": {} - }, - "@apollo/utils.removealiases": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.removealiases/-/utils.removealiases-2.0.1.tgz", - "integrity": "sha512-0joRc2HBO4u594Op1nev+mUF6yRnxoUH64xw8x3bX7n8QBDYdeYgY4tF0vJReTy+zdn2xv6fMsquATSgC722FA==", - "requires": {} - }, - "@apollo/utils.sortast": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.sortast/-/utils.sortast-2.0.1.tgz", - "integrity": "sha512-eciIavsWpJ09za1pn37wpsCGrQNXUhM0TktnZmHwO+Zy9O4fu/WdB4+5BvVhFiZYOXvfjzJUcc+hsIV8RUOtMw==", - "requires": { - "lodash.sortby": "^4.7.0" - } - }, - "@apollo/utils.stripsensitiveliterals": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.stripsensitiveliterals/-/utils.stripsensitiveliterals-2.0.1.tgz", - "integrity": "sha512-QJs7HtzXS/JIPMKWimFnUMK7VjkGQTzqD9bKD1h3iuPAqLsxd0mUNVbkYOPTsDhUKgcvUOfOqOJWYohAKMvcSA==", - "requires": {} - }, - "@apollo/utils.usagereporting": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@apollo/utils.usagereporting/-/utils.usagereporting-2.1.0.tgz", - "integrity": "sha512-LPSlBrn+S17oBy5eWkrRSGb98sWmnEzo3DPTZgp8IQc8sJe0prDgDuppGq4NeQlpoqEHz0hQeYHAOA0Z3aQsxQ==", - "requires": { - "@apollo/usage-reporting-protobuf": "^4.1.0", - "@apollo/utils.dropunuseddefinitions": "^2.0.1", - "@apollo/utils.printwithreducedwhitespace": "^2.0.1", - "@apollo/utils.removealiases": "2.0.1", - "@apollo/utils.sortast": "^2.0.1", - "@apollo/utils.stripsensitiveliterals": "^2.0.1" - } - }, - "@apollo/utils.withrequired": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@apollo/utils.withrequired/-/utils.withrequired-2.0.1.tgz", - "integrity": "sha512-YBDiuAX9i1lLc6GeTy1m7DGLFn/gMnvXqlalOIMjM7DeOgIacEjjfwPqb0M1CQ2v11HhR15d1NmxJoRCfrNqcA==" - }, - "@graphql-tools/merge": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.4.2.tgz", - "integrity": "sha512-XbrHAaj8yDuINph+sAfuq3QCZ/tKblrTLOpirK0+CAgNlZUCHs0Fa+xtMUURgwCVThLle1AF7svJCxFizygLsw==", - "requires": { - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0" - } - }, - "@graphql-tools/schema": { - "version": "9.0.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-9.0.19.tgz", - "integrity": "sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w==", - "requires": { - "@graphql-tools/merge": "^8.4.1", - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0", - "value-or-promise": "^1.0.12" - } - }, - "@graphql-tools/utils": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", - "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", - "requires": { - "@graphql-typed-document-node/core": "^3.1.1", - "tslib": "^2.4.0" - } - }, - "@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", - "requires": {} - }, - "@josephg/resolvable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@josephg/resolvable/-/resolvable-1.0.1.tgz", - "integrity": "sha512-CtzORUwWTTOTqfVtHaKRJ0I1kNQd1bpn3sUh8I3nJDVY+5/M/Oe1DnEWzPQvqq/xPIIkzzzIP7mfCoAjFRvDhg==" - }, - "@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" - }, - "@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" - }, - "@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" - }, - "@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" - }, - "@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "requires": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" - }, - "@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" - }, - "@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" - }, - "@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" - }, - "@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" - }, - "@types/body-parser": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", - "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", - "requires": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "@types/connect": { - "version": "3.4.35", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", - "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", - "requires": { - "@types/node": "*" - } - }, - "@types/express": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.14.tgz", - "integrity": "sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==", - "requires": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.18", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "@types/express-serve-static-core": { - "version": "4.17.31", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz", - "integrity": "sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==", - "requires": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*" - } - }, - "@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" - }, - "@types/mime": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", - "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" - }, - "@types/node": { - "version": "10.17.60", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", - "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" - }, - "@types/node-fetch": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.4.tgz", - "integrity": "sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==", - "requires": { - "@types/node": "*", - "form-data": "^3.0.0" - } - }, - "@types/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" - }, - "@types/range-parser": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", - "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" - }, - "@types/serve-static": { - "version": "1.13.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", - "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", - "requires": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "requires": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - } - }, - "ansi-color": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/ansi-color/-/ansi-color-0.2.1.tgz", - "integrity": "sha512-bF6xLaZBLpOQzgYUtYEhJx090nPSZk1BQ/q2oyBK9aMMcJHzx9uXGCjI2Y+LebsN4Jwoykr0V9whbPiogdyHoQ==" - }, - "apollo-reporting-protobuf": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/apollo-reporting-protobuf/-/apollo-reporting-protobuf-3.4.0.tgz", - "integrity": "sha512-h0u3EbC/9RpihWOmcSsvTW2O6RXVaD/mPEjfrPkxRPTEPWqncsgOoRJw+wih4OqfH3PvTJvoEIf4LwKrUaqWog==", - "requires": { - "@apollo/protobufjs": "1.2.6" - } - }, - "apollo-server-env": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-4.2.1.tgz", - "integrity": "sha512-vm/7c7ld+zFMxibzqZ7SSa5tBENc4B0uye9LTfjJwGoQFY5xsUPH5FpO5j0bMUDZ8YYNbrF9SNtzc5Cngcr90g==", - "requires": { - "node-fetch": "^2.6.7" - } - }, - "apollo-server-types": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-3.8.0.tgz", - "integrity": "sha512-ZI/8rTE4ww8BHktsVpb91Sdq7Cb71rdSkXELSwdSR0eXu600/sY+1UXhTWdiJvk+Eq5ljqoHLwLbY2+Clq2b9A==", - "requires": { - "@apollo/utils.keyvaluecache": "^1.0.1", - "@apollo/utils.logger": "^1.0.0", - "apollo-reporting-protobuf": "^3.4.0", - "apollo-server-env": "^4.2.1" - } - }, - "array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", - "requires": { - "retry": "0.13.1" - } - }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "peer": true - }, - "body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", - "requires": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - } - }, - "bufrw": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bufrw/-/bufrw-1.3.0.tgz", - "integrity": "sha512-jzQnSbdJqhIltU9O5KUiTtljP9ccw2u5ix59McQy4pV2xGhVLhRZIndY8GIrgh5HjXa6+QJ9AQhOd2QWQizJFQ==", - "requires": { - "ansi-color": "^0.2.1", - "error": "^7.0.0", - "hexer": "^1.5.0", - "xtend": "^4.0.0" - } - }, - "bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" - }, - "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - } - }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "requires": { - "delayed-stream": "~1.0.0" - } - }, - "content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "requires": { - "safe-buffer": "5.2.1" - } - }, - "content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" - }, - "cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" - }, - "cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" - }, - "cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "requires": { - "object-assign": "^4", - "vary": "^1" - } - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" - }, - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - }, - "destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" - }, - "ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" - }, - "error": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/error/-/error-7.0.2.tgz", - "integrity": "sha512-UtVv4l5MhijsYUxPJo4390gzfZvAnTHreNnDjnTZaKIiZ/SemXxAhBkYSKtWa5RtBXbLP8tMgn/n0RUa/H7jXw==", - "requires": { - "string-template": "~0.2.1", - "xtend": "~4.0.0" - } - }, - "escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" - }, - "express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", - "requires": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.2", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - } - }, - "finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "requires": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - } - }, - "form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, - "forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" - }, - "fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", - "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" - } - }, - "graphql": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", - "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==" - }, - "graphql-tag": { - "version": "2.12.6", - "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", - "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", - "requires": { - "tslib": "^2.1.0" - } - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==" - }, - "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" - }, - "hexer": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/hexer/-/hexer-1.5.0.tgz", - "integrity": "sha512-dyrPC8KzBzUJ19QTIo1gXNqIISRXQ0NwteW6OeQHRN4ZuZeHkdODfj0zHBdOlHbRY8GqbqK57C9oWSvQZizFsg==", - "requires": { - "ansi-color": "^0.2.1", - "minimist": "^1.1.0", - "process": "^0.10.0", - "xtend": "^4.0.0" - } - }, - "http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "requires": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - } - }, - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" - }, - "is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", - "peer": true - }, - "jaeger-client": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/jaeger-client/-/jaeger-client-3.19.0.tgz", - "integrity": "sha512-M0c7cKHmdyEUtjemnJyx/y9uX16XHocL46yQvyqDlPdvAcwPDbHrIbKjQdBqtiE4apQ/9dmr+ZLJYYPGnurgpw==", - "requires": { - "node-int64": "^0.4.0", - "opentracing": "^0.14.4", - "thriftrw": "^3.5.0", - "uuid": "^8.3.2", - "xorshift": "^1.1.1" - } - }, - "lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" - }, - "lodash.xorby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.xorby/-/lodash.xorby-4.7.0.tgz", - "integrity": "sha512-gYiD6nvuQy0AEkMoUju+t4f4Rn18fjsLB/7x7YZFqtFT9kmegRLrj/uGEQVyVDy7otTmSrIMXNOk2wwuLcfHCQ==" - }, - "loglevel": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.0.tgz", - "integrity": "sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA==" - }, - "long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" - }, - "lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==" - }, - "media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" - }, - "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" - }, - "methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" - }, - "mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" - }, - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "requires": { - "mime-db": "1.52.0" - } - }, - "minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" - }, - "node-abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" - }, - "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "requires": { - "whatwg-url": "^5.0.0" - } - }, - "node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==" - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" - }, - "object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" - }, - "on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "requires": { - "ee-first": "1.1.1" - } - }, - "opentracing": { - "version": "0.14.7", - "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", - "integrity": "sha512-vz9iS7MJ5+Bp1URw8Khvdyw1H/hGvzHWlKQ7eRrQojSCDL1/SrWfrY9QebLw97n2deyRtzHRC3MkQfVNUCo91Q==" - }, - "parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" - }, - "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" - }, - "process": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/process/-/process-0.10.1.tgz", - "integrity": "sha512-dyIett8dgGIZ/TXKUzeYExt7WA6ldDzys9vTDU/cCA9L17Ypme+KzS+NjQCjpn9xsvi/shbMC+yP/BcFMBz0NA==" - }, - "proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "requires": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - } - }, - "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "requires": { - "side-channel": "^1.0.4" - } - }, - "range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" - }, - "raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "requires": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - } - }, - "retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==" - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "requires": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "dependencies": { - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - } - } - }, - "serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "requires": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - } - }, - "setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - } - }, - "statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" - }, - "string-template": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz", - "integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==" - }, - "thriftrw": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/thriftrw/-/thriftrw-3.12.0.tgz", - "integrity": "sha512-4YZvR4DPEI41n4Opwr4jmrLGG4hndxr7387kzRFIIzxHQjarPusH4lGXrugvgb7TtPrfZVTpZCVe44/xUxowEw==", - "requires": { - "bufrw": "^1.3.0", - "error": "7.0.2", - "long": "^2.4.0" - }, - "dependencies": { - "long": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/long/-/long-2.4.0.tgz", - "integrity": "sha512-ijUtjmO/n2A5PaosNG9ZGDsQ3vxJg7ZW8vsY8Kp0f2yIZWhSJvjmegV7t+9RPQKxKrvj8yKGehhS+po14hPLGQ==" - } - } - }, - "toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" - }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" - }, - "type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "requires": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - } - }, - "typescript": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", - "dev": true - }, - "unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" - }, - "utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" - }, - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" - }, - "value-or-promise": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/value-or-promise/-/value-or-promise-1.0.12.tgz", - "integrity": "sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==" - }, - "vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" - }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==" - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "xorshift": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/xorshift/-/xorshift-1.2.0.tgz", - "integrity": "sha512-iYgNnGyeeJ4t6U11NpA/QiKy+PXn5Aa3Azg5qkwIFz1tBLllQrjjsk9yzD7IAK0naNU4JxdeDgqW9ov4u/hc4g==" - }, - "xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" - }, - "zipkin": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/zipkin/-/zipkin-0.22.0.tgz", - "integrity": "sha512-SZgorBAvywnj5R26mqsFP+2MRUsBXGg0B8GLRLw9R0FeE4alMUAfhXYvzeAt2+MvkXne9QdQyziuqO5oXNQ0Sg==", - "peer": true, - "requires": { - "base64-js": "^1.1.2", - "is-promise": "^2.1.0" - } - }, - "zipkin-javascript-opentracing": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/zipkin-javascript-opentracing/-/zipkin-javascript-opentracing-3.0.0.tgz", - "integrity": "sha512-ubqMBXVXw0VvioQd6BlaozEyU00P1qV3rwtnD30YAKPKvNQb7Q1MC+BMa/G4T/JM6Jcrpyg3dHn31SeE4FZvjw==", - "requires": {} - }, - "zipkin-transport-http": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/zipkin-transport-http/-/zipkin-transport-http-0.22.0.tgz", - "integrity": "sha512-bjM2fm/hurseBuvpyB8mYCBBGOz3gc2f9KUSQl71LGxmgYcUeXvDHJyE9MgzWMhl+3HcD8l5nNn6OmdX63he+g==", - "peer": true, - "requires": { - "zipkin": "^0.22.0" - } - } - } -} diff --git a/dockerfiles/tracing/zipkin-subgraph/package.json b/dockerfiles/tracing/zipkin-subgraph/package.json deleted file mode 100644 index de7de6f96b..0000000000 --- a/dockerfiles/tracing/zipkin-subgraph/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "datadog-subgraph", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "start": "node dist/index.js" - }, - "author": "", - "license": "ISC", - "dependencies": { - "@apollo/federation": "^0.38.0", - "@apollo/server": "^4.0.0", - "express": "^4.18.1", - "graphql": "^16.5.0", - "graphql-tag": "^2.12.6", - "jaeger-client": "^3.19.0", - "opentracing": "^0.14.7", - "zipkin-javascript-opentracing": "^3.0.0" - }, - "devDependencies": { - "typescript": "5.5.3" - } -} diff --git a/dockerfiles/tracing/zipkin-subgraph/recorder.ts b/dockerfiles/tracing/zipkin-subgraph/recorder.ts deleted file mode 100644 index 75c57ee69b..0000000000 --- a/dockerfiles/tracing/zipkin-subgraph/recorder.ts +++ /dev/null @@ -1,8 +0,0 @@ -const { BatchRecorder } = require("zipkin"); -const { HttpLogger } = require("zipkin-transport-http"); - -module.exports.recorder = new BatchRecorder({ - logger: new HttpLogger({ - endpoint: "http://zipkin:9411/api/v2/spans" - }) -}); \ No newline at end of file diff --git a/dockerfiles/tracing/zipkin-subgraph/tsconfig.json b/dockerfiles/tracing/zipkin-subgraph/tsconfig.json deleted file mode 100644 index ff87005192..0000000000 --- a/dockerfiles/tracing/zipkin-subgraph/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "esModuleInterop": true, - "target": "es6", - "moduleResolution": "node", - "sourceMap": true, - "outDir": "dist" - }, - "lib": ["es2015"] -} diff --git a/docs/shared/batch-processor-preamble.mdx b/docs/shared/batch-processor-preamble.mdx index 978ba15c1f..77b32d252d 100644 --- a/docs/shared/batch-processor-preamble.mdx +++ b/docs/shared/batch-processor-preamble.mdx @@ -2,8 +2,16 @@ All exporters support configuration of a batch span processor with `batch_proces You must tune your `batch_processor` configuration if you see any of the following messages in your logs: -* `OpenTelemetry trace error occurred: cannot send span to the batch span processor because the channel is full` +* `OpenTelemetry trace error occurred: cannot send message to batch processor '-tracing' as the channel is full` * `OpenTelemetry metrics error occurred: cannot send span to the batch span processor because the channel is full` -The exact settings depend on the bandwidth available for you to send data to your application peformance monitor (APM) and the bandwidth configuration of your APM. Expect to tune these settings over time as your application changes. +The exact settings depend on the bandwidth available for you to send data to your application performance monitor (APM) and the bandwidth configuration of your APM. Expect to tune these settings over time as your application changes. + +You can see how many spans are being dropped by enabling metrics export and looking at the: + +- `apollo.router.telemetry.batch_processor.errors` - The number of errors encountered by exporter batch processors. + - `name`: One of `apollo-tracing`, `datadog-tracing`, `otlp-tracing`, `zipkin-tracing`. + - `error` = One of `channel closed`, `channel full`. + +By looking at the rate of batch processor errors you can decide how to tune your batch processor settings. diff --git a/docs/shared/config/apq.mdx b/docs/shared/config/apq.mdx new file mode 100644 index 0000000000..fab7cb309f --- /dev/null +++ b/docs/shared/config/apq.mdx @@ -0,0 +1,30 @@ +### `apq` + +```yaml title="apq" +apq: + enabled: true + router: + cache: + in_memory: + limit: 1 + redis: + namespace: example_namespace + password: example_password + pool_size: 1 + required_to_start: false + reset_ttl: true + timeout: null + tls: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + ttl: null + urls: + - http://example.com/urls_item + username: example_username + subgraph: + all: + enabled: false + subgraphs: {} +``` diff --git a/docs/shared/config/authentication.mdx b/docs/shared/config/authentication.mdx new file mode 100644 index 0000000000..323ef3c8d0 --- /dev/null +++ b/docs/shared/config/authentication.mdx @@ -0,0 +1,44 @@ +### `authentication` + + + +```yaml title="authentication" +authentication: + connector: + sources: {} + router: + jwt: + header_name: authorization + header_value_prefix: Bearer + ignore_other_prefixes: false + jwks: + - algorithms: null + headers: + - name: example_name + value: example_value + issuer: example_issuer + poll_interval: + nanos: 0 + secs: 60 + url: http://service.example.com/url + on_error: Continue + sources: + - name: authorization + type: header + value_prefix: Bearer + subgraph: + all: + aws_sig_v4: + hardcoded: + access_key_id: example_access_key_id + assume_role: + external_id: example_external_id + role_arn: example_role_arn + session_name: example_session_name + region: example_region + secret_access_key: example_secret_access_key + service_name: example_service_name + subgraphs: {} +``` + + diff --git a/docs/shared/config/authorization.mdx b/docs/shared/config/authorization.mdx new file mode 100644 index 0000000000..a26d2b8a93 --- /dev/null +++ b/docs/shared/config/authorization.mdx @@ -0,0 +1,13 @@ +### `authorization` + +```yaml title="authorization" +authorization: + directives: + dry_run: false + enabled: true + errors: + log: true + response: errors + reject_unauthorized: false + require_authentication: false +``` diff --git a/docs/shared/config/batching.mdx b/docs/shared/config/batching.mdx new file mode 100644 index 0000000000..6f2bea7318 --- /dev/null +++ b/docs/shared/config/batching.mdx @@ -0,0 +1,12 @@ +### `batching` + +```yaml title="batching" +batching: + enabled: false + maximum_size: null + mode: batch_http_link + subgraph: + all: + enabled: false + subgraphs: {} +``` diff --git a/docs/shared/config/connectors.mdx b/docs/shared/config/connectors.mdx new file mode 100644 index 0000000000..a4a679902b --- /dev/null +++ b/docs/shared/config/connectors.mdx @@ -0,0 +1,10 @@ +### `connectors` + +```yaml title="connectors" +connectors: + debug_extensions: false + expose_sources_in_context: false + max_requests_per_operation_per_source: null + sources: {} + subgraphs: {} +``` diff --git a/docs/shared/config/coprocessor.mdx b/docs/shared/config/coprocessor.mdx new file mode 100644 index 0000000000..49c37e5fea --- /dev/null +++ b/docs/shared/config/coprocessor.mdx @@ -0,0 +1,98 @@ +### `coprocessor` + + + +```yaml title="coprocessor" +coprocessor: + client: + dns_resolution_strategy: ipv4_only + experimental_http2: enable + execution: + request: + body: false + context: false + headers: false + method: false + query_plan: false + sdl: false + response: + body: false + context: false + headers: false + sdl: false + status_code: false + router: + request: + body: false + condition: + eq: + - false + - false + context: false + headers: false + method: false + path: false + sdl: false + response: + body: false + condition: + eq: + - false + - false + context: false + headers: false + sdl: false + status_code: false + subgraph: + all: + request: + body: false + condition: + eq: + - false + - false + context: false + headers: false + method: false + service_name: false + subgraph_request_id: false + uri: false + response: + body: false + condition: + eq: + - false + - false + context: false + headers: false + service_name: false + status_code: false + subgraph_request_id: false + supergraph: + request: + body: false + condition: + eq: + - false + - false + context: false + headers: false + method: false + sdl: false + response: + body: false + condition: + eq: + - false + - false + context: false + headers: false + sdl: false + status_code: false + timeout: + nanos: 0 + secs: 1 + url: http://service.example.com/url +``` + + diff --git a/docs/shared/config/cors.mdx b/docs/shared/config/cors.mdx new file mode 100644 index 0000000000..76d5fb0748 --- /dev/null +++ b/docs/shared/config/cors.mdx @@ -0,0 +1,36 @@ +### `cors` + +```yaml title="cors" +cors: + allow_any_origin: false + allow_credentials: false + allow_headers: [] + expose_headers: null + max_age: null + methods: + - GET + - POST + - OPTIONS + policies: + - origins: + - https://studio.apollographql.com + - https://myapp.com + allow_credentials: false + allow_headers: [] + expose_headers: [] + # methods not specified - uses global defaults [GET, POST, OPTIONS] + - origins: + - https://restricted.com + methods: [] # Explicitly no methods allowed + - origins: + - https://api.example.com + match_origins: + - "^https://.*\\.example\\.com$" + allow_headers: + - content-type + - authorization + methods: + - GET + - POST + # Specific methods override global defaults +``` diff --git a/docs/shared/config/csrf.mdx b/docs/shared/config/csrf.mdx new file mode 100644 index 0000000000..9591056a28 --- /dev/null +++ b/docs/shared/config/csrf.mdx @@ -0,0 +1,9 @@ +### `csrf` + +```yaml title="csrf" +csrf: + required_headers: + - x-apollo-operation-name + - apollo-require-preflight + unsafe_disabled: false +``` diff --git a/docs/shared/config/demand_control.mdx b/docs/shared/config/demand_control.mdx new file mode 100644 index 0000000000..b94def2bf3 --- /dev/null +++ b/docs/shared/config/demand_control.mdx @@ -0,0 +1,11 @@ +### `demand_control` + +```yaml title="demand_control" +demand_control: + enabled: false + mode: measure + strategy: + static_estimated: + list_size: 0 + max: 0.0 +``` diff --git a/docs/shared/config/experimental_chaos.mdx b/docs/shared/config/experimental_chaos.mdx new file mode 100644 index 0000000000..0b50502cfd --- /dev/null +++ b/docs/shared/config/experimental_chaos.mdx @@ -0,0 +1,6 @@ +### `experimental_chaos` + +```yaml title="experimental_chaos" +experimental_chaos: + force_reload: null +``` diff --git a/docs/shared/config/experimental_type_conditioned_fetching.mdx b/docs/shared/config/experimental_type_conditioned_fetching.mdx new file mode 100644 index 0000000000..2902967d69 --- /dev/null +++ b/docs/shared/config/experimental_type_conditioned_fetching.mdx @@ -0,0 +1,5 @@ +### `experimental_type_conditioned_fetching` + +```yaml title="experimental_type_conditioned_fetching" +experimental_type_conditioned_fetching: false +``` diff --git a/docs/shared/config/fleet_detector.mdx b/docs/shared/config/fleet_detector.mdx new file mode 100644 index 0000000000..eb30000b57 --- /dev/null +++ b/docs/shared/config/fleet_detector.mdx @@ -0,0 +1,5 @@ +### `fleet_detector` + +```yaml title="fleet_detector" +fleet_detector: {} +``` diff --git a/docs/shared/config/forbid_mutations.mdx b/docs/shared/config/forbid_mutations.mdx new file mode 100644 index 0000000000..ffab6b2f68 --- /dev/null +++ b/docs/shared/config/forbid_mutations.mdx @@ -0,0 +1,5 @@ +### `forbid_mutations` + +```yaml title="forbid_mutations" +forbid_mutations: false +``` diff --git a/docs/shared/config/headers.mdx b/docs/shared/config/headers.mdx new file mode 100644 index 0000000000..8ae504126d --- /dev/null +++ b/docs/shared/config/headers.mdx @@ -0,0 +1,11 @@ +### `headers` + +```yaml title="headers" +headers: + all: + request: + - insert: + name: example_name + value: example_value + subgraphs: {} +``` diff --git a/docs/shared/config/health_check.mdx b/docs/shared/config/health_check.mdx new file mode 100644 index 0000000000..7e49924a48 --- /dev/null +++ b/docs/shared/config/health_check.mdx @@ -0,0 +1,13 @@ +### `health_check` + +```yaml title="health_check" +health_check: + enabled: true + listen: example_listen + path: /health + readiness: + allowed: 100 + interval: + sampling: 0s + unready: null +``` diff --git a/docs/shared/config/homepage.mdx b/docs/shared/config/homepage.mdx new file mode 100644 index 0000000000..3bfe701c5a --- /dev/null +++ b/docs/shared/config/homepage.mdx @@ -0,0 +1,7 @@ +### `homepage` + +```yaml title="homepage" +homepage: + enabled: true + graph_ref: null +``` diff --git a/docs/shared/config/include_subgraph_errors.mdx b/docs/shared/config/include_subgraph_errors.mdx new file mode 100644 index 0000000000..34d3a2a2a5 --- /dev/null +++ b/docs/shared/config/include_subgraph_errors.mdx @@ -0,0 +1,7 @@ +### `include_subgraph_errors` + +```yaml title="include_subgraph_errors" +include_subgraph_errors: + all: false + subgraphs: {} +``` diff --git a/docs/shared/config/license_enforcement.mdx b/docs/shared/config/license_enforcement.mdx new file mode 100644 index 0000000000..d926504cb1 --- /dev/null +++ b/docs/shared/config/license_enforcement.mdx @@ -0,0 +1,5 @@ +### `license_enforcement` + +```yaml title="license_enforcement" +license_enforcement: {} +``` diff --git a/docs/shared/config/limits.mdx b/docs/shared/config/limits.mdx new file mode 100644 index 0000000000..b617c36d7c --- /dev/null +++ b/docs/shared/config/limits.mdx @@ -0,0 +1,16 @@ +### `limits` + +```yaml title="limits" +limits: + http1_max_request_buf_size: null + http1_max_request_headers: null + http_max_request_bytes: 2000000 + introspection_max_depth: true + max_aliases: null + max_depth: null + max_height: null + max_root_fields: null + parser_max_recursion: 500 + parser_max_tokens: 15000 + warn_only: false +``` diff --git a/docs/shared/config/override_subgraph_url.mdx b/docs/shared/config/override_subgraph_url.mdx new file mode 100644 index 0000000000..cb42d11fe9 --- /dev/null +++ b/docs/shared/config/override_subgraph_url.mdx @@ -0,0 +1,5 @@ +### `override_subgraph_url` + +```yaml title="override_subgraph_url" +override_subgraph_url: {} +``` diff --git a/docs/shared/config/persisted_queries.mdx b/docs/shared/config/persisted_queries.mdx new file mode 100644 index 0000000000..2661245424 --- /dev/null +++ b/docs/shared/config/persisted_queries.mdx @@ -0,0 +1,15 @@ +### `persisted_queries` + +```yaml title="persisted_queries" +persisted_queries: + enabled: false + experimental_prewarm_query_plan_cache: + on_reload: true + on_startup: false + hot_reload: false + local_manifests: null + log_unknown: false + safelist: + enabled: false + require_id: false +``` diff --git a/docs/shared/config/plugins.mdx b/docs/shared/config/plugins.mdx new file mode 100644 index 0000000000..39c73550c9 --- /dev/null +++ b/docs/shared/config/plugins.mdx @@ -0,0 +1,5 @@ +### `plugins` + +```yaml title="plugins" +plugins: unknown_type_plugins +``` diff --git a/docs/shared/config/preview_entity_cache.mdx b/docs/shared/config/preview_entity_cache.mdx new file mode 100644 index 0000000000..f833cc8e0d --- /dev/null +++ b/docs/shared/config/preview_entity_cache.mdx @@ -0,0 +1,54 @@ +### `preview_entity_cache` + + + +```yaml title="preview_entity_cache" +preview_entity_cache: + enabled: false + expose_keys_in_context: false + invalidation: + concurrent_requests: 10 + listen: example_listen + path: example_path + scan_count: 1000 + metrics: + enabled: false + separate_per_type: false + ttl: 30s + subgraph: + all: + enabled: true + invalidation: + enabled: false + shared_key: '' + private_id: null + redis: + namespace: example_namespace + password: example_password + pool_size: 1 + required_to_start: false + reset_ttl: true + timeout: null + tls: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + ttl: null + urls: + - http://example.com/urls_item + username: example_username + ttl: 30s + subgraphs: {} +``` + + + +When using Redis as the cache backend, the router emits additional Redis-specific metrics to help monitor cache performance: + +- **Connection metrics**: Track Redis connection establishment and health +- **Command metrics**: Monitor Redis command execution, queue length, and redelivery counts +- **Performance metrics**: Measure average latency, network latency, and request/response sizes +- **Operational metrics**: Help identify connection issues, network problems, or performance bottlenecks + +These metrics use the `kind` attribute to distinguish between different Redis cache uses (e.g., `entity`). For the complete list of Redis metrics and their descriptions, see the [Redis Cache metrics documentation](/router/configuration/telemetry/instrumentation/standard-instruments/#redis-cache). diff --git a/docs/shared/config/preview_file_uploads.mdx b/docs/shared/config/preview_file_uploads.mdx new file mode 100644 index 0000000000..b598822fab --- /dev/null +++ b/docs/shared/config/preview_file_uploads.mdx @@ -0,0 +1,13 @@ +### `preview_file_uploads` + +```yaml title="preview_file_uploads" +preview_file_uploads: + enabled: false + protocols: + multipart: + enabled: true + limits: + max_file_size: example_max_file_size + max_files: 0 + mode: stream +``` diff --git a/docs/shared/config/progressive_override.mdx b/docs/shared/config/progressive_override.mdx new file mode 100644 index 0000000000..5265cb86a1 --- /dev/null +++ b/docs/shared/config/progressive_override.mdx @@ -0,0 +1,5 @@ +### `progressive_override` + +```yaml title="progressive_override" +progressive_override: {} +``` diff --git a/docs/shared/config/rhai.mdx b/docs/shared/config/rhai.mdx new file mode 100644 index 0000000000..a4e44353d3 --- /dev/null +++ b/docs/shared/config/rhai.mdx @@ -0,0 +1,7 @@ +### `rhai` + +```yaml title="rhai" +rhai: + main: example_main + scripts: example_scripts +``` diff --git a/docs/shared/config/sandbox.mdx b/docs/shared/config/sandbox.mdx new file mode 100644 index 0000000000..0cf620f23d --- /dev/null +++ b/docs/shared/config/sandbox.mdx @@ -0,0 +1,6 @@ +### `sandbox` + +```yaml title="sandbox" +sandbox: + enabled: false +``` diff --git a/docs/shared/config/subscription.mdx b/docs/shared/config/subscription.mdx new file mode 100644 index 0000000000..33b1fa84ad --- /dev/null +++ b/docs/shared/config/subscription.mdx @@ -0,0 +1,22 @@ +### `subscription` + +```yaml title="subscription" +subscription: + enable_deduplication: true + enabled: true + max_opened_subscriptions: null + mode: + callback: + heartbeat_interval: disabled + listen: example_listen + path: example_path + public_url: http://service.example.com/public_url + subgraphs: [] + passthrough: + all: + heartbeat_interval: disabled + path: null + protocol: graphql_ws + subgraphs: {} + queue_capacity: null +``` diff --git a/docs/shared/config/supergraph.mdx b/docs/shared/config/supergraph.mdx new file mode 100644 index 0000000000..289f8660cc --- /dev/null +++ b/docs/shared/config/supergraph.mdx @@ -0,0 +1,42 @@ +### `supergraph` + + + +```yaml title="supergraph" +supergraph: + defer_support: true + early_cancel: false + experimental_log_on_broken_pipe: false + generate_query_fragments: true + introspection: false + listen: example_listen + path: / + query_planning: + cache: + in_memory: + limit: 1 + redis: + namespace: example_namespace + password: example_password + pool_size: 1 + required_to_start: false + reset_ttl: true + timeout: null + tls: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + ttl: + nanos: 0 + secs: 2592000 + urls: + - http://example.com/urls_item + username: example_username + experimental_paths_limit: null + experimental_plans_limit: null + experimental_reuse_query_plans: false + warmed_up_queries: null +``` + + diff --git a/docs/shared/config/telemetry.mdx b/docs/shared/config/telemetry.mdx new file mode 100644 index 0000000000..715b3d5c66 --- /dev/null +++ b/docs/shared/config/telemetry.mdx @@ -0,0 +1,698 @@ +### `telemetry` + + + +```yaml title="telemetry" +telemetry: + apollo: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + nanos: 0 + secs: 30 + max_queue_size: 2048 + scheduled_delay: + nanos: 0 + secs: 5 + buffer_size: 10000 + client_name_header: apollographql-client-name + client_version_header: apollographql-client-version + endpoint: https://usage-reporting.api.apollographql.com/api/ingress/traces + errors: + preview_extended_error_metrics: disabled + subgraph: + all: + redact: true + redaction_policy: strict + send: true + subgraphs: {} + experimental_local_field_metrics: false + experimental_otlp_endpoint: https://usage-reporting.api.apollographql.com/ + experimental_otlp_tracing_protocol: grpc + field_level_instrumentation_sampler: 0.0 + metrics_reference_mode: extended + otlp_tracing_sampler: 0.0 + send_headers: + only: + - example_only_item + send_variable_values: + only: + - example_only_item + signature_normalization_algorithm: legacy + exporters: + logging: + common: + resource: {} + service_name: null + service_namespace: null + stdout: + enabled: true + format: + json: + display_current_span: false + display_filename: false + display_level: true + display_line_number: false + display_resource: true + display_span_id: true + display_span_list: true + display_target: true + display_thread_id: false + display_thread_name: false + display_timestamp: true + display_trace_id: hexadecimal + span_attributes: [] + rate_limit: + capacity: 1 + enabled: false + interval: + nanos: 0 + secs: 1 + tty_format: + json: + display_current_span: false + display_filename: false + display_level: true + display_line_number: false + display_resource: true + display_span_id: true + display_span_list: true + display_target: true + display_thread_id: false + display_thread_name: false + display_timestamp: true + display_trace_id: hexadecimal + span_attributes: [] + metrics: + common: + buckets: + - 0.001 + - 0.005 + - 0.015 + - 0.05 + - 0.1 + - 0.2 + - 0.3 + - 0.4 + - 0.5 + - 1.0 + - 5.0 + - 10.0 + resource: {} + service_name: null + service_namespace: null + views: + - aggregation: + histogram: + buckets: + - 0.0 + allowed_attribute_keys: + - example_allowed_attribute_keys_item + description: example_description + name: example_name + unit: example_unit + otlp: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + nanos: 0 + secs: 30 + max_queue_size: 2048 + scheduled_delay: + nanos: 0 + secs: 5 + enabled: false + endpoint: example_endpoint + grpc: + ca: null + cert: null + domain_name: null + key: null + metadata: {} + http: + headers: {} + protocol: grpc + temporality: cumulative + prometheus: + enabled: false + listen: example_listen + path: /metrics + tracing: + common: + max_attributes_per_event: 128 + max_attributes_per_link: 128 + max_attributes_per_span: 128 + max_events_per_span: 128 + max_links_per_span: 128 + parent_based_sampler: true + preview_datadog_agent_sampling: null + resource: {} + sampler: 0.0 + service_name: null + service_namespace: null + datadog: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + nanos: 0 + secs: 30 + max_queue_size: 2048 + scheduled_delay: + nanos: 0 + secs: 5 + enable_span_mapping: true + enabled: false + endpoint: example_endpoint + fixed_span_names: true + resource_mapping: {} + span_metrics: + connect: true + connect_request: true + execution: true + http_request: true + parse_query: true + query_planning: true + request: true + router: true + subgraph: true + subgraph_request: true + supergraph: true + experimental_response_trace_id: + enabled: false + format: hexadecimal + header_name: example_header_name + otlp: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + nanos: 0 + secs: 30 + max_queue_size: 2048 + scheduled_delay: + nanos: 0 + secs: 5 + enabled: false + endpoint: example_endpoint + grpc: + ca: null + cert: null + domain_name: null + key: null + metadata: {} + http: + headers: {} + protocol: grpc + temporality: cumulative + propagation: + aws_xray: false + baggage: false + datadog: false + jaeger: false + request: + format: hexadecimal + header_name: example_header_name + trace_context: false + zipkin: false + zipkin: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + nanos: 0 + secs: 30 + max_queue_size: 2048 + scheduled_delay: + nanos: 0 + secs: 5 + enabled: false + endpoint: example_endpoint + instrumentation: + events: + connector: + error: + condition: + eq: + - false + - false + level: info + request: + condition: + eq: + - false + - false + level: info + response: + condition: + eq: + - false + - false + level: info + router: + error: + condition: + eq: + - false + - false + level: info + request: + condition: + eq: + - false + - false + level: info + response: + condition: + eq: + - false + - false + level: info + subgraph: + error: + condition: + eq: + - false + - false + level: info + request: + condition: + eq: + - false + - false + level: info + response: + condition: + eq: + - false + - false + level: info + supergraph: + error: + condition: + eq: + - false + - false + level: info + request: + condition: + eq: + - false + - false + level: info + response: + condition: + eq: + - false + - false + level: info + instruments: + cache: + apollo.router.operations.entity.cache: + attributes: + graphql.type.name: + alias: example_alias + connector: + http.client.request.body.size: + attributes: + connector.http.method: + alias: example_alias + connector.source.name: + alias: example_alias + connector.url.template: + alias: example_alias + subgraph.name: + alias: example_alias + http.client.request.duration: + attributes: + connector.http.method: + alias: example_alias + connector.source.name: + alias: example_alias + connector.url.template: + alias: example_alias + subgraph.name: + alias: example_alias + http.client.response.body.size: + attributes: + connector.http.method: + alias: example_alias + connector.source.name: + alias: example_alias + connector.url.template: + alias: example_alias + subgraph.name: + alias: example_alias + default_requirement_level: none + graphql: + field.execution: + attributes: + graphql.field.name: + alias: example_alias + graphql.field.type: + alias: example_alias + graphql.list.length: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.type.name: + alias: example_alias + list.length: + attributes: + graphql.field.name: + alias: example_alias + graphql.field.type: + alias: example_alias + graphql.list.length: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.type.name: + alias: example_alias + router: + http.server.active_requests: + attributes: + http.request.method: false + server.address: false + server.port: false + url.scheme: false + http.server.request.body.size: + attributes: + baggage: null + dd.trace_id: + alias: example_alias + error.type: + alias: example_alias + http.request.body.size: + alias: example_alias + http.request.method: + alias: example_alias + http.response.body.size: + alias: example_alias + http.response.status_code: + alias: example_alias + http.route: + alias: example_alias + network.local.address: + alias: example_alias + network.local.port: + alias: example_alias + network.peer.address: + alias: example_alias + network.peer.port: + alias: example_alias + network.protocol.name: + alias: example_alias + network.protocol.version: + alias: example_alias + network.transport: + alias: example_alias + network.type: + alias: example_alias + server.address: + alias: example_alias + server.port: + alias: example_alias + trace_id: + alias: example_alias + url.path: + alias: example_alias + url.query: + alias: example_alias + url.scheme: + alias: example_alias + user_agent.original: + alias: example_alias + http.server.request.duration: + attributes: + baggage: null + dd.trace_id: + alias: example_alias + error.type: + alias: example_alias + http.request.body.size: + alias: example_alias + http.request.method: + alias: example_alias + http.response.body.size: + alias: example_alias + http.response.status_code: + alias: example_alias + http.route: + alias: example_alias + network.local.address: + alias: example_alias + network.local.port: + alias: example_alias + network.peer.address: + alias: example_alias + network.peer.port: + alias: example_alias + network.protocol.name: + alias: example_alias + network.protocol.version: + alias: example_alias + network.transport: + alias: example_alias + network.type: + alias: example_alias + server.address: + alias: example_alias + server.port: + alias: example_alias + trace_id: + alias: example_alias + url.path: + alias: example_alias + url.query: + alias: example_alias + url.scheme: + alias: example_alias + user_agent.original: + alias: example_alias + http.server.response.body.size: + attributes: + baggage: null + dd.trace_id: + alias: example_alias + error.type: + alias: example_alias + http.request.body.size: + alias: example_alias + http.request.method: + alias: example_alias + http.response.body.size: + alias: example_alias + http.response.status_code: + alias: example_alias + http.route: + alias: example_alias + network.local.address: + alias: example_alias + network.local.port: + alias: example_alias + network.peer.address: + alias: example_alias + network.peer.port: + alias: example_alias + network.protocol.name: + alias: example_alias + network.protocol.version: + alias: example_alias + network.transport: + alias: example_alias + network.type: + alias: example_alias + server.address: + alias: example_alias + server.port: + alias: example_alias + trace_id: + alias: example_alias + url.path: + alias: example_alias + url.query: + alias: example_alias + url.scheme: + alias: example_alias + user_agent.original: + alias: example_alias + subgraph: + http.client.request.body.size: + attributes: + http.request.resend_count: + alias: example_alias + subgraph.graphql.document: + alias: example_alias + subgraph.graphql.operation.name: + alias: example_alias + subgraph.graphql.operation.type: + alias: example_alias + subgraph.name: + alias: example_alias + http.client.request.duration: + attributes: + http.request.resend_count: + alias: example_alias + subgraph.graphql.document: + alias: example_alias + subgraph.graphql.operation.name: + alias: example_alias + subgraph.graphql.operation.type: + alias: example_alias + subgraph.name: + alias: example_alias + http.client.response.body.size: + attributes: + http.request.resend_count: + alias: example_alias + subgraph.graphql.document: + alias: example_alias + subgraph.graphql.operation.name: + alias: example_alias + subgraph.graphql.operation.type: + alias: example_alias + subgraph.name: + alias: example_alias + supergraph: + cost.actual: + attributes: + cost.actual: + alias: example_alias + cost.delta: + alias: example_alias + cost.estimated: + alias: example_alias + cost.result: + alias: example_alias + graphql.document: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.operation.type: + alias: example_alias + cost.delta: + attributes: + cost.actual: + alias: example_alias + cost.delta: + alias: example_alias + cost.estimated: + alias: example_alias + cost.result: + alias: example_alias + graphql.document: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.operation.type: + alias: example_alias + cost.estimated: + attributes: + cost.actual: + alias: example_alias + cost.delta: + alias: example_alias + cost.estimated: + alias: example_alias + cost.result: + alias: example_alias + graphql.document: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.operation.type: + alias: example_alias + spans: + connector: + attributes: + connector.http.method: + alias: example_alias + connector.source.name: + alias: example_alias + connector.url.template: + alias: example_alias + subgraph.name: + alias: example_alias + default_attribute_requirement_level: none + mode: deprecated + router: + attributes: + baggage: null + dd.trace_id: + alias: example_alias + error.type: + alias: example_alias + http.request.body.size: + alias: example_alias + http.request.method: + alias: example_alias + http.response.body.size: + alias: example_alias + http.response.status_code: + alias: example_alias + http.route: + alias: example_alias + network.local.address: + alias: example_alias + network.local.port: + alias: example_alias + network.peer.address: + alias: example_alias + network.peer.port: + alias: example_alias + network.protocol.name: + alias: example_alias + network.protocol.version: + alias: example_alias + network.transport: + alias: example_alias + network.type: + alias: example_alias + server.address: + alias: example_alias + server.port: + alias: example_alias + trace_id: + alias: example_alias + url.path: + alias: example_alias + url.query: + alias: example_alias + url.scheme: + alias: example_alias + user_agent.original: + alias: example_alias + subgraph: + attributes: + http.request.resend_count: + alias: example_alias + subgraph.graphql.document: + alias: example_alias + subgraph.graphql.operation.name: + alias: example_alias + subgraph.graphql.operation.type: + alias: example_alias + subgraph.name: + alias: example_alias + supergraph: + attributes: + cost.actual: + alias: example_alias + cost.delta: + alias: example_alias + cost.estimated: + alias: example_alias + cost.result: + alias: example_alias + graphql.document: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.operation.type: + alias: example_alias +``` + + diff --git a/docs/shared/config/tls.mdx b/docs/shared/config/tls.mdx new file mode 100644 index 0000000000..562517fd75 --- /dev/null +++ b/docs/shared/config/tls.mdx @@ -0,0 +1,23 @@ +### `tls` + +```yaml title="tls" +tls: + connector: + all: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + sources: {} + subgraph: + all: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + subgraphs: {} + supergraph: + certificate: example_certificate + certificate_chain: example_certificate_chain + key: example_key +``` diff --git a/docs/shared/config/traffic_shaping.mdx b/docs/shared/config/traffic_shaping.mdx new file mode 100644 index 0000000000..202aba9661 --- /dev/null +++ b/docs/shared/config/traffic_shaping.mdx @@ -0,0 +1,32 @@ +### `traffic_shaping` + +```yaml title="traffic_shaping" +traffic_shaping: + all: + compression: gzip + deduplicate_query: false + dns_resolution_strategy: ipv4_only + experimental_http2: enable + global_rate_limit: + capacity: 1 + interval: 30s + timeout: null + connector: + all: + compression: gzip + dns_resolution_strategy: ipv4_only + experimental_http2: enable + global_rate_limit: + capacity: 1 + interval: 30s + timeout: null + sources: {} + deduplicate_variables: null + router: + concurrency_limit: 0 + global_rate_limit: + capacity: 1 + interval: 30s + timeout: null + subgraphs: {} +``` diff --git a/docs/shared/diagrams/router-request-lifecycle-overview.mdx b/docs/shared/diagrams/router-request-lifecycle-overview.mdx new file mode 100644 index 0000000000..91233474ac --- /dev/null +++ b/docs/shared/diagrams/router-request-lifecycle-overview.mdx @@ -0,0 +1,29 @@ +```mermaid +flowchart RL + subgraph client["Client"] + end + + subgraph router["Router"] + direction LR + routerService("Router
Service") + supergraphService("Supergraph
Service") + executionService("Execution
Service") + subgraphService("Subgraph
Service") + routerService -->|request| supergraphService -->|request| executionService -->|request| subgraphService + subgraphService -->|response| executionService -->|response| supergraphService -->|response| routerService + + end + + subgraph infra["Your infrastructure"] + direction TB + api1("subgraph A"); + api2("subgraph B"); + api3("subgraph C"); + api1 --- api2 --- api3 + + end + +client -->|request| router -->|request| infra + +infra -->|response| router -->|response| client +``` diff --git a/docs/shared/helm-show-router-output.mdx b/docs/shared/helm-show-router-output.mdx index d0f7035d69..54b8217faf 100644 --- a/docs/shared/helm-show-router-output.mdx +++ b/docs/shared/helm-show-router-output.mdx @@ -1,35 +1,30 @@ ```yaml -Pulled: ghcr.io/apollographql/helm-charts/router:1.31.0 -Digest: sha256:26fbe98268456935cac5b51d44257bf96c02ee919fde8d47a06602ce2cda66a3 # Default values for router. # This is a YAML-formatted file. # Declare variables to be passed into your templates. replicaCount: 1 -# -- See https://www.apollographql.com/docs/router/configuration/overview/#yaml-config-file for yaml structure +# -- See https://www.apollographql.com/docs/graphos/reference/router/configuration#yaml-config-file for yaml structure router: configuration: - supergraph: # HTTP server - listen: 0.0.0.0:80 + supergraph: + listen: 0.0.0.0:4000 health_check: listen: 0.0.0.0:8088 - telemetry: - metrics: - prometheus: - enabled: false - listen: 0.0.0.0:9090 - path: "/metrics" args: - - --hot-reload + - --hot-reload managedFederation: # -- If using managed federation, the graph API key to identify router to Studio apiKey: - # -- If using managed federation, use an existing secret which stores the graph API key instead of creating a new one. + # -- If using managed federation, use existing Secret which stores the graph API key instead of creating a new one. # If set along `managedFederation.apiKey`, a secret with the graph API key will be created using this parameter as name existingSecret: + # -- If using managed federation, the name of the key within the existing Secret which stores the graph API key. + # If set along `managedFederation.apiKey`, a secret with the graph API key will be created using this parameter as key, defaults to using a key of `managedFederationApiKey` + existingSecretKeyRefKey: # -- If using managed federation, the variant of which graph to use graphRef: "" @@ -46,8 +41,8 @@ supergraphFile: # value: debug # extraEnvVars: [] -extraEnvVarsCM: '' -extraEnvVarsSecret: '' +extraEnvVarsCM: "" +extraEnvVarsSecret: "" # An array of extra VolumeMounts # Example: @@ -74,7 +69,7 @@ image: containerPorts: # -- If you override the port in `router.configuration.server.listen` then make sure to match the listen port here - http: 80 + http: 4000 # -- For exposing the metrics port when running a serviceMonitor for example metrics: 9090 # -- For exposing the health check endpoint @@ -127,10 +122,12 @@ serviceAccount: podAnnotations: {} -podSecurityContext: {} +podSecurityContext: + {} # fsGroup: 2000 -securityContext: {} +securityContext: + {} # capabilities: # drop: # - ALL @@ -142,6 +139,7 @@ service: type: ClusterIP port: 80 annotations: {} + targetport: http serviceMonitor: enabled: false @@ -166,7 +164,12 @@ ingress: virtualservice: enabled: false # namespace: "" - # gatewayName: "" + # gatewayName: "" # Deprecated in favor of gatewayNames + # gatewayNames: [] + # - "gateway-1" + # - "gateway-2" + # Hosts: "" # configurable but will default to '*' + # - somehost.domain.com # http: # main: # # set enabled to true to add @@ -194,7 +197,8 @@ serviceentry: # a list of external hosts you want to be able to make https calls to # - api.example.com -resources: {} +resources: + {} # We usually recommend not to specify default resources and to leave this as a conscious # choice for the user. This also increases chances charts run on environments with little # resources, such as Minikube. If you do want to specify resources, uncomment the following @@ -212,6 +216,20 @@ autoscaling: maxReplicas: 100 targetCPUUtilizationPercentage: 80 # targetMemoryUtilizationPercentage: 80 + # + # Specify container-specific HPA scaling targets + # Only available in 1.27+ (https://kubernetes.io/blog/2023/05/02/hpa-container-resource-metric/) + # containerBased: + # - name: + # type: cpu + # targetUtilizationPercentage: 75 + +# -- Sets the [rolling update strategy parameters](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#rolling-update-deployment). Can take absolute values or % values. +rollingUpdate: + {} +# Defaults if not set are: +# maxUnavailable: 25% +# maxSurge: 25% nodeSelector: {} @@ -235,4 +253,10 @@ probes: # -- Configure liveness probe liveness: initialDelaySeconds: 0 + +# -- Sets the [topology spread constraints](https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/) for Deployment pods +topologySpreadConstraints: [] + +# -- Sets the restart policy of pods +restartPolicy: Always ``` diff --git a/docs/shared/k8s-manual-config.mdx b/docs/shared/k8s-manual-config.mdx index d5f0b4b430..ab44a0b7a3 100644 --- a/docs/shared/k8s-manual-config.mdx +++ b/docs/shared/k8s-manual-config.mdx @@ -6,10 +6,10 @@ kind: ServiceAccount metadata: name: release-name-router labels: - helm.sh/chart: router-1.30.1 + helm.sh/chart: router-2.6.0 app.kubernetes.io/name: router app.kubernetes.io/instance: release-name - app.kubernetes.io/version: "v1.30.1" + app.kubernetes.io/version: "v2.6.0" app.kubernetes.io/managed-by: Helm --- # Source: router/templates/secret.yaml @@ -18,10 +18,10 @@ kind: Secret metadata: name: "release-name-router" labels: - helm.sh/chart: router-1.30.1 + helm.sh/chart: router-2.6.0 app.kubernetes.io/name: router app.kubernetes.io/instance: release-name - app.kubernetes.io/version: "v1.30.1" + app.kubernetes.io/version: "v2.6.0" app.kubernetes.io/managed-by: Helm data: managedFederationApiKey: "UkVEQUNURUQ=" @@ -32,26 +32,22 @@ kind: ConfigMap metadata: name: release-name-router labels: - helm.sh/chart: router-1.30.1 + helm.sh/chart: router-2.6.0 app.kubernetes.io/name: router app.kubernetes.io/instance: release-name - app.kubernetes.io/version: "v1.30.1" + app.kubernetes.io/version: "v2.6.0" app.kubernetes.io/managed-by: Helm data: configuration.yaml: | health_check: listen: 0.0.0.0:8088 supergraph: - listen: 0.0.0.0:80 + listen: 0.0.0.0:4000 telemetry: metrics: - common: - resources: - service.name: release-name-router prometheus: enabled: true - listen: 0.0.0.0:9090 - path: /metrics + listen: 127.0.0.1:9090 --- # Source: router/templates/service.yaml apiVersion: v1 @@ -59,10 +55,10 @@ kind: Service metadata: name: release-name-router labels: - helm.sh/chart: router-1.30.1 + helm.sh/chart: router-2.6.0 app.kubernetes.io/name: router app.kubernetes.io/instance: release-name - app.kubernetes.io/version: "v1.30.1" + app.kubernetes.io/version: "v2.6.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -85,12 +81,13 @@ kind: Deployment metadata: name: release-name-router labels: - helm.sh/chart: router-1.30.1 + helm.sh/chart: router-2.6.0 app.kubernetes.io/name: router app.kubernetes.io/instance: release-name - app.kubernetes.io/version: "v1.30.1" + app.kubernetes.io/version: "v2.6.0" app.kubernetes.io/managed-by: Helm + annotations: prometheus.io/path: /metrics prometheus.io/port: "9090" @@ -106,23 +103,29 @@ spec: annotations: kubectl.kubernetes.io/default-container: router labels: + helm.sh/chart: router-2.6.0 app.kubernetes.io/name: router app.kubernetes.io/instance: release-name + app.kubernetes.io/version: "v2.6.0" + app.kubernetes.io/managed-by: Helm spec: serviceAccountName: release-name-router + restartPolicy: Always securityContext: {} containers: - name: router securityContext: {} - image: "ghcr.io/apollographql/router:v1.30.1" + image: "ghcr.io/apollographql/router:v2.6.0" imagePullPolicy: IfNotPresent args: - --hot-reload - --config - /app/configuration.yaml env: + - name: APOLLO_ROUTER_OFFICIAL_HELM_CHART + value: "true" - name: APOLLO_KEY valueFrom: secretKeyRef: @@ -133,7 +136,7 @@ spec: value: REDACTED ports: - name: http - containerPort: 80 + containerPort: 4000 protocol: TCP - name: health containerPort: 8088 @@ -142,12 +145,12 @@ spec: {} livenessProbe: httpGet: - path: "/health?live" + path: /health?live port: 8088 initialDelaySeconds: 0 readinessProbe: httpGet: - path: "/health?ready" + path: /health?ready port: 8088 initialDelaySeconds: 0 resources: @@ -168,10 +171,10 @@ kind: Pod metadata: name: "release-name-router-test-connection" labels: - helm.sh/chart: router-1.30.1 + helm.sh/chart: router-2.6.0 app.kubernetes.io/name: router app.kubernetes.io/instance: release-name - app.kubernetes.io/version: "v1.30.1" + app.kubernetes.io/version: "v2.6.0" app.kubernetes.io/managed-by: Helm annotations: "helm.sh/hook": test diff --git a/docs/shared/router-config-properties-table.mdx b/docs/shared/router-config-properties-table.mdx new file mode 100644 index 0000000000..ba134613ab --- /dev/null +++ b/docs/shared/router-config-properties-table.mdx @@ -0,0 +1,1469 @@ +### `apq` + +Automatic Persisted Queries (APQ) configuration + +- **Type:** `object` + +```yaml title="apq" +apq: + enabled: true + router: + cache: + in_memory: + limit: 1 + redis: + namespace: example_namespace + password: example_password + pool_size: 1 + required_to_start: false + reset_ttl: true + timeout: null + tls: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + ttl: null + urls: + - http://example.com/urls_item + username: example_username + subgraph: + all: + enabled: false + subgraphs: {} +``` + + +--- + +### `authentication` + +Authentication + +- **Type:** `object` + +```yaml title="authentication" +authentication: + connector: + sources: {} + router: + jwt: + header_name: authorization + header_value_prefix: Bearer + ignore_other_prefixes: false + jwks: + - algorithms: null + headers: + - name: example_name + value: example_value + issuer: example_issuer + poll_interval: + nanos: 0 + secs: 60 + url: http://service.example.com/url + on_error: Continue + sources: + - name: authorization + type: header + value_prefix: Bearer + subgraph: + all: + aws_sig_v4: + hardcoded: + access_key_id: example_access_key_id + assume_role: + external_id: example_external_id + role_arn: example_role_arn + session_name: example_session_name + region: example_region + secret_access_key: example_secret_access_key + service_name: example_service_name + subgraphs: {} +``` + + +--- + +### `authorization` + +Authorization plugin + +- **Type:** `object` + +```yaml title="authorization" +authorization: + directives: + dry_run: false + enabled: true + errors: + log: true + response: errors + reject_unauthorized: false + require_authentication: false +``` + + +--- + +### `batching` + +Configuration for Batching + +- **Type:** `object` + +```yaml title="batching" +batching: + enabled: false + maximum_size: null + mode: batch_http_link + subgraph: + all: + enabled: false + subgraphs: {} +``` + + +--- + +### `connectors` + +- **Type:** `object` + +```yaml title="connectors" +connectors: + debug_extensions: false + expose_sources_in_context: false + max_requests_per_operation_per_source: null + sources: {} + subgraphs: {} +``` + + +--- + +### `coprocessor` + +Configures the externalization plugin + +- **Type:** `object` + +```yaml title="coprocessor" +coprocessor: + client: + dns_resolution_strategy: ipv4_only + experimental_http2: enable + execution: + request: + body: false + context: false + headers: false + method: false + query_plan: false + sdl: false + response: + body: false + context: false + headers: false + sdl: false + status_code: false + router: + request: + body: false + condition: + eq: + - false + - false + context: false + headers: false + method: false + path: false + sdl: false + response: + body: false + condition: + eq: + - false + - false + context: false + headers: false + sdl: false + status_code: false + subgraph: + all: + request: + body: false + condition: + eq: + - false + - false + context: false + headers: false + method: false + service_name: false + subgraph_request_id: false + uri: false + response: + body: false + condition: + eq: + - false + - false + context: false + headers: false + service_name: false + status_code: false + subgraph_request_id: false + supergraph: + request: + body: false + condition: + eq: + - false + - false + context: false + headers: false + method: false + sdl: false + response: + body: false + condition: + eq: + - false + - false + context: false + headers: false + sdl: false + status_code: false + timeout: + nanos: 0 + secs: 1 + url: http://service.example.com/url +``` + + +--- + +### `cors` + +Cross origin request configuration. + +- **Type:** `object` + +```yaml title="cors" +cors: + allow_any_origin: false + allow_credentials: false + allow_headers: [] + expose_headers: null + match_origins: null + max_age: null + methods: + - GET + - POST + - OPTIONS + policies: + - origins: [https://studio.apollographql.com] +``` + + +--- + +### `csrf` + +CSRF protection configuration. + +See https://owasp.org/www-community/attacks/csrf for an explanation on CSRF attacks. + +- **Type:** `object` + +```yaml title="csrf" +csrf: + required_headers: + - x-apollo-operation-name + - apollo-require-preflight + unsafe_disabled: false +``` + + +--- + +### `demand_control` + +Demand control configuration + +- **Type:** `object` + +```yaml title="demand_control" +demand_control: + enabled: false + mode: measure + strategy: + static_estimated: + list_size: 0 + max: 0.0 +``` + + +--- + +### `experimental_chaos` + +Configuration for chaos testing, trying to reproduce bugs that require uncommon conditions. You probably don’t want this in production! + +- **Type:** `object` + +```yaml title="experimental_chaos" +experimental_chaos: + force_reload: null +``` + + +--- + +### `experimental_type_conditioned_fetching` + +Type conditioned fetching configuration. + +- **Type:** `boolean` +- **Default:** `False` + +```yaml title="experimental_type_conditioned_fetching" +experimental_type_conditioned_fetching: false +``` + + +--- + +### `fleet_detector` + +- **Type:** `object` + +```yaml title="fleet_detector" +fleet_detector: {} +``` + + +--- + +### `forbid_mutations` + +Forbid mutations configuration + +- **Type:** `boolean` + +```yaml title="forbid_mutations" +forbid_mutations: false +``` + + +--- + +### `headers` + +Configuration for header propagation + +- **Type:** `object` + +```yaml title="headers" +headers: + all: + request: + - insert: + name: example_name + value: example_value + subgraphs: {} +``` + + +--- + +### `health_check` + +Configuration options pertaining to the health component. + +- **Type:** `object` + +```yaml title="health_check" +health_check: + enabled: true + listen: example_listen + path: /health + readiness: + allowed: 100 + interval: + sampling: 0s + unready: null +``` + + +--- + +### `homepage` + +Configuration options pertaining to the home page. + +- **Type:** `object` + +```yaml title="homepage" +homepage: + enabled: true + graph_ref: null +``` + + +--- + +### `include_subgraph_errors` + +Configuration for exposing errors that originate from subgraphs + +- **Type:** `object` + +```yaml title="include_subgraph_errors" +include_subgraph_errors: + all: false + subgraphs: {} +``` + + +--- + +### `license_enforcement` + +- **Type:** `object` + +```yaml title="license_enforcement" +license_enforcement: {} +``` + + +--- + +### `limits` + +Configuration for operation limits, parser limits, HTTP limits, etc. + +- **Type:** `object` + +```yaml title="limits" +limits: + http1_max_request_buf_size: null + http1_max_request_headers: null + http_max_request_bytes: 2000000 + introspection_max_depth: true + max_aliases: null + max_depth: null + max_height: null + max_root_fields: null + parser_max_recursion: 500 + parser_max_tokens: 15000 + warn_only: false +``` + + +--- + +### `override_subgraph_url` + +Subgraph URL mappings + +- **Type:** `any` + +```yaml title="override_subgraph_url" +override_subgraph_url: {} +``` + + +--- + +### `persisted_queries` + +Persisted Queries (PQ) configuration + +- **Type:** `object` + +```yaml title="persisted_queries" +persisted_queries: + enabled: false + experimental_prewarm_query_plan_cache: + on_reload: true + on_startup: false + hot_reload: false + local_manifests: null + log_unknown: false + safelist: + enabled: false + require_id: false +``` + + +--- + +### `plugins` + +- **Type:** `any` + +```yaml title="plugins" +plugins: unknown_type_plugins +``` + + +--- + +### `preview_entity_cache` + +Configuration for entity caching + +- **Type:** `object` + +```yaml title="preview_entity_cache" +preview_entity_cache: + enabled: false + expose_keys_in_context: false + invalidation: + concurrent_requests: 10 + listen: example_listen + path: example_path + scan_count: 1000 + metrics: + enabled: false + separate_per_type: false + ttl: 30s + subgraph: + all: + enabled: true + invalidation: + enabled: false + shared_key: '' + private_id: null + redis: + namespace: example_namespace + password: example_password + pool_size: 1 + required_to_start: false + reset_ttl: true + timeout: null + tls: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + ttl: null + urls: + - http://example.com/urls_item + username: example_username + ttl: 30s + subgraphs: {} +``` + + +--- + +### `preview_file_uploads` + +Configuration for File Uploads plugin + +- **Type:** `object` + +```yaml title="preview_file_uploads" +preview_file_uploads: + enabled: false + protocols: + multipart: + enabled: true + limits: + max_file_size: example_max_file_size + max_files: 0 + mode: stream +``` + + +--- + +### `progressive_override` + +Configuration for the progressive override plugin + +- **Type:** `object` + +```yaml title="progressive_override" +progressive_override: {} +``` + + +--- + +### `rhai` + +Configuration for the Rhai Plugin + +- **Type:** `object` + +```yaml title="rhai" +rhai: + main: example_main + scripts: example_scripts +``` + + +--- + +### `sandbox` + +Configuration options pertaining to the sandbox page. + +- **Type:** `object` + +```yaml title="sandbox" +sandbox: + enabled: false +``` + + +--- + +### `subscription` + +Subscriptions configuration + +- **Type:** `object` + +```yaml title="subscription" +subscription: + enable_deduplication: true + enabled: true + max_opened_subscriptions: null + mode: + callback: + heartbeat_interval: disabled + listen: example_listen + path: example_path + public_url: http://service.example.com/public_url + subgraphs: [] + passthrough: + all: + heartbeat_interval: disabled + path: null + protocol: graphql_ws + subgraphs: {} + queue_capacity: null +``` + + +--- + +### `supergraph` + +Configuration options pertaining to the supergraph server component. + +- **Type:** `object` + +```yaml title="supergraph" +supergraph: + defer_support: true + early_cancel: false + experimental_log_on_broken_pipe: false + generate_query_fragments: true + introspection: false + listen: example_listen + path: / + query_planning: + cache: + in_memory: + limit: 1 + redis: + namespace: example_namespace + password: example_password + pool_size: 1 + required_to_start: false + reset_ttl: true + timeout: null + tls: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + ttl: + nanos: 0 + secs: 2592000 + urls: + - http://example.com/urls_item + username: example_username + experimental_paths_limit: null + experimental_plans_limit: null + experimental_reuse_query_plans: false + warmed_up_queries: null +``` + + +--- + +### `telemetry` + +Telemetry configuration + +- **Type:** `object` + +```yaml title="telemetry" +telemetry: + apollo: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + nanos: 0 + secs: 30 + max_queue_size: 2048 + scheduled_delay: + nanos: 0 + secs: 5 + buffer_size: 10000 + client_name_header: apollographql-client-name + client_version_header: apollographql-client-version + endpoint: https://usage-reporting.api.apollographql.com/api/ingress/traces + errors: + preview_extended_error_metrics: disabled + subgraph: + all: + redact: true + redaction_policy: strict + send: true + subgraphs: {} + experimental_local_field_metrics: false + experimental_otlp_endpoint: https://usage-reporting.api.apollographql.com/ + experimental_otlp_tracing_protocol: grpc + field_level_instrumentation_sampler: 0.0 + metrics_reference_mode: extended + otlp_tracing_sampler: 0.0 + send_headers: + only: + - example_only_item + send_variable_values: + only: + - example_only_item + signature_normalization_algorithm: legacy + exporters: + logging: + common: + resource: {} + service_name: null + service_namespace: null + stdout: + enabled: true + format: + json: + display_current_span: false + display_filename: false + display_level: true + display_line_number: false + display_resource: true + display_span_id: true + display_span_list: true + display_target: true + display_thread_id: false + display_thread_name: false + display_timestamp: true + display_trace_id: hexadecimal + span_attributes: [] + rate_limit: + capacity: 1 + enabled: false + interval: + nanos: 0 + secs: 1 + tty_format: + json: + display_current_span: false + display_filename: false + display_level: true + display_line_number: false + display_resource: true + display_span_id: true + display_span_list: true + display_target: true + display_thread_id: false + display_thread_name: false + display_timestamp: true + display_trace_id: hexadecimal + span_attributes: [] + metrics: + common: + buckets: + - 0.001 + - 0.005 + - 0.015 + - 0.05 + - 0.1 + - 0.2 + - 0.3 + - 0.4 + - 0.5 + - 1.0 + - 5.0 + - 10.0 + resource: {} + service_name: null + service_namespace: null + views: + - aggregation: + histogram: + buckets: + - 0.0 + allowed_attribute_keys: + - example_allowed_attribute_keys_item + description: example_description + name: example_name + unit: example_unit + otlp: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + nanos: 0 + secs: 30 + max_queue_size: 2048 + scheduled_delay: + nanos: 0 + secs: 5 + enabled: false + endpoint: example_endpoint + grpc: + ca: null + cert: null + domain_name: null + key: null + metadata: {} + http: + headers: {} + protocol: grpc + temporality: cumulative + prometheus: + enabled: false + listen: example_listen + path: /metrics + tracing: + common: + max_attributes_per_event: 128 + max_attributes_per_link: 128 + max_attributes_per_span: 128 + max_events_per_span: 128 + max_links_per_span: 128 + parent_based_sampler: true + preview_datadog_agent_sampling: null + resource: {} + sampler: 0.0 + service_name: null + service_namespace: null + datadog: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + nanos: 0 + secs: 30 + max_queue_size: 2048 + scheduled_delay: + nanos: 0 + secs: 5 + enable_span_mapping: true + enabled: false + endpoint: example_endpoint + fixed_span_names: true + resource_mapping: {} + span_metrics: + connect: true + connect_request: true + execution: true + http_request: true + parse_query: true + query_planning: true + request: true + router: true + subgraph: true + subgraph_request: true + supergraph: true + experimental_response_trace_id: + enabled: false + format: hexadecimal + header_name: example_header_name + otlp: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + nanos: 0 + secs: 30 + max_queue_size: 2048 + scheduled_delay: + nanos: 0 + secs: 5 + enabled: false + endpoint: example_endpoint + grpc: + ca: null + cert: null + domain_name: null + key: null + metadata: {} + http: + headers: {} + protocol: grpc + temporality: cumulative + propagation: + aws_xray: false + baggage: false + datadog: false + jaeger: false + request: + format: hexadecimal + header_name: example_header_name + trace_context: false + zipkin: false + zipkin: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + nanos: 0 + secs: 30 + max_queue_size: 2048 + scheduled_delay: + nanos: 0 + secs: 5 + enabled: false + endpoint: example_endpoint + instrumentation: + events: + connector: + error: + condition: + eq: + - false + - false + level: info + request: + condition: + eq: + - false + - false + level: info + response: + condition: + eq: + - false + - false + level: info + router: + error: + condition: + eq: + - false + - false + level: info + request: + condition: + eq: + - false + - false + level: info + response: + condition: + eq: + - false + - false + level: info + subgraph: + error: + condition: + eq: + - false + - false + level: info + request: + condition: + eq: + - false + - false + level: info + response: + condition: + eq: + - false + - false + level: info + supergraph: + error: + condition: + eq: + - false + - false + level: info + request: + condition: + eq: + - false + - false + level: info + response: + condition: + eq: + - false + - false + level: info + instruments: + cache: + apollo.router.operations.entity.cache: + attributes: + graphql.type.name: + alias: example_alias + connector: + http.client.request.body.size: + attributes: + connector.http.method: + alias: example_alias + connector.source.name: + alias: example_alias + connector.url.template: + alias: example_alias + subgraph.name: + alias: example_alias + http.client.request.duration: + attributes: + connector.http.method: + alias: example_alias + connector.source.name: + alias: example_alias + connector.url.template: + alias: example_alias + subgraph.name: + alias: example_alias + http.client.response.body.size: + attributes: + connector.http.method: + alias: example_alias + connector.source.name: + alias: example_alias + connector.url.template: + alias: example_alias + subgraph.name: + alias: example_alias + default_requirement_level: none + graphql: + field.execution: + attributes: + graphql.field.name: + alias: example_alias + graphql.field.type: + alias: example_alias + graphql.list.length: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.type.name: + alias: example_alias + list.length: + attributes: + graphql.field.name: + alias: example_alias + graphql.field.type: + alias: example_alias + graphql.list.length: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.type.name: + alias: example_alias + router: + http.server.active_requests: + attributes: + http.request.method: false + server.address: false + server.port: false + url.scheme: false + http.server.request.body.size: + attributes: + baggage: null + dd.trace_id: + alias: example_alias + error.type: + alias: example_alias + http.request.body.size: + alias: example_alias + http.request.method: + alias: example_alias + http.response.body.size: + alias: example_alias + http.response.status_code: + alias: example_alias + http.route: + alias: example_alias + network.local.address: + alias: example_alias + network.local.port: + alias: example_alias + network.peer.address: + alias: example_alias + network.peer.port: + alias: example_alias + network.protocol.name: + alias: example_alias + network.protocol.version: + alias: example_alias + network.transport: + alias: example_alias + network.type: + alias: example_alias + server.address: + alias: example_alias + server.port: + alias: example_alias + trace_id: + alias: example_alias + url.path: + alias: example_alias + url.query: + alias: example_alias + url.scheme: + alias: example_alias + user_agent.original: + alias: example_alias + http.server.request.duration: + attributes: + baggage: null + dd.trace_id: + alias: example_alias + error.type: + alias: example_alias + http.request.body.size: + alias: example_alias + http.request.method: + alias: example_alias + http.response.body.size: + alias: example_alias + http.response.status_code: + alias: example_alias + http.route: + alias: example_alias + network.local.address: + alias: example_alias + network.local.port: + alias: example_alias + network.peer.address: + alias: example_alias + network.peer.port: + alias: example_alias + network.protocol.name: + alias: example_alias + network.protocol.version: + alias: example_alias + network.transport: + alias: example_alias + network.type: + alias: example_alias + server.address: + alias: example_alias + server.port: + alias: example_alias + trace_id: + alias: example_alias + url.path: + alias: example_alias + url.query: + alias: example_alias + url.scheme: + alias: example_alias + user_agent.original: + alias: example_alias + http.server.response.body.size: + attributes: + baggage: null + dd.trace_id: + alias: example_alias + error.type: + alias: example_alias + http.request.body.size: + alias: example_alias + http.request.method: + alias: example_alias + http.response.body.size: + alias: example_alias + http.response.status_code: + alias: example_alias + http.route: + alias: example_alias + network.local.address: + alias: example_alias + network.local.port: + alias: example_alias + network.peer.address: + alias: example_alias + network.peer.port: + alias: example_alias + network.protocol.name: + alias: example_alias + network.protocol.version: + alias: example_alias + network.transport: + alias: example_alias + network.type: + alias: example_alias + server.address: + alias: example_alias + server.port: + alias: example_alias + trace_id: + alias: example_alias + url.path: + alias: example_alias + url.query: + alias: example_alias + url.scheme: + alias: example_alias + user_agent.original: + alias: example_alias + subgraph: + http.client.request.body.size: + attributes: + http.request.resend_count: + alias: example_alias + subgraph.graphql.document: + alias: example_alias + subgraph.graphql.operation.name: + alias: example_alias + subgraph.graphql.operation.type: + alias: example_alias + subgraph.name: + alias: example_alias + http.client.request.duration: + attributes: + http.request.resend_count: + alias: example_alias + subgraph.graphql.document: + alias: example_alias + subgraph.graphql.operation.name: + alias: example_alias + subgraph.graphql.operation.type: + alias: example_alias + subgraph.name: + alias: example_alias + http.client.response.body.size: + attributes: + http.request.resend_count: + alias: example_alias + subgraph.graphql.document: + alias: example_alias + subgraph.graphql.operation.name: + alias: example_alias + subgraph.graphql.operation.type: + alias: example_alias + subgraph.name: + alias: example_alias + supergraph: + cost.actual: + attributes: + cost.actual: + alias: example_alias + cost.delta: + alias: example_alias + cost.estimated: + alias: example_alias + cost.result: + alias: example_alias + graphql.document: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.operation.type: + alias: example_alias + cost.delta: + attributes: + cost.actual: + alias: example_alias + cost.delta: + alias: example_alias + cost.estimated: + alias: example_alias + cost.result: + alias: example_alias + graphql.document: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.operation.type: + alias: example_alias + cost.estimated: + attributes: + cost.actual: + alias: example_alias + cost.delta: + alias: example_alias + cost.estimated: + alias: example_alias + cost.result: + alias: example_alias + graphql.document: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.operation.type: + alias: example_alias + spans: + connector: + attributes: + connector.http.method: + alias: example_alias + connector.source.name: + alias: example_alias + connector.url.template: + alias: example_alias + subgraph.name: + alias: example_alias + default_attribute_requirement_level: none + mode: deprecated + router: + attributes: + baggage: null + dd.trace_id: + alias: example_alias + error.type: + alias: example_alias + http.request.body.size: + alias: example_alias + http.request.method: + alias: example_alias + http.response.body.size: + alias: example_alias + http.response.status_code: + alias: example_alias + http.route: + alias: example_alias + network.local.address: + alias: example_alias + network.local.port: + alias: example_alias + network.peer.address: + alias: example_alias + network.peer.port: + alias: example_alias + network.protocol.name: + alias: example_alias + network.protocol.version: + alias: example_alias + network.transport: + alias: example_alias + network.type: + alias: example_alias + server.address: + alias: example_alias + server.port: + alias: example_alias + trace_id: + alias: example_alias + url.path: + alias: example_alias + url.query: + alias: example_alias + url.scheme: + alias: example_alias + user_agent.original: + alias: example_alias + subgraph: + attributes: + http.request.resend_count: + alias: example_alias + subgraph.graphql.document: + alias: example_alias + subgraph.graphql.operation.name: + alias: example_alias + subgraph.graphql.operation.type: + alias: example_alias + subgraph.name: + alias: example_alias + supergraph: + attributes: + cost.actual: + alias: example_alias + cost.delta: + alias: example_alias + cost.estimated: + alias: example_alias + cost.result: + alias: example_alias + graphql.document: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.operation.type: + alias: example_alias +``` + + +--- + +### `tls` + +TLS related configuration options. + +- **Type:** `object` + +```yaml title="tls" +tls: + connector: + all: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + sources: {} + subgraph: + all: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + subgraphs: {} + supergraph: + certificate: example_certificate + certificate_chain: example_certificate_chain + key: example_key +``` + + +--- + +### `traffic_shaping` + +Configuration for the experimental traffic shaping plugin + +- **Type:** `object` + +```yaml title="traffic_shaping" +traffic_shaping: + all: + compression: gzip + deduplicate_query: false + dns_resolution_strategy: ipv4_only + experimental_http2: enable + global_rate_limit: + capacity: 1 + interval: 30s + timeout: null + connector: + all: + compression: gzip + dns_resolution_strategy: ipv4_only + experimental_http2: enable + global_rate_limit: + capacity: 1 + interval: 30s + timeout: null + sources: {} + deduplicate_variables: null + router: + concurrency_limit: 0 + global_rate_limit: + capacity: 1 + interval: 30s + timeout: null + subgraphs: {} +``` diff --git a/docs/shared/router-lifecycle-services.mdx b/docs/shared/router-lifecycle-services.mdx index 12b57bc588..3e19114e03 100644 --- a/docs/shared/router-lifecycle-services.mdx +++ b/docs/shared/router-lifecycle-services.mdx @@ -1,5 +1,11 @@ -A router's request lifecycle has three major services: +A router's request lifecycle has three major services that support instrumentation: -* **Router service** - Handles an incoming request before it is parsed. Works within a context of opaque bytes. -* **Supergraph service** - Handles a request after it has been parsed and before it is sent to the subgraph. Works within a GraphQL context. -* **Subgraph service** - Handles a request after it has been sent to the subgraph. Works within a GraphQL context. +* **Router service** - Operates within the context of an HTTP server, handling the opaque bytes of an incoming HTTP request. Does query analysis to parse the GraphQL operation and validate it against schema. +* **Supergraph service** - Handles a GraphQL request after it's been parsed and validated, and before it's sent to subgraphs. Runs the query planner to produce a query plan to execute. +* **Subgraph service** - Handles GraphQL subgraph requests that have been executed as part of a query plan. Creates HTTP client requests to subgraphs. + + + +The router's **Execution service** that executes query plans doesn't support instrumentation. + + diff --git a/docs/shared/router-yaml-complete.mdx b/docs/shared/router-yaml-complete.mdx new file mode 100644 index 0000000000..8b2f184b24 --- /dev/null +++ b/docs/shared/router-yaml-complete.mdx @@ -0,0 +1,1096 @@ +```yaml +apq: + enabled: true + router: + cache: + in_memory: + limit: 1 + redis: + namespace: example_namespace + password: example_password + pool_size: 1 + required_to_start: false + reset_ttl: true + timeout: null + tls: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + ttl: null + urls: + - http://example.com/urls_item + username: example_username + subgraph: + all: + enabled: false + subgraphs: {} +authentication: + connector: + sources: {} + router: + jwt: + header_name: authorization + header_value_prefix: Bearer + ignore_other_prefixes: false + jwks: + - algorithms: null + headers: + - name: example_name + value: example_value + issuer: example_issuer + poll_interval: + secs: 60 + nanos: 0 + url: http://service.example.com/url + on_error: Continue + sources: + - name: authorization + type: header + value_prefix: Bearer + subgraph: + all: + aws_sig_v4: + hardcoded: + access_key_id: example_access_key_id + assume_role: + external_id: example_external_id + role_arn: example_role_arn + session_name: example_session_name + region: example_region + secret_access_key: example_secret_access_key + service_name: example_service_name + subgraphs: {} +authorization: + directives: + dry_run: false + enabled: true + errors: + log: true + response: errors + reject_unauthorized: false + require_authentication: false +batching: + enabled: false + maximum_size: null + mode: batch_http_link + subgraph: + all: + enabled: false + subgraphs: {} +connectors: + debug_extensions: false + expose_sources_in_context: false + max_requests_per_operation_per_source: null + sources: {} + subgraphs: {} +coprocessor: + client: + dns_resolution_strategy: ipv4_only + experimental_http2: enable + execution: + request: + body: false + context: false + headers: false + method: false + query_plan: false + sdl: false + response: + body: false + context: false + headers: false + sdl: false + status_code: false + router: + request: + body: false + condition: + eq: + - false + - false + context: false + headers: false + method: false + path: false + sdl: false + response: + body: false + condition: + eq: + - false + - false + context: false + headers: false + sdl: false + status_code: false + subgraph: + all: + request: + body: false + condition: + eq: + - false + - false + context: false + headers: false + method: false + service_name: false + subgraph_request_id: false + uri: false + response: + body: false + condition: + eq: + - false + - false + context: false + headers: false + service_name: false + status_code: false + subgraph_request_id: false + supergraph: + request: + body: false + condition: + eq: + - false + - false + context: false + headers: false + method: false + sdl: false + response: + body: false + condition: + eq: + - false + - false + context: false + headers: false + sdl: false + status_code: false + timeout: + secs: 1 + nanos: 0 + url: http://service.example.com/url +cors: + allow_any_origin: false + allow_credentials: false + allow_headers: [] + expose_headers: null + match_origins: null + max_age: null + methods: + - GET + - POST + - OPTIONS + policies: + - origins: [https://studio.apollographql.com] +csrf: + required_headers: + - x-apollo-operation-name + - apollo-require-preflight + unsafe_disabled: false +demand_control: + enabled: false + mode: measure + strategy: + static_estimated: + list_size: 0 + max: 0.0 +experimental_chaos: + force_reload: null +experimental_type_conditioned_fetching: false +fleet_detector: {} +forbid_mutations: false +headers: + all: + request: + - insert: + name: example_name + value: example_value + subgraphs: {} +health_check: + enabled: true + listen: example_listen + path: /health + readiness: + allowed: 100 + interval: + sampling: 0s + unready: null +homepage: + enabled: true + graph_ref: null +include_subgraph_errors: + all: false + subgraphs: {} +license_enforcement: {} +limits: + http1_max_request_buf_size: null + http1_max_request_headers: null + http_max_request_bytes: 2000000 + introspection_max_depth: true + max_aliases: null + max_depth: null + max_height: null + max_root_fields: null + parser_max_recursion: 500 + parser_max_tokens: 15000 + warn_only: false +override_subgraph_url: {} +persisted_queries: + enabled: false + experimental_prewarm_query_plan_cache: + on_reload: true + on_startup: false + hot_reload: false + local_manifests: null + log_unknown: false + safelist: + enabled: false + require_id: false +plugins: unknown_type_plugins +preview_entity_cache: + enabled: false + expose_keys_in_context: false + invalidation: + concurrent_requests: 10 + listen: example_listen + path: example_path + scan_count: 1000 + metrics: + enabled: false + separate_per_type: false + ttl: 30s + subgraph: + all: + enabled: true + invalidation: + enabled: false + shared_key: "" + private_id: null + redis: + namespace: example_namespace + password: example_password + pool_size: 1 + required_to_start: false + reset_ttl: true + timeout: null + tls: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + ttl: null + urls: + - http://example.com/urls_item + username: example_username + ttl: 30s + subgraphs: {} +preview_file_uploads: + enabled: false + protocols: + multipart: + enabled: true + limits: + max_file_size: example_max_file_size + max_files: 0 + mode: stream +progressive_override: {} +rhai: + main: example_main + scripts: example_scripts +sandbox: + enabled: false +subscription: + enable_deduplication: true + enabled: true + max_opened_subscriptions: null + mode: + callback: + heartbeat_interval: disabled + listen: example_listen + path: example_path + public_url: http://service.example.com/public_url + subgraphs: [] + passthrough: + all: + heartbeat_interval: disabled + path: null + protocol: graphql_ws + subgraphs: {} + queue_capacity: null +supergraph: + defer_support: true + early_cancel: false + experimental_log_on_broken_pipe: false + generate_query_fragments: true + introspection: false + listen: example_listen + path: / + query_planning: + cache: + in_memory: + limit: 1 + redis: + namespace: example_namespace + password: example_password + pool_size: 1 + required_to_start: false + reset_ttl: true + timeout: null + tls: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + ttl: + secs: 2592000 + nanos: 0 + urls: + - http://example.com/urls_item + username: example_username + experimental_paths_limit: null + experimental_plans_limit: null + experimental_reuse_query_plans: false + warmed_up_queries: null +telemetry: + apollo: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + secs: 30 + nanos: 0 + max_queue_size: 2048 + scheduled_delay: + secs: 5 + nanos: 0 + buffer_size: 10000 + client_name_header: apollographql-client-name + client_version_header: apollographql-client-version + endpoint: https://usage-reporting.api.apollographql.com/api/ingress/traces + errors: + preview_extended_error_metrics: disabled + subgraph: + all: + redact: true + redaction_policy: strict + send: true + subgraphs: {} + experimental_local_field_metrics: false + experimental_otlp_endpoint: https://usage-reporting.api.apollographql.com/ + experimental_otlp_tracing_protocol: grpc + field_level_instrumentation_sampler: 0.0 + metrics_reference_mode: extended + otlp_tracing_sampler: 0.0 + send_headers: + only: + - example_only_item + send_variable_values: + only: + - example_only_item + signature_normalization_algorithm: legacy + exporters: + logging: + common: + resource: {} + service_name: null + service_namespace: null + stdout: + enabled: true + format: + json: + display_current_span: false + display_filename: false + display_level: true + display_line_number: false + display_resource: true + display_span_id: true + display_span_list: true + display_target: true + display_thread_id: false + display_thread_name: false + display_timestamp: true + display_trace_id: hexadecimal + span_attributes: [] + rate_limit: + capacity: 1 + enabled: false + interval: + secs: 1 + nanos: 0 + tty_format: + json: + display_current_span: false + display_filename: false + display_level: true + display_line_number: false + display_resource: true + display_span_id: true + display_span_list: true + display_target: true + display_thread_id: false + display_thread_name: false + display_timestamp: true + display_trace_id: hexadecimal + span_attributes: [] + metrics: + common: + buckets: + - 0.001 + - 0.005 + - 0.015 + - 0.05 + - 0.1 + - 0.2 + - 0.3 + - 0.4 + - 0.5 + - 1.0 + - 5.0 + - 10.0 + resource: {} + service_name: null + service_namespace: null + views: + - aggregation: + histogram: + buckets: + - 0.0 + allowed_attribute_keys: + - example_allowed_attribute_keys_item + description: example_description + name: example_name + unit: example_unit + otlp: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + secs: 30 + nanos: 0 + max_queue_size: 2048 + scheduled_delay: + secs: 5 + nanos: 0 + enabled: false + endpoint: example_endpoint + grpc: + ca: null + cert: null + domain_name: null + key: null + metadata: {} + http: + headers: {} + protocol: grpc + temporality: cumulative + prometheus: + enabled: false + listen: example_listen + path: /metrics + tracing: + common: + max_attributes_per_event: 128 + max_attributes_per_link: 128 + max_attributes_per_span: 128 + max_events_per_span: 128 + max_links_per_span: 128 + parent_based_sampler: true + preview_datadog_agent_sampling: null + resource: {} + sampler: 0.0 + service_name: null + service_namespace: null + datadog: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + secs: 30 + nanos: 0 + max_queue_size: 2048 + scheduled_delay: + secs: 5 + nanos: 0 + enable_span_mapping: true + enabled: false + endpoint: example_endpoint + fixed_span_names: true + resource_mapping: {} + span_metrics: + parse_query: true + connect: true + execution: true + http_request: true + request: true + query_planning: true + connect_request: true + subgraph: true + router: true + supergraph: true + subgraph_request: true + experimental_response_trace_id: + enabled: false + format: hexadecimal + header_name: example_header_name + otlp: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + secs: 30 + nanos: 0 + max_queue_size: 2048 + scheduled_delay: + secs: 5 + nanos: 0 + enabled: false + endpoint: example_endpoint + grpc: + ca: null + cert: null + domain_name: null + key: null + metadata: {} + http: + headers: {} + protocol: grpc + temporality: cumulative + propagation: + aws_xray: false + baggage: false + datadog: false + jaeger: false + request: + format: hexadecimal + header_name: example_header_name + trace_context: false + zipkin: false + zipkin: + batch_processor: + max_concurrent_exports: 1 + max_export_batch_size: 512 + max_export_timeout: + secs: 30 + nanos: 0 + max_queue_size: 2048 + scheduled_delay: + secs: 5 + nanos: 0 + enabled: false + endpoint: example_endpoint + instrumentation: + events: + connector: + error: + condition: + eq: + - false + - false + level: info + request: + condition: + eq: + - false + - false + level: info + response: + condition: + eq: + - false + - false + level: info + router: + error: + condition: + eq: + - false + - false + level: info + request: + condition: + eq: + - false + - false + level: info + response: + condition: + eq: + - false + - false + level: info + subgraph: + error: + condition: + eq: + - false + - false + level: info + request: + condition: + eq: + - false + - false + level: info + response: + condition: + eq: + - false + - false + level: info + supergraph: + error: + condition: + eq: + - false + - false + level: info + request: + condition: + eq: + - false + - false + level: info + response: + condition: + eq: + - false + - false + level: info + instruments: + cache: + apollo.router.operations.entity.cache: + attributes: + graphql.type.name: + alias: example_alias + connector: + http.client.request.body.size: + attributes: + connector.http.method: + alias: example_alias + connector.source.name: + alias: example_alias + connector.url.template: + alias: example_alias + subgraph.name: + alias: example_alias + http.client.request.duration: + attributes: + connector.http.method: + alias: example_alias + connector.source.name: + alias: example_alias + connector.url.template: + alias: example_alias + subgraph.name: + alias: example_alias + http.client.response.body.size: + attributes: + connector.http.method: + alias: example_alias + connector.source.name: + alias: example_alias + connector.url.template: + alias: example_alias + subgraph.name: + alias: example_alias + default_requirement_level: none + graphql: + field.execution: + attributes: + graphql.field.name: + alias: example_alias + graphql.field.type: + alias: example_alias + graphql.list.length: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.type.name: + alias: example_alias + list.length: + attributes: + graphql.field.name: + alias: example_alias + graphql.field.type: + alias: example_alias + graphql.list.length: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.type.name: + alias: example_alias + router: + http.server.active_requests: + attributes: + http.request.method: false + server.address: false + server.port: false + url.scheme: false + http.server.request.body.size: + attributes: + baggage: null + dd.trace_id: + alias: example_alias + error.type: + alias: example_alias + http.request.body.size: + alias: example_alias + http.request.method: + alias: example_alias + http.response.body.size: + alias: example_alias + http.response.status_code: + alias: example_alias + http.route: + alias: example_alias + network.local.address: + alias: example_alias + network.local.port: + alias: example_alias + network.peer.address: + alias: example_alias + network.peer.port: + alias: example_alias + network.protocol.name: + alias: example_alias + network.protocol.version: + alias: example_alias + network.transport: + alias: example_alias + network.type: + alias: example_alias + server.address: + alias: example_alias + server.port: + alias: example_alias + trace_id: + alias: example_alias + url.path: + alias: example_alias + url.query: + alias: example_alias + url.scheme: + alias: example_alias + user_agent.original: + alias: example_alias + http.server.request.duration: + attributes: + baggage: null + dd.trace_id: + alias: example_alias + error.type: + alias: example_alias + http.request.body.size: + alias: example_alias + http.request.method: + alias: example_alias + http.response.body.size: + alias: example_alias + http.response.status_code: + alias: example_alias + http.route: + alias: example_alias + network.local.address: + alias: example_alias + network.local.port: + alias: example_alias + network.peer.address: + alias: example_alias + network.peer.port: + alias: example_alias + network.protocol.name: + alias: example_alias + network.protocol.version: + alias: example_alias + network.transport: + alias: example_alias + network.type: + alias: example_alias + server.address: + alias: example_alias + server.port: + alias: example_alias + trace_id: + alias: example_alias + url.path: + alias: example_alias + url.query: + alias: example_alias + url.scheme: + alias: example_alias + user_agent.original: + alias: example_alias + http.server.response.body.size: + attributes: + baggage: null + dd.trace_id: + alias: example_alias + error.type: + alias: example_alias + http.request.body.size: + alias: example_alias + http.request.method: + alias: example_alias + http.response.body.size: + alias: example_alias + http.response.status_code: + alias: example_alias + http.route: + alias: example_alias + network.local.address: + alias: example_alias + network.local.port: + alias: example_alias + network.peer.address: + alias: example_alias + network.peer.port: + alias: example_alias + network.protocol.name: + alias: example_alias + network.protocol.version: + alias: example_alias + network.transport: + alias: example_alias + network.type: + alias: example_alias + server.address: + alias: example_alias + server.port: + alias: example_alias + trace_id: + alias: example_alias + url.path: + alias: example_alias + url.query: + alias: example_alias + url.scheme: + alias: example_alias + user_agent.original: + alias: example_alias + subgraph: + http.client.request.body.size: + attributes: + http.request.resend_count: + alias: example_alias + subgraph.graphql.document: + alias: example_alias + subgraph.graphql.operation.name: + alias: example_alias + subgraph.graphql.operation.type: + alias: example_alias + subgraph.name: + alias: example_alias + http.client.request.duration: + attributes: + http.request.resend_count: + alias: example_alias + subgraph.graphql.document: + alias: example_alias + subgraph.graphql.operation.name: + alias: example_alias + subgraph.graphql.operation.type: + alias: example_alias + subgraph.name: + alias: example_alias + http.client.response.body.size: + attributes: + http.request.resend_count: + alias: example_alias + subgraph.graphql.document: + alias: example_alias + subgraph.graphql.operation.name: + alias: example_alias + subgraph.graphql.operation.type: + alias: example_alias + subgraph.name: + alias: example_alias + supergraph: + cost.actual: + attributes: + cost.actual: + alias: example_alias + cost.delta: + alias: example_alias + cost.estimated: + alias: example_alias + cost.result: + alias: example_alias + graphql.document: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.operation.type: + alias: example_alias + cost.delta: + attributes: + cost.actual: + alias: example_alias + cost.delta: + alias: example_alias + cost.estimated: + alias: example_alias + cost.result: + alias: example_alias + graphql.document: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.operation.type: + alias: example_alias + cost.estimated: + attributes: + cost.actual: + alias: example_alias + cost.delta: + alias: example_alias + cost.estimated: + alias: example_alias + cost.result: + alias: example_alias + graphql.document: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.operation.type: + alias: example_alias + spans: + connector: + attributes: + connector.http.method: + alias: example_alias + connector.source.name: + alias: example_alias + connector.url.template: + alias: example_alias + subgraph.name: + alias: example_alias + default_attribute_requirement_level: none + mode: deprecated + router: + attributes: + baggage: null + dd.trace_id: + alias: example_alias + error.type: + alias: example_alias + http.request.body.size: + alias: example_alias + http.request.method: + alias: example_alias + http.response.body.size: + alias: example_alias + http.response.status_code: + alias: example_alias + http.route: + alias: example_alias + network.local.address: + alias: example_alias + network.local.port: + alias: example_alias + network.peer.address: + alias: example_alias + network.peer.port: + alias: example_alias + network.protocol.name: + alias: example_alias + network.protocol.version: + alias: example_alias + network.transport: + alias: example_alias + network.type: + alias: example_alias + server.address: + alias: example_alias + server.port: + alias: example_alias + trace_id: + alias: example_alias + url.path: + alias: example_alias + url.query: + alias: example_alias + url.scheme: + alias: example_alias + user_agent.original: + alias: example_alias + subgraph: + attributes: + http.request.resend_count: + alias: example_alias + subgraph.graphql.document: + alias: example_alias + subgraph.graphql.operation.name: + alias: example_alias + subgraph.graphql.operation.type: + alias: example_alias + subgraph.name: + alias: example_alias + supergraph: + attributes: + cost.actual: + alias: example_alias + cost.delta: + alias: example_alias + cost.estimated: + alias: example_alias + cost.result: + alias: example_alias + graphql.document: + alias: example_alias + graphql.operation.name: + alias: example_alias + graphql.operation.type: + alias: example_alias +tls: + connector: + all: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + sources: {} + subgraph: + all: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + subgraphs: {} + supergraph: + certificate: example_certificate + certificate_chain: example_certificate_chain + key: example_key +traffic_shaping: + all: + compression: gzip + deduplicate_query: false + dns_resolution_strategy: ipv4_only + experimental_http2: enable + global_rate_limit: + capacity: 1 + interval: 30s + timeout: null + connector: + all: + compression: gzip + dns_resolution_strategy: ipv4_only + experimental_http2: enable + global_rate_limit: + capacity: 1 + interval: 30s + timeout: null + sources: {} + deduplicate_variables: null + router: + concurrency_limit: 0 + global_rate_limit: + capacity: 1 + interval: 30s + timeout: null + subgraphs: {} +``` diff --git a/docs/source/_redirects b/docs/source/_redirects deleted file mode 100644 index be1bb526d6..0000000000 --- a/docs/source/_redirects +++ /dev/null @@ -1,10 +0,0 @@ -/managed-federation/overview /docs/federation/managed-federation/overview -/managed-federation/setup /docs/federation/managed-federation/setup -/managed-federation/federated-schema-checks /docs/federation/managed-federation/federated-schema-checks -/configuration/external/ /docs/router/customizations/coprocessor/ -/configuration/caching/ /docs/router/configuration/in-memory-caching/ -/development-workflow/* /docs/router/executing-operations/:splat -/configuration/spaceport* /docs/router/configuration/apollo-telemetry/ -/managed-federation/client%20awareness/ /docs/router/managed-federation/client-awareness/ -/configuration/logging /docs/router/configuration/telemetry/overview -/configuration/metrics /docs/router/configuration/telemetry/overview \ No newline at end of file diff --git a/docs/source/_sidebar.yaml b/docs/source/_sidebar.yaml new file mode 100644 index 0000000000..8ad53da920 --- /dev/null +++ b/docs/source/_sidebar.yaml @@ -0,0 +1,297 @@ +switcher: + heading: "Apollo Router" + versions: + - label: v2 + latest: true + href: ./ + - label: v1 + href: ./v1 +items: + - label: "Overview" + href: "." + - label: "Get Started" + href: "./get-started" + - label: "Request Lifecycle" + href: "./request-lifecycle" + - label: "Configuration" + children: + - label: "Overview" + href: "./configuration/overview" + - label: "Environment Variable Reference" + href: "./configuration/envvars" + - label: "CLI Reference" + href: "./configuration/cli" + - label: "YAML Reference" + href: "./configuration/yaml" + - label: "Features" + children: + - label: "Security" + children: + - label: "Overview" + href: "./security" + - label: "Persisted Queries" + href: "./security/persisted-queries" + - label: "Authorization" + href: "./security/authorization" + - label: "Authentication" + children: + - label: "JWT Authentication" + href: "./security/jwt" + - label: "Router Authentication" + href: "./security/router-authentication" + - label: "Subgraph Authentication" + href: "./security/subgraph-authentication" + - label: "CORS" + href: "./security/cors" + - label: "CSRF Prevention" + href: "./security/csrf" + - label: "TLS" + href: "./security/tls" + - label: "Request Limits" + href: "./security/request-limits" + - label: "Demand Control" + href: "./security/demand-control" + - label: "Observability" + children: + - label: "Overview" + href: "./observability" + - label: "Federated Trace Data" + href: "./observability/federated-trace-data" + - label: "Router Telemetry" + children: + - label: "Overview" + href: "./observability/telemetry" + - label: "Log Exporters" + children: + - label: "Overview" + href: "./observability/telemetry/log-exporters/overview" + - label: "Stdout" + href: "./observability/telemetry/log-exporters/stdout" + - label: "Metrics Exporters" + children: + - label: "Overview" + href: "./observability/telemetry/metrics-exporters/overview" + - label: "Datadog" + href: "./observability/telemetry/metrics-exporters/datadog" + - label: "Dynatrace" + href: "./observability/telemetry/metrics-exporters/dynatrace" + - label: "New Relic" + href: "./observability/telemetry/metrics-exporters/new-relic" + - label: "OTLP" + href: "./observability/telemetry/metrics-exporters/otlp" + - label: "Prometheus" + href: "./observability/telemetry/metrics-exporters/prometheus" + - label: "Trace Exporters" + children: + - label: "Overview" + href: "./observability/telemetry/trace-exporters/overview" + - label: "Datadog" + href: "./observability/telemetry/trace-exporters/datadog" + - label: "Dynatrace" + href: "./observability/telemetry/trace-exporters/dynatrace" + - label: "Jaeger" + href: "./observability/telemetry/trace-exporters/jaeger" + - label: "New Relic" + href: "./observability/telemetry/trace-exporters/new-relic" + - label: "OTLP" + href: "./observability/telemetry/trace-exporters/otlp" + - label: "Zipkin" + href: "./observability/telemetry/trace-exporters/zipkin" + - label: "Instrumentation" + children: + - label: "Instruments" + href: "./observability/telemetry/instrumentation/instruments" + - label: "Events" + href: "./observability/telemetry/instrumentation/events" + - label: "Conditions" + href: "./observability/telemetry/instrumentation/conditions" + - label: "Spans" + href: "./observability/telemetry/instrumentation/spans" + - label: "Selectors" + href: "./observability/telemetry/instrumentation/selectors" + - label: "Standard Attributes" + href: "./observability/telemetry/instrumentation/standard-attributes" + - label: "Standard Instruments" + href: "./observability/telemetry/instrumentation/standard-instruments" + - label: "Subgraph Observability" + children: + - label: "Subgraph Error Inclusion" + href: "./observability/subgraph-error-inclusion" + - label: "Debugging Subgraph Requests" + href: "./observability/debugging-subgraph-requests" + - label: "Client Observability" + children: + - label: "Debugging Client Requests" + href: "./observability/debugging-client-requests" + - label: "Client ID Enforcement" + href: "./observability/client-id-enforcement" + - label: "OpenTelemetry" + children: + - label: "Set up OTel" + href: "./observability/otel" + - label: "Connect OTel to Prometheus" + href: "./observability/otel-traces-to-prometheus" + - label: "Query Planning" + children: + - label: "Native Query Planner" + href: "./query-planning/native-query-planner" + - label: "Query Planning Best Practices" + href: "./query-planning/query-planning-best-practices" + - label: "Caching" + children: + - label: "In-Memory Caching" + href: "./performance/caching/in-memory" + - label: "Distributed Caching" + href: "./performance/caching/distributed" + - label: "Entity Caching" + href: "./performance/caching/entity" + - label: "Performance and Scaling" + children: + - label: "Overview" + href: "./performance" + - label: "Traffic Shaping" + href: "./performance/traffic-shaping" + - label: "Query Batching" + href: "./performance/query-batching" + - label: "Client Features" + children: + - label: "HTTP Header Propagation" + href: "./header-propagation" + - label: "@defer" + href: "./operations/defer" + - label: "GraphQL Subscriptions" + children: + - label: "Overview" + href: "./operations/subscriptions/overview" + - label: "Configuration" + href: "./operations/subscriptions/configuration" + - label: "Callback Protocol" + href: "./operations/subscriptions/callback-protocol" + - label: "Multipart Protocol" + href: "./operations/subscriptions/multipart-protocol" + - label: "Enable with API Gateway" + href: "./operations/subscriptions/api-gateway" + - label: "File Upload" + href: "./operations/file-upload" + - label: "Customization" + children: + - label: "Overview" + href: "./customization/overview" + - label: "Coprocessors" + children: + - label: "Coprocessor Configuration" + href: "./customization/coprocessor" + - label: "Coprocessor Reference" + href: "./customization/coprocessor/reference" + - label: "Rhai Scripts" + children: + - label: "Rhai Configuration" + href: "./customization/rhai" + - label: "Rhai API Reference" + href: "./customization/rhai/reference" + - label: "Custom Builds" + children: + - label: "Building a Binary" + href: "./customization/custom-binary" + - label: "Rust Plugins" + href: "./customization/native-plugins" + - label: "Deployment" + children: + - label: "Overview" + href: "./self-hosted" + - label: "Docker" + children: + - label: "Docker with the Apollo Runtime Container" + href: "./self-hosted/containerization/docker" + - label: "Docker with Apollo Router" + href: "./self-hosted/containerization/docker-router-only" + - label: "Kubernetes" + children: + - label: "Quickstart" + href: "./self-hosted/containerization/kubernetes/quickstart" + - label: "Deploying with Extensibility" + href: "./self-hosted/containerization/kubernetes/extensibility" + - label: "Enabling Metrics" + href: "./self-hosted/containerization/kubernetes/metrics" + - label: "Other Considerations" + href: "./self-hosted/containerization/kubernetes/other-considerations" + - label: "AWS" + children: + - label: "AWS ECS" + href: "./self-hosted/containerization/aws" + - label: "Azure" + children: + - label: "Azure Container Apps" + href: "./self-hosted/containerization/azure" + - label: "GCP" + children: + - label: "Google Cloud Run" + href: "./self-hosted/containerization/gcp" + - label: "Apollo Cloud Routing" + children: + - label: "Overview" + href: "./cloud/" + - label: "Configuration" + href: "./cloud/configuration" + - label: "Secure Subgraphs" + href: "./cloud/secure-subgraphs" + - label: "Subscriptions" + href: "./cloud/subscriptions" + - label: "Serverless" + href: "./cloud/serverless" + - label: "Dedicated" + children: + - label: "Overview" + href: "./cloud/dedicated" + - label: "Quickstart" + href: "./cloud/dedicated-quickstart" + - label: "Custom Domains" + href: "./cloud/custom-domains" + - label: "Throughput Guide" + href: "./cloud/throughput-guide" + - label: "Migrate to Dedicated" + href: "./cloud/migrate-to-dedicated" + - label: "AWS Lattice" + children: + - label: "Lattice Configuration" + href: "./cloud/lattice-configuration" + - label: "Lattice Management" + href: "./cloud/lattice-management" + - label: "Lattice Troubleshooting" + href: "./cloud/lattice-troubleshooting" + - label: "Tools" + children: + - label: "Router Resource Estimator" + href: "./self-hosted/resource-estimator" + - label: "Health Checks" + href: "./self-hosted/health-checks" + - label: "Resource Management" + href: "./self-hosted/resource-management" + - label: "Releases" + children: + - label: "Changelogs" + href: "./changelog" + - label: "What's New in Router v2" + href: "./about-v2" + - label: "Upgrade from Router v1" + href: "./upgrade/from-router-v1" + - label: "Migrate from Gateway" + href: "./migration/from-gateway" + - label: "API Gateway Comparison" + href: "./router-api-gateway-comparison" + - label: "GraphOS Integration" + children: + - label: "GraphOS Plan License" + href: "./license" + - label: "GraphOS Plan Features" + href: "./graphos-features" + - label: "Apollo Uplink" + href: "./uplink" + - label: "Operation Metrics Reporting" + href: "./graphos-reporting" + - label: "Reference" + children: + - label: "Federation Version Support" + href: "./federation-version-support" + - label: "Errors" + href: "./errors" diff --git a/docs/source/config.json b/docs/source/config.json index 8e481a369b..4fd0c6569c 100644 --- a/docs/source/config.json +++ b/docs/source/config.json @@ -2,9 +2,6 @@ "title": "Self-Hosted Router", "algoliaFilters": ["docset:router"], "sidebar": { - "Routing": { - "About Router": "/routing/about-router" - }, "Reference": { "Migration": { "From Gateway": "/reference/migration/from-gateway" diff --git a/docs/source/images/get-started/connector-dark.svg b/docs/source/images/get-started/connector-dark.svg new file mode 100644 index 0000000000..d7ebaa51d7 --- /dev/null +++ b/docs/source/images/get-started/connector-dark.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/images/get-started/connector.svg b/docs/source/images/get-started/connector.svg new file mode 100644 index 0000000000..b1dc3861d8 --- /dev/null +++ b/docs/source/images/get-started/connector.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/images/router-quickstart-sandbox.jpg b/docs/source/images/router-quickstart-sandbox.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c26adceed6e13b64768b0d4a8583b447f8854b71 GIT binary patch literal 552843 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盆(ovApNyJZE0L}P(y{U+hrG+ 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⁢t3n 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

$?2 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≥(>&uawp}U8iDLdI!8k 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 z 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%0Dju;8ylFF6}gcbLRI45B*_S{pk81V)k>N>uTJy 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ℑ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ʞVv3m35n2s|hF>)V+A%R%_NHUZbI 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
+ + +
+ +Enable richer error reporting via `preview_extended_error_metrics` and `redaction_policy` router configurations. + +```yaml title="router.yaml" +telemetry: + apollo: + errors: + preview_extended_error_metrics: enabled # (default: disabled) + subgraph: + all: + # By default, subgraphs should report errors to GraphOS + send: true # (default: true) + redaction_policy: extended # (default: strict) + subgraphs: + account: # Override the default behavior for the "account" subgraph + send: false +``` + +When enabled, the router sends metrics to Studio +with additional attributes including the `service` and `code` found in [GraphQL error extensions](https://spec.graphql.org/October2021/#sec-Errors.Error-result-format) (`errors[].extensions.service` and `errors[].extensions.code`, respectively). -If you're writing a plugin, you can get the Studio Trace ID by reading the value of `apollo_operation_id` from the context. +Additional diagnostic capabilities available in Studio now support viewing errors by `service` or `code`. The `service` dimension refers to the subgraph or connector where the error originated from. +The `code` refers to the specific type of error that was raised by the router, federated subgraphs, or connectors. - \ No newline at end of file +##### Cardinality limitations + +At scale, this feature is known to hit cardinality limitations in the OTel reporting agent. When this happens, some of the extended metrics attributes may no longer +be visible in Studio. In the case of cardinality warnings in logs, adjusting the Apollo batch processing configuration to send reports more frequently can help to alleviate this. + +Additionally, this feature may increase the Router's memory usage profile. Similarly, lowering the `scheduled_delay` can help to alleviate that as well. See the example below. + +```yaml title="router.yaml" +telemetry: + apollo: + batch_processor: + scheduled_delay: 100ms # default is 5s +``` diff --git a/docs/source/routing/header-propagation.mdx b/docs/source/routing/header-propagation.mdx index 5d2fae3323..f2c769ae2e 100644 --- a/docs/source/routing/header-propagation.mdx +++ b/docs/source/routing/header-propagation.mdx @@ -42,7 +42,9 @@ You can specify which headers to propagate based on a matching [regex pattern](h -The router _never_ propagates so-called [hop-by-hop headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#hop-by-hop_headers) (such as `Content-Length`) when propagating by pattern. + The router _never_ propagates so-called [hop-by-hop + headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#hop-by-hop_headers) (such as `Content-Length`) + when propagating by pattern. @@ -169,6 +171,44 @@ headers: With this ordering, first all headers are added to the propagation list, then the `test` header is removed. +## Rule fallthrough + +Headers will only propagate to a target header once via the first matching rule to do so. Ensure that defaulting of headers is done in the last rule so that other rules are not ignored: + +

+ +```yaml title="bad_configuration.yaml" +headers: + all: + request: + - propagate: + named: "some-header" + default: "some default" + - propagate: + named: "some-other-header" + rename: "some-header" +``` + +In this example, `some-other-header` will not be propagated to `some-header` because it has already been defaulted by the previous rule. + +To correctly have fallthrough of rules make sure that any defaulting is done in the last rule: + +

+ +```yaml title="good_configuration.yaml" +headers: + all: + request: + - propagate: + named: "some-header" + - propagate: + named: "some-other-header" + rename: "some-header" + default: "some default" +``` + +With this ordering, the `some-other-header` will be propagated to `some-header` if `some-header` is not present. If no header is present, `some-header` will be set to the default. + ## Example Here's a complete example showing all the possible configuration options in use: @@ -212,6 +252,7 @@ headers: value: "accounts" ``` + ## Response header propagation It is not currently possible to propagate response headers from subgraphs to clients using YAML configuration alone. However, you _can_ achieve this using [Rhai scripting](/graphos/routing/customization/rhai). @@ -258,7 +299,8 @@ fn subgraph_service(service, subgraph) { -If you require a configuration-based solution for response header propagation, [please leave a comment on our issue tracker](https://github.com/apollographql/router/issues/1284). + If you require a configuration-based solution for response header propagation, [please leave a comment on our issue + tracker](https://github.com/apollographql/router/issues/1284). diff --git a/docs/source/reference/graphos-features.mdx b/docs/source/routing/license.mdx similarity index 61% rename from docs/source/reference/graphos-features.mdx rename to docs/source/routing/license.mdx index 129e790afa..8b21dd4ce4 100644 --- a/docs/source/reference/graphos-features.mdx +++ b/docs/source/routing/license.mdx @@ -1,41 +1,16 @@ --- -title: GraphOS Router Features -subtitle: Use router features enabled by GraphOS and the Enterprise plan -description: Unlock Enterprise features for the GraphOS Router by connecting it to Apollo GraphOS. -redirectFrom: - - /router/enterprise-features +title: GraphOS Router License +description: Learn how to manage the license for an Apollo GraphOS Router, including offline license configuration. --- -A router connected to GraphOS, whether cloud-hosted or self-hosted, is called a **GraphOS Router**. It has access to specific GraphOS features depending on the connected GraphOS organization's plan. Refer to the [pricing page](https://www.apollographql.com/pricing#graphos-router) to compare GraphOS Router features across plan types. +## The GraphOS license -## GraphOS Router features +Whenever your instance of GraphOS Router starts up and connects to GraphOS, it fetches a __license__, which is the credential that authorizes its use of GraphOS features: - - -For details on these features, see [this blog post](https://blog.apollographql.com/apollo-router-v1-12-improved-router-security-performance-and-extensibility) in addition to the documentation links above. - -## Enterprise plan features - - - -Try the Enterprise features of GraphOS Router with a free [GraphOS trial](https://www.apollographql.com/pricing). - - - -To enable support for Enterprise features in GraphOS Router: - -- Your organization must have a [GraphOS Enterprise plan](https://www.apollographql.com/pricing/). -- You must run GraphOS Router v1.12 or later. [Download the latest version.](/graphos/reference/router/self-hosted-install#1-download-and-extract-the-router-binary) - - Certain Enterprise features might require a later router version. See a particular feature's documentation for details. -- Your router instances must connect to GraphOS with a **graph API key** and **graph ref** associated with your organization. - - You connect your router to GraphOS by setting [these environment variables](/graphos/reference/router/configuration#environment-variables) when starting the router. - - If your router _already_ connects to your GraphOS Enterprise organization, no further action is required. - -After enabling support, you can begin using all [Enterprise features](#graphos-router-features). +A router instance retains its license for the duration of its execution. If you stop a router instance and then later start a new instance on the same machine, it must fetch a new license. -### The Enterprise license +Licenses are served via [Apollo Uplink](/graphos/routing/uplink), the same multi-cloud endpoint that your router uses to fetch its supergraph schema from GraphOS. Because of this, licenses introduce no additional network dependencies, meaning your router's uptime remains unaffected. To learn more about multi-cloud Uplink, read the [Apollo blog post](https://www.apollographql.com/blog/announcement/backend/introducing-multi-cloud-support-for-apollo-uplink). -Whenever your router instance starts up and connects to GraphOS, it fetches a **license**, which is the credential that authorizes its use of Enterprise features: ```mermaid flowchart LR; @@ -48,27 +23,62 @@ flowchart LR; router--"Fetches supergraph schema
and license"-->uplink; ``` -A router instance retains its license for the duration of its execution. If you stop a router instance and then later start a new instance on the same machine, it must fetch a new license. +A router instance's license is valid for the duration of your organization's current subscription billing period (plus a [grace period](#grace-period-for-expired-plans)), even if the router temporarily becomes disconnected from GraphOS. -Licenses are served via [Apollo Uplink](/graphos/routing/uplink), the same multi-cloud endpoint that your router uses to fetch its supergraph schema from GraphOS. Because of this, licenses introduce no additional network dependencies, meaning your router's uptime remains unaffected. To learn more about multi-cloud Uplink, read the [Apollo blog post](https://www.apollographql.com/blog/announcement/backend/introducing-multi-cloud-support-for-apollo-uplink). +## Licenses with local development -A router instance's license is valid for the duration of your organization's current subscription billing period (plus a [grace period](#grace-period-for-expired-plans)), even if the router temporarily becomes disconnected from GraphOS. +You might also need to run an GraphOS Router instance on your local machine, such as with the [`rover dev`](/graphos/graphs/local-development) command. It's likely that your local router instance doesn't connect to GraphOS to get its supergraph schema from Uplink. For example, you can run `rover dev` to perform composition locally. + +**You _can_ use GraphOS Router features with a locally composed supergraph schema!** To do so, your router must still connect to GraphOS to obtain its [license](#the-graphos-license). + +### Set up local development + +These steps work both for running the router executable directly (`./router`) and for running it via `rover dev`: + +1. [Create a new variant](/graphos/graphs/federated-graphs/#adding-a-variant-via-the-rover-cli) for your supergraph that you'll use _only_ to fetch GraphOS licenses. + - Give the variant a name that clearly distinguishes it from variants that track schemas and metrics. + - Every team member that runs a router locally can use this same variant. + - When you create this variant, publish a dummy subgraph schema like the following (your router won't use it): + + ```graphql + type Query { + hello: String + } + ``` + +2. Create a [graph API key](/graphos/platform/access-management/api-keys#graph-api-keys) for your supergraph and assign it the __Contributor__ role. + - We recommend creating a separate graph API key for _each team member_ that will run the router locally. + +3. When you start up your local router with your usual command, set the `APOLLO_GRAPH_REF` and `APOLLO_KEY` environment variables for that command: + + ```bash + APOLLO_GRAPH_REF="..." APOLLO_KEY="..." ./router --supergraph schema.graphql + ``` - + - The value of `APOLLO_GRAPH_REF` is the graph ref for the new, license-specific variant you created (for example, `docs-example-graph@local-licenses`). + - The value of `APOLLO_KEY` is the graph API key you created. -### Offline Enterprise license +4. Your router will fetch an GraphOS license while using its locally composed supergraph schema. - +## Offline license + + -Offline Enterprise license support is available on an as-needed basis. Send a request to your Apollo contact to enable it for your GraphOS Studio organization. +Offline license support is available on an as-needed basis to Enterprise organizations. Send a request to your Apollo contact to enable it for your GraphOS Studio organization. Running your GraphOS Router fleet while fully connected to GraphOS is the best choice for most Apollo users. However, some scenarios can prevent your routers from connecting to GraphOS for an extended period, ranging from disasters that break connectivity to isolated sites operating with air-gapped networks. If you need to restart or rapidly scale your entire router fleet, but you're unable to communicate with Apollo Uplink, new router instances won't be able to serve traffic. -To support long-term disconnection scenarios, GraphOS supports **offline Enterprise licenses** for the GraphOS Router. An offline license enables routers to start and serve traffic without a persistent connection to GraphOS. The functionality available with an offline license is much like being fully connected to GraphOS, including the GraphOS [schema delivery pipeline](/graphos/platform/schema-management#schema-delivery) for supergraph CI (schema checks, linting, contracts, etc.). +To support long-term disconnection scenarios, GraphOS supports __offline licenses__ for the GraphOS Router. An offline license enables routers to start and serve traffic without a persistent connection to GraphOS. Instead of fetching its supergraph schema from Apollo Uplink, an offline router gets its supergraph schema from a local supergraph schema file. + + + +You can use the GraphOS [schema delivery pipeline](/graphos/platform/schema-management#schema-delivery) for supergraph CI (schema checks, linting, contracts, etc.) in an online environment to manage the local supergraph schema file provided to your offline router. + + An offline license can be retrieved from GraphOS with the [`rover license fetch`](/rover/commands/license) command. @@ -76,15 +86,15 @@ With an offline license, a router can either be fully disconnected from GraphOS -A router using an offline license requires [the use of local manifests](/graphos/routing/security/persisted-queries#experimental_local_manifests) when using [safelisting with persisted queries](/graphos/routing/security/persisted-queries), otherwise it will not work as designed when the router is disconnected from Uplink. +A router using an offline license requires [the use of local manifests](/graphos/routing/security/persisted-queries#local_manifests) when using [safelisting with persisted queries](/graphos/routing/security/persisted-queries), otherwise it will not work as designed when the router is disconnected from Uplink. An offline license is valid for the lesser of the duration of your contract with Apollo, or one year, with an added grace period of 28 days. You are responsible for keeping your offline license files up to date within your infrastructure by rerunning `rover license fetch` to fetch updated license files. -#### Set up offline license for the GraphOS Router +### Set up offline license for the GraphOS Router -Follow these steps to configure an GraphOS Router to use an offline Enterprise license: +Follow these steps to configure an GraphOS Router to use an offline license: 1. Fetch an offline license by running [`rover license fetch`](/rover/commands/license/#license-fetch) with the ID of the graph from which you want to fetch a license: @@ -115,63 +125,28 @@ Follow these steps to configure an GraphOS Router to use an offline Enterprise l These metrics are necessary for several important GraphOS features (operations checks, field insights, operation traces, contracts). Sending them best-effort incurs no performance or uptime penalties. -### Licenses with local development - -You might also need to run an GraphOS Router instance on your local machine, such as with the [`rover dev`](/graphos/graphs/local-development) command. It's likely that your local router instance doesn't connect to GraphOS to get its supergraph schema from Uplink. For example, you can run `rover dev` to perform composition locally. - -**You _can_ use Enterprise router features with a locally composed supergraph schema!** To do so, your router must still connect to GraphOS to obtain its [license](#the-enterprise-license). - -#### Set up local development - -These steps work both for running the router executable directly (`./router`) and for running it via `rover dev`: - -1. [Create a new variant](/graphos/graphs/federated-graphs/#adding-a-variant-via-the-rover-cli) for your supergraph that you'll use _only_ to fetch Enterprise licenses. - - Give the variant a name that clearly distinguishes it from variants that track schemas and metrics. - - Every team member that runs a router locally can use this same variant. - - When you create this variant, publish a dummy subgraph schema like the following (your router won't use it): - - ```graphql - type Query { - hello: String - } - ``` - -2. Create a [graph API key](/graphos/platform/access-management/api-keys#graph-api-keys) for your supergraph and assign it the **Contributor** role. - - We recommend creating a separate graph API key for _each team member_ that will run the router locally. - -3. When you start up your local router with your usual command, set the `APOLLO_GRAPH_REF` and `APOLLO_KEY` environment variables for that command: - - ```bash - APOLLO_GRAPH_REF="..." APOLLO_KEY="..." ./router --supergraph schema.graphql - ``` - - - The value of `APOLLO_GRAPH_REF` is the graph ref for the new, license-specific variant you created (for example, `docs-example-graph@local-licenses`). - - The value of `APOLLO_KEY` is the graph API key you created. - -4. Your router will fetch an Enterprise license while using its locally composed supergraph schema. - -### Common errors +## Troubleshooting -**If your router doesn't successfully connect to GraphOS,** it logs an error that begins with one of the following strings if any Enterprise features are enabled: +**If your router doesn't successfully connect to GraphOS,** it logs an error that begins with one of the following strings if any GraphOS features are enabled: | Error Message | Description | |-----------------------------|-------------| | `Not connected to GraphOS.` | At least one of the `APOLLO_KEY` and `APOLLO_GRAPH_REF` environment variables wasn't set on router startup. | -| `License not found.` | The router connected to GraphOS with credentials that are not associated with a GraphOS Enterprise plan. | -| `License has expired.` | Your organization's GraphOS Enterprise subscription has ended. **Your router will stop processing incoming requests at the end of the standard [grace period](#grace-period-for-expired-plans).** | +| `License not found.` | The router connected to GraphOS with credentials that are not associated with a GraphOS plan. | +| `License has expired.` | Your organization's GraphOS subscription has ended. **Your router will stop processing incoming requests at the end of the standard [grace period](#grace-period-for-expired-plans).** | -## Turning off Enterprise features +## Turning off GraphOS features -To turn off an Enterprise feature, remove all of its associated configuration keys from your router's [YAML config file](/graphos/reference/router/configuration#yaml-config-file). +To turn off an GraphOS feature, remove all of its associated configuration keys from your router's [YAML config file](/graphos/reference/router/configuration#yaml-config-file). ## Grace period for expired plans -If your organization terminates its GraphOS Enterprise subscription, your router's Enterprise license is considered expired at the end of your final paid subscription period. GraphOS provides a grace period for expired licenses so that you can turn off Enterprise features before they produce breaking errors in your router. +If your organization terminates its GraphOS subscription, your router's license is considered expired at the end of your final paid subscription period. GraphOS provides a grace period for expired licenses so that you can turn off GraphOS features before they produce breaking errors in your router. -If your router has an expired Enterprise license, its behavior degrades according to the following schedule, _if_ any Enterprise features are still enabled: +If your router has an expired GraphOS license, its behavior degrades according to the following schedule, _if_ any GraphOS features are still enabled: - **For the first 14 days after your license expires,** your router continues to behave as though it has a valid license. - **After 14 days,** your router begins a **soft outage**: it continues processing client requests, but it emits logs and metrics that indicate it's experiencing an outage. - **After 28 days,** your router begins a **hard outage**. It no longer processes incoming client requests and continues emitting logs and metrics from the soft outage. -Your router resumes normal functioning whenever you renew your GraphOS Enterprise subscription or turn off all [Enterprise features](#list-of-features). +Your router resumes normal functioning whenever you renew your GraphOS subscription or turn off all [GraphOS features](#list-of-features). diff --git a/docs/source/reference/migration/from-gateway.mdx b/docs/source/routing/migration/from-gateway.mdx similarity index 97% rename from docs/source/reference/migration/from-gateway.mdx rename to docs/source/routing/migration/from-gateway.mdx index e1fa6222a3..72de828208 100644 --- a/docs/source/reference/migration/from-gateway.mdx +++ b/docs/source/routing/migration/from-gateway.mdx @@ -142,3 +142,9 @@ Refer the full list of [router error codes](/router/errors) for any changes to y ## Reporting migration issues If you encounter a migration issue that isn't resolved by this article, please search for existing [GitHub discussions](https://github.com/apollographql/router/discussions/) and start a new discussion if you don't find what you're looking for. + +## Additional resources + +You can use the Apollo Solutions [router proxy migration strategy repository](https://github.com/apollosolutions/router-node-proxy) to run both `@apollo/gateway` and Apollo Router and conditionally proxy traffic to the router for a gradual release. + + diff --git a/docs/source/routing/observability/client-awareness.mdx b/docs/source/routing/observability/client-awareness.mdx deleted file mode 100644 index f7660f85a8..0000000000 --- a/docs/source/routing/observability/client-awareness.mdx +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: Client Awareness -subtitle: Configure client awareness in the router -description: Configure client awareness in the Apollo GraphOS Router or Apollo Router Core to separate the metrics and operations per client. ---- - -import { Link } from "gatsby"; - -The GraphOS Router and Apollo Router Core support [client awareness](/graphos/metrics/client-awareness/) by default. If the client sets the headers `apollographql-client-name` and `apollographql-client-version` in its HTTP requests, GraphOS Studio can separate the metrics and operations per client. - -## Overriding client awareness headers - -Different header names can be used by updating the configuration file. If those headers will be sent by a browser, they must be allowed in the [CORS (Cross Origin Resource Sharing) configuration](/router/configuration/cors), as follows: - -```yaml title="router.yaml" -telemetry: - apollo: - # defaults to apollographql-client-name - client_name_header: MyClientHeaderName - # defaults to apollographql-client-version - client_version_header: MyClientHeaderVersion -cors: - # The headers to allow. - # (Defaults to [ Content-Type ], which is required for GraphOS Studio) - allow_headers: [ Content-Type, MyClientHeaderName, MyClientHeaderVersion] -``` diff --git a/docs/source/routing/observability/client-id-enforcement.mdx b/docs/source/routing/observability/client-id-enforcement.mdx index da882d2ba7..e8b5077c1e 100644 --- a/docs/source/routing/observability/client-id-enforcement.mdx +++ b/docs/source/routing/observability/client-id-enforcement.mdx @@ -1,5 +1,5 @@ --- -title: Client ID Enforcement +title: Client Awareness and Enforcement subtitle: Require client details and operation names to help monitor schema usage description: Improve GraphQL operation monitoring by tagging operations with with client details. See code examples for Apollo GraphOS Router and Apollo Server. published: 2022-05-31 @@ -7,19 +7,77 @@ id: TN0001 tags: [server, observability, router] redirectFrom: - /technotes/TN0001-client-id-enforcement/ + - /graphos/routing/observability/client-awareness --- -As part of GraphOS Studio metrics reporting, servers can [tag reported operations with the requesting client's name and version](/graphos/metrics/client-awareness). This helps graph maintainers understand which clients are using which fields in the schema. +Metrics about GraphQL schema usage are more insightful when information about clients using the schema is available. Understanding client usage can help you reshape your schema to serve clients more efficiently. +As part of GraphOS Studio metrics reporting, servers can [tag reported operations with the requesting client's name and version](/graphos/metrics/client-awareness). +This **client awareness** helps graph maintainers understand which clients are using which fields in the schema. -Clients can (and should) also [name their GraphQL operations](/react/data/operation-best-practices/#name-all-operations), which provides more context around how and where data is being used. +Apollo's GraphOS Router and Apollo Server can enable client awareness by requiring metadata about requesting clients. +The router supports client awareness by default. If the client sets its name and version with the headers `apollographql-client-name` and `apollographql-client-version` in its HTTP requests, GraphOS Studio can separate the metrics and operations per client. -Together, these pieces of information help teams monitor their graph and make changes to it safely. We strongly encourage that your GraphQL gateway require client details and operation names from all requesting clients. + -## Enforcing in GraphOS Router +The client name is also used by the persisted queries feature. -The GraphOS Router supports client awareness by default if the client sets the `apollographql-client-name` and `apollographql-client-id` in their requests. These values can be overridden using the [router configuration file](/router/managed-federation/client-awareness/) directly. + -Client headers can also be enforced using a [Rhai script](/graphos/routing/customization/rhai) on every incoming request. + +Clients should [name their GraphQL operations](/react/data/operation-best-practices/#name-all-operations) to provide more context around how and where data is being used. + +## Why enforce client reporting? + +Client metadata enables better insights into schema usage, such as: + +- **Identifying which clients use which fields**: This facilitates usage monitoring and safe deprecation of fields. +- **Understanding traffic patterns**: This helps optimize schema design based on real-world client behavior. +- **Improving operation-level observability**: This provides details for debugging and performance improvements. + +Apollo strongly recommends requiring client name, client version, and operation names in all incoming GraphQL requests. + +## Customizing client awareness information + +The GraphOS Router supports client awareness by default if the client sets the `apollographql-client-name` and `apollographql-client-id` in their requests. +These values can be overridden using the [router configuration file](/router/managed-federation/client-awareness/) directly. +You can use a Rhai script to _enforce_ that clients include metadata. + +### Via router configuration + +If headers with customized names need to be sent by a browser, they must be allowed in the [CORS (Cross Origin Resource Sharing) configuration](/router/configuration/cors), as follows: + +```yaml title="router.yaml" +telemetry: + apollo: + # defaults to apollographql-client-name + client_name_header: MyClientHeaderName + # defaults to apollographql-client-version + client_version_header: MyClientHeaderVersion +cors: + # The headers to allow. + # (Defaults to [ Content-Type ], which is required for GraphOS Studio) + allow_headers: [ Content-Type, MyClientHeaderName, MyClientHeaderVersion] +``` + +### Via router customization + +The client awareness headers are parsed out of the HTTP request immediately in the router request lifecycle. If you need to dynamically set the values, you should instead mutate the values in the [GraphQL context](/graphos/routing/customization/overview#request-context) after they have been parsed, but before they are sent. You can do so by using Rhai scripts or coprocessors and hooking into the `RouterRequest` lifecycle stage. + +```rhai title="client-name-version.rhai" +fn router_service(service) { + const request_callback = Fn("process_request"); + service.map_request(request_callback); +} + +fn process_request(request) { + // ... logic to parse request and calculate name/version + request.context["apollo::telemetry::client_name"] = "custom name"; + request.context["apollo::telemetry::client_version"] = "custom version"; +} + +### Enforcing via Rhai script + +Client headers can be enforced using a [Rhai script](/graphos/routing/customization/rhai) on every incoming request. ```rhai title="client-id.rhai" fn supergraph_service(service) { @@ -61,6 +119,9 @@ fn process_request(request) { } ``` +See a runnable example Rhai script in the [Apollo Solutions repository](https://github.com/apollosolutions/example-rhai-client-id-validation). + + If you're an enterprise customer looking for more material on this topic, try the [Enterprise best practices: Router extensibility](https://www.apollographql.com/tutorials/router-extensibility) course on Odyssey. @@ -69,7 +130,7 @@ Not an enterprise customer? [Learn about GraphOS for Enterprise.](https://www.ap -## Enforcing in Apollo Server +## Enforcing headers in Apollo Server If you're using Apollo Server for your gateway, you can require client metadata in every incoming request with a [custom plugin](/apollo-server/integrations/plugins/): diff --git a/docs/source/routing/observability/debugging-client-requests.mdx b/docs/source/routing/observability/debugging-client-requests.mdx new file mode 100644 index 0000000000..6126b3cfaa --- /dev/null +++ b/docs/source/routing/observability/debugging-client-requests.mdx @@ -0,0 +1,79 @@ +--- +title: Debugging Client Requests to GraphOS Router +subtitle: Options for analyzing and debugging incoming requests +description: Learn how to use GraphOS router telemetry and GraphOS Insights to inspect and debug incoming HTTP client requests. +context: + - telemetry +--- + +By default, the GraphOS Router operates [without generating HTTP request logs or exporting telemetry metrics beyond what it sends to GraphOS](/graphos/routing/observability). +This default minimizes potentially high observability costs that can result from high request volumes. +If you need more data than the default [GraphOS Insights](/graphos/platform/insights), you can configure your router to collect and export additional telemetry. + +## Using GraphOS Insights + +GraphOS Studio lets you analyze data from failed requests, such as GraphQL error messages ([if enabled](/graphos/routing/graphos-reporting#errors)) and the ID of the client making the request. You can also [segment your insights data](/graphos/platform/insights/client-segmentation) based on the client ID. + + + +[Learn how to ensure client IDs are included in all requests.](/graphos/routing/observability/client-id-enforcement) + + + +## Enabling additional telemetry + +You can instrument [router telemetry](/graphos/routing/observability/telemetry) if you need information outside of what's presented in GraphOS Studio to debug client requests. + + + +If you want to debug client requests in your own environment, Apollo recommends first doing so in a non-production environment or using logic to debug on a per-request basis. + + + +### Logging requests + +You can conditionally include request bodies, including GraphQL operations, in your telemetry based on specific [conditions](/graphos/reference/router/telemetry/instrumentation/conditions). Apply these conditions on a router request [event](/graphos/reference/router/telemetry/instrumentation/events) like so: + +```yaml title="router.yaml" +telemetry: + instrumentation: + events: + router: + request: + level: info + condition: # Only log the router request if you sent `x-log-request` with the value `enabled` + eq: + - request_header: x-log-request + - "enabled" +``` + +### Debugging router logs + +By default, the router uses the `info` level for its logging. [Enabling other logging levels](/graphos/reference/router/telemetry/log-exporters/overview) can help debug specific scenarios. Using non-`info` level configurations is only recommended for local or non-production environments. + +## Rhai scripts and coprocessors + +Hooking into the router service layer with either [Rhai scripts](/graphos/routing/customization/rhai) or [coprocessors](/graphos/routing/customization/coprocessor) gives you access to the full HTTP request before processing occurs. You can use either Rhai scripts or coprocessors to add custom logic for what to log and when. + +See the Apollo Solutions ["Hello World" coprocessor](https://github.com/apollosolutions/example-coprocessor-helloworld) for an example of a coprocessor that simply logs the router's payload. + + + +## Alternative cloud services + +If you are deploying the router to a cloud service, you likely already have access to the raw HTTP logs through other services like load balancers. You should be able to find specific client request logs for a particular operation using the operation hash or trace ID. Refer to the docs for your cloud providers for more information. Popular cloud provider links are provided below. + +### Amazon Web Services + +- [AWS CloudWatch Logs](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/WhatIsCloudWatchLogs.html) +- [AWS Elastic Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-access-logs.html) + +### Google Cloud Platform + +- [Google Cloud Observability](https://cloud.google.com/logging/docs/log-analytics) +- [Google Cloud Load Balancing](https://cloud.google.com/load-balancing/docs/l7-internal/monitoring) + +### Microsoft Azure + +- [Azure App Service Logging](https://learn.microsoft.com/en-us/azure/app-service/troubleshoot-diagnostic-logs) +- [Azure Load Balancer](https://learn.microsoft.com/en-us/azure/load-balancer/monitor-load-balancer) diff --git a/docs/source/routing/observability/debugging-subgraph-requests.mdx b/docs/source/routing/observability/debugging-subgraph-requests.mdx index 4e11c45962..61a37b0a8b 100644 --- a/docs/source/routing/observability/debugging-subgraph-requests.mdx +++ b/docs/source/routing/observability/debugging-subgraph-requests.mdx @@ -9,17 +9,25 @@ redirectFrom: - /technotes/TN0039-debugging-subgraph-requests/ --- -As your graph grows, you may need to debug a problematic query for one reason or another. The GraphOS Router and `@apollo/gateway` both serve as an entry point into your federated graph and offer ways to debug requests. +As your graph grows, you may need to debug a problematic query for one reason or another. The Apollo Router (Apollo GraphOS Router or Apollo Router Core) and `@apollo/gateway` both serve as an entry point into your federated graph and offer ways to debug requests. Each client request goes through a process called [query planning](/graphos/explorer/additional-features/#query-plans-for-supergraphs) that generates the subgraph requests to execute. You can log out the query plan in both the router and gateway. ## Output query plans with headers -With router v0.16.0+ and `@apollo/gateway` v2.5.4+, you can pass the following headers to return the query plans in the GraphQL response extensions: +With the Apollo GraphOS Router or Apollo Router Core v1.61.0+ or v2.x+, you can pass the following header to return the query plans in the GraphQL response extensions: + +- The header `Apollo-Expose-Query-Plan` must be set with one of the following options: + - A value of `true` returns a human-readable string and JSON blob of the query plan + - A value of `dry-run` will generate the query plan and then abort execution. This can be helpful if you want to warm up any internal or external [query plan caches](/graphos/routing/performance/caching/in-memory) + +### Legacy header options + +In older versions of the router v0.16.0+ and [`@apollo/gateway`](/apollo-server/using-federation/api/apollo-gateway/) v2.5.4+, you can pass the following headers to return the query plans in the GraphQL response extensions: - Including the `Apollo-Query-Plan-Experimental` header returns the query plan in the response extensions -- Additionally, including the `Apollo-Query-Plan-Experimental-Format` header with one of the supported options changes the output format: - - A value of `prettified` returns a human-readable string of the query plan +- Additionally including the `Apollo-Query-Plan-Experimental-Format` header with one of the supported options changes the output format: + - A value of `prettified` returns a human-readable string of the query plan - A value of `internal` returns a JSON representation of the query plan ## Log router subgraph calls diff --git a/docs/source/routing/observability/federated-trace-data.mdx b/docs/source/routing/observability/federated-trace-data.mdx new file mode 100644 index 0000000000..3aa5f1da67 --- /dev/null +++ b/docs/source/routing/observability/federated-trace-data.mdx @@ -0,0 +1,82 @@ +--- +title: Federated Trace Data +subtitle: Reporting fine-grained performance metrics +description: Explore how federated traces enable fine-grained performance metrics reporting. Learn about the reporting flow and how tracing data is exposed and aggregated. +--- + +One of the many benefits of using GraphQL as an API layer is that it enables fine-grained, field-level [tracing](/graphos/metrics/#resolver-level-traces) of every executed operation. The [GraphOS platform](/graphos/) can consume and aggregate these traces to provide detailed insights into your supergraph's usage and performance. + +Your supergraph's router can generate _federated traces_ and [report them to GraphOS](/graphos/metrics/sending-operation-metrics). A federated trace is assembled from timing and error information provided by each subgraph that helps resolve a particular operation. + +## Reporting flow + +The overall flow of a federated trace is as follows: + +1. The router receives an operation from a client. +2. The router generates a [query plan](/federation/query-plans) for the operation and delegates sub-queries to individual subgraphs. +3. Each queried subgraph returns response data to the router. + - The `extensions` field of each response includes trace data for the corresponding sub-query. + - The subgraph must support the federated trace format to include trace data in its response. See [this section](#in-your-subgraphs). +4. The router collects the set of sub-query traces from subgraphs and arranges them in the shape of the query plan. +5. The router [reports the federated trace to GraphOS](/graphos/metrics/sending-operation-metrics/) for processing. + +In summary, subgraphs report timing and error information to the router, and the router is responsible for aggregating those metrics and reporting them to GraphOS. + +## Enabling federated tracing + +### In your subgraphs + +For a subgraph to include trace data in its responses to your router, it must use a [subgraph-compatible library](/graphos/reference/federation/compatible-subgraphs) that supports the trace format. + +To check whether your subgraph library supports federated tracing, see the `FEDERATED TRACING` entry for the library on [this page](/graphos/reference/federation/compatible-subgraphs). + +If your library does support federated tracing, see its documentation to learn how to enable the feature. + + + +If your subgraph uses Apollo Server with `@apollo/subgraph`, federated tracing is enabled by default. You can customize this behavior with Apollo Server's [inline trace plugin](/apollo-server/api/plugin/inline-trace). +If your subgraph uses the Ariadne Python library, this [demo repository](https://github.com/apollosolutions/ariadne-federated-traces) shows how to add support for federated tracing. + + + +### In the Apollo Router + +See [Sending Apollo Router usage data to GraphOS](/router/configuration/telemetry/apollo-telemetry). + +### In `@apollo/gateway` + +You can use the `@apollo/server` package's [built-in usage reporting plugin](/apollo-server/api/plugin/usage-reporting) to enable federated tracing for your gateway. Provide an API key to your gateway via the `APOLLO_KEY` environment variable for the gateway to report metrics to the default ingress. To ensure that subgraphs do not report metrics as well, either do not provide them with an `APOLLO_KEY` or install the [`ApolloServerPluginUsageReportingDisabled` plugin](https://www.apollographql.com/docs/apollo-server/api/plugin/usage-reporting/) in your `ApolloServer`. + +These options will cause the Apollo gateway to collect tracing information from the underlying subgraphs and pass them on, along with the query plan, to the Apollo metrics ingress. + + + +By default, metrics are reported to the `current` GraphOS variant. To change the variant for reporting, set the `APOLLO_GRAPH_VARIANT` environment variable. + + + +## How tracing data is exposed from a subgraph + + + +This section explains how your router communicates with subgraphs around encoded tracing information. It is not necessary to understand in order to enable federated tracing. + + + +Your router inspects the `extensions` field of all subgraph responses for the presence of an `ftv1` field. This field contains a representation of the tracing information for the sub-query that was executed against the subgraph, sent as the Base64 encoding of the [protobuf representation](https://github.com/apollographql/apollo-server/blob/main/packages/usage-reporting-protobuf/src/reports.proto) of the trace. + +To obtain this information from a subgraph, the router includes the header pair `'apollo-federation-include-trace': 'ftv1'` in its request (if it's [configured to collect trace data](#in-the-apollo-router)). If the subgraph [supports federated traces](#in-your-subgraphs), it attaches tracing information in the `extensions` field of its response. + +## How traces are constructed and aggregated + +Your router constructs traces in the shape of the [query plan](/federation/query-plans/), embedding an individual `Trace` for each fetch that is performed in the query plan. This indicates the sub-query traces, as well as which order they were fetched from the underlying subgraphs. + +The field-level statistics that Apollo aggregates from these traces are collected for the fields over which the operation was executed in the subgraphs. In other words, field stats are collected based on the operations the query planner makes, instead of the operations that the clients make. On the other hand, operation-level statistics are aggregated over the operations executed by the client, which means that even if query-planning changes, statistics still correspond to the same client-delivered operation. + +## How errors work + +The Apollo Platform provides functionality to modify error details for the client, via the [`formatError`](/apollo-server/data/errors#for-client-responses) option. Additionally, there is functionality to support modifying error details for the metrics ingress, via the [`sendErrors`](/apollo-server/data/errors#for-apollo-studio-reporting) option to the [inline trace plugin](/apollo-server/api/plugin/inline-trace/). + +When modifying errors for the client, you might want to use this option to hide implementation details, like database errors, from your users. When modifying errors for reporting, you might want to obfuscate or redact personal information, like user IDs or emails. + +Since federated metrics collection works by collecting latency and error information from a set of distributed subgraphs, these options are respected from those subgraphs as well as from the router. Subgraphs embed errors in their `ftv1` extension after the `rewriteError` method (passed to the inline trace plugin in the subgraph, not the usage reporting plugin in the gateway!) is applied, and the gateway only reports the errors that are sent via that extension, ignoring the format that downstream errors are reported to end users. This functionality enables subgraph implementers to determine how error information should be displayed to both users and in metrics without needing the gateway to contain any logic that might be subgraph-specific. diff --git a/docs/source/routing/observability/index.mdx b/docs/source/routing/observability/index.mdx index 48d531e69f..6964b15fe3 100644 --- a/docs/source/routing/observability/index.mdx +++ b/docs/source/routing/observability/index.mdx @@ -1,67 +1,68 @@ --- -title: Observability with GraphOS +title: GraphOS Observability Overview subtitle: Capture and export signals about supergraph health with GraphOS and router telemetry description: Learn how to collect supergraph metrics in order to monitor and optimize your GraphQL usage and performance. Collect raw metrics, insights, and alerts with Apollo GraphOS, GraphOS Studio, and GraphOS Router and Apollo Router Core. redirectFrom: - /federation/performance/monitoring/ --- -Monitoring a supergraph requires gathering metrics about each client, server, subgraph, and router involved in sending or handling requests. Ideally, the entire request pipeline—from client to router to subgraph and back—is instrumented with metrics that can be collected and exported for analysis. +Apollo GraphOS provides the observability signals and tools your team needs to monitor the health and performance of your deployed supergraph. It collects operation metrics from across your supergraph and presents them in its Studio Insights suite to help you visualize and analyze the state of your supergraph. -Apollo GraphOS provides the observability signals and tools your team needs to maintain the health and performance of your deployed supergraphs. Via declarative configuration, GraphOS enables routers to collect GraphQL operation and field metrics and report them back. GraphOS also specifies how to capture metrics on the clients and subgraphs handling operations. +## How observability in GraphOS works -## Understanding runtime health with router telemetry +GraphOS collects its metrics from clients, routers, and subgraphs. By default, GraphOS Router automatically [reports operation and field usage metrics to GraphOS Studio](/graphos/platform/insights/sending-operation-metrics#from-the-apollo-router-or-apollo-server). -Both the GraphOS Router and Apollo Router Core run a request-handling pipeline with multiple stages that starts with receiving requests and ends with sending back responses. The continuous operation and throughput of this request pipeline, or "request lifecycle," reflects the health of a running supergraph. Observability of the router request lifecycle is therefore key to understanding the health of a supergraph. +To gain deeper insights into the health of your supergraph, you can configure your GraphOS Router to collect telemetry about requests as they're processed through the pipeline of the router request lifecycle. The router provides both standard and customizable signals. - +GraphOS supports exporting its collected metrics to various observability tools. GraphOS Studio offers a Datadog integration, and GraphOS Router provides exporters for several observability tools and APMs. -To enable observability, the router supports telemetry that can be added and customized in every stage of the router request lifecycle. You can add logs, metrics, and traces, and you can export them to your application performance monitoring (APM) solution. + -To learn more, go to [Router Telemetry](/graphos/routing/observability/telemetry), then browse the pages in [Router Telemetry](/graphos/reference/router/telemetry/log-exporters/overview) reference docs. +If you're new to observability, check out [OpenTelemetry's observability primer](https://opentelemetry.io/docs/concepts/observability-primer/) to learn core observability concepts. -## Automating supergraph metrics collection with GraphOS + -Everything connected to GraphOS—including clients, routers, and subgraphs—can report metrics about GraphQL operations they send and service. GraphOS thus is the hub for collecting operation metrics, and its Studio IDE offers tools to visualize and analyze those operations and their field usage. +## Analyzing metrics and gathering insights with GraphOS -The metrics that GraphOS collects can be forwarded to your APM solution. Apollo offers a [Datadog integration](/graphos/platform/insights/datadog-forwarding) to forward your graph's performance metrics to your Datadog account. + +Reporting metrics from Apollo Server or a monograph requires a legacy Team plan or [current Free or Enterprise plan](https://www.apollographql.com/pricing). -## Analyzing metrics and gathering insights with GraphOS + -Once the various metrics are collected by GraphOS, you can use the GraphOS Studio UI to visualize and analyze them to understand your supergraph's usage and performance. +Everything connected to GraphOS—including clients, routers, and subgraphs—can report metrics about GraphQL operations they send and service. GraphOS thus is the hub for collecting operation metrics. -- Examine them in the Studio IDE from any variant's **Insights** page and use them to improve your graph's performance. +Once operation and field usage metrics are collected by GraphOS, you can use the GraphOS Studio [**Insights**](/graphos/platform/insights) suite to visualize and analyze them to understand your supergraph's usage and performance. -- Create GraphOS notifications to notify your team about changes to your graph and its performance. +Additionally, you can forward the metrics that GraphOS collects to your APM solution. Apollo offers a [Datadog integration](/graphos/platform/insights/datadog-forwarding) to forward your graph's performance metrics to your Datadog account. - +## Enabling additional runtime telemetry -The following require an [Enterprise plan](https://www.apollographql.com/pricing/): +Both the GraphOS Router and Apollo Router Core run a request-handling pipeline with multiple stages that starts with receiving requests and ends with sending back responses. The continuous operation and throughput of this request pipeline, or _request lifecycle_, reflects the health of a running supergraph. Observability of the router request lifecycle is therefore key to understanding the health of a supergraph. -
-
+ + +To enable observability, the router supports telemetry that can be added and customized in different stages of the router request lifecycle. You can add logs, metrics, and traces and export them to your application performance monitoring (APM) solution. -- Connecting a self-hosted router to GraphOS -- Forwarding metrics to Datadog +To learn more, go to [Router Telemetry](/graphos/routing/observability/telemetry), then browse the pages in [Router Telemetry](/graphos/reference/router/telemetry/log-exporters/overview) reference docs. -
-Reporting metrics from [Apollo Server](/apollo-server) or a [monograph](/graphos/get-started/concepts/graphs/#monographs) requires an [Enterprise or legacy Team plan](https://www.apollographql.com/pricing). +## Next steps -If your organization doesn't currently have an Enterprise plan, you can test out this functionality by signing up for a free [GraphOS trial](https://studio.apollographql.com/signup?referrer=docs-content). + -
+If you're an enterprise customer looking for more material on this topic, try the [Enterprise best practices: Supergraph observability](https://www.apollographql.com/tutorials/supergraph-observability) course on Odyssey. +Not an enterprise customer? [Learn about GraphOS for Enterprise.](https://www.apollographql.com/pricing) -## Next steps + -- Learn about metrics collection with [GraphOS Metrics Collection](/graphos/platform/insights/sending-operation-metrics). +- Learn how to use [GraphOS Insights](/graphos/platform/insights/) to monitor and improve your graph's performance. -- Learn about subgraph observability with [Subgraph Observability](/graphos/routing/observability/subgraph-error-inclusion). +- Learn how to [configure router telemetry](/graphos/routing/observability/telemetry) -- Learn about client observability with [Client Observability](/graphos/routing/observability/client-id-enforcement/). +- Learn about [subgraph observability](/graphos/routing/observability/subgraph-error-inclusion). -- Learn how to use insights to improve your graph's performance with [GraphOS Metrics and Insights](/graphos/platform/insights/). +- Learn about [client observability](/graphos/routing/observability/debugging-client-requests). -- Learn how to use notifications with [GraphOS notifications](/graphos/platform/insights/notifications). +- Learn how to enable [GraphOS notifications](/graphos/platform/insights/notifications). diff --git a/docs/source/routing/observability/otel-traces-to-prometheus.mdx b/docs/source/routing/observability/otel-traces-to-prometheus.mdx index 08841ed1b0..4dd16d86a5 100644 --- a/docs/source/routing/observability/otel-traces-to-prometheus.mdx +++ b/docs/source/routing/observability/otel-traces-to-prometheus.mdx @@ -7,14 +7,10 @@ published: 2022-06-03 tags: [server, observability] redirectFrom: - /technotes/TN0003-opentelemetry-traces-to-prometheus/ +context: + - telemetry --- - - -Self-hosting the GraphOS Router is limited to [GraphOS Enterprise plans](https://www.apollographql.com/pricing). Other plan types use [managed cloud routing with GraphOS](/graphos/cloud-routing). Check out the [pricing page](https://www.apollographql.com/pricing#graphos-router) to learn more. - - - If you're an enterprise customer looking for more material on this topic, try the [Enterprise best practices: Supergraph observability](https://www.apollographql.com/tutorials/supergraph-observability) course on Odyssey. diff --git a/docs/source/routing/observability/otel.mdx b/docs/source/routing/observability/otel.mdx new file mode 100644 index 0000000000..7096368712 --- /dev/null +++ b/docs/source/routing/observability/otel.mdx @@ -0,0 +1,287 @@ +--- +title: OpenTelemetry in Apollo Federation +sidebar_title: OpenTelemetry +subtitle: Configure your federated graph to emit logs, traces, and metrics +description: Learn how to configure your federated GraphQL services to generate and process telemetry data, including logs, traces, and metrics. +context: + - telemetry +--- + +[OpenTelemetry](https://opentelemetry.io/) is a collection of open-source tools for generating and processing telemetry data (such as logs, traces, and metrics) from different systems in a generic and consistent way. + +You can configure your gateway, your individual subgraphs, or even a monolothic Apollo Server instance to emit telemetry related to processing GraphQL operations. + +Additionally, the `@apollo/gateway` library provides built-in OpenTelemetry instrumentation to emit [gateway-specific spans](#gateway-specific-spans) for operation traces. + +If you are using GraphOS Router, it comes [pre-built with support for OpenTelemetry](/graphos/routing/observability/telemetry). + + + +GraphOS Studio does not currently consume OpenTelemetry-formatted data. To push trace data to Studio, see [Federated trace data](/graphos/routing/observability/federated-trace-data). + +You should configure OpenTelemetry if you want to push trace data to an OpenTelemetry-compatible system, such as [Zipkin](https://zipkin.io/) or [Jaeger](https://www.jaegertracing.io/). + + + +## Setup + +### 1. Install required libraries + +To use OpenTelemetry in your application, you need to install a baseline set of `@opentelemetry` Node.js libraries. This set differs slightly depending on whether you're setting up your federated gateway or a subgraph/monolith. + + + +```bash +npm install \ + @opentelemetry/api@1.0 \ + @opentelemetry/core@1.0 \ + @opentelemetry/resources@1.0 \ + @opentelemetry/sdk-trace-base@1.0 \ + @opentelemetry/sdk-trace-node@1.0 \ + @opentelemetry/instrumentation-http@0.27 \ + @opentelemetry/instrumentation-express@0.28 +``` + + + + + +```bash +npm install \ + @opentelemetry/api@1.0 \ + @opentelemetry/core@1.0 \ + @opentelemetry/resources@1.0 \ + @opentelemetry/sdk-trace-base@1.0 \ + @opentelemetry/sdk-trace-node@1.0 \ + @opentelemetry/instrumentation@0.27 \ + @opentelemetry/instrumentation-http@0.27 \ + @opentelemetry/instrumentation-express@0.28 \ + @opentelemetry/instrumentation-graphql@0.27 +``` + + + +Most importantly, subgraphs and monoliths must install `@opentelemetry/instrumentation-graphql`, and gateways must not install it. + +As shown above, most `@opentelemetry` libraries have reached `1.0`. The instrumentation packages listed above are compatible at the time of this writing. + +#### Update `@apollo/gateway` + +If you're using OpenTelemetry in your federated gateway, also update the `@apollo/gateway` library to version `0.31.1` or later to add support for [gateway-specific spans](#gateway-specific-spans). + +### 2. Configure instrumentation + +Next, update your application to configure your OpenTelemetry instrumentation as early as possible in your app's execution. This must occur before you even import `@apollo/server`, `express`, or `http`. Otherwise, your trace data will be incomplete. + +We recommend putting this configuration in its own file, which you import at the very top of `index.js`. A sample file is provided below (note the lines that should either be deleted or uncommented). + +```js title="open-telemetry.js" +// Import required symbols +const { Resource } = require('@opentelemetry/resources'); +const { SimpleSpanProcessor, ConsoleSpanExporter } = require ("@opentelemetry/sdk-trace-base"); +const { NodeTracerProvider } = require("@opentelemetry/sdk-trace-node"); +const { registerInstrumentations } = require('@opentelemetry/instrumentation'); +const { HttpInstrumentation } = require ('@opentelemetry/instrumentation-http'); +const { ExpressInstrumentation } = require ('@opentelemetry/instrumentation-express'); +// DELETE IF SETTING UP A GATEWAY, UNCOMMENT OTHERWISE +// const { GraphQLInstrumentation } = require ('@opentelemetry/instrumentation-graphql'); + +// Register server-related instrumentation +registerInstrumentations({ + instrumentations: [ + new HttpInstrumentation(), + new ExpressInstrumentation(), + // DELETE IF SETTING UP A GATEWAY, UNCOMMENT OTHERWISE + //new GraphQLInstrumentation() + ] +}); + +// Initialize provider and identify this particular service +// (in this case, we're implementing a federated gateway) +const provider = new NodeTracerProvider({ + resource: Resource.default().merge(new Resource({ + // Replace with any string to identify this service in your system + "service.name": "gateway", + })), +}); + +// Configure a test exporter to print all traces to the console +const consoleExporter = new ConsoleSpanExporter(); +provider.addSpanProcessor( + new SimpleSpanProcessor(consoleExporter) +); + +// Register the provider to begin tracing +provider.register(); +``` + +For now, this code does not push trace data to an external system. Instead, it prints that data to the console for debugging purposes. + + +After you make these changes to your app, start it up locally. It should begin printing trace data similar to the following: + + + +```js +{ + traceId: '0ed36c42718622cc726a661a3328aa61', + parentId: undefined, + name: 'HTTP POST', + id: '36c6a3ae19563ec3', + kind: 1, + timestamp: 1624650903925787, + duration: 26793, + attributes: { + 'http.url': 'http://localhost:4000/', + 'http.host': 'localhost:4000', + 'net.host.name': 'localhost', + 'http.method': 'POST', + 'http.route': '', + 'http.target': '/', + 'http.user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36', + 'http.request_content_length_uncompressed': 1468, + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': '::1', + 'net.host.port': 4000, + 'net.peer.ip': '::1', + 'net.peer.port': 39722, + 'http.status_code': 200, + 'http.status_text': 'OK' + }, + status: { code: 1 }, + events: [] +} + +{ + traceId: '0ed36c42718622cc726a661a3328aa61', + parentId: '36c6a3ae19563ec3', + name: 'middleware - ', + id: '3776786d86f24124', + kind: 0, + timestamp: 1624650903934147, + duration: 63, + attributes: { + 'http.route': '/', + 'express.name': '', + 'express.type': 'middleware' + }, + status: { code: 0 }, + events: [] +} +``` + + + +Nice! Next, we can modify this code to begin pushing trace data to an external service, such as Zipkin or Jaeger. + +### 3. Push trace data to a tracing system + +Next, let's modify the code in the [previous step](#2-configure-instrumentation) to instead push traces to a locally running instance of [Zipkin](https://zipkin.io/). + + + +To run Zipkin locally, [see the quickstart](https://zipkin.io/pages/quickstart.html). If you want to use a different tracing system, consult the documentation for that system. + + + +First, we need to replace our `ConsoleSpanExporter` (which prints traces to the terminal) with a `ZipkinExporter`, which specifically pushes trace data to a running Zipkin instance. + +Install the following additional library: + +```bash +npm install @opentelemetry/exporter-zipkin@1.0 +``` + +Then, import the `ZipkinExporter` in your dedicated OpenTelemetry file: + +```js title="open-telemetry.js" +const { ZipkinExporter } = require("@opentelemetry/exporter-zipkin"); +``` + +Now we can replace our `ConsoleSpanExporter` with a `ZipkinExporter`. Replace lines 31-34 of the code in [the previous step](#2-configure-instrumentation) with the following: + +```js +// Configure an exporter that pushes all traces to Zipkin +// (This assumes Zipkin is running on localhost at the +// default port of 9411) +const zipkinExporter = new ZipkinExporter({ + // url: set_this_if_not_running_zipkin_locally +}); +provider.addSpanProcessor( + new SimpleSpanProcessor(zipkinExporter) +); +``` + +Now, open Zipkin in your browser at `http://localhost:9411`. You should now be able to query recent trace data in the UI! + +You can show the details of any operation and see a breakdown of its processing timeline by span. + +### 4. Update for production readiness + +Our example telemetry configuration assumes that Zipkin is running locally, and that we want to process every span individually as it's emitted. + +To prepare for production, we'll want to optimize performance by sending our traces to an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) using the `OTLPTraceExporter` and replace our `SimpleSpanProcessor` with a `BatchSpanProcessor`. +The Collector should be deployed as a local sidecar agent to buffer traces before they're sent along to their final destination. +See the [getting started docs](https://opentelemetry.io/docs/collector/getting-started/) for an overview. + +```bash +npm install @opentelemetry/exporter-trace-otlp-http@0.27 +``` + +Then, import the `OTLPTraceExporter` and `BatchSpanProcessor` in your dedicated OpenTelemetry file: + + +```js:title=open-telemetry.js +const { OTLPTraceExporter } = require("@opentelemetry/exporter-trace-otlp-http"); +const { BatchSpanProcessor } = require("@opentelemetry/sdk-trace-base"); +``` + +Now we can replace our `ZipkinExporter` with an `OTLPTraceExporter`. We can also replace our `SimpleSpanProcessor` with a `BatchSpanProcessor`. Replace lines 4-9 of the code in [the previous step](#3-push-trace-data-to-a-tracing-system) with the following: + +```js +// Configure an exporter that pushes all traces to a Collector +// (This assumes the Collector is running on the default url +// of http://localhost:4318/v1/traces) +const collectorTraceExporter = new OTLPTraceExporter(); +provider.addSpanProcessor( + new BatchSpanProcessor(collectorTraceExporter, { + maxQueueSize: 1000, + scheduledDelayMillis: 1000, + }), +); +``` + +You can learn more about using the `OTLPTraceExporter` in the [instrumentation docs](https://opentelemetry.io/docs/instrumentation/js/exporters/). + +## GraphQL-specific spans + +The `@opentelemetry/instrumentation-graphql` library enables subgraphs and monoliths to emit the following spans as part of [OpenTelemetry traces](https://opentelemetry.io/docs/concepts/data-sources/#traces): + +| Name | Description | +|------|-------------| +| `graphql.parse` | The amount of time the server spent parsing an operation string. | +| `graphql.validate` | The amount of time the server spent validating an operation string. | +| `graphql.execute` | The total amount of time the server spent executing an operation. | +| `graphql.resolve` | The amount of time the server spent resolving a particular field. | + +Note that not every GraphQL span appears in every operation trace. This is because Apollo server can skip parsing or validating an operation string if that string is available in the operation cache. + + + +Federated gateways must not install the `@opentelemetry/instrumentation-graphql` library, so these spans are not included in its traces. + + + +## Gateway-specific spans + +The `@apollo/gateway` library emits the following spans as part of [OpenTelemetry traces](https://opentelemetry.io/docs/concepts/data-sources/#traces): + +| Name | Description | +|------|-------------| +| `gateway.request` | The total amount of time the gateway spent serving a request. | +| `gateway.validate` | The amount of time the gateway spent validating a GraphQL operation string. | +| `gateway.plan` | The amount of time the gateway spent generating a query plan for a validated operation. | +| `gateway.execute` | The amount of time the gateway spent executing operations on subgraphs. | +| `gateway.fetch` | The amount of time the gateway spent fetching data from a particular subgraph. | +| `gateway.postprocessing` | The amount of time the gateway spent composing a complete response from individual subgraph responses. | diff --git a/docs/source/routing/observability/subgraph-error-inclusion.mdx b/docs/source/routing/observability/subgraph-error-inclusion.mdx index d991a3e710..c5b0d480ff 100644 --- a/docs/source/routing/observability/subgraph-error-inclusion.mdx +++ b/docs/source/routing/observability/subgraph-error-inclusion.mdx @@ -17,17 +17,157 @@ This redaction prevents potential leaks of sensitive information to the client. To configure subgraph error inclusion, add the `include_subgraph_errors` plugin to your [YAML config file](/router/configuration/overview/#yaml-config-file), like so: ```yaml title="router.yaml" +# Option 1: Simple boolean toggle (default is false) include_subgraph_errors: - all: true # Propagate errors from all subgraphs + all: true # Propagate errors (message + extensions) from all subgraphs subgraphs: - products: false # Do not propagate errors from the products subgraph + products: false # Override: Do not propagate errors from the 'products' subgraph (redact fully) ``` -Any configuration under the `subgraphs` key takes precedence over configuration under the `all` key. In the example above, subgraph errors are included from all subgraphs _except_ the `products` subgraph. +Any configuration under the `subgraphs` key takes precedence over the `all` configuration for that specific subgraph. In the example above, subgraph errors are included from all subgraphs _except_ the `products` subgraph, which will have its errors fully redacted. + +If `all` is a boolean (`true` or `false`), then any configuration under `subgraphs` must also be a boolean. + +```yaml title="router.yaml" +# Option 2: Fine-grained control using objects +include_subgraph_errors: + all: # Default configuration for all subgraphs + redact_message: true # Redact error messages globally + allow_extensions_keys: # Allow only specific extension keys globally + - code + - trace_id + subgraphs: + # Subgraph 'products': Override global settings + products: + redact_message: false # Keep original error messages for 'products' + allow_extensions_keys: # Extend global allow list for 'products' + - reason # Allows 'code', 'trace_id' (from global) and 'reason' + exclude_global_keys: # Exclude 'trace_id' from the inherited global list + - trace_id # Allows 'code' (global) and 'reason' (subgraph), but not 'trace_id' + + # Subgraph 'inventory': Override global allow list with a deny list + inventory: + deny_extensions_keys: # Deny specific keys for 'inventory' (overrides global allow list) + - internal_debug_info + # Allows 'code', 'trace_id' (from global) but denies 'internal_debug_info' + + # Subgraph 'reviews': Use only common options, inheriting global allow/deny behavior + reviews: + redact_message: false # Override only message redaction, inherits global allow list + exclude_global_keys: # Inherits global allow list, but excludes 'code' + - code # Allows 'trace_id' but not 'code' + + # Subgraph 'accounts': Fully redact errors, overriding global object config + accounts: false +``` + + + +Using a `deny_extensions_keys` approach carries security risks because it follows a blocklist pattern—any sensitive information not explicitly included in the deny list might be exposed to clients if not covered by other rules (like a global `allow_extensions_keys`). + + + +For better security, we recommend either fully redacting subgraph errors (by setting the subgraph to `false`) or using the `allow_extensions_keys` approach (either globally or per-subgraph) to explicitly specify which error extension fields can be exposed to clients. + +### Configuration Schema + +The top-level `include_subgraph_errors` key accepts an object with the following keys: + +| Key | Type | Description | Default | +| :---------- | :--------------------------------------- | :--------------------------------------------------------------------------------------------------------- | :------ | +| `all` | `boolean` \| [ErrorMode Object](#errormode-object) | Configuration applied to all subgraphs unless overridden. | `false` | +| `subgraphs` | `map` | Per-subgraph overrides for the `all` configuration. The key is the subgraph name. | `{}` | + +#### ErrorMode Object + +This object provides fine-grained control over error propagation. + +| Key | Type | Description | Required | +| :---------------------- | :-------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------- | +| `redact_message` | `boolean` | If `true`, replaces the original error message with `Subgraph errors redacted`. If `false`, keeps the original message. | Optional | +| `allow_extensions_keys` | `[string]` | Propagates **only** the specified keys in the `extensions` object. Cannot be used with `deny_extensions_keys` in the same object. If omitted, inherits global behavior. | Optional | +| `deny_extensions_keys` | `[string]` | Redacts the specified keys from the `extensions` object. Cannot be used with `allow_extensions_keys` in the same object. If omitted, inherits global behavior. | Optional | +| `exclude_global_keys` | `[string]` | When inheriting a global `allow_extensions_keys` or `deny_extensions_keys` list, these keys are removed from the inherited list before applying subgraph-specific rules. | Optional | + +### Key Behaviors & Precedence + +1. **Subgraph Specificity**: Configuration under `subgraphs.` always overrides the `all` configuration for that specific subgraph. +2. **Boolean Override**: If `subgraphs.` is set to `true` or `false`, it completely overrides any `all` object configuration. + * `true`: Include the error, keep the original message, include all extensions (except `service` if explicitly denied later, though unlikely with `true`). + * `false`: Redact the error message and remove all extensions. +3. **Global Boolean Restriction**: If `all` is set to `true` or `false`, then all entries under `subgraphs` must also be `true` or `false`. Object configurations are not allowed for subgraphs in this case. +4. **Allow vs. Deny**: Within a single configuration object (either `all` or a specific subgraph), `allow_extensions_keys` and `deny_extensions_keys` are mutually exclusive. +5. **Inheritance & Overrides (Object Config)**: + * If a subgraph config is an object, it inherits the behavior (`allow` or `deny` list, `redact_message`) from the global `all` object config by default. + * `redact_message` in the subgraph object overrides the global `redact_message`. + * `allow_extensions_keys` in the subgraph object: + * Overrides a global `deny_extensions_keys` list. + * Extends a global `allow_extensions_keys` list (after applying `exclude_global_keys`). + * `deny_extensions_keys` in the subgraph object: + * Overrides a global `allow_extensions_keys` list. + * Extends a global `deny_extensions_keys` list (after applying `exclude_global_keys`). + * `exclude_global_keys` removes keys from the inherited global list *before* the subgraph's `allow` or `deny` list is applied or extended. +6. **`service` Extension**: The `service` extension (containing the subgraph name) is added by default if errors are included for a subgraph, unless it's explicitly removed by an `allow_extensions_keys` list (that doesn't include `"service"`) or a `deny_extensions_keys` list (that includes `"service"`). ## Sending errors to GraphOS -To report the subgraph errors to GraphOS that is a separate configuration that is not affected by client subgraph error inclusion, see the [GraphOS reporting docs](/router/configuration/telemetry/apollo-telemetry). + +Reporting subgraph errors to GraphOS is configured separately and is not affected by client-facing error inclusion settings. See the [GraphOS reporting docs](/router/configuration/telemetry/apollo-telemetry). ## Logging GraphQL request errors -To log the GraphQL error responses (i.e. messages returned in the GraphQL `errors` array) from the router, see the [logging configuration documentation](/router/configuration/telemetry/exporters/logging/overview). +To log the GraphQL error responses (i.e., messages returned in the GraphQL `errors` array) from the router, see the [logging configuration documentation](/router/configuration/telemetry/exporters/logging/overview). + +## Exposing subgraph name via `service` extension + +If errors are included for a particular subgraph (i.e., not fully redacted by setting its config to `false`), the router attempts to add the subgraph's name to the error's `extensions` object under the key `service`. + +This `service` extension key is treated like any other extension key and is subject to the `allow_extensions_keys` and `deny_extensions_keys` rules. + +* If using `allow_extensions_keys`, you must include `"service"` in the list if you want it to be propagated. +* If using `deny_extensions_keys`, including `"service"` will prevent it from being propagated. +* If no allow/deny lists apply (e.g., `all: true`), `"service"` will be included by default. + +**Example:** + +Assume `include_subgraph_errors.all` is configured as: +```yaml +all: + redact_message: false + allow_extensions_keys: + - code # Allows only 'code', implicitly denying 'service' +``` +If the `products` subgraph returns an error like `{"message": "Invalid ID", "extensions": {"code": "BAD_USER_INPUT"}}`, the final error sent to the client will be: +```json +{ + "message": "Invalid ID", + "path": [...], + "extensions": { + "code": "BAD_USER_INPUT" + // "service": "products" is NOT included because it wasn't in allow_extensions_keys + } +} +``` + +If the configuration was instead: +```yaml +all: + redact_message: false + allow_extensions_keys: + - code + - service # Explicitly allow 'service' +``` +The final error would be: +```json +{ + "data": null, + "errors": [ + { + "message": "Invalid product ID", + "path": [], + "extensions": { + "service": "products", + } + } + ] +} +``` diff --git a/docs/source/routing/observability/telemetry.mdx b/docs/source/routing/observability/telemetry.mdx deleted file mode 100644 index fc593d8dd0..0000000000 --- a/docs/source/routing/observability/telemetry.mdx +++ /dev/null @@ -1,158 +0,0 @@ ---- -title: Router Telemetry -subtitle: Collect observable data to monitor your router and supergraph -description: Observe and monitor the health and performance of GraphQL operations in the Apollo GraphOS Router or Apollo Router Core by collecting and exporting telemetry logs, metrics, and traces. ---- - -import TelemetryPerformanceNote from '../../../shared/telemetry-performance.mdx'; - -In this overview, learn about: -- How GraphOS Router and Apollo Router Core telemetry enable supergraph observability and debuggability -- What data is captured in the router's logs, metrics, and traces -- What exporters are available to provide telemetry to your application performance monitoring (APM) tools - -```mermaid -flowchart LR - subgraph Router - lifecycle("Request Lifecycle
(telemetry sources)") - exporters("Logs, Metrics,
Traces Exporters") - lifecycle-->exporters - end - - apms["APM, agent,
or collector"] - exporters--"native or
OTLP"-->apms - -``` - -## Observability through telemetry - -The health of your supergraph is only as good as the health of your router. Because the router is the single entry point to the supergraph, all client requests pass through the [router's request lifecycle](/router/customizations/overview#the-request-lifecycle). Any issues with the router are likely to affect the handling of all requests to your supergraph. - -Diagnosing your router's health and performance requires it to show observable data about its inner workings. The more observable data you can monitor and analyze, the faster you can identify unhealthy behaviors, deduce root causes, and implement fixes. - -The router provides the necessary data to monitor its health and troubleshoot issues. The router's observability is critical for maintaining a healthy, performant supergraph and minimizing its [mean time to repair (MTTR)](https://en.wikipedia.org/wiki/Mean_time_to_repair). - -## Collect exactly the telemetry you need - -Effective telemetry provides just the right amount and granularity of information to maintain your graph. Too much data can overwhelm your system, for example, with high cardinality metrics. Too little may not provide enough information to debug issues. - -Specific events that need to be captured—and the conditions under which they need to be captured—can change as client applications and graphs change. Different environments, such as production and development, can have different observability requirements. - -Router telemetry is customizable to meet the observability needs of different graphs. You can record custom events in different stages of the router request lifecycle and create custom contexts with [attributes](#router-telemetry-attributes) to track a request or response as it flows through the router. You can shape the volume and rate of emitted telemetry, for example, with batched telemetry. - -## Router telemetry types - -The router collects different types of telemetry, including: - -* [Logs and events](#logs-and-events) -* [Metrics and instruments](#metrics-and-instruments) -* [Traces and spans](#traces-and-spans) - -These let you collect data about the inner workings of your router and export logs, metrics, and traces to your application performance monitoring (APM) and observability tools. - -```mermaid -flowchart LR - subgraph Router - lifecycle("Request Lifecycle
(telemetry sources)") - logs_exporter("Logs
Exporter") - terminal(stdout) - metrics_exporter("Metrics
Exporter") - traces_exporter("Traces
Exporter") - prometheus("Prometheus
Endpoint") - - lifecycle-->logs_exporter-->terminal - lifecycle--->metrics_exporter-->prometheus - lifecycle--->traces_exporter - end - - otlp_apm["OTLP-enabled APM
(e.g. New Relic)"] - zipkin[Zipkin] - datadog[Datadog agent] - apm1[APM] - collector("OpenTelemetry
Collector") - jaeger("Jaeger
(agent or collector)") - - - metrics_exporter--"OTLP"--->otlp_apm - metrics_exporter--"OTLP"--->collector - metrics_exporter--"OTLP"--->datadog - traces_exporter--"OTLP"--->jaeger - traces_exporter--"native or
OTLP"-->datadog - traces_exporter--"native"-->jaeger - traces_exporter--"native"--->zipkin - prometheus<--"scrapes"-->apm1 - -``` - -### Logs and events - -Logs record **events** in the router. Examples of logged events include: - -* Information about the router lifecycle -* Warnings about misconfiguration -* Errors that occurred during a request - -Logs can be consumed by [logging exporters](/router/configuration/telemetry/exporters/logging/overview) and as part of [spans](#traces-and-spans) via [tracing exporters](/router/configuration/telemetry/exporters/tracing/overview). - -### Metrics and instruments - -Metrics are measurements of the router's behavior that can be exported and monitored. Different kinds of metrics include histograms, gauges, and counts. - -Metrics can be consumed by _exporters_. See [Metrics exporters](/router/configuration/telemetry/exporters/metrics/overview) for an overview of supported exporters. - -An individual metric is called an _instrument_. Example instruments of the router include: - -* Number of received requests -* Histogram of request durations -* Number of in-flight requests - -See [Instruments](/router/configuration/telemetry/instrumentation/instruments) for an overview of available instruments and a guide for configuring and customizing instruments. - -### Traces and spans - -Traces monitor the flow of a request through the router. A trace is composed of [**spans**](/router/configuration/telemetry/instrumentation/spans). A span captures a request's duration as it flows through the router request lifecycle. Spans may include contextual information about the request, such as the HTTP status code, or the name of the subgraph being queried. - -Examples of spans include: - -* `router` - Wraps an entire request from the HTTP perspective -* `supergraph` - Wraps a request once GraphQL parsing has taken place -* `subgraph` - Wraps a request to a subgraph. - -Traces are consumed via [tracing exporters](/router/configuration/telemetry/exporters/tracing/overview). - -## Router telemetry exporters - -The router exports its collected telemetry in formats compatible with industry-standard APM tools. The router supports logging, metrics, and tracing exporters for a variety of tools, including: - -* Prometheus -* OpenTelemetry Collector -* Datadog -* New Relic -* Jaeger -* Zipkin - -For more information, see [logging exporters](/router/configuration/telemetry/exporters/logging/overview), [metrics exporters](/router/configuration/telemetry/exporters/metrics/overview), and [tracing exporters](/router/configuration/telemetry/exporters/tracing/overview). - -## Router telemetry attributes - -You can annotate events, metrics, and spans with **attributes**. Attributes are key-value pairs that add contextual information about the router pipeline to telemetry. You can then use these attributes to filter and group data in your APMs. - -Example attributes include: - -* HTTP status code -* GraphQL operation name -* Subgraph name - -You can use [standard attributes](/router/configuration/telemetry/instrumentation/standard-attributes) or [selectors](/router/configuration/telemetry/instrumentation/selectors) as span attributes. - - - -[Custom attributes for spans](/router/configuration/telemetry/instrumentation/spans/#attributes) require a GraphOS [Dedicated or Enterprise plan](https://www.apollographql.com/pricing#observability). - - - -## Best practices - -### Balancing telemetry and router performance - - diff --git a/docs/source/routing/observability/telemetry/index.mdx b/docs/source/routing/observability/telemetry/index.mdx new file mode 100644 index 0000000000..eb87916cab --- /dev/null +++ b/docs/source/routing/observability/telemetry/index.mdx @@ -0,0 +1,338 @@ +--- +title: Router Telemetry +subtitle: Collect observable data to monitor your router and supergraph +description: Observe and monitor the health and performance of GraphQL operations in the Apollo GraphOS Router or Apollo Router Core by collecting and exporting telemetry logs, metrics, and traces. +context: + - telemetry +--- + +import TelemetryPerformanceNote from '../../../../shared/telemetry-performance.mdx'; + +Since the router is the single access point for all traffic to and from your graph, router telemetry is the most comprehensive way to observe your supergraph. By implementing telemetry, you can: + +- Monitor your supergraph's health and performance +- Diagnose issues and deduce root causes +- Optimize resource usage and system reliability + +To understand how router telemetry fits into the broader set of GraphOS observability tooling, see the [observability overview](/graphos/routing/observability). + +## How router telemetry works + +By default, the router doesn't collect or export any telemetry beyond [the operation](/graphos/platform/insights/sending-operation-metrics#from-the-apollo-router-or-apollo-server) and [field usage metrics](/graphos/platform/insights/sending-operation-metrics#from-the-apollo-router) it sends to GraphOS. You configure which additional telemetry data to collect and where to export it via your router's configuration file. + +The router request lifecycle is the primary data source for telemetry data or _signals_. Telemetry signals include _logs_, _metrics_, and _traces_. The section on [router telemetry signals](#router-telemetry-signals) explains these data types and gives basic configuration examples. _Exporters_ are responsible for sending telemetry data to your application performance monitoring (APM) and observability tools for storage, visualization, and analysis. + +```mermaid +flowchart LR + subgraph Router + lifecycle("Request Lifecycle
(telemetry sources)") + exporters("Logs, Metrics,
Traces Exporters") + lifecycle-->exporters + end + + apms["APM, agent,
or collector"] + exporters--"OTLP"-->apms +``` + +### Telemetry exporters + +The router emits telemetry in the industry-standard OpenTelemetry Protocol (OTLP) format and is therefore compatible with many APM tools, including: + +- Prometheus +- OpenTelemetry Collector +- Datadog +- New Relic +- Jaeger +- Zipkin + +The router follows the [W3C Trace Context specification](https://www.w3.org/TR/trace-context/) for `trace_id` generation and propagation. OpenTelemetry uses 128-bit (32-character hexadecimal) trace IDs as defined in the W3C standard. When working with systems that do not follow this standard, the router provides format conversion options to ensure compatibility. + +When the router receives an incompatible or malformed `trace_id` in incoming requests (such as invalid hexadecimal characters, incorrect length, or non-standard formats), it logs an error message containing the invalid trace ID. + +### Attributes and selectors + +Attributes and selectors are key-value pairs that add contextual information from the router request lifecycle to telemetry data. You can use attributes and selectors to annotate events, metrics, and spans so they can help you filter and group data in your APMs. + +The router supports a set of standard attributes from [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/). Example attributes include: + +- HTTP status code +- GraphQL operation name +- Subgraph name + +Selectors allow you to define custom data points based on the router's request lifecycle. + +| | Description | +| ----- | ----- | +| **Attribute** | Standard data points that can be attached to spans, instruments, and events. | +| **Selector** | Custom data points extracted from the router's request lifecycle, tailored to specific needs. | + +## Router telemetry signals + +The router supports three signal types for collecting and exporting telemetry: + + + + + + + + + + + + + + + + + + + + + + +
SignalDescription
Logs and events +
    +
  • Capture and export logs in text or JSON format.
  • +
  • Trigger custom events to log critical actions during the router request lifecycle.
  • +
+
Metrics and instruments +
    +
  • Export standard metrics for Router operations.
  • +
  • Leverage OpenTelemetry (OTEL) metrics to capture HTTP lifecycle data.
  • +
  • Define custom metrics using attributes and selectors.
  • +
+
Traces and spans +
    +
  • Export traces of router transactions.
  • +
  • Use spans to monitor specific actions within traces and attach attributes or selectors for deeper insights.
  • +
+
+ + +These mechanisms let you collect data about the inner workings of your router and graph and export them accordingly. + +### Logs and events + +Logs record events in the router's request lifecycle. Examples of logged events include: + +- Information about the router lifecycle +- Warnings about misconfiguration +- Errors that occurred during a request + +#### Log exporters + +You can log events to standard output in either text or JSON format. Logs can also be consumed by [logging exporters](/router/configuration/telemetry/exporters/logging/overview) and as part of [spans](/graphos/routing/observability/telemetry#traces-and-spans) via [tracing exporters](/router/configuration/telemetry/exporters/tracing/overview). + +```mermaid +flowchart LR + Router --"Emits logs in
text or JSON format"--> stdout + stdout --"Exports logs"--> log_store + log_store[("Log store")] +``` + +#### Example log configuration + +This configuration snippet enables stdout logging in JSON: + +```yaml title="router.yaml" +telemetry: + exporters: + logging: + stdout: + enabled: true + format: json +``` + +### Metrics and instruments + +Metrics are measurements of the router's behavior that are collected and often analyzed over time to identify trends. Examples of router metrics include the number of incoming HTTP requests and the time spent processing a request. + +Instruments define _how_ to collect and report metrics. Different kinds of instruments include counters, gauges, and histograms. For example, given the metric "number of incoming HTTP requests," a counter records the total number of requests, a histogram captures the distribution of request counts over time, and a gauge provides a snapshot of the current request count at a given moment. + +#### Instrument types + +Metric instruments fall into three categories: + + + + + + + + + + + + + + + + + + + + + + +
Instrument TypeDescription
OTEL instruments + Standard OpenTelemetry instruments around the HTTP lifecycle, including: +
    +
  • The number of HTTP requests by HTTP status
  • +
  • A histogram of HTTP router request duration
  • +
  • The number of active requests in flight
  • +
  • A histogram of request body sizes
  • +
+
Router instruments + Standard instruments for the router request life cycle, including: +
    +
  • Count of GraphQL errors in responses
  • +
  • Time spent loading the schema in seconds
  • +
  • Number of entries in the router's cache
  • +
  • Time spent warming up the query planner queries in seconds
  • +
+
Custom instrument + Custom instruments defined in the router request life cycle. +
+ +#### Example instrument configuration + +This configuration snippet enables OTEL instrumentation for a histogram of request body sizes: + +```yaml title="router.yaml" +telemetry: + instrumentation: + instruments: + router: + http.server.request.body.size: true +``` + +See [Instruments](/router/configuration/telemetry/instrumentation/instruments) for an overview of available instruments and a guide for configuring and customizing instruments. + +#### Metric exporters + +In addition to the [operation metrics](/graphos/platform/insights/sending-operation-metrics#from-the-apollo-router-or-apollo-server) and [field usage metrics](/graphos/platform/insights/sending-operation-metrics#from-the-apollo-router) that GraphOS Router sends to GraphOS, you can configure the router with metric exporters for other observability tools and APMs. + +```mermaid +flowchart LR + Router --"OTEL
metrics"--> APM + Router --"Usage/Performance
metrics"--> GraphOS +``` + +This configuration snippet enables exporting metrics to Prometheus: + +```yaml title="router.yaml" +telemetry: + exporters: + metrics: + prometheus: + enabled: true + listen: 127.0.0.1:9090 + path: /metrics +``` + +Learn more about [sending metrics to Prometheus](/graphos/reference/router/telemetry/metrics-exporters/prometheus) and [metric exporters](/graphos/reference/router/telemetry/metrics-exporters/overview) in general. + +### Traces and spans + +Traces help you monitor the flow of a request through the router. A trace is composed of [spans](/router/configuration/telemetry/instrumentation/spans). A span captures a request's duration as it flows through the router request lifecycle. Spans may include contextual information about the request, such as the HTTP status code or the name of the subgraph being queried. + +Examples of spans include: + +- router \- Wraps an entire request from the HTTP perspective +- supergraph \- Wraps a request once GraphQL parsing has taken place +- subgraph \- Wraps a request to a subgraph. + +#### Tracing exporters + +If you've enabled federated tracing (also known as FTV1 tracing) in your subgraph libraries, the router [sends field-level traces to GraphOS](/graphos/routing/graphos-reporting#reporting-field-level-traces). Additionally, trace exporters can consume and report traces to your APM. + +```mermaid +flowchart LR + Router --"OTEL
traces"--> APM + Router --"FTV1 Data"--> GraphOS +``` + +This configuration snippet enables +- setting attributes that Datadog uses to organize its APM view +- exporting traces to a Datadog agent: + +```yaml title="router.yaml" +telemetry: + instrumentation: + spans: + mode: spec_compliant + router: + attributes: + otel.name: router + operation.name: "router" + resource.name: + request_method: true + supergraph: + attributes: + otel.name: supergraph + operation.name: "supergraph" + resource.name: + operation_name: string + subgraph: + attributes: + otel.name: subgraph + operation.name: "subgraph" + resource.name: + subgraph_operation_name: string + exporters: + tracing: + otlp: + enabled: true + endpoint: "${env.DATADOG_AGENT_HOST}:4317" +``` + +Learn more about [sending traces to DataDog](/graphos/reference/router/telemetry/trace-exporters/datadog) and [trace exporters](/graphos/reference/router/telemetry/trace-exporters/overview) in general. + +## Best practices + +### Collecting exactly the telemetry you need + +Effective telemetry provides just the right amount and granularity of information to maintain your graph. Too much data can overwhelm your system, for example, with high cardinality metrics. Too little may not provide enough information to debug issues. + +Specific events that need to be captured—and the conditions under which they need to be captured—can change as client applications and graphs change. Different environments, such as production and development, can have different observability requirements. + +Router telemetry is customizable to meet the observability needs of different graphs. Keep in mind your particular environments' and graphs' requirements when configuring your telemetry. + +#### Setting conditions for collecting telemetry + +You can set [conditions](/graphos/reference/router/telemetry/instrumentation/conditions) for instruments and events to only collect telemetry data when necessary. This configuration snippet enables only collecting the configured telemetry data when the `request_header` is equal to "example-value": + +```yaml +eq: + - "example-value" + - request_header: x-req-header +``` +#### Dropping metrics using views + +You can use metric exporters' [`view`](/graphos/reference/router/telemetry/metrics-exporters/overview#views) property with the `drop` aggregation to remove certain metrics from being sent to your APM. This configuration snippet removes all instruments that begin with `apollo_router`: + +```yaml title="router.yaml" +telemetry: + exporters: + metrics: + common: + service_name: apollo-router + views: + - name: apollo_router* + aggregation: drop +``` + +### Balancing telemetry and router performance + + + +## Next steps + +Consult the following documentation for details on how to configure the various telemetry mechanisms and exporters: + +- [Log Exporters Overview](/graphos/reference/router/telemetry/log-exporters/overview) +- [Trace Exporters Overview](/graphos/reference/router/telemetry/trace-exporters/overview) +- [Metrics Exporters Overview](/graphos/reference/router/telemetry/metrics-exporters/overview) +- [Attributes and Selectors](/graphos/reference/router/telemetry/instrumentation/selectors) +- [Conditions](/graphos/reference/router/telemetry/instrumentation/conditions) diff --git a/docs/source/reference/router/telemetry/instrumentation/conditions.mdx b/docs/source/routing/observability/telemetry/instrumentation/conditions.mdx similarity index 66% rename from docs/source/reference/router/telemetry/instrumentation/conditions.mdx rename to docs/source/routing/observability/telemetry/instrumentation/conditions.mdx index e98316df27..7ff72a34a5 100644 --- a/docs/source/reference/router/telemetry/instrumentation/conditions.mdx +++ b/docs/source/routing/observability/telemetry/instrumentation/conditions.mdx @@ -2,6 +2,8 @@ title: Conditions subtitle: Set conditions for when events or instruments are triggered description: Set conditions for when events or instruments are triggered in the Apollo GraphOS Router. +context: + - telemetry --- You can set conditions for when an [instrument](/router/configuration/telemetry/instrumentation/instruments) should be mutated or an [event](/router/configuration/telemetry/instrumentation/events) should be triggered. @@ -154,3 +156,77 @@ The available basic conditions: | `any` | A list of conditions of which at least one must be true | You can create complex conditions by using these basic conditions as building blocks. + +## Example condition configurations + +Some example configuration of common use cases for conditions. + +### Event for a specific subgraph + +You can trigger an event for a specific subgraph by configuring a condition with the subgraph's name. + +The example below uses the [`subgraph_name`](/router/configuration/telemetry/instrumentation/selectors#subgraph) selector to log subgraph responses for the subgraph named "products": + +```yaml title=router.yaml +telemetry: + instrumentation: + events: + subgraph: + response: + level: info + condition: + eq: + - subgraph_name: true + - "products" +``` + +### On GraphQL error + +You can use the [`on_graphql_error`](/router/configuration/telemetry/instrumentation/selectors#supergraph) selector to create a condition based on whether or not a GraphQL error is present. + +The example configuration below uses `on_graphql_error` to log only supergraph responses that contain GraphQL errors: + +```yaml title="router.yaml" +telemetry: + instrumentation: + events: + router: + request: + level: info + condition: # Only log the router request if you sent `x-log-request` with the value `enabled` + eq: + - request_header: x-log-request + - "enabled" + response: off + error: error + supergraph: + response: + level: info + condition: # Only log supergraph response containing GraphQL errors + eq: + - on_graphql_error: true + - true + error: error +``` + +### On large payloads + +For observability of large payloads, you can set attributes using conditions that indicate whether the length of a request or response exceeds a threshold. + +The example below sets a custom attribute to `true` if the length of a request is greater than 100: + +```yaml +telemetry: + instrumentation: + spans: + mode: spec_compliant + router: + attributes: + trace_id: true + payload_is_to_big: # Set this attribute to true if the value of content-length header is > than 100 + static: true + condition: + gt: + - request_header: "content-length" + - 100 +``` diff --git a/docs/source/reference/router/telemetry/instrumentation/events.mdx b/docs/source/routing/observability/telemetry/instrumentation/events.mdx similarity index 81% rename from docs/source/reference/router/telemetry/instrumentation/events.mdx rename to docs/source/routing/observability/telemetry/instrumentation/events.mdx index dd06fd6c26..c32824ea27 100644 --- a/docs/source/reference/router/telemetry/instrumentation/events.mdx +++ b/docs/source/routing/observability/telemetry/instrumentation/events.mdx @@ -2,22 +2,26 @@ title: Events subtitle: Capture events from the router's request lifecycle description: Capture standard and custom events from the Apollo GraphOS Router's request lifecycle services. +context: + - telemetry --- import RouterServices from '../../../../../shared/router-lifecycle-services.mdx'; import TelemetryPerformanceNote from '../../../../../shared/telemetry-performance.mdx'; -An _event_ is used to signal when something of note happens in the [GraphOS Router's request lifecycle](/router/customizations/overview/#the-request-lifecycle). Events are output to both logs and traces. +An _event_ is used to signal when something of note happens in the [GraphOS Router's request lifecycle](/graphos/routing/request-lifecycle). Events are output to both logs and traces. You can configure events for each service in `router.yaml`. Events can be standard or custom, and they can be triggered by configurable conditions. ## Event configuration + + ### Router request lifecycle services -The `router`, `supergraph` and `subgraph` sections are used to define custom event configuration for each service: +The `router`, `supergraph`, `subgraph` and `connector` sections are used to define custom event configuration for each service: ```yaml title="future.router.yaml" telemetry: @@ -29,6 +33,8 @@ telemetry: # ... subgraph: # highlight-line # ... + connector: # highlight-line + # ... ``` ### Standard events @@ -90,8 +96,6 @@ telemetry: ### Custom events - - For each service you can also configure custom events. ```yaml title="future.router.yaml" @@ -198,9 +202,27 @@ telemetry: response_header: "x-my-header" ``` -## Event configuration example +## Event configuration reference + +You can configure events with the following options: + +| Option | Values | Default | Description | +|--------------------|------------------------------------------------------------------------------|---------|-------------------------------------------------------------| +| `` | | | The name of the custom attribute. | +| `attributes` | [standard attributes](/router/configuration/telemetry/instrumentation/standard-attributes) or [selectors](/router/configuration/telemetry/instrumentation/selectors) | | The attributes of the custom log event. | +| `condition` | [conditions](/router/configuration/telemetry/instrumentation/conditions) | | The condition that must be met for the event to be emitted. | +| `error` | `trace`\|`info`\|`warn`\|`error`\| `off` | `off` | The level of the error log event. | +| `level` | `trace`\|`info`\|`warn`\|`error`\| `off` | `off` | The level of the custom log event. | +| `message` | | | The message of the custom log event. | +| `on` | `request`\|`response`\|`error` | | When to trigger the event. | +| `request` | `trace`\|`info`\|`warn`\|`error`\| `off` | `off` | The level of the request log event. | +| `response` | `trace`\|`info`\|`warn`\|`error`\| `off` | `off` | The level of the response log event. | + +## Event configuration examples -For example, the router service can be configured with standard events (`request`, `response`, `error`), and a custom event (`my.event`) with a condition: +### Standard and custom events + +You can use both standard events and custom events in the same configuration. The example below has all the standard events (`request`, `response`, `error`) and one custom event (`my.event`) with a condition: ```yaml title="future.router.yaml" telemetry: @@ -229,19 +251,38 @@ telemetry: # Custom event configuration for supergraph service ... subgraph: # Custom event configuration for subgraph service ... + connector: + # Custom event configuration for HTTP connectors ... ``` -## Event configuration reference +### Debugging subscriptions -| Option | Values | Default | Description | -|--------------------|------------------------------------------------------------------------------|---------|-------------------------------------------------------------| -| `` | | | The name of the custom attribute. | -| `attributes` | [standard attributes](/router/configuration/telemetry/instrumentation/standard-attributes) or [selectors](/router/configuration/telemetry/instrumentation/selectors) | | The attributes of the custom log event. | -| `condition` | [conditions](/router/configuration/telemetry/instrumentation/conditions) | | The condition that must be met for the event to be emitted. | -| `error` | `trace`\|`info`\|`warn`\|`error`\| `off` | `off` | The level of the error log event. | -| `level` | `trace`\|`info`\|`warn`\|`error`\| `off` | `off` | The level of the custom log event. | -| `message` | | | The message of the custom log event. | -| `on` | `request`\|`response`\|`error` | | When to trigger the event. | -| `request` | `trace`\|`info`\|`warn`\|`error`\| `off` | `off` | The level of the request log event. | -| `response` | `trace`\|`info`\|`warn`\|`error`\| `off` | `off` | The level of the response log event. | +When developing and debugging the router, you might want to log all subscription events. The example configuration below logs all subscription events for both errors and data. + + + +Logs of all subscription errors and data may contain personally identifiable information (PII), so make sure not to log PII in your production environments and only enable it for development. + + + +```yaml title="router.yaml" +telemetry: + instrumentation: + events: + supergraph: + subscription.event: + message: subscription event + on: event_response # on every subscription event + level: info + # Only display event if it's a subscription event + condition: + eq: + - operation_kind: string + - subscription + attributes: + response.data: + response_data: $ # Display all the response data payload + response.errors: + response_errors: $ # Display all the response errors payload +``` diff --git a/docs/source/reference/router/telemetry/instrumentation/instruments.mdx b/docs/source/routing/observability/telemetry/instrumentation/instruments.mdx similarity index 81% rename from docs/source/reference/router/telemetry/instrumentation/instruments.mdx rename to docs/source/routing/observability/telemetry/instrumentation/instruments.mdx index ecec8d1852..8b7a826fad 100644 --- a/docs/source/reference/router/telemetry/instrumentation/instruments.mdx +++ b/docs/source/routing/observability/telemetry/instrumentation/instruments.mdx @@ -2,6 +2,8 @@ title: Instruments subtitle: Collect measurements with standard and custom instruments description: Create and customize instruments to collect data and report measurements from the Apollo GraphOS Router's request lifecycle services. +context: + - telemetry --- import RouterServices from '../../../../../shared/router-lifecycle-services.mdx'; @@ -27,6 +29,12 @@ OpenTelemetry specifies multiple [standard metric instruments](https://opentelem * `http.client.request.duration` - A histogram of request durations for requests handled by subgraphs. * `http.client.response.body.size` - A histogram of response body sizes for requests handled by subgraphs. +* For connector HTTP requests: + + * `http.client.request.body.size` - A histogram of request body sizes for connectors HTTP requests. + * `http.client.request.duration` - A histogram of request durations for connectors HTTP requests. + * `http.client.response.body.size` - A histogram of response body sizes for connectors HTTP responses. + The [`default_requirement_level` setting](#default_requirement_level) configures whether or not these instruments are enabled by default. Out of the box, its default value of `required` enables them. You must explicitly configure an instrument for different behavior. @@ -47,6 +55,10 @@ telemetry: http.client.request.body.size: true # (default false) http.client.request.duration: true # (default false) http.client.response.body.size: true # (default false) + connector: + http.client.request.body.size: true # (default false) + http.client.request.duration: true # (default false) + http.client.response.body.size: true # (default false) ``` They can be customized by attaching or removing attributes. See [attributes](#attributes) to learn more about configuring attributes. @@ -64,6 +76,10 @@ telemetry: http.client.request.duration: attributes: subgraph.name: true + connector: + http.client.request.duration: + attributes: + connector.source.name: true ``` ### Apollo standard instruments @@ -72,7 +88,7 @@ To learn about Apollo-provided standard metric instruments for the router's requ ### Custom instruments - + You can define custom instruments on the router, supergraph, and subgraph services in the router pipeline. You can also define custom instruments for each JSON element in the response data the router returns to clients. @@ -82,6 +98,7 @@ The example configuration below defines four custom instruments: - `acme.request.duration` on the `router` service - `acme.graphql.requests` on the `supergraph` service - `acme.graphql.subgraph.errors` on the `subgraph` service +- `acme.user.not.found` on a connector HTTP response - `acme.graphql.list.lengths` on each JSON element returned to the client (defined on `graphql`) ```yaml title="router.yaml" @@ -118,6 +135,21 @@ telemetry: unit: count description: "my description" + connector: + acme.user.not.found: + value: unit + type: counter + unit: count + description: "Count of 404 responses from the user API" + condition: + all: + - eq: + - 404 + - connector_http_response_status: code + - eq: + - "user_api" + - connector_source: name + graphql: acme.graphql.list.lengths: value: @@ -476,9 +508,49 @@ telemetry: | `` | | | The name of the custom instrument. | | `attributes` | [standard attributes](/router/configuration/telemetry/instrumentation/standard-attributes) or [selectors](/router/configuration/telemetry/instrumentation/selectors) | | The attributes of the custom instrument. | | `condition` | [conditions](/router/configuration/telemetry/instrumentation/conditions) | | The condition for mutating the instrument. | -| `default_requirement_level` | `required`\|`recommended` | `required` | The default attribute requirement level. | -| `type` | `counter`\|`histogram` | | The name of the custom instrument. | +| `default_requirement_level` | `required` \| `recommended` | `required` | The default attribute requirement level. | +| `type` | `counter` \| `histogram` | | The name of the custom instrument. | | `unit` | | | A unit name, for example `By` or `{request}`. | | `description` | | | The description of the custom instrument. | -| `value` | `unit`\|`duration`\|``\|`event_unit`\|`event_duration`\|`event_custom` | | The value of the instrument. | +| `value` | `unit` \| `duration` \| `` \| `event_unit` \| `event_duration` \| `event_custom` | | The value of the instrument. | + +### Production instrumentation example + +At minimum, observability of a router running in production requires knowing about errors that arise from operations and subgraphs. + +The example configuration below adds instruments with both standard OpenTelemetry attributes and custom attributes to extract information about erring operations: +```yaml title="router.yaml" +telemetry: + instrumentation: + instruments: + router: + http.server.request.duration: + # Adding subgraph name, response status code from the router and the operation name + attributes: + http.response.status_code: true + graphql.operation.name: + operation_name: string + # This attribute will be set to true if the response contains graphql errors + graphql.errors: + on_graphql_error: true + http.server.response.body.size: + attributes: + graphql.operation.name: + operation_name: string + subgraph: + # Adding subgraph name, response status code from the subgraph and original operation name from the supergraph + http.client.request.duration: + attributes: + subgraph.name: true + http.response.status_code: + subgraph_response_status: code + graphql.operation.name: + supergraph_operation_name: string + # This attribute will be set to true if the response contains graphql errors + graphql.errors: + subgraph_on_graphql_error: true + http.client.request.body.size: + attributes: + subgraph.name: true +``` diff --git a/docs/source/routing/observability/telemetry/instrumentation/selectors.mdx b/docs/source/routing/observability/telemetry/instrumentation/selectors.mdx new file mode 100644 index 0000000000..4efdcc1eb9 --- /dev/null +++ b/docs/source/routing/observability/telemetry/instrumentation/selectors.mdx @@ -0,0 +1,247 @@ +--- +title: Selectors +subtitle: Select data from the router pipeline to extract +description: Extract and select data from the Apollo GraphOS Router's pipeline services to attach to telemetry. +context: + - telemetry +--- +import RouterServices from '../../../../../shared/router-lifecycle-services.mdx'; + +A _selector_ is used to extract data from the GraphOS Router's request lifecycle (pipeline) services and attach them to telemetry, specifically [spans](/router/configuration/telemetry/instrumentation/spans), [instruments](/router/configuration/telemetry/instrumentation/instruments), [conditions](/router/configuration/telemetry/instrumentation/conditions) and [events](/router/configuration/telemetry/instrumentation/events). + +An example of a selector, `request_header`, of the router service on a custom span attribute: + +```yaml title="router.yaml" +telemetry: + instrumentation: + spans: + mode: spec_compliant + router: + attributes: + "my_attribute": + # ... + request_header: "x-my-header" #highlight-line +``` + +## Selector configuration reference + +Each service of the router pipeline (`router`, `supergraph`, `subgraph`, `connector`) has its own available selectors. +You can also extract GraphQL metrics from the response data the router returns to clients. + +### Router + +The router service is the initial entrypoint for all requests. It is HTTP centric and deals with opaque bytes. + +| Selector | Defaultable | Values | Description | +|-----------------------|-------------|-----------------------------|----------------------------------------------------------------------| +| `trace_id` | Yes | `open_telemetry` \| `datadog` | The trace ID | +| `operation_name` | Yes | `string` \| `hash` | The operation name from the query | +| `studio_operation_id` | Yes | `true` \| `false` | The Apollo Studio operation id | +| `request_header` | Yes | | The name of the request header | +| `request_context` | Yes | | The name of a request context key | +| `response_header` | Yes | | The name of a response header | +| `response_status` | Yes | `code` \| `reason` | The response status | +| `response_context` | Yes | | The name of a response context key | +| `baggage` | Yes | | The name of a baggage item | +| `env` | Yes | | The name of an environment variable | +| `on_graphql_error` | No | `true` \| `false` | Boolean set to true if the response payload contains a GraphQL error | +| `static` | No | | A static string value | +| `error` | No | `reason` | a string value containing error reason when it's a critical error | + +### Supergraph + +The supergraph service is executed after query parsing but before query execution. It is GraphQL centric and deals with GraphQL queries and responses. + +| Selector | Defaultable | Values | Description | +|--------------------|-------------|-------------------------------------------------------|-----------------------------------------------------------------------------------| +| `operation_name` | Yes | `string` \| `hash` | The operation name from the query | +| `operation_kind` | No | `string` | The operation kind from the query | +| `query` | Yes | `string` \| `aliases` \| `depth` \| `height` \| `root_fields` | The GraphQL query | +| `query_variable` | Yes | | The name of a GraphQL query variable | +| `request_header` | Yes | | The name of a request header | +| `response_header` | Yes | | The name of a response header | +| `is_primary_response` | No | `true` \| `false` | Boolean returning true if it's the primary response and not events like subscription events or deferred responses | +| `response_data` | Yes | | JSON Path into the supergraph response body data (it might impact performance) | +| `response_errors` | Yes | | JSON Path into the supergraph response body errors (it might impact performance) | +| `request_context` | Yes | | The name of a request context key | +| `response_context` | Yes | | The name of a response context key | +| `on_graphql_error` | No | `true` \| `false` | Boolean set to true if the response payload contains a GraphQL error | +| `baggage` | Yes | | The name of a baggage item | +| `env` | Yes | | The name of an environment variable | +| `static` | No | | A static string value | +| `error` | No | `reason` | A string value containing error reason when it's a critical error | + +### Subgraph + +The subgraph service executes multiple times during query execution, with each execution representing a call to a single subgraph. It is GraphQL centric and deals with GraphQL queries and responses. + +| Selector | Defaultable | Values | Description | +|-----------------------------|-------------|------------------|--------------------------------------------------------------------------------| +| `subgraph_operation_name` | Yes | `string` \| `hash` | The operation name from the subgraph query | +| `subgraph_operation_kind` | No | `string` | The operation kind from the subgraph query | +| `subgraph_query` | Yes | `string` | The GraphQL query to the subgraph | +| `subgraph_name` | No | `true` \| `false` | The subgraph name | +| `subgraph_query_variable` | Yes | | The name of a subgraph query variable | +| `subgraph_response_data` | Yes | | JSON Path into the subgraph response body data (it might impact performance) | +| `subgraph_response_errors` | Yes | | JSON Path into the subgraph response body errors (it might impact performance) | +| `subgraph_request_header` | Yes | | The name of a subgraph request header | +| `subgraph_response_header` | Yes | | The name of a subgraph response header | +| `subgraph_response_status` | Yes | `code` \| `reason` | The status of a subgraph response | +| `subgraph_on_graphql_error` | No | `true` \| `false` | Boolean set to true if the subgraph response payload contains a GraphQL error | +| `supergraph_operation_name` | Yes | `string` \| `hash` | The operation name from the supergraph query | +| `supergraph_operation_kind` | Yes | `string` | The operation kind from the supergraph query | +| `supergraph_query` | Yes | `string` | The graphql query to the supergraph | +| `supergraph_query_variable` | Yes | | The name of a supergraph query variable | +| `supergraph_request_header` | Yes | | The name of a supergraph request header | +| `subgraph_resend_count` | Yes | `true` \| `false` | Number of retries for an http request to a subgraph | +| `request_context` | Yes | | The name of a request context key | +| `response_context` | Yes | | The name of a response context key | +| `baggage` | Yes | | The name of a baggage item | +| `env` | Yes | | The name of an environment variable | +| `static` | No | | A static string value | +| `error` | No | `reason` | A string value containing error reason when it's a critical error | +| `cache` | No | `hit` \| `miss` | Returns the number of cache hit or miss for this subgraph request | + +### Connector + +#### HTTP + +Apollo Connectors for REST APIs make HTTP calls to the upstream HTTP API. These selectors let you extract metrics from these HTTP requests and responses. + +| Selector | Defaultable | Values | Description | +|---------------------------------------|-------------|--------------------------------|--------------------------------------------------------------------------------------------------------------| +| `subgraph_name` | No | `true`\|`false` | The name of the subgraph containing the connector | +| `connector_source ` | No | `name` | The name of the `@source` associated with this connector, if any | +| `connector_http_request_header` | Yes | | The name of a connector request header | +| `connector_http_response_header` | Yes | | The name of a connector response header | +| `connector_http_response_status` | No | `code`\|`reason` | The status of a connector response | +| `connector_http_method` | No | `true`\|`false` | The HTTP method of a connector request | +| `connector_url_template ` | No | `true`\|`false` | The URL template of a connector request | +| `connector_request_mapping_problems` | No | `problems`\|`count`\|`boolean` | Any mapping problems with the connector request | +| `connector_response_mapping_problems` | No | `problems`\|`count`\|`boolean` | Any mapping problems with the connector response | +| `connector_on_response_error` | No | | Returns true if `is_successful` condition is false, or, if unset, if the response has a non-200 status code. | +| `static` | No | | A static string value | +| `error` | No | `reason` | A string value containing error reason when it's a critical error | +| `request_context` | Yes | | The value of a request context key | +| `supergraph_operation_name` | Yes | `string`\|`hash` | The operation name from the supergraph query | +| `supergraph_operation_kind` | Yes | `string` | The operation kind from the supergraph query | + + +### GraphQL + +GraphQL metrics are extracted from the response data the router returns to client requests. + +| Selector | Defaultable | Values | Description | +|------------------|-------------|------------------|---------------------------------------------| +| `list_length` | No | `value` | The length of a list from the response data | +| `field_name` | No | `string` | The name of a field from the response data | +| `field_type` | No | `string` | The type of a field from the response data | +| `type_name` | No | | The GraphQL type from the response data | +| `operation_name` | Yes | `string` \| `hash` | The operation name of the query | +| `static` | No | | A static string value | + + +## Example selector configurations + +Some example configurations of common use cases for selectors: + +### Configuring trace ID + +Logging the trace ID of a request that has GraphQL errors: + +```yaml title="router.yaml" +telemetry: + instrumentation: + events: + router: + my.event: + message: 'An event occurred' + level: error + on: response + condition: + eq: + - on_graphql_error: true + - true + attributes: + # The trace ID from the request + id_from_header: + trace_id: open_telemetry +``` + +### Setting JSON paths + +Configuring selectors with JSON paths, such as supergraph `response_data` and `response_errors`: + +```yaml title="router.yaml" +telemetry: + exporters: + metrics: + prometheus: + enabled: true + instrumentation: + instruments: + supergraph: + my.request.on_graphql_error: + value: event_unit + type: counter + unit: error + description: my description + condition: + exists: + response_errors: "$.[0].extensions.code" + attributes: + response_errors: + response_errors: "$.[0].extensions.code" +``` + +### Getting GraphQL operation info + +Configuring the `query` selector to get information about GraphQL operations, with an example for a custom view of operation limits: + +```yaml +telemetry: + exporters: + metrics: + common: + views: + # Define a custom view because operation limits are different than the default latency-oriented view of OpenTelemetry + - name: oplimits.* + aggregation: + histogram: + buckets: + - 0 + - 5 + - 10 + - 25 + - 50 + - 100 + - 500 + - 1000 + instrumentation: + instruments: + supergraph: + oplimits.aliases: + value: + query: aliases + type: histogram + unit: number + description: "Aliases for an operation" + oplimits.depth: + value: + query: depth + type: histogram + unit: number + description: "Depth for an operation" + oplimits.height: + value: + query: height + type: histogram + unit: number + description: "Height for an operation" + oplimits.root_fields: + value: + query: root_fields + type: histogram + unit: number + description: "Root fields for an operation" +``` diff --git a/docs/source/reference/router/telemetry/instrumentation/spans.mdx b/docs/source/routing/observability/telemetry/instrumentation/spans.mdx similarity index 85% rename from docs/source/reference/router/telemetry/instrumentation/spans.mdx rename to docs/source/routing/observability/telemetry/instrumentation/spans.mdx index 70016ebdb0..7c7ae1b6cf 100644 --- a/docs/source/reference/router/telemetry/instrumentation/spans.mdx +++ b/docs/source/routing/observability/telemetry/instrumentation/spans.mdx @@ -2,11 +2,13 @@ title: Spans subtitle: Add router lifecycle context to traces description: Use spans to add contextual information from the Apollo GraphOS Router or Apollo Router Core to traces displayed by your application performance monitors (APM). +context: + - telemetry --- import RouterServices from '../../../../../shared/router-lifecycle-services.mdx'; -A **span** captures contextual information about requests and responses as they're processed through the [router's request lifecycle (pipeline)](/router/customizations/overview/#the-request-lifecycle). The information from spans can be used when displaying traces in your application performance monitors (APM). +A **span** captures contextual information about requests and responses as they're processed through the [router's request lifecycle (pipeline)](/graphos/routing/request-lifecycle). The information from spans can be used when displaying traces in your application performance monitors (APM). ## Spans configuration @@ -14,7 +16,7 @@ A **span** captures contextual information about requests and responses as they' -The `router`, `supergraph` and `subgraph` sections are used to define custom span configuration for each service: +The `router`, `supergraph`, `subgraph` and `connector` sections are used to define custom span configuration for each service: ```yaml title="router.yaml" telemetry: @@ -29,6 +31,9 @@ telemetry: subgraph: # highlight-line attributes: {} # ... + connector: # highlight-line + attributes: {} + # ... ``` ### `attributes` @@ -39,7 +44,7 @@ Attributes may be drawn from [standard attributes](/router/configuration/telemet -Granular customization of attributes on spans requires a GraphOS [Dedicated or Enterprise plan](https://www.apollographql.com/pricing#observability). +Granular customization of attributes on spans requires a [GraphOS plan](https://www.apollographql.com/pricing#observability). @@ -119,13 +124,12 @@ The `mode` option enables the router spans to either use legacy attributes in th Valid values: -* `spec_compliant` -* `deprecated` (default) +* `spec_compliant` (default) +* `deprecated` #### `spec_compliant` -This mode follows the OpenTelemetry spec. Attributes that were previously added to spans that did not follow conventions are now removed. -You will likely gain a significant performance improvement by using this mode as it reduces the number of attributes that are added to spans. +This mode is the default and follows the OpenTelemetry spec. ```yaml title="router.yaml" telemetry: @@ -134,21 +138,20 @@ telemetry: mode: spec_compliant ``` -For now this is not the default, however it will be in a future release. - #### `deprecated` -This mode is the default and follows the previous behavior if you have not configured the `mode` option to `spec_compliant`. + +This mode follows the previous behavior which is deprecated. The performance of this mode is significantly worse because many more attributes are added to spans, including attributes that do not follow OpenTelemetry conventions. + ```yaml title="router.yaml" telemetry: instrumentation: spans: mode: deprecated ``` -Attributes are added to spans that do not follow OpenTelemetry conventions. -The `mode` option will be defaulted to `spec_compliant` in a future release, and eventually removed. +The `mode` option will eventually be removed in a future release. @@ -159,7 +162,7 @@ If it's in error then `otel.status_code` = `error`, if not it will be `ok`. ## Naming -By default, we will use a span naming convention that aligns with the current [semantinc conventions for GraphQL server in OpenTelemetry](https://opentelemetry.io/docs/specs/semconv/graphql/graphql-spans/) which means the root span name +By default, we will use a span naming convention that aligns with the current [semantic conventions for GraphQL server in OpenTelemetry](https://opentelemetry.io/docs/specs/semconv/graphql/graphql-spans/) which means the root span name must be of format ` ` provided that `graphql.operation.type` and `graphql.operation.name` are available. If you want to change the name of spans we're creating for each services you can override this value by setting the `otel.name` attribute using any selectors you want. @@ -238,6 +241,9 @@ telemetry: subgraph: attributes: {} # ... + connector: + attributes: {} + # ... ``` ## Spans configuration reference @@ -248,5 +254,5 @@ telemetry: | `attributes` | [standard attributes](/router/configuration/telemetry/instrumentation/standard-attributes)\|[selectors](/router/configuration/telemetry/instrumentation/selectors) | | The attributes of the span. | | `condition` | [conditions](/router/configuration/telemetry/instrumentation/conditions) | | The condition for adding a custom attribute. | | `default_attribute_requirement_level` | `required`\|`recommended` | `required` | The default attribute requirement level. | -| `mode` | `spec_compliant` \| `deprecated` | `deprecated` | The attributes of the span. | +| `mode` | `spec_compliant` \| `deprecated` | `spec_compliant` | The attributes of the span. | diff --git a/docs/source/reference/router/telemetry/instrumentation/standard-attributes.mdx b/docs/source/routing/observability/telemetry/instrumentation/standard-attributes.mdx similarity index 88% rename from docs/source/reference/router/telemetry/instrumentation/standard-attributes.mdx rename to docs/source/routing/observability/telemetry/instrumentation/standard-attributes.mdx index b1440ca115..d393e3097d 100644 --- a/docs/source/reference/router/telemetry/instrumentation/standard-attributes.mdx +++ b/docs/source/routing/observability/telemetry/instrumentation/standard-attributes.mdx @@ -2,6 +2,8 @@ title: OpenTelemetry standard attributes subtitle: Attach standard attributes to router telemetry description: Attach OpenTelemetry (OTel) standard attributes to Apollo GraphOS Router or Apollo Router Core telemetry. +context: + - telemetry --- import RouterServices from '../../../../../shared/router-lifecycle-services.mdx'; @@ -108,3 +110,15 @@ Standard attributes of the `subgraph` service: | `subgraph.graphql.operation.name` | | The operation name from the subgraph query (need `spec_compliant` [mode](/router/configuration/telemetry/instrumentation/spans/#mode) to disable it) | | `subgraph.graphql.operation.type` | `query`\|`mutation`\|`subscription` | The operation kind from the subgraph query | | `subgraph.graphql.document` | | The GraphQL query to the subgraph (need `spec_compliant` [mode](/router/configuration/telemetry/instrumentation/spans/#mode) to disable it) | +| `http.request.resend_count` | `true`\|`false` | Number of retries for an http request to a subgraph | + +#### Connector + +Standard attributes of the `connector` service: + +| Attribute | Values | Description | +|--------------------------|-------------------------------------|------------------------------------------------------------------| +| `subgraph.name` | | The name of the subgraph containing the connector | +| `connector.source.name` | | The name of the `@source` associated with this connector, if any | +| `connector.http.method` | | The HTTP method for the connector (`GET` or `POST`, for example) | +| `connector.url.template` | | The URL template for the connector | diff --git a/docs/source/routing/observability/telemetry/instrumentation/standard-instruments.mdx b/docs/source/routing/observability/telemetry/instrumentation/standard-instruments.mdx new file mode 100644 index 0000000000..7490f72a3f --- /dev/null +++ b/docs/source/routing/observability/telemetry/instrumentation/standard-instruments.mdx @@ -0,0 +1,192 @@ +--- +title: Router Instruments +subtitle: Standard metric instruments for the router's request lifecycle +description: Reference of standard metric instruments for the request lifecycle of GraphOS Router and Apollo Router Core. Consumable via the router's metrics exporters. +context: + - telemetry +--- + +## Standard metric instruments + +GraphOS Router and Apollo Router Core provide a set of standard router instruments that expose detailed information about the router's request lifecycle. You can consume the metrics they capture by configuring a [metrics exporter](/router/configuration/telemetry/exporters/metrics/overview). + +Standard router instruments are different than OpenTelemetry (OTel) instruments or custom instruments: + +- Router instruments provide standard metrics about the router request lifeycle and have names starting with `apollo.router` or `apollo_router`. +- OTel instruments provide metrics about the HTTP lifecycle and have names starting with `http`. +- Custom instruments provide customized metrics about the router request lifecycle. + +The rest of this reference lists the available standard router instruments. + +### Measuring router overhead + +Measuring overhead in the router can be challenging because it consists of multiple components, each executing tasks in parallel. Subgraph latency, cache performance, and plugins influence performance and have the potential to cause back pressure. Limitations to CPU, memory, and network bandwidth can all create bottlenecks that hinder request processing. External factors such as request rate and operation complexity heavily affect the router’s overall load. + +You can find the activity of a particular request in its trace spans. Spans have the following attributes: + - busy_ns - time in which the span is actively executing + - idle_ns - time in which the span is alive, but not actively executing + +These attributes represent how a span spends time (in nanoseconds) over its lifetime. Your APM provider can likely use this trace data to generate synthetic metrics which you can then create an approximation of. + +### GraphQL + +- `apollo.router.graphql_error` - counts GraphQL errors in responses. Also counts errors which occur during the response validation phase, which are represented in client responses as `extensions.valueCompletion` instead of actual GraphQL errors. Attributes: + - `code`: error code, including `RESPONSE_VALIDATION_FAILED` in the case of a value completion error. + +### Session + +- `apollo.router.session.count.active` - Number of in-flight GraphQL requests + +### Cache + +- `apollo.router.cache.size` — Number of entries in the cache +- `apollo.router.cache.hit.time` - Time to hit the cache in seconds +- `apollo.router.cache.hit.time.count` - Number of cache hits +- `apollo.router.cache.miss.time` - Time to miss the cache in seconds +- `apollo.router.cache.miss.time.count` - Number of cache misses +- `apollo.router.cache.storage.estimated_size` - The estimated storage size of the cache in bytes (query planner in memory only). + +All cache metrics listed above have the following attributes: + +- `kind`: the cache being queried (`apq`, `query planner`, `introspection`) +- `storage`: The backend storage of the cache (`memory`, `redis`) + +### Redis Cache + +When using Redis as a cache backend, additional Redis-specific metrics are available: + +- `apollo.router.cache.redis.connections` - Number of Redis connections established +- `apollo.router.cache.redis.command_queue_length` - Number of Redis commands buffered and not yet sent +- `apollo.router.cache.redis.commands_executed` - Total number of Redis commands executed +- `apollo.router.cache.redis.redelivery_count` - Number of Redis command redeliveries due to connection issues +- `apollo.router.cache.redis.errors` - Number of Redis errors by error type and cache kind +- `experimental.apollo.router.cache.redis.latency_avg` - Average Redis command latency in seconds +- `experimental.apollo.router.cache.redis.network_latency_avg` - Average Redis network latency in seconds +- `experimental.apollo.router.cache.redis.request_size_avg` - Average Redis request size in bytes +- `experimental.apollo.router.cache.redis.response_size_avg` - Average Redis response size in bytes + +All Redis metrics include the following attribute: + +- `kind`: the cache being queried (`apq`, `query planner`, `introspection`, `entity`) + +The `apollo.router.cache.redis.errors` metric also includes an `error_type` attribute with possible values: +- `config` - Configuration errors (invalid Redis settings) +- `auth` - Authentication errors (wrong credentials) +- `routing` - Cluster routing errors +- `io` - Network I/O errors +- `invalid_command` - Invalid Redis commands +- `invalid_argument` - Invalid command arguments +- `url` - Invalid Redis URL format +- `protocol` - Redis protocol errors +- `tls` - TLS/SSL connection errors +- `canceled` - Canceled operations +- `unknown` - Unknown errors +- `timeout` - Operation timeouts +- `cluster` - Redis cluster state errors +- `parse` - Data parsing errors +- `sentinel` - Redis Sentinel errors +- `backpressure` - Backpressure/overload errors + +### Coprocessor + +- `apollo.router.operations.coprocessor` - Total operations with coprocessors enabled. + - `coprocessor.succeeded`: bool + - `coprocessor.stage`: string (`RouterRequest`, `RouterResponse`, `SubgraphRequest`, `SubgraphResponse`) + +- `apollo.router.operations.coprocessor.duration` - Time spent waiting for the coprocessor to answer, in seconds. + - `coprocessor.stage`: string (`RouterRequest`, `RouterResponse`, `SubgraphRequest`, `SubgraphResponse`) + +### Performance + +- `apollo_router_schema_load_duration` - Time spent loading the schema in seconds. + +### Query planning + +- `apollo.router.query_planning.warmup.duration` - Time spent warming up the query planner queries in seconds. +- `apollo.router.query_planning.plan.duration` - Histogram of plan durations isolated to query planning time only. +- `apollo.router.query_planning.total.duration` - Histogram of plan durations including queue time. +- `apollo.router.query_planning.plan.evaluated_plans` - Histogram of the number of evaluated query plans. + +### Compute jobs + +- `apollo.router.compute_jobs.queued` - A gauge of the number of jobs queued for the thread pool dedicated to CPU-heavy components like GraphQL parsing and validation, and the query planner. +- `apollo.router.compute_jobs.queue_is_full` - A counter of requests rejected because the queue was full. +- `apollo.router.compute_jobs.duration` - A histogram of time spent in the compute pipeline by the job, including the queue and query planning. + - `job.type`: (`QueryPlanning`, `QueryParsing`, `Introspection`) + - `job.outcome`: (`ExecutedOk`, `ExecutedError`, `ChannelError`, `RejectedQueueFull`, `Abandoned`) +- `apollo.router.compute_jobs.queue.wait.duration` - A histogram of time spent in the compute queue by the job. + - `job.type`: (`QueryPlanning`, `QueryParsing`, `Introspection`) +- `apollo.router.compute_jobs.execution.duration` - A histogram of time spent to execute job (excludes time spent in the queue). + - `job.type`: (`QueryPlanning`, `QueryParsing`, `Introspection`) +- `apollo.router.compute_jobs.active_jobs` - A gauge of the number of compute jobs being processed in parallel. + - `job.type`: (`QueryPlanning`, `QueryParsing`, `Introspection`) +### Uplink + + + +[Learn more about Apollo Uplink.](/federation/managed-federation/uplink/) + + + +- `apollo.router.uplink.fetch.duration.seconds` - Uplink request duration, attributes: + - `url`: The Uplink URL that was polled + - `query`: The query that the router sent to Uplink (`SupergraphSdl` or `License`) + - `kind`: (`new`, `unchanged`, `http_error`, `uplink_error`) + - `code`: The error code depending on type (if an error occurred) + - `error`: The error message (if an error occurred) +- `apollo.router.uplink.fetch.count.total` + - `status`: (`success`, `failure`) + - `query`: The query that the router sent to Uplink (`SupergraphSdl` or `License`) + + + +The initial call to Uplink during router startup is not reflected in metrics. + + + +### Subscriptions + + + +[Learn more about subscriptions.](/router/executing-operations/subscription-support/) + + + +- `apollo.router.opened.subscriptions` - Number of different opened subscriptions (not the number of clients with an opened subscriptions in case it's deduplicated). This metric contains `graphql.operation.name` label to know exactly which subscription is still opened. +- `apollo.router.skipped.event.count` - Number of subscription events that has been skipped because too many events have been received from the subgraph but not yet sent to the client. + +### Batching + +- `apollo.router.operations.batching` - A counter of the number of query batches received by the router. +- `apollo.router.operations.batching.size` - A histogram tracking the number of queries contained within a query batch. + +### GraphOS Studio + +- `apollo.router.telemetry.studio.reports` - The number of reports submitted to GraphOS Studio by the router. + - `report.type`: The type of report submitted: "traces" or "metrics" + - `report.protocol`: Either "apollo" or "otlp", depending on the otlp_tracing_sampler configuration. + +### Telemetry + +- `apollo.router.telemetry.batch_processor.errors` - The number of errors encountered by exporter batch processors. + - `name`: One of `apollo-tracing`, `datadog-tracing`, `jaeger-collector`, `otlp-tracing`, `zipkin-tracing`. + - `error`: One of `channel closed`, `channel full`. + +- `apollo.router.telemetry.metrics.cardinality_overflow` - A count of how often a telemetry metric hit otel's hard cardinality limit. + +### Internals + +- `apollo.router.pipelines` - The number of request pipelines active in the router + - `schema.id` - The Apollo Studio schema hash associated with the pipeline. + - `launch.id` - The Apollo Studio launch id associated with the pipeline (optional). + - `config.hash` - The hash of the configuration + +### Server + +- `apollo.router.open_connections` - The number of open connections to the Router. + - `schema.id` - The Apollo Studio schema hash associated with the pipeline. + - `launch.id` - The Apollo Studio launch id associated with the pipeline (optional). + - `config.hash` - The hash of the configuration. + - `server.address` - The address that the router is listening on. + - `server.port` - The port that the router is listening on if not a unix socket. + - `http.connection.state` - Either `active` or `terminating`. diff --git a/docs/source/reference/router/telemetry/log-exporters/overview.mdx b/docs/source/routing/observability/telemetry/log-exporters/overview.mdx similarity index 87% rename from docs/source/reference/router/telemetry/log-exporters/overview.mdx rename to docs/source/routing/observability/telemetry/log-exporters/overview.mdx index f1cef8cff1..48edfec40a 100644 --- a/docs/source/reference/router/telemetry/log-exporters/overview.mdx +++ b/docs/source/routing/observability/telemetry/log-exporters/overview.mdx @@ -2,6 +2,8 @@ title: Router Logging subtitle: Configure logging in the router description: Configure logging in the Apollo GraphOS Router or Apollo Router Core. Set the log level and output format. +context: + - telemetry --- GraphOS Router and Apollo Router Core provide built-in logging to capture records about their activity. @@ -134,40 +136,21 @@ telemetry: logging: common: resource: - "environment.name": "production" - "environment.namespace": "{env.MY_K8_NAMESPACE_ENV_VARIABLE}" + "deployment.environment.name": "production" + "k8s.namespace.name": "{env.MY_K8_NAMESPACE_ENV_VARIABLE}" ``` For OpenTelemetry conventions for resources, see [Resource Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/README.md). ### Request/Response logging - - - By default, the router _doesn't_ log the following values that might contain sensitive data, even if a sufficient log level is set: - Request bodies - Response bodies - Headers -You can enable selective logging of these values via the `experimental_when_header` option: - -```yaml title="router.yaml" -telemetry: - exporters: - logging: - # If one of these headers matches we will log supergraph and subgraphs requests/responses - experimental_when_header: - - name: apollo-router-log-request - value: my_client - headers: true # default: false - body: true # default: false - # log request for all requests coming from Iphones - - name: user-agent - match: ^Mozilla/5.0 (iPhone* - headers: true -``` +You can enable selective logging of these values using [standard events](../instrumentation/events) with [conditions](../instrumentation/conditions) ## Logging common reference diff --git a/docs/source/reference/router/telemetry/log-exporters/stdout.mdx b/docs/source/routing/observability/telemetry/log-exporters/stdout.mdx similarity index 99% rename from docs/source/reference/router/telemetry/log-exporters/stdout.mdx rename to docs/source/routing/observability/telemetry/log-exporters/stdout.mdx index fddae9b1be..2063eb2024 100644 --- a/docs/source/reference/router/telemetry/log-exporters/stdout.mdx +++ b/docs/source/routing/observability/telemetry/log-exporters/stdout.mdx @@ -2,6 +2,8 @@ title: Router Logging to stdout subtitle: Configure logging to stdout description: Configure logging output to stdout in the Apollo GraphOS Router or Apollo Router Core. Format in human-readable text or machine-readable JSON. +context: + - telemetry --- You can configure GraphOS Router or Apollo Router Core logging to be directed to stdout, and its output format can be set to text or JSON. diff --git a/docs/source/reference/router/telemetry/metrics-exporters/datadog.mdx b/docs/source/routing/observability/telemetry/metrics-exporters/datadog.mdx similarity index 51% rename from docs/source/reference/router/telemetry/metrics-exporters/datadog.mdx rename to docs/source/routing/observability/telemetry/metrics-exporters/datadog.mdx index 4aa7850a76..4f37c4d5b2 100644 --- a/docs/source/reference/router/telemetry/metrics-exporters/datadog.mdx +++ b/docs/source/routing/observability/telemetry/metrics-exporters/datadog.mdx @@ -1,34 +1,22 @@ --- -title: Datadog exporter (via OTLP) -subtitle: Configure the Datadog exporter for metrics -description: Configure the Datadog exporter for metrics via OpenTelemetry Protocol (OTLP) in the Apollo GraphOS Router or Apollo Router Core. +title: Datadog configuration of OTLP exporter +subtitle: Configure the OTLP metrics exporter for Datadog +description: Configure the OpenTelemetry Protocol (OTLP) metrics exporter for Datadog in the Apollo GraphOS Router or Apollo Router Core. +context: + - telemetry --- -Enable and configure the [OTLP exporter](/router/configuration/telemetry/exporters/metrics/otlp) for metrics in the GraphOS Router or Apollo Router Core for use with [Datadog](https://www.datadoghq.com/). +This metrics exporter is a configuration of the [OTLP exporter](/router/configuration/telemetry/exporters/metrics/otlp) to use with [Datadog](https://www.datadoghq.com/). For general tracing configuration, refer to [Router Metrics Configuration](/router/configuration/telemetry/exporters/metrics/overview). -## Datadog configuration +## Configuration -To export metrics to Datadog, you must both: - -- Configure the Datadog agent to accept OpenTelemetry Protocol (OTLP) metrics, and -- Configure the router to send traces to the Datadog agent. - -### Datadog agent configuration - -To configure the Datadog agent, add OTLP configuration (`otlp_config`) to your `datadog.yaml`. For example: - -```yaml title="datadog.yaml" -otlp_config: - receiver: - protocols: - grpc: - endpoint: :4317 -``` +To export metrics to Datadog, you must configure both the router to send traces to the Datadog agent and the Datadog agent to accept OpenTelemetry Protocol (OTLP) metrics. ### Router configuration -To configure the router, enable the [OTLP exporter](/router/configuration/telemetry/exporters/metrics/otlp#configuration) and set both `temporality: delta` and `endpoint: `. For example: + +You should enable the [OTLP exporter](/router/configuration/telemetry/exporters/metrics/otlp#configuration) and set both `temporality: delta` and `endpoint: `. For example: ```yaml title="router.yaml" telemetry: @@ -44,9 +32,20 @@ telemetry: -**You must set `temporality: delta`**, otherwise the router generates incorrect metrics. +You must set `temporality: delta`, otherwise the router generates incorrect metrics. -For more details about Datadog configuration, see [Datadog's docs on Agent configuration](https://docs.datadoghq.com/opentelemetry/otlp_ingest_in_the_agent/?tab=host). +### Datadog agent configuration + +To configure the Datadog agent, add OTLP configuration (`otlp_config`) to your `datadog.yaml`. For example: +```yaml title="datadog.yaml" +otlp_config: + receiver: + protocols: + grpc: + endpoint: :4317 +``` + +For more details about Datadog configuration, see [Datadog's docs on Agent configuration](https://docs.datadoghq.com/opentelemetry/otlp_ingest_in_the_agent/?tab=host). diff --git a/docs/source/reference/router/telemetry/metrics-exporters/dynatrace.mdx b/docs/source/routing/observability/telemetry/metrics-exporters/dynatrace.mdx similarity index 83% rename from docs/source/reference/router/telemetry/metrics-exporters/dynatrace.mdx rename to docs/source/routing/observability/telemetry/metrics-exporters/dynatrace.mdx index fedb7f9e90..866c43e686 100644 --- a/docs/source/reference/router/telemetry/metrics-exporters/dynatrace.mdx +++ b/docs/source/routing/observability/telemetry/metrics-exporters/dynatrace.mdx @@ -1,10 +1,12 @@ --- -title: Dynatrace exporter (via OTLP) -subtitle: Configure the Dynatrace exporter for metrics -description: Configure the Dynatrace exporter for metrics via OpenTelemetry Protocol (OTLP) in the Apollo Router. +title: Dynatrace configuration of OTLP exporter +subtitle: Configure the OTLP exporter for Dynatrace +description: Configure the OTLP metrics exporter for Dynatrace via OpenTelemetry Protocol (OTLP) in the Apollo Router. +context: + - telemetry --- -Enable and configure the [OTLP exporter](/router/configuration/telemetry/exporters/metrics/otlp) for metrics in the Apollo Router for use with [Dynatrace](https://dynatrace.com/). +This metrics exporter is a configuration of the [OTLP exporter](/router/configuration/telemetry/exporters/metrics/otlp) to use with [Dynatrace](https://www.dynatrace.com/). For general tracing configuration, refer to [Router Metrics Configuration](/router/configuration/telemetry/exporters/metrics/overview). diff --git a/docs/source/reference/router/telemetry/metrics-exporters/new-relic.mdx b/docs/source/routing/observability/telemetry/metrics-exporters/new-relic.mdx similarity index 84% rename from docs/source/reference/router/telemetry/metrics-exporters/new-relic.mdx rename to docs/source/routing/observability/telemetry/metrics-exporters/new-relic.mdx index 656360e73c..dedc42beaa 100644 --- a/docs/source/reference/router/telemetry/metrics-exporters/new-relic.mdx +++ b/docs/source/routing/observability/telemetry/metrics-exporters/new-relic.mdx @@ -1,10 +1,12 @@ --- -title: New Relic exporter (via OTLP) +title: New Relic configuration of OTLP exporter subtitle: Configure the New Relic exporter for metrics description: Configure the New Relic exporter for metrics via OpenTelemetry Protocol (OTLP) in the Apollo GraphOS Router or Apollo Router Core. +context: + - telemetry --- -Enable and configure the [OTLP exporter](/router/configuration/telemetry/exporters/metrics/otlp) for metrics in the GraphOS Router or Apollo Router Core for use with [New Relic](https://newrelic.com/). +This metrics exporter is a configuration of the [OTLP exporter](/router/configuration/telemetry/exporters/metrics/otlp) to use with [New Relic](https://newrelic.com/). For general tracing configuration, refer to [Router Metrics Configuration](/router/configuration/telemetry/exporters/metrics/overview). @@ -36,8 +38,7 @@ telemetry: endpoint: https://otlp.nr-data.net:4317/v1/metrics grpc: metadata: - api-key: - - "" + api-key: "" ``` For more details about New Relic configuration, see [New Relic's docs on OpenTelemetry configuration](https://docs.newrelic.com/docs/more-integrations/open-source-telemetry-integrations/opentelemetry/get-started/opentelemetry-set-up-your-app/#review-settings). diff --git a/docs/source/reference/router/telemetry/metrics-exporters/otlp.mdx b/docs/source/routing/observability/telemetry/metrics-exporters/otlp.mdx similarity index 97% rename from docs/source/reference/router/telemetry/metrics-exporters/otlp.mdx rename to docs/source/routing/observability/telemetry/metrics-exporters/otlp.mdx index 08042c405a..dc71efdee6 100644 --- a/docs/source/reference/router/telemetry/metrics-exporters/otlp.mdx +++ b/docs/source/routing/observability/telemetry/metrics-exporters/otlp.mdx @@ -2,6 +2,8 @@ title: OpenTelemetry Protocol (OTLP) exporter subtitle: Configure the OpenTelemetry Protocol (OTLP) exporter for metrics description: Configure the OpenTelemetry Protocol (OTLP) exporter for metrics in the Apollo GraphOS Router or Apollo Router Core. +context: + - telemetry --- import BatchProcessorPreamble from '../../../../../shared/batch-processor-preamble.mdx'; import BatchProcessorRef from '../../../../../shared/batch-processor-ref.mdx'; @@ -14,6 +16,7 @@ Using the OTLP protocol, you can export metrics to any OTLP compatible receiver, * [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) * [Datadog](https://www.datadoghq.com/) (see [configuration instructions](/router/configuration/telemetry/exporters/metrics/datadog)) +* [Dynatrace](https://www.dynatrace.com/) (see [configuration instructions](/router/configuration/telemetry/exporters/metrics/dynatrace)) * [New Relic](https://www.newrelic.com/) (see [configuration instructions](/router/configuration/telemetry/exporters/metrics/new-relic)) ## OTLP configuration diff --git a/docs/source/reference/router/telemetry/metrics-exporters/overview.mdx b/docs/source/routing/observability/telemetry/metrics-exporters/overview.mdx similarity index 64% rename from docs/source/reference/router/telemetry/metrics-exporters/overview.mdx rename to docs/source/routing/observability/telemetry/metrics-exporters/overview.mdx index efb22e3ccc..7c7379ed01 100644 --- a/docs/source/reference/router/telemetry/metrics-exporters/overview.mdx +++ b/docs/source/routing/observability/telemetry/metrics-exporters/overview.mdx @@ -4,6 +4,8 @@ subtitle: Export router metrics description: Collect and export metrics from the Apollo GraphOS Router or Apollo Router Core for Prometheus, OpenTelemetry Protocol (OTLP), Datadog, and New Relic. redirectFrom: - /technotes/TN0015-router-to-apm-via-opentelemetry/ +context: + - telemetry --- The GraphOS Router and Apollo Router Core support collection of metrics with [OpenTelemetry](https://opentelemetry.io/), with exporters for: @@ -26,7 +28,6 @@ Common metrics configuration contains global settings for all exporters: * [Service name](#service_name) * [Resource attributes](#resource) * [Custom default histogram buckets](#buckets) -* [`apollo_router_http_requests` attributes](#attributes) * [OpenTelemetry views](#views) ### `service_name` @@ -87,8 +88,8 @@ telemetry: metrics: common: resource: - "environment.name": "production" - "environment.namespace": "{env.MY_K8_NAMESPACE_ENV_VARIABLE}" + "deployment.environment.name": "production" + "k8s.namespace.name": "{env.MY_K8_NAMESPACE_ENV_VARIABLE}" ``` For OpenTelemetry conventions for resources, see [Resource Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/README.md). @@ -115,83 +116,6 @@ telemetry: - 20.00 ``` - -### `attributes` - -You can add custom attributes (OpenTelemetry) and labels (Prometheus) to the `apollo_router_http_requests` metric. Attributes can be: - -* static values (preferably using a [resource](#resource)) -* headers from the request or response -* a value from a context -* a value from the request or response body ([JSON path](https://goessner.net/articles/JsonPath/)) - - - -Use [resource attributes](#resource) instead to provide information about telemetry resources, including hosts and environments. - - - -An example of configuring these attributes is shown below: - -```yaml title="router.yaml" -telemetry: - exporters: - metrics: - common: - attributes: - supergraph: # Attribute configuration for requests to/responses from the router - static: - - name: "version" - value: "v1.0.0" - request: - header: - - named: "content-type" - rename: "payload_type" - default: "application/json" - - named: "x-custom-header-to-add" - response: - body: - # Apply the value of the provided path of the router's response body as an attribute - - path: .errors[0].extensions.http.status - name: error_from_body - # Use the unique extension code to identify the kind of error - - path: .errors[0].extensions.code - name: error_code - context: - # Apply the indicated element from the plugin chain's context as an attribute - - named: my_key - subgraph: # Attribute configuration for requests to/responses from subgraphs - all: - static: - # Always apply this attribute to all metrics for all subgraphs - - name: kind - value: subgraph_request - errors: # Only work if it's a valid GraphQL error (for example if the subgraph returns an http error or if the router can't reach the subgraph) - include_messages: true # Will include the error message in a message attribute - extensions: # Include extensions data - - name: subgraph_error_extended_type # Name of the attribute - path: .type # JSON query path to fetch data from extensions - - name: message - path: .reason - # Will create this kind of metric for example apollo_router_http_requests_error_total{message="cannot contact the subgraph",subgraph="my_subgraph_name",subgraph_error_extended_type="SubrequestHttpError"} - subgraphs: - my_subgraph_name: # Apply these rules only for the subgraph named `my_subgraph_name` - request: - header: - - named: "x-custom-header" - body: - # Apply the value of the provided path of the router's request body as an attribute (here it's the query) - - path: .query - name: query - default: UNKNOWN -``` - - - -OpenTelemetry includes many [standard attributes](https://opentelemetry.io/docs/specs/semconv/attributes-registry/) that you can use via custom [instruments](/router/configuration/telemetry/instrumentation/instruments). - - - ### `views` You can override default attributes and default buckets for specific metrics thanks to this configuration. @@ -240,7 +164,6 @@ telemetry: | `service_name` | `unknown_service:router` | The OpenTelemetry service name. | | `service_namespace` | | The OpenTelemetry namespace. | | `resource` | | The OpenTelemetry resource to attach to metrics. | -| `attributes` | | Customization for the apollo_router_http_requests instrument. | | `views` | | Override default buckets or configuration for metrics (including dropping the metric itself) | diff --git a/docs/source/reference/router/telemetry/metrics-exporters/prometheus.mdx b/docs/source/routing/observability/telemetry/metrics-exporters/prometheus.mdx similarity index 58% rename from docs/source/reference/router/telemetry/metrics-exporters/prometheus.mdx rename to docs/source/routing/observability/telemetry/metrics-exporters/prometheus.mdx index 3123714d14..ee12934f78 100644 --- a/docs/source/reference/router/telemetry/metrics-exporters/prometheus.mdx +++ b/docs/source/routing/observability/telemetry/metrics-exporters/prometheus.mdx @@ -2,12 +2,21 @@ title: Prometheus exporter subtitle: Configure the Prometheus metrics exporter description: Configure the Prometheus metrics exporter endpoint in the Apollo GraphOS Router or Apollo Router Core. +context: + - telemetry --- Enable and configure the [Prometheus](https://www.prometheus.io/) exporter for metrics in the GraphOS Router or Apollo Router Core. For general metrics configuration, refer to [Router Metrics Configuration](/router/configuration/telemetry/exporters/metrics/overview). + + +The Prometheus exporter replaces `.` characters with `_` in instrument names. +For example, the [`apollo.router.cache.miss.time.count` instrument](/graphos/routing/observability/telemetry/instrumentation/standard-instruments#cache) is exported as `apollo_router_cache_miss_time_count` with the Prometheus exporter. + + + ## Prometheus configuration To export metrics to Prometheus, enable the Prometheus endpoint and set its address and path in [`router.yaml`](/router/configuration/overview#yaml-config-file): @@ -15,11 +24,12 @@ To export metrics to Prometheus, enable the Prometheus endpoint and set its addr ```yaml title="router.yaml" telemetry: exporters: - metrics: - prometheus: - enabled: true - listen: 127.0.0.1:9090 - path: /metrics + metrics: + prometheus: + enabled: true + resource_selector: all # default: none + listen: 127.0.0.1:9090 + path: /metrics ``` Once enabled, you will be able to access the Prometheus endpoint at `http://localhost:9090/metrics`. @@ -38,14 +48,17 @@ The address and port to listen on for Prometheus metrics. Defaults to `127.0.0.1 The path to expose the Prometheus metrics. Defaults to `/metrics`. -## Prometheus configuration reference +### `resource_selector` + +Resource selector is used to select which resource to export with every metrics. If it's set to `all`, it will export all resource attributes with every metrics. Defaults to `none`. -| Attribute | Default | Description | -|---------------|------------------|--------------------------------------------| -| `enabled` | `false` | Enable the Prometheus exporter. | -| `listen` | `127.0.0.1:9090` | The address to serve Prometheus metric on. | -| `path` | `/metrics` | The path to serve Prometheus metrics on. | +## Prometheus configuration reference +| Attribute | Default | Description | +| --------- | ---------------- | ------------------------------------------ | +| `enabled` | `false` | Enable the Prometheus exporter. | +| `listen` | `127.0.0.1:9090` | The address to serve Prometheus metric on. | +| `path` | `/metrics` | The path to serve Prometheus metrics on. | ## Using Prometheus with containers @@ -58,12 +71,12 @@ You can enable other containers to access it by setting the endpoint to listen t ```yaml title="router.yaml" telemetry: exporters: - metrics: - prometheus: - # By setting this endpoint you enable other containers and pods to access the Prometheus endpoint - enabled: true - listen: 0.0.0.0:9090 #highlight-line - path: /metrics + metrics: + prometheus: + # By setting this endpoint you enable other containers and pods to access the Prometheus endpoint + enabled: true + listen: 0.0.0.0:9090 #highlight-line + path: /metrics ``` You can validate your setting locally: @@ -71,17 +84,14 @@ You can validate your setting locally: 1. Run a query against the router. 2. Navigate to [http://localhost:9090/metrics](http://localhost:9090/metrics), and check that the endpoint returns metrics similar to the following: - ``` - # HELP apollo_router_http_request_duration_seconds Total number of HTTP requests made. - # TYPE apollo_router_http_request_duration_seconds histogram - apollo_router_http_request_duration_seconds_bucket{le="0.5"} 1 - apollo_router_http_request_duration_seconds_bucket{le="0.9"} 1 - ---SNIP--- - ``` + ``` + http_server_request_duration{le="0.5"} 1 + http_server_request_duration{le="0.9"} 1 + ---SNIP--- + ``` If you haven't run a query against the router yet, you'll see a blank page because no metrics have been generated. - diff --git a/docs/source/routing/observability/telemetry/trace-exporters/datadog.mdx b/docs/source/routing/observability/telemetry/trace-exporters/datadog.mdx new file mode 100644 index 0000000000..afac82bc43 --- /dev/null +++ b/docs/source/routing/observability/telemetry/trace-exporters/datadog.mdx @@ -0,0 +1,469 @@ +--- +title: Datadog configuration of OTLP exporter +subtitle: Configure the OTLP trace exporter for Datadog +description: Configure the OpenTelemetry Protocol (OTLP) trace exporter for Datadog in the Apollo GraphOS Router or Apollo Router Core. +context: + - telemetry +--- + +import BatchProcessorPreamble from '../../../../../shared/batch-processor-preamble.mdx'; +import BatchProcessorRef from '../../../../../shared/batch-processor-ref.mdx'; + +This tracing exporter is a configuration of the [OTLP exporter](/graphos/routing/observability/telemetry/trace-exporters/otlp) to use with [Datadog](https://www.datadoghq.com/). + +For general tracing configuration, refer to [Router Tracing Configuration](/router/configuration/telemetry/exporters/tracing/overview). + +## Attributes for Datadog APM UI + +The router should set attributes that Datadog uses to organize its APM view and other UI: + +- `otel.name`: span name that's fixed for Datadog +- `resource.name`: Datadog resource name that's displayed in traces +- `operation.name`: Datadog operation name that populates a dropdown menu in the Datadog service page + +You should add these attributes to your `router.yaml` configuration file. The example below sets these attributes for the `router`, `supergraph`, and `subgraph` stages of the router's request lifecycle: + +```yaml title="router.yaml" +telemetry: + instrumentation: + spans: + mode: spec_compliant + router: + attributes: + otel.name: router + operation.name: "router" + resource.name: + request_method: true + + supergraph: + attributes: + otel.name: supergraph + operation.name: "supergraph" + resource.name: + operation_name: string + + subgraph: + attributes: + otel.name: subgraph + operation.name: "subgraph" + resource.name: + subgraph_operation_name: string +``` + +Consequently you can filter for these operations in Datadog APM: + +Datadog APM showing operations set with example attributes set in router.yaml + +## OTLP configuration + +[OpenTelemetry protocol (OTLP)](https://opentelemetry.io/docs/specs/otel/protocol/) is the recommended protocol for transmitting telemetry, including traces, to Datadog. + +To setup traces to Datadog via OTLP, you must do the following: + +- Modify the default configuration of the Datadog Agent to accept OTLP traces from the router. +- Configure the router to send traces to the configured Datadog Agent. + +### Datadog Agent configuration + +To configure the Datadog Agent, add OTLP configuration to your `datadog.yaml`. For example: + +```yaml title="datadog.yaml" +otlp_config: + receiver: + protocols: + grpc: + endpoint: :4317 +``` + +For additional Datadog Agent configuration details, review Datadog's [Enabling OTLP Ingestion on the Datadog Agent](https://docs.datadoghq.com/opentelemetry/interoperability/otlp_ingest_in_the_agent/?tab=host#enabling-otlp-ingestion-on-the-datadog-agent) documentation. + +### Router configuration + +To configure the router, enable the [OTLP exporter](./otlp) and set `endpoint: `. For example: + +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + common: + # Configured to forward 10 percent of spans from the Datadog Agent to Datadog. Experiment to find a value that is good for you. + preview_datadog_agent_sampling: true + sampler: 0.1 + + otlp: + enabled: true + # Optional endpoint, either 'default' or a URL (Defaults to http://127.0.0.1:4317) + endpoint: "${env.DATADOG_AGENT_HOST}:4317" + + # Optional batch processor setting, this will enable the batch processor to send concurrent requests in a high load scenario. + batch_processor: + max_concurrent_exports: 100 +``` + +Adjusting the `sampler` controls the sampling decisions that the router makes on its own and decreases the rate at which you sample. Your sample rate can have a direct impact on your Datadog bill. + + + +If you see warning messages from the router regarding the batch span processor, you may need to adjust your `batch_processor` settings in your `exporter` config to match the volume of spans being created in a router instance. This applies to both OTLP and the Datadog native exporters. + + + +### Enabling Datadog Agent sampling + +The Datadog APM view relies on traces to generate metrics. For these metrics to be accurate, all requests must be sampled and sent to the Datadog agent. +To prevent all traces from being sent to Datadog, in your router you must set `preview_datadog_agent_sampling` to `true` and adjust the `sampler` to the desired percentage of traces to be sent to Datadog. + +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + common: + # Configured to forward 10 percent of spans from the Datadog Agent to Datadog. Experiment to find a value that is good for you. + sampler: 0.1 + preview_datadog_agent_sampling: true +``` + + + + - The router doesn't support [`in-agent` ingestion control](https://docs.datadoghq.com/tracing/trace_pipeline/ingestion_mechanisms/?tab=java#in-the-agent). + + - Configuring `traces_per_second` in the Datadog Agent will not dynamically adjust the router's sampling rate to meet the target rate. + + - Using `preview_datadog_agent_sampling` will send _all_ spans to the Datadog Agent. This will have an impact on the resource usage and performance of both the router and Datadog Agent. + + + +### Enabling log correlation + +To enable Datadog log correlation, you must configure `dd.trace_id` to appear on the `router` span: + +```yaml title="router.yaml" +telemetry: + instrumentation: + spans: + mode: spec_compliant + router: + attributes: + dd.trace_id: true #highlight-line +``` + +Your JSON formatted log messages will automatically output `dd.trace_id` on each log message if `dd.trace_id` was detected on the `router` span. + +## Datadog native configuration + + + +Native Datadog tracing is not part of the OpenTelemetry spec, and given that Datadog supports OTLP we will be deprecating native Datadog tracing in the future. Use [OTLP configuration](#otlp-configuration) instead. + + + +The router can be configured to connect to either the native, default Datadog agent address or a URL: + +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + common: + # Configured to forward 10 percent of spans from the Datadog Agent to Datadog. Experiment to find a value that is good for you. + preview_datadog_agent_sampling: true + sampler: 0.1 + + datadog: + enabled: true + # Optional endpoint, either 'default' or a URL (Defaults to http://127.0.0.1:8126) + endpoint: "http://${env.DATADOG_AGENT_HOST}:8126" + + # Optional batch processor setting, this will enable the batch processor to send concurrent requests in a high load scenario. + batch_processor: + max_concurrent_exports: 100 + + # Enable graphql.operation.name attribute on supergraph spans. + instrumentation: + spans: + mode: spec_compliant + supergraph: + attributes: + graphql.operation.name: true +``` + + + +Depending on the volume of spans being created in a router instance, it will be necessary to adjust the `batch_processor` settings in your `exporter` config. This applies to both OTLP and the Datadog native exporter. + + + +### `enabled` + +Set to true to enable the Datadog exporter. Defaults to false. + +### `enable_span_mapping` (default: `true`) + +[There are some incompatibilities](https://docs.rs/opentelemetry-datadog/latest/opentelemetry_datadog/#quirks) between Datadog and OpenTelemetry, the Datadog exporter might not provide meaningful contextual information in the exported spans. To fix this, you can configure the router to perform a mapping for the span name and the span resource name. + +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + datadog: + enabled: true + enable_span_mapping: true +``` + +With `enable_span_mapping: true`, the router performs the following mapping: + +1. Use the OpenTelemetry span name to set the Datadog span operation name. +2. Use the OpenTelemetry span attributes to set the Datadog span resource name. + +#### Example trace + +For example, assume a client sends a query `MyQuery` to the router. The router's query planner sends a subgraph query to `my-subgraph-name` and creates the following trace: + +``` + | apollo_router request | + | apollo_router router | + | apollo_router supergraph | + | apollo_router query_planning | apollo_router execution | + | apollo_router fetch | + | apollo_router subgraph | + | apollo_router subgraph_request | +``` + +As you can see, there is no clear information about the name of the query, the name of the subgraph, and the name of the query sent to the subgraph. + +Instead, when `enable_span_mapping` is set to `true` the following trace will be created: + +``` + | request /graphql | + | router /graphql | + | supergraph MyQuery | + | query_planning MyQuery | execution | + | fetch fetch | + | subgraph my-subgraph-name | + | subgraph_request MyQuery__my-subgraph-name__0 | +``` + + +### `fixed_span_names` (default: `true`) + +When `fixed_span_names: true`, the apollo router to use the original span names instead of the dynamic ones as described by OTel semantic conventions. + +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + datadog: + enabled: true + fixed_span_names: true +``` + +This will allow you to have a finite list of operation names in Datadog on the APM view. + +### `resource_mapping` +When set, `resource_mapping` allows you to specify which attribute to use in the Datadog APM and Trace view. +The default resource mappings are: + +| OpenTelemetry Span Name | Datadog Span Operation Name | +|-------------------------|-----------------------------| +| `request` | `http.route` | +| `router` | `http.route` | +| `supergraph` | `graphql.operation.name` | +| `query_planning` | `graphql.operation.name` | +| `subgraph` | `subgraph.name` | +| `subgraph_request` | `graphql.operation.name` | +| `http_request` | `http.route` | + +You may override these mappings by specifying the `resource_mapping` configuration: + +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + datadog: + enabled: true + resource_mapping: + # Use `my.span.attribute` as the resource name for the `router` span + router: "my.span.attribute" + instrumentation: + spans: + router: + attributes: + # Add a custom attribute to the `router` span + my.span.attribute: + request_header: x-custom-header +``` +If you have introduced a new span in a custom build of the Router you can enable resource mapping for it by adding it to the `resource_mapping` configuration. + +### `span_metrics` +When set, `span_metrics` allows you to specify which spans will show span metrics in the Datadog APM and Trace view. +By default, span metrics are enabled for: + +* `request` +* `router` +* `supergraph` +* `subgraph` +* `subgraph_request` +* `http_request` +* `query_planning` +* `execution` +* `query_parsing` + +You may override these defaults by specifying `span_metrics` configuration: + +The following will disable span metrics for the supergraph span. +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + datadog: + enabled: true + span_metrics: + # Disable span metrics for supergraph + supergraph: false + # Enable span metrics for my_custom_span + my_custom_span: true +``` + +If you have introduced a new span in a custom build of the Router you can enable span metrics for it by adding it to the `span_metrics` configuration. + +### `batch_processor` + + + +```yaml +telemetry: + exporters: + tracing: + datadog: + batch_processor: + max_export_batch_size: 512 + max_concurrent_exports: 1 + max_export_timeout: 30s + max_queue_size: 2048 + scheduled_delay: 5s +``` + +#### `batch_processor` configuration reference + + + +## Datadog native configuration reference + +| Attribute | Default | Description | +|-----------------------|-------------------------------------|-----------------------------------------| +| `enabled` | `false` | Enable the OTLP exporter. | +| `enable_span_mapping` | `false` | If span mapping should be used. | +| `endpoint` | `http://localhost:8126/v0.4/traces` | The endpoint to send spans to. | +| `batch_processor` | | The batch processor settings. | +| `resource_mapping` | See [config](#resource_mapping) | A map of span names to attribute names. | +| `span_metrics` | See [config](#span_metrics) | A map of span names to boolean. | + +## Sampler configuration + +When using Datadog to gain insight into your router's performance, you need to decide whether to use the Datadog APM view or rely on OTLP metrics. +The Datadog APM view is driven by traces. In order for this view to be accurate, all requests must be sampled and sent to the Datadog Agent. + +Tracing is expensive both in terms of APM costs and router performance, so you typically will want to set the `sampler` to sample at low rates in production environments. +This, however, impacts the APM view, which will show only a small percentage of traces. + +To mitigate this, you can use Datadog Agent sampling mode, where _all_ traces are sent to the Datadog Agent but only a percentage of them are forwarded to Datadog. This keeps the APM view accurate while lowering costs. Note that the router will incur a performance cost of having an effective sample rate of 100%. + +Use the following guidelines on how to configure the `sampler` and `preview_datadog_agent_sampling` to get the desired behavior: + +**I want the APM view to show metrics for 100% of traffic, and I am OK with the performance impact on the router.** + +Set `preview_datadog_agent_sampling` to `true` and adjust the `sampler` to the desired percentage of traces to be sent to Datadog. + +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + common: + # All requests will be traced and sent to the Datadog agent. + # Only 10 percent of spans will be forwarded from the Datadog agent to Datadog. + preview_datadog_agent_sampling: true + sampler: 0.1 +``` + +**I want the Datadog Agent to be in control of the percentage of traces sent to Datadog.** + +Use the Datadog Agent's `probabilistic_sampling` option sampler and set the `sampler` to `always_on` to allow the agent to control the sampling rate. + +Router config: +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + common: + # All requests will be traced and sent to the Datadog agent. + sampler: always_on +``` + +Datadog agent config: +```yaml +otlp_config: + traces: + probabilistic_sampling: + # Only 10 percent of spans will be forwarded to Datadog + sampling_percentage: 10 +``` + +**I want the best performance from the router and I'm not concerned with the APM view. I use metrics and traces to monitor my application.** + +Set the `sample` to a low value to reduce the number of traces sent to Datadog. Leave `preview_datadog_agent_sampling` to `false`. + +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + common: + # Only 10 percent of requests will be traced and sent to the Datadog agent. The APM view will only show a subset of total request data but the Router will perform better. + sampler: 0.1 + preview_datadog_agent_sampling: false +``` + +### `sampler` (default: `always_on`) + +The `sampler` configuration allows you to control the sampling decisions that the router will make on its own and decrease the rate at which you sample, which can have a direct impact on your Datadog bill. + +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + common: + # Only 10 percent of spans will be forwarded to the Datadog agent. Experiment to find a value that is good for you! + sampler: 0.1 +``` + +If you are using the Datadog APM viw then you should set `preview_datadog_agent_sampling` to `true` and adjust the `sampler` to the desired percentage of traces to be sent to Datadog. + +### `preview_datadog_agent_sampling` (default: `false`) + +The Datadog APM view relies on traces to generate metrics. For this to be accurate 100% of requests must be sampled and sent to the Datadog agent. +To prevent ALL traces from then being sent to Datadog, you must set `preview_datadog_agent_sampling` to `true` and adjust the `sampler` to the desired percentage of traces to be sent to Datadog. + +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + common: + # Only 10 percent of spans will be forwarded from the Datadog agent to Datadog. Experiment to find a value that is good for you! + preview_datadog_agent_sampling: true + sampler: 0.1 +``` + +Using `preview_datadog_agent_sampling` will send _all_ spans to the Datadog Agent, but only the percentage of traces configured by the `sampler` will be forwarded to Datadog. This means that your APM view will be accurate, but it will incur performance and resource usage costs for both the router and Datadog Agent to send and receive all spans. + +If your use case allows your APM view to show only a subset of traces, then you can set `preview_datadog_agent_sampling` to `false`. You should alternatively rely on OTLP metrics to gain insight into the router's performance. + + + +- The router doesn't support [`in-agent` ingestion control](https://docs.datadoghq.com/tracing/trace_pipeline/ingestion_mechanisms/?tab=java#in-the-agent). + +- Configuring `traces_per_second` in the Datadog Agent will not dynamically adjust the router's sampling rate to meet the target rate. + + diff --git a/docs/source/reference/router/telemetry/trace-exporters/dynatrace.mdx b/docs/source/routing/observability/telemetry/trace-exporters/dynatrace.mdx similarity index 77% rename from docs/source/reference/router/telemetry/trace-exporters/dynatrace.mdx rename to docs/source/routing/observability/telemetry/trace-exporters/dynatrace.mdx index db736561d4..0cc9cedfea 100644 --- a/docs/source/reference/router/telemetry/trace-exporters/dynatrace.mdx +++ b/docs/source/routing/observability/telemetry/trace-exporters/dynatrace.mdx @@ -1,10 +1,12 @@ --- -title: Dynatrace exporter (via OTLP) -subtitle: Configure the Dynatrace exporter for tracing -description: Configure the Dynatrace exporter for tracing via OpenTelemetry Protocol (OTLP) in the Apollo Router. +title: Dynatrace configuration of OTLP exporter +subtitle: Configure the OTLP trace exporter for Dynatrace +description: Configure the OpenTelemetry Protocol (OTLP) trace exporter for Dynatrace in the Apollo GraphOS Router or Apollo Router Core. +context: + - telemetry --- -Enable and configure the [OTLP exporter](/router/configuration/telemetry/exporters/tracing/otlp) for tracing in the Apollo Router for use with [Dynatrace](https://dynatrace.com/). +This tracing exporter is a configuration of the [OTLP exporter](/graphos/routing/observability/telemetry/trace-exporters/otlp) to use with [Dynatrace](https://dynatrace.com/). For general tracing configuration, refer to [Router Tracing Configuration](/router/configuration/telemetry/exporters/tracing/overview). diff --git a/docs/source/routing/observability/telemetry/trace-exporters/jaeger.mdx b/docs/source/routing/observability/telemetry/trace-exporters/jaeger.mdx new file mode 100644 index 0000000000..014b2a8f62 --- /dev/null +++ b/docs/source/routing/observability/telemetry/trace-exporters/jaeger.mdx @@ -0,0 +1,43 @@ +--- +title: Jaeger configuration of OTLP exporter +subtitle: Configure the OTLP trace exporter for Jaeger +description: Configure the OpenTelemetry Protocol (OTLP) trace exporter for Jaeger in the Apollo GraphOS Router or Apollo Router Core. +context: + - telemetry +--- + +This tracing exporter is a configuration of the [OTLP exporter](/graphos/routing/observability/telemetry/trace-exporters/otlp) to use with [Jaeger](https://www.jaegertracing.io/). + +For general tracing configuration, refer to [Router Tracing Configuration](/router/configuration/telemetry/exporters/tracing/overview). + +## Jaeger OTLP configuration + +Since Jaeger v1.35.0, [Jaeger supports native OTLP ingestion](https://medium.com/jaegertracing/introducing-native-support-for-opentelemetry-in-jaeger-eb661be8183c), and it's the recommended way to send traces to Jaeger. + +When running Jaeger with Docker, make sure that port **4317** is exposed and that `COLLECTOR_OTLP_ENABLED` is set to `true`. For example: + +```bash +docker run --name jaeger \ + -e COLLECTOR_OTLP_ENABLED=true \ + -p 16686:16686 \ + -p 4317:4317 \ + -p 4318:4318 \ + jaegertracing/all-in-one:1.35 +``` + +To configure the router to send traces via OTLP, set the Jaeger endpoint with port 4317. For example: + +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + propagation: + # Important! You must enable Jaeger propagation to use allow use of Jaeger headers for traceIDs. + jaeger: true + otlp: + enabled: true + # Optional endpoint, either 'default' or a URL (Defaults to http://127.0.0.1:4317) + endpoint: "http://${env.JAEGER_HOST}:4317" +``` + +See [OTLP configuration](/router/configuration/telemetry/exporters/tracing/otlp#configuration) for more details on settings. diff --git a/docs/source/reference/router/telemetry/trace-exporters/new-relic.mdx b/docs/source/routing/observability/telemetry/trace-exporters/new-relic.mdx similarity index 64% rename from docs/source/reference/router/telemetry/trace-exporters/new-relic.mdx rename to docs/source/routing/observability/telemetry/trace-exporters/new-relic.mdx index 6afde09486..346e363ab1 100644 --- a/docs/source/reference/router/telemetry/trace-exporters/new-relic.mdx +++ b/docs/source/routing/observability/telemetry/trace-exporters/new-relic.mdx @@ -1,10 +1,12 @@ --- -title: New Relic exporter (via OTLP) -subtitle: Configure the New Relic exporter for tracing -description: Configure the New Relic exporter for tracing via OpenTelemetry Protocol (OTLP) in the Apollo GraphOS Router or Apollo Router Core. +title: New Relic configuration of OTLP exporter +subtitle: Configure the OTLP trace exporter for New Relic +description: Configure the OpenTelemetry Protocol (OTLP) trace exporter for New Relic in the Apollo GraphOS Router or Apollo Router Core. +context: + - telemetry --- -Enable and configure the [OTLP exporter](/router/configuration/telemetry/exporters/tracing/otlp) for tracing in the GraphOS Router or Apollo Router Core for use with [New Relic](https://newrelic.com/). +This tracing exporter is a configuration of the [OTLP exporter](/graphos/routing/observability/telemetry/trace-exporters/otlp) to use with [New Relic](https://newrelic.com/). For general tracing configuration, refer to [Router Tracing Configuration](/router/configuration/telemetry/exporters/tracing/overview). @@ -23,8 +25,7 @@ telemetry: protocol: grpc grpc: metadata: - "api-key": - - + api-key: ``` diff --git a/docs/source/reference/router/telemetry/trace-exporters/otlp.mdx b/docs/source/routing/observability/telemetry/trace-exporters/otlp.mdx similarity index 99% rename from docs/source/reference/router/telemetry/trace-exporters/otlp.mdx rename to docs/source/routing/observability/telemetry/trace-exporters/otlp.mdx index fa7ff3a39e..432ac0ddaa 100644 --- a/docs/source/reference/router/telemetry/trace-exporters/otlp.mdx +++ b/docs/source/routing/observability/telemetry/trace-exporters/otlp.mdx @@ -2,6 +2,8 @@ title: OpenTelemetry Protocol (OTLP) exporter subtitle: Configure the OpenTelemetry Protocol exporter for tracing description: Configure the OpenTelemetry Protocol (OTLP) exporter for tracing in the Apollo GraphOS Router or Apollo Router Core. +context: + - telemetry --- import BatchProcessorPreamble from '../../../../../shared/batch-processor-preamble.mdx'; import BatchProcessorRef from '../../../../../shared/batch-processor-ref.mdx'; diff --git a/docs/source/reference/router/telemetry/trace-exporters/overview.mdx b/docs/source/routing/observability/telemetry/trace-exporters/overview.mdx similarity index 78% rename from docs/source/reference/router/telemetry/trace-exporters/overview.mdx rename to docs/source/routing/observability/telemetry/trace-exporters/overview.mdx index ecbabb800d..02172cfa3b 100644 --- a/docs/source/reference/router/telemetry/trace-exporters/overview.mdx +++ b/docs/source/routing/observability/telemetry/trace-exporters/overview.mdx @@ -2,15 +2,18 @@ title: Router Tracing subtitle: Collect tracing information from the router description: Collect and export tracing information from the Apollo GraphOS Router or Apollo Router Core. Supports OpenTelemetry Protocol (OTLP), Datadog, New Relic, Jaeger, Zipkin. +context: + - telemetry --- -The GraphOS Router and Apollo Router Core support collection of traces with [OpenTelemetry](https://opentelemetry.io/), with exporters for: +Apollo Router supports a collection of tracing exporters: -* [Jaeger](/router/configuration/telemetry/exporters/tracing/jaeger) -* [Zipkin](/router/configuration/telemetry/exporters/tracing/zipkin) -* [Datadog](/router/configuration/telemetry/exporters/tracing/datadog) -* [New Relic](/router/configuration/telemetry/exporters/tracing/new-relic) * [OpenTelemetry Protocol (OTLP)](/router/configuration/telemetry/exporters/tracing/otlp) over HTTP or gRPC +* [Datadog (via OTLP configuration)](/router/configuration/telemetry/exporters/tracing/datadog) +* [Dynatrace (via OTLP configuration)](/router/configuration/telemetry/exporters/tracing/dynatrace) +* [Jaeger (via OTLP configuration)](/router/configuration/telemetry/exporters/tracing/jaeger) +* [New Relic (via OTLP configuration)](/router/configuration/telemetry/exporters/tracing/new-relic) +* [Zipkin](/router/configuration/telemetry/exporters/tracing/zipkin). The router generates [**spans**](/router/configuration/telemetry/instrumentation/spans) that include the various phases of serving a request and associated dependencies. This is useful for showing how response time is affected by: @@ -88,8 +91,8 @@ telemetry: tracing: common: resource: - "environment.name": "production" - "environment.namespace": "{env.MY_K8_NAMESPACE_ENV_VARIABLE}" + "deployment.environment.name": "production" + "k8s.namespace.name": "{env.MY_K8_NAMESPACE_ENV_VARIABLE}" ``` For OpenTelemetry conventions for resources, see [Resource Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/README.md). @@ -107,10 +110,38 @@ telemetry: parent_based_sampler: true # (default) If an incoming span has OpenTelemetry headers then the request will always be sampled. ``` -- `sampler` sets the sampling rate as a decimal percentage, `always_on`, or `always_off`. For example, setting `sampler: 0.1` samples 10% of your requests. +- `sampler` sets the sampling rate as a decimal percentage, `always_on`, or `always_off`. + - For example, setting `sampler: 0.1` samples 10% of your requests. + - `always_on` (the default) sends all spans to your APM. + - `always_off` turns off sampling. No spans reach your APM. - `parent_based_sampler` enables clients to make the sampling decision. This guarantees that a trace that starts at a client will also have spans at the router. You may wish to disable it (setting `parent_based_sampler: false`) if your router is exposed directly to the internet. +### `preview_datadog_agent_sampling` + +
+ + +
+ +Enable accurate Datadog APM views with the `preview_datadog_agent_sampling` option. + +The Datadog APM view relies on traces to generate metrics. For this to be accurate, all requests must be sampled and sent to the Datadog Agent. + +To both enable accurate APM views and prevent _all_ traces from being sent to Datadog, you must set `preview_datadog_agent_sampling` to `true` and adjust the `sampler` to the desired percentage of traces to be sent to Datadog. + +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + common: + # Only 10 percent of spans will be forwarded from the Datadog agent to Datadog. Experiment to find a value that is good for you! + sampler: 0.1 + preview_datadog_agent_sampling: true +``` + +To learn more details and limitations about this option, go to [`preview_datadog_agent_sampling`](/router/configuration/telemetry/exporters/tracing/datadog#preview_datadog_agent_sampling) in DataDog trace exporter docs. + ### `propagation` The `telemetry.exporters.tracing.propagation` section allows you to configure which propagators are active in addition to those automatically activated by using an exporter. @@ -210,7 +241,7 @@ Spans may link to other spans in the same or different trace. For example, a spa ### `experimental_response_trace_id` - + If you want to expose in response headers the generated trace ID or the one you provided using propagation headers you can use this configuration: @@ -235,17 +266,21 @@ Using this configuration you will have a response header called `my-trace-id` co ## Tracing common reference -| Attribute | Default | Description | -|----------------------------------|--------------------------|-------------------------------------------------| -| `service_name` | `unknown_service:router` | The OpenTelemetry service name. | -| `service_namespace` | | The OpenTelemetry namespace. | -| `resource` | | The OpenTelemetry resource to attach to traces. | -| `experimental_response_trace_id` | | Return the trace ID in a response header. | -| `max_attributes_per_event` | 128 | The maximum number of attributes per event. | -| `max_attributes_per_link` | 128 | The maximum number of attributes per link. | -| `max_attributes_per_span` | 128 | The maximum number of attributes per span. | -| `max_events_per_span` | 128 | The maximum number of events per span. | -| `max_links_per_span` | 128 | The maximum links per span. | +| Attribute | Default | Description | +|----------------------------------|--------------------------|--------------------------------------------------| +| `parent_based_sampler` | `true` | Sampling decisions from upstream will be honored | +| `preview_datadog_agent_sampling` | `false` | Send all spans to the Datadog agent. | +| `propagation` | | The propagation configuration. | +| `sampler` | `always_on` | The sampling rate for traces. | +| `service_name` | `unknown_service:router` | The OpenTelemetry service name. | +| `service_namespace` | | The OpenTelemetry namespace. | +| `resource` | | The OpenTelemetry resource to attach to traces. | +| `experimental_response_trace_id` | | Return the trace ID in a response header. | +| `max_attributes_per_event` | 128 | The maximum number of attributes per event. | +| `max_attributes_per_link` | 128 | The maximum number of attributes per link. | +| `max_attributes_per_span` | 128 | The maximum number of attributes per span. | +| `max_events_per_span` | 128 | The maximum number of events per span. | +| `max_links_per_span` | 128 | The maximum links per span. | ## Related topics diff --git a/docs/source/reference/router/telemetry/trace-exporters/zipkin.mdx b/docs/source/routing/observability/telemetry/trace-exporters/zipkin.mdx similarity index 98% rename from docs/source/reference/router/telemetry/trace-exporters/zipkin.mdx rename to docs/source/routing/observability/telemetry/trace-exporters/zipkin.mdx index b4a732999e..1af24bb6a8 100644 --- a/docs/source/reference/router/telemetry/trace-exporters/zipkin.mdx +++ b/docs/source/routing/observability/telemetry/trace-exporters/zipkin.mdx @@ -2,6 +2,8 @@ title: Zipkin exporter subtitle: Configure the Zipkin exporter for tracing description: Enable and configure the Zipkin exporter for tracing in the Apollo GraphOS Router or Apollo Router Core. +context: + - telemetry --- import BatchProcessorPreamble from '../../../../../shared/batch-processor-preamble.mdx'; import BatchProcessorRef from '../../../../../shared/batch-processor-ref.mdx'; diff --git a/docs/source/routing/operations/defer.mdx b/docs/source/routing/operations/defer.mdx index 7a21d412bb..464da5291b 100644 --- a/docs/source/routing/operations/defer.mdx +++ b/docs/source/routing/operations/defer.mdx @@ -1,19 +1,17 @@ --- -title: Router Support for @defer +title: "@defer Directive Support" subtitle: Improve performance by delivering fields incrementally description: Improve your GraphQL query performance with GraphOS Router and Apollo Router Core's support for the @defer directive. Incrementally deliver response data by deferring certain fields. -minVersion: 1.8.0 +minVersion: Router v1.8.0 --- -Queries sent to GraphOS Router or Apollo Router Core can use the `@defer` directive to enable the incremental delivery of response data. By deferring data for some fields, the router can resolve and return data for the query's _other_ fields more quickly, improving responsiveness. +Apollo Router's support of the `@defer` directive helps client developers tackle the common challenge of how to create a responsive user experience when certain fields of a query take longer to resolve than others. `@defer` improves application performance by delivering response data incrementally. This is important for real-time applications like dashboards or reports. By deferring data for some fields, the router can resolve and return data for the query's other fields more quickly, improving responsiveness. -The router's `@defer` support is compatible with all [federation-compatible subgraph libraries](/federation/building-supergraphs/supported-subgraphs/). That's because the router takes advantage of your supergraph's existing [entities](/federation/entities/) to fetch any deferred field data via followup queries to your subgraphs. - -## What is `@defer`? +## What is the `@defer` directive? The `@defer` directive enables a client query to specify sets of fields that it doesn't need to receive data for _immediately_. This is helpful whenever some fields in a query take much longer to resolve than others. -Deferred fields are always contained within a GraphQL fragment, and the `@defer` directive is applied to that fragment (_not_ to the individual fields). +The `@defer` directive is applied to a GraphQL query. Specifically, deferred fields are always contained within a GraphQL fragment, and the `@defer` directive is applied to that fragment. Here's an example query that uses `@defer`: @@ -33,7 +31,8 @@ query GetTopProducts { To respond incrementally, the router uses a multipart-encoded HTTP response. To use `@defer` successfully with the router, a client's GraphQL library must _also_ support the directive by handling multipart HTTP responses correctly. -The router's `@defer` support is compatible with all [federation-compatible subgraph libraries](/federation/building-supergraphs/supported-subgraphs/), because the deferring logic exists entirely within the router itself. +The router's `@defer` support is compatible with all [federation-compatible subgraph libraries](/federation/building-supergraphs/supported-subgraphs/). That's because the router takes advantage of your supergraph's existing [entities](/federation/entities/) to fetch any deferred field data via followup queries to your subgraphs. + ## Which fields can my router defer? @@ -143,7 +142,7 @@ To use `@defer` successfully, your supergraph and its clients must meet the requ - This functionality is currently supported in Apollo Client for [Web](/react/data/defer) and [Kotlin (experimental)](/kotlin/fetching/defer). - Your supergraph must be one of: - A [cloud supergraph](/graphos/routing/cloud#cloud-supergraphs) - - A [self-hosted supergraph](/graphos/routing/self-hosted#self-hosted-supergraphs) running the [GraphOS Router](/graphos/routing/about-router) + - A [self-hosted supergraph](/graphos/routing/self-hosted#self-hosted-supergraphs) running the GraphOS Router ### Entity-specific requirements diff --git a/docs/source/routing/operations/file-upload.mdx b/docs/source/routing/operations/file-upload.mdx index 9170a22a1e..03e3f5bcb1 100644 --- a/docs/source/routing/operations/file-upload.mdx +++ b/docs/source/routing/operations/file-upload.mdx @@ -2,8 +2,9 @@ title: File uploads subtitle: Receive files uploaded by clients with the GraphOS Router description: Configure the GraphOS Router to receive file uploads using the GraphQL multipart request spec. -minVersion: 1.41.1 +minVersion: Router v1.41.1 noIndex: true +releaseStage: preview --- @@ -277,7 +278,7 @@ Each part of the request payload is separated by a boundary string (`gc0p4Jq0M2Y Refer to the docs for your client library for further instructions. - [Apollo Client (web)](/react/data/file-uploads/) -- [Apollo iOS](/ios/file-uploads/) +- [Apollo iOS](/ios/advanced/file-uploads/) - [Apollo Kotlin](/kotlin/advanced/upload/) Custom clients can be implemented following the [spec documentation](https://github.com/jaydenseric/graphql-multipart-request-spec). diff --git a/docs/source/routing/operations/index.mdx b/docs/source/routing/operations/index.mdx deleted file mode 100644 index 96f2310f06..0000000000 --- a/docs/source/routing/operations/index.mdx +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Real-Time Operations -subtitle: Configure the router for real-time operations -description: Configure the Apollo GraphOS Router to support real-time operations, including subscriptions, defer directive, and file uploads. ---- - -Responsive applications rely on the router to handle operations in real time. You can configure to support various real-time operations: - -- [**Subscriptions**](/graphos/routing/operations/subscriptions) - support GraphQL subscription operations -- [**Defer**](/graphos/routing/operations/defer) - use the `@defer` directive to enable incremental delivery of response data -- [**File Uploads**](/graphos/routing/operations/file-upload) - upload files to the router using multipart HTTP requests diff --git a/docs/source/routing/operations/subscriptions/api-gateway.mdx b/docs/source/routing/operations/subscriptions/api-gateway.mdx index 6c49d88679..22ed87c988 100644 --- a/docs/source/routing/operations/subscriptions/api-gateway.mdx +++ b/docs/source/routing/operations/subscriptions/api-gateway.mdx @@ -15,8 +15,6 @@ Organizations can require their APIs—including [the router](/graphos/routing/r The rest of this article describes how to configure API gateways from different vendors to stream and not buffer HTTP responses, therefore supporting subscriptions over HTTP multipart. - - ## Azure API Management (APIM) By default, an HTTP API endpoint in APIM buffers each response from a downstream service, where the endpoint must receive all chunks of an HTTP response before it sends the response to the client. @@ -95,6 +93,4 @@ Streaming of HTTP multipart is supported out of the box with no additional confi ## AWS API Gateway -AWS API Gateway doesn't support streaming of HTTP data. - -A possible workaround is to use a Lambda expression which does support streaming. To learn more, see [AWS Lambda response streaming](https://aws.amazon.com/blogs/compute/introducing-aws-lambda-response-streaming/). +AWS API Gateway currently doesn't support streaming of HTTP data. diff --git a/docs/source/routing/operations/subscriptions/index.mdx b/docs/source/routing/operations/subscriptions/configuration.mdx similarity index 88% rename from docs/source/routing/operations/subscriptions/index.mdx rename to docs/source/routing/operations/subscriptions/configuration.mdx index 75cebe5e2d..d1dc6778d9 100644 --- a/docs/source/routing/operations/subscriptions/index.mdx +++ b/docs/source/routing/operations/subscriptions/configuration.mdx @@ -2,59 +2,23 @@ title: Configure GraphQL Subscription Support subtitle: Enable clients to receive real-time updates description: Configure your router to support GraphQL subscriptions, enabling clients to receive real-time updates via WebSocket or HTTP callbacks. -minVersion: 1.22.0 +minVersion: Router v1.22.0 --- - + -**For self-hosted routers, subscription support is an [Enterprise feature](/router/enterprise-features/).** +Rate limits apply on the Free plan. +Subscription pricing applies on Developer and Standard plans. -Subscription support is also available for cloud routers with a GraphOS Serverless or Dedicated plan. For cloud router subscription information, refer to [the cloud-specific docs](/graphos/operations/subscriptions). + - - -GraphOS routers provides support for GraphQL subscription operations: - -```graphql -subscription OnStockPricesChanged { - stockPricesChanged { - symbol - price - } -} -``` - -With subscription support enabled, you can add `Subscription` fields to the schema of any subgraph that supports common WebSocket protocols for subscription communication: - -```graphql title="stocks.graphql" -type Subscription { - stockPricesChanged: [Stock!]! -} -``` - -## What are subscriptions for? - - - -## How subscriptions work - - - - - -[Walk through an example.](#example-execution) - - - -### Special considerations - -Whenever your router updates its supergraph schema at runtime, it terminates all active subscriptions. Clients can detect this special-case termination via an error code and execute a new subscription. See [Termination on schema update](#termination-on-schema-update). +Learn how to configure the router to enable GraphQL subscriptions. ## Prerequisites Before you add `Subscription` fields to your subgraphs, do all of the following in the order shown to prevent schema composition errors: -1. Update your router instances to version `1.22.0` or later. [Download the latest version.](/router/quickstart/) +1. Update your router instances to version `1.22.0` or later. [Download the latest version.](/graphos/routing/get-started) - Previous versions of the router don't support subscription operations. 1. Make sure your router is [connected to a GraphOS Enterprise organization](/router/enterprise-features/#enabling-enterprise-features). - Subscription support is an Enterprise feature of self-hosted routers. @@ -84,7 +48,11 @@ After you complete these prerequisites, you can safely [configure your router](# After completing all [prerequisites](#prerequisites), in your router's [YAML config file](/router/configuration/overview/#yaml-config-file), you configure how the router communicates with each of your subgraphs when executing GraphQL subscriptions. -The router supports two popular [WebSocket protocols](#websocket-setup) for subscriptions, and it also provides support for an [HTTP-callback-based protocol](#http-callback-setup). Your router must use whichever protocol is expected by each subgraph. +When communicating with subgraphs, the router will use routing URLs defined in the supergraph or, if specified, the router YAML config. The router supports two popular [WebSocket protocols](#websocket-setup) for subscriptions, and it also provides support for an [HTTP-callback-based protocol](#http-callback-setup). Your router must use whichever protocol is expected by each subgraph. + + +Use `wss://` (WebSocket Secure) instead of `ws://` to encrypt data transmission between the router and subgraphs, providing similar security benefits as using `https://` over `http://`. + ### WebSocket setup @@ -345,7 +313,24 @@ For example, if thousands of clients all subscribe to real-time score updates fo The router considers subscription operations **identical** if all of the following are true: - The operations sent to the subgraph have identical GraphQL selection sets (i.e., requested fields). -- The operations provide identical values for all headers that the router sends to the subgraph. +- The operations provide identical values for all headers that the router sends to the subgraph, except those listed in `ignored_headers`. + +### Configuring deduplication + +You can ignore specific headers when deduplicating subscriptions. + +If you propagate a header with varying values to your subgraph (for example, `User-Agent`), but you still want to deduplicate subscriptions and you can accept that only one out of many values will be propagated, you can configure `ignored_headers` like this: + +```yaml title="router.yaml" +subscription: + enabled: true +# highlight-start + deduplication: + enabled: true # default: true + ignored_headers: + - user-agent # It won't include this header in the deduplication algorithm, so even if the value is different it will still deduplicate +# highlight-end +``` ### Disabling deduplication @@ -355,7 +340,8 @@ You can disable subscription deduplication by adding the following to your route subscription: enabled: true # highlight-start - enable_deduplication: false # default: true + deduplication: + enabled: false # default: true # highlight-end ``` @@ -375,14 +361,14 @@ Disabling deduplication is useful if you need to create a separate connection to ### Termination on schema update -Whenever your router's supergraph schema is updated, **the router terminates all active subscriptions.** +Whenever your router's supergraph schema or configuration is updated, **the router terminates all active subscriptions.** Clients can detect this special-case termination via an error code and establish a new subscription. -Your router's supergraph schema is updated in the following cases: +Subscriptions are terminated in the following cases: - Your router regularly polls GraphOS for its supergraph schema, and an updated schema becomes available. -- Your router obtains its supergraph schema from a local file, which it watches for updates if the [`--hot-reload` option](/router/configuration/overview#--hr----hot-reload) is set. +- Your router obtains its supergraph schema or configuration from a local file, which it watches for updates if the [`--hot-reload` option](/router/configuration/overview#--hr----hot-reload) is set. -When the router terminates subscriptions this way, it sends the following as a final response payload to all active subscribing clients: +The router then sends the following as a final response payload to all active subscribing clients: ```json { @@ -397,7 +383,7 @@ When the router terminates subscriptions this way, it sends the following as a f } ``` -A client that receives this `SUBSCRIPTION_SCHEMA_RELOAD` error code can reconnect by executing a new subscription operation. +A client that receives this `SUBSCRIPTION_SCHEMA_RELOAD` or `SUBSCRIPTION_CONFIG_RELOAD` error code can reconnect by executing a new subscription operation. ### WebSocket auth support diff --git a/docs/source/routing/operations/subscriptions/multipart-protocol.mdx b/docs/source/routing/operations/subscriptions/multipart-protocol.mdx index 53f51ec5a2..9cae39c397 100644 --- a/docs/source/routing/operations/subscriptions/multipart-protocol.mdx +++ b/docs/source/routing/operations/subscriptions/multipart-protocol.mdx @@ -83,3 +83,10 @@ With the exception of [heartbeats](#heartbeats), every response part body includ ``` Both types of `errors` follow the [GraphQL error format](http://spec.graphql.org/draft/#sec-Errors.Error-Result-Format), but top-level `errors` never include `locations` or `path`. + +## Additional resources + +Check out the [federated subscriptions course](https://www.apollographql.com/tutorials/federated-subscriptions-typescript) to explore an end-to-end implementation with Apollo Router, Apollo Server, and Typescript. +You can also see the Apollo Solutions [federated subscriptions repository](https://github.com/apollosolutions/router-extensibility-load-testing) for an example of federated subscriptions via an HTTP Multipart based subscription with the router in HTTP callback mode. + + diff --git a/docs/source/routing/operations/subscriptions/overview.mdx b/docs/source/routing/operations/subscriptions/overview.mdx new file mode 100644 index 0000000000..de88f29b17 --- /dev/null +++ b/docs/source/routing/operations/subscriptions/overview.mdx @@ -0,0 +1,46 @@ +--- +title: Overview of GraphQL Subscription Support +subtitle: Enable clients to receive real-time updates +description: Configure your router to support GraphQL subscriptions, enabling clients to receive real-time updates via WebSocket or HTTP callbacks. +minVersion: Router v1.22.0 +--- + + + +Rate limits apply on the Free plan. +Subscription pricing applies on Developer and Standard plans. + + + +GraphQL subscriptions enable clients to manage real-time data updates effectively, without the need for constant polling. This is particularly crucial in applications that need live updates, such as notifications, stock tickers, or chat applications. Using subscriptions improves performance and user experience by ensuring that clients receive the latest information as soon as changes occur on the backend. + +```graphql title="Example GraphQL subscription" showLineNumbers=false +subscription OnStockPricesChanged { + stockPricesChanged { + symbol + price + } +} +``` + +The router supports both the callback and multipart protocols for handling subscriptions. With subscription support enabled, you can add `Subscription` fields to the schema of any subgraph that supports common WebSocket protocols for subscription communication: + +```graphql title="stocks.graphql" showLineNumbers=false +type Subscription { + stockPricesChanged: [Stock!]! +} +``` + +## What are subscriptions for? + + + +## How subscriptions work + + + +## Next steps + +- [Configure](/graphos/routing/operations/subscriptions/configuration) the router and update your subgraph schemas to enable and handle subscriptions. + +- Learn about the [callback](/graphos/routing/operations/subscriptions/callback-protocol) and [multipart](/graphos/routing/operations/subscriptions/multipart-protocol) protocols. diff --git a/docs/source/routing/performance/caching/distributed.mdx b/docs/source/routing/performance/caching/distributed.mdx index 2af986512b..550654f84f 100644 --- a/docs/source/routing/performance/caching/distributed.mdx +++ b/docs/source/routing/performance/caching/distributed.mdx @@ -4,7 +4,12 @@ subtitle: Configure Redis-backed caching for query plans and APQ description: Distributed caching for GraphOS Router with GraphOS Enterprise. Configure a Redis-backed cache for query plans and automatic persisted queries (APQ). --- - + + +Rate limits apply on the Free plan. +Performance pricing applies on Developer and Standard plans. + + If you have multiple GraphOS Router instances, those instances can share a Redis-backed cache for their query plans and automatic persisted queries (APQ). This means that if _any_ of your router instances caches a particular value, _all_ of your instances can look up that value to significantly improve responsiveness. For more details on query plans and APQ, see the article on [in-memory caching](/router/configuration/in-memory-caching). @@ -19,7 +24,7 @@ To use this feature: Whenever a router instance requires a query plan or APQ query string to resolve a client operation: -1. The router instance checks its own [in-memory cache](#in-memory-caching) for the required value and uses it if found. +1. The router instance checks its own [in-memory cache](/router/configuration/in-memory-caching) for the required value and uses it if found. 2. If _not_ found, the router instance then checks the distributed Redis cache for the required value and uses it if found. It also then replicates the found value in its own in-memory cache. 3. If _not_ found, the router instance _generates_ the required query plan or requests the full operation string from the client for APQ. 4. The router instance stores the obtained value in both the distributed cache _and_ its in-memory cache. @@ -85,6 +90,13 @@ In your router's YAML config file, **you should specify your Redis URLs via envi
+ + +Cached query plans are not evicted on schema refresh, which can quickly lead to distributed cache overflow when combined with [cache-warm up](/router/configuration/in-memory-caching#cache-warm-up) and frequent schema publishes. +Test your cache configuration with expected queries and consider decreasing the [TTL](/router/configuration/distributed-caching#ttl) to prevent cache overflow. + + + ### Distributed query plan caching To enable distributed caching of query plans, add the following to your router's [YAML config file](/router/configuration/overview/#yaml-config-file): @@ -138,15 +150,27 @@ supergraph: pool_size: 4 # Optional, defaults to 1 ``` -#### Timeout +### Timeout Connecting and sending commands to Redis are subject to a timeout, set by default to 500ms, that can be overriden. -#### TTL +### TTL The `ttl` option defines the default global expiration for Redis entries. For APQ caching, the default is no expiration, while for query plan caching, the default expiration is set to 30 days. +When enabling distributed caching, consider how frequently you publish new schemas and configure the TTL accordingly. When new schemas are published, the router [pre-warms](/router/configuration/in-memory-caching#cache-warm-up) the in-memory and distributed caches but doesn't invalidate existing cached query plans in the distributed cache, +creating an additive effect on cache utilization. + +To prevent cache overflow, consider decreasing the TTL to 24 hours or twice the median publish interval (whichever's lesser), and monitor cache utilization in your environment, especially during schema publish events. + +Also note that when [cache warm-up](/router/configuration/in-memory-caching#cache-warm-up) is enabled, each router instance will warm the distributed cache with query plans from *its own in-memory cache*. In the worst case, a schema publish will increase the number of query plans in the distributed cache by the number +of router instances multiplied by the number of warmed-up queries per instance, which may noticeably increase the total cache utilization. + + +Be sure to test your configuration with expected queries and during schema publish events to understand the impact of distributed caching on cache utilization. + + ### Namespace When using the same Redis instance for multiple purposes, the `namespace` option defines a prefix for all the keys defined by the router. @@ -181,4 +205,4 @@ When this option is active, accessing a cache entry in Redis will reset its expi ### Pool size -The `pool_size` option defines the number of connections to Redis that the router will open. By default, the router will open a single connection to Redis. If there is a lot of traffic between router and Redis and/or there is some latency in thos requests, it is recommended to increase the pool size to reduce that latency. \ No newline at end of file +The `pool_size` option defines the number of connections to Redis that the router will open. By default, the router will open a single connection to Redis. If there is a lot of traffic between router and Redis and/or there is some latency in those requests, it is recommended to increase the pool size to reduce that latency. \ No newline at end of file diff --git a/docs/source/routing/performance/caching/entity.mdx b/docs/source/routing/performance/caching/entity.mdx index 76e9a616b1..d2a9a17c0a 100644 --- a/docs/source/routing/performance/caching/entity.mdx +++ b/docs/source/routing/performance/caching/entity.mdx @@ -2,12 +2,16 @@ title: Subgraph Entity Caching for the GraphOS Router subtitle: Configure Redis-backed caching for entities description: Subgraph entity caching for GraphOS Router with GraphOS Enterprise. Cache and reuse individual entities across queries. -minVersion: 1.40.0 +minVersion: Router v1.40.0 +releaseStage: preview --- - + - +Rate limits apply on the Free plan. +Performance pricing applies on Developer and Standard plans. + + Learn how the GraphOS Router can cache subgraph query responses using Redis to improve your query latency for entities in the supergraph. @@ -162,6 +166,7 @@ For example: # Enable entity caching globally preview_entity_cache: enabled: true + expose_keys_in_context: true # Optional, it will expose cache keys in the context in order to use it in coprocessors or Rhai subgraph: all: enabled: true @@ -212,19 +217,18 @@ This entry contains an object with the `all` field to affect all subgraph reques ### Entity cache invalidation -You can invalidate entity cache entries with a [specifically formatted request](#invalidation-request-format once you [configure your router](#configuration) appropriately. For example, if price data changes before a price entity's TTL expires, you can send an invalidation request. +You can invalidate entity cache entries with a [specifically formatted request](#invalidation-request-format) once you [configure your router](#configuration) appropriately. For example, if price data changes before a price entity's TTL expires, you can send an invalidation request. ```mermaid - flowchart RL subgraph QueryResponse["Cache invalidation POST"] n1["{ -   "kind": "subgraph", -   "subgraph": "price", -   "type": "Price", -   "key": { -     "id": "101" -   } +     "kind": "subgraph", +     "subgraph": "price", +     "type": "Price", +     "key": { +         "id": "101" +     } }"] end @@ -235,18 +239,18 @@ flowchart RL end subgraph PriceQueryFragment["Price Query Fragment (e.g. TTL 2200)"] - n2[" ̶{̶ -   " ̶p̶r̶i̶c̶e̶": ̶{̶ -     " ̶i̶d̶": ̶1̶0̶1̶, -     " ̶p̶r̶o̶d̶u̶c̶t̶_̶i̶d̶": ̶1̶2̶, -     " ̶a̶m̶o̶u̶n̶t̶": ̶1̶5̶0̶0̶, -     "̶c̶u̶r̶r̶e̶n̶c̶y̶_̶c̶o̶d̶e̶": " ̶U̶S̶D̶" -    ̶}̶ - ̶}̶"] + n2["{ +     "price": { +         "id": 101, +         "product_id": 12, +         "amount": 1500, +         "currency_code": "USD" +     } + }"] end Router - Database[("   ")] + Database[("    ")] QueryResponse --> Router Purchases --> Router @@ -295,6 +299,7 @@ preview_entity_cache: redis: urls: ["redis://..."] invalidation: + enabled: true # base64 string that will be provided in the `Authorization: Basic` header value shared_key: "agm3ipv7egb78dmxzv0gr5q0t5l6qs37" subgraphs: @@ -340,7 +345,7 @@ Invalidation requests are defined as JSON objects with the following format: ```json { - "kind": "subgraph", + "kind": "type", "subgraph": "accounts", "type": "User" } @@ -350,7 +355,7 @@ Invalidation requests are defined as JSON objects with the following format: ```json { - "kind": "subgraph", + "kind": "entity", "subgraph": "accounts", "type": "User", "key": { diff --git a/docs/source/routing/performance/caching/in-memory.mdx b/docs/source/routing/performance/caching/in-memory.mdx index fbadf23a4d..4c632f3cd0 100644 --- a/docs/source/routing/performance/caching/in-memory.mdx +++ b/docs/source/routing/performance/caching/in-memory.mdx @@ -40,12 +40,19 @@ supergraph: ### Cache warm-up -When loading a new schema, a query plan might change for some queries, so cached query plans cannot be reused. +When loading a new schema, a query plan might change for some queries, so cached query plans cannot be reused. To prevent increased latency upon query plan cache invalidation, the router precomputes query plans for the most used queries from the cache when a new schema is loaded. Precomputed plans will be cached before the router switches traffic over to the new schema. + + +You can also send the header `Apollo-Expose-Query-Plan: dry-run` for [generating query plans at runtime](https://www.apollographql.com/docs/graphos/reference/federation/query-plans#outputting-query-plans-with-headers) which can be used to warm up your cache instances with a custom defined operation list. + + + + By default, the router warms up the cache with 30% of the queries already in cache, but it can be configured as follows: ```yaml title="router.yaml" @@ -60,38 +67,38 @@ supergraph: To get more information on the planning and warm-up process use the following metrics (where `` can be `redis` for distributed cache or `memory`): * counters: - * `apollo_router_cache_hit_count{kind="query planner", storage=""}` - * `apollo_router_cache_miss_count{kind="query planner", storage=""}` + * `apollo.router.cache.hit.time.count{kind="query planner", storage=""}` + * `apollo.router.cache.miss.time.count{kind="query planner", storage=""}` * histograms: * `apollo.router.query_planning.plan.duration`: time spent planning queries - * `apollo_router_schema_loading_time`: time spent loading a schema - * `apollo_router_cache_hit_time{kind="query planner", storage=""}`: time to get a value from the cache - * `apollo_router_cache_miss_time{kind="query planner", storage=""}` + * `planner`: The query planner implementation used (`rust` or `js`) + * `outcome`: The outcome of the query planning process (`success`, `timeout`, `cancelled`, `error`) + * `apollo.router.schema.load.duration`: time spent loading a schema + * `apollo.router.cache.hit.time{kind="query planner", storage=""}`: time to get a value from the cache + * `apollo.router.cache.miss.time{kind="query planner", storage=""}` * gauges - * `apollo_router_cache_size{kind="query planner", storage="memory"}`: current size of the cache (only for in-memory cache) + * `apollo.router.cache.size{kind="query planner", storage="memory"}`: current size of the cache (only for in-memory cache) * `apollo.router.cache.storage.estimated_size{kind="query planner", storage="memory"}`: estimated storage size of the cache (only for in-memory query planner cache) -Typically, we would look at `apollo_router_cache_size` and the cache hit rate to define the right size of the in memory cache, -then look at `apollo_router_schema_loading_time` and `apollo.router.query_planning.plan.duration` to decide how much time we want to spend warming up queries. +Typically, we would look at `apollo.router.cache.size` and the cache hit rate to define the right size of the in memory cache, +then look at `apollo.router.schema.load.duration` and `apollo.router.query_planning.plan.duration` to decide how much time we want to spend warming up queries. #### Cache warm-up with distributed caching If the router is using distributed caching for query plans, the warm-up phase will also store the new query plans in Redis. Since all Router instances might have the same distributions of queries in their in-memory cache, the list of queries is shuffled before warm-up, so each Router instance can plan queries in a different order and share their results through the cache. -#### Schema aware query hashing +#### Cache warm-up with headers -The query plan cache key uses a hashing algorithm specifically designed for GraphQL queries, using the schema. If a schema update does not affect a query (example: a field was added), then the query hash will stay the same. The query plan cache can use that key during warm up to check if a cached entry can be reused instead of planning it again. + -It can be activated through this option: +With router v1.61.0+ and v2.x+, if you have enabled exposing query plans via `--dev` mode or `plugins.experimental.expose_query_plan: true`, you can pass the `Apollo-Expose-Query-Plan` header to return query plans in the GraphQL response extensions. You must set the header to one of the following values: -```yaml title="router.yaml" -supergraph: - query_planning: - warmed_up_queries: 100 - experimental_reuse_query_plans: true -``` +- `true`: Returns a human-readable string and JSON blob of the query plan while still executing the query to fetch data. +- `dry-run`: Generates the query plan and aborts without executing the query. + +After using `dry-run`, query plans are saved to your configured cache locations. Using real, mirrored, or similar to production operations is a great way to warmup the caches before transitioning traffic to new router instances. ## Caching automatic persisted queries (APQ) diff --git a/docs/source/routing/performance/caching/index.mdx b/docs/source/routing/performance/caching/index.mdx index 8a004e82cf..b424ac0982 100644 --- a/docs/source/routing/performance/caching/index.mdx +++ b/docs/source/routing/performance/caching/index.mdx @@ -1,16 +1,33 @@ --- -title: Caching -subtitle: Accelerate query retrieval with GraphOS caching. +title: Caching in Apollo Router +subtitle: Accelerate query retrieval with in-memory and distributed caching +description: Accelerate query retrieval for your federated graph with in-memory and distributed caching in Apollo Router. --- -By default, GraphOS Router stores the following data in its in-memory cache to improve performance: +Apollo Router supports multiple caching strategies, including in-memory caching, distributed caching with Redis, and entity caching, that allow you to reduce redundant subgraph requests and improve query latency. + +## In-memory caching + +By default, the router stores in its in-memory cache to improve performance: - Generated query plans - Automatic persisted queries (APQ) - Introspection responses -You can configure certain caching behaviors for generated query plans and APQ (but not introspection responses). For details, see In-memory caching in the Apollo Router. +You can configure certain caching behaviors for generated query plans and APQ (but not introspection responses). For details, see in-memory caching in the Apollo Router. + +Learn more about [in-memory caching](/graphos/routing/performance/caching/in-memory). + +## Distributed caching + +You can configure a Redis-backed distributed cache that enables multiple router instances to share cached values. Those instances can share a Redis-backed cache for their query plans and automatic persisted queries (APQ). If any of your router instances caches a particular value, all of your instances can look up that value to significantly improve responsiveness. + +Learn more about [distributed caching](/graphos/routing/performance/caching/distributed). + +## Entity-based caching + +Entity caching speeds up graph data retrieval by storing only the necessary data for entities, rather than entire client responses. It's helpful in scenarios where the same data is requested frequently, such as product information systems and user profile services. -If you have a GraphOS Enterprise plan, you can configure a Redis-backed distributed cache that enables multiple router instances to share cached values. For details, see Distributed caching in GraphOS Router. +Using Redis, the router stores subgraph responses and ensures that requests for the same entity from different clients are served from the cache, rather than fetching the data repeatedly from subgraphs. It can cache data at a fine-grained level and provides separate configurations for caching duration (TTL) and other caching behaviors per subgraph. -You can configure a Redis-backed entity cache that enables a client query to retrieve cached entity data split between subgraph responses. For details, see subgraph entity caching in GraphOS Router. +[Learn more about entity-based caching](/graphos/routing/performance/caching/entity). diff --git a/docs/source/routing/performance/query-batching.mdx b/docs/source/routing/performance/query-batching.mdx index 50530f8797..6734f3c872 100644 --- a/docs/source/routing/performance/query-batching.mdx +++ b/docs/source/routing/performance/query-batching.mdx @@ -1,16 +1,30 @@ --- title: Query Batching subtitle: Receive query batches with the GraphOS Router -description: Handle multiple GraphQL requests with GraphOS Router's query batching capabilities. Aggregate operations into single HTTP requests, reducing overhead. +description: Handle multiple GraphQL requests with GraphOS Router's query batching capabilities. Aggregate operations into single HTTP requests to preserve data consistency. --- - + -Learn about query batching and how to configure the GraphOS Router to receive query batches. +Rate limits apply on the Free plan. -## About query batching + -Modern applications often require several requests to render a single page. This is usually the result of a component-based architecture where individual micro-frontends (MFE) make requests separately to fetch data relevant to them. Not only does this cause a performance overhead—different components may be requesting the same data—it can also cause a consistency issue. To combat this, MFE-based UIs batch multiple client operations, issued close together, into a single HTTP request. This is supported in Apollo Client and Apollo Server. +Learn how query batching can help preserve data consistency of responses, and learn how to configure the GraphOS Router to receive query batches. + +## About query batching + +Modern applications often require several requests to render a single page. This is usually the result of a component-based architecture where individual micro-frontends (MFE) make requests separately to fetch data relevant to them. + +Query batching makes response data consistent for MFEs making multiple requests per page. It primarily preserves data consistency rather than improve performance. + +### Preserving data consistency + +When the underlying data is changing rapidly, the separate requests of an application may result in responses with inconsistent data. Given two requests for a single page, the values returned in their responses may be different because the data's been updated or subgraph servers briefly had different values. + +To prevent this inconsistency from happening, clients can bundle multiple requests together into a batch so routers or servers can produce responses with consistent data. MFE-based UIs usually batch multiple client operations, issued close together, into a single HTTP request. Both Apollo Client and Apollo Server support this. + +### Router batching support The router's batching support is provided by two sets of functionality: - client batching @@ -22,12 +36,14 @@ With subgraph batching, the router analyzes input client batch requests and issu The GraphOS Router supports client and subgraph query batching. +The GraphOS Router must be configured to receive query batches, otherwise it rejects them. When processing a batch, the router deserializes and processes each operation of a batch independently. It responds to the client only after all operations of the batch have been completed. Each operation executes concurrently with respect to other operations in the batch. + +### Client batching support + If you’re using Apollo Client, you can leverage the built-in support for batching to reduce the number of individual operations sent to the router. Once configured, Apollo Client automatically combines multiple operations into a single HTTP request. The number of operations within a batch is client-configurable, including the maximum number in a batch and the maximum duration to wait for operations to accumulate before sending the batch. -The GraphOS Router must be configured to receive query batches, otherwise it rejects them. When processing a batch, the router deserializes and processes each operation of a batch independently. It responds to the client only after all operations of the batch have been completed. Each operation executes concurrently with respect to other operations in the batch. - ## Configure client query batching Both the GraphOS Router and client need to be configured to support query batching. @@ -50,6 +66,7 @@ batching: | :-- | :-- | :-- | :-- | | `enabled` | Flag to enable reception of client query batches | boolean | `false` | | `mode` | Supported client batching mode | `batch_http_link`: the client uses Apollo Link and its [`BatchHttpLink`](/react/api/link/apollo-link-batch-http) link. | No Default | +| `maximum_size` | Maximum number of queries in a client batch (optional) | integer | `null` (no limit on number of queries) | #### Subgraph query batching @@ -87,7 +104,7 @@ batching: - There are limitations on the ability of the router to preserve batches from the client request into the subgraph requests. In particular, certain forms of queries will require data to be present before they are processed. Consequently, the router will only be able to generate batches from queries which are processed which don't contain such constraints. This may result in the router issuing multiple batches or requests. -- If [query deduplication](/router/configuration/traffic-shaping/#query-deduplication) or [entity caching](/router/configuration/entity-caching) are enabled, they will not apply to batched queries. Batching will take precedence over query deduplication and entity caching. Query deduplication and Entity caching will still be performed for non-batched queries. +- If [query deduplication](/router/configuration/traffic-shaping/#query-deduplication) or [entity caching](/router/configuration/entity-caching) are enabled, they will not apply to batched queries. Batching will take precedence over query deduplication and entity caching. Query deduplication and entity caching will still be performed for non-batched queries. @@ -191,6 +208,11 @@ To enable batching in an Apollo client, configure `BatchHttpLink`. For details o If the router receives a query batch from a client, and batching is *not* enabled, the router sends a `BATCHING_NOT_ENABLED` error to the client. +## Query batching and GraphOS plans +When processing batched operations, the router counts each entry in the batch as a distinct billable operation that counts towards your graph usage. Sending a batch with `N` operations is counted the same as sending `N` requests of the same non-batched operation. + +Note that GraphOS plans only track router operations, so configuring subgraph batching does not impact your graph usage count. + ## Metrics for query batching Metrics in the GraphOS Router for query batching: @@ -312,6 +334,53 @@ As a result, the router returns an invalid batch error: } ``` +### Excessive queries batch error + +If the number of queries provided exceeds the maximum batch size, the entire batch fails. + +For example, this configuration sets a batch size limit of 2, but three queries are provided: +```yaml +batching: + enabled: true + mode: batch_http_link + maximum_size: 2 +``` + +```graphql +[ + query MyFirstQuery { + me { + id + } + }, + query MySecondQuery { + me { + name + } + }, + query MyThirdQuery { + me { + name + } + } +] +``` + +As a result, the router returns an error: +```json +{ + "errors": [ + { + "message": "Invalid GraphQL request", + "extensions": { + "details": "Batch limits exceeded: you provided a batch with 3 entries, but the configured maximum router batch size is 2", + "code": "BATCH_LIMIT_EXCEEDED" + } + } + ] +} +``` + ### Individual query error If a single query in a batch cannot be processed, this results in an individual error. diff --git a/docs/source/routing/performance/query-planner-pools.mdx b/docs/source/routing/performance/query-planner-pools.mdx deleted file mode 100644 index e278a1e030..0000000000 --- a/docs/source/routing/performance/query-planner-pools.mdx +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: Query Planner Pools -subtitle: Run multiple query planners in parallel -minVersion: 1.44.0 -redirectFrom: - - /router/configuration/overview/#query-planner-pools ---- - - - -You can improve the performance of the router's query planner by configuring parallelized query planning. - -By default, the query planner plans one operation at a time. It plans one operation to completion before planning the next one. This serial planning can be problematic when an operation takes a long time to plan and consequently blocks the query planner from working on other operations. - -## Configuring query planner pools - -To resolve such blocking scenarios, you can enable parallel query planning. Configure it in `router.yaml` with `supergraph.query_planning.experimental_parallelism`: - -```yaml title="router.yaml" -supergraph: - query_planning: - experimental_parallelism: auto # number of available cpus -``` - -The value of `experimental_parallelism` is the number of query planners in the router's _query planner pool_. A query planner pool is a preallocated set of query planners from which the router can use to plan operations. The total number of pools is the maximum number of query planners that can run in parallel and therefore the maximum number of operations that can be worked on simultaneously. - -Valid values of `experimental_parallelism`: -- Any integer starting from `1` -- The special value `auto`, which sets the number of query planners equal to the number of available CPUs on the router's host machine - -The default value of `experimental_parallelism` is `1`. - -In practice, you should tune `experimental_parallelism` based on metrics and benchmarks gathered from your router. \ No newline at end of file diff --git a/docs/source/routing/performance/traffic-shaping.mdx b/docs/source/routing/performance/traffic-shaping.mdx index 19cc7db838..a27833d1eb 100644 --- a/docs/source/routing/performance/traffic-shaping.mdx +++ b/docs/source/routing/performance/traffic-shaping.mdx @@ -17,6 +17,7 @@ traffic_shaping: capacity: 10 interval: 5s # Must not be greater than 18_446_744_073_709_551_615 milliseconds and not less than 0 milliseconds timeout: 50s # If a request to the router takes more than 50secs then cancel the request (30 sec by default) + concurrency_limit: 100 # The router is limited to processing 100 concurrent requests. Excess requests must be rejected. all: deduplicate_query: true # Enable query deduplication for all subgraphs. compression: br # Enable brotli compression for all subgraphs. @@ -28,11 +29,6 @@ traffic_shaping: capacity: 10 interval: 5s # Must not be greater than 18_446_744_073_709_551_615 milliseconds and not less than 0 milliseconds timeout: 50s # If a request to the subgraph 'products' takes more than 50secs then cancel the request (30 sec by default) - experimental_retry: - min_per_sec: 10 # minimal number of retries per second (`min_per_sec`, default is 10 retries per second) - ttl: 10s # for each successful request, we register a token, that expires according to this option (default: 10s) - retry_percent: 0.2 # defines the proportion of available retries to the current number of tokens - retry_mutations: false # allows retries on mutations. This should only be enabled if mutations are idempotent experimental_http2: enable # Configures HTTP/2 usage. Can be 'enable' (default), 'disable' or 'http2only' ``` @@ -90,6 +86,22 @@ Since [deferred](/router/executing-operations/defer-support/#what-is-defer) frag +### Concurrency + +By default, the router does not enforce a concurrency limit. To enforce a limit, change your config to include: + +```yaml title="router.yaml" +traffic_shaping: + router: + concurrency_limit: 100 # The router is limited to processing 100 concurrent requests. Excess requests must be rejected. +``` + + + +This is not a concurrency limit on connections, but a limit on the number of active requests being processed by the router at any one time. + + + ### Compression Compression is automatically supported on the client side, depending on the `Accept-Encoding` header provided by the client. @@ -150,22 +162,6 @@ traffic_shaping: interval: 5s # Must not be greater than 18_446_744_073_709_551_615 milliseconds and not less than 0 milliseconds ``` -### Experimental request retry - -On failure, subgraph requests can be retried automatically. This is deactivated by default for mutations. This uses [Finagle's *RetryBudget* algorithm](https://finagle.github.io/blog/2016/02/08/retry-budgets/), in which every successful request adds an expirable token to a bucket, and every retry consumes a number of those tokens. On top of that, a minimal number of retries per second is available, to test regularly when the retry budget was entirely consumed or on startup when very few requests have been sent. The tokens expire so the budget has a large number of available retries if a lot of recent requests were successful but reduces quickly on frequent failures to avoid sending too much traffic to the subgraph. - -It is configurable as follows: - -```yaml title="router.yaml" -traffic_shaping: - all: - experimental_retry: - min_per_sec: 10 # minimal number of retries per second (`min_per_sec`, default is 10 retries per second) - ttl: 10s # for each successful request, we register a token, that expires according to this option (default: 10s) - retry_percent: 0.2 # defines the proportion of available retries to the current number of tokens - retry_mutations: false # allows retries on mutations. This should only be enabled if mutations are idempotent -``` - ### Variable deduplication When subgraphs are sent entity requests by the router using the `_entities` field, it is often the case that the same entity (identified by a unique `@key` constraint) is requested multiple times within the execution of a single federated query. For example, an author's name might need to be fetched multiple times when accessing a list of a reviews for a product for which the author has written multiple reviews. @@ -188,13 +184,12 @@ traffic_shaping: ### Ordering -Traffic shaping always executes these steps in the same order, to ensure a consistent behaviour. Declaration order in the configuration will not affect the runtime order: +Traffic shaping always executes these steps in the same order, to ensure a consistent behavior. Declaration order in the configuration will not affect the runtime order: - preparing the subgraph request - variable deduplication - query deduplication - timeout -- request retry - rate limiting - compression - sending the request to the subgraph diff --git a/docs/source/routing/query-planning/native-query-planner.mdx b/docs/source/routing/query-planning/native-query-planner.mdx index 7da2d5a099..b6cfda3d53 100644 --- a/docs/source/routing/query-planning/native-query-planner.mdx +++ b/docs/source/routing/query-planning/native-query-planner.mdx @@ -1,79 +1,38 @@ --- title: Native Query Planner subtitle: Run the Rust-native query planner in GraphOS Router -minVersion: 1.49.0 +minVersion: Router v1.49.0 redirectFrom: - /router/configuration/experimental_query_planner_mode - /router/executing-operations/native-query-planner --- - - -Learn to run the GraphOS Router with the Rust-native query planner and improve your query planning performance and scalability. +Learn about the Rust-native query planner in GraphOS Router v2.x ## Background about query planner implementations -In v1.49.0 the router introduced a [query planner](/graphos/routing/about-router#query-planning) implemented natively in Rust. This native query planner improves the overall performance and resource utilization of query planning. It exists alongside the legacy JavaScript implementation that uses the V8 JavaScript engine, and it will eventually replace the legacy implementation. - -### Comparing query planner implementations - -As part of the effort to ensure correctness and stability of the new query planner, starting in v1.53.0 the router enables both the new and legacy planners and runs them in parallel to compare their results by default. After their comparison, the router discards the native query planner's results and uses only the legacy planner to execute requests. The native query planner uses a single thread in the cold path of the router. It has a bounded queue of ten queries. If the queue is full, the router simply does not run the comparison to avoid excessive resource consumption. - -## Configuring query planning - -You can configure the `experimental_query_planner_mode` option in your `router.yaml` to set the query planner to run. - -The `experimental_query_planner_mode` option has the following supported modes: - -- `new`- enables only the new Rust-native query planner -- `legacy` - enables only the legacy JavaScript query planner -- `both_best_effort` (default) - enables both new and legacy query planners for comparison. The legacy query planner is used for execution. - - - -## Optimize native query planner +In v1.49.0, the router introduced a query planner implemented natively in Rust. This native query planner improves the overall performance and resource utilization of query planning. It existed alongside the legacy JavaScript implementation that uses the V8 JavaScript engine. - +As of v1.59.0, the native query planner is the default planner in the router. As a result, the legacy query planner, which was built using Deno and relied on the v8 engine, has been deprecated. -To run the native query planner with the best performance and resource utilization, configure your router with the following options: +As of v2.x, the legacy query planner has been removed. -```yaml title="router.yaml" -experimental_query_planner_mode: new -``` +## Improved performance of native query planner - - -In router v1.56, running the native query planner with the best performance and resource utilization also requires setting `experimental_introspection_mode: new`. - - - -Setting `experimental_query_planner_mode: new` not only enables native query planning and schema introspection, it also disables the V8 JavaScript runtime used by the legacy query planner. Disabling V8 frees up CPU and memory and improves native query planning performance. +The native query planner achieves better performance for a variety of graphs. In our tests, we observe: -Additionally, to enable more optimal native query planning and faster throughput by reducing the size of queries sent to subgraphs, you can enable query fragment generation with the following option: +- 10x median improvement in query planning time (observed via the `apollo.router.query_planning.plan.duration` metric) +- 2.9x improvement in router’s CPU utilization +- 2.2x improvement in router’s memory usage -```yaml title="router.yaml" -supergraph: - generate_query_fragments: true -``` +You can expect generated plans and subgraph operations in the native query planner to have slight differences when compared to the legacy, JavaScript-based query planner. We've determines these differences to be semantically insignificant, based on an analysis of approximately 2.5 million unique user operations in GraphOS and a comparison of about 630 million operations from actual router deployments running in shadow mode over four months. -Regarding [fragment reuse and generation](/router/configuration/overview#fragment-reuse-and-generation), in the future the `generate_query_fragments` option will be the only option for handling fragments. +The subgraph operations generated by the query planner may change with each release. We strongly recommend against relying on their exact shape, as new router features and optimizations may continue to change them. ## Metrics for native query planner -When running both query planners for comparison with `experimental_query_planner_mode: both_best_effort`, the following metrics track mismatches and errors: - -- `apollo.router.operations.query_planner.both` with the following attributes: - - `generation.is_matched` (bool) - - `generation.js_error` (bool) - - `generation.rust_error` (bool) - -- `apollo.router.query_planning.plan.duration` with the following attributes to differentiate between planners: - - `planner` (rust | js) - -## Limitations of native query planner - -The native query planner doesn't implement `@context`. This is planned to be implemented in a future router release. +The available metrics for the native query planner are listed and described in the [router standard instruments](/router/configuration/telemetry/instrumentation/standard-instruments#query-planner)) page. diff --git a/docs/source/routing/query-planning/query-planning-best-practices.mdx b/docs/source/routing/query-planning/query-planning-best-practices.mdx new file mode 100644 index 0000000000..1047205405 --- /dev/null +++ b/docs/source/routing/query-planning/query-planning-best-practices.mdx @@ -0,0 +1,186 @@ +--- +title: Best Practices for Query Planning +subtitle: Design your schemas and use features to optimize query planning performance +description: Learn best practices in GraphQL schema design to achieve efficient query planning of your graphs using Apollo Federation and Apollo Router +--- + +When working with Apollo Federation, changes in your schema can have unexpected impact on the complexity and performance of your graph. Adding one field or changing one directive may create a new supergraph that has hundreds, or even thousands, of new possible paths and edges to connect entities and resolve client operations. Consequently, query planning throughput and latency may degrade. +While validation errors can be found at build time with [schema composition](https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/composition), other changes may lead to issues that only arise at runtime, during query plan generation or execution. + +Examples of changes that can impact query planning include: +* Adding or modifying `@key`, `@requires`, `@provides`, or `@shareable` directive usage +* Adding or removing a type implementation from an interface +* Using `interfaceObject` and adding new fields to an interface + +To help alleviate these issues as much as possible, we recommend following some of these best practices for your federated graph. + +## Use shared types and fields judiciously + +The [`@shareable` directive](https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/sharing-types) allows multiple subgraphs to resolve the same types or fields on entities, giving the query planner options for potentially shorter query paths. However, it's important to use it judiciously. +- Extensive `@shareable` use can exponentially increase the number of possible query plans generated as the query planner will find the shortest path to the desired result. This can then potentially lead to performance degradation at runtime as we generate plans. +- Using `@shareable` at root fields on the `Query`, `Mutation`, and `Subscription` types indicates that any subgraph can resolve a given entry point. While query plans can be deterministic for a given version of Router + Federation, there are no guarantees across versions, meaning that your plans may change if new services get added or deleted. This could cause an unexpected change in traffic for a given service, even there were no changes in the operations. + - Using shared root types also implies that the fields return the same data in the same order across all subgraphs, even if the data is a list, which is often not the case for dynamic applications. + +## Minimize operations spanning multiple subgraphs + +Operations that need to query multiple subgraphs can impact performance because each additional subgraph queried adds complexity to the query plan, increasing the time in the Router for both generation and execution of the operation. +- Design your schema to minimize operations that span numerous subgraphs. +- Using directives like `@requires` or `@interfaceObject` carefully to control complexity. + + +### `@requires` directive + +The [`@requires` directive](https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/reference/directives#requires) allows a subgraph to fetch additional fields needed to resolve an entity. This can be powerful but must be handled with care. +- Changes to fields utilized by `@requires` can impact the subgraph fetches that current operations depend on and may create larger and slower plans. +- When performing schema migrations involving `@requires`, ensure compatibility by deploying changes in a manner that avoids disrupting ongoing queries. Plan deployments and schema changes in an atomic fashion. + +#### Example + +Consider the following example of a `Products` subgraph and a `Reviews` subgraph: + +```graphql title="Products subgraph" showLineNumbers=false disableCopy=true +type Product @key(fields: "upc") { + upc: ID! + nameLowerCase: String! +} +``` + +```graphql title="Reviews subgraph" showLineNumbers=false disableCopy=true +type Product @key(fields: "upc") { + upc: ID! + nameLowercase: String! @external + reviews: [Review]! @requires(fields: "nameLowercase") +} +``` + +Suppose you want to deprecate the `nameLowercase` field and replace it with the `name` field, like so: + +```graphql title="Products subgraph" showLineNumbers=false disableCopy=true {3-4} +type Product @key(fields: "upc") { + upc: ID! + nameLowerCase: String! @deprecated + name: String! +} +``` + +```graphql title="Reviews subgraph" showLineNumbers=false disableCopy=true {3-5} +type Product @key(fields: "upc") { + upc: ID! + nameLowercase: String! @external + name: String! @external + reviews: [Review]! @requires(fields: "name") +} +``` + +To perform this migration in place: + +1. Modify the `Products` subgraph to add the new field using `rover subgraph publish` to push the new subgraph schema. +2. Deploy a new version of the `Reviews` subgraph with a resolver that accepts either `nameLowercase` or `name` in the source object. +3. Modify the Reviews subgraph's schema in the registry so that it `@requires(fields: "name")`. +4. Deploy a new version of the `Reviews` subgraph with a resolver that only accepts the `name` in its source object. + +Alternatively, you can perform this operation with an atomic migration at the subgraph level by modifying the subgraph's URL: + +1. Modify the `Products` subgraph to add the `name` field (as usual, first deploy all replicas, then use `rover subgraph publish` to push the new subgraph schema). +2. Deploy a new set of `Reviews` replicas to a new URL that reads from `name`. +3. Register the `Reviews` subgraph with the new URL and the schema changes above. + +With this atomic strategy, the query planner resolves all outstanding requests to the old subgraph URL that relied on `nameLowercase` with the old query-planning configuration, which `@requires` the `nameLowercase` field. All new requests are made to the new subgraph URL using the new query-planning configuration, which `@requires` the `name` field. + +## Manage interface migrations + +Interfaces are an essential part of GraphQL schema design, offering flexibility in defining polymorphic types. However, they can also be open for implementation across service boundaries, allowing subgraphs to contribute a new type that changes how existing operations execute. + +- Approach interface migrations similar to database migrations. Ensure that changes to interface implementations are performed safely, avoiding disruptions to query operations. + + +### Example + +Suppose you define a `Channel` interface in one subgraph and other types that implement `Channel` in two other subgraphs: + +```graphql disableCopy=true showLineNumbers=false title="Channel subgraph" +interface Channel @key(fields: "id") { + id: ID! +} +``` + +```graphql disableCopy=true showLineNumbers=false title="Web subgraph" +type WebChannel implements Channel @key(fields: "id") { + id: ID! + webHook: String! +} +``` + +```graphql disableCopy=true showLineNumbers=false title="Email subgraph" +type EmailChannel implements Channel @key(fields: "id") { + id: ID! + emailAddress: String! +} +``` + +To safely remove the `EmailChannel` type from your supergraph schema: + +1. Perform a `rover subgraph publish` of the `email` subgraph that removes the `EmailChannel` type from its schema. +2. Deploy a new version of the subgraph that removes the `EmailChannel` type. + +The first step causes the query planner to stop sending fragments `...on EmailChannel`, which would fail validation if sent to a subgraph that isn't aware of the type. + +If you want to keep the `EmailChannel` type but remove it from the `Channel` interface, the process is similar. Instead of removing the `EmailChannel` type altogether, only remove the `implements Channel` addendum to the type definition. This is because the query planner expands queries to interfaces or unions into fragments on their implementing types. + +For example, a query like this: + +```graphql +query FindChannel($id: ID!) { + channel(id: $id) { + id + } +} +``` + +generates two queries, one to each subgraph, like so: + + + + ```graphql title="Query to email subgraph" + query { + _entities(...) { + ...on EmailChannel { + id +} +} +} + ``` + + ```graphql title="Query to web subgraph" + query { + _entities(...) { + ...on WebChannel { + id +} +} +} + ``` + + + +Currently, the router expands all interfaces into implementing types. + +## Use recommended features + +GraphOS and router provide many features that help monitor and improve query planning performance, both at build time and runtime. + +### Build time + +* Use [schema proposals](https://www.apollographql.com/docs/graphos/platform/schema-management/proposals) to review changes that have a large impact across entities and interfaces +* Enable [common linter settings](https://www.apollographql.com/docs/graphos/platform/schema-management/linting) +* Setup [custom checks](https://www.apollographql.com/docs/graphos/platform/schema-management/checks/custom) to do advanced and specific validations, like [limiting the size of query plans](https://github.com/apollosolutions/example-graphos-custom-check-query-planner) + +### Runtime + +In the [router configuration](https://www.apollographql.com/docs/graphos/routing/configuration) there are many settings to help monitor and improve performance impacts. Here are some features all production graphs should consider: + +* Monitor your query planner performance with the [standard instruments](https://www.apollographql.com/docs/graphos/routing/observability/telemetry/instrumentation/standard-instruments#query-planning) +* Enabling and configuring the [in-memory cache for query plans](https://www.apollographql.com/docs/graphos/routing/performance/caching/in-memory) +* Using the cache [warm up features](https://www.apollographql.com/docs/graphos/routing/performance/caching/in-memory#cache-warm-up) included out of the box and using the `dry-run` headers for operations +* Enabling and configuring [distributed caches for query plans](https://www.apollographql.com/docs/graphos/routing/performance/caching/distributed) to share across router instances +* Limiting the size of operations (and therefore their query plans) with [request limits](https://www.apollographql.com/docs/graphos/routing/security/request-limits) and the cost with [demand control](https://www.apollographql.com/docs/graphos/routing/security/demand-control) diff --git a/docs/source/routing/request-lifecycle.mdx b/docs/source/routing/request-lifecycle.mdx new file mode 100644 index 0000000000..ba03f7bc71 --- /dev/null +++ b/docs/source/routing/request-lifecycle.mdx @@ -0,0 +1,269 @@ +--- +title: Router Request Lifecycle +subtitle: Learn how the router processes client requests +description: Understand how GraphQL client requests get processed through the request lifecycle pipeline of an Apollo Router. +--- + +import RequestLifecycleOverviewDiagram from '../../shared/diagrams/router-request-lifecycle-overview.mdx'; + +Every client request made to an Apollo Router goes through the **router request lifecycle**: a multi-stage pipeline of services that processes requests and returns responses. + + + +The router processes a client request by first passing it between services along the lifecycle's **request path**. In the request path, it needs to figure out how to use your subgraphs to fetch or update the fields of the request. To do this, the router generates a _query plan_: + +Diagram of a query plan + + +A query plan is a blueprint for dividing a single incoming operation into one or more operations that are each resolvable by a single subgraph. Some of these operations depend on the results of other operations, so the query plan also defines any required ordering for their execution. The router's _query planner_ determines the optimal set of subgraph queries for each client operation, then merges the subgraph responses into a single response for the client. + +## Request path + +In the request path, the request lifecycle services process each request in the following order: + +* The **Router service** receives the client request from the HTTP server and parses it into a GraphQL operation. +* The **Supergraph service** receives a GraphQL operation and calls the router's query planner to produce the query plan that most efficiently executes the operation. +* The **Execution service** executes a query plan by calling the necessary subgraph services to make subgraph requests +* Each subgraph has an associated **Subgraph service** that makes HTTP requests to the subgraph. + +Each service encapsulates and transforms the contents of a request into its own context. The following diagram and its steps describe how an HTTP request is transformed and propagated through the request path: + +```mermaid +flowchart TB; + client(Client); + subgraph router["Router"] + direction LR + httpServer("HTTP server") + subgraph routerService["Router Service"] + routerPlugins[[Router plugins]]; + end + subgraph " " + subgraph supergraphService["Supergraph Service"] + supergraphPlugins[[Supergraph plugins]]; + end + queryPlanner("Query Planner"); + end + + + subgraph executionService["Execution Service"] + executionPlugins[[Execution plugins]]; + end + + subgraph subgraphService["Subgraph Services"] + subgraph service1["Subgraph Service A"] + subgraphPlugins1[[Subgraph plugins]]; + end + subgraph service2["Subgraph Service B"] + subgraphPlugins2[[Subgraph plugins]]; + end + end + end; +subgraphA[Subgraph A]; +subgraphB[Subgraph B]; + +client --"1. HTTP request"--> httpServer; +httpServer --"2. RouterRequest"--> routerService; +routerService --"3. SupergraphRequest"--> supergraphService +supergraphService --"4. Query"--> queryPlanner; +queryPlanner --"5. Query plan"--> supergraphService; +supergraphService --"6. ExecutionRequest"--> executionService; + +executionService --"7a. SubgraphRequest"--> service1; +executionService --"7b. SubgraphRequest"--> service2; + +service1 --"8a. HTTP request"--> subgraphA; +service2 --"8b. HTTP request"--> subgraphB; +``` + +1. The router receives a client request at an HTTP server. +2. The HTTP server transforms the HTTP request into a `RouterRequest` containing HTTP headers and the request body as a stream of byte arrays. +3. The router service receives the `RouterRequest`. It handles Automatic Persisted Queries (APQ), parses the GraphQL request from JSON, validates the query against the schema, and calls the supergraph service with the resulting `SupergraphRequest`. +4. The supergraph service calls the query planner with the GraphQL query from the `SupergraphRequest`. +5. The query planner returns a query plan for most efficiently executing the query. +6. The supergraph service calls the execution service with an `ExecutionRequest`, made up of `SupergraphRequest` and the query plan. +7. For each fetch node of the query plan, the execution service creates a `SubgraphRequest` and then calls the respective subgraph service. +8. Each subgraph has its own subgraph service, and each service can have its own subgraph plugin configuration. The subgraph service transforms the `SubgraphRequest` into an HTTP request to its subgraph. The `SubgraphRequest` contains: + - the (read-only) `SupergraphRequest` + - HTTP headers + - the subgraph request's operation type (query, mutation, or subscription) + - a GraphQL request object as the request body + +Subgraph responses follow the response path. + +## Response path + +In the response path, the lifecycle services gather subgraph responses into a client response in the following order: + +* The **Execution service** receives and formats all subgraph responses. +* The **Supergraph service** gathers the content of all subgraph responses into stream. +* The **Router service** serializes the stream of responses into JSON and forwards it to the HTTP server to send it to the client. + +The following diagram and its steps describe the response path in further detail: + +```mermaid +flowchart BT; + client(Client); + subgraph " " + direction LR + httpServer("HTTP server") + subgraph routerService["Router Service"] + routerPlugins[[Router plugins]]; + end + subgraph " " + subgraph supergraphService["Supergraph Service"] + supergraphPlugins[[Supergraph plugins]]; + end + queryPlanner("QueryPlanner"); + end + + + subgraph executionService["Execution Service"] + executionPlugins[[Execution plugins]]; + end + + subgraph subgraphService["Subgraph Services"] + subgraph service1["Subgraph Service A"] + subgraphPlugins1[[Subgraph plugins]]; + end + subgraph service2["Subgraph Service B"] + subgraphPlugins2[[Subgraph plugins]]; + end + end + end; +subgraph1[Subgraph A]; +subgraph2[Subgraph B]; + +subgraph1 -- "9a. HTTP response"--> service1; +subgraph2 -- "9b. HTTP response"--> service2; +service1 --"10a. SubgraphResponse"--> executionService; +service2 --"10b. SubgraphResponse"--> executionService; +executionService --"11. ExecutionResponse"--> supergraphService; +supergraphService --"12. SupergraphResponse"--> routerService; +routerService --"13. RouterResponse"--> httpServer; +httpServer --"14. HTTP response" --> client +``` +9. Each subgraph provides an HTTP response to the subgraph services. +10. Each subgraph service creates a `SubgraphResponse` containing the HTTP headers and a GraphQL response. +11. Once the execution service has received all subgraph responses, it formats the GraphQL responses—removing unneeded data and propagating nulls—before sending it back to the supergraph plugin as the `ExecutionResponse`. +12. The `SupergraphResponse` has the same content as the `ExecutionResponse`. It contains headers and a stream of GraphQL responses. That stream only contains one element for most queries—it can contain more if the query uses the `@defer` directive or subscriptions. +13. The router service receives the `SupergraphResponse` and serializes the GraphQL responses to JSON. +14. The HTTP server sends the JSON in an HTTP response to the client. + +## Request and response nuances + +Although the preceding diagrams showed the request and response paths separately and sequentially, in reality some requests and responses may happen simultaneously and repeatedly. + +For example, `SubgraphRequest`s can happen both in parallel _and_ in sequence: one subgraph's response may be necessary for another's `SubgraphRequest`. The [query planner](/graphos/reference/federation/query-plans) decides which requests can happen in parallel vs. which need to happen in sequence. + +To match subgraph requests to responses in customizations, the router exposes a `subgraph_request_id` field that will hold the same value in paired requests and responses. + +### Requests run in parallel + +```mermaid +flowchart LR; + subgraph parallel[" "] + subgraph executionService["Execution Service"] + executionPlugins[[Execution plugins]]; + end + + subgraph subgraphService["Subgraph Services"] + subgraph service1["Subgraph Service A"] + subgraphPlugins1[[Subgraph plugins]]; + end + subgraph service2["Subgraph Service B"] + subgraphPlugins2[[Subgraph plugins]]; + end + end + + + executionService --"1A. SubgraphRequest"--> service1; + executionService --"1B. SubgraphRequest"--> service2; + service1 --"4A. SubgraphResponse"--> executionService; + service2 --"4B. SubgraphResponse"--> executionService; + end + subgraphA[Subgraph A]; + subgraphB[Subgraph B]; + + service1 --"2A. HTTP request"--> subgraphA; + service2 --"2B. HTTP request"--> subgraphB; + subgraphA --"3A. HTTP response"--> service1; + subgraphB --"3B. HTTP response"--> service2; +``` + +### Requests run sequentially + +```mermaid +flowchart LR; + subgraph sequentially[" "] + subgraph executionService["Execution Service"] + executionPlugins[[Execution plugins]]; + end + + subgraph subgraphService["Subgraph Services"] + subgraph service1["Subgraph Service A"] + subgraphPlugins1[[Subgraph plugins]]; + end + subgraph service2["Subgraph Service B"] + subgraphPlugins2[[Subgraph plugins]]; + end + end + + + executionService --"1. SubgraphRequest"--> service1; + service1 --"4. SubgraphResponse"--> executionService; + executionService --"5. SubgraphRequest"--> service2; + service2 --"8. SubgraphResponse"--> executionService; + end + subgraphA[Subgraph A]; + subgraphB[Subgraph B]; + + service1 --"2. HTTP request"--> subgraphA; + service2 --"6. HTTP request"--> subgraphB; + subgraphA --"3. HTTP response"--> service1; + subgraphB --"7. HTTP response"--> service2; +``` + +Additionally, some requests and responses may happen multiple times for the same operation. With subscriptions, for example, a subgraph sends a new `SubgraphResponse` whenever data is updated. Each response object travels through all the services in the response path and interacts with any customizations you've created. + +## Observability of the request lifecycle + +To understand the state and health of your router as it services requests, you can add instrumentation to request lifecycle services and collect telemetry. The router's telemetry is based on [OpenTelemetry](https://opentelemetry.io/docs/what-is-opentelemetry/), so you can configure your router's YAML configuration to add traces, metrics, and logs. + +You can instrument the Router, Supergraph, and Subgraph services with [events](/router/configuration/telemetry/instrumentation/events) to capture data points along the request lifecycle. To customize events, you can set [conditions](/router/configuration/telemetry/instrumentation/conditions) to control when events are triggered, and [attributes](/router/configuration/telemetry/instrumentation/events#attributes) and [selectors](/router/configuration/telemetry/instrumentation/selectors) to specify the data attached to events. + +To learn more about router observability with telemetry, go to [Router Telemetry](/graphos/routing/observability/telemetry). + +## Router customizations along the request lifecycle + +You can create customizations for the router to extend its functionality. Customizations intervene at specific points of the request lifecycle, where each point is represented by a specific service with its own request and response objects. + +Customizations are implemented as plugins. Each service of the request lifecycle can have a set of customizable plugins that can be executed before or after the service: + +- For requests, the router executes plugins _before_ the service. + +```mermaid +flowchart LR + subgraph Service + Plugin1["Plugin 1"] -->|request| Plugin2["Plugin 2"] -->|request| coreService["Core
service"] + coreService + end + +Client -->|request| Plugin1 +coreService -->|request| NextService["Next service"] +``` + +- For responses, the router executes the plugins _after_ the service. + +```mermaid +flowchart RL + subgraph Service + coreService["Core
service"] -->|response| Plugin2["Plugin 2"] -->|response| Plugin1["Plugin 1"] + end + +Plugin1["Plugin 1"] -->|response| Client +NextService["Next service"] -->|response| coreService +``` + +Each request and response object contains a `Context` object, which is carried throughout the entire process. Each request's `Context` object is unique. You can use it to store plugin-specific information between the request and response or to communicate between different hook points. A plugin can be called at multiple steps of the request lifecycle. + +To learn how to hook in to the various lifecycle stages, including examples customizations, start with the [router customization overview](/graphos/routing/customization/overview), then refer to the [Rhai scripts](/graphos/routing/customization/rhai/) and [external coprocessing](/router/customizations/coprocessor/) docs. diff --git a/docs/source/routing/router-api-gateway-comparison.mdx b/docs/source/routing/router-api-gateway-comparison.mdx index 7bc41176c0..9d2ea50b22 100644 --- a/docs/source/routing/router-api-gateway-comparison.mdx +++ b/docs/source/routing/router-api-gateway-comparison.mdx @@ -1,7 +1,7 @@ --- title: Does GraphOS Router Replace My API Gateway? subtitle: How the GraphOS Router differs from API gateways -description: The GraphOS Router isn't based on URLs or REST endpoints, its a GraphQL-native solution to handle your clients API operations. +description: The GraphOS Router is not based on service mapping, it's a solution to handle your API orchestration for all your different clients operations. published: 2023-03-31 id: TN0037 tags: [federation, router, gateway] @@ -9,32 +9,36 @@ redirectFrom: - /technotes/TN0037-api-gateways/ --- -The GraphOS Router is a high-performant GraphQL gateway that supports [Apollo Federation](https://www.apollographql.com/docs/federation). It handles GraphQL requests that can then be resolved by many GraphQL subgraphs underneath. When comparing the router to other API technologies in the market today, a natural first comparison to draw is to API gateways. Tools like Kong or your cloud provider offer capabilities to secure, manage, and monitor your API endpoints. -These gateways usually live at the outermost edges of your companies infrastructure. Sometimes they are even required by your security team so that all inbound and outbound traffic flows through the same top layer of your tech stack. +The GraphOS Router is a high-performant API orchestration gateway that supports [Apollo Federation](https://www.apollographql.com/docs/federation). It handles requests that can then be resolved by many different services underneath, whether they are REST or GraphQL. +When comparing the router to other API technologies in the market today, a natural first comparison to draw is to API management gateways. +Tools like Kong or your cloud provider offer capabilities to secure, manage, and monitor your API endpoints. +These gateways usually live at the outermost edges of your companies infrastructure. Sometimes they are even required by your security team at all layers, so that traffic flows through the same secure layer of your tech stack regardless of business use case. -The key distinction of the router is that is not based on URLs or REST endpoints. The router is a GraphQL-native solution to handle your clients API operations. +The key distinction of GraphOS Router is that it is not a point-to-point solution for endpoints. The GraphOS Router is an API schema-aware solution to handle the orchestration of many different operations, even if that requires making multiple dependent service calls to resolve an operation. -Apollo GraphQL and Kong have partnered to produce a joint paper that provides API practitioners with the tools they need to navigate the complex API landscape and drive successful digital experiences. Read more in the [blog post](https://www.apollographql.com/blog/leveraging-graphql-for-next-generation-api-platforms). +Apollo GraphQL and Kong have partnered to produce a joint paper that provides API practitioners with the tools they need to navigate the complex API landscape and drive successful digital experiences. +Read more in the [blog post](https://www.apollographql.com/blog/leveraging-graphql-for-next-generation-api-platforms). -## GraphQL native +## Schema-aware -First, let's define what we mean by "GraphQL native." The GraphOS Router runs all the client operations against a [supergraph](/intro/platform/). -This means that requests processed by the router are not for any random service, but are restricted to what is defined and published by the GraphQL subgraphs for a given supergraph schema. -Subgraphs define the schema and capabilities they want to expose. A well-defined GraphQL schema does not just expose all the data available in a company, instead, a [demand-oriented schema](/graphos/schema-design/guides/demand-oriented-schema-design) gives clients access to all the operations they need to execute, but without over exposing your data. -Since subgraphs are also the ones that define their service capabilities, the router can be the central location to enforce standardized rules or policies that are declared by subgraphs, for example, [a directive-based authN/Z plugin](https://www.apollographql.com/blog/platform/financial-services/directive-based-authorization-for-financial-services/). +First, let's define what we mean by "schema aware". The GraphOS Router runs all the client operations against a [supergraph](/intro/platform/). +This means that requests processed by the router are not for any random service, but are restricted to what is defined and published by the connected services and what is defined in their API schema. In Apollo Federation, we call services connected to the GraphOS Router subgraphs. +Subgraphs define the schema and capabilities they want to expose via type-safe API schema language called [GraphQL](https://graphql.com/), however the service itself could be either a REST or GraphQL API. A well-defined GraphQL schema does not just expose all the data available in a company, instead, a [demand-oriented schema](/graphos/schema-design/guides/demand-oriented-schema-design) gives clients access to all the operations they need to execute, but without over exposing your data. +Since subgraphs are also the ones that define the capabilities they want to expose, the router can be the central location to enforce standardized rules or policies that are declared by subgraphs rather then the gateway, for example, [directive-based authN/Z](https://www.apollographql.com/docs/graphos/routing/security/authorization). -```graphql +```graphql title="accounts-service-rest-api.graphql" type Query { - bankAccounts: [BankAccount] @authenticated @hasRole(role: ADMIN) + bankAccounts: [BankAccount] @authenticated @requiresScopes(scopes: [["accounts:read", "user:read"], ["accounts:admin"]]) } ``` -API gateways (like Apigee, Mulesoft, or ones from AWS, Azure, or Google Cloud) usually have little understanding of all the services underneath them or what their capabilities are. They may have configuration options and rules that can apply to those services, but these are blanket rules that must be configured at the gateway level, not at the service definition. -If you wanted to apply a common rule across many services it would be up to the API gateway managers to configure, deploy, and apply that new rule to a set of services rather than the individual service teams. +API gateways (like Apigee, Mulesoft, or ones from AWS, Azure, or Google Cloud) usually have little understanding of all the services underneath them or what their capabilities are. +They may have configuration options and rules that can apply to those services, but these are blanket rules that must be configured at the gateway level, not at the service definition. +If you wanted to apply a common rule across many services it would be up to the API gateway team to configure, deploy, and apply that new rule to a set of services rather than the individual service teams. ```yaml # Mock gateway rules @@ -47,24 +51,37 @@ gatewayConfig: ruleToApply: requires-admin-permissions-plugin ``` +## API Orchestration +Traditional API management tools are point-to-point solutions combined with a few other capabilities for discovery, but they do little handle the real complexity of APIs, which is understanding how to use them to power your clients. +Our front-end teams are typically not involved in our API and service design. They are the ones left to deal with the complexity of connecting data pieces, chaining calls together, and applying performance optimizations when each API team has met their SLOs, but calling all the APIs together is still to slow. +This orchestration problem leads to more services for dedicated use cases, which reduces our ability to reuse APIs in other scenarios (*see* [GraphQL as an Abstraction Layer](https://www.apollographql.com/docs/graphos/reference/guides/using-graphql-for-abstraction)). + +GraphOS Router introduces a new concept for APIs, [Apollo Federation](https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/federation). Federation is a declarative model, which means rather than just consuming and producing APIs as is, client teams instead declare what data they need in their operation definitions and subgraph teams define their API capabilities via the schema definitions. +Then we run both the operation and API schemas through a process called [Query Planning](https://www.apollographql.com/docs/graphos/routing/about-router#subgraph-query-planner). The planner figures out what services we need to call and in which order to resolve a given operation. + + + + ## Support for non-GraphQL APIs -GraphQL is an innovative technology that give clients much more control over their operations and a common language for service teams to communicate. However, GraphQL usually is not the one and only API technology used by companies today. -If you need to have a common gateway to secure REST endpoints and GraphQL endpoints, the GraphOS Router can be a complimentary tool that lives underneath this higher-level API gateway. You can configure company-wide policies at the outermost edge layer, and anything else that is better defined as a GraphQL-specific policy can be managed by the router. -In addition, using a [defense-in-depth](https://csrc.nist.gov/glossary/term/defense_in_depth) security strategy reduces your companies risk, so having both an API gateway and router applying shared rules can lead to a more secure environment. +GraphQL is an innovative technology that gives clients much more control over their operations and a common language for service teams to communicate. However, GraphQL usually is not the one and only API technology used by companies today. +Luckily, GraphOS Router supports non-GraphQL APIs via [Apollo Connectors](https://www.apollographql.com/docs/graphos/schema-design/connectors). With Connectors, you can bring in your JSON APIs as-is or you can even define a more focused API to limit your orchestration surface area and make sure there is not a duplication of data owners. + +If you already have common gateway to secure REST and GraphQL endpoints, the GraphOS Router can be a complimentary tool that lives underneath this higher-level API gateway. You can configure company-wide policies at the outermost edge layer, and anything else that is better defined as a graph-specific policy can be managed by the router. +In addition, using a [defense-in-depth](https://csrc.nist.gov/glossary/term/defense_in_depth) security strategy reduces your companies risk, so having both an API gateway and GraphOS Router applying shared security rules can lead to a more secure environment. ```mermaid graph LR subgraph Clients - Client1 - Client2 - Client3 + Client-1 + Client-2 + Client-3 end subgraph Partners - Partner1 - Partner2 + Partner-1 + Partner-2 end subgraph Edge @@ -73,43 +90,40 @@ end style Edge height:100%; -subgraph GraphQL +subgraph GraphOS Router - Subgraph1 - Subgraph2 + Subgraph-1 end subgraph Data Layer - REST1 - REST2 - gRPC1 - DB1 - DB2 + REST-1 + REST-2 + JSON-1 + gRPC-1 + DB-1 end -Client1 --> API-Gateway -Client2 --> API-Gateway -Client3 --> API-Gateway -Partner1 --> API-Gateway -Partner2 --> API-Gateway +Client-1 --> API-Gateway +Client-2 --> API-Gateway +Client-3 --> API-Gateway +Partner-1 --> API-Gateway +Partner-2 --> API-Gateway API-Gateway --> Router -API-Gateway --> REST1 -API-Gateway --> REST2 -Router --> Subgraph1 -Router --> Subgraph2 -Subgraph1 --> gRPC1 -Subgraph1 --> DB1 -Subgraph1 --> REST2 -Subgraph2 --> DB2 +API-Gateway --> REST-1 +Router --> Subgraph-1 +Router --> REST-2 +Router --> JSON-1 +Subgraph-1 --> gRPC-1 +Subgraph-1 --> DB-1 ``` ## When to consider GraphOS Router -If you are running a GraphQL API in production, and you want to be able to: +If you are running APIs in production, and you want to be able to: -- [Monitor your GraphQL operations](/graphos/platform/insights/field-usage) in a way that other telemetry tools don't support while [continuing support for existing APM tools](/graphos/reference/router/telemetry/metrics-exporters/overview) -- [Safely publish new schemas without introducing a breaking change](/graphos/platform/schema-management/checks) -- [Secure your GraphQL schema](https://www.apollographql.com/docs/router/configuration/authn-jwt) -- [Extend the GraphQL runtime with custom features](https://www.apollographql.com/docs/router/customizations/overview) +- [Monitor your client operations and use](/graphos/platform/insights/field-usage) in a way that other telemetry tools don't support while [continuing support for existing APM tools](/graphos/reference/router/telemetry/metrics-exporters/overview) +- [Safely publish new API schemas without introducing a breaking change](/graphos/platform/schema-management/checks) +- [Secure your APIs with a declarative schema model](https://www.apollographql.com/docs/router/configuration/authn-jwt) +- [Extend the GraphOS Router runtime with custom features](https://www.apollographql.com/docs/router/customizations/overview) -And do all this with [minimal latency and scalable performance](https://www.apollographql.com/blog/announcement/backend/apollo-router-our-graphql-federation-runtime-in-rust) then consider adopting [GraphOS and the GraphOS Router](/graphos/) today! +And do all this with [minimal latency and scalable performance](https://www.apollographql.com/blog/announcement/backend/apollo-router-our-graphql-federation-runtime-in-rust) then consider adopting [GraphOS Studio and the GraphOS Router](/graphos/) today! diff --git a/docs/source/routing/security/authorization-overview.mdx b/docs/source/routing/security/authorization-overview.mdx new file mode 100644 index 0000000000..1cb1f0b6db --- /dev/null +++ b/docs/source/routing/security/authorization-overview.mdx @@ -0,0 +1,112 @@ +--- +title: Overview of Authorization in the GraphOS Router +subtitle: Strengthen subgraph security with a centralized governance layer +description: Enforce authorization in the GraphOS Router with the @requireScopes, @authenticated, and @policy directives. +--- + + + +Rate limits apply on the Free plan. + + + +APIs provide access to business-critical data. Unrestricted access can result in data breaches, monetary losses, or potential denial of service. Even for internal services, checks can be essential to limit data to authorized parties. + +Services may have their own access controls, but enforcing authorization _in the Apollo Router_ is valuable for a few reasons: + +- **Optimal query execution**: Validating authorization _before_ processing requests enables the early termination of unauthorized requests. Stopping unauthorized requests at the edge of your graph reduces the load on your services and enhances performance. + + ```mermaid + flowchart LR; + clients(Client); + subgraph Router[" "] + router(["GraphOS Router"]); + serviceB[Users
API]; + serviceC[Posts
API]; + end + router -.->|"❌ Subquery"| serviceB & serviceC; + clients -->|"⚠️Unauthorized
request"| router; + ``` + + - If every field in a particular subquery requires authorization, the router's [query planner](/router/customizations/overview#request-path) can _eliminate entire subgraph requests_ for unauthorized requests. For example, a request may have permission to view a particular user's posts on a social media platform but not have permission to view any of that user's personally identifiable information (PII). Check out [How it works](/graphos/routing/security/authorization#how-it-works) to learn more. + + ```mermaid + flowchart LR; + clients(Client); + subgraph Router[" "] + router(["GraphOS Router"]); + serviceB[Users
API]; + serviceC[Posts
API]; + end + router -->|"✅ Authorized
subquery"| serviceC; + router -.->|"❌ Unauthorized
subquery"| serviceB; + clients -->|"⚠️ Partially authorized
request"| router; + ``` + + - Also, [query deduplication](/router/configuration/traffic-shaping/#query-deduplication) groups requested fields based on their required authorization. Entire groups can be eliminated from the query plan if they don't have the correct authorization. + +- **Declarative access rules**: You define access controls at the field level, and GraphOS [composes](/graphos/routing/security/authorization#composition-and-federation) them across your services. These rules create graph-native governance without the need for an extra orchestration layer. + +- **Principled architecture**: Through composition, the router centralizes authorization logic while allowing for auditing at the service level. This centralized authorization is an initial checkpoint that other service layers can reinforce. + + ```mermaid + flowchart LR; + clients(Client); + Level2:::padding + subgraph Level1["
🔐 Router layer                                                   "] + router(["GraphOS Router"]); + subgraph Level2["🔐 Service layer"] + serviceB[Users
API]; + serviceC[Posts
API]; + end + end + + router -->|"Subquery"| serviceB & serviceC; + clients -->|"Request"| router; + + classDef padding padding-left:1em, padding-right:1em + ``` + + + +To learn more about why authorization is ideal at the router layer, watch Andrew Carlson's talk at Austin API Summit 2024: [Centralize Data Access Control with GraphQL](https://www.youtube.com/watch?v=ETyAPY4bsYY). + + + + + + + + + +## How access control works + +The GraphOS Router provides access controls via **authorization directives** that define access to specific fields and types across your supergraph: + +- The [`@requiresScopes`](/graphos/routing/security/authorization#requiresscopes) directive allows granular access control through the scopes you define. +- The [`@authenticated`](/graphos/routing/security/authorization#authenticated) directive allows access to the annotated field or type for _authenticated requests only_. +- The [`@policy`](/graphos/routing/security/authorization#policy) directive offloads authorization validation to a [Rhai script](/graphos/routing/customization/rhai/) or a [coprocessor](/router/customizations/coprocessor) and integrates the result in the router. It's useful when your authorization policies go beyond simple authentication and scopes. + +For example, imagine you're building a social media platform that includes a `Users` subgraph. You can use the [`@requiresScopes`](/graphos/routing/security/authorization#requiresscopes) directive to declare that viewing other users' information requires the `read:user` scope: + +```graphql +type Query { + users: [User!]! @requiresScopes(scopes: [["read:users"]]) +} +``` + +You can use the [`@authenticated`](/graphos/routing/security/authorization#authenticated) directive to declare that users must be logged in to update their own information: + +```graphql +type Mutation { + updateUser(input: UpdateUserInput!): User! @authenticated +} +``` + +You can define both directives—together or separately—at the field level to fine-tune your access controls. When directives are declared both on a field and the field's type, they will all be tried, and the field will be removed if any of them does not authorize it. +GraphOS [composes](/graphos/routing/security/authorization#composition-and-federation) restrictions into the supergraph schema so that each subgraph's restrictions are respected. +The router then enforces these directives on all incoming requests. + +## Next steps + +- Learn how to use [authorization directives](/graphos/routing/security/authorization) in your GraphQL schemas to secure access to your graphs. diff --git a/docs/source/routing/security/authorization.mdx b/docs/source/routing/security/authorization.mdx index 9ef9776743..dce4ca0408 100644 --- a/docs/source/routing/security/authorization.mdx +++ b/docs/source/routing/security/authorization.mdx @@ -1,106 +1,16 @@ --- title: Authorization in the GraphOS Router -subtitle: Strengthen subgraph security with a centralized governance layer +subtitle: Enforce authorization with schema directives description: Enforce authorization in the GraphOS Router with the @requireScopes, @authenticated, and @policy directives. --- - - -APIs provide access to business-critical data. Unrestricted access can result in data breaches, monetary losses, or potential denial of service. Even for internal services, checks can be essential to limit data to authorized parties. - -Services may have their own access controls, but enforcing authorization _in the Apollo Router_ is valuable for a few reasons: - -- **Optimal query execution**: Validating authorization _before_ processing requests enables the early termination of unauthorized requests. Stopping unauthorized requests at the edge of your graph reduces the load on your services and enhances performance. - - ```mermaid - flowchart LR; - clients(Client); - subgraph Router[" "] - router(["GraphOS Router"]); - serviceB[Users
API]; - serviceC[Posts
API]; - end - router -.->|"❌ Subquery"| serviceB & serviceC; - clients -->|"⚠️Unauthorized
request"| router; - ``` - - - If every field in a particular subquery requires authorization, the router's [query planner](/router/customizations/overview#request-path) can _eliminate entire subgraph requests_ for unauthorized requests. For example, a request may have permission to view a particular user's posts on a social media platform but not have permission to view any of that user's personally identifiable information (PII). Check out [How it works](#how-it-works) to learn more. - - ```mermaid - flowchart LR; - clients(Client); - subgraph Router[" "] - router(["GraphOS Router"]); - serviceB[Users
API]; - serviceC[Posts
API]; - end - router -->|"✅ Authorized
subquery"| serviceC; - router -.->|"❌ Unauthorized
subquery"| serviceB; - clients -->|"⚠️ Partially authorized
request"| router; - ``` - - Also, [query deduplication](/router/configuration/traffic-shaping/#query-deduplication) groups requested fields based on their required authorization. Entire groups can be eliminated from the query plan if they don't have the correct authorization. - -- **Declarative access rules**: You define access controls at the field level, and GraphOS [composes](#composition-and-federation) them across your services. These rules create graph-native governance without the need for an extra orchestration layer. - -- **Principled architecture**: Through composition, the router centralizes authorization logic while allowing for auditing at the service level. This centralized authorization is an initial checkpoint that other service layers can reinforce. - - ```mermaid - flowchart LR; - clients(Client); - Level2:::padding - subgraph Level1["
🔐 Router layer                                                   "] - router(["GraphOS Router"]); - subgraph Level2["🔐 Service layer"] - serviceB[Users
API]; - serviceC[Posts
API]; - end - end - - router -->|"Subquery"| serviceB & serviceC; - clients -->|"Request"| router; - - classDef padding padding-left:1em, padding-right:1em - ``` + - - -To learn more about why authorization is ideal at the router layer, watch Andrew Carlson's talk at Austin API Summit 2024: [Centralize Data Access Control with GraphQL](https://www.youtube.com/watch?v=ETyAPY4bsYY). - - - - - - +Rate limits apply on the Free plan. - - -## How access control works - -The GraphOS Router provides access controls via **authorization directives** that define access to specific fields and types across your supergraph: + -- The [`@requiresScopes`](#requiresscopes) directive allows granular access control through the scopes you define. -- The [`@authenticated`](#authenticated) directive allows access to the annotated field or type for _authenticated requests only_. -- The [`@policy`](#policy) directive offloads authorization validation to a [Rhai script](/graphos/routing/customization/rhai/) or a [coprocessor](/router/customizations/coprocessor) and integrates the result in the router. It's useful when your authorization policies go beyond simple authentication and scopes. - -For example, imagine you're building a social media platform that includes a `Users` subgraph. You can use the [`@requiresScopes`](#requiresscopes) directive to declare that viewing other users' information requires the `read:user` scope: - -```graphql -type Query { - users: [User!]! @requiresScopes(scopes: [["read:users"]]) -} -``` - -You can use the [`@authenticated`](#authenticated) directive to declare that users must be logged in to update their own information: - -```graphql -type Mutation { - updateUser(input: UpdateUserInput!): User! @authenticated -} -``` - -You can define both directives—together or separately—at the field level to fine-tune your access controls. When directives are declared both on a field and the field's type, they will all be tried, and the field will be removed if any of them does not authorize it. -GraphOS [composes](#composition-and-federation) restrictions into the supergraph schema so that each subgraph's restrictions are respected. -The router then enforces these directives on all incoming requests. +Learn how to secure access to your graph via the router by using authorization directives in your GraphQL schemas. ## Prerequisites @@ -111,6 +21,7 @@ Only the GraphOS Router supports authorization directives—[`@apollo/gatewa Before using the authorization directives in your subgraph schemas, you must: + - Validate that your GraphOS Router uses version `1.29.1` or later and is [connected to your GraphOS Enterprise organization](/router/enterprise-features/#enabling-enterprise-features) - Include **[claims](#configure-request-claims)** in requests made to the router (for `@authenticated` and `@requiresScopes`) @@ -120,8 +31,8 @@ Claims are the individual details of a request's authentication and scope. They To provide the router with the claims it needs, you must either configure JSON Web Token (JWT) authentication or add an external coprocessor that adds claims to a request's context. In some cases (explained below), you may require both. -- **JWT authentication configuration**: If you configure [JWT authentication](/router/configuration/authn-jwt), the GraphOS Router [automatically adds a JWT token's claims](/router/configuration/authn-jwt#working-with-jwt-claims) to the request's context at the `apollo_authentication::JWT::claims` key. -- **Adding claims via coprocessor**: If you can't use JWT authentication, you can [add claims with a coprocessor](/router/customizations/coprocessor#adding-authorization-claims-via-coprocessor). Coprocessors let you hook into the GraphOS Router's request-handling lifecycle with custom code. +- **JWT authentication configuration**: If you configure [JWT authentication](/router/configuration/authn-jwt), the GraphOS Router [automatically adds a JWT token's claims](/router/configuration/authn-jwt#working-with-jwt-claims) to the request's context at the `apollo::authentication::jwt_claims` key. +- **Adding claims via coprocessor**: If you can't use JWT authentication, you can [add claims with a coprocessor](/router/customizations/coprocessor#adding-authorization-claims-via-coprocessor). Coprocessors let you hook into the GraphOS Router's request-handling lifecycle with custom code. - **Augmenting JWT claims via coprocessor**: Your authorization policies may require information beyond what your JSON web tokens provide. For example, a token's claims may include user IDs, which you then use to look up user roles. For situations like this, you can [augment the claims](/router/configuration/authn-jwt#claim-augmentation-via-coprocessors) from your JSON web tokens with coprocessors. ## Authorization directives @@ -134,13 +45,11 @@ authorization: enabled: false ``` - - ### `@requiresScopes` - + -The `@requiresScopes` directive marks fields and types as restricted based on required scopes. +The `@requiresScopes` directive marks fields and types as restricted based on required scopes. The directive includes a `scopes` argument with an array of the required scopes to declare which scopes are required: ```graphql @@ -151,7 +60,7 @@ The directive includes a `scopes` argument with an array of the required scopes Use `@requiresScopes` when access to a field or type depends only on claims associated with a claims object or access token. -If your authorization validation logic or data are more complex—such as checking specific values in headers or looking up data from other sources such as databases—and aren't solely based on a claims object or access token, use [`@policy`](#policy) instead. +If your authorization validation logic or data are more complex—such as checking specific values in headers or looking up data from other sources such as databases—and aren't solely based on a claims object or access token, use [`@policy`](#policy) instead. @@ -159,24 +68,24 @@ Depending on the scopes present on the request, the router filters out unauthori > You can use Boolean logic to define the required scopes. See [Combining required scopes](#combining-required-scopes-with-andor-logic) for details. -The directive validates the required scopes by loading the claims object at the `apollo_authentication::JWT::claims` key in a request's context. +The directive validates the required scopes by loading the claims object at the `apollo::authentication::jwt_claims` key in a request's context. The claims object's `scope` key's value should be a space-separated string of scopes in the format defined by the [OAuth2 RFC for access token scopes](https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). ```rhai -claims = context["apollo_authentication::JWT::claims"] +claims = context["apollo::authentication::jwt_claims"] claims["scope"] = "scope1 scope2 scope3" ``` -If the `apollo_authentication::JWT::claims` object holds scopes in another format, for example, an array of strings, or at a key other than `"scope"`, you can edit the claims with a [Rhai script](/graphos/routing/customization/rhai). +If the `apollo::authentication::jwt_claims` object holds scopes in another format, for example, an array of strings, or at a key other than `"scope"`, you can edit the claims with a [Rhai script](/graphos/routing/customization/rhai). The example below extracts an array of scopes from the `"roles"` claim and reformats them as a space-separated string. ```Rhai fn router_service(service) { let request_callback = |request| { - let claims = request.context["apollo_authentication::JWT::claims"]; + let claims = request.context["apollo::authentication::jwt_claims"]; let roles = claims["roles"]; let scope = ""; @@ -192,7 +101,7 @@ fn router_service(service) { } claims["scope"] = scope; - request.context["apollo_authentication::JWT::claims"] = claims; + request.context["apollo::authentication::jwt_claims"] = claims; }; service.map_request(request_callback); } @@ -215,7 +124,9 @@ It is defined as follows: ```graphql scalar federation__Scope -directive @requiresScopes(scopes: [[federation__Scope!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM +directive @requiresScopes( + scopes: [[federation__Scope!]!]! +) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM ``` #### Combining required scopes with `AND`/`OR` logic @@ -244,7 +155,6 @@ You can nest arrays and elements as needed to achieve your desired logic. For ex This syntax requires requests to have either (`scope1` **AND** `scope2`) **OR** just `scope3` to be authorized. - #### Example `@requiresScopes` use case Imagine the social media platform you're building lets users view other users' information only if they have the required permissions. @@ -308,26 +218,21 @@ The router returns `null` for unauthorized fields and applies the [standard Grap { "data": { "me": null, - "post": { - "title": "Securing supergraphs", - } + "post": { + "title": "Securing supergraphs" + } }, "errors": [ { "message": "Unauthorized field or type", - "path": [ - "me" - ], + "path": ["me"], "extensions": { "code": "UNAUTHORIZED_FIELD_OR_TYPE" } }, { "message": "Unauthorized field or type", - "path": [ - "post", - "views" - ], + "path": ["post", "views"], "extensions": { "code": "UNAUTHORIZED_FIELD_OR_TYPE" } @@ -336,14 +241,12 @@ The router returns `null` for unauthorized fields and applies the [standard Grap } ``` - - ### `@authenticated` - + The `@authenticated` directive marks specific fields and types as requiring authentication. -It works by checking for the `apollo_authentication::JWT::claims` key in a request's context, that is added either by the JWT authentication plugin, when the request contains a valid JWT, or by an authentication coprocessor. +It works by checking for the `apollo::authentication::jwt_claims` key in a request's context, that is added either by the JWT authentication plugin, when the request contains a valid JWT, or by an authentication coprocessor. If the key exists, it means the request is authenticated, and the router executes the query in its entirety. If the request is unauthenticated, the router removes `@authenticated` fields before planning the query and only executes the parts of the query that don't require authentication. @@ -392,7 +295,6 @@ type Post { content: String! views: Int @authenticated #highlight-line } - ``` Consider the following query: @@ -443,26 +345,21 @@ The response retains the initial request's shape but returns `null` for unauthor { "data": { "me": null, - "post": { - "title": "Securing supergraphs", - } + "post": { + "title": "Securing supergraphs" + } }, "errors": [ { "message": "Unauthorized field or type", - "path": [ - "me" - ], + "path": ["me"], "extensions": { "code": "UNAUTHORIZED_FIELD_OR_TYPE" } }, { "message": "Unauthorized field or type", - "path": [ - "post", - "views" - ], + "path": ["post", "views"], "extensions": { "code": "UNAUTHORIZED_FIELD_OR_TYPE" } @@ -473,11 +370,9 @@ The response retains the initial request's shape but returns `null` for unauthor If _every_ requested field requires authentication and a request is unauthenticated, the router generates an error indicating that the query is unauthorized. - - ### `@policy` - + The `@policy` directive marks fields and types as restricted based on authorization policies evaluated in a [Rhai script](/graphos/routing/customization/rhai/) or [coprocessor](/router/customizations/coprocessor). This enables custom authorization validation beyond authentication and scopes. It is useful when we need more complex policy evaluation than verifying the presence of a claim value in a list (example: checking specific values in headers). @@ -497,12 +392,12 @@ Using the `@policy` directive requires a [Supergraph plugin](/router/customizati An overview of how `@policy` is processed through the router's request lifecycle: -* At the [`RouterService` level](/router/customizations/overview#the-request-lifecycle), the GraphOS Router extracts the list of policies relevant to a request from the schema and then stores them in the request's context in `apollo_authorization::policies::required` as a map `policy -> null|true|false`. +- At the [`RouterService` level](/graphos/routing/request-lifecycle), the GraphOS Router extracts the list of policies relevant to a request from the schema and then stores them in the request's context in `apollo::authorization::required_policies` as a map `policy -> null|true|false`. -* At the `SupergraphService` level, you must provide a Rhai script or coprocessor to evaluate the map. -If the policy is validated, the script or coprocessor should set its value to `true` or otherwise set it to `false`. If the value is left to `null`, it will be treated as `false` by the router. Afterward, the router filters the requests' types and fields to only those where the policy is `true`. +- At the `SupergraphService` level, you must provide a Rhai script or coprocessor to evaluate the map. + If the policy is validated, the script or coprocessor should set its value to `true` or otherwise set it to `false`. If the value is left to `null`, it will be treated as `false` by the router. Afterward, the router filters the requests' types and fields to only those where the policy is `true`. -* If no field of a subgraph query passes its authorization policies, the router stops further processing of the query and precludes unauthorized subgraph requests. This efficiency gain is a key benefit of the `@policy` and other authorization directives. +- If no field of a subgraph query passes its authorization policies, the router stops further processing of the query and precludes unauthorized subgraph requests. This efficiency gain is a key benefit of the `@policy` and other authorization directives. #### Usage @@ -519,7 +414,9 @@ The `@policy` directive is defined as follows: ```graphql scalar federation__Policy -directive @policy(policies: [[federation__Policy!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM +directive @policy( + policies: [[federation__Policy!]!]! +) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM ``` Using the `@policy` directive requires a [Supergraph plugin](/router/customizations/overview) to evaluate the authorization policies. You can do this with a [Rhai script](/graphos/routing/customization/rhai/) or [coprocessor](/router/customizations/coprocessor). Refer to the following [example use case](#example-policy-use-case) for more information. (Although a [native plugin](/router/customizations/native) can also evaluate authorization policies, we don't recommend using it.) @@ -573,7 +470,6 @@ type Post { content: String! views: Int @authenticated } - ``` You can use a [coprocessor](/router/customizations/coprocessor) called at the Supergraph request stage to receive and execute the list of policies. @@ -585,30 +481,30 @@ coprocessor: url: http://127.0.0.1:8081 supergraph: request: - context: true + context: all ``` A coprocessor can then receive a request with this format: ```json { - "version": 1, - "stage": "SupergraphRequest", - "control": "continue", - "id": "d0a8245df0efe8aa38a80dba1147fb2e", - "context": { - "entries": { - "apollo_authentication::JWT::claims": { - "exp": 10000000000, - "sub": "457f6bb6-789c-4e8b-8560-f3943a09e72a" - }, - "apollo_authorization::policies::required": { - "read_profile": null, - "read_credit_card": null - } - } - }, - "method": "POST" + "version": 1, + "stage": "SupergraphRequest", + "control": "continue", + "id": "d0a8245df0efe8aa38a80dba1147fb2e", + "context": { + "entries": { + "apollo::authentication::jwt_claims": { + "exp": 10000000000, + "sub": "457f6bb6-789c-4e8b-8560-f3943a09e72a" + }, + "apollo::authorization::required_policies": { + "read_profile": null, + "read_credit_card": null + } + } + }, + "method": "POST" } ``` @@ -616,28 +512,28 @@ A user can read their own profile, so `read_profile` will succeed. But only the ```json { - "version": 1, - "stage": "SupergraphRequest", - "control": "continue", - "id": "d0a8245df0efe8aa38a80dba1147fb2e", - "context": { - "entries": { - "apollo_authentication::JWT::claims": { - "exp": 10000000000, - "sub": "457f6bb6-789c-4e8b-8560-f3943a09e72a" - }, - "apollo_authorization::policies::required": { - "read_profile": true, - "read_credit_card": false - } - } + "version": 1, + "stage": "SupergraphRequest", + "control": "continue", + "id": "d0a8245df0efe8aa38a80dba1147fb2e", + "context": { + "entries": { + "apollo::authentication::jwt_claims": { + "exp": 10000000000, + "sub": "457f6bb6-789c-4e8b-8560-f3943a09e72a" + }, + "apollo::authorization::required_policies": { + "read_profile": true, + "read_credit_card": false + } } + } } ``` ##### Usage with a Rhai script -For another example, suppose that you want to restrict access for posts to a support user. Given that the `policies` argument is a string, you can set it as a `":"` format that a Rhai script can parse and evaluate. +For another example, suppose that you want to restrict access for posts to a support user. Given that the `policies` argument is a string, you can set it as a `":"` format that a Rhai script can parse and evaluate. The relevant part of your schema may look like this: @@ -657,8 +553,8 @@ You can then use the following Rhai script to parse and evaluate the `policies` ```rhai fn supergraph_service(service) { let request_callback = |request| { - let claims = request.context["apollo_authentication::JWT::claims"]; - let policies = request.context["apollo_authorization::policies::required"]; + let claims = request.context["apollo::authentication::jwt_claims"]; + let policies = request.context["apollo::authorization::required_policies"]; if policies != () { for key in policies.keys() { @@ -676,7 +572,7 @@ fn supergraph_service(service) { } } } - request.context["apollo_authorization::policies::required"] = policies; + request.context["apollo::authorization::required_policies"] = policies; }; service.map_request(request_callback); } @@ -695,7 +591,6 @@ GraphOS's composition strategy for authorization directives is intentionally acc If a shared field uses different authorization directives across subgraphs, composition merges them using `AND` logic. For example, suppose the `me` query requires `@authenticated` in one subgraph and the `read:user` scope in another subgraph: - ```graphql title="Subgraph A" type Query { me: User @authenticated @@ -724,7 +619,7 @@ A request must both be authenticated **AND** have the required `read:user` scope -Recall that the `@authenticated` directive only checks for the existence of the `apollo_authentication::JWT::claims` key in a request's context, so authentication is guaranteed if the request includes scopes. +Recall that the `@authenticated` directive only checks for the existence of the `apollo::authentication::jwt_claims` key in a request's context, so authentication is guaranteed if the request includes scopes. @@ -763,8 +658,8 @@ Refer to the section on [Combining policies with AND/OR logic](#combining-polici Using **OR** logic for shared directives simplifies schema updates. If requirements change suddenly, you don't need to update the directive in all subgraphs simultaneously. - #### Combining `AND`/`OR` logic with `@requiresScopes` + As with [combining scopes for a single use of [`@requiresScopes`](#combining-required-scopes-with-andor-logic), you can use nested arrays to introduce **AND** logic in a single subgraph: ```graphql title="Subgraph A" @@ -783,7 +678,8 @@ Since both subgraphs use the same authorization directive, composition [merges t ```graphql title="Supergraph" type Query { - users: [User!]! @requiresScopes(scopes: [["read:others", "read:users"], ["read:profiles"]]) + users: [User!]! + @requiresScopes(scopes: [["read:others", "read:users"], ["read:profiles"]]) } ``` @@ -982,6 +878,14 @@ authorization: dry_run: true # default: false ``` -## Related topics +## Additional resources + +Refer to the guide on [authenticating requests with the GraphOS Router](/graphos/routing/security/router-authentication) for an overview of authorization and authentication techniques. + +- See the Apollo Solutions [auth coprocessor example](https://github.com/apollosolutions/example-coprocessor-custom-auth-directive) for how to set up a JavaScript coprocessor that applies custom auth checks. +- See the Apollo Solutions [`@policy` coprocessor example](https://github.com/apollosolutions/example-coprocessor-auth-policy) for how to set up a JavaScript coprocessor that evaluates policy-based authorization +- See the following Apollo Solutions repositories for examples of how to use JWT authentication with the `@requiresScopes` directive: + - [Standard JWT authentication and authorization](https://github.com/apollosolutions/example-jwtauthentication) + - [Non-standard authorization](https://github.com/apollosolutions/example-rhai-normalizejwtscopes) with Rhai scripts -* [Authenticating requests with the GraphOS Router](/technotes/TN0004-router-authentication/) + diff --git a/docs/source/routing/security/cors.mdx b/docs/source/routing/security/cors.mdx index 82e4c7ccf9..e44b711ff2 100644 --- a/docs/source/routing/security/cors.mdx +++ b/docs/source/routing/security/cors.mdx @@ -15,9 +15,9 @@ description: Manage browser access to your Apollo GraphOS Router or Apollo Route By default, the router enables _only_ GraphOS Studio to initiate browser connections to it. If your supergraph serves data to other browser-based applications, you need to do one of the following in the `cors` section of your router's [YAML config file](/router/configuration/overview/#yaml-config-file): -* Add the origins of those web applications to the router's list of allowed `origins`. +* Add the origins of those web applications to the router's list of allowed `policies`. * Use this option if there is a known, finite list of web applications that consume your supergraph. -* Add a regex that matches the origins of those web applications to the router's list of allowed `origins`. +* Add a regex that matches the origins of those web applications to the router's list of allowed `policies`. * This option comes in handy if you want to match origins against a pattern, see the example below that matches subdomains of a specific namespace. * Enable the `allow_any_origin` option. * Use this option if your supergraph is a public API with arbitrarily many web app consumers. @@ -40,18 +40,19 @@ cors: # # An origin is a combination of scheme, hostname and port. # It does not have any path section, so no trailing slash. - origins: - - https://www.your-app.example.com - - https://studio.apollographql.com # Keep this so GraphOS Studio can run queries against your router - match_origins: - - "^https://([a-z0-9]+[.])*api[.]example[.]com$" # any host that uses https and ends with .api.example.com + policies: + - origins: + - https://www.your-app.example.com + - https://studio.apollographql.com # Keep this so GraphOS Studio can run queries against your router + match_origins: + - "^https://([a-z0-9]+[.])*api[.]example[.]com$" # any host that uses https and ends with .api.example.com ``` -You can also disable CORS entirely by setting `origins` to an empty list: +You can also disable CORS entirely by setting `policies` to an empty list: ```yml title="router.yaml" cors: - origins: [] + policies: [] ``` If your router serves exclusively _non_-browser-based clients, you probably don't need to modify the default CORS configuration. @@ -66,9 +67,10 @@ To allow browsers to pass credentials to the router, set `allow_credentials` to ```yaml {6} title="router.yaml" cors: - origins: - - https://www.your-app.example.com - - https://studio.apollographql.com + policies: + - origins: + - https://www.your-app.example.com + - https://studio.apollographql.com allow_credentials: true ``` @@ -91,6 +93,19 @@ headers: For examples of sending cookies and authorization headers from Apollo Client, see [Authentication](/react/networking/authentication/). +## Policy inheritance + +Individual policies within the `policies` array inherit global CORS settings unless explicitly overridden: + +- **Allow credentials**: Policies inherit the global `allow_credentials` setting unless they specify their own value +- **Allow headers**: Policies inherit global headers if their `allow_headers` is empty, otherwise use policy-specific headers +- **Expose headers**: Policies inherit global headers if their `expose_headers` is empty, otherwise use policy-specific headers +- **Methods**: Policies have three inheritance states: + - Not specified (`null`): Inherits global `methods` + - Empty array (`[]`): No methods allowed for this policy + - Specific values: Uses those exact methods +- **Max age**: Policies inherit the global `max_age` unless they specify their own value + ## All `cors` options The following snippet shows all CORS configuration defaults for the router: @@ -109,8 +124,9 @@ cors: # # An origin is a combination of scheme, hostname and port. # It does not have any path section, so no trailing slash. - origins: - - https://studio.apollographql.com # Keep this so GraphOS Studio can still run queries against your router + policies: + - origins: + - https://studio.apollographql.com # Keep this so GraphOS Studio can still run queries against your router # Set to true to add the `Access-Control-Allow-Credentials` header allow_credentials: false diff --git a/docs/source/routing/security/csrf.mdx b/docs/source/routing/security/csrf.mdx index 61fb81e79f..9f7a9bda5c 100644 --- a/docs/source/routing/security/csrf.mdx +++ b/docs/source/routing/security/csrf.mdx @@ -2,7 +2,7 @@ title: Cross-Site Request Forgery (CSRF) Prevention subtitle: Prevent CSRF attacks in the router description: Prevent cross-site request forgery (CSRF) attacks in the Apollo GraphOS Router or Apollo Router Core. -minVersion: 0.9.0 +minVersion: Router v0.9.0 --- ## About CSRF diff --git a/docs/source/routing/security/demand-control.mdx b/docs/source/routing/security/demand-control.mdx index d4ef25212d..70a000d26d 100644 --- a/docs/source/routing/security/demand-control.mdx +++ b/docs/source/routing/security/demand-control.mdx @@ -2,10 +2,14 @@ title: Demand Control subtitle: Protect your graph from high-cost GraphQL operations description: Protect your graph from malicious or demanding clients with Apollo GraphOS Router's demand control features. Estimate, calculate, observe, and reject high-cost GraphQL operations. -minVersion: 1.48.0 +minVersion: Router v1.48.0 --- - + + +Rate limits apply on the Free plan. + + ## What is demand control? @@ -433,3 +437,46 @@ You can also chart the percentage of operations that would be allowed or rejecte src="../../images/demand-control-example-cost-result.png" width="600" /> + +## Accessing programmatically + +You can programmatically access demand control cost data using [Rhai scripts](/routing/customization/rhai) or Coprocessors. This can be useful for custom logging, decision making, or exposing cost data to clients. + +### Exposing cost in response headers + +It's possible to expose cost information in the HTTP response payload returned to clients, which can be useful for debugging. This can be accomplished via a Rhai script on the `supergraph_service` hook: + +```rust +fn supergraph_service(service) { + service.map_response(|response| { + if response.is_primary() { + try { + // Get cost estimation values from context + let estimated_cost = response.context[Router.APOLLO_COST_ESTIMATED_KEY]; + let actual_cost = response.context[Router.APOLLO_COST_ACTUAL_KEY]; + let strategy = response.context[Router.APOLLO_COST_STRATEGY_KEY]; + let result = response.context[Router.APOLLO_COST_RESULT_KEY]; + + // Add them as response headers + if estimated_cost != () { + response.headers["apollo-cost-estimate"] = estimated_cost.to_string(); + } + + if actual_cost != () { + response.headers["apollo-cost-actual"] = actual_cost.to_string(); + } + + if strategy != () { + response.headers["apollo-cost-strategy"] = strategy.to_string(); + } + + if result != () { + response.headers["apollo-cost-result"] = result.to_string(); + } + } catch(err) { + log_debug(`Could not add cost headers: ${err}`); + } + } + }); +} +``` diff --git a/docs/source/routing/security/jwt.mdx b/docs/source/routing/security/jwt.mdx index 4083721245..51148bf339 100644 --- a/docs/source/routing/security/jwt.mdx +++ b/docs/source/routing/security/jwt.mdx @@ -1,10 +1,14 @@ --- title: JWT Authentication in the GraphOS Router subtitle: Restrict access to credentialed users and systems -description: Protect sensitive data by enabling JWT authentication in the Apollo GraphOS Router. Restrict access to credentialed users and systems. +description: Protect sensitive data by enabling JWT authentication in the Apollo GraphOS Router. Restrict access to credentialed users and systems. --- - + + +Rate limits apply on the Free plan. + + Authentication is crucial to prevent illegitimate access and protect sensitive data in your graph. The GraphOS Router supports request authentication and key rotation via the [JSON Web Token](https://www.rfc-editor.org/rfc/rfc7519) (**JWT**) and [JSON Web Key](https://www.rfc-editor.org/rfc/rfc7517) (**JWK**) standards. This support is compatible with popular identity providers (**IdPs**) like Okta and Auth0. @@ -29,8 +33,9 @@ These are the high-level steps of JWT-based authentication with the GraphOS Rout - Your router obtains all of its known JWKs from URLs that you specify in its configuration file. Each URL provides its keys within a single JSON object called a [JWK Set](https://www.rfc-editor.org/rfc/rfc7517#section-5) (or a **JWKS**). - **If validation fails, the router rejects the request.** This can occur if the JWT is malformed, or if it's been expired for more than 60 seconds (this window accounts for synchronization issues). -5. The router extracts all **claims** from the validated JWT and includes them in the request's context, making them available to your [router customizations](/router/customizations/overview/), such as Rhai scripts. -6. Your customizations can handle the request differently depending on the details of the extracted claims, and/or you can propagate the claims to subgraphs to enable more granular access control. +5. The router extracts all **claims** from the validated JWT and includes them in the request's context (`apollo::authentication::jwt_claims`), making them available to your [router customizations](/router/customizations/overview/), such as Rhai scripts. +6. The router will insert the status of JWT processing into the request context (`apollo::authentication::jwt_status`). This status is informational and may be used for logging or debugging purposes. +7. Your customizations can handle the request differently depending on the details of the extracted claims, and/or you can propagate the claims to subgraphs to enable more granular access control. - For examples, [see below](#working-with-jwt-claims). ## Turning it on @@ -47,7 +52,9 @@ Otherwise, if you issue JWTs via a popular third-party IdP (Auth0, Okta, PingOne jwt: jwks: # This key is required. - url: https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json - issuer: + issuers: # optional list of issuers + - https://issuer.one + - https://issuer.two poll_interval: headers: # optional list of static headers added to the HTTP request to the JWKS URL - name: User-Agent @@ -55,6 +62,7 @@ Otherwise, if you issue JWTs via a popular third-party IdP (Auth0, Okta, PingOne # These keys are optional. Default values are shown. header_name: Authorization header_value_prefix: Bearer + on_error: Error # array of alternative token sources sources: - type: header @@ -108,7 +116,7 @@ The following configuration options are supported: - `url`: **required** URL from which the JWKS file will be read. Must be a valid URL. - **If you use a third-party IdP,** consult its documentation to determine its JWKS URL. - **If you use your own custom IdP,** you need to make its JWKS available at a router-accessible URL if you haven't already. For more information, see [Creating your own JWKS](#creating-your-own-jwks-advanced). -- `issuer`: **optional** name of the issuer, that will be compared to the `iss` claim in the JWT if present. If it does not match, the request will be rejected. +- `issuers`: **optional** list of issuers accepted, that will be compared to the `iss` claim in the JWT if present. If none match, the request will be rejected. - `algorithms`: **optional** list of accepted algorithms. Possible values are `HS256`, `HS384`, `HS512`, `ES256`, `ES384`, `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `EdDSA` - `poll_interval`: **optional** interval in human-readable format (e.g. `60s` or `1hour 30s`) at which the JWKS will be polled for changes. If not specified, the JWKS endpoint will be polled every 60 seconds. - `headers`: **optional** a list of headers sent when downloading from the JWKS URL @@ -149,6 +157,51 @@ The default value is `Bearer`. +##### `on_error` + + + + +This setting controls the behavior of the router when an error occurs during JWT validation. Possible values are `Error` (default) and `Continue`. + +- `Error`: The router responds with an error when an error occurs during JWT validation. +- `Continue`: The router continues processing the request when an error occurs during JWT validation. Requests with invalid JWTs will be treated as unauthenticated. + +Regardless of whether JWT authentication succeeds, the status of JWT processing is inserted into the request context (`apollo::authentication::jwt_status`). This status is informational and may be used for logging or debugging purposes. + +```js +// On failure +{ + // Whether the JWT came from a header or cookie source + type: string, + // The name of the source's field + name: string, + // Error details + error: { + // A user-friendly error message + message: string, + // A machine-readable error code + code: string, + // The underlying reason for the error, if any + reason: string? + } +} + +// On success +{ + // Whether the JWT came from a header or cookie source + type: string, + // The name of the source's field + name: string +} +``` + + + + + + + ##### `sources` @@ -201,10 +254,12 @@ The default value is `false`. ## Working with JWT claims -After the GraphOS Router validates a client request's JWT, it adds that token's **claims** to the request's context at this key: `apollo_authentication::JWT::claims` +After the GraphOS Router validates a client request's JWT, it adds that token's **claims** to the request's context at this key: `apollo::authentication::jwt_claims` > - If no JWT is present for a client request, this context value is the empty tuple, `()`. -> - If a JWT _is_ present but validation of the JWT fails, the router _rejects_ the request. +> - If a JWT _is_ present but validation of the JWT fails, +> - When `on_error` is set to `Error`, the router _rejects_ the request. +> - When `on_error` is set to `Continue`, the router _continues_ processing the request, and the context value is the empty tuple, `()`. If unauthenticated requests should be rejected, the router can be configured like this: @@ -299,7 +354,7 @@ This script should be run in the router's `SupergraphService`, which executes be ```rhai title="claims_validation.rhai" fn process_request(request) { // Router.APOLLO_AUTHENTICATION_JWT_CLAIMS is a Rhai-scope - // constant with value `apollo_authentication::JWT::claims` + // constant with value `apollo::authentication::jwt_claims` let claims = request.context[Router.APOLLO_AUTHENTICATION_JWT_CLAIMS]; if claims == () || !claims.contains("iss") || claims["iss"] != "https://idp.local" { throw #{ @@ -368,7 +423,7 @@ coprocessor: url: http://127.0.0.1:8081 router: request: - context: true + context: all ``` The router sends requests to the coprocessor with this format: @@ -381,7 +436,7 @@ The router sends requests to the coprocessor with this format: "id": "d0a8245df0efe8aa38a80dba1147fb2e", "context": { "entries": { - "apollo_authentication::JWT::claims": { + "apollo::authentication::jwt_claims": { "exp": 10000000000, "sub": "457f6bb6-789c-4e8b-8560-f3943a09e72a" } @@ -401,7 +456,7 @@ The coprocessor can then look up the user with the identifier specified in the ` "id": "d0a8245df0efe8aa38a80dba1147fb2e", "context": { "entries": { - "apollo_authentication::JWT::claims": { + "apollo::authentication::jwt_claims": { "exp": 10000000000, "sub": "457f6bb6-789c-4e8b-8560-f3943a09e72a", "scope": "profile:read profile:write" @@ -745,19 +800,10 @@ If you _do_ need to pass entire JWTs to subgraphs, you can do so via the GraphOS If your router enables [tracing](/router/configuration/telemetry/exporters/tracing/overview), the JWT authentication plugin has its own tracing span: `authentication_plugin` -If your router enables [metrics collection via Prometheus](/router/configuration/telemetry/exporters/metrics/prometheus), the JWT authentication plugin provides and exports the following metrics: +If your router [exports metrics](/graphos/routing/observability/telemetry/metrics-exporters/overview), the JWT authentication plugin exports the `apollo.router.operations.authentication.jwt` metric. You can use the metric's `authentication.jwt.failed` attribute to count failed authentications. If the `authentication.jwt.failed` attribute is absent or `false`, the authentication succeeded. -- `apollo_authentication_failure_count` -- `apollo_authentication_success_count` +## Additional resources -Those metrics have the following shapes: +You can use the Apollo Solutions [router JWKS generator](https://github.com/apollosolutions/router-jwks-generator) to create a router configuration file for use with the authentication plugin. -``` -# HELP apollo_authentication_failure_count apollo_authentication_failure_count -# TYPE apollo_authentication_failure_count counter -apollo_authentication_failure_count{kind="JWT",service_name="apollo-router"} 1 - -# HELP apollo_authentication_success_count apollo_authentication_success_count -# TYPE apollo_authentication_success_count counter -apollo_authentication_success_count{kind="JWT",service_name="apollo-router"} 11 -``` + diff --git a/docs/source/routing/security/persisted-queries.mdx b/docs/source/routing/security/persisted-queries.mdx index 5befd7071c..49c02b3eed 100644 --- a/docs/source/routing/security/persisted-queries.mdx +++ b/docs/source/routing/security/persisted-queries.mdx @@ -2,10 +2,14 @@ title: Safelisting with Persisted Queries subtitle: Secure your graph while minimizing request latency description: Secure your federated GraphQL API by creating an allowlist of trusted operations. Minimize request latency and enhance performance. -minVersion: 1.25.0 +minVersion: Router v1.25.0 --- - + + +Rate limits apply on the Free plan. + + @@ -47,6 +51,8 @@ From version `1.25.0` to `1.32.0`, the `persisted_queries` configuration option #### `persisted_queries` + + This base configuration enables the feature. All other configuration options build off this one. ```yaml title="router.yaml" @@ -56,6 +62,8 @@ persisted_queries: #### `log_unknown` + + Adding `log_unknown: true` to `persisted_queries` configures the router to log any incoming operations not registered to the PQL. ```yaml title="router.yaml" @@ -64,39 +72,70 @@ persisted_queries: log_unknown: true ``` -If used with the [`safelist`](#safelist) option, the router logs unregistered and rejected operations. With [`safelist.required_id`](#require_id) off, the only rejected operations are unregistered ones. If [`safelist.required_id`](#require_id) is turned on, operations can be rejected even when registered because they use operation IDs rather than operation strings. +If used with the [`safelist`](#safelist) option, the router logs unregistered and rejected operations. With [`safelist.require_id`](#require_id) off, the only rejected operations are unregistered ones. If [`safelist.require_id`](#require_id) is turned on, operations can be rejected even when registered because they use operation IDs rather than operation strings. #### `experimental_prewarm_query_plan_cache` - +
+ + +
-By default, the router [prewarms the query plan cache](/router/configuration/in-memory-caching#cache-warm-up) using all operations on the PQL when a new schema is loaded, but not at startup. Using the `experimental_prewarm_query_plan_cache` option, you can tell the router to prewarm the cache using the PQL on startup as well, or tell it not to prewarm the cache when reloading the schema. (This does not affect whether the router prewarms the query plan cache with recently-used operations from its in-memory cache.) Prewarming the cache means can reduce request latency by ensuring that operations are pre-planned when requests are received, but can make startup or schema reloads slower. +By default, the router [prewarms the query plan cache](/router/configuration/in-memory-caching#cache-warm-up) using all operations on the PQL when a new schema is loaded, but not at startup. Using the `experimental_prewarm_query_plan_cache` option, you can tell the router to prewarm the cache using the PQL on startup as well, or tell it not to prewarm the cache when reloading the schema. (This does not affect whether the router prewarms the query plan cache with recently-used operations from its in-memory cache.) Prewarming the cache can reduce request latency by ensuring that operations are pre-planned when requests are received, but can make startup or schema reloads slower. ```yaml title="router.yaml" persisted_queries: enabled: true experimental_prewarm_query_plan_cache: - on_startup: true # default: false - on_reload: false # default: true + on_startup: true # default: false + on_reload: false # default: true ``` -#### `experimental_local_manifests` +#### `local_manifests` + + - + + +From version `1.50.0` to `1.54`, the `local_manifests` configuration option was named `experimental_local_manifests`. Upgrade your router to version `1.55.0` or later to use the [generally available](/resources/product-launch-stages/#general-availability) version of the feature and the example configuration snippet below. -Adding `experimental_local_manifests` to your `persisted-queries` configuration lets you use local persisted query manifests instead of the hosted Uplink version. This is helpful when you're using an offline Enterprise license and can't use Uplink. With the `experimental_local_manifests`, the router doesn't reload the manifest from the file system, so you need to restart the router to apply changes. + + +Adding `local_manifests` to your `persisted-queries` configuration lets you use local persisted query manifests instead of the hosted Uplink version. This is helpful when you're using an [offline Enterprise license](/graphos/routing/license/#offline-license) and can't use Uplink. With `local_manifests`, the router doesn't reload the manifest from the file system, so you need to restart the router to apply changes. ```yaml title="router.yaml" persisted_queries: enabled: true - experimental_local_manifests: + local_manifests: - ./path/to/persisted-query-manifest.json ``` -You can download a version of your manifest to use locally from [GraphOS Studio](https://studio.apollographql.com/?referrer=docs-content). Open the PQL page for a graph by clicking the **Go to persisted query lists** to the left of the graph's name. Then, click the ••• menu under the **Actions** column to download a PQL's manifest as a JSON file. Save this file locally and update your `experimental_local_manifests` configuration with the path the file. +You can download a version of your manifest to use locally from [GraphOS Studio](https://studio.apollographql.com/?referrer=docs-content). Open the PQL page for a graph by clicking the **Go to persisted query lists** to the left of the graph's name. Then, click the ••• menu under the **Actions** column to download a PQL's manifest as a JSON file. Save this file locally and update your `local_manifests` configuration with the path the file. + +#### `hot_reload` + + + + + +This option only works in tandem with the `local_manifests` option. + + + +If you configure `local_manifests`, you can set `hot_reload` to `true` to automatically reload manifest files whenever they change. This lets you update local manifest files without restarting the router. + +```yaml title="router.yaml" +persisted_queries: + enabled: true + local_manifests: + - ./path/to/persisted-query-manifest.json + hot_reload: true +``` #### `safelist` + + Adding `safelist: true` to `persisted_queries` causes the router to reject any operations that haven't been registered to your PQL. ```yaml title="router.yaml" @@ -114,11 +153,14 @@ To enable safelisting, you _must_ turn off [automatic persisted queries](/router -By default, the [`require_id`](#required_id) suboption is `false`, meaning the router accepts both operation IDs and operation strings as long as the operation is registered. +By default, the [`require_id`](#require_id) suboption is `false`, meaning the router accepts both operation IDs and operation strings as long as the operation is registered. #### `require_id` + + Adding `require_id: true` to the `safelist` option causes the router to reject any operations that either: + - haven't been registered to your PQL - use a full operation string rather than the operation ID @@ -138,6 +180,30 @@ To enable safelisting, you _must_ turn off [automatic persisted queries](/router +### Customization via request context + +GraphOS Router can be [customized](/graphos/routing/customization/overview) via several mechanisms such as [Rhai scripts](/graphos/routing/customization/rhai) and [coprocessors](/graphos/routing/customization/coprocessor). These plugins can affect your router's persistent query processing by writing to the request context. + +#### `apollo_persisted_queries::client_name` + +When publishing operations to a PQL, you can specify a client name associated with the operation (by including a `clientName` field in the individual operation in your [manifest](/graphos/platform/security/persisted-queries#per-operation-properties), or by including the `--for-client-name` option to `rover persisted-queries publish`). If an operation has a client name, it will only be executed by requests that specify that client name. (Your PQL can contain multiple operations with the same ID and different client names.) + +Your customization (Rhai script, coprocessor, etc) can examine a request during the [Router Service stage](/graphos/routing/customization/overview#request-path) of the request path and set the `apollo_persisted_queries::client_name` value in the request context to the request's client name. + +If this context value is not set by a customization, your router will use the same client name used for [client awareness](/graphos/routing/observability/client-awareness) in observability. This client name is read from an HTTP header specified by `telemetry.apollo.client_name_header`, or `apollographql-client-name` by default. + +If your request specifies an ID and a client name but there is no operation in the PQL with that ID and client name, your router will look to see if there is an operation with that ID and no client name specified, and use that if it finds it. + +#### `apollo_persisted_queries::safelist::skip_enforcement` + +If safelisting is enabled, you can still opt out of safelist enforcement on a per-request basis. + +Your customization (Rhai script, coprocessor, etc) can examine a request during the [Router Service stage](/graphos/routing/customization/overview#request-path) of the request path and set the `apollo_persisted_queries::safelist::skip_enforcement` value in the request context to the boolean value `true`. + +For any request where you set this value, Router will skip safelist enforcement: requests with a full operation string will be allowed even if they are not in the safelist, and even if [`safelist.required_id`](#require_id) is enabled. + +This does not affect the behavior of the [`log_unknown` option](#log_unknown): unknown operations will still be logged if that option is set. + ## Limitations -* **Unsupported with offline license**. An GraphOS Router using an [offline Enterprise license](/router/enterprise-features/#offline-enterprise-license) cannot use safelisting with persisted queries. The feature relies on Apollo Uplink to fetch persisted query manifests, so it doesn't work as designed when the router is disconnected from Uplink. +- **Unsupported with offline license**. A GraphOS Router using an [offline license](/graphos/routing/license/#offline-license) cannot use safelisting with persisted queries. The feature relies on Apollo Uplink to fetch persisted query manifests, so it doesn't work as designed when the router is disconnected from Uplink. diff --git a/docs/source/routing/security/request-limits.mdx b/docs/source/routing/security/request-limits.mdx index 3feb97f84f..58ce1895fb 100644 --- a/docs/source/routing/security/request-limits.mdx +++ b/docs/source/routing/security/request-limits.mdx @@ -31,7 +31,11 @@ limits: ## Operation-based limits - + + +Rate limits apply on the Free plan. + + You can define **operation limits** in your router's configuration to reject potentially malicious requests. An operation that exceeds _any_ specified limit is rejected (unless you run your router in [`warn_only` mode](#warn_only-mode)). @@ -286,13 +290,6 @@ If router receives more headers than the buffer size, it responds to the client Limit the maximum buffer size for the HTTP1 connection. Default is ~400kib. -Note for Rust Crate Users: If you are using the Router as a Rust crate, the `http1_request_max_buf_size` option requires the `hyper_header_limits` feature and also necessitates using Apollo's fork of the Hyper crate until the [changes are merged upstream](https://github.com/hyperium/hyper/pull/3523). -You can include this fork by adding the following patch to your Cargo.toml file: -```toml -[patch.crates-io] -"hyper" = { git = "https://github.com/apollographql/hyper.git", tag = "header-customizations-20241108" } -``` - ## Parser-based limits ### `parser_max_tokens` diff --git a/docs/source/routing/security/router-authentication.mdx b/docs/source/routing/security/router-authentication.mdx index cdcc22ef4e..abcfaba187 100644 --- a/docs/source/routing/security/router-authentication.mdx +++ b/docs/source/routing/security/router-authentication.mdx @@ -9,12 +9,6 @@ redirectFrom: - /technotes/TN0004-router-authentication/ --- - - -Self-hosting the GraphOS Router is limited to [GraphOS Enterprise plans](https://www.apollographql.com/pricing). Other plan types use [managed cloud routing with GraphOS](/graphos/cloud-routing). Check out the [pricing page](https://www.apollographql.com/pricing#graphos-router) to learn more. - - - If you're an enterprise customer looking for more material on this topic, try the [Enterprise best practices: Router extensibility](https://www.apollographql.com/tutorials/router-extensibility) course on Odyssey. @@ -37,12 +31,11 @@ In fact, we recommend you [combine all three strategies](#combining-authenticati In addition to the approaches outlined below, you can use [authorization directives](/graphos/routing/security/authorization/) to enforce authorization at the router layer. This allows you to authorize requests prior to them hitting your subgraphs saving on bandwidth and processing time. - + -This is an [Enterprise feature](/router/enterprise-features) of the GraphOS Router. -It requires an organization with a [GraphOS Enterprise plan](https://www.apollographql.com/pricing/). +Rate limits apply on the Free plan. - + ```mermaid sequenceDiagram @@ -113,8 +106,6 @@ headers: As of router v1.13, you can use the [JWT Authentication plugin](/router/configuration/authn-jwt) to validate JWT-based authentication tokens in your supergraph. - - ```mermaid sequenceDiagram Client->>Router: Request with JWT @@ -131,23 +122,28 @@ authentication: jwt: jwks: - url: https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json + on_error: Error ``` Pros: -- The router prevents unauthenticated requests from reaching your subgraphs. +- The router prevents unauthenticated requests from reaching your subgraphs (except when `on_error` is set to `Continue`.) - The router can extract claims from the JWT and pass them to your subgraphs as headers, reducing logic needed in your subgraphs. Cons: - It supports only JWT-based authentication with keys from a JWKS endpoint. +### Additional resources + +You can use the Apollo Solutions [router JWKS generator](https://github.com/apollosolutions/router-jwks-generator) to create a router configuration file for use with the authentication plugin. + + + ## Use a coprocessor If you have a custom authentication strategy, you can use a [coprocessor](/graphos/routing/customization/coprocessor) to implement it. - - ```mermaid sequenceDiagram Client->>Router: Request with token @@ -204,6 +200,12 @@ Cons: - The initial lift of implementing a coprocessor is non-trivial, but once it's in place you can leverage it for any number of router customizations. +### Additional resources + +See the Apollo Solutions [coprocessor example](https://github.com/apollosolutions/example-coprocessor-external-auth) for a runnable example including configuration YAML. + + + ## Combining authentication strategies You can combine the strategies to handle a number of authentication requirements and practice "defense-in-depth": diff --git a/docs/source/routing/security/subgraph-authentication.mdx b/docs/source/routing/security/subgraph-authentication.mdx index d138e981d7..8791a74946 100644 --- a/docs/source/routing/security/subgraph-authentication.mdx +++ b/docs/source/routing/security/subgraph-authentication.mdx @@ -2,7 +2,7 @@ title: Subgraph Authentication subtitle: Implement subgraph authentication using AWS SigV4 description: Secure communication to AWS subgraphs via the Apollo GraphOS Router or Apollo Router Core using AWS Signature Version 4 (SigV4). -minVersion: 1.27.0 +minVersion: Router v1.27.0 --- The GraphOS Router and Apollo Router Core support subgraph request authentication and key rotation via [AWS Signature Version 4](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html) (SigV4). diff --git a/docs/source/routing/security/tls.mdx b/docs/source/routing/security/tls.mdx index 97d1af2187..7f21e938a3 100644 --- a/docs/source/routing/security/tls.mdx +++ b/docs/source/routing/security/tls.mdx @@ -7,17 +7,27 @@ redirectFrom: The GraphOS Router supports TLS to authenticate and encrypt communications, both on the client side and the subgraph side. It works automatically on the subgraph side if the subgraph URL starts with `https://`. +```yaml title="Example TLS configuration" +tls: + supergraph: + certificate: ${file./path/to/certificate.pem} + certificate_chain: ${file./path/to/certificate_chain.pem} + key: ${file./path/to/key.pem} +``` + ## Configuring TLS TLS support is configured in the `tls` section, under the `supergraph` key for the client side, and the `subgraph` key for the subgraph side, with configuration possible for all subgraphs and overriding per subgraph. -The list of supported TLS versions and algorithms is static, it cannot be configured. +The list of supported TLS versions and algorithms is static. + +### Supported TLS versions -Supported TLS versions: * TLS 1.2 * TLS 1.3 -Supported cipher suites: +### Supported TLS cipher suites + * TLS13_AES_256_GCM_SHA384 * TLS13_AES_128_GCM_SHA256 * TLS13_CHACHA20_POLY1305_SHA256 @@ -28,14 +38,15 @@ Supported cipher suites: * TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 * TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 -Supported key exchange groups: +### Supported key exchange groups + * X25519 * SECP256R1 * SECP384R1 ## TLS termination -Clients can connect to the router directly over HTTPS, without terminating TLS in an intermediary. You can configure this in the `tls` configuration section: +Clients can connect to the router directly over HTTPS, without terminating TLS in an intermediary. You can configure this in the `tls` configuration section: ```yaml tls: diff --git a/docs/source/routing/self-hosted/containerization/aws.mdx b/docs/source/routing/self-hosted/containerization/aws.mdx new file mode 100644 index 0000000000..9eaad0a49a --- /dev/null +++ b/docs/source/routing/self-hosted/containerization/aws.mdx @@ -0,0 +1,156 @@ +--- +title: Deploying GraphOS Router on AWS +subtitle: Deploy router with Amazon Elastic Container Service (ECS) +description: Build and deploy Apollo Router on Amazon Web Services (AWS) with Amazon Elastic Container Service (ECS) +--- + +Learn how to deploy the router for development on AWS with Elastic Container Service (ECS). + +You will: +- Build a router image with a Dockerfile. +- Set up an Elastic Cloud Registry and push your router image to it. +- Create an ECS task definition for your router and deploy it. + +## Prerequisites + +Before you start: + +1. [Set up a GraphQL API in GraphOS](/graphos/get-started/guides/graphql#step-1-set-up-your-graphql-api). + - Save your `APOLLO_KEY` and `APOLLO_GRAPH_REF`. You'll need them when deploying the router. +1. Install [Docker](https://www.docker.com/get-started/) locally. +1. Set up you [AWS environment](https://aws.amazon.com/getting-started/guides/setup-environment/) + - Install the AWS CLI. + - Use an existing Create an Amazon +1. Choose a version of the router to deploy (for example, `v1.61.0`). You'll need it when specifying the router image to deploy. + +## Build router image + +To deploy your own router, start by customizing and building a router image, using a Dockerfile and a router configuration file: + +1. In a local directory, create a `router.yaml` file and copy-paste the following configuration into the file: + + ```yaml title="router.yaml" + supergraph: + listen: 0.0.0.0:4000 + health_check: + listen: 0.0.0.0:8088 + ``` + + The router's default HTTP and health check endpoint addresses are localhost, so they wouldn't be reachable when deployed. This configuration enables the router to listen to all addresses. + +1. Create a `Dockerfile` file and copy-paste the following into the file: + + ```text showLineNumbers=false + # Use the official Apollo Router Core image as the base. + # Set the image tag to the desired router version (e.g. v1.61.0) + FROM ghcr.io/apollographql/router:v1.61.0 + + # Replace the default router config with the local, customized router.yaml + COPY router.yaml /dist/config/router.yaml + ``` + + The Dockerfile sources the base router image from the GitHub container registry, using the version of router you specify. It then copies your customized `router.yaml` configuration file to overwrite the default router configuration file. + +1. From the same local directory, run the following `docker` CLI command to build a new router image. Choose a name and tag for the image, for example `router-aws:v1.61.0`. + + ```bash showLineNumbers=false + docker buildx build --platform linux/amd64 -t router-aws:v1.61.0 --load . + ``` + + - Because Cloud Run only supports AMD64-based images, the `docker buildx build --platform linux/amd64` command ensures the image is built for AMD64 and is compatible. + - The `--load` option loads the built image to `docker images`. + +1. Run `docker images` and validate that your router image is in the returned list of images. + +## Push router image to registry + +Now that you have a built router image, create a repository in Elastic Cloud Registry (ECR) and push your image to it: + +1. In a local terminal, run the AWS CLI command to create a new ECR repository: + - For `--repository-name`, set a name for your repository (for example, `router-repo`) + - For `--region`, set your AWS region (for example, `us-west-1`) + + ```bash showLineNumbers=false + aws ecr create-repository \ + --repository-name router-repo \ + --region us-west-1 + ``` + +1. In AWS CLI, authenticate your Docker CLI to ECR. + - For `--region`, use your AWS regions (for example, `us-west-1`) + - Use your ECR repository URI, which you can copy from your ECR Repositories Console (for example, `0123456789000.dkr.ecr.us-west-1.amazonaws.com`) + + ```bash showLineNumbers=false + aws ecr get-login-password --region us-west-1 | docker login --username AWS --password-stdin 0123456789000.dkr.ecr.us-west-1.amazonaws.com + ``` + + > To troubleshoot ECR authentication, go to [AWS documentation](https://docs.aws.amazon.com/AmazonECR/latest/userguide/getting-started-cli.html#cli-authenticate-registry). + +1. Run `docker tag` to tag the image before pushing it to ECR. + + ```bash showLineNumbers=false + docker tag router-aws:v1.61.0 0123456789000.dkr.ecr.us-west-1.amazonaws.com/router-repo:v1.61.0 + ``` + +1. Run `docker push` to push the router image to your ECR repository URI, using a tag (e.g., `:v1.61.0`): + + ```bash showLineNumbers=false + docker push 0123456789000.dkr.ecr.us-west-1.amazonaws.com/router-repo:v1.61.0 + ``` + +1. Run `aws ecr list-images` and validate that your image is in the list of images in your ECR repository: + + ```bash showLineNumbers=false + aws ecr list-images --repository-name router-repo + ``` + +## Create and deploy ECS task + +With your image pushed to your ECR repository, in ECS you can define a task for the router and deploy it as a service. + +### Create cluster + +You need an ECS cluster to deploy the router. + +If you don't have a cluster, you can create one with default settings: + +1. In the AWS Console, go to the Amazon ECS Console, then click **Create cluster**. +1. Enter a name for your cluster. +1. Click **Create**. + +### Create task definition + +Create an ECS task definition for your router: + +1. In the AWS ECS Console, go to **Task definitions** from the left navigation panel, then click **Create new task definition** and select **Create new task definition**. +1. Fill in the details for **Container - 1**: + - **Name**: Enter a container name + - **Image URI**: Select the URI of your router image + - **Port mappings**: + - **Container port**: Enter `4000` (must match ) and + - **Port name**: Enter a port name + - **Environment variables**: Enter the environment variables `APOLLO_KEY` and `APOLLO_GRAPH_REF` and set them to your graph API key and graph ref, respectively +1. In `Docker configuration - optional`, enter the command options to configure the router and run it in development mode: + + ```text showLineNumbers=false + --dev, --config, /dist/config/router.yaml + ``` +1. Click **Create**. + +### Deploy router + +Deploy the router in your ECS cluster: + +1. In AWS ECS Console under **Task definitions**, select your defined task, then click **Deploy** and select **Create service**. +1. Fill in the fields for the service: + - **Existing cluster**: Select your cluster + - **Service name**: Enter a name for your service +1. Click **Create** to create the service. ECS will start deploying the service for the router. +1. After AWS finishes deploying, click on the service to go to its page in Console. Check the service logs for messages from the running router. For example: + + ```text title="Example router log message" + {"timestamp":"2025-04-04T17:32:14.928608731Z","level":"INFO","message":"Apollo Router v1.61.0 // (c) Apollo Graph, Inc. // Licensed as ELv2 (https://go.apollo.dev/elv2)","target":"apollo_router::executable","resource":{}} + ``` +1. Go to the service URL and validate the the router's development Sandbox is running successfully. + +Congrats, you've successfully deployed the router! diff --git a/docs/source/routing/self-hosted/containerization/azure.mdx b/docs/source/routing/self-hosted/containerization/azure.mdx new file mode 100644 index 0000000000..97aba854c2 --- /dev/null +++ b/docs/source/routing/self-hosted/containerization/azure.mdx @@ -0,0 +1,157 @@ +--- +title: Deploying GraphOS Router on Azure +subtitle: Deploy router with Azure Container App +description: Build and deploy Apollo GraphOS Router as an Azure Container App +--- + +Learn how to deploy the router for development on Azure as a Container App. + +You will: +- Build a router image with a Dockerfile. +- Set up an Azure Container Registry and push your router image to it. +- Create and deploy an Azure Container App for your router. + +## Prerequisites + +Before you start: + +1. [Set up a GraphQL API in GraphOS](/graphos/get-started/guides/graphql#step-1-set-up-your-graphql-api). + - Save your `APOLLO_KEY` and `APOLLO_GRAPH_REF`. You'll need them when deploying the router. +1. Install [Docker](https://www.docker.com/get-started/) locally. +1. Login to or create an [Azure account](https://azure.microsoft.com/en-us/pricing/purchase-options/azure-account). +1. Install [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli). +1. Choose a version of the router to deploy (for example, `v1.61.0`). You'll need it when specifying the router image to deploy. + +## Build router image + +To deploy your own router, start by customizing and building a router image, using a Dockerfile and a router configuration file: + +1. In a local directory, create a `router.yaml` file and copy-paste the following configuration into the file: + + ```yaml title="router.yaml" + supergraph: + listen: 0.0.0.0:4000 + health_check: + listen: 0.0.0.0:8088 + ``` + + The router's default HTTP and health check endpoint addresses are localhost, so they wouldn't be reachable when deployed. This configuration enables the router to listen to all addresses. + +1. Create a `Dockerfile` file and copy-paste the following into the file: + + ```text showLineNumbers=false + # Use the official Apollo Router Core image as the base. + # Set the image tag to the desired router version (e.g. v1.61.0) + FROM ghcr.io/apollographql/router:v1.61.0 + + # Replace the default router config with the local, customized router.yaml + COPY router.yaml /dist/config/router.yaml + ``` + + The Dockerfile sources the base router image from the GitHub container registry, using the version of router you specify. It then copies your customized `router.yaml` configuration file to overwrite the default router configuration file. + +1. From the same local directory, use `docker buildx build` to build a new router image for a specific platform. Choose a name and tag for the image, for example `router:v1.61.0`. + + ```bash showLineNumbers=false + docker buildx build --platform linux/amd64 -t router:v1.61.0 --load . + ``` + + - The `--load` option loads the built image to `docker images`. + +1. Run `docker images` and validate that your router image is in the returned list of images. + +## Push router image to container registry + +Create an Azure Container Registry as needed, then push your router image to it. + +### Create new container registry + +If you don't have an existing container registry, create a new one: + +1. Log in to the Azure Portal and go to [Container registries](https://portal.azure.com/?quickstart=true#browse/Microsoft.ContainerRegistry%2Fregistries). +1. Click **Create container registry**. +1. Fill in the details: + - **Subscription**: Select your subscription + - **Resource group**: Select existing group or create new + - **Registry name**: Enter a unique name for your registry (for example, `myapolloregistry`) + - **Location**: Select an appropriate region + - **Pricing plan**: Select an appropriate plan + +1. Click **Review + create**, then click **Create**. +1. Your registry should now be created. Click on your registry to go to its portal page. + +### Log in to container registry + +1. In a terminal, sign in to Azure CLI: + + ```bash showLineNumbers=false + az login + ``` + +1. Log in and authenticate to your registry (for example, `myapolloregistry`): + + ```bash showLineNumbers=false + az acr login --name myapolloregistry + ``` + +### Tag and push image to registry + +1. Use `docker tag` to create an alias of the image with the fully qualified path to your registry (for example, `myapolloregistry.azurecr.io`): + + ```bash showLineNumbers=false + docker tag router:v1.61.0 myapolloregistry.azurecr.io/router:v1.61.0 + ``` +1. Push the image to the registry: + + ```bash showLineNumbers=false + docker push myapolloregistry.azurecr.io/router:v1.61.0 + ``` + +1. Use `az acr repository list` to verify your image is now in the registry: + + ```bash showLineNumbers=false + az acr repository list --name myapolloregistry + ``` + + + +```bash showLineNumbers=false +[ + "myapolloregistry" +] +``` + + +### Deploy the router + +Create and deploy a container app to run the router in Azure: + +1. Log in to the Azure Portal, then go to [Create Container App](https://portal.azure.com/#create/Microsoft.ContainerApp) +1. Fill in the details for the **Basics** tab: + - Subscription: Select your subscription + - Resource Group: Select existing group or create new + - Name: Enter a unique name for your web app. + - Publish: Choose **Container** + - Operating System: Choose **Linux** + - Region: Select an appropriate region + - App Service Plan: Select existing plan or create new + +1. Fill in the details for the **Container** tab: + - **Subscription**: Select your subscription + - **Registry**: Select your registry + - **Image**: Select your router image + - **Image tag**: Select your router image's tag + - **Arguments override**: Enter `--dev, --config, /dist/config/router.yaml` + - **Environment variables**: Enter `APOLLO_GRAPH_REF` and `APOLLO_KEY` with your graph ref and API key, respectively + +1. Fill in the details for the **Ingress** tab: + - **Ingress**: Check **Enabled** + - **Ingress traffic**: Select **Accepting traffic from anywhere** + - **Ingress type**: Select **HTTP** + - **Target port**: Enter `4000` (must match your router's `supergraph.listen` port) + +1. Click **Review + create**. +1. Click **Create**, then wait for your deployment to complete. +1. Click **Go to resource** to open the portal page for your deployed container, then click on the **Application Url** to verify that your router's Sandbox is running successfully. + +Congrats, you've successfully deployed the router! diff --git a/docs/source/routing/self-hosted/containerization/docker-router-only.mdx b/docs/source/routing/self-hosted/containerization/docker-router-only.mdx new file mode 100644 index 0000000000..c57775bd17 --- /dev/null +++ b/docs/source/routing/self-hosted/containerization/docker-router-only.mdx @@ -0,0 +1,196 @@ +--- +title: Deploying only GraphOS Router in Docker +subtitle: Deploy router-only container image +description: Run an Apollo Router-only container image in Docker with examples covering basic setup, configuration overrides, debugging, and building custom Docker images. +--- + +import ElasticNotice from '../../../../shared/elastic-notice.mdx'; + +This guide provides the following examples of running an Apollo Router container image in Docker: + +* Running a basic example with default configuration. +* Customizing your configuration to override the default configuration. +* Debugging your containerized router. +* Manually specifying a supergraph for your router. +* Building your own router Docker image. + +The [documentation](https://docs.docker.com/engine/reference/run/) for the `docker run` command is a helpful reference for the examples in this guide. + +The exact image version to use depends on which release you wish to use. In the following examples, replace `` with your chosen version, for example `v1.32.0`. + + + +This container image only contains the router. Apollo recommends using the [Apollo Runtime container](docker.mdx), which contains all Apollo runtime services. + +## Basic example running router in Docker + +To run the router, your Docker container must have the [`APOLLO_GRAPH_REF`](/router/configuration/overview#apollo_graph_ref) and [`APOLLO_KEY`](/router/configuration/overview#apollo_key) environment variables set to your graph ref and API key, respectively. + +Below is a basic example of running a router image with Docker, either with `docker run` or `docker compose`. It downloads your supergraph schema from Apollo and uses a default configuration that listens for connections on port `4000`. + +You can use `docker run` with the following example command: + +```bash title="Docker" +docker run -p 4000:4000 \ + --env APOLLO_GRAPH_REF="" \ + --env APOLLO_KEY="" \ + --rm \ + ghcr.io/apollographql/router: +``` + +You can also use `docker compose` with the following example `compose.yaml`: + +```yaml title="compose.yaml" +services: + apollo-router: + image: ghcr.io/apollographql/router: + ports: + - "4000:4000" + environment: + APOLLO_GRAPH_REF: "" + APOLLO_KEY: "" +``` + +Whether you use `docker run` or `docker compose`, make sure to replace `` with whichever version you want to use, and `` and `` with your graph reference and API key, respectively. + +For more complex configurations, such as overriding subgraph URLs or propagating headers, see [Router Configuration](/router/configuration/overview/). + +## Override the configuration + +Apollo's default Docker images include a [basic router configuration](https://github.com/apollographql/router/blob/main/dockerfiles/router.yaml). Inside the container, this file is located at `/dist/config/router.yaml`. + +If you wish to override the default configuration, it is important to preserve aspects of the default configuration. In particular, it is generally important for the router to bind to and listen on the special address of `0.0.0.0` (for all interfaces) to ensure it's exposed on a network interface that's accessible outside of the local container. Without this configuration, the router will only listen on `localhost`. + +You can provide your own configuration from the host environment to the router by mounting your configuration to `/dist/config/router.yaml` as follows: + +```bash {4} +docker run -p 4000:4000 \ + --env APOLLO_GRAPH_REF="" \ + --env APOLLO_KEY="" \ + --mount "type=bind,source=/home/user/router.yaml,target=/dist/config/router.yaml" \ + --rm \ + ghcr.io/apollographql/router: +``` + + + +Both local and container paths must be specified as absolute paths. + + + +In this example we are mounting a file from the host system (`/home/user/router.yaml`) in place of the default configuration provided in the image at `/dist/config/router.yaml`. + +## Passing command-line arguments to the router binary + +By default, the `router` command invoked inside the published container doesn't set any of the [available command-line options](/router/configuration/overview#command-line-options). To set them, append the desired option(s) to the `docker run` command. + +For example, to start the router using the `--log debug` option, use the following `docker run` command with the option added at the end: + +```bash {5} +docker run -p 4000:4000 \ + --env APOLLO_GRAPH_REF="" \ + --env APOLLO_KEY="" \ + --rm \ + ghcr.io/apollographql/router: --log debug +``` + +## Debugging your container + +You can debug your container by setting the `entrypoint` in a `docker run` command: + +```bash +docker run -p 4000:4000 \ + --env APOLLO_GRAPH_REF="" \ + --env APOLLO_KEY="" \ + --mount "type=bind,source=/router.yaml,target=/dist/config/router.yaml" \ + --rm \ + --interactive \ + --tty \ + --entrypoint=bash \ + ghcr.io/apollographql/router: +dist# pwd +/dist +dist# ls +config router schema +dist# exit +exit +``` + +In this example, we've added both interactive and tty flags, and we've changed the entrypoint of the image to be a bash shell. + +### Running the debug container to investigate memory issues + +```bash +docker run -p 4000:4000 \ + --env APOLLO_GRAPH_REF="" \ + --env APOLLO_KEY="" \ + --mount "type=bind,source=/data,target=/dist/data" + --rm \ + ghcr.io/apollographql/router:-debug +``` + +The router runs under the control of [heaptrack](https://github.com/KDE/heaptrack). The heaptrack output is saved to the `/data` directory. The output can be analyzed directly using `heaptrack_gui` or `heaptrack_print` or shared with Apollo support. + +## Specifying the supergraph + +If you don't want to automatically update your supergraph via [Apollo Uplink](/federation/managed-federation/uplink/), or you don't have connectivity to access Apollo Uplink from your environment, you have two options: + +### Using a local supergraph file + +You can manually specify the details of your supergraph in a `docker run` command: + +```bash +docker run -p 4000:4000 \ + --mount "type=bind,source=/docker.graphql,target=/dist/schema/local.graphql" \ + --rm \ + ghcr.io/apollographql/router: -c config/router.yaml -s schema/local.graphql +``` + +In this example, we have to mount the local definition of the supergraph into our image, _and_ specify the location of the file. It doesn't have to be mounted in the `/dist/schema` directory, but it's a reasonable location to use. We must specify the configuration file location as well, since overriding the default params will override our default config file location. In this case, since we don't want to change our router configuration but want to make sure it's used, we just specify the default location of the default configuration. + +### Using an OCI image reference + +You can use the `--graph-artifact-reference` option to fetch the supergraph schema from an OCI image: + +```bash +docker run -p 4000:4000 \ + --env APOLLO_KEY="" \ + --env GRAPH_ARTIFACT_REFERENCE="" \ + --rm \ + ghcr.io/apollographql/router: +``` + +When using this option, the router will fetch the schema from the specified OCI image instead of using Apollo Uplink. + +## Building your own container + + + +This section is aimed at developers familiar with tooling such as `docker` and `git` who wish to make their own DIY container images. The script documented here is not a part of the router product, but an illustrative example of what's involved in making your own images. + + + +In the `dockerfiles/diy` directory, we now provide a script, `build_docker_image.sh` which illustrates how to build your own docker images from either our released tarballs or from a git commit hash or tag. Here's how to use it: + +```bash +% ./build_docker_image.sh -h +Usage: build_docker_image.sh [-b [-r ]] [-d] [] + -b build docker image from the default repo, if not present build from a released version + -d build debug image, router will run under control of heaptrack + -r build docker image from a specified repo, only valid with -b flag + a valid release. If [-b] is specified, this is optional + Example 1: Building HEAD from the repo + build_docker_image.sh -b + Example 2: Building HEAD from a different repo + build_docker_image.sh -b -r /Users/anon/dev/router + Example 3: Building tag from the repo + build_docker_image.sh -b v0.9.1 + Example 4: Building commit hash from the repo + build_docker_image.sh -b 7f7d223f42af34fad35b898d976bc07d0f5440c5 + Example 5: Building tag v0.9.1 from the released version + build_docker_image.sh v0.9.1 + Example 6: Building a debug image with tag v0.9.1 from the released version + build_docker_image.sh -d v0.9.1 +``` + +The example uses [debian:bullseye-slim image](https://hub.docker.com/_/debian/) for the final image build. Feel free to modify the script to use images which better suit your own needs, but be careful if using the `-d` flag because it makes the assumption that there is a `heaptrack` package available to install. \ No newline at end of file diff --git a/docs/source/routing/self-hosted/containerization/docker.mdx b/docs/source/routing/self-hosted/containerization/docker.mdx index 605b8723c1..0a2751f614 100644 --- a/docs/source/routing/self-hosted/containerization/docker.mdx +++ b/docs/source/routing/self-hosted/containerization/docker.mdx @@ -1,161 +1,126 @@ --- -title: Run in Docker -subtitle: Run the router container image in Docker -description: Run the Apollo Router Core container image in Docker with examples covering basic setup, configuration overrides, debugging, and building custom Docker images. +title: Deploying the Apollo Runtime in Docker +subtitle: Run an Apollo Runtime container image in Docker +description: Easily deploy a container with everything you need to serve GraphQL requests using Apollo. --- import ElasticNotice from '../../../../shared/elastic-notice.mdx'; -This guide provides the following examples of running an Apollo Router Core container image in Docker: +This guide provides the following examples of running an Apollo Router container image in Docker: * Running a basic example with default configuration. * Customizing your configuration to override the default configuration. -* Debugging your containerized router. * Manually specifying a supergraph for your router. -* Building your own router Docker image. The [documentation](https://docs.docker.com/engine/reference/run/) for the `docker run` command is a helpful reference for the examples in this guide. -The exact image version to use depends on which release you wish to use. In the following examples, replace `` with your chosen version, for example `v1.32.0`. +The exact image version to use depends on which release you wish to use. In the following examples, replace `` with your chosen version. For additional details on versioning, see the [container tags documentation](https://github.com/apollographql/apollo-runtime?tab=readme-ov-file#container-tags). -## Basic example running router in Docker +## Quick start To run the router, your Docker container must have the [`APOLLO_GRAPH_REF`](/router/configuration/overview#apollo_graph_ref) and [`APOLLO_KEY`](/router/configuration/overview#apollo_key) environment variables set to your graph ref and API key, respectively. -Here's a basic example of running a router image in Docker. Make sure to replace `` with whichever version you want to use, such as `v1.32.0`. +Below is a basic example of running an Apollo Runtime image with Docker. It downloads your supergraph schema from Apollo and uses a default configuration that listens for connections on port `4000`. + +You can use `docker run` with the following example command: ```bash title="Docker" -docker run -p 4000:4000 \ +docker run \ + -p 4000:4000 \ --env APOLLO_GRAPH_REF="" \ --env APOLLO_KEY="" \ --rm \ - ghcr.io/apollographql/router: + ghcr.io/apollographql/apollo-runtime:latest ``` -This command downloads your supergraph schema from Apollo and uses a default configuration that listens for connections on port `4000`. +Make sure to replace `` and `` with your graph reference and API key, respectively. -For more complex configurations, such as overriding subgraph URLs or propagating headers, see [Router Configuration](/router/configuration/overview/). +## Enabling MCP -## Override the configuration + -Apollo's default Docker images include a [basic router configuration](https://github.com/apollographql/router/blob/main/dockerfiles/router.yaml). Inside the container, this file is located at `/dist/config/router.yaml`. +This feature is [experimental](/graphos/resources/feature-launch-stages#experimental). For more information about MCP support, review the [MCP documentation](https://www.apollographql.com/docs/apollo-mcp-server). -If you wish to override the default configuration, it is important preserve aspects of the default configuration. In particular, it is generally important for the router to bind to and listen on the special address of `0.0.0.0` (for all interfaces) to ensure it's exposed on a network interface that's accessible outside of the local container. Without this configuration, the router will only listen on `localhost`. + -You can provide your own configuration from the host environment to the router by mounting your configuration to `/dist/config/router.yaml` as follows: +To serve MCP requests, enable the [MCP server](https://www.apollographql.com/docs/apollo-mcp-server) using the `MCP_ENABLE` environment variable. You'll also need to export container port `5000` for HTTP Streamable connections to the MCP server, using the `-p 5000:5000` flag. -```bash {4} -docker run -p 4000:4000 \ +```bash title="Docker" {3, 6} +docker run \ + -p 4000:4000 \ + -p 5000:5000 \ --env APOLLO_GRAPH_REF="" \ --env APOLLO_KEY="" \ - --mount "type=bind,source=/home/user/router.yaml,target=/dist/config/router.yaml" \ + --env MCP_ENABLE=1 \ + --env MCP_UPLINK=1 \ --rm \ - ghcr.io/apollographql/router: + ghcr.io/apollographql/apollo-runtime:latest ``` - - -Both local and container paths must be specified as absolute paths. - - - -In this example we are mounting a file from the host system (`/home/user/router.yaml`) in place of the default configuration provided in the image at `/dist/config/router.yaml`. - -## Passing command-line arguments to the router binary - -By default, the `router` command invoked inside the published container doesn't set any of the [available command-line options](/router/configuration/overview#command-line-options). To set them, append the desired option(s) to the `docker run` command. +## Configuring using local files -For example, to start the router using the `--log debug` option, use the following `docker run` command with the option added at the end: +You can provide your own configuration from the host environment to the router by mounting the directory containing your configuration files to `/config` as follows: -```bash {5} +```bash title="Docker" docker run -p 4000:4000 \ --env APOLLO_GRAPH_REF="" \ --env APOLLO_KEY="" \ + -v <>:/config --rm \ - ghcr.io/apollographql/router: --log debug + ghcr.io/apollographql/apollo-runtime: ``` -## Debugging your container - -You can debug your container by setting the `entrypoint` in a `docker run` command: +You can also mount specific files, for example the schema file, by specifying: -```bash -docker run -p 4000:4000 \ - --env APOLLO_GRAPH_REF="" \ - --env APOLLO_KEY="" \ - --mount "type=bind,source=/router.yaml,target=/dist/config/router.yaml" \ - --rm \ - --interactive \ - --tty \ - --entrypoint=bash \ - ghcr.io/apollographql/router: -dist# pwd -/dist -dist# ls -config router schema -dist# exit -exit +```bash title="Docker" +... +-v <:/config/schema.graphql +... ``` -In this example, we've added both interactive and tty flags, and we've changed the entrypoint of the image to be a bash shell. +If you wish to override the default router configuration, it is important to preserve aspects of the default configuration. In particular, it is generally important for the router to bind to and listen on the special address of `0.0.0.0` (for all interfaces) to ensure it's exposed on a network interface that's accessible outside of the local container. Without this configuration, the router will only listen on `localhost`. -### Running the debug container to investigate memory issues +This allows for using local supergraph schemas, persisted query manifests, router configuration, and more. To learn more, [review the documentation](https://github.com/apollographql/apollo-runtime?tab=readme-ov-file#configuring-using-local-files). -```bash -docker run -p 4000:4000 \ - --env APOLLO_GRAPH_REF="" \ - --env APOLLO_KEY="" \ - --mount "type=bind,source=/data,target=/dist/data" - --rm \ - ghcr.io/apollographql/router:-debug -``` + -The router runs under the control of [heaptrack](https://github.com/KDE/heaptrack). The heaptrack output is saved to the `/data` directory. The output can be analyzed directly using `heaptrack_gui` or `heaptrack_print` or shared with Apollo support. +Both local and container paths must be specified as absolute paths. + + ## Specifying the supergraph -If you don't want to automatically update your supergraph via [Apollo Uplink](/federation/managed-federation/uplink/), or you don't have connectivity to access Apollo Uplink from your environment, you can manually specify the details of your supergraph in a `docker run` command: +If you don't want to automatically update your supergraph via [Apollo Uplink](/federation/managed-federation/uplink/), or you don't have connectivity to access Apollo Uplink from your environment, you have two options: + +1. Using a local supergraph file, as documented in the [Configuring using local files](#configuring-using-local-files) section. +1. Using an [OCI image reference](#using-an-oci-image-reference) + +### Using an OCI image reference + +You can use the `GRAPH_ARTIFACT_REFERENCE` environment variable to fetch the supergraph schema from an OCI image: ```bash docker run -p 4000:4000 \ - --mount "type=bind,source=/docker.graphql,target=/dist/schema/local.graphql" \ + --env APOLLO_KEY="" \ + --env GRAPH_ARTIFACT_REFERENCE="" \ --rm \ - ghcr.io/apollographql/router: -c config/router.yaml -s schema/local.graphql + ghcr.io/apollographql/apollo-runtime: ``` -In this example, we have to mount the local definition of the supergraph into our image, _and_ specify the location of the file. It doesn't have to be mounted in the `/dist/schema` directory, but it's a reasonable location to use. We must specify the configuration file location as well, since overriding the default params will override our default config file location. In this case, since we don't want to change our router configuration but want to make sure it's used, we just specify the default location of the default configuration. +When using this option, the router will fetch the schema from the specified OCI image instead of using Apollo Uplink. Additional information on graph artifacts is available in the [router CLI options documentation](/docs/graphos/routing/configuration/cli#command-line-options). -## Building your own container +## Running a specific Router and MCP version - +The container has a tagging scheme that consists of three parts, the container version, the Apollo Router version, and the MCP Server version, each separated by underscores. -This section is aimed at developers familiar with tooling such as `docker` and `git` who wish to make their own DIY container images. The script documented here is not a part of the router product, but an illustrative example of what's involved in making your own images. +To learn more, see the [tagging documentation](https://github.com/apollographql/apollo-runtime?tab=readme-ov-file#container-tags). - +## Additional router configuration information -In the `dockerfiles/diy` directory, we now provide a script, `build_docker_image.sh` which illustrates how to build your own docker images from either our released tarballs or from a git commit hash or tag. Here's how to use it: +For more complex configurations, such as overriding subgraph URLs or propagating headers, see [Router Configuration](/router/configuration/overview/). -```bash -% ./build_docker_image.sh -h -Usage: build_docker_image.sh [-b [-r ]] [-d] [] - -b build docker image from the default repo, if not present build from a released version - -d build debug image, router will run under control of heaptrack - -r build docker image from a specified repo, only valid with -b flag - a valid release. If [-b] is specified, this is optional - Example 1: Building HEAD from the repo - build_docker_image.sh -b - Example 2: Building HEAD from a different repo - build_docker_image.sh -b -r /Users/anon/dev/router - Example 3: Building tag from the repo - build_docker_image.sh -b v0.9.1 - Example 4: Building commit hash from the repo - build_docker_image.sh -b 7f7d223f42af34fad35b898d976bc07d0f5440c5 - Example 5: Building tag v0.9.1 from the released version - build_docker_image.sh v0.9.1 - Example 6: Building a debug image with tag v0.9.1 from the released version - build_docker_image.sh -d v0.9.1 -``` +## Router-only Docker container -The example uses [debian:bullseye-slim image](https://hub.docker.com/_/debian/) for the final image build. Feel free to modify the script to use images which better suit your own needs, but be careful if using the `-d` flag because it makes the assumption that there is a `heaptrack` package available to install. +Learn more about the Docker container that includes only the Apollo Router in the [Router-only Docker container documentation](docker-router-only). diff --git a/docs/source/routing/self-hosted/containerization/gcp.mdx b/docs/source/routing/self-hosted/containerization/gcp.mdx new file mode 100644 index 0000000000..6236080198 --- /dev/null +++ b/docs/source/routing/self-hosted/containerization/gcp.mdx @@ -0,0 +1,155 @@ +--- +title: Deploying GraphOS Router on GCP +subtitle: Deploy router with Google Cloud Run +description: Build and deploy Apollo GraphOS Router or Apollo Router Core to Google Cloud Platform (GCP) with Google Cloud Run. +--- + +Learn how to deploy the router for development on Google Cloud Platform (GCP) with Google Cloud Run. + +You will: +- Build a router image using a Dockerfile and a router configuration file. +- Set up a container registry and push your router image to it. +- Create a Cloud Run service and configure it to deploy your router. + +## Prerequisites + +Before you start: + +1. [Set up a GraphQL API in GraphOS](/graphos/get-started/guides/graphql#step-1-set-up-your-graphql-api). + - Save your `APOLLO_KEY` and `APOLLO_GRAPH_REF` in your GCP Secret Manager. You'll need them when deploying the router. +1. Install [Docker](https://www.docker.com/get-started/) locally. +1. Create a [GCP account](https://cloud.google.com/) or use an existing account. +1. Create a [GCP project](https://console.cloud.google.com/projectcreate). Choose a project name (for example, `my-project`) and save it to use later when deploying the router. +1. Install the [gcloud CLI](https://cloud.google.com/sdk/docs/install) and log in to your GCP account. +1. Choose a version of the router to deploy (for example, `v1.61.0`). You'll need it when specifying the router image to deploy. + +## Build router image + +To deploy your own router, start by customizing and building a router image, using a Dockerfile and a router configuration file: + +1. In a local directory, create a `router.yaml` file and copy-paste the following configuration into the file: + + ```yaml title="router.yaml" + supergraph: + listen: 0.0.0.0:4000 + health_check: + listen: 0.0.0.0:8088 + ``` + + The router's default HTTP and health check endpoint addresses are localhost, so they wouldn't be reachable when deployed. This configuration enables the router to listen to all addresses. + +1. Create a `Dockerfile` file and copy-paste the following into the file: + + ```text showLineNumbers=false + # Use the official Apollo Router Core image as the base. + # Set the image tag to the desired router version (e.g. v1.61.0) + FROM ghcr.io/apollographql/router:v1.61.0 + + # Replace the default router config with the local, customized router.yaml + COPY router.yaml /dist/config/router.yaml + ``` + + The Dockerfile sources the base router image from the GitHub container registry, using the version of router you specify. It then copies your customized `router.yaml` configuration file to overwrite the default router configuration file. + +1. From the same local directory, use `docker buildx` CLI command to build a new router image. Choose a name and tag for the image, for example `router-gcp:v1.61.0`. + + ```bash showLineNumbers=false + docker buildx build --platform linux/amd64 -t router-gcp:v1.61.0 --load . + ``` + + - Because Cloud Run only supports AMD64-based images, the `docker buildx build --platform linux/amd64` command ensures the image is built for AMD64 and is compatible. + - The `--load` option loads the built image to `docker images`. + +1. Use `docker images` to validate that your router image is successfully built and loaded. + +## Push router image to container registry + +With a router image built, set up GCP Artifact Registry, then tag and push your image to it. + +### Set up container registry + +1. In GCP, enable [Artifact Registry](https://cloud.google.com/artifact-registry/docs/enable-service) in your project. + - Create a repository and choose a repository name (for example, `my-repo`). Keep this name handy, as you'll need it later to build and deploy a router image. + +### Tag and push router image + +1. Use `docker tag` to tag the image before pushing it to Artifact Registry. Make sure your tag conforms with Artifact Registry's [naming convention](https://cloud.google.com/artifact-registry/docs/docker/names) (for example, `us-west2-docker.pkg.dev/my-project/my-repo/router-gcp:v1.61.0`). + + ```bash showLineNumbers=false + docker tag router-gcp:v1.61.0 \ + us-west2-docker.pkg.dev/my-project/my-repo/router-gcp:v1.61.0 + ``` + +1. Use `docker push` to push the router image to Artifact Registry. + + ```bash showLineNumbers=false + docker push us-west2-docker.pkg.dev/my-project/my-repo/router-gcp:v1.61.0 + ``` + +1. Validate the router image has been successfully pushed to Artifact Registry. You can use Google Cloud Console and navigate to your repository in Artifact Registry. You can also use the gcloud CLI and run `gcloud artifacts docker images`. For example: + + ```bash showLineNumbers=false + gcloud artifacts docker images list us-west2-docker.pkg.dev/my-project/my-repo + ``` + +## Create and deploy Cloud Run service + +With the router image pushed to GCP, you can now configure and deploy it as a Cloud Run service. + +You can use either Google Cloud console or gcloud CLI. In either case, you need to gather the following information: + +* Name for your deployed router service (for example, `my-router`) +* GCP project name (for example, `my-project`) +* Artifact Registry repo name (for example, `my-repo`) +* GCP region (for example, `us-west2`) +* Full image path (for example, `us-west2-docker.pkg.dev/my-project/my-repo/router-gcp:v1.61.0`) +* `APOLLO_KEY` and `APOLLO_GRAPH_REF` secrets + +### Deploy with Google Cloud console + +1. In GCP console for your project, go to Cloud Run and select **Deploy container** > **Service**. +1. On the **Create Service** page, fill in the details: + - **Container image URL**: Select your router image + - **Service name**: Enter a name for your deployed router (for example, `my-router`) + - **Region**: Select your GCP region + - **Authentication**: Select **Allow unauthenticated invocations**. +1. On the **Container(s)** > **Edit Container** tab, go to the **Settings** tab and fill in the details: + - **Container port**: Enter `4000` (must match the `supergraph.listen` port of your router configuration) + - **Container command**: Enter `/dist/router` + - **Container arguments**: Enter `--dev` (runs the router in development mode) +1. Also on the **Container(s)** > **Edit Container** tab, go to the **Variables & Secrets** and fill in the details: + * Add `APOLLO_KEY` and set it to your graph API key + * Add `APOLLO_GRAPH_REF` and set it to your graph ref +1. Click **Deploy**. +1. Once deployed, select the service from the [Cloud Run console](https://console.cloud.google.com/run), then click on its **URL** (for example, `https://my-router-123456789012.us-west1.run.app/`) and validate that router's development Sandbox is running successfully. + +### Deploy with gcloud CLI + +1. To deploy the router with the gcloud CLI, use `gcloud run deploy` with your configuration info in place of the example info: + + ```bash showLineNumbers=false + gcloud run deploy my-router \ + --image=us-west2-docker.pkg.dev/my-project/my-repo/router-gcp:v1.61.0 \ + --command=/dist/router \ + --args=--dev \ + --set-secrets=APOLLO_KEY=APOLLO_KEY:latest,APOLLO_GRAPH_REF=APOLLO_GRAPH_REF:latest \ + --region=us-west2 \ + --project=router-container-gcp + ``` + +1. Update traffic to your deployed router by running `gcloud run services update-traffic`: + + ```bash + gcloud run services update-traffic my-router --to-latest + ``` + +1. Use `gcloud run services` to get the service URL. For example, for a service named `my-router`: + + ```bash + gcloud run services describe my-router --format 'value(status.url)' + ``` + +1. In a browser, go to the service URL and validate the the router's development Sandbox is running successfully. + +Congrats, you've successfully deployed the router! + diff --git a/docs/source/routing/self-hosted/containerization/index.mdx b/docs/source/routing/self-hosted/containerization/index.mdx index 0b9a452de0..fd4ad5125f 100644 --- a/docs/source/routing/self-hosted/containerization/index.mdx +++ b/docs/source/routing/self-hosted/containerization/index.mdx @@ -1,7 +1,7 @@ --- -title: Containerizing the GraphOS Router -subtitle: Run router images in containers -description: Containerize the Apollo GraphOS Router for portability and scalability. Choose from default or debug images. Deploy in Kubernetes or run in Docker. +title: Using Docker to deploy the Apollo Runtime Container +subtitle: Run Apollo Runtime services in containers +description: Learn how to deploy self-hosted Apollo Runtime services like the router and MCP server using container technologies like Docker, Kubernetes, AWS, Azure, and GCP. --- import ElasticNotice from '../../../../shared/elastic-notice.mdx'; @@ -10,22 +10,22 @@ Apollo provides container images of the Apollo Router Core that you can self-hos -## About router container images +## Docker -Apollo provides container images of the router [on GitHub](https://github.com/apollographql/router/pkgs/container/router) in its container repository. The router images are based on [debian:bullseye-slim](https://hub.docker.com/_/debian/), which is designed to provide constrained, secure, and small images. +### Apollo Runtime container (Recommended) -Each release of the router includes both default (production) and debug images. While each image for a release contains the same build, the debug images have helpful debugging utilities installed and run the router under the control of [heaptrack](https://github.com/KDE/heaptrack). +Apollo provides combined Docker images containing the Apollo Router and MCP Server for easy deployment. The images are available via GitHub, downloadable from the [registry](https://github.com/apollographql/apollo-runtime/pkgs/container/apollo-runtime) and the [repository](https://github.com/apollographql/apollo-runtime). -A router image has the following layout: +For more information on deploying using your container environment: -* A `/dist` directory containing the router executable and licensing details -* A `dist/config` directory containing a default configuration file, `router.yaml` -* A `/dist/schema` directory for conveniently mounting a locally defined supergraph schema +- [Docker](/graphos/routing/self-hosted/containerization/docker) -## Next steps +### Router only container -The default behavior of a router image is suitable for a basic deployment or development scenario. +This image is recommended only for Kubernetes-based deployments, and is used by the [Helm chart](/router/containerization/kubernetes/). For more information on deploying using your container environment: -For examples of customizing and deploying router images in specific environments, see the guides for: -* [Deploying in Kubernetes](/router/containerization/kubernetes/). -* [Running in Docker](/router/containerization/docker/) +- [Docker](/graphos/routing/self-hosted/containerization/docker-router-only) +- [AWS using Elastic Container Service (ECS)](/graphos/routing/self-hosted/containerization/aws) +- [Azure using Azure Container Apps](/graphos/routing/self-hosted/containerization/azure) +- [GCP using Google Cloud Run](/graphos/routing/self-hosted/containerization/gcp) +- [Kubernetes](/router/containerization/kubernetes/) diff --git a/docs/source/routing/self-hosted/containerization/kubernetes.mdx b/docs/source/routing/self-hosted/containerization/kubernetes.mdx deleted file mode 100644 index 344a982aa3..0000000000 --- a/docs/source/routing/self-hosted/containerization/kubernetes.mdx +++ /dev/null @@ -1,301 +0,0 @@ ---- -title: Deploy in Kubernetes -subtitle: Self-hosted deployment of the router in Kubernetes -description: Deploy the Apollo GraphOS Router or Apollo Router Core in Kubernetes using Helm charts. Customize configurations, enable metrics, and choose values for migration. ---- - -import ElasticNotice from '../../../../shared/elastic-notice.mdx'; -import HelmShowOutput from '../../../../shared/helm-show-router-output.mdx'; -import CoprocTypicalConfig from '../../../../shared/coproc-typical-config.mdx'; - -Learn how to deploy a self-hosted router (GraphOS Router or Apollo Router Core) in Kubernetes using Helm charts. - -```mermaid -flowchart LR; - clients(Clients); - subgraph "Kubernetes cluster"; - lb(["Load Balancer"]) - router_a(["Router"]); - coproc_a(["Coprocessor"]); - router_b(["Router"]); - coproc_b(["Coprocessor"]); - router_a <-.-> coproc_a; - router_b <-.-> coproc_b; - end; - clients -.->|Query| lb; - lb -.-> router_a & router_b; - class clients secondary; -``` - -The following guides provide the steps to: - -* Get a router Helm chart from the Apollo container repository. -* Deploy a router with a basic Helm chart. -* Configure chart values to export metrics, enable Rhai scripting, and deploy a coprocessor. -* Choose chart values that best suit migration from a gateway to the router. - - - -## About the router Helm chart - -[Helm](https://helm.sh) is a package manager for Kubernetes (k8s). Apollo provides an application Helm chart with each release of Apollo Router Core in GitHub. Since the router version 0.14.0, Apollo has released the router Helm chart as an [Open Container Initiative (OCI)](https://helm.sh/docs/topics/registries/) image in a GitHub container registry. - - - -The path to the OCI router chart is `oci://ghcr.io/apollographql/helm-charts/router`. - - - -You customize a deployed router with the same [command-line options and YAML configuration](/router/configuration/overview) but under different Helm CLI options and YAML keys. - -## Basic deployment - -Follow this guide to deploy the Router using Helm to install the basic chart provided with each router release. - -Each router chart has a `values.yaml` file with router and deployment settings. The released, unedited file has a few explicit settings, including: - -* Default container ports for the router's [HTTP server](/router/configuration/overview/#listen-address), [health check endpoint](/router/configuration/health-checks), and [metrics endpoint](/router/configuration/telemetry/exporters/metrics/overview). -* A command-line argument to enable [hot reloading of the router](/router/configuration/overview/#--hr----hot-reload). -* A single replica. - - - -The values of the Helm chart for Apollo Router Core v1.31.0 in the GitHub container repository, as output by the `helm show` command: - -```bash -helm show values oci://ghcr.io/apollographql/helm-charts/router -``` - - - - - -### Set up Helm - -1. Install [Helm](https://helm.sh/docs/intro/install/) **version 3.x**. The router's Helm chart requires Helm v3.x. - - - - Your Kubernetes version must be compatible with Helm v3. For details, see [Helm Version Support Policy](https://helm.sh/docs/topics/version_skew/#supported-version-skew). - - - - -1. Verify you can pull from the registry by showing the latest router chart values with the `helm show values` command: - - ```bash - helm show values oci://ghcr.io/apollographql/helm-charts/router - ``` - -### Set up cluster - -Install the tools and provision the infrastructure for your Kubernetes cluster. - -For an example, see the [Setup from Apollo's Reference Architecture](https://github.com/apollosolutions/reference-architecture/blob/main/docs/setup.md). It provides steps you can reference for gathering accounts and credentials for your cloud platform (GCP or AWS), provisioning resources, and deploying your subgraphs. - - - -To manage the system resources you need to deploy the router on Kubernetes: - -* Read [Managing router resources in Kubernetes](/technotes/TN0016-router-resource-management/). -* Use the [router resource estimator](/technotes/TN0045-router_resource_estimator/). - - - -### Set up graph - -Set up your self-hosted graph and get its [graph ref](/router/configuration/overview/#apollo_graph_ref) and [API key](/router/configuration/overview/#apollo_graph_ref). - -If you need a guide to set up your graph, you can follow [the self-hosted router quickstart](/graphos/quickstart/self-hosted) and complete [step 1 (Set up Apollo tools)](/graphos/quickstart/self-hosted/#1-set-up-apollo-tools), [step 4 (Obtain your subgraph schemas)](/graphos/quickstart/self-hosted/#4-obtain-your-subgraph-schemas), and [step 5 (Publish your subgraph schemas)](/graphos/quickstart/self-hosted/#5-publish-your-subgraph-schemas). - -### Deploy router - -To deploy the router, run the `helm install` command with an argument for the OCI image in the container repository, an argument for the `values.yaml` configuration file, and additional arguments to override specific configuration values. - -```bash -helm install --namespace --set managedFederation.apiKey="" --set managedFederation.graphRef="" oci://ghcr.io/apollographql/helm-charts/router --version --values router/values.yaml -``` -The necessary arguments for specific configuration values: - -* `--set managedFederation.graphRef=""`. The reference to your managed graph (`id@variant`), the same value as the [`APOLLO_GRAPH_REF` environment variable](/router/configuration/overview/#apollo_graph_ref). -* `--set managedFederation.apiKey=""`. The API key to your managed graph, the same value as the [`APOLLO_KEY` environment variable](/router/configuration/overview/#apollo_key). - -Some optional but recommended arguments: - -* `--namespace `. The namespace scope for this deployment. -* `--version `. The version of the router to deploy. If not specified by `helm install`, the latest version is installed. - -### Verify deployment - -Verify that your router is one of the deployed releases with the `helm list` command. - -If you deployed with the `--namespace ` option, you can list only the releases within your namespace: - -```bash -helm list --namespace -``` - -## Deploy with metrics endpoints - -The router supports [metrics endpoints for Prometheus and OpenTelemetry protocol (OTLP)](/router/configuration/telemetry/exporters/metrics/overview). A [basic deployment](#basic-deployment) doesn't enable metrics endpoints, because the router chart disables both Prometheus (explicitly) and OTLP (by omission). - -To enable metrics endpoints in your deployed router through a YAML configuration file: - -1. Create a YAML file, `my_values.yaml`, to contain additional values that override default values. -1. Edit `my_values.yaml` to enable metrics endpoints: - - ```yaml title="my_values.yaml" - router: - configuration: - telemetry: - metrics: - prometheus: - enabled: true - listen: 0.0.0.0:9090 - path: "/metrics" - otlp: - temporality: delta - endpoint: - ``` - - - - Although this example enables both Prometheus and OTLP, in practice it's common to enable only one endpoint. - - - - * `router.configuration.telemetry.metrics.prometheus` was already configured but disabled (`enabled: false`) by default. This configuration sets `enabled: true`. - * `router.configuration.telemetry.metrics.otlp` is enabled by inclusion. - * `router.configuration.telemetry.temporality` by default is `temporality: cumulative` and is a good choice for most metrics consumers. For DataDog, use `temporality: delta`. - -1. Deploy the router with the additional YAML configuration file. For example, starting with the `helm install` command from the basic deployment step, append `--values my_values.yaml`: - - ```bash - helm install --namespace --set managedFederation.apiKey="" --set managedFederation.graphRef="" oci://ghcr.io/apollographql/helm-charts/router --version --values router/values.yaml --values my_values.yaml - ``` - -## Deploy with Rhai scripts - -The router supports [Rhai scripting](/graphos/routing/customization/rhai) to add custom functionality. - -Enabling Rhai scripts in your deployed router requires mounting an extra volume for your Rhai scripts and getting your scripts onto the volume. That can be done by following steps in [a separate example for creating a custom in-house router chart](https://github.com/apollographql/in-house-router-example). The example creates a new (in-house) chart that wraps (and depends on) the released router chart, and the new chart has templates that add the necessary configuration to allow Rhai scripts for a deployed router. - -## Deploy with a coprocessor - -The router supports [external coprocessing](/router/customizations/coprocessor) to run custom logic on requests throughout the [router's request-handling lifecycle](/graphos/routing/customization/rhai/#router-request-lifecycle). - -A deployed coprocessor has its own application image and container in the router pod. - -To configure a coprocessor and its container for your deployed router through a YAML configuration file: - -1. Create a YAML file, `my_values.yaml`, to contain additional values that override default values. -1. Edit `my_values.yaml` to configure a coprocessor for the router. For reference, follow the [typical](/router/customizations/coprocessor#typical-configuration) and [minimal](/router/customizations/coprocessor#minimal-configuration) configuration examples, and apply them to `router.configuration.coprocessor`. - - - - - - - -1. Edit `my_values.yaml` to add a container for the coprocessor. - - ```yaml title="my_values.yaml" - extraContainers: - - name: # name of deployed container - image: # name of application image - ports: - - containerPort: # must match port of router.configuration.coprocessor.url - env: [] # array of environment variables - ``` - -1. Deploy the router with the additional YAML configuration file. For example, starting with the `helm install` command from the basic deployment step, append `--values my_values.yaml`: - - ```bash - helm install --namespace --set managedFederation.apiKey="" --set managedFederation.graphRef="" oci://ghcr.io/apollographql/helm-charts/router --version --values router/values.yaml --values my_values.yaml - ``` - -## Separate configurations per environment - -To support your different deployment configurations for different environments (development, staging, production, etc.), Apollo recommends separating your configuration values into separate files: - -- A **common** file, which contains values that apply across all environments. -- A unique **environment** file per environment, which includes and overrides the values from the common file while adding new environment-specific values. - -The `helm install` command applies each `--values ` option in the order you set them within the command. Therefore, a common file must be set before an environment file so that the environment file's values are applied last and override the common file's values. - -For example, this command deploys with a `common_values.yaml` file applied first and then a `prod_values.yaml` file: - -```bash -helm install --namespace --set managedFederation.apiKey="" --set managedFederation.graphRef="" oci://ghcr.io/apollographql/helm-charts/router --version --values router/values.yaml --values common_values.yaml --values prod_values.yaml -``` - -## Deploying in Kubernetes with Istio - -[Istio](https://istio.io/) is a service mesh for Kubernetes which is often installed on a cluster for its traffic-shaping abilities. While we do not specifically recommend or support Istio, nor do we provide specific instructions for installing the Router in a cluster with Istio, there is a known consideration to make when configuring Istio. - -Consideration and additional configuration may be necessary as a consequence of how Istio does its sidecar injection. Without additional configuration, Istio may attempt to reconfigure the network interface at the same time the router is starting, which will result in a failure to start. - -This is not specifically a router issue and Istio has instructions on how to manage the matter in a general sense in their [own documentation](https://istio.io/latest/docs/ops/common-problems/injection/#pod-or-containers-start-with-network-issues-if-istio-proxy-is-not-ready). Their suggestion prevents the startup of all other containers in a pod until Istio itself is ready. We recommend this approach when using Istio. - -## Configure for migration from gateway - -When [migrating from `@apollo/gateway` to the router](/router/migrating-from-gateway), consider the following tips to maximize the compatibility of your router deployment. - -### Increase maximum request bytes - -By default the router sets its maximum supported request size at 2MB, while the gateway sets its maximum supported request size at 20MB. If your gateway accepts requests larger than 2MB, which it does by default, you can use the following configuration to ensure that the router is compatible with your gateway deployment. - - ```yaml title="values.yaml" - router: - configuration: - limits: - http_max_request_bytes: 20000000 #20MB - ``` - -### Increase request timeout - -The router's timeout is increased to accommodate subgraph operations with high latency. - - ```yaml title="values.yaml" - router: - configuration: - traffic_shaping: - router: - timeout: 6min - all: - timeout: 5min - ``` - -### Propagate subgraph errors - -The gateway propagates subgraph errors to clients, but the router doesn't by default, so it needs to be configured to propagate them. - - ```yaml title="values.yaml" - router: - configuration: - include_subgraph_errors: - all: true - ``` - -## Troubleshooting - -### Pods terminating due to memory pressure - -If your deployment of routers is terminating due to memory pressure, you can add router cache metrics to monitor and remediate your system: - -1. Add and track the following metrics to your monitoring system: - - * `apollo.router.cache.storage.estimated_size` - * `apollo_router_cache_size` - * ratio of `apollo_router_cache_hits` to `apollo_router_cache_misses` - -2. Observe and monitor the metrics: - - * Observe the `apollo.router.cache.storage.estimated_size` to see if it grows over time and correlates with pod memory usage. - * Observe the ratio of cache hits to misses to determine if the cache is being effective. - -3. Based on your observations, try some remediating adjustments: - - * Lower the cache size if the cache reaches near 100% hit-rate but the cache size is still growing. - * Increase the pod memory if the cache hit rate is low and the cache size is still growing. - * Lower the cache size if the latency of query planning cache misses is acceptable and memory availability is limited. diff --git a/docs/source/routing/self-hosted/containerization/kubernetes/extensibility.mdx b/docs/source/routing/self-hosted/containerization/kubernetes/extensibility.mdx new file mode 100644 index 0000000000..b7d0acdca7 --- /dev/null +++ b/docs/source/routing/self-hosted/containerization/kubernetes/extensibility.mdx @@ -0,0 +1,138 @@ +--- +title: Router Extensibility Features in Kubernetes +subtitle: Learn how to deploy a self-hosted router (GraphOS Router or Apollo Router Core) in Kubernetes with extensibility features +description: How to deploy the Apollo GraphOS Router or Apollo Router Core in Kubernetes with extensibility features. +--- + + + +The router supports two extensibility options to customize the router's behavior. The extensibility features are: + +- [Rhai scripting](/graphos/routing/customization/rhai) +- [External coprocessors](/router/customizations/coprocessor) + +This guide shows how to deploy a router with these features in Kubernetes. + +## Deploy with Rhai scripts + +The router supports [Rhai scripting](/graphos/routing/customization/rhai) to add custom functionality. + +Enabling Rhai scripts in your deployed router requires mounting an extra volume for your Rhai scripts and getting your scripts onto the volume. That can be done by following steps in [a separate example for creating a custom in-house router chart](https://github.com/apollographql/in-house-router-example). The example creates a new (in-house) chart that depends on the released router chart, and the new chart has templates that add the necessary configuration to allow Rhai scripts for a deployed router. + + + Ideally this would a separate example fully within the docs, however the `in-house-router-example` is public and the example is a good one so it isn't worth duplicating the effort as of now. + + +## Deploying with a coprocessor + +You have two options to consider when deploying a coprocessor. + +* [Deploy as a sidecar container](#deploy-as-a-sidecar-container) +* [Deploy as a separate Kubernetes `Deployment`](#deploying-using-a-separate-deployment) + +Consider the following when deciding which option to use: + +* The sidecar container option is the simplest and most common way to deploy a coprocessor. It allows you to run the coprocessor in the same pod as the router, which can simplify networking and configuration. +* The separate `Deployment` option allows you to run the coprocessor in a different pod, which can be useful if you want to scale the coprocessor independently of the router. + +### Deploy as a sidecar container + +The router supports [external coprocessing](/router/customizations/coprocessor) to run custom logic on requests throughout the [router's request-handling lifecycle](/graphos/routing/customization/rhai/#router-request-lifecycle). + +A deployed coprocessor can have its own application image and container in the router pod. + +To configure a coprocessor and its container for your deployed router through a YAML configuration file: + +1. Create a YAML file, `coprocessor_values.yaml`, to contain additional values that override default values. +1. Edit `coprocessor_values.yaml` to configure a coprocessor for the router. For reference, follow the [typical](/router/customizations/coprocessor#typical-configuration) and [minimal](/router/customizations/coprocessor#minimal-configuration) configuration examples, and apply them to `router.configuration.coprocessor`. + + + + + + + +1. Edit `coprocessor_values.yaml` to add a container for the coprocessor. + + ```yaml title="coprocessor_values.yaml" + extraContainers: + - name: # name of deployed container + image: # name of application image + ports: + - containerPort: # must match port of router.configuration.coprocessor.url + env: [] # array of environment variables + ``` + +1. Deploy the router with the additional YAML configuration file. For example, starting with the `helm install` command from the basic deployment step, append `--values coprocessor_values.yaml`: + + ```bash + helm install --namespace --set managedFederation.apiKey="" --set managedFederation.graphRef="" oci://ghcr.io/apollographql/helm-charts/router --version --values router/values.yaml --values coprocessor_values.yaml + ``` + +### Deploying using a separate `Deployment` + +Deploying as a separate `Deployment` can take shape in two ways: + +* Using an entirely separate Helm chart. +* Using the router's Helm chart as a dependency and adding a new `Deployment` template + * This option is more complex but allows you to customize the router's Helm chart and add your own templates whilst keeping the coporcessor's deployment alongside the router's. + +#### Separate Helm chart + +In the case of using a separate Helm chart, a `coprocessor` chart would be deployed independently of the router. This chart would contain the configuration for the coprocessor's deployment. An example folder structure might look like: + +``` +charts/ +├── coprocessor/ +│ ├── Chart.yaml +│ ├── values.yaml +│ ├── templates/ +│ │ ├── deployment.yaml +│ │ ├── service.yaml +│ │ └── ... +│ └── ... +├── router/ +│ ├── values.yaml +│ └── ... +``` + + +The `router` chart would be the router's Helm chart, which you can deploy as described in the [Kubernetes deployment guide](/graphos/routing/self-hosted/containerization/kubernetes/quickstart). + +#### Using the router's Helm chart as a dependency + +In the case of using the router's Helm chart as a dependency, you can create a new template in the `templates` folder of the `router` Helm chart. This template would contain the configuration for the coprocessor's deployment. + +The `Chart.yaml` file for the router would include: + +```yaml +dependencies: + - name: router + version: 2.3.0 + repository: oci://ghcr.io/apollographql/helm-charts +``` + +An example folder structure might look like: + +``` +charts/ +├── router/ +│ ├── Chart.yaml +│ ├── values.yaml +│ ├── templates/ +│ │ ├── deployment.yaml +│ │ ├── service.yaml +│ │ └── ... +│ └── ... +``` + +In the above example, the `router` chart would be the router's Helm chart, which you can deploy as described in the [Kubernetes deployment guide](/graphos/routing/self-hosted/containerization/kubernetes/quickstart). The `templates` folder would contain the configuration for the coprocessor's deployment. Within the `values.yaml` you can then nest the necessary configuration under the `router` key, such as: + +```values.yaml +router: + configuration: + coprocessor: + url: http://: +``` + + diff --git a/docs/source/routing/self-hosted/containerization/kubernetes/metrics.mdx b/docs/source/routing/self-hosted/containerization/kubernetes/metrics.mdx new file mode 100644 index 0000000000..f4a639ea3b --- /dev/null +++ b/docs/source/routing/self-hosted/containerization/kubernetes/metrics.mdx @@ -0,0 +1,47 @@ +--- +title: Enabling Metrics Endpoints +subtitle: Learn how to use a self-hosted router in Kubernetes with metrics endpoints +description: Use the Apollo GraphOS Router or Apollo Router Core in Kubernetes with metrics endpoints and considerations when doing so. +--- + + + +The router supports [metrics endpoints for Prometheus and OpenTelemetry protocol (OTLP)](/router/configuration/telemetry/exporters/metrics/overview). The default deployment doesn't enable metrics endpoints because the router chart disables both Prometheus (explicitly) and OTLP (by omission). + +This guide shows how to deploy a router with metric endpoints in Kubernetes. + +## Deploy with metrics endpoints +To enable metrics endpoints in your deployed router through a YAML configuration file: + +1. Create a YAML file, `my_values.yaml`, to contain additional values that override default values. +1. Edit `my_values.yaml` to enable metrics endpoints: + + ```yaml title="my_values.yaml" + router: + configuration: + telemetry: + metrics: + prometheus: + enabled: true + listen: 0.0.0.0:9090 + path: "/metrics" + otlp: + temporality: cumulative # default; if using DataDog, use temporality: delta + endpoint: + ``` + + + + Although this example enables both Prometheus and OTLP, in practice it's common to enable only one endpoint. + + + + * `router.configuration.telemetry.metrics.prometheus` was already configured but disabled (`enabled: false`) by default. This configuration sets `enabled: true`. + * `router.configuration.telemetry.metrics.otlp` is enabled by inclusion. + * `router.configuration.telemetry.temporality` by default is `temporality: cumulative` and is a good choice for most metrics consumers. For DataDog, use `temporality: delta`. + +1. Deploy the router with the additional YAML configuration file. For example, starting with the `helm install` command from the basic deployment step, append `--values my_values.yaml`: + + ```bash + helm install --namespace --set managedFederation.apiKey="" --set managedFederation.graphRef="" oci://ghcr.io/apollographql/helm-charts/router --version --values router/values.yaml --values my_values.yaml + ``` diff --git a/docs/source/routing/self-hosted/containerization/kubernetes/other-considerations.mdx b/docs/source/routing/self-hosted/containerization/kubernetes/other-considerations.mdx new file mode 100644 index 0000000000..bb793a9076 --- /dev/null +++ b/docs/source/routing/self-hosted/containerization/kubernetes/other-considerations.mdx @@ -0,0 +1,59 @@ +--- +title: Considerations for Hosting the Router in Kubernetes +subtitle: Learn about other considerations for hosting the router in Kubernetes, including Istio and resources +description: Learn about other considerations for hosting the router in Kubernetes, including Istio and resources. +--- + + + +There are a few other considerations to keep in mind when hosting the router in Kubernetes. These include: + +- [Using Istio](#deploying-in-kubernetes-with-istio) +- [Troubleshooting a hosted router](#troubleshooting-a-hosted-router) +- [Shutting down gracefully](#shutting-down-gracefully) + +## Deploying in Kubernetes with Istio + +[Istio](https://istio.io/) is a service mesh for Kubernetes which is often installed on a cluster for its traffic-shaping abilities. While Apollo dopes not specifically recommend or support Istio, nor does Apollo provide specific instructions for installing the Router in a cluster with Istio, there is a known consideration to make when configuring Istio. + +Consideration and additional configuration may be necessary as a consequence of how Istio does its sidecar injection. Without additional configuration, Istio may attempt to reconfigure the network interface at the same time the router is starting, which will result in a failure to start. + +This is not specifically a router issue and Istio has instructions on how to manage the matter in a general sense in their [own documentation](https://istio.io/latest/docs/ops/common-problems/injection/#pod-or-containers-start-with-network-issues-if-istio-proxy-is-not-ready). Their suggestion prevents the startup of all other containers in a pod until Istio itself is ready. Apollo recommends this approach when using Istio. + +## Troubleshooting a hosted router + + + +To manage the system resources you need to deploy the router on Kubernetes: + +* Read [Managing router resources in Kubernetes](/technotes/TN0016-router-resource-management/). +* Use the [router resource estimator](/technotes/TN0045-router_resource_estimator/). + + + +### Pods terminating due to memory pressure + +If your deployment of routers is terminating due to memory pressure, you can add router cache metrics to monitor and remediate your system: + +1. Add and track the following metrics to your monitoring system: + + * `apollo.router.cache.storage.estimated_size` + * `apollo.router.cache.size` + * ratio of `apollo.router.cache.hit.time.count` to `apollo.router.cache.miss.time.count` + +2. Observe and monitor the metrics: + + * Observe the `apollo.router.cache.storage.estimated_size` to see if it grows over time and correlates with pod memory usage. + * Observe the ratio of cache hits to misses to determine if the cache is being effective. + +3. Based on your observations, try some remediating adjustments: + + * Lower the cache size if the cache reaches near 100% hit-rate but the cache size is still growing. + * Increase the pod memory if the cache hit rate is low and the cache size is still growing. + * Lower the cache size if the latency of query planning cache misses is acceptable and memory availability is limited. + +## Shutting down gracefully + +Apollo Router stops accepting new requests immediately after receiving a `SIGTERM` in Kubernetes. It waits for all active requests to complete before shutting down. There is no time limit controlling this process; it continues until all requests either finish or time out at the request level. + +You can configure health checks using [Kubernetes lifecycle hooks](https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/) to ensure all pods shut down safely. You can also set the shutdown grace period to be slightly longer than any configured [router timeouts](/graphos/routing/performance/traffic-shaping). diff --git a/docs/source/routing/self-hosted/containerization/kubernetes/quickstart.mdx b/docs/source/routing/self-hosted/containerization/kubernetes/quickstart.mdx new file mode 100644 index 0000000000..26f76f635f --- /dev/null +++ b/docs/source/routing/self-hosted/containerization/kubernetes/quickstart.mdx @@ -0,0 +1,135 @@ +--- +title: Kubernetes Quickstart +subtitle: Learn how to deploy a self-hosted router in Kubernetes using Helm charts +description: Deploy the Apollo GraphOS Router or Apollo Router Core in Kubernetes using Helm charts. Customize configurations, enable metrics, and choose values for migration. +redirectFrom: + - /graphos/routing/self-hosted/containerization/kubernetes +--- + +import HelmShowOutput from '../../../../../shared/helm-show-router-output.mdx'; +import CoprocTypicalConfig from '../../../../../shared/coproc-typical-config.mdx'; + +This guide shows how to: + +* Get the router Helm chart from the Apollo container repository. +* Deploy a router with a basic Helm chart. + + + +## Prerequisites + + + +This guide assumes you are familiar with Kubernetes and Helm. If you are not familiar with either, you can find a [Kubernetes tutorial](https://kubernetes.io/docs/tutorials/) and a [Helm tutorial](https://helm.sh/docs/intro/quickstart/) to get started. + + + +* A GraphOS graph set up in your Apollo account. If you don't have a graph, you can create one in the [GraphOS Studio](https://studio.apollographql.com/). +* [Helm](https://helm.sh/docs/intro/install/) **version 3.x or higher** installed on your local machine. +* A Kubernetes cluster with access to the internet. + +### GraphOS graph + +Set up your self-hosted graph and get its [graph ref](/router/configuration/overview/#apollo_graph_ref) and [API key](/router/configuration/overview/#apollo_graph_ref). + +If you need a guide to set up your graph, you can follow [the self-hosted router quickstart](/graphos/quickstart/self-hosted) and complete [step 1 (Set up Apollo tools)](/graphos/quickstart/self-hosted/#1-set-up-apollo-tools), [step 4 (Obtain your subgraph schemas)](/graphos/quickstart/self-hosted/#4-obtain-your-subgraph-schemas), and [step 5 (Publish your subgraph schemas)](/graphos/quickstart/self-hosted/#5-publish-your-subgraph-schemas). + +### Kubernetes cluster + +If you don't have a Kubernetes cluster, you can set one up using [kind](https://kind.sigs.k8s.io/) or [minikube](https://minikube.sigs.k8s.io/docs/) locally, or by referring to your cloud provider's documentation. + +## Quickstart + +To deploy the router, run the `helm install` command with an argument for the router's OCI image URL. Optionally, you can add arguments for the `values.yaml` configuration file and/or additional arguments to override specific configuration values. + +```bash +helm install --namespace apollo-router --set managedFederation.apiKey="" --set managedFederation.graphRef="" oci://ghcr.io/apollographql/helm-charts/router +``` + +The necessary arguments for specific configuration values: + +* `--set managedFederation.graphRef=""`: The reference to your managed graph (`id@variant`), the same value as the [`APOLLO_GRAPH_REF` environment variable](/router/configuration/overview/#apollo_graph_ref). +* `--set managedFederation.apiKey=""`: The API key to your managed graph, the same value as the [`APOLLO_KEY` environment variable](/router/configuration/overview/#apollo_key). + +Some optional but recommended arguments: + +* `--namespace `. The namespace scope for this deployment. +* `--version `. The version of the router to deploy. If not specified by `helm install`, the latest version is installed. + +### Verify deployment + +Verify that your router is one of the deployed releases with the `helm list` command. If you deployed with the `--namespace ` option, you can list only the releases within your namespace: + +```bash +helm list --namespace +``` +## Deployed architecture + +The default deployed architecture will be: + +```mermaid +flowchart LR; + clients(Clients); + subgraph "Kubernetes cluster"; + lb(["Load Balancer"]) + router_a(["Router"]); + router_b(["Router"]); + end; + clients -.->|Query| lb; + lb -.-> router_a & router_b; + class clients secondary; +``` + +## Router Helm chart configuration + + Apollo provides an application Helm chart with each release of Apollo Router Core in GitHub. Since the router version v0.14.0, Apollo has released the router Helm chart as an [Open Container Initiative (OCI)](https://helm.sh/docs/topics/registries/) image in the GitHub container registry. + + + +The path to the OCI router chart is `oci://ghcr.io/apollographql/helm-charts/router` and tagged with the applicable router release version. For example, router version `v2.3.0`'s Helm chart would be `oci://ghcr.io/apollographql/helm-charts/router:2.3.0`. + + + +You customize a deployed router with the same [command-line options and YAML configuration options](/router/configuration/overview) using different Helm CLI options and YAML keys through a [values file](https://helm.sh/docs/chart_template_guide/values_files/). + +Each router chart has a defult `values.yaml` file with router and deployment settings. The released, unedited file has a few explicit settings, including: + +* Default container ports for the router's [HTTP server](/router/configuration/overview/#listen-address), [health check endpoint](/router/configuration/health-checks), and [metrics endpoint](/router/configuration/telemetry/exporters/metrics/overview). +* A command-line argument to enable [hot reloading of the router](/router/configuration/overview/#--hr----hot-reload). +* A single replica. + + + +The values of the Helm chart for Apollo Router Core v2.3.0 in the GitHub container repository, as output by the `helm show` command: + +```bash +helm show values oci://ghcr.io/apollographql/helm-charts/router +``` + + + + + +## Separate configurations per environment + +To support your different deployment configurations for different environments (development, staging, production, etc.), Apollo recommends separating your configuration values into separate files: + +- A **common** file, which contains values that apply across all environments. +- A unique **environment** file per environment, which includes and overrides the values from the common file while adding new environment-specific values. + +The `helm install` command applies each `--values ` option in the order you set them within the command. Therefore, a common file must be set before an environment file so that the environment file's values are applied last and override the common file's values. + +For example, this command deploys with a `common_values.yaml` file applied first and then a `prod_values.yaml` file: + +```bash +helm install --namespace --set managedFederation.apiKey="" --set managedFederation.graphRef="" oci://ghcr.io/apollographql/helm-charts/router --version --values router/values.yaml --values common_values.yaml --values prod_values.yaml +``` + +## Consider using the Apollo GraphOS Operator + +The [Apollo GraphOS Operator](/apollo-operator/), which is in Preview, offers an alternative method to deploy the router and manage your schema and subgraphs. The Operator provides declarative Kubernetes resources for managing routers, supergraphs, graph schemas, and subgraphs. It can simplify complex multi-service architectures. + +The Operator offers different [workflow patterns](/apollo-operator/workflows/) depending on your infrastructure: +- Single-cluster setups for simpler deployments +- Multi-cluster and hybrid configurations for distributed services +- Deploy-only patterns for existing CI/CD workflows diff --git a/docs/source/routing/self-hosted/health-checks.mdx b/docs/source/routing/self-hosted/health-checks.mdx index 6a2566c0c5..41966f296c 100644 --- a/docs/source/routing/self-hosted/health-checks.mdx +++ b/docs/source/routing/self-hosted/health-checks.mdx @@ -89,15 +89,46 @@ In Kubernetes, you can configure health checks by setting `readinessProbe` and ` # ... snipped for partial example ... livenessProbe: httpGet: - path: "/health" + path: "/health?live" port: 8088 readinessProbe: httpGet: - path: "/health" + path: "/health?ready" port: 8088 # ... snipped for partial example ... ``` + See a more complete example in our [Kubernetes documentation](/router/containerization/kubernetes/). + + +For these checks we take advantage of additional functionality in the router which enables specific "ready" and "live" checks to better support kubernetes deployments. For each check, if the router is live or ready it will return OK (200). If not, it will return Service Unavailable (503). + + +### Liveness + +Liveness is clearly defined in Router 2 as the point at which a router configuration has been activated. From this point onwards, the router will remain Live unless the endpoint stops responding. + +### Readiness + +Readiness is clearly defined in Router 2 as the point at which a router configuration has been activated. From this point onwards, the router will monitor responses and identify over-loading. If over-loading passes beyond a defined tolerance, the router will declare itself unready for a period of time. During this time, it will continue to service requests and when the unready period expires, the router will once more start to monitor for over-loading. This is all controlled by new configuration in the router health check. + +```yaml title="router.yaml" +health_check: + listen: 0.0.0.0:8088 + enabled: true + readiness: # optional, with default as detailed below + allowed: 50 # optional, default 100 + interval: + sampling: 5s # optional, default 5s + unready: 10s # optional, default (2 * sampling) +``` + +In this snippet, readiness has been configured to allow 50 rejections due to load shedding (GATEWAY_TIMEOUT or SERVICE_UNAVAILABLE) in each sampling interval (10 seconds). If the router determines that it is "unready", i.e.: these limits are exceeded, then it will indicate this status (SERVICE_UNAVAILABLE) via the `readinessProbe` for the unready interval (30 seconds). Once this interval has passed, it will return to "ready" and start sampling responses. + + +The default sampling and unready intervals are chosen to align with the defaults for Kubernetes readinessProbe interval (10s). The idea being that there is sampling within a default interval and that the unready interval matches the probe perdiod. + + ## Using with Docker Docker has a `HEALTHCHECK` instruction that tells Docker how to test whether a container is still working. These are defined in the `Dockerfile` when building your container: ``` diff --git a/docs/source/routing/self-hosted/index.mdx b/docs/source/routing/self-hosted/index.mdx index 1f7b68e428..615d0b22e0 100644 --- a/docs/source/routing/self-hosted/index.mdx +++ b/docs/source/routing/self-hosted/index.mdx @@ -1,10 +1,9 @@ --- -title: Self-Hosted Router -subtitle: Host and deploy routers in your own infrastructure -description: Distribute operations efficiently across microservices in your federated GraphQL API with the Apollo GraphOS Router or Apollo Router Core. Configure caching, security features, and more. +title: Self-Hosting the Apollo Router +subtitle: How to deploy the router on your own infrastructure --- -A self-hosted GraphOS Router or Apollo Router Core enables you to fully manage the runtime infrastructure and deployments of your supergraph. +Apollo Router is a graph runtime that you can deploy in your own infrastructure. ```mermaid flowchart LR; @@ -19,31 +18,44 @@ flowchart LR; class clients secondary; ``` - +For each version of the Apollo Router, Apollo provides: -Self-hosting the GraphOS Router is limited to [GraphOS Enterprise plans](https://www.apollographql.com/pricing). Other plan types use [managed cloud routing with GraphOS](/graphos/cloud-routing). Check out the [pricing page](https://www.apollographql.com/pricing#graphos-router) to learn more. +- [A Helm chart for Kubernetes](#kubernetes-using-helm) +- [A Docker image](#container) +- [A binary](#local-binary) - +## Kubernetes using Helm -## Downloading and installing a self-hosted router +Helm is a package manager for Kubernetes. Apollo provides a Helm chart with each release of Apollo Router in the GitHub Container Registry. Since router v0.14.0, Apollo has released each router Helm chart as an Open Container Initiative (OCI) image in `oci://ghcr.io/apollographql/helm-charts/router`. -Apollo provides the router as both a binary and as images to run in your containerized deployments. +Follow our [Kubernetes quickstart](/graphos/routing/kubernetes/quickstart) to deploy the router with a Helm chart. -### Router binary +## Docker -Apollo provides a binary of the router for multiple platforms. You can download the router bundle, extract the router binary, and run it. +### Apollo Runtime Container (Recommended) -To learn more, follow the steps in the [self-hosted router installation](/graphos/reference/router/self-hosted-install) reference to run a router binary with a sample supergraph schema. +Apollo provides the [Apollo Runtime Container](https://github.com/apollographql/apollo-runtime), which bundles all that's required to run the Apollo Runtime in one place. This includes the Apollo Router and the [Apollo MCP Server](/apollo-mcp-server). -### Router container image +You can download the images from: -Apollo provides container images of the router to deploy in self-hosted environments. The router images are hosted on GitHub in its container repository. Each router release includes both production and debug images. +- [Docker Hub](https://hub.docker.com/r/apollograph/apollo-runtime) (recommended) +- [GitHub Container Registry](https://github.com/apollographql/apollo-runtime/pkgs/container/apollo-runtime) -To learn more, go to the [containerization overview](/graphos/routing/self-hosted/containerization). +For more information on deploying using your container environment: -- If running with Docker, go to the [Docker](/graphos/routing/self-hosted/containerization/docker) docs to learn how to run router images. -- If running with Kubernetes, go to the [Kubernetes](/graphos/routing/self-hosted/containerization/kubernetes) docs to learn to deploy with router Helm charts. +- [Docker](/graphos/routing/self-hosted/containerization/docker) -## Next steps +### Router only container -- To prepare your routers for production deployment, follow the guides in [Production Readiness](/graphos/platform/production-readiness/checklist) docs. +This image is recommended only for Kubernetes-based deployments, and is used by the [Helm chart](/router/containerization/kubernetes/). For more information on deploying using your container environment: + +- [Docker](/graphos/routing/self-hosted/containerization/docker-router-only) +- [AWS using Elastic Container Service (ECS)](/graphos/routing/self-hosted/containerization/aws) +- [Azure using Azure Container App](/graphos/routing/self-hosted/containerization/azure) +- [GCP using Google Cloud Run](/graphos/routing/self-hosted/containerization/gcp) + +## Local binary + +Running the Apollo Router directly from its binary speeds up local development and enables embedded use cases where containers are unavailable. + +Follow the [quickstart](/graphos/routing/get-started) to run a router binary. diff --git a/docs/source/routing/self-hosted/resource-estimator.mdx b/docs/source/routing/self-hosted/resource-estimator.mdx index 26d8e363d0..d37a1c8511 100644 --- a/docs/source/routing/self-hosted/resource-estimator.mdx +++ b/docs/source/routing/self-hosted/resource-estimator.mdx @@ -19,9 +19,9 @@ The _router resource estimator_ estimates the system resources you need to alloc To use the estimator: 1. Enter your estimated **request rate per second** and **peak request rate per second**. -2. Adjust the other parameters to match your estimated production load. - - When selecting the **number of instances**, select fewer for efficiency, more for safety. -3. Be sure to test and iterate. +1. Adjust the other parameters to match your estimated production load. For **number of instances**, use fewer for improved efficiency or more for improved redundancy. +1. Adjust the **Assumptions** as necessary. +1. Be sure to test and iterate. ## Router resource estimator diff --git a/docs/source/routing/self-hosted/resource-management.mdx b/docs/source/routing/self-hosted/resource-management.mdx index 0e40121313..9b68551983 100644 --- a/docs/source/routing/self-hosted/resource-management.mdx +++ b/docs/source/routing/self-hosted/resource-management.mdx @@ -9,11 +9,11 @@ redirectFrom: - /technotes/TN0016-router-resource-management/ --- - + -Self-hosting the GraphOS Router is limited to [GraphOS Enterprise plans](https://www.apollographql.com/pricing). Other plan types use [managed cloud routing with GraphOS](/graphos/cloud-routing). Check out the [pricing page](https://www.apollographql.com/pricing#graphos-router) to learn more. +Self-hosting the GraphOS Router requires a current GraphOS [Enterprise or Free plan](https://www.apollographql.com/pricing). - + Determining the correct resource requests and limits for your application pods in a Kubernetes system is not an exact science. Your specific needs depend on many factors, including: diff --git a/docs/source/reference/migration/from-router-version-0.x.mdx b/docs/source/routing/upgrade/from-router-v0.mdx similarity index 100% rename from docs/source/reference/migration/from-router-version-0.x.mdx rename to docs/source/routing/upgrade/from-router-v0.mdx diff --git a/docs/source/routing/upgrade/from-router-v1.mdx b/docs/source/routing/upgrade/from-router-v1.mdx new file mode 100644 index 0000000000..83dfbbc518 --- /dev/null +++ b/docs/source/routing/upgrade/from-router-v1.mdx @@ -0,0 +1,639 @@ +--- +title: Upgrading from Versions 1.x +subtitle: Upgrade from version 1.x to 2.x of GraphOS Router +description: Learn how to upgrade from version 1.x to 2.x of Apollo GraphOS Router. +--- + +GraphOS Router v2.x includes various breaking changes when upgrading from v1.x, including removing deprecated features and renaming public interfaces to be more future-proof. + +This upgrade guide describes the steps to upgrade your GraphOS Router deployment from version 1.x to 2.x. It describes breaking changes and how to resolve them. It also recommends new features to use. + +## Upgrade strategy + + + +Before making any changes, auto-upgrade your configuration. This will remove options that already have no effect in v1.x, and make the rest of the upgrade easier. + +Check the changes that will be applied using: +```bash +router config upgrade --diff router.yaml +``` + +Then apply the changes using: +```bash +router config upgrade router.yaml > router.next.yaml +mv router.next.yaml router.yaml +``` + +### Resource utilization changes + +The 2.x release includes significant architectural improvements to enable support for backpressure. The router will now start rejecting requests when it is busy, instead of queueing them in memory. This change can cause changes in resource utilization, including increased CPU usage because the router can handle more requests. + +During upgrade, carefully monitor logs and resource consumption to ensure that your router has successfully upgraded and that your router has enough resources to perform as expected. + +## Removals and deprecations + +The following headings describe features that have been removed or deprecated in router v2.x. Alternatives to the removed or deprecated features are described, if available. + +### Removed metrics + +Multiple metrics have been removed in router v2.x as part of evolving towards OpenTelemetry metrics and conventions. Each of the removed metrics listed below has a replacement metric or a method for deriving its value: + +- Removed `apollo_router_http_request_retry_total`. This is replaced by `http.client.request.duration` metric's `http.request.resend_count` attribute. Set [`default_requirement_level`](/graphos/reference/router/telemetry/instrumentation/instruments#default_requirement_level) + to `recommended` to make the router emit this attribute. + +- Removed `apollo_router_timeout`. This metric conflated timed-out requests from client + to the router, and requests from the router to subgraphs. Timed-out requests + have HTTP status code 504. Use the `http.response.status_code` attribute on the + `http.server.request.duration` metric to identify timed-out router requests, and + the same attribute on the `http.client.request.duration` metric to identify + timed-out subgraph requests. + +- Removed `apollo_router_http_requests_total`. This is replaced by + `http.server.request.duration` metric for requests from clients to router and + `http.client.request.duration` for requests from router to subgraphs. + +- Removed `apollo_router_http_request_duration_seconds`. This is replaced by + `http.server.request.duration` metric for requests from clients to router and + `http.client.request.duration` for requests from router to subgraphs. + +- Removed `apollo_router_session_count_total`. This does not have an equivalent in 2.0.0, + though one may be introduced in a point release. + +- Removed `apollo_router_session_count_active`. This is replaced by + `http.server.active_requests`. + +- Removed `apollo_require_authentication_failure_count`. Use the + `http.server.request.duration` metric's `http.response.status_code` attribute. + Requests with authentication failures have HTTP status code 401. + +- Removed `apollo_authentication_failure_count`. Use the + `apollo.router.operations.authentication.jwt` metric's + `authentication.jwt.failed` attribute. + +- Removed `apollo_authentication_success_count`. Use the + `apollo.router.operations.authentication.jwt` metric instead. If the + `authentication.jwt.failed` attribute is _absent_ or `false`, the authentication + succeeded. + +- Removed`apollo_router_deduplicated_subscriptions_total`. Use the + `apollo.router.operations.subscriptions` metric's `subscriptions.deduplicated` + attribute. + +- Removed `apollo_router_cache_miss_count`. Cache miss count can be derived from `apollo.router.cache.miss.time`. + +- Removed `apollo_router_cache_hit_count`. Cache hit count can be derived from `apollo.router.cache.hit.time`. + +### Removed processing time metrics + +Calculating the overhead of injecting the router into your service stack when making multiple downstream calls is a complex task. Due to the router being unable to get reliable calculations, the metrics `apollo_router_span` and `apollo_router_processing_time` have been removed. + +**Upgrade step**: test your workloads with the router and validate that its latency meets your requirements. + +### Measuring router overhead + +Measuring overhead in the router can be challenging because it consists of multiple components, each executing tasks in parallel. Subgraph latency, cache performance, and plugins influence performance and have the potential to cause back pressure. Limitations to CPU, memory, and network bandwidth can all create bottlenecks that hinder request processing. External factors such as request rate and operation complexity heavily affect the router’s overall load. + +You can find the activity of a particular request in its trace spans. Spans have the following attributes: + - busy_ns - time in which the span is actively executing + - idle_ns - time in which the span is alive, but not actively executing + +These attributes represent how a span spends time (in nanoseconds) over its lifetime. Your APM provider can likely use this trace data to generate synthetic metrics which you can then create an approximation of. + +### Removed custom instrumentation selectors + + + +The `subgraph_response_body` selector is removed in favor of `subgraph_response_data` and `subgraph_response_errors`. + +**Upgrade step**: replace `subgraph_response_body` with `subgraph_response_data` and `subgraph_response_errors`. For example: + +```yaml +telemetry: + instrumentation: + instruments: + subgraph: + http.client.request.duration: + attributes: + http.response.status_code: + subgraph_response_status: code + my_data_value: + # Previously: + # subgraph_response_body: .data.test + subgraph_response_data: $.test # The data object is the root object of this selector + my_error_code: + # Previously: + # subgraph_response_body: .errors[*].extensions.extra_code + subgraph_response_errors: $[*].extensions.extra_code # The errors object is the root object of this selector +``` + +### Scaffold no longer supported for Rust plugin code generation + + + +Support for the `cargo-scaffold` command to generate boilerplate source code for a Rust plugin has been removed in router v2.x. + +**Upgrade step**: Source code generated using Scaffold will continue to compile, so existing Rust plugins will be unaffected by this change. + +### Removed configurable poll interval for Apollo Uplink + + + +The configurable poll interval of Apollo Uplink has been removed in router v2.x. + +**Upgrade step**: remove uses of both the `--apollo-uplink-poll-interval` command-line argument and the `APOLLO_UPLINK_POLL_INTERVAL` environment variable. + +### Removed hot reloading of supergraph URLs + + + +Hot reloading is no longer supported for supergraph URLs configured via either the `--supergraph-urls` command-line argument or the `APOLLO_ROUTER_SUPERGRAPH_URLS` environment variable. In router v1.x, if hot reloading was enabled, the router would repeatedly fetch the URLs on the interval specified by `--apollo-uplink-poll-interval`. This poll interval has been removed in v2.x. + +**Upgrade step**: if you want to hot reload from a remote URL, try running a script that downloads the supergraph URL at a periodic interval, then point the router to the downloaded file on the filesystem. + +### Removed busy timer for request processing duration + +In `context::Context` that's typically used for router customizations, methods and structs related to request processing duration have been removed, because request processing duration is already included as part of spans sent by the +router. Users customizing the router with Rhai scripts, Rust scripts, or coprocessors don't need to track this information manually. + +**Upgrade step**: remove calls and uses of the following methods and structs from `context::Context`: + +- `context::Context::busy_time()` +- `context::Context::enter_active_request()` +- `context::BusyTimer` struct +- `context::BusyTimerGuard` struct + +### Removed `OneShotAsyncCheckpointLayer` and `.oneshot_checkpoint_async()` + +Both `OneShotAsyncCheckpointLayer` and `.oneshot_checkpoint_async()` are removed as part of architectural optimizations in router v2.x. + +**Upgrade step**: +- Replace uses of `apollo_router::layers::ServiceBuilderExt::oneshot_checkpoint_async` with the `checkpoint_async` method. + +- Replace uses of `OneShotAsyncCheckpointLayer` with `AsyncCheckpointLayer`. For example: + +Previous plugin code using `OneShotAsyncCheckpointLayer`: + +```rust +OneShotAsyncCheckpointLayer::new(move |request: execution::Request| { + let request_config = request_config.clone(); + // ... +}) +.service(service) +.boxed() +``` + +New plugin code using `AsyncCheckpointLayer`: + +```rust +use apollo_router::layers::async_checkpoint_layer::AsyncCheckpointLayer; + +AsyncCheckpointLayer::new(move |request: execution::Request| { + let request_config = request_config.clone(); + // ... +}) +.buffered() +.service(service) +.boxed() +``` + + + +The `buffered()` method is provided by the `apollo_router::layers::ServiceBuilderExt` trait and ensures that your service may be cloned. + + + + +### Removed deprecated methods of Rust plugins + +The following deprecated methods are removed from the public crate API available to Rust plugins: + +- `services::router::Response::map()` +- `SchemaSource::File.delay` field +- `ConfigurationSource::File.delay` field +- `context::extensions::sync::ExtensionsMutex::lock()`. Use `ExtensionsMutex::with_lock()` instead. +- `test_harness::TestHarness::build()`. Use `TestHarness::build_supergraph()` instead. +- `PluginInit::new()`. Use `PluginInit::builder()` instead. +- `PluginInit::try_new()`. Use `PluginInit::try_builder()` instead. + +### Removed Jaeger tracing exporter + +The `jaeger` exporter has been removed, as Jaeger now fully supports the OTLP format. + +**Upgrade step**: + +- Change your router config to use the `otlp` exporter: + +```yaml title="router.yaml" +telemetry: + exporters: + tracing: + propagation: + jaeger: true + otlp: + enabled: true +``` + +- Ensure that you have enabled OTLP support in your Jaeger instance using `COLLECTOR_OTLP_ENABLED=true` and exposing ports `4317` and `4318` for gRPC and HTTP, respectively. + +### Adding custom metrics attributes + +Previously in router v1, you can add custom attributes to metrics via the `telemetry.exporters.metrics.common.attributes` section. In router v2, this has been moved to the `telemetry.exporters.metrics.common.resource` section for static values and to the `telemetry.instrumentation.instruments` section for dynamic values that can select on different request stages. + +**Upgrade step**: move custom attributes from `telemetry.exporters.metrics.common.attributes` to either `telemetry.exporters.metrics.common.resource` for static values or `telemetry.instrumentation.instruments` for dynamic values. Use the examples below as reference: + + +```yaml +# Router v1 +telemetry: + exporters: + metrics: + common: + service_name: "name" + attributes: + router: + static: + - name: "env_full_name" + value: "deployment_env" + request: + header: + - named: "content-type" + rename: "custom_content_name_attribute" + default: "application/json" +``` + +```yaml +# Router v2 +telemetry: + instrumentation: + instruments: + router: + # Add to each instrument + http.server.request.duration: + attributes: + custom_content_name_attribute: + request_header: "content-type" + default: "application/json" + + exporters: + metrics: + common: + service_name: "name" + resource: + env_full_name: "deployment_env" +``` + +### Emitting custom metrics + +Rust plugins can no longer use the router's internal metrics system via `tracing` macros. Consequently, `tracing` field names that start with the following strings aren't interpreted as macros for router metrics: +- `counter.` +- `histogram.` +- `monotonic_counter.` +- `value.` + +**Upgrade step**: instead of using `tracing` macros , use [OpenTelemetry](https://docs.rs/opentelemetry/latest/opentelemetry/) crates. You can use the new `apollo_router::metrics::meter_provider()` API to access the router's global meter provider to register your instruments. + + + +The router v2.x logs an error for each legacy metric field it detects in a `tracing` event. + + + +### Removed `--schema` CLI argument + +The deprecated `--schema` command-line argument is removed in router v2.x + +**Upgrade step**: replace uses of `--schema` with `router config schema` to print the configuration supergraph. + + +### Removed automatically updating configuration at runtime + +The ability to automatically upgrade configurations at runtime is removed. Previously, during configuration parsing/validation, the router 'upgrade migrations' would be applied automatically to generate a valid runtime representation of a config for the life of the executing process. + +Automatic configuration upgrades can still be applied explicitly. + +**Upgrade step**: use the `router config` commands as shown at the top of the upgrade guide. + + + +## Configuration changes + +The following describes changes to router configuration, including renamed options and changed default values. + +### Renamed metrics + +Various metrics in router 2.x have been renamed to conform to the OpenTelemetry convention of using `.` as the namespace separator, instead of `_`. + +**Update step**: use the updated names for the following metrics: + +| Previous metric | Renamed metric | +| --------------- | -------------- | +| `apollo_router_opened_subscriptions` | `apollo.router.opened.subscriptions` | +| `apollo_router_cache_hit_time` | `apollo.router.cache.hit.time` | +| `apollo_router_cache_size` | `apollo.router.cache.size` | +| `apollo_router_cache_miss_time` | `apollo.router.cache.miss.time` | +| `apollo_router_state_change_total` | `apollo.router.state.change.total` | +| `apollo_router_span_lru_size` | `apollo.router.exporter.span.lru.size` * | +| `apollo_router_uplink_fetch_count_total` | `apollo.router.uplink.fetch.count.total` | +| `apollo_router_uplink_fetch_duration_seconds` | `apollo.router.uplink.fetch.duration.seconds`| + + + +\* `apollo.router.exporter.span.lru.size` now also has an additional `exporter` prefix. + +\* `apollo_router_session_count_active` was removed and replaced by `http.server.active_requests`. + + + +### Changed trace default + +In router v2.x, the trace [`telemetry.instrumentation.spans.mode`](/graphos/reference/router/telemetry/instrumentation/spans#mode) has a default value of `spec_compliant`. Previously in router 1.x, its default value was `deprecated`. + +### Changed defaults of GraphOS reporting metrics + +Default values of some GraphOS reporting metrics have been changed from v1.x to the following in v2.x: + +- `telemetry.apollo.signature_normalization_algorithm` now defaults to `enhanced`. (In v1.x the default is `legacy`.) +- `telemetry.apollo.metrics_reference_mode` now defaults to `extended`. (In v1.x the default is `standard`.) + + +### Renamed configuration for Apollo operation usage reporting via OTLP + +The router supports reporting operation usage metrics to GraphOS via OpenTelemetry Protocol (OTLP). + +Prior to version 1.49.0 of the router, all GraphOS reporting was performed using a [private tracing format](/graphos/metrics/sending-operation-metrics#reporting-format). In v1.49.0, we introduced support for using OTel to perform this reporting. In v1.x, this is controlled using the `otlp_tracing_sampler` (or `experimental_otlp_tracing_sampler` prior to v1.61) flag, and it's off by default. + +Now in v2.x, this flag is renamed to `otlp_tracing_sampler`, and it's enabled by default. + +**Upgrade step**: in your router config, replace uses of `experimental_otlp_tracing_sampler` to `otlp_tracing_sampler`. + +Learn more about configuring [usage reporting via OTLP](/router/configuration/telemetry/apollo-telemetry#usage-reporting-via-opentelemetry-protocol-otlp). + + +### Renamed context keys + +The router request context is used to share data across stages of the request pipeline. The keys have been renamed to prevent conflicts and to better indicate which pipeline stage or plugin populates the data. + + + +You can continue using deprecated context key names from router 1.x if you configure `context: deprecated` in your router. For details, see [Context configuration](/graphos/routing/customization/coprocessor#context-configuration). + + + +**Upgrade step**: if you access context entries in a custom plugin, Rhai script, coprocessor, or telemetry selector, you can update your context keys to account for the new names: + +| Previous context key name | New context key name | +| ------------------------- | -------------------- | +| `apollo_authentication::JWT::claims` | `apollo::authentication::jwt_claims` | +| `apollo_authorization::authenticated::required` | `apollo::authorization::authentication_required` | +| `apollo_authorization::scopes::required` | `apollo::authorization::required_scopes` | +| `apollo_authorization::policies::required` | `apollo::authorization::required_policies` | +| `apollo_operation_id` | `apollo::supergraph::operation_id` | +| `apollo_override::unresolved_labels` | `apollo::progressive_override::unresolved_labels` | +| `apollo_override::labels_to_override` | `apollo::progressive_override::labels_to_override` | +| `apollo_router::supergraph::first_event` | `apollo::supergraph::first_event` | +| `apollo_telemetry::client_name` | `apollo::telemetry::client_name` | +| `apollo_telemetry::client_version` | `apollo::telemetry::client_version` | +| `apollo_telemetry::studio::exclude` | `apollo::telemetry::studio_exclude` | +| `apollo_telemetry::subgraph_ftv1` | `apollo::telemetry::subgraph_ftv1` | +| `cost.actual` | `apollo::demand_control::actual_cost` | +| `cost.estimated` | `apollo::demand_control::estimated_cost` | +| `cost.result` | `apollo::demand_control::result` | +| `cost.strategy` | `apollo::demand_control::strategy` | +| `experimental::expose_query_plan.enabled` | `apollo::expose_query_plan::enabled` | +| `experimental::expose_query_plan.formatted_plan` | `apollo::expose_query_plan::formatted_plan` | +| `experimental::expose_query_plan.plan` | `apollo::expose_query_plan::plan` | +| `operation_kind` | `apollo::supergraph::operation_kind` | +| `operation_name` | `apollo::supergraph::operation_name` | +| `persisted_query_hit` | `apollo::apq::cache_hit` | +| `persisted_query_register` | `apollo::apq::registered` | + + + +### Context Keys for Coprocessors + +The [context key renames](#renamed-context-keys) may impact your coprocessor logic. It can be tricky to update all context key usage together with the router upgrade. To aid this, the `context` option for Coprocessors has been extended. + +You can specify `context: deprecated` to send all context with the old names, compatible with v1.x. Context keys are translated to their v1.x names before being sent to the coprocessor, and translated back to the v2.x names after being received from the coprocessor. + + + +`context: true` is an alias for `context: deprecated`. In a future major release, the `context: true` setting will be removed. + + + +You can now also specify exactly which context keys you wish to send to a coprocessor by listing them under the `selective` key. This will reduce the size of the request/response and may improve performance. + +**Upgrade step**: Either upgrade your coprocessor to use the new context keys, or add `context: deprecated` to your coprocessor configuration. + +Example: + +```yaml +coprocessor: + url: http://127.0.0.1:3000 # mandatory URL which is the address of the coprocessor + router: + request: + context: false # Do not send any context entries + supergraph: + request: + headers: true + context: # Send only these 2 context keys to your coprocessor + selective: + - apollo::supergraph::operation_name + - apollo::demand_control::actual_cost + body: true + response: + headers: true + context: all # Send all context keys with new names (2.x version) + body: true + subgraph: + all: + request: + context: deprecated # Send all the context keys with deprecated names (1.x version) +``` + + + +The `selective` context keys feature can not be used together with `deprecated` names. + + + +### Updated syntax for configuring supergraph endpoint path + +The syntax for configuring the router to receive GraphQL requests at a specific URL path has been updated: + +- The syntax for named parameters was changed from a colon to braces: + +```yaml +supergraph: + # Previously: + # path: /foo/:bar/baz + path: /foo/{bar}/baz +``` + +- The syntax for wildcards was changed to require braces and a name: + +```yaml +supergraph: + # Previously: + # path: /foo/* + path: /foo/{*rest} +``` + + + +No syntax changes are required when using the default endpoint path or a path without wildcards. + + + +### Changed syntax for header propagation path + + + +In router v2.x, the path used for selecting data from a client request body for [header propagation](/graphos/routing/header-propagation#insert) must comply with the [JSONPath](https://www.ietf.org/archive/id/draft-goessner-dispatch-jsonpath-00.html) spec. This means a `$` is now required to select the root element. + +**Upgrade step**: in your router config, prefix your paths with a `$` when selecting root elements. For example: + +```yaml +headers: + all: + request: + - insert: + name: from_app_name + # Previously: + # path: .extensions.metadata[0].app_name + path: $.extensions.metadata[0].app_name +``` + +## Functionality changes + +### Updated `tower` service pipeline + +In router v1.x, a brand new `tower::Service` pipeline was built for every request, so Rust plugin hooks were called for every request. Now in router v2.x, the `tower::Service` pipeline is built once and cloned for every request. + +**Upgrade step**: carefully audit how your Rust plugins store state in any `tower` services you add to the pipeline, because the `tower` service is now cloned for every request. + + +## New capabilities + +The following lists new capabilities in router v2.x that we recommend you use. These capabilities don't introduce breaking changes. + +### More granular logging with custom telemetry + + + +Previously, router v1.x had an experimental `experimental_when_header` feature to log requests and responses if a request header was set to a specific value. This feature provided very limited control: + +```yaml title="router.previous.yaml" +telemetry: + exporters: + logging: + # If one of these headers matches we will log supergraph and subgraphs requests/responses + experimental_when_header: # NO LONGER SUPPORTED + - name: apollo-router-log-request + value: my_client + headers: true # default: false + body: true # default: false +``` + +In router v2.x, you can achieve much more granular logging using custom telemetry. The example below logs requests and responses at every stage of the request pipeline: + +```yaml title="router.yaml" +telemetry: + instrumentation: + events: + router: + request: # Display router request log + level: info + condition: + eq: + - request_header: apollo-router-log-request + - my_client + response: # Display router response log + level: info + condition: + eq: + - request_header: apollo-router-log-request + - my_client + supergraph: + request: # Display supergraph request log + level: info + condition: + eq: + - request_header: apollo-router-log-request + - my_client + response: + level: info + condition: + eq: + - request_header: apollo-router-log-request + - my_client + subgraph: + request: # Display subgraph request log + level: info + condition: + eq: + - supergraph_request_header: apollo-router-log-request + - my_client + response: # Display subgraph response log + level: info + condition: + eq: + - supergraph_request_header: apollo-router-log-request + - my_client +``` + +### Improved traffic shaping + + + +Traffic shaping has been improved significantly in router v2.x. We've added a new mechanism, concurrency control, and we've improved the router's ability to observe timeout and traffic shaping restrictions correctly. These improvements do mean that clients of the router may see an increase in errors as traffic shaping constraints are enforced: + +- [Service Unavailable](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503) +- [Gateway Timeout](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/504) + +We recommend that users experiment with their configuration in order to arrive at the right combination of timeout, concurrency and rate limit controls for their particular use case. + +To learn more about configuring the router for traffic shaping, go to [Traffic Shaping](/graphos/routing/performance/traffic-shaping). + +### Enforce introspection depth limit + +To protect against abusive requests, the router enforces a depth limit on introspection queries by default. + +Because the [schema-introspection schema](https://spec.graphql.org/draft/#sec-Schema-Introspection.Schema-Introspection-Schema) is recursive, a client can query fields of the types of some other fields at unbounded nesting levels, and this can produce responses that grow much faster than the size of the request. Consequently, the router by default refuses to execute introspection queries that nest list fields too deep and instead returns an error. + + + +- The criteria matches `MaxIntrospectionDepthRule` in graphql-js, but may change in future versions. + +- In rare cases where the router rejects legitimate queries, you can configure the router to disable the limit by setting `limits.introspection_max_depth: false`. For example: + +```yaml +# Do not enable introspection in production! +supergraph: + introspection: true # Without this, schema introspection is entirely disabled by default +limits: + introspection_max_depth: false # Defaults to true +``` + + + +### Enforce valid CORS configuration + +Previously in router v1.x, invalid values in the CORS configuration, such as malformed regexes, were ignored with an error logged. + +Now in router 2.x, such invalid values in the CORS configuration prevent the router from starting up and result in errors like the following: + +``` +could not create router: CORS configuration error: +``` + +**Upgrade step****: Validate your CORS configuration. For details, go to [CORS configuration documentation](/graphos/routing/security/cors). + +## Deploy your router + +Make sure that you are referencing the correct router release: **v{products.router.version("connectors").version}** + +## Reporting upgrade issues + +If you encounter an upgrade issue that isn't resolved by this article, please search for existing [Apollo Community posts](https://community.apollographql.com/c/router/20) and start a new post if you don't find what you're looking for. diff --git a/docs/source/routing/uplink.mdx b/docs/source/routing/uplink.mdx new file mode 100644 index 0000000000..30359e8172 --- /dev/null +++ b/docs/source/routing/uplink.mdx @@ -0,0 +1,172 @@ +--- +title: Apollo Uplink +subtitle: Fetch your managed router's configuration +description: Learn how to configure Apollo Uplink for managed GraphQL federation, including polling behavior and Uplink URLs. +--- + +When using [managed federation](/federation/managed-federation/overview/), your supergraph's router by default regularly polls an endpoint called _Apollo Uplink_ for its latest supergraph schema and other configuration: + +```mermaid +graph LR; + subgraph "Your infrastructure" + serviceA[Products subgraph]; + serviceB[Reviews subgraph]; + router([Router]); + end + subgraph "Apollo GraphOS" + registry{{Schema Registry}}; + uplink{{Uplink}} + end + serviceA & serviceB -->|"Publishes
schema"| registry; + registry -->|"Updates
config"| uplink; + router -->|Polls for config changes| uplink; + class registry secondary; + class uplink secondary; +``` + +If you're using [Enterprise features](https://www.apollographql.com/pricing), Uplink also serves your router's license. + +To maximize uptime, Uplink is hosted simultaneously at two endpoints, one in GCP and one in AWS: + +- GCP: `https://uplink.api.apollographql.com/` +- AWS: `https://aws.uplink.api.apollographql.com/` + +## Default polling behavior + +### GraphOS Router + +If you use the GraphOS Router with managed federation, it polls Uplink every ten seconds by default. Each time, it cycles through Uplink endpoints until it receives a response. + +Whenever a poll request times out or otherwise fails (the default timeout is thirty seconds), the router continues polling as usual at the next interval. In the meantime, it continues using its most recent successfully obtained configuration. + +### `@apollo/gateway` + +If you use the `@apollo/gateway` library with managed federation, your gateway polls Uplink every ten seconds by default. Each time, it cycles through Uplink endpoints until it receives a response. + + + +Versions of `@apollo/gateway` prior to v0.45.0 don't support multiple Uplink endpoints and only use the GCP endpoint by default. + + + +Whenever a poll request fails, the gateway retries that request (again, using round robin). It continues retrying until a request succeeds, or until reaching the defined maximum number of retries. + +Even if a particular poll request fails all of its retries, the gateway continues polling as usual at the next interval (with its own set of retries if needed). In the meantime, the gateway continues using its most recent successfully obtained configuration. + +## Configuring polling behavior + +You can configure the following aspects of your router's Uplink polling behavior: + +- The interval at which your router polls (minimum ten seconds) +- The list of Uplink URLs your router uses +- The request timeout for each poll request (GraphOS Router only) + - For `@apollo/gateway`, this value is always thirty seconds. +- The number of retries performed for a failed poll request (`@apollo/gateway` only) + - The GraphOS Router does not perform retries for a failed poll request. It continues polling at the next interval. + +### GraphOS Router + +You configure Uplink polling for the GraphOS Router by providing certain command-line options when running the router executable. These options all start with `--apollo-uplink`. + +[See the GraphOS Router docs](/graphos/reference/router/configuration#--apollo-uplink-endpoints). + +### `@apollo/gateway` + +#### Retry limit + +You can configure how many times your gateway retries a single failed poll request like so: + +```js {6} +const { ApolloGateway } = require('@apollo/gateway'); + +// ... + +const gateway = new ApolloGateway({ + uplinkMaxRetries: 2 +}); +``` + +By default, the gateway retries a single poll request a number of times equal to three times the number of [Uplink URLs](#uplink-urls-advanced) (this is almost always `6` times). + +Even if a particular poll request fails all of its retries, the gateway continues polling as usual at the next interval (with its own set of retries if needed). In the meantime, the gateway continues using its most recently obtained configuration. + +#### Poll interval + +You can configure the interval at which your gateway polls Apollo Uplink like so: + +```js {6} +const { ApolloGateway } = require('@apollo/gateway'); + +// ... + +const gateway = new ApolloGateway({ + pollIntervalInMs: 15000 // 15 seconds +}); +``` + +The `pollIntervalInMs` option specifies the polling interval in milliseconds. This value must be at least `10000` (which is also the default value). + +#### Uplink URLs (advanced) + + + +Most gateways never need to configure their list of Apollo Uplink URLs. Consult this section only if advised to do so. + + + +You can provide a custom list of URLs for the gateway to use when polling Uplink. You can provide this list either in the `ApolloGateway` constructor or as an environment variable. + +##### `ApolloGateway` constructor + +Provide a custom list of Uplink URLs to the `ApolloGateway` constructor like so: + +```js {6-9} +const { ApolloGateway } = require('@apollo/gateway'); + +// ... + +const gateway = new ApolloGateway({ + uplinkEndpoints: [ + // Omits AWS endpoint + 'https://uplink.api.apollographql.com/' + ] +}); +``` + +This example omits the AWS endpoint, which means it's never polled. + + + +If you also provide a list of endpoints via [environment variable](#environment-variable), the environment variable takes precedence. + + + +##### Environment variable + +You can provide a comma-separated list of Uplink URLs as the value of the `APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT` environment variable in your gateway's environment: + +```bash +APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT=https://aws.uplink.api.apollographql.com/,https://uplink.api.apollographql.com/ +``` + +## Schema size limit + +Supergraph schemas provided by Uplink cannot exceed 10MB in size. The vast majority of supergraph schemas are well below this limit. + +If your supergraph schema does exceed 10MB, you can set up a [build status webhook](/graphos/platform/insights/notifications/build-status) for your graph. Whenever you're notified of a successful supergraph schema composition, your webhook can fetch the latest supergraph schema [via the Rover CLI](/rover/commands/supergraphs#supergraph-fetch). + +## Bypassing Uplink + + + +In advanced use cases, you may want your router to use a supergraph schema different than the latest validated schema provided by Uplink. For example, you have different deployment environments for the same [graph variant](/graphos/get-started/concepts/graphs-and-variants#variants), and you want everything that managed federation provides except for your routers to use supergraph schemas specific to their deployment environment. + +For this scenario, you can follow a workflow that, instead of retrieving supergraph schemas from Uplink, uses the [GraphOS Platform API](/graphos/reference/platform-api) to retrieve a supergraph schema for a specific [GraphOS launch](/graphos/platform/schema-management/delivery/launch). The workflow, in summary: + +1. When deploying your graphs, publish your subgraphs in a batch using the GraphOS Platform API. + * The Platform API triggers a launch (and possible downstream launches for contracts) and returns the launch ID (and downstream launch IDs, if necessary). +1. Poll for the launch status, until the launch (and all downstream launches) has completed successfully. +1. Retrieve the supergraph schema of the successful launch by calling the Platform API with the launch ID. +1. Set or "pin" the supergraph schema to your routers by deploying them with the [`--supergraph` or `-s` option](/graphos/reference/router/configuration#-s----supergraph). + +For an example with operations calling the Platform API, see a [blue-green deployment example](/graphos/schema-design/guides/production-readiness/best-practices#example-blue-green-deployment). diff --git a/examples/add-timestamp-header/rhai/Cargo.toml b/examples/add-timestamp-header/rhai/Cargo.toml index 5a095bda82..6025f5dce1 100644 --- a/examples/add-timestamp-header/rhai/Cargo.toml +++ b/examples/add-timestamp-header/rhai/Cargo.toml @@ -2,12 +2,13 @@ name = "add-timestamp-header" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } -http = "0.2" +http = "1.2" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } diff --git a/examples/async-auth/rust/Cargo.toml b/examples/async-auth/rust/Cargo.toml index 5749743aea..7c6b35daef 100644 --- a/examples/async-auth/rust/Cargo.toml +++ b/examples/async-auth/rust/Cargo.toml @@ -2,16 +2,17 @@ name = "async-allow-client-id" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } async-trait = "0.1" -http = "0.2" +http = "1.2" schemars = { version = "0.8", features = ["url"] } serde = "1" serde_json = "1" serde_json_bytes.workspace = true tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } diff --git a/examples/async-auth/rust/src/allow_client_id_from_file.rs b/examples/async-auth/rust/src/allow_client_id_from_file.rs index a41e6ce370..39a0efe247 100644 --- a/examples/async-auth/rust/src/allow_client_id_from_file.rs +++ b/examples/async-auth/rust/src/allow_client_id_from_file.rs @@ -48,12 +48,12 @@ impl Plugin for AllowClientIdFromFile { // switching the async file read with an async http request fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { let header_key = self.header.clone(); - // oneshot_async_checkpoint is an async function. + // async_checkpoint is an async function. // this means it will run whenever the service `await`s it // given we're getting a mutable reference to self, // self won't be present anymore when we `await` the checkpoint. // - // this is solved by cloning the path and moving it into the oneshot_async_checkpoint callback. + // this is solved by cloning the path and moving it into the async_checkpoint callback. // // see https://rust-lang.github.io/async-book/03_async_await/01_chapter.html#async-lifetimes for more information let allowed_ids_path = self.allowed_ids_path.clone(); @@ -141,12 +141,16 @@ impl Plugin for AllowClientIdFromFile { } } }; - // `ServiceBuilder` provides us with an `oneshot_async_checkpoint` method. + // `ServiceBuilder` provides us with an `async_checkpoint` method. + // + // Because our service is not `Clone`, we use the `ServiceExt` trait's `buffered` method to + // make the service `Clone`. // // This method allows us to return ControlFlow::Continue(request) if we want to let the request through, // or ControlFlow::Break(response) with a crafted response if we don't want the request to go through. ServiceBuilder::new() - .oneshot_checkpoint_async(handler) + .checkpoint_async(handler) + .buffered() .service(service) .boxed() } diff --git a/examples/async-auth/rust/src/main.rs b/examples/async-auth/rust/src/main.rs index 98681f4b1b..61079910cc 100644 --- a/examples/async-auth/rust/src/main.rs +++ b/examples/async-auth/rust/src/main.rs @@ -1,3 +1,4 @@ +//! ```text //! curl -v \ //! --header 'content-type: application/json' \ //! --header 'x-client-id: unknown' \ @@ -22,6 +23,7 @@ //! < //! * Connection #0 to host 127.0.0.1 left intact //! {"data":{"me":{"name":"Ada Lovelace"}}} +//! ``` //! The only thing you need to add to your main.rs file is // adding the module to your main.rs file diff --git a/examples/cache-control/rhai/Cargo.toml b/examples/cache-control/rhai/Cargo.toml index 70039854a6..cb3cd656ae 100644 --- a/examples/cache-control/rhai/Cargo.toml +++ b/examples/cache-control/rhai/Cargo.toml @@ -1,13 +1,14 @@ [package] name = "cache-control" -version = "0.1.0" +version = "0.2.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } -http = "0.2" +http = "1.2" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } diff --git a/examples/cache-control/rhai/src/cache_control.rhai b/examples/cache-control/rhai/src/cache_control.rhai index 0ad1767213..e56c49f454 100644 --- a/examples/cache-control/rhai/src/cache_control.rhai +++ b/examples/cache-control/rhai/src/cache_control.rhai @@ -10,6 +10,18 @@ fn subgraph_service(service, subgraph) { return; } + // if a subgraph needs revalidation, the whole response needs revalidation + if cache_control == "no-cache" { + response.context.cache_control_no_cache = true; + return; + } + + // if a subgraph does not allow cache, the whole response is not allowed to be stored + if cache_control == "no-store" { + response.context.cache_control_no_store = true; + return; + } + let max_age = get_max_age(cache_control); // use the smallest max age @@ -48,10 +60,16 @@ fn supergraph_service(service) { // https://www.apollographql.com/docs/router/customizations/rhai-api/#response-interface if response.is_primary() { let uncacheable = response.context.cache_control_uncacheable; + let no_cache = response.context.cache_control_no_cache; + let no_store = response.context.cache_control_no_store; let max_age = response.context.cache_control_max_age; let scope = response.context.cache_control_scope; - - if uncacheable != true && max_age != () && scope != () { + + if no_cache == true { + response.headers["cache-control"] = "no-cache"; + } else if no_store == true { + response.headers["cache-control"] = "no-store"; + } else if uncacheable != true && max_age != () && scope != () { response.headers["cache-control"] = `max-age=${max_age}, ${scope}`; } } diff --git a/examples/cache-control/rhai/src/main.rs b/examples/cache-control/rhai/src/main.rs index 14d1d53e0b..f3eb8abeda 100644 --- a/examples/cache-control/rhai/src/main.rs +++ b/examples/cache-control/rhai/src/main.rs @@ -22,14 +22,15 @@ mod tests { let mut mock_service1 = test::MockSubgraphService::new(); let mut mock_service2 = test::MockSubgraphService::new(); - mock_service1.expect_clone().return_once(|| { + mock_service1.expect_clone().returning(move || { let mut mock_service = test::MockSubgraphService::new(); + let value = header_one.clone(); mock_service .expect_call() .once() .returning(move |req: subgraph::Request| { let mut headers = HeaderMap::new(); - if let Some(value) = &header_one { + if let Some(value) = &value { headers.insert("cache-control", value.parse().unwrap()); } @@ -41,14 +42,15 @@ mod tests { mock_service }); - mock_service2.expect_clone().return_once(move || { + mock_service2.expect_clone().returning(move || { let mut mock_service = test::MockSubgraphService::new(); + let value = header_two.clone(); mock_service .expect_call() .once() .returning(move |req: subgraph::Request| { let mut headers = HeaderMap::new(); - if let Some(value) = &header_two { + if let Some(value) = &value { headers.insert("cache-control", value.parse().unwrap()); } @@ -118,7 +120,7 @@ mod tests { } #[tokio::test] - async fn test_subgraph_cache_control() { + async fn test_cache_control_mixed() { assert_eq!( cache_control_header( Some("max-age=100, private".to_string()), @@ -130,7 +132,7 @@ mod tests { } #[tokio::test] - async fn test_subgraph_cache_control_public() { + async fn test_cache_control_public() { assert_eq!( cache_control_header( Some("max-age=100, public".to_string()), @@ -142,10 +144,34 @@ mod tests { } #[tokio::test] - async fn test_subgraph_cache_control_missing() { + async fn test_cache_control_missing() { assert_eq!( cache_control_header(Some("max-age=100, private".to_string()), None).await, None ); } + + #[tokio::test] + async fn test_subgraph_cache_no_cache() { + assert_eq!( + cache_control_header( + Some("max-age=100, private".to_string()), + Some("no-cache".to_string()) + ) + .await, + Some("no-cache".to_string()) + ); + } + + #[tokio::test] + async fn test_subgraph_cache_no_store() { + assert_eq!( + cache_control_header( + Some("max-age=100, private".to_string()), + Some("no-store".to_string()) + ) + .await, + Some("no-store".to_string()) + ); + } } diff --git a/examples/context/rust/Cargo.toml b/examples/context/rust/Cargo.toml index 75b80dcefe..6338fd1125 100644 --- a/examples/context/rust/Cargo.toml +++ b/examples/context/rust/Cargo.toml @@ -2,12 +2,13 @@ name = "context-data" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } async-trait = "0.1" -http = "0.2" -tower = { version = "0.4", features = ["full"] } +http = "1.2" +tower = { version = "0.5", features = ["full"] } tracing = "0.1" diff --git a/examples/cookies-to-headers/rhai/Cargo.toml b/examples/cookies-to-headers/rhai/Cargo.toml index 8902489130..37dfb38b90 100644 --- a/examples/cookies-to-headers/rhai/Cargo.toml +++ b/examples/cookies-to-headers/rhai/Cargo.toml @@ -2,12 +2,13 @@ name = "cookies-to-headers" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } -http = "0.2" +http = "1.2" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } diff --git a/examples/cookies-to-headers/rhai/src/main.rs b/examples/cookies-to-headers/rhai/src/main.rs index 6ff83b1d75..de52fe0418 100644 --- a/examples/cookies-to-headers/rhai/src/main.rs +++ b/examples/cookies-to-headers/rhai/src/main.rs @@ -48,7 +48,7 @@ mod tests { let expected_mock_response_data = "response created within the mock"; // Let's set up our mock to make sure it will be called once - mock_service.expect_clone().return_once(move || { + mock_service.expect_clone().returning(move || { let mut mock_service = test::MockSubgraphService::new(); mock_service .expect_call() diff --git a/examples/coprocessor-override-launchdarkly/package-lock.json b/examples/coprocessor-override-launchdarkly/package-lock.json index 8201944392..7906fd5fd3 100644 --- a/examples/coprocessor-override-launchdarkly/package-lock.json +++ b/examples/coprocessor-override-launchdarkly/package-lock.json @@ -317,21 +317,21 @@ } }, "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dev": true, "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", + "qs": "6.13.0", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -351,12 +351,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -371,15 +371,30 @@ "node": ">= 0.8" } }, - "node_modules/call-bind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", "dev": true, "dependencies": { - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -440,9 +455,9 @@ } }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "dev": true, "engines": { "node": ">= 0.6" @@ -469,20 +484,6 @@ "ms": "2.0.0" } }, - "node_modules/define-data-property": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -522,6 +523,20 @@ "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -529,14 +544,44 @@ "dev": true }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "dev": true, "engines": { "node": ">= 0.8" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.1.tgz", + "integrity": "sha512-BPOBuyUF9QIVhuNLhbToCLHP6+0MHwZ7xLBkPPCZqK4JmpJgGnv10035STzzQwFpqdzNFMB3irvDI63IagvDwA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -553,37 +598,37 @@ } }, "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "dev": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -592,12 +637,16 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -607,13 +656,13 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dev": true, "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -666,20 +715,42 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", "dev": true, "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -693,12 +764,12 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -713,34 +784,10 @@ "node": ">=4" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", - "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "engines": { "node": ">= 0.4" @@ -750,9 +797,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "dependencies": { "function-bind": "^1.1.2" @@ -910,6 +957,15 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -920,10 +976,13 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", - "dev": true + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/methods": { "version": "1.1.2", @@ -1070,10 +1129,13 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", "dev": true, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -1100,9 +1162,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "dev": true }, "node_modules/picomatch": { @@ -1137,12 +1199,12 @@ "dev": true }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dev": true, "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -1161,9 +1223,9 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dev": true, "dependencies": { "bytes": "3.1.2", @@ -1228,9 +1290,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dev": true, "dependencies": { "debug": "2.6.9", @@ -1251,6 +1313,15 @@ "node": ">= 0.8.0" } }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/send/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1258,51 +1329,93 @@ "dev": true }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dev": true, "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" } }, - "node_modules/set-function-length": { + "node_modules/setprototypeof": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.0.tgz", - "integrity": "sha512-4DBHDoyHlM1IRPGYcoxexgh67y4ueR53FKV1yyxwFMY7aCqcN/38M1+SwZ/qJQ8iLv7+ck385ot4CcisOAPT9w==", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "dependencies": { - "define-data-property": "^1.1.1", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.2", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.1" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1766,21 +1879,21 @@ "dev": true }, "body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dev": true, "requires": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", + "qs": "6.13.0", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } @@ -1796,12 +1909,12 @@ } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" } }, "bytes": { @@ -1810,15 +1923,24 @@ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "dev": true }, - "call-bind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", "dev": true, "requires": { - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, + "call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "dev": true, + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" } }, "chokidar": { @@ -1859,9 +1981,9 @@ "dev": true }, "cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "dev": true }, "cookie-signature": { @@ -1885,17 +2007,6 @@ "ms": "2.0.0" } }, - "define-data-property": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", - "dev": true, - "requires": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" - } - }, "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1919,6 +2030,17 @@ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.2.tgz", "integrity": "sha512-HTlk5nmhkm8F6JcdXvHIzaorzCoziNQT9mGxLPVXW8wJF1TiGSL60ZGB4gHWabHOaMmWmhvk2/lPHfnBiT78AQ==" }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1926,11 +2048,32 @@ "dev": true }, "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true + }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true }, + "es-object-atoms": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.1.tgz", + "integrity": "sha512-BPOBuyUF9QIVhuNLhbToCLHP6+0MHwZ7xLBkPPCZqK4JmpJgGnv10035STzzQwFpqdzNFMB3irvDI63IagvDwA==", + "dev": true, + "requires": { + "es-errors": "^1.3.0" + } + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1944,37 +2087,37 @@ "dev": true }, "express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "dev": true, "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -1983,22 +2126,22 @@ } }, "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "requires": { "to-regex-range": "^5.0.1" } }, "finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dev": true, "requires": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -2032,15 +2175,31 @@ "dev": true }, "get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", "dev": true, "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + } + }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" } }, "glob-parent": { @@ -2053,13 +2212,10 @@ } }, "gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, - "requires": { - "get-intrinsic": "^1.1.3" - } + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true }, "has-flag": { "version": "3.0.0", @@ -2067,31 +2223,16 @@ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true }, - "has-property-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", - "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", - "dev": true, - "requires": { - "get-intrinsic": "^1.2.2" - } - }, - "has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "dev": true - }, "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true }, "hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "requires": { "function-bind": "^1.1.2" @@ -2210,6 +2351,12 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -2217,9 +2364,9 @@ "dev": true }, "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "dev": true }, "methods": { @@ -2321,9 +2468,9 @@ "dev": true }, "object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", "dev": true }, "on-finished": { @@ -2342,9 +2489,9 @@ "dev": true }, "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "dev": true }, "picomatch": { @@ -2370,12 +2517,12 @@ "dev": true }, "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dev": true, "requires": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" } }, "range-parser": { @@ -2385,9 +2532,9 @@ "dev": true }, "raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dev": true, "requires": { "bytes": "3.1.2", @@ -2426,9 +2573,9 @@ } }, "send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dev": true, "requires": { "debug": "2.6.9", @@ -2446,6 +2593,12 @@ "statuses": "2.0.1" }, "dependencies": { + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true + }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2455,28 +2608,15 @@ } }, "serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dev": true, "requires": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" - } - }, - "set-function-length": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.0.tgz", - "integrity": "sha512-4DBHDoyHlM1IRPGYcoxexgh67y4ueR53FKV1yyxwFMY7aCqcN/38M1+SwZ/qJQ8iLv7+ck385ot4CcisOAPT9w==", - "dev": true, - "requires": { - "define-data-property": "^1.1.1", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.2", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.1" + "send": "0.19.0" } }, "setprototypeof": { @@ -2486,14 +2626,51 @@ "dev": true }, "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + } + }, + "side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + } + }, + "side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + } + }, + "side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" } }, "simple-update-notifier": { diff --git a/examples/coprocessor-override-launchdarkly/src/index.ts b/examples/coprocessor-override-launchdarkly/src/index.ts index b66dcbbd8f..6518e11104 100644 --- a/examples/coprocessor-override-launchdarkly/src/index.ts +++ b/examples/coprocessor-override-launchdarkly/src/index.ts @@ -3,8 +3,8 @@ import express from "express"; import { listenForFlagUpdates } from "./launchDarkly.js"; const LABEL_PREFIX = "launchDarkly:"; -const UNRESOLVED_LABELS_CONTEXT_KEY = "apollo_override::unresolved_labels"; -const LABELS_TO_OVERRIDE_CONTEXT_KEY = "apollo_override::labels_to_override"; +const UNRESOLVED_LABELS_CONTEXT_KEY = "apollo::progressive_override::unresolved_labels"; +const LABELS_TO_OVERRIDE_CONTEXT_KEY = "apollo::progressive_override::labels_to_override"; const { PORT } = process.env; diff --git a/examples/coprocessor-subgraph/rust/Cargo.toml b/examples/coprocessor-subgraph/rust/Cargo.toml index 342a094553..d549879771 100644 --- a/examples/coprocessor-subgraph/rust/Cargo.toml +++ b/examples/coprocessor-subgraph/rust/Cargo.toml @@ -2,19 +2,21 @@ name = "external-subgraph" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } async-trait = "0.1" +bytes = "1.6.0" futures = "0.3" -http = "0.2.8" -hyper = "0.14.23" +http = "1.2.0" +http-body-util = "0.1.2" multimap = "0.9.0" schemars = { version = "0.8", features = ["url"] } serde = "1" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } tracing = "0.1" diff --git a/examples/coprocessor-subgraph/rust/src/echo_co_processor.rs b/examples/coprocessor-subgraph/rust/src/echo_co_processor.rs index 594c0d2990..de1ac74be6 100644 --- a/examples/coprocessor-subgraph/rust/src/echo_co_processor.rs +++ b/examples/coprocessor-subgraph/rust/src/echo_co_processor.rs @@ -6,8 +6,11 @@ use apollo_router::register_plugin; use apollo_router::services::router; use apollo_router::Endpoint; use apollo_router::ListenAddr; +use bytes::Bytes; use futures::future::BoxFuture; use http::StatusCode; +use http_body_util::BodyExt; +use http_body_util::Full; use multimap::MultiMap; use schemars::JsonSchema; use serde::Deserialize; @@ -83,7 +86,7 @@ impl Service for SimpleEndpoint { let fut = async move { let body = req.router_request.into_body(); - let body = hyper::body::to_bytes(body).await.unwrap(); + let body = body.collect().await.unwrap().to_bytes(); let mut json_body: serde_json::Value = serde_json::from_slice(&body).unwrap(); tracing::info!("✉️ got payload:"); @@ -113,7 +116,7 @@ impl Service for SimpleEndpoint { let context = context.get_mut("entries").unwrap(); // context always has entries. if let Some(context) = context.as_object_mut() { context.insert( - "apollo_authentication::JWT::claims".to_string(), + "apollo::authentication::jwt_claims".to_string(), json! { true }, ); } @@ -123,7 +126,7 @@ impl Service for SimpleEndpoint { "context".to_string(), json! {{ "entries": { - "apollo_authentication::JWT::claims": true + "apollo::authentication::jwt_claims": true } }}, ) @@ -145,7 +148,11 @@ impl Service for SimpleEndpoint { // return the modified payload let http_response = http::Response::builder() .status(StatusCode::OK) - .body(hyper::Body::from(serde_json::to_vec(&json_body).unwrap())) + .body( + Full::new(Bytes::from(serde_json::to_vec(&json_body).unwrap())) + .map_err(|_never| "there is an error") + .boxed_unsync(), + ) .unwrap(); let mut router_response = router::Response::from(http_response); router_response.context = req.context; diff --git a/examples/coprocessor-surrogate-cache-key/README.md b/examples/coprocessor-surrogate-cache-key/README.md new file mode 100644 index 0000000000..059c6466f5 --- /dev/null +++ b/examples/coprocessor-surrogate-cache-key/README.md @@ -0,0 +1,124 @@ +## Context + +Existing caching systems often support a concept of surrogate keys, where a key can be linked to a specific piece of cached data, independently of the actual cache key. + +As an example, a news website might want to invalidate all cached articles linked to a specific company or person following an event. To that end, when returning the article, the service can add a surrogate key to the article response, and the cache would keep a map from surrogate keys to cache keys. + +## Surrogate keys and the router’s entity cache + +To support a surrogate key system with the entity caching in the router, we make the following assumptions: + +- The subgraph returns surrogate keys with the response. The router will not manipulate those surrogate keys directly. Instead, it leaves that task to a coprocessor +- The coprocessor tasked with managing surrogate keys will store the mapping from surrogate keys to cache keys. It will be useful to invalidate all cache keys related to a surrogate cache key in Redis. +- The router will expose a way to gather the cache keys used in a subgraph request + +### Router side support + +The router has two features to support surrogate cache key: + +- An id field for subgraph requests and responses. This is a random, unique id per subgraph call that can be used to keep state between the request and response side, and keep data from the various subgraph calls separately for the entire client request. You have to enable it in configuration (`subgraph_request_id`): + +```yaml title=router.yaml +coprocessor: + url: http://127.0.0.1:3000 # mandatory URL which is the address of the coprocessor + supergraph: + response: + context: true + subgraph: + all: + response: + subgraph_request_id: true + context: true +``` + +- The entity cache has an option to store in the request context, at the key `apollo::entity_cache::cached_keys_status`, a map `subgraph request id => cache keys` only when it's enabled in the configuration (`expose_keys_in_context`)): + +```yaml title=router.yaml +preview_entity_cache: + enabled: true + expose_keys_in_context: true + metrics: + enabled: true + invalidation: + listen: 0.0.0.0:4000 + path: /invalidation + # Configure entity caching per subgraph + subgraph: + all: + enabled: true + # Configure Redis + redis: + urls: ["redis://localhost:6379"] + ttl: 24h # Optional, by default no expiration +``` + +The coprocessor will then work at two stages: + +- Subgraph response: + - Extract the subgraph request id + - Extract the list of surrogate keys from the response +- Supergraph stage: + - Extract the map `subgraph request id => cache keys` + - Match it with the surrogate cache keys obtained at the subgraph response stage + +The coprocessor then has a map of `surrogate keys => cache keys` that it can use to invalidate cached data directly from Redis. + +### Example workflow + +- The router receives a client request +- The router starts a subgraph request: + - The entity cache plugin checks if the request has a corresponding cached entry: + - If the entire response can be obtained from cache, we return a response here + - If it cannot be obtained, or only partially (\_entities query), a request is transmitted to the subgraph + - The subgraph responds to the request. The response can contain a list of surrogate keys in a header: `Surrogate-Keys: homepage, feed` + - The subgraph response stage coprocessor extracts the surrogate keys from headers, and stores it in the request context, associated with the subgraph request id `0e67db40-e98d-4ad7-bb60-2012fb5db504`: + +```json +{ + "​0ee3bf47-5e8d-47e3-8e7e-b05ae877d9c7": ["homepage", "feed"] +} +``` + +- The entity cache processes the subgraph response: + - It generates a new subgraph response by interspersing data it got from cache with data from the original response + - It stores the list of keys in the context. `new` indicates newly cached data coming from the subgraph, linked to the surrogate keys, while `cached` is data obtained from the cache. These are the keys directly used in Redis: + +```json +{ + "apollo::entity_cache::cached_keys_status": { + "0ee3bf47-5e8d-47e3-8e7e-b05ae877d9c7": [ + { + "key": "version:1.0:subgraph:products:type:Query:hash:af9febfacdc8244afc233a857e3c4b85a749355707763dc523a6d9e8964e9c8d:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "max-age=60,public" + } + ] + } +} +``` + +- The supergraph response stage loads data from the context and creates the mapping: + +```json +{ + "homepage": [ + { + "key": "version:1.0:subgraph:products:type:Query:hash:af9febfacdc8244afc233a857e3c4b85a749355707763dc523a6d9e8964e9c8d:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "max-age=60,public" + } + ], + "feed": [ + { + "key": "version:1.0:subgraph:products:type:Query:hash:af9febfacdc8244afc233a857e3c4b85a749355707763dc523a6d9e8964e9c8d:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "max-age=60,public" + } + ] +} +``` + +- When a surrogate key must be used to invalidate data, that mapping is used to obtained the related cache keys + + +In this example we provide a very simple implementation using in memory data in NodeJs. It just prints the mapping at the supergraph response level to show you how you can create that mapping. diff --git a/examples/coprocessor-surrogate-cache-key/nodejs/.gitignore b/examples/coprocessor-surrogate-cache-key/nodejs/.gitignore new file mode 100644 index 0000000000..d5f19d89b3 --- /dev/null +++ b/examples/coprocessor-surrogate-cache-key/nodejs/.gitignore @@ -0,0 +1,2 @@ +node_modules +package-lock.json diff --git a/examples/coprocessor-surrogate-cache-key/nodejs/README.md b/examples/coprocessor-surrogate-cache-key/nodejs/README.md new file mode 100644 index 0000000000..e65978e36b --- /dev/null +++ b/examples/coprocessor-surrogate-cache-key/nodejs/README.md @@ -0,0 +1,16 @@ +# External Subgraph nodejs example + +This is an example that involves a nodejs coprocessor alongside a router. + +## Usage + +- Start the coprocessor: + +```bash +$ npm ci && npm run start +``` + +- Start the router +``` +$ APOLLO_KEY="YOUR_APOLLO_KEY" APOLLO_GRAPH_REF="YOUR_APOLLO_GRAPH_REF" cargo run -- --configuration router.yaml +``` diff --git a/examples/coprocessor-surrogate-cache-key/nodejs/package.json b/examples/coprocessor-surrogate-cache-key/nodejs/package.json new file mode 100644 index 0000000000..1c3ef66300 --- /dev/null +++ b/examples/coprocessor-surrogate-cache-key/nodejs/package.json @@ -0,0 +1,15 @@ +{ + "name": "coprocessor", + "version": "1.0.0", + "description": "A coprocessor example for the router", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "express": "^4.18.2" + } +} \ No newline at end of file diff --git a/examples/coprocessor-surrogate-cache-key/nodejs/router.yaml b/examples/coprocessor-surrogate-cache-key/nodejs/router.yaml new file mode 100644 index 0000000000..d3b36e3966 --- /dev/null +++ b/examples/coprocessor-surrogate-cache-key/nodejs/router.yaml @@ -0,0 +1,36 @@ +supergraph: + listen: 127.0.0.1:4000 + introspection: true +sandbox: + enabled: true +homepage: + enabled: false +include_subgraph_errors: + all: true # Propagate errors from all subraphs + +coprocessor: + url: http://127.0.0.1:3000 # mandatory URL which is the address of the coprocessor + supergraph: + response: + context: true + subgraph: + all: + response: + subgraph_request_id: true + context: true +preview_entity_cache: + enabled: true + expose_keys_in_context: true + metrics: + enabled: true + invalidation: + listen: 0.0.0.0:4000 + path: /invalidation + # Configure entity caching per subgraph + subgraph: + all: + enabled: true + # Configure Redis + redis: + urls: ["redis://localhost:6379"] + ttl: 24h # Optional, by default no expiration \ No newline at end of file diff --git a/examples/coprocessor-surrogate-cache-key/nodejs/src/index.js b/examples/coprocessor-surrogate-cache-key/nodejs/src/index.js new file mode 100644 index 0000000000..daadb50740 --- /dev/null +++ b/examples/coprocessor-surrogate-cache-key/nodejs/src/index.js @@ -0,0 +1,110 @@ +const express = require("express"); +const app = express(); +const port = 3000; + +app.use(express.json()); + +// This is for demo purpose and will keep growing over the time +// It saves the value of surrogate cache keys returned by a subgraph request +let surrogateKeys = new Map(); +// Example: +// { +// "​​0e67db40-e98d-4ad7-bb60-2012fb5db504": [ +// "elections", +// "sp500" +// ], +// "​​0d77db40-e98d-4ad7-bb60-2012fb5db555": [ +// "homepage" +// ] +// } +// -------------- +// For every surrogate cache key we know the related cache keys +// Example: +// { +// "elections": [ +// "version:1.0:subgraph:reviews:type:Product:entity:4e48855987eae27208b466b941ecda5fb9b88abc03301afef6e4099a981889e9:hash:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" +// ], +// "sp500": [ +// "version:1.0:subgraph:reviews:type:Product:entity:4e48855987eae27208b466b941ecda5fb9b88abc03301afef6e4099a981889e9:hash:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" +// ] +// } + +app.post("/", (req, res) => { + const request = req.body; + console.log("✉️ Got payload:"); + console.log(JSON.stringify(request, null, 2)); + switch (request.stage) { + case "SubgraphResponse": + request.headers["surrogate-keys"] = ["homepage, feed"]; // To simulate + // Fetch the surrogate keys returned by the subgraph to create a mapping between subgraph request id and surrogate keys, to create the final mapping later + // Example: + // { + // "​​0e67db40-e98d-4ad7-bb60-2012fb5db504": [ + // "elections", + // "sp500" + // ] + // } + if (request.headers["surrogate-keys"] && request.subgraphRequestId) { + let keys = request.headers["surrogate-keys"] + .join(",") + .split(",") + .map((k) => k.trim()); + + surrogateKeys.set(request.subgraphRequestId, keys); + console.log("surrogateKeys", surrogateKeys); + } + break; + case "SupergraphResponse": + if ( + request.context && + request.context.entries && + request.context.entries["apollo::entity_cache::cached_keys_status"] + ) { + let contextEntry = + request.context.entries["apollo::entity_cache::cached_keys_status"]; + let mapping = {}; + Object.keys(contextEntry).forEach((request_id) => { + let cache_keys = contextEntry[`${request_id}`]; + let surrogateCachekeys = surrogateKeys.get(request_id); + if (surrogateCachekeys) { + // Create the mapping between surrogate cache keys and effective cache keys + // Example: + // { + // "elections": [ + // "version:1.0:subgraph:reviews:type:Product:entity:4e48855987eae27208b466b941ecda5fb9b88abc03301afef6e4099a981889e9:hash:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + // ], + // "sp500": [ + // "version:1.0:subgraph:reviews:type:Product:entity:4e48855987eae27208b466b941ecda5fb9b88abc03301afef6e4099a981889e9:hash:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + // ] + // } + + surrogateCachekeys.reduce((acc, current) => { + if (acc[`${current}`]) { + acc[`${current}`] = acc[`${current}`].concat(cache_keys); + } else { + acc[`${current}`] = cache_keys; + } + + return acc; + }, mapping); + } + }); + + console.log(mapping); + } + break; + default: + return res.json(request); + } + res.json(request); +}); + +app.listen(port, () => { + console.log(`🚀 Coprocessor running on port ${port}`); + console.log( + `Run a router with the provided router.yaml configuration to test the example:` + ); + console.log( + `APOLLO_KEY="YOUR_APOLLO_KEY" APOLLO_GRAPH_REF="YOUR_APOLLO_GRAPH_REF" cargo run -- --configuration router.yaml` + ); +}); diff --git a/examples/data-response-mutate/rhai/Cargo.toml b/examples/data-response-mutate/rhai/Cargo.toml index 789dc3894c..12fcdee1f2 100644 --- a/examples/data-response-mutate/rhai/Cargo.toml +++ b/examples/data-response-mutate/rhai/Cargo.toml @@ -2,12 +2,13 @@ name = "rhai-data-response-mutate" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } -http = "0.2" +http = "1.2" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } diff --git a/examples/error-response-mutate/rhai/Cargo.toml b/examples/error-response-mutate/rhai/Cargo.toml index eb8271ca13..ba7edf6523 100644 --- a/examples/error-response-mutate/rhai/Cargo.toml +++ b/examples/error-response-mutate/rhai/Cargo.toml @@ -2,12 +2,13 @@ name = "rhai-error-response-mutate" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } -http = "0.2" +http = "1.2" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } diff --git a/examples/forbid-anonymous-operations/rhai/Cargo.toml b/examples/forbid-anonymous-operations/rhai/Cargo.toml index f39649e681..22cc87af21 100644 --- a/examples/forbid-anonymous-operations/rhai/Cargo.toml +++ b/examples/forbid-anonymous-operations/rhai/Cargo.toml @@ -2,12 +2,13 @@ name = "forbid_anonymous_operations_rhai" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } -http = "0.2" +http = "1.2" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } diff --git a/examples/forbid-anonymous-operations/rust/Cargo.toml b/examples/forbid-anonymous-operations/rust/Cargo.toml index 15c9ef4997..ebbca4daef 100644 --- a/examples/forbid-anonymous-operations/rust/Cargo.toml +++ b/examples/forbid-anonymous-operations/rust/Cargo.toml @@ -2,14 +2,15 @@ name = "forbid-anonymous-operations" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } async-trait = "0.1" -http = "0.2" +http = "1.2" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } tracing = "0.1" diff --git a/examples/forbid-anonymous-operations/rust/src/main.rs b/examples/forbid-anonymous-operations/rust/src/main.rs index 3f3fb95eb2..c3e20dfdd4 100644 --- a/examples/forbid-anonymous-operations/rust/src/main.rs +++ b/examples/forbid-anonymous-operations/rust/src/main.rs @@ -1,3 +1,4 @@ +//! ```text //! curl -v \ //! --header 'content-type: application/json' \ //! --url 'http://127.0.0.1:4000' \ @@ -9,6 +10,7 @@ //! < //! * Connection #0 to host 127.0.0.1 left intact //! {"errors":[{"message":"Anonymous operations are not allowed","locations":[],"path":null}]} +//! ``` use anyhow::Result; diff --git a/examples/hello-world/rust/Cargo.toml b/examples/hello-world/rust/Cargo.toml index c631098e34..9a9c843a0c 100644 --- a/examples/hello-world/rust/Cargo.toml +++ b/examples/hello-world/rust/Cargo.toml @@ -2,6 +2,7 @@ name = "hello-world" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] @@ -12,5 +13,5 @@ schemars = { version = "0.8", features = ["url"] } serde = "1" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } tracing = "0.1" diff --git a/examples/jwt-claims/rhai/Cargo.toml b/examples/jwt-claims/rhai/Cargo.toml index 8d10a41f70..d48e074f6c 100644 --- a/examples/jwt-claims/rhai/Cargo.toml +++ b/examples/jwt-claims/rhai/Cargo.toml @@ -2,12 +2,13 @@ name = "jwt-claims" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } -http = "0.2" +http = "1.2" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } diff --git a/examples/logging/rhai/Cargo.toml b/examples/logging/rhai/Cargo.toml index a281c2a94f..5cdd277c99 100644 --- a/examples/logging/rhai/Cargo.toml +++ b/examples/logging/rhai/Cargo.toml @@ -2,12 +2,13 @@ name = "rhai-logging" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } -http = "0.2" +http = "1.2" serde_json = "1" tokio = { version = "1.17", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } diff --git a/examples/op-name-to-header/rhai/Cargo.toml b/examples/op-name-to-header/rhai/Cargo.toml index 823a01d25d..145dffc051 100644 --- a/examples/op-name-to-header/rhai/Cargo.toml +++ b/examples/op-name-to-header/rhai/Cargo.toml @@ -2,12 +2,13 @@ name = "op-name-to-header" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } -http = "0.2" +http = "1.2" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } diff --git a/examples/status-code-propagation/rust/Cargo.toml b/examples/status-code-propagation/rust/Cargo.toml index c396718afe..30622e5d5d 100644 --- a/examples/status-code-propagation/rust/Cargo.toml +++ b/examples/status-code-propagation/rust/Cargo.toml @@ -2,15 +2,16 @@ name = "propagate-status-code" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } async-trait = "0.1" -http = "0.2" +http = "1.2" schemars = { version = "0.8", features = ["url"] } serde = "1" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } diff --git a/examples/status-code-propagation/rust/router.yaml b/examples/status-code-propagation/rust/router.yaml index a3eda78a0f..d4a9fc59e5 100644 --- a/examples/status-code-propagation/rust/router.yaml +++ b/examples/status-code-propagation/rust/router.yaml @@ -1,5 +1,5 @@ cors: - origins: + policies: - "https://studio.apollographql.com/" plugins: example.propagate_status_code: diff --git a/examples/status-code-propagation/rust/src/propagate_status_code.rs b/examples/status-code-propagation/rust/src/propagate_status_code.rs index f78e6e3bde..7b49b13522 100644 --- a/examples/status-code-propagation/rust/src/propagate_status_code.rs +++ b/examples/status-code-propagation/rust/src/propagate_status_code.rs @@ -44,7 +44,7 @@ impl Plugin for PropagateStatusCode { // - check for the presence of a value for `status_codes` (first parameter) // update the value if present (second parameter) res.context - .upsert(&"status_code".to_string(), |status_code: u16| { + .upsert("status_code", |status_code: u16| { // return the status code with the highest priority for &code in all_status_codes.iter() { if code == response_status_code || code == status_code { @@ -210,7 +210,7 @@ mod tests { let context = router_request.context; // Insert several status codes which shall override the router response status context - .insert(&"status_code".to_string(), json!(500u16)) + .insert("status_code", json!(500u16)) .expect("couldn't insert status_code"); Ok(supergraph::Response::fake_builder() diff --git a/examples/subgraph-request-log/rhai/Cargo.toml b/examples/subgraph-request-log/rhai/Cargo.toml index 51bb198014..4481f1454a 100644 --- a/examples/subgraph-request-log/rhai/Cargo.toml +++ b/examples/subgraph-request-log/rhai/Cargo.toml @@ -2,12 +2,13 @@ name = "rhai-subgraph-request-log" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } -http = "0.2" +http = "1.2" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } diff --git a/examples/supergraph-sdl/rust/Cargo.toml b/examples/supergraph-sdl/rust/Cargo.toml index 827e44ed5d..4fea3cc989 100644 --- a/examples/supergraph-sdl/rust/Cargo.toml +++ b/examples/supergraph-sdl/rust/Cargo.toml @@ -2,11 +2,12 @@ name = "supergraph_sdl" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" [dependencies] anyhow = "1" -apollo-compiler = "=1.0.0-beta.24" +apollo-compiler = "1.25.0" apollo-router = { path = "../../../apollo-router" } async-trait = "0.1" -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } tracing = "0.1" diff --git a/examples/surrogate-cache-key/rhai/Cargo.toml b/examples/surrogate-cache-key/rhai/Cargo.toml index f1df802b90..ea26f55d66 100644 --- a/examples/surrogate-cache-key/rhai/Cargo.toml +++ b/examples/surrogate-cache-key/rhai/Cargo.toml @@ -2,12 +2,13 @@ name = "rhai-surrogate-cache-key" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } -http = "0.2" +http = "1.2" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } +tower = { version = "0.5", features = ["full"] } diff --git a/examples/telemetry/jaeger-agent.router.yaml b/examples/telemetry/jaeger-agent.router.yaml deleted file mode 100644 index 0ffb4db464..0000000000 --- a/examples/telemetry/jaeger-agent.router.yaml +++ /dev/null @@ -1,9 +0,0 @@ -telemetry: - exporters: - tracing: - common: - service_name: router - jaeger: - enabled: true - agent: - endpoint: default diff --git a/examples/telemetry/jaeger-collector.router.yaml b/examples/telemetry/jaeger-collector.router.yaml deleted file mode 100644 index 1befe21e70..0000000000 --- a/examples/telemetry/jaeger-collector.router.yaml +++ /dev/null @@ -1,12 +0,0 @@ -telemetry: - exporters: - tracing: - common: - service_name: router - jaeger: - enabled: true - collector: - endpoint: "https://example.com" - username: "username" - password: "password" - diff --git a/examples/throw-error/rhai/Cargo.toml b/examples/throw-error/rhai/Cargo.toml index 0ecd75ce83..b522e0a21a 100644 --- a/examples/throw-error/rhai/Cargo.toml +++ b/examples/throw-error/rhai/Cargo.toml @@ -2,15 +2,14 @@ name = "throw-error" version = "0.1.0" edition = "2021" +license-file = "../../../LICENSE" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1" apollo-router = { path = "../../../apollo-router" } -http = "0.2" +http = "1.2" +http-body-util = "0.1.2" serde_json = "1" tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["full"] } - -[dev-dependencies] -hyper = "0.14.24" +tower = { version = "0.5", features = ["full"] } diff --git a/examples/throw-error/rhai/src/main.rs b/examples/throw-error/rhai/src/main.rs index 900ae48273..e0fa3a48d1 100644 --- a/examples/throw-error/rhai/src/main.rs +++ b/examples/throw-error/rhai/src/main.rs @@ -15,6 +15,7 @@ mod tests { use apollo_router::services::supergraph; use apollo_router::Context; use http::StatusCode; + use http_body_util::BodyExt; use serde_json::json; use tower::ServiceExt; @@ -64,9 +65,13 @@ mod tests { .expect("a router response"); assert_eq!(StatusCode::UNAUTHORIZED, service_response.response.status()); - let body = hyper::body::to_bytes(service_response.response) + let body = service_response + .response + .into_body() + .collect() .await - .unwrap(); + .unwrap() + .to_bytes(); assert_eq!( expected_response, serde_json::from_slice::(&body).unwrap() diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 3b73906d4a..9cd07ba6d1 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -1,20 +1,24 @@ - [package] name = "router-fuzz" version = "0.0.0" authors = ["Automatically generated"] publish = false edition = "2018" +license-file = "../LICENSE" [package.metadata] cargo-fuzz = true [dependencies] -libfuzzer-sys = "0.4" +libfuzzer-sys = "=0.4.10" +apollo-federation = { path = "../apollo-federation" } apollo-parser.workspace = true apollo-smith.workspace = true +bnf = "0.5.0" env_logger = "0.11.0" log = "0.4" +# Required until https://github.com/shnewto/bnf/pull/175, remove when bnf 0.6 is out +rand = "=0.8.5" reqwest = { workspace = true, features = ["json", "blocking"] } serde_json.workspace = true @@ -50,7 +54,8 @@ test = false doc = false [[bin]] -name = "federation" -path = "fuzz_targets/federation.rs" +name = "connector_selection_parse" +path = "fuzz_targets/connector_selection_parse.rs" test = false doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md index 7b666c4632..470da459cb 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -25,12 +25,11 @@ Run the fuzzer with this command: cargo +nightly fuzz run router ``` -### Federation +### Connectors -This target is useful to spot differences between `gateway@1.x` and `gateway@2.x`. Before launching it you have to spawn the docker-compose located in the `fuzz` directory: `docker-compose -f fuzz/docker-compose.yml up`. -And then run it with: +This target fuzzes the Connector's Mapping Language and ensures that it continues to compose and that it +successfully handles requests to a running router instance with the fuzzed schema. ``` -# Only works on Linux -cargo +nightly fuzz run federation -``` \ No newline at end of file +cargo +nightly fuzz run connectors +``` diff --git a/fuzz/docker-compose.yml b/fuzz/docker-compose.yml deleted file mode 100644 index 6caac4e8a3..0000000000 --- a/fuzz/docker-compose.yml +++ /dev/null @@ -1,38 +0,0 @@ -version: "3.9" -services: - - subgraphs: - build: - dockerfile: ../dockerfiles/Dockerfile.federation-demo - ports: - - 4100:4100 - - 4001:4001 - - 4002:4002 - - 4003:4003 - - 4004:4004 - command: npm run start-services - environment: - - JAEGER_HOST=jaeger - depends_on: - - jaeger - - gateway: - container_name: apollo-gateway-fed-2 - build: ../dockerfiles/fed2-demo-gateway - environment: - - APOLLO_OTEL_EXPORTER_TYPE=collector - - APOLLO_OTEL_EXPORTER_HOST=collector - - APOLLO_OTEL_EXPORTER_PORT=4318 - - APOLLO_SCHEMA_CONFIG_EMBEDDED=true - volumes: - - ./supergraph.graphql:/etc/config/supergraph.graphql - ports: - - "4200:4000" - - jaeger: - image: jaegertracing/all-in-one:latest - ports: - - 6831:6831/udp - - 6832:6832/udp - - 16686:16686 - - 14268:14268 diff --git a/fuzz/fuzz_targets/connector_selection_parse.rs b/fuzz/fuzz_targets/connector_selection_parse.rs new file mode 100644 index 0000000000..f817d03ba4 --- /dev/null +++ b/fuzz/fuzz_targets/connector_selection_parse.rs @@ -0,0 +1,112 @@ +#![no_main] + +use std::sync::LazyLock; + +use apollo_federation::connectors::JSONSelection; +use bnf::Grammar; +use libfuzzer_sys::arbitrary; +use libfuzzer_sys::arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use libfuzzer_sys::Corpus; +use rand::rngs::StdRng; + +fuzz_target!(|input: GeneratedSelection| -> Corpus { + // Generating a selection might choose a path which recurses too deeply, so + // we just mark those traversals as being rejected since they would require + // seeding and iterating the Rng. + let Some(selection) = input.0 else { + return Corpus::Reject; + }; + + let parsed = JSONSelection::parse(&selection).unwrap(); + drop(parsed); + + Corpus::Keep +}); + +const BNF_GRAMMAR: &str = r##" + ::= "" | | + ::= | " " + ::= "{}" | "{ " " }" + + ::= | " " + ::= | | | + ::= | + ::= "." | "->" | "->" + ::= "@" | "@" + ::= "$(" ")" | "$(" ")" + ::= + ::= "$" | "$" | "$" | "$" + ::= "()" | "(" ")" + ::= | ", " + ::= | | | + + ::= | | "true" | "false" | "null" + ::= "{}" | "{" "}" + ::= "[]" | "[" "]" + + ::= | ", " + ::= ": " + + ::= | "-" + ::= "." | | "." | "." + + ::= '""' | "''" | '"' '"' | "'" "'" + ::= '\"' | | '\"' | + ::= "\'" | | "\'" | + ::= | | + "!" | "{" | "}" | "[" | "]" | "@" | "#" | "$" | "%" | "^" | + "&" | "*" | "(" | ")" | "-" | "_" | "=" | "+" | ";" | ":" | + "|" | "," | "<" | "." | ">" | "/" | "?" | " " | "\\" + + ::= | | | + ::= | " " | " " | " " " " + ::= " " + + ::= " " + ::= " " + + ::= ":" + ::= + ::= | + ::= "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | + "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | + "u" | "v" | "w" | "x" | "y" | "z" + ::= "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | + "K" | "L" | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T" | + "U" | "V" | "W" | "X" | "Y" | "Z" + ::= | + ::= "0" | + ::= "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" + ::= | + ::= | + ::= | + "##; +static GRAMMAR: LazyLock = LazyLock::new(|| BNF_GRAMMAR.parse().unwrap()); + +struct GeneratedSelection(Option); +impl<'a> Arbitrary<'a> for GeneratedSelection { + fn arbitrary(u: &mut libfuzzer_sys::arbitrary::Unstructured<'a>) -> arbitrary::Result { + let bytes = <[u8; 32] as Arbitrary>::arbitrary(u)?; + let mut rng: StdRng = rand::SeedableRng::from_seed(bytes); + + let selection = GRAMMAR.generate_seeded(&mut rng).ok(); + Ok(GeneratedSelection(selection)) + } +} + +impl std::fmt::Debug for GeneratedSelection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0 + .as_deref() + .map(|selection| { + write!(f, "```original\n{}\n```", selection)?; + if let Ok(parsed) = JSONSelection::parse(selection) { + write!(f, "\n\n```pretty\n{}\n```", parsed)?; + } + + Ok(()) + }) + .unwrap_or(Ok(())) + } +} diff --git a/fuzz/fuzz_targets/federation.rs b/fuzz/fuzz_targets/federation.rs deleted file mode 100644 index bd0577048e..0000000000 --- a/fuzz/fuzz_targets/federation.rs +++ /dev/null @@ -1,138 +0,0 @@ -#![no_main] -use std::fs::OpenOptions; -use std::io::Write; - -use libfuzzer_sys::fuzz_target; -use log::debug; -use router_fuzz::generate_valid_operation; -use serde_json::json; -use serde_json::Value; - -const GATEWAY_FED1_URL: &str = "http://localhost:4100/graphql"; -const GATEWAY_FED2_URL: &str = "http://localhost:4200/graphql"; - -fuzz_target!(|data: &[u8]| { - let generated_operation = match generate_valid_operation(data, "fuzz/supergraph.graphql") { - Ok((d, _)) => d, - Err(_err) => { - return; - } - }; - - let http_client = reqwest::blocking::Client::new(); - let gateway_fed1_response = http_client - .post(GATEWAY_FED1_URL) - .json(&json!({ "query": generated_operation })) - .send() - .unwrap() - .json::(); - let gateway_fed2_response = http_client - .post(GATEWAY_FED2_URL) - .json(&json!({ "query": generated_operation })) - .send() - .unwrap() - .json::(); - - debug!("======= DOCUMENT ======="); - debug!("{}", generated_operation); - debug!("========================"); - debug!("======= RESPONSE ======="); - if gateway_fed1_response.is_ok() != gateway_fed2_response.is_ok() { - let gateway_fed1_error = if let Err(err) = &gateway_fed1_response { - Some(err) - } else { - None - }; - let gateway_fed2_error = if let Err(err) = &gateway_fed2_response { - Some(err) - } else { - None - }; - if gateway_fed1_error.is_some() && gateway_fed2_error.is_some() { - // Do not check errors for now - return; - } - let mut file = OpenOptions::new() - .read(true) - .create(true) - .append(true) - .open("federation.txt") - .unwrap(); - - let errors = format!( - r#" - - -====DOCUMENT=== -{generated_operation} - -====GATEWAY FED 1==== -{gateway_fed1_error:?} - -====GATEWAY FED 2==== -{gateway_fed2_error:?} - -"# - ); - debug!("{errors}"); - file.write_all(errors.as_bytes()).unwrap(); - file.flush().unwrap(); - - // panic!() - } else if gateway_fed1_response.is_ok() { - let gateway_fed2_errors_detected = gateway_fed2_response - .as_ref() - .unwrap() - .as_object() - .unwrap() - .get("errors") - .map(|e| !e.as_array().unwrap().len()) - .unwrap_or(0); - let federation_detected = gateway_fed1_response - .as_ref() - .unwrap() - .as_object() - .unwrap() - .get("errors") - .map(|e| !e.as_array().unwrap().len()) - .unwrap_or(0); - if gateway_fed2_errors_detected > 0 && gateway_fed2_errors_detected == federation_detected { - // Do not check the shape of errors right now - return; - } - let gateway_fed1_response = - serde_json::to_string_pretty(&gateway_fed1_response.unwrap()).unwrap(); - let gateway_fed2_response = - serde_json::to_string_pretty(&gateway_fed2_response.unwrap()).unwrap(); - if gateway_fed1_response != gateway_fed2_response { - let mut file = OpenOptions::new() - .read(true) - .create(true) - .append(true) - .open("federation.txt") - .unwrap(); - - let errors = format!( - r#" - - -====DOCUMENT=== -{generated_operation} - -====GATEWAY FED 1==== -{gateway_fed1_response} - -====GATEWAY FED 2==== -{gateway_fed2_response} - -"# - ); - debug!("{errors}"); - file.write_all(errors.as_bytes()).unwrap(); - file.flush().unwrap(); - - // panic!(); - } - } - debug!("========================"); -}); diff --git a/fuzz/fuzz_targets/router.rs b/fuzz/fuzz_targets/router.rs index eff2229f1e..b9e502ad48 100644 --- a/fuzz/fuzz_targets/router.rs +++ b/fuzz/fuzz_targets/router.rs @@ -39,16 +39,8 @@ fuzz_target!(|data: &[u8]| { debug!("========================"); debug!("======= RESPONSE ======="); if router_response.is_ok() != subgraph_response.is_ok() { - let router_error = if let Err(err) = &router_response { - Some(err) - } else { - None - }; - let subgraph_error = if let Err(err) = &subgraph_response { - Some(err) - } else { - None - }; + let router_error = router_response.as_ref().err(); + let subgraph_error = subgraph_response.as_ref().err(); if router_error.is_some() && subgraph_error.is_some() { // Do not check errors for now return; diff --git a/fuzz/fuzz_targets/router_errors.rs b/fuzz/fuzz_targets/router_errors.rs index f1659df0a2..0713d50dae 100644 --- a/fuzz/fuzz_targets/router_errors.rs +++ b/fuzz/fuzz_targets/router_errors.rs @@ -34,11 +34,7 @@ fuzz_target!(|data: &[u8]| { debug!("========================"); debug!("======= RESPONSE ======="); if router_response.is_ok() != gateway_response.is_ok() { - let router_error = if let Err(err) = &router_response { - Some(err) - } else { - None - }; + let router_error = router_response.as_ref().err(); let gateway_error = if let Err(err) = &gateway_response { if err.is_decode() { return; diff --git a/fuzz/router.yaml b/fuzz/router.yaml index 6756683b4e..def8d82978 100644 --- a/fuzz/router.yaml +++ b/fuzz/router.yaml @@ -1,7 +1,6 @@ supergraph: listen: 0.0.0.0:4000 introspection: true -experimental_query_planner_mode: both sandbox: enabled: true homepage: diff --git a/fuzz/subgraph/Cargo.toml b/fuzz/subgraph/Cargo.toml index 130edb8e8f..fe3be90158 100644 --- a/fuzz/subgraph/Cargo.toml +++ b/fuzz/subgraph/Cargo.toml @@ -2,11 +2,12 @@ name = "everything-subgraph" version = "0.1.0" edition = "2021" +license-file = "../../LICENSE" [dependencies] -axum = "0.6.20" -async-graphql = "6" -async-graphql-axum = "6" +axum = "0.8" +async-graphql = "7" +async-graphql-axum = "7" env_logger = "0.11" tokio = { version = "1.22.0", features = ["time", "full"] } -tower = "0.4.0" +tower = "0.5.0" diff --git a/fuzz/subgraph/src/main.rs b/fuzz/subgraph/src/main.rs index 0be9550c7c..1346a9dcf3 100644 --- a/fuzz/subgraph/src/main.rs +++ b/fuzz/subgraph/src/main.rs @@ -30,8 +30,10 @@ async fn main() { .route("/", post(graphql_handler)) .layer(ServiceBuilder::new().layer(Extension(schema))); - axum::Server::bind(&"0.0.0.0:4005".parse().expect("Fixed address is valid")) - .serve(router.into_make_service()) + let listener = tokio::net::TcpListener::bind("0.0.0.0:4005") + .await + .expect("Failed to bind port"); + axum::serve(listener, router) .await .expect("Server failed to start") } diff --git a/fuzz/subgraph/src/model.rs b/fuzz/subgraph/src/model.rs index ea8b5f3c7d..9ecc74a23b 100644 --- a/fuzz/subgraph/src/model.rs +++ b/fuzz/subgraph/src/model.rs @@ -1,6 +1,6 @@ #![allow(non_snake_case)] -use std::sync::Once; +use std::sync::LazyLock; use async_graphql::Context; use async_graphql::Object; @@ -128,25 +128,21 @@ impl User { } fn users() -> &'static [User] { - static mut USERS: Vec = vec![]; - static INIT: Once = Once::new(); - unsafe { - INIT.call_once(|| { - USERS = vec![ - User { - id: "1".to_string(), - name: "Ada Lovelace".to_string(), - username: "@ada".to_string(), - }, - User { - id: "2".to_string(), - name: "Alan Turing".to_string(), - username: "@complete".to_string(), - }, - ]; - }); - &USERS - } + static USERS: LazyLock> = LazyLock::new(|| { + vec![ + User { + id: "1".to_string(), + name: "Ada Lovelace".to_string(), + username: "@ada".to_string(), + }, + User { + id: "2".to_string(), + name: "Alan Turing".to_string(), + username: "@complete".to_string(), + }, + ] + }); + &USERS } /* @@ -223,43 +219,39 @@ impl Product { } fn products() -> &'static [Product] { - static mut PRODUCTS: Vec = vec![]; - static INIT: Once = Once::new(); - unsafe { - INIT.call_once(|| { - PRODUCTS = vec![ - Product { - upc: "1".to_string(), - name: Some("Table".to_string()), - price: 899, - weight: 100, - inStock: true, - }, - Product { - upc: "2".to_string(), - name: Some("Couch".to_string()), - price: 1299, - weight: 1000, - inStock: false, - }, - Product { - upc: "3".to_string(), - name: Some("Chair".to_string()), - price: 54, - weight: 50, - inStock: true, - }, - Product { - upc: "4".to_string(), - name: Some("Bed".to_string()), - price: 1000, - weight: 1200, - inStock: false, - }, - ]; - }); - &PRODUCTS - } + static PRODUCTS: LazyLock> = LazyLock::new(|| { + vec![ + Product { + upc: "1".to_string(), + name: Some("Table".to_string()), + price: 899, + weight: 100, + inStock: true, + }, + Product { + upc: "2".to_string(), + name: Some("Couch".to_string()), + price: 1299, + weight: 1000, + inStock: false, + }, + Product { + upc: "3".to_string(), + name: Some("Chair".to_string()), + price: 54, + weight: 50, + inStock: true, + }, + Product { + upc: "4".to_string(), + name: Some("Bed".to_string()), + price: 1000, + weight: 1200, + inStock: false, + }, + ] + }); + &PRODUCTS } /* @@ -303,37 +295,33 @@ impl Review { } fn reviews() -> &'static [Review] { - static mut REVIEWS: Vec = vec![]; - static INIT: Once = Once::new(); - unsafe { - INIT.call_once(|| { - REVIEWS = vec![ - Review { - id: "1".to_string(), - authorId: "1".to_string(), - productUpc: "1".to_string(), - body: Some("Love it!".to_string()), - }, - Review { - id: "2".to_string(), - authorId: "1".to_string(), - productUpc: "2".to_string(), - body: Some("Too expensive.".to_string()), - }, - Review { - id: "3".to_string(), - authorId: "2".to_string(), - productUpc: "3".to_string(), - body: Some("Could be better.".to_string()), - }, - Review { - id: "4".to_string(), - authorId: "2".to_string(), - productUpc: "1".to_string(), - body: Some("Prefer something else.".to_string()), - }, - ]; - }); - &REVIEWS - } + static REVIEWS: LazyLock> = LazyLock::new(|| { + vec![ + Review { + id: "1".to_string(), + authorId: "1".to_string(), + productUpc: "1".to_string(), + body: Some("Love it!".to_string()), + }, + Review { + id: "2".to_string(), + authorId: "1".to_string(), + productUpc: "2".to_string(), + body: Some("Too expensive.".to_string()), + }, + Review { + id: "3".to_string(), + authorId: "2".to_string(), + productUpc: "3".to_string(), + body: Some("Could be better.".to_string()), + }, + Review { + id: "4".to_string(), + authorId: "2".to_string(), + productUpc: "1".to_string(), + body: Some("Prefer something else.".to_string()), + }, + ] + }); + &REVIEWS } diff --git a/fuzz/supergraph-moretypes.graphql b/fuzz/supergraph-moretypes.graphql deleted file mode 100644 index eed557f97c..0000000000 --- a/fuzz/supergraph-moretypes.graphql +++ /dev/null @@ -1,169 +0,0 @@ -schema - @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) -{ - query: Query - mutation: Mutation - subscription: Subscription -} - -directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE - -directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION - -directive @join__graph(name: String!, url: String!) on ENUM_VALUE - -directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE - -directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR - -directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION - -directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA - -directive @noArgs on FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT | MUTATION | QUERY | SUBSCRIPTION - -directive @withArgs(arg1: String = "Default", arg2: String, arg3: Boolean, arg4: Int, arg5: [ID]) on FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT | MUTATION | QUERY | SUBSCRIPTION - -interface AnInterface - @join__type(graph: MAIN) -{ - sharedField: String! -} - -type BasicResponse - @join__type(graph: MAIN) -{ - id: Int! - nullableId: Int -} - -type BasicTypesResponse - @join__type(graph: MAIN) -{ - nullableId: ID - nonNullId: ID! - nullableInt: Int - nonNullInt: Int! - nullableString: String - nonNullString: String! - nullableFloat: Float - nonNullFloat: Float! - nullableBoolean: Boolean - nonNullBoolean: Boolean! -} - -type EverythingResponse - @join__type(graph: MAIN) -{ - id: Int! - nullableId: Int - basicTypes: BasicTypesResponse - enumResponse: SomeEnum - interfaceResponse: AnInterface - interfaceImplementationResponse: InterfaceImplementation2 - unionType2Response: UnionType2 - listOfBools: [Boolean!]! - listOfInterfaces: [AnInterface] - objectTypeWithInputField(boolInput: Boolean, secondInput: Boolean!): ObjectTypeResponse - listOfObjects: [ObjectTypeResponse] -} - -type InterfaceImplementation1 implements AnInterface - @join__implements(graph: MAIN, interface: "AnInterface") - @join__type(graph: MAIN) -{ - sharedField: String! - implementation1Field: Int! -} - -type InterfaceImplementation2 implements AnInterface - @join__implements(graph: MAIN, interface: "AnInterface") - @join__type(graph: MAIN) -{ - sharedField: String! - implementation2Field: Float! -} - -scalar join__FieldSet - -enum join__Graph { - MAIN @join__graph(name: "main", url: "http://localhost:4001/graphql") -} - -scalar link__Import - -enum link__Purpose { - """ - `SECURITY` features provide metadata necessary to securely resolve fields. - """ - SECURITY - - """ - `EXECUTION` features provide metadata necessary for operation execution. - """ - EXECUTION -} - -type Mutation - @join__type(graph: MAIN) -{ - noInputMutation: EverythingResponse! -} - -type ObjectTypeResponse - @join__type(graph: MAIN) -{ - stringField: String! - intField: Int! - nullableField: String -} - -type Query - @join__type(graph: MAIN) -{ - scalarInputQuery(listInput: [String!]!, stringInput: String!, nullableStringInput: String, intInput: Int!, floatInput: Float!, boolInput: Boolean!, enumInput: SomeEnum, idInput: ID!): EverythingResponse! - noInputQuery: EverythingResponse! - basicResponseQuery: BasicResponse! - scalarResponseQuery: String - defaultArgQuery(stringInput: String! = "default"): BasicResponse! - sortQuery(listInput: [String!]!, stringInput: String!, nullableStringInput: String, INTInput: Int!, floatInput: Float!, boolInput: Boolean!, enumInput: SomeEnum, idInput: ID!): SortResponse! -} - -enum SomeEnum - @join__type(graph: MAIN) -{ - SOME_VALUE_1 @join__enumValue(graph: MAIN) - SOME_VALUE_2 @join__enumValue(graph: MAIN) - SOME_VALUE_3 @join__enumValue(graph: MAIN) -} - -type SortResponse - @join__type(graph: MAIN) -{ - id: Int! - nullableId: Int - zzz: Int - aaa: Int - CCC: Int -} - -type Subscription - @join__type(graph: MAIN) -{ - noInputSubscription: EverythingResponse! -} - -type UnionType1 - @join__type(graph: MAIN) -{ - unionType1Field: String! - nullableString: String -} - -type UnionType2 - @join__type(graph: MAIN) -{ - unionType2Field: String! - nullableString: String -} \ No newline at end of file diff --git a/fuzz/supergraph.graphql b/fuzz/supergraph.graphql deleted file mode 100644 index 112d8f88f0..0000000000 --- a/fuzz/supergraph.graphql +++ /dev/null @@ -1,147 +0,0 @@ -schema - @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { - query: Query - mutation: Mutation -} - -directive @join__field( - graph: join__Graph - requires: join__FieldSet - provides: join__FieldSet - type: String - external: Boolean - override: String - usedOverridden: Boolean -) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION - -directive @join__graph(name: String!, url: String!) on ENUM_VALUE - -directive @join__implements( - graph: join__Graph! - interface: String! -) repeatable on OBJECT | INTERFACE - -directive @join__type( - graph: join__Graph! - key: join__FieldSet - extension: Boolean! = false - resolvable: Boolean! = true - isInterfaceObject: Boolean! = false -) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR - -directive @link( - url: String - as: String - for: link__Purpose - import: [link__Import] -) repeatable on SCHEMA - -directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT - -scalar join__FieldSet -scalar link__Import - -enum link__Purpose { - """ - `SECURITY` features provide metadata necessary to securely resolve fields. - """ - SECURITY - - """ - `EXECUTION` features provide metadata necessary for operation execution. - """ - EXECUTION -} - -enum join__Graph { - INVENTORY - @join__graph(name: "inventory", url: "http://localhost:4004/graphql") - PANDAS @join__graph(name: "pandas", url: "http://localhost:4002/graphql") - PRODUCTS @join__graph(name: "products", url: "http://localhost:4003/graphql") - USERS @join__graph(name: "users", url: "http://localhost:4001/graphql") -} - -type DeliveryEstimates @join__type(graph: INVENTORY) { - estimatedDelivery: String - fastestDelivery: String -} - -type Panda @join__type(graph: PANDAS) { - name: ID! - favoriteFood: String -} - -type Product implements ProductItf & SkuItf - @join__implements(graph: INVENTORY, interface: "ProductItf") - @join__implements(graph: PRODUCTS, interface: "ProductItf") - @join__implements(graph: PRODUCTS, interface: "SkuItf") - @join__type(graph: INVENTORY, key: "id") - @join__type(graph: PRODUCTS, key: "id") - @join__type(graph: PRODUCTS, key: "sku package") - @join__type(graph: PRODUCTS, key: "sku variation { id }") { - id: ID! - dimensions: ProductDimension - @join__field(graph: INVENTORY, external: true) - @join__field(graph: PRODUCTS) - delivery(zip: String): DeliveryEstimates - @join__field(graph: INVENTORY, requires: "dimensions { size weight }") - sku: String @join__field(graph: PRODUCTS) - package: String @join__field(graph: PRODUCTS) - variation: ProductVariation @join__field(graph: PRODUCTS) - createdBy: User @join__field(graph: PRODUCTS) -} - -type ProductDimension - @join__type(graph: INVENTORY) - @join__type(graph: PRODUCTS) { - size: String - weight: Float -} - -interface ProductItf implements SkuItf - @join__implements(graph: PRODUCTS, interface: "SkuItf") - @join__type(graph: INVENTORY) - @join__type(graph: PRODUCTS) { - id: ID! - dimensions: ProductDimension - delivery(zip: String): DeliveryEstimates @join__field(graph: INVENTORY) - sku: String @join__field(graph: PRODUCTS) - package: String @join__field(graph: PRODUCTS) - variation: ProductVariation @join__field(graph: PRODUCTS) - createdBy: User @join__field(graph: PRODUCTS) -} - -type ProductVariation @join__type(graph: PRODUCTS) { - id: ID! -} - -type Query - @join__type(graph: INVENTORY) - @join__type(graph: PANDAS) - @join__type(graph: PRODUCTS) - @join__type(graph: USERS) { - allPandas: [Panda] @join__field(graph: PANDAS) - panda(name: ID!): Panda @join__field(graph: PANDAS) - allProducts: [ProductItf] @join__field(graph: PRODUCTS) - product(id: ID!): ProductItf @join__field(graph: PRODUCTS) -} - -enum ShippingClass @join__type(graph: INVENTORY) @join__type(graph: PRODUCTS) { - STANDARD - EXPRESS - OVERNIGHT -} - -interface SkuItf @join__type(graph: PRODUCTS) { - sku: String -} - -type User - @join__type(graph: PRODUCTS, key: "email") - @join__type(graph: USERS, key: "email") { - email: ID! - totalProductsCreated: Int - name: String @join__field(graph: USERS) -} diff --git a/helm/chart/router/Chart.yaml b/helm/chart/router/Chart.yaml index 9d7c28d3d0..cfb2a62fb3 100644 --- a/helm/chart/router/Chart.yaml +++ b/helm/chart/router/Chart.yaml @@ -20,10 +20,10 @@ type: application # so it matches the shape of our release process and release automation. # By proxy of that decision, this version uses SemVer 2.0.0, though the prefix # of "v" is not included. -version: 1.57.1 +version: 2.6.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v1.57.1" +appVersion: "v2.6.0" diff --git a/helm/chart/router/README.md b/helm/chart/router/README.md index 6dd2aaea77..bbba89a650 100644 --- a/helm/chart/router/README.md +++ b/helm/chart/router/README.md @@ -2,7 +2,7 @@ [router](https://github.com/apollographql/router) Rust Graph Routing runtime for Apollo Federation -![Version: 1.57.1](https://img.shields.io/badge/Version-1.57.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v1.57.1](https://img.shields.io/badge/AppVersion-v1.57.1-informational?style=flat-square) +![Version: 2.6.0](https://img.shields.io/badge/Version-2.6.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v2.6.0](https://img.shields.io/badge/AppVersion-v2.6.0-informational?style=flat-square) ## Prerequisites @@ -11,7 +11,7 @@ ## Get Repo Info ```console -helm pull oci://ghcr.io/apollographql/helm-charts/router --version 1.57.1 +helm pull oci://ghcr.io/apollographql/helm-charts/router --version 2.6.0 ``` ## Install Chart @@ -19,7 +19,7 @@ helm pull oci://ghcr.io/apollographql/helm-charts/router --version 1.57.1 **Important:** only helm3 is supported ```console -helm upgrade --install [RELEASE_NAME] oci://ghcr.io/apollographql/helm-charts/router --version 1.57.1 --values my-values.yaml +helm upgrade --install [RELEASE_NAME] oci://ghcr.io/apollographql/helm-charts/router --version 2.6.0 --values my-values.yaml ``` _See [configuration](#configuration) below._ @@ -81,10 +81,11 @@ helm show values oci://ghcr.io/apollographql/helm-charts/router | resources | object | `{}` | | | restartPolicy | string | `"Always"` | Sets the restart policy of pods | | rollingUpdate | object | `{}` | Sets the [rolling update strategy parameters](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#rolling-update-deployment). Can take absolute values or % values. | -| router | object | `{"args":["--hot-reload"],"configuration":{"health_check":{"listen":"0.0.0.0:8088"},"supergraph":{"listen":"0.0.0.0:4000"}}}` | See https://www.apollographql.com/docs/router/configuration/overview/#yaml-config-file for yaml structure | +| router | object | `{"args":["--hot-reload"],"configuration":{"health_check":{"listen":"0.0.0.0:8088"},"supergraph":{"listen":"0.0.0.0:4000"}}}` | See https://www.apollographql.com/docs/graphos/reference/router/configuration#yaml-config-file for yaml structure | | securityContext | object | `{}` | | | service.annotations | object | `{}` | | | service.port | int | `80` | | +| service.targetport | string | `"http"` | | | service.type | string | `"ClusterIP"` | | | serviceAccount.annotations | object | `{}` | | | serviceAccount.create | bool | `true` | | @@ -98,4 +99,4 @@ helm show values oci://ghcr.io/apollographql/helm-charts/router | virtualservice.enabled | bool | `false` | | ---------------------------------------------- -Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) +Autogenerated from chart metadata using [helm-docs v1.11.2](https://github.com/norwoodj/helm-docs/releases/v1.11.2) diff --git a/helm/chart/router/templates/configmap.yaml b/helm/chart/router/templates/configmap.yaml index 171cc2e1fb..9d5a7b3a9c 100644 --- a/helm/chart/router/templates/configmap.yaml +++ b/helm/chart/router/templates/configmap.yaml @@ -5,7 +5,7 @@ {{- if (.Values.router.configuration.telemetry).exporters }} {{- $configuration = dict "telemetry" (dict "exporters" (dict "metrics" (dict "common" (dict "resource" (dict "service.name" $routerFullName))))) -}} {{- else }} -{{- $configuration := dict "telemetry" (dict "metrics" (dict "common" (dict "resources" (dict "service.name" $routerFullName)))) -}} +{{- $configuration := dict "telemetry" (dict "metrics" (dict "common" (dict "resource" (dict "service.name" $routerFullName)))) -}} {{- end }} {{- $_ := mustMergeOverwrite $configuration .Values.router.configuration -}} diff --git a/helm/chart/router/templates/service.yaml b/helm/chart/router/templates/service.yaml index 9cc426588b..f30f2e419e 100644 --- a/helm/chart/router/templates/service.yaml +++ b/helm/chart/router/templates/service.yaml @@ -15,7 +15,7 @@ spec: type: {{ .Values.service.type }} ports: - port: {{ .Values.service.port }} - targetPort: http + targetPort: {{ .Values.service.targetport }} protocol: TCP name: http - port: {{ (splitList ":" (index .Values.router.configuration "health_check").listen | last ) | default "8088" }} diff --git a/helm/chart/router/templates/virtualservice.yaml b/helm/chart/router/templates/virtualservice.yaml index 3d273583d7..d04b8bd1ed 100644 --- a/helm/chart/router/templates/virtualservice.yaml +++ b/helm/chart/router/templates/virtualservice.yaml @@ -20,13 +20,13 @@ metadata: {{- end }} spec: hosts: - { { if .Values.virtualservice.Hosts } } - { { - range .Values.virtualservice.Hosts } } - - { { . | quote } } - { { - end } } - { { - else } } + {{ if .Values.virtualservice.Hosts }} + {{- range .Values.virtualservice.Hosts }} + - {{ . | quote }} + {{- end }} + {{- else }} - "*" - { { - end } } + {{- end }} {{- if .Values.virtualservice.gatewayName }} gateways: - {{ .Values.virtualservice.gatewayName }} diff --git a/helm/chart/router/values.yaml b/helm/chart/router/values.yaml index 35f45618a7..101634fe50 100644 --- a/helm/chart/router/values.yaml +++ b/helm/chart/router/values.yaml @@ -4,7 +4,7 @@ replicaCount: 1 -# -- See https://www.apollographql.com/docs/router/configuration/overview/#yaml-config-file for yaml structure +# -- See https://www.apollographql.com/docs/graphos/reference/router/configuration#yaml-config-file for yaml structure router: configuration: supergraph: @@ -138,6 +138,7 @@ service: type: ClusterIP port: 80 annotations: {} + targetport: http serviceMonitor: enabled: false diff --git a/licenses.html b/licenses.html index 01b9b1d8ef..26a534927f 100644 --- a/licenses.html +++ b/licenses.html @@ -44,16 +44,18 @@

Third Party Licenses

Overview of licenses:

All license text:

@@ -67,11 +69,13 @@

Used by:

  • aws-runtime
  • aws-sigv4
  • aws-smithy-async
  • +
  • aws-smithy-http-client
  • aws-smithy-http
  • aws-smithy-json
  • +
  • aws-smithy-observability
  • aws-smithy-query
  • -
  • aws-smithy-runtime
  • aws-smithy-runtime-api
  • +
  • aws-smithy-runtime
  • aws-smithy-types
  • aws-smithy-xml
  • aws-types
  • @@ -258,11 +262,15 @@

    Apache License 2.0

    Used by:

    @@ -659,8 +667,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                      Apache License
    @@ -851,7 +858,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2022 Jacob Pratt et al. + Copyright 2021 Scott Lamb <slamb@slamb.org> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -1290,8 +1297,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                      Apache License
    @@ -1501,12 +1507,18 @@ 

    Used by:

    Apache License 2.0

    Used by:

    @@ -1717,12 +1729,16 @@ 

    Used by:

    Apache License 2.0

    Used by:

                                      Apache License
                                Version 2.0, January 2004
    -                        https://www.apache.org/licenses/
    +                        http://www.apache.org/licenses/
     
        TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
     
    @@ -1896,14 +1912,114 @@ 

    Used by:

    of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +--- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. +
  • Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -2081,40 +2197,38 @@ 

    Used by:

    of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License.
  • Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -2304,7 +2418,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright (c) Microsoft Corporation. + Copyright 2019 Akhil Velagapudi Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2323,7 +2437,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -2513,7 +2627,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 Tomasz "Soveu" Marx + Copyright 2020 - 2024 Tatsuya Kawano Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2526,14 +2640,13 @@

    Used by:

    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -
  • Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -2723,7 +2836,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023 The Fuchsia Authors + Copyright 2020 Tomasz "Soveu" Marx Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2743,17 +2856,9 @@

    Used by:

    Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -2943,7 +3048,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023 The OpenTelemetry Authors + Copyright 2023 The Fuchsia Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2956,16 +3061,14 @@

    Used by:

    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +
  • Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -3155,7 +3258,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright [2021] [youki team] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -3174,7 +3277,18 @@

    Used by:

    Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -3356,7 +3470,7 @@ 

    Used by:

    APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" + boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -3364,7 +3478,7 @@

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2017 Juniper Networks, Inc. + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -3383,8 +3497,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -3566,7 +3679,7 @@ 

    Used by:

    APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" + boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -3574,7 +3687,7 @@

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2019 Michael P. Jung + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -3587,14 +3700,13 @@

    Used by:

    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - -
    +
  • Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -3784,7 +3896,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2019 TiKV Project Authors. + Copyright 2017 Juniper Networks, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -3803,11 +3915,8 @@

    Used by:

    Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -3997,7 +4106,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + Copyright 2019 Michael P. Jung Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -4010,38 +4119,14 @@

    Used by:

    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +
  • Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -4231,7 +4316,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + Copyright 2019 TiKV Project Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -4244,14 +4329,19 @@

    Used by:

    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -
  • Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -4430,8 +4520,18 @@ 

    Used by:

    END OF TERMS AND CONDITIONS - Copyright 2019 Yoshua Wuyts - Copyright 2016-2018 Michael Tilli (Pyfisch) & `httpdate` contributors + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -4450,14 +4550,44 @@

    Used by:

    Apache License 2.0

    Used by:

    -
                                     Apache License
    -                           Version 2.0, January 2004
    -                        https://www.apache.org/licenses/
    -
    -   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +                    
  • anstream
  • +
  • anstyle-query
  • +
  • anstyle-wincon
  • +
  • anstyle
  • +
  • bytesize
  • +
  • clap
  • +
  • clap_builder
  • +
  • clap_derive
  • +
  • colorchoice
  • +
  • concolor-query
  • +
  • concolor
  • +
  • crc32fast
  • +
  • derive_builder
  • +
  • derive_builder_core
  • +
  • derive_builder_macro
  • +
  • enum-as-inner
  • +
  • env_filter
  • +
  • env_logger
  • +
  • graphql-parser
  • +
  • hex
  • +
  • humantime
  • +
  • is_terminal_polyfill
  • +
  • quick-error
  • +
  • resolv-conf
  • +
  • serde_regex
  • +
  • serde_spanned
  • +
  • stringprep
  • +
  • tikv-jemalloc-ctl
  • +
  • toml
  • +
  • toml_datetime
  • +
  • toml_edit
  • +
  • toml_write
  • + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
     
        1. Definitions.
     
    @@ -4647,7 +4777,7 @@ 

    Used by:

    you may not use this file except in compliance with the License. You may obtain a copy of the License at - https://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -4661,197 +4791,221 @@

    Used by:

    Apache License 2.0

    Used by:

    -
                                     Apache License
    -                           Version 2.0, January 2004
    -                        http://www.apache.org/licenses/
    -
    -   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    -
    -   1. Definitions.
    -
    -      "License" shall mean the terms and conditions for use, reproduction,
    -      and distribution as defined by Sections 1 through 9 of this document.
    -
    -      "Licensor" shall mean the copyright owner or entity authorized by
    -      the copyright owner that is granting the License.
    -
    -      "Legal Entity" shall mean the union of the acting entity and all
    -      other entities that control, are controlled by, or are under common
    -      control with that entity. For the purposes of this definition,
    -      "control" means (i) the power, direct or indirect, to cause the
    -      direction or management of such entity, whether by contract or
    -      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    -      outstanding shares, or (iii) beneficial ownership of such entity.
    -
    -      "You" (or "Your") shall mean an individual or Legal Entity
    -      exercising permissions granted by this License.
    -
    -      "Source" form shall mean the preferred form for making modifications,
    -      including but not limited to software source code, documentation
    -      source, and configuration files.
    -
    -      "Object" form shall mean any form resulting from mechanical
    -      transformation or translation of a Source form, including but
    -      not limited to compiled object code, generated documentation,
    -      and conversions to other media types.
    -
    -      "Work" shall mean the work of authorship, whether in Source or
    -      Object form, made available under the License, as indicated by a
    -      copyright notice that is included in or attached to the work
    -      (an example is provided in the Appendix below).
    -
    -      "Derivative Works" shall mean any work, whether in Source or Object
    -      form, that is based on (or derived from) the Work and for which the
    -      editorial revisions, annotations, elaborations, or other modifications
    -      represent, as a whole, an original work of authorship. For the purposes
    -      of this License, Derivative Works shall not include works that remain
    -      separable from, or merely link (or bind by name) to the interfaces of,
    -      the Work and Derivative Works thereof.
    -
    -      "Contribution" shall mean any work of authorship, including
    -      the original version of the Work and any modifications or additions
    -      to that Work or Derivative Works thereof, that is intentionally
    -      submitted to Licensor for inclusion in the Work by the copyright owner
    -      or by an individual or Legal Entity authorized to submit on behalf of
    -      the copyright owner. For the purposes of this definition, "submitted"
    -      means any form of electronic, verbal, or written communication sent
    -      to the Licensor or its representatives, including but not limited to
    -      communication on electronic mailing lists, source code control systems,
    -      and issue tracking systems that are managed by, or on behalf of, the
    -      Licensor for the purpose of discussing and improving the Work, but
    -      excluding communication that is conspicuously marked or otherwise
    -      designated in writing by the copyright owner as "Not a Contribution."
    -
    -      "Contributor" shall mean Licensor and any individual or Legal Entity
    -      on behalf of whom a Contribution has been received by Licensor and
    -      subsequently incorporated within the Work.
    -
    -   2. Grant of Copyright License. Subject to the terms and conditions of
    -      this License, each Contributor hereby grants to You a perpetual,
    -      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    -      copyright license to reproduce, prepare Derivative Works of,
    -      publicly display, publicly perform, sublicense, and distribute the
    -      Work and such Derivative Works in Source or Object form.
    -
    -   3. Grant of Patent License. Subject to the terms and conditions of
    -      this License, each Contributor hereby grants to You a perpetual,
    -      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    -      (except as stated in this section) patent license to make, have made,
    -      use, offer to sell, sell, import, and otherwise transfer the Work,
    -      where such license applies only to those patent claims licensable
    -      by such Contributor that are necessarily infringed by their
    -      Contribution(s) alone or by combination of their Contribution(s)
    -      with the Work to which such Contribution(s) was submitted. If You
    -      institute patent litigation against any entity (including a
    -      cross-claim or counterclaim in a lawsuit) alleging that the Work
    -      or a Contribution incorporated within the Work constitutes direct
    -      or contributory patent infringement, then any patent licenses
    -      granted to You under this License for that Work shall terminate
    -      as of the date such litigation is filed.
    -
    -   4. Redistribution. You may reproduce and distribute copies of the
    -      Work or Derivative Works thereof in any medium, with or without
    -      modifications, and in Source or Object form, provided that You
    -      meet the following conditions:
    -
    -      (a) You must give any other recipients of the Work or
    -          Derivative Works a copy of this License; and
    -
    -      (b) You must cause any modified files to carry prominent notices
    -          stating that You changed the files; and
    -
    -      (c) You must retain, in the Source form of any Derivative Works
    -          that You distribute, all copyright, patent, trademark, and
    -          attribution notices from the Source form of the Work,
    -          excluding those notices that do not pertain to any part of
    -          the Derivative Works; and
    -
    -      (d) If the Work includes a "NOTICE" text file as part of its
    -          distribution, then any Derivative Works that You distribute must
    -          include a readable copy of the attribution notices contained
    -          within such NOTICE file, excluding those notices that do not
    -          pertain to any part of the Derivative Works, in at least one
    -          of the following places: within a NOTICE text file distributed
    -          as part of the Derivative Works; within the Source form or
    -          documentation, if provided along with the Derivative Works; or,
    -          within a display generated by the Derivative Works, if and
    -          wherever such third-party notices normally appear. The contents
    -          of the NOTICE file are for informational purposes only and
    -          do not modify the License. You may add Your own attribution
    -          notices within Derivative Works that You distribute, alongside
    -          or as an addendum to the NOTICE text from the Work, provided
    -          that such additional attribution notices cannot be construed
    -          as modifying the License.
    -
    -      You may add Your own copyright statement to Your modifications and
    -      may provide additional or different license terms and conditions
    -      for use, reproduction, or distribution of Your modifications, or
    -      for any such Derivative Works as a whole, provided Your use,
    -      reproduction, and distribution of the Work otherwise complies with
    -      the conditions stated in this License.
    -
    -   5. Submission of Contributions. Unless You explicitly state otherwise,
    -      any Contribution intentionally submitted for inclusion in the Work
    -      by You to the Licensor shall be under the terms and conditions of
    -      this License, without any additional terms or conditions.
    -      Notwithstanding the above, nothing herein shall supersede or modify
    -      the terms of any separate license agreement you may have executed
    -      with Licensor regarding such Contributions.
    -
    -   6. Trademarks. This License does not grant permission to use the trade
    -      names, trademarks, service marks, or product names of the Licensor,
    -      except as required for reasonable and customary use in describing the
    -      origin of the Work and reproducing the content of the NOTICE file.
    -
    -   7. Disclaimer of Warranty. Unless required by applicable law or
    -      agreed to in writing, Licensor provides the Work (and each
    -      Contributor provides its Contributions) on an "AS IS" BASIS,
    -      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    -      implied, including, without limitation, any warranties or conditions
    -      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    -      PARTICULAR PURPOSE. You are solely responsible for determining the
    -      appropriateness of using or redistributing the Work and assume any
    -      risks associated with Your exercise of permissions under this License.
    -
    -   8. Limitation of Liability. In no event and under no legal theory,
    -      whether in tort (including negligence), contract, or otherwise,
    -      unless required by applicable law (such as deliberate and grossly
    -      negligent acts) or agreed to in writing, shall any Contributor be
    -      liable to You for damages, including any direct, indirect, special,
    -      incidental, or consequential damages of any character arising as a
    -      result of this License or out of the use or inability to use the
    -      Work (including but not limited to damages for loss of goodwill,
    -      work stoppage, computer failure or malfunction, or any and all
    -      other commercial damages or losses), even if such Contributor
    -      has been advised of the possibility of such damages.
    -
    -   9. Accepting Warranty or Additional Liability. While redistributing
    -      the Work or Derivative Works thereof, You may choose to offer,
    -      and charge a fee for, acceptance of support, warranty, indemnity,
    -      or other liability obligations and/or rights consistent with this
    -      License. However, in accepting such obligations, You may act only
    -      on Your own behalf and on Your sole responsibility, not on behalf
    -      of any other Contributor, and only if You agree to indemnify,
    -      defend, and hold each Contributor harmless for any liability
    -      incurred by, or claims asserted against, such Contributor by reason
    -      of your accepting any such warranty or additional liability.
    -
    -   END OF TERMS AND CONDITIONS
    +                
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1.  Definitions.
    +
    +    "License" shall mean the terms and conditions for use, reproduction,
    +    and distribution as defined by Sections 1 through 9 of this document.
    +
    +    "Licensor" shall mean the copyright owner or entity authorized by
    +    the copyright owner that is granting the License.
    +
    +    "Legal Entity" shall mean the union of the acting entity and all
    +    other entities that control, are controlled by, or are under common
    +    control with that entity. For the purposes of this definition,
    +    "control" means (i) the power, direct or indirect, to cause the
    +    direction or management of such entity, whether by contract or
    +    otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +    outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +    "You" (or "Your") shall mean an individual or Legal Entity
    +    exercising permissions granted by this License.
    +
    +    "Source" form shall mean the preferred form for making modifications,
    +    including but not limited to software source code, documentation
    +    source, and configuration files.
    +
    +    "Object" form shall mean any form resulting from mechanical
    +    transformation or translation of a Source form, including but
    +    not limited to compiled object code, generated documentation,
    +    and conversions to other media types.
    +
    +    "Work" shall mean the work of authorship, whether in Source or
    +    Object form, made available under the License, as indicated by a
    +    copyright notice that is included in or attached to the work
    +    (an example is provided in the Appendix below).
    +
    +    "Derivative Works" shall mean any work, whether in Source or Object
    +    form, that is based on (or derived from) the Work and for which the
    +    editorial revisions, annotations, elaborations, or other modifications
    +    represent, as a whole, an original work of authorship. For the purposes
    +    of this License, Derivative Works shall not include works that remain
    +    separable from, or merely link (or bind by name) to the interfaces of,
    +    the Work and Derivative Works thereof.
    +
    +    "Contribution" shall mean any work of authorship, including
    +    the original version of the Work and any modifications or additions
    +    to that Work or Derivative Works thereof, that is intentionally
    +    submitted to Licensor for inclusion in the Work by the copyright owner
    +    or by an individual or Legal Entity authorized to submit on behalf of
    +    the copyright owner. For the purposes of this definition, "submitted"
    +    means any form of electronic, verbal, or written communication sent
    +    to the Licensor or its representatives, including but not limited to
    +    communication on electronic mailing lists, source code control systems,
    +    and issue tracking systems that are managed by, or on behalf of, the
    +    Licensor for the purpose of discussing and improving the Work, but
    +    excluding communication that is conspicuously marked or otherwise
    +    designated in writing by the copyright owner as "Not a Contribution."
    +
    +    "Contributor" shall mean Licensor and any individual or Legal Entity
    +    on behalf of whom a Contribution has been received by Licensor and
    +    subsequently incorporated within the Work.
    +
    +2.  Grant of Copyright License. Subject to the terms and conditions of
    +    this License, each Contributor hereby grants to You a perpetual,
    +    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +    copyright license to reproduce, prepare Derivative Works of,
    +    publicly display, publicly perform, sublicense, and distribute the
    +    Work and such Derivative Works in Source or Object form.
    +
    +3.  Grant of Patent License. Subject to the terms and conditions of
    +    this License, each Contributor hereby grants to You a perpetual,
    +    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +    (except as stated in this section) patent license to make, have made,
    +    use, offer to sell, sell, import, and otherwise transfer the Work,
    +    where such license applies only to those patent claims licensable
    +    by such Contributor that are necessarily infringed by their
    +    Contribution(s) alone or by combination of their Contribution(s)
    +    with the Work to which such Contribution(s) was submitted. If You
    +    institute patent litigation against any entity (including a
    +    cross-claim or counterclaim in a lawsuit) alleging that the Work
    +    or a Contribution incorporated within the Work constitutes direct
    +    or contributory patent infringement, then any patent licenses
    +    granted to You under this License for that Work shall terminate
    +    as of the date such litigation is filed.
    +
    +4.  Redistribution. You may reproduce and distribute copies of the
    +    Work or Derivative Works thereof in any medium, with or without
    +    modifications, and in Source or Object form, provided that You
    +    meet the following conditions:
    +
    +    (a) You must give any other recipients of the Work or
    +    Derivative Works a copy of this License; and
    +
    +    (b) You must cause any modified files to carry prominent notices
    +    stating that You changed the files; and
    +
    +    (c) You must retain, in the Source form of any Derivative Works
    +    that You distribute, all copyright, patent, trademark, and
    +    attribution notices from the Source form of the Work,
    +    excluding those notices that do not pertain to any part of
    +    the Derivative Works; and
    +
    +    (d) If the Work includes a "NOTICE" text file as part of its
    +    distribution, then any Derivative Works that You distribute must
    +    include a readable copy of the attribution notices contained
    +    within such NOTICE file, excluding those notices that do not
    +    pertain to any part of the Derivative Works, in at least one
    +    of the following places: within a NOTICE text file distributed
    +    as part of the Derivative Works; within the Source form or
    +    documentation, if provided along with the Derivative Works; or,
    +    within a display generated by the Derivative Works, if and
    +    wherever such third-party notices normally appear. The contents
    +    of the NOTICE file are for informational purposes only and
    +    do not modify the License. You may add Your own attribution
    +    notices within Derivative Works that You distribute, alongside
    +    or as an addendum to the NOTICE text from the Work, provided
    +    that such additional attribution notices cannot be construed
    +    as modifying the License.
    +
    +    You may add Your own copyright statement to Your modifications and
    +    may provide additional or different license terms and conditions
    +    for use, reproduction, or distribution of Your modifications, or
    +    for any such Derivative Works as a whole, provided Your use,
    +    reproduction, and distribution of the Work otherwise complies with
    +    the conditions stated in this License.
    +
    +5.  Submission of Contributions. Unless You explicitly state otherwise,
    +    any Contribution intentionally submitted for inclusion in the Work
    +    by You to the Licensor shall be under the terms and conditions of
    +    this License, without any additional terms or conditions.
    +    Notwithstanding the above, nothing herein shall supersede or modify
    +    the terms of any separate license agreement you may have executed
    +    with Licensor regarding such Contributions.
    +
    +6.  Trademarks. This License does not grant permission to use the trade
    +    names, trademarks, service marks, or product names of the Licensor,
    +    except as required for reasonable and customary use in describing the
    +    origin of the Work and reproducing the content of the NOTICE file.
    +
    +7.  Disclaimer of Warranty. Unless required by applicable law or
    +    agreed to in writing, Licensor provides the Work (and each
    +    Contributor provides its Contributions) on an "AS IS" BASIS,
    +    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +    implied, including, without limitation, any warranties or conditions
    +    of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +    PARTICULAR PURPOSE. You are solely responsible for determining the
    +    appropriateness of using or redistributing the Work and assume any
    +    risks associated with Your exercise of permissions under this License.
    +
    +8.  Limitation of Liability. In no event and under no legal theory,
    +    whether in tort (including negligence), contract, or otherwise,
    +    unless required by applicable law (such as deliberate and grossly
    +    negligent acts) or agreed to in writing, shall any Contributor be
    +    liable to You for damages, including any direct, indirect, special,
    +    incidental, or consequential damages of any character arising as a
    +    result of this License or out of the use or inability to use the
    +    Work (including but not limited to damages for loss of goodwill,
    +    work stoppage, computer failure or malfunction, or any and all
    +    other commercial damages or losses), even if such Contributor
    +    has been advised of the possibility of such damages.
    +
    +9.  Accepting Warranty or Additional Liability. While redistributing
    +    the Work or Derivative Works thereof, You may choose to offer,
    +    and charge a fee for, acceptance of support, warranty, indemnity,
    +    or other liability obligations and/or rights consistent with this
    +    License. However, in accepting such obligations, You may act only
    +    on Your own behalf and on Your sole responsibility, not on behalf
    +    of any other Contributor, and only if You agree to indemnify,
    +    defend, and hold each Contributor harmless for any liability
    +    incurred by, or claims asserted against, such Contributor by reason
    +    of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
     
  • Apache License 2.0

    Used by:

    -
                                    Apache License
    +                
                                     Apache License
                                Version 2.0, January 2004
    -                        http://www.apache.org/licenses/
    +                        https://www.apache.org/licenses/
     
        TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
     
    @@ -5037,13 +5191,13 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2018-2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -5057,203 +5211,3101 @@

    Used by:

    Apache License 2.0

    Used by:

    -
                                   Apache License
    -                         Version 2.0, January 2004
    -                      http://www.apache.org/licenses/
    -
    +                
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                    Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2018-2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                   Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright [yyyy] [name of copyright owner]
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                   Apache License
    +                         Version 2.0, January 2004
    +                      http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +  "License" shall mean the terms and conditions for use, reproduction,
    +  and distribution as defined by Sections 1 through 9 of this document.
    +
    +  "Licensor" shall mean the copyright owner or entity authorized by
    +  the copyright owner that is granting the License.
    +
    +  "Legal Entity" shall mean the union of the acting entity and all
    +  other entities that control, are controlled by, or are under common
    +  control with that entity. For the purposes of this definition,
    +  "control" means (i) the power, direct or indirect, to cause the
    +  direction or management of such entity, whether by contract or
    +  otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +  outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +  "You" (or "Your") shall mean an individual or Legal Entity
    +  exercising permissions granted by this License.
    +
    +  "Source" form shall mean the preferred form for making modifications,
    +  including but not limited to software source code, documentation
    +  source, and configuration files.
    +
    +  "Object" form shall mean any form resulting from mechanical
    +  transformation or translation of a Source form, including but
    +  not limited to compiled object code, generated documentation,
    +  and conversions to other media types.
    +
    +  "Work" shall mean the work of authorship, whether in Source or
    +  Object form, made available under the License, as indicated by a
    +  copyright notice that is included in or attached to the work
    +  (an example is provided in the Appendix below).
    +
    +  "Derivative Works" shall mean any work, whether in Source or Object
    +  form, that is based on (or derived from) the Work and for which the
    +  editorial revisions, annotations, elaborations, or other modifications
    +  represent, as a whole, an original work of authorship. For the purposes
    +  of this License, Derivative Works shall not include works that remain
    +  separable from, or merely link (or bind by name) to the interfaces of,
    +  the Work and Derivative Works thereof.
    +
    +  "Contribution" shall mean any work of authorship, including
    +  the original version of the Work and any modifications or additions
    +  to that Work or Derivative Works thereof, that is intentionally
    +  submitted to Licensor for inclusion in the Work by the copyright owner
    +  or by an individual or Legal Entity authorized to submit on behalf of
    +  the copyright owner. For the purposes of this definition, "submitted"
    +  means any form of electronic, verbal, or written communication sent
    +  to the Licensor or its representatives, including but not limited to
    +  communication on electronic mailing lists, source code control systems,
    +  and issue tracking systems that are managed by, or on behalf of, the
    +  Licensor for the purpose of discussing and improving the Work, but
    +  excluding communication that is conspicuously marked or otherwise
    +  designated in writing by the copyright owner as "Not a Contribution."
    +
    +  "Contributor" shall mean Licensor and any individual or Legal Entity
    +  on behalf of whom a Contribution has been received by Licensor and
    +  subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +  this License, each Contributor hereby grants to You a perpetual,
    +  worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +  copyright license to reproduce, prepare Derivative Works of,
    +  publicly display, publicly perform, sublicense, and distribute the
    +  Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +  this License, each Contributor hereby grants to You a perpetual,
    +  worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +  (except as stated in this section) patent license to make, have made,
    +  use, offer to sell, sell, import, and otherwise transfer the Work,
    +  where such license applies only to those patent claims licensable
    +  by such Contributor that are necessarily infringed by their
    +  Contribution(s) alone or by combination of their Contribution(s)
    +  with the Work to which such Contribution(s) was submitted. If You
    +  institute patent litigation against any entity (including a
    +  cross-claim or counterclaim in a lawsuit) alleging that the Work
    +  or a Contribution incorporated within the Work constitutes direct
    +  or contributory patent infringement, then any patent licenses
    +  granted to You under this License for that Work shall terminate
    +  as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +  Work or Derivative Works thereof in any medium, with or without
    +  modifications, and in Source or Object form, provided that You
    +  meet the following conditions:
    +
    +  (a) You must give any other recipients of the Work or
    +      Derivative Works a copy of this License; and
    +
    +  (b) You must cause any modified files to carry prominent notices
    +      stating that You changed the files; and
    +
    +  (c) You must retain, in the Source form of any Derivative Works
    +      that You distribute, all copyright, patent, trademark, and
    +      attribution notices from the Source form of the Work,
    +      excluding those notices that do not pertain to any part of
    +      the Derivative Works; and
    +
    +  (d) If the Work includes a "NOTICE" text file as part of its
    +      distribution, then any Derivative Works that You distribute must
    +      include a readable copy of the attribution notices contained
    +      within such NOTICE file, excluding those notices that do not
    +      pertain to any part of the Derivative Works, in at least one
    +      of the following places: within a NOTICE text file distributed
    +      as part of the Derivative Works; within the Source form or
    +      documentation, if provided along with the Derivative Works; or,
    +      within a display generated by the Derivative Works, if and
    +      wherever such third-party notices normally appear. The contents
    +      of the NOTICE file are for informational purposes only and
    +      do not modify the License. You may add Your own attribution
    +      notices within Derivative Works that You distribute, alongside
    +      or as an addendum to the NOTICE text from the Work, provided
    +      that such additional attribution notices cannot be construed
    +      as modifying the License.
    +
    +  You may add Your own copyright statement to Your modifications and
    +  may provide additional or different license terms and conditions
    +  for use, reproduction, or distribution of Your modifications, or
    +  for any such Derivative Works as a whole, provided Your use,
    +  reproduction, and distribution of the Work otherwise complies with
    +  the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +  any Contribution intentionally submitted for inclusion in the Work
    +  by You to the Licensor shall be under the terms and conditions of
    +  this License, without any additional terms or conditions.
    +  Notwithstanding the above, nothing herein shall supersede or modify
    +  the terms of any separate license agreement you may have executed
    +  with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +  names, trademarks, service marks, or product names of the Licensor,
    +  except as required for reasonable and customary use in describing the
    +  origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +  agreed to in writing, Licensor provides the Work (and each
    +  Contributor provides its Contributions) on an "AS IS" BASIS,
    +  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +  implied, including, without limitation, any warranties or conditions
    +  of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +  PARTICULAR PURPOSE. You are solely responsible for determining the
    +  appropriateness of using or redistributing the Work and assume any
    +  risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +  whether in tort (including negligence), contract, or otherwise,
    +  unless required by applicable law (such as deliberate and grossly
    +  negligent acts) or agreed to in writing, shall any Contributor be
    +  liable to You for damages, including any direct, indirect, special,
    +  incidental, or consequential damages of any character arising as a
    +  result of this License or out of the use or inability to use the
    +  Work (including but not limited to damages for loss of goodwill,
    +  work stoppage, computer failure or malfunction, or any and all
    +  other commercial damages or losses), even if such Contributor
    +  has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +  the Work or Derivative Works thereof, You may choose to offer,
    +  and charge a fee for, acceptance of support, warranty, indemnity,
    +  or other liability obligations and/or rights consistent with this
    +  License. However, in accepting such obligations, You may act only
    +  on Your own behalf and on Your sole responsibility, not on behalf
    +  of any other Contributor, and only if You agree to indemnify,
    +  defend, and hold each Contributor harmless for any liability
    +  incurred by, or claims asserted against, such Contributor by reason
    +  of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +  To apply the Apache License to your work, attach the following
    +  boilerplate notice, with the fields enclosed by brackets "[]"
    +  replaced with your own identifying information. (Don't include
    +  the brackets!)  The text should be enclosed in the appropriate
    +  comment syntax for the file format. We also recommend that a
    +  file or class name and description of purpose be included on the
    +  same "printed page" as the copyright notice for easier
    +  identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +   http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0 January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright (c) 2016 Alex Crichton
    +Copyright (c) 2017 The Tokio Authors
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright (c) 2019 Matthias Einwag
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2014 Paho Lurie-Gregg
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2016 Sean McArthur
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2017 Sergio Benitez
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2017 Sergio Benitez
    +Copyright 2014 Alex Chricton
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
     TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
     
     1. Definitions.
     
    -  "License" shall mean the terms and conditions for use, reproduction,
    -  and distribution as defined by Sections 1 through 9 of this document.
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
     
    -  "Licensor" shall mean the copyright owner or entity authorized by
    -  the copyright owner that is granting the License.
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
     
    -  "Legal Entity" shall mean the union of the acting entity and all
    -  other entities that control, are controlled by, or are under common
    -  control with that entity. For the purposes of this definition,
    -  "control" means (i) the power, direct or indirect, to cause the
    -  direction or management of such entity, whether by contract or
    -  otherwise, or (ii) ownership of fifty percent (50%) or more of the
    -  outstanding shares, or (iii) beneficial ownership of such entity.
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
     
    -  "You" (or "Your") shall mean an individual or Legal Entity
    -  exercising permissions granted by this License.
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
     
    -  "Source" form shall mean the preferred form for making modifications,
    -  including but not limited to software source code, documentation
    -  source, and configuration files.
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2017 Valentin Brandl
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
     
    -  "Object" form shall mean any form resulting from mechanical
    -  transformation or translation of a Source form, including but
    -  not limited to compiled object code, generated documentation,
    -  and conversions to other media types.
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
     
    -  "Work" shall mean the work of authorship, whether in Source or
    -  Object form, made available under the License, as indicated by a
    -  copyright notice that is included in or attached to the work
    -  (an example is provided in the Appendix below).
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
     
    -  "Derivative Works" shall mean any work, whether in Source or Object
    -  form, that is based on (or derived from) the Work and for which the
    -  editorial revisions, annotations, elaborations, or other modifications
    -  represent, as a whole, an original work of authorship. For the purposes
    -  of this License, Derivative Works shall not include works that remain
    -  separable from, or merely link (or bind by name) to the interfaces of,
    -  the Work and Derivative Works thereof.
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
     
    -  "Contribution" shall mean any work of authorship, including
    -  the original version of the Work and any modifications or additions
    -  to that Work or Derivative Works thereof, that is intentionally
    -  submitted to Licensor for inclusion in the Work by the copyright owner
    -  or by an individual or Legal Entity authorized to submit on behalf of
    -  the copyright owner. For the purposes of this definition, "submitted"
    -  means any form of electronic, verbal, or written communication sent
    -  to the Licensor or its representatives, including but not limited to
    -  communication on electronic mailing lists, source code control systems,
    -  and issue tracking systems that are managed by, or on behalf of, the
    -  Licensor for the purpose of discussing and improving the Work, but
    -  excluding communication that is conspicuously marked or otherwise
    -  designated in writing by the copyright owner as "Not a Contribution."
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
     
    -  "Contributor" shall mean Licensor and any individual or Legal Entity
    -  on behalf of whom a Contribution has been received by Licensor and
    -  subsequently incorporated within the Work.
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
     
     2. Grant of Copyright License. Subject to the terms and conditions of
    -  this License, each Contributor hereby grants to You a perpetual,
    -  worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    -  copyright license to reproduce, prepare Derivative Works of,
    -  publicly display, publicly perform, sublicense, and distribute the
    -  Work and such Derivative Works in Source or Object form.
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
     
     3. Grant of Patent License. Subject to the terms and conditions of
    -  this License, each Contributor hereby grants to You a perpetual,
    -  worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    -  (except as stated in this section) patent license to make, have made,
    -  use, offer to sell, sell, import, and otherwise transfer the Work,
    -  where such license applies only to those patent claims licensable
    -  by such Contributor that are necessarily infringed by their
    -  Contribution(s) alone or by combination of their Contribution(s)
    -  with the Work to which such Contribution(s) was submitted. If You
    -  institute patent litigation against any entity (including a
    -  cross-claim or counterclaim in a lawsuit) alleging that the Work
    -  or a Contribution incorporated within the Work constitutes direct
    -  or contributory patent infringement, then any patent licenses
    -  granted to You under this License for that Work shall terminate
    -  as of the date such litigation is filed.
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
     
     4. Redistribution. You may reproduce and distribute copies of the
    -  Work or Derivative Works thereof in any medium, with or without
    -  modifications, and in Source or Object form, provided that You
    -  meet the following conditions:
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
     
    -  (a) You must give any other recipients of the Work or
    -      Derivative Works a copy of this License; and
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
     
    -  (b) You must cause any modified files to carry prominent notices
    -      stating that You changed the files; and
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
     
    -  (c) You must retain, in the Source form of any Derivative Works
    -      that You distribute, all copyright, patent, trademark, and
    -      attribution notices from the Source form of the Work,
    -      excluding those notices that do not pertain to any part of
    -      the Derivative Works; and
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
     
    -  (d) If the Work includes a "NOTICE" text file as part of its
    -      distribution, then any Derivative Works that You distribute must
    -      include a readable copy of the attribution notices contained
    -      within such NOTICE file, excluding those notices that do not
    -      pertain to any part of the Derivative Works, in at least one
    -      of the following places: within a NOTICE text file distributed
    -      as part of the Derivative Works; within the Source form or
    -      documentation, if provided along with the Derivative Works; or,
    -      within a display generated by the Derivative Works, if and
    -      wherever such third-party notices normally appear. The contents
    -      of the NOTICE file are for informational purposes only and
    -      do not modify the License. You may add Your own attribution
    -      notices within Derivative Works that You distribute, alongside
    -      or as an addendum to the NOTICE text from the Work, provided
    -      that such additional attribution notices cannot be construed
    -      as modifying the License.
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
     
    -  You may add Your own copyright statement to Your modifications and
    -  may provide additional or different license terms and conditions
    -  for use, reproduction, or distribution of Your modifications, or
    -  for any such Derivative Works as a whole, provided Your use,
    -  reproduction, and distribution of the Work otherwise complies with
    -  the conditions stated in this License.
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
     
     5. Submission of Contributions. Unless You explicitly state otherwise,
    -  any Contribution intentionally submitted for inclusion in the Work
    -  by You to the Licensor shall be under the terms and conditions of
    -  this License, without any additional terms or conditions.
    -  Notwithstanding the above, nothing herein shall supersede or modify
    -  the terms of any separate license agreement you may have executed
    -  with Licensor regarding such Contributions.
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
     
     6. Trademarks. This License does not grant permission to use the trade
    -  names, trademarks, service marks, or product names of the Licensor,
    -  except as required for reasonable and customary use in describing the
    -  origin of the Work and reproducing the content of the NOTICE file.
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
     
     7. Disclaimer of Warranty. Unless required by applicable law or
    -  agreed to in writing, Licensor provides the Work (and each
    -  Contributor provides its Contributions) on an "AS IS" BASIS,
    -  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    -  implied, including, without limitation, any warranties or conditions
    -  of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    -  PARTICULAR PURPOSE. You are solely responsible for determining the
    -  appropriateness of using or redistributing the Work and assume any
    -  risks associated with Your exercise of permissions under this License.
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
     
     8. Limitation of Liability. In no event and under no legal theory,
    -  whether in tort (including negligence), contract, or otherwise,
    -  unless required by applicable law (such as deliberate and grossly
    -  negligent acts) or agreed to in writing, shall any Contributor be
    -  liable to You for damages, including any direct, indirect, special,
    -  incidental, or consequential damages of any character arising as a
    -  result of this License or out of the use or inability to use the
    -  Work (including but not limited to damages for loss of goodwill,
    -  work stoppage, computer failure or malfunction, or any and all
    -  other commercial damages or losses), even if such Contributor
    -  has been advised of the possibility of such damages.
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
     
     9. Accepting Warranty or Additional Liability. While redistributing
    -  the Work or Derivative Works thereof, You may choose to offer,
    -  and charge a fee for, acceptance of support, warranty, indemnity,
    -  or other liability obligations and/or rights consistent with this
    -  License. However, in accepting such obligations, You may act only
    -  on Your own behalf and on Your sole responsibility, not on behalf
    -  of any other Contributor, and only if You agree to indemnify,
    -  defend, and hold each Contributor harmless for any liability
    -  incurred by, or claims asserted against, such Contributor by reason
    -  of your accepting any such warranty or additional liability.
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
     
     END OF TERMS AND CONDITIONS
     
     APPENDIX: How to apply the Apache License to your work.
     
    -  To apply the Apache License to your work, attach the following
    -  boilerplate notice, with the fields enclosed by brackets "[]"
    -  replaced with your own identifying information. (Don't include
    -  the brackets!)  The text should be enclosed in the appropriate
    -  comment syntax for the file format. We also recommend that a
    -  file or class name and description of purpose be included on the
    -  same "printed page" as the copyright notice for easier
    -  identification within third-party archives.
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
     
    -Copyright [yyyy] [name of copyright owner]
    +Copyright 2017 http-rs authors
     
     Licensed under the Apache License, Version 2.0 (the "License");
     you may not use this file except in compliance with the License.
     You may obtain a copy of the License at
     
    -   http://www.apache.org/licenses/LICENSE-2.0
    +	http://www.apache.org/licenses/LICENSE-2.0
     
     Unless required by applicable law or agreed to in writing, software
     distributed under the License is distributed on an "AS IS" BASIS,
    @@ -5266,8 +8318,7 @@ 

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -5443,44 +8494,40 @@ 

    Used by:

    defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2017 quininer kel + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License.
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -5658,21 +8705,38 @@ 

    Used by:

    of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2018 The pin-utils authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License.
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -5862,14 +8926,13 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright (c) 2016 Alex Crichton -Copyright (c) 2017 The Tokio Authors +Copyright 2019 The CryptoCorrosion Contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -5882,7 +8945,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -6072,7 +9135,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2014 Paho Lurie-Gregg +Copyright 2019 quininer kel Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -6084,13 +9147,14 @@

    Used by:

    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License.
    +limitations under the License. +
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -6280,13 +9344,13 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2016 Sean McArthur +Copyright 2019-2020 CreepySkeleton <creepy-skeleton@yandex.ru> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -6299,7 +9363,8 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -6489,7 +9554,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 Sergio Benitez +Copyright 2020 Andrew Straw Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -6508,7 +9573,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -6698,8 +9763,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 Sergio Benitez -Copyright 2014 Alex Chricton +Copyright 2023 Dirkjan Ochtman Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -6718,8 +9782,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -6909,7 +9972,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 http-rs authors +Copyright 2023 Notify Contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -6928,7 +9991,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -7118,7 +10181,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 quininer kel +Copyright [2016] [rust-uname Developers] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -7137,7 +10200,10 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -7327,7 +10393,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2018 The pin-utils authors +Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -7339,14 +10405,191 @@

    Used by:

    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License. -
    +limitations under the License.
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -7536,13 +10779,13 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2019 The CryptoCorrosion Contributors +Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -7555,7 +10798,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -7745,27 +10988,28 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2020 John Foley +Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License. - -
    +limitations under the License.
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -7955,13 +11199,13 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright [2016] [rust-uname Developers] +Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -7974,7 +11218,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -8141,222 +11385,56 @@ 

    Used by:

    has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License.
    -
  • -
  • -

    Apache License 2.0

    -

    Used by:

    -
  • + +
  • +

    Apache License 2.0

    +

    Used by:

    +
                                  Apache License
                             Version 2.0, January 2004
    @@ -8552,7 +11630,7 @@ 

    Used by:

    you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -8565,12 +11643,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -8754,7 +11827,7 @@ 

    Used by:

    To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate + the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier @@ -8779,17 +11852,14 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    -                     http://www.apache.org/licenses/
    +                     https://www.apache.org/licenses/
     
     TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
     
    @@ -8963,42 +12033,18 @@ 

    Used by:

    of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License.
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    -                     http://www.apache.org/licenses/
    +                     https://www.apache.org/licenses/
     
     TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
     
    @@ -9178,32 +12224,25 @@ 

    Used by:

    To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate + the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License.
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -9381,23 +12420,46 @@ 

    Used by:

    of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License.
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    -                     https://www.apache.org/licenses/
    +                     https://www.apache.org/licenses/LICENSE-2.0
     
     TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
     
    @@ -9602,205 +12664,203 @@ 

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    -                     https://www.apache.org/licenses/LICENSE-2.0
    +                    http://www.apache.org/licenses/
     
     TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
     
     1. Definitions.
     
    -   "License" shall mean the terms and conditions for use, reproduction,
    -   and distribution as defined by Sections 1 through 9 of this document.
    +  "License" shall mean the terms and conditions for use, reproduction,
    +  and distribution as defined by Sections 1 through 9 of this document.
     
    -   "Licensor" shall mean the copyright owner or entity authorized by
    -   the copyright owner that is granting the License.
    +  "Licensor" shall mean the copyright owner or entity authorized by
    +  the copyright owner that is granting the License.
     
    -   "Legal Entity" shall mean the union of the acting entity and all
    -   other entities that control, are controlled by, or are under common
    -   control with that entity. For the purposes of this definition,
    -   "control" means (i) the power, direct or indirect, to cause the
    -   direction or management of such entity, whether by contract or
    -   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    -   outstanding shares, or (iii) beneficial ownership of such entity.
    +  "Legal Entity" shall mean the union of the acting entity and all
    +  other entities that control, are controlled by, or are under common
    +  control with that entity. For the purposes of this definition,
    +  "control" means (i) the power, direct or indirect, to cause the
    +  direction or management of such entity, whether by contract or
    +  otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +  outstanding shares, or (iii) beneficial ownership of such entity.
     
    -   "You" (or "Your") shall mean an individual or Legal Entity
    -   exercising permissions granted by this License.
    +  "You" (or "Your") shall mean an individual or Legal Entity
    +  exercising permissions granted by this License.
     
    -   "Source" form shall mean the preferred form for making modifications,
    -   including but not limited to software source code, documentation
    -   source, and configuration files.
    +  "Source" form shall mean the preferred form for making modifications,
    +  including but not limited to software source code, documentation
    +  source, and configuration files.
     
    -   "Object" form shall mean any form resulting from mechanical
    -   transformation or translation of a Source form, including but
    -   not limited to compiled object code, generated documentation,
    -   and conversions to other media types.
    +  "Object" form shall mean any form resulting from mechanical
    +  transformation or translation of a Source form, including but
    +  not limited to compiled object code, generated documentation,
    +  and conversions to other media types.
     
    -   "Work" shall mean the work of authorship, whether in Source or
    -   Object form, made available under the License, as indicated by a
    -   copyright notice that is included in or attached to the work
    -   (an example is provided in the Appendix below).
    +  "Work" shall mean the work of authorship, whether in Source or
    +  Object form, made available under the License, as indicated by a
    +  copyright notice that is included in or attached to the work
    +  (an example is provided in the Appendix below).
     
    -   "Derivative Works" shall mean any work, whether in Source or Object
    -   form, that is based on (or derived from) the Work and for which the
    -   editorial revisions, annotations, elaborations, or other modifications
    -   represent, as a whole, an original work of authorship. For the purposes
    -   of this License, Derivative Works shall not include works that remain
    -   separable from, or merely link (or bind by name) to the interfaces of,
    -   the Work and Derivative Works thereof.
    +  "Derivative Works" shall mean any work, whether in Source or Object
    +  form, that is based on (or derived from) the Work and for which the
    +  editorial revisions, annotations, elaborations, or other modifications
    +  represent, as a whole, an original work of authorship. For the purposes
    +  of this License, Derivative Works shall not include works that remain
    +  separable from, or merely link (or bind by name) to the interfaces of,
    +  the Work and Derivative Works thereof.
     
    -   "Contribution" shall mean any work of authorship, including
    -   the original version of the Work and any modifications or additions
    -   to that Work or Derivative Works thereof, that is intentionally
    -   submitted to Licensor for inclusion in the Work by the copyright owner
    -   or by an individual or Legal Entity authorized to submit on behalf of
    -   the copyright owner. For the purposes of this definition, "submitted"
    -   means any form of electronic, verbal, or written communication sent
    -   to the Licensor or its representatives, including but not limited to
    -   communication on electronic mailing lists, source code control systems,
    -   and issue tracking systems that are managed by, or on behalf of, the
    -   Licensor for the purpose of discussing and improving the Work, but
    -   excluding communication that is conspicuously marked or otherwise
    -   designated in writing by the copyright owner as "Not a Contribution."
    +  "Contribution" shall mean any work of authorship, including
    +  the original version of the Work and any modifications or additions
    +  to that Work or Derivative Works thereof, that is intentionally
    +  submitted to Licensor for inclusion in the Work by the copyright owner
    +  or by an individual or Legal Entity authorized to submit on behalf of
    +  the copyright owner. For the purposes of this definition, "submitted"
    +  means any form of electronic, verbal, or written communication sent
    +  to the Licensor or its representatives, including but not limited to
    +  communication on electronic mailing lists, source code control systems,
    +  and issue tracking systems that are managed by, or on behalf of, the
    +  Licensor for the purpose of discussing and improving the Work, but
    +  excluding communication that is conspicuously marked or otherwise
    +  designated in writing by the copyright owner as "Not a Contribution."
     
    -   "Contributor" shall mean Licensor and any individual or Legal Entity
    -   on behalf of whom a Contribution has been received by Licensor and
    -   subsequently incorporated within the Work.
    +  "Contributor" shall mean Licensor and any individual or Legal Entity
    +  on behalf of whom a Contribution has been received by Licensor and
    +  subsequently incorporated within the Work.
     
     2. Grant of Copyright License. Subject to the terms and conditions of
    -   this License, each Contributor hereby grants to You a perpetual,
    -   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    -   copyright license to reproduce, prepare Derivative Works of,
    -   publicly display, publicly perform, sublicense, and distribute the
    -   Work and such Derivative Works in Source or Object form.
    +  this License, each Contributor hereby grants to You a perpetual,
    +  worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +  copyright license to reproduce, prepare Derivative Works of,
    +  publicly display, publicly perform, sublicense, and distribute the
    +  Work and such Derivative Works in Source or Object form.
     
     3. Grant of Patent License. Subject to the terms and conditions of
    -   this License, each Contributor hereby grants to You a perpetual,
    -   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    -   (except as stated in this section) patent license to make, have made,
    -   use, offer to sell, sell, import, and otherwise transfer the Work,
    -   where such license applies only to those patent claims licensable
    -   by such Contributor that are necessarily infringed by their
    -   Contribution(s) alone or by combination of their Contribution(s)
    -   with the Work to which such Contribution(s) was submitted. If You
    -   institute patent litigation against any entity (including a
    -   cross-claim or counterclaim in a lawsuit) alleging that the Work
    -   or a Contribution incorporated within the Work constitutes direct
    -   or contributory patent infringement, then any patent licenses
    -   granted to You under this License for that Work shall terminate
    -   as of the date such litigation is filed.
    +  this License, each Contributor hereby grants to You a perpetual,
    +  worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +  (except as stated in this section) patent license to make, have made,
    +  use, offer to sell, sell, import, and otherwise transfer the Work,
    +  where such license applies only to those patent claims licensable
    +  by such Contributor that are necessarily infringed by their
    +  Contribution(s) alone or by combination of their Contribution(s)
    +  with the Work to which such Contribution(s) was submitted. If You
    +  institute patent litigation against any entity (including a
    +  cross-claim or counterclaim in a lawsuit) alleging that the Work
    +  or a Contribution incorporated within the Work constitutes direct
    +  or contributory patent infringement, then any patent licenses
    +  granted to You under this License for that Work shall terminate
    +  as of the date such litigation is filed.
     
     4. Redistribution. You may reproduce and distribute copies of the
    -   Work or Derivative Works thereof in any medium, with or without
    -   modifications, and in Source or Object form, provided that You
    -   meet the following conditions:
    +  Work or Derivative Works thereof in any medium, with or without
    +  modifications, and in Source or Object form, provided that You
    +  meet the following conditions:
     
    -   (a) You must give any other recipients of the Work or
    -       Derivative Works a copy of this License; and
    +  (a) You must give any other recipients of the Work or
    +      Derivative Works a copy of this License; and
     
    -   (b) You must cause any modified files to carry prominent notices
    -       stating that You changed the files; and
    +  (b) You must cause any modified files to carry prominent notices
    +      stating that You changed the files; and
     
    -   (c) You must retain, in the Source form of any Derivative Works
    -       that You distribute, all copyright, patent, trademark, and
    -       attribution notices from the Source form of the Work,
    -       excluding those notices that do not pertain to any part of
    -       the Derivative Works; and
    +  (c) You must retain, in the Source form of any Derivative Works
    +      that You distribute, all copyright, patent, trademark, and
    +      attribution notices from the Source form of the Work,
    +      excluding those notices that do not pertain to any part of
    +      the Derivative Works; and
     
    -   (d) If the Work includes a "NOTICE" text file as part of its
    -       distribution, then any Derivative Works that You distribute must
    -       include a readable copy of the attribution notices contained
    -       within such NOTICE file, excluding those notices that do not
    -       pertain to any part of the Derivative Works, in at least one
    -       of the following places: within a NOTICE text file distributed
    -       as part of the Derivative Works; within the Source form or
    -       documentation, if provided along with the Derivative Works; or,
    -       within a display generated by the Derivative Works, if and
    -       wherever such third-party notices normally appear. The contents
    -       of the NOTICE file are for informational purposes only and
    -       do not modify the License. You may add Your own attribution
    -       notices within Derivative Works that You distribute, alongside
    -       or as an addendum to the NOTICE text from the Work, provided
    -       that such additional attribution notices cannot be construed
    -       as modifying the License.
    +  (d) If the Work includes a "NOTICE" text file as part of its
    +      distribution, then any Derivative Works that You distribute must
    +      include a readable copy of the attribution notices contained
    +      within such NOTICE file, excluding those notices that do not
    +      pertain to any part of the Derivative Works, in at least one
    +      of the following places: within a NOTICE text file distributed
    +      as part of the Derivative Works; within the Source form or
    +      documentation, if provided along with the Derivative Works; or,
    +      within a display generated by the Derivative Works, if and
    +      wherever such third-party notices normally appear. The contents
    +      of the NOTICE file are for informational purposes only and
    +      do not modify the License. You may add Your own attribution
    +      notices within Derivative Works that You distribute, alongside
    +      or as an addendum to the NOTICE text from the Work, provided
    +      that such additional attribution notices cannot be construed
    +      as modifying the License.
     
    -   You may add Your own copyright statement to Your modifications and
    -   may provide additional or different license terms and conditions
    -   for use, reproduction, or distribution of Your modifications, or
    -   for any such Derivative Works as a whole, provided Your use,
    -   reproduction, and distribution of the Work otherwise complies with
    -   the conditions stated in this License.
    +  You may add Your own copyright statement to Your modifications and
    +  may provide additional or different license terms and conditions
    +  for use, reproduction, or distribution of Your modifications, or
    +  for any such Derivative Works as a whole, provided Your use,
    +  reproduction, and distribution of the Work otherwise complies with
    +  the conditions stated in this License.
     
     5. Submission of Contributions. Unless You explicitly state otherwise,
    -   any Contribution intentionally submitted for inclusion in the Work
    -   by You to the Licensor shall be under the terms and conditions of
    -   this License, without any additional terms or conditions.
    -   Notwithstanding the above, nothing herein shall supersede or modify
    -   the terms of any separate license agreement you may have executed
    -   with Licensor regarding such Contributions.
    +  any Contribution intentionally submitted for inclusion in the Work
    +  by You to the Licensor shall be under the terms and conditions of
    +  this License, without any additional terms or conditions.
    +  Notwithstanding the above, nothing herein shall supersede or modify
    +  the terms of any separate license agreement you may have executed
    +  with Licensor regarding such Contributions.
     
     6. Trademarks. This License does not grant permission to use the trade
    -   names, trademarks, service marks, or product names of the Licensor,
    -   except as required for reasonable and customary use in describing the
    -   origin of the Work and reproducing the content of the NOTICE file.
    +  names, trademarks, service marks, or product names of the Licensor,
    +  except as required for reasonable and customary use in describing the
    +  origin of the Work and reproducing the content of the NOTICE file.
     
     7. Disclaimer of Warranty. Unless required by applicable law or
    -   agreed to in writing, Licensor provides the Work (and each
    -   Contributor provides its Contributions) on an "AS IS" BASIS,
    -   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    -   implied, including, without limitation, any warranties or conditions
    -   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    -   PARTICULAR PURPOSE. You are solely responsible for determining the
    -   appropriateness of using or redistributing the Work and assume any
    -   risks associated with Your exercise of permissions under this License.
    +  agreed to in writing, Licensor provides the Work (and each
    +  Contributor provides its Contributions) on an "AS IS" BASIS,
    +  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +  implied, including, without limitation, any warranties or conditions
    +  of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +  PARTICULAR PURPOSE. You are solely responsible for determining the
    +  appropriateness of using or redistributing the Work and assume any
    +  risks associated with Your exercise of permissions under this License.
     
     8. Limitation of Liability. In no event and under no legal theory,
    -   whether in tort (including negligence), contract, or otherwise,
    -   unless required by applicable law (such as deliberate and grossly
    -   negligent acts) or agreed to in writing, shall any Contributor be
    -   liable to You for damages, including any direct, indirect, special,
    -   incidental, or consequential damages of any character arising as a
    -   result of this License or out of the use or inability to use the
    -   Work (including but not limited to damages for loss of goodwill,
    -   work stoppage, computer failure or malfunction, or any and all
    -   other commercial damages or losses), even if such Contributor
    -   has been advised of the possibility of such damages.
    +  whether in tort (including negligence), contract, or otherwise,
    +  unless required by applicable law (such as deliberate and grossly
    +  negligent acts) or agreed to in writing, shall any Contributor be
    +  liable to You for damages, including any direct, indirect, special,
    +  incidental, or consequential damages of any character arising as a
    +  result of this License or out of the use or inability to use the
    +  Work (including but not limited to damages for loss of goodwill,
    +  work stoppage, computer failure or malfunction, or any and all
    +  other commercial damages or losses), even if such Contributor
    +  has been advised of the possibility of such damages.
     
     9. Accepting Warranty or Additional Liability. While redistributing
    -   the Work or Derivative Works thereof, You may choose to offer,
    -   and charge a fee for, acceptance of support, warranty, indemnity,
    -   or other liability obligations and/or rights consistent with this
    -   License. However, in accepting such obligations, You may act only
    -   on Your own behalf and on Your sole responsibility, not on behalf
    -   of any other Contributor, and only if You agree to indemnify,
    -   defend, and hold each Contributor harmless for any liability
    -   incurred by, or claims asserted against, such Contributor by reason
    -   of your accepting any such warranty or additional liability.
    +  the Work or Derivative Works thereof, You may choose to offer,
    +  and charge a fee for, acceptance of support, warranty, indemnity,
    +  or other liability obligations and/or rights consistent with this
    +  License. However, in accepting such obligations, You may act only
    +  on Your own behalf and on Your sole responsibility, not on behalf
    +  of any other Contributor, and only if You agree to indemnify,
    +  defend, and hold each Contributor harmless for any liability
    +  incurred by, or claims asserted against, such Contributor by reason
    +  of your accepting any such warranty or additional liability.
     
     END OF TERMS AND CONDITIONS
     
     APPENDIX: How to apply the Apache License to your work.
     
    -   To apply the Apache License to your work, attach the following
    -   boilerplate notice, with the fields enclosed by brackets "[]"
    -   replaced with your own identifying information. (Don't include
    -   the brackets!)  The text should be enclosed in the appropriate
    -   comment syntax for the file format. We also recommend that a
    -   file or class name and description of purpose be included on the
    -   same "printed page" as the copyright notice for easier
    -   identification within third-party archives.
    +  To apply the Apache License to your work, attach the following
    +  boilerplate notice, with the fields enclosed by brackets "[]"
    +  replaced with your own identifying information. (Don't include
    +  the brackets!)  The text should be enclosed in the appropriate
    +  comment syntax for the file format. We also recommend that a
    +  file or class name and description of purpose be included on the
    +  same "printed page" as the copyright notice for easier
    +  identification within third-party archives.
     
    -Copyright [yyyy] [name of copyright owner]
    +Copyright (c) The ORAS Authors
     
     Licensed under the Apache License, Version 2.0 (the "License");
     you may not use this file except in compliance with the License.
     You may obtain a copy of the License at
     
    -	https://www.apache.org/licenses/LICENSE-2.0
    +    http://www.apache.org/licenses/LICENSE-2.0
     
     Unless required by applicable law or agreed to in writing, software
     distributed under the License is distributed on an "AS IS" BASIS,
    @@ -9814,7 +12874,6 @@ 

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -9998,7 +13057,7 @@ 

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -10188,26 +13247,26 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright [yyyy] [name of copyright owner] +Copyright 2019-2020 CreepySkeleton <creepy-skeleton@yandex.ru> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License.
    +limitations under the License. +
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -10409,14 +13468,14 @@ 

    Used by:

    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License. -
    +limitations under the License.
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -10619,16 +13678,60 @@ 

    Used by:

    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -
  • Apache License 2.0

    Used by:

    +
    # Contributing
    +
    +## License
    +
    +Licensed under either of
    +
    + * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
    + * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
    +
    +at your option.
    +
    +### Contribution
    +
    +Unless you explicitly state otherwise, any contribution intentionally submitted
    +for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any
    +additional terms or conditions.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    ../../LICENSE-APACHE
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    // Licensed under the Apache License, Version 2.0
    +// <LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
    +// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
    +// All files in the project carrying such notice may not be copied, modified, or distributed
    +// except according to those terms.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + -
      Apache License
    +                
    Apache License
                                Version 2.0, January 2004
                             http://www.apache.org/licenses/
     
    @@ -10816,7 +13919,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -10835,53 +13938,9 @@

    Used by:

    Apache License 2.0

    Used by:

    -
    # Contributing
    -
    -## License
    -
    -Licensed under either of
    -
    - * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
    - * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
    -
    -at your option.
    -
    -### Contribution
    -
    -Unless you explicitly state otherwise, any contribution intentionally submitted
    -for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any
    -additional terms or conditions.
    -
    -
  • -
  • -

    Apache License 2.0

    -

    Used by:

    - -
    ../../LICENSE-APACHE
    -
  • -
  • -

    Apache License 2.0

    -

    Used by:

    - -
    // Licensed under the Apache License, Version 2.0
    -// <LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
    -// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
    -// All files in the project carrying such notice may not be copied, modified, or distributed
    -// except according to those terms.
    -
    -
  • -
  • -

    Apache License 2.0

    -

    Used by:

    -
    Apache License
                                Version 2.0, January 2004
    @@ -11071,232 +14130,442 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2019 Josh Mcguigan
     
    -   Licensed under the Apache License, Version 2.0 (the "License");
    -   you may not use this file except in compliance with the License.
    -   You may obtain a copy of the License at
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
     
    -       http://www.apache.org/licenses/LICENSE-2.0
    +	http://www.apache.org/licenses/LICENSE-2.0
     
    -   Unless required by applicable law or agreed to in writing, software
    -   distributed under the License is distributed on an "AS IS" BASIS,
    -   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    -   See the License for the specific language governing permissions and
    -   limitations under the License.
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
     
  • Apache License 2.0

    Used by:

    Apache License
    -                           Version 2.0, January 2004
    -                        http://www.apache.org/licenses/
    -
    -   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    -
    -   1. Definitions.
    +Version 2.0, January 2004
    +http://www.apache.org/licenses/
     
    -      "License" shall mean the terms and conditions for use, reproduction,
    -      and distribution as defined by Sections 1 through 9 of this document.
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
     
    -      "Licensor" shall mean the copyright owner or entity authorized by
    -      the copyright owner that is granting the License.
    +1. Definitions.
     
    -      "Legal Entity" shall mean the union of the acting entity and all
    -      other entities that control, are controlled by, or are under common
    -      control with that entity. For the purposes of this definition,
    -      "control" means (i) the power, direct or indirect, to cause the
    -      direction or management of such entity, whether by contract or
    -      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    -      outstanding shares, or (iii) beneficial ownership of such entity.
    +"License" shall mean the terms and conditions for use, reproduction,
    +and distribution as defined by Sections 1 through 9 of this document.
     
    -      "You" (or "Your") shall mean an individual or Legal Entity
    -      exercising permissions granted by this License.
    +"Licensor" shall mean the copyright owner or entity authorized by
    +the copyright owner that is granting the License.
     
    -      "Source" form shall mean the preferred form for making modifications,
    -      including but not limited to software source code, documentation
    -      source, and configuration files.
    +"Legal Entity" shall mean the union of the acting entity and all
    +other entities that control, are controlled by, or are under common
    +control with that entity. For the purposes of this definition,
    +"control" means (i) the power, direct or indirect, to cause the
    +direction or management of such entity, whether by contract or
    +otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +outstanding shares, or (iii) beneficial ownership of such entity.
     
    -      "Object" form shall mean any form resulting from mechanical
    -      transformation or translation of a Source form, including but
    -      not limited to compiled object code, generated documentation,
    -      and conversions to other media types.
    +"You" (or "Your") shall mean an individual or Legal Entity
    +exercising permissions granted by this License.
     
    -      "Work" shall mean the work of authorship, whether in Source or
    -      Object form, made available under the License, as indicated by a
    -      copyright notice that is included in or attached to the work
    -      (an example is provided in the Appendix below).
    +"Source" form shall mean the preferred form for making modifications,
    +including but not limited to software source code, documentation
    +source, and configuration files.
     
    -      "Derivative Works" shall mean any work, whether in Source or Object
    -      form, that is based on (or derived from) the Work and for which the
    -      editorial revisions, annotations, elaborations, or other modifications
    -      represent, as a whole, an original work of authorship. For the purposes
    -      of this License, Derivative Works shall not include works that remain
    -      separable from, or merely link (or bind by name) to the interfaces of,
    -      the Work and Derivative Works thereof.
    +"Object" form shall mean any form resulting from mechanical
    +transformation or translation of a Source form, including but
    +not limited to compiled object code, generated documentation,
    +and conversions to other media types.
     
    -      "Contribution" shall mean any work of authorship, including
    -      the original version of the Work and any modifications or additions
    -      to that Work or Derivative Works thereof, that is intentionally
    -      submitted to Licensor for inclusion in the Work by the copyright owner
    -      or by an individual or Legal Entity authorized to submit on behalf of
    -      the copyright owner. For the purposes of this definition, "submitted"
    -      means any form of electronic, verbal, or written communication sent
    -      to the Licensor or its representatives, including but not limited to
    -      communication on electronic mailing lists, source code control systems,
    -      and issue tracking systems that are managed by, or on behalf of, the
    -      Licensor for the purpose of discussing and improving the Work, but
    -      excluding communication that is conspicuously marked or otherwise
    -      designated in writing by the copyright owner as "Not a Contribution."
    +"Work" shall mean the work of authorship, whether in Source or
    +Object form, made available under the License, as indicated by a
    +copyright notice that is included in or attached to the work
    +(an example is provided in the Appendix below).
     
    -      "Contributor" shall mean Licensor and any individual or Legal Entity
    -      on behalf of whom a Contribution has been received by Licensor and
    -      subsequently incorporated within the Work.
    +"Derivative Works" shall mean any work, whether in Source or Object
    +form, that is based on (or derived from) the Work and for which the
    +editorial revisions, annotations, elaborations, or other modifications
    +represent, as a whole, an original work of authorship. For the purposes
    +of this License, Derivative Works shall not include works that remain
    +separable from, or merely link (or bind by name) to the interfaces of,
    +the Work and Derivative Works thereof.
     
    -   2. Grant of Copyright License. Subject to the terms and conditions of
    -      this License, each Contributor hereby grants to You a perpetual,
    -      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    -      copyright license to reproduce, prepare Derivative Works of,
    -      publicly display, publicly perform, sublicense, and distribute the
    -      Work and such Derivative Works in Source or Object form.
    +"Contribution" shall mean any work of authorship, including
    +the original version of the Work and any modifications or additions
    +to that Work or Derivative Works thereof, that is intentionally
    +submitted to Licensor for inclusion in the Work by the copyright owner
    +or by an individual or Legal Entity authorized to submit on behalf of
    +the copyright owner. For the purposes of this definition, "submitted"
    +means any form of electronic, verbal, or written communication sent
    +to the Licensor or its representatives, including but not limited to
    +communication on electronic mailing lists, source code control systems,
    +and issue tracking systems that are managed by, or on behalf of, the
    +Licensor for the purpose of discussing and improving the Work, but
    +excluding communication that is conspicuously marked or otherwise
    +designated in writing by the copyright owner as "Not a Contribution."
     
    -   3. Grant of Patent License. Subject to the terms and conditions of
    -      this License, each Contributor hereby grants to You a perpetual,
    -      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    -      (except as stated in this section) patent license to make, have made,
    -      use, offer to sell, sell, import, and otherwise transfer the Work,
    -      where such license applies only to those patent claims licensable
    -      by such Contributor that are necessarily infringed by their
    -      Contribution(s) alone or by combination of their Contribution(s)
    -      with the Work to which such Contribution(s) was submitted. If You
    -      institute patent litigation against any entity (including a
    -      cross-claim or counterclaim in a lawsuit) alleging that the Work
    -      or a Contribution incorporated within the Work constitutes direct
    -      or contributory patent infringement, then any patent licenses
    -      granted to You under this License for that Work shall terminate
    -      as of the date such litigation is filed.
    +"Contributor" shall mean Licensor and any individual or Legal Entity
    +on behalf of whom a Contribution has been received by Licensor and
    +subsequently incorporated within the Work.
     
    -   4. Redistribution. You may reproduce and distribute copies of the
    -      Work or Derivative Works thereof in any medium, with or without
    -      modifications, and in Source or Object form, provided that You
    -      meet the following conditions:
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +this License, each Contributor hereby grants to You a perpetual,
    +worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +copyright license to reproduce, prepare Derivative Works of,
    +publicly display, publicly perform, sublicense, and distribute the
    +Work and such Derivative Works in Source or Object form.
     
    -      (a) You must give any other recipients of the Work or
    -          Derivative Works a copy of this License; and
    +3. Grant of Patent License. Subject to the terms and conditions of
    +this License, each Contributor hereby grants to You a perpetual,
    +worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +(except as stated in this section) patent license to make, have made,
    +use, offer to sell, sell, import, and otherwise transfer the Work,
    +where such license applies only to those patent claims licensable
    +by such Contributor that are necessarily infringed by their
    +Contribution(s) alone or by combination of their Contribution(s)
    +with the Work to which such Contribution(s) was submitted. If You
    +institute patent litigation against any entity (including a
    +cross-claim or counterclaim in a lawsuit) alleging that the Work
    +or a Contribution incorporated within the Work constitutes direct
    +or contributory patent infringement, then any patent licenses
    +granted to You under this License for that Work shall terminate
    +as of the date such litigation is filed.
     
    -      (b) You must cause any modified files to carry prominent notices
    -          stating that You changed the files; and
    +4. Redistribution. You may reproduce and distribute copies of the
    +Work or Derivative Works thereof in any medium, with or without
    +modifications, and in Source or Object form, provided that You
    +meet the following conditions:
     
    -      (c) You must retain, in the Source form of any Derivative Works
    -          that You distribute, all copyright, patent, trademark, and
    -          attribution notices from the Source form of the Work,
    -          excluding those notices that do not pertain to any part of
    -          the Derivative Works; and
    +(a) You must give any other recipients of the Work or
    +Derivative Works a copy of this License; and
     
    -      (d) If the Work includes a "NOTICE" text file as part of its
    -          distribution, then any Derivative Works that You distribute must
    -          include a readable copy of the attribution notices contained
    -          within such NOTICE file, excluding those notices that do not
    -          pertain to any part of the Derivative Works, in at least one
    -          of the following places: within a NOTICE text file distributed
    -          as part of the Derivative Works; within the Source form or
    -          documentation, if provided along with the Derivative Works; or,
    -          within a display generated by the Derivative Works, if and
    -          wherever such third-party notices normally appear. The contents
    -          of the NOTICE file are for informational purposes only and
    -          do not modify the License. You may add Your own attribution
    -          notices within Derivative Works that You distribute, alongside
    -          or as an addendum to the NOTICE text from the Work, provided
    -          that such additional attribution notices cannot be construed
    -          as modifying the License.
    +(b) You must cause any modified files to carry prominent notices
    +stating that You changed the files; and
     
    -      You may add Your own copyright statement to Your modifications and
    -      may provide additional or different license terms and conditions
    -      for use, reproduction, or distribution of Your modifications, or
    -      for any such Derivative Works as a whole, provided Your use,
    -      reproduction, and distribution of the Work otherwise complies with
    -      the conditions stated in this License.
    +(c) You must retain, in the Source form of any Derivative Works
    +that You distribute, all copyright, patent, trademark, and
    +attribution notices from the Source form of the Work,
    +excluding those notices that do not pertain to any part of
    +the Derivative Works; and
     
    -   5. Submission of Contributions. Unless You explicitly state otherwise,
    -      any Contribution intentionally submitted for inclusion in the Work
    -      by You to the Licensor shall be under the terms and conditions of
    -      this License, without any additional terms or conditions.
    -      Notwithstanding the above, nothing herein shall supersede or modify
    -      the terms of any separate license agreement you may have executed
    -      with Licensor regarding such Contributions.
    +(d) If the Work includes a "NOTICE" text file as part of its
    +distribution, then any Derivative Works that You distribute must
    +include a readable copy of the attribution notices contained
    +within such NOTICE file, excluding those notices that do not
    +pertain to any part of the Derivative Works, in at least one
    +of the following places: within a NOTICE text file distributed
    +as part of the Derivative Works; within the Source form or
    +documentation, if provided along with the Derivative Works; or,
    +within a display generated by the Derivative Works, if and
    +wherever such third-party notices normally appear. The contents
    +of the NOTICE file are for informational purposes only and
    +do not modify the License. You may add Your own attribution
    +notices within Derivative Works that You distribute, alongside
    +or as an addendum to the NOTICE text from the Work, provided
    +that such additional attribution notices cannot be construed
    +as modifying the License.
     
    -   6. Trademarks. This License does not grant permission to use the trade
    -      names, trademarks, service marks, or product names of the Licensor,
    -      except as required for reasonable and customary use in describing the
    -      origin of the Work and reproducing the content of the NOTICE file.
    +You may add Your own copyright statement to Your modifications and
    +may provide additional or different license terms and conditions
    +for use, reproduction, or distribution of Your modifications, or
    +for any such Derivative Works as a whole, provided Your use,
    +reproduction, and distribution of the Work otherwise complies with
    +the conditions stated in this License.
     
    -   7. Disclaimer of Warranty. Unless required by applicable law or
    -      agreed to in writing, Licensor provides the Work (and each
    -      Contributor provides its Contributions) on an "AS IS" BASIS,
    -      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    -      implied, including, without limitation, any warranties or conditions
    -      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    -      PARTICULAR PURPOSE. You are solely responsible for determining the
    -      appropriateness of using or redistributing the Work and assume any
    -      risks associated with Your exercise of permissions under this License.
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +any Contribution intentionally submitted for inclusion in the Work
    +by You to the Licensor shall be under the terms and conditions of
    +this License, without any additional terms or conditions.
    +Notwithstanding the above, nothing herein shall supersede or modify
    +the terms of any separate license agreement you may have executed
    +with Licensor regarding such Contributions.
     
    -   8. Limitation of Liability. In no event and under no legal theory,
    -      whether in tort (including negligence), contract, or otherwise,
    -      unless required by applicable law (such as deliberate and grossly
    -      negligent acts) or agreed to in writing, shall any Contributor be
    -      liable to You for damages, including any direct, indirect, special,
    -      incidental, or consequential damages of any character arising as a
    -      result of this License or out of the use or inability to use the
    -      Work (including but not limited to damages for loss of goodwill,
    -      work stoppage, computer failure or malfunction, or any and all
    -      other commercial damages or losses), even if such Contributor
    -      has been advised of the possibility of such damages.
    +6. Trademarks. This License does not grant permission to use the trade
    +names, trademarks, service marks, or product names of the Licensor,
    +except as required for reasonable and customary use in describing the
    +origin of the Work and reproducing the content of the NOTICE file.
     
    -   9. Accepting Warranty or Additional Liability. While redistributing
    -      the Work or Derivative Works thereof, You may choose to offer,
    -      and charge a fee for, acceptance of support, warranty, indemnity,
    -      or other liability obligations and/or rights consistent with this
    -      License. However, in accepting such obligations, You may act only
    -      on Your own behalf and on Your sole responsibility, not on behalf
    -      of any other Contributor, and only if You agree to indemnify,
    -      defend, and hold each Contributor harmless for any liability
    -      incurred by, or claims asserted against, such Contributor by reason
    -      of your accepting any such warranty or additional liability.
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +agreed to in writing, Licensor provides the Work (and each
    +Contributor provides its Contributions) on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +implied, including, without limitation, any warranties or conditions
    +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +PARTICULAR PURPOSE. You are solely responsible for determining the
    +appropriateness of using or redistributing the Work and assume any
    +risks associated with Your exercise of permissions under this License.
     
    -   END OF TERMS AND CONDITIONS
    +8. Limitation of Liability. In no event and under no legal theory,
    +whether in tort (including negligence), contract, or otherwise,
    +unless required by applicable law (such as deliberate and grossly
    +negligent acts) or agreed to in writing, shall any Contributor be
    +liable to You for damages, including any direct, indirect, special,
    +incidental, or consequential damages of any character arising as a
    +result of this License or out of the use or inability to use the
    +Work (including but not limited to damages for loss of goodwill,
    +work stoppage, computer failure or malfunction, or any and all
    +other commercial damages or losses), even if such Contributor
    +has been advised of the possibility of such damages.
     
    -   APPENDIX: How to apply the Apache License to your work.
    +9. Accepting Warranty or Additional Liability. While redistributing
    +the Work or Derivative Works thereof, You may choose to offer,
    +and charge a fee for, acceptance of support, warranty, indemnity,
    +or other liability obligations and/or rights consistent with this
    +License. However, in accepting such obligations, You may act only
    +on Your own behalf and on Your sole responsibility, not on behalf
    +of any other Contributor, and only if You agree to indemnify,
    +defend, and hold each Contributor harmless for any liability
    +incurred by, or claims asserted against, such Contributor by reason
    +of your accepting any such warranty or additional liability.
     
    -      To apply the Apache License to your work, attach the following
    -      boilerplate notice, with the fields enclosed by brackets "{}"
    -      replaced with your own identifying information. (Don't include
    -      the brackets!)  The text should be enclosed in the appropriate
    -      comment syntax for the file format. We also recommend that a
    -      file or class name and description of purpose be included on the
    -      same "printed page" as the copyright notice for easier
    -      identification within third-party archives.
    +END OF TERMS AND CONDITIONS
     
    -   Copyright {yyyy} {name of copyright owner}
    +APPENDIX: How to apply the Apache License to your work.
     
    -   Licensed under the Apache License, Version 2.0 (the "License");
    -   you may not use this file except in compliance with the License.
    -   You may obtain a copy of the License at
    +To apply the Apache License to your work, attach the following
    +boilerplate notice, with the fields enclosed by brackets "[]"
    +replaced with your own identifying information. (Don't include
    +the brackets!)  The text should be enclosed in the appropriate
    +comment syntax for the file format. We also recommend that a
    +file or class name and description of purpose be included on the
    +same "printed page" as the copyright notice for easier
    +identification within third-party archives.
     
    -       http://www.apache.org/licenses/LICENSE-2.0
    +Copyright 2020 LaunchBadge, LLC
     
    -   Unless required by applicable law or agreed to in writing, software
    -   distributed under the License is distributed on an "AS IS" BASIS,
    -   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    -   See the License for the specific language governing permissions and
    -   limitations under the License.
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
     
    -
    +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License.
  • Apache License 2.0

    @@ -11511,13 +14780,9 @@

    Used by:

    Apache License 2.0

    Used by:

    Apache License
     Version 2.0, January 2004
    @@ -11614,6 +14872,69 @@ 

    Used by:

    http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    Copyright 2015 Nicholas Allegra (comex).
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +    http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    Copyright 2016 Nicolas Silva
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +    http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    Copyright 2021 Oliver Giersch
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +    http://www.apache.org/licenses/LICENSE-2.0
    +
     Unless required by applicable law or agreed to in writing, software
     distributed under the License is distributed on an "AS IS" BASIS,
     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    @@ -11667,9 +14988,12 @@ 

    Used by:

    Apache License 2.0

    Used by:

    Licensed under the Apache License, Version 2.0
     <LICENSE-APACHE or
    @@ -11705,39 +15029,6 @@ 

    Used by:

  • zstd-sys
  • MIT or Apache-2.0
    -
    -
  • -
  • -

    BSD 2-Clause "Simplified" License

    -

    Used by:

    - -
    BSD 2-Clause License
    -
    -Copyright (c) 2023, Maarten de Vries <maarten@de-vri.es>
    -
    -Redistribution and use in source and binary forms, with or without
    -modification, are permitted provided that the following conditions are met:
    -
    -1. Redistributions of source code must retain the above copyright notice, this
    -   list of conditions and the following disclaimer.
    -
    -2. Redistributions in binary form must reproduce the above copyright notice,
    -   this list of conditions and the following disclaimer in the documentation
    -   and/or other materials provided with the distribution.
    -
    -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
    -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
    -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
    -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
    -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
    -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
    -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
    -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
     
  • @@ -11763,6 +15054,7 @@

    BSD 3-Clause "New" or "Revised" Licens

    Used by:

    BSD 3-Clause License
     
    @@ -11800,9 +15092,6 @@ 

    BSD 3-Clause "New" or "Revised" Licens

    Used by:

    Copyright (c) 2016 Dropbox, Inc.
    @@ -11817,45 +15106,6 @@ 

    Used by:

    3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -
    -
  • -
  • -

    BSD 3-Clause "New" or "Revised" License

    -

    Used by:

    - -
    Copyright (c) 2016 by Armin Ronacher.
    -
    -Some rights reserved.
    -
    -Redistribution and use in source and binary forms, with or without
    -modification, are permitted provided that the following conditions are
    -met:
    -
    -    * Redistributions of source code must retain the above copyright
    -      notice, this list of conditions and the following disclaimer.
    -
    -    * Redistributions in binary form must reproduce the above
    -      copyright notice, this list of conditions and the following
    -      disclaimer in the documentation and/or other materials provided
    -      with the distribution.
    -
    -    * The names of the contributors may not be used to endorse or
    -      promote products derived from this software without specific
    -      prior written permission.
    -
    -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
    -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
    -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
    -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
    -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
    -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
    -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
    -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
    -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
    -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
    -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
     
  • @@ -11935,6 +15185,7 @@

    BSD 3-Clause "New" or "Revised" Licens

    Used by:

    Copyright (c) <year> <owner>. 
     
    @@ -11989,46 +15240,46 @@ 

    Used by:

    -
    Creative Commons CC0 1.0 Universal
    -
    -<<beginOptional;name=ccOptionalIntro>> CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER.  <<endOptional>>
    -
    -Statement of Purpose
    -
    -The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work").
    -
    -Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others.
    -
    -For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights.
    -
    -1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following:
    -
    -     i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work;
    -
    -     ii. moral rights retained by the original author(s) and/or performer(s);
    -
    -     iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work;
    -
    -     iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below;
    -
    -     v. rights protecting the extraction, dissemination, use and reuse of data in a Work;
    -
    -     vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and
    -
    -     vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof.
    -
    -2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose.
    -
    -3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose.
    -
    -4. Limitations and Disclaimers.
    -
    -     a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document.
    -
    -     b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.
    -
    -     c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work.
    -
    +                
    Creative Commons CC0 1.0 Universal
    +
    +<<beginOptional;name=ccOptionalIntro>> CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER.  <<endOptional>>
    +
    +Statement of Purpose
    +
    +The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work").
    +
    +Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others.
    +
    +For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights.
    +
    +1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following:
    +
    +     i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work;
    +
    +     ii. moral rights retained by the original author(s) and/or performer(s);
    +
    +     iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work;
    +
    +     iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below;
    +
    +     v. rights protecting the extraction, dissemination, use and reuse of data in a Work;
    +
    +     vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and
    +
    +     vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof.
    +
    +2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose.
    +
    +3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose.
    +
    +4. Limitations and Disclaimers.
    +
    +     a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document.
    +
    +     b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.
    +
    +     c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work.
    +
          d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. 
  • @@ -12158,6 +15409,75 @@

    Used by:

    d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. + +
  • +
  • +

    Community Data License Agreement Permissive 2.0

    +

    Used by:

    + +
    # Community Data License Agreement - Permissive - Version 2.0
    +
    +This is the Community Data License Agreement - Permissive, Version
    +2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree
    +as follows:
    +
    +## 1. Provision of the Data
    +
    +1.1. A Data Recipient may use, modify, and share the Data made
    +available by Data Provider(s) under this agreement if that Data
    +Recipient follows the terms of this agreement.
    +
    +1.2. This agreement does not impose any restriction on a Data
    +Recipient's use, modification, or sharing of any portions of the
    +Data that are in the public domain or that may be used, modified,
    +or shared under any other legal exception or limitation.
    +
    +## 2. Conditions for Sharing Data
    +
    +2.1. A Data Recipient may share Data, with or without modifications, so
    +long as the Data Recipient makes available the text of this agreement
    +with the shared Data.
    +
    +## 3. No Restrictions on Results
    +
    +3.1. This agreement does not impose any restriction or obligations
    +with respect to the use, modification, or sharing of Results.
    +
    +## 4. No Warranty; Limitation of Liability
    +
    +4.1. All Data Recipients receive the Data subject to the following
    +terms:
    +
    +THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS,
    +WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED
    +INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE,
    +NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
    +
    +NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT,
    +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING
    +WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF
    +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
    +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE DATA OR RESULTS,
    +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
    +
    +## 5. Definitions
    +
    +5.1. "Data" means the material received by a Data Recipient under
    +this agreement.
    +
    +5.2. "Data Provider" means any person who is the source of Data
    +provided under this agreement and in reliance on a Data Recipient's
    +agreement to its terms.
    +
    +5.3. "Data Recipient" means any person who receives Data directly
    +or indirectly from a Data Provider and agrees to the terms of this
    +agreement.
    +
    +5.4. "Results" means any outcome obtained by computational analysis
    +of Data, including for example machine learning models and models'
    +insights.
     
  • @@ -12266,10 +15586,14 @@

    Used by:

    Elastic License 2.0

    Used by:

    Copyright 2021 Apollo Graph, Inc.
     
    +Source code in this repository is covered by (i) the Elastic License 2.0 or (ii) an MIT compatible license, in each case, as designated by a licensing file in a subdirectory or file header. The default throughout the repository is a license under the Elastic License 2.0, unless a file header or a licensing file in a subdirectory specifies another license.
    +
    +--------------------------------------------------------------------------------
    +
     Elastic License 2.0
     
     ## Acceptance
    @@ -12362,22 +15686,17 @@ 

    Used by:

    **trademark** means trademarks, service marks, and similar rights. --------------------------------------------------------------------------------- -
    +--------------------------------------------------------------------------------
  • Elastic License 2.0

    Used by:

    -
    Copyright 2021 Apollo Graph, Inc.
    -
    -Source code in this repository is covered by (i) the Elastic License 2.0 or (ii) an MIT compatible license, in each case, as designated by a licensing file in a subdirectory or file header. The default throughout the repository is a license under the Elastic License 2.0, unless a file header or a licensing file in a subdirectory specifies another license.
    +                
    Elastic License 2.0
     
    ---------------------------------------------------------------------------------
    -
    -Elastic License 2.0
    +URL: https://www.elastic.co/licensing/elastic-license
     
     ## Acceptance
     
    @@ -12468,48 +15787,6 @@ 

    Used by:

    **use** means anything you do with the software requiring one of your licenses. **trademark** means trademarks, service marks, and similar rights. - ---------------------------------------------------------------------------------
    -
  • -
  • -

    ISC License

    -

    Used by:

    - -
       Copyright 2015-2016 Brian Smith.
    -
    -   Permission to use, copy, modify, and/or distribute this software for any
    -   purpose with or without fee is hereby granted, provided that the above
    -   copyright notice and this permission notice appear in all copies.
    -
    -   THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES
    -   WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
    -   MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY
    -   SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
    -   WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
    -   OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
    -   CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
    -
  • -
  • -

    ISC License

    -

    Used by:

    - -
    /* Copyright (c) 2015, Google Inc.
    - *
    - * Permission to use, copy, modify, and/or distribute this software for any
    - * purpose with or without fee is hereby granted, provided that the above
    - * copyright notice and this permission notice appear in all copies.
    - *
    - * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
    - * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
    - * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
    - * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
    - * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
    - * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
    - * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */
     
  • @@ -12593,6 +15870,27 @@

    Used by:

    OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +
  • +
  • +

    ISC License

    +

    Used by:

    + +
    Copyright 2015-2025 Brian Smith.
    +
    +Permission to use, copy, modify, and/or distribute this software for any
    +purpose with or without fee is hereby granted, provided that the above
    +copyright notice and this permission notice appear in all copies.
    +
    +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
    +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
    +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
    +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
    +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
    +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
    +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
     
  • @@ -12616,42 +15914,11 @@

    Used by:

    MIT License

    Used by:

    -
    Copyright (c) 2014 Alex Crichton
    -
    -Permission is hereby granted, free of charge, to any
    -person obtaining a copy of this software and associated
    -documentation files (the "Software"), to deal in the
    -Software without restriction, including without
    -limitation the rights to use, copy, modify, merge,
    -publish, distribute, sublicense, and/or sell copies of
    -the Software, and to permit persons to whom the Software
    -is furnished to do so, subject to the following
    -conditions:
    -
    -The above copyright notice and this permission notice
    -shall be included in all copies or substantial portions
    -of the Software.
    +                
    # The MIT License (MIT)
     
    -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    -DEALINGS IN THE SOFTWARE.
    -
    -
  • -
  • -

    MIT License

    -

    Used by:

    - -
    Copyright (c) 2014 Carl Lerche and other MIO contributors
    +Copyright (c) 2014 Santiago Lapresta and contributors
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -12669,20 +15936,21 @@ 

    Used by:

    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -
    +THE SOFTWARE.
  • MIT License

    Used by:

    -
    Copyright (c) 2014-2019 Geoffroy Couprie
    +                
    (The MIT License)
    +
    +Copyright (c) 2015 Michael Yang <mikkyangg@gmail.com>
     
     Permission is hereby granted, free of charge, to any person obtaining
     a copy of this software and associated documentation files (the
    -"Software"), to deal in the Software without restriction, including
    +'Software'), to deal in the Software without restriction, including
     without limitation the rights to use, copy, modify, merge, publish,
     distribute, sublicense, and/or sell copies of the Software, and to
     permit persons to whom the Software is furnished to do so, subject to
    @@ -12691,23 +15959,49 @@ 

    Used by:

    The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  • MIT License

    Used by:

    +
    Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors.
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + -
    Copyright (c) 2014-2019 Sean McArthur
    +                
    Copyright (c) 2014 Carl Lerche and other MIO contributors
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -12726,7 +16020,34 @@ 

    Used by:

    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2014-2019 Geoffroy Couprie
     
    +Permission is hereby granted, free of charge, to any person obtaining
    +a copy of this software and associated documentation files (the
    +"Software"), to deal in the Software without restriction, including
    +without limitation the rights to use, copy, modify, merge, publish,
    +distribute, sublicense, and/or sell copies of the Software, and to
    +permit persons to whom the Software is furnished to do so, subject to
    +the following conditions:
    +
    +The above copyright notice and this permission notice shall be
    +included in all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
    +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
    +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
    +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
     
  • @@ -12754,6 +16075,35 @@

    Used by:

    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2014-2023 Sean McArthur
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
     
  • @@ -12762,7 +16112,7 @@

    Used by:

    -
    Copyright (c) 2014-2021 Sean McArthur
    +                
    Copyright (c) 2014-2025 Sean McArthur
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -12843,7 +16193,6 @@ 

    MIT License

    Used by:

    Copyright (c) 2015 Jonathan Reem
     
    @@ -12912,34 +16261,6 @@ 

    Used by:

    The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -
    -
  • -
  • -

    MIT License

    -

    Used by:

    - -
    Copyright (c) 2015-2016 the fiat-crypto authors (see
    -https://github.com/mit-plv/fiat-crypto/blob/master/AUTHORS).
    -
    -Permission is hereby granted, free of charge, to any person obtaining a copy
    -of this software and associated documentation files (the "Software"), to deal
    -in the Software without restriction, including without limitation the rights
    -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    -copies of the Software, and to permit persons to whom the Software is
    -furnished to do so, subject to the following conditions:
    -
    -The above copyright notice and this permission notice shall be included in all
    -copies or substantial portions of the Software.
    -
    -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    -SOFTWARE.
     
  • @@ -13007,8 +16328,8 @@

    Used by:

    MIT License

    Used by:

    Copyright (c) 2016 William Orr <will@worrbase.com>
     
    @@ -13035,6 +16356,34 @@ 

    Used by:

    MIT License

    Used by:

    +
    Copyright (c) 2016 arcnmx
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    +
    Copyright (c) 2017 Daniel Abramov
    @@ -13086,39 +16435,12 @@ 

    Used by:

    OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    -
  • -
  • -

    MIT License

    -

    Used by:

    - -
    Copyright (c) 2017 Gilad Naaman
    -
    -Permission is hereby granted, free of charge, to any person obtaining a copy
    -of this software and associated documentation files (the "Software"), to deal
    -in the Software without restriction, including without limitation the rights
    -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    -copies of the Software, and to permit persons to whom the Software is
    -furnished to do so, subject to the following conditions:
    -
    -The above copyright notice and this permission notice shall be included in all
    -copies or substantial portions of the Software.
    -
    -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    -SOFTWARE.
  • MIT License

    Used by:

    Copyright (c) 2017 Redox OS Developers
     
    @@ -13175,6 +16497,34 @@ 

    Used by:

    OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2017-2019 Geoffroy Couprie
    +
    +Permission is hereby granted, free of charge, to any person obtaining
    +a copy of this software and associated documentation files (the
    +"Software"), to deal in the Software without restriction, including
    +without limitation the rights to use, copy, modify, merge, publish,
    +distribute, sublicense, and/or sell copies of the Software, and to
    +permit persons to whom the Software is furnished to do so, subject to
    +the following conditions:
    +
    +The above copyright notice and this permission notice shall be
    +included in all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
    +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
    +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
    +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
     
  • @@ -13304,6 +16654,7 @@

    Used by:

    MIT License

    Used by:

    Copyright (c) 2019 Carl Lerche
    @@ -13337,8 +16688,8 @@ 

    Used by:

    MIT License

    Used by:

    Copyright (c) 2019 Carl Lerche
     
    @@ -13467,7 +16818,6 @@ 

    MIT License

    Used by:

    Copyright (c) 2019 Hyper Contributors
     
    @@ -13526,13 +16876,13 @@ 

    Used by:

    MIT License

    Used by:

    Copyright (c) 2019 Tokio Contributors
     
    @@ -13565,9 +16915,10 @@ 

    Used by:

    MIT License

    Used by:

    Copyright (c) 2019 Tower Contributors
     
    @@ -13600,9 +16951,9 @@ 

    Used by:

    MIT License

    Used by:

    -
    Copyright (c) 2019-2021 Tower Contributors
    +                
    Copyright (c) 2019 axum Contributors
     
     Permission is hereby granted, free of charge, to any
     person obtaining a copy of this software and associated
    @@ -13633,9 +16984,9 @@ 

    Used by:

    MIT License

    Used by:

    -
    Copyright (c) 2019-2024 Sean McArthur & Hyper Contributors
    +                
    Copyright (c) 2019-2021 Tower Contributors
     
     Permission is hereby granted, free of charge, to any
     person obtaining a copy of this software and associated
    @@ -13666,38 +17017,42 @@ 

    Used by:

    MIT License

    Used by:

    -
    Copyright (c) 2020 Lucio Franco
    +                
    Copyright (c) 2019-2024 Sean McArthur & Hyper Contributors
     
    -Permission is hereby granted, free of charge, to any person obtaining a copy
    -of this software and associated documentation files (the "Software"), to deal
    -in the Software without restriction, including without limitation the rights
    -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    -copies of the Software, and to permit persons to whom the Software is
    -furnished to do so, subject to the following conditions:
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
     
    -The above copyright notice and this permission notice shall be included in
    -all copies or substantial portions of the Software.
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
     
    -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    -THE SOFTWARE.
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
     
  • MIT License

    Used by:

    -
    Copyright (c) 2023 Tokio Contributors
    +                
    Copyright (c) 2019-2025 Sean McArthur & Hyper Contributors
     
     Permission is hereby granted, free of charge, to any
     person obtaining a copy of this software and associated
    @@ -13728,16 +17083,123 @@ 

    Used by:

    MIT License

    Used by:

    +
    Copyright (c) 2019-present, Rodrigo Cesar de Freitas Dias
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2020 Lucio Franco
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2023 Sean McArthur
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + -
    Copyright 2021 Alec Embke
    +                
    Copyright 2016 Nika Layzell
     
     Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
     
     The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
     
     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright 2017-2019 Florent Fayolle, Valentin Lorentz
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of
    +this software and associated documentation files (the "Software"), to deal in
    +the Software without restriction, including without limitation the rights to
    +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
    +of the Software, and to permit persons to whom the Software is furnished to do
    +so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
     
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
     
  • @@ -13759,15 +17221,48 @@

    Used by:

    MIT License

    Used by:

    +
    Copyright 2021 axum Contributors
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + -
    Copyright 2023 Alec Embke
    +                
    Copyright 2024 Alec Embke
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright 2024 Alec Embke
     
     Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
     
     The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
     
     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
     
  • @@ -13832,11 +17327,11 @@

    Used by:

    MIT License

    Used by:

    MIT License
     
    -Copyright (c) 2017 Evgeny Safronov
    +Copyright (c) 2017 
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -13861,13 +17356,11 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    -Copyright (c) 2017 Ted Driggs
    +Copyright (c) 2017 Evgeny Safronov
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -13892,11 +17385,11 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    -Copyright (c) 2018 Canop
    +Copyright (c) 2017 Shea Newton
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -13921,11 +17414,13 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    -Copyright (c) 2018 The typed-arena developers
    +Copyright (c) 2017 Ted Driggs
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -13950,11 +17445,11 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    -Copyright (c) 2019 Acrimon
    +Copyright (c) 2018 The typed-arena developers
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -13979,11 +17474,11 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    -Copyright (c) 2019 Bojan
    +Copyright (c) 2019 Acrimon
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -14128,11 +17623,11 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    -Copyright (c) 2019 brunoczim
    +Copyright (c) 2019 jD91mZM2
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -14157,7 +17652,7 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    @@ -14215,11 +17710,11 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    -Copyright (c) 2021 the Deno authors
    +Copyright (c) 2021-2022 Joshua Barretto 
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -14244,11 +17739,12 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    -Copyright (c) 2021-2022 Joshua Barretto 
    +Copyright (c) 2022 Ibraheem Ahmed
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -14273,11 +17769,11 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    -Copyright (c) 2022 Ibraheem Ahmed
    +Copyright (c) 2022 Nugine
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -14295,18 +17791,17 @@ 

    Used by:

    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -
    +SOFTWARE.
  • MIT License

    Used by:

    MIT License
     
    -Copyright (c) 2022 Nugine
    +Copyright (c) 2022 picoHz
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -14324,17 +17819,18 @@ 

    Used by:

    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE.
    +SOFTWARE. +
  • MIT License

    Used by:

    MIT License
     
    -Copyright (c) 2022 picoHz
    +Copyright (c) 2023 4lDO2
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -14359,11 +17855,39 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    -Copyright (c) 2023 4lDO2
    +Copyright (c) 2024 Ben Newman <shape@eloper.dev> and Apollo Graph, Inc.
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of
    +this software and associated documentation files (the "Software"), to deal in
    +the Software without restriction, including without limitation the rights to
    +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software is furnished to do so,
    +subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
    +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
    +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
    +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
    +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2025 rutrum
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -14381,30 +17905,15 @@ 

    Used by:

    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -
    +SOFTWARE.
  • MIT License

    Used by:

    MIT License
    @@ -14422,6 +17931,8 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
    @@ -14502,6 +18013,35 @@ 

    Used by:

    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) [year] [fullname]
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
  • MIT License

    @@ -14530,6 +18070,35 @@

    Used by:

    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2020 Nicholas Fleck
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
  • MIT License

    @@ -14552,8 +18121,8 @@

    Used by:

    MIT License

    Used by:

    Permission is hereby granted, free of charge, to any
     person obtaining a copy of this software and associated
    @@ -14585,7 +18154,6 @@ 

    MIT License

    Used by:

    Permission is hereby granted, free of charge, to any person obtaining
     a copy of this software and associated documentation files (the
    @@ -14672,6 +18240,7 @@ 

    MIT License

    Used by:

    The MIT License (MIT)
     
    @@ -14728,8 +18297,37 @@ 

    Used by:

    MIT License

    Used by:

    +
    The MIT License (MIT)
    +
    +Copyright (c) 2014 Mathijs van de Nes
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +Some files in the "tests/data" subdirectory of this repository are under other
    +licences; see files named LICENSE.*.txt for details.
    +
  • +
  • +

    MIT License

    +

    Used by:

    + @@ -14764,6 +18362,7 @@

    Used by:

  • aho-corasick
  • byteorder
  • globset
  • +
  • jiff
  • memchr
  • regex-automata
  • walkdir
  • @@ -14826,11 +18425,11 @@

    Used by:

    MIT License

    Used by:

    The MIT License (MIT)
     
    -Copyright (c) 2015 Siyu Wang
    +Copyright (c) 2015 Guillaume Gomez
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -14856,11 +18455,41 @@ 

    Used by:

    MIT License

    Used by:

    The MIT License (MIT)
     
    -Copyright (c) 2015 Vincent Prouillet
    +Copyright (c) 2015 Markus Westerlind
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
    + +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015 Siyu Wang
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -14879,18 +18508,18 @@ 

    Used by:

    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +
  • MIT License

    Used by:

    The MIT License (MIT)
     
    -Copyright (c) 2015-2020 Julien Cretin
    -Copyright (c) 2017-2020 Google Inc.
    +Copyright (c) 2015 Vincent Prouillet
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -14915,37 +18544,37 @@ 

    Used by:

    MIT License

    Used by:

    The MIT License (MIT)
     
    -Copyright (c) 2016 Google Inc. (lewinb@google.com) -- though not an official
    -Google product or in any way related!
    -Copyright (c) 2018-2020 Lewin Bormann (lbo@spheniscida.de)
    +Copyright (c) 2015-2020 Julien Cretin
    +Copyright (c) 2017-2020 Google Inc.
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
    -of this software and associated documentation files (the "Software"), to
    -deal in the Software without restriction, including without limitation the
    -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
    -sell copies of the Software, and to permit persons to whom the Software is
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
     furnished to do so, subject to the following conditions:
     
    -The above copyright notice and this permission notice shall be included in
    -all copies or substantial portions of the Software.
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
     
     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
     IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
     FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
     AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
    -IN THE SOFTWARE.
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
     
  • MIT License

    Used by:

    The MIT License (MIT)
    @@ -15034,9 +18663,9 @@ 

    Used by:

    MIT License

    Used by:

    The MIT License (MIT)
     
    @@ -15096,9 +18725,9 @@ 

    Used by:

    MIT License

    Used by:

    The MIT License (MIT)
     
    @@ -15128,11 +18757,11 @@ 

    Used by:

    MIT License

    Used by:

    The MIT License (MIT)
     
    -Copyright (c) 2020 Benjamin Coenen
    +Copyright 2017-2023 Eira Fransham.
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -15141,16 +18770,17 @@ 

    Used by:

    copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE.
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +
  • MIT License

    @@ -15172,11 +18802,11 @@

    Used by:

    MIT License

    Used by:

    The MIT License (MIT)
     
    -Copyright (c) 2015 Bartłomiej Kamiński
    +Copyright (c) 2015 Austin Bonander
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -15194,17 +18824,19 @@ 

    Used by:

    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE.
    +SOFTWARE. + +
  • MIT License

    Used by:

    The MIT License (MIT)
     
    -Copyright (c) 2015 Markus Westerlind
    +Copyright (c) 2015 Bartłomiej Kamiński
     
     Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
    @@ -15213,18 +18845,16 @@ 

    Used by:

    copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.
  • MIT License

    @@ -15233,6 +18863,7 @@

    Used by:

  • aho-corasick
  • byteorder
  • globset
  • +
  • jiff
  • memchr
  • regex-automata
  • same-file
  • @@ -16405,118 +20036,168 @@

    Used by:

    -
    This packge contains a modified version of ca-bundle.crt:
    -
    -ca-bundle.crt -- Bundle of CA Root Certificates
    -
    -Certificate data from Mozilla as of: Thu Nov  3 19:04:19 2011#
    -This is a bundle of X.509 certificates of public Certificate Authorities
    -(CA). These were automatically extracted from Mozilla's root certificates
    -file (certdata.txt).  This file can be found in the mozilla source tree:
    -http://mxr.mozilla.org/mozilla/source/security/nss/lib/ckfw/builtins/certdata.txt?raw=1#
    -It contains the certificates in PEM format and therefore
    -can be directly used with curl / libcurl / php_curl, or with
    -an Apache+mod_ssl webserver for SSL client authentication.
    -Just configure this file as the SSLCACertificateFile.#
    -
    -***** BEGIN LICENSE BLOCK *****
    -This Source Code Form is subject to the terms of the Mozilla Public License,
    -v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain
    -one at http://mozilla.org/MPL/2.0/.
    -
    -***** END LICENSE BLOCK *****
    -@(#) $RCSfile: certdata.txt,v $ $Revision: 1.80 $ $Date: 2011/11/03 15:11:58 $
    +                
    This packge contains a modified version of ca-bundle.crt:	
    +
    +ca-bundle.crt -- Bundle of CA Root Certificates	
    +
    +Certificate data from Mozilla as of: Thu Nov  3 19:04:19 2011#	
    +This is a bundle of X.509 certificates of public Certificate Authorities	
    +(CA). These were automatically extracted from Mozilla's root certificates	
    +file (certdata.txt).  This file can be found in the mozilla source tree:	
    +http://mxr.mozilla.org/mozilla/source/security/nss/lib/ckfw/builtins/certdata.txt?raw=1#	
    +It contains the certificates in PEM format and therefore	
    +can be directly used with curl / libcurl / php_curl, or with	
    +an Apache+mod_ssl webserver for SSL client authentication.	
    +Just configure this file as the SSLCACertificateFile.#	
    +
    +***** BEGIN LICENSE BLOCK *****	
    +This Source Code Form is subject to the terms of the Mozilla Public License,	
    +v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain	
    +one at http://mozilla.org/MPL/2.0/.	
    +
    +***** END LICENSE BLOCK *****	
    +@(#) $RCSfile: certdata.txt,v $ $Revision: 1.80 $ $Date: 2011/11/03 15:11:58 $	
     
  • -

    OpenSSL License

    +

    University of Illinois/NCSA Open Source License

    Used by:

    -
    /* ====================================================================
    - * Copyright (c) 1998-2011 The OpenSSL Project.  All rights reserved.
    - *
    - * Redistribution and use in source and binary forms, with or without
    - * modification, are permitted provided that the following conditions
    - * are met:
    - *
    - * 1. Redistributions of source code must retain the above copyright
    - *    notice, this list of conditions and the following disclaimer. 
    - *
    - * 2. Redistributions in binary form must reproduce the above copyright
    - *    notice, this list of conditions and the following disclaimer in
    - *    the documentation and/or other materials provided with the
    - *    distribution.
    - *
    - * 3. All advertising materials mentioning features or use of this
    - *    software must display the following acknowledgment:
    - *    "This product includes software developed by the OpenSSL Project
    - *    for use in the OpenSSL Toolkit. (http://www.openssl.org/)"
    - *
    - * 4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to
    - *    endorse or promote products derived from this software without
    - *    prior written permission. For written permission, please contact
    - *    openssl-core@openssl.org.
    - *
    - * 5. Products derived from this software may not be called "OpenSSL"
    - *    nor may "OpenSSL" appear in their names without prior written
    - *    permission of the OpenSSL Project.
    - *
    - * 6. Redistributions of any form whatsoever must retain the following
    - *    acknowledgment:
    - *    "This product includes software developed by the OpenSSL Project
    - *    for use in the OpenSSL Toolkit (http://www.openssl.org/)"
    - *
    - * THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY
    - * EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
    - * PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE OpenSSL PROJECT OR
    - * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
    - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
    - * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
    - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
    - * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
    - * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    - * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
    - * OF THE POSSIBILITY OF SUCH DAMAGE.
    - * ====================================================================
    - *
    - * This product includes cryptographic software written by Eric Young
    - * (eay@cryptsoft.com).  This product includes software written by Tim
    - * Hudson (tjh@cryptsoft.com).
    - *
    - */
    +
    University of Illinois/NCSA Open Source License
    +
    +Copyright (c) <Year> <Owner Organization Name>. All rights reserved.
    +
    +Developed by: <Name of Development Group> <Name of Institution> <URL for Development Group/Institution>
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal with the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
    +
    +     * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimers.
    +
    +     * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimers in the documentation and/or other materials provided with the distribution.
    +
    +     * Neither the names of <Name of Development Group, Name of Institution>, nor the names of its contributors may be used to endorse or promote products derived from this Software without specific prior written permission.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE.
    +
  • -

    Unicode License Agreement - Data Files and Software (2016)

    +

    Unicode License v3

    Used by:

    -
    UNICODE, INC. LICENSE AGREEMENT - DATA FILES AND SOFTWARE
    -
    -Unicode Data Files include all data files under the directories http://www.unicode.org/Public/, http://www.unicode.org/reports/, http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/, and http://www.unicode.org/utility/trac/browser/.
    -
    -Unicode Data Files do not include PDF online code charts under the directory http://www.unicode.org/Public/.
    -
    -Software includes any source code published in the Unicode Standard or under the directories http://www.unicode.org/Public/, http://www.unicode.org/reports/, http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/, and http://www.unicode.org/utility/trac/browser/.
    -
    -NOTICE TO USER: Carefully read the following legal agreement. BY DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING UNICODE INC.'S DATA FILES ("DATA FILES"), AND/OR SOFTWARE ("SOFTWARE"), YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
    +                
    UNICODE LICENSE V3
     
     COPYRIGHT AND PERMISSION NOTICE
     
    -Copyright © 1991-2016 Unicode, Inc. All rights reserved. Distributed under the Terms of Use in http://www.unicode.org/copyright.html.
    +Copyright © 1991-2023 Unicode, Inc.
    +
    +NOTICE TO USER: Carefully read the following legal agreement. BY
    +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
    +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
    +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
    +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
    +
    +Permission is hereby granted, free of charge, to any person obtaining a
    +copy of data files and any associated documentation (the "Data Files") or
    +software and any associated documentation (the "Software") to deal in the
    +Data Files or Software without restriction, including without limitation
    +the rights to use, copy, modify, merge, publish, distribute, and/or sell
    +copies of the Data Files or Software, and to permit persons to whom the
    +Data Files or Software are furnished to do so, provided that either (a)
    +this copyright and permission notice appear with all copies of the Data
    +Files or Software, or (b) this copyright and permission notice appear in
    +associated Documentation.
    +
    +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
    +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
    +THIRD PARTY RIGHTS.
    +
    +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
    +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
    +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
    +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
    +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
    +FILES OR SOFTWARE.
    +
    +Except as contained in this notice, the name of a copyright holder shall
    +not be used in advertising or otherwise to promote the sale, use or other
    +dealings in these Data Files or Software without prior written
    +authorization of the copyright holder.
    +
    +
  • +
  • +

    zlib License

    +

    Used by:

    + +
    Copyright (c) 2020 Matias Rodriguez.
    +
    +This software is provided 'as-is', without any express or implied
    +warranty. In no event will the authors be held liable for any damages
    +arising from the use of this software.
    +
    +Permission is granted to anyone to use this software for any purpose,
    +including commercial applications, and to alter it and redistribute it
    +freely, subject to the following restrictions:
    +
    +1. The origin of this software must not be misrepresented; you must not
    +   claim that you wrote the original software. If you use this software
    +   in a product, an acknowledgment in the product documentation would be
    +   appreciated but is not required.
    +2. Altered source versions must be plainly marked as such, and must not be
    +   misrepresented as being the original software.
    +3. This notice may not be removed or altered from any source distribution.
    +
  • +
  • +

    zlib License

    +

    Used by:

    + +
    Copyright (c) 2024 Orson Peters
    +
    +This software is provided 'as-is', without any express or implied warranty. In
    +no event will the authors be held liable for any damages arising from the use of
    +this software.
     
    -Permission is hereby granted, free of charge, to any person obtaining a copy of the Unicode data files and any associated documentation (the "Data Files") or Unicode software and any associated documentation (the "Software") to deal in the Data Files or Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, and/or sell copies of the Data Files or Software, and to permit persons to whom the Data Files or Software are furnished to do so, provided that either
    +Permission is granted to anyone to use this software for any purpose, including
    +commercial applications, and to alter it and redistribute it freely, subject to
    +the following restrictions:
     
    -     (a) this copyright and permission notice appear with all copies of the Data Files or Software, or
    -     (b) this copyright and permission notice appear in associated Documentation.
    +1. The origin of this software must not be misrepresented; you must not claim
    +    that you wrote the original software. If you use this software in a product,
    +    an acknowledgment in the product documentation would be appreciated but is
    +    not required.
     
    -THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR SOFTWARE.
    +2. Altered source versions must be plainly marked as such, and must not be
    +    misrepresented as being the original software.
     
    -Except as contained in this notice, the name of a copyright holder shall not be used in advertising or otherwise to promote the sale, use or other dealings in these Data Files or Software without prior written authorization of the copyright holder.
    -
    +3. This notice may not be removed or altered from any source distribution.
  • diff --git a/netlify.toml b/netlify.toml deleted file mode 100644 index 2c501be4ba..0000000000 --- a/netlify.toml +++ /dev/null @@ -1,19 +0,0 @@ -[build] - ignore = "exit 0" - -[build.environment] - NODE_VERSION = "16" - -[context.deploy-preview.build] - base = "docs" - ignore = "git diff --quiet main HEAD ." - command = """\ - cd ../ - rm -rf monodocs - git clone https://github.com/apollographql/docs --branch main --single-branch monodocs - cd monodocs - npm i - cp -r ../docs local - DOCS_LOCAL=true npm run build \ - """ - publish = "../monodocs/public" diff --git a/renovate.json5 b/renovate.json5 index bc12c72e28..269fe4ac28 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -1,88 +1,81 @@ { - "extends": [ - "config:base", - ":semanticCommits", + extends: [ + 'config:recommended', + ':semanticCommits', ], - // Renovate Regex Manager Configuration - // - // A slight variation on the pattern documented within Renovate's docs: - // - // => https://docs.renovatebot.com/modules/manager/regex/ - // - // This powers a mechanism that allows Renovate (the package dependency - // manager that we use within this repository) to bump packages that live - // outside of typical package manifests (e.g., `package.json`) and instead - // any number of files. - // - // This pattern can be conceivably adapted to any pattern to allow the - // "Renovation" of nearly anything. This is largely what Renovate does - // behind the scenes for various datasources anyhow (e.g., Dockerfiles). - // - // You can find a list of data-source specific details on this page: - // - // => https://docs.renovatebot.com/modules/datasource/ - // - "regexManagers": [ + customManagers: [ + // A slight variation on the pattern documented within Renovate's docs: + // => https://docs.renovatebot.com/modules/manager/regex/ { - "fileMatch": [ - "^\\.tool-versions$", - "(^|/)Dockerfile[^/]*$", - "^rust-toolchain\\.toml$", - "^docs/.*?\\.mdx$" + customType: 'regex', + managerFilePatterns: [ + '/^\\.tool-versions$/', + '/(^|/)Dockerfile[^/]*$/', + '/^rust-toolchain\\.toml$/', + '/^docs/.*?\\.mdx$/', + '/^.config/mise/.*?\\.toml$/', + '/^apollo-router/Cargo\\.toml$/', + '/^apollo-router/README\\.md$/', ], - "matchStrings": [ - "(#|)?\\n[^.]*?(?[0-9]+\\.[0-9]+\\.[0-9]+)\\b" + matchStrings: [ + '(#|)?\\n[^.]*?(?[0-9]+\\.[0-9]+\\.[0-9]+)\\b', ], - "depNameTemplate": "rust", - "datasourceTemplate": "docker" - } + depNameTemplate: 'rust', + datasourceTemplate: 'docker', + }, ], - "packageRules": [ - // Don't do `rust` image updates on Dockerfiles since they'll we want them - // managed/grouped into the package rule directly after this one. This - // prevents multiple PRs for the same bump, and puts all our Rust version - // bumps together. + packageRules: [ + // Don't do `rust` image updates separately since they'll we want them + // managed/grouped into the rule directly above this one. This prevents + // multiple PRs for the same bump, and puts all our Rust version bumps + // together. { - "matchPackageNames": ["rust"], - "matchManagers": ["dockerfile"], - "enabled": false + matchPackageNames: [ + 'rust', + ], + matchManagers: [ + 'dockerfile', + 'mise', + ], + enabled: false, }, { - // This "rust" name maps to the Docker Hub "rust" image above on account - // of the `regexManagers[0]` defined above being `datasourceTemplate` = `docker`. - "matchPackageNames": ["rust"], - "matchManagers": "regex", - "groupName": "rustc", - "branchName": "{{{branchPrefix}}}rustc" + matchPackageNames: [ + 'rust', + ], + matchManagers: 'regex', + groupName: 'rustc', + branchName: '{{{branchPrefix}}}rustc', }, - // Bunch up all non-major npm dependencies into a single PR. In the common case + // Keep serde_yaml at 0.8.x - version 0.9.x has breaking changes and the + // underlying package is deprecated. The package works as-is and we can + // consider replacing it in the future. We'll allow 0.8.x just in case + // there HAPPEN to be any security updates that we need to be aware of. + // See: https://github.com/dtolnay/serde-yaml/releases/tag/0.9.34 + { + matchManagers: ['cargo'], + matchPackageNames: ['serde_yaml'], + allowedVersions: '0.8.x', + }, + // Bunch up all non-major dependencies into a single PR. In the common case // where the upgrades apply cleanly, this causes less noise and is resolved faster // than starting a bunch of upgrades in parallel for what may turn out to be // a suite of related packages all released at once. - // - // Since too much in the Rust ecosystem is pre-1.0, we make an exception here. - { - "matchCurrentVersion": "< 1.0.0", - "separateMinorPatch": true, - "matchManagers": [ "cargo" ], - "minor": { - "groupName": "cargo pre-1.0 packages", - "groupSlug": "cargo-all-pre-1.0", - }, - "patch": { - "groupName": "cargo pre-1.0 packages", - "groupSlug": "cargo-all-pre-1.0", - "automerge": true, - } - }, { - "matchCurrentVersion": ">= 1.0.0", - "matchManagers": [ "cargo", "npm" ], - "excludePackageNames": [], - "matchUpdateTypes": ["minor", "patch", "pin", "digest"], - "groupName": "all non-major packages >= 1.0", - "groupSlug": "all-non-major-gte-1.0", - "automerge": true, + matchCurrentVersion: '>= 1.0.0', + matchManagers: [ + 'cargo', + 'npm', + ], + matchUpdateTypes: [ + 'minor', + 'patch', + 'pin', + 'digest', + ], + groupName: 'all non-major packages >= 1.0', + groupSlug: 'all-non-major-gte-1.0', + automerge: true, }, // We're currently constrained in our ability to update the `tracing` // packages to the latest versions because of our usage. As an extension @@ -93,37 +86,52 @@ // in the `/apollo-router/Cargo.toml` file around the declarations for // `^opentelemetry` and `^tracing` packages. { - "matchManagers": [ - "cargo" + matchManagers: [ + 'cargo', ], - "matchPackagePatterns": [ - "^tracing", - "^opentelemetry", + groupName: 'cargo tracing packages', + groupSlug: 'cargo-tracing-packages', + dependencyDashboardApproval: true, + matchPackageNames: [ + '/^tracing/', + '/^opentelemetry/', ], - "groupName": "cargo tracing packages", - "groupSlug": "cargo-tracing-packages", - "dependencyDashboardApproval": true }, - // Our own `apollo-` packages deserve to get front-and-center treatment. - // We'll put them in their own PR to facilitate workflows that surface - // their changes earlier, and get us dog-fooding them quicker. - // They also have a small proclivity to require more hands-on changes - // since they're pre-0.x and we use them so extensively. + // Various monorepos / crates that are often released together + { + groupName: 'apollo-rs crates', + groupSlug: 'rust-apollo-rs-updates', + matchManagers: ['cargo'], + matchPackageNames: ['/^apollo-(parser|compiler|smith)$/'], + }, + { + groupName: 'rand crates', + groupSlug: 'rust-rand', + matchManagers: ['cargo'], + matchPackageNames: ['/^rand$/', '/^rand[-_]/'], + }, { - "matchPackagePatterns": ["^apollo-"], - "groupName": "apollo-rs crates", - "groupSlug": "rust-apollo-rs-updates", - "matchManagers": ["cargo"], - "automerge": false + groupName: 'nom crates', + groupSlug: 'rust-nom', + matchManagers: ['cargo'], + matchPackageNames: ['/^nom$/', '/^nom[-_]/'], }, - // We'll review these in a PR of their own since it has the most direct runtime - // implications on users that we'd like to be very intentional about. { - "matchPackageNames": ["router-bridge"], - "groupName": "router-bridge updates (including federation)", - "groupSlug": "rust-router-bridge-updates", - "matchManagers": ["cargo"], - "automerge": false - } - ] + groupName: 'axum crates', + groupSlug: 'rust-axum', + matchManagers: ['cargo'], + matchPackageNames: ['/^axum$/', '/^axum-extra$/'], + }, + // Handle exact version pins (versions starting with =) in Cargo.toml files + // separately. Put them in individual PRs and require dashboard approval + // since they were explicitly pinned for a reason. + { + matchManagers: ['cargo'], + matchCurrentValue: '/^=/', + dependencyDashboardApproval: true, + automerge: false, + groupName: null, + groupSlug: null, + }, + ], } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 0c7dc7c811..caf8e18364 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] # renovate-automation: rustc version -channel = "1.76.0" # If updated, remove `rowan` dependency from apollo-router/Cargo.toml -components = [ "rustfmt", "clippy" ] +channel = "1.89.0" +components = ["rustfmt", "clippy"] diff --git a/scripts/install.sh b/scripts/install.sh index a542864a9a..cb5a3ac2fe 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,11 +7,11 @@ set -u -BINARY_DOWNLOAD_PREFIX="https://github.com/apollographql/router/releases/download" +BINARY_DOWNLOAD_PREFIX="${APOLLO_ROUTER_BINARY_DOWNLOAD_PREFIX:="https://github.com/apollographql/router/releases/download"}" # Router version defined in apollo-router's Cargo.toml # Note: Change this line manually during the release steps. -PACKAGE_VERSION="v1.57.1" +PACKAGE_VERSION="v2.6.0" download_binary() { downloader --check @@ -200,4 +200,3 @@ downloader() { } download_binary "$@" || exit 1 - diff --git a/xtask/Cargo.lock b/xtask/Cargo.lock deleted file mode 100644 index 0a1c01d258..0000000000 --- a/xtask/Cargo.lock +++ /dev/null @@ -1,2167 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "addr2line" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - -[[package]] -name = "aho-corasick" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6748e8def348ed4d14996fa801f4122cd763fff530258cdc03f64b25f89d3a5a" -dependencies = [ - "memchr", -] - -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e1ebcb11de5c03c67de28a7df593d32191b44939c482e97702baaaa6ab6a5" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd" - -[[package]] -name = "anstyle-parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" -dependencies = [ - "windows-sys 0.48.0", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" -dependencies = [ - "anstyle", - "windows-sys 0.52.0", -] - -[[package]] -name = "anyhow" -version = "1.0.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" - -[[package]] -name = "ascii" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" - -[[package]] -name = "autocfg" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" - -[[package]] -name = "backtrace" -version = "0.3.68" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4319208da049c43661739c5fade2ba182f09d1dc2299b32298d3a31692b17e12" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" - -[[package]] -name = "byteorder" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" - -[[package]] -name = "bytes" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" - -[[package]] -name = "camino" -version = "1.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0ec6b951b160caa93cc0c7b209e5a3bff7aae9062213451ac99493cd844c239" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cfa25e60aea747ec7e1124f238816749faa93759c6ff5b31f1ccdda137f4479" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror", -] - -[[package]] -name = "cc" -version = "1.0.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" -dependencies = [ - "libc", -] - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "chrono" -version = "0.4.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" -dependencies = [ - "android-tzdata", - "iana-time-zone", - "num-traits", - "windows-targets 0.52.0", -] - -[[package]] -name = "clap" -version = "4.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64acc1846d54c1fe936a78dc189c34e28d3f5afc348403f28ecf53660b9b8462" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fb8393d67ba2e7bfaf28a23458e4e2b543cc73a99595511eb207fdb8aede942" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "clap_lex" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" - -[[package]] -name = "colorchoice" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" - -[[package]] -name = "combine" -version = "3.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680" -dependencies = [ - "ascii", - "byteorder", - "either", - "memchr", - "unreachable", -] - -[[package]] -name = "console" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" -dependencies = [ - "encode_unicode", - "lazy_static", - "libc", - "unicode-width", - "windows-sys 0.52.0", -] - -[[package]] -name = "core-foundation" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" - -[[package]] -name = "cpufeatures" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "dialoguer" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" -dependencies = [ - "console", - "shell-words", - "tempfile", - "thiserror", - "zeroize", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "either" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" - -[[package]] -name = "encode_unicode" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" - -[[package]] -name = "encoding_rs" -version = "0.8.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071a31f4ee85403370b58aca746f01041ede6f0da2730960ad001edc2b71b394" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "errno" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "fastrand" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" - -[[package]] -name = "filetime" -version = "0.2.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4029edd3e734da6fe05b6cd7bd2960760a616bd2ddd0d59a0124746d6272af0" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.3.5", - "windows-sys 0.48.0", -] - -[[package]] -name = "flate2" -version = "1.0.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "form_urlencoded" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" - -[[package]] -name = "futures-io" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" - -[[package]] -name = "futures-macro" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "futures-sink" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" - -[[package]] -name = "futures-task" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" - -[[package]] -name = "futures-util" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" -dependencies = [ - "futures-core", - "futures-io", - "futures-macro", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", -] - -[[package]] -name = "gimli" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" - -[[package]] -name = "graphql-introspection-query" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f2a4732cf5140bd6c082434494f785a19cfb566ab07d1382c3671f5812fed6d" -dependencies = [ - "serde", -] - -[[package]] -name = "graphql-parser" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ebc8013b4426d5b81a4364c419a95ed0b404af2b82e2457de52d9348f0e474" -dependencies = [ - "combine", - "thiserror", -] - -[[package]] -name = "graphql_client" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50cfdc7f34b7f01909d55c2dcb71d4c13cbcbb4a1605d6c8bd760d654c1144b" -dependencies = [ - "graphql_query_derive", - "reqwest", - "serde", - "serde_json", -] - -[[package]] -name = "graphql_client_codegen" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e27ed0c2cf0c0cc52c6bcf3b45c907f433015e580879d14005386251842fb0a" -dependencies = [ - "graphql-introspection-query", - "graphql-parser", - "heck 0.4.1", - "lazy_static", - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn 1.0.109", -] - -[[package]] -name = "graphql_query_derive" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83febfa838f898cfa73dfaa7a8eb69ff3409021ac06ee94cfb3d622f6eeb1a97" -dependencies = [ - "graphql_client_codegen", - "proc-macro2", - "syn 1.0.109", -] - -[[package]] -name = "h2" -version = "0.3.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97ec8491ebaf99c8eaa73058b045fe58073cd6be7f596ac993ced0b0a0c01049" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "home" -version = "0.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" -dependencies = [ - "windows-sys 0.52.0", -] - -[[package]] -name = "http" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" -dependencies = [ - "bytes", - "http", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.4.9", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d78e1e73ec14cf7375674f74d7dde185c8206fd9dea6fb6295e8a98098aaa97" -dependencies = [ - "futures-util", - "http", - "hyper", - "rustls", - "tokio", - "tokio-rustls", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "windows", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "idna" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown", -] - -[[package]] -name = "insta" -version = "1.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "810ae6042d48e2c9e9215043563a58a80b877bc863228a74cf10c49d4620a6f5" -dependencies = [ - "console", - "lazy_static", - "linked-hash-map", - "pest", - "pest_derive", - "serde", - "similar", -] - -[[package]] -name = "ipnet" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" - -[[package]] -name = "js-sys" -version = "0.3.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - -[[package]] -name = "libc" -version = "0.2.155" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - -[[package]] -name = "linux-raw-sys" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" - -[[package]] -name = "lock_api" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" - -[[package]] -name = "memchr" -version = "2.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e" - -[[package]] -name = "memorable-wordlist" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673c6a442e72f0bca457afb369a8130596eeeb51c80a38b1dd39b6c490ed36c1" -dependencies = [ - "rand", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" -dependencies = [ - "adler", -] - -[[package]] -name = "mio" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" -dependencies = [ - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" -dependencies = [ - "windows-sys 0.52.0", -] - -[[package]] -name = "num-traits" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "object" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bda667d9f2b5051b8833f59f3bf748b28ef54f850f4fcb389a252aa383866d1" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" - -[[package]] -name = "openssl-probe" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" - -[[package]] -name = "parking_lot" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.2", - "smallvec", - "windows-targets 0.52.0", -] - -[[package]] -name = "percent-encoding" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" - -[[package]] -name = "pest" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1acb4a4365a13f749a93f1a094a7805e5cfa0955373a9de860d962eaa3a5fe5a" -dependencies = [ - "thiserror", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666d00490d4ac815001da55838c500eafb0320019bbaa44444137c48b443a853" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ca01446f50dbda87c1786af8770d535423fa8a53aec03b8f4e3d7eb10e0929" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "pest_meta" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56af0a30af74d0445c0bf6d9d051c979b516a1a5af790d251daee76005420a48" -dependencies = [ - "once_cell", - "pest", - "sha2", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12cc1b0bf1727a77a54b6654e7b5f1af8604923edc8b81885f8ec92f9e3f0a05" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "ppv-lite86" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" - -[[package]] -name = "proc-macro2" -version = "1.0.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha", - "rand_core", - "rand_hc", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core", -] - -[[package]] -name = "redox_syscall" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" -dependencies = [ - "bitflags 2.4.0", -] - -[[package]] -name = "regex" -version = "1.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" - -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "hyper", - "hyper-rustls", - "ipnet", - "js-sys", - "log", - "mime", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-native-certs", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "system-configuration", - "tokio", - "tokio-rustls", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", - "winreg", -] - -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin 0.5.2", - "untrusted 0.7.1", - "web-sys", - "winapi", -] - -[[package]] -name = "ring" -version = "0.17.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.15", - "libc", - "spin 0.9.8", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" - -[[package]] -name = "rustix" -version = "0.38.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" -dependencies = [ - "bitflags 2.4.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring 0.17.8", - "rustls-webpki", - "sct", -] - -[[package]] -name = "rustls-native-certs" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" -dependencies = [ - "openssl-probe", - "rustls-pemfile", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2" -dependencies = [ - "base64 0.21.7", -] - -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", -] - -[[package]] -name = "ryu" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" -dependencies = [ - "windows-sys 0.48.0", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "sct" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" -dependencies = [ - "ring 0.16.20", - "untrusted 0.7.1", -] - -[[package]] -name = "security-framework" -version = "2.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918" -dependencies = [ - "serde", -] - -[[package]] -name = "serde" -version = "1.0.204" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.204" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "serde_json" -version = "1.0.120" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" -dependencies = [ - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha2" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shell-words" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" - -[[package]] -name = "signal-hook-registry" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" -dependencies = [ - "libc", -] - -[[package]] -name = "similar" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420acb44afdae038210c99e69aae24109f32f15500aa708e81d46c9f29d55fcf" - -[[package]] -name = "slab" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" -dependencies = [ - "autocfg", -] - -[[package]] -name = "smallvec" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" - -[[package]] -name = "socket2" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "socket2" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" -dependencies = [ - "libc", - "windows-sys 0.48.0", -] - -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "strsim" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tar" -version = "0.4.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb797dad5fb5b76fcf519e702f4a589483b5ef06567f160c392832c1f5e44909" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "tempfile" -version = "3.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" -dependencies = [ - "cfg-if", - "fastrand", - "rustix", - "windows-sys 0.52.0", -] - -[[package]] -name = "thiserror" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a802ec30afc17eee47b2855fc72e0c4cd62be9b4efe6591edde0ec5bd68d8f" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb623b56e39ab7dcd4b1b98bb6c8f8d907ed255b18de254088016b27a8ee19b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "tinyvec" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.38.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2caba9f80616f438e09748d5acda951967e1ea58508ef53d9c6402485a46df" -dependencies = [ - "backtrace", - "bytes", - "libc", - "mio", - "num_cpus", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2 0.5.5", - "tokio-macros", - "windows-sys 0.48.0", -] - -[[package]] -name = "tokio-macros" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", - "tracing", -] - -[[package]] -name = "tower-service" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" - -[[package]] -name = "tracing" -version = "0.1.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" -dependencies = [ - "cfg-if", - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" - -[[package]] -name = "typenum" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" - -[[package]] -name = "ucd-trie" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" - -[[package]] -name = "unicode-bidi" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" - -[[package]] -name = "unicode-ident" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" - -[[package]] -name = "unicode-normalization" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-width" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" - -[[package]] -name = "unreachable" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56" -dependencies = [ - "void", -] - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", -] - -[[package]] -name = "utf8parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" - -[[package]] -name = "version_check" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" - -[[package]] -name = "void" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "wasm-bindgen" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" -dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" -dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.48", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" - -[[package]] -name = "web-sys" -version = "0.3.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - -[[package]] -name = "which" -version = "6.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8211e4f58a2b2805adfbefbc07bab82958fc91e3836339b1ab7ae32465dce0d7" -dependencies = [ - "either", - "home", - "rustix", - "winsafe", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" -dependencies = [ - "winapi", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.0", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" -dependencies = [ - "windows_aarch64_gnullvm 0.52.0", - "windows_aarch64_msvc 0.52.0", - "windows_i686_gnu 0.52.0", - "windows_i686_msvc 0.52.0", - "windows_x86_64_gnu 0.52.0", - "windows_x86_64_gnullvm 0.52.0", - "windows_x86_64_msvc 0.52.0", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" - -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - -[[package]] -name = "xattr" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f" -dependencies = [ - "libc", - "linux-raw-sys", - "rustix", -] - -[[package]] -name = "xshell" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db0ab86eae739efd1b054a8d3d16041914030ac4e01cd1dca0cf252fd8b6437" -dependencies = [ - "xshell-macros", -] - -[[package]] -name = "xshell-macros" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d422e8e38ec76e2f06ee439ccc765e9c6a9638b9e7c9f2e8255e4d41e8bd852" - -[[package]] -name = "xtask" -version = "1.5.0" -dependencies = [ - "anyhow", - "base64 0.22.1", - "camino", - "cargo_metadata", - "chrono", - "clap", - "console", - "dialoguer", - "flate2", - "graphql_client", - "insta", - "itertools", - "libc", - "memorable-wordlist", - "nu-ansi-term", - "once_cell", - "regex", - "reqwest", - "serde", - "serde_json", - "tar", - "tempfile", - "tinytemplate", - "tokio", - "walkdir", - "which", - "xshell", - "zip", -] - -[[package]] -name = "zeroize" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" - -[[package]] -name = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "byteorder", - "crc32fast", - "crossbeam-utils", -] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index b57c3a8759..63589e6ed7 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -1,50 +1,48 @@ -[workspace] - [package] name = "xtask" version = "1.5.0" authors = ["Apollo Graph, Inc. "] edition = "2021" -license = "LicenseRef-ELv2" +license = "Elastic-2.0" publish = false [dependencies] anyhow = "1" camino = "1" clap = { version = "4.5.1", features = ["derive"] } -cargo_metadata = "0.18.1" +cargo_metadata = "0.22.0" # Only use the `clock` features of `chrono` to avoid the `time` dependency # impacted by CVE-2020-26235. https://github.com/chronotope/chrono/issues/602 # and https://github.com/chronotope/chrono/issues/1073 will explain more. chrono = { version = "0.4.34", default-features = false, features = ["clock"] } -console = "0.15.8" +console = "0.16.0" dialoguer = "0.11.0" flate2 = "1" -graphql_client = { version = "0.14.0", features = ["reqwest-rustls"] } -itertools = "0.13.0" +graphql_client = "0.14.0" +itertools = "0.14.0" libc = "0.2" memorable-wordlist = "0.1.7" nu-ansi-term = "0.50" once_cell = "1" regex = "1.10.3" -reqwest = { version = "0.11", default-features = false, features = [ +reqwest = { workspace = true, default-features = false, features = [ "blocking", + "json", "rustls-tls", "rustls-tls-native-roots" ] } serde = { version = "1.0.197", features = ["derive"] } -serde_json = "1" tar = "0.4" tempfile = "3" tinytemplate = "1.2.1" tokio = { version = "1.36.0", features = ["full"] } -which = "6.0.1" +which = "8.0.0" walkdir = "2.4.0" xshell = "0.2.6" [target.'cfg(target_os = "macos")'.dependencies] base64 = "0.22" -zip = { version = "0.6", default-features = false } +zip = { version = "4.0", default-features = false } [dev-dependencies] insta = { version = "1.35.1", features = ["json", "redactions", "yaml"] } diff --git a/xtask/README.md b/xtask/README.md index 0bd6883a87..e1af5e6bc1 100644 --- a/xtask/README.md +++ b/xtask/README.md @@ -1,6 +1,6 @@ # xtask -The Apollo Router project uses [xtask](https://github.com/matklad/cargo-xtask) to help with the automation of code quality. +The Apollo Router project uses the [xtask](https://github.com/matklad/cargo-xtask) pattern to help with the automation of code quality. You can run `cargo xtask --help` to see the usage. Generally we recommend that you continue to use the default cargo commands like `cargo fmt`, `cargo clippy`, and `cargo test`, but if you are debugging something that is happening in CI it can be useful to run the xtask commands that we run [in CI](../.github/workflows). diff --git a/xtask/src/commands/changeset/mod.rs b/xtask/src/commands/changeset/mod.rs index 785ec5a7f0..6b65ff24de 100644 --- a/xtask/src/commands/changeset/mod.rs +++ b/xtask/src/commands/changeset/mod.rs @@ -105,7 +105,7 @@ pub enum Command { } #[allow(clippy::derive_ord_xor_partial_ord)] -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Ord)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, PartialOrd, Ord)] enum Classification { Breaking, Feature, @@ -144,15 +144,6 @@ impl Classification { ]; } -impl std::cmp::PartialOrd for Classification { - fn partial_cmp(&self, other: &Classification) -> Option { - Self::ORDERED_ALL - .iter() - .position(|item| item == self) - .partial_cmp(&Self::ORDERED_ALL.iter().position(|item| item == other)) - } -} - type ParseError = &'static str; impl FromStr for Classification { type Err = ParseError; @@ -405,7 +396,7 @@ impl Create { let index = pr_body_meta_regex.find(&pr_body).map(|mat| mat.start()).unwrap_or(pr_body.len()); // Run the above Regex and trim the blurb. let clean_pr_body = pr_body_fixes_regex - .replace_all(&pr_body[..index].trim(), "") + .replace_all(pr_body[..index].trim(), "") .trim() .to_string(); @@ -587,7 +578,7 @@ pub fn slurp_and_remove_changesets() -> String { } #[allow(clippy::derive_ord_xor_partial_ord)] -#[derive(Clone, Debug, Eq, Ord)] +#[derive(Clone, Debug, Eq)] struct Changeset { classification: Classification, content: String, @@ -601,8 +592,14 @@ impl std::cmp::PartialEq for Changeset { } impl std::cmp::PartialOrd for Changeset { - fn partial_cmp(&self, other: &Changeset) -> Option { - self.classification.partial_cmp(&other.classification) + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl std::cmp::Ord for Changeset { + fn cmp(&self, other: &Changeset) -> std::cmp::Ordering { + self.classification.cmp(&other.classification) } } diff --git a/xtask/src/commands/compliance.rs b/xtask/src/commands/compliance.rs index 6b900853fe..c1f2495e50 100644 --- a/xtask/src/commands/compliance.rs +++ b/xtask/src/commands/compliance.rs @@ -1,16 +1,11 @@ use anyhow::Result; use xtask::*; -#[derive(Debug, clap::Parser)] +#[derive(Debug, clap::Parser, Default)] pub struct Compliance {} impl Compliance { pub fn run(&self) -> Result<()> { - // Cargo deny is triggering `git credential-manager-core get` - // On windows CI this will hangs as it requires user input. - // The root cause seems to be the krates step in cargo-deny but did not manage to figure it out. - // Disabling as a temporary measure, but we must fix this soon. https://github.com/apollographql/router/issues/3237 - #[cfg(not(windows))] cargo!(["deny", "-L", "error", "check"]); Ok(()) } diff --git a/xtask/src/commands/dev.rs b/xtask/src/commands/dev.rs index 694bb70449..4c7c4ec0c8 100644 --- a/xtask/src/commands/dev.rs +++ b/xtask/src/commands/dev.rs @@ -3,6 +3,7 @@ use anyhow::Result; use super::Compliance; use super::Lint; use super::Test; +use super::Unused; #[derive(Debug, clap::Parser)] pub struct Dev { @@ -12,6 +13,8 @@ pub struct Dev { lint: Lint, #[clap(flatten)] test: Test, + #[clap(flatten)] + unused: Unused, } impl Dev { @@ -21,6 +24,10 @@ impl Dev { eprintln!("Checking format and clippy..."); self.lint.run_local()?; eprintln!("Running tests..."); - self.test.run() + self.test.run()?; + eprintln!("Checking dependencies..."); + self.unused.run()?; + + Ok(()) } } diff --git a/xtask/src/commands/dist.rs b/xtask/src/commands/dist.rs index a28dbe234c..ce77603393 100644 --- a/xtask/src/commands/dist.rs +++ b/xtask/src/commands/dist.rs @@ -25,10 +25,7 @@ impl Dist { args.push(target); cargo!(args); - let bin_path = TARGET_DIR - .join(target.to_string()) - .join("release") - .join(RELEASE_BIN); + let bin_path = TARGET_DIR.join(target).join("release").join(RELEASE_BIN); eprintln!("successfully compiled to: {}", &bin_path); } diff --git a/xtask/src/commands/licenses.rs b/xtask/src/commands/licenses.rs index e8f14a3440..5f5b764d07 100644 --- a/xtask/src/commands/licenses.rs +++ b/xtask/src/commands/licenses.rs @@ -1,7 +1,7 @@ use anyhow::Result; use xtask::*; -#[derive(Debug, clap::Parser)] +#[derive(Debug, clap::Parser, Default)] pub struct Licenses {} impl Licenses { diff --git a/xtask/src/commands/lint.rs b/xtask/src/commands/lint.rs index 8fbcff0981..59af1830fd 100644 --- a/xtask/src/commands/lint.rs +++ b/xtask/src/commands/lint.rs @@ -52,6 +52,7 @@ impl Lint { "clippy", "--all", "--all-targets", + "--all-features", "--no-deps", "--", "-D", diff --git a/xtask/src/commands/mod.rs b/xtask/src/commands/mod.rs index 64c02325ed..0077b3d1fa 100644 --- a/xtask/src/commands/mod.rs +++ b/xtask/src/commands/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod lint; pub(crate) mod package; pub(crate) mod release; pub(crate) mod test; +pub(crate) mod unused; pub(crate) use all::All; pub(crate) use compliance::Compliance; @@ -19,3 +20,4 @@ pub(crate) use licenses::Licenses; pub(crate) use lint::Lint; pub(crate) use package::Package; pub(crate) use test::Test; +pub(crate) use unused::Unused; diff --git a/xtask/src/commands/package/macos.rs b/xtask/src/commands/package/macos.rs index 4dcb0e1787..fc942d8a67 100644 --- a/xtask/src/commands/package/macos.rs +++ b/xtask/src/commands/package/macos.rs @@ -189,7 +189,7 @@ impl PackageMacos { let mut zip = zip::ZipWriter::new(std::io::BufWriter::new( std::fs::File::create(&dist_zip).context("could not create file")?, )); - let options = zip::write::FileOptions::default() + let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Stored) .unix_permissions(0o755); let path = Path::new("dist").join(RELEASE_BIN); diff --git a/xtask/src/commands/release.rs b/xtask/src/commands/release.rs index 51066382f9..3e837fdfde 100644 --- a/xtask/src/commands/release.rs +++ b/xtask/src/commands/release.rs @@ -9,7 +9,8 @@ use walkdir::WalkDir; use xtask::*; use crate::commands::changeset::slurp_and_remove_changesets; -mod process; +use crate::commands::Compliance; +use crate::commands::Licenses; #[derive(Debug, clap::Subcommand)] pub enum Command { @@ -18,9 +19,6 @@ pub enum Command { /// Verify that a release is ready to be published PreVerify, - - Start(process::Start), - Continue, } impl Command { @@ -28,20 +26,18 @@ impl Command { match self { Command::Prepare(command) => command.run(), Command::PreVerify => PreVerify::run(), - Command::Start(start) => process::Process::start(start), - Command::Continue => process::Process::cont(), } } } #[derive(Debug, Clone, Eq, PartialEq)] -pub(crate) enum Version { +enum Version { Major, Minor, Patch, Current, Nightly, - Version(String), + Custom(String), } type ParseError = &'static str; @@ -55,7 +51,7 @@ impl FromStr for Version { "patch" => Version::Patch, "current" => Version::Current, "nightly" => Version::Nightly, - version => Version::Version(version.to_string()), + version => Version::Custom(version.to_string()), }) } } @@ -75,19 +71,25 @@ pub struct Prepare { macro_rules! replace_in_file { ($path:expr, $regex:expr, $replacement:expr) => { - let before = std::fs::read_to_string($path)?; + let before = std::fs::read_to_string($path) + .map_err(|e| anyhow!("failed to read {:?}: {}", $path, e))?; let re = regex::Regex::new(&format!("(?m){}", $regex))?; let after = re.replace_all(&before, $replacement); - std::fs::write($path, &after.as_ref())?; + std::fs::write($path, &after.as_ref()) + .map_err(|e| anyhow!("failed to write to {:?}: {}", $path, e))?; }; } impl Prepare { pub fn run(&self) -> Result<()> { - self.prepare_release() + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { self.prepare_release().await }) } - fn prepare_release(&self) -> Result<(), Error> { + async fn prepare_release(&self) -> Result<(), Error> { self.ensure_pristine_checkout()?; self.ensure_prereqs()?; let version = self.update_cargo_tomls(&self.version)?; @@ -173,7 +175,20 @@ impl Prepare { Ok(()) } - /// Update the `apollo-router` version in the `dependencies` sections of the `Cargo.toml` files in `apollo-router-scaffold/templates/**`. + /// Read the current apollo-router version number from `Cargo.toml`. + fn cargo_toml_version() -> Result { + let metadata = MetadataCommand::new() + .manifest_path("./apollo-router/Cargo.toml") + .exec()?; + Ok(metadata + .root_package() + .expect("root package missing") + .version + .to_string()) + } + + /// Update the `apollo-router` version in the `dependencies` sections of the `Cargo.toml` + /// files. fn update_cargo_tomls(&self, version: &Version) -> Result { println!("updating Cargo.toml files"); fn bump(component: &str) -> Result<()> { @@ -210,17 +225,18 @@ impl Prepare { // Just get the first 8 characters, for brevity. let head_commit = head_commit.chars().take(8).collect::(); + let base_version = Self::cargo_toml_version()?; + let date = Utc::now().format("%Y%m%d"); + replace_in_file!( "./apollo-router/Cargo.toml", r#"^(?Pversion\s*=\s*)"[^"]+""#, format!( - "${{existingVersion}}\"0.0.0-nightly.{}+{}\"", - Utc::now().format("%Y%m%d"), - head_commit + r#"${{existingVersion}}"0.0.0-nightly-{base_version}.{date}+{head_commit}""# ) ); } - Version::Version(version) => { + Version::Custom(version) => { // Also updates apollo-router's dependency: cargo!(["set-version", version, "--package", "apollo-federation"]); @@ -228,34 +244,14 @@ impl Prepare { } } - let metadata = MetadataCommand::new() - .manifest_path("./apollo-router/Cargo.toml") - .exec()?; - let resolved_version = metadata - .root_package() - .expect("root package missing") - .version - .to_string(); - + let resolved_version = Self::cargo_toml_version()?; if let Version::Nightly = version { - println!("Not changing `apollo-router-scaffold` or `apollo-router-benchmarks` because of nightly build mode."); + println!("Not changing `apollo-router-benchmarks` because of nightly build mode."); } else { - let packages = vec!["apollo-router-scaffold", "apollo-router-benchmarks"]; + let packages = vec!["apollo-router-benchmarks"]; for package in packages { cargo!(["set-version", &resolved_version, "--package", package]) } - replace_in_file!( - "./apollo-router-scaffold/templates/base/Cargo.template.toml", - "^apollo-router\\s*=\\s*\"[^\"]+\"", - format!("apollo-router = \"{resolved_version}\"") - ); - replace_in_file!( - "./apollo-router-scaffold/templates/base/xtask/Cargo.template.toml", - r#"^apollo-router-scaffold = \{\s*git\s*=\s*"https://github.com/apollographql/router.git",\s*tag\s*=\s*"v[^"]+"\s*\}$"#, - format!( - r#"apollo-router-scaffold = {{ git = "https://github.com/apollographql/router.git", tag = "v{resolved_version}" }}"# - ) - ); } Ok(resolved_version) @@ -275,21 +271,15 @@ impl Prepare { /// Update `docker.mdx` and `kubernetes.mdx` with the release version. /// Update the kubernetes section of the docs: /// - go to the `helm/chart/router` folder - /// - run - /// ```helm template --set router.configuration.telemetry.metrics.prometheus.enabled=true --set managedFederation.apiKey="REDACTED" --set managedFederation.graphRef="REDACTED" --debug .``` + /// - run `helm template --set router.configuration.telemetry.metrics.prometheus.enabled=true --set managedFederation.apiKey="REDACTED" --set managedFederation.graphRef="REDACTED" --debug .` /// - Paste the output in the `Kubernetes Configuration` example of the `docs/source/containerization/kubernetes.mdx` file fn update_docs(&self, version: &str) -> Result<()> { println!("updating docs"); replace_in_file!( - "./docs/source/containerization/docker.mdx", + "./docs/source/routing/self-hosted/containerization/docker.mdx", "with your chosen version. e.g.: `v[^`]+`", format!("with your chosen version. e.g.: `v{version}`") ); - replace_in_file!( - "./docs/source/containerization/kubernetes.mdx", - "https://github.com/apollographql/router/tree/[^/]+/helm/chart/router", - format!("https://github.com/apollographql/router/tree/v{version}/helm/chart/router") - ); let helm_chart = String::from_utf8( std::process::Command::new(which::which("helm")?) .current_dir("./helm/chart/router") @@ -298,6 +288,8 @@ impl Prepare { "--set", "router.configuration.telemetry.metrics.prometheus.enabled=true", "--set", + "router.configuration.telemetry.metrics.prometheus.listen=127.0.0.1:9090", + "--set", "managedFederation.apiKey=REDACTED", "--set", "managedFederation.graphRef=REDACTED", @@ -309,7 +301,7 @@ impl Prepare { )?; replace_in_file!( - "./docs/source/containerization/kubernetes.mdx", + "./docs/shared/k8s-manual-config.mdx", "^```yaml\n---\n# Source: router/templates/serviceaccount.yaml(.|\n)+?```", format!("```yaml\n{}\n```", helm_chart.trim()) ); @@ -397,10 +389,10 @@ impl Prepare { /// Run `cargo xtask check-compliance`. fn check_compliance(&self) -> Result<()> { println!("checking compliance"); - cargo!(["xtask", "check-compliance"]); + Compliance::default().run()?; if !self.skip_license_check { println!("updating licenses.html"); - cargo!(["xtask", "licenses"]); + Licenses::default().run()?; } Ok(()) } diff --git a/xtask/src/commands/release/process.rs b/xtask/src/commands/release/process.rs deleted file mode 100644 index ce76ada44b..0000000000 --- a/xtask/src/commands/release/process.rs +++ /dev/null @@ -1,840 +0,0 @@ -use std::fs::File; -use std::io::Read; -use std::io::Write; -use std::path::Path; - -use anyhow::Result; -use console::style; -use dialoguer::Confirm; -use dialoguer::Input; -use dialoguer::Select; -use serde::Deserialize; -use serde::Serialize; - -#[derive(Debug, clap::Parser)] -pub struct Start { - #[clap(short = 'v', long = "version")] - version: Option, - #[clap(short = 'o', long = "origin")] - git_origin: Option, - #[clap(short = 'r', long = "repository")] - github_repository: Option, - //#[clap(short = 's', long = "suffix")] - //prerelease_suffix: Option, - #[clap(short = 'c', long = "commit")] - commit: Option, -} - -const STATE_FILE: &str = ".release-state.json"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(super) struct Process { - version: String, - git_origin: String, - github_repository: String, - prerelease_suffix: String, - commit: Commit, - state: State, - final_pr_prepared: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(super) enum Commit { - Head, - Id(String), -} - -impl Process { - pub(crate) fn start(arguments: &Start) -> Result<()> { - println!("{}", style("Starting release process").bold().bright()); - // check if a file is already present - let path = Path::new(STATE_FILE); - if path.exists() { - if Confirm::new() - .with_prompt("A release state file already exists, do you want to remove it and start a new one?") - .default(false) - .interact() - ?{ - std::fs::remove_file(path)?; - } else { - return Ok(()); - } - } - - // generate the structure - let version = match &arguments.version { - Some(v) => v.clone(), - None => Input::new() - .with_prompt("Version?") - //FIXME: used for quicker testing, remove before merging - .default("1.2.3456".to_string()) - .interact_text()?, - }; - - let git_origin = match &arguments.git_origin { - Some(v) => v.clone(), - None => Input::new() - .with_prompt("Git origin?") - .default("origin".to_string()) - .interact_text()?, - }; - - let github_repository = match &arguments.github_repository { - Some(v) => v.clone(), - None => Input::new() - .with_prompt("Github repository?") - .default("apollographql/router".to_string()) - .interact_text()?, - }; - - let commit = match &arguments.commit { - Some(v) => v.clone(), - None => Input::new() - .with_prompt("Git ref?") - .default("HEAD".to_string()) - .interact_text()?, - }; - - let commit = if &commit == "HEAD" { - Commit::Head - } else { - Commit::Id(commit) - }; - - let mut process = Self { - version, - git_origin, - github_repository, - prerelease_suffix: String::new(), - commit, - state: State::Start, - final_pr_prepared: false, - }; - - // store the file - println!("{}: {:?}", style("process").bold().bright(), process); - - process.save()?; - - // start asking questions - loop { - if !process.run()? { - return Ok(()); - } - } - } - - pub(super) fn cont() -> Result<()> { - let mut process = Process::restore()?; - - loop { - if !process.run()? { - return Ok(()); - } - } - } - - fn save(&self) -> Result<()> { - let path = Path::new(STATE_FILE); - - let serialized = serde_json::to_string_pretty(&self)?; - let mut file = File::create(path)?; - file.write_all(serialized.as_bytes())?; - Ok(()) - } - - fn restore() -> Result { - let path = Path::new(STATE_FILE); - - let mut file = File::open(path)?; - let mut data = String::new(); - file.read_to_string(&mut data)?; - - Ok(serde_json::from_str(&data)?) - } - - fn run(&mut self) -> Result { - match self.state { - State::Start => self.state_start(), - State::ReleasePRCreate => self.create_release_pr(), - State::PreReleasePRChoose => self.choose_pre_release_pr(), - State::PreReleasePRCreate => self.create_pre_release_pr(), - State::PreReleasePRGitAdd => self.git_add_pre_release_pr(), - State::ReleaseFinalPRCreate => self.create_final_release_pr(), - State::ReleaseFinalPRGitAdd => self.git_add_final_release_pr(), - State::ReleaseFinalPRMerge => self.merge_final_release_pr(), - State::ReleaseFinalPRMerge2 => self.merge_final_release_pr2(), - State::WaitForMergeToMain => self.tag_and_release(), - State::WaitForReleasePublished => self.update_release_notes(), - } - } - - fn state_start(&mut self) -> Result { - println!("{}", style("Setting up the repository").bold().bright()); - - let git = which::which("git")?; - - // step 5 - let _output = std::process::Command::new(&git) - .args(["checkout", "dev"]) - .status()?; - let _output = std::process::Command::new(&git) - .args(["pull", self.git_origin.as_str()]) - .status()?; - - if let Commit::Id(id) = &self.commit { - let _output = std::process::Command::new(&git) - .args(["checkout", id]) - .status()?; - } - - // step 6 - let _output = std::process::Command::new(&git) - .args(["checkout", "-b", self.version.as_str()]) - .status()?; - - // step 7 - let _output = std::process::Command::new(&git) - .args([ - "push", - "--set-upstream", - self.git_origin.as_str(), - self.version.as_str(), - ]) - .status()?; - - self.state = State::ReleasePRCreate; - self.save()?; - - Ok(true) - } - - fn create_release_pr(&mut self) -> Result { - println!("{}", style("Creating the release PR").bold().bright()); - - let gh = which::which("gh")?; - - // step 8 - let pr_text = r#"> **Note** -> **This particular PR must be true-merged to \`main\`.** - -* This PR is only ready to review when it is marked as "Ready for Review". It represents the merge to the \`main\` branch of an upcoming release (version number in the title). -* It will act as a staging branch until we are ready to finalize the release. -* We may cut any number of alpha and release candidate (RC) versions off this branch prior to formalizing it. -* This PR is **primarily a merge commit**, so reviewing every individual commit shown below is **not necessary** since those have been reviewed in their own PR. However, things important to review on this PR **once it's marked "Ready for Review"**: - - Does this PR target the right branch? (usually, \`main\`) - - Are the appropriate **version bumps** and **release note edits** in the end of the commit list (or within the last few commits). In other words, "Did the 'release prep' PR actually land on this branch?" - - If those things look good, this PR is good to merge!"#; - - let _output = std::process::Command::new(&gh) - .args([ - "--repo", - self.github_repository.as_str(), - "pr", - "create", - "--draft", - "--label", - "release", - "-B", - "main", - "--title", - &format!("release: v{}", self.version.as_str()), - "--body", - pr_text, - ]) - .status()?; - - self.state = State::PreReleasePRChoose; - self.save()?; - Ok(false) - } - - fn choose_pre_release_pr(&mut self) -> Result { - println!("{}", style("Select next release step").bold().bright()); - - if !self.final_pr_prepared { - let items = vec!["create a prerelease", "create the final release PR"]; - - let selection = Select::new() - .with_prompt("Next step?") - .items(&items) - .interact()?; - - match selection { - 0 => { - self.state = State::PreReleasePRCreate; - } - 1 => { - self.state = State::ReleaseFinalPRCreate; - } - _ => unreachable!(), - }; - self.save()?; - Ok(true) - } else { - let items = vec!["create a prerelease", "finish the release process"]; - - let selection = Select::new() - .with_prompt("Next step?") - .items(&items) - .interact()?; - - match selection { - 0 => { - self.state = State::PreReleasePRCreate; - } - 1 => { - self.state = State::ReleaseFinalPRMerge; - } - _ => unreachable!(), - }; - self.save()?; - Ok(true) - } - } - - fn create_pre_release_pr(&mut self) -> Result { - println!("{}", style("Creating the pre release PR").bold().bright()); - - let prerelease_suffix = Input::new() - .with_prompt(&format!("prerelease suffix? {}-", self.version)) - .with_initial_text(self.prerelease_suffix.clone()) - .interact_text()?; - - let git = which::which("git")?; - - // step 5 - let _output = std::process::Command::new(&git) - .args(["checkout", &self.version]) - .status()?; - let _output = std::process::Command::new(&git) - .args(["pull", &self.git_origin, &self.version]) - .status()?; - - if let Commit::Id(id) = &self.commit { - let _output = std::process::Command::new(&git) - .args(["checkout", id]) - .status()?; - } - - let new_version = format!("{}-{}", self.version, prerelease_suffix); - println!( - "{} {new_version}", - style("prerelease version: ").bold().bright() - ); - - // step 6 - let prepare = super::Prepare { - skip_license_check: true, - pre_release: true, - version: super::Version::Version(new_version), - }; - - prepare.prepare_release()?; - - self.prerelease_suffix = prerelease_suffix; - self.state = State::PreReleasePRGitAdd; - self.save()?; - - Ok(true) - } - - fn git_add_pre_release_pr(&mut self) -> Result { - let git = which::which("git")?; - - // step 7 - println!( - "{}", - style("please check the changes and add them with `git add -up .`") - .bold() - .bright() - ); - let _output = std::process::Command::new(&git) - .args(["add", "-up", "."]) - .status()?; - - // step 8 - if Confirm::new() - .with_prompt("Commit the changes and build the prerelease?") - .default(false) - .interact()? - { - let prerelease_version = format!("{}-{}", self.version, self.prerelease_suffix); - - let _output = std::process::Command::new(&git) - .args([ - "commit", - "-m", - &format!("prep release: v{}", prerelease_version), - ]) - .status()?; - - //step 9 - let _output = std::process::Command::new(&git) - .args(["push", &self.git_origin, &self.version]) - .status()?; - - // step 10 - let _output = std::process::Command::new(&git) - .args([ - "tag", - "-a", - &format!("v{}", prerelease_version), - "-m", - &prerelease_version, - ]) - .status()?; - let _output = std::process::Command::new(&git) - .args([ - "push", - &self.git_origin, - &self.version, - &format!("v{}", prerelease_version), - ]) - .status()?; - - // step 11 - println!("{}\ncargo publish -p apollo-federation@{prerelease_version}\ncargo publish -p apollo-router@{prerelease_version}", style("publish the crates:").bold().bright()); - - self.state = State::PreReleasePRChoose; - self.save()?; - } else { - return Ok(false); - } - - Ok(false) - } - - fn create_final_release_pr(&mut self) -> Result { - println!("{}", style("Creating the final release PR").bold().bright()); - - let git = which::which("git")?; - - // step 4 - let _output = std::process::Command::new(&git) - .args(["checkout", &self.version]) - .status()?; - let _output = std::process::Command::new(&git) - .args(["pull", &self.git_origin, &self.version]) - .status()?; - - //step 5 - //git checkout -b "prep-${APOLLO_ROUTER_RELEASE_VERSION}" - let _output = std::process::Command::new(&git) - .args(["checkout", "-b", &format!("prep-{}", self.version)]) - .status()?; - - // step 6 - // cargo xtask release prepare $APOLLO_ROUTER_RELEASE_VERSION - let prepare = super::Prepare { - skip_license_check: true, - pre_release: false, - version: super::Version::Version(self.version.clone()), - }; - - prepare.prepare_release()?; - - self.state = State::ReleaseFinalPRGitAdd; - self.save()?; - - println!( - "{}\n{}", - style("prep release branch created").bold().bright(), - style("**MANUALLY CHECK AND UPDATE** the `federation-version-support.mdx` to make sure it shows the version of Federation which is included in the `router-bridge` that ships with this version of Router.\n This can be obtained by looking at the version of `router-bridge` in `apollo-router/Cargo.toml` and taking the number after the `+` (e.g., `router-bridge@0.2.0+v2.4.3` means Federation v2.4.3).").bold().bright() - ); - - println!("{}", style(r#"Make local edits to the newly rendered `CHANGELOG.md` entries to do some initial editoral. - - These things should have *ALWAYS* been resolved earlier in the review process of the PRs that introduced the changes, but they must be double checked: - - - There are no breaking changes. - - Entries are in categories (e.g., Fixes vs Features) that make sense. - - Titles stand alone and work without their descriptions. - - You don't need to read the title for the description to make sense. - - Grammar is good. (Or great! But don't let perfect be the enemy of good.) - - Formatting looks nice when rendered as markdown and follows common convention."#).bold().bright()); - - Ok(false) - } - - fn git_add_final_release_pr(&mut self) -> Result { - let git = which::which("git")?; - - // step 11 - println!( - "{}", - style("please check the changes and add them with `git add -up .`") - .bold() - .bright() - ); - - let _output = std::process::Command::new(&git) - .args(["add", "-up", "."]) - .status()?; - - let _output = std::process::Command::new(&git) - .args(["commit", "-m", &format!("prep release: v{}", self.version)]) - .status()?; - - //step 14 - // git push --set-upstream "${APOLLO_ROUTER_RELEASE_GIT_ORIGIN}" "prep-${APOLLO_ROUTER_RELEASE_VERSION}" - let _output = std::process::Command::new(&git) - .args([ - "push", - "--set-upstream", - &self.git_origin, - &format!("prep-{}", self.version), - ]) - .status()?; - - //Step 15 - //FIXME: replace this step with a template - let perl = which::which("perl")?; - let output = std::process::Command::new(&perl) - .args([ - "0777", - "-sne", - r#"print "$1\n" if m{ - (?:\#\s # Look for H1 Markdown (line starting with "\# ") - \[v?\Q$version\E\] # ...followed by [$version] (optionally with a "v") - # since some versions had that in the past. - \s.*?\n$) # ... then "space" until the end of the line. - \s* # Ignore PRE-entry-whitespace - (.*?) # Capture the ACTUAL body of the release. But do it - # in a non-greedy way, leading us to stop when we - # reach the next version boundary/heading. - \s* # Ignore POST-entry-whitespace - (?=^\#\s\[[^\]]+\]\s) # Once again, look for a version boundary. This is - # the same bit at the start, just on one line. - }msx"#, - "--", - "-version", - &self.version, - "CHANGELOG.md", - ]) - .output()?; - - let mut f = std::fs::File::create("this_release.md")?; - f.write_all(&output.stdout)?; - - //step 16 - let apollo_prep_release_header = format!( - r#"> **Note** -> -> When approved, this PR will merge into **the \`{}\` branch** which will — upon being approved itself — merge into \`main\`. -> -> **Things to review in this PR**: -> - Changelog correctness (There is a preview below, but it is not necessarily the most up to date. See the _Files Changed_ for the true reality.) -> - Version bumps -> - That it targets the right release branch (\`${}\` in this case!). -> ---- - -{}"#, - &self.version, - &self.version, - std::str::from_utf8(&output.stdout)? - ); - - //echo "${apollo_prep_release_header}\n${apollo_prep_release_notes}" | gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" pr create -B "${APOLLO_ROUTER_RELEASE_VERSION}" --title "prep release: v${APOLLO_ROUTER_RELEASE_VERSION}" --body-file - - - let gh = which::which("gh")?; - - let _output = std::process::Command::new(&gh) - .args([ - "--repo", - self.github_repository.as_str(), - "pr", - "create", - "-B", - &self.version, - "--title", - &format!("prep release: v{}", self.version.as_str()), - "--body", - &apollo_prep_release_header, - ]) - .status()?; - - self.state = State::PreReleasePRChoose; - self.final_pr_prepared = true; - self.save()?; - Ok(false) - } - - fn merge_final_release_pr(&mut self) -> Result { - println!("{}", style("Merging the final release PR").bold().bright()); - - let gh = which::which("gh")?; - - // step 4 - // gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" pr merge --squash --body "" -t "prep release: v${APOLLO_ROUTER_RELEASE_VERSION}" "prep-${APOLLO_ROUTER_RELEASE_VERSION}" - - let _output = std::process::Command::new(&gh) - .args([ - "--repo", - &self.github_repository, - "pr", - "merge", - "--squash", - "--body", - "", - "-t", - &format!("prep release: v{}", self.version), - &format!("prep-{}", self.version), - ]) - .status()?; - - self.state = State::ReleaseFinalPRMerge2; - self.save()?; - - //FIXME: can we check the PR status with the gh command? - println!( - "{}", - style("Wait for the pre PR to merge into the release PR") - .bold() - .bright() - ); - - Ok(false) - } - - fn merge_final_release_pr2(&mut self) -> Result { - let git = which::which("git")?; - - // step 5 - let _output = std::process::Command::new(&git) - .args(["checkout", &self.version]) - .status()?; - let _output = std::process::Command::new(&git) - .args(["pull", &self.git_origin, &self.version]) - .status()?; - - // step 6 - // gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" pr ready "${APOLLO_ROUTER_RELEASE_VERSION}" - let gh = which::which("gh")?; - let _output = std::process::Command::new(&gh) - .args([ - "--repo", - self.github_repository.as_str(), - "pr", - "ready", - &self.version, - ]) - .status()?; - println!("{}", style("release PR marked as ready").bold().bright()); - - // step 7 - // gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" pr merge --merge --body "" -t "release: v${APOLLO_ROUTER_RELEASE_VERSION}" --auto "${APOLLO_ROUTER_RELEASE_VERSION}" - let _output = std::process::Command::new(&gh) - .args([ - "--repo", - self.github_repository.as_str(), - "pr", - "merge", - "--merge", - "--body", - "", - "-t", - &format!("release: v{}", self.version), - "--auto", - &self.version, - ]) - .status()?; - - println!( - "{}", - style("Wait for the release PR to merge into main") - .bold() - .bright() - ); - - self.state = State::WaitForMergeToMain; - self.save()?; - - Ok(false) - } - - fn tag_and_release(&mut self) -> Result { - println!("{}", style("Tagging and releasing").bold().bright()); - - let git = which::which("git")?; - - // step 9 - // git checkout main && \ - // git pull "${APOLLO_ROUTER_RELEASE_GIT_ORIGIN}" && \ - // git tag -a "v${APOLLO_ROUTER_RELEASE_VERSION}" -m "${APOLLO_ROUTER_RELEASE_VERSION}" && \ - // git push "${APOLLO_ROUTER_RELEASE_GIT_ORIGIN}" "v${APOLLO_ROUTER_RELEASE_VERSION}" - let _output = std::process::Command::new(&git) - .args(["checkout", "main"]) - .status()?; - let _output = std::process::Command::new(&git) - .args(["pull", &self.git_origin]) - .status()?; - let _output = std::process::Command::new(&git) - .args([ - "tag", - "-a", - &format!("v{}", self.version), - "-m", - &self.version, - ]) - .status()?; - let _output = std::process::Command::new(&git) - .args(["push", &self.git_origin, &format!("v{}", self.version)]) - .status()?; - - //step 10: reconciliation PR - //gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" pr create --title "Reconcile \`dev\` after merge to \`main\` for v${APOLLO_ROUTER_RELEASE_VERSION}" - // -B dev -H main --body "Follow-up to the v${APOLLO_ROUTER_RELEASE_VERSION} being officially released, bringing version bumps and changelog updates into the \`dev\` branch." - let gh = which::which("gh")?; - let _output = std::process::Command::new(&gh) - .args([ - "--repo", - self.github_repository.as_str(), - "pr", - "create", - "--title", - &format!("Reconcile `dev` after merge to `main` for v{}", self.version), - "-B", "dev", "-H", "main", "--body", - &format!("Follow-up to the v{} being officially released, bringing version bumps and changelog updates into the `dev` branch.", self.version) - ]) - .status()?; - println!("{}", style("dev reconciliation PR created").bold().bright()); - - // step 11: mark the PR as automerge - // APOLLO_RECONCILE_PR_URL=$(gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" pr list --state open --base dev --head main --json url --jq '.[-1] | .url') - // test -n "${APOLLO_RECONCILE_PR_URL}" && \ - // gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" pr merge --merge --auto "${APOLLO_RECONCILE_PR_URL}" - let output = std::process::Command::new(&gh) - .args([ - "--repo", - self.github_repository.as_str(), - "pr", - "list", - "--state", - "open", - "--base", - "dev", - "--head", - "main", - "--json", - "url", - "--jq", - ".[-1] | .url", - ]) - .output()?; - let url = std::str::from_utf8(&output.stdout)?; - println!( - "{}: {url}", - style("reconciliation PR URL: ").bold().bright() - ); - - let _output = std::process::Command::new(&gh) - .args([ - "--repo", - self.github_repository.as_str(), - "pr", - "merge", - "--merge", - "--auto", - url.trim(), - ]) - .status()?; - - println!("{}", style("🗣️ **Solicit approval from the Router team, wait for the reconciliation PR to pass CI and auto-merge into `dev`**").bold().bright()); - println!("{}", style("⚠️ **Wait for `publish_github_release` on CircleCI to finish on this job before continuing.** ⚠️").bold().bright()); - - self.state = State::WaitForReleasePublished; - self.save()?; - - Ok(false) - } - - fn update_release_notes(&self) -> Result { - println!("{}", style("Updating release notes").bold().bright()); - - // step 15 - //FIXME: replace this step with a template - let perl = which::which("perl")?; - let output = std::process::Command::new(&perl) - .args([ - "0777", - "-sne", - r#"print "$1\n" if m{ - (?:\#\s # Look for H1 Markdown (line starting with "\# ") - \[v?\Q$version\E\] # ...followed by [$version] (optionally with a "v") - # since some versions had that in the past. - \s.*?\n$) # ... then "space" until the end of the line. - \s* # Ignore PRE-entry-whitespace - (.*?) # Capture the ACTUAL body of the release. But do it - # in a non-greedy way, leading us to stop when we - # reach the next version boundary/heading. - \s* # Ignore POST-entry-whitespace - (?=^\#\s\[[^\]]+\]\s) # Once again, look for a version boundary. This is - # the same bit at the start, just on one line. - }msx"#, - "--", - "-version", - &self.version, - "CHANGELOG.md", - ]) - .output()?; - - let mut f = std::fs::File::create("this_release.md")?; - f.write_all(&output.stdout)?; - - //step 16 - //perl -pi -e 's/\[@([^\]]+)\]\([^)]+\)/@\1/g' this_release.md - let _output = std::process::Command::new(&perl) - .args([ - "-pi", - "-e", - r#"s/\[@([^\]]+)\]\([^)]+\)/@\1/g"#, - "this_release.md", - ]) - .status()?; - - // step 17 - // gh --repo "${APOLLO_ROUTER_RELEASE_GITHUB_REPO}" release edit v"${APOLLO_ROUTER_RELEASE_VERSION}" -F ./this_release.md - let gh = which::which("gh")?; - let _output = std::process::Command::new(&gh) - .args([ - "--repo", - self.github_repository.as_str(), - "release", - "edit", - &format!("v{}", self.version), - "-F", - "./this_release.md", - ]) - .status()?; - - // step 18 - println!( - "{}\ncargo publish -p apollo-federation@{}\ncargo publish -p apollo-router@{}", - style("manually publish the crates:").bold().bright(), - self.version, - self.version - ); - - // the release process is now finished, remove the release file - let path = Path::new(STATE_FILE); - std::fs::remove_file(path)?; - Ok(false) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -enum State { - Start, - ReleasePRCreate, - PreReleasePRChoose, - PreReleasePRCreate, - PreReleasePRGitAdd, - ReleaseFinalPRCreate, - ReleaseFinalPRGitAdd, - ReleaseFinalPRMerge, - ReleaseFinalPRMerge2, - WaitForMergeToMain, - WaitForReleasePublished, -} diff --git a/xtask/src/commands/test.rs b/xtask/src/commands/test.rs index dab1db991a..b614c43d87 100644 --- a/xtask/src/commands/test.rs +++ b/xtask/src/commands/test.rs @@ -25,6 +25,9 @@ pub struct Test { /// Pass --features to cargo test #[clap(long)] features: Option, + + /// Test name filters + filters: Vec, } impl Test { @@ -37,7 +40,7 @@ impl Test { // desired by the configuration, but not any other arguments. // In the event that cargo-nextest is not available, we will // fall back to cargo test and pass all the arguments. - if let Ok(_) = which::which("cargo-nextest") { + if which::which("cargo-nextest").is_ok() { let mut args = NEXTEST_DEFAULT_ARGS .iter() .map(|s| s.to_string()) @@ -56,8 +59,10 @@ impl Test { args.push(features.to_owned()); } + args.extend(self.filters.iter().cloned()); + cargo!(args); - return Ok(()); + Ok(()) } else { eprintln!("cargo-nextest not found, falling back to cargo test"); @@ -79,6 +84,8 @@ impl Test { args.push(jobs.to_string()); } + args.extend(self.filters.iter().cloned()); + args.push("--".to_string()); if let Some(threads) = self.test_threads { diff --git a/xtask/src/commands/unused.rs b/xtask/src/commands/unused.rs new file mode 100644 index 0000000000..828fe4954d --- /dev/null +++ b/xtask/src/commands/unused.rs @@ -0,0 +1,19 @@ +use std::process::Command; + +use anyhow::ensure; +use anyhow::Result; +use xtask::*; + +#[derive(Debug, clap::Parser)] +pub struct Unused {} + +impl Unused { + pub fn run(&self) -> Result<()> { + let cargo = which::which("cargo-machete")?; + let status = Command::new(&cargo) + .current_dir(&*PKG_PROJECT_ROOT) + .status()?; + ensure!(status.success(), "cargo machete failed"); + Ok(()) + } +} diff --git a/xtask/src/lib.rs b/xtask/src/lib.rs index 4dd0ef244b..9d966bf3c3 100644 --- a/xtask/src/lib.rs +++ b/xtask/src/lib.rs @@ -1,6 +1,5 @@ mod federation_demo; -use std::convert::TryFrom; use std::env; use std::process::Child; use std::process::Command; @@ -30,15 +29,14 @@ pub static PKG_VERSION: Lazy = Lazy::new(|| { let router = metadata .packages .iter() - .find(|x| x.name == "apollo-router") + .find(|x| x.name.as_str() == "apollo-router") .expect("could not find crate apollo-router"); router.version.to_string() }); pub static PKG_PROJECT_ROOT: Lazy = Lazy::new(|| { - let manifest_dir = - Utf8PathBuf::try_from(MANIFEST_DIR).expect("could not get the root directory."); + let manifest_dir = Utf8PathBuf::from(MANIFEST_DIR); let root_dir = manifest_dir .ancestors() .nth(1) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e52e383d3e..9c9c25ab36 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -31,6 +31,9 @@ pub enum Command { /// Check the code for licence and security compliance. CheckCompliance(commands::Compliance), + /// Check if all specified dependencies are actually in use. + Unused(commands::Unused), + /// Build Router's binaries for distribution. Dist(commands::Dist), @@ -64,6 +67,7 @@ impl Xtask { Command::All(command) => command.run(), Command::Changeset(command) => command.run(), Command::CheckCompliance(command) => command.run(), + Command::Unused(command) => command.run(), Command::Dist(command) => command.run(), Command::Dev(command) => command.run(), Command::Flame(command) => command.run(), From 628d31a5f5f110131e582705cdd706cfe48367f6 Mon Sep 17 00:00:00 2001 From: Juan Carlos Blanco Delgado Date: Tue, 26 Aug 2025 22:59:03 +0100 Subject: [PATCH 2/8] test(telemetry): update test for error on custom trace_id parsing #7909 --- apollo-router/src/plugins/telemetry/mod.rs | 29 ++-------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/apollo-router/src/plugins/telemetry/mod.rs b/apollo-router/src/plugins/telemetry/mod.rs index dfda213417..1a9e9c9268 100644 --- a/apollo-router/src/plugins/telemetry/mod.rs +++ b/apollo-router/src/plugins/telemetry/mod.rs @@ -3279,49 +3279,24 @@ mod tests { #[test] fn test_custom_trace_id_propagator_invalid_hex_characters() { use crate::test_harness::tracing_test; - let _guard = tracing_test::dispatcher_guard(); let header = String::from("x-trace-id"); - let invalid_trace_id = String::from("invalid-hex-chars"); + let invalid_trace_id = String::from("invalidhexchars"); let propagator = CustomTraceIdPropagator::new(header.clone(), TraceIdFormat::Uuid); let mut headers: HashMap = HashMap::new(); headers.insert(header, invalid_trace_id.clone()); let span = propagator.extract_span_context(&headers); - assert!(span.is_none()); - - // Verify that the error log contains both trace_id and error details - assert!(tracing_test::logs_contain( - "cannot generate custom trace_id" - )); - assert!(tracing_test::logs_contain(&invalid_trace_id)); - assert!(tracing_test::logs_contain("invalid digit found in string")); - } - - #[test] - fn test_custom_trace_id_propagator_invalid_length() { - use crate::test_harness::tracing_test; - let _guard = tracing_test::dispatcher_guard(); - - let header = String::from("x-trace-id"); - let invalid_trace_id = String::from("short"); - - let propagator = CustomTraceIdPropagator::new(header.clone(), TraceIdFormat::Uuid); - let mut headers: HashMap = HashMap::new(); - headers.insert(header, invalid_trace_id.clone()); - - let span = propagator.extract_span_context(&headers); assert!(span.is_none()); - // Verify that the error log contains both trace_id and error details assert!(tracing_test::logs_contain( "cannot generate custom trace_id" )); + assert!(tracing_test::logs_contain(&invalid_trace_id)); - assert!(tracing_test::logs_contain("invalid length")); } #[test] From 84a00bf17b356c18d2106491e09a9bd790af00b6 Mon Sep 17 00:00:00 2001 From: Juan Carlos Blanco Delgado <36451129+juancarlosjr97@users.noreply.github.com> Date: Tue, 2 Sep 2025 10:32:53 +0100 Subject: [PATCH 3/8] Update .changesets/feat_feat-enhance-error-logging-trace-id.md Co-authored-by: Coenen Benjamin --- ...eat_feat-enhance-error-logging-trace-id.md | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/.changesets/feat_feat-enhance-error-logging-trace-id.md b/.changesets/feat_feat-enhance-error-logging-trace-id.md index 8f4ce091e4..5463de0e75 100644 --- a/.changesets/feat_feat-enhance-error-logging-trace-id.md +++ b/.changesets/feat_feat-enhance-error-logging-trace-id.md @@ -7,40 +7,5 @@ This pull request improves logging in the `CustomTraceIdPropagator` implementati Logging enhancement: * [`apollo-router/src/plugins/telemetry/mod.rs`](diffhunk://#diff-37adf9e170c9b384f17336e5b5e5bf9cd94fd1d618b8969996a5ad56b635ace6L1927-R1927): Updated the error logging statement to include the `trace_id` and the error details as structured fields, providing more context for debugging. - - - ---- - -**Checklist** - -Complete the checklist (and note appropriate exceptions) before the PR is marked ready-for-review. - -- [x] PR description explains the motivation for the change and relevant context for reviewing -- [x] PR description links appropriate GitHub/Jira tickets (creating when necessary) -- [x] Changeset is included for user-facing changes -- [x] Changes are compatible[^1] -- [x] Documentation[^2] completed -- [x] Performance impact assessed and acceptable -- [x] Metrics and logs are added[^3] and documented -- Tests added and passing[^4] - - [x] Unit tests - - [ ] Integration tests - - [ ] Manual tests, as necessary - -**Exceptions** - -*Note any exceptions here* - -**Notes** - -Performance impact is minimal since this change only affects error logging for invalid trace_ids, which occurs only when malformed trace_ids are provided in requests. The enhanced logging adds structured fields to existing error messages without introducing any runtime overhead for valid trace_ids. - -Documentation was completed by adding context about trace_id format requirements and error handling in `docs/source/routing/observability/telemetry/index.mdx`, explaining the W3C Trace Context specification compliance and how the router handles incompatible or malformed trace_ids. - -[^1]: It may be appropriate to bring upcoming changes to the attention of other (impacted) groups. Please endeavour to do this before seeking PR approval. The mechanism for doing this will vary considerably, so use your judgement as to how and when to do this. -[^2]: Configuration is an important part of many changes. Where applicable please try to document configuration examples. -[^3]: A lot of (if not most) features benefit from built-in observability and `debug`-level logs. Please read [this guidance](https://github.com/apollographql/router/blob/dev/dev-docs/metrics.md#adding-new-metrics) on metrics best-practices. -[^4]: Tick whichever testing boxes are applicable. If you are adding Manual Tests, please document the manual testing (extensively) in the Exceptions. By [@juancarlosjr97](https://github.com/juancarlosjr97) in https://github.com/apollographql/router/pull/7910 From ce18a95d66804ad4a3f777d2935740b4e3cd74cc Mon Sep 17 00:00:00 2001 From: Juan Carlos Blanco Delgado Date: Tue, 2 Sep 2025 10:36:21 +0100 Subject: [PATCH 4/8] chore: update pull request reference for error logging enhancement #8149 --- .changesets/feat_feat-enhance-error-logging-trace-id.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changesets/feat_feat-enhance-error-logging-trace-id.md b/.changesets/feat_feat-enhance-error-logging-trace-id.md index 5463de0e75..1886a1c13e 100644 --- a/.changesets/feat_feat-enhance-error-logging-trace-id.md +++ b/.changesets/feat_feat-enhance-error-logging-trace-id.md @@ -8,4 +8,4 @@ Logging enhancement: * [`apollo-router/src/plugins/telemetry/mod.rs`](diffhunk://#diff-37adf9e170c9b384f17336e5b5e5bf9cd94fd1d618b8969996a5ad56b635ace6L1927-R1927): Updated the error logging statement to include the `trace_id` and the error details as structured fields, providing more context for debugging. -By [@juancarlosjr97](https://github.com/juancarlosjr97) in https://github.com/apollographql/router/pull/7910 +By [@juancarlosjr97](https://github.com/juancarlosjr97) in https://github.com/apollographql/router/pull/8149 From e9db8c7faf5c6b0a81b577925971d80be72e39f1 Mon Sep 17 00:00:00 2001 From: Juan Carlos Blanco Delgado Date: Tue, 2 Sep 2025 10:37:51 +0100 Subject: [PATCH 5/8] chore: update pull request reference for error logging enhancement #8149 --- .changesets/feat_feat-enhance-error-logging-trace-id.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changesets/feat_feat-enhance-error-logging-trace-id.md b/.changesets/feat_feat-enhance-error-logging-trace-id.md index 1886a1c13e..7ad99bfb92 100644 --- a/.changesets/feat_feat-enhance-error-logging-trace-id.md +++ b/.changesets/feat_feat-enhance-error-logging-trace-id.md @@ -1,4 +1,4 @@ -### fix(telemetry): improve error logging for custom trace_id generation ([PR #7910](https://github.com/apollographql/router/pull/7910)) +### fix(telemetry): improve error logging for custom trace_id generation ([PR #8149](https://github.com/apollographql/router/pull/8149)) #7909 From f737cacc04e2ef92830b4183a38c437748f51577 Mon Sep 17 00:00:00 2001 From: Juan Carlos Blanco Delgado Date: Tue, 2 Sep 2025 10:57:56 +0100 Subject: [PATCH 6/8] chore: update pull request reference for error logging enhancement #8149 --- .changesets/feat_feat-enhance-error-logging-trace-id.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changesets/feat_feat-enhance-error-logging-trace-id.md b/.changesets/feat_feat-enhance-error-logging-trace-id.md index 7ad99bfb92..5b344aa62b 100644 --- a/.changesets/feat_feat-enhance-error-logging-trace-id.md +++ b/.changesets/feat_feat-enhance-error-logging-trace-id.md @@ -1,6 +1,6 @@ ### fix(telemetry): improve error logging for custom trace_id generation ([PR #8149](https://github.com/apollographql/router/pull/8149)) -#7909 +#8149 This pull request improves logging in the `CustomTraceIdPropagator` implementation by enhancing the error message with additional context about the `trace_id` and the error. From 9de007fb104659c17a4e8733ef6c5aaebeeaacf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 5 Sep 2025 16:10:46 +0200 Subject: [PATCH 7/8] tighten up changeset --- .../feat_feat-enhance-error-logging-trace-id.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.changesets/feat_feat-enhance-error-logging-trace-id.md b/.changesets/feat_feat-enhance-error-logging-trace-id.md index 5b344aa62b..27a1311d13 100644 --- a/.changesets/feat_feat-enhance-error-logging-trace-id.md +++ b/.changesets/feat_feat-enhance-error-logging-trace-id.md @@ -1,11 +1,5 @@ -### fix(telemetry): improve error logging for custom trace_id generation ([PR #8149](https://github.com/apollographql/router/pull/8149)) +### Improve error logging for malformed Trace IDs ([PR #8149](https://github.com/apollographql/router/pull/8149)) -#8149 - -This pull request improves logging in the `CustomTraceIdPropagator` implementation by enhancing the error message with additional context about the `trace_id` and the error. - -Logging enhancement: - -* [`apollo-router/src/plugins/telemetry/mod.rs`](diffhunk://#diff-37adf9e170c9b384f17336e5b5e5bf9cd94fd1d618b8969996a5ad56b635ace6L1927-R1927): Updated the error logging statement to include the `trace_id` and the error details as structured fields, providing more context for debugging. +When the router receives an unparseable Trace ID in incoming requests, the logged error message now includes the invalid value. Trace IDs can be unparseable due to invalid hexadecimal characters, incorrect length, or non-standard formats. By [@juancarlosjr97](https://github.com/juancarlosjr97) in https://github.com/apollographql/router/pull/8149 From 14eb37cab548646ba61fef7de7f0369eb5c70c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 5 Sep 2025 16:14:18 +0200 Subject: [PATCH 8/8] delete superfluous file --- .changesets/sedceNmNQ | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .changesets/sedceNmNQ diff --git a/.changesets/sedceNmNQ b/.changesets/sedceNmNQ deleted file mode 100644 index e69de29bb2..0000000000

    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 J6{Tz*{9kPNZ><0T literal 0 HcmV?d00001 diff --git a/docs/source/reference/router/configuration.mdx b/docs/source/reference/router/configuration.mdx deleted file mode 100644 index 9438bb331b..0000000000 --- a/docs/source/reference/router/configuration.mdx +++ /dev/null @@ -1,1350 +0,0 @@ ---- -title: Router Configuration -subtitle: Configure a router via environment variables, command-line options, and YAML -description: Learn how to configure the Apollo GraphOS Router or Apollo Router Core with environment variables, command-line options and commands, and YAML configuration files. ---- - -Learn how to customize the behavior of your GraphOS Router or Apollo Router Core with environment variables, command-line commands and options, and YAML file configuration. - -## Environment variables - -If you're using the GraphOS Router with [managed federation](/federation/managed-federation/overview/) and GraphOS Studio, set these environment variables in the startup command: - -```bash -APOLLO_KEY="..." APOLLO_GRAPH_REF="..." ./router -``` - - - - - - - - - - - - - - - - - - - - - - - - - -
    Environment VariableDescription
    - -##### `APOLLO_GRAPH_REF` - - - -The graph ref for the GraphOS graph and variant that the router fetches its supergraph schema from (e.g., `docs-example-graph@staging`). - -**Required** when using [managed federation](/federation/managed-federation/overview/), except when using an [offline license](#--license) to run the router. - -
    - -##### `APOLLO_KEY` - - - -The [graph API key](/graphos/api-keys/#graph-api-keys) that the router should use to authenticate with GraphOS when fetching its supergraph schema. - -**Required** when using [managed federation](/federation/managed-federation/overview/), except when using an [offline license](#--license) to run the router or when using `APOLLO_KEY_PATH`. - -
    - -##### `APOLLO_KEY_PATH` - - - -⚠️ **This is not available on Windows.** - -A path to a file containing the [graph API key](/graphos/api-keys/#graph-api-keys) that the router should use to authenticate with GraphOS when fetching its supergraph schema. - -**Required** when using [managed federation](/federation/managed-federation/overview/), except when using an [offline license](#--license) to run the router or when using `APOLLO_KEY`. - -
    - -## Command-line options - -After [installing the Apollo Router Core](/router/quickstart) in your current working directory, you can run the router with the following example command: - -```bash -./router --config router.yaml --supergraph supergraph-schema.graphql -``` - -This reference lists and describes the options supported by the `router` binary. Where indicated, some of these options can also be provided via an environment variable. If an option is provided _both_ ways, the command-line value takes precedence. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Option / Environment VariableDescription
    - -##### `-s` / `--supergraph` - -`APOLLO_ROUTER_SUPERGRAPH_PATH`, `APOLLO_ROUTER_SUPERGRAPH_URLS` - - - -The [supergraph schema](/federation/federated-types/overview#supergraph-schema) of a router. Specified by absolute or relative path (`-s` / `--supergraph `, or `APOLLO_ROUTER_SUPERGRAPH_PATH`), or a comma-separated list of URLs (`APOLLO_ROUTER_SUPERGRAPH_URLS`). - -> 💡 Avoid embedding tokens in `APOLLO_ROUTER_SUPERGRAPH_URLS` because the URLs may appear in log messages. - -Setting this option disables polling from Apollo Uplink to fetch the latest supergraph schema. - -To learn how to compose your supergraph schema with the Rover CLI, see the [Federation quickstart](/federation/quickstart). - -**Required** if you are _not_ using managed federation. If you _are_ using managed federation, you may need to set this option when following [advanced deployment workflows](/federation/managed-federation/deployment/#advanced-deployment-workflows). - -
    - -##### `-c` / `--config` - -`APOLLO_ROUTER_CONFIG_PATH` - - - -The absolute or relative path to the router's optional [YAML configuration file](#yaml-config-file). - -
    - -##### `--apollo-key-path` - -`APOLLO_KEY_PATH` - - - -⚠️ **This is not available on Windows.** - -The absolute or relative path to a file containing the Apollo graph API key for use with managed federation. - -
    - -##### `--dev` - - - -⚠️ **Do not set this option in production!** - -If set, a router runs in dev mode to help with local development. - -[Learn more about dev mode.](#dev-mode-defaults) - -
    - -##### `--hr` / `--hot-reload` - -`APOLLO_ROUTER_HOT_RELOAD` - - - -If set, the router watches for changes to its configuration file and any supergraph file passed with `--supergraph` and reloads them automatically without downtime. This setting only affects local files provided to the router. The supergraph and configuration provided from GraphOS via Launches (and delivered via Uplink) are _always_ loaded automatically, regardless of this setting. - -
    - -##### `--log` - -`APOLLO_ROUTER_LOG` - - - -The log level, indicating the _most_ severe log message type to include. In ascending order of verbosity, can be one of: `off`, `error`, `warn`, `info`, `debug`, or `trace`. - -The default value is `info`. - -
    - -##### `--license` - -`APOLLO_ROUTER_LICENSE_PATH`, `APOLLO_ROUTER_LICENSE` - - - -An offline GraphOS Enterprise license. Enables Enterprise router features when disconnected from GraphOS. - -An offline license is specified either as an absolute or relative path to a license file (`--license ` or `APOLLO_ROUTER_LICENSE_PATH`), or as the stringified contents of a license (`APOLLO_ROUTER_LICENSE`). - -When not set, the router retrieves an Enterprise license [from GraphOS via Apollo Uplink](/router/enterprise-features/#the-enterprise-license). - -For information about fetching an offline license and configuring the router, see [Offline Enterprise license](/router/enterprise-features/#offline-enterprise-license). - -
    - -##### `--apollo-uplink-endpoints` - -`APOLLO_UPLINK_ENDPOINTS` - - - -If using [managed federation](/federation/managed-federation/overview/), the Apollo Uplink URL(s) that the router should poll to fetch its latest configuration. Almost all managed router instances should _omit_ this option to use the default set of Uplink URLs. - -If you specify multiple URLs, separate them with commas (no whitespace). - -For default behavior and possible values, see [Apollo Uplink](/federation/managed-federation/uplink/). - -
    - -##### `--apollo-uplink-poll-interval` - -`APOLLO_UPLINK_POLL_INTERVAL` - - - -The amount of time between polls to Apollo Uplink. - -The default value is `10s` (ten seconds), which is also the minimum allowed value. - -
    - -##### `--apollo-uplink-timeout` - -`APOLLO_UPLINK_TIMEOUT` - - - -The request timeout for each poll sent to Apollo Uplink. - -The default value is `30s` (thirty seconds). - -
    - -##### `--anonymous-telemetry-disabled` - -`APOLLO_TELEMETRY_DISABLED` - - - -If set, disables sending anonymous usage information to Apollo. - -
    - -##### `--listen` - -`APOLLO_ROUTER_LISTEN_ADDRESS` - - - -If set, the listen address of the router. - -
    - -##### `-V` / `--version` - - - -If set, the router prints its version number, then exits. - -
    - -##### `--schema` - - - -**Deprecated**—use [`./router config schema`](#configuration-awareness-in-your-text-editor) instead. - -If set, the router prints a JSON schema representation of its full supported configuration format, then exits. - -
    - -### Dev mode defaults - - - -**Do not set the `--dev` option in production.** If you want to replicate any specific dev mode functionality in production, instead make the corresponding modifications to your [YAML config file](#yaml-config-file). - - - -Setting the [`--dev`](#--dev) flag is equivalent to running `./router --hot-reload` with the following configuration options: - -```yaml -sandbox: - enabled: true -homepage: - enabled: false -supergraph: - introspection: true -include_subgraph_errors: - all: true -plugins: - # Enable with the header, Apollo-Expose-Query-Plan: true - experimental.expose_query_plan: true -``` - -## `config` subcommands - -GraphOS Router and Apollo Router Core provide a set of subcommands for interacting with its configuration. You run these subcommands with the following syntax: - -``` -./router config schema -./router config upgrade -``` - - - - - - - - - - - - - - - - - - - - - - -
    SubcommandDescription
    - -##### `schema` - - - -Prints a JSON schema representation of the router's full supported configuration format. - -Use this schema to enable [configuration awareness in your text editor](#configuration-awareness-in-your-text-editor). - -
    - -##### `upgrade` - - - -Takes a config file created for a _previous_ version of the router and outputs the corresponding configuration for the _current_ version. - -For details, see [Upgrading your router configuration](#upgrading-your-router-configuration). - -
    - -## YAML config file - -GraphOS Router and Apollo Router Core take an optional YAML configuration file as input via the [`--config`](#-c----config) option: - -```bash -./router --config router.yaml -``` - -This file enables you to customize numerous aspects of your router's behavior, covered in the subsections below. - -If you pass the [`--hot-reload`](#--hr----hot-reload) flag to the `router` command, your router automatically restarts whenever changes are made to its configuration file. - - - -Enable your text editor to validate the format and content of your router YAML configuration file by [configuring it with the router's configuration schema](#configuration-awareness-in-your-text-editor). - - - -### Listen address - -By default, the router starts an HTTP server that listens on `127.0.0.1:4000`. You can specify a different address by setting `supergraph.listen`: - -#### IPv4 - -```yaml title="router.yaml" -supergraph: - # The socket address and port to listen on - listen: 127.0.0.1:4000 -``` - -#### IPv6 - -```yaml title="router.yaml" -supergraph: - # The socket address and port to listen on. - # Note that this must be quoted to avoid interpretation as an array in YAML. - listen: '[::1]:4000' -``` - -#### Unix socket - - - -Listening on a Unix socket is not supported on Windows. - - - -```yaml title="router_unix.yaml" -supergraph: - # Absolute path to a Unix socket - listen: /tmp/router.sock -``` - -### Endpoint path - -By default, the router starts an HTTP server that exposes a `POST`/`GET` endpoint at path `/`. - -You can specify a different path by setting `supergraph.path`: - -```yaml title="router.yaml" -supergraph: - # The path for GraphQL execution - # (Defaults to /) - path: /graphql -``` - -The path must start with `/`. - -Path parameters and wildcards are supported. For example: - -- `/:my_dynamic_prefix/graphql` matches both `/my_project_a/graphql` and `/my_project_b/graphql`. -- `/graphql/*` matches `/graphql/my_project_a` and `/graphql/my_project_b`. -- `/g*` matches `/graphql`, `/gateway` and `/graphql/api`. - - - -The router does _not_ support wildcards in the _middle_ of a path (e.g., `/*/graphql`). Instead, use a path parameter (e.g., `/:parameter/graphql`). - - - -### Introspection - -By default, the router does _not_ resolve introspection queries. You can enable introspection like so: - -```yaml title="router.yaml" -# Do not enable introspection in production! -supergraph: - introspection: true -``` - -### Debugging - -- To configure logging, see [Logging in the router](/router/configuration/telemetry/exporters/logging/overview). - -- To configure the inclusion of subgraph errors, see [Subgraph error inclusion](/router/configuration/subgraph-error-inclusion). - -### Landing pages - -The router can serve any of the following landing pages to browsers that visit its [endpoint path](#endpoint-path): - -- A basic landing page that displays an example query `curl` command (default) - - ```yaml title="router.yaml" - # This is the default behavior. You don't need to include this config. - homepage: - enabled: true - ``` - -- _No_ landing page - - ```yaml title="router.yaml" - homepage: - enabled: false - ``` - -- [Apollo Sandbox](/graphos/explorer/sandbox), which enables you to explore your schema and compose operations against it using the Explorer - - Note the additional configuration required to use Sandbox: - - ```yaml title="router.yaml" - sandbox: - enabled: true - - # Sandbox uses introspection to obtain your router's schema. - supergraph: - introspection: true - - # Sandbox requires the default landing page to be disabled. - homepage: - enabled: false - ``` - - - - **Do not enable Sandbox in production.** Sandbox requires enabling introspection, which is strongly discouraged in production environments. - - - -### Subgraph routing URLs - -By default, the router obtains the routing URL for each of your subgraphs from the composed supergraph schema you provide it. In most cases, no additional configuration is required. The URL can use HTTP and HTTPS for network access to subgraph, or have the following shape for Unix sockets usage: `unix:///path/to/subgraph.sock` - -However, if you _do_ need to override a particular subgraph's routing URL (for example, to handle changing network topography), you can do so with the `override_subgraph_url` option: - -```yaml -override_subgraph_url: - organizations: http://localhost:8080 - accounts: "${env.ACCOUNTS_SUBGRAPH_HOST_URL}" -``` - -In this example, the `organizations` subgraph URL is overridden to point to `http://localhost:8080`, and the `accounts` subgraph URL is overridden to point to a new URL using [variable expansion](#variable-expansion). The URL specified in the supergraph schema is ignored. - -Any subgraphs that are _omitted_ from `override_subgraph_url` continue to use the routing URL specified in the supergraph schema. - -If you need to override the subgraph URL at runtime on a per-request basis, you can use [request customizations](/router/customizations/overview/#request-path) in the `SubgraphService` layer. - -### Caching - -By default, the router stores the following data in its in-memory cache to improve performance: - -- Generated query plans -- Automatic persisted queries (APQ) -- Introspection responses - -You can configure certain caching behaviors for generated query plans and APQ (but not introspection responses). For details, see [In-Memory Caching in the router](/router/configuration/in-memory-caching/). - -**If you have a GraphOS Enterprise plan:** -- You can configure a Redis-backed _distributed_ cache that enables multiple router instances to share cached values. For details, see [Distributed caching in the GraphOS Router](/router/configuration/distributed-caching/). -- You can configure a Redis-backed _entity_ cache that enables a client query to retrieve cached entity data split between subgraph reponses. For details, see [Subgraph entity caching in the GraphOS Router](/router/configuration/entity-caching/). - - - -### Native query planner - - - -Starting with v1.49.0, the router can run a Rust-native query planner. This native query planner can be run by itself to plan all queries, replacing the legacy JavaScript implementation. - - - -Starting with v1.57.0, to run the most performant and resource-efficient native query planner and to disable the V8 JavaScript runtime in the router, set the following options in your `router.yaml`: - -```yaml title="router.yaml" -experimental_query_planner_mode: new -``` - -You can also improve throughput by reducing the size of queries sent to subgraphs with the following option: - -```yaml title="router.yaml" -supergraph: - generate_query_fragments: true -``` - - - -Learn more in [Native Query Planner](/router/executing-operations/native-query-planner) docs. - - - -### Query planner pools - - - - - -You can improve the performance of the router's query planner by configuring parallelized query planning. - -By default, the query planner plans one operation at a time. It plans one operation to completion before planning the next one. This serial planning can be problematic when an operation takes a long time to plan and consequently blocks the query planner from working on other operations. - -To resolve such blocking scenarios, you can enable parallel query planning. Configure it in `router.yaml` with `supergraph.query_planning.experimental_parallelism`: - -```yaml title="router.yaml" -supergraph: - query_planning: - experimental_parallelism: auto # number of available cpus -``` - -The value of `experimental_parallelism` is the number of query planners in the router's _query planner pool_. A query planner pool is a preallocated set of query planners from which the router can use to plan operations. The total number of pools is the maximum number of query planners that can run in parallel and therefore the maximum number of operations that can be worked on simultaneously. - -Valid values of `experimental_parallelism`: -- Any integer starting from `1` -- The special value `auto`, which sets the number of query planners equal to the number of available CPUs on the router's host machine - -The default value of `experimental_parallelism` is `1`. - -In practice, you should tune `experimental_parallelism` based on metrics and benchmarks gathered from your router. - - - -### Enhanced operation signature normalization - - - - - -The router supports enhanced operation signature normalization in the following versions: - -- [General availability](/resources/product-launch-stages/#general-availability) in v1.54.0 and later -- [Experimental](/resources/product-launch-stages/#experimental-features) in v1.49.0 to v1.53.0 - - - -Apollo's legacy operation signature algorithm removes information about certain fields, such as input objects and aliases. -This removal means some operations may have the same normalized signature though they are distinct operations. - -Enhanced normalization incorporates [input types](#input-types) and [aliases](#aliases) in signature generation. -It also includes other improvements that make it more likely that two operations that only vary slightly have the same signature. - -Configure enhanced operation signature normalization in `router.yaml` with the `telemetry.apollo.signature_normalization_algorithm` option: - -```yaml title="router.yaml" -telemetry: - apollo: - signature_normalization_algorithm: enhanced # Default is legacy -``` - -Once you enable this configuration, operations with enhanced signatures might appear with different operation IDs than they did previously in GraphOS Studio. - -#### Input types - -Enhanced signatures include input object type shapes, while still redacting any actual values. -Legacy signatures [replace input object type with `{}`](/graphos/metrics/operation-signatures/#1-transform-in-line-argument-values). - -Given the following example operation: - -```graphql showLineNumbers=false -query InlineInputTypeQuery { - inputTypeQuery( - input: { - inputString: "foo", - inputInt: 42, - inputBoolean: null, - nestedType: { someFloat: 4.2 }, - enumInput: SOME_VALUE_1, - nestedTypeList: [ { someFloat: 4.2, someNullableFloat: null } ], - listInput: [1, 2, 3] - } - ) { - enumResponse - } -} -``` - -The legacy normalization algorithm generates the following signature: - -```graphql showLineNumbers=false -query InlineInputTypeQuery { - inputTypeQuery(input: {}) { - enumResponse - } -} -``` - -The enhanced normalization algorithm generates the following signature: - -```graphql {3-11} showLineNumbers=false -query InlineInputTypeQuery { - inputTypeQuery( - input: { - inputString: "", - inputInt: 0, - inputBoolean: null, - nestedType: {someFloat: 0}, - enumInput: SOME_VALUE_1, - nestedTypeList: [{someFloat: 0, someNullableFloat: null}], - listInput: [] - } - ) { - enumResponse - } -} -``` - -#### Aliases - -Enhanced signatures include any field aliases used in an operation. -Legacy signatures [remove aliases completely](/graphos/metrics/operation-signatures/#field-aliases), meaning the signature may be invalid if the same field was used with multiple aliases. - -Given the following example operation: - -```graphql showLineNumbers=false -query AliasedQuery { - noInputQuery { - interfaceAlias1: interfaceResponse { - sharedField - } - interfaceAlias2: interfaceResponse { - ... on InterfaceImplementation1 { - implementation1Field - } - ... on InterfaceImplementation2 { - implementation2Field - } - } - inputFieldAlias1: objectTypeWithInputField(boolInput: true) { - stringField - } - inputFieldAlias2: objectTypeWithInputField(boolInput: false) { - intField - } - } -} -``` - -The legacy normalization algorithm generates the following signature: - -```graphql showLineNumbers=false -query AliasedQuery { - noInputQuery { - interfaceResponse { - sharedField - } - interfaceResponse { - ... on InterfaceImplementation1 { - implementation1Field - } - ... on InterfaceImplementation2 { - implementation2Field - } - } - objectTypeWithInputField(boolInput: true) { - stringField - } - objectTypeWithInputField(boolInput: false) { - intField - } - } -} -``` - -The enhanced normalization algorithm generates the following signature: - -```graphql showLineNumbers=false -query AliasedQuery { - noInputQuery { - interfaceAlias1: interfaceResponse { - sharedField - } - interfaceAlias2: interfaceResponse { - ... on InterfaceImplementation1 { - implementation1Field - } - ... on InterfaceImplementation2 { - implementation2Field - } - } - inputFieldAlias1: objectTypeWithInputField(boolInput: true) { - stringField - } - inputFieldAlias2: objectTypeWithInputField(boolInput: false) { - intField - } - } -} -``` - - - -### Extended reference reporting - - - - - -The router supports extended reference reporting in the following versions: - -- [General availability](/resources/product-launch-stages/#general-availability) in v1.54.0 and later -- [Experimental](/resources/product-launch-stages/#experimental-features) in v1.50.0 to v1.53.0 - - - - - - -You can configure the router to report enum and input object references for enhanced insights and operation checks. -Apollo's legacy reference reporting doesn't include data about enum values and input object fields, meaning you can't view enum and input object field usage in GraphOS Studio. -Legacy reporting can also cause [inaccurate operation checks](#enhanced-operation-checks). - -Configure extended reference reporting in `router.yaml` with the `telemetry.apollo.metrics_reference_mode` option like so: - -```yaml title="router.yaml" -telemetry: - apollo: - metrics_reference_mode: extended # Default is legacy -``` -#### Configuration effect timing - -Once you configure extended reference reporting, you can view enum value and input field usage alongside object [field usage in GraphOS Studio](/graphos/metrics/field-usage) for all subsequent operations. - -Configuring extended reference reporting automatically turns on [enhanced operation checks](#enhanced-operation-checks), though you won't see an immediate change in your operations check behavior. - -This delay is because operation checks rely on historical operation data. -To ensure sufficient data to distinguish between genuinely unused values and those simply not reported in legacy data, enhanced checks require some operations with extended reference reporting turned on. - -#### Enhanced operation checks - -Thanks to extended reference reporting, operation checks can more accurately flag issues for changes to enum values and input object fields. See the comparison table below for differences between standard operation checks based on legacy reference reporting and enhanced checks based on extended reference reporting. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - Standard Check Behavior
    - (Legacy reference reporting) -
    - Enhanced Check Behavior
    - (Extended reference reporting) -
    - -##### Enum value removal - - Removing any enum values is considered a breaking change if any operations use the enum.Removing enum values is only a breaking change if historical operations use the specific enum value(s) that were removed.
    - -##### Default argument changes for input object fields - - - Changing or removing a default argument is generally considered a breaking change, but changing or removing default values for input object fields isn't. - - Changing or removing default values for input object fields is considered a breaking change. -You can [configure checks to ignore default values changes](/graphos/platform/schema-management/checks#ignored-conditions-settings). - -
    - -##### Nullable input object field removal - Removing a nullable input object field is always considered a breaking change.Removing a nullable input object field is only considered a breaking change if the nullable field is present in historical operations. If the nullable field is always omitted in historical operations, its removal isn't considered a breaking change.
    - -##### Changing nullable input object fields to non-nullable - - Changing a nullable input object field to non-nullable is considered a breaking change.Changing a nullable input object field to non-nullable is only considered a breaking change if the field had a null value in historical operations. If the field was always a non-null value in historical operations, changing it to non-nullable isn't considered a breaking change.
    - - - - -You won't see an immediate change in checks behavior when you first turn on extended reference reporting. -[Learn more.](#configuration-effect-timing) - - - -### Safelisting with persisted queries - -You can enhance your graph's security with GraphOS Router by maintaining a persisted query list (PQL), an operation safelist made by your first-party apps. As opposed to automatic persisted queries (APQ) where operations are automatically cached, operations must be preregistered to the PQL. Once configured, the router checks incoming requests against the PQL. - -See [Safelisting with persisted queries](/router/configuration/persisted-queries) for more information. - -### HTTP header rules - -See [Sending HTTP headers to subgraphs](/graphos/routing/header-propagation/). - -### Traffic shaping - -To configure the shape of traffic between clients, routers, and subgraphs, see [Traffic shaping in the router](/router/configuration/traffic-shaping). - -### Cross-Origin Resource Sharing (CORS) - -See [Configuring CORS in the router](/router/configuration/cors). - -### Defer support - -See [router support for `@defer`](/router/executing-operations/defer-support/#disabling-defer). - -### Query batching support - -See [GraphOS Router's support for query batching](/router/executing-operations/query-batching). - -### Subscription support - -See [GraphQL subscriptions in the GraphOS Router](/router/executing-operations/subscription-support/#router-setup). - -### Authorization support - -- To configure authorization directives, see [Authorization directives](/router/configuration/authorization/#authorization-directives). - -- To configure the authorization plugin, see [Configuration options](/router/configuration/authorization/#configuration-options). - -### JWT authentication - -To enable and configure JWT authentication, see [JWT authentication in the GraphOS Router](/router/configuration/authn-jwt). - -### Cross-site request forgery (CSRF) prevention - -To configure CSRF prevention, see [CSRF prevention in the router](/router/configuration/csrf). - -### Subgraph authentication - -To configure subgraph authentication with AWS SigV4, see a [configuration example](/router/configuration/authn-subgraph/#configuration-example). - -### External coprocessing - -See [External coprocessing in the GraphOS Router](/router/customizations/coprocessor/). - -### Telemetry and monitoring - -The router supports standard and custom instrumentation to collect telemetry data from its request and response processing pipeline to produce logs, metrics and traces to export. - -See the [router telemetry overview](/router/configuration/telemetry/overview). - -### TLS - -The router supports TLS to authenticate and encrypt communications, both on the client side and the subgraph side. It works automatically on the subgraph side if the subgraph URL starts with `https://`. - -TLS support is configured in the `tls` section, under the `supergraph` key for the client side, and the `subgraph` key for the subgraph side, with configuration possible for all subgraphs and overriding per subgraph. - -The list of supported TLS versions and algorithms is static, it cannot be configured. - -Supported TLS versions: -* TLS 1.2 -* TLS 1.3 - -Supported cipher suites: -* TLS13_AES_256_GCM_SHA384 -* TLS13_AES_128_GCM_SHA256 -* TLS13_CHACHA20_POLY1305_SHA256 -* TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 -* TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 -* TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 -* TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 -* TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 -* TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 - -Supported key exchange groups: -* X25519 -* SECP256R1 -* SECP384R1 - -#### TLS termination - -Clients can connect to the router directly over HTTPS, without terminating TLS in an intermediary. You can configure this in the `tls` configuration section: - -```yaml -tls: - supergraph: - certificate: ${file./path/to/certificate.pem} - certificate_chain: ${file./path/to/certificate_chain.pem} - key: ${file./path/to/key.pem} -``` - -To set the file paths in your configuration with Unix-style expansion, you can follow the examples in the [variable expansion](#variable-expansion) guide. - -The router expects the file referenced in the `certificate_chain` value to be a combination of several PEM certificates concatenated together into a single file (as is commonplace with Apache TLS configuration). - -#### Overriding certificate authorities for subgraphs - -The router verifies TLS connections to subgraphs using the list of certificate authorities the system provides. You can override this list with a combination of global and per-subgraph settings: - -```yaml -tls: - subgraph: - # Use these certificate authorities unless overridden per-subgraph - all: - certificate_authorities: "${file./path/to/ca.crt}" - # Override global setting for individual subgraphs - subgraphs: - products: - certificate_authorities: "${file./path/to/product_ca.crt}" -``` - -The router expects the file referenced in the `certificate_chain` value to be a combination of several PEM certificates concatenated together into a single file (as is commonplace with Apache TLS configuration). - -You can only configure these certificates via the router's configuration since using `SSL_CERT_FILE` also overrides certificates for sending telemetry and communicating with Apollo Uplink. - -If the subgraph is presenting a self-signed certificate, it must be generated with the proper file extension and with `basicConstraints` disabled. You can generate it with the following command line command from a certificate signing request, in this example, `server.csr`: - -``` -openssl x509 -req -in server.csr -signkey server.key -out server.crt -extfile v3.ext -``` - -You can generate a `v3.ext` extension file like so: - -``` -subjectKeyIdentifier = hash -authorityKeyIdentifier = keyid:always,issuer:always -# this has to be disabled -# basicConstraints = CA:TRUE -keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment, keyAgreement, keyCertSign -subjectAltName = DNS:local.apollo.dev -issuerAltName = issuer:copy -``` - - - -Make sure to change the `subjectAltName` field to the subgraph's name. - - - -This produces the file as `server.crt` which can be used in `certificate_authorities`. - -#### TLS client authentication for subgraph requests - -The router supports mutual TLS authentication (mTLS) with the subgraphs. This means that it can authenticate itself to the subgraph using a certificate chain and a cryptographic key. It can be configured as follows: - -```yaml -tls: - subgraph: - # Use these certificates and key unless overridden per-subgraph - all: - client_authentication: - certificate_chain: ${file./path/to/certificate_chain.pem} - key: ${file./path/to/key.pem} - # Override global setting for individual subgraphs - subgraphs: - products: - client_authentication: - certificate_chain: ${file./path/to/certificate_chain.pem} - key: ${file./path/to/key.pem} -``` - -#### Redis TLS configuration - -For Redis TLS connections, you can set up a client certificate or override the root certificate authority by configuring `tls` in your router's [YAML config file](https://www.apollographql.com/docs/router/overview/#yaml-config-file). For example: - -```yaml -apq: - router: - cache: - redis: - urls: [ "rediss://redis.example.com:6379" ] - #highlight-start - tls: - certificate_authorities: ${file./path/to/ca.crt} - client_authentication: - certificate_chain: ${file./path/to/certificate_chain.pem} - key: ${file./path/to/key.pem} - #highlight-end -``` - -### Request limits - -The GraphOS Router supports enforcing three types of request limits for enhanced security: - -- Network-based limits -- Lexical, parser-based limits -- Semantic, operation-based limits (this is an [Enterprise feature](/router/enterprise-features/)) - -The router rejects any request that violates at least one of these limits. - -```yaml title="router.yaml" -limits: - # Network-based limits - http_max_request_bytes: 2000000 # Default value: 2 MB - http1_max_request_headers: 200 # Default value: 100 - http1_max_request_buf_size: 800kib # Default value: 400kib - - # Parser-based limits - parser_max_tokens: 15000 # Default value - parser_max_recursion: 500 # Default value - - # Operation-based limits (Enterprise only) - max_depth: 100 - max_height: 200 - max_aliases: 30 - max_root_fields: 20 -``` - -#### Operation-based limits (Enterprise only) - -See [this article](/router/configuration/operation-limits/). - -#### Network-based limits - -##### `http_max_request_bytes` - -Limits the amount of data read from the network for the body of HTTP requests, -to protect against unbounded memory consumption. -This limit is checked before JSON parsing. -Both the GraphQL document and associated variables count toward it. - -The default value is `2000000` bytes, 2 MB. - -Before increasing this limit significantly consider testing performance -in an environment similar to your production, especially if some clients are untrusted. -Many concurrent large requests could cause the router to run out of memory. - -##### `http1_max_request_headers` - -Limit the maximum number of headers of incoming HTTP1 requests. -The default value is 100 headers. - -If router receives more headers than the buffer size, it responds to the client with `431 Request Header Fields Too Large`. - -##### `http1_max_request_buf_size` - -Limit the maximum buffer size for the HTTP1 connection. Default is ~400kib. - -Note for Rust Crate Users: If you are using the Router as a Rust crate, the `http1_request_max_buf_size` option requires the `hyper_header_limits` feature and also necessitates using Apollo's fork of the Hyper crate until the [changes are merged upstream](https://github.com/hyperium/hyper/pull/3523). -You can include this fork by adding the following patch to your Cargo.toml file: -```toml -[patch.crates-io] -"hyper" = { git = "https://github.com/apollographql/hyper.git", tag = "header-customizations-20241108" } -``` - -#### Parser-based limits - -##### `parser_max_tokens` - -Limits the number of tokens a query document can include. This counts _all_ tokens, including both [lexical and ignored tokens](https://spec.graphql.org/October2021/#sec-Language.Source-Text.Lexical-Tokens). - -The default value is `15000`. - -##### `parser_max_recursion` - -Limits the deepest level of recursion allowed by the router's GraphQL parser to prevent stack overflows. This corresponds to the deepest nesting level of any single GraphQL operation or fragment defined in a query document. - -The default value is `500`. - -In the example below, the `GetProducts` operation has a recursion of three, and the `ProductVariation` fragment has a recursion of two. Therefore, the _max_ recursion of the query document is three. - -```graphql -query GetProducts { - allProducts { #1 - ...productVariation - delivery { #2 - fastestDelivery #3 - } - } -} - -fragment ProductVariation on Product { - variation { #1 - name #2 - } -} -``` - -Note that the router calculates the recursion depth for each operation and fragment _separately_. Even if a fragment is included in an operation, that fragment's recursion depth does not contribute to the _operation's_ recursion depth. - -### Demand control - -See [Demand Control](/router/executing-operations/demand-control) to learn how to analyze the cost of operations and to reject requests with operations that exceed customizable cost limits. - -### Early cancel - -Up until [Apollo Router Core v1.43.1](https://github.com/apollographql/router/releases/tag/v1.43.1), when the client closed the connection without waiting for the response, the entire request was cancelled and did not go through the entire pipeline. Since this causes issues with request monitoring, the router introduced a new behavior in 1.43.1. Now, the entire pipeline is executed if the request is detected as cancelled, but subgraph requests are not actually done. The response will be reported with the `499` status code, but not actually sent to the client. -To go back to the previous behavior of immediately cancelling the request, the following configuration can be used: - -```yaml -supergraph: - early_cancel: true -``` - -Additionally, since v1.43.1, the router can show a log when it detects that the client canceled the request. This log can be activated with: - -```yaml title="router.yaml" -supergraph: - experimental_log_on_broken_pipe: true -``` - - -### Plugins - -You can customize the router's behavior with [plugins](/router/customizations/overview). Each plugin can have its own section in the configuration file with arbitrary values: - -```yaml {4,8} title="example-plugin-router.yaml" -plugins: - example.plugin: - var1: "hello" - var2: 1 -``` - -### Variable expansion - -You can reference variables directly in your YAML config file. This is useful for referencing secrets without including them in the file. - -Currently, the router supports expansion of environment variables and file paths. Corresponding variables are prefixed with `env.` and `file.`, respectively. - -The router uses Unix-style expansion. Here are some examples: - -- `${env.ENV_VAR_NAME}` expands to the value of environment variable `ENV_VAR_NAME`. -- `${env.ENV_VAR_NAME:-some_default}` expands to the value of environment variable `ENV_VAR_NAME`, or falls back to the value `some_default` if the environment variable is not defined. -- `${file.a.txt}` expands to the contents of the file `a.txt`. -- `${file.a.txt:-some_default}` expands to the contents of the file `a.txt`, or falls back to the value `some_default` if the file does not exist. - -Variable expansions are valid only for YAML _values_, not keys: - - -```yaml -supergraph: - listen: "${env.MY_LISTEN_ADDRESS}" #highlight-line -example: - password: "${env.MY_PASSWORD}" #highlight-line -``` - - -
    -### Fragment generation and reuse - -By default, the router compresses subgraph requests by generating fragment -definitions based on the shape of the subgraph operation. In many cases this -significantly reduces the size of the query sent to subgraphs. - -The router also supports an experimental algorithm that attempts to reuse fragments -from the original operation while forming subgraph requests. This experimental feature -used to be enabled by default, but is still available to support subgraphs that rely -on the specific shape of fragments in an operation: - -```yaml -supergraph: - generate_query_fragments: false - experimental_reuse_query_fragments: true -``` - -Note that `generate_query_fragments` and `experimental_reuse_query_fragments` are -mutually exclusive; if both are explicitly set to `true`, `generate_query_fragments` -will take precedence. - - - -In the future, the `generate_query_fragments` option will be the only option for handling fragments. - - - -### Reusing configuration - -You can reuse parts of your configuration file in multiple places using standard YAML aliasing syntax: - -```yaml title="router.yaml" -headers: - subgraphs: - products: - request: - - insert: &insert_custom_header - name: "custom-header" - value: "something" - reviews: - request: - - insert: *insert_custom_header -``` - -Here, the `name` and `value` entries under `&insert_custom_header` are reused under `*insert_custom_header`. - -## Configuration awareness in your text editor - -The router can generate a JSON schema for config validation in your text editor. This schema helps you format the YAML file correctly and also provides content assist. - -Generate the schema with the following command: - -```bash -./router config schema > configuration_schema.json -``` - -After you generate the schema, configure your text editor. Here are the instructions for some commonly used editors: - -- [Visual Studio Code](https://code.visualstudio.com/docs/languages/json#_json-schemas-and-settings) -- [Emacs](https://emacs-lsp.github.io/lsp-mode/page/lsp-yaml) -- [IntelliJ](https://www.jetbrains.com/help/idea/json.html#ws_json_using_schemas) -- [Sublime](https://github.com/sublimelsp/LSP-yaml) -- [Vim](https://github.com/Quramy/vison) - -## Upgrading your router configuration - -New releases of the router might introduce breaking changes to the [YAML config file's](#yaml-config-file) expected format, usually to extend existing functionality or improve usability. - -**If you run a new version of your router with a configuration file that it no longer supports:** - -1. The router emits a warning on startup. -2. The router attempts to translate your provided configuration to the new expected format. - - If the translation succeeds without errors, the router starts up as usual. - - If the translation fails, the router terminates. - -If you encounter this warning, you can use the `router config upgrade` command to see the new expected format for your existing configuration file: - -```bash -./router config upgrade -``` - -You can also view a diff of exactly which changes are necessary to upgrade your existing configuration file: - -```bash -./router config upgrade --diff -``` - -## Related topics - -* [Checklist for configuring the router for production](/technotes/TN0008-production-readiness-checklist/#apollo-router) diff --git a/docs/source/reference/router/errors.mdx b/docs/source/reference/router/errors.mdx deleted file mode 100644 index 6878ea20e0..0000000000 --- a/docs/source/reference/router/errors.mdx +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: Router Errors -subtitle: Error and status codes returned by GraphOS Router and Apollo Router Core -description: Reference of error codes and HTTP status codes returned by Apollo GraphOS Router and Apollo Router Core, including explanations and solutions. ---- - -Learn about error codes and HTTP response status codes returned by GraphOS Router and Apollo Router Core. - -## Status codes - - - - -A request failed GraphQL validation or failed to be parsed. - - - - -Requests may receive this response in two cases: - -- For a client request that requires authentication, the client's JWT failed verification. -- For a non-client subscription endpoint calling a subscription callback URL, the router couldn't find a matching subscription identifier between its registered subscriptions and a subscription event. - - - - - - - -Both mutations and subscriptions must use POST. - - - - - - -A request's HTTP `Accept` header didn't contain any of the router's supported mime-types: -- `application/json` -- `application/graphql-response+json` -- `multipart/mixed;deferSpec=20220824` -- `multipart/mixed;subscriptionSpec=1.0`. - - - - - -Request traffic exceeded configured rate limits. See [client side traffic shaping](/router/configuration/traffic-shaping/#client-side-traffic-shaping). - - - - -The request was canceled because the client closed the connection, possibly due to a client side timeout. - - - - -The router encountered an unexpected issue. [Report](https://github.com/apollographql/router/issues/new?assignees=&labels=raised+by+user&projects=&template=bug_report.md&title=) this possible bug to the router team. - - - - - -The request was not able to complete within a configured amount of time. See [client side traffic shaping timeouts](/router/configuration/traffic-shaping/#timeouts). - - - - - - -You can create Rhai scripts that throw custom status codes. See [Terminating client requests](/graphos/reference/router/rhai#terminating-client-requests) to learn more. - - - -## Error codes - -### Demand control - -Errors returned by the router when [demand control](/router/executing-operations/demand-control) is enabled. - - - - -The estimated cost of the query was greater than the configured maximum cost. - - - - -The actual cost of the query was greater than the configured maximum cost. - - - - -The query could not be parsed. - - - - -The response from a subgraph did not match the GraphQL schema. - - - - -A subgraph returned a field with a different type that mandated by the GraphQL schema. - - - - diff --git a/docs/source/reference/router/self-hosted-install.mdx b/docs/source/reference/router/self-hosted-install.mdx deleted file mode 100644 index c6711568ba..0000000000 --- a/docs/source/reference/router/self-hosted-install.mdx +++ /dev/null @@ -1,274 +0,0 @@ ---- -title: Router Quickstart -subtitle: Run the router with GraphOS and Apollo-hosted subgraphs -description: This quickstart tutorial walks you through installing the Apollo GraphOS Router or Apollo Router Core and running it with GraphOS and some example Apollo-hosted subgraphs. ---- - -import ElasticNotice from "../../../shared/elastic-notice.mdx"; - -Hello! This tutorial walks you through installing the router (GraphOS Router or Apollo Router Core) and running it in with GraphOS and some example Apollo-hosted subgraphs. - -> **This quickstart helps you run a _self-hosted_ instance of the router.** If you [create a cloud supergraph](/graphos/quickstart/cloud/) with Apollo GraphOS, Apollo provisions and hosts your supergraph's GraphOS -> Router for you. -> -> Cloud supergraphs are recommended for organizations that don't need to host their router in their own infrastructure. - -## 1. Download and extract the router binary - - - -### Download options - -#### Automatic download (Linux, OSX, WSL) - -If you have a bash-compatible terminal, you can download the latest version of the Apollo Router Core directly to your current directory with the following command: - -```bash -curl -sSL https://router.apollo.dev/download/nix/latest | sh -``` - -#### Manual download - -Go to the Apollo Router Core's [GitHub Releases page](https://github.com/apollographql/router/releases) and download the latest `.tar.gz` file that matches your system. Currently, tarballs are available for the following: - -- Linux (x86_64) -- Linux (aarch64) -- macOS (Apple Silicon) -- Windows (x86_64) - -If a tarball for your system or architecture isn't available, you can [build and run the router from source](https://github.com/apollographql/router/blob/HEAD/DEVELOPMENT.md#development-1). You can also [open an issue on GitHub](https://github.com/apollographql/router/issues/new/choose) to request the addition of new architectures. - -After downloading, extract the file by running the following from a new project directory, substituting the path to the tarball: - -```bash -tar -xf path/to/file.tar.gz --strip-components=1 -``` - -If you omit the `--strip-components=1` option, the `router` executable is installed in a `dist` subdirectory. - -### Running the binary - -You can now run the router from your project's root directory with the following command: - -```bash -./router -``` - -If you do, you'll get output similar to the following: - -``` -Apollo Router // (c) Apollo Graph, Inc. // Licensed as ELv2 (https://go.apollo.dev/elv2) - -⚠️ The Apollo Router requires a composed supergraph schema at startup. ⚠️ - -👉 DO ONE: - - * Pass a local schema file with the '--supergraph' option: - - $ ./router --supergraph - - * Fetch a registered schema from GraphOS by setting - these environment variables: - - $ APOLLO_KEY="..." APOLLO_GRAPH_REF="..." ./router - - For details, see the Apollo docs: - https://www.apollographql.com/docs/federation/managed-federation/setup - -🔬 TESTING THINGS OUT? - - 1. Download an example supergraph schema with Apollo-hosted subgraphs: - - $ curl -L https://supergraph.demo.starstuff.dev/ > starstuff.graphql - - 2. Run the router in development mode with the supergraph schema: - - $ ./router --dev --supergraph starstuff.graphql -``` - -This is because router requires a supergraph schema and we aren't providing it one! Let's fix that. - -## 2. Download the example supergraph schema - -For this quickstart, we're using example Apollo-hosted subgraphs, along with an example supergraph schema that's composed from those subgraph schemas. - -From your project's root directory, run the following: - -```bash -curl -sSL https://supergraph.demo.starstuff.dev/ > supergraph-schema.graphql -``` - -This saves a `supergraph-schema.graphql` file with the following contents: - - - -```graphql title="supergraph-schema.graphql" -schema - @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { - query: Query - mutation: Mutation -} - -directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE - -directive @join__field( - graph: join__Graph - requires: join__FieldSet - provides: join__FieldSet - type: String - external: Boolean - override: String - usedOverridden: Boolean -) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION - -directive @join__graph(name: String!, url: String!) on ENUM_VALUE - -directive @join__implements( - graph: join__Graph! - interface: String! -) repeatable on OBJECT | INTERFACE - -directive @join__type( - graph: join__Graph! - key: join__FieldSet - extension: Boolean! = false - resolvable: Boolean! = true - isInterfaceObject: Boolean! = false -) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR - -directive @join__unionMember( - graph: join__Graph! - member: String! -) repeatable on UNION - -directive @link( - url: String - as: String - for: link__Purpose - import: [link__Import] -) repeatable on SCHEMA - -scalar join__FieldSet - -enum join__Graph { - ACCOUNTS - @join__graph(name: "accounts", url: "https://accounts.demo.starstuff.dev/") - INVENTORY - @join__graph( - name: "inventory" - url: "https://inventory.demo.starstuff.dev/" - ) - PRODUCTS - @join__graph(name: "products", url: "https://products.demo.starstuff.dev/") - REVIEWS - @join__graph(name: "reviews", url: "https://reviews.demo.starstuff.dev/") -} - -scalar link__Import - -enum link__Purpose { - """ - `SECURITY` features provide metadata necessary to securely resolve fields. - """ - SECURITY - - """ - `EXECUTION` features provide metadata necessary for operation execution. - """ - EXECUTION -} - -type Mutation @join__type(graph: PRODUCTS) @join__type(graph: REVIEWS) { - createProduct(upc: ID!, name: String): Product @join__field(graph: PRODUCTS) - createReview(upc: ID!, id: ID!, body: String): Review - @join__field(graph: REVIEWS) -} - -type Product - @join__type(graph: ACCOUNTS, key: "upc", extension: true) - @join__type(graph: INVENTORY, key: "upc") - @join__type(graph: PRODUCTS, key: "upc") - @join__type(graph: REVIEWS, key: "upc") { - upc: String! - weight: Int - @join__field(graph: INVENTORY, external: true) - @join__field(graph: PRODUCTS) - price: Int - @join__field(graph: INVENTORY, external: true) - @join__field(graph: PRODUCTS) - inStock: Boolean @join__field(graph: INVENTORY) - shippingEstimate: Int @join__field(graph: INVENTORY, requires: "price weight") - name: String @join__field(graph: PRODUCTS) - reviews: [Review] @join__field(graph: REVIEWS) - reviewsForAuthor(authorID: ID!): [Review] @join__field(graph: REVIEWS) -} - -type Query - @join__type(graph: ACCOUNTS) - @join__type(graph: INVENTORY) - @join__type(graph: PRODUCTS) - @join__type(graph: REVIEWS) { - me: User @join__field(graph: ACCOUNTS) - recommendedProducts: [Product] @join__field(graph: ACCOUNTS) - topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCTS) -} - -type Review @join__type(graph: REVIEWS, key: "id") { - id: ID! - body: String - author: User @join__field(graph: REVIEWS, provides: "username") - product: Product -} - -type User - @join__type(graph: ACCOUNTS, key: "id") - @join__type(graph: REVIEWS, key: "id") { - id: ID! - name: String @join__field(graph: ACCOUNTS) - username: String - @join__field(graph: ACCOUNTS) - @join__field(graph: REVIEWS, external: true) - reviews: [Review] @join__field(graph: REVIEWS) -} -``` - - - -This file is all that the router needs to communicate with our subgraphs! - -## 3. Run the router in development mode with the default configuration - -Now from your project root, run the following: - -```sh -./router --dev --supergraph supergraph-schema.graphql -``` - -The console output should look like the following: - -```sh -2022-06-29T22:23:24.266542Z INFO apollo_router::executable: Apollo Router v0.9.5 // (c) Apollo Graph, Inc. // Licensed as ELv2 (https://go.apollo.dev/elv2) -2022-06-29T22:23:24.488286Z INFO apollo_router::router: starting Apollo Router -2022-06-29T22:23:25.774334Z INFO apollo_router::axum_http_server_factory: GraphQL endpoint exposed at http://127.0.0.1:4000/ 🚀 -``` - -That's it! Running the router with the `--dev` flag enables a development mode that exposes [Apollo Sandbox](/graphos/explorer/sandbox/) so you can run queries against the router. - - - -**Do not use the `--dev` flag in a non-development environment.** It relaxes certain default configuration options to provide an improved local development experience (e.g., it exposes subgraph error messages to clients). - -[Learn more about dev mode defaults.](/router/configuration/overview#dev-mode-defaults) - - - -Visit `http://127.0.0.1:4000` to open Apollo Sandbox, inspect your entire supergraph, and run your first queries! - -## Next steps - -Now that you know how to run the router with a supergraph schema, you can: - -- Set up [managed federation](/federation/managed-federation/overview) -- Learn about [additional configuration options](/router/configuration/overview) -- [Estimate the system resources needed to deploy the router](/technotes/TN0045-router_resource_estimator/). diff --git a/docs/source/reference/router/telemetry/instrumentation/selectors.mdx b/docs/source/reference/router/telemetry/instrumentation/selectors.mdx deleted file mode 100644 index 580ed124ca..0000000000 --- a/docs/source/reference/router/telemetry/instrumentation/selectors.mdx +++ /dev/null @@ -1,111 +0,0 @@ ---- -title: Selectors -subtitle: Select data from the router pipeline to extract -description: Extract and select data from the Apollo GraphOS Router's pipeline services to attach to telemetry. ---- -import RouterServices from '../../../../../shared/router-lifecycle-services.mdx'; - -A _selector_ is used to extract data from the GraphOS Router's request lifecycle (pipeline) services and attach them to telemetry, specifically [spans](/router/configuration/telemetry/instrumentation/spans), [instruments](/router/configuration/telemetry/instrumentation/instruments), [conditions](/router/configuration/telemetry/instrumentation/conditions) and [events](/router/configuration/telemetry/instrumentation/events). - -An example of a selector, `request_header`, of the router service on a custom span attribute: - -```yaml title="router.yaml" -telemetry: - instrumentation: - spans: - router: - attributes: - "my_attribute": - # ... - request_header: "x-my-header" #highlight-line -``` - -## Selector configuration reference - -Each service of the router pipeline (`router`, `supergraph`, `subgraph`) has its own available selectors. -You can also extract GraphQL metrics from the response data the router returns to clients. - -### Router - -The router service is the initial entrypoint for all requests. It is HTTP centric and deals with opaque bytes. - -| Selector | Defaultable | Values | Description | -|-----------------------|-------------|-----------------------------|----------------------------------------------------------------------| -| `trace_id` | Yes | `open_telemetry`\|`datadog` | The trace ID | -| `operation_name` | Yes | `string`\|`hash` | The operation name from the query | -| `studio_operation_id` | Yes | `true`\|`false` | The Apollo Studio operation id | -| `request_header` | Yes | | The name of the request header | -| `request_context` | Yes | | The name of a request context key | -| `response_header` | Yes | | The name of a response header | -| `response_status` | Yes | `code`\|`reason` | The response status | -| `response_context` | Yes | | The name of a response context key | -| `baggage` | Yes | | The name of a baggage item | -| `env` | Yes | | The name of an environment variable | -| `on_graphql_error` | No | `true`\|`false` | Boolean set to true if the response payload contains a GraphQL error | -| `static` | No | | A static string value | -| `error` | No | `reason` | a string value containing error reason when it's a critical error | - -### Supergraph - -The supergraph service is executed after query parsing but before query execution. It is GraphQL centric and deals with GraphQL queries and responses. - -| Selector | Defaultable | Values | Description | -|--------------------|-------------|-------------------------------------------------------|-----------------------------------------------------------------------------------| -| `operation_name` | Yes | `string`\|`hash` | The operation name from the query | -| `operation_kind` | No | `string` | The operation kind from the query | -| `query` | Yes | `string`\|`aliases`\|`depth`\|`height`\|`root_fields` | The GraphQL query | -| `query_variable` | Yes | | The name of a GraphQL query variable | -| `request_header` | Yes | | The name of a request header | -| `response_header` | Yes | | The name of a response header | -| `is_primary_response` | No | `true`\|`false` | Boolean returning true if it's the primary response and not events like subscription events or deferred responses | -| `response_data` | Yes | | JSON Path into the supergraph response body data (it might impact performance) | -| `response_errors` | Yes | | JSON Path into the supergraph response body errors (it might impact performance) | -| `request_context` | Yes | | The name of a request context key | -| `response_context` | Yes | | The name of a response context key | -| `on_graphql_error` | No | `true`\|`false` | Boolean set to true if the response payload contains a GraphQL error | -| `baggage` | Yes | | The name of a baggage item | -| `env` | Yes | | The name of an environment variable | -| `static` | No | | A static string value | -| `error` | No | `reason` | A string value containing error reason when it's a critical error | - -### Subgraph - -The subgraph service executes multiple times during query execution, with each execution representing a call to a single subgraph. It is GraphQL centric and deals with GraphQL queries and responses. - -| Selector | Defaultable | Values | Description | -|-----------------------------|-------------|------------------|--------------------------------------------------------------------------------| -| `subgraph_operation_name` | Yes | `string`\|`hash` | The operation name from the subgraph query | -| `subgraph_operation_kind` | No | `string` | The operation kind from the subgraph query | -| `subgraph_query` | Yes | `string` | The GraphQL query to the subgraph | -| `subgraph_name` | No | `true`\|`false` | The subgraph name | -| `subgraph_query_variable` | Yes | | The name of a subgraph query variable | -| `subgraph_response_data` | Yes | | JSON Path into the subgraph response body data (it might impact performance) | -| `subgraph_response_errors` | Yes | | JSON Path into the subgraph response body errors (it might impact performance) | -| `subgraph_request_header` | Yes | | The name of a subgraph request header | -| `subgraph_response_header` | Yes | | The name of a subgraph response header | -| `subgraph_response_status` | Yes | `code`\|`reason` | The status of a subgraph response | -| `subgraph_on_graphql_error` | No | `true`\|`false` | Boolean set to true if the subgraph response payload contains a GraphQL error | -| `supergraph_operation_name` | Yes | `string`\|`hash` | The operation name from the supergraph query | -| `supergraph_operation_kind` | Yes | `string` | The operation kind from the supergraph query | -| `supergraph_query` | Yes | `string` | The graphql query to the supergraph | -| `supergraph_query_variable` | Yes | | The name of a supergraph query variable | -| `request_context` | Yes | | The name of a request context key | -| `response_context` | Yes | | The name of a response context key | -| `baggage` | Yes | | The name of a baggage item | -| `env` | Yes | | The name of an environment variable | -| `static` | No | | A static string value | -| `error` | No | `reason` | A string value containing error reason when it's a critical error | -| `cache` | No | `hit`\|`miss` | Returns the number of cache hit or miss for this subgraph request | - -### GraphQL - -GraphQL metrics are extracted from the response data the router returns to client requests. - -| Selector | Defaultable | Values | Description | -|------------------|-------------|------------------|---------------------------------------------| -| `list_length` | No | `value` | The length of a list from the response data | -| `field_name` | No | `string` | The name of a field from the response data | -| `field_type` | No | `string` | The type of a field from the response data | -| `type_name` | No | | The GraphQL type from the response data | -| `operation_name` | Yes | `string`\|`hash` | The operation name of the query | -| `static` | No | | A static string value | diff --git a/docs/source/reference/router/telemetry/instrumentation/standard-instruments.mdx b/docs/source/reference/router/telemetry/instrumentation/standard-instruments.mdx deleted file mode 100644 index bf0101d802..0000000000 --- a/docs/source/reference/router/telemetry/instrumentation/standard-instruments.mdx +++ /dev/null @@ -1,124 +0,0 @@ ---- -title: Router Instruments -subtitle: Standard metric instruments for the router's request lifecycle -description: Reference of standard metric instruments for the request lifecycle of GraphOS Router and Apollo Router Core. Consumable via the router's metrics exporters. ---- - -## Standard metric instruments - -GraphOS Router and Apollo Router Core provide a set of non-configurable metric instruments that expose detailed information about the router's request lifecycle. - -These instruments can be consumed by configuring a [metrics exporter](/router/configuration/telemetry/exporters/metrics/overview). - -### HTTP - -- `apollo_router_http_request_duration_seconds_bucket` - HTTP router request duration -- `apollo_router_http_request_duration_seconds_bucket` - HTTP subgraph request duration, attributes: - - `subgraph`: (Optional) The subgraph being queried -- `apollo_router_http_requests_total` - Total number of HTTP requests by HTTP status -- `apollo_router_timeout` - Number of triggered timeouts -- `apollo_router_http_request_retry_total` - Number of subgraph requests retried, attributes: - - `subgraph`: The subgraph being queried - - `status` : If the retry was aborted (`aborted`) - -### GraphQL - -- `apollo_router_graphql_error` - counts GraphQL errors in responses, attributes: - - `code`: error code - -### Session - -- `apollo_router_session_count_total` - Number of currently connected clients -- `apollo_router_session_count_active` - Number of in-flight GraphQL requests - -### Cache - -- `apollo_router_cache_size` — Number of entries in the cache -- `apollo_router_cache_hit_count` - Number of cache hits -- `apollo_router_cache_miss_count` - Number of cache misses -- `apollo_router_cache_hit_time` - Time to hit the cache in seconds -- `apollo_router_cache_miss_time` - Time to miss the cache in seconds -- `apollo.router.cache.storage.estimated_size` - The estimated storage size of the cache in bytes (query planner in memory only). - -All cache metrics listed above have the following attributes: - -- `kind`: the cache being queried (`apq`, `query planner`, `introspection`) -- `storage`: The backend storage of the cache (`memory`, `redis`) - -### Coprocessor - -- `apollo_router_operations_coprocessor_total` - Total operations with coprocessors enabled. -- `apollo_router_operations_coprocessor.duration` - Time spent waiting for the coprocessor to answer, in seconds. - -The coprocessor operations metric has the following attributes: - -- `coprocessor.stage`: string (`RouterRequest`, `RouterResponse`, `SubgraphRequest`, `SubgraphResponse`) -- `coprocessor.succeeded`: bool - -### Performance - -- `apollo_router_processing_time` - Time spent processing a request (outside of waiting for external or subgraph requests) in seconds. -- `apollo_router_schema_load_duration` - Time spent loading the schema in seconds. - -### Query planning - -- `apollo_router.query_planning.warmup.duration` - Time spent warming up the query planner queries in seconds. -- `apollo.router.query_planning.plan.duration` - Histogram of plan durations isolated to query planning time only. -- `apollo.router.query_planning.total.duration` - Histogram of plan durations including queue time. -- `apollo.router.query_planning.queued` - A gauge of the number of queued plans requests. -- `apollo.router.query_planning.plan.evaluated_plans` - Histogram of the number of evaluated query plans. -- `apollo.router.v8.heap.used` - heap memory used by V8, in bytes. -- `apollo.router.v8.heap.total` - total heap allocated by V8, in bytes. - -### Uplink - - - -[Learn more about Apollo Uplink.](/federation/managed-federation/uplink/) - - - -- `apollo_router_uplink_fetch_duration_seconds` - Uplink request duration, attributes: - - `url`: The Uplink URL that was polled - - `query`: The query that the router sent to Uplink (`SupergraphSdl` or `License`) - - `kind`: (`new`, `unchanged`, `http_error`, `uplink_error`) - - `code`: The error code depending on type (if an error occurred) - - `error`: The error message (if an error occurred) -- `apollo_router_uplink_fetch_count_total` - - `status`: (`success`, `failure`) - - `query`: The query that the router sent to Uplink (`SupergraphSdl` or `License`) - - - -The initial call to Uplink during router startup is not reflected in metrics. - - - -### Subscriptions - - - -[Learn more about subscriptions.](/router/executing-operations/subscription-support/) - - - -- `apollo_router_opened_subscriptions` - Number of different opened subscriptions (not the number of clients with an opened subscriptions in case it's deduplicated) -- `apollo_router_deduplicated_subscriptions_total` - Number of subscriptions that has been deduplicated -- `apollo_router_skipped_event_count` - Number of subscription events that has been skipped because too many events have been received from the subgraph but not yet sent to the client. - -### Batching - -- `apollo.router.operations.batching` - A counter of the number of query batches received by the router. -- `apollo.router.operations.batching.size` - A histogram tracking the number of queries contained within a query batch. - -### GraphOS Studio - -- `apollo.router.telemetry.studio.reports` - The number of reports submitted to GraphOS Studio by the router. - - `report.type`: The type of report submitted: "traces" or "metrics" - - `report.protocol`: Either "apollo" or "otlp", depending on the experimental_otlp_tracing_sampler configuration. - -### Deprecated - -The following metrics have been deprecated and should not be used. - -- `apollo_router_span` - **Deprecated**—use `apollo_router_processing_time` instead. diff --git a/docs/source/reference/router/telemetry/trace-exporters/datadog.mdx b/docs/source/reference/router/telemetry/trace-exporters/datadog.mdx deleted file mode 100644 index e1b105d338..0000000000 --- a/docs/source/reference/router/telemetry/trace-exporters/datadog.mdx +++ /dev/null @@ -1,253 +0,0 @@ ---- -title: Datadog exporter (via OTLP) -subtitle: Configure the Datadog exporter for tracing -description: Configure the Datadog exporter for tracing via OpenTelemetry Protocol (OTLP) in the Apollo GraphOS Router or Apollo Router Core. ---- -import BatchProcessorPreamble from '../../../../../shared/batch-processor-preamble.mdx'; -import BatchProcessorRef from '../../../../../shared/batch-processor-ref.mdx'; - -Enable and configure the [Datadog](https://www.datadoghq.com/) exporter for tracing in the GraphOS Router or Apollo Router Core. - -For general tracing configuration, refer to [Router Tracing Configuration](/router/configuration/telemetry/exporters/tracing/overview). - -## OTLP configuration - -To export traces to Datadog via OTLP, you must do the following: -- Configure the Datadog agent to accept OTLP traces. -- Configure the router to send traces to the Datadog agent. - -To configure the Datadog agent, add OTLP configuration to your `datadog.yaml`. For example: - -```yaml title="datadog.yaml" -otlp_config: - receiver: - protocols: - grpc: - endpoint: :4317 -``` - -To configure the router, enable the [OTLP exporter](/router/configuration/telemetry/exporters/tracing/otlp) and set `endpoint: `. For example: - -```yaml title="router.yaml" -telemetry: - exporters: - tracing: - otlp: - enabled: true - - # Optional endpoint, either 'default' or a URL (Defaults to http://127.0.0.1:4317) - endpoint: "${env.DATADOG_AGENT_HOST}:4317" - -``` - -For more details about Datadog configuration, see [Datadog Agent configuration](https://docs.datadoghq.com/opentelemetry/otlp_ingest_in_the_agent/?tab=host). - -### Enabling log correlation - -To enable Datadog log correlation, you must configure `dd.trace_id` to appear on the `router` span: - -```yaml title="router.yaml" -telemetry: - instrumentation: - spans: - mode: spec_compliant - router: - attributes: - dd.trace_id: true #highlight-line -``` - -Your JSON formatted log messages will automatically output `dd.trace_id` on each log message if `dd.trace_id` was detected on the `router` span. - -## Datadog native configuration - - - -Native Datadog tracing is not part of the OpenTelemetry spec, and given that Datadog supports OTLP we will be deprecating native Datadog tracing in the future. Use [OTLP configuration](#otlp-configuration) instead. - - - -The router can be configured to connect to either the native, default Datadog agent address or a URL: - -```yaml title="router.yaml" -telemetry: - exporters: - tracing: - datadog: - enabled: true - # Optional endpoint, either 'default' or a URL (Defaults to http://127.0.0.1:8126) - endpoint: "http://${env.DATADOG_AGENT_HOST}:8126" - - # Enable graphql.operation.name attribute on supergraph spans. - instrumentation: - spans: - mode: spec_compliant - supergraph: - attributes: - graphql.operation.name: true -``` - -### `enabled` - -Set to true to enable the Datadog exporter. Defaults to false. - -### `enable_span_mapping` (default: `true`) - -[There are some incompatibilities](https://docs.rs/opentelemetry-datadog/latest/opentelemetry_datadog/#quirks) between Datadog and OpenTelemetry, the Datadog exporter might not provide meaningful contextual information in the exported spans. To fix this, you can configure the router to perform a mapping for the span name and the span resource name. - -```yaml title="router.yaml" -telemetry: - exporters: - tracing: - datadog: - enabled: true - enable_span_mapping: true -``` - -With `enable_span_mapping: true`, the router performs the following mapping: - -1. Use the OpenTelemetry span name to set the Datadog span operation name. -2. Use the OpenTelemetry span attributes to set the Datadog span resource name. - -#### Example trace - -For example, assume a client sends a query `MyQuery` to the router. The router's query planner sends a subgraph query to `my-subgraph-name` and creates the following trace: - -``` - | apollo_router request | - | apollo_router router | - | apollo_router supergraph | - | apollo_router query_planning | apollo_router execution | - | apollo_router fetch | - | apollo_router subgraph | - | apollo_router subgraph_request | -``` - -As you can see, there is no clear information about the name of the query, the name of the subgraph, and the name of the query sent to the subgraph. - -Instead, when `enable_span_mapping` is set to `true` the following trace will be created: - -``` - | request /graphql | - | router /graphql | - | supergraph MyQuery | - | query_planning MyQuery | execution | - | fetch fetch | - | subgraph my-subgraph-name | - | subgraph_request MyQuery__my-subgraph-name__0 | -``` - - -### `fixed_span_names` (default: `true`) - -When `fixed_span_names: true`, the apollo router to use the original span names instead of the dynamic ones as described by OTel semantic conventions. - -```yaml title="router.yaml" -telemetry: - exporters: - tracing: - datadog: - enabled: true - fixed_span_names: true -``` - -This will allow you to have a finite list of operation names in Datadog on the APM view. - -### `resource_mapping` -When set, `resource_mapping` allows you to specify which attribute to use in the Datadog APM and Trace view. -The default resource mappings are: - -| OpenTelemetry Span Name | Datadog Span Operation Name | -|-------------------------|-----------------------------| -| `request` | `http.route` | -| `router` | `http.route` | -| `supergraph` | `graphql.operation.name` | -| `query_planning` | `graphql.operation.name` | -| `subgraph` | `subgraph.name` | -| `subgraph_request` | `graphql.operation.name` | -| `http_request` | `http.route` | - -You may override these mappings by specifying the `resource_mapping` configuration: - -```yaml title="router.yaml" -telemetry: - exporters: - tracing: - datadog: - enabled: true - resource_mapping: - # Use `my.span.attribute` as the resource name for the `router` span - router: "my.span.attribute" - instrumentation: - spans: - router: - attributes: - # Add a custom attribute to the `router` span - my.span.attribute: - request_header: x-custom-header -``` -If you have introduced a new span in a custom build of the Router you can enable resource mapping for it by adding it to the `resource_mapping` configuration. - -### `span_metrics` -When set, `span_metrics` allows you to specify which spans will show span metrics in the Datadog APM and Trace view. -By default, span metrics are enabled for: - -* `request` -* `router` -* `supergraph` -* `subgraph` -* `subgraph_request` -* `http_request` -* `query_planning` -* `execution` -* `query_parsing` - -You may override these defaults by specifying `span_metrics` configuration: - -The following will disable span metrics for the supergraph span. -```yaml title="router.yaml" -telemetry: - exporters: - tracing: - datadog: - enabled: true - span_metrics: - # Disable span metrics for supergraph - supergraph: false - # Enable span metrics for my_custom_span - my_custom_span: true -``` - -If you have introduced a new span in a custom build of the Router you can enable span metrics for it by adding it to the `span_metrics` configuration. - -### `batch_processor` - - - -```yaml -telemetry: - exporters: - tracing: - datadog: - batch_processor: - max_export_batch_size: 512 - max_concurrent_exports: 1 - max_export_timeout: 30s - max_queue_size: 2048 - scheduled_delay: 5s -``` - -#### `batch_processor` configuration reference - - - -## Datadog native configuration reference - -| Attribute | Default | Description | -|-----------------------|-------------------------------------|-----------------------------------------| -| `enabled` | `false` | Enable the OTLP exporter. | -| `enable_span_mapping` | `false` | If span mapping should be used. | -| `endpoint` | `http://localhost:8126/v0.4/traces` | The endpoint to send spans to. | -| `batch_processor` | | The batch processor settings. | -| `resource_mapping` | See [config](#resource_mapping) | A map of span names to attribute names. | -| `span_metrics` | See [config](#span_metrics) | A map of span names to boolean. | - diff --git a/docs/source/reference/router/telemetry/trace-exporters/jaeger.mdx b/docs/source/reference/router/telemetry/trace-exporters/jaeger.mdx deleted file mode 100644 index 28199ec84d..0000000000 --- a/docs/source/reference/router/telemetry/trace-exporters/jaeger.mdx +++ /dev/null @@ -1,139 +0,0 @@ ---- -title: Jaeger exporter (via OTLP) -subtitle: Configure the Jaeger exporter for tracing -description: Configure the Jaeger exporter for tracing via OpenTelemetry Protocol (OTLP) in the Apollo GraphOS Router or Apollo Router Core. ---- - -import BatchProcessorPreamble from '../../../../../shared/batch-processor-preamble.mdx'; -import BatchProcessorRef from '../../../../../shared/batch-processor-ref.mdx'; - -Enable and configure the [Jaeger exporter](https://www.jaegertracing.io/) for tracing in the GraphOS Router or Apollo Router Core. - -For general tracing configuration, refer to [Router Tracing Configuration](/router/configuration/telemetry/exporters/tracing/overview). - -## Jaeger OTLP configuration - -Since Jaeger v1.35.0, [Jaeger supports native OTLP ingestion](https://medium.com/jaegertracing/introducing-native-support-for-opentelemetry-in-jaeger-eb661be8183c), and it's the recommended way to send traces to Jaeger. - -When running Jaeger with Docker, make sure that port **4317** is exposed and that `COLLECTOR_OTLP_ENABLED` is set to `true`. For example: - -```bash -docker run --name jaeger \ - -e COLLECTOR_OTLP_ENABLED=true \ - -p 16686:16686 \ - -p 4317:4317 \ - -p 4318:4318 \ - jaegertracing/all-in-one:1.35 -``` - -To configure the router to send traces via OTLP, set the Jaeger endpoint with port 4317. For example: - -```yaml title="router.yaml" -telemetry: - exporters: - tracing: - otlp: - enabled: true - # Optional endpoint, either 'default' or a URL (Defaults to http://127.0.0.1:4317) - endpoint: "http://${env.JAEGER_HOST}:4317" -``` - -See [OTLP configuration](/router/configuration/telemetry/exporters/tracing/otlp#configuration) for more details on settings. - -## Jaeger Native configuration - - - -[Native Jaegar tracing is deprecated](https://opentelemetry.io/blog/2022/jaeger-native-otlp/) and will be removed in a future release of the router. Use [Jaeger via OTLP](#jaeger-otlp-configuration) instead. - - - -The router can be configured to export tracing data to Jaeger either via an agent or HTTP collector. - -Unless explicitly configured to use a collector, the router will use Jaeger agent by default. - -```yaml title="router.yaml" -telemetry: - exporters: - tracing: - jaeger: - enabled: true -``` - -#### `enabled` -Set to true to enable the Jaeger exporter. Defaults to false. - -#### `batch_processor` - -```yaml -telemetry: - exporters: - tracing: - jaeger: - batch_processor: - max_export_batch_size: 512 - max_concurrent_exports: 1 - max_export_timeout: 30s - max_queue_size: 2048 - scheduled_delay: 5s -``` - -#### `batch_processor` configuration reference - - -### Jaeger configuration reference - -| Attribute | Default | Description | -|-------------------|---------|-------------------------------| -| `enabled` | `false` | Enable the OTLP exporter. | -| `collector` | | Collector specific settings. | -| `agent` | | Agent specific settings. | -| `batch_processor` | | The batch processor settings. | - - - -### Agent configuration - -If you are running Jaeger agent then use the `agent` configuration to set the agent endpoint. For example: - -```yaml title="router.yaml" -telemetry: - exporters: - tracing: - jaeger: - enabled: true - # Optional agent configuration, - agent: - # Optional endpoint, either 'default' or a socket address (Defaults to 127.0.0.1:6832) - endpoint: "${env.JAEGER_HOST}:6832" -``` -#### Jaeger agent configuration reference - -| Attribute | Default | Description | -|-------------------|---------|------------------------------------| -| `endpoint` | `false` | Enable the OTLP exporter. | - -### Collector configuration - -If you are running Jaeger collector then use the `collector` configuration to set the agent endpoint. For example: - -```yaml title="router.yaml" -telemetry: - exporters: - tracing: - jaeger: - enabled: true - # Optional collector configuration, - collector: - # Optional endpoint, either 'default' or a URL (Defaults to http://127.0.0.1:14268/api/traces) - endpoint: "http://${env.JAEGER_HOST}:14268/api/traces" - username: "${env.JAEGER_USERNAME}" - password: "${env.JAEGER_PASSWORD}" -``` -#### Jaeger Collector configuration reference -| Attribute | Default | Description | -|-------------------|---------|------------------------------------| -| `endpoint` | `false` | Enable the OTLP exporter. | -| `username` | | The username for Jaeger collector. | -| `password` | | The password for Jaeger collector. | - diff --git a/docs/source/routing/about-router.mdx b/docs/source/routing/about-router.mdx deleted file mode 100644 index 55a962573e..0000000000 --- a/docs/source/routing/about-router.mdx +++ /dev/null @@ -1,150 +0,0 @@ ---- -title: Supergraph Routing with GraphOS Router -subtitle: Learn the basics about router features and deployment types -description: Apollo provides cloud and self-hosted GraphOS Router options. The router acts as an entry point to your GraphQL APIs and provides a unified interface for clients to interact with. -redirectFrom: - - /graphos/routing - - /federation/query-plans ---- - -## What is GraphOS Router? - -GraphOS Router is the runtime of the GraphOS platform. It executes client operations by planning and executing subgraph queries, then merging them into client responses. It's also the single entry point and gateway to your federated GraphQL API. - - - - -### Runtime of GraphOS platform - -As the runtime of the [GraphOS platform](/graphos/get-started/concepts/graphos), a GraphOS Router gets the supergraph schema—the blueprint of the federated graphs—from the GraphOS control plane. It then executes incoming clients operations based on that schema. - -Unlike API gateways that offer capabilities to manage API endpoints, the router isn't based on URLs or REST endpoints. Rather, the router is a GraphQL-native solution for handling client APIs. - -### Subgraph query planner - -Whenever your router receives an incoming GraphQL operation, it needs to figure out how to use your subgraphs to populate data for each of that operation's fields. To do this, the router generates a _query plan_: - - - - -A query plan is a blueprint for dividing a single incoming operation into one or more operations that are each resolvable by a single subgraph. Some of these operations depend on the results of other operations, so the query plan also defines any required ordering for their execution. The router's query planner determines the optimal set of subgraph queries for each client operation, then it merges the subgraph responses into a single response for the client. - - - -Use the [Explorer IDE](/graphos/platform/explorer/) to view dynamically calculated example query plans for your operations in its right-hand panel. - - - -### Entry point to federated GraphQL API - -The GraphOS Router is the gateway and entry point to a federated supergraph. Clients send GraphQL operations to your router's public endpoint instead of directly to your APIs. - -## GraphOS Router deployment types - -As the entry point to your supergraph, a GraphOS Router must be able to process the expected load of client operations. The scalability and performance of a router, or a fleet or router instances, can be influenced by their deployment infrastructure. - -### Cloud-hosted routers - -You can choose for Apollo to provision and manage the runtime infrastructure for your routers. Apollo hosts and deploys each instance of router in the cloud. Each _cloud-hosted router_ instance is fully integrated and configurable within GraphOS. - - - - - - -While cloud routers are hosted in the cloud, GraphQL subgraph servers are still hosted in your infrastructure. - - - -### Self-hosted routers - -You can choose to manage the runtime infrastructure for your routers by yourself. Using container images of router, you can host and deploy your router instances from your own infrastructure. These _self-hosted router_ instances allow you full control over their deployment. - - - - -### Common router core - -Both cloud-hosted and self-hosted routers are powered by the [Apollo Router Core](https://github.com/apollographql/router)—a high-performance router packaged as a standalone binary. - -### Router type comparison - -Apollo offers the following router options, in increasing order of configurability: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Router typeDescriptionConfigurabilityPlan availability
    Shared cloud routerApollo provisions and manages routers on shared infrastructure. - Basic configurability, including HTTP header rules, CORS settings, and - subgraph error inclusion - - Serverless** -
    Dedicated cloud router - Apollo provisions and manages routers on dedicated infrastructure that - you control and scale. - - Highly configurable, including all options for shared cloud routers and - additional configurations - - Dedicated** -
    Self-hosted routerYou host and manage the router on your own infrastructure. - Highly configurable and customizable, including all options for Cloud - Dedicated routers and additional [customization options](/graphos/routing/customization/overview). - - The Apollo Router Core is available as a free and source-available router. - Connecting your self-hosted router to GraphOS requires an{' '} - Enterprise plan. -
    - - - -**We've paused new sign-ups for Serverless and Dedicated plans while we improve our offerings based on user feedback. This means cloud routing is temporarily unavailable to new users. In the meantime, you can explore other GraphOS features with a [free trial](https://studio.apollographql.com/signup?referrer=docs-content). - - - -## GraphOS Router features - -Although powered by the source-available Apollo Router Core binary, GraphOS Routers offer an expanded feature set that isn't available when running the Apollo Router Core without connecting it to GraphOS. - -Cloud-hosted routers automatically have access to additional GraphOS Router features, while self-hosted routers must be authenticated with a GraphOS Enterprise license to gain access to these features. Refer to the [pricing page](https://www.apollographql.com/pricing#graphos-router) to compare GraphOS Router features across plan types. - - -## Next steps - -- Learn more about Apollo-managed routers in [cloud-hosted router](/graphos/routing/cloud/) - -- Learn more about deploying router instances in your own infrastructure in [self-hosted router](/graphos/routing/self-hosted/) - -- Learn the basics about configuring a router in [Configuring a Router](/graphos/routing/configure-your-router) - -- For all available configuration options, go to [Router configuration](/graphos/reference/router/configuration) reference docs - -- To learn more about the intricacies of query plans, see the [example graph](/graphos/reference/federation/query-plans#example-graph) and [query plan](/graphos/reference/federation/query-plans#example-graph) in reference docs - -- For the most performant query planning, configure and use the [Rust-native query planner](/graphos/routing/query-planning/native-query-planner). diff --git a/docs/source/routing/about-v2.mdx b/docs/source/routing/about-v2.mdx new file mode 100644 index 0000000000..f16009b596 --- /dev/null +++ b/docs/source/routing/about-v2.mdx @@ -0,0 +1,71 @@ +--- +title: What's New in GraphOS Router v2.x +subtitle: "" +description: What's new in Apollo GraphOS Router version 2.x, including Long Term Release (LTS) status, key features, and how to upgrade +--- + +GraphOS Router v{products.router.version("lts_2_latest").version} is the latest Apollo runtime platform. This release introduces changes and enhancements that improve the router’s overall quality and establishes a strong foundation for new innovations, such as Apollo Connectors for REST. It also ships with the new Native Query Planner, optimized for efficiency and performance for high scale mission critical workloads. + +## Release status of router v2.x + +GraphOS Router v2.x is an **Active** release with latest version {products.router.version("lts_2_latest").version}. + +| Current Status | Release Date | Latest Minor | Active Date | Maintenance Date | End of Life Date | +| -------------- | ------------ | ------------ | -------------- | ---------------- | ---------------- | +| Active | July 2024 | v{products.router.version("lts_2_latest").version} | February 2025 | - | - | + +As an [active](/graphos/reference/router-release-lifecycle#active) release: + +- Backwards-compatible minor releases are planned to ship monthly. +- Minor releases can contain new features, bug fixes, and security patches. +- Breaking changes may be required in extenuating circumstances. +- [Support](https://www.apollographql.com/enterprise/support) is provided via paid support plans until end-of-life. + +## What's new in router v2.x + +Below are the key highlights of the new and updated capabilities in router v2.x. + +> For detailed descriptions of updates in router v2.x, go to the [router 1.x to 2.x upgrade guide](/graphos/reference/upgrade/from-router-v1). + +### Simplified integration and API orchestration of REST services using Apollo Connectors + +The router v2.x introduces Apollo Connectors for REST, a declarative way to integrate REST services into supergraphs. By defining REST API integrations in GraphQL schema, you can orchestrate calls across your fleet of APIs and other services to produce a single response from a federated GraphQL API. + +> Follow the [quickstart](/graphos/get-started/guides/rest-quickstart) to orchestrate your first REST API calls with Apollo Connectors. + +### Improved query planner performance + +The router is powered by a new **Native Query Planner** that achieves performance at scale with demonstrable improvement in CPU and memory utilization. It's written in Rust and replaces the legacy query planner written in JavaScript, thereby utilizing a simplified architecture and ensuring more efficient and performant query execution. + +> Learn more about the [native query planner](/graphos/routing/query-planning/native-query-planner). + +### Predictable resource utilization and availability with back-pressure management + +The router introduces **back-pressure management** that enables more predictable memory and CPU consumption. It's especially beneficial for Kubernetes deployments, where the router can now provide timely signals to trigger Horizontal Pod Autoscaling (HPA) and maintain availability. + +### Secure and stable ecosystem + +The router depends on various external libraries, or crates, in the Rust ecosystem for networking, JSON processing, error handling, and more. With router v2.x, these dependencies have been updated to provide the most secure, stable, and performant runtime platform. + +Updated crates include `axum`, `http`, `hyper`, `opentelemetry`, and `redis`. Note that some updates may cause breaking changes, please see [upgrade guide](https://www.apollographql.com/docs/graphos/reference/migration/from-router-v1) for more details. + +### Improvements for consistency and usability + +Several smaller but significant changes have been introduced to reduce or eliminate inconsistencies in observability and configuration: + +- Updated Apollo operation usage reporting to use OpenTelemetry by default +- Renamed metrics to conform to OpenTelemetry naming conventions +- Improved validation of CORS configurations, preventing silent failures +- Added documentation for context keys, improving usability for advanced customers + +## Removals and deprecations + +Several configuration options, interfaces, and metrics were deprecated or removed in router v2.x, including: + +- Removed the `--apollo-uplink-poll-interval` flag due to its ineffectiveness +- Removed various metrics as part of evolving towards OpenTelemetry metrics and conventions +- Removed interfaces deprecated in router v1.x + +## Upgrading to router 2.x + +To upgrade to the latest router v{products.router.version("lts_2_latest").version}, including resolving breaking changes and using new capabilities, follow the [router 1.x to 2.x upgrade guide](/graphos/reference/upgrade/from-router-v1). \ No newline at end of file diff --git a/docs/source/routing/changelog.mdx b/docs/source/routing/changelog.mdx new file mode 100644 index 0000000000..29fb4991f7 --- /dev/null +++ b/docs/source/routing/changelog.mdx @@ -0,0 +1,16 @@ +--- +title: Apollo Router Changelogs +subtitle: "" +description: Changelog of latest Apollo Router release +--- + +This page contains the changelog for the latest release of Apollo Router. + +> Go to [GitHub](https://github.com/apollographql/router/releases) to view changelogs for all router releases. + + + +## {release.version} + + + diff --git a/docs/source/routing/cloud/configuration.mdx b/docs/source/routing/cloud/configuration.mdx index 010ff55b34..7e4ae17c86 100644 --- a/docs/source/routing/cloud/configuration.mdx +++ b/docs/source/routing/cloud/configuration.mdx @@ -234,18 +234,17 @@ For a more general introduction to CORS and common considerations, see the follo By default, the router enables only GraphOS Studio to initiate browser connections to it. If your supergraph serves data to other browser-based applications, you need to do one of the following in the `cors` section of your **Router configuration YAML**: -- Add the origins of those web applications to the router's list of allowed `origins`. +- Add the origins of those web applications to the router's list of allowed `policies`. - Use this option if there is a known, finite list of web applications that consume your cloud supergraph. -- Add a regular expression that matches the origins of those web applications to the router's list of allowed `origins`. +- Add a regular expression that matches the origins of those web applications to the router's list of allowed `policies`. - This option comes in handy if you want to match origins against a pattern, see the example below that matches subdomains of a specific namespace. - Enable the `allow_any_origin` option. - - Use this option if your supergraph is a public API with arbitrarily many web app consumers. - With this option enabled, the router sends the [wildcard (`*`)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin#directives) value for the `Access-Control-Allow-Origin` header. This enables any website to initiate browser connections to it (but they can't provide cookies or other credentials). -- You must use the `origins` + `match_origins` option if clients need to [authenticate their requests with cookies](#passing-credentials). +- You must use policies with `origins` + `match_origins` if clients need to [authenticate their requests with cookies](#passing-credentials). -The following snippet includes an example of each option (use either `allow_any_origin`, or `origins + match_origins`): +The following snippet includes an example of each option (use either `allow_any_origin`, or `policies` with specific origins): ```yaml cors: @@ -253,24 +252,27 @@ cors: # (Defaults to false) allow_any_origin: true - # List of accepted origins + # List of CORS policies with specific origins and settings # (Ignored if allow_any_origin is true) - # (Defaults to the GraphOS Studio url: `https://studio.apollographql.com`) + # (Defaults to allowing GraphOS Studio: `https://studio.apollographql.com`) # # An origin is a combination of scheme, hostname and port. # It does not have any path section, so no trailing slash. - origins: - - https://www.your-app.example.com - - https://studio.apollographql.com # Keep this so GraphOS Studio can run queries against your router - match_origins: - - 'https://([a-z0-9]+[.])*api[.]example[.]com' # any host that uses https and ends with .api.example.com + policies: + - origins: + - https://www.your-app.example.com + - https://studio.apollographql.com # Keep this so GraphOS Studio can run queries against your router + - origins: + - https://api.example.com + match_origins: + - 'https://([a-z0-9]+[.])*api[.]example[.]com' # any host that uses https and ends with .api.example.com ``` -You can also turn off CORS entirely by setting `origins` to an empty list: +You can also disable CORS entirely by setting `policies` to an empty list: ```yml cors: - origins: [] + policies: [] ``` #### Passing credentials @@ -281,7 +283,7 @@ You can enable credentials with CORS by setting the [`Access-Control-Allow-Crede -Your router must specify individual `origins` to support credentialed requests. If your router enables `allow_any_origin`, your browser will refuse to send credentials. +Your router must specify individual policies with specific `origins` to support credentialed requests. If your router configuration specifies `allow_any_origin`, your browser will refuse to send credentials. @@ -289,10 +291,11 @@ To allow browsers to pass credentials to your router, set `allow_credentials` to ```yaml cors: - origins: - - https://www.your-app.example.com - - https://studio.apollographql.com - allow_credentials: true + policies: + - origins: + - https://www.your-app.example.com + - https://studio.apollographql.com + allow_credentials: true ``` For examples of sending cookies and authorization headers from Apollo Client, see [Authentication](/react/networking/authentication/). @@ -309,32 +312,32 @@ cors: # Set to true to allow any origin allow_any_origin: false - # List of accepted origins - # (Ignored if allow_any_origin is set to true) - # - # An origin is a combination of scheme, hostname and port. - # It does not have any path section, so no trailing slash. - origins: - - https://studio.apollographql.com # Keep this so GraphOS Studio can still run queries against your router - - # Set to true to add the `Access-Control-Allow-Credentials` header + # Global settings that apply to all policies unless overridden allow_credentials: false - - # The headers to allow. - # Not setting this mirrors a client's received `access-control-request-headers` - # This is equivalent to allowing any headers, - # except it will also work if allow_credentials is set to true - allow_headers: [] - - # Allowed request methods methods: - GET - POST - OPTIONS - - # Which response headers are available to scripts running in the - # browser in response to a cross-origin request. + allow_headers: [] expose_headers: [] + max_age: null + + # List of CORS policies with specific origins and settings + # (Ignored if allow_any_origin is set to true) + # + # An origin is a combination of scheme, hostname and port. + # It does not have any path section, so no trailing slash. + policies: + - origins: + - https://studio.apollographql.com # Keep this so GraphOS Studio can still run queries against your router + # Each policy can override global settings: + # allow_credentials: false # Override global setting for this policy + # methods: [GET, POST] # Override global methods for this policy + # allow_headers: [] # Override global headers for this policy + # expose_headers: [] # Override global expose headers for this policy + # max_age: "3600s" # Override global max age for this policy + # match_origins: # Regex patterns for dynamic origin matching + # - "^https://.*\\.example\\.com$" ``` #### Response `Vary` header diff --git a/docs/source/routing/cloud/dedicated.mdx b/docs/source/routing/cloud/dedicated.mdx index b07c8673df..bd5833b702 100644 --- a/docs/source/routing/cloud/dedicated.mdx +++ b/docs/source/routing/cloud/dedicated.mdx @@ -16,7 +16,7 @@ Go the [Cloud Dedicated quickstart](/graphos/routing/cloud/dedicated-quickstart) -## Runs on AWS +## Runs on Amazon Web Services (AWS) Cloud Dedicated runs a fleet of GraphOS Routers in the AWS region of your choice. The following regions are currently available: diff --git a/docs/source/routing/cloud/index.mdx b/docs/source/routing/cloud/index.mdx index 0d6111cf8f..7c9edb1bd5 100644 --- a/docs/source/routing/cloud/index.mdx +++ b/docs/source/routing/cloud/index.mdx @@ -5,7 +5,7 @@ subtitle: Cloud-hosted routers for cloud supergraphs -Apollo has paused new sign-ups for Serverless and Dedicated plans while we improve our offerings based on user feedback. This means cloud routing is temporarily unavailable to new users. In the meantime, you can explore other GraphOS features with a [free trial](https://studio.apollographql.com/signup?referrer=docs-content). +Apollo has paused new sign-ups for Serverless and Dedicated plans while we improve our offerings based on user feedback. This means cloud routing is temporarily unavailable to new users. In the meantime, you can explore other GraphOS features with a [Free plan](https://studio.apollographql.com/signup?referrer=docs-content). @@ -55,7 +55,7 @@ Cloud routers aren't available with Enterprise or legacy Free or Team plans. -**We've paused new sign-ups for Serverless and Dedicated plans while we improve our offerings based on user feedback. In the meantime, you can explore GraphOS features with a [free trial](https://studio.apollographql.com/signup?referrer=docs-content). +**We've paused new sign-ups for Serverless and Dedicated plans while we improve our offerings based on user feedback. In the meantime, you can explore GraphOS features with a [Free plan](https://studio.apollographql.com/signup?referrer=docs-content). diff --git a/docs/source/routing/cloud/migrate-to-dedicated.mdx b/docs/source/routing/cloud/migrate-to-dedicated.mdx index 96a5bddb01..76bf86c458 100644 --- a/docs/source/routing/cloud/migrate-to-dedicated.mdx +++ b/docs/source/routing/cloud/migrate-to-dedicated.mdx @@ -16,7 +16,7 @@ GraphOS offers two tiers of cloud routing: Serverless and Dedicated. This guide -Dedicated cloud routers currently support all [premium router features](/router/enterprise-features) except for [safelisting with persisted queries](/graphos/routing/security/persisted-queries/), [automatic persisted queries](/apollo-server/performance/apq/), and [offline licenses](/router/enterprise-features/#offline-enterprise-license). Support for both persisted queries features is on the roadmap. +Dedicated cloud routers currently support all [premium router features](/router/enterprise-features) except for [safelisting with persisted queries](/graphos/routing/security/persisted-queries/), [automatic persisted queries](/apollo-server/performance/apq/), and [offline licenses](/graphos/routing/license/#offline-license). Support for both persisted queries features is on the roadmap. diff --git a/docs/source/routing/cloud/serverless.mdx b/docs/source/routing/cloud/serverless.mdx index 027d6916f9..704c7d6785 100644 --- a/docs/source/routing/cloud/serverless.mdx +++ b/docs/source/routing/cloud/serverless.mdx @@ -6,7 +6,7 @@ description: Learn about Apollo GraphOS Serverless cloud routers, including inte -GraphOS Serverless cloud routers run on shared infrastructure that Apollo manages. Serverless is for demos, proof-of-concepts, and small production apps that don't need more than ten requests per second (RPS). +GraphOS Serverless cloud routers run on shared infrastructure that Apollo manages. Serverless is for demos, proof-of-concepts, and small production apps that don't need more than ten requests per second (RPS) or more than 150 concurrent subscriptions. ## Serverless compute limitations diff --git a/docs/source/routing/cloud/subscriptions.mdx b/docs/source/routing/cloud/subscriptions.mdx index e522843b62..2cc2bfd587 100644 --- a/docs/source/routing/cloud/subscriptions.mdx +++ b/docs/source/routing/cloud/subscriptions.mdx @@ -4,6 +4,7 @@ subtitle: Real-time data delivery across your services description: Cloud routers support GraphQL subscriptions by default, enabling clients to receive real-time updates via WebSocket or HTTP callbacks. redirectFrom: - /graphos/operations/subscriptions +releaseStage: preview --- @@ -75,7 +76,7 @@ Subscriptions are enabled automatically for GraphOS Cloud with the following def ```yaml subscription: - enabled: true + enabled: true # Enabled by default, you don't need to add this to your configuration mode: passthrough: all: diff --git a/docs/source/routing/configuration/cli.mdx b/docs/source/routing/configuration/cli.mdx new file mode 100644 index 0000000000..1f4219bd12 --- /dev/null +++ b/docs/source/routing/configuration/cli.mdx @@ -0,0 +1,335 @@ +--- +title: Router CLI Configuration Reference +subtitle: "" +description: Reference of command-line options for Apollo GraphOS Router and Apollo Router Core. +--- + +import RouterYaml from "../../../shared/router-yaml-complete.mdx"; +import RouterConfigTable from "../../../shared/router-config-properties-table.mdx"; + +This reference covers the command-line options for configuring an Apollo Router. + +## Command-line options + +This reference lists and describes the options supported by the `router` binary via command-line options. Where indicated, some of these options can also be provided via an environment variable. + + + +For options available as both a command-line option and an environment variable, the command-line value takes precedence. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Option / Environment VariableDescription
    + +##### `-s` / `--supergraph` + +`APOLLO_ROUTER_SUPERGRAPH_PATH`, `APOLLO_ROUTER_SUPERGRAPH_URLS` + + + +The [supergraph schema](/federation/federated-types/overview#supergraph-schema) of a router. Specified by absolute or relative path (`-s` / `--supergraph `, or `APOLLO_ROUTER_SUPERGRAPH_PATH`), or a comma-separated list of URLs (`--supergraph-urls `, or `APOLLO_ROUTER_SUPERGRAPH_URLS`).

    + +> 💡 Avoid embedding tokens in `APOLLO_ROUTER_SUPERGRAPH_URLS` because the URLs may appear in log messages.

    + +Setting this option disables polling from Apollo Uplink to fetch the latest supergraph schema.
    + +To learn how to compose your supergraph schema with the Rover CLI, see the [Federation quickstart](/federation/quickstart).

    + +**Required** if you are _not_ using managed federation. If you _are_ using managed federation, you may need to set this option when following [advanced deployment workflows](/federation/managed-federation/deployment/#advanced-deployment-workflows). + +
    + +##### `-c` / `--config` + +`APOLLO_ROUTER_CONFIG_PATH` + + + +The absolute or relative path to the router's optional [YAML configuration file](/graphos/routing/configuration/yaml). + +
    + +##### `--apollo-key-path` + +`APOLLO_KEY_PATH` + + + +The absolute or relative path to a file containing the Apollo graph API key for use with managed federation. + +⚠️ **This is not available on Windows.** + +
    + +##### `--dev` + + + +⚠️ **Do not set this option in production!** + +
    +If set, a router runs in dev mode to help with local development. + +[Learn more about dev mode](#development-mode). + +
    + +##### `--hr` / `--hot-reload` + +`APOLLO_ROUTER_HOT_RELOAD` + + + +If set, the router watches for changes to its configuration file and any supergraph file passed with `--supergraph` and reloads them automatically without downtime. This setting only affects local files provided to the router. The supergraph and configuration provided from GraphOS via Launches (and delivered via Uplink) are _always_ loaded automatically, regardless of this setting. + +
    + +##### `--log` + +`APOLLO_ROUTER_LOG` + + + +The log level, indicating the _most_ severe log message type to include. In ascending order of verbosity, can be one of: `off`, `error`, `warn`, `info`, `debug`, or `trace`. + +The default value is `info`. + +
    + +##### `--license` + +`APOLLO_ROUTER_LICENSE_PATH`, `APOLLO_ROUTER_LICENSE` + + + +An offline GraphOS Enterprise license. Enables Enterprise router features when disconnected from GraphOS.
    + +An offline license is specified either as an absolute or relative path to a license file (`--license ` or `APOLLO_ROUTER_LICENSE_PATH`), or as the stringified contents of a license (`APOLLO_ROUTER_LICENSE`).
    + +When not set, the router retrieves an Enterprise license [from GraphOS via Apollo Uplink](/router/enterprise-features/#the-enterprise-license).
    + +For information about fetching an offline license and configuring the router, see [Offline Enterprise license](/graphos/routing/license/#offline-license). + +
    + +##### `--apollo-uplink-endpoints` + +`APOLLO_UPLINK_ENDPOINTS` + + + +If using [managed federation](/federation/managed-federation/overview/), the Apollo Uplink URL(s) that the router should poll to fetch its latest configuration. Almost all managed router instances should _omit_ this option to use the default set of Uplink URLs.
    + +If you specify multiple URLs, separate them with commas (no whitespace).
    + +For default behavior and possible values, see [Apollo Uplink](/federation/managed-federation/uplink/). + +
    + +##### `--graph-artifact-reference` + +`GRAPH_ARTIFACT_REFERENCE` + + + +An OCI reference to an image that contains the supergraph schema for the router.
    + +When this option is set, the router will fetch the schema from the specified OCI image instead of using Apollo Uplink. Note that Apollo Uplink will still be used for entitlements and persisted queries.
    + +⚠️ **This option does not support hot reloading schemas.** + +
    + +##### `--apollo-uplink-timeout` + +`APOLLO_UPLINK_TIMEOUT` + + + +The request timeout for each poll sent to Apollo Uplink. + +The default value is `30s` (thirty seconds). + +
    + +##### `--anonymous-telemetry-disabled` + +`APOLLO_TELEMETRY_DISABLED` + + + +If set, disables sending anonymous usage information to Apollo. + +
    + +##### `--listen` + +`APOLLO_ROUTER_LISTEN_ADDRESS` + + + +If set, the listen address of the router. + +
    + +##### `-V` / `--version` + + + +If set, the router prints its version number, then exits. + +
    + +### Development mode + +The router can be run in development mode by using the `--dev` command-line option. + +The `--dev` option is equivalent to running the router with the `--hot-reload` option the following configuration options: + +```yaml +sandbox: + enabled: true +homepage: + enabled: false +supergraph: + introspection: true +include_subgraph_errors: + all: true +plugins: + # Enable with the header, Apollo-Expose-Query-Plan: true + experimental.expose_query_plan: true +``` + + + +**Don't set the `--dev` option in production.** If you want to replicate any specific dev mode functionality in production, set the corresponding option in your [YAML config file](/graphos/routing/configuration/yaml). + + + +## Configuration schema for IDE validation + +The router can generate a JSON schema for config validation in your text editor. This schema helps you format the YAML file correctly and also provides content assistance. + +Generate the schema with the following command: + +```bash +./router config schema > configuration_schema.json +``` + +After you generate the schema, configure your text editor. Here are the instructions for some commonly used editors: + +- [Visual Studio Code](https://code.visualstudio.com/docs/languages/json#_json-schemas-and-settings) +- [Emacs](https://emacs-lsp.github.io/lsp-mode/page/lsp-yaml) +- [IntelliJ](https://www.jetbrains.com/help/idea/json.html#ws_json_using_schemas) +- [Sublime](https://github.com/sublimelsp/LSP-yaml) +- [Vim](https://github.com/Quramy/vison) + +## Upgrading your router configuration + +New releases of the router might introduce breaking changes to the [YAML config file's](/graphos/routing/configuration/yaml) expected format, usually to extend existing functionality or improve usability. + +**If you run a new version of your router with a configuration file that it no longer supports, it emits a warning on startup and terminates.** + +If you encounter this warning, you can use the `router config upgrade` command to see the new expected format for your existing configuration file: + +```bash +./router config upgrade +``` + +You can also view a diff of exactly which changes are necessary to upgrade your existing configuration file: + +```bash +./router config upgrade --diff +``` + +## Validating your router configuration + +The router can be used to validate an existing configuration file. This can be useful if you want to have a validate step as part of your CI pipeline. + +``` +./router config validate +``` + +This command takes a config file and validates it against the router's full supported configuration format. + + + +This is a static validation that checks if it is syntactically correct using the JSON schema. The router does additional logical checks on startup against the config that this command does not capture. + + diff --git a/docs/source/routing/configuration/envvars.mdx b/docs/source/routing/configuration/envvars.mdx new file mode 100644 index 0000000000..fe953714ac --- /dev/null +++ b/docs/source/routing/configuration/envvars.mdx @@ -0,0 +1,83 @@ +--- +title: Router Environment Variable Configuration Reference +subtitle: "" +description: Reference of YAML configuration properties for Apollo GraphOS Router and Apollo Router Core. +--- + +This reference covers the environment variables for configuring an Apollo Router. + +## Environment variables + +This section lists and describes the environment variables you can set when running the `router` binary. + + + +These environment variables apply only if your supergraph schema is managed by GraphOS. + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Environment VariableDescription
    + +##### `APOLLO_GRAPH_REF` + + + +The graph ref for the GraphOS graph and variant that the router fetches its supergraph schema from (e.g., `docs-example-graph@staging`). + +**Required** when using [managed federation](/federation/managed-federation/overview/), except when using an [offline license](/graphos/routing/license/#offline-license) to run the router. + +
    + +##### `APOLLO_KEY` + + + +The [graph API key](/graphos/api-keys/#graph-api-keys) that the router should use to authenticate with GraphOS when fetching its supergraph schema. + +**Required** when using [managed federation](/federation/managed-federation/overview/), except when using an [offline license](/graphos/routing/license/#offline-license) to run the router or when using `APOLLO_KEY_PATH`. + +
    + +##### `APOLLO_KEY_PATH` + + + +⚠️ **This is not available on Windows.** + +A path to a file containing the [graph API key](/graphos/api-keys/#graph-api-keys) that the router should use to authenticate with GraphOS when fetching its supergraph schema. + +**Required** when using [managed federation](/federation/managed-federation/overview/), except when using an [offline license](/graphos/routing/license/#offline-license) to run the router or when using `APOLLO_KEY`. + +
    + +## Example command + +To use environment variables when running router, you must set them before the `router` command: + +```bash +APOLLO_KEY="..." APOLLO_GRAPH_REF="..." ./router +``` diff --git a/docs/source/routing/configuration/overview.mdx b/docs/source/routing/configuration/overview.mdx new file mode 100644 index 0000000000..c26c5fb777 --- /dev/null +++ b/docs/source/routing/configuration/overview.mdx @@ -0,0 +1,38 @@ +--- +title: Router Configuration Overview +subtitle: Overview and reference for router configuration +description: Learn how to configure the Apollo GraphOS Router or Apollo Router Core with environment variables, command-line options and commands, and YAML configuration files. +redirectFrom: + - /router/configuration/overview +--- + +Running an Apollo Router instance involves some key steps: +- Getting the schema of the federated supergraph that your router is running +- Configuring runtime features declaratively in YAML +- Deploying to different environments (dev, prod, etc.) +- Providing configuration command-line options, YAML, and environment variables at startup + +## Getting supergraph schema + +As the runtime for a federated graph, a router needs to know the schema of the graph it's running. We call the schema for a federated graph a _supergraph schema_. + +You need to configure the router at startup to know where to get its supergraph schema. That configuration depends on whether or not its supergraph schema is managed by GraphOS: + +- If your supergraph schema is managed by GraphOS, you can configure your router with the GraphOS graph ref and API key as environment variables (`APOLLO_GRAPH_REF` and `APOLLO_KEY`). Based on these, the router can automatically fetch the supergraph schema from GraphOS. +- Otherwise, you can provide the router a supergraph schema file [via command line or environment variable](/graphos/routing/configuration/cli#-s----supergraph). + +## Configuring features in YAML + +Configuring the router's features happens primarily via a YAML configuration file. Named `router.yaml` by default, the router's YAML configuration file lets you configure all of a router's runtime features declaratively. + +## Passing configuration at startup + +Command-line options, a YAML config, and environment variables are passed to the router at startup. + +This example command would be for a router with a GraphOS-managed graph and a custom named `myrouter.yaml` config file: + +```bash showLineNumbers=false +APOLLO_KEY="..." APOLLO_GRAPH_REF="..." router --config myrouter.yaml +``` + +> The router can hot-reload updated configuration. When enabled, changes in `router.yaml` trigger the router to restart with its updated configuration. diff --git a/docs/source/routing/configuration/yaml.mdx b/docs/source/routing/configuration/yaml.mdx new file mode 100644 index 0000000000..c7d938e971 --- /dev/null +++ b/docs/source/routing/configuration/yaml.mdx @@ -0,0 +1,825 @@ +--- +title: Router YAML Configuration Reference +subtitle: "" +description: Reference of YAML configuration properties for Apollo GraphOS Router and Apollo Router Core. +--- +import RouterYaml from '../../../shared/router-yaml-complete.mdx'; +import RouterConfigTable from '../../../shared/router-config-properties-table.mdx'; + +import Apq from '../../../shared/config/apq.mdx'; +import Authn from '../../../shared/config/authentication.mdx'; +import Authz from '../../../shared/config/authorization.mdx'; +import Batching from '../../../shared/config/batching.mdx'; +import Connectors from '../../../shared/config/connectors.mdx'; +import Coproc from '../../../shared/config/coprocessor.mdx'; +import Cors from '../../../shared/config/cors.mdx'; +import Csrf from '../../../shared/config/csrf.mdx'; +import DemandCtrl from '../../../shared/config/demand_control.mdx'; +import ExperimentalChaos from '../../../shared/config/experimental_chaos.mdx'; +import ExperimentalType from '../../../shared/config/experimental_type_conditioned_fetching.mdx'; +import FleetDetector from '../../../shared/config/fleet_detector.mdx'; +import ForbidMut from '../../../shared/config/forbid_mutations.mdx'; +import Headers from '../../../shared/config/headers.mdx'; +import HealthChk from '../../../shared/config/health_check.mdx'; +import Homepage from '../../../shared/config/homepage.mdx'; +import IncludeSubErr from '../../../shared/config/include_subgraph_errors.mdx'; +import LicenseEnf from '../../../shared/config/license_enforcement.mdx'; +import Limits from '../../../shared/config/limits.mdx'; +import OverrideSubUrl from '../../../shared/config/override_subgraph_url.mdx'; +import PersistedQueries from '../../../shared/config/persisted_queries.mdx'; +import Plugins from '../../../shared/config/plugins.mdx'; +import PreviewEntityCache from '../../../shared/config/preview_entity_cache.mdx'; +import PreviewFileUploads from '../../../shared/config/preview_file_uploads.mdx'; +import ProgOverride from '../../../shared/config/progressive_override.mdx'; +import Rhai from '../../../shared/config/rhai.mdx'; +import Sandbox from '../../../shared/config/sandbox.mdx'; +import Subscription from '../../../shared/config/subscription.mdx'; +import Supergraph from '../../../shared/config/supergraph.mdx'; +import Telemetry from '../../../shared/config/telemetry.mdx'; +import Tls from '../../../shared/config/tls.mdx'; +import TrafficShaping from '../../../shared/config/traffic_shaping.mdx'; + +This reference covers the YAML configuration file properties for configuring an Apollo Router. + +## YAML configuration properties + +The router can be configured by a YAML configuration file. This file enables you to declaratively configure various runtime properties of your router's behavior. + +At startup, you set the config file for your router by providing its path with the [`--config`](#-c----config) option: + +```bash +./router --config router.yaml +``` + + + +Enable your text editor to validate the format and content of your router YAML configuration file by [configuring it with the router's configuration schema](/graphos/routing/configuration/cli#configuration-schema-for-ide-validation). + + + +## Example YAML with all properties + +Expand the code block to view an example YAML config file containing all properties. + + + + + + + +## Properties + +--- + + + +Learn more in [Caching Automatic Persisted Queries](/graphos/routing/performance/caching/in-memory#caching-automatic-persisted-queries-apq). + +--- + + + +- To learn about JWT authentication, go to [JWT authentication in the GraphOS Router](/router/configuration/authn-jwt). + +- To learn about subgraph authentication with AWS SigV4, go to a [subgraph authentication configuration example](/router/configuration/authn-subgraph/#configuration-example). + +--- + + + +- To configure authorization directives, see [Authorization directives](/router/configuration/authorization/#authorization-directives). + +- To configure the authorization plugin, see [Configuration options](/router/configuration/authorization/#configuration-options). + +--- + + + +Learn more in [query batching](/router/executing-operations/query-batching). + +--- + + + +Learn more in [Working with router for Apollo Connectors](/graphos/connectors/router). + +--- + + + +Learn more in [External coprocessing in the GraphOS Router](/router/customizations/coprocessor/). + +--- + + + +By default, the router only allows GraphOS Studio to initiate browser connections to it. If your supergraph serves data to other browser-based applications, you need to update its Cross-Origin Resource Sharing (CORS) configuration. + +Learn more in [CORS](/graphos/routing/security/cors). + +--- + + + +Learn more in [CSRF prevention in the router](/router/configuration/csrf). + +--- + + + +With demand control, the router analyzes the cost of operations and rejects requests with operations that exceed customizable cost limits. + +Learn more in [Demand Control](/router/executing-operations/demand-control) + +--- + + + +--- + + + +--- + + + +--- + + + +--- + + + +Learn more in [Sending HTTP headers to subgraphs](/graphos/routing/header-propagation/). + +--- + + + +Learn more in [Health Checks](/graphos/routing/self-hosted/health-checks). + +--- + + + +The router can serve a landing page to browsers that visit its endpoint path (`supergraph.path`): + +- A basic landing page that displays an example query `curl` command (default) + + ```yaml title="router.yaml" + # This is the default behavior. You don't need to include this config. + homepage: + enabled: true + ``` + +- _No_ landing page + + ```yaml title="router.yaml" + homepage: + enabled: false + ``` + +- Sending users to Apollo Explorer + + ```yaml title="router.yaml" + homepage: + graph_ref: my-org-graph@production + ``` + + When you specify a `graph_ref`, the router's landing page includes an option for users to redirect to Apollo Explorer. Users can check a box that will remember their preference and automatically redirect them to Explorer on subsequent visits. + + + The `graph_ref` value should match the format `organization-name@variant-name`, which is the same format used with the `APOLLO_GRAPH_REF` environment variable. Note that the router does not automatically use the value from the `APOLLO_GRAPH_REF` environment variable for this setting - you must explicitly set `graph_ref` in your YAML configuration even if you're already using the environment variable. + + +--- + + + +--- + + + +--- + + + +Learn more in [Request Limits](/graphos/routing/security/request-limits). + +--- + + + +By default, the router obtains the routing URL for each of your subgraphs from the composed supergraph schema you provide it. In most cases, no additional configuration is required. The URL can use HTTP and HTTPS for network access to subgraph, or have the following shape for Unix sockets usage: `unix:///path/to/subgraph.sock` + +However, if you _do_ need to override a particular subgraph's routing URL (for example, to handle changing network topography), you can do so with the `override_subgraph_url` option: + +```yaml +override_subgraph_url: + organizations: http://localhost:8080 + accounts: "${env.ACCOUNTS_SUBGRAPH_HOST_URL}" +``` + +In this example, the `organizations` subgraph URL is overridden to point to `http://localhost:8080`, and the `accounts` subgraph URL is overridden to point to a new URL using [variable expansion](#variable-expansion). The URL specified in the supergraph schema is ignored. + +Any subgraphs that are _omitted_ from `override_subgraph_url` continue to use the routing URL specified in the supergraph schema. + +If you need to override the subgraph URL at runtime on a per-request basis, you can use [request customizations](/router/customizations/overview/#request-path) in the `SubgraphService` layer. + + + +You can enhance your graph's security with GraphOS Router by maintaining a persisted query list (PQL), an operation safelist made by your first-party apps. As opposed to automatic persisted queries (APQ) where operations are automatically cached, operations must be preregistered to the PQL. Once configured, the router checks incoming requests against the PQL. + +Learn more in [Safelisting with persisted queries](/router/configuration/persisted-queries). + +--- + + + +You can customize the router's behavior with [plugins](/router/customizations/overview). Each plugin can have its own section in the configuration file with arbitrary values: + +```yaml {4,8} title="example-plugin-router.yaml" +plugins: + example.plugin: + var1: "hello" + var2: 1 +``` + +Learn more in [Native Plugins for router](/graphos/routing/customization/native-plugins). + +--- + + + +Learn more in [Entity Caching](/graphos/routing/performance/caching/entity). + +--- + + + +Learn more in [File Uploads](/graphos/routing/operations/file-upload). + +--- + + + +Learn more in [Progressive Override](/graphos/schema-design/federated-schemas/reference/directives#progressive-override). + +--- + + + +Learn more in [Rhai customization for router](/graphos/routing/customization/rhai). + +--- + + + +[Apollo Sandbox](/graphos/explorer/sandbox) is a GraphQL development environment. It runs a graph via introspection queries on the router's supergrpah schema, and it provides an IDE for making queries to the graph. + +Running Sandbox in router requires configuring `sandbox.enabled`, `supergraph.instrospection`, and `homepage.enabled`: + +```yaml title="router.yaml" +sandbox: + enabled: true + +# Sandbox uses introspection to obtain your router's schema. +supergraph: + introspection: true + +# Sandbox requires the default landing page to be disabled. +homepage: + enabled: false +``` + + + +**Do not enable Sandbox in production.** Sandbox requires enabling introspection, which is strongly discouraged in production environments. + + + +Learn more in [Apollo Sandbox](/graphos/platform/sandbox). + +--- + + + +Learn more in [Subscriptions](/graphos/routing/operations/subscriptions). + +--- + + + +#### Supergraph listen address + +As the gateway and single endpoint to your supergraph, an Apollo Router has a socket address and port that it listens for client requests. This listen address is configurable in YAML as `supergraph.listen`. + +By default, the router starts an HTTP server that listens on `127.0.0.1:4000`. You can specify a different address by setting `supergraph.listen` for IPv4, IPv6, or Unix sockets. + +##### IPv4 + +```yaml title="router.yaml" +supergraph: + # The socket address and port to listen on (default: 127.0.0.1:400) + listen: 127.0.0.1:4000 +``` + +##### IPv6 + +```yaml title="router.yaml" +supergraph: + # The socket address and port to listen on. (default: [::1]:4000) + # Note that this must be quoted to avoid interpretation as an array in YAML. + listen: "[::1]:4000" +``` + +##### Unix socket + +```yaml title="router_unix.yaml" +supergraph: + # Absolute path to a Unix socket + listen: /tmp/router.sock +``` + + + +Listening on a Unix socket is not supported on Windows. + + + +#### Supergraph endpoint path + +The path of the HTTP endpoint of the supergraph that the router runs is configured by `supergraph.path`. + +By default, the router starts an HTTP server that exposes a `POST`/`GET` endpoint at path `/`. + +```yaml title="router.yaml" +supergraph: + # The path for GraphQL execution + # (Defaults to /) + path: /graphql +``` + +The path must start with `/`. + +A path can contain parameters and wildcards: + +- `/{parameter}` matches a single segment. For example: + + - `/abc/{my_param}/def` matches `/abc/1/def` and `/abc/whatever/def`, but it doesn't match `/abc/1/2/def` or `/abc/def` + +- `/{*parameter}` matches all segments in the rest of a path. For example: + - `/abc/{*wildcard}` matches `/abc/1/def` and `/abc/w/h/a/t/e/v/e/r`, but it doesn't match `/abc/` or `/not_abc_at_all` + + + +- Both parameters and wildcards require a name, even though you can’t use those names anywhere. + +- The router doesn't support wildcards in the _middle_ of a path (e.g., `/{*wild}/graphql`). Instead, use a path parameter (e.g., `/{parameter}/graphql`). + + + +#### Introspection + +In GraphQL, introspection queries are used during development to learn about a GraphQL API's schema. The router can resolve introspection queries, based on the configuration of `supergraph.introspection`. + +By default, the router doesn't resolve introspection queries. + +To enable introspection queries during development, set the `supergraph.introspection` flag: + +```yaml title="router.yaml" +# Do not enable introspection in production! +supergraph: + introspection: true +``` + +##### Introspection recursion limit + +The [schema-introspection schema](https://spec.graphql.org/draft/#sec-Schema-Introspection.Schema-Introspection-Schema) is recursive: a client can query the fields of the types of some other fields, and so on arbitrarily deep. This can produce responses that grow much faster than the size of the request. + +To prevent this, the router is configured by default to not execute introspection queries that nest list fields that are too deep, instead returning an error. The criteria matches `MaxIntrospectionDepthRule` in graphql-js, and it may change in future versions. + +In case the router rejects legitimate queries, you can disable the limit by setting the `limits.introspection_max_depth` flag: + +```yaml title="router.yaml" +# Do not enable introspection in production! +supergraph: + introspection: true +limits: + introspection_max_depth: false +``` + +#### Early cancel + +Up until [Apollo Router Core v1.43.1](https://github.com/apollographql/router/releases/tag/v1.43.1), when the client closed the connection without waiting for the response, the entire request was cancelled and did not go through the entire pipeline. Since this causes issues with request monitoring, the router introduced a new behavior in 1.43.1. Now, the entire pipeline is executed if the request is detected as cancelled, but subgraph requests are not actually done. The response will be reported with the `499` status code, but not actually sent to the client. + +To go back to the previous behavior of immediately cancelling the request, the following configuration can be used for `supergraph.early_cancel`: + +```yaml +supergraph: + early_cancel: true +``` + +Additionally, since v1.43.1, the router can show a log when it detects that the client canceled the request. This log can be activated with: + +```yaml title="router.yaml" +supergraph: + experimental_log_on_broken_pipe: true +``` + +#### Connection shutdown timeout + +When the Router schema or configuration updates all connections must be closed for resources to be freed. +To ensure that long-lived connections do not hang on to resources, a maximum graceful shutdown timeout can be configured with `supergraph.connection_shutdown_timeout`: + +```yaml title="router.yaml" +supergraph: + connection_shutdown_timeout: 60s +``` + +The default value is 60 seconds. + +Note that if `early_cancel` is `false` (default), then requests in progress will still hold onto pipeline resources. +In that case, traffic shaping request timeouts should be used to prevent long-running requests: + +```yaml title="router.yaml" +traffic_shaping: + router: + timeout: 60s +``` + +#### Automatic fragment generation + +By default, the router compresses subgraph requests by generating fragment definitions based on the shape of the subgraph operation. In many cases this significantly reduces the size of the query sent to subgraphs. + +You can explicitly opt-out of this behavior by specifying `supergraph.generate_query_fragments`: + +```yaml +supergraph: + generate_query_fragments: false +``` + +--- + + + +#### Enhanced operation signature normalization + + + + + +The router supports enhanced operation signature normalization in the following versions: + +- [General availability](/resources/product-launch-stages/#general-availability) in v1.54.0 and later +- [Experimental](/resources/product-launch-stages/#experimental-features) in v1.49.0 to v1.53.0 + + + +Apollo's legacy operation signature algorithm removes information about certain fields, such as input objects and aliases. +This removal means some operations may have the same normalized signature though they are distinct operations. + +Enhanced normalization incorporates [input types](#input-types) and [aliases](#aliases) in signature generation. +It also includes other improvements that make it more likely that two operations that only vary slightly have the same signature. + +Configure enhanced operation signature normalization in `router.yaml` with the `telemetry.apollo.signature_normalization_algorithm` option: + +```yaml title="router.yaml" +telemetry: + apollo: + signature_normalization_algorithm: enhanced # Default is legacy +``` + +Once you enable this configuration, operations with enhanced signatures might appear with different operation IDs than they did previously in GraphOS Studio. + +##### Input types + +Enhanced signatures include input object type shapes, while still redacting any actual values. +Legacy signatures [replace input object type with `{}`](/graphos/metrics/operation-signatures/#1-transform-in-line-argument-values). + +Given the following example operation: + +```graphql showLineNumbers=false +query InlineInputTypeQuery { + inputTypeQuery( + input: { + inputString: "foo" + inputInt: 42 + inputBoolean: null + nestedType: { someFloat: 4.2 } + enumInput: SOME_VALUE_1 + nestedTypeList: [{ someFloat: 4.2, someNullableFloat: null }] + listInput: [1, 2, 3] + } + ) { + enumResponse + } +} +``` + +The legacy normalization algorithm generates the following signature: + +```graphql showLineNumbers=false +query InlineInputTypeQuery { + inputTypeQuery(input: {}) { + enumResponse + } +} +``` + +The enhanced normalization algorithm generates the following signature: + +```graphql {3-11} showLineNumbers=false +query InlineInputTypeQuery { + inputTypeQuery( + input: { + inputString: "" + inputInt: 0 + inputBoolean: null + nestedType: { someFloat: 0 } + enumInput: SOME_VALUE_1 + nestedTypeList: [{ someFloat: 0, someNullableFloat: null }] + listInput: [] + } + ) { + enumResponse + } +} +``` + +##### Aliases + +Enhanced signatures include any field aliases used in an operation. +Legacy signatures [remove aliases completely](/graphos/metrics/operation-signatures/#field-aliases), meaning the signature may be invalid if the same field was used with multiple aliases. + +Given the following example operation: + +```graphql showLineNumbers=false +query AliasedQuery { + noInputQuery { + interfaceAlias1: interfaceResponse { + sharedField + } + interfaceAlias2: interfaceResponse { + ... on InterfaceImplementation1 { + implementation1Field + } + ... on InterfaceImplementation2 { + implementation2Field + } + } + inputFieldAlias1: objectTypeWithInputField(boolInput: true) { + stringField + } + inputFieldAlias2: objectTypeWithInputField(boolInput: false) { + intField + } + } +} +``` + +The legacy normalization algorithm generates the following signature: + +```graphql showLineNumbers=false +query AliasedQuery { + noInputQuery { + interfaceResponse { + sharedField + } + interfaceResponse { + ... on InterfaceImplementation1 { + implementation1Field + } + ... on InterfaceImplementation2 { + implementation2Field + } + } + objectTypeWithInputField(boolInput: true) { + stringField + } + objectTypeWithInputField(boolInput: false) { + intField + } + } +} +``` + +The enhanced normalization algorithm generates the following signature: + +```graphql showLineNumbers=false +query AliasedQuery { + noInputQuery { + interfaceAlias1: interfaceResponse { + sharedField + } + interfaceAlias2: interfaceResponse { + ... on InterfaceImplementation1 { + implementation1Field + } + ... on InterfaceImplementation2 { + implementation2Field + } + } + inputFieldAlias1: objectTypeWithInputField(boolInput: true) { + stringField + } + inputFieldAlias2: objectTypeWithInputField(boolInput: false) { + intField + } + } +} +``` + +#### Extended reference reporting + + + + + +The router supports extended reference reporting in the following versions: + +- [General availability](/resources/product-launch-stages/#general-availability) in v1.54.0 and later +- [Experimental](/resources/product-launch-stages/#experimental-features) in v1.50.0 to v1.53.0 + + + + + +You can configure the router to report enum and input object references for enhanced insights and operation checks. +Apollo's legacy reference reporting doesn't include data about enum values and input object fields, meaning you can't view enum and input object field usage in GraphOS Studio. +Legacy reporting can also cause [inaccurate operation checks](#enhanced-operation-checks). + +Configure extended reference reporting in `router.yaml` with the `telemetry.apollo.metrics_reference_mode` option like so: + +```yaml title="router.yaml" +telemetry: + apollo: + metrics_reference_mode: extended # Default is legacy +``` + +#### Extended error reporting + + + + + + + +The router supports extended error reporting in the following versions: + +- [Preview](/resources/product-launch-stages/#preview) in v2.1.2 and later +- [Experimental](/resources/product-launch-stages/#experimental-features) in v2.0.0 + + + +You can configure the router to report extended error information for improved diagnostics. +Apollo's legacy error reporting doesn't include the service or error code, meaning you can't easily attribute errors to their root cause in GraphOS Studio. + +Configure extended reference reporting in `router.yaml` with the `telemetry.apollo.errors.preview_extended_error_metrics` option like so: + +```yaml title="router.yaml" +telemetry: + apollo: + errors: + preview_extended_error_metrics: enabled # Default is disabled +``` + +[Learn more.](/graphos/routing/graphos-reporting#errors) + +##### Configuration effect timing + +Once you configure extended reference reporting, you can view enum value and input field usage alongside object [field usage in GraphOS Studio](/graphos/metrics/field-usage) for all subsequent operations. + +Configuring extended reference reporting automatically turns on [enhanced operation checks](#enhanced-operation-checks), though you won't see an immediate change in your operations check behavior. + +This delay is because operation checks rely on historical operation data. +To ensure sufficient data to distinguish between genuinely unused values and those simply not reported in legacy data, enhanced checks require some operations with extended reference reporting turned on. + +##### Enhanced operation checks + +Thanks to extended reference reporting, operation checks can more accurately flag issues for changes to enum values and input object fields. See the comparison table below for differences between standard operation checks based on legacy reference reporting and enhanced checks based on extended reference reporting. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Standard Check Behavior
    + (Legacy reference reporting) +
    + Enhanced Check Behavior
    + (Extended reference reporting) +
    + +###### Enum value removal + + Removing any enum values is considered a breaking change if any operations use the enum.Removing enum values is only a breaking change if historical operations use the specific enum value(s) that were removed.
    + +###### Default argument changes for input object fields + + + Changing or removing a default argument is generally considered a breaking change, but changing or removing default values for input object fields isn't. + + Changing or removing default values for input object fields is considered a breaking change. + +You can [configure checks to ignore default values changes](/graphos/platform/schema-management/checks#ignored-conditions-settings). + +
    + +###### Nullable input object field removal + + Removing a nullable input object field is always considered a breaking change.Removing a nullable input object field is only considered a breaking change if the nullable field is present in historical operations. If the nullable field is always omitted in historical operations, its removal isn't considered a breaking change.
    + +###### Changing nullable input object fields to non-nullable + + Changing a nullable input object field to non-nullable is considered a breaking change.Changing a nullable input object field to non-nullable is only considered a breaking change if the field had a null value in historical operations. If the field was always a non-null value in historical operations, changing it to non-nullable isn't considered a breaking change.
    + + + +You won't see an immediate change in checks behavior when you first turn on extended reference reporting. +[Learn more.](#configuration-effect-timing) + + + +--- + + + +Learn more in [TLS for the router](/graphos/routing/security/tls). + +--- + + + +Learn more in [Traffic Shaping](/graphos/routing/performance/traffic-shaping). + +--- + +## YAML configuration utilities + +### Variable expansion + +You can reference variables directly in your YAML config file. This is useful for referencing secrets without including them in the file. + +Currently, the router supports expansion of environment variables and file paths. Corresponding variables are prefixed with `env.` and `file.`, respectively. + +The router uses Unix-style expansion. Here are some examples: + +- `${env.ENV_VAR_NAME}` expands to the value of environment variable `ENV_VAR_NAME`. +- `${env.ENV_VAR_NAME:-some_default}` expands to the value of environment variable `ENV_VAR_NAME`, or falls back to the value `some_default` if the environment variable is not defined. +- `${file.a.txt}` expands to the contents of the file `a.txt`. +- `${file.a.txt:-some_default}` expands to the contents of the file `a.txt`, or falls back to the value `some_default` if the file does not exist. + +Variable expansions are valid only for YAML _values_, not keys. + + +### Reusing configurations with YAML aliases + +You can reuse parts of your configuration file in multiple places using standard YAML aliasing syntax: + +```yaml title="router.yaml" +headers: + subgraphs: + products: + request: + - insert: &insert_custom_header + name: "custom-header" + value: "something" + reviews: + request: + - insert: *insert_custom_header +``` + +Here, the `name` and `value` entries under `&insert_custom_header` are reused under `*insert_custom_header`. + +## Related topics + +- [Checklist for configuring the router for production](/technotes/TN0008-production-readiness-checklist/#apollo-router) \ No newline at end of file diff --git a/docs/source/routing/configure-your-router.mdx b/docs/source/routing/configure-your-router.mdx deleted file mode 100644 index c5c835b946..0000000000 --- a/docs/source/routing/configure-your-router.mdx +++ /dev/null @@ -1,103 +0,0 @@ ---- -title: Configuring a Router -subtitle: Learn to configure cloud-hosted and self-hosted routers -description: Learn how to configure the Apollo GraphOS Router with environment variables, command-line options and commands, and YAML configuration files. -redirectFrom: - - /router/configuration/overview/ ---- - -You can configure a router in multiple ways. Because cloud-hosted and self-hosted routers share the common foundation of Apollo Router Core, all routers support declarative configuration with a YAML file, usually named `router.yaml`. Differences between configuring cloud-hosted and self-hosted routers: - -- A cloud-hosted router is managed by Apollo and fully integrated with GraphOS, so its configuration is provided via the GraphOS Studio IDE. -- A self-hosted router is launched via command line, so its configuration includes command-line options and environment variables. - -| | Cloud-Hosted Router | Self-Hosted Router | -| --- | :---: | :---: | -| Studio IDE | ✅ | ❌ | -| YAML file | ✅ | ✅ | -| Command-line options | ❌ | ✅ | -| Environment variables | ❌ | ✅ | - - -## YAML file-based configuration - -Both cloud-hosted and self-hosted routers support YAML file-based configuration. You can edit a YAML file named `router.yaml` to declaratively configure your router. - -- For cloud-hosted routers, use GraphOS Studio to edit `router.yaml`. -- For self-hosted routers, edit a local `router.yaml` and run your router with the `--config` flag: - - ```bash - ./router --config router.yaml - ``` - - - -For self-hosted routers, run `router config schema` to print a JSON schema of all configuration options supported by your router. - - - -To learn more about router configuration options, go to [router configuration reference docs](/graphos/reference/router/configuration). - - -## Configuring a self-hosted router - -You provide the configuration for a self-hosted router when you run a router binary or deploy a router image. - -From a command line, the router accepts configuration flags and environment variables. - -The `--config` flag sets a path to a YAML configuration file. Use this flag to customize non-default settings for your router. - - ```bash - ./router --config router.yaml - ``` - -GraphOS Routers must set the following environment variables so that they can fetch their supergraph schemas from GraphOS: - - `APOLLO_KEY` sets the graph API key to use for authenticating with GraphOS - - `APOLLO_GRAPH_REF` sets the graph ref of the graph and variant in GraphOS - -You can run your router from the command line with these environment variables: - - ```bash - APOLLO_KEY="..." APOLLO_GRAPH_REF="..." ./router - ``` - -To learn more about configuration options available to self-hosted routers, go to [router configuration reference docs](/graphos/reference/router/configuration). - -## Configuring a cloud-hosted router - -You can manage a cloud router's configuration from [GraphOS Studio](https://studio.apollographql.com?referrer=docs-content): - -- Open Studio and go to the **Cloud Router** page -- In the **General** tab, you can view: - - - The URL of your router's GraphQL API endpoint. Every cloud router URL is on a subdomain of `apollographos.net` - - Your router's status and launch history - -In the **Configuration** tab, you can manage: - - - Secrets - - Your router's YAML-base configuration - - - A router on the **Serverless** plan has the following options available: - - HTTP headers - - CORS rules - - Subgraph error inclusion - - Introspection - -## Next steps - -Browse this section to learn how to configure various features of the router: - -- **Real-Time Operations** sets up a router to support various real-time operations - -- **Security** protects a router from malicious or malformed requests and operations - -- **Performance and Scaling** optimize a router's response latency and throughput - -- **Router Customization** can add custom functionality to a router with scripts and coprocessors that can hook into a router's request-handling pipeline. - - -> Haven't run a router with GraphOS yet? Try the [quickstart](/graphos/get-started/guides/quickstart) - -> Learn more about configuration options available to cloud-hosted routers, go to [router configuration reference docs](/graphos/reference/router/configuration). - diff --git a/docs/source/routing/customization/coprocessor.mdx b/docs/source/routing/customization/coprocessor.mdx index fcea96f061..5cc4484fb7 100644 --- a/docs/source/routing/customization/coprocessor.mdx +++ b/docs/source/routing/customization/coprocessor.mdx @@ -4,9 +4,13 @@ subtitle: Customize your router's behavior in any language description: Customize the Apollo GraphOS Router with external coprocessing. Write standalone code in any language, hook into request lifecycle, and modify request/response details. --- -import CoprocTypicalConfig from '../../../shared/coproc-typical-config.mdx'; +import CoprocTypicalConfig from "../../../shared/coproc-typical-config.mdx"; - + + +Rate limits apply on the Free plan. + + With **external coprocessing**, you can hook into the GraphOS Router's request-handling lifecycle by writing standalone code in any language and framework. This code (i.e., your **coprocessor**) can run anywhere on your network that's accessible to the router over HTTP. @@ -55,10 +59,10 @@ As shown in the diagram above, the `RouterService`, `SupergraphService`, `Execut Each supported service can send its coprocessor requests at two different **stages**: - As execution proceeds "down" from the client to individual subgraphs - - Here, the coprocessor can inspect and modify details of requests before GraphQL operations are processed. - - The coprocessor can also instruct the router to [_terminate_ a client request](#terminating-a-client-request) immediately. + - Here, the coprocessor can inspect and modify details of requests before GraphQL operations are processed. + - The coprocessor can also instruct the router to [_terminate_ a client request](#terminating-a-client-request) immediately. - As execution proceeds back "up" from subgraphs to the client - - Here, the coprocessor can inspect and modify details of the router's response to the client. + - Here, the coprocessor can inspect and modify details of the router's response to the client. At _every_ stage, the router waits for your coprocessor's response before it continues processing the corresponding request. Because of this, you should maximize responsiveness by configuring _only_ whichever coprocessor requests your customization requires. @@ -68,7 +72,7 @@ If your coprocessor hooks into your router's `SubgraphService`, the router sends ## Setup -First, make sure your router is [connected to a GraphOS Enterprise organization](/router/enterprise-features/#enabling-enterprise-features). +First, make sure your router is [connected to a GraphOS organization](/graphos/platform/access-management/org). You configure external coprocessing in your router's [YAML config file](/router/configuration/overview/#yaml-config-file), under the `coprocessor` key. @@ -76,7 +80,7 @@ You configure external coprocessing in your router's [YAML config file](/router/ This example configuration sends commonly used request and response details to your coprocessor (see the comments below for explanations of each field): - + ### Minimal configuration @@ -100,48 +104,93 @@ You can define [conditions](/router/configuration/telemetry/instrumentation/cond The `Execution` stage doesn't support coprocessor conditions. - - + Example configurations: - - Run during the `SupergraphResponse` stage only for the first event of a supergraph response. Useful for handling only the first subscription event when a subscription is opened: - +- Run during the `SupergraphResponse` stage only for the first event of a supergraph response. Useful for handling only the first subscription event when a subscription is opened: ```yaml title="router.yaml" coprocessor: url: http://127.0.0.1:3000 supergraph: - response: + response: condition: eq: - - true - - is_primary_response: true # Will be true only for the first event received on a supergraph response (like classical queries and mutations for example) + - true + - is_primary_response: true # Will be true only for the first event received on a supergraph response (like classical queries and mutations for example) body: true headers: true ``` - Run during the `Request` stage only if the request contains a request header: - ```yaml title="router.yaml" coprocessor: url: http://127.0.0.1:3000 router: - request: + request: condition: eq: - - request_header: should-execute-copro # Header name - - "enabled" # Header value + - request_header: should-execute-copro # Header name + - "enabled" # Header value body: true headers: true ``` +### Context configuration + +The router request context is used to share data across stages of the request pipeline. The coprocessor can also use this context. +By default, the context is not sent to the coprocessor (`context: false`). +You can send _all_ context keys to your coprocessor using `context: all`. +You can also specify exactly which context keys you wish to send to a coprocessor by listing them under the `selective` key. This will reduce the size of the request/response and may improve performance. + +If you're upgrading from router 1.x, the [context key names changed](/graphos/routing/upgrade/from-router-v1#renamed-context-keys) in router v2.0. You can specify `context: deprecated` to send all context with the old names, compatible with v1.x. Context keys are translated to their v1.x names before being sent to the coprocessor, and translated back to the v2.x names after being received from the coprocessor. + + + +`context: true` from router 1.x is still supported by the configuration, and is an alias for `context: deprecated`. +We strongly recommend using `context: deprecated` or `context: all` instead. + + + +Example: + +```yaml title="router.yaml" +coprocessor: + url: http://127.0.0.1:3000 # mandatory URL which is the address of the coprocessor + router: + request: + context: false # Do not send any context entries + supergraph: + request: + headers: true + context: # It will only send these 2 context keys to your coprocessor + selective: + - apollo::supergraph::operation_name + - apollo::demand_control::actual_cost + body: true + response: + headers: true + context: all # It will send all context keys with new names (2.x version) + body: true + subgraph: + all: + request: + context: deprecated # It will send all the context keys with deprecated names (1.x version) +``` + + + +If you use the `selective` configuration, you must use the new context key names from v2.x. It does not support the `deprecated` keys from v1.x. So for example, if you try to specify `operation_name` instead of `apollo::supergraph::operation_name`, it won't map to the new context key. + + + ### Client configuration -For example, to enable h2c (http2 cleartext) communication with a coprocessor you can use this configuration:: +For example, to enable h2c (http2 cleartext) communication with a coprocessor you can use this configuration: ```yaml title="router.yaml" coprocessor: @@ -149,7 +198,6 @@ coprocessor: # Using an HTTP (not HTTPS) URL and experimental_http2: http2only results in connections that use h2c client: experimental_http2: http2only - ``` ## Coprocessor request format @@ -165,11 +213,11 @@ The router communicates with your coprocessor via HTTP POST requests (called **c Properties of the JSON body are divided into two high-level categories: - "Control" properties - - These provide information about the context of the specific router request or response. They provide a mechanism to influence the router's execution flow. - - The router always includes these properties in coprocessor requests. + - These provide information about the context of the specific router request or response. They provide a mechanism to influence the router's execution flow. + - The router always includes these properties in coprocessor requests. - Data properties - - These provide information about the substance of a request or response, such as the GraphQL query string and any HTTP headers. Aside from `sdl`, your coprocessor can modify all of these properties. - - You [configure which of these fields](#setup) the router includes in its coprocessor requests. By default, the router includes _none_ of them. + - These provide information about the substance of a request or response, such as the GraphQL query string and any HTTP headers. Aside from `sdl`, your coprocessor can modify all of these properties. + - You [configure which of these fields](#setup) the router includes in its coprocessor requests. By default, the router includes _none_ of them. ### Example requests by stage @@ -263,7 +311,7 @@ Properties of the JSON body are divided into two high-level categories: "apollo_telemetry::subgraph_metrics_attributes": {}, "accepts-json": false, "accepts-multipart": false, - "apollo_telemetry::client_name": "manual", + "apollo::telemetry::client_name": "manual", "apollo_telemetry::usage_reporting": { "statsReportKey": "# Long\nquery Long{me{name}}", "referencedFieldsByType": { @@ -281,7 +329,7 @@ Properties of the JSON body are divided into two high-level categories: } } }, - "apollo_telemetry::client_version": "", + "apollo::telemetry::client_version": "", "accepts-wildcard": true } }, @@ -297,7 +345,6 @@ Properties of the JSON body are divided into two high-level categories: ```json - { // Control properties "version": 1, @@ -306,30 +353,14 @@ Properties of the JSON body are divided into two high-level categories: // Data properties "headers": { - "cookie": [ - "tasty_cookie=strawberry" - ], - "content-type": [ - "application/json" - ], - "host": [ - "127.0.0.1:4000" - ], - "apollo-federation-include-trace": [ - "ftv1" - ], - "apollographql-client-name": [ - "manual" - ], - "accept": [ - "*/*" - ], - "user-agent": [ - "curl/7.79.1" - ], - "content-length": [ - "46" - ] + "cookie": ["tasty_cookie=strawberry"], + "content-type": ["application/json"], + "host": ["127.0.0.1:4000"], + "apollo-federation-include-trace": ["ftv1"], + "apollographql-client-name": ["manual"], + "accept": ["*/*"], + "user-agent": ["curl/7.79.1"], + "content-length": ["46"] }, "body": { "query": "query Long {\n me {\n name\n}\n}", @@ -347,7 +378,6 @@ Properties of the JSON body are divided into two high-level categories: "serviceName": "service name shouldn't change", "uri": "http://thisurihaschanged" } - ``` @@ -357,7 +387,6 @@ Properties of the JSON body are divided into two high-level categories: ```json - { // Control properties "version": 1, @@ -379,7 +408,6 @@ Properties of the JSON body are divided into two high-level categories: "aheader": ["a value"] } } - ``` @@ -389,7 +417,6 @@ Properties of the JSON body are divided into two high-level categories: ```json - { // Control properties "version": 1, @@ -398,30 +425,14 @@ Properties of the JSON body are divided into two high-level categories: // Data properties "headers": { - "cookie": [ - "tasty_cookie=strawberry" - ], - "content-type": [ - "application/json" - ], - "host": [ - "127.0.0.1:4000" - ], - "apollo-federation-include-trace": [ - "ftv1" - ], - "apollographql-client-name": [ - "manual" - ], - "accept": [ - "*/*" - ], - "user-agent": [ - "curl/7.79.1" - ], - "content-length": [ - "46" - ] + "cookie": ["tasty_cookie=strawberry"], + "content-type": ["application/json"], + "host": ["127.0.0.1:4000"], + "apollo-federation-include-trace": ["ftv1"], + "apollographql-client-name": ["manual"], + "accept": ["*/*"], + "user-agent": ["curl/7.79.1"], + "content-length": ["46"] }, "body": { "query": "query Long {\n me {\n name\n}\n}", @@ -438,24 +449,86 @@ Properties of the JSON body are divided into two high-level categories: "serviceName": "service name shouldn't change", "uri": "http://thisurihaschanged", "queryPlan": { - "usage_reporting":{"statsReportKey":"# Me\nquery Me{me{name username}}","referencedFieldsByType":{"User":{"fieldNames":["name","username"],"isInterface":false},"Query":{"fieldNames":["me"],"isInterface":false}}}, - "root":{ - "kind":"Fetch", - "serviceName":"accounts", - "variableUsages":[], - "operation":"query Me__accounts__0{me{name username}}", - "operationName":"Me__accounts__0", - "operationKind":"query", - "id":null, - "inputRewrites":null, - "outputRewrites":null, - "authorization":{"is_authenticated":false,"scopes":[],"policies":[]}}, - "formatted_query_plan":"QueryPlan {\n Fetch(service: \"accounts\") {\n {\n me {\n name\n username\n }\n }\n },\n}", - "query":{ - "string":"query Me {\n me {\n name\n username\n }\n}\n","fragments":{"map":{}},"operations":[{"name":"Me","kind":"query","type_name":"Query","selection_set":[{"Field":{"name":"me","alias":null,"selection_set":[{"Field":{"name":"name","alias":null,"selection_set":null,"field_type":{"Named":"String"},"include_skip":{"include":"Yes","skip":"No"}}},{"Field":{"name":"username","alias":null,"selection_set":null,"field_type":{"Named":"String"},"include_skip":{"include":"Yes","skip":"No"}}}],"field_type":{"Named":"User"},"include_skip":{"include":"Yes","skip":"No"}}}],"variables":{}}],"subselections":{},"unauthorized":{"paths":[],"errors":{"log":true,"response":"errors"}},"filtered_query":null,"defer_stats":{"has_defer":false,"has_unconditional_defer":false,"conditional_defer_variable_names":[]},"is_original":true} + "usage_reporting": { + "statsReportKey": "# Me\nquery Me{me{name username}}", + "referencedFieldsByType": { + "User": { "fieldNames": ["name", "username"], "isInterface": false }, + "Query": { "fieldNames": ["me"], "isInterface": false } } + }, + "root": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "query Me__accounts__0{me{name username}}", + "operationName": "Me__accounts__0", + "operationKind": "query", + "id": null, + "inputRewrites": null, + "outputRewrites": null, + "authorization": { + "is_authenticated": false, + "scopes": [], + "policies": [] + } + }, + "formatted_query_plan": "QueryPlan {\n Fetch(service: \"accounts\") {\n {\n me {\n name\n username\n }\n }\n },\n}", + "query": { + "string": "query Me {\n me {\n name\n username\n }\n}\n", + "fragments": { "map": {} }, + "operations": [ + { + "name": "Me", + "kind": "query", + "type_name": "Query", + "selection_set": [ + { + "Field": { + "name": "me", + "alias": null, + "selection_set": [ + { + "Field": { + "name": "name", + "alias": null, + "selection_set": null, + "field_type": { "Named": "String" }, + "include_skip": { "include": "Yes", "skip": "No" } + } + }, + { + "Field": { + "name": "username", + "alias": null, + "selection_set": null, + "field_type": { "Named": "String" }, + "include_skip": { "include": "Yes", "skip": "No" } + } + } + ], + "field_type": { "Named": "User" }, + "include_skip": { "include": "Yes", "skip": "No" } + } + } + ], + "variables": {} + } + ], + "subselections": {}, + "unauthorized": { + "paths": [], + "errors": { "log": true, "response": "errors" } + }, + "filtered_query": null, + "defer_stats": { + "has_defer": false, + "has_unconditional_defer": false, + "conditional_defer_variable_names": [] + }, + "is_original": true + } + } } - ``` @@ -465,7 +538,6 @@ Properties of the JSON body are divided into two high-level categories: ```json - { // Control properties "version": 1, @@ -487,7 +559,6 @@ Properties of the JSON body are divided into two high-level categories: "aheader": ["a value"] } } - ``` @@ -497,7 +568,6 @@ Properties of the JSON body are divided into two high-level categories: ```json - { // Control properties "version": 1, @@ -534,38 +604,28 @@ Properties of the JSON body are divided into two high-level categories: "statsReportKey": "# TopProducts\nquery TopProducts{topProducts{name price reviews{body id}}}", "referencedFieldsByType": { "Query": { - "fieldNames": [ - "topProducts" - ], + "fieldNames": ["topProducts"], "isInterface": false }, "Review": { - "fieldNames": [ - "body", - "id" - ], + "fieldNames": ["body", "id"], "isInterface": false }, "Product": { - "fieldNames": [ - "price", - "name", - "reviews" - ], + "fieldNames": ["price", "name", "reviews"], "isInterface": false } } }, - "apollo_telemetry::client_version": "", + "apollo::telemetry::client_version": "", "apollo_telemetry::subgraph_metrics_attributes": {}, - "apollo_telemetry::client_name": "" + "apollo::telemetry::client_name": "" } }, "uri": "https://reviews.demo.starstuff.dev/", "method": "POST", "serviceName": "reviews" } - ``` @@ -575,7 +635,6 @@ Properties of the JSON body are divided into two high-level categories: ```json - { // Control properties "version": 1, @@ -586,39 +645,17 @@ Properties of the JSON body are divided into two high-level categories: // Data properties "headers": { - "etag": [ - "W/\"d3-7aayASjs0+e2c/TpiAYgEu/yyo0\"" - ], - "via": [ - "2 fly.io" - ], - "server": [ - "Fly/90d459b3 (2023-03-07)" - ], - "date": [ - "Thu, 09 Mar 2023 14:28:46 GMT" - ], - "x-powered-by": [ - "Express" - ], - "x-ratelimit-limit": [ - "10000000" - ], - "access-control-allow-origin": [ - "*" - ], - "x-ratelimit-remaining": [ - "9999478" - ], - "content-type": [ - "application/json; charset=utf-8" - ], - "fly-request-id": [ - "01GV3CCG5EM3ZNVZD2GH0B00E2-lhr" - ], - "x-ratelimit-reset": [ - "1678374007" - ] + "etag": ["W/\"d3-7aayASjs0+e2c/TpiAYgEu/yyo0\""], + "via": ["2 fly.io"], + "server": ["Fly/90d459b3 (2023-03-07)"], + "date": ["Thu, 09 Mar 2023 14:28:46 GMT"], + "x-powered-by": ["Express"], + "x-ratelimit-limit": ["10000000"], + "access-control-allow-origin": ["*"], + "x-ratelimit-remaining": ["9999478"], + "content-type": ["application/json; charset=utf-8"], + "fly-request-id": ["01GV3CCG5EM3ZNVZD2GH0B00E2-lhr"], + "x-ratelimit-reset": ["1678374007"] }, "body": { "data": { @@ -660,37 +697,27 @@ Properties of the JSON body are divided into two high-level categories: "statsReportKey": "# TopProducts\nquery TopProducts{topProducts{name price reviews{body id}}}", "referencedFieldsByType": { "Product": { - "fieldNames": [ - "price", - "name", - "reviews" - ], + "fieldNames": ["price", "name", "reviews"], "isInterface": false }, "Query": { - "fieldNames": [ - "topProducts" - ], + "fieldNames": ["topProducts"], "isInterface": false }, "Review": { - "fieldNames": [ - "body", - "id" - ], + "fieldNames": ["body", "id"], "isInterface": false } } }, - "apollo_telemetry::client_version": "", + "apollo::telemetry::client_version": "", "apollo_telemetry::subgraph_metrics_attributes": {}, - "apollo_telemetry::client_name": "" + "apollo::telemetry::client_name": "" } }, "serviceName": "reviews", "statusCode": 200 } - ``` @@ -796,6 +823,7 @@ This value is one of the following: - `SubgraphResponse`: The `SubgraphService` has just received a subgraph response. **Do not return a _different_ value for this property.** If you do, the router treats the coprocessor request as if it failed. + @@ -905,7 +933,7 @@ When `stage` is `SupergraphResponse`, if present and `true` then there will be s An object mapping of all HTTP header names and values for the corresponding request or response. -Ensure headers are handled like HTTP headers in general. For example, normalize header case before your coprocessor operates on them. +Ensure headers are handled like HTTP headers in general. For example, normalize header case before your coprocessor operates on them. If your coprocessor [returns a _different_ value](#responding-to-coprocessor-requests) for `headers`, the router replaces the existing headers with that value. @@ -960,7 +988,6 @@ This value can be very large, so you should avoid including it in coprocessor re The router ignores modifications to this value. - @@ -1031,7 +1058,6 @@ When `stage` is `ExecutionRequest`, this contains the query plan for the client - ## Responding to coprocessor requests The router expects your coprocessor to respond with a `200` status code and a JSON body that matches the structure of the [request body](#example-requests-by-stage). @@ -1041,7 +1067,7 @@ In the response body, your coprocessor can return _modified values_ for certain The router supports modifying the following properties from your coprocessor: - [`control`](#control) - - Modify this property to immediately [terminate a client request](#terminating-a-client-request). + - Modify this property to immediately [terminate a client request](#terminating-a-client-request). - [`body`](#body) - [`headers`](#headers) - [`context`](#context) @@ -1080,8 +1106,8 @@ If the router receives an object with this format for `control`, it immediately - The HTTP status code is set to the value of the `break` property (`401` in the example above). - The response body is the coprocessor's returned value for `body`. - - The value of `body` should adhere to the standard GraphQL JSON response format (see the example above). - - Alternatively, you can specify a string value for `body`. If you do, the router returns an error response with that string as the error's `message`. + - The value of `body` should adhere to the standard GraphQL JSON response format (see the example above). + - Alternatively, you can specify a string value for `body`. If you do, the router returns an error response with that string as the error's `message`. The example response above sets the HTTP status code to `400`, which indicates a failed request. @@ -1145,7 +1171,6 @@ If a request to a coprocessor results in a **failed response**, which is seperat - Your coprocessor's response body doesn't match the JSON structure of the corresponding [request body](#example-requests-by-stage). - Your coprocessor's response body sets different values for [control properties](#property-reference) that must not change, such as `stage` and `version`. - ## Handling deferred query responses GraphOS Router and Apollo Router Core support the incremental delivery of query response data via [the `@defer` directive](/router/executing-operations/defer-support/): @@ -1166,6 +1191,7 @@ For a single query with deferred fields, your router sends multiple "chunks" of - The [`status_code`](#status_code) and [`headers`](#headers) fields are included only in the coprocessor request for any response's _first_ chunk. These values can't change after the first chunk is returned to the client, so they're subsequently omitted. - If your coprocessor modifes the response [`body`](#body) for a response chunk, it must provide the new value as a _string_, _not_ as an object. This is because response chunk bodies include multipart boundary information in addition to the actual serialized JSON response data. [See examples.](#examples-of-deferred-response-chunks) + - Many responses will not contain deferred streams and for these the body string can usually be fairly reliably transformed into a JSON object for easy manipulation within the coprocessor. Coprocessors should be carefully coded to allow for the presence of a body that is not a valid JSON object. - Because the data is a JSON string at both `RouterRequest` and `RouterResponse`, it's entirely possible for a coprocessor to rewrite the body from invalid JSON content into valid JSON content. This is one of the primary use cases for `RouterRequest` body processing. @@ -1186,20 +1212,16 @@ The first response chunk includes `headers` and `statusCode` fields: "sdl": "...", // String omitted due to length // highlight-start "headers": { - "content-type": [ - "multipart/mixed;boundary=\"graphql\";deferSpec=20220824" - ], - "vary": [ - "origin" - ] + "content-type": ["multipart/mixed;boundary=\"graphql\";deferSpec=20220824"], + "vary": ["origin"] }, // highlight-end "body": "\r\n--graphql\r\ncontent-type: application/json\r\n\r\n{\"data\":{\"me\":{\"id\":\"1\"}},\"hasNext\":true}\r\n--graphql\r\n", "context": { "entries": { - "operation_kind": "query", - "apollo_telemetry::client_version": "", - "apollo_telemetry::client_name": "manual" + "apollo::supergraph::operation_kind": "query", + "apollo::telemetry::client_version": "", + "apollo::telemetry::client_name": "manual" } }, "statusCode": 200 //highlight-line @@ -1219,9 +1241,9 @@ Subsequent response chunks omit the `headers` and `statusCode` fields: "body": "content-type: application/json\r\n\r\n{\"hasNext\":false,\"incremental\":[{\"data\":{\"name\":\"Ada Lovelace\"},\"path\":[\"me\"]}]}\r\n--graphql--\r\n", "context": { "entries": { - "operation_kind": "query", - "apollo_telemetry::client_version": "", - "apollo_telemetry::client_name": "manual" + "apollo::supergraph::operation_kind": "query", + "apollo::telemetry::client_version": "", + "apollo::telemetry::client_name": "manual" } } } @@ -1239,40 +1261,51 @@ coprocessor: router: # By including this key, a coprocessor can hook into the `RouterService`. You can also use `SupergraphService` for authorization. request: # By including this key, the `RouterService` sends a coprocessor request whenever it first receives a client request. headers: false # These boolean properties indicate which request data to include in the coprocessor request. All are optional and false by default. - context: true # The authorization directives works with claims stored in the request's context + context: all # The authorization directives works with claims stored in the request's context ``` This configuration prompts the router to send an HTTP POST request to your coprocessor whenever it receives a client request. For example, your coprocessor may receive a request with this format: ```json { - "version": 1, - "stage": "RouterRequest", - "control": "continue", - "id": "d0a8245df0efe8aa38a80dba1147fb2e", - "context": { - "entries": { - "accepts-json": true - } + "version": 1, + "stage": "RouterRequest", + "control": "continue", + "id": "d0a8245df0efe8aa38a80dba1147fb2e", + "context": { + "entries": { + "accepts-json": true } + } } ``` -When your coprocessor receives this request from the router, it should add claims to the request's [`context`](#context) and return them in the response to the router. Specifically, the coprocessor should add an entry with a claims object. The key must be `apollo_authentication::JWT::claims`, and the value should be the claims required by the authorization directives you intend to use. For example, if you want to use [`@requireScopes`](/router/configuration/authorization#requiresscopes), the response may look something like this: +When your coprocessor receives this request from the router, it should add claims to the request's [`context`](#context) and return them in the response to the router. Specifically, the coprocessor should add an entry with a claims object. The key must be `apollo::authentication::jwt_claims`, and the value should be the claims required by the authorization directives you intend to use. For example, if you want to use [`@requireScopes`](/router/configuration/authorization#requiresscopes), the response may look something like this: ```json { - "version": 1, - "stage": "RouterRequest", - "control": "continue", - "id": "d0a8245df0efe8aa38a80dba1147fb2e", - "context": { - "entries": { - "accepts-json": true, - "apollo_authentication::JWT::claims": { - "scope": "profile:read profile:write" - } - } + "version": 1, + "stage": "RouterRequest", + "control": "continue", + "id": "d0a8245df0efe8aa38a80dba1147fb2e", + "context": { + "entries": { + "accepts-json": true, + "apollo::authentication::jwt_claims": { + "scope": "profile:read profile:write" + } } + } } ``` + +## Additional resources + +- See the Apollo Solutions ["Hello World" coprocessor](https://github.com/apollosolutions/example-coprocessor-helloworld) for an example of a coprocessor that simply logs the router's payload. +- See the following Apollo Solutions authorization and authentication examples: + - [External authentication coprocessor example](https://github.com/apollosolutions/example-coprocessor-external-auth) + - [Custom auth coprocessor example](https://github.com/apollosolutions/example-coprocessor-custom-auth-directive) + - [`@policy` coprocessor example](https://github.com/apollosolutions/example-coprocessor-auth-policy) +- Use the Apollo Solutions [router extensibility load testing repository](https://github.com/apollosolutions/router-extensibility-load-testing) to load test coprocessors. + + diff --git a/docs/source/routing/customization/coprocessor/index.mdx b/docs/source/routing/customization/coprocessor/index.mdx new file mode 100644 index 0000000000..172030b516 --- /dev/null +++ b/docs/source/routing/customization/coprocessor/index.mdx @@ -0,0 +1,476 @@ +--- +title: External Coprocessing +subtitle: Customize your router's behavior in any language +description: Customize the Apollo GraphOS Router with external coprocessing. Write standalone code in any language, hook into request lifecycle, and modify request/response details. +--- + +import CoprocTypicalConfig from "../../../../shared/coproc-typical-config.mdx"; + + + +Rate limits apply on the Free plan. + + + +With **external coprocessing**, you can hook into the GraphOS Router's request-handling lifecycle by writing standalone code in any language and framework. This code (i.e., your **coprocessor**) can run anywhere on your network that's accessible to the router over HTTP. + +You can configure your router to "call out" to your coprocessor at different **stages** throughout the request-handling lifecycle, enabling you to perform custom logic based on a client request's headers, query string, and other details. This logic can access disk and perform network requests, all while safely isolated from the critical router process. + +When your coprocessor responds to these requests, its response body can modify [various details](#responding-to-coprocessor-requests) of the client's request or response. You can even [terminate a client request](#terminating-a-client-request). + +**Recommended locations for hosting your coprocessor include:** + +- On the same host as your router (minimal request latency) +- In the same Pod as your router, as a "sidecar" container (minimal request latency) +- In the same availability zone as your router (low request latency with increased deployment isolation) + +## How it works + +Whenever your router receives a client request, at various **stages** in the [request-handling lifecycle](/graphos/routing/customization/rhai/#router-request-lifecycle) it can send HTTP POST requests to your coprocessor: + +```mermaid +flowchart TB; + client(Client); + coprocessing(Coprocessor); + subgraph " " + routerService("RouterService"); + supergraphService("SupergraphService"); + executionService("ExecutionService"); + subgraphService[["SubgraphService(s)"]]; + end; + subgraphs[[Subgraphs]]; + client --"1. Sends request"--> routerService; + routerService <-."2. Can send request
    details to coprocessor
    and receive modifications".-> coprocessing; + routerService --"3"--> supergraphService; + supergraphService <-."4. Can send request
    details to coprocessor
    and receive modifications".-> coprocessing; + supergraphService --"5"--> executionService; + executionService <-."6. Can send request
    details to coprocessor
    and receive modifications".-> coprocessing; + executionService --"7"--> subgraphService; + subgraphService <-."8. Can send request
    details to coprocessor
    and receive modifications".-> coprocessing; + subgraphService -- "9"--> subgraphs; + + class client,subgraphs,coprocessing secondary; +``` + +This diagram shows request execution proceeding "down" from a client, through the router, to individual subgraphs. Execution then proceeds back "up" to the client in the reverse order. + +As shown in the diagram above, the `RouterService`, `SupergraphService`, `ExecutionService`, and `SubgraphService` steps of the [request-handling lifecycle](/graphos/routing/customization/rhai/#router-request-lifecycle) can send these POST requests (also called **coprocessor requests**). + +Each supported service can send its coprocessor requests at two different **stages**: + +- As execution proceeds "down" from the client to individual subgraphs + - Here, the coprocessor can inspect and modify details of requests before GraphQL operations are processed. + - The coprocessor can also instruct the router to [_terminate_ a client request](#terminating-a-client-request) immediately. +- As execution proceeds back "up" from subgraphs to the client + - Here, the coprocessor can inspect and modify details of the router's response to the client. + +At _every_ stage, the router waits for your coprocessor's response before it continues processing the corresponding request. Because of this, you should maximize responsiveness by configuring _only_ whichever coprocessor requests your customization requires. + +### Multiple requests with `SubgraphService` + +If your coprocessor hooks into your router's `SubgraphService`, the router sends a separate coprocessor request _for each subgraph request in its query plan._ In other words, if your router needs to query three separate subgraphs to fully resolve a client operation, it sends three separate coprocessor requests. Each coprocessor request includes the [name](#servicename) and [URL](#uri) of the subgraph being queried. + +## Setup + +First, make sure your router is [connected to a GraphOS Enterprise organization](/router/enterprise-features/#enabling-enterprise-features). + +You configure external coprocessing in your router's [YAML config file](/router/configuration/overview/#yaml-config-file), under the `coprocessor` key. + +### Typical configuration + +This example configuration sends commonly used request and response details to your coprocessor (see the comments below for explanations of each field): + + + +### Minimal configuration + +You can confirm that your router can reach your coprocessor by setting this minimal configuration before expanding it as needed: + +```yaml title="router.yaml" +coprocessor: + url: http://127.0.0.1:8081 # Replace with the URL of your coprocessor's HTTP endpoint. + router: + request: + headers: false +``` + +In this case, the `RouterService` only sends a coprocessor request whenever it receives a client request. The coprocessor request body includes _no_ data related to the client request (only "control" data, which is [covered below](#coprocessor-request-format)). + +### Conditions + +You can define [conditions](/router/configuration/telemetry/instrumentation/conditions) for a stage of the request lifecycle that you want to run the coprocessor. You can set coprocessor conditions with [selectors](/router/configuration/telemetry/instrumentation/selectors) based on headers or context entries. + + + +The `Execution` stage doesn't support coprocessor conditions. + + + +Example configurations: + +- Run during the `SupergraphResponse` stage only for the first event of a supergraph response. Useful for handling only the first subscription event when a subscription is opened: + +```yaml title="router.yaml" +coprocessor: + url: http://127.0.0.1:3000 + supergraph: + response: + condition: + eq: + - true + - is_primary_response: true # Will be true only for the first event received on a supergraph response (like classical queries and mutations for example) + body: true + headers: true +``` + +- Run during the `Request` stage only if the request contains a request header: + +```yaml title="router.yaml" +coprocessor: + url: http://127.0.0.1:3000 + router: + request: + condition: + eq: + - request_header: should-execute-copro # Header name + - "enabled" # Header value + body: true + headers: true +``` + +### Context configuration + +The router request context is used to share data across stages of the request pipeline. The coprocessor can also use this context. +By default, the context is not sent to the coprocessor (`context: false`). +You can send _all_ context keys to your coprocessor using `context: all`. +You can also specify exactly which context keys you wish to send to a coprocessor by listing them under the `selective` key. This will reduce the size of the request/response and may improve performance. + +If you're upgrading from router 1.x, the [context key names changed](/docs/graphos/routing/upgrade/from-router-v1#renamed-context-keys) in router v2.0. You can specify `context: deprecated` to send all context with the old names, compatible with v1.x. Context keys are translated to their v1.x names before being sent to the coprocessor, and translated back to the v2.x names after being received from the coprocessor. + + + +`context: true` from router 1.x is still supported by the configuration, and is an alias for `context: deprecated`. +We strongly recommend using `context: deprecated` or `context: all` instead. + + + +Example: + +```yaml title="router.yaml" +coprocessor: + url: http://127.0.0.1:3000 # mandatory URL which is the address of the coprocessor + router: + request: + context: false # Do not send any context entries + supergraph: + request: + headers: true + context: # It will only send these 2 context keys to your coprocessor + selective: + - apollo::supergraph::operation_name + - apollo::demand_control::actual_cost + body: true + response: + headers: true + context: all # It will send all context keys with new names (2.x version) + body: true + subgraph: + all: + request: + context: deprecated # It will send all the context keys with deprecated names (1.x version) +``` + + + +If you use the `selective` configuration, you must use the new context key names from v2.x. It does not support the `deprecated` keys from v1.x. So for example, if you try to specify `operation_name` instead of `apollo::supergraph::operation_name`, it won't map to the new context key. + + + +### Client configuration + + + +For example, to enable h2c (http2 cleartext) communication with a coprocessor you can use this configuration: + +```yaml title="router.yaml" +coprocessor: + url: http://127.0.0.1:8081 + # Using an HTTP (not HTTPS) URL and experimental_http2: http2only results in connections that use h2c + client: + experimental_http2: http2only +``` + +## Coprocessor request format + +The router communicates with your coprocessor via HTTP POST requests (called **coprocessor requests**). The body of each coprocessor request is a JSON object with properties that describe either the current client request or the current router response. + + + +**Body properties vary by the router's current execution stage.** [See example request bodies for each stage.](#example-requests-by-stage) + + + +Properties of the JSON body are divided into two high-level categories: + +- "Control" properties + - These provide information about the context of the specific router request or response. They provide a mechanism to influence the router's execution flow. + - The router always includes these properties in coprocessor requests. +- Data properties + - These provide information about the substance of a request or response, such as the GraphQL query string and any HTTP headers. Aside from `sdl`, your coprocessor can modify all of these properties. + - You [configure which of these fields](#setup) the router includes in its coprocessor requests. By default, the router includes _none_ of them. + +To learn more about coprocessor requests, go to: +- [Reference of request properties](/graphos/routing/customization/coprocessor/reference#properties) +- [Example requests by stage](/graphos/routing/customization/coprocessor/reference#example-requests-by-stage) + +## Responding to coprocessor requests + +The router expects your coprocessor to respond with a `200` status code and a JSON body that matches the structure of the [request body](#example-requests-by-stage). + +In the response body, your coprocessor can return _modified values_ for certain properties. By doing so, you can modify the remainder of the router's execution for the client request. + +The router supports modifying the following properties from your coprocessor: + +- [`control`](#control) + - Modify this property to immediately [terminate a client request](#terminating-a-client-request). +- [`body`](#body) +- [`headers`](#headers) +- [`context`](#context) + + + +**Do not** modify other [control properties](#property-reference). Doing so can cause the client request to fail. + + + +If you omit a property from your response body entirely, the router uses its existing value for that property. + +### Terminating a client request + +Every coprocessor request body includes a `control` property with the string value `continue`. If your coprocessor's response body _also_ sets `control` to `continue`, the router continues processing the client request as usual. + +Alternatively, your coprocessor's response body can set `control` to an _object_ with a `break` property, like so: + +```json +{ + "control": { "break": 401 }, //highlight-line + "body": { + "errors": [ + { + "message": "Not authenticated.", + "extensions": { + "code": "ERR_UNAUTHENTICATED" + } + } + ] + } +} +``` + +If the router receives an object with this format for `control`, it immediately terminates the request-handling lifecycle for the client request. It sends an HTTP response to the client with the following details: + +- The HTTP status code is set to the value of the `break` property (`401` in the example above). +- The response body is the coprocessor's returned value for `body`. + - The value of `body` should adhere to the standard GraphQL JSON response format (see the example above). + - Alternatively, you can specify a string value for `body`. If you do, the router returns an error response with that string as the error's `message`. + +The example response above sets the HTTP status code to `400`, which indicates a failed request. + +You can _also_ use this mechanism to immediately return a _successful_ response: + +```json +{ + "control": { "break": 200 }, + "body": { + "data": { + "currentUser": { + "name": "Ada Lovelace" + } + } + } +} +``` + + + +If you return a successful response, make sure the structure of the `data` property matches the structure expected by the client query. + + + + + +The `body` in the `RouterRequest` and `RouterResponse` stages is always a string, but you can still `break` with a GraphQL response if it's encoded as JSON. + + + + + +```json +{ + "control": { "break": 500 }, + "body": "{ \"errors\": [ { \"message\": \"Something went wrong\", \"extensions\": { \"code\": \"INTERNAL_SERVER_ERRROR\" } } ] }" +} +``` + +```json +{ + "control": { "break": 200 }, + "body": "{ \"data\": { \"currentUser\": { \"name\": \"Ada Lovelace\" } }" +} +``` + + + + + +If you return a successful response, make sure the structure of the `data` property matches the structure expected by the client query. + + + +### Failed responses + +If a request to a coprocessor results in a **failed response**, which is seperate from a **control break**, the router will return an error to the client making the supergraph request. The router considers all of the following scenarios to be a **failed response** from your coprocessor: + +- Your coprocessor doesn't respond within the amount of time specified by the `timeout` key in your [configuration](#typical-configuration) (default one second). +- Your coprocessor responds with a non-`2xx` HTTP code. +- Your coprocessor's response body doesn't match the JSON structure of the corresponding [request body](#example-requests-by-stage). +- Your coprocessor's response body sets different values for [control properties](#property-reference) that must not change, such as `stage` and `version`. + +## Handling deferred query responses + +GraphOS Router and Apollo Router Core support the incremental delivery of query response data via [the `@defer` directive](/router/executing-operations/defer-support/): + +```mermaid +sequenceDiagram + Client->>Router: Sends a query that
    defers some fields + Note over Router: Resolves non-deferred
    fields + Router->>Client: Returns data for
    non-deferred fields + Note over Router: Resolves deferred
    fields + Router->>Client: Returns data for
    deferred fields +``` + +For a single query with deferred fields, your router sends multiple "chunks" of response data to the client. If you enable coprocessor requests for the `RouterResponse` stage, your router sends a separate coprocessor request for _each chunk_ it returns as part of a deferred query. + +**Note the following about handling deferred response chunks:** + +- The [`status_code`](#status_code) and [`headers`](#headers) fields are included only in the coprocessor request for any response's _first_ chunk. These values can't change after the first chunk is returned to the client, so they're subsequently omitted. + +- If your coprocessor modifes the response [`body`](#body) for a response chunk, it must provide the new value as a _string_, _not_ as an object. This is because response chunk bodies include multipart boundary information in addition to the actual serialized JSON response data. [See examples.](#examples-of-deferred-response-chunks) + + - Many responses will not contain deferred streams and for these the body string can usually be fairly reliably transformed into a JSON object for easy manipulation within the coprocessor. Coprocessors should be carefully coded to allow for the presence of a body that is not a valid JSON object. + +- Because the data is a JSON string at both `RouterRequest` and `RouterResponse`, it's entirely possible for a coprocessor to rewrite the body from invalid JSON content into valid JSON content. This is one of the primary use cases for `RouterRequest` body processing. + +### Examples of deferred response chunks + +The examples below illustrate the differences between the _first_ chunk of a deferred response and all subsequent chunks: + +#### First response chunk + +The first response chunk includes `headers` and `statusCode` fields: + +```json +{ + "version": 1, + "stage": "RouterResponse", + "id": "8dee7fe947273640a5c2c7e1da90208c", + "sdl": "...", // String omitted due to length + // highlight-start + "headers": { + "content-type": ["multipart/mixed;boundary=\"graphql\";deferSpec=20220824"], + "vary": ["origin"] + }, + // highlight-end + "body": "\r\n--graphql\r\ncontent-type: application/json\r\n\r\n{\"data\":{\"me\":{\"id\":\"1\"}},\"hasNext\":true}\r\n--graphql\r\n", + "context": { + "entries": { + "apollo::supergraph::operation_kind": "query", + "apollo::telemetry::client_version": "", + "apollo::telemetry::client_name": "manual" + } + }, + "statusCode": 200 //highlight-line +} +``` + +#### Subsequent response chunk + +Subsequent response chunks omit the `headers` and `statusCode` fields: + +```json +{ + "version": 1, + "stage": "RouterResponse", + "id": "8dee7fe947273640a5c2c7e1da90208c", + "sdl": "...", // String omitted due to length + "body": "content-type: application/json\r\n\r\n{\"hasNext\":false,\"incremental\":[{\"data\":{\"name\":\"Ada Lovelace\"},\"path\":[\"me\"]}]}\r\n--graphql--\r\n", + "context": { + "entries": { + "apollo::supergraph::operation_kind": "query", + "apollo::telemetry::client_version": "", + "apollo::telemetry::client_name": "manual" + } + } +} +``` + +## Adding authorization claims via coprocessor + +To use the [authorization directives](/router/configuration/authorization#authorization-directives), a request needs to include **claims**—the details of its authentication and scope. The most straightforward way to add claims is with [JWT authentication](/router/configuration/./authn-jwt). You can also add claims with a [`RouterService` or `SupergraphService` coprocessor](#how-it-works) since they hook into the request lifecycle before the router applies authorization logic. + +An example configuration of the router calling a coprocessor for authorization claims: + +```yaml title="router.yaml" +coprocessor: + url: http://127.0.0.1:8081 # Required. Replace with the URL of your coprocessor's HTTP endpoint. + router: # By including this key, a coprocessor can hook into the `RouterService`. You can also use `SupergraphService` for authorization. + request: # By including this key, the `RouterService` sends a coprocessor request whenever it first receives a client request. + headers: false # These boolean properties indicate which request data to include in the coprocessor request. All are optional and false by default. + context: all # The authorization directives works with claims stored in the request's context +``` + +This configuration prompts the router to send an HTTP POST request to your coprocessor whenever it receives a client request. For example, your coprocessor may receive a request with this format: + +```json +{ + "version": 1, + "stage": "RouterRequest", + "control": "continue", + "id": "d0a8245df0efe8aa38a80dba1147fb2e", + "context": { + "entries": { + "accepts-json": true + } + } +} +``` + +When your coprocessor receives this request from the router, it should add claims to the request's [`context`](#context) and return them in the response to the router. Specifically, the coprocessor should add an entry with a claims object. The key must be `apollo::authentication::jwt_claims`, and the value should be the claims required by the authorization directives you intend to use. For example, if you want to use [`@requireScopes`](/router/configuration/authorization#requiresscopes), the response may look something like this: + +```json +{ + "version": 1, + "stage": "RouterRequest", + "control": "continue", + "id": "d0a8245df0efe8aa38a80dba1147fb2e", + "context": { + "entries": { + "accepts-json": true, + "apollo::authentication::jwt_claims": { + "scope": "profile:read profile:write" + } + } + } +} +``` + +## Additional resources + +- See the Apollo Solutions ["Hello World" coprocessor](https://github.com/apollosolutions/example-coprocessor-helloworld) for an example of a coprocessor that simply logs the router's payload. +- See the following Apollo Solutions authorization and authentication examples: + - [External authentication coprocessor example](https://github.com/apollosolutions/example-coprocessor-external-auth) + - [Custom auth coprocessor example](https://github.com/apollosolutions/example-coprocessor-custom-auth-directive) + - [`@policy` coprocessor example](https://github.com/apollosolutions/example-coprocessor-auth-policy) +- Use the Apollo Solutions [router extensibility load testing repository](https://github.com/apollosolutions/router-extensibility-load-testing) to load test coprocessors. + + diff --git a/docs/source/routing/customization/coprocessor/reference.mdx b/docs/source/routing/customization/coprocessor/reference.mdx new file mode 100644 index 0000000000..d5cda41277 --- /dev/null +++ b/docs/source/routing/customization/coprocessor/reference.mdx @@ -0,0 +1,844 @@ +--- +title: Coprocessor Reference +--- + +## Property reference + +Table of coprocessor request properties. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Property / TypeDescription
    + +**Control properties** + +
    + +##### `control` + +`string | object` + + + +Indicates whether the router should continue processing the current client request. In coprocessor request bodies from the router, this value is always the string value `continue`. + +In your coprocessor's response, you can instead return an _object_ with the following format: + +```json +{ "break": 400 } +``` + +If you do this, the router terminates the request-handling lifecycle and immediately responds to the client with the provided HTTP code and response [`body`](#body) you specify. + +For details, see [Terminating a client request](#terminating-a-client-request). + +
    + +##### `id` + +`string` + + + +A unique ID corresponding to the client request associated with this coprocessor request. + +**Do not return a _different_ value for this property.** If you do, the router treats the coprocessor request as if it failed. + +
    + +##### `subgraphRequestId` + +`string` + + + +A unique ID corresponding to the subgraph request associated with this coprocessor request (only available at the `SubgraphRequest` and `SubgraphResponse` stages). + +**Do not return a _different_ value for this property.** If you do, the router treats the coprocessor request as if it failed. + +
    + +##### `stage` + +`string` + + + +Indicates which stage of the router's [request-handling lifecycle](/graphos/routing/customization/rhai/#router-request-lifecycle) this coprocessor request corresponds to. + +This value is one of the following: + +- `RouterRequest`: The `RouterService` has just received a client request. +- `RouterResponse`: The `RouterService` is about to send response data to a client. +- `SupergraphRequest`: The `SupergraphService` is about to send a GraphQL request. +- `SupergraphResponse`: The `SupergraphService` has just received a GraphQL response. +- `SubgraphRequest`: The `SubgraphService` is about to send a request to a subgraph. +- `SubgraphResponse`: The `SubgraphService` has just received a subgraph response. + +**Do not return a _different_ value for this property.** If you do, the router treats the coprocessor request as if it failed. + +
    + +##### `version` + +`number` + + + +Indicates which version of the coprocessor request protocol the router is using. + +Currently, this value is always `1`. + +**Do not return a _different_ value for this property.** If you do, the router treats the coprocessor request as if it failed. + +
    + +**Data properties** + +
    + +##### `body` + +`string | object` + + + +The body of the corresponding request or response. + +This field is populated when the underlying HTTP method is `POST`. If you are looking for operation data on `GET` requests, that info will be populated in the `path` parameter per the [GraphQL over HTTP spec](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#get). + +If your coprocessor [returns a _different_ value](#responding-to-coprocessor-requests) for `body`, the router replaces the existing body with that value. This is common when [terminating a client request](#terminating-a-client-request). + +This field's type depends on the coprocessor request's [`stage`](#stage): + +- For `SubgraphService` stages, `body` is a JSON _object_. +- For `SupergraphService` stages, `body` is a JSON _object_. +- For `RouterService` stages, `body` is a JSON _string_. + - This is necessary to support handling [deferred queries](#handling-deferred-query-responses). + - If you modify `body` during the `RouterRequest` stage, the new value must be a valid string serialization of a JSON object. If it isn't, the router detects that the body is malformed and returns an error to the client. + +This field's structure depends on whether the coprocessor request corresponds to a request, a standard response, or a response "chunk" for a deferred query: + +- **If a request,** `body` usually contains a `query` property containing the GraphQL query string. +- **If a standard response,** `body` usually contains `data` and/or `errors` properties for the GraphQL operation result. +- **If a response "chunk",** `body` contains `data` for _some_ of the operation fields. + +By default, the `RouterResponse` stage returns _redacted_ errors within the `errors` field. To process subgraph errors manually in your coprocessor, enable [subgraph error inclusion](/router/configuration/subgraph-error-inclusion). + +
    + +##### `context` + +`object` + + + +An object representing the router's shared context for the corresponding client request. + +If your coprocessor [returns a _different_ value](#responding-to-coprocessor-requests) for `context`, the router replaces the existing context with that value. + +
    + +##### `hasNext` + +`bool` + + + +When `stage` is `SupergraphResponse`, if present and `true` then there will be subsequent `SupergraphResponse` calls to the co-processor for each multi-part (`@defer`/subscriptions) response. + +
    + +##### `headers` + +`object` + + + +An object mapping of all HTTP header names and values for the corresponding request or response. + +Ensure headers are handled like HTTP headers in general. For example, normalize header case before your coprocessor operates on them. + +If your coprocessor [returns a _different_ value](#responding-to-coprocessor-requests) for `headers`, the router replaces the existing headers with that value. + +> The router discards any `content-length` headers sent by coprocessors because incorrect `content-length` values can lead to HTTP request failures. + +
    + +##### `method` + +`string` + + + +The HTTP method that is used by the request. + +
    + +##### `path` + +`string` + + + +The `RouterService` or `SupergraphService` path that this coprocessor request pertains to. + +
    + +##### `sdl` + +`string` + + + +A string representation of the router's current supergraph schema. + +This value can be very large, so you should avoid including it in coprocessor requests if possible. + +The router ignores modifications to this value. + +
    + +##### `serviceName` + +`string` + + + +The name of the subgraph that this coprocessor request pertains to. + +This value is present only for coprocessor requests from the router's `SubgraphService`. + +**Do not return a _different_ value for this property.** If you do, the router treats the coprocessor request as if it failed. + +
    + +##### `statusCode` + +`number` + + + +The HTTP status code returned with a response. + +
    + +##### `uri` + +`string` + + + +When `stage` is `SubgraphRequest`, this is the full URI of the subgraph the router will query. + +
    + +##### `query_plan` + +`string` + + + +When `stage` is `ExecutionRequest`, this contains the query plan for the client query. It cannot be modified by the coprocessor. + +
    + +## Example requests by stage + +### `RouterRequest` + + + +```json title="Example coprocessor request body" +{ + // Control properties + "version": 1, + "stage": "RouterRequest", + "control": "continue", + "id": "1b19c05fdafc521016df33148ad63c1b", + + // Data properties + "headers": { + "cookie": [ + "tasty_cookie=strawberry" + ], + "content-type": [ + "application/json" + ], + "host": [ + "127.0.0.1:4000" + ], + "apollo-federation-include-trace": [ + "ftv1" + ], + "apollographql-client-name": [ + "manual" + ], + "accept": [ + "*/*" + ], + "user-agent": [ + "curl/7.79.1" + ], + "content-length": [ + "46" + ] + }, + "body": "{ + \"query\": \"query GetActiveUser {\n me {\n name\n}\n}\" + }", + "context": { + "entries": { + "accepts-json": false, + "accepts-wildcard": true, + "accepts-multipart": false + } + }, + "sdl": "...", // String omitted due to length + "path": "/", + "method": "POST" +} +``` + + + +### `RouterResponse` + + + +```json +{ + // Control properties + "version": 1, + "stage": "RouterResponse", + "control": "continue", + "id": "1b19c05fdafc521016df33148ad63c1b", + + // Data properties + "headers": { + "vary": [ + "origin" + ], + "content-type": [ + "application/json" + ] + }, + "body": "{ + \"data\": { + \"me\": { + \"name\": \"Ada Lovelace\" + } + } + }", + "context": { + "entries": { + "apollo_telemetry::subgraph_metrics_attributes": {}, + "accepts-json": false, + "accepts-multipart": false, + "apollo::telemetry::client_name": "manual", + "apollo_telemetry::usage_reporting": { + "statsReportKey": "# Long\nquery Long{me{name}}", + "referencedFieldsByType": { + "User": { + "fieldNames": [ + "name" + ], + "isInterface": false + }, + "Query": { + "fieldNames": [ + "me" + ], + "isInterface": false + } + } + }, + "apollo::telemetry::client_version": "", + "accepts-wildcard": true + } + }, + "statusCode": 200, + "sdl": "..." // Omitted due to length +} +``` + + + +### `SupergraphRequest` + + + +```json +{ + // Control properties + "version": 1, + "stage": "SupergraphRequest", + "control": "continue", + + // Data properties + "headers": { + "cookie": ["tasty_cookie=strawberry"], + "content-type": ["application/json"], + "host": ["127.0.0.1:4000"], + "apollo-federation-include-trace": ["ftv1"], + "apollographql-client-name": ["manual"], + "accept": ["*/*"], + "user-agent": ["curl/7.79.1"], + "content-length": ["46"] + }, + "body": { + "query": "query Long {\n me {\n name\n}\n}", + "operationName": "MyQuery", + "variables": {} + }, + "context": { + "entries": { + "accepts-json": false, + "accepts-wildcard": true, + "accepts-multipart": false, + "this-is-a-test-context": 42 + } + }, + "serviceName": "service name shouldn't change", + "uri": "http://thisurihaschanged" +} +``` + + + +### `SupergraphResponse` + + + +```json +{ + // Control properties + "version": 1, + "stage": "SupergraphResponse", + "control": { + "break": 200 + }, + + // Data properties + "body": { + "errors": [{ "message": "my error message" }] + }, + "context": { + "entries": { + "testKey": true + } + }, + "headers": { + "aheader": ["a value"] + } +} +``` + + + +#### `ExecutionRequest` + + + +```json +{ + // Control properties + "version": 1, + "stage": "ExecutionRequest", + "control": "continue", + + // Data properties + "headers": { + "cookie": ["tasty_cookie=strawberry"], + "content-type": ["application/json"], + "host": ["127.0.0.1:4000"], + "apollo-federation-include-trace": ["ftv1"], + "apollographql-client-name": ["manual"], + "accept": ["*/*"], + "user-agent": ["curl/7.79.1"], + "content-length": ["46"] + }, + "body": { + "query": "query Long {\n me {\n name\n}\n}", + "operationName": "MyQuery" + }, + "context": { + "entries": { + "accepts-json": false, + "accepts-wildcard": true, + "accepts-multipart": false, + "this-is-a-test-context": 42 + } + }, + "serviceName": "service name shouldn't change", + "uri": "http://thisurihaschanged", + "queryPlan": { + "usage_reporting": { + "statsReportKey": "# Me\nquery Me{me{name username}}", + "referencedFieldsByType": { + "User": { "fieldNames": ["name", "username"], "isInterface": false }, + "Query": { "fieldNames": ["me"], "isInterface": false } + } + }, + "root": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "query Me__accounts__0{me{name username}}", + "operationName": "Me__accounts__0", + "operationKind": "query", + "id": null, + "inputRewrites": null, + "outputRewrites": null, + "authorization": { + "is_authenticated": false, + "scopes": [], + "policies": [] + } + }, + "formatted_query_plan": "QueryPlan {\n Fetch(service: \"accounts\") {\n {\n me {\n name\n username\n }\n }\n },\n}", + "query": { + "string": "query Me {\n me {\n name\n username\n }\n}\n", + "fragments": { "map": {} }, + "operations": [ + { + "name": "Me", + "kind": "query", + "type_name": "Query", + "selection_set": [ + { + "Field": { + "name": "me", + "alias": null, + "selection_set": [ + { + "Field": { + "name": "name", + "alias": null, + "selection_set": null, + "field_type": { "Named": "String" }, + "include_skip": { "include": "Yes", "skip": "No" } + } + }, + { + "Field": { + "name": "username", + "alias": null, + "selection_set": null, + "field_type": { "Named": "String" }, + "include_skip": { "include": "Yes", "skip": "No" } + } + } + ], + "field_type": { "Named": "User" }, + "include_skip": { "include": "Yes", "skip": "No" } + } + } + ], + "variables": {} + } + ], + "subselections": {}, + "unauthorized": { + "paths": [], + "errors": { "log": true, "response": "errors" } + }, + "filtered_query": null, + "defer_stats": { + "has_defer": false, + "has_unconditional_defer": false, + "conditional_defer_variable_names": [] + }, + "is_original": true + } + } +} +``` + + + +### `ExecutionResponse` + + + +```json +{ + // Control properties + "version": 1, + "stage": "ExecutionResponse", + "control": { + "break": 200 + }, + + // Data properties + "body": { + "errors": [{ "message": "my error message" }] + }, + "context": { + "entries": { + "testKey": true + } + }, + "headers": { + "aheader": ["a value"] + } +} +``` + + + +#### `SubgraphRequest` + + + +```json +{ + // Control properties + "version": 1, + "stage": "SubgraphRequest", + "control": "continue", + "id": "666d677225c1bc6d7c54a52b409dbd4e", + "subgraphRequestId": "b5964998b2394b64a864ef802fb5a4b3", + + // Data properties + "headers": {}, + "body": { + "query": "query TopProducts__reviews__1($representations:[_Any!]!){_entities(representations:$representations){...on Product{reviews{body id}}}}", + "operationName": "TopProducts__reviews__1", + "variables": { + "representations": [ + { + "__typename": "Product", + "upc": "1" + }, + { + "__typename": "Product", + "upc": "2" + }, + { + "__typename": "Product", + "upc": "3" + } + ] + } + }, + "context": { + "entries": { + "apollo_telemetry::usage_reporting": { + "statsReportKey": "# TopProducts\nquery TopProducts{topProducts{name price reviews{body id}}}", + "referencedFieldsByType": { + "Query": { + "fieldNames": ["topProducts"], + "isInterface": false + }, + "Review": { + "fieldNames": ["body", "id"], + "isInterface": false + }, + "Product": { + "fieldNames": ["price", "name", "reviews"], + "isInterface": false + } + } + }, + "apollo::telemetry::client_version": "", + "apollo_telemetry::subgraph_metrics_attributes": {}, + "apollo::telemetry::client_name": "" + } + }, + "uri": "https://reviews.demo.starstuff.dev/", + "method": "POST", + "serviceName": "reviews" +} +``` + + + +### `SubgraphResponse` + + + +```json +{ + // Control properties + "version": 1, + "stage": "SubgraphResponse", + "id": "b7810c6f7f95640fd6c6c8781e3953c0", + "subgraphRequestId": "b5964998b2394b64a864ef802fb5a4b3", + "control": "continue", + + // Data properties + "headers": { + "etag": ["W/\"d3-7aayASjs0+e2c/TpiAYgEu/yyo0\""], + "via": ["2 fly.io"], + "server": ["Fly/90d459b3 (2023-03-07)"], + "date": ["Thu, 09 Mar 2023 14:28:46 GMT"], + "x-powered-by": ["Express"], + "x-ratelimit-limit": ["10000000"], + "access-control-allow-origin": ["*"], + "x-ratelimit-remaining": ["9999478"], + "content-type": ["application/json; charset=utf-8"], + "fly-request-id": ["01GV3CCG5EM3ZNVZD2GH0B00E2-lhr"], + "x-ratelimit-reset": ["1678374007"] + }, + "body": { + "data": { + "_entities": [ + { + "reviews": [ + { + "body": "Love it!", + "id": "1" + }, + { + "body": "Prefer something else.", + "id": "4" + } + ] + }, + { + "reviews": [ + { + "body": "Too expensive.", + "id": "2" + } + ] + }, + { + "reviews": [ + { + "body": "Could be better.", + "id": "3" + } + ] + } + ] + } + }, + "context": { + "entries": { + "apollo_telemetry::usage_reporting": { + "statsReportKey": "# TopProducts\nquery TopProducts{topProducts{name price reviews{body id}}}", + "referencedFieldsByType": { + "Product": { + "fieldNames": ["price", "name", "reviews"], + "isInterface": false + }, + "Query": { + "fieldNames": ["topProducts"], + "isInterface": false + }, + "Review": { + "fieldNames": ["body", "id"], + "isInterface": false + } + } + }, + "apollo::telemetry::client_version": "", + "apollo_telemetry::subgraph_metrics_attributes": {}, + "apollo::telemetry::client_name": "" + } + }, + "serviceName": "reviews", + "statusCode": 200 +} +``` + + diff --git a/docs/source/routing/customization/custom-binary.mdx b/docs/source/routing/customization/custom-binary.mdx index 87b87c2fae..4eaed10eee 100644 --- a/docs/source/routing/customization/custom-binary.mdx +++ b/docs/source/routing/customization/custom-binary.mdx @@ -15,54 +15,26 @@ Learn how to compile a custom binary from Apollo Router Core source, which is re ## Prerequisites -To compile the router, you need to have the following installed: - -* [Rust 1.76.0 or later](https://www.rust-lang.org/tools/install) -* [Node.js 16.9.1 or later](https://nodejs.org/en/download/) -* [CMake 3.5.1 or later](https://cmake.org/download/) - -After you install the above, also install the `cargo-xtask` and `cargo-scaffold` crates: - -```sh -cargo install cargo-xtask -cargo install cargo-scaffold -``` +To compile the router, you need to have [Rust 1.89.0 or later](https://www.rust-lang.org/tools/install) installed. ## 1. Create a new project -1. Use the `cargo-scaffold` command to create a project for your custom router: +1. Use the `cargo new` command to create a project for your custom router: ```bash - cargo-scaffold scaffold https://github.com/apollographql/router.git -r apollo-router-scaffold/templates/base -t main + cargo new --bin starstuff ``` -2. The `cargo-scaffold` command prompts you for some configuration settings. For the purposes of this tutorial, set your project's name to `starstuff`. +For the purposes of this tutorial, set your project's name to `starstuff`. -3. After your project is created, change to the `starstuff` directory: +2. After your project is created, change to the `starstuff` directory: ```bash cd starstuff ``` -The generated project has the following layout: - -```bash -starstuff -├── Cargo.toml # Dependencies are declared here -├── README.md -├── router.yaml # Router yaml config -├── src -│ ├── main.rs # Entry point -│ └── plugins # Custom plugins are located here -│ └── mod.rs -└── xtask # Build support files - ├── Cargo.toml - └── src - └── main.rs -``` - -The router uses an auto-discovery mechanism for plugins, so any plugins you add via dependency are automatically available to the router at runtime. +Write the source code for your custom binary. ## 2. Compile the router @@ -104,30 +76,9 @@ If you're using managed federation, you set the `APOLLO_KEY` and `APOLLO_GRAPH_R ## 4. Create a plugin -1. From within your project directory, scaffold a new plugin with the following command: +1. From within your project directory, implement your new plugin. - ```bash - cargo router plugin create hello_world - ``` - -2. The command prompts you to choose a starting template: - - ```bash {2} - Select a plugin template: - > "basic" - "auth" - "tracing" - ``` - - The available templates are: - - * `basic` - a barebones plugin - * `auth` - an authentication plugin for making an external call - * `tracing` - a telemetry plugin that adds a custom metric span and a log message - - For the purposes of this tutorial, choose `basic`. - -4. Add configuration options for the created plugin to your `router.yaml` file: +2. Add configuration options for the created plugin to your `router.yaml` file: ```yaml title="router.yaml" plugins: @@ -135,7 +86,7 @@ If you're using managed federation, you set the `APOLLO_KEY` and `APOLLO_GRAPH_R message: "starting my plugin" ``` -5. Run the router again: +3. Run the router again: ```bash cargo run -- --hot-reload --config router.yaml --supergraph supergraph-schema.graphql @@ -149,19 +100,9 @@ If you're using managed federation, you set the `APOLLO_KEY` and `APOLLO_GRAPH_R Nice work! You now have a custom router binary with an associated plugin. Next, you can extend the plugin with the functionality you need or add more plugins. -## Removing a plugin - -To remove a previously added plugin from your router project, use the following command: - -```bash -cargo router plugin remove hello_world -``` - -Note that depending on the structure of your plugin, the command might fail to remove all of its associated files. - ## Memory allocator -On Linux the `apollo-router` crate sets [jemalloc](http://jemalloc.net/) +On Linux the `apollo-router` crate sets [jemalloc](http://jemalloc.net/) as [the global memory allocator for Rust](https://doc.rust-lang.org/std/alloc/index.html#the-global_allocator-attribute) to reduce memory fragmentation. Future versions may do so on more platforms, or switch to yet a different allocator. @@ -177,38 +118,6 @@ If you make a library crate, also specify `default-features = false` in order to leave the choice open for the eventual executable crate. (Cargo default features are only disabled if *all* dependents specify `default-features = false`.) -## Docker - -You can use the provided [Dockerfile](https://github.com/apollographql/router/tree/main/apollo-router-scaffold/templates/base/Dockerfile) to build a release container. - -Make sure your router is configured to listen to `0.0.0.0` so you can query it from outside the container: - -```yml - supergraph: - listen: 0.0.0.0:4000 -``` - -Use your `APOLLO_KEY` and `APOLLO_GRAPH_REF` environment variables to run the router in managed federation. - - ```bash - docker build -t my_custom_router . - docker run -e APOLLO_KEY="your apollo key" -e APOLLO_GRAPH_REF="your apollo graph ref" my_custom_router - ``` - -Otherwise add a `COPY` step to the Dockerfile, and edit the entrypoint: - -```Dockerfile -# Copy configuration for docker image -COPY router.yaml /dist/config.yaml -# Copy supergraph for docker image -COPY my_supergraph.graphql /dist/supergraph.graphql - -# [...] and change the entrypoint - -# Default executable is the router -ENTRYPOINT ["/dist/router", "-s", "/dist/supergraph.graphql"] -``` - ## Related topics * [Optimizing Custom Router Builds](/graphos/routing/self-hosted/containerization/optimize-build) diff --git a/docs/source/routing/customization/native-plugins.mdx b/docs/source/routing/customization/native-plugins.mdx index aa27eb991c..8f48b91ac0 100644 --- a/docs/source/routing/customization/native-plugins.mdx +++ b/docs/source/routing/customization/native-plugins.mdx @@ -1,17 +1,17 @@ --- -title: Writing Native Rust Plugins +title: Writing Native Rust Plugins subtitle: Extend the router with custom Rust code description: Extend the Apollo GraphOS Router or Apollo Router Core with custom Rust code. Plan, build, and register plugins. Define plugin configuration and implement lifecycle hooks. --- -import ElasticNotice from '../../../shared/elastic-notice.mdx'; -import NativePluginNotice from '../../../shared/native-plugin-notice.mdx'; +import ElasticNotice from "../../../shared/elastic-notice.mdx"; +import NativePluginNotice from "../../../shared/native-plugin-notice.mdx"; Your federated graph might have specific requirements that aren't supported by the built-in [configuration options](/router/configuration/overview/) of the GraphOS Router or Apollo Router Core. For example, you might need to further customize the behavior of: -* Authentication/authorization -* Logging -* Operation tracing +- Authentication/authorization +- Logging +- Operation tracing In these cases, you can create custom plugins for the router. @@ -86,7 +86,7 @@ impl Plugin for HelloWorld { // This is invoked once after the router starts and compiled-in // plugins are registered - fn new(init: PluginInit) -> Result { + fn new (init: PluginInit) -> Result { Ok(HelloWorld { configuration: init.config }) } @@ -158,13 +158,14 @@ fn supergraph_service( The [tower-rs](https://github.com/tower-rs) library (which the router is built on) comes with many "off-the-shelf" layers. In addition, Apollo provides layers that cover common functionality and integration with third-party products. Some notable layers are: -* **buffered** - Make a service `Clone`. Typically required for any `async` layers. -* **checkpoint** - Perform a sync call to decide if a request should proceed or not. Useful for validation. -* **checkpoint_async** - Perform an async call to decide if the request should proceed or not. e.g. for Authentication. Requires `buffered`. -* **oneshot_checkpoint_async** - Perform an async call to decide if the request should proceed or not. e.g. for Authentication. Does not require `buffered` and should be preferred to `checkpoint_async` for that reason. -* **instrument** - Add a tracing span around a service. -* **map_request** - Transform the request before proceeding. e.g. for header manipulation. -* **map_response** - Transform the response before proceeding. e.g. for header manipulation. + +- **buffered** - Make a service `Clone`. Typically required for any `async` layers. +- **checkpoint** - Perform a sync call to decide if a request should proceed or not. Useful for validation. +- **checkpoint_async** - Perform an async call to decide if the request should proceed or not. e.g. for Authentication. Requires `buffered`. +- **oneshot_checkpoint_async** - Perform an async call to decide if the request should proceed or not. e.g. for Authentication. Does not require `buffered` and should be preferred to `checkpoint_async` for that reason. +- **instrument** - Add a tracing span around a service. +- **map_request** - Transform the request before proceeding. e.g. for header manipulation. +- **map_response** - Transform the response before proceeding. e.g. for header manipulation. Before implementing a layer yourself, always check whether an existing layer implementation might fit your needs. Reusing layers is significantly faster than implementing layers from scratch. @@ -172,8 +173,8 @@ Before implementing a layer yourself, always check whether an existing layer imp Sometimes you might need to pass custom information between services. For example: -* Authentication information obtained by the `SupergraphService` might be required by `SubgraphService`s. -* Cache control headers from `SubgraphService`s might be aggregated and returned to the client by the `SupergraphService`. +- Authentication information obtained by the `SupergraphService` might be required by `SubgraphService`s. +- Cache control headers from `SubgraphService`s might be aggregated and returned to the client by the `SupergraphService`. Whenever the router receives a request, it creates a corresponding `context` object and passes it along to each service. This object can store anything that's Serde-compatible (e.g., all simple types or a custom type). @@ -207,25 +208,15 @@ Use `upsert` if you might need to resolve multiple simultaneous writes to a sing Note: `upsert` requires v to implement `Default`. -#### `enter_active_request` - -```rust -let _guard = context.enter_active_request(); -http_client.request().await; -drop(_guard); -``` - -The Router measures how much time it spends working on a request, by subtracting the time spent waiting on network calls, like subgraphs or coprocessors. The result is reported in the `apollo_router_processing_time` metric. If the native plugin is performing network calls, then they should be taken into account in this metric. It is done by calling the `enter_active_request` method, which returns a guard value. Until that value is dropped, the router will consider that a network request is happening. - ### 6. Register your plugin To enable the router to discover your plugin, you need to **register** the plugin. To do so, use the `register_plugin!()` macro provided by `apollo-router`. This takes 3 arguments: -* A group name -* A plugin name -* A struct implementing the `Plugin` trait +- A group name +- A plugin name +- A struct implementing the `Plugin` trait For example: @@ -245,54 +236,36 @@ plugins: # Any values here are passed to the plugin as part of your configuration ``` -## Using macros -To create custom metrics, traces, and spans, you can use [`tracing` macros](https://docs.rs/tracing/latest/tracing/index.html#macros) to generate events and logs. + +## Observability ### Add custom metrics + -Make sure to [enable Prometheus metrics](/router/configuration/telemetry/exporters/metrics/prometheus) in your configuration if you want to have metrics generated by the router. +Make sure to [configure a metrics exporter](/graphos/routing/observability/telemetry/metrics-exporters/overview) if you want to have metrics generated by the router. -To create your custom metrics in [Prometheus](https://prometheus.io/) you can use [event macros](https://docs.rs/tracing/latest/tracing/index.html#macros) to generate an event. -If you observe a specific naming pattern for your event you'll be able to generate your own custom metrics directly in Prometheus. - -To publish a new metric, use tracing macros to generate an event that contains one of the following prefixes: - -`monotonic_counter.` _(non-negative numbers)_: Used when the metric will only ever increase. -`counter.`: For when the metric may increase or decrease over time. -`value.`: For discrete data points (i.e., when taking the sum of values does not make semantic sense) -`histogram.`: For building histograms (takes `f64`) +To create custom metrics, you can use the [OpenTelemetry](https://docs.rs/opentelemetry/0.24.0/opentelemetry/) crates. -#### Examples: +To publish a new metric, get the OpenTelemetry [`MeterProvider`](https://docs.rs/opentelemetry/0.24.0/opentelemetry/metrics/struct.MeterProvider.html) and register an instrument: ```rust -use tracing::info; - -let loading_time = std::time::Instant::now(); - -info!(monotonic_counter.foo = 1, my_custom_label = "bar"); // Will increment the monotonic counter foo by 1 -// Generated metric for the example above in prometheus -// foo{my_custom_label="bar"} 1 -info!(monotonic_counter.bar = 1.1); - -info!(counter.baz = 1, name = "baz"); // Will increment the counter baz by 1 -info!(counter.baz = -1); // Will decrement the counter baz by 1 -info!(counter.xyz = 1.1); - -info!(value.qux = 1); -info!(value.abc = -1); -info!(value.def = 1.1); - -let caller = "router"; -tracing::info!( - histogram.loading_time = loading_time.elapsed().as_secs_f64(), - kind = %caller, // Custom attribute for the metrics -); +use apollo_router::metrics::meter_provider; + +let meter = meter_provider().meter("apollo/router"); +// The instrument will publish metrics until it is dropped. +let _instrument = meter.u64_observable_gauge("foo") + .with_description("The amount of active foos") + .with_callback(|gauge| { + gauge.observe(count_the_foos()); + }) + .init(); ``` ### Add custom spans + Make sure to [enable OpenTelemetry tracing](/router/configuration/telemetry/exporters/tracing/overview) in your configuration if you want customize the traces generated and linked by the router. @@ -307,8 +280,11 @@ use tracing::info_span; info_span!("my_span"); ``` +### Accessing operation IDs -## Plugin Lifecycle +When writing plugins, you can access [GraphOS Studio operation IDs](/graphos/platform/insights/operation-signatures) by reading the value of `apollo::supergraph::operation_id` from [`context`](#5-define-necessary-context). + +## Plugin lifecycle Like individual requests, plugins follow their own strict lifecycle that helps provide structure to the router's execution. @@ -322,16 +298,15 @@ There is no sequencing for plugin registration, and registrations might even exe Within a given service (router, subgraph, etc.), a _request_ is handled in the following order: -* [Rhai script](/graphos/routing/customization/rhai) -* [External coprocessor](/router/customizations/coprocessor) -* Rust plugins, in the same order they're declared in your [YAML configuration file](/router/configuration/overview/#yaml-config-file). +- [Rhai script](/graphos/routing/customization/rhai) +- [External coprocessor](/router/customizations/coprocessor) +- Rust plugins, in the same order they're declared in your [YAML configuration file](/router/configuration/overview/#yaml-config-file). The corresponding _response_ is handled in the opposite order. This ordering is relevant for communicating through [the `context` object](#5-define-necessary-context). When a single supergraph request involves multiple subgraph requests, the handling of each subgraph request and response is ordered as above but different subgraph requests may be handled in parallel, making their relative ordering non-deterministic. - ### Router lifecycle notes If a router is listening for dynamic changes to its configuration, it also triggers lifecycle events when those changes occur. diff --git a/docs/source/routing/customization/overview.mdx b/docs/source/routing/customization/overview.mdx index c417b06d19..c029e2af10 100644 --- a/docs/source/routing/customization/overview.mdx +++ b/docs/source/routing/customization/overview.mdx @@ -4,6 +4,8 @@ subtitle: Extend your router with custom functionality description: Extend the GraphOS Router or Apollo Router Core with custom functionality. Understand the request lifecycle and how customizations intervene at specific points. --- +import RequestLifecycleOverviewDiagram from '../../../shared/diagrams/router-request-lifecycle-overview.mdx'; + You can create **customizations** for the GraphOS Router or Apollo Router Core to add functionality that isn't available via built-in [configuration options](/router/configuration/overview/). For example, you can make an external call to fetch authentication data for each incoming request. ## Customization types @@ -11,10 +13,10 @@ You can create **customizations** for the GraphOS Router or Apollo Router Core t The GraphOS Router supports the following customization types: - [**Rhai scripts**](/graphos/routing/customization/rhai/) - - The [Rhai scripting language](https://rhai.rs/book/) lets you add functionality directly to your stock router binary by hooking into different phases of the router's request lifecycle. + - The [Rhai scripting language](https://rhai.rs/book/) lets you add functionality directly to your stock router binary by hooking into different phases of the router's request lifecycle. - [**External co-processing**](/router/customizations/coprocessor/) ([Enterprise feature](/router/enterprise-features/)) - - If your organization has a [GraphOS Enterprise plan](https://www.apollographql.com/pricing/), you can write custom request-handling code in any language. This code can run in the same container as your router or separately. - - The router calls your custom code via HTTP, passing it the details of each incoming client request. + - If your organization has a [GraphOS Enterprise plan](https://www.apollographql.com/pricing/), you can write custom request-handling code in any language. This code can run in the same container as your router or separately. + - The router calls your custom code via HTTP, passing it the details of each incoming client request. The Apollo Router Core supports customization only through [Rhai scripts](/graphos/routing/customization/rhai/). @@ -24,39 +26,19 @@ Because [Rhai scripts](/graphos/routing/customization/rhai/) are easier to deplo - Make network requests - Use libraries from a particular language or framework -## The request lifecycle - -Customizations intervene at specific points of the request lifecycle, depending on the task you want to perform. Each point is represented by a specific service with its own request and response objects. - -```mermaid -flowchart RL - subgraph client["Client"] - end +## Customizations along the request lifecycle - subgraph router["Router"] - direction LR - routerService("Router
    Service") - supergraphService("Supergraph
    Service") - executionService("Execution
    Service") - subgraphService("Subgraph
    Service") - routerService -->|request| supergraphService -->|request| executionService -->|request| subgraphService - subgraphService -->|response| executionService -->|response| supergraphService -->|response| routerService +Customizations intervene at specific points of the [request lifecycle](/graphos/routing/request-lifecycle), depending on the task you want to perform. Each point is represented by a specific service with its own request and response objects. - end + - subgraph infra["Your infrastructure"] - direction TB - api1("subgraph A"); - api2("subgraph B"); - api3("subgraph C"); - api1 --- api2 --- api3 + - end +Understand the entire request lifecycle by following flowcharts of its [request path](/graphos/routing/request-lifecycle#request-path) and [response path](/graphos/routing/request-lifecycle#response-path), starting from a client request to your subgraphs, and all the way back from subgraph responses to a client response. -client -->|request| router -->|request| infra + -infra -->|response| router -->|response| client -``` +### Request lifecycle plugins Each service can have a set of plugins. For requests, the router executes plugins _before_ the service. @@ -76,7 +58,7 @@ For responses, the router executes the plugins _after_ the service. ```mermaid flowchart RL subgraph Service - coreService["Core
    service"] -->|response| Plugin2["Plugin 2"] -->|response| Plugin1["Plugin 1"] + coreService["Core
    service"] -->|response| Plugin2["Plugin 2"] -->|response| Plugin1["Plugin 1"] end Plugin1["Plugin 1"] -->|response| Client @@ -85,214 +67,59 @@ NextService["Next service"] -->|response| coreService Each request and response object contains a `Context` object, which is carried throughout the entire process. Each request's `Context` object is unique. You can use it to store plugin-specific information between the request and response or to communicate between different hook points. (A plugin can be called at multiple steps of the request lifecycle.) -The following flowcharts diagram the entire request lifecycle. -The first details the path of a request from a client, through the parts of the router, all the way to your subgraphs. -The second details the path of a response from your subgraphs back to the client. - -### Request path - -```mermaid -flowchart TB; - client(Client); - subgraph router["Router"] - direction LR - httpServer("HTTP server") - subgraph routerService["Router Service"] - routerPlugins[[Router plugins]]; - end - subgraph " " - subgraph supergraphService["Supergraph Service"] - supergraphPlugins[[Supergraph plugins]]; - end - queryPlanner("Query Planner"); - end - - - subgraph executionService["Execution Service"] - executionPlugins[[Execution plugins]]; - end - - subgraph subgraphService["Subgraph Services"] - subgraph service1["Subgraph Service A"] - subgraphPlugins1[[Subgraph plugins]]; - end - subgraph service2["Subgraph Service B"] - subgraphPlugins2[[Subgraph plugins]]; - end - end - end; -subgraphA[Subgraph A]; -subgraphB[Subgraph B]; - -client --"1. HTTP request"--> httpServer; -httpServer --"2. RouterRequest"--> routerService; -routerService --"3. SupergraphRequest"--> supergraphService -supergraphService --"4. Query"--> queryPlanner; -queryPlanner --"5. Query plan"--> supergraphService; -supergraphService --"6. ExecutionRequest"--> executionService; - -executionService --"7a. SubgraphRequest"--> service1; -executionService --"7b. SubgraphRequest"--> service2; - -service1 --"8a. HTTP request"--> subgraphA; -service2 --"8b. HTTP request"--> subgraphB; -``` - -1. The router receives a client request at an HTTP server. -2. The HTTP server transforms the HTTP request into a `RouterRequest` containing HTTP headers and the request body as a stream of byte arrays. -3. The router service receives the `RouterRequest`. It handles Automatic Persisted Queries (APQ), parses the GraphQL request from JSON, and calls the supergraph service with the resulting `SupergraphRequest`. -4. The supergraph service calls the query planner with the GraphQL query from the `SupergraphRequest`. -5. The query planner returns a query plan for most efficiently executing the query. -6. The supergraph service calls the execution service with an `ExecutionRequest`, made up of `SupergraphRequest` and the query plan. -7. For each fetch node of the query plan, the execution service creates a `SubgraphRequest` and then calls the respective subgraph service. -8. Each subgraph has its own subgraph service, and each service can have its own subgraph plugin configuration. The subgraph service transforms the `SubgraphRequest` into an HTTP request to its subgraph. The `SubgraphRequest` contains: - - the (read-only) `SupergraphRequest` - - HTTP headers - - the subgraph request's operation type (query, mutation, or subscription) - - a GraphQL request object as the request body - -Once your subgraphs provide a response, the response follows the path outlined below. - -### Response path - -```mermaid -flowchart BT; - client(Client); - subgraph " " - direction LR - httpServer("HTTP server") - subgraph routerService["Router Service"] - routerPlugins[[Router plugins]]; - end - subgraph " " - subgraph supergraphService["Supergraph Service"] - supergraphPlugins[[Supergraph plugins]]; - end - queryPlanner("QueryPlanner"); - end - - - subgraph executionService["Execution Service"] - executionPlugins[[Execution plugins]]; - end - - subgraph subgraphService["Subgraph Services"] - subgraph service1["Subgraph Service A"] - subgraphPlugins1[[Subgraph plugins]]; - end - subgraph service2["Subgraph Service B"] - subgraphPlugins2[[Subgraph plugins]]; - end - end - end; -subgraph1[Subgraph A]; -subgraph2[Subgraph B]; - -subgraph1 -- "9a. HTTP response"--> service1; -subgraph2 -- "9b. HTTP response"--> service2; -service1 --"10a. SubgraphResponse"--> executionService; -service2 --"10b. SubgraphResponse"--> executionService; -executionService --"11. ExecutionResponse"--> supergraphService; -supergraphService --"12. SupergraphResponse"--> routerService; -routerService --"13. RouterResponse"--> httpServer; -httpServer --"14. HTTP response" --> client -``` - -9. Each subgraph provides an HTTP response to the subgraph services. -10. Each subgraph service creates a `SubgraphResponse` containing the HTTP headers and a GraphQL response. -11. Once the execution service has received all subgraph responses, it formats the GraphQL responses—removing unneeded data and propagating nulls—before sending it back to the supergraph plugin as the `ExecutionResponse`. -12. The `SupergraphResponse` has the same content as the `ExecutionResponse`. It contains headers and a stream of GraphQL responses. That stream only contains one element for most queries—it can contain more if the query uses the `@defer` directive or subscriptions. -13. The router service receives the `SupergraphResponse` and serializes the GraphQL responses to JSON. -14. The HTTP server sends the JSON in an HTTP response to the client. - -### Request and response nuances - -For simplicity's sake, the preceding diagrams show the request and response sides separately and sequentially. In reality, some requests and responses may happen simultaneously and repeatedly. +### Request and response buffering -For example, `SubgraphRequest`s can happen both in parallel _and_ in sequence: one subgraph's response may be necessary for another's `SubgraphRequest`. (The query planner decides which requests can happen in parallel vs. which need to happen in sequence). To match subgraph requests to responses in customizations, the router exposes a `subgraph_request_id` field that will hold the same value in paired requests and responses. + -##### Requests run in parallel - -```mermaid -flowchart LR; - subgraph parallel[" "] - subgraph executionService["Execution Service"] - executionPlugins[[Execution plugins]]; - end - - subgraph subgraphService["Subgraph Services"] - subgraph service1["Subgraph Service A"] - subgraphPlugins1[[Subgraph plugins]]; - end - subgraph service2["Subgraph Service B"] - subgraphPlugins2[[Subgraph plugins]]; - end - end - - - executionService --"1A. SubgraphRequest"--> service1; - executionService --"1B. SubgraphRequest"--> service2; - service1 --"4A. SubgraphResponse"--> executionService; - service2 --"4B. SubgraphResponse"--> executionService; - end - subgraphA[Subgraph A]; - subgraphB[Subgraph B]; - - service1 --"2A. HTTP request"--> subgraphA; - service2 --"2B. HTTP request"--> subgraphB; - subgraphA --"3A. HTTP response"--> service1; - subgraphB --"3B. HTTP response"--> service2; -``` - -##### Requests run sequentially - -```mermaid -flowchart LR; - subgraph sequentially[" "] - subgraph executionService["Execution Service"] - executionPlugins[[Execution plugins]]; - end - - subgraph subgraphService["Subgraph Services"] - subgraph service1["Subgraph Service A"] - subgraphPlugins1[[Subgraph plugins]]; - end - subgraph service2["Subgraph Service B"] - subgraphPlugins2[[Subgraph plugins]]; - end - end - - - executionService --"1. SubgraphRequest"--> service1; - service1 --"4. SubgraphResponse"--> executionService; - executionService --"5. SubgraphRequest"--> service2; - service2 --"8. SubgraphResponse"--> executionService; - end - subgraphA[Subgraph A]; - subgraphB[Subgraph B]; - - service1 --"2. HTTP request"--> subgraphA; - service2 --"6. HTTP request"--> subgraphB; - subgraphA --"3. HTTP response"--> service1; - subgraphB --"7. HTTP response"--> service2; -``` - -Additionally, some requests and responses may happen multiple times for the same operation. With subscriptions, for example, a subgraph sends a new `SubgraphResponse` whenever data is updated. Each response object travels through all the services in the response path and interacts with any customizations you've created. +This guidance applies if you are: + - Modifying the router + - Creating a native Rust plugin + - Creating a custom binary -### Request and Response buffering + The router expects to execute on a stream of data. In order to work correctly and provide high performance, the following expectations must be met: * **Request Path**: No buffering before the end of the `router_service` processing step * **Response Path**: No buffering -> In general, it's best to avoid buffering where possible. If necessary, it is ok to do so on the request path once the `router_service` step is complete. - -This guidance applies if you are: - - Modifying the router - - Creating a native Rust plugin - - Creating a custom binary - -## Customization creation - -To learn how to hook in to the various lifecycle stages, including examples customizations, refer to the [Rhai scripts](/graphos/routing/customization/rhai/) and [external coprocessing](/router/customizations/coprocessor/) docs. + + +In general, it's best to avoid buffering where possible. If necessary, it is ok to do so on the request path once the `router_service` step is complete. + + + + +### Request Context + +The router makes several values available in the request context, which is shared across stages of the processing pipeline. + +- `apollo::apq::cache_hit`: present if the request used APQ, true if we got a cache hit for the query id, false otherwise +- `apollo::apq::registered`: true if the request registered a query in APQ +- `apollo::authentication::jwt_claims`: claims extracted from a JWT if present in the request +- `apollo::authorization::authenticated_required`: true if the query covers type of fields marked with `@authenticated` +- `apollo::authorization::required_policies`: if the query covers type of fields marked with `@policy`, it contains a map of `policy name -> Option`. A coprocessor or rhai script can edit this map to mark `true` on authorization policies that succeed or `false` on ones that fail +- `apollo::authorization::required_scopes`: if the query covers type of fields marked with `@requiresScopes`, it contains the list of scopes used by those directive applications +- `apollo::demand_control::actual_cost`: calculated cost of the responses returned by the subgraphs; populated by the demand control plugin +- `apollo::demand_control::estimated_cost`: estimated cost of the requests to be sent to the subgraphs; populated by the demand control plugin +- `apollo::demand_control::result`: `COST_OK` if allowed, and `COST_TOO_EXPENSIVE` if rejected due to cost limits; populated by the demand control plugin +- `apollo::demand_control::strategy`: the name of the cost calculation strategy used by the demand control plugin +- `apollo::entity_cache::cached_keys_status`: a map of cache control statuses for cached entities, keyed by subgraph request id; populated by the entity caching plugin when `expose_keys_in_context` is turned on in the router configuration +- `apollo::expose_query_plan::enabled`: true if experimental query plan exposure is enabled +- `apollo::expose_query_plan::formatted_plan`: query plan formatted as text +- `apollo::expose_query_plan::plan`: contains the query plan serialized as JSON (editing it has no effect on execution) +- `apollo::progressive_override::labels_to_override`: used in progressive override, list of labels for which we need an override +- `apollo::progressive_override::unresolved_labels`: used in progressive override, contains the list of unresolved labels +- `apollo::supergraph::first_event`: false if the current response chunk is not the first response in the stream, nonexistent otherwise +- `apollo::supergraph::operation_id`: contains the usage reporting stats report key +- `apollo::supergraph::operation_kind`: can be `query`, `mutation` or `subscription` +- `apollo::supergraph::operation_name`: name of the operation being executed (according to the query and the `operation_name` field in the request) +- `apollo::telemetry::client_name`: client name extracted from the client name header +- `apollo::telemetry::client_version`: client version extracted from the client version header +- `apollo::telemetry::contains_graphql_error`: true if the response contains at least one error +- `apollo::telemetry::studio_exclude`: true if the current request's trace details should be excluded from Studio +- `apollo::telemetry::subgraph_ftv1`: JSON-serialized trace data returned by the subgraph when FTV1 is enabled + +## Creating customizations + +To learn how to hook into the various lifecycle stages, including examples customizations, refer to the [Rhai scripts](/graphos/routing/customization/rhai/) and [external coprocessing](/router/customizations/coprocessor/) docs. diff --git a/docs/source/routing/customization/rhai.mdx b/docs/source/routing/customization/rhai/index.mdx similarity index 94% rename from docs/source/routing/customization/rhai.mdx rename to docs/source/routing/customization/rhai/index.mdx index 0716e19639..95ee3f157f 100644 --- a/docs/source/routing/customization/rhai.mdx +++ b/docs/source/routing/customization/rhai/index.mdx @@ -8,7 +8,9 @@ You can customize your GraphOS Router or Apollo Router Core's behavior with scri ## Rhai language reference -To learn about Rhai, see the [Rhai language reference](https://rhai.rs/book/language/) and some [example scripts](https://rhai.rs/book/start/examples/scripts.html). +- To start learning Rhai, check out the [Rhai language reference](https://rhai.rs/book/language/) and its [example scripts](https://rhai.rs/book/start/examples/scripts.html). + +- To learn about symbols and behaviors specific to customizations for GraphOS Router and Apollo Router Core, go to the [Rhai API reference for router](/graphos/reference/router/rhai). ## Use cases @@ -461,13 +463,9 @@ Whenever you make changes to your scripts, check your router's log output to mak ## Limitations -Currently, Rhai scripts _cannot_ do the following: - -* Use Rust crates -* Execute network requests -* Read or write to disk +Rhai customization is best suited for simple modifications such as altering headers, modifying context values, or lightweight payload transformations. -If your router customization needs to do any of these, you can instead use [external co-processing](/router/customizations/coprocessor/) (this is an [Enterprise feature](/router/enterprise-features)). +For more advanced functionality — including network requests, writing to disk, using Rust crates injecting custom span data, or accessing external data — use YAML configuration or [external co-processing](/router/customizations/coprocessor/). ### Global variables @@ -546,3 +544,10 @@ Syntax highlighting can make it easier to spot errors in a script. We recommend ### Logging For tracking down runtime errors, insert [logging](/graphos/reference/router/rhai#logging) statements to narrow down the issue. + +## Additional resources + +You can use the Apollo Solutions [router extensibility load testing repository](https://github.com/apollosolutions/router-extensibility-load-testing) to load test Rhai scripts as well as router configurations. +The Apollo Solutions [Rhai test repository](https://github.com/apollosolutions/rhai-test) is an experimental CLI tool for running unit tests against your router Rhai scripts. + + diff --git a/docs/source/reference/router/rhai.mdx b/docs/source/routing/customization/rhai/reference.mdx similarity index 91% rename from docs/source/reference/router/rhai.mdx rename to docs/source/routing/customization/rhai/reference.mdx index 23b11026ec..36aa4e40b5 100644 --- a/docs/source/reference/router/rhai.mdx +++ b/docs/source/routing/customization/rhai/reference.mdx @@ -2,6 +2,9 @@ title: Rhai Script API Reference subtitle: APIs for router customizations description: This reference documents the symbols and behaviors that are specific to Rhai customizations for the Apollo GraphOS Router and Apollo Router Core. Includes entry point hooks, logging, and more. +redirectFrom: + - /graphos/routing/customization/rhai-reference + --- This reference documents the symbols and behaviors that are specific to [Rhai customizations](/graphos/routing/customization/rhai/) for the GraphOS Router and Apollo Router Core. @@ -24,7 +27,7 @@ fn supergraph_service(service) { let request_callback = |request| { print("Supergraph service: Client request received"); }; - + let response_callback = |response| { print("Supergraph service: Client response ready to send"); }; @@ -54,11 +57,28 @@ log_trace("trace-level log message"); ## Terminating client requests -Your Rhai script can terminate the associated client request that triggered it. To do so, it must throw an exception from the supergraph service. This returns an `Internal Server Error` to the client with a `500` response code. +Your Rhai script can terminate the associated client request that triggered it. To do so, it must throw an exception from either the router service or supergraph service. This returns an `Internal Server Error` to the client with a `500` response code. + +When choosing between router service versus supergraph service termination: + +- **Router service**: Cannot access request/response body and executes before parsing and validation; ideal for checks that should be consistently applied, like checking required headers, even when invalid operations are provided by the client. +- **Supergraph service**: Use whenever you need to examine request bodies to enforce termination. For example: ```rhai -// Must throw exception from supergraph service +// Throw exception from router service +// Note: The request/response body is unavailable from the router_service. +fn router_service(service) { + // Define a closure to process our request + let f = |request| { + // Something is malformed in the request... + throw "An was found to be wrong in the request_service..."; + }; + // Map our response using our closure + service.map_request(f); +} + +// ...or throw exception from supergraph service fn supergraph_service(service) { // Define a closure to process our response let f = |response| { @@ -76,7 +96,22 @@ The key must be a number and the message must be something which can be converte For example: ```rhai -// Must throw exception from supergraph service +// Throw exception from router service +// Note: The request/response body is unavailable from the router_service. +fn router_service(service) { + // Define a closure to process our request + let f = |request| { + // Something is malformed in the request... + throw #{ + status: 400, + message: "An was found to be wrong in the request_service..." + }; + }; + // Map our response using our closure + service.map_request(f); +} + +// ...or throw exception from supergraph service fn supergraph_service(service) { // Define a closure to process our response let f = |response| { @@ -117,7 +152,7 @@ fn supergraph_service(service) { } ``` -Rhai throws at the `map_request` layer behave the same as `ControlFlow::Break`, which is explained in the [external extensibility section](/router/customizations/coprocessor/#terminating-a-client-request). +Rhai throws at the `map_request` layer behave the same as `ControlFlow::Break`, which is explained in the [coprocessor section](/router/customizations/coprocessor/#terminating-a-client-request). If the supplied status code is not a valid HTTP status code, then a `500` response code will result. @@ -301,7 +336,7 @@ Look at the examples to see how this works in practice. ## Unix timestamp -Your Rhai customization can use the function `unix_now()` to obtain the current Unix timestamp in seconds since the Unix epoch. +Your Rhai customization can use the function `unix_now()` to obtain the current Unix timestamp in seconds since the Unix epoch. ```rhai fn supergraph_service(service) { @@ -311,7 +346,7 @@ fn supergraph_service(service) { ## Unix timestamp (in milliseconds) -Your Rhai customization can use the function `unix_ms_now()` to obtain the current Unix timestamp in milliseconds since the Unix epoch. +Your Rhai customization can use the function `unix_ms_now()` to obtain the current Unix timestamp in milliseconds since the Unix epoch. ```rhai fn supergraph_service(service) { @@ -322,7 +357,7 @@ fn supergraph_service(service) { ## Unique IDs (UUID) Your Rhai customization can use the function `uuid_v4()` to obtain a UUIDv4 ID. - + ```rhai fn supergraph_service(service) { let id = uuid_v4(); @@ -381,6 +416,7 @@ request.body.query request.body.operation_name request.body.variables request.body.extensions +request.uri.scheme request.uri.host request.uri.path request.uri.port @@ -400,6 +436,7 @@ request.subgraph.body.query request.subgraph.body.operation_name request.subgraph.body.variables request.subgraph.body.extensions +request.subgraph.uri.scheme request.subgraph.uri.host request.subgraph.uri.path request.subgraph.uri.port @@ -433,7 +470,7 @@ let my_cache_key = response.headers["cache-key"]; // Define an upsert resolver callback // The `current` parameter is the current value for the specified key. -// This particular callback checks whether `current` is an ObjectMap +// This particular callback checks whether `current` is an ObjectMap // (default is the unit value of ()). If not, assign an empty ObjectMap. // Finally, update the stored ObjectMap with our subgraph name as key // and the returned cache-key as a value. @@ -530,6 +567,16 @@ Request extensions may be read or modified. They are exposed to Rhai as an [Obje print(`${request.body.extensions}`); // Log all extensions ``` +### `request.uri.scheme` + +This is the scheme component of the request's URI, as a string. + +Modifying this value for a client request has no effect, because the request has already reached the router. However, modifying `request.subgraph.uri.scheme` in a `subgraph_service` callback _does_ modify the scheme that the router uses to communicate with the corresponding subgraph. + +```rhai +print(`${request.uri.scheme}`); // Log the request scheme +``` + ### `request.uri.host` This is the host component of the request's URI, as a string. diff --git a/docs/source/routing/errors.mdx b/docs/source/routing/errors.mdx new file mode 100644 index 0000000000..6c67228008 --- /dev/null +++ b/docs/source/routing/errors.mdx @@ -0,0 +1,213 @@ +--- +title: Router Errors +subtitle: Error and status codes returned by GraphOS Router and Apollo Router Core +description: Reference of error codes and HTTP status codes returned by Apollo GraphOS Router and Apollo Router Core, including explanations and solutions. +--- + +Learn about error codes and HTTP response status codes returned by GraphOS Router and Apollo Router Core. + +## HTTP status codes + + + + +A request failed GraphQL validation or failed to be parsed. + + + + +Requests may receive this response in two cases: + +- For a client request that requires authentication, the client's JWT failed verification. +- For a non-client subscription endpoint calling a subscription callback URL, the router couldn't find a matching subscription identifier between its registered subscriptions and a subscription event. + + + + + + +Both mutations and subscriptions must use POST. + + + + + + +A request's HTTP `Accept` header didn't contain any of the router's supported mime-types: + +- `application/json` +- `application/graphql-response+json` +- `multipart/mixed;deferSpec=20220824` +- `multipart/mixed;subscriptionSpec=1.0`. + + + + + +Request traffic exceeded configured rate limits. See [client side traffic shaping](/router/configuration/traffic-shaping/#client-side-traffic-shaping). + + + + +The request was canceled because the client closed the connection, possibly due to a client side timeout. + + + + +The router encountered an unexpected issue. [Report](https://github.com/apollographql/router/issues/new?assignees=&labels=raised+by+user&projects=&template=bug_report.md&title=) this possible bug to the router team. + + + + + +The request was not able to complete within a configured amount of time. See [client side traffic shaping timeouts](/router/configuration/traffic-shaping/#timeouts). + + + + + + +You can create Rhai scripts that throw custom status codes. See [Terminating client requests](/graphos/reference/router/rhai#terminating-client-requests) to learn more. + + + +## GraphQL error codes + +GraphQL error codes can appear in client responses under `errors[].extensions.code`, which is an established convention found in [GraphQL error extensions](https://spec.graphql.org/October2021/#sec-Errors.Error-result-format). Learn how to see these error codes in Studio via [extended error metrics](/graphos/routing/graphos-reporting#enabling-extended-error-reporting). + + + + + +The operation was not executed because sending a persisted query ID and a +body in the same request is disallowed. + + + + +There was an error fetching data from a connector service. + + + + +The estimated cost of the query was greater than the configured maximum cost. + + + + +The actual cost of the query was greater than the configured maximum cost. + + + + +The query could not be parsed. + + + + +The response from a subgraph did not match the GraphQL schema. + + + + +The request timed out when fetching data from a connector service. + + + + +The operation failed during GraphQL validation. + + + + +The operation could not be executed because the operation name was invalid or +did not match an operation in the query document. + + + + +There was an error at the HTTP transport layer when fetching data from a +connector service. + + + + +The operation was not executed due to exceeding the `max_aliases` limit. + + + + +The operation was not executed due to exceeding the `max_depth` limit. + + + + +The operation was not executed due to exceeding the `max_height` limit. + + + + +The operation was not executed due to exceeding the `max_root_fields` limit. + + + + +The operation was not executed because safelisting is enabled and the freeform GraphQL document provided was not found in the persisted query safelist. + + + + +The operation could not be parsed as GraphQL. + + + + +The operation was not executed due to a mismatch with the automatic persisted query (APQ) protocol. +There was an attempt to store this operation in the APQ cache, but the provided hash did not match the operation. + + + + +The operation was not executed because it was not found in the automatic persisted query (APQ) cache. +This is an expected behavior when using the APQ protocol. + + + + +An operation attempted to use automatic persisted queries, but the feature was not enabled. + + + + +The operation (specified by ID) was not executed because the ID was not found in the persisted query manifest, and APQs are not enabled. + + + + +The router is configured to only execute operations specified by persisted query ID, but the request contained freeform GraphQL instead. + + + + +There was an error due to exceeding the max requests configuration for a +connector service. + + + + +The response returned from a subgraph failed validation for the supergraph +schema. + + + + +There was an error at the HTTP transport layer when fetching data from a subgraph service. + + + + +The operation was not fully executed because it attempted to use a field +or type was unauthorized. + + + diff --git a/docs/source/reference/router/federation-version-support.mdx b/docs/source/routing/federation-version-support.mdx similarity index 92% rename from docs/source/reference/router/federation-version-support.mdx rename to docs/source/routing/federation-version-support.mdx index e7d7a4aa98..a9960e5bc1 100644 --- a/docs/source/reference/router/federation-version-support.mdx +++ b/docs/source/routing/federation-version-support.mdx @@ -37,7 +37,15 @@ The table below shows which version of federation each router release is compile - v1.57.0 and later (see latest releases) + v2.0.0 and later (see latest releases) + + + 2.9.3 + + + + + v1.57.0 - v1.60.0 2.9.3 @@ -415,6 +423,9 @@ The table below shows which version of federation each router release is compile ## Federation 1 support -Federation 2.x composition is backward compatible with Federation 1.x subgraph schemas, so you can use the router with any valid Federation 1.x supergraph. +Only Apollo Router Core and GraphOS Router v1.59 and earlier support Federation v1.x supergraphs. The following _don't_ support Federation v1.x: + +- Router v1.60 and later +- Router v2.0 and later -If your Federation 1.x supergraph _doesn't_ work with the router, see possible causes in [Backward compatibility in Federation 2](/federation/federation-2/backward-compatibility/). +[Learn how to upgrade from Federation version 1 to 2.](/graphos/schema-design/federated-schemas/reference/moving-to-federation-2) diff --git a/docs/source/routing/get-started.mdx b/docs/source/routing/get-started.mdx new file mode 100644 index 0000000000..db08692800 --- /dev/null +++ b/docs/source/routing/get-started.mdx @@ -0,0 +1,317 @@ +--- +title: Apollo Router Quickstart +subtitle: Run the router locally +description: This quickstart tutorial walks you through installing an Apollo Router binary, running it with an example supergraph schema and a YAML configuration file, and making test queries with Apollo Sandbox. +redirectFrom: + - /graphos/routing/self-hosted/install +--- + +import ElasticNotice from "../../shared/elastic-notice.mdx"; + +Hello! Let's run Apollo Router for the first time, using the simple scenario of developing locally. + +In this guide, you will: + +- Download and run the router as a binary. +- Create a supergraph schema. +- Create a router YAML configuration file. +- Run the router in development mode. +- Make a query to the running router. + +## 1. Download the router + +Let's start by downloading and running the router locally. + +1. Download the latest version of the router binary with a single command line: + + ```bash showLineNumbers=false + curl -sSL https://router.apollo.dev/download/nix/latest | sh + ``` + + + + To download and install a specific version of router, set the version in the download URL path. + + For example, to download router v2.0.0: + + ```bash showLineNumbers=false + curl -sSL https://router.apollo.dev/download/nix/v2.0.0 | sh + ``` + + + + Optionally, go to [router releases](https://github.com/apollographql/router/releases) in GitHub to download and extract a bundle. + +1. Check that your router downloaded successfully by running the `router` binary from your project's root directory: + + ```bash showLineNumbers=false + ./router --version + ``` + + +## 2. Create a supergraph schema + +A router needs a schema for the federated graph, or _supergraph_, that it's orchestrating. This guide uses an example supergraph schema, which you download and provide to the router. + +The example supergraph schema is composed of four subgraphs: `accounts`, `inventory`, `products`, and `reviews`. It outlines the types (`Query`, `Mutation`, `Product`, `Review`, `User`) and their fields, and it specifies which subgraph is responsible for resolving each piece of data using Apollo Federation directives (`@join__*`, `@link`). + +1. From your project's root directory, run the following to download and save an example supergraph schema: + + ```bash showLineNumbers=false + curl -sSL https://supergraph.demo.starstuff.dev/ > supergraph.graphql + ``` + + + +```graphql title="supergraph.graphql" +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { + query: Query + mutation: Mutation +} + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar join__FieldSet + +enum join__Graph { + ACCOUNTS + @join__graph(name: "accounts", url: "https://accounts.demo.starstuff.dev/") + INVENTORY + @join__graph( + name: "inventory" + url: "https://inventory.demo.starstuff.dev/" + ) + PRODUCTS + @join__graph(name: "products", url: "https://products.demo.starstuff.dev/") + REVIEWS + @join__graph(name: "reviews", url: "https://reviews.demo.starstuff.dev/") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Mutation @join__type(graph: PRODUCTS) @join__type(graph: REVIEWS) { + createProduct(upc: ID!, name: String): Product @join__field(graph: PRODUCTS) + createReview(upc: ID!, id: ID!, body: String): Review + @join__field(graph: REVIEWS) +} + +type Product + @join__type(graph: ACCOUNTS, key: "upc", extension: true) + @join__type(graph: INVENTORY, key: "upc") + @join__type(graph: PRODUCTS, key: "upc") + @join__type(graph: REVIEWS, key: "upc") { + upc: String! + weight: Int + @join__field(graph: INVENTORY, external: true) + @join__field(graph: PRODUCTS) + price: Int + @join__field(graph: INVENTORY, external: true) + @join__field(graph: PRODUCTS) + inStock: Boolean @join__field(graph: INVENTORY) + shippingEstimate: Int @join__field(graph: INVENTORY, requires: "price weight") + name: String @join__field(graph: PRODUCTS) + reviews: [Review] @join__field(graph: REVIEWS) + reviewsForAuthor(authorID: ID!): [Review] @join__field(graph: REVIEWS) +} + +type Query + @join__type(graph: ACCOUNTS) + @join__type(graph: INVENTORY) + @join__type(graph: PRODUCTS) + @join__type(graph: REVIEWS) { + me: User @join__field(graph: ACCOUNTS) + recommendedProducts: [Product] @join__field(graph: ACCOUNTS) + topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCTS) +} + +type Review @join__type(graph: REVIEWS, key: "id") { + id: ID! + body: String + author: User @join__field(graph: REVIEWS, provides: "username") + product: Product +} + +type User + @join__type(graph: ACCOUNTS, key: "id") + @join__type(graph: REVIEWS, key: "id") { + id: ID! + name: String @join__field(graph: ACCOUNTS) + username: String + @join__field(graph: ACCOUNTS) + @join__field(graph: REVIEWS, external: true) + reviews: [Review] @join__field(graph: REVIEWS) +} +``` + + + +## 3. Create a router config + +The router's many features are configurable via a YAML configuration file. You set your options declaratively in YAML, then point your router to it at startup. + +Let's customize a common setting: the router's _supergraph listen address_. It's the network address and port on which the router receives client requests. By default the address is `127.0.0.1:4000`. As an exercise, let's change the port to `5555`. + +1. In your same working directory, create a file named `router.yaml`. Open it for editing. +1. Add the following configuration that sets `supergraph.listen` to `127.0.0.1:5555`: + + ```yaml title="router.yaml" + supergraph: + listen: 127.0.0.1:5555 + ``` + +## 4. Run the router + + +Let's run the router in dev mode, using both the supergraph schema and YAML configuration files you created: + +1. Run the router with these command-line options: + - `--dev` enables dev mode + - `--config` provides the path to your YAML configuration file + - `--supergraph` provides the path to your supergraph schema + + ```sh showLineNumbers=false + ./router --dev --config router.yaml --supergraph supergraph.graphql + ``` + + + + Running router with `--dev` is the same as using the following configuration: + + ```yaml title="Same configuration as --dev" + sandbox: + enabled: true + homepage: + enabled: false + supergraph: + introspection: true + include_subgraph_errors: + all: true + plugins: + # Enable with the header, Apollo-Expose-Query-Plan: true + experimental.expose_query_plan: true + ``` + + [Learn more](/graphos/routing/configuration/cli#development-mode) about router dev mode. + + + +1. Check that your router is running, with output similar to the example: + + + + ```sh showLineNumbers=false disableCopy=true + 2025-04-25T21:54:05.910202Z INFO Running with *development* mode settings which facilitate development experience (e.g., introspection enabled) + 2025-04-25T21:54:05.981114Z INFO Apollo Router v2.1.3 // (c) Apollo Graph, Inc. // Licensed as ELv2 (https://go.apollo.dev/elv2) + 2025-04-25T21:54:05.981141Z INFO Anonymous usage data is gathered to inform Apollo product development. See https://go.apollo.dev/o/privacy for details. + 2025-04-25T21:54:05.985764Z INFO state machine transitioned event="UpdateLicense(Unlicensed)" state=Startup previous_state="Startup" + 2025-04-25T21:54:05.987948Z INFO state machine transitioned event="UpdateConfiguration()" state=Startup previous_state="Startup" + 2025-04-25T21:54:05.988144Z INFO state machine transitioned event="NoMoreLicense" state=Startup previous_state="Startup" + 2025-04-25T21:54:06.010232Z INFO Health check exposed at http://127.0.0.1:8088/health + 2025-04-25T21:54:06.010717Z WARN Connector debugging is enabled, this may expose sensitive information. + 2025-04-25T21:54:06.405064Z INFO GraphQL endpoint exposed at http://127.0.0.1:5555/ 🚀 + 2025-04-25T21:54:06.405628Z INFO You're using some "experimental" features of the Apollo Router (those which have their configuration prefixed by "experimental_"). + We may make breaking changes in future releases. To help us design the stable version we need your feedback. + Here is a list of links where you can give your opinion: + + - experimental_response_trace_id: https://github.com/apollographql/router/discussions/2147 + + For more information about launch stages, please see the documentation here: https://www.apollographql.com/docs/resources/product-launch-stages/ + 2025-04-25T21:54:06.406568Z INFO state machine transitioned event="UpdateSchema()" state=Running previous_state="Startup" + 2025-04-25T21:54:06.406591Z INFO state machine transitioned event="NoMoreConfiguration" state=Running previous_state="Running" + ``` + + + +## 5. Make a query + +When the router runs in dev mode, it hosts an [Apollo Sandbox](/graphos/explorer/sandbox/) automatically. Sandbox has a browser-based IDE, Explorer, that you can use to write and send real GraphQL queries to your graph. + + +1. Go to the URL your router is running at, [`http://127.0.0.1:5555`](http://127.0.0.1:5555). Sandbox should be running there. + +1. Copy and paste the example query into the **Operation** pane of Explorer: + + ```graphql + query Query { + recommendedProducts { + inStock + name + price + reviews { + author { + name + } + } + } + } + ``` + +1. Click **Query** to run the query, then check for its response in the **Response** pane. + + Apollo Sandbox IDE showing successful query and response at the end of the router quickstart + +That's it! You've successfully sent a query to a router running a development graph and received a response. + + +## Next steps + +Now that you've run the router locally, explore more about deployment and configuration: + +- Deploy the router [in your own infrastructure](/graphos/routing/self-hosted) with containers and/or Helm. +- [Configure runtime features](/router/configuration/overview) of the router. diff --git a/docs/source/routing/graphos-features.mdx b/docs/source/routing/graphos-features.mdx new file mode 100644 index 0000000000..64816b9925 --- /dev/null +++ b/docs/source/routing/graphos-features.mdx @@ -0,0 +1,37 @@ +--- +title: Licensed GraphOS Router Features +subtitle: Features that require a licensed GraphOS plan +description: Unlock Enterprise features for the GraphOS Router by connecting it to Apollo GraphOS. +redirectFrom: + - /router/enterprise-features +--- + +This page lists the additional features of GraphOS Router that are enabled via integration with a licensed GraphOS plan. + +> Refer to the [pricing page](https://www.apollographql.com/pricing) to compare GraphOS Router features across plan types. + +## GraphOS Router licensed features + +- Real-time updates via [GraphQL subscriptions](/graphos/routing/operations/subscriptions) +- Improved performance with [query batching](/graphos/routing/performance/query-batching) +- Authentication of inbound requests via [JSON Web Token (JWT)](/graphos/routing/security/jwt) +- [Authorization of specific fields and types](/graphos/routing/security/authorization) through the [`@requiresScopes`](/graphos/routing/security/authorization#requiresscopes), [`@authenticated`](/graphos/routing/security/authorization#authenticated), and [`@policy`](/graphos/routing/security/authorization#policy) directives +- Incremental migration between subgraphs through [the progressive `@override` directive](/graphos/schema-design/federated-schemas/entities/migrate-fields#incremental-migration-with-progressive-override). +- Redis-backed [distributed caching of query plans and persisted queries](/graphos/routing/performance/caching/distributed) +- Redis-backed [entity caching of subgraph responses](/graphos/routing/performance/caching/entity) +- Custom request handling in any language via [external coprocessing](/graphos/routing/customization/coprocessor) +- Mitigation of potentially malicious requests via [request limits](/graphos/routing/security/request-limits), [demand control](/graphos/routing/security/request-limits), and [safelisting](/graphos/routing/security/persisted-queries) +- Custom instrumentation and telemetry, including [custom attributes for spans](/graphos/reference/router/telemetry/instrumentation/spans#attributes). +- An [offline license](/graphos/routing/license/#offline-license) that enables running the router with GraphOS features when disconnected from the internet. +- [Using contexts to share data](/graphos/schema-design/federated-schemas/entities/use-contexts) with the `@context` and `@fromContext` directives + +## Enabling licensed plan features + +To enable licensed plan features in a router: + +- You must run GraphOS Router v1.12 or later. [Download the latest version.](/graphos/reference/router/self-hosted-install#1-download-and-extract-the-router-binary) + - Certain features might require a later router version. See a particular feature's documentation for details. +- Your router instances must connect to GraphOS with a **graph API key** and **graph ref** associated with your organization. + - You connect your router to GraphOS by setting [these environment variables](/graphos/reference/router/configuration#environment-variables) when starting the router. + +> Learn more about the [GraphOS plan license](/graphos/routing/license). diff --git a/docs/source/routing/graphos-reporting.mdx b/docs/source/routing/graphos-reporting.mdx index 56fb8e294a..0768a45c08 100644 --- a/docs/source/routing/graphos-reporting.mdx +++ b/docs/source/routing/graphos-reporting.mdx @@ -15,27 +15,34 @@ export APOLLO_KEY= export APOLLO_GRAPH_REF=@ ``` - + -### Usage reporting via OpenTelemetry Protocol (OTLP) +### GraphOS tracing via OpenTelemetry Protocol (OTLP) - + Prior to router v1.49.0, all GraphOS reporting was performed using a [private tracing format](/graphos/metrics/sending-operation-metrics#reporting-format) called Apollo Usage Reporting protocol. As the ecosystem around OpenTelemetry (OTel) has rapidly expanded, Apollo evaluated migrating its internal tracing system to use an OTel-based protocol. -Starting in v1.49.0, the router can use OpenTelemetry Protocol (OTLP) to report operation usage metrics to GraphOS. The benefits of reporting via OTLP include: +Starting in v1.49.0, the router can use OpenTelemetry Protocol (OTLP) to report traces to GraphOS. The benefits of reporting via OTLP include: - A comprehensive way to visualize the router execution path in GraphOS Studio. - Additional spans that were previously not included in Studio traces, such as query parsing, planning, execution, and more. - Additional metadata such as subgraph fetch details, router idle / busy timing, and more. -#### Configuring usage reporting via OTLP +Usage metrics are still using the Apollo Usage Reporting protocol. -You can enable usage reporting via OTLP by an option that can also configure the ratio of traces sent via OTLP and Apollo Usage Reporting protocol: + +#### Configuring trace reporting via OTLP -- In router v1.x, this is controlled using the `experimental_otlp_tracing_sampler` option and is disabled by default. +You can enable trace reporting via OTLP by an option that can also configure the ratio of traces sent via OTLP and Apollo Usage Reporting protocol: + +- In router v1.49-1.60, this is controlled using the `experimental_otlp_tracing_sampler` option and is disabled by default. + +- In router v1.61, v2.x and later, this option is renamed to `otlp_tracing_sampler`. + +- In router v2.x and later, this option is enabled by default. The supported values of the OTLP sampler option are the following: @@ -49,7 +56,7 @@ The OTLP sampler is applied _after_ the common tracing sampler. In the following telemetry: apollo: # Send 0.7 OTLP / 0.3 Apollo - experimental_otlp_tracing_sampler: 0.7 + otlp_tracing_sampler: 0.7 exporters: tracing: @@ -73,7 +80,7 @@ Your subgraph libraries must support federated tracing (also known as FTV1 traci - To confirm support, check the `FEDERATED TRACING` entry for your library on [this page](/federation/building-supergraphs/supported-subgraphs). - Consult your library's documentation to learn how to enable federated tracing. - - If you use Apollo Server with `@apollo/subgraph`, federated tracing support is enabled automatically. + - If you use Apollo Server with `@apollo/subgraph`, federated tracing support is enabled automatically. ### Subgraph trace sampling @@ -84,7 +91,7 @@ You can customize your router's trace sampling probability by setting the follow ```yaml title="router.yaml" telemetry: apollo: - # In this example, the trace sampler is configured + # In this example, the trace sampler is configured # with a 50% probability of sampling a request. # This value can't exceed the value of tracing.common.sampler. field_level_instrumentation_sampler: 0.5 @@ -328,7 +335,6 @@ An array of names for the variables that the router _will not_ report to GraphOS - ```yaml title="router.yaml" telemetry: apollo: @@ -364,6 +370,7 @@ By default, your router _does_ report error information, and it _does_ redact th Your subgraph libraries must support federated tracing (also known as FTV1 tracing) to provide errors to GraphOS. If you use Apollo Server with `@apollo/subgraph`, federated tracing support is enabled automatically. To confirm support: + - Check the `FEDERATED TRACING` entry for your library on [the supported subgraphs page](/federation/building-supergraphs/supported-subgraphs). - If federated tracing isn't enabled automatically for your library, consult its documentation to learn how to enable it. - Note that federated tracing can also be sampled (see above) so error messages might not be available for all your operations if you have sampled to a lower level. @@ -384,9 +391,46 @@ telemetry: send: false ``` +#### Enabling extended error reporting - +